mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
refactor(agent-core): delete the unused session harness layer (#110287)
This commit is contained in:
committed by
GitHub
parent
2e70de9d32
commit
b6bac30e33
@@ -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!"],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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++;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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(
|
||||
|
||||
@@ -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,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,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. */
|
||||
|
||||
@@ -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.
|
||||
|
||||
Generated
+3
@@ -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
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user