Source code for ralph.agents.parsers.agy

"""Parser for the AGY v1.0.8 --print wire format.

Source of truth: ``ralph-workflow/tmp/agy-source-of-truth.txt``.

AGY --print mode emits plain-text model responses on stdout, one line at a
time. The parser classifies every plain-text line as
``AgentOutputLine(type='text')`` (NOT ``type='raw'``) so the smoke report's
"Observed output:" section renders model content via ``_render_text_line``
(in ``ralph.pipeline.activity_stream``) instead of the literal ``raw`` type
label via ``_render_metadata_event_line``.

The parser inherits from :class:`NdjsonParserBase`, which owns the 6 shared
NDJSON behaviours: ``data:`` SSE prefix strip, ``[DONE]`` short-circuit,
non-dict-JSON-to-raw fallback, lifecycle-event suppression, error
extraction, and JSON-dict dispatch. AGY v1.0.8 --print mode does NOT emit
JSON lifecycle or error events; the inherited behaviour is preserved as a
safe default for any future AGY --json flag.

The ``[plain] tool: NAME`` convention from :class:`GenericParser` is
intentionally NOT classified as ``tool_use`` here. That convention is a
GenericParser convention, not an AGY wire-format fact documented in the
source of truth. AGY tool activity is reported via the persisted
``smoke_test_result`` artifact (see ``smoke_plumbing._agy_tool_activity_seen``).

Behaviour specifics:

  * A single plain-text line is buffered, then emitted at iterator
    exhaustion (or at the next paragraph-boundary flush) as a single
    ``text`` event. This coalesces consecutive short lines into one
    coherent text block matching the GenericParser coalescing semantics.
  * The ``Task declared complete:`` marker is treated as plain text (not
    a structured completion signal). The smoke detector at
    ``smoke_plumbing._explicit_completion_seen`` scans the raw transcript
    for the substring, so the marker must pass through as a regular
    ``text`` event rather than being filtered as ``raw``.
  * Empty input (the documented quota-exhausted failure mode in
    ``agy-source-of-truth.txt``) yields zero events, allowing the smoke
    plumbing to surface the empty-stdout diagnostic for the live case.
"""

from __future__ import annotations

from typing import TYPE_CHECKING

from ralph.display.vt_normalizer import normalize_vt_text

from ._ndjson_base import NdjsonParserBase
from .text_accumulator import TextAccumulator

if TYPE_CHECKING:
    from collections.abc import Iterator

    from ralph.agents.idle_watchdog import SubagentPidRegistry

    from .agent_output_line import AgentOutputLine


__all__ = ["AgyParser"]


[docs] class AgyParser(NdjsonParserBase): """Plain-text parser for AGY v1.0.8 --print output. Inherits the NDJSON state machine from :class:`NdjsonParserBase` (SSE strip, ``[DONE]`` short-circuit, lifecycle suppression, error extraction, JSON-dict dispatch, non-dict-JSON-to-raw). Overrides :meth:`_classify_non_json_line` so the AGY --print plain-text stream is classified as ``type='text'`` and coalesced via :class:`TextAccumulator` into coherent blocks. """ def __init__( self, subagent_pid_registry: SubagentPidRegistry | None = None, subagent_source_label: str | None = None, ) -> None: super().__init__() # R5: bind the per-invocation shared SubagentPidRegistry + per-transport # source label. AGY's --print plain-text stream does not currently # carry embedded PIDs; this is forward-compat for the # per-transport SubagentPidSource seam. self._subagent_pid_registry: SubagentPidRegistry | None = subagent_pid_registry self._subagent_source_label: str | None = subagent_source_label self._text_accumulator: TextAccumulator | None = None self._has_prior_text_line: bool = False def _classify_non_json_line(self, stripped: str) -> Iterator[AgentOutputLine]: """Classify an AGY plain-text line as ``type='text'`` and coalesce. VT normalization is applied first so ANSI-decorated lines (e.g. from a TUI run piped without a PTY) are classified consistently. Consecutive non-blank lines are joined with a single ``\\n`` separator before being fed into the :class:`TextAccumulator` so the rendered text output preserves the line boundaries that the model emitted (the prior implementation concatenated lines without separators, producing merged output like ``I will create the todo list implementation.Using module.exports...`` that glued words from different lines together). """ normalized = normalize_vt_text(stripped).strip() if not normalized: return if self._text_accumulator is None: self._text_accumulator = TextAccumulator() chunk = f"\n{normalized}" if self._has_prior_text_line else normalized self._has_prior_text_line = True yield from self._text_accumulator.accumulate( chunk, stripped, kind="text", keep_current_when_empty=False, )
[docs] def flush_accumulators(self) -> Iterator[AgentOutputLine]: """Drain the text accumulator and yield the buffered text event.""" if self._text_accumulator is None: return acc = self._text_accumulator self._text_accumulator = None self._has_prior_text_line = False yield from acc.flush(kind="text")