Files
cowork-local/domain/agents/agent_result.py
T
duylh19andClaude Opus 5 19e6b4deb2 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>
2026-08-23 13:13:56 +09:00

87 lines
3.7 KiB
Python

"""What one finished turn produced (R04-T03).
The outcome of a turn is currently spread over three shapes: ``run_cowork``
returns the mutated message list, ``task_executors._run_agent`` returns a
``(answer_text, timed_out, incomplete_reason)`` tuple, and the UI reconstructs
the rest (did it get cancelled? did it hit the ceiling?) from side effects. Each
caller therefore knows a slightly different amount about the same turn.
:class:`AgentResult` is the single answer. Frozen, like the request that started
the turn, so a result cannot be edited into disagreeing with what actually
happened.
Layer rules (``docs/architecture/ADR-001-layered-architecture.md``): domain
layer — standard library plus sibling domain types only.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Dict, Tuple
from .agent_event import PlanStep, TurnCompletedEvent
@dataclass(frozen=True)
class AgentResult:
"""The outcome of one conversation turn."""
# The conversation AFTER the turn (system prompt, history, the new user
# message, every assistant reply and tool result).
messages: Tuple[Dict[str, Any], ...] = ()
steps_used: int = 0 # provider calls this turn consumed
cancelled: bool = False # the user pressed Stop
budget_exhausted: bool = False # stopped at effective_max_steps
# The agent's final checklist, so a caller can ask "did it really finish?"
# (``core/plan.py::plan_incomplete_reason``) without replaying the events.
plan_steps: Tuple[PlanStep, ...] = ()
# Non-empty when the turn ended on a failure. A string rather than the
# exception: the domain layer must not depend on where the error came from,
# and the message is what every consumer (bubble, error.txt, audit) shows.
error: str = ""
def __post_init__(self) -> None:
"""Freeze the collections the runtime hands over.
Both arrive as live lists that the caller keeps appending to after the
turn (the UI merges messages back into its own history), so copying here
is what keeps a result a record rather than a moving target.
"""
object.__setattr__(self, "messages", tuple(self.messages or ()))
object.__setattr__(self, "plan_steps", tuple(self.plan_steps or ()))
@property
def final_text(self) -> str:
"""The answer to show the user.
Scans backwards for the last assistant message with real content, which
is not the same as ``messages[-1]``: a turn that was cancelled or that
ran out of steps mid-loop ends on a tool message, and a reasoning-only
reply leaves a blank assistant message behind. Same rule as
``core/task_executors.py::_last_assistant_text``, which this replaces.
"""
for message in reversed(self.messages):
if message.get("role") == "assistant" and (message.get("content") or "").strip():
return str(message["content"])
return ""
@property
def ok(self) -> bool:
"""Whether the turn ran to a normal end.
Hitting the step ceiling still counts as ok: the agent did work and
produced an answer, it just was not allowed to keep going — which the
transcript says in its own note rather than by failing the turn.
"""
return not self.error and not self.cancelled
def to_turn_completed_event(self) -> TurnCompletedEvent:
"""The end-of-turn event carrying this outcome to subscribers."""
return TurnCompletedEvent(
final_text=self.final_text, steps_used=self.steps_used,
cancelled=self.cancelled, budget_exhausted=self.budget_exhausted,
)
__all__ = ["AgentResult"]