Files
hermes-agent/agent/provider_projection.py
T
Alexander Prendota 07200e9cd6 feat(agent): fold an agent-as-provider's own tool work back into the turn
Most providers are models: they ask Hermes to run a tool and Hermes runs it,
so the transcript and the loop's counters see every tool iteration. Some
providers are agents — an ACP CLI behind a client shim, or the codex
app-server, which already takes an analogous path in `agent/codex_runtime.py`.
They execute their own read/edit/execute tools inside their own session, and
by the time Hermes sees the response that work is done.

Those calls must never come back as pending `tool_calls` — Hermes would
re-run finished work. But summarising them into `reasoning` blinds two
subsystems:

- the self-improvement loop, which distils memories and skills by replaying
  `messages`; a one-line activity feed teaches it nothing;
- the skill-review nudge, whose `_iters_since_skill` counter only moves on
  Hermes tool iterations, of which there are none.

So a client may hand both back on the completion object —
`hermes_projected_messages` (completed assistant(tool_calls) + tool(result)
rows) and `hermes_provider_tool_iterations` — and
`splice_provider_projection` applies them. Rows go through `append_message`
like every other live-transcript append, so they carry a timestamp and
persist the same way the codex projection path's rows do.

The splice is append-only, sits before this turn's assistant message so the
order reads call -> result -> answer, and is a no-op for every client that
sets neither attribute, i.e. every ordinary OpenAI-compatible provider.
Garbage attribute values are tolerated rather than allowed to break the turn.
2026-08-26 10:10:11 -07:00

71 lines
2.8 KiB
Python

"""Fold an agent-as-provider's own activity back into Hermes' turn state.
Most providers are models: they ask Hermes to run a tool and Hermes runs it, so
the transcript and the loop's counters see every tool iteration. Some providers
are *agents* — an ACP CLI reached through a client shim, or the codex
app-server, which takes an analogous path in ``agent/codex_runtime.py``. They
execute their own read/edit/execute tools inside their own session, and by the
time Hermes sees the response that work is already done.
Those calls must never come back as pending ``tool_calls`` — Hermes would re-run
finished work. But two subsystems go blind if they are merely summarised into
the ``reasoning`` field:
* the **self-improvement loop**, which distils memories and skills by replaying
``messages`` — a one-line activity feed teaches it nothing;
* the **skill-review nudge**, whose counter (``_iters_since_skill``) only moves
on Hermes tool iterations, of which there are none.
So the provider client hands both back on the completion object and this helper
applies them: ``hermes_projected_messages`` (already-completed
``assistant(tool_calls=[…])`` + ``tool(result)`` history rows) and
``hermes_provider_tool_iterations`` (how many tool iterations happened inside
the provider). Clients that set neither are unaffected, which is every ordinary
OpenAI-compatible provider.
The splice is append-only and rows go through ``append_message`` like every
other live-transcript append, so they carry a timestamp and persist the same way
the codex projection path's rows do.
"""
from __future__ import annotations
import logging
from typing import Any
from agent.message_metadata import append_message
logger = logging.getLogger(__name__)
__all__ = ["splice_provider_projection"]
def splice_provider_projection(
agent: Any, response: Any, messages: list[dict[str, Any]]
) -> int:
"""Append the provider's projected history rows and tick the nudge counter.
Returns the number of rows spliced. Tolerates absent/garbage attributes so a
third-party OpenAI-compatible client can't break the turn.
"""
projected = getattr(response, "hermes_projected_messages", None)
rows = [m for m in projected if isinstance(m, dict)] if isinstance(projected, list) else []
for row in rows:
append_message(messages, row)
if rows:
logger.debug(
"spliced %d provider-projected transcript row(s) from %s",
len(rows),
getattr(agent, "provider", "?"),
)
raw_iters = getattr(response, "hermes_provider_tool_iterations", 0)
try:
iterations = int(raw_iters or 0)
except (TypeError, ValueError):
iterations = 0
if iterations > 0:
agent._iters_since_skill = getattr(agent, "_iters_since_skill", 0) + iterations
return len(rows)