mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-22 16:25:58 +00:00
A cron job ("Daily Buzz Report") died with 'AIAgent' object has no
attribute '_claim_stream_writer'. The #65991 single-writer fence lives on
AIAgent (run_agent.py), but the streaming paths that use it live in other
modules — chat_completion_helpers (chat / anthropic / bedrock) and
codex_runtime (codex responses) — and called it directly as
agent._claim_stream_writer() / agent._stream_writer_is_current(). That makes
those modules hard-depend on the method being present on whatever object is
passed as agent.
The fence is an *additive* safety net that may only ever drop a provably
superseded stream, never the sole legitimate writer. But the direct calls
turned any agent that doesn't expose it — a version-skewed checkout (the
streaming helper module newer than run_agent), a hot-reloaded gateway mid
git-pull, a duck-typed agent, or a test double — into a fatal AttributeError
that aborts the whole turn (and, on cron, fails the job).
Route every cross-module claim/check through agent/stream_single_writer.py.
claim_stream_writer(agent) returns 0 when the fence is unavailable (or
raises), and stream_writer_is_current(agent, token) treats a 0 token or an
absent guard as "current" — so a guard-less agent degrades to "no fence"
instead of crashing, while a real AIAgent keeps the full single-writer
protection. Internal self.* uses inside run_agent are unchanged (self is
always a full AIAgent there).
70 lines
2.8 KiB
Python
70 lines
2.8 KiB
Python
"""Best-effort accessors for the single-writer stream fence (#65991).
|
|
|
|
The fence itself lives on ``AIAgent`` (``_claim_stream_writer`` /
|
|
``_stream_writer_is_current`` in ``run_agent.py``), but the streaming code paths
|
|
that use it live in *other* modules — ``chat_completion_helpers`` (chat /
|
|
anthropic / bedrock) and ``codex_runtime`` (codex responses). Calling the fence
|
|
directly as ``agent._claim_stream_writer()`` from those modules makes them
|
|
hard-depend on the method being present on whatever object is passed in as
|
|
``agent``.
|
|
|
|
That coupling is a latent crash: a partially-updated checkout (the streaming
|
|
helper module newer than ``run_agent``), a hot-reloaded gateway, a duck-typed
|
|
agent, or a test double without the method turns an *additive* safety net into a
|
|
fatal ``AttributeError`` that aborts the whole turn. A cron job died exactly
|
|
this way with ``'AIAgent' object has no attribute '_claim_stream_writer'``.
|
|
|
|
The fence is only ever allowed to drop a *provably* superseded stream — never
|
|
the sole legitimate writer. So when the guard is unavailable (or raises), the
|
|
correct degradation is "no fence": keep streaming. These helpers make the
|
|
claim/check best-effort to guarantee that.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import Any
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def claim_stream_writer(agent: Any) -> int:
|
|
"""Claim the delta sink for the calling stream attempt, best-effort.
|
|
|
|
Returns the agent's monotonic writer token when the fence is available, or
|
|
``0`` when the agent doesn't expose it (or the claim raised). A ``0`` token
|
|
pairs with :func:`stream_writer_is_current` always returning ``True``, so a
|
|
guard-less agent is simply never fenced instead of crashing the turn.
|
|
"""
|
|
claim = getattr(agent, "_claim_stream_writer", None)
|
|
if callable(claim):
|
|
try:
|
|
return int(claim())
|
|
except Exception:
|
|
logger.debug(
|
|
"stream single-writer: claim failed; proceeding unfenced",
|
|
exc_info=True,
|
|
)
|
|
return 0
|
|
|
|
|
|
def stream_writer_is_current(agent: Any, token: int) -> bool:
|
|
"""True when ``token`` is still the active writer, best-effort.
|
|
|
|
A falsy token (from a claim that no-oped) or an agent without the fence
|
|
means we cannot prove supersession, so the stream is treated as current and
|
|
never fenced. This preserves the single-writer invariant's one-way promise:
|
|
only a demonstrably stale writer is ever stopped.
|
|
"""
|
|
if not token:
|
|
return True
|
|
is_current = getattr(agent, "_stream_writer_is_current", None)
|
|
if callable(is_current):
|
|
try:
|
|
return bool(is_current(token))
|
|
except Exception:
|
|
logger.debug(
|
|
"stream single-writer: is_current check failed; treating as current",
|
|
exc_info=True,
|
|
)
|
|
return True
|