mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-01 05:38:33 +08:00
1063 lines
37 KiB
TypeScript
1063 lines
37 KiB
TypeScript
import type { ProviderOptions } from '@ai-sdk/provider-utils';
|
|
import type { TelemetryOptions, ToolCallRepairFunction, ToolSet } from 'ai';
|
|
import type { JSONSchema7 } from 'json-schema';
|
|
import type { z } from 'zod';
|
|
|
|
import { incrementMessageCount, incrementTokenCountFromUsage } from './execution-counter';
|
|
import { GenerateSink } from './generate-sink';
|
|
import type { RunOutputSink, RunServices } from './run-output-sink';
|
|
import { RuntimeContextBuilder, getModelIdString } from './runtime-context';
|
|
import {
|
|
extractSettledToolCalls,
|
|
formatMcpConnectionNote,
|
|
makeErrorStream,
|
|
mergeUsage,
|
|
normalizeInput,
|
|
} from './runtime-helpers';
|
|
import { StreamSink } from './stream-sink';
|
|
import { isCancellation } from '../../sdk/cancellation';
|
|
import { computeCost, getModelCost, type ModelCost } from '../../sdk/catalog';
|
|
import type {
|
|
BuiltMemory,
|
|
BuiltProviderTool,
|
|
BuiltTelemetry,
|
|
BuiltTool,
|
|
CheckpointStore,
|
|
EpisodicMemoryConfig,
|
|
FinishReason,
|
|
GenerateResult,
|
|
McpConnectionFailedEvent,
|
|
ObservationalMemoryConfig,
|
|
ObservationLogMemoryConfig,
|
|
PendingToolCall,
|
|
RunOptions,
|
|
SerializableAgentState,
|
|
StreamChunk,
|
|
StreamResult,
|
|
ThinkingConfig,
|
|
TitleGenerationConfig,
|
|
TokenUsage,
|
|
} from '../../types';
|
|
import { AgentEvent } from '../../types/runtime/event';
|
|
import type {
|
|
ExecutionOptions,
|
|
ModelConfig,
|
|
PersistedExecutionOptions,
|
|
PromptCachingConfig,
|
|
} from '../../types/sdk/agent';
|
|
import type { AgentMessage, ContentToolCall } from '../../types/sdk/message';
|
|
import type { JSONValue } from '../../types/utils/json';
|
|
import { parseWithSchema } from '../../utils/parse';
|
|
import { MemoryOrchestrator } from '../memory/memory-orchestrator';
|
|
import type { ScopedMemoryTaskEvent } from '../memory/scoped-memory-task-runner';
|
|
import { generateThreadTitle } from '../memory/title-generation';
|
|
import { AgentMessageList, type SerializedMessageList } from '../model/message-list';
|
|
import type { FetchFn } from '../model/model-factory';
|
|
import {
|
|
applyRuntimeCacheBreakpoints,
|
|
buildInstructionPromptCacheOptions,
|
|
getEffectiveAnthropicCacheTtl,
|
|
mergeProviderOptions,
|
|
} from '../model/prompt-cache';
|
|
import { BackgroundTaskTracker } from '../state/background-task-tracker';
|
|
import { AgentEventBus, type AgentAbortScope } from '../state/event-bus';
|
|
import { generateRunId, RunStateManager } from '../state/run-state';
|
|
import { startStreamSession } from '../streaming/stream-session';
|
|
import { RuntimeTelemetry } from '../telemetry/runtime-telemetry';
|
|
import { DeferredToolManager } from '../tools/deferred-tool-manager';
|
|
import { fixToolCall } from '../tools/fix-tool-call';
|
|
import {
|
|
ToolCallExecutor,
|
|
type PendingResume,
|
|
type ToolBatchContext,
|
|
} from '../tools/tool-call-executor';
|
|
|
|
export interface AgentRuntimeConfig {
|
|
name: string;
|
|
model: ModelConfig;
|
|
/**
|
|
* Proxy-aware `fetch` used for all model calls in this runtime (main model and
|
|
* title generation). When unset, model construction falls back to the ambient
|
|
* HTTP_PROXY resolver.
|
|
*/
|
|
modelFetch?: FetchFn;
|
|
instructions: string;
|
|
instructionProviderOptions?: ProviderOptions;
|
|
tools?: BuiltTool[];
|
|
deferredTools?: BuiltTool[];
|
|
toolSearch?: {
|
|
topK?: number;
|
|
};
|
|
providerTools?: BuiltProviderTool[];
|
|
memory?: BuiltMemory;
|
|
observationLog?: ObservationLogMemoryConfig;
|
|
observationalMemory?: ObservationalMemoryConfig;
|
|
episodicMemory?: EpisodicMemoryConfig;
|
|
structuredOutput?: z.ZodType | JSONSchema7;
|
|
checkpointStorage?: 'memory' | CheckpointStore;
|
|
thinking?: ThinkingConfig;
|
|
promptCaching?: PromptCachingConfig;
|
|
eventBus?: AgentEventBus;
|
|
/** Number of tool calls to execute concurrently. Default `1` (sequential). */
|
|
toolCallConcurrency?: number;
|
|
titleGeneration?: TitleGenerationConfig;
|
|
telemetry?: BuiltTelemetry;
|
|
/** Existing run id to continue, used when resuming a suspended run. */
|
|
runId?: string;
|
|
/**
|
|
* Pre-fetched model cost from the catalog. When provided, skips the per-run
|
|
* catalog fetch. Set once during Agent.build() and shared across per-run runtimes.
|
|
*/
|
|
modelCost?: ModelCost;
|
|
/**
|
|
* Shared RunStateManager for suspend/resume. When provided, per-run runtimes
|
|
* use the same store so resume() can find state from a prior run.
|
|
*/
|
|
runState?: RunStateManager;
|
|
/** Host callback for observational-memory background task lifecycle events. */
|
|
onMemoryTaskEvent?: (event: ScopedMemoryTaskEvent) => void;
|
|
/**
|
|
* Per-server MCP connection failures recorded during `Agent.build()` when
|
|
* resolving MCP tools. Tools from these servers were skipped; the runtime
|
|
* surfaces each as a non-fatal `warning` stream chunk at the start of a
|
|
* stream so hosts can tell the user an MCP server was unavailable without
|
|
* aborting the run.
|
|
*/
|
|
mcpConnectionFailures?: McpConnectionFailedEvent[];
|
|
}
|
|
|
|
const MAX_LOOP_ITERATIONS = 30;
|
|
|
|
const EMPTY_MESSAGE_LIST: SerializedMessageList = {
|
|
messages: [],
|
|
historyIds: [],
|
|
inputIds: [],
|
|
responseIds: [],
|
|
};
|
|
|
|
type RuntimeExecutionOptions = RunOptions & ExecutionOptions & { iterationCount?: number };
|
|
|
|
/** Shared input for the private generate/stream loops. */
|
|
interface LoopContext {
|
|
list: AgentMessageList;
|
|
options?: RuntimeExecutionOptions;
|
|
abortScope: AgentAbortScope;
|
|
pendingResume?: PendingResume;
|
|
}
|
|
|
|
/**
|
|
* Core agent execution engine using the Vercel AI SDK directly.
|
|
*
|
|
* - `generate()` uses `generateText()` — no streaming internally.
|
|
* - `stream()` uses `streamText()` — yields chunks in real time.
|
|
*
|
|
* Memory strategy:
|
|
* - `filterLlmMessages` strips custom messages before sending to the LLM.
|
|
* - Memory stores ALL AgentMessages (including custom) unchanged.
|
|
* - New messages for each turn are tracked via AgentMessageList.turnDelta(),
|
|
* which uses Set-based source tracking to identify turn-only messages.
|
|
* The list serializes with id-based sets so it can survive process restarts.
|
|
*/
|
|
export class AgentRuntime {
|
|
private config: AgentRuntimeConfig;
|
|
|
|
private runState: RunStateManager;
|
|
|
|
private eventBus: AgentEventBus;
|
|
|
|
private currentState: SerializableAgentState;
|
|
|
|
private modelCost: ModelCost | undefined;
|
|
|
|
private backgroundTasks = new BackgroundTaskTracker();
|
|
|
|
private deferredToolManager: DeferredToolManager | undefined;
|
|
|
|
private runId: string;
|
|
|
|
private telemetry: RuntimeTelemetry;
|
|
|
|
private memory: MemoryOrchestrator;
|
|
|
|
private context: RuntimeContextBuilder;
|
|
|
|
private toolExecutor: ToolCallExecutor;
|
|
|
|
constructor(config: AgentRuntimeConfig) {
|
|
this.config = config;
|
|
this.telemetry = new RuntimeTelemetry(config);
|
|
this.runId = config.runId ?? generateRunId();
|
|
if (config.deferredTools && config.deferredTools.length > 0) {
|
|
this.deferredToolManager = new DeferredToolManager(config.deferredTools, config.toolSearch);
|
|
}
|
|
this.context = new RuntimeContextBuilder(config, this.deferredToolManager);
|
|
this.runState = config.runState ?? new RunStateManager(config.checkpointStorage);
|
|
this.eventBus = config.eventBus ?? new AgentEventBus();
|
|
this.memory = new MemoryOrchestrator(
|
|
config,
|
|
this.backgroundTasks,
|
|
this.eventBus,
|
|
this.telemetry,
|
|
);
|
|
this.toolExecutor = new ToolCallExecutor({
|
|
telemetry: this.telemetry,
|
|
eventBus: this.eventBus,
|
|
concurrency: config.toolCallConcurrency ?? 1,
|
|
onCancelled: () => this.updateState({ status: 'cancelled' }),
|
|
});
|
|
this.modelCost = config.modelCost;
|
|
this.currentState = {
|
|
persistence: undefined,
|
|
status: 'idle',
|
|
messageList: EMPTY_MESSAGE_LIST,
|
|
pendingToolCalls: {},
|
|
};
|
|
}
|
|
|
|
setTelemetry(telemetry: BuiltTelemetry | undefined): void {
|
|
this.config.telemetry = telemetry;
|
|
}
|
|
|
|
/**
|
|
* Wait for in-flight background tasks (title generation, future
|
|
* observer cycles) to settle. Safe to call multiple times.
|
|
*/
|
|
async dispose(): Promise<void> {
|
|
this.eventBus.dispose();
|
|
await this.backgroundTasks.flush();
|
|
}
|
|
|
|
/** Return the latest state snapshot. */
|
|
getState(): SerializableAgentState {
|
|
return { ...this.currentState };
|
|
}
|
|
|
|
/** Set the abort flag to cancel the currently running agent. */
|
|
abort(): void {
|
|
this.eventBus.abort();
|
|
}
|
|
|
|
/**
|
|
* Non-streaming: run the full agent loop using generateText and return the
|
|
* final result. Errors are returned on the result (`finishReason: 'error'`,
|
|
* `error` field) rather than thrown, so callers always receive a
|
|
* `GenerateResult`. The streaming path (`stream()`) emits error + finish
|
|
* chunks instead.
|
|
*/
|
|
async generate(
|
|
input: AgentMessage[] | string,
|
|
options?: RunOptions & ExecutionOptions,
|
|
): Promise<GenerateResult> {
|
|
const abortScope = this.eventBus.createAbortScope(options?.abortSignal);
|
|
let list: AgentMessageList | undefined = undefined;
|
|
try {
|
|
const initializedList = await this.initRun(input, options);
|
|
list = initializedList;
|
|
const sink = new GenerateSink(this.createRunServices());
|
|
const rawResult = await this.telemetry.withRootSpan(
|
|
'generate',
|
|
options,
|
|
this.runId,
|
|
async () =>
|
|
await this.runAgentLoop<GenerateResult>(
|
|
{ list: initializedList, options, abortScope },
|
|
sink,
|
|
),
|
|
);
|
|
return this.finalizeGenerate(rawResult, list);
|
|
} catch (error) {
|
|
await this.telemetry.flush(options);
|
|
const isAbort = abortScope.isAborted;
|
|
this.updateState({ status: isAbort ? 'cancelled' : 'failed' });
|
|
if (isAbort) {
|
|
// Durably save the turn-so-far so a cancelled run still leaves its assistant
|
|
// work in memory (mirrors the suspend-time save). Best-effort.
|
|
if (list) await this.memory.persistTurnDelta(list, options);
|
|
} else {
|
|
this.eventBus.emit({ type: AgentEvent.Error, message: String(error), error });
|
|
}
|
|
return {
|
|
runId: this.runId,
|
|
messages: list?.responseDelta() ?? [],
|
|
finishReason: 'error',
|
|
error,
|
|
getState: () => this.getState(),
|
|
};
|
|
} finally {
|
|
abortScope.dispose();
|
|
}
|
|
}
|
|
|
|
/** Streaming: run the agent loop using streamText, yielding chunks in real time. */
|
|
async stream(
|
|
input: AgentMessage[] | string,
|
|
options?: RunOptions & ExecutionOptions,
|
|
): Promise<StreamResult> {
|
|
const abortScope = this.eventBus.createAbortScope(options?.abortSignal);
|
|
let list: AgentMessageList;
|
|
try {
|
|
list = await this.initRun(input, options);
|
|
} catch (error) {
|
|
const isAbort = abortScope.isAborted;
|
|
this.updateState({ status: isAbort ? 'cancelled' : 'failed' });
|
|
if (!isAbort) {
|
|
this.eventBus.emit({ type: AgentEvent.Error, message: String(error), error });
|
|
}
|
|
abortScope.dispose();
|
|
return { runId: this.runId, stream: makeErrorStream(error), getState: () => this.getState() };
|
|
}
|
|
|
|
return {
|
|
runId: this.runId,
|
|
stream: this.startStream({ list, options, abortScope }),
|
|
getState: () => this.getState(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Resume a suspended tool call with arbitrary data.
|
|
* Restores the suspended run state, passes pending tool calls into the loop
|
|
* as pending tool calls, and delegates to the same generate/stream loop used for fresh runs.
|
|
*
|
|
* - `'generate'` — runs the generate loop after tool execution, returns `GenerateResult`.
|
|
* - `'stream'` — runs the stream loop after tool execution, returns `StreamResult`.
|
|
*/
|
|
async resume(
|
|
method: 'generate',
|
|
data: unknown,
|
|
options: { runId: string; toolCallId: string } & ExecutionOptions,
|
|
): Promise<GenerateResult>;
|
|
async resume(
|
|
method: 'stream',
|
|
data: unknown,
|
|
options: { runId: string; toolCallId: string } & ExecutionOptions,
|
|
): Promise<StreamResult>;
|
|
async resume(
|
|
method: 'generate' | 'stream',
|
|
data: unknown,
|
|
options: { runId: string; toolCallId: string } & ExecutionOptions,
|
|
): Promise<GenerateResult | StreamResult> {
|
|
this.runId = options.runId;
|
|
const state = await this.runState.resume(this.runId);
|
|
if (!state) throw new Error(`No suspended run found for runId: ${this.runId}`);
|
|
|
|
const toolCall = state.pendingToolCalls[options.toolCallId];
|
|
if (!toolCall) throw new Error(`No tool call found for toolCallId: ${options.toolCallId}`);
|
|
|
|
const list = AgentMessageList.deserialize(state.messageList);
|
|
this.context.hydrateDeferredToolsFromList(list);
|
|
|
|
const toolForValidation = this.context
|
|
.getCurrentTools(state.persistence)
|
|
.find((t) => t.name === toolCall.toolName);
|
|
if (!toolForValidation) throw new Error(`Tool ${toolCall.toolName} not found`);
|
|
|
|
let resumeData: unknown = data;
|
|
let abortScope: AgentAbortScope | undefined;
|
|
|
|
if (!isCancellation(resumeData) && toolForValidation.resumeSchema) {
|
|
const parseResult = await parseWithSchema(toolForValidation.resumeSchema, data);
|
|
if (!parseResult.success) {
|
|
throw new Error(`Invalid resume payload: ${parseResult.error}`);
|
|
}
|
|
resumeData = parseResult.data as JSONValue;
|
|
}
|
|
|
|
try {
|
|
// Merge persisted execution options with fresh caller options
|
|
const { runId: _rid, toolCallId: _tcid, ...callerExecOptions } = options;
|
|
const persisted = state.executionOptions ?? {};
|
|
const persistedMaxIterations = persisted.maxIterations;
|
|
const callerMaxIterations = callerExecOptions.maxIterations;
|
|
if (
|
|
callerMaxIterations !== undefined &&
|
|
persistedMaxIterations !== undefined &&
|
|
callerMaxIterations < persistedMaxIterations
|
|
) {
|
|
throw new Error(
|
|
`Cannot decrease maxIterations when resuming a run. Expected >= ${persistedMaxIterations}, received ${callerMaxIterations}.`,
|
|
);
|
|
}
|
|
|
|
const mergedMaxIterations = callerMaxIterations ?? persistedMaxIterations;
|
|
const mergedExecOptions: ExecutionOptions & { iterationCount?: number } = {
|
|
...callerExecOptions,
|
|
...(mergedMaxIterations !== undefined ? { maxIterations: mergedMaxIterations } : {}),
|
|
...(state.iterationCount !== undefined ? { iterationCount: state.iterationCount } : {}),
|
|
};
|
|
|
|
const tool = this.context
|
|
.getCurrentTools(state.persistence, mergedExecOptions.executionCounter)
|
|
.find((t) => t.name === toolCall.toolName);
|
|
if (!tool) throw new Error(`Tool ${toolCall.toolName} not found`);
|
|
|
|
const resumeOptions: RuntimeExecutionOptions = {
|
|
persistence: state.persistence,
|
|
...mergedExecOptions,
|
|
};
|
|
|
|
abortScope = this.eventBus.createAbortScope(resumeOptions.abortSignal);
|
|
const activeAbortScope = abortScope;
|
|
|
|
const pendingResume: PendingResume = {
|
|
pendingToolCalls: state.pendingToolCalls,
|
|
resumeToolCallId: options.toolCallId,
|
|
resumeData,
|
|
};
|
|
|
|
await this.ensureModelCost();
|
|
|
|
await this.memory.setListObservationLogMemory(list, state.persistence);
|
|
|
|
const claimed = await this.runState.claimResume(this.runId, state);
|
|
if (!claimed) {
|
|
throw new Error(`Run ${this.runId} is not suspended. Cannot resume.`);
|
|
}
|
|
|
|
if (method === 'generate') {
|
|
const sink = new GenerateSink(this.createRunServices());
|
|
const rawResult = await this.telemetry.withRootSpan(
|
|
'generate',
|
|
resumeOptions,
|
|
this.runId,
|
|
async () =>
|
|
await this.runAgentLoop<GenerateResult>(
|
|
{
|
|
list,
|
|
options: resumeOptions,
|
|
abortScope: activeAbortScope,
|
|
pendingResume,
|
|
},
|
|
sink,
|
|
),
|
|
);
|
|
try {
|
|
return this.finalizeGenerate(rawResult, list);
|
|
} finally {
|
|
abortScope.dispose();
|
|
}
|
|
}
|
|
|
|
return {
|
|
runId: this.runId,
|
|
stream: this.startStream({
|
|
list,
|
|
options: resumeOptions,
|
|
abortScope: activeAbortScope,
|
|
pendingResume,
|
|
}),
|
|
getState: () => this.getState(),
|
|
};
|
|
} catch (error) {
|
|
const isAbort = abortScope?.isAborted ?? false;
|
|
abortScope?.dispose();
|
|
this.updateState({ status: isAbort ? 'cancelled' : 'failed' });
|
|
if (!isAbort) {
|
|
this.eventBus.emit({ type: AgentEvent.Error, message: String(error), error });
|
|
}
|
|
if (method === 'generate') {
|
|
return {
|
|
runId: this.runId,
|
|
messages: [],
|
|
finishReason: 'error' as const,
|
|
error,
|
|
getState: () => this.getState(),
|
|
};
|
|
}
|
|
return { runId: this.runId, stream: makeErrorStream(error), getState: () => this.getState() };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Durable-log RFC (resilience phase): re-drive a run from a `running`-status
|
|
* step checkpoint after a process crash. Unlike resume(), there is no
|
|
* pending tool call to settle — the checkpoint was written at a step
|
|
* boundary — so the loop re-enters directly at the next model call.
|
|
* `contextNotes` are appended as user messages before the model call: the
|
|
* host uses them to surface interrupted tool calls ("effect unverified —
|
|
* verify before retrying") and undrained steering corrections recovered
|
|
* from its durable event log. Tool calls are never re-executed mechanically.
|
|
*/
|
|
async crashResume(
|
|
options: { runId: string; contextNotes?: string[] } & ExecutionOptions,
|
|
): Promise<StreamResult> {
|
|
this.runId = options.runId;
|
|
const state = await this.runState.loadForCrashResume(this.runId);
|
|
if (!state) throw new Error(`No checkpoint found for runId: ${this.runId}`);
|
|
if (state.status !== 'running') {
|
|
throw new Error(
|
|
`Checkpoint for runId ${this.runId} has status '${state.status}' — crashResume only accepts step checkpoints; use resume() for suspended runs`,
|
|
);
|
|
}
|
|
// A claimed HITL resume also persists as 'running' but still carries its
|
|
// pending tool calls; re-driving it would skip settling them. Step
|
|
// checkpoints are always written with empty pendingToolCalls.
|
|
if (Object.keys(state.pendingToolCalls).length > 0) {
|
|
throw new Error(
|
|
`Checkpoint for runId ${this.runId} has pending tool calls — crashResume only accepts step checkpoints`,
|
|
);
|
|
}
|
|
|
|
const list = AgentMessageList.deserialize(state.messageList);
|
|
this.context.hydrateDeferredToolsFromList(list);
|
|
|
|
let abortScope: AgentAbortScope | undefined;
|
|
try {
|
|
const { runId: _rid, contextNotes, ...callerExecOptions } = options;
|
|
const persisted = state.executionOptions ?? {};
|
|
const persistedMaxIterations = persisted.maxIterations;
|
|
const callerMaxIterations = callerExecOptions.maxIterations;
|
|
if (
|
|
callerMaxIterations !== undefined &&
|
|
persistedMaxIterations !== undefined &&
|
|
callerMaxIterations < persistedMaxIterations
|
|
) {
|
|
throw new Error(
|
|
`Cannot decrease maxIterations when resuming a run. Expected >= ${persistedMaxIterations}, received ${callerMaxIterations}.`,
|
|
);
|
|
}
|
|
const mergedMaxIterations = callerMaxIterations ?? persistedMaxIterations;
|
|
const resumeOptions: RuntimeExecutionOptions = {
|
|
persistence: state.persistence,
|
|
...callerExecOptions,
|
|
...(mergedMaxIterations !== undefined ? { maxIterations: mergedMaxIterations } : {}),
|
|
...(state.iterationCount !== undefined ? { iterationCount: state.iterationCount } : {}),
|
|
};
|
|
|
|
for (const note of contextNotes ?? []) {
|
|
list.addInput([{ role: 'user', content: [{ type: 'text', text: note }] }]);
|
|
}
|
|
|
|
abortScope = this.eventBus.createAbortScope(resumeOptions.abortSignal);
|
|
const activeAbortScope = abortScope;
|
|
|
|
await this.ensureModelCost();
|
|
await this.memory.setListObservationLogMemory(list, state.persistence);
|
|
|
|
return {
|
|
runId: this.runId,
|
|
stream: this.startStream({
|
|
list,
|
|
options: resumeOptions,
|
|
abortScope: activeAbortScope,
|
|
}),
|
|
getState: () => this.getState(),
|
|
};
|
|
} catch (error) {
|
|
const isAbort = abortScope?.isAborted ?? false;
|
|
abortScope?.dispose();
|
|
this.updateState({ status: isAbort ? 'cancelled' : 'failed' });
|
|
if (!isAbort) {
|
|
this.eventBus.emit({ type: AgentEvent.Error, message: String(error), error });
|
|
}
|
|
return { runId: this.runId, stream: makeErrorStream(error), getState: () => this.getState() };
|
|
}
|
|
}
|
|
|
|
// --- Private ---
|
|
|
|
/**
|
|
* Build an AgentMessageList for the current turn:
|
|
* - Loads memory history (if configured) and adds as historySet
|
|
* - Adds raw input (custom messages preserved) as inputSet
|
|
*
|
|
* The system prompt is NOT stored in the list; list.forLlm(instructions)
|
|
* prepends it at every LLM call site.
|
|
*/
|
|
private async buildMessageList(
|
|
input: AgentMessage[],
|
|
options?: RunOptions & ExecutionOptions,
|
|
): Promise<AgentMessageList> {
|
|
const list = new AgentMessageList();
|
|
await this.memory.loadInto(list, options);
|
|
list.addInput(input);
|
|
|
|
// Persist input now (after history load, so the prompt isn't polluted) so it
|
|
// survives an abort or abandoned HITL suspend that never reaches finishComplete.
|
|
// Best-effort: persistInputMessages swallows failures — the end-of-turn save
|
|
// is authoritative for completed turns, so this must not abort the turn.
|
|
await this.memory.persistInputMessages(list, options);
|
|
|
|
return list;
|
|
}
|
|
|
|
/**
|
|
* Common setup for generate() and stream(): reset abort state, transition to running,
|
|
* emit AgentStart, fetch model cost, normalize input, and build the message list.
|
|
* Throws if buildMessageList fails; callers catch and handle the error.
|
|
*/
|
|
private async initRun(
|
|
input: AgentMessage[] | string,
|
|
options?: RunOptions & ExecutionOptions,
|
|
): Promise<AgentMessageList> {
|
|
this.updateState({
|
|
status: 'running',
|
|
persistence: options?.persistence,
|
|
});
|
|
this.eventBus.emit({ type: AgentEvent.AgentStart });
|
|
await this.ensureModelCost();
|
|
const normalizedInput = normalizeInput(input);
|
|
incrementMessageCount(options?.executionCounter);
|
|
return await this.buildMessageList(normalizedInput, options);
|
|
}
|
|
|
|
/**
|
|
* Post-loop finalization for generate: apply cost, set model id, roll up sub-agent usage,
|
|
* transition to success, and emit AgentEnd. Returns the finalized result.
|
|
*/
|
|
private finalizeGenerate(result: GenerateResult, list: AgentMessageList): GenerateResult {
|
|
result.runId = this.runId;
|
|
result.usage = this.applyCost(result.usage);
|
|
result.model = this.modelIdString;
|
|
this.updateState({ status: 'success', messageList: list.serialize() });
|
|
this.eventBus.emit({ type: AgentEvent.AgentEnd, messages: result.messages });
|
|
return { ...result, getState: () => this.getState() };
|
|
}
|
|
|
|
private buildAiSdkOptions(
|
|
toolMap: Map<string, BuiltTool>,
|
|
options?: ExecutionOptions,
|
|
): {
|
|
telemetry?: TelemetryOptions;
|
|
repairToolCall?: ToolCallRepairFunction<NoInfer<ToolSet>>;
|
|
onStepStart?: ExecutionOptions['onStepStart'];
|
|
onStepEnd?: ExecutionOptions['onStepEnd'];
|
|
} {
|
|
return {
|
|
...this.telemetry.buildTelemetryOptions(options),
|
|
...(options?.onStepStart ? { onStepStart: options.onStepStart } : {}),
|
|
...(options?.onStepEnd || options?.onStepFinish
|
|
? { onStepEnd: options.onStepEnd ?? options.onStepFinish }
|
|
: {}),
|
|
repairToolCall: async (options) => {
|
|
return await fixToolCall(
|
|
{
|
|
toolCall: options.toolCall,
|
|
error: options.error,
|
|
},
|
|
toolMap,
|
|
);
|
|
},
|
|
};
|
|
}
|
|
|
|
/** Throw (and mark the run cancelled) if the abort scope has fired. */
|
|
private assertNotAborted(abortScope: AgentAbortScope): void {
|
|
if (abortScope.isAborted) {
|
|
this.updateState({ status: 'cancelled' });
|
|
throw new Error('Agent run was aborted');
|
|
}
|
|
}
|
|
|
|
/** Build the shared services the output sinks call into for terminal concerns. */
|
|
private createRunServices(): RunServices {
|
|
return {
|
|
runId: this.runId,
|
|
modelId: this.modelIdString,
|
|
applyCost: (usage) => this.applyCost(usage),
|
|
saveToMemory: async (list, options) => await this.memory.saveToMemory(list, options),
|
|
maybeGenerateTitle: async (list, options) => await this.maybeGenerateTitle(list, options),
|
|
flushTelemetry: async (options) => await this.telemetry.flush(options),
|
|
cleanupRun: async () => await this.cleanupRun(),
|
|
updateState: (patch) => this.updateState(patch),
|
|
emitAgentEnd: (messages) => this.eventBus.emit({ type: AgentEvent.AgentEnd, messages }),
|
|
getState: () => this.getState(),
|
|
};
|
|
}
|
|
|
|
/** Fire-and-forget (or sync, if configured) thread-title generation at end of turn. */
|
|
private async maybeGenerateTitle(
|
|
list: AgentMessageList,
|
|
options: (RunOptions & ExecutionOptions) | undefined,
|
|
): Promise<void> {
|
|
if (!this.config.titleGeneration || !options?.persistence?.threadId || !this.config.memory) {
|
|
return;
|
|
}
|
|
const titlePromise = generateThreadTitle({
|
|
memory: this.config.memory,
|
|
threadId: options.persistence.threadId,
|
|
resourceId: options.persistence.resourceId,
|
|
titleConfig: this.config.titleGeneration,
|
|
agentModel: this.config.model,
|
|
modelFetch: this.config.modelFetch,
|
|
turnDelta: list.turnDelta(),
|
|
executionCounter: options.executionCounter,
|
|
});
|
|
this.backgroundTasks.track(titlePromise);
|
|
if (this.config.titleGeneration.sync) {
|
|
await titlePromise;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Single agentic loop shared by generate and stream. The `sink` adapts the
|
|
* loop to its output channel: running the LLM call, emitting tool-batch
|
|
* results, and producing the terminal result for suspension / completion.
|
|
*
|
|
* Aborts throw `Agent run was aborted` (checked at the loop top, after the
|
|
* model call, and after each tool batch); model and tool-batch errors
|
|
* propagate. Callers (generate's try/catch, the stream session) translate
|
|
* those throws into their terminal contract.
|
|
*/
|
|
private async runAgentLoop<T>(ctx: LoopContext, sink: RunOutputSink<T>): Promise<T> {
|
|
const { list, options, abortScope, pendingResume } = ctx;
|
|
this.context.hydrateDeferredToolsFromList(list);
|
|
// Inject a model-facing note for any MCP servers that failed to connect
|
|
// during build(). The agent can mention the outage to the user when
|
|
// relevant; the note is system-message only and never persisted.
|
|
list.mcpConnectionNote = formatMcpConnectionNote(this.config.mcpConnectionFailures ?? []);
|
|
|
|
let totalUsage: TokenUsage | undefined;
|
|
let lastFinishReason: FinishReason = 'stop';
|
|
let structuredOutput: unknown;
|
|
const runTelemetry = this.telemetry.resolve(options);
|
|
const staticLoopContext = this.context.buildStaticLoopContext({
|
|
...options,
|
|
persistence: options?.persistence,
|
|
});
|
|
// Anthropic cache breakpoints are Anthropic-only and don't change across
|
|
// iterations, so compute once. Explicit .instructions() providerOptions win.
|
|
const instructionProviderOptions = mergeProviderOptions(
|
|
buildInstructionPromptCacheOptions(this.config.promptCaching, this.modelIdString),
|
|
this.config.instructionProviderOptions,
|
|
);
|
|
const maxIterations = options?.maxIterations ?? MAX_LOOP_ITERATIONS;
|
|
let iterationCount = options?.iterationCount ?? 0;
|
|
let reachedStopCondition = false;
|
|
|
|
const buildToolBatchContext = (toolMap: Map<string, BuiltTool>): ToolBatchContext => ({
|
|
toolMap,
|
|
list,
|
|
runId: this.runId,
|
|
persistence: options?.persistence,
|
|
telemetry: runTelemetry,
|
|
executionCounter: options?.executionCounter,
|
|
abortSignal: abortScope.signal,
|
|
isAborted: () => abortScope.isAborted,
|
|
});
|
|
|
|
if (pendingResume) {
|
|
const pendingLoopContext = this.context.buildToolLoopContext(
|
|
staticLoopContext.aiProviderTools,
|
|
options?.persistence,
|
|
options?.executionCounter,
|
|
);
|
|
const batch = await this.toolExecutor.iteratePendingToolCallsConcurrent({
|
|
...buildToolBatchContext(pendingLoopContext.toolMap),
|
|
pendingResume,
|
|
});
|
|
await sink.emitToolBatch(batch);
|
|
|
|
if (Object.keys(batch.pending).length > 0) {
|
|
const suspendRunId = await this.persistSuspension(
|
|
batch.pending,
|
|
options,
|
|
list,
|
|
totalUsage,
|
|
maxIterations,
|
|
iterationCount,
|
|
);
|
|
return await sink.finishSuspended({
|
|
suspendRunId,
|
|
list,
|
|
usage: totalUsage,
|
|
suspensions: batch.suspensions,
|
|
});
|
|
}
|
|
}
|
|
|
|
for (; iterationCount < maxIterations; iterationCount++) {
|
|
this.assertNotAborted(abortScope);
|
|
|
|
this.eventBus.emit({ type: AgentEvent.TurnStart });
|
|
|
|
const {
|
|
toolMap,
|
|
aiTools,
|
|
hasTools,
|
|
effectiveInstructions,
|
|
volatileInstructions,
|
|
staticToolCacheName,
|
|
} = this.context.buildToolLoopContext(
|
|
staticLoopContext.aiProviderTools,
|
|
options?.persistence,
|
|
options?.executionCounter,
|
|
);
|
|
const { system, messages } = list.forLlm(
|
|
effectiveInstructions,
|
|
instructionProviderOptions,
|
|
volatileInstructions,
|
|
);
|
|
// Runtime breakpoints (conversation history, static tools) are per-call
|
|
// only — never persisted back to the message list or tool set.
|
|
const cached = applyRuntimeCacheBreakpoints({
|
|
system,
|
|
messages,
|
|
aiTools,
|
|
promptCaching: this.config.promptCaching,
|
|
modelId: this.modelIdString,
|
|
staticToolCacheName,
|
|
});
|
|
|
|
const turn = await sink.callModel({
|
|
model: staticLoopContext.model,
|
|
system,
|
|
messages: cached.messages,
|
|
abortSignal: abortScope.signal,
|
|
hasTools,
|
|
aiTools: cached.aiTools,
|
|
providerOptions: staticLoopContext.providerOptions,
|
|
outputSpec: staticLoopContext.outputSpec,
|
|
aiSdkOptions: this.buildAiSdkOptions(toolMap, options),
|
|
});
|
|
|
|
// Fold the just-finished turn's usage in before the abort check so a
|
|
// stop that lands right after the model call still bills its tokens.
|
|
totalUsage = mergeUsage(totalUsage, turn.usage);
|
|
incrementTokenCountFromUsage(options?.executionCounter, turn.usage);
|
|
sink.reportUsage(totalUsage);
|
|
|
|
this.assertNotAborted(abortScope);
|
|
|
|
lastFinishReason = turn.finishReason;
|
|
list.addResponse(turn.newMessages);
|
|
// The turn is now in the list; drop any retained streamed text so a later
|
|
// abort's snapshot can't duplicate it (a stop before this point recovers it).
|
|
sink.onTurnFolded?.();
|
|
|
|
if (turn.aiFinishReason !== 'tool-calls') {
|
|
// A rejected/filtered request (e.g. a provider prompt safety block)
|
|
// surfaces as an output-less turn instead of an SDK error — throw so
|
|
// the failure reaches the caller rather than ending the run silently.
|
|
if (turn.errorReason) throw new Error(turn.errorReason.message);
|
|
structuredOutput = turn.structuredOutput;
|
|
this.emitTurnEnd(turn.newMessages, extractSettledToolCalls(turn.newMessages));
|
|
reachedStopCondition = true;
|
|
break;
|
|
}
|
|
|
|
const batch = await this.toolExecutor.iterateToolCallsConcurrent({
|
|
...buildToolBatchContext(toolMap),
|
|
toolCalls: turn.toolCalls,
|
|
});
|
|
|
|
this.assertNotAborted(abortScope);
|
|
|
|
await sink.emitToolBatch(batch);
|
|
|
|
if (Object.keys(batch.pending).length > 0) {
|
|
const suspendRunId = await this.persistSuspension(
|
|
batch.pending,
|
|
options,
|
|
list,
|
|
totalUsage,
|
|
maxIterations,
|
|
iterationCount + 1,
|
|
);
|
|
return await sink.finishSuspended({
|
|
suspendRunId,
|
|
list,
|
|
usage: totalUsage,
|
|
suspensions: batch.suspensions,
|
|
});
|
|
}
|
|
|
|
// Emit TurnEnd after all tool calls in this iteration are processed
|
|
this.emitTurnEnd(turn.newMessages, extractSettledToolCalls(list.responseDelta()));
|
|
|
|
// Step boundary reached with nothing pending: durably checkpoint so a
|
|
// crash before the next model call loses only the in-flight step.
|
|
if (options?.stepCheckpoints) {
|
|
await this.persistStepCheckpoint(
|
|
list,
|
|
totalUsage,
|
|
options,
|
|
maxIterations,
|
|
iterationCount + 1,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (!reachedStopCondition && iterationCount >= maxIterations) {
|
|
lastFinishReason = 'max-iterations';
|
|
}
|
|
|
|
return await sink.finishComplete({
|
|
list,
|
|
options,
|
|
finishReason: lastFinishReason,
|
|
usage: totalUsage,
|
|
structuredOutput,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Wire up a ReadableStream and start the stream loop in the background via the
|
|
* StreamSession, which owns the single shutdown / cleanup path.
|
|
*/
|
|
private startStream(ctx: LoopContext): ReadableStream<StreamChunk> {
|
|
let sink: StreamSink | undefined;
|
|
return startStreamSession({
|
|
eventBus: this.eventBus,
|
|
abortScope: ctx.abortScope,
|
|
runId: this.runId,
|
|
options: ctx.options,
|
|
withRootSpan: async (operation, options, runId, fn) =>
|
|
await this.telemetry.withRootSpan(operation, options, runId, fn),
|
|
runLoop: async (guard) => {
|
|
// Surface MCP connection failures as non-fatal warnings before the
|
|
// first LLM step. Tools from these servers were skipped during
|
|
// build(); the run continues with the remaining tools.
|
|
for (const failure of this.config.mcpConnectionFailures ?? []) {
|
|
void guard.write({
|
|
type: 'warning',
|
|
message: failure.error,
|
|
code: 'mcp_connection_failed',
|
|
source: 'mcp',
|
|
server: failure.server,
|
|
});
|
|
}
|
|
sink = new StreamSink(guard, this.createRunServices(), ctx.options);
|
|
await this.runAgentLoop(ctx, sink);
|
|
},
|
|
getAbortFinish: () => sink?.getAbortFinish() ?? {},
|
|
// Durably save the turn-so-far when a streaming run is aborted, so a cancelled
|
|
// run still leaves its assistant work in memory. Fold in the text streamed for
|
|
// the in-flight turn first — its `newMessages` are only built once the stream
|
|
// completes, which the abort skipped, so it isn't in the list yet.
|
|
persistTurnOnAbort: async () => {
|
|
const partial = sink?.getAbortSnapshot();
|
|
if (partial) ctx.list.addResponse([partial]);
|
|
await this.memory.persistTurnDelta(ctx.list, ctx.options);
|
|
},
|
|
flushTelemetry: async (options) => await this.telemetry.flush(options),
|
|
cleanupRun: async () => await this.cleanupRun(),
|
|
updateState: (status) => this.updateState({ status }),
|
|
emitError: (error) =>
|
|
this.eventBus.emit({ type: AgentEvent.Error, message: String(error), error }),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Persist a suspended run state and update the current state snapshot, and durably
|
|
* save the turn-so-far to thread memory so a suspended turn that is later cancelled or
|
|
* abandoned still leaves its assistant work behind. Returns the runtime's runId.
|
|
*/
|
|
private async persistSuspension(
|
|
pendingToolCalls: Record<string, PendingToolCall>,
|
|
options: RuntimeExecutionOptions | undefined,
|
|
list: AgentMessageList,
|
|
totalUsage: TokenUsage | undefined,
|
|
maxIterations?: number,
|
|
iterationCount?: number,
|
|
): Promise<string> {
|
|
// Persist loop controls only. providerOptions are intentionally excluded
|
|
// because they may contain sensitive data (API keys, auth headers).
|
|
const resolvedMaxIterations = maxIterations ?? options?.maxIterations;
|
|
const resolvedIterationCount = iterationCount ?? options?.iterationCount;
|
|
const executionOptions: PersistedExecutionOptions | undefined =
|
|
resolvedMaxIterations !== undefined ? { maxIterations: resolvedMaxIterations } : undefined;
|
|
|
|
const state: SerializableAgentState = {
|
|
persistence: options?.persistence,
|
|
status: 'suspended',
|
|
messageList: list.serialize(),
|
|
pendingToolCalls,
|
|
usage: totalUsage,
|
|
executionOptions,
|
|
...(resolvedIterationCount !== undefined ? { iterationCount: resolvedIterationCount } : {}),
|
|
};
|
|
await this.runState.suspend(this.runId, state);
|
|
this.updateState({ status: 'suspended', pendingToolCalls, messageList: list.serialize() });
|
|
await this.memory.persistTurnDelta(list, options);
|
|
|
|
return this.runId;
|
|
}
|
|
|
|
/**
|
|
* Durable-log RFC (resilience phase): per-step checkpoint — the completion
|
|
* of the "step boundary = durability boundary" rule. Called at the end of
|
|
* each loop iteration (after tool results are appended to `list`, before
|
|
* the next model call), gated on the `stepCheckpoints` opt-in so the write
|
|
* cost is only paid where crash-resume matters. Reuses the suspension state
|
|
* shape; pendingToolCalls is empty at a step boundary.
|
|
*/
|
|
private async persistStepCheckpoint(
|
|
list: AgentMessageList,
|
|
totalUsage: TokenUsage | undefined,
|
|
options: RuntimeExecutionOptions | undefined,
|
|
maxIterations?: number,
|
|
iterationCount?: number,
|
|
): Promise<void> {
|
|
const resolvedMaxIterations = maxIterations ?? options?.maxIterations;
|
|
const resolvedIterationCount = iterationCount ?? options?.iterationCount;
|
|
const executionOptions: PersistedExecutionOptions | undefined =
|
|
resolvedMaxIterations !== undefined ? { maxIterations: resolvedMaxIterations } : undefined;
|
|
|
|
const state: SerializableAgentState = {
|
|
persistence: options?.persistence,
|
|
status: 'running',
|
|
messageList: list.serialize(),
|
|
pendingToolCalls: {},
|
|
usage: totalUsage,
|
|
executionOptions,
|
|
...(resolvedIterationCount !== undefined ? { iterationCount: resolvedIterationCount } : {}),
|
|
};
|
|
await this.runState.checkpointStep(this.runId, state);
|
|
}
|
|
|
|
/** Clean up stored state for a run when it finishes without re-suspending. */
|
|
private async cleanupRun(): Promise<void> {
|
|
await this.runState.complete(this.runId);
|
|
}
|
|
|
|
/** Emit a TurnEnd event when an assistant message is present in `newMessages`. */
|
|
private emitTurnEnd(newMessages: AgentMessage[], toolResults: ContentToolCall[]): void {
|
|
const assistantMsg = newMessages.find((m) => 'role' in m && m.role === 'assistant');
|
|
if (assistantMsg) {
|
|
this.eventBus.emit({ type: AgentEvent.TurnEnd, message: assistantMsg, toolResults });
|
|
}
|
|
}
|
|
|
|
/** Patch the current state with partial updates. */
|
|
private updateState(patch: Partial<SerializableAgentState>): void {
|
|
this.currentState = { ...this.currentState, ...patch };
|
|
}
|
|
|
|
/** Get the model ID string. */
|
|
private get modelIdString(): string {
|
|
return getModelIdString(this.config.model);
|
|
}
|
|
|
|
/** Fetch model cost from catalog. Retries on subsequent calls if the catalog was unavailable. */
|
|
private async ensureModelCost(): Promise<ModelCost | undefined> {
|
|
if (this.modelCost) return this.modelCost;
|
|
try {
|
|
this.modelCost = await getModelCost(this.modelIdString);
|
|
} catch {
|
|
// Catalog unavailable — proceed without cost data, will retry next call
|
|
}
|
|
return this.modelCost;
|
|
}
|
|
|
|
/** Apply cost to a TokenUsage object using catalog pricing. */
|
|
private applyCost(usage: TokenUsage | undefined): TokenUsage | undefined {
|
|
if (!usage || !this.modelCost) return usage;
|
|
const anthropicCacheTtl = getEffectiveAnthropicCacheTtl(
|
|
this.config.promptCaching,
|
|
this.modelIdString,
|
|
);
|
|
return { ...usage, cost: computeCost(usage, this.modelCost, { anthropicCacheTtl }) };
|
|
}
|
|
|
|
/**
|
|
* Configured telemetry handle (build-time). Run-time inheritance via
|
|
* `ExecutionOptions.telemetry` only applies inside an active
|
|
* agentic loop; out-of-band callers like `agent.reflect()` see the
|
|
* builder-time value.
|
|
*/
|
|
getConfiguredTelemetry(): BuiltTelemetry | undefined {
|
|
return this.config.telemetry;
|
|
}
|
|
}
|