Skip to content
Draft
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
11 changes: 9 additions & 2 deletions agent/anthropic_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -890,13 +890,20 @@ def _read_claude_code_credentials_from_keychain() -> Optional[Dict[str, Any]]:
logger.debug("Keychain: no entry found for 'Claude Code-credentials'")
return None

raw = result.stdout.strip()
stdout = getattr(result, "stdout", "")
if isinstance(stdout, bytes):
stdout = stdout.decode("utf-8", errors="replace")
elif not isinstance(stdout, str):
logger.debug("Keychain: credentials payload is not text")
return None

raw = stdout.strip()
if not raw:
return None

try:
data = json.loads(raw)
except json.JSONDecodeError:
except (json.JSONDecodeError, TypeError):
logger.debug("Keychain: credentials payload is not valid JSON")
return None

Expand Down
14 changes: 14 additions & 0 deletions agent/conversation_compression.py
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,7 @@ def compress_context(
task_id: str = "default",
focus_topic: Optional[str] = None,
force: bool = False,
conversation_history: Optional[list] = None,
) -> Tuple[list, str]:
"""Compress conversation context and split the session in SQLite.

Expand Down Expand Up @@ -512,6 +513,19 @@ def _release_lock() -> None:
old_title = agent._session_db.get_session_title(agent.session_id)
# Trigger memory extraction on the old session before it rotates.
agent.commit_memory_session(messages)
# Persist this turn's unflushed tail before ending the old session.
# Compression callers that have the pre-turn history pass it in so
# identity-based flush can skip already persisted rows. Fallback to
# the old session's current DB row count for manual/direct callers.
if hasattr(agent, "_flush_messages_to_session_db"):
flush_history = conversation_history
if flush_history is None:
try:
persisted_count = len(agent._session_db.get_messages(agent.session_id))
except Exception:
persisted_count = 0
flush_history = messages[:min(persisted_count, len(messages))]
agent._flush_messages_to_session_db(messages, flush_history)
agent._session_db.end_session(agent.session_id, "compression")
old_session_id = agent.session_id
agent.session_id = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}"
Expand Down
4 changes: 4 additions & 0 deletions agent/conversation_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -2741,6 +2741,7 @@ def _perform_api_call(next_api_kwargs):
messages, system_message,
approx_tokens=approx_tokens,
task_id=effective_task_id,
conversation_history=conversation_history,
)
# Compression created a new session — clear history
# so _flush_messages_to_session_db writes compressed
Expand Down Expand Up @@ -2917,6 +2918,7 @@ def _perform_api_call(next_api_kwargs):
messages, active_system_prompt = agent._compress_context(
messages, system_message, approx_tokens=approx_tokens,
task_id=effective_task_id,
conversation_history=conversation_history,
)
# Compression created a new session — clear history
# so _flush_messages_to_session_db writes compressed
Expand Down Expand Up @@ -3073,6 +3075,7 @@ def _perform_api_call(next_api_kwargs):
messages, active_system_prompt = agent._compress_context(
messages, system_message, approx_tokens=approx_tokens,
task_id=effective_task_id,
conversation_history=conversation_history,
)
# Compression created a new session — clear history
# so _flush_messages_to_session_db writes compressed
Expand Down Expand Up @@ -4028,6 +4031,7 @@ def _perform_api_call(next_api_kwargs):
messages, system_message,
approx_tokens=agent.context_compressor.last_prompt_tokens,
task_id=effective_task_id,
conversation_history=conversation_history,
)
# Compression created a new session — clear history so
# _flush_messages_to_session_db writes compressed messages
Expand Down
141 changes: 136 additions & 5 deletions agent/transports/anthropic.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,136 @@
This transport owns format conversion and normalization — NOT client lifecycle.
"""

from typing import Any, Dict, List, Optional
import html
import json
import re
from typing import Any, Dict, List, Optional, Tuple

from agent.transports.base import ProviderTransport
from agent.transports.types import NormalizedResponse
from agent.transports.types import NormalizedResponse, ToolCall


_NAME_ATTR_RE = re.compile(r"""\bname\s*=\s*(?P<quote>["'])(?P<name>.*?)(?P=quote)""", re.IGNORECASE)
_TEXT_INVOKE_RE = re.compile(
r"""<(?:[A-Za-z_][\w.-]*:)?(?P<tag>invoke|function)\b(?P<attrs>[^>]*)>"""
r"""(?P<body>.*?)"""
r"""</(?:[A-Za-z_][\w.-]*:)?(?P=tag)>""",
re.DOTALL | re.IGNORECASE,
)
_TEXT_PARAMETER_RE = re.compile(
r"""<(?:[A-Za-z_][\w.-]*:)?parameter\b(?P<attrs>[^>]*)>"""
r"""(?P<value>.*?)"""
r"""</(?:[A-Za-z_][\w.-]*:)?parameter>""",
re.DOTALL | re.IGNORECASE,
)
_TEXT_JSON_TOOL_RE = re.compile(
r"""<(?P<tag>tool_call|function_call)\b[^>]*>(?P<body>.*?)</(?P=tag)>""",
re.DOTALL | re.IGNORECASE,
)
_TEXT_JSON_TOOL_LIST_RE = re.compile(
r"""<(?P<tag>tool_calls|function_calls)\b[^>]*>(?P<body>.*?)</(?P=tag)>""",
re.DOTALL | re.IGNORECASE,
)


def _parse_text_tool_value(raw: str) -> Any:
value = html.unescape(raw).strip()
try:
return json.loads(value)
except (json.JSONDecodeError, TypeError, ValueError):
return value


def _extract_attr_name(attrs: str) -> Optional[str]:
match = _NAME_ATTR_RE.search(attrs or "")
if not match:
return None
name = html.unescape(match.group("name")).strip()
return name or None


def _tool_call_from_text_invoke(match: re.Match[str], index: int) -> Optional[ToolCall]:
name = _extract_attr_name(match.group("attrs"))
if not name:
return None

arguments: Dict[str, Any] = {}
for param in _TEXT_PARAMETER_RE.finditer(match.group("body") or ""):
param_name = _extract_attr_name(param.group("attrs"))
if not param_name:
continue
arguments[param_name] = _parse_text_tool_value(param.group("value"))

return ToolCall(
id=f"toolu_text_{index}",
name=name,
arguments=json.dumps(arguments, ensure_ascii=False),
)


def _tool_calls_from_json_payload(payload: Any, start_index: int) -> List[ToolCall]:
if isinstance(payload, dict):
entries = [payload]
elif isinstance(payload, list):
entries = [entry for entry in payload if isinstance(entry, dict)]
else:
return []

calls: List[ToolCall] = []
for entry in entries:
name = entry.get("name")
if not isinstance(name, str) or not name.strip():
continue
arguments = entry.get("arguments", entry.get("input", {}))
if isinstance(arguments, str):
try:
arguments = json.loads(arguments)
except (json.JSONDecodeError, TypeError, ValueError):
arguments = {}
if not isinstance(arguments, dict):
arguments = {}
calls.append(
ToolCall(
id=str(entry.get("id") or f"toolu_text_{start_index + len(calls)}"),
name=name.strip(),
arguments=json.dumps(arguments, ensure_ascii=False),
)
)
return calls


def _salvage_text_tool_calls(text: str) -> Tuple[List[ToolCall], str]:
"""Promote complete tool-call markup leaked into Anthropic text blocks."""
if not text:
return [], text

tool_calls: List[ToolCall] = []

def _replace_invoke(match: re.Match[str]) -> str:
tool_call = _tool_call_from_text_invoke(match, len(tool_calls) + 1)
if not tool_call:
return match.group(0)
tool_calls.append(tool_call)
return ""

stripped = _TEXT_INVOKE_RE.sub(_replace_invoke, text)

def _replace_json(match: re.Match[str]) -> str:
try:
payload = json.loads(match.group("body").strip())
except (json.JSONDecodeError, TypeError, ValueError):
return match.group(0)
calls = _tool_calls_from_json_payload(payload, len(tool_calls) + 1)
if not calls:
return match.group(0)
tool_calls.extend(calls)
return ""

stripped = _TEXT_JSON_TOOL_RE.sub(_replace_json, stripped)
stripped = _TEXT_JSON_TOOL_LIST_RE.sub(_replace_json, stripped)
stripped = re.sub(r"[ \t]+\n", "\n", stripped)
stripped = re.sub(r"\n{3,}", "\n\n", stripped).strip()
return tool_calls, stripped


class AnthropicTransport(ProviderTransport):
Expand Down Expand Up @@ -83,9 +209,7 @@ def normalize_response(self, response: Any, **kwargs) -> NormalizedResponse:
Parses content blocks (text, thinking, tool_use), maps stop_reason
to OpenAI finish_reason, and collects reasoning_details in provider_data.
"""
import json
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)
_MCP_PREFIX = "mcp_"
Expand Down Expand Up @@ -152,6 +276,13 @@ def normalize_response(self, response: Any, **kwargs) -> NormalizedResponse:
)

finish_reason = self._STOP_REASON_MAP.get(response.stop_reason, "stop")
content = "\n".join(text_parts) if text_parts else None
if not tool_calls and content:
salvaged_tool_calls, stripped_content = _salvage_text_tool_calls(content)
if salvaged_tool_calls:
tool_calls.extend(salvaged_tool_calls)
content = stripped_content or None
finish_reason = "tool_calls"

provider_data = {}
if reasoning_details:
Expand All @@ -175,7 +306,7 @@ def normalize_response(self, response: Any, **kwargs) -> NormalizedResponse:
provider_data["anthropic_content_blocks"] = ordered_blocks

return NormalizedResponse(
content="\n".join(text_parts) if text_parts else None,
content=content,
tool_calls=tool_calls or None,
finish_reason=finish_reason,
reasoning="\n\n".join(reasoning_parts) if reasoning_parts else None,
Expand Down
24 changes: 20 additions & 4 deletions agent/transports/codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,10 +218,26 @@ def build_kwargs(
kwargs.pop("timeout", None)

if is_codex_backend:
# chatgpt.com/backend-api/codex rejects body-level
# ``extra_headers`` with HTTP 400. Correlation/cache routing for
# this backend must not be sent through the Responses payload.
kwargs.pop("extra_headers", None)
# chatgpt.com/backend-api/codex uses stable request-level
# correlation headers as part of its cache routing. These are
# OpenAI SDK request options, not JSON body fields; removing them
# collapses prefix-cache hits for long-lived Codex sessions.
prompt_cache_key = kwargs.get("prompt_cache_key")
cache_scope_id = str(prompt_cache_key or session_id or "").strip()
if cache_scope_id:
existing_extra_headers = kwargs.get("extra_headers")
merged_extra_headers: Dict[str, str] = {}
if isinstance(existing_extra_headers, dict):
merged_extra_headers.update(
{
str(key): str(value)
for key, value in existing_extra_headers.items()
if key and value is not None
}
)
merged_extra_headers["session_id"] = cache_scope_id
merged_extra_headers["x-client-request-id"] = cache_scope_id
kwargs["extra_headers"] = merged_extra_headers

max_tokens = params.get("max_tokens")
if max_tokens is not None and not is_codex_backend:
Expand Down
2 changes: 1 addition & 1 deletion gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -16916,7 +16916,7 @@ def restart_signal_handler():
_loop = asyncio.get_running_loop()
await _loop.run_in_executor(None, discover_mcp_tools)
except Exception as e:
logger.debug("MCP tool discovery failed: %s", e)
logger.warning("MCP tool discovery failed: %s", e, exc_info=True)

# Start the gateway
success = await runner.start()
Expand Down
82 changes: 73 additions & 9 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -3725,6 +3725,78 @@ def _set_nested(config, dotted_key: str, value):
current[last] = value


_CONFIG_DEFAULT_MISSING = object()
_CONFIG_BOOL_TRUE = {"true", "yes", "on", "1"}
_CONFIG_BOOL_FALSE = {"false", "no", "off", "0"}


def _default_config_value_for_key(dotted_key: str) -> Any:
current: Any = DEFAULT_CONFIG
for part in dotted_key.split("."):
if isinstance(current, dict):
if part not in current:
return _CONFIG_DEFAULT_MISSING
current = current[part]
continue
if isinstance(current, list):
try:
idx = int(part)
except (TypeError, ValueError):
return _CONFIG_DEFAULT_MISSING
if idx < 0 or idx >= len(current):
return _CONFIG_DEFAULT_MISSING
current = current[idx]
continue
return _CONFIG_DEFAULT_MISSING
return current


def _coerce_config_set_value(key: str, value: str) -> Any:
"""Coerce ``hermes config set`` values only when the key's type is known.

The old global ``on/off`` heuristic corrupted string enums such as
``approvals.mode=off``. Keep numeric convenience for unknown extension
keys, but reserve boolean word parsing for keys whose default is boolean.
"""
if not isinstance(value, str):
return value

default_value = _default_config_value_for_key(key)
stripped = value.strip()
lowered = stripped.lower()

if isinstance(default_value, bool):
if lowered in _CONFIG_BOOL_TRUE:
return True
if lowered in _CONFIG_BOOL_FALSE:
return False
return value

if isinstance(default_value, int) and not isinstance(default_value, bool):
try:
return int(stripped)
except ValueError:
return value

if isinstance(default_value, float):
try:
return float(stripped)
except ValueError:
return value

if default_value is _CONFIG_DEFAULT_MISSING or default_value is None:
try:
return int(stripped)
except ValueError:
pass
try:
return float(stripped)
except ValueError:
return value

return value


def get_missing_config_fields() -> List[Dict[str, Any]]:
"""
Check which config fields are missing or outdated (recursive).
Expand Down Expand Up @@ -6295,15 +6367,7 @@ def set_config_value(key: str, value: str):
# _set_nested which preserves list-typed nodes; before #17876 the
# inline navigation here silently overwrote lists with dicts.

# Convert value to appropriate type
if value.lower() in {'true', 'yes', 'on'}:
value = True
elif value.lower() in {'false', 'no', 'off'}:
value = False
elif value.isdigit():
value = int(value)
elif value.replace('.', '', 1).isdigit():
value = float(value)
value = _coerce_config_set_value(key, value)

_set_nested(user_config, key, value)

Expand Down
Loading