Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions src/backend/orchestration/workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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", [])),
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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")
12 changes: 10 additions & 2 deletions src/frontend/src/hooks/chatStream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
};

Expand Down
Loading