Skip to content

Two defects left after 7c79515: strict-gateway 400 from missing required in tools_schema, and DSML markers inside tool_call arguments #846

Open

Description

@lunxian8

Summary

Two defects that survive 7c79515 (verified on b70079d, Windows 11, Python 3.11). Both are independent of the content-channel leak tracked in #837.


A. Strict gateways reject every request: Invalid schema for function 'code_run': null is not of type "array"

Observed (real log, agent could not start):

!!!Error: HTTP 400: {"error":{"message":"Invalid schema for function 'code_run': null is not of type \"array\" ...
"type":"invalid_request_error","param":"","code":null},"request_id":"..."}

Root cause: assets/tools_schema.json ships 9 tools and none of them declares required (verified: 0/9). llmcore._prepare_oai_tools() forwards the schema untouched for both api_mode="chat_completions" and "responses". Strict OpenAI-compatible validators (DeepSeek-style) do not treat a missing required as optional — their validator sees null and rejects the whole request with HTTP 400 before any model call, so the agent never runs.

Proposed fix (normalize, deep copy, never mutate the shared schema):

def _normalize_tool_parameters(fn):
    fn = dict(fn)
    params = fn.get("parameters")
    if not isinstance(params, dict):
        params = {"type": "object", "properties": {}}
    else:
        params = dict(params)
        props = params.get("properties")
        params["properties"] = dict(props) if isinstance(props, dict) else {}
    if not isinstance(params.get("required"), list):
        params["required"] = []
    params.setdefault("type", "object")
    fn["parameters"] = params
    return fn

Apply it to every tool inside _prepare_oai_tools (both API modes). After the change: all 9 tools serialize with "required": [], original TOOLS_SCHEMA stays unmodified, full test suite unchanged.


B. DSML markers also leak into tool_call.arguments (not just content)

agent_loop.py parses arguments with no sanitizing:

else: tool_calls = [{'tool_name': tc.function.name, 'args': json.loads(tc.function.arguments), 'id': tc.id}
                    for tc in response.tool_calls]

Two real failure shapes:

  1. Marker appended inside a string value — the call "succeeds" but the tool receives garbage:
    {"script": "print(1)</||DSML|| parameter>"} → executed as-is / SyntaxError.
  2. Marker truncates the JSON — json.loads raises and the whole turn aborts.

Proposed fix: run the same _strip_dsml_markers() on arguments before json.loads, and degrade a still-unparseable payload to no_tool (retry) instead of raising:

if 'DSML' in _raw_args:
    _raw_args = _strip_dsml_markers(_raw_args)
try:
    _args = json.loads(_raw_args)
except Exception:
    tool_calls = [{'tool_name': 'no_tool', 'args': {}}]   # ask the model for a clean call

C. Why 7c79515 does not cover these

The commit only inspects the last 50 chars of content for a closing </|DSML|...> marker. Neither A nor B touches content; and even in the content channel, </||DSML|| parameter> (ASCII pipes), leading/opening tags, and markers in the middle of a longer answer still pass through.


Verification of the combined patch

  • 24 new unit tests (marker variants |/||/|/‖, cross-chunk splits, text-channel <code_run> recovery, argument sanitizing): 24 passed
  • Full frontends/tests: 291 passed, 8 failed — all 8 pre-existing on this machine (5x missing tauri config files, 2x Windows symlink, 1x _model_lock missing on the test FakeAgent), unrelated to this change
  • E2E with a mocked LLM response: polluted arguments are stripped and executed correctly; truncated arguments fall back to no_tool and retry instead of crashing

Related: #837 (content-channel leak), #838 (robust stripping + text-channel recovery, still unmerged), #836, #805.

Activity

  1. lunxian8 commented on Oct 22, 2026

    @lunxian8
    Author

    Reproduced the null is not of type "array" 400 exactly, so here is the minimal condition (this is not universal — it depends on the gateway and the client fingerprint).

    Trigger: agentrouter.org + User-Agent: codex_cli_rs/... (what GA sends by default) + the raw assets/tools_schema.json (no required).

    POST https://agentrouter.org/v1/chat/completions
    User-Agent: codex_cli_rs/0.50.0 (Windows 11)
    originator: codex_exec
    tools: <assets/tools_schema.json as-is>   -> 400 Invalid schema for function 'code_run': null is not of type "array" (request_id ...) [trace_id ...]
    tools: <same, each parameters.required = []> -> 200
    

    Matrix (same payload, both variants):

    endpoint model no required required: []
    agentrouter.org, UA codex_cli_rs deepseek-v4-flash 400 200
    agentrouter.org, UA claude-cli/2.1.280 deepseek-v4-flash 200 200
    api.deepseek.com/v1 deepseek-flash 200 200
    open.bigmodel.cn/api/paas/v4 glm-4.7 / glm-5.3 200 200

    So: several gateways are tolerant, but the strict path rejects it, and it fails before the model ever sees the request. Note the client fingerprint matters too — agentrouter returns unauthorized client detected for a plain python-urllib UA even with a valid key, so this is easy to miss in a quick curl test.

    Bare curl equivalent:

    curl -s https://agentrouter.org/v1/chat/completions   -H "Authorization: Bearer $KEY" -H "Content-Type: application/json"   -H "User-Agent: codex_cli_rs/0.50.0" -H "originator: codex_exec"   -d '{"model":"deepseek-v4-flash","messages":[{"role":"user","content":"hi"}],
           "tools":[{"type":"function","function":{"name":"code_run","description":"x",
           "parameters":{"type":"object","properties":{"script":{"type":"string"}}}}}]}'

    Also relevant: assets/tools_schema.json has 9 tools and zero required keys; assets/tools_schema_cn.json likewise. Adding required: [] is schema-valid and changes nothing semantically, so it is safe to always emit it.

  2. lunxian8 commented on Oct 22, 2026

    @lunxian8
    Author

    One practical note while testing the fix for A: on the desktop build, patched code will appear not to work, because closing/reopening the window does not restart the bridge process — the running kernel keeps the llmcore.py it imported at startup. I hit exactly this: patch applied at 08:46, kernel still running since two days earlier, same 400 kept coming. Verified on b70079d: POST /services/bridge/exit exists in frontends/desktop_bridge.py but has no callers in the repo.

    So when validating this fix, kill the desktop_bridge.py tree and relaunch it (taskkill /F /T /PID <bridge> then pythonw frontends\desktop_bridge.py), otherwise you are testing the old code. Filed separately as #847.

  3. lunxian8 commented on Oct 22, 2026

    @lunxian8
    Author

    另有一类同源的网关兼容崩溃,已单独记录:#848 —— 网关会发 data: null 心跳帧,_parse_openai_sse 里 json.loads 得到 None 后直接.get() 抛 AttributeError: 'NoneType' object has no attribute 'get',被 _stream_with_retry 吞成 [!!! 流异常中断 ... !!!],随后进入 Incomplete response. Regenerate 死循环。已在本地加 if not isinstance(evt, dict): continue 守卫 + traceback 落盘验证通过(未提交,等官方合并)。

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

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions