Checkpoint of in-progress backend work present in the tree, not authored this session: auto_name, inference tool-phase/turn, secret_guard, provider-registry, plus a new agent-allowlist test (7 tests, passing). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
397 lines
16 KiB
TypeScript
397 lines
16 KiB
TypeScript
import type { Agent, 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 { matchToolGlob } from '../agents.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 <invoke>-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 { stripToolMarkup } from './tool-call-parser.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<unknown>, 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<string, string[] | undefined>;
|
|
};
|
|
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,
|
|
agent?: Agent | null,
|
|
): Promise<ToolPhaseResult> {
|
|
const { sessionId, chatId, assistantMessageId } = args;
|
|
const content = stripToolMarkup(result.content, { final: true });
|
|
const { toolCalls, promptTokens, completionTokens } = result;
|
|
|
|
// v1.11.3: ctx_max comes from llama-swap /upstream/<model>/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;
|
|
}
|
|
if (agent && !matchToolGlob(tc.name, agent.tools)) {
|
|
const stored = {
|
|
tool_call_id: tc.id,
|
|
output: null,
|
|
truncated: false,
|
|
error: `tool '${tc.name}' is not allowed for agent '${agent.name}'`,
|
|
};
|
|
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,
|
|
error: stored.error,
|
|
});
|
|
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,
|
|
};
|
|
}
|