Skip to content
Closed
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
80 changes: 80 additions & 0 deletions agent/anthropic_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -1511,6 +1511,15 @@ def _convert_content_part_to_anthropic(part: Any) -> Optional[Dict[str, Any]]:

if ptype == "input_text":
block: Dict[str, Any] = {"type": "text", "text": part.get("text", "")}
elif ptype == "text":
# A stored Anthropic text block. Rebuild from whitelisted fields only β€”
# SDK response text blocks carry output-only siblings (parsed_output,
# citations=None) that the Messages INPUT schema rejects with HTTP 400
# "Extra inputs are not permitted". Do NOT dict(part) it verbatim.
block = {"type": "text", "text": part.get("text", "")}
cits = part.get("citations")
if isinstance(cits, list) and cits:
block["citations"] = cits
elif ptype in {"image_url", "input_image"}:
image_value = part.get("image_url", {})
url = image_value.get("url", "") if isinstance(image_value, dict) else str(image_value or "")
Expand Down Expand Up @@ -1625,13 +1634,84 @@ def _content_parts_to_anthropic_blocks(parts: Any) -> List[Dict[str, Any]]:
return out


def _sanitize_replay_block(b: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Strip output-only fields from a stored Anthropic content block so it is
valid as REQUEST input on replay.

The SDK response objects carry output-only attributes that the Messages
*input* schema forbids ("Extra inputs are not permitted"): text blocks get
``parsed_output``/``citations`` (when null), tool_use blocks get ``caller``,
etc. ``normalize_response`` captured blocks verbatim via ``_to_plain_data``,
so these leak back as input on the next turn β†’ HTTP 400.

Whitelist per type (NOT a blacklist) so future SDK output-only fields can't
reintroduce the bug. Returns a clean block, or None to drop it.
"""
if not isinstance(b, dict):
return None
btype = b.get("type")
if btype == "text":
out: Dict[str, Any] = {"type": "text", "text": b.get("text", "")}
# citations is input-valid ONLY when it's a non-empty list; the SDK
# emits citations=None on responses, which the input schema rejects.
cits = b.get("citations")
if isinstance(cits, list) and cits:
out["citations"] = cits
if isinstance(b.get("cache_control"), dict):
out["cache_control"] = b["cache_control"]
return out
if btype == "thinking":
out = {"type": "thinking", "thinking": b.get("thinking", "")}
if b.get("signature"):
out["signature"] = b["signature"]
return out
if btype == "redacted_thinking":
# Only valid with its data payload; drop if missing.
return {"type": "redacted_thinking", "data": b["data"]} if b.get("data") else None
if btype == "tool_use":
out = {
"type": "tool_use",
"id": _sanitize_tool_id(b.get("id", "")),
"name": b.get("name", ""),
"input": b.get("input", {}),
}
if isinstance(b.get("cache_control"), dict):
out["cache_control"] = b["cache_control"]
return out
if btype == "image":
src = b.get("source")
return {"type": "image", "source": src} if isinstance(src, dict) else None
# Unknown/unsupported block type on the input path β€” drop rather than risk
# another "Extra inputs are not permitted".
return None


def _convert_assistant_message(m: Dict[str, Any]) -> Dict[str, Any]:
"""Convert an assistant message to Anthropic content blocks.

Handles thinking blocks, regular content, tool calls, and
reasoning_content injection for Kimi/DeepSeek endpoints.
"""
content = m.get("content", "")
# Anthropic interleaved-thinking fast path: when this turn carries a
# verbatim, order-preserving block list (set by normalize_response only
# for turns that interleave SIGNED thinking with tool_use), replay it.
# Each block is run through _sanitize_replay_block to strip output-only
# SDK fields (parsed_output, caller, citations=None, …) that the Messages
# INPUT schema forbids β€” replaying them verbatim caused HTTP 400 "Extra
# inputs are not permitted" (text.parsed_output). Block ORDER is preserved
# (the reason this channel exists); only forbidden sibling fields are
# dropped, leaving thinking signatures and tool_use id/name/input intact.
ordered_blocks = m.get("anthropic_content_blocks")
if isinstance(ordered_blocks, list) and ordered_blocks:
replayed: List[Dict[str, Any]] = []
for b in ordered_blocks:
clean = _sanitize_replay_block(b)
if clean is not None:
replayed.append(clean)
if replayed:
return {"role": "assistant", "content": replayed}

blocks = _extract_preserved_thinking_blocks(m)
if content:
if isinstance(content, list):
Expand Down
12 changes: 12 additions & 0 deletions agent/chat_completion_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -924,6 +924,18 @@ def build_assistant_message(agent, assistant_message, finish_reason: str) -> dic
if preserved:
msg["reasoning_details"] = preserved

# Anthropic interleaved-thinking replay: when a turn interleaves signed
# thinking blocks with tool_use, the parallel reasoning_details +
# tool_calls fields lose the cross-type ordering, and reconstruction
# front-loads thinking β€” reordering signed blocks and triggering HTTP 400
# ("thinking ... blocks in the latest assistant message cannot be
# modified"). Carry the verbatim ordered block list so the adapter can
# replay the latest assistant message unchanged. See
# agent/transports/anthropic.py and agent/anthropic_adapter.py.
ordered_blocks = getattr(assistant_message, "anthropic_content_blocks", None)
if ordered_blocks:
msg["anthropic_content_blocks"] = ordered_blocks

# Codex Responses API: preserve encrypted reasoning items for
# multi-turn continuity. These get replayed as input on the next turn.
codex_items = getattr(assistant_message, "codex_reasoning_items", None)
Expand Down
53 changes: 48 additions & 5 deletions agent/transports/anthropic.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ def normalize_response(self, response: Any, **kwargs) -> NormalizedResponse:
to OpenAI finish_reason, and collects reasoning_details in provider_data.
"""
import json
from agent.anthropic_adapter import _to_plain_data
from agent.anthropic_adapter import _to_plain_data, _sanitize_replay_block
from agent.transports.types import ToolCall

strip_tool_prefix = kwargs.get("strip_tool_prefix", False)
Expand All @@ -94,14 +94,40 @@ def normalize_response(self, response: Any, **kwargs) -> NormalizedResponse:
reasoning_parts = []
reasoning_details = []
tool_calls = []
# Verbatim, order-preserving copy of every content block in the turn.
# Anthropic signs each thinking block against the turn content that
# PRECEDES it at its position; when a turn interleaves thinking and
# tool_use (adaptive/interleaved thinking, Claude 4.6+), the parallel
# reasoning_details + tool_calls lists below lose that cross-type
# ordering. Replaying the latest assistant message in the wrong order
# invalidates the signatures -> HTTP 400 "thinking ... blocks in the
# latest assistant message cannot be modified". Preserve the exact
# block sequence here so the adapter can replay it unchanged. See
# tests/agent/test_anthropic_thinking_block_order.py.
ordered_blocks = []

for block in response.content:
block_dict = _to_plain_data(block)
clean_block = None
if isinstance(block_dict, dict):
# Sanitize at capture so output-only SDK fields (parsed_output,
# caller, citations=None, …) never persist to state.db and leak
# back as request input on replay β†’ HTTP 400 "Extra inputs are
# not permitted". Defence-in-depth with the replay-side sanitize.
clean_block = _sanitize_replay_block(block_dict)
if clean_block is not None:
ordered_blocks.append(clean_block)
if block.type == "text":
text_parts.append(block.text)
elif block.type == "thinking":
reasoning_parts.append(block.thinking)
block_dict = _to_plain_data(block)
if isinstance(block_dict, dict):
elif block.type in ("thinking", "redacted_thinking"):
if block.type == "thinking":
reasoning_parts.append(block.thinking)
# Use the sanitized block (clean_block) for reasoning_details too,
# since _extract_preserved_thinking_blocks replays these on the
# non-ordered path. Falls back to raw only if sanitize dropped it.
if isinstance(clean_block, dict):
reasoning_details.append(clean_block)
elif isinstance(block_dict, dict):
reasoning_details.append(block_dict)
elif block.type == "tool_use":
name = block.name
Expand Down Expand Up @@ -130,6 +156,23 @@ def normalize_response(self, response: Any, **kwargs) -> NormalizedResponse:
provider_data = {}
if reasoning_details:
provider_data["reasoning_details"] = reasoning_details
# Only worth carrying the ordered-blocks channel when the turn
# actually interleaves signed thinking with tool_use β€” that's the
# only shape the parallel lists reconstruct incorrectly. A turn that
# is purely text, or thinking-then-tools with a single leading
# thinking block, replays correctly without it.
_has_signed_thinking = any(
isinstance(b, dict)
and b.get("type") in ("thinking", "redacted_thinking")
and (b.get("signature") or b.get("data"))
for b in ordered_blocks
)
_has_tool_use = any(
isinstance(b, dict) and b.get("type") == "tool_use"
for b in ordered_blocks
)
if _has_signed_thinking and _has_tool_use:
provider_data["anthropic_content_blocks"] = ordered_blocks

return NormalizedResponse(
content="\n".join(text_parts) if text_parts else None,
Expand Down
12 changes: 12 additions & 0 deletions agent/transports/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,18 @@ def reasoning_details(self):
pd = self.provider_data or {}
return pd.get("reasoning_details")

@property
def anthropic_content_blocks(self):
"""Verbatim, order-preserving Anthropic content blocks for a turn.

Present only when an Anthropic turn interleaves signed thinking with
tool_use β€” the one shape the parallel reasoning_details + tool_calls
lists reconstruct in the wrong order, invalidating thinking-block
signatures on replay. See agent/transports/anthropic.py.
"""
pd = self.provider_data or {}
return pd.get("anthropic_content_blocks")

@property
def codex_reasoning_items(self):
pd = self.provider_data or {}
Expand Down
28 changes: 28 additions & 0 deletions hermes_cli/curses_ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,33 @@ def flush_stdin() -> None:
pass


def pop_kitty_keyboard() -> None:
"""Disable the Kitty keyboard protocol before a curses session.

The interactive front-end (Hermes TUI / prompt_toolkit session) may leave
the terminal in Kitty keyboard protocol mode. Terminals that implement it
(Ghostty, Kitty, foot, WezTerm) then encode arrow keys as CSI-u sequences
(e.g. ``\\x1b[57352u``) instead of the legacy ``\\x1bOA`` form. Python's
``curses`` was built against the legacy terminfo definition and cannot
decode CSI-u, so ``getch()`` returns a bare ``ESC`` (27) β€” which every
wizard treats as cancel, making arrow keys "advance the page" instead of
moving the selection. Terminals without the protocol (Konsole, xterm) are
unaffected.

Emitting the pop sequence (``CSI < u`` β€” the same escape cli.py already
uses on prompt return) before ``curses.wrapper()`` forces legacy encoding
for the duration of the curses screen, so arrow keys decode correctly.
No-op on non-TTY stdout.
"""
try:
if not sys.stdout.isatty():
return
sys.stdout.write("\x1b[<u")
sys.stdout.flush()
except Exception:
pass


# Normalized menu actions returned by ``read_menu_key``. Using sentinels keeps
# every menu's key-handling branch identical and free of raw escape-byte logic.
NAV_UP = "up"
Expand Down Expand Up @@ -518,6 +545,7 @@ def _draw(stdscr):
result_holder[0] = outcome
return

pop_kitty_keyboard()
curses.wrapper(_draw)
flush_stdin()
return result_holder[0] if result_holder[0] is not _KEEP else cancel_value
Expand Down
31 changes: 27 additions & 4 deletions hermes_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,7 @@ def _log_wal_fallback_once(db_label: str, exc: Exception) -> None:
reasoning TEXT,
reasoning_content TEXT,
reasoning_details TEXT,
anthropic_content_blocks TEXT,
codex_reasoning_items TEXT,
codex_message_items TEXT,
platform_message_id TEXT,
Expand Down Expand Up @@ -1847,6 +1848,7 @@ def append_message(
reasoning: str = None,
reasoning_content: str = None,
reasoning_details: Any = None,
anthropic_content_blocks: Any = None,
codex_reasoning_items: Any = None,
codex_message_items: Any = None,
platform_message_id: str = None,
Expand All @@ -1869,6 +1871,10 @@ def append_message(
json.dumps(reasoning_details)
if reasoning_details else None
)
anthropic_content_blocks_json = (
json.dumps(anthropic_content_blocks)
if anthropic_content_blocks else None
)
codex_items_json = (
json.dumps(codex_reasoning_items)
if codex_reasoning_items else None
Expand All @@ -1891,9 +1897,10 @@ def _do(conn):
cursor = conn.execute(
"""INSERT INTO messages (session_id, role, content, tool_call_id,
tool_calls, tool_name, timestamp, token_count, finish_reason,
reasoning, reasoning_content, reasoning_details, codex_reasoning_items,
reasoning, reasoning_content, reasoning_details, anthropic_content_blocks,
codex_reasoning_items,
codex_message_items, platform_message_id, observed)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
session_id,
role,
Expand All @@ -1907,6 +1914,7 @@ def _do(conn):
reasoning,
reasoning_content,
reasoning_details_json,
anthropic_content_blocks_json,
codex_items_json,
codex_message_items_json,
platform_message_id,
Expand Down Expand Up @@ -1955,6 +1963,9 @@ def _do(conn):
role = msg.get("role", "unknown")
tool_calls = msg.get("tool_calls")
reasoning_details = msg.get("reasoning_details") if role == "assistant" else None
anthropic_content_blocks = (
msg.get("anthropic_content_blocks") if role == "assistant" else None
)
codex_reasoning_items = (
msg.get("codex_reasoning_items") if role == "assistant" else None
)
Expand All @@ -1965,6 +1976,9 @@ def _do(conn):
reasoning_details_json = (
json.dumps(reasoning_details) if reasoning_details else None
)
anthropic_content_blocks_json = (
json.dumps(anthropic_content_blocks) if anthropic_content_blocks else None
)
codex_items_json = (
json.dumps(codex_reasoning_items) if codex_reasoning_items else None
)
Expand All @@ -1981,9 +1995,10 @@ def _do(conn):
conn.execute(
"""INSERT INTO messages (session_id, role, content, tool_call_id,
tool_calls, tool_name, timestamp, token_count, finish_reason,
reasoning, reasoning_content, reasoning_details, codex_reasoning_items,
reasoning, reasoning_content, reasoning_details, anthropic_content_blocks,
codex_reasoning_items,
codex_message_items, platform_message_id, observed)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
session_id,
role,
Expand All @@ -1997,6 +2012,7 @@ def _do(conn):
msg.get("reasoning") if role == "assistant" else None,
msg.get("reasoning_content") if role == "assistant" else None,
reasoning_details_json,
anthropic_content_blocks_json,
codex_items_json,
codex_message_items_json,
platform_msg_id,
Expand Down Expand Up @@ -2339,6 +2355,7 @@ def get_messages_as_conversation(
rows = self._conn.execute(
"SELECT role, content, tool_call_id, tool_calls, tool_name, "
"finish_reason, reasoning, reasoning_content, reasoning_details, "
"anthropic_content_blocks, "
"codex_reasoning_items, codex_message_items, platform_message_id, observed "
f"FROM messages WHERE session_id IN ({placeholders})"
f"{active_clause} ORDER BY id",
Expand Down Expand Up @@ -2386,6 +2403,12 @@ def get_messages_as_conversation(
except (json.JSONDecodeError, TypeError):
logger.warning("Failed to deserialize reasoning_details, falling back to None")
msg["reasoning_details"] = None
if row["anthropic_content_blocks"]:
try:
msg["anthropic_content_blocks"] = json.loads(row["anthropic_content_blocks"])
except (json.JSONDecodeError, TypeError):
logger.warning("Failed to deserialize anthropic_content_blocks, falling back to None")
msg["anthropic_content_blocks"] = None
if row["codex_reasoning_items"]:
try:
msg["codex_reasoning_items"] = json.loads(row["codex_reasoning_items"])
Expand Down
4 changes: 3 additions & 1 deletion plugins/memory/holographic/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,14 +168,16 @@ def initialize(self, session_id: str, **kwargs) -> None:
db_path = db_path.replace("${HERMES_HOME}", _hermes_home)
default_trust = float(self._config.get("default_trust", 0.5))
hrr_dim = int(self._config.get("hrr_dim", 1024))
hrr_weight = float(self._config.get("hrr_weight", 0.3))
hrr_weight = float(self._config.get("hrr_weight", 0.15))
embed_weight = float(self._config.get("embed_weight", 0.25))
temporal_decay = int(self._config.get("temporal_decay_half_life", 0))

self._store = MemoryStore(db_path=db_path, default_trust=default_trust, hrr_dim=hrr_dim)
self._retriever = FactRetriever(
store=self._store,
temporal_decay_half_life=temporal_decay,
hrr_weight=hrr_weight,
embed_weight=embed_weight,
hrr_dim=hrr_dim,
)
self._session_id = session_id
Expand Down
Loading
Loading