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

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:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

View File

@@ -0,0 +1,101 @@
{
"name": "@openclaw/agent-core",
"version": "0.0.0-private",
"private": true,
"files": [
"dist"
],
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./agent": {
"types": "./dist/agent.d.ts",
"default": "./dist/agent.js"
},
"./agent-loop": {
"types": "./dist/agent-loop.d.ts",
"default": "./dist/agent-loop.js"
},
"./llm": {
"types": "./dist/llm.d.ts",
"default": "./dist/llm.js"
},
"./node": {
"types": "./dist/node.d.ts",
"default": "./dist/node.js"
},
"./runtime-deps": {
"types": "./dist/runtime-deps.d.ts",
"default": "./dist/runtime-deps.js"
},
"./validation": {
"types": "./dist/validation.d.ts",
"default": "./dist/validation.js"
},
"./types": {
"types": "./dist/types.d.ts",
"default": "./dist/types.js"
},
"./harness/agent-harness": {
"types": "./dist/harness/agent-harness.d.ts",
"default": "./dist/harness/agent-harness.js"
},
"./harness/types": {
"types": "./dist/harness/types.d.ts",
"default": "./dist/harness/types.js"
},
"./harness/messages": {
"types": "./dist/harness/messages.d.ts",
"default": "./dist/harness/messages.js"
},
"./harness/env/kill-tree": {
"types": "./dist/harness/env/kill-tree.d.ts",
"default": "./dist/harness/env/kill-tree.js"
},
"./harness/session": {
"types": "./dist/harness/session.d.ts",
"default": "./dist/harness/session.js"
},
"./harness/session/jsonl-storage": {
"types": "./dist/harness/session/jsonl-storage.d.ts",
"default": "./dist/harness/session/jsonl-storage.js"
},
"./harness/session/memory-storage": {
"types": "./dist/harness/session/memory-storage.d.ts",
"default": "./dist/harness/session/memory-storage.js"
},
"./harness/session/uuid": {
"types": "./dist/harness/session/uuid.d.ts",
"default": "./dist/harness/session/uuid.js"
},
"./harness/compaction": {
"types": "./dist/harness/compaction.d.ts",
"default": "./dist/harness/compaction.js"
},
"./harness/branch-summarization": {
"types": "./dist/harness/branch-summarization.d.ts",
"default": "./dist/harness/branch-summarization.js"
},
"./harness/prompt-template-arguments": {
"types": "./dist/harness/prompt-template-arguments.d.ts",
"default": "./dist/harness/prompt-template-arguments.js"
},
"./harness/skills": {
"types": "./dist/harness/skills.d.ts",
"default": "./dist/harness/skills.js"
},
"./harness/utils/truncate": {
"types": "./dist/harness/utils/truncate.d.ts",
"default": "./dist/harness/utils/truncate.js"
}
},
"dependencies": {
"@openclaw/ai": "workspace:*",
"typebox": "1.3.3"
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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,
});
}

View File

@@ -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);
});
});

View File

@@ -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);
});
});

View 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);
});
});

View 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",
});
}

View 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}`);
});
});

View 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");
}

View 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);
}

View 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();
});
});

View 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
}
}

View 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);
});
});

View 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);
}

View File

@@ -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");
});
});

View 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);
}

View 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");
});
});

View 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");
});
});

View 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);
}
}

View 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]);
});
});

View 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);
}
}

View 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);
}
}

View 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>;
}

View 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;
}

View 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("")}`;
}

View 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);
}

View 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";

View 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]/,
);
}
});
});
});

View 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 };
}

View 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";

View File

@@ -0,0 +1,2 @@
// AI contract compatibility barrel for agent-core consumers.
export * from "@openclaw/ai";

View 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";

View 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");
},
);
});

View 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;
}

View 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");
}

View 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;
};

View File

@@ -0,0 +1,2 @@
// Tool validation facade for callers that import validation from agent-core.
export { validateToolArguments, validateToolCall } from "@openclaw/ai/validation";