Skip to content

useAgentChat: onToolCall fires for server-side tools, and answering one sends the server a false failure verdict #2195

Closed

Description

@joao-cabral

Describe the bug

useAgentChat's onToolCall dispatch fires for every tool part in input-available on the leaf assistant message, with no filter for whether the client is actually responsible for that tool. Server-side tools sit in input-available for their entire execution, so the client callback is invoked for tools it cannot and should not resolve.

packages/agents/src/chat/react.tsx:1793:

const pendingToolCalls = lastMsg.parts.filter(
  (part) =>
    isToolUIPart(part) &&
    part.state === "input-available" &&
    !processedToolCalls.current.has(part.toolCallId)
);

This is more than a spurious callback, because answering is destructive. addToolOutput routes through sendToolOutputToServer, which at react.tsx:1727 does:

const shouldAutoContinue =
  state === "output-error" ? false : autoContinueAfterToolResult;

and then sends CF_AGENT_TOOL_RESULT upstream. So an app that answers an unknown tool with an error — the natural defensive pattern, since an unanswered client tool hangs the turn — emits a failure verdict on work the server is executing correctly, and simultaneously suppresses auto-continuation for that turn.

The observable result is a tool part carrying both a successful server result and a spurious output-error, plus a client status of error while the server keeps streaming normally. The UI reports a failed turn over a perfectly healthy stream.

The asymmetry

The framework already models "which tools can the client resolve" — but only on the server. Think.hasPendingInteraction() narrows pending interactions through clientResolvableToolNames() (packages/agents/src/chat/tool-state.ts). The client-side hook holds the same information in the same file — toolsRef.current, used for extractClientToolSchemas at react.tsx:1001 — but does not apply it to the dispatch.

To Reproduce

  1. Mount useAgentChat({ agent, tools: { someClientTool }, onToolCall }), where onToolCall answers with addToolOutput({ state: "output-error", ... }) for a tool it does not recognise.
  2. Have the server-side agent define and call a tool of its own (any server-executed tool, not present in tools).
  3. While the server executes it, the part is input-available and onToolCall fires for it on the client.
  4. The app answers, and the server receives CF_AGENT_TOOL_RESULT with state: "output-error" and autoContinue: false for a call it is completing successfully.

Expected behavior

Either:

  • onToolCall only fires for tools the client is responsible for, or
  • the hook exposes the client-resolvable tool set, so an app can filter reliably.

The second matters because there is currently no supported signal for "this tool belongs to the server". tools is marked deprecated in the source (react.tsx:998, "deprecated client tools"), and onToolCall is the recommended path, so an app on the recommended path has nothing to discriminate against.

Version

agents@0.20.1. The dispatch is unchanged in the 0.21.0 and 0.22.0 release notes.

Additional context

Current workaround is an app-level guard at the top of onToolCall that returns early for any tool the app did not declare. That works, but every app has to reconstruct by hand a set the server already maintains internally — and the failure mode for not doing it is silent and misleading rather than loud.

If the intended contract is in fact "the app must filter", it would help to state that in the onToolCall docs, since the natural reading of "resolve the tool or report an error" is what produces the bug.

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions