Files
hermes-agent/agent/side_question.py
T
Teknium 74a95a3ddf feat: /btw now answers side questions with conversation context; /background renamed to /bg
/bg (formerly /background, which is retired) keeps the existing semantics:
spawn a fresh, independent agent session in the background.

/btw is now its own command matching the convention other harnesses use:
ask a quick side question ABOUT the current conversation without
interrupting it. A one-shot auxiliary LLM call (main model by default,
overridable via auxiliary.side_question.* in config.yaml) answers from a
read-only transcript snapshot — the live session's history, role
alternation, and prompt cache are untouched, and the current turn keeps
running.

Surfaces wired: CLI (inline mid-run dispatch), gateway (all messengers,
busy-dispatch table + idle dispatch, i18n across all 17 locales), TUI
(prompt.btw RPC + btw.complete event), Discord native slash, relay
command manifest, desktop exec routing, docs (EN + zh-Hans).
2026-08-29 07:25:17 -07:00

160 lines
5.6 KiB
Python

"""Context-aware side questions (``/btw``).
``/btw <question>`` answers a quick question ABOUT the current conversation
without interrupting it: a one-shot auxiliary LLM call receives a read-only
transcript snapshot plus the question, and the answer is delivered alongside
the live session. The live conversation history is never touched — no
synthetic turns, no role-alternation risk, no prompt-cache invalidation.
This is deliberately different from ``/bg`` (``/background``'s successor),
which spawns a fresh, contextless agent session for independent work.
Model selection rides the standard auxiliary plumbing
(:func:`agent.auxiliary_client.call_llm` via :func:`agent.oneshot.run_oneshot`):
pass ``main_runtime`` to inherit the live session's provider/model; users can
override per-task via ``auxiliary.side_question.provider`` / ``.model`` in
config.yaml.
"""
import logging
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
# Free-form auxiliary task name — resolvable via auxiliary.side_question.* in
# config.yaml, falls back main-model-first like every other aux task.
SIDE_QUESTION_TASK = "side_question"
# Per-message and total character budgets for the transcript snapshot. The
# snapshot is rendered to plain text (never replayed as raw provider messages)
# so assistant tool_calls entries can't trip provider-side validation on a
# tools-less one-shot request.
_PER_MESSAGE_CHAR_CAP = 2000
_TRANSCRIPT_CHAR_BUDGET = 24000
_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.\n"
"Rules:\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."
)
def _msg_text(msg: Dict[str, Any]) -> str:
"""Best-effort plain text from a provider-format message content field."""
content = msg.get("content")
if isinstance(content, str):
return content
if isinstance(content, list):
parts = []
for block in content:
if isinstance(block, dict):
text = block.get("text")
if isinstance(text, str):
parts.append(text)
return "\n".join(parts)
return ""
def render_history_for_side_question(
history: Optional[List[Dict[str, Any]]],
char_budget: int = _TRANSCRIPT_CHAR_BUDGET,
) -> str:
"""Render a conversation snapshot as a plain-text transcript.
Keeps the most recent messages that fit ``char_budget``, newest-biased
(older context is what gets dropped). Tool calls are summarized by name;
tool results are included truncated so "what did that command output"
style questions remain answerable.
"""
lines: List[str] = []
for msg in history or []:
if not isinstance(msg, dict):
continue
role = msg.get("role")
text = _msg_text(msg).strip()
if role == "system":
continue # system prompt is not needed and can be huge
if role == "user":
if text:
lines.append(f"USER: {text[:_PER_MESSAGE_CHAR_CAP]}")
elif role == "assistant":
tool_calls = msg.get("tool_calls") or []
if tool_calls:
names = [
(tc.get("function") or {}).get("name", "?")
for tc in tool_calls
if isinstance(tc, dict)
]
lines.append(f"ASSISTANT [called tools: {', '.join(names)}]")
if text:
lines.append(f"ASSISTANT: {text[:_PER_MESSAGE_CHAR_CAP]}")
elif role == "tool":
if text:
lines.append(f"TOOL RESULT: {text[:_PER_MESSAGE_CHAR_CAP]}")
# Newest-biased fit: walk from the end until the budget is spent.
kept: List[str] = []
used = 0
for line in reversed(lines):
cost = len(line) + 1
if used + cost > char_budget and kept:
break
kept.append(line)
used += cost
kept.reverse()
if not kept:
return "(no prior conversation)"
prefix = ""
if len(kept) < len(lines):
prefix = "[...older conversation omitted...]\n"
return prefix + "\n".join(kept)
def answer_side_question(
question: str,
history: Optional[List[Dict[str, Any]]],
*,
main_runtime: Optional[Dict[str, Any]] = None,
max_tokens: int = 2048,
temperature: Optional[float] = 0.3,
timeout: float = 180.0,
) -> str:
"""Answer ``question`` against a snapshot of ``history``.
Returns the model's text answer. Raises whatever the auxiliary client
raises (RuntimeError on no provider, etc.) — callers surface the error
on their own UI.
"""
from agent.oneshot import run_oneshot
question = (question or "").strip()
if not question:
raise ValueError("answer_side_question requires a non-empty question")
transcript = render_history_for_side_question(history)
user_input = (
"Conversation transcript (snapshot):\n"
"-----\n"
f"{transcript}\n"
"-----\n\n"
f"Side question: {question}"
)
return run_oneshot(
instructions=_INSTRUCTIONS,
user_input=user_input,
task=SIDE_QUESTION_TASK,
max_tokens=max_tokens,
temperature=temperature,
timeout=timeout,
main_runtime=main_runtime,
)