Files
hermes-agent/agent/side_question.py
T
Teknium 599a749ae9 fix(agent): side-question forks release clients only, never close the shared session
/btw and ctx.side_question tore their cache-parity fork down with
shutdown_memory_provider() + close(). The fork shares the live session's
id, and CLI/gateway key a session's terminal sandbox, browser and file
read stamps by that id (run_conversation(task_id=session_id)), so
close() -> _close_task_resources(session_id) -> cleanup_vm/cleanup_browser
tore down the parent session's live tool state after every side question.

Background review already releases only clients for exactly this reason
(_release_fork_clients). Lift that into one shared teardown,
background_review.release_cache_parity_fork(parent, fork, task=...):
attribute usage to the parent under `task`, then release_clients(). Both
one-shot fork callers use it; /btw usage is now recorded under its own
task name, side_question, instead of background_review.

Tests: a parent-session terminal env survives a plugin side question and
a real /btw fork (both red before); the side request runs under the
turn's profile and secret scope when redeemed from a fresh thread.
2026-10-24 19:56:21 -07:00

176 lines
8.4 KiB
Python

"""Context-aware side questions (``/btw``): answer a question ABOUT the conversation without
touching it (no synthetic turns, no role-alternation risk, no prompt-cache invalidation).
Preferred path: a detached cache-parity fork of the live parent ``AIAgent`` replays its
snapshot verbatim against the warm prefix cache (tools denied at dispatch, persistence
detached, usage attributed to the parent). Fallback (no live parent, e.g. gateway evicted
the agent): a rendered transcript through :func:`agent.oneshot.run_oneshot`.
``auxiliary.side_question.provider``/``.model`` route the fork elsewhere with a compact digest.
"""
import logging
from typing import Any, Callable, Dict, List, Optional
from agent.background_review import _msg_text
logger = logging.getLogger(__name__)
# Free-form auxiliary task name (auxiliary.side_question.*), main-model-first.
SIDE_QUESTION_TASK = "side_question"
# Fork path: the model may waste an iteration on a (denied) tool call first.
_FORK_MAX_ITERATIONS = 3
# Fallback one-shot path: per-message and total character budgets.
_PER_MESSAGE_CHAR_CAP = 2000
_TRANSCRIPT_CHAR_BUDGET = 24000
_FORK_PROMPT = (
"The user asked a quick SIDE question with /btw while the main work continues in the original "
"session.\nRules:\n- Answer ONLY the side question, using the conversation above as context. Do not continue, "
"redo, or critique the main task.\n- Do NOT call any tools — they are disabled for this side question. Answer "
"directly in text.\n- If the conversation does not contain enough information to answer, say so plainly instead "
"of guessing.\n- Be concise and direct."
)
_ONESHOT_INSTRUCTIONS = (
"You are the same AI assistant that is currently working inside the conversation transcribed below. The user "
"has asked a quick SIDE question with /btw while the main work continues.\nRules:\n- Answer ONLY the side "
"question. Do not continue, redo, or critique the main task.\n- Use the transcript as your primary context; it "
"is a snapshot and may not include the very latest activity.\n- If the transcript does not contain enough "
"information to answer, say so plainly instead of guessing.\n- Be concise and direct."
)
_ROLE_LABELS = {"user": "USER", "assistant": "ASSISTANT", "tool": "TOOL RESULT"}
def trim_snapshot_for_fork(history: Optional[list[dict[str, Any]]]) -> list[dict[str, Any]]:
"""Drop trailing messages until the snapshot ends with a completed assistant text.
A mid-turn tail (unresolved ``tool_calls``, tool result, in-flight user message) breaks
role alternation on strict providers; trimming only the TAIL keeps the warm prefix cache.
"""
msgs = list(history or [])
while msgs:
last = msgs[-1]
if isinstance(last, dict) and last.get("role") == "assistant" and not last.get("tool_calls"):
break
msgs.pop()
return msgs
def render_history_for_side_question(history: Optional[list[dict[str, Any]]], char_budget: int = _TRANSCRIPT_CHAR_BUDGET) -> str:
"""Plain-text transcript for the fallback path: newest-biased fit to ``char_budget``,
tool calls summarized by name, tool results truncated, system prompt skipped."""
lines: list[str] = []
for msg in history or []:
if not isinstance(msg, dict):
continue
role = msg.get("role")
text = _msg_text(msg)
if role == "assistant" and msg.get("tool_calls"):
names = [(tc.get("function") or {}).get("name", "?") for tc in msg["tool_calls"] if isinstance(tc, dict)]
lines.append(f"ASSISTANT [called tools: {', '.join(names)}]")
label = _ROLE_LABELS.get(role)
if label and text:
lines.append(f"{label}: {text[:_PER_MESSAGE_CHAR_CAP]}")
kept: list[str] = []
used = 0
for line in reversed(lines):
if used + len(line) + 1 > char_budget and kept:
break
kept.append(line)
used += len(line) + 1
if not kept:
return "(no prior conversation)"
prefix = "[...older conversation omitted...]\n" if len(kept) < len(lines) else ""
return prefix + "\n".join(reversed(kept))
def _side_question_task_config() -> dict[str, Any]:
"""Return ``auxiliary.side_question`` from config (or ``{}``)."""
try:
from hermes_cli.config import load_config_readonly
aux = load_config_readonly().get("auxiliary")
except Exception:
return {}
task = aux.get(SIDE_QUESTION_TASK) if isinstance(aux, dict) else None
return task if isinstance(task, dict) else {}
def _answer_via_fork(parent_agent: Any, question: str, history: Optional[list[dict[str, Any]]],
on_fork: Optional[Callable[[Any], None]] = None) -> str:
"""Answer via a cache-parity fork of ``parent_agent`` on the calling thread.
An empty thread-scoped tool whitelist denies every tool call at dispatch: ``tools[]``
stays byte-identical for cache parity, but the side question can never mutate anything.
``on_fork(fork)`` publishes the built fork so an owner can interrupt it, and
``on_fork(None)`` follows its teardown.
"""
from agent.background_review import _digest_history, build_cache_parity_fork, release_cache_parity_fork
from hermes_cli.plugins import clear_thread_tool_whitelist, set_thread_tool_whitelist
fork, _rt, routed = build_cache_parity_fork(parent_agent, _side_question_task_config(),
max_iterations=_FORK_MAX_ITERATIONS, write_origin="side_question")
if on_fork is not None:
on_fork(fork)
try:
set_thread_tool_whitelist(set(), deny_msg_fmt=(
"Side question (/btw) denied tool call: {tool_name}. "
"Tools are disabled here — answer directly from the conversation context."))
snapshot = trim_snapshot_for_fork(history)
result = fork.run_conversation(user_message=f"{_FORK_PROMPT}\n\nSide question: {question}",
conversation_history=_digest_history(snapshot) if routed else snapshot)
answer = (result or {}).get("final_response", "") or ""
if not answer and result and result.get("error"):
raise RuntimeError(str(result["error"]))
return answer.strip()
finally:
clear_thread_tool_whitelist()
release_cache_parity_fork(parent_agent, fork, task=SIDE_QUESTION_TASK)
if on_fork is not None:
on_fork(None)
def _answer_via_oneshot(question: str, history: Optional[list[dict[str, Any]]], **run_kwargs: Any) -> str:
"""Fallback: answer from a rendered transcript digest in one aux call."""
from agent.oneshot import run_oneshot
user_input = (
f"Conversation transcript (snapshot):\n-----\n{render_history_for_side_question(history)}\n-----\n\n"
f"Side question: {question}"
)
return run_oneshot(instructions=_ONESHOT_INSTRUCTIONS, user_input=user_input, task=SIDE_QUESTION_TASK, **run_kwargs)
def answer_side_question(
question: str, history: Optional[list[dict[str, Any]]], *, parent_agent: Any = None,
main_runtime: Optional[dict[str, Any]] = None, max_tokens: int = 2048, temperature: Optional[float] = 0.3,
timeout: float = 180.0, on_fork: Optional[Callable[[Any], None]] = None,
should_stop: Optional[Callable[[], bool]] = None,
) -> str:
"""Fork when ``parent_agent`` is live, else (or on empty answer / failure) the one-shot
digest. Raises on failure — callers surface the error on their own UI.
``on_fork`` receives the fork once built (and ``None`` after its teardown) so the caller can
interrupt it; ``should_stop()`` true after the fork skips the one-shot fallback."""
question = (question or "").strip()
if not question:
raise ValueError("answer_side_question requires a non-empty question")
if parent_agent is not None:
try:
answer = (_answer_via_fork(parent_agent, question, history, on_fork) if on_fork is not None
else _answer_via_fork(parent_agent, question, history))
if answer:
return answer
logger.warning("/btw fork returned an empty answer; falling back to one-shot")
except Exception:
logger.warning("/btw cache-parity fork failed; falling back to one-shot", exc_info=True)
if should_stop is not None and should_stop():
raise RuntimeError("side question stopped")
return _answer_via_oneshot(question, history, main_runtime=main_runtime, max_tokens=max_tokens,
temperature=temperature, timeout=timeout)