Skip to content

fix(stream): emit a tool_call for zero-argument tools so their card is never lost - #86

Merged
Luhaozhu merged 1 commit into
mainfrom
fix/zero-arg-tool-call-card
Aug 13, 2026
Merged

fix(stream): emit a tool_call for zero-argument tools so their card is never lost#86
Luhaozhu merged 1 commit into
mainfrom
fix/zero-arg-tool-call-card

Conversation

@Luhaozhu

Copy link
Copy Markdown
Contributor

Problem

A tool declared with no parameters (its arguments are always {}) never showed up in the chat's tool list, even though it ran and its output reached the answer. With tools running in parallel, a sibling tool's card also briefly rendered the missing tool's output.

Root cause

_tool_args_ready (orchestration/tool_payloads.py) exists to hold a tool card back while the model is still streaming the call's arguments — the first chunks arrive with incomplete args. It cannot distinguish "not written yet" from "there is nothing to write":

if not tool_args:
    if tool_name in _FAST_EMIT_TOOLS:
        return True
    return False

A zero-argument tool keeps empty args forever, so it never clears the gate and astream_chat_workflow emits no tool_call event for it at all.

Its tool_result then arrives orphaned, and two things go wrong downstream:

  1. Frontend (hooks/chatStream.ts) — findToolCallIndex fell back to "bind this result to the last still-running card". Under parallel tool calls that is an unrelated sibling: the sibling's card gets overwritten with the wrong output (until its own result lands and overwrites it back), and the real call never appears.
  2. Persistenceattach_tool_result appends a bare log entry with no display name, no args and no content_offset. buildHistorySegments requires every entry to carry an offset, so one such entry drops the entire message off the offset-ordered history replay.

Fix

  • Backend — at the tool_result stage, synthesize the tool_call that was never emitted when the tool_id never produced a card, in both streaming paths (astream_chat_workflow and _astream_subagent_direct). The card keeps the tool's normal display name; update_plan, which deliberately renders no card, is still skipped beforehand. Because the synthesized call goes through the regular log path, the persisted entry regains its display name, args and content_offset.
  • Frontend — drop the blind "last running card" fallback for results that carry a tool_id which matched nothing. That combination means the result belongs to a card that was never created, and claiming an unrelated one only hides the real call. Results with no tool_id at all keep the old behaviour.

Tests

New tests/orchestration/test_missing_tool_call_synthesis.py covers the gate precondition, synthesis for an unseen tool_id, idempotency once the id is claimed, the skill-load display name, the raw-name fallback, and that the persisted log entry is complete while a sibling card does not absorb the orphan result.

…s never lost

`_tool_args_ready` holds a tool card back while the model is still streaming
the call's arguments. It cannot tell "not written yet" from "there is nothing
to write", so a tool declared without parameters keeps empty args forever and
never clears the gate — no `tool_call` event is emitted for the whole run.

The result then arrives orphaned. The frontend, finding no card for that
tool_id, fell back to "bind it to the last still-running card", which with
parallel tool calls files the output under a sibling tool: the call vanishes
from the tool list entirely and the sibling briefly renders the wrong output.
The persisted log entry was equally degraded (no display name, no args, no
content_offset), which also knocked the whole message off the offset-ordered
history replay.

Backend: at the tool_result stage, synthesize the missing `tool_call` when the
tool_id never produced a card — in both streaming paths. Frontend: stop the
blind "last running card" fallback for results that carry a tool_id which
matched nothing; such a result belongs to a card that was never created, so
claiming an unrelated one only hides the real call.
@Luhaozhu
Luhaozhu merged commit 7c74e4b into main Aug 13, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant