fix: sanitize internal fields from API messages - #138
Conversation
Mistral and other strict API providers reject extra fields (like finish_reason, reasoning, _flush_sentinel) on chat messages with 422 Unprocessable Entity. Root cause: _build_assistant_message() adds internal bookkeeping fields (finish_reason, reasoning) to message dicts for trajectory saving and logging. When these messages are sent back to the API as conversation history, strict providers reject the unknown fields. Fix: Add a whitelist-based _sanitize_for_api() helper that keeps only standard OpenAI-compatible fields per message role. This replaces the previous fragile blacklist approach (which only removed 'reasoning' but forgot 'finish_reason') and is applied consistently across all 3 API call sites: 1. Main conversation loop 2. Memory flush 3. Max iterations summary (was completely unsanitized) The whitelist includes reasoning_content and reasoning_details for providers that support multi-turn reasoning (OpenRouter, Moonshot AI). Original messages list is unchanged — trajectory saving, session DB, and logging continue to work as before.
Bartok9
left a comment
There was a problem hiding this comment.
LGTM! The whitelist-based sanitization is the right approach.
Key benefits:
- Forward-compatible (new internal fields won't accidentally leak)
- Handles all message roles properly
- Fixed the edge case in
_handle_max_iterations()
The _API_FIELDS_BY_ROLE dict is clean and easy to extend if needed.
Tested locally with Mistral - the 422 errors are resolved. 🎉
Bartok9
left a comment
There was a problem hiding this comment.
LGTM! The whitelist-based sanitization is the right approach.
Key benefits:
- Forward-compatible (new internal fields won't accidentally leak)
- Handles all message roles properly
- Fixed the edge case in _handle_max_iterations()
Tested logic - the 422 errors should be resolved.
|
@cutepawss are we aware of they accept extra_headers kwarg? Thats needed to control a lot of models' reasoning effort, whether they think at all, etc. I'm worried this isn't strict OAI spec but strictly mistral spec? |
|
@teknium1
client.chat.completions.create(
model="...",
messages=api_messages, # ← only this is sanitized
extra_body={"reasoning": {"effort": "high"}}, # ← untouched
extra_headers={"X-Custom": "value"}, # ← untouched
)What we're stripping are internal bookkeeping fields like The whitelist is based on the OpenAI Chat Completion message spec, plus known provider extensions ( |
|
Okay but messages with OpenRouter require a reasoning_content or reasoning field to carry over reasoning content for instance. Scrubbing that would break interleaved reasoning in many models |
|
@teknium1 Thanks for flagging this I want to make sure I'm not breaking anything here. From what I can see, if role == "assistant" and msg.get("reasoning"):
api_msg["reasoning_content"] = msg["reasoning"]So This is also covered by the tests in the PR — That said; is there a provider or flow where |
…advance readiness offline `forecast readiness` was read-only: closing benchmark-evidence gaps meant running five `forecast backtest` commands by hand, then re-checking. Now one command does it offline. - `forecast readiness --run-safe-benchmarks` runs the OFFLINE builtin suite (the 5 shipped datasets — mini/synthetic/heldout + manifold + kalshi public) via a deterministic generated source (default --probability-source forecast-engine; naive / baseline-ensemble also allowed), persists a backtest run each, then re-evaluates readiness with unchanged thresholds and reports the gaps that CLOSED vs remain. --dry-run lists what would run; agent-protocol is rejected (needs an LLM runner). - The default forecast-engine source is deliberate: a 'dataset' source would NOT close positive_best_baseline_edge_runs. Both external families (manifold + kalshi) are in the suite so external_source_families can close. Verified on a fresh ledger: the suite closes datasets / external families / leakage / positive-edge, leaving only the genuinely-live gaps (live_scored_forecasts, agent_protocol_scored_cases). - Mirrored on the tool: evidence_readiness accepts run_safe_benchmarks (deferred cli import; OFFLINE sources only) so the agent can self-improve readiness through the tool rather than a shell script. Tests: offline suite runs (5 runs, leakage-free, both external families), closes only the offline gaps + leaves the live ones, agent-protocol rejected. Readiness/backtest (126) + forecast tool (51) regression green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…high + 10 med + lows) A multi-agent review of NousResearch#150/NousResearch#138/NousResearch#137/NousResearch#139 confirmed 21 findings. Fixed: HIGH (NousResearch#139): forecast.theses ran inline on the gateway dispatch thread while doing per-thesis N+1 work — it can stall interrupt/approval RPCs on a large book. Added it to _LONG_HANDLERS (thread pool) + a routing regression test. Also: one shared ForecastLedger per call (was two), error code 5008 (was a colliding 5021), _num excludes bool, and the CLI dashboard now renders factors too (parity with the RPC/tool; matches its docstring). NousResearch#150 (vote-share intervals): intervals silently dropped on the fraction-scale path (the PMF branch never attached them) — now attached on both branches. Interval lookup now tolerates case/whitespace divergence from the scorer/hook key normalization. And the intervals (always percentage-points) are rescaled to the payload's scale, so a fraction-scale share renders `0.70 [0.50-0.85]` not `0.70 [50-85]`. NousResearch#138 (readiness benchmarks): evaluating with the default --last window or a --dataset filter could hide the freshly-run suite (a closed gap looked reopened / zero closed) — the improve path now evaluates over ALL runs, unfiltered. Dropped `naive` (can't beat baselines, so it can't close the edge gap). The tool mirror wraps the run in try/except. NousResearch#137 (cycle --agent): selection now filters on alert.scope_type == "question" (a domain/topic/portfolio alert's scope_ref is not a question; this also keeps a question-scoped domain_error_profile_applies trigger). --max-questions now counts processed (expensive) LLM runs, not just commits, so it actually caps. --max-iterations 0 is honored (was coerced to 12). Deferred (noted): a per-candidate central-within-interval COMMIT gate (a new backend invariant mirroring central_within) — warrants its own pass. New tests cover every fix above. Full tests/forecasting + tests/tools + gateway regression green (6834 passed); TUI type-check + 99 chart/workspace tests green; bundle rebuilt. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…NousResearch#137) (NousResearch#138) * feat(deploy): persistent gateway hosting + env-passthrough log hardening Re-lands the reviewed-good content of NousResearch#137 on a branch cut from current main. NousResearch#137 could not merge: it was mergeable_state dirty, and it sat on claude/slack-session-94aae0 — the branch of closed NousResearch#106, which NousResearch#130 said should not be continued. Two things ship here. tools/env_passthrough.py — CodeQL clear-text-logging fixes. The refusal path logged the caller-supplied variable name; skill frontmatter is a taint source under CodeQL's model, and _is_hermes_provider_credential's own name matches the sensitive-data heuristic, so anything derived from it is treated as secret. Replaced with counts and static strings. The config-read failure now logs the exception type rather than str(e), because a YAML parse error quotes the offending line, which may hold a secret. deploy/, docs/DEPLOYMENT.md, website/docs/guides/persistent-hosting.md, Dockerfile, docker-compose.yml, README.md — running the gateway 24/7 with no long-lived credentials on the host. Docker Compose, a hardened systemd unit, and container platforms, all bootstrapping from the 1Password secret source that already exists on main. The image gains the onepassword extra so a headless deploy doesn't do a first-boot install into the venv. Only placeholder tokens (ops_...your-token...) appear anywhere. Dropped from NousResearch#137: .claude/settings.json, which reverted @modelcontextprotocol/server-memory from 0.6.2 to 0.6.3. That version does not exist on npm — the published line jumps 0.6.2 to 2025.4.25 — so the revert re-breaks the memory MCP server and undoes NousResearch#134. It was also the sole merge conflict with main, so dropping the defect and clearing the conflict are the same edit. hermes_cli/config.py is not touched, so the GHSA-mv8x-fg99-32mf _sanitize_env_lines regression NousResearch#130 warned about is not in play. Verified against main rather than assumed: the onepassword extra (pyproject.toml), every secrets.onepassword key the sample config sets (agent/secret_sources/onepassword.py), `hermes secrets onepassword setup --vault/--item`, and `hermes gateway run` used by the unit's ExecStart. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jy1tjok1XKgpTUK69b8dKT * docs(website): register the persistent-hosting guide in the sidebar website/sidebars.ts enumerates the Guides category by hand — it is not an autogenerated sidebar, so the sidebar_position: 18 in the new guide's frontmatter is inert. Without this line the page builds and is reachable by direct URL, but appears nowhere in site navigation, while README.md and docs/DEPLOYMENT.md both link its published URL. docusaurus.config.ts sets onBrokenLinks: 'warn', so nothing fails — it just quietly isn't there. Placed after guides/team-telegram-assistant: both are about deploying the messaging gateway, so that is where a reader looking for gateway hosting would already be. Not in NousResearch#137; found while reviewing it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jy1tjok1XKgpTUK69b8dKT * test(env-passthrough): pin that registration never logs a variable name The clear-text-logging fix in the previous commit had nothing guarding it. Nothing in the suite asserted that a refused variable's name stays out of the log, so a future edit could interpolate it back and every test would still pass — which is roughly how it got there the first time. Five tests: no name from either the blocked or the allowed set appears in any record; the refused/registered counts are correct and the GHSA pointer survives; no warning when nothing is refused; no record at all for empty input; and the config-read handler logs the exception type rather than str(e), using a recognisable secret in the raised message so a leak is unambiguous. Confirmed these fail against main's version of the module — three of the five do, and the captured log in the failure output shows the secret verbatim. A regression test that passes against the code it is meant to catch is worth nothing, so that check mattered more than the passing run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jy1tjok1XKgpTUK69b8dKT * docs(system-log): record the NousResearch#137 salvage Per docs/system-log/README.md. New file for the UTC day; no prior entry for 2026-08-02 existed on main or locally, so nothing was overwritten. Records what was carried, what was dropped and why, what was added beyond NousResearch#137, what was verified, and — separately — what could not be verified in a container with no Docker daemon and no website node_modules. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jy1tjok1XKgpTUK69b8dKT --------- Co-authored-by: Claude <noreply@anthropic.com>
…arch#139) website/sidebars.ts lists the Guides category by hand. Four pages had never been added to it: google-gemini, local-ollama-setup, minimax-oauth, and pipe-script-output. Each one builds and resolves by direct URL but appears nowhere in navigation. onBrokenLinks: 'warn' means this never failed a build, which is how four of them accumulated without anyone noticing. Placed by subject rather than appended to the end: local-ollama-setup beside local-llm-on-mac, pipe-script-output beside cron-script-only, and the two provider guides inside the existing aws-bedrock / azure-foundry / xai-grok-oauth block. website/docs/guides/ holds 29 .md files and the sidebar now lists 29 unique guide ids, so the two sets match exactly — no orphans, no duplicates. Follow-up to NousResearch#138, which fixed the same failure mode for one new guide. Claude-Session: https://claude.ai/code/session_01Jy1tjok1XKgpTUK69b8dKT Co-authored-by: Claude <noreply@anthropic.com>
Fixes #134
Problem
When using Mistral (or any strict OpenAI-compatible provider) as a direct API endpoint, the second message always fails with:
The first message works fine because there's no assistant message in history yet. On the second turn, the previous assistant response — carrying internal fields like
finish_reason— gets sent back to the API, which rejects it.Root Cause
_build_assistant_message()addsfinish_reasonandreasoningto every assistant message dict for trajectory saving and session logging. The API preparation code strippedreasoningbut forgotfinish_reason. Additionally,_handle_max_iterations()sent messages with no sanitization at all.This is a broader design issue: the code used a fragile blacklist (manually popping individual fields), which breaks whenever a new internal field is added.
Fix
Added a whitelist-based
_sanitize_for_api()helper that keeps only standard OpenAI-compatible fields per message role:Applied consistently across all 3 API call sites:
reasoning_contentandreasoning_detailsare kept in the whitelist for providers that support multi-turn reasoning (OpenRouter, Moonshot AI). The originalmessageslist is unchanged, so trajectory saving, session DB logging, and display continue to work.Testing
Added 8 tests in
tests/agent/test_sanitize_api.py:test_finish_reason_strippedfinish_reasonremovedtest_reasoning_becomes_reasoning_contentreasoning→reasoning_contentconversiontest_reasoning_content_not_added_when_emptytest_flush_sentinel_stripped_flush_sentinelon user msgs removedtest_standard_assistant_fields_preservedtool_calls,reasoning_detailspass throughtest_tool_message_preserves_tool_call_idtool_call_id, drop extrastest_system_message_minimalrole+contenttest_unknown_role_defaults_to_role_content