Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 49 additions & 1 deletion agent/chat_completion_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@
)
from agent.errors import EmptyStreamError, ProviderStreamParseError
from agent.turn_context import substitute_api_content
from agent.confab_notice import (
CONFAB_NOTICE_DISPLAY_KIND,
CONFAB_NOTICE_FIELD,
CONFAB_NOTICE_KEY,
extract_confab_notice,
)
from agent.gemini_native_adapter import is_native_gemini_base_url
from agent.model_metadata import is_local_endpoint, _ceil_chars_to_tokens
from agent.message_content import flatten_message_text
Expand Down Expand Up @@ -2471,6 +2477,21 @@ def build_assistant_message(agent, assistant_message, finish_reason: str) -> dic
if codex_message_items:
msg["codex_message_items"] = codex_message_items

# Out-of-band confab notice → presentation-only fields on the assistant
# row. This is the DURABLE triage record: an operator reading history can
# tell a confirmed self-confabulation catch from content that needs real
# provenance work. Both keys are stripped from every outgoing provider
# copy in conversation_loop's api_msg builder — they must never reach a
# model. Never appended to content. See agent/confab_notice.py.
_confab_notice = getattr(assistant_message, "confab_notice", None)
if _confab_notice:
msg["display_kind"] = CONFAB_NOTICE_DISPLAY_KIND
_display_metadata = msg.get("display_metadata")
if not isinstance(_display_metadata, dict):
_display_metadata = {}
_display_metadata[CONFAB_NOTICE_KEY] = _confab_notice
msg["display_metadata"] = _display_metadata

if assistant_tool_calls:
tool_calls = []
for tool_call in assistant_tool_calls:
Expand Down Expand Up @@ -4999,6 +5020,9 @@ def _call_chat_completions(stream_attempt_id: int):
role = "assistant"
reasoning_parts: list = []
usage_obj = None
# Out-of-band confab notice (see agent/confab_notice.py). Rides the
# final usage chunk; at most one is accepted per provider response.
confab_notice_acc: dict = {"value": None, "seen": 0}
_diag = agent._stream_diag_init()
request_client_holder["diag"] = _diag
_writer_token = {"value": None}
Expand Down Expand Up @@ -5193,6 +5217,23 @@ def _flush_pending_stream_text():
_discard_stale_stream_chunk(stream_attempt_id, chunk)
continue

# Out-of-band confab notice. The contract puts it on the final
# usage chunk (choices: []), but scan every chunk so a producer
# that attaches it slightly earlier is still consumed — at most
# ONE notice per response is accepted; a second is dropped and
# logged rather than overwriting the first.
_notice = extract_confab_notice(chunk)
if _notice is not None:
confab_notice_acc["seen"] += 1
if confab_notice_acc["value"] is None:
confab_notice_acc["value"] = _notice
else:
logger.debug(
"Ignoring duplicate %s in stream (seen=%d)",
CONFAB_NOTICE_FIELD,
confab_notice_acc["seen"],
)

if not chunk.choices:
if hasattr(chunk, "model") and chunk.model:
model_name = chunk.model
Expand Down Expand Up @@ -5607,12 +5648,19 @@ def _flush_pending_stream_text():
message=mock_message,
finish_reason=effective_finish_reason,
)
return SimpleNamespace(
_mock_response = SimpleNamespace(
id="stream-" + str(uuid.uuid4()),
model=model_name,
choices=[mock_choice],
usage=usage_obj,
)
# Forward the validated out-of-band notice on the synthetic completion
# so the transport's normalize_response sees the same top-level shape
# the non-streaming path gets. Only set when one was accepted, so a
# clean turn's response object is byte-identical to before.
if confab_notice_acc["value"] is not None:
setattr(_mock_response, CONFAB_NOTICE_FIELD, confab_notice_acc["value"])
return _mock_response

def _call_anthropic(request_client):
"""Stream an Anthropic Messages API response.
Expand Down
147 changes: 147 additions & 0 deletions agent/confab_notice.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
"""Validation for the out-of-band ``hermes_confab_notice`` response extension.

A bridge that detects and strips self-confabulated scaffold text from a model
reply must still tell the user the reply was contaminated — that signal is
load-bearing for triage. Delivering it *in band* (appended to assistant
``content``) mutates conversation history and is replayed upstream on every
full-history recovery, so the notice moves to a top-level response-envelope
extension instead:

.. code-block:: json

{"hermes_confab_notice": {"version": 1,
"kind": "scaffold_confab_removed",
"request_id": "3b264082",
"scope": "visible",
"grammar": "inbound"}}

This module owns the *only* gate between that untrusted provider-supplied
payload and anything user-visible or persisted. It fails closed: an unknown
version, a wrong ``kind``, a bad ``scope``, a non-string ``request_id``, or a
duplicate notice in one response yields ``None`` and a debug log. Callers must
never display or persist a payload this function rejected.

The returned object is a fresh dict containing only the validated keys — a
provider cannot smuggle extra fields into ``display_metadata`` by attaching
them to the extension.
"""

from __future__ import annotations

import logging
from typing import Any, Dict, Optional

logger = logging.getLogger(__name__)

#: Top-level key the bridge attaches to the completion / final usage chunk.
CONFAB_NOTICE_FIELD = "hermes_confab_notice"

#: Key under which the validated notice is carried on
#: ``NormalizedResponse.provider_data`` and inside ``display_metadata``.
CONFAB_NOTICE_KEY = "confab_notice"

#: ``display_kind`` stamped on the assistant row carrying a notice.
CONFAB_NOTICE_DISPLAY_KIND = "confab_notice"

#: The only schema version this consumer understands.
CONFAB_NOTICE_VERSION = 1

#: The only catch kind defined by v1 of the contract.
CONFAB_NOTICE_KIND = "scaffold_confab_removed"

#: Allowed ``scope`` values.
CONFAB_NOTICE_SCOPES = ("visible", "intermediate", "both")

#: User-facing status line. Out of band, but never invisible — this is the
#: current-turn half of the triage contract.
CONFAB_NOTICE_TEXT = (
"⚠️ Confabulation caught: the provider detected and removed "
"self-fabricated scaffold text from this reply."
)

# Defensive bound — ``request_id`` and ``grammar`` are short opaque labels.
_MAX_LABEL_LEN = 256


def validate_confab_notice(raw: Any) -> Optional[Dict[str, Any]]:
"""Return a sanitized copy of *raw* if it is a valid v1 notice, else ``None``.

Fails closed on every deviation from the contract. Never raises.
"""
if not isinstance(raw, dict):
if raw is not None:
logger.debug(
"Ignoring %s: expected object, got %s",
CONFAB_NOTICE_FIELD,
type(raw).__name__,
)
return None

version = raw.get("version")
# bool is an int subclass — True would otherwise pass as version 1.
if isinstance(version, bool) or version != CONFAB_NOTICE_VERSION:
logger.debug("Ignoring %s: unsupported version %r", CONFAB_NOTICE_FIELD, version)
return None

kind = raw.get("kind")
if kind != CONFAB_NOTICE_KIND:
logger.debug("Ignoring %s: unknown kind %r", CONFAB_NOTICE_FIELD, kind)
return None

request_id = raw.get("request_id")
if not isinstance(request_id, str) or not request_id.strip():
logger.debug(
"Ignoring %s: request_id must be a non-empty string, got %r",
CONFAB_NOTICE_FIELD,
request_id,
)
return None
if len(request_id) > _MAX_LABEL_LEN:
logger.debug("Ignoring %s: request_id too long", CONFAB_NOTICE_FIELD)
return None

scope = raw.get("scope")
if scope not in CONFAB_NOTICE_SCOPES:
logger.debug("Ignoring %s: invalid scope %r", CONFAB_NOTICE_FIELD, scope)
return None

# ``grammar`` is the detector's bounded label, or null when several catches
# cannot be represented by one label. Absent is treated as null.
grammar = raw.get("grammar")
if grammar is not None:
if not isinstance(grammar, str) or not grammar.strip():
logger.debug("Ignoring %s: invalid grammar %r", CONFAB_NOTICE_FIELD, grammar)
return None
if len(grammar) > _MAX_LABEL_LEN:
logger.debug("Ignoring %s: grammar too long", CONFAB_NOTICE_FIELD)
return None

return {
"version": CONFAB_NOTICE_VERSION,
"kind": CONFAB_NOTICE_KIND,
"request_id": request_id,
"scope": scope,
"grammar": grammar,
}


def extract_confab_notice(obj: Any) -> Optional[Dict[str, Any]]:
"""Pull ``hermes_confab_notice`` off a completion / chunk and validate it.

Reads the attribute first (SimpleNamespace stubs, permissive SDK models)
and falls back to the OpenAI SDK's ``model_extra`` bag, which is where an
unknown top-level field lands on a pydantic response model. Returns the
sanitized notice or ``None``.
"""
if obj is None:
return None
raw = getattr(obj, CONFAB_NOTICE_FIELD, None)
if raw is None:
extra = getattr(obj, "model_extra", None)
if isinstance(extra, dict):
raw = extra.get(CONFAB_NOTICE_FIELD)
if raw is None and isinstance(obj, dict):
raw = obj.get(CONFAB_NOTICE_FIELD)
if raw is None:
return None
return validate_confab_notice(raw)
19 changes: 19 additions & 0 deletions agent/conversation_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
call_with_messages as _call_with_messages,
)
from agent.display import KawaiiSpinner
from agent.confab_notice import CONFAB_NOTICE_TEXT
from agent.error_classifier import FailoverReason, classify_api_error
from agent.message_metadata import append_message
from agent.turn_context import (
Expand Down Expand Up @@ -7656,6 +7657,24 @@ def _perform_api_call(next_api_kwargs):

assistant_message = normalized
finish_reason = normalized.finish_reason

# Out-of-band confab notice: the provider caught and removed
# self-fabricated scaffold text from this reply. Tell the user NOW
# (CLI/TUI/gateway all receive _emit_status) — out of band must not
# mean invisible, because this signal is load-bearing for triage.
# Exactly one status per accepted notice: the request_id ledger
# stops a retry/fallback that re-normalizes the same response from
# emitting twice. See agent/confab_notice.py.
_confab_notice = getattr(normalized, "confab_notice", None)
if _confab_notice:
_seen = getattr(agent, "_confab_notices_announced", None)
if _seen is None:
_seen = set()
agent._confab_notices_announced = _seen
_notice_id = _confab_notice.get("request_id")
if _notice_id not in _seen:
_seen.add(_notice_id)
agent._emit_status(CONFAB_NOTICE_TEXT)

# Normalize content to string — some OpenAI-compatible servers
# (llama-server, etc.) return content as a dict or list instead
Expand Down
9 changes: 9 additions & 0 deletions agent/transports/chat_completions.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from typing import Any, Dict

from agent.lmstudio_reasoning import resolve_lmstudio_effort
from agent.confab_notice import CONFAB_NOTICE_KEY, extract_confab_notice
from agent.reasoning_effort import (
KIMI_K3_EFFORTS,
KIMI_K3_OVERRIDES,
Expand Down Expand Up @@ -987,6 +988,14 @@ def normalize_response(self, response: Any, **kwargs) -> NormalizedResponse:
if rd:
provider_data["reasoning_details"] = rd

# Out-of-band confab notice (agent/confab_notice.py). Top-level on the
# completion object for non-stream responses; the streaming path
# forwards the same field on its synthetic completion. Validated here —
# an unvalidated payload is dropped, never carried forward.
_confab_notice = extract_confab_notice(response)
if _confab_notice is not None:
provider_data[CONFAB_NOTICE_KEY] = _confab_notice

# OpenAI structured-refusal field. When a model declines, the SDK
# populates ``message.refusal`` with the explanation and leaves
# ``content`` empty. OpenAI-compatible proxies that front Anthropic /
Expand Down
11 changes: 11 additions & 0 deletions agent/transports/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,17 @@ def codex_message_items(self):
pd = self.provider_data or {}
return pd.get("codex_message_items")

@property
def confab_notice(self):
"""Validated out-of-band confab notice, or ``None``.

Set by the chat_completions transport when a bridge attached a valid
v1 ``hermes_confab_notice`` to the response envelope. Presentation
only — see agent/confab_notice.py.
"""
pd = self.provider_data or {}
return pd.get("confab_notice")


# ---------------------------------------------------------------------------
# Factory helpers
Expand Down
8 changes: 7 additions & 1 deletion apps/desktop/src/types/hermes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -581,7 +581,13 @@ export interface SessionMessage {
reasoning_content?: null | string
reasoning_details?: unknown
display_kind?:
'async_delegation_complete' | 'auto_continue' | 'hidden' | 'model_switch' | 'personality_switch' | string
| 'async_delegation_complete'
| 'auto_continue'
| 'confab_notice'
| 'hidden'
| 'model_switch'
| 'personality_switch'
| string
/**
* A backend older than this app can still serve this as unparsed JSON text,
* so readers must narrow before indexing into it.
Expand Down
5 changes: 5 additions & 0 deletions hermes_cli/cli_agent_setup_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -798,6 +798,11 @@ def _display_resumed_history(self):
if display_kind == "auto_continue":
entries.append(("event", "resumed interrupted turn"))
continue
if display_kind == "confab_notice":
# Presentation-only tag on an assistant row that still has
# real content — surface the catch as an event line AND fall
# through so the reply itself is still recapped.
entries.append(("event", "confabulation caught — scaffold text removed"))

if role == "system":
continue
Expand Down
Loading
Loading