Merge remote-tracking branch 'origin/gamma/refactor'
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Application conversations package: turn lifecycle orchestration and agent execution."""
|
||||
@@ -0,0 +1,322 @@
|
||||
"""The turn lifecycle, once, in pure Python (R04-T03).
|
||||
|
||||
Extracted from ``core/chat_agent.py::run_cowork``, whose 260-line body mixed the
|
||||
lifecycle (compose the prompt, call the model, dispatch tools, respect the step
|
||||
ceiling, tidy the sandbox) with the concrete machinery that does each of those
|
||||
things. The lifecycle is the part with rules worth testing — and the part that
|
||||
was untestable, because reaching it meant standing up a Qt widget and a worker
|
||||
thread.
|
||||
|
||||
Here it is a plain object driven through the seams in :mod:`turn_runtime`, so a
|
||||
test states a rule ("the guard runs before the model", "a rejected command never
|
||||
executes") in three lines. ``core/chat_agent.py`` keeps its signature and
|
||||
delegates, and the presentation layer keeps receiving the same events via the
|
||||
legacy codec, so nothing downstream had to change with it.
|
||||
|
||||
Behavioural contract: this is a 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 —
|
||||
changing what a user sees belongs in its own change, not smuggled into a move.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from ...domain.agents.agent_event import (
|
||||
AssistantMessageCompletedEvent,
|
||||
ErrorEvent,
|
||||
OutputsAddedEvent,
|
||||
OutputsRemovedEvent,
|
||||
PlanStep,
|
||||
PlanUpdatedEvent,
|
||||
ReasoningChunkEvent,
|
||||
TextChunkEvent,
|
||||
ToolCallFinishedEvent,
|
||||
ToolCallStartedEvent,
|
||||
ToolOutputChunkEvent,
|
||||
)
|
||||
from ...domain.agents.agent_result import AgentResult
|
||||
from ...domain.agents.conversation_execution_request import ConversationExecutionRequest
|
||||
from .turn_runtime import (
|
||||
BUDGET_NOTE_TEMPLATE,
|
||||
GATED_TOOLS,
|
||||
PLAN_TOOL,
|
||||
REASONING_ONLY_NOTE,
|
||||
REJECTED_OUTPUT,
|
||||
AttachmentReader,
|
||||
CancelFn,
|
||||
CommandGuard,
|
||||
ContextCompactor,
|
||||
EventSink,
|
||||
ModelCallPort,
|
||||
PermissionRequest,
|
||||
PromptGuard,
|
||||
PromptPreparer,
|
||||
ToolRuntimePort,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("cowork_local.application.conversations")
|
||||
|
||||
|
||||
class ConversationApplicationService:
|
||||
"""Runs one :class:`ConversationExecutionRequest` to completion."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model: ModelCallPort,
|
||||
tools: ToolRuntimePort,
|
||||
*,
|
||||
prepare_prompt: Optional[PromptPreparer] = None,
|
||||
prompt_guard: Optional[PromptGuard] = None,
|
||||
command_guard: Optional[CommandGuard] = None,
|
||||
compact: Optional[ContextCompactor] = None,
|
||||
permission_request: Optional[PermissionRequest] = None,
|
||||
attachment_reader: Optional[AttachmentReader] = None,
|
||||
) -> None:
|
||||
self._model = model
|
||||
self._tools = tools
|
||||
# Every hook is optional so the service degrades to a plain chat turn.
|
||||
# That is not only a test convenience: a headless caller legitimately has
|
||||
# no guards (``security_config=None`` today) and no permission dialog.
|
||||
self._prepare_prompt = prepare_prompt
|
||||
self._prompt_guard = prompt_guard
|
||||
self._command_guard = command_guard
|
||||
self._compact = compact
|
||||
self._permission_request = permission_request
|
||||
self._attachment_reader = attachment_reader
|
||||
|
||||
# -- public API ------------------------------------------------------ #
|
||||
def execute(self, request: ConversationExecutionRequest, sink: EventSink,
|
||||
cancel: Optional[CancelFn] = None,
|
||||
messages: Optional[List[Dict[str, Any]]] = None) -> AgentResult:
|
||||
"""Run the turn, streaming events to ``sink``, and report the outcome.
|
||||
|
||||
``messages``, when given, is a working list the caller already built —
|
||||
it MUST already end with this turn's user message, and the service
|
||||
appends into that very object instead of composing its own. The Cowork
|
||||
widget needs this: it hands out the same list to
|
||||
``_reattach_running_turn``, which replays the steps done so far while the
|
||||
worker is still appending, and to ``_finalize_turn``, which slices it by
|
||||
the pre-turn snapshot length. A private list would break both silently.
|
||||
Passing ``None`` (every headless caller) lets the service compose the
|
||||
list from the request, which is the mode the rest of this class assumes.
|
||||
|
||||
Raises whatever the runtime raises (a blocked prompt, a dead gateway):
|
||||
the caller already has a failure path for that — ``AgentWorker.failed``
|
||||
in the UI, the artifact writer in Schedule Task — and swallowing the
|
||||
exception here would silently turn a failed turn into an empty answer.
|
||||
An :class:`ErrorEvent` is emitted first so subscribers see the failure
|
||||
on the same stream as everything else.
|
||||
"""
|
||||
cancel = cancel or (lambda: False)
|
||||
|
||||
# -- pre-flight. Runs BEFORE the output snapshot, so a turn refused here
|
||||
# leaves the output folder completely untouched (tidying is not a
|
||||
# read-only operation — see ToolRuntimePort.finalize).
|
||||
try:
|
||||
# The caller's list is used by reference on purpose (see above); only
|
||||
# the self-composed path may build a fresh one.
|
||||
working = messages if messages is not None else self._compose_messages(request)
|
||||
tools = list(self._tools.specs(request.allowed_tools))
|
||||
if self._prepare_prompt is not None:
|
||||
self._prepare_prompt(working, tuple(getattr(t, "name", "") for t in tools))
|
||||
if request.enforce_rules and self._prompt_guard is not None:
|
||||
self._prompt_guard(working)
|
||||
except Exception as exc: # noqa: BLE001 — reported, then re-raised as-is
|
||||
sink(ErrorEvent(message=str(exc)))
|
||||
raise
|
||||
|
||||
before = self._tools.snapshot()
|
||||
steps_used = 0
|
||||
plan_steps: Tuple[PlanStep, ...] = ()
|
||||
completed_naturally = False
|
||||
try:
|
||||
for _ in range(request.effective_max_steps):
|
||||
if cancel():
|
||||
break
|
||||
# Auto-compress when nearing the model's context budget; a no-op
|
||||
# when off or when the conversation is still short.
|
||||
if self._compact is not None:
|
||||
self._compact(working, cancel)
|
||||
|
||||
assistant = self._model.call(
|
||||
working, tools,
|
||||
on_text=lambda piece: sink(TextChunkEvent(delta=piece)),
|
||||
on_reasoning=lambda piece: sink(ReasoningChunkEvent(delta=piece)),
|
||||
cancel=cancel,
|
||||
)
|
||||
working.append(assistant)
|
||||
steps_used += 1
|
||||
tool_calls = assistant.get("tool_calls") or []
|
||||
|
||||
if not tool_calls and not (assistant.get("content") or "").strip():
|
||||
# Written into the message, not just emitted, so the stored
|
||||
# conversation never ends on a blank assistant turn.
|
||||
assistant["content"] = REASONING_ONLY_NOTE
|
||||
sink(TextChunkEvent(delta=REASONING_ONLY_NOTE))
|
||||
sink(AssistantMessageCompletedEvent(content=assistant.get("content", "")))
|
||||
|
||||
if not tool_calls:
|
||||
completed_naturally = True
|
||||
break
|
||||
|
||||
for call in tool_calls:
|
||||
if cancel():
|
||||
break
|
||||
tool_message, steps = self._dispatch(request, call, sink, cancel)
|
||||
working.append(tool_message)
|
||||
if steps is not None:
|
||||
plan_steps = steps
|
||||
|
||||
if not completed_naturally and not cancel():
|
||||
self._announce_budget_exhausted(request, working, sink)
|
||||
except Exception as exc: # noqa: BLE001 — reported, then re-raised as-is
|
||||
sink(ErrorEvent(message=str(exc)))
|
||||
raise
|
||||
finally:
|
||||
# Always tidy: the sandbox and generator scripts must not survive a
|
||||
# turn that stopped abruptly. Runs on success, cancel and failure.
|
||||
self._finalize_outputs(before, sink, cancelled=cancel())
|
||||
|
||||
result = AgentResult(
|
||||
messages=working, steps_used=steps_used, cancelled=cancel(),
|
||||
budget_exhausted=not completed_naturally and not cancel(),
|
||||
plan_steps=plan_steps,
|
||||
)
|
||||
sink(result.to_turn_completed_event())
|
||||
return result
|
||||
|
||||
# -- internals ------------------------------------------------------- #
|
||||
def _compose_messages(self, request: ConversationExecutionRequest) -> List[Dict[str, Any]]:
|
||||
"""History snapshot plus this turn's user message.
|
||||
|
||||
The attachment text is read HERE rather than when the request was built,
|
||||
because extraction is slow enough to freeze the UI thread; the request
|
||||
deliberately carries paths only.
|
||||
"""
|
||||
body = request.prompt
|
||||
if self._attachment_reader is not None:
|
||||
body = self._attachment_reader(request.prompt, request.attachments)
|
||||
messages = [dict(m) for m in request.messages]
|
||||
messages.append({"role": "user", "content": request.user_content(body)})
|
||||
return messages
|
||||
|
||||
def _dispatch(self, request: ConversationExecutionRequest, call: Dict[str, Any],
|
||||
sink: EventSink, cancel: CancelFn
|
||||
) -> Tuple[Dict[str, Any], Optional[Tuple[PlanStep, ...]]]:
|
||||
"""Run one tool call.
|
||||
|
||||
Returns ``(tool_message, plan_steps)`` — the message to append to the
|
||||
conversation, and the new checklist when this call was the plan tool
|
||||
(``None`` otherwise, so the caller can tell "no change" from "empty
|
||||
plan").
|
||||
"""
|
||||
call_id = str(call.get("id", ""))
|
||||
name = str(call.get("name", ""))
|
||||
args = call.get("arguments") or {}
|
||||
|
||||
# The plan tool is invisible in the transcript: it updates the Plan panel
|
||||
# and nothing else, so it skips preview, guard and gate entirely.
|
||||
if name == PLAN_TOOL:
|
||||
outcome = self._tools.execute(name, args, on_output=None, cancel=cancel)
|
||||
steps = tuple(outcome.get("plan_steps") or ())
|
||||
sink(PlanUpdatedEvent(steps=steps))
|
||||
return self._tool_message(call_id, name, outcome.get("output", "")), steps
|
||||
|
||||
# Announce first: the user sees the code/command about to run before the
|
||||
# guard or the approval dialog interrupts them, which is the whole point
|
||||
# of showing the step CLI-style.
|
||||
preview = self._tools.preview(name, args)
|
||||
sink(ToolCallStartedEvent(call_id=call_id, name=name, arguments=dict(args),
|
||||
preview=preview))
|
||||
|
||||
if request.enforce_rules and self._command_guard is not None:
|
||||
self._command_guard(name, args)
|
||||
|
||||
if not self._approved(request, name, args, preview, sink, call_id):
|
||||
return self._tool_message(call_id, name, REJECTED_OUTPUT), None
|
||||
|
||||
outcome = self._tools.execute(
|
||||
name, args,
|
||||
on_output=lambda piece: sink(ToolOutputChunkEvent(
|
||||
call_id=call_id, name=name, delta=piece)),
|
||||
cancel=cancel,
|
||||
)
|
||||
sink(ToolCallFinishedEvent(
|
||||
call_id=call_id, name=name, ok=bool(outcome.get("ok", False)),
|
||||
output=str(outcome.get("output", "")), path=str(outcome.get("path", "") or ""),
|
||||
produced=outcome.get("produced") or (),
|
||||
))
|
||||
return self._tool_message(call_id, name, outcome.get("output", "")), None
|
||||
|
||||
def _approved(self, request: ConversationExecutionRequest, name: str,
|
||||
args: Dict[str, Any], preview: Any, sink: EventSink,
|
||||
call_id: str) -> bool:
|
||||
"""Whether this call may run.
|
||||
|
||||
Only command-shaped tools are gated, and only when the workspace asked
|
||||
to confirm them: file writes stay inside the turn's own sandbox, so
|
||||
prompting for those would be noise. A rejection is reported as a failed
|
||||
tool result — the model needs to read back that it was refused, or it
|
||||
will simply try the same call again.
|
||||
"""
|
||||
if not request.requires_permission_gate or name not in GATED_TOOLS:
|
||||
return True
|
||||
if self._permission_request is None:
|
||||
# Confirm mode with nobody to ask: refusing is the safe direction,
|
||||
# since auto-running is exactly what confirm mode exists to prevent.
|
||||
logger.warning("turn: confirm mode without a permission callback — refusing %r", name)
|
||||
approved = False
|
||||
else:
|
||||
approved = bool(self._permission_request({
|
||||
"name": name, "args": args,
|
||||
"preview": preview.to_dict() if preview is not None else {},
|
||||
}))
|
||||
if not approved:
|
||||
sink(ToolCallFinishedEvent(call_id=call_id, name=name, ok=False,
|
||||
output=REJECTED_OUTPUT))
|
||||
return approved
|
||||
|
||||
@staticmethod
|
||||
def _tool_message(call_id: str, name: str, output: Any) -> Dict[str, Any]:
|
||||
"""The canonical ``role: tool`` message the model reads back."""
|
||||
return {"role": "tool", "tool_call_id": call_id, "name": name,
|
||||
"content": str(output or "")}
|
||||
|
||||
@staticmethod
|
||||
def _announce_budget_exhausted(request: ConversationExecutionRequest,
|
||||
messages: List[Dict[str, Any]], sink: EventSink) -> None:
|
||||
"""Report being cut off by the step ceiling.
|
||||
|
||||
The note always reaches the transcript. It is merged into the stored
|
||||
answer only when the last message is the assistant's — which, when the
|
||||
ceiling is hit, it never is (the turn ends on a tool result). The branch
|
||||
is kept because it is what the current runtime does, and because it is
|
||||
the correct behaviour the day a caller ends the loop differently.
|
||||
"""
|
||||
note = BUDGET_NOTE_TEMPLATE.format(steps=request.effective_max_steps)
|
||||
sink(TextChunkEvent(delta=note))
|
||||
if messages and messages[-1].get("role") == "assistant":
|
||||
messages[-1]["content"] = (messages[-1].get("content") or "") + note
|
||||
|
||||
def _finalize_outputs(self, before: Any, sink: EventSink, cancelled: bool) -> None:
|
||||
"""Tidy the output folder and report what moved.
|
||||
|
||||
Failures are logged, never raised: this runs in a ``finally``, so an
|
||||
exception here would replace the turn's real error (or its success) with
|
||||
a housekeeping one.
|
||||
"""
|
||||
try:
|
||||
removed, added = self._tools.finalize(before, cancelled=cancelled)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("turn: tidying the output folder failed")
|
||||
return
|
||||
if removed:
|
||||
sink(OutputsRemovedEvent(paths=tuple(removed)))
|
||||
if added:
|
||||
sink(OutputsAddedEvent(paths=tuple(added)))
|
||||
|
||||
|
||||
__all__ = ["ConversationApplicationService"]
|
||||
@@ -0,0 +1,325 @@
|
||||
"""Wires :class:`ConversationApplicationService` to the existing runtime (R04-T03).
|
||||
|
||||
The service is written against the narrow seams in :mod:`turn_runtime` so it can
|
||||
be tested with plain fakes. This module supplies the real implementations — the
|
||||
provider call with its recovery pass, the tool/sandbox runtime, the security
|
||||
guards, context compaction — and is therefore the ONLY file in
|
||||
``application/conversations/`` that knows ``core/*`` exists. Same shape (and
|
||||
same reason) as ``application/model_routing/core_routing_adapter.py`` in R03.
|
||||
|
||||
Every ``core`` import is deferred into a method body: importing the tool runtime
|
||||
pulls in ``requests``, ``psutil`` and the sandbox stack, and code that merely
|
||||
*builds* a service must not pay for that.
|
||||
|
||||
Faithfulness notes — two places where this reproduces a quirk of the current
|
||||
runtime rather than the behaviour one would design fresh. Both are marked
|
||||
inline: the MS365 system-prompt paragraph keys off the CONFIGURED extra tools
|
||||
(not the advertised subset), and the ``tool_result`` path falls back to the
|
||||
call's own ``path`` argument resolved against the workdir.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
from ...domain.agents.agent_event import PlanStep, ToolPreview
|
||||
from .conversation_application_service import ConversationApplicationService
|
||||
from .turn_runtime import PLAN_TOOL, EventSink
|
||||
|
||||
# Legacy emit: the dict-based callback every current caller already owns.
|
||||
LegacyEmit = Callable[[Dict[str, Any]], None]
|
||||
|
||||
|
||||
def legacy_event_sink(emit: LegacyEmit) -> EventSink:
|
||||
"""Adapt a typed :class:`EventSink` onto the legacy dict ``emit``.
|
||||
|
||||
This is what lets R04 land without touching the presentation layer: the
|
||||
service thinks in typed events, ``ui/chat_panel.py::_on_event`` keeps
|
||||
receiving exactly the dicts it already dispatches on. Deleted in R08 once
|
||||
the widget consumes events directly.
|
||||
"""
|
||||
return lambda event: emit(event.to_legacy_dict())
|
||||
|
||||
|
||||
class CoreModelCall:
|
||||
""":class:`ModelCallPort` over ``code_agent._call_provider_with_recovery``.
|
||||
|
||||
Not ``provider.chat`` directly: the recovery wrapper adds the one bounded
|
||||
retry that hides a dropped connection or a momentarily unreachable gateway,
|
||||
and losing it would be a visible regression on flaky corporate networks.
|
||||
"""
|
||||
|
||||
def __init__(self, provider: Any) -> None:
|
||||
self._provider = provider
|
||||
|
||||
def call(self, messages, tools, on_text=None, on_reasoning=None, cancel=None):
|
||||
from ...core.code_agent import _call_provider_with_recovery
|
||||
|
||||
return _call_provider_with_recovery(self._provider, messages, tools, on_text,
|
||||
cancel, on_reasoning)
|
||||
|
||||
|
||||
class CoreToolRuntime:
|
||||
""":class:`ToolRuntimePort` over ``core/tools.py`` + Cowork's file tools."""
|
||||
|
||||
def __init__(self, output_dir: Path, *, title: str = "",
|
||||
extra_tools: Optional[Sequence[Any]] = None, extra_executor=None,
|
||||
security_config: Any = None, agent_role: str = "") -> None:
|
||||
self._output_dir = Path(output_dir)
|
||||
self._title = title
|
||||
self._extra_tools = list(extra_tools or ())
|
||||
self._extra_names = {getattr(t, "name", "") for t in self._extra_tools}
|
||||
# The connector executor MCP/REST tools are routed to; None when the
|
||||
# turn has no connectors enabled.
|
||||
self._extra_executor = extra_executor
|
||||
self._security_config = security_config
|
||||
self._agent_role = agent_role
|
||||
self._ctx: Any = None # built on first use (see _tool_context)
|
||||
|
||||
# -- the configured extra tools, for the system-prompt hints ---------- #
|
||||
@property
|
||||
def extra_names(self) -> frozenset:
|
||||
return frozenset(self._extra_names)
|
||||
|
||||
def _tool_context(self):
|
||||
"""The sandboxed ``ToolContext`` every built-in tool call runs inside.
|
||||
|
||||
Built once per turn and cached: it carries the resource limits and the
|
||||
network policy, so re-deriving it mid-turn could let a Settings change
|
||||
take effect halfway through work already in flight.
|
||||
"""
|
||||
if self._ctx is None:
|
||||
from ...core import agent_security
|
||||
from ...core.tools import ToolContext
|
||||
|
||||
limits, block_network = agent_security.sandbox_settings(self._security_config)
|
||||
self._ctx = ToolContext(
|
||||
self._output_dir, flatten_writes=True, # keep every file in the Output root
|
||||
resource_limits=limits, block_network=block_network,
|
||||
allow_url_fetch=agent_security.url_fetch_allowed(self._security_config),
|
||||
jira=(self._security_config.data.get("jira") if self._security_config else None),
|
||||
)
|
||||
return self._ctx
|
||||
|
||||
# -- ToolRuntimePort -------------------------------------------------- #
|
||||
def specs(self, allowed_tools: Optional[Sequence[str]] = None) -> List[Any]:
|
||||
"""Advertised tools: Cowork's own two, the enabled built-ins, then MCP.
|
||||
|
||||
``allowed_tools`` restricts the list so a read-only step literally cannot
|
||||
write. ``update_plan`` and the connector tools always survive the filter:
|
||||
the plan tool has no side effects, and connectors are opted into
|
||||
explicitly rather than governed by the built-in capability scope.
|
||||
"""
|
||||
from ...core.chat_agent import SAVE_FILE_SPEC
|
||||
from ...core.plan import UPDATE_PLAN_SPEC
|
||||
from ...core.tools import enabled_tool_specs
|
||||
|
||||
specs = ([SAVE_FILE_SPEC, UPDATE_PLAN_SPEC]
|
||||
+ list(enabled_tool_specs(self._security_config))
|
||||
+ self._extra_tools)
|
||||
if allowed_tools is None:
|
||||
return specs
|
||||
allow = set(allowed_tools) | {PLAN_TOOL} | self._extra_names
|
||||
return [t for t in specs if getattr(t, "name", "") in allow]
|
||||
|
||||
def preview(self, name: str, args: Dict[str, Any]) -> Optional[ToolPreview]:
|
||||
"""What the user sees before the call runs."""
|
||||
# A connector call has no local diff to show, so it renders as the plain
|
||||
# argument dump the runtime already used.
|
||||
if name in self._extra_names:
|
||||
return ToolPreview(kind="info", title=name, text=str(args))
|
||||
if name == "save_file":
|
||||
return self._save_file_preview(args)
|
||||
from ...core.tools import describe_action
|
||||
|
||||
raw = describe_action(self._tool_context(), name, args)
|
||||
return ToolPreview.from_dict(raw)
|
||||
|
||||
def _save_file_preview(self, args: Dict[str, Any]) -> ToolPreview:
|
||||
"""A before/after diff for the file the agent is about to write.
|
||||
|
||||
A brand-new file renders all-green (before is empty); an overwrite shows
|
||||
the real change, so saving a file reads like editing one.
|
||||
"""
|
||||
import difflib
|
||||
|
||||
from ...core.chat_agent import _structure_summary, _titled_filename
|
||||
|
||||
fname = _titled_filename(self._title, args.get("filename", "output.txt"))
|
||||
content = str(args.get("content", ""))
|
||||
summary = _structure_summary(fname, content)
|
||||
old = ""
|
||||
existing = self._output_dir / fname
|
||||
if existing.exists():
|
||||
try:
|
||||
old = existing.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
pass # unreadable existing file: show it as a fresh write
|
||||
diff = "".join(difflib.unified_diff(
|
||||
old.splitlines(keepends=True), content.splitlines(keepends=True),
|
||||
fromfile=f"a/{fname}", tofile=f"b/{fname}",
|
||||
)) or content[:4000]
|
||||
return ToolPreview(kind="diff", title=f"Save {fname}",
|
||||
text=f"{summary}\n\n{diff[:4000]}")
|
||||
|
||||
def execute(self, name: str, args: Dict[str, Any], on_output=None,
|
||||
cancel=None) -> Dict[str, Any]:
|
||||
"""Run one tool call and return the runtime's result mapping."""
|
||||
if name == PLAN_TOOL:
|
||||
return self._execute_plan(args)
|
||||
if name in self._extra_names and self._extra_executor is not None:
|
||||
# Connector results carry no local file, so no path/produced keys —
|
||||
# matching what the runtime reports for an MCP call today.
|
||||
result = self._extra_executor(name, args) or {}
|
||||
return {"ok": bool(result.get("ok", False)), "output": result.get("output", "")}
|
||||
if name == "save_file":
|
||||
from ...core.chat_agent import _do_save_file
|
||||
|
||||
return dict(_do_save_file(self._output_dir, self._title, args))
|
||||
|
||||
from ...core.tools import execute_tool
|
||||
|
||||
ctx = self._tool_context()
|
||||
result = dict(execute_tool(ctx, name, args, cancel=cancel, on_output=on_output,
|
||||
agent_role=self._agent_role))
|
||||
# Quirk preserved: a tool that wrote the file named in its OWN arguments
|
||||
# (write_file/edit_file) does not report a path, so the runtime derives
|
||||
# one from the argument. Dropping this would empty the Output list.
|
||||
if not result.get("path") and isinstance(args, dict) and args.get("path"):
|
||||
result["path"] = str(ctx.workdir / str(args["path"]))
|
||||
return result
|
||||
|
||||
def _execute_plan(self, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Apply an ``update_plan`` call: validate the steps and audit them.
|
||||
|
||||
Produces no file and no chat bubble; the service turns the returned
|
||||
steps into a single plan event.
|
||||
"""
|
||||
from ...core import agent_roles, audit_log
|
||||
from ...core.plan import normalize_plan_steps
|
||||
|
||||
steps = normalize_plan_steps(args.get("steps"))
|
||||
audit_log.record("tool_call", PLAN_TOOL, True, f"{len(steps)} step(s)",
|
||||
agent_role=agent_roles.PLANNER)
|
||||
return {"ok": True, "output": "Plan updated.",
|
||||
"plan_steps": [PlanStep(title=s["title"], status=s["status"]) for s in steps]}
|
||||
|
||||
def snapshot(self) -> Any:
|
||||
from ...core.tools import _snapshot
|
||||
|
||||
return _snapshot(self._output_dir)
|
||||
|
||||
def finalize(self, before: Any, cancelled: bool = False
|
||||
) -> Tuple[List[str], List[str]]:
|
||||
"""Drop the scratch sandbox and flatten deliverables into the root.
|
||||
|
||||
Returns ``(gone, arrived)``: a file that MOVED counts as both, because
|
||||
the Output list keys entries by path and must drop the old one.
|
||||
"""
|
||||
from ...core.chat_agent import _cleanup_cowork_intermediates
|
||||
|
||||
removed, moved = _cleanup_cowork_intermediates(self._output_dir, before,
|
||||
cancelled=cancelled)
|
||||
gone = list(removed) + [old for old, _new in moved]
|
||||
arrived = [new for _old, new in moved]
|
||||
return gone, arrived
|
||||
|
||||
|
||||
def build_cowork_conversation_service(
|
||||
provider: Any,
|
||||
output_dir: Path,
|
||||
emit: LegacyEmit,
|
||||
*,
|
||||
title: str = "",
|
||||
project_context: str = "",
|
||||
extra_tools: Optional[Sequence[Any]] = None,
|
||||
extra_executor=None,
|
||||
security_config: Any = None,
|
||||
gate: Any = None,
|
||||
agent_role: str = "",
|
||||
) -> ConversationApplicationService:
|
||||
"""A service wired to the real runtime, ready to execute a Cowork turn.
|
||||
|
||||
``emit`` is the legacy dict callback: the guards and the compactor publish
|
||||
their own notices through it directly (exactly as they do now), while the
|
||||
service's typed events reach it via :func:`legacy_event_sink`.
|
||||
|
||||
``gate`` present means the workspace asked to confirm commands; pass the
|
||||
request with ``gate_mode="confirm"`` so the two agree. A gate of ``None``
|
||||
keeps the pre-existing auto-run behaviour.
|
||||
"""
|
||||
from ...core import agent_roles
|
||||
|
||||
tools = CoreToolRuntime(
|
||||
output_dir, title=title, extra_tools=extra_tools, extra_executor=extra_executor,
|
||||
security_config=security_config, agent_role=agent_role or agent_roles.COWORK,
|
||||
)
|
||||
|
||||
def prepare_prompt(messages: List[Dict[str, Any]], advertised: Tuple[str, ...]) -> None:
|
||||
"""Insert the system prompt, then fold in skills, rules and project text.
|
||||
|
||||
``advertised`` is unused on purpose: the runtime decides the MS365
|
||||
paragraph from the CONFIGURED connector tools, not from the subset a
|
||||
capability scope left advertised. Changing that changes the prompt the
|
||||
model sees, so it stays as-is here and belongs to R05's tool-policy work.
|
||||
"""
|
||||
from ...core.chat_agent import (
|
||||
COWORK_TOOL_PROMPT,
|
||||
OPENDATALOADER_PDF_PROMPT,
|
||||
_apply_project_context,
|
||||
_apply_security_rules,
|
||||
_apply_skills,
|
||||
)
|
||||
from ...core.deps import _can_pip
|
||||
from ...core.java_runtime import find_java
|
||||
from ...core.security_rules import load_rules
|
||||
from ...core.skills import active_skills_text
|
||||
|
||||
if not messages or messages[0].get("role") != "system":
|
||||
system = COWORK_TOOL_PROMPT
|
||||
if any(n.startswith("ms365_") for n in tools.extra_names):
|
||||
system += ("\nThe user has signed in to Microsoft 365 and enabled some ms365__* "
|
||||
"tools (Outlook / Teams / OneDrive / SharePoint / meeting transcripts, "
|
||||
"via the built-in MS365 MCP server). Use them whenever the request "
|
||||
"involves that data — don't say you can't access it.")
|
||||
if find_java() is not None and _can_pip():
|
||||
# Only advertise the Java-backed PDF extractor when BOTH the JVM
|
||||
# and pip are available, so the agent is never steered into a
|
||||
# command that cannot work on this machine.
|
||||
system += "\n\n" + OPENDATALOADER_PDF_PROMPT
|
||||
messages.insert(0, {"role": "system", "content": system})
|
||||
_apply_skills(messages, active_skills_text())
|
||||
_apply_security_rules(messages, load_rules())
|
||||
_apply_project_context(messages, project_context)
|
||||
|
||||
def prompt_guard(messages: List[Dict[str, Any]]) -> None:
|
||||
from ...core import agent_security
|
||||
|
||||
agent_security.enforce_prompt(provider, messages, security_config, emit)
|
||||
|
||||
def command_guard(name: str, args: Dict[str, Any]) -> None:
|
||||
from ...core import agent_security
|
||||
|
||||
agent_security.enforce_command(provider, name, args, security_config, emit)
|
||||
|
||||
def compact(messages: List[Dict[str, Any]], cancel) -> None:
|
||||
from ...core import context_budget
|
||||
|
||||
context_budget.maybe_compact(provider, messages, security_config,
|
||||
emit=emit, cancel=cancel)
|
||||
|
||||
return ConversationApplicationService(
|
||||
CoreModelCall(provider), tools,
|
||||
prepare_prompt=prepare_prompt,
|
||||
prompt_guard=prompt_guard,
|
||||
command_guard=command_guard,
|
||||
compact=compact,
|
||||
permission_request=(gate.request if gate is not None else None),
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"LegacyEmit", "legacy_event_sink", "CoreModelCall", "CoreToolRuntime",
|
||||
"build_cowork_conversation_service",
|
||||
]
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Turn the Cowork widget's captured state into a request (R04-T04).
|
||||
|
||||
``ui/cowork_tab.py::build_job`` reads a dozen values off the widget on the UI
|
||||
thread and has to translate three of them before a turn can run: which message
|
||||
is this turn's prompt, which messages are its history, and whether the workspace
|
||||
wants commands confirmed. Those rules lived inline in the widget, where no test
|
||||
could reach them — and each fails silently when wrong (a duplicated user message,
|
||||
or a command that quietly stops asking for approval).
|
||||
|
||||
They live here instead, as the mapping step the migration map assigns to the
|
||||
application layer. The widget keeps only what is genuinely widget-specific:
|
||||
reading its own state and building the provider.
|
||||
|
||||
Layer rules (``docs/architecture/ADR-001-layered-architecture.md``): pure Python.
|
||||
Everything arrives as a plain value, so this module never sees a widget.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional, Sequence
|
||||
|
||||
from ...domain.agents.conversation_execution_request import ConversationExecutionRequest
|
||||
|
||||
|
||||
def build_cowork_turn_request(
|
||||
*,
|
||||
turn_id: str,
|
||||
session_id: str,
|
||||
messages: Sequence[Dict[str, Any]],
|
||||
surface: str = "cowork",
|
||||
project_id: str = "",
|
||||
title: str = "",
|
||||
provider_id: str = "",
|
||||
model: str = "",
|
||||
instructions: str = "",
|
||||
output_dir: Optional[Any] = None,
|
||||
home_output_root: Optional[Any] = None,
|
||||
confirm_commands: bool = False,
|
||||
agent_role: str = "cowork",
|
||||
) -> ConversationExecutionRequest:
|
||||
"""Build one Cowork turn's immutable request.
|
||||
|
||||
``messages`` is the widget's working list, which ALREADY ends with this
|
||||
turn's user message (the chat panel composes it — prefix, attachments,
|
||||
session notes — before the job starts). So the prompt is that last message
|
||||
and the history is everything before it. The request records both; the
|
||||
service is handed the same working list and appends into it.
|
||||
|
||||
Keyword-only on purpose: a dozen positional strings in a call site is exactly
|
||||
how a title ends up in the project-id slot.
|
||||
"""
|
||||
history = list(messages or ())
|
||||
# ``pop`` rather than ``[-1]``/``[:-1]`` so the empty-list case needs no
|
||||
# special branch: a turn with nothing in it yields an empty prompt instead of
|
||||
# raising IndexError deep inside a worker thread.
|
||||
last = history.pop() if history else {}
|
||||
return ConversationExecutionRequest(
|
||||
turn_id=turn_id,
|
||||
session_id=session_id,
|
||||
surface=surface,
|
||||
project_id=project_id,
|
||||
title=title,
|
||||
prompt=str(last.get("content") or ""),
|
||||
messages=history,
|
||||
provider_id=provider_id,
|
||||
model=model,
|
||||
project_context=instructions,
|
||||
output_dir=output_dir,
|
||||
home_output_root=home_output_root,
|
||||
# The workspace's Auto-run override (or the global setting) decides
|
||||
# whether run_command/install_package must be approved first.
|
||||
gate_mode="confirm" if confirm_commands else "auto",
|
||||
agent_role=agent_role,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["build_cowork_turn_request"]
|
||||
@@ -0,0 +1,177 @@
|
||||
"""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",
|
||||
]
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Application model routing package: model route decisions and multi-provider balancing.
|
||||
|
||||
Public surface (R03-T03 — the single routing entry point every chat surface uses):
|
||||
|
||||
* :class:`RoutingApplicationService` — decides one turn's provider/model.
|
||||
* :class:`RoutingRequest` / :class:`RoutingOutcome` — the immutable DTOs in and out.
|
||||
* :class:`RoutingMode` — Off / Auto / Manual / Fallback.
|
||||
* :func:`build_routing_application_service` — wires the service to a live
|
||||
``AppContext`` (engine + per-workspace mode + confirm timeout).
|
||||
|
||||
Typical call site (see ``ui/chat_panel.py::_apply_routing``)::
|
||||
|
||||
service = build_routing_application_service(self.ctx)
|
||||
outcome = service.resolve(
|
||||
RoutingRequest(surface="cowork", prompt=text,
|
||||
current_provider=provider, current_model=model),
|
||||
confirm=lambda decision, timeout: confirm_switch(self, decision, timeout),
|
||||
)
|
||||
|
||||
Only ``core_routing_adapter`` touches ``core/routing``; the service and the DTOs
|
||||
stay pure Python so the whole rule set is testable without Qt or the engine.
|
||||
"""
|
||||
|
||||
from .core_routing_adapter import (
|
||||
AppContextModeResolver,
|
||||
CoreRoutingEngine,
|
||||
build_routing_application_service,
|
||||
)
|
||||
from .routing_application_service import (
|
||||
ConfirmationCallback,
|
||||
ModeResolver,
|
||||
RoutingApplicationService,
|
||||
RoutingDecisionPort,
|
||||
)
|
||||
from .routing_models import (
|
||||
RouteEvaluation,
|
||||
RoutingMode,
|
||||
RoutingOutcome,
|
||||
RoutingRequest,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AppContextModeResolver",
|
||||
"ConfirmationCallback",
|
||||
"CoreRoutingEngine",
|
||||
"ModeResolver",
|
||||
"RouteEvaluation",
|
||||
"RoutingApplicationService",
|
||||
"RoutingDecisionPort",
|
||||
"RoutingMode",
|
||||
"RoutingOutcome",
|
||||
"RoutingRequest",
|
||||
"build_routing_application_service",
|
||||
]
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Adapters that plug the existing routing engine into the application service.
|
||||
|
||||
:mod:`routing_application_service` is written against two narrow ports so it can
|
||||
be unit-tested with plain fakes. This module supplies the real implementations —
|
||||
the assessment/scoring engine in ``core/routing`` and the per-workspace mode
|
||||
lookup on ``AppContext`` — and is therefore the ONLY file in
|
||||
``application/model_routing/`` that knows those concrete types exist.
|
||||
|
||||
All engine imports are deferred into method bodies. Importing the routing stack
|
||||
pulls in Pydantic models and the on-disk assessment store, and the UI must be
|
||||
able to import this module during startup without paying that cost (the same
|
||||
lazy-wiring reason ``state.py::AppContext.routing`` gives).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
from .routing_application_service import RoutingApplicationService
|
||||
from .routing_models import RouteEvaluation, RoutingMode, RoutingRequest
|
||||
|
||||
logger = logging.getLogger("cowork_local.application.model_routing")
|
||||
|
||||
|
||||
class CoreRoutingEngine:
|
||||
""":class:`RoutingDecisionPort` backed by ``core/routing/service.py``.
|
||||
|
||||
Translates in both directions: application DTOs in, and the engine's
|
||||
``RouteResult``/``SwitchDecision``/``TaskType`` flattened back out into a
|
||||
:class:`RouteEvaluation`, so no ``core.routing`` type ever escapes into the
|
||||
application service or the UI call sites.
|
||||
"""
|
||||
|
||||
def __init__(self, routing_service: Any) -> None:
|
||||
self._routing_service = routing_service
|
||||
|
||||
def evaluate(self, request: RoutingRequest, mode: RoutingMode) -> RouteEvaluation:
|
||||
"""Rank candidates for this turn and report the engine's verdict."""
|
||||
from ...core.routing.models import TaskType, candidate_key
|
||||
|
||||
result = self._routing_service.route(
|
||||
request.surface,
|
||||
request.prompt,
|
||||
request.current_provider,
|
||||
request.current_model,
|
||||
# The engine only knows off/auto/manual; FALLBACK was already mapped
|
||||
# to AUTO upstream so the value handed over here is always valid.
|
||||
mode_override=mode.value,
|
||||
required_capabilities=list(request.required_capabilities) or None,
|
||||
task_type=self._parse_task_type(request.task_type, TaskType),
|
||||
)
|
||||
|
||||
decision = result.decision
|
||||
target = result.target() # (provider, model_id) or None
|
||||
current_key = (
|
||||
candidate_key(request.current_provider, request.current_model)
|
||||
if request.current_model
|
||||
else ""
|
||||
)
|
||||
return RouteEvaluation(
|
||||
task_type=self._task_type_value(result.task_type),
|
||||
should_switch=bool(result.should_switch),
|
||||
target_provider=target[0] if target else None,
|
||||
target_model=target[1] if target else None,
|
||||
score_gain=float(getattr(decision, "score_gain", 0.0) or 0.0),
|
||||
reason=str(getattr(decision, "reason", "") or ""),
|
||||
current_is_usable=self._current_is_usable(result, current_key),
|
||||
decision=decision,
|
||||
)
|
||||
|
||||
# -- translation helpers --------------------------------------------- #
|
||||
@staticmethod
|
||||
def _parse_task_type(raw: Optional[str], task_type_enum) -> Optional[Any]:
|
||||
"""Coerce a task-type string to the engine's enum.
|
||||
|
||||
``None`` (the common case) means "let the engine classify the prompt".
|
||||
An unrecognised string is also downgraded to ``None`` rather than
|
||||
raising, so a stale value in a saved workspace cannot break a turn.
|
||||
"""
|
||||
if raw is None:
|
||||
return None
|
||||
if isinstance(raw, task_type_enum):
|
||||
return raw
|
||||
try:
|
||||
return task_type_enum(str(raw).strip().lower())
|
||||
except ValueError:
|
||||
logger.warning("routing: unknown task type %r — classifying from the prompt", raw)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _task_type_value(task_type: Any) -> str:
|
||||
"""The plain string form of the engine's task type enum."""
|
||||
return str(getattr(task_type, "value", task_type) or "")
|
||||
|
||||
@staticmethod
|
||||
def _current_is_usable(result: Any, current_key: str) -> bool:
|
||||
"""Whether the currently selected model can still serve this task.
|
||||
|
||||
This is the signal FALLBACK mode acts on. A model is usable when the
|
||||
ranking scored it above zero; ``rank_models`` already drops candidates
|
||||
that are unavailable, lack a probe for this task type, or failed their
|
||||
last probe, so "absent from the ranking" is precisely "cannot serve it".
|
||||
|
||||
With no ranking (routing off, or the engine's internal error path) or no
|
||||
current model, we answer True: absence of evidence must not trigger a
|
||||
surprise switch in a mode whose whole promise is not to surprise.
|
||||
"""
|
||||
ranking = getattr(result, "ranking", None)
|
||||
if ranking is None or not current_key:
|
||||
return True
|
||||
try:
|
||||
return float(ranking.score_of(current_key)) > 0.0
|
||||
except Exception: # noqa: BLE001 — defensive: never fail a turn on telemetry-ish data
|
||||
logger.debug("routing: could not score current model %r", current_key, exc_info=True)
|
||||
return True
|
||||
|
||||
|
||||
class AppContextModeResolver:
|
||||
""":class:`ModeResolver` backed by the active workspace's settings.
|
||||
|
||||
Reads through ``AppContext.project_routing_mode``, which already layers the
|
||||
workspace override on top of the global default — so per-workspace routing
|
||||
modes keep working unchanged now that the mode lookup moved out of the
|
||||
widgets.
|
||||
"""
|
||||
|
||||
def __init__(self, ctx: Any) -> None:
|
||||
self._ctx = ctx
|
||||
|
||||
def mode_for(self, surface: str) -> RoutingMode:
|
||||
"""Effective mode for ``surface`` in the active workspace."""
|
||||
return RoutingMode.parse(self._ctx.project_routing_mode(surface))
|
||||
|
||||
|
||||
def build_routing_application_service(ctx: Any) -> RoutingApplicationService:
|
||||
"""The shared :class:`RoutingApplicationService` for this app context.
|
||||
|
||||
Cached on the context (like ``AppContext.routing()`` caches the engine) so
|
||||
every surface talks to the same instance and a future stateful addition —
|
||||
per-surface cool-down, switch history — is shared rather than duplicated per
|
||||
widget. Falls back to a fresh instance if the context refuses attribute
|
||||
assignment, which keeps tests using lightweight stand-ins working.
|
||||
"""
|
||||
cached = getattr(ctx, "_routing_app_service", None)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
service = RoutingApplicationService(
|
||||
CoreRoutingEngine(ctx.routing()),
|
||||
AppContextModeResolver(ctx),
|
||||
# Read at call time: the user can change the confirm timeout in Settings
|
||||
# between two turns and the next Manual dialog should honour it.
|
||||
confirm_timeout_sec=lambda: float(
|
||||
(ctx.config.routing or {}).get("confirm_timeout_sec", 60) or 60
|
||||
),
|
||||
)
|
||||
try:
|
||||
ctx._routing_app_service = service
|
||||
except Exception: # noqa: BLE001 — read-only/slotted stand-ins stay supported
|
||||
logger.debug("routing: could not cache the application service on the context", exc_info=True)
|
||||
return service
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AppContextModeResolver",
|
||||
"CoreRoutingEngine",
|
||||
"build_routing_application_service",
|
||||
]
|
||||
@@ -0,0 +1,236 @@
|
||||
"""The one place that decides how a turn is routed (R03-T03).
|
||||
|
||||
Before this service, ``ui/chat_panel.py#L638``, ``ui/co4e_tab.py`` and
|
||||
``ui/folder_tab.py`` each carried their own copy of the same eight-step dance:
|
||||
clear last turn's override → read the surface's mode → bail on "off" → call the
|
||||
routing engine → check ``should_switch`` → resolve the target → show the Manual
|
||||
confirm dialog → publish the override and a status line. Three copies meant
|
||||
three chances to drift, and none of them could be tested without a Qt widget.
|
||||
|
||||
The dance now lives here, once, in pure Python:
|
||||
|
||||
* the routing engine is reached through :class:`RoutingDecisionPort`;
|
||||
* the surface's Off/Auto/Manual/Fallback mode through :class:`ModeResolver`;
|
||||
* the Manual-mode confirmation through a ``confirm`` callback supplied per call,
|
||||
so the Qt dialog stays in the presentation layer where it belongs.
|
||||
|
||||
Every failure path degrades to "keep the current model": a routing problem must
|
||||
never be the reason a user cannot send a message.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Callable, Optional, Protocol, runtime_checkable
|
||||
|
||||
from .routing_models import (
|
||||
RouteEvaluation,
|
||||
RoutingMode,
|
||||
RoutingOutcome,
|
||||
RoutingRequest,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("cowork_local.application.model_routing")
|
||||
|
||||
# Asks the user to approve a Manual-mode switch. Receives the underlying
|
||||
# decision object (for rendering) plus the timeout in seconds; returns True to
|
||||
# approve. Supplied by the caller so this module never imports a UI toolkit.
|
||||
ConfirmationCallback = Callable[[Any, float], bool]
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class RoutingDecisionPort(Protocol):
|
||||
"""The routing engine, as this service needs it.
|
||||
|
||||
Narrowed to a single method on purpose: the concrete engine
|
||||
(``core/routing/service.py::RoutingService``) exposes assessment,
|
||||
persistence and scheduling too, none of which a turn-time decision needs.
|
||||
"""
|
||||
|
||||
def evaluate(self, request: RoutingRequest, mode: RoutingMode) -> RouteEvaluation:
|
||||
"""Rank candidates for ``request`` and report whether to switch."""
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ModeResolver(Protocol):
|
||||
"""Resolves the effective routing mode for a surface.
|
||||
|
||||
In the app this reads the active workspace's per-surface override with the
|
||||
global default behind it (``AppContext.project_routing_mode``); in tests it
|
||||
is a two-line stub.
|
||||
"""
|
||||
|
||||
def mode_for(self, surface: str) -> RoutingMode:
|
||||
"""Effective mode for ``surface``."""
|
||||
|
||||
|
||||
class RoutingApplicationService:
|
||||
"""Turn-time routing decisions for every chat surface."""
|
||||
|
||||
# Matches DEFAULT_CONFIG["routing"]["confirm_timeout_sec"]; used only when
|
||||
# no timeout provider is wired, so a bare service is still usable in tests.
|
||||
DEFAULT_CONFIRM_TIMEOUT_SEC = 60.0
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
decision_port: RoutingDecisionPort,
|
||||
mode_resolver: Optional[ModeResolver] = None,
|
||||
*,
|
||||
confirm_timeout_sec: Optional[Callable[[], float]] = None,
|
||||
) -> None:
|
||||
self._decision_port = decision_port
|
||||
self._mode_resolver = mode_resolver
|
||||
# A callable rather than a number: the timeout lives in mutable config
|
||||
# the user can change in Settings between two turns.
|
||||
self._confirm_timeout_sec = confirm_timeout_sec
|
||||
|
||||
# -- public API ------------------------------------------------------ #
|
||||
def resolve(
|
||||
self,
|
||||
request: RoutingRequest,
|
||||
confirm: Optional[ConfirmationCallback] = None,
|
||||
) -> RoutingOutcome:
|
||||
"""Decide this turn's provider/model.
|
||||
|
||||
Returns a :class:`RoutingOutcome`; ``provider``/``model`` are ``None``
|
||||
whenever the surface should keep its own selection. Never raises — an
|
||||
unexpected failure is logged and reported as "keep current", because a
|
||||
broken assessment store must not block chatting.
|
||||
"""
|
||||
mode = request.mode or self._resolve_mode(request.surface)
|
||||
try:
|
||||
return self._resolve_unguarded(request, mode, confirm)
|
||||
except Exception: # noqa: BLE001 — routing must never break a turn
|
||||
logger.exception("routing.resolve failed — keeping the current model")
|
||||
return RoutingOutcome.keep_current(mode, reason="routing error — keeping current model")
|
||||
|
||||
def confirm_timeout(self) -> float:
|
||||
"""Seconds to wait for a Manual-mode confirmation.
|
||||
|
||||
Falls back to the built-in default when the provider is missing or
|
||||
returns something unusable, so a corrupted config value cannot produce a
|
||||
zero-second dialog that instantly declines every switch.
|
||||
"""
|
||||
if self._confirm_timeout_sec is None:
|
||||
return self.DEFAULT_CONFIRM_TIMEOUT_SEC
|
||||
try:
|
||||
value = float(self._confirm_timeout_sec())
|
||||
except (TypeError, ValueError):
|
||||
return self.DEFAULT_CONFIRM_TIMEOUT_SEC
|
||||
return value if value > 0 else self.DEFAULT_CONFIRM_TIMEOUT_SEC
|
||||
|
||||
# -- internals ------------------------------------------------------- #
|
||||
def _resolve_mode(self, surface: str) -> RoutingMode:
|
||||
"""The surface's configured mode, defaulting to OFF when unresolvable —
|
||||
routing stays opt-in, so "we don't know" must mean "don't switch"."""
|
||||
if self._mode_resolver is None:
|
||||
return RoutingMode.OFF
|
||||
try:
|
||||
return RoutingMode.parse(self._mode_resolver.mode_for(surface))
|
||||
except Exception: # noqa: BLE001 — a config read must not break a turn
|
||||
logger.exception("routing: could not resolve mode for surface %r", surface)
|
||||
return RoutingMode.OFF
|
||||
|
||||
def _resolve_unguarded(
|
||||
self,
|
||||
request: RoutingRequest,
|
||||
mode: RoutingMode,
|
||||
confirm: Optional[ConfirmationCallback],
|
||||
) -> RoutingOutcome:
|
||||
"""The decision flow proper; :meth:`resolve` owns the safety net."""
|
||||
# 1. Routing disabled, or nothing to classify -> keep the selection.
|
||||
if mode is RoutingMode.OFF:
|
||||
return RoutingOutcome.keep_current(mode, reason="routing off")
|
||||
if not request.has_prompt:
|
||||
return RoutingOutcome.keep_current(mode, reason="empty prompt — nothing to route")
|
||||
|
||||
# 2. Ask the engine. FALLBACK is evaluated with AUTO's ranking because
|
||||
# it needs the same candidate list; only the accept/reject rule below
|
||||
# differs, so the engine stays unaware of the extra mode.
|
||||
engine_mode = RoutingMode.AUTO if mode is RoutingMode.FALLBACK else mode
|
||||
evaluation = self._decision_port.evaluate(request, engine_mode)
|
||||
|
||||
# 3. Apply the mode's own accept rule to the engine's verdict.
|
||||
if mode is RoutingMode.FALLBACK:
|
||||
accepted, reason = self._fallback_verdict(evaluation)
|
||||
else:
|
||||
accepted, reason = evaluation.should_switch, evaluation.reason
|
||||
|
||||
if not accepted or not evaluation.has_target:
|
||||
return RoutingOutcome.keep_current(
|
||||
mode,
|
||||
reason=reason or evaluation.reason,
|
||||
task_type=evaluation.task_type,
|
||||
decision=evaluation.decision,
|
||||
)
|
||||
|
||||
# 4. Manual mode asks first; a decline or a timeout keeps the current
|
||||
# model (and is reported as such, so the surface can tell the two
|
||||
# cases apart from "nothing better was found").
|
||||
if mode is RoutingMode.MANUAL and not self._approved(evaluation, confirm):
|
||||
return RoutingOutcome.keep_current(
|
||||
mode,
|
||||
reason="switch declined by user or confirmation timed out",
|
||||
task_type=evaluation.task_type,
|
||||
declined=True,
|
||||
decision=evaluation.decision,
|
||||
)
|
||||
|
||||
# 5. Publish the override for THIS turn only. The provider falls back to
|
||||
# the request's current provider when the engine named a model but no
|
||||
# provider (same-provider switch).
|
||||
return RoutingOutcome(
|
||||
mode=mode,
|
||||
switched=True,
|
||||
provider=evaluation.target_provider or request.current_provider,
|
||||
model=evaluation.target_model or "",
|
||||
task_type=evaluation.task_type,
|
||||
score_gain=evaluation.score_gain,
|
||||
reason=reason or evaluation.reason,
|
||||
decision=evaluation.decision,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _fallback_verdict(evaluation: RouteEvaluation) -> tuple:
|
||||
"""FALLBACK's accept rule: switch ONLY to rescue an unusable selection.
|
||||
|
||||
The user's pinned model wins as long as it can serve the turn, even when
|
||||
a higher-scoring candidate exists — that is the whole point of the mode.
|
||||
A switch happens only when the current model is not a usable candidate
|
||||
(never assessed, marked unavailable, or its last probe failed) and the
|
||||
engine has something to move to.
|
||||
"""
|
||||
if evaluation.current_is_usable:
|
||||
return False, "fallback mode — current model is healthy, keeping it"
|
||||
if not evaluation.has_target:
|
||||
return False, "fallback mode — current model unusable and no replacement available"
|
||||
return True, "fallback mode — current model unavailable, switching to the best alternative"
|
||||
|
||||
def _approved(
|
||||
self,
|
||||
evaluation: RouteEvaluation,
|
||||
confirm: Optional[ConfirmationCallback],
|
||||
) -> bool:
|
||||
"""Run the Manual-mode confirmation callback.
|
||||
|
||||
No callback means no way to ask, and silently switching in Manual mode
|
||||
would violate the mode's contract — so a missing callback is treated as
|
||||
"not approved". A callback that raises is treated the same way, since a
|
||||
broken dialog must not auto-approve a model change.
|
||||
"""
|
||||
if confirm is None:
|
||||
logger.warning("routing: manual mode without a confirmation callback — keeping current model")
|
||||
return False
|
||||
try:
|
||||
return bool(confirm(evaluation.decision, self.confirm_timeout()))
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("routing: confirmation callback failed — keeping current model")
|
||||
return False
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ConfirmationCallback",
|
||||
"ModeResolver",
|
||||
"RoutingApplicationService",
|
||||
"RoutingDecisionPort",
|
||||
]
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Pure-Python DTOs exchanged with :mod:`routing_application_service`.
|
||||
|
||||
These types are the vocabulary the chat surfaces (Cowork chat, Co4E, AI-Edit)
|
||||
now speak instead of each re-deriving routing state from raw config lookups and
|
||||
``core/routing`` internals.
|
||||
|
||||
Layer rules (``docs/architecture/ADR-001-layered-architecture.md``): application
|
||||
code is 100% pure Python. Nothing here imports PySide6, and nothing here imports
|
||||
``core.routing`` either — the concrete routing engine is reached only through
|
||||
the adapter in :mod:`core_routing_adapter`, which keeps this module trivially
|
||||
testable with plain fakes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any, Optional, Tuple
|
||||
|
||||
|
||||
class RoutingMode(str, Enum):
|
||||
"""The four routing behaviours a surface can be in (R03-T03).
|
||||
|
||||
``OFF``/``AUTO``/``MANUAL`` map 1:1 onto the existing per-surface toggle and
|
||||
onto ``core/routing/models.py::SwitchMode``. ``FALLBACK`` is new and
|
||||
deliberately NOT an optimisation mode: it keeps whatever model the user
|
||||
chose and only re-routes when that model cannot serve the turn, which is the
|
||||
behaviour a resilience-minded workspace wants (never surprise me, but never
|
||||
leave me stuck either).
|
||||
"""
|
||||
|
||||
OFF = "off"
|
||||
AUTO = "auto"
|
||||
MANUAL = "manual"
|
||||
FALLBACK = "fallback"
|
||||
|
||||
@classmethod
|
||||
def parse(cls, raw: Any, default: "RoutingMode" = None) -> "RoutingMode":
|
||||
"""Best-effort coercion from config/UI strings.
|
||||
|
||||
Routing must never break a turn, so an unrecognised value degrades to
|
||||
``default`` (``OFF`` unless told otherwise) instead of raising — the same
|
||||
defensive posture ``config.routing_mode_for`` already takes.
|
||||
"""
|
||||
fallback = default if default is not None else cls.OFF
|
||||
if isinstance(raw, cls):
|
||||
return raw
|
||||
try:
|
||||
return cls(str(raw or "").strip().lower())
|
||||
except ValueError:
|
||||
return fallback
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RoutingRequest:
|
||||
"""Everything needed to decide how ONE turn should be routed.
|
||||
|
||||
Frozen: the request is captured from live UI state (the selected model, the
|
||||
typed prompt) and then handed to code that may run on a worker thread. An
|
||||
immutable snapshot means the user changing the model picker mid-turn cannot
|
||||
retroactively alter the decision that was already made — the same rationale
|
||||
behind R04's ``ConversationExecutionRequest``.
|
||||
"""
|
||||
|
||||
surface: str # "cowork" | "co4e" | "ai_edit" | ...
|
||||
prompt: str # the user's text; drives task classification
|
||||
current_provider: str # provider the surface would use as-is
|
||||
current_model: str = "" # model the surface would use ("" = provider default)
|
||||
mode: Optional[RoutingMode] = None # explicit override; None -> resolve per surface
|
||||
# Pre-classified task type ("coding", "qa", ...). AI-Edit always knows its
|
||||
# turns are coding work, so it pins this and skips prompt classification.
|
||||
task_type: Optional[str] = None
|
||||
required_capabilities: Tuple[str, ...] = () # e.g. ("vision",)
|
||||
|
||||
@property
|
||||
def has_prompt(self) -> bool:
|
||||
"""Whether there is anything to classify. An empty prompt cannot be
|
||||
routed meaningfully, so every surface short-circuits on it."""
|
||||
return bool((self.prompt or "").strip())
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RouteEvaluation:
|
||||
"""A routing engine's verdict, normalised away from ``core/routing`` types.
|
||||
|
||||
The adapter flattens ``RouteResult``/``SwitchDecision`` into these plain
|
||||
fields so the application service never touches Pydantic models or enums
|
||||
owned by another layer. ``decision`` still carries the original object
|
||||
because the Manual-mode confirm dialog renders its ``reason``.
|
||||
"""
|
||||
|
||||
task_type: str
|
||||
should_switch: bool
|
||||
target_provider: Optional[str] = None
|
||||
target_model: Optional[str] = None
|
||||
score_gain: float = 0.0
|
||||
reason: str = ""
|
||||
# False when the currently selected model is not a usable candidate for this
|
||||
# task (unranked, unavailable, or failed its last probe) — the single signal
|
||||
# FALLBACK mode acts on.
|
||||
current_is_usable: bool = True
|
||||
decision: Any = None # original SwitchDecision, for the UI dialog
|
||||
|
||||
@property
|
||||
def has_target(self) -> bool:
|
||||
"""A switch is only actionable when the engine named a model to move to."""
|
||||
return bool(self.target_model or self.target_provider)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RoutingOutcome:
|
||||
"""What the calling surface should actually do for this turn.
|
||||
|
||||
A surface needs exactly three things from routing — "which provider/model do
|
||||
I build?", "do I tell the user?" and "was I told to stand down?" — so those
|
||||
are the fields here, and nothing else. ``provider``/``model`` are ``None``
|
||||
when the surface should keep its own selection untouched.
|
||||
"""
|
||||
|
||||
mode: RoutingMode
|
||||
switched: bool = False
|
||||
provider: Optional[str] = None
|
||||
model: Optional[str] = None
|
||||
task_type: str = ""
|
||||
score_gain: float = 0.0
|
||||
reason: str = ""
|
||||
# True when Manual mode proposed a switch and the user declined or the
|
||||
# confirmation timed out. Distinct from "no switch proposed" so a surface
|
||||
# can tell "routing had nothing to offer" from "the user said no".
|
||||
declined: bool = False
|
||||
decision: Any = field(default=None, repr=False)
|
||||
|
||||
@property
|
||||
def should_notify(self) -> bool:
|
||||
"""Whether the surface should post the "switched model" status bubble.
|
||||
Only an executed switch is worth interrupting the transcript for."""
|
||||
return self.switched
|
||||
|
||||
@classmethod
|
||||
def keep_current(
|
||||
cls,
|
||||
mode: RoutingMode,
|
||||
*,
|
||||
reason: str = "",
|
||||
task_type: str = "",
|
||||
declined: bool = False,
|
||||
decision: Any = None,
|
||||
) -> "RoutingOutcome":
|
||||
"""The no-change outcome — the single constructor for every path that
|
||||
leaves the surface's own model selection in place (routing off, empty
|
||||
prompt, no better candidate, user declined, internal error)."""
|
||||
return cls(
|
||||
mode=mode, switched=False, provider=None, model=None,
|
||||
task_type=task_type, reason=reason, declined=declined, decision=decision,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["RoutingMode", "RoutingRequest", "RouteEvaluation", "RoutingOutcome"]
|
||||
@@ -0,0 +1 @@
|
||||
"""Application scheduling package: TaskApplicationService and AI task planning."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Application settings package: Settings application service."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Application workflows package: Co4E graph execution orchestration."""
|
||||
@@ -0,0 +1,377 @@
|
||||
"""``Co4EWorkflowService`` — nửa "hành vi" tách ra từ ``Co4ERunManager`` cũ.
|
||||
|
||||
Bối cảnh: ``core/co4e_run_manager.py::Co4ERunManager`` là một ``QObject`` gộp
|
||||
chung dữ liệu run (nay là ``domain/workflows/run_record.py::RunRecord``), logic
|
||||
chạy job trên ``AgentWorker``/``QThread``, và logic đọc/ghi lịch sử ra đĩa. File
|
||||
này là phần còn lại sau khi tách DTO: quản lý vòng đời nhiều run cùng lúc, các
|
||||
hook nhận sự kiện từ worker, và lưu/nạp lịch sử — nhưng THUẦN PYTHON, không kế
|
||||
thừa ``QObject`` và không tự dựng ``QThread`` (``application/`` cấm PySide6).
|
||||
|
||||
Hai điều thay ``Signal`` cũ:
|
||||
* ``changed = Signal()`` -> danh sách callback ``self._changed_callbacks`` +
|
||||
``on_changed(cb)`` để đăng ký; mọi chỗ code cũ gọi ``self.changed.emit()``
|
||||
nay gọi ``self._emit_changed()``, gọi callback theo ĐÚNG thứ tự đã đăng ký.
|
||||
* ``event = Signal(str, dict)`` -> ``self._event_callbacks`` + ``on_event(cb)``,
|
||||
tương tự, thay ``self.event.emit(rid, ev)`` bằng ``self._emit_event(rid, ev)``.
|
||||
* ``self.changed.connect(self._save_history)`` (lớp cũ tự nối signal của
|
||||
chính nó vào slot riêng, trong ``__init__``) -> ở đây gọi thẳng
|
||||
``self._save_history()`` làm bước ĐẦU TIÊN bên trong ``_emit_changed()``,
|
||||
trước khi chạy các callback đã đăng ký từ bên ngoài. Chọn cách "gọi thẳng"
|
||||
(thay vì "đăng ký như callback đầu tiên") vì nó khớp với thứ tự nối cũ
|
||||
(``_save_history`` luôn được nối sớm nhất trong ``__init__`` nên luôn chạy
|
||||
trước mọi slot ngoài nối sau) mà không cần một danh sách callback nội bộ
|
||||
riêng chỉ để chứa đúng một phần tử cố định.
|
||||
|
||||
``start()`` KHÔNG tự tạo ``AgentWorker``/``QThread`` — nó nhận một ``runner``
|
||||
(``WorkflowRunner`` Protocol, mặc định ``None``) tiêm qua constructor. Adapter
|
||||
Qt thật (bọc ``AgentWorker`` — xem ``core/worker.py``) là việc của widget ở
|
||||
``presentation/``, không viết ở đây; test dùng fake chạy đồng bộ
|
||||
(``tests/fakes/fake_co4e_workflow_service.py`` hoặc fake cục bộ trong
|
||||
``tests/test_co4e_workflow_service.py``).
|
||||
|
||||
KHÔNG xoá/sửa ``core/co4e_run_manager.py`` — lớp cũ tiếp tục chạy song song
|
||||
cho tới khi widget Co4E Studio thật (``ui/co4e_tab.py``) chuyển hẳn sang dùng
|
||||
service này.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Callable, Dict, List, Optional, Protocol, Set
|
||||
|
||||
from ...core.co4e import CO4E_DIR, STEP_DONE, STEP_ERROR, STEP_PLANNED, Workflow, slugify, workflow_to_dict
|
||||
from ...domain.workflows.run_record import RunRecord
|
||||
|
||||
_TERMINAL_NODE = {STEP_DONE, STEP_ERROR, STEP_PLANNED}
|
||||
_HISTORY_CAP = 500 # giữ N run gần nhất trên đĩa
|
||||
|
||||
|
||||
def _now_str() -> str:
|
||||
return datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
|
||||
def _current_user() -> str:
|
||||
"""Best-effort creator name for a run (signed-in MS365 identity -> OS user)."""
|
||||
return os.environ.get("USERNAME") or os.environ.get("USER") or "you"
|
||||
|
||||
|
||||
# ---- ports (Protocol) — thay QThread thật bằng thứ tiêm được ---------------
|
||||
class RunnerJob(Protocol):
|
||||
"""Bề mặt tối thiểu mà job workflow cần từ 'worker' của nó.
|
||||
|
||||
Tương ứng ``AgentWorker.emit_event``/``AgentWorker.is_cancelled`` cũ
|
||||
(``core/worker.py``) — giữ nguyên chữ ký đó để hàm job bên trong
|
||||
``co4e_runner.run_workflow`` không phải đổi khi runner đứng sau là
|
||||
``AgentWorker``/``QThread`` thật (adapter ở presentation/) hay là fake
|
||||
đồng bộ trong test.
|
||||
"""
|
||||
|
||||
def emit_event(self, ev: dict) -> None: ...
|
||||
def is_cancelled(self) -> bool: ...
|
||||
|
||||
|
||||
class RunWorkerHandle(Protocol):
|
||||
"""Điều khiển một job đang chạy nền — tương ứng phần
|
||||
``AgentWorker.request_stop()`` cũ mà ``Co4ERunManager.stop()`` gọi."""
|
||||
|
||||
def request_stop(self) -> None: ...
|
||||
|
||||
|
||||
class WorkflowRunner(Protocol):
|
||||
"""Cổng chạy một job nền, tiêm qua constructor ``Co4EWorkflowService``.
|
||||
|
||||
Thay cho việc service tự ``AgentWorker(job); worker.start()`` (cần
|
||||
``QThread`` -> cấm ở ``application/``). Bên gọi ``start()`` truyền vào
|
||||
``job`` với đúng chữ ký cũ (``job(worker) -> Optional[dict]``); runner chịu
|
||||
trách nhiệm chạy nó (nền thật hay đồng bộ) và gọi lại ba callback tương ứng
|
||||
ba signal cũ của ``AgentWorker`` (``event``/``finished_ok``/``failed``).
|
||||
"""
|
||||
|
||||
def start(self, run_id: str, job: Callable[[RunnerJob], Optional[dict]],
|
||||
on_event: Callable[[dict], None],
|
||||
on_finished: Callable[[Optional[dict]], None],
|
||||
on_failed: Callable[[str], None]) -> RunWorkerHandle: ...
|
||||
|
||||
|
||||
class Co4EWorkflowService:
|
||||
"""Tầng application: vòng đời nhiều run Co4E cùng lúc, thuần Python.
|
||||
|
||||
Vai trò: đây là nơi ``build_co4e_tab(ctx, workflow_service)``
|
||||
(``presentation/co4e/co4e_tab.py``) sẽ lấy ``workflow_service`` thật một
|
||||
khi widget Co4E Studio được lắp lại để dùng nó — hiện widget thật
|
||||
(``ui/co4e_tab.py``) vẫn dùng ``Co4ERunManager`` cũ song song.
|
||||
"""
|
||||
|
||||
def __init__(self, ctx, *, history_path: Optional[Path] = None,
|
||||
runner: Optional[WorkflowRunner] = None):
|
||||
self.ctx = ctx
|
||||
self._runs: Dict[str, RunRecord] = {}
|
||||
self._worker_handles: Dict[str, RunWorkerHandle] = {}
|
||||
self._seq = 0
|
||||
self._output_root: Optional[Path] = None # thư mục output co4e của workspace đang chọn
|
||||
self._project_id: str = "" # workspace đang chọn — Flow Status lọc theo no
|
||||
self._runner = runner
|
||||
# DTO domain khong duoc cham dia (xem domain/workflows/run_record.py),
|
||||
# nen viec doc/ghi file lich su nam o day, tang application.
|
||||
self._history_path_value = (
|
||||
Path(history_path) if history_path is not None else (CO4E_DIR / "run_history.json")
|
||||
)
|
||||
self._changed_callbacks: List[Callable[[], None]] = []
|
||||
self._event_callbacks: List[Callable[[str, dict], None]] = []
|
||||
self._load_history() # khoi phuc lich su cu de Flow Status
|
||||
# giu du lich su qua cac lan restart
|
||||
|
||||
# ---- callback thay Signal ---------------------------------------------
|
||||
def on_changed(self, cb: Callable[[], None]) -> None:
|
||||
self._changed_callbacks.append(cb)
|
||||
|
||||
def on_event(self, cb: Callable[[str, dict], None]) -> None:
|
||||
self._event_callbacks.append(cb)
|
||||
|
||||
def _emit_changed(self) -> None:
|
||||
self._save_history() # xem docstring dau file: giu dung thu tu ban Qt cu
|
||||
for cb in self._changed_callbacks:
|
||||
cb()
|
||||
|
||||
def _emit_event(self, run_id: str, ev) -> None:
|
||||
for cb in self._event_callbacks:
|
||||
cb(run_id, ev)
|
||||
|
||||
# ---- persistence --------------------------------------------------
|
||||
# Doc/ghi thu cong (json.loads/write_text + tmp.replace), KHONG dung
|
||||
# AtomicJsonFile — ban dau file nay dung AtomicJsonFile.read(), nhung
|
||||
# review phat hien no doi hanh vi that so voi Co4ERunManager cu: gap
|
||||
# JSON hong, AtomicJsonFile.read() ĐOI TEN file hong thanh
|
||||
# "<ten>.bad-<timestamp>" (quarantine) roi moi tra ve mac dinh, trong
|
||||
# khi ban cu chi bat loi va ĐE NGUYEN file hong tai cho, khong dong gi
|
||||
# vao no. Day la mot thay doi quan sat duoc tren dia ma khong test nao
|
||||
# khoa lai va khong co comment bao truoc — Lam (N3) da quyet 24/08:
|
||||
# GIU HANH VI CU nguyen van (khong quarantine), vi day la buoc tach
|
||||
# chi duoc phep doi hanh vi khi da noi ra ro rang va co lưới an toan,
|
||||
# khong phai luc nay.
|
||||
def _load_history(self) -> None:
|
||||
try:
|
||||
data = json.loads(self._history_path_value.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return
|
||||
max_seq = 0
|
||||
for rec in data.get("runs", []):
|
||||
try:
|
||||
record = RunRecord.from_dict(rec)
|
||||
except Exception:
|
||||
continue
|
||||
if not record.id:
|
||||
continue
|
||||
self._runs[record.id] = record
|
||||
if record.id.startswith("run") and record.id[3:].isdigit():
|
||||
max_seq = max(max_seq, int(record.id[3:]))
|
||||
self._seq = max_seq # tranh sinh id trung voi lich su
|
||||
|
||||
def _save_history(self) -> None:
|
||||
runs = list(self._runs.values())[-_HISTORY_CAP:]
|
||||
payload = {"runs": [r.to_dict() for r in runs]}
|
||||
try:
|
||||
self._history_path_value.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = self._history_path_value.with_suffix(".json.tmp")
|
||||
tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8")
|
||||
tmp.replace(self._history_path_value) # atomic — khong bao gio de lai file ghi do dang
|
||||
except OSError:
|
||||
# Giu dung hanh vi cu (core/co4e_run_manager.py::_save_history):
|
||||
# mot lan luu that bai (day dia, mat quyen...) KHONG duoc phep
|
||||
# chan luong goi cua moi hook (_on_event/_on_finished/_on_failed)
|
||||
# dang di qua _emit_changed(). Bo try/except nay se lam mot loi
|
||||
# ghi dia lam vo ca luot xu ly su kien dang chay, chi vi lich su
|
||||
# khong luu duoc lan nay -- nguoi dung van thay Flow Status dung
|
||||
# trong phien hien tai, chi la ban ghi tren dia lui lai mot buoc.
|
||||
pass
|
||||
|
||||
# ---- lifecycle ----------------------------------------------------
|
||||
def _next_id(self) -> str:
|
||||
self._seq += 1
|
||||
return f"run{self._seq}"
|
||||
|
||||
def start(self, wf: Workflow, *, skill_map: Optional[Dict[str, str]] = None,
|
||||
plan_mode: bool = False, only_nodes: Optional[set] = None,
|
||||
seed_outputs: Optional[Dict[str, str]] = None,
|
||||
manual: bool = False, label: Optional[str] = None) -> str:
|
||||
"""Đăng ký một run mới và giao job cho ``self._runner`` (nếu có).
|
||||
|
||||
Không tự thực thi AI thật ở đây: khi ``self._runner`` là ``None``
|
||||
(mặc định), run được ghi nhận nhưng không job nào được giao đi — dùng
|
||||
cho test/khi chưa lắp adapter Qt thật.
|
||||
"""
|
||||
run_id = self._next_id()
|
||||
total = len(only_nodes) if only_nodes else len(wf.nodes)
|
||||
record = RunRecord(run_id, wf.id, label or wf.name, total, plan_mode, manual,
|
||||
created_by=_current_user(), created_at=_now_str(),
|
||||
project_id=self._project_id)
|
||||
# workflow_to_dict() tu dung dataclasses.asdict() de dung ca cay (node,
|
||||
# step, sub-agent) -> ban than no da la mot "deep copy" sang dict moi,
|
||||
# khong con giu tham chieu toi wf.nodes/wf.edges song. Vi vay KHONG can
|
||||
# deepcopy(wf) truoc nhu ban Qt cu (RunHandle.wf giu nguyen doi tuong
|
||||
# Workflow) -- xem doc string dau file domain/workflows/run_record.py
|
||||
# ve ly do snapshot o day la dict tho chu khong phai doi tuong.
|
||||
record.wf = workflow_to_dict(wf)
|
||||
nodes = list(wf.nodes)
|
||||
edges = list(wf.edges)
|
||||
out_dir = self._out_dir(wf)
|
||||
record.out_dir = str(out_dir)
|
||||
ctx = self.ctx
|
||||
sk = dict(skill_map or {})
|
||||
only: Optional[Set[str]] = set(only_nodes) if only_nodes else None
|
||||
seed = dict(seed_outputs or {})
|
||||
run_label = record.name
|
||||
self._runs[run_id] = record
|
||||
|
||||
if self._runner is not None:
|
||||
def job(worker: RunnerJob):
|
||||
from ...core import co4e_runner
|
||||
return co4e_runner.run_workflow(
|
||||
ctx, nodes, edges, out_dir, worker.emit_event, worker.is_cancelled,
|
||||
plan_mode=plan_mode, skill_map=sk, only_nodes=only, seed_outputs=seed,
|
||||
usage_label=run_label)
|
||||
|
||||
self._worker_handles[run_id] = self._runner.start(
|
||||
run_id, job,
|
||||
on_event=lambda ev, rid=run_id: self._on_event(rid, ev),
|
||||
on_finished=lambda _r=None, rid=run_id: self._on_finished(rid),
|
||||
on_failed=lambda e, rid=run_id: self._on_failed(rid, e),
|
||||
)
|
||||
self._emit_changed()
|
||||
return run_id
|
||||
|
||||
# ---- worker callbacks (goi tu runner, thay slot Qt cu) -----------------
|
||||
def _on_event(self, run_id: str, ev) -> None:
|
||||
record = self._runs.get(run_id)
|
||||
if record is not None and isinstance(ev, dict):
|
||||
t = ev.get("type")
|
||||
if t == "node_status":
|
||||
record.node_status[ev.get("node_id")] = ev.get("status")
|
||||
record.done = sum(1 for s in record.node_status.values() if s in _TERMINAL_NODE)
|
||||
self._emit_changed()
|
||||
elif t == "run_done":
|
||||
if record.status == "running":
|
||||
record.status = "done" if ev.get("ok", True) else "error"
|
||||
self._emit_changed()
|
||||
# quirk co y giu nguyen (xem test_on_event_unknown_run_id... trong ca
|
||||
# test cu lan test moi): re-emit VO DIEU KIEN, ke ca run_id la hoac ev
|
||||
# khong phai dict/None -- khac _on_finished/_on_failed la no-op hoan
|
||||
# toan khi run_id la.
|
||||
#
|
||||
# Khac biet CO CHU Y so voi ban Qt cu: Signal(str, dict) cua PySide6 ep
|
||||
# ev=None thanh {} khi giao cho slot (tac dung phu cua kieu Signal khai
|
||||
# bao cung). O day khong con Signal nen callback nhan DUNG gia tri ev
|
||||
# goc (None neu goi voi None) -- khong gia lap lai viec ep kieu do vi
|
||||
# no la tac dung phu cua Qt, khong phai quy tac nghiep vu can giu.
|
||||
self._emit_event(run_id, ev)
|
||||
|
||||
def _on_finished(self, run_id: str) -> None:
|
||||
record = self._runs.get(run_id)
|
||||
if record is not None and record.status == "running":
|
||||
# job returned without a run_done event (shouldn't happen) — settle it
|
||||
record.status = "done"
|
||||
self._emit_changed()
|
||||
|
||||
def _on_failed(self, run_id: str, err: str) -> None:
|
||||
record = self._runs.get(run_id)
|
||||
if record is not None:
|
||||
record.status = "error"
|
||||
record.error = str(err)
|
||||
self._emit_event(run_id, {"type": "run_error", "error": str(err)})
|
||||
self._emit_changed()
|
||||
|
||||
# ---- control --------------------------------------------------------
|
||||
def stop(self, run_id: str) -> None:
|
||||
record = self._runs.get(run_id)
|
||||
worker = self._worker_handles.get(run_id)
|
||||
if record is not None and worker is not None and record.running:
|
||||
worker.request_stop()
|
||||
record.status = "stopped"
|
||||
self._emit_changed()
|
||||
|
||||
def stop_all(self) -> None:
|
||||
# Only the CURRENT workspace's runs (Flow Status is per-project).
|
||||
for run_id in [r for r, rec in self._runs.items() if self._belongs(rec)]:
|
||||
self.stop(run_id)
|
||||
|
||||
def rename(self, run_id: str, new_name: str) -> None:
|
||||
"""Rename a run in the Flow Status history (and its kept workflow snapshot),
|
||||
then persist + refresh views. No-op on a blank name / unknown run."""
|
||||
record = self._runs.get(run_id)
|
||||
new_name = (new_name or "").strip()
|
||||
if record is None or not new_name or new_name == record.name:
|
||||
return
|
||||
record.name = new_name
|
||||
# DTO doi: RunHandle.wf cu la doi tuong Workflow (gan record.wf.name),
|
||||
# RunRecord.wf o day la dict tho (xem domain/workflows/run_record.py)
|
||||
# nen doi truc tiep khoa "name" cua dict thay vi thuoc tinh doi tuong.
|
||||
if record.wf is not None:
|
||||
record.wf["name"] = new_name
|
||||
self._emit_changed()
|
||||
|
||||
def remove(self, run_id: str) -> None:
|
||||
record = self._runs.get(run_id)
|
||||
if record is not None and record.running:
|
||||
self.stop(run_id)
|
||||
self._runs.pop(run_id, None)
|
||||
self._worker_handles.pop(run_id, None)
|
||||
self._emit_changed()
|
||||
|
||||
def clear_finished(self) -> None:
|
||||
# Only clear finished runs of the CURRENT workspace.
|
||||
for run_id in [r for r, rec in self._runs.items() if not rec.running and self._belongs(rec)]:
|
||||
self._runs.pop(run_id, None)
|
||||
self._worker_handles.pop(run_id, None)
|
||||
self._emit_changed()
|
||||
|
||||
# ---- queries ----------------------------------------------------------
|
||||
def _belongs(self, r: RunRecord) -> bool:
|
||||
"""Whether a run belongs to the currently-selected workspace."""
|
||||
return getattr(r, "project_id", "") == self._project_id
|
||||
|
||||
def runs(self) -> List[RunRecord]:
|
||||
"""Runs of the CURRENT workspace only — Flow Status is per-project."""
|
||||
return [r for r in self._runs.values() if self._belongs(r)]
|
||||
|
||||
def all_runs(self) -> List[RunRecord]:
|
||||
"""Every tracked run across all workspaces (background tracking)."""
|
||||
return list(self._runs.values())
|
||||
|
||||
def get(self, run_id: str) -> Optional[RunRecord]:
|
||||
return self._runs.get(run_id)
|
||||
|
||||
def active_count(self) -> int:
|
||||
return sum(1 for r in self._runs.values() if r.running and self._belongs(r))
|
||||
|
||||
def set_current_project(self, project_id: str) -> None:
|
||||
"""Filter Flow Status (and new runs) to this workspace. Runs started while
|
||||
this is set are tagged with it; the Runs view shows only matching runs."""
|
||||
pid = project_id or ""
|
||||
if pid != self._project_id:
|
||||
self._project_id = pid
|
||||
self._emit_changed() # re-render Flow Status for the new workspace
|
||||
|
||||
def set_output_root(self, root: Optional[Path]) -> None:
|
||||
"""Point flow outputs at the SELECTED workspace's co4e folder (set by the
|
||||
Co4E tab when a project is chosen). ``None`` → fall back to the global
|
||||
Cowork output dir."""
|
||||
self._output_root = Path(root) if root else None
|
||||
|
||||
def _out_dir(self, wf: Workflow) -> Path:
|
||||
# Flow deliverables are written into the SELECTED workspace (the active
|
||||
# project's folder) so they land where the user works with files (Folder
|
||||
# tab), not in the config/install folder. One subfolder per flow keeps
|
||||
# runs tidy. Falls back to the global Cowork output dir when no workspace
|
||||
# is selected.
|
||||
base = self._output_root
|
||||
if base is None:
|
||||
try:
|
||||
base = self.ctx.config.cowork_output_dir() / "co4e"
|
||||
except Exception: # noqa: BLE001 - fall back to the config dir if unavailable
|
||||
base = CO4E_DIR / "runs" / "co4e"
|
||||
d = Path(base) / slugify(wf.name or "flow")
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
@@ -0,0 +1 @@
|
||||
"""Application workspaces package: File workspace and AI file editor services."""
|
||||
Reference in New Issue
Block a user