mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-25 11:02:03 +00:00
A "one-shot" is a single stateless model call that runs OUTSIDE any conversation: it never touches session history, never breaks prompt caching, and returns plain text. UI surfaces need this for small generative chores — a commit message from a diff, a rename suggestion, a summary — where an agent turn would pollute the thread and hand-rolling an LLM call at every call site would be worse. - `agent/oneshot.py`: `run_oneshot(...)` over the existing auxiliary-client plumbing (same path as title generation). Two call shapes: explicit instructions/input, or a registered `template` + `variables` (templates own the prompt engineering so it stays consistent across CLI/TUI/desktop). Ships a `commit_message` template. Model selection inherits the live session via `main_runtime`, else the configured aux `task` backend. - `tui_gateway/server.py`: `llm.oneshot` RPC (long-handler) inheriting the session's model when `session_id` resolves. Stateless by construction — no session mutation, cache untouched.
158 lines
6.1 KiB
Python
158 lines
6.1 KiB
Python
"""Shared one-off LLM requests for non-conversational helpers.
|
|
|
|
A "one-shot" is a single, stateless model call that runs *outside* any
|
|
conversation: it never touches a session's history, never breaks prompt
|
|
caching, and returns plain text. UI surfaces use it for small generative
|
|
chores — a commit message from a diff, a rename suggestion, a summary —
|
|
where spinning up an agent turn would be wrong (it would pollute the thread)
|
|
and hand-rolling an LLM call at every call site would be worse.
|
|
|
|
Two ways to call it:
|
|
|
|
* ``run_oneshot(instructions=..., user_input=...)`` — caller supplies the
|
|
full prompt.
|
|
* ``run_oneshot(template="commit_message", variables={...})`` — caller
|
|
names a registered template and passes its variables; the template owns
|
|
the prompt engineering so it stays consistent across CLI/TUI/desktop.
|
|
|
|
Model selection rides the same auxiliary plumbing as title generation
|
|
(:func:`agent.auxiliary_client.call_llm`): pass ``main_runtime`` to inherit
|
|
the live session's provider/model, otherwise the configured ``task`` (default
|
|
``title_generation``) resolves a cheap/fast backend.
|
|
"""
|
|
|
|
import logging
|
|
from typing import Any, Callable, Dict, Optional, Tuple
|
|
|
|
from agent.auxiliary_client import call_llm, extract_content_or_reasoning
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# A template turns a variables dict into a (instructions, user_input) pair.
|
|
# Templates are plain callables (not str.format) so diff/code payloads with
|
|
# literal "{" / "}" pass through untouched.
|
|
PromptTemplate = Callable[[Dict[str, Any]], Tuple[str, str]]
|
|
|
|
|
|
def _truncate(text: str, limit: int) -> str:
|
|
text = text or ""
|
|
if len(text) <= limit:
|
|
return text
|
|
return text[:limit].rstrip() + "\n…(truncated)"
|
|
|
|
|
|
_COMMIT_INSTRUCTIONS = (
|
|
"You write git commit messages. Given a diff of staged changes, write ONE "
|
|
"concise Conventional Commits message describing what the change does and why.\n"
|
|
"Rules:\n"
|
|
"- Subject line: type(scope): summary — imperative mood, lower-case, no "
|
|
"trailing period, ≤ 72 characters. Types: feat, fix, refactor, perf, docs, "
|
|
"test, build, chore, style, ci.\n"
|
|
"- Omit the scope if it isn't obvious.\n"
|
|
"- Add a short body (wrapped at ~72 cols) ONLY when the change needs "
|
|
"explanation; skip it for small/obvious changes.\n"
|
|
"- Describe the actual change, never restate the diff line-by-line.\n"
|
|
"- Return ONLY the commit message text — no quotes, no markdown fences, no "
|
|
"preamble."
|
|
)
|
|
|
|
|
|
def _commit_message_template(variables: Dict[str, Any]) -> Tuple[str, str]:
|
|
diff = _truncate(str(variables.get("diff") or ""), 12000)
|
|
recent = _truncate(str(variables.get("recent_commits") or ""), 1500)
|
|
|
|
parts = []
|
|
if recent.strip():
|
|
parts.append(
|
|
"Recent commit subjects from this repo (match their style/conventions):\n"
|
|
f"{recent}"
|
|
)
|
|
parts.append("Diff to describe:\n" + (diff or "(no textual diff available)"))
|
|
|
|
# "Regenerate" must yield something new even on models that decode greedily
|
|
# / pin temperature server-side. A trailing nonce isn't enough, so we hand
|
|
# back the previous message and require a genuinely different one.
|
|
avoid = _truncate(str(variables.get("avoid") or "").strip(), 1000)
|
|
if avoid:
|
|
parts.append(
|
|
"You already proposed the message below and the user wants a "
|
|
"different one. Write a NEW message with different wording (and, if "
|
|
"reasonable, a different emphasis or scope framing) — do not repeat "
|
|
f"it:\n{avoid}"
|
|
)
|
|
|
|
return _COMMIT_INSTRUCTIONS, "\n\n".join(parts)
|
|
|
|
|
|
# Registry of named templates. Add an entry here to give a new surface a
|
|
# consistent, reusable prompt without teaching every caller the prompt text.
|
|
PROMPT_TEMPLATES: Dict[str, PromptTemplate] = {
|
|
"commit_message": _commit_message_template,
|
|
}
|
|
|
|
|
|
def render_template(name: str, variables: Optional[Dict[str, Any]] = None) -> Tuple[str, str]:
|
|
"""Resolve a registered template into (instructions, user_input).
|
|
|
|
Raises KeyError if the template name is unknown so callers fail loudly
|
|
instead of silently sending an empty prompt.
|
|
"""
|
|
template = PROMPT_TEMPLATES.get(name)
|
|
if template is None:
|
|
raise KeyError(f"unknown one-shot template: {name}")
|
|
return template(variables or {})
|
|
|
|
|
|
def run_oneshot(
|
|
*,
|
|
instructions: str = "",
|
|
user_input: str = "",
|
|
template: Optional[str] = None,
|
|
variables: Optional[Dict[str, Any]] = None,
|
|
task: str = "title_generation",
|
|
max_tokens: int = 1024,
|
|
temperature: Optional[float] = 0.3,
|
|
timeout: float = 60.0,
|
|
main_runtime: Optional[Dict[str, Any]] = None,
|
|
) -> str:
|
|
"""Run a single stateless LLM request and return its text.
|
|
|
|
Provide either a registered ``template`` (+ ``variables``) or an explicit
|
|
``instructions`` / ``user_input`` pair. Returns the model's text answer,
|
|
stripped of surrounding whitespace and any wrapping code fence.
|
|
|
|
Raises RuntimeError when no LLM provider is configured (surfaced from
|
|
:func:`call_llm`) and KeyError for an unknown template name.
|
|
"""
|
|
if template:
|
|
instructions, user_input = render_template(template, variables)
|
|
|
|
if not (instructions or "").strip() and not (user_input or "").strip():
|
|
raise ValueError("run_oneshot requires a template or instructions/user_input")
|
|
|
|
messages = []
|
|
if (instructions or "").strip():
|
|
messages.append({"role": "system", "content": instructions})
|
|
messages.append({"role": "user", "content": user_input or ""})
|
|
|
|
response = call_llm(
|
|
task=task,
|
|
messages=messages,
|
|
max_tokens=max_tokens,
|
|
temperature=temperature,
|
|
timeout=timeout,
|
|
main_runtime=main_runtime,
|
|
)
|
|
|
|
text = (extract_content_or_reasoning(response) or "").strip()
|
|
return _strip_code_fence(text)
|
|
|
|
|
|
def _strip_code_fence(text: str) -> str:
|
|
"""Drop a single wrapping ``` fence the model may have added."""
|
|
if not text.startswith("```"):
|
|
return text
|
|
lines = text.splitlines()
|
|
if len(lines) >= 2 and lines[0].startswith("```") and lines[-1].strip() == "```":
|
|
return "\n".join(lines[1:-1]).strip()
|
|
return text
|