Skip to content

Commit db5373a

Browse files
committed
fix: improve write/tool schema compatibility
1 parent 71c37d0 commit db5373a

6 files changed

Lines changed: 192 additions & 7 deletions

File tree

src/provider/runtime-interception.ts

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ export async function handleToolLoopEventLegacy(
102102
event,
103103
toolLoopMode,
104104
allowedToolNames,
105-
toolSchemaMap: _toolSchemaMap,
105+
toolSchemaMap,
106106
toolLoopGuard,
107107
toolMapper,
108108
toolSessionId,
@@ -121,11 +121,60 @@ export async function handleToolLoopEventLegacy(
121121
? extractOpenAiToolCall(event as any, allowedToolNames)
122122
: null;
123123
if (interceptedToolCall) {
124-
const termination = evaluateToolLoopGuard(toolLoopGuard, interceptedToolCall);
124+
const compat = applyToolSchemaCompat(interceptedToolCall, toolSchemaMap);
125+
let normalizedToolCall = compat.toolCall;
126+
log.debug("Applied tool schema compatibility (legacy)", {
127+
tool: normalizedToolCall.function.name,
128+
originalArgKeys: compat.originalArgKeys,
129+
normalizedArgKeys: compat.normalizedArgKeys,
130+
collisionKeys: compat.collisionKeys,
131+
validationOk: compat.validation.ok,
132+
});
133+
134+
if (compat.validation.hasSchema && !compat.validation.ok) {
135+
const validationTermination = evaluateSchemaValidationLoopGuard(
136+
toolLoopGuard,
137+
normalizedToolCall,
138+
compat.validation,
139+
);
140+
if (validationTermination) {
141+
return { intercepted: false, skipConverter: true, terminate: validationTermination };
142+
}
143+
144+
const reroutedWrite = tryRerouteEditToWrite(
145+
normalizedToolCall,
146+
compat.normalizedArgs,
147+
allowedToolNames,
148+
toolSchemaMap,
149+
);
150+
if (reroutedWrite) {
151+
log.info("Rerouting malformed edit call to write (legacy)", {
152+
path: reroutedWrite.path,
153+
missing: compat.validation.missing,
154+
typeErrors: compat.validation.typeErrors,
155+
});
156+
normalizedToolCall = reroutedWrite.toolCall;
157+
} else if (shouldEmitNonFatalSchemaValidationHint(normalizedToolCall, compat.validation)) {
158+
const hintChunk = createNonFatalSchemaValidationHintChunk(
159+
responseMeta,
160+
normalizedToolCall,
161+
compat.validation,
162+
);
163+
log.debug("Emitting non-fatal schema validation hint in legacy and skipping malformed tool execution", {
164+
tool: normalizedToolCall.function.name,
165+
missing: compat.validation.missing,
166+
typeErrors: compat.validation.typeErrors,
167+
});
168+
await onToolResult(hintChunk);
169+
return { intercepted: false, skipConverter: true };
170+
}
171+
}
172+
173+
const termination = evaluateToolLoopGuard(toolLoopGuard, normalizedToolCall);
125174
if (termination) {
126175
return { intercepted: false, skipConverter: true, terminate: termination };
127176
}
128-
await onInterceptedToolCall(interceptedToolCall);
177+
await onInterceptedToolCall(normalizedToolCall);
129178
return { intercepted: true, skipConverter: true };
130179
}
131180

src/provider/tool-schema-compat.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ const ARG_KEY_ALIASES = new Map<string, string>([
2828
["terminalcommand", "command"],
2929
["contents", "content"],
3030
["text", "content"],
31+
["body", "content"],
32+
["data", "content"],
33+
["payload", "content"],
3134
["streamcontent", "content"],
3235
["recursive", "force"],
3336
["oldstring", "old_string"],
@@ -212,6 +215,29 @@ function normalizeToolSpecificArgs(toolName: string, args: JsonRecord): JsonReco
212215
};
213216
}
214217

218+
if (normalizedToolName === "write") {
219+
const normalized: JsonRecord = { ...args };
220+
221+
// Some model variants confuse write/edit and send edit-style payload keys.
222+
// Map them into canonical write arguments before schema validation/sanitization.
223+
if (normalized.content === undefined && normalized.new_string !== undefined) {
224+
const coerced = coerceToString(normalized.new_string);
225+
if (coerced !== null) {
226+
normalized.content = coerced;
227+
}
228+
delete normalized.new_string;
229+
}
230+
231+
if (normalized.content !== undefined && typeof normalized.content !== "string") {
232+
const coerced = coerceToString(normalized.content);
233+
if (coerced !== null) {
234+
normalized.content = coerced;
235+
}
236+
}
237+
238+
return normalized;
239+
}
240+
215241
if (normalizedToolName !== "edit" || !EDIT_COMPAT_REPAIR_ENABLED) {
216242
return args;
217243
}

src/proxy/prompt-builder.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ export function buildPromptFromMessages(messages: Array<any>, tools: Array<any>)
1818
})
1919
.join("\n");
2020
lines.push(
21-
`SYSTEM: You have access to the following tools. When you need to use one, respond with a tool_call in the standard OpenAI format.\n\nAvailable tools:\n${toolDescs}`,
21+
`SYSTEM: You have access to the following tools. When you need to use one, respond with a tool_call in the standard OpenAI format.\n` +
22+
`Tool guidance: prefer write/edit for file changes; use bash mainly to run commands/tests.\n\nAvailable tools:\n${toolDescs}`,
2223
);
2324
}
2425

src/tools/defaults.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ export function registerDefaultTools(registry: ToolRegistry): void {
88
registry.register({
99
id: "bash",
1010
name: "bash",
11-
description: "Execute a shell command in a safe environment",
11+
description: "Execute a shell command. Use this to run programs/tests; prefer write/edit for creating or modifying files.",
1212
parameters: {
1313
type: "object",
1414
properties: {
@@ -99,7 +99,7 @@ export function registerDefaultTools(registry: ToolRegistry): void {
9999
registry.register({
100100
id: "write",
101101
name: "write",
102-
description: "Write content to a file (creates or overwrites)",
102+
description: "Write content to a file (creates or overwrites). Prefer this over using bash redirection/heredocs for file creation.",
103103
parameters: {
104104
type: "object",
105105
properties: {
@@ -138,7 +138,7 @@ export function registerDefaultTools(registry: ToolRegistry): void {
138138
registry.register({
139139
id: "edit",
140140
name: "edit",
141-
description: "Edit a file by replacing old text with new text",
141+
description: "Edit a file by replacing old text with new text. Use for targeted replacements; use write to overwrite an entire file.",
142142
parameters: {
143143
type: "object",
144144
properties: {

tests/unit/provider-runtime-interception.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,44 @@ describe("provider runtime interception fallback", () => {
240240
expect(interceptedArgs).toContain("\"content\":\"hello\"");
241241
});
242242

243+
it("normalizes legacy arguments using schema compatibility before intercept", async () => {
244+
let interceptedArgs = "";
245+
const result = await handleToolLoopEventLegacy(
246+
createBaseOptions({
247+
event: {
248+
type: "tool_call",
249+
call_id: "c3-legacy",
250+
tool_call: {
251+
writeToolCall: {
252+
args: { filePath: "foo.txt", contents: "hello" },
253+
},
254+
},
255+
} as any,
256+
allowedToolNames: new Set(["write"]),
257+
toolSchemaMap: new Map([
258+
[
259+
"write",
260+
{
261+
type: "object",
262+
properties: {
263+
path: { type: "string" },
264+
content: { type: "string" },
265+
},
266+
required: ["path", "content"],
267+
},
268+
],
269+
]),
270+
onInterceptedToolCall: async (toolCall) => {
271+
interceptedArgs = toolCall.function.arguments;
272+
},
273+
}),
274+
);
275+
276+
expect(result).toEqual({ intercepted: true, skipConverter: true });
277+
expect(interceptedArgs).toContain("\"path\":\"foo.txt\"");
278+
expect(interceptedArgs).toContain("\"content\":\"hello\"");
279+
});
280+
243281
it("repairs edit content payloads into canonical edit arguments in v1", async () => {
244282
let interceptedArgs = "";
245283
const toolResults: any[] = [];

tests/unit/provider-tool-schema-compat.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -603,4 +603,75 @@ describe("tool schema compatibility", () => {
603603
expect(map.has("read")).toBe(true);
604604
expect(map.has("todowrite")).toBe(true);
605605
});
606+
607+
it("coerces non-string write content into a string", () => {
608+
const result = applyToolSchemaCompat(
609+
{
610+
id: "w1",
611+
type: "function",
612+
function: {
613+
name: "write",
614+
arguments: JSON.stringify({
615+
path: "/tmp/a.txt",
616+
content: [{ text: "hello" }, { text: " world" }],
617+
}),
618+
},
619+
},
620+
new Map([
621+
[
622+
"write",
623+
{
624+
type: "object",
625+
properties: {
626+
path: { type: "string" },
627+
content: { type: "string" },
628+
},
629+
required: ["path", "content"],
630+
additionalProperties: false,
631+
},
632+
],
633+
]),
634+
);
635+
636+
const args = JSON.parse(result.toolCall.function.arguments);
637+
expect(args.path).toBe("/tmp/a.txt");
638+
expect(args.content).toBe("hello world");
639+
expect(result.validation.ok).toBe(true);
640+
});
641+
642+
it("repairs write new_string into content", () => {
643+
const result = applyToolSchemaCompat(
644+
{
645+
id: "w2",
646+
type: "function",
647+
function: {
648+
name: "write",
649+
arguments: JSON.stringify({
650+
path: "/tmp/b.txt",
651+
new_string: "hello",
652+
}),
653+
},
654+
},
655+
new Map([
656+
[
657+
"write",
658+
{
659+
type: "object",
660+
properties: {
661+
path: { type: "string" },
662+
content: { type: "string" },
663+
},
664+
required: ["path", "content"],
665+
additionalProperties: false,
666+
},
667+
],
668+
]),
669+
);
670+
671+
const args = JSON.parse(result.toolCall.function.arguments);
672+
expect(args.path).toBe("/tmp/b.txt");
673+
expect(args.content).toBe("hello");
674+
expect(args.new_string).toBeUndefined();
675+
expect(result.validation.ok).toBe(true);
676+
});
606677
});

0 commit comments

Comments
 (0)