mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
fix(agents): snapshot harness registrations
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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<string, RegisteredAgentHarness>;
|
||||
};
|
||||
|
||||
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<TFunction>(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<AgentHarnessDeliveryDefaults>;
|
||||
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<AgentHarness>;
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<AgentHarness> | 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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user