Skip to content

[Bug]: sensenova hallucinates undeclared exec tool in subagent requests causing 502 stream disconnected #1795

Closed as not planned

Description

@yxr1995-maker

Client or integration

Codex App

Area

Provider adapter / bridge

Summary

When OpenCodex routes subagent requests (explorer, shadow calls via gpt-5.6-luna) to sensenova/sensenova-6.8-flash-lite, the model hallucinates tool calls to exec even when exec is not declared in the request tools. This triggers bridge.ts:998 validation failure, causing 502 and stream disconnect.

Error: routed provider emitted undeclared client tool "exec"; only request-declared tools may be called

Reproduction

  1. Configure shadowCallIntercept to route gpt-5.6-luna to sensenova/sensenova-6.8-flash-lite
  2. Codex spawns subagent (explorer) with limited tool set (search_files, read_file, etc., NO exec)
  3. sensenova sees system prompt mentioning exec capability and hallucinates tool call to exec
  4. bridge.ts:998 checks declaredToolNames.has("exec") -> false -> 502

Expected behavior

Undeclared tool calls from third-party providers should be tolerated (dropped with warning) instead of failing the entire turn. This is especially important for subagent/shadow calls where the model may see system prompts describing capabilities not available in the current request tool set.

Actual behavior

502 error: stream disconnected before completion

Version

OpenCodex 2.21.0

Operating system

macOS 27.0, Apple Silicon

Provider and model

Provider: sensenova
Model: sensenova-6.8-flash-lite
Adapter: openai-chat

Logs or error output

From usage.jsonl - 31 occurrences between 01:40-02:42 on 2026-08-16, all with gpt-5.6-luna -> sensenova routing:

provider: sensenova
model: sensenova-6.8-flash-lite
status: 502
upstreamError: routed provider emitted undeclared client tool "exec"; only request-declared tools may be called
requestedModel: gpt-5.6-luna
resolvedModel: sensenova-6.8-flash-lite

Proposed fix

In bridge.ts around line 998, when declaredToolNames check fails, drop the tool call and continue streaming instead of returning 502:

if (options?.declaredToolNames && !options.declaredToolNames.has(event.name)) {
console.warn("[opencodex] dropping undeclared tool call from routed provider");
break; // skip this tool call, continue stream
}

This allows the turn to complete with text response instead of failing entirely.

Activity

  1. coderabbitai commented on Aug 15, 2026

    @coderabbitai
    Contributor
    🔗 Related PRs

    #304 - fix(kiro): restore hardening and complete text turns [open]
    #402 - fix(cursor): stop false shell blocked reports and cmd/command mismatch (#399) [merged]
    #1411 - fix(command-code): pair tool results and synthesize missing-result errors [merged]
    #1531 - fix(openai-chat): refuse to emit an unnamed streamed tool call [merged]
    #1788 - fix(responses): fail closed when a routed provider calls an undeclared tool [open]


    📝 Issue Planner

    Check the box below or use the @coderabbitai plan command to generate an implementation plan and prompts that you can use with your favorite coding assistant.

    • Create Plan

    🧪 Issue enrichment is currently in open beta.

    To disable automatic issue enrichment, add the following to your .coderabbit.yaml:

    issue_enrichment:
      auto_enrich:
        enabled: false

    💬 Have feedback or questions? Drop into our discord!

  2. added
    bugSomething isn't working
    toolstool_calls, MCP, web-search / sidecar tools
    on Aug 15, 2026
  3. ntdatt812 commented on Aug 16, 2026

    @ntdatt812
    Contributor

    The failures here are real, but the proposed fix runs against a decision this repo has already recorded, so it seemed worth flagging before anyone implements it.

    structure/04_transports-and-sidecars.md has a decision log for exactly this question. It lists "rely only on prompt guidance" among the rejected alternatives and states the chosen behaviour as:

    선택한 방식: Retain the allowed wire-name set with the existing bridge maps and fail the turn with an explicit compatibility error before emitting any undeclared tool item.

    with the stated reason:

    Model guidance is not an enforcement boundary, while automatic translation would invent executable caller intent and arguments after generation.

    and the intended consequence:

    providers that emit aliases they never advertised must correct their adapter mapping instead of relying on client abort behavior.

    Dropping the call and continuing is the fail-open behaviour that decision moved away from. It would also silently diverge the two transports: bridge.ts:998 would tolerate an undeclared name while the native Responses passthrough refuses it (#1788, which closes that gap in the other direction).

    On the mechanism, in case it helps narrow the real fix. The catalog nudge is already being injected on this path — openai-chat.ts calls buildNonOpenAIToolCatalogNudgeForTools whenever shouldInjectNonOpenAIToolCatalogNudge(provider) is true, and sensenova is a non-OpenAI host, so the request should already carry "Valid tool names for this turn are exactly …". What it does not carry is an explicit prohibition on exec: NEIGHBOR_AGENT_TOOL_NAMES lists only Read, Grep, Glob, Bash, LS, and exec is deliberately absent for the same reason apply_patch is — it is Codex's own first-class tool, not a neighbouring harness's.

    So the mismatch looks less like "the guard is too strict" and more like "the subagent turn ships a system prompt describing exec while its catalog omits it", and the model believes the prompt over the tool list. That framing suggests fixes that keep the enforcement boundary intact — tightening what the nudge says when a Codex-native tool is described but undeclared, or not describing exec in a subagent prompt that cannot execute it.

    I would be careful with the first, though: the comment above NEIGHBOR_AGENT_TOOL_NAMES records that explicitly forbidding apply_patch pushed routed models into python3 heredoc edits instead. Naming exec as forbidden may well buy the same class of workaround.

    I have not sent a PR for this because the two readings lead to opposite changes and only a maintainer can pick. If the answer is "keep fail-closed and fix the prompt/catalog mismatch", I am happy to take it — I have been in this code for #1788 and #1805. If the answer is that routed subagent turns should genuinely tolerate undeclared calls, that is a change to the decision log rather than to bridge.ts, and it should probably say so explicitly so the passthrough and bridged paths do not drift apart again.

  4. Wibias commented on Aug 16, 2026

    @Wibias
    Contributor

    Additional reproduction confirms this is not SenseNova-specific and also affects the Codex-native apply_patch Code Mode boundary.

    Observed on current latest stable OpenCodex v2.22.0 (2026-08-16) with an explicit provider route:

    • Provider/model: teamwicked-kimi-k3/teamwicked-kimi-k3
    • Adapter: openai-chat
    • Requested/effective effort: xhigh
    • Route kind: explicit-provider
    • Result: HTTP 502 / upstream_server_error
    • Upstream reason recorded by OpenCodex:
    routed provider emitted undeclared client tool "apply_patch"; only request-declared tools may be called
    

    The attempt ran for ~31.5 s and produced first output after ~28 s before terminating on the undeclared tool call. Usage was unreported because the turn failed at this boundary.

    This looks like the same underlying class as the reported undeclared exec call, but it is particularly relevant because apply_patch already has an intentional Code Mode special case in the repo.

    Current behavior is internally consistent:

    • buildToolBridgeMaps() derives declaredToolNames from the request-visible tool catalog.
    • bridge.ts correctly fails closed when the routed provider emits a name outside that set.
    • Under Codex Code Mode, patching can be available only as nested exec -> tools.apply_patch(...), while top-level apply_patch is not necessarily a request-declared wire tool.
    • tool-catalog-nudge.ts deliberately does not put apply_patch in NEIGHBOR_AGENT_TOOL_NAMES, because the earlier explicit prohibition caused routed models to avoid the intended patch path and fall back to Python heredocs/sed.

    So this reproduction suggests the remaining problem is the prompt/tool-contract interpretation for routed Code Mode models: Kimi K3 still interpreted the available patch capability as permission to emit a top-level apply_patch call rather than invoking the declared exec surface and using the nested tools.apply_patch(...) helper.

    That makes the issue provider-cross-cutting rather than a SenseNova quirk. I would keep the existing fail-closed enforcement boundary. Dropping undeclared calls or auto-translating top-level apply_patch into exec -> tools.apply_patch(...) would either silently lose intended actions or invent executable caller intent after generation.

    Likely direction to investigate: make Code Mode guidance distinguish more clearly between top-level callable tool names and helpers available only inside exec, without reinstating the old blanket apply_patch prohibition that caused routed models to switch to heredoc edits.

  5. Wibias commented on Aug 16, 2026

    @Wibias
    Contributor

    I opened draft PR #1817 for the fail-closed-compatible fix: #1817

    The implementation addresses both reproduced forms without weakening declaredToolNames enforcement:

    • responses/parser.ts no longer leaks apply_patch patch-envelope argument guidance into every custom tool; only logical apply_patch keeps that text, while Code Mode exec and other custom tools get neutral freeform-input guidance.
    • the non-OpenAI catalog nudge now defines the listed catalog as the complete top-level invocation surface and explicitly says that names appearing only in instructions/tool metadata/nested helper APIs do not become extra top-level tools. Nested tools.* helpers must be used through their listed parent tool.
    • undeclared returned names still fail closed. No drop-and-continue and no automatic apply_patch -> exec translation.

    The PR includes focused regressions plus the full research trace, including #1544, #1699/#1700, the prior apply_patch-prohibition regression, this issue's discussion, and the v2.22.0 Kimi K3 reproduction:

    devlog/_plan/260816_undeclared_tool_invocation_contract/000_research.md

    I left the PR as draft because this tool environment cannot run Bun locally. A live SenseNova/Kimi re-probe plus the focused/typecheck/shared runtime tests should be green before treating #1795 as resolved.

  6. Wibias commented on Aug 16, 2026

    @Wibias
    Contributor

    Correction to my earlier Kimi K3 repro comment: that failing run was from a different worktree that I later found was still running OpenCodex v2.14.2, not v2.22.0. I re-ran the same Kimi K3 / openai-chat / xhigh Code Mode scenario on actual v2.22.0 and have not reproduced the undeclared top-level apply_patch failure so far.

    The separate static metadata defect discussed in #1817 is still present in v2.22.0/current dev: the Responses parser still attaches apply_patch-specific input guidance to non-apply_patch custom tools, and the non-OpenAI catalog nudge still does not explicitly distinguish the top-level invocation surface from nested helper APIs. So the live Kimi evidence should be treated as v2.14.2 evidence, while the v2.22.0 case remains unconfirmed.

  7. lidge-jun commented on Aug 16, 2026

    @lidge-jun
    Owner

    Looked at this closely and it needs a decision from you before any code moves, so here is what the tracing shows.

    The guard is deliberate, not an oversight. Both bridge paths refuse an undeclared tool name — streaming at src/bridge.ts:1006, non-streaming at :1715 — and both are pinned by tests (tests/bridge.test.ts:345, tests/responses-stream-tool-events.test.ts:30). So the request here is to relax a safety contract, which is a different thing from fixing a defect.

    Why "drop it with a warning" is not obviously the safer option. An exec call the client never declared is by construction one the client has no handler for. Dropping it silently means the model believes it ran a command and gets back either nothing or a fabricated absence, then continues the turn on that false premise. For exec in particular the 502 is the honest answer: the turn genuinely cannot complete as the model intended. Trading a visible failure for an invisible one is not automatically an improvement.

    Where the tolerance belongs matters more than whether it exists. Three options, very different blast radii:

    1. Global — any routed provider may emit any tool name and have it dropped. Removes the guard for the cases it was written for.
    2. Per-provider opt-in — a config flag for a known-noisy upstream. Contained, but the operator has to know which providers need it.
    3. Subagent-scope only — narrowest, and closest to your actual case.

    Option 3 raises a question worth answering first. You describe the subagent seeing "a system prompt mentioning exec capability" while its declared tool set omits exec. If that is accurate, the request is internally inconsistent before the provider replies — a prompt advertising a capability the tools array does not include. That would make this a request-construction defect with a different and safer fix than loosening the validator.

    What I could not establish. No live SenseNova/Kimi reproduction was available, so I cannot tell whether this is provider-specific or general small-model behavior under a capability-describing prompt, and I cannot confirm whether the subagent request really does advertise exec in its prompt while omitting it from tools.

    Two things would let this move:

    • a captured subagent request body (tools array + system prompt, redacted) showing the mismatch;
    • your call on scope — global, per-provider opt-in, or subagent-only.

    Leaving this open rather than guessing at a safety-boundary change. Reasoning recorded in devlog/_plan/260816_wave34_closeout/130_1795_undeclared_tools.md.

  8. impartshadow commented on Aug 17, 2026

    @impartshadow

    This is an unvalidated-tool-call trust boundary failure — the bridge treats "provider emitted a tool call" and "provider emitted a tool call I asked for" as the same event, and only discovers the gap after the stream is already committed.

    The issue itself names the mechanism: the model "hallucinates tool calls to exec even when exec is not declared in the request tools," and the report's own framing gives the reason — subagent system prompts describe "capabilities not available in the current request tool set." That's the trigger. sensenova-6.8-flash-lite is pattern-matching on prose in the system prompt (mentions of exec-capable orchestration) rather than the actual tools array in the request payload, and nothing upstream of bridge.ts:998 filters model output against declaredToolNames before it's treated as a committed turn action.

    The check that would've caught this isn't at line 998 — it's earlier, at the boundary where the provider stream is parsed into tool-call events. Before any tool-call event is forwarded to turn state, assert declaredToolNames.has(event.name); on failure, the event should be classified as "provider protocol violation, not turn failure" and short-circuited to a no-op/log, not propagated as a stream-fatal error. The fix proposed in the issue (break instead of 502) is directionally right, but the deeper fix is making that assertion a first-class contract at the parse boundary, not a late validation gate — so every routed-provider adapter inherits it instead of each call site remembering to check.

    One thing I can't confirm from the issue alone: whether this is sensenova-specific (weaker instruction-following on tool-call constraints) or a general routed-provider issue that just happens to surface here first — that would need the same shadowCallIntercept route tested against a different backing model to isolate provider vs. bridge behavior.

    Advisory SFA-2026-EE235C1DD2 · Full redacted write-up: https://impartshadow.github.io/echo-site/failure-cases/bug-sensenova-hallucinates-undeclared-exec-tool-in-s-ee235c1dd2.html — I triage one of these in public
    every day.
    If this diagnosis is useful, react 👍 to this comment; if it is wrong, react 👎 — I use the verdict to choose what I investigate next.

  9. lidge-jun commented on Aug 18, 2026

    @lidge-jun
    Owner

    Closing as by-design. When a provider hallucinates a tool that was never declared in the request (exec here), refusing the call and failing the turn is the intended contract — the proxy will not fail-open by fabricating or silently dropping an undeclared invocation, because that would execute or discard actions the caller never authorized. The nudge/parser hardening that landed via #1817 is the supported mitigation surface. If a subagent-specific tolerance mode is ever wanted, that is a product decision for a fresh feature issue, not a bug in the current behavior.

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 workingtoolstool_calls, MCP, web-search / sidecar tools

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions