Files
hermes-agent/agent/turn_tool_round.py

297 lines
14 KiB
Python

"""One tool-calling round of the conversation turn loop: validate/cap/dedupe the model's
tool calls, persist the tool-call turn BEFORE any side effect, execute the tools, honour
guardrail halts / persistence failures, then compress after tool results. Nothing here
imports ``agent.conversation_loop`` at module level (cycle) — loop-internal helpers resolve
lazily.
"""
from __future__ import annotations
from contextlib import suppress
from dataclasses import dataclass
import logging
from typing import Any, Dict, Optional, Tuple
from agent.message_metadata import append_message
from agent.message_sanitization import coalesce_tool_call_id
from agent.turn_preflight import compress_after_tool_results
from agent.turn_tool_validation import validate_tool_calls
logger = logging.getLogger("agent.conversation_loop")
# Post-response housekeeping tools: a round made only of these mutes tool progress.
_HOUSEKEEPING_TOOLS = frozenset({"memory", "todo_list", "skill_manage", "session_search"})
@dataclass
class ToolRoundVerdict:
"""``action``: ``"continue"`` (tools ran, next API call), ``"break"`` (turn ends:
persistence failure, guardrail halt, post-tool compression end) or ``"return"``
(``result`` is the turn's result dict). The other fields are the loop locals the round
rebinds."""
action: str
messages: Any
conversation_history: Any
active_system_prompt: Any
compression_attempts: Any
final_response: Any
failed: Any
_turn_exit_reason: Any
truncated_tool_call_retries: Any
current_turn_user_idx: Any
result: Optional[Dict[str, Any]] = None
def run_tool_round(
agent: Any, *, assistant_message: Any, finish_reason: Any, messages: Any,
conversation_history: Any, api_call_count: Any, effective_task_id: Any, user_message: Any,
system_message: Any, active_system_prompt: Any, compression_attempts: Any,
max_compression_attempts: Any, final_response: Any, failed: Any, _turn_exit_reason: Any,
truncated_tool_call_retries: Any, current_turn_user_idx: Any,
) -> ToolRoundVerdict:
"""Execute one tool round in the exact original order. Persist-before-execute is a
durability invariant: resume must see the executed block if a destructive tool restarts
Hermes; a failed canonical append ends the turn rather than running tools from
process-only state."""
from agent.conversation_loop import _invalid_tool_name_error_content
def _verdict(action: str, result: Optional[Dict[str, Any]] = None) -> ToolRoundVerdict:
return ToolRoundVerdict(
action=action, messages=messages, conversation_history=conversation_history,
active_system_prompt=active_system_prompt, compression_attempts=compression_attempts,
final_response=final_response, failed=failed, _turn_exit_reason=_turn_exit_reason,
truncated_tool_call_retries=truncated_tool_call_retries,
current_turn_user_idx=current_turn_user_idx, result=result,
)
if not agent.quiet_mode:
agent._vprint(f"{agent.log_prefix}🔧 Processing {len(assistant_message.tool_calls)} tool call(s)...")
if agent.verbose_logging:
for tc in assistant_message.tool_calls:
raw_args = tc.function.arguments
args_preview = raw_args[:200] if isinstance(raw_args, str) else repr(raw_args)[:200]
logging.debug("Tool call: %s with args: %s...", tc.function.name, args_preview)
_tvv = validate_tool_calls(
agent, assistant_message, finish_reason, messages=messages,
conversation_history=conversation_history, api_call_count=api_call_count,
effective_task_id=effective_task_id,
)
if _tvv.action == "return":
return _verdict("return", _tvv.result)
if _tvv.action == "continue":
return _verdict("continue")
# Post-call guardrails.
assistant_message.tool_calls = agent._deduplicate_tool_calls(
agent._cap_delegate_task_calls(assistant_message.tool_calls)
)
# Mixed batch: the assistant message keeps EVERY emitted call (each tool_call needs a
# matching result) while only valid ones dispatch.
_invalid_batch_calls = [
tc for tc in assistant_message.tool_calls if tc.function.name not in agent.valid_tool_names
] if _tvv.mixed_invalid_batch else []
assistant_msg, duplicate_previous_interim = stage_tool_call_message(
agent, assistant_message=assistant_message, finish_reason=finish_reason, messages=messages
)
append_message(messages, assistant_msg)
# Mixed batch: error-result invalid calls and drop them from execution.
if _invalid_batch_calls:
for tc in _invalid_batch_calls:
append_message(messages, {
"role": "tool",
"name": tc.function.name,
"tool_call_id": coalesce_tool_call_id(tc),
"content": _invalid_tool_name_error_content(
tc.function.name, agent.valid_tool_names
),
})
assistant_message.tool_calls = [
tc for tc in assistant_message.tool_calls if tc.function.name in agent.valid_tool_names
]
# Persist the tool-call turn before any tool side effects so resume sees the executed
# block if a destructive tool restarts Hermes.
try:
_tool_turn_persisted = agent._flush_messages_to_session_db(messages, conversation_history)
except Exception as exc:
_tool_turn_persisted = False
from hermes_state import classify_persistence_error
agent._last_persistence_error_cause = classify_persistence_error(exc)
logger.warning(
"Incremental tool-call persistence failed before execution "
"(session=%s): %s",
agent.session_id or "none",
exc,
)
if _tool_turn_persisted is False:
# Canonical append failed: never project the row or run tools from process-only
# state; break rather than retry. No recorded cause means genuinely unknown.
if getattr(agent, "_last_persistence_error_cause", None) is None:
agent._last_persistence_error_cause = "unknown"
_turn_exit_reason = "session_persistence_failed"
final_response = ""
failed = True
return _verdict("break")
# A UI must never observe an assistant/tool-call row that is only an in-memory
# projection: emit interim commentary after the DB append.
if not duplicate_previous_interim:
agent._emit_interim_assistant_message(assistant_msg)
# Flush open streaming boxes before tools so early content doesn't wrap tool feed
# lines. Display callback only — TTS (_stream_callback) must NOT receive None (EOS).
if agent.stream_delta_callback:
with suppress(Exception):
agent.stream_delta_callback(None)
agent._execute_tool_calls(assistant_message, messages, effective_task_id, api_call_count)
if getattr(agent, "_incremental_persistence_failed", False):
# Tool result could not be made canonical: never send the in-memory result to
# the model or project later events from this turn.
_turn_exit_reason = "session_persistence_failed"
final_response = ""
failed = True
return _verdict("break")
if agent._tool_guardrail_halt_decision is not None:
decision = agent._tool_guardrail_halt_decision
_turn_exit_reason = "guardrail_halt"
final_response = agent._toolguard_controlled_halt_response(decision)
agent._emit_diagnostic_status(f"⚠️ Tool guardrail halted {decision.tool_name}: {decision.code}")
append_message(messages, {"role": "assistant", "content": final_response})
# Emit the halt so it isn't mistaken for a crash; the stream callback is still
# alive, so SSE/TUI clients see the explanation.
if final_response:
agent._safe_print(f"\n{final_response}\n")
if agent.stream_delta_callback:
with suppress(Exception):
agent.stream_delta_callback(final_response)
agent.stream_delta_callback(None)
return _verdict("break")
# Reset per-turn retry counters so one truncation can't poison the turn.
truncated_tool_call_retries = 0
# Defer the paragraph break: _fire_stream_delta() prepends one "\n\n" when real
# text arrives, so tool iterations don't stack blank lines.
agent._stream_needs_break = True
# Refund the iteration when the ONLY tool was execute_code (programmatic tool
# calling) — cheap RPC-style calls shouldn't eat the budget.
if {tc.function.name for tc in assistant_message.tool_calls} == {"execute_code"}:
agent.iteration_budget.refund()
_ptc = compress_after_tool_results(
agent, messages=messages, system_message=system_message, user_message=user_message,
active_system_prompt=active_system_prompt, conversation_history=conversation_history,
compression_attempts=compression_attempts,
max_compression_attempts=max_compression_attempts, effective_task_id=effective_task_id,
final_response=final_response, turn_exit_reason=_turn_exit_reason,
current_turn_user_idx=current_turn_user_idx,
)
messages = _ptc.messages
active_system_prompt = _ptc.active_system_prompt
conversation_history = _ptc.conversation_history
compression_attempts = _ptc.compression_attempts
final_response = _ptc.final_response
_turn_exit_reason = _ptc.turn_exit_reason
current_turn_user_idx = _ptc.current_turn_user_idx
if _ptc.end_turn:
return _verdict("break")
# Save session log incrementally (so progress is visible even if interrupted)
agent._session_messages = messages
# Touch activity so slow post-tool work plus a slow follow-up API call can't exceed
# the gateway inactivity timeout (HERMES_AGENT_TIMEOUT).
# Touch activity before continuing so the gateway's inactivity monitor never sees a stale timestamp
# between tool completion and the start of the next API call. Without this, a tool-call result (which
# takes ~0s to process) followed by slow post-tool processing (compression, persist) and a slow
# follow-up API call can exceed the gateway inactivity timeout (HERMES_AGENT_TIMEOUT, default 1800s) and
# the gateway kills the session before the next activity touch fires (#69559, #69131).
agent._touch_activity(f"tool results posted, continuing iteration #{api_call_count}")
return _verdict("continue")
def stage_tool_call_message(
agent: Any, *, assistant_message: Any, finish_reason: Any, messages: Any
) -> Tuple[Dict[str, Any], bool]:
"""Build the assistant tool-call row and update the per-turn fallback/mute state.
Drops a bare bracketed marker beside a call (#78148), classifies housekeeping-only
rounds, keeps visible content as the empty-follow-up fallback, pops thinking-only
prefills (resetting their counters), re-arms the post-tool nudge and the
dropped-tool-call stall budget. Returns ``(assistant_msg, duplicate_previous_interim)``;
the flag suppresses re-emitting interim commentary the previous ``incomplete`` row
already showed."""
from agent.conversation_loop import _STALE_MARKER_RE
assistant_msg = agent._build_assistant_message(assistant_message, finish_reason)
turn_content = assistant_message.content or ""
# A bare bracketed token (e.g. ``[memory]``) beside a function call is protocol
# scaffolding; persisting it lets the post-tool fallback replay it forever (#78148).
if assistant_message.tool_calls and _STALE_MARKER_RE.fullmatch(turn_content.strip()):
logger.warning(
"Discarding bare tool-call marker from assistant content: %s", turn_content
)
turn_content = ""
assistant_msg["content"] = ""
# Classify tools regardless of visible content: a substantive tool-only turn must
# invalidate any older housekeeping fallback (so a two-turn-old housekeeping
# narration isn't attributed to the preceding tool turn), and clear the mute flag a
# prior housekeeping turn set, else _vprint suppresses this turn's tool progress.
_all_housekeeping = all(
tc.function.name in _HOUSEKEEPING_TOOLS for tc in assistant_message.tool_calls
)
if assistant_message.tool_calls and not _all_housekeeping:
agent._last_content_with_tools = None
agent._last_content_tools_all_housekeeping = False
agent._mute_post_response = False
# Content + tool_calls in one turn: keep the content as a fallback final response in
# case the follow-up turn after tools is empty. Mute only when EVERY tool call is
# post-response housekeeping; substantive tools keep output on.
if turn_content and agent._has_content_after_think_block(turn_content):
agent._last_content_with_tools = turn_content
agent._last_content_tools_all_housekeeping = _all_housekeeping
if _all_housekeeping and agent._has_stream_consumers():
agent._mute_post_response = True
elif agent._should_emit_quiet_tool_messages():
clean = agent._strip_think_blocks(turn_content).strip()
if clean:
agent._vprint(f" ┊ 💬 {clean}")
# Pop thinking-only prefill message(s) before appending (same rationale as the
# final-response path). Tool calls after a prefill recovery reset the prefill
# counter, so each tool-call success is a fresh start, not a cumulative burn.
_had_prefill = False
while messages and isinstance(messages[-1], dict) and messages[-1].get("_thinking_prefill"):
messages.pop()
_had_prefill = True
if _had_prefill:
agent._thinking_prefill_retries = 0
agent._empty_content_retries = 0
# Re-arm the post-tool nudge so it can fire on a LATER tool round; a landed tool call
# recovers any dropped-tool-call stall, so refresh that budget per stall.
agent._post_tool_empty_retried = False
agent._dropped_toolcall_retries = 0
previous_msg = messages[-1] if messages else None
current_interim_visible = agent._interim_assistant_visible_text(assistant_msg)
duplicate_previous_interim = (
bool(current_interim_visible)
and isinstance(previous_msg, dict)
and previous_msg.get("role") == "assistant"
and previous_msg.get("finish_reason") == "incomplete"
and agent._interim_assistant_visible_text(previous_msg) == current_interim_visible
)
return assistant_msg, duplicate_previous_interim