Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
1238
packages/agent-core/src/agent-loop.test.ts
Normal file
1238
packages/agent-core/src/agent-loop.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
1092
packages/agent-core/src/agent-loop.ts
Normal file
1092
packages/agent-core/src/agent-loop.ts
Normal file
File diff suppressed because it is too large
Load Diff
621
packages/agent-core/src/agent.ts
Normal file
621
packages/agent-core/src/agent.ts
Normal file
@@ -0,0 +1,621 @@
|
||||
// Agent Core module implements agent behavior.
|
||||
import type {
|
||||
ImageContent,
|
||||
Message,
|
||||
Model,
|
||||
SimpleStreamOptions,
|
||||
TextContent,
|
||||
ThinkingBudgets,
|
||||
Transport,
|
||||
} from "../../llm-core/src/index.js";
|
||||
import { runAgentLoop, runAgentLoopContinue } from "./agent-loop.js";
|
||||
import { resolveAgentReasoningOption } from "./reasoning.js";
|
||||
import { type AgentCoreStreamRuntimeDeps, resolveAgentCoreStreamFn } from "./runtime-deps.js";
|
||||
import type {
|
||||
AfterToolCallContext,
|
||||
AfterToolCallResult,
|
||||
AgentContext,
|
||||
AgentEvent,
|
||||
AgentLoopConfig,
|
||||
AgentLoopTurnUpdate,
|
||||
AgentMessage,
|
||||
AgentState,
|
||||
AgentTool,
|
||||
BeforeToolCallContext,
|
||||
BeforeToolCallResult,
|
||||
QueueMode,
|
||||
StreamFn,
|
||||
ToolExecutionMode,
|
||||
} from "./types.js";
|
||||
|
||||
export type { QueueMode } from "./types.js";
|
||||
|
||||
function defaultConvertToLlm(messages: AgentMessage[]): Message[] {
|
||||
return messages.filter(
|
||||
(message) =>
|
||||
message.role === "user" || message.role === "assistant" || message.role === "toolResult",
|
||||
);
|
||||
}
|
||||
|
||||
const EMPTY_USAGE = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
};
|
||||
|
||||
const DEFAULT_MODEL = {
|
||||
id: "unknown",
|
||||
name: "unknown",
|
||||
api: "unknown",
|
||||
provider: "unknown",
|
||||
baseUrl: "",
|
||||
reasoning: false,
|
||||
input: [],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 0,
|
||||
maxTokens: 0,
|
||||
} satisfies Model;
|
||||
|
||||
type MutableAgentState = Omit<
|
||||
AgentState,
|
||||
"isStreaming" | "streamingMessage" | "pendingToolCalls" | "errorMessage"
|
||||
> & {
|
||||
isStreaming: boolean;
|
||||
streamingMessage?: AgentMessage;
|
||||
pendingToolCalls: Set<string>;
|
||||
errorMessage?: string;
|
||||
};
|
||||
|
||||
function createMutableAgentState(
|
||||
initialState?: Partial<
|
||||
Omit<AgentState, "pendingToolCalls" | "isStreaming" | "streamingMessage" | "errorMessage">
|
||||
>,
|
||||
): MutableAgentState {
|
||||
let tools = initialState?.tools?.slice() ?? [];
|
||||
let messages = initialState?.messages?.slice() ?? [];
|
||||
|
||||
return {
|
||||
systemPrompt: initialState?.systemPrompt ?? "",
|
||||
model: initialState?.model ?? DEFAULT_MODEL,
|
||||
thinkingLevel: initialState?.thinkingLevel ?? "off",
|
||||
get tools() {
|
||||
return tools;
|
||||
},
|
||||
set tools(nextTools: AgentTool[]) {
|
||||
tools = nextTools.slice();
|
||||
},
|
||||
get messages() {
|
||||
return messages;
|
||||
},
|
||||
set messages(nextMessages: AgentMessage[]) {
|
||||
messages = nextMessages.slice();
|
||||
},
|
||||
isStreaming: false,
|
||||
streamingMessage: undefined,
|
||||
pendingToolCalls: new Set<string>(),
|
||||
errorMessage: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** Options for constructing an {@link Agent}. */
|
||||
export interface AgentOptions {
|
||||
/** Initial transcript, tools, model, and prompt state. */
|
||||
initialState?: Partial<
|
||||
Omit<AgentState, "pendingToolCalls" | "isStreaming" | "streamingMessage" | "errorMessage">
|
||||
>;
|
||||
/** Convert agent-owned transcript messages into provider-facing messages. */
|
||||
convertToLlm?: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
|
||||
/** Optionally rewrite context before each provider request. */
|
||||
transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise<AgentMessage[]>;
|
||||
/** Injected stream runtime used when streamFn is not supplied. */
|
||||
runtime?: AgentCoreStreamRuntimeDeps;
|
||||
/** Explicit stream implementation, preferred over runtime.streamSimple. */
|
||||
streamFn?: StreamFn;
|
||||
/** Resolve provider API keys at request time. */
|
||||
getApiKey?: (provider: string) => Promise<string | undefined> | string | undefined;
|
||||
/** Inspect the provider payload before it is sent. */
|
||||
onPayload?: SimpleStreamOptions["onPayload"];
|
||||
/** Inspect the provider response after it returns. */
|
||||
onResponse?: SimpleStreamOptions["onResponse"];
|
||||
/** Hook that may short-circuit or alter a tool call before execution. */
|
||||
beforeToolCall?: (
|
||||
context: BeforeToolCallContext,
|
||||
signal?: AbortSignal,
|
||||
) => Promise<BeforeToolCallResult | undefined>;
|
||||
/** Hook that may hydrate a deferred authorized tool call into an executable tool. */
|
||||
resolveDeferredTool?: AgentLoopConfig["resolveDeferredTool"];
|
||||
/** Hook that may alter a tool result after execution. */
|
||||
afterToolCall?: (
|
||||
context: AfterToolCallContext,
|
||||
signal?: AbortSignal,
|
||||
) => Promise<AfterToolCallResult | undefined>;
|
||||
/** Hook that may update model, reasoning, or context after a turn. */
|
||||
prepareNextTurn?: (
|
||||
signal?: AbortSignal,
|
||||
) => Promise<AgentLoopTurnUpdate | undefined> | AgentLoopTurnUpdate | undefined;
|
||||
/** Queue drain mode for steering messages injected before the next assistant response. */
|
||||
steeringMode?: QueueMode;
|
||||
/** Queue drain mode for follow-up messages injected after the agent would otherwise stop. */
|
||||
followUpMode?: QueueMode;
|
||||
/** Session identifier forwarded to cache-aware providers. */
|
||||
sessionId?: string;
|
||||
/** Optional per-thinking-level token budgets forwarded to providers. */
|
||||
thinkingBudgets?: ThinkingBudgets;
|
||||
/** Preferred provider transport. */
|
||||
transport?: Transport;
|
||||
/** Optional cap for provider-requested retry delays. */
|
||||
maxRetryDelayMs?: number;
|
||||
/** Default strategy for executing multiple tool calls in one assistant message. */
|
||||
toolExecution?: ToolExecutionMode;
|
||||
}
|
||||
|
||||
class PendingMessageQueue {
|
||||
private messages: AgentMessage[] = [];
|
||||
public mode: QueueMode;
|
||||
|
||||
constructor(mode: QueueMode) {
|
||||
this.mode = mode;
|
||||
}
|
||||
|
||||
enqueue(message: AgentMessage): void {
|
||||
this.messages.push(message);
|
||||
}
|
||||
|
||||
hasItems(): boolean {
|
||||
return this.messages.length > 0;
|
||||
}
|
||||
|
||||
drain(): AgentMessage[] {
|
||||
if (this.mode === "all") {
|
||||
const drained = this.messages.slice();
|
||||
this.messages = [];
|
||||
return drained;
|
||||
}
|
||||
|
||||
// one-at-a-time preserves later queued messages for subsequent loop turns.
|
||||
const first = this.messages[0];
|
||||
if (!first) {
|
||||
return [];
|
||||
}
|
||||
this.messages = this.messages.slice(1);
|
||||
return [first];
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.messages = [];
|
||||
}
|
||||
}
|
||||
|
||||
type ActiveRun = {
|
||||
promise: Promise<void>;
|
||||
resolve: () => void;
|
||||
abortController: AbortController;
|
||||
};
|
||||
|
||||
/**
|
||||
* Stateful wrapper around the low-level agent loop.
|
||||
*
|
||||
* `Agent` owns the current transcript, emits lifecycle events, executes tools,
|
||||
* and exposes queueing APIs for steering and follow-up messages.
|
||||
*/
|
||||
export class Agent {
|
||||
private mutableState: MutableAgentState;
|
||||
private readonly listeners = new Set<
|
||||
(event: AgentEvent, signal: AbortSignal) => Promise<void> | void
|
||||
>();
|
||||
private readonly steeringQueue: PendingMessageQueue;
|
||||
private readonly followUpQueue: PendingMessageQueue;
|
||||
|
||||
public convertToLlm: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
|
||||
public transformContext?: (
|
||||
messages: AgentMessage[],
|
||||
signal?: AbortSignal,
|
||||
) => Promise<AgentMessage[]>;
|
||||
public runtime?: AgentCoreStreamRuntimeDeps;
|
||||
public streamFn: StreamFn;
|
||||
public getApiKey?: (provider: string) => Promise<string | undefined> | string | undefined;
|
||||
public onPayload?: SimpleStreamOptions["onPayload"];
|
||||
public onResponse?: SimpleStreamOptions["onResponse"];
|
||||
public beforeToolCall?: (
|
||||
context: BeforeToolCallContext,
|
||||
signal?: AbortSignal,
|
||||
) => Promise<BeforeToolCallResult | undefined>;
|
||||
public resolveDeferredTool?: AgentLoopConfig["resolveDeferredTool"];
|
||||
public afterToolCall?: (
|
||||
context: AfterToolCallContext,
|
||||
signal?: AbortSignal,
|
||||
) => Promise<AfterToolCallResult | undefined>;
|
||||
public prepareNextTurn?: (
|
||||
signal?: AbortSignal,
|
||||
) => Promise<AgentLoopTurnUpdate | undefined> | AgentLoopTurnUpdate | undefined;
|
||||
private activeRun?: ActiveRun;
|
||||
/** Session identifier forwarded to providers for cache-aware backends. */
|
||||
public sessionId?: string;
|
||||
/** Optional per-level thinking token budgets forwarded to the stream function. */
|
||||
public thinkingBudgets?: ThinkingBudgets;
|
||||
/** Preferred transport forwarded to the stream function. */
|
||||
public transport: Transport;
|
||||
/** Optional cap for provider-requested retry delays. */
|
||||
public maxRetryDelayMs?: number;
|
||||
/** Tool execution strategy for assistant messages that contain multiple tool calls. */
|
||||
public toolExecution: ToolExecutionMode;
|
||||
|
||||
constructor(options: AgentOptions = {}) {
|
||||
this.mutableState = createMutableAgentState(options.initialState);
|
||||
this.convertToLlm = options.convertToLlm ?? defaultConvertToLlm;
|
||||
this.transformContext = options.transformContext;
|
||||
this.runtime = options.runtime;
|
||||
this.streamFn = resolveAgentCoreStreamFn(options.runtime, options.streamFn);
|
||||
this.getApiKey = options.getApiKey;
|
||||
this.onPayload = options.onPayload;
|
||||
this.onResponse = options.onResponse;
|
||||
this.beforeToolCall = options.beforeToolCall;
|
||||
this.resolveDeferredTool = options.resolveDeferredTool;
|
||||
this.afterToolCall = options.afterToolCall;
|
||||
this.prepareNextTurn = options.prepareNextTurn;
|
||||
this.steeringQueue = new PendingMessageQueue(options.steeringMode ?? "one-at-a-time");
|
||||
this.followUpQueue = new PendingMessageQueue(options.followUpMode ?? "one-at-a-time");
|
||||
this.sessionId = options.sessionId;
|
||||
this.thinkingBudgets = options.thinkingBudgets;
|
||||
this.transport = options.transport ?? "auto";
|
||||
this.maxRetryDelayMs = options.maxRetryDelayMs;
|
||||
this.toolExecution = options.toolExecution ?? "parallel";
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to agent lifecycle events.
|
||||
*
|
||||
* Listener promises are awaited in subscription order and are included in
|
||||
* the current run's settlement. Listeners also receive the active abort
|
||||
* signal for the current run.
|
||||
*
|
||||
* `agent_end` is the final emitted event for a run, but the agent does not
|
||||
* become idle until all awaited listeners for that event have settled.
|
||||
*/
|
||||
subscribe(
|
||||
listener: (event: AgentEvent, signal: AbortSignal) => Promise<void> | void,
|
||||
): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Current agent state.
|
||||
*
|
||||
* Assigning `state.tools` or `state.messages` copies the provided top-level array.
|
||||
*/
|
||||
get state(): AgentState {
|
||||
return this.mutableState;
|
||||
}
|
||||
|
||||
/** Controls how queued steering messages are drained. */
|
||||
set steeringMode(mode: QueueMode) {
|
||||
this.steeringQueue.mode = mode;
|
||||
}
|
||||
|
||||
get steeringMode(): QueueMode {
|
||||
return this.steeringQueue.mode;
|
||||
}
|
||||
|
||||
/** Controls how queued follow-up messages are drained. */
|
||||
set followUpMode(mode: QueueMode) {
|
||||
this.followUpQueue.mode = mode;
|
||||
}
|
||||
|
||||
get followUpMode(): QueueMode {
|
||||
return this.followUpQueue.mode;
|
||||
}
|
||||
|
||||
/** Queue a message to be injected after the current assistant turn finishes. */
|
||||
steer(message: AgentMessage): void {
|
||||
this.steeringQueue.enqueue(message);
|
||||
}
|
||||
|
||||
/** Queue a message to run only after the agent would otherwise stop. */
|
||||
followUp(message: AgentMessage): void {
|
||||
this.followUpQueue.enqueue(message);
|
||||
}
|
||||
|
||||
/** Remove all queued steering messages. */
|
||||
clearSteeringQueue(): void {
|
||||
this.steeringQueue.clear();
|
||||
}
|
||||
|
||||
/** Remove all queued follow-up messages. */
|
||||
clearFollowUpQueue(): void {
|
||||
this.followUpQueue.clear();
|
||||
}
|
||||
|
||||
/** Remove all queued steering and follow-up messages. */
|
||||
clearAllQueues(): void {
|
||||
this.clearSteeringQueue();
|
||||
this.clearFollowUpQueue();
|
||||
}
|
||||
|
||||
/** Returns true when either queue still contains pending messages. */
|
||||
hasQueuedMessages(): boolean {
|
||||
return this.steeringQueue.hasItems() || this.followUpQueue.hasItems();
|
||||
}
|
||||
|
||||
/** Active abort signal for the current run, if any. */
|
||||
get signal(): AbortSignal | undefined {
|
||||
return this.activeRun?.abortController.signal;
|
||||
}
|
||||
|
||||
/** Abort the current run, if one is active. */
|
||||
abort(): void {
|
||||
this.activeRun?.abortController.abort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve when the current run and all awaited event listeners have finished.
|
||||
*
|
||||
* This resolves after `agent_end` listeners settle.
|
||||
*/
|
||||
waitForIdle(): Promise<void> {
|
||||
return this.activeRun?.promise ?? Promise.resolve();
|
||||
}
|
||||
|
||||
/** Clear transcript state, runtime state, and queued messages. */
|
||||
reset(): void {
|
||||
this.mutableState.messages = [];
|
||||
this.mutableState.isStreaming = false;
|
||||
this.mutableState.streamingMessage = undefined;
|
||||
this.mutableState.pendingToolCalls = new Set<string>();
|
||||
this.mutableState.errorMessage = undefined;
|
||||
this.clearFollowUpQueue();
|
||||
this.clearSteeringQueue();
|
||||
}
|
||||
|
||||
/** Start a new prompt from text, a single message, or a batch of messages. */
|
||||
async prompt(message: AgentMessage | AgentMessage[]): Promise<void>;
|
||||
async prompt(input: string, images?: ImageContent[]): Promise<void>;
|
||||
async prompt(
|
||||
input: string | AgentMessage | AgentMessage[],
|
||||
images?: ImageContent[],
|
||||
): Promise<void> {
|
||||
if (this.activeRun) {
|
||||
throw new Error(
|
||||
"Agent is already processing a prompt. Use steer() or followUp() to queue messages, or wait for completion.",
|
||||
);
|
||||
}
|
||||
const messages = this.normalizePromptInput(input, images);
|
||||
await this.runPromptMessages(messages);
|
||||
}
|
||||
|
||||
/** Continue from the current transcript. The last message must be a user or tool-result message. */
|
||||
async continue(): Promise<void> {
|
||||
if (this.activeRun) {
|
||||
throw new Error("Agent is already processing. Wait for completion before continuing.");
|
||||
}
|
||||
|
||||
const lastMessage = this.mutableState.messages[this.mutableState.messages.length - 1];
|
||||
if (!lastMessage) {
|
||||
throw new Error("No messages to continue from");
|
||||
}
|
||||
|
||||
if (lastMessage.role === "assistant") {
|
||||
const queuedSteering = this.steeringQueue.drain();
|
||||
if (queuedSteering.length > 0) {
|
||||
await this.runPromptMessages(queuedSteering, { skipInitialSteeringPoll: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const queuedFollowUps = this.followUpQueue.drain();
|
||||
if (queuedFollowUps.length > 0) {
|
||||
await this.runPromptMessages(queuedFollowUps);
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error("Cannot continue from message role: assistant");
|
||||
}
|
||||
|
||||
await this.runContinuation();
|
||||
}
|
||||
|
||||
private normalizePromptInput(
|
||||
input: string | AgentMessage | AgentMessage[],
|
||||
images?: ImageContent[],
|
||||
): AgentMessage[] {
|
||||
if (Array.isArray(input)) {
|
||||
return input;
|
||||
}
|
||||
|
||||
if (typeof input !== "string") {
|
||||
return [input];
|
||||
}
|
||||
|
||||
const content: Array<TextContent | ImageContent> = [{ type: "text", text: input }];
|
||||
if (images && images.length > 0) {
|
||||
content.push(...images);
|
||||
}
|
||||
return [{ role: "user", content, timestamp: Date.now() }];
|
||||
}
|
||||
|
||||
private async runPromptMessages(
|
||||
messages: AgentMessage[],
|
||||
options: { skipInitialSteeringPoll?: boolean } = {},
|
||||
): Promise<void> {
|
||||
await this.runWithLifecycle(async (signal) => {
|
||||
await runAgentLoop(
|
||||
messages,
|
||||
this.createContextSnapshot(),
|
||||
this.createLoopConfig(options),
|
||||
(event) => this.processEvents(event),
|
||||
signal,
|
||||
this.streamFn,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private async runContinuation(): Promise<void> {
|
||||
await this.runWithLifecycle(async (signal) => {
|
||||
await runAgentLoopContinue(
|
||||
this.createContextSnapshot(),
|
||||
this.createLoopConfig(),
|
||||
(event) => this.processEvents(event),
|
||||
signal,
|
||||
this.streamFn,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private createContextSnapshot(): AgentContext {
|
||||
return {
|
||||
systemPrompt: this.mutableState.systemPrompt,
|
||||
messages: this.mutableState.messages.slice(),
|
||||
tools: this.mutableState.tools.slice(),
|
||||
};
|
||||
}
|
||||
|
||||
private createLoopConfig(options: { skipInitialSteeringPoll?: boolean } = {}): AgentLoopConfig {
|
||||
let skipInitialSteeringPoll = options.skipInitialSteeringPoll === true;
|
||||
return {
|
||||
model: this.mutableState.model,
|
||||
thinkingLevel: this.mutableState.thinkingLevel,
|
||||
reasoning: resolveAgentReasoningOption(
|
||||
this.mutableState.model,
|
||||
this.mutableState.thinkingLevel,
|
||||
),
|
||||
sessionId: this.sessionId,
|
||||
onPayload: this.onPayload,
|
||||
onResponse: this.onResponse,
|
||||
transport: this.transport,
|
||||
thinkingBudgets: this.thinkingBudgets,
|
||||
maxRetryDelayMs: this.maxRetryDelayMs,
|
||||
toolExecution: this.toolExecution,
|
||||
beforeToolCall: this.beforeToolCall,
|
||||
resolveDeferredTool: this.resolveDeferredTool,
|
||||
afterToolCall: this.afterToolCall,
|
||||
prepareNextTurn: this.prepareNextTurn
|
||||
? async () => await this.prepareNextTurn?.(this.signal)
|
||||
: undefined,
|
||||
convertToLlm: this.convertToLlm,
|
||||
transformContext: this.transformContext,
|
||||
getApiKey: this.getApiKey,
|
||||
getSteeringMessages: async () => {
|
||||
if (skipInitialSteeringPoll) {
|
||||
skipInitialSteeringPoll = false;
|
||||
return [];
|
||||
}
|
||||
return this.steeringQueue.drain();
|
||||
},
|
||||
getFollowUpMessages: async () => this.followUpQueue.drain(),
|
||||
};
|
||||
}
|
||||
|
||||
private async runWithLifecycle(executor: (signal: AbortSignal) => Promise<void>): Promise<void> {
|
||||
if (this.activeRun) {
|
||||
throw new Error("Agent is already processing.");
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
let resolvePromise = () => {};
|
||||
const promise = new Promise<void>((resolve) => {
|
||||
resolvePromise = resolve;
|
||||
});
|
||||
this.activeRun = { promise, resolve: resolvePromise, abortController };
|
||||
|
||||
this.mutableState.isStreaming = true;
|
||||
this.mutableState.streamingMessage = undefined;
|
||||
this.mutableState.errorMessage = undefined;
|
||||
|
||||
try {
|
||||
await executor(abortController.signal);
|
||||
} catch (error) {
|
||||
await this.handleRunFailure(error, abortController.signal.aborted);
|
||||
} finally {
|
||||
this.finishRun();
|
||||
}
|
||||
}
|
||||
|
||||
private async handleRunFailure(error: unknown, aborted: boolean): Promise<void> {
|
||||
const failureMessage = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "" }],
|
||||
api: this.mutableState.model.api,
|
||||
provider: this.mutableState.model.provider,
|
||||
model: this.mutableState.model.id,
|
||||
usage: EMPTY_USAGE,
|
||||
stopReason: aborted ? "aborted" : "error",
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
timestamp: Date.now(),
|
||||
} satisfies AgentMessage;
|
||||
await this.processEvents({ type: "message_start", message: failureMessage });
|
||||
await this.processEvents({ type: "message_end", message: failureMessage });
|
||||
await this.processEvents({ type: "turn_end", message: failureMessage, toolResults: [] });
|
||||
await this.processEvents({ type: "agent_end", messages: [failureMessage] });
|
||||
}
|
||||
|
||||
private finishRun(): void {
|
||||
this.mutableState.isStreaming = false;
|
||||
this.mutableState.streamingMessage = undefined;
|
||||
this.mutableState.pendingToolCalls = new Set<string>();
|
||||
this.activeRun?.resolve();
|
||||
this.activeRun = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce internal state for a loop event, then await listeners.
|
||||
*
|
||||
* `agent_end` only means no further loop events will be emitted. The run is
|
||||
* considered idle later, after all awaited listeners for `agent_end` finish
|
||||
* and `finishRun()` clears runtime-owned state.
|
||||
*/
|
||||
private async processEvents(event: AgentEvent): Promise<void> {
|
||||
switch (event.type) {
|
||||
case "agent_start":
|
||||
case "turn_start":
|
||||
case "tool_execution_update":
|
||||
break;
|
||||
|
||||
case "message_start":
|
||||
this.mutableState.streamingMessage = event.message;
|
||||
break;
|
||||
|
||||
case "message_update":
|
||||
this.mutableState.streamingMessage = event.message;
|
||||
break;
|
||||
|
||||
case "message_end":
|
||||
this.mutableState.streamingMessage = undefined;
|
||||
this.mutableState.messages.push(event.message);
|
||||
break;
|
||||
|
||||
case "tool_execution_start": {
|
||||
const pendingToolCalls = new Set(this.mutableState.pendingToolCalls);
|
||||
pendingToolCalls.add(event.toolCallId);
|
||||
this.mutableState.pendingToolCalls = pendingToolCalls;
|
||||
break;
|
||||
}
|
||||
|
||||
case "tool_execution_end": {
|
||||
const pendingToolCalls = new Set(this.mutableState.pendingToolCalls);
|
||||
pendingToolCalls.delete(event.toolCallId);
|
||||
this.mutableState.pendingToolCalls = pendingToolCalls;
|
||||
break;
|
||||
}
|
||||
|
||||
case "turn_end":
|
||||
if (event.message.role === "assistant" && event.message.errorMessage) {
|
||||
this.mutableState.errorMessage = event.message.errorMessage;
|
||||
}
|
||||
break;
|
||||
|
||||
case "agent_end":
|
||||
this.mutableState.streamingMessage = undefined;
|
||||
break;
|
||||
}
|
||||
|
||||
const signal = this.activeRun?.abortController.signal;
|
||||
if (!signal) {
|
||||
throw new Error("Agent listener invoked outside active run");
|
||||
}
|
||||
for (const listener of this.listeners) {
|
||||
await listener(event, signal);
|
||||
}
|
||||
}
|
||||
}
|
||||
1211
packages/agent-core/src/harness/agent-harness.ts
Normal file
1211
packages/agent-core/src/harness/agent-harness.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,324 @@
|
||||
// Agent Core module implements branch summarization behavior.
|
||||
import type { Model, StreamFn } from "../../../../llm-core/src/index.js";
|
||||
import {
|
||||
type AgentCoreCompletionRuntimeDeps,
|
||||
resolveAgentCoreCompleteFn,
|
||||
} from "../../runtime-deps.js";
|
||||
import type { AgentMessage } from "../../types.js";
|
||||
import {
|
||||
asAgentMessage,
|
||||
convertToLlm,
|
||||
createBranchSummaryMessage,
|
||||
createCompactionSummaryMessage,
|
||||
createCustomMessage,
|
||||
} from "../messages.js";
|
||||
import type { BranchSummaryResult, Session, SessionTreeEntry } from "../types.js";
|
||||
import { BranchSummaryError, err, ok, type Result } from "../types.js";
|
||||
import { estimateTokens, SUMMARIZATION_SYSTEM_PROMPT } from "./compaction.js";
|
||||
import {
|
||||
computeFileLists,
|
||||
createFileOps,
|
||||
extractFileOpsFromMessage,
|
||||
type FileOperations,
|
||||
formatFileOperations,
|
||||
serializeConversation,
|
||||
} from "./utils.js";
|
||||
|
||||
/** File-operation details stored on generated branch summary entries. */
|
||||
export interface BranchSummaryDetails {
|
||||
/** Files read while exploring the summarized branch. */
|
||||
readFiles: string[];
|
||||
/** Files modified while exploring the summarized branch. */
|
||||
modifiedFiles: string[];
|
||||
}
|
||||
|
||||
export type { FileOperations } from "./utils.js";
|
||||
|
||||
/** Prepared branch content for summarization. */
|
||||
export interface BranchPreparation {
|
||||
/** Messages selected for the branch summary. */
|
||||
messages: AgentMessage[];
|
||||
/** File operations extracted from the branch. */
|
||||
fileOps: FileOperations;
|
||||
/** Estimated token count for selected messages. */
|
||||
totalTokens: number;
|
||||
}
|
||||
|
||||
/** Entries selected for branch summarization. */
|
||||
export interface CollectEntriesResult {
|
||||
/** Entries to summarize in chronological order. */
|
||||
entries: SessionTreeEntry[];
|
||||
/** Deepest common ancestor between the previous leaf and target entry. */
|
||||
commonAncestorId: string | null;
|
||||
}
|
||||
|
||||
/** Minimal tree entry shape needed to compare two session branches. */
|
||||
export interface BranchPathEntry {
|
||||
/** Stable entry id. */
|
||||
id: string;
|
||||
/** Parent entry id, or null for the session root. */
|
||||
parentId: string | null;
|
||||
}
|
||||
|
||||
/** Branch entries selected after comparing old and target paths. */
|
||||
export interface CollectBranchPathEntriesResult<TEntry extends BranchPathEntry> {
|
||||
/** Entries to summarize in chronological order. */
|
||||
entries: TEntry[];
|
||||
/** Deepest common ancestor between the previous leaf and target entry. */
|
||||
commonAncestorId: string | null;
|
||||
}
|
||||
|
||||
/** Options for generating a branch summary. */
|
||||
export interface GenerateBranchSummaryOptions {
|
||||
/** Model used for summarization. */
|
||||
model: Model;
|
||||
/** API key forwarded to the provider. */
|
||||
apiKey: string;
|
||||
/** Optional request headers forwarded to the provider. */
|
||||
headers?: Record<string, string>;
|
||||
/** Abort signal for the summarization request. */
|
||||
signal: AbortSignal;
|
||||
/** Runtime used to complete the summarization request. */
|
||||
runtime?: AgentCoreCompletionRuntimeDeps;
|
||||
/** Optional stream implementation used instead of the runtime complete function. */
|
||||
streamFn?: StreamFn;
|
||||
/** Optional instructions appended to or replacing the default prompt. */
|
||||
customInstructions?: string;
|
||||
/** Replace the default prompt with custom instructions instead of appending them. */
|
||||
replaceInstructions?: boolean;
|
||||
/** Tokens reserved for prompt and model output. Defaults to 16384. */
|
||||
reserveTokens?: number;
|
||||
}
|
||||
|
||||
/** Collect entries that should be summarized before navigating to a different session tree entry. */
|
||||
export function collectEntriesForBranchSummaryFromBranches<TEntry extends BranchPathEntry>(
|
||||
oldBranch: readonly TEntry[],
|
||||
targetBranch: readonly TEntry[],
|
||||
): CollectBranchPathEntriesResult<TEntry> {
|
||||
const oldPath = new Set(oldBranch.map((entry) => entry.id));
|
||||
let commonAncestorId: string | null = null;
|
||||
for (let i = targetBranch.length - 1; i >= 0; i--) {
|
||||
if (oldPath.has(targetBranch[i].id)) {
|
||||
commonAncestorId = targetBranch[i].id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const firstSummarizedIndex =
|
||||
commonAncestorId === null
|
||||
? 0
|
||||
: oldBranch.findIndex((entry) => entry.id === commonAncestorId) + 1;
|
||||
return { entries: oldBranch.slice(firstSummarizedIndex), commonAncestorId };
|
||||
}
|
||||
|
||||
/** Collect concrete session entries to summarize before moving from one leaf to another. */
|
||||
export async function collectEntriesForBranchSummary(
|
||||
session: Session,
|
||||
oldLeafId: string | null,
|
||||
targetId: string,
|
||||
): Promise<CollectEntriesResult> {
|
||||
if (!oldLeafId) {
|
||||
return { entries: [], commonAncestorId: null };
|
||||
}
|
||||
const oldBranch = await session.getBranch(oldLeafId);
|
||||
const targetPath = await session.getBranch(targetId);
|
||||
return collectEntriesForBranchSummaryFromBranches(oldBranch, targetPath);
|
||||
}
|
||||
function getMessageFromEntry(entry: SessionTreeEntry): AgentMessage | undefined {
|
||||
switch (entry.type) {
|
||||
case "message":
|
||||
if (entry.message.role === "toolResult") {
|
||||
return undefined;
|
||||
}
|
||||
return entry.message;
|
||||
|
||||
case "custom_message":
|
||||
return asAgentMessage(
|
||||
createCustomMessage(
|
||||
entry.customType,
|
||||
entry.content,
|
||||
entry.display,
|
||||
entry.details,
|
||||
entry.timestamp,
|
||||
),
|
||||
);
|
||||
|
||||
case "branch_summary":
|
||||
return asAgentMessage(
|
||||
createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp),
|
||||
);
|
||||
|
||||
case "compaction":
|
||||
return asAgentMessage(
|
||||
createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp),
|
||||
);
|
||||
case "thinking_level_change":
|
||||
case "model_change":
|
||||
case "custom":
|
||||
case "label":
|
||||
case "session_info":
|
||||
case "leaf":
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Prepare branch entries for summarization within an optional token budget. */
|
||||
export function prepareBranchEntries(
|
||||
entries: SessionTreeEntry[],
|
||||
tokenBudget = 0,
|
||||
): BranchPreparation {
|
||||
const messages: AgentMessage[] = [];
|
||||
const fileOps = createFileOps();
|
||||
let totalTokens = 0;
|
||||
for (const entry of entries) {
|
||||
if (entry.type === "branch_summary" && !entry.fromHook && entry.details) {
|
||||
const details = entry.details as BranchSummaryDetails;
|
||||
if (Array.isArray(details.readFiles)) {
|
||||
for (const f of details.readFiles) {
|
||||
fileOps.read.add(f);
|
||||
}
|
||||
}
|
||||
if (Array.isArray(details.modifiedFiles)) {
|
||||
for (const f of details.modifiedFiles) {
|
||||
fileOps.edited.add(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let i = entries.length - 1; i >= 0; i--) {
|
||||
const entry = entries[i];
|
||||
const message = getMessageFromEntry(entry);
|
||||
if (!message) {
|
||||
continue;
|
||||
}
|
||||
extractFileOpsFromMessage(message, fileOps);
|
||||
|
||||
const tokens = estimateTokens(message);
|
||||
if (tokenBudget > 0 && totalTokens + tokens > tokenBudget) {
|
||||
// Prefer already-compressed summaries when the budget is almost filled; they
|
||||
// preserve older branch context better than dropping the whole prefix.
|
||||
if (entry.type === "compaction" || entry.type === "branch_summary") {
|
||||
if (totalTokens < tokenBudget * 0.9) {
|
||||
messages.unshift(message);
|
||||
totalTokens += tokens;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
messages.unshift(message);
|
||||
totalTokens += tokens;
|
||||
}
|
||||
|
||||
return { messages, fileOps, totalTokens };
|
||||
}
|
||||
|
||||
const BRANCH_SUMMARY_PREAMBLE = `The user explored a different conversation branch before returning here.
|
||||
Summary of that exploration:
|
||||
|
||||
`;
|
||||
|
||||
const BRANCH_SUMMARY_PROMPT = `Create a structured summary of this conversation branch for context when returning later.
|
||||
|
||||
Use this EXACT format:
|
||||
|
||||
## Goal
|
||||
[What was the user trying to accomplish in this branch?]
|
||||
|
||||
## Constraints & Preferences
|
||||
- [Any constraints, preferences, or requirements mentioned]
|
||||
- [Or "(none)" if none were mentioned]
|
||||
|
||||
## Progress
|
||||
### Done
|
||||
- [x] [Completed tasks/changes]
|
||||
|
||||
### In Progress
|
||||
- [ ] [Work that was started but not finished]
|
||||
|
||||
### Blocked
|
||||
- [Issues preventing progress, if any]
|
||||
|
||||
## Key Decisions
|
||||
- **[Decision]**: [Brief rationale]
|
||||
|
||||
## Next Steps
|
||||
1. [What should happen next to continue this work]
|
||||
|
||||
Keep each section concise. Preserve exact file paths, function names, and error messages.`;
|
||||
|
||||
/** Generate a summary for abandoned branch entries. */
|
||||
export async function generateBranchSummary(
|
||||
entries: SessionTreeEntry[],
|
||||
options: GenerateBranchSummaryOptions,
|
||||
): Promise<Result<BranchSummaryResult, BranchSummaryError>> {
|
||||
const {
|
||||
model,
|
||||
apiKey,
|
||||
headers,
|
||||
signal,
|
||||
customInstructions,
|
||||
replaceInstructions,
|
||||
reserveTokens = 16384,
|
||||
} = options;
|
||||
const contextWindow = model.contextWindow || 128000;
|
||||
const tokenBudget = contextWindow - reserveTokens;
|
||||
|
||||
const { messages, fileOps } = prepareBranchEntries(entries, tokenBudget);
|
||||
|
||||
if (messages.length === 0) {
|
||||
return ok({ summary: "No content to summarize", readFiles: [], modifiedFiles: [] });
|
||||
}
|
||||
const llmMessages = convertToLlm(messages);
|
||||
const conversationText = serializeConversation(llmMessages);
|
||||
let instructions: string;
|
||||
if (replaceInstructions && customInstructions) {
|
||||
instructions = customInstructions;
|
||||
} else if (customInstructions) {
|
||||
instructions = `${BRANCH_SUMMARY_PROMPT}\n\nAdditional focus: ${customInstructions}`;
|
||||
} else {
|
||||
instructions = BRANCH_SUMMARY_PROMPT;
|
||||
}
|
||||
const promptText = `<conversation>\n${conversationText}\n</conversation>\n\n${instructions}`;
|
||||
|
||||
const summarizationMessages = [
|
||||
{
|
||||
role: "user" as const,
|
||||
content: [{ type: "text" as const, text: promptText }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
];
|
||||
const context = { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages };
|
||||
const streamOptions = { apiKey, headers, signal, maxTokens: 2048 };
|
||||
const response = options.streamFn
|
||||
? await (await options.streamFn(model, context, streamOptions)).result()
|
||||
: await resolveAgentCoreCompleteFn(options.runtime)(model, context, streamOptions);
|
||||
if (response.stopReason === "aborted") {
|
||||
return err(
|
||||
new BranchSummaryError("aborted", response.errorMessage || "Branch summary aborted"),
|
||||
);
|
||||
}
|
||||
if (response.stopReason === "error") {
|
||||
return err(
|
||||
new BranchSummaryError(
|
||||
"summarization_failed",
|
||||
`Branch summary failed: ${response.errorMessage || "Unknown error"}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
let summary = response.content
|
||||
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
||||
.map((c) => c.text)
|
||||
.join("\n");
|
||||
summary = BRANCH_SUMMARY_PREAMBLE + summary;
|
||||
const { readFiles, modifiedFiles } = computeFileLists(fileOps);
|
||||
summary += formatFileOperations(readFiles, modifiedFiles);
|
||||
|
||||
return ok({
|
||||
summary: summary || "No summary generated",
|
||||
readFiles,
|
||||
modifiedFiles,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ImageContent } from "../../llm.js";
|
||||
import type { AgentMessage } from "../../types.js";
|
||||
import type { SessionTreeEntry } from "../types.js";
|
||||
import { estimateTokens, findCutPoint } from "./compaction.js";
|
||||
|
||||
const IMAGE_PAYLOAD = "a".repeat(1_500_000);
|
||||
|
||||
function imageBlock(): ImageContent {
|
||||
return { type: "image", data: IMAGE_PAYLOAD, mimeType: "image/png" };
|
||||
}
|
||||
|
||||
function userImage(timestamp: number): AgentMessage {
|
||||
return { role: "user", content: [imageBlock()], timestamp };
|
||||
}
|
||||
|
||||
function userText(text: string, timestamp: number): AgentMessage {
|
||||
return { role: "user", content: [{ type: "text", text }], timestamp };
|
||||
}
|
||||
|
||||
function toolResultImage(timestamp: number): AgentMessage {
|
||||
return {
|
||||
role: "toolResult",
|
||||
toolCallId: "call-1",
|
||||
toolName: "screenshot",
|
||||
content: [imageBlock()],
|
||||
isError: false,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
function assistantText(text: string, timestamp: number): AgentMessage {
|
||||
return {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text }],
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic",
|
||||
model: "claude-fable-5",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
function messageEntry(message: AgentMessage, index: number): SessionTreeEntry {
|
||||
return {
|
||||
type: "message",
|
||||
id: `entry-${index}`,
|
||||
parentId: index === 0 ? null : `entry-${index - 1}`,
|
||||
timestamp: new Date(message.timestamp).toISOString(),
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
function buildTranscript(recentUserTurns: AgentMessage[]): SessionTreeEntry[] {
|
||||
const messages: AgentMessage[] = [userText("start of the conversation", 1)];
|
||||
let timestamp = 2;
|
||||
for (const turn of recentUserTurns) {
|
||||
messages.push(assistantText("ok", timestamp++));
|
||||
messages.push(turn);
|
||||
}
|
||||
return messages.map((message, index) => messageEntry(message, index));
|
||||
}
|
||||
|
||||
describe("estimateTokens image accounting", () => {
|
||||
it("charges a user-message image block the same as a tool-result image block", () => {
|
||||
const userTokens = estimateTokens(userImage(1));
|
||||
const toolTokens = estimateTokens(toolResultImage(1));
|
||||
|
||||
expect(userTokens).toBe(toolTokens);
|
||||
expect(userTokens).toBeGreaterThanOrEqual(1200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findCutPoint with image-heavy recent turns", () => {
|
||||
it("trims image-dominated user turns instead of keeping the whole transcript", () => {
|
||||
const entries = buildTranscript([userImage(10), userImage(20), userImage(30)]);
|
||||
|
||||
const result = findCutPoint(entries, 0, entries.length, 1500);
|
||||
|
||||
expect(result.firstKeptEntryIndex).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("matches the cut point of an equivalent text-cost control", () => {
|
||||
const equivalentText = "x".repeat(4800);
|
||||
const imageEntries = buildTranscript([userImage(10), userImage(20), userImage(30)]);
|
||||
const textEntries = buildTranscript([
|
||||
userText(equivalentText, 10),
|
||||
userText(equivalentText, 20),
|
||||
userText(equivalentText, 30),
|
||||
]);
|
||||
|
||||
const imageResult = findCutPoint(imageEntries, 0, imageEntries.length, 1500);
|
||||
const textResult = findCutPoint(textEntries, 0, textEntries.length, 1500);
|
||||
|
||||
expect(textResult.firstKeptEntryIndex).toBeGreaterThan(0);
|
||||
expect(imageResult.firstKeptEntryIndex).toBe(textResult.firstKeptEntryIndex);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AgentMessage } from "../../types.js";
|
||||
import type { SessionTreeEntry } from "../types.js";
|
||||
import { estimateTokens, findCutPoint } from "./compaction.js";
|
||||
|
||||
const KEEP_RECENT_TOKENS = 20000;
|
||||
const LARGE_TOOL_OUTPUT = "x".repeat(120000);
|
||||
|
||||
function userText(text: string, timestamp: number): AgentMessage {
|
||||
return { role: "user", content: [{ type: "text", text }], timestamp };
|
||||
}
|
||||
|
||||
function assistantText(text: string, timestamp: number): AgentMessage {
|
||||
return {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text }],
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic",
|
||||
model: "claude-fable-5",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
function toolResultText(text: string, timestamp: number): AgentMessage {
|
||||
return {
|
||||
role: "toolResult",
|
||||
toolCallId: "call-1",
|
||||
toolName: "bash",
|
||||
content: [{ type: "text", text }],
|
||||
isError: false,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
function nestedToolResult(
|
||||
block: { type: string; content?: unknown; text?: string },
|
||||
timestamp: number,
|
||||
): AgentMessage {
|
||||
return {
|
||||
role: "toolResult",
|
||||
toolCallId: "call-1",
|
||||
toolName: "codex_progress",
|
||||
content: [
|
||||
{
|
||||
id: "call-1",
|
||||
toolUseId: "call-1",
|
||||
...block,
|
||||
},
|
||||
],
|
||||
isError: false,
|
||||
timestamp,
|
||||
} as unknown as AgentMessage;
|
||||
}
|
||||
|
||||
function messageEntry(message: AgentMessage, index: number): SessionTreeEntry {
|
||||
return {
|
||||
type: "message",
|
||||
id: `entry-${index}`,
|
||||
parentId: index === 0 ? null : `entry-${index - 1}`,
|
||||
timestamp: new Date(message.timestamp).toISOString(),
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
function buildTranscript(): SessionTreeEntry[] {
|
||||
return buildTranscriptWithToolResult(toolResultText(LARGE_TOOL_OUTPUT, 5));
|
||||
}
|
||||
|
||||
function buildTranscriptWithToolResult(toolResult: AgentMessage): SessionTreeEntry[] {
|
||||
const messages: AgentMessage[] = [
|
||||
userText("start of the conversation", 1),
|
||||
assistantText("first reply", 2),
|
||||
userText("please run the command", 3),
|
||||
assistantText("running it now", 4),
|
||||
toolResult,
|
||||
];
|
||||
return messages.map((message, index) => messageEntry(message, index));
|
||||
}
|
||||
|
||||
describe("findCutPoint with a trailing oversized tool result", () => {
|
||||
it("counts the final tool result as larger than the keep budget", () => {
|
||||
const trailing = toolResultText(LARGE_TOOL_OUTPUT, 5);
|
||||
|
||||
expect(estimateTokens(trailing)).toBeGreaterThanOrEqual(KEEP_RECENT_TOKENS);
|
||||
});
|
||||
|
||||
it("trims the prefix instead of keeping the whole transcript", () => {
|
||||
const entries = buildTranscript();
|
||||
|
||||
const result = findCutPoint(entries, 0, entries.length, KEEP_RECENT_TOKENS);
|
||||
|
||||
expect(result.firstKeptEntryIndex).toBeGreaterThan(0);
|
||||
expect(result.firstKeptEntryIndex).toBe(3);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "Codex toolResult text",
|
||||
block: { type: "toolResult", content: "duplicate", text: LARGE_TOOL_OUTPUT },
|
||||
},
|
||||
{
|
||||
name: "snake-case tool_result content",
|
||||
block: { type: "tool_result", content: LARGE_TOOL_OUTPUT },
|
||||
},
|
||||
])("counts and trims the prefix for $name", ({ block }) => {
|
||||
const trailing = nestedToolResult(block, 5);
|
||||
const entries = buildTranscriptWithToolResult(trailing);
|
||||
|
||||
expect(estimateTokens(trailing)).toBeGreaterThanOrEqual(KEEP_RECENT_TOKENS);
|
||||
const result = findCutPoint(entries, 0, entries.length, KEEP_RECENT_TOKENS);
|
||||
|
||||
expect(result.firstKeptEntryIndex).toBeGreaterThan(0);
|
||||
expect(result.firstKeptEntryIndex).toBe(3);
|
||||
});
|
||||
});
|
||||
256
packages/agent-core/src/harness/compaction/compaction.test.ts
Normal file
256
packages/agent-core/src/harness/compaction/compaction.test.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createAssistantMessageEventStream } from "../../llm.js";
|
||||
import type { AssistantMessage, Model, StreamFn } from "../../llm.js";
|
||||
import {
|
||||
calculateContextTokens,
|
||||
compact,
|
||||
estimateContextTokens,
|
||||
generateSummary,
|
||||
} from "./compaction.js";
|
||||
import { createFileOps } from "./utils.js";
|
||||
|
||||
describe("calculateContextTokens", () => {
|
||||
it("prefers the final-iteration context snapshot over aggregate billing usage", () => {
|
||||
expect(
|
||||
calculateContextTokens({
|
||||
input: 12,
|
||||
output: 15_104,
|
||||
cacheRead: 819_661,
|
||||
cacheWrite: 93_130,
|
||||
contextUsage: {
|
||||
state: "available",
|
||||
promptTokens: 148_874,
|
||||
totalTokens: 163_978,
|
||||
},
|
||||
totalTokens: 927_907,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
}),
|
||||
).toBe(163_978);
|
||||
});
|
||||
|
||||
it("preserves the numeric compatibility fallback when the snapshot is unavailable", () => {
|
||||
expect(
|
||||
calculateContextTokens({
|
||||
input: 12,
|
||||
output: 15_104,
|
||||
cacheRead: 819_661,
|
||||
cacheWrite: 93_130,
|
||||
contextUsage: { state: "unavailable" },
|
||||
totalTokens: 927_907,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
}),
|
||||
).toBe(927_907);
|
||||
});
|
||||
|
||||
it("estimates the transcript instead of using aggregate billing when context is unavailable", () => {
|
||||
const estimate = estimateContextTokens([
|
||||
{ role: "user", content: "hello", timestamp: 0 },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "done" }],
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic",
|
||||
model: "claude-fable-5",
|
||||
usage: {
|
||||
input: 12,
|
||||
output: 15_104,
|
||||
cacheRead: 819_661,
|
||||
cacheWrite: 93_130,
|
||||
contextUsage: { state: "unavailable" },
|
||||
totalTokens: 927_907,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(estimate.tokens).toBeLessThan(927_907);
|
||||
expect(estimate.tokens).toBeGreaterThan(0);
|
||||
expect(estimate.usageTokens).toBe(0);
|
||||
expect(estimate.lastUsageIndex).toBeNull();
|
||||
});
|
||||
|
||||
it("uses the previous exact snapshot and estimates only the unavailable tail", () => {
|
||||
const estimate = estimateContextTokens([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "previous" }],
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic",
|
||||
model: "claude-fable-5",
|
||||
usage: {
|
||||
input: 12,
|
||||
output: 1_000,
|
||||
cacheRead: 148_862,
|
||||
cacheWrite: 0,
|
||||
contextUsage: {
|
||||
state: "available",
|
||||
promptTokens: 148_874,
|
||||
totalTokens: 149_874,
|
||||
},
|
||||
totalTokens: 149_874,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: 0,
|
||||
},
|
||||
{ role: "user", content: "next", timestamp: 1 },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "done" }],
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic",
|
||||
model: "claude-fable-5",
|
||||
usage: {
|
||||
input: 12,
|
||||
output: 15_104,
|
||||
cacheRead: 819_661,
|
||||
cacheWrite: 93_130,
|
||||
contextUsage: { state: "unavailable" },
|
||||
totalTokens: 927_907,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: 2,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(estimate.usageTokens).toBe(149_874);
|
||||
expect(estimate.tokens).toBeGreaterThan(149_874);
|
||||
expect(estimate.tokens).toBeLessThan(927_907);
|
||||
expect(estimate.lastUsageIndex).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateSummary thinking options", () => {
|
||||
it("maps explicit Fable off to low effort for compaction", async () => {
|
||||
const model: Model = {
|
||||
id: "production-fable",
|
||||
name: "Production Fable",
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic",
|
||||
baseUrl: "https://api.anthropic.com",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 1_000_000,
|
||||
maxTokens: 128_000,
|
||||
params: { canonicalModelId: "claude-fable-5" },
|
||||
};
|
||||
const summaryMessage: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "summary" }],
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: 1,
|
||||
};
|
||||
const streamFn = vi.fn<StreamFn>((_model, context, options) => {
|
||||
expect(options?.reasoning).toBe("low");
|
||||
expect(context.systemPrompt).toContain("user and an AI assistant");
|
||||
expect(context.systemPrompt).not.toContain("AI coding assistant");
|
||||
const stream = createAssistantMessageEventStream();
|
||||
stream.push({ type: "done", reason: "stop", message: summaryMessage });
|
||||
stream.end();
|
||||
return stream;
|
||||
});
|
||||
|
||||
const result = await generateSummary(
|
||||
[{ role: "user", content: "hello", timestamp: 1 }],
|
||||
model,
|
||||
1000,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
"off",
|
||||
streamFn,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ ok: true, value: "summary" });
|
||||
expect(streamFn).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("split-turn compaction", () => {
|
||||
it("serializes history and turn-prefix summaries", async () => {
|
||||
const model: Model = {
|
||||
id: "summary-model",
|
||||
name: "Summary Model",
|
||||
api: "test-api",
|
||||
provider: "test-provider",
|
||||
baseUrl: "https://example.test",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 100_000,
|
||||
maxTokens: 8_000,
|
||||
};
|
||||
let active = 0;
|
||||
let maxActive = 0;
|
||||
let callCount = 0;
|
||||
const streamFn = vi.fn<StreamFn>(() => {
|
||||
active++;
|
||||
maxActive = Math.max(maxActive, active);
|
||||
callCount++;
|
||||
const stream = createAssistantMessageEventStream();
|
||||
setTimeout(() => {
|
||||
active--;
|
||||
const message: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: `summary-${callCount}` }],
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: 1,
|
||||
};
|
||||
stream.push({ type: "done", reason: "stop", message });
|
||||
stream.end();
|
||||
}, 5);
|
||||
return stream;
|
||||
});
|
||||
|
||||
const result = await compact(
|
||||
{
|
||||
firstKeptEntryId: "kept-entry",
|
||||
messagesToSummarize: [{ role: "user", content: "history", timestamp: 1 }],
|
||||
turnPrefixMessages: [{ role: "user", content: "prefix", timestamp: 2 }],
|
||||
isSplitTurn: true,
|
||||
tokensBefore: 100,
|
||||
fileOps: createFileOps(),
|
||||
settings: { enabled: true, reserveTokens: 1_000, keepRecentTokens: 100 },
|
||||
},
|
||||
model,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
streamFn,
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(streamFn).toHaveBeenCalledTimes(2);
|
||||
expect(maxActive).toBe(1);
|
||||
});
|
||||
});
|
||||
900
packages/agent-core/src/harness/compaction/compaction.ts
Normal file
900
packages/agent-core/src/harness/compaction/compaction.ts
Normal file
@@ -0,0 +1,900 @@
|
||||
// Agent Core module implements compaction behavior.
|
||||
import {
|
||||
resolveClaudeFable5ModelIdentity,
|
||||
type AssistantMessage,
|
||||
type Context,
|
||||
type Model,
|
||||
type SimpleStreamOptions,
|
||||
type StreamFn,
|
||||
type Usage,
|
||||
} from "../../../../llm-core/src/index.js";
|
||||
import { resolveAgentReasoningOption } from "../../reasoning.js";
|
||||
import {
|
||||
type AgentCoreCompletionRuntimeDeps,
|
||||
resolveAgentCoreCompleteFn,
|
||||
} from "../../runtime-deps.js";
|
||||
import type { AgentMessage, ThinkingLevel } from "../../types.js";
|
||||
import {
|
||||
asAgentMessage,
|
||||
convertToLlm,
|
||||
createBranchSummaryMessage,
|
||||
createCompactionSummaryMessage,
|
||||
createCustomMessage,
|
||||
type HarnessMessage,
|
||||
} from "../messages.js";
|
||||
import { buildSessionContext } from "../session/session.js";
|
||||
import {
|
||||
type CompactionEntry,
|
||||
CompactionError,
|
||||
err,
|
||||
ok,
|
||||
type Result,
|
||||
type SessionTreeEntry,
|
||||
} from "../types.js";
|
||||
import {
|
||||
computeFileLists,
|
||||
createFileOps,
|
||||
extractFileOpsFromMessage,
|
||||
type FileOperations,
|
||||
formatFileOperations,
|
||||
getCompactionContentBlockText,
|
||||
serializeConversation,
|
||||
} from "./utils.js";
|
||||
|
||||
/** File-operation details stored on generated compaction entries. */
|
||||
export interface CompactionDetails {
|
||||
/** Files read in the compacted history. */
|
||||
readFiles: string[];
|
||||
/** Files modified in the compacted history. */
|
||||
modifiedFiles: string[];
|
||||
}
|
||||
function safeJsonStringify(value: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(value) ?? "undefined";
|
||||
} catch {
|
||||
return "[unserializable]";
|
||||
}
|
||||
}
|
||||
|
||||
function extractFileOperations(
|
||||
messages: AgentMessage[],
|
||||
entries: SessionTreeEntry[],
|
||||
prevCompactionIndex: number,
|
||||
): FileOperations {
|
||||
const fileOps = createFileOps();
|
||||
if (prevCompactionIndex >= 0) {
|
||||
const prevCompaction = entries[prevCompactionIndex] as CompactionEntry;
|
||||
if (!prevCompaction.fromHook && prevCompaction.details) {
|
||||
const details = prevCompaction.details as CompactionDetails;
|
||||
if (Array.isArray(details.readFiles)) {
|
||||
for (const f of details.readFiles) {
|
||||
fileOps.read.add(f);
|
||||
}
|
||||
}
|
||||
if (Array.isArray(details.modifiedFiles)) {
|
||||
for (const f of details.modifiedFiles) {
|
||||
fileOps.edited.add(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const msg of messages) {
|
||||
extractFileOpsFromMessage(msg, fileOps);
|
||||
}
|
||||
|
||||
return fileOps;
|
||||
}
|
||||
function getMessageFromEntry(entry: SessionTreeEntry): AgentMessage | undefined {
|
||||
if (entry.type === "message") {
|
||||
return entry.message;
|
||||
}
|
||||
if (entry.type === "custom_message") {
|
||||
return asAgentMessage(
|
||||
createCustomMessage(
|
||||
entry.customType,
|
||||
entry.content,
|
||||
entry.display,
|
||||
entry.details,
|
||||
entry.timestamp,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (entry.type === "branch_summary") {
|
||||
return asAgentMessage(createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp));
|
||||
}
|
||||
if (entry.type === "compaction") {
|
||||
return asAgentMessage(
|
||||
createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp),
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getMessageFromEntryForCompaction(entry: SessionTreeEntry): AgentMessage | undefined {
|
||||
if (entry.type === "compaction") {
|
||||
return undefined;
|
||||
}
|
||||
return getMessageFromEntry(entry);
|
||||
}
|
||||
|
||||
/** Generated compaction data ready to be persisted as a compaction entry. */
|
||||
export interface CompactionResult<T = unknown> {
|
||||
/** Summary text that replaces compacted history in future context. */
|
||||
summary: string;
|
||||
/** Entry id where retained history starts. */
|
||||
firstKeptEntryId: string;
|
||||
/** Estimated context tokens before compaction. */
|
||||
tokensBefore: number;
|
||||
/** Optional implementation-specific details stored with the compaction entry. */
|
||||
details?: T;
|
||||
}
|
||||
|
||||
/** Compaction thresholds and retention settings. */
|
||||
export interface CompactionSettings {
|
||||
/** Enable automatic compaction decisions. */
|
||||
enabled: boolean;
|
||||
/** Tokens reserved for summary prompt and output. */
|
||||
reserveTokens: number;
|
||||
/** Approximate recent-context tokens to keep after compaction. */
|
||||
keepRecentTokens: number;
|
||||
}
|
||||
|
||||
/** Default compaction settings used by the harness. */
|
||||
export const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = {
|
||||
enabled: true,
|
||||
reserveTokens: 16384,
|
||||
keepRecentTokens: 20000,
|
||||
};
|
||||
|
||||
/** Calculate total context tokens from provider usage. */
|
||||
export function calculateContextTokens(usage: Usage): number {
|
||||
if (usage.contextUsage?.state === "available") {
|
||||
return usage.contextUsage.totalTokens;
|
||||
}
|
||||
return usage.totalTokens || usage.input + usage.output + usage.cacheRead + usage.cacheWrite;
|
||||
}
|
||||
function getAssistantUsage(msg: AgentMessage): Usage | undefined {
|
||||
if (msg.role === "assistant" && "usage" in msg) {
|
||||
const assistantMsg = msg;
|
||||
if (
|
||||
assistantMsg.stopReason !== "aborted" &&
|
||||
assistantMsg.stopReason !== "error" &&
|
||||
assistantMsg.usage
|
||||
) {
|
||||
return assistantMsg.usage;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Return usage from the last successful assistant message in session entries. */
|
||||
export function getLastAssistantUsage(entries: SessionTreeEntry[]): Usage | undefined {
|
||||
for (let i = entries.length - 1; i >= 0; i--) {
|
||||
const entry = entries[i];
|
||||
if (entry.type === "message") {
|
||||
const usage = getAssistantUsage(entry.message);
|
||||
if (usage) {
|
||||
return usage;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Estimated context-token usage for a message list. */
|
||||
export interface ContextUsageEstimate {
|
||||
/** Estimated total context tokens. */
|
||||
tokens: number;
|
||||
/** Tokens reported by the most recent assistant usage block. */
|
||||
usageTokens: number;
|
||||
/** Estimated tokens not covered by usable provider usage. */
|
||||
trailingTokens: number;
|
||||
/** Index of the message that provided usage, or null when none exists. */
|
||||
lastUsageIndex: number | null;
|
||||
}
|
||||
|
||||
function getLastAssistantUsageInfo(
|
||||
messages: AgentMessage[],
|
||||
): { usage: Usage; index: number } | undefined {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const usage = getAssistantUsage(messages[i]);
|
||||
if (usage && usage.contextUsage?.state !== "unavailable") {
|
||||
return { usage, index: i };
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Estimate context tokens for messages using provider usage when available. */
|
||||
export function estimateContextTokens(messages: AgentMessage[]): ContextUsageEstimate {
|
||||
const usageInfo = getLastAssistantUsageInfo(messages);
|
||||
|
||||
if (!usageInfo) {
|
||||
let estimated = 0;
|
||||
for (const message of messages) {
|
||||
estimated += estimateTokens(message);
|
||||
}
|
||||
return {
|
||||
tokens: estimated,
|
||||
usageTokens: 0,
|
||||
trailingTokens: estimated,
|
||||
lastUsageIndex: null,
|
||||
};
|
||||
}
|
||||
|
||||
const usageTokens = calculateContextTokens(usageInfo.usage);
|
||||
let trailingTokens = 0;
|
||||
for (let i = usageInfo.index + 1; i < messages.length; i++) {
|
||||
trailingTokens += estimateTokens(messages[i]);
|
||||
}
|
||||
|
||||
return {
|
||||
tokens: usageTokens + trailingTokens,
|
||||
usageTokens,
|
||||
trailingTokens,
|
||||
lastUsageIndex: usageInfo.index,
|
||||
};
|
||||
}
|
||||
|
||||
/** Return whether context usage exceeds the configured compaction threshold. */
|
||||
export function shouldCompact(
|
||||
contextTokens: number,
|
||||
contextWindow: number,
|
||||
settings: CompactionSettings,
|
||||
): boolean {
|
||||
if (!settings.enabled) {
|
||||
return false;
|
||||
}
|
||||
return contextTokens > contextWindow - settings.reserveTokens;
|
||||
}
|
||||
|
||||
const IMAGE_BLOCK_CHARS = 4800;
|
||||
|
||||
function countContentBlockChars(
|
||||
content: Array<{ type: string; content?: unknown; text?: string }>,
|
||||
): number {
|
||||
let chars = 0;
|
||||
for (const block of content) {
|
||||
if (block.type === "image") {
|
||||
chars += IMAGE_BLOCK_CHARS;
|
||||
} else {
|
||||
chars += getCompactionContentBlockText(block).length;
|
||||
}
|
||||
}
|
||||
return chars;
|
||||
}
|
||||
|
||||
/** Estimate token count for one message using a conservative character heuristic. */
|
||||
export function estimateTokens(message: AgentMessage): number {
|
||||
let chars = 0;
|
||||
const harnessMessage = message as HarnessMessage;
|
||||
|
||||
switch (harnessMessage.role) {
|
||||
case "user": {
|
||||
const content = (
|
||||
harnessMessage as { content: string | Array<{ type: string; text?: string }> }
|
||||
).content;
|
||||
if (typeof content === "string") {
|
||||
chars = content.length;
|
||||
} else if (Array.isArray(content)) {
|
||||
chars = countContentBlockChars(content);
|
||||
}
|
||||
return Math.ceil(chars / 4);
|
||||
}
|
||||
case "assistant": {
|
||||
const assistant = harnessMessage;
|
||||
for (const block of assistant.content) {
|
||||
if (block.type === "text") {
|
||||
chars += block.text.length;
|
||||
} else if (block.type === "thinking") {
|
||||
chars += block.thinking.length;
|
||||
} else if (block.type === "toolCall") {
|
||||
chars += block.name.length + safeJsonStringify(block.arguments).length;
|
||||
}
|
||||
}
|
||||
return Math.ceil(chars / 4);
|
||||
}
|
||||
case "custom":
|
||||
case "toolResult": {
|
||||
if (typeof harnessMessage.content === "string") {
|
||||
chars = harnessMessage.content.length;
|
||||
} else {
|
||||
chars = countContentBlockChars(harnessMessage.content);
|
||||
}
|
||||
return Math.ceil(chars / 4);
|
||||
}
|
||||
case "bashExecution": {
|
||||
chars = harnessMessage.command.length + harnessMessage.output.length;
|
||||
return Math.ceil(chars / 4);
|
||||
}
|
||||
case "branchSummary":
|
||||
case "compactionSummary": {
|
||||
chars = harnessMessage.summary.length;
|
||||
return Math.ceil(chars / 4);
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
function findValidCutPoints(
|
||||
entries: SessionTreeEntry[],
|
||||
startIndex: number,
|
||||
endIndex: number,
|
||||
): number[] {
|
||||
const cutPoints: number[] = [];
|
||||
for (let i = startIndex; i < endIndex; i++) {
|
||||
const entry = entries[i];
|
||||
switch (entry.type) {
|
||||
case "message": {
|
||||
const role = (entry.message as HarnessMessage).role;
|
||||
switch (role) {
|
||||
case "bashExecution":
|
||||
case "custom":
|
||||
case "branchSummary":
|
||||
case "compactionSummary":
|
||||
case "user":
|
||||
case "assistant":
|
||||
cutPoints.push(i);
|
||||
break;
|
||||
case "toolResult":
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "thinking_level_change":
|
||||
case "model_change":
|
||||
case "compaction":
|
||||
case "branch_summary":
|
||||
case "custom":
|
||||
case "custom_message":
|
||||
case "label":
|
||||
case "session_info":
|
||||
case "leaf":
|
||||
break;
|
||||
}
|
||||
if (entry.type === "branch_summary" || entry.type === "custom_message") {
|
||||
cutPoints.push(i);
|
||||
}
|
||||
}
|
||||
return cutPoints;
|
||||
}
|
||||
|
||||
/** Find the user-visible message that starts the turn containing an entry. */
|
||||
export function findTurnStartIndex(
|
||||
entries: SessionTreeEntry[],
|
||||
entryIndex: number,
|
||||
startIndex: number,
|
||||
): number {
|
||||
for (let i = entryIndex; i >= startIndex; i--) {
|
||||
const entry = entries[i];
|
||||
if (entry.type === "branch_summary" || entry.type === "custom_message") {
|
||||
return i;
|
||||
}
|
||||
if (entry.type === "message") {
|
||||
const role = (entry.message as HarnessMessage).role;
|
||||
if (role === "user" || role === "bashExecution") {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** Cut point selected for compaction. */
|
||||
export interface CutPointResult {
|
||||
/** Index of the first entry retained after compaction. */
|
||||
firstKeptEntryIndex: number;
|
||||
/** Index of the turn-start entry when the cut splits a turn, otherwise -1. */
|
||||
turnStartIndex: number;
|
||||
/** Whether the selected cut point splits an in-progress turn. */
|
||||
isSplitTurn: boolean;
|
||||
}
|
||||
|
||||
/** Find the compaction cut point that keeps approximately the requested recent-token budget. */
|
||||
export function findCutPoint(
|
||||
entries: SessionTreeEntry[],
|
||||
startIndex: number,
|
||||
endIndex: number,
|
||||
keepRecentTokens: number,
|
||||
): CutPointResult {
|
||||
const cutPoints = findValidCutPoints(entries, startIndex, endIndex);
|
||||
|
||||
if (cutPoints.length === 0) {
|
||||
return { firstKeptEntryIndex: startIndex, turnStartIndex: -1, isSplitTurn: false };
|
||||
}
|
||||
let accumulatedTokens = 0;
|
||||
let cutIndex = cutPoints[0];
|
||||
|
||||
for (let i = endIndex - 1; i >= startIndex; i--) {
|
||||
const entry = entries[i];
|
||||
if (entry.type !== "message") {
|
||||
continue;
|
||||
}
|
||||
const messageTokens = estimateTokens(entry.message);
|
||||
accumulatedTokens += messageTokens;
|
||||
if (accumulatedTokens >= keepRecentTokens) {
|
||||
cutIndex = cutPoints[cutPoints.length - 1];
|
||||
for (const cutPoint of cutPoints) {
|
||||
if (cutPoint >= i) {
|
||||
cutIndex = cutPoint;
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
while (cutIndex > startIndex) {
|
||||
const prevEntry = entries[cutIndex - 1];
|
||||
if (prevEntry.type === "compaction") {
|
||||
break;
|
||||
}
|
||||
if (prevEntry.type === "message") {
|
||||
break;
|
||||
}
|
||||
cutIndex--;
|
||||
}
|
||||
const cutEntry = entries[cutIndex];
|
||||
const isUserMessage = cutEntry.type === "message" && cutEntry.message.role === "user";
|
||||
const turnStartIndex = isUserMessage ? -1 : findTurnStartIndex(entries, cutIndex, startIndex);
|
||||
|
||||
return {
|
||||
firstKeptEntryIndex: cutIndex,
|
||||
turnStartIndex,
|
||||
isSplitTurn: !isUserMessage && turnStartIndex !== -1,
|
||||
};
|
||||
}
|
||||
|
||||
export const SUMMARIZATION_SYSTEM_PROMPT = `You are a context summarization assistant. Your task is to read a conversation between a user and an AI assistant, then produce a structured summary following the exact format specified.
|
||||
|
||||
Do NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the structured summary.`;
|
||||
|
||||
const SUMMARIZATION_PROMPT = `The messages above are a conversation to summarize. Create a structured context checkpoint summary that another LLM will use to continue the work.
|
||||
|
||||
Use this EXACT format:
|
||||
|
||||
## Goal
|
||||
[What is the user trying to accomplish? Can be multiple items if the session covers different tasks.]
|
||||
|
||||
## Constraints & Preferences
|
||||
- [Any constraints, preferences, or requirements mentioned by user]
|
||||
- [Or "(none)" if none were mentioned]
|
||||
|
||||
## Progress
|
||||
### Done
|
||||
- [x] [Completed tasks/changes]
|
||||
|
||||
### In Progress
|
||||
- [ ] [Current work]
|
||||
|
||||
### Blocked
|
||||
- [Issues preventing progress, if any]
|
||||
|
||||
## Key Decisions
|
||||
- **[Decision]**: [Brief rationale]
|
||||
|
||||
## Next Steps
|
||||
1. [Ordered list of what should happen next]
|
||||
|
||||
## Critical Context
|
||||
- [Any data, examples, or references needed to continue]
|
||||
- [Or "(none)" if not applicable]
|
||||
|
||||
Keep each section concise. Preserve exact file paths, function names, and error messages.`;
|
||||
|
||||
const UPDATE_SUMMARIZATION_PROMPT = `The messages above are NEW conversation messages to incorporate into the existing summary provided in <previous-summary> tags.
|
||||
|
||||
Update the existing structured summary with new information. RULES:
|
||||
- PRESERVE all existing information from the previous summary
|
||||
- ADD new progress, decisions, and context from the new messages
|
||||
- UPDATE the Progress section: move items from "In Progress" to "Done" when completed
|
||||
- UPDATE "Next Steps" based on what was accomplished
|
||||
- PRESERVE exact file paths, function names, and error messages
|
||||
- If something is no longer relevant, you may remove it
|
||||
|
||||
Use this EXACT format:
|
||||
|
||||
## Goal
|
||||
[Preserve existing goals, add new ones if the task expanded]
|
||||
|
||||
## Constraints & Preferences
|
||||
- [Preserve existing, add new ones discovered]
|
||||
|
||||
## Progress
|
||||
### Done
|
||||
- [x] [Include previously done items AND newly completed items]
|
||||
|
||||
### In Progress
|
||||
- [ ] [Current work - update based on progress]
|
||||
|
||||
### Blocked
|
||||
- [Current blockers - remove if resolved]
|
||||
|
||||
## Key Decisions
|
||||
- **[Decision]**: [Brief rationale] (preserve all previous, add new)
|
||||
|
||||
## Next Steps
|
||||
1. [Update based on current state]
|
||||
|
||||
## Critical Context
|
||||
- [Preserve important context, add new if needed]
|
||||
|
||||
Keep each section concise. Preserve exact file paths, function names, and error messages.`;
|
||||
|
||||
function createSummarizationOptions(
|
||||
model: Model,
|
||||
maxTokens: number,
|
||||
apiKey: string | undefined,
|
||||
headers: Record<string, string> | undefined,
|
||||
signal: AbortSignal | undefined,
|
||||
thinkingLevel: ThinkingLevel | undefined,
|
||||
): SimpleStreamOptions {
|
||||
const options: SimpleStreamOptions = { maxTokens, signal, apiKey, headers };
|
||||
const fableReasoning =
|
||||
(model.api === "anthropic-messages" || model.api === "bedrock-converse-stream") &&
|
||||
resolveClaudeFable5ModelIdentity(model) !== undefined;
|
||||
if ((model.reasoning || fableReasoning) && thinkingLevel) {
|
||||
options.reasoning = resolveAgentReasoningOption(model, thinkingLevel);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
async function completeSummarization(
|
||||
model: Model,
|
||||
context: Context,
|
||||
options: SimpleStreamOptions,
|
||||
streamFn?: StreamFn,
|
||||
runtime?: AgentCoreCompletionRuntimeDeps,
|
||||
): Promise<AssistantMessage> {
|
||||
if (streamFn) {
|
||||
return (await streamFn(model, context, options)).result();
|
||||
}
|
||||
return await resolveAgentCoreCompleteFn(runtime)(model, context, options);
|
||||
}
|
||||
|
||||
/** Runs one summarization completion and maps abort/error stops to CompactionError. */
|
||||
async function runSummarizationCompletion(params: {
|
||||
promptText: string;
|
||||
model: Model;
|
||||
maxTokens: number;
|
||||
apiKey: string | undefined;
|
||||
headers?: Record<string, string>;
|
||||
signal?: AbortSignal;
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
streamFn?: StreamFn;
|
||||
runtime?: AgentCoreCompletionRuntimeDeps;
|
||||
errorLabel: string;
|
||||
}): Promise<Result<string, CompactionError>> {
|
||||
const summarizationMessages = [
|
||||
{
|
||||
role: "user" as const,
|
||||
content: [{ type: "text" as const, text: params.promptText }],
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
];
|
||||
|
||||
const response = await completeSummarization(
|
||||
params.model,
|
||||
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
|
||||
createSummarizationOptions(
|
||||
params.model,
|
||||
params.maxTokens,
|
||||
params.apiKey,
|
||||
params.headers,
|
||||
params.signal,
|
||||
params.thinkingLevel,
|
||||
),
|
||||
params.streamFn,
|
||||
params.runtime,
|
||||
);
|
||||
if (response.stopReason === "aborted") {
|
||||
return err(
|
||||
new CompactionError("aborted", response.errorMessage || `${params.errorLabel} aborted`),
|
||||
);
|
||||
}
|
||||
if (response.stopReason === "error") {
|
||||
return err(
|
||||
new CompactionError(
|
||||
"summarization_failed",
|
||||
`${params.errorLabel} failed: ${response.errorMessage || "Unknown error"}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ok(
|
||||
response.content
|
||||
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
||||
.map((c) => c.text)
|
||||
.join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
/** Generate or update a conversation summary for compaction. */
|
||||
export async function generateSummary(
|
||||
currentMessages: AgentMessage[],
|
||||
model: Model,
|
||||
reserveTokens: number,
|
||||
apiKey: string | undefined,
|
||||
headers?: Record<string, string>,
|
||||
signal?: AbortSignal,
|
||||
customInstructions?: string,
|
||||
previousSummary?: string,
|
||||
thinkingLevel?: ThinkingLevel,
|
||||
streamFn?: StreamFn,
|
||||
runtime?: AgentCoreCompletionRuntimeDeps,
|
||||
): Promise<Result<string, CompactionError>> {
|
||||
const maxTokens = Math.min(
|
||||
Math.floor(0.8 * reserveTokens),
|
||||
model.maxTokens > 0 ? model.maxTokens : Number.POSITIVE_INFINITY,
|
||||
);
|
||||
let basePrompt = previousSummary ? UPDATE_SUMMARIZATION_PROMPT : SUMMARIZATION_PROMPT;
|
||||
if (customInstructions) {
|
||||
basePrompt = `${basePrompt}\n\nAdditional focus: ${customInstructions}`;
|
||||
}
|
||||
const llmMessages = convertToLlm(currentMessages);
|
||||
const conversationText = serializeConversation(llmMessages);
|
||||
let promptText = `<conversation>\n${conversationText}\n</conversation>\n\n`;
|
||||
if (previousSummary) {
|
||||
promptText += `<previous-summary>\n${previousSummary}\n</previous-summary>\n\n`;
|
||||
}
|
||||
promptText += basePrompt;
|
||||
|
||||
return await runSummarizationCompletion({
|
||||
promptText,
|
||||
model,
|
||||
maxTokens,
|
||||
apiKey,
|
||||
headers,
|
||||
signal,
|
||||
thinkingLevel,
|
||||
streamFn,
|
||||
runtime,
|
||||
errorLabel: "Summarization",
|
||||
});
|
||||
}
|
||||
|
||||
/** Prepared inputs for a compaction run. */
|
||||
export interface CompactionPreparation {
|
||||
/** Entry id where retained history starts. */
|
||||
firstKeptEntryId: string;
|
||||
/** Messages summarized into the history summary. */
|
||||
messagesToSummarize: AgentMessage[];
|
||||
/** Prefix messages summarized separately when compaction splits a turn. */
|
||||
turnPrefixMessages: AgentMessage[];
|
||||
/** Whether compaction splits a turn. */
|
||||
isSplitTurn: boolean;
|
||||
/** Estimated context tokens before compaction. */
|
||||
tokensBefore: number;
|
||||
/** Previous compaction summary used for iterative updates. */
|
||||
previousSummary?: string;
|
||||
/** File operations extracted from summarized history. */
|
||||
fileOps: FileOperations;
|
||||
/** Settings used to prepare compaction. */
|
||||
settings: CompactionSettings;
|
||||
}
|
||||
|
||||
/** Prepare session entries for compaction, or return undefined when compaction is not applicable. */
|
||||
export function prepareCompaction(
|
||||
pathEntries: SessionTreeEntry[],
|
||||
settings: CompactionSettings,
|
||||
): Result<CompactionPreparation | undefined, CompactionError> {
|
||||
if (pathEntries.length === 0 || pathEntries[pathEntries.length - 1].type === "compaction") {
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
let prevCompactionIndex = -1;
|
||||
for (let i = pathEntries.length - 1; i >= 0; i--) {
|
||||
if (pathEntries[i].type === "compaction") {
|
||||
prevCompactionIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let previousSummary: string | undefined;
|
||||
let boundaryStart = 0;
|
||||
if (prevCompactionIndex >= 0) {
|
||||
const prevCompaction = pathEntries[prevCompactionIndex] as CompactionEntry;
|
||||
previousSummary = prevCompaction.summary;
|
||||
const firstKeptEntryIndex = pathEntries.findIndex(
|
||||
(entry) => entry.id === prevCompaction.firstKeptEntryId,
|
||||
);
|
||||
boundaryStart = firstKeptEntryIndex >= 0 ? firstKeptEntryIndex : prevCompactionIndex + 1;
|
||||
}
|
||||
const boundaryEnd = pathEntries.length;
|
||||
|
||||
const tokensBefore = estimateContextTokens(buildSessionContext(pathEntries).messages).tokens;
|
||||
|
||||
const cutPoint = findCutPoint(pathEntries, boundaryStart, boundaryEnd, settings.keepRecentTokens);
|
||||
const firstKeptEntry = pathEntries[cutPoint.firstKeptEntryIndex];
|
||||
if (!firstKeptEntry?.id) {
|
||||
return err(
|
||||
new CompactionError(
|
||||
"invalid_session",
|
||||
"First kept entry has no UUID - session may need migration",
|
||||
),
|
||||
);
|
||||
}
|
||||
const firstKeptEntryId = firstKeptEntry.id;
|
||||
|
||||
const historyEnd = cutPoint.isSplitTurn ? cutPoint.turnStartIndex : cutPoint.firstKeptEntryIndex;
|
||||
const messagesToSummarize: AgentMessage[] = [];
|
||||
for (let i = boundaryStart; i < historyEnd; i++) {
|
||||
const msg = getMessageFromEntryForCompaction(pathEntries[i]);
|
||||
if (msg) {
|
||||
messagesToSummarize.push(msg);
|
||||
}
|
||||
}
|
||||
const turnPrefixMessages: AgentMessage[] = [];
|
||||
if (cutPoint.isSplitTurn) {
|
||||
for (let i = cutPoint.turnStartIndex; i < cutPoint.firstKeptEntryIndex; i++) {
|
||||
const msg = getMessageFromEntryForCompaction(pathEntries[i]);
|
||||
if (msg) {
|
||||
turnPrefixMessages.push(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
const fileOps = extractFileOperations(messagesToSummarize, pathEntries, prevCompactionIndex);
|
||||
if (cutPoint.isSplitTurn) {
|
||||
for (const msg of turnPrefixMessages) {
|
||||
extractFileOpsFromMessage(msg, fileOps);
|
||||
}
|
||||
}
|
||||
|
||||
return ok({
|
||||
firstKeptEntryId,
|
||||
messagesToSummarize,
|
||||
turnPrefixMessages,
|
||||
isSplitTurn: cutPoint.isSplitTurn,
|
||||
tokensBefore,
|
||||
previousSummary,
|
||||
fileOps,
|
||||
settings,
|
||||
});
|
||||
}
|
||||
|
||||
const TURN_PREFIX_SUMMARIZATION_PROMPT = `This is the PREFIX of a turn that was too large to keep. The SUFFIX (recent work) is retained.
|
||||
|
||||
Summarize the prefix to provide context for the retained suffix:
|
||||
|
||||
## Original Request
|
||||
[What did the user ask for in this turn?]
|
||||
|
||||
## Early Progress
|
||||
- [Key decisions and work done in the prefix]
|
||||
|
||||
## Context for Suffix
|
||||
- [Information needed to understand the retained recent work]
|
||||
|
||||
Be concise. Focus on what's needed to understand the kept suffix.`;
|
||||
|
||||
export { serializeConversation } from "./utils.js";
|
||||
|
||||
/** Generate compaction summary data from prepared session history. */
|
||||
export async function compact(
|
||||
preparation: CompactionPreparation,
|
||||
model: Model,
|
||||
apiKey: string | undefined,
|
||||
headers?: Record<string, string>,
|
||||
customInstructions?: string,
|
||||
signal?: AbortSignal,
|
||||
thinkingLevel?: ThinkingLevel,
|
||||
streamFn?: StreamFn,
|
||||
runtime?: AgentCoreCompletionRuntimeDeps,
|
||||
): Promise<Result<CompactionResult, CompactionError>> {
|
||||
const {
|
||||
firstKeptEntryId,
|
||||
messagesToSummarize,
|
||||
turnPrefixMessages,
|
||||
isSplitTurn,
|
||||
tokensBefore,
|
||||
previousSummary,
|
||||
fileOps,
|
||||
settings,
|
||||
} = preparation;
|
||||
|
||||
if (!firstKeptEntryId) {
|
||||
return err(
|
||||
new CompactionError(
|
||||
"invalid_session",
|
||||
"First kept entry has no UUID - session may need migration",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
let summary: string;
|
||||
|
||||
if (isSplitTurn && turnPrefixMessages.length > 0) {
|
||||
const historyResult =
|
||||
messagesToSummarize.length > 0
|
||||
? await generateSummary(
|
||||
messagesToSummarize,
|
||||
model,
|
||||
settings.reserveTokens,
|
||||
apiKey,
|
||||
headers,
|
||||
signal,
|
||||
customInstructions,
|
||||
previousSummary,
|
||||
thinkingLevel,
|
||||
streamFn,
|
||||
runtime,
|
||||
)
|
||||
: ok<string, CompactionError>("No prior history.");
|
||||
if (!historyResult.ok) {
|
||||
return err(historyResult.error);
|
||||
}
|
||||
const turnPrefixResult = await generateTurnPrefixSummary(
|
||||
turnPrefixMessages,
|
||||
model,
|
||||
settings.reserveTokens,
|
||||
apiKey,
|
||||
headers,
|
||||
signal,
|
||||
thinkingLevel,
|
||||
streamFn,
|
||||
runtime,
|
||||
);
|
||||
if (!turnPrefixResult.ok) {
|
||||
return err(turnPrefixResult.error);
|
||||
}
|
||||
summary = `${historyResult.value}\n\n---\n\n**Turn Context (split turn):**\n\n${turnPrefixResult.value}`;
|
||||
} else {
|
||||
const summaryResult = await generateSummary(
|
||||
messagesToSummarize,
|
||||
model,
|
||||
settings.reserveTokens,
|
||||
apiKey,
|
||||
headers,
|
||||
signal,
|
||||
customInstructions,
|
||||
previousSummary,
|
||||
thinkingLevel,
|
||||
streamFn,
|
||||
runtime,
|
||||
);
|
||||
if (!summaryResult.ok) {
|
||||
return err(summaryResult.error);
|
||||
}
|
||||
summary = summaryResult.value;
|
||||
}
|
||||
|
||||
const { readFiles, modifiedFiles } = computeFileLists(fileOps);
|
||||
summary += formatFileOperations(readFiles, modifiedFiles);
|
||||
|
||||
return ok({
|
||||
summary,
|
||||
firstKeptEntryId,
|
||||
tokensBefore,
|
||||
details: { readFiles, modifiedFiles } as CompactionDetails,
|
||||
});
|
||||
}
|
||||
async function generateTurnPrefixSummary(
|
||||
messages: AgentMessage[],
|
||||
model: Model,
|
||||
reserveTokens: number,
|
||||
apiKey: string | undefined,
|
||||
headers?: Record<string, string>,
|
||||
signal?: AbortSignal,
|
||||
thinkingLevel?: ThinkingLevel,
|
||||
streamFn?: StreamFn,
|
||||
runtime?: AgentCoreCompletionRuntimeDeps,
|
||||
): Promise<Result<string, CompactionError>> {
|
||||
const maxTokens = Math.min(
|
||||
Math.floor(0.5 * reserveTokens),
|
||||
model.maxTokens > 0 ? model.maxTokens : Number.POSITIVE_INFINITY,
|
||||
);
|
||||
const llmMessages = convertToLlm(messages);
|
||||
const conversationText = serializeConversation(llmMessages);
|
||||
const promptText = `<conversation>\n${conversationText}\n</conversation>\n\n${TURN_PREFIX_SUMMARIZATION_PROMPT}`;
|
||||
return await runSummarizationCompletion({
|
||||
promptText,
|
||||
model,
|
||||
maxTokens,
|
||||
apiKey,
|
||||
headers,
|
||||
signal,
|
||||
thinkingLevel,
|
||||
streamFn,
|
||||
runtime,
|
||||
errorLabel: "Turn prefix summarization",
|
||||
});
|
||||
}
|
||||
36
packages/agent-core/src/harness/compaction/utils.test.ts
Normal file
36
packages/agent-core/src/harness/compaction/utils.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Message } from "../../../../llm-core/src/index.js";
|
||||
import { serializeConversation } from "./utils.js";
|
||||
|
||||
describe("serializeConversation", () => {
|
||||
it.each([
|
||||
{
|
||||
name: "Codex nested toolResult text",
|
||||
block: {
|
||||
type: "toolResult",
|
||||
id: "call-1",
|
||||
toolUseId: "call-1",
|
||||
content: "duplicate fallback",
|
||||
text: "codex nested output",
|
||||
},
|
||||
expected: "codex nested output",
|
||||
},
|
||||
{
|
||||
name: "snake-case nested tool_result content fallback",
|
||||
block: {
|
||||
type: "tool_result",
|
||||
content: "fallback output",
|
||||
},
|
||||
expected: "fallback output",
|
||||
},
|
||||
])("serializes $name", ({ block, expected }) => {
|
||||
const messages = [
|
||||
{
|
||||
role: "toolResult",
|
||||
content: [block],
|
||||
},
|
||||
] as unknown as Message[];
|
||||
|
||||
expect(serializeConversation(messages)).toBe(`[Tool result]: ${expected}`);
|
||||
});
|
||||
});
|
||||
183
packages/agent-core/src/harness/compaction/utils.ts
Normal file
183
packages/agent-core/src/harness/compaction/utils.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
// Agent Core helper module supports utils behavior.
|
||||
import type { Message } from "../../../../llm-core/src/index.js";
|
||||
import type { AgentMessage } from "../../types.js";
|
||||
|
||||
/** File paths touched by a session branch or compaction range. */
|
||||
export interface FileOperations {
|
||||
/** Files read but not necessarily modified. */
|
||||
read: Set<string>;
|
||||
/** Files written by full-file write operations. */
|
||||
written: Set<string>;
|
||||
/** Files modified by edit operations. */
|
||||
edited: Set<string>;
|
||||
}
|
||||
|
||||
/** Create an empty file-operation accumulator. */
|
||||
export function createFileOps(): FileOperations {
|
||||
return {
|
||||
read: new Set(),
|
||||
written: new Set(),
|
||||
edited: new Set(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Add file operations from assistant tool calls to an accumulator. */
|
||||
export function extractFileOpsFromMessage(message: AgentMessage, fileOps: FileOperations): void {
|
||||
if (message.role !== "assistant") {
|
||||
return;
|
||||
}
|
||||
if (!("content" in message) || !Array.isArray(message.content)) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const block of message.content) {
|
||||
if (typeof block !== "object" || block === null) {
|
||||
continue;
|
||||
}
|
||||
if (!("type" in block) || block.type !== "toolCall") {
|
||||
continue;
|
||||
}
|
||||
if (!("arguments" in block) || !("name" in block)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const args = block.arguments as Record<string, unknown> | undefined;
|
||||
if (!args) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const path = typeof args.path === "string" ? args.path : undefined;
|
||||
if (!path) {
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (block.name) {
|
||||
case "read":
|
||||
fileOps.read.add(path);
|
||||
break;
|
||||
case "write":
|
||||
fileOps.written.add(path);
|
||||
break;
|
||||
case "edit":
|
||||
fileOps.edited.add(path);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Compute sorted read-only and modified file lists from accumulated operations. */
|
||||
export function computeFileLists(fileOps: FileOperations): {
|
||||
readFiles: string[];
|
||||
modifiedFiles: string[];
|
||||
} {
|
||||
const modified = new Set([...fileOps.edited, ...fileOps.written]);
|
||||
const readOnly = [...fileOps.read].filter((f) => !modified.has(f)).toSorted();
|
||||
const modifiedFiles = [...modified].toSorted();
|
||||
return { readFiles: readOnly, modifiedFiles };
|
||||
}
|
||||
|
||||
/** Format file lists as summary metadata tags. */
|
||||
export function formatFileOperations(readFiles: string[], modifiedFiles: string[]): string {
|
||||
const sections: string[] = [];
|
||||
if (readFiles.length > 0) {
|
||||
sections.push(`<read-files>\n${readFiles.join("\n")}\n</read-files>`);
|
||||
}
|
||||
if (modifiedFiles.length > 0) {
|
||||
sections.push(`<modified-files>\n${modifiedFiles.join("\n")}\n</modified-files>`);
|
||||
}
|
||||
if (sections.length === 0) {
|
||||
return "";
|
||||
}
|
||||
return `\n\n${sections.join("\n\n")}`;
|
||||
}
|
||||
|
||||
const TOOL_RESULT_MAX_CHARS = 2000;
|
||||
|
||||
function safeJsonStringify(value: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(value) ?? "undefined";
|
||||
} catch {
|
||||
return "[unserializable]";
|
||||
}
|
||||
}
|
||||
|
||||
function truncateForSummary(text: string, maxChars: number): string {
|
||||
if (text.length <= maxChars) {
|
||||
return text;
|
||||
}
|
||||
const truncatedChars = text.length - maxChars;
|
||||
return `${text.slice(0, maxChars)}\n\n[... ${truncatedChars} more characters truncated]`;
|
||||
}
|
||||
|
||||
/** Extract text that compaction both estimates and includes in summary prompts. */
|
||||
export function getCompactionContentBlockText(block: {
|
||||
type: string;
|
||||
content?: unknown;
|
||||
text?: string;
|
||||
}): string {
|
||||
if (block.type === "text" && block.text) {
|
||||
return block.text;
|
||||
}
|
||||
if (block.type !== "toolResult" && block.type !== "tool_result") {
|
||||
return "";
|
||||
}
|
||||
if (block.text) {
|
||||
return block.text;
|
||||
}
|
||||
return typeof block.content === "string" ? block.content : "";
|
||||
}
|
||||
|
||||
/** Serialize LLM messages to plain text for summarization prompts. */
|
||||
export function serializeConversation(messages: Message[]): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.role === "user") {
|
||||
const content =
|
||||
typeof msg.content === "string"
|
||||
? msg.content
|
||||
: msg.content
|
||||
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
||||
.map((c) => c.text)
|
||||
.join("");
|
||||
if (content) {
|
||||
parts.push(`[User]: ${content}`);
|
||||
}
|
||||
} else if (msg.role === "assistant") {
|
||||
const textParts: string[] = [];
|
||||
const thinkingParts: string[] = [];
|
||||
const toolCalls: string[] = [];
|
||||
|
||||
for (const block of msg.content) {
|
||||
if (block.type === "text") {
|
||||
textParts.push(block.text);
|
||||
} else if (block.type === "thinking") {
|
||||
thinkingParts.push(block.thinking);
|
||||
} else if (block.type === "toolCall") {
|
||||
const args = block.arguments;
|
||||
const argsStr = Object.entries(args)
|
||||
.map(([k, v]) => `${k}=${safeJsonStringify(v)}`)
|
||||
.join(", ");
|
||||
toolCalls.push(`${block.name}(${argsStr})`);
|
||||
}
|
||||
}
|
||||
|
||||
if (thinkingParts.length > 0) {
|
||||
parts.push(`[Assistant thinking]: ${thinkingParts.join("\n")}`);
|
||||
}
|
||||
if (textParts.length > 0) {
|
||||
parts.push(`[Assistant]: ${textParts.join("\n")}`);
|
||||
}
|
||||
if (toolCalls.length > 0) {
|
||||
parts.push(`[Assistant tool calls]: ${toolCalls.join("; ")}`);
|
||||
}
|
||||
} else if (msg.role === "toolResult") {
|
||||
const content = msg.content.map(getCompactionContentBlockText).join("");
|
||||
if (content) {
|
||||
parts.push(`[Tool result]: ${truncateForSummary(content, TOOL_RESULT_MAX_CHARS)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join("\n\n");
|
||||
}
|
||||
138
packages/agent-core/src/harness/env/kill-tree.ts
vendored
Normal file
138
packages/agent-core/src/harness/env/kill-tree.ts
vendored
Normal file
@@ -0,0 +1,138 @@
|
||||
// Agent Core module implements kill tree behavior.
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
const DEFAULT_GRACE_MS = 3000;
|
||||
const MAX_GRACE_MS = 60_000;
|
||||
|
||||
export type KillProcessTreeOptions = {
|
||||
graceMs?: number;
|
||||
detached?: boolean;
|
||||
force?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Best-effort process-tree termination with graceful shutdown.
|
||||
* - Windows: use taskkill /T to include descendants. Sends SIGTERM-equivalent
|
||||
* first (without /F), then force-kills if process survives.
|
||||
* - Unix: send SIGTERM to process group first, wait grace period, then SIGKILL.
|
||||
*
|
||||
* When the child was spawned with `detached: false`, pass `detached: false` to
|
||||
* skip the Unix `process.kill(-pid, ...)` group-kill. That avoids signaling the
|
||||
* gateway's own process group.
|
||||
*/
|
||||
export function killProcessTree(pid: number, opts?: KillProcessTreeOptions): void {
|
||||
if (!Number.isFinite(pid) || pid <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.platform === "win32") {
|
||||
if (opts?.force === true) {
|
||||
signalProcessTreeWindows(pid, "SIGKILL");
|
||||
return;
|
||||
}
|
||||
const graceMs = normalizeGraceMs(opts?.graceMs);
|
||||
killProcessTreeWindows(pid, graceMs);
|
||||
return;
|
||||
}
|
||||
|
||||
const useGroupKill = opts?.detached !== false;
|
||||
if (opts?.force === true) {
|
||||
signalProcessTreeUnix(pid, "SIGKILL", useGroupKill);
|
||||
return;
|
||||
}
|
||||
|
||||
const graceMs = normalizeGraceMs(opts?.graceMs);
|
||||
signalProcessTreeUnix(pid, "SIGTERM", useGroupKill);
|
||||
setTimeout(() => {
|
||||
const stillAlive = useGroupKill
|
||||
? isProcessAlive(-pid) || isProcessAlive(pid)
|
||||
: isProcessAlive(pid);
|
||||
if (!stillAlive) {
|
||||
return;
|
||||
}
|
||||
signalProcessTreeUnix(pid, "SIGKILL", useGroupKill);
|
||||
}, graceMs).unref();
|
||||
}
|
||||
|
||||
export function signalProcessTree(
|
||||
pid: number,
|
||||
signal: "SIGTERM" | "SIGKILL",
|
||||
opts?: { detached?: boolean },
|
||||
): void {
|
||||
if (!Number.isFinite(pid) || pid <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.platform === "win32") {
|
||||
signalProcessTreeWindows(pid, signal);
|
||||
return;
|
||||
}
|
||||
|
||||
signalProcessTreeUnix(pid, signal, opts?.detached !== false);
|
||||
}
|
||||
|
||||
function normalizeGraceMs(value?: number): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
return DEFAULT_GRACE_MS;
|
||||
}
|
||||
return Math.max(0, Math.min(MAX_GRACE_MS, Math.floor(value)));
|
||||
}
|
||||
|
||||
function isProcessAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function signalProcessTreeUnix(
|
||||
pid: number,
|
||||
signal: "SIGTERM" | "SIGKILL",
|
||||
useGroupKill: boolean,
|
||||
): void {
|
||||
if (useGroupKill) {
|
||||
try {
|
||||
process.kill(-pid, signal);
|
||||
return;
|
||||
} catch {
|
||||
// Process group does not exist or we lack permission; try direct pid.
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(pid, signal);
|
||||
} catch {
|
||||
// Already gone.
|
||||
}
|
||||
}
|
||||
|
||||
function runTaskkill(args: string[]): void {
|
||||
try {
|
||||
spawn("taskkill", args, {
|
||||
stdio: "ignore",
|
||||
detached: true,
|
||||
windowsHide: true,
|
||||
});
|
||||
} catch {
|
||||
// Ignore taskkill spawn failures.
|
||||
}
|
||||
}
|
||||
|
||||
function killProcessTreeWindows(pid: number, graceMs: number): void {
|
||||
signalProcessTreeWindows(pid, "SIGTERM");
|
||||
|
||||
setTimeout(() => {
|
||||
if (!isProcessAlive(pid)) {
|
||||
return;
|
||||
}
|
||||
signalProcessTreeWindows(pid, "SIGKILL");
|
||||
}, graceMs).unref();
|
||||
}
|
||||
|
||||
function signalProcessTreeWindows(pid: number, signal: "SIGTERM" | "SIGKILL"): void {
|
||||
const args =
|
||||
signal === "SIGKILL" ? ["/F", "/T", "/PID", String(pid)] : ["/T", "/PID", String(pid)];
|
||||
runTaskkill(args);
|
||||
}
|
||||
22
packages/agent-core/src/harness/env/nodejs.test.ts
vendored
Normal file
22
packages/agent-core/src/harness/env/nodejs.test.ts
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
// Agent Core tests cover nodejs behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveExecTimeoutMs } from "./nodejs.js";
|
||||
|
||||
describe("NodeExecutionEnv timeout helpers", () => {
|
||||
it("converts positive timeout seconds to milliseconds", () => {
|
||||
expect(resolveExecTimeoutMs(1)).toBe(1_000);
|
||||
expect(resolveExecTimeoutMs(1.5)).toBe(1_500);
|
||||
expect(resolveExecTimeoutMs(0.0005)).toBe(1);
|
||||
});
|
||||
|
||||
it("caps oversized timeout seconds to a timer-safe delay", () => {
|
||||
expect(resolveExecTimeoutMs(Number.MAX_SAFE_INTEGER)).toBe(2_147_000_000);
|
||||
});
|
||||
|
||||
it("ignores absent, invalid, or non-positive timeout seconds", () => {
|
||||
expect(resolveExecTimeoutMs(undefined)).toBeUndefined();
|
||||
expect(resolveExecTimeoutMs(Number.NaN)).toBeUndefined();
|
||||
expect(resolveExecTimeoutMs(0)).toBeUndefined();
|
||||
expect(resolveExecTimeoutMs(-1)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
619
packages/agent-core/src/harness/env/nodejs.ts
vendored
Normal file
619
packages/agent-core/src/harness/env/nodejs.ts
vendored
Normal file
@@ -0,0 +1,619 @@
|
||||
// Agent Core module implements nodejs behavior.
|
||||
import { spawn } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { constants, createReadStream } from "node:fs";
|
||||
import {
|
||||
access,
|
||||
appendFile,
|
||||
lstat,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readdir,
|
||||
readFile,
|
||||
realpath,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { isAbsolute, join, resolve } from "node:path";
|
||||
import { createInterface } from "node:readline";
|
||||
import {
|
||||
type ExecutionEnv,
|
||||
ExecutionError,
|
||||
err,
|
||||
FileError,
|
||||
type FileInfo,
|
||||
type FileKind,
|
||||
ok,
|
||||
type Result,
|
||||
toError,
|
||||
} from "../types.js";
|
||||
import { killProcessTree } from "./kill-tree.js";
|
||||
|
||||
const MAX_TIMER_TIMEOUT_MS = 2_147_000_000;
|
||||
|
||||
function resolvePath(cwd: string, path: string): string {
|
||||
return isAbsolute(path) ? path : resolve(cwd, path);
|
||||
}
|
||||
|
||||
/** Convert user-facing timeout seconds into a positive, timer-safe millisecond delay. */
|
||||
export function resolveExecTimeoutMs(timeoutSeconds: unknown): number | undefined {
|
||||
if (
|
||||
typeof timeoutSeconds !== "number" ||
|
||||
!Number.isFinite(timeoutSeconds) ||
|
||||
timeoutSeconds <= 0
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const milliseconds = Math.floor(timeoutSeconds * 1000);
|
||||
if (!Number.isFinite(milliseconds) || milliseconds <= 0) {
|
||||
return 1;
|
||||
}
|
||||
return Math.min(milliseconds, MAX_TIMER_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
function fileKindFromStats(stats: {
|
||||
isFile(): boolean;
|
||||
isDirectory(): boolean;
|
||||
isSymbolicLink(): boolean;
|
||||
}): FileKind | undefined {
|
||||
if (stats.isFile()) {
|
||||
return "file";
|
||||
}
|
||||
if (stats.isDirectory()) {
|
||||
return "directory";
|
||||
}
|
||||
if (stats.isSymbolicLink()) {
|
||||
return "symlink";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function fileInfoFromStats(
|
||||
path: string,
|
||||
stats: {
|
||||
isFile(): boolean;
|
||||
isDirectory(): boolean;
|
||||
isSymbolicLink(): boolean;
|
||||
size: number;
|
||||
mtimeMs: number;
|
||||
},
|
||||
): Result<FileInfo, FileError> {
|
||||
const kind = fileKindFromStats(stats);
|
||||
if (!kind) {
|
||||
return err(new FileError("invalid", "Unsupported file type", path));
|
||||
}
|
||||
return ok({
|
||||
name: path.replace(/\/+$/, "").split("/").pop() ?? path,
|
||||
path,
|
||||
kind,
|
||||
size: stats.size,
|
||||
mtimeMs: stats.mtimeMs,
|
||||
});
|
||||
}
|
||||
|
||||
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
|
||||
return error instanceof Error && "code" in error;
|
||||
}
|
||||
|
||||
function toFileError(error: unknown, path?: string): FileError {
|
||||
if (error instanceof FileError) {
|
||||
return error;
|
||||
}
|
||||
const cause = toError(error);
|
||||
if (isNodeError(error)) {
|
||||
const message = error.message;
|
||||
switch (error.code) {
|
||||
case "ABORT_ERR":
|
||||
return new FileError("aborted", message, path, cause);
|
||||
case "ENOENT":
|
||||
return new FileError("not_found", message, path, cause);
|
||||
case "EACCES":
|
||||
case "EPERM":
|
||||
return new FileError("permission_denied", message, path, cause);
|
||||
case "ENOTDIR":
|
||||
return new FileError("not_directory", message, path, cause);
|
||||
case "EISDIR":
|
||||
return new FileError("is_directory", message, path, cause);
|
||||
case "EINVAL":
|
||||
return new FileError("invalid", message, path, cause);
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
return new FileError("unknown", cause.message, path, cause);
|
||||
}
|
||||
|
||||
function abortResult(
|
||||
signal: AbortSignal | undefined,
|
||||
path?: string,
|
||||
): Result<never, FileError> | undefined {
|
||||
return signal?.aborted ? err(new FileError("aborted", "aborted", path)) : undefined;
|
||||
}
|
||||
|
||||
async function pathExists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await access(path, constants.F_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function runCommand(
|
||||
command: string,
|
||||
args: string[],
|
||||
timeoutMs: number,
|
||||
): Promise<{ stdout: string; status: number | null }> {
|
||||
return await new Promise((resolveLocal) => {
|
||||
let stdout = "";
|
||||
let child: ReturnType<typeof spawn>;
|
||||
try {
|
||||
child = spawn(command, args, {
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
windowsHide: true,
|
||||
});
|
||||
} catch {
|
||||
resolveLocal({ stdout: "", status: null });
|
||||
return;
|
||||
}
|
||||
const timeout = setTimeout(() => {
|
||||
if (child.pid) {
|
||||
killProcessTree(child.pid, { force: true });
|
||||
}
|
||||
}, timeoutMs);
|
||||
child.stdout?.setEncoding("utf8");
|
||||
child.stdout?.on("data", (chunk: string) => {
|
||||
stdout += chunk;
|
||||
});
|
||||
child.on("error", () => {
|
||||
clearTimeout(timeout);
|
||||
resolveLocal({ stdout: "", status: null });
|
||||
});
|
||||
child.on("close", (status) => {
|
||||
clearTimeout(timeout);
|
||||
resolveLocal({ stdout, status });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function findBashOnPath(): Promise<string | null> {
|
||||
const result =
|
||||
process.platform === "win32"
|
||||
? await runCommand("where", ["bash.exe"], 5000)
|
||||
: await runCommand("which", ["bash"], 5000);
|
||||
if (result.status !== 0 || !result.stdout) {
|
||||
return null;
|
||||
}
|
||||
const firstMatch = result.stdout.trim().split(/\r?\n/)[0];
|
||||
return firstMatch && (await pathExists(firstMatch)) ? firstMatch : null;
|
||||
}
|
||||
|
||||
async function getShellConfig(
|
||||
customShellPath?: string,
|
||||
): Promise<Result<{ shell: string; args: string[] }, ExecutionError>> {
|
||||
if (customShellPath) {
|
||||
if (await pathExists(customShellPath)) {
|
||||
return ok({ shell: customShellPath, args: ["-c"] });
|
||||
}
|
||||
return err(
|
||||
new ExecutionError("shell_unavailable", `Custom shell path not found: ${customShellPath}`),
|
||||
);
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
const candidates: string[] = [];
|
||||
const programFiles = process.env.ProgramFiles;
|
||||
if (programFiles) {
|
||||
candidates.push(`${programFiles}\\Git\\bin\\bash.exe`);
|
||||
}
|
||||
const programFilesX86 = process.env["ProgramFiles(x86)"];
|
||||
if (programFilesX86) {
|
||||
candidates.push(`${programFilesX86}\\Git\\bin\\bash.exe`);
|
||||
}
|
||||
for (const candidate of candidates) {
|
||||
if (await pathExists(candidate)) {
|
||||
return ok({ shell: candidate, args: ["-c"] });
|
||||
}
|
||||
}
|
||||
const bashOnPath = await findBashOnPath();
|
||||
if (bashOnPath) {
|
||||
return ok({ shell: bashOnPath, args: ["-c"] });
|
||||
}
|
||||
return err(new ExecutionError("shell_unavailable", "No bash shell found"));
|
||||
}
|
||||
|
||||
if (await pathExists("/bin/bash")) {
|
||||
return ok({ shell: "/bin/bash", args: ["-c"] });
|
||||
}
|
||||
const bashOnPath = await findBashOnPath();
|
||||
if (bashOnPath) {
|
||||
return ok({ shell: bashOnPath, args: ["-c"] });
|
||||
}
|
||||
return ok({ shell: "sh", args: ["-c"] });
|
||||
}
|
||||
|
||||
function getShellEnv(
|
||||
baseEnv?: NodeJS.ProcessEnv,
|
||||
extraEnv?: Record<string, string>,
|
||||
): NodeJS.ProcessEnv {
|
||||
return {
|
||||
...process.env,
|
||||
...baseEnv,
|
||||
...extraEnv,
|
||||
};
|
||||
}
|
||||
|
||||
/** Node-backed execution environment for agent harness filesystem and shell operations. */
|
||||
export class NodeExecutionEnv implements ExecutionEnv {
|
||||
cwd: string;
|
||||
private shellPath?: string;
|
||||
private shellEnv?: NodeJS.ProcessEnv;
|
||||
|
||||
constructor(options: { cwd: string; shellPath?: string; shellEnv?: NodeJS.ProcessEnv }) {
|
||||
this.cwd = options.cwd;
|
||||
this.shellPath = options.shellPath;
|
||||
this.shellEnv = options.shellEnv;
|
||||
}
|
||||
|
||||
async absolutePath(path: string): Promise<Result<string, FileError>> {
|
||||
return ok(resolvePath(this.cwd, path));
|
||||
}
|
||||
|
||||
async joinPath(parts: string[]): Promise<Result<string, FileError>> {
|
||||
return ok(join(...parts));
|
||||
}
|
||||
|
||||
async exec(
|
||||
command: string,
|
||||
options?: {
|
||||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
timeout?: number;
|
||||
abortSignal?: AbortSignal;
|
||||
onStdout?: (chunk: string) => void;
|
||||
onStderr?: (chunk: string) => void;
|
||||
},
|
||||
): Promise<Result<{ stdout: string; stderr: string; exitCode: number }, ExecutionError>> {
|
||||
if (options?.abortSignal?.aborted) {
|
||||
return err(new ExecutionError("aborted", "aborted"));
|
||||
}
|
||||
|
||||
const cwd = options?.cwd ? resolvePath(this.cwd, options.cwd) : this.cwd;
|
||||
const shellConfig = await getShellConfig(this.shellPath);
|
||||
if (!shellConfig.ok) {
|
||||
return shellConfig;
|
||||
}
|
||||
|
||||
return await new Promise((resolvePromise) => {
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let settled = false;
|
||||
let timedOut = false;
|
||||
let callbackError: ExecutionError | undefined;
|
||||
let child: ReturnType<typeof spawn> | undefined;
|
||||
const timeoutRef: { current?: ReturnType<typeof setTimeout> } = {};
|
||||
|
||||
const onAbort = () => {
|
||||
if (child?.pid) {
|
||||
killProcessTree(child.pid, { force: true });
|
||||
}
|
||||
};
|
||||
|
||||
const settle = (
|
||||
result: Result<{ stdout: string; stderr: string; exitCode: number }, ExecutionError>,
|
||||
) => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
if (options?.abortSignal) {
|
||||
options.abortSignal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
resolvePromise(result);
|
||||
};
|
||||
|
||||
try {
|
||||
child = spawn(shellConfig.value.shell, [...shellConfig.value.args, command], {
|
||||
cwd,
|
||||
detached: process.platform !== "win32",
|
||||
env: getShellEnv(this.shellEnv, options?.env),
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
});
|
||||
} catch (error) {
|
||||
const cause = toError(error);
|
||||
settle(err(new ExecutionError("spawn_error", cause.message, cause)));
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutMs = resolveExecTimeoutMs(options?.timeout);
|
||||
timeoutRef.current =
|
||||
timeoutMs === undefined
|
||||
? undefined
|
||||
: setTimeout(() => {
|
||||
timedOut = true;
|
||||
if (child?.pid) {
|
||||
killProcessTree(child.pid, { force: true });
|
||||
}
|
||||
}, timeoutMs);
|
||||
|
||||
if (options?.abortSignal) {
|
||||
if (options.abortSignal.aborted) {
|
||||
onAbort();
|
||||
} else {
|
||||
options.abortSignal.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
}
|
||||
|
||||
child.stdout?.setEncoding("utf8");
|
||||
child.stderr?.setEncoding("utf8");
|
||||
child.stdout?.on("data", (chunk: string) => {
|
||||
stdout += chunk;
|
||||
try {
|
||||
options?.onStdout?.(chunk);
|
||||
} catch (error) {
|
||||
const cause = toError(error);
|
||||
callbackError = new ExecutionError("callback_error", cause.message, cause);
|
||||
onAbort();
|
||||
}
|
||||
});
|
||||
child.stderr?.on("data", (chunk: string) => {
|
||||
stderr += chunk;
|
||||
try {
|
||||
options?.onStderr?.(chunk);
|
||||
} catch (error) {
|
||||
const cause = toError(error);
|
||||
callbackError = new ExecutionError("callback_error", cause.message, cause);
|
||||
onAbort();
|
||||
}
|
||||
});
|
||||
|
||||
child.on("error", (error) => {
|
||||
settle(err(new ExecutionError("spawn_error", error.message, error)));
|
||||
});
|
||||
|
||||
child.on("close", (code) => {
|
||||
if (callbackError) {
|
||||
settle(err(callbackError));
|
||||
return;
|
||||
}
|
||||
if (timedOut) {
|
||||
settle(err(new ExecutionError("timeout", `timeout:${options?.timeout}`)));
|
||||
return;
|
||||
}
|
||||
if (options?.abortSignal?.aborted) {
|
||||
settle(err(new ExecutionError("aborted", "aborted")));
|
||||
return;
|
||||
}
|
||||
settle(ok({ stdout, stderr, exitCode: code ?? 0 }));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async readTextFile(path: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>> {
|
||||
const resolved = resolvePath(this.cwd, path);
|
||||
const aborted = abortResult(abortSignal, resolved);
|
||||
if (aborted) {
|
||||
return aborted;
|
||||
}
|
||||
try {
|
||||
return ok(await readFile(resolved, { encoding: "utf8", signal: abortSignal }));
|
||||
} catch (error) {
|
||||
return err(toFileError(error, resolved));
|
||||
}
|
||||
}
|
||||
|
||||
async readTextLines(
|
||||
path: string,
|
||||
options?: { maxLines?: number; abortSignal?: AbortSignal },
|
||||
): Promise<Result<string[], FileError>> {
|
||||
const resolved = resolvePath(this.cwd, path);
|
||||
const aborted = abortResult(options?.abortSignal, resolved);
|
||||
if (aborted) {
|
||||
return aborted;
|
||||
}
|
||||
if (options?.maxLines !== undefined && options.maxLines <= 0) {
|
||||
return ok([]);
|
||||
}
|
||||
let stream: ReturnType<typeof createReadStream> | undefined;
|
||||
let lineReader: ReturnType<typeof createInterface> | undefined;
|
||||
try {
|
||||
stream = createReadStream(resolved, { encoding: "utf8", signal: options?.abortSignal });
|
||||
lineReader = createInterface({ input: stream, crlfDelay: Infinity });
|
||||
const lines: string[] = [];
|
||||
for await (const line of lineReader) {
|
||||
const loopAbort = abortResult(options?.abortSignal, resolved);
|
||||
if (loopAbort) {
|
||||
return loopAbort;
|
||||
}
|
||||
lines.push(line);
|
||||
if (options?.maxLines !== undefined && lines.length >= options.maxLines) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
const afterReadAbort = abortResult(options?.abortSignal, resolved);
|
||||
if (afterReadAbort) {
|
||||
return afterReadAbort;
|
||||
}
|
||||
return ok(lines);
|
||||
} catch (error) {
|
||||
return err(toFileError(error, resolved));
|
||||
} finally {
|
||||
lineReader?.close();
|
||||
stream?.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
async readBinaryFile(
|
||||
path: string,
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<Result<Uint8Array, FileError>> {
|
||||
const resolved = resolvePath(this.cwd, path);
|
||||
const aborted = abortResult(abortSignal, resolved);
|
||||
if (aborted) {
|
||||
return aborted;
|
||||
}
|
||||
try {
|
||||
return ok(await readFile(resolved, { signal: abortSignal }));
|
||||
} catch (error) {
|
||||
return err(toFileError(error, resolved));
|
||||
}
|
||||
}
|
||||
|
||||
async writeFile(
|
||||
path: string,
|
||||
content: string | Uint8Array,
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<Result<void, FileError>> {
|
||||
const resolved = resolvePath(this.cwd, path);
|
||||
const aborted = abortResult(abortSignal, resolved);
|
||||
if (aborted) {
|
||||
return aborted;
|
||||
}
|
||||
try {
|
||||
await mkdir(resolve(resolved, ".."), { recursive: true });
|
||||
const afterMkdirAbort = abortResult(abortSignal, resolved);
|
||||
if (afterMkdirAbort) {
|
||||
return afterMkdirAbort;
|
||||
}
|
||||
await writeFile(resolved, content, { signal: abortSignal });
|
||||
return ok(undefined);
|
||||
} catch (error) {
|
||||
return err(toFileError(error, resolved));
|
||||
}
|
||||
}
|
||||
|
||||
async appendFile(path: string, content: string | Uint8Array): Promise<Result<void, FileError>> {
|
||||
const resolved = resolvePath(this.cwd, path);
|
||||
try {
|
||||
await mkdir(resolve(resolved, ".."), { recursive: true });
|
||||
await appendFile(resolved, content);
|
||||
return ok(undefined);
|
||||
} catch (error) {
|
||||
return err(toFileError(error, resolved));
|
||||
}
|
||||
}
|
||||
|
||||
async fileInfo(path: string): Promise<Result<FileInfo, FileError>> {
|
||||
const resolved = resolvePath(this.cwd, path);
|
||||
try {
|
||||
return fileInfoFromStats(resolved, await lstat(resolved));
|
||||
} catch (error) {
|
||||
return err(toFileError(error, resolved));
|
||||
}
|
||||
}
|
||||
|
||||
async listDir(path: string, abortSignal?: AbortSignal): Promise<Result<FileInfo[], FileError>> {
|
||||
const resolved = resolvePath(this.cwd, path);
|
||||
const aborted = abortResult(abortSignal, resolved);
|
||||
if (aborted) {
|
||||
return aborted;
|
||||
}
|
||||
try {
|
||||
const entries = await readdir(resolved, { withFileTypes: true });
|
||||
const infos: FileInfo[] = [];
|
||||
for (const entry of entries) {
|
||||
const loopAbort = abortResult(abortSignal, resolved);
|
||||
if (loopAbort) {
|
||||
return loopAbort;
|
||||
}
|
||||
const entryPath = resolve(resolved, entry.name);
|
||||
try {
|
||||
const info = fileInfoFromStats(entryPath, await lstat(entryPath));
|
||||
if (info.ok) {
|
||||
infos.push(info.value);
|
||||
}
|
||||
} catch (error) {
|
||||
return err(toFileError(error, entryPath));
|
||||
}
|
||||
}
|
||||
return ok(infos);
|
||||
} catch (error) {
|
||||
return err(toFileError(error, resolved));
|
||||
}
|
||||
}
|
||||
|
||||
async canonicalPath(path: string): Promise<Result<string, FileError>> {
|
||||
const resolved = resolvePath(this.cwd, path);
|
||||
try {
|
||||
return ok(await realpath(resolved));
|
||||
} catch (error) {
|
||||
return err(toFileError(error, resolved));
|
||||
}
|
||||
}
|
||||
|
||||
async exists(path: string): Promise<Result<boolean, FileError>> {
|
||||
const result = await this.fileInfo(path);
|
||||
if (result.ok) {
|
||||
return ok(true);
|
||||
}
|
||||
if (result.error.code === "not_found") {
|
||||
return ok(false);
|
||||
}
|
||||
return err(result.error);
|
||||
}
|
||||
|
||||
async createDir(
|
||||
path: string,
|
||||
options?: { recursive?: boolean },
|
||||
): Promise<Result<void, FileError>> {
|
||||
const resolved = resolvePath(this.cwd, path);
|
||||
try {
|
||||
await mkdir(resolved, { recursive: options?.recursive ?? true });
|
||||
return ok(undefined);
|
||||
} catch (error) {
|
||||
return err(toFileError(error, resolved));
|
||||
}
|
||||
}
|
||||
|
||||
async remove(
|
||||
path: string,
|
||||
options?: { recursive?: boolean; force?: boolean },
|
||||
): Promise<Result<void, FileError>> {
|
||||
const resolved = resolvePath(this.cwd, path);
|
||||
try {
|
||||
await rm(resolved, {
|
||||
recursive: options?.recursive ?? false,
|
||||
force: options?.force ?? false,
|
||||
});
|
||||
return ok(undefined);
|
||||
} catch (error) {
|
||||
return err(toFileError(error, resolved));
|
||||
}
|
||||
}
|
||||
|
||||
async createTempDir(prefix = "tmp-"): Promise<Result<string, FileError>> {
|
||||
try {
|
||||
return ok(await mkdtemp(join(tmpdir(), prefix)));
|
||||
} catch (error) {
|
||||
return err(toFileError(error));
|
||||
}
|
||||
}
|
||||
|
||||
async createTempFile(options?: {
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
}): Promise<Result<string, FileError>> {
|
||||
const dir = await this.createTempDir("tmp-");
|
||||
if (!dir.ok) {
|
||||
return dir;
|
||||
}
|
||||
const filePath = join(
|
||||
dir.value,
|
||||
`${options?.prefix ?? ""}${randomUUID()}${options?.suffix ?? ""}`,
|
||||
);
|
||||
try {
|
||||
await writeFile(filePath, "");
|
||||
return ok(filePath);
|
||||
} catch (error) {
|
||||
return err(toFileError(error, filePath));
|
||||
}
|
||||
}
|
||||
|
||||
async cleanup(): Promise<void> {
|
||||
// nothing to clean up for the local node implementation
|
||||
}
|
||||
}
|
||||
41
packages/agent-core/src/harness/messages.test.ts
Normal file
41
packages/agent-core/src/harness/messages.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
// Agent Core tests cover messages behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { convertToLlm, createCustomMessage } from "./messages.js";
|
||||
|
||||
describe("harness message timestamps", () => {
|
||||
it("rejects invalid timestamps before creating context messages", () => {
|
||||
expect(() => createCustomMessage("note", "content", true, {}, "not-a-date")).toThrow(
|
||||
"custom message timestamp must be a valid timestamp",
|
||||
);
|
||||
});
|
||||
it("normalizes persisted compaction summary timestamp strings", () => {
|
||||
const timestamp = "2026-05-30T17:00:00.000Z";
|
||||
const persistedMessages: Parameters<typeof convertToLlm>[0] = [
|
||||
{
|
||||
role: "compactionSummary",
|
||||
summary: "older context",
|
||||
tokensBefore: 123,
|
||||
timestamp,
|
||||
},
|
||||
];
|
||||
|
||||
const [message] = convertToLlm(persistedMessages);
|
||||
|
||||
expect(message?.timestamp).toBe(Date.parse(timestamp));
|
||||
});
|
||||
|
||||
it("keeps corrupt persisted compaction timestamps non-fatal", () => {
|
||||
const persistedMessages: Parameters<typeof convertToLlm>[0] = [
|
||||
{
|
||||
role: "compactionSummary",
|
||||
summary: "older context",
|
||||
tokensBefore: 123,
|
||||
timestamp: "not a timestamp",
|
||||
},
|
||||
];
|
||||
|
||||
const [message] = convertToLlm(persistedMessages);
|
||||
|
||||
expect(message?.timestamp).toBe(0);
|
||||
});
|
||||
});
|
||||
179
packages/agent-core/src/harness/messages.ts
Normal file
179
packages/agent-core/src/harness/messages.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
// Agent Core module implements messages behavior.
|
||||
import type { ImageContent, Message, TextContent } from "../../../llm-core/src/index.js";
|
||||
import type {
|
||||
AgentMessage,
|
||||
BashExecutionMessage,
|
||||
BranchSummaryMessage,
|
||||
CompactionSummaryMessage,
|
||||
CustomMessage,
|
||||
} from "../types.js";
|
||||
import { parseSessionTimestampMs, requireSessionTimestampMs } from "./session/timestamps.js";
|
||||
|
||||
export type {
|
||||
BashExecutionMessage,
|
||||
BranchSummaryMessage,
|
||||
CompactionSummaryMessage,
|
||||
CustomMessage,
|
||||
} from "../types.js";
|
||||
|
||||
/** Harness-only transcript entries that can be normalized into LLM messages. */
|
||||
export type HarnessMessage =
|
||||
| AgentMessage
|
||||
| BashExecutionMessage
|
||||
| CustomMessage
|
||||
| BranchSummaryMessage
|
||||
| CompactionSummaryMessage;
|
||||
|
||||
// Internal session paths keep call sites explicit about this harness-owned
|
||||
// boundary even though these message roles are part of AgentMessage.
|
||||
export function asAgentMessage(message: HarnessMessage): AgentMessage {
|
||||
return message as AgentMessage;
|
||||
}
|
||||
|
||||
function normalizeCompactionSummaryTimestamp(timestamp: number | string): number {
|
||||
if (typeof timestamp === "number") {
|
||||
return timestamp;
|
||||
}
|
||||
const parsed = parseSessionTimestampMs(timestamp);
|
||||
// Corrupt persisted rows should not abort context conversion; session order is already preserved.
|
||||
return parsed ?? 0;
|
||||
}
|
||||
|
||||
export const COMPACTION_SUMMARY_PREFIX = `The conversation history before this point was compacted into the following summary:
|
||||
|
||||
<summary>
|
||||
`;
|
||||
|
||||
export const COMPACTION_SUMMARY_SUFFIX = `
|
||||
</summary>`;
|
||||
|
||||
export const BRANCH_SUMMARY_PREFIX = `The following is a summary of a branch that this conversation came back from:
|
||||
|
||||
<summary>
|
||||
`;
|
||||
|
||||
export const BRANCH_SUMMARY_SUFFIX = `</summary>`;
|
||||
|
||||
/** Render a shell execution record as user-visible context text for the model. */
|
||||
export function bashExecutionToText(msg: BashExecutionMessage): string {
|
||||
let text = `Ran \`${msg.command}\`\n`;
|
||||
if (msg.output) {
|
||||
text += `\`\`\`\n${msg.output}\n\`\`\``;
|
||||
} else {
|
||||
text += "(no output)";
|
||||
}
|
||||
if (msg.cancelled) {
|
||||
text += "\n\n(command cancelled)";
|
||||
} else if (msg.exitCode !== null && msg.exitCode !== undefined && msg.exitCode !== 0) {
|
||||
text += `\n\nCommand exited with code ${msg.exitCode}`;
|
||||
}
|
||||
if (msg.truncated && msg.fullOutputPath) {
|
||||
text += `\n\n[Output truncated. Full output: ${msg.fullOutputPath}]`;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
/** Build a persisted branch summary message from the repository timestamp string. */
|
||||
export function createBranchSummaryMessage(
|
||||
summary: string,
|
||||
fromId: string,
|
||||
timestamp: string,
|
||||
): BranchSummaryMessage {
|
||||
return {
|
||||
role: "branchSummary",
|
||||
summary,
|
||||
fromId,
|
||||
timestamp: requireSessionTimestampMs(timestamp, "branch summary timestamp"),
|
||||
};
|
||||
}
|
||||
|
||||
/** Build a persisted compaction summary message from the repository timestamp string. */
|
||||
export function createCompactionSummaryMessage(
|
||||
summary: string,
|
||||
tokensBefore: number,
|
||||
timestamp: string,
|
||||
): CompactionSummaryMessage {
|
||||
return {
|
||||
role: "compactionSummary",
|
||||
summary,
|
||||
tokensBefore,
|
||||
timestamp: requireSessionTimestampMs(timestamp, "compaction summary timestamp"),
|
||||
};
|
||||
}
|
||||
|
||||
/** Build a custom transcript message that can be shown and replayed into context. */
|
||||
export function createCustomMessage(
|
||||
customType: string,
|
||||
content: string | (TextContent | ImageContent)[],
|
||||
display: boolean,
|
||||
details: unknown,
|
||||
timestamp: string,
|
||||
): CustomMessage {
|
||||
return {
|
||||
role: "custom",
|
||||
customType,
|
||||
content,
|
||||
display,
|
||||
details,
|
||||
timestamp: requireSessionTimestampMs(timestamp, "custom message timestamp"),
|
||||
};
|
||||
}
|
||||
|
||||
/** Convert harness transcript messages into the LLM-facing message sequence. */
|
||||
export function convertToLlm(messages: AgentMessage[]): Message[] {
|
||||
return messages
|
||||
.map((m): Message | undefined => {
|
||||
const message = m as HarnessMessage;
|
||||
switch (message.role) {
|
||||
case "bashExecution":
|
||||
if (message.excludeFromContext) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: bashExecutionToText(message) }],
|
||||
timestamp: message.timestamp,
|
||||
};
|
||||
case "custom": {
|
||||
const content =
|
||||
typeof message.content === "string"
|
||||
? [{ type: "text" as const, text: message.content }]
|
||||
: message.content;
|
||||
return {
|
||||
role: "user",
|
||||
content,
|
||||
timestamp: message.timestamp,
|
||||
};
|
||||
}
|
||||
case "branchSummary":
|
||||
return {
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: BRANCH_SUMMARY_PREFIX + message.summary + BRANCH_SUMMARY_SUFFIX,
|
||||
},
|
||||
],
|
||||
timestamp: message.timestamp,
|
||||
};
|
||||
case "compactionSummary":
|
||||
return {
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: COMPACTION_SUMMARY_PREFIX + message.summary + COMPACTION_SUMMARY_SUFFIX,
|
||||
},
|
||||
],
|
||||
timestamp: normalizeCompactionSummaryTimestamp(message.timestamp),
|
||||
};
|
||||
case "user":
|
||||
case "assistant":
|
||||
case "toolResult":
|
||||
return message;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
})
|
||||
.filter((m): m is Message => m !== undefined);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// Agent Core tests cover prompt template argument parsing behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseCommandArgs, substituteArgs } from "./prompt-template-arguments.js";
|
||||
|
||||
describe("prompt template arguments", () => {
|
||||
it("preserves quoted empty arguments so positional placeholders stay aligned", () => {
|
||||
expect(parseCommandArgs('first "" third')).toEqual(["first", "", "third"]);
|
||||
expect(parseCommandArgs("first '' third")).toEqual(["first", "", "third"]);
|
||||
expect(substituteArgs("$1|$2|$3", parseCommandArgs('first "" third'))).toBe("first||third");
|
||||
});
|
||||
});
|
||||
93
packages/agent-core/src/harness/prompt-template-arguments.ts
Normal file
93
packages/agent-core/src/harness/prompt-template-arguments.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import type { PromptTemplate } from "./types.js";
|
||||
|
||||
/** Parse an argument string using simple shell-style single and double quotes. */
|
||||
export function parseCommandArgs(argsString: string): string[] {
|
||||
const args: string[] = [];
|
||||
let current = "";
|
||||
let inQuote: string | null = null;
|
||||
let hasToken = false;
|
||||
|
||||
for (const char of argsString) {
|
||||
if (inQuote) {
|
||||
if (char === inQuote) {
|
||||
inQuote = null;
|
||||
} else {
|
||||
hasToken = true;
|
||||
current += char;
|
||||
}
|
||||
} else if (char === '"' || char === "'") {
|
||||
hasToken = true;
|
||||
inQuote = char;
|
||||
} else if (/\s/.test(char)) {
|
||||
if (hasToken) {
|
||||
args.push(current);
|
||||
current = "";
|
||||
hasToken = false;
|
||||
}
|
||||
} else {
|
||||
hasToken = true;
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
if (hasToken) {
|
||||
args.push(current);
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function parseSafeNonNegativeInteger(raw: string): number | undefined {
|
||||
const parsed = Number(raw);
|
||||
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Substitute prompt template placeholders (`$1`, `$@`, `$ARGUMENTS`, `${@:N}`, `${@:N:L}`) with command arguments.
|
||||
*
|
||||
* Unsafe integer placeholders resolve to empty text instead of throwing, so malformed templates cannot abort prompt
|
||||
* loading or invocation.
|
||||
*/
|
||||
export function substituteArgs(content: string, args: string[]): string {
|
||||
let result = content;
|
||||
result = result.replace(/\$(\d+)/g, (_, num: string) => {
|
||||
const parsed = parseSafeNonNegativeInteger(num);
|
||||
if (parsed === undefined || parsed <= 0) {
|
||||
return "";
|
||||
}
|
||||
return args[parsed - 1] ?? "";
|
||||
});
|
||||
result = result.replace(
|
||||
/\$\{@:(\d+)(?::(\d+))?\}/g,
|
||||
(_, startStr: string, lengthStr?: string) => {
|
||||
const parsedStart = parseSafeNonNegativeInteger(startStr);
|
||||
if (parsedStart === undefined) {
|
||||
return "";
|
||||
}
|
||||
// Keep shell-style `${@:0:...}` compatibility: start 0 includes `$0` in shell, but
|
||||
// prompt templates have no command name, so it maps to the first provided argument.
|
||||
let start = parsedStart - 1;
|
||||
if (start < 0) {
|
||||
start = 0;
|
||||
}
|
||||
if (lengthStr) {
|
||||
const length = parseSafeNonNegativeInteger(lengthStr);
|
||||
if (length === undefined) {
|
||||
return "";
|
||||
}
|
||||
return args.slice(start, start + length).join(" ");
|
||||
}
|
||||
return args.slice(start).join(" ");
|
||||
},
|
||||
);
|
||||
const allArgs = args.join(" ");
|
||||
result = result.replace(/\$ARGUMENTS/g, allArgs);
|
||||
result = result.replace(/\$@/g, allArgs);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Format a prompt template invocation using command-style argument substitution. */
|
||||
export function formatPromptTemplateInvocation(
|
||||
template: PromptTemplate,
|
||||
args: string[] = [],
|
||||
): string {
|
||||
return substituteArgs(template.content, args);
|
||||
}
|
||||
30
packages/agent-core/src/harness/prompt-templates.test.ts
Normal file
30
packages/agent-core/src/harness/prompt-templates.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
// Agent Core tests cover prompt templates behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseCommandArgs, substituteArgs } from "./prompt-template-arguments.js";
|
||||
|
||||
describe("prompt template argument substitution", () => {
|
||||
it("parses quoted and multiline arguments", () => {
|
||||
expect(parseCommandArgs(`alpha "beta gamma"\ndelta 'echo one two'`)).toEqual([
|
||||
"alpha",
|
||||
"beta gamma",
|
||||
"delta",
|
||||
"echo one two",
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects unsafe positional placeholders", () => {
|
||||
expect(substituteArgs("$9007199254740992", ["first", "second"])).toBe("");
|
||||
});
|
||||
|
||||
it("rejects unsafe slice starts and lengths", () => {
|
||||
const args = ["alpha", "beta", "gamma"];
|
||||
|
||||
expect(substituteArgs("${@:9007199254740992}", args)).toBe("");
|
||||
expect(substituteArgs("${@:1:9007199254740992}", args)).toBe("");
|
||||
});
|
||||
|
||||
it("preserves zero slice compatibility", () => {
|
||||
expect(substituteArgs("${@:0:0}", ["alpha", "beta"])).toBe("");
|
||||
expect(substituteArgs("${@:0:1}", ["alpha", "beta"])).toBe("alpha");
|
||||
});
|
||||
});
|
||||
264
packages/agent-core/src/harness/session/jsonl-storage.test.ts
Normal file
264
packages/agent-core/src/harness/session/jsonl-storage.test.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
// Agent Core tests cover jsonl storage behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ok, type FileSystem } from "../types.js";
|
||||
import { JsonlSessionStorage, loadJsonlSessionMetadata } from "./jsonl-storage.js";
|
||||
import { Session } from "./session.js";
|
||||
|
||||
type JsonlStorageFs = Pick<
|
||||
FileSystem,
|
||||
"readTextFile" | "readTextLines" | "writeFile" | "appendFile"
|
||||
>;
|
||||
|
||||
function createReadOnlyFs(content: string): JsonlStorageFs {
|
||||
return {
|
||||
readTextFile: async () => ok(content),
|
||||
readTextLines: async (_path, options) => ok(content.split("\n").slice(0, options?.maxLines)),
|
||||
writeFile: async () => ok(undefined),
|
||||
appendFile: async () => ok(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
describe("JsonlSessionStorage timestamps", () => {
|
||||
it("rejects invalid session header timestamps", async () => {
|
||||
const fs = createReadOnlyFs(
|
||||
`${JSON.stringify({
|
||||
type: "session",
|
||||
version: 3,
|
||||
id: "session-1",
|
||||
timestamp: "not-a-date",
|
||||
cwd: "/repo",
|
||||
})}\n`,
|
||||
);
|
||||
|
||||
await expect(loadJsonlSessionMetadata(fs, "/sessions/invalid.jsonl")).rejects.toThrow(
|
||||
"session header has invalid timestamp",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects invalid entry timestamps", async () => {
|
||||
const fs = createReadOnlyFs(
|
||||
`${JSON.stringify({
|
||||
type: "session",
|
||||
version: 3,
|
||||
id: "session-1",
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
cwd: "/repo",
|
||||
})}\n${JSON.stringify({
|
||||
type: "custom",
|
||||
id: "entry-1",
|
||||
parentId: null,
|
||||
timestamp: "not-a-date",
|
||||
customType: "note",
|
||||
})}\n`,
|
||||
);
|
||||
|
||||
await expect(JsonlSessionStorage.open(fs, "/sessions/invalid-entry.jsonl")).rejects.toThrow(
|
||||
"line 2 has invalid timestamp",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses a leaf control's opaque append parent for the next entry", async () => {
|
||||
let content = [
|
||||
{
|
||||
type: "session",
|
||||
version: 3,
|
||||
id: "session-1",
|
||||
timestamp: "2026-06-15T00:00:00.000Z",
|
||||
cwd: "/repo",
|
||||
},
|
||||
{
|
||||
type: "custom",
|
||||
id: "active-root",
|
||||
parentId: null,
|
||||
timestamp: "2026-06-15T00:00:01.000Z",
|
||||
customType: "root",
|
||||
},
|
||||
{
|
||||
type: "metadata",
|
||||
id: "plugin-metadata",
|
||||
parentId: null,
|
||||
timestamp: "2026-06-15T00:00:02.000Z",
|
||||
},
|
||||
{
|
||||
type: "leaf",
|
||||
id: "active-leaf",
|
||||
parentId: "inactive-tail",
|
||||
timestamp: "2026-06-15T00:00:03.000Z",
|
||||
targetId: "active-root",
|
||||
appendParentId: "plugin-metadata",
|
||||
},
|
||||
]
|
||||
.map((entry) => JSON.stringify(entry))
|
||||
.join("\n");
|
||||
content += "\n";
|
||||
const fs: JsonlStorageFs = {
|
||||
...createReadOnlyFs(content),
|
||||
readTextFile: async () => ok(content),
|
||||
appendFile: async (_path, appended) => {
|
||||
content += String(appended);
|
||||
return ok(undefined);
|
||||
},
|
||||
};
|
||||
const storage = await JsonlSessionStorage.open(fs, "/sessions/session.jsonl");
|
||||
const session = new Session(storage);
|
||||
|
||||
expect(await session.getLeafId()).toBe("active-root");
|
||||
const entryId = await session.appendCustomEntry("continued");
|
||||
const entry = await session.getEntry(entryId);
|
||||
|
||||
expect(entry).toMatchObject({ parentId: "plugin-metadata" });
|
||||
expect((await storage.getPathToRoot(entryId)).map((pathEntry) => pathEntry.id)).toEqual([
|
||||
"active-root",
|
||||
entryId,
|
||||
]);
|
||||
expect(content.trim().split(/\r?\n/).at(-1)).toContain('"parentId":"plugin-metadata"');
|
||||
});
|
||||
|
||||
it("keeps a terminal side append off the visible branch", async () => {
|
||||
let content = [
|
||||
{
|
||||
type: "session",
|
||||
version: 3,
|
||||
id: "session-1",
|
||||
timestamp: "2026-06-15T00:00:00.000Z",
|
||||
cwd: "/repo",
|
||||
},
|
||||
{
|
||||
type: "custom",
|
||||
id: "active-root",
|
||||
parentId: null,
|
||||
timestamp: "2026-06-15T00:00:01.000Z",
|
||||
customType: "active",
|
||||
},
|
||||
{
|
||||
type: "custom",
|
||||
id: "side-one",
|
||||
parentId: "active-root",
|
||||
timestamp: "2026-06-15T00:00:02.000Z",
|
||||
customType: "side",
|
||||
},
|
||||
{
|
||||
type: "leaf",
|
||||
id: "side-leaf",
|
||||
parentId: "side-one",
|
||||
timestamp: "2026-06-15T00:00:03.000Z",
|
||||
targetId: "active-root",
|
||||
appendParentId: "side-one",
|
||||
appendMode: "side",
|
||||
},
|
||||
{
|
||||
type: "custom",
|
||||
id: "side-two",
|
||||
parentId: "side-one",
|
||||
timestamp: "2026-06-15T00:00:04.000Z",
|
||||
customType: "side",
|
||||
appendMode: "side",
|
||||
},
|
||||
]
|
||||
.map((entry) => JSON.stringify(entry))
|
||||
.join("\n");
|
||||
content += "\n";
|
||||
const fs: JsonlStorageFs = {
|
||||
...createReadOnlyFs(content),
|
||||
readTextFile: async () => ok(content),
|
||||
appendFile: async (_path, appended) => {
|
||||
content += String(appended);
|
||||
return ok(undefined);
|
||||
},
|
||||
};
|
||||
const storage = await JsonlSessionStorage.open(fs, "/sessions/session.jsonl");
|
||||
const session = new Session(storage);
|
||||
|
||||
expect(await storage.getLeafId()).toBe("active-root");
|
||||
expect(await storage.getAppendParentId()).toBe("side-two");
|
||||
const entryId = await session.appendCustomEntry("continued");
|
||||
|
||||
expect(await storage.getEntry(entryId)).toMatchObject({ parentId: "side-two" });
|
||||
expect((await storage.getPathToRoot(entryId)).map((entry) => entry.id)).toEqual([
|
||||
"active-root",
|
||||
entryId,
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not let opaque rows replace the selected visible leaf", async () => {
|
||||
const content = [
|
||||
{
|
||||
type: "session",
|
||||
version: 3,
|
||||
id: "session-1",
|
||||
timestamp: "2026-06-15T00:00:00.000Z",
|
||||
cwd: "/repo",
|
||||
},
|
||||
{
|
||||
type: "custom",
|
||||
id: "active-root",
|
||||
parentId: null,
|
||||
timestamp: "2026-06-15T00:00:01.000Z",
|
||||
customType: "active",
|
||||
},
|
||||
{
|
||||
type: "custom",
|
||||
id: "inactive-root",
|
||||
parentId: null,
|
||||
timestamp: "2026-06-15T00:00:02.000Z",
|
||||
customType: "inactive",
|
||||
},
|
||||
{
|
||||
type: "leaf",
|
||||
id: "active-leaf",
|
||||
parentId: "inactive-root",
|
||||
timestamp: "2026-06-15T00:00:03.000Z",
|
||||
targetId: "active-root",
|
||||
},
|
||||
{
|
||||
type: "metadata",
|
||||
id: "plugin-metadata",
|
||||
parentId: "inactive-root",
|
||||
timestamp: "2026-06-15T00:00:04.000Z",
|
||||
},
|
||||
]
|
||||
.map((entry) => JSON.stringify(entry))
|
||||
.join("\n");
|
||||
const storage = await JsonlSessionStorage.open(
|
||||
createReadOnlyFs(`${content}\n`),
|
||||
"/sessions/session.jsonl",
|
||||
);
|
||||
const session = new Session(storage);
|
||||
|
||||
expect(await session.getLeafId()).toBe("active-root");
|
||||
expect((await session.getBranch()).map((entry) => entry.id)).toEqual(["active-root"]);
|
||||
});
|
||||
|
||||
it("rejects a leaf control with a missing append parent", async () => {
|
||||
const content = [
|
||||
{
|
||||
type: "session",
|
||||
version: 3,
|
||||
id: "session-1",
|
||||
timestamp: "2026-06-15T00:00:00.000Z",
|
||||
cwd: "/repo",
|
||||
},
|
||||
{
|
||||
type: "custom",
|
||||
id: "active-root",
|
||||
parentId: null,
|
||||
timestamp: "2026-06-15T00:00:01.000Z",
|
||||
customType: "active",
|
||||
},
|
||||
{
|
||||
type: "leaf",
|
||||
id: "active-leaf",
|
||||
parentId: "active-root",
|
||||
timestamp: "2026-06-15T00:00:02.000Z",
|
||||
targetId: "active-root",
|
||||
appendParentId: "missing",
|
||||
},
|
||||
]
|
||||
.map((entry) => JSON.stringify(entry))
|
||||
.join("\n");
|
||||
|
||||
await expect(
|
||||
JsonlSessionStorage.open(createReadOnlyFs(`${content}\n`), "/sessions/session.jsonl"),
|
||||
).rejects.toThrow("Append parent missing not found");
|
||||
});
|
||||
});
|
||||
285
packages/agent-core/src/harness/session/jsonl-storage.ts
Normal file
285
packages/agent-core/src/harness/session/jsonl-storage.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
// Agent Core module implements jsonl storage behavior.
|
||||
import type {
|
||||
FileError,
|
||||
FileSystem,
|
||||
JsonlSessionMetadata,
|
||||
Result,
|
||||
SessionTreeEntry,
|
||||
} from "../types.js";
|
||||
import { SessionError, toError } from "../types.js";
|
||||
import {
|
||||
appendParentIdAfterEntry,
|
||||
BaseSessionStorage,
|
||||
leafIdUpdateAfterEntry,
|
||||
} from "./storage-base.js";
|
||||
import { parseSessionTimestampMs } from "./timestamps.js";
|
||||
|
||||
type JsonlSessionStorageFileSystem = Pick<
|
||||
FileSystem,
|
||||
"readTextFile" | "readTextLines" | "writeFile" | "appendFile"
|
||||
>;
|
||||
|
||||
interface SessionHeader {
|
||||
type: "session";
|
||||
version: 3;
|
||||
id: string;
|
||||
timestamp: string;
|
||||
cwd: string;
|
||||
parentSession?: string;
|
||||
}
|
||||
|
||||
function getFileSystemResultOrThrow<TValue>(
|
||||
result: Result<TValue, FileError>,
|
||||
message: string,
|
||||
): TValue {
|
||||
if (!result.ok) {
|
||||
const code = result.error.code === "not_found" ? "not_found" : "storage";
|
||||
throw new SessionError(code, `${message}: ${result.error.message}`, result.error);
|
||||
}
|
||||
return result.value;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function invalidSession(filePath: string, message: string, cause?: Error): SessionError {
|
||||
return new SessionError(
|
||||
"invalid_session",
|
||||
`Invalid JSONL session file ${filePath}: ${message}`,
|
||||
cause,
|
||||
);
|
||||
}
|
||||
|
||||
function invalidEntry(
|
||||
filePath: string,
|
||||
lineNumber: number,
|
||||
message: string,
|
||||
cause?: Error,
|
||||
): SessionError {
|
||||
return new SessionError(
|
||||
"invalid_entry",
|
||||
`Invalid JSONL session file ${filePath}: line ${lineNumber} ${message}`,
|
||||
cause,
|
||||
);
|
||||
}
|
||||
|
||||
function parseHeaderLine(line: string, filePath: string): SessionHeader {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(line);
|
||||
} catch (error) {
|
||||
throw invalidSession(filePath, "first line is not a valid session header", toError(error));
|
||||
}
|
||||
if (!isRecord(parsed)) {
|
||||
throw invalidSession(filePath, "first line is not a valid session header");
|
||||
}
|
||||
if (parsed.type !== "session") {
|
||||
throw invalidSession(filePath, "first line is not a valid session header");
|
||||
}
|
||||
if (parsed.version !== 3) {
|
||||
throw invalidSession(filePath, "unsupported session version");
|
||||
}
|
||||
if (typeof parsed.id !== "string" || !parsed.id) {
|
||||
throw invalidSession(filePath, "session header is missing id");
|
||||
}
|
||||
if (typeof parsed.timestamp !== "string" || !parsed.timestamp) {
|
||||
throw invalidSession(filePath, "session header is missing timestamp");
|
||||
}
|
||||
if (parseSessionTimestampMs(parsed.timestamp) === undefined) {
|
||||
throw invalidSession(filePath, "session header has invalid timestamp");
|
||||
}
|
||||
if (typeof parsed.cwd !== "string" || !parsed.cwd) {
|
||||
throw invalidSession(filePath, "session header is missing cwd");
|
||||
}
|
||||
if (parsed.parentSession !== undefined && typeof parsed.parentSession !== "string") {
|
||||
throw invalidSession(filePath, "session header parentSession must be a string");
|
||||
}
|
||||
return {
|
||||
type: "session",
|
||||
version: 3,
|
||||
id: parsed.id,
|
||||
timestamp: parsed.timestamp,
|
||||
cwd: parsed.cwd,
|
||||
parentSession: parsed.parentSession,
|
||||
};
|
||||
}
|
||||
|
||||
function parseEntryLine(line: string, filePath: string, lineNumber: number): SessionTreeEntry {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(line);
|
||||
} catch (error) {
|
||||
throw invalidEntry(filePath, lineNumber, "is not valid JSON", toError(error));
|
||||
}
|
||||
if (!isRecord(parsed)) {
|
||||
throw invalidEntry(filePath, lineNumber, "is not a valid session entry");
|
||||
}
|
||||
if (typeof parsed.type !== "string") {
|
||||
throw invalidEntry(filePath, lineNumber, "is missing entry type");
|
||||
}
|
||||
if (typeof parsed.id !== "string" || !parsed.id) {
|
||||
throw invalidEntry(filePath, lineNumber, "is missing entry id");
|
||||
}
|
||||
if (parsed.parentId !== null && typeof parsed.parentId !== "string") {
|
||||
throw invalidEntry(filePath, lineNumber, "has invalid parentId");
|
||||
}
|
||||
if (typeof parsed.timestamp !== "string" || !parsed.timestamp) {
|
||||
throw invalidEntry(filePath, lineNumber, "is missing timestamp");
|
||||
}
|
||||
if (parseSessionTimestampMs(parsed.timestamp) === undefined) {
|
||||
throw invalidEntry(filePath, lineNumber, "has invalid timestamp");
|
||||
}
|
||||
if (parsed.type === "leaf" && parsed.targetId !== null && typeof parsed.targetId !== "string") {
|
||||
throw invalidEntry(filePath, lineNumber, "has invalid targetId");
|
||||
}
|
||||
if (
|
||||
parsed.type === "leaf" &&
|
||||
parsed.appendParentId !== undefined &&
|
||||
parsed.appendParentId !== null &&
|
||||
typeof parsed.appendParentId !== "string"
|
||||
) {
|
||||
throw invalidEntry(filePath, lineNumber, "has invalid appendParentId");
|
||||
}
|
||||
if (parsed.appendMode !== undefined && parsed.appendMode !== "side") {
|
||||
throw invalidEntry(filePath, lineNumber, "has invalid appendMode");
|
||||
}
|
||||
return parsed as unknown as SessionTreeEntry;
|
||||
}
|
||||
|
||||
function headerToSessionMetadata(header: SessionHeader, path: string): JsonlSessionMetadata {
|
||||
return {
|
||||
id: header.id,
|
||||
createdAt: header.timestamp,
|
||||
cwd: header.cwd,
|
||||
path,
|
||||
parentSessionPath: header.parentSession,
|
||||
};
|
||||
}
|
||||
|
||||
/** Read only the JSONL session header and convert it to session metadata. */
|
||||
export async function loadJsonlSessionMetadata(
|
||||
fs: JsonlSessionStorageFileSystem,
|
||||
filePath: string,
|
||||
): Promise<JsonlSessionMetadata> {
|
||||
const lines = getFileSystemResultOrThrow(
|
||||
await fs.readTextLines(filePath, { maxLines: 1 }),
|
||||
`Failed to read session header ${filePath}`,
|
||||
);
|
||||
const line = lines[0];
|
||||
if (line?.trim()) {
|
||||
return headerToSessionMetadata(parseHeaderLine(line, filePath), filePath);
|
||||
}
|
||||
throw invalidSession(filePath, "missing session header");
|
||||
}
|
||||
|
||||
async function loadJsonlStorage(
|
||||
fs: JsonlSessionStorageFileSystem,
|
||||
filePath: string,
|
||||
): Promise<{
|
||||
header: SessionHeader;
|
||||
entries: SessionTreeEntry[];
|
||||
leafId: string | null;
|
||||
appendParentId: string | null;
|
||||
}> {
|
||||
const content = getFileSystemResultOrThrow(
|
||||
await fs.readTextFile(filePath),
|
||||
`Failed to read session ${filePath}`,
|
||||
);
|
||||
const lines = content.split("\n").filter((line) => line.trim());
|
||||
if (lines.length === 0) {
|
||||
throw invalidSession(filePath, "missing session header");
|
||||
}
|
||||
|
||||
const header = parseHeaderLine(lines[0], filePath);
|
||||
const entries: SessionTreeEntry[] = [];
|
||||
let leafId: string | null = null;
|
||||
let appendParentId: string | null = null;
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const entry = parseEntryLine(lines[i], filePath, i + 1);
|
||||
entries.push(entry);
|
||||
const leafUpdate = leafIdUpdateAfterEntry(entry);
|
||||
if (leafUpdate !== undefined) {
|
||||
leafId = leafUpdate;
|
||||
}
|
||||
appendParentId = appendParentIdAfterEntry(entry);
|
||||
}
|
||||
return { header, entries, leafId, appendParentId };
|
||||
}
|
||||
|
||||
/** Append-only JSONL-backed storage for one session tree. */
|
||||
export class JsonlSessionStorage extends BaseSessionStorage<JsonlSessionMetadata> {
|
||||
private readonly fs: JsonlSessionStorageFileSystem;
|
||||
private readonly filePath: string;
|
||||
|
||||
private constructor(
|
||||
fs: JsonlSessionStorageFileSystem,
|
||||
filePath: string,
|
||||
header: SessionHeader,
|
||||
entries: SessionTreeEntry[],
|
||||
leafId: string | null,
|
||||
appendParentId: string | null,
|
||||
) {
|
||||
super(headerToSessionMetadata(header, filePath), entries, leafId, appendParentId);
|
||||
this.fs = fs;
|
||||
this.filePath = filePath;
|
||||
}
|
||||
|
||||
static async open(
|
||||
fs: JsonlSessionStorageFileSystem,
|
||||
filePath: string,
|
||||
): Promise<JsonlSessionStorage> {
|
||||
const loaded = await loadJsonlStorage(fs, filePath);
|
||||
return new JsonlSessionStorage(
|
||||
fs,
|
||||
filePath,
|
||||
loaded.header,
|
||||
loaded.entries,
|
||||
loaded.leafId,
|
||||
loaded.appendParentId,
|
||||
);
|
||||
}
|
||||
|
||||
/** Create a new JSONL file with a session header and no entries. */
|
||||
static async create(
|
||||
fs: JsonlSessionStorageFileSystem,
|
||||
filePath: string,
|
||||
options: {
|
||||
cwd: string;
|
||||
sessionId: string;
|
||||
parentSessionPath?: string;
|
||||
},
|
||||
): Promise<JsonlSessionStorage> {
|
||||
const header: SessionHeader = {
|
||||
type: "session",
|
||||
version: 3,
|
||||
id: options.sessionId,
|
||||
timestamp: new Date().toISOString(),
|
||||
cwd: options.cwd,
|
||||
parentSession: options.parentSessionPath,
|
||||
};
|
||||
getFileSystemResultOrThrow(
|
||||
await fs.writeFile(filePath, `${JSON.stringify(header)}\n`),
|
||||
`Failed to create session ${filePath}`,
|
||||
);
|
||||
return new JsonlSessionStorage(fs, filePath, header, [], null, null);
|
||||
}
|
||||
|
||||
override async setLeafId(leafId: string | null): Promise<void> {
|
||||
const entry = this.createLeafEntry(leafId);
|
||||
getFileSystemResultOrThrow(
|
||||
await this.fs.appendFile(this.filePath, `${JSON.stringify(entry)}\n`),
|
||||
`Failed to append session leaf ${entry.id}`,
|
||||
);
|
||||
this.recordEntry(entry);
|
||||
}
|
||||
|
||||
override async appendEntry(entry: SessionTreeEntry): Promise<void> {
|
||||
this.validateEntryForAppend(entry);
|
||||
getFileSystemResultOrThrow(
|
||||
await this.fs.appendFile(this.filePath, `${JSON.stringify(entry)}\n`),
|
||||
`Failed to append session entry ${entry.id}`,
|
||||
);
|
||||
this.recordEntry(entry);
|
||||
}
|
||||
}
|
||||
180
packages/agent-core/src/harness/session/memory-storage.test.ts
Normal file
180
packages/agent-core/src/harness/session/memory-storage.test.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
// Agent Core tests cover memory storage behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { SessionTreeEntry } from "../types.js";
|
||||
import { InMemorySessionStorage } from "./memory-storage.js";
|
||||
import { Session } from "./session.js";
|
||||
|
||||
const rootEntry: SessionTreeEntry = {
|
||||
type: "custom",
|
||||
id: "root",
|
||||
parentId: null,
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
customType: "root",
|
||||
};
|
||||
|
||||
const childEntry: SessionTreeEntry = {
|
||||
type: "custom",
|
||||
id: "child",
|
||||
parentId: "root",
|
||||
timestamp: "2026-01-01T00:00:01.000Z",
|
||||
customType: "child",
|
||||
};
|
||||
|
||||
describe("InMemorySessionStorage", () => {
|
||||
it("uses shared entry indexes for labels, leaves, and paths", async () => {
|
||||
const storage = new InMemorySessionStorage({
|
||||
entries: [
|
||||
rootEntry,
|
||||
childEntry,
|
||||
{
|
||||
type: "label",
|
||||
id: "label-1",
|
||||
parentId: "child",
|
||||
timestamp: "2026-01-01T00:00:02.000Z",
|
||||
targetId: "child",
|
||||
label: " latest ",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(await storage.getLeafId()).toBe("label-1");
|
||||
expect(await storage.getLabel("child")).toBe("latest");
|
||||
expect((await storage.getPathToRoot("child")).map((entry) => entry.id)).toEqual([
|
||||
"root",
|
||||
"child",
|
||||
]);
|
||||
});
|
||||
|
||||
it("records explicit leaf updates through the shared storage path", async () => {
|
||||
const storage = new InMemorySessionStorage({
|
||||
entries: [rootEntry, childEntry],
|
||||
});
|
||||
|
||||
await storage.setLeafId("root");
|
||||
|
||||
const entries = await storage.getEntries();
|
||||
const leaf = entries.at(-1);
|
||||
expect(await storage.getLeafId()).toBe("root");
|
||||
expect(leaf).toMatchObject({
|
||||
type: "leaf",
|
||||
parentId: "child",
|
||||
targetId: "root",
|
||||
});
|
||||
});
|
||||
|
||||
it("traverses descendants of leaf markers through the selected target", async () => {
|
||||
const leafEntry: SessionTreeEntry = {
|
||||
type: "leaf",
|
||||
id: "leaf-1",
|
||||
parentId: "child",
|
||||
timestamp: "2026-01-01T00:00:02.000Z",
|
||||
targetId: "root",
|
||||
};
|
||||
const replacementEntry: SessionTreeEntry = {
|
||||
type: "custom",
|
||||
id: "replacement",
|
||||
parentId: leafEntry.id,
|
||||
timestamp: "2026-01-01T00:00:03.000Z",
|
||||
customType: "replacement",
|
||||
};
|
||||
const storage = new InMemorySessionStorage({
|
||||
entries: [rootEntry, childEntry, leafEntry, replacementEntry],
|
||||
});
|
||||
|
||||
expect((await storage.getPathToRoot(replacementEntry.id)).map((entry) => entry.id)).toEqual([
|
||||
"root",
|
||||
"replacement",
|
||||
]);
|
||||
expect((await storage.getPathToRoot(leafEntry.id)).map((entry) => entry.id)).toEqual(["root"]);
|
||||
});
|
||||
|
||||
it("honors an explicit root append parent after a visible leaf selection", async () => {
|
||||
const storage = new InMemorySessionStorage({
|
||||
entries: [
|
||||
rootEntry,
|
||||
{
|
||||
type: "leaf",
|
||||
id: "leaf-1",
|
||||
parentId: "root",
|
||||
timestamp: "2026-01-01T00:00:01.000Z",
|
||||
targetId: "root",
|
||||
appendParentId: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
const session = new Session(storage);
|
||||
|
||||
const entryId = await session.appendCustomEntry("new-root");
|
||||
|
||||
expect(await session.getEntry(entryId)).toMatchObject({ parentId: null });
|
||||
expect((await storage.getPathToRoot(entryId)).map((entry) => entry.id)).toEqual([
|
||||
"root",
|
||||
entryId,
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps marked side ancestry separate from the next active append", async () => {
|
||||
const sideOne: SessionTreeEntry = {
|
||||
type: "custom",
|
||||
id: "side-one",
|
||||
parentId: "root",
|
||||
timestamp: "2026-01-01T00:00:01.000Z",
|
||||
customType: "side",
|
||||
};
|
||||
const sideTwo: SessionTreeEntry = {
|
||||
type: "custom",
|
||||
id: "side-two",
|
||||
parentId: sideOne.id,
|
||||
timestamp: "2026-01-01T00:00:03.000Z",
|
||||
appendMode: "side",
|
||||
customType: "side",
|
||||
};
|
||||
const storage = new InMemorySessionStorage({
|
||||
entries: [
|
||||
rootEntry,
|
||||
sideOne,
|
||||
{
|
||||
type: "leaf",
|
||||
id: "first-leaf",
|
||||
parentId: sideOne.id,
|
||||
timestamp: "2026-01-01T00:00:02.000Z",
|
||||
targetId: "root",
|
||||
appendParentId: sideOne.id,
|
||||
appendMode: "side",
|
||||
},
|
||||
sideTwo,
|
||||
],
|
||||
});
|
||||
const session = new Session(storage);
|
||||
|
||||
expect(await storage.getLeafId()).toBe("root");
|
||||
expect(await storage.getAppendParentId()).toBe(sideTwo.id);
|
||||
expect((await storage.getPathToRoot(sideTwo.id)).map((entry) => entry.id)).toEqual([
|
||||
"root",
|
||||
sideOne.id,
|
||||
sideTwo.id,
|
||||
]);
|
||||
|
||||
const nextEntryId = await session.appendCustomEntry("active");
|
||||
expect((await storage.getPathToRoot(nextEntryId)).map((entry) => entry.id)).toEqual([
|
||||
"root",
|
||||
nextEntryId,
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects a leaf entry with a missing append parent before recording it", async () => {
|
||||
const storage = new InMemorySessionStorage({ entries: [rootEntry] });
|
||||
|
||||
await expect(
|
||||
storage.appendEntry({
|
||||
type: "leaf",
|
||||
id: "leaf-1",
|
||||
parentId: "root",
|
||||
timestamp: "2026-01-01T00:00:01.000Z",
|
||||
targetId: "root",
|
||||
appendParentId: "missing",
|
||||
}),
|
||||
).rejects.toThrow("Append parent missing not found");
|
||||
expect(await storage.getEntries()).toEqual([rootEntry]);
|
||||
});
|
||||
});
|
||||
24
packages/agent-core/src/harness/session/memory-storage.ts
Normal file
24
packages/agent-core/src/harness/session/memory-storage.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
// Agent Core module implements memory storage behavior.
|
||||
import type { SessionMetadata, SessionTreeEntry } from "../types.js";
|
||||
import { BaseSessionStorage } from "./storage-base.js";
|
||||
import { uuidv7 } from "./uuid.js";
|
||||
|
||||
/** Volatile session storage used by tests and in-process harness callers. */
|
||||
export class InMemorySessionStorage<
|
||||
TMetadata extends SessionMetadata = SessionMetadata,
|
||||
> extends BaseSessionStorage<TMetadata> {
|
||||
constructor(options?: { entries?: SessionTreeEntry[]; metadata?: TMetadata }) {
|
||||
super(
|
||||
options?.metadata ?? ({ id: uuidv7(), createdAt: new Date().toISOString() } as TMetadata),
|
||||
options?.entries ? [...options.entries] : [],
|
||||
);
|
||||
}
|
||||
|
||||
override async setLeafId(leafId: string | null): Promise<void> {
|
||||
this.recordEntry(this.createLeafEntry(leafId));
|
||||
}
|
||||
|
||||
override async appendEntry(entry: SessionTreeEntry): Promise<void> {
|
||||
this.recordEntry(entry);
|
||||
}
|
||||
}
|
||||
290
packages/agent-core/src/harness/session/session.ts
Normal file
290
packages/agent-core/src/harness/session/session.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
// Agent Core module implements session behavior.
|
||||
import type { ImageContent, TextContent } from "../../../../llm-core/src/index.js";
|
||||
import type { AgentMessage } from "../../types.js";
|
||||
import {
|
||||
asAgentMessage,
|
||||
createBranchSummaryMessage,
|
||||
createCompactionSummaryMessage,
|
||||
createCustomMessage,
|
||||
} from "../messages.js";
|
||||
import type {
|
||||
BranchSummaryEntry,
|
||||
CompactionEntry,
|
||||
CustomEntry,
|
||||
CustomMessageEntry,
|
||||
LabelEntry,
|
||||
MessageEntry,
|
||||
ModelChangeEntry,
|
||||
SessionContext,
|
||||
SessionInfoEntry,
|
||||
SessionMetadata,
|
||||
SessionStorage,
|
||||
SessionTreeEntry,
|
||||
ThinkingLevelChangeEntry,
|
||||
} from "../types.js";
|
||||
import { SessionError } from "../types.js";
|
||||
|
||||
/** Build model context from the active session branch and its latest state markers. */
|
||||
export function buildSessionContext(pathEntries: SessionTreeEntry[]): SessionContext {
|
||||
let thinkingLevel = "off";
|
||||
let model: { provider: string; modelId: string } | null = null;
|
||||
let compaction: CompactionEntry | null = null;
|
||||
|
||||
for (const entry of pathEntries) {
|
||||
if (entry.type === "thinking_level_change") {
|
||||
thinkingLevel = entry.thinkingLevel;
|
||||
} else if (entry.type === "model_change") {
|
||||
model = { provider: entry.provider, modelId: entry.modelId };
|
||||
} else if (entry.type === "message" && entry.message.role === "assistant") {
|
||||
model = { provider: entry.message.provider, modelId: entry.message.model };
|
||||
} else if (entry.type === "compaction") {
|
||||
compaction = entry;
|
||||
}
|
||||
}
|
||||
|
||||
const messages: AgentMessage[] = [];
|
||||
const appendMessage = (entry: SessionTreeEntry) => {
|
||||
if (entry.type === "message") {
|
||||
messages.push(entry.message);
|
||||
} else if (entry.type === "custom_message") {
|
||||
messages.push(
|
||||
asAgentMessage(
|
||||
createCustomMessage(
|
||||
entry.customType,
|
||||
entry.content,
|
||||
entry.display,
|
||||
entry.details,
|
||||
entry.timestamp,
|
||||
),
|
||||
),
|
||||
);
|
||||
} else if (entry.type === "branch_summary" && entry.summary) {
|
||||
messages.push(
|
||||
asAgentMessage(createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp)),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (compaction) {
|
||||
messages.push(
|
||||
asAgentMessage(
|
||||
createCompactionSummaryMessage(
|
||||
compaction.summary,
|
||||
compaction.tokensBefore,
|
||||
compaction.timestamp,
|
||||
),
|
||||
),
|
||||
);
|
||||
const compactionIdx = pathEntries.findIndex(
|
||||
(e) => e.type === "compaction" && e.id === compaction.id,
|
||||
);
|
||||
// Replay only the compacted entry's retained tail plus newer branch entries; older
|
||||
// transcript content is represented by the synthetic compaction summary above.
|
||||
let foundFirstKept = false;
|
||||
for (let i = 0; i < compactionIdx; i++) {
|
||||
const entry = pathEntries[i];
|
||||
if (entry.id === compaction.firstKeptEntryId) {
|
||||
foundFirstKept = true;
|
||||
}
|
||||
if (foundFirstKept) {
|
||||
appendMessage(entry);
|
||||
}
|
||||
}
|
||||
for (let i = compactionIdx + 1; i < pathEntries.length; i++) {
|
||||
appendMessage(pathEntries[i]);
|
||||
}
|
||||
} else {
|
||||
for (const entry of pathEntries) {
|
||||
appendMessage(entry);
|
||||
}
|
||||
}
|
||||
|
||||
return { messages, thinkingLevel, model };
|
||||
}
|
||||
|
||||
/** High-level session API backed by pluggable tree storage. */
|
||||
export class Session<TMetadata extends SessionMetadata = SessionMetadata> {
|
||||
private storage: SessionStorage<TMetadata>;
|
||||
|
||||
constructor(storage: SessionStorage<TMetadata>) {
|
||||
this.storage = storage;
|
||||
}
|
||||
|
||||
getMetadata(): Promise<TMetadata> {
|
||||
return this.storage.getMetadata();
|
||||
}
|
||||
|
||||
getStorage(): SessionStorage<TMetadata> {
|
||||
return this.storage;
|
||||
}
|
||||
|
||||
getLeafId(): Promise<string | null> {
|
||||
return this.storage.getLeafId();
|
||||
}
|
||||
|
||||
private getAppendParentId(): Promise<string | null> {
|
||||
return this.storage.getAppendParentId?.() ?? this.storage.getLeafId();
|
||||
}
|
||||
|
||||
getEntry(id: string): Promise<SessionTreeEntry | undefined> {
|
||||
return this.storage.getEntry(id);
|
||||
}
|
||||
|
||||
getEntries(): Promise<SessionTreeEntry[]> {
|
||||
return this.storage.getEntries();
|
||||
}
|
||||
|
||||
async getBranch(fromId?: string): Promise<SessionTreeEntry[]> {
|
||||
const leafId = fromId ?? (await this.storage.getLeafId());
|
||||
return this.storage.getPathToRoot(leafId);
|
||||
}
|
||||
|
||||
async buildContext(): Promise<SessionContext> {
|
||||
return buildSessionContext(await this.getBranch());
|
||||
}
|
||||
|
||||
getLabel(id: string): Promise<string | undefined> {
|
||||
return this.storage.getLabel(id);
|
||||
}
|
||||
|
||||
async getSessionName(): Promise<string | undefined> {
|
||||
const entries = await this.storage.findEntries("session_info");
|
||||
return entries[entries.length - 1]?.name?.trim() || undefined;
|
||||
}
|
||||
|
||||
private async appendTypedEntry(entry: SessionTreeEntry): Promise<string> {
|
||||
await this.storage.appendEntry(entry);
|
||||
return entry.id;
|
||||
}
|
||||
|
||||
async appendMessage(message: AgentMessage): Promise<string> {
|
||||
return this.appendTypedEntry({
|
||||
type: "message",
|
||||
id: await this.storage.createEntryId(),
|
||||
parentId: await this.getAppendParentId(),
|
||||
timestamp: new Date().toISOString(),
|
||||
message,
|
||||
} satisfies MessageEntry);
|
||||
}
|
||||
|
||||
async appendThinkingLevelChange(thinkingLevel: string): Promise<string> {
|
||||
return this.appendTypedEntry({
|
||||
type: "thinking_level_change",
|
||||
id: await this.storage.createEntryId(),
|
||||
parentId: await this.getAppendParentId(),
|
||||
timestamp: new Date().toISOString(),
|
||||
thinkingLevel,
|
||||
} satisfies ThinkingLevelChangeEntry);
|
||||
}
|
||||
|
||||
async appendModelChange(provider: string, modelId: string): Promise<string> {
|
||||
return this.appendTypedEntry({
|
||||
type: "model_change",
|
||||
id: await this.storage.createEntryId(),
|
||||
parentId: await this.getAppendParentId(),
|
||||
timestamp: new Date().toISOString(),
|
||||
provider,
|
||||
modelId,
|
||||
} satisfies ModelChangeEntry);
|
||||
}
|
||||
|
||||
async appendCompaction(
|
||||
summary: string,
|
||||
firstKeptEntryId: string,
|
||||
tokensBefore: number,
|
||||
details?: unknown,
|
||||
fromHook?: boolean,
|
||||
): Promise<string> {
|
||||
return this.appendTypedEntry({
|
||||
type: "compaction",
|
||||
id: await this.storage.createEntryId(),
|
||||
parentId: await this.getAppendParentId(),
|
||||
timestamp: new Date().toISOString(),
|
||||
summary,
|
||||
firstKeptEntryId,
|
||||
tokensBefore,
|
||||
details,
|
||||
fromHook,
|
||||
} satisfies CompactionEntry);
|
||||
}
|
||||
|
||||
/** Append a non-LLM transcript marker for harness-specific state. */
|
||||
async appendCustomEntry(customType: string, data?: unknown): Promise<string> {
|
||||
return this.appendTypedEntry({
|
||||
type: "custom",
|
||||
id: await this.storage.createEntryId(),
|
||||
parentId: await this.getAppendParentId(),
|
||||
timestamp: new Date().toISOString(),
|
||||
customType,
|
||||
data,
|
||||
} satisfies CustomEntry);
|
||||
}
|
||||
|
||||
/** Append harness-specific content that can also be replayed into model context. */
|
||||
async appendCustomMessageEntry(
|
||||
customType: string,
|
||||
content: string | (TextContent | ImageContent)[],
|
||||
display: boolean,
|
||||
details?: unknown,
|
||||
): Promise<string> {
|
||||
return this.appendTypedEntry({
|
||||
type: "custom_message",
|
||||
id: await this.storage.createEntryId(),
|
||||
parentId: await this.getAppendParentId(),
|
||||
timestamp: new Date().toISOString(),
|
||||
customType,
|
||||
content,
|
||||
display,
|
||||
details,
|
||||
} satisfies CustomMessageEntry);
|
||||
}
|
||||
|
||||
/** Record or clear the display label for an existing session entry. */
|
||||
async appendLabel(targetId: string, label: string | undefined): Promise<string> {
|
||||
if (!(await this.storage.getEntry(targetId))) {
|
||||
throw new SessionError("not_found", `Entry ${targetId} not found`);
|
||||
}
|
||||
return this.appendTypedEntry({
|
||||
type: "label",
|
||||
id: await this.storage.createEntryId(),
|
||||
parentId: await this.getAppendParentId(),
|
||||
timestamp: new Date().toISOString(),
|
||||
targetId,
|
||||
label,
|
||||
} satisfies LabelEntry);
|
||||
}
|
||||
|
||||
async appendSessionName(name: string): Promise<string> {
|
||||
return this.appendTypedEntry({
|
||||
type: "session_info",
|
||||
id: await this.storage.createEntryId(),
|
||||
parentId: await this.getAppendParentId(),
|
||||
timestamp: new Date().toISOString(),
|
||||
name: name.trim(),
|
||||
} satisfies SessionInfoEntry);
|
||||
}
|
||||
|
||||
/** Move the visible branch leaf and optionally attach a summary of the abandoned branch. */
|
||||
async moveTo(
|
||||
entryId: string | null,
|
||||
summary?: { summary: string; details?: unknown; fromHook?: boolean },
|
||||
): Promise<string | undefined> {
|
||||
if (entryId !== null && !(await this.storage.getEntry(entryId))) {
|
||||
throw new SessionError("not_found", `Entry ${entryId} not found`);
|
||||
}
|
||||
await this.storage.setLeafId(entryId);
|
||||
if (!summary) {
|
||||
return undefined;
|
||||
}
|
||||
return this.appendTypedEntry({
|
||||
type: "branch_summary",
|
||||
id: await this.storage.createEntryId(),
|
||||
parentId: entryId,
|
||||
timestamp: new Date().toISOString(),
|
||||
fromId: entryId ?? "root",
|
||||
summary: summary.summary,
|
||||
details: summary.details,
|
||||
fromHook: summary.fromHook,
|
||||
} satisfies BranchSummaryEntry);
|
||||
}
|
||||
}
|
||||
283
packages/agent-core/src/harness/session/storage-base.ts
Normal file
283
packages/agent-core/src/harness/session/storage-base.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
// Agent Core module implements storage base behavior.
|
||||
import {
|
||||
type LeafEntry,
|
||||
SessionError,
|
||||
type SessionMetadata,
|
||||
type SessionStorage,
|
||||
type SessionTreeEntry,
|
||||
} from "../types.js";
|
||||
import { uuidv7 } from "./uuid.js";
|
||||
|
||||
function updateLabelCache(labelsById: Map<string, string>, entry: SessionTreeEntry): void {
|
||||
if (entry.type !== "label") {
|
||||
return;
|
||||
}
|
||||
const label = entry.label?.trim();
|
||||
if (label) {
|
||||
labelsById.set(entry.targetId, label);
|
||||
} else {
|
||||
labelsById.delete(entry.targetId);
|
||||
}
|
||||
}
|
||||
|
||||
function buildLabelsById(entries: SessionTreeEntry[]): Map<string, string> {
|
||||
const labelsById = new Map<string, string>();
|
||||
for (const entry of entries) {
|
||||
updateLabelCache(labelsById, entry);
|
||||
}
|
||||
return labelsById;
|
||||
}
|
||||
|
||||
function isSideAppendEntry(entry: SessionTreeEntry): boolean {
|
||||
return entry.appendMode === "side";
|
||||
}
|
||||
|
||||
function generateEntryId(byId: { has(id: string): boolean }): string {
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const id = uuidv7().slice(0, 8);
|
||||
if (!byId.has(id)) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
return uuidv7();
|
||||
}
|
||||
|
||||
/** Return the visible-leaf update represented by one session tree entry. */
|
||||
export function leafIdUpdateAfterEntry(entry: SessionTreeEntry): string | null | undefined {
|
||||
if (entry.type !== "leaf" && isSideAppendEntry(entry)) {
|
||||
return undefined;
|
||||
}
|
||||
switch (entry.type) {
|
||||
case "leaf":
|
||||
return entry.targetId;
|
||||
case "message":
|
||||
case "thinking_level_change":
|
||||
case "model_change":
|
||||
case "compaction":
|
||||
case "branch_summary":
|
||||
case "custom":
|
||||
case "custom_message":
|
||||
case "label":
|
||||
case "session_info":
|
||||
return entry.id;
|
||||
default:
|
||||
// JSONL transcripts may contain parent-linked plugin rows that advance
|
||||
// the raw append cursor without selecting a model-visible branch.
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Return the raw parent for the next append after applying a tree entry. */
|
||||
export function appendParentIdAfterEntry(entry: SessionTreeEntry): string | null {
|
||||
return entry.type === "leaf"
|
||||
? entry.appendParentId === undefined
|
||||
? entry.targetId
|
||||
: entry.appendParentId
|
||||
: entry.id;
|
||||
}
|
||||
|
||||
function resolveLeafId(entries: readonly SessionTreeEntry[]): string | null {
|
||||
let leafId: string | null = null;
|
||||
for (const entry of entries) {
|
||||
const update = leafIdUpdateAfterEntry(entry);
|
||||
if (update !== undefined) {
|
||||
leafId = update;
|
||||
}
|
||||
}
|
||||
return leafId;
|
||||
}
|
||||
|
||||
function resolveAppendParentId(entries: readonly SessionTreeEntry[]): string | null {
|
||||
let appendParentId: string | null = null;
|
||||
for (const entry of entries) {
|
||||
appendParentId = appendParentIdAfterEntry(entry);
|
||||
}
|
||||
return appendParentId;
|
||||
}
|
||||
|
||||
function buildLogicalParentsById(entries: readonly SessionTreeEntry[]): Map<string, string | null> {
|
||||
const logicalParentsById = new Map<string, string | null>();
|
||||
let leafId: string | null = null;
|
||||
let appendParentId: string | null = null;
|
||||
for (const entry of entries) {
|
||||
const leafUpdate = leafIdUpdateAfterEntry(entry);
|
||||
if (
|
||||
leafUpdate === entry.id &&
|
||||
!isSideAppendEntry(entry) &&
|
||||
entry.parentId === appendParentId &&
|
||||
leafId !== appendParentId
|
||||
) {
|
||||
logicalParentsById.set(entry.id, leafId);
|
||||
}
|
||||
if (leafUpdate !== undefined) {
|
||||
leafId = leafUpdate;
|
||||
}
|
||||
appendParentId = appendParentIdAfterEntry(entry);
|
||||
}
|
||||
return logicalParentsById;
|
||||
}
|
||||
|
||||
export abstract class BaseSessionStorage<
|
||||
TMetadata extends SessionMetadata = SessionMetadata,
|
||||
> implements SessionStorage<TMetadata> {
|
||||
private readonly metadata: TMetadata;
|
||||
private readonly entries: SessionTreeEntry[];
|
||||
private readonly byId: Map<string, SessionTreeEntry>;
|
||||
private readonly labelsById: Map<string, string>;
|
||||
private readonly logicalParentsById: Map<string, string | null>;
|
||||
private leafId: string | null;
|
||||
private appendParentId: string | null;
|
||||
|
||||
protected constructor(
|
||||
metadata: TMetadata,
|
||||
entries: SessionTreeEntry[],
|
||||
leafId: string | null = resolveLeafId(entries),
|
||||
appendParentId: string | null = resolveAppendParentId(entries),
|
||||
) {
|
||||
this.metadata = metadata;
|
||||
this.entries = entries;
|
||||
this.byId = new Map(entries.map((entry) => [entry.id, entry]));
|
||||
this.labelsById = buildLabelsById(entries);
|
||||
this.logicalParentsById = buildLogicalParentsById(entries);
|
||||
this.leafId = leafId;
|
||||
this.appendParentId = appendParentId;
|
||||
if (this.leafId !== null && !this.byId.has(this.leafId)) {
|
||||
throw new SessionError("invalid_session", `Entry ${this.leafId} not found`);
|
||||
}
|
||||
if (this.appendParentId !== null && !this.byId.has(this.appendParentId)) {
|
||||
throw new SessionError("invalid_session", `Append parent ${this.appendParentId} not found`);
|
||||
}
|
||||
}
|
||||
|
||||
async getMetadata(): Promise<TMetadata> {
|
||||
return this.metadata;
|
||||
}
|
||||
|
||||
async getLeafId(): Promise<string | null> {
|
||||
if (this.leafId !== null && !this.byId.has(this.leafId)) {
|
||||
throw new SessionError("invalid_session", `Entry ${this.leafId} not found`);
|
||||
}
|
||||
return this.leafId;
|
||||
}
|
||||
|
||||
async getAppendParentId(): Promise<string | null> {
|
||||
if (this.appendParentId !== null && !this.byId.has(this.appendParentId)) {
|
||||
throw new SessionError("invalid_session", `Append parent ${this.appendParentId} not found`);
|
||||
}
|
||||
return this.appendParentId;
|
||||
}
|
||||
|
||||
protected createLeafEntry(leafId: string | null): LeafEntry {
|
||||
if (leafId !== null && !this.byId.has(leafId)) {
|
||||
throw new SessionError("not_found", `Entry ${leafId} not found`);
|
||||
}
|
||||
return {
|
||||
type: "leaf",
|
||||
id: generateEntryId(this.byId),
|
||||
parentId: this.appendParentId,
|
||||
timestamp: new Date().toISOString(),
|
||||
targetId: leafId,
|
||||
};
|
||||
}
|
||||
|
||||
async createEntryId(): Promise<string> {
|
||||
return generateEntryId(this.byId);
|
||||
}
|
||||
|
||||
protected validateEntryForAppend(entry: SessionTreeEntry): void {
|
||||
const leafId = leafIdUpdateAfterEntry(entry);
|
||||
const leafIsNewEntry = entry.type !== "leaf" && leafId === entry.id;
|
||||
if (leafId !== undefined && leafId !== null && !leafIsNewEntry && !this.byId.has(leafId)) {
|
||||
throw new SessionError("not_found", `Entry ${leafId} not found`);
|
||||
}
|
||||
|
||||
const appendParentId = appendParentIdAfterEntry(entry);
|
||||
const appendParentIsNewEntry = entry.type !== "leaf" && appendParentId === entry.id;
|
||||
if (appendParentId !== null && !appendParentIsNewEntry && !this.byId.has(appendParentId)) {
|
||||
throw new SessionError("not_found", `Append parent ${appendParentId} not found`);
|
||||
}
|
||||
}
|
||||
|
||||
protected recordEntry(entry: SessionTreeEntry): void {
|
||||
// Leaf and label entries are append-only state changes; keep derived indexes
|
||||
// synchronized here so memory and JSONL storage expose identical behavior.
|
||||
this.validateEntryForAppend(entry);
|
||||
const leafId = leafIdUpdateAfterEntry(entry);
|
||||
if (
|
||||
leafId === entry.id &&
|
||||
!isSideAppendEntry(entry) &&
|
||||
entry.parentId === this.appendParentId &&
|
||||
this.leafId !== this.appendParentId
|
||||
) {
|
||||
this.logicalParentsById.set(entry.id, this.leafId);
|
||||
}
|
||||
this.entries.push(entry);
|
||||
this.byId.set(entry.id, entry);
|
||||
updateLabelCache(this.labelsById, entry);
|
||||
if (leafId !== undefined) {
|
||||
this.leafId = leafId;
|
||||
}
|
||||
this.appendParentId = appendParentIdAfterEntry(entry);
|
||||
}
|
||||
|
||||
async getEntry(id: string): Promise<SessionTreeEntry | undefined> {
|
||||
return this.byId.get(id);
|
||||
}
|
||||
|
||||
async findEntries<TType extends SessionTreeEntry["type"]>(
|
||||
type: TType,
|
||||
): Promise<Array<Extract<SessionTreeEntry, { type: TType }>>> {
|
||||
return this.entries.filter(
|
||||
(entry): entry is Extract<SessionTreeEntry, { type: TType }> => entry.type === type,
|
||||
);
|
||||
}
|
||||
|
||||
async getLabel(id: string): Promise<string | undefined> {
|
||||
return this.labelsById.get(id);
|
||||
}
|
||||
|
||||
async getPathToRoot(leafId: string | null): Promise<SessionTreeEntry[]> {
|
||||
if (leafId === null) {
|
||||
return [];
|
||||
}
|
||||
const path: SessionTreeEntry[] = [];
|
||||
let current = this.byId.get(leafId);
|
||||
if (!current) {
|
||||
throw new SessionError("not_found", `Entry ${leafId} not found`);
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
while (current) {
|
||||
if (seen.has(current.id)) {
|
||||
throw new SessionError("invalid_session", `Cycle found at entry ${current.id}`);
|
||||
}
|
||||
seen.add(current.id);
|
||||
if (current.type !== "leaf") {
|
||||
path.unshift(current);
|
||||
}
|
||||
// Leaf rows are control records. Descendants written by older appenders
|
||||
// may point at the marker, but their visible ancestry starts at its target.
|
||||
const parentId =
|
||||
current.type === "leaf"
|
||||
? current.targetId
|
||||
: this.logicalParentsById.has(current.id)
|
||||
? (this.logicalParentsById.get(current.id) ?? null)
|
||||
: current.parentId;
|
||||
if (!parentId) {
|
||||
break;
|
||||
}
|
||||
const parent = this.byId.get(parentId);
|
||||
if (!parent) {
|
||||
throw new SessionError("invalid_session", `Entry ${parentId} not found`);
|
||||
}
|
||||
current = parent;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
async getEntries(): Promise<SessionTreeEntry[]> {
|
||||
return [...this.entries];
|
||||
}
|
||||
|
||||
abstract setLeafId(leafId: string | null): Promise<void>;
|
||||
abstract appendEntry(entry: SessionTreeEntry): Promise<void>;
|
||||
}
|
||||
17
packages/agent-core/src/harness/session/timestamps.ts
Normal file
17
packages/agent-core/src/harness/session/timestamps.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/** Parse an ISO-like session timestamp to milliseconds. */
|
||||
export function parseSessionTimestampMs(value: unknown): number | undefined {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = Date.parse(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
/** Parse a required timestamp or throw a labeled validation error. */
|
||||
export function requireSessionTimestampMs(value: string, label: string): number {
|
||||
const parsed = parseSessionTimestampMs(value);
|
||||
if (parsed === undefined) {
|
||||
throw new Error(`${label} must be a valid timestamp`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
59
packages/agent-core/src/harness/session/uuid.ts
Normal file
59
packages/agent-core/src/harness/session/uuid.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
// Agent Core module implements uuid behavior.
|
||||
let lastTimestamp = -Infinity;
|
||||
let sequence = 0;
|
||||
|
||||
// Small UUIDv7 generator for browser/node package builds without a runtime dep.
|
||||
function fillRandomBytes(bytes: Uint8Array): void {
|
||||
const crypto = globalThis.crypto;
|
||||
if (crypto?.getRandomValues) {
|
||||
crypto.getRandomValues(bytes as Uint8Array<ArrayBuffer>);
|
||||
return;
|
||||
}
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
bytes[i] = Math.floor(Math.random() * 256);
|
||||
}
|
||||
}
|
||||
|
||||
/** Generate a monotonic UUIDv7 string. */
|
||||
export function uuidv7(): string {
|
||||
const random = new Uint8Array(16);
|
||||
fillRandomBytes(random);
|
||||
const timestamp = Date.now();
|
||||
|
||||
if (timestamp > lastTimestamp) {
|
||||
sequence = random[6] * 0x1000000 + random[7] * 0x10000 + random[8] * 0x100 + random[9];
|
||||
lastTimestamp = timestamp;
|
||||
} else {
|
||||
// Same-ms calls increment the sequence so generated ids remain sortable and
|
||||
// unique even when random bytes repeat.
|
||||
sequence = (sequence + 1) >>> 0;
|
||||
if (sequence === 0) {
|
||||
lastTimestamp++;
|
||||
}
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(16);
|
||||
bytes[0] = (lastTimestamp / 0x10000000000) & 0xff;
|
||||
bytes[1] = (lastTimestamp / 0x100000000) & 0xff;
|
||||
bytes[2] = (lastTimestamp / 0x1000000) & 0xff;
|
||||
bytes[3] = (lastTimestamp / 0x10000) & 0xff;
|
||||
bytes[4] = (lastTimestamp / 0x100) & 0xff;
|
||||
bytes[5] = lastTimestamp & 0xff;
|
||||
bytes[6] = 0x70 | ((sequence >>> 28) & 0x0f);
|
||||
bytes[7] = (sequence >>> 20) & 0xff;
|
||||
bytes[8] = 0x80 | ((sequence >>> 14) & 0x3f);
|
||||
bytes[9] = (sequence >>> 6) & 0xff;
|
||||
bytes[10] = ((sequence & 0x3f) << 2) | (random[10] & 0x03);
|
||||
bytes[11] = random[11];
|
||||
bytes[12] = random[12];
|
||||
bytes[13] = random[13];
|
||||
bytes[14] = random[14];
|
||||
bytes[15] = random[15];
|
||||
|
||||
return formatUuid(bytes);
|
||||
}
|
||||
|
||||
function formatUuid(bytes: Uint8Array): string {
|
||||
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0"));
|
||||
return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex.slice(6, 8).join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10, 16).join("")}`;
|
||||
}
|
||||
14
packages/agent-core/src/harness/skills.ts
Normal file
14
packages/agent-core/src/harness/skills.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
// Agent Core module implements skill invocation formatting.
|
||||
import type { Skill } from "./types.js";
|
||||
|
||||
/** Format a skill invocation prompt, optionally appending additional user instructions. */
|
||||
export function formatSkillInvocation(skill: Skill, additionalInstructions?: string): string {
|
||||
const skillBlock = `<skill name="${skill.name}" location="${skill.filePath}">\nReferences are relative to ${dirnameEnvPath(skill.filePath)}.\n\n${skill.content}\n</skill>`;
|
||||
return additionalInstructions ? `${skillBlock}\n\n${additionalInstructions}` : skillBlock;
|
||||
}
|
||||
|
||||
function dirnameEnvPath(path: string): string {
|
||||
const normalized = path.replace(/\/+$/, "");
|
||||
const slashIndex = normalized.lastIndexOf("/");
|
||||
return slashIndex <= 0 ? "/" : normalized.slice(0, slashIndex);
|
||||
}
|
||||
842
packages/agent-core/src/harness/types.ts
Normal file
842
packages/agent-core/src/harness/types.ts
Normal file
@@ -0,0 +1,842 @@
|
||||
// Agent Core type module defines shared TypeScript contracts.
|
||||
import type {
|
||||
ImageContent,
|
||||
Model,
|
||||
SimpleStreamOptions,
|
||||
StreamFn,
|
||||
TextContent,
|
||||
Transport,
|
||||
} from "../../../llm-core/src/index.js";
|
||||
import type { AgentEvent, AgentMessage, AgentTool, QueueMode, ThinkingLevel } from "../index.js";
|
||||
import type { AgentCoreCompletionRuntimeDeps, AgentCoreRuntimeDeps } from "../runtime-deps.js";
|
||||
import type { Session } from "./session/session.js";
|
||||
|
||||
/** Result of a fallible operation. Expected failures are returned as `ok: false` instead of thrown. */
|
||||
export type Result<TValue, TError> = { ok: true; value: TValue } | { ok: false; error: TError };
|
||||
|
||||
/** Create a successful {@link Result}. */
|
||||
export function ok<TValue, TError>(value: TValue): Result<TValue, TError> {
|
||||
return { ok: true, value };
|
||||
}
|
||||
|
||||
/** Create a failed {@link Result}. */
|
||||
export function err<TValue, TError>(error: TError): Result<TValue, TError> {
|
||||
return { ok: false, error };
|
||||
}
|
||||
|
||||
/** Normalize unknown thrown values into Error instances before using them as typed error causes. */
|
||||
export function toError(error: unknown): Error {
|
||||
if (error instanceof Error) {
|
||||
return error;
|
||||
}
|
||||
if (typeof error === "string") {
|
||||
return new Error(error);
|
||||
}
|
||||
try {
|
||||
return new Error(JSON.stringify(error));
|
||||
} catch {
|
||||
return new Error(String(error));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill loaded from a `SKILL.md` file or provided by an application.
|
||||
*
|
||||
* `name`, `description`, `filePath`, and optional `promptVersion` are available to host-owned prompt builders and
|
||||
* direct skill invocation.
|
||||
*/
|
||||
export interface Skill {
|
||||
/** Stable skill name used for lookup and model-visible listings. */
|
||||
name: string;
|
||||
/** Short model-visible description of when to use the skill. */
|
||||
description: string;
|
||||
/** Full skill instructions. */
|
||||
content: string;
|
||||
/** Absolute path to the skill file. Used for model-visible location and resolving relative references. */
|
||||
filePath: string;
|
||||
/** Deterministic marker for the skill content, rendered as <version> when available. */
|
||||
promptVersion?: string;
|
||||
/** Exclude this skill from model-visible skill lists while still allowing explicit application invocation. */
|
||||
disableModelInvocation?: boolean;
|
||||
}
|
||||
|
||||
/** Prompt template that can be formatted into a prompt for explicit invocation. */
|
||||
export interface PromptTemplate {
|
||||
/** Stable template name used for lookup or application command routing. */
|
||||
name: string;
|
||||
/** Optional description for command lists or autocomplete. */
|
||||
description?: string;
|
||||
/** Template content. Argument placeholders are formatted by `formatPromptTemplateInvocation`. */
|
||||
content: string;
|
||||
}
|
||||
|
||||
/** Resources made available to explicit invocation methods and system-prompt callbacks. */
|
||||
export interface AgentHarnessResources<
|
||||
TSkill extends Skill = Skill,
|
||||
TPromptTemplate extends PromptTemplate = PromptTemplate,
|
||||
> {
|
||||
/** Prompt templates available for explicit invocation. */
|
||||
promptTemplates?: TPromptTemplate[];
|
||||
/** Skills available to the model and explicit skill invocation. */
|
||||
skills?: TSkill[];
|
||||
}
|
||||
|
||||
/** Curated provider request options owned by the harness and snapshotted per turn. */
|
||||
export interface AgentHarnessStreamOptions {
|
||||
/** Preferred transport forwarded to the stream function. */
|
||||
transport?: Transport;
|
||||
/** Provider request timeout in milliseconds. */
|
||||
timeoutMs?: number;
|
||||
/** Maximum provider retry attempts. */
|
||||
maxRetries?: number;
|
||||
/** Optional cap for provider-requested retry delays. */
|
||||
maxRetryDelayMs?: number;
|
||||
/** Additional request headers merged with auth and lifecycle headers. */
|
||||
headers?: Record<string, string>;
|
||||
/** Provider metadata forwarded with requests. */
|
||||
metadata?: SimpleStreamOptions["metadata"];
|
||||
/** Provider cache retention hint. */
|
||||
cacheRetention?: SimpleStreamOptions["cacheRetention"];
|
||||
}
|
||||
|
||||
/** Per-request stream option patch returned by provider hooks. */
|
||||
export interface AgentHarnessStreamOptionsPatch extends Omit<
|
||||
Partial<AgentHarnessStreamOptions>,
|
||||
"headers" | "metadata"
|
||||
> {
|
||||
/** Header patch. `undefined` values delete keys; explicit `headers: undefined` clears all headers. */
|
||||
headers?: Record<string, string | undefined>;
|
||||
/** Metadata patch. `undefined` values delete keys; explicit `metadata: undefined` clears all metadata. */
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Kind of filesystem object as addressed by a {@link FileSystem}. Symlinks are not followed automatically. */
|
||||
export type FileKind = "file" | "directory" | "symlink";
|
||||
|
||||
/** Stable, backend-independent file error codes returned by {@link FileSystem} file operations. */
|
||||
export type FileErrorCode =
|
||||
| "aborted"
|
||||
| "not_found"
|
||||
| "permission_denied"
|
||||
| "not_directory"
|
||||
| "is_directory"
|
||||
| "invalid"
|
||||
| "not_supported"
|
||||
| "unknown";
|
||||
|
||||
/** Error returned by {@link FileSystem} file operations. */
|
||||
export class FileError extends Error {
|
||||
/** Backend-independent error code. */
|
||||
public code: FileErrorCode;
|
||||
/** Absolute addressed path associated with the failure, when available. */
|
||||
public path?: string;
|
||||
|
||||
constructor(code: FileErrorCode, message: string, path?: string, cause?: Error) {
|
||||
super(message, cause === undefined ? undefined : { cause });
|
||||
this.name = "FileError";
|
||||
this.code = code;
|
||||
this.path = path;
|
||||
}
|
||||
}
|
||||
|
||||
/** Stable, backend-independent execution error codes returned by {@link ExecutionEnv.exec}. */
|
||||
export type ExecutionErrorCode =
|
||||
| "aborted"
|
||||
| "timeout"
|
||||
| "shell_unavailable"
|
||||
| "spawn_error"
|
||||
| "callback_error"
|
||||
| "unknown";
|
||||
|
||||
/** Error returned by {@link ExecutionEnv.exec}. */
|
||||
export class ExecutionError extends Error {
|
||||
/** Backend-independent error code. */
|
||||
public code: ExecutionErrorCode;
|
||||
|
||||
constructor(code: ExecutionErrorCode, message: string, cause?: Error) {
|
||||
super(message, cause === undefined ? undefined : { cause });
|
||||
this.name = "ExecutionError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
/** Stable compaction error codes returned by compaction helpers. */
|
||||
export type CompactionErrorCode =
|
||||
| "aborted"
|
||||
| "summarization_failed"
|
||||
| "invalid_session"
|
||||
| "unknown";
|
||||
|
||||
/** Error returned by compaction helpers. */
|
||||
export class CompactionError extends Error {
|
||||
/** Backend-independent error code. */
|
||||
public code: CompactionErrorCode;
|
||||
|
||||
constructor(code: CompactionErrorCode, message: string, cause?: Error) {
|
||||
super(message, cause === undefined ? undefined : { cause });
|
||||
this.name = "CompactionError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
/** Stable branch-summary error codes returned by branch summarization helpers. */
|
||||
export type BranchSummaryErrorCode = "aborted" | "summarization_failed" | "invalid_session";
|
||||
|
||||
/** Error returned by branch summarization helpers. */
|
||||
export class BranchSummaryError extends Error {
|
||||
/** Backend-independent error code. */
|
||||
public code: BranchSummaryErrorCode;
|
||||
|
||||
constructor(code: BranchSummaryErrorCode, message: string, cause?: Error) {
|
||||
super(message, cause === undefined ? undefined : { cause });
|
||||
this.name = "BranchSummaryError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionErrorCode =
|
||||
| "not_found"
|
||||
| "invalid_session"
|
||||
| "invalid_entry"
|
||||
| "invalid_fork_target"
|
||||
| "storage"
|
||||
| "unknown";
|
||||
|
||||
/** Error thrown by session storage, repositories, and session tree operations. */
|
||||
export class SessionError extends Error {
|
||||
/** Session subsystem error code. */
|
||||
public code: SessionErrorCode;
|
||||
|
||||
constructor(code: SessionErrorCode, message: string, cause?: Error) {
|
||||
super(message, cause === undefined ? undefined : { cause });
|
||||
this.name = "SessionError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export type AgentHarnessErrorCode =
|
||||
| "busy"
|
||||
| "invalid_state"
|
||||
| "invalid_argument"
|
||||
| "session"
|
||||
| "hook"
|
||||
| "auth"
|
||||
| "compaction"
|
||||
| "branch_summary"
|
||||
| "unknown";
|
||||
|
||||
/** Public AgentHarness failure with a stable top-level classification. */
|
||||
export class AgentHarnessError extends Error {
|
||||
public code: AgentHarnessErrorCode;
|
||||
|
||||
constructor(code: AgentHarnessErrorCode, message: string, cause?: Error) {
|
||||
super(message, cause === undefined ? undefined : { cause });
|
||||
this.name = "AgentHarnessError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
/** Metadata for one filesystem object in a {@link FileSystem}. */
|
||||
export interface FileInfo {
|
||||
/** Basename of {@link path}. */
|
||||
name: string;
|
||||
/** Absolute, syntactically normalized addressed path in the execution environment. Symlinks are not followed. */
|
||||
path: string;
|
||||
/** Object kind. Symlink targets are not followed; use {@link FileSystem.canonicalPath} explicitly. */
|
||||
kind: FileKind;
|
||||
/** Size in bytes for the addressed filesystem object. */
|
||||
size: number;
|
||||
/** Modification time as milliseconds since Unix epoch. */
|
||||
mtimeMs: number;
|
||||
}
|
||||
|
||||
/** Options for {@link Shell.exec}. */
|
||||
export interface ExecutionEnvExecOptions {
|
||||
/** Working directory for the command. Relative paths are resolved against {@link ExecutionEnv.cwd}. Defaults to {@link ExecutionEnv.cwd}. */
|
||||
cwd?: string;
|
||||
/** Additional environment variables for the command. Values override the environment defaults. Defaults to no overrides. */
|
||||
env?: Record<string, string>;
|
||||
/** Timeout in seconds. Implementations should return a timeout error when the command exceeds this duration. Defaults to no timeout. */
|
||||
timeout?: number;
|
||||
/** Abort signal used to terminate the command. Defaults to no abort signal. */
|
||||
abortSignal?: AbortSignal;
|
||||
/** Called with stdout chunks as they are produced. */
|
||||
onStdout?: (chunk: string) => void;
|
||||
/** Called with stderr chunks as they are produced. */
|
||||
onStderr?: (chunk: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filesystem capability used by the harness.
|
||||
*
|
||||
* Paths passed to methods may be absolute or relative to {@link cwd}. Paths returned by file operations are addressed paths
|
||||
* in the filesystem namespace, but are not canonicalized through symlinks unless returned by {@link canonicalPath}.
|
||||
*
|
||||
* Operation methods must never throw or reject. All filesystem failures, including unexpected backend failures, must be
|
||||
* encoded in the returned {@link Result}. Implementations must preserve this invariant.
|
||||
*/
|
||||
export interface FileSystem {
|
||||
/** Current working directory for relative paths. */
|
||||
cwd: string;
|
||||
|
||||
/** Return an absolute addressed path without requiring it to exist and without resolving symlinks. */
|
||||
absolutePath(path: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>>;
|
||||
/** Join path segments in the filesystem namespace without requiring the result to exist. */
|
||||
joinPath(parts: string[], abortSignal?: AbortSignal): Promise<Result<string, FileError>>;
|
||||
/** Read a UTF-8 text file. */
|
||||
readTextFile(path: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>>;
|
||||
/** Read UTF-8 text lines. Implementations should stop once `maxLines` lines have been read. */
|
||||
readTextLines(
|
||||
path: string,
|
||||
options?: { maxLines?: number; abortSignal?: AbortSignal },
|
||||
): Promise<Result<string[], FileError>>;
|
||||
/** Read a binary file. */
|
||||
readBinaryFile(path: string, abortSignal?: AbortSignal): Promise<Result<Uint8Array, FileError>>;
|
||||
/** Create or overwrite a file, creating parent directories when supported. */
|
||||
writeFile(
|
||||
path: string,
|
||||
content: string | Uint8Array,
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<Result<void, FileError>>;
|
||||
/** Create or append to a file, creating parent directories when supported. */
|
||||
appendFile(
|
||||
path: string,
|
||||
content: string | Uint8Array,
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<Result<void, FileError>>;
|
||||
/** Return metadata for the addressed path without following symlinks. */
|
||||
fileInfo(path: string, abortSignal?: AbortSignal): Promise<Result<FileInfo, FileError>>;
|
||||
/** List direct children of a directory without following symlinks. */
|
||||
listDir(path: string, abortSignal?: AbortSignal): Promise<Result<FileInfo[], FileError>>;
|
||||
/** Return the canonical path for an existing path, resolving symlinks where supported. */
|
||||
canonicalPath(path: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>>;
|
||||
/** Return false for missing paths. Other errors, such as permission failures, return a {@link FileError}. */
|
||||
exists(path: string, abortSignal?: AbortSignal): Promise<Result<boolean, FileError>>;
|
||||
/** Create a directory. Defaults: `recursive: true`, no abort signal. */
|
||||
createDir(
|
||||
path: string,
|
||||
options?: { recursive?: boolean; abortSignal?: AbortSignal },
|
||||
): Promise<Result<void, FileError>>;
|
||||
/** Remove a file or directory. Defaults: `recursive: false`, `force: false`, no abort signal. */
|
||||
remove(
|
||||
path: string,
|
||||
options?: { recursive?: boolean; force?: boolean; abortSignal?: AbortSignal },
|
||||
): Promise<Result<void, FileError>>;
|
||||
/** Create a temporary directory and return its absolute path. Defaults: `prefix: "tmp-"`, no abort signal. */
|
||||
createTempDir(prefix?: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>>;
|
||||
/** Create a temporary file and return its absolute path. Defaults: `prefix: ""`, `suffix: ""`, no abort signal. */
|
||||
createTempFile(options?: {
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
abortSignal?: AbortSignal;
|
||||
}): Promise<Result<string, FileError>>;
|
||||
|
||||
/** Release filesystem resources. Must be best-effort and must not throw or reject. */
|
||||
cleanup(): Promise<void>;
|
||||
}
|
||||
|
||||
/** Shell execution capability used by the harness. */
|
||||
export interface Shell {
|
||||
/** Execute a shell command in {@link FileSystem.cwd} unless `options.cwd` is provided. */
|
||||
exec(
|
||||
command: string,
|
||||
options?: ExecutionEnvExecOptions,
|
||||
): Promise<Result<{ stdout: string; stderr: string; exitCode: number }, ExecutionError>>;
|
||||
/** Release shell resources. Must be best-effort and must not throw or reject. */
|
||||
cleanup(): Promise<void>;
|
||||
}
|
||||
|
||||
/** Filesystem and process execution environment used by the harness. */
|
||||
export interface ExecutionEnv extends FileSystem, Shell {}
|
||||
|
||||
/** Base fields shared by append-only session tree entries. */
|
||||
export interface SessionTreeEntryBase {
|
||||
/** Entry discriminator used for JSONL persistence and typed narrowing. */
|
||||
type: string;
|
||||
/** Stable entry id unique within a session file. */
|
||||
id: string;
|
||||
/** Parent entry id, or null for a root entry. */
|
||||
parentId: string | null;
|
||||
/** ISO timestamp string used for persistence and sorting. */
|
||||
timestamp: string;
|
||||
/** This row consumes the raw side cursor instead of the visible leaf. */
|
||||
appendMode?: "side";
|
||||
}
|
||||
|
||||
/** Persisted transcript message entry. */
|
||||
export interface MessageEntry extends SessionTreeEntryBase {
|
||||
type: "message";
|
||||
message: AgentMessage;
|
||||
}
|
||||
|
||||
/** Persisted thinking-level selection marker. */
|
||||
export interface ThinkingLevelChangeEntry extends SessionTreeEntryBase {
|
||||
type: "thinking_level_change";
|
||||
thinkingLevel: string;
|
||||
}
|
||||
|
||||
/** Persisted model selection marker. */
|
||||
export interface ModelChangeEntry extends SessionTreeEntryBase {
|
||||
type: "model_change";
|
||||
provider: string;
|
||||
modelId: string;
|
||||
}
|
||||
|
||||
/** Persisted summary that replaces older transcript history in context. */
|
||||
export interface CompactionEntry<T = unknown> extends SessionTreeEntryBase {
|
||||
type: "compaction";
|
||||
summary: string;
|
||||
firstKeptEntryId: string;
|
||||
tokensBefore: number;
|
||||
details?: T;
|
||||
fromHook?: boolean;
|
||||
}
|
||||
|
||||
/** Persisted summary of an abandoned branch when navigating the session tree. */
|
||||
export interface BranchSummaryEntry<T = unknown> extends SessionTreeEntryBase {
|
||||
type: "branch_summary";
|
||||
fromId: string;
|
||||
summary: string;
|
||||
details?: T;
|
||||
fromHook?: boolean;
|
||||
}
|
||||
|
||||
/** Persisted harness/application marker that is not replayed into model context. */
|
||||
export interface CustomEntry<T = unknown> extends SessionTreeEntryBase {
|
||||
type: "custom";
|
||||
customType: string;
|
||||
data?: T;
|
||||
}
|
||||
|
||||
/** Persisted harness/application message that can be replayed into model context. */
|
||||
export interface CustomMessageEntry<T = unknown> extends SessionTreeEntryBase {
|
||||
type: "custom_message";
|
||||
customType: string;
|
||||
content: string | (TextContent | ImageContent)[];
|
||||
details?: T;
|
||||
display: boolean;
|
||||
}
|
||||
|
||||
/** Append-only label update for another session entry. */
|
||||
export interface LabelEntry extends SessionTreeEntryBase {
|
||||
type: "label";
|
||||
targetId: string;
|
||||
label: string | undefined;
|
||||
}
|
||||
|
||||
/** Persisted session metadata marker. */
|
||||
export interface SessionInfoEntry extends SessionTreeEntryBase {
|
||||
// The persisted discriminator predates the public "session name" wording.
|
||||
type: "session_info";
|
||||
name?: string;
|
||||
}
|
||||
|
||||
/** Append-only marker that changes the active visible leaf. */
|
||||
export interface LeafEntry extends SessionTreeEntryBase {
|
||||
type: "leaf";
|
||||
targetId: string | null;
|
||||
/** Raw parent for the next append when it differs from the visible leaf. */
|
||||
appendParentId?: string | null;
|
||||
}
|
||||
|
||||
/** All persisted session tree entry variants. */
|
||||
export type SessionTreeEntry =
|
||||
| MessageEntry
|
||||
| ThinkingLevelChangeEntry
|
||||
| ModelChangeEntry
|
||||
| CompactionEntry
|
||||
| BranchSummaryEntry
|
||||
| CustomEntry
|
||||
| CustomMessageEntry
|
||||
| LabelEntry
|
||||
| SessionInfoEntry
|
||||
| LeafEntry;
|
||||
|
||||
export interface SessionContext {
|
||||
messages: AgentMessage[];
|
||||
thinkingLevel: string;
|
||||
model: { provider: string; modelId: string } | null;
|
||||
}
|
||||
|
||||
export interface SessionMetadata {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface JsonlSessionMetadata extends SessionMetadata {
|
||||
cwd: string;
|
||||
path: string;
|
||||
parentSessionPath?: string;
|
||||
}
|
||||
|
||||
export interface SessionStorage<TMetadata extends SessionMetadata = SessionMetadata> {
|
||||
getMetadata(): Promise<TMetadata>;
|
||||
getLeafId(): Promise<string | null>;
|
||||
getAppendParentId?(): Promise<string | null>;
|
||||
/** Persist a leaf entry that records the active session-tree leaf. */
|
||||
setLeafId(leafId: string | null): Promise<void>;
|
||||
createEntryId(): Promise<string>;
|
||||
appendEntry(entry: SessionTreeEntry): Promise<void>;
|
||||
getEntry(id: string): Promise<SessionTreeEntry | undefined>;
|
||||
findEntries<TType extends SessionTreeEntry["type"]>(
|
||||
type: TType,
|
||||
): Promise<Array<Extract<SessionTreeEntry, { type: TType }>>>;
|
||||
getLabel(id: string): Promise<string | undefined>;
|
||||
getPathToRoot(leafId: string | null): Promise<SessionTreeEntry[]>;
|
||||
getEntries(): Promise<SessionTreeEntry[]>;
|
||||
}
|
||||
|
||||
export type { Session } from "./session/session.js";
|
||||
|
||||
export type AgentHarnessPhase = "idle" | "turn" | "compaction" | "branch_summary" | "retry";
|
||||
|
||||
export type PendingSessionWrite = SessionTreeEntry extends infer TEntry
|
||||
? TEntry extends SessionTreeEntry
|
||||
? Omit<TEntry, "id" | "parentId" | "timestamp">
|
||||
: never
|
||||
: never;
|
||||
|
||||
export interface QueueUpdateEvent {
|
||||
type: "queue_update";
|
||||
steer: AgentMessage[];
|
||||
followUp: AgentMessage[];
|
||||
nextTurn: AgentMessage[];
|
||||
}
|
||||
|
||||
export interface SavePointEvent {
|
||||
type: "save_point";
|
||||
hadPendingMutations: boolean;
|
||||
}
|
||||
|
||||
export interface AbortEvent {
|
||||
type: "abort";
|
||||
clearedSteer: AgentMessage[];
|
||||
clearedFollowUp: AgentMessage[];
|
||||
}
|
||||
|
||||
export interface SettledEvent {
|
||||
type: "settled";
|
||||
nextTurnCount: number;
|
||||
}
|
||||
|
||||
export interface BeforeAgentStartEvent<
|
||||
TSkill extends Skill = Skill,
|
||||
TPromptTemplate extends PromptTemplate = PromptTemplate,
|
||||
> {
|
||||
type: "before_agent_start";
|
||||
prompt: string;
|
||||
images?: ImageContent[];
|
||||
systemPrompt: string;
|
||||
resources: AgentHarnessResources<TSkill, TPromptTemplate>;
|
||||
}
|
||||
|
||||
export interface ContextEvent {
|
||||
type: "context";
|
||||
messages: AgentMessage[];
|
||||
}
|
||||
|
||||
export interface BeforeProviderRequestEvent {
|
||||
type: "before_provider_request";
|
||||
model: Model;
|
||||
sessionId: string;
|
||||
streamOptions: AgentHarnessStreamOptions;
|
||||
}
|
||||
|
||||
export interface BeforeProviderPayloadEvent {
|
||||
type: "before_provider_payload";
|
||||
model: Model;
|
||||
payload: unknown;
|
||||
}
|
||||
|
||||
export interface AfterProviderResponseEvent {
|
||||
type: "after_provider_response";
|
||||
status: number;
|
||||
headers: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ToolCallEvent {
|
||||
type: "tool_call";
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
input: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ToolResultEvent {
|
||||
type: "tool_result";
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
input: Record<string, unknown>;
|
||||
content: Array<TextContent | ImageContent>;
|
||||
details: unknown;
|
||||
isError: boolean;
|
||||
}
|
||||
|
||||
export interface SessionBeforeCompactEvent {
|
||||
type: "session_before_compact";
|
||||
preparation: CompactionPreparation;
|
||||
branchEntries: SessionTreeEntry[];
|
||||
customInstructions?: string;
|
||||
signal: AbortSignal;
|
||||
}
|
||||
|
||||
export interface SessionCompactEvent {
|
||||
type: "session_compact";
|
||||
compactionEntry: CompactionEntry;
|
||||
fromHook: boolean;
|
||||
}
|
||||
|
||||
export interface SessionBeforeTreeEvent {
|
||||
type: "session_before_tree";
|
||||
preparation: TreePreparation;
|
||||
signal: AbortSignal;
|
||||
}
|
||||
|
||||
export interface SessionTreeEvent {
|
||||
type: "session_tree";
|
||||
newLeafId: string | null;
|
||||
oldLeafId: string | null;
|
||||
summaryEntry?: BranchSummaryEntry;
|
||||
fromHook?: boolean;
|
||||
}
|
||||
|
||||
export interface ModelSelectEvent {
|
||||
type: "model_select";
|
||||
model: Model;
|
||||
previousModel: Model | undefined;
|
||||
source: "set" | "restore";
|
||||
}
|
||||
|
||||
export interface ThinkingLevelSelectEvent {
|
||||
type: "thinking_level_select";
|
||||
level: ThinkingLevel;
|
||||
previousLevel: ThinkingLevel;
|
||||
}
|
||||
|
||||
export interface ResourcesUpdateEvent<
|
||||
TSkill extends Skill = Skill,
|
||||
TPromptTemplate extends PromptTemplate = PromptTemplate,
|
||||
> {
|
||||
type: "resources_update";
|
||||
resources: AgentHarnessResources<TSkill, TPromptTemplate>;
|
||||
previousResources: AgentHarnessResources<TSkill, TPromptTemplate>;
|
||||
}
|
||||
|
||||
export type AgentHarnessOwnEvent<
|
||||
TSkill extends Skill = Skill,
|
||||
TPromptTemplate extends PromptTemplate = PromptTemplate,
|
||||
> =
|
||||
| QueueUpdateEvent
|
||||
| SavePointEvent
|
||||
| AbortEvent
|
||||
| SettledEvent
|
||||
| BeforeAgentStartEvent<TSkill, TPromptTemplate>
|
||||
| ContextEvent
|
||||
| BeforeProviderRequestEvent
|
||||
| BeforeProviderPayloadEvent
|
||||
| AfterProviderResponseEvent
|
||||
| ToolCallEvent
|
||||
| ToolResultEvent
|
||||
| SessionBeforeCompactEvent
|
||||
| SessionCompactEvent
|
||||
| SessionBeforeTreeEvent
|
||||
| SessionTreeEvent
|
||||
| ModelSelectEvent
|
||||
| ThinkingLevelSelectEvent
|
||||
| ResourcesUpdateEvent<TSkill, TPromptTemplate>;
|
||||
|
||||
export type AgentHarnessEvent<
|
||||
TSkill extends Skill = Skill,
|
||||
TPromptTemplate extends PromptTemplate = PromptTemplate,
|
||||
> = AgentEvent | AgentHarnessOwnEvent<TSkill, TPromptTemplate>;
|
||||
|
||||
/** Hook result for mutating the initial prompt run before the agent starts. */
|
||||
export interface BeforeAgentStartResult {
|
||||
/** Replacement messages for the prompt run. */
|
||||
messages?: AgentMessage[];
|
||||
/** Replacement system prompt for the prompt run. */
|
||||
systemPrompt?: string;
|
||||
}
|
||||
|
||||
/** Hook result for replacing the full context message list before provider conversion. */
|
||||
export interface ContextResult {
|
||||
messages: AgentMessage[];
|
||||
}
|
||||
|
||||
/** Hook result for patching provider request options before payload construction. */
|
||||
export interface BeforeProviderRequestResult {
|
||||
streamOptions?: AgentHarnessStreamOptionsPatch;
|
||||
}
|
||||
|
||||
/** Hook result for replacing the provider payload after construction. */
|
||||
export interface BeforeProviderPayloadResult {
|
||||
payload: unknown;
|
||||
}
|
||||
|
||||
/** Hook result for blocking a tool call before execution. */
|
||||
export interface ToolCallResult {
|
||||
block?: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/** Hook patch for a completed tool result before it is persisted/emitted. */
|
||||
export interface ToolResultPatch {
|
||||
content?: Array<TextContent | ImageContent>;
|
||||
details?: unknown;
|
||||
isError?: boolean;
|
||||
terminate?: boolean;
|
||||
}
|
||||
|
||||
/** Hook result for cancelling or replacing a planned compaction. */
|
||||
export interface SessionBeforeCompactResult {
|
||||
cancel?: boolean;
|
||||
compaction?: CompactResult;
|
||||
}
|
||||
|
||||
/** Hook result for cancelling, labeling, or supplying branch-summary behavior before tree navigation. */
|
||||
export interface SessionBeforeTreeResult {
|
||||
cancel?: boolean;
|
||||
summary?: { summary: string; details?: unknown };
|
||||
customInstructions?: string;
|
||||
replaceInstructions?: boolean;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/** Typed return values expected from AgentHarness hook handlers by event type. */
|
||||
export type AgentHarnessEventResultMap = {
|
||||
before_agent_start: BeforeAgentStartResult | undefined;
|
||||
context: ContextResult | undefined;
|
||||
before_provider_request: BeforeProviderRequestResult | undefined;
|
||||
before_provider_payload: BeforeProviderPayloadResult | undefined;
|
||||
after_provider_response: undefined;
|
||||
tool_call: ToolCallResult | undefined;
|
||||
tool_result: ToolResultPatch | undefined;
|
||||
session_before_compact: SessionBeforeCompactResult | undefined;
|
||||
session_compact: undefined;
|
||||
session_before_tree: SessionBeforeTreeResult | undefined;
|
||||
session_tree: undefined;
|
||||
model_select: undefined;
|
||||
thinking_level_select: undefined;
|
||||
resources_update: undefined;
|
||||
queue_update: undefined;
|
||||
save_point: undefined;
|
||||
abort: undefined;
|
||||
settled: undefined;
|
||||
};
|
||||
|
||||
/** Queued messages removed by an abort operation. */
|
||||
export interface AbortResult {
|
||||
clearedSteer: AgentMessage[];
|
||||
clearedFollowUp: AgentMessage[];
|
||||
}
|
||||
|
||||
/** Compaction data supplied by hooks or returned from compaction preparation. */
|
||||
export interface CompactResult {
|
||||
summary: string;
|
||||
firstKeptEntryId: string;
|
||||
tokensBefore: number;
|
||||
details?: unknown;
|
||||
}
|
||||
|
||||
/** Result of moving the active session-tree leaf. */
|
||||
export interface NavigateTreeResult {
|
||||
cancelled: boolean;
|
||||
editorText?: string;
|
||||
summaryEntry?: BranchSummaryEntry;
|
||||
}
|
||||
|
||||
/** Settings that control automatic context compaction. */
|
||||
export interface CompactionSettings {
|
||||
enabled: boolean;
|
||||
reserveTokens: number;
|
||||
keepRecentTokens: number;
|
||||
}
|
||||
|
||||
/** Prepared compaction inputs exposed to hooks before a summary is generated. */
|
||||
export interface CompactionPreparation {
|
||||
firstKeptEntryId: string;
|
||||
messagesToSummarize: AgentMessage[];
|
||||
turnPrefixMessages: AgentMessage[];
|
||||
isSplitTurn: boolean;
|
||||
tokensBefore: number;
|
||||
previousSummary?: string;
|
||||
fileOps: FileOperations;
|
||||
settings: CompactionSettings;
|
||||
}
|
||||
|
||||
/** File operations accumulated from summarized transcript ranges. */
|
||||
export interface FileOperations {
|
||||
read: Set<string>;
|
||||
written: Set<string>;
|
||||
edited: Set<string>;
|
||||
}
|
||||
|
||||
/** Prepared branch navigation inputs exposed to hooks before a summary is generated. */
|
||||
export interface TreePreparation {
|
||||
targetId: string;
|
||||
oldLeafId: string | null;
|
||||
commonAncestorId: string | null;
|
||||
entriesToSummarize: SessionTreeEntry[];
|
||||
userWantsSummary: boolean;
|
||||
customInstructions?: string;
|
||||
replaceInstructions?: boolean;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/** Options for generating a branch summary. */
|
||||
export interface GenerateBranchSummaryOptions {
|
||||
model: Model;
|
||||
apiKey: string;
|
||||
headers?: Record<string, string>;
|
||||
signal: AbortSignal;
|
||||
runtime?: AgentCoreCompletionRuntimeDeps;
|
||||
streamFn?: StreamFn;
|
||||
customInstructions?: string;
|
||||
replaceInstructions?: boolean;
|
||||
reserveTokens?: number;
|
||||
}
|
||||
|
||||
/** Generated branch summary text and file-operation metadata. */
|
||||
export interface BranchSummaryResult {
|
||||
summary: string;
|
||||
readFiles: string[];
|
||||
modifiedFiles: string[];
|
||||
}
|
||||
|
||||
/** Construction options for AgentHarness. */
|
||||
export interface AgentHarnessOptions<
|
||||
TSkill extends Skill = Skill,
|
||||
TPromptTemplate extends PromptTemplate = PromptTemplate,
|
||||
TTool extends AgentTool = AgentTool,
|
||||
> {
|
||||
env: ExecutionEnv;
|
||||
session: Session;
|
||||
tools?: TTool[];
|
||||
/**
|
||||
* Concrete resources available to explicit invocation methods and system-prompt callbacks.
|
||||
* Applications own loading/reloading resources and should call `setResources()` with new values.
|
||||
*/
|
||||
resources?: AgentHarnessResources<TSkill, TPromptTemplate>;
|
||||
systemPrompt?:
|
||||
| string
|
||||
| ((context: {
|
||||
env: ExecutionEnv;
|
||||
session: Session;
|
||||
model: Model;
|
||||
thinkingLevel: ThinkingLevel;
|
||||
activeTools: TTool[];
|
||||
resources: AgentHarnessResources<TSkill, TPromptTemplate>;
|
||||
}) => string | Promise<string>);
|
||||
getApiKeyAndHeaders?: (
|
||||
model: Model,
|
||||
) => Promise<{ apiKey: string; headers?: Record<string, string> } | undefined>;
|
||||
runtime?: AgentCoreRuntimeDeps;
|
||||
/** Curated stream/provider request options. Snapshotted at turn start. */
|
||||
streamOptions?: AgentHarnessStreamOptions;
|
||||
model: Model;
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
activeToolNames?: string[];
|
||||
steeringMode?: QueueMode;
|
||||
followUpMode?: QueueMode;
|
||||
}
|
||||
|
||||
export type { CoreAgentHarness as AgentHarness } from "./agent-harness.js";
|
||||
63
packages/agent-core/src/harness/utils/truncate.test.ts
Normal file
63
packages/agent-core/src/harness/utils/truncate.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
// Agent Core tests cover truncate behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { truncateHead, truncateLine, truncateTail } from "./truncate.js";
|
||||
|
||||
describe("truncate utilities", () => {
|
||||
it("does not count a trailing newline as an extra display line", () => {
|
||||
expect(truncateHead("alpha\nbeta\n").totalLines).toBe(2);
|
||||
expect(truncateTail("alpha\nbeta\n").totalLines).toBe(2);
|
||||
});
|
||||
|
||||
it("classifies trailing-newline truncation by the byte limit", () => {
|
||||
expect(truncateHead("x\n", { maxBytes: 1 }).truncatedBy).toBe("bytes");
|
||||
expect(truncateTail("x\n", { maxBytes: 1 }).truncatedBy).toBe("bytes");
|
||||
});
|
||||
|
||||
it("keeps complete UTF-8 characters when taking a partial tail line", () => {
|
||||
const result = truncateTail("alpha🙂", { maxBytes: 4 });
|
||||
|
||||
expect(result.content).toBe("🙂");
|
||||
expect(result.lastLinePartial).toBe(true);
|
||||
expect(result.outputBytes).toBe(4);
|
||||
});
|
||||
|
||||
describe("truncateLine", () => {
|
||||
it("returns text unchanged when within limit", () => {
|
||||
expect(truncateLine("short", 10)).toEqual({ text: "short", wasTruncated: false });
|
||||
});
|
||||
|
||||
it("truncates and appends suffix when over limit", () => {
|
||||
const result = truncateLine("this is a very long line", 10);
|
||||
expect(result.wasTruncated).toBe(true);
|
||||
expect(result.text).toBe("this is a ... [truncated]");
|
||||
});
|
||||
|
||||
it("uses GREP_MAX_LINE_LENGTH as the default limit", () => {
|
||||
const result = truncateLine("x");
|
||||
expect(result.wasTruncated).toBe(false);
|
||||
expect(result.text).toBe("x");
|
||||
});
|
||||
|
||||
it("does not split a surrogate pair at the cut point", () => {
|
||||
// Emoji at boundary: "AB" + 🤖(surrogate pair) + "CD" — cut at 3 splits the emoji.
|
||||
expect(truncateLine("AB🤖CD", 3).text).toBe("AB... [truncated]");
|
||||
// Three emoji, cut in the middle of the second emoji.
|
||||
expect(truncateLine("🤖🤖🤖", 5).text).toBe("🤖🤖... [truncated]");
|
||||
// CJK Extension B (surrogate pair) at boundary stays intact.
|
||||
expect(truncateLine("AB𠮷CD", 5).text).toBe("AB𠮷C... [truncated]");
|
||||
});
|
||||
|
||||
it("never produces unpaired surrogates in output", () => {
|
||||
const results = [
|
||||
truncateLine("AB🤖CD", 3).text,
|
||||
truncateLine("🤖🤖🤖", 5).text,
|
||||
truncateLine("AB𠮷CD", 5).text,
|
||||
];
|
||||
for (const text of results) {
|
||||
expect(text).not.toMatch(
|
||||
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
386
packages/agent-core/src/harness/utils/truncate.ts
Normal file
386
packages/agent-core/src/harness/utils/truncate.ts
Normal file
@@ -0,0 +1,386 @@
|
||||
// Agent Core module implements truncate behavior.
|
||||
export const DEFAULT_MAX_LINES = 2000;
|
||||
export const DEFAULT_MAX_BYTES = 50 * 1024; // 50KB
|
||||
export const GREP_MAX_LINE_LENGTH = 500; // Max chars per grep match line
|
||||
|
||||
/** Result metadata for content truncated by line count, byte count, or both. */
|
||||
export interface TruncationResult {
|
||||
/** The truncated content */
|
||||
content: string;
|
||||
/** Whether truncation occurred */
|
||||
truncated: boolean;
|
||||
/** Which limit was hit: "lines", "bytes", or null if not truncated */
|
||||
truncatedBy: "lines" | "bytes" | null;
|
||||
/** Total number of lines in the original content */
|
||||
totalLines: number;
|
||||
/** Total number of bytes in the original content */
|
||||
totalBytes: number;
|
||||
/** Number of complete lines in the truncated output */
|
||||
outputLines: number;
|
||||
/** Number of bytes in the truncated output */
|
||||
outputBytes: number;
|
||||
/** Whether the last line was partially truncated (only for tail truncation edge case) */
|
||||
lastLinePartial: boolean;
|
||||
/** Whether the first line exceeded the byte limit (for head truncation) */
|
||||
firstLineExceedsLimit: boolean;
|
||||
/** The max lines limit that was applied */
|
||||
maxLines: number;
|
||||
/** The max bytes limit that was applied */
|
||||
maxBytes: number;
|
||||
}
|
||||
|
||||
/** Byte and line ceilings used by the truncation helpers. */
|
||||
export interface TruncationOptions {
|
||||
/** Maximum number of lines (default: 2000) */
|
||||
maxLines?: number;
|
||||
/** Maximum number of bytes (default: 50KB) */
|
||||
maxBytes?: number;
|
||||
}
|
||||
|
||||
interface ResolvedTruncationInput {
|
||||
lines: string[];
|
||||
totalLines: number;
|
||||
totalBytes: number;
|
||||
maxLines: number;
|
||||
maxBytes: number;
|
||||
}
|
||||
|
||||
interface RuntimeBuffer {
|
||||
byteLength(content: string, encoding: "utf8"): number;
|
||||
}
|
||||
|
||||
const runtimeBuffer = (globalThis as { Buffer?: RuntimeBuffer }).Buffer;
|
||||
|
||||
function splitLinesForCounting(content: string): string[] {
|
||||
if (content.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const lines = content.split("\n");
|
||||
if (content.endsWith("\n")) {
|
||||
lines.pop();
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function findFirstNonAscii(content: string): number {
|
||||
for (let index = 0; index < content.length; index++) {
|
||||
if (content.charCodeAt(index) > 0x7f) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function utf8ByteLength(content: string): number {
|
||||
if (runtimeBuffer) {
|
||||
return runtimeBuffer.byteLength(content, "utf8");
|
||||
}
|
||||
|
||||
const firstNonAscii = findFirstNonAscii(content);
|
||||
if (firstNonAscii === -1) {
|
||||
return content.length;
|
||||
}
|
||||
|
||||
let bytes = firstNonAscii;
|
||||
for (let i = firstNonAscii; i < content.length; i++) {
|
||||
const code = content.charCodeAt(i);
|
||||
if (code <= 0x7f) {
|
||||
bytes += 1;
|
||||
} else if (code <= 0x7ff) {
|
||||
bytes += 2;
|
||||
} else if (code >= 0xd800 && code <= 0xdbff && i + 1 < content.length) {
|
||||
const next = content.charCodeAt(i + 1);
|
||||
if (next >= 0xdc00 && next <= 0xdfff) {
|
||||
bytes += 4;
|
||||
i++;
|
||||
} else {
|
||||
bytes += 3;
|
||||
}
|
||||
} else {
|
||||
bytes += 3;
|
||||
}
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function replaceUnpairedSurrogates(content: string): string {
|
||||
let output = "";
|
||||
for (let i = 0; i < content.length; i++) {
|
||||
const code = content.charCodeAt(i);
|
||||
if (code >= 0xd800 && code <= 0xdbff) {
|
||||
if (i + 1 < content.length) {
|
||||
const next = content.charCodeAt(i + 1);
|
||||
if (next >= 0xdc00 && next <= 0xdfff) {
|
||||
output += content[i] + content[i + 1];
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
output += "<22>";
|
||||
} else if (code >= 0xdc00 && code <= 0xdfff) {
|
||||
output += "<22>";
|
||||
} else {
|
||||
output += content[i];
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format byte counts for compact tool-output diagnostics.
|
||||
*/
|
||||
export function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) {
|
||||
return `${bytes}B`;
|
||||
} else if (bytes < 1024 * 1024) {
|
||||
return `${(bytes / 1024).toFixed(1)}KB`;
|
||||
}
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
||||
}
|
||||
|
||||
function resolveTruncationInput(
|
||||
content: string,
|
||||
options: TruncationOptions,
|
||||
): ResolvedTruncationInput {
|
||||
const maxLines = options.maxLines ?? DEFAULT_MAX_LINES;
|
||||
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
|
||||
const totalBytes = utf8ByteLength(content);
|
||||
const lines = splitLinesForCounting(content);
|
||||
return {
|
||||
lines,
|
||||
totalLines: lines.length,
|
||||
totalBytes,
|
||||
maxLines,
|
||||
maxBytes,
|
||||
};
|
||||
}
|
||||
|
||||
function buildTruncationResult(
|
||||
input: ResolvedTruncationInput,
|
||||
params: {
|
||||
content: string;
|
||||
truncated: boolean;
|
||||
truncatedBy: TruncationResult["truncatedBy"];
|
||||
outputLines: number;
|
||||
outputBytes?: number;
|
||||
lastLinePartial?: boolean;
|
||||
firstLineExceedsLimit?: boolean;
|
||||
},
|
||||
): TruncationResult {
|
||||
return {
|
||||
content: params.content,
|
||||
truncated: params.truncated,
|
||||
truncatedBy: params.truncatedBy,
|
||||
totalLines: input.totalLines,
|
||||
totalBytes: input.totalBytes,
|
||||
outputLines: params.outputLines,
|
||||
outputBytes: params.outputBytes ?? utf8ByteLength(params.content),
|
||||
lastLinePartial: params.lastLinePartial ?? false,
|
||||
firstLineExceedsLimit: params.firstLineExceedsLimit ?? false,
|
||||
maxLines: input.maxLines,
|
||||
maxBytes: input.maxBytes,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the beginning of content while respecting independent line and byte ceilings.
|
||||
*
|
||||
* Head truncation preserves complete lines; a first line that exceeds the byte
|
||||
* ceiling produces empty output and sets firstLineExceedsLimit.
|
||||
*/
|
||||
export function truncateHead(content: string, options: TruncationOptions = {}): TruncationResult {
|
||||
const input = resolveTruncationInput(content, options);
|
||||
|
||||
if (input.totalLines <= input.maxLines && input.totalBytes <= input.maxBytes) {
|
||||
return buildTruncationResult(input, {
|
||||
content,
|
||||
truncated: false,
|
||||
truncatedBy: null,
|
||||
outputLines: input.totalLines,
|
||||
outputBytes: input.totalBytes,
|
||||
});
|
||||
}
|
||||
|
||||
const firstLineBytes = utf8ByteLength(input.lines[0]);
|
||||
if (firstLineBytes > input.maxBytes) {
|
||||
return buildTruncationResult(input, {
|
||||
content: "",
|
||||
truncated: true,
|
||||
truncatedBy: "bytes",
|
||||
outputLines: 0,
|
||||
outputBytes: 0,
|
||||
firstLineExceedsLimit: true,
|
||||
});
|
||||
}
|
||||
|
||||
const outputLinesArr: string[] = [];
|
||||
let outputBytesCount = 0;
|
||||
let truncatedBy: "lines" | "bytes" = input.totalLines > input.maxLines ? "lines" : "bytes";
|
||||
|
||||
for (let i = 0; i < input.lines.length && i < input.maxLines; i++) {
|
||||
const line = input.lines[i];
|
||||
const lineBytes = utf8ByteLength(line) + (i > 0 ? 1 : 0); // +1 for newline
|
||||
|
||||
if (outputBytesCount + lineBytes > input.maxBytes) {
|
||||
truncatedBy = "bytes";
|
||||
break;
|
||||
}
|
||||
|
||||
outputLinesArr.push(line);
|
||||
outputBytesCount += lineBytes;
|
||||
}
|
||||
|
||||
if (
|
||||
input.totalLines > input.maxLines &&
|
||||
outputLinesArr.length >= input.maxLines &&
|
||||
outputBytesCount <= input.maxBytes
|
||||
) {
|
||||
truncatedBy = "lines";
|
||||
}
|
||||
|
||||
const outputContent = outputLinesArr.join("\n");
|
||||
|
||||
return buildTruncationResult(input, {
|
||||
content: outputContent,
|
||||
truncated: true,
|
||||
truncatedBy,
|
||||
outputLines: outputLinesArr.length,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the end of content while respecting independent line and byte ceilings.
|
||||
*
|
||||
* Tail truncation preserves recent output for command errors and may keep a
|
||||
* partial first line when one final line alone exceeds the byte ceiling.
|
||||
*/
|
||||
export function truncateTail(content: string, options: TruncationOptions = {}): TruncationResult {
|
||||
const input = resolveTruncationInput(content, options);
|
||||
|
||||
if (input.totalLines <= input.maxLines && input.totalBytes <= input.maxBytes) {
|
||||
return buildTruncationResult(input, {
|
||||
content,
|
||||
truncated: false,
|
||||
truncatedBy: null,
|
||||
outputLines: input.totalLines,
|
||||
outputBytes: input.totalBytes,
|
||||
});
|
||||
}
|
||||
|
||||
const outputLinesArr: string[] = [];
|
||||
let outputBytesCount = 0;
|
||||
let truncatedBy: "lines" | "bytes" = input.totalLines > input.maxLines ? "lines" : "bytes";
|
||||
let lastLinePartial = false;
|
||||
|
||||
for (let i = input.lines.length - 1; i >= 0 && outputLinesArr.length < input.maxLines; i--) {
|
||||
const line = input.lines[i];
|
||||
const lineBytes = utf8ByteLength(line) + (outputLinesArr.length > 0 ? 1 : 0); // +1 for newline
|
||||
|
||||
if (outputBytesCount + lineBytes > input.maxBytes) {
|
||||
truncatedBy = "bytes";
|
||||
// Edge case: if we haven't added ANY lines yet and this line exceeds maxBytes,
|
||||
// take the end of the line (partial)
|
||||
if (outputLinesArr.length === 0) {
|
||||
const truncatedLine = truncateStringToBytesFromEnd(line, input.maxBytes);
|
||||
outputLinesArr.unshift(truncatedLine);
|
||||
outputBytesCount = utf8ByteLength(truncatedLine);
|
||||
lastLinePartial = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
outputLinesArr.unshift(line);
|
||||
outputBytesCount += lineBytes;
|
||||
}
|
||||
|
||||
if (
|
||||
input.totalLines > input.maxLines &&
|
||||
outputLinesArr.length >= input.maxLines &&
|
||||
outputBytesCount <= input.maxBytes
|
||||
) {
|
||||
truncatedBy = "lines";
|
||||
}
|
||||
|
||||
const outputContent = outputLinesArr.join("\n");
|
||||
|
||||
return buildTruncationResult(input, {
|
||||
content: outputContent,
|
||||
truncated: true,
|
||||
truncatedBy,
|
||||
outputLines: outputLinesArr.length,
|
||||
lastLinePartial,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate a string to fit within a byte limit (from the end).
|
||||
* Handles multi-byte UTF-8 characters correctly.
|
||||
*/
|
||||
function truncateStringToBytesFromEnd(str: string, maxBytes: number): string {
|
||||
if (maxBytes <= 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
let outputBytes = 0;
|
||||
let start = str.length;
|
||||
let needsReplacement = false;
|
||||
for (let i = str.length; i > 0; ) {
|
||||
let characterStart = i - 1;
|
||||
const code = str.charCodeAt(characterStart);
|
||||
let characterBytes: number;
|
||||
let unpairedSurrogate = false;
|
||||
if (code >= 0xdc00 && code <= 0xdfff && characterStart > 0) {
|
||||
const previous = str.charCodeAt(characterStart - 1);
|
||||
if (previous >= 0xd800 && previous <= 0xdbff) {
|
||||
characterStart--;
|
||||
characterBytes = 4;
|
||||
} else {
|
||||
characterBytes = 3;
|
||||
unpairedSurrogate = true;
|
||||
}
|
||||
} else if (code >= 0xd800 && code <= 0xdfff) {
|
||||
characterBytes = 3;
|
||||
unpairedSurrogate = true;
|
||||
} else {
|
||||
characterBytes = code <= 0x7f ? 1 : code <= 0x7ff ? 2 : 3;
|
||||
}
|
||||
if (outputBytes + characterBytes > maxBytes) {
|
||||
break;
|
||||
}
|
||||
outputBytes += characterBytes;
|
||||
start = characterStart;
|
||||
needsReplacement ||= unpairedSurrogate;
|
||||
i = characterStart;
|
||||
}
|
||||
|
||||
const output = str.slice(start);
|
||||
return needsReplacement ? replaceUnpairedSurrogates(output) : output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trim a single display line and mark it with the grep-style truncation suffix.
|
||||
*
|
||||
* The cut point is backed off by one code unit when it would otherwise split a
|
||||
* surrogate pair, so emoji / CJK Extension B characters crossing the boundary
|
||||
* stay intact instead of rendering as replacement characters.
|
||||
*/
|
||||
export function truncateLine(
|
||||
line: string,
|
||||
maxChars: number = GREP_MAX_LINE_LENGTH,
|
||||
): { text: string; wasTruncated: boolean } {
|
||||
if (line.length <= maxChars) {
|
||||
return { text: line, wasTruncated: false };
|
||||
}
|
||||
let cut = maxChars;
|
||||
// Avoid splitting a surrogate pair at the truncation boundary.
|
||||
if (cut < line.length) {
|
||||
const lastCode = line.charCodeAt(cut - 1);
|
||||
if (lastCode >= 0xd800 && lastCode <= 0xdbff) {
|
||||
const nextCode = line.charCodeAt(cut);
|
||||
if (nextCode >= 0xdc00 && nextCode <= 0xdfff) {
|
||||
cut -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { text: `${line.slice(0, cut)}... [truncated]`, wasTruncated: true };
|
||||
}
|
||||
49
packages/agent-core/src/index.ts
Normal file
49
packages/agent-core/src/index.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
// Public agent-core package surface: agent loop, harness, session storage,
|
||||
// compaction, execution envs, and utility helpers.
|
||||
export * from "./agent.js";
|
||||
export * from "./agent-loop.js";
|
||||
export * from "./node.js";
|
||||
export * from "./runtime-deps.js";
|
||||
export * from "./types.js";
|
||||
export * from "./validation.js";
|
||||
export * from "./harness/agent-harness.js";
|
||||
export * from "./harness/env/kill-tree.js";
|
||||
export * from "./harness/messages.js";
|
||||
export * from "./harness/prompt-template-arguments.js";
|
||||
export * from "./harness/skills.js";
|
||||
export * from "./harness/types.js";
|
||||
export * from "./harness/session/jsonl-storage.js";
|
||||
export * from "./harness/session/memory-storage.js";
|
||||
export * from "./harness/session/session.js";
|
||||
export { uuidv7 } from "./harness/session/uuid.js";
|
||||
export {
|
||||
type BranchPreparation,
|
||||
type BranchPathEntry,
|
||||
type BranchSummaryDetails,
|
||||
type CollectBranchPathEntriesResult,
|
||||
type CollectEntriesResult,
|
||||
collectEntriesForBranchSummary,
|
||||
collectEntriesForBranchSummaryFromBranches,
|
||||
generateBranchSummary,
|
||||
prepareBranchEntries,
|
||||
} from "./harness/compaction/branch-summarization.js";
|
||||
export {
|
||||
calculateContextTokens,
|
||||
compact,
|
||||
DEFAULT_COMPACTION_SETTINGS,
|
||||
estimateContextTokens,
|
||||
estimateTokens,
|
||||
findCutPoint,
|
||||
findTurnStartIndex,
|
||||
generateSummary,
|
||||
getLastAssistantUsage,
|
||||
prepareCompaction,
|
||||
serializeConversation,
|
||||
shouldCompact,
|
||||
type CompactionDetails,
|
||||
type CompactionPreparation,
|
||||
type CompactionResult,
|
||||
type CompactionSettings,
|
||||
type ContextUsageEstimate,
|
||||
} from "./harness/compaction/compaction.js";
|
||||
export * from "./harness/utils/truncate.js";
|
||||
2
packages/agent-core/src/llm.ts
Normal file
2
packages/agent-core/src/llm.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// AI contract compatibility barrel for agent-core consumers.
|
||||
export * from "@openclaw/ai";
|
||||
3
packages/agent-core/src/node.ts
Normal file
3
packages/agent-core/src/node.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// Node-specific agent-core entrypoint with the default Node execution env.
|
||||
export { NodeExecutionEnv } from "./harness/env/nodejs.js";
|
||||
export * from "./index.js";
|
||||
53
packages/agent-core/src/reasoning.test.ts
Normal file
53
packages/agent-core/src/reasoning.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Model } from "../../llm-core/src/index.js";
|
||||
import { resolveAgentReasoningOption } from "./reasoning.js";
|
||||
|
||||
function makeModel(
|
||||
thinkingLevelMap?: Model["thinkingLevelMap"],
|
||||
overrides: Partial<Model> = {},
|
||||
): Model {
|
||||
return {
|
||||
id: "test-model",
|
||||
name: "Test Model",
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic",
|
||||
baseUrl: "https://example.test",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 1000,
|
||||
maxTokens: 100,
|
||||
thinkingLevelMap,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveAgentReasoningOption", () => {
|
||||
it("uses a model's enabled fallback for explicit off", () => {
|
||||
expect(resolveAgentReasoningOption(makeModel({ off: "low" }), "off")).toBe("low");
|
||||
});
|
||||
|
||||
it.each([undefined, null, "none"])("disables reasoning when off maps to %s", (offFallback) => {
|
||||
expect(resolveAgentReasoningOption(makeModel({ off: offFallback }), "off")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves enabled thinking levels", () => {
|
||||
expect(resolveAgentReasoningOption(makeModel({ off: "low" }), "high")).toBe("high");
|
||||
});
|
||||
|
||||
it.each(["anthropic-messages", "bedrock-converse-stream"] as const)(
|
||||
"maps explicit off to low for canonical Fable aliases on %s",
|
||||
(api) => {
|
||||
expect(
|
||||
resolveAgentReasoningOption(
|
||||
makeModel(undefined, {
|
||||
id: "production-deployment",
|
||||
api,
|
||||
params: { canonicalModelId: "claude-fable-5" },
|
||||
}),
|
||||
"off",
|
||||
),
|
||||
).toBe("low");
|
||||
},
|
||||
);
|
||||
});
|
||||
37
packages/agent-core/src/reasoning.ts
Normal file
37
packages/agent-core/src/reasoning.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
resolveClaudeFable5ModelIdentity,
|
||||
type Model,
|
||||
type SimpleStreamOptions,
|
||||
} from "../../llm-core/src/index.js";
|
||||
import type { ThinkingLevel } from "./types.js";
|
||||
|
||||
type EnabledThinkingLevel = NonNullable<SimpleStreamOptions["reasoning"]>;
|
||||
|
||||
const ENABLED_THINKING_LEVELS = new Set<EnabledThinkingLevel>([
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh",
|
||||
"max",
|
||||
]);
|
||||
|
||||
function isEnabledThinkingLevel(value: unknown): value is EnabledThinkingLevel {
|
||||
return ENABLED_THINKING_LEVELS.has(value as EnabledThinkingLevel);
|
||||
}
|
||||
|
||||
export function resolveAgentReasoningOption(
|
||||
model: Model,
|
||||
thinkingLevel: ThinkingLevel,
|
||||
): SimpleStreamOptions["reasoning"] {
|
||||
if (thinkingLevel !== "off") {
|
||||
return thinkingLevel;
|
||||
}
|
||||
const offFallback =
|
||||
model.thinkingLevelMap?.off ??
|
||||
((model.api === "anthropic-messages" || model.api === "bedrock-converse-stream") &&
|
||||
resolveClaudeFable5ModelIdentity(model)
|
||||
? "low"
|
||||
: undefined);
|
||||
return isEnabledThinkingLevel(offFallback) ? offFallback : undefined;
|
||||
}
|
||||
45
packages/agent-core/src/runtime-deps.ts
Normal file
45
packages/agent-core/src/runtime-deps.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
// Agent Core module implements runtime deps behavior.
|
||||
import type { CompleteSimpleFn, StreamFn } from "../../llm-core/src/index.js";
|
||||
|
||||
/** Runtime functions injected by host packages so agent-core stays provider-agnostic. */
|
||||
export interface AgentCoreRuntimeDeps {
|
||||
/** Streaming completion implementation used for normal agent turns. */
|
||||
streamSimple: StreamFn;
|
||||
/** Non-streaming completion implementation used by summarization helpers. */
|
||||
completeSimple: CompleteSimpleFn;
|
||||
}
|
||||
|
||||
/** Runtime dependency subset required by streaming agent loops. */
|
||||
export type AgentCoreStreamRuntimeDeps = Pick<AgentCoreRuntimeDeps, "streamSimple">;
|
||||
/** Runtime dependency subset required by summarization helpers. */
|
||||
export type AgentCoreCompletionRuntimeDeps = Pick<AgentCoreRuntimeDeps, "completeSimple">;
|
||||
|
||||
function missingRuntimeDep(name: keyof AgentCoreRuntimeDeps): Error {
|
||||
return new Error(
|
||||
`@openclaw/agent-core runtime dependency "${name}" is not configured. Pass an AgentCoreRuntimeDeps instance or a streamFn explicitly.`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Resolve the stream function, preferring an explicit override over injected runtime deps. */
|
||||
export function resolveAgentCoreStreamFn(
|
||||
runtime: AgentCoreStreamRuntimeDeps | undefined,
|
||||
streamFn?: StreamFn,
|
||||
): StreamFn {
|
||||
if (streamFn) {
|
||||
return streamFn;
|
||||
}
|
||||
if (runtime?.streamSimple) {
|
||||
return runtime.streamSimple;
|
||||
}
|
||||
throw missingRuntimeDep("streamSimple");
|
||||
}
|
||||
|
||||
/** Resolve the completion function used by non-streaming helper flows. */
|
||||
export function resolveAgentCoreCompleteFn(
|
||||
runtime: AgentCoreCompletionRuntimeDeps | undefined,
|
||||
): CompleteSimpleFn {
|
||||
if (runtime?.completeSimple) {
|
||||
return runtime.completeSimple;
|
||||
}
|
||||
throw missingRuntimeDep("completeSimple");
|
||||
}
|
||||
543
packages/agent-core/src/types.ts
Normal file
543
packages/agent-core/src/types.ts
Normal file
@@ -0,0 +1,543 @@
|
||||
// Agent Core type module defines shared TypeScript contracts.
|
||||
import type { Static, TSchema } from "typebox";
|
||||
import type {
|
||||
AssistantMessage,
|
||||
AssistantMessageEvent,
|
||||
ImageContent,
|
||||
Message,
|
||||
Model,
|
||||
SimpleStreamOptions,
|
||||
StreamFn as LlmStreamFn,
|
||||
TextContent,
|
||||
Tool,
|
||||
ToolResultMessage,
|
||||
} from "../../llm-core/src/index.js";
|
||||
|
||||
/**
|
||||
* Stream function used by the agent loop.
|
||||
*
|
||||
* Contract:
|
||||
* - Must not throw or return a rejected promise for request/model/runtime failures.
|
||||
* - Must return an AssistantMessageEventStream.
|
||||
* - Failures must be encoded in the returned stream via protocol events and a
|
||||
* final AssistantMessage with stopReason "error" or "aborted" and errorMessage.
|
||||
*/
|
||||
export type StreamFn = LlmStreamFn;
|
||||
|
||||
/**
|
||||
* Configuration for how tool calls from a single assistant message are executed.
|
||||
*
|
||||
* - "sequential": each tool call is prepared, executed, and finalized before the next one starts.
|
||||
* - "parallel": tool calls are prepared sequentially, then allowed tools execute concurrently.
|
||||
* `tool_execution_end` is emitted in tool completion order after each tool is finalized,
|
||||
* while tool-result message artifacts are emitted later in assistant source order.
|
||||
*/
|
||||
export type ToolExecutionMode = "sequential" | "parallel";
|
||||
|
||||
/**
|
||||
* Controls how many queued user messages are injected when the agent loop reaches a queue drain point.
|
||||
*
|
||||
* - "all": drain and inject every queued message at that point.
|
||||
* - "one-at-a-time": drain and inject only the oldest queued message, leaving the rest queued for later drain points.
|
||||
*/
|
||||
export type QueueMode = "all" | "one-at-a-time";
|
||||
|
||||
/** A single tool call content block emitted by an assistant message. */
|
||||
export type AgentToolCall = Extract<AssistantMessage["content"][number], { type: "toolCall" }>;
|
||||
|
||||
/**
|
||||
* Result returned from `beforeToolCall`.
|
||||
*
|
||||
* Returning `{ block: true }` prevents the tool from executing. The loop emits an error tool result instead.
|
||||
* `reason` becomes the text shown in that error result. If omitted, a default blocked message is used.
|
||||
*/
|
||||
export interface BeforeToolCallResult {
|
||||
block?: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface DeferredToolCallContext {
|
||||
/** The assistant message that requested the deferred tool call. */
|
||||
assistantMessage: AssistantMessage;
|
||||
/** The raw tool call block whose authorized tool definition is deferred. */
|
||||
toolCall: AgentToolCall;
|
||||
/** Current agent context before the deferred tool is hydrated. */
|
||||
context: AgentContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Partial override returned from `afterToolCall`.
|
||||
*
|
||||
* Merge semantics are field-by-field:
|
||||
* - `content`: if provided, replaces the tool result content array in full
|
||||
* - `details`: if provided, replaces the tool result details value in full
|
||||
* - `isError`: if provided, replaces the tool result error flag
|
||||
* - `terminate`: if provided, replaces the early-termination hint
|
||||
*
|
||||
* Omitted fields keep the original executed tool result values.
|
||||
* There is no deep merge for `content` or `details`.
|
||||
*/
|
||||
export interface AfterToolCallResult {
|
||||
content?: (TextContent | ImageContent)[];
|
||||
details?: unknown;
|
||||
isError?: boolean;
|
||||
/**
|
||||
* Hint that the agent should stop after the current tool batch.
|
||||
* Early termination only happens when every finalized tool result in the batch sets this to true.
|
||||
*/
|
||||
terminate?: boolean;
|
||||
}
|
||||
|
||||
/** Context passed to `beforeToolCall`. */
|
||||
export interface BeforeToolCallContext {
|
||||
/** The assistant message that requested the tool call. */
|
||||
assistantMessage: AssistantMessage;
|
||||
/** The raw tool call block from `assistantMessage.content`. */
|
||||
toolCall: AgentToolCall;
|
||||
/** Validated tool arguments for the target tool schema. */
|
||||
args: unknown;
|
||||
/** Current agent context at the time the tool call is prepared. */
|
||||
context: AgentContext;
|
||||
}
|
||||
|
||||
/** Context passed to `afterToolCall`. */
|
||||
export interface AfterToolCallContext {
|
||||
/** The assistant message that requested the tool call. */
|
||||
assistantMessage: AssistantMessage;
|
||||
/** The raw tool call block from `assistantMessage.content`. */
|
||||
toolCall: AgentToolCall;
|
||||
/** Validated tool arguments for the target tool schema. */
|
||||
args: unknown;
|
||||
/** The executed tool result before unknown `afterToolCall` overrides are applied. */
|
||||
result: AgentToolResult<unknown>;
|
||||
/** Whether the executed tool result is currently treated as an error. */
|
||||
isError: boolean;
|
||||
/** Current agent context at the time the tool call is finalized. */
|
||||
context: AgentContext;
|
||||
}
|
||||
|
||||
/** Context passed to `shouldStopAfterTurn`. */
|
||||
export interface ShouldStopAfterTurnContext {
|
||||
/** The assistant message that completed the turn. */
|
||||
message: AssistantMessage;
|
||||
/** Tool result messages passed to the preceding `turn_end` event. */
|
||||
toolResults: ToolResultMessage[];
|
||||
/** Current agent context after the turn's assistant message and tool results have been appended. */
|
||||
context: AgentContext;
|
||||
/** Messages that this loop invocation will return if it exits at this point. Prompt runs include the initial prompt messages; continuation runs do not include pre-existing context messages. */
|
||||
newMessages: AgentMessage[];
|
||||
}
|
||||
|
||||
/** Replacement runtime state used by the agent loop before starting another provider request. */
|
||||
export interface AgentLoopTurnUpdate {
|
||||
/** Context for the next provider request. */
|
||||
context?: AgentContext;
|
||||
/** Model for the next provider request. */
|
||||
model?: Model;
|
||||
/** Thinking level for the next provider request. */
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
}
|
||||
|
||||
export interface PrepareNextTurnContext extends ShouldStopAfterTurnContext {}
|
||||
|
||||
export interface AgentLoopConfig extends SimpleStreamOptions {
|
||||
model: Model;
|
||||
/** Logical thinking level retained across model changes before provider mapping. */
|
||||
thinkingLevel?: ThinkingLevel;
|
||||
|
||||
/**
|
||||
* Converts AgentMessage[] to LLM-compatible Message[] before each LLM call.
|
||||
*
|
||||
* Each AgentMessage must be converted to a UserMessage, AssistantMessage, or ToolResultMessage
|
||||
* that the LLM can understand. AgentMessages that cannot be converted (e.g., UI-only notifications,
|
||||
* status messages) should be filtered out.
|
||||
*
|
||||
* Contract: must not throw or reject. Return a safe fallback value instead.
|
||||
* Throwing interrupts the low-level agent loop without producing a normal event sequence.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* convertToLlm: (messages) => messages.flatMap(m => {
|
||||
* if (m.role === "custom") {
|
||||
* // Convert custom message to user message
|
||||
* return [{ role: "user", content: m.content, timestamp: m.timestamp }];
|
||||
* }
|
||||
* if (m.role === "notification") {
|
||||
* // Filter out UI-only messages
|
||||
* return [];
|
||||
* }
|
||||
* // Pass through standard LLM messages
|
||||
* return [m];
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
convertToLlm: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
|
||||
|
||||
/**
|
||||
* Optional transform applied to the context before `convertToLlm`.
|
||||
*
|
||||
* Use this for operations that work at the AgentMessage level:
|
||||
* - Context window management (pruning old messages)
|
||||
* - Injecting context from external sources
|
||||
*
|
||||
* Contract: must not throw or reject. Return the original messages or another
|
||||
* safe fallback value instead.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* transformContext: async (messages) => {
|
||||
* if (estimateTokens(messages) > MAX_TOKENS) {
|
||||
* return pruneOldMessages(messages);
|
||||
* }
|
||||
* return messages;
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise<AgentMessage[]>;
|
||||
|
||||
/**
|
||||
* Resolves an API key dynamically for each LLM call.
|
||||
*
|
||||
* Useful for short-lived OAuth tokens (e.g., GitHub Copilot) that may expire
|
||||
* during long-running tool execution phases.
|
||||
*
|
||||
* Contract: must not throw or reject. Return undefined when no key is available.
|
||||
*/
|
||||
getApiKey?: (provider: string) => Promise<string | undefined> | string | undefined;
|
||||
|
||||
/**
|
||||
* Called after each turn fully completes and `turn_end` has been emitted.
|
||||
*
|
||||
* If it returns true, the loop emits `agent_end` and exits before polling steering or follow-up queues,
|
||||
* without starting another LLM call. The current assistant response and any tool executions finish normally.
|
||||
*
|
||||
* Use this to request a graceful stop after the current turn, e.g. before context gets too full.
|
||||
*
|
||||
* Contract: must not throw or reject. Throwing interrupts the low-level agent loop without producing a normal event sequence.
|
||||
*/
|
||||
shouldStopAfterTurn?: (context: ShouldStopAfterTurnContext) => boolean | Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Called after `turn_end` and before the loop decides whether another provider request should start.
|
||||
* Return replacement context/model/thinking state to affect the next turn in this run.
|
||||
* Return undefined to keep using the current context/config.
|
||||
*/
|
||||
prepareNextTurn?: (
|
||||
context: PrepareNextTurnContext,
|
||||
) => AgentLoopTurnUpdate | undefined | Promise<AgentLoopTurnUpdate | undefined>;
|
||||
|
||||
/**
|
||||
* Returns steering messages to inject into the conversation mid-run.
|
||||
*
|
||||
* Called after the current assistant turn finishes executing its tool calls, unless `shouldStopAfterTurn` exits first.
|
||||
* If messages are returned, they are added to the context before the next LLM call.
|
||||
* Tool calls from the current assistant message are not skipped.
|
||||
*
|
||||
* Use this for "steering" the agent while it's working.
|
||||
*
|
||||
* Contract: must not throw or reject. Return [] when no steering messages are available.
|
||||
*/
|
||||
getSteeringMessages?: () => Promise<AgentMessage[]>;
|
||||
|
||||
/**
|
||||
* Returns follow-up messages to process after the agent would otherwise stop.
|
||||
*
|
||||
* Called when the agent has no more tool calls and no steering messages.
|
||||
* If messages are returned, they're added to the context and the agent
|
||||
* continues with another turn.
|
||||
*
|
||||
* Use this for follow-up messages that should wait until the agent finishes.
|
||||
*
|
||||
* Contract: must not throw or reject. Return [] when no follow-up messages are available.
|
||||
*/
|
||||
getFollowUpMessages?: () => Promise<AgentMessage[]>;
|
||||
|
||||
/**
|
||||
* Tool execution mode.
|
||||
* - "sequential": execute tool calls one by one
|
||||
* - "parallel": preflight tool calls sequentially, then execute allowed tools concurrently;
|
||||
* emit `tool_execution_end` in tool completion order after each tool is finalized,
|
||||
* then emit tool-result message artifacts later in assistant source order
|
||||
*
|
||||
* Default: "parallel"
|
||||
*/
|
||||
toolExecution?: ToolExecutionMode;
|
||||
|
||||
/**
|
||||
* Called before a tool is executed, after arguments have been validated.
|
||||
*
|
||||
* Return `{ block: true }` to prevent execution. The loop emits an error tool result instead.
|
||||
* The hook receives the agent abort signal and is responsible for honoring it.
|
||||
*/
|
||||
beforeToolCall?: (
|
||||
context: BeforeToolCallContext,
|
||||
signal?: AbortSignal,
|
||||
) => Promise<BeforeToolCallResult | undefined>;
|
||||
|
||||
/**
|
||||
* Hydrates an already-authorized tool that was deferred out of the current
|
||||
* provider-visible tool set. Return undefined for every other unknown name so
|
||||
* the loop keeps the normal "Tool <name> not found" result. Thrown or rejected
|
||||
* failures become error tool results for the requested call.
|
||||
*/
|
||||
resolveDeferredTool?: (
|
||||
context: DeferredToolCallContext,
|
||||
signal?: AbortSignal,
|
||||
) => Promise<AgentTool | undefined> | AgentTool | undefined;
|
||||
|
||||
/**
|
||||
* Called after a tool finishes executing, before `tool_execution_end` and tool-result message events are emitted.
|
||||
*
|
||||
* Return an `AfterToolCallResult` to override parts of the executed tool result:
|
||||
* - `content` replaces the full content array
|
||||
* - `details` replaces the full details payload
|
||||
* - `isError` replaces the error flag
|
||||
* - `terminate` replaces the early-termination hint
|
||||
*
|
||||
* Any omitted fields keep their original values. No deep merge is performed.
|
||||
* The hook receives the agent abort signal and is responsible for honoring it.
|
||||
*/
|
||||
afterToolCall?: (
|
||||
context: AfterToolCallContext,
|
||||
signal?: AbortSignal,
|
||||
) => Promise<AfterToolCallResult | undefined>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thinking/reasoning level for models that support it.
|
||||
* Note: "xhigh" is only supported by selected model families. Use model thinking-level metadata
|
||||
* from openclaw/plugin-sdk/llm to detect support for a concrete model.
|
||||
*/
|
||||
export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
|
||||
|
||||
export interface BashExecutionMessage {
|
||||
/** Harness role for shell command transcripts. */
|
||||
role: "bashExecution";
|
||||
/** Command line that was executed. */
|
||||
command: string;
|
||||
/** Captured command output, usually already truncated for context. */
|
||||
output: string;
|
||||
/** Process exit code when the command reached process exit. */
|
||||
exitCode: number | undefined;
|
||||
/** True when the command was interrupted before normal completion. */
|
||||
cancelled: boolean;
|
||||
/** True when output was shortened for transcript/context storage. */
|
||||
truncated: boolean;
|
||||
/** Optional path containing the complete output when truncation occurred. */
|
||||
fullOutputPath?: string;
|
||||
/** Millisecond timestamp for transcript ordering. */
|
||||
timestamp: number;
|
||||
/** Exclude this command transcript from model context while keeping it in session history. */
|
||||
excludeFromContext?: boolean;
|
||||
}
|
||||
|
||||
export interface CustomMessage<T = unknown> {
|
||||
/** Harness role for application-defined transcript content. */
|
||||
role: "custom";
|
||||
/** Application-defined discriminator for rendering or handling this message. */
|
||||
customType: string;
|
||||
/** Content replayed into model context when this message is included. */
|
||||
content: string | (TextContent | ImageContent)[];
|
||||
/** Whether UI surfaces should display this message. */
|
||||
display: boolean;
|
||||
/** Optional application-specific metadata. */
|
||||
details?: T;
|
||||
/** Millisecond timestamp for transcript ordering. */
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface BranchSummaryMessage {
|
||||
/** Harness role for summaries produced when returning from another branch. */
|
||||
role: "branchSummary";
|
||||
/** Summary text inserted back into model context. */
|
||||
summary: string;
|
||||
/** Entry id of the branch root or source leaf being summarized. */
|
||||
fromId: string;
|
||||
/** Millisecond timestamp for transcript ordering. */
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface CompactionSummaryMessage {
|
||||
/** Harness role for summaries that replace compacted transcript history. */
|
||||
role: "compactionSummary";
|
||||
/** Summary text inserted back into model context. */
|
||||
summary: string;
|
||||
/** Estimated context tokens before compaction. */
|
||||
tokensBefore: number;
|
||||
/** Timestamp may be numeric in memory or string when loaded from older persisted rows. */
|
||||
timestamp: number | string;
|
||||
/** Optional estimated context tokens after compaction. */
|
||||
tokensAfter?: number;
|
||||
/** Optional first retained entry id from the compaction range. */
|
||||
firstKeptEntryId?: string;
|
||||
/** Optional implementation-specific compaction metadata. */
|
||||
details?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extensible interface for custom app and harness messages.
|
||||
* Apps can extend via declaration merging.
|
||||
*/
|
||||
export interface CustomAgentMessages {
|
||||
bashExecution: BashExecutionMessage;
|
||||
custom: CustomMessage;
|
||||
branchSummary: BranchSummaryMessage;
|
||||
compactionSummary: CompactionSummaryMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* AgentMessage: Union of LLM messages + custom messages.
|
||||
* This abstraction allows apps to add custom message types while maintaining
|
||||
* type safety and compatibility with the base LLM messages.
|
||||
*/
|
||||
export type AgentMessage = Message | CustomAgentMessages[keyof CustomAgentMessages];
|
||||
|
||||
/**
|
||||
* Public agent state.
|
||||
*
|
||||
* `tools` and `messages` use accessor properties so implementations can copy
|
||||
* assigned arrays before storing them.
|
||||
*/
|
||||
export interface AgentState {
|
||||
/** System prompt sent with each model request. */
|
||||
systemPrompt: string;
|
||||
/** Active model used for future turns. */
|
||||
model: Model;
|
||||
/** Requested reasoning level for future turns. */
|
||||
thinkingLevel: ThinkingLevel;
|
||||
/** Available tools. Assigning a new array copies the top-level array. */
|
||||
set tools(tools: AgentTool[]);
|
||||
get tools(): AgentTool[];
|
||||
/** Conversation transcript. Assigning a new array copies the top-level array. */
|
||||
set messages(messages: AgentMessage[]);
|
||||
get messages(): AgentMessage[];
|
||||
/**
|
||||
* True while the agent is processing a prompt or continuation.
|
||||
*
|
||||
* This remains true until awaited `agent_end` listeners settle.
|
||||
*/
|
||||
readonly isStreaming: boolean;
|
||||
/** Partial assistant message for the current streamed response, if any. */
|
||||
readonly streamingMessage?: AgentMessage;
|
||||
/** Tool call ids currently executing. */
|
||||
readonly pendingToolCalls: ReadonlySet<string>;
|
||||
/** Error message from the most recent failed or aborted assistant turn, if any. */
|
||||
readonly errorMessage?: string;
|
||||
}
|
||||
|
||||
/** Channel-safe progress text emitted by a running tool. */
|
||||
export interface AgentToolProgress {
|
||||
/** Public text suitable for user-facing progress surfaces. */
|
||||
text: string;
|
||||
/** Tool progress is rendered by channel progress UIs. */
|
||||
visibility: "channel";
|
||||
/** Progress text must not contain secrets, private args, or fetched content. */
|
||||
privacy: "public";
|
||||
/** Optional stable id for progress line replacement. */
|
||||
id?: string;
|
||||
}
|
||||
|
||||
/** Final or partial result produced by a tool. */
|
||||
export interface AgentToolResult<T> {
|
||||
/** Text or image content returned to the model. */
|
||||
content: (TextContent | ImageContent)[];
|
||||
/** Arbitrary structured details for logs or UI rendering. */
|
||||
details: T;
|
||||
/** Optional public progress hint for partial tool updates; never model content. */
|
||||
progress?: AgentToolProgress;
|
||||
/**
|
||||
* Hint that the agent should stop after the current tool batch.
|
||||
* Early termination only happens when every finalized tool result in the batch sets this to true.
|
||||
*/
|
||||
terminate?: boolean;
|
||||
}
|
||||
|
||||
/** Callback used by tools to stream partial execution updates. */
|
||||
export type AgentToolUpdateCallback<T = unknown> = (partialResult: AgentToolResult<T>) => void;
|
||||
|
||||
/** Tool definition used by the agent runtime. */
|
||||
export interface AgentTool<
|
||||
TParameters extends TSchema = TSchema,
|
||||
TDetails = unknown,
|
||||
> extends Tool<TParameters> {
|
||||
/** Human-readable label for UI display. */
|
||||
label: string;
|
||||
/** Preserve lifecycle telemetry without rendering transient channel progress. */
|
||||
hideFromChannelProgress?: boolean;
|
||||
/**
|
||||
* Optional compatibility shim for raw tool-call arguments before schema validation.
|
||||
* Must return an object that matches `TParameters`.
|
||||
*/
|
||||
prepareArguments?: (args: unknown) => Static<TParameters>;
|
||||
/** Execute the tool call. Throw on failure instead of encoding errors in `content`. */
|
||||
execute: (
|
||||
toolCallId: string,
|
||||
params: Static<TParameters>,
|
||||
signal?: AbortSignal,
|
||||
onUpdate?: AgentToolUpdateCallback<TDetails>,
|
||||
) => Promise<AgentToolResult<TDetails>>;
|
||||
/**
|
||||
* Per-tool execution mode override.
|
||||
* - "sequential": this tool must execute one at a time with other tool calls.
|
||||
* - "parallel": this tool can execute concurrently with other tool calls.
|
||||
*
|
||||
* If omitted, the default execution mode applies.
|
||||
*/
|
||||
executionMode?: ToolExecutionMode;
|
||||
}
|
||||
|
||||
/** Context snapshot passed into the low-level agent loop. */
|
||||
export interface AgentContext {
|
||||
/** System prompt included with the request. */
|
||||
systemPrompt: string;
|
||||
/** Transcript visible to the model. */
|
||||
messages: AgentMessage[];
|
||||
/** Tools available for this run. */
|
||||
tools?: AgentTool[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Events emitted by the Agent for UI updates.
|
||||
*
|
||||
* `agent_end` is the last event emitted for a run, but awaited `Agent.subscribe()`
|
||||
* listeners for that event are still part of run settlement. The agent becomes
|
||||
* idle only after those listeners finish.
|
||||
*/
|
||||
export type AgentEvent =
|
||||
// Agent lifecycle
|
||||
| { type: "agent_start" }
|
||||
| { type: "agent_end"; messages: AgentMessage[] }
|
||||
// Turn lifecycle - a turn is one assistant response + any tool calls/results
|
||||
| { type: "turn_start" }
|
||||
| { type: "turn_end"; message: AgentMessage; toolResults: ToolResultMessage[] }
|
||||
// Message lifecycle - emitted for user, assistant, and toolResult messages
|
||||
| { type: "message_start"; message: AgentMessage }
|
||||
// Only emitted for assistant messages during streaming
|
||||
| { type: "message_update"; message: AgentMessage; assistantMessageEvent: AssistantMessageEvent }
|
||||
| { type: "message_end"; message: AgentMessage }
|
||||
// Tool execution lifecycle
|
||||
| {
|
||||
type: "tool_execution_start";
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
args: unknown;
|
||||
hideFromChannelProgress?: boolean;
|
||||
}
|
||||
| {
|
||||
type: "tool_execution_update";
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
args: unknown;
|
||||
partialResult: unknown;
|
||||
hideFromChannelProgress?: boolean;
|
||||
}
|
||||
| {
|
||||
type: "tool_execution_end";
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
result: unknown;
|
||||
isError: boolean;
|
||||
/** False when resolution, argument preparation, validation, or policy blocked execution. */
|
||||
executionStarted?: boolean;
|
||||
hideFromChannelProgress?: boolean;
|
||||
};
|
||||
2
packages/agent-core/src/validation.ts
Normal file
2
packages/agent-core/src/validation.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Tool validation facade for callers that import validation from agent-core.
|
||||
export { validateToolArguments, validateToolCall } from "@openclaw/ai/validation";
|
||||
Reference in New Issue
Block a user