Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled

Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

View File

@@ -0,0 +1,89 @@
{
"name": "@openclaw/acp-core",
"version": "0.0.0-private",
"private": true,
"files": [
"dist"
],
"type": "module",
"main": "./dist/index.mjs",
"types": "./dist/index.d.mts",
"exports": {
".": {
"types": "./dist/index.d.mts",
"import": "./dist/index.mjs",
"default": "./dist/index.mjs"
},
"./normalize-text": {
"types": "./dist/normalize-text.d.mts",
"import": "./dist/normalize-text.mjs",
"default": "./dist/normalize-text.mjs"
},
"./meta": {
"types": "./dist/meta.d.mts",
"import": "./dist/meta.mjs",
"default": "./dist/meta.mjs"
},
"./numeric-options": {
"types": "./dist/numeric-options.d.mts",
"import": "./dist/numeric-options.mjs",
"default": "./dist/numeric-options.mjs"
},
"./record-shared": {
"types": "./dist/record-shared.d.mts",
"import": "./dist/record-shared.mjs",
"default": "./dist/record-shared.mjs"
},
"./session": {
"types": "./dist/session.d.mts",
"import": "./dist/session.mjs",
"default": "./dist/session.mjs"
},
"./session-interaction-mode": {
"types": "./dist/session-interaction-mode.d.mts",
"import": "./dist/session-interaction-mode.mjs",
"default": "./dist/session-interaction-mode.mjs"
},
"./session-lineage-meta": {
"types": "./dist/session-lineage-meta.d.mts",
"import": "./dist/session-lineage-meta.mjs",
"default": "./dist/session-lineage-meta.mjs"
},
"./types": {
"types": "./dist/types.d.mts",
"import": "./dist/types.mjs",
"default": "./dist/types.mjs"
},
"./runtime/error-text": {
"types": "./dist/runtime/error-text.d.mts",
"import": "./dist/runtime/error-text.mjs",
"default": "./dist/runtime/error-text.mjs"
},
"./runtime/errors": {
"types": "./dist/runtime/errors.d.mts",
"import": "./dist/runtime/errors.mjs",
"default": "./dist/runtime/errors.mjs"
},
"./runtime/session-identifiers": {
"types": "./dist/runtime/session-identifiers.d.mts",
"import": "./dist/runtime/session-identifiers.mjs",
"default": "./dist/runtime/session-identifiers.mjs"
},
"./runtime/session-identity": {
"types": "./dist/runtime/session-identity.d.mts",
"import": "./dist/runtime/session-identity.mjs",
"default": "./dist/runtime/session-identity.mjs"
},
"./runtime/types": {
"types": "./dist/runtime/types.d.mts",
"import": "./dist/runtime/types.mjs",
"default": "./dist/runtime/types.mjs"
}
},
"dependencies": {
"@openclaw/normalization-core": "workspace:*"
},
"scripts": {
"build": "tsdown src/index.ts src/error-format.ts src/meta.ts src/normalize-text.ts src/numeric-options.ts src/record-shared.ts src/session.ts src/session-interaction-mode.ts src/session-lineage-meta.ts src/types.ts src/runtime/error-text.ts src/runtime/errors.ts src/runtime/session-identifiers.ts src/runtime/session-identity.ts src/runtime/types.ts --no-config --platform node --format esm --dts --out-dir dist --clean"
}
}

View File

@@ -0,0 +1,36 @@
// Error-format helper tests cover the non-Error cause stringifier contract.
import { describe, expect, it } from "vitest";
import {
configureAcpErrorRedactor,
redactSensitiveText,
stringifyNonErrorCause,
} from "./error-format.js";
describe("stringifyNonErrorCause", () => {
it("returns a string for values JSON.stringify serializes to undefined", () => {
// JSON.stringify(fn|symbol|undefined) is undefined; the `string`-typed helper must not leak it.
expect(stringifyNonErrorCause(() => {})).toBe("[object Function]");
expect(stringifyNonErrorCause(Symbol("x"))).toBe("[object Symbol]");
expect(stringifyNonErrorCause(undefined)).toBe("[object Undefined]");
});
it("stringifies ordinary scalar and object causes", () => {
expect(stringifyNonErrorCause({ a: 1 })).toBe('{"a":1}');
expect(stringifyNonErrorCause("hi")).toBe("hi");
expect(stringifyNonErrorCause(42)).toBe("42");
expect(stringifyNonErrorCause(null)).toBe("null");
});
});
describe("redactSensitiveText", () => {
it("applies fallback secret redaction after a configured redactor", () => {
configureAcpErrorRedactor((value) => value.replace("prefix", "host-redacted"));
try {
expect(redactSensitiveText("prefix ghp_123456789012345678901234")).toBe(
"host-redacted [REDACTED]",
);
} finally {
configureAcpErrorRedactor(undefined);
}
});
});

View File

@@ -0,0 +1,81 @@
// ACP Core helper module supports error format behavior.
const SECRET_PATTERNS: RegExp[] = [
/\b[A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|CARD[_-]?NUMBER|CARD[_-]?CVC|CARD[_-]?CVV|CVC|CVV|SECURITY[_-]?CODE|PAYMENT[_-]?CREDENTIAL|SHARED[_-]?PAYMENT[_-]?TOKEN)\b\s*[=:]\s*(["']?)([^\s"'\\]+)\1/g,
/\b[A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|CARD[_-]?NUMBER|CARD[_-]?CVC|CARD[_-]?CVV|CVC|CVV|SECURITY[_-]?CODE|PAYMENT[_-]?CREDENTIAL|SHARED[_-]?PAYMENT[_-]?TOKEN)\b\s*[=:]\s*\\+(["'])([^\s"'\\]+)\\+\1/g,
/[?&](?:access[-_]?token|auth[-_]?token|hook[-_]?token|refresh[-_]?token|api[-_]?key|client[-_]?secret|token|key|secret|password|pass|passwd|auth|signature|card[-_]?number|card[-_]?cvc|card[-_]?cvv|cvc|cvv|security[-_]?code|payment[-_]?credential|shared[-_]?payment[-_]?token)=([^&\s"'<>]+)/gi,
/"(?:apiKey|token|secret|password|passwd|accessToken|refreshToken|cardNumber|card_number|cardCvc|card_cvc|cardCvv|card_cvv|cvc|cvv|securityCode|security_code|paymentCredential|payment_credential|sharedPaymentToken|shared_payment_token)"\s*:\s*"([^"]+)"/g,
/(^|[\s,{])["']?(?:api[-_]key|access[-_]token|refresh[-_]token|authToken|auth[-_]token|clientSecret|client[-_]secret|appSecret|app[-_]secret)["']?\s*[:=]\s*(["'])([^"'\r\n]+)\2/gi,
/(^|[\s,{])["']?(?:authorization|proxy-authorization|cookie|set-cookie|x-api-key|x-auth-token)["']?\s*[:=]\s*(["'])([^"'\r\n]+)\2/gi,
/--(?:api[-_]?key|hook[-_]?token|token|secret|password|passwd|card[-_]?number|card[-_]?cvc|card[-_]?cvv|cvc|cvv|security[-_]?code|payment[-_]?credential|shared[-_]?payment[-_]?token)\s+(["']?)([^\s"']+)\1/gi,
/Authorization\s*[:=]\s*Bearer\s+([A-Za-z0-9._\-+=]+)/gi,
/Authorization\s*[:=]\s*Basic\s+([A-Za-z0-9+/=]+)/gi,
/(?:X-OpenClaw-Token|x-pomerium-jwt-assertion|X-Api-Key|X-Auth-Token)\s*[:=]\s*([^\s"',;]+)/gi,
/\bBearer\s+([A-Za-z0-9._\-+=]{18,})\b/g,
/(^|[\s,;])(?:access_token|refresh_token|auth[-_]?token|api[-_]?key|client[-_]?secret|app[-_]?secret|token|secret|password|passwd|card[-_]?number|card[-_]?cvc|card[-_]?cvv|cvc|cvv|security[-_]?code|payment[-_]?credential|shared[-_]?payment[-_]?token)=([^\s&#]+)/gi,
/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]+?-----END [A-Z ]*PRIVATE KEY-----/g,
/\b(sk-[A-Za-z0-9_-]{8,})\b/g,
/(ghp_[A-Za-z0-9]{20,})/g,
/(github_pat_[A-Za-z0-9_]{20,})/g,
/(xox[baprs]-[A-Za-z0-9-]{10,})/g,
/(xapp-[A-Za-z0-9-]{10,})/g,
/(gsk_[A-Za-z0-9_-]{10,})/g,
/(AIza[0-9A-Za-z\-_]{20,})/g,
/(ya29\.[0-9A-Za-z_\-./+=]{10,})/g,
/(1\/\/0[0-9A-Za-z_\-./+=]{10,})/g,
/(eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,})/g,
/(pplx-[A-Za-z0-9_-]{10,})/g,
/(npm_[A-Za-z0-9]{10,})/g,
/(AKID[A-Za-z0-9]{10,})/g,
/(LTAI[A-Za-z0-9]{10,})/g,
/(hf_[A-Za-z0-9]{10,})/g,
/(r8_[A-Za-z0-9]{10,})/g,
/\bbot(\d{6,}:[A-Za-z0-9_-]{20,})\b/g,
/\b(\d{6,}:[A-Za-z0-9_-]{20,})\b/g,
];
let configuredRedactor: ((value: string) => string) | undefined;
/** Installs a host-provided redactor used before ACP fallback secret-pattern redaction. */
export function configureAcpErrorRedactor(redactor: ((value: string) => string) | undefined): void {
configuredRedactor = redactor;
}
/** Redacts common provider, GitHub, HTTP, payment, bot, and private-key secrets from error text. */
export function redactSensitiveText(value: string): string {
let redacted = configuredRedactor ? configuredRedactor(value) : value;
for (const pattern of SECRET_PATTERNS) {
redacted = redacted.replace(pattern, (match, ...args: string[]) => {
if (match.includes("PRIVATE KEY-----")) {
return "[REDACTED_PRIVATE_KEY]";
}
const groups = args.slice(0, -2);
// Replace only the captured secret when possible so surrounding diagnostics stay useful.
const token = groups.findLast((group) => typeof group === "string" && group.length > 0);
return token ? match.replace(token, "[REDACTED]") : "[REDACTED]";
});
}
return redacted;
}
/**
* Render a non-Error `cause` value without leaking `[object Object]` or throwing
* while formatting nested ACP runtime failures.
*/
export function stringifyNonErrorCause(value: unknown): string {
if (value === null) {
return "null";
}
if (typeof value === "string") {
return value;
}
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
return String(value);
}
try {
// JSON.stringify returns undefined (not a string) for functions/symbols/undefined; fall back to
// a tag string so this `string`-typed helper never leaks undefined (matches src/infra/errors.ts).
return JSON.stringify(value) ?? Object.prototype.toString.call(value);
} catch {
return Object.prototype.toString.call(value);
}
}

View File

@@ -0,0 +1,16 @@
// Public barrel for shared ACP session, metadata, and runtime helper contracts.
export * from "./error-format.js";
export * from "./meta.js";
export * from "./normalize-text.js";
export * from "./numeric-options.js";
export * from "./record-shared.js";
export * from "./session-interaction-mode.js";
export * from "./session-lineage-meta.js";
export * from "./session.js";
export * from "./types.js";
export * from "./runtime/error-text.js";
export * from "./runtime/errors.js";
export * from "./runtime/session-identifiers.js";
export * from "./runtime/session-identity.js";
export * from "./runtime/types.js";

View File

@@ -0,0 +1,25 @@
// ACP Core tests cover meta behavior.
import { describe, expect, it } from "vitest";
import { readBool, readNonNegativeInteger, readNumber, readString } from "./meta.js";
describe("ACP metadata readers", () => {
it("returns the first normalized string value", () => {
expect(readString({ old: " ", current: " session-1 " }, ["old", "current"])).toBe("session-1");
});
it("preserves false boolean values", () => {
expect(readBool({ enabled: false, fallback: true }, ["enabled", "fallback"])).toBe(false);
});
it("accepts finite numbers and rejects non-numeric values", () => {
expect(readNumber({ first: "1", second: 0 }, ["first", "second"])).toBe(0);
expect(readNumber({ first: Number.POSITIVE_INFINITY }, ["first"])).toBeUndefined();
});
it("accepts zero as a non-negative integer", () => {
expect(readNonNegativeInteger({ count: 0, fallback: 2 }, ["count", "fallback"])).toBe(0);
expect(
readNonNegativeInteger({ count: -1, fallback: 2.5 }, ["count", "fallback"]),
).toBeUndefined();
});
});

View File

@@ -0,0 +1,55 @@
// ACP Core module implements meta behavior.
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
function readMetaValue<T>(
meta: Record<string, unknown> | null | undefined,
keys: string[],
normalize: (value: unknown) => T | undefined,
): T | undefined {
if (!meta) {
return undefined;
}
for (const key of keys) {
const normalized = normalize(meta[key]);
if (normalized !== undefined) {
return normalized;
}
}
return undefined;
}
/** Reads the first present string metadata value from a current-to-legacy key list. */
export function readString(
meta: Record<string, unknown> | null | undefined,
keys: string[],
): string | undefined {
return readMetaValue(meta, keys, normalizeOptionalString);
}
/** Reads the first boolean metadata value without dropping false. */
export function readBool(
meta: Record<string, unknown> | null | undefined,
keys: string[],
): boolean | undefined {
return readMetaValue(meta, keys, (value) => (typeof value === "boolean" ? value : undefined));
}
/** Reads the first finite numeric metadata value from a current-to-legacy key list. */
export function readNumber(
meta: Record<string, unknown> | null | undefined,
keys: string[],
): number | undefined {
return readMetaValue(meta, keys, (value) =>
typeof value === "number" && Number.isFinite(value) ? value : undefined,
);
}
/** Reads the first safe non-negative integer metadata value, preserving zero. */
export function readNonNegativeInteger(
meta: Record<string, unknown> | null | undefined,
keys: string[],
): number | undefined {
return readMetaValue(meta, keys, (value) =>
typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : undefined,
);
}

View File

@@ -0,0 +1,3 @@
// ACP text normalization facade shared with older imports.
export { normalizeOptionalString as normalizeText } from "@openclaw/normalization-core/string-coerce";

View File

@@ -0,0 +1,11 @@
// ACP Core module implements numeric options behavior.
import { resolveIntegerOption as resolveSharedIntegerOption } from "@openclaw/normalization-core/number-coercion";
/** Resolves ACP integer options through the shared normalization contract. */
export function resolveIntegerOption(
value: number | undefined,
fallback: number,
params: { min: number },
): number {
return resolveSharedIntegerOption(value, fallback, params);
}

View File

@@ -0,0 +1,3 @@
// ACP record normalization facade shared with older imports.
export { asOptionalRecord as asRecord } from "@openclaw/normalization-core/record-coerce";

View File

@@ -0,0 +1,67 @@
// ACP Core tests cover error text behavior.
import { describe, expect, it } from "vitest";
import { formatAcpRuntimeErrorText, toAcpRuntimeErrorText } from "./error-text.js";
import { AcpRuntimeError, toAcpRuntimeError } from "./errors.js";
describe("formatAcpRuntimeErrorText", () => {
it("adds actionable next steps for known ACP runtime error codes", () => {
const text = formatAcpRuntimeErrorText(
new AcpRuntimeError("ACP_BACKEND_MISSING", "backend missing"),
);
expect(text).toBe(
"ACP error (ACP_BACKEND_MISSING): backend missing\nnext: Run `/acp doctor`, install/enable the backend plugin, then retry.",
);
});
it("returns consistent ACP error envelope for runtime failures", () => {
const text = formatAcpRuntimeErrorText(new AcpRuntimeError("ACP_TURN_FAILED", "turn failed"));
expect(text).toBe(
"ACP error (ACP_TURN_FAILED): turn failed\nnext: Retry, or use `/acp cancel` and send the message again.",
);
});
it("surfaces redacted numeric RequestError details in runtime failure text", () => {
const token = "sk-abcdefghijklmnopqrstuvwxyz123456";
const requestError = Object.assign(new Error("Internal error"), {
name: "RequestError",
code: -32603,
data: {
details: `Unknown config option: timeout; token=${token}`,
},
});
const text = formatAcpRuntimeErrorText(
toAcpRuntimeError({
error: requestError,
fallbackCode: "ACP_TURN_FAILED",
fallbackMessage: "fallback",
}),
);
expect(text).toContain(
"ACP error (ACP_TURN_FAILED): Internal error: Unknown config option: timeout",
);
expect(text).toContain("next: Retry");
expect(text).not.toContain(token);
});
it("applies the same RequestError details normalization through text conversion", () => {
const requestError = Object.assign(new Error("Internal error"), {
name: "RequestError",
code: -32603,
data: {
details: "Unknown config option: timeout",
},
});
const text = toAcpRuntimeErrorText({
error: requestError,
fallbackCode: "ACP_TURN_FAILED",
fallbackMessage: "fallback",
});
expect(text).toContain(
"ACP error (ACP_TURN_FAILED): Internal error: Unknown config option: timeout",
);
});
});

View File

@@ -0,0 +1,48 @@
// ACP Core module implements error text behavior.
import { type AcpRuntimeErrorCode, AcpRuntimeError, toAcpRuntimeError } from "./errors.js";
function resolveAcpRuntimeErrorNextStep(error: AcpRuntimeError): string | undefined {
if (error.code === "ACP_BACKEND_MISSING" || error.code === "ACP_BACKEND_UNAVAILABLE") {
return "Run `/acp doctor`, install/enable the backend plugin, then retry.";
}
if (error.code === "ACP_DISPATCH_DISABLED") {
return "Enable `acp.dispatch.enabled=true` to allow thread-message ACP turns.";
}
if (error.code === "ACP_SESSION_INIT_FAILED") {
return "If this session is stale, recreate it with `/acp spawn` and rebind the thread.";
}
if (error.code === "ACP_INVALID_RUNTIME_OPTION") {
return "Use `/acp status` to inspect options and pass valid values.";
}
if (error.code === "ACP_BACKEND_UNSUPPORTED_CONTROL") {
return "This backend does not support that control; use a supported command.";
}
if (error.code === "ACP_TURN_FAILED") {
return "Retry, or use `/acp cancel` and send the message again.";
}
return undefined;
}
/** Formats ACP runtime errors with the operator next-step hint attached when known. */
export function formatAcpRuntimeErrorText(error: AcpRuntimeError): string {
const next = resolveAcpRuntimeErrorNextStep(error);
if (!next) {
return `ACP error (${error.code}): ${error.message}`;
}
return `ACP error (${error.code}): ${error.message}\nnext: ${next}`;
}
/** Normalizes unknown failures into ACP runtime error text for user-facing surfaces. */
export function toAcpRuntimeErrorText(params: {
error: unknown;
fallbackCode: AcpRuntimeErrorCode;
fallbackMessage: string;
}): string {
return formatAcpRuntimeErrorText(
toAcpRuntimeError({
error: params.error,
fallbackCode: params.fallbackCode,
fallbackMessage: params.fallbackMessage,
}),
);
}

View File

@@ -0,0 +1,192 @@
// ACP Core tests cover errors behavior.
import { afterEach, describe, expect, it } from "vitest";
import { configureAcpErrorRedactor } from "../error-format.js";
import {
AcpRuntimeError,
formatAcpErrorChain,
isAcpRuntimeError,
toAcpRuntimeError,
withAcpRuntimeErrorBoundary,
} from "./errors.js";
async function expectRejectedAcpRuntimeError(promise: Promise<unknown>): Promise<AcpRuntimeError> {
try {
await promise;
} catch (error) {
expect(error).toBeInstanceOf(AcpRuntimeError);
return error as AcpRuntimeError;
}
throw new Error("expected ACP runtime error rejection");
}
afterEach(() => {
configureAcpErrorRedactor(undefined);
});
describe("withAcpRuntimeErrorBoundary", () => {
it("wraps generic errors with fallback code and source message", async () => {
const sourceError = new Error("boom");
const error = await expectRejectedAcpRuntimeError(
withAcpRuntimeErrorBoundary({
run: async () => {
throw sourceError;
},
fallbackCode: "ACP_TURN_FAILED",
fallbackMessage: "fallback",
}),
);
expect(error.name).toBe("AcpRuntimeError");
expect(error.code).toBe("ACP_TURN_FAILED");
expect(error.message).toBe("boom");
expect(error.cause).toBe(sourceError);
});
it("passes through existing ACP runtime errors", async () => {
const existing = new AcpRuntimeError("ACP_BACKEND_MISSING", "backend missing");
await expect(
withAcpRuntimeErrorBoundary({
run: async () => {
throw existing;
},
fallbackCode: "ACP_TURN_FAILED",
fallbackMessage: "fallback",
}),
).rejects.toBe(existing);
});
it("preserves ACP runtime codes from foreign package errors", async () => {
class ForeignAcpRuntimeError extends Error {
readonly code = "ACP_BACKEND_MISSING" as const;
}
const foreignError = new ForeignAcpRuntimeError("backend missing");
const error = await expectRejectedAcpRuntimeError(
withAcpRuntimeErrorBoundary({
run: async () => {
throw foreignError;
},
fallbackCode: "ACP_TURN_FAILED",
fallbackMessage: "fallback",
}),
);
expect(error.name).toBe("AcpRuntimeError");
expect(error.code).toBe("ACP_BACKEND_MISSING");
expect(error.message).toBe("backend missing");
expect(error.cause).toBe(foreignError);
expect(isAcpRuntimeError(foreignError)).toBe(true);
});
it("preserves redacted RequestError details from numeric ACP errors", () => {
const token = "sk-abcdefghijklmnopqrstuvwxyz123456";
const requestError = Object.assign(new Error("Internal error"), {
name: "RequestError",
code: -32603,
data: {
details: `unknown config option: timeout; token=${token}`,
},
});
const error = toAcpRuntimeError({
error: requestError,
fallbackCode: "ACP_TURN_FAILED",
fallbackMessage: "fallback",
});
expect(error.code).toBe("ACP_TURN_FAILED");
expect(error.message).toContain("Internal error: unknown config option: timeout");
expect(error.message).not.toContain(token);
expect(error.cause).toBe(requestError);
});
it("keeps foreign OpenClaw ACP string code behavior unchanged", () => {
const foreignError = Object.assign(new Error("backend missing"), {
code: "ACP_BACKEND_MISSING",
data: {
details: "extra backend diagnostic",
},
});
const error = toAcpRuntimeError({
error: foreignError,
fallbackCode: "ACP_TURN_FAILED",
fallbackMessage: "fallback",
});
expect(error.code).toBe("ACP_BACKEND_MISSING");
expect(error.message).toBe("backend missing");
expect(error.cause).toBe(foreignError);
});
it("keeps generic non-RequestError messages unchanged", () => {
const sourceError = Object.assign(new Error("boom"), {
data: {
details: "extra diagnostic",
},
});
const error = toAcpRuntimeError({
error: sourceError,
fallbackCode: "ACP_TURN_FAILED",
fallbackMessage: "fallback",
});
expect(error.code).toBe("ACP_TURN_FAILED");
expect(error.message).toBe("boom");
expect(error.cause).toBe(sourceError);
});
});
describe("formatAcpErrorChain redaction", () => {
it("redacts secret-shaped tokens that arrive as top-level non-Error values", () => {
const token = "sk-abcdefghijklmnopqrstuvwxyz123456";
const out = formatAcpErrorChain(`upstream rejected token=${token}`);
expect(out).toMatch(/upstream rejected/);
expect(out).not.toContain(token);
});
it("redacts secret-shaped tokens that arrive in nested cause messages", () => {
const token = "sk-abcdefghijklmnopqrstuvwxyz123456";
const inner = new Error(`upstream rejected token=${token}`);
const acp = new AcpRuntimeError("ACP_TURN_FAILED", "ACP turn failed", { cause: inner });
const out = formatAcpErrorChain(acp);
expect(out).toMatch(/ACP_TURN_FAILED/);
expect(out).toMatch(/upstream rejected/);
expect(out).not.toContain(token);
});
it("redacts common HTTP, provider, and private-key credentials in ACP error text", () => {
const secrets = [
"Authorization: Basic dXNlcjpwYXNzd29yZGFiY2RlZg==",
"Bearer eyJabcdefghijklmnopqrstuvwxyz.abcdefghijklmnopqrstuvwxyz.abcdefghijklmnopqrstuvwxyz",
"github_pat_abcdefghijklmnopqrstuvwxyz123456",
["xoxb", "1234567890", "abcdefghijklmnop"].join("-"),
"bot123456789:abcdefghijklmnopqrstuvwxyz123456",
"-----BEGIN PRIVATE KEY-----\nabcdefghijklmnopqrstuvwxyz\n-----END PRIVATE KEY-----",
];
const out = formatAcpErrorChain(
new AcpRuntimeError("ACP_TURN_FAILED", `backend failed: ${secrets.join(" ")}`),
);
for (const secret of secrets) {
expect(out).not.toContain(secret);
}
expect(out).toContain("backend failed");
});
it("uses a configured host redactor before rendering ACP error text", () => {
configureAcpErrorRedactor((value) => value.replaceAll("custom-secret", "[CUSTOM]"));
const out = formatAcpErrorChain(new AcpRuntimeError("ACP_TURN_FAILED", "custom-secret"));
expect(out).toContain("[CUSTOM]");
expect(out).not.toContain("custom-secret");
});
});

View File

@@ -0,0 +1,168 @@
// ACP Core module implements errors behavior.
import { redactSensitiveText, stringifyNonErrorCause } from "../error-format.js";
export const ACP_ERROR_CODES = [
"ACP_BACKEND_MISSING",
"ACP_BACKEND_UNAVAILABLE",
"ACP_BACKEND_UNSUPPORTED_CONTROL",
"ACP_DISPATCH_DISABLED",
"ACP_INVALID_RUNTIME_OPTION",
"ACP_SESSION_INIT_FAILED",
"ACP_TURN_FAILED",
] as const;
export type AcpRuntimeErrorCode = (typeof ACP_ERROR_CODES)[number];
const ACP_ERROR_CODE_SET = new Set<AcpRuntimeErrorCode>(ACP_ERROR_CODES);
/** Error type used at ACP runtime boundaries so callers can preserve structured failure codes. */
export class AcpRuntimeError extends Error {
readonly code: AcpRuntimeErrorCode;
/**
* Backend-specific structured failure code (e.g. acpx "SESSION_RESUME_REQUIRED"),
* preserved so recovery decisions key on the failure kind rather than parsing
* the human-readable message.
*/
readonly detailCode?: string;
override readonly cause?: unknown;
constructor(
code: AcpRuntimeErrorCode,
message: string,
options?: { cause?: unknown; detailCode?: string },
) {
super(message);
this.name = "AcpRuntimeError";
this.code = code;
this.detailCode = options?.detailCode;
this.cause = options?.cause;
}
}
function getForeignAcpRuntimeError(value: unknown): {
code: AcpRuntimeErrorCode;
message: string;
} | null {
if (!(value instanceof Error)) {
return null;
}
const code = (value as { code?: unknown }).code;
if (typeof code !== "string" || !ACP_ERROR_CODE_SET.has(code as AcpRuntimeErrorCode)) {
return null;
}
return {
code: code as AcpRuntimeErrorCode,
message: value.message,
};
}
function readAcpRequestErrorDetails(value: Error): string | undefined {
const code = (value as { code?: unknown }).code;
if (typeof code !== "number") {
return undefined;
}
const data = (value as { data?: unknown }).data;
if (!data || typeof data !== "object") {
return undefined;
}
const details = (data as { details?: unknown }).details;
if (details === undefined || details === null) {
return undefined;
}
const rendered = redactSensitiveText(stringifyNonErrorCause(details)).trim();
return rendered.length > 0 ? rendered : undefined;
}
function messageWithAcpRequestErrorDetails(error: Error): string {
const details = readAcpRequestErrorDetails(error);
if (!details || error.message.includes(details)) {
return error.message;
}
return `${error.message}: ${details}`;
}
/** Recognizes local and cross-realm ACP runtime errors by their stable error code. */
export function isAcpRuntimeError(value: unknown): value is AcpRuntimeError {
return value instanceof AcpRuntimeError || getForeignAcpRuntimeError(value) !== null;
}
/** Converts arbitrary thrown values into ACP runtime errors with redacted request details. */
export function toAcpRuntimeError(params: {
error: unknown;
fallbackCode: AcpRuntimeErrorCode;
fallbackMessage: string;
}): AcpRuntimeError {
if (params.error instanceof AcpRuntimeError) {
return params.error;
}
const foreignAcpRuntimeError = getForeignAcpRuntimeError(params.error);
if (foreignAcpRuntimeError) {
return new AcpRuntimeError(foreignAcpRuntimeError.code, foreignAcpRuntimeError.message, {
cause: params.error,
});
}
if (params.error instanceof Error) {
return new AcpRuntimeError(
params.fallbackCode,
messageWithAcpRequestErrorDetails(params.error),
{
cause: params.error,
},
);
}
return new AcpRuntimeError(params.fallbackCode, params.fallbackMessage, {
cause: params.error,
});
}
/**
* Render an error and its `.cause` chain as a single human-readable line for
* logs, lifecycle events, and tool results. Format is
* `Name [code]: message <- Name [code]: message <- ...`. Number codes also
* appear, so JSON-RPC error codes like `-32603` survive into surfaces that
* downstream consumers see (gateway logs, telegram replies, tool_result text).
*
* Depth is capped to defend against self-referential `.cause` cycles.
*/
export function formatAcpErrorChain(error: unknown): string {
if (!(error instanceof Error)) {
return redactSensitiveText(String(error));
}
const segments: string[] = [renderSingleError(error)];
let current: unknown = (error as unknown as { cause?: unknown }).cause;
let depth = 0;
while (current !== undefined && current !== null && depth < 8) {
if (current instanceof Error) {
segments.push(renderSingleError(current));
current = (current as unknown as { cause?: unknown }).cause;
} else {
segments.push(stringifyNonErrorCause(current));
current = undefined;
}
depth += 1;
}
return redactSensitiveText(segments.join(" <- "));
}
function renderSingleError(error: Error): string {
const codeValue = (error as unknown as { code?: unknown }).code;
const codeSuffix =
typeof codeValue === "string" || typeof codeValue === "number" ? ` [${codeValue}]` : "";
return `${error.name}${codeSuffix}: ${error.message}`;
}
/** Wraps async runtime work and rethrows failures as ACP runtime errors. */
export async function withAcpRuntimeErrorBoundary<T>(params: {
run: () => Promise<T>;
fallbackCode: AcpRuntimeErrorCode;
fallbackMessage: string;
}): Promise<T> {
try {
return await params.run();
} catch (error) {
throw toAcpRuntimeError({
error,
fallbackCode: params.fallbackCode,
fallbackMessage: params.fallbackMessage,
});
}
}

View File

@@ -0,0 +1,117 @@
// ACP Core tests cover session identifiers behavior.
import { describe, expect, it } from "vitest";
import {
resolveAcpSessionCwd,
resolveAcpSessionIdentifierLinesFromIdentity,
resolveAcpThreadSessionDetailLines,
} from "./session-identifiers.js";
describe("session identifier helpers", () => {
it("hides unresolved identifiers from thread intro details while pending", () => {
const lines = resolveAcpThreadSessionDetailLines({
sessionKey: "agent:codex:acp:pending-1",
meta: {
backend: "acpx",
agent: "codex",
runtimeSessionName: "runtime-1",
identity: {
state: "pending",
source: "ensure",
lastUpdatedAt: Date.now(),
acpxSessionId: "acpx-123",
agentSessionId: "inner-123",
},
mode: "persistent",
state: "idle",
lastActivityAt: Date.now(),
},
});
expect(lines).toStrictEqual([]);
});
it("adds a Codex resume hint when agent identity is resolved", () => {
const lines = resolveAcpThreadSessionDetailLines({
sessionKey: "agent:codex:acp:resolved-1",
meta: {
backend: "acpx",
agent: "codex",
runtimeSessionName: "runtime-1",
identity: {
state: "resolved",
source: "status",
lastUpdatedAt: Date.now(),
acpxSessionId: "acpx-123",
agentSessionId: "inner-123",
},
mode: "persistent",
state: "idle",
lastActivityAt: Date.now(),
},
});
expect(lines).toStrictEqual([
"agent session id: inner-123",
"acpx session id: acpx-123",
"resume in Codex CLI: `codex resume inner-123` (continues this conversation).",
]);
});
it("adds a Kimi resume hint when agent identity is resolved", () => {
const lines = resolveAcpThreadSessionDetailLines({
sessionKey: "agent:kimi:acp:resolved-1",
meta: {
backend: "acpx",
agent: "kimi",
runtimeSessionName: "runtime-1",
identity: {
state: "resolved",
source: "status",
lastUpdatedAt: Date.now(),
acpxSessionId: "acpx-kimi-123",
agentSessionId: "kimi-inner-123",
},
mode: "persistent",
state: "idle",
lastActivityAt: Date.now(),
},
});
expect(lines).toStrictEqual([
"agent session id: kimi-inner-123",
"acpx session id: acpx-kimi-123",
"resume in Kimi CLI: `kimi resume kimi-inner-123` (continues this conversation).",
]);
});
it("shows pending identity text for status rendering", () => {
const lines = resolveAcpSessionIdentifierLinesFromIdentity({
backend: "acpx",
mode: "status",
identity: {
state: "pending",
source: "status",
lastUpdatedAt: Date.now(),
agentSessionId: "inner-123",
},
});
expect(lines).toEqual(["session ids: pending (available after the first reply)"]);
});
it("prefers runtimeOptions.cwd over legacy meta.cwd", () => {
const cwd = resolveAcpSessionCwd({
backend: "acpx",
agent: "codex",
runtimeSessionName: "runtime-1",
mode: "persistent",
runtimeOptions: {
cwd: "/repo/new",
},
cwd: "/repo/old",
state: "idle",
lastActivityAt: Date.now(),
});
expect(cwd).toBe("/repo/new");
});
});

View File

@@ -0,0 +1,142 @@
// ACP Core module implements session identifiers behavior.
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
import { normalizeText } from "../normalize-text.js";
import type { SessionAcpIdentity, SessionAcpMeta } from "../types.js";
import { isSessionIdentityPending, resolveSessionIdentityFromMeta } from "./session-identity.js";
export const ACP_SESSION_IDENTITY_RENDERER_VERSION = "v1";
export type AcpSessionIdentifierRenderMode = "status" | "thread";
type SessionResumeHintResolver = (params: { agentSessionId: string }) => string;
const ACP_AGENT_RESUME_HINT_BY_KEY = new Map<string, SessionResumeHintResolver>([
[
"codex",
({ agentSessionId }) =>
`resume in Codex CLI: \`codex resume ${agentSessionId}\` (continues this conversation).`,
],
[
"openai",
({ agentSessionId }) =>
`resume in Codex CLI: \`codex resume ${agentSessionId}\` (continues this conversation).`,
],
[
"codex-cli",
({ agentSessionId }) =>
`resume in Codex CLI: \`codex resume ${agentSessionId}\` (continues this conversation).`,
],
[
"kimi",
({ agentSessionId }) =>
`resume in Kimi CLI: \`kimi resume ${agentSessionId}\` (continues this conversation).`,
],
[
"moonshot-kimi",
({ agentSessionId }) =>
`resume in Kimi CLI: \`kimi resume ${agentSessionId}\` (continues this conversation).`,
],
]);
function normalizeAgentHintKey(value: unknown): string | undefined {
const normalized = normalizeText(value);
if (!normalized) {
return undefined;
}
return normalizeLowercaseStringOrEmpty(normalized).replace(/[\s_]+/g, "-");
}
function resolveAcpAgentResumeHintLine(params: {
agentId?: string;
agentSessionId?: string;
}): string | undefined {
const agentSessionId = normalizeText(params.agentSessionId);
const agentKey = normalizeAgentHintKey(params.agentId);
if (!agentSessionId || !agentKey) {
return undefined;
}
const resolver = ACP_AGENT_RESUME_HINT_BY_KEY.get(agentKey);
return resolver ? resolver({ agentSessionId }) : undefined;
}
/** Renders status-safe ACP session identifier lines from persisted session metadata. */
export function resolveAcpSessionIdentifierLines(params: {
sessionKey: string;
meta?: SessionAcpMeta;
}): string[] {
const backend = normalizeText(params.meta?.backend) ?? "backend";
const identity = resolveSessionIdentityFromMeta(params.meta);
return resolveAcpSessionIdentifierLinesFromIdentity({
backend,
identity,
mode: "status",
});
}
/** Renders resolved ACP backend/agent ids, hiding pending ids from thread intros. */
export function resolveAcpSessionIdentifierLinesFromIdentity(params: {
backend: string;
identity?: SessionAcpIdentity;
mode?: AcpSessionIdentifierRenderMode;
}): string[] {
const backend = normalizeText(params.backend) ?? "backend";
const mode = params.mode ?? "status";
const identity = params.identity;
const agentSessionId = normalizeText(identity?.agentSessionId);
const acpxSessionId = normalizeText(identity?.acpxSessionId);
const acpxRecordId = normalizeText(identity?.acpxRecordId);
const hasIdentifier = Boolean(agentSessionId || acpxSessionId || acpxRecordId);
if (isSessionIdentityPending(identity) && hasIdentifier) {
// Status views explain that ids are still settling; thread intros stay quiet so
// users do not copy provisional backend ids before the first reply resolves them.
if (mode === "status") {
return ["session ids: pending (available after the first reply)"];
}
return [];
}
const lines: string[] = [];
if (agentSessionId) {
lines.push(`agent session id: ${agentSessionId}`);
}
if (acpxSessionId) {
lines.push(`${backend} session id: ${acpxSessionId}`);
}
if (acpxRecordId) {
lines.push(`${backend} record id: ${acpxRecordId}`);
}
return lines;
}
/** Resolves the runtime cwd, preferring modern runtimeOptions over legacy metadata. */
export function resolveAcpSessionCwd(meta?: SessionAcpMeta): string | undefined {
const runtimeCwd = normalizeText(meta?.runtimeOptions?.cwd);
if (runtimeCwd) {
return runtimeCwd;
}
return normalizeText(meta?.cwd);
}
/** Renders thread-detail identifier lines plus a backend-specific resume hint when stable. */
export function resolveAcpThreadSessionDetailLines(params: {
sessionKey: string;
meta?: SessionAcpMeta;
}): string[] {
const meta = params.meta;
const identity = resolveSessionIdentityFromMeta(meta);
const backend = normalizeText(meta?.backend) ?? "backend";
const lines = resolveAcpSessionIdentifierLinesFromIdentity({
backend,
identity,
mode: "thread",
});
if (lines.length === 0) {
return lines;
}
const hint = resolveAcpAgentResumeHintLine({
agentId: meta?.agent,
agentSessionId: identity?.agentSessionId,
});
if (hint) {
lines.push(hint);
}
return lines;
}

View File

@@ -0,0 +1,261 @@
// ACP Core module implements session identity behavior.
import { normalizeText } from "../normalize-text.js";
import type { SessionAcpIdentity, SessionAcpIdentitySource, SessionAcpMeta } from "../types.js";
import type { AcpRuntimeHandle, AcpRuntimeStatus } from "./types.js";
// ACP session identity merge and extraction helpers for resume-safe runtime state.
/** Normalize a stored identity state value from metadata. */
function normalizeIdentityState(value: unknown): SessionAcpIdentity["state"] | undefined {
if (value !== "pending" && value !== "resolved") {
return undefined;
}
return value;
}
/** Normalize where an ACP identity observation came from. */
function normalizeIdentitySource(value: unknown): SessionAcpIdentitySource | undefined {
if (value !== "ensure" && value !== "status" && value !== "event") {
return undefined;
}
return value;
}
/** Normalize an identity object and infer pending/resolved state from stable ids. */
function normalizeIdentity(
identity: SessionAcpIdentity | undefined,
): SessionAcpIdentity | undefined {
if (!identity) {
return undefined;
}
const state = normalizeIdentityState(identity.state);
const source = normalizeIdentitySource(identity.source);
const acpxRecordId = normalizeText(identity.acpxRecordId);
const acpxSessionId = normalizeText(identity.acpxSessionId);
const agentSessionId = normalizeText(identity.agentSessionId);
const lastUpdatedAt =
typeof identity.lastUpdatedAt === "number" && Number.isFinite(identity.lastUpdatedAt)
? identity.lastUpdatedAt
: undefined;
const hasAnyId = Boolean(acpxRecordId || acpxSessionId || agentSessionId);
if (!state && !source && !hasAnyId && lastUpdatedAt === undefined) {
return undefined;
}
const resolved = Boolean(acpxSessionId || agentSessionId);
const normalizedState = state ?? (resolved ? "resolved" : "pending");
return {
state: normalizedState,
...(acpxRecordId ? { acpxRecordId } : {}),
...(acpxSessionId ? { acpxSessionId } : {}),
...(agentSessionId ? { agentSessionId } : {}),
source: source ?? "status",
lastUpdatedAt: lastUpdatedAt ?? Date.now(),
};
}
type IdentityIds = Pick<SessionAcpIdentity, "acpxRecordId" | "acpxSessionId" | "agentSessionId">;
/** Read identity ids from a runtime handle shape. */
function readIdentityIdsFromHandle(handle: AcpRuntimeHandle): IdentityIds {
return {
acpxRecordId: normalizeText((handle as { acpxRecordId?: unknown }).acpxRecordId),
acpxSessionId: normalizeText(handle.backendSessionId),
agentSessionId: normalizeText(handle.agentSessionId),
};
}
/** Build an identity only when at least one stable id is known. */
function buildSessionIdentity(params: {
ids: IdentityIds;
state: SessionAcpIdentity["state"];
source: SessionAcpIdentitySource;
now: number;
}): SessionAcpIdentity | undefined {
const { acpxRecordId, acpxSessionId, agentSessionId } = params.ids;
if (!acpxRecordId && !acpxSessionId && !agentSessionId) {
return undefined;
}
return {
state: params.state,
...(acpxRecordId ? { acpxRecordId } : {}),
...(acpxSessionId ? { acpxSessionId } : {}),
...(agentSessionId ? { agentSessionId } : {}),
source: params.source,
lastUpdatedAt: params.now,
};
}
/** Resolve normalized ACP identity from persisted session metadata. */
export function resolveSessionIdentityFromMeta(
meta: SessionAcpMeta | undefined,
): SessionAcpIdentity | undefined {
if (!meta) {
return undefined;
}
return normalizeIdentity(meta.identity);
}
/** Return true when an identity has a backend or agent session id. */
export function identityHasStableSessionId(identity: SessionAcpIdentity | undefined): boolean {
return Boolean(identity?.acpxSessionId || identity?.agentSessionId);
}
/** Resolve the runtime resume id, preferring agent session id over ACP backend id. */
export function resolveRuntimeResumeSessionId(
identity: SessionAcpIdentity | undefined,
): string | undefined {
if (!identity) {
return undefined;
}
return normalizeText(identity.agentSessionId) ?? normalizeText(identity.acpxSessionId);
}
/** Return true when identity is absent or still pending. */
export function isSessionIdentityPending(identity: SessionAcpIdentity | undefined): boolean {
if (!identity) {
return true;
}
return identity.state === "pending";
}
/** Compare identities ignoring lastUpdatedAt timestamp churn. */
export function identityEquals(
left: SessionAcpIdentity | undefined,
right: SessionAcpIdentity | undefined,
): boolean {
const a = normalizeIdentity(left);
const b = normalizeIdentity(right);
if (!a && !b) {
return true;
}
if (!a || !b) {
return false;
}
return (
a.state === b.state &&
a.acpxRecordId === b.acpxRecordId &&
a.acpxSessionId === b.acpxSessionId &&
a.agentSessionId === b.agentSessionId &&
a.source === b.source
);
}
/** Merge current and incoming identity observations without downgrading resolved ids. */
export function mergeSessionIdentity(params: {
current: SessionAcpIdentity | undefined;
incoming: SessionAcpIdentity | undefined;
now: number;
}): SessionAcpIdentity | undefined {
const current = normalizeIdentity(params.current);
const incoming = normalizeIdentity(params.incoming);
if (!current) {
if (!incoming) {
return undefined;
}
return { ...incoming, lastUpdatedAt: params.now };
}
if (!incoming) {
return current;
}
const currentResolved = current.state === "resolved";
const incomingResolved = incoming.state === "resolved";
const allowIncomingValue = !currentResolved || incomingResolved;
const nextRecordId =
allowIncomingValue && incoming.acpxRecordId ? incoming.acpxRecordId : current.acpxRecordId;
const nextAcpxSessionId =
allowIncomingValue && incoming.acpxSessionId ? incoming.acpxSessionId : current.acpxSessionId;
const nextAgentSessionId =
allowIncomingValue && incoming.agentSessionId
? incoming.agentSessionId
: current.agentSessionId;
const nextResolved = Boolean(nextAcpxSessionId || nextAgentSessionId);
const nextState: SessionAcpIdentity["state"] = nextResolved
? "resolved"
: currentResolved
? "resolved"
: incoming.state;
const nextSource = allowIncomingValue ? incoming.source : current.source;
const next: SessionAcpIdentity = {
state: nextState,
...(nextRecordId ? { acpxRecordId: nextRecordId } : {}),
...(nextAcpxSessionId ? { acpxSessionId: nextAcpxSessionId } : {}),
...(nextAgentSessionId ? { agentSessionId: nextAgentSessionId } : {}),
source: nextSource,
lastUpdatedAt: params.now,
};
return next;
}
/** Create a pending identity from an ensure-session handle. */
export function createIdentityFromEnsure(params: {
handle: AcpRuntimeHandle;
now: number;
}): SessionAcpIdentity | undefined {
return buildSessionIdentity({
ids: readIdentityIdsFromHandle(params.handle),
state: "pending",
source: "ensure",
now: params.now,
});
}
/** Create an identity from a runtime event handle. */
export function createIdentityFromHandleEvent(params: {
handle: AcpRuntimeHandle;
now: number;
}): SessionAcpIdentity | undefined {
const ids = readIdentityIdsFromHandle(params.handle);
return buildSessionIdentity({
ids,
state: ids.agentSessionId ? "resolved" : "pending",
source: "event",
now: params.now,
});
}
/** Create an identity from runtime status output. */
export function createIdentityFromStatus(params: {
status: AcpRuntimeStatus | undefined;
now: number;
}): SessionAcpIdentity | undefined {
if (!params.status) {
return undefined;
}
const details = params.status.details;
const acpxRecordId =
normalizeText((params.status as { acpxRecordId?: unknown }).acpxRecordId) ??
normalizeText(details?.acpxRecordId);
const acpxSessionId =
normalizeText(params.status.backendSessionId) ??
normalizeText(details?.backendSessionId) ??
normalizeText(details?.acpxSessionId);
const agentSessionId =
normalizeText(params.status.agentSessionId) ?? normalizeText(details?.agentSessionId);
if (!acpxRecordId && !acpxSessionId && !agentSessionId) {
return undefined;
}
const resolved = Boolean(acpxSessionId || agentSessionId);
return {
state: resolved ? "resolved" : "pending",
...(acpxRecordId ? { acpxRecordId } : {}),
...(acpxSessionId ? { acpxSessionId } : {}),
...(agentSessionId ? { agentSessionId } : {}),
source: "status",
lastUpdatedAt: params.now,
};
}
/** Convert ACP identity ids into runtime handle resume identifiers. */
export function resolveRuntimeHandleIdentifiersFromIdentity(
identity: SessionAcpIdentity | undefined,
): { backendSessionId?: string; agentSessionId?: string } {
if (!identity) {
return {};
}
return {
...(identity.acpxSessionId ? { backendSessionId: identity.acpxSessionId } : {}),
...(identity.agentSessionId ? { agentSessionId: identity.agentSessionId } : {}),
};
}

View File

@@ -0,0 +1,203 @@
// ACP Core type module defines shared TypeScript contracts.
export type AcpRuntimePromptMode = "prompt" | "steer";
export type AcpRuntimeSessionMode = "persistent" | "oneshot";
/** Runtime update tags emitted by ACP adapters; unknown backend tags are passed through. */
export type AcpSessionUpdateTag =
| "agent_message_chunk"
| "agent_thought_chunk"
| "tool_call"
| "tool_call_update"
| "usage_update"
| "available_commands_update"
| "current_mode_update"
| "config_option_update"
| "session_info_update"
| "plan"
| (string & {});
export type AcpRuntimeControl = "session/set_mode" | "session/set_config_option" | "session/status";
/** Stable handle returned by ensureSession and passed back into all ACP runtime operations. */
export type AcpRuntimeHandle = {
sessionKey: string;
backend: string;
runtimeSessionName: string;
/** Effective runtime working directory for this ACP session, if exposed by adapter/runtime. */
cwd?: string;
/** Backend-local record identifier, if exposed by adapter/runtime (for example acpx record id). */
acpxRecordId?: string;
/** Backend-level ACP session identifier, if exposed by adapter/runtime. */
backendSessionId?: string;
/** Upstream harness session identifier, if exposed by adapter/runtime. */
agentSessionId?: string;
};
export type AcpRuntimeEnsureInput = {
sessionKey: string;
agent: string;
mode: AcpRuntimeSessionMode;
/** Backend or agent session id to resume when reopening an existing conversation. */
resumeSessionId?: string;
/** Optional runtime model override that must be available during session creation. */
model?: string;
/** Optional runtime thinking/reasoning override that must be available during session creation. */
thinking?: string;
cwd?: string;
env?: Record<string, string>;
};
export type AcpRuntimeTurnAttachment = {
mediaType: string;
data: string;
};
/** Per-turn payload delivered to ACP adapters. */
export type AcpRuntimeTurnInput = {
handle: AcpRuntimeHandle;
text: string;
attachments?: AcpRuntimeTurnAttachment[];
mode: AcpRuntimePromptMode;
requestId: string;
signal?: AbortSignal;
};
export type AcpRuntimeCapabilities = {
controls: AcpRuntimeControl[];
/**
* Optional backend-advertised option keys for session/set_config_option.
* Empty/undefined means "backend accepts keys, but did not advertise a strict list".
*/
configOptionKeys?: string[];
};
export type AcpRuntimeStatus = {
summary?: string;
/** Backend-local record identifier, if exposed by adapter/runtime. */
acpxRecordId?: string;
/** Backend-level ACP session identifier, if known at status time. */
backendSessionId?: string;
/** Upstream harness session identifier, if known at status time. */
agentSessionId?: string;
details?: Record<string, unknown>;
};
export type AcpRuntimeDoctorReport = {
ok: boolean;
code?: string;
message: string;
installCommand?: string;
details?: string[];
};
/** Streaming event union produced by ACP adapters while a turn is running. */
export type AcpRuntimeEvent =
| {
type: "text_delta";
text: string;
stream?: "output" | "thought";
tag?: AcpSessionUpdateTag;
}
| {
type: "status";
text: string;
tag?: AcpSessionUpdateTag;
used?: number;
size?: number;
}
| {
type: "tool_call";
text: string;
tag?: AcpSessionUpdateTag;
toolCallId?: string;
status?: string;
title?: string;
}
| {
type: "done";
stopReason?: string;
}
| {
type: "error";
message: string;
code?: string;
detailCode?: string;
retryable?: boolean;
};
export type AcpRuntimeTurnResultError = {
message: string;
code?: string;
detailCode?: string;
retryable?: boolean;
};
/** Terminal turn result, separated from the live event stream for reliable failure handling. */
export type AcpRuntimeTurnResult =
| {
status: "completed";
stopReason?: string;
}
| {
status: "cancelled";
stopReason?: string;
}
| {
status: "failed";
error: AcpRuntimeTurnResultError;
};
export interface AcpRuntimeTurn {
readonly requestId: string;
readonly events: AsyncIterable<AcpRuntimeEvent>;
readonly result: Promise<AcpRuntimeTurnResult>;
/** Requests backend cancellation while keeping result/error reporting adapter-owned. */
cancel(input?: { reason?: string }): Promise<void>;
/** Closes the event stream when the caller stops listening before terminal result. */
closeStream(input?: { reason?: string }): Promise<void>;
}
/** ACP adapter contract implemented by backend plugins and consumed by gateway/session flows. */
export interface AcpRuntime {
ensureSession(input: AcpRuntimeEnsureInput): Promise<AcpRuntimeHandle>;
/**
* Preferred turn API. Live events are streamed separately from the terminal
* result so adapters can report failures without relying on legacy done/error
* events in the stream.
*/
startTurn?(input: AcpRuntimeTurnInput): AcpRuntimeTurn;
runTurn(input: AcpRuntimeTurnInput): AsyncIterable<AcpRuntimeEvent>;
getCapabilities?(input: {
handle?: AcpRuntimeHandle;
}): Promise<AcpRuntimeCapabilities> | AcpRuntimeCapabilities;
getStatus?(input: { handle: AcpRuntimeHandle; signal?: AbortSignal }): Promise<AcpRuntimeStatus>;
setMode?(input: { handle: AcpRuntimeHandle; mode: string }): Promise<void>;
setConfigOption?(input: { handle: AcpRuntimeHandle; key: string; value: string }): Promise<void>;
doctor?(): Promise<AcpRuntimeDoctorReport>;
/**
* Prepare the next ensureSession for this session key to start fresh instead
* of reopening backend-owned persistent state.
*/
prepareFreshSession?(input: { sessionKey: string }): Promise<void>;
cancel(input: { handle: AcpRuntimeHandle; reason?: string }): Promise<void>;
close(input: {
handle: AcpRuntimeHandle;
reason: string;
/**
* Discard backend-owned persistent session state so the next ensureSession
* starts fresh instead of reopening the same conversation.
*/
discardPersistentState?: boolean;
}): Promise<void>;
}

View File

@@ -0,0 +1,106 @@
// ACP Core tests cover session interaction mode behavior.
import { describe, expect, it } from "vitest";
import {
isParentOwnedBackgroundAcpSession,
isRequesterParentOfBackgroundAcpSession,
} from "./session-interaction-mode.js";
const parentKey = "agent:main:main";
const otherKey = "agent:peer:some-other";
describe("isParentOwnedBackgroundAcpSession", () => {
it("returns interactive when entry is undefined", () => {
expect(isParentOwnedBackgroundAcpSession(undefined)).toBe(false);
});
it("returns parent-owned-background for persistent sessions with spawnedBy set", () => {
expect(
isParentOwnedBackgroundAcpSession({
acp: { mode: "persistent" } as never,
spawnedBy: parentKey,
}),
).toBe(true);
});
it("returns interactive for persistent ACP sessions without parent linkage", () => {
expect(
isParentOwnedBackgroundAcpSession({
acp: { mode: "persistent" } as never,
}),
).toBe(false);
});
it("returns parent-owned-background for oneshot sessions with spawnedBy set", () => {
expect(
isParentOwnedBackgroundAcpSession({
acp: { mode: "oneshot" } as never,
spawnedBy: parentKey,
}),
).toBe(true);
});
it("returns parent-owned-background for oneshot sessions with parentSessionKey set", () => {
expect(
isParentOwnedBackgroundAcpSession({
acp: { mode: "oneshot" } as never,
parentSessionKey: parentKey,
}),
).toBe(true);
});
it("returns interactive for a oneshot session without any parent linkage", () => {
expect(
isParentOwnedBackgroundAcpSession({
acp: { mode: "oneshot" } as never,
}),
).toBe(false);
});
});
describe("isRequesterParentOfBackgroundAcpSession", () => {
const backgroundEntry = {
acp: { mode: "oneshot" } as never,
spawnedBy: parentKey,
parentSessionKey: parentKey,
};
it("returns true when requester matches spawnedBy", () => {
expect(
isRequesterParentOfBackgroundAcpSession(
{ acp: { mode: "oneshot" } as never, spawnedBy: parentKey },
parentKey,
),
).toBe(true);
});
it("returns true when requester matches parentSessionKey", () => {
expect(
isRequesterParentOfBackgroundAcpSession(
{ acp: { mode: "oneshot" } as never, parentSessionKey: parentKey },
parentKey,
),
).toBe(true);
});
it("returns false when requester is a different session (not the parent)", () => {
expect(isRequesterParentOfBackgroundAcpSession(backgroundEntry, otherKey)).toBe(false);
});
it("returns false when requester key is missing", () => {
expect(isRequesterParentOfBackgroundAcpSession(backgroundEntry, undefined)).toBe(false);
expect(isRequesterParentOfBackgroundAcpSession(backgroundEntry, "")).toBe(false);
});
it("returns true when target is parent-owned persistent ACP session", () => {
expect(
isRequesterParentOfBackgroundAcpSession(
{ acp: { mode: "persistent" } as never, spawnedBy: parentKey },
parentKey,
),
).toBe(true);
});
it("delegates to isParentOwnedBackgroundAcpSession for target-only checks", () => {
expect(isParentOwnedBackgroundAcpSession(backgroundEntry)).toBe(true);
});
});

View File

@@ -0,0 +1,57 @@
// ACP Core module implements session interaction mode behavior.
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
type AcpSessionInteractionMode = "interactive" | "parent-owned-background";
type SessionInteractionEntry = {
spawnedBy?: string;
parentSessionKey?: string;
acp?: unknown;
};
function resolveAcpSessionInteractionMode(
entry?: SessionInteractionEntry | null,
): AcpSessionInteractionMode {
// Parent-owned ACP sessions are background work delegated from another session.
// They should report back through the parent task notifier instead of speaking directly
// on the user-facing channel themselves.
if (!entry?.acp) {
return "interactive";
}
if (normalizeOptionalString(entry.spawnedBy) || normalizeOptionalString(entry.parentSessionKey)) {
return "parent-owned-background";
}
return "interactive";
}
/** Returns true for ACP sessions delegated from a parent session instead of user-facing chat. */
export function isParentOwnedBackgroundAcpSession(entry?: SessionInteractionEntry | null): boolean {
return resolveAcpSessionInteractionMode(entry) === "parent-owned-background";
}
/**
* Returns true when `entry` is a parent-owned background ACP session AND the
* given `requesterSessionKey` is the session that spawned/owns it. This is a
* strictly narrower check than {@link isParentOwnedBackgroundAcpSession}: the
* target must match *and* the caller must be the parent.
*
* Used to gate behaviors that only make sense for the parent↔own-child pair
* (e.g. skipping the A2A ping-pong flow in `sessions_send`), so that an
* unrelated session with broad visibility (e.g. `tools.sessions.visibility=all`)
* sending to the same target is still routed through the normal A2A path.
*/
export function isRequesterParentOfBackgroundAcpSession(
entry: SessionInteractionEntry | null | undefined,
requesterSessionKey: string | null | undefined,
): boolean {
if (!isParentOwnedBackgroundAcpSession(entry)) {
return false;
}
const requester = normalizeOptionalString(requesterSessionKey);
if (!requester) {
return false;
}
const spawnedBy = normalizeOptionalString(entry?.spawnedBy);
const parentSessionKey = normalizeOptionalString(entry?.parentSessionKey);
return requester === spawnedBy || requester === parentSessionKey;
}

View File

@@ -0,0 +1,100 @@
// ACP Core tests cover session lineage meta behavior.
import { describe, expect, it } from "vitest";
import { toAcpSessionLineageMeta, type AcpSessionLineageRow } from "./session-lineage-meta.js";
describe("toAcpSessionLineageMeta", () => {
it("keeps root session metadata minimal", () => {
const meta = toAcpSessionLineageMeta({
key: "agent:main:main",
kind: "direct",
channel: "telegram",
});
expect(meta).toEqual({
sessionKey: "agent:main:main",
kind: "direct",
channel: "telegram",
});
expect(Object.keys(meta)).toEqual(["sessionKey", "kind", "channel"]);
});
it("maps a one-level child parent key into parentSessionId", () => {
const meta = toAcpSessionLineageMeta({
key: "agent:main:subagent:child",
kind: "direct",
parentSessionKey: "agent:main:main",
spawnedBy: "agent:main:main",
spawnDepth: 1,
subagentRole: "orchestrator",
subagentControlScope: "children",
});
expect(meta).toEqual({
sessionKey: "agent:main:subagent:child",
kind: "direct",
parentSessionId: "agent:main:main",
spawnedBy: "agent:main:main",
spawnDepth: 1,
subagentRole: "orchestrator",
subagentControlScope: "children",
});
});
it("keeps multi-level child lineage and workspace metadata", () => {
const meta = toAcpSessionLineageMeta({
key: "agent:main:subagent:parent:subagent:leaf",
kind: "direct",
parentSessionKey: "agent:main:subagent:parent",
spawnedBy: "agent:main:subagent:parent",
spawnDepth: 2,
subagentRole: "leaf",
subagentControlScope: "none",
spawnedWorkspaceDir: "/workspace/leaf",
});
expect(meta).toEqual({
sessionKey: "agent:main:subagent:parent:subagent:leaf",
kind: "direct",
parentSessionId: "agent:main:subagent:parent",
spawnedBy: "agent:main:subagent:parent",
spawnDepth: 2,
subagentRole: "leaf",
subagentControlScope: "none",
spawnedWorkspaceDir: "/workspace/leaf",
});
});
it("falls back to spawnedBy for parentSessionId when no explicit parent key is present", () => {
expect(
toAcpSessionLineageMeta({
key: "agent:main:subagent:child",
kind: "direct",
spawnedBy: "agent:main:main",
}),
).toEqual({
sessionKey: "agent:main:subagent:child",
kind: "direct",
parentSessionId: "agent:main:main",
spawnedBy: "agent:main:main",
});
});
it("omits malformed optional lineage values", () => {
const row = {
key: "agent:main:subagent:broken",
kind: "direct",
channel: "",
parentSessionKey: " ",
spawnedBy: 42,
spawnDepth: 1.5,
subagentRole: "worker",
subagentControlScope: "all",
spawnedWorkspaceDir: "",
} as unknown as AcpSessionLineageRow;
expect(toAcpSessionLineageMeta(row)).toEqual({
sessionKey: "agent:main:subagent:broken",
kind: "direct",
});
});
});

View File

@@ -0,0 +1,79 @@
// ACP Core module implements session lineage meta behavior.
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
const SUBAGENT_ROLES = ["orchestrator", "leaf"] as const;
const SUBAGENT_CONTROL_SCOPES = ["children", "none"] as const;
type SubagentRole = (typeof SUBAGENT_ROLES)[number];
type SubagentControlScope = (typeof SUBAGENT_CONTROL_SCOPES)[number];
export type AcpSessionLineageMeta = {
/** Stable session key emitted to ACP clients. */
sessionKey: string;
kind?: string;
channel?: string;
/** Best available parent session id, preferring explicit parentSessionKey over legacy spawnedBy. */
parentSessionId?: string;
spawnedBy?: string;
spawnDepth?: number;
subagentRole?: SubagentRole;
subagentControlScope?: SubagentControlScope;
spawnedWorkspaceDir?: string;
spawnedCwd?: string;
};
export type AcpSessionLineageRow = {
/** Raw persisted session key; kept even when other optional fields are malformed. */
key: string;
kind?: string;
channel?: string;
parentSessionKey?: string;
spawnedBy?: string;
spawnDepth?: number;
subagentRole?: string;
subagentControlScope?: string;
spawnedWorkspaceDir?: string;
spawnedCwd?: string;
};
function readInteger(value: unknown): number | undefined {
if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
return undefined;
}
return value;
}
function readEnum<T extends string>(value: unknown, allowed: readonly T[]): T | undefined {
const normalized = normalizeOptionalString(value);
return allowed.find((candidate) => candidate === normalized);
}
/** Converts persisted session rows into compact ACP lineage metadata for protocol responses. */
export function toAcpSessionLineageMeta(row: AcpSessionLineageRow): AcpSessionLineageMeta {
const sessionKey = normalizeOptionalString(row.key) ?? row.key;
const kind = normalizeOptionalString(row.kind);
const channel = normalizeOptionalString(row.channel);
// Older rows may only carry spawnedBy; expose it as parentSessionId so ACP clients
// can follow lineage without knowing which storage-era field populated it.
const parentSessionId =
normalizeOptionalString(row.parentSessionKey) ?? normalizeOptionalString(row.spawnedBy);
const spawnedBy = normalizeOptionalString(row.spawnedBy);
const spawnDepth = readInteger(row.spawnDepth);
const subagentRole = readEnum(row.subagentRole, SUBAGENT_ROLES);
const subagentControlScope = readEnum(row.subagentControlScope, SUBAGENT_CONTROL_SCOPES);
const spawnedWorkspaceDir = normalizeOptionalString(row.spawnedWorkspaceDir);
const spawnedCwd = normalizeOptionalString(row.spawnedCwd);
return {
sessionKey,
...(kind ? { kind } : {}),
...(channel ? { channel } : {}),
...(parentSessionId ? { parentSessionId } : {}),
...(spawnedBy ? { spawnedBy } : {}),
...(spawnDepth !== undefined ? { spawnDepth } : {}),
...(subagentRole ? { subagentRole } : {}),
...(subagentControlScope ? { subagentControlScope } : {}),
...(spawnedWorkspaceDir ? { spawnedWorkspaceDir } : {}),
...(spawnedCwd ? { spawnedCwd } : {}),
};
}

View File

@@ -0,0 +1,235 @@
// ACP Core tests cover session behavior.
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createInMemorySessionStore } from "./session.js";
describe("acp session manager", () => {
let nowMs = 0;
const now = () => nowMs;
const advance = (ms: number) => {
nowMs += ms;
};
let store = createInMemorySessionStore({ now });
beforeEach(() => {
nowMs = 1_000;
store = createInMemorySessionStore({ now });
});
afterEach(() => {
store.clearAllSessionsForTest();
});
it("tracks active runs and clears on cancel", () => {
const session = store.createSession({
sessionKey: "acp:test",
cwd: "/tmp",
});
const controller = new AbortController();
store.setActiveRun(session.sessionId, "run-1", controller);
expect(store.getSessionByRunId("run-1")?.sessionId).toBe(session.sessionId);
const cancelled = store.cancelActiveRun(session.sessionId);
expect(cancelled).toBe(true);
expect(store.getSessionByRunId("run-1")).toBeUndefined();
});
it("removes stale run lookup entries when rebinding an active run", () => {
const session = store.createSession({
sessionKey: "acp:rebind",
cwd: "/tmp",
});
store.setActiveRun(session.sessionId, "run-old", new AbortController());
store.setActiveRun(session.sessionId, "run-new", new AbortController());
expect(store.getSessionByRunId("run-old")).toBeUndefined();
expect(store.getSessionByRunId("run-new")?.sessionId).toBe(session.sessionId);
});
it("deletes sessions and aborts active runs on close", () => {
const session = store.createSession({
sessionId: "close-me",
sessionKey: "acp:close",
cwd: "/tmp",
});
const controller = new AbortController();
store.setActiveRun(session.sessionId, "run-close", controller);
expect(store.deleteSession(session.sessionId)).toBe(true);
expect(controller.signal.aborted).toBe(true);
expect(store.hasSession(session.sessionId)).toBe(false);
expect(store.getSessionByRunId("run-close")).toBeUndefined();
});
it("reports false when deleting a missing session", () => {
expect(store.deleteSession("missing")).toBe(false);
});
it("refreshes existing session IDs instead of creating duplicates", () => {
const first = store.createSession({
sessionId: "existing",
sessionKey: "acp:one",
cwd: "/tmp/one",
});
advance(500);
const refreshed = store.createSession({
sessionId: "existing",
sessionKey: "acp:two",
cwd: "/tmp/two",
});
expect(refreshed).toBe(first);
expect(refreshed.sessionKey).toBe("acp:two");
expect(refreshed.cwd).toBe("/tmp/two");
expect(refreshed.createdAt).toBe(1_000);
expect(refreshed.lastTouchedAt).toBe(1_500);
expect(store.hasSession("existing")).toBe(true);
});
it("falls back for non-finite idle TTL options", () => {
const boundedStore = createInMemorySessionStore({
maxSessions: 2,
idleTtlMs: Number.NaN,
now,
});
try {
boundedStore.createSession({
sessionId: "first",
sessionKey: "acp:first",
cwd: "/tmp",
});
advance(1);
boundedStore.createSession({
sessionId: "second",
sessionKey: "acp:second",
cwd: "/tmp",
});
expect(boundedStore.hasSession("first")).toBe(true);
expect(boundedStore.hasSession("second")).toBe(true);
} finally {
boundedStore.clearAllSessionsForTest();
}
});
it("falls back for non-finite max session options", () => {
const boundedStore = createInMemorySessionStore({
maxSessions: Number.NaN,
idleTtlMs: 24 * 60 * 60 * 1_000,
now,
});
try {
for (let index = 0; index < 5_000; index += 1) {
const session = boundedStore.createSession({
sessionId: `session-${index}`,
sessionKey: `acp:${index}`,
cwd: "/tmp",
});
boundedStore.setActiveRun(session.sessionId, `run-${index}`, new AbortController());
}
expect(() =>
boundedStore.createSession({
sessionId: "overflow",
sessionKey: "acp:overflow",
cwd: "/tmp",
}),
).toThrow(/session limit reached/i);
} finally {
boundedStore.clearAllSessionsForTest();
}
});
it("reaps idle sessions before enforcing the max session cap", () => {
const boundedStore = createInMemorySessionStore({
maxSessions: 1,
idleTtlMs: 1_000,
now,
});
try {
boundedStore.createSession({
sessionId: "old",
sessionKey: "acp:old",
cwd: "/tmp",
});
advance(2_000);
const fresh = boundedStore.createSession({
sessionId: "fresh",
sessionKey: "acp:fresh",
cwd: "/tmp",
});
expect(fresh.sessionId).toBe("fresh");
expect(boundedStore.getSession("old")).toBeUndefined();
expect(boundedStore.hasSession("old")).toBe(false);
} finally {
boundedStore.clearAllSessionsForTest();
}
});
it("uses soft-cap eviction for the oldest idle session when full", () => {
const boundedStore = createInMemorySessionStore({
maxSessions: 2,
idleTtlMs: 24 * 60 * 60 * 1_000,
now,
});
try {
const first = boundedStore.createSession({
sessionId: "first",
sessionKey: "acp:first",
cwd: "/tmp",
});
advance(100);
const second = boundedStore.createSession({
sessionId: "second",
sessionKey: "acp:second",
cwd: "/tmp",
});
const controller = new AbortController();
boundedStore.setActiveRun(second.sessionId, "run-2", controller);
advance(100);
const third = boundedStore.createSession({
sessionId: "third",
sessionKey: "acp:third",
cwd: "/tmp",
});
expect(third.sessionId).toBe("third");
expect(boundedStore.getSession(first.sessionId)).toBeUndefined();
const retainedSession = boundedStore.getSession(second.sessionId);
expect(retainedSession?.sessionId).toBe("second");
} finally {
boundedStore.clearAllSessionsForTest();
}
});
it("rejects when full and no session is evictable", () => {
const boundedStore = createInMemorySessionStore({
maxSessions: 1,
idleTtlMs: 24 * 60 * 60 * 1_000,
now,
});
try {
const only = boundedStore.createSession({
sessionId: "only",
sessionKey: "acp:only",
cwd: "/tmp",
});
boundedStore.setActiveRun(only.sessionId, "run-only", new AbortController());
expect(() =>
boundedStore.createSession({
sessionId: "next",
sessionKey: "acp:next",
cwd: "/tmp",
}),
).toThrow(/session limit reached/i);
} finally {
boundedStore.clearAllSessionsForTest();
}
});
});

View File

@@ -0,0 +1,213 @@
// ACP Core module implements session behavior.
import { randomUUID } from "node:crypto";
import { resolveIntegerOption } from "./numeric-options.js";
import type { AcpSession } from "./types.js";
export type AcpSessionStore = {
/** Creates or refreshes an in-memory ACP session under the supplied session id. */
createSession: (params: {
sessionKey: string;
cwd: string;
sessionId?: string;
ledgerSessionId?: string;
}) => AcpSession;
hasSession: (sessionId: string) => boolean;
getSession: (sessionId: string) => AcpSession | undefined;
getSessionByRunId: (runId: string) => AcpSession | undefined;
/** Binds an active runtime run to a session so cancel/close can abort it later. */
setActiveRun: (sessionId: string, runId: string, abortController: AbortController) => void;
clearActiveRun: (sessionId: string) => void;
cancelActiveRun: (sessionId: string) => boolean;
deleteSession: (sessionId: string) => boolean;
clearAllSessionsForTest: () => void;
};
type AcpSessionStoreOptions = {
maxSessions?: number;
idleTtlMs?: number;
now?: () => number;
};
const DEFAULT_MAX_SESSIONS = 5_000;
const DEFAULT_IDLE_TTL_MS = 24 * 60 * 60 * 1_000;
/** Creates the bounded in-memory ACP session registry used by local ACP runtime clients. */
export function createInMemorySessionStore(options: AcpSessionStoreOptions = {}): AcpSessionStore {
const maxSessions = resolveIntegerOption(options.maxSessions, DEFAULT_MAX_SESSIONS, { min: 1 });
const idleTtlMs = resolveIntegerOption(options.idleTtlMs, DEFAULT_IDLE_TTL_MS, { min: 1_000 });
const now = options.now ?? Date.now;
const sessions = new Map<string, AcpSession>();
const runIdToSessionId = new Map<string, string>();
const touchSession = (session: AcpSession, nowMs: number) => {
session.lastTouchedAt = nowMs;
};
const removeSession = (sessionId: string) => {
const session = sessions.get(sessionId);
if (!session) {
return false;
}
if (session.activeRunId) {
runIdToSessionId.delete(session.activeRunId);
}
session.abortController?.abort();
sessions.delete(sessionId);
return true;
};
const reapIdleSessions = (nowMs: number) => {
const idleBefore = nowMs - idleTtlMs;
for (const [sessionId, session] of sessions.entries()) {
if (session.activeRunId || session.abortController) {
continue;
}
if (session.lastTouchedAt > idleBefore) {
continue;
}
removeSession(sessionId);
}
};
const evictOldestIdleSession = () => {
let oldestSessionId: string | null = null;
let oldestLastTouchedAt = Number.POSITIVE_INFINITY;
for (const [sessionId, session] of sessions.entries()) {
if (session.activeRunId || session.abortController) {
continue;
}
if (session.lastTouchedAt >= oldestLastTouchedAt) {
continue;
}
oldestLastTouchedAt = session.lastTouchedAt;
oldestSessionId = sessionId;
}
if (!oldestSessionId) {
return false;
}
return removeSession(oldestSessionId);
};
const createSession: AcpSessionStore["createSession"] = (params) => {
const nowMs = now();
const sessionId = params.sessionId ?? randomUUID();
const existingSession = sessions.get(sessionId);
if (existingSession) {
existingSession.sessionKey = params.sessionKey;
if ("ledgerSessionId" in params) {
existingSession.ledgerSessionId = params.ledgerSessionId;
}
existingSession.cwd = params.cwd;
touchSession(existingSession, nowMs);
return existingSession;
}
reapIdleSessions(nowMs);
// Active runs are never evicted to make cancellation ownership explicit; callers must
// clear/cancel them before the soft cap can make room.
if (sessions.size >= maxSessions && !evictOldestIdleSession()) {
throw new Error(
`ACP session limit reached (max ${maxSessions}). Close idle ACP clients and retry.`,
);
}
const session: AcpSession = {
sessionId,
sessionKey: params.sessionKey,
...(params.ledgerSessionId ? { ledgerSessionId: params.ledgerSessionId } : {}),
cwd: params.cwd,
createdAt: nowMs,
lastTouchedAt: nowMs,
abortController: null,
activeRunId: null,
};
sessions.set(sessionId, session);
return session;
};
const hasSession: AcpSessionStore["hasSession"] = (sessionId) => sessions.has(sessionId);
const getSession: AcpSessionStore["getSession"] = (sessionId) => {
const session = sessions.get(sessionId);
if (session) {
touchSession(session, now());
}
return session;
};
const getSessionByRunId: AcpSessionStore["getSessionByRunId"] = (runId) => {
const sessionId = runIdToSessionId.get(runId);
if (!sessionId) {
return undefined;
}
const session = sessions.get(sessionId);
if (session) {
touchSession(session, now());
}
return session;
};
const setActiveRun: AcpSessionStore["setActiveRun"] = (sessionId, runId, abortController) => {
const session = sessions.get(sessionId);
if (!session) {
return;
}
if (session.activeRunId && session.activeRunId !== runId) {
runIdToSessionId.delete(session.activeRunId);
}
session.activeRunId = runId;
session.abortController = abortController;
runIdToSessionId.set(runId, sessionId);
touchSession(session, now());
};
const clearActiveRun: AcpSessionStore["clearActiveRun"] = (sessionId) => {
const session = sessions.get(sessionId);
if (!session) {
return;
}
if (session.activeRunId) {
runIdToSessionId.delete(session.activeRunId);
}
session.activeRunId = null;
session.abortController = null;
touchSession(session, now());
};
const cancelActiveRun: AcpSessionStore["cancelActiveRun"] = (sessionId) => {
const session = sessions.get(sessionId);
if (!session?.abortController) {
return false;
}
session.abortController.abort();
if (session.activeRunId) {
runIdToSessionId.delete(session.activeRunId);
}
session.abortController = null;
session.activeRunId = null;
touchSession(session, now());
return true;
};
const deleteSession: AcpSessionStore["deleteSession"] = (sessionId) => removeSession(sessionId);
const clearAllSessionsForTest: AcpSessionStore["clearAllSessionsForTest"] = () => {
for (const session of sessions.values()) {
session.abortController?.abort();
}
sessions.clear();
runIdToSessionId.clear();
};
return {
createSession,
hasSession,
getSession,
getSessionByRunId,
setActiveRun,
clearActiveRun,
cancelActiveRun,
deleteSession,
clearAllSessionsForTest,
};
}
export const defaultAcpSessionStore = createInMemorySessionStore();

View File

@@ -0,0 +1,96 @@
// ACP Core type module defines shared TypeScript contracts.
import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce";
const ACP_PROVENANCE_MODE_VALUES = ["off", "meta", "meta+receipt"] as const;
export type SessionId = string;
export type AcpProvenanceMode = (typeof ACP_PROVENANCE_MODE_VALUES)[number];
export function normalizeAcpProvenanceMode(
value: string | undefined,
): AcpProvenanceMode | undefined {
const normalized = normalizeOptionalLowercaseString(value);
if (!normalized) {
return undefined;
}
return (ACP_PROVENANCE_MODE_VALUES as readonly string[]).includes(normalized)
? (normalized as AcpProvenanceMode)
: undefined;
}
export type AcpSession = {
sessionId: SessionId;
sessionKey: string;
ledgerSessionId?: string;
cwd: string;
createdAt: number;
lastTouchedAt: number;
abortController: AbortController | null;
activeRunId: string | null;
};
export type AcpServerOptions = {
gatewayUrl?: string;
gatewayToken?: string;
gatewayPassword?: string;
defaultSessionKey?: string;
defaultSessionLabel?: string;
requireExistingSession?: boolean;
resetSession?: boolean;
prefixCwd?: boolean;
provenanceMode?: AcpProvenanceMode;
sessionCreateRateLimit?: {
maxRequests?: number;
windowMs?: number;
};
verbose?: boolean;
};
export type SessionAcpIdentitySource = "ensure" | "status" | "event";
export type SessionAcpIdentityState = "pending" | "resolved";
export type SessionAcpIdentity = {
/** Pending identities may expose provisional ids; resolved identities are safe for resume output. */
state: SessionAcpIdentityState;
acpxRecordId?: string;
acpxSessionId?: string;
agentSessionId?: string;
/** Runtime lifecycle point that last supplied the identity fields. */
source: SessionAcpIdentitySource;
lastUpdatedAt: number;
};
export type AcpSessionRuntimeOptions = {
/**
* ACP runtime mode set via session/set_mode (for example: "plan", "normal", "auto").
*/
runtimeMode?: string;
/** ACP runtime config option: model id. */
model?: string;
/** ACP runtime config option: thinking/reasoning effort. */
thinking?: string;
/** Working directory override for ACP session turns. */
cwd?: string;
/** ACP runtime config option: permission profile id. */
permissionProfile?: string;
/** ACP runtime config option: per-turn timeout in seconds. */
timeoutSeconds?: number;
/** Backend-specific option bag mapped through session/set_config_option. */
backendExtras?: Record<string, string>;
};
export type SessionAcpMeta = {
backend: string;
agent: string;
runtimeSessionName: string;
/** Canonical backend/agent ids used for resume hints and thread/status details. */
identity?: SessionAcpIdentity;
mode: "persistent" | "oneshot";
runtimeOptions?: AcpSessionRuntimeOptions;
cwd?: string;
state: "idle" | "running" | "error";
lastActivityAt: number;
lastError?: string;
};

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,621 @@
// Agent Core module implements agent behavior.
import type {
ImageContent,
Message,
Model,
SimpleStreamOptions,
TextContent,
ThinkingBudgets,
Transport,
} from "../../llm-core/src/index.js";
import { runAgentLoop, runAgentLoopContinue } from "./agent-loop.js";
import { resolveAgentReasoningOption } from "./reasoning.js";
import { type AgentCoreStreamRuntimeDeps, resolveAgentCoreStreamFn } from "./runtime-deps.js";
import type {
AfterToolCallContext,
AfterToolCallResult,
AgentContext,
AgentEvent,
AgentLoopConfig,
AgentLoopTurnUpdate,
AgentMessage,
AgentState,
AgentTool,
BeforeToolCallContext,
BeforeToolCallResult,
QueueMode,
StreamFn,
ToolExecutionMode,
} from "./types.js";
export type { QueueMode } from "./types.js";
function defaultConvertToLlm(messages: AgentMessage[]): Message[] {
return messages.filter(
(message) =>
message.role === "user" || message.role === "assistant" || message.role === "toolResult",
);
}
const EMPTY_USAGE = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
const DEFAULT_MODEL = {
id: "unknown",
name: "unknown",
api: "unknown",
provider: "unknown",
baseUrl: "",
reasoning: false,
input: [],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 0,
maxTokens: 0,
} satisfies Model;
type MutableAgentState = Omit<
AgentState,
"isStreaming" | "streamingMessage" | "pendingToolCalls" | "errorMessage"
> & {
isStreaming: boolean;
streamingMessage?: AgentMessage;
pendingToolCalls: Set<string>;
errorMessage?: string;
};
function createMutableAgentState(
initialState?: Partial<
Omit<AgentState, "pendingToolCalls" | "isStreaming" | "streamingMessage" | "errorMessage">
>,
): MutableAgentState {
let tools = initialState?.tools?.slice() ?? [];
let messages = initialState?.messages?.slice() ?? [];
return {
systemPrompt: initialState?.systemPrompt ?? "",
model: initialState?.model ?? DEFAULT_MODEL,
thinkingLevel: initialState?.thinkingLevel ?? "off",
get tools() {
return tools;
},
set tools(nextTools: AgentTool[]) {
tools = nextTools.slice();
},
get messages() {
return messages;
},
set messages(nextMessages: AgentMessage[]) {
messages = nextMessages.slice();
},
isStreaming: false,
streamingMessage: undefined,
pendingToolCalls: new Set<string>(),
errorMessage: undefined,
};
}
/** Options for constructing an {@link Agent}. */
export interface AgentOptions {
/** Initial transcript, tools, model, and prompt state. */
initialState?: Partial<
Omit<AgentState, "pendingToolCalls" | "isStreaming" | "streamingMessage" | "errorMessage">
>;
/** Convert agent-owned transcript messages into provider-facing messages. */
convertToLlm?: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
/** Optionally rewrite context before each provider request. */
transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise<AgentMessage[]>;
/** Injected stream runtime used when streamFn is not supplied. */
runtime?: AgentCoreStreamRuntimeDeps;
/** Explicit stream implementation, preferred over runtime.streamSimple. */
streamFn?: StreamFn;
/** Resolve provider API keys at request time. */
getApiKey?: (provider: string) => Promise<string | undefined> | string | undefined;
/** Inspect the provider payload before it is sent. */
onPayload?: SimpleStreamOptions["onPayload"];
/** Inspect the provider response after it returns. */
onResponse?: SimpleStreamOptions["onResponse"];
/** Hook that may short-circuit or alter a tool call before execution. */
beforeToolCall?: (
context: BeforeToolCallContext,
signal?: AbortSignal,
) => Promise<BeforeToolCallResult | undefined>;
/** Hook that may hydrate a deferred authorized tool call into an executable tool. */
resolveDeferredTool?: AgentLoopConfig["resolveDeferredTool"];
/** Hook that may alter a tool result after execution. */
afterToolCall?: (
context: AfterToolCallContext,
signal?: AbortSignal,
) => Promise<AfterToolCallResult | undefined>;
/** Hook that may update model, reasoning, or context after a turn. */
prepareNextTurn?: (
signal?: AbortSignal,
) => Promise<AgentLoopTurnUpdate | undefined> | AgentLoopTurnUpdate | undefined;
/** Queue drain mode for steering messages injected before the next assistant response. */
steeringMode?: QueueMode;
/** Queue drain mode for follow-up messages injected after the agent would otherwise stop. */
followUpMode?: QueueMode;
/** Session identifier forwarded to cache-aware providers. */
sessionId?: string;
/** Optional per-thinking-level token budgets forwarded to providers. */
thinkingBudgets?: ThinkingBudgets;
/** Preferred provider transport. */
transport?: Transport;
/** Optional cap for provider-requested retry delays. */
maxRetryDelayMs?: number;
/** Default strategy for executing multiple tool calls in one assistant message. */
toolExecution?: ToolExecutionMode;
}
class PendingMessageQueue {
private messages: AgentMessage[] = [];
public mode: QueueMode;
constructor(mode: QueueMode) {
this.mode = mode;
}
enqueue(message: AgentMessage): void {
this.messages.push(message);
}
hasItems(): boolean {
return this.messages.length > 0;
}
drain(): AgentMessage[] {
if (this.mode === "all") {
const drained = this.messages.slice();
this.messages = [];
return drained;
}
// one-at-a-time preserves later queued messages for subsequent loop turns.
const first = this.messages[0];
if (!first) {
return [];
}
this.messages = this.messages.slice(1);
return [first];
}
clear(): void {
this.messages = [];
}
}
type ActiveRun = {
promise: Promise<void>;
resolve: () => void;
abortController: AbortController;
};
/**
* Stateful wrapper around the low-level agent loop.
*
* `Agent` owns the current transcript, emits lifecycle events, executes tools,
* and exposes queueing APIs for steering and follow-up messages.
*/
export class Agent {
private mutableState: MutableAgentState;
private readonly listeners = new Set<
(event: AgentEvent, signal: AbortSignal) => Promise<void> | void
>();
private readonly steeringQueue: PendingMessageQueue;
private readonly followUpQueue: PendingMessageQueue;
public convertToLlm: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
public transformContext?: (
messages: AgentMessage[],
signal?: AbortSignal,
) => Promise<AgentMessage[]>;
public runtime?: AgentCoreStreamRuntimeDeps;
public streamFn: StreamFn;
public getApiKey?: (provider: string) => Promise<string | undefined> | string | undefined;
public onPayload?: SimpleStreamOptions["onPayload"];
public onResponse?: SimpleStreamOptions["onResponse"];
public beforeToolCall?: (
context: BeforeToolCallContext,
signal?: AbortSignal,
) => Promise<BeforeToolCallResult | undefined>;
public resolveDeferredTool?: AgentLoopConfig["resolveDeferredTool"];
public afterToolCall?: (
context: AfterToolCallContext,
signal?: AbortSignal,
) => Promise<AfterToolCallResult | undefined>;
public prepareNextTurn?: (
signal?: AbortSignal,
) => Promise<AgentLoopTurnUpdate | undefined> | AgentLoopTurnUpdate | undefined;
private activeRun?: ActiveRun;
/** Session identifier forwarded to providers for cache-aware backends. */
public sessionId?: string;
/** Optional per-level thinking token budgets forwarded to the stream function. */
public thinkingBudgets?: ThinkingBudgets;
/** Preferred transport forwarded to the stream function. */
public transport: Transport;
/** Optional cap for provider-requested retry delays. */
public maxRetryDelayMs?: number;
/** Tool execution strategy for assistant messages that contain multiple tool calls. */
public toolExecution: ToolExecutionMode;
constructor(options: AgentOptions = {}) {
this.mutableState = createMutableAgentState(options.initialState);
this.convertToLlm = options.convertToLlm ?? defaultConvertToLlm;
this.transformContext = options.transformContext;
this.runtime = options.runtime;
this.streamFn = resolveAgentCoreStreamFn(options.runtime, options.streamFn);
this.getApiKey = options.getApiKey;
this.onPayload = options.onPayload;
this.onResponse = options.onResponse;
this.beforeToolCall = options.beforeToolCall;
this.resolveDeferredTool = options.resolveDeferredTool;
this.afterToolCall = options.afterToolCall;
this.prepareNextTurn = options.prepareNextTurn;
this.steeringQueue = new PendingMessageQueue(options.steeringMode ?? "one-at-a-time");
this.followUpQueue = new PendingMessageQueue(options.followUpMode ?? "one-at-a-time");
this.sessionId = options.sessionId;
this.thinkingBudgets = options.thinkingBudgets;
this.transport = options.transport ?? "auto";
this.maxRetryDelayMs = options.maxRetryDelayMs;
this.toolExecution = options.toolExecution ?? "parallel";
}
/**
* Subscribe to agent lifecycle events.
*
* Listener promises are awaited in subscription order and are included in
* the current run's settlement. Listeners also receive the active abort
* signal for the current run.
*
* `agent_end` is the final emitted event for a run, but the agent does not
* become idle until all awaited listeners for that event have settled.
*/
subscribe(
listener: (event: AgentEvent, signal: AbortSignal) => Promise<void> | void,
): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
/**
* Current agent state.
*
* Assigning `state.tools` or `state.messages` copies the provided top-level array.
*/
get state(): AgentState {
return this.mutableState;
}
/** Controls how queued steering messages are drained. */
set steeringMode(mode: QueueMode) {
this.steeringQueue.mode = mode;
}
get steeringMode(): QueueMode {
return this.steeringQueue.mode;
}
/** Controls how queued follow-up messages are drained. */
set followUpMode(mode: QueueMode) {
this.followUpQueue.mode = mode;
}
get followUpMode(): QueueMode {
return this.followUpQueue.mode;
}
/** Queue a message to be injected after the current assistant turn finishes. */
steer(message: AgentMessage): void {
this.steeringQueue.enqueue(message);
}
/** Queue a message to run only after the agent would otherwise stop. */
followUp(message: AgentMessage): void {
this.followUpQueue.enqueue(message);
}
/** Remove all queued steering messages. */
clearSteeringQueue(): void {
this.steeringQueue.clear();
}
/** Remove all queued follow-up messages. */
clearFollowUpQueue(): void {
this.followUpQueue.clear();
}
/** Remove all queued steering and follow-up messages. */
clearAllQueues(): void {
this.clearSteeringQueue();
this.clearFollowUpQueue();
}
/** Returns true when either queue still contains pending messages. */
hasQueuedMessages(): boolean {
return this.steeringQueue.hasItems() || this.followUpQueue.hasItems();
}
/** Active abort signal for the current run, if any. */
get signal(): AbortSignal | undefined {
return this.activeRun?.abortController.signal;
}
/** Abort the current run, if one is active. */
abort(): void {
this.activeRun?.abortController.abort();
}
/**
* Resolve when the current run and all awaited event listeners have finished.
*
* This resolves after `agent_end` listeners settle.
*/
waitForIdle(): Promise<void> {
return this.activeRun?.promise ?? Promise.resolve();
}
/** Clear transcript state, runtime state, and queued messages. */
reset(): void {
this.mutableState.messages = [];
this.mutableState.isStreaming = false;
this.mutableState.streamingMessage = undefined;
this.mutableState.pendingToolCalls = new Set<string>();
this.mutableState.errorMessage = undefined;
this.clearFollowUpQueue();
this.clearSteeringQueue();
}
/** Start a new prompt from text, a single message, or a batch of messages. */
async prompt(message: AgentMessage | AgentMessage[]): Promise<void>;
async prompt(input: string, images?: ImageContent[]): Promise<void>;
async prompt(
input: string | AgentMessage | AgentMessage[],
images?: ImageContent[],
): Promise<void> {
if (this.activeRun) {
throw new Error(
"Agent is already processing a prompt. Use steer() or followUp() to queue messages, or wait for completion.",
);
}
const messages = this.normalizePromptInput(input, images);
await this.runPromptMessages(messages);
}
/** Continue from the current transcript. The last message must be a user or tool-result message. */
async continue(): Promise<void> {
if (this.activeRun) {
throw new Error("Agent is already processing. Wait for completion before continuing.");
}
const lastMessage = this.mutableState.messages[this.mutableState.messages.length - 1];
if (!lastMessage) {
throw new Error("No messages to continue from");
}
if (lastMessage.role === "assistant") {
const queuedSteering = this.steeringQueue.drain();
if (queuedSteering.length > 0) {
await this.runPromptMessages(queuedSteering, { skipInitialSteeringPoll: true });
return;
}
const queuedFollowUps = this.followUpQueue.drain();
if (queuedFollowUps.length > 0) {
await this.runPromptMessages(queuedFollowUps);
return;
}
throw new Error("Cannot continue from message role: assistant");
}
await this.runContinuation();
}
private normalizePromptInput(
input: string | AgentMessage | AgentMessage[],
images?: ImageContent[],
): AgentMessage[] {
if (Array.isArray(input)) {
return input;
}
if (typeof input !== "string") {
return [input];
}
const content: Array<TextContent | ImageContent> = [{ type: "text", text: input }];
if (images && images.length > 0) {
content.push(...images);
}
return [{ role: "user", content, timestamp: Date.now() }];
}
private async runPromptMessages(
messages: AgentMessage[],
options: { skipInitialSteeringPoll?: boolean } = {},
): Promise<void> {
await this.runWithLifecycle(async (signal) => {
await runAgentLoop(
messages,
this.createContextSnapshot(),
this.createLoopConfig(options),
(event) => this.processEvents(event),
signal,
this.streamFn,
);
});
}
private async runContinuation(): Promise<void> {
await this.runWithLifecycle(async (signal) => {
await runAgentLoopContinue(
this.createContextSnapshot(),
this.createLoopConfig(),
(event) => this.processEvents(event),
signal,
this.streamFn,
);
});
}
private createContextSnapshot(): AgentContext {
return {
systemPrompt: this.mutableState.systemPrompt,
messages: this.mutableState.messages.slice(),
tools: this.mutableState.tools.slice(),
};
}
private createLoopConfig(options: { skipInitialSteeringPoll?: boolean } = {}): AgentLoopConfig {
let skipInitialSteeringPoll = options.skipInitialSteeringPoll === true;
return {
model: this.mutableState.model,
thinkingLevel: this.mutableState.thinkingLevel,
reasoning: resolveAgentReasoningOption(
this.mutableState.model,
this.mutableState.thinkingLevel,
),
sessionId: this.sessionId,
onPayload: this.onPayload,
onResponse: this.onResponse,
transport: this.transport,
thinkingBudgets: this.thinkingBudgets,
maxRetryDelayMs: this.maxRetryDelayMs,
toolExecution: this.toolExecution,
beforeToolCall: this.beforeToolCall,
resolveDeferredTool: this.resolveDeferredTool,
afterToolCall: this.afterToolCall,
prepareNextTurn: this.prepareNextTurn
? async () => await this.prepareNextTurn?.(this.signal)
: undefined,
convertToLlm: this.convertToLlm,
transformContext: this.transformContext,
getApiKey: this.getApiKey,
getSteeringMessages: async () => {
if (skipInitialSteeringPoll) {
skipInitialSteeringPoll = false;
return [];
}
return this.steeringQueue.drain();
},
getFollowUpMessages: async () => this.followUpQueue.drain(),
};
}
private async runWithLifecycle(executor: (signal: AbortSignal) => Promise<void>): Promise<void> {
if (this.activeRun) {
throw new Error("Agent is already processing.");
}
const abortController = new AbortController();
let resolvePromise = () => {};
const promise = new Promise<void>((resolve) => {
resolvePromise = resolve;
});
this.activeRun = { promise, resolve: resolvePromise, abortController };
this.mutableState.isStreaming = true;
this.mutableState.streamingMessage = undefined;
this.mutableState.errorMessage = undefined;
try {
await executor(abortController.signal);
} catch (error) {
await this.handleRunFailure(error, abortController.signal.aborted);
} finally {
this.finishRun();
}
}
private async handleRunFailure(error: unknown, aborted: boolean): Promise<void> {
const failureMessage = {
role: "assistant",
content: [{ type: "text", text: "" }],
api: this.mutableState.model.api,
provider: this.mutableState.model.provider,
model: this.mutableState.model.id,
usage: EMPTY_USAGE,
stopReason: aborted ? "aborted" : "error",
errorMessage: error instanceof Error ? error.message : String(error),
timestamp: Date.now(),
} satisfies AgentMessage;
await this.processEvents({ type: "message_start", message: failureMessage });
await this.processEvents({ type: "message_end", message: failureMessage });
await this.processEvents({ type: "turn_end", message: failureMessage, toolResults: [] });
await this.processEvents({ type: "agent_end", messages: [failureMessage] });
}
private finishRun(): void {
this.mutableState.isStreaming = false;
this.mutableState.streamingMessage = undefined;
this.mutableState.pendingToolCalls = new Set<string>();
this.activeRun?.resolve();
this.activeRun = undefined;
}
/**
* Reduce internal state for a loop event, then await listeners.
*
* `agent_end` only means no further loop events will be emitted. The run is
* considered idle later, after all awaited listeners for `agent_end` finish
* and `finishRun()` clears runtime-owned state.
*/
private async processEvents(event: AgentEvent): Promise<void> {
switch (event.type) {
case "agent_start":
case "turn_start":
case "tool_execution_update":
break;
case "message_start":
this.mutableState.streamingMessage = event.message;
break;
case "message_update":
this.mutableState.streamingMessage = event.message;
break;
case "message_end":
this.mutableState.streamingMessage = undefined;
this.mutableState.messages.push(event.message);
break;
case "tool_execution_start": {
const pendingToolCalls = new Set(this.mutableState.pendingToolCalls);
pendingToolCalls.add(event.toolCallId);
this.mutableState.pendingToolCalls = pendingToolCalls;
break;
}
case "tool_execution_end": {
const pendingToolCalls = new Set(this.mutableState.pendingToolCalls);
pendingToolCalls.delete(event.toolCallId);
this.mutableState.pendingToolCalls = pendingToolCalls;
break;
}
case "turn_end":
if (event.message.role === "assistant" && event.message.errorMessage) {
this.mutableState.errorMessage = event.message.errorMessage;
}
break;
case "agent_end":
this.mutableState.streamingMessage = undefined;
break;
}
const signal = this.activeRun?.abortController.signal;
if (!signal) {
throw new Error("Agent listener invoked outside active run");
}
for (const listener of this.listeners) {
await listener(event, signal);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,324 @@
// Agent Core module implements branch summarization behavior.
import type { Model, StreamFn } from "../../../../llm-core/src/index.js";
import {
type AgentCoreCompletionRuntimeDeps,
resolveAgentCoreCompleteFn,
} from "../../runtime-deps.js";
import type { AgentMessage } from "../../types.js";
import {
asAgentMessage,
convertToLlm,
createBranchSummaryMessage,
createCompactionSummaryMessage,
createCustomMessage,
} from "../messages.js";
import type { BranchSummaryResult, Session, SessionTreeEntry } from "../types.js";
import { BranchSummaryError, err, ok, type Result } from "../types.js";
import { estimateTokens, SUMMARIZATION_SYSTEM_PROMPT } from "./compaction.js";
import {
computeFileLists,
createFileOps,
extractFileOpsFromMessage,
type FileOperations,
formatFileOperations,
serializeConversation,
} from "./utils.js";
/** File-operation details stored on generated branch summary entries. */
export interface BranchSummaryDetails {
/** Files read while exploring the summarized branch. */
readFiles: string[];
/** Files modified while exploring the summarized branch. */
modifiedFiles: string[];
}
export type { FileOperations } from "./utils.js";
/** Prepared branch content for summarization. */
export interface BranchPreparation {
/** Messages selected for the branch summary. */
messages: AgentMessage[];
/** File operations extracted from the branch. */
fileOps: FileOperations;
/** Estimated token count for selected messages. */
totalTokens: number;
}
/** Entries selected for branch summarization. */
export interface CollectEntriesResult {
/** Entries to summarize in chronological order. */
entries: SessionTreeEntry[];
/** Deepest common ancestor between the previous leaf and target entry. */
commonAncestorId: string | null;
}
/** Minimal tree entry shape needed to compare two session branches. */
export interface BranchPathEntry {
/** Stable entry id. */
id: string;
/** Parent entry id, or null for the session root. */
parentId: string | null;
}
/** Branch entries selected after comparing old and target paths. */
export interface CollectBranchPathEntriesResult<TEntry extends BranchPathEntry> {
/** Entries to summarize in chronological order. */
entries: TEntry[];
/** Deepest common ancestor between the previous leaf and target entry. */
commonAncestorId: string | null;
}
/** Options for generating a branch summary. */
export interface GenerateBranchSummaryOptions {
/** Model used for summarization. */
model: Model;
/** API key forwarded to the provider. */
apiKey: string;
/** Optional request headers forwarded to the provider. */
headers?: Record<string, string>;
/** Abort signal for the summarization request. */
signal: AbortSignal;
/** Runtime used to complete the summarization request. */
runtime?: AgentCoreCompletionRuntimeDeps;
/** Optional stream implementation used instead of the runtime complete function. */
streamFn?: StreamFn;
/** Optional instructions appended to or replacing the default prompt. */
customInstructions?: string;
/** Replace the default prompt with custom instructions instead of appending them. */
replaceInstructions?: boolean;
/** Tokens reserved for prompt and model output. Defaults to 16384. */
reserveTokens?: number;
}
/** Collect entries that should be summarized before navigating to a different session tree entry. */
export function collectEntriesForBranchSummaryFromBranches<TEntry extends BranchPathEntry>(
oldBranch: readonly TEntry[],
targetBranch: readonly TEntry[],
): CollectBranchPathEntriesResult<TEntry> {
const oldPath = new Set(oldBranch.map((entry) => entry.id));
let commonAncestorId: string | null = null;
for (let i = targetBranch.length - 1; i >= 0; i--) {
if (oldPath.has(targetBranch[i].id)) {
commonAncestorId = targetBranch[i].id;
break;
}
}
const firstSummarizedIndex =
commonAncestorId === null
? 0
: oldBranch.findIndex((entry) => entry.id === commonAncestorId) + 1;
return { entries: oldBranch.slice(firstSummarizedIndex), commonAncestorId };
}
/** Collect concrete session entries to summarize before moving from one leaf to another. */
export async function collectEntriesForBranchSummary(
session: Session,
oldLeafId: string | null,
targetId: string,
): Promise<CollectEntriesResult> {
if (!oldLeafId) {
return { entries: [], commonAncestorId: null };
}
const oldBranch = await session.getBranch(oldLeafId);
const targetPath = await session.getBranch(targetId);
return collectEntriesForBranchSummaryFromBranches(oldBranch, targetPath);
}
function getMessageFromEntry(entry: SessionTreeEntry): AgentMessage | undefined {
switch (entry.type) {
case "message":
if (entry.message.role === "toolResult") {
return undefined;
}
return entry.message;
case "custom_message":
return asAgentMessage(
createCustomMessage(
entry.customType,
entry.content,
entry.display,
entry.details,
entry.timestamp,
),
);
case "branch_summary":
return asAgentMessage(
createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp),
);
case "compaction":
return asAgentMessage(
createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp),
);
case "thinking_level_change":
case "model_change":
case "custom":
case "label":
case "session_info":
case "leaf":
return undefined;
}
return undefined;
}
/** Prepare branch entries for summarization within an optional token budget. */
export function prepareBranchEntries(
entries: SessionTreeEntry[],
tokenBudget = 0,
): BranchPreparation {
const messages: AgentMessage[] = [];
const fileOps = createFileOps();
let totalTokens = 0;
for (const entry of entries) {
if (entry.type === "branch_summary" && !entry.fromHook && entry.details) {
const details = entry.details as BranchSummaryDetails;
if (Array.isArray(details.readFiles)) {
for (const f of details.readFiles) {
fileOps.read.add(f);
}
}
if (Array.isArray(details.modifiedFiles)) {
for (const f of details.modifiedFiles) {
fileOps.edited.add(f);
}
}
}
}
for (let i = entries.length - 1; i >= 0; i--) {
const entry = entries[i];
const message = getMessageFromEntry(entry);
if (!message) {
continue;
}
extractFileOpsFromMessage(message, fileOps);
const tokens = estimateTokens(message);
if (tokenBudget > 0 && totalTokens + tokens > tokenBudget) {
// Prefer already-compressed summaries when the budget is almost filled; they
// preserve older branch context better than dropping the whole prefix.
if (entry.type === "compaction" || entry.type === "branch_summary") {
if (totalTokens < tokenBudget * 0.9) {
messages.unshift(message);
totalTokens += tokens;
}
}
break;
}
messages.unshift(message);
totalTokens += tokens;
}
return { messages, fileOps, totalTokens };
}
const BRANCH_SUMMARY_PREAMBLE = `The user explored a different conversation branch before returning here.
Summary of that exploration:
`;
const BRANCH_SUMMARY_PROMPT = `Create a structured summary of this conversation branch for context when returning later.
Use this EXACT format:
## Goal
[What was the user trying to accomplish in this branch?]
## Constraints & Preferences
- [Any constraints, preferences, or requirements mentioned]
- [Or "(none)" if none were mentioned]
## Progress
### Done
- [x] [Completed tasks/changes]
### In Progress
- [ ] [Work that was started but not finished]
### Blocked
- [Issues preventing progress, if any]
## Key Decisions
- **[Decision]**: [Brief rationale]
## Next Steps
1. [What should happen next to continue this work]
Keep each section concise. Preserve exact file paths, function names, and error messages.`;
/** Generate a summary for abandoned branch entries. */
export async function generateBranchSummary(
entries: SessionTreeEntry[],
options: GenerateBranchSummaryOptions,
): Promise<Result<BranchSummaryResult, BranchSummaryError>> {
const {
model,
apiKey,
headers,
signal,
customInstructions,
replaceInstructions,
reserveTokens = 16384,
} = options;
const contextWindow = model.contextWindow || 128000;
const tokenBudget = contextWindow - reserveTokens;
const { messages, fileOps } = prepareBranchEntries(entries, tokenBudget);
if (messages.length === 0) {
return ok({ summary: "No content to summarize", readFiles: [], modifiedFiles: [] });
}
const llmMessages = convertToLlm(messages);
const conversationText = serializeConversation(llmMessages);
let instructions: string;
if (replaceInstructions && customInstructions) {
instructions = customInstructions;
} else if (customInstructions) {
instructions = `${BRANCH_SUMMARY_PROMPT}\n\nAdditional focus: ${customInstructions}`;
} else {
instructions = BRANCH_SUMMARY_PROMPT;
}
const promptText = `<conversation>\n${conversationText}\n</conversation>\n\n${instructions}`;
const summarizationMessages = [
{
role: "user" as const,
content: [{ type: "text" as const, text: promptText }],
timestamp: Date.now(),
},
];
const context = { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages };
const streamOptions = { apiKey, headers, signal, maxTokens: 2048 };
const response = options.streamFn
? await (await options.streamFn(model, context, streamOptions)).result()
: await resolveAgentCoreCompleteFn(options.runtime)(model, context, streamOptions);
if (response.stopReason === "aborted") {
return err(
new BranchSummaryError("aborted", response.errorMessage || "Branch summary aborted"),
);
}
if (response.stopReason === "error") {
return err(
new BranchSummaryError(
"summarization_failed",
`Branch summary failed: ${response.errorMessage || "Unknown error"}`,
),
);
}
let summary = response.content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map((c) => c.text)
.join("\n");
summary = BRANCH_SUMMARY_PREAMBLE + summary;
const { readFiles, modifiedFiles } = computeFileLists(fileOps);
summary += formatFileOperations(readFiles, modifiedFiles);
return ok({
summary: summary || "No summary generated",
readFiles,
modifiedFiles,
});
}

View File

@@ -0,0 +1,106 @@
import { describe, expect, it } from "vitest";
import type { ImageContent } from "../../llm.js";
import type { AgentMessage } from "../../types.js";
import type { SessionTreeEntry } from "../types.js";
import { estimateTokens, findCutPoint } from "./compaction.js";
const IMAGE_PAYLOAD = "a".repeat(1_500_000);
function imageBlock(): ImageContent {
return { type: "image", data: IMAGE_PAYLOAD, mimeType: "image/png" };
}
function userImage(timestamp: number): AgentMessage {
return { role: "user", content: [imageBlock()], timestamp };
}
function userText(text: string, timestamp: number): AgentMessage {
return { role: "user", content: [{ type: "text", text }], timestamp };
}
function toolResultImage(timestamp: number): AgentMessage {
return {
role: "toolResult",
toolCallId: "call-1",
toolName: "screenshot",
content: [imageBlock()],
isError: false,
timestamp,
};
}
function assistantText(text: string, timestamp: number): AgentMessage {
return {
role: "assistant",
content: [{ type: "text", text }],
api: "anthropic-messages",
provider: "anthropic",
model: "claude-fable-5",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp,
};
}
function messageEntry(message: AgentMessage, index: number): SessionTreeEntry {
return {
type: "message",
id: `entry-${index}`,
parentId: index === 0 ? null : `entry-${index - 1}`,
timestamp: new Date(message.timestamp).toISOString(),
message,
};
}
function buildTranscript(recentUserTurns: AgentMessage[]): SessionTreeEntry[] {
const messages: AgentMessage[] = [userText("start of the conversation", 1)];
let timestamp = 2;
for (const turn of recentUserTurns) {
messages.push(assistantText("ok", timestamp++));
messages.push(turn);
}
return messages.map((message, index) => messageEntry(message, index));
}
describe("estimateTokens image accounting", () => {
it("charges a user-message image block the same as a tool-result image block", () => {
const userTokens = estimateTokens(userImage(1));
const toolTokens = estimateTokens(toolResultImage(1));
expect(userTokens).toBe(toolTokens);
expect(userTokens).toBeGreaterThanOrEqual(1200);
});
});
describe("findCutPoint with image-heavy recent turns", () => {
it("trims image-dominated user turns instead of keeping the whole transcript", () => {
const entries = buildTranscript([userImage(10), userImage(20), userImage(30)]);
const result = findCutPoint(entries, 0, entries.length, 1500);
expect(result.firstKeptEntryIndex).toBeGreaterThan(0);
});
it("matches the cut point of an equivalent text-cost control", () => {
const equivalentText = "x".repeat(4800);
const imageEntries = buildTranscript([userImage(10), userImage(20), userImage(30)]);
const textEntries = buildTranscript([
userText(equivalentText, 10),
userText(equivalentText, 20),
userText(equivalentText, 30),
]);
const imageResult = findCutPoint(imageEntries, 0, imageEntries.length, 1500);
const textResult = findCutPoint(textEntries, 0, textEntries.length, 1500);
expect(textResult.firstKeptEntryIndex).toBeGreaterThan(0);
expect(imageResult.firstKeptEntryIndex).toBe(textResult.firstKeptEntryIndex);
});
});

View File

@@ -0,0 +1,124 @@
import { describe, expect, it } from "vitest";
import type { AgentMessage } from "../../types.js";
import type { SessionTreeEntry } from "../types.js";
import { estimateTokens, findCutPoint } from "./compaction.js";
const KEEP_RECENT_TOKENS = 20000;
const LARGE_TOOL_OUTPUT = "x".repeat(120000);
function userText(text: string, timestamp: number): AgentMessage {
return { role: "user", content: [{ type: "text", text }], timestamp };
}
function assistantText(text: string, timestamp: number): AgentMessage {
return {
role: "assistant",
content: [{ type: "text", text }],
api: "anthropic-messages",
provider: "anthropic",
model: "claude-fable-5",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp,
};
}
function toolResultText(text: string, timestamp: number): AgentMessage {
return {
role: "toolResult",
toolCallId: "call-1",
toolName: "bash",
content: [{ type: "text", text }],
isError: false,
timestamp,
};
}
function nestedToolResult(
block: { type: string; content?: unknown; text?: string },
timestamp: number,
): AgentMessage {
return {
role: "toolResult",
toolCallId: "call-1",
toolName: "codex_progress",
content: [
{
id: "call-1",
toolUseId: "call-1",
...block,
},
],
isError: false,
timestamp,
} as unknown as AgentMessage;
}
function messageEntry(message: AgentMessage, index: number): SessionTreeEntry {
return {
type: "message",
id: `entry-${index}`,
parentId: index === 0 ? null : `entry-${index - 1}`,
timestamp: new Date(message.timestamp).toISOString(),
message,
};
}
function buildTranscript(): SessionTreeEntry[] {
return buildTranscriptWithToolResult(toolResultText(LARGE_TOOL_OUTPUT, 5));
}
function buildTranscriptWithToolResult(toolResult: AgentMessage): SessionTreeEntry[] {
const messages: AgentMessage[] = [
userText("start of the conversation", 1),
assistantText("first reply", 2),
userText("please run the command", 3),
assistantText("running it now", 4),
toolResult,
];
return messages.map((message, index) => messageEntry(message, index));
}
describe("findCutPoint with a trailing oversized tool result", () => {
it("counts the final tool result as larger than the keep budget", () => {
const trailing = toolResultText(LARGE_TOOL_OUTPUT, 5);
expect(estimateTokens(trailing)).toBeGreaterThanOrEqual(KEEP_RECENT_TOKENS);
});
it("trims the prefix instead of keeping the whole transcript", () => {
const entries = buildTranscript();
const result = findCutPoint(entries, 0, entries.length, KEEP_RECENT_TOKENS);
expect(result.firstKeptEntryIndex).toBeGreaterThan(0);
expect(result.firstKeptEntryIndex).toBe(3);
});
it.each([
{
name: "Codex toolResult text",
block: { type: "toolResult", content: "duplicate", text: LARGE_TOOL_OUTPUT },
},
{
name: "snake-case tool_result content",
block: { type: "tool_result", content: LARGE_TOOL_OUTPUT },
},
])("counts and trims the prefix for $name", ({ block }) => {
const trailing = nestedToolResult(block, 5);
const entries = buildTranscriptWithToolResult(trailing);
expect(estimateTokens(trailing)).toBeGreaterThanOrEqual(KEEP_RECENT_TOKENS);
const result = findCutPoint(entries, 0, entries.length, KEEP_RECENT_TOKENS);
expect(result.firstKeptEntryIndex).toBeGreaterThan(0);
expect(result.firstKeptEntryIndex).toBe(3);
});
});

View File

@@ -0,0 +1,256 @@
import { describe, expect, it, vi } from "vitest";
import { createAssistantMessageEventStream } from "../../llm.js";
import type { AssistantMessage, Model, StreamFn } from "../../llm.js";
import {
calculateContextTokens,
compact,
estimateContextTokens,
generateSummary,
} from "./compaction.js";
import { createFileOps } from "./utils.js";
describe("calculateContextTokens", () => {
it("prefers the final-iteration context snapshot over aggregate billing usage", () => {
expect(
calculateContextTokens({
input: 12,
output: 15_104,
cacheRead: 819_661,
cacheWrite: 93_130,
contextUsage: {
state: "available",
promptTokens: 148_874,
totalTokens: 163_978,
},
totalTokens: 927_907,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
}),
).toBe(163_978);
});
it("preserves the numeric compatibility fallback when the snapshot is unavailable", () => {
expect(
calculateContextTokens({
input: 12,
output: 15_104,
cacheRead: 819_661,
cacheWrite: 93_130,
contextUsage: { state: "unavailable" },
totalTokens: 927_907,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
}),
).toBe(927_907);
});
it("estimates the transcript instead of using aggregate billing when context is unavailable", () => {
const estimate = estimateContextTokens([
{ role: "user", content: "hello", timestamp: 0 },
{
role: "assistant",
content: [{ type: "text", text: "done" }],
api: "anthropic-messages",
provider: "anthropic",
model: "claude-fable-5",
usage: {
input: 12,
output: 15_104,
cacheRead: 819_661,
cacheWrite: 93_130,
contextUsage: { state: "unavailable" },
totalTokens: 927_907,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: 1,
},
]);
expect(estimate.tokens).toBeLessThan(927_907);
expect(estimate.tokens).toBeGreaterThan(0);
expect(estimate.usageTokens).toBe(0);
expect(estimate.lastUsageIndex).toBeNull();
});
it("uses the previous exact snapshot and estimates only the unavailable tail", () => {
const estimate = estimateContextTokens([
{
role: "assistant",
content: [{ type: "text", text: "previous" }],
api: "anthropic-messages",
provider: "anthropic",
model: "claude-fable-5",
usage: {
input: 12,
output: 1_000,
cacheRead: 148_862,
cacheWrite: 0,
contextUsage: {
state: "available",
promptTokens: 148_874,
totalTokens: 149_874,
},
totalTokens: 149_874,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: 0,
},
{ role: "user", content: "next", timestamp: 1 },
{
role: "assistant",
content: [{ type: "text", text: "done" }],
api: "anthropic-messages",
provider: "anthropic",
model: "claude-fable-5",
usage: {
input: 12,
output: 15_104,
cacheRead: 819_661,
cacheWrite: 93_130,
contextUsage: { state: "unavailable" },
totalTokens: 927_907,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: 2,
},
]);
expect(estimate.usageTokens).toBe(149_874);
expect(estimate.tokens).toBeGreaterThan(149_874);
expect(estimate.tokens).toBeLessThan(927_907);
expect(estimate.lastUsageIndex).toBe(0);
});
});
describe("generateSummary thinking options", () => {
it("maps explicit Fable off to low effort for compaction", async () => {
const model: Model = {
id: "production-fable",
name: "Production Fable",
api: "anthropic-messages",
provider: "anthropic",
baseUrl: "https://api.anthropic.com",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1_000_000,
maxTokens: 128_000,
params: { canonicalModelId: "claude-fable-5" },
};
const summaryMessage: AssistantMessage = {
role: "assistant",
content: [{ type: "text", text: "summary" }],
api: model.api,
provider: model.provider,
model: model.id,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: 1,
};
const streamFn = vi.fn<StreamFn>((_model, context, options) => {
expect(options?.reasoning).toBe("low");
expect(context.systemPrompt).toContain("user and an AI assistant");
expect(context.systemPrompt).not.toContain("AI coding assistant");
const stream = createAssistantMessageEventStream();
stream.push({ type: "done", reason: "stop", message: summaryMessage });
stream.end();
return stream;
});
const result = await generateSummary(
[{ role: "user", content: "hello", timestamp: 1 }],
model,
1000,
undefined,
undefined,
undefined,
undefined,
undefined,
"off",
streamFn,
);
expect(result).toEqual({ ok: true, value: "summary" });
expect(streamFn).toHaveBeenCalledOnce();
});
});
describe("split-turn compaction", () => {
it("serializes history and turn-prefix summaries", async () => {
const model: Model = {
id: "summary-model",
name: "Summary Model",
api: "test-api",
provider: "test-provider",
baseUrl: "https://example.test",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 100_000,
maxTokens: 8_000,
};
let active = 0;
let maxActive = 0;
let callCount = 0;
const streamFn = vi.fn<StreamFn>(() => {
active++;
maxActive = Math.max(maxActive, active);
callCount++;
const stream = createAssistantMessageEventStream();
setTimeout(() => {
active--;
const message: AssistantMessage = {
role: "assistant",
content: [{ type: "text", text: `summary-${callCount}` }],
api: model.api,
provider: model.provider,
model: model.id,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: 1,
};
stream.push({ type: "done", reason: "stop", message });
stream.end();
}, 5);
return stream;
});
const result = await compact(
{
firstKeptEntryId: "kept-entry",
messagesToSummarize: [{ role: "user", content: "history", timestamp: 1 }],
turnPrefixMessages: [{ role: "user", content: "prefix", timestamp: 2 }],
isSplitTurn: true,
tokensBefore: 100,
fileOps: createFileOps(),
settings: { enabled: true, reserveTokens: 1_000, keepRecentTokens: 100 },
},
model,
undefined,
undefined,
undefined,
undefined,
undefined,
streamFn,
);
expect(result.ok).toBe(true);
expect(streamFn).toHaveBeenCalledTimes(2);
expect(maxActive).toBe(1);
});
});

View File

@@ -0,0 +1,900 @@
// Agent Core module implements compaction behavior.
import {
resolveClaudeFable5ModelIdentity,
type AssistantMessage,
type Context,
type Model,
type SimpleStreamOptions,
type StreamFn,
type Usage,
} from "../../../../llm-core/src/index.js";
import { resolveAgentReasoningOption } from "../../reasoning.js";
import {
type AgentCoreCompletionRuntimeDeps,
resolveAgentCoreCompleteFn,
} from "../../runtime-deps.js";
import type { AgentMessage, ThinkingLevel } from "../../types.js";
import {
asAgentMessage,
convertToLlm,
createBranchSummaryMessage,
createCompactionSummaryMessage,
createCustomMessage,
type HarnessMessage,
} from "../messages.js";
import { buildSessionContext } from "../session/session.js";
import {
type CompactionEntry,
CompactionError,
err,
ok,
type Result,
type SessionTreeEntry,
} from "../types.js";
import {
computeFileLists,
createFileOps,
extractFileOpsFromMessage,
type FileOperations,
formatFileOperations,
getCompactionContentBlockText,
serializeConversation,
} from "./utils.js";
/** File-operation details stored on generated compaction entries. */
export interface CompactionDetails {
/** Files read in the compacted history. */
readFiles: string[];
/** Files modified in the compacted history. */
modifiedFiles: string[];
}
function safeJsonStringify(value: unknown): string {
try {
return JSON.stringify(value) ?? "undefined";
} catch {
return "[unserializable]";
}
}
function extractFileOperations(
messages: AgentMessage[],
entries: SessionTreeEntry[],
prevCompactionIndex: number,
): FileOperations {
const fileOps = createFileOps();
if (prevCompactionIndex >= 0) {
const prevCompaction = entries[prevCompactionIndex] as CompactionEntry;
if (!prevCompaction.fromHook && prevCompaction.details) {
const details = prevCompaction.details as CompactionDetails;
if (Array.isArray(details.readFiles)) {
for (const f of details.readFiles) {
fileOps.read.add(f);
}
}
if (Array.isArray(details.modifiedFiles)) {
for (const f of details.modifiedFiles) {
fileOps.edited.add(f);
}
}
}
}
for (const msg of messages) {
extractFileOpsFromMessage(msg, fileOps);
}
return fileOps;
}
function getMessageFromEntry(entry: SessionTreeEntry): AgentMessage | undefined {
if (entry.type === "message") {
return entry.message;
}
if (entry.type === "custom_message") {
return asAgentMessage(
createCustomMessage(
entry.customType,
entry.content,
entry.display,
entry.details,
entry.timestamp,
),
);
}
if (entry.type === "branch_summary") {
return asAgentMessage(createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp));
}
if (entry.type === "compaction") {
return asAgentMessage(
createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp),
);
}
return undefined;
}
function getMessageFromEntryForCompaction(entry: SessionTreeEntry): AgentMessage | undefined {
if (entry.type === "compaction") {
return undefined;
}
return getMessageFromEntry(entry);
}
/** Generated compaction data ready to be persisted as a compaction entry. */
export interface CompactionResult<T = unknown> {
/** Summary text that replaces compacted history in future context. */
summary: string;
/** Entry id where retained history starts. */
firstKeptEntryId: string;
/** Estimated context tokens before compaction. */
tokensBefore: number;
/** Optional implementation-specific details stored with the compaction entry. */
details?: T;
}
/** Compaction thresholds and retention settings. */
export interface CompactionSettings {
/** Enable automatic compaction decisions. */
enabled: boolean;
/** Tokens reserved for summary prompt and output. */
reserveTokens: number;
/** Approximate recent-context tokens to keep after compaction. */
keepRecentTokens: number;
}
/** Default compaction settings used by the harness. */
export const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = {
enabled: true,
reserveTokens: 16384,
keepRecentTokens: 20000,
};
/** Calculate total context tokens from provider usage. */
export function calculateContextTokens(usage: Usage): number {
if (usage.contextUsage?.state === "available") {
return usage.contextUsage.totalTokens;
}
return usage.totalTokens || usage.input + usage.output + usage.cacheRead + usage.cacheWrite;
}
function getAssistantUsage(msg: AgentMessage): Usage | undefined {
if (msg.role === "assistant" && "usage" in msg) {
const assistantMsg = msg;
if (
assistantMsg.stopReason !== "aborted" &&
assistantMsg.stopReason !== "error" &&
assistantMsg.usage
) {
return assistantMsg.usage;
}
}
return undefined;
}
/** Return usage from the last successful assistant message in session entries. */
export function getLastAssistantUsage(entries: SessionTreeEntry[]): Usage | undefined {
for (let i = entries.length - 1; i >= 0; i--) {
const entry = entries[i];
if (entry.type === "message") {
const usage = getAssistantUsage(entry.message);
if (usage) {
return usage;
}
}
}
return undefined;
}
/** Estimated context-token usage for a message list. */
export interface ContextUsageEstimate {
/** Estimated total context tokens. */
tokens: number;
/** Tokens reported by the most recent assistant usage block. */
usageTokens: number;
/** Estimated tokens not covered by usable provider usage. */
trailingTokens: number;
/** Index of the message that provided usage, or null when none exists. */
lastUsageIndex: number | null;
}
function getLastAssistantUsageInfo(
messages: AgentMessage[],
): { usage: Usage; index: number } | undefined {
for (let i = messages.length - 1; i >= 0; i--) {
const usage = getAssistantUsage(messages[i]);
if (usage && usage.contextUsage?.state !== "unavailable") {
return { usage, index: i };
}
}
return undefined;
}
/** Estimate context tokens for messages using provider usage when available. */
export function estimateContextTokens(messages: AgentMessage[]): ContextUsageEstimate {
const usageInfo = getLastAssistantUsageInfo(messages);
if (!usageInfo) {
let estimated = 0;
for (const message of messages) {
estimated += estimateTokens(message);
}
return {
tokens: estimated,
usageTokens: 0,
trailingTokens: estimated,
lastUsageIndex: null,
};
}
const usageTokens = calculateContextTokens(usageInfo.usage);
let trailingTokens = 0;
for (let i = usageInfo.index + 1; i < messages.length; i++) {
trailingTokens += estimateTokens(messages[i]);
}
return {
tokens: usageTokens + trailingTokens,
usageTokens,
trailingTokens,
lastUsageIndex: usageInfo.index,
};
}
/** Return whether context usage exceeds the configured compaction threshold. */
export function shouldCompact(
contextTokens: number,
contextWindow: number,
settings: CompactionSettings,
): boolean {
if (!settings.enabled) {
return false;
}
return contextTokens > contextWindow - settings.reserveTokens;
}
const IMAGE_BLOCK_CHARS = 4800;
function countContentBlockChars(
content: Array<{ type: string; content?: unknown; text?: string }>,
): number {
let chars = 0;
for (const block of content) {
if (block.type === "image") {
chars += IMAGE_BLOCK_CHARS;
} else {
chars += getCompactionContentBlockText(block).length;
}
}
return chars;
}
/** Estimate token count for one message using a conservative character heuristic. */
export function estimateTokens(message: AgentMessage): number {
let chars = 0;
const harnessMessage = message as HarnessMessage;
switch (harnessMessage.role) {
case "user": {
const content = (
harnessMessage as { content: string | Array<{ type: string; text?: string }> }
).content;
if (typeof content === "string") {
chars = content.length;
} else if (Array.isArray(content)) {
chars = countContentBlockChars(content);
}
return Math.ceil(chars / 4);
}
case "assistant": {
const assistant = harnessMessage;
for (const block of assistant.content) {
if (block.type === "text") {
chars += block.text.length;
} else if (block.type === "thinking") {
chars += block.thinking.length;
} else if (block.type === "toolCall") {
chars += block.name.length + safeJsonStringify(block.arguments).length;
}
}
return Math.ceil(chars / 4);
}
case "custom":
case "toolResult": {
if (typeof harnessMessage.content === "string") {
chars = harnessMessage.content.length;
} else {
chars = countContentBlockChars(harnessMessage.content);
}
return Math.ceil(chars / 4);
}
case "bashExecution": {
chars = harnessMessage.command.length + harnessMessage.output.length;
return Math.ceil(chars / 4);
}
case "branchSummary":
case "compactionSummary": {
chars = harnessMessage.summary.length;
return Math.ceil(chars / 4);
}
}
return 0;
}
function findValidCutPoints(
entries: SessionTreeEntry[],
startIndex: number,
endIndex: number,
): number[] {
const cutPoints: number[] = [];
for (let i = startIndex; i < endIndex; i++) {
const entry = entries[i];
switch (entry.type) {
case "message": {
const role = (entry.message as HarnessMessage).role;
switch (role) {
case "bashExecution":
case "custom":
case "branchSummary":
case "compactionSummary":
case "user":
case "assistant":
cutPoints.push(i);
break;
case "toolResult":
break;
}
break;
}
case "thinking_level_change":
case "model_change":
case "compaction":
case "branch_summary":
case "custom":
case "custom_message":
case "label":
case "session_info":
case "leaf":
break;
}
if (entry.type === "branch_summary" || entry.type === "custom_message") {
cutPoints.push(i);
}
}
return cutPoints;
}
/** Find the user-visible message that starts the turn containing an entry. */
export function findTurnStartIndex(
entries: SessionTreeEntry[],
entryIndex: number,
startIndex: number,
): number {
for (let i = entryIndex; i >= startIndex; i--) {
const entry = entries[i];
if (entry.type === "branch_summary" || entry.type === "custom_message") {
return i;
}
if (entry.type === "message") {
const role = (entry.message as HarnessMessage).role;
if (role === "user" || role === "bashExecution") {
return i;
}
}
}
return -1;
}
/** Cut point selected for compaction. */
export interface CutPointResult {
/** Index of the first entry retained after compaction. */
firstKeptEntryIndex: number;
/** Index of the turn-start entry when the cut splits a turn, otherwise -1. */
turnStartIndex: number;
/** Whether the selected cut point splits an in-progress turn. */
isSplitTurn: boolean;
}
/** Find the compaction cut point that keeps approximately the requested recent-token budget. */
export function findCutPoint(
entries: SessionTreeEntry[],
startIndex: number,
endIndex: number,
keepRecentTokens: number,
): CutPointResult {
const cutPoints = findValidCutPoints(entries, startIndex, endIndex);
if (cutPoints.length === 0) {
return { firstKeptEntryIndex: startIndex, turnStartIndex: -1, isSplitTurn: false };
}
let accumulatedTokens = 0;
let cutIndex = cutPoints[0];
for (let i = endIndex - 1; i >= startIndex; i--) {
const entry = entries[i];
if (entry.type !== "message") {
continue;
}
const messageTokens = estimateTokens(entry.message);
accumulatedTokens += messageTokens;
if (accumulatedTokens >= keepRecentTokens) {
cutIndex = cutPoints[cutPoints.length - 1];
for (const cutPoint of cutPoints) {
if (cutPoint >= i) {
cutIndex = cutPoint;
break;
}
}
break;
}
}
while (cutIndex > startIndex) {
const prevEntry = entries[cutIndex - 1];
if (prevEntry.type === "compaction") {
break;
}
if (prevEntry.type === "message") {
break;
}
cutIndex--;
}
const cutEntry = entries[cutIndex];
const isUserMessage = cutEntry.type === "message" && cutEntry.message.role === "user";
const turnStartIndex = isUserMessage ? -1 : findTurnStartIndex(entries, cutIndex, startIndex);
return {
firstKeptEntryIndex: cutIndex,
turnStartIndex,
isSplitTurn: !isUserMessage && turnStartIndex !== -1,
};
}
export const SUMMARIZATION_SYSTEM_PROMPT = `You are a context summarization assistant. Your task is to read a conversation between a user and an AI assistant, then produce a structured summary following the exact format specified.
Do NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the structured summary.`;
const SUMMARIZATION_PROMPT = `The messages above are a conversation to summarize. Create a structured context checkpoint summary that another LLM will use to continue the work.
Use this EXACT format:
## Goal
[What is the user trying to accomplish? Can be multiple items if the session covers different tasks.]
## Constraints & Preferences
- [Any constraints, preferences, or requirements mentioned by user]
- [Or "(none)" if none were mentioned]
## Progress
### Done
- [x] [Completed tasks/changes]
### In Progress
- [ ] [Current work]
### Blocked
- [Issues preventing progress, if any]
## Key Decisions
- **[Decision]**: [Brief rationale]
## Next Steps
1. [Ordered list of what should happen next]
## Critical Context
- [Any data, examples, or references needed to continue]
- [Or "(none)" if not applicable]
Keep each section concise. Preserve exact file paths, function names, and error messages.`;
const UPDATE_SUMMARIZATION_PROMPT = `The messages above are NEW conversation messages to incorporate into the existing summary provided in <previous-summary> tags.
Update the existing structured summary with new information. RULES:
- PRESERVE all existing information from the previous summary
- ADD new progress, decisions, and context from the new messages
- UPDATE the Progress section: move items from "In Progress" to "Done" when completed
- UPDATE "Next Steps" based on what was accomplished
- PRESERVE exact file paths, function names, and error messages
- If something is no longer relevant, you may remove it
Use this EXACT format:
## Goal
[Preserve existing goals, add new ones if the task expanded]
## Constraints & Preferences
- [Preserve existing, add new ones discovered]
## Progress
### Done
- [x] [Include previously done items AND newly completed items]
### In Progress
- [ ] [Current work - update based on progress]
### Blocked
- [Current blockers - remove if resolved]
## Key Decisions
- **[Decision]**: [Brief rationale] (preserve all previous, add new)
## Next Steps
1. [Update based on current state]
## Critical Context
- [Preserve important context, add new if needed]
Keep each section concise. Preserve exact file paths, function names, and error messages.`;
function createSummarizationOptions(
model: Model,
maxTokens: number,
apiKey: string | undefined,
headers: Record<string, string> | undefined,
signal: AbortSignal | undefined,
thinkingLevel: ThinkingLevel | undefined,
): SimpleStreamOptions {
const options: SimpleStreamOptions = { maxTokens, signal, apiKey, headers };
const fableReasoning =
(model.api === "anthropic-messages" || model.api === "bedrock-converse-stream") &&
resolveClaudeFable5ModelIdentity(model) !== undefined;
if ((model.reasoning || fableReasoning) && thinkingLevel) {
options.reasoning = resolveAgentReasoningOption(model, thinkingLevel);
}
return options;
}
async function completeSummarization(
model: Model,
context: Context,
options: SimpleStreamOptions,
streamFn?: StreamFn,
runtime?: AgentCoreCompletionRuntimeDeps,
): Promise<AssistantMessage> {
if (streamFn) {
return (await streamFn(model, context, options)).result();
}
return await resolveAgentCoreCompleteFn(runtime)(model, context, options);
}
/** Runs one summarization completion and maps abort/error stops to CompactionError. */
async function runSummarizationCompletion(params: {
promptText: string;
model: Model;
maxTokens: number;
apiKey: string | undefined;
headers?: Record<string, string>;
signal?: AbortSignal;
thinkingLevel?: ThinkingLevel;
streamFn?: StreamFn;
runtime?: AgentCoreCompletionRuntimeDeps;
errorLabel: string;
}): Promise<Result<string, CompactionError>> {
const summarizationMessages = [
{
role: "user" as const,
content: [{ type: "text" as const, text: params.promptText }],
timestamp: Date.now(),
},
];
const response = await completeSummarization(
params.model,
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, messages: summarizationMessages },
createSummarizationOptions(
params.model,
params.maxTokens,
params.apiKey,
params.headers,
params.signal,
params.thinkingLevel,
),
params.streamFn,
params.runtime,
);
if (response.stopReason === "aborted") {
return err(
new CompactionError("aborted", response.errorMessage || `${params.errorLabel} aborted`),
);
}
if (response.stopReason === "error") {
return err(
new CompactionError(
"summarization_failed",
`${params.errorLabel} failed: ${response.errorMessage || "Unknown error"}`,
),
);
}
return ok(
response.content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map((c) => c.text)
.join("\n"),
);
}
/** Generate or update a conversation summary for compaction. */
export async function generateSummary(
currentMessages: AgentMessage[],
model: Model,
reserveTokens: number,
apiKey: string | undefined,
headers?: Record<string, string>,
signal?: AbortSignal,
customInstructions?: string,
previousSummary?: string,
thinkingLevel?: ThinkingLevel,
streamFn?: StreamFn,
runtime?: AgentCoreCompletionRuntimeDeps,
): Promise<Result<string, CompactionError>> {
const maxTokens = Math.min(
Math.floor(0.8 * reserveTokens),
model.maxTokens > 0 ? model.maxTokens : Number.POSITIVE_INFINITY,
);
let basePrompt = previousSummary ? UPDATE_SUMMARIZATION_PROMPT : SUMMARIZATION_PROMPT;
if (customInstructions) {
basePrompt = `${basePrompt}\n\nAdditional focus: ${customInstructions}`;
}
const llmMessages = convertToLlm(currentMessages);
const conversationText = serializeConversation(llmMessages);
let promptText = `<conversation>\n${conversationText}\n</conversation>\n\n`;
if (previousSummary) {
promptText += `<previous-summary>\n${previousSummary}\n</previous-summary>\n\n`;
}
promptText += basePrompt;
return await runSummarizationCompletion({
promptText,
model,
maxTokens,
apiKey,
headers,
signal,
thinkingLevel,
streamFn,
runtime,
errorLabel: "Summarization",
});
}
/** Prepared inputs for a compaction run. */
export interface CompactionPreparation {
/** Entry id where retained history starts. */
firstKeptEntryId: string;
/** Messages summarized into the history summary. */
messagesToSummarize: AgentMessage[];
/** Prefix messages summarized separately when compaction splits a turn. */
turnPrefixMessages: AgentMessage[];
/** Whether compaction splits a turn. */
isSplitTurn: boolean;
/** Estimated context tokens before compaction. */
tokensBefore: number;
/** Previous compaction summary used for iterative updates. */
previousSummary?: string;
/** File operations extracted from summarized history. */
fileOps: FileOperations;
/** Settings used to prepare compaction. */
settings: CompactionSettings;
}
/** Prepare session entries for compaction, or return undefined when compaction is not applicable. */
export function prepareCompaction(
pathEntries: SessionTreeEntry[],
settings: CompactionSettings,
): Result<CompactionPreparation | undefined, CompactionError> {
if (pathEntries.length === 0 || pathEntries[pathEntries.length - 1].type === "compaction") {
return ok(undefined);
}
let prevCompactionIndex = -1;
for (let i = pathEntries.length - 1; i >= 0; i--) {
if (pathEntries[i].type === "compaction") {
prevCompactionIndex = i;
break;
}
}
let previousSummary: string | undefined;
let boundaryStart = 0;
if (prevCompactionIndex >= 0) {
const prevCompaction = pathEntries[prevCompactionIndex] as CompactionEntry;
previousSummary = prevCompaction.summary;
const firstKeptEntryIndex = pathEntries.findIndex(
(entry) => entry.id === prevCompaction.firstKeptEntryId,
);
boundaryStart = firstKeptEntryIndex >= 0 ? firstKeptEntryIndex : prevCompactionIndex + 1;
}
const boundaryEnd = pathEntries.length;
const tokensBefore = estimateContextTokens(buildSessionContext(pathEntries).messages).tokens;
const cutPoint = findCutPoint(pathEntries, boundaryStart, boundaryEnd, settings.keepRecentTokens);
const firstKeptEntry = pathEntries[cutPoint.firstKeptEntryIndex];
if (!firstKeptEntry?.id) {
return err(
new CompactionError(
"invalid_session",
"First kept entry has no UUID - session may need migration",
),
);
}
const firstKeptEntryId = firstKeptEntry.id;
const historyEnd = cutPoint.isSplitTurn ? cutPoint.turnStartIndex : cutPoint.firstKeptEntryIndex;
const messagesToSummarize: AgentMessage[] = [];
for (let i = boundaryStart; i < historyEnd; i++) {
const msg = getMessageFromEntryForCompaction(pathEntries[i]);
if (msg) {
messagesToSummarize.push(msg);
}
}
const turnPrefixMessages: AgentMessage[] = [];
if (cutPoint.isSplitTurn) {
for (let i = cutPoint.turnStartIndex; i < cutPoint.firstKeptEntryIndex; i++) {
const msg = getMessageFromEntryForCompaction(pathEntries[i]);
if (msg) {
turnPrefixMessages.push(msg);
}
}
}
const fileOps = extractFileOperations(messagesToSummarize, pathEntries, prevCompactionIndex);
if (cutPoint.isSplitTurn) {
for (const msg of turnPrefixMessages) {
extractFileOpsFromMessage(msg, fileOps);
}
}
return ok({
firstKeptEntryId,
messagesToSummarize,
turnPrefixMessages,
isSplitTurn: cutPoint.isSplitTurn,
tokensBefore,
previousSummary,
fileOps,
settings,
});
}
const TURN_PREFIX_SUMMARIZATION_PROMPT = `This is the PREFIX of a turn that was too large to keep. The SUFFIX (recent work) is retained.
Summarize the prefix to provide context for the retained suffix:
## Original Request
[What did the user ask for in this turn?]
## Early Progress
- [Key decisions and work done in the prefix]
## Context for Suffix
- [Information needed to understand the retained recent work]
Be concise. Focus on what's needed to understand the kept suffix.`;
export { serializeConversation } from "./utils.js";
/** Generate compaction summary data from prepared session history. */
export async function compact(
preparation: CompactionPreparation,
model: Model,
apiKey: string | undefined,
headers?: Record<string, string>,
customInstructions?: string,
signal?: AbortSignal,
thinkingLevel?: ThinkingLevel,
streamFn?: StreamFn,
runtime?: AgentCoreCompletionRuntimeDeps,
): Promise<Result<CompactionResult, CompactionError>> {
const {
firstKeptEntryId,
messagesToSummarize,
turnPrefixMessages,
isSplitTurn,
tokensBefore,
previousSummary,
fileOps,
settings,
} = preparation;
if (!firstKeptEntryId) {
return err(
new CompactionError(
"invalid_session",
"First kept entry has no UUID - session may need migration",
),
);
}
let summary: string;
if (isSplitTurn && turnPrefixMessages.length > 0) {
const historyResult =
messagesToSummarize.length > 0
? await generateSummary(
messagesToSummarize,
model,
settings.reserveTokens,
apiKey,
headers,
signal,
customInstructions,
previousSummary,
thinkingLevel,
streamFn,
runtime,
)
: ok<string, CompactionError>("No prior history.");
if (!historyResult.ok) {
return err(historyResult.error);
}
const turnPrefixResult = await generateTurnPrefixSummary(
turnPrefixMessages,
model,
settings.reserveTokens,
apiKey,
headers,
signal,
thinkingLevel,
streamFn,
runtime,
);
if (!turnPrefixResult.ok) {
return err(turnPrefixResult.error);
}
summary = `${historyResult.value}\n\n---\n\n**Turn Context (split turn):**\n\n${turnPrefixResult.value}`;
} else {
const summaryResult = await generateSummary(
messagesToSummarize,
model,
settings.reserveTokens,
apiKey,
headers,
signal,
customInstructions,
previousSummary,
thinkingLevel,
streamFn,
runtime,
);
if (!summaryResult.ok) {
return err(summaryResult.error);
}
summary = summaryResult.value;
}
const { readFiles, modifiedFiles } = computeFileLists(fileOps);
summary += formatFileOperations(readFiles, modifiedFiles);
return ok({
summary,
firstKeptEntryId,
tokensBefore,
details: { readFiles, modifiedFiles } as CompactionDetails,
});
}
async function generateTurnPrefixSummary(
messages: AgentMessage[],
model: Model,
reserveTokens: number,
apiKey: string | undefined,
headers?: Record<string, string>,
signal?: AbortSignal,
thinkingLevel?: ThinkingLevel,
streamFn?: StreamFn,
runtime?: AgentCoreCompletionRuntimeDeps,
): Promise<Result<string, CompactionError>> {
const maxTokens = Math.min(
Math.floor(0.5 * reserveTokens),
model.maxTokens > 0 ? model.maxTokens : Number.POSITIVE_INFINITY,
);
const llmMessages = convertToLlm(messages);
const conversationText = serializeConversation(llmMessages);
const promptText = `<conversation>\n${conversationText}\n</conversation>\n\n${TURN_PREFIX_SUMMARIZATION_PROMPT}`;
return await runSummarizationCompletion({
promptText,
model,
maxTokens,
apiKey,
headers,
signal,
thinkingLevel,
streamFn,
runtime,
errorLabel: "Turn prefix summarization",
});
}

View File

@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import type { Message } from "../../../../llm-core/src/index.js";
import { serializeConversation } from "./utils.js";
describe("serializeConversation", () => {
it.each([
{
name: "Codex nested toolResult text",
block: {
type: "toolResult",
id: "call-1",
toolUseId: "call-1",
content: "duplicate fallback",
text: "codex nested output",
},
expected: "codex nested output",
},
{
name: "snake-case nested tool_result content fallback",
block: {
type: "tool_result",
content: "fallback output",
},
expected: "fallback output",
},
])("serializes $name", ({ block, expected }) => {
const messages = [
{
role: "toolResult",
content: [block],
},
] as unknown as Message[];
expect(serializeConversation(messages)).toBe(`[Tool result]: ${expected}`);
});
});

View File

@@ -0,0 +1,183 @@
// Agent Core helper module supports utils behavior.
import type { Message } from "../../../../llm-core/src/index.js";
import type { AgentMessage } from "../../types.js";
/** File paths touched by a session branch or compaction range. */
export interface FileOperations {
/** Files read but not necessarily modified. */
read: Set<string>;
/** Files written by full-file write operations. */
written: Set<string>;
/** Files modified by edit operations. */
edited: Set<string>;
}
/** Create an empty file-operation accumulator. */
export function createFileOps(): FileOperations {
return {
read: new Set(),
written: new Set(),
edited: new Set(),
};
}
/** Add file operations from assistant tool calls to an accumulator. */
export function extractFileOpsFromMessage(message: AgentMessage, fileOps: FileOperations): void {
if (message.role !== "assistant") {
return;
}
if (!("content" in message) || !Array.isArray(message.content)) {
return;
}
for (const block of message.content) {
if (typeof block !== "object" || block === null) {
continue;
}
if (!("type" in block) || block.type !== "toolCall") {
continue;
}
if (!("arguments" in block) || !("name" in block)) {
continue;
}
const args = block.arguments as Record<string, unknown> | undefined;
if (!args) {
continue;
}
const path = typeof args.path === "string" ? args.path : undefined;
if (!path) {
continue;
}
switch (block.name) {
case "read":
fileOps.read.add(path);
break;
case "write":
fileOps.written.add(path);
break;
case "edit":
fileOps.edited.add(path);
break;
}
}
}
/** Compute sorted read-only and modified file lists from accumulated operations. */
export function computeFileLists(fileOps: FileOperations): {
readFiles: string[];
modifiedFiles: string[];
} {
const modified = new Set([...fileOps.edited, ...fileOps.written]);
const readOnly = [...fileOps.read].filter((f) => !modified.has(f)).toSorted();
const modifiedFiles = [...modified].toSorted();
return { readFiles: readOnly, modifiedFiles };
}
/** Format file lists as summary metadata tags. */
export function formatFileOperations(readFiles: string[], modifiedFiles: string[]): string {
const sections: string[] = [];
if (readFiles.length > 0) {
sections.push(`<read-files>\n${readFiles.join("\n")}\n</read-files>`);
}
if (modifiedFiles.length > 0) {
sections.push(`<modified-files>\n${modifiedFiles.join("\n")}\n</modified-files>`);
}
if (sections.length === 0) {
return "";
}
return `\n\n${sections.join("\n\n")}`;
}
const TOOL_RESULT_MAX_CHARS = 2000;
function safeJsonStringify(value: unknown): string {
try {
return JSON.stringify(value) ?? "undefined";
} catch {
return "[unserializable]";
}
}
function truncateForSummary(text: string, maxChars: number): string {
if (text.length <= maxChars) {
return text;
}
const truncatedChars = text.length - maxChars;
return `${text.slice(0, maxChars)}\n\n[... ${truncatedChars} more characters truncated]`;
}
/** Extract text that compaction both estimates and includes in summary prompts. */
export function getCompactionContentBlockText(block: {
type: string;
content?: unknown;
text?: string;
}): string {
if (block.type === "text" && block.text) {
return block.text;
}
if (block.type !== "toolResult" && block.type !== "tool_result") {
return "";
}
if (block.text) {
return block.text;
}
return typeof block.content === "string" ? block.content : "";
}
/** Serialize LLM messages to plain text for summarization prompts. */
export function serializeConversation(messages: Message[]): string {
const parts: string[] = [];
for (const msg of messages) {
if (msg.role === "user") {
const content =
typeof msg.content === "string"
? msg.content
: msg.content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map((c) => c.text)
.join("");
if (content) {
parts.push(`[User]: ${content}`);
}
} else if (msg.role === "assistant") {
const textParts: string[] = [];
const thinkingParts: string[] = [];
const toolCalls: string[] = [];
for (const block of msg.content) {
if (block.type === "text") {
textParts.push(block.text);
} else if (block.type === "thinking") {
thinkingParts.push(block.thinking);
} else if (block.type === "toolCall") {
const args = block.arguments;
const argsStr = Object.entries(args)
.map(([k, v]) => `${k}=${safeJsonStringify(v)}`)
.join(", ");
toolCalls.push(`${block.name}(${argsStr})`);
}
}
if (thinkingParts.length > 0) {
parts.push(`[Assistant thinking]: ${thinkingParts.join("\n")}`);
}
if (textParts.length > 0) {
parts.push(`[Assistant]: ${textParts.join("\n")}`);
}
if (toolCalls.length > 0) {
parts.push(`[Assistant tool calls]: ${toolCalls.join("; ")}`);
}
} else if (msg.role === "toolResult") {
const content = msg.content.map(getCompactionContentBlockText).join("");
if (content) {
parts.push(`[Tool result]: ${truncateForSummary(content, TOOL_RESULT_MAX_CHARS)}`);
}
}
}
return parts.join("\n\n");
}

View File

@@ -0,0 +1,138 @@
// Agent Core module implements kill tree behavior.
import { spawn } from "node:child_process";
const DEFAULT_GRACE_MS = 3000;
const MAX_GRACE_MS = 60_000;
export type KillProcessTreeOptions = {
graceMs?: number;
detached?: boolean;
force?: boolean;
};
/**
* Best-effort process-tree termination with graceful shutdown.
* - Windows: use taskkill /T to include descendants. Sends SIGTERM-equivalent
* first (without /F), then force-kills if process survives.
* - Unix: send SIGTERM to process group first, wait grace period, then SIGKILL.
*
* When the child was spawned with `detached: false`, pass `detached: false` to
* skip the Unix `process.kill(-pid, ...)` group-kill. That avoids signaling the
* gateway's own process group.
*/
export function killProcessTree(pid: number, opts?: KillProcessTreeOptions): void {
if (!Number.isFinite(pid) || pid <= 0) {
return;
}
if (process.platform === "win32") {
if (opts?.force === true) {
signalProcessTreeWindows(pid, "SIGKILL");
return;
}
const graceMs = normalizeGraceMs(opts?.graceMs);
killProcessTreeWindows(pid, graceMs);
return;
}
const useGroupKill = opts?.detached !== false;
if (opts?.force === true) {
signalProcessTreeUnix(pid, "SIGKILL", useGroupKill);
return;
}
const graceMs = normalizeGraceMs(opts?.graceMs);
signalProcessTreeUnix(pid, "SIGTERM", useGroupKill);
setTimeout(() => {
const stillAlive = useGroupKill
? isProcessAlive(-pid) || isProcessAlive(pid)
: isProcessAlive(pid);
if (!stillAlive) {
return;
}
signalProcessTreeUnix(pid, "SIGKILL", useGroupKill);
}, graceMs).unref();
}
export function signalProcessTree(
pid: number,
signal: "SIGTERM" | "SIGKILL",
opts?: { detached?: boolean },
): void {
if (!Number.isFinite(pid) || pid <= 0) {
return;
}
if (process.platform === "win32") {
signalProcessTreeWindows(pid, signal);
return;
}
signalProcessTreeUnix(pid, signal, opts?.detached !== false);
}
function normalizeGraceMs(value?: number): number {
if (typeof value !== "number" || !Number.isFinite(value)) {
return DEFAULT_GRACE_MS;
}
return Math.max(0, Math.min(MAX_GRACE_MS, Math.floor(value)));
}
function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
function signalProcessTreeUnix(
pid: number,
signal: "SIGTERM" | "SIGKILL",
useGroupKill: boolean,
): void {
if (useGroupKill) {
try {
process.kill(-pid, signal);
return;
} catch {
// Process group does not exist or we lack permission; try direct pid.
}
}
try {
process.kill(pid, signal);
} catch {
// Already gone.
}
}
function runTaskkill(args: string[]): void {
try {
spawn("taskkill", args, {
stdio: "ignore",
detached: true,
windowsHide: true,
});
} catch {
// Ignore taskkill spawn failures.
}
}
function killProcessTreeWindows(pid: number, graceMs: number): void {
signalProcessTreeWindows(pid, "SIGTERM");
setTimeout(() => {
if (!isProcessAlive(pid)) {
return;
}
signalProcessTreeWindows(pid, "SIGKILL");
}, graceMs).unref();
}
function signalProcessTreeWindows(pid: number, signal: "SIGTERM" | "SIGKILL"): void {
const args =
signal === "SIGKILL" ? ["/F", "/T", "/PID", String(pid)] : ["/T", "/PID", String(pid)];
runTaskkill(args);
}

View File

@@ -0,0 +1,22 @@
// Agent Core tests cover nodejs behavior.
import { describe, expect, it } from "vitest";
import { resolveExecTimeoutMs } from "./nodejs.js";
describe("NodeExecutionEnv timeout helpers", () => {
it("converts positive timeout seconds to milliseconds", () => {
expect(resolveExecTimeoutMs(1)).toBe(1_000);
expect(resolveExecTimeoutMs(1.5)).toBe(1_500);
expect(resolveExecTimeoutMs(0.0005)).toBe(1);
});
it("caps oversized timeout seconds to a timer-safe delay", () => {
expect(resolveExecTimeoutMs(Number.MAX_SAFE_INTEGER)).toBe(2_147_000_000);
});
it("ignores absent, invalid, or non-positive timeout seconds", () => {
expect(resolveExecTimeoutMs(undefined)).toBeUndefined();
expect(resolveExecTimeoutMs(Number.NaN)).toBeUndefined();
expect(resolveExecTimeoutMs(0)).toBeUndefined();
expect(resolveExecTimeoutMs(-1)).toBeUndefined();
});
});

View File

@@ -0,0 +1,619 @@
// Agent Core module implements nodejs behavior.
import { spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { constants, createReadStream } from "node:fs";
import {
access,
appendFile,
lstat,
mkdir,
mkdtemp,
readdir,
readFile,
realpath,
rm,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { isAbsolute, join, resolve } from "node:path";
import { createInterface } from "node:readline";
import {
type ExecutionEnv,
ExecutionError,
err,
FileError,
type FileInfo,
type FileKind,
ok,
type Result,
toError,
} from "../types.js";
import { killProcessTree } from "./kill-tree.js";
const MAX_TIMER_TIMEOUT_MS = 2_147_000_000;
function resolvePath(cwd: string, path: string): string {
return isAbsolute(path) ? path : resolve(cwd, path);
}
/** Convert user-facing timeout seconds into a positive, timer-safe millisecond delay. */
export function resolveExecTimeoutMs(timeoutSeconds: unknown): number | undefined {
if (
typeof timeoutSeconds !== "number" ||
!Number.isFinite(timeoutSeconds) ||
timeoutSeconds <= 0
) {
return undefined;
}
const milliseconds = Math.floor(timeoutSeconds * 1000);
if (!Number.isFinite(milliseconds) || milliseconds <= 0) {
return 1;
}
return Math.min(milliseconds, MAX_TIMER_TIMEOUT_MS);
}
function fileKindFromStats(stats: {
isFile(): boolean;
isDirectory(): boolean;
isSymbolicLink(): boolean;
}): FileKind | undefined {
if (stats.isFile()) {
return "file";
}
if (stats.isDirectory()) {
return "directory";
}
if (stats.isSymbolicLink()) {
return "symlink";
}
return undefined;
}
function fileInfoFromStats(
path: string,
stats: {
isFile(): boolean;
isDirectory(): boolean;
isSymbolicLink(): boolean;
size: number;
mtimeMs: number;
},
): Result<FileInfo, FileError> {
const kind = fileKindFromStats(stats);
if (!kind) {
return err(new FileError("invalid", "Unsupported file type", path));
}
return ok({
name: path.replace(/\/+$/, "").split("/").pop() ?? path,
path,
kind,
size: stats.size,
mtimeMs: stats.mtimeMs,
});
}
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
return error instanceof Error && "code" in error;
}
function toFileError(error: unknown, path?: string): FileError {
if (error instanceof FileError) {
return error;
}
const cause = toError(error);
if (isNodeError(error)) {
const message = error.message;
switch (error.code) {
case "ABORT_ERR":
return new FileError("aborted", message, path, cause);
case "ENOENT":
return new FileError("not_found", message, path, cause);
case "EACCES":
case "EPERM":
return new FileError("permission_denied", message, path, cause);
case "ENOTDIR":
return new FileError("not_directory", message, path, cause);
case "EISDIR":
return new FileError("is_directory", message, path, cause);
case "EINVAL":
return new FileError("invalid", message, path, cause);
default:
break;
}
}
return new FileError("unknown", cause.message, path, cause);
}
function abortResult(
signal: AbortSignal | undefined,
path?: string,
): Result<never, FileError> | undefined {
return signal?.aborted ? err(new FileError("aborted", "aborted", path)) : undefined;
}
async function pathExists(path: string): Promise<boolean> {
try {
await access(path, constants.F_OK);
return true;
} catch {
return false;
}
}
async function runCommand(
command: string,
args: string[],
timeoutMs: number,
): Promise<{ stdout: string; status: number | null }> {
return await new Promise((resolveLocal) => {
let stdout = "";
let child: ReturnType<typeof spawn>;
try {
child = spawn(command, args, {
stdio: ["ignore", "pipe", "ignore"],
windowsHide: true,
});
} catch {
resolveLocal({ stdout: "", status: null });
return;
}
const timeout = setTimeout(() => {
if (child.pid) {
killProcessTree(child.pid, { force: true });
}
}, timeoutMs);
child.stdout?.setEncoding("utf8");
child.stdout?.on("data", (chunk: string) => {
stdout += chunk;
});
child.on("error", () => {
clearTimeout(timeout);
resolveLocal({ stdout: "", status: null });
});
child.on("close", (status) => {
clearTimeout(timeout);
resolveLocal({ stdout, status });
});
});
}
async function findBashOnPath(): Promise<string | null> {
const result =
process.platform === "win32"
? await runCommand("where", ["bash.exe"], 5000)
: await runCommand("which", ["bash"], 5000);
if (result.status !== 0 || !result.stdout) {
return null;
}
const firstMatch = result.stdout.trim().split(/\r?\n/)[0];
return firstMatch && (await pathExists(firstMatch)) ? firstMatch : null;
}
async function getShellConfig(
customShellPath?: string,
): Promise<Result<{ shell: string; args: string[] }, ExecutionError>> {
if (customShellPath) {
if (await pathExists(customShellPath)) {
return ok({ shell: customShellPath, args: ["-c"] });
}
return err(
new ExecutionError("shell_unavailable", `Custom shell path not found: ${customShellPath}`),
);
}
if (process.platform === "win32") {
const candidates: string[] = [];
const programFiles = process.env.ProgramFiles;
if (programFiles) {
candidates.push(`${programFiles}\\Git\\bin\\bash.exe`);
}
const programFilesX86 = process.env["ProgramFiles(x86)"];
if (programFilesX86) {
candidates.push(`${programFilesX86}\\Git\\bin\\bash.exe`);
}
for (const candidate of candidates) {
if (await pathExists(candidate)) {
return ok({ shell: candidate, args: ["-c"] });
}
}
const bashOnPath = await findBashOnPath();
if (bashOnPath) {
return ok({ shell: bashOnPath, args: ["-c"] });
}
return err(new ExecutionError("shell_unavailable", "No bash shell found"));
}
if (await pathExists("/bin/bash")) {
return ok({ shell: "/bin/bash", args: ["-c"] });
}
const bashOnPath = await findBashOnPath();
if (bashOnPath) {
return ok({ shell: bashOnPath, args: ["-c"] });
}
return ok({ shell: "sh", args: ["-c"] });
}
function getShellEnv(
baseEnv?: NodeJS.ProcessEnv,
extraEnv?: Record<string, string>,
): NodeJS.ProcessEnv {
return {
...process.env,
...baseEnv,
...extraEnv,
};
}
/** Node-backed execution environment for agent harness filesystem and shell operations. */
export class NodeExecutionEnv implements ExecutionEnv {
cwd: string;
private shellPath?: string;
private shellEnv?: NodeJS.ProcessEnv;
constructor(options: { cwd: string; shellPath?: string; shellEnv?: NodeJS.ProcessEnv }) {
this.cwd = options.cwd;
this.shellPath = options.shellPath;
this.shellEnv = options.shellEnv;
}
async absolutePath(path: string): Promise<Result<string, FileError>> {
return ok(resolvePath(this.cwd, path));
}
async joinPath(parts: string[]): Promise<Result<string, FileError>> {
return ok(join(...parts));
}
async exec(
command: string,
options?: {
cwd?: string;
env?: Record<string, string>;
timeout?: number;
abortSignal?: AbortSignal;
onStdout?: (chunk: string) => void;
onStderr?: (chunk: string) => void;
},
): Promise<Result<{ stdout: string; stderr: string; exitCode: number }, ExecutionError>> {
if (options?.abortSignal?.aborted) {
return err(new ExecutionError("aborted", "aborted"));
}
const cwd = options?.cwd ? resolvePath(this.cwd, options.cwd) : this.cwd;
const shellConfig = await getShellConfig(this.shellPath);
if (!shellConfig.ok) {
return shellConfig;
}
return await new Promise((resolvePromise) => {
let stdout = "";
let stderr = "";
let settled = false;
let timedOut = false;
let callbackError: ExecutionError | undefined;
let child: ReturnType<typeof spawn> | undefined;
const timeoutRef: { current?: ReturnType<typeof setTimeout> } = {};
const onAbort = () => {
if (child?.pid) {
killProcessTree(child.pid, { force: true });
}
};
const settle = (
result: Result<{ stdout: string; stderr: string; exitCode: number }, ExecutionError>,
) => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
if (options?.abortSignal) {
options.abortSignal.removeEventListener("abort", onAbort);
}
if (settled) {
return;
}
settled = true;
resolvePromise(result);
};
try {
child = spawn(shellConfig.value.shell, [...shellConfig.value.args, command], {
cwd,
detached: process.platform !== "win32",
env: getShellEnv(this.shellEnv, options?.env),
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
});
} catch (error) {
const cause = toError(error);
settle(err(new ExecutionError("spawn_error", cause.message, cause)));
return;
}
const timeoutMs = resolveExecTimeoutMs(options?.timeout);
timeoutRef.current =
timeoutMs === undefined
? undefined
: setTimeout(() => {
timedOut = true;
if (child?.pid) {
killProcessTree(child.pid, { force: true });
}
}, timeoutMs);
if (options?.abortSignal) {
if (options.abortSignal.aborted) {
onAbort();
} else {
options.abortSignal.addEventListener("abort", onAbort, { once: true });
}
}
child.stdout?.setEncoding("utf8");
child.stderr?.setEncoding("utf8");
child.stdout?.on("data", (chunk: string) => {
stdout += chunk;
try {
options?.onStdout?.(chunk);
} catch (error) {
const cause = toError(error);
callbackError = new ExecutionError("callback_error", cause.message, cause);
onAbort();
}
});
child.stderr?.on("data", (chunk: string) => {
stderr += chunk;
try {
options?.onStderr?.(chunk);
} catch (error) {
const cause = toError(error);
callbackError = new ExecutionError("callback_error", cause.message, cause);
onAbort();
}
});
child.on("error", (error) => {
settle(err(new ExecutionError("spawn_error", error.message, error)));
});
child.on("close", (code) => {
if (callbackError) {
settle(err(callbackError));
return;
}
if (timedOut) {
settle(err(new ExecutionError("timeout", `timeout:${options?.timeout}`)));
return;
}
if (options?.abortSignal?.aborted) {
settle(err(new ExecutionError("aborted", "aborted")));
return;
}
settle(ok({ stdout, stderr, exitCode: code ?? 0 }));
});
});
}
async readTextFile(path: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>> {
const resolved = resolvePath(this.cwd, path);
const aborted = abortResult(abortSignal, resolved);
if (aborted) {
return aborted;
}
try {
return ok(await readFile(resolved, { encoding: "utf8", signal: abortSignal }));
} catch (error) {
return err(toFileError(error, resolved));
}
}
async readTextLines(
path: string,
options?: { maxLines?: number; abortSignal?: AbortSignal },
): Promise<Result<string[], FileError>> {
const resolved = resolvePath(this.cwd, path);
const aborted = abortResult(options?.abortSignal, resolved);
if (aborted) {
return aborted;
}
if (options?.maxLines !== undefined && options.maxLines <= 0) {
return ok([]);
}
let stream: ReturnType<typeof createReadStream> | undefined;
let lineReader: ReturnType<typeof createInterface> | undefined;
try {
stream = createReadStream(resolved, { encoding: "utf8", signal: options?.abortSignal });
lineReader = createInterface({ input: stream, crlfDelay: Infinity });
const lines: string[] = [];
for await (const line of lineReader) {
const loopAbort = abortResult(options?.abortSignal, resolved);
if (loopAbort) {
return loopAbort;
}
lines.push(line);
if (options?.maxLines !== undefined && lines.length >= options.maxLines) {
break;
}
}
const afterReadAbort = abortResult(options?.abortSignal, resolved);
if (afterReadAbort) {
return afterReadAbort;
}
return ok(lines);
} catch (error) {
return err(toFileError(error, resolved));
} finally {
lineReader?.close();
stream?.destroy();
}
}
async readBinaryFile(
path: string,
abortSignal?: AbortSignal,
): Promise<Result<Uint8Array, FileError>> {
const resolved = resolvePath(this.cwd, path);
const aborted = abortResult(abortSignal, resolved);
if (aborted) {
return aborted;
}
try {
return ok(await readFile(resolved, { signal: abortSignal }));
} catch (error) {
return err(toFileError(error, resolved));
}
}
async writeFile(
path: string,
content: string | Uint8Array,
abortSignal?: AbortSignal,
): Promise<Result<void, FileError>> {
const resolved = resolvePath(this.cwd, path);
const aborted = abortResult(abortSignal, resolved);
if (aborted) {
return aborted;
}
try {
await mkdir(resolve(resolved, ".."), { recursive: true });
const afterMkdirAbort = abortResult(abortSignal, resolved);
if (afterMkdirAbort) {
return afterMkdirAbort;
}
await writeFile(resolved, content, { signal: abortSignal });
return ok(undefined);
} catch (error) {
return err(toFileError(error, resolved));
}
}
async appendFile(path: string, content: string | Uint8Array): Promise<Result<void, FileError>> {
const resolved = resolvePath(this.cwd, path);
try {
await mkdir(resolve(resolved, ".."), { recursive: true });
await appendFile(resolved, content);
return ok(undefined);
} catch (error) {
return err(toFileError(error, resolved));
}
}
async fileInfo(path: string): Promise<Result<FileInfo, FileError>> {
const resolved = resolvePath(this.cwd, path);
try {
return fileInfoFromStats(resolved, await lstat(resolved));
} catch (error) {
return err(toFileError(error, resolved));
}
}
async listDir(path: string, abortSignal?: AbortSignal): Promise<Result<FileInfo[], FileError>> {
const resolved = resolvePath(this.cwd, path);
const aborted = abortResult(abortSignal, resolved);
if (aborted) {
return aborted;
}
try {
const entries = await readdir(resolved, { withFileTypes: true });
const infos: FileInfo[] = [];
for (const entry of entries) {
const loopAbort = abortResult(abortSignal, resolved);
if (loopAbort) {
return loopAbort;
}
const entryPath = resolve(resolved, entry.name);
try {
const info = fileInfoFromStats(entryPath, await lstat(entryPath));
if (info.ok) {
infos.push(info.value);
}
} catch (error) {
return err(toFileError(error, entryPath));
}
}
return ok(infos);
} catch (error) {
return err(toFileError(error, resolved));
}
}
async canonicalPath(path: string): Promise<Result<string, FileError>> {
const resolved = resolvePath(this.cwd, path);
try {
return ok(await realpath(resolved));
} catch (error) {
return err(toFileError(error, resolved));
}
}
async exists(path: string): Promise<Result<boolean, FileError>> {
const result = await this.fileInfo(path);
if (result.ok) {
return ok(true);
}
if (result.error.code === "not_found") {
return ok(false);
}
return err(result.error);
}
async createDir(
path: string,
options?: { recursive?: boolean },
): Promise<Result<void, FileError>> {
const resolved = resolvePath(this.cwd, path);
try {
await mkdir(resolved, { recursive: options?.recursive ?? true });
return ok(undefined);
} catch (error) {
return err(toFileError(error, resolved));
}
}
async remove(
path: string,
options?: { recursive?: boolean; force?: boolean },
): Promise<Result<void, FileError>> {
const resolved = resolvePath(this.cwd, path);
try {
await rm(resolved, {
recursive: options?.recursive ?? false,
force: options?.force ?? false,
});
return ok(undefined);
} catch (error) {
return err(toFileError(error, resolved));
}
}
async createTempDir(prefix = "tmp-"): Promise<Result<string, FileError>> {
try {
return ok(await mkdtemp(join(tmpdir(), prefix)));
} catch (error) {
return err(toFileError(error));
}
}
async createTempFile(options?: {
prefix?: string;
suffix?: string;
}): Promise<Result<string, FileError>> {
const dir = await this.createTempDir("tmp-");
if (!dir.ok) {
return dir;
}
const filePath = join(
dir.value,
`${options?.prefix ?? ""}${randomUUID()}${options?.suffix ?? ""}`,
);
try {
await writeFile(filePath, "");
return ok(filePath);
} catch (error) {
return err(toFileError(error, filePath));
}
}
async cleanup(): Promise<void> {
// nothing to clean up for the local node implementation
}
}

View File

@@ -0,0 +1,41 @@
// Agent Core tests cover messages behavior.
import { describe, expect, it } from "vitest";
import { convertToLlm, createCustomMessage } from "./messages.js";
describe("harness message timestamps", () => {
it("rejects invalid timestamps before creating context messages", () => {
expect(() => createCustomMessage("note", "content", true, {}, "not-a-date")).toThrow(
"custom message timestamp must be a valid timestamp",
);
});
it("normalizes persisted compaction summary timestamp strings", () => {
const timestamp = "2026-05-30T17:00:00.000Z";
const persistedMessages: Parameters<typeof convertToLlm>[0] = [
{
role: "compactionSummary",
summary: "older context",
tokensBefore: 123,
timestamp,
},
];
const [message] = convertToLlm(persistedMessages);
expect(message?.timestamp).toBe(Date.parse(timestamp));
});
it("keeps corrupt persisted compaction timestamps non-fatal", () => {
const persistedMessages: Parameters<typeof convertToLlm>[0] = [
{
role: "compactionSummary",
summary: "older context",
tokensBefore: 123,
timestamp: "not a timestamp",
},
];
const [message] = convertToLlm(persistedMessages);
expect(message?.timestamp).toBe(0);
});
});

View File

@@ -0,0 +1,179 @@
// Agent Core module implements messages behavior.
import type { ImageContent, Message, TextContent } from "../../../llm-core/src/index.js";
import type {
AgentMessage,
BashExecutionMessage,
BranchSummaryMessage,
CompactionSummaryMessage,
CustomMessage,
} from "../types.js";
import { parseSessionTimestampMs, requireSessionTimestampMs } from "./session/timestamps.js";
export type {
BashExecutionMessage,
BranchSummaryMessage,
CompactionSummaryMessage,
CustomMessage,
} from "../types.js";
/** Harness-only transcript entries that can be normalized into LLM messages. */
export type HarnessMessage =
| AgentMessage
| BashExecutionMessage
| CustomMessage
| BranchSummaryMessage
| CompactionSummaryMessage;
// Internal session paths keep call sites explicit about this harness-owned
// boundary even though these message roles are part of AgentMessage.
export function asAgentMessage(message: HarnessMessage): AgentMessage {
return message as AgentMessage;
}
function normalizeCompactionSummaryTimestamp(timestamp: number | string): number {
if (typeof timestamp === "number") {
return timestamp;
}
const parsed = parseSessionTimestampMs(timestamp);
// Corrupt persisted rows should not abort context conversion; session order is already preserved.
return parsed ?? 0;
}
export const COMPACTION_SUMMARY_PREFIX = `The conversation history before this point was compacted into the following summary:
<summary>
`;
export const COMPACTION_SUMMARY_SUFFIX = `
</summary>`;
export const BRANCH_SUMMARY_PREFIX = `The following is a summary of a branch that this conversation came back from:
<summary>
`;
export const BRANCH_SUMMARY_SUFFIX = `</summary>`;
/** Render a shell execution record as user-visible context text for the model. */
export function bashExecutionToText(msg: BashExecutionMessage): string {
let text = `Ran \`${msg.command}\`\n`;
if (msg.output) {
text += `\`\`\`\n${msg.output}\n\`\`\``;
} else {
text += "(no output)";
}
if (msg.cancelled) {
text += "\n\n(command cancelled)";
} else if (msg.exitCode !== null && msg.exitCode !== undefined && msg.exitCode !== 0) {
text += `\n\nCommand exited with code ${msg.exitCode}`;
}
if (msg.truncated && msg.fullOutputPath) {
text += `\n\n[Output truncated. Full output: ${msg.fullOutputPath}]`;
}
return text;
}
/** Build a persisted branch summary message from the repository timestamp string. */
export function createBranchSummaryMessage(
summary: string,
fromId: string,
timestamp: string,
): BranchSummaryMessage {
return {
role: "branchSummary",
summary,
fromId,
timestamp: requireSessionTimestampMs(timestamp, "branch summary timestamp"),
};
}
/** Build a persisted compaction summary message from the repository timestamp string. */
export function createCompactionSummaryMessage(
summary: string,
tokensBefore: number,
timestamp: string,
): CompactionSummaryMessage {
return {
role: "compactionSummary",
summary,
tokensBefore,
timestamp: requireSessionTimestampMs(timestamp, "compaction summary timestamp"),
};
}
/** Build a custom transcript message that can be shown and replayed into context. */
export function createCustomMessage(
customType: string,
content: string | (TextContent | ImageContent)[],
display: boolean,
details: unknown,
timestamp: string,
): CustomMessage {
return {
role: "custom",
customType,
content,
display,
details,
timestamp: requireSessionTimestampMs(timestamp, "custom message timestamp"),
};
}
/** Convert harness transcript messages into the LLM-facing message sequence. */
export function convertToLlm(messages: AgentMessage[]): Message[] {
return messages
.map((m): Message | undefined => {
const message = m as HarnessMessage;
switch (message.role) {
case "bashExecution":
if (message.excludeFromContext) {
return undefined;
}
return {
role: "user",
content: [{ type: "text", text: bashExecutionToText(message) }],
timestamp: message.timestamp,
};
case "custom": {
const content =
typeof message.content === "string"
? [{ type: "text" as const, text: message.content }]
: message.content;
return {
role: "user",
content,
timestamp: message.timestamp,
};
}
case "branchSummary":
return {
role: "user",
content: [
{
type: "text" as const,
text: BRANCH_SUMMARY_PREFIX + message.summary + BRANCH_SUMMARY_SUFFIX,
},
],
timestamp: message.timestamp,
};
case "compactionSummary":
return {
role: "user",
content: [
{
type: "text" as const,
text: COMPACTION_SUMMARY_PREFIX + message.summary + COMPACTION_SUMMARY_SUFFIX,
},
],
timestamp: normalizeCompactionSummaryTimestamp(message.timestamp),
};
case "user":
case "assistant":
case "toolResult":
return message;
default:
return undefined;
}
})
.filter((m): m is Message => m !== undefined);
}

View File

@@ -0,0 +1,11 @@
// Agent Core tests cover prompt template argument parsing behavior.
import { describe, expect, it } from "vitest";
import { parseCommandArgs, substituteArgs } from "./prompt-template-arguments.js";
describe("prompt template arguments", () => {
it("preserves quoted empty arguments so positional placeholders stay aligned", () => {
expect(parseCommandArgs('first "" third')).toEqual(["first", "", "third"]);
expect(parseCommandArgs("first '' third")).toEqual(["first", "", "third"]);
expect(substituteArgs("$1|$2|$3", parseCommandArgs('first "" third'))).toBe("first||third");
});
});

View File

@@ -0,0 +1,93 @@
import type { PromptTemplate } from "./types.js";
/** Parse an argument string using simple shell-style single and double quotes. */
export function parseCommandArgs(argsString: string): string[] {
const args: string[] = [];
let current = "";
let inQuote: string | null = null;
let hasToken = false;
for (const char of argsString) {
if (inQuote) {
if (char === inQuote) {
inQuote = null;
} else {
hasToken = true;
current += char;
}
} else if (char === '"' || char === "'") {
hasToken = true;
inQuote = char;
} else if (/\s/.test(char)) {
if (hasToken) {
args.push(current);
current = "";
hasToken = false;
}
} else {
hasToken = true;
current += char;
}
}
if (hasToken) {
args.push(current);
}
return args;
}
function parseSafeNonNegativeInteger(raw: string): number | undefined {
const parsed = Number(raw);
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : undefined;
}
/**
* Substitute prompt template placeholders (`$1`, `$@`, `$ARGUMENTS`, `${@:N}`, `${@:N:L}`) with command arguments.
*
* Unsafe integer placeholders resolve to empty text instead of throwing, so malformed templates cannot abort prompt
* loading or invocation.
*/
export function substituteArgs(content: string, args: string[]): string {
let result = content;
result = result.replace(/\$(\d+)/g, (_, num: string) => {
const parsed = parseSafeNonNegativeInteger(num);
if (parsed === undefined || parsed <= 0) {
return "";
}
return args[parsed - 1] ?? "";
});
result = result.replace(
/\$\{@:(\d+)(?::(\d+))?\}/g,
(_, startStr: string, lengthStr?: string) => {
const parsedStart = parseSafeNonNegativeInteger(startStr);
if (parsedStart === undefined) {
return "";
}
// Keep shell-style `${@:0:...}` compatibility: start 0 includes `$0` in shell, but
// prompt templates have no command name, so it maps to the first provided argument.
let start = parsedStart - 1;
if (start < 0) {
start = 0;
}
if (lengthStr) {
const length = parseSafeNonNegativeInteger(lengthStr);
if (length === undefined) {
return "";
}
return args.slice(start, start + length).join(" ");
}
return args.slice(start).join(" ");
},
);
const allArgs = args.join(" ");
result = result.replace(/\$ARGUMENTS/g, allArgs);
result = result.replace(/\$@/g, allArgs);
return result;
}
/** Format a prompt template invocation using command-style argument substitution. */
export function formatPromptTemplateInvocation(
template: PromptTemplate,
args: string[] = [],
): string {
return substituteArgs(template.content, args);
}

View File

@@ -0,0 +1,30 @@
// Agent Core tests cover prompt templates behavior.
import { describe, expect, it } from "vitest";
import { parseCommandArgs, substituteArgs } from "./prompt-template-arguments.js";
describe("prompt template argument substitution", () => {
it("parses quoted and multiline arguments", () => {
expect(parseCommandArgs(`alpha "beta gamma"\ndelta 'echo one two'`)).toEqual([
"alpha",
"beta gamma",
"delta",
"echo one two",
]);
});
it("rejects unsafe positional placeholders", () => {
expect(substituteArgs("$9007199254740992", ["first", "second"])).toBe("");
});
it("rejects unsafe slice starts and lengths", () => {
const args = ["alpha", "beta", "gamma"];
expect(substituteArgs("${@:9007199254740992}", args)).toBe("");
expect(substituteArgs("${@:1:9007199254740992}", args)).toBe("");
});
it("preserves zero slice compatibility", () => {
expect(substituteArgs("${@:0:0}", ["alpha", "beta"])).toBe("");
expect(substituteArgs("${@:0:1}", ["alpha", "beta"])).toBe("alpha");
});
});

View File

@@ -0,0 +1,264 @@
// Agent Core tests cover jsonl storage behavior.
import { describe, expect, it } from "vitest";
import { ok, type FileSystem } from "../types.js";
import { JsonlSessionStorage, loadJsonlSessionMetadata } from "./jsonl-storage.js";
import { Session } from "./session.js";
type JsonlStorageFs = Pick<
FileSystem,
"readTextFile" | "readTextLines" | "writeFile" | "appendFile"
>;
function createReadOnlyFs(content: string): JsonlStorageFs {
return {
readTextFile: async () => ok(content),
readTextLines: async (_path, options) => ok(content.split("\n").slice(0, options?.maxLines)),
writeFile: async () => ok(undefined),
appendFile: async () => ok(undefined),
};
}
describe("JsonlSessionStorage timestamps", () => {
it("rejects invalid session header timestamps", async () => {
const fs = createReadOnlyFs(
`${JSON.stringify({
type: "session",
version: 3,
id: "session-1",
timestamp: "not-a-date",
cwd: "/repo",
})}\n`,
);
await expect(loadJsonlSessionMetadata(fs, "/sessions/invalid.jsonl")).rejects.toThrow(
"session header has invalid timestamp",
);
});
it("rejects invalid entry timestamps", async () => {
const fs = createReadOnlyFs(
`${JSON.stringify({
type: "session",
version: 3,
id: "session-1",
timestamp: "2026-01-01T00:00:00.000Z",
cwd: "/repo",
})}\n${JSON.stringify({
type: "custom",
id: "entry-1",
parentId: null,
timestamp: "not-a-date",
customType: "note",
})}\n`,
);
await expect(JsonlSessionStorage.open(fs, "/sessions/invalid-entry.jsonl")).rejects.toThrow(
"line 2 has invalid timestamp",
);
});
it("uses a leaf control's opaque append parent for the next entry", async () => {
let content = [
{
type: "session",
version: 3,
id: "session-1",
timestamp: "2026-06-15T00:00:00.000Z",
cwd: "/repo",
},
{
type: "custom",
id: "active-root",
parentId: null,
timestamp: "2026-06-15T00:00:01.000Z",
customType: "root",
},
{
type: "metadata",
id: "plugin-metadata",
parentId: null,
timestamp: "2026-06-15T00:00:02.000Z",
},
{
type: "leaf",
id: "active-leaf",
parentId: "inactive-tail",
timestamp: "2026-06-15T00:00:03.000Z",
targetId: "active-root",
appendParentId: "plugin-metadata",
},
]
.map((entry) => JSON.stringify(entry))
.join("\n");
content += "\n";
const fs: JsonlStorageFs = {
...createReadOnlyFs(content),
readTextFile: async () => ok(content),
appendFile: async (_path, appended) => {
content += String(appended);
return ok(undefined);
},
};
const storage = await JsonlSessionStorage.open(fs, "/sessions/session.jsonl");
const session = new Session(storage);
expect(await session.getLeafId()).toBe("active-root");
const entryId = await session.appendCustomEntry("continued");
const entry = await session.getEntry(entryId);
expect(entry).toMatchObject({ parentId: "plugin-metadata" });
expect((await storage.getPathToRoot(entryId)).map((pathEntry) => pathEntry.id)).toEqual([
"active-root",
entryId,
]);
expect(content.trim().split(/\r?\n/).at(-1)).toContain('"parentId":"plugin-metadata"');
});
it("keeps a terminal side append off the visible branch", async () => {
let content = [
{
type: "session",
version: 3,
id: "session-1",
timestamp: "2026-06-15T00:00:00.000Z",
cwd: "/repo",
},
{
type: "custom",
id: "active-root",
parentId: null,
timestamp: "2026-06-15T00:00:01.000Z",
customType: "active",
},
{
type: "custom",
id: "side-one",
parentId: "active-root",
timestamp: "2026-06-15T00:00:02.000Z",
customType: "side",
},
{
type: "leaf",
id: "side-leaf",
parentId: "side-one",
timestamp: "2026-06-15T00:00:03.000Z",
targetId: "active-root",
appendParentId: "side-one",
appendMode: "side",
},
{
type: "custom",
id: "side-two",
parentId: "side-one",
timestamp: "2026-06-15T00:00:04.000Z",
customType: "side",
appendMode: "side",
},
]
.map((entry) => JSON.stringify(entry))
.join("\n");
content += "\n";
const fs: JsonlStorageFs = {
...createReadOnlyFs(content),
readTextFile: async () => ok(content),
appendFile: async (_path, appended) => {
content += String(appended);
return ok(undefined);
},
};
const storage = await JsonlSessionStorage.open(fs, "/sessions/session.jsonl");
const session = new Session(storage);
expect(await storage.getLeafId()).toBe("active-root");
expect(await storage.getAppendParentId()).toBe("side-two");
const entryId = await session.appendCustomEntry("continued");
expect(await storage.getEntry(entryId)).toMatchObject({ parentId: "side-two" });
expect((await storage.getPathToRoot(entryId)).map((entry) => entry.id)).toEqual([
"active-root",
entryId,
]);
});
it("does not let opaque rows replace the selected visible leaf", async () => {
const content = [
{
type: "session",
version: 3,
id: "session-1",
timestamp: "2026-06-15T00:00:00.000Z",
cwd: "/repo",
},
{
type: "custom",
id: "active-root",
parentId: null,
timestamp: "2026-06-15T00:00:01.000Z",
customType: "active",
},
{
type: "custom",
id: "inactive-root",
parentId: null,
timestamp: "2026-06-15T00:00:02.000Z",
customType: "inactive",
},
{
type: "leaf",
id: "active-leaf",
parentId: "inactive-root",
timestamp: "2026-06-15T00:00:03.000Z",
targetId: "active-root",
},
{
type: "metadata",
id: "plugin-metadata",
parentId: "inactive-root",
timestamp: "2026-06-15T00:00:04.000Z",
},
]
.map((entry) => JSON.stringify(entry))
.join("\n");
const storage = await JsonlSessionStorage.open(
createReadOnlyFs(`${content}\n`),
"/sessions/session.jsonl",
);
const session = new Session(storage);
expect(await session.getLeafId()).toBe("active-root");
expect((await session.getBranch()).map((entry) => entry.id)).toEqual(["active-root"]);
});
it("rejects a leaf control with a missing append parent", async () => {
const content = [
{
type: "session",
version: 3,
id: "session-1",
timestamp: "2026-06-15T00:00:00.000Z",
cwd: "/repo",
},
{
type: "custom",
id: "active-root",
parentId: null,
timestamp: "2026-06-15T00:00:01.000Z",
customType: "active",
},
{
type: "leaf",
id: "active-leaf",
parentId: "active-root",
timestamp: "2026-06-15T00:00:02.000Z",
targetId: "active-root",
appendParentId: "missing",
},
]
.map((entry) => JSON.stringify(entry))
.join("\n");
await expect(
JsonlSessionStorage.open(createReadOnlyFs(`${content}\n`), "/sessions/session.jsonl"),
).rejects.toThrow("Append parent missing not found");
});
});

View File

@@ -0,0 +1,285 @@
// Agent Core module implements jsonl storage behavior.
import type {
FileError,
FileSystem,
JsonlSessionMetadata,
Result,
SessionTreeEntry,
} from "../types.js";
import { SessionError, toError } from "../types.js";
import {
appendParentIdAfterEntry,
BaseSessionStorage,
leafIdUpdateAfterEntry,
} from "./storage-base.js";
import { parseSessionTimestampMs } from "./timestamps.js";
type JsonlSessionStorageFileSystem = Pick<
FileSystem,
"readTextFile" | "readTextLines" | "writeFile" | "appendFile"
>;
interface SessionHeader {
type: "session";
version: 3;
id: string;
timestamp: string;
cwd: string;
parentSession?: string;
}
function getFileSystemResultOrThrow<TValue>(
result: Result<TValue, FileError>,
message: string,
): TValue {
if (!result.ok) {
const code = result.error.code === "not_found" ? "not_found" : "storage";
throw new SessionError(code, `${message}: ${result.error.message}`, result.error);
}
return result.value;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function invalidSession(filePath: string, message: string, cause?: Error): SessionError {
return new SessionError(
"invalid_session",
`Invalid JSONL session file ${filePath}: ${message}`,
cause,
);
}
function invalidEntry(
filePath: string,
lineNumber: number,
message: string,
cause?: Error,
): SessionError {
return new SessionError(
"invalid_entry",
`Invalid JSONL session file ${filePath}: line ${lineNumber} ${message}`,
cause,
);
}
function parseHeaderLine(line: string, filePath: string): SessionHeader {
let parsed: unknown;
try {
parsed = JSON.parse(line);
} catch (error) {
throw invalidSession(filePath, "first line is not a valid session header", toError(error));
}
if (!isRecord(parsed)) {
throw invalidSession(filePath, "first line is not a valid session header");
}
if (parsed.type !== "session") {
throw invalidSession(filePath, "first line is not a valid session header");
}
if (parsed.version !== 3) {
throw invalidSession(filePath, "unsupported session version");
}
if (typeof parsed.id !== "string" || !parsed.id) {
throw invalidSession(filePath, "session header is missing id");
}
if (typeof parsed.timestamp !== "string" || !parsed.timestamp) {
throw invalidSession(filePath, "session header is missing timestamp");
}
if (parseSessionTimestampMs(parsed.timestamp) === undefined) {
throw invalidSession(filePath, "session header has invalid timestamp");
}
if (typeof parsed.cwd !== "string" || !parsed.cwd) {
throw invalidSession(filePath, "session header is missing cwd");
}
if (parsed.parentSession !== undefined && typeof parsed.parentSession !== "string") {
throw invalidSession(filePath, "session header parentSession must be a string");
}
return {
type: "session",
version: 3,
id: parsed.id,
timestamp: parsed.timestamp,
cwd: parsed.cwd,
parentSession: parsed.parentSession,
};
}
function parseEntryLine(line: string, filePath: string, lineNumber: number): SessionTreeEntry {
let parsed: unknown;
try {
parsed = JSON.parse(line);
} catch (error) {
throw invalidEntry(filePath, lineNumber, "is not valid JSON", toError(error));
}
if (!isRecord(parsed)) {
throw invalidEntry(filePath, lineNumber, "is not a valid session entry");
}
if (typeof parsed.type !== "string") {
throw invalidEntry(filePath, lineNumber, "is missing entry type");
}
if (typeof parsed.id !== "string" || !parsed.id) {
throw invalidEntry(filePath, lineNumber, "is missing entry id");
}
if (parsed.parentId !== null && typeof parsed.parentId !== "string") {
throw invalidEntry(filePath, lineNumber, "has invalid parentId");
}
if (typeof parsed.timestamp !== "string" || !parsed.timestamp) {
throw invalidEntry(filePath, lineNumber, "is missing timestamp");
}
if (parseSessionTimestampMs(parsed.timestamp) === undefined) {
throw invalidEntry(filePath, lineNumber, "has invalid timestamp");
}
if (parsed.type === "leaf" && parsed.targetId !== null && typeof parsed.targetId !== "string") {
throw invalidEntry(filePath, lineNumber, "has invalid targetId");
}
if (
parsed.type === "leaf" &&
parsed.appendParentId !== undefined &&
parsed.appendParentId !== null &&
typeof parsed.appendParentId !== "string"
) {
throw invalidEntry(filePath, lineNumber, "has invalid appendParentId");
}
if (parsed.appendMode !== undefined && parsed.appendMode !== "side") {
throw invalidEntry(filePath, lineNumber, "has invalid appendMode");
}
return parsed as unknown as SessionTreeEntry;
}
function headerToSessionMetadata(header: SessionHeader, path: string): JsonlSessionMetadata {
return {
id: header.id,
createdAt: header.timestamp,
cwd: header.cwd,
path,
parentSessionPath: header.parentSession,
};
}
/** Read only the JSONL session header and convert it to session metadata. */
export async function loadJsonlSessionMetadata(
fs: JsonlSessionStorageFileSystem,
filePath: string,
): Promise<JsonlSessionMetadata> {
const lines = getFileSystemResultOrThrow(
await fs.readTextLines(filePath, { maxLines: 1 }),
`Failed to read session header ${filePath}`,
);
const line = lines[0];
if (line?.trim()) {
return headerToSessionMetadata(parseHeaderLine(line, filePath), filePath);
}
throw invalidSession(filePath, "missing session header");
}
async function loadJsonlStorage(
fs: JsonlSessionStorageFileSystem,
filePath: string,
): Promise<{
header: SessionHeader;
entries: SessionTreeEntry[];
leafId: string | null;
appendParentId: string | null;
}> {
const content = getFileSystemResultOrThrow(
await fs.readTextFile(filePath),
`Failed to read session ${filePath}`,
);
const lines = content.split("\n").filter((line) => line.trim());
if (lines.length === 0) {
throw invalidSession(filePath, "missing session header");
}
const header = parseHeaderLine(lines[0], filePath);
const entries: SessionTreeEntry[] = [];
let leafId: string | null = null;
let appendParentId: string | null = null;
for (let i = 1; i < lines.length; i++) {
const entry = parseEntryLine(lines[i], filePath, i + 1);
entries.push(entry);
const leafUpdate = leafIdUpdateAfterEntry(entry);
if (leafUpdate !== undefined) {
leafId = leafUpdate;
}
appendParentId = appendParentIdAfterEntry(entry);
}
return { header, entries, leafId, appendParentId };
}
/** Append-only JSONL-backed storage for one session tree. */
export class JsonlSessionStorage extends BaseSessionStorage<JsonlSessionMetadata> {
private readonly fs: JsonlSessionStorageFileSystem;
private readonly filePath: string;
private constructor(
fs: JsonlSessionStorageFileSystem,
filePath: string,
header: SessionHeader,
entries: SessionTreeEntry[],
leafId: string | null,
appendParentId: string | null,
) {
super(headerToSessionMetadata(header, filePath), entries, leafId, appendParentId);
this.fs = fs;
this.filePath = filePath;
}
static async open(
fs: JsonlSessionStorageFileSystem,
filePath: string,
): Promise<JsonlSessionStorage> {
const loaded = await loadJsonlStorage(fs, filePath);
return new JsonlSessionStorage(
fs,
filePath,
loaded.header,
loaded.entries,
loaded.leafId,
loaded.appendParentId,
);
}
/** Create a new JSONL file with a session header and no entries. */
static async create(
fs: JsonlSessionStorageFileSystem,
filePath: string,
options: {
cwd: string;
sessionId: string;
parentSessionPath?: string;
},
): Promise<JsonlSessionStorage> {
const header: SessionHeader = {
type: "session",
version: 3,
id: options.sessionId,
timestamp: new Date().toISOString(),
cwd: options.cwd,
parentSession: options.parentSessionPath,
};
getFileSystemResultOrThrow(
await fs.writeFile(filePath, `${JSON.stringify(header)}\n`),
`Failed to create session ${filePath}`,
);
return new JsonlSessionStorage(fs, filePath, header, [], null, null);
}
override async setLeafId(leafId: string | null): Promise<void> {
const entry = this.createLeafEntry(leafId);
getFileSystemResultOrThrow(
await this.fs.appendFile(this.filePath, `${JSON.stringify(entry)}\n`),
`Failed to append session leaf ${entry.id}`,
);
this.recordEntry(entry);
}
override async appendEntry(entry: SessionTreeEntry): Promise<void> {
this.validateEntryForAppend(entry);
getFileSystemResultOrThrow(
await this.fs.appendFile(this.filePath, `${JSON.stringify(entry)}\n`),
`Failed to append session entry ${entry.id}`,
);
this.recordEntry(entry);
}
}

View File

@@ -0,0 +1,180 @@
// Agent Core tests cover memory storage behavior.
import { describe, expect, it } from "vitest";
import type { SessionTreeEntry } from "../types.js";
import { InMemorySessionStorage } from "./memory-storage.js";
import { Session } from "./session.js";
const rootEntry: SessionTreeEntry = {
type: "custom",
id: "root",
parentId: null,
timestamp: "2026-01-01T00:00:00.000Z",
customType: "root",
};
const childEntry: SessionTreeEntry = {
type: "custom",
id: "child",
parentId: "root",
timestamp: "2026-01-01T00:00:01.000Z",
customType: "child",
};
describe("InMemorySessionStorage", () => {
it("uses shared entry indexes for labels, leaves, and paths", async () => {
const storage = new InMemorySessionStorage({
entries: [
rootEntry,
childEntry,
{
type: "label",
id: "label-1",
parentId: "child",
timestamp: "2026-01-01T00:00:02.000Z",
targetId: "child",
label: " latest ",
},
],
});
expect(await storage.getLeafId()).toBe("label-1");
expect(await storage.getLabel("child")).toBe("latest");
expect((await storage.getPathToRoot("child")).map((entry) => entry.id)).toEqual([
"root",
"child",
]);
});
it("records explicit leaf updates through the shared storage path", async () => {
const storage = new InMemorySessionStorage({
entries: [rootEntry, childEntry],
});
await storage.setLeafId("root");
const entries = await storage.getEntries();
const leaf = entries.at(-1);
expect(await storage.getLeafId()).toBe("root");
expect(leaf).toMatchObject({
type: "leaf",
parentId: "child",
targetId: "root",
});
});
it("traverses descendants of leaf markers through the selected target", async () => {
const leafEntry: SessionTreeEntry = {
type: "leaf",
id: "leaf-1",
parentId: "child",
timestamp: "2026-01-01T00:00:02.000Z",
targetId: "root",
};
const replacementEntry: SessionTreeEntry = {
type: "custom",
id: "replacement",
parentId: leafEntry.id,
timestamp: "2026-01-01T00:00:03.000Z",
customType: "replacement",
};
const storage = new InMemorySessionStorage({
entries: [rootEntry, childEntry, leafEntry, replacementEntry],
});
expect((await storage.getPathToRoot(replacementEntry.id)).map((entry) => entry.id)).toEqual([
"root",
"replacement",
]);
expect((await storage.getPathToRoot(leafEntry.id)).map((entry) => entry.id)).toEqual(["root"]);
});
it("honors an explicit root append parent after a visible leaf selection", async () => {
const storage = new InMemorySessionStorage({
entries: [
rootEntry,
{
type: "leaf",
id: "leaf-1",
parentId: "root",
timestamp: "2026-01-01T00:00:01.000Z",
targetId: "root",
appendParentId: null,
},
],
});
const session = new Session(storage);
const entryId = await session.appendCustomEntry("new-root");
expect(await session.getEntry(entryId)).toMatchObject({ parentId: null });
expect((await storage.getPathToRoot(entryId)).map((entry) => entry.id)).toEqual([
"root",
entryId,
]);
});
it("keeps marked side ancestry separate from the next active append", async () => {
const sideOne: SessionTreeEntry = {
type: "custom",
id: "side-one",
parentId: "root",
timestamp: "2026-01-01T00:00:01.000Z",
customType: "side",
};
const sideTwo: SessionTreeEntry = {
type: "custom",
id: "side-two",
parentId: sideOne.id,
timestamp: "2026-01-01T00:00:03.000Z",
appendMode: "side",
customType: "side",
};
const storage = new InMemorySessionStorage({
entries: [
rootEntry,
sideOne,
{
type: "leaf",
id: "first-leaf",
parentId: sideOne.id,
timestamp: "2026-01-01T00:00:02.000Z",
targetId: "root",
appendParentId: sideOne.id,
appendMode: "side",
},
sideTwo,
],
});
const session = new Session(storage);
expect(await storage.getLeafId()).toBe("root");
expect(await storage.getAppendParentId()).toBe(sideTwo.id);
expect((await storage.getPathToRoot(sideTwo.id)).map((entry) => entry.id)).toEqual([
"root",
sideOne.id,
sideTwo.id,
]);
const nextEntryId = await session.appendCustomEntry("active");
expect((await storage.getPathToRoot(nextEntryId)).map((entry) => entry.id)).toEqual([
"root",
nextEntryId,
]);
});
it("rejects a leaf entry with a missing append parent before recording it", async () => {
const storage = new InMemorySessionStorage({ entries: [rootEntry] });
await expect(
storage.appendEntry({
type: "leaf",
id: "leaf-1",
parentId: "root",
timestamp: "2026-01-01T00:00:01.000Z",
targetId: "root",
appendParentId: "missing",
}),
).rejects.toThrow("Append parent missing not found");
expect(await storage.getEntries()).toEqual([rootEntry]);
});
});

View File

@@ -0,0 +1,24 @@
// Agent Core module implements memory storage behavior.
import type { SessionMetadata, SessionTreeEntry } from "../types.js";
import { BaseSessionStorage } from "./storage-base.js";
import { uuidv7 } from "./uuid.js";
/** Volatile session storage used by tests and in-process harness callers. */
export class InMemorySessionStorage<
TMetadata extends SessionMetadata = SessionMetadata,
> extends BaseSessionStorage<TMetadata> {
constructor(options?: { entries?: SessionTreeEntry[]; metadata?: TMetadata }) {
super(
options?.metadata ?? ({ id: uuidv7(), createdAt: new Date().toISOString() } as TMetadata),
options?.entries ? [...options.entries] : [],
);
}
override async setLeafId(leafId: string | null): Promise<void> {
this.recordEntry(this.createLeafEntry(leafId));
}
override async appendEntry(entry: SessionTreeEntry): Promise<void> {
this.recordEntry(entry);
}
}

View File

@@ -0,0 +1,290 @@
// Agent Core module implements session behavior.
import type { ImageContent, TextContent } from "../../../../llm-core/src/index.js";
import type { AgentMessage } from "../../types.js";
import {
asAgentMessage,
createBranchSummaryMessage,
createCompactionSummaryMessage,
createCustomMessage,
} from "../messages.js";
import type {
BranchSummaryEntry,
CompactionEntry,
CustomEntry,
CustomMessageEntry,
LabelEntry,
MessageEntry,
ModelChangeEntry,
SessionContext,
SessionInfoEntry,
SessionMetadata,
SessionStorage,
SessionTreeEntry,
ThinkingLevelChangeEntry,
} from "../types.js";
import { SessionError } from "../types.js";
/** Build model context from the active session branch and its latest state markers. */
export function buildSessionContext(pathEntries: SessionTreeEntry[]): SessionContext {
let thinkingLevel = "off";
let model: { provider: string; modelId: string } | null = null;
let compaction: CompactionEntry | null = null;
for (const entry of pathEntries) {
if (entry.type === "thinking_level_change") {
thinkingLevel = entry.thinkingLevel;
} else if (entry.type === "model_change") {
model = { provider: entry.provider, modelId: entry.modelId };
} else if (entry.type === "message" && entry.message.role === "assistant") {
model = { provider: entry.message.provider, modelId: entry.message.model };
} else if (entry.type === "compaction") {
compaction = entry;
}
}
const messages: AgentMessage[] = [];
const appendMessage = (entry: SessionTreeEntry) => {
if (entry.type === "message") {
messages.push(entry.message);
} else if (entry.type === "custom_message") {
messages.push(
asAgentMessage(
createCustomMessage(
entry.customType,
entry.content,
entry.display,
entry.details,
entry.timestamp,
),
),
);
} else if (entry.type === "branch_summary" && entry.summary) {
messages.push(
asAgentMessage(createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp)),
);
}
};
if (compaction) {
messages.push(
asAgentMessage(
createCompactionSummaryMessage(
compaction.summary,
compaction.tokensBefore,
compaction.timestamp,
),
),
);
const compactionIdx = pathEntries.findIndex(
(e) => e.type === "compaction" && e.id === compaction.id,
);
// Replay only the compacted entry's retained tail plus newer branch entries; older
// transcript content is represented by the synthetic compaction summary above.
let foundFirstKept = false;
for (let i = 0; i < compactionIdx; i++) {
const entry = pathEntries[i];
if (entry.id === compaction.firstKeptEntryId) {
foundFirstKept = true;
}
if (foundFirstKept) {
appendMessage(entry);
}
}
for (let i = compactionIdx + 1; i < pathEntries.length; i++) {
appendMessage(pathEntries[i]);
}
} else {
for (const entry of pathEntries) {
appendMessage(entry);
}
}
return { messages, thinkingLevel, model };
}
/** High-level session API backed by pluggable tree storage. */
export class Session<TMetadata extends SessionMetadata = SessionMetadata> {
private storage: SessionStorage<TMetadata>;
constructor(storage: SessionStorage<TMetadata>) {
this.storage = storage;
}
getMetadata(): Promise<TMetadata> {
return this.storage.getMetadata();
}
getStorage(): SessionStorage<TMetadata> {
return this.storage;
}
getLeafId(): Promise<string | null> {
return this.storage.getLeafId();
}
private getAppendParentId(): Promise<string | null> {
return this.storage.getAppendParentId?.() ?? this.storage.getLeafId();
}
getEntry(id: string): Promise<SessionTreeEntry | undefined> {
return this.storage.getEntry(id);
}
getEntries(): Promise<SessionTreeEntry[]> {
return this.storage.getEntries();
}
async getBranch(fromId?: string): Promise<SessionTreeEntry[]> {
const leafId = fromId ?? (await this.storage.getLeafId());
return this.storage.getPathToRoot(leafId);
}
async buildContext(): Promise<SessionContext> {
return buildSessionContext(await this.getBranch());
}
getLabel(id: string): Promise<string | undefined> {
return this.storage.getLabel(id);
}
async getSessionName(): Promise<string | undefined> {
const entries = await this.storage.findEntries("session_info");
return entries[entries.length - 1]?.name?.trim() || undefined;
}
private async appendTypedEntry(entry: SessionTreeEntry): Promise<string> {
await this.storage.appendEntry(entry);
return entry.id;
}
async appendMessage(message: AgentMessage): Promise<string> {
return this.appendTypedEntry({
type: "message",
id: await this.storage.createEntryId(),
parentId: await this.getAppendParentId(),
timestamp: new Date().toISOString(),
message,
} satisfies MessageEntry);
}
async appendThinkingLevelChange(thinkingLevel: string): Promise<string> {
return this.appendTypedEntry({
type: "thinking_level_change",
id: await this.storage.createEntryId(),
parentId: await this.getAppendParentId(),
timestamp: new Date().toISOString(),
thinkingLevel,
} satisfies ThinkingLevelChangeEntry);
}
async appendModelChange(provider: string, modelId: string): Promise<string> {
return this.appendTypedEntry({
type: "model_change",
id: await this.storage.createEntryId(),
parentId: await this.getAppendParentId(),
timestamp: new Date().toISOString(),
provider,
modelId,
} satisfies ModelChangeEntry);
}
async appendCompaction(
summary: string,
firstKeptEntryId: string,
tokensBefore: number,
details?: unknown,
fromHook?: boolean,
): Promise<string> {
return this.appendTypedEntry({
type: "compaction",
id: await this.storage.createEntryId(),
parentId: await this.getAppendParentId(),
timestamp: new Date().toISOString(),
summary,
firstKeptEntryId,
tokensBefore,
details,
fromHook,
} satisfies CompactionEntry);
}
/** Append a non-LLM transcript marker for harness-specific state. */
async appendCustomEntry(customType: string, data?: unknown): Promise<string> {
return this.appendTypedEntry({
type: "custom",
id: await this.storage.createEntryId(),
parentId: await this.getAppendParentId(),
timestamp: new Date().toISOString(),
customType,
data,
} satisfies CustomEntry);
}
/** Append harness-specific content that can also be replayed into model context. */
async appendCustomMessageEntry(
customType: string,
content: string | (TextContent | ImageContent)[],
display: boolean,
details?: unknown,
): Promise<string> {
return this.appendTypedEntry({
type: "custom_message",
id: await this.storage.createEntryId(),
parentId: await this.getAppendParentId(),
timestamp: new Date().toISOString(),
customType,
content,
display,
details,
} satisfies CustomMessageEntry);
}
/** Record or clear the display label for an existing session entry. */
async appendLabel(targetId: string, label: string | undefined): Promise<string> {
if (!(await this.storage.getEntry(targetId))) {
throw new SessionError("not_found", `Entry ${targetId} not found`);
}
return this.appendTypedEntry({
type: "label",
id: await this.storage.createEntryId(),
parentId: await this.getAppendParentId(),
timestamp: new Date().toISOString(),
targetId,
label,
} satisfies LabelEntry);
}
async appendSessionName(name: string): Promise<string> {
return this.appendTypedEntry({
type: "session_info",
id: await this.storage.createEntryId(),
parentId: await this.getAppendParentId(),
timestamp: new Date().toISOString(),
name: name.trim(),
} satisfies SessionInfoEntry);
}
/** Move the visible branch leaf and optionally attach a summary of the abandoned branch. */
async moveTo(
entryId: string | null,
summary?: { summary: string; details?: unknown; fromHook?: boolean },
): Promise<string | undefined> {
if (entryId !== null && !(await this.storage.getEntry(entryId))) {
throw new SessionError("not_found", `Entry ${entryId} not found`);
}
await this.storage.setLeafId(entryId);
if (!summary) {
return undefined;
}
return this.appendTypedEntry({
type: "branch_summary",
id: await this.storage.createEntryId(),
parentId: entryId,
timestamp: new Date().toISOString(),
fromId: entryId ?? "root",
summary: summary.summary,
details: summary.details,
fromHook: summary.fromHook,
} satisfies BranchSummaryEntry);
}
}

View File

@@ -0,0 +1,283 @@
// Agent Core module implements storage base behavior.
import {
type LeafEntry,
SessionError,
type SessionMetadata,
type SessionStorage,
type SessionTreeEntry,
} from "../types.js";
import { uuidv7 } from "./uuid.js";
function updateLabelCache(labelsById: Map<string, string>, entry: SessionTreeEntry): void {
if (entry.type !== "label") {
return;
}
const label = entry.label?.trim();
if (label) {
labelsById.set(entry.targetId, label);
} else {
labelsById.delete(entry.targetId);
}
}
function buildLabelsById(entries: SessionTreeEntry[]): Map<string, string> {
const labelsById = new Map<string, string>();
for (const entry of entries) {
updateLabelCache(labelsById, entry);
}
return labelsById;
}
function isSideAppendEntry(entry: SessionTreeEntry): boolean {
return entry.appendMode === "side";
}
function generateEntryId(byId: { has(id: string): boolean }): string {
for (let i = 0; i < 100; i++) {
const id = uuidv7().slice(0, 8);
if (!byId.has(id)) {
return id;
}
}
return uuidv7();
}
/** Return the visible-leaf update represented by one session tree entry. */
export function leafIdUpdateAfterEntry(entry: SessionTreeEntry): string | null | undefined {
if (entry.type !== "leaf" && isSideAppendEntry(entry)) {
return undefined;
}
switch (entry.type) {
case "leaf":
return entry.targetId;
case "message":
case "thinking_level_change":
case "model_change":
case "compaction":
case "branch_summary":
case "custom":
case "custom_message":
case "label":
case "session_info":
return entry.id;
default:
// JSONL transcripts may contain parent-linked plugin rows that advance
// the raw append cursor without selecting a model-visible branch.
return undefined;
}
}
/** Return the raw parent for the next append after applying a tree entry. */
export function appendParentIdAfterEntry(entry: SessionTreeEntry): string | null {
return entry.type === "leaf"
? entry.appendParentId === undefined
? entry.targetId
: entry.appendParentId
: entry.id;
}
function resolveLeafId(entries: readonly SessionTreeEntry[]): string | null {
let leafId: string | null = null;
for (const entry of entries) {
const update = leafIdUpdateAfterEntry(entry);
if (update !== undefined) {
leafId = update;
}
}
return leafId;
}
function resolveAppendParentId(entries: readonly SessionTreeEntry[]): string | null {
let appendParentId: string | null = null;
for (const entry of entries) {
appendParentId = appendParentIdAfterEntry(entry);
}
return appendParentId;
}
function buildLogicalParentsById(entries: readonly SessionTreeEntry[]): Map<string, string | null> {
const logicalParentsById = new Map<string, string | null>();
let leafId: string | null = null;
let appendParentId: string | null = null;
for (const entry of entries) {
const leafUpdate = leafIdUpdateAfterEntry(entry);
if (
leafUpdate === entry.id &&
!isSideAppendEntry(entry) &&
entry.parentId === appendParentId &&
leafId !== appendParentId
) {
logicalParentsById.set(entry.id, leafId);
}
if (leafUpdate !== undefined) {
leafId = leafUpdate;
}
appendParentId = appendParentIdAfterEntry(entry);
}
return logicalParentsById;
}
export abstract class BaseSessionStorage<
TMetadata extends SessionMetadata = SessionMetadata,
> implements SessionStorage<TMetadata> {
private readonly metadata: TMetadata;
private readonly entries: SessionTreeEntry[];
private readonly byId: Map<string, SessionTreeEntry>;
private readonly labelsById: Map<string, string>;
private readonly logicalParentsById: Map<string, string | null>;
private leafId: string | null;
private appendParentId: string | null;
protected constructor(
metadata: TMetadata,
entries: SessionTreeEntry[],
leafId: string | null = resolveLeafId(entries),
appendParentId: string | null = resolveAppendParentId(entries),
) {
this.metadata = metadata;
this.entries = entries;
this.byId = new Map(entries.map((entry) => [entry.id, entry]));
this.labelsById = buildLabelsById(entries);
this.logicalParentsById = buildLogicalParentsById(entries);
this.leafId = leafId;
this.appendParentId = appendParentId;
if (this.leafId !== null && !this.byId.has(this.leafId)) {
throw new SessionError("invalid_session", `Entry ${this.leafId} not found`);
}
if (this.appendParentId !== null && !this.byId.has(this.appendParentId)) {
throw new SessionError("invalid_session", `Append parent ${this.appendParentId} not found`);
}
}
async getMetadata(): Promise<TMetadata> {
return this.metadata;
}
async getLeafId(): Promise<string | null> {
if (this.leafId !== null && !this.byId.has(this.leafId)) {
throw new SessionError("invalid_session", `Entry ${this.leafId} not found`);
}
return this.leafId;
}
async getAppendParentId(): Promise<string | null> {
if (this.appendParentId !== null && !this.byId.has(this.appendParentId)) {
throw new SessionError("invalid_session", `Append parent ${this.appendParentId} not found`);
}
return this.appendParentId;
}
protected createLeafEntry(leafId: string | null): LeafEntry {
if (leafId !== null && !this.byId.has(leafId)) {
throw new SessionError("not_found", `Entry ${leafId} not found`);
}
return {
type: "leaf",
id: generateEntryId(this.byId),
parentId: this.appendParentId,
timestamp: new Date().toISOString(),
targetId: leafId,
};
}
async createEntryId(): Promise<string> {
return generateEntryId(this.byId);
}
protected validateEntryForAppend(entry: SessionTreeEntry): void {
const leafId = leafIdUpdateAfterEntry(entry);
const leafIsNewEntry = entry.type !== "leaf" && leafId === entry.id;
if (leafId !== undefined && leafId !== null && !leafIsNewEntry && !this.byId.has(leafId)) {
throw new SessionError("not_found", `Entry ${leafId} not found`);
}
const appendParentId = appendParentIdAfterEntry(entry);
const appendParentIsNewEntry = entry.type !== "leaf" && appendParentId === entry.id;
if (appendParentId !== null && !appendParentIsNewEntry && !this.byId.has(appendParentId)) {
throw new SessionError("not_found", `Append parent ${appendParentId} not found`);
}
}
protected recordEntry(entry: SessionTreeEntry): void {
// Leaf and label entries are append-only state changes; keep derived indexes
// synchronized here so memory and JSONL storage expose identical behavior.
this.validateEntryForAppend(entry);
const leafId = leafIdUpdateAfterEntry(entry);
if (
leafId === entry.id &&
!isSideAppendEntry(entry) &&
entry.parentId === this.appendParentId &&
this.leafId !== this.appendParentId
) {
this.logicalParentsById.set(entry.id, this.leafId);
}
this.entries.push(entry);
this.byId.set(entry.id, entry);
updateLabelCache(this.labelsById, entry);
if (leafId !== undefined) {
this.leafId = leafId;
}
this.appendParentId = appendParentIdAfterEntry(entry);
}
async getEntry(id: string): Promise<SessionTreeEntry | undefined> {
return this.byId.get(id);
}
async findEntries<TType extends SessionTreeEntry["type"]>(
type: TType,
): Promise<Array<Extract<SessionTreeEntry, { type: TType }>>> {
return this.entries.filter(
(entry): entry is Extract<SessionTreeEntry, { type: TType }> => entry.type === type,
);
}
async getLabel(id: string): Promise<string | undefined> {
return this.labelsById.get(id);
}
async getPathToRoot(leafId: string | null): Promise<SessionTreeEntry[]> {
if (leafId === null) {
return [];
}
const path: SessionTreeEntry[] = [];
let current = this.byId.get(leafId);
if (!current) {
throw new SessionError("not_found", `Entry ${leafId} not found`);
}
const seen = new Set<string>();
while (current) {
if (seen.has(current.id)) {
throw new SessionError("invalid_session", `Cycle found at entry ${current.id}`);
}
seen.add(current.id);
if (current.type !== "leaf") {
path.unshift(current);
}
// Leaf rows are control records. Descendants written by older appenders
// may point at the marker, but their visible ancestry starts at its target.
const parentId =
current.type === "leaf"
? current.targetId
: this.logicalParentsById.has(current.id)
? (this.logicalParentsById.get(current.id) ?? null)
: current.parentId;
if (!parentId) {
break;
}
const parent = this.byId.get(parentId);
if (!parent) {
throw new SessionError("invalid_session", `Entry ${parentId} not found`);
}
current = parent;
}
return path;
}
async getEntries(): Promise<SessionTreeEntry[]> {
return [...this.entries];
}
abstract setLeafId(leafId: string | null): Promise<void>;
abstract appendEntry(entry: SessionTreeEntry): Promise<void>;
}

View File

@@ -0,0 +1,17 @@
/** Parse an ISO-like session timestamp to milliseconds. */
export function parseSessionTimestampMs(value: unknown): number | undefined {
if (typeof value !== "string" || !value.trim()) {
return undefined;
}
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
/** Parse a required timestamp or throw a labeled validation error. */
export function requireSessionTimestampMs(value: string, label: string): number {
const parsed = parseSessionTimestampMs(value);
if (parsed === undefined) {
throw new Error(`${label} must be a valid timestamp`);
}
return parsed;
}

View File

@@ -0,0 +1,59 @@
// Agent Core module implements uuid behavior.
let lastTimestamp = -Infinity;
let sequence = 0;
// Small UUIDv7 generator for browser/node package builds without a runtime dep.
function fillRandomBytes(bytes: Uint8Array): void {
const crypto = globalThis.crypto;
if (crypto?.getRandomValues) {
crypto.getRandomValues(bytes as Uint8Array<ArrayBuffer>);
return;
}
for (let i = 0; i < bytes.length; i++) {
bytes[i] = Math.floor(Math.random() * 256);
}
}
/** Generate a monotonic UUIDv7 string. */
export function uuidv7(): string {
const random = new Uint8Array(16);
fillRandomBytes(random);
const timestamp = Date.now();
if (timestamp > lastTimestamp) {
sequence = random[6] * 0x1000000 + random[7] * 0x10000 + random[8] * 0x100 + random[9];
lastTimestamp = timestamp;
} else {
// Same-ms calls increment the sequence so generated ids remain sortable and
// unique even when random bytes repeat.
sequence = (sequence + 1) >>> 0;
if (sequence === 0) {
lastTimestamp++;
}
}
const bytes = new Uint8Array(16);
bytes[0] = (lastTimestamp / 0x10000000000) & 0xff;
bytes[1] = (lastTimestamp / 0x100000000) & 0xff;
bytes[2] = (lastTimestamp / 0x1000000) & 0xff;
bytes[3] = (lastTimestamp / 0x10000) & 0xff;
bytes[4] = (lastTimestamp / 0x100) & 0xff;
bytes[5] = lastTimestamp & 0xff;
bytes[6] = 0x70 | ((sequence >>> 28) & 0x0f);
bytes[7] = (sequence >>> 20) & 0xff;
bytes[8] = 0x80 | ((sequence >>> 14) & 0x3f);
bytes[9] = (sequence >>> 6) & 0xff;
bytes[10] = ((sequence & 0x3f) << 2) | (random[10] & 0x03);
bytes[11] = random[11];
bytes[12] = random[12];
bytes[13] = random[13];
bytes[14] = random[14];
bytes[15] = random[15];
return formatUuid(bytes);
}
function formatUuid(bytes: Uint8Array): string {
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0"));
return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex.slice(6, 8).join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10, 16).join("")}`;
}

View File

@@ -0,0 +1,14 @@
// Agent Core module implements skill invocation formatting.
import type { Skill } from "./types.js";
/** Format a skill invocation prompt, optionally appending additional user instructions. */
export function formatSkillInvocation(skill: Skill, additionalInstructions?: string): string {
const skillBlock = `<skill name="${skill.name}" location="${skill.filePath}">\nReferences are relative to ${dirnameEnvPath(skill.filePath)}.\n\n${skill.content}\n</skill>`;
return additionalInstructions ? `${skillBlock}\n\n${additionalInstructions}` : skillBlock;
}
function dirnameEnvPath(path: string): string {
const normalized = path.replace(/\/+$/, "");
const slashIndex = normalized.lastIndexOf("/");
return slashIndex <= 0 ? "/" : normalized.slice(0, slashIndex);
}

View File

@@ -0,0 +1,842 @@
// Agent Core type module defines shared TypeScript contracts.
import type {
ImageContent,
Model,
SimpleStreamOptions,
StreamFn,
TextContent,
Transport,
} from "../../../llm-core/src/index.js";
import type { AgentEvent, AgentMessage, AgentTool, QueueMode, ThinkingLevel } from "../index.js";
import type { AgentCoreCompletionRuntimeDeps, AgentCoreRuntimeDeps } from "../runtime-deps.js";
import type { Session } from "./session/session.js";
/** Result of a fallible operation. Expected failures are returned as `ok: false` instead of thrown. */
export type Result<TValue, TError> = { ok: true; value: TValue } | { ok: false; error: TError };
/** Create a successful {@link Result}. */
export function ok<TValue, TError>(value: TValue): Result<TValue, TError> {
return { ok: true, value };
}
/** Create a failed {@link Result}. */
export function err<TValue, TError>(error: TError): Result<TValue, TError> {
return { ok: false, error };
}
/** Normalize unknown thrown values into Error instances before using them as typed error causes. */
export function toError(error: unknown): Error {
if (error instanceof Error) {
return error;
}
if (typeof error === "string") {
return new Error(error);
}
try {
return new Error(JSON.stringify(error));
} catch {
return new Error(String(error));
}
}
/**
* Skill loaded from a `SKILL.md` file or provided by an application.
*
* `name`, `description`, `filePath`, and optional `promptVersion` are available to host-owned prompt builders and
* direct skill invocation.
*/
export interface Skill {
/** Stable skill name used for lookup and model-visible listings. */
name: string;
/** Short model-visible description of when to use the skill. */
description: string;
/** Full skill instructions. */
content: string;
/** Absolute path to the skill file. Used for model-visible location and resolving relative references. */
filePath: string;
/** Deterministic marker for the skill content, rendered as <version> when available. */
promptVersion?: string;
/** Exclude this skill from model-visible skill lists while still allowing explicit application invocation. */
disableModelInvocation?: boolean;
}
/** Prompt template that can be formatted into a prompt for explicit invocation. */
export interface PromptTemplate {
/** Stable template name used for lookup or application command routing. */
name: string;
/** Optional description for command lists or autocomplete. */
description?: string;
/** Template content. Argument placeholders are formatted by `formatPromptTemplateInvocation`. */
content: string;
}
/** Resources made available to explicit invocation methods and system-prompt callbacks. */
export interface AgentHarnessResources<
TSkill extends Skill = Skill,
TPromptTemplate extends PromptTemplate = PromptTemplate,
> {
/** Prompt templates available for explicit invocation. */
promptTemplates?: TPromptTemplate[];
/** Skills available to the model and explicit skill invocation. */
skills?: TSkill[];
}
/** Curated provider request options owned by the harness and snapshotted per turn. */
export interface AgentHarnessStreamOptions {
/** Preferred transport forwarded to the stream function. */
transport?: Transport;
/** Provider request timeout in milliseconds. */
timeoutMs?: number;
/** Maximum provider retry attempts. */
maxRetries?: number;
/** Optional cap for provider-requested retry delays. */
maxRetryDelayMs?: number;
/** Additional request headers merged with auth and lifecycle headers. */
headers?: Record<string, string>;
/** Provider metadata forwarded with requests. */
metadata?: SimpleStreamOptions["metadata"];
/** Provider cache retention hint. */
cacheRetention?: SimpleStreamOptions["cacheRetention"];
}
/** Per-request stream option patch returned by provider hooks. */
export interface AgentHarnessStreamOptionsPatch extends Omit<
Partial<AgentHarnessStreamOptions>,
"headers" | "metadata"
> {
/** Header patch. `undefined` values delete keys; explicit `headers: undefined` clears all headers. */
headers?: Record<string, string | undefined>;
/** Metadata patch. `undefined` values delete keys; explicit `metadata: undefined` clears all metadata. */
metadata?: Record<string, unknown>;
}
/** Kind of filesystem object as addressed by a {@link FileSystem}. Symlinks are not followed automatically. */
export type FileKind = "file" | "directory" | "symlink";
/** Stable, backend-independent file error codes returned by {@link FileSystem} file operations. */
export type FileErrorCode =
| "aborted"
| "not_found"
| "permission_denied"
| "not_directory"
| "is_directory"
| "invalid"
| "not_supported"
| "unknown";
/** Error returned by {@link FileSystem} file operations. */
export class FileError extends Error {
/** Backend-independent error code. */
public code: FileErrorCode;
/** Absolute addressed path associated with the failure, when available. */
public path?: string;
constructor(code: FileErrorCode, message: string, path?: string, cause?: Error) {
super(message, cause === undefined ? undefined : { cause });
this.name = "FileError";
this.code = code;
this.path = path;
}
}
/** Stable, backend-independent execution error codes returned by {@link ExecutionEnv.exec}. */
export type ExecutionErrorCode =
| "aborted"
| "timeout"
| "shell_unavailable"
| "spawn_error"
| "callback_error"
| "unknown";
/** Error returned by {@link ExecutionEnv.exec}. */
export class ExecutionError extends Error {
/** Backend-independent error code. */
public code: ExecutionErrorCode;
constructor(code: ExecutionErrorCode, message: string, cause?: Error) {
super(message, cause === undefined ? undefined : { cause });
this.name = "ExecutionError";
this.code = code;
}
}
/** Stable compaction error codes returned by compaction helpers. */
export type CompactionErrorCode =
| "aborted"
| "summarization_failed"
| "invalid_session"
| "unknown";
/** Error returned by compaction helpers. */
export class CompactionError extends Error {
/** Backend-independent error code. */
public code: CompactionErrorCode;
constructor(code: CompactionErrorCode, message: string, cause?: Error) {
super(message, cause === undefined ? undefined : { cause });
this.name = "CompactionError";
this.code = code;
}
}
/** Stable branch-summary error codes returned by branch summarization helpers. */
export type BranchSummaryErrorCode = "aborted" | "summarization_failed" | "invalid_session";
/** Error returned by branch summarization helpers. */
export class BranchSummaryError extends Error {
/** Backend-independent error code. */
public code: BranchSummaryErrorCode;
constructor(code: BranchSummaryErrorCode, message: string, cause?: Error) {
super(message, cause === undefined ? undefined : { cause });
this.name = "BranchSummaryError";
this.code = code;
}
}
export type SessionErrorCode =
| "not_found"
| "invalid_session"
| "invalid_entry"
| "invalid_fork_target"
| "storage"
| "unknown";
/** Error thrown by session storage, repositories, and session tree operations. */
export class SessionError extends Error {
/** Session subsystem error code. */
public code: SessionErrorCode;
constructor(code: SessionErrorCode, message: string, cause?: Error) {
super(message, cause === undefined ? undefined : { cause });
this.name = "SessionError";
this.code = code;
}
}
export type AgentHarnessErrorCode =
| "busy"
| "invalid_state"
| "invalid_argument"
| "session"
| "hook"
| "auth"
| "compaction"
| "branch_summary"
| "unknown";
/** Public AgentHarness failure with a stable top-level classification. */
export class AgentHarnessError extends Error {
public code: AgentHarnessErrorCode;
constructor(code: AgentHarnessErrorCode, message: string, cause?: Error) {
super(message, cause === undefined ? undefined : { cause });
this.name = "AgentHarnessError";
this.code = code;
}
}
/** Metadata for one filesystem object in a {@link FileSystem}. */
export interface FileInfo {
/** Basename of {@link path}. */
name: string;
/** Absolute, syntactically normalized addressed path in the execution environment. Symlinks are not followed. */
path: string;
/** Object kind. Symlink targets are not followed; use {@link FileSystem.canonicalPath} explicitly. */
kind: FileKind;
/** Size in bytes for the addressed filesystem object. */
size: number;
/** Modification time as milliseconds since Unix epoch. */
mtimeMs: number;
}
/** Options for {@link Shell.exec}. */
export interface ExecutionEnvExecOptions {
/** Working directory for the command. Relative paths are resolved against {@link ExecutionEnv.cwd}. Defaults to {@link ExecutionEnv.cwd}. */
cwd?: string;
/** Additional environment variables for the command. Values override the environment defaults. Defaults to no overrides. */
env?: Record<string, string>;
/** Timeout in seconds. Implementations should return a timeout error when the command exceeds this duration. Defaults to no timeout. */
timeout?: number;
/** Abort signal used to terminate the command. Defaults to no abort signal. */
abortSignal?: AbortSignal;
/** Called with stdout chunks as they are produced. */
onStdout?: (chunk: string) => void;
/** Called with stderr chunks as they are produced. */
onStderr?: (chunk: string) => void;
}
/**
* Filesystem capability used by the harness.
*
* Paths passed to methods may be absolute or relative to {@link cwd}. Paths returned by file operations are addressed paths
* in the filesystem namespace, but are not canonicalized through symlinks unless returned by {@link canonicalPath}.
*
* Operation methods must never throw or reject. All filesystem failures, including unexpected backend failures, must be
* encoded in the returned {@link Result}. Implementations must preserve this invariant.
*/
export interface FileSystem {
/** Current working directory for relative paths. */
cwd: string;
/** Return an absolute addressed path without requiring it to exist and without resolving symlinks. */
absolutePath(path: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>>;
/** Join path segments in the filesystem namespace without requiring the result to exist. */
joinPath(parts: string[], abortSignal?: AbortSignal): Promise<Result<string, FileError>>;
/** Read a UTF-8 text file. */
readTextFile(path: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>>;
/** Read UTF-8 text lines. Implementations should stop once `maxLines` lines have been read. */
readTextLines(
path: string,
options?: { maxLines?: number; abortSignal?: AbortSignal },
): Promise<Result<string[], FileError>>;
/** Read a binary file. */
readBinaryFile(path: string, abortSignal?: AbortSignal): Promise<Result<Uint8Array, FileError>>;
/** Create or overwrite a file, creating parent directories when supported. */
writeFile(
path: string,
content: string | Uint8Array,
abortSignal?: AbortSignal,
): Promise<Result<void, FileError>>;
/** Create or append to a file, creating parent directories when supported. */
appendFile(
path: string,
content: string | Uint8Array,
abortSignal?: AbortSignal,
): Promise<Result<void, FileError>>;
/** Return metadata for the addressed path without following symlinks. */
fileInfo(path: string, abortSignal?: AbortSignal): Promise<Result<FileInfo, FileError>>;
/** List direct children of a directory without following symlinks. */
listDir(path: string, abortSignal?: AbortSignal): Promise<Result<FileInfo[], FileError>>;
/** Return the canonical path for an existing path, resolving symlinks where supported. */
canonicalPath(path: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>>;
/** Return false for missing paths. Other errors, such as permission failures, return a {@link FileError}. */
exists(path: string, abortSignal?: AbortSignal): Promise<Result<boolean, FileError>>;
/** Create a directory. Defaults: `recursive: true`, no abort signal. */
createDir(
path: string,
options?: { recursive?: boolean; abortSignal?: AbortSignal },
): Promise<Result<void, FileError>>;
/** Remove a file or directory. Defaults: `recursive: false`, `force: false`, no abort signal. */
remove(
path: string,
options?: { recursive?: boolean; force?: boolean; abortSignal?: AbortSignal },
): Promise<Result<void, FileError>>;
/** Create a temporary directory and return its absolute path. Defaults: `prefix: "tmp-"`, no abort signal. */
createTempDir(prefix?: string, abortSignal?: AbortSignal): Promise<Result<string, FileError>>;
/** Create a temporary file and return its absolute path. Defaults: `prefix: ""`, `suffix: ""`, no abort signal. */
createTempFile(options?: {
prefix?: string;
suffix?: string;
abortSignal?: AbortSignal;
}): Promise<Result<string, FileError>>;
/** Release filesystem resources. Must be best-effort and must not throw or reject. */
cleanup(): Promise<void>;
}
/** Shell execution capability used by the harness. */
export interface Shell {
/** Execute a shell command in {@link FileSystem.cwd} unless `options.cwd` is provided. */
exec(
command: string,
options?: ExecutionEnvExecOptions,
): Promise<Result<{ stdout: string; stderr: string; exitCode: number }, ExecutionError>>;
/** Release shell resources. Must be best-effort and must not throw or reject. */
cleanup(): Promise<void>;
}
/** Filesystem and process execution environment used by the harness. */
export interface ExecutionEnv extends FileSystem, Shell {}
/** Base fields shared by append-only session tree entries. */
export interface SessionTreeEntryBase {
/** Entry discriminator used for JSONL persistence and typed narrowing. */
type: string;
/** Stable entry id unique within a session file. */
id: string;
/** Parent entry id, or null for a root entry. */
parentId: string | null;
/** ISO timestamp string used for persistence and sorting. */
timestamp: string;
/** This row consumes the raw side cursor instead of the visible leaf. */
appendMode?: "side";
}
/** Persisted transcript message entry. */
export interface MessageEntry extends SessionTreeEntryBase {
type: "message";
message: AgentMessage;
}
/** Persisted thinking-level selection marker. */
export interface ThinkingLevelChangeEntry extends SessionTreeEntryBase {
type: "thinking_level_change";
thinkingLevel: string;
}
/** Persisted model selection marker. */
export interface ModelChangeEntry extends SessionTreeEntryBase {
type: "model_change";
provider: string;
modelId: string;
}
/** Persisted summary that replaces older transcript history in context. */
export interface CompactionEntry<T = unknown> extends SessionTreeEntryBase {
type: "compaction";
summary: string;
firstKeptEntryId: string;
tokensBefore: number;
details?: T;
fromHook?: boolean;
}
/** Persisted summary of an abandoned branch when navigating the session tree. */
export interface BranchSummaryEntry<T = unknown> extends SessionTreeEntryBase {
type: "branch_summary";
fromId: string;
summary: string;
details?: T;
fromHook?: boolean;
}
/** Persisted harness/application marker that is not replayed into model context. */
export interface CustomEntry<T = unknown> extends SessionTreeEntryBase {
type: "custom";
customType: string;
data?: T;
}
/** Persisted harness/application message that can be replayed into model context. */
export interface CustomMessageEntry<T = unknown> extends SessionTreeEntryBase {
type: "custom_message";
customType: string;
content: string | (TextContent | ImageContent)[];
details?: T;
display: boolean;
}
/** Append-only label update for another session entry. */
export interface LabelEntry extends SessionTreeEntryBase {
type: "label";
targetId: string;
label: string | undefined;
}
/** Persisted session metadata marker. */
export interface SessionInfoEntry extends SessionTreeEntryBase {
// The persisted discriminator predates the public "session name" wording.
type: "session_info";
name?: string;
}
/** Append-only marker that changes the active visible leaf. */
export interface LeafEntry extends SessionTreeEntryBase {
type: "leaf";
targetId: string | null;
/** Raw parent for the next append when it differs from the visible leaf. */
appendParentId?: string | null;
}
/** All persisted session tree entry variants. */
export type SessionTreeEntry =
| MessageEntry
| ThinkingLevelChangeEntry
| ModelChangeEntry
| CompactionEntry
| BranchSummaryEntry
| CustomEntry
| CustomMessageEntry
| LabelEntry
| SessionInfoEntry
| LeafEntry;
export interface SessionContext {
messages: AgentMessage[];
thinkingLevel: string;
model: { provider: string; modelId: string } | null;
}
export interface SessionMetadata {
id: string;
createdAt: string;
}
export interface JsonlSessionMetadata extends SessionMetadata {
cwd: string;
path: string;
parentSessionPath?: string;
}
export interface SessionStorage<TMetadata extends SessionMetadata = SessionMetadata> {
getMetadata(): Promise<TMetadata>;
getLeafId(): Promise<string | null>;
getAppendParentId?(): Promise<string | null>;
/** Persist a leaf entry that records the active session-tree leaf. */
setLeafId(leafId: string | null): Promise<void>;
createEntryId(): Promise<string>;
appendEntry(entry: SessionTreeEntry): Promise<void>;
getEntry(id: string): Promise<SessionTreeEntry | undefined>;
findEntries<TType extends SessionTreeEntry["type"]>(
type: TType,
): Promise<Array<Extract<SessionTreeEntry, { type: TType }>>>;
getLabel(id: string): Promise<string | undefined>;
getPathToRoot(leafId: string | null): Promise<SessionTreeEntry[]>;
getEntries(): Promise<SessionTreeEntry[]>;
}
export type { Session } from "./session/session.js";
export type AgentHarnessPhase = "idle" | "turn" | "compaction" | "branch_summary" | "retry";
export type PendingSessionWrite = SessionTreeEntry extends infer TEntry
? TEntry extends SessionTreeEntry
? Omit<TEntry, "id" | "parentId" | "timestamp">
: never
: never;
export interface QueueUpdateEvent {
type: "queue_update";
steer: AgentMessage[];
followUp: AgentMessage[];
nextTurn: AgentMessage[];
}
export interface SavePointEvent {
type: "save_point";
hadPendingMutations: boolean;
}
export interface AbortEvent {
type: "abort";
clearedSteer: AgentMessage[];
clearedFollowUp: AgentMessage[];
}
export interface SettledEvent {
type: "settled";
nextTurnCount: number;
}
export interface BeforeAgentStartEvent<
TSkill extends Skill = Skill,
TPromptTemplate extends PromptTemplate = PromptTemplate,
> {
type: "before_agent_start";
prompt: string;
images?: ImageContent[];
systemPrompt: string;
resources: AgentHarnessResources<TSkill, TPromptTemplate>;
}
export interface ContextEvent {
type: "context";
messages: AgentMessage[];
}
export interface BeforeProviderRequestEvent {
type: "before_provider_request";
model: Model;
sessionId: string;
streamOptions: AgentHarnessStreamOptions;
}
export interface BeforeProviderPayloadEvent {
type: "before_provider_payload";
model: Model;
payload: unknown;
}
export interface AfterProviderResponseEvent {
type: "after_provider_response";
status: number;
headers: Record<string, string>;
}
export interface ToolCallEvent {
type: "tool_call";
toolCallId: string;
toolName: string;
input: Record<string, unknown>;
}
export interface ToolResultEvent {
type: "tool_result";
toolCallId: string;
toolName: string;
input: Record<string, unknown>;
content: Array<TextContent | ImageContent>;
details: unknown;
isError: boolean;
}
export interface SessionBeforeCompactEvent {
type: "session_before_compact";
preparation: CompactionPreparation;
branchEntries: SessionTreeEntry[];
customInstructions?: string;
signal: AbortSignal;
}
export interface SessionCompactEvent {
type: "session_compact";
compactionEntry: CompactionEntry;
fromHook: boolean;
}
export interface SessionBeforeTreeEvent {
type: "session_before_tree";
preparation: TreePreparation;
signal: AbortSignal;
}
export interface SessionTreeEvent {
type: "session_tree";
newLeafId: string | null;
oldLeafId: string | null;
summaryEntry?: BranchSummaryEntry;
fromHook?: boolean;
}
export interface ModelSelectEvent {
type: "model_select";
model: Model;
previousModel: Model | undefined;
source: "set" | "restore";
}
export interface ThinkingLevelSelectEvent {
type: "thinking_level_select";
level: ThinkingLevel;
previousLevel: ThinkingLevel;
}
export interface ResourcesUpdateEvent<
TSkill extends Skill = Skill,
TPromptTemplate extends PromptTemplate = PromptTemplate,
> {
type: "resources_update";
resources: AgentHarnessResources<TSkill, TPromptTemplate>;
previousResources: AgentHarnessResources<TSkill, TPromptTemplate>;
}
export type AgentHarnessOwnEvent<
TSkill extends Skill = Skill,
TPromptTemplate extends PromptTemplate = PromptTemplate,
> =
| QueueUpdateEvent
| SavePointEvent
| AbortEvent
| SettledEvent
| BeforeAgentStartEvent<TSkill, TPromptTemplate>
| ContextEvent
| BeforeProviderRequestEvent
| BeforeProviderPayloadEvent
| AfterProviderResponseEvent
| ToolCallEvent
| ToolResultEvent
| SessionBeforeCompactEvent
| SessionCompactEvent
| SessionBeforeTreeEvent
| SessionTreeEvent
| ModelSelectEvent
| ThinkingLevelSelectEvent
| ResourcesUpdateEvent<TSkill, TPromptTemplate>;
export type AgentHarnessEvent<
TSkill extends Skill = Skill,
TPromptTemplate extends PromptTemplate = PromptTemplate,
> = AgentEvent | AgentHarnessOwnEvent<TSkill, TPromptTemplate>;
/** Hook result for mutating the initial prompt run before the agent starts. */
export interface BeforeAgentStartResult {
/** Replacement messages for the prompt run. */
messages?: AgentMessage[];
/** Replacement system prompt for the prompt run. */
systemPrompt?: string;
}
/** Hook result for replacing the full context message list before provider conversion. */
export interface ContextResult {
messages: AgentMessage[];
}
/** Hook result for patching provider request options before payload construction. */
export interface BeforeProviderRequestResult {
streamOptions?: AgentHarnessStreamOptionsPatch;
}
/** Hook result for replacing the provider payload after construction. */
export interface BeforeProviderPayloadResult {
payload: unknown;
}
/** Hook result for blocking a tool call before execution. */
export interface ToolCallResult {
block?: boolean;
reason?: string;
}
/** Hook patch for a completed tool result before it is persisted/emitted. */
export interface ToolResultPatch {
content?: Array<TextContent | ImageContent>;
details?: unknown;
isError?: boolean;
terminate?: boolean;
}
/** Hook result for cancelling or replacing a planned compaction. */
export interface SessionBeforeCompactResult {
cancel?: boolean;
compaction?: CompactResult;
}
/** Hook result for cancelling, labeling, or supplying branch-summary behavior before tree navigation. */
export interface SessionBeforeTreeResult {
cancel?: boolean;
summary?: { summary: string; details?: unknown };
customInstructions?: string;
replaceInstructions?: boolean;
label?: string;
}
/** Typed return values expected from AgentHarness hook handlers by event type. */
export type AgentHarnessEventResultMap = {
before_agent_start: BeforeAgentStartResult | undefined;
context: ContextResult | undefined;
before_provider_request: BeforeProviderRequestResult | undefined;
before_provider_payload: BeforeProviderPayloadResult | undefined;
after_provider_response: undefined;
tool_call: ToolCallResult | undefined;
tool_result: ToolResultPatch | undefined;
session_before_compact: SessionBeforeCompactResult | undefined;
session_compact: undefined;
session_before_tree: SessionBeforeTreeResult | undefined;
session_tree: undefined;
model_select: undefined;
thinking_level_select: undefined;
resources_update: undefined;
queue_update: undefined;
save_point: undefined;
abort: undefined;
settled: undefined;
};
/** Queued messages removed by an abort operation. */
export interface AbortResult {
clearedSteer: AgentMessage[];
clearedFollowUp: AgentMessage[];
}
/** Compaction data supplied by hooks or returned from compaction preparation. */
export interface CompactResult {
summary: string;
firstKeptEntryId: string;
tokensBefore: number;
details?: unknown;
}
/** Result of moving the active session-tree leaf. */
export interface NavigateTreeResult {
cancelled: boolean;
editorText?: string;
summaryEntry?: BranchSummaryEntry;
}
/** Settings that control automatic context compaction. */
export interface CompactionSettings {
enabled: boolean;
reserveTokens: number;
keepRecentTokens: number;
}
/** Prepared compaction inputs exposed to hooks before a summary is generated. */
export interface CompactionPreparation {
firstKeptEntryId: string;
messagesToSummarize: AgentMessage[];
turnPrefixMessages: AgentMessage[];
isSplitTurn: boolean;
tokensBefore: number;
previousSummary?: string;
fileOps: FileOperations;
settings: CompactionSettings;
}
/** File operations accumulated from summarized transcript ranges. */
export interface FileOperations {
read: Set<string>;
written: Set<string>;
edited: Set<string>;
}
/** Prepared branch navigation inputs exposed to hooks before a summary is generated. */
export interface TreePreparation {
targetId: string;
oldLeafId: string | null;
commonAncestorId: string | null;
entriesToSummarize: SessionTreeEntry[];
userWantsSummary: boolean;
customInstructions?: string;
replaceInstructions?: boolean;
label?: string;
}
/** Options for generating a branch summary. */
export interface GenerateBranchSummaryOptions {
model: Model;
apiKey: string;
headers?: Record<string, string>;
signal: AbortSignal;
runtime?: AgentCoreCompletionRuntimeDeps;
streamFn?: StreamFn;
customInstructions?: string;
replaceInstructions?: boolean;
reserveTokens?: number;
}
/** Generated branch summary text and file-operation metadata. */
export interface BranchSummaryResult {
summary: string;
readFiles: string[];
modifiedFiles: string[];
}
/** Construction options for AgentHarness. */
export interface AgentHarnessOptions<
TSkill extends Skill = Skill,
TPromptTemplate extends PromptTemplate = PromptTemplate,
TTool extends AgentTool = AgentTool,
> {
env: ExecutionEnv;
session: Session;
tools?: TTool[];
/**
* Concrete resources available to explicit invocation methods and system-prompt callbacks.
* Applications own loading/reloading resources and should call `setResources()` with new values.
*/
resources?: AgentHarnessResources<TSkill, TPromptTemplate>;
systemPrompt?:
| string
| ((context: {
env: ExecutionEnv;
session: Session;
model: Model;
thinkingLevel: ThinkingLevel;
activeTools: TTool[];
resources: AgentHarnessResources<TSkill, TPromptTemplate>;
}) => string | Promise<string>);
getApiKeyAndHeaders?: (
model: Model,
) => Promise<{ apiKey: string; headers?: Record<string, string> } | undefined>;
runtime?: AgentCoreRuntimeDeps;
/** Curated stream/provider request options. Snapshotted at turn start. */
streamOptions?: AgentHarnessStreamOptions;
model: Model;
thinkingLevel?: ThinkingLevel;
activeToolNames?: string[];
steeringMode?: QueueMode;
followUpMode?: QueueMode;
}
export type { CoreAgentHarness as AgentHarness } from "./agent-harness.js";

View File

@@ -0,0 +1,63 @@
// Agent Core tests cover truncate behavior.
import { describe, expect, it } from "vitest";
import { truncateHead, truncateLine, truncateTail } from "./truncate.js";
describe("truncate utilities", () => {
it("does not count a trailing newline as an extra display line", () => {
expect(truncateHead("alpha\nbeta\n").totalLines).toBe(2);
expect(truncateTail("alpha\nbeta\n").totalLines).toBe(2);
});
it("classifies trailing-newline truncation by the byte limit", () => {
expect(truncateHead("x\n", { maxBytes: 1 }).truncatedBy).toBe("bytes");
expect(truncateTail("x\n", { maxBytes: 1 }).truncatedBy).toBe("bytes");
});
it("keeps complete UTF-8 characters when taking a partial tail line", () => {
const result = truncateTail("alpha🙂", { maxBytes: 4 });
expect(result.content).toBe("🙂");
expect(result.lastLinePartial).toBe(true);
expect(result.outputBytes).toBe(4);
});
describe("truncateLine", () => {
it("returns text unchanged when within limit", () => {
expect(truncateLine("short", 10)).toEqual({ text: "short", wasTruncated: false });
});
it("truncates and appends suffix when over limit", () => {
const result = truncateLine("this is a very long line", 10);
expect(result.wasTruncated).toBe(true);
expect(result.text).toBe("this is a ... [truncated]");
});
it("uses GREP_MAX_LINE_LENGTH as the default limit", () => {
const result = truncateLine("x");
expect(result.wasTruncated).toBe(false);
expect(result.text).toBe("x");
});
it("does not split a surrogate pair at the cut point", () => {
// Emoji at boundary: "AB" + 🤖(surrogate pair) + "CD" — cut at 3 splits the emoji.
expect(truncateLine("AB🤖CD", 3).text).toBe("AB... [truncated]");
// Three emoji, cut in the middle of the second emoji.
expect(truncateLine("🤖🤖🤖", 5).text).toBe("🤖🤖... [truncated]");
// CJK Extension B (surrogate pair) at boundary stays intact.
expect(truncateLine("AB𠮷CD", 5).text).toBe("AB𠮷C... [truncated]");
});
it("never produces unpaired surrogates in output", () => {
const results = [
truncateLine("AB🤖CD", 3).text,
truncateLine("🤖🤖🤖", 5).text,
truncateLine("AB𠮷CD", 5).text,
];
for (const text of results) {
expect(text).not.toMatch(
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/,
);
}
});
});
});

View File

@@ -0,0 +1,386 @@
// Agent Core module implements truncate behavior.
export const DEFAULT_MAX_LINES = 2000;
export const DEFAULT_MAX_BYTES = 50 * 1024; // 50KB
export const GREP_MAX_LINE_LENGTH = 500; // Max chars per grep match line
/** Result metadata for content truncated by line count, byte count, or both. */
export interface TruncationResult {
/** The truncated content */
content: string;
/** Whether truncation occurred */
truncated: boolean;
/** Which limit was hit: "lines", "bytes", or null if not truncated */
truncatedBy: "lines" | "bytes" | null;
/** Total number of lines in the original content */
totalLines: number;
/** Total number of bytes in the original content */
totalBytes: number;
/** Number of complete lines in the truncated output */
outputLines: number;
/** Number of bytes in the truncated output */
outputBytes: number;
/** Whether the last line was partially truncated (only for tail truncation edge case) */
lastLinePartial: boolean;
/** Whether the first line exceeded the byte limit (for head truncation) */
firstLineExceedsLimit: boolean;
/** The max lines limit that was applied */
maxLines: number;
/** The max bytes limit that was applied */
maxBytes: number;
}
/** Byte and line ceilings used by the truncation helpers. */
export interface TruncationOptions {
/** Maximum number of lines (default: 2000) */
maxLines?: number;
/** Maximum number of bytes (default: 50KB) */
maxBytes?: number;
}
interface ResolvedTruncationInput {
lines: string[];
totalLines: number;
totalBytes: number;
maxLines: number;
maxBytes: number;
}
interface RuntimeBuffer {
byteLength(content: string, encoding: "utf8"): number;
}
const runtimeBuffer = (globalThis as { Buffer?: RuntimeBuffer }).Buffer;
function splitLinesForCounting(content: string): string[] {
if (content.length === 0) {
return [];
}
const lines = content.split("\n");
if (content.endsWith("\n")) {
lines.pop();
}
return lines;
}
function findFirstNonAscii(content: string): number {
for (let index = 0; index < content.length; index++) {
if (content.charCodeAt(index) > 0x7f) {
return index;
}
}
return -1;
}
function utf8ByteLength(content: string): number {
if (runtimeBuffer) {
return runtimeBuffer.byteLength(content, "utf8");
}
const firstNonAscii = findFirstNonAscii(content);
if (firstNonAscii === -1) {
return content.length;
}
let bytes = firstNonAscii;
for (let i = firstNonAscii; i < content.length; i++) {
const code = content.charCodeAt(i);
if (code <= 0x7f) {
bytes += 1;
} else if (code <= 0x7ff) {
bytes += 2;
} else if (code >= 0xd800 && code <= 0xdbff && i + 1 < content.length) {
const next = content.charCodeAt(i + 1);
if (next >= 0xdc00 && next <= 0xdfff) {
bytes += 4;
i++;
} else {
bytes += 3;
}
} else {
bytes += 3;
}
}
return bytes;
}
function replaceUnpairedSurrogates(content: string): string {
let output = "";
for (let i = 0; i < content.length; i++) {
const code = content.charCodeAt(i);
if (code >= 0xd800 && code <= 0xdbff) {
if (i + 1 < content.length) {
const next = content.charCodeAt(i + 1);
if (next >= 0xdc00 && next <= 0xdfff) {
output += content[i] + content[i + 1];
i++;
continue;
}
}
output += "<22>";
} else if (code >= 0xdc00 && code <= 0xdfff) {
output += "<22>";
} else {
output += content[i];
}
}
return output;
}
/**
* Format byte counts for compact tool-output diagnostics.
*/
export function formatSize(bytes: number): string {
if (bytes < 1024) {
return `${bytes}B`;
} else if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)}KB`;
}
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
}
function resolveTruncationInput(
content: string,
options: TruncationOptions,
): ResolvedTruncationInput {
const maxLines = options.maxLines ?? DEFAULT_MAX_LINES;
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
const totalBytes = utf8ByteLength(content);
const lines = splitLinesForCounting(content);
return {
lines,
totalLines: lines.length,
totalBytes,
maxLines,
maxBytes,
};
}
function buildTruncationResult(
input: ResolvedTruncationInput,
params: {
content: string;
truncated: boolean;
truncatedBy: TruncationResult["truncatedBy"];
outputLines: number;
outputBytes?: number;
lastLinePartial?: boolean;
firstLineExceedsLimit?: boolean;
},
): TruncationResult {
return {
content: params.content,
truncated: params.truncated,
truncatedBy: params.truncatedBy,
totalLines: input.totalLines,
totalBytes: input.totalBytes,
outputLines: params.outputLines,
outputBytes: params.outputBytes ?? utf8ByteLength(params.content),
lastLinePartial: params.lastLinePartial ?? false,
firstLineExceedsLimit: params.firstLineExceedsLimit ?? false,
maxLines: input.maxLines,
maxBytes: input.maxBytes,
};
}
/**
* Keep the beginning of content while respecting independent line and byte ceilings.
*
* Head truncation preserves complete lines; a first line that exceeds the byte
* ceiling produces empty output and sets firstLineExceedsLimit.
*/
export function truncateHead(content: string, options: TruncationOptions = {}): TruncationResult {
const input = resolveTruncationInput(content, options);
if (input.totalLines <= input.maxLines && input.totalBytes <= input.maxBytes) {
return buildTruncationResult(input, {
content,
truncated: false,
truncatedBy: null,
outputLines: input.totalLines,
outputBytes: input.totalBytes,
});
}
const firstLineBytes = utf8ByteLength(input.lines[0]);
if (firstLineBytes > input.maxBytes) {
return buildTruncationResult(input, {
content: "",
truncated: true,
truncatedBy: "bytes",
outputLines: 0,
outputBytes: 0,
firstLineExceedsLimit: true,
});
}
const outputLinesArr: string[] = [];
let outputBytesCount = 0;
let truncatedBy: "lines" | "bytes" = input.totalLines > input.maxLines ? "lines" : "bytes";
for (let i = 0; i < input.lines.length && i < input.maxLines; i++) {
const line = input.lines[i];
const lineBytes = utf8ByteLength(line) + (i > 0 ? 1 : 0); // +1 for newline
if (outputBytesCount + lineBytes > input.maxBytes) {
truncatedBy = "bytes";
break;
}
outputLinesArr.push(line);
outputBytesCount += lineBytes;
}
if (
input.totalLines > input.maxLines &&
outputLinesArr.length >= input.maxLines &&
outputBytesCount <= input.maxBytes
) {
truncatedBy = "lines";
}
const outputContent = outputLinesArr.join("\n");
return buildTruncationResult(input, {
content: outputContent,
truncated: true,
truncatedBy,
outputLines: outputLinesArr.length,
});
}
/**
* Keep the end of content while respecting independent line and byte ceilings.
*
* Tail truncation preserves recent output for command errors and may keep a
* partial first line when one final line alone exceeds the byte ceiling.
*/
export function truncateTail(content: string, options: TruncationOptions = {}): TruncationResult {
const input = resolveTruncationInput(content, options);
if (input.totalLines <= input.maxLines && input.totalBytes <= input.maxBytes) {
return buildTruncationResult(input, {
content,
truncated: false,
truncatedBy: null,
outputLines: input.totalLines,
outputBytes: input.totalBytes,
});
}
const outputLinesArr: string[] = [];
let outputBytesCount = 0;
let truncatedBy: "lines" | "bytes" = input.totalLines > input.maxLines ? "lines" : "bytes";
let lastLinePartial = false;
for (let i = input.lines.length - 1; i >= 0 && outputLinesArr.length < input.maxLines; i--) {
const line = input.lines[i];
const lineBytes = utf8ByteLength(line) + (outputLinesArr.length > 0 ? 1 : 0); // +1 for newline
if (outputBytesCount + lineBytes > input.maxBytes) {
truncatedBy = "bytes";
// Edge case: if we haven't added ANY lines yet and this line exceeds maxBytes,
// take the end of the line (partial)
if (outputLinesArr.length === 0) {
const truncatedLine = truncateStringToBytesFromEnd(line, input.maxBytes);
outputLinesArr.unshift(truncatedLine);
outputBytesCount = utf8ByteLength(truncatedLine);
lastLinePartial = true;
}
break;
}
outputLinesArr.unshift(line);
outputBytesCount += lineBytes;
}
if (
input.totalLines > input.maxLines &&
outputLinesArr.length >= input.maxLines &&
outputBytesCount <= input.maxBytes
) {
truncatedBy = "lines";
}
const outputContent = outputLinesArr.join("\n");
return buildTruncationResult(input, {
content: outputContent,
truncated: true,
truncatedBy,
outputLines: outputLinesArr.length,
lastLinePartial,
});
}
/**
* Truncate a string to fit within a byte limit (from the end).
* Handles multi-byte UTF-8 characters correctly.
*/
function truncateStringToBytesFromEnd(str: string, maxBytes: number): string {
if (maxBytes <= 0) {
return "";
}
let outputBytes = 0;
let start = str.length;
let needsReplacement = false;
for (let i = str.length; i > 0; ) {
let characterStart = i - 1;
const code = str.charCodeAt(characterStart);
let characterBytes: number;
let unpairedSurrogate = false;
if (code >= 0xdc00 && code <= 0xdfff && characterStart > 0) {
const previous = str.charCodeAt(characterStart - 1);
if (previous >= 0xd800 && previous <= 0xdbff) {
characterStart--;
characterBytes = 4;
} else {
characterBytes = 3;
unpairedSurrogate = true;
}
} else if (code >= 0xd800 && code <= 0xdfff) {
characterBytes = 3;
unpairedSurrogate = true;
} else {
characterBytes = code <= 0x7f ? 1 : code <= 0x7ff ? 2 : 3;
}
if (outputBytes + characterBytes > maxBytes) {
break;
}
outputBytes += characterBytes;
start = characterStart;
needsReplacement ||= unpairedSurrogate;
i = characterStart;
}
const output = str.slice(start);
return needsReplacement ? replaceUnpairedSurrogates(output) : output;
}
/**
* Trim a single display line and mark it with the grep-style truncation suffix.
*
* The cut point is backed off by one code unit when it would otherwise split a
* surrogate pair, so emoji / CJK Extension B characters crossing the boundary
* stay intact instead of rendering as replacement characters.
*/
export function truncateLine(
line: string,
maxChars: number = GREP_MAX_LINE_LENGTH,
): { text: string; wasTruncated: boolean } {
if (line.length <= maxChars) {
return { text: line, wasTruncated: false };
}
let cut = maxChars;
// Avoid splitting a surrogate pair at the truncation boundary.
if (cut < line.length) {
const lastCode = line.charCodeAt(cut - 1);
if (lastCode >= 0xd800 && lastCode <= 0xdbff) {
const nextCode = line.charCodeAt(cut);
if (nextCode >= 0xdc00 && nextCode <= 0xdfff) {
cut -= 1;
}
}
}
return { text: `${line.slice(0, cut)}... [truncated]`, wasTruncated: true };
}

View File

@@ -0,0 +1,49 @@
// Public agent-core package surface: agent loop, harness, session storage,
// compaction, execution envs, and utility helpers.
export * from "./agent.js";
export * from "./agent-loop.js";
export * from "./node.js";
export * from "./runtime-deps.js";
export * from "./types.js";
export * from "./validation.js";
export * from "./harness/agent-harness.js";
export * from "./harness/env/kill-tree.js";
export * from "./harness/messages.js";
export * from "./harness/prompt-template-arguments.js";
export * from "./harness/skills.js";
export * from "./harness/types.js";
export * from "./harness/session/jsonl-storage.js";
export * from "./harness/session/memory-storage.js";
export * from "./harness/session/session.js";
export { uuidv7 } from "./harness/session/uuid.js";
export {
type BranchPreparation,
type BranchPathEntry,
type BranchSummaryDetails,
type CollectBranchPathEntriesResult,
type CollectEntriesResult,
collectEntriesForBranchSummary,
collectEntriesForBranchSummaryFromBranches,
generateBranchSummary,
prepareBranchEntries,
} from "./harness/compaction/branch-summarization.js";
export {
calculateContextTokens,
compact,
DEFAULT_COMPACTION_SETTINGS,
estimateContextTokens,
estimateTokens,
findCutPoint,
findTurnStartIndex,
generateSummary,
getLastAssistantUsage,
prepareCompaction,
serializeConversation,
shouldCompact,
type CompactionDetails,
type CompactionPreparation,
type CompactionResult,
type CompactionSettings,
type ContextUsageEstimate,
} from "./harness/compaction/compaction.js";
export * from "./harness/utils/truncate.js";

View File

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

View File

@@ -0,0 +1,3 @@
// Node-specific agent-core entrypoint with the default Node execution env.
export { NodeExecutionEnv } from "./harness/env/nodejs.js";
export * from "./index.js";

View File

@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import type { Model } from "../../llm-core/src/index.js";
import { resolveAgentReasoningOption } from "./reasoning.js";
function makeModel(
thinkingLevelMap?: Model["thinkingLevelMap"],
overrides: Partial<Model> = {},
): Model {
return {
id: "test-model",
name: "Test Model",
api: "anthropic-messages",
provider: "anthropic",
baseUrl: "https://example.test",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1000,
maxTokens: 100,
thinkingLevelMap,
...overrides,
};
}
describe("resolveAgentReasoningOption", () => {
it("uses a model's enabled fallback for explicit off", () => {
expect(resolveAgentReasoningOption(makeModel({ off: "low" }), "off")).toBe("low");
});
it.each([undefined, null, "none"])("disables reasoning when off maps to %s", (offFallback) => {
expect(resolveAgentReasoningOption(makeModel({ off: offFallback }), "off")).toBeUndefined();
});
it("preserves enabled thinking levels", () => {
expect(resolveAgentReasoningOption(makeModel({ off: "low" }), "high")).toBe("high");
});
it.each(["anthropic-messages", "bedrock-converse-stream"] as const)(
"maps explicit off to low for canonical Fable aliases on %s",
(api) => {
expect(
resolveAgentReasoningOption(
makeModel(undefined, {
id: "production-deployment",
api,
params: { canonicalModelId: "claude-fable-5" },
}),
"off",
),
).toBe("low");
},
);
});

View File

@@ -0,0 +1,37 @@
import {
resolveClaudeFable5ModelIdentity,
type Model,
type SimpleStreamOptions,
} from "../../llm-core/src/index.js";
import type { ThinkingLevel } from "./types.js";
type EnabledThinkingLevel = NonNullable<SimpleStreamOptions["reasoning"]>;
const ENABLED_THINKING_LEVELS = new Set<EnabledThinkingLevel>([
"minimal",
"low",
"medium",
"high",
"xhigh",
"max",
]);
function isEnabledThinkingLevel(value: unknown): value is EnabledThinkingLevel {
return ENABLED_THINKING_LEVELS.has(value as EnabledThinkingLevel);
}
export function resolveAgentReasoningOption(
model: Model,
thinkingLevel: ThinkingLevel,
): SimpleStreamOptions["reasoning"] {
if (thinkingLevel !== "off") {
return thinkingLevel;
}
const offFallback =
model.thinkingLevelMap?.off ??
((model.api === "anthropic-messages" || model.api === "bedrock-converse-stream") &&
resolveClaudeFable5ModelIdentity(model)
? "low"
: undefined);
return isEnabledThinkingLevel(offFallback) ? offFallback : undefined;
}

View File

@@ -0,0 +1,45 @@
// Agent Core module implements runtime deps behavior.
import type { CompleteSimpleFn, StreamFn } from "../../llm-core/src/index.js";
/** Runtime functions injected by host packages so agent-core stays provider-agnostic. */
export interface AgentCoreRuntimeDeps {
/** Streaming completion implementation used for normal agent turns. */
streamSimple: StreamFn;
/** Non-streaming completion implementation used by summarization helpers. */
completeSimple: CompleteSimpleFn;
}
/** Runtime dependency subset required by streaming agent loops. */
export type AgentCoreStreamRuntimeDeps = Pick<AgentCoreRuntimeDeps, "streamSimple">;
/** Runtime dependency subset required by summarization helpers. */
export type AgentCoreCompletionRuntimeDeps = Pick<AgentCoreRuntimeDeps, "completeSimple">;
function missingRuntimeDep(name: keyof AgentCoreRuntimeDeps): Error {
return new Error(
`@openclaw/agent-core runtime dependency "${name}" is not configured. Pass an AgentCoreRuntimeDeps instance or a streamFn explicitly.`,
);
}
/** Resolve the stream function, preferring an explicit override over injected runtime deps. */
export function resolveAgentCoreStreamFn(
runtime: AgentCoreStreamRuntimeDeps | undefined,
streamFn?: StreamFn,
): StreamFn {
if (streamFn) {
return streamFn;
}
if (runtime?.streamSimple) {
return runtime.streamSimple;
}
throw missingRuntimeDep("streamSimple");
}
/** Resolve the completion function used by non-streaming helper flows. */
export function resolveAgentCoreCompleteFn(
runtime: AgentCoreCompletionRuntimeDeps | undefined,
): CompleteSimpleFn {
if (runtime?.completeSimple) {
return runtime.completeSimple;
}
throw missingRuntimeDep("completeSimple");
}

View File

@@ -0,0 +1,543 @@
// Agent Core type module defines shared TypeScript contracts.
import type { Static, TSchema } from "typebox";
import type {
AssistantMessage,
AssistantMessageEvent,
ImageContent,
Message,
Model,
SimpleStreamOptions,
StreamFn as LlmStreamFn,
TextContent,
Tool,
ToolResultMessage,
} from "../../llm-core/src/index.js";
/**
* Stream function used by the agent loop.
*
* Contract:
* - Must not throw or return a rejected promise for request/model/runtime failures.
* - Must return an AssistantMessageEventStream.
* - Failures must be encoded in the returned stream via protocol events and a
* final AssistantMessage with stopReason "error" or "aborted" and errorMessage.
*/
export type StreamFn = LlmStreamFn;
/**
* Configuration for how tool calls from a single assistant message are executed.
*
* - "sequential": each tool call is prepared, executed, and finalized before the next one starts.
* - "parallel": tool calls are prepared sequentially, then allowed tools execute concurrently.
* `tool_execution_end` is emitted in tool completion order after each tool is finalized,
* while tool-result message artifacts are emitted later in assistant source order.
*/
export type ToolExecutionMode = "sequential" | "parallel";
/**
* Controls how many queued user messages are injected when the agent loop reaches a queue drain point.
*
* - "all": drain and inject every queued message at that point.
* - "one-at-a-time": drain and inject only the oldest queued message, leaving the rest queued for later drain points.
*/
export type QueueMode = "all" | "one-at-a-time";
/** A single tool call content block emitted by an assistant message. */
export type AgentToolCall = Extract<AssistantMessage["content"][number], { type: "toolCall" }>;
/**
* Result returned from `beforeToolCall`.
*
* Returning `{ block: true }` prevents the tool from executing. The loop emits an error tool result instead.
* `reason` becomes the text shown in that error result. If omitted, a default blocked message is used.
*/
export interface BeforeToolCallResult {
block?: boolean;
reason?: string;
}
export interface DeferredToolCallContext {
/** The assistant message that requested the deferred tool call. */
assistantMessage: AssistantMessage;
/** The raw tool call block whose authorized tool definition is deferred. */
toolCall: AgentToolCall;
/** Current agent context before the deferred tool is hydrated. */
context: AgentContext;
}
/**
* Partial override returned from `afterToolCall`.
*
* Merge semantics are field-by-field:
* - `content`: if provided, replaces the tool result content array in full
* - `details`: if provided, replaces the tool result details value in full
* - `isError`: if provided, replaces the tool result error flag
* - `terminate`: if provided, replaces the early-termination hint
*
* Omitted fields keep the original executed tool result values.
* There is no deep merge for `content` or `details`.
*/
export interface AfterToolCallResult {
content?: (TextContent | ImageContent)[];
details?: unknown;
isError?: boolean;
/**
* Hint that the agent should stop after the current tool batch.
* Early termination only happens when every finalized tool result in the batch sets this to true.
*/
terminate?: boolean;
}
/** Context passed to `beforeToolCall`. */
export interface BeforeToolCallContext {
/** The assistant message that requested the tool call. */
assistantMessage: AssistantMessage;
/** The raw tool call block from `assistantMessage.content`. */
toolCall: AgentToolCall;
/** Validated tool arguments for the target tool schema. */
args: unknown;
/** Current agent context at the time the tool call is prepared. */
context: AgentContext;
}
/** Context passed to `afterToolCall`. */
export interface AfterToolCallContext {
/** The assistant message that requested the tool call. */
assistantMessage: AssistantMessage;
/** The raw tool call block from `assistantMessage.content`. */
toolCall: AgentToolCall;
/** Validated tool arguments for the target tool schema. */
args: unknown;
/** The executed tool result before unknown `afterToolCall` overrides are applied. */
result: AgentToolResult<unknown>;
/** Whether the executed tool result is currently treated as an error. */
isError: boolean;
/** Current agent context at the time the tool call is finalized. */
context: AgentContext;
}
/** Context passed to `shouldStopAfterTurn`. */
export interface ShouldStopAfterTurnContext {
/** The assistant message that completed the turn. */
message: AssistantMessage;
/** Tool result messages passed to the preceding `turn_end` event. */
toolResults: ToolResultMessage[];
/** Current agent context after the turn's assistant message and tool results have been appended. */
context: AgentContext;
/** Messages that this loop invocation will return if it exits at this point. Prompt runs include the initial prompt messages; continuation runs do not include pre-existing context messages. */
newMessages: AgentMessage[];
}
/** Replacement runtime state used by the agent loop before starting another provider request. */
export interface AgentLoopTurnUpdate {
/** Context for the next provider request. */
context?: AgentContext;
/** Model for the next provider request. */
model?: Model;
/** Thinking level for the next provider request. */
thinkingLevel?: ThinkingLevel;
}
export interface PrepareNextTurnContext extends ShouldStopAfterTurnContext {}
export interface AgentLoopConfig extends SimpleStreamOptions {
model: Model;
/** Logical thinking level retained across model changes before provider mapping. */
thinkingLevel?: ThinkingLevel;
/**
* Converts AgentMessage[] to LLM-compatible Message[] before each LLM call.
*
* Each AgentMessage must be converted to a UserMessage, AssistantMessage, or ToolResultMessage
* that the LLM can understand. AgentMessages that cannot be converted (e.g., UI-only notifications,
* status messages) should be filtered out.
*
* Contract: must not throw or reject. Return a safe fallback value instead.
* Throwing interrupts the low-level agent loop without producing a normal event sequence.
*
* @example
* ```typescript
* convertToLlm: (messages) => messages.flatMap(m => {
* if (m.role === "custom") {
* // Convert custom message to user message
* return [{ role: "user", content: m.content, timestamp: m.timestamp }];
* }
* if (m.role === "notification") {
* // Filter out UI-only messages
* return [];
* }
* // Pass through standard LLM messages
* return [m];
* })
* ```
*/
convertToLlm: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
/**
* Optional transform applied to the context before `convertToLlm`.
*
* Use this for operations that work at the AgentMessage level:
* - Context window management (pruning old messages)
* - Injecting context from external sources
*
* Contract: must not throw or reject. Return the original messages or another
* safe fallback value instead.
*
* @example
* ```typescript
* transformContext: async (messages) => {
* if (estimateTokens(messages) > MAX_TOKENS) {
* return pruneOldMessages(messages);
* }
* return messages;
* }
* ```
*/
transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise<AgentMessage[]>;
/**
* Resolves an API key dynamically for each LLM call.
*
* Useful for short-lived OAuth tokens (e.g., GitHub Copilot) that may expire
* during long-running tool execution phases.
*
* Contract: must not throw or reject. Return undefined when no key is available.
*/
getApiKey?: (provider: string) => Promise<string | undefined> | string | undefined;
/**
* Called after each turn fully completes and `turn_end` has been emitted.
*
* If it returns true, the loop emits `agent_end` and exits before polling steering or follow-up queues,
* without starting another LLM call. The current assistant response and any tool executions finish normally.
*
* Use this to request a graceful stop after the current turn, e.g. before context gets too full.
*
* Contract: must not throw or reject. Throwing interrupts the low-level agent loop without producing a normal event sequence.
*/
shouldStopAfterTurn?: (context: ShouldStopAfterTurnContext) => boolean | Promise<boolean>;
/**
* Called after `turn_end` and before the loop decides whether another provider request should start.
* Return replacement context/model/thinking state to affect the next turn in this run.
* Return undefined to keep using the current context/config.
*/
prepareNextTurn?: (
context: PrepareNextTurnContext,
) => AgentLoopTurnUpdate | undefined | Promise<AgentLoopTurnUpdate | undefined>;
/**
* Returns steering messages to inject into the conversation mid-run.
*
* Called after the current assistant turn finishes executing its tool calls, unless `shouldStopAfterTurn` exits first.
* If messages are returned, they are added to the context before the next LLM call.
* Tool calls from the current assistant message are not skipped.
*
* Use this for "steering" the agent while it's working.
*
* Contract: must not throw or reject. Return [] when no steering messages are available.
*/
getSteeringMessages?: () => Promise<AgentMessage[]>;
/**
* Returns follow-up messages to process after the agent would otherwise stop.
*
* Called when the agent has no more tool calls and no steering messages.
* If messages are returned, they're added to the context and the agent
* continues with another turn.
*
* Use this for follow-up messages that should wait until the agent finishes.
*
* Contract: must not throw or reject. Return [] when no follow-up messages are available.
*/
getFollowUpMessages?: () => Promise<AgentMessage[]>;
/**
* Tool execution mode.
* - "sequential": execute tool calls one by one
* - "parallel": preflight tool calls sequentially, then execute allowed tools concurrently;
* emit `tool_execution_end` in tool completion order after each tool is finalized,
* then emit tool-result message artifacts later in assistant source order
*
* Default: "parallel"
*/
toolExecution?: ToolExecutionMode;
/**
* Called before a tool is executed, after arguments have been validated.
*
* Return `{ block: true }` to prevent execution. The loop emits an error tool result instead.
* The hook receives the agent abort signal and is responsible for honoring it.
*/
beforeToolCall?: (
context: BeforeToolCallContext,
signal?: AbortSignal,
) => Promise<BeforeToolCallResult | undefined>;
/**
* Hydrates an already-authorized tool that was deferred out of the current
* provider-visible tool set. Return undefined for every other unknown name so
* the loop keeps the normal "Tool <name> not found" result. Thrown or rejected
* failures become error tool results for the requested call.
*/
resolveDeferredTool?: (
context: DeferredToolCallContext,
signal?: AbortSignal,
) => Promise<AgentTool | undefined> | AgentTool | undefined;
/**
* Called after a tool finishes executing, before `tool_execution_end` and tool-result message events are emitted.
*
* Return an `AfterToolCallResult` to override parts of the executed tool result:
* - `content` replaces the full content array
* - `details` replaces the full details payload
* - `isError` replaces the error flag
* - `terminate` replaces the early-termination hint
*
* Any omitted fields keep their original values. No deep merge is performed.
* The hook receives the agent abort signal and is responsible for honoring it.
*/
afterToolCall?: (
context: AfterToolCallContext,
signal?: AbortSignal,
) => Promise<AfterToolCallResult | undefined>;
}
/**
* Thinking/reasoning level for models that support it.
* Note: "xhigh" is only supported by selected model families. Use model thinking-level metadata
* from openclaw/plugin-sdk/llm to detect support for a concrete model.
*/
export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
export interface BashExecutionMessage {
/** Harness role for shell command transcripts. */
role: "bashExecution";
/** Command line that was executed. */
command: string;
/** Captured command output, usually already truncated for context. */
output: string;
/** Process exit code when the command reached process exit. */
exitCode: number | undefined;
/** True when the command was interrupted before normal completion. */
cancelled: boolean;
/** True when output was shortened for transcript/context storage. */
truncated: boolean;
/** Optional path containing the complete output when truncation occurred. */
fullOutputPath?: string;
/** Millisecond timestamp for transcript ordering. */
timestamp: number;
/** Exclude this command transcript from model context while keeping it in session history. */
excludeFromContext?: boolean;
}
export interface CustomMessage<T = unknown> {
/** Harness role for application-defined transcript content. */
role: "custom";
/** Application-defined discriminator for rendering or handling this message. */
customType: string;
/** Content replayed into model context when this message is included. */
content: string | (TextContent | ImageContent)[];
/** Whether UI surfaces should display this message. */
display: boolean;
/** Optional application-specific metadata. */
details?: T;
/** Millisecond timestamp for transcript ordering. */
timestamp: number;
}
export interface BranchSummaryMessage {
/** Harness role for summaries produced when returning from another branch. */
role: "branchSummary";
/** Summary text inserted back into model context. */
summary: string;
/** Entry id of the branch root or source leaf being summarized. */
fromId: string;
/** Millisecond timestamp for transcript ordering. */
timestamp: number;
}
export interface CompactionSummaryMessage {
/** Harness role for summaries that replace compacted transcript history. */
role: "compactionSummary";
/** Summary text inserted back into model context. */
summary: string;
/** Estimated context tokens before compaction. */
tokensBefore: number;
/** Timestamp may be numeric in memory or string when loaded from older persisted rows. */
timestamp: number | string;
/** Optional estimated context tokens after compaction. */
tokensAfter?: number;
/** Optional first retained entry id from the compaction range. */
firstKeptEntryId?: string;
/** Optional implementation-specific compaction metadata. */
details?: unknown;
}
/**
* Extensible interface for custom app and harness messages.
* Apps can extend via declaration merging.
*/
export interface CustomAgentMessages {
bashExecution: BashExecutionMessage;
custom: CustomMessage;
branchSummary: BranchSummaryMessage;
compactionSummary: CompactionSummaryMessage;
}
/**
* AgentMessage: Union of LLM messages + custom messages.
* This abstraction allows apps to add custom message types while maintaining
* type safety and compatibility with the base LLM messages.
*/
export type AgentMessage = Message | CustomAgentMessages[keyof CustomAgentMessages];
/**
* Public agent state.
*
* `tools` and `messages` use accessor properties so implementations can copy
* assigned arrays before storing them.
*/
export interface AgentState {
/** System prompt sent with each model request. */
systemPrompt: string;
/** Active model used for future turns. */
model: Model;
/** Requested reasoning level for future turns. */
thinkingLevel: ThinkingLevel;
/** Available tools. Assigning a new array copies the top-level array. */
set tools(tools: AgentTool[]);
get tools(): AgentTool[];
/** Conversation transcript. Assigning a new array copies the top-level array. */
set messages(messages: AgentMessage[]);
get messages(): AgentMessage[];
/**
* True while the agent is processing a prompt or continuation.
*
* This remains true until awaited `agent_end` listeners settle.
*/
readonly isStreaming: boolean;
/** Partial assistant message for the current streamed response, if any. */
readonly streamingMessage?: AgentMessage;
/** Tool call ids currently executing. */
readonly pendingToolCalls: ReadonlySet<string>;
/** Error message from the most recent failed or aborted assistant turn, if any. */
readonly errorMessage?: string;
}
/** Channel-safe progress text emitted by a running tool. */
export interface AgentToolProgress {
/** Public text suitable for user-facing progress surfaces. */
text: string;
/** Tool progress is rendered by channel progress UIs. */
visibility: "channel";
/** Progress text must not contain secrets, private args, or fetched content. */
privacy: "public";
/** Optional stable id for progress line replacement. */
id?: string;
}
/** Final or partial result produced by a tool. */
export interface AgentToolResult<T> {
/** Text or image content returned to the model. */
content: (TextContent | ImageContent)[];
/** Arbitrary structured details for logs or UI rendering. */
details: T;
/** Optional public progress hint for partial tool updates; never model content. */
progress?: AgentToolProgress;
/**
* Hint that the agent should stop after the current tool batch.
* Early termination only happens when every finalized tool result in the batch sets this to true.
*/
terminate?: boolean;
}
/** Callback used by tools to stream partial execution updates. */
export type AgentToolUpdateCallback<T = unknown> = (partialResult: AgentToolResult<T>) => void;
/** Tool definition used by the agent runtime. */
export interface AgentTool<
TParameters extends TSchema = TSchema,
TDetails = unknown,
> extends Tool<TParameters> {
/** Human-readable label for UI display. */
label: string;
/** Preserve lifecycle telemetry without rendering transient channel progress. */
hideFromChannelProgress?: boolean;
/**
* Optional compatibility shim for raw tool-call arguments before schema validation.
* Must return an object that matches `TParameters`.
*/
prepareArguments?: (args: unknown) => Static<TParameters>;
/** Execute the tool call. Throw on failure instead of encoding errors in `content`. */
execute: (
toolCallId: string,
params: Static<TParameters>,
signal?: AbortSignal,
onUpdate?: AgentToolUpdateCallback<TDetails>,
) => Promise<AgentToolResult<TDetails>>;
/**
* Per-tool execution mode override.
* - "sequential": this tool must execute one at a time with other tool calls.
* - "parallel": this tool can execute concurrently with other tool calls.
*
* If omitted, the default execution mode applies.
*/
executionMode?: ToolExecutionMode;
}
/** Context snapshot passed into the low-level agent loop. */
export interface AgentContext {
/** System prompt included with the request. */
systemPrompt: string;
/** Transcript visible to the model. */
messages: AgentMessage[];
/** Tools available for this run. */
tools?: AgentTool[];
}
/**
* Events emitted by the Agent for UI updates.
*
* `agent_end` is the last event emitted for a run, but awaited `Agent.subscribe()`
* listeners for that event are still part of run settlement. The agent becomes
* idle only after those listeners finish.
*/
export type AgentEvent =
// Agent lifecycle
| { type: "agent_start" }
| { type: "agent_end"; messages: AgentMessage[] }
// Turn lifecycle - a turn is one assistant response + any tool calls/results
| { type: "turn_start" }
| { type: "turn_end"; message: AgentMessage; toolResults: ToolResultMessage[] }
// Message lifecycle - emitted for user, assistant, and toolResult messages
| { type: "message_start"; message: AgentMessage }
// Only emitted for assistant messages during streaming
| { type: "message_update"; message: AgentMessage; assistantMessageEvent: AssistantMessageEvent }
| { type: "message_end"; message: AgentMessage }
// Tool execution lifecycle
| {
type: "tool_execution_start";
toolCallId: string;
toolName: string;
args: unknown;
hideFromChannelProgress?: boolean;
}
| {
type: "tool_execution_update";
toolCallId: string;
toolName: string;
args: unknown;
partialResult: unknown;
hideFromChannelProgress?: boolean;
}
| {
type: "tool_execution_end";
toolCallId: string;
toolName: string;
result: unknown;
isError: boolean;
/** False when resolution, argument preparation, validation, or policy blocked execution. */
executionStarted?: boolean;
hideFromChannelProgress?: boolean;
};

View File

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

21
packages/ai/LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 OpenClaw Foundation
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

28
packages/ai/README.md Normal file
View File

@@ -0,0 +1,28 @@
# `@openclaw/ai`
Reusable model API contracts, provider adapters, and streaming primitives from
OpenClaw. The package supports isolated runtime instances; importing it does not
register providers globally.
```ts
import { createLlmRuntime } from "@openclaw/ai";
import { registerBuiltInApiProviders } from "@openclaw/ai/providers";
const runtime = createLlmRuntime();
registerBuiltInApiProviders(runtime.registry);
```
Provider-neutral contracts, validation, diagnostics, and event streams are
available from the package root and focused subpaths such as
`@openclaw/ai/event-stream` and `@openclaw/ai/validation`. No second OpenClaw
runtime package is required.
Provider ids, credentials, model catalogs, retries, and failover remain
application concerns. OpenClaw supplies those policies around this package.
Host policy (request fetch guarding, secret redaction, strict-tool defaults,
diagnostics logging) can be injected with `configureAiTransportHost`; the
defaults are inert.
`@openclaw/ai/internal/*` subpaths exist for the OpenClaw application itself.
They carry no semver guarantee and can change or disappear in any release; do
not depend on them outside OpenClaw.

645
packages/ai/npm-shrinkwrap.json generated Normal file
View File

@@ -0,0 +1,645 @@
{
"name": "@openclaw/ai",
"version": "2026.6.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openclaw/ai",
"version": "2026.6.11",
"license": "MIT",
"dependencies": {
"@anthropic-ai/sdk": "0.109.1",
"@google/genai": "2.10.0",
"@mistralai/mistralai": "2.4.0",
"openai": "6.45.0",
"partial-json": "0.1.7",
"typebox": "1.3.3"
},
"engines": {
"node": ">=22.19.0"
}
},
"node_modules/@anthropic-ai/sdk": {
"version": "0.109.1",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.109.1.tgz",
"integrity": "sha512-q9OnEKLr5H9nxSuXdgDgJhxfYMiE+AaUEBze2Gk91UcaaLnsN+Lx5fbCYywiqurU/APLdwv23x03Wm6WN3EBsg==",
"license": "MIT",
"dependencies": {
"json-schema-to-ts": "^3.1.1",
"standardwebhooks": "^1.0.0"
},
"bin": {
"anthropic-ai-sdk": "bin/cli"
},
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
},
"peerDependenciesMeta": {
"zod": {
"optional": true
}
}
},
"node_modules/@babel/runtime": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@google/genai": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@google/genai/-/genai-2.10.0.tgz",
"integrity": "sha512-e4cFxj3tiuMtsgOT4G9c1hXyGJhg7/Buj7VVeBacRY3fRtkRZZ59Q3nuVp2xbq8BGQXLXCDB253qMhklMOeUDg==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"google-auth-library": "^10.3.0",
"p-retry": "^4.6.2",
"protobufjs": "^7.5.4",
"ws": "^8.18.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"@modelcontextprotocol/sdk": "^1.25.2"
},
"peerDependenciesMeta": {
"@modelcontextprotocol/sdk": {
"optional": true
}
}
},
"node_modules/@mistralai/mistralai": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.4.0.tgz",
"integrity": "sha512-t6hCx242MTGolB76CI+17jDtPIe/bzLsMdUTMMoMn9Qo1h02N2G5jQYHmKDGU3X//OgR2wvngTD7tO6tPp5poQ==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/semantic-conventions": "^1.40.0",
"ws": "^8.18.0",
"zod": "^3.25.0 || ^4.0.0",
"zod-to-json-schema": "^3.25.0"
},
"peerDependencies": {
"@opentelemetry/api": "^1.9.0"
},
"peerDependenciesMeta": {
"@opentelemetry/api": {
"optional": true
}
}
},
"node_modules/@opentelemetry/semantic-conventions": {
"version": "1.41.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz",
"integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==",
"license": "Apache-2.0",
"engines": {
"node": ">=14"
}
},
"node_modules/@protobufjs/aspromise": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
"integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/base64": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
"integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/codegen": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz",
"integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/eventemitter": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz",
"integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/fetch": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz",
"integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==",
"license": "BSD-3-Clause",
"dependencies": {
"@protobufjs/aspromise": "^1.1.1"
}
},
"node_modules/@protobufjs/float": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
"integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/inquire": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz",
"integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/path": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
"integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/pool": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
"integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/utf8": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz",
"integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==",
"license": "BSD-3-Clause"
},
"node_modules/@stablelib/base64": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
"license": "MIT"
},
"node_modules/@types/node": {
"version": "26.1.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.0.tgz",
"integrity": "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==",
"license": "MIT",
"dependencies": {
"undici-types": "~8.3.0"
}
},
"node_modules/@types/retry": {
"version": "0.12.5",
"resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.5.tgz",
"integrity": "sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw==",
"license": "MIT"
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/bignumber.js": {
"version": "9.3.1",
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
"integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/data-uri-to-buffer": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
"integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/extend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT"
},
"node_modules/fast-sha256": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
"license": "Unlicense"
},
"node_modules/fetch-blob": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
"integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "paypal",
"url": "https://paypal.me/jimmywarting"
}
],
"license": "MIT",
"dependencies": {
"node-domexception": "^1.0.0",
"web-streams-polyfill": "^3.0.3"
},
"engines": {
"node": "^12.20 || >= 14.13"
}
},
"node_modules/formdata-polyfill": {
"version": "4.0.10",
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
"integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
"license": "MIT",
"dependencies": {
"fetch-blob": "^3.1.2"
},
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/gaxios": {
"version": "7.1.5",
"resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.5.tgz",
"integrity": "sha512-5FZy72Rh8LhtjmvDrKkI+lVhrsQrVKVsItxMoDm5mNQE+xR0WVIIs+jzPSJgBvKVsLi24fZhXJIsNI0bihDzFg==",
"license": "Apache-2.0",
"dependencies": {
"extend": "^3.0.2",
"https-proxy-agent": "^7.0.1",
"node-fetch": "^3.3.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/gcp-metadata": {
"version": "8.1.2",
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz",
"integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==",
"license": "Apache-2.0",
"dependencies": {
"gaxios": "^7.0.0",
"google-logging-utils": "^1.0.0",
"json-bigint": "^1.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/google-auth-library": {
"version": "10.9.0",
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.9.0.tgz",
"integrity": "sha512-xtvUqvINPhTaBm7nXqlYPcrMHJPm1lCNdSovxnKKhTm+4JsvQ+KGVYJViLoH9Yxu8w+T0Qv5HubzYT9BLrppJg==",
"license": "Apache-2.0",
"dependencies": {
"base64-js": "^1.3.0",
"ecdsa-sig-formatter": "^1.0.11",
"gaxios": "^7.1.4",
"gcp-metadata": "8.1.2",
"google-logging-utils": "1.1.3",
"jws": "^4.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/google-logging-utils": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz",
"integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==",
"license": "Apache-2.0",
"engines": {
"node": ">=14"
}
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/json-bigint": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz",
"integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==",
"license": "MIT",
"dependencies": {
"bignumber.js": "^9.0.0"
}
},
"node_modules/json-schema-to-ts": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.18.3",
"ts-algebra": "^2.0.0"
},
"engines": {
"node": ">=16"
}
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/long": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
"license": "Apache-2.0"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/node-domexception": {
"name": "@nolyfill/domexception",
"version": "1.0.28",
"resolved": "https://registry.npmjs.org/@nolyfill/domexception/-/domexception-1.0.28.tgz",
"integrity": "sha512-tlc/FcYIv5i8RYsl2iDil4A0gOihaas1R5jPcIC4Zw3GhjKsVilw90aHcVlhZPTBLGBzd379S+VcnsDjd9ChiA==",
"license": "MIT",
"engines": {
"node": ">=12.4.0"
}
},
"node_modules/node-fetch": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
"license": "MIT",
"dependencies": {
"data-uri-to-buffer": "^4.0.0",
"fetch-blob": "^3.1.4",
"formdata-polyfill": "^4.0.10"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/node-fetch"
}
},
"node_modules/openai": {
"version": "6.45.0",
"resolved": "https://registry.npmjs.org/openai/-/openai-6.45.0.tgz",
"integrity": "sha512-5DQVNErssk0afNpTTHUm/qZPU4iKR9OYdNid8Ib4puq4gHNNvGWZht2zY4h9a8JMF949Ik6m8gQutllVPbjdnw==",
"license": "Apache-2.0",
"peerDependencies": {
"@aws-sdk/credential-provider-node": ">=3.972.0 <4",
"@smithy/hash-node": ">=4.3.0 <5",
"@smithy/signature-v4": ">=5.4.0 <6",
"ws": "^8.18.0",
"zod": "^3.25 || ^4.0"
},
"peerDependenciesMeta": {
"@aws-sdk/credential-provider-node": {
"optional": true
},
"@smithy/hash-node": {
"optional": true
},
"@smithy/signature-v4": {
"optional": true
},
"ws": {
"optional": true
},
"zod": {
"optional": true
}
}
},
"node_modules/p-retry": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz",
"integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==",
"license": "MIT",
"dependencies": {
"@types/retry": "0.12.0",
"retry": "^0.13.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/partial-json": {
"version": "0.1.7",
"resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz",
"integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==",
"license": "MIT"
},
"node_modules/protobufjs": {
"version": "7.6.3",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.3.tgz",
"integrity": "sha512-+k0vdJKNdW+Vu+dYe8tZA/VvQb6XKNWexC6URwBFXxNnjLJz9nQJCemGyNgRAWD+B7+nGNc9qMPGwcD7s4nzUw==",
"hasInstallScript": true,
"license": "BSD-3-Clause",
"dependencies": {
"@protobufjs/aspromise": "^1.1.2",
"@protobufjs/base64": "^1.1.2",
"@protobufjs/codegen": "^2.0.5",
"@protobufjs/eventemitter": "^1.1.1",
"@protobufjs/fetch": "^1.1.1",
"@protobufjs/float": "^1.0.2",
"@protobufjs/inquire": "^1.1.2",
"@protobufjs/path": "^1.1.2",
"@protobufjs/pool": "^1.1.0",
"@protobufjs/utf8": "^1.1.1",
"@types/node": ">=13.7.0",
"long": "^5.3.2"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/retry": {
"version": "0.13.1",
"resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz",
"integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==",
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/standardwebhooks": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
"integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
"license": "MIT",
"dependencies": {
"@stablelib/base64": "^1.0.0",
"fast-sha256": "^1.3.0"
}
},
"node_modules/ts-algebra": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
"license": "MIT"
},
"node_modules/typebox": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.3.tgz",
"integrity": "sha512-URXGUE31PJDQC+PtRMJeLdF4kmmOdFoVPikPCtV2oOIhUpNpppEdIz7W8bH8cFYPYHdDpaRvqwdegMTmHliudg==",
"license": "MIT"
},
"node_modules/undici-types": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
"license": "MIT"
},
"node_modules/web-streams-polyfill": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/ws": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/zod": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/zod-to-json-schema": {
"version": "3.25.2",
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz",
"integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==",
"license": "ISC",
"peerDependencies": {
"zod": "^3.25.28 || ^4"
}
}
}
}

89
packages/ai/package.json Normal file
View File

@@ -0,0 +1,89 @@
{
"name": "@openclaw/ai",
"version": "2026.6.11",
"description": "Reusable model provider adapters and streaming runtime from OpenClaw",
"keywords": [
"ai",
"anthropic",
"google",
"llm",
"mistral",
"openai",
"streaming"
],
"homepage": "https://github.com/openclaw/openclaw#readme",
"bugs": {
"url": "https://github.com/openclaw/openclaw/issues"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/openclaw/openclaw.git",
"directory": "packages/ai"
},
"files": [
"dist",
"npm-shrinkwrap.json",
"LICENSE",
"README.md"
],
"type": "module",
"main": "./dist/index.mjs",
"types": "./dist/index.d.mts",
"exports": {
".": {
"types": "./dist/index.d.mts",
"import": "./dist/index.mjs",
"default": "./dist/index.mjs"
},
"./providers": {
"types": "./dist/providers.d.mts",
"import": "./dist/providers.mjs",
"default": "./dist/providers.mjs"
},
"./diagnostics": {
"types": "./dist/diagnostics.d.mts",
"import": "./dist/diagnostics.mjs",
"default": "./dist/diagnostics.mjs"
},
"./event-stream": {
"types": "./dist/event-stream.d.mts",
"import": "./dist/event-stream.mjs",
"default": "./dist/event-stream.mjs"
},
"./types": {
"types": "./dist/types.d.mts",
"import": "./dist/types.mjs",
"default": "./dist/types.mjs"
},
"./validation": {
"types": "./dist/validation.d.mts",
"import": "./dist/validation.mjs",
"default": "./dist/validation.mjs"
},
"./internal/*": {
"types": "./dist/internal/*.d.mts",
"import": "./dist/internal/*.mjs",
"default": "./dist/internal/*.mjs"
}
},
"dependencies": {
"@anthropic-ai/sdk": "0.109.1",
"@google/genai": "2.10.0",
"@mistralai/mistralai": "2.4.0",
"openai": "6.45.0",
"partial-json": "0.1.7",
"typebox": "1.3.3"
},
"engines": {
"node": ">=22.19.0"
},
"publishConfig": {
"access": "public"
},
"openclaw": {
"release": {
"publishToNpm": true
}
}
}

View File

@@ -0,0 +1,76 @@
// LLM Runtime tests cover api registry behavior.
import { describe, expect, it, vi } from "vitest";
import {
createApiRegistry,
createAssistantMessageEventStream,
createLlmRuntime,
type Model,
} from "./index.js";
const TEST_SOURCE_ID = "test:llm-runtime-api-registry";
const emptyStream = () => createAssistantMessageEventStream();
const model = {
id: "test-model",
name: "Test Model",
api: "test-api",
provider: "test-provider",
baseUrl: "https://example.invalid",
input: ["text"],
reasoning: false,
contextWindow: 1000,
maxTokens: 100,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
} satisfies Model;
describe("LLM API registry", () => {
it("rejects mismatched model API calls", () => {
const registry = createApiRegistry();
registry.registerApiProvider(
{
api: "test-api",
stream: emptyStream,
streamSimple: emptyStream,
},
TEST_SOURCE_ID,
);
const provider = registry.getApiProvider("test-api");
expect(provider).toBeDefined();
expect(() => provider?.streamSimple({ ...model, api: "other-api" }, { messages: [] })).toThrow(
"Mismatched api: other-api expected test-api",
);
});
it("isolates providers between runtime instances", () => {
const first = createLlmRuntime();
const second = createLlmRuntime();
const streamSimple = vi.fn(() => createAssistantMessageEventStream());
first.registry.registerApiProvider({ api: "test-api", stream: streamSimple, streamSimple });
first.streamSimple(model, { messages: [] });
expect(streamSimple).toHaveBeenCalledOnce();
expect(() => second.streamSimple(model, { messages: [] })).toThrow(
"No API provider registered for api: test-api",
);
});
it("unregisters every provider owned by one source", () => {
const registry = createApiRegistry();
for (const api of ["test-api", "test-api-2"] as const) {
registry.registerApiProvider(
{
api,
stream: emptyStream,
streamSimple: emptyStream,
},
TEST_SOURCE_ID,
);
}
registry.unregisterApiProviders(TEST_SOURCE_ID);
expect(registry.getApiProviders()).toEqual([]);
});
});

View File

@@ -0,0 +1,119 @@
// LLM Runtime module implements api registry behavior.
import type {
Api,
AssistantMessageEventStreamContract,
Context,
Model,
SimpleStreamOptions,
StreamFunction,
StreamOptions,
} from "@openclaw/llm-core";
/** Runtime stream adapter signature stored in the API provider registry. */
export type ApiStreamFunction = (
model: Model,
context: Context,
options?: StreamOptions,
) => AssistantMessageEventStreamContract;
/** Runtime simple-stream adapter signature stored in the API provider registry. */
export type ApiStreamSimpleFunction = (
model: Model,
context: Context,
options?: SimpleStreamOptions,
) => AssistantMessageEventStreamContract;
/** Provider implementation registered by core or plugins for a specific model API. */
export interface ApiProvider<
TApi extends Api = Api,
TOptions extends StreamOptions = StreamOptions,
> {
/** Model API id this provider handles. */
api: TApi;
/** Full streaming adapter for callers that already own structured options. */
stream: StreamFunction<TApi, TOptions>;
/** Simple streaming adapter used by agent and plugin runtime defaults. */
streamSimple: StreamFunction<TApi, SimpleStreamOptions>;
}
/** Type-erased provider returned by a registry after API guards are installed. */
export interface RegisteredApiProvider {
api: Api;
stream: ApiStreamFunction;
streamSimple: ApiStreamSimpleFunction;
}
type RegisteredApiProviderEntry = {
provider: RegisteredApiProvider;
sourceId?: string;
};
function wrapStream<TApi extends Api, TOptions extends StreamOptions>(
api: TApi,
stream: StreamFunction<TApi, TOptions>,
): ApiStreamFunction {
return (model, context, options) => {
if (model.api !== api) {
throw new Error(`Mismatched api: ${model.api} expected ${api}`);
}
return stream(model as Model<TApi>, context, options as TOptions);
};
}
function wrapStreamSimple<TApi extends Api>(
api: TApi,
streamSimple: StreamFunction<TApi, SimpleStreamOptions>,
): ApiStreamSimpleFunction {
return (model, context, options) => {
if (model.api !== api) {
throw new Error(`Mismatched api: ${model.api} expected ${api}`);
}
return streamSimple(model as Model<TApi>, context, options);
};
}
/** Creates an isolated provider registry for one runtime or tenant. */
export function createApiRegistry() {
const providers = new Map<string, RegisteredApiProviderEntry>();
function registerApiProvider<TApi extends Api, TOptions extends StreamOptions>(
provider: ApiProvider<TApi, TOptions>,
/** Optional source id used to unregister all providers owned by one plugin/runtime. */
sourceId?: string,
): void {
providers.set(provider.api, {
provider: {
api: provider.api,
stream: wrapStream(provider.api, provider.stream),
streamSimple: wrapStreamSimple(provider.api, provider.streamSimple),
},
sourceId,
});
}
function getApiProvider(api: Api): RegisteredApiProvider | undefined {
return providers.get(api)?.provider;
}
function getApiProviders(): RegisteredApiProvider[] {
return Array.from(providers.values(), (entry) => entry.provider);
}
function unregisterApiProviders(sourceId: string): void {
for (const [api, entry] of providers.entries()) {
if (entry.sourceId === sourceId) {
providers.delete(api);
}
}
}
return {
registerApiProvider,
getApiProvider,
getApiProviders,
unregisterApiProviders,
clearApiProviders: () => providers.clear(),
};
}
export type ApiRegistry = ReturnType<typeof createApiRegistry>;

View File

@@ -0,0 +1,261 @@
// NEVER convert to top-level imports - breaks browser/Vite builds
let existsSync: typeof import("node:fs").existsSync | null = null;
let homedir: typeof import("node:os").homedir | null = null;
let join: typeof import("node:path").join | null = null;
type DynamicImport = (specifier: string) => Promise<unknown>;
type NodeBuiltinModule =
| typeof import("node:fs")
| typeof import("node:os")
| typeof import("node:path");
const dynamicImport: DynamicImport = (specifier) => import(specifier);
const NODE_FS_SPECIFIER = "node:fs";
const NODE_OS_SPECIFIER = "node:os";
const NODE_PATH_SPECIFIER = "node:path";
function loadNodeBuiltinModule(specifier: string): NodeBuiltinModule | null {
const getBuiltinModule = (typeof process !== "undefined" ? process : undefined) as
| (NodeJS.Process & { getBuiltinModule?: (id: string) => unknown })
| undefined;
if (typeof getBuiltinModule?.getBuiltinModule === "function") {
return getBuiltinModule.getBuiltinModule(specifier) as NodeBuiltinModule;
}
if (typeof require === "function") {
return require(specifier) as NodeBuiltinModule;
}
return null;
}
function loadNodeHelpersSync(): boolean {
try {
const fsModule = loadNodeBuiltinModule(NODE_FS_SPECIFIER) as typeof import("node:fs") | null;
const osModule = loadNodeBuiltinModule(NODE_OS_SPECIFIER) as typeof import("node:os") | null;
const pathModule = loadNodeBuiltinModule(NODE_PATH_SPECIFIER) as
| typeof import("node:path")
| null;
existsSync ??= fsModule?.existsSync ?? null;
homedir ??= osModule?.homedir ?? null;
join ??= pathModule?.join ?? null;
if (!existsSync || !homedir || !join) {
return false;
}
return true;
} catch {
return false;
}
}
// Eagerly load in Node.js/Bun environment only
if (typeof process !== "undefined" && (process.versions?.node || process.versions?.bun)) {
if (!loadNodeHelpersSync()) {
void dynamicImport(NODE_FS_SPECIFIER).then((m) => {
existsSync = (m as typeof import("node:fs")).existsSync;
});
void dynamicImport(NODE_OS_SPECIFIER).then((m) => {
homedir = (m as typeof import("node:os")).homedir;
});
void dynamicImport(NODE_PATH_SPECIFIER).then((m) => {
join = (m as typeof import("node:path")).join;
});
}
}
let procEnvCache: Map<string, string> | null = null;
function getProcessEnv(): NodeJS.ProcessEnv | undefined {
return typeof process === "undefined" ? undefined : process.env;
}
/**
* Fallback for https://github.com/oven-sh/bun/issues/27802
* Bun compiled binaries have an empty `process.env` inside sandbox
* environments on Linux. We can recover the env from `/proc/self/environ`.
*/
function getProcEnv(key: string): string | undefined {
if (typeof process === "undefined" || !process.versions?.bun) {
return undefined;
}
const env = getProcessEnv();
if (!env) {
return undefined;
}
// If process.env already has entries, the bug is not triggered.
if (Object.keys(env).length > 0) {
return undefined;
}
if (procEnvCache === null) {
procEnvCache = new Map();
try {
const { readFileSync } = require("node:fs") as typeof import("node:fs");
const data = readFileSync("/proc/self/environ", "utf-8");
for (const entry of data.split("\0")) {
const idx = entry.indexOf("=");
if (idx > 0) {
procEnvCache.set(entry.slice(0, idx), entry.slice(idx + 1));
}
}
} catch {
// /proc/self/environ may not be readable.
}
}
return procEnvCache.get(key);
}
function getEnvValue(key: string): string | undefined {
return getProcessEnv()?.[key] || getProcEnv(key);
}
let cachedVertexAdcCredentialsExists: true | null = null;
function hasVertexAdcCredentials(): boolean {
if (cachedVertexAdcCredentialsExists === null) {
if (!existsSync || !homedir || !join) {
const isNode =
typeof process !== "undefined" && (process.versions?.node || process.versions?.bun);
if (!isNode || !loadNodeHelpersSync()) {
return false;
}
}
const nodeExistsSync = existsSync;
const nodeHomedir = homedir;
const nodeJoin = join;
if (!nodeExistsSync || !nodeHomedir || !nodeJoin) {
return false;
}
// Check GOOGLE_APPLICATION_CREDENTIALS env var first (standard way)
const gacPath = getEnvValue("GOOGLE_APPLICATION_CREDENTIALS");
if (gacPath) {
cachedVertexAdcCredentialsExists = nodeExistsSync(gacPath) ? true : null;
} else {
// Fall back to default ADC path (lazy evaluation)
cachedVertexAdcCredentialsExists = nodeExistsSync(
nodeJoin(nodeHomedir(), ".config", "gcloud", "application_default_credentials.json"),
)
? true
: null;
}
}
return cachedVertexAdcCredentialsExists === true;
}
function getApiKeyEnvVars(provider: string): readonly string[] | undefined {
if (provider === "github-copilot") {
return ["COPILOT_GITHUB_TOKEN"];
}
// ANTHROPIC_OAUTH_TOKEN takes precedence over ANTHROPIC_API_KEY
if (provider === "anthropic") {
return ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"];
}
if (provider === "moonshot") {
return ["MOONSHOT_API_KEY", "KIMI_API_KEY"];
}
if (provider === "kimi" || provider === "kimi-coding") {
return ["KIMI_API_KEY", "KIMICODE_API_KEY"];
}
const envMap: Record<string, string> = {
openai: "OPENAI_API_KEY",
"azure-openai-responses": "AZURE_OPENAI_API_KEY",
deepseek: "DEEPSEEK_API_KEY",
google: "GEMINI_API_KEY",
"google-vertex": "GOOGLE_CLOUD_API_KEY",
groq: "GROQ_API_KEY",
cerebras: "CEREBRAS_API_KEY",
xai: "XAI_API_KEY",
openrouter: "OPENROUTER_API_KEY",
"vercel-ai-gateway": "AI_GATEWAY_API_KEY",
zai: "ZAI_API_KEY",
mistral: "MISTRAL_API_KEY",
minimax: "MINIMAX_API_KEY",
"minimax-cn": "MINIMAX_CN_API_KEY",
moonshotai: "MOONSHOT_API_KEY",
"moonshotai-cn": "MOONSHOT_API_KEY",
huggingface: "HF_TOKEN",
fireworks: "FIREWORKS_API_KEY",
together: "TOGETHER_API_KEY",
opencode: "OPENCODE_API_KEY",
"opencode-go": "OPENCODE_API_KEY",
"cloudflare-workers-ai": "CLOUDFLARE_API_KEY",
"cloudflare-ai-gateway": "CLOUDFLARE_API_KEY",
xiaomi: "XIAOMI_API_KEY",
"xiaomi-token-plan-cn": "XIAOMI_TOKEN_PLAN_CN_API_KEY",
"xiaomi-token-plan-ams": "XIAOMI_TOKEN_PLAN_AMS_API_KEY",
"xiaomi-token-plan-sgp": "XIAOMI_TOKEN_PLAN_SGP_API_KEY",
};
const envVar = envMap[provider];
return envVar ? [envVar] : undefined;
}
/**
* Find configured environment variables that can provide an API key for a provider.
*
* This only reports actual API key variables. It intentionally excludes ambient
* credential sources such as AWS profiles, AWS IAM credentials, and Google
* Application Default Credentials.
*/
export function findEnvKeys(provider: string): string[] | undefined {
const envVars = getApiKeyEnvVars(provider);
if (!envVars) {
return undefined;
}
const found = envVars.filter((envVar) => Boolean(getEnvValue(envVar)));
return found.length > 0 ? found : undefined;
}
/**
* Get API key for provider from known environment variables, e.g. OPENAI_API_KEY.
*
* Will not return API keys for providers that require OAuth tokens.
*/
export function getEnvApiKey(provider: string): string | undefined {
const envKeys = findEnvKeys(provider);
if (envKeys?.[0]) {
return getEnvValue(envKeys[0]);
}
// Vertex AI supports either an explicit API key or Application Default Credentials.
// Auth is configured via `gcloud auth application-default login`.
if (provider === "google-vertex") {
const hasCredentials = hasVertexAdcCredentials();
const hasProject = Boolean(
getEnvValue("GOOGLE_CLOUD_PROJECT") || getEnvValue("GCLOUD_PROJECT"),
);
const hasLocation = Boolean(getEnvValue("GOOGLE_CLOUD_LOCATION"));
if (hasCredentials && hasProject && hasLocation) {
return "<authenticated>";
}
}
if (provider === "amazon-bedrock") {
// Amazon Bedrock supports multiple credential sources:
// 1. AWS_PROFILE - named profile from ~/.aws/credentials
// 2. AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY - standard IAM keys
// 3. AWS_BEARER_TOKEN_BEDROCK - Bedrock bearer token
// 4. AWS_CONTAINER_CREDENTIALS_RELATIVE_URI - ECS task roles
// 5. AWS_CONTAINER_CREDENTIALS_FULL_URI - ECS task roles (full URI)
// 6. AWS_WEB_IDENTITY_TOKEN_FILE - IRSA (IAM Roles for Service Accounts)
if (
getEnvValue("AWS_PROFILE") ||
(getEnvValue("AWS_ACCESS_KEY_ID") && getEnvValue("AWS_SECRET_ACCESS_KEY")) ||
getEnvValue("AWS_BEARER_TOKEN_BEDROCK") ||
getEnvValue("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI") ||
getEnvValue("AWS_CONTAINER_CREDENTIALS_FULL_URI") ||
getEnvValue("AWS_WEB_IDENTITY_TOKEN_FILE")
) {
return "<authenticated>";
}
}
return undefined;
}

66
packages/ai/src/host.ts Normal file
View File

@@ -0,0 +1,66 @@
// Host policy ports for the reusable transport package. Fetch guarding,
// secret redaction, strict-tool policy, and diagnostics logging are owned by
// the embedding application (OpenClaw core installs its implementations via
// configureAiTransportHost); the library defaults below are inert so external
// consumers get safe, dependency-free behavior without wiring anything.
import type { Model } from "@openclaw/llm-core";
/** Strict-tool policy inputs for OpenAI-compatible routes. */
export interface OpenAIStrictToolSettingOptions {
transport?: "stream" | "websocket";
supportsStrictMode?: boolean;
}
/** Narrow host ports consumed by the built-in provider adapters. */
export interface AiTransportHost {
/**
* Builds a policy-guarded fetch for one model request.
* Returning undefined keeps the provider SDK's default fetch.
*/
buildModelFetch(
model: Model,
timeoutMs?: number,
options?: { sanitizeSse?: boolean },
): typeof fetch | undefined;
/** Redacts secrets inside structured tool-result payloads. */
redactSecrets<T>(value: T): T;
/** Redacts secret-bearing text in tool payload strings. */
redactToolPayloadText(text: string): string;
/**
* Resolves the host strict-tool default for OpenAI-compatible routes.
* undefined lets the request omit the strict flag entirely.
*/
resolveOpenAIStrictToolSetting(
model: Pick<Model, "provider" | "api" | "baseUrl" | "id"> & { compat?: unknown },
options?: OpenAIStrictToolSettingOptions,
): boolean | undefined;
/**
* Emits one transport diagnostic; build runs only when the host logs it and
* may return null to suppress the entry (e.g. de-duplication).
*/
logDebug(
subsystem: string,
build: () => { message: string; data?: Record<string, unknown> } | null,
): void;
}
const inertAiTransportHost: AiTransportHost = {
buildModelFetch: () => undefined,
redactSecrets: (value) => value,
redactToolPayloadText: (text) => text,
resolveOpenAIStrictToolSetting: (_model, options) =>
options?.supportsStrictMode ? false : undefined,
logDebug: () => {},
};
let activeAiTransportHost = inertAiTransportHost;
/** Installs host implementations for the transport policy ports. */
export function configureAiTransportHost(host: Partial<AiTransportHost>): void {
activeAiTransportHost = { ...inertAiTransportHost, ...host };
}
/** Returns the active transport host (inert defaults unless configured). */
export function getAiTransportHost(): AiTransportHost {
return activeAiTransportHost;
}

5
packages/ai/src/index.ts Normal file
View File

@@ -0,0 +1,5 @@
/** Reusable model API contracts, provider adapters, and streaming runtime. */
export * from "@openclaw/llm-core";
export * from "./api-registry.js";
export * from "./host.js";
export * from "./stream.js";

View File

@@ -0,0 +1,8 @@
export * from "../providers/anthropic.js";
export * from "../providers/anthropic-auth-headers.js";
export * from "../providers/anthropic-model-contract.js";
export * from "../providers/anthropic-refusal.js";
export * from "../providers/anthropic-server-fallback.js";
export * from "../providers/anthropic-thinking-replay.js";
export * from "../providers/anthropic-tool-projection.js";
export * from "../providers/anthropic-usage.js";

View File

@@ -0,0 +1,19 @@
// Process-default registry/runtime retained for the OpenClaw compatibility
// facade (src/llm). Deliberately not part of the public package API: external
// consumers create isolated runtimes via createLlmRuntime(); exporting these
// from the root barrel would reintroduce the mutable process-global registry.
import { createApiRegistry } from "../api-registry.js";
import { createLlmRuntime } from "../stream.js";
export const defaultApiRegistry = createApiRegistry();
export const defaultLlmRuntime = createLlmRuntime(defaultApiRegistry);
export const {
registerApiProvider,
getApiProvider,
getApiProviders,
unregisterApiProviders,
clearApiProviders,
} = defaultApiRegistry;
export const { stream, complete, streamSimple, completeSimple } = defaultLlmRuntime;

View File

@@ -0,0 +1,14 @@
export * from "../providers/agent-tools-parameter-schema.js";
export * from "../providers/azure-deployment-map.js";
export * from "../providers/azure-openai-responses-client-compat.js";
export * from "../providers/clean-for-gemini.js";
export * from "../providers/openai-completions.js";
export * from "../providers/openai-prompt-cache.js";
export * from "../providers/openai-reasoning-effort.js";
export * from "../providers/openai-responses.js";
export * from "../providers/openai-responses-stream-compat.js";
export * from "../providers/openai-stop-reason.js";
export * from "../providers/openai-tool-projection.js";
export * from "../providers/openai-tool-schema.js";
export * from "../providers/schema-keyword-strip.js";
export * from "../providers/tool-schema-json-projection.js";

View File

@@ -0,0 +1,15 @@
export * from "./default-runtime.js";
export * from "../env-api-keys.js";
export * from "../model-utils.js";
export * from "../session-resources.js";
export * from "../utils/deferred-event-buffer.js";
export * from "../utils/hash.js";
export * from "../utils/headers.js";
export * from "../utils/json-parse.js";
export * from "../utils/llm-request-activity.js";
export * from "../utils/oauth/openai-chatgpt-jwt.js";
export * from "../utils/overflow.js";
export * from "../utils/reasoning-tag-text-partitioner.js";
export * from "../utils/sanitize-unicode.js";
export * from "../utils/stream-first-event-timeout.js";
export * from "../utils/streaming-byte-guard.js";

View File

@@ -0,0 +1,5 @@
export * from "../providers/simple-options.js";
export * from "../providers/tool-result-text.js";
export * from "../providers/transform-messages.js";
export * from "../utils/prompt-cache-stability.js";
export * from "../utils/system-prompt-cache-boundary.js";

View File

@@ -0,0 +1,110 @@
// Provides model selection, usage, and thinking-level utility helpers.
import {
resolveClaudeFable5ModelIdentity,
resolveClaudeNativeThinkingLevelMap,
} from "@openclaw/llm-core";
import type { Api, Model, ModelThinkingLevel, Usage } from "./types.js";
/** Calculates and stores model cost fields from token usage and per-million pricing. */
export function calculateCost<TApi extends Api>(model: Model<TApi>, usage: Usage): Usage["cost"] {
usage.cost.input = (model.cost.input / 1000000) * usage.input;
usage.cost.output = (model.cost.output / 1000000) * usage.output;
usage.cost.cacheRead = (model.cost.cacheRead / 1000000) * usage.cacheRead;
usage.cost.cacheWrite = (model.cost.cacheWrite / 1000000) * usage.cacheWrite;
usage.cost.total =
usage.cost.input + usage.cost.output + usage.cost.cacheRead + usage.cost.cacheWrite;
return usage.cost;
}
const EXTENDED_THINKING_LEVELS: ModelThinkingLevel[] = [
"off",
"minimal",
"low",
"medium",
"high",
"xhigh",
"max",
];
function resolveThinkingLevelMap<TApi extends Api>(model: Model<TApi>) {
return model.api === "anthropic-messages"
? (resolveClaudeNativeThinkingLevelMap(model) ?? model.thinkingLevelMap)
: model.thinkingLevelMap;
}
/** Returns thinking levels exposed by a reasoning-capable model. */
export function getSupportedThinkingLevels<TApi extends Api>(
model: Model<TApi>,
): ModelThinkingLevel[] {
const fableContract =
model.api === "anthropic-messages" && resolveClaudeFable5ModelIdentity(model) !== undefined;
if (!model.reasoning && !fableContract) {
return ["off"];
}
const thinkingLevelMap = resolveThinkingLevelMap(model);
return EXTENDED_THINKING_LEVELS.filter((level) => {
const mapped = thinkingLevelMap?.[level];
if (mapped === null) {
return false;
}
if (level === "xhigh" || level === "max") {
return mapped !== undefined;
}
return true;
});
}
/** Clamps a requested thinking level to the closest supported level for a model. */
export function clampThinkingLevel<TApi extends Api>(
model: Model<TApi>,
level: ModelThinkingLevel,
): ModelThinkingLevel {
const availableLevels = getSupportedThinkingLevels(model);
if (availableLevels.includes(level)) {
return level;
}
const requestedIndex = EXTENDED_THINKING_LEVELS.indexOf(level);
if (requestedIndex === -1) {
return availableLevels[0] ?? "off";
}
// Explicit provider opt-outs are hard caps. Downgrade them before considering
// stronger levels so unsupported xhigh/max requests cannot increase cost.
const thinkingLevelMap = resolveThinkingLevelMap(model);
if ((level === "xhigh" || level === "max") && thinkingLevelMap?.[level] === null) {
for (let i = requestedIndex - 1; i >= 0; i--) {
const candidate = EXTENDED_THINKING_LEVELS[i];
if (availableLevels.includes(candidate)) {
return candidate;
}
}
}
// Prefer the next stronger available level, then walk down if the request was above the model cap.
for (let i = requestedIndex; i < EXTENDED_THINKING_LEVELS.length; i++) {
const candidate = EXTENDED_THINKING_LEVELS[i];
if (availableLevels.includes(candidate)) {
return candidate;
}
}
for (let i = requestedIndex - 1; i >= 0; i--) {
const candidate = EXTENDED_THINKING_LEVELS[i];
if (availableLevels.includes(candidate)) {
return candidate;
}
}
return availableLevels[0] ?? "off";
}
/** Compares model identity by provider and id. */
export function modelsAreEqual<TApi extends Api>(
a: Model<TApi> | null | undefined,
b: Model<TApi> | null | undefined,
): boolean {
if (!a || !b) {
return false;
}
return a.id === b.id && a.provider === b.provider;
}

View File

@@ -0,0 +1,6 @@
/** Lazy built-in protocol adapter registration. */
export {
BUILT_IN_API_PROVIDER_SOURCE_ID,
registerBuiltInApiProviders,
resetApiProviders,
} from "./providers/register-builtins.js";

View File

@@ -0,0 +1,971 @@
/**
* Normalizes model-facing tool parameter schemas across provider quirks.
* Handles local JSON Schema refs, OpenAPI nullable syntax, top-level unions,
* and provider-specific unsupported keyword stripping.
*/
import { isRecord as isSchemaRecord } from "@openclaw/normalization-core/record-coerce";
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
import {
normalizeStringEntries,
uniqueValues,
} from "@openclaw/normalization-core/string-normalization";
import type { TSchema } from "typebox";
import { cleanSchemaForGemini } from "./clean-for-gemini.js";
import { stripUnsupportedSchemaKeywords } from "./schema-keyword-strip.js";
/**
* Narrow structural view of the host's model compat config. packages/ai must stay
* config-agnostic, so only tool-schema-relevant fields are modeled here; the host's
* ModelCompatConfig remains structurally assignable.
*/
export type ToolSchemaModelCompat = {
toolSchemaProfile?: string;
unsupportedToolSchemaKeywords?: string[];
omitEmptyArrayItems?: boolean;
};
/** Extracts the compat record whether callers pass a model (`{ compat }`) or the compat itself. */
export function extractToolSchemaModelCompat(
modelOrCompat: { compat?: unknown } | ToolSchemaModelCompat | undefined,
): ToolSchemaModelCompat | undefined {
if (!modelOrCompat || typeof modelOrCompat !== "object") {
return undefined;
}
if ("compat" in modelOrCompat) {
const compat = (modelOrCompat as { compat?: unknown }).compat;
return compat && typeof compat === "object" ? (compat as ToolSchemaModelCompat) : undefined;
}
return modelOrCompat as ToolSchemaModelCompat;
}
/** JSON Schema keywords this model/provider rejects in tool schemas. */
export function resolveUnsupportedToolSchemaKeywords(
modelOrCompat: { compat?: unknown } | ToolSchemaModelCompat | undefined,
): ReadonlySet<string> {
const keywords = extractToolSchemaModelCompat(modelOrCompat)?.unsupportedToolSchemaKeywords ?? [];
return new Set(
normalizeStringEntries(
keywords.filter((keyword): keyword is string => typeof keyword === "string"),
),
);
}
/** Whether empty `items: {}` on array schemas must be omitted for this model/provider. */
export function shouldOmitEmptyArrayItems(
modelOrCompat: { compat?: unknown } | ToolSchemaModelCompat | undefined,
): boolean {
return extractToolSchemaModelCompat(modelOrCompat)?.omitEmptyArrayItems === true;
}
export type ToolParameterSchemaOptions = {
modelProvider?: string;
modelId?: string;
modelCompat?: ToolSchemaModelCompat;
};
const MAX_TOOL_PARAMETER_SCHEMA_CACHE_ENTRIES_PER_SCHEMA = 8;
const toolParameterSchemaCache = new WeakMap<object, Array<{ key: string; value: TSchema }>>();
function resolveToolParameterSchemaCacheKey(
options: ToolParameterSchemaOptions | undefined,
): string {
const normalizedProvider = normalizeLowercaseStringOrEmpty(options?.modelProvider);
const normalizedModelId = normalizeLowercaseStringOrEmpty(options?.modelId);
const toolSchemaProfile = normalizeLowercaseStringOrEmpty(
options?.modelCompat?.toolSchemaProfile,
);
const unsupportedKeywords = Array.from(
resolveUnsupportedToolSchemaKeywords(options?.modelCompat),
).toSorted();
const omitEmptyArrayItems = shouldOmitEmptyArrayItems(options?.modelCompat);
return JSON.stringify([
normalizedProvider,
normalizedModelId,
toolSchemaProfile,
unsupportedKeywords,
omitEmptyArrayItems,
]);
}
function getCachedToolParameterSchema(schema: object, key: string): TSchema | undefined {
return toolParameterSchemaCache.get(schema)?.find((entry) => entry.key === key)?.value;
}
function rememberCachedToolParameterSchema(schema: object, key: string, value: TSchema): TSchema {
const entries = toolParameterSchemaCache.get(schema) ?? [];
toolParameterSchemaCache.set(
schema,
[{ key, value }, ...entries.filter((entry) => entry.key !== key)].slice(
0,
MAX_TOOL_PARAMETER_SCHEMA_CACHE_ENTRIES_PER_SCHEMA,
),
);
return value;
}
function isGeminiModelId(modelId: string): boolean {
return /(?:^|[/:])gemini(?:$|[-/:.])/.test(modelId);
}
function extractEnumValues(schema: unknown): unknown[] | undefined {
if (!schema || typeof schema !== "object") {
return undefined;
}
const record = schema as Record<string, unknown>;
if (Array.isArray(record.enum)) {
return record.enum;
}
if ("const" in record) {
return [record.const];
}
const variants = Array.isArray(record.anyOf)
? record.anyOf
: Array.isArray(record.oneOf)
? record.oneOf
: null;
if (variants) {
const values = variants.flatMap((variant) => {
const extracted = extractEnumValues(variant);
return extracted ?? [];
});
return values.length > 0 ? values : undefined;
}
return undefined;
}
function mergePropertySchemas(existing: unknown, incoming: unknown): unknown {
if (!existing) {
return incoming;
}
if (!incoming) {
return existing;
}
const existingEnum = extractEnumValues(existing);
const incomingEnum = extractEnumValues(incoming);
if (existingEnum || incomingEnum) {
const values = uniqueValues([...(existingEnum ?? []), ...(incomingEnum ?? [])]);
const merged: Record<string, unknown> = {};
for (const source of [existing, incoming]) {
if (!source || typeof source !== "object") {
continue;
}
const record = source as Record<string, unknown>;
for (const key of ["title", "description", "default"]) {
if (!(key in merged) && key in record) {
merged[key] = record[key];
}
}
}
const types = new Set(values.map((value) => typeof value));
if (types.size === 1) {
merged.type = Array.from(types)[0];
}
merged.enum = values;
return merged;
}
return existing;
}
type FlattenableVariantKey = "anyOf" | "oneOf";
type TopLevelConditionalKey = FlattenableVariantKey | "allOf";
function setOwnSchemaProperty(target: Record<string, unknown>, key: string, value: unknown): void {
Object.defineProperty(target, key, {
value,
enumerable: true,
configurable: true,
writable: true,
});
}
function hasTopLevelArrayKeyword(
schemaRecord: Record<string, unknown>,
key: TopLevelConditionalKey,
): boolean {
return Array.isArray(schemaRecord[key]);
}
function getFlattenableVariantKey(
schemaRecord: Record<string, unknown>,
): FlattenableVariantKey | null {
if (hasTopLevelArrayKeyword(schemaRecord, "anyOf")) {
return "anyOf";
}
if (hasTopLevelArrayKeyword(schemaRecord, "oneOf")) {
return "oneOf";
}
return null;
}
function getTopLevelConditionalKey(
schemaRecord: Record<string, unknown>,
): TopLevelConditionalKey | null {
return (
getFlattenableVariantKey(schemaRecord) ??
(hasTopLevelArrayKeyword(schemaRecord, "allOf") ? "allOf" : null)
);
}
function hasTopLevelObjectSchema(
schemaRecord: Record<string, unknown>,
conditionalKey: TopLevelConditionalKey | null,
): boolean {
return (
schemaRecord.type === "object" &&
isSchemaRecord(schemaRecord.properties) &&
conditionalKey === null
);
}
function isObjectLikeSchemaMissingType(
schemaRecord: Record<string, unknown>,
conditionalKey: TopLevelConditionalKey | null,
): boolean {
return (
!("type" in schemaRecord) &&
(isSchemaRecord(schemaRecord.properties) || Array.isArray(schemaRecord.required)) &&
conditionalKey === null
);
}
function isTypedObjectSchemaMissingValidProperties(
schemaRecord: Record<string, unknown>,
conditionalKey: TopLevelConditionalKey | null,
): boolean {
return (
schemaRecord.type === "object" &&
!isSchemaRecord(schemaRecord.properties) &&
conditionalKey === null
);
}
function isTrulyEmptySchema(schemaRecord: Record<string, unknown>): boolean {
return Object.keys(schemaRecord).length === 0;
}
function normalizeArraySchemasMissingItems(schema: unknown): unknown {
if (!isSchemaRecord(schema)) {
return schema;
}
let changed = false;
const nextSchema: Record<string, unknown> = { ...schema };
if (nextSchema.type === "array" && nextSchema.items === undefined) {
nextSchema.items = {};
changed = true;
}
const normalizeSchemaValue = (key: string): void => {
if (!(key in nextSchema)) {
return;
}
const value = nextSchema[key];
if (Array.isArray(value)) {
const normalized = value.map(normalizeArraySchemasMissingItems);
if (normalized.some((entry, index) => entry !== value[index])) {
nextSchema[key] = normalized;
changed = true;
}
return;
}
const normalized = normalizeArraySchemasMissingItems(value);
if (normalized !== value) {
nextSchema[key] = normalized;
changed = true;
}
};
for (const key of [
"items",
"contains",
"additionalProperties",
"propertyNames",
"not",
"if",
"then",
"else",
]) {
normalizeSchemaValue(key);
}
for (const key of ["anyOf", "oneOf", "allOf", "prefixItems"]) {
normalizeSchemaValue(key);
}
for (const key of [
"properties",
"patternProperties",
"dependentSchemas",
"$defs",
"definitions",
]) {
const value = nextSchema[key];
if (!isSchemaRecord(value)) {
continue;
}
let entriesChanged = false;
const normalizedEntries: Array<[string, unknown]> = Object.entries(value).map(
([entryKey, entryValue]) => {
const normalizedEntryValue = normalizeArraySchemasMissingItems(entryValue);
if (normalizedEntryValue !== entryValue) {
entriesChanged = true;
}
return [entryKey, normalizedEntryValue];
},
);
if (entriesChanged) {
nextSchema[key] = Object.fromEntries(normalizedEntries);
changed = true;
}
}
return changed ? nextSchema : schema;
}
function schemaAllowsArrayType(schema: Record<string, unknown>): boolean {
const type = schema.type;
return type === "array" || (Array.isArray(type) && type.includes("array"));
}
const ARRAY_ITEMS_SCHEMA_OBJECT_KEYS = new Set([
"additionalProperties",
"contains",
"else",
"if",
"items",
"not",
"propertyNames",
"then",
]);
const ARRAY_ITEMS_SCHEMA_ARRAY_KEYS = new Set(["allOf", "anyOf", "oneOf", "prefixItems"]);
const ARRAY_ITEMS_SCHEMA_MAP_KEYS = new Set([
"$defs",
"definitions",
"dependentSchemas",
"patternProperties",
"properties",
]);
function stripEmptyArrayItemsFromArraySchemas(schema: unknown): unknown {
if (Array.isArray(schema)) {
let changed = false;
const entries = schema.map((entry) => {
const next = stripEmptyArrayItemsFromArraySchemas(entry);
changed ||= next !== entry;
return next;
});
return changed ? entries : schema;
}
if (!isSchemaRecord(schema)) {
return schema;
}
let changed = false;
const entries = Object.entries(schema).flatMap(([key, value]) => {
if (
key === "items" &&
schemaAllowsArrayType(schema) &&
isSchemaRecord(value) &&
isTrulyEmptySchema(value)
) {
changed = true;
return [];
}
if (ARRAY_ITEMS_SCHEMA_OBJECT_KEYS.has(key)) {
const next = stripEmptyArrayItemsFromArraySchemas(value);
changed ||= next !== value;
return [[key, next] as const];
}
if (ARRAY_ITEMS_SCHEMA_ARRAY_KEYS.has(key) && Array.isArray(value)) {
const next = stripEmptyArrayItemsFromArraySchemas(value);
changed ||= next !== value;
return [[key, next] as const];
}
if (ARRAY_ITEMS_SCHEMA_MAP_KEYS.has(key) && isSchemaRecord(value)) {
let mapChanged = false;
const next = Object.fromEntries(
Object.entries(value).map(([entryKey, entryValue]) => {
const entryNext = stripEmptyArrayItemsFromArraySchemas(entryValue);
mapChanged ||= entryNext !== entryValue;
return [entryKey, entryNext] as const;
}),
);
changed ||= mapChanged;
return [[key, mapChanged ? next : value] as const];
}
return [[key, value] as const];
});
return changed ? Object.fromEntries(entries) : schema;
}
type SchemaDefs = {
$defs: Map<string, unknown>;
definitions: Map<string, unknown>;
};
function copySchemaMeta(from: Record<string, unknown>, to: Record<string, unknown>): void {
for (const key of ["title", "description", "default"] as const) {
if (key in from && from[key] !== undefined) {
to[key] = from[key];
}
}
}
function extendSchemaDefs(
defs: SchemaDefs | undefined,
schema: Record<string, unknown>,
): SchemaDefs | undefined {
const defsEntry =
schema.$defs && typeof schema.$defs === "object" && !Array.isArray(schema.$defs)
? (schema.$defs as Record<string, unknown>)
: undefined;
const legacyDefsEntry =
schema.definitions &&
typeof schema.definitions === "object" &&
!Array.isArray(schema.definitions)
? (schema.definitions as Record<string, unknown>)
: undefined;
if (!defsEntry && !legacyDefsEntry) {
return defs;
}
const next: SchemaDefs = defs
? {
$defs: new Map(defs.$defs),
definitions: new Map(defs.definitions),
}
: {
$defs: new Map<string, unknown>(),
definitions: new Map<string, unknown>(),
};
if (defsEntry) {
for (const [key, value] of Object.entries(defsEntry)) {
next.$defs.set(key, value);
}
}
if (legacyDefsEntry) {
for (const [key, value] of Object.entries(legacyDefsEntry)) {
next.definitions.set(key, value);
}
}
return next;
}
function decodeJsonPointerSegment(segment: string): string {
return segment.replaceAll("~1", "/").replaceAll("~0", "~");
}
function resolveJsonPointerPath(value: unknown, segments: string[]): unknown {
let current = value;
for (const segment of segments) {
if (!current || typeof current !== "object") {
return undefined;
}
const key = decodeJsonPointerSegment(segment);
if (Array.isArray(current)) {
const index = Number(key);
if (!Number.isInteger(index) || index < 0 || index >= current.length) {
return undefined;
}
current = current[index];
continue;
}
const record = current as Record<string, unknown>;
if (!Object.hasOwn(record, key)) {
return undefined;
}
current = record[key];
}
return current;
}
function resolveLocalJsonPointer(rootDocument: unknown, ref: string): unknown {
if (!ref.startsWith("#/")) {
return undefined;
}
return resolveJsonPointerPath(rootDocument, ref.slice(2).split("/"));
}
const SCHEMA_MAP_KEYS = new Set([
"$defs",
"definitions",
"dependentSchemas",
"patternProperties",
"properties",
]);
const SCHEMA_OBJECT_KEYS = new Set([
"additionalProperties",
"contains",
"else",
"if",
"items",
"not",
"propertyNames",
"then",
]);
const SCHEMA_ARRAY_KEYS = new Set(["allOf", "anyOf", "items", "oneOf", "prefixItems"]);
const SCHEMA_LITERAL_KEYS = new Set(["const", "default", "enum", "examples"]);
function tryResolveLocalRef(
ref: string,
defs: SchemaDefs | undefined,
rootDocument: unknown,
): unknown {
const match = ref.match(/^#\/(\$defs|definitions)\/([^/]+)(?:\/(.*))?$/);
if (match && defs) {
const namespace = match[1] === "$defs" ? defs.$defs : defs.definitions;
const name = decodeJsonPointerSegment(match[2] ?? "");
const resolved = name ? namespace.get(name) : undefined;
if (resolved !== undefined) {
const remainingPath = match[3] ? match[3].split("/") : [];
return resolveJsonPointerPath(resolved, remainingPath);
}
}
return resolveLocalJsonPointer(rootDocument, ref);
}
function inlineLocalSchemaRefsWithDefs(
schema: unknown,
defs: SchemaDefs | undefined,
refStack: Set<string> | undefined,
state: { unresolvedLocalRefs: boolean },
rootDocument: unknown,
): unknown {
if (!schema || typeof schema !== "object") {
return schema;
}
if (Array.isArray(schema)) {
return schema.map((entry) =>
inlineLocalSchemaRefsWithDefs(entry, defs, refStack, state, rootDocument),
);
}
const obj = schema as Record<string, unknown>;
const nextDefs = extendSchemaDefs(defs, obj);
const refValue = typeof obj.$ref === "string" ? obj.$ref : undefined;
if (refValue) {
if (refStack?.has(refValue)) {
return {};
}
const resolved = tryResolveLocalRef(refValue, nextDefs, rootDocument);
if (resolved === undefined) {
if (refValue.startsWith("#/")) {
state.unresolvedLocalRefs = true;
}
return { ...obj };
}
const nextRefStack = refStack ? new Set(refStack) : new Set<string>();
nextRefStack.add(refValue);
const inlined = inlineLocalSchemaRefsWithDefs(
resolved,
nextDefs,
nextRefStack,
state,
rootDocument,
);
if (!inlined || typeof inlined !== "object" || Array.isArray(inlined)) {
return inlined;
}
const result: Record<string, unknown> = { ...(inlined as Record<string, unknown>) };
copySchemaMeta(obj, result);
if (obj.nullable === true) {
result.nullable = true;
}
return result;
}
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj)) {
if (key === "$defs" || key === "definitions" || key === "components") {
continue;
}
if (SCHEMA_LITERAL_KEYS.has(key)) {
setOwnSchemaProperty(result, key, value);
continue;
}
if (SCHEMA_MAP_KEYS.has(key) && isSchemaRecord(value)) {
setOwnSchemaProperty(
result,
key,
Object.fromEntries(
Object.entries(value).map(([entryKey, entryValue]) => [
entryKey,
inlineLocalSchemaRefsWithDefs(entryValue, nextDefs, refStack, state, rootDocument),
]),
),
);
continue;
}
if (SCHEMA_OBJECT_KEYS.has(key) && isSchemaRecord(value)) {
setOwnSchemaProperty(
result,
key,
inlineLocalSchemaRefsWithDefs(value, nextDefs, refStack, state, rootDocument),
);
continue;
}
if (SCHEMA_ARRAY_KEYS.has(key) && Array.isArray(value)) {
setOwnSchemaProperty(
result,
key,
value.map((entry) =>
inlineLocalSchemaRefsWithDefs(entry, nextDefs, refStack, state, rootDocument),
),
);
continue;
}
setOwnSchemaProperty(result, key, value);
}
if (state.unresolvedLocalRefs) {
if ("$defs" in obj) {
result.$defs = obj.$defs;
}
if ("definitions" in obj) {
result.definitions = obj.definitions;
}
if ("components" in obj) {
result.components = obj.components;
}
}
return result;
}
/** Inline local $ref pointers so providers receive self-contained tool schemas. */
function inlineLocalToolSchemaRefs(schema: unknown): TSchema {
if (!schema || typeof schema !== "object") {
return schema as TSchema;
}
const defs = extendSchemaDefs(undefined, schema as Record<string, unknown>);
return inlineLocalSchemaRefsWithDefs(
schema,
defs,
undefined,
{
unresolvedLocalRefs: false,
},
schema,
) as TSchema;
}
const OPENAPI_SCHEMA_ANNOTATION_KEYS = new Set([
"discriminator",
"externalDocs",
"readOnly",
"writeOnly",
"xml",
"example",
]);
function appendNullSchemaType(type: unknown): unknown {
if (type === "null") {
return type;
}
if (typeof type === "string") {
return [type, "null"];
}
if (Array.isArray(type)) {
return type.includes("null") ? type : [...type, "null"];
}
return type;
}
function isNullSchemaLike(schema: unknown): boolean {
if (!isSchemaRecord(schema)) {
return false;
}
if (schema.type === "null") {
return true;
}
if (Array.isArray(schema.type) && schema.type.includes("null")) {
return true;
}
if ("const" in schema && schema.const === null) {
return true;
}
return Array.isArray(schema.enum) && schema.enum.includes(null);
}
function hasOpenApiComposition(schema: Record<string, unknown>): boolean {
return ["allOf", "anyOf", "oneOf"].some((key) => Array.isArray(schema[key]));
}
function schemaCompositionAlreadyAllowsNull(schema: Record<string, unknown>): boolean {
return (
(Array.isArray(schema.anyOf) && schema.anyOf.some(isNullSchemaLike)) ||
(Array.isArray(schema.oneOf) && schema.oneOf.some(isNullSchemaLike))
);
}
function wrapNullableComposedSchema(schema: Record<string, unknown>): Record<string, unknown> {
if (schemaCompositionAlreadyAllowsNull(schema)) {
return schema;
}
const wrapped: Record<string, unknown> = {
anyOf: [schema, { type: "null" }],
};
copySchemaMeta(schema, wrapped);
return wrapped;
}
function normalizeOpenApiSchemaKeywords(schema: unknown): unknown {
if (Array.isArray(schema)) {
let changed = false;
const normalized = schema.map((entry) => {
const next = normalizeOpenApiSchemaKeywords(entry);
changed ||= next !== entry;
return next;
});
return changed ? normalized : schema;
}
if (!isSchemaRecord(schema)) {
return schema;
}
let changed = false;
const nullable = schema.nullable === true;
const normalized: Record<string, unknown> = {};
for (const [key, value] of Object.entries(schema)) {
if (key === "nullable" || OPENAPI_SCHEMA_ANNOTATION_KEYS.has(key)) {
changed = true;
continue;
}
if (SCHEMA_LITERAL_KEYS.has(key)) {
normalized[key] = value;
continue;
}
if (SCHEMA_MAP_KEYS.has(key) && isSchemaRecord(value)) {
let mapChanged = false;
const next = Object.fromEntries(
Object.entries(value).map(([entryKey, entryValue]) => {
const nextEntry = normalizeOpenApiSchemaKeywords(entryValue);
mapChanged ||= nextEntry !== entryValue;
return [entryKey, nextEntry];
}),
);
normalized[key] = mapChanged ? next : value;
changed ||= mapChanged;
continue;
}
if (key === "components") {
normalized[key] = value;
continue;
}
if (SCHEMA_OBJECT_KEYS.has(key) && isSchemaRecord(value)) {
const next = normalizeOpenApiSchemaKeywords(value);
normalized[key] = next;
changed ||= next !== value;
continue;
}
if (SCHEMA_ARRAY_KEYS.has(key) && Array.isArray(value)) {
const next = value.map(normalizeOpenApiSchemaKeywords);
normalized[key] = next;
changed ||= next.some((entry, index) => entry !== value[index]);
continue;
}
normalized[key] = value;
}
if (nullable) {
if (hasOpenApiComposition(normalized)) {
return wrapNullableComposedSchema(normalized);
}
if ("type" in normalized) {
const nextType = appendNullSchemaType(normalized.type);
if (nextType !== normalized.type) {
normalized.type = nextType;
}
}
if (Array.isArray(normalized.enum) && !normalized.enum.includes(null)) {
normalized.enum = [...normalized.enum, null];
}
}
return changed || nullable ? normalized : schema;
}
function normalizeToolParameterSchemaUncached(
schema: unknown,
options?: ToolParameterSchemaOptions,
): TSchema {
const inlinedSchema = normalizeOpenApiSchemaKeywords(inlineLocalToolSchemaRefs(schema));
const schemaRecord =
inlinedSchema && typeof inlinedSchema === "object"
? (inlinedSchema as Record<string, unknown>)
: undefined;
if (!schemaRecord) {
return inlinedSchema as TSchema;
}
// Provider quirks:
// - Gemini rejects several JSON Schema keywords, so we scrub those.
// - OpenAI rejects function tool schemas unless the *top-level* is `type: "object"`.
// (TypeBox root unions compile to `{ anyOf: [...] }` without `type`).
// - Anthropic expects full JSON Schema draft 2020-12 compliance.
// - xAI rejects validation-constraint keywords (minLength, maxLength, etc.) outright.
//
// Normalize once here so callers can always pass `tools` through unchanged.
const normalizedProvider = normalizeLowercaseStringOrEmpty(options?.modelProvider);
const normalizedModelId = normalizeLowercaseStringOrEmpty(options?.modelId);
const normalizedToolSchemaProfile = normalizeLowercaseStringOrEmpty(
options?.modelCompat?.toolSchemaProfile,
);
const isGeminiProvider =
normalizedProvider.includes("google") ||
normalizedProvider.includes("gemini") ||
isGeminiModelId(normalizedModelId) ||
normalizedToolSchemaProfile === "gemini";
const isAnthropicProvider = normalizedProvider.includes("anthropic");
const unsupportedToolSchemaKeywords = resolveUnsupportedToolSchemaKeywords(options?.modelCompat);
const omitEmptyArrayItems = shouldOmitEmptyArrayItems(options?.modelCompat);
function applyProviderCleaning(s: unknown): TSchema {
const normalizedSchema = normalizeArraySchemasMissingItems(s);
const arrayItemsCompatibleSchema = omitEmptyArrayItems
? stripEmptyArrayItemsFromArraySchemas(normalizedSchema)
: normalizedSchema;
if (isGeminiProvider && !isAnthropicProvider) {
const geminiCompatibleSchema = cleanSchemaForGemini(arrayItemsCompatibleSchema);
return unsupportedToolSchemaKeywords.size > 0
? (stripUnsupportedSchemaKeywords(
geminiCompatibleSchema,
unsupportedToolSchemaKeywords,
) as TSchema)
: geminiCompatibleSchema;
}
if (unsupportedToolSchemaKeywords.size > 0) {
return stripUnsupportedSchemaKeywords(
arrayItemsCompatibleSchema,
unsupportedToolSchemaKeywords,
) as TSchema;
}
return arrayItemsCompatibleSchema as TSchema;
}
const conditionalKey = getTopLevelConditionalKey(schemaRecord);
const flattenableVariantKey = getFlattenableVariantKey(schemaRecord);
if (hasTopLevelObjectSchema(schemaRecord, conditionalKey)) {
return applyProviderCleaning(schemaRecord);
}
if (isObjectLikeSchemaMissingType(schemaRecord, conditionalKey)) {
return applyProviderCleaning({
...schemaRecord,
type: "object",
properties: isSchemaRecord(schemaRecord.properties) ? schemaRecord.properties : {},
});
}
if (isTypedObjectSchemaMissingValidProperties(schemaRecord, conditionalKey)) {
return applyProviderCleaning({ ...schemaRecord, properties: {} });
}
if (!flattenableVariantKey) {
if (isTrulyEmptySchema(schemaRecord)) {
// Handle the proven MCP no-parameter case: a truly empty schema object.
return applyProviderCleaning({ type: "object", properties: {} });
}
if (conditionalKey === "allOf") {
// Top-level `allOf` is not safely flattenable with the same heuristics we
// use for unions. Keep it explicit rather than silently rewriting it.
return applyProviderCleaning(inlinedSchema);
}
return applyProviderCleaning(inlinedSchema);
}
const variants = schemaRecord[flattenableVariantKey] as unknown[];
const mergedProperties: Record<string, unknown> = {};
const requiredCounts = new Map<string, number>();
let objectVariants = 0;
for (const entry of variants) {
if (!entry || typeof entry !== "object") {
continue;
}
const props = (entry as { properties?: unknown }).properties;
if (!props || typeof props !== "object") {
continue;
}
objectVariants += 1;
for (const [key, value] of Object.entries(props as Record<string, unknown>)) {
if (!(key in mergedProperties)) {
mergedProperties[key] = value;
continue;
}
mergedProperties[key] = mergePropertySchemas(mergedProperties[key], value);
}
const required = Array.isArray((entry as { required?: unknown }).required)
? (entry as { required: unknown[] }).required
: [];
for (const key of required) {
if (typeof key !== "string") {
continue;
}
requiredCounts.set(key, (requiredCounts.get(key) ?? 0) + 1);
}
}
const baseRequired = Array.isArray(schemaRecord.required)
? schemaRecord.required.filter((key) => typeof key === "string")
: undefined;
const mergedRequired =
baseRequired && baseRequired.length > 0
? baseRequired
: objectVariants > 0
? Array.from(requiredCounts.entries())
.filter(([, count]) => count === objectVariants)
.map(([key]) => key)
: undefined;
const nextSchema: Record<string, unknown> = { ...schemaRecord };
const flattenedSchema = {
type: "object",
...(typeof nextSchema.title === "string" ? { title: nextSchema.title } : {}),
...(typeof nextSchema.description === "string" ? { description: nextSchema.description } : {}),
properties:
Object.keys(mergedProperties).length > 0 ? mergedProperties : (schemaRecord.properties ?? {}),
...(mergedRequired && mergedRequired.length > 0 ? { required: mergedRequired } : {}),
additionalProperties:
"additionalProperties" in schemaRecord ? schemaRecord.additionalProperties : true,
};
// Flatten union schemas into a single object schema:
// - Gemini doesn't allow top-level `type` together with `anyOf`.
// - OpenAI rejects schemas without top-level `type: "object"`.
// - Anthropic accepts proper JSON Schema with constraints.
// Merging properties preserves useful enums like `action` while keeping schemas portable.
return applyProviderCleaning(flattenedSchema);
}
/** Return a provider-compatible JSON schema for a model-facing tool. */
export function normalizeToolParameterSchema(
schema: unknown,
options?: ToolParameterSchemaOptions,
): TSchema {
if (!schema || typeof schema !== "object") {
return normalizeToolParameterSchemaUncached(schema, options);
}
const cacheKey = resolveToolParameterSchemaCacheKey(options);
const cached = getCachedToolParameterSchema(schema, cacheKey);
if (cached) {
return cached;
}
return rememberCachedToolParameterSchema(
schema,
cacheKey,
normalizeToolParameterSchemaUncached(schema, options),
);
}

View File

@@ -0,0 +1,38 @@
type AnthropicAuthModel = {
provider?: string;
authHeader?: boolean;
headers?: Record<string, string>;
};
export function usesFoundryBearerAuth(model: AnthropicAuthModel): boolean {
return (
model.provider === "microsoft-foundry" &&
(model.authHeader === true || hasBearerAuthorizationHeader(model.headers))
);
}
function hasBearerAuthorizationHeader(headers?: Record<string, string>): boolean {
if (!headers) {
return false;
}
return Object.entries(headers).some(
([key, value]) => key.toLowerCase() === "authorization" && /^bearer\s+\S+/i.test(value.trim()),
);
}
export function omitFoundryBearerCredentialHeaders(
headers?: Record<string, string>,
): Record<string, string> | undefined {
if (!headers) {
return undefined;
}
const next: Record<string, string> = {};
for (const [key, value] of Object.entries(headers)) {
const lower = key.toLowerCase();
if (lower === "authorization" || lower === "x-api-key" || lower === "api-key") {
continue;
}
next[key] = value;
}
return Object.keys(next).length > 0 ? next : undefined;
}

View File

@@ -0,0 +1,98 @@
// Model-bound thinking cannot be exposed or replayed after a model switch.
import { resolveClaudeFable5ModelIdentity, resolveClaudeModelIdentity } from "@openclaw/llm-core";
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
export {
resolveClaudeFable5ModelIdentity,
resolveClaudeModelIdentity,
resolveClaudeNativeThinkingLevelMap,
supportsClaudeAdaptiveThinking,
supportsClaudeNativeMaxEffort,
supportsClaudeNativeXhighEffort,
} from "@openclaw/llm-core";
type ReplayModelRef = {
provider?: string;
api?: string;
modelId?: string;
responseModelId?: string;
modelParams?: Record<string, unknown>;
};
function normalizeModelId(modelId?: string): string {
const normalized = normalizeLowercaseStringOrEmpty(modelId);
const unprefixed = normalized.startsWith("anthropic/")
? normalized.slice("anthropic/".length)
: normalized;
return unprefixed.replace(/[._\s]+/g, "-");
}
function normalizeApi(api?: string): string {
const normalized = normalizeLowercaseStringOrEmpty(api);
return normalized === "openclaw-anthropic-messages-transport" ? "anthropic-messages" : normalized;
}
function hasConcreteResponseModel(ref: ReplayModelRef): boolean {
const responseModelId = normalizeModelId(ref.responseModelId);
// Deployment APIs may echo the requested alias. Only a different response
// model proves the backing identity and overrides configured metadata.
return responseModelId.length > 0 && responseModelId !== normalizeModelId(ref.modelId);
}
export function usesClaudeFable5MessagesContract(model: {
id?: string;
params?: Record<string, unknown>;
api?: string;
}): boolean {
return (
normalizeApi(model.api) === "anthropic-messages" &&
resolveClaudeFable5ModelIdentity(model) !== undefined
);
}
export function requiresClaudeAdaptiveThinking(model: {
id?: string;
params?: Record<string, unknown>;
api?: string;
}): boolean {
if (normalizeApi(model.api) !== "anthropic-messages") {
return false;
}
const modelId = resolveClaudeModelIdentity(model);
return (
resolveClaudeFable5ModelIdentity(model) !== undefined ||
/(?:^|-)claude-mythos-preview(?=$|[^a-z0-9])/.test(modelId)
);
}
function resolveReplayFableIdentity(ref: ReplayModelRef): string | undefined {
if (normalizeApi(ref.api) !== "anthropic-messages") {
return undefined;
}
if (hasConcreteResponseModel(ref)) {
return resolveClaudeFable5ModelIdentity({ id: ref.responseModelId });
}
return resolveClaudeFable5ModelIdentity({ id: ref.modelId, params: ref.modelParams });
}
export function resolveModelBoundThinkingReplayMode(params: {
source: ReplayModelRef;
target: ReplayModelRef;
}): "default" | "preserve" | "drop" {
const sourceApi = normalizeApi(params.source.api);
const targetApi = normalizeApi(params.target.api);
const sourceIdentity = resolveReplayFableIdentity(params.source);
const targetIdentity = resolveReplayFableIdentity(params.target);
const sameRoute =
normalizeLowercaseStringOrEmpty(params.source.provider) ===
normalizeLowercaseStringOrEmpty(params.target.provider) &&
sourceApi === targetApi &&
normalizeModelId(params.source.modelId) === normalizeModelId(params.target.modelId);
if (!sourceIdentity && !targetIdentity) {
return "default";
}
if (!sourceIdentity && !hasConcreteResponseModel(params.source) && targetIdentity && sameRoute) {
return "preserve";
}
const sameModel = sourceApi === targetApi && sourceIdentity === targetIdentity;
return sameModel ? "preserve" : "drop";
}

View File

@@ -0,0 +1,55 @@
import type { AssistantMessageDiagnostic } from "../types.js";
type AnthropicRefusalOutput = {
stopReason: string;
errorMessage?: string;
diagnostics?: AssistantMessageDiagnostic[];
};
type AnthropicRefusalDetails = {
category: string | null;
explanation: string | null;
};
function readNullableString(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function readAnthropicRefusalDetails(value: unknown): AnthropicRefusalDetails {
if (!value || typeof value !== "object") {
return { category: null, explanation: null };
}
const details = value as Record<string, unknown>;
return {
category: readNullableString(details.category),
explanation: readNullableString(details.explanation),
};
}
function formatAnthropicRefusalMessage(details: AnthropicRefusalDetails): string {
const category = details.category ? ` (category: ${details.category})` : "";
const explanation = details.explanation ? `: ${details.explanation}` : ".";
return `Anthropic refusal${category}${explanation}`;
}
export function applyAnthropicRefusal(
output: AnthropicRefusalOutput,
stopDetails: unknown,
provider: string,
): void {
const details = readAnthropicRefusalDetails(stopDetails);
output.stopReason = "error";
output.errorMessage = formatAnthropicRefusalMessage(details);
output.diagnostics = [
...(output.diagnostics ?? []),
{
type: "provider_refusal",
timestamp: Date.now(),
details: {
provider,
category: details.category,
explanation: details.explanation,
},
},
];
}

View File

@@ -0,0 +1,83 @@
import type { AssistantMessageDiagnostic } from "../types.js";
/** Anthropic beta that re-serves safety refusals on an allowed fallback model. */
export const ANTHROPIC_SERVER_SIDE_FALLBACK_BETA = "server-side-fallback-2026-06-01";
// Anthropic documents claude-opus-4-8 as the allowed fallback for claude-fable-5.
export const CLAUDE_FABLE_5_FALLBACK_MODEL = "claude-opus-4-8";
// Fallback-served turns bill at the serving model's rates.
export const CLAUDE_FABLE_5_FALLBACK_MODEL_COST = {
input: 5,
output: 25,
cacheRead: 0.5,
cacheWrite: 6.25,
} as const;
export function buildAnthropicServerSideFallbacks(): Array<{ model: string }> {
return [{ model: CLAUDE_FABLE_5_FALLBACK_MODEL }];
}
export type AnthropicFallbackBoundary = {
fromModel: string | null;
toModel: string | null;
};
function readBoundaryModel(value: unknown): string | null {
if (!value || typeof value !== "object") {
return null;
}
const model = (value as { model?: unknown }).model;
return typeof model === "string" && model.trim() ? model : null;
}
/** Reads a `fallback` content block marking where one model's output gives way to the next. */
export function readAnthropicFallbackBoundary(block: unknown): AnthropicFallbackBoundary | null {
if (!block || typeof block !== "object") {
return null;
}
const record = block as { type?: unknown; from?: unknown; to?: unknown };
if (record.type !== "fallback") {
return null;
}
return {
fromModel: readBoundaryModel(record.from),
toModel: readBoundaryModel(record.to),
};
}
/**
* Drops pre-fallback thinking/tool calls while preserving the text prefix that
* the serving model continued. Dropped tool calls must never execute or replay.
*/
export function applyAnthropicFallbackBoundary(params: {
output: {
content: Array<{ type: string }>;
responseModel?: string;
diagnostics?: AssistantMessageDiagnostic[];
};
boundary: AnthropicFallbackBoundary;
provider: string;
}): void {
const { output, boundary } = params;
const survivors = output.content.filter((block) => block.type === "text");
for (const survivor of survivors) {
delete (survivor as { textSignature?: string }).textSignature;
}
output.content.splice(0, output.content.length, ...survivors);
if (boundary.toModel) {
output.responseModel = boundary.toModel;
}
output.diagnostics = [
...(output.diagnostics ?? []),
{
type: "provider_fallback",
timestamp: Date.now(),
details: {
provider: params.provider,
fromModel: boundary.fromModel,
toModel: boundary.toModel,
},
},
];
}

View File

@@ -0,0 +1,58 @@
type ReplayMessage = {
role?: unknown;
content?: unknown;
toolCallId?: unknown;
};
export const ANTHROPIC_OMITTED_REASONING_TEXT = "[assistant reasoning omitted]";
function asReplayMessage(value: unknown): ReplayMessage | undefined {
return value && typeof value === "object" ? (value as ReplayMessage) : undefined;
}
/**
* Anthropic tool results continue the preceding assistant turn. Preserve that
* turn's signed thinking even when the next request disables new thinking.
*/
export function findActiveAnthropicToolTurnAssistantIndex(messages: readonly unknown[]): number {
const toolResultIds = new Set<string>();
let index = messages.length - 1;
while (index >= 0) {
const message = asReplayMessage(messages[index]);
if (message?.role !== "toolResult") {
break;
}
if (typeof message.toolCallId === "string") {
toolResultIds.add(message.toolCallId);
}
index -= 1;
}
if (toolResultIds.size === 0) {
return -1;
}
const assistant = asReplayMessage(messages[index]);
if (assistant?.role !== "assistant" || !Array.isArray(assistant.content)) {
return -1;
}
const toolCallIds = new Set<string>();
for (const block of assistant.content) {
if (!block || typeof block !== "object") {
continue;
}
const record = block as { type?: unknown; id?: unknown };
if (
(record.type === "toolCall" ||
record.type === "tool_use" ||
record.type === "function_call") &&
typeof record.id === "string"
) {
toolCallIds.add(record.id);
}
}
return [...toolResultIds].every((toolCallId) => toolCallIds.has(toolCallId)) ? index : -1;
}

View File

@@ -0,0 +1,163 @@
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { projectRuntimeToolInputSchema } from "./tool-schema-json-projection.js";
type AnthropicToolDescriptor = {
readonly name: string;
readonly description: string;
readonly parameters: unknown;
};
type AnthropicProjectedTool = {
readonly originalName: string;
readonly wireName: string;
readonly description?: string;
readonly inputSchema: {
readonly type: "object";
readonly properties: Record<string, unknown>;
readonly required: string[];
};
};
export type AnthropicToolProjection = {
readonly inputToolCount: number;
readonly unavailableOriginalNames: ReadonlySet<string>;
readonly tools: readonly AnthropicProjectedTool[];
};
type AnthropicParallelToolChoice = {
readonly disable_parallel_tool_use?: boolean;
};
export type AnthropicProjectedToolChoice =
| ({ readonly type: "auto" } & AnthropicParallelToolChoice)
| ({ readonly type: "any" } & AnthropicParallelToolChoice)
| { readonly type: "none" }
| ({ readonly type: "tool"; readonly name: string } & AnthropicParallelToolChoice);
function isProviderSupportedViolation(violation: string): boolean {
return violation.endsWith(".$dynamicRef") || violation.endsWith(".$dynamicAnchor");
}
/** Snapshots direct/custom tool descriptors before Anthropic payload construction. */
export function projectAnthropicTools(
tools: readonly AnthropicToolDescriptor[],
toWireName: (name: string) => string,
): AnthropicToolProjection {
const projectedTools: AnthropicProjectedTool[] = [];
const unavailableOriginalNames = new Set<string>();
for (const tool of tools) {
let projectedTool: AnthropicProjectedTool;
let originalName: string | undefined;
try {
const name = tool.name;
originalName = name;
if (!name) {
continue;
}
const schemaProjection = projectRuntimeToolInputSchema(tool.parameters, `${name}.parameters`);
if (
!isRecord(schemaProjection.schema) ||
schemaProjection.violations.some((violation) => !isProviderSupportedViolation(violation))
) {
unavailableOriginalNames.add(name);
continue;
}
const properties = schemaProjection.schema.properties;
const required = schemaProjection.schema.required;
if (
(properties !== undefined && properties !== null && !isRecord(properties)) ||
(required !== undefined &&
required !== null &&
(!Array.isArray(required) || required.some((entry) => typeof entry !== "string")))
) {
unavailableOriginalNames.add(name);
continue;
}
let description: string | undefined;
try {
description = typeof tool.description === "string" ? tool.description : undefined;
} catch {
// Description is optional; keep the usable tool schema.
}
const wireName = toWireName(name);
projectedTool = {
originalName: name,
wireName,
...(description ? { description } : {}),
inputSchema: {
type: "object",
properties: (properties ?? {}) as Record<string, unknown>,
required: (required ?? []) as string[],
},
};
} catch {
// Direct/custom tool arrays can bypass the runtime quarantine.
if (originalName) {
unavailableOriginalNames.add(originalName);
}
continue;
}
const conflictingTool = projectedTools.find(
(entry) => entry.wireName === projectedTool.wireName,
);
if (conflictingTool && conflictingTool.originalName !== projectedTool.originalName) {
throw new Error(
`Anthropic tool names "${conflictingTool.originalName}" and "${projectedTool.originalName}" both map to "${projectedTool.wireName}"`,
);
}
projectedTools.push(projectedTool);
}
return {
inputToolCount: tools.length,
unavailableOriginalNames,
tools: projectedTools,
};
}
/** Keeps forced Anthropic tool choices aligned with the projected wire names. */
export function reconcileAnthropicToolChoice(
choice: AnthropicProjectedToolChoice,
projection: AnthropicToolProjection,
): AnthropicProjectedToolChoice | undefined {
if (projection.inputToolCount === 0) {
return choice;
}
if (choice.type === "tool") {
const requestedName = choice.name;
const originalMatch = projection.tools.find((tool) => tool.originalName === requestedName);
if (originalMatch) {
return { ...choice, name: originalMatch.wireName };
}
if (projection.unavailableOriginalNames.has(requestedName)) {
throw new Error(
`Anthropic tool_choice requested unavailable tool "${requestedName}" after schema conversion`,
);
}
const matchedTool = projection.tools.find((tool) => tool.wireName === requestedName);
if (!matchedTool) {
throw new Error(
`Anthropic tool_choice requested unavailable tool "${requestedName}" after schema conversion`,
);
}
return { ...choice, name: matchedTool.wireName };
}
if (projection.tools.length === 0) {
if (choice.type === "auto") {
return undefined;
}
if (choice.type === "any") {
throw new Error(
"Anthropic tool_choice requires a tool, but no tools survived schema conversion",
);
}
}
return choice;
}
/** Maps Claude Code wire names without trusting every direct/custom descriptor. */
export function resolveOriginalAnthropicToolName(
name: string,
projection: AnthropicToolProjection | undefined,
): string {
return projection?.tools.find((tool) => tool.wireName === name)?.originalName ?? name;
}

View File

@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import { readLastAnthropicIterationUsage } from "./anthropic-usage.js";
describe("readLastAnthropicIterationUsage", () => {
it.each(["message", "compaction", "advisor_message"])(
"reads the final %s iteration as the context snapshot",
(type) => {
expect(
readLastAnthropicIterationUsage({
iterations: [
{
type: "message",
input_tokens: 1,
output_tokens: 2,
cache_read_input_tokens: 3,
cache_creation_input_tokens: 4,
},
{
type,
input_tokens: 12,
output_tokens: 15_104,
cache_read_input_tokens: 148_862,
cache_creation_input_tokens: 0,
},
],
}),
).toEqual({
state: "valid",
usage: {
contextPromptTokens: 148_874,
totalTokens: 163_978,
},
});
},
);
it("reports absent iterations separately from malformed iterations", () => {
expect(readLastAnthropicIterationUsage({ input_tokens: 1 })).toEqual({ state: "absent" });
});
it("does not reuse an earlier iteration when the final iteration is malformed", () => {
expect(
readLastAnthropicIterationUsage({
iterations: [
{
type: "message",
input_tokens: 12,
output_tokens: 15_104,
cache_read_input_tokens: 148_862,
cache_creation_input_tokens: 0,
},
{
type: "message",
input_tokens: "malformed",
output_tokens: 1,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
],
}),
).toEqual({ state: "invalid" });
});
it("rejects a final iteration with incomplete cache usage", () => {
expect(
readLastAnthropicIterationUsage({
iterations: [
{
type: "message",
input_tokens: 12,
output_tokens: 15_104,
},
],
}),
).toEqual({ state: "invalid" });
});
});

View File

@@ -0,0 +1,83 @@
type AnthropicUsagePayload = {
input_tokens?: unknown;
output_tokens?: unknown;
cache_read_input_tokens?: unknown;
cache_creation_input_tokens?: unknown;
iterations?: unknown;
};
export type AnthropicPromptUsageSnapshot = {
input: number;
cacheRead: number;
cacheWrite: number;
};
export type AnthropicIterationUsageSnapshot = {
contextPromptTokens: number;
totalTokens: number;
};
export type AnthropicIterationUsageResult =
| { state: "absent" }
| { state: "invalid" }
| { state: "valid"; usage: AnthropicIterationUsageSnapshot };
export function readAnthropicUsageTokenCount(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
}
export function readAnthropicPromptUsageSnapshot(
usage: AnthropicUsagePayload,
): AnthropicPromptUsageSnapshot | undefined {
const input = readAnthropicUsageTokenCount(usage.input_tokens);
const cacheRead =
usage.cache_read_input_tokens == null
? 0
: readAnthropicUsageTokenCount(usage.cache_read_input_tokens);
const cacheWrite =
usage.cache_creation_input_tokens == null
? 0
: readAnthropicUsageTokenCount(usage.cache_creation_input_tokens);
if (input === undefined || cacheRead === undefined || cacheWrite === undefined) {
return undefined;
}
return { input, cacheRead, cacheWrite };
}
export function readLastAnthropicIterationUsage(
usage: AnthropicUsagePayload,
): AnthropicIterationUsageResult {
if (usage.iterations == null) {
return { state: "absent" };
}
if (!Array.isArray(usage.iterations) || usage.iterations.length === 0) {
return { state: "invalid" };
}
// Anthropic documents the final iteration as the true context window.
// Top-level cache fields remain cumulative billing totals across iterations.
const iteration = usage.iterations.at(-1);
if (!iteration || typeof iteration !== "object" || Array.isArray(iteration)) {
return { state: "invalid" };
}
const record = iteration as AnthropicUsagePayload;
const input = readAnthropicUsageTokenCount(record.input_tokens);
const cacheRead = readAnthropicUsageTokenCount(record.cache_read_input_tokens);
const cacheWrite = readAnthropicUsageTokenCount(record.cache_creation_input_tokens);
const outputTokens = readAnthropicUsageTokenCount(record.output_tokens);
if (
input === undefined ||
cacheRead === undefined ||
cacheWrite === undefined ||
outputTokens === undefined
) {
return { state: "invalid" };
}
const contextPromptTokens = input + cacheRead + cacheWrite;
return {
state: "valid",
usage: {
contextPromptTokens,
totalTokens: contextPromptTokens + outputTokens,
},
};
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,30 @@
// Azure deployment map tests cover model-to-deployment resolution.
import { describe, expect, it } from "vitest";
import {
parseAzureDeploymentNameMap,
resolveAzureDeploymentNameFromMap,
} from "./azure-deployment-map.js";
describe("Azure deployment name map", () => {
it("preserves equals signs inside deployment names", () => {
const map = parseAzureDeploymentNameMap("gpt-5=deployment=blue, ignored, gpt-4 = prod = east ");
expect(map.get("gpt-5")).toBe("deployment=blue");
expect(map.get("gpt-4")).toBe("prod = east");
expect(
resolveAzureDeploymentNameFromMap({
modelId: "gpt-5",
deploymentMap: "gpt-5=deployment=blue",
}),
).toBe("deployment=blue");
});
it("falls back to the model id when the map has no usable entry", () => {
expect(
resolveAzureDeploymentNameFromMap({
modelId: "gpt-5",
deploymentMap: "other=deployment,missing-value=",
}),
).toBe("gpt-5");
});
});

View File

@@ -0,0 +1,32 @@
/** Parses AZURE_OPENAI_DEPLOYMENT_MAP-style model=deployment entries. */
export function parseAzureDeploymentNameMap(value: string | undefined): Map<string, string> {
const map = new Map<string, string>();
if (!value) {
return map;
}
for (const entry of value.split(",")) {
const trimmed = entry.trim();
if (!trimmed) {
continue;
}
const separator = trimmed.indexOf("=");
if (separator <= 0) {
continue;
}
const modelId = trimmed.slice(0, separator).trim();
const deploymentName = trimmed.slice(separator + 1).trim();
if (!modelId || !deploymentName) {
continue;
}
map.set(modelId, deploymentName);
}
return map;
}
/** Resolves the Azure deployment name for a model id, falling back to the model id. */
export function resolveAzureDeploymentNameFromMap(params: {
modelId: string;
deploymentMap?: string;
}): string {
return parseAzureDeploymentNameMap(params.deploymentMap).get(params.modelId) || params.modelId;
}

View File

@@ -0,0 +1,29 @@
export function isTraditionalAzureOpenAIHost(hostname: string): boolean {
return (
hostname.endsWith(".openai.azure.com") || hostname.endsWith(".cognitiveservices.azure.com")
);
}
export function isOpenAICompatibleAzureResponsesBaseUrl(baseUrl: string): boolean {
let url: URL;
try {
url = new URL(baseUrl);
} catch {
return false;
}
if (isTraditionalAzureOpenAIHost(url.hostname)) {
return false;
}
const hostname = url.hostname.toLowerCase();
const isFoundryHost =
hostname.endsWith(".services.ai.azure.com") ||
hostname.endsWith(".api.cognitive.microsoft.com");
if (!isFoundryHost) {
return false;
}
const normalizedPath = url.pathname.replace(/\/+$/, "");
return normalizedPath === "/openai/v1" || normalizedPath.endsWith("/openai/v1");
}

View File

@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import type { Model } from "../types.js";
import { testing } from "./azure-openai-responses.js";
const azureResponsesModel = {
id: "gpt-5.5",
name: "GPT-5.5",
api: "azure-openai-responses",
provider: "azure",
baseUrl: "https://example.openai.azure.com/openai/v1",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200000,
maxTokens: 8192,
} satisfies Model<"azure-openai-responses">;
describe("azure-openai-responses", () => {
it("keeps traditional Azure OpenAI hosts on the AzureOpenAI client path", () => {
const config = testing.resolveAzureConfig(azureResponsesModel, {
azureResourceName: "example",
azureApiVersion: "v1",
});
expect(config).toEqual({
baseUrl: "https://example.openai.azure.com/openai/v1",
apiVersion: "v1",
});
expect(testing.isOpenAICompatibleAzureResponsesBaseUrl(config.baseUrl)).toBe(false);
expect(
testing.isOpenAICompatibleAzureResponsesBaseUrl(
"https://example.cognitiveservices.azure.com/openai/v1",
),
).toBe(false);
});
it("uses the OpenAI-compatible client path for Foundry /openai/v1 endpoints", () => {
expect(
testing.isOpenAICompatibleAzureResponsesBaseUrl(
"https://project.services.ai.azure.com/api/projects/demo/openai/v1",
),
).toBe(true);
expect(
testing.isOpenAICompatibleAzureResponsesBaseUrl(
"https://project.services.ai.azure.com/openai/v1",
),
).toBe(true);
expect(
testing.isOpenAICompatibleAzureResponsesBaseUrl(
"https://eastus.api.cognitive.microsoft.com/openai/v1",
),
).toBe(true);
});
it("does not treat non-v1 custom endpoints as OpenAI-compatible Responses bases", () => {
expect(
testing.isOpenAICompatibleAzureResponsesBaseUrl(
"https://project.services.ai.azure.com/api/projects/demo",
),
).toBe(false);
});
it("keeps private or APIM Azure OpenAI-compatible paths on the AzureOpenAI client path", () => {
expect(testing.isOpenAICompatibleAzureResponsesBaseUrl("https://aoai.internal/openai/v1")).toBe(
false,
);
expect(
testing.isOpenAICompatibleAzureResponsesBaseUrl(
"https://gateway.example.com/proxy/openai/v1",
),
).toBe(false);
});
});

View File

@@ -0,0 +1,252 @@
// Azure OpenAI Responses provider adapts Azure deployments to Responses API streams.
import OpenAI, { AzureOpenAI } from "openai";
import type { ResponseCreateParamsStreaming } from "openai/resources/responses/responses.js";
import { getEnvApiKey } from "../env-api-keys.js";
import { getAiTransportHost } from "../host.js";
import type {
Context,
Model,
SimpleStreamOptions,
StreamFunction,
StreamOptions,
} from "../types.js";
import { AssistantMessageEventStream } from "../utils/event-stream.js";
import { resolveAzureDeploymentNameFromMap } from "./azure-deployment-map.js";
import { isOpenAICompatibleAzureResponsesBaseUrl } from "./azure-openai-responses-client-compat.js";
import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.js";
import {
applyCommonResponsesParams,
convertResponsesMessages,
createResponsesAssistantOutput,
resolveResponsesReasoningEffort,
runResponsesStreamLifecycle,
} from "./openai-responses-shared.js";
import { buildBaseOptions } from "./simple-options.js";
const DEFAULT_AZURE_API_VERSION = "v1";
const AZURE_TOOL_CALL_PROVIDERS = new Set(["openai", "opencode", "azure-openai-responses"]);
function resolveDeploymentName(
model: Model<"azure-openai-responses">,
options?: AzureOpenAIResponsesOptions,
): string {
if (options?.azureDeploymentName) {
return options.azureDeploymentName;
}
return resolveAzureDeploymentNameFromMap({
modelId: model.id,
deploymentMap: process.env.AZURE_OPENAI_DEPLOYMENT_NAME_MAP,
});
}
function formatAzureOpenAIError(error: unknown): string {
if (error instanceof Error) {
const status = (error as Error & { status?: unknown }).status;
const statusCode = typeof status === "number" ? status : undefined;
if (statusCode !== undefined) {
return `Azure OpenAI API error (${statusCode}): ${error.message}`;
}
return error.message;
}
try {
return JSON.stringify(error);
} catch {
return String(error);
}
}
// Azure OpenAI Responses-specific options
export interface AzureOpenAIResponsesOptions extends StreamOptions {
reasoningEffort?: "minimal" | "low" | "medium" | "high" | "xhigh";
reasoningSummary?: "auto" | "detailed" | "concise" | null;
azureApiVersion?: string;
azureResourceName?: string;
azureBaseUrl?: string;
azureDeploymentName?: string;
}
/**
* Generate function for Azure OpenAI Responses API
*/
export const streamAzureOpenAIResponses: StreamFunction<
"azure-openai-responses",
AzureOpenAIResponsesOptions
> = (
model: Model<"azure-openai-responses">,
context: Context,
options?: AzureOpenAIResponsesOptions,
) => {
const stream = new AssistantMessageEventStream();
const output = createResponsesAssistantOutput(model, "azure-openai-responses");
// Start async processing
void runResponsesStreamLifecycle({
stream,
model,
output,
options,
createClient: () => {
const apiKey = options?.apiKey || getEnvApiKey(model.provider) || "";
return createClient(model, apiKey, options);
},
buildParams: () => buildParams(model, context, options, resolveDeploymentName(model, options)),
formatError: formatAzureOpenAIError,
});
return stream;
};
export const streamSimpleAzureOpenAIResponses: StreamFunction<
"azure-openai-responses",
SimpleStreamOptions
> = (model: Model<"azure-openai-responses">, context: Context, options?: SimpleStreamOptions) => {
const apiKey = options?.apiKey || getEnvApiKey(model.provider);
if (!apiKey) {
throw new Error(`No API key for provider: ${model.provider}`);
}
const base = buildBaseOptions(model, options, apiKey);
const reasoningEffort = resolveResponsesReasoningEffort(model, options?.reasoning);
return streamAzureOpenAIResponses(model, context, {
...base,
reasoningEffort: reasoningEffort === "max" ? "xhigh" : reasoningEffort,
} satisfies AzureOpenAIResponsesOptions);
};
function normalizeAzureBaseUrl(baseUrl: string): string {
const trimmed = baseUrl.trim().replace(/\/+$/, "");
let url: URL;
try {
url = new URL(trimmed);
} catch {
throw new Error(`Invalid Azure OpenAI base URL: ${baseUrl}`);
}
const isAzureHost =
url.hostname.endsWith(".openai.azure.com") ||
url.hostname.endsWith(".cognitiveservices.azure.com");
const normalizedPath = url.pathname.replace(/\/+$/, "");
// Ensure Azure hosts have /openai/v1 as base path so the AzureOpenAI SDK
// can append /deployments/<model>/... and ?api-version=v1 correctly.
if (
isAzureHost &&
(normalizedPath === "" || normalizedPath === "/" || normalizedPath === "/openai")
) {
url.pathname = "/openai/v1";
url.search = "";
}
return url.toString().replace(/\/+$/, "");
}
function buildDefaultBaseUrl(resourceName: string): string {
return `https://${resourceName}.openai.azure.com/openai/v1`;
}
function resolveAzureConfig(
model: Model<"azure-openai-responses">,
options?: AzureOpenAIResponsesOptions,
): { baseUrl: string; apiVersion: string } {
const apiVersion =
options?.azureApiVersion || process.env.AZURE_OPENAI_API_VERSION || DEFAULT_AZURE_API_VERSION;
const baseUrl =
options?.azureBaseUrl?.trim() || process.env.AZURE_OPENAI_BASE_URL?.trim() || undefined;
const resourceName = options?.azureResourceName || process.env.AZURE_OPENAI_RESOURCE_NAME;
let resolvedBaseUrl = baseUrl;
if (!resolvedBaseUrl && resourceName) {
resolvedBaseUrl = buildDefaultBaseUrl(resourceName);
}
if (!resolvedBaseUrl && model.baseUrl) {
resolvedBaseUrl = model.baseUrl;
}
if (!resolvedBaseUrl) {
throw new Error(
"Azure OpenAI base URL is required. Set AZURE_OPENAI_BASE_URL or AZURE_OPENAI_RESOURCE_NAME, or pass azureBaseUrl, azureResourceName, or model.baseUrl.",
);
}
return {
baseUrl: normalizeAzureBaseUrl(resolvedBaseUrl),
apiVersion,
};
}
function createClient(
model: Model<"azure-openai-responses">,
apiKeyInput: string,
options?: AzureOpenAIResponsesOptions,
) {
let apiKey = apiKeyInput;
if (!apiKey) {
if (!process.env.AZURE_OPENAI_API_KEY) {
throw new Error(
"Azure OpenAI API key is required. Set AZURE_OPENAI_API_KEY environment variable or pass it as an argument.",
);
}
apiKey = process.env.AZURE_OPENAI_API_KEY;
}
const headers = { ...model.headers };
if (options?.headers) {
Object.assign(headers, options.headers);
}
const { baseUrl, apiVersion } = resolveAzureConfig(model, options);
const guardedFetch = getAiTransportHost().buildModelFetch({ ...model, baseUrl });
if (isOpenAICompatibleAzureResponsesBaseUrl(baseUrl)) {
return new OpenAI({
apiKey,
dangerouslyAllowBrowser: true,
defaultHeaders: headers,
baseURL: baseUrl,
fetch: guardedFetch,
});
}
return new AzureOpenAI({
apiKey,
apiVersion,
dangerouslyAllowBrowser: true,
defaultHeaders: headers,
baseURL: baseUrl,
fetch: guardedFetch,
});
}
function buildParams(
model: Model<"azure-openai-responses">,
context: Context,
options: AzureOpenAIResponsesOptions | undefined,
deploymentName: string,
) {
const messages = convertResponsesMessages(model, context, AZURE_TOOL_CALL_PROVIDERS);
const params: ResponseCreateParamsStreaming = {
model: deploymentName,
input: messages,
stream: true,
prompt_cache_key:
options?.cacheRetention === "none"
? undefined
: clampOpenAIPromptCacheKey(options?.promptCacheKey ?? options?.sessionId),
};
applyCommonResponsesParams(params, model, context, options);
return params;
}
export const testing = {
isOpenAICompatibleAzureResponsesBaseUrl,
normalizeAzureBaseUrl,
resolveAzureConfig,
};

View File

@@ -0,0 +1,15 @@
import type { CacheRetention } from "../types.js";
/**
* Resolve cache retention preference.
* Defaults to "short" and uses OPENCLAW_CACHE_RETENTION for backward compatibility.
*/
export function resolveCacheRetention(cacheRetention?: CacheRetention): CacheRetention {
if (cacheRetention) {
return cacheRetention;
}
if (typeof process !== "undefined" && process.env.OPENCLAW_CACHE_RETENTION === "long") {
return "long";
}
return "short";
}

View File

@@ -0,0 +1,228 @@
// Gemini schema cleaner tests cover OpenAPI-compatible tool schema cleanup for
// Gemini-backed providers before schemas are sent upstream.
import { describe, expect, it } from "vitest";
import { cleanSchemaForGemini } from "./clean-for-gemini.js";
describe("cleanSchemaForGemini", () => {
it("coerces null properties to an empty object", () => {
const cleaned = cleanSchemaForGemini({
type: "object",
properties: null,
}) as { type?: unknown; properties?: unknown };
expect(cleaned.type).toBe("object");
expect(cleaned.properties).toStrictEqual({});
});
it("coerces non-object properties to an empty object", () => {
const cleaned = cleanSchemaForGemini({
type: "object",
properties: "invalid",
}) as { properties?: unknown };
expect(cleaned.properties).toStrictEqual({});
});
it("coerces array properties to an empty object", () => {
const cleaned = cleanSchemaForGemini({
type: "object",
properties: [],
}) as { properties?: unknown };
expect(cleaned.properties).toStrictEqual({});
});
it("filters required fields that are not in properties", () => {
const cleaned = cleanSchemaForGemini({
type: "object",
properties: {
action: { type: "string" },
amount: { type: "number" },
},
required: ["action", "amount", "token"],
}) as { required?: string[] };
expect(cleaned.required).toEqual(["action", "amount"]);
});
it("preserves required when all fields exist in properties", () => {
const cleaned = cleanSchemaForGemini({
type: "object",
properties: {
action: { type: "string" },
amount: { type: "number" },
},
required: ["action", "amount"],
}) as { required?: string[] };
expect(cleaned.required).toEqual(["action", "amount"]);
});
it("removes required entirely when no fields match properties", () => {
const cleaned = cleanSchemaForGemini({
type: "object",
properties: {
action: { type: "string" },
},
required: ["missing_a", "missing_b"],
}) as { required?: string[] };
expect(cleaned.required).toBeUndefined();
});
it("removes required from object schemas when properties is absent", () => {
const cleaned = cleanSchemaForGemini({
type: "object",
required: ["a", "b"],
}) as { required?: string[] };
expect(cleaned.required).toBeUndefined();
});
it("leaves required as-is for non-object schemas when properties is absent", () => {
const cleaned = cleanSchemaForGemini({
type: "array",
required: ["a", "b"],
}) as { required?: string[] };
expect(cleaned.required).toEqual(["a", "b"]);
});
it("filters required in nested object properties", () => {
const cleaned = cleanSchemaForGemini({
type: "object",
properties: {
config: {
type: "object",
properties: {
name: { type: "string" },
},
required: ["name", "ghost"],
},
},
}) as { properties?: { config?: { required?: string[] } } };
expect(cleaned.properties?.config?.required).toEqual(["name"]);
});
it("does not treat inherited keys as declared properties", () => {
const cleaned = cleanSchemaForGemini({
type: "object",
properties: {
name: { type: "string" },
},
required: ["toString", "name"],
}) as { required?: string[] };
expect(cleaned.required).toEqual(["name"]);
});
it("coerces nested null properties while preserving valid siblings", () => {
const cleaned = cleanSchemaForGemini({
type: "object",
properties: {
bad: {
type: "object",
properties: null,
},
good: {
type: "string",
},
},
}) as {
properties?: {
bad?: { properties?: unknown };
good?: { type?: unknown };
};
};
expect(cleaned.properties?.bad?.properties).toStrictEqual({});
expect(cleaned.properties?.good?.type).toBe("string");
});
it("strips empty required arrays", () => {
const cleaned = cleanSchemaForGemini({
type: "object",
properties: {
name: { type: "string" },
},
required: [],
}) as Record<string, unknown>;
expect(cleaned).not.toHaveProperty("required");
expect(cleaned.type).toBe("object");
});
it("preserves non-empty required arrays", () => {
const cleaned = cleanSchemaForGemini({
type: "object",
properties: {
name: { type: "string" },
},
required: ["name"],
}) as Record<string, unknown>;
expect(cleaned.required).toEqual(["name"]);
});
it("strips empty required arrays in nested schemas", () => {
const cleaned = cleanSchemaForGemini({
type: "object",
properties: {
nested: {
type: "object",
properties: {
optional: { type: "string" },
},
required: [],
},
},
required: ["nested"],
}) as { properties?: { nested?: Record<string, unknown> }; required?: string[] };
expect(cleaned.required).toEqual(["nested"]);
expect(cleaned.properties?.nested).not.toHaveProperty("required");
});
it("strips the not keyword from schemas", () => {
// `not` is outside the OpenAPI 3.0 subset accepted by Gemini-backed
// providers and triggers upstream HTTP 400s if left in tool schemas.
const cleaned = cleanSchemaForGemini({
type: "object",
not: { const: true },
properties: {
name: { type: "string" },
},
}) as Record<string, unknown>;
expect(cleaned).not.toHaveProperty("not");
expect(cleaned.type).toBe("object");
expect(cleaned.properties).toEqual({ name: { type: "string" } });
});
it("collapses type arrays by stripping null entries", () => {
// Type arrays like ["string", "null"] must collapse to a scalar OpenAPI
// type for Gemini compatibility.
const cleaned = cleanSchemaForGemini({
type: ["string", "null"],
description: "nullable field",
}) as Record<string, unknown>;
expect(cleaned.type).toBe("string");
expect(cleaned.description).toBe("nullable field");
});
it("collapses type arrays in nested property schemas", () => {
const cleaned = cleanSchemaForGemini({
type: "object",
properties: {
agentId: {
type: ["string", "null"],
description: "Agent id",
},
},
}) as { properties?: { agentId?: Record<string, unknown> } };
expect(cleaned.properties?.agentId?.type).toBe("string");
});
});

View File

@@ -0,0 +1,458 @@
// Cloud Code Assist API rejects a subset of JSON Schema keywords.
// This module scrubs/normalizes tool schemas to keep Gemini happy.
import type { TSchema } from "typebox";
// Keywords that Cloud Code Assist API rejects (not compliant with their JSON Schema subset)
export const GEMINI_UNSUPPORTED_SCHEMA_KEYWORDS = new Set([
"patternProperties",
"additionalProperties",
"$schema",
"$id",
"$ref",
"$defs",
"definitions",
// Non-standard (OpenAPI) keyword; Claude validators reject it.
"examples",
// Cloud Code Assist appears to validate tool schemas more strictly/quirkily than
// draft 2020-12 in practice; these constraints frequently trigger 400s.
"minLength",
"maxLength",
"minimum",
"maximum",
"multipleOf",
"pattern",
"format",
"minItems",
"maxItems",
"uniqueItems",
"minProperties",
"maxProperties",
// JSON Schema composition keywords not supported by OpenAPI 3.0 subset.
// `const` is handled separately (converted to enum) in the cleaning loop,
// but `not` has no safe equivalent and must be stripped.
"not",
]);
const SCHEMA_META_KEYS = ["description", "title", "default"] as const;
function copySchemaMeta(from: Record<string, unknown>, to: Record<string, unknown>): void {
for (const key of SCHEMA_META_KEYS) {
if (key in from && from[key] !== undefined) {
to[key] = from[key];
}
}
}
// Check if an anyOf/oneOf array contains only literal values that can be flattened.
// TypeBox Type.Literal generates { const: "value", type: "string" }.
// Some schemas may use { enum: ["value"], type: "string" }.
// Both patterns are flattened to { type: "string", enum: ["a", "b", ...] }.
function tryFlattenLiteralAnyOf(variants: unknown[]): { type: string; enum: unknown[] } | null {
if (variants.length === 0) {
return null;
}
const allValues: unknown[] = [];
let commonType: string | null = null;
for (const variant of variants) {
if (!variant || typeof variant !== "object") {
return null;
}
const v = variant as Record<string, unknown>;
let literalValue: unknown;
if ("const" in v) {
literalValue = v.const;
} else if (Array.isArray(v.enum) && v.enum.length === 1) {
literalValue = v.enum[0];
} else {
return null;
}
const variantType = typeof v.type === "string" ? v.type : null;
if (!variantType) {
return null;
}
if (commonType === null) {
commonType = variantType;
} else if (commonType !== variantType) {
return null;
}
allValues.push(literalValue);
}
if (commonType && allValues.length > 0) {
return { type: commonType, enum: allValues };
}
return null;
}
function isNullSchema(variant: unknown): boolean {
if (!variant || typeof variant !== "object" || Array.isArray(variant)) {
return false;
}
const record = variant as Record<string, unknown>;
if ("const" in record && record.const === null) {
return true;
}
if (Array.isArray(record.enum) && record.enum.length === 1) {
return record.enum[0] === null;
}
const typeValue = record.type;
if (typeValue === "null") {
return true;
}
if (Array.isArray(typeValue) && typeValue.length === 1 && typeValue[0] === "null") {
return true;
}
return false;
}
function stripNullVariants(variants: unknown[]): {
variants: unknown[];
stripped: boolean;
} {
if (variants.length === 0) {
return { variants, stripped: false };
}
const nonNull = variants.filter((variant) => !isNullSchema(variant));
return {
variants: nonNull,
stripped: nonNull.length !== variants.length,
};
}
type SchemaDefs = Map<string, unknown>;
function extendSchemaDefs(
defs: SchemaDefs | undefined,
schema: Record<string, unknown>,
): SchemaDefs | undefined {
const defsEntry =
schema.$defs && typeof schema.$defs === "object" && !Array.isArray(schema.$defs)
? (schema.$defs as Record<string, unknown>)
: undefined;
const legacyDefsEntry =
schema.definitions &&
typeof schema.definitions === "object" &&
!Array.isArray(schema.definitions)
? (schema.definitions as Record<string, unknown>)
: undefined;
if (!defsEntry && !legacyDefsEntry) {
return defs;
}
const next = defs ? new Map(defs) : new Map<string, unknown>();
if (defsEntry) {
for (const [key, value] of Object.entries(defsEntry)) {
next.set(key, value);
}
}
if (legacyDefsEntry) {
for (const [key, value] of Object.entries(legacyDefsEntry)) {
next.set(key, value);
}
}
return next;
}
function decodeJsonPointerSegment(segment: string): string {
return segment.replaceAll("~1", "/").replaceAll("~0", "~");
}
function tryResolveLocalRef(ref: string, defs: SchemaDefs | undefined): unknown {
if (!defs) {
return undefined;
}
const match = ref.match(/^#\/(?:\$defs|definitions)\/(.+)$/);
if (!match) {
return undefined;
}
const name = decodeJsonPointerSegment(match[1] ?? "");
if (!name) {
return undefined;
}
return defs.get(name);
}
function simplifyUnionVariants(params: { obj: Record<string, unknown>; variants: unknown[] }): {
variants: unknown[];
simplified?: unknown;
} {
const { obj, variants } = params;
const { variants: nonNullVariants, stripped } = stripNullVariants(variants);
const flattened = tryFlattenLiteralAnyOf(nonNullVariants);
if (flattened) {
const result: Record<string, unknown> = {
type: flattened.type,
enum: flattened.enum,
};
copySchemaMeta(obj, result);
return { variants: nonNullVariants, simplified: result };
}
if (stripped && nonNullVariants.length === 1) {
const lone = nonNullVariants[0];
if (lone && typeof lone === "object" && !Array.isArray(lone)) {
const result: Record<string, unknown> = {
...(lone as Record<string, unknown>),
};
copySchemaMeta(obj, result);
return { variants: nonNullVariants, simplified: result };
}
return { variants: nonNullVariants, simplified: lone };
}
return { variants: stripped ? nonNullVariants : variants };
}
// Gemini rejects object schemas whose `required` entries do not exist in `properties`.
function sanitizeRequiredFields(schema: Record<string, unknown>): Record<string, unknown> {
if (!Array.isArray(schema.required)) {
return schema;
}
if (
!schema.properties ||
typeof schema.properties !== "object" ||
Array.isArray(schema.properties)
) {
if (schema.type === "object") {
delete schema.required;
}
return schema;
}
const properties = schema.properties as Record<string, unknown>;
const required = schema.required.filter(
(key): key is string => typeof key === "string" && Object.hasOwn(properties, key),
);
if (required.length > 0) {
schema.required = required;
} else {
delete schema.required;
}
return schema;
}
function cleanSchemaForGeminiWithDefs(
schema: unknown,
defs: SchemaDefs | undefined,
refStack: Set<string> | undefined,
): unknown {
if (!schema || typeof schema !== "object") {
return schema;
}
if (Array.isArray(schema)) {
return schema.map((item) => cleanSchemaForGeminiWithDefs(item, defs, refStack));
}
const obj = schema as Record<string, unknown>;
const nextDefs = extendSchemaDefs(defs, obj);
const refValue = typeof obj.$ref === "string" ? obj.$ref : undefined;
if (refValue) {
if (refStack?.has(refValue)) {
return {};
}
const resolved = tryResolveLocalRef(refValue, nextDefs);
if (resolved) {
const nextRefStack = refStack ? new Set(refStack) : new Set<string>();
nextRefStack.add(refValue);
const cleaned = cleanSchemaForGeminiWithDefs(resolved, nextDefs, nextRefStack);
if (!cleaned || typeof cleaned !== "object" || Array.isArray(cleaned)) {
return cleaned;
}
const result: Record<string, unknown> = {
...(cleaned as Record<string, unknown>),
};
copySchemaMeta(obj, result);
return result;
}
const result: Record<string, unknown> = {};
copySchemaMeta(obj, result);
return result;
}
const hasAnyOf = "anyOf" in obj && Array.isArray(obj.anyOf);
const hasOneOf = "oneOf" in obj && Array.isArray(obj.oneOf);
let cleanedAnyOf = hasAnyOf
? (obj.anyOf as unknown[]).map((variant) =>
cleanSchemaForGeminiWithDefs(variant, nextDefs, refStack),
)
: undefined;
let cleanedOneOf = hasOneOf
? (obj.oneOf as unknown[]).map((variant) =>
cleanSchemaForGeminiWithDefs(variant, nextDefs, refStack),
)
: undefined;
if (hasAnyOf) {
const simplified = simplifyUnionVariants({ obj, variants: cleanedAnyOf ?? [] });
cleanedAnyOf = simplified.variants;
if ("simplified" in simplified) {
return simplified.simplified;
}
}
if (hasOneOf) {
const simplified = simplifyUnionVariants({ obj, variants: cleanedOneOf ?? [] });
cleanedOneOf = simplified.variants;
if ("simplified" in simplified) {
return simplified.simplified;
}
}
const cleaned: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj)) {
if (GEMINI_UNSUPPORTED_SCHEMA_KEYWORDS.has(key)) {
continue;
}
if (key === "const") {
cleaned.enum = [value];
continue;
}
// Google's schema validator rejects `"required": []` — omit empty arrays.
if (key === "required" && Array.isArray(value) && value.length === 0) {
continue;
}
if (key === "type" && (hasAnyOf || hasOneOf)) {
continue;
}
if (
key === "type" &&
Array.isArray(value) &&
value.every((entry) => typeof entry === "string")
) {
const types = value.filter((entry) => entry !== "null");
cleaned.type = types.length === 1 ? types[0] : types;
continue;
}
if (key === "properties") {
if (value && typeof value === "object" && !Array.isArray(value)) {
const props = value as Record<string, unknown>;
cleaned[key] = Object.fromEntries(
Object.entries(props).map(([k, v]) => [
k,
cleanSchemaForGeminiWithDefs(v, nextDefs, refStack),
]),
);
} else {
// Guard malformed schemas (e.g. properties: null) that can trigger
// downstream Object.* crashes in strict provider validators.
cleaned[key] = {};
}
} else if (key === "items" && value) {
if (Array.isArray(value)) {
cleaned[key] = value.map((entry) =>
cleanSchemaForGeminiWithDefs(entry, nextDefs, refStack),
);
} else if (typeof value === "object") {
cleaned[key] = cleanSchemaForGeminiWithDefs(value, nextDefs, refStack);
} else {
cleaned[key] = value;
}
} else if (key === "anyOf" && Array.isArray(value)) {
cleaned[key] =
cleanedAnyOf ??
value.map((variant) => cleanSchemaForGeminiWithDefs(variant, nextDefs, refStack));
} else if (key === "oneOf" && Array.isArray(value)) {
cleaned[key] =
cleanedOneOf ??
value.map((variant) => cleanSchemaForGeminiWithDefs(variant, nextDefs, refStack));
} else if (key === "allOf" && Array.isArray(value)) {
cleaned[key] = value.map((variant) =>
cleanSchemaForGeminiWithDefs(variant, nextDefs, refStack),
);
} else {
cleaned[key] = value;
}
}
// Cloud Code Assist API rejects anyOf/oneOf in nested schemas even after
// simplifyUnionVariants runs above. Flatten remaining unions as a fallback:
// pick the common type or use the first variant's type so the tool
// declaration is accepted by Google's validation layer.
if (cleaned.anyOf && Array.isArray(cleaned.anyOf)) {
const flattened = flattenUnionFallback(cleaned, cleaned.anyOf);
if (flattened) {
return sanitizeRequiredFields(flattened);
}
}
if (cleaned.oneOf && Array.isArray(cleaned.oneOf)) {
const flattened = flattenUnionFallback(cleaned, cleaned.oneOf);
if (flattened) {
return sanitizeRequiredFields(flattened);
}
}
return sanitizeRequiredFields(cleaned);
}
/**
* Last-resort flattening for anyOf/oneOf arrays that could not be simplified
* by `simplifyUnionVariants`. Picks a representative type so the schema is
* accepted by Google's restricted JSON Schema validation.
*/
function flattenUnionFallback(
obj: Record<string, unknown>,
variants: unknown[],
): Record<string, unknown> | undefined {
const objects = variants.filter(
(v): v is Record<string, unknown> => Boolean(v) && typeof v === "object",
);
if (objects.length === 0) {
return undefined;
}
const types = new Set(objects.map((v) => v.type).filter(Boolean));
if (objects.length === 1) {
const merged: Record<string, unknown> = { ...objects[0] };
copySchemaMeta(obj, merged);
return merged;
}
if (types.size === 1) {
const merged: Record<string, unknown> = { type: Array.from(types)[0] };
copySchemaMeta(obj, merged);
return merged;
}
const first = objects[0];
if (first?.type) {
const merged: Record<string, unknown> = { type: first.type };
copySchemaMeta(obj, merged);
return merged;
}
const merged: Record<string, unknown> = {};
copySchemaMeta(obj, merged);
return merged;
}
export function cleanSchemaForGemini(schema: unknown): TSchema {
if (!schema || typeof schema !== "object") {
return schema as TSchema;
}
if (Array.isArray(schema)) {
return schema.map(cleanSchemaForGemini) as TSchema;
}
const defs = extendSchemaDefs(undefined, schema as Record<string, unknown>);
return cleanSchemaForGeminiWithDefs(schema, defs, undefined) as TSchema;
}

View File

@@ -0,0 +1,22 @@
// Cloudflare provider metadata describes Cloudflare-hosted model capabilities.
import type { Model } from "../types.js";
export function isCloudflareProvider(provider: string): boolean {
return provider === "cloudflare-workers-ai" || provider === "cloudflare-ai-gateway";
}
/** Substitute `{VAR}` placeholders in a Cloudflare baseUrl from process.env. */
export function resolveCloudflareBaseUrl(model: Model): string {
const url = model.baseUrl;
if (!url.includes("{")) {
return url;
}
const baseUrl = url.replace(/\{([A-Z_][A-Z0-9_]*)\}/g, (_match, name: string) => {
const value = process.env[name];
if (!value) {
throw new Error(`${name} is required for provider ${model.provider} but is not set.`);
}
return value;
});
return baseUrl;
}

Some files were not shown because too many files have changed in this diff Show More