From 44ec4d05de4a0b5ea8f5aa00ed9c77c998d4ddcc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 10 Apr 2026 13:49:45 +0100 Subject: [PATCH] feat: add pluggable agent harness registry --- src/agents/embedded-agent.ts | 17 +++ src/agents/harness/builtin-pi.ts | 11 ++ src/agents/harness/index.ts | 26 ++++ src/agents/harness/registry.test.ts | 139 ++++++++++++++++++ src/agents/harness/registry.ts | 82 +++++++++++ src/agents/harness/selection.test.ts | 118 +++++++++++++++ src/agents/harness/selection.ts | 100 +++++++++++++ src/agents/harness/types.ts | 44 ++++++ src/agents/pi-embedded-runner.ts | 20 ++- src/agents/pi-embedded-runner/compact.ts | 5 + src/agents/pi-embedded-runner/run.ts | 16 +- .../pi-embedded-runner/run/backend.test.ts | 30 ++++ src/agents/pi-embedded-runner/run/backend.ts | 8 + .../run/tool-media-payloads.test.ts | 42 ++++++ .../run/tool-media-payloads.ts | 39 +++++ src/agents/pi-embedded-runner/run/types.ts | 2 + src/agents/pi-embedded-runner/runtime.ts | 20 +++ src/agents/pi-embedded-runner/types.ts | 1 + src/agents/pi-embedded.ts | 12 ++ src/auto-reply/reply/session.ts | 7 + src/gateway/server-plugins.test.ts | 1 + src/gateway/server-startup-post-attach.ts | 8 + src/gateway/server-startup.test.ts | 59 ++++++++ src/gateway/test-helpers.plugin-registry.ts | 1 + src/plugin-sdk/agent-harness.ts | 55 +++++++ src/plugin-sdk/core.ts | 1 + src/plugin-sdk/index.ts | 1 + src/plugin-sdk/plugin-entry.ts | 2 + src/plugins/api-builder.ts | 3 + src/plugins/bundled-capability-runtime.ts | 11 ++ src/plugins/captured-registration.ts | 7 + src/plugins/loader.test.ts | 44 ++++++ src/plugins/loader.ts | 14 ++ src/plugins/registry-empty.ts | 1 + src/plugins/registry-types.ts | 10 ++ src/plugins/registry.ts | 58 ++++++++ src/plugins/runtime/index.test.ts | 1 + src/plugins/runtime/runtime-agent.ts | 7 +- .../runtime/runtime-embedded-pi.runtime.ts | 2 +- src/plugins/runtime/types-core.ts | 1 + src/plugins/status.test-helpers.ts | 2 + src/plugins/status.ts | 2 + src/plugins/types.ts | 4 + src/test-utils/channel-plugins.ts | 1 + test/helpers/plugins/plugin-api.ts | 1 + test/helpers/plugins/plugin-runtime-mock.ts | 4 + 46 files changed, 1030 insertions(+), 10 deletions(-) create mode 100644 src/agents/embedded-agent.ts create mode 100644 src/agents/harness/builtin-pi.ts create mode 100644 src/agents/harness/index.ts create mode 100644 src/agents/harness/registry.test.ts create mode 100644 src/agents/harness/registry.ts create mode 100644 src/agents/harness/selection.test.ts create mode 100644 src/agents/harness/selection.ts create mode 100644 src/agents/harness/types.ts create mode 100644 src/agents/pi-embedded-runner/run/backend.test.ts create mode 100644 src/agents/pi-embedded-runner/run/backend.ts create mode 100644 src/agents/pi-embedded-runner/run/tool-media-payloads.test.ts create mode 100644 src/agents/pi-embedded-runner/run/tool-media-payloads.ts create mode 100644 src/agents/pi-embedded-runner/runtime.ts create mode 100644 src/plugin-sdk/agent-harness.ts diff --git a/src/agents/embedded-agent.ts b/src/agents/embedded-agent.ts new file mode 100644 index 00000000000..b88dcb2491f --- /dev/null +++ b/src/agents/embedded-agent.ts @@ -0,0 +1,17 @@ +export { + abortEmbeddedPiRun as abortEmbeddedAgentRun, + compactEmbeddedPiSession as compactEmbeddedAgentSession, + isEmbeddedPiRunActive as isEmbeddedAgentRunActive, + isEmbeddedPiRunStreaming as isEmbeddedAgentRunStreaming, + queueEmbeddedPiMessage as queueEmbeddedAgentMessage, + resolveActiveEmbeddedRunSessionId as resolveActiveEmbeddedAgentRunSessionId, + resolveEmbeddedSessionLane, + runEmbeddedPiAgent as runEmbeddedAgent, + waitForEmbeddedPiRunEnd as waitForEmbeddedAgentRunEnd, +} from "./pi-embedded-runner.js"; +export type { + EmbeddedPiAgentMeta as EmbeddedAgentMeta, + EmbeddedPiCompactResult as EmbeddedAgentCompactResult, + EmbeddedPiRunMeta as EmbeddedAgentRunMeta, + EmbeddedPiRunResult as EmbeddedAgentRunResult, +} from "./pi-embedded-runner.js"; diff --git a/src/agents/harness/builtin-pi.ts b/src/agents/harness/builtin-pi.ts new file mode 100644 index 00000000000..fbab6b115c0 --- /dev/null +++ b/src/agents/harness/builtin-pi.ts @@ -0,0 +1,11 @@ +import { runEmbeddedAttempt } from "../pi-embedded-runner/run/attempt.js"; +import type { AgentHarness } from "./types.js"; + +export function createPiAgentHarness(): AgentHarness { + return { + id: "pi", + label: "PI embedded agent", + supports: () => ({ supported: true, priority: 0 }), + runAttempt: runEmbeddedAttempt, + }; +} diff --git a/src/agents/harness/index.ts b/src/agents/harness/index.ts new file mode 100644 index 00000000000..45369279449 --- /dev/null +++ b/src/agents/harness/index.ts @@ -0,0 +1,26 @@ +export { + clearAgentHarnesses, + getAgentHarness, + getRegisteredAgentHarness, + listAgentHarnessIds, + listRegisteredAgentHarnesses, + registerAgentHarness, + resetRegisteredAgentHarnessSessions, + restoreRegisteredAgentHarnesses, +} from "./registry.js"; +export { + maybeCompactAgentHarnessSession, + runAgentHarnessAttemptWithFallback, + selectAgentHarness, +} from "./selection.js"; +export type { + AgentHarness, + AgentHarnessAttemptParams, + AgentHarnessAttemptResult, + AgentHarnessCompactParams, + AgentHarnessCompactResult, + AgentHarnessResetParams, + AgentHarnessSupport, + AgentHarnessSupportContext, + RegisteredAgentHarness, +} from "./types.js"; diff --git a/src/agents/harness/registry.test.ts b/src/agents/harness/registry.test.ts new file mode 100644 index 00000000000..2d225c6b149 --- /dev/null +++ b/src/agents/harness/registry.test.ts @@ -0,0 +1,139 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + clearAgentHarnesses, + getAgentHarness, + getRegisteredAgentHarness, + listAgentHarnessIds, + listRegisteredAgentHarnesses, + registerAgentHarness, + resetRegisteredAgentHarnessSessions, + restoreRegisteredAgentHarnesses, +} from "./registry.js"; +import { selectAgentHarness } from "./selection.js"; +import type { AgentHarness } from "./types.js"; + +const originalRuntime = process.env.OPENCLAW_AGENT_RUNTIME; + +afterEach(() => { + clearAgentHarnesses(); + if (originalRuntime == null) { + delete process.env.OPENCLAW_AGENT_RUNTIME; + } else { + process.env.OPENCLAW_AGENT_RUNTIME = originalRuntime; + } +}); + +function makeHarness( + id: string, + options: { + priority?: number; + providers?: string[]; + } = {}, +): AgentHarness { + const providers = options.providers?.map((provider) => provider.trim().toLowerCase()); + return { + id, + label: id, + supports: (ctx) => + !providers || providers.includes(ctx.provider.trim().toLowerCase()) + ? { supported: true, priority: options.priority ?? 10 } + : { supported: false }, + async runAttempt() { + throw new Error("not used"); + }, + }; +} + +describe("agent harness registry", () => { + it("registers and retrieves a harness with owner metadata", () => { + const harness = makeHarness("custom"); + registerAgentHarness(harness, { ownerPluginId: "plugin-a" }); + + expect(getAgentHarness("custom")).toMatchObject({ id: "custom", pluginId: "plugin-a" }); + expect(getRegisteredAgentHarness("custom")?.ownerPluginId).toBe("plugin-a"); + expect(listAgentHarnessIds()).toEqual(["custom"]); + }); + + it("restores a registry snapshot", () => { + registerAgentHarness(makeHarness("a")); + const snapshot = listRegisteredAgentHarnesses(); + registerAgentHarness(makeHarness("b")); + + restoreRegisteredAgentHarnesses(snapshot); + + expect(listAgentHarnessIds()).toEqual(["a"]); + }); + + it("dispatches generic session reset to registered harnesses", async () => { + const resets: unknown[] = []; + registerAgentHarness({ + ...makeHarness("custom"), + reset: async (params) => { + resets.push(params); + }, + }); + + await resetRegisteredAgentHarnessSessions({ + sessionId: "session-1", + sessionKey: "agent:main:session-1", + sessionFile: "/tmp/session.jsonl", + reason: "reset", + }); + + expect(resets).toEqual([ + { + sessionId: "session-1", + sessionKey: "agent:main:session-1", + sessionFile: "/tmp/session.jsonl", + reason: "reset", + }, + ]); + }); + + it("keeps model-specific harnesses behind plugin registration in auto mode", () => { + process.env.OPENCLAW_AGENT_RUNTIME = "auto"; + + expect(selectAgentHarness({ provider: "plugin-models", modelId: "custom-1" }).id).toBe("pi"); + + registerAgentHarness(makeHarness("custom", { providers: ["plugin-models"] }), { + ownerPluginId: "plugin-a", + }); + + expect(selectAgentHarness({ provider: "plugin-models", modelId: "custom-1" }).id).toBe( + "custom", + ); + }); + + it("falls back to PI for other models", () => { + process.env.OPENCLAW_AGENT_RUNTIME = "auto"; + + expect(selectAgentHarness({ provider: "anthropic", modelId: "sonnet-4.6" }).id).toBe("pi"); + }); + + it("lets a plugin harness win in auto mode by priority", () => { + process.env.OPENCLAW_AGENT_RUNTIME = "auto"; + registerAgentHarness(makeHarness("plugin-harness", { priority: 200 }), { + ownerPluginId: "plugin-a", + }); + + expect(selectAgentHarness({ provider: "codex", modelId: "gpt-5.4" }).id).toBe("plugin-harness"); + }); + + it("honors explicit PI mode", () => { + process.env.OPENCLAW_AGENT_RUNTIME = "pi"; + registerAgentHarness(makeHarness("plugin-harness", { priority: 200 }), { + ownerPluginId: "plugin-a", + }); + + expect(selectAgentHarness({ provider: "codex", modelId: "gpt-5.4" }).id).toBe("pi"); + }); + + it("honors explicit plugin harness mode when the plugin harness is registered", () => { + process.env.OPENCLAW_AGENT_RUNTIME = "custom"; + registerAgentHarness(makeHarness("custom", { providers: ["custom-provider"] }), { + ownerPluginId: "plugin-a", + }); + + expect(selectAgentHarness({ provider: "anthropic", modelId: "sonnet-4.6" }).id).toBe("custom"); + }); +}); diff --git a/src/agents/harness/registry.ts b/src/agents/harness/registry.ts new file mode 100644 index 00000000000..c5541cce7c0 --- /dev/null +++ b/src/agents/harness/registry.ts @@ -0,0 +1,82 @@ +import { createSubsystemLogger } from "../../logging/subsystem.js"; +import type { AgentHarness, AgentHarnessResetParams, RegisteredAgentHarness } from "./types.js"; + +const AGENT_HARNESS_REGISTRY_STATE = Symbol.for("openclaw.agentHarnessRegistryState"); +const log = createSubsystemLogger("agents/harness"); + +type AgentHarnessRegistryState = { + harnesses: Map; +}; + +function getAgentHarnessRegistryState(): AgentHarnessRegistryState { + const globalState = globalThis as typeof globalThis & { + [AGENT_HARNESS_REGISTRY_STATE]?: AgentHarnessRegistryState; + }; + globalState[AGENT_HARNESS_REGISTRY_STATE] ??= { + harnesses: new Map(), + }; + return globalState[AGENT_HARNESS_REGISTRY_STATE]; +} + +export function registerAgentHarness( + harness: AgentHarness, + options?: { ownerPluginId?: string }, +): void { + const id = harness.id.trim(); + getAgentHarnessRegistryState().harnesses.set(id, { + harness: { + ...harness, + id, + pluginId: harness.pluginId ?? options?.ownerPluginId, + }, + ownerPluginId: options?.ownerPluginId, + }); +} + +export function getAgentHarness(id: string): AgentHarness | undefined { + return getRegisteredAgentHarness(id)?.harness; +} + +export function getRegisteredAgentHarness(id: string): RegisteredAgentHarness | undefined { + return getAgentHarnessRegistryState().harnesses.get(id.trim()); +} + +export function listAgentHarnessIds(): string[] { + return [...getAgentHarnessRegistryState().harnesses.keys()]; +} + +export function listRegisteredAgentHarnesses(): RegisteredAgentHarness[] { + return Array.from(getAgentHarnessRegistryState().harnesses.values()); +} + +export function clearAgentHarnesses(): void { + getAgentHarnessRegistryState().harnesses.clear(); +} + +export function restoreRegisteredAgentHarnesses(entries: RegisteredAgentHarness[]): void { + const map = getAgentHarnessRegistryState().harnesses; + map.clear(); + for (const entry of entries) { + map.set(entry.harness.id, entry); + } +} + +export async function resetRegisteredAgentHarnessSessions( + params: AgentHarnessResetParams, +): Promise { + await Promise.all( + listRegisteredAgentHarnesses().map(async (entry) => { + if (!entry.harness.reset) { + return; + } + try { + await entry.harness.reset(params); + } catch (error) { + log.warn(`${entry.harness.label} session reset hook failed`, { + harnessId: entry.harness.id, + error, + }); + } + }), + ); +} diff --git a/src/agents/harness/selection.test.ts b/src/agents/harness/selection.test.ts new file mode 100644 index 00000000000..e21ade2e145 --- /dev/null +++ b/src/agents/harness/selection.test.ts @@ -0,0 +1,118 @@ +import type { Api, Model } from "@mariozechner/pi-ai"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { + EmbeddedRunAttemptParams, + EmbeddedRunAttemptResult, +} from "../pi-embedded-runner/run/types.js"; +import { clearAgentHarnesses, registerAgentHarness } from "./registry.js"; +import { runAgentHarnessAttemptWithFallback } from "./selection.js"; +import type { AgentHarness } from "./types.js"; + +const piRunAttempt = vi.fn(async () => createAttemptResult("pi")); + +vi.mock("./builtin-pi.js", () => ({ + createPiAgentHarness: (): AgentHarness => ({ + id: "pi", + label: "PI embedded agent", + supports: () => ({ supported: true, priority: 0 }), + runAttempt: piRunAttempt, + }), +})); + +const originalRuntime = process.env.OPENCLAW_AGENT_RUNTIME; + +afterEach(() => { + clearAgentHarnesses(); + piRunAttempt.mockClear(); + if (originalRuntime == null) { + delete process.env.OPENCLAW_AGENT_RUNTIME; + } else { + process.env.OPENCLAW_AGENT_RUNTIME = originalRuntime; + } +}); + +function createAttemptParams(): EmbeddedRunAttemptParams { + return { + prompt: "hello", + sessionId: "session-1", + runId: "run-1", + sessionFile: "/tmp/session.jsonl", + workspaceDir: "/tmp/workspace", + timeoutMs: 5_000, + provider: "codex", + modelId: "gpt-5.4", + model: { id: "gpt-5.4", provider: "codex" } as Model, + authStorage: {} as never, + modelRegistry: {} as never, + thinkLevel: "low", + } as EmbeddedRunAttemptParams; +} + +function createAttemptResult(sessionIdUsed: string): EmbeddedRunAttemptResult { + return { + aborted: false, + timedOut: false, + idleTimedOut: false, + timedOutDuringCompaction: false, + promptError: null, + promptErrorSource: null, + sessionIdUsed, + messagesSnapshot: [], + assistantTexts: [`${sessionIdUsed} ok`], + toolMetas: [], + lastAssistant: undefined, + didSendViaMessagingTool: false, + messagingToolSentTexts: [], + messagingToolSentMediaUrls: [], + messagingToolSentTargets: [], + cloudCodeAssistFormatError: false, + replayMetadata: { hadPotentialSideEffects: false, replaySafe: true }, + itemLifecycle: { startedCount: 0, completedCount: 0, activeCount: 0 }, + }; +} + +function registerFailingCodexHarness(): void { + registerAgentHarness( + { + id: "codex", + label: "Failing Codex", + supports: (ctx) => + ctx.provider === "codex" ? { supported: true, priority: 100 } : { supported: false }, + runAttempt: vi.fn(async () => { + throw new Error("codex startup failed"); + }), + }, + { ownerPluginId: "codex" }, + ); +} + +describe("runAgentHarnessAttemptWithFallback", () => { + it("falls back to the PI harness when a forced plugin harness is unavailable", async () => { + process.env.OPENCLAW_AGENT_RUNTIME = "codex"; + + const result = await runAgentHarnessAttemptWithFallback(createAttemptParams()); + + expect(result.sessionIdUsed).toBe("pi"); + expect(piRunAttempt).toHaveBeenCalledTimes(1); + }); + + it("falls back to the PI harness in auto mode when the selected plugin harness fails", async () => { + process.env.OPENCLAW_AGENT_RUNTIME = "auto"; + registerFailingCodexHarness(); + + const result = await runAgentHarnessAttemptWithFallback(createAttemptParams()); + + expect(result.sessionIdUsed).toBe("pi"); + expect(piRunAttempt).toHaveBeenCalledTimes(1); + }); + + it("surfaces a forced plugin harness failure instead of replaying through PI", async () => { + process.env.OPENCLAW_AGENT_RUNTIME = "codex"; + registerFailingCodexHarness(); + + await expect(runAgentHarnessAttemptWithFallback(createAttemptParams())).rejects.toThrow( + "codex startup failed", + ); + expect(piRunAttempt).not.toHaveBeenCalled(); + }); +}); diff --git a/src/agents/harness/selection.ts b/src/agents/harness/selection.ts new file mode 100644 index 00000000000..18d7bc9eca7 --- /dev/null +++ b/src/agents/harness/selection.ts @@ -0,0 +1,100 @@ +import { createSubsystemLogger } from "../../logging/subsystem.js"; +import type { CompactEmbeddedPiSessionParams } from "../pi-embedded-runner/compact.js"; +import type { + EmbeddedRunAttemptParams, + EmbeddedRunAttemptResult, +} from "../pi-embedded-runner/run/types.js"; +import { resolveEmbeddedAgentRuntime } from "../pi-embedded-runner/runtime.js"; +import type { EmbeddedPiCompactResult } from "../pi-embedded-runner/types.js"; +import { createPiAgentHarness } from "./builtin-pi.js"; +import { listRegisteredAgentHarnesses } from "./registry.js"; +import type { AgentHarness, AgentHarnessSupport } from "./types.js"; + +const log = createSubsystemLogger("agents/harness"); + +function listAvailableAgentHarnesses(): AgentHarness[] { + return [...listRegisteredAgentHarnesses().map((entry) => entry.harness), createPiAgentHarness()]; +} + +function compareHarnessSupport( + left: { harness: AgentHarness; support: AgentHarnessSupport & { supported: true } }, + right: { harness: AgentHarness; support: AgentHarnessSupport & { supported: true } }, +): number { + const priorityDelta = (right.support.priority ?? 0) - (left.support.priority ?? 0); + if (priorityDelta !== 0) { + return priorityDelta; + } + return left.harness.id.localeCompare(right.harness.id); +} + +export function selectAgentHarness(params: { provider: string; modelId?: string }): AgentHarness { + const runtime = resolveEmbeddedAgentRuntime(); + const harnesses = listAvailableAgentHarnesses(); + if (runtime !== "auto") { + const forced = harnesses.find((entry) => entry.id === runtime); + if (forced) { + return forced; + } + log.warn("requested agent harness is not registered; falling back to embedded PI backend", { + requestedRuntime: runtime, + }); + return createPiAgentHarness(); + } + + const supported = harnesses + .map((harness) => ({ + harness, + support: harness.supports({ + provider: params.provider, + modelId: params.modelId, + requestedRuntime: runtime, + }), + })) + .filter( + ( + entry, + ): entry is { + harness: AgentHarness; + support: AgentHarnessSupport & { supported: true }; + } => entry.support.supported, + ) + .toSorted(compareHarnessSupport); + + return supported[0]?.harness ?? createPiAgentHarness(); +} + +export async function runAgentHarnessAttemptWithFallback( + params: EmbeddedRunAttemptParams, +): Promise { + const runtime = resolveEmbeddedAgentRuntime(); + const harness = selectAgentHarness({ + provider: params.provider, + modelId: params.modelId, + }); + if (harness.id === "pi") { + return harness.runAttempt(params); + } + + try { + return await harness.runAttempt(params); + } catch (error) { + if (runtime !== "auto") { + throw error; + } + log.warn(`${harness.label} failed; falling back to embedded PI backend`, { error }); + return createPiAgentHarness().runAttempt(params); + } +} + +export async function maybeCompactAgentHarnessSession( + params: CompactEmbeddedPiSessionParams, +): Promise { + const harness = selectAgentHarness({ + provider: params.provider ?? "", + modelId: params.model, + }); + if (!harness.compact) { + return undefined; + } + return harness.compact(params); +} diff --git a/src/agents/harness/types.ts b/src/agents/harness/types.ts new file mode 100644 index 00000000000..26ea26225f2 --- /dev/null +++ b/src/agents/harness/types.ts @@ -0,0 +1,44 @@ +import type { CompactEmbeddedPiSessionParams } from "../pi-embedded-runner/compact.js"; +import type { + EmbeddedRunAttemptParams, + EmbeddedRunAttemptResult, +} from "../pi-embedded-runner/run/types.js"; +import type { EmbeddedAgentRuntime } from "../pi-embedded-runner/runtime.js"; +import type { EmbeddedPiCompactResult } from "../pi-embedded-runner/types.js"; + +export type AgentHarnessSupportContext = { + provider: string; + modelId?: string; + requestedRuntime: EmbeddedAgentRuntime; +}; + +export type AgentHarnessSupport = + | { supported: true; priority?: number; reason?: string } + | { supported: false; reason?: string }; + +export type AgentHarnessAttemptParams = EmbeddedRunAttemptParams; +export type AgentHarnessAttemptResult = EmbeddedRunAttemptResult; +export type AgentHarnessCompactParams = CompactEmbeddedPiSessionParams; +export type AgentHarnessCompactResult = EmbeddedPiCompactResult; +export type AgentHarnessResetParams = { + sessionId?: string; + sessionKey?: string; + sessionFile?: string; + reason?: "new" | "reset" | "idle" | "daily" | "compaction" | "deleted" | "unknown"; +}; + +export type AgentHarness = { + id: string; + label: string; + pluginId?: string; + supports(ctx: AgentHarnessSupportContext): AgentHarnessSupport; + runAttempt(params: AgentHarnessAttemptParams): Promise; + compact?(params: AgentHarnessCompactParams): Promise; + reset?(params: AgentHarnessResetParams): Promise | void; + dispose?(): Promise | void; +}; + +export type RegisteredAgentHarness = { + harness: AgentHarness; + ownerPluginId?: string; +}; diff --git a/src/agents/pi-embedded-runner.ts b/src/agents/pi-embedded-runner.ts index 456ceceea1e..b087cf27dc4 100644 --- a/src/agents/pi-embedded-runner.ts +++ b/src/agents/pi-embedded-runner.ts @@ -1,5 +1,8 @@ export type { MessagingToolSend } from "./pi-embedded-messaging.js"; -export { compactEmbeddedPiSession } from "./pi-embedded-runner/compact.js"; +export { + compactEmbeddedPiSession, + compactEmbeddedPiSession as compactEmbeddedAgentSession, +} from "./pi-embedded-runner/compact.js"; export { applyExtraParamsToAgent, resolveAgentTransportOverride, @@ -13,21 +16,34 @@ export { limitHistoryTurns, } from "./pi-embedded-runner/history.js"; export { resolveEmbeddedSessionLane } from "./pi-embedded-runner/lanes.js"; -export { runEmbeddedPiAgent } from "./pi-embedded-runner/run.js"; +export { + runEmbeddedPiAgent, + runEmbeddedPiAgent as runEmbeddedAgent, +} from "./pi-embedded-runner/run.js"; export { abortEmbeddedPiRun, + abortEmbeddedPiRun as abortEmbeddedAgentRun, isEmbeddedPiRunActive, + isEmbeddedPiRunActive as isEmbeddedAgentRunActive, isEmbeddedPiRunStreaming, + isEmbeddedPiRunStreaming as isEmbeddedAgentRunStreaming, queueEmbeddedPiMessage, + queueEmbeddedPiMessage as queueEmbeddedAgentMessage, resolveActiveEmbeddedRunSessionId, + resolveActiveEmbeddedRunSessionId as resolveActiveEmbeddedAgentRunSessionId, waitForEmbeddedPiRunEnd, + waitForEmbeddedPiRunEnd as waitForEmbeddedAgentRunEnd, } from "./pi-embedded-runner/runs.js"; export { buildEmbeddedSandboxInfo } from "./pi-embedded-runner/sandbox-info.js"; export { createSystemPromptOverride } from "./pi-embedded-runner/system-prompt.js"; export { splitSdkTools } from "./pi-embedded-runner/tool-split.js"; export type { + EmbeddedPiAgentMeta as EmbeddedAgentMeta, EmbeddedPiAgentMeta, + EmbeddedPiCompactResult as EmbeddedAgentCompactResult, EmbeddedPiCompactResult, + EmbeddedPiRunMeta as EmbeddedAgentRunMeta, EmbeddedPiRunMeta, + EmbeddedPiRunResult as EmbeddedAgentRunResult, EmbeddedPiRunResult, } from "./pi-embedded-runner/types.js"; diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index d3ff4959b68..5d6f511d254 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -56,6 +56,7 @@ import { resolveContextWindowInfo } from "../context-window-guard.js"; import { formatUserTime, resolveUserTimeFormat, resolveUserTimezone } from "../date-time.js"; import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../defaults.js"; import { resolveOpenClawDocsPath } from "../docs-path.js"; +import { maybeCompactAgentHarnessSession } from "../harness/selection.js"; import { resolveHeartbeatPromptForSystemPrompt } from "../heartbeat-system-prompt.js"; import { applyAuthHeaderOverride, @@ -1231,6 +1232,10 @@ export async function compactEmbeddedPiSessionDirect( export async function compactEmbeddedPiSession( params: CompactEmbeddedPiSessionParams, ): Promise { + const harnessResult = await maybeCompactAgentHarnessSession(params); + if (harnessResult) { + return harnessResult; + } const sessionLane = resolveSessionLane(params.sessionKey?.trim() || params.sessionId); const globalLane = resolveGlobalLane(params.lane); const enqueueGlobal = diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index 17a2a7b4418..87c542bb015 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -72,8 +72,8 @@ import { resolveGlobalLane, resolveSessionLane } from "./lanes.js"; import { log } from "./logger.js"; import { resolveModelAsync } from "./model.js"; import { handleAssistantFailover } from "./run/assistant-failover.js"; -import { runEmbeddedAttempt } from "./run/attempt.js"; import { createEmbeddedRunAuthController } from "./run/auth-controller.js"; +import { runEmbeddedAttemptWithBackend } from "./run/backend.js"; import { createFailoverDecisionLogger } from "./run/failover-observation.js"; import { mergeRetryFailoverReason, resolveRunFailoverDecision } from "./run/failover-policy.js"; import { @@ -99,6 +99,7 @@ import type { RunEmbeddedPiAgentParams } from "./run/params.js"; import { buildEmbeddedRunPayloads } from "./run/payloads.js"; import { handleRetryLimitExhaustion } from "./run/retry-limit.js"; import { resolveEffectiveRuntimeModel, resolveHookModelSelection } from "./run/setup.js"; +import { mergeAttemptToolMediaPayloads } from "./run/tool-media-payloads.js"; import { sessionLikelyHasOversizedToolResults, truncateOversizedToolResultsInSession, @@ -595,7 +596,7 @@ export async function runEmbeddedPiAgent( resolvedStreamApiKey = (apiKeyInfo as ApiKeyInfo).apiKey; } - const attempt = await runEmbeddedAttempt({ + const attempt = await runEmbeddedAttemptWithBackend({ sessionId: params.sessionId, sessionKey: resolvedSessionKey, trigger: params.trigger, @@ -1444,11 +1445,16 @@ export async function runEmbeddedPiAgent( didSendViaMessagingTool: attempt.didSendViaMessagingTool, didSendDeterministicApprovalPrompt: attempt.didSendDeterministicApprovalPrompt, }); + const payloadsWithToolMedia = mergeAttemptToolMediaPayloads({ + payloads, + toolMediaUrls: attempt.toolMediaUrls, + toolAudioAsVoice: attempt.toolAudioAsVoice, + }); // Timeout aborts can leave the run without any assistant payloads. // Emit an explicit timeout error instead of silently completing, so // callers do not lose the turn as an orphaned user message. - if (timedOut && !timedOutDuringCompaction && payloads.length === 0) { + if (timedOut && !timedOutDuringCompaction && !payloadsWithToolMedia?.length) { const timeoutText = idleTimedOut ? "The model did not produce a response before the LLM idle timeout. " + "Please try again, or increase `agents.defaults.llm.idleTimeoutSeconds` in your config (set to 0 to disable)." @@ -1480,7 +1486,7 @@ export async function runEmbeddedPiAgent( // Detect incomplete turns where prompt() resolved prematurely and the // runner would otherwise drop an empty reply. const incompleteTurnText = resolveIncompleteTurnPayloadText({ - payloadCount: payloads.length, + payloadCount: payloadsWithToolMedia?.length ?? 0, aborted, timedOut, attempt, @@ -1586,7 +1592,7 @@ export async function runEmbeddedPiAgent( }); } return { - payloads: payloads.length ? payloads : undefined, + payloads: payloadsWithToolMedia?.length ? payloadsWithToolMedia : undefined, meta: { durationMs: Date.now() - started, agentMeta, diff --git a/src/agents/pi-embedded-runner/run/backend.test.ts b/src/agents/pi-embedded-runner/run/backend.test.ts new file mode 100644 index 00000000000..80fb4f8b4b8 --- /dev/null +++ b/src/agents/pi-embedded-runner/run/backend.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { resolveEmbeddedAgentRuntime } from "../runtime.js"; + +describe("resolveEmbeddedAgentRuntime", () => { + it("uses auto mode by default", () => { + expect(resolveEmbeddedAgentRuntime({})).toBe("auto"); + }); + + it("accepts the PI kill switch", () => { + expect(resolveEmbeddedAgentRuntime({ OPENCLAW_AGENT_RUNTIME: "pi" })).toBe("pi"); + }); + + it("accepts codex app-server aliases", () => { + expect(resolveEmbeddedAgentRuntime({ OPENCLAW_AGENT_RUNTIME: "codex-app-server" })).toBe( + "codex", + ); + expect(resolveEmbeddedAgentRuntime({ OPENCLAW_AGENT_RUNTIME: "codex" })).toBe("codex"); + expect(resolveEmbeddedAgentRuntime({ OPENCLAW_AGENT_RUNTIME: "app-server" })).toBe("codex"); + }); + + it("accepts auto mode", () => { + expect(resolveEmbeddedAgentRuntime({ OPENCLAW_AGENT_RUNTIME: "auto" })).toBe("auto"); + }); + + it("preserves plugin harness runtime ids", () => { + expect(resolveEmbeddedAgentRuntime({ OPENCLAW_AGENT_RUNTIME: "custom-harness" })).toBe( + "custom-harness", + ); + }); +}); diff --git a/src/agents/pi-embedded-runner/run/backend.ts b/src/agents/pi-embedded-runner/run/backend.ts new file mode 100644 index 00000000000..bb6762cdd03 --- /dev/null +++ b/src/agents/pi-embedded-runner/run/backend.ts @@ -0,0 +1,8 @@ +import { runAgentHarnessAttemptWithFallback } from "../../harness/selection.js"; +import type { EmbeddedRunAttemptParams, EmbeddedRunAttemptResult } from "./types.js"; + +export async function runEmbeddedAttemptWithBackend( + params: EmbeddedRunAttemptParams, +): Promise { + return runAgentHarnessAttemptWithFallback(params); +} diff --git a/src/agents/pi-embedded-runner/run/tool-media-payloads.test.ts b/src/agents/pi-embedded-runner/run/tool-media-payloads.test.ts new file mode 100644 index 00000000000..ad686241deb --- /dev/null +++ b/src/agents/pi-embedded-runner/run/tool-media-payloads.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { mergeAttemptToolMediaPayloads } from "./tool-media-payloads.js"; + +describe("mergeAttemptToolMediaPayloads", () => { + it("attaches tool media to the first visible reply", () => { + expect( + mergeAttemptToolMediaPayloads({ + payloads: [ + { text: "thinking", isReasoning: true }, + { text: "done", mediaUrls: ["/tmp/a.png"] }, + ], + toolMediaUrls: ["/tmp/a.png", "/tmp/b.opus"], + toolAudioAsVoice: true, + }), + ).toEqual([ + { text: "thinking", isReasoning: true }, + { + text: "done", + mediaUrls: ["/tmp/a.png", "/tmp/b.opus"], + mediaUrl: "/tmp/a.png", + audioAsVoice: true, + }, + ]); + }); + + it("creates a media-only reply when no visible reply exists", () => { + expect( + mergeAttemptToolMediaPayloads({ + payloads: [{ text: "thinking", isReasoning: true }], + toolMediaUrls: ["/tmp/reply.opus"], + toolAudioAsVoice: true, + }), + ).toEqual([ + { text: "thinking", isReasoning: true }, + { + mediaUrls: ["/tmp/reply.opus"], + mediaUrl: "/tmp/reply.opus", + audioAsVoice: true, + }, + ]); + }); +}); diff --git a/src/agents/pi-embedded-runner/run/tool-media-payloads.ts b/src/agents/pi-embedded-runner/run/tool-media-payloads.ts new file mode 100644 index 00000000000..9ffef206245 --- /dev/null +++ b/src/agents/pi-embedded-runner/run/tool-media-payloads.ts @@ -0,0 +1,39 @@ +import type { EmbeddedPiRunResult } from "../types.js"; + +type EmbeddedRunPayload = NonNullable[number]; + +export function mergeAttemptToolMediaPayloads(params: { + payloads?: EmbeddedRunPayload[]; + toolMediaUrls?: string[]; + toolAudioAsVoice?: boolean; +}): EmbeddedRunPayload[] | undefined { + const mediaUrls = Array.from( + new Set(params.toolMediaUrls?.map((url) => url.trim()).filter(Boolean) ?? []), + ); + if (mediaUrls.length === 0 && !params.toolAudioAsVoice) { + return params.payloads; + } + + const payloads = params.payloads?.length ? [...params.payloads] : []; + const payloadIndex = payloads.findIndex((payload) => !payload.isReasoning); + if (payloadIndex >= 0) { + const payload = payloads[payloadIndex]; + const mergedMediaUrls = Array.from(new Set([...(payload.mediaUrls ?? []), ...mediaUrls])); + payloads[payloadIndex] = { + ...payload, + mediaUrls: mergedMediaUrls.length ? mergedMediaUrls : undefined, + mediaUrl: payload.mediaUrl ?? mergedMediaUrls[0], + audioAsVoice: payload.audioAsVoice || params.toolAudioAsVoice || undefined, + }; + return payloads; + } + + return [ + ...payloads, + { + mediaUrls: mediaUrls.length ? mediaUrls : undefined, + mediaUrl: mediaUrls[0], + audioAsVoice: params.toolAudioAsVoice || undefined, + }, + ]; +} diff --git a/src/agents/pi-embedded-runner/run/types.ts b/src/agents/pi-embedded-runner/run/types.ts index 87e498938e3..88b698a7777 100644 --- a/src/agents/pi-embedded-runner/run/types.ts +++ b/src/agents/pi-embedded-runner/run/types.ts @@ -78,6 +78,8 @@ export type EmbeddedRunAttemptResult = { messagingToolSentTexts: string[]; messagingToolSentMediaUrls: string[]; messagingToolSentTargets: MessagingToolSend[]; + toolMediaUrls?: string[]; + toolAudioAsVoice?: boolean; successfulCronAdds?: number; cloudCodeAssistFormatError: boolean; attemptUsage?: NormalizedUsage; diff --git a/src/agents/pi-embedded-runner/runtime.ts b/src/agents/pi-embedded-runner/runtime.ts new file mode 100644 index 00000000000..6d03a963ba9 --- /dev/null +++ b/src/agents/pi-embedded-runner/runtime.ts @@ -0,0 +1,20 @@ +export type EmbeddedAgentRuntime = "pi" | "auto" | (string & {}); + +export function resolveEmbeddedAgentRuntime( + env: NodeJS.ProcessEnv = process.env, +): EmbeddedAgentRuntime { + const raw = env.OPENCLAW_AGENT_RUNTIME?.trim(); + if (!raw) { + return "auto"; + } + if (raw === "pi") { + return "pi"; + } + if (raw === "codex" || raw === "codex-app-server" || raw === "app-server") { + return "codex"; + } + if (raw === "auto") { + return "auto"; + } + return raw; +} diff --git a/src/agents/pi-embedded-runner/types.ts b/src/agents/pi-embedded-runner/types.ts index a1f2553d68e..96ac367b33e 100644 --- a/src/agents/pi-embedded-runner/types.ts +++ b/src/agents/pi-embedded-runner/types.ts @@ -64,6 +64,7 @@ export type EmbeddedPiRunResult = { replyToId?: string; isError?: boolean; isReasoning?: boolean; + audioAsVoice?: boolean; }>; meta: EmbeddedPiRunMeta; // True if a messaging tool (telegram, whatsapp, discord, slack, sessions_send) diff --git a/src/agents/pi-embedded.ts b/src/agents/pi-embedded.ts index cd85e012ad0..0b548defe70 100644 --- a/src/agents/pi-embedded.ts +++ b/src/agents/pi-embedded.ts @@ -1,17 +1,29 @@ export type { + EmbeddedAgentCompactResult, + EmbeddedAgentMeta, + EmbeddedAgentRunMeta, + EmbeddedAgentRunResult, EmbeddedPiAgentMeta, EmbeddedPiCompactResult, EmbeddedPiRunMeta, EmbeddedPiRunResult, } from "./pi-embedded-runner.js"; export { + abortEmbeddedAgentRun, abortEmbeddedPiRun, + compactEmbeddedAgentSession, compactEmbeddedPiSession, + isEmbeddedAgentRunActive, + isEmbeddedAgentRunStreaming, isEmbeddedPiRunActive, isEmbeddedPiRunStreaming, + queueEmbeddedAgentMessage, queueEmbeddedPiMessage, + resolveActiveEmbeddedAgentRunSessionId, resolveActiveEmbeddedRunSessionId, resolveEmbeddedSessionLane, + runEmbeddedAgent, runEmbeddedPiAgent, + waitForEmbeddedAgentRunEnd, waitForEmbeddedPiRunEnd, } from "./pi-embedded-runner.js"; diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index 3bbe575e4ab..deca24c6e0f 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -2,6 +2,7 @@ import crypto from "node:crypto"; import path from "node:path"; import { resolveSessionAgentId } from "../../agents/agent-scope.js"; import { clearBootstrapSnapshotOnSessionRollover } from "../../agents/bootstrap-cache.js"; +import { resetRegisteredAgentHarnessSessions } from "../../agents/harness/registry.js"; import { disposeSessionMcpRuntime } from "../../agents/pi-bundle-mcp-tools.js"; import { normalizeChatType } from "../../channels/chat-type.js"; import type { OpenClawConfig } from "../../config/config.js"; @@ -700,6 +701,12 @@ export async function initSessionState(params: { }, ); }); + await resetRegisteredAgentHarnessSessions({ + sessionId: previousSessionEntry.sessionId, + sessionKey, + sessionFile: previousSessionEntry.sessionFile, + reason: previousSessionEndReason ?? "unknown", + }); } const sessionCtx: TemplateContext = { diff --git a/src/gateway/server-plugins.test.ts b/src/gateway/server-plugins.test.ts index ced9cecca23..36851373655 100644 --- a/src/gateway/server-plugins.test.ts +++ b/src/gateway/server-plugins.test.ts @@ -78,6 +78,7 @@ const createRegistry = (diagnostics: PluginDiagnostic[]): PluginRegistry => ({ webFetchProviders: [], webSearchProviders: [], memoryEmbeddingProviders: [], + agentHarnesses: [], gatewayHandlers: {}, httpRoutes: [], cliRegistrars: [], diff --git a/src/gateway/server-startup-post-attach.ts b/src/gateway/server-startup-post-attach.ts index 2201c328cf1..c1a6ff3d95b 100644 --- a/src/gateway/server-startup-post-attach.ts +++ b/src/gateway/server-startup-post-attach.ts @@ -2,6 +2,7 @@ import { getAcpSessionManager } from "../acp/control-plane/manager.js"; import { ACP_SESSION_IDENTITY_RENDERER_VERSION } from "../acp/runtime/session-identifiers.js"; import { resolveOpenClawAgentDir } from "../agents/agent-paths.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; +import { selectAgentHarness } from "../agents/harness/selection.js"; import { loadModelCatalog } from "../agents/model-catalog.js"; import { getModelRefStatus, @@ -11,6 +12,7 @@ import { } from "../agents/model-selection.js"; import { ensureOpenClawModelsJson } from "../agents/models-config.js"; import { resolveModel } from "../agents/pi-embedded-runner/model.js"; +import { resolveEmbeddedAgentRuntime } from "../agents/pi-embedded-runner/runtime.js"; import { resolveAgentSessionDirs } from "../agents/session-dirs.js"; import { cleanStaleLockFiles } from "../agents/session-write-lock.js"; import { scheduleSubagentOrphanRecovery } from "../agents/subagent-registry.js"; @@ -61,6 +63,12 @@ async function prewarmConfiguredPrimaryModel(params: { if (isCliProvider(provider, params.cfg)) { return; } + if (resolveEmbeddedAgentRuntime() !== "auto") { + return; + } + if (selectAgentHarness({ provider, modelId: model }).id !== "pi") { + return; + } const agentDir = resolveOpenClawAgentDir(); try { await ensureOpenClawModelsJson(params.cfg, agentDir); diff --git a/src/gateway/server-startup.test.ts b/src/gateway/server-startup.test.ts index a711706b17d..c3474e3aa8c 100644 --- a/src/gateway/server-startup.test.ts +++ b/src/gateway/server-startup.test.ts @@ -19,6 +19,8 @@ const resolveModelMock = vi.fn< api: "openai-codex-responses", }, })); +const selectAgentHarnessMock = vi.fn((_params: unknown) => ({ id: "pi" })); +const resolveEmbeddedAgentRuntimeMock = vi.fn(() => "auto"); vi.mock("../agents/agent-paths.js", () => ({ resolveOpenClawAgentDir: () => "/tmp/agent", @@ -29,6 +31,10 @@ vi.mock("../agents/models-config.js", () => ({ ensureOpenClawModelsJsonMock(config, agentDir), })); +vi.mock("../agents/harness/selection.js", () => ({ + selectAgentHarness: (params: unknown) => selectAgentHarnessMock(params), +})); + vi.mock("../agents/pi-embedded-runner/model.js", () => ({ resolveModel: ( provider: unknown, @@ -39,6 +45,10 @@ vi.mock("../agents/pi-embedded-runner/model.js", () => ({ ) => resolveModelMock(provider, modelId, agentDir, cfg, options), })); +vi.mock("../agents/pi-embedded-runner/runtime.js", () => ({ + resolveEmbeddedAgentRuntime: () => resolveEmbeddedAgentRuntimeMock(), +})); + let prewarmConfiguredPrimaryModel: typeof import("./server-startup.js").__testing.prewarmConfiguredPrimaryModel; describe("gateway startup primary model warmup", () => { @@ -51,6 +61,10 @@ describe("gateway startup primary model warmup", () => { beforeEach(() => { ensureOpenClawModelsJsonMock.mockClear(); resolveModelMock.mockClear(); + selectAgentHarnessMock.mockClear(); + selectAgentHarnessMock.mockReturnValue({ id: "pi" }); + resolveEmbeddedAgentRuntimeMock.mockClear(); + resolveEmbeddedAgentRuntimeMock.mockReturnValue("auto"); }); it("prewarms an explicit configured primary model", async () => { @@ -108,4 +122,49 @@ describe("gateway startup primary model warmup", () => { expect(ensureOpenClawModelsJsonMock).not.toHaveBeenCalled(); expect(resolveModelMock).not.toHaveBeenCalled(); }); + + it("skips static warmup when another agent harness handles the model", async () => { + selectAgentHarnessMock.mockReturnValue({ id: "codex" }); + const cfg = { + agents: { + defaults: { + model: { + primary: "codex/gpt-5.4", + }, + }, + }, + } as OpenClawConfig; + + await prewarmConfiguredPrimaryModel({ + cfg, + log: { warn: vi.fn() }, + }); + + expect(selectAgentHarnessMock).toHaveBeenCalledWith({ + provider: "codex", + modelId: "gpt-5.4", + }); + expect(ensureOpenClawModelsJsonMock).not.toHaveBeenCalled(); + expect(resolveModelMock).not.toHaveBeenCalled(); + }); + + it("skips static warmup when a non-PI agent runtime is forced", async () => { + resolveEmbeddedAgentRuntimeMock.mockReturnValue("codex"); + await prewarmConfiguredPrimaryModel({ + cfg: { + agents: { + defaults: { + model: { + primary: "codex/gpt-5.4", + }, + }, + }, + } as OpenClawConfig, + log: { warn: vi.fn() }, + }); + + expect(selectAgentHarnessMock).not.toHaveBeenCalled(); + expect(ensureOpenClawModelsJsonMock).not.toHaveBeenCalled(); + expect(resolveModelMock).not.toHaveBeenCalled(); + }); }); diff --git a/src/gateway/test-helpers.plugin-registry.ts b/src/gateway/test-helpers.plugin-registry.ts index 468c51ebdea..d52ebf9c237 100644 --- a/src/gateway/test-helpers.plugin-registry.ts +++ b/src/gateway/test-helpers.plugin-registry.ts @@ -23,6 +23,7 @@ function createStubPluginRegistry(): PluginRegistry { webFetchProviders: [], webSearchProviders: [], memoryEmbeddingProviders: [], + agentHarnesses: [], gatewayHandlers: {}, httpRoutes: [], cliRegistrars: [], diff --git a/src/plugin-sdk/agent-harness.ts b/src/plugin-sdk/agent-harness.ts new file mode 100644 index 00000000000..491879149df --- /dev/null +++ b/src/plugin-sdk/agent-harness.ts @@ -0,0 +1,55 @@ +// Public agent harness surface for plugins that replace the low-level agent runtime. +// Keep model/vendor-specific protocol code in the plugin that registers the harness. + +export type { + AgentHarness, + AgentHarnessAttemptParams, + AgentHarnessAttemptResult, + AgentHarnessCompactParams, + AgentHarnessCompactResult, + AgentHarnessResetParams, + AgentHarnessSupport, + AgentHarnessSupportContext, +} from "../agents/harness/types.js"; +export type { + EmbeddedRunAttemptParams, + EmbeddedRunAttemptResult, +} from "../agents/pi-embedded-runner/run/types.js"; +export type { CompactEmbeddedPiSessionParams } from "../agents/pi-embedded-runner/compact.js"; +export type { EmbeddedPiCompactResult } from "../agents/pi-embedded-runner/types.js"; +export type { AnyAgentTool } from "../agents/tools/common.js"; +export type { MessagingToolSend } from "../agents/pi-embedded-messaging.js"; +export type { AgentApprovalEventData } from "../infra/agent-events.js"; +export type { ExecApprovalDecision } from "../infra/exec-approvals.js"; +export type { NormalizedUsage } from "../agents/usage.js"; + +export { VERSION as OPENCLAW_VERSION } from "../version.js"; +export { formatErrorMessage } from "../infra/errors.js"; +export { log as embeddedAgentLog } from "../agents/pi-embedded-runner/logger.js"; +export { resolveEmbeddedAgentRuntime } from "../agents/pi-embedded-runner/runtime.js"; +export { resolveUserPath } from "../utils.js"; +export { callGatewayTool } from "../agents/tools/gateway.js"; +export { isMessagingTool, isMessagingToolSendAction } from "../agents/pi-embedded-messaging.js"; +export { + extractToolResultMediaArtifact, + filterToolResultMediaUrls, +} from "../agents/pi-embedded-subscribe.tools.js"; +export { normalizeUsage } from "../agents/usage.js"; +export { resolveOpenClawAgentDir } from "../agents/agent-paths.js"; +export { resolveSessionAgentIds } from "../agents/agent-scope.js"; +export { resolveModelAuthMode } from "../agents/model-auth.js"; +export { supportsModelTools } from "../agents/model-tool-support.js"; +export { resolveAttemptSpawnWorkspaceDir } from "../agents/pi-embedded-runner/run/attempt.thread-helpers.js"; +export { buildEmbeddedAttemptToolRunContext } from "../agents/pi-embedded-runner/run/attempt.tool-run-context.js"; +export { + abortEmbeddedPiRun as abortAgentHarnessRun, + clearActiveEmbeddedRun, + queueEmbeddedPiMessage as queueAgentHarnessMessage, + setActiveEmbeddedRun, +} from "../agents/pi-embedded-runner/runs.js"; +export { normalizeProviderToolSchemas } from "../agents/pi-embedded-runner/tool-schema-runtime.js"; +export { createOpenClawCodingTools } from "../agents/pi-tools.js"; +export { resolveSandboxContext } from "../agents/sandbox.js"; +export { isSubagentSessionKey } from "../routing/session-key.js"; +export { acquireSessionWriteLock } from "../agents/session-write-lock.js"; +export { emitSessionTranscriptUpdate } from "../sessions/transcript-events.js"; diff --git a/src/plugin-sdk/core.ts b/src/plugin-sdk/core.ts index 4d9b1945b12..eca1541d69c 100644 --- a/src/plugin-sdk/core.ts +++ b/src/plugin-sdk/core.ts @@ -28,6 +28,7 @@ import type { OpenClawPluginApi } from "../plugins/types.js"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; export type { + AgentHarness, AnyAgentTool, MediaUnderstandingProviderPlugin, OpenClawPluginApi, diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 4ac64ef7917..769892bdc03 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -42,6 +42,7 @@ export type { ChannelSetupWizardAllowFromEntry, } from "../channels/plugins/setup-wizard-types.js"; export type { + AgentHarness, AnyAgentTool, CliBackendPlugin, MediaUnderstandingProviderPlugin, diff --git a/src/plugin-sdk/plugin-entry.ts b/src/plugin-sdk/plugin-entry.ts index 0bd6126ea4f..dccf511a88b 100644 --- a/src/plugin-sdk/plugin-entry.ts +++ b/src/plugin-sdk/plugin-entry.ts @@ -2,6 +2,7 @@ import type { OpenClawConfig } from "../config/config.js"; import { emptyPluginConfigSchema } from "../plugins/config-schema.js"; import type { AnyAgentTool, + AgentHarness, MediaUnderstandingProviderPlugin, OpenClawPluginApi, OpenClawPluginCommandDefinition, @@ -73,6 +74,7 @@ import { createCachedLazyValueGetter } from "./lazy-value.js"; export type { AnyAgentTool, + AgentHarness, MediaUnderstandingProviderPlugin, OpenClawPluginApi, OpenClawPluginNodeHostCommand, diff --git a/src/plugins/api-builder.ts b/src/plugins/api-builder.ts index b1349bada1d..f719979b5ee 100644 --- a/src/plugins/api-builder.ts +++ b/src/plugins/api-builder.ts @@ -46,6 +46,7 @@ export type BuildPluginApiParams = { | "registerCommand" | "registerContextEngine" | "registerCompactionProvider" + | "registerAgentHarness" | "registerMemoryCapability" | "registerMemoryPromptSection" | "registerMemoryPromptSupplement" @@ -94,6 +95,7 @@ const noopOnConversationBindingResolved: OpenClawPluginApi["onConversationBindin const noopRegisterCommand: OpenClawPluginApi["registerCommand"] = () => {}; const noopRegisterContextEngine: OpenClawPluginApi["registerContextEngine"] = () => {}; const noopRegisterCompactionProvider: OpenClawPluginApi["registerCompactionProvider"] = () => {}; +const noopRegisterAgentHarness: OpenClawPluginApi["registerAgentHarness"] = () => {}; const noopRegisterMemoryCapability: OpenClawPluginApi["registerMemoryCapability"] = () => {}; const noopRegisterMemoryPromptSection: OpenClawPluginApi["registerMemoryPromptSection"] = () => {}; const noopRegisterMemoryPromptSupplement: OpenClawPluginApi["registerMemoryPromptSupplement"] = @@ -158,6 +160,7 @@ export function buildPluginApi(params: BuildPluginApiParams): OpenClawPluginApi registerContextEngine: handlers.registerContextEngine ?? noopRegisterContextEngine, registerCompactionProvider: handlers.registerCompactionProvider ?? noopRegisterCompactionProvider, + registerAgentHarness: handlers.registerAgentHarness ?? noopRegisterAgentHarness, registerMemoryCapability: handlers.registerMemoryCapability ?? noopRegisterMemoryCapability, registerMemoryPromptSection: handlers.registerMemoryPromptSection ?? noopRegisterMemoryPromptSection, diff --git a/src/plugins/bundled-capability-runtime.ts b/src/plugins/bundled-capability-runtime.ts index 199068d55ea..0374d20cde2 100644 --- a/src/plugins/bundled-capability-runtime.ts +++ b/src/plugins/bundled-capability-runtime.ts @@ -151,6 +151,7 @@ function createCapabilityPluginRecord(params: { webFetchProviderIds: [], webSearchProviderIds: [], memoryEmbeddingProviderIds: [], + agentHarnessIds: [], gatewayMethods: [], cliCommands: [], services: [], @@ -328,6 +329,7 @@ export function loadBundledCapabilityRuntimeRegistry(params: { record.memoryEmbeddingProviderIds.push( ...captured.memoryEmbeddingProviders.map((entry) => entry.id), ); + record.agentHarnessIds.push(...captured.agentHarnesses.map((entry) => entry.id)); record.toolNames.push(...captured.tools.map((entry) => entry.name)); registry.cliBackends?.push( @@ -438,6 +440,15 @@ export function loadBundledCapabilityRuntimeRegistry(params: { rootDir: record.rootDir, })), ); + registry.agentHarnesses.push( + ...captured.agentHarnesses.map((harness) => ({ + pluginId: record.id, + pluginName: record.name, + harness, + source: record.source, + rootDir: record.rootDir, + })), + ); registry.tools.push( ...captured.tools.map((tool) => ({ pluginId: record.id, diff --git a/src/plugins/captured-registration.ts b/src/plugins/captured-registration.ts index 8b77c3130ed..be8c4e7bcbd 100644 --- a/src/plugins/captured-registration.ts +++ b/src/plugins/captured-registration.ts @@ -4,6 +4,7 @@ import type { MemoryEmbeddingProviderAdapter } from "./memory-embedding-provider import type { PluginRuntime } from "./runtime/types.js"; import type { AnyAgentTool, + AgentHarness, CliBackendPlugin, ImageGenerationProviderPlugin, MediaUnderstandingProviderPlugin, @@ -29,6 +30,7 @@ type CapturedPluginCliRegistration = { export type CapturedPluginRegistration = { api: OpenClawPluginApi; providers: ProviderPlugin[]; + agentHarnesses: AgentHarness[]; cliRegistrars: CapturedPluginCliRegistration[]; cliBackends: CliBackendPlugin[]; speechProviders: SpeechProviderPlugin[]; @@ -49,6 +51,7 @@ export function createCapturedPluginRegistration(params?: { registrationMode?: OpenClawPluginApi["registrationMode"]; }): CapturedPluginRegistration { const providers: ProviderPlugin[] = []; + const agentHarnesses: AgentHarness[] = []; const cliRegistrars: CapturedPluginCliRegistration[] = []; const cliBackends: CliBackendPlugin[] = []; const speechProviders: SpeechProviderPlugin[] = []; @@ -71,6 +74,7 @@ export function createCapturedPluginRegistration(params?: { return { providers, + agentHarnesses, cliRegistrars, cliBackends, speechProviders, @@ -120,6 +124,9 @@ export function createCapturedPluginRegistration(params?: { registerProvider(provider: ProviderPlugin) { providers.push(provider); }, + registerAgentHarness(harness: AgentHarness) { + agentHarnesses.push(harness); + }, registerCliBackend(backend: CliBackendPlugin) { cliBackends.push(backend); }, diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index de9c07eb644..6ac45b6f82b 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; +import { listAgentHarnessIds } from "../agents/harness/registry.js"; import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; import { clearInternalHooks, @@ -1452,6 +1453,49 @@ module.exports = { id: "throws-after-import", register() {} };`, clearPluginCommands(); }); + it("clears plugin agent harnesses during activating reloads", () => { + useNoBundledPlugins(); + const plugin = writePlugin({ + id: "codex-harness", + filename: "codex-harness.cjs", + body: `module.exports = { + id: "codex-harness", + register(api) { + api.registerAgentHarness({ + id: "codex", + label: "Codex", + supports: () => ({ supported: true }), + runAttempt: async () => ({ ok: false, error: "unused" }), + }); + }, + };`, + }); + + loadOpenClawPlugins({ + cache: false, + workspaceDir: plugin.dir, + config: { + plugins: { + load: { paths: [plugin.file] }, + allow: ["codex-harness"], + }, + }, + onlyPluginIds: ["codex-harness"], + }); + expect(listAgentHarnessIds()).toEqual(["codex"]); + + loadOpenClawPlugins({ + cache: false, + workspaceDir: makeTempDir(), + config: { + plugins: { + allow: [], + }, + }, + }); + expect(listAgentHarnessIds()).toEqual([]); + }); + it("does not register internal hooks globally during non-activating loads", () => { useNoBundledPlugins(); const plugin = writePlugin({ diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index be01e4ffb98..4da37c3f407 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -2,6 +2,11 @@ import { createHash } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { createJiti } from "jiti"; +import { + clearAgentHarnesses, + listRegisteredAgentHarnesses, + restoreRegisteredAgentHarnesses, +} from "../agents/harness/registry.js"; import type { ChannelPlugin } from "../channels/plugins/types.js"; import { isChannelConfigured } from "../config/channel-configured.js"; import type { OpenClawConfig } from "../config/config.js"; @@ -149,6 +154,7 @@ export class PluginLoadReentryError extends Error { type CachedPluginState = { registry: PluginRegistry; memoryCorpusSupplements: ReturnType; + agentHarnesses: ReturnType; compactionProviders: ReturnType; memoryEmbeddingProviders: ReturnType; memoryFlushPlanResolver: ReturnType; @@ -182,6 +188,7 @@ export function clearPluginLoaderCache(): void { registryCache.clear(); inFlightPluginRegistryLoads.clear(); openAllowlistWarningCache.clear(); + clearAgentHarnesses(); clearCompactionProviders(); clearMemoryEmbeddingProviders(); clearMemoryPluginState(); @@ -715,6 +722,7 @@ function createPluginRecord(params: { webFetchProviderIds: [], webSearchProviderIds: [], memoryEmbeddingProviderIds: [], + agentHarnessIds: [], gatewayMethods: [], cliCommands: [], services: [], @@ -1106,6 +1114,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi if (cacheEnabled) { const cached = getCachedPluginRegistry(cacheKey); if (cached) { + restoreRegisteredAgentHarnesses(cached.agentHarnesses); restoreRegisteredCompactionProviders(cached.compactionProviders); restoreRegisteredMemoryEmbeddingProviders(cached.memoryEmbeddingProviders); restoreMemoryPluginState({ @@ -1134,6 +1143,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi // Clear previously registered plugin state before reloading. // Skip for non-activating (snapshot) loads to avoid wiping commands from other plugins. if (shouldActivate) { + clearAgentHarnesses(); clearPluginCommands(); clearPluginInteractiveHandlers(); clearMemoryPluginState(); @@ -1710,6 +1720,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi hookPolicy: entry?.hooks, registrationMode, }); + const previousAgentHarnesses = listRegisteredAgentHarnesses(); const previousCompactionProviders = listRegisteredCompactionProviders(); const previousMemoryEmbeddingProviders = listRegisteredMemoryEmbeddingProviders(); const previousMemoryFlushPlanResolver = getMemoryFlushPlanResolver(); @@ -1730,6 +1741,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi } // Snapshot loads should not replace process-global runtime prompt state. if (!shouldActivate) { + restoreRegisteredAgentHarnesses(previousAgentHarnesses); restoreRegisteredCompactionProviders(previousCompactionProviders); restoreRegisteredMemoryEmbeddingProviders(previousMemoryEmbeddingProviders); restoreMemoryPluginState({ @@ -1743,6 +1755,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi registry.plugins.push(record); seenIds.set(pluginId, candidate.origin); } catch (err) { + restoreRegisteredAgentHarnesses(previousAgentHarnesses); restoreRegisteredCompactionProviders(previousCompactionProviders); restoreRegisteredMemoryEmbeddingProviders(previousMemoryEmbeddingProviders); restoreMemoryPluginState({ @@ -1802,6 +1815,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi setCachedPluginRegistry(cacheKey, { memoryCorpusSupplements: listMemoryCorpusSupplements(), registry, + agentHarnesses: listRegisteredAgentHarnesses(), compactionProviders: listRegisteredCompactionProviders(), memoryEmbeddingProviders: listRegisteredMemoryEmbeddingProviders(), memoryFlushPlanResolver: getMemoryFlushPlanResolver(), diff --git a/src/plugins/registry-empty.ts b/src/plugins/registry-empty.ts index 77e3c022768..100d24f1a8e 100644 --- a/src/plugins/registry-empty.ts +++ b/src/plugins/registry-empty.ts @@ -20,6 +20,7 @@ export function createEmptyPluginRegistry(): PluginRegistry { webFetchProviders: [], webSearchProviders: [], memoryEmbeddingProviders: [], + agentHarnesses: [], gatewayHandlers: {}, gatewayMethodScopes: {}, httpRoutes: [], diff --git a/src/plugins/registry-types.ts b/src/plugins/registry-types.ts index 728216e962e..1f7dcfb3ae9 100644 --- a/src/plugins/registry-types.ts +++ b/src/plugins/registry-types.ts @@ -1,3 +1,4 @@ +import type { AgentHarness } from "../agents/harness/types.js"; import type { ChannelPlugin } from "../channels/plugins/types.js"; import type { OperatorScope } from "../gateway/method-scopes.js"; import type { GatewayRequestHandlers } from "../gateway/server-methods/types.js"; @@ -132,6 +133,13 @@ export type PluginWebSearchProviderRegistration = PluginOwnedProviderRegistration; export type PluginMemoryEmbeddingProviderRegistration = PluginOwnedProviderRegistration; +export type PluginAgentHarnessRegistration = { + pluginId: string; + pluginName?: string; + harness: AgentHarness; + source: string; + rootDir?: string; +}; export type PluginHookRegistration = { pluginId: string; @@ -228,6 +236,7 @@ export type PluginRecord = { webFetchProviderIds: string[]; webSearchProviderIds: string[]; memoryEmbeddingProviderIds: string[]; + agentHarnessIds: string[]; gatewayMethods: string[]; cliCommands: string[]; services: string[]; @@ -260,6 +269,7 @@ export type PluginRegistry = { webFetchProviders: PluginWebFetchProviderRegistration[]; webSearchProviders: PluginWebSearchProviderRegistration[]; memoryEmbeddingProviders: PluginMemoryEmbeddingProviderRegistration[]; + agentHarnesses: PluginAgentHarnessRegistration[]; gatewayHandlers: GatewayRequestHandlers; gatewayMethodScopes?: Partial>; httpRoutes: PluginHttpRouteRegistration[]; diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 0680660b83d..b3950d47ad0 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -1,4 +1,9 @@ import path from "node:path"; +import { + getRegisteredAgentHarness, + registerAgentHarness as registerGlobalAgentHarness, +} from "../agents/harness/registry.js"; +import type { AgentHarness } from "../agents/harness/types.js"; import type { AnyAgentTool } from "../agents/tools/common.js"; import type { ChannelPlugin } from "../channels/plugins/types.js"; import { registerContextEngineForOwner } from "../context-engine/registry.js"; @@ -48,6 +53,7 @@ import type { PluginConversationBindingResolvedHandlerRegistration, PluginHookRegistration, PluginHttpRouteRegistration as RegistryTypesPluginHttpRouteRegistration, + PluginAgentHarnessRegistration, PluginMemoryEmbeddingProviderRegistration, PluginNodeHostCommandRegistration, PluginProviderRegistration, @@ -120,6 +126,7 @@ export type { PluginCommandRegistration, PluginConversationBindingResolvedHandlerRegistration, PluginHookRegistration, + PluginAgentHarnessRegistration, PluginMemoryEmbeddingProviderRegistration, PluginNodeHostCommandRegistration, PluginProviderRegistration, @@ -524,6 +531,55 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { }); }; + const registerAgentHarness = (record: PluginRecord, harness: AgentHarness) => { + const id = harness.id.trim(); + if (!id) { + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: "agent harness registration missing id", + }); + return; + } + const existing = + registryParams.activateGlobalSideEffects === false + ? registry.agentHarnesses.find((entry) => entry.harness.id === id) + : getRegisteredAgentHarness(id); + if (existing) { + const ownerPluginId = + "ownerPluginId" in existing + ? existing.ownerPluginId + : "pluginId" in existing + ? existing.pluginId + : undefined; + const ownerDetail = ownerPluginId ? ` (owner: ${ownerPluginId})` : ""; + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: `agent harness already registered: ${id}${ownerDetail}`, + }); + return; + } + const normalizedHarness = { + ...harness, + id, + pluginId: harness.pluginId ?? record.id, + }; + if (registryParams.activateGlobalSideEffects !== false) { + registerGlobalAgentHarness(normalizedHarness, { ownerPluginId: record.id }); + } + record.agentHarnessIds.push(id); + registry.agentHarnesses.push({ + pluginId: record.id, + pluginName: record.name, + harness: normalizedHarness, + source: record.source, + rootDir: record.rootDir, + }); + }; + const registerCliBackend = (record: PluginRecord, backend: CliBackendPlugin) => { const id = backend.id.trim(); if (!id) { @@ -1075,6 +1131,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { registerHook(record, events, handler, opts, params.config), registerHttpRoute: (routeParams) => registerHttpRoute(record, routeParams), registerProvider: (provider) => registerProvider(record, provider), + registerAgentHarness: (harness) => registerAgentHarness(record, harness), registerSpeechProvider: (provider) => registerSpeechProvider(record, provider), registerRealtimeTranscriptionProvider: (provider) => registerRealtimeTranscriptionProvider(record, provider), @@ -1335,6 +1392,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { registerTool, registerChannel, registerProvider, + registerAgentHarness, registerCliBackend, registerSpeechProvider, registerRealtimeTranscriptionProvider, diff --git a/src/plugins/runtime/index.test.ts b/src/plugins/runtime/index.test.ts index 5e2808e6368..86e0b7f9d70 100644 --- a/src/plugins/runtime/index.test.ts +++ b/src/plugins/runtime/index.test.ts @@ -215,6 +215,7 @@ describe("plugin runtime command execution", () => { provider: DEFAULT_PROVIDER, }); expectFunctionKeys(runtime.agent as Record, [ + "runEmbeddedAgent", "runEmbeddedPiAgent", "resolveAgentDir", ]); diff --git a/src/plugins/runtime/runtime-agent.ts b/src/plugins/runtime/runtime-agent.ts index cd5868d5de0..4b233201195 100644 --- a/src/plugins/runtime/runtime-agent.ts +++ b/src/plugins/runtime/runtime-agent.ts @@ -26,9 +26,12 @@ export function createRuntimeAgent(): PluginRuntime["agent"] { resolveThinkingDefault, resolveAgentTimeoutMs, ensureAgentWorkspace, - } satisfies Omit & - Partial>; + } satisfies Omit & + Partial>; + defineCachedValue(agentRuntime, "runEmbeddedAgent", () => + createLazyRuntimeMethod(loadEmbeddedPiRuntime, (runtime) => runtime.runEmbeddedAgent), + ); defineCachedValue(agentRuntime, "runEmbeddedPiAgent", () => createLazyRuntimeMethod(loadEmbeddedPiRuntime, (runtime) => runtime.runEmbeddedPiAgent), ); diff --git a/src/plugins/runtime/runtime-embedded-pi.runtime.ts b/src/plugins/runtime/runtime-embedded-pi.runtime.ts index 217fd2f40e6..140d27fa397 100644 --- a/src/plugins/runtime/runtime-embedded-pi.runtime.ts +++ b/src/plugins/runtime/runtime-embedded-pi.runtime.ts @@ -1 +1 @@ -export { runEmbeddedPiAgent } from "../../agents/pi-embedded.js"; +export { runEmbeddedAgent, runEmbeddedPiAgent } from "../../agents/pi-embedded.js"; diff --git a/src/plugins/runtime/types-core.ts b/src/plugins/runtime/types-core.ts index f8b528d8ef7..e53d809f48b 100644 --- a/src/plugins/runtime/types-core.ts +++ b/src/plugins/runtime/types-core.ts @@ -35,6 +35,7 @@ export type PluginRuntimeCore = { resolveAgentWorkspaceDir: typeof import("../../agents/agent-scope.js").resolveAgentWorkspaceDir; resolveAgentIdentity: typeof import("../../agents/identity.js").resolveAgentIdentity; resolveThinkingDefault: typeof import("../../agents/model-selection.js").resolveThinkingDefault; + runEmbeddedAgent: typeof import("../../agents/embedded-agent.js").runEmbeddedAgent; runEmbeddedPiAgent: typeof import("../../agents/pi-embedded.js").runEmbeddedPiAgent; resolveAgentTimeoutMs: typeof import("../../agents/timeout.js").resolveAgentTimeoutMs; ensureAgentWorkspace: typeof import("../../agents/workspace.js").ensureAgentWorkspace; diff --git a/src/plugins/status.test-helpers.ts b/src/plugins/status.test-helpers.ts index 1b8e12f0c56..5ef110c6d36 100644 --- a/src/plugins/status.test-helpers.ts +++ b/src/plugins/status.test-helpers.ts @@ -60,6 +60,7 @@ export function createPluginRecord( webFetchProviderIds: [], webSearchProviderIds: [], memoryEmbeddingProviderIds: [], + agentHarnessIds: [], gatewayMethods: [], cliCommands: [], services: [], @@ -127,6 +128,7 @@ export function createPluginLoadResult( webFetchProviders: [], webSearchProviders: [], memoryEmbeddingProviders: [], + agentHarnesses: [], tools: [], hooks: [], typedHooks: [], diff --git a/src/plugins/status.ts b/src/plugins/status.ts index 33f73c0a366..42430af9513 100644 --- a/src/plugins/status.ts +++ b/src/plugins/status.ts @@ -34,6 +34,7 @@ export type PluginCapabilityKind = | "media-understanding" | "image-generation" | "web-search" + | "agent-harness" | "channel"; export type PluginInspectShape = @@ -245,6 +246,7 @@ function buildCapabilityEntries(plugin: PluginRegistry["plugins"][number]) { { kind: "media-understanding" as const, ids: plugin.mediaUnderstandingProviderIds }, { kind: "image-generation" as const, ids: plugin.imageGenerationProviderIds }, { kind: "web-search" as const, ids: plugin.webSearchProviderIds }, + { kind: "agent-harness" as const, ids: plugin.agentHarnessIds }, { kind: "channel" as const, ids: plugin.channelIds }, ].filter((entry) => entry.ids.length > 0); } diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 31ea4e05563..b8aa11fc0d0 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -10,6 +10,7 @@ import type { OAuthCredential, AuthProfileStore, } from "../agents/auth-profiles/types.js"; +import type { AgentHarness } from "../agents/harness/types.js"; import type { ModelCatalogEntry } from "../agents/model-catalog.js"; import type { FailoverReason } from "../agents/pi-embedded-helpers/types.js"; import type { ModelProviderRequestTransportOverrides } from "../agents/provider-request-config.js"; @@ -85,6 +86,7 @@ import type { PluginRuntime } from "./runtime/types.js"; export type { PluginRuntime } from "./runtime/types.js"; export type { AnyAgentTool } from "../agents/tools/common.js"; +export type { AgentHarness } from "../agents/harness/types.js"; export type ProviderAuthOptionBag = { token?: string; @@ -2238,6 +2240,8 @@ export type OpenClawPluginApi = { registerCompactionProvider: ( provider: import("./compaction-provider.js").CompactionProvider, ) => void; + /** Register an agent harness implementation. */ + registerAgentHarness: (harness: AgentHarness) => void; /** Register the active memory capability for this memory plugin (exclusive slot). */ registerMemoryCapability: ( capability: import("./memory-state.js").MemoryPluginCapability, diff --git a/src/test-utils/channel-plugins.ts b/src/test-utils/channel-plugins.ts index 8da6a64837b..e4f5093c8d1 100644 --- a/src/test-utils/channel-plugins.ts +++ b/src/test-utils/channel-plugins.ts @@ -36,6 +36,7 @@ export const createTestRegistry = (channels: TestChannelRegistration[] = []): Pl webFetchProviders: [], webSearchProviders: [], memoryEmbeddingProviders: [], + agentHarnesses: [], gatewayHandlers: {}, gatewayMethodScopes: {}, httpRoutes: [], diff --git a/test/helpers/plugins/plugin-api.ts b/test/helpers/plugins/plugin-api.ts index 75c72b1bbd4..fe56e55e941 100644 --- a/test/helpers/plugins/plugin-api.ts +++ b/test/helpers/plugins/plugin-api.ts @@ -39,6 +39,7 @@ export function createTestPluginApi(api: TestPluginApiInput = {}): OpenClawPlugi registerCommand() {}, registerContextEngine() {}, registerCompactionProvider() {}, + registerAgentHarness() {}, registerMemoryCapability() {}, registerMemoryPromptSection() {}, registerMemoryPromptSupplement() {}, diff --git a/test/helpers/plugins/plugin-runtime-mock.ts b/test/helpers/plugins/plugin-runtime-mock.ts index 604ddff88fc..67b9b297b44 100644 --- a/test/helpers/plugins/plugin-runtime-mock.ts +++ b/test/helpers/plugins/plugin-runtime-mock.ts @@ -102,6 +102,10 @@ export function createPluginRuntimeMock(overrides: DeepPartial = payloads: [], meta: {}, }) as unknown as PluginRuntime["agent"]["runEmbeddedPiAgent"], + runEmbeddedAgent: vi.fn().mockResolvedValue({ + payloads: [], + meta: {}, + }) as unknown as PluginRuntime["agent"]["runEmbeddedAgent"], resolveAgentTimeoutMs: vi.fn( () => 30_000, ) as unknown as PluginRuntime["agent"]["resolveAgentTimeoutMs"],