mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-12 01:31:08 +00:00
feat: add pluggable agent harness registry
This commit is contained in:
17
src/agents/embedded-agent.ts
Normal file
17
src/agents/embedded-agent.ts
Normal 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";
|
||||
11
src/agents/harness/builtin-pi.ts
Normal file
11
src/agents/harness/builtin-pi.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
26
src/agents/harness/index.ts
Normal file
26
src/agents/harness/index.ts
Normal 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";
|
||||
139
src/agents/harness/registry.test.ts
Normal file
139
src/agents/harness/registry.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
82
src/agents/harness/registry.ts
Normal file
82
src/agents/harness/registry.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
118
src/agents/harness/selection.test.ts
Normal file
118
src/agents/harness/selection.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
100
src/agents/harness/selection.ts
Normal file
100
src/agents/harness/selection.ts
Normal 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);
|
||||
}
|
||||
44
src/agents/harness/types.ts
Normal file
44
src/agents/harness/types.ts
Normal 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;
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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,
|
||||
|
||||
30
src/agents/pi-embedded-runner/run/backend.test.ts
Normal file
30
src/agents/pi-embedded-runner/run/backend.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
8
src/agents/pi-embedded-runner/run/backend.ts
Normal file
8
src/agents/pi-embedded-runner/run/backend.ts
Normal 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);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
39
src/agents/pi-embedded-runner/run/tool-media-payloads.ts
Normal file
39
src/agents/pi-embedded-runner/run/tool-media-payloads.ts
Normal 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,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -78,6 +78,8 @@ export type EmbeddedRunAttemptResult = {
|
||||
messagingToolSentTexts: string[];
|
||||
messagingToolSentMediaUrls: string[];
|
||||
messagingToolSentTargets: MessagingToolSend[];
|
||||
toolMediaUrls?: string[];
|
||||
toolAudioAsVoice?: boolean;
|
||||
successfulCronAdds?: number;
|
||||
cloudCodeAssistFormatError: boolean;
|
||||
attemptUsage?: NormalizedUsage;
|
||||
|
||||
20
src/agents/pi-embedded-runner/runtime.ts
Normal file
20
src/agents/pi-embedded-runner/runtime.ts
Normal 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;
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -78,6 +78,7 @@ const createRegistry = (diagnostics: PluginDiagnostic[]): PluginRegistry => ({
|
||||
webFetchProviders: [],
|
||||
webSearchProviders: [],
|
||||
memoryEmbeddingProviders: [],
|
||||
agentHarnesses: [],
|
||||
gatewayHandlers: {},
|
||||
httpRoutes: [],
|
||||
cliRegistrars: [],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,6 +23,7 @@ function createStubPluginRegistry(): PluginRegistry {
|
||||
webFetchProviders: [],
|
||||
webSearchProviders: [],
|
||||
memoryEmbeddingProviders: [],
|
||||
agentHarnesses: [],
|
||||
gatewayHandlers: {},
|
||||
httpRoutes: [],
|
||||
cliRegistrars: [],
|
||||
|
||||
55
src/plugin-sdk/agent-harness.ts
Normal file
55
src/plugin-sdk/agent-harness.ts
Normal 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";
|
||||
@@ -28,6 +28,7 @@ import type { OpenClawPluginApi } from "../plugins/types.js";
|
||||
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
|
||||
|
||||
export type {
|
||||
AgentHarness,
|
||||
AnyAgentTool,
|
||||
MediaUnderstandingProviderPlugin,
|
||||
OpenClawPluginApi,
|
||||
|
||||
@@ -42,6 +42,7 @@ export type {
|
||||
ChannelSetupWizardAllowFromEntry,
|
||||
} from "../channels/plugins/setup-wizard-types.js";
|
||||
export type {
|
||||
AgentHarness,
|
||||
AnyAgentTool,
|
||||
CliBackendPlugin,
|
||||
MediaUnderstandingProviderPlugin,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -20,6 +20,7 @@ export function createEmptyPluginRegistry(): PluginRegistry {
|
||||
webFetchProviders: [],
|
||||
webSearchProviders: [],
|
||||
memoryEmbeddingProviders: [],
|
||||
agentHarnesses: [],
|
||||
gatewayHandlers: {},
|
||||
gatewayMethodScopes: {},
|
||||
httpRoutes: [],
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -215,6 +215,7 @@ describe("plugin runtime command execution", () => {
|
||||
provider: DEFAULT_PROVIDER,
|
||||
});
|
||||
expectFunctionKeys(runtime.agent as Record<string, unknown>, [
|
||||
"runEmbeddedAgent",
|
||||
"runEmbeddedPiAgent",
|
||||
"resolveAgentDir",
|
||||
]);
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { runEmbeddedPiAgent } from "../../agents/pi-embedded.js";
|
||||
export { runEmbeddedAgent, runEmbeddedPiAgent } from "../../agents/pi-embedded.js";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -36,6 +36,7 @@ export const createTestRegistry = (channels: TestChannelRegistration[] = []): Pl
|
||||
webFetchProviders: [],
|
||||
webSearchProviders: [],
|
||||
memoryEmbeddingProviders: [],
|
||||
agentHarnesses: [],
|
||||
gatewayHandlers: {},
|
||||
gatewayMethodScopes: {},
|
||||
httpRoutes: [],
|
||||
|
||||
@@ -39,6 +39,7 @@ export function createTestPluginApi(api: TestPluginApiInput = {}): OpenClawPlugi
|
||||
registerCommand() {},
|
||||
registerContextEngine() {},
|
||||
registerCompactionProvider() {},
|
||||
registerAgentHarness() {},
|
||||
registerMemoryCapability() {},
|
||||
registerMemoryPromptSection() {},
|
||||
registerMemoryPromptSupplement() {},
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user