feat(agents): v0.38 Slice 1 — gateway.toolLoop() provider-agnostic loop control

Adds `gateway.toolLoop(opts)` as the provider-neutral loop wrapper over the
already-provider-neutral `gateway.chat()`. The Vercel AI SDK abstraction does
all the per-provider tool-def normalization, tool-call parsing, and tool-result
framing; this helper just sequences the assistant→tool-dispatch→tool-result
cycle with:

  - D11 stable-ID callbacks (onToolCallStart returns the gbrain-owned UUID v7
    that the caller persists at first observation; reread on replay)
  - Write-ordering invariant (persist assistant → persist pending tool row →
    execute side effect → settle complete/failed)
  - Crash-replay reconciliation via `replayState.priorTools` keyed by
    gbrainToolUseId (NOT provider IDs)
  - Capability-driven cache_control (Anthropic only, via cacheSystem flag)
  - Stop-reason mapping for refusal / content_filter / max_turns / aborted

The loop is stateless beyond the optional replay state — testable via the
existing `__setChatTransportForTests` seam without any DB.

This is the substrate Slice 1's `subagent.ts` rewire (S1.5) consumes.

Plan: ~/.claude/plans/system-instruction-you-are-working-shimmying-breeze.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-05-21 16:13:33 -07:00
co-authored by Claude Opus 4.7
parent 14d3f987e9
commit cf91450dd8
+304
View File
@@ -2140,6 +2140,310 @@ export async function chat(opts: ChatOpts): Promise<ChatResult> {
}
}
// ---- Tool loop (v0.38 — D11 + D6/D7 gateway-native subagent path) ----
/**
* A tool handler runs a single tool invocation. `idempotent` lets the loop
* safely re-execute a pending row on crash-replay; non-idempotent tools that
* crashed mid-execute are surfaced as a hard error.
*/
export interface ToolHandler {
idempotent?: boolean;
execute(input: unknown, signal: AbortSignal): Promise<unknown>;
}
/**
* State the caller carries in from a prior crashed run. The reconciler keys
* by gbrain-owned `gbrainToolUseId` (D11), NOT provider-supplied IDs.
* `priorMessages` is the chat history up to the assistant's last turn;
* `priorTools` maps gbrainToolUseId → outcome. The D5 read-time shim
* synthesizes gbrainToolUseIds for legacy v1 rows so this Map sees both
* shapes uniformly.
*/
export interface ToolLoopReplayState {
priorMessages: ChatMessage[];
priorTools: Map<string, { status: 'pending' | 'complete' | 'failed'; output?: unknown; error?: string }>;
nextTurnIdx: number;
nextMessageIdx: number;
}
export interface ToolLoopOpts {
/** "provider:modelId" — defaults to config.chat_model. */
model?: string;
/** System prompt (provider-neutral). Cached when caching supported + cacheSystem true. */
system?: string;
/**
* Initial user message(s). When `replayState` is set, these are prepended only
* if `replayState.priorMessages` is empty — typically empty on a fresh call,
* non-empty on a fresh-from-scratch run.
*/
initialMessages: ChatMessage[];
/** Tool definitions (provider-neutral JSON Schema). */
tools: ChatToolDef[];
/** Implementations keyed by tool name. */
toolHandlers: Map<string, ToolHandler>;
/** Hard cap on loop iterations. Default 20. */
maxTurns?: number;
/** Per-turn max output tokens. Default 4096. */
maxTokens?: number;
abortSignal?: AbortSignal;
/** Apply Anthropic cache_control to system + last tool. Silently ignored elsewhere. */
cacheSystem?: boolean;
/** Crash-replay state. When set, the loop resumes from the recorded position. */
replayState?: ToolLoopReplayState;
/**
* D11 + write-ordering invariant callbacks. Fire BEFORE side effects so a
* crash mid-execute is reconcilable on the next replay.
*
* Ordering per turn:
* 1. onAssistantTurn — assistant message persisted (D11 step 1)
* 2. onToolCallStart — pending row persisted (D11 step 2)
* 3. handler.execute — side effect
* 4. onToolCallComplete / onToolCallFailed (D11 step 4)
*/
onAssistantTurn?: (turnIdx: number, messageIdx: number, blocks: ChatBlock[], usage: ChatResult['usage'], model: string) => Promise<void>;
/**
* Persist a pending tool execution. The caller assigns ordinal + uuid v7 and
* returns them so the loop can key replay by gbrainToolUseId. The provider
* supplies its own `providerToolCallId` (kept as a debug-only side channel).
*/
onToolCallStart?: (
turnIdx: number,
messageIdx: number,
ordinal: number,
toolName: string,
input: unknown,
providerToolCallId: string,
) => Promise<{ gbrainToolUseId: string }>;
onToolCallComplete?: (gbrainToolUseId: string, output: unknown) => Promise<void>;
onToolCallFailed?: (gbrainToolUseId: string, error: string) => Promise<void>;
/** Optional per-call heartbeat for observability. */
onHeartbeat?: (event: string, data: Record<string, unknown>) => void;
}
export type ToolLoopStopReason = 'end' | 'max_turns' | 'refusal' | 'content_filter' | 'aborted' | 'unrecoverable';
export interface ToolLoopResult {
finalText: string;
totalTurns: number;
totalUsage: ChatResult['usage'];
stopReason: ToolLoopStopReason;
/** Final messages array including all assistant + tool results. Caller persists if desired. */
messages: ChatMessage[];
}
/**
* Provider-agnostic tool-calling loop. Wraps `gateway.chat()` with:
* - assistant→tool-dispatch→tool-result cycle
* - gbrain-stable IDs (D11) at first observation
* - write-ordering invariant (persist before side effect)
* - crash-replay reconciliation via gbrainToolUseId
* - capability-driven cache_control (Anthropic only)
*
* This replaces the direct `new Anthropic()` + `client.create()` path in
* `src/core/minions/handlers/subagent.ts`. The provider abstraction lives in
* `gateway.chat()` (Vercel AI SDK); this function is just the loop control.
*
* Designed so the caller (subagent handler) supplies persistence callbacks —
* the loop itself is stateless beyond `replayState`. That keeps it testable
* via `__setChatTransportForTests` without any DB.
*/
export async function toolLoop(opts: ToolLoopOpts): Promise<ToolLoopResult> {
const maxTurns = opts.maxTurns ?? 20;
const maxTokens = opts.maxTokens ?? 4096;
const handlers = opts.toolHandlers;
const totalUsage: ChatResult['usage'] = {
input_tokens: 0,
output_tokens: 0,
cache_read_tokens: 0,
cache_creation_tokens: 0,
};
// Seed messages: prior history (replay) or initial.
const messages: ChatMessage[] = opts.replayState
? [...opts.replayState.priorMessages]
: [...opts.initialMessages];
if (opts.replayState && opts.replayState.priorMessages.length === 0) {
messages.push(...opts.initialMessages);
}
let turnIdx = opts.replayState?.nextTurnIdx ?? 0;
let messageIdx = opts.replayState?.nextMessageIdx ?? 0;
let finalText = '';
let stopReason: ToolLoopStopReason = 'end';
while (turnIdx < maxTurns) {
if (opts.abortSignal?.aborted) {
stopReason = 'aborted';
break;
}
opts.onHeartbeat?.('turn_start', { turn_idx: turnIdx });
let chatResult: ChatResult;
try {
chatResult = await chat({
model: opts.model,
system: opts.system,
messages,
tools: opts.tools,
maxTokens,
abortSignal: opts.abortSignal,
cacheSystem: opts.cacheSystem,
});
} catch (err) {
opts.onHeartbeat?.('llm_call_failed', {
turn_idx: turnIdx,
error: err instanceof Error ? err.message : String(err),
});
throw err;
}
totalUsage.input_tokens += chatResult.usage.input_tokens;
totalUsage.output_tokens += chatResult.usage.output_tokens;
totalUsage.cache_read_tokens += chatResult.usage.cache_read_tokens;
totalUsage.cache_creation_tokens += chatResult.usage.cache_creation_tokens;
// D11 step 1: persist assistant turn BEFORE any tool dispatch.
const assistantMessageIdx = messageIdx++;
await opts.onAssistantTurn?.(turnIdx, assistantMessageIdx, chatResult.blocks, chatResult.usage, chatResult.model);
messages.push({ role: 'assistant', content: chatResult.blocks });
// Check stop reason BEFORE tool dispatch. The loop only continues on tool_calls.
if (chatResult.stopReason === 'refusal') {
stopReason = 'refusal';
finalText = chatResult.text;
break;
}
if (chatResult.stopReason === 'content_filter') {
stopReason = 'content_filter';
finalText = chatResult.text;
break;
}
const toolCalls = chatResult.blocks.filter(
(b): b is { type: 'tool-call'; toolCallId: string; toolName: string; input: unknown } =>
b.type === 'tool-call',
);
if (toolCalls.length === 0) {
stopReason = 'end';
finalText = chatResult.text;
break;
}
// D11 + write-ordering invariant: persist pending → execute → settle.
const toolResultBlocks: ChatBlock[] = [];
for (let callIdx = 0; callIdx < toolCalls.length; callIdx++) {
const call = toolCalls[callIdx];
if (opts.abortSignal?.aborted) {
stopReason = 'aborted';
break;
}
const handler = handlers.get(call.toolName);
if (!handler) {
// Tool not registered. Synthesize an error result; don't persist.
toolResultBlocks.push({
type: 'tool-result',
toolCallId: call.toolCallId,
toolName: call.toolName,
output: `tool "${call.toolName}" is not in the registry for this subagent`,
isError: true,
});
opts.onHeartbeat?.('tool_failed', { turn_idx: turnIdx, tool_name: call.toolName, error: 'not_registered' });
continue;
}
// Step 2: persist pending row + claim gbrainToolUseId. The caller's
// callback handles uniqueness contention via ON CONFLICT DO NOTHING +
// re-read pattern (see persistToolExecPending in subagent.ts).
const { gbrainToolUseId } = (await opts.onToolCallStart?.(
turnIdx,
assistantMessageIdx,
callIdx,
call.toolName,
call.input,
call.toolCallId,
)) ?? { gbrainToolUseId: `inline-${turnIdx}-${callIdx}` };
// Replay short-circuit: prior outcome wins, idempotent re-execute allowed.
const prior = opts.replayState?.priorTools.get(gbrainToolUseId);
if (prior?.status === 'complete') {
toolResultBlocks.push({
type: 'tool-result',
toolCallId: call.toolCallId,
toolName: call.toolName,
output: prior.output,
});
opts.onHeartbeat?.('tool_replay_complete', { turn_idx: turnIdx, tool_name: call.toolName });
continue;
}
if (prior?.status === 'failed') {
toolResultBlocks.push({
type: 'tool-result',
toolCallId: call.toolCallId,
toolName: call.toolName,
output: prior.error ?? 'tool failed',
isError: true,
});
opts.onHeartbeat?.('tool_replay_failed', { turn_idx: turnIdx, tool_name: call.toolName });
continue;
}
if (prior?.status === 'pending' && !handler.idempotent) {
// Non-idempotent crash-mid-execute. Surface as unrecoverable.
stopReason = 'unrecoverable';
throw new Error(
`non-idempotent tool "${call.toolName}" pending on resume; gbrainToolUseId=${gbrainToolUseId} — cannot safely re-run`,
);
}
// Step 3: execute (side effect).
opts.onHeartbeat?.('tool_called', { turn_idx: turnIdx, tool_name: call.toolName });
try {
const output = await handler.execute(call.input, opts.abortSignal ?? new AbortController().signal);
// Step 4: settle complete.
await opts.onToolCallComplete?.(gbrainToolUseId, output);
toolResultBlocks.push({
type: 'tool-result',
toolCallId: call.toolCallId,
toolName: call.toolName,
output,
});
opts.onHeartbeat?.('tool_result', { turn_idx: turnIdx, tool_name: call.toolName });
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
await opts.onToolCallFailed?.(gbrainToolUseId, errMsg);
toolResultBlocks.push({
type: 'tool-result',
toolCallId: call.toolCallId,
toolName: call.toolName,
output: errMsg,
isError: true,
});
opts.onHeartbeat?.('tool_failed', { turn_idx: turnIdx, tool_name: call.toolName, error: errMsg });
}
}
if (stopReason === 'aborted') break;
// Feed all tool results back as a single user message.
const userMessageIdx = messageIdx++;
void userMessageIdx;
messages.push({ role: 'user', content: toolResultBlocks });
turnIdx++;
}
if (turnIdx >= maxTurns && stopReason === 'end') {
stopReason = 'max_turns';
}
return { finalText, totalTurns: turnIdx, totalUsage, stopReason, messages };
}
// ---- Reranker (v0.35.0.0+) ----
/** Tagged error class for gateway.rerank() failures. `reason` classifies into the