From 4b001b437e4103ab7106ad1f9899aa660f77d3e8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Apr 2026 14:27:15 +0000 Subject: [PATCH 1/4] Initial plan From 9ce0c15e8b0ca16adf3ea0b1d6ab501d709a78eb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Apr 2026 14:41:19 +0000 Subject: [PATCH 2/4] fix: detect null-type tool_call and restart fresh; add permanent --continue disable guard Agent-Logs-Url: https://github.com/github/gh-aw/sessions/1bc21234-f151-423f-879b-2cc881234acf Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- ...river-null-type-tool-call-fresh-restart.md | 5 + actions/setup/js/copilot_harness.cjs | 45 +++- actions/setup/js/copilot_harness.test.cjs | 200 ++++++++++++++++++ 3 files changed, 248 insertions(+), 2 deletions(-) create mode 100644 .changeset/patch-copilot-driver-null-type-tool-call-fresh-restart.md diff --git a/.changeset/patch-copilot-driver-null-type-tool-call-fresh-restart.md b/.changeset/patch-copilot-driver-null-type-tool-call-fresh-restart.md new file mode 100644 index 00000000000..38eab1ae112 --- /dev/null +++ b/.changeset/patch-copilot-driver-null-type-tool-call-fresh-restart.md @@ -0,0 +1,5 @@ +--- +"gh-aw": patch +--- + +Fix copilot-driver: detect null-type tool_call 400 error and restart fresh instead of retrying with `--continue`. A malformed tool call with `type: null` poisons the conversation history; retrying via `--continue` re-injects the same broken state and fails identically on every attempt. This change restarts fresh to discard the poisoned history and permanently disables `--continue` for the remainder of the run so the corrupt state can never be reloaded. diff --git a/actions/setup/js/copilot_harness.cjs b/actions/setup/js/copilot_harness.cjs index 93aff5d37c0..52b0fdd05b3 100644 --- a/actions/setup/js/copilot_harness.cjs +++ b/actions/setup/js/copilot_harness.cjs @@ -69,6 +69,14 @@ const MODEL_NOT_SUPPORTED_PATTERN = /The requested model is not supported/; // On a fresh run the token is genuinely absent — retrying will not help. const NO_AUTH_INFO_PATTERN = /No authentication information found/; +// Pattern to detect null-type tool_call error that poisons conversation history. +// Matches the Copilot API 400 error: +// "Invalid type for '...tool_calls[N].type': expected one of 'function', ..., but got null instead." +// The model emitted a malformed tool call with type: null. Retrying with --continue +// re-injects the same broken history, producing the same 400 on every subsequent attempt. +// A fresh restart is required to discard the poisoned history. +const NULL_TYPE_TOOL_CALL_PATTERN = /tool_calls\[.*?\]\.type.*null/; + /** * @typedef {(path: import("node:fs").PathOrFileDescriptor, data: string | Uint8Array, options?: import("node:fs").WriteFileOptions) => void} AppendFileSyncLike */ @@ -125,6 +133,18 @@ function isNoAuthInfoError(output) { return NO_AUTH_INFO_PATTERN.test(output); } +/** + * Determines if the collected output contains a null-type tool_call error. + * This error occurs when the model emits a malformed tool call with type: null. + * The Copilot API rejects it with a 400, and retrying with --continue will re-inject + * the same broken history, causing the same failure on every subsequent attempt. + * @param {string} output - Collected stdout+stderr from the process + * @returns {boolean} + */ +function isNullTypeToolCallError(output) { + return NULL_TYPE_TOOL_CALL_PATTERN.test(output); +} + /** * Sleep for a specified duration * @param {number} ms - Duration in milliseconds @@ -384,6 +404,9 @@ async function main() { let scheduledExit2Retries = 0; let scheduledExit2RetryAttempted = false; let useContinueOnRetry = false; + // Once set to true, --continue is never re-enabled for the remainder of this run. + // This prevents a broken --continue recovery from resurrecting --continue on the next attempt. + let continueDisabledPermanently = false; const driverStartTime = Date.now(); for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { @@ -417,12 +440,14 @@ async function main() { const isMCPPolicy = isMCPPolicyError(result.output); const isModelNotSupported = isModelNotSupportedError(result.output); const isAuthErr = isNoAuthInfoError(result.output); + const isNullTypeToolCall = isNullTypeToolCallError(result.output); log( `attempt ${attempt + 1} failed:` + ` exitCode=${result.exitCode}` + ` isCAPIError400=${isCAPIError}` + ` isMCPPolicyError=${isMCPPolicy}` + ` isModelNotSupportedError=${isModelNotSupported}` + + ` isNullTypeToolCallError=${isNullTypeToolCall}` + ` isAuthError=${isAuthErr}` + ` hasOutput=${result.hasOutput}` + ` retriesRemaining=${MAX_RETRIES - attempt}` @@ -448,6 +473,7 @@ async function main() { if (isAuthErr) { if (useContinueOnRetry && attempt < MAX_RETRIES) { useContinueOnRetry = false; + continueDisabledPermanently = true; log(`attempt ${attempt + 1}: auth error on --continue — retrying as fresh run (session credential may be corrupted; context will be lost)`); continue; } @@ -455,6 +481,20 @@ async function main() { break; } + // Null-type tool_call error: the model emitted a malformed tool call that poisons the + // conversation history. Retrying with --continue re-injects the same broken history and + // produces the same 400 on every subsequent attempt. Restart fresh to discard the poisoned + // history, and permanently disable --continue so the corrupt state is never re-loaded. + if (isNullTypeToolCall) { + if (attempt < MAX_RETRIES && result.hasOutput) { + const priorMode = attempt > 0 && useContinueOnRetry ? "--continue" : "fresh run"; + useContinueOnRetry = false; + continueDisabledPermanently = true; + log(`attempt ${attempt + 1}: null-type tool_call error (${priorMode}) — restarting fresh (poisoned history discarded; --continue disabled permanently)`); + continue; + } + } + // Scheduled runs: retry once on exit code 2 even when no output was produced. // This specifically targets transient Copilot API outages at startup where there is no // partial session state to continue from. @@ -471,8 +511,9 @@ async function main() { if (attempt < MAX_RETRIES && result.hasOutput) { const reason = isCAPIError ? "CAPIError 400 (transient)" : "partial execution"; - useContinueOnRetry = true; - log(`attempt ${attempt + 1}: ${reason} — will retry with --continue (attempt ${attempt + 2}/${MAX_RETRIES + 1})`); + useContinueOnRetry = !continueDisabledPermanently; + const retryMode = useContinueOnRetry ? "--continue" : "fresh run (--continue permanently disabled)"; + log(`attempt ${attempt + 1}: ${reason} — will retry with ${retryMode} (attempt ${attempt + 2}/${MAX_RETRIES + 1})`); continue; } diff --git a/actions/setup/js/copilot_harness.test.cjs b/actions/setup/js/copilot_harness.test.cjs index 69f50c3bb42..8163fef7c79 100644 --- a/actions/setup/js/copilot_harness.test.cjs +++ b/actions/setup/js/copilot_harness.test.cjs @@ -366,6 +366,206 @@ describe("copilot_harness.cjs", () => { }); }); + describe("null-type tool_call detection pattern", () => { + const NULL_TYPE_TOOL_CALL_PATTERN = /tool_calls\[.*?\]\.type.*null/; + + it("matches the exact error from the failed workflow run", () => { + const errorOutput = "Execution failed: CAPIError: 400 Invalid type for 'messages[45].tool_calls[0].type':" + " expected one of 'function', 'all...ols', or 'custom', but got null instead."; + expect(NULL_TYPE_TOOL_CALL_PATTERN.test(errorOutput)).toBe(true); + }); + + it("matches with different array indices", () => { + expect(NULL_TYPE_TOOL_CALL_PATTERN.test("tool_calls[0].type: null")).toBe(true); + expect(NULL_TYPE_TOOL_CALL_PATTERN.test("tool_calls[12].type, got null")).toBe(true); + }); + + it("does not match unrelated tool_calls errors", () => { + expect(NULL_TYPE_TOOL_CALL_PATTERN.test("tool_calls[0].name: missing")).toBe(false); + expect(NULL_TYPE_TOOL_CALL_PATTERN.test("Error: tool call failed")).toBe(false); + }); + + it("does not match unrelated null errors", () => { + expect(NULL_TYPE_TOOL_CALL_PATTERN.test("Unexpected null value in response")).toBe(false); + expect(NULL_TYPE_TOOL_CALL_PATTERN.test("CAPIError: 400 Bad Request")).toBe(false); + expect(NULL_TYPE_TOOL_CALL_PATTERN.test("")).toBe(false); + }); + }); + + describe("null-type tool_call restarts fresh instead of --continue", () => { + // Inline the same retry logic as the driver including null-type tool_call handling + const MCP_POLICY_BLOCKED_PATTERN = /MCP servers were blocked by policy:/; + const NO_AUTH_INFO_PATTERN = /No authentication information found/; + const NULL_TYPE_TOOL_CALL_PATTERN = /tool_calls\[.*?\]\.type.*null/; + const MAX_RETRIES = 3; + + /** + * @param {{hasOutput: boolean, exitCode: number, output: string}} result + * @param {number} attempt + * @param {boolean} useContinueOnRetry + * @param {boolean} continueDisabledPermanently + * @returns {{ shouldRetry: boolean, useContinueOnRetry: boolean, continueDisabledPermanently: boolean }} + */ + function applyRetryPolicy(result, attempt, useContinueOnRetry = false, continueDisabledPermanently = false) { + if (result.exitCode === 0) return { shouldRetry: false, useContinueOnRetry, continueDisabledPermanently }; + if (MCP_POLICY_BLOCKED_PATTERN.test(result.output)) return { shouldRetry: false, useContinueOnRetry, continueDisabledPermanently }; + if (NO_AUTH_INFO_PATTERN.test(result.output)) { + if (useContinueOnRetry && attempt < MAX_RETRIES) { + return { shouldRetry: true, useContinueOnRetry: false, continueDisabledPermanently: true }; + } + return { shouldRetry: false, useContinueOnRetry, continueDisabledPermanently }; + } + if (NULL_TYPE_TOOL_CALL_PATTERN.test(result.output)) { + if (attempt < MAX_RETRIES && result.hasOutput) { + return { shouldRetry: true, useContinueOnRetry: false, continueDisabledPermanently: true }; + } + return { shouldRetry: false, useContinueOnRetry, continueDisabledPermanently }; + } + if (attempt < MAX_RETRIES && result.hasOutput) { + return { shouldRetry: true, useContinueOnRetry: !continueDisabledPermanently, continueDisabledPermanently }; + } + return { shouldRetry: false, useContinueOnRetry, continueDisabledPermanently }; + } + + it("restarts fresh when null-type error occurs on a --continue attempt", () => { + const result = { + exitCode: 1, + hasOutput: true, + output: "CAPIError: 400 Invalid type for 'messages[45].tool_calls[0].type':" + " expected one of 'function', 'all...ols', or 'custom', but got null instead.", + }; + const { + shouldRetry, + useContinueOnRetry: newContinue, + continueDisabledPermanently: disabled, + } = applyRetryPolicy( + result, + 1, + true, // was using --continue + false + ); + expect(shouldRetry).toBe(true); + expect(newContinue).toBe(false); // must NOT use --continue on restart + expect(disabled).toBe(true); // permanently disabled + }); + + it("restarts fresh when null-type error occurs on a fresh run", () => { + const result = { + exitCode: 1, + hasOutput: true, + output: "CAPIError: 400 Invalid type for 'messages[0].tool_calls[0].type': got null instead.", + }; + const { shouldRetry, useContinueOnRetry: newContinue, continueDisabledPermanently: disabled } = applyRetryPolicy(result, 0, false, false); + expect(shouldRetry).toBe(true); + expect(newContinue).toBe(false); // must NOT use --continue + expect(disabled).toBe(true); // permanently disabled + }); + + it("does not retry when budget is exhausted", () => { + const result = { + exitCode: 1, + hasOutput: true, + output: "tool_calls[0].type: null", + }; + const { shouldRetry } = applyRetryPolicy(result, MAX_RETRIES, true, false); + expect(shouldRetry).toBe(false); + }); + + it("does not retry when no output was produced", () => { + const result = { + exitCode: 1, + hasOutput: false, + output: "tool_calls[0].type: null", + }; + const { shouldRetry } = applyRetryPolicy(result, 0, false, false); + expect(shouldRetry).toBe(false); + }); + }); + + describe("permanent --continue disable guard", () => { + // Inline retry logic to verify that once continueDisabledPermanently is set, + // subsequent partial-execution retries never re-enable --continue. + const NULL_TYPE_TOOL_CALL_PATTERN = /tool_calls\[.*?\]\.type.*null/; + const MAX_RETRIES = 3; + + /** + * @param {{hasOutput: boolean, exitCode: number, output: string}} result + * @param {number} attempt + * @param {boolean} useContinueOnRetry + * @param {boolean} continueDisabledPermanently + * @returns {{ shouldRetry: boolean, useContinueOnRetry: boolean, continueDisabledPermanently: boolean }} + */ + function applyRetryPolicy(result, attempt, useContinueOnRetry = false, continueDisabledPermanently = false) { + if (result.exitCode === 0) return { shouldRetry: false, useContinueOnRetry, continueDisabledPermanently }; + if (NULL_TYPE_TOOL_CALL_PATTERN.test(result.output)) { + if (attempt < MAX_RETRIES && result.hasOutput) { + return { shouldRetry: true, useContinueOnRetry: false, continueDisabledPermanently: true }; + } + return { shouldRetry: false, useContinueOnRetry, continueDisabledPermanently }; + } + if (attempt < MAX_RETRIES && result.hasOutput) { + return { shouldRetry: true, useContinueOnRetry: !continueDisabledPermanently, continueDisabledPermanently }; + } + return { shouldRetry: false, useContinueOnRetry, continueDisabledPermanently }; + } + + it("does not re-enable --continue after a null-type fresh restart", () => { + // Attempt 0 (fresh): normal failure → schedule --continue + const attempt0Result = { exitCode: 1, hasOutput: true, output: "some error" }; + const after0 = applyRetryPolicy(attempt0Result, 0, false, false); + expect(after0.shouldRetry).toBe(true); + expect(after0.useContinueOnRetry).toBe(true); + expect(after0.continueDisabledPermanently).toBe(false); + + // Attempt 1 (--continue): null-type error → restart fresh, disable permanently + const attempt1Result = { exitCode: 1, hasOutput: true, output: "tool_calls[0].type: null" }; + const after1 = applyRetryPolicy(attempt1Result, 1, after0.useContinueOnRetry, after0.continueDisabledPermanently); + expect(after1.shouldRetry).toBe(true); + expect(after1.useContinueOnRetry).toBe(false); // disabled for this retry + expect(after1.continueDisabledPermanently).toBe(true); // permanently set + + // Attempt 2 (fresh): another partial failure → MUST NOT re-enable --continue + const attempt2Result = { exitCode: 1, hasOutput: true, output: "another error" }; + const after2 = applyRetryPolicy(attempt2Result, 2, after1.useContinueOnRetry, after1.continueDisabledPermanently); + expect(after2.shouldRetry).toBe(true); + expect(after2.useContinueOnRetry).toBe(false); // guard prevents re-enabling + expect(after2.continueDisabledPermanently).toBe(true); + }); + + it("does not re-enable --continue after an auth-error fresh restart", () => { + const NO_AUTH_INFO_PATTERN_LOCAL = /No authentication information found/; + + function applyRetryPolicyWithAuth(result, attempt, useContinueOnRetry = false, continueDisabledPermanently = false) { + if (result.exitCode === 0) return { shouldRetry: false, useContinueOnRetry, continueDisabledPermanently }; + if (NO_AUTH_INFO_PATTERN_LOCAL.test(result.output)) { + if (useContinueOnRetry && attempt < MAX_RETRIES) { + return { shouldRetry: true, useContinueOnRetry: false, continueDisabledPermanently: true }; + } + return { shouldRetry: false, useContinueOnRetry, continueDisabledPermanently }; + } + if (attempt < MAX_RETRIES && result.hasOutput) { + return { shouldRetry: true, useContinueOnRetry: !continueDisabledPermanently, continueDisabledPermanently }; + } + return { shouldRetry: false, useContinueOnRetry, continueDisabledPermanently }; + } + + // Attempt 0 (fresh): normal failure → schedule --continue + const attempt0Result = { exitCode: 1, hasOutput: true, output: "some work done" }; + const after0 = applyRetryPolicyWithAuth(attempt0Result, 0, false, false); + expect(after0.useContinueOnRetry).toBe(true); + + // Attempt 1 (--continue): auth error → restart fresh, disable permanently + const attempt1Result = { exitCode: 1, hasOutput: true, output: "No authentication information found" }; + const after1 = applyRetryPolicyWithAuth(attempt1Result, 1, after0.useContinueOnRetry, after0.continueDisabledPermanently); + expect(after1.shouldRetry).toBe(true); + expect(after1.useContinueOnRetry).toBe(false); + expect(after1.continueDisabledPermanently).toBe(true); + + // Attempt 2 (fresh): partial failure → MUST NOT re-enable --continue + const attempt2Result = { exitCode: 1, hasOutput: true, output: "some other error" }; + const after2 = applyRetryPolicyWithAuth(attempt2Result, 2, after1.useContinueOnRetry, after1.continueDisabledPermanently); + expect(after2.useContinueOnRetry).toBe(false); // guard prevents re-enabling + }); + }); + describe("retry configuration", () => { it("has sensible default values", () => { // These match the constants in copilot_harness.cjs From 88c199e5795e08c3b9a99ec44813dbb0bf1a030a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Apr 2026 14:44:54 +0000 Subject: [PATCH 3/4] refactor: improve string readability in null-type tool_call tests Agent-Logs-Url: https://github.com/github/gh-aw/sessions/1bc21234-f151-423f-879b-2cc881234acf Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/copilot_harness.test.cjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/actions/setup/js/copilot_harness.test.cjs b/actions/setup/js/copilot_harness.test.cjs index 8163fef7c79..907b0bb87de 100644 --- a/actions/setup/js/copilot_harness.test.cjs +++ b/actions/setup/js/copilot_harness.test.cjs @@ -370,7 +370,7 @@ describe("copilot_harness.cjs", () => { const NULL_TYPE_TOOL_CALL_PATTERN = /tool_calls\[.*?\]\.type.*null/; it("matches the exact error from the failed workflow run", () => { - const errorOutput = "Execution failed: CAPIError: 400 Invalid type for 'messages[45].tool_calls[0].type':" + " expected one of 'function', 'all...ols', or 'custom', but got null instead."; + const errorOutput = "Execution failed: CAPIError: 400 Invalid type for 'messages[45].tool_calls[0].type': expected one of 'function', 'all...ols', or 'custom', but got null instead."; expect(NULL_TYPE_TOOL_CALL_PATTERN.test(errorOutput)).toBe(true); }); @@ -430,7 +430,7 @@ describe("copilot_harness.cjs", () => { const result = { exitCode: 1, hasOutput: true, - output: "CAPIError: 400 Invalid type for 'messages[45].tool_calls[0].type':" + " expected one of 'function', 'all...ols', or 'custom', but got null instead.", + output: "CAPIError: 400 Invalid type for 'messages[45].tool_calls[0].type': expected one of 'function', 'all...ols', or 'custom', but got null instead.", }; const { shouldRetry, From 2b5249151053185d4437f03348945a4eed1c3a53 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Apr 2026 15:05:19 +0000 Subject: [PATCH 4/4] docs: update retry-policy comments for null-type tool_call; rename test for accuracy Agent-Logs-Url: https://github.com/github/gh-aw/sessions/1eb81da3-5f18-4bab-803c-d9ba0be9fca8 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/copilot_harness.cjs | 14 ++++++++++++-- actions/setup/js/copilot_harness.test.cjs | 2 +- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/actions/setup/js/copilot_harness.cjs b/actions/setup/js/copilot_harness.cjs index 52b0fdd05b3..058ad437aab 100644 --- a/actions/setup/js/copilot_harness.cjs +++ b/actions/setup/js/copilot_harness.cjs @@ -23,6 +23,12 @@ * - On a fresh run (attempt 0 or after a `--continue`-auth fallback): the env-var token is * genuinely absent or invalid. All further retries will produce the same failure, so the * driver bails immediately. + * - Null-type tool_call errors (400 "Invalid type for '...tool_calls[N].type': ... got null") + * poison the conversation history. Retrying with `--continue` re-injects the same broken + * state on every subsequent attempt. The driver restarts fresh to discard the poisoned + * history and permanently disables `--continue` for the remainder of the run so the corrupt + * state can never be reloaded. Once `--continue` is disabled this way it is not re-enabled + * even if later retries produce output. * - Retries use exponential backoff: 5s → 10s → 20s (capped at 60s). * - Maximum 3 retry attempts after the initial run. * @@ -434,8 +440,12 @@ async function main() { // Retry whenever the session was partially executed (hasOutput), using --continue so that // the Copilot CLI can continue from where it left off. CAPIError 400 is the well-known // transient case, but any partial-execution failure is eligible for a continue retry. - // Exceptions: MCP policy errors, model-not-supported errors, and auth errors are persistent - // configuration issues — never retry. + // Exceptions: + // - MCP policy errors and model-not-supported errors are persistent configuration issues. + // - Auth errors trigger a one-time fallback to a fresh run; after that --continue is + // permanently disabled. + // - Null-type tool_call 400 errors poison conversation history — always restart fresh and + // permanently disable --continue so the corrupt state is never reloaded. const isCAPIError = isTransientCAPIError(result.output); const isMCPPolicy = isMCPPolicyError(result.output); const isModelNotSupported = isModelNotSupportedError(result.output); diff --git a/actions/setup/js/copilot_harness.test.cjs b/actions/setup/js/copilot_harness.test.cjs index 907b0bb87de..f02550332a5 100644 --- a/actions/setup/js/copilot_harness.test.cjs +++ b/actions/setup/js/copilot_harness.test.cjs @@ -369,7 +369,7 @@ describe("copilot_harness.cjs", () => { describe("null-type tool_call detection pattern", () => { const NULL_TYPE_TOOL_CALL_PATTERN = /tool_calls\[.*?\]\.type.*null/; - it("matches the exact error from the failed workflow run", () => { + it("matches the error format observed in failed workflow runs", () => { const errorOutput = "Execution failed: CAPIError: 400 Invalid type for 'messages[45].tool_calls[0].type': expected one of 'function', 'all...ols', or 'custom', but got null instead."; expect(NULL_TYPE_TOOL_CALL_PATTERN.test(errorOutput)).toBe(true); });