feat: add pluggable agent harness registry

This commit is contained in:
Peter Steinberger
2026-04-10 13:49:45 +01:00
parent fa97004ee1
commit 44ec4d05de
46 changed files with 1030 additions and 10 deletions

View File

@@ -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";

View File

@@ -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,
};
}

View File

@@ -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";

View File

@@ -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");
});
});

View File

@@ -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<string, RegisteredAgentHarness>;
};
function getAgentHarnessRegistryState(): AgentHarnessRegistryState {
const globalState = globalThis as typeof globalThis & {
[AGENT_HARNESS_REGISTRY_STATE]?: AgentHarnessRegistryState;
};
globalState[AGENT_HARNESS_REGISTRY_STATE] ??= {
harnesses: new Map<string, RegisteredAgentHarness>(),
};
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<void> {
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,
});
}
}),
);
}

View File

@@ -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<Api>,
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();
});
});

View File

@@ -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<EmbeddedRunAttemptResult> {
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<EmbeddedPiCompactResult | undefined> {
const harness = selectAgentHarness({
provider: params.provider ?? "",
modelId: params.model,
});
if (!harness.compact) {
return undefined;
}
return harness.compact(params);
}

View File

@@ -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<AgentHarnessAttemptResult>;
compact?(params: AgentHarnessCompactParams): Promise<AgentHarnessCompactResult | undefined>;
reset?(params: AgentHarnessResetParams): Promise<void> | void;
dispose?(): Promise<void> | void;
};
export type RegisteredAgentHarness = {
harness: AgentHarness;
ownerPluginId?: string;
};

View File

@@ -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";

View File

@@ -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<EmbeddedPiCompactResult> {
const harnessResult = await maybeCompactAgentHarnessSession(params);
if (harnessResult) {
return harnessResult;
}
const sessionLane = resolveSessionLane(params.sessionKey?.trim() || params.sessionId);
const globalLane = resolveGlobalLane(params.lane);
const enqueueGlobal =

View File

@@ -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,

View File

@@ -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",
);
});
});

View File

@@ -0,0 +1,8 @@
import { runAgentHarnessAttemptWithFallback } from "../../harness/selection.js";
import type { EmbeddedRunAttemptParams, EmbeddedRunAttemptResult } from "./types.js";
export async function runEmbeddedAttemptWithBackend(
params: EmbeddedRunAttemptParams,
): Promise<EmbeddedRunAttemptResult> {
return runAgentHarnessAttemptWithFallback(params);
}

View File

@@ -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,
},
]);
});
});

View File

@@ -0,0 +1,39 @@
import type { EmbeddedPiRunResult } from "../types.js";
type EmbeddedRunPayload = NonNullable<EmbeddedPiRunResult["payloads"]>[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,
},
];
}

View File

@@ -78,6 +78,8 @@ export type EmbeddedRunAttemptResult = {
messagingToolSentTexts: string[];
messagingToolSentMediaUrls: string[];
messagingToolSentTargets: MessagingToolSend[];
toolMediaUrls?: string[];
toolAudioAsVoice?: boolean;
successfulCronAdds?: number;
cloudCodeAssistFormatError: boolean;
attemptUsage?: NormalizedUsage;

View File

@@ -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;
}

View File

@@ -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)

View File

@@ -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";

View File

@@ -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 = {

View File

@@ -78,6 +78,7 @@ const createRegistry = (diagnostics: PluginDiagnostic[]): PluginRegistry => ({
webFetchProviders: [],
webSearchProviders: [],
memoryEmbeddingProviders: [],
agentHarnesses: [],
gatewayHandlers: {},
httpRoutes: [],
cliRegistrars: [],

View File

@@ -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);

View File

@@ -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();
});
});

View File

@@ -23,6 +23,7 @@ function createStubPluginRegistry(): PluginRegistry {
webFetchProviders: [],
webSearchProviders: [],
memoryEmbeddingProviders: [],
agentHarnesses: [],
gatewayHandlers: {},
httpRoutes: [],
cliRegistrars: [],

View File

@@ -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";

View File

@@ -28,6 +28,7 @@ import type { OpenClawPluginApi } from "../plugins/types.js";
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
export type {
AgentHarness,
AnyAgentTool,
MediaUnderstandingProviderPlugin,
OpenClawPluginApi,

View File

@@ -42,6 +42,7 @@ export type {
ChannelSetupWizardAllowFromEntry,
} from "../channels/plugins/setup-wizard-types.js";
export type {
AgentHarness,
AnyAgentTool,
CliBackendPlugin,
MediaUnderstandingProviderPlugin,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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);
},

View File

@@ -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({

View File

@@ -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<typeof listMemoryCorpusSupplements>;
agentHarnesses: ReturnType<typeof listRegisteredAgentHarnesses>;
compactionProviders: ReturnType<typeof listRegisteredCompactionProviders>;
memoryEmbeddingProviders: ReturnType<typeof listRegisteredMemoryEmbeddingProviders>;
memoryFlushPlanResolver: ReturnType<typeof getMemoryFlushPlanResolver>;
@@ -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(),

View File

@@ -20,6 +20,7 @@ export function createEmptyPluginRegistry(): PluginRegistry {
webFetchProviders: [],
webSearchProviders: [],
memoryEmbeddingProviders: [],
agentHarnesses: [],
gatewayHandlers: {},
gatewayMethodScopes: {},
httpRoutes: [],

View File

@@ -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<WebSearchProviderPlugin>;
export type PluginMemoryEmbeddingProviderRegistration =
PluginOwnedProviderRegistration<MemoryEmbeddingProviderAdapter>;
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<Record<string, OperatorScope>>;
httpRoutes: PluginHttpRouteRegistration[];

View File

@@ -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,

View File

@@ -215,6 +215,7 @@ describe("plugin runtime command execution", () => {
provider: DEFAULT_PROVIDER,
});
expectFunctionKeys(runtime.agent as Record<string, unknown>, [
"runEmbeddedAgent",
"runEmbeddedPiAgent",
"resolveAgentDir",
]);

View File

@@ -26,9 +26,12 @@ export function createRuntimeAgent(): PluginRuntime["agent"] {
resolveThinkingDefault,
resolveAgentTimeoutMs,
ensureAgentWorkspace,
} satisfies Omit<PluginRuntime["agent"], "runEmbeddedPiAgent" | "session"> &
Partial<Pick<PluginRuntime["agent"], "runEmbeddedPiAgent" | "session">>;
} satisfies Omit<PluginRuntime["agent"], "runEmbeddedAgent" | "runEmbeddedPiAgent" | "session"> &
Partial<Pick<PluginRuntime["agent"], "runEmbeddedAgent" | "runEmbeddedPiAgent" | "session">>;
defineCachedValue(agentRuntime, "runEmbeddedAgent", () =>
createLazyRuntimeMethod(loadEmbeddedPiRuntime, (runtime) => runtime.runEmbeddedAgent),
);
defineCachedValue(agentRuntime, "runEmbeddedPiAgent", () =>
createLazyRuntimeMethod(loadEmbeddedPiRuntime, (runtime) => runtime.runEmbeddedPiAgent),
);

View File

@@ -1 +1 @@
export { runEmbeddedPiAgent } from "../../agents/pi-embedded.js";
export { runEmbeddedAgent, runEmbeddedPiAgent } from "../../agents/pi-embedded.js";

View File

@@ -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;

View File

@@ -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: [],

View File

@@ -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);
}

View File

@@ -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,

View File

@@ -36,6 +36,7 @@ export const createTestRegistry = (channels: TestChannelRegistration[] = []): Pl
webFetchProviders: [],
webSearchProviders: [],
memoryEmbeddingProviders: [],
agentHarnesses: [],
gatewayHandlers: {},
gatewayMethodScopes: {},
httpRoutes: [],

View File

@@ -39,6 +39,7 @@ export function createTestPluginApi(api: TestPluginApiInput = {}): OpenClawPlugi
registerCommand() {},
registerContextEngine() {},
registerCompactionProvider() {},
registerAgentHarness() {},
registerMemoryCapability() {},
registerMemoryPromptSection() {},
registerMemoryPromptSupplement() {},

View File

@@ -102,6 +102,10 @@ export function createPluginRuntimeMock(overrides: DeepPartial<PluginRuntime> =
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"],