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>
371 lines
14 KiB
Python
371 lines
14 KiB
Python
"""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",
|
|
]
|