"""AgentEvent - the typed event stream one agent turn produces (R04-T02). Today the turn engine talks to its caller through untyped dicts:: emit({"type": "tool_result", "id": tc_id, "name": name, "ok": result.get("ok", False), "output": result.get("output", "")}) and every consumer re-discovers the vocabulary by reading the producer. There are eleven such shapes across ``core/chat_agent.py``, ``core/code_agent.py`` and ``core/task_executors.py``; a consumer that misspells ``"tool_result"`` or reads ``"result"`` instead of ``"output"`` fails silently, at runtime, only for the tool path that triggers it. This module makes the vocabulary explicit. Each event is a frozen dataclass, so: * the set of possible events is enumerable (see :data:`EVENT_TYPES`); * a field name typo is an ``AttributeError`` at the point of use, not a silently missing chat bubble; * an event can cross a thread boundary safely - it cannot be mutated after the producer hands it over, which is exactly what the Qt-signal seam needs. Bridging with the legacy dicts is deliberate and two-way: :func:`event_from_dict` adapts what ``run_cowork`` emits today, and :meth:`AgentEvent.to_dict` renders an event back into the legacy shape so existing widgets keep working untouched while the presentation layer migrates screen by screen (EPIC R08). Pure domain code: stdlib only, no Qt, no I/O. """ from __future__ import annotations from dataclasses import dataclass, field from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple @dataclass(frozen=True) class AgentEvent: """Base class for everything a turn can report. ``type`` is the legacy string tag, kept as a class attribute so the bridge functions can round-trip an event without a separate mapping table. """ type: str = field(init=False, default="event") def to_dict(self) -> Dict[str, Any]: """Render into the legacy ``emit()`` dict shape.""" return {"type": self.type} # --------------------------------------------------------------------------- # # Assistant output # --------------------------------------------------------------------------- # @dataclass(frozen=True) class TextChunkEvent(AgentEvent): """One fragment of the visible answer, as it streams in.""" delta: str type: str = field(init=False, default="text") def to_dict(self) -> Dict[str, Any]: return {"type": self.type, "delta": self.delta} @dataclass(frozen=True) class ReasoningChunkEvent(AgentEvent): """One fragment of the model's PRIVATE reasoning. Drives the "Thinking" indicator only. Consumers must never append this to the answer or persist it into conversation history - keeping it a distinct type is what makes that mistake hard to make by accident. """ delta: str type: str = field(init=False, default="reasoning") def to_dict(self) -> Dict[str, Any]: return {"type": self.type, "delta": self.delta} @dataclass(frozen=True) class AssistantDoneEvent(AgentEvent): """One assistant message finished. A turn with tool calls emits this once per step, not once per turn - see :class:`TurnCompletedEvent`.""" content: str = "" type: str = field(init=False, default="assistant_done") def to_dict(self) -> Dict[str, Any]: return {"type": self.type, "content": self.content} # --------------------------------------------------------------------------- # # Planning # --------------------------------------------------------------------------- # @dataclass(frozen=True) class PlanUpdatedEvent(AgentEvent): """The agent rewrote its plan (the ``update_plan`` tool).""" steps: Tuple[Dict[str, Any], ...] = () type: str = field(init=False, default="plan_set") def to_dict(self) -> Dict[str, Any]: return {"type": self.type, "steps": [dict(s) for s in self.steps]} # --------------------------------------------------------------------------- # # Tool lifecycle # --------------------------------------------------------------------------- # @dataclass(frozen=True) class ToolCallStartedEvent(AgentEvent): """A tool call is about to run, with the preview shown to the user. Maps the legacy ``tool_proposed`` event. "Proposed" was a misnomer: by the time it is emitted the call is already going to run unless a permission gate rejects it, and the gate reports that as a finished call with ``ok=False``. """ call_id: str name: str args: Dict[str, Any] = field(default_factory=dict) preview: Optional[Dict[str, Any]] = None type: str = field(init=False, default="tool_proposed") def to_dict(self) -> Dict[str, Any]: out: Dict[str, Any] = {"type": self.type, "id": self.call_id, "name": self.name, "args": dict(self.args)} if self.preview is not None: out["preview"] = dict(self.preview) return out @dataclass(frozen=True) class ToolOutputEvent(AgentEvent): """A line of live output from a running tool (command stdout, for example).""" call_id: str name: str delta: str type: str = field(init=False, default="tool_output") def to_dict(self) -> Dict[str, Any]: return {"type": self.type, "id": self.call_id, "name": self.name, "delta": self.delta} @dataclass(frozen=True) class ToolCallFinishedEvent(AgentEvent): """A tool call ended, successfully or not. ``ok=False`` covers every failure mode alike - the tool raised, the sandbox blocked it, or the user rejected it at the permission gate - because the consumer's job is the same in all three: show the failure and let the model react to it. """ call_id: str name: str ok: bool = False output: str = "" path: str = "" # file the tool wrote, when it wrote one produced: Tuple[str, ...] = () # extra artefacts (e.g. a generator's outputs) type: str = field(init=False, default="tool_result") def to_dict(self) -> Dict[str, Any]: out: Dict[str, Any] = {"type": self.type, "id": self.call_id, "name": self.name, "ok": self.ok, "output": self.output} if self.path: out["path"] = self.path if self.produced: out["produced"] = list(self.produced) return out # --------------------------------------------------------------------------- # # Output folder # --------------------------------------------------------------------------- # @dataclass(frozen=True) class OutputsAddedEvent(AgentEvent): """Files appeared in the turn's output folder.""" paths: Tuple[str, ...] = () type: str = field(init=False, default="outputs_added") def to_dict(self) -> Dict[str, Any]: return {"type": self.type, "paths": list(self.paths)} @dataclass(frozen=True) class OutputsRemovedEvent(AgentEvent): """Files were cleaned up from the turn's output folder (intermediates).""" paths: Tuple[str, ...] = () type: str = field(init=False, default="outputs_removed") def to_dict(self) -> Dict[str, Any]: return {"type": self.type, "paths": list(self.paths)} @dataclass(frozen=True) class NoticeEvent(AgentEvent): """A UI-visible aside that is not part of the model's answer. Three producers today, all reachable from a normal turn: ``core/agent_security.py`` (a request or command blocked by the security layer), ``core/context_budget.py`` (the conversation was auto-compressed) and the attachment readers (a file that could not be processed, plus live "reading page X/Y" progress). ``level`` selects how the UI renders it: ``"progress"`` updates the thinking indicator in place, anything else becomes a warning bubble. Dropping these would silently hide security warnings from the user, which is why the type exists rather than being folded into TextChunkEvent. """ text: str level: str = "info" type: str = field(init=False, default="notice") def to_dict(self) -> Dict[str, Any]: return {"type": self.type, "level": self.level, "text": self.text} @dataclass(frozen=True) class HistoryReadyEvent(AgentEvent): """A history session exists for this run and can be opened.""" session_id: str type: str = field(init=False, default="history_ready") def to_dict(self) -> Dict[str, Any]: return {"type": self.type, "session_id": self.session_id} # --------------------------------------------------------------------------- # # Turn lifecycle - emitted by the application service, not by the legacy engine # --------------------------------------------------------------------------- # @dataclass(frozen=True) class TurnCompletedEvent(AgentEvent): """The whole turn finished: no more events will follow. New in R04. The legacy engine has no end-of-turn signal at all, so every consumer infers "done" from the worker thread finishing - which is why a cancelled turn and a failed turn look identical to the UI today. """ content: str = "" cancelled: bool = False type: str = field(init=False, default="turn_completed") def to_dict(self) -> Dict[str, Any]: return {"type": self.type, "content": self.content, "cancelled": self.cancelled} @dataclass(frozen=True) class ErrorEvent(AgentEvent): """The turn failed. ``recoverable`` marks errors the user can act on (pick another model, shorten the prompt) rather than a hard outage.""" message: str recoverable: bool = False type: str = field(init=False, default="error") def to_dict(self) -> Dict[str, Any]: return {"type": self.type, "message": self.message, "recoverable": self.recoverable} # The legacy tag -> event class map. Also the authoritative list of what a turn # can emit, which is what makes an exhaustive consumer possible for the first time. EVENT_TYPES: Dict[str, type] = { "text": TextChunkEvent, "reasoning": ReasoningChunkEvent, "assistant_done": AssistantDoneEvent, "plan_set": PlanUpdatedEvent, "tool_proposed": ToolCallStartedEvent, "tool_start": ToolCallStartedEvent, "tool_output": ToolOutputEvent, "tool_result": ToolCallFinishedEvent, "outputs_added": OutputsAddedEvent, "outputs_removed": OutputsRemovedEvent, "notice": NoticeEvent, "history_ready": HistoryReadyEvent, "turn_completed": TurnCompletedEvent, "error": ErrorEvent, } def event_from_dict(payload: Mapping[str, Any]) -> Optional[AgentEvent]: """Adapt one legacy ``emit()`` dict into a typed event. Returns ``None`` for an unknown tag instead of raising: the legacy engine is still being refactored and may grow an event before this module knows about it. Dropping an unrecognised event degrades the UI by one missing bubble; raising here would abort a turn that had otherwise succeeded. """ kind = str(payload.get("type", "")) cls = EVENT_TYPES.get(kind) if cls is None: return None if cls is TextChunkEvent or cls is ReasoningChunkEvent: return cls(delta=str(payload.get("delta", ""))) if cls is AssistantDoneEvent: return AssistantDoneEvent(content=str(payload.get("content", ""))) if cls is PlanUpdatedEvent: return PlanUpdatedEvent(steps=tuple(payload.get("steps") or ())) if cls is ToolCallStartedEvent: return ToolCallStartedEvent( call_id=str(payload.get("id", "")), name=str(payload.get("name", "")), args=dict(payload.get("args") or {}), preview=payload.get("preview"), ) if cls is ToolOutputEvent: return ToolOutputEvent(call_id=str(payload.get("id", "")), name=str(payload.get("name", "")), delta=str(payload.get("delta", ""))) if cls is ToolCallFinishedEvent: return ToolCallFinishedEvent( call_id=str(payload.get("id", "")), name=str(payload.get("name", "")), ok=bool(payload.get("ok", False)), output=str(payload.get("output", "")), path=str(payload.get("path", "") or ""), produced=tuple(payload.get("produced") or ()), ) if cls is OutputsAddedEvent or cls is OutputsRemovedEvent: return cls(paths=tuple(str(p) for p in (payload.get("paths") or ()))) if cls is NoticeEvent: return NoticeEvent(text=str(payload.get("text", "")), level=str(payload.get("level", "info"))) if cls is HistoryReadyEvent: return HistoryReadyEvent(session_id=str(payload.get("session_id", ""))) if cls is TurnCompletedEvent: return TurnCompletedEvent(content=str(payload.get("content", "")), cancelled=bool(payload.get("cancelled", False))) return ErrorEvent(message=str(payload.get("message", "")), recoverable=bool(payload.get("recoverable", False))) def collect_text(events: Sequence[AgentEvent]) -> str: """Join every :class:`TextChunkEvent` - the visible answer, reasoning excluded. Provided here so no consumer has to re-derive "which events are the answer", the question the untyped dicts made easy to get wrong. """ return "".join(e.delta for e in events if isinstance(e, TextChunkEvent)) def tool_calls(events: Sequence[AgentEvent]) -> List[ToolCallFinishedEvent]: """Every finished tool call, in order - for audit views and assertions.""" return [e for e in events if isinstance(e, ToolCallFinishedEvent)] __all__ = [ "AgentEvent", "TextChunkEvent", "ReasoningChunkEvent", "AssistantDoneEvent", "PlanUpdatedEvent", "ToolCallStartedEvent", "ToolOutputEvent", "ToolCallFinishedEvent", "OutputsAddedEvent", "OutputsRemovedEvent", "NoticeEvent", "HistoryReadyEvent", "TurnCompletedEvent", "ErrorEvent", "EVENT_TYPES", "event_from_dict", "collect_text", "tool_calls", ]