@@ -93,6 +93,61 @@ const SERVER_ERROR_PATTERN = /InternalServerError|ServiceUnavailableError|500 In
9393// an identical rejection: retrying only re-bills the turns that succeeded before the failure point.
9494const INVALID_REQUEST_ERROR_PATTERN = / i n v a l i d _ r e q u e s t _ e r r o r / i;
9595
96+ // Codex's `turn.failed` event nests the actual provider error as a JSON string inside
97+ // `error.message` (sometimes doubly-nested, e.g. `error.message` -> `{"error": {...}}`).
98+ // This is a specific, common form of "unsupported model" failure: the configured model does
99+ // not support the `custom` tool type Codex uses for its `apply_patch`/freeform tool schema.
100+ // The provider rejects the whole request before any work happens, surfacing as:
101+ // {"error": {"message": "Invalid value: 'custom'", "type": "invalid_request_error",
102+ // "param": "tools", "code": "unknown_parameter"}}
103+ // This is a model-capability mismatch, not a malformed request, so it warrants a dedicated,
104+ // more actionable message than the generic invalid_request_error handling below.
105+
106+ /**
107+ * Unwraps up to a few levels of Codex's nested provider error payload to find the
108+ * innermost object that carries string `param`/`code` fields.
109+ * @param {unknown } error
110+ * @returns {{ param?: string, code?: string } | null }
111+ */
112+ function extractNestedProviderErrorDetails ( error ) {
113+ const candidates = [ error ] ;
114+ for ( let visited = 0 ; visited < 8 && candidates . length > 0 ; visited ++ ) {
115+ const current = candidates . shift ( ) ;
116+ if ( ! current || typeof current !== "object" ) continue ;
117+ /** @type {{ param?: unknown, code?: unknown, error?: unknown, message?: unknown, metadata?: unknown } } */
118+ const candidate = current ;
119+ if ( typeof candidate . param === "string" && typeof candidate . code === "string" ) {
120+ return { param : candidate . param , code : candidate . code } ;
121+ }
122+ if ( candidate . error && typeof candidate . error === "object" ) candidates . push ( candidate . error ) ;
123+ if ( typeof candidate . message === "string" ) {
124+ const parsed = parseJsonOrUndefined ( candidate . message ) ;
125+ if ( parsed !== undefined ) candidates . push ( parsed ) ;
126+ }
127+ if ( candidate . metadata && typeof candidate . metadata === "object" ) {
128+ /** @type {{ raw?: unknown } } */
129+ const metadata = candidate . metadata ;
130+ if ( typeof metadata . raw === "string" ) {
131+ const parsed = parseJsonOrUndefined ( metadata . raw ) ;
132+ if ( parsed !== undefined ) candidates . push ( parsed ) ;
133+ }
134+ }
135+ }
136+ return null ;
137+ }
138+
139+ /**
140+ * @param {string } value
141+ * @returns {unknown }
142+ */
143+ function parseJsonOrUndefined ( value ) {
144+ try {
145+ return JSON . parse ( value ) ;
146+ } catch {
147+ return undefined ;
148+ }
149+ }
150+
96151// Post-result watchdog: once the agent writes a terminal safe-output the harness
97152// arms a watchdog timer and kills the Codex process if it is still running after
98153// POST_RESULT_WATCHDOG_IDLE_TIMEOUT_MS of inactivity. This prevents the step from
@@ -203,6 +258,28 @@ function isInvalidRequestError(output) {
203258 } ) ;
204259}
205260
261+ /**
262+ * Determines if Codex emitted a `turn.failed` provider event indicating the configured model
263+ * does not support Codex's required `custom` tool-calling schema (the provider rejects the
264+ * `tools` request parameter with code `unknown_parameter`). This is a model-capability mismatch
265+ * — the model itself is valid but incompatible with Codex — so it is surfaced as a dedicated,
266+ * non-retryable condition with actionable guidance rather than the generic invalid-request message.
267+ * @param {string } output - Collected stdout+stderr from the process
268+ * @returns {boolean }
269+ */
270+ function isUnsupportedModelToolsError ( output ) {
271+ return output . split ( / \r ? \n / ) . some ( line => {
272+ try {
273+ const event = JSON . parse ( line ) ;
274+ if ( event ?. type !== "turn.failed" || ! event . error ) return false ;
275+ const details = extractNestedProviderErrorDetails ( event . error ) ;
276+ return ! ! details && details . param === "tools" && details . code === "unknown_parameter" ;
277+ } catch {
278+ return false ;
279+ }
280+ } ) ;
281+ }
282+
206283/**
207284 * Determines if the collected output shows that Codex's internal stream-reconnect
208285 * retries are exhausted (i.e., the output contains "Reconnecting... N/N" where both
@@ -778,6 +855,7 @@ async function main() {
778855 const isMissingApiKey = isMissingApiKeyError ( result . output ) ;
779856 const isServer = isServerError ( result . output ) ;
780857 const isInvalidModel = isInvalidModelError ( result . output ) ;
858+ const isUnsupportedModelTools = isUnsupportedModelToolsError ( result . output ) ;
781859 const isInvalidRequest = isInvalidRequestError ( result . output ) ;
782860 const permissionDeniedCount = countPermissionDeniedIssues ( result . output ) ;
783861 const hasNumerousPermissionDenied = hasNumerousPermissionDeniedIssues ( result . output ) ;
@@ -792,6 +870,7 @@ async function main() {
792870 ` isMissingApiKeyError=${ isMissingApiKey } ` +
793871 ` isServerError=${ isServer } ` +
794872 ` isInvalidModelError=${ isInvalidModel } ` +
873+ ` isUnsupportedModelToolsError=${ isUnsupportedModelTools } ` +
795874 ` isInvalidRequestError=${ isInvalidRequest } ` +
796875 ` permissionDeniedCount=${ permissionDeniedCount } ` +
797876 ` hasNumerousPermissionDenied=${ hasNumerousPermissionDenied } ` +
@@ -849,6 +928,15 @@ async function main() {
849928 return { action : "stop" } ;
850929 }
851930
931+ if ( isUnsupportedModelTools ) {
932+ log (
933+ `attempt ${ attempt + 1 } : configured model does not support Codex's required tool-calling schema` +
934+ ` ("tools" param rejected with code "unknown_parameter") — not retrying` +
935+ ` (pick a model documented as compatible with Codex CLI, or remove the \`model:\` override in workflow frontmatter to use the engine default)`
936+ ) ;
937+ return { action : "stop" } ;
938+ }
939+
852940 if ( isInvalidRequest ) {
853941 log ( `attempt ${ attempt + 1 } : invalid_request_error (HTTP 400) — not retrying (the provider rejected the request payload; an identical fresh run would fail the same way)` ) ;
854942 return { action : "stop" } ;
@@ -913,6 +1001,7 @@ if (typeof module !== "undefined" && module.exports) {
9131001 isMissingApiKeyError,
9141002 isServerError,
9151003 isInvalidModelError,
1004+ isUnsupportedModelToolsError,
9161005 isInvalidRequestError,
9171006 isReconnectExhaustedError,
9181007 countPermissionDeniedIssues,
0 commit comments