Skip to content

[Bug] 2.43.0 unpaired-tool-result guard rejects Codex desktop sub-agent seed shape: every routed-model delegated thread dies instantly with 400 "tool result requires a non-empty string call_id" #3807

Closed

Description

@DaveW001

Client or integration

Codex App

Area

Proxy and routing

Summary

The unpaired-tool-result guard added by PR #3471 (for issue #3259) in src/server/responses/core.ts rejects, for all non-passthrough adapters, any request whose parsed history contains a tool-result item without a non-empty string call_id. The proxy answers HTTP 400 tool result requires a non-empty string call_id, so the request never reaches the provider.

Codex desktop seeds every agent-created (sub-agent) thread's first model request with exactly such an item: a lone function_call_output (name create_thread, output = the <codex_delegation> prompt) that carries id/name/namespace/output but no call_id field at all. The identical seed shape appears in both surviving and dead child rollouts across app-server CLI 0.152.0 and 0.153.4.

Net effect behind a local OpenCodex proxy: sub-agents on routed models (opencode-go, zai, meta, google, any translating adapter) die in under one second on their first turn. Retries, resumes, and fresh re-dispatches all reproduce it, so sub-agent delegation is 100% unusable on routed models. Sub-agents on openai-responses passthrough providers are unaffected because the guard is keyed on non-passthrough adapters, which makes the breakage look model-specific from the client side. The same guard also kills a long-running routed child mid-session once its history re-includes the seed item.

Reproduction

Minimal probe, no Codex desktop needed. While any translating-adapter provider is configured, send a tool-result item with no call_id:

curl -s http://127.0.0.1:10101/v1/responses \
  -H 'Content-Type: application/json' \
  -d '{"model":"<any-routed-model>","input":[{"type":"function_call_output","output":"x"}],"max_output_tokens":1}'

Response:

{"error":{"message":"tool result requires a non-empty string call_id","type":"invalid_request_error","code":"invalid_request_error"}}

Adding any non-empty "call_id":"call_x" to the same item returns HTTP 200, so the check is emptiness-only, not actual pairing.

End-to-end steps:

  1. Upgrade the proxy to 2.43.0 from 2.41.0 (the guard is absent in 2.41.0; zero matching 400s on any earlier day in usage.jsonl).
  2. In Codex desktop on a routed-model thread, dispatch a sub-agent via create_thread with no model override so the child inherits the routed model.
  3. The child's first turn fails in under 1 second with the exact 400 above; wait_threads reports status: systemError.

Counted exposure: 90 matching 400s through 2026-09-06 13:36 ET across 8 routed models (qwen3.8-flash 68, glm-5.3 6, muse-spark-1.3-contributor 5, deepseek-v4-pro 3, glm-5.3-flash 2, gemini-3.8-flash 2, deepseek-v4-flash 2, gemini-3.7-flash 2). Control: passthrough-model child threads created in the same window do not hit the error.

Version

2.43.0

Operating system

Windows 11 25H2 (build 26200)

Provider and model

opencode-go / qwen3.8-flash (also zai, meta, google translating adapters; openai-responses passthrough unaffected)

Logs or error output

{"error":{"message":"tool result requires a non-empty string call_id","type":"invalid_request_error","code":"invalid_request_error"}}

usage.jsonl shows the first occurrence about 28 minutes after the 2.41.0 to 2.43.0 self-update on 2026-09-05, then 90 matching entries through 2026-09-06 across 8 routed models. Zero matching occurrences on any earlier day.

Screenshots and supporting files

No response

Redacted configuration

{
  "providers": ["opencode-go", "zai", "meta", "google"],
  "adapter_family": "openai-chat translating adapters",
  "unaffected": "openai-responses passthrough provider",
  "client": "Codex desktop, app-server CLI 0.153.4"
}

Suggested fix. The guard's own comment notes the reverse case is repaired, not rejected (chat/outbound.ts synthesizes call_{uuid} when a tool call lacks an id). Options, in order of preference:

  1. Repair instead of reject on the translating path: synthesize a placeholder call_id for unpaired tool results and optionally log a diagnostic, mirroring the reverse-case repair. This still fixes the actual failure mode from [Provider compatibility] Codex App delegated tasks fail on ollama-cloud with orphan tool result <missing-id> #3259 (a literal undefined reaching the wire).
  2. Or scope the hard rejection to interior unpaired results (a real pairing bug mid-conversation) and tolerate or repair a leading unpaired function_call_output.
  3. Or make it configurable (for example rejectUnpairedToolResults: "repair" | "reject") with repair as the default.

Workaround currently in use: dispatch sub-agents with an explicit passthrough/OpenAI-hosted model override on create_thread until the guard is repaired or made configurable.

Happy to re-test any candidate fix against the live proxy; the curl probe above is exact.

  • I searched existing issues and documentation.
  • I removed secrets, tokens, account details, request credentials, and personal data.

Activity

  1. github-actions commented on Sep 6, 2026

    @github-actions
    Contributor

    Issue reopened

    The report now contains the information required by the automated check. Thanks for updating it.

  2. added
    bugSomething isn't working
    proxyHTTP proxy, routing, reverse-proxy / management auth
    on Sep 6, 2026
  3. lidge-jun commented on Sep 6, 2026

    @lidge-jun
    Owner

    리뷰 · 우선순위 74 / 80

    지금 dev HEAD(a92835dbc, package 2.45.0)를 보면, PR #3471이 넣은 unpaired tool-result 가드가 src/server/responses/core.ts 약 5895–5922줄에 그대로 살아 있다. 번역(translating) 어댑터 경로에서 parsed.context.messages 안의 toolResult 중 toolCallId가 문자열이 아니거나 빈 문자열이면, 업스트림으로 보내지 않고 HTTP 400 tool result requires a non-empty string call_id로 바로 끊는다. 주석에도 적혀 있듯이 원래 목표는 #3259처럼 undefined call_id가 kiro/ollama/anthropic 쪽에 새어 나가 TypeError나 깨진 tool_result를 만드는 일을 막는 것이었다. 그 가드는 passthrough 어댑터에는 걸리지 않아서, openai-responses 패스스루 쪽 자식 스레드는 멀쩡해 보이고 라우팅/번역 모델 쪽만 “모델 문제”처럼 보이는 asymmetry가 생긴다.

    이 이슈가 말하는 Codex desktop 시드 모양은 그 가드와 정면으로 충돌한다. 에이전트가 create_thread로 만든 자식 스레드의 첫 모델 요청에, call_id 필드 없이 id/name/namespace/output만 있는 lone function_call_output(내용이 <codex_delegation> 프롬프트)이 들어간다. 그 아이템은 파서의 permissive catch-all을 타고 toolResult로 들어오지만 toolCallId가 비어 있으니, 번역 어댑터면 첫 턴에서 무조건 400이다. 재시도·재개·새 디스패치도 같은 시드를 다시 넣으니, 라우팅 모델 위 서브에이전트 위임은 사실상 100% 죽는다. 리포터의 curl 프로브도 정확히 그 조건을 재현한다: function_call_output에 call_id만 없으면 400, 아무 non-empty call_id만 달면 200이다. 즉 “진짜 짝 검사”가 아니라 emptiness 검사다.

    같은 HEAD에서 반대 방향은 이미 수리한다. src/chat/outbound.ts 약 379줄·595줄 근처는 tool call에 id가 없으면 call_ + uuid 조각을 합성한다. 가드 주석도 “schema로 옮기면 패스스루/compaction까지 죽는다”고 설명하면서, 번역 경로만 하드 reject를 택했다. 그래서 #3471의 의도는 타당했지만, Codex App의 정상 시드 모양까지 거부하는 부수 효과가 2.43.0부터 열린 것이다. 리포터가 센 90건의 matching 400(8개 라우팅 모델, qwen3.8-flash가 대부분)과 “2.41.0에는 0건 / 2.43.0 업그레이드 직후부터”라는 타임라인도, HEAD에 남은 그 가드와 맞는다. 현재 open PR 검색으로는 #3807을 직접 고치는 패치가 아직 없다. 관련 원본 #3259는 이미 CLOSED다.

    라인 5910-5921 - !adapter.passthrough일 때 빈 toolCallId toolResult를 무조건 400으로 끊어서, Codex desktop의 create_thread 시드(function_call_output without call_id)가 라우팅/번역 모델에서 즉시 죽는다
    경로 src/server/responses/core.ts unpaired 가드 - emptiness만 보고 짝이 없는 mid-conversation 버그와 leading seed를 구분하지 않는다
    경로 src/chat/outbound.ts - tool call 쪽은 이미 call_+uuid로 수리하는데, tool result 쪽은 대칭 수리가 없어 정책이 한쪽으로만 기울어 있다
    이슈 본문 제안 3번(설정 플래그) - rejectUnpairedToolResults 같은 새 knobs는 types/config 분할·설정 표면을 키우므로, 기본값을 repair로 바꾸는 코드 경로 수정보다 비용이 크다

    메인테이너의 판단이 필요한 지점

    너의 추천
    독립 버그픽스 PR로 src/server/responses/core.ts의 번역 경로 unpaired 가드를 reject 대신 repair로 바꾼다. 빈 toolCallId면 outbound와 같이 call_+짧은 uuid를 넣고 선택적으로 diagnostic만 남긴 뒤 진행하게 한다. 설정 플래그는 넣지 말고, curl 프로브 + Codex desktop create_thread 시드 fixture 회귀 테스트를 붙인 뒤 dev에 랜딩한다. 임시 워크어라운드(패스스루 모델 override)는 문서/이슈에만 짧게 남긴다.

    이 댓글은 grok-bot이 작성했습니다

  4. stephen-drew commented on Sep 6, 2026

    @stephen-drew

    Additional Windows Codex Desktop report: this also blocks continuation of an established conversation, with the same error subsequently blocking remote compaction.

    Verified environment

    • Installed OpenCodex package: @bitkyc08/opencodex 2.43.0.
    • ocx status --json reports CLI 2.43.0, running proxy 2.43.0, no version skew, and a healthy local proxy.
    • Configured Codex runtime reported by that command: 0.153.4.
    • Windows desktop. Exact provider/model for each failed request has not been independently reconstructed, so no provider-specific attribution is claimed here.

    Observed failure sequence

    This was a long-running, tool-using conversation, rather than a newly created child task. Codex Desktop's task-history read-back reports four failed turns on 6 September 2026:

    UTC Observed result
    19:15:43 An existing working turn ended with HTTP 400, tool result requires a non-empty string call_id.
    19:30:54 A subsequent turn failed with the same error. Its displayed history includes a functionCallOutput named send_message_to_thread, namespace codex_app.
    19:35:49 An ordinary user request to continue failed with the same error in about 1.3 seconds.
    19:37:52 Remote compaction failed with Error running remote compact task: followed by the same HTTP 400.

    Exact error envelope, with no conversation content:

    {"type":"error","status":400,"error":{"message":"tool result requires a non-empty string call_id","type":"invalid_request_error","code":"invalid_request_error"},"headers":{}}

    The task-history representation is not the raw outbound request. The send_message_to_thread association is an investigation lead, not proof of which input item triggered validation. We have not captured a minimal synthetic reproduction or established that every continuation fails. A later turn in the same conversation did resume sufficiently to collect and post this report; the recovery mechanism is unverified.

    User impact and requested coverage

    The user repeatedly tries to resume an ongoing task but receives a protocol error instead of a useful response. When compaction also fails, the normal recovery path is blocked and the conversation appears unusable, despite substantial work already having occurred.

    Please include established-thread inter-task message delivery, ordinary continuation after such delivery, and remote compaction in the regression coverage for this issue, alongside fresh child-task creation. Related issue #3732 describes valid task-input envelopes without call_id; please clarify which released version, if any, covers these continuation and compaction paths.

    Expected behaviour is to preserve valid external task input and continue or compact successfully, while retaining validation for genuinely malformed tool results. Simply inventing a pairing ID could lose the distinction between external input and an actual tool response.

    No private conversation, business records, local account paths, credentials or full logs are attached. No runtime patch, restart or provider/routing change was performed to prepare this report.

  5. added a commit that references this issue on Sep 8, 2026
    ff524ae
  6. lidge-jun commented on Sep 8, 2026

    @lidge-jun
    Owner

    Fixed on dev in ff524ae (via #4058).

    Worth being precise about what was still broken, because it is narrower than the report reads. The seed shape you described has been admitted since a73bb160f (2.44.0), which added externalTaskInputContent() — that runs before the guard. What remained is that the admission test checked for the field ("call_id" in item), so a client sending call_id: null or "" still got the 400. A hasPairingKey() predicate replaces the field check, and src/server/responses/core.ts is byte-identical, so the #3259 unpaired-tool-result protection is fully intact.

    This inverts four assertions landed by #3735 that treated a null or empty call_id as a paired tool result. Those were wrong independently of #3259: functionCallItemSchema declares call_id: z.string().min(1), so no function_call can carry null or "" as a pairing key, and such an item had nothing it could ever pair with.

    If your delegated threads still fail after 2.49.0 ships, please reopen with a captured request body — the rescope above means the remaining failure would be a different shape than the one this fixes.

    Closing manually because pull requests here target dev, and GitHub only auto-closes on merge into main.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingproxyHTTP proxy, routing, reverse-proxy / management auth

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions