Files
f9f6bc01fd
CI / test (push) Canceled after 0s
Feature/delta team/epic r04 (#7)
## 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>
2026-08-31 05:15:13 +00: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"]