import type { Session, ToolCall } from '../../types/api.js'; import * as modelContext from '../model-context.js'; import { PathScopeError } from '../path_guard.js'; import { TOOLS_BY_NAME } from '../tools.js'; import { maybeFlagForCompaction } from './payload.js'; import { insertParts, partsFromAssistantMessage, partsFromToolMessage } from './parts.js'; // v1.13.16: richer unknown-tool error so the model can self-correct when it // drifts to a Claude Code tool name (e.g. read_file → suggest view_file). // Applies to all unknown tool names, not just -derived ones — at the // dispatch layer we no longer know which format produced the call, and the // extra signal is harmless for Qwen-derived calls. import { formatUnknownToolError } from './tool-suggestions.js'; // v1.13.17-cross-repo-reads: pre-prompt validation for request_read_access. // Resolves the grant root before pausing the loop so the user is never // prompted about paths we couldn't grant anyway (e.g. /etc/passwd). import { resolveGrantRoot } from '../grant_resolver.js'; import type { InferenceContext, StreamResult, TurnArgs, } from './turn.js'; // v1.13.13: synthesis pipeline — replaces the immediate recursive turn when // any of this batch's tool calls is in SYNTHESIS_TOOLS. Falls through to // recursion on synthesis failure (timeout / model error). See module header // in synthesisPipeline.ts for the auto-fetch + token-budget rules. import { SYNTHESIS_TOOLS, runSynthesisPass } from '../synthesisPipeline.js'; async function executeToolCall( projectRoot: string, toolCall: ToolCall, extraRoots: readonly string[], ): Promise<{ output: unknown; truncated: boolean; error?: string }> { const tool = TOOLS_BY_NAME[toolCall.name]; if (!tool) { return { output: null, truncated: false, error: formatUnknownToolError(toolCall.name, Object.keys(TOOLS_BY_NAME)), }; } const parsed = tool.inputSchema.safeParse(toolCall.args); if (!parsed.success) { // v1.12 Track B.2: enrich the zod-reject path so the model sees a // one-line, tool-named hint ("tool 'search_symbols' rejected — query: // Required") instead of a JSON blob of flatten output. Higher recovery // rate on the next turn; doom-loop guard still bounds infinite retries. // The cast is because tool.inputSchema is ZodType, so zod can't // statically narrow flatten()'s fieldErrors key set — but the runtime // shape is the standard { formErrors: string[]; fieldErrors: Record<...> }. const flatten = parsed.error.flatten() as { formErrors: string[]; fieldErrors: Record; }; const fieldErrors = Object.entries(flatten.fieldErrors) .map(([field, errs]) => `${field}: ${errs?.[0] ?? 'invalid'}`) .join('; '); const formError = flatten.formErrors[0]; const hint = fieldErrors || formError || 'unknown validation error'; return { output: null, truncated: false, error: `tool '${toolCall.name}' rejected — ${hint}`, }; } try { const output = await tool.execute(parsed.data, projectRoot, extraRoots); const truncated = typeof output === 'object' && output !== null && 'truncated' in output ? Boolean((output as { truncated: unknown }).truncated) : false; return { output, truncated }; } catch (err) { if (err instanceof PathScopeError) { return { output: null, truncated: false, error: err.message }; } return { output: null, truncated: false, error: err instanceof Error ? err.message : String(err), }; } } // v1.14.0: return struct from executeToolPhase so the caller (the outer // while loop in turn.ts) can decide whether to continue, break, or handle // synthesis. Replaces the recursive call into runAssistantTurn. export interface ToolPhaseResult { action: 'continue' | 'paused' | 'synthesis_done'; toolCallCount: number; toolCalls: ToolCall[]; nextAssistantId: string | null; } export async function executeToolPhase( ctx: InferenceContext, args: TurnArgs, result: StreamResult, startedAt: string | null, session: Session, projectRoot: string ): Promise { const { sessionId, chatId, assistantMessageId } = args; const { content, toolCalls, promptTokens, completionTokens } = result; // v1.11.3: ctx_max comes from llama-swap /upstream//props, not the // streaming completion (which doesn't emit n_ctx). getModelContext caches // the positive lookup for the process lifetime, so this is a single Map // hit after the first invocation per model. const mctx = await modelContext.getModelContext(session.model); const nCtx = mctx?.n_ctx ?? null; const [updated] = await ctx.sql< { tokens_used: number | null; ctx_used: number | null; ctx_max: number | null; finished_at: string | null }[] >` UPDATE messages SET content = ${content}, status = 'complete', tokens_used = ${completionTokens}, ctx_used = ${promptTokens}, ctx_max = ${nCtx}, finished_at = clock_timestamp() WHERE id = ${assistantMessageId} RETURNING tokens_used, ctx_used, ctx_max, finished_at `; // v1.13.20: message_parts is the sole source of truth for tool_calls. // Legacy messages.tool_calls column was dropped; reads route through the // messages_with_parts view. // v1.13.1-C: include result.reasoning so models with separate reasoning // channels (qwen3.6) get a kind='reasoning' part at sequence 0. await insertParts( ctx.sql, partsFromAssistantMessage({ content, tool_calls: toolCalls, reasoning: result.reasoning, }).map((p) => ({ ...p, message_id: assistantMessageId, })), ); // v1.11: flag for compaction if this turn pushed us over the usable budget. // We never compact mid-loop (the recursive runAssistantTurn keeps tools // flowing); the flag fires on the NEXT turn's pre-fetch hook above. await maybeFlagForCompaction(ctx, chatId, updated); const [toolSessRow] = await ctx.sql<{ project_id: string; name: string; updated_at: string }[]>` UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId} RETURNING project_id, name, updated_at `; ctx.publishUser({ type: 'session_updated', session_id: sessionId, project_id: toolSessRow!.project_id, name: toolSessRow!.name, updated_at: toolSessRow!.updated_at }); for (const tc of toolCalls) { ctx.publish(sessionId, { type: 'tool_call', message_id: assistantMessageId, chat_id: chatId, tool_call: tc, }); } ctx.publish(sessionId, { type: 'message_complete', message_id: assistantMessageId, chat_id: chatId, tokens_used: updated?.tokens_used ?? null, ctx_used: updated?.ctx_used ?? null, ctx_max: updated?.ctx_max ?? null, started_at: startedAt, finished_at: updated?.finished_at ?? null, model: session.model, }); // Batch 9.7: ask_user_input pauses the loop. The tool row is still inserted // (the answer endpoint needs a target row to UPDATE), but tool_results is // pre-stamped with output=null as a "pending" sentinel and no tool_result // frame goes out — the card renders from the tool_call frame alone. Mixed // batches still execute the other tools normally. ctx.publishUser({ type: 'chat_status', chat_id: chatId, status: 'tool_running', at: new Date().toISOString() }); let pausingForUserInput = false; // v1.13.13: capture synth-tool result text so the synthesis pipeline below // doesn't have to re-fetch from DB. Array (not single) because a batch // could theoretically include multiple synthesis tools — we take the first // for the synthesis input. Race-free under Promise.all because each // callback pushes its own captured value. const synthEntries: Array<{ tc: ToolCall; output: unknown; error?: string }> = []; await Promise.all( toolCalls.map(async (tc) => { const [toolRow] = await ctx.sql<{ id: string }[]>` INSERT INTO messages (session_id, chat_id, role, content, status, created_at) VALUES (${sessionId}, ${chatId}, 'tool', '', 'complete', clock_timestamp()) RETURNING id `; const toolMessageId = toolRow!.id; if (tc.name === 'ask_user_input') { pausingForUserInput = true; const sentinel = { tool_call_id: tc.id, output: null, truncated: false }; // v1.13.20: parts-only. The answer-endpoint UPDATE later // (messages.ts) will delete and re-insert this part when the user // submits their answer. await insertParts( ctx.sql, partsFromToolMessage({ tool_results: sentinel }).map((p) => ({ ...p, message_id: toolMessageId, })), ); return; } // v1.13.17-cross-repo-reads: request_read_access pauses identically to // ask_user_input EXCEPT for an up-front validation pass — if the path // can't be granted under the whitelist / repo-shape rules, surface an // immediate denial without prompting the user. Per design D1, we never // ask the user about /etc/passwd or paths outside PROJECT_ROOT_WHITELIST. if (tc.name === 'request_read_access') { const tcArgs = tc.args as { path?: unknown; reason?: unknown }; const requested = typeof tcArgs.path === 'string' ? tcArgs.path : ''; const resolution = await resolveGrantRoot( ctx.sql, requested, projectRoot, ctx.config.PROJECT_ROOT_WHITELIST, ); if (!resolution.ok) { // Auto-deny without pausing. The model sees the reason on its // next turn and decides what to do. const stored = { tool_call_id: tc.id, output: `denied: ${resolution.reason}`, truncated: false, }; // v1.13.20: parts-only write. await insertParts( ctx.sql, partsFromToolMessage({ tool_results: stored }).map((p) => ({ ...p, message_id: toolMessageId, })), ); ctx.publish(sessionId, { type: 'tool_result', tool_message_id: toolMessageId, chat_id: chatId, tool_call_id: tc.id, output: stored.output, truncated: false, }); return; } // Path is plausibly grantable — install the pending sentinel and // pause. The grant endpoint re-derives the root at decision time // (state may have changed in the meantime) so we don't stash it here. pausingForUserInput = true; const sentinel = { tool_call_id: tc.id, output: null, truncated: false }; // v1.13.20: parts-only write. await insertParts( ctx.sql, partsFromToolMessage({ tool_results: sentinel }).map((p) => ({ ...p, message_id: toolMessageId, })), ); return; } const tres = await executeToolCall(projectRoot, tc, session.allowed_read_paths); if (SYNTHESIS_TOOLS.has(tc.name)) { synthEntries.push({ tc, output: tres.output, ...(tres.error ? { error: tres.error } : {}) }); } const stored = { tool_call_id: tc.id, output: tres.output, truncated: tres.truncated, ...(tres.error ? { error: tres.error } : {}), }; // v1.13.20: parts-only write. Reads route through messages_with_parts. await insertParts( ctx.sql, partsFromToolMessage({ tool_results: stored }).map((p) => ({ ...p, message_id: toolMessageId, })), ); ctx.publish(sessionId, { type: 'tool_result', tool_message_id: toolMessageId, chat_id: chatId, tool_call_id: tc.id, output: tres.output, truncated: tres.truncated, ...(tres.error ? { error: tres.error } : {}), }); }) ); if (pausingForUserInput) { ctx.publishUser({ type: 'chat_status', chat_id: chatId, status: 'waiting_for_input', at: new Date().toISOString(), }); ctx.log.info( { sessionId, chatId, assistantMessageId }, 'inference paused awaiting user input', ); return { action: 'paused' as const, toolCallCount: toolCalls.length, toolCalls, nextAssistantId: null, }; } // v1.13.13: synthesis-pipeline branch. When any of this batch's tool calls // is a codecontext overview/analysis tool that produced a non-error result, // run a forced second-inference synthesis pass with auto-fetched files + // project docs instead of the normal recursive runAssistantTurn. Falls // through to the recursive call on synthesis failure (timeout, model // error). User-abort re-throws so the outer handler runs. const synthEntry = synthEntries.find((e) => !e.error && e.output != null); if (synthEntry) { // codecontext wrappers return { result: string, truncated: boolean, ... }. // Defensive: stringify the output if it isn't the expected shape so the // synthesis still has something to chew on rather than crashing on // missing `.result`. const out = synthEntry.output as { result?: unknown; truncated?: boolean; outputPath?: string }; const toolResultText = typeof out?.result === 'string' ? out.result : JSON.stringify(synthEntry.output); // v1.13.15-b: forward the wrapper's truncation flag + opaque tmpfs id so // synthesisPipeline can re-read the full content for reference extraction. const ran = await runSynthesisPass({ ctx, args, session, projectRoot, toolName: synthEntry.tc.name, toolResultText, ...(typeof out?.truncated === 'boolean' ? { truncated: out.truncated } : {}), ...(typeof out?.outputPath === 'string' ? { outputPath: out.outputPath } : {}), }); if (ran) { return { action: 'synthesis_done' as const, toolCallCount: toolCalls.length, toolCalls, nextAssistantId: null, }; } // ran === false → synthesis failed (timeout / model error) → fall through // to the standard continue path below. The synth message (if created) // was already marked status='failed' inside runSynthesisPass. } // v1.14.0: create the next assistant row and return a continue result. // The caller (outer while loop in turn.ts) handles the iteration. const [nextAssistant] = await ctx.sql<{ id: string }[]>` INSERT INTO messages (session_id, chat_id, role, content, status, created_at) VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp()) RETURNING id `; return { action: 'continue' as const, toolCallCount: toolCalls.length, toolCalls, nextAssistantId: nextAssistant!.id, }; }