feat: run client tools in-process and keep the harness out of the workspace - #34
Open
serrade wants to merge 2 commits into
Open
feat: run client tools in-process and keep the harness out of the workspace#34serrade wants to merge 2 commits into
serrade wants to merge 2 commits into
Conversation
When the model called a tool that cannot be expressed as one of the client's declared tools, resolveToolCall returned nil and the call was dropped. chatCompletionResponse then sent empty content with finish_reason stop, and the streaming path emitted no chunk at all while still counting the call, so the stream closed with finish_reason tool_calls and nothing in it. OpenAI-compatible clients treat both as a provider failure and surface an error instead of the turn. Three layers now make an empty turn impossible: - unresolvableToolCallNames and needsToolCallRepair expose what used to fail silently. - repairedRequest drives one retry that names the client's actual tools. The hint goes into both prompt and incrementalPrompt because the bridge prefers the latter for warm agents. - nonEmptyAssistantText supplies the model's narration when present and an explanation otherwise. Streaming counts only chunks it actually emitted, so finish_reason can no longer claim tool calls that were never sent. Mappable tool calls keep streaming exactly as before.
…kspace Clients that declare tools were served by a spawned stdio MCP subprocess that called back over HTTP with a 1500 ms abort. The SDK documents MCP server tool calls as able to fail closed under approval or sandbox rules, so the channel was unreliable exactly when it mattered. Client tools now run as in-process SDK custom tools, built from the schemas the caller already sends. Callback tools never fail closed, the subprocess and its HTTP hop are gone, and the tool call is captured in the same process that started the run. The harness also had the caller's real workspace as its working directory, so its built-in read/write/shell succeeded against the project directly and strong models never called back through the client. They now get an empty scratch directory whenever the caller owns tool execution, which the caller states explicitly through clientOwnsToolExecution so turns that attach no inventory stay isolated too. Scratch directories are removed on agent eviction and on startup. Two related fixes: - Assistant narration survives tool-call capture, so a call that cannot be mapped still leaves the client with something to show. - The "emit exactly one tool call and no prose" directive is no longer repeated once a tool result for the current request has arrived. A read is not a mutation, so the directive used to persist after the result and models re-ran the same read every turn. A tool result belonging to an earlier request still leaves the directive in place. Prompt text now names the custom-tools server, and tool-call normalisation accepts that provider identifier alongside the previous name. Scoped to chat completions, matching the documented position on tool calls in the Responses API.
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.
Fixes the remaining half of #30. Builds on # (empty assistant turns); please merge that one first.
Problem
Two things stopped strong models from routing work through the client, both described in #30.
The client tool channel was fragile. Client tools were exposed through a stdio MCP server that the bridge spawned as a subprocess of itself, which called back over HTTP with a 1500 ms abort:
The SDK documents MCP server tool calls as able to fail closed under approval or sandbox rules, unlike in-process callback tools. So the one channel the client depends on was the one most likely to be refused.
The harness could do the work itself.
local.cwdwas the client's real workspace:so the SDK's built-in
read/write/shellsucceeded against the project directly. Nothing forced a model to call back through the client, andclaude-opus-5andclaude-sonnet-5simply did the work internally and returned narration, which the client cannot act on.Fix
Client tools run in-process.
clientCustomToolsbuilds SDK custom tools from the schemas the caller already sends, reusing the existingclientMcpInputSchemaandvalidateClientMcpToolCall. Eachexecutehands the call to the in-flight capture callback. Callback tools never fail closed, and the subprocess plus its HTTP hop are gone: about 244 lines of forwarding server, generated source and callback endpoint deleted.The harness gets an empty scratch directory whenever the caller owns tool execution, so its built-ins cannot see or change the real project and the client tools become the only route to it. Callers that execute nothing themselves keep the real directory. Because
bridgeToolObjectstrims the attached inventory per turn, an empty tool list does not mean the caller stopped owning execution, soCursorSDKHarnessstates it explicitly withclientOwnsToolExecution. Scratch directories are removed on agent eviction and on bridge startup.Two related fixes fall out of this:
testChatFileRequestAfterPriorToolResultStillRequiresLocalToolcovers.Prompt text now names the custom-tools server, and tool-call normalisation accepts that provider identifier alongside the previous name so older transcripts still resolve.
Verification
npm test(229) andnpm run typecheckpass,swift buildclean, Package macOS smoke green on my fork.clientCustomTools, plus new coverage for workspace isolation and the ownership flag.claude-opus-5now returns a correctly namedread_filetool call both streaming and not, answers from the tool result on the next turn instead of re-reading, andcomposer-2.5behaves exactly as before.Notes for review
runLocalAgentBody, the evict helpers,startServer). The changes are semantically independent but will conflict textually. Happy to rebase onto it whenever it lands, or to go first, whichever you prefer.