- harness/es.py: append-only event store + проекции (образец — кооп-портал) - harness/levels.py: уровни 1с/1м/1ч/1д, контекст ∝ масштабу - harness/scheduler.py: уровневые потоки, идемпотентные тики, absorbed-стабы - harness/isolated_runner.py + _llm_worker.py: LLM-тик в сабпроцессе (глобальный asyncio-рантайм Strands не переживает 2+ create_harness) - cli.py: init/task/tasks/status/events + requeue (написан самим минутным агентом) - 9router: отдельный ключ sk-simbiot, модель auto (glm-5.3 первым)
153 lines
5.1 KiB
Python
153 lines
5.1 KiB
Python
"""Terminal transcript output for bidirectional streaming."""
|
|
|
|
import logging
|
|
from dataclasses import dataclass
|
|
from typing import TYPE_CHECKING
|
|
|
|
from rich.console import Console, ConsoleOptions, ConsoleRenderable, Group, RenderableType, RenderResult
|
|
from rich.live import Live
|
|
from rich.segment import ControlType, Segment
|
|
from rich.style import Style
|
|
from rich.text import Text
|
|
|
|
from ..types.events import (
|
|
BidiOutputEvent,
|
|
BidiTranscriptDeltaEvent,
|
|
BidiTranscriptStartEvent,
|
|
BidiTranscriptStopEvent,
|
|
Role,
|
|
)
|
|
from ..types.io import OutputStream
|
|
|
|
if TYPE_CHECKING:
|
|
from ..agent.agent import BidiAgent
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class _UserText(ConsoleRenderable):
|
|
"""Render a full-width user block without adding copyable trailing spaces."""
|
|
|
|
BACKGROUND_COLOR = "#f3f3f3"
|
|
PLACEHOLDER = "Start talking ..."
|
|
PLACEHOLDER_COLOR = "#a0a0a0"
|
|
TEXT_COLOR = "#373737"
|
|
TEXT_PREFIX = "> "
|
|
|
|
def __init__(self, text: str | None = None) -> None:
|
|
"""Initialize a user transcript block."""
|
|
self._text = text
|
|
|
|
def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
|
|
"""Render wrapped text and color each row through the terminal edge."""
|
|
text = self.PLACEHOLDER if self._text is None else self._text
|
|
color = self.PLACEHOLDER_COLOR if self._text is None else self.TEXT_COLOR
|
|
style = Style(color=color, bgcolor=self.BACKGROUND_COLOR)
|
|
erase_to_end = Segment(
|
|
"\x1b[0K",
|
|
style,
|
|
((ControlType.ERASE_IN_LINE, 0),),
|
|
)
|
|
|
|
yield erase_to_end
|
|
yield Segment.line()
|
|
|
|
lines = Text(text, style=style).wrap(
|
|
console,
|
|
max(options.max_width - len(self.TEXT_PREFIX), 1),
|
|
overflow="fold",
|
|
)
|
|
for index, line in enumerate(lines or [Text("", style=style)]):
|
|
prefix = self.TEXT_PREFIX if index == 0 else " " * len(self.TEXT_PREFIX)
|
|
row = Text(f"{prefix}{line.plain}", style=style, end="")
|
|
yield row
|
|
yield erase_to_end
|
|
yield Segment.line()
|
|
|
|
yield erase_to_end
|
|
yield Segment.line()
|
|
|
|
|
|
@dataclass
|
|
class _Transcript:
|
|
"""Text and completion state for one transcript."""
|
|
|
|
role: Role
|
|
text: str = ""
|
|
complete: bool = False
|
|
|
|
def __rich__(self) -> RenderableType:
|
|
"""Render the transcript using its speaker's style."""
|
|
text = " ".join(self.text.split())
|
|
if self.role == "user":
|
|
return _UserText(text)
|
|
return Text(f"\n{text}\n")
|
|
|
|
|
|
class _TranscriptOutputStream(OutputStream):
|
|
"""Render transcript events to a terminal stream."""
|
|
|
|
def __init__(self) -> None:
|
|
"""Initialize transcript output."""
|
|
self._console = Console()
|
|
self._live: Live
|
|
self._transcripts: dict[str, _Transcript] = {}
|
|
|
|
async def start(self, _agent: "BidiAgent") -> None:
|
|
"""Start transcript output."""
|
|
self._live = Live(
|
|
self,
|
|
console=self._console,
|
|
auto_refresh=False,
|
|
transient=True,
|
|
redirect_stdout=False,
|
|
redirect_stderr=False,
|
|
)
|
|
self._live.start(refresh=True)
|
|
|
|
async def stop(self) -> None:
|
|
"""Print remaining transcripts and restore the terminal cursor."""
|
|
try:
|
|
remaining = list(self._transcripts.values())
|
|
self._transcripts.clear()
|
|
if hasattr(self, "_live"):
|
|
self._live.stop()
|
|
if remaining:
|
|
self._console.print(Group(*remaining))
|
|
finally:
|
|
self._console.show_cursor()
|
|
|
|
async def __call__(self, event: BidiOutputEvent) -> None:
|
|
"""Update the transcript identified by its start, delta, or stop event."""
|
|
if isinstance(event, BidiTranscriptStartEvent):
|
|
self._transcripts[event.content_id] = _Transcript(event.role)
|
|
elif isinstance(event, BidiTranscriptDeltaEvent):
|
|
self._transcripts[event.content_id].text += event.delta
|
|
elif isinstance(event, BidiTranscriptStopEvent):
|
|
self._transcripts[event.content_id].complete = True
|
|
else:
|
|
return
|
|
|
|
logger.debug("content_id=<%s>, event_type=<%s> | transcript received", event.content_id, event["type"])
|
|
self._refresh()
|
|
|
|
def _refresh(self) -> None:
|
|
"""Print completed entries in order and redraw the remaining transcripts."""
|
|
completed = []
|
|
while self._transcripts:
|
|
content_id = next(iter(self._transcripts))
|
|
if not self._transcripts[content_id].complete:
|
|
break
|
|
# Later entries stay live until every preceding transcript has finished.
|
|
completed.append(self._transcripts.pop(content_id))
|
|
|
|
if completed:
|
|
self._console.print(Group(*completed))
|
|
else:
|
|
self._live.refresh()
|
|
|
|
def __rich__(self) -> RenderableType:
|
|
"""Group the live transcripts, or prompt for speech when none remain."""
|
|
if self._transcripts:
|
|
return Group(*self._transcripts.values())
|
|
return _UserText()
|