Skip to content

Commit bdfd3c5

Browse files
committed
Merge branch 'main' into cl-6903-orchestrator-reports-it-looped-it-churned-it-cancelled-with
2 parents 83147fe + c0c8abe commit bdfd3c5

4 files changed

Lines changed: 191 additions & 26 deletions

File tree

src/plugins/edit-file-line-range.test.ts

Lines changed: 62 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,65 @@ describe("parseEditFileMode", () => {
5252
expect(mode.message).toContain("only one edit mode is allowed");
5353
expect(mode.message).toContain("Omit old_string");
5454
expect(mode.message).toContain("omit start_line/end_line");
55+
expect(mode.message).toContain("old_string (len 1)");
56+
expect(mode.message).toContain("start_line=1");
57+
expect(mode.message).toContain("end_line=1");
58+
}
59+
});
60+
61+
test("treats filler start_line/end_line of 0 as absent and picks substring mode", () => {
62+
const mode = parseEditFileMode({
63+
path: "a.ts",
64+
old_string: "x",
65+
new_string: "y",
66+
start_line: 0,
67+
end_line: 0,
68+
});
69+
expect(mode.kind).toBe("substring");
70+
});
71+
72+
test("treats null line fields as absent and picks substring mode", () => {
73+
const mode = parseEditFileMode({
74+
path: "a.ts",
75+
old_string: "x",
76+
new_string: "y",
77+
start_line: null,
78+
end_line: null,
79+
});
80+
expect(mode.kind).toBe("substring");
81+
});
82+
83+
test("treats filler empty old_string as absent and picks line-range mode", () => {
84+
const mode = parseEditFileMode({
85+
path: "a.ts",
86+
old_string: "",
87+
start_line: 2,
88+
end_line: 3,
89+
new_string: "z",
90+
});
91+
expect(mode.kind).toBe("line_range");
92+
});
93+
94+
test("empty old_string alone gets a helpful error naming what was received", () => {
95+
const mode = parseEditFileMode({
96+
path: "a.ts",
97+
old_string: "",
98+
new_string: "y",
99+
});
100+
expect(mode.kind).toBe("invalid");
101+
if (mode.kind === "invalid") {
102+
expect(mode.message).toContain("old_string is empty");
103+
expect(mode.message).toContain("old_string (len 0)");
104+
}
105+
});
106+
107+
test("missing both modes reports what was received", () => {
108+
const mode = parseEditFileMode({ path: "a.ts", new_string: "y" });
109+
expect(mode.kind).toBe("invalid");
110+
if (mode.kind === "invalid") {
111+
expect(mode.message).toContain("requires old_string");
112+
expect(mode.message).toContain("no old_string");
113+
expect(mode.message).toContain("no start_line");
55114
}
56115
});
57116

@@ -166,11 +225,9 @@ describe("editFileLineRangePlugin", () => {
166225
});
167226

168227
function handler(next: (call: ToolCall, signal: AbortSignal) => Promise<ToolResult>) {
169-
const mws = [
170-
pathEscapePlugin(cwd).middleware!,
171-
editFileLineRangePlugin().middleware!,
172-
verifyPlugin().middleware!,
173-
];
228+
const mws = [pathEscapePlugin(cwd), editFileLineRangePlugin(), verifyPlugin()].flatMap((p) =>
229+
p.middleware ? [p.middleware] : [],
230+
);
174231
return composeMiddleware(mws, next);
175232
}
176233

src/plugins/edit-file-line-range.ts

Lines changed: 39 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -27,17 +27,42 @@ function optionalInt(value: unknown): number | undefined {
2727
: undefined;
2828
}
2929

30+
// Models pad the unused mode's fields with fillers (old_string: "", start_line: 0).
31+
// For mode selection a filler counts as absent: "" is never a valid old_string and
32+
// 0/null/non-integers are never valid 1-based lines. Treating them as present made
33+
// filler-padded calls look like "both edit modes" and rejected them (CL-6900).
3034
function hasOldStringArg(args: Record<string, unknown>): boolean {
31-
return typeof args.old_string === "string";
35+
return typeof args.old_string === "string" && args.old_string.length > 0;
36+
}
37+
38+
function validLineNumber(value: unknown): number | undefined {
39+
const n = optionalInt(value);
40+
return n !== undefined && n >= 1 ? n : undefined;
3241
}
3342

3443
function hasLineRangeArgs(args: Record<string, unknown>): boolean {
35-
return optionalInt(args.start_line) !== undefined || optionalInt(args.end_line) !== undefined;
44+
return (
45+
validLineNumber(args.start_line) !== undefined || validLineNumber(args.end_line) !== undefined
46+
);
47+
}
48+
49+
function describeReceived(args: Record<string, unknown>): string {
50+
const old = args.old_string;
51+
return [
52+
typeof old === "string" ? `old_string (len ${old.length})` : "no old_string",
53+
args.start_line === undefined
54+
? "no start_line"
55+
: `start_line=${JSON.stringify(args.start_line)}`,
56+
args.end_line === undefined ? "no end_line" : `end_line=${JSON.stringify(args.end_line)}`,
57+
].join(", ");
3658
}
3759

38-
const MIXED_MODE_MESSAGE =
39-
"edit_file: received both old_string and start_line/end_line; only one edit mode is allowed. " +
40-
"Omit old_string to use line-range mode, or omit start_line/end_line to use substring mode.";
60+
function mixedModeMessage(args: Record<string, unknown>): string {
61+
return (
62+
`edit_file: received ${describeReceived(args)}; only one edit mode is allowed. ` +
63+
"Omit old_string to use line-range mode, or omit start_line/end_line to use substring mode."
64+
);
65+
}
4166

4267
export function parseLineRangeFields(
4368
path: string,
@@ -88,30 +113,30 @@ export function parseEditFileMode(args: Record<string, unknown>): EditFileModePa
88113
// slice, producing a confusing "old_string does not match" error even when the caller
89114
// meant plain substring mode (CL-4399). One explicit error beats a wrong guess.
90115
if (substring && lineRange) {
91-
return { kind: "invalid", message: MIXED_MODE_MESSAGE };
116+
return { kind: "invalid", message: mixedModeMessage(args) };
92117
}
93118

94119
if (lineRange) {
95120
return parseLineRangeFields(path, new_string, args);
96121
}
97122

98123
if (!substring) {
124+
const emptyOldString = typeof args.old_string === "string";
99125
return {
100126
kind: "invalid",
101-
message:
102-
"edit_file requires old_string (substring mode) or start_line and end_line (line-range mode)",
127+
message: emptyOldString
128+
? "edit_file: old_string is empty; provide the exact text to replace (substring mode), " +
129+
"or omit it and send start_line/end_line >= 1 (line-range mode). " +
130+
`Received ${describeReceived(args)}.`
131+
: "edit_file requires old_string (substring mode) or start_line and end_line (line-range mode); " +
132+
`received ${describeReceived(args)}`,
103133
};
104134
}
105135

106-
const old_string = String(args.old_string);
107-
if (old_string.length === 0) {
108-
return { kind: "invalid", message: "old_string must not be empty" };
109-
}
110-
111136
return {
112137
kind: "substring",
113138
path,
114-
old_string,
139+
old_string: String(args.old_string),
115140
new_string,
116141
replace_all: Boolean(args.replace_all),
117142
};

src/subagent/task-tool.ts

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,51 @@ function taskToolResult(callId: string, content: string): ToolResult {
195195
return { callId, content, ...(isError ? { isError: true } : {}) };
196196
}
197197

198+
type RequiredTaskField = "description" | "prompt";
199+
200+
const REQUIRED_TASK_FIELD_HINTS: Record<RequiredTaskField, string> = {
201+
description: "a short label for the sub-agent job",
202+
prompt: "the actionable goal for the worker",
203+
};
204+
205+
/** Truncated echo of a received value so the rejection shows what arrived. */
206+
function receivedFieldPreview(value: string): string {
207+
const trimmed = value.trim();
208+
return JSON.stringify(trimmed.length > 80 ? `${trimmed.slice(0, 77)}...` : trimmed);
209+
}
210+
211+
/**
212+
* Rejection naming only the actually-bad required fields, echoing the valid
213+
* one back. A generic "requires description and prompt" hid which field was
214+
* missing, so models retried the identical call verbatim (CL-6901).
215+
*/
216+
function requiredTaskFieldsError(
217+
args: Record<string, unknown>,
218+
bad: readonly RequiredTaskField[],
219+
): string {
220+
const parts = bad.map((name) => {
221+
const value = args[name];
222+
const hint = REQUIRED_TASK_FIELD_HINTS[name];
223+
if (value === undefined) return `is missing ${name} (string): ${hint}`;
224+
if (typeof value !== "string") return `has invalid ${name} (must be a string): ${hint}`;
225+
return `requires a non-empty ${name}: ${hint}`;
226+
});
227+
let message = `Error: task ${parts.join(" and ")}.`;
228+
const good = (Object.keys(REQUIRED_TASK_FIELD_HINTS) as RequiredTaskField[]).filter(
229+
(name) =>
230+
!bad.includes(name) &&
231+
typeof args[name] === "string" &&
232+
(args[name] as string).trim().length > 0,
233+
);
234+
if (good.length > 0) {
235+
const echo = good
236+
.map((name) => `${name} ${receivedFieldPreview(args[name] as string)}`)
237+
.join(" and ");
238+
message += ` Received ${echo} — keep it and add ${bad.join(" and ")}.`;
239+
}
240+
return message;
241+
}
242+
198243
export function createTaskTool(deps: TaskToolDeps): AgentTool {
199244
const run = deps.run;
200245
const telemetry = deps.telemetry ?? NOOP_TELEMETRY;
@@ -206,10 +251,13 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool {
206251
const args = call.arguments;
207252
const parsed = TaskToolArgs(args);
208253
if (parsed instanceof type.errors) {
209-
return taskToolResult(
210-
call.id,
211-
"Error: task requires description (string) and prompt (string).",
254+
const bad = (["description", "prompt"] as const).filter(
255+
(name) => typeof args[name] !== "string",
212256
);
257+
if (bad.length === 0) {
258+
return taskToolResult(call.id, `Error: task arguments invalid: ${parsed.summary}`);
259+
}
260+
return taskToolResult(call.id, requiredTaskFieldsError(args, bad));
213261
}
214262
const {
215263
description: rawDesc,
@@ -233,7 +281,10 @@ export function createTaskTool(deps: TaskToolDeps): AgentTool {
233281
const doNot = rawDoNot?.map((d) => d.trim()).filter((d) => d.length > 0) ?? [];
234282
const reportFocus = rawReportFocus?.trim();
235283
if (description.length === 0 || prompt.length === 0) {
236-
return taskToolResult(call.id, "Error: task requires a non-empty description and prompt.");
284+
const empty = (["description", "prompt"] as const).filter(
285+
(name) => (name === "description" ? description : prompt).length === 0,
286+
);
287+
return taskToolResult(call.id, requiredTaskFieldsError(args, empty));
237288
}
238289

239290
let provider: SubAgentProvider =

tests/unit/subagent.test.ts

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,16 +48,48 @@ test("task tool definition requires description and prompt", () => {
4848
expect(taskToolDefinition.inputSchema.required).toEqual(["description", "prompt"]);
4949
});
5050

51-
test("handler rejects empty description or prompt", async () => {
51+
test("handler rejects empty description or prompt, naming only the empty field", async () => {
5252
const tool = createTaskTool({
5353
permissionGate: testPermissionGate,
5454
cwd: "/repo",
5555
getWorkdirBase: () => "/repo/.ctx",
5656
provider,
5757
run: async () => "should not run",
5858
});
59-
expect(await callHandler(tool, { description: "", prompt: "do it" })).toContain("Error:");
60-
expect(await callHandler(tool, { description: "label", prompt: " " })).toContain("Error:");
59+
const emptyDesc = await callHandler(tool, { description: "", prompt: "do it" });
60+
expect(emptyDesc).toContain("Error: task requires a non-empty description");
61+
expect(emptyDesc).toContain('Received prompt "do it"');
62+
expect(emptyDesc).not.toContain("non-empty prompt");
63+
const emptyPrompt = await callHandler(tool, { description: "label", prompt: " " });
64+
expect(emptyPrompt).toContain("Error: task requires a non-empty prompt");
65+
expect(emptyPrompt).toContain('Received description "label" — keep it and add prompt.');
66+
expect(emptyPrompt).not.toContain("non-empty description");
67+
});
68+
69+
test("handler rejects missing required fields, naming only the missing ones", async () => {
70+
const tool = createTaskTool({
71+
permissionGate: testPermissionGate,
72+
cwd: "/repo",
73+
getWorkdirBase: () => "/repo/.ctx",
74+
provider,
75+
run: async () => "should not run",
76+
});
77+
const missingPrompt = await callHandler(tool, { description: "Add GET /health route" });
78+
expect(missingPrompt).toContain(
79+
"Error: task is missing prompt (string): the actionable goal for the worker.",
80+
);
81+
expect(missingPrompt).toContain(
82+
'Received description "Add GET /health route" — keep it and add prompt.',
83+
);
84+
expect(missingPrompt).not.toContain("missing description");
85+
const missingDesc = await callHandler(tool, { prompt: "do it" });
86+
expect(missingDesc).toContain("Error: task is missing description (string)");
87+
expect(missingDesc).toContain('Received prompt "do it" — keep it and add description.');
88+
expect(missingDesc).not.toContain("missing prompt");
89+
const missingBoth = await callHandler(tool, {});
90+
expect(missingBoth).toContain("Error: task is missing description (string)");
91+
expect(missingBoth).toContain("is missing prompt (string)");
92+
expect(missingBoth).not.toContain("Received");
6193
});
6294

6395
test("generic leaf gets role-default medium even when parent effort is high", async () => {

0 commit comments

Comments
 (0)