feat(R04): add the immutable turn snapshot and typed agent event stream
R04-T01 — `domain/agents/conversation_execution_request.py`: a frozen
snapshot of everything one chat turn needs. Turn inputs previously lived in a
closure plus a 15-key ctx dict inside `ui/chat_panel.py::_start_turn`, and the
worker thread kept reading the widget back while it ran, so every later click
was visible to work already in flight. The request also owns the prompt
composition rules (instruction prefix separator, session notes, model-switch
review note) that were inline in that closure.
R04-T02 — `domain/agents/agent_event.py`: 13 frozen event types replacing the
untyped `{"type": ...}` dicts, whose only specification was the 130-line
if/elif chain in `_on_event`. Each event serialises back to the exact legacy
dict, so the presentation layer is untouched; `agent_event_codec.py` parses the
other way and is a temporary shim, isolated so R08 can delete it in one move.
`assistant_done` is deliberately NOT the end of a turn (it fires once per
provider call), so it maps to AssistantMessageCompletedEvent while the new
TurnCompletedEvent reports the turn itself.
R04-T03 (part) — `domain/agents/agent_result.py`: one named outcome for a
finished turn, replacing the message list / 3-tuple / reconstructed-from-side-
effects trio the three callers each read differently.
Verification: 66 tests. Beyond the unit tests,
`tests/integration/test_agent_event_bridge.py` runs the REAL `run_cowork` loop
offline and asserts every dict it emits is recognised and round-trips
byte-for-byte — a guard against an event type nobody modelled or a key whose
meaning silently drifted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,358 @@
|
||||
"""Typed events a turn emits while it runs (R04-T02).
|
||||
|
||||
The runtime currently speaks in bare dicts: ``emit({"type": "tool_result", "id":
|
||||
..., "ok": ...})``. Nothing declares which keys a given type carries, so the
|
||||
only specification is the 130-line ``if/elif`` chain in
|
||||
``ui/chat_panel.py::_on_event`` — and a typo in an emitter surfaces as a widget
|
||||
that silently renders nothing.
|
||||
|
||||
This module makes the vocabulary explicit. Each event is a frozen dataclass with
|
||||
real fields, and each one knows how to serialise itself back to the exact legacy
|
||||
dict the widget already reads (:meth:`AgentEvent.to_legacy_dict`), with
|
||||
:func:`from_legacy_dict` parsing the other way. That two-way bridge is what lets
|
||||
R04 introduce typed events WITHOUT touching the presentation layer — decomposing
|
||||
``_on_event`` into a renderer is R08-T01's job, and forcing both changes into one
|
||||
PR is exactly the "rewrite everything at once" the refactor plan forbids.
|
||||
|
||||
Scope note: this covers the interactive/scheduled **Cowork turn** vocabulary
|
||||
(the ``run_cowork`` path R04 unifies). Co4E's own node events (``node_status``,
|
||||
``stage_text``, ``run_done``) belong to ``Co4EWorkflowService`` in R07-T06 and
|
||||
are deliberately left as dicts here — :func:`from_legacy_dict` returns ``None``
|
||||
for them so a bridge can pass them straight through.
|
||||
|
||||
Layer rules (``docs/architecture/ADR-001-layered-architecture.md``): domain
|
||||
layer, standard library only. No PySide6, no ``core/*`` imports.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, ClassVar, Dict, Iterable, Optional, Tuple
|
||||
|
||||
# Notice levels. "progress" is special-cased by the UI (it retargets the live
|
||||
# thinking indicator instead of adding a bubble), so the vocabulary is pinned
|
||||
# here rather than left to each emitter's string literal.
|
||||
NOTICE_INFO = "info"
|
||||
NOTICE_WARNING = "warning"
|
||||
NOTICE_PROGRESS = "progress"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Value objects shared by several events.
|
||||
# --------------------------------------------------------------------------- #
|
||||
@dataclass(frozen=True)
|
||||
class ToolPreview:
|
||||
"""The human-readable preview of a proposed tool call.
|
||||
|
||||
Mirrors ``core/tools.py::describe_action``'s return shape exactly (three
|
||||
string keys, nothing else), so wrapping it in a type is lossless. ``kind``
|
||||
drives which bubble the UI renders: "diff" -> coloured before/after,
|
||||
"command" -> terminal block, "info" -> plain text.
|
||||
"""
|
||||
|
||||
kind: str = "info"
|
||||
title: str = ""
|
||||
text: str = ""
|
||||
|
||||
def to_dict(self) -> Dict[str, str]:
|
||||
return {"kind": self.kind, "title": self.title, "text": self.text}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, raw: Any) -> Optional["ToolPreview"]:
|
||||
"""Parse a legacy preview dict; ``None`` when there was none.
|
||||
|
||||
A non-dict value degrades to ``None`` rather than raising: a malformed
|
||||
preview must cost the user a nicer bubble, never the whole turn.
|
||||
"""
|
||||
if not isinstance(raw, dict) or not raw:
|
||||
return None
|
||||
return cls(kind=str(raw.get("kind", "info")), title=str(raw.get("title", "")),
|
||||
text=str(raw.get("text", "")))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PlanStep:
|
||||
"""One entry of the agent's ``update_plan`` checklist.
|
||||
|
||||
``status`` is kept a plain string on purpose: ``core/plan.py`` already owns
|
||||
validation (clamping anything unknown to "pending" against
|
||||
pending/running/done/error), and duplicating that vocabulary here would give
|
||||
the app two sources of truth to drift apart.
|
||||
"""
|
||||
|
||||
title: str
|
||||
status: str = "pending"
|
||||
|
||||
def to_dict(self) -> Dict[str, str]:
|
||||
return {"title": self.title, "status": self.status}
|
||||
|
||||
|
||||
def _as_str_tuple(values: Iterable[Any]) -> Tuple[str, ...]:
|
||||
"""Freeze an iterable of paths into a tuple of strings.
|
||||
|
||||
Emitters hand us live lists (``record["outputs"]``, ``_cleanup``'s result);
|
||||
copying decouples the event from later mutation of that list.
|
||||
"""
|
||||
return tuple(str(v) for v in (values or ()))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Base class.
|
||||
# --------------------------------------------------------------------------- #
|
||||
class AgentEvent:
|
||||
"""Base for every turn event.
|
||||
|
||||
Not a dataclass itself (it holds no data) — subclasses are the frozen
|
||||
dataclasses. ``EVENT_TYPE`` is the legacy wire name, which stays the single
|
||||
identifier shared between the typed world and the dict world.
|
||||
"""
|
||||
|
||||
EVENT_TYPE: ClassVar[str] = ""
|
||||
|
||||
def _payload(self) -> Dict[str, Any]:
|
||||
"""Type-specific keys of the legacy dict (without ``type``)."""
|
||||
return {}
|
||||
|
||||
def to_legacy_dict(self) -> Dict[str, Any]:
|
||||
"""The exact dict shape ``ui/chat_panel.py::_on_event`` dispatches on."""
|
||||
return {"type": self.EVENT_TYPE, **self._payload()}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Streaming events.
|
||||
# --------------------------------------------------------------------------- #
|
||||
@dataclass(frozen=True)
|
||||
class TextChunkEvent(AgentEvent):
|
||||
"""A fragment of the assistant's visible answer."""
|
||||
|
||||
EVENT_TYPE: ClassVar[str] = "text"
|
||||
delta: str = ""
|
||||
|
||||
def _payload(self) -> Dict[str, Any]:
|
||||
return {"delta": self.delta}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReasoningChunkEvent(AgentEvent):
|
||||
"""A fragment of a reasoning model's thinking, shown in a collapsed box."""
|
||||
|
||||
EVENT_TYPE: ClassVar[str] = "reasoning"
|
||||
delta: str = ""
|
||||
|
||||
def _payload(self) -> Dict[str, Any]:
|
||||
return {"delta": self.delta}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AssistantMessageCompletedEvent(AgentEvent):
|
||||
"""One assistant message finished streaming.
|
||||
|
||||
Emitted once per provider call, so a tool-using turn produces SEVERAL of
|
||||
these — it marks an autosave point, not the end of the turn. The end of the
|
||||
turn is :class:`TurnCompletedEvent`.
|
||||
"""
|
||||
|
||||
EVENT_TYPE: ClassVar[str] = "assistant_done"
|
||||
content: str = ""
|
||||
|
||||
def _payload(self) -> Dict[str, Any]:
|
||||
return {"content": self.content}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Tool-call lifecycle.
|
||||
# --------------------------------------------------------------------------- #
|
||||
@dataclass(frozen=True)
|
||||
class ToolCallStartedEvent(AgentEvent):
|
||||
"""A tool call is about to run (after any security/permission gate).
|
||||
|
||||
Field names are the typed ones (``call_id``, ``arguments``); the legacy keys
|
||||
``id``/``args`` are produced only at the serialisation boundary, so new code
|
||||
never has to shadow the ``id`` builtin.
|
||||
"""
|
||||
|
||||
EVENT_TYPE: ClassVar[str] = "tool_proposed"
|
||||
call_id: str = ""
|
||||
name: str = ""
|
||||
arguments: Dict[str, Any] = field(default_factory=dict)
|
||||
preview: Optional[ToolPreview] = None
|
||||
|
||||
def _payload(self) -> Dict[str, Any]:
|
||||
payload: Dict[str, Any] = {"id": self.call_id, "name": self.name,
|
||||
"args": dict(self.arguments)}
|
||||
# Omitted rather than sent as None: the widget does
|
||||
# ``preview = ev.get("preview") or {}`` and an absent key is the shape it
|
||||
# already handles for tools without a preview.
|
||||
if self.preview is not None:
|
||||
payload["preview"] = self.preview.to_dict()
|
||||
return payload
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolOutputChunkEvent(AgentEvent):
|
||||
"""Live stdout/stderr from a running command, appended to its step bubble."""
|
||||
|
||||
EVENT_TYPE: ClassVar[str] = "tool_output"
|
||||
call_id: str = ""
|
||||
name: str = ""
|
||||
delta: str = ""
|
||||
|
||||
def _payload(self) -> Dict[str, Any]:
|
||||
return {"id": self.call_id, "name": self.name, "delta": self.delta}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolCallFinishedEvent(AgentEvent):
|
||||
"""A tool call returned. ``path``/``produced`` name files it created."""
|
||||
|
||||
EVENT_TYPE: ClassVar[str] = "tool_result"
|
||||
call_id: str = ""
|
||||
name: str = ""
|
||||
ok: bool = False
|
||||
output: str = ""
|
||||
path: str = "" # the single file this call wrote, if any
|
||||
produced: Tuple[str, ...] = () # extra deliverables a command produced
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
# Callers pass a live list; freeze it so the event cannot change later.
|
||||
object.__setattr__(self, "produced", _as_str_tuple(self.produced))
|
||||
|
||||
def _payload(self) -> Dict[str, Any]:
|
||||
payload: Dict[str, Any] = {"id": self.call_id, "name": self.name,
|
||||
"ok": self.ok, "output": self.output}
|
||||
# Both keys stay ABSENT when empty, matching what chat_agent emits today:
|
||||
# downstream code tests them with ``ev.get(...)`` truthiness and iterates
|
||||
# ``ev.get("produced", [])``, so adding empty values would be a change.
|
||||
if self.path:
|
||||
payload["path"] = self.path
|
||||
if self.produced:
|
||||
payload["produced"] = list(self.produced)
|
||||
return payload
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Side-channel events (plan, notices, output folder).
|
||||
# --------------------------------------------------------------------------- #
|
||||
@dataclass(frozen=True)
|
||||
class PlanUpdatedEvent(AgentEvent):
|
||||
"""The agent published a new version of its step checklist (full list)."""
|
||||
|
||||
EVENT_TYPE: ClassVar[str] = "plan_set"
|
||||
steps: Tuple[PlanStep, ...] = ()
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
object.__setattr__(self, "steps", tuple(self.steps or ()))
|
||||
|
||||
def _payload(self) -> Dict[str, Any]:
|
||||
return {"steps": [s.to_dict() for s in self.steps]}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NoticeEvent(AgentEvent):
|
||||
"""An aside outside the model's own answer.
|
||||
|
||||
Three sources today: context auto-compaction (info), a blocked
|
||||
security check (warning), and attachment reading progress (progress).
|
||||
"""
|
||||
|
||||
EVENT_TYPE: ClassVar[str] = "notice"
|
||||
text: str = ""
|
||||
level: str = NOTICE_INFO
|
||||
|
||||
def _payload(self) -> Dict[str, Any]:
|
||||
return {"level": self.level, "text": self.text}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OutputsAddedEvent(AgentEvent):
|
||||
"""Deliverables appeared in the turn's output folder."""
|
||||
|
||||
EVENT_TYPE: ClassVar[str] = "outputs_added"
|
||||
paths: Tuple[str, ...] = ()
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
object.__setattr__(self, "paths", _as_str_tuple(self.paths))
|
||||
|
||||
def _payload(self) -> Dict[str, Any]:
|
||||
return {"paths": list(self.paths)}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OutputsRemovedEvent(AgentEvent):
|
||||
"""Intermediate/generator files were cleaned up — drop them from Output."""
|
||||
|
||||
EVENT_TYPE: ClassVar[str] = "outputs_removed"
|
||||
paths: Tuple[str, ...] = ()
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
object.__setattr__(self, "paths", _as_str_tuple(self.paths))
|
||||
|
||||
def _payload(self) -> Dict[str, Any]:
|
||||
return {"paths": list(self.paths)}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HistoryReadyEvent(AgentEvent):
|
||||
"""The turn's conversation now exists on disk and can be opened.
|
||||
|
||||
Emitted by the unattended (Schedule Task) path so the scheduler refreshes
|
||||
History only once the session is really there.
|
||||
"""
|
||||
|
||||
EVENT_TYPE: ClassVar[str] = "history_ready"
|
||||
session_id: str = ""
|
||||
|
||||
def _payload(self) -> Dict[str, Any]:
|
||||
return {"session_id": self.session_id}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Turn-level events introduced by R04 (no legacy consumer yet).
|
||||
# --------------------------------------------------------------------------- #
|
||||
@dataclass(frozen=True)
|
||||
class TurnCompletedEvent(AgentEvent):
|
||||
"""The whole turn ended — exactly once per turn.
|
||||
|
||||
Nothing consumes ``"turn_completed"`` yet: the widget's ``if/elif`` chain
|
||||
simply has no branch for it, so emitting it is inert until R08 wires a
|
||||
renderer. It exists now because the state it carries (was the turn
|
||||
cancelled? did it hit the step ceiling?) is currently reconstructed by the
|
||||
UI from side effects rather than being told to it.
|
||||
"""
|
||||
|
||||
EVENT_TYPE: ClassVar[str] = "turn_completed"
|
||||
final_text: str = ""
|
||||
steps_used: int = 0
|
||||
cancelled: bool = False
|
||||
budget_exhausted: bool = False # stopped at effective_max_steps
|
||||
|
||||
def _payload(self) -> Dict[str, Any]:
|
||||
return {"final_text": self.final_text, "steps_used": self.steps_used,
|
||||
"cancelled": self.cancelled, "budget_exhausted": self.budget_exhausted}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ErrorEvent(AgentEvent):
|
||||
"""The turn hit an error.
|
||||
|
||||
``recoverable`` separates "this turn is over" from "something failed but the
|
||||
loop carried on" — a distinction the current code loses, because both end up
|
||||
as a bare ``except Exception`` plus a text bubble.
|
||||
"""
|
||||
|
||||
EVENT_TYPE: ClassVar[str] = "error"
|
||||
message: str = ""
|
||||
recoverable: bool = False
|
||||
|
||||
def _payload(self) -> Dict[str, Any]:
|
||||
return {"message": self.message, "recoverable": self.recoverable}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"NOTICE_INFO", "NOTICE_WARNING", "NOTICE_PROGRESS",
|
||||
"AgentEvent", "ToolPreview", "PlanStep",
|
||||
"TextChunkEvent", "ReasoningChunkEvent", "AssistantMessageCompletedEvent",
|
||||
"ToolCallStartedEvent", "ToolOutputChunkEvent", "ToolCallFinishedEvent",
|
||||
"PlanUpdatedEvent", "NoticeEvent", "OutputsAddedEvent", "OutputsRemovedEvent",
|
||||
"HistoryReadyEvent", "TurnCompletedEvent", "ErrorEvent",
|
||||
]
|
||||
Reference in New Issue
Block a user