Skip to content

Commit 37008b7

Browse files
nh2navedmerchant
andauthored
fix(ask_followup_question): report non-array follow_up as a type error. Fixes #511 (#662)
A native tool call for `ask_followup_question` with a present-but-non-array `follow_up` (e.g. a keyed object produced by incremental JSON parsing) was forwarded unchecked and then rejected by the tool via `recordMissingParamError`, which always reports the misleading message Missing value for required parameter follow_up The value was the wrong type, not missing, which sent the model into a retry loop with the same payload. Fix: AskFollowupQuestionTool.execute now distinguishes the cases: `null`/`undefined` `follow_up` still reports a missing parameter, while a present-but-non-array value reports a clear type/shape error (surfaced to both the user via `say(error)` and the model via `toolError`) instructing it to retry with a JSON array. `NativeToolCallParser` keeps forwarding the raw follow_up value on the finalize path (with an explanatory comment) rather than guarding with `Array.isArray`, so the tool can emit the precise error instead of the call being silently dropped to null by the generic parser guard. Adds test coverage for string and keyed-object non-array follow_up values, plus a parser finalize test confirming the value is forwarded rather than dropped. Assisted-By: Claude Opus 4.8 in Zoo Code. Co-authored-by: Naved Merchant <naved.merchant@gmail.com>
1 parent e2fce6c commit 37008b7

3 files changed

Lines changed: 86 additions & 3 deletions

File tree

src/core/assistant-message/NativeToolCallParser.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -819,6 +819,12 @@ export class NativeToolCallParser {
819819
break
820820

821821
case "ask_followup_question":
822+
// Require a question and a present follow_up. When follow_up is
823+
// present-but-not-an-array (e.g. an object/string/number produced by
824+
// incremental JSON parsing), we still construct nativeArgs and forward
825+
// the raw value so the tool can emit a precise "must be an array" error
826+
// instead of the generic parser failure, which would surface as a
827+
// misleading "Missing value for required parameter 'follow_up'" error.
822828
if (args.question !== undefined && args.follow_up !== undefined) {
823829
nativeArgs = {
824830
question: args.question,

src/core/tools/AskFollowupQuestionTool.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ interface Suggestion {
1212

1313
interface AskFollowupQuestionParams {
1414
question: string
15+
// follow_up is typed as an array, but at runtime the value may arrive as a
16+
// non-array (object/string/number) due to incremental JSON parsing, so the
17+
// runtime validation in execute() guards against that explicitly.
1518
follow_up: Suggestion[]
1619
}
1720

@@ -29,17 +32,37 @@ export class AskFollowupQuestionTool extends BaseTool<"ask_followup_question"> {
2932
pushToolResult(await task.sayAndCreateMissingParamError("ask_followup_question", paramName))
3033
}
3134

35+
const recordValidationError = async (message: string): Promise<void> => {
36+
task.consecutiveMistakeCount++
37+
task.recordToolError("ask_followup_question")
38+
task.didToolFailInCurrentTurn = true
39+
await task.say("error", message)
40+
pushToolResult(formatResponse.toolError(message))
41+
}
42+
3243
try {
3344
if (!question) {
3445
await recordMissingParamError("question")
3546
return
3647
}
3748

38-
if (!follow_up || !Array.isArray(follow_up)) {
49+
// Truly missing follow_up (null/undefined) -> report as a missing parameter.
50+
if (follow_up === undefined || follow_up === null) {
3951
await recordMissingParamError("follow_up")
4052
return
4153
}
4254

55+
// Present-but-wrong-type follow_up (object/string/number) -> report a clear
56+
// type/shape error rather than the misleading "Missing value" message, so the
57+
// model can correct it instead of looping with the same payload.
58+
if (!Array.isArray(follow_up)) {
59+
await recordValidationError(
60+
"The 'follow_up' parameter must be an array of suggestion objects, each shaped like { text: string, mode?: string }. " +
61+
"Retry with 'follow_up' as a JSON array.",
62+
)
63+
return
64+
}
65+
4366
// Transform follow_up suggestions to the format expected by task.ask
4467
const follow_up_json = {
4568
question,

src/core/tools/__tests__/askFollowupQuestionTool.spec.ts

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,15 +83,43 @@ describe("AskFollowupQuestionTool", () => {
8383
expect(mockTask.sayAndCreateMissingParamError).toHaveBeenCalledWith("ask_followup_question", "follow_up")
8484
})
8585

86-
it("should handle follow_up that is not an array", async () => {
86+
it("should report a type error when follow_up is a string", async () => {
8787
const params = { question: "What?", follow_up: "not-an-array" as any }
8888

8989
await tool.execute(params, mockTask, mockCallbacks)
9090

9191
expect(mockTask.consecutiveMistakeCount).toBe(1)
9292
expect(mockTask.recordToolError).toHaveBeenCalledWith("ask_followup_question")
9393
expect(mockTask.didToolFailInCurrentTurn).toBe(true)
94-
expect(mockTask.sayAndCreateMissingParamError).toHaveBeenCalledWith("ask_followup_question", "follow_up")
94+
// A present-but-non-array value is a type error.
95+
expect(mockTask.sayAndCreateMissingParamError).not.toHaveBeenCalled()
96+
const pushed = (mockCallbacks.pushToolResult as any).mock.calls[0][0]
97+
expect(pushed).toContain("must be an array")
98+
expect(pushed).not.toContain("Missing value")
99+
})
100+
101+
it("should report a type error when follow_up is an object", async () => {
102+
// Reproduces the issue: follow_up arrives as a keyed object instead of an array.
103+
const params = {
104+
question: "How should I proceed?",
105+
follow_up: {
106+
"0": { mode: null, text: "Keep the guard" },
107+
"1": { mode: null, text: "Remove the guard" },
108+
} as any,
109+
}
110+
111+
await tool.execute(params, mockTask, mockCallbacks)
112+
113+
expect(mockTask.consecutiveMistakeCount).toBe(1)
114+
expect(mockTask.recordToolError).toHaveBeenCalledWith("ask_followup_question")
115+
expect(mockTask.didToolFailInCurrentTurn).toBe(true)
116+
expect(mockTask.sayAndCreateMissingParamError).not.toHaveBeenCalled()
117+
expect(mockTask.say).toHaveBeenCalledWith("error", expect.stringContaining("must be an array"))
118+
const pushed = (mockCallbacks.pushToolResult as any).mock.calls[0][0]
119+
expect(pushed).toContain("must be an array")
120+
expect(pushed).not.toContain("Missing value")
121+
// The tool must not proceed to ask the user with an invalid payload.
122+
expect(mockTask.ask).not.toHaveBeenCalled()
95123
})
96124

97125
// ===== Happy path tests =====
@@ -513,5 +541,31 @@ describe("AskFollowupQuestionTool", () => {
513541
})
514542
}
515543
})
544+
545+
it("should finalize and forward a non-array follow_up so the tool can report it", () => {
546+
NativeToolCallParser.startStreamingToolCall("call_789", "ask_followup_question")
547+
548+
// follow_up arrives as a keyed object instead of an array (the bug repro).
549+
const completeJson =
550+
'{"question":"How should I proceed?","follow_up":{"0":{"mode":null,"text":"Keep"},"1":{"mode":null,"text":"Remove"}}}'
551+
NativeToolCallParser.processStreamingChunk("call_789", completeJson)
552+
553+
const result = NativeToolCallParser.finalizeStreamingToolCall("call_789")
554+
555+
// The call must NOT be dropped (null) - it should reach the tool with the raw
556+
// value so the tool can emit a precise "must be an array" error.
557+
expect(result).not.toBeNull()
558+
expect(result?.type).toBe("tool_use")
559+
expect(result?.name).toBe("ask_followup_question")
560+
if (result?.type === "tool_use") {
561+
const nativeArgs = result.nativeArgs as { question: string; follow_up: unknown }
562+
expect(nativeArgs.question).toBe("How should I proceed?")
563+
expect(Array.isArray(nativeArgs.follow_up)).toBe(false)
564+
expect(nativeArgs.follow_up).toEqual({
565+
"0": { mode: null, text: "Keep" },
566+
"1": { mode: null, text: "Remove" },
567+
})
568+
}
569+
})
516570
})
517571
})

0 commit comments

Comments
 (0)