Skip to content

feat: run client tools in-process and keep the harness out of the workspace - #34

Open
serrade wants to merge 2 commits into
standardagents:mainfrom
serrade:feat/in-process-client-tools
Open

feat: run client tools in-process and keep the harness out of the workspace#34
serrade wants to merge 2 commits into
standardagents:mainfrom
serrade:feat/in-process-client-tools

Conversation

@serrade

@serrade serrade commented Jul 30, 2026

Copy link
Copy Markdown

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:

options.mcpServers = clientForwardingMcpServers(input.clientTools, agentCacheKey(input));

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.cwd was the client's real workspace:

local: { cwd: input.workingDirectory }

so the SDK's built-in read/write/shell succeeded against the project directly. Nothing forced a model to call back through the client, and claude-opus-5 and claude-sonnet-5 simply did the work internally and returned narration, which the client cannot act on.

Fix

Client tools run in-process. clientCustomTools builds SDK custom tools from the schemas the caller already sends, reusing the existing clientMcpInputSchema and validateClientMcpToolCall. Each execute hands 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 bridgeToolObjects trims the attached inventory per turn, an empty tool list does not mean the caller stopped owning execution, so CursorSDKHarness states it explicitly with clientOwnsToolExecution. Scratch directories are removed on agent eviction and on bridge startup.

Two related fixes fall out of this:

  • Assistant narration now survives tool-call capture, so a call that cannot be mapped still leaves the client 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 came back and models re-ran the same read every turn. A tool result belonging to an earlier request still leaves the directive in place, which testChatFileRequestAfterPriorToolResultStillRequiresLocalTool covers.

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) and npm run typecheck pass, swift build clean, Package macOS smoke green on my fork.
  • The bridge tests that exercised the MCP subprocess were rewritten against clientCustomTools, plus new coverage for workspace isolation and the ownership flag.
  • Live, through the running app: claude-opus-5 now returns a correctly named read_file tool call both streaming and not, answers from the tool result on the next turn instead of re-reading, and composer-2.5 behaves exactly as before.

Notes for review

  • Scope: chat completions only, matching the README's position on tool calls in the Responses API.
  • Relationship to fix: stabilize SDK bridge lifecycle, port reuse, and shutdown guards #27: that PR reworks bridge lifecycle and disposal in the same functions (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.
  • Not covered here: callers that declare no tools at all still receive the SDK's own tool names passed through verbatim, which any OpenAI-compatible client will reject. That is a third path, described in #30. I originally said I was preparing a change for it and have since withdrawn that — see the correction on Chat completions can return an unusable assistant turn: empty content, or tool calls the client never declared #30 — so it is simply out of scope here.
  • Workspace isolation is the one behavioural change existing users could notice, since the harness can no longer touch the project directly. It enforces what the prompt already claims ("the outer client owns local tool execution"), but if you would rather have it opt-in I can put it behind an environment variable in a follow-up commit.

serrade added 2 commits July 29, 2026 19:55
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.
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