Skip to content

Commit 3761994

Browse files
author
Zoo (VP)
committed
feat(error-interception): improve AI guidance quality for 4 patterns
1 parent b2f6d3e commit 3761994

6 files changed

Lines changed: 294 additions & 8 deletions

File tree

src/core/assistant-message/__tests__/presentAssistantMessage-error-interception.spec.ts

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { describe, it, expect, beforeEach, vi } from "vitest"
44
import { presentAssistantMessage } from "../presentAssistantMessage"
55
import { getTaskErrorState } from "../../tools/error-interception"
6+
import { ToolErrorInterceptor } from "../../tools/error-interception/ToolErrorInterceptor"
67
import { NativeToolCallParser } from "../NativeToolCallParser"
78

89
// Mock heavy dependencies that are not relevant to error interception paths.
@@ -667,8 +668,9 @@ describe("presentAssistantMessage - Error Interception Integration", () => {
667668
expect(String(toolResult.content)).toContain("INVALID_JSON_ARGUMENTS")
668669
// The guidance should mention concatenation
669670
expect(String(toolResult.content)).toContain("concatenated")
670-
// Should mention one tool call per file
671-
expect(String(toolResult.content)).toContain("one tool call per file")
671+
// Should mention sequential (one at a time) calls, not parallel
672+
expect(String(toolResult.content)).toContain("ONE AT A TIME")
673+
expect(String(toolResult.content)).toContain("never in parallel")
672674
// Should NOT contain the generic PARAM_MISSING message
673675
expect(String(toolResult.content)).not.toContain("PARAM_MISSING")
674676
// consecutiveMistakeCount should increment
@@ -704,6 +706,39 @@ describe("presentAssistantMessage - Error Interception Integration", () => {
704706
expect(String(toolResult.content)).toContain("PARAM_MISSING")
705707
expect(String(toolResult.content)).not.toContain("INVALID_JSON_ARGUMENTS")
706708
})
709+
710+
describe("CONTEXT_OVERFLOW guidance", () => {
711+
it("produces guided_runtime_error payload with correct guidance for context overflow", () => {
712+
// Directly test the interceptor's transformError path for a
713+
// context overflow signal, simulating an API request that was
714+
// rejected due to context window limits.
715+
const interceptor = new ToolErrorInterceptor()
716+
const task = {}
717+
718+
const signal = {
719+
source: "api_request" as const,
720+
stage: "api" as const,
721+
taskId: "ei-task-id",
722+
metadata: { contextWindowExceeded: true },
723+
}
724+
725+
const message = interceptor.transformError(task, signal)
726+
expect(message).toBeDefined()
727+
728+
const parsed = JSON.parse(message!)
729+
expect(parsed.version).toBe(1)
730+
expect(parsed.status).toBe("error")
731+
expect(parsed.type).toBe("guided_runtime_error")
732+
expect(parsed.category).toBe("CONTEXT_OVERFLOW")
733+
expect(parsed.retryable).toBe(true)
734+
expect(parsed.pattern_id).toBe("EI/CONTEXT_OVERFLOW/001")
735+
expect(parsed.what).toContain("context")
736+
expect(parsed.what).toContain("exceeded")
737+
expect(parsed.next.length).toBeGreaterThan(0)
738+
expect(parsed.next.some((n: string) => n.includes("summary"))).toBe(true)
739+
expect(parsed.next.some((n: string) => n.includes("Do not repeat"))).toBe(true)
740+
})
741+
})
707742
})
708743
})
709744
})

src/core/tools/error-interception/ErrorClassifier.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ const SAFE_FACT_KEYS = new Set<string>([
1717
"invalidProtocol",
1818
"missingNativeArgs",
1919
"missingParameter",
20+
"parameterName",
2021
"pathEmpty",
2122
"repetitionCount",
2223
"retryDisposition",
@@ -61,6 +62,59 @@ function hasToolContext(signal: InterceptionSignal): boolean {
6162
return signal.toolName !== undefined || signal.toolCallId !== undefined
6263
}
6364

65+
/**
66+
* Extract a parameter name from an error message or result text.
67+
*
68+
* Common patterns from tool execution errors:
69+
* - "Required parameter 'path' is missing"
70+
* - "The 'path' parameter must be a string"
71+
* - "Missing required parameter: command"
72+
* - "parameter 'path' is required"
73+
*/
74+
function extractParameterName(signal: InterceptionSignal): string | undefined {
75+
// Check metadata first (explicitly provided by the caller).
76+
const metaName = signal.metadata["parameterName"]
77+
if (typeof metaName === "string" && metaName.length > 0) return metaName
78+
79+
// Try to extract from error.message.
80+
if (signal.error !== null && typeof signal.error === "object") {
81+
const message = (signal.error as { message?: unknown }).message
82+
if (typeof message === "string") {
83+
const name = tryExtractParamNameFromText(message)
84+
if (name) return name
85+
}
86+
}
87+
88+
// Try to extract from result.text.
89+
if (typeof signal.result === "object" && signal.result !== null) {
90+
const text = (signal.result as { text?: unknown }).text
91+
if (typeof text === "string") {
92+
const name = tryExtractParamNameFromText(text)
93+
if (name) return name
94+
}
95+
}
96+
97+
return undefined
98+
}
99+
100+
function tryExtractParamNameFromText(text: string): string | undefined {
101+
// Pattern: "parameter 'name'" or "parameter \"name\"" or "parameter: name"
102+
const paramQuoteMatch = text.match(/parameter\s*['"']([^'"']+)['"']/i)
103+
if (paramQuoteMatch) return paramQuoteMatch[1]
104+
105+
// Pattern: "Required parameter 'name'" — already covered above, but also
106+
// try "Missing required parameter: name" (colon-separated, no quotes).
107+
const colonMatch = text.match(/(?:missing|required)\s+parameter\s*[:\s]+(\w+)/i)
108+
if (colonMatch) return colonMatch[1]
109+
110+
// Pattern: "The 'name' parameter must be..." — extract the quoted name
111+
// before the word "parameter".
112+
const theParamMatch = text.match(/the\s+['"']([^'"']+)['"']\s+parameter/i)
113+
if (theParamMatch) return theParamMatch[1]
114+
115+
return undefined
116+
}
117+
64118
function isEligible(pattern: ErrorPattern, signal: InterceptionSignal): boolean {
65119
if (pattern.category === "UNCLASSIFIED") return false
66120
return !pattern.requiresToolContext || hasToolContext(signal)
@@ -90,6 +144,22 @@ function sanitizeFacts(signal: InterceptionSignal, pattern: ErrorPattern): Reado
90144
facts.category = pattern.category
91145
facts.errorSource = signal.source
92146

147+
// Inject extracted parameter name for PARAM_MISSING and generic
148+
// PARAM_TYPE_MISMATCH patterns so the transformer can include it in
149+
// guidance messages. Skip the CWD_OBJECT_MISUSE and NESTED_PARAM_OVERFLOW
150+
// variants — they have their own specific guidance.
151+
if (
152+
pattern.category === "PARAM_MISSING" ||
153+
(pattern.category === "PARAM_TYPE_MISMATCH" && pattern.id === "EI/PARAM_TYPE_MISMATCH/001")
154+
) {
155+
if (facts.parameterName === undefined) {
156+
const paramName = extractParameterName(signal)
157+
if (paramName !== undefined) {
158+
facts.parameterName = paramName
159+
}
160+
}
161+
}
162+
93163
return Object.freeze(facts)
94164
}
95165

src/core/tools/error-interception/MessageTransformer.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,17 +50,40 @@ function resolveTemplate(patternId: string) {
5050
}
5151

5252
function buildPayload(classification: ErrorClassification, occurrence: number): GuidancePayload {
53-
const { category, patternId, retryPolicy } = classification
53+
const { category, patternId, retryPolicy, facts } = classification
5454
const template = resolveTemplate(patternId)
5555

56+
let what = template.what
57+
let next = template.next
58+
59+
// Inject extracted parameter name into guidance for PARAM_MISSING and
60+
// generic PARAM_TYPE_MISMATCH patterns.
61+
const paramName = facts["parameterName"]
62+
if (typeof paramName === "string" && paramName.length > 0) {
63+
if (category === "PARAM_MISSING") {
64+
what = `Required parameter '${paramName}' is missing.`
65+
next = [
66+
`Provide a valid value for '${paramName}' in a single corrected native tool call.`,
67+
"Retry only once with the complete parameter set.",
68+
]
69+
} else if (category === "PARAM_TYPE_MISMATCH" && patternId === "EI/PARAM_TYPE_MISMATCH/001") {
70+
what = `Parameter '${paramName}' has a type that does not match the tool schema.`
71+
next = [
72+
`Re-read the tool schema for the '${paramName}' parameter.`,
73+
"Correct only the reported field type and keep the rest unchanged.",
74+
"Submit one corrected native tool call; do not repeat blindly.",
75+
]
76+
}
77+
}
78+
5679
return {
5780
version: GUIDANCE_VERSION,
5881
status: "error",
5982
type: payloadType(classification.facts["errorSource"] as ErrorSource | undefined),
6083
category,
61-
what: template.what,
84+
what,
6285
why: template.why,
63-
next: clampNextItems(template.next),
86+
next: clampNextItems(next),
6487
retryable: isRetryable(retryPolicy, category),
6588
occurrence: Math.max(1, occurrence),
6689
pattern_id: patternId,

src/core/tools/error-interception/__tests__/ErrorClassifier.spec.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,79 @@ describe("classifyError", () => {
378378
})
379379
})
380380

381+
describe("parameter name extraction", () => {
382+
it("extracts parameter name from error message for PARAM_MISSING", () => {
383+
const signal = baseSignal({
384+
source: "validation",
385+
stage: "preflight",
386+
error: { message: "Required parameter 'path' is missing" },
387+
metadata: { missingParameter: true },
388+
})
389+
const result = classifyError(signal)
390+
expect(result.category).toBe("PARAM_MISSING")
391+
expect(result.facts.parameterName).toBe("path")
392+
})
393+
394+
it("extracts parameter name from result text for PARAM_MISSING", () => {
395+
const signal = baseSignal({
396+
source: "tool_result",
397+
stage: "result",
398+
result: { status: "missing-parameter", text: "Missing required parameter: command" },
399+
metadata: {},
400+
})
401+
const result = classifyError(signal)
402+
expect(result.category).toBe("PARAM_MISSING")
403+
expect(result.facts.parameterName).toBe("command")
404+
})
405+
406+
it("extracts parameter name from 'The [name] parameter' pattern for PARAM_TYPE_MISMATCH", () => {
407+
const signal = baseSignal({
408+
source: "tool_result",
409+
stage: "result",
410+
error: { code: -32602, message: "The 'path' parameter must be a string" },
411+
metadata: {},
412+
})
413+
const result = classifyError(signal)
414+
expect(result.category).toBe("PARAM_TYPE_MISMATCH")
415+
expect(result.facts.parameterName).toBe("path")
416+
})
417+
418+
it("uses parameterName from metadata when provided", () => {
419+
const signal = baseSignal({
420+
source: "validation",
421+
stage: "preflight",
422+
metadata: { missingParameter: true, parameterName: "command" },
423+
})
424+
const result = classifyError(signal)
425+
expect(result.category).toBe("PARAM_MISSING")
426+
expect(result.facts.parameterName).toBe("command")
427+
})
428+
429+
it("does not set parameterName when no name is extractable", () => {
430+
const signal = baseSignal({
431+
source: "validation",
432+
stage: "preflight",
433+
metadata: { missingParameter: true },
434+
})
435+
const result = classifyError(signal)
436+
expect(result.category).toBe("PARAM_MISSING")
437+
expect(result.facts.parameterName).toBeUndefined()
438+
})
439+
440+
it("does not inject parameterName for CWD_OBJECT_MISUSE variant", () => {
441+
const signal = baseSignal({
442+
source: "validation",
443+
stage: "preflight",
444+
error: { message: "cwd must be a string" },
445+
metadata: { variant: "CWD_OBJECT_MISUSE" },
446+
})
447+
const result = classifyError(signal)
448+
expect(result.category).toBe("PARAM_TYPE_MISMATCH")
449+
expect(result.patternId).toBe("EI/PARAM_TYPE_MISMATCH/002")
450+
expect(result.facts.parameterName).toBeUndefined()
451+
})
452+
})
453+
381454
describe("classifyToolResult", () => {
382455
it("classifies a structured tool result by status", () => {
383456
const result = classifyToolResult({ status: "missing-parameter" }, "task-456", "call-1")

src/core/tools/error-interception/__tests__/MessageTransformer.spec.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,90 @@ describe("transformErrorToMessage", () => {
182182
})
183183
})
184184

185+
describe("parameter name injection in guidance", () => {
186+
it("injects parameter name into PARAM_MISSING guidance when parameterName fact is present", () => {
187+
const classification = {
188+
category: "PARAM_MISSING" as const,
189+
patternId: "EI/PARAM_MISSING/001",
190+
confidence: "exact" as const,
191+
retryPolicy: "correct-and-retry" as const,
192+
facts: { errorSource: "tool_result", parameterName: "path" },
193+
}
194+
const message = transformErrorToMessage(classification)
195+
const parsed = JSON.parse(message)
196+
197+
expect(parsed.category).toBe("PARAM_MISSING")
198+
expect(parsed.what).toContain("'path'")
199+
expect(parsed.what).toContain("missing")
200+
expect(parsed.next[0]).toContain("'path'")
201+
})
202+
203+
it("injects parameter name into PARAM_TYPE_MISMATCH guidance when parameterName fact is present", () => {
204+
const classification = {
205+
category: "PARAM_TYPE_MISMATCH" as const,
206+
patternId: "EI/PARAM_TYPE_MISMATCH/001",
207+
confidence: "exact" as const,
208+
retryPolicy: "correct-and-retry" as const,
209+
facts: { errorSource: "tool_result", parameterName: "command" },
210+
}
211+
const message = transformErrorToMessage(classification)
212+
const parsed = JSON.parse(message)
213+
214+
expect(parsed.category).toBe("PARAM_TYPE_MISMATCH")
215+
expect(parsed.what).toContain("'command'")
216+
expect(parsed.what).toContain("type")
217+
expect(parsed.next[0]).toContain("'command'")
218+
})
219+
220+
it("falls back to generic guidance when parameterName is absent", () => {
221+
const classification = {
222+
category: "PARAM_MISSING" as const,
223+
patternId: "EI/PARAM_MISSING/001",
224+
confidence: "exact" as const,
225+
retryPolicy: "correct-and-retry" as const,
226+
facts: { errorSource: "tool_result" },
227+
}
228+
const message = transformErrorToMessage(classification)
229+
const parsed = JSON.parse(message)
230+
231+
expect(parsed.category).toBe("PARAM_MISSING")
232+
expect(parsed.what).not.toContain("'")
233+
expect(parsed.what).toContain("required parameter")
234+
})
235+
236+
it("does not inject parameter name for CWD_OBJECT_MISUSE variant", () => {
237+
const classification = {
238+
category: "PARAM_TYPE_MISMATCH" as const,
239+
patternId: "EI/PARAM_TYPE_MISMATCH/002",
240+
confidence: "exact" as const,
241+
retryPolicy: "correct-and-retry" as const,
242+
facts: { errorSource: "tool_result", parameterName: "cwd" },
243+
}
244+
const message = transformErrorToMessage(classification)
245+
const parsed = JSON.parse(message)
246+
247+
// CWD_OBJECT_MISUSE has its own specific guidance; parameterName
248+
// should NOT override it.
249+
expect(parsed.what).toContain("parallel tool call")
250+
expect(parsed.what).not.toContain("'cwd'")
251+
})
252+
253+
it("end-to-end: classifies and transforms PARAM_MISSING with parameter name from error message", () => {
254+
const signal = baseSignal({
255+
source: "validation",
256+
stage: "preflight",
257+
error: { message: "Required parameter 'path' is missing" },
258+
metadata: { missingParameter: true },
259+
})
260+
const classification = classifyError(signal)
261+
const message = transformErrorToMessage(classification)
262+
const parsed = JSON.parse(message)
263+
264+
expect(parsed.category).toBe("PARAM_MISSING")
265+
expect(parsed.what).toContain("'path'")
266+
})
267+
})
268+
185269
describe("encode helpers", () => {
186270
it("encodeUtf8Bytes returns the same length as getPayloadByteLength", () => {
187271
const text = '{"what":"test"}'

src/core/tools/error-interception/errorPatterns.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -292,8 +292,8 @@ export const ERROR_PATTERNS: readonly ErrorPattern[] = [
292292
what: "The requested MCP tool or server is not registered or is unavailable.",
293293
why: "The tool name may belong to a different MCP namespace, or the server/tool is disabled.",
294294
next: [
295-
"Select the tool from the returned available server/tool list.",
296-
"Do not guess names or invent namespaces.",
295+
"Check the available MCP tools by examining the tool definitions provided in the system prompt or by using the list_mcp_tools command.",
296+
"Select a tool from the available server/tool list; do not guess names or invent namespaces.",
297297
"If no replacement exists, inform the user and stop retrying.",
298298
],
299299
},
@@ -368,7 +368,8 @@ export const ERROR_PATTERNS: readonly ErrorPattern[] = [
368368
what: "Tool call arguments could not be parsed as JSON.",
369369
why: "You concatenated multiple JSON objects into a single arguments string. Each tool call must contain exactly one valid JSON object.",
370370
next: [
371-
"Issue one tool call per file. Use parallel tool calls if you need multiple files simultaneously.",
371+
"Make tool calls ONE AT A TIME, never in parallel.",
372+
"Each tool call must contain exactly one valid JSON object as arguments.",
372373
],
373374
},
374375
},

0 commit comments

Comments
 (0)