feat(R04): immutable turn snapshot, typed agent events, conversation service

EPIC R04 (Team Duy) - the turn lifecycle leaves the widget.

R04-T01 domain/agents/conversation_execution_request.py
  Frozen snapshot of one turn, captured on the UI thread at submit time. The
  job closure used to read widget/workspace state from inside the worker
  thread, so a turn could run on a mix of submit-time and later state
  depending on thread timing.
R04-T02 domain/agents/agent_event.py
  13 frozen event types replacing untyped emit() dicts, with a two-way bridge
  so existing widgets keep consuming the legacy shape until EPIC R08. Adds
  TurnCompletedEvent - the end-of-turn signal the engine never had, which is
  why a cancelled turn and a failed turn look identical to the UI today.
R04-T03 application/conversations/conversation_application_service.py
  Runs a turn from a request and reports typed events. Never raises across the
  worker boundary; TurnResult.raise_if_failed() preserves the existing
  exception-based failure path. begin_turn()/execute_turn() expose the live
  message list for callers that autosave history mid-run.
R04-T04 ui/cowork_tab.py::build_job -> snapshot + service.
R04-T05 core/task_executors.py::_run_agent -> same service (was a second,
  slightly different assembly of the same call).

Caught while wiring the bridge: the first event vocabulary had no "notice"
event, so Agent Security warnings and auto-compaction notices would have been
silently swallowed. Added NoticeEvent plus a test that scans the engine sources
for emit() tags and fails when one has no typed counterpart.

New: tests/integration/ - real offscreen CoworkTab running a scripted turn end
to end (7 tests), including a characterisation of the extra provider call Agent
Security spends reviewing each request.

Suite: 225 passed, 2.74s. check_imports: PASS. All new files < 400 LOC.
2 pre-existing failures remain in test_config_security.py (EPIC R02/Team Nam).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-21 10:32:14 +09:00
co-authored by Claude Opus 5
parent 96bec976e7
commit a53163ebaf
11 changed files with 1663 additions and 53 deletions
+370
View File
@@ -0,0 +1,370 @@
"""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",
]