From eb5fd9189873ca947c98e377bd2fe131bd871d91 Mon Sep 17 00:00:00 2001 From: conoremclaughlin Date: Wed, 29 Jul 2026 18:11:15 +0900 Subject: [PATCH] feat: reuse one Claude session across a turn's tool loop (by Wren) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every backend round-trip in a turn's local-tool loop spawned a fresh `claude -p` with the ENTIRE transcript window re-packed into a synthetic prompt (buildPromptEnvelope) and no --resume. One turn with N tool round-trips = N+1 separate Claude sessions, each re-piping the whole window, none showing the real thread — which is why heartbeat failures (e.g. Myra) are opaque in the Claude jsonl. Seed one backend-native session id per turn (claude only): the first spawn creates it (--session-id) with the full envelope; each tool-loop continuation resumes it (--resume) sending ONLY the tool-results delta. The whole turn collapses into one coherent Claude session — the jsonl becomes the real conversation (greppable, debuggable) — and we stop re-piping the window on every round-trip. Non-claude backends (codex/gemini) keep the stateless full-envelope-per-spawn behavior; they can't seed a session id up front the way claude's --session-id allows. Threads backendSessionId/backendSessionSeedId through startBackendTurn to the claude adapter (which already supported --resume/--session-id). Proven: `claude -p --session-id X` then `--resume X` threads across separate print-mode processes into one jsonl (with and without --append-system-prompt). Co-Authored-By: Wren --- packages/cli/src/commands/chat.ts | 33 ++++++++++++++++++++----- packages/cli/src/repl/backend-runner.ts | 13 ++++++++++ 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/commands/chat.ts b/packages/cli/src/commands/chat.ts index 6244d5f0..cbbc6f8c 100644 --- a/packages/cli/src/commands/chat.ts +++ b/packages/cli/src/commands/chat.ts @@ -13,6 +13,7 @@ import { watchFile, } from 'fs'; import { isAbsolute, join } from 'path'; +import { randomUUID } from 'crypto'; import { readIdentityJson, resolveAgentId, @@ -3862,6 +3863,19 @@ export async function runChat(options: ChatOptions): Promise { } let prompt = buildPromptEnvelope(agentId, runtime, ledger, raw); + + // Within-turn backend session reuse (claude only). Seed ONE backend-native + // session id for this turn: the first spawn creates it (--session-id) with + // the full envelope, and every tool-loop continuation resumes it (--resume) + // sending only the tool-results delta. This collapses a turn's N tool + // round-trips into a single coherent Claude session — the jsonl becomes the + // real thread (debuggable) instead of N fragments — and stops re-piping the + // whole transcript window on every round-trip. Other backends keep the + // stateless full-envelope-per-spawn behavior (codex/gemini can't seed a + // session id up front the way claude's --session-id allows). + const canReuseBackendSession = runtime.backend === 'claude'; + const backendSeedId = canReuseBackendSession ? randomUUID() : undefined; + const turnStartedAt = Date.now(); const backendGate = toolPolicy.getBackendToolGate(); const passthroughPlan = buildBackendToolPassthrough( @@ -3952,6 +3966,8 @@ export async function runChat(options: ChatOptions): Promise { passthroughArgs, timeoutMs: runtime.backendTurnTimeoutMs, attachmentDirs: sessionAttachmentDirs.length > 0 ? sessionAttachmentDirs : undefined, + // Seed the turn's backend session so tool-loop continuations can resume it. + ...(backendSeedId ? { backendSessionSeedId: backendSeedId } : {}), }); currentTurnAbort = turn.abort; inkRepl?.setAbortHandler(abortCurrentTurn); @@ -4370,12 +4386,14 @@ export async function runChat(options: ChatOptions): Promise { return `Tool ${r.tool} (${r.status}): ${resultStr}`; }) .join('\n\n'); - const continuationPrompt = buildPromptEnvelope( - agentId, - runtime, - ledger, - `[Tool results from previous turn]\n${toolResultsSummary}\n\nContinue your response based on these tool results. If you need more tools, emit ink-tool blocks. Otherwise, provide your final answer.` - ); + const continuationBody = `[Tool results from previous turn]\n${toolResultsSummary}\n\nContinue your response based on these tool results. If you need more tools, emit ink-tool blocks. Otherwise, provide your final answer.`; + // When resuming the same Claude session, the model already holds the full + // transcript + tool instructions from the seeded turn — send ONLY the + // delta. Otherwise (stateless backends) re-pack the full envelope so the + // fresh spawn has the context it needs. + const continuationPrompt = canReuseBackendSession + ? continuationBody + : buildPromptEnvelope(agentId, runtime, ledger, continuationBody); // Show continuation indicator naming the tools that just ran — this is // the SB working, not a system message @@ -4405,6 +4423,9 @@ export async function runChat(options: ChatOptions): Promise { passthroughArgs, timeoutMs: runtime.backendTurnTimeoutMs, attachmentDirs: sessionAttachmentDirs.length > 0 ? sessionAttachmentDirs : undefined, + // Resume the turn's seeded backend session so this round-trip appends to + // the same Claude thread instead of re-piping the whole window. + ...(backendSeedId ? { backendSessionId: backendSeedId } : {}), }); currentTurnAbort = contTurn.abort; inkRepl?.setAbortHandler(abortCurrentTurn); diff --git a/packages/cli/src/repl/backend-runner.ts b/packages/cli/src/repl/backend-runner.ts index 6cc719e1..9633b3bf 100644 --- a/packages/cli/src/repl/backend-runner.ts +++ b/packages/cli/src/repl/backend-runner.ts @@ -16,6 +16,17 @@ export interface BackendRunRequest { * view attached files natively. */ attachmentDirs?: string[]; + /** + * Resume an existing backend-native session (claude: --resume). When set, + * only the delta prompt need be sent — the backend already holds the thread. + */ + backendSessionId?: string; + /** + * Seed a NEW backend-native session with this id on a fresh spawn + * (claude: --session-id). Pass this on the first spawn of a turn, then pass + * the same id as `backendSessionId` on subsequent spawns to resume it. + */ + backendSessionSeedId?: string; } export interface BackendRunResult { @@ -43,6 +54,8 @@ export function startBackendTurn(request: BackendRunRequest): BackendTurnHandle promptParts, passthroughArgs: request.passthroughArgs || [], attachmentDirs: request.attachmentDirs, + backendSessionId: request.backendSessionId, + backendSessionSeedId: request.backendSessionSeedId, }); const command = `${prepared.binary} ${prepared.args.join(' ')}`;