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