Skip to content

codemode/Think execute tool: the full calls[] replay log is returned to the model, bypassing transformResult #2182

Closed

Description

@Konan69

What happens

@cloudflare/codemode 0.5.1 runPass() (packages/codemode/src/proxy-tool.ts, ~L655–700) returns

{ status: "completed", executionId, result: await applyTransform(transformResult, result), logs, calls }

where calls = execution.log — the durable replay log (ToolLogEntry[]), i.e. every connector call with its complete args and result. Only result goes through transformResult; calls is echoed raw ("so UIs can render an audit trail without a separate getExecution round trip").

@cloudflare/think's createExecuteTool (packages/think/src/tools/execute.ts) returns that object as the AI SDK tool output, so the whole replay log becomes part of the model's transcript and is re-sent on every subsequent step. The transformResult docs promise "the audit trail keeps the full value while the model sees the transformed one" — that holds for result but not for calls.

Impact (measured)

Production chat turn, gpt-5.6 via AI Gateway: one execute() whose script called a single MCP method (list_tables) returned a 342,049-char tool output — a few KB of result, the rest calls[0].result. Later steps in the same turn re-sent it; the turn reached ~140k input tokens / 680 KB per request. With transformResult: truncateResult in place (Think's default), the cap looked like it was working while calls blew past it.

What other harnesses do

opencode 2's codemode returns only the script's return value + logs (nested call log is UI metadata without results); codex's code mode truncates the script output by token policy and records nested calls for telemetry only; DeepSeek's harness states "nested Code dispatch values never enter model context". The model channel is the script result; the call log is a UI/audit channel.

Proposed fix

  1. Think (createExecuteTool): give the returned tool a toModelOutput that projects the output without calls ({ status, executionId, result, logs }, plus pending for paused / error for error). The full output stays in the persisted UI message part (audit trail, narration, citations), and convertToModelMessages(messages, { tools }) — which Think already uses on replay — applies the projection on every later turn. Think already does exactly this for its fetch and workspace tools.
  2. codemode: document that calls is a UI/audit payload, not model context (and/or run calls[].result through transformResult for hosts that don't have a model/UI split).

A follow-up worth considering, per the opencode/DeepSeek pattern: spill an oversized result to the workspace (state.* is already a connector) and return a preview + path instead of truncating.

Related durable-log limit that interacts with this: toStored refuses any single args/result value > 1,000,000 bytes and fails the execution, so a script cannot spill > 1 MB in one state.writeFile call — chunked appendFile is required. Worth a note in the execute tool description.

I have the Think change ready on a fork and can open a PR if you want it.

Activity

  1. Konan69 commented on Aug 29, 2026

    @Konan69
    ContributorAuthor

    Measured in a real workerd test (Think 0.16.0 + codemode 0.5.1, createExecuteTool with a state workspace connector), to size the two channels:

    Script Script result Raw tool output (with calls) Outcome
    host tool returns 600k chars → state.writeFile → return { path, sliceLength, totalLength } 119 chars 1,200,839 chars completed
    host tool returns 1.2M chars in one call — 503 chars fails before the script can spill: The result of tools.spill_payload is too large to record durably (1200014 bytes > 1000000 byte limit). Write large data to a file or workspace instead…
    host tool returns 4 × 300k, script state.appendFile ×4 122 chars 2,402,074 chars completed

    Two things this shows beyond the original report:

    1. The "write large data to a file" advice in tooLargeMessage is only actionable when the connector result is already under 1 MB — the durable log records the host result before the script sees it, so a single oversized connector response fails the run regardless of what the script would have done with it. Chunking has to happen on the connector side.
    2. Even when the script does the right thing (spills, returns a ~120-char reference), the tool output that reaches the model is 1.2–2.4 MB because calls[] carries both the connector results and the writeFile/appendFile args (the spilled content again). The spill defeats itself unless calls is kept out of the model channel.

    calls is also a lot to embed in a persisted UI message part; since runtime.getExecution(id) already holds the log, UIs that want the audit trail could fetch it by executionId instead of the tool output carrying it.

  2. Konan69 commented on Aug 29, 2026

    @Konan69
    ContributorAuthor

    PR creation is blocked for external accounts on this repo, so here is the fix as a fork commit instead (same pattern as #1363):

    Branch: https://github.com/Konan69/agents/tree/think-execute-to-model-output
    Commit: Konan69@9367d0f

    packages/think/src/tools/execute.ts: the execute tool gets a toModelOutput that returns the ProxyToolOutput without calls (status / executionId / result / logs; pending and error untouched). The AI SDK applies it in the step loop and in convertToModelMessages(messages, { tools }) on replay, so the model sees the projection on this and later turns while the raw output with the full trace stays on the persisted UI part for audit/narration/citations — the same way Think already uses toModelOutput for its fetch and workspace tools. The paused-output truncation wrapper is preserved. Includes a test (packages/think/src/tests/execute-tool.test.ts + agent fixture) and a patch changeset for @cloudflare/think.

    Happy to rebase or adjust if a maintainer wants to pull it in.

  3. Konan69 commented on Aug 29, 2026

    @Konan69
    ContributorAuthor

    Second commit on the same fork branch, for the follow-up mentioned above: Konan69@460938b

    transformResult in createExecuteRuntime now spills an oversized result to the workspace state backend (/tool-output/execute-<sha256>.json, chunked under the 1 MB durable-value limit) and returns { spilled, path, bytes, preview, hint } instead of truncating; falls back to truncateResult when no workspace is configured or the write fails. The state.* connector hint names the 1 MB per-connector-result limit so scripts chunk large fetches. Test + changeset included.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions