refactor(core): Split agent runtime lifecycle functions

This commit is contained in:
Robin Braumann 2026-09-24 15:05:06 +02:00
parent 8a4fd13423
commit f2563da22b
No known key found for this signature in database
7 changed files with 1399 additions and 1152 deletions

File diff suppressed because it is too large Load Diff

View File

@ -39,6 +39,13 @@ function wrapBuiltInRules(fragments: string[]): string | undefined {
return `<built_in_rules>\n${fragments.map((f) => `- ${f}`).join('\n')}\n</built_in_rules>`;
}
function prependBuiltInRules(fragments: string[], instructions: string): string {
const rules = wrapBuiltInRules(fragments);
if (!rules) return instructions;
if (!instructions) return rules;
return `${rules}\n\n${instructions}`;
}
export interface StaticLoopContext {
model: LanguageModel;
aiProviderTools: ReturnType<typeof toAiSdkProviderTools>;
@ -68,7 +75,7 @@ export class RuntimeContextBuilder {
buildStaticLoopContext(
execOptions?: ExecutionOptions & { persistence?: AgentPersistenceOptions },
): StaticLoopContext {
const { Output, jsonSchema } = loadAi();
const ai = loadAi();
const aiProviderTools = toAiSdkProviderTools(this.config.providerTools);
const model = createModel(this.config.model, this.config.modelFetch);
const outputSchema = this.config.structuredOutput;
@ -78,16 +85,7 @@ export class RuntimeContextBuilder {
isRawJsonSchemaOutput,
);
const outputSpec = outputSchema
? Output.object({
// Zod schemas pass through directly; a raw JSON Schema gets
// `additionalProperties: false` locked onto every object (required by Anthropic)
// and wrapped with the AI SDK's `jsonSchema()` helper.
schema: isZodSchema(outputSchema)
? outputSchema
: jsonSchema(lockAdditionalProperties(outputSchema)),
})
: undefined;
const outputSpec = this.buildOutputSpec(outputSchema, ai);
return {
model,
@ -99,6 +97,18 @@ export class RuntimeContextBuilder {
};
}
private buildOutputSpec(
outputSchema: AgentRuntimeConfig['structuredOutput'],
{ Output, jsonSchema }: ReturnType<typeof loadAi>,
): StaticLoopContext['outputSpec'] {
if (!outputSchema) return undefined;
// Anthropic requires additionalProperties: false on each raw schema object.
const schema = isZodSchema(outputSchema)
? outputSchema
: jsonSchema(lockAdditionalProperties(outputSchema));
return Output.object({ schema });
}
/** Build the current local tool view; deferred loads can change this between iterations. */
buildToolLoopContext(
aiProviderTools: ReturnType<typeof toAiSdkProviderTools>,
@ -260,6 +270,14 @@ export class RuntimeContextBuilder {
instructions: string;
volatileInstructions: string | undefined;
} {
const { stableFragments, volatileFragments } = this.collectInstructionFragments(tools);
return {
instructions: prependBuiltInRules(stableFragments, this.config.instructions),
volatileInstructions: wrapBuiltInRules(volatileFragments),
};
}
private collectInstructionFragments(tools: BuiltTool[]) {
const loadedToolNames = new Set(
this.deferredToolManager?.getLoadedTools().map((t) => t.name) ?? [],
);
@ -272,9 +290,8 @@ export class RuntimeContextBuilder {
) {
continue;
}
(loadedToolNames.has(tool.name) ? volatileFragments : stableFragments).push(
tool.systemInstruction,
);
const fragments = loadedToolNames.has(tool.name) ? volatileFragments : stableFragments;
fragments.push(tool.systemInstruction);
}
// Define the untrusted-data boundary ahead of the first wrapped result.
@ -288,18 +305,7 @@ export class RuntimeContextBuilder {
target.unshift(UNTRUSTED_OUTPUT_DOCTRINE);
}
const userInstructions = this.config.instructions;
const stableBlock = wrapBuiltInRules(stableFragments);
const instructions = stableBlock
? userInstructions
? `${stableBlock}\n\n${userInstructions}`
: stableBlock
: userInstructions;
return {
instructions,
volatileInstructions: wrapBuiltInRules(volatileFragments),
};
return { stableFragments, volatileFragments };
}
/** Build the providerOptions object for provider-specific thinking config. */

View File

@ -1,4 +1,4 @@
import type { StreamTextTransform, ToolSet } from 'ai';
import type { StreamTextTransform, TextStreamPart, ToolSet } from 'ai';
import type {
CompleteEmission,
@ -160,7 +160,7 @@ export class StreamSink implements RunOutputSink<void> {
return transforms.length > 0 ? { experimental_transform: transforms } : {};
}
async callModel(ctx: ModelCallContext): Promise<ModelTurnResult> {
private getStreamDeadlines(): { idleMs: number; firstOutputMs: number } {
// Clamped to MAX_MODEL_STREAM_TIMEOUT_MS: above it setTimeout fires after
// 1ms, turning an "effectively disable" override into instant stalls.
const idleMs = Math.min(
@ -177,48 +177,55 @@ export class StreamSink implements RunOutputSink<void> {
),
MAX_MODEL_STREAM_TIMEOUT_MS,
);
return { idleMs, firstOutputMs };
}
async callModel(ctx: ModelCallContext): Promise<ModelTurnResult> {
const deadlines = this.getStreamDeadlines();
for (let attempt = 0; ; attempt++) {
// Opt-in: only build the reader when the host bills stopped runs, and only
// for providers with a reader. Re-created per attempt so a retried turn
// starts from a clean raw capture.
this.rawUsageReader = this.options?.recoverUsageOnAbort
? createRawUsageReader(this.services.modelId)
: undefined;
// Some providers report failures (e.g. prompt safety blocks) only on the
// raw stream — no error, no content — so raw chunks are required to
// explain an otherwise silent empty response.
this.rawErrorReader = createRawErrorReader(this.services.modelId);
this.resetRawReaders();
// Per-attempt controller: a stall must be able to cancel this attempt's
// fetch (releasing the socket the 1h network timeout would otherwise
// hold) without touching the run-level signal.
const turnAbort = new AbortController();
const attemptState = { streamedContent: false };
try {
return await this.streamModelTurn(ctx, turnAbort, { idleMs, firstOutputMs }, attemptState);
return await this.streamModelTurn(ctx, turnAbort, deadlines, attemptState);
} catch (error) {
if (isAttachmentValidationError(error)) {
if (!attemptState.streamedContent) await ctx.onInputRejected?.(error);
throw error;
}
// A stalled or empty stream before any content is invisible to the user
// (and to the host's persistence) — re-issue the request instead of
// failing the run for what is usually a dead connection at request time.
// Once content has streamed, a retry would duplicate segments and orphan
// already-persisted tool-call facts, so the error surfaces instead.
// The abandoned attempt's prompt processing goes unbilled — accepted:
// it only has usage at all when the stream died between message_start
// and the first content chunk.
const retryable =
(error instanceof ModelStreamStallError ||
loadAi().NoOutputGeneratedError.isInstance(error)) &&
attempt < MAX_MODEL_STREAM_STALL_RETRIES &&
!attemptState.streamedContent &&
!ctx.abortSignal.aborted;
if (!retryable) throw error;
if (!this.canRetryStream(error, attempt, attemptState, ctx.abortSignal)) throw error;
}
}
}
private resetRawReaders(): void {
// Each attempt needs fresh readers so a retry does not reuse captured usage.
this.rawUsageReader = this.options?.recoverUsageOnAbort
? createRawUsageReader(this.services.modelId)
: undefined;
// Some providers report a blocked prompt only in raw chunks.
this.rawErrorReader = createRawErrorReader(this.services.modelId);
}
private canRetryStream(
error: unknown,
attempt: number,
attemptState: { streamedContent: boolean },
abortSignal: AbortSignal,
): boolean {
// Retry only before content is visible or persisted. The abandoned attempt stays unbilled.
return (
(error instanceof ModelStreamStallError ||
loadAi().NoOutputGeneratedError.isInstance(error)) &&
attempt < MAX_MODEL_STREAM_STALL_RETRIES &&
!attemptState.streamedContent &&
!abortSignal.aborted
);
}
private async streamModelTurn(
ctx: ModelCallContext,
turnAbort: AbortController,
@ -227,6 +234,36 @@ export class StreamSink implements RunOutputSink<void> {
): Promise<ModelTurnResult> {
const { idleMs, firstOutputMs } = deadlines;
const { NoOutputGeneratedError, streamText } = loadAi();
const { result, activity } = this.startModelStream(ctx, turnAbort, idleMs, streamText);
// Use the longer deadline before the first output to allow prompt processing.
const chunkStream =
idleMs > 0
? withChunkIdleTimeout(
result.stream,
() => (attemptState.streamedContent ? idleMs : firstOutputMs),
() => turnAbort.abort(),
() => activity.lastAt,
)
: result.stream;
for await (const chunk of chunkStream) {
if (chunk.type === 'error' && isAttachmentValidationError(chunk.error)) throw chunk.error;
// Result promises report this error. Do not forward it or count it as content before a retry.
if (chunk.type === 'error' && NoOutputGeneratedError.isInstance(chunk.error)) continue;
if (!this.captureStreamChunk(chunk, attemptState)) continue;
await this.writeStreamChunk(chunk);
}
return await this.resolveModelTurn(result, ctx, idleMs, turnAbort);
}
private startModelStream(
ctx: ModelCallContext,
turnAbort: AbortController,
idleMs: number,
streamText: ReturnType<typeof loadAi>['streamText'],
) {
// Raw chunks serve two consumers: the usage/error readers parse them, and
// the stall watchdog counts them as liveness — keepalive events the SDK
// otherwise drops (e.g. Anthropic `ping`, a gateway's empty-delta frames)
@ -264,83 +301,58 @@ export class StreamSink implements RunOutputSink<void> {
: undefined,
),
});
return { result, activity };
}
// A healthy streaming response emits chunks continuously (raw provider
// events included), so prolonged silence means the connection or provider
// is wedged — fail the turn instead of hanging until the network timeout.
// Pre-first-output silence gets the longer deadline (prompt processing on
// large cache-miss prompts sends nothing for minutes); once content has
// streamed, the tighter idle limit applies.
const chunkStream =
idleMs > 0
? withChunkIdleTimeout(
result.stream,
() => (attemptState.streamedContent ? idleMs : firstOutputMs),
() => turnAbort.abort(),
() => activity.lastAt,
)
: result.stream;
private captureStreamChunk(
chunk: TextStreamPart<ToolSet>,
attemptState: { streamedContent: boolean },
): boolean {
// Capture raw chunks here when a stream does not use the tap, such as a test stream.
if (chunk.type === 'raw') {
this.rawUsageReader?.capture(chunk.rawValue);
this.rawErrorReader?.capture(chunk.rawValue);
return false;
}
if (!STALL_RETRY_SAFE_CHUNK_TYPES.has(chunk.type)) attemptState.streamedContent = true;
// The runtime writes its own finish chunk after the loop completes.
if (chunk.type === 'finish') return false;
// Retain partial text so cancellation can save an unfinished response.
if (chunk.type === 'text-delta') this.partialText += chunk.text ?? '';
return true;
}
// Consume the stream. When the AbortSignal fires mid-stream the AI SDK
// cancels the underlying fetch and the async iterator throws; the error
// propagates to the StreamSession which closes the consumer stream.
for await (const chunk of chunkStream) {
if (chunk.type === 'error' && isAttachmentValidationError(chunk.error)) throw chunk.error;
// The rejected result promises surface this error after the stream
// closes. Forwarding it here would mark a successful retry as failed.
// Runs before the `streamedContent` check below so a skipped chunk
// cannot make a stalled attempt look unretryable.
if (chunk.type === 'error' && NoOutputGeneratedError.isInstance(chunk.error)) continue;
// Raw chunks are normally consumed by the raw-chunk tap inside the SDK
// pipeline and never reach this loop; this branch is the fallback for
// streams the tap was not applied to (e.g. mocked streams in tests) and
// is why 'raw' stays in the retry-safe set. Capturing usage here also
// lets an aborted turn still be billed via getTerminalFinish.
if (chunk.type === 'raw') {
this.rawUsageReader?.capture(chunk.rawValue);
this.rawErrorReader?.capture(chunk.rawValue);
continue;
}
// Anything beyond transport bookkeeping counts as content: once seen,
// a stalled attempt is no longer silently retryable (see callModel).
if (!STALL_RETRY_SAFE_CHUNK_TYPES.has(chunk.type)) attemptState.streamedContent = true;
// Filter only the SDK's terminal `finish` chunk — the runtime emits its
// own consolidated `finish` after the loop completes. `start-step` /
// `finish-step` are passed through as LLM-iteration boundaries.
if (chunk.type === 'finish') continue;
// Accumulate streamed text so an abort mid-response can persist it (the
// turn's `newMessages` are only built below, which the abort never reaches).
if (chunk.type === 'text-delta') this.partialText += chunk.text ?? '';
// Provider-executed tools (e.g. native web search) skip the local
// execution loop that emits tool-execution lifecycle events via the
// event bus. Stamp them here at chunk-arrival time so live chat and the
// persisted timeline both show a duration. A failed call arrives as a
// `tool-error` part (never a `tool-result`), so close its timing there.
if ((chunk.type === 'tool-result' || chunk.type === 'tool-error') && chunk.providerExecuted) {
await this.guard.write({
type: 'tool-execution-end',
toolCallId: chunk.toolCallId,
toolName: chunk.toolName ?? '',
isError: chunk.type === 'tool-error',
endTime: Date.now(),
});
}
const converted = convertChunk(chunk);
if (converted) await this.guard.write(converted);
if (chunk.type === 'tool-call' && chunk.providerExecuted) {
await this.guard.write({
type: 'tool-execution-start',
toolCallId: chunk.toolCallId,
toolName: chunk.toolName ?? '',
startTime: Date.now(),
});
}
private async writeStreamChunk(chunk: TextStreamPart<ToolSet>): Promise<void> {
// Provider tools skip the local executor. Record their timing when chunks arrive.
if ((chunk.type === 'tool-result' || chunk.type === 'tool-error') && chunk.providerExecuted) {
await this.guard.write({
type: 'tool-execution-end',
toolCallId: chunk.toolCallId,
toolName: chunk.toolName ?? '',
isError: chunk.type === 'tool-error',
endTime: Date.now(),
});
}
const converted = convertChunk(chunk);
if (converted) await this.guard.write(converted);
if (chunk.type === 'tool-call' && chunk.providerExecuted) {
await this.guard.write({
type: 'tool-execution-start',
toolCallId: chunk.toolCallId,
toolName: chunk.toolName ?? '',
startTime: Date.now(),
});
}
}
private async resolveModelTurn(
result: ReturnType<StreamSink['startModelStream']>['result'],
ctx: ModelCallContext,
idleMs: number,
turnAbort: AbortController,
): Promise<ModelTurnResult> {
// The result promises settle as part of stream close on a healthy turn;
// guard them with the same stall deadline so an SDK-internal promise that
// never settles cannot hang the turn after the chunk loop ended.

View File

@ -447,9 +447,25 @@ function resolveDelegateSubAgentSystemInstruction(
* }));
*/
export function createDelegateSubAgentTool(options: CreateDelegateSubAgentToolOptions = {}) {
// Per-parent child path index for stable task paths (/root/name_0, /root/name_1, ...).
// Each parent has its own index so child task paths stay stable.
const childPathIndexes = new Map<string, number>();
const toolName = resolveDelegateSubAgentToolName(options.name);
const resolvedOptions = resolveDelegateSubAgentOptions(options, toolName);
const tool = buildDelegateSubAgentTool(toolName, resolvedOptions, childPathIndexes);
return withSdkOwnedBuiltInMetadata({
...tool,
...createDelegateCancellationHandler(resolvedOptions, childPathIndexes),
metadata: {
...tool.metadata,
[INLINE_DELEGATE_SUB_AGENT_TOOL_METADATA_KEY]: buildDelegateToolMetadata(resolvedOptions),
},
});
}
function resolveDelegateSubAgentOptions(
options: CreateDelegateSubAgentToolOptions,
toolName: string,
): CreateDelegateSubAgentToolOptions {
const resolvedOptions: CreateDelegateSubAgentToolOptions = {
...options,
policy: resolveDelegateSubAgentPolicy(options.policy, toolName),
@ -462,6 +478,14 @@ export function createDelegateSubAgentTool(options: CreateDelegateSubAgentToolOp
`${toolName} requires resumeSubAgent and cancelSubAgent to be configured together`,
);
}
return resolvedOptions;
}
function buildDelegateSubAgentTool(
toolName: string,
resolvedOptions: CreateDelegateSubAgentToolOptions,
childPathIndexes: Map<string, number>,
) {
const inlineProviderToolInstruction = resolvedOptions.resolveInlineSubAgentProviderTools
? "Provider-defined tools are loaded for the inline child's selected model provider."
: 'Inline children do not inherit provider-defined tools.';
@ -483,73 +507,77 @@ export function createDelegateSubAgentTool(options: CreateDelegateSubAgentToolOp
) => await handleDelegateSubAgent(input, ctx, resolvedOptions, childPathIndexes);
const toModelOutput = (output: z.infer<typeof delegateSubAgentOutputSchema>) =>
resolvedOptions.toModelOutput ? resolvedOptions.toModelOutput(output) : output;
const tool = resolvedOptions.resumeSubAgent
? toolBuilder
.suspend(delegateSubAgentSuspendSchema)
.resume(delegateSubAgentResumeSchema)
.handler(handler)
.toModelOutput(toModelOutput)
.build()
: toolBuilder.handler(handler).toModelOutput(toModelOutput).build();
return withSdkOwnedBuiltInMetadata({
...tool,
...(resolvedOptions.cancelSubAgent !== undefined
if (resolvedOptions.resumeSubAgent) {
return toolBuilder
.suspend(delegateSubAgentSuspendSchema)
.resume(delegateSubAgentResumeSchema)
.handler(handler)
.toModelOutput(toModelOutput)
.build();
}
return toolBuilder.handler(handler).toModelOutput(toModelOutput).build();
}
function createDelegateCancellationHandler(
options: CreateDelegateSubAgentToolOptions,
childPathIndexes: Map<string, number>,
): Pick<BuiltTool, 'onCancellation'> {
if (options.cancelSubAgent === undefined) return {};
return {
onCancellation: async (rawInput: unknown, ctx: ToolCancellationContext) => {
const parsedInput = delegateSubAgentInputSchema.safeParse(rawInput);
if (!parsedInput.success) {
throw new Error('Delegated child input is missing or invalid');
}
await cancelDelegatedSubAgent(parsedInput.data, ctx, options, childPathIndexes);
},
};
}
function buildDelegateToolMetadata(
resolvedOptions: CreateDelegateSubAgentToolOptions,
): DelegateSubAgentToolMetadata {
return {
...(resolvedOptions.name !== undefined ? { name: resolvedOptions.name } : {}),
...(resolvedOptions.availableSubAgents !== undefined
? { availableSubAgents: resolvedOptions.availableSubAgents }
: {}),
policy: resolvedOptions.policy,
...(resolvedOptions.inlineSubAgentBlockedTools !== undefined
? { inlineSubAgentBlockedTools: resolvedOptions.inlineSubAgentBlockedTools }
: {}),
...(resolvedOptions.inlineSubAgentModelsByDifficulty !== undefined
? {
onCancellation: async (rawInput: unknown, ctx: ToolCancellationContext) => {
const parsedInput = delegateSubAgentInputSchema.safeParse(rawInput);
if (!parsedInput.success) {
throw new Error('Delegated child input is missing or invalid');
}
await cancelDelegatedSubAgent(parsedInput.data, ctx, resolvedOptions, childPathIndexes);
},
inlineSubAgentModelsByDifficulty: resolvedOptions.inlineSubAgentModelsByDifficulty,
}
: {}),
metadata: {
...tool.metadata,
[INLINE_DELEGATE_SUB_AGENT_TOOL_METADATA_KEY]: {
...(resolvedOptions.name !== undefined ? { name: resolvedOptions.name } : {}),
...(resolvedOptions.availableSubAgents !== undefined
? { availableSubAgents: resolvedOptions.availableSubAgents }
: {}),
policy: resolvedOptions.policy,
...(resolvedOptions.inlineSubAgentBlockedTools !== undefined
? { inlineSubAgentBlockedTools: resolvedOptions.inlineSubAgentBlockedTools }
: {}),
...(resolvedOptions.inlineSubAgentModelsByDifficulty !== undefined
? {
inlineSubAgentModelsByDifficulty: resolvedOptions.inlineSubAgentModelsByDifficulty,
}
: {}),
...(resolvedOptions.resolveInlineSubAgentProviderTools !== undefined
? {
resolveInlineSubAgentProviderTools:
resolvedOptions.resolveInlineSubAgentProviderTools,
}
: {}),
...(resolvedOptions.runSubAgent !== undefined
? { runSubAgent: resolvedOptions.runSubAgent }
: {}),
...(resolvedOptions.resumeSubAgent !== undefined
? { resumeSubAgent: resolvedOptions.resumeSubAgent }
: {}),
...(resolvedOptions.shouldRetrySubAgentResumeError !== undefined
? { shouldRetrySubAgentResumeError: resolvedOptions.shouldRetrySubAgentResumeError }
: {}),
...(resolvedOptions.cancelSubAgent !== undefined
? { cancelSubAgent: resolvedOptions.cancelSubAgent }
: {}),
...(resolvedOptions.systemInstruction !== undefined
? { systemInstruction: resolvedOptions.systemInstruction }
: {}),
...(resolvedOptions.description !== undefined
? { description: resolvedOptions.description }
: {}),
...(resolvedOptions.toModelOutput !== undefined
? { toModelOutput: resolvedOptions.toModelOutput }
: {}),
} satisfies DelegateSubAgentToolMetadata,
},
});
...(resolvedOptions.resolveInlineSubAgentProviderTools !== undefined
? {
resolveInlineSubAgentProviderTools: resolvedOptions.resolveInlineSubAgentProviderTools,
}
: {}),
...(resolvedOptions.runSubAgent !== undefined
? { runSubAgent: resolvedOptions.runSubAgent }
: {}),
...(resolvedOptions.resumeSubAgent !== undefined
? { resumeSubAgent: resolvedOptions.resumeSubAgent }
: {}),
...(resolvedOptions.shouldRetrySubAgentResumeError !== undefined
? { shouldRetrySubAgentResumeError: resolvedOptions.shouldRetrySubAgentResumeError }
: {}),
...(resolvedOptions.cancelSubAgent !== undefined
? { cancelSubAgent: resolvedOptions.cancelSubAgent }
: {}),
...(resolvedOptions.systemInstruction !== undefined
? { systemInstruction: resolvedOptions.systemInstruction }
: {}),
...(resolvedOptions.description !== undefined
? { description: resolvedOptions.description }
: {}),
...(resolvedOptions.toModelOutput !== undefined
? { toModelOutput: resolvedOptions.toModelOutput }
: {}),
};
}
export function getInlineDelegateSubAgentToolOptions(
@ -632,26 +660,7 @@ async function handleDelegateSubAgent(
request = restored.request;
startedAt = Date.now();
emitSubAgentStarted(ctx, request, startedAt);
let output: DelegateSubAgentToolOutput;
try {
output = await options.resumeSubAgent(
{
...request,
...getChildCheckpointTarget(checkpoint),
resumeData: ctx.resumeData,
},
createRunnerHelpers(ctx, request, options.name),
);
} catch (error) {
if (ctx.abortSignal?.aborted || isAbortError(error)) throw error;
if (options.shouldRetrySubAgentResumeError?.(error) === false) throw error;
return await ctx.suspend(ctx.suspendPayload);
}
emitSubAgentCompleted(ctx, request, output, startedAt);
if (output.status === 'suspended') {
return await cascadeChildSuspension(ctx, request, output);
}
return output;
return await resumeDelegatedChild(ctx, restored, startedAt, options, options.resumeSubAgent);
}
const childPathIndexKey = getChildPathIndexKey(ctx);
@ -664,42 +673,90 @@ async function handleDelegateSubAgent(
startedAt = Date.now();
emitSubAgentStarted(ctx, request, startedAt);
const toolName = options.name ?? DELEGATE_SUB_AGENT_TOOL_NAME;
if (!options.runSubAgent) {
throw new Error(
`${toolName} was registered without a runSubAgent callback, and no host runner was provided. Register it on an Agent (for inline delegation) or pass runSubAgent.`,
);
}
const output = await options.runSubAgent(
request,
return await runDelegatedChild(ctx, request, startedAt, options);
} catch (error) {
return handleDelegateFailure(error, ctx, taskPath, request, startedAt);
}
}
async function resumeDelegatedChild(
ctx: InterruptibleToolContext,
{ request, checkpoint }: ReturnType<typeof restoreDelegateRequest>,
startedAt: number,
options: CreateDelegateSubAgentToolOptions,
resumeSubAgent: DelegateSubAgentResumeRunner,
): Promise<DelegateSubAgentToolOutput> {
let output: DelegateSubAgentToolOutput;
try {
output = await resumeSubAgent(
{
...request,
...getChildCheckpointTarget(checkpoint),
resumeData: ctx.resumeData,
},
createRunnerHelpers(ctx, request, options.name),
);
emitSubAgentCompleted(ctx, request, output, startedAt);
if (
output.status === 'suspended' &&
options.resumeSubAgent !== undefined &&
isInterruptibleToolContext(ctx)
) {
return await cascadeChildSuspension(ctx, request, output);
}
return output;
} catch (error) {
// When the parent has a signal it is the authority: `isAbortError` also
// matches by message text, and an unrelated child error must not be
// mistaken for a cancellation and kill the parent run.
const aborted = ctx.abortSignal ? ctx.abortSignal.aborted : isAbortError(error);
const output: DelegateSubAgentToolOutput = {
status: aborted ? 'cancelled' : 'failed',
...(taskPath !== undefined ? { taskPath } : {}),
answer: '',
...(aborted ? {} : { error: stringifyUnknown(error) }),
};
if (request !== undefined && startedAt !== undefined) {
emitSubAgentCompleted(ctx, request, output, startedAt);
}
if (aborted) throw error;
return output;
if (ctx.abortSignal?.aborted || isAbortError(error)) throw error;
if (options.shouldRetrySubAgentResumeError?.(error) === false) throw error;
return await ctx.suspend(ctx.suspendPayload);
}
emitSubAgentCompleted(ctx, request, output, startedAt);
if (output.status === 'suspended') {
return await cascadeChildSuspension(ctx, request, output);
}
return output;
}
async function runDelegatedChild(
ctx: ToolContext | InterruptibleToolContext,
request: DelegateSubAgentRequest,
startedAt: number,
options: CreateDelegateSubAgentToolOptions,
): Promise<DelegateSubAgentToolOutput> {
const toolName = options.name ?? DELEGATE_SUB_AGENT_TOOL_NAME;
if (!options.runSubAgent) {
throw new Error(
`${toolName} was registered without a runSubAgent callback, and no host runner was provided. Register it on an Agent (for inline delegation) or pass runSubAgent.`,
);
}
const output = await options.runSubAgent(
request,
createRunnerHelpers(ctx, request, options.name),
);
emitSubAgentCompleted(ctx, request, output, startedAt);
if (
output.status === 'suspended' &&
options.resumeSubAgent !== undefined &&
isInterruptibleToolContext(ctx)
) {
return await cascadeChildSuspension(ctx, request, output);
}
return output;
}
function handleDelegateFailure(
error: unknown,
ctx: ToolContext,
taskPath: SubAgentTaskPath | undefined,
request: DelegateSubAgentRequest | undefined,
startedAt: number | undefined,
): DelegateSubAgentToolOutput {
// When the parent has a signal it is the authority: `isAbortError` also
// matches by message text, and an unrelated child error must not be
// mistaken for a cancellation and kill the parent run.
const aborted = ctx.abortSignal ? ctx.abortSignal.aborted : isAbortError(error);
const output: DelegateSubAgentToolOutput = {
status: aborted ? 'cancelled' : 'failed',
...(taskPath !== undefined ? { taskPath } : {}),
answer: '',
...(aborted ? {} : { error: stringifyUnknown(error) }),
};
if (request !== undefined && startedAt !== undefined) {
emitSubAgentCompleted(ctx, request, output, startedAt);
}
if (aborted) throw error;
return output;
}
function createDelegateSubAgentRequest(
@ -1001,12 +1058,7 @@ export function generateResultToDelegateSubAgentOutput(
...(result.structuredOutput !== undefined ? { structuredOutput: result.structuredOutput } : {}),
...(result.usage !== undefined
? {
usage: {
promptTokens: result.usage.promptTokens,
completionTokens: result.usage.completionTokens,
totalTokens: result.usage.totalTokens,
...(result.usage.cost !== undefined ? { cost: result.usage.cost } : {}),
},
usage: toDelegateUsage(result.usage),
}
: {}),
...(result.finishReason !== undefined ? { finishReason: result.finishReason } : {}),
@ -1017,6 +1069,15 @@ export function generateResultToDelegateSubAgentOutput(
};
}
function toDelegateUsage(usage: TokenUsage): DelegateSubAgentToolOutput['usage'] {
return {
promptTokens: usage.promptTokens,
completionTokens: usage.completionTokens,
totalTokens: usage.totalTokens,
...(usage.cost !== undefined ? { cost: usage.cost } : {}),
};
}
/** Last non-empty assistant text across the run's messages. */
function lastText(messages: AgentMessage[]): string {
const llmMessages = filterLlmMessages(messages);

View File

@ -117,42 +117,14 @@ export async function executeTool(
throw new Error(`No handler found for tool "${builtTool.name}"`);
}
// Anchor programmatic skill activations to the calling tool's result by
// default, so the skill body rides on that result (kept out of `system`)
// instead of forcing a full prompt-prefix rewrite. An explicit anchor wins.
const baseLoadSkill = executionContext.loadSkill;
const loadSkill: ToolExecutionContext['loadSkill'] = baseLoadSkill
? async (skillId, anchor) =>
await baseLoadSkill(skillId, anchor ?? (toolCallId ? { toolCallId } : undefined))
: undefined;
const loadSkill = anchorSkillLoader(executionContext.loadSkill, toolCallId);
if (builtTool.suspendSchema) {
const isCancelled = isCancellation(resumeData);
const ctx: InterruptibleToolContext = {
suspend: async (payload: unknown, options?: ToolSuspendOptions): Promise<never> => {
const resolvedOptions: ToolSuspendOptions = {
continuation: executionContext.continuation,
...options,
resumeSchema: options?.resumeSchema ?? executionContext.resumeSchema,
};
await executionContext.onSuspend?.(payload, resolvedOptions);
return await Promise.resolve({
[SUSPEND_BRAND]: true,
payload,
...resolvedOptions,
} as never);
},
suspend: createSuspendHandler(executionContext),
resumeData: isCancelled ? undefined : resumeData,
cancellation: isCancelled ? { message: resumeData.message } : undefined,
parentTelemetry,
toolCallId,
toolName: builtTool.name,
runId: executionContext.runId,
...(loadSkill ? { loadSkill } : {}),
persistence: executionContext.persistence,
emitEvent: executionContext.emitEvent,
abortSignal: executionContext.abortSignal,
executionCounter: executionContext.executionCounter,
...createToolContext(builtTool, executionContext, parentTelemetry, toolCallId, loadSkill),
suspendPayload: executionContext.suspendPayload,
continuation: executionContext.continuation,
resumeSchema: executionContext.resumeSchema,
@ -160,7 +132,34 @@ export async function executeTool(
return await builtTool.handler(args, ctx);
}
const ctx: ToolContext = {
const ctx = createToolContext(
builtTool,
executionContext,
parentTelemetry,
toolCallId,
loadSkill,
);
return await builtTool.handler(args, ctx);
}
function anchorSkillLoader(
loadSkill: ToolExecutionContext['loadSkill'],
toolCallId: string | undefined,
): ToolExecutionContext['loadSkill'] {
if (!loadSkill) return undefined;
// Keep skill content on the tool result. An explicit anchor takes precedence.
return async (skillId, anchor) =>
await loadSkill(skillId, anchor ?? (toolCallId ? { toolCallId } : undefined));
}
function createToolContext(
builtTool: BuiltTool,
executionContext: ToolExecutionContext,
parentTelemetry: BuiltTelemetry | undefined,
toolCallId: string | undefined,
loadSkill: ToolExecutionContext['loadSkill'],
): ToolContext {
return {
parentTelemetry,
toolCallId,
toolName: builtTool.name,
@ -171,7 +170,24 @@ export async function executeTool(
abortSignal: executionContext.abortSignal,
executionCounter: executionContext.executionCounter,
};
return await builtTool.handler(args, ctx);
}
function createSuspendHandler(
executionContext: ToolExecutionContext,
): InterruptibleToolContext['suspend'] {
return async (payload: unknown, options?: ToolSuspendOptions): Promise<never> => {
const resolvedOptions: ToolSuspendOptions = {
continuation: executionContext.continuation,
...options,
resumeSchema: options?.resumeSchema ?? executionContext.resumeSchema,
};
await executionContext.onSuspend?.(payload, resolvedOptions);
return await Promise.resolve({
[SUSPEND_BRAND]: true,
payload,
...resolvedOptions,
} as never);
};
}
/**

View File

@ -122,13 +122,28 @@ export interface ToolCallBatchResult {
pending: Record<string, PendingToolCall>;
}
interface RuntimeToolCall {
interface ToolCallInput {
toolCallId: string;
toolName: string;
input: JSONObject;
input: unknown;
providerExecuted?: boolean;
}
interface RuntimeToolCall extends ToolCallInput {
input: JSONObject;
}
type ToolCallIdentity = Pick<PendingToolCall, 'toolCallId' | 'toolName' | 'input'>;
type SuspendedToolOutcome = Extract<ToolCallOutcome, { outcome: 'suspended' }>;
interface InterruptedToolSuspension {
didSuspend: boolean;
abortObserved: boolean;
payload: unknown;
options?: ToolSuspendOptions;
cleanup?: Promise<void>;
}
/** Shared input for the tool-call batch iterators. */
export interface ToolBatchContext {
toolMap: Map<string, BuiltTool>;
@ -288,33 +303,54 @@ export class ToolCallExecutor {
* even if one throws, then re-throws the first error.
*/
async iterateToolCallsConcurrent(
ctx: ToolBatchContext & {
toolCalls: Array<{
toolCallId: string;
toolName: string;
input: unknown;
providerExecuted?: boolean;
}>;
},
ctx: ToolBatchContext & { toolCalls: ToolCallInput[] },
): Promise<ToolCallBatchResult> {
const {
toolCalls,
toolMap,
list,
runId,
telemetry: resolvedTelemetry,
executionCounter,
abortSignal,
} = ctx;
const errors: ToolCallError[] = [];
const runtimeToolCalls: RuntimeToolCall[] = [];
const runtimeToolCalls = this.normalizeToolCalls(ctx.toolCalls, ctx, errors);
const executableCalls = runtimeToolCalls.filter((tc) => !tc.providerExecuted);
const providerExecutedCount = runtimeToolCalls.length - executableCalls.length;
for (let i = 0; i < providerExecutedCount; i++) {
incrementToolCallCount(ctx.executionCounter);
}
const executableCallsById = new Map(executableCalls.map((tc) => [tc.toolCallId, tc]));
const unexecutedIds = new Set(executableCalls.map((tc) => tc.toolCallId));
const result: ToolCallBatchResult = { results: [], suspensions: [], errors, pending: {} };
for (let batchStart = 0; batchStart < executableCalls.length; ) {
if (ctx.isAborted()) {
this.skipAbortedToolCalls(unexecutedIds, executableCallsById, ctx.list, result.results);
return await this.finalizeBatch(result, ctx);
}
const batch = this.takeNextToolCallBatch(executableCalls, batchStart, ctx.toolMap);
batchStart += batch.length;
const settled = await this.executeToolCallBatch(batch, ctx);
for (const tc of batch) unexecutedIds.delete(tc.toolCallId);
const hasSuspension = this.collectBatchResults(batch, settled, result, ctx);
if (ctx.isAborted()) {
this.skipAbortedToolCalls(unexecutedIds, executableCallsById, ctx.list, result.results);
return await this.finalizeBatch(result, ctx);
}
if (hasSuspension) {
this.retainUnexecutedToolCalls(unexecutedIds, executableCallsById, result.pending);
break;
}
}
return await this.finalizeBatch(result, ctx);
}
private normalizeToolCalls(
toolCalls: ToolCallInput[],
ctx: ToolBatchContext,
errors: ToolCallError[],
): RuntimeToolCall[] {
const calls: RuntimeToolCall[] = [];
for (const toolCall of toolCalls) {
const normalizedInput = normalizeToolInputForModel(toolCall.input);
if (!normalizedInput.ok) {
const error = new Error(normalizedInput.error);
incrementToolCallCount(executionCounter);
list.setToolCallError(toolCall.toolCallId, error);
incrementToolCallCount(ctx.executionCounter);
ctx.list.setToolCallError(toolCall.toolCallId, error);
errors.push({
toolCallId: toolCall.toolCallId,
toolName: toolCall.toolName,
@ -323,177 +359,180 @@ export class ToolCallExecutor {
});
continue;
}
runtimeToolCalls.push({ ...toolCall, input: normalizedInput.input });
calls.push({ ...toolCall, input: normalizedInput.input });
}
return calls;
}
const executableCalls = runtimeToolCalls.filter((tc) => !tc.providerExecuted);
const providerExecutedCount = runtimeToolCalls.length - executableCalls.length;
for (let i = 0; i < providerExecutedCount; i++) {
incrementToolCallCount(executionCounter);
private async executeToolCallBatch(
batch: RuntimeToolCall[],
ctx: ToolBatchContext,
): Promise<Array<PromiseSettledResult<ToolCallOutcome>>> {
return await Promise.allSettled(
batch.map(
async (tc) =>
await this.processToolCall({
toolCallId: tc.toolCallId,
toolName: tc.toolName,
input: tc.input,
toolMap: ctx.toolMap,
list: ctx.list,
runId: ctx.runId,
persistence: ctx.persistence,
resolvedTelemetry: ctx.telemetry,
executionCounter: ctx.executionCounter,
abortSignal: ctx.abortSignal,
countToolCall: true,
}),
),
);
}
private collectBatchResults(
calls: RuntimeToolCall[],
settled: Array<PromiseSettledResult<ToolCallOutcome>>,
batch: ToolCallBatchResult,
ctx: ToolBatchContext,
): boolean {
let hasSuspension = false;
for (let i = 0; i < settled.length; i++) {
if (this.collectSettledToolCall(calls[i], settled[i], batch, ctx)) hasSuspension = true;
}
const executableCallsById = new Map(executableCalls.map((tc) => [tc.toolCallId, tc]));
const unexecutedIds = new Set(executableCalls.map((tc) => tc.toolCallId));
const results: ToolCallSuccess[] = [];
const suspensions: ToolCallSuspension[] = [];
const pending: Record<string, PendingToolCall> = {};
return hasSuspension;
}
for (let batchStart = 0; batchStart < executableCalls.length; ) {
if (ctx.isAborted()) {
this.deps.onCancelled();
for (const id of unexecutedIds) {
const tc = executableCallsById.get(id)!;
const modelOutput = '[Skipped: run was aborted]';
list.setToolCallResult(tc.toolCallId, modelOutput, { canceled: true });
results.push({
toolCallId: tc.toolCallId,
toolName: tc.toolName,
input: tc.input,
toolEntry: {
tool: tc.toolName,
input: tc.input,
output: modelOutput,
transformed: false,
canceled: true,
},
modelOutput,
});
}
return await this.finalizeBatch({ results, suspensions, errors, pending }, ctx);
}
const batch = this.takeNextToolCallBatch(executableCalls, batchStart, toolMap);
batchStart += batch.length;
const settledResults = await Promise.allSettled(
batch.map(
async (tc) =>
await this.processToolCall({
toolCallId: tc.toolCallId,
toolName: tc.toolName,
input: tc.input,
toolMap,
list,
runId,
persistence: ctx.persistence,
resolvedTelemetry,
executionCounter,
abortSignal,
countToolCall: true,
}),
),
);
for (const tc of batch) {
unexecutedIds.delete(tc.toolCallId);
}
let hasSuspension = false;
for (let i = 0; i < settledResults.length; i++) {
const result = settledResults[i];
const tc = batch[i];
const toolInput = tc.input;
if (result.status === 'rejected') {
list.setToolCallError(tc.toolCallId, result.reason);
errors.push({
toolCallId: tc.toolCallId,
toolName: tc.toolName,
input: toolInput,
error: result.reason,
});
} else if (result.value.outcome === 'suspended') {
hasSuspension = true;
suspensions.push({
toolCallId: tc.toolCallId,
toolName: tc.toolName,
input: toolInput,
payload: result.value.payload,
resumeSchema: result.value.resumeSchema,
});
pending[tc.toolCallId] = {
suspended: true,
toolCallId: tc.toolCallId,
toolName: tc.toolName,
input: toolInput,
suspendPayload: result.value.payload,
resumeSchema: result.value.resumeSchema,
...(result.value.continuation !== undefined
? { continuation: result.value.continuation }
: {}),
runId,
};
} else if (result.value.outcome === 'success') {
results.push({
toolCallId: tc.toolCallId,
toolName: tc.toolName,
input: toolInput,
toolEntry: result.value.toolEntry,
modelOutput: result.value.modelOutput,
customMessage: result.value.customMessage,
...(result.value.mcpServerName !== undefined
? { mcpServerName: result.value.mcpServerName }
: {}),
});
} else if (result.value.outcome === 'cancelled') {
results.push({
toolCallId: tc.toolCallId,
toolName: tc.toolName,
input: toolInput,
toolEntry: result.value.toolEntry,
modelOutput: result.value.modelOutput,
});
} else if (result.value.outcome === 'error') {
errors.push({
toolCallId: tc.toolCallId,
toolName: tc.toolName,
input: toolInput,
error: result.value.error,
});
} else if (result.value.outcome === 'noop') {
// noop
}
}
if (ctx.isAborted()) {
this.deps.onCancelled();
for (const id of unexecutedIds) {
const tc = executableCallsById.get(id)!;
const modelOutput = '[Skipped: run was aborted]';
list.setToolCallResult(tc.toolCallId, modelOutput, { canceled: true });
results.push({
toolCallId: tc.toolCallId,
toolName: tc.toolName,
input: tc.input,
toolEntry: {
tool: tc.toolName,
input: tc.input,
output: modelOutput,
transformed: false,
canceled: true,
},
modelOutput,
});
}
return await this.finalizeBatch({ results, suspensions, errors, pending }, ctx);
}
if (hasSuspension) {
for (const id of unexecutedIds) {
const tc = executableCallsById.get(id)!;
pending[tc.toolCallId] = {
suspended: false,
toolCallId: tc.toolCallId,
toolName: tc.toolName,
input: tc.input,
};
}
private collectSettledToolCall(
call: RuntimeToolCall,
result: PromiseSettledResult<ToolCallOutcome>,
batch: ToolCallBatchResult,
ctx: ToolBatchContext,
): boolean {
if (result.status === 'rejected') {
ctx.list.setToolCallError(call.toolCallId, result.reason);
batch.errors.push({
toolCallId: call.toolCallId,
toolName: call.toolName,
input: call.input,
error: result.reason,
});
return false;
}
const outcome = result.value;
switch (outcome.outcome) {
case 'suspended':
batch.suspensions.push(this.buildToolCallSuspension(call, outcome));
batch.pending[call.toolCallId] = this.buildPendingToolCall(call, outcome, ctx.runId);
return true;
case 'success':
case 'cancelled':
this.appendCompletedToolCall(call, outcome, batch.results);
break;
case 'error':
batch.errors.push({
toolCallId: call.toolCallId,
toolName: call.toolName,
input: call.input,
error: outcome.error,
});
break;
}
}
return false;
}
return await this.finalizeBatch({ results, suspensions, errors, pending }, ctx);
private buildToolCallSuspension(
call: ToolCallIdentity,
outcome: SuspendedToolOutcome,
): ToolCallSuspension {
return {
toolCallId: call.toolCallId,
toolName: call.toolName,
input: call.input,
payload: outcome.payload,
resumeSchema: outcome.resumeSchema,
};
}
private buildPendingToolCall(
call: ToolCallIdentity,
outcome: SuspendedToolOutcome,
runId: string,
): PendingToolCall {
return {
suspended: true,
toolCallId: call.toolCallId,
toolName: call.toolName,
input: call.input,
suspendPayload: outcome.payload,
resumeSchema: outcome.resumeSchema,
...(outcome.continuation !== undefined ? { continuation: outcome.continuation } : {}),
runId,
};
}
private appendCompletedToolCall(
call: ToolCallIdentity,
outcome: Extract<ToolCallOutcome, { outcome: 'success' | 'cancelled' }>,
results: ToolCallSuccess[],
): void {
const result: ToolCallSuccess = {
toolCallId: call.toolCallId,
toolName: call.toolName,
input: call.input,
toolEntry: outcome.toolEntry,
modelOutput: outcome.modelOutput,
};
if (outcome.outcome === 'success') {
result.customMessage = outcome.customMessage;
if (outcome.mcpServerName !== undefined) result.mcpServerName = outcome.mcpServerName;
}
results.push(result);
}
private skipAbortedToolCalls(
ids: Set<string>,
calls: Map<string, RuntimeToolCall>,
list: AgentMessageList,
results: ToolCallSuccess[],
): void {
this.deps.onCancelled();
for (const id of ids) {
const call = calls.get(id)!;
const modelOutput = '[Skipped: run was aborted]';
list.setToolCallResult(call.toolCallId, modelOutput, { canceled: true });
results.push(this.buildSkippedToolResult(call, modelOutput));
}
}
private buildSkippedToolResult(call: ToolCallIdentity, modelOutput: string): ToolCallSuccess {
return {
toolCallId: call.toolCallId,
toolName: call.toolName,
input: call.input,
toolEntry: {
tool: call.toolName,
input: call.input,
output: modelOutput,
transformed: false,
canceled: true,
},
modelOutput,
};
}
private retainUnexecutedToolCalls(
ids: Set<string>,
calls: Map<string, RuntimeToolCall>,
pending: Record<string, PendingToolCall>,
): void {
for (const id of ids) {
const call = calls.get(id)!;
pending[call.toolCallId] = {
suspended: false,
toolCallId: call.toolCallId,
toolName: call.toolName,
input: call.input,
};
}
}
/**
@ -509,172 +548,112 @@ export class ToolCallExecutor {
async iteratePendingToolCallsConcurrent(
ctx: ToolBatchContext & { pendingResume: PendingResume },
): Promise<ToolCallBatchResult> {
const {
pendingResume,
toolMap,
list,
runId,
persistence,
telemetry: resolvedTelemetry,
executionCounter,
abortSignal,
} = ctx;
const { pendingResume } = ctx;
const resumedId = pendingResume.resumeToolCallId;
const resumedEntry = pendingResume.pendingToolCalls[resumedId];
if (!resumedEntry) {
throw new Error(`No pending tool call found for toolCallId: ${resumedId}`);
}
const resumedToolName = resumedEntry.toolName;
const results: ToolCallSuccess[] = [];
const suspensions: ToolCallSuspension[] = [];
const errors: ToolCallError[] = [];
const pending: Record<string, PendingToolCall> = {};
// 1. Execute the resumed tool
const processResult = await this.processToolCall(
if (!resumedEntry) throw new Error(`No pending tool call found for toolCallId: ${resumedId}`);
const batch: ToolCallBatchResult = { results: [], suspensions: [], errors: [], pending: {} };
const outcome = await this.processToolCall(
this.pendingToolCallParams(resumedEntry, ctx, pendingResume.resumeData),
);
if (processResult.outcome === 'suspended') {
pending[resumedId] = {
suspended: true,
toolCallId: resumedEntry.toolCallId,
toolName: resumedToolName,
input: resumedEntry.input,
suspendPayload: processResult.payload,
resumeSchema: processResult.resumeSchema,
...(processResult.continuation !== undefined
? { continuation: processResult.continuation }
: {}),
runId,
};
suspensions.push({
toolCallId: resumedId,
toolName: resumedToolName,
input: resumedEntry.input,
payload: processResult.payload,
resumeSchema: processResult.resumeSchema,
});
} else if (processResult.outcome === 'success') {
results.push({
toolCallId: resumedEntry.toolCallId,
toolName: resumedToolName,
input: resumedEntry.input,
toolEntry: processResult.toolEntry,
modelOutput: processResult.modelOutput,
customMessage: processResult.customMessage,
...(processResult.mcpServerName !== undefined
? { mcpServerName: processResult.mcpServerName }
: {}),
});
} else if (processResult.outcome === 'cancelled') {
results.push({
toolCallId: resumedEntry.toolCallId,
toolName: resumedToolName,
input: resumedEntry.input,
toolEntry: processResult.toolEntry,
modelOutput: processResult.modelOutput,
});
list.addInput([
{ role: 'user', content: [{ type: 'text', text: processResult.userMessage }] },
]);
for (const id of Object.keys(pendingResume.pendingToolCalls)) {
if (id !== resumedId) {
const siblingEntry = pendingResume.pendingToolCalls[id];
const siblingTool = toolMap.get(siblingEntry.toolName);
const siblingParams = this.pendingToolCallParams(
siblingEntry,
ctx,
pendingResume.resumeData,
);
if (siblingEntry.suspended && siblingTool?.onCancellation) {
try {
await this.runCancellationCleanup(
siblingParams,
siblingTool,
processResult.userMessage,
);
} catch {
this.retainPendingToolCall(id, siblingEntry, pending, suspensions);
continue;
}
}
const modelOutput = '[Skipped: a sibling tool call was cancelled]';
list.setToolCallResult(id, modelOutput, {
canceled: true,
});
results.push({
toolCallId: siblingEntry.toolCallId,
toolName: siblingEntry.toolName,
input: siblingEntry.input,
toolEntry: {
tool: siblingEntry.toolName,
input: siblingEntry.input,
output: modelOutput,
transformed: false,
canceled: true,
},
modelOutput,
});
}
}
return await this.finalizeBatch({ results, suspensions, errors, pending }, ctx);
} else if (processResult.outcome === 'retryable-error') {
if (outcome.outcome === 'cancelled') {
this.appendCompletedToolCall(resumedEntry, outcome, batch.results);
ctx.list.addInput([{ role: 'user', content: [{ type: 'text', text: outcome.userMessage }] }]);
await this.cancelSiblingToolCalls(ctx, batch, outcome.userMessage);
return await this.finalizeBatch(batch, ctx);
}
if (outcome.outcome === 'retryable-error') {
for (const [id, entry] of Object.entries(pendingResume.pendingToolCalls)) {
this.retainPendingToolCall(id, entry, pending, suspensions);
this.retainPendingToolCall(id, entry, batch.pending, batch.suspensions);
}
return await this.finalizeBatch({ results, suspensions, errors, pending }, ctx);
} else if (processResult.outcome === 'error') {
errors.push({
toolCallId: resumedEntry.toolCallId,
toolName: resumedToolName,
input: resumedEntry.input,
error: processResult.error,
});
} else if (processResult.outcome === 'noop') {
// noop
return await this.finalizeBatch(batch, ctx);
}
// 2. Process remaining items
const unexecuted: Array<{ toolCallId: string; toolName: string; input: unknown }> = [];
this.collectResumedToolCall(resumedId, resumedEntry, outcome, batch, ctx.runId);
await this.executeRemainingToolCalls(ctx, batch);
return await this.finalizeBatch(batch, ctx);
}
for (const [id, entry] of Object.entries(pendingResume.pendingToolCalls)) {
if (id === resumedId) continue;
if (entry.suspended) {
this.retainPendingToolCall(id, entry, pending, suspensions);
} else {
// Unexecuted — collect for batch execution
unexecuted.push({
toolCallId: id,
private collectResumedToolCall(
id: string,
entry: PendingToolCall,
outcome: ToolCallOutcome,
batch: ToolCallBatchResult,
runId: string,
): void {
switch (outcome.outcome) {
case 'suspended':
batch.pending[id] = this.buildPendingToolCall(entry, outcome, runId);
batch.suspensions.push(this.buildToolCallSuspension({ ...entry, toolCallId: id }, outcome));
break;
case 'success':
this.appendCompletedToolCall(entry, outcome, batch.results);
break;
case 'error':
batch.errors.push({
toolCallId: entry.toolCallId,
toolName: entry.toolName,
input: entry.input,
error: outcome.error,
});
break;
}
}
private async cancelSiblingToolCalls(
ctx: ToolBatchContext & { pendingResume: PendingResume },
batch: ToolCallBatchResult,
userMessage: string,
): Promise<void> {
for (const id of Object.keys(ctx.pendingResume.pendingToolCalls)) {
if (id === ctx.pendingResume.resumeToolCallId) continue;
const entry = ctx.pendingResume.pendingToolCalls[id];
const tool = ctx.toolMap.get(entry.toolName);
const params = this.pendingToolCallParams(entry, ctx, ctx.pendingResume.resumeData);
if (entry.suspended && tool?.onCancellation) {
try {
await this.runCancellationCleanup(params, tool, userMessage);
} catch {
this.retainPendingToolCall(id, entry, batch.pending, batch.suspensions);
continue;
}
}
const modelOutput = '[Skipped: a sibling tool call was cancelled]';
ctx.list.setToolCallResult(id, modelOutput, { canceled: true });
batch.results.push(this.buildSkippedToolResult(entry, modelOutput));
}
}
private async executeRemainingToolCalls(
ctx: ToolBatchContext & { pendingResume: PendingResume },
result: ToolCallBatchResult,
): Promise<void> {
const unexecuted: ToolCallInput[] = [];
for (const [id, entry] of Object.entries(ctx.pendingResume.pendingToolCalls)) {
if (id === ctx.pendingResume.resumeToolCallId) continue;
if (entry.suspended) {
this.retainPendingToolCall(id, entry, result.pending, result.suspensions);
} else {
unexecuted.push({ toolCallId: id, toolName: entry.toolName, input: entry.input });
}
}
// Execute unexecuted tools via iterateToolCallsConcurrent
if (unexecuted.length > 0) {
const batch = await this.iterateToolCallsConcurrent({
toolCalls: unexecuted,
toolMap,
list,
runId,
persistence,
telemetry: resolvedTelemetry,
executionCounter,
abortSignal,
isAborted: ctx.isAborted,
});
results.push(...batch.results);
suspensions.push(...batch.suspensions);
errors.push(...batch.errors);
Object.assign(pending, batch.pending);
}
return await this.finalizeBatch({ results, suspensions, errors, pending }, ctx);
if (unexecuted.length === 0) return;
const batch = await this.iterateToolCallsConcurrent({
toolCalls: unexecuted,
toolMap: ctx.toolMap,
list: ctx.list,
runId: ctx.runId,
persistence: ctx.persistence,
telemetry: ctx.telemetry,
executionCounter: ctx.executionCounter,
abortSignal: ctx.abortSignal,
isAborted: ctx.isAborted,
});
result.results.push(...batch.results);
result.suspensions.push(...batch.suspensions);
result.errors.push(...batch.errors);
Object.assign(result.pending, batch.pending);
}
/**
@ -728,61 +707,83 @@ export class ToolCallExecutor {
});
}
return await this.executeValidatedToolCall(params, builtTool, input);
}
private async executeValidatedToolCall(
params: ProcessToolCallParams,
builtTool: BuiltTool,
input: JSONValue,
): Promise<ToolCallOutcome> {
let toolResult: unknown;
let interruptedSuspendPayload: unknown;
let interruptedSuspendOptions: ToolSuspendOptions | undefined;
let didSuspend = false;
let abortObserved = false;
let suspensionCleanup: Promise<void> | undefined;
const cleanupInterruptedSuspension = async () => {
suspensionCleanup ??= this.runCancellationCleanup(
{
...params,
input,
suspendPayload: interruptedSuspendPayload,
continuation: interruptedSuspendOptions?.continuation,
resumeSchema: getToolResumeJsonSchema(builtTool, interruptedSuspendOptions?.resumeSchema),
},
builtTool,
'Run aborted',
).catch(() => undefined);
await suspensionCleanup;
const suspension: InterruptedToolSuspension = {
didSuspend: false,
abortObserved: false,
payload: undefined,
};
try {
toolResult = await this.runToolHandler(params, builtTool, input, async (payload, options) => {
didSuspend = true;
interruptedSuspendPayload = payload;
interruptedSuspendOptions = options;
if (abortObserved || params.abortSignal?.aborted) {
await cleanupInterruptedSuspension();
suspension.didSuspend = true;
suspension.payload = payload;
suspension.options = options;
if (suspension.abortObserved || params.abortSignal?.aborted) {
await this.cleanupInterruptedSuspension(params, builtTool, input, suspension);
}
});
} catch (error) {
if (isAbortError(error) || params.abortSignal?.aborted) {
abortObserved = true;
if (didSuspend) {
await cleanupInterruptedSuspension();
} else if (params.suspendPayload !== undefined || params.continuation !== undefined) {
try {
await this.runCancellationCleanup({ ...params, input }, builtTool, 'Run aborted');
} catch {
// Parent shutdown must continue; persistent stores will prune stale checkpoints.
}
}
suspension.abortObserved = true;
await this.cleanupAbortedTool(params, builtTool, input, suspension);
this.deps.onCancelled();
return this.buildCancelledOutcome(params, 'Run aborted');
}
return await this.toolError(params, error, builtTool);
}
if (isSuspendedToolResult(toolResult)) {
return await this.buildSuspendedOutcome(params, builtTool, toolResult);
}
return await this.buildSuccessOutcome(params, builtTool, input, toolResult);
}
private async cleanupInterruptedSuspension(
params: ProcessToolCallParams,
builtTool: BuiltTool,
input: JSONValue,
suspension: InterruptedToolSuspension,
): Promise<void> {
// Both abort and late suspension can reach this path. Run cleanup only once.
suspension.cleanup ??= this.runCancellationCleanup(
{
...params,
input,
suspendPayload: suspension.payload,
continuation: suspension.options?.continuation,
resumeSchema: getToolResumeJsonSchema(builtTool, suspension.options?.resumeSchema),
},
builtTool,
'Run aborted',
).catch(() => undefined);
await suspension.cleanup;
}
private async cleanupAbortedTool(
params: ProcessToolCallParams,
builtTool: BuiltTool,
input: JSONValue,
suspension: InterruptedToolSuspension,
): Promise<void> {
if (suspension.didSuspend) {
await this.cleanupInterruptedSuspension(params, builtTool, input, suspension);
} else if (params.suspendPayload !== undefined || params.continuation !== undefined) {
try {
await this.runCancellationCleanup({ ...params, input }, builtTool, 'Run aborted');
} catch {
// Parent shutdown must continue; persistent stores will prune stale checkpoints.
}
}
}
private async runCancellationCleanup(
params: ProcessToolCallParams,
builtTool: BuiltTool,
@ -1095,24 +1096,12 @@ export class ToolCallExecutor {
list.setToolCallResult(toolCallId, guardedResult.historyOutput);
let customMessage = await builtTool.toMessage?.(toolResult);
if (customMessage && builtTool.outputTrust === 'untrusted') {
customMessage = protectUntrustedToolMessage(customMessage, builtTool);
}
let guardedCustomMessage = customMessage
? await guardToolMessageForModel(customMessage, this.deps.tokenCounter, storage)
: undefined;
// Stamp tool provenance so derived transcripts (e.g. the observation
// log observer) can keep this content inside untrusted-data boundaries.
if (guardedCustomMessage && 'role' in guardedCustomMessage) {
guardedCustomMessage = {
...guardedCustomMessage,
origin: { kind: 'tool', toolName },
};
}
if (guardedCustomMessage) {
list.addResponse([guardedCustomMessage]);
}
const guardedCustomMessage = await this.appendCustomToolMessage(
params,
builtTool,
toolResult,
storage,
);
return {
outcome: 'success',
@ -1128,6 +1117,33 @@ export class ToolCallExecutor {
};
}
private async appendCustomToolMessage(
params: ProcessToolCallParams,
builtTool: BuiltTool,
toolResult: unknown,
storage: ToolResultGuardStorage | undefined,
): Promise<AgentMessage | undefined> {
let customMessage = await builtTool.toMessage?.(toolResult);
if (customMessage && builtTool.outputTrust === 'untrusted') {
customMessage = protectUntrustedToolMessage(customMessage, builtTool);
}
let guardedCustomMessage = customMessage
? await guardToolMessageForModel(customMessage, this.deps.tokenCounter, storage)
: undefined;
// Stamp tool provenance so derived transcripts (e.g. the observation
// log observer) can keep this content inside untrusted-data boundaries.
if (guardedCustomMessage && 'role' in guardedCustomMessage) {
guardedCustomMessage = {
...guardedCustomMessage,
origin: { kind: 'tool', toolName: params.toolName },
};
}
if (guardedCustomMessage) {
params.list.addResponse([guardedCustomMessage]);
}
return guardedCustomMessage;
}
private getResultStorage(params: ProcessToolCallParams): ToolResultGuardStorage | undefined {
const filesystem = this.deps.workspaceFilesystem;
if (!filesystem) return undefined;

View File

@ -197,39 +197,41 @@ export function sanitizeOffloadedToolResultsForMemory(
return messages.map((message) => {
if (!('content' in message)) return { ...message };
const content = message.content.map((block): MessageContent => {
if (block.type === 'tool-call') {
if (block.state === 'resolved' && isOffloadedToolResult(block.output)) {
return { ...block, output: { ...EXPIRED_OFFLOADED_TOOL_RESULT } };
}
if (block.state === 'resolved' && isContentToolResultOutput(block.output)) {
return {
...block,
output: toJsonValue({
type: 'content',
value: block.output.value.map((part) =>
part.type === 'text' && isSerializedOffloadedToolResult(part.text)
? { ...part, text: EXPIRED_OFFLOADED_TOOL_RESULT_JSON }
: part,
),
}),
};
}
if (block.state === 'rejected' && isSerializedOffloadedToolResult(block.error)) {
return { ...block, error: EXPIRED_OFFLOADED_TOOL_RESULT_JSON };
}
}
if (block.type === 'text' && isSerializedOffloadedToolResult(block.text)) {
return { ...block, text: EXPIRED_OFFLOADED_TOOL_RESULT_JSON };
}
return { ...block };
});
const content = message.content.map(sanitizeOffloadedContentBlock);
return { ...message, content };
});
}
function sanitizeOffloadedContentBlock(block: MessageContent): MessageContent {
if (block.type === 'tool-call') {
if (block.state === 'resolved' && isOffloadedToolResult(block.output)) {
return { ...block, output: { ...EXPIRED_OFFLOADED_TOOL_RESULT } };
}
if (block.state === 'resolved' && isContentToolResultOutput(block.output)) {
return { ...block, output: sanitizeOffloadedContentResult(block.output) };
}
if (block.state === 'rejected' && isSerializedOffloadedToolResult(block.error)) {
return { ...block, error: EXPIRED_OFFLOADED_TOOL_RESULT_JSON };
}
}
if (block.type === 'text' && isSerializedOffloadedToolResult(block.text)) {
return { ...block, text: EXPIRED_OFFLOADED_TOOL_RESULT_JSON };
}
return { ...block };
}
function sanitizeOffloadedContentResult(output: ContentToolResultOutput): JSONValue {
return toJsonValue({
type: 'content',
value: output.value.map((part) => {
if (part.type !== 'text' || !isSerializedOffloadedToolResult(part.text)) return part;
return { ...part, text: EXPIRED_OFFLOADED_TOOL_RESULT_JSON };
}),
});
}
async function tryOffloadResult(
serialized: string,
tokenCount: number,