refactor(agent-core): delete the unused session harness layer (#110287)

This commit is contained in:
Peter Steinberger
2026-07-18 02:27:48 +01:00
committed by GitHub
parent 2e70de9d32
commit b6bac30e33
35 changed files with 130 additions and 4342 deletions
-8
View File
@@ -427,22 +427,14 @@ const config = {
"src/agent.ts!",
"src/agent-loop.ts!",
"src/llm.ts!",
"src/node.ts!",
"src/runtime-deps.ts!",
"src/validation.ts!",
"src/types.ts!",
"src/harness/agent-harness.ts!",
"src/harness/types.ts!",
"src/harness/messages.ts!",
"src/harness/env/kill-tree.ts!",
"src/harness/session.ts!",
"src/harness/session/jsonl-storage.ts!",
"src/harness/session/memory-storage.ts!",
"src/harness/session/uuid.ts!",
"src/harness/compaction.ts!",
"src/harness/branch-summarization.ts!",
"src/harness/prompt-template-arguments.ts!",
"src/harness/skills.ts!",
"src/harness/utils/truncate.ts!",
],
project: ["src/**/*.ts!"],
-1
View File
@@ -348,7 +348,6 @@ extensions/zalouser/src/monitor.ts
extensions/zalouser/src/zalo-js.ts
packages/agent-core/src/agent-loop.test.ts
packages/agent-core/src/agent-loop.ts
packages/agent-core/src/harness/agent-harness.ts
packages/agent-core/src/harness/compaction/compaction.ts
packages/ai/src/providers/agent-tools-parameter-schema.ts
packages/ai/src/providers/anthropic.test.ts
+1 -32
View File
@@ -25,10 +25,6 @@
"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"
@@ -41,14 +37,6 @@
"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"
@@ -57,22 +45,6 @@
"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"
@@ -85,10 +57,6 @@
"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"
@@ -96,6 +64,7 @@
},
"dependencies": {
"@openclaw/ai": "workspace:*",
"@openclaw/llm-core": "workspace:*",
"@openclaw/normalization-core": "workspace:*",
"typebox": "1.3.3"
}
+2 -2
View File
@@ -7,8 +7,8 @@ import type {
Context,
EventStream,
ToolResultMessage,
} from "../../llm-core/src/index.js";
import type { EventStream as SourceEventStream } from "../../llm-core/src/index.js";
} from "@openclaw/llm-core";
import type { EventStream as SourceEventStream } from "@openclaw/llm-core";
import { TranscriptNotContinuableError } from "./errors.js";
import { resolveAgentReasoningOption } from "./reasoning.js";
import { type AgentCoreStreamRuntimeDeps, resolveAgentCoreStreamFn } from "./runtime-deps.js";
+1 -1
View File
@@ -7,7 +7,7 @@ import type {
TextContent,
ThinkingBudgets,
Transport,
} from "../../llm-core/src/index.js";
} from "@openclaw/llm-core";
import { runAgentLoop, runAgentLoopContinue } from "./agent-loop.js";
import { TranscriptNotContinuableError } from "./errors.js";
import { resolveAgentReasoningOption } from "./reasoning.js";
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,5 @@
// Agent Core module implements branch summarization behavior.
import type { Model, StreamFn } from "../../../../llm-core/src/index.js";
import type { Model, StreamFn } from "@openclaw/llm-core";
import {
type AgentCoreCompletionRuntimeDeps,
resolveAgentCoreCompleteFn,
@@ -12,7 +12,7 @@ import {
createCompactionSummaryMessage,
createCustomMessage,
} from "../messages.js";
import type { BranchSummaryResult, Session, SessionTreeEntry } from "../types.js";
import type { BranchSummaryResult, SessionTreeEntry } from "../types.js";
import { BranchSummaryError, err, ok, type Result } from "../types.js";
import { estimateTokens, SUMMARIZATION_SYSTEM_PROMPT } from "./compaction.js";
import {
@@ -42,14 +42,6 @@ export interface BranchPreparation {
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. */
@@ -109,19 +101,6 @@ export function collectEntriesForBranchSummaryFromBranches<TEntry extends Branch
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":
@@ -7,7 +7,7 @@ import {
type SimpleStreamOptions,
type StreamFn,
type Usage,
} from "../../../../llm-core/src/index.js";
} from "@openclaw/llm-core";
import { resolveAgentReasoningOption } from "../../reasoning.js";
import {
type AgentCoreCompletionRuntimeDeps,
@@ -1,5 +1,5 @@
import type { Message } from "@openclaw/llm-core";
import { describe, expect, it } from "vitest";
import type { Message } from "../../../../llm-core/src/index.js";
import { serializeConversation } from "./utils.js";
describe("serializeConversation", () => {
@@ -1,17 +1,10 @@
import type { Message } from "@openclaw/llm-core";
// Agent Core helper module supports utils behavior.
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import type { Message } from "../../../../llm-core/src/index.js";
import type { AgentMessage } from "../../types.js";
import type { FileOperations } 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>;
}
export type { FileOperations } from "../types.js";
/** Create an empty file-operation accumulator. */
export function createFileOps(): FileOperations {
-236
View File
@@ -1,236 +0,0 @@
// Agent Core tests cover nodejs behavior.
import { EventEmitter } from "node:events";
import { parse } from "node:path";
import { PassThrough } from "node:stream";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { NodeExecutionEnv } from "./nodejs.js";
const { spawnMock } = vi.hoisted(() => ({ spawnMock: vi.fn() }));
vi.mock("node:child_process", () => ({
spawn: spawnMock,
}));
afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
vi.clearAllMocks();
});
function mockSpawnChild() {
const child = Object.assign(new EventEmitter(), {
pid: 12345,
stdin: new PassThrough(),
stdout: new PassThrough(),
stderr: new PassThrough(),
kill: vi.fn(() => true),
});
spawnMock.mockReturnValue(child);
return child as typeof child & {
stdin: PassThrough;
stdout: PassThrough;
stderr: PassThrough;
};
}
function createMockExecEnv(): NodeExecutionEnv {
return new NodeExecutionEnv({ cwd: process.cwd(), shellPath: process.execPath });
}
async function waitForSpawnCall(): Promise<void> {
const deadline = Date.now() + 2_000;
while (Date.now() < deadline) {
if (spawnMock.mock.calls.length > 0) {
return;
}
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
}
throw new Error("expected spawn to be called");
}
describe("NodeExecutionEnv file metadata", () => {
let env: NodeExecutionEnv;
let tempDir: string;
beforeEach(async () => {
const rootEnv = new NodeExecutionEnv({ cwd: process.cwd() });
const created = await rootEnv.createTempDir("agent-core-nodejs-");
if (!created.ok) {
throw created.error;
}
tempDir = created.value;
env = new NodeExecutionEnv({ cwd: tempDir });
});
afterEach(async () => {
const removed = await env.remove(tempDir, { recursive: true, force: true });
if (!removed.ok) {
throw removed.error;
}
});
it("reports basenames consistently from fileInfo and listDir", async () => {
const written = await env.writeFile("notes/todo.txt", "hello");
expect(written.ok).toBe(true);
const info = await env.fileInfo("notes/todo.txt");
expect(info.ok).toBe(true);
if (info.ok) {
expect(info.value.name).toBe("todo.txt");
}
const entries = await env.listDir("notes");
expect(entries.ok).toBe(true);
if (entries.ok) {
expect(entries.value.map((entry) => entry.name)).toEqual(["todo.txt"]);
}
});
it("reports an empty basename for the filesystem root", async () => {
const info = await env.fileInfo(parse(tempDir).root);
expect(info.ok).toBe(true);
if (info.ok) {
expect(info.value.name).toBe("");
}
});
it.runIf(process.platform !== "win32")("preserves backslashes in POSIX filenames", async () => {
const fileName = "notes\\todo.txt";
const written = await env.writeFile(fileName, "hello");
expect(written.ok).toBe(true);
const info = await env.fileInfo(fileName);
expect(info.ok).toBe(true);
if (info.ok) {
expect(info.value.name).toBe(fileName);
}
});
});
describe("NodeExecutionEnv timeout handling", () => {
let env: NodeExecutionEnv;
beforeEach(() => {
env = createMockExecEnv();
});
it.each([
{ timeout: 1, expectedDelayMs: 1_000 },
{ timeout: 1.5, expectedDelayMs: 1_500 },
{ timeout: 0.0005, expectedDelayMs: 1 },
{ timeout: Number.MAX_SAFE_INTEGER, expectedDelayMs: 2_147_000_000 },
])("schedules timeout $timeout as $expectedDelayMs ms", async ({ timeout, expectedDelayMs }) => {
const timeoutSpy = vi.spyOn(globalThis, "setTimeout");
const child = mockSpawnChild();
const resultPromise = env.exec("echo hello", { timeout });
await waitForSpawnCall();
expect(timeoutSpy).toHaveBeenCalledWith(expect.any(Function), expectedDelayMs);
child.emit("close", 0);
await expect(resultPromise).resolves.toMatchObject({ ok: true });
});
it.each([undefined, Number.NaN, 0, -1])(
"does not schedule an invalid timeout value %s",
async (timeout) => {
const timeoutSpy = vi.spyOn(globalThis, "setTimeout");
const child = mockSpawnChild();
const resultPromise = env.exec("echo hello", { timeout });
await waitForSpawnCall();
expect(timeoutSpy).not.toHaveBeenCalled();
child.emit("close", 0);
await expect(resultPromise).resolves.toMatchObject({ ok: true });
},
);
});
describe("NodeExecutionEnv exec stream errors", () => {
let env: NodeExecutionEnv;
beforeEach(() => {
env = createMockExecEnv();
});
it.each(["stdout", "stderr"] as const)(
"rejects with spawn_error when %s stream emits an error",
async (streamName) => {
const child = mockSpawnChild();
const resultPromise = env.exec("echo hello");
await waitForSpawnCall();
child[streamName].emit("error", new Error(`${streamName} EPIPE`));
const result = await resultPromise;
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe("spawn_error");
expect(result.error.message).toContain(`${streamName} read error`);
expect(result.error.message).toContain("EPIPE");
}
},
);
it("keeps the other stream guarded after a stdout error", async () => {
const child = mockSpawnChild();
const resultPromise = env.exec("echo hello");
await waitForSpawnCall();
child.stdout.emit("error", new Error("stdout EPIPE"));
// stderr error after stdout already failed must not throw
expect(() => {
child.stderr.emit("error", new Error("stderr later"));
}).not.toThrow();
const result = await resultPromise;
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.message).toContain("stdout read error");
}
});
it("completes normally when no stream errors occur", async () => {
const child = mockSpawnChild();
const resultPromise = env.exec("echo hello");
await waitForSpawnCall();
child.emit("close", 0);
const result = await resultPromise;
expect(result.ok).toBe(true);
});
it("contains stdout errors during Windows shell discovery", async () => {
const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
Object.defineProperty(process, "platform", { value: "win32", configurable: true });
// Force PATH discovery even on Windows hosts with Git Bash in Program Files.
vi.stubEnv("ProgramFiles", "");
vi.stubEnv("ProgramFiles(x86)", "");
try {
const child = mockSpawnChild();
const resultPromise = new NodeExecutionEnv({ cwd: process.cwd() }).exec("echo hello");
await waitForSpawnCall();
expect(spawnMock.mock.calls[0]?.[0]).toBe("where");
expect(spawnMock.mock.calls[0]?.[1]).toEqual(["bash.exe"]);
child.stdout.emit("error", new Error("where stdout failed"));
const result = await resultPromise;
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe("shell_unavailable");
}
} finally {
if (platformDescriptor) {
Object.defineProperty(process, "platform", platformDescriptor);
}
}
});
});
-651
View File
@@ -1,651 +0,0 @@
// 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 { basename, isAbsolute, join, resolve } from "node:path";
import { createInterface } from "node:readline";
import { toErrorObject } from "@openclaw/normalization-core/error-coercion";
import {
type ExecutionEnv,
ExecutionError,
err,
FileError,
type FileInfo,
type FileKind,
ok,
type Result,
} 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. */
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: basename(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 = toErrorObject(error, "Non-Error thrown");
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;
}
type ChildOutputStreamName = "stdout" | "stderr";
function listenForChildOutputErrors(
child: ReturnType<typeof spawn>,
onError: (stream: ChildOutputStreamName, error: Error) => void,
): void {
for (const streamName of ["stdout", "stderr"] as const) {
child[streamName]?.on("error", (error: Error) => onError(streamName, error));
}
}
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, detached: false });
}
}, timeoutMs);
child.stdout?.setEncoding("utf8");
child.stdout?.on("data", (chunk: string) => {
stdout += chunk;
});
listenForChildOutputErrors(child, () => {
if (child.pid) {
killProcessTree(child.pid, { force: true, detached: false });
}
clearTimeout(timeout);
resolveLocal({ stdout: "", status: null });
});
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, detached: 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 = toErrorObject(error, "Non-Error thrown");
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, detached: 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 = toErrorObject(error, "Non-Error thrown");
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 = toErrorObject(error, "Non-Error thrown");
callbackError = new ExecutionError("callback_error", cause.message, cause);
onAbort();
}
});
// Guard stdout/stderr against stream errors (e.g. EPIPE when the
// child exits before all pipe data is consumed). Without listeners,
// Node.js throws an uncaught exception that crashes the process.
const onStreamError = (stream: ChildOutputStreamName, error: Error) => {
if (settled) {
return;
}
onAbort();
settle(
err(new ExecutionError("spawn_error", `${stream} read error: ${error.message}`, error)),
);
};
listenForChildOutputErrors(child, onStreamError);
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
}
}
+17 -2
View File
@@ -1,5 +1,5 @@
// Agent Core module implements messages behavior.
import type { ImageContent, Message, TextContent } from "../../../llm-core/src/index.js";
import type { ImageContent, Message, TextContent } from "@openclaw/llm-core";
import type {
AgentMessage,
BashExecutionMessage,
@@ -7,7 +7,6 @@ import type {
CompactionSummaryMessage,
CustomMessage,
} from "../types.js";
import { parseSessionTimestampMs, requireSessionTimestampMs } from "./session/timestamps.js";
export type {
BashExecutionMessage,
@@ -30,6 +29,22 @@ export function asAgentMessage(message: HarnessMessage): AgentMessage {
return message as AgentMessage;
}
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;
}
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;
}
function normalizeCompactionSummaryTimestamp(timestamp: number | string): number {
if (typeof timestamp === "number") {
return timestamp;
@@ -1,4 +1,8 @@
import type { PromptTemplate } from "./types.js";
export interface PromptTemplate {
name: string;
description?: string;
content: string;
}
/** Parse an argument string using simple shell-style single and double quotes. */
export function parseCommandArgs(argsString: string): string[] {
@@ -1,314 +0,0 @@
// Agent Core tests cover jsonl storage behavior.
import { describe, expect, it } from "vitest";
import { NodeExecutionEnv } from "../env/nodejs.js";
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("reports physical entry line numbers when blank JSONL rows are skipped", async () => {
const header = JSON.stringify({
type: "session",
version: 3,
id: "session-1",
timestamp: "2026-01-01T00:00:00.000Z",
cwd: "/repo",
});
const entry = JSON.stringify({
type: "custom",
id: "entry-1",
parentId: null,
timestamp: "2026-01-01T00:00:01.000Z",
customType: "note",
});
const invalidContent = [header, "\t\r", "", entry, " ", "not-json", ""].join("\n");
const validContent = [header, "\t\r", "", entry, " ", ""].join("\n");
const rootEnv = new NodeExecutionEnv({ cwd: process.cwd() });
const created = await rootEnv.createTempDir("agent-core-jsonl-");
if (!created.ok) {
throw created.error;
}
const fs = new NodeExecutionEnv({ cwd: created.value });
try {
const invalidWrite = await fs.writeFile("invalid-entry.jsonl", invalidContent);
if (!invalidWrite.ok) {
throw invalidWrite.error;
}
await expect(JsonlSessionStorage.open(fs, "invalid-entry.jsonl")).rejects.toMatchObject({
name: "SessionError",
code: "invalid_entry",
message: "Invalid JSONL session file invalid-entry.jsonl: line 6 is not valid JSON",
});
const validWrite = await fs.writeFile("valid.jsonl", validContent);
if (!validWrite.ok) {
throw validWrite.error;
}
const storage = await JsonlSessionStorage.open(fs, "valid.jsonl");
expect((await storage.getEntries()).map((storedEntry) => storedEntry.id)).toEqual([
"entry-1",
]);
} finally {
const removed = await rootEnv.remove(created.value, { recursive: true, force: true });
expect(removed.ok).toBe(true);
}
});
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");
});
});
@@ -1,303 +0,0 @@
// Agent Core module implements jsonl storage behavior.
import { toErrorObject } from "@openclaw/normalization-core/error-coercion";
import type {
FileError,
FileSystem,
JsonlSessionMetadata,
Result,
SessionTreeEntry,
} from "../types.js";
import { SessionError } 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",
toErrorObject(error, "Non-Error thrown"),
);
}
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",
toErrorObject(error, "Non-Error thrown"),
);
}
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");
const headerIndex = lines.findIndex((line) => line.trim());
if (headerIndex === -1) {
throw invalidSession(filePath, "missing session header");
}
const headerLine = lines.at(headerIndex);
if (headerLine === undefined) {
throw invalidSession(filePath, "missing session header");
}
const header = parseHeaderLine(headerLine, filePath);
const entries: SessionTreeEntry[] = [];
let leafId: string | null = null;
let appendParentId: string | null = null;
for (const [offset, line] of lines.slice(headerIndex + 1).entries()) {
if (!line.trim()) {
continue;
}
const entry = parseEntryLine(line, filePath, headerIndex + offset + 2);
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);
}
}
@@ -1,245 +0,0 @@
// Agent Core tests cover memory storage behavior.
import { afterEach, describe, expect, it, vi } 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",
};
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
describe("InMemorySessionStorage", () => {
it.each([32, 128])("keeps %i rapid short entry ids unique", async (count) => {
let randomValue = 0;
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
vi.stubGlobal("crypto", {
getRandomValues(bytes: Uint8Array) {
bytes.fill(0);
new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).setUint32(
bytes.byteLength - 4,
randomValue++,
);
return bytes;
},
});
const storage = new InMemorySessionStorage();
const ids = await Promise.all(Array.from({ length: count }, () => storage.createEntryId()));
expect(ids.every((id) => id.length === 8)).toBe(true);
expect(new Set(ids).size).toBe(count);
});
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("returns a selected branch in root-to-leaf order", async () => {
const activeLeaf: SessionTreeEntry = {
type: "custom",
id: "active-leaf",
parentId: "child",
timestamp: "2026-01-01T00:00:02.000Z",
customType: "active",
};
const sideLeaf: SessionTreeEntry = {
type: "custom",
id: "side-leaf",
parentId: "root",
timestamp: "2026-01-01T00:00:03.000Z",
customType: "side",
};
const storage = new InMemorySessionStorage({
entries: [rootEntry, childEntry, activeLeaf, sideLeaf],
});
expect((await storage.getPathToRoot(activeLeaf.id)).map((entry) => entry.id)).toEqual([
"root",
"child",
"active-leaf",
]);
expect((await storage.getPathToRoot(sideLeaf.id)).map((entry) => entry.id)).toEqual([
"root",
"side-leaf",
]);
});
it("normalizes session names to one line", async () => {
const session = new Session(new InMemorySessionStorage());
await session.appendSessionName(" first\nsecond\r\nthird ");
expect(await session.getSessionName()).toBe("first second third");
});
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]);
});
});
@@ -1,24 +0,0 @@
// 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);
}
}
@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import type { SessionTreeEntry } from "../types.js";
import { buildSessionContext } from "./session.js";
const timestamp = "2026-07-17T00:00:00.000Z";
function userEntry(id: string, parentId: string | null, content: string): SessionTreeEntry {
return {
type: "message",
id,
parentId,
timestamp,
message: { role: "user", content, timestamp: Date.parse(timestamp) },
};
}
describe("buildSessionContext", () => {
it("replays only the retained tail and newer entries after compaction", () => {
const entries: SessionTreeEntry[] = [
userEntry("old", null, "discarded"),
userEntry("kept", "old", "retained"),
{
type: "model_change",
id: "model",
parentId: "kept",
timestamp,
provider: "test-provider",
modelId: "test-model",
},
{
type: "compaction",
id: "compaction",
parentId: "model",
timestamp,
summary: "older context",
firstKeptEntryId: "kept",
tokensBefore: 123,
},
userEntry("new", "compaction", "new turn"),
];
const context = buildSessionContext(entries);
expect(context).toMatchObject({
thinkingLevel: "off",
model: { provider: "test-provider", modelId: "test-model" },
});
expect(context.messages.map((message) => message.role)).toEqual([
"compactionSummary",
"user",
"user",
]);
expect(context.messages).toMatchObject([
{ summary: "older context" },
{ content: "retained" },
{ content: "new turn" },
]);
});
});
@@ -1,5 +1,3 @@
// Agent Core module implements session behavior.
import type { ImageContent, TextContent } from "../../../../llm-core/src/index.js";
import type { AgentMessage } from "../../types.js";
import {
asAgentMessage,
@@ -7,24 +5,9 @@ import {
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";
import type { CompactionEntry, SessionContext, SessionTreeEntry } from "../types.js";
/** Build model context from the active session branch and its latest state markers. */
/** Build model context from an ordered session branch and its latest state markers. */
export function buildSessionContext(pathEntries: SessionTreeEntry[]): SessionContext {
let thinkingLevel = "off";
let model: { provider: string; modelId: string } | null = null;
@@ -76,10 +59,10 @@ export function buildSessionContext(pathEntries: SessionTreeEntry[]): SessionCon
),
);
const compactionIdx = pathEntries.findIndex(
(e) => e.type === "compaction" && e.id === compaction.id,
(entry) => entry.type === "compaction" && entry.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.
// The synthetic summary replaces only history before the retained tail; newer branch
// entries must still replay or post-compaction turns disappear from model context.
let foundFirstKept = false;
for (const entry of pathEntries.slice(0, compactionIdx)) {
if (entry.id === compaction.firstKeptEntryId) {
@@ -100,190 +83,3 @@ export function buildSessionContext(pathEntries: SessionTreeEntry[]): SessionCon
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.replace(/[\r\n]+/g, " ").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);
}
}
@@ -1,284 +0,0 @@
// 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(-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.push(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;
}
path.reverse();
return path;
}
async getEntries(): Promise<SessionTreeEntry[]> {
return [...this.entries];
}
abstract setLeafId(leafId: string | null): Promise<void>;
abstract appendEntry(entry: SessionTreeEntry): Promise<void>;
}
@@ -1,17 +0,0 @@
/** 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;
}
@@ -1,8 +1,6 @@
// 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) {
@@ -24,8 +22,6 @@ export function uuidv7(): string {
sequence = new DataView(random.buffer, random.byteOffset + 6, 4).getUint32(0);
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++;
-14
View File
@@ -1,14 +0,0 @@
// 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);
}
@@ -1,13 +0,0 @@
import { describe, expect, it } from "vitest";
import { toError } from "./types.js";
describe("toError", () => {
it("preserves the shipped facade semantics", () => {
const thrown = { code: "E_TEST", detail: "structured" };
const error = toError(thrown);
expect(error.message).toBe('{"code":"E_TEST","detail":"structured"}');
expect(error).not.toHaveProperty("cause");
});
});
+14 -719
View File
@@ -1,169 +1,12 @@
// Agent Core type module defines shared TypeScript contracts.
import type { Result } from "@openclaw/normalization-core/result";
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";
import type { ImageContent, TextContent } from "@openclaw/llm-core";
import type { AgentMessage } from "../types.js";
export { err, ok } from "@openclaw/normalization-core/result";
export type { Result } from "@openclaw/normalization-core/result";
/**
* @deprecated Use `toErrorObject` from `@openclaw/normalization-core/error-coercion`.
* Kept through the next major release for the shipped agent-core plugin API.
*/
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));
}
}
type CompactionErrorCode = "aborted" | "summarization_failed" | "invalid_session" | "unknown";
/**
* 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) {
@@ -173,12 +16,9 @@ export class CompactionError extends Error {
}
}
/** Stable branch-summary error codes returned by branch summarization helpers. */
export type BranchSummaryErrorCode = "aborted" | "summarization_failed" | "invalid_session";
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) {
@@ -188,195 +28,30 @@ export class BranchSummaryError extends Error {
}
}
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. */
interface SessionTreeEntryBase {
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 {
interface MessageEntry extends SessionTreeEntryBase {
type: "message";
message: AgentMessage;
}
/** Persisted thinking-level selection marker. */
export interface ThinkingLevelChangeEntry extends SessionTreeEntryBase {
interface ThinkingLevelChangeEntry extends SessionTreeEntryBase {
type: "thinking_level_change";
thinkingLevel: string;
}
/** Persisted model selection marker. */
export interface ModelChangeEntry extends SessionTreeEntryBase {
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;
@@ -386,8 +61,7 @@ export interface CompactionEntry<T = unknown> extends SessionTreeEntryBase {
fromHook?: boolean;
}
/** Persisted summary of an abandoned branch when navigating the session tree. */
export interface BranchSummaryEntry<T = unknown> extends SessionTreeEntryBase {
interface BranchSummaryEntry<T = unknown> extends SessionTreeEntryBase {
type: "branch_summary";
fromId: string;
summary: string;
@@ -395,15 +69,13 @@ export interface BranchSummaryEntry<T = unknown> extends SessionTreeEntryBase {
fromHook?: boolean;
}
/** Persisted harness/application marker that is not replayed into model context. */
export interface CustomEntry<T = unknown> extends SessionTreeEntryBase {
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 {
interface CustomMessageEntry<T = unknown> extends SessionTreeEntryBase {
type: "custom_message";
customType: string;
content: string | (TextContent | ImageContent)[];
@@ -411,29 +83,23 @@ export interface CustomMessageEntry<T = unknown> extends SessionTreeEntryBase {
display: boolean;
}
/** Append-only label update for another session entry. */
export interface LabelEntry extends SessionTreeEntryBase {
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.
interface SessionInfoEntry extends SessionTreeEntryBase {
type: "session_info";
name?: string;
}
/** Append-only marker that changes the active visible leaf. */
export interface LeafEntry extends SessionTreeEntryBase {
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
@@ -452,385 +118,14 @@ export interface SessionContext {
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";
+9 -11
View File
@@ -1,29 +1,27 @@
// Public agent-core package surface: agent loop, harness, session storage,
// compaction, execution envs, and utility helpers.
// Public agent-core package surface: agent loop, compaction, session context,
// and the focused helpers consumed by OpenClaw.
export * from "./agent.js";
export * from "./agent-loop.js";
export * from "./errors.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 { buildSessionContext } from "./harness/session/session.js";
export { uuidv7 } from "./harness/session/uuid.js";
export type {
BranchSummaryResult,
FileOperations,
Result,
SessionTreeEntry,
} from "./harness/types.js";
export {
type BranchPreparation,
type BranchPathEntry,
type BranchSummaryDetails,
type CollectBranchPathEntriesResult,
type CollectEntriesResult,
collectEntriesForBranchSummary,
collectEntriesForBranchSummaryFromBranches,
generateBranchSummary,
prepareBranchEntries,
-3
View File
@@ -1,3 +0,0 @@
// Node-specific agent-core entrypoint with the default Node execution env.
export { NodeExecutionEnv } from "./harness/env/nodejs.js";
export * from "./index.js";
+1 -1
View File
@@ -1,5 +1,5 @@
import type { Model } from "@openclaw/llm-core";
import { describe, expect, it } from "vitest";
import type { Model } from "../../llm-core/src/index.js";
import { resolveAgentReasoningOption } from "./reasoning.js";
function makeModel(
+1 -1
View File
@@ -3,7 +3,7 @@ import {
resolveClaudeSonnet5ModelIdentity,
type Model,
type SimpleStreamOptions,
} from "../../llm-core/src/index.js";
} from "@openclaw/llm-core";
import type { ThinkingLevel } from "./types.js";
type EnabledThinkingLevel = Exclude<NonNullable<SimpleStreamOptions["reasoning"]>, "off">;
+1 -1
View File
@@ -1,5 +1,5 @@
// Agent Core module implements runtime deps behavior.
import type { CompleteSimpleFn, StreamFn } from "../../llm-core/src/index.js";
import type { CompleteSimpleFn, StreamFn } from "@openclaw/llm-core";
/** Runtime functions injected by host packages so agent-core stays provider-agnostic. */
export interface AgentCoreRuntimeDeps {
+1 -1
View File
@@ -1,4 +1,4 @@
import type { AssistantMessage, Model } from "../../llm-core/src/index.js";
import type { AssistantMessage, Model } from "@openclaw/llm-core";
import type { AgentEvent, AgentMessage } from "./types.js";
/** Canonical empty aborted/error assistant recorded when a run ends without output. */
+3 -3
View File
@@ -1,5 +1,3 @@
// Agent Core type module defines shared TypeScript contracts.
import type { Static, TSchema } from "typebox";
import type {
AssistantMessage,
AssistantMessageEvent,
@@ -11,7 +9,9 @@ import type {
TextContent,
Tool,
ToolResultMessage,
} from "../../llm-core/src/index.js";
} from "@openclaw/llm-core";
// Agent Core type module defines shared TypeScript contracts.
import type { Static, TSchema } from "typebox";
/**
* Stream function used by the agent loop.
+3
View File
@@ -2054,6 +2054,9 @@ importers:
'@openclaw/ai':
specifier: workspace:*
version: link:../ai
'@openclaw/llm-core':
specifier: workspace:*
version: link:../llm-core
'@openclaw/normalization-core':
specifier: workspace:*
version: link:../normalization-core
-8
View File
@@ -351,24 +351,16 @@ function buildAgentCoreDistEntries(): Record<string, string> {
agent: "packages/agent-core/src/agent.ts",
"agent-loop": "packages/agent-core/src/agent-loop.ts",
llm: "packages/agent-core/src/llm.ts",
node: "packages/agent-core/src/node.ts",
"runtime-deps": "packages/agent-core/src/runtime-deps.ts",
types: "packages/agent-core/src/types.ts",
validation: "packages/agent-core/src/validation.ts",
"harness/agent-harness": "packages/agent-core/src/harness/agent-harness.ts",
"harness/types": "packages/agent-core/src/harness/types.ts",
"harness/messages": "packages/agent-core/src/harness/messages.ts",
"harness/env/kill-tree": "packages/agent-core/src/harness/env/kill-tree.ts",
"harness/session": "packages/agent-core/src/harness/session/session.ts",
"harness/session/jsonl-storage": "packages/agent-core/src/harness/session/jsonl-storage.ts",
"harness/session/memory-storage": "packages/agent-core/src/harness/session/memory-storage.ts",
"harness/session/uuid": "packages/agent-core/src/harness/session/uuid.ts",
"harness/compaction": "packages/agent-core/src/harness/compaction/compaction.ts",
"harness/branch-summarization":
"packages/agent-core/src/harness/compaction/branch-summarization.ts",
"harness/prompt-template-arguments":
"packages/agent-core/src/harness/prompt-template-arguments.ts",
"harness/skills": "packages/agent-core/src/harness/skills.ts",
"harness/utils/truncate": "packages/agent-core/src/harness/utils/truncate.ts",
};
}