Skip to content

Commit 4c78b7b

Browse files
committed
fix(agents): snapshot harness registrations
1 parent ec1cd7b commit 4c78b7b

4 files changed

Lines changed: 291 additions & 16 deletions

File tree

src/agents/harness/registry.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,72 @@ describe("agent harness registry", () => {
7979
expect(listAgentHarnessIds()).toEqual(["custom"]);
8080
});
8181

82+
it("snapshots harness fields before registry lookup", async () => {
83+
let idReads = 0;
84+
let labelReads = 0;
85+
let supportsReads = 0;
86+
let resetReads = 0;
87+
const events: string[] = [];
88+
89+
registerAgentHarness(
90+
{
91+
marker: "original",
92+
get id() {
93+
idReads += 1;
94+
if (idReads > 1) {
95+
throw new Error("agent harness id getter re-read");
96+
}
97+
return " volatile ";
98+
},
99+
get label() {
100+
labelReads += 1;
101+
if (labelReads > 1) {
102+
throw new Error("agent harness label getter re-read");
103+
}
104+
return "Volatile";
105+
},
106+
get supports() {
107+
supportsReads += 1;
108+
if (supportsReads > 1) {
109+
throw new Error("agent harness supports getter re-read");
110+
}
111+
return function (this: { marker?: string }) {
112+
events.push(`supports:${this.marker ?? "missing"}`);
113+
return { supported: true as const, priority: 20 };
114+
};
115+
},
116+
async runAttempt() {
117+
throw new Error("not used");
118+
},
119+
get reset() {
120+
resetReads += 1;
121+
if (resetReads > 1) {
122+
throw new Error("agent harness reset getter re-read");
123+
}
124+
return async function (this: { marker?: string }) {
125+
events.push(`reset:${this.marker ?? "missing"}`);
126+
};
127+
},
128+
} as AgentHarness & { marker: string },
129+
{ ownerPluginId: "plugin-a" },
130+
);
131+
132+
const harness = getAgentHarness("volatile");
133+
expect(harness?.id).toBe("volatile");
134+
expect(harness?.label).toBe("Volatile");
135+
expect(harness?.pluginId).toBe("plugin-a");
136+
expect(harness?.supports({ provider: "codex", requestedRuntime: "auto" })).toEqual({
137+
supported: true,
138+
priority: 20,
139+
});
140+
await harness?.reset?.({ reason: "reset" });
141+
expect(events).toEqual(["supports:original", "reset:original"]);
142+
expect(idReads).toBe(1);
143+
expect(labelReads).toBe(1);
144+
expect(supportsReads).toBe(1);
145+
expect(resetReads).toBe(1);
146+
});
147+
82148
it("restores a registry snapshot", () => {
83149
registerAgentHarness(makeHarness("a"));
84150
const snapshot = listRegisteredAgentHarnesses();

src/agents/harness/registry.ts

Lines changed: 88 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@
22
* Registry for native agent harness implementations and lifecycle cleanup.
33
*/
44
import { createSubsystemLogger } from "../../logging/subsystem.js";
5-
import type { AgentHarness, AgentHarnessResetParams, RegisteredAgentHarness } from "./types.js";
5+
import type {
6+
AgentHarness,
7+
AgentHarnessDeliveryDefaults,
8+
AgentHarnessResetParams,
9+
RegisteredAgentHarness,
10+
} from "./types.js";
611

712
/**
813
* Process-wide registry for agent harnesses contributed by core and runtime plugins.
@@ -17,6 +22,20 @@ type AgentHarnessRegistryState = {
1722
harnesses: Map<string, RegisteredAgentHarness>;
1823
};
1924

25+
const agentHarnessSnapshotFields = [
26+
"label",
27+
"pluginId",
28+
"contextEngineHostCapabilities",
29+
"deliveryDefaults",
30+
"supports",
31+
"runAttempt",
32+
"runSideQuestion",
33+
"classify",
34+
"compact",
35+
"reset",
36+
"dispose",
37+
] as const satisfies readonly (keyof AgentHarness)[];
38+
2039
function getAgentHarnessRegistryState(): AgentHarnessRegistryState {
2140
const globalState = globalThis as typeof globalThis & {
2241
[AGENT_HARNESS_REGISTRY_STATE]?: AgentHarnessRegistryState;
@@ -27,18 +46,80 @@ function getAgentHarnessRegistryState(): AgentHarnessRegistryState {
2746
return globalState[AGENT_HARNESS_REGISTRY_STATE];
2847
}
2948

49+
function bindAgentHarnessFunction<TFunction>(harness: AgentHarness, fn: TFunction): TFunction {
50+
if (typeof fn !== "function") {
51+
return fn;
52+
}
53+
return function (this: unknown, ...args: unknown[]) {
54+
return Reflect.apply(fn as (...args: unknown[]) => unknown, harness, args);
55+
} as TFunction;
56+
}
57+
58+
function snapshotAgentHarnessDeliveryDefaults(
59+
value: unknown,
60+
): AgentHarnessDeliveryDefaults | undefined {
61+
if (value === undefined) {
62+
return undefined;
63+
}
64+
if (!value || typeof value !== "object") {
65+
return value as AgentHarnessDeliveryDefaults;
66+
}
67+
const source = value as Partial<AgentHarnessDeliveryDefaults>;
68+
if (source.sourceVisibleReplies === undefined) {
69+
return {};
70+
}
71+
return { sourceVisibleReplies: source.sourceVisibleReplies };
72+
}
73+
74+
function snapshotAgentHarnessValue(
75+
harness: AgentHarness,
76+
key: (typeof agentHarnessSnapshotFields)[number],
77+
value: unknown,
78+
): unknown {
79+
if (typeof value === "function") {
80+
return bindAgentHarnessFunction(harness, value);
81+
}
82+
if (key === "contextEngineHostCapabilities" && Array.isArray(value)) {
83+
return [...value];
84+
}
85+
if (key === "deliveryDefaults") {
86+
return snapshotAgentHarnessDeliveryDefaults(value);
87+
}
88+
return value;
89+
}
90+
91+
export function snapshotAgentHarness(
92+
harness: AgentHarness,
93+
options: { ownerPluginId?: string } = {},
94+
): AgentHarness {
95+
const rawId = harness.id;
96+
const id = typeof rawId === "string" ? rawId.trim() : "";
97+
const snapshot = { id } as Partial<AgentHarness>;
98+
for (const key of agentHarnessSnapshotFields) {
99+
const value = harness[key];
100+
if (value === undefined) {
101+
continue;
102+
}
103+
snapshot[key] = snapshotAgentHarnessValue(harness, key, value) as never;
104+
}
105+
if (!snapshot.pluginId && options.ownerPluginId) {
106+
snapshot.pluginId = options.ownerPluginId;
107+
}
108+
return snapshot as AgentHarness;
109+
}
110+
30111
/** Registers or replaces an agent harness under its trimmed id. */
31112
export function registerAgentHarness(
32113
harness: AgentHarness,
33114
options?: { ownerPluginId?: string },
34115
): void {
35-
const id = harness.id.trim();
116+
const snapshot = snapshotAgentHarness(harness, options);
117+
const id = snapshot.id;
118+
if (!id) {
119+
throw new Error("agent harness registration missing id");
120+
}
36121
getAgentHarnessRegistryState().harnesses.set(id, {
37-
harness: {
38-
...harness,
39-
id,
40-
pluginId: harness.pluginId ?? options?.ownerPluginId,
41-
},
122+
harness: snapshot,
42123
ownerPluginId: options?.ownerPluginId,
43124
});
44125
}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
// Agent harness registration contracts cover plugin-owned runtime harness snapshots.
2+
import {
3+
createPluginRegistryFixture,
4+
registerVirtualTestPlugin,
5+
} from "openclaw/plugin-sdk/plugin-test-contracts";
6+
import { afterEach, describe, expect, it } from "vitest";
7+
import { clearAgentHarnesses, getRegisteredAgentHarness } from "../../agents/harness/registry.js";
8+
import type { AgentHarness } from "../../agents/harness/types.js";
9+
10+
afterEach(() => {
11+
clearAgentHarnesses();
12+
});
13+
14+
describe("agent harness registration", () => {
15+
it("snapshots harness fields before runtime lookup", async () => {
16+
let idReads = 0;
17+
let labelReads = 0;
18+
let supportsReads = 0;
19+
let runAttemptReads = 0;
20+
let resetReads = 0;
21+
let disposeReads = 0;
22+
const events: string[] = [];
23+
const { config, registry } = createPluginRegistryFixture();
24+
25+
registerVirtualTestPlugin({
26+
registry,
27+
config,
28+
id: "volatile-harness-owner",
29+
name: "Volatile Harness Owner",
30+
register(api) {
31+
api.registerAgentHarness({
32+
marker: "original",
33+
get id() {
34+
idReads += 1;
35+
if (idReads > 1) {
36+
throw new Error("agent harness id getter re-read");
37+
}
38+
return " volatile-harness ";
39+
},
40+
get label() {
41+
labelReads += 1;
42+
if (labelReads > 1) {
43+
throw new Error("agent harness label getter re-read");
44+
}
45+
return "Volatile Harness";
46+
},
47+
get supports() {
48+
supportsReads += 1;
49+
if (supportsReads > 1) {
50+
throw new Error("agent harness supports getter re-read");
51+
}
52+
return function (this: { marker?: string }) {
53+
events.push(`supports:${this.marker ?? "missing"}`);
54+
return { supported: true as const, priority: 50 };
55+
};
56+
},
57+
get runAttempt() {
58+
runAttemptReads += 1;
59+
if (runAttemptReads > 1) {
60+
throw new Error("agent harness runAttempt getter re-read");
61+
}
62+
return async function (this: { marker?: string }) {
63+
events.push(`run:${this.marker ?? "missing"}`);
64+
return { ok: false as const, error: "unused" };
65+
};
66+
},
67+
get reset() {
68+
resetReads += 1;
69+
if (resetReads > 1) {
70+
throw new Error("agent harness reset getter re-read");
71+
}
72+
return async function (this: { marker?: string }) {
73+
events.push(`reset:${this.marker ?? "missing"}`);
74+
};
75+
},
76+
get dispose() {
77+
disposeReads += 1;
78+
if (disposeReads > 1) {
79+
throw new Error("agent harness dispose getter re-read");
80+
}
81+
return async function (this: { marker?: string }) {
82+
events.push(`dispose:${this.marker ?? "missing"}`);
83+
};
84+
},
85+
} as AgentHarness & { marker: string });
86+
},
87+
});
88+
89+
expect(registry.registry.diagnostics).toEqual([]);
90+
const localHarness = registry.registry.agentHarnesses[0]?.harness;
91+
const globalHarness = getRegisteredAgentHarness("volatile-harness")?.harness;
92+
expect(localHarness?.id).toBe("volatile-harness");
93+
expect(localHarness?.pluginId).toBe("volatile-harness-owner");
94+
expect(globalHarness?.id).toBe("volatile-harness");
95+
expect(globalHarness?.pluginId).toBe("volatile-harness-owner");
96+
expect(localHarness?.label).toBe("Volatile Harness");
97+
expect(localHarness?.supports({ provider: "codex", requestedRuntime: "auto" })).toEqual({
98+
supported: true,
99+
priority: 50,
100+
});
101+
await expect(localHarness?.runAttempt({} as never)).resolves.toEqual({
102+
ok: false,
103+
error: "unused",
104+
});
105+
await globalHarness?.reset?.({ reason: "reset" });
106+
await globalHarness?.dispose?.();
107+
expect(events).toEqual([
108+
"supports:original",
109+
"run:original",
110+
"reset:original",
111+
"dispose:original",
112+
]);
113+
expect(idReads).toBe(1);
114+
expect(labelReads).toBe(1);
115+
expect(supportsReads).toBe(1);
116+
expect(runAttemptReads).toBe(1);
117+
expect(resetReads).toBe(1);
118+
expect(disposeReads).toBe(1);
119+
});
120+
});

src/plugins/registry.ts

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { clearCodeModeNamespacesForPlugin } from "../agents/code-mode-namespaces
1313
import {
1414
getRegisteredAgentHarness,
1515
registerAgentHarness as registerGlobalAgentHarness,
16+
snapshotAgentHarness,
1617
} from "../agents/harness/registry.js";
1718
import type { AgentHarness } from "../agents/harness/types.js";
1819
import type { AnyAgentTool } from "../agents/tools/common.js";
@@ -1077,7 +1078,19 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
10771078
};
10781079

10791080
const registerAgentHarness = (record: PluginRecord, harness: AgentHarness) => {
1080-
const id = normalizeOptionalString((harness as Partial<AgentHarness> | undefined)?.id) ?? "";
1081+
let snapshot: AgentHarness;
1082+
try {
1083+
snapshot = snapshotAgentHarness(harness, { ownerPluginId: record.id });
1084+
} catch (error) {
1085+
pushDiagnostic({
1086+
level: "error",
1087+
pluginId: record.id,
1088+
source: record.source,
1089+
message: `agent harness registration has unreadable fields: ${formatErrorMessage(error)}`,
1090+
});
1091+
return;
1092+
}
1093+
const id = normalizeOptionalString(snapshot.id) ?? "";
10811094
if (!id) {
10821095
pushDiagnostic({
10831096
level: "error",
@@ -1087,7 +1100,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
10871100
});
10881101
return;
10891102
}
1090-
if (typeof harness.supports !== "function" || typeof harness.runAttempt !== "function") {
1103+
if (typeof snapshot.supports !== "function" || typeof snapshot.runAttempt !== "function") {
10911104
pushDiagnostic({
10921105
level: "error",
10931106
pluginId: record.id,
@@ -1116,19 +1129,14 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
11161129
});
11171130
return;
11181131
}
1119-
const normalizedHarness = {
1120-
...harness,
1121-
id,
1122-
pluginId: harness.pluginId ?? record.id,
1123-
};
11241132
if (registryParams.activateGlobalSideEffects !== false) {
1125-
registerGlobalAgentHarness(normalizedHarness, { ownerPluginId: record.id });
1133+
registerGlobalAgentHarness(snapshot, { ownerPluginId: record.id });
11261134
}
11271135
record.agentHarnessIds.push(id);
11281136
registry.agentHarnesses.push({
11291137
pluginId: record.id,
11301138
pluginName: record.name,
1131-
harness: normalizedHarness,
1139+
harness: snapshot,
11321140
source: record.source,
11331141
rootDir: record.rootDir,
11341142
});

0 commit comments

Comments
 (0)