From 4c78b7babbcc4465a3864535479241fa4ef4d02b Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 5 Jun 2026 15:37:10 +0200 Subject: [PATCH] fix(agents): snapshot harness registrations --- src/agents/harness/registry.test.ts | 66 ++++++++++ src/agents/harness/registry.ts | 95 +++++++++++++- ...gent-harness-registration.contract.test.ts | 120 ++++++++++++++++++ src/plugins/registry.ts | 26 ++-- 4 files changed, 291 insertions(+), 16 deletions(-) create mode 100644 src/plugins/contracts/agent-harness-registration.contract.test.ts diff --git a/src/agents/harness/registry.test.ts b/src/agents/harness/registry.test.ts index f2bd0c420c6d..955d661e5e17 100644 --- a/src/agents/harness/registry.test.ts +++ b/src/agents/harness/registry.test.ts @@ -79,6 +79,72 @@ describe("agent harness registry", () => { expect(listAgentHarnessIds()).toEqual(["custom"]); }); + it("snapshots harness fields before registry lookup", async () => { + let idReads = 0; + let labelReads = 0; + let supportsReads = 0; + let resetReads = 0; + const events: string[] = []; + + registerAgentHarness( + { + marker: "original", + get id() { + idReads += 1; + if (idReads > 1) { + throw new Error("agent harness id getter re-read"); + } + return " volatile "; + }, + get label() { + labelReads += 1; + if (labelReads > 1) { + throw new Error("agent harness label getter re-read"); + } + return "Volatile"; + }, + get supports() { + supportsReads += 1; + if (supportsReads > 1) { + throw new Error("agent harness supports getter re-read"); + } + return function (this: { marker?: string }) { + events.push(`supports:${this.marker ?? "missing"}`); + return { supported: true as const, priority: 20 }; + }; + }, + async runAttempt() { + throw new Error("not used"); + }, + get reset() { + resetReads += 1; + if (resetReads > 1) { + throw new Error("agent harness reset getter re-read"); + } + return async function (this: { marker?: string }) { + events.push(`reset:${this.marker ?? "missing"}`); + }; + }, + } as AgentHarness & { marker: string }, + { ownerPluginId: "plugin-a" }, + ); + + const harness = getAgentHarness("volatile"); + expect(harness?.id).toBe("volatile"); + expect(harness?.label).toBe("Volatile"); + expect(harness?.pluginId).toBe("plugin-a"); + expect(harness?.supports({ provider: "codex", requestedRuntime: "auto" })).toEqual({ + supported: true, + priority: 20, + }); + await harness?.reset?.({ reason: "reset" }); + expect(events).toEqual(["supports:original", "reset:original"]); + expect(idReads).toBe(1); + expect(labelReads).toBe(1); + expect(supportsReads).toBe(1); + expect(resetReads).toBe(1); + }); + it("restores a registry snapshot", () => { registerAgentHarness(makeHarness("a")); const snapshot = listRegisteredAgentHarnesses(); diff --git a/src/agents/harness/registry.ts b/src/agents/harness/registry.ts index 0d71b21cc4da..744ffa482b65 100644 --- a/src/agents/harness/registry.ts +++ b/src/agents/harness/registry.ts @@ -2,7 +2,12 @@ * Registry for native agent harness implementations and lifecycle cleanup. */ import { createSubsystemLogger } from "../../logging/subsystem.js"; -import type { AgentHarness, AgentHarnessResetParams, RegisteredAgentHarness } from "./types.js"; +import type { + AgentHarness, + AgentHarnessDeliveryDefaults, + AgentHarnessResetParams, + RegisteredAgentHarness, +} from "./types.js"; /** * Process-wide registry for agent harnesses contributed by core and runtime plugins. @@ -17,6 +22,20 @@ type AgentHarnessRegistryState = { harnesses: Map; }; +const agentHarnessSnapshotFields = [ + "label", + "pluginId", + "contextEngineHostCapabilities", + "deliveryDefaults", + "supports", + "runAttempt", + "runSideQuestion", + "classify", + "compact", + "reset", + "dispose", +] as const satisfies readonly (keyof AgentHarness)[]; + function getAgentHarnessRegistryState(): AgentHarnessRegistryState { const globalState = globalThis as typeof globalThis & { [AGENT_HARNESS_REGISTRY_STATE]?: AgentHarnessRegistryState; @@ -27,18 +46,80 @@ function getAgentHarnessRegistryState(): AgentHarnessRegistryState { return globalState[AGENT_HARNESS_REGISTRY_STATE]; } +function bindAgentHarnessFunction(harness: AgentHarness, fn: TFunction): TFunction { + if (typeof fn !== "function") { + return fn; + } + return function (this: unknown, ...args: unknown[]) { + return Reflect.apply(fn as (...args: unknown[]) => unknown, harness, args); + } as TFunction; +} + +function snapshotAgentHarnessDeliveryDefaults( + value: unknown, +): AgentHarnessDeliveryDefaults | undefined { + if (value === undefined) { + return undefined; + } + if (!value || typeof value !== "object") { + return value as AgentHarnessDeliveryDefaults; + } + const source = value as Partial; + if (source.sourceVisibleReplies === undefined) { + return {}; + } + return { sourceVisibleReplies: source.sourceVisibleReplies }; +} + +function snapshotAgentHarnessValue( + harness: AgentHarness, + key: (typeof agentHarnessSnapshotFields)[number], + value: unknown, +): unknown { + if (typeof value === "function") { + return bindAgentHarnessFunction(harness, value); + } + if (key === "contextEngineHostCapabilities" && Array.isArray(value)) { + return [...value]; + } + if (key === "deliveryDefaults") { + return snapshotAgentHarnessDeliveryDefaults(value); + } + return value; +} + +export function snapshotAgentHarness( + harness: AgentHarness, + options: { ownerPluginId?: string } = {}, +): AgentHarness { + const rawId = harness.id; + const id = typeof rawId === "string" ? rawId.trim() : ""; + const snapshot = { id } as Partial; + for (const key of agentHarnessSnapshotFields) { + const value = harness[key]; + if (value === undefined) { + continue; + } + snapshot[key] = snapshotAgentHarnessValue(harness, key, value) as never; + } + if (!snapshot.pluginId && options.ownerPluginId) { + snapshot.pluginId = options.ownerPluginId; + } + return snapshot as AgentHarness; +} + /** Registers or replaces an agent harness under its trimmed id. */ export function registerAgentHarness( harness: AgentHarness, options?: { ownerPluginId?: string }, ): void { - const id = harness.id.trim(); + const snapshot = snapshotAgentHarness(harness, options); + const id = snapshot.id; + if (!id) { + throw new Error("agent harness registration missing id"); + } getAgentHarnessRegistryState().harnesses.set(id, { - harness: { - ...harness, - id, - pluginId: harness.pluginId ?? options?.ownerPluginId, - }, + harness: snapshot, ownerPluginId: options?.ownerPluginId, }); } diff --git a/src/plugins/contracts/agent-harness-registration.contract.test.ts b/src/plugins/contracts/agent-harness-registration.contract.test.ts new file mode 100644 index 000000000000..20f25b883e7f --- /dev/null +++ b/src/plugins/contracts/agent-harness-registration.contract.test.ts @@ -0,0 +1,120 @@ +// Agent harness registration contracts cover plugin-owned runtime harness snapshots. +import { + createPluginRegistryFixture, + registerVirtualTestPlugin, +} from "openclaw/plugin-sdk/plugin-test-contracts"; +import { afterEach, describe, expect, it } from "vitest"; +import { clearAgentHarnesses, getRegisteredAgentHarness } from "../../agents/harness/registry.js"; +import type { AgentHarness } from "../../agents/harness/types.js"; + +afterEach(() => { + clearAgentHarnesses(); +}); + +describe("agent harness registration", () => { + it("snapshots harness fields before runtime lookup", async () => { + let idReads = 0; + let labelReads = 0; + let supportsReads = 0; + let runAttemptReads = 0; + let resetReads = 0; + let disposeReads = 0; + const events: string[] = []; + const { config, registry } = createPluginRegistryFixture(); + + registerVirtualTestPlugin({ + registry, + config, + id: "volatile-harness-owner", + name: "Volatile Harness Owner", + register(api) { + api.registerAgentHarness({ + marker: "original", + get id() { + idReads += 1; + if (idReads > 1) { + throw new Error("agent harness id getter re-read"); + } + return " volatile-harness "; + }, + get label() { + labelReads += 1; + if (labelReads > 1) { + throw new Error("agent harness label getter re-read"); + } + return "Volatile Harness"; + }, + get supports() { + supportsReads += 1; + if (supportsReads > 1) { + throw new Error("agent harness supports getter re-read"); + } + return function (this: { marker?: string }) { + events.push(`supports:${this.marker ?? "missing"}`); + return { supported: true as const, priority: 50 }; + }; + }, + get runAttempt() { + runAttemptReads += 1; + if (runAttemptReads > 1) { + throw new Error("agent harness runAttempt getter re-read"); + } + return async function (this: { marker?: string }) { + events.push(`run:${this.marker ?? "missing"}`); + return { ok: false as const, error: "unused" }; + }; + }, + get reset() { + resetReads += 1; + if (resetReads > 1) { + throw new Error("agent harness reset getter re-read"); + } + return async function (this: { marker?: string }) { + events.push(`reset:${this.marker ?? "missing"}`); + }; + }, + get dispose() { + disposeReads += 1; + if (disposeReads > 1) { + throw new Error("agent harness dispose getter re-read"); + } + return async function (this: { marker?: string }) { + events.push(`dispose:${this.marker ?? "missing"}`); + }; + }, + } as AgentHarness & { marker: string }); + }, + }); + + expect(registry.registry.diagnostics).toEqual([]); + const localHarness = registry.registry.agentHarnesses[0]?.harness; + const globalHarness = getRegisteredAgentHarness("volatile-harness")?.harness; + expect(localHarness?.id).toBe("volatile-harness"); + expect(localHarness?.pluginId).toBe("volatile-harness-owner"); + expect(globalHarness?.id).toBe("volatile-harness"); + expect(globalHarness?.pluginId).toBe("volatile-harness-owner"); + expect(localHarness?.label).toBe("Volatile Harness"); + expect(localHarness?.supports({ provider: "codex", requestedRuntime: "auto" })).toEqual({ + supported: true, + priority: 50, + }); + await expect(localHarness?.runAttempt({} as never)).resolves.toEqual({ + ok: false, + error: "unused", + }); + await globalHarness?.reset?.({ reason: "reset" }); + await globalHarness?.dispose?.(); + expect(events).toEqual([ + "supports:original", + "run:original", + "reset:original", + "dispose:original", + ]); + expect(idReads).toBe(1); + expect(labelReads).toBe(1); + expect(supportsReads).toBe(1); + expect(runAttemptReads).toBe(1); + expect(resetReads).toBe(1); + expect(disposeReads).toBe(1); + }); +}); diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 9d33ec91a534..e891113d4868 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -13,6 +13,7 @@ import { clearCodeModeNamespacesForPlugin } from "../agents/code-mode-namespaces import { getRegisteredAgentHarness, registerAgentHarness as registerGlobalAgentHarness, + snapshotAgentHarness, } from "../agents/harness/registry.js"; import type { AgentHarness } from "../agents/harness/types.js"; import type { AnyAgentTool } from "../agents/tools/common.js"; @@ -1077,7 +1078,19 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { }; const registerAgentHarness = (record: PluginRecord, harness: AgentHarness) => { - const id = normalizeOptionalString((harness as Partial | undefined)?.id) ?? ""; + let snapshot: AgentHarness; + try { + snapshot = snapshotAgentHarness(harness, { ownerPluginId: record.id }); + } catch (error) { + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: `agent harness registration has unreadable fields: ${formatErrorMessage(error)}`, + }); + return; + } + const id = normalizeOptionalString(snapshot.id) ?? ""; if (!id) { pushDiagnostic({ level: "error", @@ -1087,7 +1100,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { }); return; } - if (typeof harness.supports !== "function" || typeof harness.runAttempt !== "function") { + if (typeof snapshot.supports !== "function" || typeof snapshot.runAttempt !== "function") { pushDiagnostic({ level: "error", pluginId: record.id, @@ -1116,19 +1129,14 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { }); return; } - const normalizedHarness = { - ...harness, - id, - pluginId: harness.pluginId ?? record.id, - }; if (registryParams.activateGlobalSideEffects !== false) { - registerGlobalAgentHarness(normalizedHarness, { ownerPluginId: record.id }); + registerGlobalAgentHarness(snapshot, { ownerPluginId: record.id }); } record.agentHarnessIds.push(id); registry.agentHarnesses.push({ pluginId: record.id, pluginName: record.name, - harness: normalizedHarness, + harness: snapshot, source: record.source, rootDir: record.rootDir, });