-
-
Notifications
You must be signed in to change notification settings - Fork 1.9k
feat(agent-runner): add tool_choice parameter to fix empty tool calls response in "skills-like" tool call mode #7101
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -100,6 +100,32 @@ def _get_persona_custom_error_message(self) -> str | None: | |||||||||||||||||||||||||||||||||||||
| event = getattr(self.run_context.context, "event", None) | ||||||||||||||||||||||||||||||||||||||
| return extract_persona_custom_error_message_from_event(event) | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| async def _complete_with_assistant_response(self, llm_resp: LLMResponse) -> None: | ||||||||||||||||||||||||||||||||||||||
| """Finalize the current step as a plain assistant response with no tool calls.""" | ||||||||||||||||||||||||||||||||||||||
| self.final_llm_resp = llm_resp | ||||||||||||||||||||||||||||||||||||||
| self._transition_state(AgentState.DONE) | ||||||||||||||||||||||||||||||||||||||
| self.stats.end_time = time.time() | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| parts = [] | ||||||||||||||||||||||||||||||||||||||
| if llm_resp.reasoning_content or llm_resp.reasoning_signature: | ||||||||||||||||||||||||||||||||||||||
| parts.append( | ||||||||||||||||||||||||||||||||||||||
| ThinkPart( | ||||||||||||||||||||||||||||||||||||||
| think=llm_resp.reasoning_content, | ||||||||||||||||||||||||||||||||||||||
| encrypted=llm_resp.reasoning_signature, | ||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||
| if llm_resp.completion_text: | ||||||||||||||||||||||||||||||||||||||
| parts.append(TextPart(text=llm_resp.completion_text)) | ||||||||||||||||||||||||||||||||||||||
| if len(parts) == 0: | ||||||||||||||||||||||||||||||||||||||
| logger.warning("LLM returned empty assistant message with no tool calls.") | ||||||||||||||||||||||||||||||||||||||
| self.run_context.messages.append(Message(role="assistant", content=parts)) | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||||||||
| await self.agent_hooks.on_agent_done(self.run_context, llm_resp) | ||||||||||||||||||||||||||||||||||||||
| except Exception as e: | ||||||||||||||||||||||||||||||||||||||
| logger.error(f"Error in on_agent_done hook: {e}", exc_info=True) | ||||||||||||||||||||||||||||||||||||||
| self._resolve_unconsumed_follow_ups() | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| @override | ||||||||||||||||||||||||||||||||||||||
| async def reset( | ||||||||||||||||||||||||||||||||||||||
| self, | ||||||||||||||||||||||||||||||||||||||
|
|
@@ -463,34 +489,7 @@ async def step(self): | |||||||||||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| if not llm_resp.tools_call_name: | ||||||||||||||||||||||||||||||||||||||
| # 如果没有工具调用,转换到完成状态 | ||||||||||||||||||||||||||||||||||||||
| self.final_llm_resp = llm_resp | ||||||||||||||||||||||||||||||||||||||
| self._transition_state(AgentState.DONE) | ||||||||||||||||||||||||||||||||||||||
| self.stats.end_time = time.time() | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| # record the final assistant message | ||||||||||||||||||||||||||||||||||||||
| parts = [] | ||||||||||||||||||||||||||||||||||||||
| if llm_resp.reasoning_content or llm_resp.reasoning_signature: | ||||||||||||||||||||||||||||||||||||||
| parts.append( | ||||||||||||||||||||||||||||||||||||||
| ThinkPart( | ||||||||||||||||||||||||||||||||||||||
| think=llm_resp.reasoning_content, | ||||||||||||||||||||||||||||||||||||||
| encrypted=llm_resp.reasoning_signature, | ||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||
| if llm_resp.completion_text: | ||||||||||||||||||||||||||||||||||||||
| parts.append(TextPart(text=llm_resp.completion_text)) | ||||||||||||||||||||||||||||||||||||||
| if len(parts) == 0: | ||||||||||||||||||||||||||||||||||||||
| logger.warning( | ||||||||||||||||||||||||||||||||||||||
| "LLM returned empty assistant message with no tool calls." | ||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||
| self.run_context.messages.append(Message(role="assistant", content=parts)) | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| # call the on_agent_done hook | ||||||||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||||||||
| await self.agent_hooks.on_agent_done(self.run_context, llm_resp) | ||||||||||||||||||||||||||||||||||||||
| except Exception as e: | ||||||||||||||||||||||||||||||||||||||
| logger.error(f"Error in on_agent_done hook: {e}", exc_info=True) | ||||||||||||||||||||||||||||||||||||||
| self._resolve_unconsumed_follow_ups() | ||||||||||||||||||||||||||||||||||||||
| await self._complete_with_assistant_response(llm_resp) | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| # 返回 LLM 结果 | ||||||||||||||||||||||||||||||||||||||
| if llm_resp.result_chain: | ||||||||||||||||||||||||||||||||||||||
|
|
@@ -510,6 +509,24 @@ async def step(self): | |||||||||||||||||||||||||||||||||||||
| if llm_resp.tools_call_name: | ||||||||||||||||||||||||||||||||||||||
| if self.tool_schema_mode == "skills_like": | ||||||||||||||||||||||||||||||||||||||
| llm_resp, _ = await self._resolve_tool_exec(llm_resp) | ||||||||||||||||||||||||||||||||||||||
| if not llm_resp.tools_call_name: | ||||||||||||||||||||||||||||||||||||||
| logger.warning( | ||||||||||||||||||||||||||||||||||||||
| "skills_like tool re-query returned no tool calls; fallback to assistant response." | ||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||
| if llm_resp.result_chain: | ||||||||||||||||||||||||||||||||||||||
| yield AgentResponse( | ||||||||||||||||||||||||||||||||||||||
| type="llm_result", | ||||||||||||||||||||||||||||||||||||||
| data=AgentResponseData(chain=llm_resp.result_chain), | ||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||
| elif llm_resp.completion_text: | ||||||||||||||||||||||||||||||||||||||
| yield AgentResponse( | ||||||||||||||||||||||||||||||||||||||
| type="llm_result", | ||||||||||||||||||||||||||||||||||||||
| data=AgentResponseData( | ||||||||||||||||||||||||||||||||||||||
| chain=MessageChain().message(llm_resp.completion_text), | ||||||||||||||||||||||||||||||||||||||
| ), | ||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||
| await self._complete_with_assistant_response(llm_resp) | ||||||||||||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| tool_call_result_blocks = [] | ||||||||||||||||||||||||||||||||||||||
| cached_images = [] # Collect cached images for LLM visibility | ||||||||||||||||||||||||||||||||||||||
|
|
@@ -873,7 +890,9 @@ def _append_tool_call_result(tool_call_id: str, content: str) -> None: | |||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| def _build_tool_requery_context( | ||||||||||||||||||||||||||||||||||||||
| self, tool_names: list[str] | ||||||||||||||||||||||||||||||||||||||
| self, | ||||||||||||||||||||||||||||||||||||||
| tool_names: list[str], | ||||||||||||||||||||||||||||||||||||||
| extra_instruction: str | None = None, | ||||||||||||||||||||||||||||||||||||||
| ) -> list[dict[str, T.Any]]: | ||||||||||||||||||||||||||||||||||||||
| """Build contexts for re-querying LLM with param-only tool schemas.""" | ||||||||||||||||||||||||||||||||||||||
| contexts: list[dict[str, T.Any]] = [] | ||||||||||||||||||||||||||||||||||||||
|
|
@@ -888,13 +907,20 @@ def _build_tool_requery_context( | |||||||||||||||||||||||||||||||||||||
| + ". Now call the tool(s) with required arguments using the tool schema, " | ||||||||||||||||||||||||||||||||||||||
| "and follow the existing tool-use rules." | ||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||
| if extra_instruction: | ||||||||||||||||||||||||||||||||||||||
| instruction = f"{instruction}\n{extra_instruction}" | ||||||||||||||||||||||||||||||||||||||
| if contexts and contexts[0].get("role") == "system": | ||||||||||||||||||||||||||||||||||||||
| content = contexts[0].get("content") or "" | ||||||||||||||||||||||||||||||||||||||
| contexts[0]["content"] = f"{content}\n{instruction}" | ||||||||||||||||||||||||||||||||||||||
| else: | ||||||||||||||||||||||||||||||||||||||
| contexts.insert(0, {"role": "system", "content": instruction}) | ||||||||||||||||||||||||||||||||||||||
| return contexts | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| @staticmethod | ||||||||||||||||||||||||||||||||||||||
| def _has_meaningful_assistant_reply(llm_resp: LLMResponse) -> bool: | ||||||||||||||||||||||||||||||||||||||
| text = (llm_resp.completion_text or "").strip() | ||||||||||||||||||||||||||||||||||||||
| return bool(text) | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| def _build_tool_subset(self, tool_set: ToolSet, tool_names: list[str]) -> ToolSet: | ||||||||||||||||||||||||||||||||||||||
| """Build a subset of tools from the given tool set based on tool names.""" | ||||||||||||||||||||||||||||||||||||||
| subset = ToolSet() | ||||||||||||||||||||||||||||||||||||||
|
|
@@ -932,11 +958,45 @@ async def _resolve_tool_exec( | |||||||||||||||||||||||||||||||||||||
| model=self.req.model, | ||||||||||||||||||||||||||||||||||||||
| session_id=self.req.session_id, | ||||||||||||||||||||||||||||||||||||||
| extra_user_content_parts=self.req.extra_user_content_parts, | ||||||||||||||||||||||||||||||||||||||
| tool_choice="required", | ||||||||||||||||||||||||||||||||||||||
| abort_signal=self._abort_signal, | ||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||
| if requery_resp: | ||||||||||||||||||||||||||||||||||||||
| llm_resp = requery_resp | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| # If the re-query still returns no tool calls, and also does not have a meaningful assistant reply, | ||||||||||||||||||||||||||||||||||||||
| # we consider it as a failure of the LLM to follow the tool-use instruction, | ||||||||||||||||||||||||||||||||||||||
| # and we will retry once with a stronger instruction that explicitly requires the LLM to either call the tool or give an explanation. | ||||||||||||||||||||||||||||||||||||||
| if ( | ||||||||||||||||||||||||||||||||||||||
| not llm_resp.tools_call_name | ||||||||||||||||||||||||||||||||||||||
| and not self._has_meaningful_assistant_reply(llm_resp) | ||||||||||||||||||||||||||||||||||||||
| ): | ||||||||||||||||||||||||||||||||||||||
| logger.warning( | ||||||||||||||||||||||||||||||||||||||
| "skills_like tool re-query returned no tool calls and no explanation; retrying with stronger instruction." | ||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||
| repair_contexts = self._build_tool_requery_context( | ||||||||||||||||||||||||||||||||||||||
| tool_names, | ||||||||||||||||||||||||||||||||||||||
| extra_instruction=( | ||||||||||||||||||||||||||||||||||||||
| "This is the second-stage tool execution step. " | ||||||||||||||||||||||||||||||||||||||
| "You must do exactly one of the following: " | ||||||||||||||||||||||||||||||||||||||
| "1. Call one of the selected tools using the provided tool schema. " | ||||||||||||||||||||||||||||||||||||||
| "2. If calling a tool is no longer possible or appropriate, reply to the user with a brief explanation of why. " | ||||||||||||||||||||||||||||||||||||||
| "Do not return an empty response. " | ||||||||||||||||||||||||||||||||||||||
| "Do not ignore the selected tools without explanation." | ||||||||||||||||||||||||||||||||||||||
| ), | ||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||
| repair_resp = await self.provider.text_chat( | ||||||||||||||||||||||||||||||||||||||
| contexts=repair_contexts, | ||||||||||||||||||||||||||||||||||||||
| func_tool=param_subset, | ||||||||||||||||||||||||||||||||||||||
| model=self.req.model, | ||||||||||||||||||||||||||||||||||||||
| session_id=self.req.session_id, | ||||||||||||||||||||||||||||||||||||||
| extra_user_content_parts=self.req.extra_user_content_parts, | ||||||||||||||||||||||||||||||||||||||
| tool_choice="required", | ||||||||||||||||||||||||||||||||||||||
| abort_signal=self._abort_signal, | ||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+988
to
+996
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. suggestion (bug_risk): Second-stage repair still enforces In the second-stage repair call you instruct the model that it may either call a tool or return an explanation, but you still pass repair_resp = await self.provider.text_chat(
contexts=repair_contexts,
func_tool=param_subset,
model=self.req.model,
session_id=self.req.session_id,
extra_user_content_parts=self.req.extra_user_content_parts,
tool_choice="required",
abort_signal=self._abort_signal,
)For Gemini/Anthropic this maps to
Suggested change
|
||||||||||||||||||||||||||||||||||||||
| if repair_resp: | ||||||||||||||||||||||||||||||||||||||
| llm_resp = repair_resp | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| return llm_resp, subset | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| def done(self) -> bool: | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
suggestion:
_has_meaningful_assistant_replyonly looks atcompletion_text, which can misclassify valid replies.The current implementation only inspects
completion_text:If a provider returns the assistant reply in another field (e.g.
result_chainor message parts) and leavescompletion_textempty, this will incorrectly treat a valid reply as missing and trigger an unnecessary second repair attempt. IfLLMResponseguarantees that any assistant NL reply is always mirrored tocompletion_text, this is fine; otherwise, this helper should also consider other fields that can contain assistant text before deciding there was “no explanation.”Suggested implementation:
This implementation assumes
LLMResponsemay expose assistant content viaresult_chain,message, ormessagesfields, and that these follow typical "message with content" conventions. If your actualLLMResponsetype uses different field names or shapes (e.g.,output_messages,assistant_message, etc.), you should:_has_meaningful_assistant_replyto explicitly inspect those real fields.LLMResponse(e.g., aget_assistant_text()method) and have_has_meaningful_assistant_replycall that instead, to keep this runner decoupled from provider‑specific details.