cloudflare / cloudflare/agents
useAgentChat: onToolCall fires for server-side tools, and answering one sends the server a false failure verdict
- Dominant language
- TypeScript
- Stars
- 5.6k
- Forks
- 711
- Avg merge
- 1d 20h
- Merged PRs (30d)
- 53
Description
**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`:
```ts
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:
```ts
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.
Contributor guide
Assessment
This issue has not been assessed yet.