CI / test (push) Canceled after 0s
## Summary epic r04 - begin refactor ## Change Type - [x] Cowork feature - [ ] Bug fix - [ ] Core AI contribution - [ ] Test / hardening - [ ] Performance - [ ] Documentation ## Related Work Cowork Task: Core Repo: http://34.143.229.138/gitea-admin/fsg-ai-core-assets Core AI Issue: Core Task: Related PR: ## Scope What is intentionally included? What is intentionally NOT included? ## Validation - [ ] Unit tests - [ ] Integration tests - [ ] Manual verification - [ ] Regression check Commands / evidence: ## Security Impact Permission / credential / network / customer data impact: ## Compatibility - [ ] No breaking change - [ ] Breaking change documented ## Reviewer Notes Anything Cowork reviewers should pay attention to. --------- Co-authored-by: Anh Tran Nguyen Minh <anhtnm1@fpt.com> Co-authored-by: Huong Le Thi Thien <huongltt35@fpt.com> Co-authored-by: Nam Pham Dinh Thanh <nampdt@fpt.com> Co-authored-by: Vu Dam Tuan <vudt15@fpt.com> Co-authored-by: Hiep Ha Van <hiephv3@fpt.com> Co-authored-by: Lam Hoang Van <lamhv7@fpt.com> Reviewed-on: #7 Co-authored-by: Duy Le Huu <duylh19@fpt.com>
87 lines
3.7 KiB
Python
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"]
|