Files
hermes-agent/agent/turn_scripted_prelude.py
Teknium a274d3388f fix: core modules import again on Python 3.11-3.13 (re-quote eager forward references)
#135348 ran ruff's UP037 autofix with ruff.strict.toml's target-version = "py314". On 3.14
annotations are lazy (PEP 649), so stripping quotes is safe there; on 3.11-3.13 a function or
variable annotation is evaluated when the def or statement runs. Every unquoted annotation that
names the enclosing class (`def clone_for_agent(self) -> ContextEngine`), a class bound later in
the module, or a TYPE_CHECKING-only import now raises NameError on import: 300 modules on main
(gateway.config, run_agent, hermes_state, cron.jobs, the Telegram adapter, ...) fail to import
under 3.11, the update path crashes mid-`hermes update` on any install whose interpreter is
older than 3.14, and the e2e-upgrade suite's retry leg fails with
`NameError: name 'ContextEngine' is not defined`.

- Re-quote exactly the 79 annotations (36 files) that name something unbound when they are
  evaluated (including tui_gateway/server.py's `_TurnScopes`, which only reaches that module's
  globals through register() after import); no other text changes.
- agent/turn_scripted_prelude.py: `Generator[...]` gets its third argument back. d76cce4f11
  applied UP043 (default type args, valid only from 3.13) to a module-level alias, so
  `Generator[Y, S]` raises TypeError on 3.11/3.12 and takes gateway.run, agent.conversation_loop
  and tui_gateway.{server,ws,entry} down with it.
- ruff.strict.toml target-version = "py311", the requires-python floor. With it UP037 leaves
  these quotes alone and only strips the two that are genuinely redundant, which this commit
  strips; any future version-gated fix stays valid on every supported Python.
- tests/ci/test_strict_ruff_target.py pins the target to the requires-python floor (red on main).
2026-10-09 14:25:07 -07:00

60 lines
2.9 KiB
Python

"""Scripted prelude: tool calls a prompt built-in plays before the turn's first model call.
A prelude is a generator owned by the built-in (``agent.initiate_setup_prompt.initiate_setup_prelude``
is the one today). Each step it yields ``(content, tool_name, args)``: the assistant text and the one
tool call that row carries. The loop runs it through the unchanged ``run_tool_round`` (row persisted
before the tool runs, interim text and tool events emitted, tool hooks fired) and sends the tool's
result string back into the generator, so a later step can use an earlier answer.
It runs before any provider call, so the rows sit between this turn's user message and the first
model call: nothing cached is mutated, the system prompt is untouched, no user row is added, and
roles alternate ``user -> assistant(tool_calls) -> tool -> ... -> assistant(model)``. No API call is
counted and no ``pre/post_api_request`` hook fires for a step.
"""
from __future__ import annotations
import json
from typing import Any, Generator, Optional, Tuple
from agent.message_sanitization import deterministic_call_id
from agent.transports.types import NormalizedResponse, build_tool_call
from agent.turn_tool_round import run_tool_round
Prelude = Generator[tuple[str, str, dict], Optional[str], None]
def run_scripted_prelude(agent: Any, s: Any, prelude: Prelude) -> Any:
"""Play ``prelude`` into the loop state ``s``; returns the last tool round's verdict, or None when
no step ran. The caller ends the turn on ``return`` and skips the model on ``break``."""
from agent.conversation_loop import _run_phase
verdict, result = None, None
while not agent._interrupt_requested:
try:
content, name, args = prelude.send(result)
except StopIteration:
break
s.finish_reason = "tool_calls"
s.assistant_message = NormalizedResponse(
content=content, finish_reason="tool_calls",
# Seeded by the card and its place in history: unique within the session, byte-stable on replay.
tool_calls=[build_tool_call(deterministic_call_id(name, json.dumps(args, sort_keys=True), len(s.messages)),
name, args)])
verdict = _run_phase(run_tool_round, agent, s)
if verdict.action != "continue":
break
tail = s.messages[-1] if s.messages else {}
result = tail.get("content") if tail.get("role") == "tool" else None
prelude.close()
return verdict
def play_prelude(agent: Any, s: Any, prelude: Optional[Prelude]) -> tuple[str, Any]:
"""``("return", result)`` ends the turn now, ``("break", None)`` skips the model call, ``("run", None)`` runs
the loop as usual (also when there is no prelude)."""
verdict = run_scripted_prelude(agent, s, prelude) if prelude is not None else None
if verdict is not None and verdict.action in ("return", "break"):
return verdict.action, verdict.result
return "run", None