diff --git a/src/backend/orchestration/workflow.py b/src/backend/orchestration/workflow.py index d917599..8d420e2 100644 --- a/src/backend/orchestration/workflow.py +++ b/src/backend/orchestration/workflow.py @@ -116,6 +116,40 @@ def _extract_skill_id_from_path(path: str) -> str: return "" +def _synthesize_missing_tool_call( + tool_id: str, tool_name: str, displayed_tools: set +) -> Optional[Dict[str, Any]]: + """Build the ``tool_call`` event a tool never got, or ``None`` if it already has one. + + ``_tool_args_ready`` holds back the tool card while streamed args are still + incomplete, but it cannot tell "not written yet" from "there is nothing to + write": a zero-argument tool (``get_latest_ai_news`` and friends) keeps + empty args forever, so its card is suppressed for the whole run. Without a + card the frontend has nothing to attach the result to and — with tools + running in parallel — binds it onto whichever sibling is still running, + making the call vanish from the tool list and briefly corrupting the + sibling's output. Emitting the missing call right before its result keeps + every executed tool visible, in order. + + The caller must pass the already-resolved (post skill-load override) tool + name, and must have skipped tools that intentionally render no card. + """ + if not tool_id or tool_id in displayed_tools: + return None + displayed_tools.add(tool_id) + display_name = ( + "加载技能" if tool_name == "load_skill" else TOOL_DISPLAY_NAMES.get(tool_name, tool_name) + ) + return { + "type": "tool_call", + "tool_name": tool_name, + "tool_display_name": display_name, + "tool_args": {}, + "input": {}, + "tool_id": tool_id, + } + + def _ontology_review_event_context(runtime: Dict[str, Any]) -> Dict[str, Any]: committee_size = max( (int(pack.get("config", {}).get("committee_size", 3)) for pack in runtime.get("packs", [])), @@ -1670,6 +1704,21 @@ async def _finish_direct_log( tool_name = "load_skill" else: _direct_tool_count += 1 + + _missing_call = _synthesize_missing_tool_call( + tool_id, tool_name, displayed_tools + ) + if _missing_call is not None: + _ontology_trace.append( + { + "type": "tool_call", + "tool_id": tool_id, + "tool_name": tool_name, + "input": {}, + } + ) + yield _missing_call + tool_content = payload.get("content", "") try: @@ -2615,6 +2664,21 @@ async def astream_chat_workflow( ) if is_skill_result: tool_name = "load_skill" + + _missing_call = _synthesize_missing_tool_call( + tool_id, tool_name, displayed_tools + ) + if _missing_call is not None: + _ontology_trace.append( + { + "type": "tool_call", + "tool_id": tool_id, + "tool_name": tool_name, + "input": {}, + } + ) + yield _missing_call + tool_content = payload.get("content", "") # Parse tool result diff --git a/src/backend/tests/orchestration/test_missing_tool_call_synthesis.py b/src/backend/tests/orchestration/test_missing_tool_call_synthesis.py new file mode 100644 index 0000000..84f077e --- /dev/null +++ b/src/backend/tests/orchestration/test_missing_tool_call_synthesis.py @@ -0,0 +1,86 @@ +"""Zero-argument tools must still get a tool_call event before their result. + +``_tool_args_ready`` holds the tool card back while streamed args are still +incomplete. It can't tell "not written yet" from "there is nothing to write", +so a tool declared without parameters (its args stay ``{}`` forever) never +cleared the gate and its card was suppressed for the whole run. The result then +arrived orphaned, and the frontend — finding no card for that tool_id — bound it +onto whichever sibling tool was still running, so the call disappeared from the +tool list and briefly overwrote the sibling's output. + +``_synthesize_missing_tool_call`` closes that hole at the tool_result stage. +""" + +from core.chat.tool_log import attach_tool_result, build_tool_call_event, upsert_tool_call +from core.config.display_names import TOOL_DISPLAY_NAMES +from orchestration.tool_payloads import _tool_args_ready +from orchestration.workflow import _synthesize_missing_tool_call + +# The tool this was found on. Its display name is edition-dependent (the industry +# MCP is trimmed out of CE), so the tests resolve it through the same map the +# production code uses rather than pinning a literal. +ZERO_ARG_TOOL = "get_latest_ai_news" + + +def test_zero_arg_tool_is_gated_out_of_the_tool_call_stage(): + # The precondition this fix exists for: no args, not a fast-emit tool. + assert _tool_args_ready(ZERO_ARG_TOOL, {}) is False + + +def test_synthesizes_a_card_for_a_tool_that_never_emitted_one(): + displayed: set = set() + evt = _synthesize_missing_tool_call("call_1", ZERO_ARG_TOOL, displayed) + assert evt == { + "type": "tool_call", + "tool_name": ZERO_ARG_TOOL, + "tool_display_name": TOOL_DISPLAY_NAMES.get(ZERO_ARG_TOOL, ZERO_ARG_TOOL), + "tool_args": {}, + "input": {}, + "tool_id": "call_1", + } + # The id is now claimed, so a repeated result can't emit a second card. + assert displayed == {"call_1"} + assert _synthesize_missing_tool_call("call_1", ZERO_ARG_TOOL, displayed) is None + + +def test_no_synthesis_when_the_tool_call_already_streamed(): + assert _synthesize_missing_tool_call("call_1", "bash", {"call_1"}) is None + + +def test_no_synthesis_without_a_tool_id(): + # Nothing to key the card on — leave the existing name-based matching alone. + assert _synthesize_missing_tool_call("", ZERO_ARG_TOOL, set()) is None + + +def test_skill_load_keeps_its_curated_display_name(): + evt = _synthesize_missing_tool_call("call_2", "load_skill", set()) + assert evt is not None + assert evt["tool_display_name"] == "加载技能" + + +def test_unknown_tool_falls_back_to_its_raw_name(): + evt = _synthesize_missing_tool_call("call_3", "some_third_party_tool", set()) + assert evt is not None + assert evt["tool_display_name"] == "some_third_party_tool" + + +def test_persisted_log_entry_is_complete_instead_of_the_bare_fallback(): + """Without the synthesized call the log entry came from attach_tool_result's + append branch — no display name, no args, and (downstream) no content_offset, + which knocked the whole message off the offset-ordered history replay.""" + log: list = [] + upsert_tool_call(log, {"tool_name": "sibling", "tool_display_name": "同伴", "tool_args": {"a": 1}, "tool_id": "sib"}) + + # New behaviour: the synthesized call lands in the log before its result. + evt = _synthesize_missing_tool_call("call_1", ZERO_ARG_TOOL, set()) + assert evt is not None + build_tool_call_event({**evt, "type": "tool_call"}, "c1", log) + attach_tool_result(log, "call_1", ZERO_ARG_TOOL, {"items": []}) + + entry = next(tc for tc in log if tc["tool_id"] == "call_1") + assert entry["tool_display_name"] == TOOL_DISPLAY_NAMES.get(ZERO_ARG_TOOL, ZERO_ARG_TOOL) + assert entry["tool_args"] == {} + assert entry["result"] == {"items": []} + assert entry["status"] == "success" + # The sibling card must not have absorbed the orphan result. + assert "result" not in next(tc for tc in log if tc["tool_id"] == "sib") diff --git a/src/frontend/src/hooks/chatStream.ts b/src/frontend/src/hooks/chatStream.ts index 986ea78..3f91c28 100644 --- a/src/frontend/src/hooks/chatStream.ts +++ b/src/frontend/src/hooks/chatStream.ts @@ -447,8 +447,16 @@ export async function processChatStream(resp: Response, opts: ChatStreamOptions) if (directIndex >= 0) return directIndex; } const eventToolName = getEventToolRawName(obj); - const byNameIndex = findLastRunningToolIndex(eventToolName); - if (byNameIndex >= 0) return byNameIndex; + if (eventToolName) { + const byNameIndex = findLastRunningToolIndex(eventToolName); + if (byNameIndex >= 0) return byNameIndex; + } + // Last resort — bind to whatever is still running — only for events that + // carry no tool_id at all. An id that matched nothing means the result + // belongs to a card we never created (a tool_call event we never got); with + // tools running in parallel, grabbing an unrelated running card would file + // this output under the wrong tool and hide the real call entirely. + if (eventToolId) return -1; return findLastRunningToolIndex(); };