Skip to content

Commit edc0a22

Browse files
vincentkocsteipete
authored andcommitted
fix(agents): quarantine tools before schema normalization
1 parent 2682c02 commit edc0a22

17 files changed

Lines changed: 807 additions & 55 deletions

extensions/codex/src/app-server/dynamic-tool-build.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,32 @@ describe("Codex app-server dynamic tool build", () => {
179179
expect(resolveCodexDynamicToolsLoading({}, privateQaCodexEnv)).toBe("direct");
180180
});
181181

182+
it("quarantines unreadable tool entries before Codex-specific filtering", async () => {
183+
const messageTool = createRuntimeDynamicTool("message");
184+
const sourceTools = new Proxy([messageTool] as RuntimeDynamicToolForTest[], {
185+
get(target, property, receiver) {
186+
if (property === "0") {
187+
throw new Error("fuzzplugin tool entry getter exploded");
188+
}
189+
if (property === "1") {
190+
return messageTool;
191+
}
192+
if (property === "length") {
193+
return 2;
194+
}
195+
return Reflect.get(target, property, receiver);
196+
},
197+
});
198+
setOpenClawCodingToolsFactoryForTests(() => sourceTools);
199+
const sessionFile = path.join(tempDir, "session.jsonl");
200+
const workspaceDir = path.join(tempDir, "workspace");
201+
const params = createParams(sessionFile, workspaceDir);
202+
params.disableTools = false;
203+
params.runtimePlan = createCodexRuntimePlanFixture();
204+
205+
await expect(buildDynamicToolsForTest(params, workspaceDir)).resolves.toEqual([messageTool]);
206+
});
207+
182208
it("limits Codex memory flush runs to managed read and write tools", async () => {
183209
const factoryOptions: unknown[] = [];
184210
setOpenClawCodingToolsFactoryForTests((options) => {

extensions/codex/src/app-server/dynamic-tool-build.ts

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@ import {
22
buildAgentHookContextChannelFields,
33
buildEmbeddedAttemptToolRunContext,
44
embeddedAgentLog,
5+
filterProviderNormalizableTools,
56
isSubagentSessionKey,
67
normalizeAgentRuntimeTools,
78
resolveAttemptSpawnWorkspaceDir,
89
resolveModelAuthMode,
910
resolveSandboxContext,
1011
supportsModelTools,
1112
type EmbeddedRunAttemptParams,
13+
type RuntimeToolSchemaDiagnostic,
1214
} from "openclaw/plugin-sdk/agent-harness-runtime";
1315
import { resolveAgentDir } from "openclaw/plugin-sdk/agent-runtime";
1416
import { isToolAllowed } from "openclaw/plugin-sdk/sandbox";
@@ -265,15 +267,19 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
265267
},
266268
});
267269
toolBuildStages.mark("create-openclaw-coding-tools");
270+
const preNormalizationDiagnostics: RuntimeToolSchemaDiagnostic[] = [];
271+
const readableAllToolProjection = filterProviderNormalizableTools(allTools);
272+
preNormalizationDiagnostics.push(...readableAllToolProjection.diagnostics);
273+
const readableAllTools = [...readableAllToolProjection.tools];
268274
const codexFilteredTools = addNodeShellDynamicToolsIfNeeded(
269275
addSandboxShellDynamicToolsIfAvailable(
270276
isCodexMemoryFlushRun(params)
271-
? filterCodexMemoryFlushDynamicTools(allTools)
272-
: filterCodexDynamicTools(allTools, input.pluginConfig),
273-
allTools,
277+
? filterCodexMemoryFlushDynamicTools(readableAllTools)
278+
: filterCodexDynamicTools(readableAllTools, input.pluginConfig),
279+
readableAllTools,
274280
input,
275281
),
276-
allTools,
282+
readableAllTools,
277283
input,
278284
);
279285
toolBuildStages.mark("codex-filtering");
@@ -295,8 +301,25 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
295301
modelId: params.modelId,
296302
modelApi: params.model.api,
297303
model: params.model,
304+
onPreNormalizationSchemaDiagnostics: (diagnostics) =>
305+
preNormalizationDiagnostics.push(...diagnostics),
298306
});
299307
toolBuildStages.mark("runtime-normalization");
308+
if (preNormalizationDiagnostics.length > 0) {
309+
embeddedAgentLog.warn(
310+
`codex app-server quarantined ${preNormalizationDiagnostics.length} unsupported runtime tool schema${preNormalizationDiagnostics.length === 1 ? "" : "s"} before dynamic tool registration`,
311+
{
312+
runId: params.runId,
313+
sessionId: params.sessionId,
314+
diagnostics: preNormalizationDiagnostics.map((diagnostic) => ({
315+
index: diagnostic.toolIndex,
316+
tool: diagnostic.toolName,
317+
violations: diagnostic.violations.slice(0, 12),
318+
violationCount: diagnostic.violations.length,
319+
})),
320+
},
321+
);
322+
}
300323
const summary = toolBuildStages.snapshot();
301324
if (shouldWarnCodexDynamicToolBuildStageSummary(summary)) {
302325
const phase = input.forceHeartbeatTool ? "registered-tools" : "runtime-tools";
@@ -308,7 +331,7 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
308331
phase,
309332
totalMs: summary.totalMs,
310333
stages: summary.stages,
311-
allToolCount: allTools.length,
334+
allToolCount: readableAllTools.length,
312335
codexFilteredToolCount: codexFilteredTools.length,
313336
visionFilteredToolCount: visionFilteredTools.length,
314337
filteredToolCount: filteredTools.length,

src/agents/embedded-agent-runner/compact.ts

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,10 @@ import {
111111
} from "../session-write-lock.js";
112112
import { createAgentSession, estimateTokens, SessionManager } from "../sessions/index.js";
113113
import { detectRuntimeShell } from "../shell-utils.js";
114-
import { filterRuntimeCompatibleTools } from "../tool-schema-projection.js";
114+
import {
115+
filterProviderNormalizableTools,
116+
filterRuntimeCompatibleTools,
117+
} from "../tool-schema-projection.js";
115118
import { logRuntimeToolSchemaQuarantine } from "../tool-schema-quarantine.js";
116119
import {
117120
classifyCompactionReason,
@@ -827,8 +830,18 @@ async function compactEmbeddedAgentSessionDirectOnce(
827830
modelApi: model.api,
828831
model,
829832
};
830-
const tools = runtimePlan.tools.normalize(
833+
const normalizableToolProjection = filterProviderNormalizableTools(
831834
toolsEnabled ? toolsRaw : [],
835+
);
836+
logRuntimeToolSchemaQuarantine({
837+
diagnostics: normalizableToolProjection.diagnostics,
838+
tools: toolsEnabled ? toolsRaw : [],
839+
runId,
840+
sessionKey: params.sessionKey,
841+
sessionId: params.sessionId,
842+
});
843+
const tools = runtimePlan.tools.normalize(
844+
[...normalizableToolProjection.tools],
832845
runtimePlanModelContext,
833846
);
834847
const bundleMcpRuntime = toolsEnabled
@@ -873,9 +886,22 @@ async function compactEmbeddedAgentSessionDirectOnce(
873886
senderE164: params.senderE164,
874887
warn: (message) => log.warn(message),
875888
});
889+
const normalizableBundledToolProjection = filterProviderNormalizableTools(filteredBundledTools);
890+
if (normalizableBundledToolProjection.diagnostics.length > 0) {
891+
logRuntimeToolSchemaQuarantine({
892+
diagnostics: normalizableBundledToolProjection.diagnostics,
893+
tools: filteredBundledTools,
894+
runId,
895+
sessionKey: params.sessionKey,
896+
sessionId: params.sessionId,
897+
});
898+
}
876899
const normalizedBundledTools =
877900
filteredBundledTools.length > 0
878-
? runtimePlan.tools.normalize(filteredBundledTools, runtimePlanModelContext)
901+
? runtimePlan.tools.normalize(
902+
[...normalizableBundledToolProjection.tools],
903+
runtimePlanModelContext,
904+
)
879905
: filteredBundledTools;
880906
const projectedEffectiveTools = [...tools, ...normalizedBundledTools];
881907
const toolSchemaProjection = filterRuntimeCompatibleTools(projectedEffectiveTools);

src/agents/embedded-agent-runner/run/attempt.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1413,6 +1413,14 @@ export async function runEmbeddedAttempt(
14131413
modelApi: params.model.api,
14141414
model: params.model,
14151415
runtimeHandle: getProviderRuntimeHandle(),
1416+
onPreNormalizationSchemaDiagnostics: (diagnostics, sourceTools) =>
1417+
logRuntimeToolSchemaQuarantine({
1418+
diagnostics,
1419+
tools: sourceTools,
1420+
runId: params.runId,
1421+
sessionKey: params.sessionKey,
1422+
sessionId: params.sessionId,
1423+
}),
14161424
});
14171425
const clientTools = toolsEnabled && !isRawModelRun ? params.clientTools : undefined;
14181426
const bundleMcpEnabled = shouldCreateBundleMcpRuntimeForAttempt({
@@ -1501,6 +1509,14 @@ export async function runEmbeddedAttempt(
15011509
modelApi: params.model.api,
15021510
model: params.model,
15031511
runtimeHandle: getProviderRuntimeHandle(),
1512+
onPreNormalizationSchemaDiagnostics: (diagnostics, sourceTools) =>
1513+
logRuntimeToolSchemaQuarantine({
1514+
diagnostics,
1515+
tools: sourceTools,
1516+
runId: params.runId,
1517+
sessionKey: params.sessionKey,
1518+
sessionId: params.sessionId,
1519+
}),
15041520
})
15051521
: filteredBundledTools;
15061522
const projectedUncompactedEffectiveTools = filterLocalModelLeanTools({

src/agents/runtime-plan/tools.test.ts

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
} from "openclaw/plugin-sdk/agent-runtime-test-contracts";
77
import { beforeEach, describe, expect, it, vi } from "vitest";
88
import { getPluginToolMeta, setPluginToolMeta } from "../../plugins/tools.js";
9+
import type { RuntimeToolSchemaDiagnostic } from "../tool-schema-projection.js";
910
import { logAgentRuntimeToolDiagnostics, normalizeAgentRuntimeTools } from "./tools.js";
1011
import type { AgentRuntimePlan } from "./types.js";
1112

@@ -55,6 +56,82 @@ describe("AgentRuntimePlan tool policy helpers", () => {
5556
});
5657
});
5758

59+
it("quarantines unreadable tools before RuntimePlan normalization", () => {
60+
const healthy = { ...createParameterFreeTool(), name: "healthy" } as AgentTool;
61+
const unreadable = { ...createParameterFreeTool(), name: "fuzzplugin_unreadable" } as AgentTool;
62+
Object.defineProperty(unreadable, "parameters", {
63+
enumerable: true,
64+
get() {
65+
throw new Error("fuzzplugin parameters getter exploded");
66+
},
67+
});
68+
const tools = [unreadable, healthy];
69+
const diagnostics: RuntimeToolSchemaDiagnostic[][] = [];
70+
const normalize = vi.fn((entries: AgentTool[]) => entries);
71+
const runtimePlan = {
72+
tools: {
73+
normalize,
74+
logDiagnostics: vi.fn(),
75+
},
76+
} as unknown as AgentRuntimePlan;
77+
78+
expect(
79+
normalizeAgentRuntimeTools({
80+
runtimePlan,
81+
tools,
82+
provider: "openai",
83+
onPreNormalizationSchemaDiagnostics: (entries) => diagnostics.push([...entries]),
84+
}),
85+
).toEqual([healthy]);
86+
expect(normalize).toHaveBeenCalledWith([healthy], {
87+
workspaceDir: undefined,
88+
modelApi: undefined,
89+
model: undefined,
90+
});
91+
expect(diagnostics).toEqual([
92+
[
93+
{
94+
toolName: "fuzzplugin_unreadable",
95+
toolIndex: 0,
96+
violations: ["fuzzplugin_unreadable.parameters is unreadable"],
97+
},
98+
],
99+
]);
100+
});
101+
102+
it("quarantines non-object schemas before provider schema normalization", () => {
103+
const healthy = { ...createParameterFreeTool(), name: "healthy" } as AgentTool;
104+
const arraySchema = {
105+
...createParameterFreeTool("fuzzplugin_array_root"),
106+
parameters: { type: "array", items: { type: "number" } },
107+
} as unknown as AgentTool;
108+
const diagnostics: RuntimeToolSchemaDiagnostic[][] = [];
109+
mocks.normalizeProviderToolSchemas.mockImplementationOnce(({ tools: entries }) => entries);
110+
111+
expect(
112+
normalizeAgentRuntimeTools({
113+
tools: [arraySchema, healthy],
114+
provider: "openai",
115+
onPreNormalizationSchemaDiagnostics: (entries) => diagnostics.push([...entries]),
116+
}),
117+
).toEqual([healthy]);
118+
expect(mocks.normalizeProviderToolSchemas).toHaveBeenCalledWith(
119+
expect.objectContaining({
120+
tools: [healthy],
121+
provider: "openai",
122+
}),
123+
);
124+
expect(diagnostics).toEqual([
125+
[
126+
{
127+
toolName: "fuzzplugin_array_root",
128+
toolIndex: 0,
129+
violations: ['fuzzplugin_array_root.parameters.type must be "object"'],
130+
},
131+
],
132+
]);
133+
});
134+
58135
it("accepts legacy optional model fields while normalizing RuntimePlan context", () => {
59136
const tools = [createParameterFreeTool()] as AgentTool[];
60137
const normalize = vi.fn(() => tools);
@@ -145,6 +222,96 @@ describe("AgentRuntimePlan tool policy helpers", () => {
145222
});
146223
});
147224

225+
it("does not reread quarantined tools while preserving normalized metadata", () => {
226+
const unreadableName = {
227+
...createParameterFreeTool("fuzzplugin_unreadable_name"),
228+
} as AgentTool;
229+
Object.defineProperty(unreadableName, "name", {
230+
enumerable: true,
231+
get() {
232+
throw new Error("fuzzplugin name getter exploded");
233+
},
234+
});
235+
const healthy = createParameterFreeTool("fixture__lookup_note") as AgentTool;
236+
setPluginToolMeta(healthy, {
237+
pluginId: "bundle-mcp",
238+
optional: false,
239+
mcp: {
240+
serverName: "fixture",
241+
safeServerName: "fixture",
242+
toolName: "lookup_note",
243+
operation: "tool",
244+
},
245+
});
246+
const normalized = {
247+
...healthy,
248+
parameters: normalizedParameterFreeSchema(),
249+
};
250+
const diagnostics: RuntimeToolSchemaDiagnostic[][] = [];
251+
mocks.normalizeProviderToolSchemas.mockReturnValueOnce([normalized]);
252+
253+
const result = normalizeAgentRuntimeTools({
254+
tools: [unreadableName, healthy],
255+
provider: "openai",
256+
onPreNormalizationSchemaDiagnostics: (entries) => diagnostics.push([...entries]),
257+
});
258+
259+
expect(result).toEqual([normalized]);
260+
expect(getPluginToolMeta(result[0])).toMatchObject({
261+
pluginId: "bundle-mcp",
262+
mcp: {
263+
serverName: "fixture",
264+
toolName: "lookup_note",
265+
},
266+
});
267+
expect(diagnostics).toEqual([
268+
[
269+
{
270+
toolName: "tool[0]",
271+
toolIndex: 0,
272+
violations: ["tool[0].name is unreadable"],
273+
},
274+
],
275+
]);
276+
});
277+
278+
it("quarantines unreadable tools before provider schema normalization", () => {
279+
const healthy = { ...createParameterFreeTool(), name: "healthy" } as AgentTool;
280+
const unreadable = { ...createParameterFreeTool(), name: "fuzzplugin_unreadable" } as AgentTool;
281+
Object.defineProperty(unreadable, "parameters", {
282+
enumerable: true,
283+
get() {
284+
throw new Error("fuzzplugin parameters getter exploded");
285+
},
286+
});
287+
const tools = [unreadable, healthy];
288+
const diagnostics: RuntimeToolSchemaDiagnostic[][] = [];
289+
mocks.normalizeProviderToolSchemas.mockImplementationOnce(({ tools: entries }) => entries);
290+
291+
expect(
292+
normalizeAgentRuntimeTools({
293+
tools,
294+
provider: "openai",
295+
onPreNormalizationSchemaDiagnostics: (entries) => diagnostics.push([...entries]),
296+
}),
297+
).toEqual([healthy]);
298+
expect(mocks.normalizeProviderToolSchemas).toHaveBeenCalledWith(
299+
expect.objectContaining({
300+
tools: [healthy],
301+
provider: "openai",
302+
}),
303+
);
304+
expect(diagnostics).toEqual([
305+
[
306+
{
307+
toolName: "fuzzplugin_unreadable",
308+
toolIndex: 0,
309+
violations: ["fuzzplugin_unreadable.parameters is unreadable"],
310+
},
311+
],
312+
]);
313+
});
314+
148315
it("can normalize without cold-loading provider runtime plugins", () => {
149316
const tools = [createParameterFreeTool()] as AgentTool[];
150317

0 commit comments

Comments
 (0)