fix(gateway): resolve MEDIA-tag producer-tool id through the canonical tool_call_id policy - #94331
Conversation
…l tool_call_id policy
_collect_auto_append_media_tags() and _collect_history_media_paths()
each built a private tool_call-id-to-name map using
call.get("id") or call.get("call_id") when registering an assistant
tool_call's producer name, then looked up the paired tool result via
msg.get("tool_call_id") or msg.get("call_id").
For a Codex/Responses-style tool_call whose id ("fc_...") and call_id
("call_...") diverge — the exact shape agent/message_sanitization.py's
tool_call_id_variants()/coalesce_tool_call_id() were consolidated to
handle (NousResearch#55626/NousResearch#58168/NousResearch#63000) — the registration key (id-first) and the
lookup key (tool_call_id, populated from call_id) never match. The
producer-tool name is never resolved, so a real text_to_speech/
image_generate result is treated as an untrusted tool: its MEDIA: tag
is never auto-appended (silent data loss, no error surfaced) and its
JSON-payload path is never collected for cross-turn dedup.
Route both functions through the single policy owner
(tool_call_id_variants / tool_result_id_variants) that the rest of the
codebase already uses for this exact divergent-id shape, instead of
re-implementing a narrower one-field lookup.
Empirically verified: a tool_call with id="fc_abc123",
call_id="call_xyz789" paired with a tool result whose tool_call_id is
"call_xyz789" returned zero media tags on pre-fix code (both the
MEDIA: text-tag path and the image_generate JSON-payload path); the
image_generate path was also invisible to history-dedup collection,
which would let the same file be re-delivered on a later turn.
Mutation-verified: reverting the fix reproduces exactly this failure —
the 3 divergent-id regression tests fail, the 3 control tests (single
matching id, untrusted-tool filtering) are unaffected.
Correct fix in the right direction: routing both MEDIA collectors through
Also fine: registering all variants unconditionally when a name exists (vs. the old |
Fixes NousResearch#898 — Python 3.11 changed argparse to raise an exception on duplicate subparser names (CPython NousResearch#94331). The 'skills' name was registered twice: once for Skills Hub and once for skills config. Changes: - Remove duplicate 'skills' subparser registration - Add 'config' as a sub-action under the existing 'hermes skills' command - Route 'hermes skills config' to skills_config module - Add regression test to catch future duplicates Migration: 'hermes skills' (config) is now 'hermes skills config'
Summary
_collect_auto_append_media_tags()and_collect_history_media_paths()(gateway/run.py) each build a private tool-call-id → producer-name map when scanning an assistant message'stool_calls, usingcall.get("id") or call.get("call_id")to register the key, then look up the pairedrole="tool"result viamsg.get("tool_call_id") or msg.get("call_id").For a Codex/Responses-style tool_call whose
id("fc_...") andcall_id("call_...") diverge — the exact shapeagent/message_sanitization.py'stool_call_id_variants()/coalesce_tool_call_id()were consolidated to handle (see #55626 / #58168 / #63000, and the same-week id-variant cluster inagent/agent_runtime_helpers.py) — the registration key (id-first) and the lookup key (tool_call_id, populated fromcall_idon Responses-shaped calls) never match.Effect: the producer-tool name is never resolved, so a genuinely allowlisted
text_to_speech/image_generateresult is treated as an untrusted tool:MEDIA:text tag is never auto-appended — the file is silently never delivered, no error surfaced.image_generateJSON-payload path (host_image/image/agent_visible_image) is invisible to both the auto-append collector and the history-dedup collector, which would also let the same file be re-delivered on a later turn once the id happens to line up (e.g. after compression rewrites message shapes).Fix
Route both functions through the single policy owner the rest of the codebase already uses for this exact divergent-id shape:
tool_call_id_variants(call)when registering — every wire spelling of a tool_call's pairing id (id,call_id,response_item_id, pipe-encoded bridge ids) maps to the producer name, not just one.tool_result_id_variants(msg.get("tool_call_id") or msg.get("call_id"))when looking a result up — checked against every registered variant.No behavior change for the common (single, matching id) case — only the divergent-id case is affected.
Verification
id="fc_abc123",call_id="call_xyz789"paired with a tool result whosetool_call_idis"call_xyz789"returns zero media tags for both theMEDIA:text-tag path and theimage_generateJSON-payload path.tests/gateway/test_media_tag_divergent_call_id.py, 6 tests): divergent-id text-tag survival, divergent-id JSON-payload survival (both functions), a control case (single matching id, unaffected), and a control case confirming the producer-tool allowlist still rejects an ineligible tool even with a divergent id.tests/gateway/test_73771_media_resend_dedup.py,test_media_extraction.py,test_media_spaced_paths_and_history_dedupe.py(28 tests) pass unchanged.tests/gateway/sweep (5849 tests, excluding files that fail to collect in this environment due to a pre-existingaiohttp.test_utilsimport gap unrelated to this change): 12 pre-existing failures reproduce byte-for-byte with the fix stashed — confirmed independent of this change.ruff checkclean on both changed files.Competitor / conflict notes (checked fresh before opening)
_collect_auto_append_media_tagsfor an unrelated concern — adding a fallback that readsmsg.get("tool_name")/msg.get("name")directly when the call_id-based lookup misses entirely (e.g. a message that never went through atool_callspairing at all). Textual proximity only; no semantic overlap with this fix's id-resolution change. Whichever lands first will need a small rebase for the other.gateway/run.py) moves both target functions verbatim (bug intact) into a newgateway/auto_continue_helpers.pymodule. Pure relocation, no logic change on that branch — if it lands, this fix would need to be re-applied at the new location, but there is no semantic conflict.