diff --git a/services/hermes/agent-deployment.yaml b/services/hermes/agent-deployment.yaml index 77228b6e6..4e94c9671 100644 --- a/services/hermes/agent-deployment.yaml +++ b/services/hermes/agent-deployment.yaml @@ -25,7 +25,7 @@ spec: ai.bstein.dev/execution: Hermes Kanban with durable direct Codex and Claude Code CLI workers ai.bstein.dev/model-policy: Jetson-assisted AUTO routing, low through xhigh, cross-provider fallback ai.bstein.dev/placement: rpi5 preferred; Jetson deferred until state storage is available - ai.bstein.dev/config-rev: "20260812-stream-tool-failover" + ai.bstein.dev/config-rev: "20260812-deduplicate-tool-stream" vault.hashicorp.com/agent-inject: "true" vault.hashicorp.com/role: hermes-agent vault.hashicorp.com/agent-inject-secret-anthropic-token: kv/data/atlas/hermes/agent-tokens diff --git a/services/hermes/scripts/codex_broker.py b/services/hermes/scripts/codex_broker.py index 6f34c4520..2d950547b 100644 --- a/services/hermes/scripts/codex_broker.py +++ b/services/hermes/scripts/codex_broker.py @@ -273,17 +273,35 @@ def _normalized_stream(body: bytes, completed: dict[str, Any]) -> bytes: """Return Responses SSE with the reconstructed terminal response attached.""" normalized: list[str] = [] replaced_terminal = False + skip_event_data = False for line in body.decode("utf-8", errors="replace").splitlines(): + if line == "event: response.function_call_arguments.done": + # Switchyard translates both the argument deltas and this terminal + # snapshot into Chat Completions deltas. That duplicates the JSON + # object and Hermes mistakes the result for provider truncation. + # The preceding deltas are complete and already validated by + # ``_completed_response``; suppress only the redundant snapshot. + skip_event_data = True + continue if line.startswith("data:"): value = line[5:].strip() try: event = json.loads(value) except (TypeError, ValueError, json.JSONDecodeError): event = None + if ( + skip_event_data + or isinstance(event, dict) + and event.get("type") == "response.function_call_arguments.done" + ): + skip_event_data = False + continue if isinstance(event, dict) and event.get("type") == "response.completed": event["response"] = completed line = "data: " + json.dumps(event, separators=(",", ":")) replaced_terminal = True + elif line: + skip_event_data = False normalized.append(line) if not replaced_terminal: raise RuntimeError("Codex stream ended without a completed event") diff --git a/testing/tests/test_hermes_chat_quality.py b/testing/tests/test_hermes_chat_quality.py index 2532d6333..c85d06866 100644 --- a/testing/tests/test_hermes_chat_quality.py +++ b/testing/tests/test_hermes_chat_quality.py @@ -684,6 +684,23 @@ def test_codex_broker_auth_and_request_contract(tmp_path: Path, monkeypatch): "output" ] == [completed_item] assert normalized.endswith("\n\n") + streamed_function_body = ( + "event: response.function_call_arguments.delta\n" + 'data: {"type":"response.function_call_arguments.delta",' + '"item_id":"call_1","delta":"{\\"path\\":\\"/tmp\\"}"}\n\n' + "event: response.function_call_arguments.done\n" + 'data: {"type":"response.function_call_arguments.done",' + '"item_id":"call_1","arguments":"{\\"path\\":\\"/tmp\\"}"}\n\n' + "event: response.completed\n" + "data: " + + json.dumps({"type": "response.completed", "response": completed}) + + "\n\n" + ).encode() + normalized_function_stream = module._normalized_stream( + streamed_function_body, completed + ).decode() + assert "response.function_call_arguments.delta" in normalized_function_stream + assert "response.function_call_arguments.done" not in normalized_function_stream with pytest.raises(RuntimeError, match="retryable incomplete response"): module._completed_response( [