R04-T03 — the turn lifecycle, extracted from `core/chat_agent.py::run_cowork` into `application/conversations/`. The 260-line body mixed the lifecycle (step budget, cancel checks, guard -> preview -> gate -> execute ordering, sandbox tidy-up) with the machinery doing each step, and reaching any of it meant standing up a Qt widget and a worker thread. It is now a plain object driven through two Protocols and six callables (`turn_runtime.py`), with the concrete `core/*` wiring confined to `core_runtime_adapter.py` — the same shape R03 used for routing. Faithful port, not an improvement pass: where the original had a quirk (the step-ceiling note only merges into the answer when the last message is the assistant's) the quirk is preserved and commented. R04-T04 — `ui/cowork_tab.py::build_job` no longer calls run_cowork. It captures the widget's state at submit time, builds the request via the new `cowork_turn_request.py` and executes it. `execute(..., messages=...)` hands the widget's own list over because `_reattach_running_turn` replays from it WHILE the worker appends and `_finalize_turn` slices it afterwards — a private list would break both silently. R04-T05 — `core/task_executors.py`'s cowork branch shares the same engine. All five unattended-run behaviours stay put (plan reminder, history_ready, History autosave per assistant message, timeout notice, plan_incomplete_reason), and `_unattended_prompt` now expresses the load-bearing prefix order in one readable call instead of three successive rebindings. Verification: 74 new tests (364 passed, 1 skipped overall; check_imports PASS). The two that matter most: - `test_conversation_service_parity.py` runs the same scripted turn through run_cowork AND the service and compares the event stream, the resulting conversation and the advertised tool list across 7 scenarios; - `test_task_executor_turn.py` was written BEFORE the migration and passed 8/8 against the old code, then unchanged against the new. Known: `ui/cowork_tab.py` (416 -> 455) and `core/task_executors.py` (476 -> 524) stay above the 400-LOC limit. Both were already over it before this change; bringing them under needs the R08 / R07 decompositions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
178 lines
7.7 KiB
Python
178 lines
7.7 KiB
Python
"""The seams :mod:`conversation_application_service` runs a turn through (R04-T03).
|
|
|
|
Two Protocols and six callables — chosen deliberately, not by reflex. The
|
|
refactor plan forbids giving every class an interface, so a contract exists here
|
|
only where there is both a real ``core/*`` implementation AND a test double:
|
|
|
|
* :class:`ModelCallPort` — one provider round-trip *including* the app's
|
|
existing context-overflow recovery, which is why the raw ``Provider.chat``
|
|
signature is not enough.
|
|
* :class:`ToolRuntimePort` — the tool + output-folder runtime, kept as one
|
|
cohesive object because every method operates on the same sandbox.
|
|
|
|
Everything else is a single function, so it is expressed as a callable type
|
|
rather than a class with one method (the same choice R03 made for
|
|
``ConfirmationCallback``). All of them are optional: a service built with none
|
|
of them still runs a plain chat turn, which is what keeps the unit tests short.
|
|
|
|
Layer rules (``docs/architecture/ADR-001-layered-architecture.md``): application
|
|
layer — pure Python. Nothing here imports PySide6, ``core.*``, ``providers.*``
|
|
or ``ui.*``; the concrete wiring lives in :mod:`core_runtime_adapter`.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import (
|
|
Any,
|
|
Callable,
|
|
Dict,
|
|
List,
|
|
Optional,
|
|
Protocol,
|
|
Sequence,
|
|
Tuple,
|
|
runtime_checkable,
|
|
)
|
|
|
|
from ...domain.agents.agent_event import AgentEvent, ToolPreview
|
|
|
|
# The plan tool is special-cased by the loop: it drives the Plan panel and
|
|
# produces no chat bubble and no file. Named here so the check is not a bare
|
|
# string literal in the middle of the dispatch.
|
|
PLAN_TOOL = "update_plan"
|
|
|
|
# Tools that need approval before they run when the workspace is in confirm
|
|
# mode. R05 replaces this tuple with a real ``ToolPolicyGateway`` keyed on
|
|
# ToolCapability; until then it mirrors exactly what the runtime gates today.
|
|
GATED_TOOLS = ("run_command", "install_package")
|
|
|
|
# Shown when the user (or the workspace policy) rejects a proposed command. The
|
|
# exact string also becomes the tool message the model reads back, so it must
|
|
# stay stable.
|
|
REJECTED_OUTPUT = "Rejected by user."
|
|
|
|
# A reasoning model can answer with thinking only. The note is written into the
|
|
# assistant message itself, not merely emitted, so an unattended run does not
|
|
# read back an empty answer and report "(no output)".
|
|
REASONING_ONLY_NOTE = "*(model returned only its reasoning — try rephrasing)*"
|
|
|
|
# Emitted when the turn is stopped by its own safety ceiling rather than by the
|
|
# model finishing. Never silent: being cut off looks exactly like being done.
|
|
BUDGET_NOTE_TEMPLATE = (
|
|
"\n\n⚠️ Reached the {steps}-step safety limit before the task signalled "
|
|
"completion — stopping here. Re-run to continue if more work remains."
|
|
)
|
|
|
|
|
|
def combine_instructions(*blocks: Optional[str]) -> str:
|
|
"""Join the standing-instruction blocks of a turn, skipping the absent ones.
|
|
|
|
A turn's instructions arrive as several independent blocks — the project's
|
|
shared context, an Admin agent's persona, a skill's rules, the
|
|
"this runs unattended" reminder — and each caller was joining them inline
|
|
with its own ``f"{a}\\n\\n{b}" if a else b`` expression. Two call sites now
|
|
need the same rule (the Cowork widget in R04-T04 and the task runner in
|
|
R04-T05), which is the point at which it stops being an expression.
|
|
|
|
Whitespace-only blocks count as absent: they would otherwise open the system
|
|
prompt with a stray blank line.
|
|
"""
|
|
return "\n\n".join(b.strip() for b in blocks if b and b.strip())
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Callables.
|
|
# --------------------------------------------------------------------------- #
|
|
# Receives every typed event the turn produces. The caller decides what that
|
|
# means — render it, forward it as a legacy dict, autosave on it.
|
|
EventSink = Callable[[AgentEvent], None]
|
|
|
|
# True once the user has asked to stop. Polled between steps and between tool
|
|
# calls, the same cadence the current runtime uses.
|
|
CancelFn = Callable[[], bool]
|
|
|
|
# ``(prompt, attachment_paths) -> body``. Runs on the worker thread because
|
|
# extracting a .docx may pip-install a parser or call LibreOffice.
|
|
AttachmentReader = Callable[[str, Tuple[str, ...]], str]
|
|
|
|
# ``(messages, advertised_tool_names) -> None`` — inserts the system prompt and
|
|
# folds in skills, security rules and project instructions, in place. It needs
|
|
# the tool names because the system prompt gains an MS365 paragraph only when
|
|
# ms365 tools are actually present.
|
|
PromptPreparer = Callable[[List[Dict[str, Any]], Tuple[str, ...]], None]
|
|
|
|
# Reviews the assembled request; raises to refuse the turn outright.
|
|
PromptGuard = Callable[[List[Dict[str, Any]]], None]
|
|
|
|
# Reviews one proposed tool call; raises to refuse it.
|
|
CommandGuard = Callable[[str, Dict[str, Any]], None]
|
|
|
|
# ``(messages, cancel) -> None``. Summarises old turns in place when the
|
|
# conversation nears the model's context budget; a no-op when compaction is off
|
|
# or the conversation is short. It takes the cancel signal because compacting
|
|
# calls the model itself, so Stop has to reach it too.
|
|
ContextCompactor = Callable[[List[Dict[str, Any]], "CancelFn"], None]
|
|
|
|
# ``(action) -> approved``. Blocks the worker thread while a human decides.
|
|
PermissionRequest = Callable[[Dict[str, Any]], bool]
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Ports.
|
|
# --------------------------------------------------------------------------- #
|
|
@runtime_checkable
|
|
class ModelCallPort(Protocol):
|
|
"""One call to the model, with the app's retry/recovery behaviour applied."""
|
|
|
|
def call(self, messages: List[Dict[str, Any]], tools: Sequence[Any],
|
|
on_text: Optional[Callable[[str], None]] = None,
|
|
on_reasoning: Optional[Callable[[str], None]] = None,
|
|
cancel: Optional[CancelFn] = None) -> Dict[str, Any]:
|
|
"""Return the canonical assistant message (content plus tool calls)."""
|
|
|
|
|
|
@runtime_checkable
|
|
class ToolRuntimePort(Protocol):
|
|
"""The tools a turn may call, and the folder its files land in."""
|
|
|
|
def specs(self, allowed_tools: Optional[Sequence[str]] = None) -> Sequence[Any]:
|
|
"""Tool specs to advertise to the model, already filtered.
|
|
|
|
Returns opaque objects (the provider layer's ``ToolSpec``); the service
|
|
only ever reads ``.name`` off them, which is what keeps this layer free
|
|
of a provider import.
|
|
"""
|
|
|
|
def preview(self, name: str, args: Dict[str, Any]) -> Optional[ToolPreview]:
|
|
"""Human-readable description of a call that is about to run."""
|
|
|
|
def execute(self, name: str, args: Dict[str, Any],
|
|
on_output: Optional[Callable[[str], None]] = None,
|
|
cancel: Optional[CancelFn] = None) -> Dict[str, Any]:
|
|
"""Run one tool call.
|
|
|
|
Returns the runtime's own result mapping: ``ok``, ``output``, optionally
|
|
``path``/``produced`` for files it created, and ``plan_steps`` for the
|
|
plan tool.
|
|
"""
|
|
|
|
def snapshot(self) -> Any:
|
|
"""Opaque record of the output folder before the turn started."""
|
|
|
|
def finalize(self, before: Any, cancelled: bool = False
|
|
) -> Tuple[Sequence[str], Sequence[str]]:
|
|
"""Tidy the output folder; return ``(removed_paths, added_paths)``.
|
|
|
|
Not read-only — it deletes the scratch sandbox and flattens sub-folders —
|
|
so the service only calls it for a turn that actually started.
|
|
"""
|
|
|
|
|
|
__all__ = [
|
|
"PLAN_TOOL", "GATED_TOOLS", "REJECTED_OUTPUT", "REASONING_ONLY_NOTE",
|
|
"BUDGET_NOTE_TEMPLATE", "combine_instructions",
|
|
"EventSink", "CancelFn", "AttachmentReader", "PromptPreparer", "PromptGuard",
|
|
"CommandGuard", "ContextCompactor", "PermissionRequest",
|
|
"ModelCallPort", "ToolRuntimePort",
|
|
]
|