Skip to content

Commit 4a695dc

Browse files
committed
Nudge once when assistants print tool-call markup as text
Models sometimes emit <tool_call><function=...> wrappers as assistant text instead of real tool_call blocks. Catch that narrow shape, give one corrective nudge per no-real-tool epoch without counting it as incomplete-report narration, then fall through to the existing report policy. Thinking blocks and arbitrary XML stay out of scope; the epoch resets only on genuine tool activity or a parent follow-up.
1 parent 57226a7 commit 4a695dc

2 files changed

Lines changed: 147 additions & 1 deletion

File tree

src/subagent/nudge-director.test.ts

Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,13 +84,20 @@ function inferenceDone(
8484
}
8585

8686
function inferenceDoneText(text: string, inputTokens = 0): ReactorInboundEvent {
87+
return inferenceDoneContent([{ type: "text", text }], inputTokens);
88+
}
89+
90+
function inferenceDoneContent(
91+
content: readonly Record<string, unknown>[],
92+
inputTokens = 0,
93+
): ReactorInboundEvent {
8794
return {
8895
type: "inference.done",
8996
turn: {
9097
role: "assistant",
9198
model: "test",
9299
timestamp: 0,
93-
content: [{ type: "text", text }],
100+
content,
94101
},
95102
usage: {
96103
input: inputTokens,
@@ -419,6 +426,107 @@ const REPORT_ENVELOPE = [
419426
"src/gate.ts",
420427
].join("\n");
421428

429+
describe("SubAgentDirector verbatim tool markup recovery", () => {
430+
const verbatimToolCall =
431+
'<tool_call><function=read_file>{"path":"src/index.ts"}</function></tool_call>';
432+
433+
test("nudges once for explicit tool-call wrapper text before report policy", async () => {
434+
const director = new SubAgentDirector("system", [], undefined, 30);
435+
const caps = capabilities();
436+
437+
const correction = actions(
438+
await director.decide(inferenceDoneText(verbatimToolCall), state, caps),
439+
);
440+
expect(correction).toContainEqual({
441+
type: "checkpoint",
442+
message: "subagent-verbatim-tool-call-nudge",
443+
});
444+
expect(ephemeralTexts(inferAction(correction))?.[0]).toContain(
445+
"real tool call",
446+
);
447+
448+
const reportNudge = actions(
449+
await director.decide(inferenceDoneText(verbatimToolCall), state, caps),
450+
);
451+
expect(reportNudge).toContainEqual({
452+
type: "checkpoint",
453+
message: "subagent-incomplete-report-nudge",
454+
});
455+
456+
const stopped = actions(
457+
await director.decide(inferenceDoneText(verbatimToolCall), state, caps),
458+
);
459+
expect(stopped).toContainEqual({
460+
type: "checkpoint",
461+
message: "subagent-incomplete-report",
462+
});
463+
});
464+
465+
test("does not treat arbitrary XML or thinking as verbatim tool calls", async () => {
466+
const caps = capabilities();
467+
const arbitraryXML = new SubAgentDirector("system", [], undefined, 30);
468+
const arbitraryResult = actions(
469+
await arbitraryXML.decide(
470+
inferenceDoneText("<read_file>src/index.ts</read_file>"),
471+
state,
472+
caps,
473+
),
474+
);
475+
expect(arbitraryResult).toContainEqual({
476+
type: "checkpoint",
477+
message: "subagent-incomplete-report-nudge",
478+
});
479+
480+
const thinkingOnly = new SubAgentDirector("system", [], undefined, 30);
481+
const thinkingResult = actions(
482+
await thinkingOnly.decide(
483+
inferenceDoneContent([
484+
{ type: "thinking", thinking: verbatimToolCall },
485+
]),
486+
state,
487+
caps,
488+
),
489+
);
490+
expect(thinkingResult).toContainEqual({
491+
type: "checkpoint",
492+
message: "subagent-incomplete-report-nudge",
493+
});
494+
});
495+
496+
test("resets correction only after genuine tool activity or parent follow-up", async () => {
497+
const director = new SubAgentDirector("system", [], undefined, 30);
498+
const caps = capabilities();
499+
500+
await director.decide(inferenceDoneText(verbatimToolCall), state, caps);
501+
const narration = actions(
502+
await director.decide(inferenceDoneText("Still working"), state, caps),
503+
);
504+
expect(narration).toContainEqual({
505+
type: "checkpoint",
506+
message: "subagent-incomplete-report-nudge",
507+
});
508+
509+
await director.decide(inferenceDone(["read-1"]), state, caps);
510+
await director.decide(toolDone("read-1"), state, caps);
511+
const afterTool = actions(
512+
await director.decide(inferenceDoneText(verbatimToolCall), state, caps),
513+
);
514+
expect(afterTool).toContainEqual({
515+
type: "checkpoint",
516+
message: "subagent-verbatim-tool-call-nudge",
517+
});
518+
519+
await director.decide(messageReceived("Try again"), state, caps);
520+
const afterFollowup = actions(
521+
await director.decide(inferenceDoneText(verbatimToolCall), state, caps),
522+
);
523+
expect(afterFollowup).toContainEqual({
524+
type: "checkpoint",
525+
message: "subagent-verbatim-tool-call-nudge",
526+
});
527+
});
528+
});
529+
422530
describe("SubAgentDirector incomplete-report wiring", () => {
423531
test("tool-less narration after tools gets one wrap-up nudge, not a complete", async () => {
424532
const director = new SubAgentDirector("system", [], undefined, 30);

src/subagent/nudge-director.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,20 @@ const TOOL_FAILURE_RECOVERY_NUDGE =
4747
const INCOMPLETE_REPORT_NUDGE =
4848
"Write your final report now using ## Summary, ## Findings, ## Blockers, and ## Paths. Do not narrate status. No more tools unless one lookup is required to cite a line.";
4949

50+
const VERBATIM_TOOL_CALL_NUDGE =
51+
"You wrote tool-call markup as assistant text. Invoke the real tool call instead of printing its markup, or write your final report if no tool is needed.";
52+
53+
function hasVerbatimToolCallMarkup(
54+
content: readonly { type: string; text?: string }[],
55+
): boolean {
56+
return content.some(
57+
(block) =>
58+
block.type === "text" &&
59+
typeof block.text === "string" &&
60+
/<tool_call>\s*<(?:function|tool)=[A-Za-z_][\w.-]*>/.test(block.text),
61+
);
62+
}
63+
5064
function ephemeralNudgeTurn(text: string): ConversationTurn {
5165
return {
5266
role: "user",
@@ -128,6 +142,10 @@ export class SubAgentDirector extends DefaultDirector {
128142
// narration without the envelope salvages as incomplete-report
129143
// (MAX_TOOLLESS_NARRATION_CYCLES = 2).
130144
private toolLessNarrationCycles = 0;
145+
// One corrective nudge per no-real-tool epoch when the assistant prints
146+
// explicit tool-call markup as text instead of issuing a real tool_call.
147+
// Cleared only by genuine tool activity or a non-empty parent follow-up.
148+
private verbatimToolCallNudgeFired = false;
131149

132150
// Once this leaf has replied with a terminal report (complete envelope or
133151
// salvage), empty continuations from idle-compact / stall must not fall
@@ -227,6 +245,7 @@ export class SubAgentDirector extends DefaultDirector {
227245
// A real parent follow-up re-opens the brief; empty continuations do not.
228246
if (isNonEmptyParentMessage(event)) {
229247
this.reportReplied = false;
248+
this.verbatimToolCallNudgeFired = false;
230249
}
231250

232251
const afterCompact = this.compaction.resumeAfterCompact(event);
@@ -288,9 +307,28 @@ export class SubAgentDirector extends DefaultDirector {
288307
this.lastAssistantText = lastText(content);
289308
const hasToolCalls = content.some((block) => block.type === "tool_call");
290309
if (hasToolCalls) {
310+
this.verbatimToolCallNudgeFired = false;
291311
this.thrashState = nextThrashState(this.thrashState, content);
292312
}
293313

314+
if (
315+
!hasToolCalls &&
316+
!this.verbatimToolCallNudgeFired &&
317+
hasVerbatimToolCallMarkup(content)
318+
) {
319+
this.verbatimToolCallNudgeFired = true;
320+
this.interventions({
321+
id: "verbatim-tool-call",
322+
class: "nudge",
323+
state: this.interventionState(),
324+
detail: "assistant emitted explicit tool-call markup as text",
325+
});
326+
return [
327+
capabilities.checkpoint("subagent-verbatim-tool-call-nudge"),
328+
inferWithSubAgentNudge(capabilities, VERBATIM_TOOL_CALL_NUDGE),
329+
];
330+
}
331+
294332
const stop = evaluateSubAgentStop({
295333
hasToolCalls,
296334
thrashState: this.thrashState,

0 commit comments

Comments
 (0)