diff --git a/CHANGELOG.md b/CHANGELOG.md index e99a7f73bf97..68d36bbb441b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -93,6 +93,7 @@ Docs: https://docs.openclaw.ai - Gateway/ACP: close child ACP sessions spawned via `sessions_spawn` when their parent session is reset or deleted, instead of leaving orphaned `claude-agent-acp` processes that accumulate and exhaust memory. Fixes #68916. (#85190) Thanks @openperf. - Diagnostics: bound cleanup timeout detail logs, emit drop summaries when async diagnostic bursts exceed the queue cap, and surface async queue drops through diagnostic telemetry. - Agents/subagents: surface blocked child-run completions as errors instead of successful subagent finishes. (#80886) Thanks @TurboTheTurtle. +- Context engines: fail closed with a descriptive error when the selected agent runtime cannot satisfy declared context-engine host requirements. - Agents/Pi: treat accepted embedded `sessions_spawn` child-session handoffs as terminal progress so parent turns no longer report false non-deliverable failures. (#85054) Thanks @samzong. - CLI/models: resolve `openclaw models set` aliases from the runtime config while keeping authored aliases ahead of runtime-only defaults. (#83262) Thanks @IWhatsskill. - WhatsApp: update Baileys to `7.0.0-rc13` and drop the obsolete logger type patch. diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 207fd8598c03..06f3e44af8b5 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -c92444c2028d520c6722798da3a4ec57a530a5fa50218c91022f8f672270c40f plugin-sdk-api-baseline.json -1e8ed9b9c80729ac0ee5ec266bf35388aef9c28c0b34d27d5a10ac8d92fc1af1 plugin-sdk-api-baseline.jsonl +3630f720962d414c3707bce622ca0e20593b58d2365b6a442b0b23463dced6b3 plugin-sdk-api-baseline.json +b2d974246a3f1a6cb40d11f5abfd74df312a3d8937642a3f6ad17191624b67a7 plugin-sdk-api-baseline.jsonl diff --git a/docs/concepts/context-engine.md b/docs/concepts/context-engine.md index ef6ed7e75777..dda087730122 100644 --- a/docs/concepts/context-engine.md +++ b/docs/concepts/context-engine.md @@ -224,6 +224,33 @@ Optional members: | `onSubagentEnded(params)` | Method | Clean up after a subagent ends. | | `dispose()` | Method | Release resources. Called during gateway shutdown or plugin reload - not per-session. | +### Host requirements + +Context engines can declare host capability requirements on `info.hostRequirements`. +OpenClaw checks these requirements before starting the operation and fails closed +with a descriptive error when the selected runtime cannot satisfy them. + +For agent runs, declare `assemble-before-prompt` when the engine must control the +actual model prompt through `assemble()`: + +```ts +info: { + id: "my-context-engine", + name: "My Context Engine", + hostRequirements: { + "agent-run": { + requiredCapabilities: ["assemble-before-prompt"], + unsupportedMessage: + "Use the native Codex or Pi embedded runtime, or select the legacy context engine.", + }, + }, +} +``` + +Native Codex and Pi embedded agent runs satisfy `assemble-before-prompt`. +Generic CLI backends do not, so engines that require it are rejected before the +CLI process starts. + ### ownsCompaction `ownsCompaction` controls whether Pi's built-in in-attempt auto-compaction stays enabled for the run: diff --git a/docs/plugins/codex-harness.md b/docs/plugins/codex-harness.md index 3123a2d1e261..d992d21abf60 100644 --- a/docs/plugins/codex-harness.md +++ b/docs/plugins/codex-harness.md @@ -134,6 +134,10 @@ Lossless remains supported as a context engine. Configure it through `compaction.provider: "lossless-claw"` shape to the Lossless context-engine slot when Codex is the active runtime. +The native Codex app-server harness supports context engines that require +pre-prompt assembly. Generic CLI backends, including `codex-cli`, do not provide +that host capability. + When the active context engine reports `ownsCompaction: true`, `/compact` runs that engine's compaction lifecycle and invalidates the bound Codex app-server thread. The next Codex turn starts a fresh backend thread and rehydrates it from diff --git a/extensions/codex/harness.ts b/extensions/codex/harness.ts index a9cc9f6eb775..03defd1835f6 100644 --- a/extensions/codex/harness.ts +++ b/extensions/codex/harness.ts @@ -1,4 +1,7 @@ -import type { AgentHarness } from "openclaw/plugin-sdk/agent-harness-runtime"; +import type { + AgentHarness, + ContextEngineHostCapability, +} from "openclaw/plugin-sdk/agent-harness-runtime"; import type { CodexAppServerListModelsOptions, CodexAppServerModel, @@ -6,6 +9,15 @@ import type { } from "./src/app-server/models.js"; const DEFAULT_CODEX_HARNESS_PROVIDER_IDS = new Set(["codex"]); +const CODEX_APP_SERVER_CONTEXT_ENGINE_HOST_CAPABILITIES = [ + "bootstrap", + "assemble-before-prompt", + "after-turn", + "maintain", + "compact", + "runtime-llm-complete", + "thread-bootstrap-projection", +] as const satisfies readonly ContextEngineHostCapability[]; export type { CodexAppServerListModelsOptions, CodexAppServerModel, CodexAppServerModelListResult }; @@ -24,6 +36,7 @@ export function createCodexAppServerAgentHarness(options?: { return { id: options?.id ?? "codex", label: options?.label ?? "Codex agent harness", + contextEngineHostCapabilities: CODEX_APP_SERVER_CONTEXT_ENGINE_HOST_CAPABILITIES, deliveryDefaults: { sourceVisibleReplies: "message_tool", }, diff --git a/extensions/codex/src/app-server/run-attempt.ts b/extensions/codex/src/app-server/run-attempt.ts index 3904ca9f19dd..84709aa503e0 100644 --- a/extensions/codex/src/app-server/run-attempt.ts +++ b/extensions/codex/src/app-server/run-attempt.ts @@ -4,11 +4,13 @@ import fs from "node:fs/promises"; import path from "node:path"; import { assembleHarnessContextEngine, + assertContextEngineHostSupport, bootstrapHarnessContextEngine, buildAgentHookContextChannelFields, buildHarnessContextEngineRuntimeContext, buildHarnessContextEngineRuntimeContextFromUsage, buildEmbeddedAttemptToolRunContext, + CODEX_APP_SERVER_CONTEXT_ENGINE_HOST, clearActiveEmbeddedRun, compactContextEngineWithSafetyTimeout, embeddedAgentLog, @@ -961,6 +963,13 @@ export async function runCodexAppServerAttempt( const activeContextEngine = isActiveHarnessContextEngine(params.contextEngine) ? params.contextEngine : undefined; + if (activeContextEngine) { + assertContextEngineHostSupport({ + contextEngine: activeContextEngine, + operation: "agent-run", + host: CODEX_APP_SERVER_CONTEXT_ENGINE_HOST, + }); + } const hookChannelId = resolveCodexAppServerHookChannelId(params, sandboxSessionKey); let yieldDetected = false; const tools = await buildDynamicTools({ diff --git a/src/agents/cli-backends.ts b/src/agents/cli-backends.ts index ab11363180f2..4ac61ec25fa0 100644 --- a/src/agents/cli-backends.ts +++ b/src/agents/cli-backends.ts @@ -1,5 +1,6 @@ import type { CliBackendConfig } from "../config/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { ContextEngineHostCapability } from "../context-engine/types.js"; import { resolveRuntimeCliBackends } from "../plugins/cli-backends.runtime.js"; import { resolvePluginSetupCliBackend } from "../plugins/setup-registry.js"; import { resolveRuntimeTextTransforms } from "../plugins/text-transforms.runtime.js"; @@ -37,6 +38,7 @@ export type ResolvedCliBackend = { textTransforms?: PluginTextTransforms; defaultAuthProfileId?: string; authEpochMode?: CliBackendAuthEpochMode; + contextEngineHostCapabilities?: readonly ContextEngineHostCapability[]; prepareExecution?: CliBackendPlugin["prepareExecution"]; resolveExecutionArgs?: CliBackendPlugin["resolveExecutionArgs"]; nativeToolMode?: CliBackendNativeToolMode; @@ -62,6 +64,7 @@ type FallbackCliBackendPolicy = { textTransforms?: PluginTextTransforms; defaultAuthProfileId?: string; authEpochMode?: CliBackendAuthEpochMode; + contextEngineHostCapabilities?: readonly ContextEngineHostCapability[]; prepareExecution?: CliBackendPlugin["prepareExecution"]; resolveExecutionArgs?: CliBackendPlugin["resolveExecutionArgs"]; nativeToolMode?: CliBackendNativeToolMode; @@ -100,6 +103,7 @@ function resolveSetupCliBackendPolicy(provider: string): FallbackCliBackendPolic textTransforms: entry.backend.textTransforms, defaultAuthProfileId: entry.backend.defaultAuthProfileId, authEpochMode: entry.backend.authEpochMode, + contextEngineHostCapabilities: entry.backend.contextEngineHostCapabilities, prepareExecution: entry.backend.prepareExecution, resolveExecutionArgs: entry.backend.resolveExecutionArgs, nativeToolMode: entry.backend.nativeToolMode, @@ -239,6 +243,7 @@ export function resolveCliBackendConfig( textTransforms: mergePluginTextTransforms(runtimeTextTransforms, registered.textTransforms), defaultAuthProfileId: registered.defaultAuthProfileId, authEpochMode: registered.authEpochMode, + contextEngineHostCapabilities: registered.contextEngineHostCapabilities, prepareExecution: registered.prepareExecution, resolveExecutionArgs: registered.resolveExecutionArgs, nativeToolMode: registered.nativeToolMode, @@ -269,6 +274,7 @@ export function resolveCliBackendConfig( ), defaultAuthProfileId: fallbackPolicy.defaultAuthProfileId, authEpochMode: fallbackPolicy.authEpochMode, + contextEngineHostCapabilities: fallbackPolicy.contextEngineHostCapabilities, prepareExecution: fallbackPolicy.prepareExecution, resolveExecutionArgs: fallbackPolicy.resolveExecutionArgs, nativeToolMode: fallbackPolicy.nativeToolMode, @@ -296,6 +302,7 @@ export function resolveCliBackendConfig( ), defaultAuthProfileId: fallbackPolicy?.defaultAuthProfileId, authEpochMode: fallbackPolicy?.authEpochMode, + contextEngineHostCapabilities: fallbackPolicy?.contextEngineHostCapabilities, prepareExecution: fallbackPolicy?.prepareExecution, resolveExecutionArgs: fallbackPolicy?.resolveExecutionArgs, nativeToolMode: fallbackPolicy?.nativeToolMode, diff --git a/src/agents/cli-runner/prepare.test.ts b/src/agents/cli-runner/prepare.test.ts index cd3389f26786..28676502c972 100644 --- a/src/agents/cli-runner/prepare.test.ts +++ b/src/agents/cli-runner/prepare.test.ts @@ -699,6 +699,51 @@ describe("shouldSkipLocalCliCredentialEpoch", () => { } }); + it("rejects CLI runs for context engines that require pre-prompt assembly", async () => { + const { dir, sessionFile } = createSessionFile(); + const engineId = `cli-unsupported-engine-${Date.now().toString(36)}`; + registerContextEngine(engineId, (): ContextEngine => { + return { + info: { + id: engineId, + name: "CLI unsupported engine", + hostRequirements: { + "agent-run": { + requiredCapabilities: ["assemble-before-prompt"], + unsupportedMessage: "Use the native Codex or Pi embedded runtime.", + }, + }, + }, + ingest: vi.fn(async () => ({ ingested: true })), + assemble: vi.fn(async ({ messages }) => ({ messages, estimatedTokens: 0 })), + compact: vi.fn(async () => ({ ok: true, compacted: false })), + }; + }); + + try { + await expect( + prepareCliRunContext({ + sessionId: "session-test", + sessionFile, + workspaceDir: dir, + prompt: "latest ask", + provider: "test-cli", + model: "test-model", + timeoutMs: 1_000, + runId: "run-test-context-engine-host-compat", + config: { + ...createCliBackendConfig(), + plugins: { slots: { contextEngine: engineId } }, + }, + }), + ).rejects.toThrow( + `Context engine "${engineId}" cannot run operation "agent-run" on CLI backend "test-cli".`, + ); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + it("uses runtime config when resolving the CLI context engine", async () => { const { dir, sessionFile } = createSessionFile(); const engineId = `cli-runtime-config-engine-${Date.now().toString(36)}`; diff --git a/src/agents/cli-runner/prepare.ts b/src/agents/cli-runner/prepare.ts index 2df790fbb0f1..a94a836d3171 100644 --- a/src/agents/cli-runner/prepare.ts +++ b/src/agents/cli-runner/prepare.ts @@ -1,4 +1,8 @@ import { getRuntimeConfig } from "../../config/config.js"; +import { + assertContextEngineHostSupport, + buildGenericCliContextEngineHostSupport, +} from "../../context-engine/host-compat.js"; import { ensureContextEnginesInitialized } from "../../context-engine/init.js"; import { resolveContextEngine } from "../../context-engine/registry.js"; import { ensureMcpLoopbackServer } from "../../gateway/mcp-http.js"; @@ -513,6 +517,16 @@ export async function prepareCliRunContext( }); const contextEngine = resolvedContextEngine.info.id !== "legacy" ? resolvedContextEngine : undefined; + if (contextEngine) { + assertContextEngineHostSupport({ + contextEngine, + operation: "agent-run", + host: buildGenericCliContextEngineHostSupport({ + backendId: backendResolved.id, + capabilities: backendResolved.contextEngineHostCapabilities, + }), + }); + } const hadSessionFile = await hasCliSessionTranscript({ sessionId: params.sessionId, sessionFile: params.sessionFile, diff --git a/src/agents/harness/builtin-pi.ts b/src/agents/harness/builtin-pi.ts index fbab6b115c00..25dd1c1eada5 100644 --- a/src/agents/harness/builtin-pi.ts +++ b/src/agents/harness/builtin-pi.ts @@ -1,3 +1,4 @@ +import { PI_EMBEDDED_CONTEXT_ENGINE_HOST } from "../../context-engine/host-compat.js"; import { runEmbeddedAttempt } from "../pi-embedded-runner/run/attempt.js"; import type { AgentHarness } from "./types.js"; @@ -5,6 +6,7 @@ export function createPiAgentHarness(): AgentHarness { return { id: "pi", label: "PI embedded agent", + contextEngineHostCapabilities: PI_EMBEDDED_CONTEXT_ENGINE_HOST.capabilities, supports: () => ({ supported: true, priority: 0 }), runAttempt: runEmbeddedAttempt, }; diff --git a/src/agents/harness/selection.test.ts b/src/agents/harness/selection.test.ts index 94b430d45344..a7bf134fae09 100644 --- a/src/agents/harness/selection.test.ts +++ b/src/agents/harness/selection.test.ts @@ -1,6 +1,7 @@ import type { Api, Model } from "@earendil-works/pi-ai"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; +import type { ContextEngine } from "../../context-engine/types.js"; import type { EmbeddedRunAttemptParams, EmbeddedRunAttemptResult, @@ -21,6 +22,14 @@ vi.mock("./builtin-pi.js", () => ({ createPiAgentHarness: (): AgentHarness => ({ id: "pi", label: "PI embedded agent", + contextEngineHostCapabilities: [ + "bootstrap", + "assemble-before-prompt", + "after-turn", + "maintain", + "compact", + "runtime-llm-complete", + ], supports: () => ({ supported: true, priority: 0 }), runAttempt: piRunAttempt, }), @@ -86,6 +95,29 @@ function createAttemptResult(sessionIdUsed: string): EmbeddedRunAttemptResult { }; } +function createContextEngineRequiringAssembly(): ContextEngine { + return { + info: { + id: "lossless-claw", + name: "Lossless", + hostRequirements: { + "agent-run": { + requiredCapabilities: ["assemble-before-prompt"], + }, + }, + }, + async ingest() { + return { ingested: true }; + }, + async assemble({ messages }) { + return { messages, estimatedTokens: 0 }; + }, + async compact() { + return { ok: true, compacted: false }; + }, + }; +} + function registerFailingCodexHarness(): void { registerAgentHarness( { @@ -203,6 +235,16 @@ describe("runAgentHarnessAttempt", () => { expect(piRunAttempt).toHaveBeenCalledTimes(1); }); + it("allows the selected PI harness to satisfy context-engine pre-prompt assembly", async () => { + const result = await runAgentHarnessAttempt({ + ...createAttemptParams(providerRuntimeConfig("codex", "pi")), + contextEngine: createContextEngineRequiringAssembly(), + }); + + expect(result.sessionIdUsed).toBe("pi"); + expect(piRunAttempt).toHaveBeenCalledTimes(1); + }); + it("surfaces an auto-selected plugin harness failure instead of replaying through PI", async () => { registerFailingCodexHarness(); diff --git a/src/agents/harness/types.ts b/src/agents/harness/types.ts index 4b9dfc83383a..934a6aa9001a 100644 --- a/src/agents/harness/types.ts +++ b/src/agents/harness/types.ts @@ -69,6 +69,12 @@ export type AgentHarness = { id: string; label: string; pluginId?: string; + /** + * Context-engine host capabilities provided by this harness during agent + * runs. Harnesses that omit this are unsupported for engines that declare + * host requirements. + */ + contextEngineHostCapabilities?: readonly import("../../context-engine/types.js").ContextEngineHostCapability[]; deliveryDefaults?: AgentHarnessDeliveryDefaults; supports(ctx: AgentHarnessSupportContext): AgentHarnessSupport; runAttempt(params: AgentHarnessAttemptParams): Promise; diff --git a/src/agents/harness/v2.test.ts b/src/agents/harness/v2.test.ts index 9a951feeaa9c..8bcc6f872db7 100644 --- a/src/agents/harness/v2.test.ts +++ b/src/agents/harness/v2.test.ts @@ -1,5 +1,7 @@ import type { Api, Model } from "@earendil-works/pi-ai"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { PI_EMBEDDED_CONTEXT_ENGINE_HOST } from "../../context-engine/host-compat.js"; +import type { ContextEngine } from "../../context-engine/types.js"; import { onInternalDiagnosticEvent, resetDiagnosticEventsForTest, @@ -7,6 +9,7 @@ import { type DiagnosticEventPayload, } from "../../infra/diagnostic-events.js"; import type { EmbeddedRunAttemptResult } from "../pi-embedded-runner/run/types.js"; +import { createPiAgentHarness } from "./builtin-pi.js"; import type { AgentHarness, AgentHarnessAttemptParams } from "./types.js"; import type { AgentHarnessV2 } from "./v2.js"; import { adaptAgentHarnessToV2, runAgentHarnessV2LifecycleAttempt } from "./v2.js"; @@ -66,6 +69,29 @@ function createAttemptResult(): EmbeddedRunAttemptResult { }; } +function createContextEngineRequiringAssembly(): ContextEngine { + return { + info: { + id: "lossless-claw", + name: "Lossless", + hostRequirements: { + "agent-run": { + requiredCapabilities: ["assemble-before-prompt"], + }, + }, + }, + async ingest() { + return { ingested: true }; + }, + async assemble({ messages }) { + return { messages, estimatedTokens: 0 }; + }, + async compact() { + return { ok: true, compacted: false }; + }, + }; +} + async function flushDiagnosticEvents(): Promise { await new Promise((resolve) => setImmediate(resolve)); } @@ -150,6 +176,51 @@ describe("AgentHarness V2 compatibility adapter", () => { ]); }); + it("rejects V1-adapted harnesses that do not advertise required context-engine capabilities", async () => { + const params = createAttemptParams(); + params.contextEngine = createContextEngineRequiringAssembly(); + const runAttempt = vi.fn(async () => createAttemptResult()); + const harness = adaptAgentHarnessToV2({ + id: "custom", + label: "Custom", + supports: () => ({ supported: true }), + runAttempt, + }); + + await expect(runAgentHarnessV2LifecycleAttempt(harness, params)).rejects.toThrow( + 'Context engine "lossless-claw" cannot run operation "agent-run" on agent harness "custom".', + ); + expect(runAttempt).not.toHaveBeenCalled(); + }); + + it("allows V1-adapted harnesses that advertise required context-engine capabilities", async () => { + const params = createAttemptParams(); + params.contextEngine = createContextEngineRequiringAssembly(); + const result = createAttemptResult(); + const runAttempt = vi.fn(async () => result); + const harness = adaptAgentHarnessToV2({ + id: "codex", + label: "Codex", + contextEngineHostCapabilities: ["assemble-before-prompt"], + supports: () => ({ supported: true }), + runAttempt, + }); + + await expect(runAgentHarnessV2LifecycleAttempt(harness, params)).resolves.toEqual({ + ...result, + agentHarnessId: "codex", + }); + expect(runAttempt).toHaveBeenCalledOnce(); + }); + + it("advertises Pi embedded host capabilities through the V1 adapter", async () => { + const harness = createPiAgentHarness(); + + expect(harness.contextEngineHostCapabilities).toEqual( + PI_EMBEDDED_CONTEXT_ENGINE_HOST.capabilities, + ); + }); + it("emits trusted harness lifecycle diagnostics for successful attempts", async () => { resetDiagnosticEventsForTest(); const params = createAttemptParams(); diff --git a/src/agents/harness/v2.ts b/src/agents/harness/v2.ts index bf14a314cdc4..d6f58c84ded5 100644 --- a/src/agents/harness/v2.ts +++ b/src/agents/harness/v2.ts @@ -1,3 +1,7 @@ +import { + assertContextEngineHostSupport, + type ContextEngineHostSupport, +} from "../../context-engine/host-compat.js"; import { diagnosticErrorCategory } from "../../infra/diagnostic-error-metadata.js"; import { emitTrustedDiagnosticEvent, @@ -27,6 +31,7 @@ type AgentHarnessV2RunBase = { label: string; pluginId?: string; params: AgentHarnessAttemptParams; + contextEngineHost?: ContextEngineHostSupport; }; export type AgentHarnessV2PreparedRun = AgentHarnessV2RunBase & { @@ -81,6 +86,7 @@ export function adaptAgentHarnessToV2(harness: AgentHarness): AgentHarnessV2 { label: harness.label, pluginId: harness.pluginId, params, + contextEngineHost: buildAgentHarnessContextEngineHostSupport(harness), lifecycleState: "prepared", }), start: async (prepared) => ({ @@ -88,9 +94,19 @@ export function adaptAgentHarnessToV2(harness: AgentHarness): AgentHarnessV2 { label: prepared.label, pluginId: prepared.pluginId, params: prepared.params, + contextEngineHost: prepared.contextEngineHost, lifecycleState: "started", }), - send: async (session) => harness.runAttempt(session.params), + send: async (session) => { + if (session.params.contextEngine && session.params.contextEngine.info.id !== "legacy") { + assertContextEngineHostSupport({ + contextEngine: session.params.contextEngine, + operation: "agent-run", + host: session.contextEngineHost ?? buildAgentHarnessContextEngineHostSupport(harness), + }); + } + return harness.runAttempt(session.params); + }, resolveOutcome: async (session, result) => applyAgentHarnessResultClassification(harness, result, session.params), cleanup: async (_params) => { @@ -103,6 +119,16 @@ export function adaptAgentHarnessToV2(harness: AgentHarness): AgentHarnessV2 { }; } +function buildAgentHarnessContextEngineHostSupport( + harness: AgentHarness, +): ContextEngineHostSupport { + return { + id: `agent-harness:${harness.id}`, + label: `agent harness "${harness.id}"`, + capabilities: harness.contextEngineHostCapabilities ?? [], + }; +} + function agentHarnessDiagnosticBase( harness: AgentHarnessV2, params: AgentHarnessAttemptParams, diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 620cb28f87f0..262d1702b6ae 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -19,6 +19,10 @@ import { bindOwnedSessionTranscriptWrites, withOwnedSessionTranscriptWrites, } from "../../../config/sessions/transcript-write-context.js"; +import { + assertContextEngineHostSupport, + PI_EMBEDDED_CONTEXT_ENGINE_HOST, +} from "../../../context-engine/host-compat.js"; import { resolveContextEngineOwnerPluginId } from "../../../context-engine/registry.js"; import type { AssembleResult } from "../../../context-engine/types.js"; import { emitTrustedDiagnosticEvent } from "../../../infra/diagnostic-events.js"; @@ -1284,6 +1288,13 @@ export async function runEmbeddedAttempt( ); } const activeContextEngine = isRawModelRun ? undefined : params.contextEngine; + if (activeContextEngine && activeContextEngine.info.id !== "legacy") { + assertContextEngineHostSupport({ + contextEngine: activeContextEngine, + operation: "agent-run", + host: PI_EMBEDDED_CONTEXT_ENGINE_HOST, + }); + } const activeContextEnginePluginId = resolveContextEngineOwnerPluginId(activeContextEngine); const agentDir = params.agentDir ?? resolveAgentDir(params.config ?? {}, sessionAgentId); const diagnosticTrace = freezeDiagnosticTraceContext( diff --git a/src/commands/doctor-config-flow.test.ts b/src/commands/doctor-config-flow.test.ts index 77d27dbbe4ca..43e22ec5ff88 100644 --- a/src/commands/doctor-config-flow.test.ts +++ b/src/commands/doctor-config-flow.test.ts @@ -864,6 +864,7 @@ vi.mock("./doctor/shared/legacy-config-issues.js", async () => { }); vi.mock("../plugins/setup-registry.js", () => ({ + resolvePluginSetupCliBackend: vi.fn(() => undefined), resolvePluginSetupAutoEnableReasons: vi.fn(() => []), runPluginSetupConfigMigrations: vi.fn(({ config }: { config: unknown }) => ({ config, diff --git a/src/commands/doctor/repair-sequencing.ts b/src/commands/doctor/repair-sequencing.ts index cf79df7f2932..e06ac5d38958 100644 --- a/src/commands/doctor/repair-sequencing.ts +++ b/src/commands/doctor/repair-sequencing.ts @@ -17,6 +17,7 @@ import { applyDoctorConfigMutation, type DoctorConfigMutationState, } from "./shared/config-mutation-state.js"; +import { maybeRepairContextEngineHostCompatibility } from "./shared/context-engine-host-compat.js"; import { scanEmptyAllowlistPolicyWarnings } from "./shared/empty-allowlist-scan.js"; import { maybeRepairExecSafeBinProfiles } from "./shared/exec-safe-bins.js"; import { maybeRepairInvalidPluginConfig } from "./shared/invalid-plugin-config.js"; @@ -90,6 +91,13 @@ export async function runDoctorRepairSequence(params: { changes: codexRouteRepair.changes, warnings: codexRouteRepair.warnings, }); + applyMutation( + await maybeRepairContextEngineHostCompatibility({ + cfg: state.candidate, + doctorFixCommand: params.doctorFixCommand, + env, + }), + ); const missingConfiguredPluginInstallRepair = await repairMissingConfiguredPluginInstalls({ cfg: state.candidate, env, diff --git a/src/commands/doctor/shared/context-engine-host-compat.test.ts b/src/commands/doctor/shared/context-engine-host-compat.test.ts new file mode 100644 index 000000000000..72f505f69280 --- /dev/null +++ b/src/commands/doctor/shared/context-engine-host-compat.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../../../config/types.openclaw.js"; +import { registerContextEngine } from "../../../context-engine/registry.js"; +import type { ContextEngine, ContextEngineHostCapability } from "../../../context-engine/types.js"; +import { + collectConfiguredContextEngineAgentRunHosts, + collectContextEngineHostCompatibilityWarnings, + maybeRepairContextEngineHostCompatibility, +} from "./context-engine-host-compat.js"; + +let engineCounter = 0; + +function uniqueEngineId(): string { + engineCounter += 1; + return `doctor-host-compat-${engineCounter}`; +} + +function registerEngine(requiredCapabilities: ContextEngineHostCapability[]): string { + const id = uniqueEngineId(); + const engine: ContextEngine = { + info: { + id, + name: "Doctor Host Compat", + hostRequirements: + requiredCapabilities.length > 0 + ? { + "agent-run": { + requiredCapabilities, + unsupportedMessage: "Use a compatible runtime or switch to legacy.", + }, + } + : undefined, + }, + async ingest() { + return { ingested: true }; + }, + async assemble({ messages }) { + return { messages, estimatedTokens: 0 }; + }, + async compact() { + return { ok: true, compacted: false }; + }, + }; + registerContextEngine(id, () => engine); + return id; +} + +function configWithEngine(engineId: string, cfg: OpenClawConfig = {}): OpenClawConfig { + return { + ...cfg, + plugins: { + ...cfg.plugins, + slots: { + ...cfg.plugins?.slots, + contextEngine: engineId, + }, + }, + }; +} + +describe("doctor context-engine host compatibility", () => { + it("collects native Codex and Pi as compatible agent-run hosts", () => { + const hosts = collectConfiguredContextEngineAgentRunHosts({ + cfg: { + agents: { + defaults: { + models: { + "openai/gpt-5.5": { agentRuntime: { id: "codex" } }, + "anthropic/claude-sonnet-4-6": { agentRuntime: { id: "pi" } }, + }, + }, + }, + }, + }); + + expect(hosts.map((host) => host.host.id).toSorted()).toEqual([ + "codex-app-server", + "pi-embedded", + ]); + }); + + it("does not warn for context engines without host requirements", async () => { + const engineId = registerEngine([]); + const warnings = await collectContextEngineHostCompatibilityWarnings({ + cfg: configWithEngine(engineId, { + agents: { + defaults: { + model: "anthropic/claude-sonnet-4-6", + models: { + "anthropic/claude-sonnet-4-6": { agentRuntime: { id: "claude-cli" } }, + }, + }, + }, + }), + doctorFixCommand: "openclaw doctor --fix", + }); + + expect(warnings).toEqual([]); + }); + + it("repairs an incompatible context engine by switching the global slot to legacy", async () => { + const engineId = registerEngine(["assemble-before-prompt"]); + const result = await maybeRepairContextEngineHostCompatibility({ + cfg: configWithEngine(engineId, { + agents: { + defaults: { + model: "anthropic/claude-sonnet-4-6", + models: { + "anthropic/claude-sonnet-4-6": { agentRuntime: { id: "claude-cli" } }, + }, + }, + }, + }), + doctorFixCommand: "openclaw doctor --fix", + }); + + expect(result.config.plugins?.slots?.contextEngine).toBe("legacy"); + expect(result.changes).toEqual([ + `Set plugins.slots.contextEngine to "legacy" because context engine "${engineId}" is incompatible with every configured agent-run host.`, + ]); + }); + + it("leaves compatible native runtimes unchanged", async () => { + const engineId = registerEngine(["assemble-before-prompt", "runtime-llm-complete"]); + const cfg = configWithEngine(engineId, { + agents: { + defaults: { + models: { + "openai/gpt-5.5": { agentRuntime: { id: "codex" } }, + }, + }, + }, + }); + const result = await maybeRepairContextEngineHostCompatibility({ + cfg, + doctorFixCommand: "openclaw doctor --fix", + }); + + expect(result.config).toBe(cfg); + expect(result.changes).toEqual([]); + }); + + it("warns but does not auto-repair mixed compatible and incompatible runtimes", async () => { + const engineId = registerEngine(["assemble-before-prompt"]); + const cfg = configWithEngine(engineId, { + agents: { + defaults: { + models: { + "openai/gpt-5.5": { agentRuntime: { id: "codex" } }, + "anthropic/claude-sonnet-4-6": { agentRuntime: { id: "claude-cli" } }, + }, + }, + }, + }); + const result = await maybeRepairContextEngineHostCompatibility({ + cfg, + doctorFixCommand: "openclaw doctor --fix", + }); + + expect(result.config).toBe(cfg); + expect(result.changes).toEqual([]); + expect(result.warnings?.join("\n")).toContain( + "Some configured runtimes support context engine", + ); + }); +}); diff --git a/src/commands/doctor/shared/context-engine-host-compat.ts b/src/commands/doctor/shared/context-engine-host-compat.ts new file mode 100644 index 000000000000..ce8d557d278f --- /dev/null +++ b/src/commands/doctor/shared/context-engine-host-compat.ts @@ -0,0 +1,429 @@ +import { resolveDefaultAgentDir } from "../../../agents/agent-scope-config.js"; +import { resolveCliBackendConfig } from "../../../agents/cli-backends.js"; +import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../../../agents/defaults.js"; +import { resolveAgentHarnessPolicy } from "../../../agents/harness/policy.js"; +import { getRegisteredAgentHarness } from "../../../agents/harness/registry.js"; +import { normalizeEmbeddedAgentRuntime } from "../../../agents/pi-embedded-runner/runtime.js"; +import { normalizeProviderId } from "../../../agents/provider-id.js"; +import type { OpenClawConfig } from "../../../config/types.openclaw.js"; +import { + buildGenericCliContextEngineHostSupport, + CODEX_APP_SERVER_CONTEXT_ENGINE_HOST, + evaluateContextEngineHostSupport, + PI_EMBEDDED_CONTEXT_ENGINE_HOST, + type ContextEngineHostSupport, +} from "../../../context-engine/host-compat.js"; +import { ensureContextEnginesInitialized } from "../../../context-engine/init.js"; +import { getContextEngineFactory, resolveContextEngine } from "../../../context-engine/registry.js"; +import type { ContextEngineInfo } from "../../../context-engine/types.js"; +import { ensurePluginRegistryLoaded } from "../../../plugins/runtime/runtime-registry-loader.js"; +import { defaultSlotIdForKey } from "../../../plugins/slots.js"; +import { isRecord, resolveUserPath } from "../../../utils.js"; + +export type HostCandidate = { + runtimeId: string; + host: ContextEngineHostSupport; + paths: string[]; +}; + +type HostCompatibilityIssue = { + candidate: HostCandidate; + missingCapabilities: string[]; + requiredCapabilities: string[]; +}; + +type ContextEngineInfoResult = + | { info: ContextEngineInfo; warnings: [] } + | { info?: undefined; warnings: string[] }; + +function normalizeRuntimeId(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const normalized = normalizeEmbeddedAgentRuntime(value.trim().toLowerCase()); + return normalized || undefined; +} + +function parseModelRef(value: unknown): { provider: string; modelId: string } | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + const slash = trimmed.indexOf("/"); + if (slash <= 0 || slash >= trimmed.length - 1) { + return undefined; + } + return { + provider: normalizeProviderId(trimmed.slice(0, slash)), + modelId: trimmed.slice(slash + 1).trim(), + }; +} + +function listModelRefs(value: unknown): string[] { + if (typeof value === "string" && value.trim()) { + return [value.trim()]; + } + if (!isRecord(value)) { + return []; + } + const refs: string[] = []; + if (typeof value.primary === "string" && value.primary.trim()) { + refs.push(value.primary.trim()); + } + if (Array.isArray(value.fallbacks)) { + for (const fallback of value.fallbacks) { + if (typeof fallback === "string" && fallback.trim()) { + refs.push(fallback.trim()); + } + } + } + return refs; +} + +function collectExplicitRuntimeRefs( + cfg: OpenClawConfig, +): Array<{ runtimeId: string; path: string }> { + const refs: Array<{ runtimeId: string; path: string }> = []; + const push = (runtime: unknown, path: string) => { + const runtimeId = normalizeRuntimeId(runtime); + if (runtimeId && runtimeId !== "default") { + refs.push({ runtimeId, path }); + } + }; + + for (const [providerId, providerConfig] of Object.entries(cfg.models?.providers ?? {})) { + push(providerConfig?.agentRuntime?.id, `models.providers.${providerId}.agentRuntime.id`); + providerConfig?.models?.forEach((modelConfig, index) => { + push( + modelConfig?.agentRuntime?.id, + `models.providers.${providerId}.models[${index}].agentRuntime.id`, + ); + }); + } + + for (const [modelRef, modelConfig] of Object.entries(cfg.agents?.defaults?.models ?? {})) { + push(modelConfig?.agentRuntime?.id, `agents.defaults.models.${modelRef}.agentRuntime.id`); + } + + cfg.agents?.list?.forEach((agent, index) => { + const agentId = typeof agent.id === "string" && agent.id.trim() ? agent.id.trim() : `${index}`; + for (const [modelRef, modelConfig] of Object.entries(agent.models ?? {})) { + push( + modelConfig?.agentRuntime?.id, + `agents.list.${agentId}.models.${modelRef}.agentRuntime.id`, + ); + } + }); + + return refs; +} + +function collectSelectedModelRefs( + cfg: OpenClawConfig, +): Array<{ modelRef: string; path: string; agentId?: string }> { + const refs: Array<{ modelRef: string; path: string; agentId?: string }> = []; + const pushModel = (value: unknown, path: string, agentId?: string) => { + for (const modelRef of listModelRefs(value)) { + refs.push({ modelRef, path, ...(agentId ? { agentId } : {}) }); + } + }; + const pushModelMap = (models: unknown, path: string, agentId?: string) => { + if (!isRecord(models)) { + return; + } + for (const modelRef of Object.keys(models)) { + refs.push({ modelRef, path: `${path}.${modelRef}`, ...(agentId ? { agentId } : {}) }); + } + }; + + if (cfg.agents?.defaults?.model !== undefined) { + pushModel(cfg.agents.defaults.model, "agents.defaults.model"); + } else { + refs.push({ + modelRef: `${DEFAULT_PROVIDER}/${DEFAULT_MODEL}`, + path: "agents.defaults.model (default)", + }); + } + pushModelMap(cfg.agents?.defaults?.models, "agents.defaults.models"); + + cfg.agents?.list?.forEach((agent, index) => { + const agentId = typeof agent.id === "string" && agent.id.trim() ? agent.id.trim() : undefined; + const label = agentId ?? `${index}`; + pushModel(agent.model ?? cfg.agents?.defaults?.model, `agents.list.${label}.model`, agentId); + pushModelMap(agent.models, `agents.list.${label}.models`, agentId); + }); + + return refs; +} + +function runtimeHostCandidate(params: { + cfg: OpenClawConfig; + runtimeId: string; + paths: string[]; +}): HostCandidate { + const runtimeId = normalizeRuntimeId(params.runtimeId) ?? params.runtimeId; + if (runtimeId === "pi" || runtimeId === "auto") { + return { runtimeId, host: PI_EMBEDDED_CONTEXT_ENGINE_HOST, paths: params.paths }; + } + if (runtimeId === "codex") { + return { runtimeId, host: CODEX_APP_SERVER_CONTEXT_ENGINE_HOST, paths: params.paths }; + } + + const harness = getRegisteredAgentHarness(runtimeId)?.harness; + if (harness) { + return { + runtimeId, + host: { + id: `harness:${harness.id}`, + label: `${harness.label} harness`, + capabilities: harness.contextEngineHostCapabilities ?? [], + }, + paths: params.paths, + }; + } + + const cliBackend = resolveCliBackendConfig(runtimeId, params.cfg); + return { + runtimeId, + host: buildGenericCliContextEngineHostSupport({ + backendId: cliBackend?.id ?? runtimeId, + capabilities: cliBackend?.contextEngineHostCapabilities, + }), + paths: params.paths, + }; +} + +/** Collect effective agent-run host candidates from config and environment runtime policy. */ +export function collectConfiguredContextEngineAgentRunHosts(params: { + cfg: OpenClawConfig; + env?: NodeJS.ProcessEnv; +}): HostCandidate[] { + const envRuntime = normalizeRuntimeId(params.env?.OPENCLAW_AGENT_RUNTIME); + const runtimePaths = new Map(); + const push = (runtimeId: string | undefined, path: string) => { + if (!runtimeId) { + return; + } + const normalized = normalizeRuntimeId(runtimeId) ?? runtimeId; + const paths = runtimePaths.get(normalized) ?? []; + paths.push(path); + runtimePaths.set(normalized, paths); + }; + + if (envRuntime) { + push(envRuntime, "OPENCLAW_AGENT_RUNTIME"); + return [...runtimePaths.entries()].map(([runtimeId, paths]) => + runtimeHostCandidate({ cfg: params.cfg, runtimeId, paths }), + ); + } + + for (const ref of collectExplicitRuntimeRefs(params.cfg)) { + push(ref.runtimeId, ref.path); + } + for (const model of collectSelectedModelRefs(params.cfg)) { + const parsed = parseModelRef(model.modelRef); + if (!parsed) { + continue; + } + const policy = resolveAgentHarnessPolicy({ + config: params.cfg, + provider: parsed.provider, + modelId: parsed.modelId, + agentId: model.agentId, + }); + push(policy.runtime, model.path); + } + + return [...runtimePaths.entries()].map(([runtimeId, paths]) => + runtimeHostCandidate({ cfg: params.cfg, runtimeId, paths }), + ); +} + +function selectedContextEngineSlotId(cfg: OpenClawConfig): string { + const slotValue = cfg.plugins?.slots?.contextEngine; + return typeof slotValue === "string" && slotValue.trim() + ? slotValue.trim() + : defaultSlotIdForKey("contextEngine"); +} + +async function resolveSelectedContextEngineInfo(params: { + cfg: OpenClawConfig; + env?: NodeJS.ProcessEnv; +}): Promise { + const engineId = selectedContextEngineSlotId(params.cfg); + const defaultEngineId = defaultSlotIdForKey("contextEngine"); + if (engineId === defaultEngineId || engineId === "none") { + return { info: { id: engineId, name: engineId }, warnings: [] }; + } + + ensureContextEnginesInitialized(); + if (!getContextEngineFactory(engineId)) { + try { + ensurePluginRegistryLoaded({ + scope: "all", + config: params.cfg, + env: params.env, + onlyPluginIds: [engineId], + }); + } catch (error) { + if (!getContextEngineFactory(engineId)) { + const message = error instanceof Error ? error.message : String(error); + return { + warnings: [ + `- plugins.slots.contextEngine: could not inspect context engine "${engineId}" host requirements because its plugin failed to load: ${message}`, + ], + }; + } + } + if (!getContextEngineFactory(engineId)) { + return { + warnings: [ + `- plugins.slots.contextEngine: could not inspect context engine "${engineId}" host requirements because it is not registered.`, + ], + }; + } + } + + try { + const engine = await resolveContextEngine(params.cfg, { + agentDir: resolveDefaultAgentDir(params.cfg, params.env), + workspaceDir: params.cfg.agents?.defaults?.workspace + ? resolveUserPath(params.cfg.agents.defaults.workspace, params.env) + : undefined, + }); + return { info: engine.info, warnings: [] }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + warnings: [ + `- plugins.slots.contextEngine: could not inspect context engine "${engineId}" host requirements: ${message}`, + ], + }; + } +} + +function collectHostCompatibilityIssues(params: { + info: ContextEngineInfo; + hosts: HostCandidate[]; +}): HostCompatibilityIssue[] { + return params.hosts.flatMap((candidate) => { + const evaluation = evaluateContextEngineHostSupport({ + contextEngineInfo: params.info, + operation: "agent-run", + host: candidate.host, + }); + if (evaluation.ok) { + return []; + } + return [ + { + candidate, + missingCapabilities: evaluation.missingCapabilities, + requiredCapabilities: evaluation.requirements.requiredCapabilities, + }, + ]; + }); +} + +function formatPaths(paths: string[]): string { + const unique = [...new Set(paths)]; + if (unique.length <= 2) { + return unique.join(", "); + } + return `${unique.slice(0, 2).join(", ")}, and ${unique.length - 2} more`; +} + +function formatHostCapabilities(capabilities: readonly string[]): string { + return capabilities.length > 0 ? capabilities.join(", ") : "(none)"; +} + +function formatCompatibilityWarnings(params: { + info: ContextEngineInfo; + issues: HostCompatibilityIssue[]; + hostCount: number; + doctorFixCommand: string; +}): string[] { + if (params.issues.length === 0) { + return []; + } + const lines = params.issues.map((issue) => { + const paths = formatPaths(issue.candidate.paths); + return ( + `- plugins.slots.contextEngine: context engine "${params.info.id}" is incompatible with ` + + `${issue.candidate.host.label} (${paths}). ` + + `Missing host capabilities: ${issue.missingCapabilities.join(", ")}. ` + + `Required capabilities: ${issue.requiredCapabilities.join(", ")}. ` + + `Host capabilities: ${formatHostCapabilities(issue.candidate.host.capabilities)}.` + ); + }); + const incompatibleAllHosts = params.issues.length === params.hostCount; + lines.push( + incompatibleAllHosts + ? `- Run "${params.doctorFixCommand}" to switch plugins.slots.contextEngine to "legacy", or configure a compatible runtime/harness for agent runs.` + : `- Some configured runtimes support context engine "${params.info.id}" and others do not; doctor will not rewrite the global contextEngine slot automatically. Configure unsupported models to use a compatible runtime/harness or set plugins.slots.contextEngine to "legacy".`, + ); + return [lines.join("\n")]; +} + +/** Collect doctor warnings for context engines that cannot run under configured hosts. */ +export async function collectContextEngineHostCompatibilityWarnings(params: { + cfg: OpenClawConfig; + doctorFixCommand: string; + env?: NodeJS.ProcessEnv; +}): Promise { + const resolved = await resolveSelectedContextEngineInfo(params); + if (!resolved.info) { + return resolved.warnings; + } + const hosts = collectConfiguredContextEngineAgentRunHosts(params); + const issues = collectHostCompatibilityIssues({ info: resolved.info, hosts }); + return [ + ...resolved.warnings, + ...formatCompatibilityWarnings({ + info: resolved.info, + issues, + hostCount: hosts.length, + doctorFixCommand: params.doctorFixCommand, + }), + ]; +} + +/** Repair a globally incompatible context engine by falling back to legacy. */ +export async function maybeRepairContextEngineHostCompatibility(params: { + cfg: OpenClawConfig; + doctorFixCommand: string; + env?: NodeJS.ProcessEnv; +}): Promise<{ config: OpenClawConfig; changes: string[]; warnings?: string[] }> { + const resolved = await resolveSelectedContextEngineInfo(params); + if (!resolved.info) { + return { config: params.cfg, changes: [], warnings: resolved.warnings }; + } + + const hosts = collectConfiguredContextEngineAgentRunHosts(params); + const issues = collectHostCompatibilityIssues({ info: resolved.info, hosts }); + if (issues.length === 0) { + return { config: params.cfg, changes: [], warnings: resolved.warnings }; + } + + const warnings = formatCompatibilityWarnings({ + info: resolved.info, + issues, + hostCount: hosts.length, + doctorFixCommand: params.doctorFixCommand, + }); + if (issues.length !== hosts.length) { + return { config: params.cfg, changes: [], warnings: [...resolved.warnings, ...warnings] }; + } + + const next = structuredClone(params.cfg); + next.plugins ??= {}; + next.plugins.slots ??= {}; + next.plugins.slots.contextEngine = defaultSlotIdForKey("contextEngine"); + return { + config: next, + changes: [ + `Set plugins.slots.contextEngine to "legacy" because context engine "${resolved.info.id}" is incompatible with every configured agent-run host.`, + ], + warnings: resolved.warnings, + }; +} diff --git a/src/commands/doctor/shared/preview-warnings.ts b/src/commands/doctor/shared/preview-warnings.ts index 37fb8ab460fe..001ad4869bbf 100644 --- a/src/commands/doctor/shared/preview-warnings.ts +++ b/src/commands/doctor/shared/preview-warnings.ts @@ -445,6 +445,16 @@ export async function collectDoctorPreviewWarnings(params: { if (hasPluginConfig) { const { collectCodexRouteWarnings } = await import("./codex-route-warnings.js"); warnings.push(...collectCodexRouteWarnings({ cfg: params.cfg, env })); + + const { collectContextEngineHostCompatibilityWarnings } = + await import("./context-engine-host-compat.js"); + warnings.push( + ...(await collectContextEngineHostCompatibilityWarnings({ + cfg: params.cfg, + doctorFixCommand: params.doctorFixCommand, + env, + })), + ); } if (hasSubagentAllowlistConfig(params.cfg)) { const { collectStaleSubagentAllowlistWarnings, scanStaleSubagentAllowlistReferences } = diff --git a/src/context-engine/host-compat.test.ts b/src/context-engine/host-compat.test.ts new file mode 100644 index 000000000000..e836a9b064ef --- /dev/null +++ b/src/context-engine/host-compat.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; +import { + assertContextEngineHostSupport, + buildGenericCliContextEngineHostSupport, + CODEX_APP_SERVER_CONTEXT_ENGINE_HOST, + evaluateContextEngineHostSupport, + PI_EMBEDDED_CONTEXT_ENGINE_HOST, +} from "./host-compat.js"; +import type { ContextEngine, ContextEngineHostCapability } from "./types.js"; + +function createEngine(requiredCapabilities: ContextEngineHostCapability[]): ContextEngine { + return { + info: { + id: "lossless-claw", + name: "Lossless", + hostRequirements: { + "agent-run": { + requiredCapabilities, + unsupportedMessage: + "Use the native Codex or Pi embedded runtime, or switch contextEngine to legacy.", + }, + }, + }, + async ingest() { + return { ingested: true }; + }, + async assemble({ messages }) { + return { messages, estimatedTokens: 0 }; + }, + async compact() { + return { ok: true, compacted: false }; + }, + }; +} + +describe("context engine host compatibility", () => { + it("allows engines with no host requirements", () => { + assertContextEngineHostSupport({ + contextEngine: createEngine([]), + operation: "agent-run", + host: buildGenericCliContextEngineHostSupport({ backendId: "claude-cli" }), + }); + }); + + it("rejects generic CLI hosts when an engine requires pre-prompt assembly", () => { + expect(() => + assertContextEngineHostSupport({ + contextEngine: createEngine(["assemble-before-prompt"]), + operation: "agent-run", + host: buildGenericCliContextEngineHostSupport({ backendId: "claude-cli" }), + }), + ).toThrow( + 'Context engine "lossless-claw" cannot run operation "agent-run" on CLI backend "claude-cli".', + ); + }); + + it("evaluates missing capabilities without throwing", () => { + const evaluation = evaluateContextEngineHostSupport({ + contextEngineInfo: createEngine(["assemble-before-prompt"]).info, + operation: "agent-run", + host: buildGenericCliContextEngineHostSupport({ backendId: "claude-cli" }), + }); + + expect(evaluation).toMatchObject({ + ok: false, + missingCapabilities: ["assemble-before-prompt"], + }); + }); + + it("allows native Codex and Pi embedded hosts to satisfy pre-prompt assembly", () => { + const engine = createEngine(["assemble-before-prompt"]); + + assertContextEngineHostSupport({ + contextEngine: engine, + operation: "agent-run", + host: CODEX_APP_SERVER_CONTEXT_ENGINE_HOST, + }); + assertContextEngineHostSupport({ + contextEngine: engine, + operation: "agent-run", + host: PI_EMBEDDED_CONTEXT_ENGINE_HOST, + }); + }); + + it("allows native Codex to satisfy thread bootstrap projection", () => { + assertContextEngineHostSupport({ + contextEngine: createEngine(["assemble-before-prompt", "thread-bootstrap-projection"]), + operation: "agent-run", + host: CODEX_APP_SERVER_CONTEXT_ENGINE_HOST, + }); + }); +}); diff --git a/src/context-engine/host-compat.ts b/src/context-engine/host-compat.ts new file mode 100644 index 000000000000..2ec835c33bce --- /dev/null +++ b/src/context-engine/host-compat.ts @@ -0,0 +1,126 @@ +import type { + ContextEngine, + ContextEngineHostCapability, + ContextEngineHostRequirements, + ContextEngineInfo, + ContextEngineOperation, +} from "./types.js"; + +export type ContextEngineHostSupport = { + id: string; + label: string; + capabilities: readonly ContextEngineHostCapability[]; +}; + +export const GENERIC_CLI_CONTEXT_ENGINE_HOST_CAPABILITIES = [ + "bootstrap", + "after-turn", + "maintain", +] as const satisfies readonly ContextEngineHostCapability[]; + +export const PI_EMBEDDED_CONTEXT_ENGINE_HOST = { + id: "pi-embedded", + label: "Pi embedded runner", + capabilities: [ + "bootstrap", + "assemble-before-prompt", + "after-turn", + "maintain", + "compact", + "runtime-llm-complete", + ], +} as const satisfies ContextEngineHostSupport; + +export const CODEX_APP_SERVER_CONTEXT_ENGINE_HOST = { + id: "codex-app-server", + label: "Codex app-server harness", + capabilities: [ + "bootstrap", + "assemble-before-prompt", + "after-turn", + "maintain", + "compact", + "runtime-llm-complete", + "thread-bootstrap-projection", + ], +} as const satisfies ContextEngineHostSupport; + +export type ContextEngineHostSupportEvaluation = + | { + ok: true; + requirements?: ContextEngineHostRequirements; + missingCapabilities: []; + } + | { + ok: false; + requirements: ContextEngineHostRequirements; + missingCapabilities: ContextEngineHostCapability[]; + }; + +/** Build the default host support advertised by the generic CLI runner. */ +export function buildGenericCliContextEngineHostSupport(params: { + backendId: string; + capabilities?: readonly ContextEngineHostCapability[]; +}): ContextEngineHostSupport { + return { + id: `cli:${params.backendId}`, + label: `CLI backend "${params.backendId}"`, + capabilities: params.capabilities ?? GENERIC_CLI_CONTEXT_ENGINE_HOST_CAPABILITIES, + }; +} + +/** Evaluate whether a context-engine host can safely run the requested operation. */ +export function evaluateContextEngineHostSupport(params: { + contextEngineInfo: ContextEngineInfo; + operation: ContextEngineOperation; + host: ContextEngineHostSupport; +}): ContextEngineHostSupportEvaluation { + const requirements = params.contextEngineInfo.hostRequirements?.[params.operation]; + if (!requirements || requirements.requiredCapabilities.length === 0) { + return { ok: true, requirements, missingCapabilities: [] }; + } + + const supported = new Set(params.host.capabilities); + const missingCapabilities = requirements.requiredCapabilities.filter( + (capability) => !supported.has(capability), + ); + if (missingCapabilities.length === 0) { + return { ok: true, requirements, missingCapabilities: [] }; + } + + return { + ok: false, + requirements, + missingCapabilities, + }; +} + +/** Assert that a context engine can safely run under the supplied host. */ +export function assertContextEngineHostSupport(params: { + contextEngine: ContextEngine; + operation: ContextEngineOperation; + host: ContextEngineHostSupport; +}): void { + const evaluation = evaluateContextEngineHostSupport({ + contextEngineInfo: params.contextEngine.info, + operation: params.operation, + host: params.host, + }); + if (evaluation.ok) { + return; + } + + const engineId = params.contextEngine.info.id; + const required = evaluation.requirements.requiredCapabilities.join(", "); + const actual = + params.host.capabilities.length > 0 ? params.host.capabilities.join(", ") : "(none)"; + const guidance = evaluation.requirements.unsupportedMessage + ? ` ${evaluation.requirements.unsupportedMessage}` + : ""; + throw new Error( + `Context engine "${engineId}" cannot run operation "${params.operation}" on ${params.host.label}. ` + + `Missing host capabilities: ${evaluation.missingCapabilities.join(", ")}. ` + + `Required capabilities: ${required}. ` + + `Host capabilities: ${actual}.${guidance}`, + ); +} diff --git a/src/context-engine/types.ts b/src/context-engine/types.ts index 5d9a07ea7272..45fde956744d 100644 --- a/src/context-engine/types.ts +++ b/src/context-engine/types.ts @@ -44,6 +44,24 @@ export type ContextEngineProjection = { fingerprint?: string; }; +export type ContextEngineOperation = "agent-run" | "manual-compact" | "subagent-spawn"; + +export type ContextEngineHostCapability = + | "bootstrap" + | "assemble-before-prompt" + | "after-turn" + | "maintain" + | "compact" + | "runtime-llm-complete" + | "thread-bootstrap-projection"; + +export type ContextEngineHostRequirements = { + /** Host capabilities required before the engine can safely serve this operation. */ + requiredCapabilities: ContextEngineHostCapability[]; + /** Optional engine-authored guidance appended to the host compatibility error. */ + unsupportedMessage?: string; +}; + export type CompactResult = { ok: boolean; compacted: boolean; @@ -93,6 +111,11 @@ export type ContextEngineInfo = { * background turn maintenance. */ turnMaintenanceMode?: "foreground" | "background"; + /** + * Host capability requirements for operations where using an unsupported + * runtime would silently degrade or corrupt the engine's behavior. + */ + hostRequirements?: Partial>; }; export type SubagentSpawnPreparation = { diff --git a/src/plugin-sdk/agent-harness-runtime.ts b/src/plugin-sdk/agent-harness-runtime.ts index 10f3a82e63eb..08af8e4ce78a 100644 --- a/src/plugin-sdk/agent-harness-runtime.ts +++ b/src/plugin-sdk/agent-harness-runtime.ts @@ -42,6 +42,8 @@ export type { } from "../agents/pi-embedded-runner/run/types.js"; export type { ContextEngine as HarnessContextEngine, + ContextEngineHostCapability, + ContextEngineOperation, ContextEngineProjection, } from "../context-engine/types.js"; export type { CompactEmbeddedPiSessionParams } from "../agents/pi-embedded-runner/compact.js"; @@ -186,6 +188,10 @@ export { } from "../agents/harness/prompt-compaction-hook-helpers.js"; export { createCodexAppServerToolResultExtensionRunner } from "../agents/harness/codex-app-server-extensions.js"; export { createAgentToolResultMiddlewareRunner } from "../agents/harness/tool-result-middleware.js"; +export { + assertContextEngineHostSupport, + CODEX_APP_SERVER_CONTEXT_ENGINE_HOST, +} from "../context-engine/host-compat.js"; export { assembleHarnessContextEngine, bootstrapHarnessContextEngine, diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 39f3e590a2bf..461973940b83 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -120,8 +120,11 @@ export type { BootstrapResult, CompactResult, ContextEngine, + ContextEngineHostCapability, + ContextEngineHostRequirements, ContextEngineInfo, ContextEngineMaintenanceResult, + ContextEngineOperation, ContextEngineRuntimeContext, IngestBatchResult, IngestResult, @@ -134,6 +137,7 @@ export type { export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; export { registerContextEngine } from "../context-engine/registry.js"; +export { assertContextEngineHostSupport } from "../context-engine/host-compat.js"; export { buildMemorySystemPromptAddition, delegateCompactionToRuntime, diff --git a/src/plugins/cli-backend.types.ts b/src/plugins/cli-backend.types.ts index 01771a802924..d5164d0078b9 100644 --- a/src/plugins/cli-backend.types.ts +++ b/src/plugins/cli-backend.types.ts @@ -1,5 +1,6 @@ import type { CliBackendConfig } from "../config/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { ContextEngineHostCapability } from "../context-engine/types.js"; export type PluginTextReplacement = { from: string | RegExp; @@ -74,6 +75,11 @@ export type CliBackendPlugin = { id: string; /** Default backend config before user overrides from `agents.defaults.cliBackends`. */ config: CliBackendConfig; + /** + * Context-engine host capabilities provided by this backend when it is + * driven through the generic CLI runner. + */ + contextEngineHostCapabilities?: readonly ContextEngineHostCapability[]; /** * Optional live-smoke metadata owned by the backend plugin. * diff --git a/src/plugins/contracts/plugin-sdk-index.test.ts b/src/plugins/contracts/plugin-sdk-index.test.ts index 855029de8d87..61ded15b8708 100644 --- a/src/plugins/contracts/plugin-sdk-index.test.ts +++ b/src/plugins/contracts/plugin-sdk-index.test.ts @@ -97,6 +97,7 @@ describe("plugin-sdk exports", () => { it("keeps the root runtime surface intentionally small", async () => { const runtimeExports = await readIndexRuntimeExports(); expect([...runtimeExports].toSorted()).toEqual([ + "assertContextEngineHostSupport", "buildMemorySystemPromptAddition", "delegateCompactionToRuntime", "emptyPluginConfigSchema",