feat(R04): run every Cowork turn through ConversationApplicationService
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>
This commit is contained in:
+69
-19
@@ -2,7 +2,9 @@
|
||||
|
||||
``execute_task`` dispatches by ``task_type`` to the app's existing engines:
|
||||
|
||||
- ``cowork`` → ``chat_agent.run_cowork`` (documents/answers, real files)
|
||||
- ``cowork`` → ``ConversationApplicationService`` (documents/answers, real
|
||||
files) — the same turn engine the interactive Cowork chat
|
||||
runs on since R04-T05
|
||||
- ``co4e_code`` → ``code_agent.run_code`` (code agent with file/command tools)
|
||||
- ``script`` → local subprocess with a timeout
|
||||
- ``flow`` → the task's own simple step list, run sequentially, each
|
||||
@@ -162,6 +164,30 @@ _TIMEOUT_NOTICE_TMPL = (
|
||||
)
|
||||
|
||||
|
||||
_UNATTENDED_PREFIX = (
|
||||
"This runs unattended (Schedule Task) — no one is watching live. Use "
|
||||
"update_plan to track your steps and keep it accurate: mark a step "
|
||||
"'error' (not silently skip it) if it genuinely can't be completed."
|
||||
)
|
||||
|
||||
|
||||
def _unattended_prompt(prompt: str, *, skill_text: str = "",
|
||||
agent_instructions: str = "") -> str:
|
||||
"""Assemble the user message an unattended run sends.
|
||||
|
||||
The order is load-bearing and used to be encoded as three successive
|
||||
rebindings of ``prompt``, each prepending its own block: the plan reminder
|
||||
must lead (it is the instruction that keeps a run without a human watching
|
||||
honest), then the chosen skill's rules, then the Admin agent's persona, and
|
||||
the task's own words last. Routing it through ``combine_instructions`` keeps
|
||||
that order in one readable expression and drops the absent blocks instead of
|
||||
leaving blank lines behind.
|
||||
"""
|
||||
from ..application.conversations.turn_runtime import combine_instructions
|
||||
|
||||
return combine_instructions(_UNATTENDED_PREFIX, skill_text, agent_instructions, prompt)
|
||||
|
||||
|
||||
def _cancel_with_timeout(cancel: CancelFn, timeout_sec: Optional[int]) -> Tuple[CancelFn, Callable[[], bool]]:
|
||||
"""Wrap ``cancel`` so it also fires once ``timeout_sec`` of wall-clock time
|
||||
elapses. ``timed_out()`` tells the caller whether THAT is why it stopped
|
||||
@@ -218,36 +244,29 @@ def _run_agent(ctx, task_type: str, prompt: str, out_dir: Path,
|
||||
# default, see state.build_provider_for). A legacy Admin-agent preset
|
||||
# (task.admin_agent_id), if still set on an older task, keeps working and
|
||||
# takes precedence — it pins the provider/model AND prepends instructions.
|
||||
agent_instructions = ""
|
||||
if admin_agent is not None:
|
||||
from .admin_agents import build_agent_provider
|
||||
|
||||
provider = build_agent_provider(ctx, admin_agent)
|
||||
agent_instructions = admin_agent.effective_prompt()
|
||||
if agent_instructions:
|
||||
prompt = f"{agent_instructions}\n\n{prompt}"
|
||||
elif provider_name or model:
|
||||
# An explicit per-task provider/model override.
|
||||
provider = ctx.build_provider_for(provider_name or None, model or None)
|
||||
else:
|
||||
# Neither overridden → the machine's own Settings default, exactly as before.
|
||||
provider = ctx.build_active_provider()
|
||||
# A chosen skill's instructions are prepended so this unattended run follows
|
||||
# A chosen skill's instructions are applied so this unattended run follows
|
||||
# them, mirroring how the interactive chat applies /skill.
|
||||
skill_text = ""
|
||||
if skill_slug:
|
||||
from .skills import skill_prefix_for
|
||||
|
||||
skill_text = skill_prefix_for(skill_slug)
|
||||
if skill_text:
|
||||
prompt = f"{skill_text}\n\n{prompt}"
|
||||
# This is an UNATTENDED run (no human watching to catch a half-finished
|
||||
# job) — push the agent to actually use the Plan checklist so completion
|
||||
# can be verified afterward, instead of just trusting "no exception".
|
||||
prompt = (
|
||||
"This runs unattended (Schedule Task) — no one is watching live. Use "
|
||||
"update_plan to track your steps and keep it accurate: mark a step "
|
||||
"'error' (not silently skip it) if it genuinely can't be completed.\n\n"
|
||||
f"{prompt}"
|
||||
)
|
||||
# Assemble reminder + skill + persona + the task's own words in one place
|
||||
# (see _unattended_prompt for why that order matters).
|
||||
prompt = _unattended_prompt(prompt, skill_text=skill_text,
|
||||
agent_instructions=agent_instructions)
|
||||
messages = [{"role": "user", "content": prompt}]
|
||||
session_id = new_session_id()
|
||||
project_id = project.project_id if project is not None else ""
|
||||
@@ -273,10 +292,41 @@ def _run_agent(ctx, task_type: str, prompt: str, out_dir: Path,
|
||||
watched_cancel, timed_out = _cancel_with_timeout(cancel, timeout_sec)
|
||||
try:
|
||||
if task_type == "cowork":
|
||||
from .chat_agent import run_cowork
|
||||
run_cowork(provider, messages, out_dir, emit_and_autosave, watched_cancel,
|
||||
security_config=ctx.config, agent_role=agent_roles.TASK,
|
||||
project_context=project_context)
|
||||
# R04-T05: the unattended run shares the interactive turn engine
|
||||
# instead of calling run_cowork itself, so there is exactly one place
|
||||
# where a turn's lifecycle is defined. Everything unattended-specific
|
||||
# stays here (the plan reminder above, the History autosave in
|
||||
# emit_and_autosave, the timeout notice below).
|
||||
from ..application.conversations.core_runtime_adapter import (
|
||||
build_cowork_conversation_service,
|
||||
legacy_event_sink,
|
||||
)
|
||||
from ..domain.agents.conversation_execution_request import (
|
||||
ConversationExecutionRequest,
|
||||
)
|
||||
|
||||
# No extra_tools/extra_executor and no permission gate: a scheduled
|
||||
# run gets no MCP connectors and nobody is there to approve a
|
||||
# command, which is exactly what run_cowork was called with.
|
||||
service = build_cowork_conversation_service(
|
||||
provider, out_dir, emit_and_autosave, title=title,
|
||||
project_context=project_context, security_config=ctx.config,
|
||||
agent_role=agent_roles.TASK,
|
||||
)
|
||||
request = ConversationExecutionRequest(
|
||||
# The artifact folder is named by the run id, which identifies
|
||||
# this attempt in the audit log.
|
||||
turn_id=out_dir.name or session_id, session_id=session_id,
|
||||
surface="task", title=title, project_id=project_id,
|
||||
prompt=prompt, output_dir=out_dir,
|
||||
agent_role=agent_roles.TASK, unattended=True,
|
||||
timeout_sec=timeout_sec,
|
||||
)
|
||||
# ``messages`` is handed over so the History autosave in
|
||||
# emit_and_autosave (and the final save in the finally block below)
|
||||
# keep reading the live conversation as it grows.
|
||||
service.execute(request, legacy_event_sink(emit_and_autosave),
|
||||
cancel=watched_cancel, messages=messages)
|
||||
else:
|
||||
from .code_agent import run_code
|
||||
limits, block_network = agent_security.sandbox_settings(ctx.config)
|
||||
|
||||
Reference in New Issue
Block a user