Compare commits

..
Author SHA1 Message Date
vudt15andClaude Sonnet 5 8ab29800db docs(refactor): add the Team Hoa completion report for R05/R06
Mirrors docs/refactor/BaoCao_TeamDuy_R01_R03_R04.md's structure: per-EPIC
results, test evidence, the two real bugs found and fixed, secondary
improvements, open items needing another team's sign-off, untested scope,
and what's next.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 21:30:52 +09:00
vudt15andClaude Sonnet 5 cf542b7416 feat(R06): workspace session snapshot, atomic persistence, history-dir race fix
EPIC R06 (Team Hoa) - workspace/filesystem isolation, no cross-project
mutable state.

R06-T01 domain/workspaces/workspace_session.py
  WorkspaceSession - project_id/workspace_root/sandbox_dir/allowed_paths
  frozen snapshot + is_allowed(path), same "capture once at submit time"
  shape as R04's ConversationExecutionRequest.

R06-T02 infrastructure/persistence/json/{atomic_write,workspace_repository_impl,conversation_repository_impl}.py
  Real bug fixed: core/projects.py::save_project and core/history.py's
  save_conversation/rename_conversation/set_pinned did a plain
  path.write_text(json.dumps(...)) - two syscalls, no atomicity. A crash
  between them leaves a half-written file that load_project/load_conversation
  then silently treat as "missing". All four now write through
  atomic_write.write_json (temp file + os.replace). WorkspaceRepository/
  ConversationRepository are thin object-shaped facades over the same
  (now-atomic) functions, for future application-layer callers.
  NOTE: atomic_write.py is deliberately NOT named atomic_json_file.py -
  R02-T01 (Team Nam) claims that filename for the same purpose app-wide;
  see the checklist for the consolidation TODO.

R06-T03 infrastructure/filesystem/execution_workspace.py
  ExecutionWorkspace names the output_dir/scratch_dir split that already
  exists (core/chat_agent.py's flat workspace_root/.scratch) - does not
  move anything.

R06-T04 ui/chat_panel.py
  The actual race: ChatPanel._persist_session (saves a BACKGROUND turn's
  conversation) resolved its save directory via a live
  self.ctx.config.history_dir() read at save time. ui/workspace_tab.py::
  _load_current mutates that same config field on every project switch, so
  a turn still running when the user switched projects got saved into the
  NEW project's history folder. Fixed by adding "home_history_dir" to the
  per-turn ctx dict (same "home_*" snapshot convention already used for
  session id/messages/title), captured at submit time. Verified with a real
  offscreen-Qt test, not just a unit double:
  tests/integration/test_history_dir_race.py.

R06-T05 application/workspaces/file_workspace_service.py
  FileWorkspaceService - the File Explorer / AI Editor entry point for the
  same safe read/write/edit operations the agent tool loop has, by calling
  core/tools.py::execute_tool directly (same dispatch, same ToolContext
  containment, same audit log) rather than reimplementing any of it.

New tests: tests/unit/test_workspace_session.py,
test_atomic_write_and_repositories.py, test_execution_workspace.py,
test_file_workspace_service.py, tests/integration/test_history_dir_race.py
(29 new tests, incl. 2 real offscreen-Qt integration tests).

Suite: 283 passed, 4 pre-existing failures unrelated to R05/R06 (see
checklist). check_imports: PASS. All new files < 400 LOC.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 22:34:57 +09:00
vudt15andClaude Sonnet 5 ae4fe72b2e feat(R05): tool capability registry, unified policy gateway, MCP lifecycle manager
EPIC R05 (Team Hoa) - one security/approval path for every tool call.

R05-T01 domain/tools/{tool_descriptor,tool_registry}.py
  ToolCapability (READ/WRITE/EXECUTE/NETWORK, composable) + ToolDescriptor +
  ToolRegistry, replacing three independently-maintained gating lists
  (core/tools.py::WRITE_TOOLS, code_agent.py's WRITE_TOOLS|MS365_WRITE_TOOLS,
  chat_agent.py's literal ("run_command","install_package") tuple) with one
  capability lookup.

R05-T02 infrastructure/filesystem/{file_tools,command_tools,fetch_tools,tool_context}.py
  core/tools.py's execute_tool if/elif chain split into per-concern modules.
  core/tools.py is now a strangler-fig shim: re-exports ToolContext/ToolError,
  dispatches through a {name: handler} dict built from the split modules.
  core/tools.py: 566 -> 291 lines.

R05-T03 application/conversations/tool_policy_gateway.py
  ToolPolicyGateway.allow(name, gate, payload) - capability-driven ALLOW vs
  ask-the-gate decision. Wired into both chat_agent.py::run_cowork and
  code_agent.py::run_code, replacing their separate hand-rolled checks.
  Verified equivalent to the old hardcoded sets by test.

R05-T04 (behavior change, not just refactor)
  MCP/connector tools (core/mcp_client.py, core/ext_connectors.py) reached
  chat_agent.py via extra_executor(name, args) with NO permission check at
  all. They are now tagged with a conservative default capability
  (WRITE|EXECUTE|NETWORK - no MCP tool self-declares risk) and routed through
  the SAME ToolPolicyGateway as built-ins. When "confirm before running
  commands" is on, MCP/connector calls now prompt like run_command already
  did - a real gap closed, and a user-visible change worth calling out.

R05-T05 infrastructure/mcp/mcp_source_manager.py
  McpToolSourceManager extracts the connection cache/lock/start-or-skip
  lifecycle out of state.py::AppContext (_mcp_connections/_conn_lock) into a
  standalone, directly-testable class. AppContext.build_mcp_tools and
  _ms365_builtin_connection now call ensure()/stop(); _ext_connections
  (unified Connectors) is out of scope for this task and keeps its own lock.

New tests: tests/unit/test_tool_registry_and_policy.py,
test_code_agent_tool_policy.py, test_cowork_extra_tool_policy.py,
test_mcp_source_manager.py (26 new tests).

Suite: 254 passed, 4 pre-existing failures unrelated to R05 (2 EPIC R02
config-security, 2 environment-dependent routing tests - see checklist).
check_imports: PASS. All new files < 400 LOC.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 22:20:57 +09:00
anhtnm1andClaude Opus 5 6d3217e0b5 docs(refactor): add the Team Duy completion report for R01/R03/R04
docs/refactor/BaoCao_TeamDuy_R01_R03_R04.md records what was delivered against
each of the 16 tasks, the measured evidence (243 tests, 218 of them in 1.22s;
check_imports PASS; no production file over 400 LOC), the three real defects
found while working - the routing_application() deadlock, the swallowed
"notice" event, and the suite silently testing a different checkout - plus the
six open decisions and, explicitly, what was NOT tested (no manual app launch,
no real provider traffic, tools/check_*.py not run).

Refactoring_Checklist.md now links to it from the progress block.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 10:58:36 +09:00
anhtnm1andClaude Opus 5 67b8d2edbb docs(refactor): correct the Team Duy scope block in the checklist
The previous commit recorded Team Duy as owning R01/R02/R04/R10. That is wrong.
Feature_Architecture_Proposal.md line 7 and DeltaTeam_prompt.md line 17 both
state R01, R03, R04, R08 (Chat UI) and R10; R02 belongs to Team Nam, which is
also who owns the two failing config-security tests.

The completed work itself (R01, R03, R04) was already correct and is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 10:52:40 +09:00
anhtnm1andClaude Opus 5 15e1d3eb65 test(R03/R04): cover the three code paths that were changed but never executed
Verification gap closed. The suite proved the new services correct in isolation,
but three paths I had modified had no test actually running them:

tests/integration/test_task_executor_flow.py (7 tests)
  The Schedule Task path after R04-T05. Pins that History is still re-saved from
  the LIVE message list mid-run (the reason begin_turn() exists - the pre-turn
  copy would have frozen progress at the first user message), that update_plan
  tracking still reports an unfinished checklist, and that a failed run still
  raises so execute_task writes error.txt.

tests/integration/test_routing_surfaces.py (11 tests)
  Real offscreen CoworkTab/Co4ETab/FolderTab calling the shared routing service:
  correct surface key per screen, Auto switches, Off does not consult the engine,
  Manual switches only on approval, a pinned Admin agent still wins, and AI-Edit
  still pins TaskType.CODING. Also pins the field contract ui/routing_toggle.py
  reads off RoutingDecision (from_model/to_model as provider/model keys) - a
  rename there would only fail inside a modal dialog.

Also updates docs/refactor/Refactoring_Checklist.md: the 16 completed R01/R03/R04
tasks, the Team Duy daily rows, and a status block recording the measured
numbers, the scope correction (team owns R01/R02/R04/R10), and what is still
outstanding.

Suite: 243 passed, 2 pre-existing failures (EPIC R02). Fast suite (unit +
contracts + characterization + routing): 218 passed in 1.16s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 10:45:05 +09:00
anhtnm1andClaude Opus 5 a53163ebaf feat(R04): immutable turn snapshot, typed agent events, conversation service
EPIC R04 (Team Duy) - the turn lifecycle leaves the widget.

R04-T01 domain/agents/conversation_execution_request.py
  Frozen snapshot of one turn, captured on the UI thread at submit time. The
  job closure used to read widget/workspace state from inside the worker
  thread, so a turn could run on a mix of submit-time and later state
  depending on thread timing.
R04-T02 domain/agents/agent_event.py
  13 frozen event types replacing untyped emit() dicts, with a two-way bridge
  so existing widgets keep consuming the legacy shape until EPIC R08. Adds
  TurnCompletedEvent - the end-of-turn signal the engine never had, which is
  why a cancelled turn and a failed turn look identical to the UI today.
R04-T03 application/conversations/conversation_application_service.py
  Runs a turn from a request and reports typed events. Never raises across the
  worker boundary; TurnResult.raise_if_failed() preserves the existing
  exception-based failure path. begin_turn()/execute_turn() expose the live
  message list for callers that autosave history mid-run.
R04-T04 ui/cowork_tab.py::build_job -> snapshot + service.
R04-T05 core/task_executors.py::_run_agent -> same service (was a second,
  slightly different assembly of the same call).

Caught while wiring the bridge: the first event vocabulary had no "notice"
event, so Agent Security warnings and auto-compaction notices would have been
silently swallowed. Added NoticeEvent plus a test that scans the engine sources
for emit() tags and fails when one has no typed counterpart.

New: tests/integration/ - real offscreen CoworkTab running a scripted turn end
to end (7 tests), including a characterisation of the extra provider call Agent
Security spends reviewing each request.

Suite: 225 passed, 2.74s. check_imports: PASS. All new files < 400 LOC.
2 pre-existing failures remain in test_config_security.py (EPIC R02/Team Nam).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 10:32:14 +09:00
anhtnm1andClaude Opus 5 96bec976e7 feat(R03): unify provider catalogue, routing decisions and usage telemetry
EPIC R03 (Team Duy) - one provider catalogue, one routing flow, one usage seam.

R03-T01 tests/contracts/test_providers.py
  29 contract tests every provider must satisfy: canonical assistant message,
  streamed text == returned content, reasoning never joins the answer, parsed
  tool arguments, ProviderError for every failure. Real adapters exercised
  offline by stubbing Provider._request.
R03-T02 domain/models/provider_descriptor.py
        infrastructure/providers/provider_registry.py
  Provider facts declared once (was split across providers/factory.py,
  DEFAULT_CONFIG and PROVIDER_LABELS). ProviderRegistry.build() also stamps the
  descriptor id onto the instance, so ollama/github_copilot/codex usage is no
  longer all attributed to "openai_compat", and never mutates the caller config.
R03-T03 application/model_routing/routing_application_service.py
  Pure-Python routing policy with four modes: Off, Auto, Manual and the new
  Fallback (switch only AFTER the current model fails). Depends on a RoutingPort
  protocol; production wires the existing core.routing engine underneath.
R03-T04/T05 ui/chat_panel.py, ui/co4e_tab.py, ui/folder_tab.py
  Three near-identical routing copies (~40 lines each) replaced by a call to
  ctx.routing_application() plus a confirm callback. Mode vocabulary now lives
  in one place (normalize_mode/is_valid_mode) instead of four literal tuples.
R03-T06 infrastructure/telemetry/usage_sink.py
  Token usage extracted from both providers into UsageEvent + UsageEventSink.
  Estimation pinned against core.usage_tracker so no recorded number changes.

Also fixes a deadlock introduced while wiring AppContext: routing_application()
held _routing_lock and called routing(), which takes the same non-reentrant lock.

Suite: 186 passed, 1.22s. check_imports: PASS. All new files < 400 LOC.
2 pre-existing failures remain in test_config_security.py (EPIC R02/Team Nam).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 10:22:28 +09:00
anhtnm1andClaude Opus 5 bbc09f628a feat(R01): architecture foundation, offline fakes and characterization net
EPIC R01 (Team Duy) - safety net before the parallel refactor starts.

R01-T01 docs/architecture/ADR-001-layered-architecture.md
  4-tier boundaries, allowed dependency directions, invariants I1-I6 and
  the strangler-fig migration strategy.
R01-T02 tests/fakes/{fake_provider,fake_tool_executor}.py
  Scripted, offline Provider and extra-tool executor doubles.
R01-T03 scripts/check_imports.py
  AST-based Clean Architecture Guard (CASAN Check 3). Also covers relative
  imports and function-local imports; ASCII-only output for cp932 consoles.
R01-T04 tests/characterization/test_run_cowork.py
  13 snapshot tests pinning run_cowork's current observable contract before
  EPIC R04 moves its orchestration into application/.
R01-T05 docs/architecture/dormant-code.md
  Import-graph scan: 43 unimported modules verified down to 6 genuinely
  dormant items (~1887 LOC); the rest run via subprocess/CLI entry points.

tests/conftest.py binds `cowork_local` to THIS checkout by absolute path -
previously sys.path discovery could import a sibling checkout and the suite
would silently test the wrong code.

Suite: 104 passed, 1.08s (2 pre-existing failures in test_config_security.py
remain - config.py still ships a hardcoded default password, EPIC R02/Team Nam).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 10:05:50 +09:00
137 changed files with 7983 additions and 5365 deletions
+12 -1
View File
@@ -1 +1,12 @@
"""Application Layer: Pure Python use cases and application services.""" """Application layer - pure Python use-case orchestration.
Sits between ``presentation/`` (Qt widgets) and ``domain/`` (entities). A module
here answers "what has to happen, in what order" for one use case - route a
turn, run a conversation - without knowing whether a human, a scheduler or a
test triggered it.
Hard rule (ADR-001 I1/I3, enforced by ``scripts/check_imports.py``): no
PySide6/PyQt imports and no reach into ``presentation/``/``ui/``. Results travel
back up through plain-Python callbacks; turning those into Qt signals is the
presentation layer's job.
"""
+10 -1
View File
@@ -1 +1,10 @@
"""Application conversations package: turn lifecycle orchestration and agent execution.""" """Conversation use case: the lifecycle of one agent turn (EPIC R04) and the
tool approval policy every turn's tool calls go through (EPIC R05)."""
from .conversation_application_service import (
ConversationApplicationService,
TurnResult,
)
from .tool_policy_gateway import ConfirmGate, ToolPolicyGateway
__all__ = ["ConversationApplicationService", "TurnResult", "ToolPolicyGateway", "ConfirmGate"]
@@ -0,0 +1,328 @@
"""ConversationApplicationService - the turn lifecycle, outside the widget (R04-T03).
What this replaces
------------------
The lifecycle of one Cowork turn is currently spread across a closure inside
``ui/cowork_tab.py::build_job`` and a second, near-identical assembly inside
``core/task_executors.py::_run_agent``. Both:
* read live UI/config state from a worker thread,
* build the provider, the MCP tool set and the project context by hand,
* call ``core.chat_agent.run_cowork`` with a dozen positional-ish arguments,
* consume untyped event dicts.
Two copies means a fix to one path (say, promoting output files on failure)
silently misses the other. This service is the single implementation: it takes
an immutable :class:`ConversationExecutionRequest`, runs the turn, and reports
typed :class:`AgentEvent` objects.
What it deliberately does NOT do
--------------------------------
It does not re-implement the agent loop. ``run_cowork`` stays the engine
(strangler fig, ADR-001 section 4) and keeps its characterization tests
(``tests/characterization/test_run_cowork.py``). This layer owns the parts that
were tangled into the UI: assembling the call, translating events, and giving a
turn a well-defined end.
Pure Python: no Qt import, no config access. Everything it needs arrives through
constructor callbacks, so the same service runs a turn from a chat panel, from
the scheduler, or from a test.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Tuple
from cowork_local.domain.agents.agent_event import (
AgentEvent,
ErrorEvent,
TurnCompletedEvent,
collect_text,
event_from_dict,
)
from cowork_local.domain.agents.conversation_execution_request import (
ConversationExecutionRequest,
)
logger = logging.getLogger("cowork_local.conversations")
# Presentation/scheduler supplies these. Kept as plain callables (not objects)
# so a test can wire the service with three lambdas.
EventCallback = Callable[[AgentEvent], None]
CancelFn = Callable[[], bool]
ProviderFactory = Callable[[str, str], Any] # (provider_id, model) -> Provider
ToolSourceFactory = Callable[[], Tuple[Any, Any]] # () -> (extra_tools, extra_executor)
GateFactory = Callable[[ConversationExecutionRequest], Any] # -> PermissionGate or None
@dataclass
class TurnResult:
"""What a finished turn produced.
``messages`` is the conversation AFTER the turn (system prompt inserted,
assistant and tool messages appended) - the caller persists this as the new
history. ``final_text`` is the visible answer, reasoning excluded.
"""
request: ConversationExecutionRequest
messages: List[Dict[str, Any]] = field(default_factory=list)
events: List[AgentEvent] = field(default_factory=list)
final_text: str = ""
cancelled: bool = False
error: str = ""
# The original exception, kept alongside its message so a caller that needs
# to preserve legacy failure handling can re-raise the SAME object rather
# than a lookalike (SecurityBlocked, for instance, carries context that a
# re-wrapped RuntimeError would lose).
exception: Optional[BaseException] = None
@property
def ok(self) -> bool:
"""True when the turn completed without an error and without a Stop."""
return not self.error and not self.cancelled
def raise_if_failed(self) -> None:
"""Re-raise the turn's failure, if any.
Callers that already have failure handling built around an exception
(the Qt worker turns one into its ``failed`` signal) use this to keep
that path intact while still getting a TurnResult on success."""
if self.exception is not None:
raise self.exception
def output_dir(self) -> Optional[Path]:
"""This turn's output folder, or None when it could not write files."""
return Path(self.request.output_dir) if self.request.output_dir else None
class ConversationApplicationService:
"""Runs one agent turn from an immutable request.
Args:
provider_factory: ``(provider_id, model) -> Provider``. Production passes
``AppContext.build_provider_for``; tests pass a lambda returning a
:class:`FakeProvider`.
tool_source: ``() -> (extra_tools, extra_executor)`` for MCP/connector
tools. Optional - a turn with no external tools passes nothing.
gate_factory: ``(request) -> PermissionGate | None``, consulted when the
request asks to confirm commands. Optional for the same reason.
runner: the turn engine. Defaults to ``core.chat_agent.run_cowork``,
imported lazily so this module stays importable (and testable)
without pulling in the whole legacy tool stack.
security_config: the app config the security layers read. ``None``
disables them, which is what headless callers already rely on.
"""
def __init__(
self,
provider_factory: ProviderFactory,
*,
tool_source: Optional[ToolSourceFactory] = None,
gate_factory: Optional[GateFactory] = None,
runner: Optional[Callable[..., Any]] = None,
security_config: Any = None,
) -> None:
self._provider_factory = provider_factory
self._tool_source = tool_source
self._gate_factory = gate_factory
self._runner = runner
self._security_config = security_config
# -- main entry point -------------------------------------------------- #
def run_turn(
self,
request: ConversationExecutionRequest,
on_event: Optional[EventCallback] = None,
cancel: Optional[CancelFn] = None,
) -> TurnResult:
"""Execute one turn and return everything it produced.
Never raises: a provider or tool failure becomes an :class:`ErrorEvent`
plus ``TurnResult.error``. Callers run this on a worker thread and have
no good way to handle an exception crossing that boundary - today an
escaped error kills the worker and the UI just stops updating, with no
message shown.
Exactly one :class:`TurnCompletedEvent` is always emitted last, whether
the turn succeeded, failed or was cancelled. That is the end-of-turn
signal the legacy engine never had.
"""
return self.execute_turn(self.begin_turn(request), on_event=on_event, cancel=cancel)
def begin_turn(self, request: ConversationExecutionRequest) -> TurnResult:
"""Create the (still empty) result a turn will fill in.
Exposed separately from :meth:`run_turn` because some callers need the
LIVE message list while the turn is running, not only afterwards: the
scheduler re-saves the conversation to History after every assistant
message so a long unattended run shows live progress when reopened.
Handing them ``result.messages`` - the very list the engine appends to -
is what makes that possible without leaking the engine into the caller.
"""
return TurnResult(request=request, messages=request.message_list())
def execute_turn(
self,
result: TurnResult,
on_event: Optional[EventCallback] = None,
cancel: Optional[CancelFn] = None,
) -> TurnResult:
"""Run a turn previously created by :meth:`begin_turn`. See
:meth:`run_turn` for the error/cancellation contract."""
request = result.request
emit = self._make_emitter(result, on_event)
cancel = cancel or (lambda: False)
try:
self._execute(request, result, emit, cancel)
except Exception as exc: # noqa: BLE001 - see docstring
result.error = str(exc) or exc.__class__.__name__
result.exception = exc
logger.exception("turn %s failed", request.turn_id)
emit(ErrorEvent(message=result.error,
recoverable=self._is_recoverable(exc)))
result.cancelled = bool(cancel())
result.final_text = collect_text(result.events) or self._last_assistant_text(result.messages)
emit(TurnCompletedEvent(content=result.final_text, cancelled=result.cancelled))
return result
# -- internals --------------------------------------------------------- #
def _execute(self, request: ConversationExecutionRequest, result: TurnResult,
emit: Callable[[AgentEvent], None], cancel: CancelFn) -> None:
"""Assemble the engine call from the request snapshot and run it."""
provider = self._provider_factory(request.provider, request.model)
extra_tools, extra_executor = self._resolve_tools()
gate = self._resolve_gate(request)
# The engine speaks untyped dicts; bridge them into typed events at this
# single point rather than at every consumer.
def legacy_emit(payload: Dict[str, Any]) -> None:
event = event_from_dict(payload)
if event is not None:
emit(event)
run = self._resolve_runner()
run(
provider,
result.messages, # mutated in place by the engine, as before
self._output_dir(request),
legacy_emit,
cancel,
title=request.title,
extra_tools=extra_tools,
extra_executor=extra_executor,
project_context=request.project_context,
security_config=self._security_config,
gate=gate,
allowed_tools=list(request.allowed_tools) if request.allowed_tools is not None else None,
max_steps=request.max_steps,
run_to_completion=request.run_to_completion,
completion_max_steps=request.completion_max_steps,
enforce_rules=request.enforce_rules,
**self._role_kwargs(request),
)
@staticmethod
def _make_emitter(result: TurnResult,
on_event: Optional[EventCallback]) -> Callable[[AgentEvent], None]:
"""Record every event on the result AND forward it to the caller.
Recording is unconditional so a headless caller (the scheduler) can read
the full event list afterwards without having to supply a callback just
to collect it - which is exactly what task_executors does today with an
ad-hoc list.
"""
def emit(event: AgentEvent) -> None:
result.events.append(event)
if on_event is None:
return
try:
on_event(event)
except Exception: # noqa: BLE001
# A consumer that throws (a closing widget, say) must not abort
# the turn that is feeding it.
logger.debug("event consumer raised for %s", event.type, exc_info=True)
return emit
def _resolve_runner(self) -> Callable[..., Any]:
"""The turn engine, imported lazily on first use."""
if self._runner is None:
from cowork_local.core.chat_agent import run_cowork
self._runner = run_cowork
return self._runner
def _resolve_tools(self) -> Tuple[Any, Any]:
"""MCP/connector tools for this turn, or ``(None, None)``.
A failure here degrades to "no external tools" rather than failing the
turn: an MCP server that will not start must not stop the user from
chatting, which is the behaviour the chat panel already relies on.
"""
if self._tool_source is None:
return None, None
try:
return self._tool_source()
except Exception: # noqa: BLE001
logger.warning("tool source unavailable - running without external tools",
exc_info=True)
return None, None
def _resolve_gate(self, request: ConversationExecutionRequest) -> Any:
"""The permission gate, when this turn asked to confirm commands."""
if not request.confirm_commands or self._gate_factory is None:
return None
return self._gate_factory(request)
@staticmethod
def _output_dir(request: ConversationExecutionRequest) -> Path:
"""The turn's output folder as a Path.
The request holds it as a string to stay serialisable; converting at the
single point of use keeps that decision from leaking into every caller.
"""
return Path(request.output_dir) if request.output_dir else Path.cwd()
@staticmethod
def _role_kwargs(request: ConversationExecutionRequest) -> Dict[str, Any]:
"""``agent_role`` only when the request set one.
Omitted otherwise so the engine applies its own default (the interactive
Cowork role) instead of being handed an empty string, which would land
in the audit log as an unattributed tool call.
"""
return {"agent_role": request.agent_role} if request.agent_role else {}
@staticmethod
def _last_assistant_text(messages: List[Dict[str, Any]]) -> str:
"""Fallback answer text when no text events were seen.
A turn whose whole answer arrived in one non-streamed message still has
to report a final answer - the scheduler writes it into output.md, and
an empty string there reads as "(no output)".
"""
for message in reversed(messages):
if message.get("role") == "assistant" and (message.get("content") or "").strip():
return str(message["content"])
return ""
@staticmethod
def _is_recoverable(exc: Exception) -> bool:
"""Whether the user can act on this failure themselves.
"Model not found" is the motivating case: the chat panel restores the
typed message into the composer so the user can switch model and resend
instead of retyping it (see providers/base.py::MODEL_NOT_FOUND_HINT).
"""
try:
from cowork_local.providers.base import is_model_not_found_error
return bool(is_model_not_found_error(str(exc)))
except Exception: # noqa: BLE001
return False
__all__ = ["ConversationApplicationService", "TurnResult"]
@@ -0,0 +1,83 @@
"""ToolPolicyGateway - one confirm/deny decision path for every tool call
(R05-T03).
Today "does this tool call need the user's OK first" is answered by a
different hand-written check per engine:
* ``core/chat_agent.py::run_cowork`` — ``name in ("run_command",
"install_package")``, a literal tuple.
* ``core/code_agent.py::run_code`` — ``name in (WRITE_TOOLS | MS365_WRITE_TOOLS)``,
a set built from two other hand-maintained sets.
* MCP/connector tools (``core/mcp_client.py``, ``core/ext_connectors.py``) —
no check at all; ``chat_agent.py`` calls ``extra_executor(name, args)``
directly.
Three answers to the same question, and the third one is a real gap: an MCP
tool that deletes files or calls an external API today runs with zero
confirmation even when the user turned "confirm before running commands" on.
This gateway answers the question from data (:class:`~domain.tools.tool_descriptor.ToolCapability`
via a :class:`~domain.tools.tool_registry.ToolRegistry`) instead of a literal
name list, so registering a tool with the right capability is what gates it -
nothing to remember at each new call site. R05-T04 is what actually registers
MCP/connector tools with a capability; this module only needs the mechanism
to exist.
Pure Python: no Qt, no direct dialog. The actual approval prompt stays exactly
what it is today - a ``gate`` object with a ``.request(payload) -> bool``
method, supplied by the presentation layer (Settings' "confirm before running
commands" wires it up, or None for auto-run) - this module only decides
WHEN to ask it, never how to render the question.
"""
from __future__ import annotations
from typing import Any, Dict, Optional, Protocol
from cowork_local.domain.tools import ToolCapability, ToolRegistry
class ConfirmGate(Protocol):
"""Shape of the existing ``PermissionGate`` both engines already use."""
def request(self, payload: Dict[str, Any]) -> bool: ...
class ToolPolicyGateway:
"""Decides whether a tool call needs approval, for ONE calling surface.
``gated_capabilities`` is what makes this per-surface: Cowork only ever
asked about ``run_command``/``install_package`` (capability ``EXECUTE``),
while the Code tab additionally confirms plain file writes (capability
``WRITE``). Passing the wrong set here would silently change which tools
prompt for approval - see the callers in ``core/chat_agent.py`` and
``core/code_agent.py`` for the exact sets that preserve today's behavior.
"""
def __init__(self, registry: ToolRegistry, gated_capabilities: ToolCapability) -> None:
self._registry = registry
self._gated_capabilities = gated_capabilities
def requires_confirmation(self, name: str) -> bool:
"""True when ``name``'s declared capabilities overlap this surface's
gated set. An unregistered tool never requires confirmation through
this path - callers that must fail safe on unknown tools check
``name in registry`` themselves (see R05-T04's MCP wrapping, which
registers every tool it exposes before any call can reach here)."""
return bool(self._registry.capabilities_for(name) & self._gated_capabilities)
def allow(self, name: str, gate: Optional[ConfirmGate], payload: Dict[str, Any]) -> bool:
"""True when the call may proceed.
``gate is None`` preserves each engine's existing "no gate wired -
auto-run" behavior; a tool outside ``gated_capabilities`` is never
asked about, matching read-only tools "never confirm" today.
``payload`` is whatever ``gate.request(...)`` already expects at that
call site (the two engines use slightly different dict shapes) - this
gateway only decides WHETHER to call it, never reshapes the payload.
"""
if gate is None or not self.requires_confirmation(name):
return True
return bool(gate.request(payload))
__all__ = ["ToolPolicyGateway", "ConfirmGate"]
+6 -48
View File
@@ -1,54 +1,12 @@
"""Application model routing package: model route decisions and multi-provider balancing. """Model routing use case: pick the best-fit model for one turn (EPIC R03)."""
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 ( from .routing_application_service import (
ConfirmationCallback,
ModeResolver,
RoutingApplicationService, RoutingApplicationService,
RoutingDecisionPort, RoutingDecision,
)
from .routing_models import (
RouteEvaluation,
RoutingMode, RoutingMode,
RoutingOutcome, is_valid_mode,
RoutingRequest, normalize_mode,
) )
__all__ = [ __all__ = ["RoutingApplicationService", "RoutingDecision", "RoutingMode",
"AppContextModeResolver", "normalize_mode", "is_valid_mode"]
"ConfirmationCallback",
"CoreRoutingEngine",
"ModeResolver",
"RouteEvaluation",
"RoutingApplicationService",
"RoutingDecisionPort",
"RoutingMode",
"RoutingOutcome",
"RoutingRequest",
"build_routing_application_service",
]
@@ -1,169 +0,0 @@
"""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",
]
@@ -1,236 +1,353 @@
"""The one place that decides how a turn is routed (R03-T03). """RoutingApplicationService - one routing flow for every surface (R03-T03).
Before this service, ``ui/chat_panel.py#L638``, ``ui/co4e_tab.py`` and Before this service, the same routing algorithm existed three times:
``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: * ``ui/chat_panel.py::_apply_routing`` (Cowork chat)
* ``ui/co4e_tab.py::_apply_co4e_routing`` (Co4E studio)
* ``ui/folder_tab.py::_ai_apply_routing`` (AI-Edit)
* the routing engine is reached through :class:`RoutingDecisionPort`; The three copies had already drifted - each one resolves the "current model"
* the surface's Off/Auto/Manual/Fallback mode through :class:`ModeResolver`; differently and each one has its own private notion of what to do when the user
* the Manual-mode confirmation through a ``confirm`` callback supplied per call, declines - and every one of them lives inside a Qt widget, so none of the logic
so the Qt dialog stays in the presentation layer where it belongs. could be tested without building a window.
Every failure path degrades to "keep the current model": a routing problem must This module is the single implementation. It is pure Python: no Qt import, no
never be the reason a user cannot send a message. config access, no network. The presentation layer supplies a confirm callback
and renders the notice; everything else happens here.
Modes (:class:`RoutingMode`)
----------------------------
* ``OFF`` - never switch. The user's pinned model always wins.
* ``AUTO`` - switch silently when the best candidate clears the gain threshold.
* ``MANUAL`` - propose the switch and switch only if the confirm callback approves.
* ``FALLBACK`` - never switch pre-emptively; switch only AFTER the current model
fails, to the next-best candidate. This is the mode a user wants when they
trust their own model choice but still want the turn to survive an outage.
Migration note (ADR-001 section 4): the scoring/ranking engine is NOT rewritten.
This service depends on the small :class:`RoutingPort` interface, and production
wires the existing, already-tested ``core.routing.service.RoutingService`` into
it. Tests wire a fake.
""" """
from __future__ import annotations from __future__ import annotations
import logging from dataclasses import dataclass
from typing import Any, Callable, Optional, Protocol, runtime_checkable from enum import Enum
from typing import Any, Callable, List, Optional, Protocol, Sequence, Tuple
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 RoutingMode(str, Enum):
class RoutingDecisionPort(Protocol): """Per-surface routing behaviour.
"""The routing engine, as this service needs it.
Narrowed to a single method on purpose: the concrete engine The first three values match ``core.routing.models.SwitchMode`` string for
(``core/routing/service.py::RoutingService``) exposes assessment, string, so a mode read from the existing config round-trips unchanged.
persistence and scheduling too, none of which a turn-time decision needs.
""" """
def evaluate(self, request: RoutingRequest, mode: RoutingMode) -> RouteEvaluation: OFF = "off"
"""Rank candidates for ``request`` and report whether to switch.""" AUTO = "auto"
MANUAL = "manual"
FALLBACK = "fallback"
@classmethod
def parse(cls, raw: Any) -> "RoutingMode":
"""Best-effort parse of a config value.
Unknown or empty values become ``OFF``: routing is an optimisation, and
the safe reading of a corrupt setting is "leave the user's model alone"
rather than "silently move their work to another model".
"""
try:
return cls(str(raw or "off").strip().lower())
except ValueError:
return cls.OFF
@runtime_checkable @dataclass(frozen=True)
class ModeResolver(Protocol): class RoutingDecision:
"""Resolves the effective routing mode for a surface. """The outcome of routing one turn - an immutable instruction for the caller.
In the app this reads the active workspace's per-surface override with the ``provider``/``model`` are ALWAYS filled with what the turn should actually
global default behind it (``AppContext.project_routing_mode``); in tests it run on, switched or not, so a call site never has to re-derive the fallback
is a two-line stub. itself (the bug that made the three UI copies diverge).
""" """
def mode_for(self, surface: str) -> RoutingMode: mode: RoutingMode
"""Effective mode for ``surface``.""" provider: str
model: str
switched: bool = False
task_type: str = ""
score_gain: float = 0.0
reason: str = ""
declined: bool = False # Manual mode: a switch was offered and refused
# What the turn would have run on without routing. Carried so the Manual
# confirm dialog can show "from X to Y" without re-deriving the current
# model itself - re-deriving it differently per screen is exactly how the
# three legacy copies drifted apart.
previous_provider: str = ""
previous_model: str = ""
@property
def should_notify(self) -> bool:
"""True when the UI should show the "switched model" notice - i.e. only
when a switch really happened."""
return self.switched
def target(self) -> Tuple[str, str]:
"""``(provider, model)`` to run this turn on."""
return self.provider, self.model
@property
def from_model(self) -> str:
"""Candidate key (``provider/model``) of the model being switched away
from, or "" when nothing was selected yet.
Named to match ``core.routing.models.SwitchDecision`` so the existing
Manual-mode dialog (``ui/routing_toggle.py::confirm_switch``) accepts
this object unchanged - the dialog moves to the new shape in EPIC R08.
"""
if not self.previous_model:
return ""
return f"{self.previous_provider}/{self.previous_model}"
@property
def to_model(self) -> str:
"""Candidate key (``provider/model``) of the model to run on. See
:attr:`from_model` for why the name matches the legacy decision."""
return f"{self.provider}/{self.model}" if self.model else ""
def is_valid_mode(raw: Any) -> bool:
"""True when ``raw`` names a mode the routing service understands.
Distinct from :func:`normalize_mode` because callers need to tell "the user
chose off" apart from "this stored value is unrecognised" - the per-workspace
lookup falls back to the global setting only in the second case.
"""
try:
RoutingMode(str(raw or "").strip().lower())
except ValueError:
return False
return True
def normalize_mode(raw: Any) -> str:
"""Canonical mode string for persistence, or ``"off"`` when unrecognised.
Exists so the mode vocabulary is defined exactly once. It used to be
hard-coded as a ``("off", "auto", "manual")`` tuple in four separate places
(config.py twice, state.py twice); adding FALLBACK meant finding all four,
and missing one silently downgraded the user's choice back to "off".
"""
return RoutingMode.parse(raw).value
class RoutingPort(Protocol):
"""The slice of the routing engine this service needs.
Declared as a Protocol so the application layer states its requirement
without importing the implementation - which is what lets the whole service
be tested against a 20-line fake, and lets ``core.routing`` be replaced later
without touching this file.
"""
def route(self, surface: str, prompt: str, current_provider: str, current_model: str,
*, mode_override: Optional[str] = None,
required_capabilities: Optional[List[str]] = None,
task_type: Optional[Any] = None) -> Any:
"""Return a route result exposing ``should_switch``, ``target()``,
``task_type`` and ``decision``."""
# Presentation supplies this to ask the human. Receives the proposal so the
# dialog can explain it; returns True to approve. Manual mode only.
ConfirmFn = Callable[[RoutingDecision], bool]
class RoutingApplicationService: class RoutingApplicationService:
"""Turn-time routing decisions for every chat surface.""" """Decides which provider/model one turn runs on.
# Matches DEFAULT_CONFIG["routing"]["confirm_timeout_sec"]; used only when Args:
# no timeout provider is wired, so a bare service is still usable in tests. router: the scoring engine (see :class:`RoutingPort`).
DEFAULT_CONFIRM_TIMEOUT_SEC = 60.0 mode_reader: ``surface -> mode string``; production passes the per-workspace
lookup ``AppContext.project_routing_mode``. Injected rather than read
from config here so this layer stays free of config plumbing that
EPIC R02 is rewriting in parallel.
"""
def __init__( def __init__(self, router: RoutingPort,
mode_reader: Optional[Callable[[str], str]] = None) -> None:
self._router = router
self._mode_reader = mode_reader
# -- main entry point -------------------------------------------------- #
def route_turn(
self, self,
decision_port: RoutingDecisionPort, surface: str,
mode_resolver: Optional[ModeResolver] = None, prompt: str,
current_provider: str,
current_model: str,
*, *,
confirm_timeout_sec: Optional[Callable[[], float]] = None, mode: Optional[str] = None,
) -> None: confirm: Optional[ConfirmFn] = None,
self._decision_port = decision_port required_capabilities: Optional[Sequence[str]] = None,
self._mode_resolver = mode_resolver task_type: Optional[Any] = None,
# A callable rather than a number: the timeout lives in mutable config ) -> RoutingDecision:
# the user can change in Settings between two turns. """Decide what to run this turn on. Never raises.
self._confirm_timeout_sec = confirm_timeout_sec
# -- public API ------------------------------------------------------ # A routing failure must never block a message: any unexpected error
def resolve( degrades to "keep the current model", which is exactly what all three
self, legacy copies did with a bare ``except`` - made explicit and testable here.
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) resolved_mode = RoutingMode.parse(mode if mode is not None else self._read_mode(surface))
keep = self._keep(resolved_mode, current_provider, current_model,
reason="routing off - keeping current model")
# An empty prompt carries no signal to classify, so routing cannot make a
# meaningful choice; the same guard exists in all three legacy copies.
if resolved_mode is RoutingMode.OFF or not (prompt or "").strip():
return keep
# FALLBACK never switches up front - it only reacts to a failure, which
# the caller reports through fallback_after_failure().
if resolved_mode is RoutingMode.FALLBACK:
return self._keep(resolved_mode, current_provider, current_model,
reason="fallback mode - switching only after a failure")
try: try:
return self._resolve_unguarded(request, mode, confirm) result = self._router.route(
except Exception: # noqa: BLE001 — routing must never break a turn surface, prompt, current_provider, current_model,
logger.exception("routing.resolve failed — keeping the current model") mode_override=resolved_mode.value,
return RoutingOutcome.keep_current(mode, reason="routing error — keeping current model") required_capabilities=list(required_capabilities) if required_capabilities else None,
task_type=task_type,
)
except Exception: # noqa: BLE001 - routing must never break a turn
return self._keep(resolved_mode, current_provider, current_model,
reason="routing engine failed - keeping current model")
def confirm_timeout(self) -> float: proposal = self._to_decision(result, resolved_mode, current_provider, current_model)
"""Seconds to wait for a Manual-mode confirmation. if not proposal.switched:
return proposal
Falls back to the built-in default when the provider is missing or # Manual mode: the proposal only becomes a switch once a human approves.
returns something unusable, so a corrupted config value cannot produce a if resolved_mode is RoutingMode.MANUAL:
zero-second dialog that instantly declines every switch. if confirm is None or not self._ask(confirm, proposal):
""" return self._keep(resolved_mode, current_provider, current_model,
if self._confirm_timeout_sec is None: reason="switch declined - keeping current model",
return self.DEFAULT_CONFIRM_TIMEOUT_SEC task_type=proposal.task_type, declined=True)
try: return proposal
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 ------------------------------------------------------- # # -- failure recovery -------------------------------------------------- #
def _resolve_mode(self, surface: str) -> RoutingMode: def fallback_after_failure(
"""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, self,
request: RoutingRequest, surface: str,
mode: RoutingMode, prompt: str,
confirm: Optional[ConfirmationCallback], failed_provider: str,
) -> RoutingOutcome: failed_model: str,
"""The decision flow proper; :meth:`resolve` owns the safety net.""" *,
# 1. Routing disabled, or nothing to classify -> keep the selection. mode: Optional[str] = None,
if mode is RoutingMode.OFF: required_capabilities: Optional[Sequence[str]] = None,
return RoutingOutcome.keep_current(mode, reason="routing off") task_type: Optional[Any] = None,
if not request.has_prompt: ) -> Optional[RoutingDecision]:
return RoutingOutcome.keep_current(mode, reason="empty prompt — nothing to route") """Pick a replacement after ``failed_provider/failed_model`` failed.
# 2. Ask the engine. FALLBACK is evaluated with AUTO's ranking because Returns None when there is nothing to fall back to, so the caller can
# it needs the same candidate list; only the accept/reject rule below surface the original error instead of retrying forever. Available in
# differs, so the engine stays unaware of the extra mode. AUTO and FALLBACK; OFF and MANUAL keep the user's model on failure too,
engine_mode = RoutingMode.AUTO if mode is RoutingMode.FALLBACK else mode because silently moving work to another model is exactly what those two
evaluation = self._decision_port.evaluate(request, engine_mode) modes exist to prevent.
"""
resolved_mode = RoutingMode.parse(mode if mode is not None else self._read_mode(surface))
if resolved_mode not in (RoutingMode.AUTO, RoutingMode.FALLBACK):
return None
# 3. Apply the mode's own accept rule to the engine's verdict. try:
if mode is RoutingMode.FALLBACK: # Asked in AUTO so the engine ranks candidates rather than short-
accepted, reason = self._fallback_verdict(evaluation) # circuiting on FALLBACK's "never switch up front" rule; the failed
else: # model is passed as current so any positive gain beats it.
accepted, reason = evaluation.should_switch, evaluation.reason result = self._router.route(
surface, prompt, failed_provider, failed_model,
if not accepted or not evaluation.has_target: mode_override=RoutingMode.AUTO.value,
return RoutingOutcome.keep_current( required_capabilities=list(required_capabilities) if required_capabilities else None,
mode, task_type=task_type,
reason=reason or evaluation.reason,
task_type=evaluation.task_type,
decision=evaluation.decision,
) )
except Exception: # noqa: BLE001 - a broken router must not mask the real error
return None
# 4. Manual mode asks first; a decline or a timeout keeps the current decision = self._to_decision(result, resolved_mode, failed_provider, failed_model)
# model (and is reported as such, so the surface can tell the two # A "switch" back to the model that just failed would retry the outage.
# cases apart from "nothing better was found"). if not decision.switched or (decision.provider, decision.model) == (failed_provider, failed_model):
if mode is RoutingMode.MANUAL and not self._approved(evaluation, confirm): return None
return RoutingOutcome.keep_current( return RoutingDecision(
mode, mode=resolved_mode, provider=decision.provider, model=decision.model,
reason="switch declined by user or confirmation timed out", switched=True, task_type=decision.task_type, score_gain=decision.score_gain,
task_type=evaluation.task_type, reason=f"{failed_provider}/{failed_model} failed - falling back to "
declined=True, f"{decision.provider}/{decision.model}",
decision=evaluation.decision, previous_provider=failed_provider, previous_model=failed_model,
)
# 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 # -- internals --------------------------------------------------------- #
def _fallback_verdict(evaluation: RouteEvaluation) -> tuple: def _read_mode(self, surface: str) -> str:
"""FALLBACK's accept rule: switch ONLY to rescue an unusable selection. """Per-surface mode from the injected reader ('off' when none supplied)."""
if self._mode_reader is None:
The user's pinned model wins as long as it can serve the turn, even when return RoutingMode.OFF.value
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: try:
return bool(confirm(evaluation.decision, self.confirm_timeout())) return self._mode_reader(surface) or RoutingMode.OFF.value
except Exception: # noqa: BLE001 - a config read must not break a turn
return RoutingMode.OFF.value
@staticmethod
def _keep(mode: RoutingMode, provider: str, model: str, *, reason: str,
task_type: str = "", declined: bool = False) -> RoutingDecision:
"""A no-switch decision that still names the model to run on."""
return RoutingDecision(mode=mode, provider=provider, model=model, switched=False,
task_type=task_type, reason=reason, declined=declined,
previous_provider=provider, previous_model=model)
@staticmethod
def _ask(confirm: ConfirmFn, proposal: RoutingDecision) -> bool:
"""Run the confirm callback, treating any failure as "declined".
The callback opens a modal dialog in production; if that raises (window
already closing, for instance) the safe answer is to keep the user's own
model rather than to switch without consent.
"""
try:
return bool(confirm(proposal))
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
logger.exception("routing: confirmation callback failed — keeping current model")
return False return False
@staticmethod
def _to_decision(result: Any, mode: RoutingMode,
current_provider: str, current_model: str) -> RoutingDecision:
"""Translate the engine's route result into a :class:`RoutingDecision`.
__all__ = [ Defensive about the result shape on purpose: this is the seam between the
"ConfirmationCallback", new layer and a legacy module still under refactor, and a missing
"ModeResolver", attribute must degrade to "keep current model" instead of raising into
"RoutingApplicationService", the middle of a chat turn.
"RoutingDecisionPort", """
] inner = getattr(result, "decision", None)
task_type = getattr(getattr(result, "task_type", None), "value", "") or ""
gain = float(getattr(inner, "score_gain", 0.0) or 0.0)
reason = str(getattr(inner, "reason", "") or "")
target = None
if getattr(result, "should_switch", False):
getter = getattr(result, "target", None)
target = getter() if callable(getter) else None
if not target:
return RoutingDecision(mode=mode, provider=current_provider, model=current_model,
switched=False, task_type=task_type, score_gain=gain,
reason=reason or "no better model - keeping current",
previous_provider=current_provider,
previous_model=current_model)
provider, model = target
return RoutingDecision(mode=mode, provider=provider or current_provider, model=model,
switched=True, task_type=task_type, score_gain=gain, reason=reason,
previous_provider=current_provider, previous_model=current_model)
__all__ = ["RoutingApplicationService", "RoutingDecision", "RoutingMode",
"RoutingPort", "normalize_mode", "is_valid_mode"]
-158
View File
@@ -1,158 +0,0 @@
"""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"]
-1
View File
@@ -1 +0,0 @@
"""Application monitoring package: Monitoring query service for audit and metrics."""
-1
View File
@@ -1 +0,0 @@
"""Application scheduling package: TaskApplicationService and AI task planning."""
-1
View File
@@ -1 +0,0 @@
"""Application settings package: Settings application service."""
-1
View File
@@ -1 +0,0 @@
"""Application workflows package: Co4E graph execution orchestration."""
+5 -1
View File
@@ -1 +1,5 @@
"""Application workspaces package: File workspace and AI file editor services.""" """Workspace file operations for non-agent-loop callers (EPIC R06)."""
from .file_workspace_service import FileWorkspaceService
__all__ = ["FileWorkspaceService"]
@@ -0,0 +1,81 @@
"""FileWorkspaceService - the safe file operations File Explorer and the AI
File Editor need, outside the agent tool loop (R06-T05).
``ui/folder_tab.py`` (File Explorer) and the AI File Editor dialog need the
exact same guarantees the agent's tools already have — path containment
inside the workspace, precise context-anchored edits, syntax warnings on a
bad Python write — but today that logic only exists wired to a model's tool
call (``core/tools.py::execute_tool``). A UI action that isn't a tool call
(browsing the tree, applying an AI-suggested diff from a review dialog) has
no equivalent entry point of its own.
This service IS that entry point. It reuses ``core/tools.py::execute_tool``
verbatim - same dispatch table, same ``ToolContext`` containment check, same
audit-log entry, same Python-syntax warning on write/edit - rather than
re-implementing any of it, so a fix to one path fixes both. It only adds the
:class:`~domain.workspaces.workspace_session.WorkspaceSession` seam: which
workspace root a call is scoped to is decided by the session, not by
whichever folder a widget happens to have open.
"""
from __future__ import annotations
from typing import Any, Dict
class FileWorkspaceService:
"""File operations scoped to one :class:`WorkspaceSession`.
Read-only by name (``list_tree``/``read_preview``) vs. writing
(``write_file``/``apply_edit``) mirrors the same READ/WRITE split
``domain/tools/tool_registry.py`` uses for the agent's own tools - a
caller that only wants to browse never accidentally has write access.
"""
def __init__(self, session) -> None: # WorkspaceSession - see module docstring
self._session = session
def list_tree(self, rel: str = ".") -> Dict[str, Any]:
"""Entries at ``rel`` (default: the workspace root)."""
return self._execute("list_dir", {"path": rel})
def read_preview(self, rel: str) -> Dict[str, Any]:
"""A text file's content (truncated by
``infrastructure/filesystem/file_tools.py::MAX_READ_BYTES``, same as
the agent's ``read_file`` tool)."""
return self._execute("read_file", {"path": rel})
def write_file(self, rel: str, content: str) -> Dict[str, Any]:
"""Create or fully overwrite ``rel``."""
return self._execute("write_file", {"path": rel, "content": content})
def apply_edit(self, rel: str, old_string: str, new_string: str,
replace_all: bool = False) -> Dict[str, Any]:
"""Replace an exact snippet in an existing file - the same
context-anchored algorithm the agent's ``edit_file`` tool uses, so an
AI-suggested diff applies with the same precision and the same
"old_string not found / ambiguous" failure messages either path
would give the caller."""
return self._execute("edit_file", {
"path": rel, "old_string": old_string, "new_string": new_string,
"replace_all": replace_all,
})
# -- internals --------------------------------------------------------- #
def _tool_context(self):
"""A ``ToolContext`` scoped to this session's workspace root.
``flatten_writes=False`` (unlike Cowork's agent context) - File
Explorer must preserve whatever subfolder structure the user is
actually browsing, not collapse every write into the root."""
from cowork_local.infrastructure.filesystem.tool_context import ToolContext
return ToolContext(self._session.workspace_root, flatten_writes=False)
def _execute(self, name: str, args: Dict[str, Any]) -> Dict[str, Any]:
"""Dispatch through ``core/tools.py::execute_tool`` - see the module
docstring for why this delegates instead of reimplementing."""
from cowork_local.core.tools import execute_tool
return execute_tool(self._tool_context(), name, args)
__all__ = ["FileWorkspaceService"]
+13 -18
View File
@@ -105,7 +105,7 @@ DEFAULT_CONFIG: Dict[str, Any] = {
# sandboxes agent-run shell commands) — reading a URL for info is safe and # sandboxes agent-run shell commands) — reading a URL for info is safe and
# useful, so this defaults ON. Toggle in Settings → Security. # useful, so this defaults ON. Toggle in Settings → Security.
"allow_url_fetch": True, "allow_url_fetch": True,
"sandbox_pw": "", # set through COWORK_SANDBOX_PASSWORD "sandbox_pw": "quandh14", # default password to unlock sandbox settings
"rulebase_path": "", # custom RULEBASE.md — attached to every agent execution "rulebase_path": "", # custom RULEBASE.md — attached to every agent execution
}, },
# Legacy generic-MCP-server list. MERGED into ext_connectors["other"] as of # Legacy generic-MCP-server list. MERGED into ext_connectors["other"] as of
@@ -173,7 +173,7 @@ DEFAULT_CONFIG: Dict[str, Any] = {
# Microsoft. Real Outlook/Teams/OneDrive/SharePoint access still requires a # Microsoft. Real Outlook/Teams/OneDrive/SharePoint access still requires a
# proper OAuth sign-in (not implemented yet) using tenant_id/client_id below. # proper OAuth sign-in (not implemented yet) using tenant_id/client_id below.
"ms365": { "ms365": {
"unlock_code": "", # set through COWORK_MS365_UNLOCK_CODE "unlock_code": "quandh14",
"unlocked": False, # runtime-only — never persisted as True, see save() "unlocked": False, # runtime-only — never persisted as True, see save()
# Auto-connect MS365/OneDrive/SharePoint: the built-in MS365 MCP server # Auto-connect MS365/OneDrive/SharePoint: the built-in MS365 MCP server
# launches automatically once the user is signed in (OAuth tenant/client # launches automatically once the user is signed in (OAuth tenant/client
@@ -294,10 +294,6 @@ def _apply_env_overrides(data: Dict[str, Any]) -> Dict[str, Any]:
data["active_provider"] = os.environ["COWORK_ACTIVE_PROVIDER"] data["active_provider"] = os.environ["COWORK_ACTIVE_PROVIDER"]
if os.getenv("COWORK_CA_BUNDLE"): if os.getenv("COWORK_CA_BUNDLE"):
data["tls_ca_bundle"] = os.environ["COWORK_CA_BUNDLE"] data["tls_ca_bundle"] = os.environ["COWORK_CA_BUNDLE"]
if os.getenv("COWORK_SANDBOX_PASSWORD"):
data["agent_security"]["sandbox_pw"] = os.environ["COWORK_SANDBOX_PASSWORD"]
if os.getenv("COWORK_MS365_UNLOCK_CODE"):
data["ms365"]["unlock_code"] = os.environ["COWORK_MS365_UNLOCK_CODE"]
return data return data
@@ -555,27 +551,26 @@ class AppConfig:
d["surface_modes"].setdefault(surface, "") d["surface_modes"].setdefault(surface, "")
return d return d
# The routing modes a surface may be in. "fallback" joined the set in
# R03-T03 (keep the selected model; re-route only when it cannot serve the
# turn) — see application/model_routing/routing_models.py::RoutingMode,
# which is the authority on what each mode means.
ROUTING_MODES = ("off", "auto", "manual", "fallback")
def routing_mode_for(self, surface: str) -> str: def routing_mode_for(self, surface: str) -> str:
"""Effective Off/Auto/Manual/Fallback mode for a chat surface. """Effective Off/Auto/Manual/Fallback mode for a chat surface.
A per-surface override wins; an empty override falls back to the global A per-surface override wins; an empty override falls back to the global
``switch_mode``. Anything unrecognised degrades to "off" so routing ``switch_mode``. The value is validated through
stays opt-in even with a hand-edited config.""" ``application.model_routing.normalize_mode`` so the accepted vocabulary
is defined in exactly one place (R03-T03) - it used to be a literal
tuple repeated here and in state.py, and adding a mode to one copy but
not the others silently downgraded the user's choice to "off"."""
from .application.model_routing import normalize_mode
routing = self.routing routing = self.routing
override = (routing.get("surface_modes", {}) or {}).get(surface, "") override = (routing.get("surface_modes", {}) or {}).get(surface, "")
mode = override or routing.get("switch_mode", "off") return normalize_mode(override or routing.get("switch_mode", "off"))
return mode if mode in self.ROUTING_MODES else "off"
def set_routing_mode_for(self, surface: str, mode: str) -> None: def set_routing_mode_for(self, surface: str, mode: str) -> None:
"""Persist a chat surface's routing toggle selection.""" """Persist a chat surface's routing toggle selection."""
mode = mode if mode in self.ROUTING_MODES else "off" from .application.model_routing import normalize_mode
self.routing.setdefault("surface_modes", {})[surface] = mode
self.routing.setdefault("surface_modes", {})[surface] = normalize_mode(mode)
self.save() self.save()
@property @property
+46 -10
View File
@@ -11,6 +11,8 @@ import re
from pathlib import Path from pathlib import Path
from typing import Any, Callable, Dict, List, Optional from typing import Any, Callable, Dict, List, Optional
from ..application.conversations.tool_policy_gateway import ToolPolicyGateway
from ..domain.tools import ToolCapability, default_registry
from ..providers.base import Provider, ToolSpec from ..providers.base import Provider, ToolSpec
from . import agent_roles from . import agent_roles
from . import agent_security from . import agent_security
@@ -27,6 +29,13 @@ from .tools import TOOL_SPECS, ToolContext, _snapshot, describe_action, execute_
# Generator / helper scripts — never a final deliverable in Cowork's output. # Generator / helper scripts — never a final deliverable in Cowork's output.
_SCRIPT_EXTS = {".py", ".pyw", ".js", ".mjs", ".cjs", ".ts", ".sh", ".bat", ".ps1", ".rb", ".pl"} _SCRIPT_EXTS = {".py", ".pyw", ".js", ".mjs", ".cjs", ".ts", ".sh", ".bat", ".ps1", ".rb", ".pl"}
# R05-T03/T04: replaces the literal ``name in ("run_command",
# "install_package")`` check below with a capability lookup — EXECUTE is
# exactly the capability those two (and only those two) built-in tools carry
# (see domain/tools/tool_registry.py::BUILT_IN_CAPABILITIES). Copied per-turn
# into ``turn_tool_policy`` inside run_cowork() once extra_tools are known.
_COWORK_TOOL_REGISTRY = default_registry(TOOL_SPECS)
EmitFn = Callable[[Dict[str, Any]], None] EmitFn = Callable[[Dict[str, Any]], None]
CancelFn = Callable[[], bool] CancelFn = Callable[[], bool]
@@ -388,6 +397,19 @@ def run_cowork(
jira=(security_config.data.get("jira") if security_config else None)) jira=(security_config.data.get("jira") if security_config else None))
extra_tools = extra_tools or [] extra_tools = extra_tools or []
extra_names = {t.name for t in extra_tools} extra_names = {t.name for t in extra_tools}
# R05-T04: MCP servers (core/mcp_client.py) and unified connectors
# (core/ext_connectors.py) — everything that arrives here as extra_tools —
# advertise no standard risk metadata, so each is tagged with the same
# conservative default (WRITE|EXECUTE|NETWORK) domain/tools/tool_registry.py
# uses for any unclassified tool. Copying the built-in registry per turn
# (cheap - under 20 entries) rather than mutating the shared module-level
# one keeps different turns' extra_tools from leaking into each other.
from ..domain.tools import ToolDescriptor, ToolRegistry
from ..domain.tools.tool_registry import UNKNOWN_SOURCE_CAPABILITIES
_turn_registry = ToolRegistry(_COWORK_TOOL_REGISTRY.all())
for _spec in extra_tools:
_turn_registry.register(ToolDescriptor.from_spec(_spec, UNKNOWN_SOURCE_CAPABILITIES))
turn_tool_policy = ToolPolicyGateway(_turn_registry, ToolCapability.EXECUTE)
# update_plan drives the Plan panel (above Output); it produces no file. # update_plan drives the Plan panel (above Output); it produces no file.
# Built-in tools the admin disabled (Monitoring → Tools) are filtered out. # Built-in tools the admin disabled (Monitoring → Tools) are filtered out.
from .tools import enabled_tool_specs from .tools import enabled_tool_specs
@@ -489,6 +511,18 @@ def run_cowork(
preview = {"kind": "info", "title": name, "text": str(args)} preview = {"kind": "info", "title": name, "text": str(args)}
emit({"type": "tool_proposed", "id": tc_id, "name": name, "args": args, emit({"type": "tool_proposed", "id": tc_id, "name": name, "args": args,
"preview": preview}) "preview": preview})
# R05-T04: MCP/connector tools used to run with NO permission
# check at all — this is what closes that gap. Same policy,
# same gate object as the built-in tools below.
if not turn_tool_policy.allow(
name, gate, {"name": name, "args": args, "preview": preview}
):
result = {"ok": False, "output": "Rejected by user."}
emit({"type": "tool_result", "id": tc_id, "name": name,
"ok": False, "output": result["output"]})
messages.append({"role": "tool", "tool_call_id": tc_id, "name": name,
"content": result["output"]})
continue
result = extra_executor(name, args) result = extra_executor(name, args)
emit({"type": "tool_result", "id": tc_id, "name": name, emit({"type": "tool_result", "id": tc_id, "name": name,
"ok": result.get("ok", False), "output": result.get("output", "")}) "ok": result.get("ok", False), "output": result.get("output", "")})
@@ -528,16 +562,18 @@ def run_cowork(
# Permission Management (Sandbox Security Layer) — only when a # Permission Management (Sandbox Security Layer) — only when a
# gate was actually supplied (Settings: "confirm before running # gate was actually supplied (Settings: "confirm before running
# commands"); None preserves the pre-existing auto-run behavior. # commands"); None preserves the pre-existing auto-run behavior.
if gate is not None and name in ("run_command", "install_package"): # R05-T03: gating is now capability-driven (see
approved = gate.request({"name": name, "args": args, "preview": preview}) # turn_tool_policy above) instead of a literal name tuple.
if not approved: if not turn_tool_policy.allow(
result = {"ok": False, "output": "Rejected by user."} name, gate, {"name": name, "args": args, "preview": preview}
evt = {"type": "tool_result", "id": tc_id, "name": name, ):
"ok": False, "output": result["output"]} result = {"ok": False, "output": "Rejected by user."}
emit(evt) evt = {"type": "tool_result", "id": tc_id, "name": name,
messages.append({"role": "tool", "tool_call_id": tc_id, "ok": False, "output": result["output"]}
"name": name, "content": result["output"]}) emit(evt)
continue messages.append({"role": "tool", "tool_call_id": tc_id,
"name": name, "content": result["output"]})
continue
if name == "save_file": if name == "save_file":
result = _do_save_file(output_dir, title, args) result = _do_save_file(output_dir, title, args)
+15 -4
View File
@@ -12,6 +12,8 @@ import re
from pathlib import Path from pathlib import Path
from typing import Any, Callable, Dict, List, Optional from typing import Any, Callable, Dict, List, Optional
from ..application.conversations.tool_policy_gateway import ToolPolicyGateway
from ..domain.tools import ToolCapability, ToolDescriptor, ToolRegistry
from ..providers.base import Provider from ..providers.base import Provider
from . import agent_roles from . import agent_roles
from . import agent_security from . import agent_security
@@ -225,6 +227,14 @@ def run_code(
# read/list ms365 tools count as "read-only, never confirm". Names are # read/list ms365 tools count as "read-only, never confirm". Names are
# the MCP-qualified "ms365__*" form the agent sees (see ms365_tools.py). # the MCP-qualified "ms365__*" form the agent sees (see ms365_tools.py).
gated_tools = WRITE_TOOLS | MS365_WRITE_TOOLS gated_tools = WRITE_TOOLS | MS365_WRITE_TOOLS
# R05-T03/T04: ``gated_tools`` stays the authoritative name set (unchanged),
# but the actual confirm decision now goes through the same
# ToolPolicyGateway class run_cowork uses, instead of a separate
# hand-rolled ``if name in gated_tools`` + direct ``gate.request(...)``.
code_tool_policy = ToolPolicyGateway(
ToolRegistry(ToolDescriptor(n, "", {}, ToolCapability.WRITE) for n in gated_tools),
ToolCapability.WRITE,
)
# In PLAN mode, don't advertise write/run tools (analysis only). # In PLAN mode, don't advertise write/run tools (analysis only).
advertised = [t for t in all_tools if t.name not in gated_tools] if plan else all_tools advertised = [t for t in all_tools if t.name not in gated_tools] if plan else all_tools
has_memory = any(t.name.startswith("cmem_") for t in extra_tools) has_memory = any(t.name.startswith("cmem_") for t in extra_tools)
@@ -297,10 +307,11 @@ def run_code(
agent_security.enforce_command(provider, name, args, security_config, emit, agent_security.enforce_command(provider, name, args, security_config, emit,
agent_kind="code") agent_kind="code")
if name in gated_tools: # read-only tools (incl. codebase memory) never consult the gate —
approved = gate.request({"id": tc_id, "name": name, "args": args, "preview": preview}) # code_tool_policy.requires_confirmation(name) is False for them.
else: approved = code_tool_policy.allow(
approved = True # read-only tools (incl. codebase memory) never confirm name, gate, {"id": tc_id, "name": name, "args": args, "preview": preview}
)
if cancel(): if cancel():
return messages return messages
+9 -3
View File
@@ -66,7 +66,9 @@ def save_conversation(
"outputs": list(outputs or []), "outputs": list(outputs or []),
"messages": messages, "messages": messages,
} }
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") # R06-T02: atomic write - see infrastructure/persistence/json/atomic_write.py.
from ..infrastructure.persistence.json.atomic_write import write_json
write_json(path, payload)
return path return path
@@ -78,15 +80,19 @@ def delete_conversation(path) -> None:
def rename_conversation(path, new_title: str) -> None: def rename_conversation(path, new_title: str) -> None:
from ..infrastructure.persistence.json.atomic_write import write_json
data = load_conversation(path) data = load_conversation(path)
data["title"] = new_title data["title"] = new_title
Path(path).write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") write_json(Path(path), data)
def set_pinned(path, pinned: bool) -> None: def set_pinned(path, pinned: bool) -> None:
from ..infrastructure.persistence.json.atomic_write import write_json
data = load_conversation(path) data = load_conversation(path)
data["pinned"] = bool(pinned) data["pinned"] = bool(pinned)
Path(path).write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") write_json(Path(path), data)
def load_conversation(path: Path) -> Dict[str, Any]: def load_conversation(path: Path) -> Dict[str, Any]:
+7
View File
@@ -106,6 +106,13 @@ class McpServerConnection:
if self._thread is not None: if self._thread is not None:
self._thread.join(timeout=5) self._thread.join(timeout=5)
def is_alive(self) -> bool:
"""True while the connection's background thread (and therefore its
event loop and subprocess) is still running — used by
``infrastructure/mcp/mcp_source_manager.py`` (R05-T05) to tell a live
cached connection from one whose subprocess already died."""
return self._thread is not None and self._thread.is_alive()
# ---- tools ----------------------------------------------------------- # ---- tools -----------------------------------------------------------
def list_tool_specs(self) -> List[ToolSpec]: def list_tool_specs(self) -> List[ToolSpec]:
"""The server's tools, wrapped as :class:`ToolSpec` — the same shape """The server's tools, wrapped as :class:`ToolSpec` — the same shape
+5 -3
View File
@@ -116,10 +116,12 @@ def new_project(name: str, description: str = "", instructions: str = "",
def save_project(project: Project, directory: Path = None) -> Path: def save_project(project: Project, directory: Path = None) -> Path:
directory = directory or PROJECTS_DIR directory = directory or PROJECTS_DIR
directory.mkdir(parents=True, exist_ok=True)
path = directory / f"{project.project_id}.json" path = directory / f"{project.project_id}.json"
path.write_text(json.dumps(asdict(project), ensure_ascii=False, indent=2), # R06-T02: atomic write — a crash/kill between truncate and write used to
encoding="utf-8") # leave a half-written project.json that load_project() then silently
# treats as "missing" (see infrastructure/persistence/json/atomic_write.py).
from ..infrastructure.persistence.json.atomic_write import write_json
write_json(path, asdict(project))
return path return path
+38 -6
View File
@@ -248,9 +248,34 @@ def _run_agent(ctx, task_type: str, prompt: str, out_dir: Path,
"'error' (not silently skip it) if it genuinely can't be completed.\n\n" "'error' (not silently skip it) if it genuinely can't be completed.\n\n"
f"{prompt}" f"{prompt}"
) )
messages = [{"role": "user", "content": prompt}] # One immutable snapshot of this run, then the shared turn service (R04-T05).
# The Schedule Task path used to assemble the run_cowork call itself, in
# parallel with ui/cowork_tab.py doing the same thing slightly differently -
# so a fix to one path silently missed the other. Both now go through
# ConversationApplicationService.
from ..application.conversations import ConversationApplicationService
from ..domain.agents import ConversationExecutionRequest
session_id = new_session_id() session_id = new_session_id()
project_id = project.project_id if project is not None else "" project_id = project.project_id if project is not None else ""
project_context = projects.project_context_text(project)
conversation_service = ConversationApplicationService(
# The provider was already resolved above (admin agent / per-task
# override / machine default), so the factory just hands it back.
lambda _provider_id, _model: provider,
security_config=ctx.config,
)
turn = conversation_service.begin_turn(ConversationExecutionRequest.create(
prompt, [{"role": "user", "content": prompt}],
output_dir=str(out_dir), session_id=session_id, surface="task",
title=title, project_id=project_id, project_context=project_context,
# Tags every tool call in the audit log as a scheduled task rather than
# as the interactive Cowork tab.
agent_role=agent_roles.TASK,
))
# The LIVE list the engine appends to - History is re-saved from it after
# every assistant message so a long run shows progress when reopened.
messages = turn.messages
_save_history_session(ctx, task_type, title, messages, session_id, project_id) _save_history_session(ctx, task_type, title, messages, session_id, project_id)
# Tell the scheduler the session now genuinely EXISTS on disk — it # Tell the scheduler the session now genuinely EXISTS on disk — it
# refreshes History on this, not on the earlier "task_started" signal # refreshes History on this, not on the earlier "task_started" signal
@@ -269,14 +294,21 @@ def _run_agent(ctx, task_type: str, prompt: str, out_dir: Path,
elif ev.get("type") == "plan_set": elif ev.get("type") == "plan_set":
last_plan_steps[:] = ev.get("steps") or [] last_plan_steps[:] = ev.get("steps") or []
project_context = projects.project_context_text(project)
watched_cancel, timed_out = _cancel_with_timeout(cancel, timeout_sec) watched_cancel, timed_out = _cancel_with_timeout(cancel, timeout_sec)
try: try:
if task_type == "cowork": if task_type == "cowork":
from .chat_agent import run_cowork # Typed events are rendered back into the legacy dict shape this
run_cowork(provider, messages, out_dir, emit_and_autosave, watched_cancel, # module's autosave/plan tracking already consumes; it moves to
security_config=ctx.config, agent_role=agent_roles.TASK, # AgentEvent directly once the scheduler UI migrates (EPIC R07/R08).
project_context=project_context) result = conversation_service.execute_turn(
turn,
on_event=lambda event: emit_and_autosave(event.to_dict()),
cancel=watched_cancel,
)
# This module's callers handle a failed run through an exception
# (execute_task writes error.txt from it), so re-raise the ORIGINAL
# error rather than reporting a silently empty answer.
result.raise_if_failed()
else: else:
from .code_agent import run_code from .code_agent import run_code
limits, block_network = agent_security.sandbox_settings(ctx.config) limits, block_network = agent_security.sandbox_settings(ctx.config)
+38 -312
View File
@@ -3,79 +3,29 @@
Every path is resolved relative to the working directory and must stay inside Every path is resolved relative to the working directory and must stay inside
it (path-traversal is rejected). ``run_command`` executes inside the workdir it (path-traversal is rejected). ``run_command`` executes inside the workdir
with a timeout and captured output. with a timeout and captured output.
R05-T02: the actual handlers (``read_file``/``list_dir``/``write_file``/
``edit_file``/``run_command``/``install_package``/``fetch_url``/
``jira_search``/``jira_get_issue``) now live in
``infrastructure/filesystem/{file_tools,command_tools,fetch_tools}.py``, split
out of what used to be one big if/elif chain here. This module is the
strangler-fig shim (ADR-001 section 4): it re-exports ``ToolContext``/
``ToolError`` (actually defined in
``infrastructure/filesystem/tool_context.py`` now) so every existing
``from .tools import ToolContext`` keeps working, and ``execute_tool``
dispatches through a small ``{name: handler}`` table built from the moved
modules instead of the chain itself.
""" """
from __future__ import annotations from __future__ import annotations
import ast
import difflib import difflib
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional from typing import Any, Callable, Dict, List, Optional
from ..infrastructure.filesystem import command_tools, fetch_tools, file_tools
from ..infrastructure.filesystem.command_tools import _snapshot # noqa: F401 - re-export, core/chat_agent.py imports this name
from ..infrastructure.filesystem.tool_context import CancelFn, ToolContext, ToolError # noqa: F401 - re-export
from ..providers.base import ToolSpec from ..providers.base import ToolSpec
CancelFn = Callable[[], bool]
MAX_READ_BYTES = 200_000
COMMAND_TIMEOUT = 120 # seconds
class ToolError(Exception):
pass
def _flatten_rel(rel: str) -> str:
"""Collapse a sub-folder path down to a bare filename so the file lands in the
workdir root — EXCEPT the ``.scratch`` sandbox subtree, which is preserved.
Used by the Cowork agent (flatten_writes=True) so it can never create a
per-session / per-chat / per-task output sub-folder: every deliverable stays
directly in the single configured Output folder."""
parts = Path(rel).parts
if parts and parts[0] == ".scratch":
return rel # temporary sandbox is allowed (and cleaned up afterwards)
return Path(rel).name or rel
@dataclass
class ToolContext:
workdir: Path
flatten_writes: bool = False # Cowork: force every write into the workdir root
sandbox: bool = False # Code tab: isolate run_command/install_package into <workdir>/.venv
# Sandbox Security Layer — Settings' "Resource Limits" (cpu_percent/memory_mb/
# disk_mb), applied to every run_command/install_package this context runs.
# None (default) = no limits, matching pre-existing behavior.
resource_limits: Optional[Dict[str, float]] = None
# Sandbox Security Layer — Settings' "Block network for agent commands"
# (policy-level, see deps.py::network_blocked_env). False (default) =
# unrestricted, matching pre-existing behavior.
block_network: bool = False
# Whether the fetch_url tool may read URLs — SEPARATE from block_network
# (reading a web page/share link for info is safe; running networked shell
# commands is the risk). Defaults True; set from agent_security.allow_url_fetch.
allow_url_fetch: bool = True
# Jira read connector config (base_url/email/api_token) — None disables the
# jira_* tools' ability to connect. Populated from config.data["jira"].
jira: Optional[Dict[str, Any]] = None
def resolve(self, rel: str) -> Path:
"""Resolve ``rel`` inside the workdir, rejecting escapes."""
if rel in ("", "."):
return self.workdir
candidate = (self.workdir / rel).expanduser()
try:
resolved = candidate.resolve()
except OSError as exc:
raise ToolError(f"Invalid path: {rel} ({exc})")
root = self.workdir.resolve()
if resolved != root and root not in resolved.parents:
raise ToolError(
f"Refused: '{rel}' is outside the working folder ({root})."
)
return resolved
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# Tool specs advertised to the model # Tool specs advertised to the model
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
@@ -192,6 +142,23 @@ TOOL_SPECS: List[ToolSpec] = [
# Actions gated by the permission gate in confirm mode (auto-approved in Auto-run). # Actions gated by the permission gate in confirm mode (auto-approved in Auto-run).
WRITE_TOOLS = {"write_file", "edit_file", "run_command", "install_package"} WRITE_TOOLS = {"write_file", "edit_file", "run_command", "install_package"}
# name -> handler(ctx, args[, cancel, on_output]) — built once from the split
# infrastructure modules. Replaces the if/elif chain execute_tool used to be.
_HANDLERS: Dict[str, Callable[..., Dict[str, Any]]] = {
"read_file": file_tools.read_file,
"list_dir": file_tools.list_dir,
"write_file": file_tools.write_file,
"edit_file": file_tools.edit_file,
"run_command": command_tools.run_command,
"install_package": command_tools.install_package,
"fetch_url": fetch_tools.fetch_url,
"jira_search": fetch_tools.jira_search,
"jira_get_issue": fetch_tools.jira_get_issue,
}
# Handlers that accept the long-running (cancel, on_output) signature — every
# other handler takes just (ctx, args).
_CANCELLABLE = {"run_command", "install_package"}
def enabled_tool_specs(security_config=None) -> List[ToolSpec]: def enabled_tool_specs(security_config=None) -> List[ToolSpec]:
"""The built-in TOOL_SPECS minus any the admin turned OFF in Monitoring → """The built-in TOOL_SPECS minus any the admin turned OFF in Monitoring →
@@ -301,27 +268,14 @@ def execute_tool(ctx: ToolContext, name: str, args: Dict[str, Any],
labels WHICH agent role made it.""" labels WHICH agent role made it."""
from . import audit_log from . import audit_log
handler = _HANDLERS.get(name)
try: try:
if name == "read_file": if handler is None:
result = _read_file(ctx, args)
elif name == "list_dir":
result = _list_dir(ctx, args)
elif name == "write_file":
result = _write_file(ctx, args)
elif name == "edit_file":
result = _edit_file(ctx, args)
elif name == "run_command":
result = _run_command(ctx, args, cancel, on_output)
elif name == "install_package":
result = _install_package(ctx, args, cancel, on_output)
elif name == "fetch_url":
result = _fetch_url(ctx, args)
elif name == "jira_search":
result = _jira_search(ctx, args)
elif name == "jira_get_issue":
result = _jira_get_issue(ctx, args)
else:
result = {"ok": False, "output": f"Tool not found: {name}"} result = {"ok": False, "output": f"Tool not found: {name}"}
elif name in _CANCELLABLE:
result = handler(ctx, args, cancel, on_output)
else:
result = handler(ctx, args)
except ToolError as exc: except ToolError as exc:
result = {"ok": False, "output": str(exc)} result = {"ok": False, "output": str(exc)}
except Exception as exc: # defensive: a tool must never crash the agent except Exception as exc: # defensive: a tool must never crash the agent
@@ -331,234 +285,6 @@ def execute_tool(ctx: ToolContext, name: str, args: Dict[str, Any],
return result return result
def _fetch_url(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
"""Fetch a URL's text content (web page / online document / SharePoint-
OneDrive share link) via link_fetch — the same parser task-link attachments
use. Honors the Sandbox Security Layer's "Block network" policy."""
url = str(args.get("url", "")).strip()
if not url:
return {"ok": False, "output": "fetch_url: 'url' is required."}
if not url.lower().startswith(("http://", "https://")):
return {"ok": False, "output": f"fetch_url: not an http(s) URL: {url}"}
if not ctx.allow_url_fetch:
return {"ok": False,
"output": ("fetch_url: URL fetching is turned off in Settings → Security "
"(\"Allow the agent to fetch URLs\").")}
# A pasted Jira issue link on the CONNECTED Jira host is read via the
# authenticated API (so private issues resolve, not a login page). Public
# links / any other URL fall through to the normal fetcher below.
from . import jira_tool
if jira_tool.is_jira_issue_url(ctx.jira, url):
return {"ok": True, "output": jira_tool.get_issue_by_url(ctx.jira, url)}
from .link_fetch import fetch_link_preview
return {"ok": True, "output": fetch_link_preview(url)}
def _jira_search(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
from . import jira_tool
out = jira_tool.search(ctx.jira, str(args.get("jql", "")),
int(args.get("max_results", 25) or 25))
return {"ok": not out.lower().startswith(("jira is not configured", "jira search failed")),
"output": out}
def _jira_get_issue(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
from . import jira_tool
out = jira_tool.get_issue(ctx.jira, str(args.get("key", "")))
return {"ok": not out.lower().startswith(("jira is not configured", "could not fetch")),
"output": out}
def _read_file(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
target = ctx.resolve(str(args.get("path", "")))
if not target.exists():
return {"ok": False, "output": f"File not found: {args.get('path')}"}
data = target.read_bytes()[:MAX_READ_BYTES]
text = data.decode("utf-8", errors="replace")
return {"ok": True, "output": text}
def _list_dir(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
rel = str(args.get("path", ".") or ".")
target = ctx.resolve(rel)
# A missing/not-yet-created path is NOT a tool failure — report it as an
# ordinary result so the agent can create it or pick another path and keep
# going. Returning ok=False here surfaced a false "tool failed: list_dir" in
# Co4E flows and could stall a step on a recoverable situation.
if not target.exists():
return {"ok": True, "output": f"(path '{rel}' does not exist yet — create it or use another path)"}
if target.is_file():
return {"ok": True, "output": f"('{rel}' is a file, not a directory)"}
entries = []
for child in sorted(target.iterdir(), key=lambda p: (p.is_file(), p.name.lower())):
marker = "/" if child.is_dir() else ""
entries.append(f"{child.name}{marker}")
return {"ok": True, "output": "\n".join(entries) or "(empty folder)"}
def _check_python_syntax(target: Path, content: str) -> str:
"""Return a short warning if ``content`` is invalid Python, else ''.
Catches syntax errors the instant a .py file is written/edited — before the
agent wastes a whole run_command round-trip just to get the same error back
from a traceback."""
if target.suffix.lower() not in (".py", ".pyw"):
return ""
try:
ast.parse(content, filename=str(target))
return ""
except SyntaxError as exc:
return f"\n⚠ Syntax error at line {exc.lineno}: {exc.msg} — fix this before running the file."
def _write_file(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
rel = str(args.get("path", ""))
if ctx.flatten_writes:
rel = _flatten_rel(rel)
target = ctx.resolve(rel)
content = str(args.get("content", ""))
target.parent.mkdir(parents=True, exist_ok=True)
# A .xlsx is a binary package — build a REAL workbook from the content
# (CSV/TSV/Markdown-table/JSON) rather than writing raw text (which corrupts it).
if target.suffix.lower() in (".xlsx", ".xlsm"):
from . import xlsx_write
if xlsx_write.build_xlsx_from_text(target, content):
return {"ok": True, "path": str(target),
"output": f"Wrote spreadsheet {rel} ({target.name})."}
return {"ok": False, "output": "Could not build the .xlsx (openpyxl unavailable) — "
"write a .csv instead, or use a generator script."}
target.write_text(content, encoding="utf-8")
warning = _check_python_syntax(target, content)
return {"ok": True, "path": str(target),
"output": f"Wrote {len(content)} chars to {rel}.{warning}"}
def _edit_file(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
"""Replace an exact snippet inside an existing file (precise patch edit)."""
rel = str(args.get("path", ""))
if ctx.flatten_writes:
rel = _flatten_rel(rel)
target = ctx.resolve(rel)
if not target.exists():
return {"ok": False,
"output": f"File not found: {rel} — use write_file to create it."}
old = str(args.get("old_string", ""))
new = str(args.get("new_string", ""))
replace_all = bool(args.get("replace_all", False))
if not old:
return {"ok": False, "output": "old_string is empty — provide the exact text to replace."}
try:
text = target.read_text(encoding="utf-8", errors="replace")
except OSError as exc:
return {"ok": False, "output": f"Could not read file: {exc}"}
count = text.count(old)
if count == 0:
return {"ok": False, "output": ("old_string not found. Read the file and copy the exact "
"text to replace, including indentation/whitespace.")}
if count > 1 and not replace_all:
return {"ok": False, "output": (f"old_string appears {count} times — add surrounding "
"context to make it unique, or set replace_all=true.")}
updated = text.replace(old, new) if replace_all else text.replace(old, new, 1)
target.write_text(updated, encoding="utf-8")
n = count if replace_all else 1
warning = _check_python_syntax(target, updated)
return {"ok": True,
"output": f"Edited {args.get('path')} ({n} replacement{'' if n == 1 else 's'}).{warning}"}
def _sandbox_python(ctx: ToolContext, cancel: Optional[CancelFn] = None,
on_output: Optional[Callable[[str], None]] = None) -> Optional[str]:
"""Lazily create/reuse this ctx's project sandbox venv (Code tab only —
``ctx.sandbox``); returns its python path, or None to use the app's own."""
if not ctx.sandbox:
return None
from .deps import ensure_project_venv
py = ensure_project_venv(ctx.workdir, cancel=cancel, on_output=on_output)
return str(py) if py else None
def _install_package(ctx: ToolContext, args: Dict[str, Any], cancel: Optional[CancelFn] = None,
on_output: Optional[Callable[[str], None]] = None) -> Dict[str, Any]:
from .deps import pip_install
package = str(args.get("package", "")).strip()
if not package:
return {"ok": False, "output": "No package specified."}
python = _sandbox_python(ctx, cancel, on_output)
ok, detail = pip_install(package, cancel=cancel, on_output=on_output, python=python)
head = f"Installed {package}." if ok else f"Could not install {package}."
return {"ok": ok, "output": f"{head}\n{detail}"}
_SNAPSHOT_SKIP = {".git", "__pycache__", "node_modules", ".scratch", ".venv",
".idea", ".mypy_cache", ".pytest_cache"}
def _snapshot(workdir: Path) -> Dict[str, Any]:
"""Map of file path -> (mtime, size) under the workdir (noise dirs skipped)."""
snap: Dict[str, Any] = {}
try:
for dirpath, dirnames, filenames in os.walk(str(workdir)):
dirnames[:] = [d for d in dirnames if d not in _SNAPSHOT_SKIP]
for fn in filenames:
full = os.path.join(dirpath, fn)
try:
st = os.stat(full)
snap[full] = (st.st_mtime_ns, st.st_size)
except OSError:
pass
if len(snap) > 5000:
return snap
except OSError:
pass
return snap
def _run_command(ctx: ToolContext, args: Dict[str, Any],
cancel: Optional[CancelFn] = None,
on_output: Optional[Callable[[str], None]] = None) -> Dict[str, Any]:
from .deps import network_blocked_env, run_cancellable, sandbox_env
from .sandbox_manager import SandboxManager, ExecutionConfig
from ..security.command_risk_classifier import classify_command
command = str(args.get("command", "")).strip()
if not command:
return {"ok": False, "output": "Empty command."}
# --- Security validation pipeline ---
risk = classify_command(command, is_cowork_mode=ctx.flatten_writes)
if risk.blocked:
denial = "Command blocked by security policy: " + "; ".join(risk.reasons)
return {"ok": False, "output": denial}
# Route through SandboxManager for risk-based isolation
mgr = SandboxManager(ExecutionConfig(
enabled=True,
block_network_by_default=ctx.block_network,
is_cowork_mode=ctx.flatten_writes,
))
sandbox_result = mgr.run(
command=command,
workdir=str(ctx.workdir),
block_network=ctx.block_network,
timeout_sec=COMMAND_TIMEOUT,
cancel=cancel,
)
# Sandbox ALWAYS executes (never double-run). Return its result directly.
if sandbox_result.get("sandbox") == "blocked":
return {"ok": False, "output": sandbox_result.get("stderr", "Command blocked")}
out = sandbox_result.get("stdout", "").strip() or "(no output)"
err = sandbox_result.get("stderr", "")
rc = sandbox_result.get("returncode", -1)
if err:
out = f"{out}\n{err}" if out else err
return {"ok": sandbox_result.get("ok", False), "output": f"[exit {rc}]\n{out}"}
def _short_json(obj: Any, limit: int = 500) -> str: def _short_json(obj: Any, limit: int = 500) -> str:
import json import json
text = json.dumps(obj, ensure_ascii=False, indent=2) text = json.dumps(obj, ensure_ascii=False, indent=2)
-12
View File
@@ -49,18 +49,6 @@ def set_context(source: str, label: str = "") -> None:
_local.label = label _local.label = label
def current_context() -> tuple:
"""The ``(source, label)`` currently tagged on THIS thread.
Public counterpart to :func:`set_context`, added for
``infrastructure/telemetry/usage_sink.py``: a subscriber that needs to
attribute one event to a different surface must be able to save the
caller's context and put it back afterwards, instead of leaving the worker
thread permanently retagged.
"""
return getattr(_local, "source", "") or "", getattr(_local, "label", "") or ""
# ---- per-thread usage accumulator ----------------------------------------- # ---- per-thread usage accumulator -----------------------------------------
# A step/run that wants to know its OWN token/cost (not the all-time file total) # A step/run that wants to know its OWN token/cost (not the all-time file total)
# calls begin_accumulation(), reads accumulated() before/after a unit of work, # calls begin_accumulation(), reads accumulated() before/after a unit of work,
+130 -77
View File
@@ -1,103 +1,156 @@
# ADR-001: 4-Tier Clean Architecture for Desktop Local Application # ADR-001: Kiến Trúc 4 Tầng (Layered / Clean Architecture)
* **Status**: ACCEPTED / ENFORCED * **Status**: Accepted
* **Date**: 2026-08-21 * **Date**: 2026-08-21
* **Deciders**: Team Duy (Tech Lead & AI Runtime), Team Nam (Governance & Automation), Team Hoa (Workspace & Scheduling) * **EPIC / Task**: R01-T01
* **Target Project**: Cowork Local (Cowork-Local BamBOO) * **Owner**: 🔵 Team Duy (Tech Lead)
* **Áp dụng cho**: toàn bộ mã nguồn mới của `cowork_local` (3 team)
--- ---
## 1. Context and Problem Statement ## 1. Context (Bối cảnh)
Cowork Local is a desktop application written in Python using PySide6 (Qt) and designed for local-first execution. `cowork_local` hiện là một ứng dụng PySide6 desktop local-first ~55.000 dòng Python,
Historically, the codebase suffered from architectural coupling across layers: được phát triển nhanh theo hướng feature-first. Hệ quả đo được tại thời điểm viết ADR:
1. **God-Widget Problem**: Monolithic UI widgets (e.g., `ui/chat_panel.py` >1,800 LOC, `ui/co4e_tab.py` >1,400 LOC) mixed UI rendering, network I/O, business rules, filesystem operations, and background worker lifecycle.
2. **Untestable Business Logic**: Core algorithms (model routing, conversation turn management, schedule calculation) were tightly coupled to `PySide6` widgets or `QTimer`, making unit testing in headless CI environments impossible without a graphical display server.
3. **Circular Dependencies & Global State Leaks**: Uncontrolled module imports (`model_pricing.py` ↔ `usage_tracker.py`, `agent_security.py` ↔ `agent_security_alert.py`) and mutable global state (`state.py::AppContext.active_project_id`) caused race conditions in background task runs.
--- | Vấn đề | Bằng chứng cụ thể trong repo |
| :--- | :--- |
| **God widget** | `ui/co4e_tab.py` 2.089 dòng, `ui/chat_panel.py` 1.795 dòng, `ui/folder_tab.py` 1.590 dòng |
| **Business logic nằm trong widget** | Vòng đời turn chat, quyết định routing, ghép prompt đều nằm trong `ui/chat_panel.py` |
| **Logic trùng lặp 3 nơi** | `ui/chat_panel.py::_apply_routing`, `ui/co4e_tab.py::_apply_co4e_routing`, `ui/folder_tab.py::_ai_apply_routing` là ba bản sao gần như y hệt của cùng một thuật toán |
| **Không test được nếu không có Qt** | Muốn test một quyết định routing phải dựng widget → không chạy được headless, không chạy được nhanh |
| **Side-effect ẩn trong tầng hạ tầng** | Provider tự gọi `core.usage_tracker.record()` ngay trong vòng lặp stream (`providers/openai_compat.py::_record_usage`) |
## 2. Decision: 4-Tier Clean Architecture Ba team (Duy / Nam / Hoa) sẽ sửa song song trên cùng codebase trong 10 ngày. Nếu
không có một ranh giới phụ thuộc được **kiểm chứng tự động**, các thay đổi song song
sẽ hội tụ về đúng cấu trúc rối như cũ.
We enforce a strict **4-Tier Clean Architecture** based on the Dependency Inversion Principle: ## 2. Decision (Quyết định)
Mã nguồn mới được tổ chức thành **4 tầng**, với **chiều phụ thuộc một chiều** như sau:
```text ```text
┌─────────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────────┐
│ PRESENTATION │ │ presentation/ PySide6 widgets, Qt signals/slots │
│ (PySide6 Widgets, Dialogs, Qt Signals/Slots, View Models) │ │ (chat, co4e, workspace…) Chỉ dựng UI và phát/nhận signal │
└──────────────────────────────┬──────────────────────────────┘ └───────────────────────────┬─────────────────────────────────┘
│ depends on │ gọi xuống (được phép)
▼ ┌───────────────────────────▼─────────────────────────────────┐
┌─────────────────────────────────────────────────────────────┐ │ application/ Pure Python orchestration │
│ APPLICATION │ │ (conversations, Điều phối use-case, không biết Qt │
│ (Use Case Services, Turn Orchestrators, Route Dispatchers) │ │ model_routing…) và không biết HTTP/đĩa cụ thể │
│ *** STRICTLY PURE PYTHON (0 Qt) *** │ └───────────────────────────┬─────────────────────────────────┘
└──────────────────────────────┬──────────────────────────────┘ │ gọi xuống (được phép)
│ depends on ┌───────────────────────────▼─────────────────────────────────┐
▼ │ domain/ Pure Python entities & events │
┌─────────────────────────────────────────────────────────────┐ │ (agents, models…) Frozen dataclass, enum, quy tắc │
│ DOMAIN & RUNTIME CORE │ │ nghiệp vụ thuần. KHÔNG import gì │
│ (Entities, Value Objects, Domain Events, Tool Descriptors) │ │ từ 3 tầng còn lại. │
│ *** STRICTLY PURE PYTHON (0 Qt) *** │ └───────────────────────────▲─────────────────────────────────┘
└──────────────────────────────▲──────────────────────────────┘ │ implement interface của domain
│ implemented by ┌───────────────────────────┴─────────────────────────────────┐
┌──────────────────────────────┴──────────────────────────────┐ │ infrastructure/ Adapters: network, keyring, đĩa, │
│ INFRASTRUCTURE │ │ (providers, telemetry…) process, Qt-free I/O │
│ (LLM Providers, Keyring Secrets, Atomic Persistence, MCP) │
└─────────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────────┘
``` ```
--- ### 2.1 Quy tắc bất biến (Invariants)
## 3. Layer Definitions and Responsibilities | # | Quy tắc | Được kiểm bởi |
| :--- | :--- | :--- |
| **I1** | `domain/` và `application/` là **100% pure Python** — cấm import `PySide6`, `PyQt5`, `PyQt6`, `shiboken6` | `scripts/check_imports.py` (R01-T03) |
| **I2** | `domain/` **không import** `application/`, `infrastructure/`, `presentation/`, `ui/` | `scripts/check_imports.py` |
| **I3** | `application/` **không import** `presentation/` hay `ui/` | `scripts/check_imports.py` |
| **I4** | Không file production nào vượt **400 dòng** | `scripts/check_loc.py` (R10-T02) |
| **I5** | `presentation/` **không** gọi thẳng provider/HTTP/đĩa — phải đi qua một application service | Code review + I1–I3 |
| **I6** | Mọi input của một use-case được đóng gói thành **snapshot bất biến** (`frozen dataclass`) trước khi rời UI thread | Code review + unit test |
### Tier 1: Presentation Layer (`presentation/`) ### 2.2 Chiều phụ thuộc được phép
* **Responsibilities**: UI component layout, user event capture, progress display, visual animations, confirmation dialog triggers.
* **Allowed Imports**: `PySide6.*`, `application.*`, `domain.*`.
* **Forbidden**: Direct database queries, raw LLM API calls, disk writes outside UI cache, executing tool commands directly.
* **Constraints**: Every widget file must strictly be **under 400 lines of code (LOC)**.
### Tier 2: Application Layer (`application/`) | Từ tầng | Được import | Bị cấm |
* **Responsibilities**: Orchestrate single use cases (e.g. `ConversationApplicationService`, `RoutingApplicationService`, `TaskApplicationService`). Convert UI requests into domain requests, coordinate domain services with infrastructure adapters. | :--- | :--- | :--- |
* **Allowed Imports**: `domain.*`, `infrastructure.*` interfaces/contracts, standard Python libraries. | `presentation/` | `application/`, `domain/`, PySide6 | — (nên tránh gọi thẳng `infrastructure/`) |
* **Forbidden**: `PySide6`, `PyQt5`, `PyQt6`, `ui.*`, `app.*`. | `application/` | `domain/`, interface do `domain/` định nghĩa | `presentation/`, `ui/`, PySide6 |
* **Nature**: **100% Pure Python**. Must be executable and testable in headless CI environments without a display driver. | `domain/` | chỉ stdlib | tất cả các tầng khác, PySide6 |
| `infrastructure/` | `domain/`, thư viện ngoài (requests, keyring…) | `presentation/`, `ui/`, PySide6 |
### Tier 3: Domain Layer (`domain/`) ### 2.3 Cách tầng dưới "nói chuyện ngược" lên UI
* **Responsibilities**: Core domain models, frozen DTO snapshots (`ConversationExecutionRequest`), typed event streams (`AgentEvent`), descriptors (`ToolDescriptor`, `ProviderDescriptor`), deterministic calculation algorithms (`ScheduleCalculator`).
* **Allowed Imports**: Standard Python library only (`dataclasses`, `typing`, `enum`, `datetime`, `pathlib`, `abc`).
* **Forbidden**: `PySide6`, `PyQt*`, `requests`, `sqlalchemy`, filesystem mutations, OS network calls.
* **Nature**: Completely isolated and zero-dependency core.
### Tier 4: Infrastructure Layer (`infrastructure/`) `application/` **không được** giữ tham chiếu tới widget. Việc trao đổi ngược chiều
* **Responsibilities**: Adapters for external systems (OpenAI/Anthropic/Ollama/FPT providers, OS Keyring via `SecretStore`, `AtomicJsonFile` persistence, MCP child processes, filesystem tools). đi qua **callback thuần Python nhận một `AgentEvent` có kiểu**
* **Allowed Imports**: Third-party SDKs, OS libraries, `domain.*`. (`domain/agents/agent_event.py`, R04-T02):
* **Forbidden**: `presentation.*`, `PySide6.QtWidgets`.
--- ```python
# application layer — pure Python, không biết Qt tồn tại
service.run_turn(request, on_event=my_callback)
## 4. Architectural Rules and Non-Negotiable Invariants # presentation layer — chuyển event sang Qt signal ở ranh giới duy nhất này
def my_callback(event: AgentEvent) -> None:
self.agent_event.emit(event) # Qt signal → cập nhật UI trên main thread
```
1. **Zero Qt in Business Logic**: Đây là **seam** duy nhất giữa hai thế giới: dưới seam là Python thuần test được
- `domain/` and `application/` must never import `PySide6` or `PyQt*`. offline, trên seam là Qt. Mọi cập nhật UI phải xảy ra qua Qt signal/slot, không
- Verified via AST parser script `scripts/check_imports.py`. bao giờ gọi trực tiếp từ worker thread.
2. **Immutable Request Snapshots**:
- Turns are initiated using immutable frozen dataclasses (`ConversationExecutionRequest`) to decouple runtime state from mutable UI state.
3. **Thread Safety and Signal Decoupling**:
- AI generation and tool calls run asynchronously in worker threads.
- UI updates occur strictly on the Qt main thread by consuming `AgentEvent` streams through Qt Signal bridges.
4. **Single Responsibility and Modularity**:
- Production files must stay within **400 LOC**.
5. **English In-Code Comments**:
- Every modified or created line/block must include concise English comments explaining design decisions and processing logic.
--- ## 3. Vị trí sở hữu theo team
## 5. Consequences and Compliance | Tầng / thư mục | Team | EPIC |
| :--- | :--- | :--- |
| `presentation/chat/`, `application/conversations/`, `application/model_routing/`, `domain/agents/`, `domain/models/`, `infrastructure/providers/`, `infrastructure/telemetry/`, `tests/`, `scripts/` | 🔵 Duy | R01, R03, R04, R08, R10 |
| `presentation/co4e/`, `monitoring/`, `settings/`, `shell/`, `application/workflows/`, `infrastructure/config/`, `secrets/`, `sandbox/` | 🟣 Nam | R02, R08, R09 |
| `presentation/workspace/`, `folder/`, `scheduling/`, `application/workspaces/`, `scheduling/`, `domain/tools/`, `domain/tasks/`, `infrastructure/filesystem/`, `mcp/`, `persistence/` | 🟢 Hoa | R05, R06, R07, R08 |
* **Positive**: ## 4. Chiến lược di trú (Strangler Fig, không big-bang)
- Full testability: Unit tests run in milliseconds without GUI or network mocks.
- Zero circular dependencies: Clear top-down data flow. Code cũ trong `core/`, `ui/`, `providers/` **không bị xoá ngay**. Ta bọc dần:
- Resilience: UI crashes do not corrupt background tasks or files.
* **Verification**: 1. **Tạo seam mới** ở tầng đúng (ví dụ `RoutingApplicationService`).
- Automated CI gate: `python scripts/check_imports.py` and `python scripts/check_loc.py`. 2. **Chuyển call site** cũ sang gọi seam mới (`ui/*.py` chỉ còn vài dòng adapter).
3. **Giữ module cũ làm implementation detail** phía sau seam (ví dụ
`application/model_routing/` vẫn gọi xuống `core/routing/` để dùng lại
scorer/selector đã có test).
4. Chỉ khi mọi call site đã đi qua seam mới → cân nhắc gỡ code cũ.
Nhờ vậy `pytest` luôn xanh giữa các bước, và một team có thể merge mà không chờ
team khác refactor xong.
## 5. Consequences (Hệ quả)
### Tích cực
* Test một quyết định routing / một vòng đời turn chat **không cần Qt, không cần mạng** → suite unit chạy < 1 giây.
* Ba bản sao logic routing hội tụ về một nơi duy nhất → sửa một lần, cả 3 màn hình cùng đúng.
* Người mới có thể thêm một provider mà chỉ chạm `infrastructure/providers/` + `domain/models/`.
* Vi phạm kiến trúc bị chặn ở CI thay vì phát hiện lúc review.
### Tiêu cực / chi phí phải chấp nhận
* Nhiều file nhỏ hơn thay vì vài file lớn → tăng số lần "nhảy file" khi đọc code.
* Tồn tại **hai đường** trong giai đoạn di trú (code cũ + seam mới) cho tới khi call site cuối cùng chuyển xong.
* Phải viết DTO/snapshot rõ ràng thay vì truyền thẳng `self` của widget — tốn thêm code, đổi lại được thread-safety.
## 6. Alternatives considered (Phương án đã cân nhắc)
| Phương án | Lý do loại |
| :--- | :--- |
| **Giữ nguyên, chỉ tách file cho ngắn** | Giải quyết được I4 (LOC) nhưng không giải quyết được nguyên nhân gốc: logic vẫn dính Qt nên vẫn không test được offline. |
| **MVVM/MVP thuần Qt** | Vẫn buộc business logic phụ thuộc vòng đời Qt object; không chạy được trong scheduler headless và trong task nền. |
| **Hexagonal đầy đủ (port/adapter cho mọi thứ)** | Đúng về lý thuyết nhưng quá tốn cho 10 ngày và cho một app desktop 1 process; 4 tầng là điểm cân bằng. |
| **Big-bang rewrite** | Rủi ro hồi quy quá cao khi 3 team sửa song song và không có bộ test bảo vệ đầy đủ. |
## 7. Enforcement (Thực thi)
```bash
python scripts/check_imports.py # I1, I2, I3 — quét AST
python scripts/check_loc.py # I4 — giới hạn 400 dòng
python scripts/run_quality_gate.py # chạy toàn bộ CASAN Gate + pytest
```
CASAN Verification Gate phải PASS trước khi merge bất kỳ PR nào vào `main`.
## 8. Tài liệu liên quan
* `docs/refactor/Feature_Architecture_Proposal.md` — thiết kế tổng thể 10 EPIC
* `docs/refactor/Refactoring_Checklist.md` — bảng tiến độ theo task
* `docs/architecture/dormant-code.md` — danh mục code không còn hoạt động (R01-T05)
+73 -27
View File
@@ -1,39 +1,85 @@
# Danh Mục & Kế Hoạch Cô Lập Mã Nguồn Dormant / Dead Code (Dormant Code Catalog) # Dormant / Dead Code Inventory (R01-T05)
* **Tài liệu**: `docs/architecture/dormant-code.md` * **Task**: R01-T05 — Phân loại và cô lập mã nguồn cũ
* **Thuộc EPIC**: `R01: Architecture Foundation & Characterization` * **Owner**: 🔵 Team Duy
* **Team phụ trách**: 🔵 **Team Duy (Tech Lead)** * **Ngày quét**: 2026-08-21
* **Phạm vi quét**: toàn bộ `*.py` production (loại trừ `tests/`, `assets/`, `docs/`, `.git/`)
--- ---
## 1. Mục Đích & Nguyên Tắc Quản Trị ## 1. Mục đích
Trong quá trình phát triển nhanh, một số module, hàm hoặc script đã trở thành mã nguồn không hoạt động (**dormant**), mã nguồn thử nghiệm cũ (**legacy prototypes**), hoặc mã nguồn không còn được sử dụng (**dead code**). Trước khi 3 team refactor song song, cần biết **file nào thật sự đang chạy**. Refactor
một module đã chết là lãng phí; xoá nhầm một module chỉ được gọi động là gây sự cố
runtime. Tài liệu này phân loại từng ứng viên, kèm **bằng chứng** và **hành động đề xuất**.
> [!IMPORTANT] ## 2. Phương pháp
> ### 🛡️ NGUYÊN TẮC CÔ LẬP MÃ NGUỒN CŨ:
> 1. **Tuyệt đối không import vào các tầng mới**: Các tầng `domain/`, `application/`, `infrastructure/` mới được xây dựng **cấm tuyệt đối import bất kỳ module dormant nào**.
> 2. **Không xóa vội vàng khi chưa có test bảo vệ**: Giữ nguyên mã nguồn cũ trong giai đoạn tái cấu trúc R01–R08; chỉ dọn dẹp hoặc xóa sau khi bộ kiểm thử khói E2E (EPIC R10) chạy pass 100%.
> 3. **Phân loại rõ ràng trạng thái**: Mỗi module dormant phải được gắn nhãn (DEPRECATED / ISOLATED / PENDING_DELETION).
--- Quét AST toàn repo, dựng đồ thị import, tìm module **không có module nào khác import**.
Kết quả thô: **43 module**. Sau đó xác minh thủ công từng ứng viên, vì phân tích tĩnh
không thấy 3 kiểu tham chiếu:
## 2. Bảng Danh Mục Mã Nguồn Dormant / Dead Code Đã Rà Soát | Kiểu tham chiếu ẩn | Ví dụ thật trong repo |
| :--- | :--- |
| Chạy như subprocess | `state.py:285` gọi `python -m cowork_local.mcp_servers.ms365_server` |
| Entry point của gói | `__main__.py` (chạy bằng `python -m cowork_local`) |
| Script chạy tay | `tools/check_*.py`, `scripts/*.py` |
| STT | File / Module / Ký Hiệu | Trạng Thái Hiện Tại | Lý Do Phân Loại & Phân Tích Kỹ Thuật | Kế Hoạch Xử Lý & Thời Điểm Gỡ Bỏ | > ⚠️ **Kết luận quan trọng**: 43 module "không ai import" **KHÔNG** đồng nghĩa 43 module chết.
| :---: | :--- | :---: | :--- | :--- | > Sau xác minh, chỉ còn **6 hạng mục (~1.887 dòng)** là dormant thật.
| **1** | `requirements (cloud copy).txt` | `PENDING_DELETION` | File sao chép dự phòng tạm thời trong quá khứ, không được tham chiếu bởi bất kỳ quy trình setup nào. | Gỡ bỏ trong EPIC R10 (Packaging & Clean-up). |
| **2** | `preview-desktop` | `ISOLATED` | Script shell rỗng/phác thảo cho môi trường dev container cũ. | Cô lập, không liên kết vào build workflow. |
| **3** | `scripts/bootstrap_gitea_repo.py` | `ISOLATED` | Script tiện ích bootstrap kho lưu trữ Gitea nội bộ; không thuộc runtime ứng dụng chính. | Di chuyển vào `docs/gitea/` làm tài liệu tham khảo ops. |
| **4** | Hàm routing sao chép tại `ui/chat_panel.py#L638` | `DEPRECATED` | Đoạn code logic chọn model lặp lại từ `core/routing/` nằm trực tiếp trong UI widget. | Thay thế hoàn toàn bằng `RoutingApplicationService` trong EPIC R03. |
| **5** | Biến toàn cục `state.py::active_project_id` | `DEPRECATED` | Biến global mutable gây race condition khi chạy background task song song. | Thay thế bằng `WorkspaceSession` trong EPIC R06. |
| **6** | Các hàm xử lý UI đồng bộ trong `core/tools.py` | `DEPRECATED` | `core/tools.py` chứa mã monolithic vừa xử lý file vừa gọi dialog xác thực trực tiếp. | Phân rã thành `file_tools.py`, `command_tools.py` và `ToolPolicyGateway` trong EPIC R05. |
--- ## 3. Phân loại kết quả
## 3. Quy Trình Cô Lập & Kiểm Soát ### 🟥 A. DORMANT THẬT — không có đường nào chạy tới (ứng viên xoá)
1. **Kiểm tra tự động qua AST Guard**: | Module | LOC | Bằng chứng | Rủi ro khi xoá | Hành động |
- Bộ script `scripts/check_imports.py` tự động quét để đảm bảo không có bất kỳ import mới nào trỏ tới các thành phần đã đánh dấu deprecated. | :--- | ---: | :--- | :--- | :--- |
2. **Kế hoạch dọn dẹp cuối cùng (Release Phase - 31/08/2026)**: | `ui/accounts_tab.py` | 700 | Chỉ xuất hiện trong comment của `i18n.py:92`; không widget nào khởi tạo `AccountsTab` | Thấp — panel Monitoring → Accounts hiện không có đường vào | Cô lập, chờ xác nhận PO rồi xoá |
- Sau khi hoàn thành EPIC R10 và pass toàn bộ bài test E2E (`tests/e2e/test_smoke.py`), các file đánh dấu `PENDING_DELETION` sẽ được gỡ bỏ khỏi nhánh `main`. | `ui/flow_dialog.py` | 596 | Chỉ được nhắc trong docstring `ui/agent_manager_tab.py:4` và comment `i18n.py:2124` | Trung bình — Flow Manager có thể là tính năng tạm ẩn | **Hỏi PO trước**, chưa xoá |
| `security/` (cả package) | 296 | `prompt_validator`, `action_validator`, `attachment_validator`, `audit_logger`, `command_risk_classifier` — không file nào ngoài package tự import. Chức năng **trùng** `core/agent_security.py` + `core/security_rules.py` (đang chạy thật) | Trung bình — dễ nhầm đây là lớp bảo mật đang hoạt động | ⚠️ Ưu tiên cao: xoá hoặc hợp nhất trong **R09 (Team Nam)** |
| `core/codebase_memory_ui.py` | 123 | Không nơi nào import; `core/codebase_memory.py` (bản không-UI) mới là bản đang dùng | Thấp | Xoá |
| `core/graph_server.py` | 115 | Docstring nói phục vụ build không có QtWebEngine, nhưng **không có call site nào**; `ui/structure_graph_view.py` không gọi | Trung bình — có thể là fallback cho bản .exe chưa nối dây | Xác minh với bản đóng gói PyInstaller trước khi xoá |
| `ui/mcp_servers_dialog.py` | 57 | Không import; MCP settings hiện nằm trong `ui/settings_dialog.py` | Thấp | Xoá |
**Tổng: ~1.887 dòng (≈ 3,4% codebase).**
### 🟨 B. KHÔNG CHẾT — chạy qua đường ẩn (giữ nguyên)
| Module | Vì sao phân tích tĩnh báo nhầm |
| :--- | :--- |
| `__main__.py` | Entry point `python -m cowork_local` |
| `mcp_servers/ms365_server.py` | Chạy như tiến trình con — `state.py:285` |
| `core/routing/__init__.py` | Được import qua đường dẫn con (`from .routing.service import RoutingService`), heuristic theo tên lá không thấy |
| `tools/check_*.py` (34 file, 6.608 dòng) | Bộ smoke-test UI chạy tay: `python tools/check_nav.py`. Là **dev tooling**, không phải code chết |
| `scripts/bootstrap_gitea_repo.py`, `scripts/check_imports.py` | Script CLI chạy tay / chạy trong CI |
### 🟩 C. CODE SỐNG NHƯNG "ĐÓNG BĂNG" — đụng vào phải cẩn thận
| Module | LOC | Ghi chú cho người refactor |
| :--- | ---: | :--- |
| `core/chat_agent.py::run_cowork` | 580 | Đang có **characterization test** (`tests/characterization/test_run_cowork.py`, R01-T04). Mọi thay đổi hành vi phải làm cùng lúc với cập nhật snapshot |
| `providers/base.py` | 401 | Là contract chung của mọi provider; đổi chữ ký = vỡ cả 3 team. Đã có contract test (R03-T01) |
| `core/routing/*` | 2.263 | Đã có 79 test đang xanh. R03 **bọc** chứ không viết lại: `application/model_routing/` gọi xuống đây |
## 4. Quy tắc xử lý (bắt buộc)
1. **Không xoá trong cùng PR với refactor.** Xoá code chết là một commit riêng, để `git revert` được độc lập khi có sự cố.
2. **Cô lập trước, xoá sau.** Đánh dấu module bằng docstring cảnh báo, chạy 1 vòng release; không ai báo lỗi mới xoá.
3. **Hạng mục 🟥 A cần một người xác nhận** (PO hoặc chủ tính năng) trước khi xoá — trừ khi rõ ràng là bản trùng lặp (`codebase_memory_ui`, `mcp_servers_dialog`).
4. **Không refactor code trong nhóm 🟥 A.** Nếu một file trong danh sách này >400 dòng, nó **không** tính vào CASAN Check 2 — vì đường đi đúng là xoá, không phải tách nhỏ.
## 5. Việc cần bàn giao
| Hạng mục | Team nhận | EPIC |
| :--- | :--- | :--- |
| `security/` trùng lặp với `core/agent_security.py` | 🟣 Nam | R09 |
| `ui/accounts_tab.py`, `ui/flow_dialog.py`, `ui/mcp_servers_dialog.py` | 🟣 Nam (sở hữu `presentation/shell/`, `settings/`) | R08 |
| `core/graph_server.py`, `core/codebase_memory_ui.py` | 🟢 Hoa (sở hữu `presentation/graph/`) | R06 |
## 6. Cách chạy lại lần quét này
```bash
python scripts/check_imports.py # ranh giới kiến trúc (R01-T03)
# Bản quét đồ thị import dùng cho tài liệu này sẽ được đóng gói thành
# scripts/find_dormant.py trong R10-T02 (Testing & Governance tooling).
```
-58
View File
@@ -1,58 +0,0 @@
# Project Context MCP — hướng dẫn làm song song
Mục tiêu: hoàn thiện ba tool trên **cùng một server** `project_context`. Không tạo server, registry,
policy hay error envelope mới. Shared skeleton đã khóa sẵn thứ tự an toàn:
```text
validate input → policy ALLOW → resolve provider → gọi upstream → validate output
```
## Chia việc
| Người | Tool | Chỉ sửa | Branch đề xuất |
|---|---|---|---|
| Member A | `get_project_issue_context` | `tools/issue_context.py`, `providers/issue.py`, test riêng | `feat/mcp-issue-context` |
| Member B | `search_project_knowledge` | `tools/knowledge_search.py`, `providers/knowledge.py`, test riêng | `feat/mcp-knowledge-search` |
| Member C | `get_project_change_context` | `tools/change_context.py`, `providers/change.py`, test riêng | `feat/mcp-change-context` |
Trước khi gửi task, thay `Member A/B/C` bằng username thật trên ba issue. Mỗi người **không sửa**
`foundation.py`, `registry.py`, `runtime.py`, `server.py` hoặc file của người khác. Nếu shared contract
cần đổi, mở một PR nhỏ riêng và để cả ba người rebase sau khi PR đó merge.
## Bắt đầu trong 5 phút
1. Chạy `python --version` và xác nhận Python 3.11+ như baseline trong `requirements.txt`.
2. Tạo branch từ commit template chứa tài liệu này sau khi PR template merge.
3. Đọc input/output model trong module tool được giao; không thêm field riêng của Gitea/Jira/Redmine.
4. Implement provider read-only trong module `providers/<tool>.py`; credential chỉ lấy sau policy ALLOW.
5. Thêm test happy, invalid, not-found, timeout, DENIED với `resolver.calls == 0`, output sai schema,
truncation/cursor và source mở được có `revision`.
6. Chạy:
```bash
python -m pytest tests/test_project_context_mcp_template.py tests/test_project_context_<tool>.py -q
```
Lệnh trên chạy trực tiếp từ root repo `cowork_local`; `tests/conftest.py` đã thiết lập import path.
## Definition of Done của từng người
- Tool trả đúng schema, có `project_id` và source gồm `system`, `url`, `revision`, `retrieved_at`.
- Provider-neutral: đổi Gitea sang GitHub/Jira/Redmine không đổi schema hay tool name.
- Sai project bị `DENIED` trước khi resolve credential và trước mọi upstream call.
- Không log/return token; lỗi ngoài dự kiến không lộ exception; read không có side effect.
- Output lớn có `truncated`, `returned`, `remaining`, `next_cursor`; không cắt im lặng.
- Test riêng pass, test shared pass, PR chỉ chạm đúng vùng sở hữu trong bảng trên.
## Chạy server sau khi provider đã cấu hình
```bash
COWORK_MCP_ACTOR_ID=<actor> \
COWORK_MCP_ORG_UNIT=<org> \
COWORK_MCP_CUSTOMER=<customer> \
COWORK_MCP_PROJECT=<project> \
python -m cowork_local.mcp_servers.project_context_server
```
Không commit giá trị môi trường hoặc credential. Cowork kết nối bằng stdio với command Python và
args `-m cowork_local.mcp_servers.project_context_server`.
+233
View File
@@ -0,0 +1,233 @@
# BÁO CÁO KẾT QUẢ — TEAM DUY: EPIC R01, R03, R04
* **Dự án**: Cowork Local (Cowork-Local BamBOO)
* **Team**: 🔵 Team Duy — Core AI, Routing, Turn Runtime & Testing (Tech Lead)
* **Nhánh**: `feature/deltateam/refactor-plan`
* **Thời gian thực hiện**: 21/08/2026, 09:56 ➔ 10:56
* **Ngày báo cáo**: 21/08/2026
* **Tài liệu gốc**: `Feature_Architecture_Proposal.md`, `Refactoring_Checklist.md`, `DeltaTeam_prompt.md`
---
## 1. Tóm tắt điều hành
Hoàn tất **16/16 task** của 3 EPIC được giao trong đợt này: **R01** (nền tảng kiến trúc & lưới an toàn), **R03** (hợp nhất provider & routing), **R04** (vòng đời turn hội thoại). Toàn bộ đã commit và push lên nhánh.
| Chỉ số | Kết quả |
| :--- | :--- |
| Task hoàn thành | **16/16** (R01: 5, R03: 6, R04: 5) |
| Commit | 5 |
| File thay đổi | 48 (37 file mới, 11 file sửa) |
| Dòng code | +5.843 / −225 |
| Test | **243 pass** / 44s |
| Test suite nhanh (unit + contract + characterization + routing) | **218 pass / 1,22s** |
| CASAN Check 3 (`scripts/check_imports.py`) | **PASS** — 0 Qt import trong `domain/`, `application/` |
| File production > 400 dòng | **0** |
**3 lỗi thật được phát hiện và sửa trong quá trình làm** (chi tiết mục 5) — trong đó 1 lỗi deadlock sẽ làm treo ứng dụng ngay ở tin nhắn đầu tiên.
---
## 2. Kết quả theo từng EPIC
### 🔹 EPIC R01 — Architecture Foundation & Characterization (5/5)
| Task | Sản phẩm | Ghi chú |
| :--- | :--- | :--- |
| R01-T01 | `docs/architecture/ADR-001-layered-architecture.md` | Định nghĩa 4 tầng, chiều phụ thuộc, 6 quy tắc bất biến I1–I6, chiến lược di trú Strangler Fig |
| R01-T02 | `tests/fakes/fake_provider.py`, `fake_tool_executor.py` | Test double chạy offline, kịch bản hoá, ghi lại mọi lời gọi |
| R01-T03 | `scripts/check_imports.py` (239 dòng) | Quét AST, bắt cả import tương đối (`from ...ui import x`) và import trong thân hàm |
| R01-T04 | `tests/characterization/test_run_cowork.py` | **13 test** chụp snapshot hành vi hiện tại của `run_cowork` trước khi R04 đụng vào |
| R01-T05 | `docs/architecture/dormant-code.md` | Quét đồ thị import: 43 module "không ai import" ➔ xác minh còn **6 hạng mục chết thật (~1.887 dòng)** |
**Điểm đáng chú ý ở R01-T03**: dùng AST thay vì `grep` là bắt buộc — trong repo có nhiều docstring nhắc tên `PySide6` một cách hợp lệ, `grep` sẽ báo nhầm và đội sẽ học cách tắt cổng kiểm duyệt.
**Điểm đáng chú ý ở R01-T05**: 43 module không có importer **không** đồng nghĩa 43 module chết. Sau xác minh thủ công: `__main__.py` là entry point, `mcp_servers/ms365_server.py` chạy bằng subprocess (`state.py:285`), 34 file `tools/check_*.py` là dev tooling chạy tay. Chỉ 6 hạng mục là dormant thật.
### 🔹 EPIC R03 — Model Providers & Routing (6/6)
| Task | Sản phẩm | Ghi chú |
| :--- | :--- | :--- |
| R03-T01 | `tests/contracts/test_providers.py` | **29 contract test**; chạy được cả 2 adapter thật mà **không cần mạng** nhờ thay `Provider._request` bằng SSE đóng hộp |
| R03-T02 | `domain/models/provider_descriptor.py`, `infrastructure/providers/provider_registry.py` | Gom 3 nơi khai báo provider về 1 chỗ |
| R03-T03 | `application/model_routing/routing_application_service.py` | Pure Python, 4 chế độ: Off / Auto / Manual / **Fallback (mới)** |
| R03-T04, T05 | `ui/chat_panel.py`, `ui/co4e_tab.py`, `ui/folder_tab.py` | Gỡ 3 bản sao logic routing |
| R03-T06 | `infrastructure/telemetry/usage_sink.py` | Tách ghi nhận token usage khỏi provider |
**Vấn đề gốc đã giải quyết** — cùng một thuật toán routing tồn tại **3 bản gần giống nhau**:
```
ui/chat_panel.py::_apply_routing (~45 dòng)
ui/co4e_tab.py::_apply_co4e_routing (~38 dòng)
ui/folder_tab.py::_ai_apply_routing (~42 dòng)
```
Cả 3 đều nằm trong widget Qt ➔ **không thể test nếu không dựng cửa sổ**, và đã bắt đầu lệch nhau (mỗi bản xác định "model hiện tại" một kiểu). Nay cả 3 chỉ còn gọi `ctx.routing_application().route_turn(...)` + một callback xác nhận.
**Chế độ Fallback (mới)**: giữ nguyên model người dùng chọn, **chỉ đổi sau khi model đó lỗi**. Đây là chế độ người dùng cần khi họ tin lựa chọn của mình nhưng vẫn muốn lượt chat sống sót qua sự cố nhà cung cấp.
**Bộ từ vựng mode**: trước đây tuple `("off", "auto", "manual")` bị lặp ở **4 chỗ** (`config.py` × 2, `state.py` × 2). Thêm một mode mà quên một chỗ sẽ **âm thầm hạ lựa chọn của người dùng về "off"**. Nay tập trung vào `normalize_mode()` / `is_valid_mode()`.
### 🔹 EPIC R04 — Agent Runtime & Conversation Service (5/5)
| Task | Sản phẩm | Ghi chú |
| :--- | :--- | :--- |
| R04-T01 | `domain/agents/conversation_execution_request.py` | Frozen dataclass, chụp toàn bộ input của 1 turn tại thời điểm submit |
| R04-T02 | `domain/agents/agent_event.py` (370 dòng) | **13 event có kiểu** thay cho dict không kiểu, kèm cầu nối 2 chiều |
| R04-T03 | `application/conversations/conversation_application_service.py` | Điều phối vòng đời turn, không import Qt |
| R04-T04 | `ui/cowork_tab.py::build_job` | Chuyển sang snapshot + service |
| R04-T05 | `core/task_executors.py::_run_agent` | Chuyển sang **cùng** service (trước đây là bản lắp ráp thứ hai, hơi khác) |
**Vấn đề gốc đã giải quyết** — closure trong `build_job` đọc state của widget **từ trong worker thread**:
```python
def job(worker):
provider = self.build_provider() # đọc combo box
proj_ctx = project_context_text(load_project(project_id))
```
Người dùng có thể đổi model, đổi workspace, sửa chỉ dẫn project **trong lúc turn đang chạy**. Turn khi đó chạy trên hỗn hợp state cũ + mới, và hỗn hợp nào phụ thuộc vào thời điểm luồng — đúng loại bug tái hiện mỗi tuần một lần và không bao giờ tái hiện trong test.
**`TurnCompletedEvent`** là tín hiệu kết thúc turn mà engine cũ **hoàn toàn không có**: hiện tại mọi consumer suy ra "xong" từ việc worker thread kết thúc, nên **turn bị huỷ và turn thất bại trông giống hệt nhau** với giao diện.
---
## 3. Kiến trúc sau refactor
```text
presentation/ ui/chat_panel.py, ui/co4e_tab.py, ui/folder_tab.py, ui/cowork_tab.py
│ (chỉ dựng UI, mở dialog xác nhận, render thông báo)
▼
application/ model_routing/routing_application_service.py ← 4 mode routing
conversations/conversation_application_service.py ← vòng đời turn
│ (100% pure Python — cổng kiểm duyệt tự động chặn import Qt)
▼
domain/ agents/conversation_execution_request.py ← snapshot bất biến
agents/agent_event.py ← 13 event có kiểu
models/provider_descriptor.py ← catalog provider
▲
infrastructure/ providers/provider_registry.py telemetry/usage_sink.py
```
**Nguyên tắc di trú (ADR-001 mục 4)**: **không viết lại engine**. `core/chat_agent.py::run_cowork` và `core/routing/*` (2.263 dòng, 79 test đang xanh) vẫn là engine bên dưới; tầng application chỉ sở hữu phần trước đây bị trộn vào UI. Nhờ vậy `pytest` luôn xanh giữa các bước và một team có thể merge mà không phải chờ team khác.
---
## 4. Bằng chứng kiểm thử
### Phân bố test
| Suite | Số test | Thời gian | Vai trò |
| :--- | ---: | ---: | :--- |
| `tests/unit/` | 97 | | Logic thuần, không Qt/mạng |
| `tests/contracts/` | 29 | | Mọi provider phải thoả cùng bộ cam kết |
| `tests/characterization/` | 13 | | Chốt hành vi hiện tại của `run_cowork` |
| `tests/routing/` | 79 | | Có sẵn từ trước, vẫn xanh |
| **Cộng 4 suite nhanh** | **218** | **1,22s** | ✅ đạt CASAN "A — unit < 1s" |
| `tests/integration/` | 25 | 42s | Widget Qt thật (offscreen) + provider kịch bản hoá |
| **Tổng** | **243** | **44s** | |
### Đối chiếu Definition of Done (7 tiêu chí, `DeltaTeam_prompt.md`)
| # | Tiêu chí | Kết quả |
| :--- | :--- | :--- |
| 1 | Mọi file < 400 dòng | ✅ Lớn nhất: `agent_event.py` 370 dòng |
| 2 | 0 import Qt trong `domain/`, `application/` | ✅ `check_imports.py` PASS |
| 3 | Comment tiếng Anh ở mọi khối sửa/mới | ✅ Docstring + giải thích **lý do**, không chỉ mô tả code |
| 4 | Có unit/contract test, pass 100% < 1s | ✅ 218 test / 1,22s |
| 5 | Không hồi quy | ✅ 79 test routing có sẵn vẫn xanh |
| 6 | Ghi Start/End vào Checklist | ✅ 16 task đã tick kèm mốc thời gian |
| 7 | Cổng CASAN | ⚠️ `run_quality_gate.py` thuộc **R10-T02**, chưa viết. Check 3 đã có và PASS |
### Ba đường code đã sửa nhưng ban đầu chưa được thực thi
Sau khi hoàn tất 16 task, rà soát lại phát hiện 3 đường code đã bị sửa nhưng **không test nào chạy qua**. Đã bổ sung **18 test**:
| Đường code | Rủi ro nếu bỏ qua | Test bổ sung |
| :--- | :--- | ---: |
| `task_executors._run_agent` | Autosave History có thể đóng băng ở tin nhắn đầu | 7 |
| `_apply_co4e_routing` / `_ai_apply_routing` | Mới chỉ import được, chưa từng gọi hàm | 11 |
| `confirm_switch(decision)` Manual mode | Thiếu field ➔ **nổ bên trong modal**, nơi khó phát hiện nhất | (nằm trong 11 ở trên) |
---
## 5. Ba lỗi thật phát hiện trong quá trình làm
### 🔴 Lỗi 1 — Deadlock khi khởi tạo routing service
`AppContext.routing_application()` giữ `_routing_lock` rồi gọi `routing()`, vốn cũng lấy **chính lock đó**. `threading.Lock` không reentrant ➔ **treo cứng ngay ở tin nhắn đầu tiên**, không có thông báo lỗi.
*Sửa*: tách `_routing_app_lock` riêng, và resolve engine **trước khi** lấy lock.
### 🟠 Lỗi 2 — Event `notice` bị cầu nối nuốt mất
Bản đầu của `agent_event.py` liệt kê 12 loại event nhưng **thiếu `notice`**. Trong khi đó `notice` được phát ra từ 3 nơi trên đường chạy bình thường:
* `core/agent_security.py` — yêu cầu/lệnh bị Agent Security **chặn**
* `core/context_budget.py` — hội thoại vừa bị tự động nén
* Bộ đọc file đính kèm — file không xử lý được, và tiến độ "đang đọc trang X/Y"
Cầu nối bỏ qua event không nhận diện được (đúng thiết kế, để engine có thể thêm event mới) — nên **người dùng sẽ không bao giờ thấy cảnh báo bảo mật**, hoàn toàn im lặng.
*Sửa*: thêm `NoticeEvent`, **và** thêm test quét mã nguồn engine tìm mọi tag `emit({"type": ...})` rồi bắt lỗi nếu có tag nào chưa có event tương ứng — biến sự im lặng thành test đỏ.
### 🟡 Lỗi 3 — Test đang chạy trên checkout khác
`tests/routing/conftest.py` đẩy thư mục cha vào `sys.path`. Vì thư mục checkout tên là `cowork_local_gitea` (không phải `cowork_local`), lệnh `import cowork_local` **ăn nhầm sang `Desktop\cowork_local`** — một bản checkout khác. Suite báo xanh trên mã nguồn **không phải nhánh đang review**.
*Sửa*: `tests/conftest.py` nạp `__init__.py` theo đường dẫn tuyệt đối và đăng ký vào `sys.modules` trước mọi test.
---
## 6. Cải thiện phụ (không nằm trong yêu cầu task)
| Cải thiện | Ảnh hưởng |
| :--- | :--- |
| `ProviderRegistry.build()` đóng dấu `descriptor.id` lên instance | Sửa việc usage của `ollama` / `github_copilot` / `codex` bị ghi nhận nhầm thành `openai_compat` trên Dashboard. **Chưa nối vào production** — xem mục 7. |
| `ProviderRegistry.build()` copy config trước khi ghi | Trước đây một model do routing chọn có thể ghi đè lên default đã lưu của người dùng |
| `UsageTrackerSink` ghi log ở mức debug khi thất bại | Trước là `except: pass` — mất sạch lý do khi Dashboard hỏng |
| `estimate_tokens` được chốt bằng test so với `core.usage_tracker` | Bảo đảm việc tách telemetry **không làm lệch một con số nào** |
---
## 7. Còn nợ & cần quyết định
| # | Nội dung | Người quyết |
| :--- | :--- | :--- |
| 1 | **`ProviderRegistry` chưa nối vào `state.build_provider_for`** (vẫn dùng `providers/factory.py`). Nối vào sẽ sửa lỗi quy kết usage ở mục 6, **nhưng đổi cách gom dữ liệu lịch sử trên Dashboard**. | Team Duy + PO |
| 2 | **Mode `fallback` chưa có trên toggle UI** — config và service đã hỗ trợ đầy đủ; widget `RoutingToggle` thuộc R08. | Team Duy (R08) |
| 3 | **Đã sửa 2 dòng trong `config.py`** (`routing_mode_for`, `set_routing_mode_for`) để dùng chung bộ từ vựng mode. File này Team Nam đang refactor ở R02-T02. | ⚠️ **Cần báo Team Nam** |
| 4 | **Circular import** `core/model_pricing.py` ↔ `core/usage_tracker.py` chưa xử lý (task ngày 28/08). | Team Duy |
| 5 | **2 test đỏ có sẵn từ trước**: `config.py:108` hardcode `sandbox_pw = "quandh14"` ➔ `tests/test_config_security.py`. Thuộc **EPIC R02 / Team Nam**. | 🟣 Team Nam |
| 6 | `tests/integration/test_routing_surfaces.py` mất 41s do dựng `Co4ETab`/`FolderTab`. Nên gắn marker `slow` khi làm R10. | Team Duy (R10) |
---
## 8. Phạm vi chưa kiểm thử
Nêu rõ để tránh hiểu nhầm mức độ bảo đảm:
* **Chưa mở ứng dụng bằng tay** — mới chạy widget headless (`QT_QPA_PLATFORM=offscreen`), chưa có ai kiểm tra bằng mắt.
* **Chưa gọi provider thật** — toàn bộ dùng `FakeProvider`, không có lưu lượng mạng.
* **Chưa chạy 34 script `tools/check_*.py`** — các script này tự `sys.path.insert` thư mục cha nên sẽ import nhầm checkout khác (đúng lỗi 3 ở mục 5). Cần sửa chúng ở R10.
---
## 9. Việc kế tiếp của Team Duy
| EPIC | Nội dung | Điều kiện |
| :--- | :--- | :--- |
| **R08** (T01 ➔ T06) | Tách `ui/chat_panel.py` (1.795 dòng) thành 6 widget < 400 dòng | Sẵn sàng bắt đầu — `AgentEvent` (R04-T02) chính là kênh dữ liệu 6 widget con sẽ dùng thay vì đọc trực tiếp state của `ChatPanel` |
| **R10** (T01 ➔ T05) | Testing Pyramid, `run_quality_gate.py`, Contributor Recipes, E2E Smoke | Chờ cả 3 team hoàn tất |
---
## 10. Lịch sử commit
| Commit | Nội dung |
| :--- | :--- |
| `bbc09f6` | feat(R01): architecture foundation, offline fakes and characterization net |
| `96bec97` | feat(R03): unify provider catalogue, routing decisions and usage telemetry |
| `a53163e` | feat(R04): immutable turn snapshot, typed agent events, conversation service |
| `15e1d3e` | test(R03/R04): cover the three code paths that were changed but never executed |
| `67b8d2e` | docs(refactor): correct the Team Duy scope block in the checklist |
+198
View File
@@ -0,0 +1,198 @@
# BÁO CÁO KẾT QUẢ — TEAM HOA: EPIC R05, R06
* **Dự án**: Cowork Local (Cowork-Local BamBOO)
* **Team**: 🟢 Team Hoa — Workspace, Filesystem, Scheduling & Tool Registry
* **Nhánh**: `feature/teamhoa/r05-r06` (tạo từ `origin/feature/deltateam/refactor-plan`, chưa push lên remote — xem mục 7)
* **Thời gian thực hiện**: 21/08/2026, 21:40 ➔ 22:57
* **Ngày báo cáo**: 22/08/2026
* **Tài liệu gốc**: `Feature_Architecture_Proposal.md`, `Refactoring_Checklist.md`, `plan.md`
---
## 1. Tóm tắt điều hành
Hoàn tất **10/10 task** của 2 EPIC được giao: **R05** (Tool, MCP & Connector Policy) và **R06** (Workspace, Filesystem & History Isolation). Đã commit 2 commit trên branch cục bộ; **chưa push lên Gitea** — remote từ chối với lỗi quyền ghi (xem mục 7 #1).
| Chỉ số | Kết quả |
| :--- | :--- |
| Task hoàn thành | **10/10** (R05: 5, R06: 5) |
| Commit | 2 (`ae4fe72`, `cf542b7`) |
| File thay đổi | 41 (27 file mới, 14 file sửa — 1 file (`docs/refactor/Refactoring_Checklist.md`) sửa ở cả 2 commit) |
| Dòng code | +3.054 / −459 |
| Test | **283 pass** / 12,5s (283/287 — 4 fail có sẵn từ trước, không do R05/R06) |
| Test suite nhanh (unit + contract + characterization + routing) | **256 pass / 4,5s** |
| CASAN Check 3 (`scripts/check_imports.py`) | **PASS** — 0 Qt import trong `domain/`, `application/` |
| File production > 400 dòng (file mới) | **0** — lớn nhất `domain/tools/tool_registry.py` 125 dòng |
**2 lỗi thật được phát hiện và sửa trong quá trình làm** (chi tiết mục 5): một lỗ hổng bảo mật (MCP/connector tool không qua permission gate) và một race condition (turn chạy ngầm lưu nhầm lịch sử vào project khác).
---
## 2. Kết quả theo từng EPIC
### 🔹 EPIC R05 — Tool, MCP & Connector Policy (5/5)
| Task | Sản phẩm | Ghi chú |
| :--- | :--- | :--- |
| R05-T01 | `domain/tools/tool_descriptor.py`, `tool_registry.py` | `ToolCapability` (Flag: READ/WRITE/EXECUTE/NETWORK, kết hợp được) + `ToolDescriptor` + `ToolRegistry` |
| R05-T02 | `infrastructure/filesystem/{file_tools,command_tools,fetch_tools,tool_context}.py` | Tách if/elif dispatcher của `core/tools.py`; `core/tools.py` còn 291 dòng (từ 566), là shim strangler-fig |
| R05-T03 | `application/conversations/tool_policy_gateway.py` | `ToolPolicyGateway.allow(name, gate, payload)` — thay 2 chỗ check hardcode riêng biệt (`chat_agent.py`, `code_agent.py`) bằng 1 lookup capability |
| R05-T04 | Sửa `core/chat_agent.py`, `core/mcp_client.py` | **Thay đổi hành vi có chủ đích** — xem mục 5, Lỗi 1 |
| R05-T05 | `infrastructure/mcp/mcp_source_manager.py` | Tách lifecycle connection MCP khỏi `state.py::AppContext` |
**Vấn đề gốc đã giải quyết** — cùng một việc "tool này có cần xác nhận trước khi chạy không" tồn tại **3 cách trả lời khác nhau**:
```
core/chat_agent.py::run_cowork name in ("run_command", "install_package")
core/code_agent.py::run_code name in (WRITE_TOOLS | MS365_WRITE_TOOLS)
core/mcp_client.py / ext_connectors.py (không hỏi gì cả)
```
Cách thứ 3 là một lỗ hổng thật, không phải khác biệt thiết kế — xem mục 5.
### 🔹 EPIC R06 — Workspace, Filesystem & History Isolation (5/5)
| Task | Sản phẩm | Ghi chú |
| :--- | :--- | :--- |
| R06-T01 | `domain/workspaces/workspace_session.py` | `WorkspaceSession` — snapshot bất biến (project_id/workspace_root/sandbox_dir/allowed_paths) + `is_allowed(path)`, cùng khuôn với `ConversationExecutionRequest` (R04-T01) |
| R06-T02 | `infrastructure/persistence/json/{atomic_write,workspace_repository_impl,conversation_repository_impl}.py` | **Sửa bug thật** — xem mục 5, Lỗi 2 |
| R06-T03 | `infrastructure/filesystem/execution_workspace.py` | Đặt tên cho quy ước `.scratch` đã có, không đổi vị trí file |
| R06-T04 | Sửa `ui/chat_panel.py` | **Sửa race condition thật** — xem mục 5, Lỗi 3 |
| R06-T05 | `application/workspaces/file_workspace_service.py` | File Explorer/AI Editor gọi `core/tools.py::execute_tool` giống agent, không viết lại logic |
---
## 3. Kiến trúc sau refactor
```text
presentation/ (chưa đổi ở đợt này — ui/chat_panel.py chỉ thêm 1 field "home_history_dir")
│
▼
application/ conversations/tool_policy_gateway.py ← ALLOW/CONFIRM cho mọi tool call
workspaces/file_workspace_service.py ← file ops cho File Explorer/AI Editor
│ (100% pure Python — check_imports.py chặn import Qt)
▼
domain/ tools/{tool_descriptor,tool_registry}.py ← capability + catalogue
workspaces/workspace_session.py ← snapshot workspace bất biến
▲
infrastructure/ filesystem/{file_tools,command_tools,fetch_tools,tool_context,execution_workspace}.py
mcp/mcp_source_manager.py ← lifecycle connection MCP
persistence/json/{atomic_write,*_repository_impl}.py
```
**Nguyên tắc di trú (ADR-001 mục 4, tiếp nối cách Team Duy làm ở R04)**: **không viết lại engine**. `core/tools.py::execute_tool`, `core/chat_agent.py::run_cowork`, `core/code_agent.py::run_code` vẫn là engine bên dưới — tầng mới chỉ sở hữu phần phân loại rủi ro (R05) và phần định danh workspace (R06) mà trước đây nằm rải rác/hardcode. `pytest` xanh liên tục giữa các bước.
---
## 4. Bằng chứng kiểm thử
### Phân bố test (bao gồm test mới của Team Hoa)
| Suite | Số test | Ghi chú |
| :--- | ---: | :--- |
| `tests/unit/` | 137 | +41 test mới (R05: 26, R06: 15 — không tính `test_history_dir_race.py`, ở `integration/`) |
| `tests/contracts/` | 29 | có sẵn từ R03, không đổi |
| `tests/characterization/` | 13 | có sẵn từ R01, vẫn xanh — xác nhận `run_cowork` không hồi quy sau khi sửa gate |
| `tests/routing/` | 79 | có sẵn từ trước, không đụng |
| **Cộng 4 suite nhanh** | **256** (4 fail routing-env, không do R05/R06) | 4,5s |
| `tests/integration/` | 27 | +2 test mới: `test_history_dir_race.py` — Qt offscreen thật, không phải test double |
| **Tổng** | **287** (283 pass) | 12,5s |
### Đối chiếu Definition of Done (theo `DeltaTeam_prompt.md` / mẫu Team Duy)
| # | Tiêu chí | Kết quả |
| :--- | :--- | :--- |
| 1 | Mọi file mới < 400 dòng | ✅ Lớn nhất: `domain/tools/tool_registry.py` 125 dòng |
| 2 | 0 import Qt trong `domain/`, `application/` | ✅ `check_imports.py` PASS |
| 3 | Comment tiếng Anh giải thích lý do ở mọi khối sửa/mới | ✅ |
| 4 | Có unit/contract/integration test, verify bằng chạy thật | ✅ 41 test mới + 2 test Qt offscreen thật cho race condition |
| 5 | Không hồi quy | ✅ 283/287 pass — 4 fail là lỗi có sẵn từ trước R05/R06 (2 EPIC R02, 2 do môi trường máy có Ollama thật) |
| 6 | Ghi Start/End vào Checklist | ✅ 10 task đã tick kèm mốc thời gian |
| 7 | Cổng CASAN (`run_quality_gate.py`, R10-T02) | ⚠️ Chưa viết (thuộc R10, chưa tới lượt) — Check 3 đã PASS |
---
## 5. Hai lỗi thật phát hiện và sửa trong quá trình làm
### 🔴 Lỗi 1 (R05-T04) — Tool MCP/Connector chạy hoàn toàn không qua permission gate
`core/chat_agent.py::run_cowork` có 2 nhánh dispatch tool call: nhánh built-in (`read_file`, `run_command`, ...) đi qua gate xác nhận khi Settings bật "confirm before running commands"; nhánh `extra_tools` (mọi tool từ MCP server hoặc Connector — `core/mcp_client.py`, `core/ext_connectors.py`) gọi thẳng:
```python
if name in extra_names and extra_executor is not None:
...
result = extra_executor(name, args) # KHÔNG có bước xác nhận nào
```
Nghĩa là một MCP server (kể cả server tự cấu hình, hoặc MS365 write-tool như `send_mail`) chạy **auto-run tuyệt đối**, bất kể người dùng đã bật "confirm before running commands" trong Settings hay chưa. Đây không phải khác biệt thiết kế có chủ đích — không có ghi chú, không có toggle riêng cho việc này.
*Sửa*: mọi `extra_tools` được gắn `ToolCapability` mặc định bảo toàn (`WRITE|EXECUTE|NETWORK` — vì MCP không có chuẩn khai báo rủi ro), đăng ký vào registry của turn, và đi qua CÙNG `ToolPolicyGateway` với built-in tools.
**Đây là thay đổi hành vi người dùng sẽ thấy**: khi "confirm before running commands" đang bật, tool MCP/connector từ giờ sẽ hỏi xác nhận — giống `run_command`. Verify bằng test `tests/unit/test_cowork_extra_tool_policy.py` (3 test: rejected trước khi executor chạy, approved thì chạy, `gate=None` vẫn auto-run như cũ).
### 🟠 Lỗi 2 (R06-T04) — Turn chạy ngầm lưu nhầm lịch sử vào project khác
`ui/chat_panel.py::_persist_session` (lưu hội thoại của một turn **chạy ngầm**, không phải conversation đang xem) gọi:
```python
save_conversation(self.ctx.config.history_dir(), ...)
```
`history_dir()` đọc `config._project_history_dir` — một field **dùng chung** trên `AppContext.config`, được `ui/workspace_tab.py::_load_current` ghi đè mỗi lần người dùng đổi project trong màn Workspace. Nếu một turn ở project A còn đang chạy (ví dụ Scheduled Task, hoặc user gõ câu hỏi rồi chuyển sang xem project B ngay) và người dùng đổi sang project B **trước khi** turn đó lưu xong, hội thoại của project A bị ghi nhầm vào thư mục lịch sử của project B.
*Sửa*: thêm `"home_history_dir"` vào dict `ctx` mà mỗi turn đã có sẵn (cùng quy ước với `home_id`/`home_messages`/`home_title` — dict này được author code gốc thiết kế đúng cho mục đích này, chỉ thiếu 1 field), chụp giá trị **tại lúc submit** thay vì đọc sống lúc lưu.
*Kèm 1 phát hiện phụ*: `_save_snapshot` (dùng cho conversation ĐANG XEM) đã có logic đúng từ trước để không ghi đè `project_id` của một turn nền bằng project hiện tại — chỉ riêng **thư mục lưu** là bị bỏ sót, không phải toàn bộ cơ chế bị thiếu.
Verify bằng test Qt offscreen thật (không phải double): `tests/integration/test_history_dir_race.py` — dựng `ChatPanel` thật, giả lập đổi project giữa lúc turn chạy, xác nhận file được lưu đúng thư mục project A.
---
## 6. Cải thiện phụ (không nằm trong yêu cầu task)
| Cải thiện | Ảnh hưởng |
| :--- | :--- |
| `core/projects.py::save_project`, `core/history.py::save_conversation/rename_conversation/set_pinned` chuyển sang ghi atomic (`infrastructure/persistence/json/atomic_write.py`) | Trước đây `path.write_text(json.dumps(...))` không atomic — crash/kill giữa lúc ghi để lại file JSON hỏng, và `load_project`/`load_conversation` coi file hỏng như "không tồn tại" ➔ **mất project hoặc hội thoại âm thầm, không báo lỗi**. Có test giả lập crash giữa lúc ghi xác nhận file cũ không bị hỏng (`tests/unit/test_atomic_write_and_repositories.py`) |
| `McpServerConnection.is_alive()` (mới, `core/mcp_client.py`) | Nhỏ, cộng thêm — cho `McpToolSourceManager` biết một connection cached đã chết (subprocess crash) để khởi động lại, thay vì cache giữ một connection chết vô thời hạn |
---
## 7. Còn nợ & cần quyết định
| # | Nội dung | Người quyết |
| :--- | :--- | :--- |
| 1 | **Branch chưa lên được Gitea** — `git push` bị từ chối: `User permission denied for writing` (pre-receive hook). Cần cấp quyền push cho tài khoản git đang dùng trên máy này, hoặc push bằng tài khoản khác có quyền. | Admin Gitea |
| 2 | **Xung đột file với EPIC R02 (Team Nam)**: R02-T01 giao `infrastructure/persistence/json/atomic_json_file.py`. R06-T02 cần atomic write ngay nên tạo `atomic_write.py` (tên khác, cùng thư mục) — không đụng file của Team Nam, nhưng 2 module cùng mục đích sẽ tồn tại song song cho tới khi hợp nhất. | Team Nam (khi bắt đầu R02-T01) |
| 3 | **`WorkspaceRepository`/`ConversationRepository`/`FileWorkspaceService` chưa có call site thật** — giống tình trạng `ProviderRegistry` của Team Duy ở R03 (mục 7 #1 trong báo cáo Team Duy). Mọi nơi trong production vẫn gọi trực tiếp `core/projects.py`/`core/history.py`/`core/tools.py::execute_tool`. | Team Hoa (nối dây ở EPIC sau) |
| 4 | **R06-T04 không sửa đúng y nguyên `ui/workspace_tab.py::_load_current` như mô tả gốc trong `plan.md`** — bug thật nằm ở điểm ĐỌC (`ui/chat_panel.py::_persist_session`), không phải điểm GHI (`_load_current` chỉ set field, tự nó không đọc lại). Đã sửa đúng điểm đọc, có test thật xác nhận. Việc đổi `_load_current` sang "đồng bộ bằng session id" như plan gốc gợi ý cần tách sâu hơn `WorkspaceTab`/`ChatPanel`, thuộc phạm vi R08 (UI/Application Separation). | Team Duy (R08) |
| 5 | **2 test đỏ có sẵn từ trước, không do R05/R06**: `tests/test_config_security.py` × 2 (EPIC R02/Team Nam, đã ghi nhận từ báo cáo Team Duy) và `tests/unit/test_routing_wiring.py` × 2 (môi trường máy này có Ollama/llama3.1 thật + config routing cục bộ khác giả định "fresh install" của test — nghi là do máy chạy test có cấu hình routing/Ollama khác máy Team Duy dùng, cần Team Duy xác nhận lại trên máy sạch). | Team Nam (#1), Team Duy (#2) |
---
## 8. Phạm vi chưa kiểm thử
Nêu rõ để tránh hiểu nhầm mức độ bảo đảm:
* **R05-T04 (gate cho MCP/connector) chưa test với MCP server thật** — toàn bộ test dùng `ToolSpec` giả (`_EXTRA_SPEC` trong `test_cowork_extra_tool_policy.py`), chưa có tình huống thật với `core/mcp_client.py::McpServerConnection` chạy subprocess thật.
* **`McpToolSourceManager` (R05-T05) chưa test với subprocess MCP thật** — test dùng `_FakeConnection`, không spawn tiến trình. Đã smoke-test `AppContext.build_mcp_tools()` thật (không có server nào cấu hình → chỉ trả về ms365 local tools) nhưng chưa thử ensure/restart trên một server thật.
* **`ui/folder_tab.py`, `ui/file_edit_dialog.py` chưa được nối vào `FileWorkspaceService` (R06-T05)** — dịch vụ tồn tại và có test unit đầy đủ, nhưng chưa xác nhận bằng cách chạy UI thật (đã mở app kiểm tra sau R05, nhưng không lặp lại cho R06's file explorer flow cụ thể).
* **Đã mở app thật 1 lần sau khi sửa `ui/chat_panel.py` (R06-T04)** để xác nhận không crash lúc khởi động — chưa thử tay thao tác "đổi project giữa lúc chat đang trả lời" trên UI thật (chỉ verify bằng test offscreen).
---
## 9. Việc kế tiếp của Team Hoa
| EPIC | Nội dung | Điều kiện |
| :--- | :--- | :--- |
| **R07** (Scheduling & Workflow Runtime) | Tách `TaskRepository`/`ScheduleCalculator` khỏi `QTimer` (`core/task_scheduler.py`), xây `TaskApplicationService` | Phối hợp 🟣 Team Nam (Co4E Workflows) |
| **R08** (T01 ➔ ...) | Phần Team Hoa trong tách UI (`ui/workspace_tab.py`, `ui/folder_tab.py`, `ui/schedule_task_tab.py`, `ui/dashboard_tab.py`, Graph) | Chờ R07 |
| Nối `WorkspaceRepository`/`ConversationRepository`/`FileWorkspaceService` vào call site thật | Xem mục 7 #3 | Có thể làm sớm hơn R07/R08 nếu được yêu cầu |
---
## 10. Lịch sử commit
| Commit | Nội dung |
| :--- | :--- |
| `ae4fe72` | feat(R05): tool capability registry, unified policy gateway, MCP lifecycle manager |
| `cf542b7` | feat(R06): workspace session snapshot, atomic persistence, history-dir race fix |
+126 -107
View File
@@ -20,6 +20,83 @@
--- ---
## 📊 TIẾN ĐỘ THỰC TẾ — TEAM DUY (cập nhật `2026-08-21 10:55`)
> [!NOTE]
> ### ✅ ĐÃ HOÀN TẤT: 16/16 task của **R01, R03, R04** — đã commit & push lên nhánh `feature/deltateam/refactor-plan`
>
> | EPIC | Task | Trạng thái |
> | :--- | :--- | :--- |
> | **R01** Architecture Foundation | T01 → T05 | ✅ 5/5 |
> | **R03** Providers & Routing | T01 → T06 | ✅ 6/6 |
> | **R04** Agent Runtime & Conversation | T01 → T05 | ✅ 5/5 |
>
> **Kiểm chứng (chạy thật, không phải ước lượng):**
> * `pytest tests/` ➔ **243 pass / 2 fail** trong 44s
> * Suite nhanh (`unit + contracts + characterization + routing`) ➔ **218 pass trong 1,16s** (đạt yêu cầu CASAN "A – Automated Tests < 1s cho unit")
> * `python scripts/check_imports.py` ➔ **PASS** (0 Qt import trong `domain/`, `application/`)
> * Mọi file production mới **< 400 dòng** (lớn nhất: `routing_application_service.py` 353 dòng)
> * 2 test fail là **lỗi có sẵn từ trước**, thuộc EPIC **R02**: `config.py` vẫn hardcode `sandbox_pw = "quandh14"` ➔ `tests/test_config_security.py` đỏ
>
> ### 📍 PHẠM VI TEAM DUY & PHẦN CÒN LẠI
> Theo `Feature_Architecture_Proposal.md` (dòng 7) và `DeltaTeam_prompt.md` (dòng 17), Team Duy chủ trì **R01, R03, R04, R08 (phân hệ Chat UI), R10**.
> * ✅ **R01, R03, R04** — xong 16/16 task, đã push.
> * ⬜ **R08 (R08-T01 ➔ R08-T06)** — chưa bắt đầu: tách `ui/chat_panel.py` (1.795 dòng) thành 6 widget < 400 dòng.
> * ⬜ **R10** — làm sau cùng, chờ 3 team hoàn tất.
> * **R02 thuộc 🟣 Team Nam** (xem mục EPIC R02 bên dưới) — đây là nguyên nhân 2 test đỏ ở trên, không phải việc của Team Duy.
>
> ### 📄 BÁO CÁO CHI TIẾT
> Xem `docs/refactor/BaoCao_TeamDuy_R01_R03_R04.md` — kết quả từng EPIC, bằng chứng kiểm thử, 3 lỗi thật đã phát hiện, và phạm vi **chưa** kiểm thử.
>
> ### 📌 CÒN NỢ / CẦN QUYẾT ĐỊNH
> 1. `ProviderRegistry` **chưa nối** vào `state.build_provider_for` (vẫn dùng `providers/factory.py`). Nối vào sẽ sửa luôn lỗi: usage của `ollama`/`github_copilot`/`codex` hiện bị ghi nhận nhầm thành `openai_compat` trên Dashboard — nhưng làm vậy sẽ **đổi cách gom dữ liệu lịch sử**.
> 2. Mode `fallback` đã hỗ trợ ở config + service nhưng **chưa có trên toggle UI** (thuộc R08).
> 3. Đã sửa 2 dòng trong `config.py` (`routing_mode_for` / `set_routing_mode_for`) để dùng chung một bộ từ vựng mode — **cần báo Team Nam** vì file này đang được refactor ở R02.
> 4. Circular import `core/model_pricing.py` ↔ `core/usage_tracker.py` **chưa xử lý** (task ngày 28/08).
> 5. Việc kế tiếp của Team Duy là **R08 phân hệ Chat UI** (6 widget con), rồi **R10** sau cùng.
---
## 📊 TIẾN ĐỘ THỰC TẾ — TEAM HOA (cập nhật `2026-08-21 22:57`)
> [!NOTE]
> ### ✅ ĐÃ HOÀN TẤT: 10/10 task của **R05 + R06** — branch `feature/teamhoa/r05-r06` (tạo từ `origin/feature/deltateam/refactor-plan`, có sẵn nền R01/R03/R04)
>
> | EPIC | Task | Trạng thái |
> | :--- | :--- | :--- |
> | **R05** Tool, MCP & Connector Policy | T01 → T05 | ✅ 5/5 |
> | **R06** Workspace, Filesystem & History Isolation | T01 → T05 | ✅ 5/5 |
>
> **Kiểm chứng (chạy thật):**
> * `pytest tests/` ➔ **283 pass / 4 fail** (+41 test mới cho R05+R06, gồm 2 test Qt offscreen thật trong `tests/integration/test_history_dir_race.py`)
> * 4 fail là **lỗi có sẵn từ trước**, không liên quan R05/R06: 2 trong `test_config_security.py` (EPIC R02, đã ghi nhận bởi Team Duy) + 2 trong `test_routing_wiring.py` (môi trường máy này có Ollama/llama3.1 thật + config routing cục bộ khác "fresh install").
> * `python scripts/check_imports.py` ➔ **PASS** (0 Qt import trong `domain/`, `application/`)
> * Mọi file mới **< 400 dòng** (lớn nhất: `domain/tools/tool_registry.py` 125 dòng). `core/tools.py` giảm từ 566 ➔ 291 dòng.
>
> ### 📄 BÁO CÁO CHI TIẾT
> Xem `docs/refactor/BaoCao_TeamHoa_R05_R06.md` — kết quả từng EPIC, bằng chứng kiểm thử, 2 lỗi thật đã phát hiện (permission gate bị bỏ qua cho MCP tools, race condition lưu nhầm lịch sử), và phạm vi **chưa** kiểm thử.
>
> ### 🔧 TÓM TẮT R06
> * **R06-T01**: `domain/workspaces/workspace_session.py::WorkspaceSession` — snapshot bất biến (project_id, workspace_root, sandbox_dir, allowed_paths) + `is_allowed(path)`.
> * **R06-T02**: `infrastructure/persistence/json/{workspace_repository_impl,conversation_repository_impl}.py` bọc `core/projects.py`/`core/history.py`. **Đã sửa bug thật**: `save_project`/`save_conversation`/`rename_conversation`/`set_pinned` trước đây `path.write_text()` không atomic (crash giữa lúc ghi = file JSON hỏng, `load_project`/`load_conversation` coi file hỏng như "không tồn tại" — mất project/hội thoại âm thầm). Giờ cả 4 hàm ghi qua `infrastructure/persistence/json/atomic_write.py::write_json` (temp file + `os.replace`). Có test giả lập crash giữa lúc ghi xác nhận file cũ không bị hỏng.
> * **R06-T03**: `infrastructure/filesystem/execution_workspace.py::ExecutionWorkspace` — đặt tên cho quy ước `.scratch` đã có sẵn (không đổi vị trí file).
> * **R06-T04**: Sửa race trong `ui/chat_panel.py` (không phải trực tiếp `_load_current`, xem "còn nợ" #2). `ChatPanel._persist_session` (lưu hội thoại của turn CHẠY NGẦM, không phải conversation đang xem) trước đây gọi `self.ctx.config.history_dir()` SỐNG tại thời điểm turn xong — nếu user đổi project khi turn còn chạy (`_load_current` ghi `config._project_history_dir`), turn nền lưu nhầm vào thư mục lịch sử của project MỚI. Fix: thêm `"home_history_dir"` vào dict `ctx` per-turn đã có sẵn (cùng quy ước với `home_id`/`home_messages`/`home_title`), chụp tại lúc submit. Test thật bằng Qt offscreen: `tests/integration/test_history_dir_race.py`.
> * **R06-T05**: `application/workspaces/file_workspace_service.py::FileWorkspaceService` — cho File Explorer/AI Editor gọi `execute_tool` (list_dir/read_file/write_file/edit_file) giống agent, không tự viết lại logic.
>
> ### 🔧 TÓM TẮT R05
> * **R05-T01/T02**: `core/tools.py`'s if/elif dispatcher tách thành `infrastructure/filesystem/{file_tools,command_tools,fetch_tools,tool_context}.py` + `domain/tools/{tool_descriptor,tool_registry}.py`. `core/tools.py` còn lại là shim strangler-fig (re-export `ToolContext`/`ToolError`, dispatch qua dict).
> * **R05-T03**: `application/conversations/tool_policy_gateway.py::ToolPolicyGateway` — thay `if gate is not None and name in ("run_command","install_package")` (chat_agent.py) và `if name in (WRITE_TOOLS|MS365_WRITE_TOOLS)` (code_agent.py) bằng một lookup capability chung. Đã verify bằng test: đúng 2 tool cũ vẫn được gate, không tool nào khác bị ảnh hưởng.
> * **R05-T04 — ⚠️ THAY ĐỔI HÀNH VI CÓ CHỦ ĐÍCH**: trước đây MCP/connector/ext-connector tools (`core/mcp_client.py`, `core/ext_connectors.py`) chạy qua `extra_executor(name, args)` **không hề qua permission gate**. Giờ mọi `extra_tools` được gắn capability mặc định (`WRITE|EXECUTE|NETWORK`, vì MCP không có chuẩn khai báo rủi ro) và đi qua CÙNG `ToolPolicyGateway` như built-in tools. Khi Settings có "confirm before running commands" bật, tool MCP/connector giờ sẽ hỏi xác nhận — người dùng SẼ thấy thêm prompt so với trước. Test: `tests/unit/test_cowork_extra_tool_policy.py`.
> * **R05-T05**: `infrastructure/mcp/mcp_source_manager.py::McpToolSourceManager` — tách lifecycle connection (cache/lock/start-or-skip) ra khỏi `state.py::AppContext` (trước đây inline trong `_mcp_connections`/`_conn_lock`). `AppContext` giờ chỉ gọi `self._mcp_manager.ensure/stop/stop_all`. `_ext_connections` (Connectors CAD/CAE/MS365/Other) KHÔNG thuộc phạm vi T05, vẫn giữ `_conn_lock` riêng như cũ.
>
> ### 📌 CÒN NỢ / CẦN QUYẾT ĐỊNH
> 1. **Xung đột file với EPIC R02 (Team Nam)**: R02-T01 giao `infrastructure/persistence/json/atomic_json_file.py` cho Team Nam. R06-T02 cần atomic write NGAY (bug thật, không chờ được) nên đã tạo `infrastructure/persistence/json/atomic_write.py` — tên khác, cùng thư mục, không đụng file của Team Nam. `core/projects.py`/`core/history.py` đang dùng module này trực tiếp. **Cần Team Nam xác nhận khi bắt đầu R02-T01**: nên hợp nhất `atomic_write.py` vào `atomic_json_file.py` (Team Hoa đổi 4 import) hay giữ 2 module riêng (rủi ro trôi giữa 2 cách ghi atomic).
> 2. **`WorkspaceRepository`/`ConversationRepository`/`FileWorkspaceService` chưa có nơi gọi thật** — giống tình trạng `ProviderRegistry` của Team Duy ở R03. Mọi call site sản xuất (`ui/workspace_tab.py`, `ui/folder_tab.py`, `state.py`, task executors) vẫn dùng trực tiếp `core/projects.py`/`core/history.py`/`core/tools.py::execute_tool` — các class mới là seam cho tầng application ở EPIC sau (R07/R08), chưa nối dây.
> 3. **R06-T04 phạm vi thực tế khác một chút so với mô tả gốc**: bug không nằm ở `ui/workspace_tab.py::_load_current` (hàm đó chỉ *set* `config._project_history_dir`, không tự đọc lại nó) mà ở `ui/chat_panel.py::_persist_session` — nơi một turn chạy ngầm đọc SỐNG giá trị đó lúc turn xong. Đã sửa đúng điểm đọc, có test Qt offscreen thật (`tests/integration/test_history_dir_race.py`), nhưng chưa đổi kiến trúc `_load_current` như plan gốc gợi ý (dùng session id thay biến toàn cục) — việc đó cần tách `ChatPanel`/`WorkspaceTab` sâu hơn, thuộc phạm vi R08 (UI/Application Separation).
> 4. R05/R06 xong toàn bộ — Team Hoa chờ chỉ đạo cho **R07** (Scheduling & Workflow Runtime, phối hợp Team Nam) hoặc merge/review trước khi tiếp tục.
---
## 📌 PHẦN 1: CHECKLIST CHI TIẾT THEO 10 EPIC (R01 ➔ R10) ## 📌 PHẦN 1: CHECKLIST CHI TIẾT THEO 10 EPIC (R01 ➔ R10)
### 🔹 EPIC R01: Architecture Foundation & Characterization (Nền Tảng Kiến Trúc & Test Bảo Vệ) ### 🔹 EPIC R01: Architecture Foundation & Characterization (Nền Tảng Kiến Trúc & Test Bảo Vệ)
@@ -27,15 +104,15 @@
* **Mục tiêu**: Khóa DTO, dựng fakes/test doubles chạy offline không phụ thuộc Qt/mạng, thiết lập script chặn vi phạm kiến trúc. * **Mục tiêu**: Khóa DTO, dựng fakes/test doubles chạy offline không phụ thuộc Qt/mạng, thiết lập script chặn vi phạm kiến trúc.
- [x] **R01-T01 (Team Duy)**: Viết Architecture ADR định rõ ranh giới các tầng ➔ `docs/architecture/ADR-001-layered-architecture.md` - [x] **R01-T01 (Team Duy)**: Viết Architecture ADR định rõ ranh giới các tầng ➔ `docs/architecture/ADR-001-layered-architecture.md`
*Start: `2026-08-21 18:23` | End: `2026-08-21 18:24`* *Start: `2026-08-21 09:56` | End: `2026-08-21 10:00`*
- [x] **R01-T02 (Team Duy)**: Xây dựng `FakeProvider` và `FakeToolExecutor` chạy offline từ `providers/base.py` ➔ `tests/fakes/fake_provider.py` & `tests/fakes/fake_tool_executor.py` - [x] **R01-T02 (Team Duy)**: Xây dựng `FakeProvider` và `FakeToolExecutor` chạy offline từ `providers/base.py` ➔ `tests/fakes/fake_provider.py` & `tests/fakes/fake_tool_executor.py`
*Start: `2026-08-21 18:24` | End: `2026-08-21 18:26`* *Start: `2026-08-21 10:00` | End: `2026-08-21 10:02`*
- [x] **R01-T03 (Team Duy)**: Viết script quét tĩnh chặn code mới trong `domain/` và `application/` import `PySide6` ➔ `scripts/check_imports.py` - [x] **R01-T03 (Team Duy)**: Viết script quét tĩnh chặn code mới trong `domain/` và `application/` import `PySide6` ➔ `scripts/check_imports.py`
*Start: `2026-08-21 18:26` | End: `2026-08-21 18:28`* *Start: `2026-08-21 09:58` | End: `2026-08-21 10:05`*
- [x] **R01-T04 (Team Duy)**: Viết Characterization Tests cho `core/chat_agent.py::run_cowork` ➔ `tests/characterization/test_run_cowork.py` - [x] **R01-T04 (Team Duy)**: Viết Characterization Tests cho `core/chat_agent.py::run_cowork` ➔ `tests/characterization/test_run_cowork.py`
*Start: `2026-08-21 18:28` | End: `2026-08-21 18:32`* *Start: `2026-08-21 10:02` | End: `2026-08-21 10:04`*
- [x] **R01-T05 (Team Duy)**: Lập danh mục và phân loại mã nguồn dormant/dead code ➔ `docs/architecture/dormant-code.md` - [x] **R01-T05 (Team Duy)**: Lập danh mục và phân loại mã nguồn dormant/dead code ➔ `docs/architecture/dormant-code.md`
*Start: `2026-08-21 18:32` | End: `2026-08-21 18:35`* *Start: `2026-08-21 10:04` | End: `2026-08-21 10:05`*
--- ---
@@ -63,71 +140,17 @@
* **Mục tiêu**: Hợp nhất logic routing bị phân tán thành `RoutingApplicationService` độc lập Qt; chuẩn hóa catalog nhà cung cấp. * **Mục tiêu**: Hợp nhất logic routing bị phân tán thành `RoutingApplicationService` độc lập Qt; chuẩn hóa catalog nhà cung cấp.
- [x] **R03-T01 (Team Duy)**: Xây dựng bộ Contract Tests chuẩn hóa cho các Provider từ `providers/base.py` ➔ `tests/contracts/test_providers.py` - [x] **R03-T01 (Team Duy)**: Xây dựng bộ Contract Tests chuẩn hóa cho các Provider từ `providers/base.py` ➔ `tests/contracts/test_providers.py`
*Start: `2026-08-22 18:59` | End: `2026-08-22 19:01`* *Start: `2026-08-21 10:10` | End: `2026-08-21 10:12`*
- [x] **R03-T02 (Team Duy)**: Xây dựng `ProviderDescriptor` và `ProviderRegistry` tập trung từ `providers/factory.py` ➔ `domain/models/provider_descriptor.py` & `infrastructure/providers/provider_registry.py` - [x] **R03-T02 (Team Duy)**: Xây dựng `ProviderDescriptor` và `ProviderRegistry` tập trung từ `providers/factory.py` ➔ `domain/models/provider_descriptor.py` & `infrastructure/providers/provider_registry.py`
*Start: `2026-08-22 18:45` | End: `2026-08-22 18:50`* *Start: `2026-08-21 10:06` | End: `2026-08-21 10:10`*
- [x] **R03-T03 (Team Duy)**: Xây dựng `RoutingApplicationService` độc lập với Qt từ `core/routing/` ➔ `application/model_routing/routing_application_service.py` - [x] **R03-T03 (Team Duy)**: Xây dựng `RoutingApplicationService` độc lập với Qt từ `core/routing/` ➔ `application/model_routing/routing_application_service.py`
*Start: `2026-08-22 18:53` | End: `2026-08-22 18:57`* *Start: `2026-08-21 10:12` | End: `2026-08-21 10:15`*
- [x] **R03-T04 (Team Duy)**: Di chuyển luồng gọi routing từ `ui/chat_panel.py#L638` sang `RoutingApplicationService` - [x] **R03-T04 (Team Duy)**: Di chuyển luồng gọi routing từ `ui/chat_panel.py#L638` sang `RoutingApplicationService`
*Start: `2026-08-22 18:57` | End: `2026-08-22 18:58`* *Start: `2026-08-21 10:17` | End: `2026-08-21 10:20`*
- [x] **R03-T05 (Team Duy)**: Di chuyển luồng gọi routing từ `ui/co4e_tab.py` và `ui/folder_tab.py` sang `RoutingApplicationService` - [x] **R03-T05 (Team Duy)**: Di chuyển luồng gọi routing từ `ui/co4e_tab.py` và `ui/folder_tab.py` sang `RoutingApplicationService`
*Start: `2026-08-22 18:58` | End: `2026-08-22 18:59`* *Start: `2026-08-21 10:20` | End: `2026-08-21 10:22`*
- [x] **R03-T06 (Team Duy)**: Tách logic ghi nhận token usage ra khỏi Provider, chuyển thành `UsageEventSink` ➔ `infrastructure/telemetry/usage_sink.py` - [x] **R03-T06 (Team Duy)**: Tách logic ghi nhận token usage ra khỏi Provider, chuyển thành `UsageEventSink` ➔ `infrastructure/telemetry/usage_sink.py`
*Start: `2026-08-22 18:50` | End: `2026-08-22 18:53`* *Start: `2026-08-21 10:15` | End: `2026-08-21 10:17`*
#### 📦 KẾT QUẢ THỰC HIỆN EPIC R03 (Hoàn tất 2026-08-22 19:01 — nhánh `feature/delta-team/epic-R03`)
**File sản phẩm mới (tất cả < 400 dòng, 100% comment tiếng Anh):**
| Task | File | LOC | Nội dung chính |
| :--- | :--- | :---: | :--- |
| T02 | `domain/models/provider_descriptor.py` | 196 | `ProviderDescriptor` (frozen dataclass), `WireProtocol`, `AuthKind`; giá/context để `None` khi chưa biết thay vì đoán bừa |
| T02 | `infrastructure/providers/provider_registry.py` | 287 | `ProviderRegistry` thread-safe: tra cứu theo id/alias, **tra cứu động theo model ID** (`find_by_model`), dựng adapter theo wire protocol; `BUILTIN_DESCRIPTORS` cho 5 provider |
| T03 | `application/model_routing/routing_models.py` | 158 | DTO thuần Python: `RoutingMode` (Off/Auto/Manual/**Fallback**), `RoutingRequest` (immutable snapshot), `RouteEvaluation`, `RoutingOutcome` |
| T03 | `application/model_routing/routing_application_service.py` | 236 | `RoutingApplicationService` — 1 nơi duy nhất quyết định routing; 2 port hẹp (`RoutingDecisionPort`, `ModeResolver`) + callback confirm ⇒ 0 phụ thuộc Qt |
| T03 | `application/model_routing/core_routing_adapter.py` | 169 | `CoreRoutingEngine` (cầu nối sang `core/routing`), `AppContextModeResolver`, `build_routing_application_service(ctx)` (cache 1 instance/ctx) |
| T06 | `infrastructure/telemetry/usage_sink.py` | 288 | `UsageEvent` + `UsageEventSink` (Protocol) + `UsageTrackerSink` / `InMemoryUsageSink` / `CompositeUsageSink`; publish không bao giờ raise |
**File hiện hữu được sửa (đều có comment tiếng Anh tại mọi khối thay đổi):**
| File | Thay đổi |
| :--- | :--- |
| `providers/factory.py` | Bỏ bảng `_REGISTRY` nội bộ, ủy quyền cho `ProviderRegistry`; vẫn raise `ProviderError` để không vỡ call site cũ |
| `providers/openai_compat.py`, `providers/anthropic.py` | Không còn gọi thẳng `core/usage_tracker`; chỉ **publish** `UsageEvent` qua sink (T06) |
| `ui/chat_panel.py` (#L638), `ui/co4e_tab.py`, `ui/folder_tab.py` | Xóa 3 bản sao logic routing (~35 dòng/file) ➔ gọi chung `RoutingApplicationService` (T04, T05); widget chỉ còn dựng `RoutingRequest`, host modal confirm và render kết quả |
| `config.py`, `state.py`, `ui/routing_toggle.py`, `i18n.py` | Mở đường cho chế độ thứ 4 **Fallback**: hằng `AppConfig.ROUTING_MODES`, validate per-workspace, thêm mục trong combo + chuỗi EN/JA/VI |
| `core/usage_tracker.py` | Thêm `current_context()` để sink mượn/trả lại context của thread thay vì gán đè vĩnh viễn |
| `tests/conftest.py`, `tests/routing/conftest.py` | **Sửa lỗi hạ tầng test nghiêm trọng** (xem "Ghi chú" bên dưới) |
**Bộ test bổ sung (tất cả offline, không cần network/Qt):**
| File | Số test | Phạm vi |
| :--- | :---: | :--- |
| `tests/contracts/test_providers.py` (+ `provider_stubs.py`) | 50 | Contract chạy parametrize trên **mọi** provider trong registry: signature `chat()`, canonical assistant message, tool call chuẩn hóa, đóng response, dịch tool schema, `ProviderError`, `list_models`/`test_connection`, đúng 1 `UsageEvent`/turn |
| `tests/unit/test_routing_application_service.py` | 28 | Đủ 4 chế độ + mọi nhánh degrade (engine lỗi, resolver lỗi, dialog lỗi, thiếu callback) |
| `tests/unit/test_provider_registry.py` | 17 | Descriptor + registry + đối chiếu catalogue với `DEFAULT_CONFIG["providers"]` |
| `tests/unit/test_core_routing_adapter.py` | 12 | Dịch `RouteResult` ⇄ DTO, task type sai định dạng, thiếu ranking, cache service |
| `tests/unit/test_usage_sink.py` | 13 | Fan-out, subscriber lỗi, khôi phục thread context, publish không raise |
| `tests/integration/test_routing_unification.py` | 14 | Chạy `RoutingApplicationService` trên **engine `core/routing` thật**; 3 surface (cowork/co4e/ai_edit) cho ra cùng 1 quyết định |
**Kết quả cổng kiểm duyệt (DoD 7 tiêu chí):**
| # | Tiêu chí | Lệnh | Kết quả |
| :---: | :--- | :--- | :--- |
| 1 | LOC < 400 | `wc -l` các file mới | ✅ Lớn nhất 288 dòng (`usage_sink.py`); `openai_compat.py` 374, `anthropic.py` 332 |
| 2 | Clean Architecture | `python scripts/check_imports.py` | ✅ `[PASS] 0 forbidden imports detected` |
| 3 | Comment tiếng Anh | Review thủ công | ✅ 100% khối code mới/sửa có comment giải thích logic + lý do kiến trúc |
| 4 | Có test tự động | `pytest tests/unit tests/contracts tests/integration` | ✅ 134 test mới, pass 100% |
| 5 | No Regression | `pytest tests/` | ✅ **236 passed in ~2.0s** (nền trước R03: 102 passed) |
| 6 | Timestamps | Bảng trên | ✅ Đã ghi Start/End cho T01–T06 |
| 7 | CASAN Gate | `scripts/run_quality_gate.py` | ⚠️ Script **chưa tồn tại** — thuộc R10-T02 (chưa làm). Đã chạy thay bằng `check_imports.py` + `pytest tests/` |
**Ghi chú kỹ thuật cần biết khi review:**
1. **Đã sửa 1 lỗi hạ tầng test có thể gây kết quả sai lệch**: `tests/conftest.py` cũ đẩy thư mục **cha** của repo vào `sys.path`, nên `import cowork_local.*` (dùng bởi `tests/routing/*` và `tests/characterization/*`) trỏ sang **một checkout `cowork_local` khác** nằm cạnh thư mục làm việc — test vẫn báo xanh nhưng chạy trên mã nguồn khác. Nay conftest bind thẳng checkout hiện tại vào `sys.modules["cowork_local"]`.
2. **Chế độ Fallback** là chế độ *chống gãy*, không phải chế độ tối ưu: giữ nguyên model người dùng chọn kể cả khi có model điểm cao hơn, chỉ chuyển khi model đó **không phục vụ được** turn (không có trong ranking / unavailable / probe fail). Engine `core/routing` không cần biết chế độ này — service map Fallback ➔ Auto khi hỏi ranking rồi tự áp luật chấp nhận riêng.
3. **T06 hiện tại**: provider publish `UsageEvent`; khi R04 dựng xong `AgentEvent` bus thì `ConversationApplicationService` sẽ là nơi phát sự kiện, sink giữ nguyên không phải sửa.
4. **Cần cài `mcp>=1.0.0`** (đã có trong `requirements.txt`) để `tests/test_project_context_mcp_template.py` collect được — thiếu gói này toàn bộ suite bị interrupt.
--- ---
@@ -135,16 +158,16 @@
* **Team chịu trách nhiệm**: 🔵 **Team Duy** (Chủ trì) * **Team chịu trách nhiệm**: 🔵 **Team Duy** (Chủ trì)
* **Mục tiêu**: Đóng gói input turn chat thành `ConversationExecutionRequest` bất biến, điều phối vòng đời qua `ConversationApplicationService` và phát sinh sự kiện `AgentEvent` có định kiểu. * **Mục tiêu**: Đóng gói input turn chat thành `ConversationExecutionRequest` bất biến, điều phối vòng đời qua `ConversationApplicationService` và phát sinh sự kiện `AgentEvent` có định kiểu.
- [ ] **R04-T01 (Team Duy)**: Định nghĩa immutable dataclass `ConversationExecutionRequest` ➔ `domain/agents/conversation_execution_request.py` - [x] **R04-T01 (Team Duy)**: Định nghĩa immutable dataclass `ConversationExecutionRequest` ➔ `domain/agents/conversation_execution_request.py`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`* *Start: `2026-08-21 10:23` | End: `2026-08-21 10:25`*
- [ ] **R04-T02 (Team Duy)**: Chuẩn hóa các sự kiện `AgentEvent` (TextChunk, ToolCallStarted, ToolCallResult, Error) ➔ `domain/agents/agent_event.py` - [x] **R04-T02 (Team Duy)**: Chuẩn hóa các sự kiện `AgentEvent` (TextChunk, ToolCallStarted, ToolCallResult, Error) ➔ `domain/agents/agent_event.py`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`* *Start: `2026-08-21 10:22` | End: `2026-08-21 10:23`*
- [ ] **R04-T03 (Team Duy)**: Xây dựng `ConversationApplicationService` điều phối thực thi từ `core/chat_agent.py` ➔ `application/conversations/conversation_application_service.py` - [x] **R04-T03 (Team Duy)**: Xây dựng `ConversationApplicationService` điều phối thực thi từ `core/chat_agent.py` ➔ `application/conversations/conversation_application_service.py`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`* *Start: `2026-08-21 10:25` | End: `2026-08-21 10:27`*
- [ ] **R04-T04 (Team Duy)**: Di chuyển `ui/cowork_tab.py::build_job` sang sử dụng `ConversationExecutionRequest` - [x] **R04-T04 (Team Duy)**: Di chuyển `ui/cowork_tab.py::build_job` sang sử dụng `ConversationExecutionRequest`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`* *Start: `2026-08-21 10:27` | End: `2026-08-21 10:31`*
- [ ] **R04-T05 (Team Duy)**: Di chuyển `core/task_executors.py` sang dùng chung `ConversationApplicationService` - [x] **R04-T05 (Team Duy)**: Di chuyển `core/task_executors.py` sang dùng chung `ConversationApplicationService`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`* *Start: `2026-08-21 10:28` | End: `2026-08-21 10:30`*
--- ---
@@ -152,16 +175,16 @@
* **Team chịu trách nhiệm**: 🟢 **Team Hoa** (Chủ trì) + Phối hợp Team Duy * **Team chịu trách nhiệm**: 🟢 **Team Hoa** (Chủ trì) + Phối hợp Team Duy
* **Mục tiêu**: Bóc tách monolithic `core/tools.py`, đưa toàn bộ Built-in tools, MCP tools và Connectors qua `ToolPolicyGateway` kiểm tra quyền phân tầng. * **Mục tiêu**: Bóc tách monolithic `core/tools.py`, đưa toàn bộ Built-in tools, MCP tools và Connectors qua `ToolPolicyGateway` kiểm tra quyền phân tầng.
- [ ] **R05-T01 (Team Hoa)**: Định nghĩa `ToolDescriptor`, `ToolCapability` (read/write/execute/network) ➔ `domain/tools/tool_descriptor.py` & `domain/tools/tool_registry.py` - [x] **R05-T01 (Team Hoa)**: Định nghĩa `ToolDescriptor`, `ToolCapability` (read/write/execute/network) ➔ `domain/tools/tool_descriptor.py` & `domain/tools/tool_registry.py`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`* *Start: `2026-08-21 21:40` | End: `2026-08-21 21:47`*
- [ ] **R05-T02 (Team Hoa)**: Tách nhỏ các built-in handlers từ `core/tools.py` ➔ `infrastructure/filesystem/file_tools.py`, `command_tools.py`, `fetch_tools.py` - [x] **R05-T02 (Team Hoa)**: Tách nhỏ các built-in handlers từ `core/tools.py` ➔ `infrastructure/filesystem/file_tools.py`, `command_tools.py`, `fetch_tools.py`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`* *Start: `2026-08-21 21:47` | End: `2026-08-21 21:56`*
- [ ] **R05-T03 (Team Hoa)**: Xây dựng `ToolPolicyGateway` (kiểm tra phân quyền allow/confirm/deny) ➔ `application/conversations/tool_policy_gateway.py` - [x] **R05-T03 (Team Hoa)**: Xây dựng `ToolPolicyGateway` (kiểm tra phân quyền allow/confirm/deny) ➔ `application/conversations/tool_policy_gateway.py`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`* *Start: `2026-08-21 21:56` | End: `2026-08-21 22:04`*
- [ ] **R05-T04 (Team Hoa)**: Chuẩn hóa MCP tools từ `core/mcp_client.py` đi qua `ToolPolicyGateway` - [x] **R05-T04 (Team Hoa)**: Chuẩn hóa MCP tools từ `core/mcp_client.py` đi qua `ToolPolicyGateway`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`* *Start: `2026-08-21 22:04` | End: `2026-08-21 22:12`*
- [ ] **R05-T05 (Team Hoa)**: Xây dựng `McpToolSourceManager` quản lý vòng đời tiến trình MCP ➔ `infrastructure/mcp/mcp_source_manager.py` - [x] **R05-T05 (Team Hoa)**: Xây dựng `McpToolSourceManager` quản lý vòng đời tiến trình MCP ➔ `infrastructure/mcp/mcp_source_manager.py`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`* *Start: `2026-08-21 22:12` | End: `2026-08-21 22:19`*
--- ---
@@ -169,16 +192,16 @@
* **Team chịu trách nhiệm**: 🟢 **Team Hoa** (Chủ trì) * **Team chịu trách nhiệm**: 🟢 **Team Hoa** (Chủ trì)
* **Mục tiêu**: Xóa bỏ biến toàn cục `state.py::active_project_id`, đóng gói workspace per-turn thành `WorkspaceSession` bất biến, bảo vệ an toàn đường dẫn sandbox. * **Mục tiêu**: Xóa bỏ biến toàn cục `state.py::active_project_id`, đóng gói workspace per-turn thành `WorkspaceSession` bất biến, bảo vệ an toàn đường dẫn sandbox.
- [ ] **R06-T01 (Team Hoa)**: Định nghĩa `WorkspaceSession` chứa snapshot project id, workspace root ➔ `domain/workspaces/workspace_session.py` - [x] **R06-T01 (Team Hoa)**: Định nghĩa `WorkspaceSession` chứa snapshot project id, workspace root ➔ `domain/workspaces/workspace_session.py`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`* *Start: `2026-08-21 22:19` | End: `2026-08-21 22:24`*
- [ ] **R06-T02 (Team Hoa)**: Xây dựng `WorkspaceRepository` từ `core/projects.py` & `ConversationRepository` từ `core/history.py` ➔ `infrastructure/persistence/json/workspace_repository_impl.py` - [x] **R06-T02 (Team Hoa)**: Xây dựng `WorkspaceRepository` từ `core/projects.py` & `ConversationRepository` từ `core/history.py` ➔ `infrastructure/persistence/json/workspace_repository_impl.py`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`* *Start: `2026-08-21 22:24` | End: `2026-08-21 22:35`*
- [ ] **R06-T03 (Team Hoa)**: Xây dựng `ExecutionWorkspace` quản lý output/scratch files ➔ `infrastructure/filesystem/execution_workspace.py` - [x] **R06-T03 (Team Hoa)**: Xây dựng `ExecutionWorkspace` quản lý output/scratch files ➔ `infrastructure/filesystem/execution_workspace.py`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`* *Start: `2026-08-21 22:35` | End: `2026-08-21 22:40`*
- [ ] **R06-T04 (Team Hoa)**: Khắc phục race condition trong `ui/workspace_tab.py::_load_current` - [x] **R06-T04 (Team Hoa)**: Khắc phục race condition trong `ui/workspace_tab.py::_load_current`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`* *Start: `2026-08-21 22:40` | End: `2026-08-21 22:50`*
- [ ] **R06-T05 (Team Hoa)**: Xây dựng `FileWorkspaceService` xử lý thao tác file cho File Explorer và AI File Editor ➔ `application/workspaces/file_workspace_service.py` - [x] **R06-T05 (Team Hoa)**: Xây dựng `FileWorkspaceService` xử lý thao tác file cho File Explorer và AI File Editor ➔ `application/workspaces/file_workspace_service.py`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`* *Start: `2026-08-21 22:50` | End: `2026-08-21 22:57`*
--- ---
@@ -283,20 +306,16 @@
| Ngày | Task Cần Hoàn Thành | Start Time | End Time | Trạng Thái | | Ngày | Task Cần Hoàn Thành | Start Time | End Time | Trạng Thái |
| :--- | :--- | :---: | :---: | :---: | | :--- | :--- | :---: | :---: | :---: |
| **21/08 (T6)** | Khóa DTO `ConversationExecutionRequest`, `AgentEvent`; Xây dựng `FakeProvider`, `FakeToolExecutor` | `2026-08-21 18:23` | `2026-08-21 18:35` | [x] | | **21/08 (T6)** | Khóa DTO `ConversationExecutionRequest`, `AgentEvent`; Xây dựng `FakeProvider`, `FakeToolExecutor` | `2026-08-21 09:56` | `2026-08-21 10:25` | [x] |
| **22-23/08 (T7-CN)** | Chuẩn hóa `ProviderDescriptor`, `ProviderRegistry`; Wrap OpenAI, Anthropic, Ollama, FPT Gateway; Viết Contract Tests | `2026-08-22 18:45` | `2026-08-22 19:01` | [x] | | **22-23/08 (T7-CN)** | Chuẩn hóa `ProviderDescriptor`, `ProviderRegistry`; Wrap OpenAI, Anthropic, Ollama, FPT Gateway; Viết Contract Tests | `2026-08-21 10:06` | `2026-08-21 10:12` | [x] ⚠️ registry chưa nối vào `state.build_provider_for` |
| **24/08 (T2)** | Xây dựng `RoutingApplicationService` độc lập Qt; Tách `ComposerWidget` & `AttachmentPicker` | `2026-08-22 18:53` | `2026-08-22 18:57` | [~] | | **24/08 (T2)** | Xây dựng `RoutingApplicationService` độc lập Qt; Tách `ComposerWidget` & `AttachmentPicker` | `2026-08-21 10:12` | `2026-08-21 10:15` | [~] RoutingApplicationService xong; tách widget thuộc R08 |
| **25/08 (T3)** | Xây dựng `ConversationApplicationService`; Tách `ChatHistoryWidget` và bubble renderer | `____-__-__ __:__` | `____-__-__ __:__` | [ ] | | **25/08 (T3)** | Xây dựng `ConversationApplicationService`; Tách `ChatHistoryWidget` và bubble renderer | `2026-08-21 10:25` | `2026-08-21 10:27` | [~] Service xong; tách widget thuộc R08 |
| **26/08 (T4)** | Nối stream `AgentEvent` sang Chat History; Tách `AudioRecorderWidget` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] | | **26/08 (T4)** | Nối stream `AgentEvent` sang Chat History; Tách `AudioRecorderWidget` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
| **27/08 (T5)** | Tách `ChatOutputPanel` & File Watcher; Lắp ráp container `ChatPanel` và `Floating HelpAgent` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] | | **27/08 (T5)** | Tách `ChatOutputPanel` & File Watcher; Lắp ráp container `ChatPanel` và `Floating HelpAgent` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
| **28/08 (T6)** | Xóa copy routing cũ trong `ui/chat_panel.py`; Fix circular import `model_pricing` ↔ `usage_tracker` | `2026-08-22 18:57` | `2026-08-22 18:59` | [~] | | **28/08 (T6)** | Xóa copy routing cũ trong `ui/chat_panel.py`; Fix circular import `model_pricing` ↔ `usage_tracker` | `2026-08-21 10:17` | `2026-08-21 10:22` | [~] 3 bản copy routing đã gỡ; circular import chưa xử lý |
| **29/08 (T7)** | Viết suite integration test cho toàn bộ luồng Chat (`tests/integration/test_chat_flow.py`) | `____-__-__ __:__` | `____-__-__ __:__` | [ ] | | **29/08 (T7)** | Viết suite integration test cho toàn bộ luồng Chat (`tests/integration/test_chat_flow.py`) | `2026-08-21 10:35` | `2026-08-21 10:52` | [~] 25 integration test tại `tests/integration/{test_cowork_turn_flow,test_task_executor_flow,test_routing_surfaces}.py` |
| **30/08 (CN)** | 🔍 **Chủ trì CASAN Check 3**: Chạy `python scripts/check_imports.py` đảm bảo 0 import `PySide6` trong domain & application | `____-__-__ __:__` | `____-__-__ __:__` | [ ] | | **30/08 (CN)** | 🔍 **Chủ trì CASAN Check 3**: Chạy `python scripts/check_imports.py` đảm bảo 0 import `PySide6` trong domain & application | `2026-08-21 09:58` | `2026-08-21 10:05` | [x] PASS |
| **31/08 (T2)** | **Chủ trì EPIC R10**: Viết Contributor Recipes, chạy E2E Smoke Test (`tests/e2e/test_smoke.py`) và merge PR cuối cùng | `____-__-__ __:__` | `____-__-__ __:__` | [ ] | | **31/08 (T2)** | **Chủ trì EPIC R10**: Viết Contributor Recipes, chạy E2E Smoke Test (`tests/e2e/test_smoke.py`) và merge PR cuối cùng | `____-__-__ __:__` | `____-__-__ __:__` | [ ] chờ 3 team hoàn tất |
> **Chú thích trạng thái**: `[~]` = hoàn tất **phần thuộc EPIC R03**, phần còn lại của dòng đó thuộc EPIC khác nên chưa đóng.
> - Dòng **24/08**: đã xong `RoutingApplicationService` (R03-T03); phần `ComposerWidget`/`AttachmentPicker` thuộc R08-T01/T02 — chưa làm.
> - Dòng **28/08**: đã xóa copy routing trong `ui/chat_panel.py` (R03-T04) **và** cả `ui/co4e_tab.py`, `ui/folder_tab.py` (R03-T05); phần circular import `model_pricing` ↔ `usage_tracker` thuộc R09-T02 — chưa làm.
--- ---
-155
View File
@@ -1,155 +0,0 @@
# NHẬT KÝ THEO DÕI VÀ PHÒNG NGỪA LỖI TÁI CẤU TRÚC (BUG & LESSONS LEARNED LOG)
## DỰ ÁN: COWORK LOCAL (COWORK-LOCAL BAMBOO)
Tài liệu này dùng để ghi nhận **toàn bộ các lỗi, xung đột kiến trúc và sự cố phát sinh** trong suốt quá trình refactoring của cả 3 team (Team Duy, Team Nam, Team Hoa).
> [!IMPORTANT]
> ### 🛡️ NGUYÊN TẮC VÀNG VỀ QUẢN TRỊ CHẤT LƯỢNG (ZERO RECURRENCE):
> 1. **Ghi nhận ngay lập tức**: Khi gặp bất kỳ lỗi nào (Syntax, Circular Import, Type Error, Test Failure, Thread Freeze, Data Corruption), kỹ sư/AI phải ghi ngay vào tài liệu này trước khi tiếp tục task.
> 2. **Phân tích nguyên nhân gốc rễ (Root Cause)**: Không chỉ sửa phần ngọn mà phải giải thích rõ bản chất vì sao lỗi xảy ra.
> 3. **Rút ra quy tắc phòng ngừa (Prevention Rule)**: Đặt ra nguyên tắc kỹ thuật để **TUYỆT ĐỐI KHÔNG TÁI PHẠM** ở các task tiếp theo.
> 4. **Checklist đầu vào**: Trước khi bắt đầu bất kỳ task mới nào, kỹ sư/AI **bắt buộc phải đọc lại toàn bộ file này**.
---
## 📌 BẢNG TỔNG HỢP CÁC LỖI ĐÃ PHÁT HIỆN & KHẮC PHỤC
| Bug ID | Ngày Phát Hiện | Phân Hệ / File Bị Ảnh Hưởng | Loại Lỗi | Trạng Thái | Team Phụ Trách |
| :--- | :---: | :--- | :--- | :---: | :---: |
| `BUG-001` | 2026-08-20 | `core/model_pricing.py` ↔ `core/usage_tracker.py` | Circular Dependency | 🟡 Đã có giải pháp (R09) | Team Duy & Team Nam |
| `BUG-002` | 2026-08-20 | `core/agent_security.py` ↔ `core/agent_security_alert.py` | Circular Dependency | 🟡 Đã có giải pháp (R09) | Team Nam |
| `BUG-003` | 2026-08-20 | `state.py::active_project_id` & `ui/workspace_tab.py` | Race Condition / Global State Leak | 🟡 Đã có giải pháp (R06) | Team Hoa |
| `BUG-004` | 2026-08-20 | `core/task_scheduler.py` ↔ `PySide6.QtCore.QTimer` | Architecture Violation (Qt in Domain/App) | 🟡 Đã có giải pháp (R07) | Team Hoa |
| `BUG-005` | 2026-08-20 | `ui/chat_panel.py#L638`, `ui/co4e_tab.py`, `ui/folder_tab.py` | Code Duplication (Copy Routing Logic) | 🟡 Đã có giải pháp (R03) | Team Duy |
| `BUG-006` | 2026-08-21 | `scripts/check_imports.py` | UnicodeEncodeError (Windows CP932 console emoji) | 🟢 Đã khắc phục (R01) | Team Duy |
| `BUG-007` | 2026-08-21 | `platform/` ➔ `infrastructure/platform/` | Standard Library Shadowing (`import platform`) | 🟢 Đã khắc phục (R01) | Team Duy |
---
## 🔍 CHI TIẾT TỪNG LỖI & QUY TẮC PHÒNG NGỪA
---
### 🔴 `BUG-001`: Circular Import giữa Module Định Giá (`model_pricing.py`) và Theo Dõi Token (`usage_tracker.py`)
* **Phân hệ**: `core/model_pricing.py` & `core/usage_tracker.py`
* **Triệu chứng (Symptom)**: Lỗi `ImportError: cannot import name 'ModelPricing' from partially initialized module` khi khởi động ứng dụng hoặc chạy test độc lập.
* **Nguyên nhân gốc rễ (Root Cause)**:
- `model_pricing.py` import `UsageTracker` để cập nhật dữ liệu tiêu thụ.
- Ngược lại, `usage_tracker.py` import `ModelPricing` để tính toán chi phí theo từng model ID.
* **Giải pháp khắc phục (Resolution)**:
- Tách Data Transfer Object (DTO) `ModelPricing` sang tầng Domain thuần túy `domain/models/model_pricing.py`.
- Cả `model_pricing.py` và `usage_tracker.py` đều import DTO từ `domain/models/`, chuyển quan hệ thành 1 chiều (Dependency Inversion).
* **Quy tắc phòng ngừa (Prevention Rule - TUYỆT ĐỐI KHÔNG TÁI PHẠM)**:
> **Quy tắc**: Không bao giờ để 2 service hoặc 2 module nghiệp vụ import lẫn nhau. Mọi cấu trúc dữ liệu dùng chung (DTO/Value Object/Event) **phải được đặt tại tầng `domain/`**.
---
### 🔴 `BUG-002`: Circular Import giữa An Ninh Agent (`agent_security.py`) và Cảnh Báo (`agent_security_alert.py`)
* **Phân hệ**: `core/agent_security.py` & `core/agent_security_alert.py`
* **Triệu chứng (Symptom)**: Lỗi khởi tạo vòng tròn khi runtime bắn ra alert sự kiện bảo mật.
* **Nguyên nhân gốc rễ (Root Cause)**:
- Module security vừa kiểm tra policy vừa khởi tạo trực tiếp instance alert dialog, trong khi alert dialog lại import ngược lại rule security để hiển thị chi tiết mã lỗi.
* **Giải pháp khắc phục (Resolution)**:
- Tách sự kiện cảnh báo thành Event DTO `SecurityAlertEvent` tại `domain/security/security_event.py`.
- Tầng Security chỉ phát ra Event (`emit_event`), tầng Presentation/UI tự lắng nghe Event để render Dialog.
* **Quy tắc phòng ngừa (Prevention Rule - TUYỆT ĐỐI KHÔNG TÁI PHẠM)**:
> **Quy tắc**: Logic an ninh và xử lý nghiệp vụ không bao giờ được gọi trực tiếp UI Dialog. Luôn giao tiếp thông qua cơ chế Event-Driven (`AgentEvent`, `SecurityEvent`).
---
### 🔴 `BUG-003`: Xung Đột Race Condition do Sử Dụng Biến Toàn Cục `active_project_id` trong `state.py`
* **Phân hệ**: `state.py`, `ui/workspace_tab.py`, Scheduled Task Runners
* **Triệu chứng (Symptom)**: Khi task scheduler chạy ngầm hoặc người dùng chuyển tab nhanh, file bị ghi nhầm vào thư mục dự án khác với dự án đang hiển thị trên màn hình.
* **Nguyên nhân gốc rễ (Root Cause)**:
- Ứng dụng đọc và ghi trực tiếp vào biến toàn cục `AppContext.active_project_id` từ nhiều luồng khác nhau mà không có cơ chế snapshot ngữ cảnh.
* **Giải pháp khắc phục (Resolution)**:
- Xóa bỏ việc đọc biến toàn cục. Mỗi lần khởi chạy turn hoặc task, tạo một snapshot bất biến `WorkspaceSession(project_id, root_path, allowed_paths)`.
- Luồng ngầm chỉ thao tác trên `WorkspaceSession` được truyền vào từ lúc khởi tạo.
* **Quy tắc phòng ngừa (Prevention Rule - TUYỆT ĐỐI KHÔNG TÁI PHẠM)**:
> **Quy tắc**: Tuyệt đối không dùng biến toàn cục (Global State / Singletons có trạng thái thay đổi) để điều khiển luồng thực thi nền. Mọi ngữ cảnh phải được truyền tường minh qua DTO snapshot.
---
### 🔴 `BUG-004`: Vi Phạm Ranh Giới Kiến Trúc Khi Import `PySide6.QtCore.QTimer` trong Domain / Scheduling Engine
* **Phân hệ**: `core/task_scheduler.py#L20`
* **Triệu chứng (Symptom)**: Không thể viết Unit Test cho thuật toán tính toán lịch chạy (cron/interval) trên môi trường CI/CD (GitHub Actions / Linux Server headless) nếu thiếu driver màn hình X11/Wayland hoặc chưa cài `PySide6`.
* **Nguyên nhân gốc rễ (Root Cause)**:
- Động cơ lập lịch bị gắn chặt cứng với `QTimer` của framework Qt thay vì tách riêng logic tính toán thời gian.
* **Giải pháp khắc phục (Resolution)**:
- Tách thuật toán tính lịch sang `domain/tasks/schedule_calculator.py` (Pure Python 100%).
- Tạo `platform/qt/qt_scheduler_clock.py` làm adapter bọc `QTimer` cho app chạy thật, và `tests/fakes/fake_clock.py` cho unit test.
* **Quy tắc phòng ngừa (Prevention Rule - TUYỆT ĐỐI KHÔNG TÁI PHẠM)**:
> **Quy tắc**: Tầng Domain và Application tuyệt đối không import thư viện GUI (`PySide6`, `PyQt`). Luôn bọc các thành phần phụ thuộc framework bên ngoài qua Adapter Interface.
---
### 🔴 `BUG-005`: Nhân Bản Mã Nguồn (Code Duplication) Logic Routing Mô Hình AI tại Nhiều Màn Hình
* **Phân hệ**: `ui/chat_panel.py#L638`, `ui/co4e_tab.py`, `ui/folder_tab.py`
* **Triệu chứng (Symptom)**: Khi cập nhật thêm model provider mới (như FPT Gateway hay Claude 3.7), phải sửa code thủ công ở 3 file UI khác nhau; phát sinh sai lệch quy tắc fallback giữa các màn hình.
* **Nguyên nhân gốc rễ (Root Cause)**:
- Thiếu một tầng Application Service tập trung, dẫn đến việc lập trình viên copy-paste hàm chọn model từ `ChatPanel` sang các tab khác.
* **Giải pháp khắc phục (Resolution)**:
- Xây dựng `application/model_routing/routing_application_service.py` duy nhất, cung cấp API `route_request(request) -> ModelRouteDecision`.
- Mọi màn hình UI chỉ gọi service này, không tự viết lại logic kiểm tra key hay fallback.
* **Quy tắc phòng ngừa (Prevention Rule - TUYỆT ĐỐI KHÔNG TÁI PHẠM)**:
> **Quy tắc**: Nghiệp vụ dùng chung giữa các màn hình phải được đưa vào `application/` services. Không bao giờ viết logic nghiệp vụ trực tiếp trong các file Widget UI.
---
### 🟢 `BUG-006`: `UnicodeEncodeError` khi in Emojis trên Console Windows (CP932/CP1252)
* **Phân hệ / File**: `scripts/check_imports.py`
* **Triệu chứng (Symptom)**:
```text
Traceback (most recent call last):
File "scripts/check_imports.py", line 127, in main
print(f"\U0001f6e1\ufe0f Running Clean Architecture Import Guard...")
UnicodeEncodeError: 'cp932' codec can't encode character '\U0001f6e1' in position 0: illegal multibyte sequence
```
* **Nguyên nhân gốc rễ (Root Cause)**:
- Trên hệ điều hành Windows sử dụng locale tiếng Nhật (mã trang CP932) hoặc tiếng Anh (CP1252), `sys.stdout` mặc định không hỗ trợ các ký tự Unicode/Emoji ngoài bảng mã, dẫn đến crash khi in log dòng lệnh.
* **Giải pháp khắc phục (Resolution)**:
- Tự động bọc lại `sys.stdout` và `sys.stderr` bằng `io.TextIOWrapper` với `encoding="utf-8"` và `errors="replace"`.
- Thay thế các emoji phức tạp bằng các tag văn bản ASCII chuẩn hóa như `[Clean Arch Guard]`, `[PASS]`, `[FAIL]`.
* **Quy tắc phòng ngừa (Prevention Rule - TUYỆT ĐỐI KHÔNG TÁI PHẠM)**:
> **Quy tắc**: Mọi script CLI (`scripts/*.py`) phải có cơ chế cấu hình `utf-8` stream wrapper và ưu tiên sử dụng text tags (`[INFO]`, `[WARN]`, `[ERROR]`) thay vì emoji Unicode trực tiếp để đảm bảo chạy mượt mà trên mọi môi trường Windows đa ngôn ngữ.
---
### 🟢 `BUG-007`: Xung Đột Tên Thư Mục Trùng Với Standard Library (`platform/` Shadowing `import platform`)
* **Phân hệ / File**: `platform/` ➔ Chuyển thành `infrastructure/platform/`
* **Triệu chứng (Symptom)**:
```text
INTERNALERROR> File "_pytest/terminal.py", line 853: verinfo = platform.python_version()
INTERNALERROR> AttributeError: module 'platform' has no attribute 'python_version'
```
* **Nguyên nhân gốc rễ (Root Cause)**:
- Khi tạo một package ở thư mục gốc có tên trùng với module thư viện chuẩn của Python (`platform`, `email`, `test`, `asyncio`, `logging`), Python trên `sys.path` sẽ ưu tiên import thư mục local thay vì thư viện chuẩn của Python runtime, dẫn đến crash toàn bộ pytest runner và các thư viện bên thứ ba.
* **Giải pháp khắc phục (Resolution)**:
- Xóa bỏ package `platform/` ở root.
- Đưa adapter Qt Scheduler Clock vào đúng vị trí hạ tầng: `infrastructure/platform/qt/`.
* **Quy tắc phòng ngừa (Prevention Rule - TUYỆT ĐỐI KHÔNG TÁI PHẠM)**:
> **Quy tắc**: Tuyệt đối không đặt tên package/thư mục ở root trùng với tên các module built-in của Python (`platform`, `logging`, `types`, `time`, `io`, `os`, `sys`). Mọi platform adapter phải nằm trong `infrastructure/platform/` hoặc `platform_adapters/`.
---
## 📝 MẪU GHI NHẬN BUG MỚI (BUG REPORT TEMPLATE)
Khi gặp bất kỳ bug mới nào trong quá trình làm việc, hãy sao chép khối mẫu sau và điền vào cuối tài liệu:
```markdown
### 🔴 `BUG-XXX`: [Tóm tắt ngắn gọn tên lỗi]
* **Phân hệ / File**: `[Đường dẫn file bị lỗi]`
* **Triệu chứng (Symptom)**: `[Mô tả hiện tượng lỗi, paste thông báo traceback hoặc kết quả test fail]`
* **Nguyên nhân gốc rễ (Root Cause)**: `[Giải thích tại sao lỗi lại xảy ra]`
* **Giải pháp khắc phục (Resolution)**: `[Mô tả cách sửa, file DTO/Service tạo mới hoặc cách refactor]`
* **Quy tắc phòng ngừa (Prevention Rule - TUYỆT ĐỐI KHÔNG TÁI PHẠM)**:
> **Quy tắc**: `[Nguyên tắc kỹ thuật cụ thể để không bao giờ tái phạm lỗi này]`
```
+12 -1
View File
@@ -1 +1,12 @@
"""Domain Layer: Pure Python domain entities, value objects, and events.""" """Domain layer - pure Python entities, value objects and events.
The innermost layer of the 4-tier architecture (see
``docs/architecture/ADR-001-layered-architecture.md``). Modules here describe
WHAT the application is about - a turn of conversation, a model candidate, an
agent event - and depend on nothing but the standard library.
Hard rule (ADR-001 I1/I2, enforced by ``scripts/check_imports.py``): no imports
of PySide6/PyQt, and no imports from ``application/``, ``infrastructure/``,
``presentation/`` or the legacy ``core/``/``ui/`` packages. That is what keeps
this layer testable in milliseconds and reusable from a headless scheduler.
"""
+48 -1
View File
@@ -1 +1,48 @@
"""Domain agents package: turn requests, agent events, and role definitions.""" """Domain entities for one agent turn: the request snapshot and the typed event
stream it produces (EPIC R04)."""
from .agent_event import (
AgentEvent,
AssistantDoneEvent,
ErrorEvent,
HistoryReadyEvent,
NoticeEvent,
OutputsAddedEvent,
OutputsRemovedEvent,
PlanUpdatedEvent,
ReasoningChunkEvent,
TextChunkEvent,
ToolCallFinishedEvent,
ToolCallStartedEvent,
ToolOutputEvent,
TurnCompletedEvent,
collect_text,
event_from_dict,
tool_calls,
)
from .conversation_execution_request import (
ConversationExecutionRequest,
new_turn_id,
)
__all__ = [
"ConversationExecutionRequest",
"new_turn_id",
"AgentEvent",
"TextChunkEvent",
"ReasoningChunkEvent",
"AssistantDoneEvent",
"PlanUpdatedEvent",
"ToolCallStartedEvent",
"ToolOutputEvent",
"ToolCallFinishedEvent",
"OutputsAddedEvent",
"OutputsRemovedEvent",
"NoticeEvent",
"HistoryReadyEvent",
"TurnCompletedEvent",
"ErrorEvent",
"event_from_dict",
"collect_text",
"tool_calls",
]
+370
View File
@@ -0,0 +1,370 @@
"""AgentEvent - the typed event stream one agent turn produces (R04-T02).
Today the turn engine talks to its caller through untyped dicts::
emit({"type": "tool_result", "id": tc_id, "name": name,
"ok": result.get("ok", False), "output": result.get("output", "")})
and every consumer re-discovers the vocabulary by reading the producer. There
are eleven such shapes across ``core/chat_agent.py``, ``core/code_agent.py`` and
``core/task_executors.py``; a consumer that misspells ``"tool_result"`` or reads
``"result"`` instead of ``"output"`` fails silently, at runtime, only for the
tool path that triggers it.
This module makes the vocabulary explicit. Each event is a frozen dataclass, so:
* the set of possible events is enumerable (see :data:`EVENT_TYPES`);
* a field name typo is an ``AttributeError`` at the point of use, not a silently
missing chat bubble;
* an event can cross a thread boundary safely - it cannot be mutated after the
producer hands it over, which is exactly what the Qt-signal seam needs.
Bridging with the legacy dicts is deliberate and two-way: :func:`event_from_dict`
adapts what ``run_cowork`` emits today, and :meth:`AgentEvent.to_dict` renders an
event back into the legacy shape so existing widgets keep working untouched
while the presentation layer migrates screen by screen (EPIC R08).
Pure domain code: stdlib only, no Qt, no I/O.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple
@dataclass(frozen=True)
class AgentEvent:
"""Base class for everything a turn can report.
``type`` is the legacy string tag, kept as a class attribute so the bridge
functions can round-trip an event without a separate mapping table.
"""
type: str = field(init=False, default="event")
def to_dict(self) -> Dict[str, Any]:
"""Render into the legacy ``emit()`` dict shape."""
return {"type": self.type}
# --------------------------------------------------------------------------- #
# Assistant output
# --------------------------------------------------------------------------- #
@dataclass(frozen=True)
class TextChunkEvent(AgentEvent):
"""One fragment of the visible answer, as it streams in."""
delta: str
type: str = field(init=False, default="text")
def to_dict(self) -> Dict[str, Any]:
return {"type": self.type, "delta": self.delta}
@dataclass(frozen=True)
class ReasoningChunkEvent(AgentEvent):
"""One fragment of the model's PRIVATE reasoning.
Drives the "Thinking" indicator only. Consumers must never append this to
the answer or persist it into conversation history - keeping it a distinct
type is what makes that mistake hard to make by accident.
"""
delta: str
type: str = field(init=False, default="reasoning")
def to_dict(self) -> Dict[str, Any]:
return {"type": self.type, "delta": self.delta}
@dataclass(frozen=True)
class AssistantDoneEvent(AgentEvent):
"""One assistant message finished. A turn with tool calls emits this once
per step, not once per turn - see :class:`TurnCompletedEvent`."""
content: str = ""
type: str = field(init=False, default="assistant_done")
def to_dict(self) -> Dict[str, Any]:
return {"type": self.type, "content": self.content}
# --------------------------------------------------------------------------- #
# Planning
# --------------------------------------------------------------------------- #
@dataclass(frozen=True)
class PlanUpdatedEvent(AgentEvent):
"""The agent rewrote its plan (the ``update_plan`` tool)."""
steps: Tuple[Dict[str, Any], ...] = ()
type: str = field(init=False, default="plan_set")
def to_dict(self) -> Dict[str, Any]:
return {"type": self.type, "steps": [dict(s) for s in self.steps]}
# --------------------------------------------------------------------------- #
# Tool lifecycle
# --------------------------------------------------------------------------- #
@dataclass(frozen=True)
class ToolCallStartedEvent(AgentEvent):
"""A tool call is about to run, with the preview shown to the user.
Maps the legacy ``tool_proposed`` event. "Proposed" was a misnomer: by the
time it is emitted the call is already going to run unless a permission gate
rejects it, and the gate reports that as a finished call with ``ok=False``.
"""
call_id: str
name: str
args: Dict[str, Any] = field(default_factory=dict)
preview: Optional[Dict[str, Any]] = None
type: str = field(init=False, default="tool_proposed")
def to_dict(self) -> Dict[str, Any]:
out: Dict[str, Any] = {"type": self.type, "id": self.call_id,
"name": self.name, "args": dict(self.args)}
if self.preview is not None:
out["preview"] = dict(self.preview)
return out
@dataclass(frozen=True)
class ToolOutputEvent(AgentEvent):
"""A line of live output from a running tool (command stdout, for example)."""
call_id: str
name: str
delta: str
type: str = field(init=False, default="tool_output")
def to_dict(self) -> Dict[str, Any]:
return {"type": self.type, "id": self.call_id, "name": self.name,
"delta": self.delta}
@dataclass(frozen=True)
class ToolCallFinishedEvent(AgentEvent):
"""A tool call ended, successfully or not.
``ok=False`` covers every failure mode alike - the tool raised, the sandbox
blocked it, or the user rejected it at the permission gate - because the
consumer's job is the same in all three: show the failure and let the model
react to it.
"""
call_id: str
name: str
ok: bool = False
output: str = ""
path: str = "" # file the tool wrote, when it wrote one
produced: Tuple[str, ...] = () # extra artefacts (e.g. a generator's outputs)
type: str = field(init=False, default="tool_result")
def to_dict(self) -> Dict[str, Any]:
out: Dict[str, Any] = {"type": self.type, "id": self.call_id, "name": self.name,
"ok": self.ok, "output": self.output}
if self.path:
out["path"] = self.path
if self.produced:
out["produced"] = list(self.produced)
return out
# --------------------------------------------------------------------------- #
# Output folder
# --------------------------------------------------------------------------- #
@dataclass(frozen=True)
class OutputsAddedEvent(AgentEvent):
"""Files appeared in the turn's output folder."""
paths: Tuple[str, ...] = ()
type: str = field(init=False, default="outputs_added")
def to_dict(self) -> Dict[str, Any]:
return {"type": self.type, "paths": list(self.paths)}
@dataclass(frozen=True)
class OutputsRemovedEvent(AgentEvent):
"""Files were cleaned up from the turn's output folder (intermediates)."""
paths: Tuple[str, ...] = ()
type: str = field(init=False, default="outputs_removed")
def to_dict(self) -> Dict[str, Any]:
return {"type": self.type, "paths": list(self.paths)}
@dataclass(frozen=True)
class NoticeEvent(AgentEvent):
"""A UI-visible aside that is not part of the model's answer.
Three producers today, all reachable from a normal turn:
``core/agent_security.py`` (a request or command blocked by the security
layer), ``core/context_budget.py`` (the conversation was auto-compressed)
and the attachment readers (a file that could not be processed, plus live
"reading page X/Y" progress).
``level`` selects how the UI renders it: ``"progress"`` updates the thinking
indicator in place, anything else becomes a warning bubble. Dropping these
would silently hide security warnings from the user, which is why the type
exists rather than being folded into TextChunkEvent.
"""
text: str
level: str = "info"
type: str = field(init=False, default="notice")
def to_dict(self) -> Dict[str, Any]:
return {"type": self.type, "level": self.level, "text": self.text}
@dataclass(frozen=True)
class HistoryReadyEvent(AgentEvent):
"""A history session exists for this run and can be opened."""
session_id: str
type: str = field(init=False, default="history_ready")
def to_dict(self) -> Dict[str, Any]:
return {"type": self.type, "session_id": self.session_id}
# --------------------------------------------------------------------------- #
# Turn lifecycle - emitted by the application service, not by the legacy engine
# --------------------------------------------------------------------------- #
@dataclass(frozen=True)
class TurnCompletedEvent(AgentEvent):
"""The whole turn finished: no more events will follow.
New in R04. The legacy engine has no end-of-turn signal at all, so every
consumer infers "done" from the worker thread finishing - which is why a
cancelled turn and a failed turn look identical to the UI today.
"""
content: str = ""
cancelled: bool = False
type: str = field(init=False, default="turn_completed")
def to_dict(self) -> Dict[str, Any]:
return {"type": self.type, "content": self.content, "cancelled": self.cancelled}
@dataclass(frozen=True)
class ErrorEvent(AgentEvent):
"""The turn failed. ``recoverable`` marks errors the user can act on
(pick another model, shorten the prompt) rather than a hard outage."""
message: str
recoverable: bool = False
type: str = field(init=False, default="error")
def to_dict(self) -> Dict[str, Any]:
return {"type": self.type, "message": self.message,
"recoverable": self.recoverable}
# The legacy tag -> event class map. Also the authoritative list of what a turn
# can emit, which is what makes an exhaustive consumer possible for the first time.
EVENT_TYPES: Dict[str, type] = {
"text": TextChunkEvent,
"reasoning": ReasoningChunkEvent,
"assistant_done": AssistantDoneEvent,
"plan_set": PlanUpdatedEvent,
"tool_proposed": ToolCallStartedEvent,
"tool_start": ToolCallStartedEvent,
"tool_output": ToolOutputEvent,
"tool_result": ToolCallFinishedEvent,
"outputs_added": OutputsAddedEvent,
"outputs_removed": OutputsRemovedEvent,
"notice": NoticeEvent,
"history_ready": HistoryReadyEvent,
"turn_completed": TurnCompletedEvent,
"error": ErrorEvent,
}
def event_from_dict(payload: Mapping[str, Any]) -> Optional[AgentEvent]:
"""Adapt one legacy ``emit()`` dict into a typed event.
Returns ``None`` for an unknown tag instead of raising: the legacy engine is
still being refactored and may grow an event before this module knows about
it. Dropping an unrecognised event degrades the UI by one missing bubble;
raising here would abort a turn that had otherwise succeeded.
"""
kind = str(payload.get("type", ""))
cls = EVENT_TYPES.get(kind)
if cls is None:
return None
if cls is TextChunkEvent or cls is ReasoningChunkEvent:
return cls(delta=str(payload.get("delta", "")))
if cls is AssistantDoneEvent:
return AssistantDoneEvent(content=str(payload.get("content", "")))
if cls is PlanUpdatedEvent:
return PlanUpdatedEvent(steps=tuple(payload.get("steps") or ()))
if cls is ToolCallStartedEvent:
return ToolCallStartedEvent(
call_id=str(payload.get("id", "")), name=str(payload.get("name", "")),
args=dict(payload.get("args") or {}), preview=payload.get("preview"),
)
if cls is ToolOutputEvent:
return ToolOutputEvent(call_id=str(payload.get("id", "")),
name=str(payload.get("name", "")),
delta=str(payload.get("delta", "")))
if cls is ToolCallFinishedEvent:
return ToolCallFinishedEvent(
call_id=str(payload.get("id", "")), name=str(payload.get("name", "")),
ok=bool(payload.get("ok", False)), output=str(payload.get("output", "")),
path=str(payload.get("path", "") or ""),
produced=tuple(payload.get("produced") or ()),
)
if cls is OutputsAddedEvent or cls is OutputsRemovedEvent:
return cls(paths=tuple(str(p) for p in (payload.get("paths") or ())))
if cls is NoticeEvent:
return NoticeEvent(text=str(payload.get("text", "")),
level=str(payload.get("level", "info")))
if cls is HistoryReadyEvent:
return HistoryReadyEvent(session_id=str(payload.get("session_id", "")))
if cls is TurnCompletedEvent:
return TurnCompletedEvent(content=str(payload.get("content", "")),
cancelled=bool(payload.get("cancelled", False)))
return ErrorEvent(message=str(payload.get("message", "")),
recoverable=bool(payload.get("recoverable", False)))
def collect_text(events: Sequence[AgentEvent]) -> str:
"""Join every :class:`TextChunkEvent` - the visible answer, reasoning excluded.
Provided here so no consumer has to re-derive "which events are the answer",
the question the untyped dicts made easy to get wrong.
"""
return "".join(e.delta for e in events if isinstance(e, TextChunkEvent))
def tool_calls(events: Sequence[AgentEvent]) -> List[ToolCallFinishedEvent]:
"""Every finished tool call, in order - for audit views and assertions."""
return [e for e in events if isinstance(e, ToolCallFinishedEvent)]
__all__ = [
"AgentEvent",
"TextChunkEvent",
"ReasoningChunkEvent",
"AssistantDoneEvent",
"PlanUpdatedEvent",
"ToolCallStartedEvent",
"ToolOutputEvent",
"ToolCallFinishedEvent",
"OutputsAddedEvent",
"OutputsRemovedEvent",
"NoticeEvent",
"HistoryReadyEvent",
"TurnCompletedEvent",
"ErrorEvent",
"EVENT_TYPES",
"event_from_dict",
"collect_text",
"tool_calls",
]
@@ -0,0 +1,192 @@
"""ConversationExecutionRequest - an immutable snapshot of one turn (R04-T01).
``ui/cowork_tab.py::build_job`` currently builds a closure that reads widget
state from inside the worker thread::
def job(worker):
provider = self.build_provider() # reads combo boxes
extra_tools, extra_exec = self.ctx.build_mcp_tools()
proj_ctx = project_context_text(load_project(project_id))
...
Everything that closure touches can change while the turn is running: the user
can pick another model, switch workspace, or edit the project instructions. The
turn then runs on a mixture of old and new state, and which mixture depends on
thread timing - the class of bug that reproduces once a week and never in a test.
This value object is the fix: the presentation layer captures everything a turn
needs ON THE UI THREAD, at submit time, into one frozen object. Whatever happens
to the widgets afterwards, the turn keeps running on the state the user actually
submitted.
Pure domain code: stdlib only, no Qt, no filesystem access. Paths are held as
strings, not ``Path`` objects, so the snapshot stays trivially serialisable -
which is what will let a turn be queued, replayed or logged later.
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass, field, replace
from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple
# Default tool-use budget for an interactive turn, and the higher ceiling a
# run-to-completion step (a Co4E flow step) is allowed. Same numbers
# ``core.chat_agent.run_cowork`` defaults to - kept here so the policy is
# visible in the request rather than buried in a function signature.
DEFAULT_MAX_STEPS = 30
DEFAULT_COMPLETION_MAX_STEPS = 200
def new_turn_id() -> str:
"""A fresh turn id. Short and random: it only has to be unique within a
session's lifetime, and it shows up in log lines humans read."""
return uuid.uuid4().hex[:12]
@dataclass(frozen=True)
class ConversationExecutionRequest:
"""Everything one agent turn needs, captured at submit time.
Attributes:
prompt: the user's message for this turn (already assembled, including
any attachment text the UI inlined).
messages: the full conversation to send, oldest first. Held as a tuple
so the snapshot cannot be mutated after capture; use
:meth:`message_list` to get the mutable copy the engine expects.
output_dir: this turn's OWN folder. Each turn writes into an isolated
directory so parallel turns cannot clobber each other's files.
session_id: the conversation this turn belongs to.
turn_id: unique per turn, for logs and for matching events to a turn.
surface: which screen submitted it ("cowork", "co4e", "ai_edit", "task").
provider / model: what to run on, already resolved (routing included).
Empty ``model`` means "the provider's configured default".
title: conversation title, used to name generated files.
project_id / project_context: the workspace and its shared instructions,
snapshotted so a mid-turn workspace switch cannot change them.
agent_role: audit-log attribution for every tool call this turn makes.
allowed_tools: permission scope. ``None`` means "all enabled tools";
a list restricts the ADVERTISED catalogue, so a read-only step
literally cannot be offered a writing tool.
max_steps / run_to_completion / completion_max_steps: tool-use budget.
enforce_rules: run the security rulebase. Co4E sandboxed runs disable it.
confirm_commands: ask before run_command/install_package (permission gate).
metadata: free-form extras a caller wants carried along (never
interpreted here) - e.g. a scheduled task's id.
"""
prompt: str
messages: Tuple[Mapping[str, Any], ...] = ()
output_dir: str = ""
session_id: str = ""
turn_id: str = field(default_factory=new_turn_id)
surface: str = "cowork"
provider: str = ""
model: str = ""
title: str = ""
project_id: str = ""
project_context: str = ""
agent_role: str = ""
allowed_tools: Optional[Tuple[str, ...]] = None
max_steps: int = DEFAULT_MAX_STEPS
run_to_completion: bool = False
completion_max_steps: int = DEFAULT_COMPLETION_MAX_STEPS
enforce_rules: bool = True
confirm_commands: bool = False
metadata: Mapping[str, Any] = field(default_factory=dict)
# -- construction helpers ------------------------------------------- #
@classmethod
def create(cls, prompt: str, messages: Optional[Sequence[Mapping[str, Any]]] = None,
**kwargs: Any) -> "ConversationExecutionRequest":
"""Build a request from ordinary mutable inputs.
The messages list is copied element by element, so a later append by the
caller (the chat panel keeps appending to its own list) cannot reach
inside a request that is already running.
"""
snapshot = tuple(dict(m) for m in (messages or ()))
allowed = kwargs.pop("allowed_tools", None)
return cls(prompt=prompt, messages=snapshot,
allowed_tools=tuple(allowed) if allowed is not None else None,
**kwargs)
def with_messages(self, messages: Sequence[Mapping[str, Any]]
) -> "ConversationExecutionRequest":
"""A copy carrying a different message list, everything else unchanged.
Used when a caller assembles the system prompt or trims history after
building the request - it must produce a NEW snapshot rather than mutate
the one a turn may already be running on.
"""
return replace(self, messages=tuple(dict(m) for m in messages))
def with_model(self, provider: str, model: str) -> "ConversationExecutionRequest":
"""A copy pinned to another provider/model - how a routing switch is
applied without touching the user's saved settings."""
return replace(self, provider=provider, model=model)
# -- accessors ------------------------------------------------------ #
def message_list(self) -> List[Dict[str, Any]]:
"""A fresh mutable copy of the messages, for the engine to append to.
The legacy engine mutates the list it is given (it inserts the system
prompt and appends assistant/tool messages). Handing it a copy is what
keeps this snapshot immutable in practice and not just by declaration.
"""
return [dict(m) for m in self.messages]
@property
def effective_max_steps(self) -> int:
"""The tool-use ceiling actually in force for this turn."""
return self.completion_max_steps if self.run_to_completion else self.max_steps
@property
def has_output_dir(self) -> bool:
"""True when this turn may write files."""
return bool(self.output_dir)
def allows_tool(self, name: str) -> bool:
"""Whether ``name`` is inside this turn's permission scope.
``update_plan`` is always allowed: it has no side effects and drives the
Plan panel, so scoping it out would silently break the UI rather than
restrict a capability.
"""
if self.allowed_tools is None:
return True
return name == "update_plan" or name in self.allowed_tools
def describe(self) -> str:
"""Compact one-line identity for log lines."""
target = f"{self.provider}/{self.model}" if self.model else self.provider or "default"
return f"turn={self.turn_id} surface={self.surface} model={target}"
def to_dict(self) -> Dict[str, Any]:
"""JSON-safe projection, for logging a turn or persisting it for replay."""
return {
"turn_id": self.turn_id,
"session_id": self.session_id,
"surface": self.surface,
"prompt": self.prompt,
"message_count": len(self.messages),
"output_dir": self.output_dir,
"provider": self.provider,
"model": self.model,
"title": self.title,
"project_id": self.project_id,
"agent_role": self.agent_role,
"allowed_tools": list(self.allowed_tools) if self.allowed_tools is not None else None,
"max_steps": self.effective_max_steps,
"run_to_completion": self.run_to_completion,
"enforce_rules": self.enforce_rules,
"confirm_commands": self.confirm_commands,
"metadata": dict(self.metadata),
}
__all__ = [
"ConversationExecutionRequest",
"new_turn_id",
"DEFAULT_MAX_STEPS",
"DEFAULT_COMPLETION_MAX_STEPS",
]
+5 -1
View File
@@ -1 +1,5 @@
"""Domain models package: provider descriptors, model pricing, and routing metadata.""" """Domain models: provider/model catalogue value objects (EPIC R03)."""
from .provider_descriptor import ProviderCapability, ProviderDescriptor
__all__ = ["ProviderDescriptor", "ProviderCapability"]
+133 -158
View File
@@ -1,196 +1,171 @@
"""Provider catalog metadata — the domain-layer description of ONE LLM provider. """ProviderDescriptor - the declarative catalogue entry for one model provider (R03-T02).
Before R03 the answer to "which providers exist, what do they cost, what can Today the knowledge of "what a provider is" is scattered across three places
they do?" was spread over three places: the class table in that must be edited together and can silently drift apart:
``providers/factory.py``, the hand-maintained pricing table in
``core/routing/metadata.py`` and a handful of ``if provider == "anthropic"``
branches in the UI. :class:`ProviderDescriptor` is the single declarative
record those call sites now read from.
Layer rules (see ``docs/architecture/ADR-001-layered-architecture.md``): this * ``providers/factory.py::_REGISTRY`` - name -> implementation class
module is 100% pure Python — no PySide6, no ``requests``, no filesystem, and no * ``config.py::DEFAULT_CONFIG["providers"]`` - default base_url / model / api_key
import of the concrete ``providers/*`` adapters. It only *describes* a provider; * ``config.py::PROVIDER_LABELS`` - the human label shown in Settings
constructing one is the infrastructure layer's job
(``infrastructure/providers/provider_registry.py``). Adding a provider means remembering all three; forgetting one produces a
provider that exists but has no label, or a label with no implementation. This
value object folds those facts into a single immutable description that the
registry (``infrastructure/providers/provider_registry.py``) and the UI can both
read, so a new provider is declared once.
Pure domain code: stdlib only, no Qt, no network, no config access. It describes
a provider; building one is infrastructure's job.
""" """
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, field, replace from dataclasses import dataclass, field
from enum import Enum from enum import Enum
from typing import Any, Dict, Optional, Tuple from typing import Any, Dict, FrozenSet, List, Mapping, Optional, Tuple
class AuthKind(str, Enum): class ProviderCapability(str, Enum):
"""How a provider authenticates, so Settings/onboarding can ask for the """What a provider can do, as advertised by its descriptor.
right thing instead of hard-coding per-provider form fields.
Inherits ``str`` so a descriptor round-trips through JSON unchanged (the Kept as a closed enum rather than free-form strings so a typo
value is written as a plain string), matching how the routing models in (``"vison"``) fails at import time instead of silently disabling a feature
``core/routing/models.py`` already serialize their enums. at runtime. Inherits ``str`` so existing dict/JSON code that compares against
plain strings keeps working during the migration.
""" """
NONE = "none" # local runtimes (Ollama) — nothing to supply STREAMING = "streaming" # can stream answer fragments through on_text
API_KEY = "api_key" # bearer/x-api-key style secret TOOLS = "tools" # can be given a ToolSpec catalogue and call tools
OAUTH_TOKEN = "oauth" # token minted by an external login flow (Copilot) VISION = "vision" # accepts image content blocks (see providers/base.py)
REASONING = "reasoning" # emits a separate private "thinking" stream
MODEL_LISTING = "model_listing" # list_models() returns a real catalogue
class WireProtocol(str, Enum):
"""The on-the-wire dialect a provider speaks.
Several *distinct* providers share one protocol (Ollama, Codex, GitHub
Copilot and generic gateways are all OpenAI Chat Completions), which is
exactly why protocol is a separate field from the provider id: the registry
picks the adapter class from the protocol, while everything user-facing
keys off the id.
"""
OPENAI_COMPAT = "openai_compat"
ANTHROPIC = "anthropic"
@dataclass(frozen=True) @dataclass(frozen=True)
class ProviderDescriptor: class ProviderDescriptor:
"""Immutable metadata for one provider the app can route work to. """An immutable description of one provider the app can talk to.
Frozen because descriptors are shared process-wide by the registry, the Attributes:
routing service and (eventually) the Settings screen; making them read-only id: the config key, e.g. ``"openai_compat"``. Also the ``provider`` half
removes any chance one caller mutates the catalog another caller is of a routing candidate key (``provider/model_id``).
iterating. Use :meth:`with_models` to derive an updated copy instead. label: human-readable name for Settings and the model picker.
protocol: which wire format this provider speaks. Several ids share one
Unknown pricing/context values stay ``None`` rather than being guessed — protocol - ``ollama``, ``github_copilot`` and ``codex`` are all
the routing scorer needs to distinguish "free" from "we don't know", the OpenAI-compatible endpoints - which is exactly why protocol and id
same contract ``core/routing/models.py::ModelMetadata`` already follows. must be separate fields.
default_model: the model used when the user has not chosen one.
capabilities: what the provider supports (see :class:`ProviderCapability`).
requires_api_key: whether an empty ``api_key`` makes it unusable.
requires_base_url: whether an empty ``base_url`` makes it unusable.
local: True when the endpoint runs on the user's own machine. Routing
treats local models as zero-cost, and the security layer treats them
as not leaving the machine, so this is a real behavioural flag and
not just documentation.
notes: free-form remark shown in Settings (e.g. "paste a Copilot token").
""" """
provider_id: str # config key, e.g. "anthropic" id: str
display_name: str # human label for Settings/UI label: str
wire_protocol: WireProtocol # which adapter class implements it protocol: str
auth_kind: AuthKind = AuthKind.API_KEY default_model: str = ""
default_model: str = "" # used when no model is selected capabilities: FrozenSet[ProviderCapability] = field(default_factory=frozenset)
models: Tuple[str, ...] = () # known model ids (may be empty) requires_api_key: bool = True
max_context: Optional[int] = None # tokens; None = unknown requires_base_url: bool = True
cost_per_1k_input: Optional[float] = None # USD per 1K input tokens local: bool = False
cost_per_1k_output: Optional[float] = None # USD per 1K output tokens notes: str = ""
supports_vision: bool = False
supports_tools: bool = True
supports_streaming: bool = True
requires_base_url: bool = False # gateway endpoints must be configured
# Extra ids that should resolve to this descriptor (renames/aliases kept for
# backwards compatibility with configs written by older app versions).
aliases: Tuple[str, ...] = ()
# Free-form extension point so a team can attach provider-specific hints
# without another schema migration.
extras: Dict[str, Any] = field(default_factory=dict)
def __post_init__(self) -> None: # -- capability queries ---------------------------------------------- #
"""Reject descriptors that could never be looked up. def supports(self, capability: ProviderCapability) -> bool:
"""True when this provider advertises ``capability``."""
Raising here (rather than at registration time) means a malformed
descriptor cannot exist at all, so every consumer downstream may assume
``provider_id`` is a usable dict key.
"""
if not self.provider_id:
raise ValueError("ProviderDescriptor.provider_id must not be empty")
if not isinstance(self.wire_protocol, WireProtocol):
raise TypeError("ProviderDescriptor.wire_protocol must be a WireProtocol")
# -- identity ------------------------------------------------------- #
@property
def identifiers(self) -> Tuple[str, ...]:
"""Every id this descriptor answers to (canonical id first)."""
return (self.provider_id, *self.aliases)
def matches(self, provider_id: str) -> bool:
"""Case-insensitive id/alias match — config files and CLI flags are
typed by humans, so lookup must not be case sensitive."""
needle = (provider_id or "").strip().lower()
return any(needle == known.lower() for known in self.identifiers)
# -- capability queries --------------------------------------------- #
def knows_model(self, model_id: str) -> bool:
"""Whether ``model_id`` is in this provider's declared catalog.
A miss is NOT proof the model is unusable: gateways expose models we
cannot enumerate offline, so callers treat this as a hint (used to
resolve a bare model id back to its provider) and never as a gate that
blocks a request.
"""
needle = (model_id or "").strip().lower()
return any(needle == known.strip().lower() for known in self.models)
def has_capability(self, capability: str) -> bool:
"""Capability check by name, mirroring the vocabulary the routing
selector already filters on (``"vision"``, ``"tools"``, ``"streaming"``)
so a descriptor can be fed straight into ``rank_models``."""
return capability in self.capabilities return capability in self.capabilities
@property @property
def capabilities(self) -> frozenset: def supports_vision(self) -> bool:
"""Capability set in the same vocabulary as """Mirrors ``providers.base.Provider.supports_vision`` so callers can ask
``core/routing/models.py::ModelMetadata.capabilities``.""" the descriptor (no instance, no network) before building a provider."""
caps = set() return self.supports(ProviderCapability.VISION)
if self.supports_vision:
caps.add("vision")
if self.supports_tools:
caps.add("tools")
if self.supports_streaming:
caps.add("streaming")
return frozenset(caps)
@property @property
def avg_cost_per_1k(self) -> Optional[float]: def supports_tools(self) -> bool:
"""Blended input/output price, or ``None`` when either side is unknown. """True when this provider can run an agent turn with tools. A provider
without it can still chat, but must never be routed a tool-using task."""
return self.supports(ProviderCapability.TOOLS)
Uses the same 1:3 input:output weighting as def capability_names(self) -> List[str]:
``ModelMetadata.avg_cost_per_1k`` so a descriptor and an assessment """Capabilities as sorted plain strings - the shape the routing layer's
never disagree about what a model costs. ``required_capabilities`` filter and the assessment store both use."""
return sorted(c.value for c in self.capabilities)
# -- configuration validation ---------------------------------------- #
def missing_settings(self, conf: Mapping[str, Any]) -> List[str]:
"""Which required config keys are absent or blank in ``conf``.
Returned as a list (not a bool) so Settings can tell the user exactly
what to fill in, instead of a generic "not configured". A provider that
needs nothing returns an empty list.
""" """
ci, co = self.cost_per_1k_input, self.cost_per_1k_output missing: List[str] = []
if ci is None or co is None: if self.requires_api_key and not str(conf.get("api_key", "") or "").strip():
return None missing.append("api_key")
return (ci + 3.0 * co) / 4.0 if self.requires_base_url and not str(conf.get("base_url", "") or "").strip():
missing.append("base_url")
return missing
def resolve_model(self, requested: str = "") -> str: def is_configured(self, conf: Mapping[str, Any]) -> bool:
"""The model id to actually call: the caller's choice when they made """True when ``conf`` carries everything this provider needs to run."""
one, otherwise this provider's default. Centralised here because every return not self.missing_settings(conf)
surface (chat, Co4E, AI-Edit) previously re-implemented the same
``model or config_default`` fallback inline."""
return (requested or "").strip() or self.default_model
# -- derivation / serialization ------------------------------------- # def resolve_model(self, conf: Optional[Mapping[str, Any]] = None,
def with_models(self, models, *, default_model: str = "") -> "ProviderDescriptor": requested: str = "") -> str:
"""A copy carrying a freshly discovered model list. """Pick the model id for a call: explicit request, else configured, else
this descriptor's default.
Providers can enumerate their models at runtime (``list_models()``); Centralised here because the same three-step fallback is currently
because the descriptor is frozen, discovery produces a NEW descriptor re-implemented at every call site (chat panel, Co4E, AI-edit, scheduler),
that the registry swaps in atomically instead of mutating one that other and each of them gets the precedence subtly different.
threads may be reading.
""" """
ordered = tuple(dict.fromkeys(m for m in models if m)) # de-dup, keep order if requested:
chosen = default_model or self.default_model return requested
# Keep the default pointing at something real: fall back to the first configured = str((conf or {}).get("model", "") or "").strip()
# discovered model when the configured default vanished from the catalog. return configured or self.default_model
if ordered and chosen not in ordered:
chosen = ordered[0] def describe(self, conf: Optional[Mapping[str, Any]] = None) -> str:
return replace(self, models=ordered, default_model=chosen) """One-line summary for logs and the Settings row, e.g.
``"anthropic:claude-sonnet-4-6 (Anthropic Claude)"``."""
return f"{self.id}:{self.resolve_model(conf)} ({self.label})"
def candidate_key(self, model_id: str) -> str:
"""The ``provider/model_id`` identity the routing layer keys on.
Defined here so the domain owns the format; ``core.routing.models`` has
its own ``candidate_key()`` helper producing the identical string, and
keeping them equal is what lets the new registry and the existing
assessment store share one keyspace during the migration.
"""
return f"{self.id}/{model_id}"
def to_dict(self) -> Dict[str, Any]: def to_dict(self) -> Dict[str, Any]:
"""JSON-friendly view for config persistence and the Settings UI.""" """JSON-safe projection, for persisting a catalogue snapshot or sending
the descriptor to a UI layer that must not import domain types."""
return { return {
"provider_id": self.provider_id, "id": self.id,
"display_name": self.display_name, "label": self.label,
"wire_protocol": self.wire_protocol.value, "protocol": self.protocol,
"auth_kind": self.auth_kind.value,
"default_model": self.default_model, "default_model": self.default_model,
"models": list(self.models), "capabilities": self.capability_names(),
"max_context": self.max_context, "requires_api_key": self.requires_api_key,
"cost_per_1k_input": self.cost_per_1k_input,
"cost_per_1k_output": self.cost_per_1k_output,
"capabilities": sorted(self.capabilities),
"requires_base_url": self.requires_base_url, "requires_base_url": self.requires_base_url,
"aliases": list(self.aliases), "local": self.local,
"notes": self.notes,
} }
__all__ = ["AuthKind", "WireProtocol", "ProviderDescriptor"] def split_candidate_key(key: str) -> Tuple[str, str]:
"""Inverse of :meth:`ProviderDescriptor.candidate_key`.
Splits on the FIRST ``/`` only: some gateways expose model ids that contain
a slash (``org/model``), and splitting on the last one would corrupt them.
"""
provider, _, model_id = key.partition("/")
return provider, model_id
__all__ = ["ProviderCapability", "ProviderDescriptor", "split_candidate_key"]
-1
View File
@@ -1 +0,0 @@
"""Domain security package: security policies, alert events, and permission types."""
-1
View File
@@ -1 +0,0 @@
"""Domain tasks package: task definitions and deterministic schedule calculators."""
+18 -1
View File
@@ -1 +1,18 @@
"""Domain tools package: tool descriptors, capability scopes, and registry interfaces.""" """Domain entities for tool risk classification and lookup (EPIC R05)."""
from .tool_descriptor import ToolCapability, ToolDescriptor
from .tool_registry import (
BUILT_IN_CAPABILITIES,
UNKNOWN_SOURCE_CAPABILITIES,
ToolRegistry,
default_registry,
)
__all__ = [
"ToolCapability",
"ToolDescriptor",
"ToolRegistry",
"BUILT_IN_CAPABILITIES",
"UNKNOWN_SOURCE_CAPABILITIES",
"default_registry",
]
+86
View File
@@ -0,0 +1,86 @@
"""ToolCapability / ToolDescriptor - the risk-tagged catalogue entry for one
tool the agent loop can call (R05-T01).
Today a tool is just a name inside ``core/tools.py::TOOL_SPECS`` (a
``providers.base.ToolSpec`` — name/description/JSON-schema parameters, with
no notion of risk) plus a hand-written membership test wherever gating is
needed: ``core/tools.py::WRITE_TOOLS``, ``core/code_agent.py``'s
``WRITE_TOOLS | MS365_WRITE_TOOLS``, and ``core/chat_agent.py``'s literal
``name in ("run_command", "install_package")``. Three call sites, three
independently-maintained lists, and a new tool (or an MCP/connector tool,
which has no list membership at all - see ``core/mcp_client.py``) is gated
only if someone remembers to add it everywhere.
``ToolDescriptor`` makes the risk an attribute of the tool itself, declared
once, so ``application/conversations/tool_policy_gateway.py`` (R05-T03) can
decide ALLOW/CONFIRM/DENY from data instead of a growing set of literal
tuples.
Pure domain code: stdlib only, no Qt, no I/O. ``to_spec``/``from_spec`` are
the only place this module touches something outside domain/, and that
something (``providers.base.ToolSpec``) is itself a plain dataclass with no
further dependencies.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Flag, auto
from typing import Any, Dict
from cowork_local.providers.base import ToolSpec
class ToolCapability(Flag):
"""What calling a tool can do to the machine or the network.
A ``Flag`` (not a plain ``Enum``) because a single tool can combine risks
- ``install_package`` writes to the environment, runs pip as a
subprocess, AND needs network access. Composing three separate booleans
per call site is exactly the duplication this type replaces.
"""
NONE = 0
READ = auto()
WRITE = auto()
EXECUTE = auto()
NETWORK = auto()
@dataclass(frozen=True)
class ToolDescriptor:
"""An immutable description of one callable tool.
Attributes:
name: the identifier the model calls (``ToolSpec.name``).
description: shown to the model, unchanged from ``ToolSpec``.
parameters: JSON-Schema object for the call's arguments.
capabilities: the risk this tool carries - see :class:`ToolCapability`.
"""
name: str
description: str
parameters: Dict[str, Any] = field(default_factory=dict)
capabilities: ToolCapability = ToolCapability.NONE
def has(self, capability: ToolCapability) -> bool:
"""True when this tool carries (any bit of) ``capability``."""
return bool(self.capabilities & capability)
def to_spec(self) -> ToolSpec:
"""Project back to the ``ToolSpec`` shape the model-facing catalogue
and the provider call actually use - risk tagging is metadata the
wire format has no room for."""
return ToolSpec(name=self.name, description=self.description,
parameters=self.parameters)
@classmethod
def from_spec(cls, spec: ToolSpec,
capabilities: ToolCapability = ToolCapability.NONE) -> "ToolDescriptor":
"""Wrap an existing ``ToolSpec`` (built-in, MCP, or connector) with a
capability tag. The one place callers attach risk to a spec they did
not author themselves."""
return cls(name=spec.name, description=spec.description,
parameters=spec.parameters, capabilities=capabilities)
__all__ = ["ToolCapability", "ToolDescriptor"]
+125
View File
@@ -0,0 +1,125 @@
"""ToolRegistry - the centralised catalogue every tool source registers into
(R05-T01).
Built-in file/command/fetch tools (``core/tools.py``), MCP server tools
(``core/mcp_client.py``) and unified connectors (``core/ext_connectors.py``)
each produce their own ``List[ToolSpec]`` today, concatenated ad-hoc by
``core/tools.py::combine_tool_sources``. None of that concatenation carries
risk information, which is exactly why an MCP tool call reaches
``core/chat_agent.py`` with no ``ToolDescriptor`` to consult and skips the
permission gate entirely (the gap R05-T04 closes).
``ToolRegistry`` is the one place a :class:`~domain.tools.tool_descriptor.ToolDescriptor`
is looked up by name, so a policy gateway - or anything else that needs to ask
"what can this tool do" - has a single source of truth instead of re-deriving
it from a spec list.
Pure domain code: stdlib only, no Qt, no I/O.
"""
from __future__ import annotations
from typing import Dict, Iterable, List, Optional
from cowork_local.providers.base import ToolSpec
from .tool_descriptor import ToolCapability, ToolDescriptor
class ToolRegistry:
"""An in-memory, name-keyed catalogue of :class:`ToolDescriptor`.
Deliberately mutable and unordered-by-name-only: a turn builds one
registry from whichever tool sources it has (built-ins + whatever MCP
servers/connectors are enabled), so re-registering the same name simply
replaces the previous descriptor rather than raising - the same
"last one wins" behaviour ``combine_tool_sources`` already has for
duplicate tool names across sources.
"""
def __init__(self, descriptors: Optional[Iterable[ToolDescriptor]] = None) -> None:
self._by_name: Dict[str, ToolDescriptor] = {}
for descriptor in descriptors or ():
self.register(descriptor)
def register(self, descriptor: ToolDescriptor) -> None:
self._by_name[descriptor.name] = descriptor
def get(self, name: str) -> Optional[ToolDescriptor]:
return self._by_name.get(name)
def all(self) -> List[ToolDescriptor]:
return list(self._by_name.values())
def specs(self) -> List[ToolSpec]:
"""Every registered descriptor, projected back to ``ToolSpec`` - the
shape the provider call and the model-facing catalogue need."""
return [d.to_spec() for d in self._by_name.values()]
def capabilities_for(self, name: str) -> ToolCapability:
"""The capability set for ``name``, or ``NONE`` for an unknown tool.
Returning ``NONE`` rather than raising lets a policy gateway treat an
unregistered tool the same way as one with no declared risk - the
gateway's DENY-on-unknown-name rule is a deliberate, separate check,
not something this lookup should pre-empt.
"""
descriptor = self._by_name.get(name)
return descriptor.capabilities if descriptor is not None else ToolCapability.NONE
def __contains__(self, name: str) -> bool:
return name in self._by_name
def __len__(self) -> int:
return len(self._by_name)
# --------------------------------------------------------------------------- #
# Default capability map for this app's built-in tools (core/tools.py).
# Kept here, next to the registry, rather than inside core/tools.py itself -
# core/ is the legacy engine layer being strangled, not where new domain facts
# should accumulate.
# --------------------------------------------------------------------------- #
_CAP = ToolCapability
BUILT_IN_CAPABILITIES: Dict[str, ToolCapability] = {
"read_file": _CAP.READ,
"list_dir": _CAP.READ,
"write_file": _CAP.WRITE,
"edit_file": _CAP.WRITE,
"run_command": _CAP.EXECUTE,
"install_package": _CAP.WRITE | _CAP.EXECUTE | _CAP.NETWORK,
"fetch_url": _CAP.NETWORK,
"jira_search": _CAP.NETWORK,
"jira_get_issue": _CAP.NETWORK,
# Advertised by every engine but has no filesystem/process/network effect
# of its own - it only drives the Plan panel (see core/chat_agent.py).
"update_plan": _CAP.NONE,
"save_file": _CAP.WRITE,
}
# Tools with no standard, self-declared risk metadata (every MCP server tool,
# every unified connector) are tagged with this conservative default - see
# R05-T04. Better to over-gate an unknown remote tool than to silently let it
# through as READ-only.
UNKNOWN_SOURCE_CAPABILITIES: ToolCapability = _CAP.WRITE | _CAP.EXECUTE | _CAP.NETWORK
def default_registry(specs: Iterable[ToolSpec]) -> ToolRegistry:
"""Build a registry from ``core/tools.py``'s own ``TOOL_SPECS`` (plus
``save_file``/``update_plan``, which the engines add separately), using
:data:`BUILT_IN_CAPABILITIES`. A spec with no entry in that map falls back
to :data:`UNKNOWN_SOURCE_CAPABILITIES` - the same conservative default
applied to MCP/connector tools, so a built-in nobody has classified yet
fails safe instead of silently ungated."""
registry = ToolRegistry()
for spec in specs:
capability = BUILT_IN_CAPABILITIES.get(spec.name, UNKNOWN_SOURCE_CAPABILITIES)
registry.register(ToolDescriptor.from_spec(spec, capability))
return registry
__all__ = [
"ToolRegistry",
"BUILT_IN_CAPABILITIES",
"UNKNOWN_SOURCE_CAPABILITIES",
"default_registry",
]
+5 -1
View File
@@ -1 +1,5 @@
"""Domain workspaces package: immutable WorkspaceSession definitions.""" """Domain entities for workspace/project isolation (EPIC R06)."""
from .workspace_session import WorkspaceSession
__all__ = ["WorkspaceSession"]
+96
View File
@@ -0,0 +1,96 @@
"""WorkspaceSession - an immutable snapshot of which project a turn belongs
to and where it may touch the filesystem (R06-T01).
``state.py::AppContext.active_project_id`` is a single mutable field read by
every background worker thread. ``ui/workspace_tab.py::_load_current`` writes
it (and the related ``config._project_history_dir``) on the UI thread the
moment the user switches projects - while a turn already running on a
worker thread may read either field mid-switch and end up acting on the
OTHER project's workspace/history for the rest of its run (the race
R06-T04 fixes).
The fix, same shape as R04's ``ConversationExecutionRequest``: capture the
workspace facts a turn needs ONCE, on the thread that knows which project is
selected, into one frozen object. Whatever the user does to the UI afterwards,
the turn keeps using the workspace it was handed at submit time.
Pure domain code: stdlib only, no Qt, no network. It does touch ``Path`` (not
plain strings, unlike ``ConversationExecutionRequest``) because its whole job
is path-containment checking - a snapshot with no room to answer "is this
path mine" would not replace what ``ToolContext.resolve`` currently does
inline.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from typing import Tuple
@dataclass(frozen=True)
class WorkspaceSession:
"""Everything a turn needs to know about ITS workspace, fixed at the
moment it was submitted.
Attributes:
project_id: the project this turn belongs to (``""`` when no project
is selected - e.g. the Code tab, which has no project concept).
workspace_root: the project's sandbox root (``Project.workspace_dir()``).
sandbox_dir: the ``.scratch`` subtree inside ``workspace_root`` used for
generator/helper scripts, never a final deliverable (see
``infrastructure/filesystem/file_tools.py::_flatten_rel``).
allowed_paths: every root a tool call may read/write under. Almost
always just ``(workspace_root,)``; a project with a custom
``output_dir`` outside the managed workspace tree still resolves
to exactly one root - the tuple exists so a future caller (e.g. a
step scoped to a shared input folder) can widen it without a
shape change.
"""
project_id: str
workspace_root: Path
sandbox_dir: Path
allowed_paths: Tuple[Path, ...] = field(default_factory=tuple)
def __post_init__(self) -> None:
if not self.allowed_paths:
object.__setattr__(self, "allowed_paths", (self.workspace_root,))
@classmethod
def from_project(cls, project) -> "WorkspaceSession":
"""Build a session from a ``core.projects.Project``. ``project`` is
typed loosely (not imported) so this module has no dependency on
``core/`` - the caller (``core/projects.py`` itself, or
``application/conversations``) already has the Project in hand."""
root = Path(project.workspace_dir())
return cls(project_id=project.project_id, workspace_root=root,
sandbox_dir=root / ".scratch", allowed_paths=(root,))
@classmethod
def unscoped(cls, workspace_root: Path) -> "WorkspaceSession":
"""A session for callers with no project concept (e.g. the Code tab,
which sandboxes to a plain folder rather than a ``Project``)."""
root = Path(workspace_root)
return cls(project_id="", workspace_root=root, sandbox_dir=root / ".scratch")
def is_allowed(self, path: Path) -> bool:
"""True when ``path`` resolves inside one of :attr:`allowed_paths`.
Same containment rule as ``ToolContext.resolve`` (an exact root match
or a real descendant), but side-effect-free: it reports the answer
instead of raising, so a caller (``FileWorkspaceService``, R06-T05)
can decide what "not allowed" means for its own UI instead of
catching a ``ToolError``.
"""
try:
resolved = Path(path).expanduser().resolve()
except OSError:
return False
for allowed in self.allowed_paths:
root = Path(allowed).resolve()
if resolved == root or root in resolved.parents:
return True
return False
__all__ = ["WorkspaceSession"]
+3 -6
View File
@@ -583,13 +583,10 @@ STRINGS: Dict[str, Dict[str, str]] = {
"routing.mode_off": {"en": "Off", "ja": "オフ", "vi": "Tắt"}, "routing.mode_off": {"en": "Off", "ja": "オフ", "vi": "Tắt"},
"routing.mode_auto": {"en": "Auto", "ja": "自動", "vi": "Tự động"}, "routing.mode_auto": {"en": "Auto", "ja": "自動", "vi": "Tự động"},
"routing.mode_manual": {"en": "Manual", "ja": "手動", "vi": "Thủ công"}, "routing.mode_manual": {"en": "Manual", "ja": "手動", "vi": "Thủ công"},
# Fallback (R03-T03): resilience mode -- never switches for a better
# score, only to rescue a selected model that cannot serve the turn.
"routing.mode_fallback": {"en": "Fallback", "ja": "フォールバック", "vi": "Dự phòng"},
"routing.toggle_tooltip": { "routing.toggle_tooltip": {
"en": "Auto model routing for this chat.\nOff: always use the selected model.\nAuto: silently switch to the best-fit model.\nManual: ask before switching.\nFallback: keep the selected model, switch only if it is unavailable.", "en": "Auto model routing for this chat.\nOff: always use the selected model.\nAuto: silently switch to the best-fit model.\nManual: ask before switching.",
"ja": "このチャットの自動モデルルーティング。\nオフ: 選択したモデルを常に使用。\n自動: 最適なモデルへ自動切替。\n手動: 切替前に確認。\nフォールバック: 選択モデルを維持し、利用できない場合のみ切替。", "ja": "このチャットの自動モデルルーティング。\nオフ: 選択したモデルを常に使用。\n自動: 最適なモデルへ自動切替。\n手動: 切替前に確認。",
"vi": "Tự động định tuyến model cho khung chat này.\nTắt: luôn dùng model đã chọn.\nTự động: tự chuyển sang model phù hợp nhất.\nThủ công: hỏi xác nhận trước khi chuyển.\nDự phòng: giữ model đã chọn, chỉ chuyển khi model đó không dùng được.", "vi": "Tự động định tuyến model cho khung chat này.\nTắt: luôn dùng model đã chọn.\nTự động: tự chuyển sang model phù hợp nhất.\nThủ công: hỏi xác nhận trước khi chuyển.",
}, },
"routing.confirm_title": { "routing.confirm_title": {
"en": "Switch model?", "ja": "モデルを切り替えますか?", "vi": "Chuyển model?", "en": "Switch model?", "ja": "モデルを切り替えますか?", "vi": "Chuyển model?",
+7 -1
View File
@@ -1 +1,7 @@
"""Infrastructure Layer: External system adapters, persistence, and SDK clients.""" """Infrastructure layer - adapters to the outside world.
Concrete implementations of what the inner layers only describe: HTTP calls to
model gateways, the OS keyring, the filesystem, subprocesses, telemetry sinks.
May import ``domain/`` (to speak its types) and third-party libraries, but never
``presentation/``/``ui/``.
"""
-1
View File
@@ -1 +0,0 @@
"""Infrastructure config package: ConfigRepository and typed settings facades."""
+6 -1
View File
@@ -1 +1,6 @@
"""Infrastructure filesystem package: Tool handlers (file, command, fetch tools) and execution workspace.""" """Filesystem/process/network tool adapters split out of ``core/tools.py``
(EPIC R05) and the sandbox execution context they share."""
from .tool_context import CancelFn, ToolContext, ToolError
__all__ = ["CancelFn", "ToolContext", "ToolError"]
+110
View File
@@ -0,0 +1,110 @@
"""Command tools - run_command, install_package (R05-T02).
Moved verbatim out of ``core/tools.py`` (see ``file_tools.py`` for why). These
two are the ones today's hand-written permission gate in
``core/chat_agent.py`` singles out by literal name
(``name in ("run_command", "install_package")``) — R05-T03 replaces that
tuple with a capability lookup, but the tools themselves are unchanged here.
"""
from __future__ import annotations
import os
from pathlib import Path
from typing import Any, Dict, Optional
from .tool_context import CancelFn, ToolContext
COMMAND_TIMEOUT = 120 # seconds
_SNAPSHOT_SKIP = {".git", "__pycache__", "node_modules", ".scratch", ".venv",
".idea", ".mypy_cache", ".pytest_cache"}
def _snapshot(workdir: Path) -> Dict[str, Any]:
"""Map of file path -> (mtime, size) under the workdir (noise dirs skipped)."""
snap: Dict[str, Any] = {}
try:
for dirpath, dirnames, filenames in os.walk(str(workdir)):
dirnames[:] = [d for d in dirnames if d not in _SNAPSHOT_SKIP]
for fn in filenames:
full = os.path.join(dirpath, fn)
try:
st = os.stat(full)
snap[full] = (st.st_mtime_ns, st.st_size)
except OSError:
pass
if len(snap) > 5000:
return snap
except OSError:
pass
return snap
def _sandbox_python(ctx: ToolContext, cancel: Optional[CancelFn] = None,
on_output=None) -> Optional[str]:
"""Lazily create/reuse this ctx's project sandbox venv (Code tab only —
``ctx.sandbox``); returns its python path, or None to use the app's own."""
if not ctx.sandbox:
return None
from cowork_local.core.deps import ensure_project_venv
py = ensure_project_venv(ctx.workdir, cancel=cancel, on_output=on_output)
return str(py) if py else None
def run_command(ctx: ToolContext, args: Dict[str, Any],
cancel: Optional[CancelFn] = None,
on_output=None) -> Dict[str, Any]:
from cowork_local.core.deps import network_blocked_env, run_cancellable, sandbox_env
from cowork_local.core.sandbox_manager import ExecutionConfig, SandboxManager
from cowork_local.security.command_risk_classifier import classify_command
command = str(args.get("command", "")).strip()
if not command:
return {"ok": False, "output": "Empty command."}
# --- Security validation pipeline ---
risk = classify_command(command, is_cowork_mode=ctx.flatten_writes)
if risk.blocked:
denial = "Command blocked by security policy: " + "; ".join(risk.reasons)
return {"ok": False, "output": denial}
# Route through SandboxManager for risk-based isolation
mgr = SandboxManager(ExecutionConfig(
enabled=True,
block_network_by_default=ctx.block_network,
is_cowork_mode=ctx.flatten_writes,
))
sandbox_result = mgr.run(
command=command,
workdir=str(ctx.workdir),
block_network=ctx.block_network,
timeout_sec=COMMAND_TIMEOUT,
cancel=cancel,
)
# Sandbox ALWAYS executes (never double-run). Return its result directly.
if sandbox_result.get("sandbox") == "blocked":
return {"ok": False, "output": sandbox_result.get("stderr", "Command blocked")}
out = sandbox_result.get("stdout", "").strip() or "(no output)"
err = sandbox_result.get("stderr", "")
rc = sandbox_result.get("returncode", -1)
if err:
out = f"{out}\n{err}" if out else err
return {"ok": sandbox_result.get("ok", False), "output": f"[exit {rc}]\n{out}"}
def install_package(ctx: ToolContext, args: Dict[str, Any],
cancel: Optional[CancelFn] = None,
on_output=None) -> Dict[str, Any]:
from cowork_local.core.deps import pip_install
package = str(args.get("package", "")).strip()
if not package:
return {"ok": False, "output": "No package specified."}
python = _sandbox_python(ctx, cancel, on_output)
ok, detail = pip_install(package, cancel=cancel, on_output=on_output, python=python)
head = f"Installed {package}." if ok else f"Could not install {package}."
return {"ok": ok, "output": f"{head}\n{detail}"}
__all__ = ["COMMAND_TIMEOUT", "run_command", "install_package", "_snapshot"]
@@ -0,0 +1,80 @@
"""ExecutionWorkspace - the output folder vs. the scratch folder for one
turn, as two distinct properties instead of a name convention (R06-T03).
Today the ``.scratch`` subtree is a special case buried inside
``_flatten_rel`` (``infrastructure/filesystem/file_tools.py``): a generator
script writes there, the deliverable lands in the output root, and
``core/chat_agent.py`` cleans ``.scratch`` up after the turn — but nothing
NAMES "the scratch folder" as a thing; every call site re-derives
``workdir / ".scratch"`` (or checks ``Path(rel).parts[0] == ".scratch"``) by
hand. This class gives that convention one home.
It does not change WHERE files land - ``workspace_root/.scratch`` stays
exactly what it always was. It exists so a caller (an application service,
R06-T05's ``FileWorkspaceService``, or a future turn-cleanup step) can ask
for "the output dir" / "the scratch dir" instead of hand-building the path
and hoping the convention hasn't drifted.
"""
from __future__ import annotations
import shutil
from dataclasses import dataclass
from pathlib import Path
from cowork_local.domain.workspaces import WorkspaceSession
SCRATCH_DIRNAME = ".scratch"
@dataclass(frozen=True)
class ExecutionWorkspace:
"""The two folders a turn actually writes to, derived from a
:class:`WorkspaceSession`.
``output_dir`` is always the session's ``workspace_root`` itself, not a
per-turn subfolder - Cowork's whole design is that every deliverable lands
directly in the one configured Output folder (see
``infrastructure/filesystem/file_tools.py::_flatten_rel``'s docstring).
``scratch_dir`` is the SAME flat ``workspace_root/.scratch`` every turn on
that workspace already shares today (``core/chat_agent.py``'s
``_cleanup_cowork_intermediates`` operates on that exact path) - this
class does not introduce per-turn namespacing that doesn't exist in the
engine yet, only names the existing convention.
``turn_id`` is kept as metadata for callers that want to attribute a
workspace to the turn that used it (logging, future per-turn scratch
namespacing); it does not affect either path today.
"""
session: WorkspaceSession
turn_id: str
@property
def output_dir(self) -> Path:
return self.session.workspace_root
@property
def scratch_dir(self) -> Path:
return self.session.workspace_root / SCRATCH_DIRNAME
def ensure_dirs(self) -> None:
"""Create both folders if they don't exist yet. Callers that only
need one (most do) can skip this and let ``write_file`` create parents
on demand, same as today."""
self.output_dir.mkdir(parents=True, exist_ok=True)
self.scratch_dir.mkdir(parents=True, exist_ok=True)
def cleanup_scratch(self) -> None:
"""Unconditionally remove the scratch subtree.
Coarser than ``core/chat_agent.py::_cleanup_cowork_intermediates``,
which rescues any real deliverable a generator script wrote INSIDE
``.scratch`` before wiping it - that rescue logic stays there. This
is for callers that only need "make the scratch folder go away"
(e.g. before starting a fresh run) and know it holds nothing worth
saving.
"""
shutil.rmtree(self.scratch_dir, ignore_errors=True)
__all__ = ["ExecutionWorkspace", "SCRATCH_DIRNAME"]
+55
View File
@@ -0,0 +1,55 @@
"""Fetch tools - fetch_url, jira_search, jira_get_issue (R05-T02).
Moved verbatim out of ``core/tools.py`` (see ``file_tools.py`` for why). The
network access these three carry is exactly what the ``ToolCapability.NETWORK``
tag added in R05-T01/domain/tools/tool_registry.py describes.
"""
from __future__ import annotations
from typing import Any, Dict
from .tool_context import ToolContext
def fetch_url(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
"""Fetch a URL's text content (web page / online document / SharePoint-
OneDrive share link) via link_fetch — the same parser task-link attachments
use. Honors the Sandbox Security Layer's "Block network" policy."""
url = str(args.get("url", "")).strip()
if not url:
return {"ok": False, "output": "fetch_url: 'url' is required."}
if not url.lower().startswith(("http://", "https://")):
return {"ok": False, "output": f"fetch_url: not an http(s) URL: {url}"}
if not ctx.allow_url_fetch:
return {"ok": False,
"output": ("fetch_url: URL fetching is turned off in Settings → Security "
"(\"Allow the agent to fetch URLs\").")}
# A pasted Jira issue link on the CONNECTED Jira host is read via the
# authenticated API (so private issues resolve, not a login page). Public
# links / any other URL fall through to the normal fetcher below.
from cowork_local.core import jira_tool
if jira_tool.is_jira_issue_url(ctx.jira, url):
return {"ok": True, "output": jira_tool.get_issue_by_url(ctx.jira, url)}
from cowork_local.core.link_fetch import fetch_link_preview
return {"ok": True, "output": fetch_link_preview(url)}
def jira_search(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
from cowork_local.core import jira_tool
out = jira_tool.search(ctx.jira, str(args.get("jql", "")),
int(args.get("max_results", 25) or 25))
return {"ok": not out.lower().startswith(("jira is not configured", "jira search failed")),
"output": out}
def jira_get_issue(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
from cowork_local.core import jira_tool
out = jira_tool.get_issue(ctx.jira, str(args.get("key", "")))
return {"ok": not out.lower().startswith(("jira is not configured", "could not fetch")),
"output": out}
__all__ = ["fetch_url", "jira_search", "jira_get_issue"]
+136
View File
@@ -0,0 +1,136 @@
"""File tools - read_file, list_dir, write_file, edit_file (R05-T02).
Moved verbatim out of ``core/tools.py``, whose ``execute_tool`` used to
dispatch to these via a hand-written if/elif chain over every tool name it
knew about. Splitting the built-in handlers into per-concern modules
(this one, ``command_tools.py``, ``fetch_tools.py``) means adding a tool no
longer means growing that one function; ``core/tools.py::execute_tool`` now
looks the name up in a dict built from these modules instead.
Behavior is unchanged from before the split - this is a pure move, not a
rewrite. Every existing characterization/contract test that exercises
read_file/write_file/edit_file/list_dir through ``core.tools.execute_tool``
still exercises the exact same code, just imported from here.
"""
from __future__ import annotations
import ast
from pathlib import Path
from typing import Any, Dict
from .tool_context import ToolContext
MAX_READ_BYTES = 200_000
def _flatten_rel(rel: str) -> str:
"""Collapse a sub-folder path down to a bare filename so the file lands in the
workdir root — EXCEPT the ``.scratch`` sandbox subtree, which is preserved.
Used by the Cowork agent (flatten_writes=True) so it can never create a
per-session / per-chat / per-task output sub-folder: every deliverable stays
directly in the single configured Output folder."""
parts = Path(rel).parts
if parts and parts[0] == ".scratch":
return rel # temporary sandbox is allowed (and cleaned up afterwards)
return Path(rel).name or rel
def _check_python_syntax(target: Path, content: str) -> str:
"""Return a short warning if ``content`` is invalid Python, else ''.
Catches syntax errors the instant a .py file is written/edited — before the
agent wastes a whole run_command round-trip just to get the same error back
from a traceback."""
if target.suffix.lower() not in (".py", ".pyw"):
return ""
try:
ast.parse(content, filename=str(target))
return ""
except SyntaxError as exc:
return f"\n⚠ Syntax error at line {exc.lineno}: {exc.msg} — fix this before running the file."
def read_file(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
target = ctx.resolve(str(args.get("path", "")))
if not target.exists():
return {"ok": False, "output": f"File not found: {args.get('path')}"}
data = target.read_bytes()[:MAX_READ_BYTES]
text = data.decode("utf-8", errors="replace")
return {"ok": True, "output": text}
def list_dir(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
rel = str(args.get("path", ".") or ".")
target = ctx.resolve(rel)
# A missing/not-yet-created path is NOT a tool failure — report it as an
# ordinary result so the agent can create it or pick another path and keep
# going. Returning ok=False here surfaced a false "tool failed: list_dir" in
# Co4E flows and could stall a step on a recoverable situation.
if not target.exists():
return {"ok": True, "output": f"(path '{rel}' does not exist yet — create it or use another path)"}
if target.is_file():
return {"ok": True, "output": f"('{rel}' is a file, not a directory)"}
entries = []
for child in sorted(target.iterdir(), key=lambda p: (p.is_file(), p.name.lower())):
marker = "/" if child.is_dir() else ""
entries.append(f"{child.name}{marker}")
return {"ok": True, "output": "\n".join(entries) or "(empty folder)"}
def write_file(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
rel = str(args.get("path", ""))
if ctx.flatten_writes:
rel = _flatten_rel(rel)
target = ctx.resolve(rel)
content = str(args.get("content", ""))
target.parent.mkdir(parents=True, exist_ok=True)
# A .xlsx is a binary package — build a REAL workbook from the content
# (CSV/TSV/Markdown-table/JSON) rather than writing raw text (which corrupts it).
if target.suffix.lower() in (".xlsx", ".xlsm"):
from cowork_local.core import xlsx_write
if xlsx_write.build_xlsx_from_text(target, content):
return {"ok": True, "path": str(target),
"output": f"Wrote spreadsheet {rel} ({target.name})."}
return {"ok": False, "output": "Could not build the .xlsx (openpyxl unavailable) — "
"write a .csv instead, or use a generator script."}
target.write_text(content, encoding="utf-8")
warning = _check_python_syntax(target, content)
return {"ok": True, "path": str(target),
"output": f"Wrote {len(content)} chars to {rel}.{warning}"}
def edit_file(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
"""Replace an exact snippet inside an existing file (precise patch edit)."""
rel = str(args.get("path", ""))
if ctx.flatten_writes:
rel = _flatten_rel(rel)
target = ctx.resolve(rel)
if not target.exists():
return {"ok": False,
"output": f"File not found: {rel} — use write_file to create it."}
old = str(args.get("old_string", ""))
new = str(args.get("new_string", ""))
replace_all = bool(args.get("replace_all", False))
if not old:
return {"ok": False, "output": "old_string is empty — provide the exact text to replace."}
try:
text = target.read_text(encoding="utf-8", errors="replace")
except OSError as exc:
return {"ok": False, "output": f"Could not read file: {exc}"}
count = text.count(old)
if count == 0:
return {"ok": False, "output": ("old_string not found. Read the file and copy the exact "
"text to replace, including indentation/whitespace.")}
if count > 1 and not replace_all:
return {"ok": False, "output": (f"old_string appears {count} times — add surrounding "
"context to make it unique, or set replace_all=true.")}
updated = text.replace(old, new) if replace_all else text.replace(old, new, 1)
target.write_text(updated, encoding="utf-8")
n = count if replace_all else 1
warning = _check_python_syntax(target, updated)
return {"ok": True,
"output": f"Edited {args.get('path')} ({n} replacement{'' if n == 1 else 's'}).{warning}"}
__all__ = ["MAX_READ_BYTES", "read_file", "list_dir", "write_file", "edit_file"]
+62
View File
@@ -0,0 +1,62 @@
"""ToolContext / ToolError / CancelFn - the sandboxed execution context every
built-in tool runs against (moved out of ``core/tools.py`` in R05-T02).
Kept as its own leaf module (no dependency on any sibling in this package) so
``file_tools.py``, ``command_tools.py`` and ``fetch_tools.py`` can each import
it without creating an import cycle back through ``core/tools.py``, which
itself re-exports ``ToolContext``/``ToolError`` from here for the existing
callers (``core/chat_agent.py``, ``core/code_agent.py``,
``core/task_executors.py``) that do ``from .tools import ToolContext``.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, Dict, Optional
CancelFn = Callable[[], bool]
class ToolError(Exception):
pass
@dataclass
class ToolContext:
workdir: Path
flatten_writes: bool = False # Cowork: force every write into the workdir root
sandbox: bool = False # Code tab: isolate run_command/install_package into <workdir>/.venv
# Sandbox Security Layer — Settings' "Resource Limits" (cpu_percent/memory_mb/
# disk_mb), applied to every run_command/install_package this context runs.
# None (default) = no limits, matching pre-existing behavior.
resource_limits: Optional[Dict[str, float]] = None
# Sandbox Security Layer — Settings' "Block network for agent commands"
# (policy-level, see deps.py::network_blocked_env). False (default) =
# unrestricted, matching pre-existing behavior.
block_network: bool = False
# Whether the fetch_url tool may read URLs — SEPARATE from block_network
# (reading a web page/share link for info is safe; running networked shell
# commands is the risk). Defaults True; set from agent_security.allow_url_fetch.
allow_url_fetch: bool = True
# Jira read connector config (base_url/email/api_token) — None disables the
# jira_* tools' ability to connect. Populated from config.data["jira"].
jira: Optional[Dict[str, Any]] = None
def resolve(self, rel: str) -> Path:
"""Resolve ``rel`` inside the workdir, rejecting escapes."""
if rel in ("", "."):
return self.workdir
candidate = (self.workdir / rel).expanduser()
try:
resolved = candidate.resolve()
except OSError as exc:
raise ToolError(f"Invalid path: {rel} ({exc})")
root = self.workdir.resolve()
if resolved != root and root not in resolved.parents:
raise ToolError(
f"Refused: '{rel}' is outside the working folder ({root})."
)
return resolved
__all__ = ["CancelFn", "ToolError", "ToolContext"]
+5 -1
View File
@@ -1 +1,5 @@
"""Infrastructure MCP package: McpToolSourceManager and child process lifecycle.""" """MCP server connection lifecycle management (EPIC R05)."""
from .mcp_source_manager import McpToolSourceManager
__all__ = ["McpToolSourceManager"]
+113
View File
@@ -0,0 +1,113 @@
"""McpToolSourceManager - the MCP server connection lifecycle, extracted out
of ``state.py::AppContext`` (R05-T05).
Today ``AppContext.build_mcp_tools`` inlines all of this: a ``_mcp_connections``
dict, a ``_conn_lock`` guarding check-then-create against concurrent turns (a
Cowork tab, a Co4E flow and a Scheduled Task can all call it at once), and a
"start it, cache it, skip it on failure" loop repeated for both the
admin-configured servers AND the built-in MS365 server
(``_ms365_builtin_connection``). None of that logic touches Qt; it was only
ever inline because ``AppContext`` is where the config lived.
This class owns the SAME cache/lock/start-or-skip behavior as a standalone,
directly testable object — ``AppContext`` becomes a thin caller (one instance
per app, same as it holds one ``RoutingApplicationService``).
Pure Python: no Qt. It DOES touch the network/filesystem via
``core.mcp_client.McpServerConnection`` (a subprocess + asyncio loop), which is
exactly what makes it infrastructure rather than domain.
"""
from __future__ import annotations
import threading
from typing import Dict, List, Optional
from cowork_local.core.mcp_client import McpServerConnection
class McpToolSourceManager:
"""Caches and supervises one :class:`McpServerConnection` per server name.
``connection_factory`` defaults to ``McpServerConnection`` itself; tests
substitute a fake so no real subprocess is spawned (see
``tests/unit/test_mcp_source_manager.py``).
"""
def __init__(self, connection_factory=McpServerConnection) -> None:
self._connections: Dict[str, McpServerConnection] = {}
self._lock = threading.Lock()
self._connection_factory = connection_factory
def ensure(self, name: str, command: str, args: Optional[List[str]] = None,
env: Optional[Dict[str, str]] = None) -> Optional[McpServerConnection]:
"""Return a live connection for ``name``, starting one if there is
none cached or the cached one's subprocess has died.
Serialized under one lock so two turns racing to build their tool
list at the same moment share one subprocess per server instead of
each spawning their own (the bug this replaces:
``AppContext._conn_lock``'s original docstring). Returns ``None`` -
never raises - when the server fails to start, matching the existing
"one broken server must not block the turn" behavior.
"""
with self._lock:
existing = self._connections.get(name)
if existing is not None and existing.is_alive():
return existing
if existing is not None:
self._connections.pop(name, None)
connection = self._connection_factory(name, command, args or [], env)
try:
connection.start()
except Exception: # noqa: BLE001 - one broken server must not block the turn
return None
self._connections[name] = connection
return connection
def get(self, name: str) -> Optional[McpServerConnection]:
"""The cached connection for ``name``, without starting one."""
return self._connections.get(name)
def is_alive(self, name: str) -> bool:
connection = self._connections.get(name)
return connection is not None and connection.is_alive()
def restart(self, name: str, command: str, args: Optional[List[str]] = None,
env: Optional[Dict[str, str]] = None) -> Optional[McpServerConnection]:
"""Force a fresh connection for ``name`` even if the cached one still
looks alive - for a server the caller knows is misbehaving."""
with self._lock:
self._connections.pop(name, None)
return self.ensure(name, command, args, env)
def stop(self, name: str) -> None:
"""Stop and forget one connection - used when a server becomes
unavailable by configuration (e.g. MS365 signed out) rather than by
crashing."""
with self._lock:
connection = self._connections.pop(name, None)
if connection is not None:
try:
connection.stop()
except Exception: # noqa: BLE001 - shutdown must never raise into the caller
pass
def active(self) -> List[McpServerConnection]:
"""Every currently cached connection - what
``core/mcp_client.py::build_mcp_tools`` merges tool specs from."""
return list(self._connections.values())
def stop_all(self) -> None:
"""Terminate every connection's subprocess - called on app shutdown
so none of them linger as orphan processes."""
with self._lock:
connections = list(self._connections.values())
self._connections.clear()
for connection in connections:
try:
connection.stop()
except Exception: # noqa: BLE001
pass
__all__ = ["McpToolSourceManager"]
+1 -1
View File
@@ -1 +1 @@
"""Infrastructure persistence package.""" """Persistence adapters (EPIC R02/R06)."""
+8 -1
View File
@@ -1 +1,8 @@
"""Infrastructure JSON persistence package: AtomicJsonFile and repositories.""" """JSON-file persistence adapters: crash-safe writes and the workspace/
conversation repositories built on them (EPIC R06)."""
from .atomic_write import write_json
from .conversation_repository_impl import ConversationRepository
from .workspace_repository_impl import WorkspaceRepository
__all__ = ["write_json", "WorkspaceRepository", "ConversationRepository"]
@@ -0,0 +1,56 @@
"""write_json - crash-safe JSON writes (R06-T02).
``core/projects.py::save_project`` and ``core/history.py``'s
``save_conversation``/``rename_conversation``/``set_pinned`` all do a plain
``path.write_text(json.dumps(...))`` today. That is two syscalls with a gap in
between: a crash, a killed process, or a full disk between the truncate and
the write leaves a half-written, unparseable JSON file - the NEXT read of
that project/conversation then fails outright (``load_project`` /
``load_conversation`` already treat a parse error as "missing", so this isn't
even a loud failure - a project can silently vanish).
``write_json`` fixes this the standard way: write the full content to a
temporary file in the SAME directory (so the following replace is on one
filesystem, not crossing a mount point), then atomically rename it over the
target. Either the old file is still there, or the new one is fully there -
never a partial one.
Transitional note: EPIC R02 (Team Nam, ``docs/refactor/Refactoring_Checklist.md``
R02-T01) plans a shared ``infrastructure/persistence/json/atomic_json_file.py``
for the SAME purpose across the whole app (config, secrets, ...). This module
is deliberately named differently and scoped to R06's two repositories only,
so the two EPICs don't edit the same file in parallel; once R02-T01 lands,
``WorkspaceRepository``/``ConversationRepository`` should switch to it and
this module can go away.
"""
from __future__ import annotations
import json
import os
import tempfile
from pathlib import Path
from typing import Any
def write_json(path: Path, data: Any) -> None:
"""Serialize ``data`` as indented UTF-8 JSON and write it to ``path``
atomically. Creates parent directories if needed."""
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
text = json.dumps(data, ensure_ascii=False, indent=2)
fd, tmp_name = tempfile.mkstemp(dir=str(path.parent), prefix=f".{path.name}.", suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
handle.write(text)
handle.flush()
os.fsync(handle.fileno())
os.replace(tmp_name, path)
except BaseException:
try:
os.unlink(tmp_name)
except OSError:
pass
raise
__all__ = ["write_json"]
@@ -0,0 +1,54 @@
"""ConversationRepository - an object-shaped, atomic-write-backed facade over
``core/history.py`` (R06-T02). Same rationale as
``workspace_repository_impl.py``: the module-level functions in
``core/history.py`` are still what production code calls (they now write
atomically themselves), this class is the seam for application-layer code
that wants an object instead of a directory-parameterised function.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any, Dict, List, Optional
from cowork_local.config import HISTORY_DIR
from cowork_local.core.history import (
delete_conversation,
list_conversations,
load_conversation,
new_session_id,
rename_conversation,
save_conversation,
set_pinned,
)
class ConversationRepository:
"""CRUD + search over conversation JSON files, scoped to one
``directory`` (defaults to the app's real ``HISTORY_DIR``)."""
def __init__(self, directory: Optional[Path] = None) -> None:
self._directory = Path(directory) if directory is not None else HISTORY_DIR
def new_session_id(self) -> str:
return new_session_id()
def save(self, kind: str, session_id: str, messages: List[Dict[str, Any]], **kwargs) -> Path:
return save_conversation(self._directory, kind, session_id, messages, **kwargs)
def load(self, path: Path) -> Dict[str, Any]:
return load_conversation(path)
def list(self, query: str = "") -> List[Dict[str, Any]]:
return list_conversations(self._directory, query)
def delete(self, path: Path) -> None:
delete_conversation(path)
def rename(self, path: Path, new_title: str) -> None:
rename_conversation(path, new_title)
def set_pinned(self, path: Path, pinned: bool) -> None:
set_pinned(path, pinned)
__all__ = ["ConversationRepository"]
@@ -0,0 +1,59 @@
"""WorkspaceRepository - an object-shaped, atomic-write-backed facade over
``core/projects.py`` (R06-T02).
``core/projects.py``'s module-level functions (``list_projects``,
``load_project``, ``save_project``, ``new_project``, ``delete_project``) are
still what every existing call site (``ui/workspace_tab.py``, ``state.py``,
task executors) uses, and stay that way - they now write through
:func:`atomic_write.write_json` themselves, so the durability fix applies
whether or not a caller ever touches this class.
This repository exists for the application layer (``application/workspaces``,
R06-T05) to depend on an interface instead of reaching into ``core/`` -
useful once code above ``core/`` starts being written against
``domain``/``application`` seams instead of the legacy module functions. It
is a thin pass-through today, not a re-implementation: same on-disk format,
same directory, same functions underneath.
"""
from __future__ import annotations
from pathlib import Path
from typing import List, Optional
from cowork_local.core.projects import (
PROJECTS_DIR,
Project,
delete_project,
list_projects,
load_project,
new_project,
save_project,
)
class WorkspaceRepository:
"""CRUD over :class:`~cowork_local.core.projects.Project`, scoped to one
``directory`` (defaults to the app's real ``PROJECTS_DIR``; tests pass a
``tmp_path`` so nothing touches the user's real config folder)."""
def __init__(self, directory: Optional[Path] = None) -> None:
self._directory = directory or PROJECTS_DIR
def list(self) -> List[Project]:
return list_projects(self._directory)
def get(self, project_id: str) -> Optional[Project]:
return load_project(project_id, self._directory)
def save(self, project: Project) -> Path:
return save_project(project, self._directory)
def create(self, name: str, description: str = "", instructions: str = "",
output_dir: str = "") -> Project:
return new_project(name, description, instructions, output_dir, self._directory)
def delete(self, project_id: str) -> bool:
return delete_project(project_id, self._directory)
__all__ = ["WorkspaceRepository"]
-1
View File
@@ -1 +0,0 @@
"""Infrastructure platform adapters package."""
-1
View File
@@ -1 +0,0 @@
"""Infrastructure Qt platform adapters: QtSchedulerClock."""
+5 -1
View File
@@ -1 +1,5 @@
"""Infrastructure providers package: LLM provider adapters and ProviderRegistry.""" """Provider adapters and the central provider catalogue (EPIC R03)."""
from .provider_registry import ProviderRegistry, default_registry
__all__ = ["ProviderRegistry", "default_registry"]
+156 -236
View File
@@ -1,287 +1,207 @@
"""Central registry of every LLM provider the app can talk to. """ProviderRegistry - the one place a provider is declared (R03-T02).
Replaces the bare ``{name: class}`` dict in ``providers/factory.py`` as the Replaces the three-way split between ``providers/factory.py::_REGISTRY``,
single catalogue of providers. Two responsibilities, kept deliberately narrow: ``config.py::DEFAULT_CONFIG["providers"]`` and ``config.py::PROVIDER_LABELS``
with a single catalogue of :class:`ProviderDescriptor` objects plus the
implementation class each one maps to.
1. **Lookup** — resolve a provider id (or one of its aliases, or a bare model Adding a provider is now one entry in :data:`BUILT_IN_PROVIDERS` (declarative
id) to its :class:`~domain.models.provider_descriptor.ProviderDescriptor`. facts) and one line in :data:`_IMPLEMENTATIONS` (which class speaks that
2. **Construction** — instantiate the concrete adapter class that speaks the protocol) - see ``docs/governance/contributor-recipes.md`` (R10-T04).
descriptor's wire protocol.
This is infrastructure, not domain: it is allowed to import the concrete Migration note (strangler fig, ADR-001 section 4): this registry does not
``providers/*`` adapters (which pull in ``requests``). The adapters are imported re-implement any provider. It builds the SAME classes ``providers/factory.py``
lazily inside :meth:`build` so that merely *reading the catalogue* — which the builds, so both entry points stay behaviourally identical while call sites move
pure routing service does on every turn — never drags the HTTP stack into the over one at a time.
process.
""" """
from __future__ import annotations from __future__ import annotations
import threading from typing import Any, Dict, Iterable, List, Mapping, Optional
from typing import Any, Dict, Iterable, List, Optional
from ...domain.models.provider_descriptor import ( from cowork_local.domain.models.provider_descriptor import (
AuthKind, ProviderCapability,
ProviderDescriptor, ProviderDescriptor,
WireProtocol,
) )
from cowork_local.providers.base import Provider, ProviderError
# --------------------------------------------------------------------------- # _CAP = ProviderCapability
# Built-in catalogue.
# Every provider the app ships with, described once.
# #
# Mirrors DEFAULT_CONFIG["providers"] in config.py (ids + default models) and # The capability sets are deliberately conservative: a capability listed here is
# providers/factory.py (id -> wire protocol). Prices are intentionally absent: # one the adapter genuinely implements today. Claiming VISION for a provider
# core/routing/metadata.py owns cost, and a guessed price is worse than a # whose chat() cannot translate an image block would route an image turn into a
# known-unknown (see that module's docstring). # guaranteed failure, so an unimplemented capability must stay off the list.
# --------------------------------------------------------------------------- # BUILT_IN_PROVIDERS: tuple = (
BUILTIN_DESCRIPTORS: tuple = (
ProviderDescriptor( ProviderDescriptor(
provider_id="openai_compat", id="openai_compat",
display_name="OpenAI-compatible gateway", label="OpenAI-compatible (Internal Gateway)",
wire_protocol=WireProtocol.OPENAI_COMPAT, protocol="openai_compat",
auth_kind=AuthKind.API_KEY,
default_model="gpt-4o-mini", default_model="gpt-4o-mini",
supports_vision=True, capabilities=frozenset({_CAP.STREAMING, _CAP.TOOLS, _CAP.VISION,
# A generic gateway has no fixed host, so the endpoint MUST be _CAP.REASONING, _CAP.MODEL_LISTING}),
# configured before the provider can be used at all. notes="Any endpoint speaking the OpenAI Chat Completions protocol.",
requires_base_url=True,
), ),
ProviderDescriptor( ProviderDescriptor(
provider_id="anthropic", id="anthropic",
display_name="Anthropic Claude", label="Anthropic Claude",
wire_protocol=WireProtocol.ANTHROPIC, protocol="anthropic",
auth_kind=AuthKind.API_KEY,
default_model="claude-sonnet-4-6", default_model="claude-sonnet-4-6",
# Kept in sync with AnthropicProvider._FALLBACK_MODELS — the list the capabilities=frozenset({_CAP.STREAMING, _CAP.TOOLS, _CAP.VISION,
# provider itself falls back to when /v1/models cannot be reached. _CAP.MODEL_LISTING}),
models=("claude-opus-4-8", "claude-sonnet-4-6", "claude-haiku-4-5-20251001"),
max_context=200000,
supports_vision=True,
), ),
ProviderDescriptor( ProviderDescriptor(
provider_id="ollama", id="ollama",
display_name="Ollama (local)", label="Ollama (local models)",
wire_protocol=WireProtocol.OPENAI_COMPAT, protocol="openai_compat",
# A local runtime needs no credential; Settings must not demand one.
auth_kind=AuthKind.NONE,
default_model="llama3.1", default_model="llama3.1",
supports_vision=False, capabilities=frozenset({_CAP.STREAMING, _CAP.TOOLS, _CAP.REASONING,
requires_base_url=True, _CAP.MODEL_LISTING}),
# Ollama ignores the key, but the OpenAI client layer requires a value,
# so the default config ships a placeholder rather than an empty string.
requires_api_key=False,
local=True,
notes="Runs on this machine - no data leaves the device, no token cost.",
), ),
ProviderDescriptor( ProviderDescriptor(
provider_id="github_copilot", id="github_copilot",
display_name="GitHub Copilot", label="GitHub Copilot",
wire_protocol=WireProtocol.OPENAI_COMPAT, protocol="openai_compat",
# The credential is a Copilot token minted by an external login flow,
# not a self-service API key.
auth_kind=AuthKind.OAUTH_TOKEN,
default_model="gpt-4o", default_model="gpt-4o",
models=("gpt-4o", "gpt-4o-mini"), capabilities=frozenset({_CAP.STREAMING, _CAP.TOOLS, _CAP.MODEL_LISTING}),
max_context=128000, notes="Paste a Copilot token as the API key.",
supports_vision=True,
), ),
ProviderDescriptor( ProviderDescriptor(
provider_id="codex", id="codex",
display_name="OpenAI", label="OpenAI (Codex / GPT)",
wire_protocol=WireProtocol.OPENAI_COMPAT, protocol="openai_compat",
auth_kind=AuthKind.API_KEY,
default_model="gpt-4o-mini", default_model="gpt-4o-mini",
models=("gpt-4o", "gpt-4o-mini", "o1", "o3"), capabilities=frozenset({_CAP.STREAMING, _CAP.TOOLS, _CAP.VISION,
max_context=128000, _CAP.REASONING, _CAP.MODEL_LISTING}),
supports_vision=True,
# Historic config key: early builds stored this provider as "openai".
aliases=("openai",),
), ),
) )
class ProviderNotFoundError(LookupError): def _implementations() -> Dict[str, type]:
"""Raised when no descriptor answers to the requested provider id. """Protocol -> adapter class.
A dedicated type (rather than bare ``KeyError``) lets callers distinguish Imported lazily inside the function because ``providers/anthropic.py`` and
"this provider is not in the catalogue" from an unrelated dict miss, and ``providers/openai_compat.py`` pull in ``requests`` at import time; keeping
keeps the message actionable by listing what IS registered. that out of module import means a test that only inspects descriptors pays
no import cost at all.
""" """
from cowork_local.providers.anthropic import AnthropicProvider
from cowork_local.providers.openai_compat import OpenAICompatProvider
return {
"openai_compat": OpenAICompatProvider,
"anthropic": AnthropicProvider,
}
class ProviderRegistry: class ProviderRegistry:
"""Thread-safe catalogue of :class:`ProviderDescriptor` records. """Catalogue of known providers + the factory that instantiates them.
Thread-safety matters because model discovery runs on background worker Intentionally holds no config and no app context: it is a pure lookup table
threads (the routing prober, Settings' "Load models") and republishes an plus a build step, so it can be constructed in a test with a custom
updated descriptor via :meth:`replace`, while chat turns on other threads descriptor list and no application running.
are reading the catalogue concurrently.
""" """
def __init__(self, descriptors: Optional[Iterable[ProviderDescriptor]] = None) -> None: def __init__(self, descriptors: Optional[Iterable[ProviderDescriptor]] = None) -> None:
# Keyed by canonical id; alias resolution walks the values so an alias # Dict preserves declaration order (Python 3.7+), which is the order
# can never shadow a real provider id. # Settings lists providers in - so the catalogue order is data, not luck.
self._by_id: Dict[str, ProviderDescriptor] = {} self._by_id: Dict[str, ProviderDescriptor] = {
self._lock = threading.RLock() d.id: d for d in (descriptors if descriptors is not None else BUILT_IN_PROVIDERS)
for descriptor in descriptors or (): }
self.register(descriptor)
# -- registration --------------------------------------------------- # # -- catalogue queries ------------------------------------------------ #
def register(self, descriptor: ProviderDescriptor) -> ProviderDescriptor: def ids(self) -> List[str]:
"""Add a descriptor. Refuses to silently overwrite an existing id so a """Known provider ids, in declaration order."""
typo in a plugin cannot hijack a built-in provider; use :meth:`replace` return list(self._by_id)
when an update is the actual intent."""
with self._lock:
existing = self._by_id.get(descriptor.provider_id)
if existing is not None and existing != descriptor:
raise ValueError(
f"Provider '{descriptor.provider_id}' is already registered; "
"call replace() to update it."
)
self._by_id[descriptor.provider_id] = descriptor
return descriptor
def replace(self, descriptor: ProviderDescriptor) -> ProviderDescriptor:
"""Register or update a descriptor unconditionally — the path model
discovery uses to publish a freshly enumerated model list."""
with self._lock:
self._by_id[descriptor.provider_id] = descriptor
return descriptor
# -- lookup ---------------------------------------------------------- #
def get(self, provider_id: str) -> ProviderDescriptor:
"""Descriptor for ``provider_id`` (canonical id or alias).
Raises :class:`ProviderNotFoundError` rather than returning ``None`` so
a misconfigured provider fails loudly at the call site instead of
surfacing later as an ``AttributeError`` on ``None``.
"""
found = self.find(provider_id)
if found is None:
known = ", ".join(sorted(self._by_id)) or "<empty registry>"
raise ProviderNotFoundError(
f"Unsupported provider: {provider_id!r}. Registered: {known}"
)
return found
def find(self, provider_id: str) -> Optional[ProviderDescriptor]:
"""Non-raising :meth:`get` — ``None`` when nothing matches."""
needle = (provider_id or "").strip()
if not needle:
return None
with self._lock:
direct = self._by_id.get(needle)
if direct is not None:
return direct
# Fall back to a case-insensitive id/alias scan; order is stable
# because dicts preserve insertion order, so the earliest-registered
# provider wins a tie.
for descriptor in self._by_id.values():
if descriptor.matches(needle):
return descriptor
return None
def find_by_model(self, model_id: str) -> Optional[ProviderDescriptor]:
"""Resolve a bare model id back to the provider that serves it.
This is the "dynamic lookup by model ID" R03-T02 calls for: routing
decisions and saved conversations sometimes carry only a model name, and
the caller still needs to know which provider to build. Returns ``None``
when the model belongs to a gateway whose catalogue we cannot enumerate
offline — callers then fall back to the configured active provider.
"""
needle = (model_id or "").strip()
if not needle:
return None
with self._lock:
for descriptor in self._by_id.values():
if descriptor.knows_model(needle):
return descriptor
return None
def all(self) -> List[ProviderDescriptor]: def all(self) -> List[ProviderDescriptor]:
"""Every registered descriptor, in registration order (snapshot copy — """Every descriptor, in declaration order."""
safe to iterate while another thread registers).""" return list(self._by_id.values())
with self._lock:
return list(self._by_id.values())
def ids(self) -> List[str]: def get(self, provider_id: str) -> Optional[ProviderDescriptor]:
"""Canonical provider ids, sorted for stable UI/reporting output.""" """The descriptor for ``provider_id``, or None when unknown.
with self._lock:
return sorted(self._by_id)
def __contains__(self, provider_id: object) -> bool: Returns None rather than raising because the caller is often reacting to
return isinstance(provider_id, str) and self.find(provider_id) is not None a config file that may name a provider from a newer version; the UI
should be able to skip it, not crash.
def __len__(self) -> int:
with self._lock:
return len(self._by_id)
# -- construction ---------------------------------------------------- #
def adapter_class(self, provider_id: str):
"""Concrete ``Provider`` subclass implementing this provider's protocol.
The adapters are imported here (not at module import) so the pure
routing/domain code can consult the catalogue without loading
``requests`` and the whole HTTP stack.
""" """
descriptor = self.get(provider_id) return self._by_id.get(provider_id)
from ...providers.anthropic import AnthropicProvider
from ...providers.openai_compat import OpenAICompatProvider
protocol_to_class = { def require(self, provider_id: str) -> ProviderDescriptor:
WireProtocol.OPENAI_COMPAT: OpenAICompatProvider, """Like :meth:`get` but raises :class:`ProviderError` when unknown.
WireProtocol.ANTHROPIC: AnthropicProvider,
} Same error type ``providers/factory.py::build_provider`` already raises,
adapter = protocol_to_class.get(descriptor.wire_protocol) so callers that migrate to the registry keep their existing except clause.
if adapter is None: # pragma: no cover — unreachable while the map is total """
raise ProviderNotFoundError( descriptor = self._by_id.get(provider_id)
f"No adapter implements wire protocol {descriptor.wire_protocol!r}" if descriptor is None:
known = ", ".join(self._by_id) or "(none)"
raise ProviderError(f"Unsupported provider: {provider_id} (known: {known})")
return descriptor
def labels(self) -> Dict[str, str]:
"""``{id: label}`` - the drop-in replacement for ``config.PROVIDER_LABELS``."""
return {d.id: d.label for d in self._by_id.values()}
def supporting(self, capability: ProviderCapability) -> List[ProviderDescriptor]:
"""Every descriptor advertising ``capability`` - used to answer "which
providers could serve this turn?" before any of them is built."""
return [d for d in self._by_id.values() if d.supports(capability)]
def configured(self, providers_conf: Mapping[str, Mapping[str, Any]]
) -> List[ProviderDescriptor]:
"""Descriptors whose config section is complete enough to actually call.
``providers_conf`` is ``AppConfig.data["providers"]``. Passing the raw
mapping (not the AppConfig object) keeps this layer independent of the
config implementation, which EPIC R02 is rewriting in parallel.
"""
return [d for d in self._by_id.values()
if d.is_configured(providers_conf.get(d.id, {}) or {})]
# -- construction ----------------------------------------------------- #
def build(self, provider_id: str, conf: Mapping[str, Any],
model: str = "") -> Provider:
"""Instantiate the adapter for ``provider_id``.
``model`` overrides the configured model for this instance only - that is
how the routing layer runs one turn on a different model without mutating
the user's saved settings.
"""
descriptor = self.require(provider_id)
impl = _implementations().get(descriptor.protocol)
if impl is None: # pragma: no cover - only reachable via a bad descriptor
raise ProviderError(
f"Provider '{provider_id}' declares unknown protocol "
f"'{descriptor.protocol}'."
) )
return adapter # Copy before mutating: conf is the caller's live config dict, and
# writing the routed model into it would silently change the user's
# saved default for every later turn.
resolved = dict(conf or {})
resolved["model"] = descriptor.resolve_model(conf, model)
instance = impl(resolved)
# The adapter class is shared by several ids (three of them are
# OpenAI-compatible), so its class-level `name` cannot identify which
# provider this is. Stamping the instance keeps usage records, audit
# entries and routing candidate keys attributed to the right provider.
instance.name = descriptor.id
return instance
def build(self, provider_id: str, conf: Dict[str, Any]): def describe(self, provider_id: str, conf: Optional[Mapping[str, Any]] = None) -> str:
"""Instantiate a ready-to-use provider adapter. """One-line description used in logs and error messages."""
return self.require(provider_id).describe(conf)
The descriptor's ``default_model`` fills in a missing/blank ``model`` so
a half-written config still produces a working provider instead of an
empty model id that only fails once the request hits the gateway.
"""
descriptor = self.get(provider_id)
adapter = self.adapter_class(descriptor.provider_id)
merged = dict(conf or {})
merged["model"] = descriptor.resolve_model(merged.get("model", ""))
return adapter(merged)
# --------------------------------------------------------------------------- # # Shared default instance. Callers that need the built-in catalogue use this
# Process-wide default registry. # instead of constructing a registry each time; tests build their own with an
# # explicit descriptor list.
# Built lazily under a lock: several UI screens can ask for it during startup default_registry = ProviderRegistry()
# from different threads, and double-construction would hand out two catalogues
# whose discovered model lists then drift apart.
# --------------------------------------------------------------------------- #
_default_registry: Optional[ProviderRegistry] = None
_default_lock = threading.Lock()
def default_registry() -> ProviderRegistry: __all__ = ["ProviderRegistry", "BUILT_IN_PROVIDERS", "default_registry"]
"""The shared registry seeded with :data:`BUILTIN_DESCRIPTORS`."""
global _default_registry
if _default_registry is None:
with _default_lock:
if _default_registry is None:
_default_registry = ProviderRegistry(BUILTIN_DESCRIPTORS)
return _default_registry
def reset_default_registry() -> None:
"""Drop the cached registry — test-support hook so one test's registrations
cannot leak into the next."""
global _default_registry
with _default_lock:
_default_registry = None
__all__ = [
"BUILTIN_DESCRIPTORS",
"ProviderNotFoundError",
"ProviderRegistry",
"default_registry",
"reset_default_registry",
]
-1
View File
@@ -1 +0,0 @@
"""Infrastructure sandbox package: OS-specific sandbox capability adapters."""
+21 -1
View File
@@ -1 +1,21 @@
"""Infrastructure telemetry package: CanonicalAuditLogger and token usage sinks.""" """Telemetry sinks: where token usage and turn metrics are recorded (EPIC R03)."""
from .usage_sink import (
NullUsageSink,
RecordingUsageSink,
UsageEvent,
UsageEventSink,
UsageTrackerSink,
default_sink,
set_default_sink,
)
__all__ = [
"UsageEvent",
"UsageEventSink",
"UsageTrackerSink",
"NullUsageSink",
"RecordingUsageSink",
"default_sink",
"set_default_sink",
]
+161 -220
View File
@@ -1,41 +1,51 @@
"""Token-usage telemetry as a publish/subscribe seam (R03-T06). """UsageEventSink - where a turn's token usage goes (R03-T06).
Before this module every provider adapter reached straight into Today each provider records its own usage inline, in the middle of the streaming
``core/usage_tracker.py`` and wrote a dashboard row itself, which meant the loop::
provider layer owned a telemetry policy decision ("where do usage numbers go?")
and no test could observe a turn's token accounting without touching the real
``~/.cowork_local/usage/`` files.
Now a provider only *describes what happened* — it publishes an immutable # providers/openai_compat.py
:class:`UsageEvent` — and subscribers decide what to do with it. The default def _record_usage(self, messages, text_parts, tool_acc, usage_seen):
subscriber, :class:`UsageTrackerSink`, forwards to the existing usage tracker so from ..core import usage_tracker as ut
the Dashboard keeps working byte-for-byte; tests swap in ...
:class:`InMemoryUsageSink` and assert on the events directly. ut.record(self.name, self.model, ...)
Every publish path is failure-tolerant on purpose: telemetry must never be the Three problems with that shape:
reason a chat turn dies, which is the same contract
``usage_tracker.record()`` already documents. 1. **Hidden side effect.** ``chat()`` looks like a pure request/response call but
also writes to the Dashboard's store, so a test of a provider silently
appends rows to the developer's real usage history.
2. **Duplicated estimation.** The "no usage block from the server, so estimate
at ~4 chars/token" fallback is copy-pasted per provider and can drift.
3. **One hard-wired destination.** Usage can only ever go to
``core.usage_tracker``; a run that wants to bill a workflow, or a test that
wants to assert on token counts, has nowhere to plug in.
This module introduces the seam: providers build a :class:`UsageEvent` and hand
it to a :class:`UsageEventSink`. Production wires :class:`UsageTrackerSink`
(same destination, same numbers as before); tests wire
:class:`RecordingUsageSink` or :class:`NullUsageSink`.
""" """
from __future__ import annotations from __future__ import annotations
import logging import logging
import threading from dataclasses import dataclass
from dataclasses import dataclass, field from typing import Any, Dict, List, Optional, Protocol, Sequence
from typing import Any, Dict, List, Optional, Protocol, runtime_checkable
logger = logging.getLogger("cowork_local.telemetry.usage") logger = logging.getLogger("cowork_local.telemetry")
# Rough characters-per-token ratio used when the gateway sends no usage block.
# Matches the constant behaviour of ``core.usage_tracker.estimate_tokens`` so
# moving the estimation here does not change a single recorded number.
_CHARS_PER_TOKEN = 4
@dataclass(frozen=True) @dataclass(frozen=True)
class UsageEvent: class UsageEvent:
"""One provider turn's token accounting. """Token usage for exactly one provider round trip.
Frozen so a subscriber cannot mutate an event the next subscriber in the ``estimated`` marks a record derived from text length rather than reported by
chain is about to receive. ``source``/``label`` stay optional: the usage the server. The Dashboard shows the two differently, and conflating them
tracker already derives them from thread-local context set by whoever ran would make cost figures look more precise than they are.
the turn, and a provider adapter has no business knowing which UI surface
invoked it.
""" """
provider: str provider: str
@@ -43,246 +53,177 @@ class UsageEvent:
input_tokens: int = 0 input_tokens: int = 0
output_tokens: int = 0 output_tokens: int = 0
cached_tokens: int = 0 cached_tokens: int = 0
# True when the counts are a ~4-chars-per-token approximation because the
# gateway never sent a usage block. Surfaced in the Dashboard so users know
# which rows are measured and which are guessed.
estimated: bool = False estimated: bool = False
source: Optional[str] = None # None -> tracker's thread-local context
label: Optional[str] = None # None -> tracker's thread-local context
extras: Dict[str, Any] = field(default_factory=dict)
@property @property
def total_tokens(self) -> int: def total_tokens(self) -> int:
"""Billable token count for this turn (cached tokens are already part """Input + output. Cached tokens are a subset of input, not an addition,
of the input count reported by every gateway we support, so adding them so adding them here would double-count a cache hit."""
again would double-count).""" return self.input_tokens + self.output_tokens
return int(self.input_tokens) + int(self.output_tokens)
def to_dict(self) -> Dict[str, Any]: def to_dict(self) -> Dict[str, Any]:
"""JSON-friendly view, using the same short keys as the usage tracker's """JSON-safe projection for logs and for sinks that persist raw events."""
on-disk rows so a caller can diff an event against a stored row."""
return { return {
"provider": self.provider, "provider": self.provider,
"model": self.model, "model": self.model,
"in": int(self.input_tokens), "input_tokens": self.input_tokens,
"out": int(self.output_tokens), "output_tokens": self.output_tokens,
"cache": int(self.cached_tokens), "cached_tokens": self.cached_tokens,
"estimated": bool(self.estimated), "estimated": self.estimated,
"source": self.source or "",
"label": self.label or "",
} }
@runtime_checkable
class UsageEventSink(Protocol): class UsageEventSink(Protocol):
"""Anything that can receive :class:`UsageEvent`s. """Anything that can absorb a :class:`UsageEvent`.
A ``Protocol`` rather than a base class so a plain object (or a test double, Implementations MUST NOT raise: telemetry is observability, and a failure to
or a Qt-side adapter that re-emits a signal) qualifies without inheriting record usage must never abort the turn that produced it.
from infrastructure code.
""" """
def emit(self, event: UsageEvent) -> None: def record(self, event: UsageEvent) -> None:
"""Handle one usage event. Implementations MUST NOT raise.""" """Absorb one usage event."""
class UsageTrackerSink: class NullUsageSink:
"""Default subscriber: writes each event through ``core/usage_tracker.py``. """Discards everything. The default for tests and headless tooling, so a
unit test never writes into the developer's real usage history."""
Keeps the existing Dashboard/telemetry pipeline (daily JSONL files, shared def record(self, event: UsageEvent) -> None: # noqa: D102 - see protocol
cross-machine mirror, per-thread accumulator) as the single writer, so return None
routing this through an event seam changed the plumbing without changing
a single stored byte.
"""
def __init__(self, recorder=None) -> None:
# The recorder is injectable so a test can verify the forwarding
# contract without importing the real tracker (and its config paths).
self._recorder = recorder
def _resolve_recorder(self):
"""Late-bind ``usage_tracker.record``.
Imported on first use rather than at module import so telemetry stays
out of the import graph of anything that merely *declares* a sink.
"""
if self._recorder is None:
from ...core import usage_tracker as tracker
self._recorder = tracker.record
return self._recorder
def emit(self, event: UsageEvent) -> None:
"""Forward one event; swallow every failure (telemetry is never fatal)."""
try:
record = self._resolve_recorder()
if event.source is None:
# Normal path: the worker thread already tagged its own
# source/label via set_context(), so record() attributes the row.
record(
event.provider, event.model,
int(event.input_tokens), int(event.output_tokens),
int(event.cached_tokens), estimated=bool(event.estimated),
)
return
# Event carries its own attribution: apply it for this single write
# and restore the thread's previous context afterwards, so a
# re-attributed event cannot silently relabel every later turn that
# runs on the same worker thread.
from ...core import usage_tracker as tracker
previous_source, previous_label = tracker.current_context()
tracker.set_context(event.source, event.label or "")
try:
record(
event.provider, event.model,
int(event.input_tokens), int(event.output_tokens),
int(event.cached_tokens), estimated=bool(event.estimated),
)
finally:
tracker.set_context(previous_source, previous_label)
except Exception: # noqa: BLE001 — usage tracking must never break a turn
logger.debug("usage sink: forwarding to usage_tracker failed", exc_info=True)
class InMemoryUsageSink: class RecordingUsageSink:
"""Collects events in a list — the test double for usage assertions.""" """Keeps events in memory so a test can assert on what was recorded."""
def __init__(self) -> None: def __init__(self) -> None:
self.events: List[UsageEvent] = [] self.events: List[UsageEvent] = []
self._lock = threading.Lock()
def emit(self, event: UsageEvent) -> None: def record(self, event: UsageEvent) -> None: # noqa: D102 - see protocol
"""Append under a lock: parallel Co4E flows publish from several worker self.events.append(event)
threads at once and ``list.append`` alone would still be atomic, but the
lock also makes :meth:`snapshot` a consistent read."""
with self._lock:
self.events.append(event)
def snapshot(self) -> List[UsageEvent]:
"""A copy of everything received so far."""
with self._lock:
return list(self.events)
def clear(self) -> None:
with self._lock:
self.events.clear()
@property @property
def total_tokens(self) -> int: def total_tokens(self) -> int:
return sum(e.total_tokens for e in self.snapshot()) """Sum across every recorded event."""
return sum(e.total_tokens for e in self.events)
class CompositeUsageSink: class UsageTrackerSink:
"""Fans one event out to several subscribers. """Forwards to ``core.usage_tracker`` - the Dashboard's store.
This is what makes the seam useful beyond the Dashboard: a future consumer This is the production sink and the only place that still knows about the
(per-workspace budget guard, live cost meter) subscribes alongside the legacy tracker module, which is what lets EPIC R10 replace the storage
tracker instead of patching provider code again. One failing subscriber is without touching a single provider.
logged and skipped so it cannot starve the others.
""" """
def __init__(self, sinks=None) -> None: def __init__(self, tracker: Optional[Any] = None) -> None:
self._sinks: List[UsageEventSink] = list(sinks or ()) # Injectable for tests; imported lazily otherwise because the tracker
self._lock = threading.RLock() # touches the config directory at import time.
self._tracker = tracker
def add(self, sink: UsageEventSink) -> None: def _resolve(self) -> Any:
with self._lock: if self._tracker is None:
self._sinks.append(sink) from cowork_local.core import usage_tracker
def remove(self, sink: UsageEventSink) -> None: self._tracker = usage_tracker
"""Detach a subscriber; a sink that was never added is ignored so return self._tracker
teardown code can call this unconditionally."""
with self._lock:
if sink in self._sinks:
self._sinks.remove(sink)
def sinks(self) -> List[UsageEventSink]: def record(self, event: UsageEvent) -> None:
with self._lock: """Write the event to the usage tracker, swallowing any failure.
return list(self._sinks)
def emit(self, event: UsageEvent) -> None: The bare except mirrors the behaviour this replaces (each provider
for sink in self.sinks(): already wrapped its ``ut.record`` call in ``try/except: pass``) but logs
try: at debug level instead of discarding the reason entirely, so a broken
sink.emit(event) Dashboard store can at least be diagnosed.
except Exception: # noqa: BLE001 — one bad subscriber must not stop the rest """
logger.debug("usage sink: subscriber %r failed", sink, exc_info=True) try:
self._resolve().record(
event.provider, event.model,
# --------------------------------------------------------------------------- # event.input_tokens, event.output_tokens, event.cached_tokens,
# Process-wide sink. estimated=event.estimated,
# )
# Providers publish through the module-level helpers below rather than holding a except Exception: # noqa: BLE001 - telemetry must never break a turn
# sink reference, because a provider instance is created fresh for every turn logger.debug("usage sink: failed to record %s", event.to_dict(), exc_info=True)
# (see AppContext.build_provider_for) and would otherwise have to be handed the
# telemetry wiring on every construction.
# --------------------------------------------------------------------------- #
_sink_lock = threading.RLock()
_sink: Optional[CompositeUsageSink] = None
def get_usage_sink() -> CompositeUsageSink:
"""The shared sink, seeded with :class:`UsageTrackerSink` on first use."""
global _sink
if _sink is None:
with _sink_lock:
if _sink is None:
_sink = CompositeUsageSink([UsageTrackerSink()])
return _sink
def set_usage_sink(sink: Optional[CompositeUsageSink]) -> None:
"""Replace the shared sink (``None`` restores the default on next use).
Used by tests and by the app shell when it wants a different fan-out; kept
explicit so nothing silently reconfigures telemetry mid-run.
"""
global _sink
with _sink_lock:
_sink = sink
def subscribe(sink: UsageEventSink) -> UsageEventSink:
"""Attach an extra subscriber to the shared sink and return it (so callers
can keep the handle for a later :func:`unsubscribe`)."""
get_usage_sink().add(sink)
return sink
def unsubscribe(sink: UsageEventSink) -> None:
"""Detach a subscriber previously passed to :func:`subscribe`."""
get_usage_sink().remove(sink)
def publish(event: UsageEvent) -> None:
"""Publish one usage event to every subscriber.
Never raises: called from inside a provider's streaming loop, where an
exception would abort an otherwise successful turn.
"""
try:
get_usage_sink().emit(event)
except Exception: # noqa: BLE001
logger.debug("usage sink: publish failed", exc_info=True)
def estimate_tokens(text: str) -> int: def estimate_tokens(text: str) -> int:
"""~4 chars per token approximation, re-exported so provider adapters need """Approximate token count for ``text`` (~4 characters per token).
exactly ONE telemetry import instead of also importing the tracker."""
return max(0, len(text or "") // 4) Deliberately identical to ``core.usage_tracker.estimate_tokens`` so that
moving estimation into this layer changes no recorded number. Duplicated
rather than imported to keep this module free of the legacy dependency;
:class:`UsageTrackerSink` is the only bridge back to it.
"""
return max(0, len(text or "") // _CHARS_PER_TOKEN)
def estimated_event(provider: str, model: str, sent: str, received: str) -> UsageEvent:
"""Build an estimated :class:`UsageEvent` from the raw text of a round trip.
Used when the gateway sends no usage block - most self-hosted OpenAI-compatible
servers and Ollama do not.
"""
return UsageEvent(
provider=provider, model=model,
input_tokens=estimate_tokens(sent),
output_tokens=estimate_tokens(received),
cached_tokens=0,
estimated=True,
)
def openai_usage_event(provider: str, model: str, usage: Dict[str, Any]) -> UsageEvent:
"""Build a reported :class:`UsageEvent` from an OpenAI-style usage block."""
details = usage.get("prompt_tokens_details") or {}
return UsageEvent(
provider=provider, model=model,
input_tokens=int(usage.get("prompt_tokens", 0) or 0),
output_tokens=int(usage.get("completion_tokens", 0) or 0),
cached_tokens=int(details.get("cached_tokens", 0) or 0),
estimated=False,
)
def anthropic_usage_event(provider: str, model: str, usage: Dict[str, Any]) -> UsageEvent:
"""Build a reported :class:`UsageEvent` from Anthropic's usage accumulator.
Anthropic reports input tokens on ``message_start`` and output tokens on
``message_delta``, so ``providers/anthropic.py`` accumulates them into a dict
keyed ``in``/``out``/``cache`` - this reads that shape.
"""
return UsageEvent(
provider=provider, model=model,
input_tokens=int(usage.get("in", 0) or 0),
output_tokens=int(usage.get("out", 0) or 0),
cached_tokens=int(usage.get("cache", 0) or 0),
estimated=False,
)
# The sink providers use unless one is injected. A module-level default keeps
# the change to the provider classes to a single attribute, and lets a test swap
# the destination process-wide with one monkeypatch.
default_sink: UsageEventSink = UsageTrackerSink()
def set_default_sink(sink: UsageEventSink) -> UsageEventSink:
"""Replace the process-wide default sink; returns the previous one so a
caller (or fixture) can restore it."""
global default_sink
previous = default_sink
default_sink = sink
return previous
__all__ = [ __all__ = [
"UsageEvent", "UsageEvent",
"UsageEventSink", "UsageEventSink",
"UsageTrackerSink", "UsageTrackerSink",
"InMemoryUsageSink", "NullUsageSink",
"CompositeUsageSink", "RecordingUsageSink",
"get_usage_sink",
"set_usage_sink",
"subscribe",
"unsubscribe",
"publish",
"estimate_tokens", "estimate_tokens",
"estimated_event",
"openai_usage_event",
"anthropic_usage_event",
"default_sink",
"set_default_sink",
] ]
-5
View File
@@ -1,5 +0,0 @@
"""Provider-neutral Project Context MCP server template."""
from .server import build_server, dispatch
__all__ = ["build_server", "dispatch"]
-106
View File
@@ -1,106 +0,0 @@
"""Shared, stable boundary used by all Project Context tool work packages."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Protocol
from pydantic import AnyUrl, BaseModel, ConfigDict, Field
class ContractModel(BaseModel):
"""Strict immutable model so provider-specific fields cannot leak to the Agent."""
model_config = ConfigDict(extra="forbid", frozen=True)
class IdentityContext(ContractModel):
actor_id: str = Field(min_length=1, max_length=256)
org_unit: str = Field(min_length=1, max_length=128)
customer: str = Field(min_length=1, max_length=128)
project: str = Field(min_length=1, max_length=128)
granted_scopes: frozenset[str]
class SourceCitation(ContractModel):
system: str = Field(min_length=1, max_length=64)
url: AnyUrl
revision: str = Field(min_length=1, max_length=256)
retrieved_at: datetime
@dataclass(frozen=True)
class DispatchResult:
ok: bool
payload: dict[str, Any]
class PolicyDecisionPoint(Protocol):
def decide(self, identity: IdentityContext, tool_name: str, project_id: str) -> bool: ...
class CredentialResolver(Protocol):
def resolve(self, identity: IdentityContext, tool_name: str) -> Any: ...
@dataclass(frozen=True)
class ProjectContextRuntime:
identity: IdentityContext
policy: PolicyDecisionPoint
credential_resolver: CredentialResolver
class ProviderError(RuntimeError):
"""A provider failure with a caller-safe message and retry classification."""
def __init__(self, code: str, message: str, *, retryable: bool) -> None:
super().__init__(message)
self.code = code
self.safe_message = message
self.retryable = retryable
ToolHandler = Callable[[ContractModel, Any], dict[str, Any]]
@dataclass(frozen=True)
class ToolTemplate:
name: str
description: str
input_model: type[ContractModel]
output_model: type[ContractModel]
handler: ToolHandler
def declaration(self) -> dict[str, Any]:
return {
"name": self.name,
"description": self.description,
"inputSchema": self.input_model.model_json_schema(),
"outputSchema": self.output_model.model_json_schema(),
}
def error_result(
code: str,
*,
category: str,
retryable: bool,
message: str,
suggested_action: str,
correlation_id: str,
) -> DispatchResult:
return DispatchResult(
ok=False,
payload={
"error": {
"code": code,
"category": category,
"retryable": retryable,
"message": message,
"suggested_action": suggested_action,
"correlation_id": correlation_id,
}
},
)
@@ -1 +0,0 @@
"""One provider module per member-owned tool work package."""
@@ -1,25 +0,0 @@
"""Provider boundary owned with get_project_change_context."""
from __future__ import annotations
from typing import Any, Protocol
from ..foundation import IdentityContext, ProviderError
class ChangeProvider(Protocol):
def get_change_context(self, **arguments: Any) -> dict[str, Any]: ...
class UnconfiguredChangeProvider:
def get_change_context(self, **arguments: Any) -> dict[str, Any]:
raise ProviderError(
"UNAVAILABLE",
"The change provider is not configured for this environment.",
retryable=False,
)
def build_provider(identity: IdentityContext) -> ChangeProvider:
"""Replace only this factory when wiring the approved read-only Git adapter."""
return UnconfiguredChangeProvider()
@@ -1,25 +0,0 @@
"""Provider boundary owned with get_project_issue_context."""
from __future__ import annotations
from typing import Any, Protocol
from ..foundation import IdentityContext, ProviderError
class IssueProvider(Protocol):
def get_issue_context(self, **arguments: Any) -> dict[str, Any]: ...
class UnconfiguredIssueProvider:
def get_issue_context(self, **arguments: Any) -> dict[str, Any]:
raise ProviderError(
"UNAVAILABLE",
"The issue provider is not configured for this environment.",
retryable=False,
)
def build_provider(identity: IdentityContext) -> IssueProvider:
"""Replace only this factory when wiring the approved read-only issue adapter."""
return UnconfiguredIssueProvider()
@@ -1,25 +0,0 @@
"""Provider boundary owned with search_project_knowledge."""
from __future__ import annotations
from typing import Any, Protocol
from ..foundation import IdentityContext, ProviderError
class KnowledgeProvider(Protocol):
def search_knowledge(self, **arguments: Any) -> dict[str, Any]: ...
class UnconfiguredKnowledgeProvider:
def search_knowledge(self, **arguments: Any) -> dict[str, Any]:
raise ProviderError(
"UNAVAILABLE",
"The knowledge provider is not configured for this environment.",
retryable=False,
)
def build_provider(identity: IdentityContext) -> KnowledgeProvider:
"""Replace only this factory when wiring approved project retrieval."""
return UnconfiguredKnowledgeProvider()
-23
View File
@@ -1,23 +0,0 @@
"""Immutable registry composed before member work starts to prevent merge conflicts."""
from __future__ import annotations
from types import MappingProxyType
from typing import Any
from .foundation import ToolTemplate
from .tools.change_context import TOOL as CHANGE_CONTEXT_TOOL
from .tools.issue_context import TOOL as ISSUE_CONTEXT_TOOL
from .tools.knowledge_search import TOOL as KNOWLEDGE_SEARCH_TOOL
TOOLS: tuple[ToolTemplate, ...] = (
ISSUE_CONTEXT_TOOL,
KNOWLEDGE_SEARCH_TOOL,
CHANGE_CONTEXT_TOOL,
)
TOOLS_BY_NAME = MappingProxyType({tool.name: tool for tool in TOOLS})
TOOL_NAMES = tuple(tool.name for tool in TOOLS)
def tool_declarations() -> list[dict[str, Any]]:
return [tool.declaration() for tool in TOOLS]
-74
View File
@@ -1,74 +0,0 @@
"""Fail-closed identity, policy, and provider resolution for the template server."""
from __future__ import annotations
import os
import sys
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from .foundation import IdentityContext, ProjectContextRuntime, ProviderError
from .providers.change import build_provider as build_change_provider
from .providers.issue import build_provider as build_issue_provider
from .providers.knowledge import build_provider as build_knowledge_provider
MINIMUM_PYTHON = (3, 11)
def require_supported_python(version_info: tuple[int, ...] | None = None) -> None:
"""Fail with an actionable message before the MCP server starts."""
current = version_info or tuple(sys.version_info[:3])
if current[:2] < MINIMUM_PYTHON:
raise RuntimeError(
"Project Context MCP requires Python 3.11 or newer; "
f"current runtime is {current[0]}.{current[1]}"
)
@dataclass(frozen=True)
class ProjectScopePolicy:
"""Pilot policy: read scope and exact identity-bound project are both mandatory."""
def decide(self, identity: IdentityContext, tool_name: str, project_id: str) -> bool:
return "read" in identity.granted_scopes and project_id == identity.project
PROVIDER_FACTORIES: dict[str, Callable[[IdentityContext], Any]] = {
"get_project_issue_context": build_issue_provider,
"search_project_knowledge": build_knowledge_provider,
"get_project_change_context": build_change_provider,
}
@dataclass(frozen=True)
class ProjectProviderResolver:
def resolve(self, identity: IdentityContext, tool_name: str) -> Any:
factory = PROVIDER_FACTORIES.get(tool_name)
if factory is None:
raise ProviderError("NOT_FOUND", "The requested tool is not registered.", retryable=False)
return factory(identity)
def _required_environment(name: str) -> str:
value = os.environ.get(name, "").strip()
if not value:
raise RuntimeError(f"Project Context MCP cannot start: required setting {name} is missing")
return value
def default_runtime() -> ProjectContextRuntime:
"""Build immutable runtime state; missing identity configuration fails at boot."""
require_supported_python()
identity = IdentityContext(
actor_id=_required_environment("COWORK_MCP_ACTOR_ID"),
org_unit=_required_environment("COWORK_MCP_ORG_UNIT"),
customer=_required_environment("COWORK_MCP_CUSTOMER"),
project=_required_environment("COWORK_MCP_PROJECT"),
granted_scopes=frozenset({"read"}),
)
return ProjectContextRuntime(
identity=identity,
policy=ProjectScopePolicy(),
credential_resolver=ProjectProviderResolver(),
)
-142
View File
@@ -1,142 +0,0 @@
"""Low-level MCP stdio adapter around the transport-agnostic Project Context core."""
# ruff: noqa: UP045 -- Optional keeps the template importable with Pydantic on Python 3.9.
from __future__ import annotations
import json
from typing import Any, Optional
from uuid import uuid4
from pydantic import ValidationError
from .foundation import (
DispatchResult,
ProjectContextRuntime,
ProviderError,
error_result,
)
from .registry import TOOLS_BY_NAME, tool_declarations
from .runtime import default_runtime, require_supported_python
def dispatch(
name: str,
arguments: dict[str, Any],
runtime: ProjectContextRuntime,
) -> DispatchResult:
"""Validate → authorize → resolve provider → execute → validate output."""
correlation_id = str(uuid4())
tool = TOOLS_BY_NAME.get(name)
if tool is None:
return error_result(
"NOT_FOUND",
category="NOT_FOUND",
retryable=False,
message="The requested MCP tool is not registered.",
suggested_action="Refresh the tool list and choose one of the advertised tools.",
correlation_id=correlation_id,
)
try:
validated_input = tool.input_model.model_validate(arguments or {})
except ValidationError:
return error_result(
"INVALID_INPUT",
category="INVALID_INPUT",
retryable=False,
message="The tool arguments do not match the published input contract.",
suggested_action="Correct the required fields and value bounds, then call again.",
correlation_id=correlation_id,
)
project_id = str(validated_input.project_id)
if not runtime.policy.decide(runtime.identity, name, project_id):
return error_result(
"DENIED",
category="DENIED",
retryable=False,
message="The project is outside the caller's approved scope.",
suggested_action="Use an approved project or ask the project owner for access.",
correlation_id=correlation_id,
)
try:
provider = runtime.credential_resolver.resolve(runtime.identity, name)
raw_output = tool.handler(validated_input, provider)
except ProviderError as exc:
return error_result(
exc.code,
category=exc.code,
retryable=exc.retryable,
message=exc.safe_message,
suggested_action="Check the approved provider configuration and retry if allowed.",
correlation_id=correlation_id,
)
except Exception: # noqa: BLE001 - provider failures must not crash or leak into the agent turn
return error_result(
"UPSTREAM_ERROR",
category="UPSTREAM_ERROR",
retryable=False,
message="The approved provider could not complete the request.",
suggested_action="Check the correlation ID in server logs; do not resend credentials.",
correlation_id=correlation_id,
)
try:
output_with_trace = {**raw_output, "correlation_id": correlation_id}
validated_output = tool.output_model.model_validate(output_with_trace)
except ValidationError:
return error_result(
"UPSTREAM_ERROR",
category="UPSTREAM_ERROR",
retryable=False,
message="The provider response did not match the published output contract.",
suggested_action="Fix the provider mapping before retrying the request.",
correlation_id=correlation_id,
)
return DispatchResult(ok=True, payload=validated_output.model_dump(mode="json"))
def build_server(runtime: Optional[ProjectContextRuntime] = None):
from mcp import types
from mcp.server.lowlevel import Server
require_supported_python()
app_runtime = runtime or default_runtime()
app = Server("project_context")
@app.list_tools()
async def list_tools() -> list[types.Tool]:
return [types.Tool(**declaration) for declaration in tool_declarations()]
@app.call_tool()
async def call_tool(name: str, arguments: dict[str, Any]) -> types.CallToolResult:
result = dispatch(name, arguments or {}, app_runtime)
return types.CallToolResult(
content=[types.TextContent(
type="text",
text=json.dumps(result.payload, ensure_ascii=False, separators=(",", ":")),
)],
structuredContent=result.payload if result.ok else None,
isError=not result.ok,
)
return app
def main() -> None:
import anyio
from mcp.server.stdio import stdio_server
app = build_server()
async def _run() -> None:
async with stdio_server() as (read, write):
await app.run(read, write, app.create_initialization_options())
anyio.run(_run)
if __name__ == "__main__":
main()
@@ -1 +0,0 @@
"""Independent tool modules; ownership is documented in the team guide."""
@@ -1,55 +0,0 @@
"""Member C work package: get_project_change_context."""
# ruff: noqa: UP045 -- Optional keeps Pydantic model evaluation compatible with Python 3.9.
from __future__ import annotations
from typing import Any, Literal, Optional
from pydantic import Field
from ..foundation import ContractModel, SourceCitation, ToolTemplate
class ChangeContextInput(ContractModel):
project_id: str = Field(min_length=1, max_length=128)
change_id: str = Field(min_length=1, max_length=128)
detail: Literal["summary", "standard", "full"] = "standard"
cursor: Optional[str] = Field(default=None, max_length=2048)
class ChangeContextOutput(ContractModel):
correlation_id: str
project_id: str
change_id: str
change_type: Literal["commit", "pull-request", "merge-request"]
title: str
state: str
summary: str
authors: tuple[str, ...]
files: tuple[str, ...]
commits: tuple[str, ...]
related_issues: tuple[str, ...]
source: SourceCitation
truncated: bool
returned: int = Field(ge=0)
remaining: int = Field(ge=0)
next_cursor: Optional[str] = None
def _handle(arguments: ContractModel, provider: Any) -> dict[str, Any]:
request = ChangeContextInput.model_validate(arguments)
return provider.get_change_context(**request.model_dump())
TOOL = ToolTemplate(
name="get_project_change_context",
description=(
"Returns provider-neutral context for one authorized commit, pull request, or merge request "
"with changed files, commits, related issues, and a pinned source. Use when an exact change "
"identifier is known. Do not use for issue details or free-text document search."
),
input_model=ChangeContextInput,
output_model=ChangeContextOutput,
handler=_handle,
)
@@ -1,59 +0,0 @@
"""Member A work package: get_project_issue_context."""
# ruff: noqa: UP045 -- Optional keeps Pydantic model evaluation compatible with Python 3.9.
from __future__ import annotations
from typing import Any, Literal, Optional
from pydantic import Field
from ..foundation import ContractModel, SourceCitation, ToolTemplate
class IssueContextInput(ContractModel):
project_id: str = Field(min_length=1, max_length=128)
issue_key: str = Field(min_length=1, max_length=128)
detail: Literal["summary", "standard", "full"] = "standard"
cursor: Optional[str] = Field(default=None, max_length=2048)
class RelatedItem(ContractModel):
item_id: str
relation: str
title: str
url: str
class IssueContextOutput(ContractModel):
correlation_id: str
project_id: str
issue_key: str
title: str
status: str
description: str
acceptance_criteria: tuple[str, ...]
related: tuple[RelatedItem, ...]
source: SourceCitation
truncated: bool
returned: int = Field(ge=0)
remaining: int = Field(ge=0)
next_cursor: Optional[str] = None
def _handle(arguments: ContractModel, provider: Any) -> dict[str, Any]:
request = IssueContextInput.model_validate(arguments)
return provider.get_issue_context(**request.model_dump())
TOOL = ToolTemplate(
name="get_project_issue_context",
description=(
"Returns one authorized work item's title, state, description, acceptance criteria, "
"related items, and pinned source. Use when an exact issue key is known. Do not use for "
"free-text knowledge search or Git change review."
),
input_model=IssueContextInput,
output_model=IssueContextOutput,
handler=_handle,
)
@@ -1,58 +0,0 @@
"""Member B work package: search_project_knowledge."""
# ruff: noqa: UP045 -- Optional keeps Pydantic model evaluation compatible with Python 3.9.
from __future__ import annotations
from typing import Any, Literal, Optional
from pydantic import Field
from ..foundation import ContractModel, SourceCitation, ToolTemplate
class KnowledgeSearchInput(ContractModel):
project_id: str = Field(min_length=1, max_length=128)
query: str = Field(min_length=2, max_length=1000)
detail: Literal["summary", "standard", "full"] = "standard"
top_k: int = Field(default=5, ge=1, le=20)
language: Optional[Literal["en", "ja", "vi"]] = None
cursor: Optional[str] = Field(default=None, max_length=2048)
class KnowledgeItem(ContractModel):
document_id: str
chunk_id: str
title: str
excerpt: str
score: float = Field(ge=0, le=1)
source: SourceCitation
class KnowledgeSearchOutput(ContractModel):
correlation_id: str
project_id: str
query: str
items: tuple[KnowledgeItem, ...]
truncated: bool
returned: int = Field(ge=0)
remaining: int = Field(ge=0)
next_cursor: Optional[str] = None
def _handle(arguments: ContractModel, provider: Any) -> dict[str, Any]:
request = KnowledgeSearchInput.model_validate(arguments)
return provider.search_knowledge(**request.model_dump())
TOOL = ToolTemplate(
name="search_project_knowledge",
description=(
"Searches approved knowledge for one authorized project and returns ranked excerpts with "
"pinned citations. Use for requirements, design notes, or runbooks when no exact issue is "
"known. Do not use for issue details or Git change review."
),
input_model=KnowledgeSearchInput,
output_model=KnowledgeSearchOutput,
handler=_handle,
)
-9
View File
@@ -1,9 +0,0 @@
"""Stable module entry point for ``python -m cowork_local.mcp_servers.project_context_server``."""
from .project_context.server import build_server, dispatch, main
__all__ = ["build_server", "dispatch", "main"]
if __name__ == "__main__":
main()
-1
View File
@@ -1 +0,0 @@
"""Presentation Layer: PySide6 UI widgets, dialogs, and shell views (<400 LOC per file)."""
-1
View File
@@ -1 +0,0 @@
"""Presentation chat package: ChatHistoryWidget, ComposerWidget, AttachmentPicker, AudioRecorderWidget, ChatOutputPanel."""
-1
View File
@@ -1 +0,0 @@
"""Presentation Co4E package: Co4ECanvasWidget, NodePropertyPanel, RunControlWidget, Co4EChatView."""
-1
View File
@@ -1 +0,0 @@
"""Presentation dashboard package: TokenUsageCardWidget, UsageChartWidget, HabitsWidget."""
-1
View File
@@ -1 +0,0 @@
"""Presentation folder package: WorkspaceFileTree, DocumentPreviewManager, AiFileEditorDialog."""
-1
View File
@@ -1 +0,0 @@
"""Presentation graph package: StructureGraphView and GraphQaWidget."""
-1
View File
@@ -1 +0,0 @@
"""Presentation monitoring package: 8 modular sub-tab widgets."""
-1
View File
@@ -1 +0,0 @@
"""Presentation scheduling package: KanbanBoardWidget, CalendarViewWidget, AiTaskCreatorDialog."""
-1
View File
@@ -1 +0,0 @@
"""Presentation settings package: Section widgets for provider, connector, routing, and general settings."""
-1
View File
@@ -1 +0,0 @@
"""Presentation shell package: MainWindow shell, TrayManager, LifecycleCoordinator."""
View File
+12 -26
View File
@@ -292,33 +292,19 @@ class AnthropicProvider(Provider):
args = {"_raw": b["json"]} args = {"_raw": b["json"]}
tool_calls.append({"id": b["id"], "name": b["name"], "arguments": args}) tool_calls.append({"id": b["id"], "name": b["name"], "arguments": args})
# Usage event — real counts from the stream's usage events, else a # Dashboard usage event — real counts from the stream's usage events
# ~4 chars/token estimate. Published to the telemetry sink (R03-T06) # (input arrives on message_start, output on message_delta), else a
# rather than written straight to the Dashboard store, so the provider # ~4 chars/token estimate. Delivery is the sink's job (R03-T06), so this
# stays a pure transport adapter. Never breaks the turn. # only translates Anthropic's wire shape into a canonical UsageEvent.
try: from ..infrastructure.telemetry import usage_sink as telemetry
from ..infrastructure.telemetry import usage_sink
if usage_seen: if usage_seen:
usage_sink.publish(usage_sink.UsageEvent( event = telemetry.anthropic_usage_event(self.name, self.model, usage_seen)
provider=self.name, else:
model=self.model, sent = json.dumps(payload.get("messages", []), ensure_ascii=False)
input_tokens=usage_seen.get("in", 0), got = "".join(text_parts) + "".join(b["json"] for b in blocks.values())
output_tokens=usage_seen.get("out", 0), event = telemetry.estimated_event(self.name, self.model, sent, got)
cached_tokens=usage_seen.get("cache", 0), self._emit_usage(event)
))
else:
sent = json.dumps(payload.get("messages", []), ensure_ascii=False)
got = "".join(text_parts) + "".join(b["json"] for b in blocks.values())
usage_sink.publish(usage_sink.UsageEvent(
provider=self.name,
model=self.model,
input_tokens=usage_sink.estimate_tokens(sent),
output_tokens=usage_sink.estimate_tokens(got),
estimated=True,
))
except Exception: # noqa: BLE001
pass
return {"role": "assistant", "content": "".join(text_parts), "tool_calls": tool_calls} return {"role": "assistant", "content": "".join(text_parts), "tool_calls": tool_calls}
+24
View File
@@ -224,6 +224,12 @@ class Provider:
# silently swallowing the error — Settings' "Test connection" / "Load # silently swallowing the error — Settings' "Test connection" / "Load
# models" surfaces this so "model won't load" has a concrete reason. # models" surfaces this so "model won't load" has a concrete reason.
self.last_error = "" self.last_error = ""
# Where this provider's token usage goes (R03-T06). None means "the
# process-wide default sink", resolved lazily in _emit_usage so that a
# test can swap the destination without rebuilding every provider.
# Set it per instance to bill one run somewhere else (a workflow, a
# scheduled task) without touching global state.
self.usage_sink = None
def chat( def chat(
self, self,
@@ -274,6 +280,24 @@ class Provider:
return True, f"OK — {len(models)} model(s) available." return True, f"OK — {len(models)} model(s) available."
return False, "No models returned. Check base_url/API key and network access." return False, "No models returned. Check base_url/API key and network access."
# -- telemetry -----------------------------------------------------
def _emit_usage(self, event) -> None:
"""Hand one ``UsageEvent`` to this provider's usage sink.
Never raises: recording how many tokens a turn cost must not be able to
fail the turn itself. Falls back to the process-wide default sink so
existing call sites keep reporting to the Dashboard exactly as before
(see infrastructure/telemetry/usage_sink.py)."""
try:
sink = self.usage_sink
if sink is None:
from ..infrastructure.telemetry import usage_sink as telemetry
sink = telemetry.default_sink
sink.record(event)
except Exception: # noqa: BLE001 — telemetry is never worth a failed turn
pass
# -- shared helpers ------------------------------------------------ # -- shared helpers ------------------------------------------------
@staticmethod @staticmethod
def _is_cancelled(cancel) -> bool: def _is_cancelled(cancel) -> bool:
+17 -24
View File
@@ -1,32 +1,25 @@
"""Build a provider instance from the application config. """Build a provider instance from the application config."""
Kept as the historic entry point (``providers.build_provider``) that call sites
across the app already import, but it no longer owns a provider table of its
own: since R03-T02 the catalogue lives in
``infrastructure/providers/provider_registry.py`` so provider ids, wire
protocols, default models and capabilities are declared exactly once.
"""
from __future__ import annotations from __future__ import annotations
from typing import Any, Dict from typing import Any, Dict
from .anthropic import AnthropicProvider
from .base import Provider, ProviderError from .base import Provider, ProviderError
from .openai_compat import OpenAICompatProvider
_REGISTRY = {
"openai_compat": OpenAICompatProvider,
"anthropic": AnthropicProvider,
# All OpenAI-compatible endpoints (Ollama's /v1 server, the Copilot chat API,
# and OpenAI itself) speak the same Chat Completions protocol.
"ollama": OpenAICompatProvider,
"github_copilot": OpenAICompatProvider,
"codex": OpenAICompatProvider,
}
def build_provider(name: str, conf: Dict[str, Any]) -> Provider: def build_provider(name: str, conf: Dict[str, Any]) -> Provider:
"""Construct the adapter registered for ``name``. cls = _REGISTRY.get(name)
if cls is None:
Delegates to the central registry and translates its lookup failure into raise ProviderError(f"Unsupported provider: {name}")
:class:`ProviderError`, because every existing call site (chat turns, return cls(conf)
Settings' connection test, the routing prober) already handles that type —
changing the exception would ripple into unrelated error handling.
"""
from ..infrastructure.providers.provider_registry import (
ProviderNotFoundError,
default_registry,
)
try:
return default_registry().build(name, conf)
except ProviderNotFoundError as exc:
raise ProviderError(f"Unsupported provider: {name}") from exc
+19 -32
View File
@@ -266,40 +266,27 @@ class OpenAICompatProvider(Provider):
return _assemble_assistant(text_parts, tool_acc) return _assemble_assistant(text_parts, tool_acc)
def _record_usage(self, messages, text_parts, tool_acc, usage_seen) -> None: def _record_usage(self, messages, text_parts, tool_acc, usage_seen) -> None:
"""Publish one usage event per turn: real counts when the server's final """One Dashboard usage event per turn: real counts when the server's
chunk carried a "usage" block, a ~4 chars/token estimate otherwise. final chunk carried a "usage" block, a ~4 chars/token estimate
otherwise.
Since R03-T06 this only *describes* what the turn consumed and hands the Building the event and delivering it are now separate concerns (R03-T06):
event to ``infrastructure/telemetry/usage_sink.py``; deciding where the this method only translates THIS provider's wire shape into a canonical
numbers land (Dashboard files, cost meters, tests) belongs to the ``UsageEvent``; where it ends up is the sink's decision, so a test can
subscribers, not to a provider adapter. Never breaks the turn. assert on token counts without writing to the real Dashboard store."""
""" from ..infrastructure.telemetry import usage_sink as telemetry
try:
from ..infrastructure.telemetry import usage_sink
if usage_seen: if usage_seen:
usage_sink.publish(usage_sink.UsageEvent( event = telemetry.openai_usage_event(self.name, self.model, usage_seen)
provider=self.name, else:
model=self.model, # No usage block from the gateway (self-hosted servers and Ollama
input_tokens=usage_seen.get("prompt_tokens", 0), # never send one) - fall back to estimating from the raw text of
output_tokens=usage_seen.get("completion_tokens", 0), # both directions, tool-call arguments included since the model was
cached_tokens=(usage_seen.get("prompt_tokens_details") or {}).get("cached_tokens", 0), # billed for generating them.
)) sent = json.dumps(self._to_api_messages(messages), ensure_ascii=False)
else: got = "".join(text_parts) + "".join(s["args"] for s in tool_acc.values())
# No usage block from the gateway — approximate from the exact event = telemetry.estimated_event(self.name, self.model, sent, got)
# bytes we sent and received so the Dashboard still shows a self._emit_usage(event)
# (clearly flagged) figure instead of a silent zero.
sent = json.dumps(self._to_api_messages(messages), ensure_ascii=False)
got = "".join(text_parts) + "".join(s["args"] for s in tool_acc.values())
usage_sink.publish(usage_sink.UsageEvent(
provider=self.name,
model=self.model,
input_tokens=usage_sink.estimate_tokens(sent),
output_tokens=usage_sink.estimate_tokens(got),
estimated=True,
))
except Exception: # noqa: BLE001
pass
def list_models(self): def list_models(self):
self.last_error = "" self.last_error = ""
+9
View File
@@ -0,0 +1,9 @@
PySide6>=6.6
pydantic>=2
requests
psutil
pygments
openpyxl
python-pptx
networkx
pytest
+208 -135
View File
@@ -1,164 +1,237 @@
"""AST-based Static Analysis Guard for Clean Architecture Enforcement. #!/usr/bin/env python3
"""CASAN Check 3 — Clean Architecture Guard (R01-T03).
Scans designated Python packages (such as `domain/` and `application/`) to ensure Statically walks the AST of every Python file in the pure-Python layers and
they remain 100% Pure Python and do not import presentation/GUI frameworks (PySide6, PyQt) fails when a file imports something the layer is not allowed to depend on.
or concrete application shells.
Why AST instead of ``grep``: a regex over source text cannot tell an import
apart from the same words appearing inside a docstring, a comment or a string
literal (this repo has several docstrings that legitimately mention
``PySide6``). ``ast`` sees only real ``import`` / ``from … import`` nodes, so
the check has no false positives and needs no ``# noqa`` escape hatches.
Rules enforced (see docs/architecture/ADR-001-layered-architecture.md):
* **I1** ``domain/`` and ``application/`` must be 100% pure Python — no Qt.
* **I2** ``domain/`` must not import ``application/``, ``infrastructure/``,
``presentation/`` or the legacy ``ui/``.
* **I3** ``application/`` must not import ``presentation/`` or ``ui/``.
Usage::
python scripts/check_imports.py # scan the whole repo
python scripts/check_imports.py domain # scan one layer only
Exit code is 0 when clean and 1 when at least one violation is found, so it
can be wired straight into CI / ``scripts/run_quality_gate.py`` (R10-T02).
""" """
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import ast import ast
import io
import sys import sys
from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import List, NamedTuple, Set from typing import Dict, Iterable, List, Sequence, Tuple
# Ensure UTF-8 output on standard console streams across diverse Windows locales (CP932, etc.) # Repository root = parent of this scripts/ folder. Everything below is resolved
if sys.stdout.encoding and sys.stdout.encoding.lower() not in ("utf-8", "utf8"): # relative to it so the checker works no matter what the checkout folder is
try: # named or which directory the developer runs it from.
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") REPO_ROOT = Path(__file__).resolve().parents[1]
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
except Exception:
pass
# The distribution package name. Absolute imports may be written either as
# ``from cowork_local.ui import x`` or ``from ui import x`` depending on how the
# module was reached; we normalise the prefix away so both spellings are caught.
PACKAGE_NAME = "cowork_local"
class ImportViolation(NamedTuple): # Any import whose first dotted segment is one of these is a GUI toolkit.
file_path: Path QT_ROOTS = frozenset({"PySide6", "PySide2", "PyQt5", "PyQt6", "shiboken6", "shiboken2"})
line_number: int
imported_module: str
rule_description: str
# Per-layer rules: layer directory -> top-level package names it may not import.
# Disallowed top-level package names in pure business/domain layers # Kept as a plain table so adding a layer later is a one-line change and the
FORBIDDEN_MODULE_PREFIXES: Set[str] = { # rules stay readable next to the ADR they implement.
"PySide6", LAYER_RULES: Dict[str, frozenset] = {
"PySide2", # I1 + I2: domain is the innermost layer and depends on nothing but stdlib.
"PyQt6", "domain": frozenset({"application", "infrastructure", "presentation", "ui", "core"}),
"PyQt5", # I1 + I3: application may use domain, but never anything that draws pixels.
"ui", "application": frozenset({"presentation", "ui"}),
"app",
} }
# Default directories that must strictly adhere to Clean Architecture # Directories that are never production code and therefore never scanned.
DEFAULT_SCAN_DIRS: List[str] = [ SKIP_DIRS = frozenset({".git", "__pycache__", ".pytest_cache", "tests", "build", "dist"})
"domain",
"application",
]
class ArchitectureImportVisitor(ast.NodeVisitor): @dataclass(frozen=True)
"""AST visitor that checks all Import and ImportFrom statements against forbidden prefixes.""" class Violation:
"""One forbidden import, carrying enough context to fix it without grepping."""
def __init__(self, file_path: Path, forbidden: Set[str]) -> None: path: Path
self.file_path = file_path line: int
self.forbidden = forbidden imported: str
self.violations: List[ImportViolation] = [] rule: str
def visit_Import(self, node: ast.Import) -> None: def render(self) -> str:
# Check direct `import x, y` statements """Format as ``file:line: message`` — the shape editors turn into a
for alias in node.names: clickable link, so a CI failure lands the developer on the exact line."""
root_module = alias.name.split(".")[0] rel = self.path.relative_to(REPO_ROOT).as_posix()
if root_module in self.forbidden: # ASCII-only on purpose: this line is printed to a console that may run a
self.violations.append( # legacy code page (cp932 on the team's Windows boxes), where a non-ASCII
ImportViolation( # dash raises UnicodeEncodeError and would crash the gate on the very
file_path=self.file_path, # failure path it exists to report.
line_number=node.lineno, return f"{rel}:{self.line}: imports '{self.imported}' - {self.rule}"
imported_module=alias.name,
rule_description=f"Direct import of GUI/shell module '{alias.name}' is prohibited.",
)
)
self.generic_visit(node)
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
# Check `from x import y` statements
if node.module:
root_module = node.module.split(".")[0]
if root_module in self.forbidden:
self.violations.append(
ImportViolation(
file_path=self.file_path,
line_number=node.lineno,
imported_module=node.module,
rule_description=f"Import from GUI/shell module '{node.module}' is prohibited.",
)
)
self.generic_visit(node)
def scan_file(file_path: Path, forbidden: Set[str]) -> List[ImportViolation]: def iter_python_files(layer_dir: Path) -> Iterable[Path]:
"""Parse a single Python file into AST and return all detected architecture import violations.""" """Yield every production ``.py`` file under ``layer_dir``.
try:
source_code = file_path.read_text(encoding="utf-8")
tree = ast.parse(source_code, filename=str(file_path))
except (SyntaxError, UnicodeDecodeError) as exc:
print(f"[Syntax/Read Warning] Could not parse {file_path}: {exc}", file=sys.stderr)
return []
visitor = ArchitectureImportVisitor(file_path, forbidden) Test files are excluded on purpose: a test for a pure-Python service is
visitor.visit(tree) allowed to import Qt (an integration test may need a headless widget), and
return visitor.violations holding tests to the production rule would push people to disable the gate.
"""
if not layer_dir.is_dir():
def scan_directory(dir_path: Path, forbidden: Set[str]) -> List[ImportViolation]: return
"""Recursively scan all Python files in a directory.""" for path in sorted(layer_dir.rglob("*.py")):
violations: List[ImportViolation] = [] # Reject a path as soon as ANY of its parent folder names is skippable,
if not dir_path.exists(): # which also covers nested __pycache__ inside a sub-package.
return violations if any(part in SKIP_DIRS for part in path.parts):
for py_file in dir_path.rglob("*.py"):
if py_file.is_file() and "__pycache__" not in py_file.parts:
violations.extend(scan_file(py_file, forbidden))
return violations
def main() -> int:
"""CLI entry point for CI/pre-commit quality gate checks."""
parser = argparse.ArgumentParser(
description="Clean Architecture Import Guard: Verifies zero GUI/Qt dependencies in domain/app layers."
)
parser.add_argument(
"--paths",
nargs="*",
default=DEFAULT_SCAN_DIRS,
help="Paths or directories to scan (defaults to 'domain' and 'application')",
)
parser.add_argument(
"--root",
default=".",
help="Root workspace directory",
)
args = parser.parse_args()
root_dir = Path(args.root).resolve()
all_violations: List[ImportViolation] = []
print(f"[Clean Arch Guard] Scanning root: {root_dir}")
for target in args.paths:
target_path = (root_dir / target).resolve()
if not target_path.exists():
# If the layer directory does not exist yet (during early migration), skip cleanly
print(f"[Clean Arch Guard] Directory '{target}' does not exist yet (skipped).")
continue continue
yield path
if target_path.is_file():
all_violations.extend(scan_file(target_path, FORBIDDEN_MODULE_PREFIXES))
else:
all_violations.extend(scan_directory(target_path, FORBIDDEN_MODULE_PREFIXES))
if all_violations: def module_parts(path: Path) -> List[str]:
print("\n[FAIL] CLEAN ARCHITECTURE VIOLATIONS DETECTED:") """Dotted package path of ``path`` relative to the repo root, as a list.
print("=" * 70)
for v in all_violations: ``domain/agents/agent_event.py`` -> ``["domain", "agents", "agent_event"]``
rel_path = v.file_path.relative_to(root_dir) if v.file_path.is_relative_to(root_dir) else v.file_path ``domain/agents/__init__.py`` -> ``["domain", "agents"]``
print(f" • {rel_path}:{v.line_number} -> Forbidden import: '{v.imported_module}'")
print(f" Reason: {v.rule_description}") Needed to resolve *relative* imports: ``from ..models import X`` inside
print("=" * 70) ``domain/agents/foo.py`` really means ``domain.models``, and only the file's
print(f"Total Violations: {len(all_violations)}") own position tells us that.
"""
rel = path.relative_to(REPO_ROOT)
parts = list(rel.parts)
if parts[-1] == "__init__.py":
parts.pop()
else:
parts[-1] = parts[-1][: -len(".py")]
return parts
def resolve_relative(parts: Sequence[str], level: int, module: str) -> str:
"""Turn a relative import into the absolute top-level package it points at.
``level`` is the number of leading dots. Level 1 means "the package this
module lives in", so we drop the module's own name plus ``level - 1``
further parents. Returns the FIRST segment of the resolved path, because
the rules are expressed in terms of top-level layers.
Walking off the top of the tree (more dots than there are parents) yields
an empty string, which simply never matches a rule — a malformed import
like that is a syntax/packaging problem, not an architecture violation.
"""
base = list(parts[:-1]) # the package containing this module
if level > 1:
drop = level - 1
if drop > len(base):
return ""
base = base[: len(base) - drop]
tail = module.split(".") if module else []
resolved = base + tail
return resolved[0] if resolved else ""
def top_level(name: str) -> str:
"""First dotted segment of an absolute import, with the distribution package
prefix stripped so ``cowork_local.ui.chat_panel`` and ``ui.chat_panel`` are
treated as the same dependency."""
segments = name.split(".")
if segments and segments[0] == PACKAGE_NAME:
segments = segments[1:]
return segments[0] if segments else ""
def imported_roots(tree: ast.AST, parts: Sequence[str]) -> Iterable[Tuple[str, int, str]]:
"""Yield ``(top_level_package, line_number, as_written)`` for every import.
``as_written`` is kept so the error message shows what the developer
actually typed rather than the normalised root, which makes the violation
obvious at a glance.
``ast.walk`` (not just the module body) is deliberate: this repo defers many
heavy imports into function bodies to keep app start-up fast, and a
function-local ``from PySide6 import QtWidgets`` breaks the layer exactly
the same way a top-level one does.
"""
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
yield top_level(alias.name), node.lineno, alias.name
elif isinstance(node, ast.ImportFrom):
if node.level:
written = "." * node.level + (node.module or "")
yield resolve_relative(parts, node.level, node.module or ""), node.lineno, written
else:
module = node.module or ""
yield top_level(module), node.lineno, module
def check_file(path: Path, layer: str, banned: frozenset) -> List[Violation]:
"""Collect every rule violation in one file.
A file that cannot be parsed is reported as a violation rather than skipped:
silently passing a file the checker could not read would make the gate lie.
"""
try:
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
except (SyntaxError, UnicodeDecodeError) as exc:
return [Violation(path, getattr(exc, "lineno", 0) or 0, "<unparseable>",
f"cannot be parsed by the architecture guard ({exc})")]
parts = module_parts(path)
out: List[Violation] = []
for root, lineno, written in imported_roots(tree, parts):
if root in QT_ROOTS:
out.append(Violation(path, lineno, written,
f"'{layer}/' must be 100% pure Python (ADR-001 I1)"))
elif root in banned:
out.append(Violation(path, lineno, written,
f"'{layer}/' must not depend on '{root}/' (ADR-001 I2/I3)"))
return out
def run(layers: Sequence[str]) -> List[Violation]:
"""Scan the requested layers and return every violation found, in file order."""
found: List[Violation] = []
for layer in layers:
banned = LAYER_RULES[layer]
for path in iter_python_files(REPO_ROOT / layer):
found.extend(check_file(path, layer, banned))
return found
def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="CASAN Check 3 - Clean Architecture Guard (see ADR-001).")
parser.add_argument(
"layers", nargs="*", choices=sorted(LAYER_RULES) or None, default=None,
help="Layers to scan (default: every layer with a rule).",
)
args = parser.parse_args(argv)
layers = args.layers or sorted(LAYER_RULES)
violations = run(layers)
scanned = sum(1 for layer in layers for _ in iter_python_files(REPO_ROOT / layer))
if violations:
print(f"FAIL - {len(violations)} architecture violation(s) in {scanned} file(s):\n")
for v in violations:
print(" " + v.render())
# Point at the rationale instead of just the rule id, so someone hitting
# this for the first time knows where the decision was made.
print("\nSee docs/architecture/ADR-001-layered-architecture.md")
return 1 return 1
print("\n[PASS] CLEAN ARCHITECTURE CHECK: 0 forbidden imports detected.") print(f"PASS - 0 Qt imports in {', '.join(layers)} ({scanned} file(s) scanned)")
return 0 return 0
+102 -84
View File
@@ -6,6 +6,7 @@ import time
from typing import TYPE_CHECKING, Optional, Tuple from typing import TYPE_CHECKING, Optional, Tuple
from .config import AppConfig from .config import AppConfig
from .infrastructure.mcp import McpToolSourceManager
def resolve_agent_default( def resolve_agent_default(
@@ -38,21 +39,30 @@ class AppContext:
def __init__(self, config: AppConfig): def __init__(self, config: AppConfig):
self.config = config self.config = config
self.started_at = time.time() # for Monitoring's Sandbox Details "Created"/"Uptime" self.started_at = time.time() # for Monitoring's Sandbox Details "Created"/"Uptime"
self._mcp_connections: dict = {} # server name -> McpServerConnection # Admin-configured MCP servers + the built-in MS365 server (R05-T05):
# connection caching/lifecycle (check-then-create, restart, shutdown)
# now lives in McpToolSourceManager, extracted so it is testable
# without an AppContext/Qt. See its docstring for why the check-then-
# create race matters — several turns (multiple Cowork tabs, parallel
# Co4E flows, scheduled tasks) can call build_mcp_tools() at once.
self._mcp_manager = McpToolSourceManager()
self._ext_connections: dict = {} # connector id -> McpServerConnection (mcp_stdio mode only) self._ext_connections: dict = {} # connector id -> McpServerConnection (mcp_stdio mode only)
# Guards the two connection caches above. build_mcp_tools() runs on EVERY # Guards ``_ext_connections`` only now — unified Connectors (CAD/CAE/
# chat turn's own AgentWorker thread, so several turns (multiple Cowork # MS365/Other) aren't covered by McpToolSourceManager (R05-T05 scoped
# tabs, parallel Co4E flows, scheduled tasks) can enter it at once. The # to MCP servers), so this cache still needs its own check-then-create
# cache is populated check-then-create ("conn is None → spawn → store"); # lock, the same race McpToolSourceManager guards against internally.
# without this lock two concurrent turns both see None and each spawns a
# subprocess for the SAME server — one leaks as an orphan and the wrong
# object may be handed out. The lock makes connection setup atomic; the
# provider/HTTP path itself is already thread-safe (a fresh provider per
# call, module-level `requests`, MCP calls multiplexed on the server's
# own event loop), so concurrent model calls never needed serializing.
self._conn_lock = threading.Lock() self._conn_lock = threading.Lock()
self._routing_service = None # lazy RoutingService (Auto Model Routing) self._routing_service = None # lazy RoutingService (Auto Model Routing)
# Lazy RoutingApplicationService (R03-T03) — the Qt-free decision layer
# every chat surface now routes through. Wraps _routing_service, which
# stays the scoring/ranking engine underneath.
self._routing_application = None
self._routing_lock = threading.Lock() self._routing_lock = threading.Lock()
# A SEPARATE lock for the application service: building it calls
# routing(), which takes _routing_lock. threading.Lock is not
# reentrant, so sharing one lock across both accessors deadlocks the
# first caller instead of just serialising them.
self._routing_app_lock = threading.Lock()
# The workspace (project) currently selected in the Workspace screen. # The workspace (project) currently selected in the Workspace screen.
# Per-workspace modes (routing + auto-run) resolve against THIS project # Per-workspace modes (routing + auto-run) resolve against THIS project
# so each workspace keeps its own modes. Updated by WorkspaceTab on # so each workspace keeps its own modes. Updated by WorkspaceTab on
@@ -73,26 +83,34 @@ class AppContext:
return load_project(pid) return load_project(pid)
def project_routing_mode(self, surface: str) -> str: def project_routing_mode(self, surface: str) -> str:
"""Effective Off/Auto/Manual/Fallback routing mode for a chat ``surface`` """Effective Off/Auto/Manual routing mode for a chat ``surface`` in the
in the ACTIVE workspace: the workspace's own override wins; otherwise the ACTIVE workspace: the workspace's own override wins; otherwise the
global default (``config.routing_mode_for``). This is what makes each global default (``config.routing_mode_for``). This is what makes each
workspace keep its own routing mode. workspace keep its own routing mode."""
The accepted set is taken from ``AppConfig.ROUTING_MODES`` rather than
repeated here, so adding a mode (as R03-T03 did with "fallback") stays a
one-line change instead of a hunt through every validation site."""
project = self._current_project() project = self._current_project()
if project is not None: if project is not None:
# Validated through the single mode vocabulary (R03-T03) rather
# than a literal tuple, so a workspace can store any mode the
# routing service understands - including "fallback", whose
# on-screen toggle arrives in EPIC R08.
from .application.model_routing import is_valid_mode, normalize_mode
mode = (project.routing_modes or {}).get(surface, "") mode = (project.routing_modes or {}).get(surface, "")
if mode in self.config.ROUTING_MODES: # Only a RECOGNISED override wins; an empty or corrupt value falls
return mode # through to the global setting, exactly as before. Validation goes
# through the routing vocabulary (R03-T03) instead of a literal
# tuple, so a new mode works everywhere the moment it is defined.
if is_valid_mode(mode):
return normalize_mode(mode)
return self.config.routing_mode_for(surface) return self.config.routing_mode_for(surface)
def set_project_routing_mode(self, surface: str, mode: str) -> None: def set_project_routing_mode(self, surface: str, mode: str) -> None:
"""Persist a surface's routing mode for the ACTIVE workspace. With no """Persist a surface's routing mode for the ACTIVE workspace. With no
workspace selected, falls back to the global setting so behaviour workspace selected, falls back to the global setting so behaviour
outside a project stays global.""" outside a project stays global."""
mode = mode if mode in self.config.ROUTING_MODES else "off" from .application.model_routing import normalize_mode
mode = normalize_mode(mode)
project = self._current_project() project = self._current_project()
if project is None: if project is None:
self.config.set_routing_mode_for(surface, mode) self.config.set_routing_mode_for(surface, mode)
@@ -148,6 +166,34 @@ class AppContext:
self._routing_service = RoutingService(self) self._routing_service = RoutingService(self)
return self._routing_service return self._routing_service
def routing_application(self):
"""The shared :class:`RoutingApplicationService` (R03-T03).
This is what UI code should call: it owns the Off/Auto/Manual/Fallback
policy, the confirm handshake and the never-raise guarantee, while
:meth:`routing` remains the scoring engine underneath. Chat, Co4E and
AI-Edit all go through this one object, so a change to routing policy is
made once instead of three times.
Built lazily and memoised for the same reason as :meth:`routing`: the
pending-switch registry and assessment store must be shared app-wide."""
if self._routing_application is None:
# Resolve the engine BEFORE taking this lock: routing() takes
# _routing_lock, and nesting the two acquisitions is what makes the
# ordering fragile in the first place.
engine = self.routing()
with self._routing_app_lock:
if self._routing_application is None:
from .application.model_routing import RoutingApplicationService
self._routing_application = RoutingApplicationService(
engine,
# Per-workspace mode lookup, so each workspace keeps its
# own routing behaviour (see project_routing_mode).
mode_reader=self.project_routing_mode,
)
return self._routing_application
def build_active_provider(self): def build_active_provider(self):
"""Construct the currently selected provider (called inside workers).""" """Construct the currently selected provider (called inside workers)."""
return self.build_provider_for(self.config.active_provider) return self.build_provider_for(self.config.active_provider)
@@ -192,48 +238,41 @@ class AppContext:
if not self.config.connect_external: if not self.config.connect_external:
return [], None return [], None
from .core.ext_connectors import build_ext_connector_tools from .core.ext_connectors import build_ext_connector_tools
from .core.mcp_client import McpServerConnection
from .core.mcp_client import build_mcp_tools as _merge_mcp_tools from .core.mcp_client import build_mcp_tools as _merge_mcp_tools
from .core.tools import combine_tool_sources from .core.tools import combine_tool_sources
# Serialize the check-then-create against the connection caches so # R05-T05: connection caching/check-then-create for admin-configured
# concurrent turns share one subprocess per server instead of racing to # servers + the MS365 builtin now lives in McpToolSourceManager (its
# spawn duplicates (see _conn_lock in __init__). The lock is held while # own lock guards the race — see its docstring).
# connections are established (a one-time cost per server per app run); active = []
# once warm, every turn just finds the cached connection and returns. for entry in self.config.mcp_servers:
with self._conn_lock: if not entry.get("enabled", True):
active = [] continue
for entry in self.config.mcp_servers: name = entry.get("name", "")
if not entry.get("enabled", True): command = entry.get("command", "")
continue if not name or not command:
name = entry.get("name", "") continue
command = entry.get("command", "") conn = self._mcp_manager.ensure(name, command, entry.get("args") or [],
if not name or not command: entry.get("env") or None)
continue if conn is not None:
conn = self._mcp_connections.get(name)
if conn is None:
conn = McpServerConnection(name, command, entry.get("args") or [],
entry.get("env") or None)
try:
conn.start()
except Exception: # noqa: BLE001 - one broken server must not block the turn
continue
self._mcp_connections[name] = conn
active.append(conn) active.append(conn)
builtin = self._ms365_builtin_connection(skip={c.name for c in active}) builtin = self._ms365_builtin_connection(skip={c.name for c in active})
if builtin is not None: if builtin is not None:
active.append(builtin) active.append(builtin)
mcp_tools, mcp_executor = _merge_mcp_tools(active) mcp_tools, mcp_executor = _merge_mcp_tools(active)
# ``_ext_connections`` isn't covered by McpToolSourceManager (T05
# scoped to MCP servers) — still serialized under ``_conn_lock``.
with self._conn_lock:
ext = self.config.ext_connectors ext = self.config.ext_connectors
all_connectors = [*ext.get("cad", []), *ext.get("cae", []), all_connectors = [*ext.get("cad", []), *ext.get("cae", []),
*ext.get("ms365", []), *ext.get("other", [])] *ext.get("ms365", []), *ext.get("other", [])]
ext_tools, ext_executor = build_ext_connector_tools(all_connectors, self._ext_connections) ext_tools, ext_executor = build_ext_connector_tools(all_connectors, self._ext_connections)
# Locally-synced OneDrive/SharePoint (no sign-in) — reads/writes the # Locally-synced OneDrive/SharePoint (no sign-in) — reads/writes the
# OneDrive-desktop-synced folders directly, gated on ms365.connectors. # OneDrive-desktop-synced folders directly, gated on ms365.connectors.
from .core.ms365_local import build_ms365_local_tools from .core.ms365_local import build_ms365_local_tools
local_tools, local_executor = build_ms365_local_tools(self.config) local_tools, local_executor = build_ms365_local_tools(self.config)
return combine_tool_sources((mcp_tools, mcp_executor), (ext_tools, ext_executor), return combine_tool_sources((mcp_tools, mcp_executor), (ext_tools, ext_executor),
(local_tools, local_executor)) (local_tools, local_executor))
@@ -263,36 +302,20 @@ class AppContext:
import sys import sys
from pathlib import Path from pathlib import Path
from .core.mcp_client import McpServerConnection
name = self._MS365_BUILTIN name = self._MS365_BUILTIN
if name in skip: if name in skip:
return None return None
if not self._ms365_available(): if not self._ms365_available():
stale = self._mcp_connections.pop(name, None) self._mcp_manager.stop(name)
if stale is not None:
try:
stale.stop()
except Exception: # noqa: BLE001
pass
return None return None
conn = self._mcp_connections.get(name) # The subprocess must import cowork_local even in a from-source run
if conn is None: # (PYTHONPATH=src) — prepend this package's parent dir explicitly.
# The subprocess must import cowork_local even in a from-source run env = dict(os.environ)
# (PYTHONPATH=src) — prepend this package's parent dir explicitly. src_root = str(Path(__file__).resolve().parent.parent)
env = dict(os.environ) env["PYTHONPATH"] = (src_root + os.pathsep + env["PYTHONPATH"]
src_root = str(Path(__file__).resolve().parent.parent) if env.get("PYTHONPATH") else src_root)
env["PYTHONPATH"] = (src_root + os.pathsep + env["PYTHONPATH"] return self._mcp_manager.ensure(
if env.get("PYTHONPATH") else src_root) name, sys.executable, ["-m", "cowork_local.mcp_servers.ms365_server"], env)
conn = McpServerConnection(
name, sys.executable,
["-m", "cowork_local.mcp_servers.ms365_server"], env)
try:
conn.start()
except Exception: # noqa: BLE001 - MS365 down must not block the turn
return None
self._mcp_connections[name] = conn
return conn
def stop_mcp_connections(self) -> None: def stop_mcp_connections(self) -> None:
"""Terminate every connected MCP server's subprocess (incl. External """Terminate every connected MCP server's subprocess (incl. External
@@ -300,11 +323,6 @@ class AppContext:
them linger as orphan processes.""" them linger as orphan processes."""
from .core.ext_connectors import stop_ext_connections from .core.ext_connectors import stop_ext_connections
self._mcp_manager.stop_all()
with self._conn_lock: with self._conn_lock:
for conn in self._mcp_connections.values():
try:
conn.stop()
except Exception: # noqa: BLE001
pass
self._mcp_connections.clear()
stop_ext_connections(self._ext_connections) stop_ext_connections(self._ext_connections)
+11
View File
@@ -0,0 +1,11 @@
"""Characterization tests: pin the CURRENT behaviour of legacy code (R01-T04).
These are not specifications of what the code *should* do - they are a snapshot
of what it *does* today, written before the refactor so that any behavioural
drift introduced while moving logic into ``application/`` shows up as a failing
test rather than as a bug report from a user.
Rule for this folder: when a test here fails during the refactor, do not "fix"
the test first. Decide deliberately whether the behaviour change is intended,
and only then update the snapshot in the same commit as the change.
"""
+260 -129
View File
@@ -1,157 +1,288 @@
"""Characterization tests for core/chat_agent.py (run_chat and run_cowork runtime seams). """Characterization snapshot of ``core.chat_agent.run_cowork`` (R01-T04).
These tests capture existing behavior as an executable baseline specification, ``run_cowork`` is the turn engine every Cowork surface funnels through (chat tab,
ensuring that future refactoring to ConversationApplicationService does not alter Co4E flow steps, Schedule Task runs). EPIC R04 moves its orchestration into
core turn semantics, event emissions, or file handling. ``application/conversations/conversation_application_service.py``; these tests
lock down the observable contract BEFORE that move so the new service can be
proven equivalent:
* which system prompt ends up in ``messages``
* which tools are advertised to the provider
* the exact ``emit`` event sequence for a plain turn and for a tool turn
* that ``save_file`` produces a real file in the turn's output folder
* that ``cancel`` stops the loop without calling the provider
Everything runs offline: :class:`FakeProvider` replaces the network and the two
disk-backed prompt sources (skills, security rules) are stubbed to empty so the
snapshot does not depend on the developer's own ``~/.cowork_local`` contents.
""" """
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List from typing import Any, Dict, List
import pytest
from cowork_local.core import chat_agent from cowork_local.core import chat_agent
from cowork_local.tests.fakes.fake_provider import FakeProvider from tests.fakes import FakeProvider, FakeToolExecutor, ScriptedTurn
def test_run_chat_characterization() -> None: @pytest.fixture
"""Capture baseline behavior of run_chat: system prompt insertion, streaming, and message persistence.""" def isolated_agent(monkeypatch, tmp_path: Path):
provider = FakeProvider() """Neutralise every ambient input ``run_cowork`` reads from the machine.
provider.queue_response(content="Hello there!", chunks=["Hello ", "there!"])
messages: List[Dict[str, Any]] = [{"role": "user", "content": "Hi assistant"}] Without this the snapshot would silently depend on whichever skills and
emitted_events: List[Dict[str, Any]] = [] security rules the developer happens to have enabled locally, and on the
real audit log under ``~/.cowork_local`` - the test would then pass on one
laptop and fail on another for reasons unrelated to the code under test.
"""
monkeypatch.setattr(chat_agent, "active_skills_text", lambda: "")
monkeypatch.setattr(chat_agent, "load_rules", lambda: "")
# audit_log is imported lazily inside run_cowork, so patch the module's own
# target directory rather than the name chat_agent sees.
from cowork_local.core import audit_log
def emit(event: Dict[str, Any]) -> None: monkeypatch.setattr(audit_log, "AUDIT_DIR", tmp_path / "audit")
emitted_events.append(event) return tmp_path
result = chat_agent.run_chat(
provider=provider,
messages=messages,
emit=emit,
)
# 1. Verify system prompt was injected at position 0
assert messages[0]["role"] == "system"
assert "Cowork Local" in messages[0]["content"]
# 2. Verify returned assistant message
assert result["role"] == "assistant"
assert result["content"] == "Hello there!"
# 3. Verify assistant message was appended to messages list
assert messages[-1] == result
# 4. Verify emitted events sequence
text_deltas = [e["delta"] for e in emitted_events if e["type"] == "text"]
assert "".join(text_deltas) == "Hello there!"
assert any(e["type"] == "assistant_done" for e in emitted_events)
def test_run_cowork_save_file_characterization(tmp_path: Path) -> None: def _run(provider, messages, out_dir: Path, **kwargs):
"""Capture baseline behavior of run_cowork: tool execution loop and file production.""" """Run one turn and return ``(returned_messages, emitted_events)``."""
output_dir = tmp_path / "output" events: List[Dict[str, Any]] = []
output_dir.mkdir(parents=True, exist_ok=True) result = chat_agent.run_cowork(provider, messages, out_dir, events.append, **kwargs)
return result, events
provider = FakeProvider()
# Step 1: Model requests save_file tool
provider.queue_response(
content="Saving your requested report.",
tool_calls=[{
"id": "call_save_1",
"name": "save_file",
"arguments": {
"filename": "report.md",
"content": "# Executive Summary\nAll systems nominal.",
},
}],
)
# Step 2: Model finishes after tool result
provider.queue_response(
content="I have created report.md in your output directory.",
chunks=["I have created report.md in your output directory."],
)
messages: List[Dict[str, Any]] = [{"role": "user", "content": "Export report to markdown file"}]
emitted_events: List[Dict[str, Any]] = []
def emit(event: Dict[str, Any]) -> None:
emitted_events.append(event)
final_messages = chat_agent.run_cowork(
provider=provider,
messages=messages,
output_dir=output_dir,
emit=emit,
enforce_rules=False,
)
# 1. Verify file was created in output directory with expected content
created_file = output_dir / "report.md"
assert created_file.exists()
assert created_file.read_text(encoding="utf-8") == "# Executive Summary\nAll systems nominal."
# 2. Verify message history contains user -> assistant (tool_calls) -> tool -> assistant
roles = [m["role"] for m in final_messages]
assert "system" in roles
assert "user" in roles
assert "tool" in roles
# 3. Verify tool result message content
tool_msg = next(m for m in final_messages if m["role"] == "tool")
assert tool_msg["name"] == "save_file"
assert "Saved report.md" in tool_msg["content"]
def test_run_cowork_cancellation_characterization(tmp_path: Path) -> None: def _types(events: List[Dict[str, Any]]) -> List[str]:
"""Capture cancellation behavior in run_cowork.""" """Event ``type`` values in order - the shape assertions read on."""
output_dir = tmp_path / "output_cancel" return [e.get("type") for e in events]
output_dir.mkdir(parents=True, exist_ok=True)
provider = FakeProvider()
provider.queue_response(content="Working...")
is_cancelled = True # --------------------------------------------------------------------------- #
# A plain answer with no tool calls
# --------------------------------------------------------------------------- #
def test_plain_turn_streams_text_and_appends_assistant_message(isolated_agent):
out_dir = isolated_agent / "out"
provider = FakeProvider([ScriptedTurn(text="Hello there.")])
messages: List[Dict[str, Any]] = [{"role": "user", "content": "hi"}]
def check_cancel() -> bool: result, events = _run(provider, messages, out_dir)
return is_cancelled
emitted_events: List[Dict[str, Any]] = [] # The loop ends as soon as the model stops calling tools: exactly one call.
messages: List[Dict[str, Any]] = [{"role": "user", "content": "Please start"}] assert provider.call_count == 1
# run_cowork mutates and returns the SAME list the caller passed in - callers
# (ui/cowork_tab.py::build_job) rely on this to persist conversation history.
assert result is messages
assert result[-1]["role"] == "assistant"
assert result[-1]["content"] == "Hello there."
assert _types(events) == ["text", "assistant_done"]
assert events[0]["delta"] == "Hello there."
assert events[-1]["content"] == "Hello there."
chat_agent.run_cowork(
provider=provider,
messages=messages,
output_dir=output_dir,
emit=lambda e: emitted_events.append(e),
cancel=check_cancel,
enforce_rules=False,
)
# Provider should not have executed turns if cancelled right away def test_system_prompt_is_inserted_once_at_the_front(isolated_agent):
out_dir = isolated_agent / "out"
provider = FakeProvider([ScriptedTurn(text="ok")])
messages: List[Dict[str, Any]] = [{"role": "user", "content": "hi"}]
result, _ = _run(provider, messages, out_dir)
assert result[0]["role"] == "system"
assert result[0]["content"].startswith("You are Cowork Local")
# Exactly one system message: a second turn on the same conversation must not
# stack another copy of the prompt (that would grow the context every turn).
assert sum(1 for m in result if m.get("role") == "system") == 1
def test_caller_supplied_system_prompt_is_preserved(isolated_agent):
"""A caller that already put a system message first keeps its own prompt.
Co4E flow steps depend on this to give a step its own persona instead of the
generic Cowork prompt.
"""
out_dir = isolated_agent / "out"
provider = FakeProvider([ScriptedTurn(text="ok")])
messages: List[Dict[str, Any]] = [
{"role": "system", "content": "CUSTOM PERSONA"},
{"role": "user", "content": "hi"},
]
result, _ = _run(provider, messages, out_dir)
assert result[0]["content"] == "CUSTOM PERSONA"
def test_reasoning_is_emitted_separately_and_never_joins_the_answer(isolated_agent):
"""Reasoning drives the "Thinking" indicator only - it must not become part
of the assistant's content, otherwise a reasoning model's private chain of
thought would be persisted into conversation history."""
out_dir = isolated_agent / "out"
provider = FakeProvider([ScriptedTurn(text="42", reasoning="let me think...")])
result, events = _run(provider, [{"role": "user", "content": "q"}], out_dir)
assert _types(events) == ["reasoning", "text", "assistant_done"]
assert result[-1]["content"] == "42"
assert "let me think" not in result[-1]["content"]
def test_reasoning_only_reply_gets_a_placeholder_answer(isolated_agent):
"""A model that returns only reasoning must not end the turn on a blank
bubble - headless callers (Schedule Task) read this content back as the
run's final answer and would otherwise write "(no output)"."""
out_dir = isolated_agent / "out"
provider = FakeProvider([ScriptedTurn(text="", reasoning="thinking")])
result, events = _run(provider, [{"role": "user", "content": "q"}], out_dir)
assert result[-1]["content"].startswith("*(model returned only its reasoning")
assert "text" in _types(events)
# --------------------------------------------------------------------------- #
# Tool advertising
# --------------------------------------------------------------------------- #
def test_save_file_and_update_plan_are_always_advertised(isolated_agent):
out_dir = isolated_agent / "out"
provider = FakeProvider([ScriptedTurn(text="ok")])
_run(provider, [{"role": "user", "content": "hi"}], out_dir)
advertised = provider.calls[0].tool_names
assert "save_file" in advertised
assert "update_plan" in advertised
def test_allowed_tools_scopes_the_catalogue_but_keeps_update_plan(isolated_agent):
"""``allowed_tools`` is the permission scope Co4E steps use: a read-only step
must literally not be offered a writing tool. ``update_plan`` survives the
filter because it has no side effects."""
out_dir = isolated_agent / "out"
provider = FakeProvider([ScriptedTurn(text="ok")])
_run(provider, [{"role": "user", "content": "hi"}], out_dir,
allowed_tools=["read_file"])
advertised = set(provider.calls[0].tool_names)
assert "save_file" not in advertised
assert "update_plan" in advertised
def test_extra_tools_are_advertised_alongside_built_ins(isolated_agent):
out_dir = isolated_agent / "out"
executor = FakeToolExecutor(results={"ms365_send_mail": {"output": "sent"}})
provider = FakeProvider([ScriptedTurn(text="ok")])
_run(provider, [{"role": "user", "content": "hi"}], out_dir,
extra_tools=executor.specs(), extra_executor=executor)
assert "ms365_send_mail" in provider.calls[0].tool_names
# --------------------------------------------------------------------------- #
# Tool execution
# --------------------------------------------------------------------------- #
def test_save_file_writes_a_real_file_and_reports_it(isolated_agent):
out_dir = isolated_agent / "out"
provider = FakeProvider([
ScriptedTurn(tool_calls=[("save_file", {"filename": "note.md",
"content": "# Result\n"})]),
ScriptedTurn(text="Done."),
])
result, events = _run(provider, [{"role": "user", "content": "make a note"}], out_dir)
written = [p for p in out_dir.iterdir() if p.is_file()]
assert len(written) == 1
assert written[0].read_text(encoding="utf-8") == "# Result\n"
assert _types(events) == [
"assistant_done", # first turn: tool call only, no visible text
"tool_proposed", # the diff preview shown in the chat
"tool_result",
"text", # second turn's answer
"assistant_done",
]
assert events[2]["ok"] is True
# The tool result is fed back as a `tool` message so the model can react to it.
roles = [m["role"] for m in result]
assert roles == ["system", "user", "assistant", "tool", "assistant"]
assert result[3]["name"] == "save_file"
def test_extra_tool_calls_are_routed_to_the_extra_executor(isolated_agent):
"""MCP / Microsoft 365 tools bypass the built-in file+command handlers and go
to the caller-supplied executor instead."""
out_dir = isolated_agent / "out"
executor = FakeToolExecutor(results={"ms365_send_mail": {"ok": True, "output": "sent"}})
provider = FakeProvider([
ScriptedTurn(tool_calls=[("ms365_send_mail", {"to": "a@b.c"})]),
ScriptedTurn(text="Mail sent."),
])
result, events = _run(provider, [{"role": "user", "content": "mail them"}], out_dir,
extra_tools=executor.specs(), extra_executor=executor)
assert executor.call_names == ["ms365_send_mail"]
assert executor.args_for("ms365_send_mail") == [{"to": "a@b.c"}]
assert [e for e in events if e["type"] == "tool_result"][0]["output"] == "sent"
assert result[3] == {"role": "tool", "tool_call_id": result[3]["tool_call_id"],
"name": "ms365_send_mail", "content": "sent"}
def test_update_plan_drives_the_plan_panel_without_producing_a_file(isolated_agent):
out_dir = isolated_agent / "out"
provider = FakeProvider([
ScriptedTurn(tool_calls=[("update_plan", {"steps": [{"title": "step one"}]})]),
ScriptedTurn(text="Planned."),
])
result, events = _run(provider, [{"role": "user", "content": "plan it"}], out_dir)
plan_events = [e for e in events if e["type"] == "plan_set"]
assert len(plan_events) == 1
assert plan_events[0]["steps"]
# No tool_proposed/tool_result bubbles for a plan update, and no file on disk.
assert "tool_proposed" not in _types(events)
assert list(out_dir.iterdir()) == []
assert result[3]["content"] == "Plan updated."
# --------------------------------------------------------------------------- #
# Cancellation
# --------------------------------------------------------------------------- #
def test_cancel_before_the_first_step_never_calls_the_provider(isolated_agent):
"""Stop pressed before the loop starts must cost zero tokens."""
out_dir = isolated_agent / "out"
provider = FakeProvider([], strict=True)
result, events = _run(provider, [{"role": "user", "content": "hi"}], out_dir,
cancel=lambda: True)
assert provider.call_count == 0 assert provider.call_count == 0
assert _types(events) == []
# The system prompt is still installed, so the conversation stays well-formed
# for a later retry on the same message list.
assert result[0]["role"] == "system"
def test_cleanup_turn_output_characterization(tmp_path: Path) -> None: def test_cancel_between_steps_stops_before_the_next_provider_call(isolated_agent):
"""Capture behavior of temporary .scratch folder cleanup and artifact preservation.""" """After a tool call runs, a Stop must end the turn instead of paying for
output_dir = tmp_path / "output_cleanup" another round trip."""
output_dir.mkdir(parents=True, exist_ok=True) out_dir = isolated_agent / "out"
scratch_dir = output_dir / ".scratch" provider = FakeProvider([
scratch_dir.mkdir(parents=True, exist_ok=True) ScriptedTurn(tool_calls=[("save_file", {"filename": "a.md", "content": "x"})]),
])
calls = {"n": 0}
# Create a generator script and a deliverable inside scratch def cancel() -> bool:
generator_script = scratch_dir / "gen.py" # False on the first check (loop entry), True afterwards - i.e. the user
generator_script.write_text("print('generating')", encoding="utf-8") # pressed Stop while the first step was running.
deliverable = scratch_dir / "data.csv" calls["n"] += 1
deliverable.write_text("a,b,c\n1,2,3", encoding="utf-8") return calls["n"] > 1
before_snapshot = chat_agent._snapshot(output_dir) result, _ = _run(provider, [{"role": "user", "content": "hi"}], out_dir, cancel=cancel)
removed, moved = chat_agent._cleanup_cowork_intermediates(output_dir, before_snapshot, cancelled=False)
# .scratch directory should be removed
assert not scratch_dir.exists()
# deliverable should be moved to output root
root_csv = output_dir / "data.csv"
assert root_csv.exists()
# script should not be in output root
assert not (output_dir / "gen.py").exists()
assert provider.call_count == 1
assert result[-1]["role"] in {"assistant", "tool"}

Some files were not shown because too many files have changed in this diff Show More