Files
hermes-agent/agent/side_question_request.py
Teknium 4ea8d23cc9 fix(agent): side-question review fixes (unattended turns, profile-bound leases, billing, attribution)
- No side_question_token for cron runs (platform="cron") or delegated subagent turns
  (_delegate_depth > 0): nobody reads the answer, so it is pure unattended spend.
- A lease is bound to the profile home it was issued in; a plugin context of another profile
  gets None and leaves the lease for its owner.
- Usage bills to the session the token was issued for (capture.session_id), not the parent's
  id at release time, which can rotate between issue and redeem.
- Plugin side questions report as aux_task="plugin_side_question" with the asking plugin_id in
  pre/post_auxiliary_call, and record usage as plugin_side_question:<plugin_id>; /btw keeps
  side_question.
- Drop the new legacy key llm.allow_side_question: the capability is declare + consent only.
- The timeout starts before the fork build, so runtime resolution counts against it.
2026-10-24 19:56:21 -07:00

289 lines
14 KiB
Python

"""One text-only request on a detached cache-parity fork of the live session agent, for plugins
(``ctx.side_question``).
The fork replays the turn snapshot captured at finalization (system prompt, ``tools[]``,
messages) verbatim plus one user prompt, so the request reads the parent's warm prompt cache.
It runs the turn loop's own request phases (assembly, the payload build with its LLM
middleware, ``perform_api_call``) exactly once: no tool dispatch (tool calls in the response
are dropped), no retries, no fallback model, no compression, no persistence. Like any other
side call it reports through ``pre/post_auxiliary_call`` (``aux_task="plugin_side_question"``
plus the asking ``plugin_id``), never the turn-scoped ``pre/post_api_request``, and its usage is
billed to the session the token was issued for as ``plugin_side_question:<plugin_id>`` (``/btw``
keeps ``side_question``).
"""
from __future__ import annotations
import contextvars
import logging
import threading
import time
import uuid
from dataclasses import dataclass, field
from typing import Any, Optional
logger = logging.getLogger(__name__)
SIDE_QUESTION_ORIGIN = "side_question"
# Hook/usage task for plugin side questions, distinct from /btw's ``side_question``.
PLUGIN_SIDE_QUESTION_TASK = "plugin_side_question"
# Tool entries the client executes; any other ``type`` (Responses ``web_search``, Anthropic
# ``web_search_20250305``, ...) runs server-side and could act without Hermes seeing it.
_CLIENT_TOOL_TYPES = frozenset({"function", "custom"})
# OpenRouter request plugins that only pick the model; anything else (``web``, ...) executes.
_ROUTING_ONLY_OPENROUTER_PLUGINS = frozenset({"pareto-router"})
@dataclass(frozen=True)
class SideQuestionCapture:
"""The parent's request inputs frozen at turn finalization (the live parent moves on).
``parent_ref`` is weak: a lease must not keep an evicted session's agent alive."""
parent_ref: Any # weakref.ref[AIAgent]
messages: list[dict[str, Any]]
system_prompt: Optional[str]
system_prompt_static: Optional[str]
tools: list[dict[str, Any]]
session_id: str
turn_id: str
runtime: tuple # runtime_key(parent) at capture
context: contextvars.Context = field(repr=False)
def runtime_key(agent: Any) -> tuple:
"""What decides which model the fork talks to and how: a turn routed elsewhere (voice chat)
restores the main runtime after ``post_llm_call``, and the captured prompt must not go out
on it."""
return tuple(getattr(agent, name, None) for name in ("model", "provider", "base_url", "api_mode", "reasoning_config"))
@dataclass
class _Audit:
plugin_id: str
started: float = field(default_factory=time.monotonic)
outcome: str = "error"
usage: dict[str, Any] = field(default_factory=dict)
def _agent_provider_refusal(agent: Any) -> Optional[str]:
"""Why ``agent``'s runtime cannot serve a text-only side request, or None.
MoA fans out to advisor models; an agent-as-provider (ACP CLI shim, external-process
provider, codex app-server, a client declaring ``SUPPORTS_HERMES_TOOL_CALLS = False``)
runs its own tools on the remote side, which no client-side drop can stop."""
from agent.background_review import _parent_can_emit_tool_calls
from hermes_cli.runtime_provider_backends import _is_external_process_provider
if agent.provider == "moa":
return "moa"
if (
str(agent.base_url or "").lower().startswith(("acp://", "acp+tcp://"))
or agent.api_mode == "codex_app_server"
or _is_external_process_provider(agent.provider)
or not _parent_can_emit_tool_calls(agent)
):
return "agent_provider"
return None
def has_server_side_tools(api_kwargs: dict[str, Any]) -> bool:
"""True when the FINAL request asks the provider to execute something itself: a tool entry
with a non-client ``type`` (transports rewrite client tools, e.g. Codex/xAI swap a client
``web_search`` for ``{"type": "web_search"}``), OpenAI ``web_search_options``, or an
OpenRouter plugin other than a routing-only one."""
extra_body = api_kwargs.get("extra_body") if isinstance(api_kwargs.get("extra_body"), dict) else {}
# ``request_overrides`` land in extra_body, whose ``tools`` the SDK merge sends over the top level.
for tool in [*(api_kwargs.get("tools") or ()), *(extra_body.get("tools") or ())]:
tool_type = tool.get("type") if isinstance(tool, dict) else None
if tool_type and tool_type not in _CLIENT_TOOL_TYPES:
return True
if "web_search_options" in api_kwargs or "web_search_options" in extra_body:
return True
plugins = [*(api_kwargs.get("plugins") or ()), *(extra_body.get("plugins") or ())]
return any(not (isinstance(p, dict) and p.get("id") in _ROUTING_ONLY_OPENROUTER_PLUGINS) for p in plugins)
def _build_fork(parent: Any, capture: SideQuestionCapture) -> Any:
"""A never-routed cache-parity fork pinned to the captured request inputs."""
from agent.background_review import build_cache_parity_fork
# task_cfg={} = never route: the side question must hit the parent's warm cache.
fork, _rt, _routed = build_cache_parity_fork(
parent, {}, max_iterations=1, write_origin=SIDE_QUESTION_ORIGIN,
)
fork._cached_system_prompt = capture.system_prompt
# The static tier decides where the system-prompt cache breakpoint splits; rebuilding it on
# the fork re-reads prompt files and can land the marker elsewhere.
fork._cached_system_prompt_static = capture.system_prompt_static
fork.tools = capture.tools
fork.valid_tool_names = {tool["function"]["name"] for tool in capture.tools}
fork.session_id = capture.session_id
return fork
def _send(fork: Any, capture: SideQuestionCapture, prompt: str, audit: _Audit) -> Any:
"""Assemble, build and perform ONE request on the fork; the normalized reply, or None on
refusal / a cancelled call."""
from agent import relay_llm
from agent.auxiliary_hooks import finish_aux_call_hooks, start_aux_call_hooks
from agent.side_question import trim_snapshot_for_fork
from agent.turn_api_call import perform_api_call
from agent.turn_api_request import build_request_payload
from agent.turn_context import _PER_TURN_RESET_STATE
from agent.turn_request_assembly import assemble_api_request
from agent.turn_retry_state import TurnRetryState
from agent.turn_truncation import normalize_response_for_agent
from agent.turn_usage import record_response_usage
turn_id = f"{capture.turn_id}:{SIDE_QUESTION_ORIGIN}"
task_id = str(uuid.uuid4())
api_request_id = f"{turn_id}:api:1"
# Turn state a fork that never ran a turn lacks: the per-turn counters the request phases
# read, the replay-canonicalization clock (build_api_messages), the ids middleware and relay
# key on, and a non-streaming transport.
for name, value in _PER_TURN_RESET_STATE:
setattr(fork, name, value)
fork._current_turn_timestamp = time.time()
fork._current_turn_id, fork._current_task_id, fork._current_api_request_id = turn_id, task_id, api_request_id
fork._disable_streaming = True
messages = trim_snapshot_for_fork(capture.messages) + [{"role": "user", "content": prompt}]
assembled = assemble_api_request(
fork, messages=messages, current_turn_user_idx=len(messages) - 1, _ext_prefetch_cache=None,
_plugin_user_context=None, moa_config=None, active_system_prompt=fork._cached_system_prompt,
original_user_message=prompt, pending_moa_prepared_request=None, request_logger=logger,
)
built = build_request_payload(
fork, api_messages=assembled.api_messages, _moa_prepared_request=None,
tools_for_api=assembled.tools_for_api, system_message=None, api_call_count=1,
api_request_id=api_request_id, effective_task_id=task_id, turn_id=turn_id,
)
if has_server_side_tools(built.api_kwargs):
audit.outcome = "refused:server_side_tools"
return None
hooks = start_aux_call_hooks(
aux_task=PLUGIN_SIDE_QUESTION_TASK, metadata={"api_request_id": api_request_id, "plugin_id": audit.plugin_id},
client=fork.client,
kwargs=built.api_kwargs, provider=str(fork.provider or ""), model=str(fork.model or ""),
api_mode=str(fork.api_mode or ""), streaming=False,
)
api_start_time = time.time()
# One hook bracket around the whole attempt: a reply that fails after the provider returned
# (accounting, normalization) still closes the observer's pre_auxiliary_call with its error.
try:
verdict = perform_api_call(
fork, api_kwargs=built.api_kwargs, _original_api_kwargs=built._original_api_kwargs,
_llm_middleware_trace=built._llm_middleware_trace, _moa_prepared_request=None,
_retry=TurnRetryState(), thinking_spinner=None, retry_count=0, api_call_count=1,
api_request_id=api_request_id, effective_task_id=task_id, turn_id=turn_id, interrupted=False,
)
response = None if verdict.action == "break" else verdict.response
if response is not None:
record_response_usage(
fork, response, messages=messages, api_call_count=1,
api_duration=time.time() - api_start_time, compression_attempts=0, max_compression_attempts=0,
)
assistant_message = normalize_response_for_agent(fork, response)
# The reply is in the transport's native shape: observers get the normalized view.
normalized = (assistant_message, assistant_message.finish_reason,
fork._usage_summary_for_api_request_hook(response))
except BaseException as exc:
finish_aux_call_hooks(hooks, error=exc)
relay_llm.complete_logical_call(api_request_id, outcome="failed")
raise
if response is None:
finish_aux_call_hooks(hooks, error=InterruptedError("side question cancelled"))
relay_llm.complete_logical_call(api_request_id, outcome="cancelled")
audit.outcome = "error"
return None
relay_llm.complete_logical_call(api_request_id, outcome="success")
finish_aux_call_hooks(hooks, response, normalized=normalized)
return assistant_message
def _answer_text(fork: Any, assistant_message: Any) -> Optional[str]:
"""Visible text of the reply; its ``tool_calls`` are dropped, never dispatched."""
from agent.message_content import flatten_message_text
content = assistant_message.content
text = content if isinstance(content, str) else flatten_message_text(content)
return fork._strip_think_blocks(text or "").strip() or None
def _log_audit(audit: _Audit) -> None:
usage = audit.usage
logger.info(
"side_question plugin=%s outcome=%s calls=%d in=%d cache_read=%d cache_write=%d out=%d latency=%.1fs",
audit.plugin_id or "?", audit.outcome,
*(int(usage.get(key) or 0) for key in (
"api_calls", "input_tokens", "cache_read_tokens", "cache_write_tokens", "output_tokens")),
time.monotonic() - audit.started,
)
def ask_text_only(
capture: SideQuestionCapture, prompt: str, *, plugin_id: str = "", timeout: float = 15.0,
) -> Optional[str]:
"""Ask the session's own agent ``prompt`` after the captured turn; its text answer or None.
None on refusal (MoA, agent-as-provider, server-side tools in the final request), timeout,
provider error or an empty / tool-call-only reply. The request parameters (tools, reasoning,
max_tokens, tool_choice) are the parent's verbatim: changing any of them changes the cache
key, and an inherited thinking budget 400s against a smaller max_tokens."""
from agent.background_review import release_cache_parity_fork
audit = _Audit(plugin_id)
parent = capture.parent_ref()
refusal = "parent_gone" if parent is None else _agent_provider_refusal(parent)
if refusal is not None:
audit.outcome = f"refused:{refusal}"
_log_audit(audit)
return None
fork = None
built: list = [] # the fork once built, for the timer
timed_out = threading.Event()
def _on_timeout() -> None:
timed_out.set()
# Soft interrupt with a system reason: aborts the in-flight request without being
# recorded as a user Stop.
for target in built:
target.interrupt(None, tool_reason="side question timed out")
# The budget starts before the build: runtime resolution (credentials, client setup) is part
# of the caller's wait. A build cannot be interrupted, so one that outlasts the budget is
# caught right after it and sends nothing.
timer = threading.Timer(timeout, _on_timeout)
timer.daemon = True
timer.start()
try:
fork = _build_fork(parent, capture)
built.append(fork)
if timed_out.is_set():
audit.outcome = "timeout"
return None
if runtime_key(parent) != capture.runtime:
audit.outcome = "refused:runtime_changed"
return None
assistant_message = _send(fork, capture, prompt, audit)
if assistant_message is None:
return None
answer = _answer_text(fork, assistant_message)
audit.outcome = "ok" if answer else "empty"
return answer
except Exception:
# Boundary: a plugin's side question returns None, it never raises into the plugin.
audit.outcome = "timeout" if timed_out.is_set() else "error"
logger.warning("side question %s (plugin=%s)", audit.outcome, plugin_id or "?", exc_info=True)
return None
finally:
timer.cancel()
if fork is not None:
# Billed to the session the token was issued for: the parent's id may have rotated
# (``/compress``, a session reset) since the turn ended.
audit.usage = release_cache_parity_fork(
parent, fork, task=f"{PLUGIN_SIDE_QUESTION_TASK}:{plugin_id or '?'}",
session_id=capture.session_id)
_log_audit(audit)