Repository navigation
Finish a Claude Agent SDK Bot's turn when it searches its tools in the same step as an app call - #1504
Open
guidovizoso wants to merge 1 commit into
Open
Finish a Claude Agent SDK Bot's turn when it searches its tools in the same step as an app call#1504guidovizoso wants to merge 1 commit into
guidovizoso wants to merge 1 commit into
Conversation
…e same step as an app call In one step the model called an app tool OpenBot runs and Claude Code's own ToolSearch. Claude Code ran the app tool first, so the harness ended its run for that result with the search still to run. OpenBot ran the app tool, then took the search for a call the browser would answer and ended the turn: no answer, no error, and the harness left waiting until its timeout. The tools a harness runs itself (ToolSearch and Read) are now named once, in shared/harness-own-tools.ts, and held to the adapter's list by a test. A call to one of them no longer ends a run for the caller: OpenBot sends its own results back, and Claude Code runs the search and answers. The learned-skills middleware follows the same rule. A run that still ends on such a call with nothing for anyone else to answer now says the Bot stopped before it finished, instead of ending silently.
guidovizoso
requested review from
MikeRyanDev,
davidmckayv,
dustywusty,
mxmzb,
samjulien and
tylerslaton
as code owners
November 11, 2026 21:34
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What a person saw
A dogfooding report on 1.0.2: a Bot on the Claude Agent SDK harness was asked to read a Slack thread and save a CSV to Google Drive. The chat showed "Looked for a tool", "Looked for a tool", "Fetch message thread from a conversation (Slack)", "Looked for a tool", and then the turn ended with no answer and no error.
Why
In one assistant message the model made two calls: the Slack fetch, which OpenBot runs (
HeadlessToolsMiddleware, reaching the harness as anmcp__ag_ui__*client tool), andToolSearch, Claude Code's own search, which the harness runs itself (TOOL_SEARCHinagent-claude-sdk/src/adapter.py)._hand_to_browserends the harness run.ToolSearchhas not run yet: Claude Code runs it after the Slack result comes back.HeadlessToolsMiddleware.run,pendingwas [Slack fetch, ToolSearch] andownedwas [Slack fetch]. It ran the fetch, emitted its result, then hitowned.length !== pending.lengthand ended the run, assuming the other call was the browser's to answer.ToolSearch, so the turn ended silently and the harness query waited until its timeout. The server log matched: 28 TOOL_CALL_START against 26 TOOL_CALL_RESULT, the two unanswered calls being the last two of that run.server/src/learning/remote.ts(learned-skill reads for remote Bots) has the same loop and the same rule, so a skill read besideToolSearchstalled the same way.The rule
shared/harness-own-tools.tsnames the tools a harness runs itself:ToolSearchandRead, the two Claude Code built-ins the adapter keeps for a full Bot (options.tools = [READ_TOOL, TOOL_SEARCH]). A test readsadapter.pyand fails if the two lists drift apart.After the middleware has answered its own calls, the run ends for the caller only if a call is left that is not one of those. When everything left is the harness's own, the middleware sends its results back and the harness carries on: Claude Code runs the search, streams its result and answers.
Why this rule and not "continue when no remaining call is one the caller offers" (
callerOffers):callerOffersis not the full list of what the caller answers. Tools added after this middleware never appear in its input.render_ui, the generative UI tool the runtime adds from the browser's catalog, is one: the browser draws and answers it, but this middleware never sees it offered. Continuing past it would send the agent a call with no result that the browser is about to answer.hiddentools), so it does not need this path.A run never ends silently on such a call
If a run ends with only harness-own calls unanswered and nothing for this middleware or the caller to run, nobody will answer them. The middleware now ends that run with
RUN_ENDED_EARLY("The Bot stopped before it finished answering. Try again."), the sentence the chat already shows for a turn that ended without an answer, instead of a silent RUN_FINISHED. With the fix above this should not happen; it is the guard if it ever does. No new user-facing text.The adapter
No change needed. On the continuation that carries the Slack result, the adapter resumes the waiting query (the only delivered call is the Slack one), Claude Code runs
ToolSearch, the adapter streams its TOOL_CALL_RESULT, and the turn finishes with the model's answer. A new adapter test pins this down.Tests
server/tests/headless-tools.test.tsToolSearch, then RUN_FINISHED; its second run answers the search and replies. Asserts the owned tool ran once, a second agent run carries its result, and the turn ends with the agent's text and RUN_FINISHED. Before the fix: fails (one agent run, not two). After: passes.computer_navigatethe caller offers +ToolSearch. Asserts one agent run, only the owned result, and RUN_FINISHED for the caller. Passes before and after.ToolSearchalone ends in RUN_ERROR withRUN_ENDED_EARLY. Before: fails (silent RUN_FINISHED). After: passes.server/tests/learning-remote.test.ts: "a skill read beside the harness's own tool search goes back to the harness". Before: fails (one remote run). After: passes. The existing "mixed learning and surface calls" test (a surface call still ends the run) still passes.shared/harness-own-tools.test.ts: the shared list equals the tools the adapter keeps for a full Bot, read fromadapter.py.agent-claude-sdk/tests/test_own_tool_beside_client_tool.py(streamed and not): a scripted Claude Code calls the app tool andToolSearchin one message, running them in order. The first run ends for the app tool's result with no results; the continuation resumes the same query, streams the search's result and ends with the answer in the turn's snapshot. Passes on main too: it covers the adapter half the fix relies on.Run locally:
bun run format:check,bun run lint,bun run typecheck,bun run typecheck:tests,bun test ./server ./toolkit ./shared(against a throwaway Postgres), andpython -m pytest agent-claude-sdk/tests(124 passed).What was verified end to end, and what was not
Not reproduced in the full product with a real model: no Claude credential (API key or plan token) was available on the machine this was built on, and the harness needs one.
Verified instead, across the real process boundary: the real
agent-claude-sdkapp (FastAPI,ag-ui-claude-sdk, the OpenBot adapter) served over HTTP, with only the Claude Code process replaced by a scripted one that makes the two calls in one message, as in the report. The realHeadlessToolsMiddlewareran against it through anHttpAgent.TOOL_CALL_STARTfetch,TOOL_CALL_STARTsearch,TOOL_CALL_RESULTfetch,RUN_FINISHED. No text, no error, the search never answered: the report's symptom.TOOL_CALL_RESULTfor the search, the answer'sTEXT_MESSAGE_*,MESSAGES_SNAPSHOTandRUN_FINISHED.