diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index d981967..8f68dce 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -12,19 +12,36 @@ jobs: test: runs-on: ubuntu-latest timeout-minutes: 15 + defaults: + run: + working-directory: cowork_local + env: + # Tiến trình con của test import `cowork_local` qua đường này. + PYTHONPATH: ${{ github.workspace }} steps: + # Checkout PHẢI nằm trong thư mục tên đúng `cowork_local`. + # Nhiều test characterization sinh tiến trình con chạy + # `python -c "from cowork_local... import ..."`; tiến trình con đó chỉ + # import được khi trên sys.path có một thư mục mang đúng tên gói. Checkout + # vào thư mục tên khác làm 73 test đỏ vì lý do không liên quan tới mã. - name: Check out source uses: actions/checkout@v4 + with: + path: cowork_local - name: Set up Python uses: actions/setup-python@v5 with: python-version: "3.11" cache: pip - cache-dependency-path: requirements-test.txt + cache-dependency-path: cowork_local/requirements.txt - - name: Install test dependencies - run: python -m pip install --disable-pip-version-check -r requirements-test.txt + # Mot file duy nhat: requirements-test.txt cu chi co pytest, nhung + # 64/108 file test dung widget that (20 file import PySide6 thang o dau + # file, khong co bao ve) nen no van phai keo ve gan nhu ca danh sach + # runtime. Cai rieng file kia thi pytest chet ngay luc thu thap test. + - name: Install dependencies + run: python -m pip install --disable-pip-version-check -r requirements.txt - name: Check Python syntax run: | @@ -39,3 +56,39 @@ jobs: - name: Run tests run: python -m pytest tests -q + + # --- CASAN Verification Gate ------------------------------------- + # Ba check này là điều kiện của cổng ngày 30/08. Chạy trên MỌI PR để + # biết vi phạm ngay hôm phát sinh, thay vì dồn tới ngày cổng. + # + # Check 1 do Team Gamma sở hữu và đã có. Check 2 (Team Hoa) và Check 3 + # (Team Duy) chưa viết — bước dưới bỏ qua nếu script chưa tồn tại, để + # thêm cổng không làm đỏ CI của hai team kia. + + - name: "CASAN Check 1 — không có credential lộ (Team Gamma)" + run: | + python scripts/audit_security.py --self-test + python scripts/audit_security.py + + - name: "CASAN Check 2 — file production ≤ 400 dòng (Team Hoa)" + run: | + if [ -f scripts/check_loc.py ]; then + python scripts/check_loc.py + else + echo "scripts/check_loc.py chưa có — Team Hoa viết, hạn 30/08. Bỏ qua." + fi + + - name: "CASAN Check 3 — domain/ và application/ không import PySide6 (Team Duy)" + run: | + if [ -f scripts/check_imports.py ]; then + python scripts/check_imports.py + else + echo "scripts/check_imports.py chưa có — Team Duy viết, hạn 30/08. Bỏ qua." + fi + + # Cổng O bổ sung sau đợt đối chiếu AS-IS/TO-BE: ba check trên đều không + # bắt được mã chết (file không ai import vẫn đúng chiều phụ thuộc, vẫn + # sạch credential, vẫn dưới 400 dòng). Đợt đó tìm ra 1.400 dòng mã trùng + # lặp chết lọt qua đúng theo cách này. + - name: "CASAN Check O — module production phải có nơi import" + run: python scripts/check_orphan_modules.py diff --git a/.gitignore b/.gitignore index 182f2ee..ed16a9c 100644 --- a/.gitignore +++ b/.gitignore @@ -28,7 +28,10 @@ bower_components/ .env.preview *.pem *.key -secrets/ +# Neo vào gốc repo: mẫu không neo nuốt MỌI thư mục tên secrets ở mọi độ +# sâu — nó đã âm thầm chặn infrastructure/secrets/ (mã nguồn, không phải +# bí mật) khỏi repo suốt 21-22/08. +/secrets/ credentials.json .npmrc .yarnrc @@ -36,9 +39,11 @@ credentials.json # ============================================================================= # Build & Distribution # ============================================================================= -dist/ -build/ -out/ +# Neo vao goc — mau khong neo se nuot moi thu muc trung ten o moi do sau, +# ke ca ma nguon. Da mac dung loi do voi secrets/ (xem khoi Credentials). +/dist/ +/build/ +/out/ .next/ .nuxt/ .output/ @@ -73,7 +78,8 @@ desktop.ini # Logs & Debug # ============================================================================= *.log -logs/ +# Neo vao goc: infrastructure/logs/ la ma nguon, khong phai log chay may. +/logs/ npm-debug.log* yarn-debug.log* yarn-error.log* diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6e92ee4..fa49083 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -48,7 +48,7 @@ Prefer the existing lightweight Conventional Commit prefixes: `feat:`, `fix:`, ` Run the application from the parent directory with `python -m cowork_local`. The current reliable test command is: ```bash -python -m pip install -r requirements-test.txt +python -m pip install -r requirements.txt python -m pytest tests -q ``` diff --git a/README.md b/README.md index 553cf59..f90b846 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,101 @@ # Cowork Local -Cowork Local is the internal AI cowork desktop platform owned by the Cowork Team. It provides the Cowork runtime, workspace and agent experiences, MCP/connectors, security controls, and model routing foundation. +Cowork Local is the internal AI cowork desktop platform. It provides a local-first desktop runtime, multi-turn conversational agents, workspace isolation, task scheduling, MCP connectors, security guardrails, and model routing. -The Cowork Team owns this product and its stable branch. The FSG AI Core Team contributes selected reusable capabilities through branches and Pull Requests; it is not the owner or final merger of this repository. +--- -## Quick start +## 🏛️ 4-Tier Clean Architecture -The imported application is a Python/PySide6 package. Run it from the directory that contains `cowork_local`: +The codebase strictly adheres to **Clean Architecture** with unidirectional inward dependencies: + +```text +presentation/ (PySide6 UI, Shell, NavRail, Chat, Scheduling, Settings, Dashboard) + │ + ▼ +application/ (Pure Python Orchestration: Conversations, Scheduling, Workspaces, Monitoring, Routing) + │ + ▼ +domain/ (Pure Python: Entities, Immutable Execution Requests, Agent Events, Descriptors) + ▲ + │ +infrastructure/ (Adapters, LLM Providers, Atomic Persistence, Keyring SecretStore, MCP) +``` + +- **Domain & Application Layers**: 100% Pure Python (zero Qt/UI imports). +- **Single Responsibility**: Every production module is strictly `<= 400 LOC`. +- **Security & Durability**: API keys stored in OS Keyring; atomic JSON disk persistence. + +--- + +## 🚀 Quick Start + +### 1. Windows — two double-clicks + +``` +install.bat once, to install the Python dependencies +run.bat every time, to start the app +``` + +`install.bat` builds an isolated virtualenv under `%LOCALAPPDATA%\CoworkLocal` +(deliberately **outside** the repo — the quality gates walk the whole directory +tree, so a `.venv` in here would turn every vendored module into a Gate O +violation). Add `--dev` to also install the test dependencies, or `--system` to +skip the virtualenv and install into the Python already on `PATH`. + +Both scripts also make the source importable under its package name. That step +is not optional: `python -m cowork_local` only resolves when the checkout +directory is literally named `cowork_local`, and the MS365 MCP server is +launched as a subprocess with `python -m cowork_local.mcp_servers.ms365_server`, +so a differently-named checkout breaks the app *and* its subprocesses. The +scripts create a junction instead of forcing anyone to rename their folder. + +### 2. Any platform — run from source + +From the **parent** of a checkout directory named `cowork_local`: ```bash python -m cowork_local ``` -The source snapshot does not include a complete runtime dependency manifest. Use the Cowork Team's supported runtime environment until that packaging contract is documented. The reliable automated test surface currently checked by CI is: - +### 3. Run Automated Tests ```bash -python -m pip install -r cowork_local/requirements-test.txt -python -m pytest cowork_local/tests -q +python -m pip install -r requirements.txt +pytest -q ``` -When already inside this repository, run `python -m pytest tests -q`. +There is one requirements file, not a runtime/test pair. A separate test file +would hold only `pytest`: 64 of the 108 test modules build real widgets, and 20 +of them import PySide6 unguarded at module scope, so it would have to pull in +almost the whole runtime list anyway — two files for one near-identical list is +just a second place for the pins to drift. -Configuration and runtime data live under `~/.cowork_local/`. Provider keys and local unlock codes must be supplied through environment variables or an approved secret manager; see `.env.example`. +--- -## Contributing +## 🛡️ CASAN Quality Gate & Verification -Start with [START_CONTRIBUTING.md](START_CONTRIBUTING.md), then read [CONTRIBUTING.md](CONTRIBUTING.md). Core AI task execution remains in [fsg-ai-core-assets](http://34.143.229.138/gitea-admin/fsg-ai-core-assets); source changes are reviewed as Pull Requests in this repository. +Before submitting any Pull Request, run the unified CASAN Quality Gate: -Security concerns should follow [SECURITY.md](SECURITY.md). Ownership and completion rules are documented under `docs/governance/`. +```bash +# Run all 4 quality gates (Clean Arch, Secrets, LOC, and Pytest Suite) +python scripts/run_quality_gate.py + +# Run static and architectural guards only (fast check) +python scripts/run_quality_gate.py --skip-tests +``` + +Individual guard scripts: +- **Clean Architecture Import Guard**: `python scripts/check_imports.py` +- **Secrets & Plaintext Audit**: `python scripts/audit_security.py` +- **Single Responsibility LOC Guard**: `python scripts/check_loc.py --max-lines 400` +- **Release E2E Smoke Test**: `pytest tests/e2e/test_smoke.py -v` + +--- + +## 🤝 Contributing & Recipes + +- **Quick Start Guide**: See [START_CONTRIBUTING.md](START_CONTRIBUTING.md). +- **Contributor Recipes**: See [docs/governance/contributor-recipes.md](docs/governance/contributor-recipes.md) for step-by-step recipes to: + 1. Add a new AI Model Provider. + 2. Add a new Built-in Tool / MCP Server. + 3. Add a new Screen / Tab / Widget. +- **Security Policy**: See [SECURITY.md](SECURITY.md). diff --git a/START_CONTRIBUTING.md b/START_CONTRIBUTING.md index 3e7353c..c99f061 100644 --- a/START_CONTRIBUTING.md +++ b/START_CONTRIBUTING.md @@ -1,40 +1,64 @@ # Start Contributing -## What is this repository? +Welcome to the **Cowork Local** contributor guide! -Cowork Local is the Cowork Team's product/platform repository: desktop runtime, UI/UX, workspaces, agents, MCP/connectors, security, and reusable platform foundations. +--- -The Cowork Team owns architecture, product behavior, releases, the stable branch, final review, and merge. The FSG AI Core Team is a contributor for selected generic capabilities such as MCP integration, agent capabilities, orchestration/model-routing tests, evaluation/security integration, and reusable platform improvements. +## 🏛️ Architecture & Ground Rules -## Where are Core AI tasks? +1. **4-Tier Clean Architecture**: + - `domain/`: Business entities and immutable data structures (Pure Python). + - `application/`: Application services and orchestration (Pure Python). + - `infrastructure/`: External integrations, adapters, persistence, and secrets. + - `presentation/`: Desktop UI widgets, PySide6 components, and Qt signals. + - **Rule**: `domain/` and `application/` must NEVER import `PySide6` or any UI framework. -Use [fsg-ai-core-assets Issues/Project](http://34.143.229.138/gitea-admin/fsg-ai-core-assets) as the Core AI task source of truth. Pick and assign a contribution task there, then move it to `In Progress`. +2. **File Size Limit (LOC)**: + - Every file in `domain/`, `application/`, `infrastructure/`, and `presentation/` must be `<= 400 LOC`. -Do not copy the Core AI backlog, golden datasets, CASAN assets, agent catalog, or evaluation repository into Cowork Local. Only source/artifacts required by an agreed Cowork runtime contract belong here. +3. **In-Code Comments**: + - All code logic, error handling, and design rationales must be documented with clear **English comments**. -## Make the change +--- -Create a focused branch: +## 🚀 Development Workflow +### 1. Create a Topic Branch ```bash -git switch -c core-ai/TL-xxx-short-name +git switch -c feat/my-new-feature ``` -For Cowork-native work use `feat/`, `fix/`, `test/`, `docs/`, `perf/`, or `refactor/`. Keep one logical change in one Pull Request. +### 2. Implement Using Contributor Recipes +Follow the standardized recipes in [`docs/governance/contributor-recipes.md`](docs/governance/contributor-recipes.md): +- **Recipe 1**: Adding a new AI Model Provider. +- **Recipe 2**: Adding a new Tool or MCP Server. +- **Recipe 3**: Adding a new UI Screen or Widget. -Run the application from the parent directory with `python -m cowork_local`. Run the current automated test suite from this repository with: +### 3. Run CASAN Quality Gate Locally +Before committing and pushing your branch, ensure all quality gates pass: ```bash -python -m pip install -r requirements-test.txt -python -m pytest tests -q +python scripts/run_quality_gate.py ``` -Use environment variables for credentials; never commit `.env`, `~/.cowork_local/`, logs, customer data, or generated runtime files. +--- -## Review and completion +## 🧪 Testing Pyramid -Before opening a Pull Request, obtain Core AI pre-review and move the Core task to `Review`. Open the Pull Request in Cowork Local with the Core repository URL, issue, task ID, scope, validation evidence, and security impact. Then move the Core task to `Upstream Review`. +We maintain a strict multi-tier test pyramid: +- `tests/unit/`: Fast unit tests (no I/O, < 0.05s). +- `tests/contracts/`: Contract tests for Provider and Tool interfaces. +- `tests/integration/`: Component integration tests (Qt offscreen). +- `tests/e2e/`: End-to-End release smoke tests (`pytest tests/e2e/test_smoke.py`). +- `tests/fakes/`: Reusable in-memory test doubles (`FakeProvider`, `FakeToolRuntime`). -The Cowork Team may request changes or approve and merge. A Core AI task is `Done` only after the Cowork Pull Request is merged—not when implementation or Core AI review finishes. Record the Pull Request and merge reference in the Core issue. +--- -See [CONTRIBUTING.md](CONTRIBUTING.md) for conventions and `docs/governance/` for ownership, review, and Definition of Done. +## 📋 Definition of Done (DoD) + +A Pull Request is ready for merge only when: +- [x] All production files are `<= 400 LOC` (`python scripts/check_loc.py`). +- [x] Clean Architecture boundary check has 0 violations (`python scripts/check_imports.py`). +- [x] Secrets audit finds 0 plaintext credentials (`python scripts/audit_security.py`). +- [x] 100% of test suite passes without regressions (`pytest tests/`). +- [x] E2E release smoke tests pass (`pytest tests/e2e/test_smoke.py`). diff --git a/__main__.py b/__main__.py index 4c2c993..0f70253 100644 --- a/__main__.py +++ b/__main__.py @@ -17,6 +17,13 @@ def main() -> int: # a plain script (`python __main__.py`), `__package__` is empty so the # relative import fails — in that case put the package root (the parent # of this file's directory) on sys.path and use an absolute import. + """Điểm vào ``python -m cowork_local``. + + Import muộn để công cụ kiểu ``-h`` và test nạp được gói mà không phải dựng cả + ứng dụng Qt. Chạy như script thường (``python __main__.py``) thì + ``__package__`` rỗng nên import tương đối hỏng — lúc đó đưa thư mục cha vào + ``sys.path`` và dùng import tuyệt đối. + """ if __package__: from .app import run else: diff --git a/app.py b/app.py index 49c974b..1db3034 100644 --- a/app.py +++ b/app.py @@ -19,1261 +19,34 @@ from PySide6.QtWidgets import ( from . import APP_NAME, DISPLAY_NAME, __version__ from .config import PROVIDER_LABELS, AppConfig from .i18n import LANGUAGE_SHORT, LANGUAGES, get_language, on_language_changed, set_language, tr +from .presentation.shell.bootstrap import build_context +from .presentation.shell.branding import app_icon +from .presentation.shell.main_window import MainWindow + +# Các checker trong tools/ và mã cũ vẫn import mấy tên này từ cowork_local.app. +# Giữ đường vào cũ để việc bóc tách không kéo theo sửa 24 file checker; nơi ở +# thật của chúng nay là presentation/shell/. +from .presentation.shell.rail_metrics import ( # noqa: E402,F401 + _NAV_COLLAPSED_WIDTH, _NAV_EXPANDED_WIDTH, _NAV_MAX_CEILING, _NAV_MAX_SHARE, + _NAV_MIN_WIDTH, _NAV_ROW_GAP, _NAV_ROW_INSET, _NavItemDelegate, +) +from .presentation.shell.toast import Toast as _Toast # noqa: E402,F401 + +from .presentation.shell.lifecycle_coordinator import LifecycleCoordinator +from .presentation.shell.tray_manager import TrayManager from .state import AppContext from .ui.widgets import tidy_popup from .theme import current_palette, set_active_theme, stylesheet from .core.task_scheduler import TaskScheduler +from .presentation.dashboard.dashboard_tab import DashboardTab +from .presentation.graph.structure_graph_view import StructureGraphView +from .presentation.scheduling.schedule_task_tab import ScheduleTaskTab from .ui.cowork_tab import CoworkTab -from .ui.dashboard_tab import DashboardTab from .ui.monitoring_tab import MonitoringTab -from .ui.schedule_task_tab import ScheduleTaskTab from .ui.settings_dialog import SettingsDialog from .ui.sidebar import HistorySidebar -from .ui.structure_graph_view import StructureGraphView from .ui.workspace_tab import WorkspaceTab -ASSETS = Path(__file__).resolve().parent / "assets" - -# Nav rail (sidebar navigation) widths — expanded shows icon+label, collapsed -# shows icon-only (still fully clickable, just narrower). -_NAV_EXPANDED_WIDTH = 150 -_NAV_COLLAPSED_WIDTH = 54 -# The splitter between rail and content draws a drag handle. It only means -# something if the rail can actually take a width from it, so the expanded rail -# is a range rather than one number; long project and thread names in RECENTS -# are the reason someone would widen it. -# -# The ceiling is a SHARE of the window, not a pixel count: 360px is a quarter -# of a 1440 screen and more than a quarter of a 1280 one, where it left the -# seven Kanban lanes 920px of the 1067 they need. A share behaves the same on -# every monitor. -# Where a rail row starts, and how much air sits between its icon and its -# label. The tree rows get these from the style; anything laid out by hand -# beside them has to use the same two numbers or it will not line up. -_NAV_ROW_INSET = 4 -_NAV_ROW_GAP = 6 -_NAV_MIN_WIDTH = 132 -_NAV_MAX_SHARE = 0.22 -_NAV_MAX_CEILING = 360 - - -def app_icon() -> QIcon: - """The buffalo app icon, used everywhere (window title bar, Windows taskbar and - the tray). The multi-size ``.ico`` is loaded FIRST so Windows has the right - pixmap for the taskbar; the high-res ``.png`` is added so the icon stays crisp - at large sizes. This keeps the taskbar icon identical to the app's icon.""" - icon = QIcon() - for name in ("icon.ico", "icon.png"): - path = ASSETS / name - if path.exists(): - icon.addFile(str(path)) - return icon - - -class _Toast(QLabel): - """A small auto-hiding notification shown at the window's top-left.""" - - def __init__(self, parent): - super().__init__(parent) - self.setObjectName("toast") - self.setWordWrap(True) - self.setMaximumWidth(380) - self.setVisible(False) - self._timer = QTimer(self) - self._timer.setSingleShot(True) - self._timer.timeout.connect(self.hide) - - def show_message(self, text: str, ok: bool = True, ms: int = 4500) -> None: - p = current_palette() - bg = p.success_soft if ok else p.danger_soft - fg = p.success if ok else p.danger - self.setStyleSheet( - f"#toast {{ background:{bg}; color:{fg}; border:1px solid {fg};" - f" border-radius:{p.radius}px; padding:10px 16px; font-weight:600; }}") - self.setText(text) - self.adjustSize() - self.move(14, 14) # top-left of the window - self.raise_() - self.setVisible(True) - self._timer.start(ms) - - -class _NavItemDelegate(QStyledItemDelegate): - """Keep a rail row's icon on the left edge, whatever the column is doing. - - QStyledItemDelegate hands the style decorationAlignment = AlignHCenter, so - a row with no label — every row once the rail collapses to 54px — has its - icon centred inside whatever box the column happens to give it. That box - tracks the column width, which is not stable: stretched to the viewport the - icons land in the middle of the rail, while a column left wider than the - view leaves them at the left. Same code, two different pictures, which is - why a test render disagreed with the running app. - """ - - def initStyleOption(self, option, index): - super().initStyleOption(option, index) - option.decorationAlignment = Qt.AlignLeft | Qt.AlignVCenter - - -class MainWindow(QMainWindow): - # Nav rows (Dashboard/Schedule/Monitoring are lazy; Workspace is the eager home page). - _ROW_DASHBOARD, _ROW_SCHEDULE, _ROW_WORKSPACE, _ROW_MONITORING = 0, 1, 2, 3 - - def __init__(self, ctx: AppContext, user_name: str = ""): - super().__init__() - self.ctx = ctx - self._user_name = user_name - self._really_quit = False - self.tray = None - self._nav_collapsed = False # icon-only nav rail toggle (Task: collapsible nav) - self._history_collapsed = False # remembers History's own collapse-to-strip state - self.setWindowTitle(f"{DISPLAY_NAME} v{__version__}") - self.setWindowIcon(app_icon()) - # Fit to the available screen so the window never opens larger than the - # monitor (auto-fit). Keep a modest minimum that still fits small laptops. - self._fit_to_screen(1180, 760) - - self.sidebar = HistorySidebar(ctx) - # Task scheduler ENGINE runs in the background whether or not its Kanban - # UI (built lazily) is on screen — scheduled tasks must fire regardless. - self.task_scheduler = TaskScheduler(ctx, parent=self) - # Desktop notification when a scheduled task finishes; also refresh - # History — a cowork/code task run saves itself as a new session there. - self.task_scheduler.task_finished.connect(self._on_scheduled_task_done) - # NOTE: task_started fires BEFORE the worker thread even begins, so its - # session doesn't exist on disk yet — refreshing History here would - # find nothing. history_ready fires once the session is actually - # saved (right as the run starts, then again after each turn), which - # is what really makes a Running task's session show up live. - self.task_scheduler.history_ready.connect(lambda _tid: self._refresh_history()) - - # Cowork chat + GraphRAG view are embedded as sub-tabs INSIDE the - # Workspace screen (per selected project). GraphRAG's heavy - # QtWebEngine is still built lazily on first display - # (StructureGraphView._ensure_web). - self.cowork = CoworkTab(ctx) - self.structure = StructureGraphView(ctx) - self.structure.status_message.connect(self.statusBar().showMessage) - self.cowork.output_changed.connect(self.structure.schedule_rescan) - self.cowork.status_message.connect(self.statusBar().showMessage) - # Refresh History (list + running markers + current highlight) whenever a - # conversation is created/updated or a turn finishes. - self.cowork.turn_finished.connect(lambda *_: self._refresh_history()) - self.cowork.history_changed.connect(self._refresh_history) - self.cowork.turn_finished.connect( - lambda result: self._notify_task(self.cowork, "cowork", result)) - - # Workspace screen — the app HOME: project management + the per-project - # Cowork / GraphRAG sub-tabs and History. - self.workspace = WorkspaceTab(ctx, cowork=self.cowork, structure=self.structure, - sidebar=self.sidebar) - self.workspace.status_message.connect(self.statusBar().showMessage) - self.workspace.projects_changed.connect(self._on_projects_changed) - self.workspace.open_chat.connect(lambda *_: self._refresh_history()) - self.workspace.new_chat.connect(lambda *_: self._refresh_history()) - - # Dashboard + Schedule pages are built lazily on first visit (lazy page - # creation — keeps startup light); None until then. - self.dashboard = None - self.schedule = None - self.monitoring = None - - # --- right side: top bar + pages (nav rail drives the stack) --- - right = QWidget() - right.setObjectName("contentArea") - rlay = QVBoxLayout(right) - rlay.setContentsMargins(10, 10, 10, 10) - rlay.setSpacing(10) - rlay.addWidget(self._build_topbar()) - - self.pages = QStackedWidget() - # (i18n key, icon, builder-or-None, eager-widget-or-None) — page index == list index - self._nav_defs = [ - ("app.tab.dashboard", "dashboard", self._build_dashboard, None), - ("app.tab.schedule", "schedule", self._build_schedule, None), - ("app.tab.workspace", "workspaces", None, self.workspace), - ("app.tab.monitoring", "monitoring", self._build_monitoring, None), - ] - self._page_widgets = [] # page index → widget (placeholder until lazily built) - self._built = [] - for _key, _icon_name, _builder, widget in self._nav_defs: - page = widget if widget is not None else QWidget() - self.pages.addWidget(page) - self._page_widgets.append(page) - self._built.append(widget is not None) - - # Left nav rail — ONE FLAT LIST, no accordion. Every screen the user - # works in is one click away: the Workspace sub-views are listed - # directly instead of hiding behind an expandable parent. The two - # occasional admin destinations sit in a second, bottom-pinned list. - # - # Monitoring is the exception that keeps its sub-views OUT of the rail: - # it has eight, which would double the rail's length for screens opened - # once a week. Its own tab strip is left visible instead (it was hidden - # while the rail carried its children), so all eight stay reachable. - self.nav = self._new_nav_tree("navrail") - self.nav_bottom = self._new_nav_tree("navrailBottom") - self._nav_building = False # guards the rebuild → select → rebuild loop - self.workspace.hide_tab_bar() - self._rebuild_nav() - self.workspace.subtabs_changed.connect(self._rebuild_nav) - for tree in (self.nav, self.nav_bottom): - tree.currentItemChanged.connect( - lambda cur, _prev, t=tree: self._on_nav_current(t, cur)) - rlay.addWidget(self.pages, 1) - - # Nav rail wrapper: a small toggle button ABOVE the page list so the - # whole rail can collapse to icon-only (still fully clickable). Same - # collapse/expand chevron iconography as every other collapsible panel. - from .ui.icons import collapse_left_icon, collapse_right_icon - from .ui.icons import icon as _icon - self._collapse_left_icon = collapse_left_icon - self._collapse_right_icon = collapse_right_icon - self._nav_wrap = QWidget() - self._nav_wrap.setObjectName("navWrap") - self._nav_width = _NAV_EXPANDED_WIDTH # remembered across collapses - self._set_nav_width_range(_NAV_MIN_WIDTH, self._nav_max_width()) - nvl = QVBoxLayout(self._nav_wrap) - nvl.setContentsMargins(0, 0, 0, 0) - nvl.setSpacing(0) - # Small, left-aligned "MENU" button (icon + label) instead of a - # full-width centered icon — sits flush with the rail's left edge, - # matching how the nav items themselves align their icon+label. - self._nav_toggle_btn = QPushButton(tr("app.nav.menu_label")) - self._nav_toggle_btn.setIcon(collapse_left_icon()) - self._nav_toggle_btn.setObjectName("navMenuBtn") - self._nav_toggle_btn.setFlat(True) - self._nav_toggle_btn.setCursor(Qt.PointingHandCursor) - self._nav_toggle_btn.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) - self._nav_toggle_btn.clicked.connect(self._toggle_nav) - # Zero left margin: the button's own QSS padding (6px) then lines its - # 16px icon up with the nav items' icons below (1px list frame + item - # padding) — same indent level, same icon size as e.g. Dashboard. - toggle_row = QHBoxLayout() - toggle_row.setContentsMargins(0, 8, 10, 8) - toggle_row.addWidget(self._nav_toggle_btn, 0, Qt.AlignLeft) - toggle_row.addStretch(1) - nvl.addLayout(toggle_row) - # Primary action at the top of the rail, with the project it will land - # in named right above it. Before, starting a chat in another project - # meant leaving Cowork → Project tab → click a row → come back. - self.nav_project = QComboBox() - self.nav_project.setObjectName("navProjectPick") - self.nav_project.setToolTip(tr("app.nav.project_pick")) - self.nav_project.currentIndexChanged.connect(self._on_rail_project_pick) - tidy_popup(self.nav_project) - self.nav_new_chat = QPushButton(tr("cowork.new_chat")) - self.nav_new_chat.setObjectName("navNewChatBtn") - self.nav_new_chat.setIcon(_icon("plus")) - self.nav_new_chat.setCursor(Qt.PointingHandCursor) - self.nav_new_chat.clicked.connect(self._on_rail_new_chat) - # At 54px the picker cannot show a name, but dropping it altogether left - # the collapsed rail with no way to change project at all. This stands in - # for it: same list, same handler, just the folder icon and a tooltip. - self.nav_project_btn = QToolButton() - self.nav_project_btn.setObjectName("navProjectPickMini") - self.nav_project_btn.setIcon(_icon("folder")) - self.nav_project_btn.setCursor(Qt.PointingHandCursor) - self.nav_project_btn.setPopupMode(QToolButton.InstantPopup) - self.nav_project_btn.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - self.nav_project_btn.setMenu(QMenu(self.nav_project_btn)) - self.nav_project_btn.menu().aboutToShow.connect(self._fill_rail_project_menu) - self.nav_project_btn.setVisible(False) - head = QVBoxLayout() - head.setContentsMargins(6, 0, 6, 6) - head.setSpacing(6) - head.addWidget(self.nav_project) - head.addWidget(self.nav_project_btn) - head.addWidget(self.nav_new_chat) - nvl.addLayout(head) - self.workspace.project_selected.connect(self._sync_rail_project) - self.workspace.projects_changed.connect(self._sync_rail_project) - self._syncing_rail_project = False - self._sync_rail_project() - # The destinations and RECENTS scroll together; the bottom group, the - # Settings button and the account row stay pinned below them. - # - # Without this the rail simply ran out of room on a short window (a - # 1280×720 laptop leaves ~570px here): nav and the bottom group have - # fixed heights, so the squeeze fell entirely on RECENTS, and once that - # hit zero the layout drew the "GẦN ĐÂY" heading straight over the last - # nav row. - self._nav_scroll = QScrollArea() - self._nav_scroll.setObjectName("navScroll") - self._nav_scroll.setWidgetResizable(True) - self._nav_scroll.setFrameShape(QScrollArea.NoFrame) - self._nav_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - scroll_body = QWidget() - sv = QVBoxLayout(scroll_body) - sv.setContentsMargins(0, 0, 0, 0) - sv.setSpacing(0) - sv.addWidget(self.nav, 0) - # RECENTS — the threads of the project named in the picker above, right - # where Claude puts them. A shortcut only: the full History panel (search, - # filters, pin, bulk delete, context menu) stays exactly where it is, and - # "all projects…" at the end of this list opens it. - self.nav_recents_hdr = QLabel(tr("app.nav.recents")) - self.nav_recents_hdr.setObjectName("navSectionHdr") - sv.addWidget(self.nav_recents_hdr) - self.nav_recents = self._new_nav_tree("navRecents") - self.nav_recents.itemClicked.connect(self._on_rail_recent) - sv.addWidget(self.nav_recents, 1) - # Collapsing hides RECENTS, and with it the only item carrying a stretch - # factor. A box layout with nothing left to expand centres what remains, - # so the destinations dropped ~300px down the rail — "thu gọn menu lại - # ra giữa". This spacer takes the slack instead, and takes none of it - # while RECENTS is visible (stretch 0 against its 1). - sv.addStretch(0) - self._nav_scroll.setWidget(scroll_body) - nvl.addWidget(self._nav_scroll, 1) - # Bottom-pinned group: the places you visit occasionally, kept out of the - # way of the ones you live in. A hairline (styled via #navrailBottom in - # theme.py) separates the two lists. - nvl.addWidget(self.nav_bottom, 0) - # Settings reads as one more row under Dashboard / Giám sát, so its icon - # and label must start exactly where theirs do. Letting QPushButton place - # them does not achieve that: the gap it leaves between icon and text is - # the platform style's, and on macOS it is visibly tighter than the tree - # rows above — a Windows-tuned nudge only moved the mismatch. So the row - # is laid out here, in the same two numbers the tree uses: 4px in, 6px - # between. - self._nav_settings_btn = QPushButton() - self._nav_settings_btn.setObjectName("navSettingsBtn") - self._nav_settings_btn.setFlat(True) - self._nav_settings_btn.setCursor(Qt.PointingHandCursor) - self._nav_settings_btn.clicked.connect(self._open_settings) - srow = QHBoxLayout(self._nav_settings_btn) - srow.setContentsMargins(_NAV_ROW_INSET, 6, 8, 6) - srow.setSpacing(_NAV_ROW_GAP) - self._nav_settings_icon = QLabel() - self._nav_settings_icon.setPixmap(_icon("settings").pixmap(16, 16)) - self._nav_settings_icon.setFixedSize(16, 16) - self._nav_settings_text = QLabel(tr("app.settings")) - srow.addWidget(self._nav_settings_icon) - srow.addWidget(self._nav_settings_text) - srow.addStretch(1) - nvl.addWidget(self._nav_settings_btn) - self._account_row = self._build_account_row() - nvl.addWidget(self._account_row) - - self.split = QSplitter(Qt.Horizontal) - self.split.addWidget(self._nav_wrap) - self.split.addWidget(right) - self.split.setStretchFactor(0, 0) - self.split.setStretchFactor(1, 1) - self.split.setSizes([_NAV_EXPANDED_WIDTH, 1000]) - self.split.splitterMoved.connect(self._on_split_moved) - self.setCentralWidget(self.split) - # Landing stays Workspace ▸ Project, exactly as before. Go through _goto - # so the page is actually shown — selecting the row alone only moves the - # highlight (its signals are blocked to avoid rebuild loops). - self._goto(self._ROW_WORKSPACE, self.workspace.current_subtab()) - self.toast = _Toast(self) # top-left "task done" popup - # Floating in-app Help assistant — a robot icon pinned bottom-right on - # every screen; expands into a small help-only chat (see - # ui/help_agent_widget.py). Managed in Monitoring → Agents Admin. - from .ui.help_agent_widget import HelpAgentWidget - self.help_agent = HelpAgentWidget(ctx, self, user_name=self._user_name) - self.help_agent.status_message.connect(self.statusBar().showMessage) - - self.statusBar().showMessage(tr("app.status.ready")) - # Author credit, pinned to the bottom-right corner. A permanent status-bar - # widget sits at the right end and is never cleared by showMessage (which - # writes on the left). - self._credit = QLabel(tr("app.credit")) - self._credit.setObjectName("faint") - self._credit.setStyleSheet("padding: 0 10px;") - self.statusBar().addPermanentWidget(self._credit) - self._restore_sessions() - self._setup_tray() - # Start the task scheduler last, once the whole window exists — it - # catches up any overdue tasks right away (first tick runs inline). - self.task_scheduler.start() - # Auto Model Routing: periodic reassess + pending-switch expiry. Runs - # background probes only when genuinely due (never a burst at launch). - try: - from .core.routing.scheduler import RoutingScheduler - self.routing_scheduler = RoutingScheduler(self.ctx, self.ctx.routing(), parent=self) - self.routing_scheduler.start() - except Exception: # noqa: BLE001 — routing must never block app startup - self.routing_scheduler = None - on_language_changed(self._retranslate) - - def resizeEvent(self, event): # noqa: N802 - Qt override - super().resizeEvent(event) - # The rail's ceiling is a share of the window, so it moves with the - # window. Computed once at construction it was read off a not-yet-sized - # window and stuck at 162px on every monitor. - if getattr(self, "_nav_wrap", None) is not None and not self._nav_collapsed: - self._set_nav_width_range(_NAV_MIN_WIDTH, self._nav_max_width()) - # Keep the floating Help assistant pinned to the bottom-right corner. - if getattr(self, "help_agent", None) is not None: - self.help_agent.reposition() - - def showEvent(self, event): # noqa: N802 - Qt override - super().showEvent(event) - if getattr(self, "help_agent", None) is not None: - self._update_dock_guard() - self.help_agent.reposition() - self.help_agent.raise_() - # Build GraphRAG's browser view and first graph once the window is up - # and idle, so clicking GraphRAG does not sit on an empty view while - # both happen. 3s is after the first paint and any startup refresh. - if not getattr(self, "_graph_prewarmed", False): - self._graph_prewarmed = True - QTimer.singleShot(3000, self._prewarm_graph) - - def _prewarm_graph(self) -> None: - view = getattr(self, "structure", None) - if view is None or not hasattr(view, "prewarm"): - return - try: - view.prewarm() - except Exception: # noqa: BLE001 — a warm-up must never break the app - pass - - # ---- i18n ---------------------------------------------------------- - def _retranslate(self) -> None: - """Re-apply the current language to this window's own static chrome - (tabs are the only long-lived text here; the tabs/dialogs retranslate - themselves).""" - self._apply_nav_labels() - self._nav_toggle_btn.setText("" if self._nav_collapsed else tr("app.nav.menu_label")) - self._nav_toggle_btn.setToolTip( - tr("app.nav.expand_tooltip") if self._nav_collapsed else tr("app.nav.collapse_tooltip")) - self._credit.setText(tr("app.credit")) - if hasattr(self, "provider_lbl"): - self.provider_lbl.setText(tr("app.provider")) - if hasattr(self, "settings_btn"): - self.settings_btn.setText(tr("app.settings")) - if hasattr(self, "theme_btn"): - self.theme_btn.setToolTip(tr("settings.theme")) - for value, act in self._theme_actions.items(): - act.setText(tr(f"settings.theme_{value}")) - if hasattr(self, "logo_lbl"): - self.logo_lbl.setText(tr("app.logo")) - if getattr(self, "help_agent", None) is not None: - self.help_agent.retranslate() - if self.tray is not None: - self.tray.setToolTip(DISPLAY_NAME) - if hasattr(self, "_tray_open_act"): - self._tray_open_act.setText(tr("app.tray.open")) - self._tray_quit_act.setText(tr("app.tray.quit")) - - # ---- system tray (run in background when the window is closed) --- - def _setup_tray(self) -> None: - from PySide6.QtGui import QAction - - if not QSystemTrayIcon.isSystemTrayAvailable(): - return - self.tray = QSystemTrayIcon(app_icon(), self) - self.tray.setToolTip(DISPLAY_NAME) - menu = QMenu() - self._tray_open_act = QAction(tr("app.tray.open"), self) - self._tray_open_act.triggered.connect(self._show_window) - self._tray_quit_act = QAction(tr("app.tray.quit"), self) - self._tray_quit_act.triggered.connect(self._quit_app) - menu.addAction(self._tray_open_act) - menu.addAction(self._tray_quit_act) - self.tray.setContextMenu(menu) - self.tray.activated.connect( - lambda reason: self._show_window() if reason == QSystemTrayIcon.Trigger else None) - self.tray.show() - - def _page_index(self, widget) -> int: - return self.pages.indexOf(widget) - - # ---- lazy page building ------------------------------------------- - def _build_dashboard(self): - d = DashboardTab(self.ctx) - d.status_message.connect(self.statusBar().showMessage) - self.dashboard = d - return d - - def _build_schedule(self): - s = ScheduleTaskTab(self.ctx, self.task_scheduler) - s.status_message.connect(self.statusBar().showMessage) - self.schedule = s - return s - - def _build_monitoring(self): - m = MonitoringTab(self.ctx, cowork=self.cowork, structure=self.structure, - task_scheduler=self.task_scheduler) - m.status_message.connect(self.statusBar().showMessage) - self.monitoring = m - return m - - def _ensure_page(self, row: int) -> None: - """Build a lazy nav page on first visit and swap it in for its placeholder.""" - if not (0 <= row < len(self._built)) or self._built[row]: - return - builder = self._nav_defs[row][2] - if builder is None: - return - real = builder() - placeholder = self._page_widgets[row] - self.pages.insertWidget(row, real) # placeholder shifts to row+1 - self.pages.removeWidget(placeholder) - placeholder.deleteLater() - self._page_widgets[row] = real - self._built[row] = True - # Monitoring KEEPS its own tab strip: its eight sub-views live in the - # page, not in the rail. Workspace is the one that hides its strip, - # because the rail lists its sub-views directly. - - def _page_index(self, widget) -> int: - if widget is self.workspace: - return self._ROW_WORKSPACE - if self.dashboard is not None and widget is self.dashboard: - return self._ROW_DASHBOARD - if self.schedule is not None and widget is self.schedule: - return self._ROW_SCHEDULE - if self.monitoring is not None and widget is self.monitoring: - return self._ROW_MONITORING - return self.pages.indexOf(widget) - - # ---- flat nav rail ------------------------------------------------- - def _new_nav_tree(self, name: str) -> QTreeWidget: - """One flat, single-column list. No indentation and no expand arrows — - every row is a destination, nothing is a container.""" - tree = QTreeWidget() - tree.setObjectName(name) - tree.setHeaderHidden(True) - tree.setIndentation(0) - tree.setRootIsDecorated(False) - tree.setUniformRowHeights(True) - # The column follows the viewport instead of the widest label. Left - # to size itself it stayed ~100px wide inside the 54px collapsed - # rail, so a horizontal scrollbar appeared and slid the icons out of - # the position they hold while the rail is open. - from PySide6.QtWidgets import QHeaderView - tree.header().setSectionResizeMode(0, QHeaderView.Stretch) - tree.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - tree.setItemDelegate(_NavItemDelegate(tree)) - return tree - - def _nav_rows(self): - """(tree, page, sub, label, icon, enabled) for every row, rail order. - - Workspace contributes all five of its sub-views — including the two the - project gate currently disables — so the rail never changes shape while - the user is looking at it. - """ - rows = [(self.nav, self._ROW_WORKSPACE, sub, label, ic, on) - for label, sub, ic, on in self.workspace.nav_entries()] - rows.append((self.nav, self._ROW_SCHEDULE, None, - tr("app.tab.schedule"), "schedule", True)) - rows.append((self.nav_bottom, self._ROW_DASHBOARD, None, - tr("app.tab.dashboard"), "dashboard", True)) - rows.append((self.nav_bottom, self._ROW_MONITORING, None, - tr("app.tab.monitoring"), "monitoring", True)) - return rows - - def _rebuild_nav(self, force: bool = False) -> None: - """Re-fill both lists from _nav_rows(), keeping the current selection. - - Rebuilding changes the current item, which would fire navigation and can - loop back here via subtabs_changed — hence the guard and the blocked - signals. - """ - if self._nav_building: - return - spec = self._nav_rows() - # Rebuilding deletes the QTreeWidgetItems, including the one a signal is - # currently being delivered for. subtabs_changed fires on every visit to - # Workspace, so skip the rebuild unless the rows really differ. - sig = [(label, page, sub, enabled) - for _t, page, sub, label, _ic, enabled in spec] - if not force and sig == getattr(self, "_nav_sig", None): - return - self._nav_sig = sig - self._nav_building = True - try: - from .ui.icons import icon as _icon - keep = self._current_nav_key() - for tree in (self.nav, self.nav_bottom): - blocked = tree.blockSignals(True) - tree.clear() - tree.blockSignals(blocked) - for tree, page, sub, label, icon_name, enabled in spec: - it = QTreeWidgetItem([""] if self._nav_collapsed else [label]) - it.setIcon(0, _icon(icon_name)) - it.setData(0, Qt.UserRole, {"page": page, "sub": sub}) - if not enabled: - # Same gate as before, shown instead of hidden: the row stays - # in place, greyed, and says why it cannot be opened. - it.setDisabled(True) - it.setToolTip(0, tr("app.nav.needs_project")) - elif self._nav_collapsed: - it.setToolTip(0, label) - blocked = tree.blockSignals(True) - tree.addTopLevelItem(it) - tree.blockSignals(blocked) - # Both destination lists are exactly as tall as their rows; the - # stretch in between belongs to RECENTS. - for tree in (self.nav, self.nav_bottom): - n = tree.topLevelItemCount() - row_h = tree.sizeHintForRow(0) if n else 0 - tree.setFixedHeight(n * row_h + 8) - if keep: - self._select_nav_row(*keep) - finally: - self._nav_building = False - - def _current_nav_key(self): - """(page, sub) of the highlighted row, or None.""" - for tree in (self.nav, self.nav_bottom): - it = tree.currentItem() - if it is not None and it.isSelected(): - data = it.data(0, Qt.UserRole) or {} - if "page" in data: - return data["page"], data.get("sub") - return None - - def _select_nav_row(self, page: int, sub) -> None: - """Highlight the row for (page, sub) without triggering navigation. - - Called both when the user clicks (to keep the two lists mutually - exclusive) and from _goto, so programmatic navigation moves the - highlight too — it used to stay behind on whatever was clicked last. - """ - for tree in (self.nav, self.nav_bottom): - blocked = tree.blockSignals(True) - match = None - for i in range(tree.topLevelItemCount()): - it = tree.topLevelItem(i) - data = it.data(0, Qt.UserRole) or {} - if data.get("page") == page and ( - data.get("sub") == sub or data.get("sub") is None): - match = it - break - if match is not None: - tree.setCurrentItem(match) - else: - tree.setCurrentItem(None) - tree.clearSelection() - tree.blockSignals(blocked) - - def _on_nav_current(self, tree: QTreeWidget, item) -> None: - """A row was picked: clear the other list so only one row looks active.""" - if item is None or self._nav_building: - return - data = item.data(0, Qt.UserRole) or {} - other = self.nav_bottom if tree is self.nav else self.nav - blocked = other.blockSignals(True) - other.setCurrentItem(None) - other.clearSelection() - other.blockSignals(blocked) - self._goto(data.get("page", 0), data.get("sub")) - - # ---- rail header: project picker + new chat ------------------------ - def _sync_rail_project(self, *_a) -> None: - """Mirror the workspace's project list/selection into the rail picker. - - One-way on purpose: the project list stays the source of truth, this is - only a second place to see and change it. - """ - if self._syncing_rail_project: - return - self._syncing_rail_project = True - try: - choices = self.workspace.project_choices() - current = self.workspace.selected_project_id() - self.nav_project.clear() - for name, pid in choices: - self.nav_project.addItem(f"📁 {name}", pid) - if not choices: - # No project yet: say so, and say what to do about it, instead of - # leaving an empty box and a button that silently does nothing. - self.nav_project.addItem(tr("app.nav.no_project"), "") - idx = self.nav_project.findData(current) - if idx >= 0: - self.nav_project.setCurrentIndex(idx) - has = bool(choices) - tidy_popup(self.nav_project) - self.nav_project.setEnabled(has) - self.nav_project_btn.setEnabled(has) - self.nav_project_btn.setToolTip( - self.nav_project.currentText().replace("📁 ", "") - if has else tr("app.nav.create_project_first")) - self.nav_new_chat.setEnabled(has) - self.nav_new_chat.setToolTip( - "" if has else tr("app.nav.create_project_first")) - finally: - self._syncing_rail_project = False - - def _fill_rail_project_menu(self) -> None: - """Mirror the picker's items. Choosing one moves the picker, which runs - _on_rail_project_pick — the collapsed rail adds no second code path.""" - menu = self.nav_project_btn.menu() - menu.clear() - for i in range(self.nav_project.count()): - act = menu.addAction(self.nav_project.itemText(i)) - act.setCheckable(True) - act.setChecked(i == self.nav_project.currentIndex()) - act.triggered.connect( - lambda _checked=False, row=i: self.nav_project.setCurrentIndex(row)) - - def _on_rail_project_pick(self, _idx: int) -> None: - if self._syncing_rail_project: - return - pid = self.nav_project.currentData() - if pid: - self.workspace.choose_project(pid) - - # ---- rail RECENTS -------------------------------------------------- - _RAIL_RECENTS = 5 - - def _refresh_rail_recents(self) -> None: - """Re-fill the rail's recents from the active project's history.""" - from .ui.icons import DOT_BLUE, dot_icon - from .ui.icons import icon as _icon - - tree = self.nav_recents - blocked = tree.blockSignals(True) - tree.clear() - running = self._running_session_ids() - threads = self.workspace.recent_threads(self._RAIL_RECENTS) - for t in threads: - it = QTreeWidgetItem([t["title"]]) - it.setToolTip(0, t["title"]) - if t["session_id"] in running: - it.setIcon(0, dot_icon(DOT_BLUE)) # same marker as History - elif t["pinned"]: - it.setIcon(0, _icon("pin")) - it.setData(0, Qt.UserRole, {"path": t["path"], "kind": t["kind"]}) - tree.addTopLevelItem(it) - if not threads: - it = QTreeWidgetItem([tr("sidebar.empty")]) - it.setDisabled(True) - tree.addTopLevelItem(it) - # The way back to everything the rail cannot show — styled as a link - # (italic, accent-colored) so it reads as "go elsewhere", not another row. - more = QTreeWidgetItem([tr("app.nav.all_projects")]) - more.setData(0, Qt.UserRole, {"all": True}) - more_font = more.font(0) - more_font.setItalic(True) - more.setFont(0, more_font) - more.setForeground(0, QColor(current_palette().accent)) - tree.addTopLevelItem(more) - tree.blockSignals(blocked) - self.nav_recents_hdr.setVisible(not self._nav_collapsed) - self.nav_recents.setVisible(not self._nav_collapsed) - - def _on_rail_recent(self, item, _col: int = 0) -> None: - data = item.data(0, Qt.UserRole) or {} - if data.get("all"): - self._goto(self._ROW_WORKSPACE, self.workspace._cowork_tab_idx) - self.workspace.show_history_pane() - return - path = data.get("path") - if path: - self._goto(self._ROW_WORKSPACE, self.workspace._cowork_tab_idx) - self.workspace.open_thread(path, data.get("kind", "cowork")) - - def _on_rail_new_chat(self) -> None: - """Start a new chat, from any screen. - - Same call the Cowork toolbar button makes — that button stays exactly - where it was; this is a second entry point, not a replacement. - """ - self._goto(self._ROW_WORKSPACE, None) - self.workspace.start_new_chat() - self._select_nav_row(self._ROW_WORKSPACE, self.workspace.current_subtab()) - - def _apply_nav_labels(self) -> None: - """Re-label every row for the current language and collapse state - (collapsed = icon only, label moves to the tooltip).""" - # force: collapsing leaves the row spec identical, only the text changes. - self._rebuild_nav(force=True) - self._nav_settings_text.setText(tr("app.settings")) - self._nav_settings_text.setVisible(not self._nav_collapsed) - self._nav_settings_btn.setToolTip(tr("app.settings")) - # Collapsed to 54px there is no room for either control's label; the - # picker would be a stub of a name, so it steps aside entirely and the - # button keeps just its + icon. - self.nav_project.setVisible(not self._nav_collapsed) - self.nav_project_btn.setVisible(self._nav_collapsed) - self._refresh_rail_recents() - # Collapsed to 54px only the theme toggle still fits; the rest of the - # account row would be clipped, so it steps aside (Settings, which opens - # the same values in a dialog, stays reachable as an icon). - self.account_lbl.setVisible(not self._nav_collapsed) - self.language_combo.setVisible(not self._nav_collapsed) - self.provider_combo.setVisible(not self._nav_collapsed) - self.nav_new_chat.setText("" if self._nav_collapsed else tr("cowork.new_chat")) - if self._nav_new_chat_enabled(): - self.nav_new_chat.setToolTip( - tr("cowork.new_chat") if self._nav_collapsed else "") - self._sync_rail_project() - - def _nav_new_chat_enabled(self) -> bool: - return bool(self.workspace.project_choices()) - - def _nav_max_width(self) -> int: - """The rail's ceiling for THIS window, as a share of it.""" - return max(_NAV_MIN_WIDTH, - min(_NAV_MAX_CEILING, int(self.width() * _NAV_MAX_SHARE))) - - def _set_nav_width_range(self, lo: int, hi: int) -> None: - """setFixedWidth would leave the splitter handle inert — visible, and - doing nothing when dragged.""" - self._nav_wrap.setMinimumWidth(lo) - self._nav_wrap.setMaximumWidth(hi) - - def _on_split_moved(self, _pos: int, _index: int) -> None: - if not self._nav_collapsed: - self._nav_width = max(_NAV_MIN_WIDTH, - min(self._nav_max_width(), self._nav_wrap.width())) - - def _toggle_nav(self) -> None: - if not self._nav_collapsed: - self._nav_width = max(_NAV_MIN_WIDTH, - min(self._nav_max_width(), self._nav_wrap.width())) - self._nav_collapsed = not self._nav_collapsed - if self._nav_collapsed: - width = _NAV_COLLAPSED_WIDTH - self._set_nav_width_range(width, width) - else: - width = self._nav_width - self._set_nav_width_range(_NAV_MIN_WIDTH, self._nav_max_width()) - self._apply_nav_labels() - # Same chevron convention as every other collapsible panel: right- - # pointing (fill-right) means "click to expand", left means "collapse". - self._nav_toggle_btn.setIcon( - self._collapse_right_icon() if self._nav_collapsed else self._collapse_left_icon()) - # Collapsed rail is icon-only (54px) — the "MENU" label wouldn't fit - # next to the icon, same rule the nav items themselves follow. - self._nav_toggle_btn.setText("" if self._nav_collapsed else tr("app.nav.menu_label")) - self._nav_toggle_btn.setToolTip( - tr("app.nav.expand_tooltip") if self._nav_collapsed else tr("app.nav.collapse_tooltip")) - # Give/reclaim the width difference to the main content pane. - sizes = self.split.sizes() - if len(sizes) == 2: - diff = sizes[0] - width - sizes[0] = width - sizes[1] = max(1, sizes[1] + diff) - self.split.setSizes(sizes) - - def _running_session_ids(self): - """All conversation ids currently running — interactive Cowork/Code - chat tab AgentWorkers, plus Schedule Task runs (their own session, - tracked by the scheduler), so a task's live run gets the same - "running" marker in History an interactive chat gets.""" - return set(self.cowork.running_session_ids()) | self.task_scheduler.running_session_ids() - - def _refresh_history(self) -> None: - """Rebuild the History list with the current conversation highlighted and - the running ones marked. Deferred to the next event-loop tick: this is often - triggered (via load_conversation) from inside the sidebar's own item-click - handler, and clearing the tree there would delete the item mid-click.""" - from PySide6.QtCore import QTimer - - def _do() -> None: - current = self.cowork.session_id - self.sidebar.set_view_state(current, self._running_session_ids()) - self.sidebar.refresh() - self._refresh_rail_recents() # the rail shortcut follows the panel - - QTimer.singleShot(0, _do) - - def _on_scheduled_task_done(self, task_id: str, ok: bool) -> None: - """Desktop notification for a finished scheduled task (toast always, - tray balloon when the window isn't focused), then refresh History — - cowork/co4e task runs just saved themselves as new sessions there.""" - from .core.tasks import load_task - - task = load_task(task_id) or {} - title = task.get("title", "") - msg = (tr("app.toast.task_done", title=title) if ok - else tr("app.toast.task_failed", title=title)) - self.toast.show_message(msg, ok=ok) - if (self.tray is not None - and self.ctx.config.data.get("tray", {}).get("notify_on_done", True) - and not self.isActiveWindow()): - try: - self.tray.showMessage( - DISPLAY_NAME, msg, - QSystemTrayIcon.Information if ok else QSystemTrayIcon.Warning, 5000) - except Exception: # noqa: BLE001 - pass - self._refresh_history() - - def _notify_task(self, tab, kind: str, result: dict) -> None: - """Notify when a task finishes/fails (skip if more stages queued).""" - if tab.composer.has_queue(): - return # a flow / queue is still running — notify only at the end - name = tr(f"app.tab.{kind}") - err = (result or {}).get("error") - # In-app popup at the top-left (shown whether or not the window is focused). - self.toast.show_message( - tr("app.toast.error", name=name) if err else tr("app.toast.done", name=name), ok=not err) - # System-tray balloon only when the window isn't the active one. - if self.tray is None: - return - if not self.ctx.config.data.get("tray", {}).get("notify_on_done", True): - return - if self.isActiveWindow(): - return # user is looking at the window already - err = (result or {}).get("error") - title = tr("app.toast.error", name=name) if err else tr("app.toast.done", name=name) - body = (err if err else (tab._last_assistant_text() or "Task completed."))[:140] - icon = QSystemTrayIcon.Critical if err else QSystemTrayIcon.Information - try: - self.tray.showMessage(title, body, icon, 5000) - except Exception: - pass - - def _show_window(self) -> None: - self.showNormal() - self.raise_() - self.activateWindow() - - def _quit_app(self) -> None: - self._really_quit = True - self.close() - - def _restore_sessions(self) -> None: - """Reopen the last conversation per tab (recover after a crash/abrupt exit).""" - from pathlib import Path - - from .core.history import load_conversation - - last = self.ctx.config.data.get("last_session", {}) - path = last.get("cowork", "") - if path and Path(path).exists(): - try: - self.cowork.load_conversation(load_conversation(path)) - # Reflect the restored thread's project in the Workspace home - # (selecting the matching row won't wipe it — the project id - # already matches, so _bind_project starts no new session). - # Skip forcing the Cowork tab open for a project that no - # longer exists (deleted since this session was saved) — that - # would show the Cowork page while the tab strip still says - # "no project selected" (see WorkspaceTab._on_sidebar_open). - pid = self.cowork.project_id - if pid in ("", "default") or self.workspace._select_project_row(pid): - self.workspace._show_cowork_tab() - except Exception: - pass - - # ---- top bar ----------------------------------------------------- - def _build_topbar(self) -> QWidget: - bar = QWidget() - bar.setObjectName("topbar") - # Styled centrally (see theme._TEMPLATE): flat, with a single hairline - # separating it from the content below — no card box behind it. - h = QHBoxLayout(bar) - h.setContentsMargins(16, 10, 12, 10) - h.setSpacing(10) - # FPT logo slot in front of the brand text: shown only when a logo - # image has been dropped into assets/ (see _brand_logo_pixmap) — the - # brand works text-only until the real artwork is supplied. - self.logo_img = QLabel() - logo_pm = self._brand_logo_pixmap() - if logo_pm is not None: - self.logo_img.setPixmap(logo_pm) - else: - self.logo_img.setVisible(False) - h.addWidget(self.logo_img) - self.logo_lbl = QLabel(tr("app.logo")) - self.logo_lbl.setObjectName("brand") # styled centrally — see theme._TEMPLATE - h.addWidget(self.logo_lbl) - h.addStretch(1) - # Provider / language / theme / Settings used to live here, five controls - # wide across the top of every screen. They are per-account settings, not - # per-screen ones, so they moved to the account row at the foot of the - # rail (_build_account_row) — same widgets, same handlers, new home. - return bar - - def _build_account_row(self) -> QWidget: - """The rail's foot: who you are, and the settings that follow you. - - Nothing new is introduced here — these are the exact widgets the top bar - used to hold, moved as-is so every existing signal still lands. - """ - box = QWidget() - box.setObjectName("navAccount") - v = QVBoxLayout(box) - v.setContentsMargins(6, 4, 6, 4) - v.setSpacing(4) - - who = QHBoxLayout() - who.setSpacing(4) - self.account_lbl = QLabel(f"👤 {self._user_name}" if self._user_name else "👤") - self.account_lbl.setObjectName("hint") - who.addWidget(self.account_lbl, 1) - self.language_combo = QComboBox() - for key in LANGUAGES: - self.language_combo.addItem(LANGUAGE_SHORT.get(key, key.upper()), key) - self.language_combo.setItemData( - self.language_combo.count() - 1, LANGUAGES[key], Qt.ToolTipRole) - idx = self.language_combo.findData(get_language()) - if idx >= 0: - self.language_combo.setCurrentIndex(idx) - tidy_popup(self.language_combo) - self.language_combo.currentIndexChanged.connect(self._on_language_changed) - who.addWidget(self.language_combo) - self.theme_btn = self._build_theme_button() - who.addWidget(self.theme_btn) - v.addLayout(who) - - self.provider_lbl = QLabel(tr("app.provider")) - self.provider_lbl.setObjectName("hint") - self.provider_lbl.setVisible(False) # the combo names itself in the rail - self.provider_combo = QComboBox() - self.provider_combo.setToolTip(tr("app.provider")) - for key, label in PROVIDER_LABELS.items(): - self.provider_combo.addItem(label, key) - tidy_popup(self.provider_combo) - idx = self.provider_combo.findData(self.ctx.config.active_provider) - if idx >= 0: - self.provider_combo.setCurrentIndex(idx) - self.provider_combo.currentIndexChanged.connect(self._on_provider_changed) - v.addWidget(self.provider_lbl) - v.addWidget(self.provider_combo) - return box - - _BRAND_LOGO_NAMES = ("fpt_logo.png", "fpt-logo.png", "logo_fpt.png", "fpt_logo.jpg") - _BRAND_LOGO_HEIGHT = 22 - - def _brand_logo_pixmap(self): - """The FPT logo scaled to top-bar height, or None while no logo file - exists yet — drop the artwork into src/cowork_local/assets/ under one - of the _BRAND_LOGO_NAMES and it appears on next launch.""" - from PySide6.QtGui import QPixmap - - for name in self._BRAND_LOGO_NAMES: - path = ASSETS / name - if not path.exists(): - continue - pm = QPixmap(str(path)) - if pm.isNull(): - continue - return pm.scaledToHeight(self._BRAND_LOGO_HEIGHT, Qt.SmoothTransformation) - return None - - _THEME_ICONS = {"system": "monitor", "dark": "moon", "light": "sun"} - - def _build_theme_button(self) -> QToolButton: - """A single icon button (System/Dark/Light) replacing the old - Settings-only theme dropdown — one click applies the choice - immediately via the existing _apply_theme(), no dialog round-trip.""" - from .ui.icons import icon as _icon - - btn = QToolButton() - btn.setPopupMode(QToolButton.InstantPopup) - menu = QMenu(btn) - self._theme_actions = {} - for value, icon_name in self._THEME_ICONS.items(): - act = menu.addAction(_icon(icon_name), tr(f"settings.theme_{value}")) - act.triggered.connect(lambda _checked=False, v=value: self._set_theme(v)) - self._theme_actions[value] = act - btn.setMenu(menu) - btn.setIcon(_icon(self._THEME_ICONS.get(self.ctx.config.theme, "monitor"))) - return btn - - def _set_theme(self, value: str) -> None: - from .ui.icons import icon as _icon - - self.ctx.config.theme = value - self.ctx.save() - self._apply_theme() - self.theme_btn.setIcon(_icon(self._THEME_ICONS.get(value, "monitor"))) - - # ---- handlers ---------------------------------------------------- - def _on_provider_changed(self, _idx: int) -> None: - self.ctx.config.active_provider = self.provider_combo.currentData() - self.ctx.save() - self.cowork.refresh_header() - # Reload the Cowork tab's Agent (Model) list for the newly selected provider. - self.cowork.refresh_agents() - self.workspace.refresh_ai_models() # + the Folder AI-edit model picker - self.statusBar().showMessage( - tr("app.status.using_provider", - label=PROVIDER_LABELS.get(self.ctx.config.active_provider)) - ) - - def _on_language_changed(self, _idx: int) -> None: - lang = self.language_combo.currentData() - if not lang or lang == get_language(): - return - self.ctx.config.language = lang - self.ctx.save() - set_language(lang) # notifies every registered persistent widget - - def _open_settings(self) -> None: - dlg = SettingsDialog(self.ctx, self) - if dlg.exec(): - self._apply_theme() - # Settings can change the theme too — keep the rail's toggle icon - # showing the value that is actually in effect. - from .ui.icons import icon as _theme_icon - self.theme_btn.setIcon( - _theme_icon(self._THEME_ICONS.get(self.ctx.config.theme, "monitor"))) - set_language(self.ctx.config.language) # apply if changed in Settings - # reflect provider/theme/language changes - i = self.provider_combo.findData(self.ctx.config.active_provider) - if i >= 0: - self.provider_combo.setCurrentIndex(i) - li = self.language_combo.findData(get_language()) - if li >= 0: - self.language_combo.blockSignals(True) - self.language_combo.setCurrentIndex(li) - self.language_combo.blockSignals(False) - self.cowork.refresh_header() - self.cowork.refresh_agents() - self.workspace.refresh_ai_models() # + the Folder AI-edit model picker - max_files = int(self.ctx.config.data.get("attachments", {}).get("max_files", 10) or 0) - self.cowork.composer.set_max_attachments(max_files) - self.sidebar.refresh() - self.statusBar().showMessage(tr("app.status.settings_saved")) - - def _goto(self, page: int, sub) -> None: - self._ensure_page(page) # build lazy page on first visit - self.pages.setCurrentIndex(page) - if page == self._ROW_WORKSPACE: - self.workspace.refresh() # re-list projects + threads on entry - widget = self._page_widgets[page] - if sub is not None and hasattr(widget, "select_subtab"): - # Enforce the project gate here rather than at each entry point. A - # greyed rail row cannot be clicked, but _goto is also reached from - # RECENTS and from startup restore, and it used to open a sub-tab - # the gate was holding shut — page shown, tab strip still hiding it. - if hasattr(widget, "subtab_available") and not widget.subtab_available(sub): - self.statusBar().showMessage(tr("app.nav.needs_project"), 4000) - else: - widget.select_subtab(sub) - # Move the highlight with the content, however navigation was triggered — - # a programmatic _goto used to leave it on whatever was clicked last. - if not self._nav_building: - self._select_nav_row(page, sub) - self._update_dock_guard() - # Switching pages updates which conversation is "current". - self._refresh_history() - - def _update_dock_guard(self) -> None: - """Keep the floating assistant clear of a screen's own bottom bar. - - Only Cowork has one (the composer). Everywhere else the dock sits in - the corner as before. - """ - dock = getattr(self, "help_agent", None) - if dock is None: - return - guard = 0 - on_cowork = (self.pages.currentIndex() == self._ROW_WORKSPACE - and self.workspace.current_subtab() == self.workspace._cowork_tab_idx) - if on_cowork: - comp = getattr(self.cowork, "composer", None) - if comp is not None and not comp.isHidden(): - # Measured from the composer's TOP edge in window coordinates: - # its own height misses the extra row of controls laid out under - # it, which left the dot still overlapping by ~25px. - origin = comp.mapTo(self, comp.rect().topLeft()) - # ...but only lift the dot if the composer is actually beneath - # it. The composer stops at the chat column's right edge, well - # short of the dot, so lifting it there raised the dot 156px for - # nothing — on Cowork alone it sat off the corner every other - # screen keeps it in. - dock_left = dock.x() - self.mapToGlobal(self.rect().topLeft()).x() - dock_right = dock_left + dock.width() - if dock_right > origin.x() and dock_left < origin.x() + comp.width(): - guard = max(0, self.height() - origin.y() + 8) - dock.set_bottom_guard(guard) - - def _on_projects_changed(self) -> None: - self.sidebar.refresh() # History regroups by project - self.cowork._apply_output_folder_label() # project may have been renamed - self.structure._refresh_project_combo() # GraphRAG's project lock list follows too - - def _apply_theme(self) -> None: - app = QApplication.instance() - if app: - set_active_theme(self.ctx.config.theme) - app.setStyleSheet(stylesheet(self.ctx.config.theme)) - # Re-apply theme styles to chat bubbles so they adapt to the new theme. - self.cowork.apply_theme() - if getattr(self, "help_agent", None) is not None: - self.help_agent.apply_theme() # chat body follows theme (header stays fixed) - - # ---- sizing ------------------------------------------------------ - # Share of the available screen the window takes when it has room to. Fixed - # pixels do not travel: 1180×760 fills a laptop and looks lost on a 4K - # panel. `want_*` stays the floor so a small screen behaves as before. - _SCREEN_SHARE_W, _SCREEN_SHARE_H = 0.80, 0.85 - - def _fit_to_screen(self, want_w: int, want_h: int) -> None: - screen = self.screen() or QGuiApplication.primaryScreen() - avail = screen.availableGeometry() if screen else None - if avail is None: - self.resize(want_w, want_h) - return - margin = 60 - # Take a share of the screen, never less than the asked-for size and - # never more than the screen can show. - w = min(max(want_w, int(avail.width() * self._SCREEN_SHARE_W)), - avail.width() - margin) - h = min(max(want_h, int(avail.height() * self._SCREEN_SHARE_H)), - avail.height() - margin) - # minimum must never exceed what the screen can show - self.setMinimumSize(min(820, avail.width() - margin), min(520, avail.height() - margin)) - self.resize(max(w, 1), max(h, 1)) - frame = self.frameGeometry() - frame.moveCenter(avail.center()) - self.move(frame.topLeft()) - - def moveEvent(self, event): # noqa: N802 - Qt override - super().moveEvent(event) - # Dragged to another monitor: its work area (and scaling) may differ, so - # the floating assistant re-pins and the panes re-decide if they fit. - self._on_screen_maybe_changed() - - def _on_screen_maybe_changed(self) -> None: - screen = self.screen() - if screen is getattr(self, "_last_screen", None): - return - self._last_screen = screen - avail = screen.availableGeometry() if screen else None - if avail is not None: - self.setMinimumSize(min(820, avail.width() - 60), - min(520, avail.height() - 60)) - if getattr(self, "help_agent", None) is not None: - self._update_dock_guard() - self.help_agent.reposition() - - # ---- lifecycle --------------------------------------------------- - def closeEvent(self, event) -> None: # noqa: N802 - keep = (self.tray is not None - and self.ctx.config.data.get("tray", {}).get("minimize_on_close", True)) - if keep and not self._really_quit: - # Keep running in the background; tasks continue and autosave. - event.ignore() - self.hide() - try: - self.tray.showMessage( - DISPLAY_NAME, tr("app.tray.running_body"), - QSystemTrayIcon.Information, 4000) - except Exception: - pass - return - # Real quit: stop every running turn (a tab may have several), then close. - self.task_scheduler.stop() # also stops any scheduled tasks - if getattr(self, "routing_scheduler", None) is not None: - self.routing_scheduler.stop() - for tab in (self.cowork,): - for w in tab.active_workers(): - if w.isRunning(): - w.request_stop() - w.wait(1500) - # Safely stop codebase-memory UI if the method exists - if hasattr(self.structure, 'stop_cmem_ui'): - self.structure.stop_cmem_ui() - self.ctx.stop_mcp_connections() # never leave a connected MCP server subprocess behind - if self.tray is not None: - self.tray.hide() - super().closeEvent(event) - def _set_windows_app_id() -> None: """Make Windows use our window icon on the taskbar (not python.exe's).""" @@ -1288,12 +61,21 @@ def _set_windows_app_id() -> None: def run(argv: List[str] | None = None) -> int: + """Điểm vào ứng dụng: dựng Composition Root, gieo dữ liệu mặc định, áp theme + rồi mở cửa sổ chính. + + Mọi bước gieo (skill dựng sẵn, flow dựng sẵn) đều bọc trong ``try`` — việc + dọn nhà không bao giờ được phép chặn app khởi động. + """ argv = argv if argv is not None else sys.argv _set_windows_app_id() app = QApplication.instance() or QApplication(argv) app.setApplicationName(APP_NAME) app.setWindowIcon(app_icon()) - ctx = AppContext(AppConfig.load()) + # Composition Root: presentation/shell/bootstrap.py quyết định app chạy + # bằng mảnh nào. Từ R02, đó là JsonConfigRepository + kho bí mật của hệ + # điều hành, không còn config.py::AppConfig. + ctx = build_context() set_language(ctx.config.language) # Built-in default skills (if any are bundled) are always-on and loaded # straight from the package; tidy away any copy seeded by older versions so they @@ -1339,6 +121,7 @@ def run(argv: List[str] | None = None) -> int: win = MainWindow(ctx, user_name="local") def _reapply_system_theme(*_a): + """Theme đang để "Theo hệ thống" thì áp lại mỗi khi Windows đổi sáng/tối.""" if ctx.config.theme == "system": set_active_theme("system") app.setStyleSheet(stylesheet("system")) diff --git a/application/__init__.py b/application/__init__.py new file mode 100644 index 0000000..24d12d6 --- /dev/null +++ b/application/__init__.py @@ -0,0 +1,12 @@ +"""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. +""" diff --git a/application/conversations/__init__.py b/application/conversations/__init__.py new file mode 100644 index 0000000..da8f034 --- /dev/null +++ b/application/conversations/__init__.py @@ -0,0 +1,12 @@ +"""Application conversations package: turn lifecycle orchestration, agent execution, and tool approval policy.""" + +from .conversation_application_service import ( + ConversationApplicationService, +) +from .tool_policy_gateway import ConfirmGate, ToolPolicyGateway + +__all__ = [ + "ConversationApplicationService", + "ToolPolicyGateway", + "ConfirmGate", +] diff --git a/application/conversations/conversation_application_service.py b/application/conversations/conversation_application_service.py new file mode 100644 index 0000000..400e441 --- /dev/null +++ b/application/conversations/conversation_application_service.py @@ -0,0 +1,328 @@ +"""The turn lifecycle, once, in pure Python (R04-T03). + +Extracted from ``core/chat_agent.py::run_cowork``, whose 260-line body mixed the +lifecycle (compose the prompt, call the model, dispatch tools, respect the step +ceiling, tidy the sandbox) with the concrete machinery that does each of those +things. The lifecycle is the part with rules worth testing — and the part that +was untestable, because reaching it meant standing up a Qt widget and a worker +thread. + +Here it is a plain object driven through the seams in :mod:`turn_runtime`, so a +test states a rule ("the guard runs before the model", "a rejected command never +executes") in three lines. ``core/chat_agent.py`` keeps its signature and +delegates, and the presentation layer keeps receiving the same events via the +legacy codec, so nothing downstream had to change with it. + +Behavioural contract: this is a faithful port, not an improvement pass. Where +the original had a quirk (the step-ceiling note only merges into the answer when +the last message is the assistant's), the quirk is preserved and commented — +changing what a user sees belongs in its own change, not smuggled into a move. +""" + +from __future__ import annotations + +import logging +from typing import Any, Dict, List, Optional, Tuple + +from ...domain.agents.agent_event import ( + AssistantMessageCompletedEvent, + ErrorEvent, + OutputsAddedEvent, + OutputsRemovedEvent, + PlanStep, + PlanUpdatedEvent, + ReasoningChunkEvent, + TextChunkEvent, + ToolCallFinishedEvent, + ToolCallStartedEvent, + ToolOutputChunkEvent, +) +from ...domain.agents.agent_result import AgentResult +from ...domain.agents.conversation_execution_request import ConversationExecutionRequest +from .turn_runtime import ( + BUDGET_NOTE_TEMPLATE, + GATED_TOOLS, + PLAN_TOOL, + REASONING_ONLY_NOTE, + REJECTED_OUTPUT, + AttachmentReader, + CancelFn, + CommandGuard, + ContextCompactor, + EventSink, + ModelCallPort, + PermissionRequest, + PromptGuard, + PromptPreparer, + ToolRuntimePort, +) + +logger = logging.getLogger("cowork_local.application.conversations") + + +class ConversationApplicationService: + """Runs one :class:`ConversationExecutionRequest` to completion.""" + + def __init__( + self, + model: ModelCallPort, + tools: ToolRuntimePort, + *, + prepare_prompt: Optional[PromptPreparer] = None, + prompt_guard: Optional[PromptGuard] = None, + command_guard: Optional[CommandGuard] = None, + compact: Optional[ContextCompactor] = None, + permission_request: Optional[PermissionRequest] = None, + attachment_reader: Optional[AttachmentReader] = None, + ) -> None: + """Nhận vào các cổng (port) thay vì tự dựng phụ thuộc. + + ``model`` và ``tools`` bắt buộc; mọi thứ còn lại là tuỳ chọn và để None thì + bỏ qua bước đó. Nhờ vậy test dựng được service với đúng phần nó cần kiểm, + không phải dựng cả provider thật lẫn sandbox. + """ + self._model = model + self._tools = tools + # Every hook is optional so the service degrades to a plain chat turn. + # That is not only a test convenience: a headless caller legitimately has + # no guards (``security_config=None`` today) and no permission dialog. + self._prepare_prompt = prepare_prompt + self._prompt_guard = prompt_guard + self._command_guard = command_guard + self._compact = compact + self._permission_request = permission_request + self._attachment_reader = attachment_reader + + # -- public API ------------------------------------------------------ # + def execute(self, request: ConversationExecutionRequest, sink: EventSink, + cancel: Optional[CancelFn] = None, + messages: Optional[List[Dict[str, Any]]] = None) -> AgentResult: + """Run the turn, streaming events to ``sink``, and report the outcome. + + ``messages``, when given, is a working list the caller already built — + it MUST already end with this turn's user message, and the service + appends into that very object instead of composing its own. The Cowork + widget needs this: it hands out the same list to + ``_reattach_running_turn``, which replays the steps done so far while the + worker is still appending, and to ``_finalize_turn``, which slices it by + the pre-turn snapshot length. A private list would break both silently. + Passing ``None`` (every headless caller) lets the service compose the + list from the request, which is the mode the rest of this class assumes. + + Raises whatever the runtime raises (a blocked prompt, a dead gateway): + the caller already has a failure path for that — ``AgentWorker.failed`` + in the UI, the artifact writer in Schedule Task — and swallowing the + exception here would silently turn a failed turn into an empty answer. + An :class:`ErrorEvent` is emitted first so subscribers see the failure + on the same stream as everything else. + """ + cancel = cancel or (lambda: False) + + # -- pre-flight. Runs BEFORE the output snapshot, so a turn refused here + # leaves the output folder completely untouched (tidying is not a + # read-only operation — see ToolRuntimePort.finalize). + try: + # The caller's list is used by reference on purpose (see above); only + # the self-composed path may build a fresh one. + working = messages if messages is not None else self._compose_messages(request) + tools = list(self._tools.specs(request.allowed_tools)) + if self._prepare_prompt is not None: + self._prepare_prompt(working, tuple(getattr(t, "name", "") for t in tools)) + if request.enforce_rules and self._prompt_guard is not None: + self._prompt_guard(working) + except Exception as exc: # noqa: BLE001 — reported, then re-raised as-is + sink(ErrorEvent(message=str(exc))) + raise + + before = self._tools.snapshot() + steps_used = 0 + plan_steps: Tuple[PlanStep, ...] = () + completed_naturally = False + try: + for _ in range(request.effective_max_steps): + if cancel(): + break + # Auto-compress when nearing the model's context budget; a no-op + # when off or when the conversation is still short. + if self._compact is not None: + self._compact(working, cancel) + + assistant = self._model.call( + working, tools, + on_text=lambda piece: sink(TextChunkEvent(delta=piece)), + on_reasoning=lambda piece: sink(ReasoningChunkEvent(delta=piece)), + cancel=cancel, + ) + working.append(assistant) + steps_used += 1 + tool_calls = assistant.get("tool_calls") or [] + + if not tool_calls and not (assistant.get("content") or "").strip(): + # Written into the message, not just emitted, so the stored + # conversation never ends on a blank assistant turn. + assistant["content"] = REASONING_ONLY_NOTE + sink(TextChunkEvent(delta=REASONING_ONLY_NOTE)) + sink(AssistantMessageCompletedEvent(content=assistant.get("content", ""))) + + if not tool_calls: + completed_naturally = True + break + + for call in tool_calls: + if cancel(): + break + tool_message, steps = self._dispatch(request, call, sink, cancel) + working.append(tool_message) + if steps is not None: + plan_steps = steps + + if not completed_naturally and not cancel(): + self._announce_budget_exhausted(request, working, sink) + except Exception as exc: # noqa: BLE001 — reported, then re-raised as-is + sink(ErrorEvent(message=str(exc))) + raise + finally: + # Always tidy: the sandbox and generator scripts must not survive a + # turn that stopped abruptly. Runs on success, cancel and failure. + self._finalize_outputs(before, sink, cancelled=cancel()) + + result = AgentResult( + messages=working, steps_used=steps_used, cancelled=cancel(), + budget_exhausted=not completed_naturally and not cancel(), + plan_steps=plan_steps, + ) + sink(result.to_turn_completed_event()) + return result + + # -- internals ------------------------------------------------------- # + def _compose_messages(self, request: ConversationExecutionRequest) -> List[Dict[str, Any]]: + """History snapshot plus this turn's user message. + + The attachment text is read HERE rather than when the request was built, + because extraction is slow enough to freeze the UI thread; the request + deliberately carries paths only. + """ + body = request.prompt + if self._attachment_reader is not None: + body = self._attachment_reader(request.prompt, request.attachments) + messages = [dict(m) for m in request.messages] + messages.append({"role": "user", "content": request.user_content(body)}) + return messages + + def _dispatch(self, request: ConversationExecutionRequest, call: Dict[str, Any], + sink: EventSink, cancel: CancelFn + ) -> Tuple[Dict[str, Any], Optional[Tuple[PlanStep, ...]]]: + """Run one tool call. + + Returns ``(tool_message, plan_steps)`` — the message to append to the + conversation, and the new checklist when this call was the plan tool + (``None`` otherwise, so the caller can tell "no change" from "empty + plan"). + """ + call_id = str(call.get("id", "")) + name = str(call.get("name", "")) + args = call.get("arguments") or {} + + # The plan tool is invisible in the transcript: it updates the Plan panel + # and nothing else, so it skips preview, guard and gate entirely. + if name == PLAN_TOOL: + outcome = self._tools.execute(name, args, on_output=None, cancel=cancel) + steps = tuple(outcome.get("plan_steps") or ()) + sink(PlanUpdatedEvent(steps=steps)) + return self._tool_message(call_id, name, outcome.get("output", "")), steps + + # Announce first: the user sees the code/command about to run before the + # guard or the approval dialog interrupts them, which is the whole point + # of showing the step CLI-style. + preview = self._tools.preview(name, args) + sink(ToolCallStartedEvent(call_id=call_id, name=name, arguments=dict(args), + preview=preview)) + + if request.enforce_rules and self._command_guard is not None: + self._command_guard(name, args) + + if not self._approved(request, name, args, preview, sink, call_id): + return self._tool_message(call_id, name, REJECTED_OUTPUT), None + + outcome = self._tools.execute( + name, args, + on_output=lambda piece: sink(ToolOutputChunkEvent( + call_id=call_id, name=name, delta=piece)), + cancel=cancel, + ) + sink(ToolCallFinishedEvent( + call_id=call_id, name=name, ok=bool(outcome.get("ok", False)), + output=str(outcome.get("output", "")), path=str(outcome.get("path", "") or ""), + produced=outcome.get("produced") or (), + )) + return self._tool_message(call_id, name, outcome.get("output", "")), None + + def _approved(self, request: ConversationExecutionRequest, name: str, + args: Dict[str, Any], preview: Any, sink: EventSink, + call_id: str) -> bool: + """Whether this call may run. + + Only command-shaped tools are gated, and only when the workspace asked + to confirm them: file writes stay inside the turn's own sandbox, so + prompting for those would be noise. A rejection is reported as a failed + tool result — the model needs to read back that it was refused, or it + will simply try the same call again. + """ + if not request.requires_permission_gate or name not in GATED_TOOLS: + return True + if self._permission_request is None: + # Confirm mode with nobody to ask: refusing is the safe direction, + # since auto-running is exactly what confirm mode exists to prevent. + logger.warning("turn: confirm mode without a permission callback — refusing %r", name) + approved = False + else: + approved = bool(self._permission_request({ + "name": name, "args": args, + "preview": preview.to_dict() if preview is not None else {}, + })) + if not approved: + sink(ToolCallFinishedEvent(call_id=call_id, name=name, ok=False, + output=REJECTED_OUTPUT)) + return approved + + @staticmethod + def _tool_message(call_id: str, name: str, output: Any) -> Dict[str, Any]: + """The canonical ``role: tool`` message the model reads back.""" + return {"role": "tool", "tool_call_id": call_id, "name": name, + "content": str(output or "")} + + @staticmethod + def _announce_budget_exhausted(request: ConversationExecutionRequest, + messages: List[Dict[str, Any]], sink: EventSink) -> None: + """Report being cut off by the step ceiling. + + The note always reaches the transcript. It is merged into the stored + answer only when the last message is the assistant's — which, when the + ceiling is hit, it never is (the turn ends on a tool result). The branch + is kept because it is what the current runtime does, and because it is + the correct behaviour the day a caller ends the loop differently. + """ + note = BUDGET_NOTE_TEMPLATE.format(steps=request.effective_max_steps) + sink(TextChunkEvent(delta=note)) + if messages and messages[-1].get("role") == "assistant": + messages[-1]["content"] = (messages[-1].get("content") or "") + note + + def _finalize_outputs(self, before: Any, sink: EventSink, cancelled: bool) -> None: + """Tidy the output folder and report what moved. + + Failures are logged, never raised: this runs in a ``finally``, so an + exception here would replace the turn's real error (or its success) with + a housekeeping one. + """ + try: + removed, added = self._tools.finalize(before, cancelled=cancelled) + except Exception: # noqa: BLE001 + logger.exception("turn: tidying the output folder failed") + return + if removed: + sink(OutputsRemovedEvent(paths=tuple(removed))) + if added: + sink(OutputsAddedEvent(paths=tuple(added))) + + +__all__ = ["ConversationApplicationService"] diff --git a/application/conversations/core_runtime_adapter.py b/application/conversations/core_runtime_adapter.py new file mode 100644 index 0000000..0fea5ea --- /dev/null +++ b/application/conversations/core_runtime_adapter.py @@ -0,0 +1,337 @@ +"""Wires :class:`ConversationApplicationService` to the existing runtime (R04-T03). + +The service is written against the narrow seams in :mod:`turn_runtime` so it can +be tested with plain fakes. This module supplies the real implementations — the +provider call with its recovery pass, the tool/sandbox runtime, the security +guards, context compaction — and is therefore the ONLY file in +``application/conversations/`` that knows ``core/*`` exists. Same shape (and +same reason) as ``application/model_routing/core_routing_adapter.py`` in R03. + +Every ``core`` import is deferred into a method body: importing the tool runtime +pulls in ``requests``, ``psutil`` and the sandbox stack, and code that merely +*builds* a service must not pay for that. + +Faithfulness notes — two places where this reproduces a quirk of the current +runtime rather than the behaviour one would design fresh. Both are marked +inline: the MS365 system-prompt paragraph keys off the CONFIGURED extra tools +(not the advertised subset), and the ``tool_result`` path falls back to the +call's own ``path`` argument resolved against the workdir. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple + +from ...domain.agents.agent_event import PlanStep, ToolPreview +from .conversation_application_service import ConversationApplicationService +from .turn_runtime import PLAN_TOOL, EventSink + +# Legacy emit: the dict-based callback every current caller already owns. +LegacyEmit = Callable[[Dict[str, Any]], None] + + +def legacy_event_sink(emit: LegacyEmit) -> EventSink: + """Adapt a typed :class:`EventSink` onto the legacy dict ``emit``. + + This is what lets R04 land without touching the presentation layer: the + service thinks in typed events, ``ui/chat_panel.py::_on_event`` keeps + receiving exactly the dicts it already dispatches on. Deleted in R08 once + the widget consumes events directly. + """ + return lambda event: emit(event.to_legacy_dict()) + + +class CoreModelCall: + """:class:`ModelCallPort` over ``code_agent._call_provider_with_recovery``. + + Not ``provider.chat`` directly: the recovery wrapper adds the one bounded + retry that hides a dropped connection or a momentarily unreachable gateway, + and losing it would be a visible regression on flaky corporate networks. + """ + + def __init__(self, provider: Any) -> None: + """Bọc một provider của ``core/`` vào cổng ``ModelCallPort``.""" + self._provider = provider + + def call(self, messages, tools, on_text=None, on_reasoning=None, cancel=None): + """Gọi model một lượt, có tự phục hồi khi tràn context hoặc bị giới hạn tốc độ.""" + from ...core.code_agent import _call_provider_with_recovery + + return _call_provider_with_recovery(self._provider, messages, tools, on_text, + cancel, on_reasoning) + + +class CoreToolRuntime: + """:class:`ToolRuntimePort` over ``core/tools.py`` + Cowork's file tools.""" + + def __init__(self, output_dir: Path, *, title: str = "", + extra_tools: Optional[Sequence[Any]] = None, extra_executor=None, + security_config: Any = None, agent_role: str = "") -> None: + """Bọc bộ tool của ``core/`` vào cổng ``ToolRuntimePort``. + + Tên các tool phụ được gom sẵn vào một ``set`` ngay tại đây: mỗi lượt gọi tool + đều phải tra tên, tra trên danh sách sẽ chậm dần theo số tool. + """ + self._output_dir = Path(output_dir) + self._title = title + self._extra_tools = list(extra_tools or ()) + self._extra_names = {getattr(t, "name", "") for t in self._extra_tools} + # The connector executor MCP/REST tools are routed to; None when the + # turn has no connectors enabled. + self._extra_executor = extra_executor + self._security_config = security_config + self._agent_role = agent_role + self._ctx: Any = None # built on first use (see _tool_context) + + # -- the configured extra tools, for the system-prompt hints ---------- # + @property + def extra_names(self) -> frozenset: + """Tên các tool bổ sung (MCP, connector) ngoài bộ dựng sẵn.""" + return frozenset(self._extra_names) + + def _tool_context(self): + """The sandboxed ``ToolContext`` every built-in tool call runs inside. + + Built once per turn and cached: it carries the resource limits and the + network policy, so re-deriving it mid-turn could let a Settings change + take effect halfway through work already in flight. + """ + if self._ctx is None: + from ...core import agent_security + from ...core.tools import ToolContext + + limits, block_network = agent_security.sandbox_settings(self._security_config) + self._ctx = ToolContext( + self._output_dir, flatten_writes=True, # keep every file in the Output root + resource_limits=limits, block_network=block_network, + allow_url_fetch=agent_security.url_fetch_allowed(self._security_config), + jira=(self._security_config.data.get("jira") if self._security_config else None), + ) + return self._ctx + + # -- ToolRuntimePort -------------------------------------------------- # + def specs(self, allowed_tools: Optional[Sequence[str]] = None) -> List[Any]: + """Advertised tools: Cowork's own two, the enabled built-ins, then MCP. + + ``allowed_tools`` restricts the list so a read-only step literally cannot + write. ``update_plan`` and the connector tools always survive the filter: + the plan tool has no side effects, and connectors are opted into + explicitly rather than governed by the built-in capability scope. + """ + from ...core.chat_agent import SAVE_FILE_SPEC + from ...core.plan import UPDATE_PLAN_SPEC + from ...core.tools import enabled_tool_specs + + specs = ([SAVE_FILE_SPEC, UPDATE_PLAN_SPEC] + + list(enabled_tool_specs(self._security_config)) + + self._extra_tools) + if allowed_tools is None: + return specs + allow = set(allowed_tools) | {PLAN_TOOL} | self._extra_names + return [t for t in specs if getattr(t, "name", "") in allow] + + def preview(self, name: str, args: Dict[str, Any]) -> Optional[ToolPreview]: + """What the user sees before the call runs.""" + # A connector call has no local diff to show, so it renders as the plain + # argument dump the runtime already used. + if name in self._extra_names: + return ToolPreview(kind="info", title=name, text=str(args)) + if name == "save_file": + return self._save_file_preview(args) + from ...core.tools import describe_action + + raw = describe_action(self._tool_context(), name, args) + return ToolPreview.from_dict(raw) + + def _save_file_preview(self, args: Dict[str, Any]) -> ToolPreview: + """A before/after diff for the file the agent is about to write. + + A brand-new file renders all-green (before is empty); an overwrite shows + the real change, so saving a file reads like editing one. + """ + import difflib + + from ...core.chat_agent import _structure_summary, _titled_filename + + fname = _titled_filename(self._title, args.get("filename", "output.txt")) + content = str(args.get("content", "")) + summary = _structure_summary(fname, content) + old = "" + existing = self._output_dir / fname + if existing.exists(): + try: + old = existing.read_text(encoding="utf-8", errors="replace") + except OSError: + pass # unreadable existing file: show it as a fresh write + diff = "".join(difflib.unified_diff( + old.splitlines(keepends=True), content.splitlines(keepends=True), + fromfile=f"a/{fname}", tofile=f"b/{fname}", + )) or content[:4000] + return ToolPreview(kind="diff", title=f"Save {fname}", + text=f"{summary}\n\n{diff[:4000]}") + + def execute(self, name: str, args: Dict[str, Any], on_output=None, + cancel=None) -> Dict[str, Any]: + """Run one tool call and return the runtime's result mapping.""" + if name == PLAN_TOOL: + return self._execute_plan(args) + if name in self._extra_names and self._extra_executor is not None: + # Connector results carry no local file, so no path/produced keys — + # matching what the runtime reports for an MCP call today. + result = self._extra_executor(name, args) or {} + return {"ok": bool(result.get("ok", False)), "output": result.get("output", "")} + if name == "save_file": + from ...core.chat_agent import _do_save_file + + return dict(_do_save_file(self._output_dir, self._title, args)) + + from ...core.tools import execute_tool + + ctx = self._tool_context() + result = dict(execute_tool(ctx, name, args, cancel=cancel, on_output=on_output, + agent_role=self._agent_role)) + # Quirk preserved: a tool that wrote the file named in its OWN arguments + # (write_file/edit_file) does not report a path, so the runtime derives + # one from the argument. Dropping this would empty the Output list. + if not result.get("path") and isinstance(args, dict) and args.get("path"): + result["path"] = str(ctx.workdir / str(args["path"])) + return result + + def _execute_plan(self, args: Dict[str, Any]) -> Dict[str, Any]: + """Apply an ``update_plan`` call: validate the steps and audit them. + + Produces no file and no chat bubble; the service turns the returned + steps into a single plan event. + """ + from ...core import agent_roles, audit_log + from ...core.plan import normalize_plan_steps + + steps = normalize_plan_steps(args.get("steps")) + audit_log.record("tool_call", PLAN_TOOL, True, f"{len(steps)} step(s)", + agent_role=agent_roles.PLANNER) + return {"ok": True, "output": "Plan updated.", + "plan_steps": [PlanStep(title=s["title"], status=s["status"]) for s in steps]} + + def snapshot(self) -> Any: + """Ảnh chụp thư mục kết quả trước lượt chạy — dùng để biết tệp nào mới sinh ra.""" + from ...core.tools import _snapshot + + return _snapshot(self._output_dir) + + def finalize(self, before: Any, cancelled: bool = False + ) -> Tuple[List[str], List[str]]: + """Drop the scratch sandbox and flatten deliverables into the root. + + Returns ``(gone, arrived)``: a file that MOVED counts as both, because + the Output list keys entries by path and must drop the old one. + """ + from ...core.chat_agent import _cleanup_cowork_intermediates + + removed, moved = _cleanup_cowork_intermediates(self._output_dir, before, + cancelled=cancelled) + gone = list(removed) + [old for old, _new in moved] + arrived = [new for _old, new in moved] + return gone, arrived + + +def build_cowork_conversation_service( + provider: Any, + output_dir: Path, + emit: LegacyEmit, + *, + title: str = "", + project_context: str = "", + extra_tools: Optional[Sequence[Any]] = None, + extra_executor=None, + security_config: Any = None, + gate: Any = None, + agent_role: str = "", +) -> ConversationApplicationService: + """A service wired to the real runtime, ready to execute a Cowork turn. + + ``emit`` is the legacy dict callback: the guards and the compactor publish + their own notices through it directly (exactly as they do now), while the + service's typed events reach it via :func:`legacy_event_sink`. + + ``gate`` present means the workspace asked to confirm commands; pass the + request with ``gate_mode="confirm"`` so the two agree. A gate of ``None`` + keeps the pre-existing auto-run behaviour. + """ + from ...core import agent_roles + + tools = CoreToolRuntime( + output_dir, title=title, extra_tools=extra_tools, extra_executor=extra_executor, + security_config=security_config, agent_role=agent_role or agent_roles.COWORK, + ) + + def prepare_prompt(messages: List[Dict[str, Any]], advertised: Tuple[str, ...]) -> None: + """Insert the system prompt, then fold in skills, rules and project text. + + ``advertised`` is unused on purpose: the runtime decides the MS365 + paragraph from the CONFIGURED connector tools, not from the subset a + capability scope left advertised. Changing that changes the prompt the + model sees, so it stays as-is here and belongs to R05's tool-policy work. + """ + from ...core.chat_agent import ( + COWORK_TOOL_PROMPT, + OPENDATALOADER_PDF_PROMPT, + _apply_project_context, + _apply_security_rules, + _apply_skills, + ) + from ...core.deps import _can_pip + from ...core.java_runtime import find_java + from ...core.security_rules import load_rules + from ...core.skills import active_skills_text + + if not messages or messages[0].get("role") != "system": + system = COWORK_TOOL_PROMPT + if any(n.startswith("ms365_") for n in tools.extra_names): + system += ("\nThe user has signed in to Microsoft 365 and enabled some ms365__* " + "tools (Outlook / Teams / OneDrive / SharePoint / meeting transcripts, " + "via the built-in MS365 MCP server). Use them whenever the request " + "involves that data — don't say you can't access it.") + if find_java() is not None and _can_pip(): + # Only advertise the Java-backed PDF extractor when BOTH the JVM + # and pip are available, so the agent is never steered into a + # command that cannot work on this machine. + system += "\n\n" + OPENDATALOADER_PDF_PROMPT + messages.insert(0, {"role": "system", "content": system}) + _apply_skills(messages, active_skills_text()) + _apply_security_rules(messages, load_rules()) + _apply_project_context(messages, project_context) + + def prompt_guard(messages: List[Dict[str, Any]]) -> None: + """Chốt an toàn cho prompt trước khi gửi: quét dấu hiệu tiêm lệnh.""" + from ...core import agent_security + + agent_security.enforce_prompt(provider, messages, security_config, emit) + + def command_guard(name: str, args: Dict[str, Any]) -> None: + """Chốt an toàn cho lệnh shell trước khi chạy: phân loại rủi ro và chặn/hỏi.""" + from ...core import agent_security + + agent_security.enforce_command(provider, name, args, security_config, emit) + + def compact(messages: List[Dict[str, Any]], cancel) -> None: + """Nén lịch sử hội thoại khi gần đầy cửa sổ ngữ cảnh.""" + from ...core import context_budget + + context_budget.maybe_compact(provider, messages, security_config, + emit=emit, cancel=cancel) + + return ConversationApplicationService( + CoreModelCall(provider), tools, + prepare_prompt=prepare_prompt, + prompt_guard=prompt_guard, + command_guard=command_guard, + compact=compact, + permission_request=(gate.request if gate is not None else None), + ) + + +__all__ = [ + "LegacyEmit", "legacy_event_sink", "CoreModelCall", "CoreToolRuntime", + "build_cowork_conversation_service", +] diff --git a/application/conversations/cowork_turn_request.py b/application/conversations/cowork_turn_request.py new file mode 100644 index 0000000..777f1c5 --- /dev/null +++ b/application/conversations/cowork_turn_request.py @@ -0,0 +1,77 @@ +"""Turn the Cowork widget's captured state into a request (R04-T04). + +``ui/cowork_tab.py::build_job`` reads a dozen values off the widget on the UI +thread and has to translate three of them before a turn can run: which message +is this turn's prompt, which messages are its history, and whether the workspace +wants commands confirmed. Those rules lived inline in the widget, where no test +could reach them — and each fails silently when wrong (a duplicated user message, +or a command that quietly stops asking for approval). + +They live here instead, as the mapping step the migration map assigns to the +application layer. The widget keeps only what is genuinely widget-specific: +reading its own state and building the provider. + +Layer rules (``docs/architecture/ADR-001-layered-architecture.md``): pure Python. +Everything arrives as a plain value, so this module never sees a widget. +""" + +from __future__ import annotations + +from typing import Any, Dict, Optional, Sequence + +from ...domain.agents.conversation_execution_request import ConversationExecutionRequest + + +def build_cowork_turn_request( + *, + turn_id: str, + session_id: str, + messages: Sequence[Dict[str, Any]], + surface: str = "cowork", + project_id: str = "", + title: str = "", + provider_id: str = "", + model: str = "", + instructions: str = "", + output_dir: Optional[Any] = None, + home_output_root: Optional[Any] = None, + confirm_commands: bool = False, + agent_role: str = "cowork", +) -> ConversationExecutionRequest: + """Build one Cowork turn's immutable request. + + ``messages`` is the widget's working list, which ALREADY ends with this + turn's user message (the chat panel composes it — prefix, attachments, + session notes — before the job starts). So the prompt is that last message + and the history is everything before it. The request records both; the + service is handed the same working list and appends into it. + + Keyword-only on purpose: a dozen positional strings in a call site is exactly + how a title ends up in the project-id slot. + """ + history = list(messages or ()) + # ``pop`` rather than ``[-1]``/``[:-1]`` so the empty-list case needs no + # special branch: a turn with nothing in it yields an empty prompt instead of + # raising IndexError deep inside a worker thread. + last = history.pop() if history else {} + return ConversationExecutionRequest( + turn_id=turn_id, + session_id=session_id, + surface=surface, + project_id=project_id, + title=title, + prompt=str(last.get("content") or ""), + messages=history, + provider_id=provider_id, + model=model, + project_context=instructions, + output_dir=output_dir, + home_output_root=home_output_root, + # The workspace's Auto-run override (or the global setting) decides + # whether run_command/install_package must be approved first. + gate_mode="confirm" if confirm_commands else "auto", + agent_role=agent_role, + ) + + +__all__ = ["build_cowork_turn_request"] diff --git a/application/conversations/tool_policy_gateway.py b/application/conversations/tool_policy_gateway.py new file mode 100644 index 0000000..6c66295 --- /dev/null +++ b/application/conversations/tool_policy_gateway.py @@ -0,0 +1,91 @@ +"""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.""" + + """Hỏi người dùng; trả về ``True`` nếu được đồng ý.""" + def request(self, payload: Dict[str, Any]) -> bool: + """Hỏi người dùng về một lời gọi tool; trả về ``True`` nếu được đồng ý.""" + ... + + +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: + """Nhận sổ đăng ký tool và tập năng lực cần xin phép. + + Truyền vào chứ không viết cứng: mỗi bề mặt chat có ngưỡng riêng, và test đặt + được ngưỡng của mình mà không đụng cấu hình thật. + """ + 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"] diff --git a/application/conversations/turn_runtime.py b/application/conversations/turn_runtime.py new file mode 100644 index 0000000..5ed6695 --- /dev/null +++ b/application/conversations/turn_runtime.py @@ -0,0 +1,177 @@ +"""The seams :mod:`conversation_application_service` runs a turn through (R04-T03). + +Two Protocols and six callables — chosen deliberately, not by reflex. The +refactor plan forbids giving every class an interface, so a contract exists here +only where there is both a real ``core/*`` implementation AND a test double: + +* :class:`ModelCallPort` — one provider round-trip *including* the app's + existing context-overflow recovery, which is why the raw ``Provider.chat`` + signature is not enough. +* :class:`ToolRuntimePort` — the tool + output-folder runtime, kept as one + cohesive object because every method operates on the same sandbox. + +Everything else is a single function, so it is expressed as a callable type +rather than a class with one method (the same choice R03 made for +``ConfirmationCallback``). All of them are optional: a service built with none +of them still runs a plain chat turn, which is what keeps the unit tests short. + +Layer rules (``docs/architecture/ADR-001-layered-architecture.md``): application +layer — pure Python. Nothing here imports PySide6, ``core.*``, ``providers.*`` +or ``ui.*``; the concrete wiring lives in :mod:`core_runtime_adapter`. +""" + +from __future__ import annotations + +from typing import ( + Any, + Callable, + Dict, + List, + Optional, + Protocol, + Sequence, + Tuple, + runtime_checkable, +) + +from ...domain.agents.agent_event import AgentEvent, ToolPreview + +# The plan tool is special-cased by the loop: it drives the Plan panel and +# produces no chat bubble and no file. Named here so the check is not a bare +# string literal in the middle of the dispatch. +PLAN_TOOL = "update_plan" + +# Tools that need approval before they run when the workspace is in confirm +# mode. R05 replaces this tuple with a real ``ToolPolicyGateway`` keyed on +# ToolCapability; until then it mirrors exactly what the runtime gates today. +GATED_TOOLS = ("run_command", "install_package") + +# Shown when the user (or the workspace policy) rejects a proposed command. The +# exact string also becomes the tool message the model reads back, so it must +# stay stable. +REJECTED_OUTPUT = "Rejected by user." + +# A reasoning model can answer with thinking only. The note is written into the +# assistant message itself, not merely emitted, so an unattended run does not +# read back an empty answer and report "(no output)". +REASONING_ONLY_NOTE = "*(model returned only its reasoning — try rephrasing)*" + +# Emitted when the turn is stopped by its own safety ceiling rather than by the +# model finishing. Never silent: being cut off looks exactly like being done. +BUDGET_NOTE_TEMPLATE = ( + "\n\n⚠️ Reached the {steps}-step safety limit before the task signalled " + "completion — stopping here. Re-run to continue if more work remains." +) + + +def combine_instructions(*blocks: Optional[str]) -> str: + """Join the standing-instruction blocks of a turn, skipping the absent ones. + + A turn's instructions arrive as several independent blocks — the project's + shared context, an Admin agent's persona, a skill's rules, the + "this runs unattended" reminder — and each caller was joining them inline + with its own ``f"{a}\\n\\n{b}" if a else b`` expression. Two call sites now + need the same rule (the Cowork widget in R04-T04 and the task runner in + R04-T05), which is the point at which it stops being an expression. + + Whitespace-only blocks count as absent: they would otherwise open the system + prompt with a stray blank line. + """ + return "\n\n".join(b.strip() for b in blocks if b and b.strip()) + + +# --------------------------------------------------------------------------- # +# Callables. +# --------------------------------------------------------------------------- # +# Receives every typed event the turn produces. The caller decides what that +# means — render it, forward it as a legacy dict, autosave on it. +EventSink = Callable[[AgentEvent], None] + +# True once the user has asked to stop. Polled between steps and between tool +# calls, the same cadence the current runtime uses. +CancelFn = Callable[[], bool] + +# ``(prompt, attachment_paths) -> body``. Runs on the worker thread because +# extracting a .docx may pip-install a parser or call LibreOffice. +AttachmentReader = Callable[[str, Tuple[str, ...]], str] + +# ``(messages, advertised_tool_names) -> None`` — inserts the system prompt and +# folds in skills, security rules and project instructions, in place. It needs +# the tool names because the system prompt gains an MS365 paragraph only when +# ms365 tools are actually present. +PromptPreparer = Callable[[List[Dict[str, Any]], Tuple[str, ...]], None] + +# Reviews the assembled request; raises to refuse the turn outright. +PromptGuard = Callable[[List[Dict[str, Any]]], None] + +# Reviews one proposed tool call; raises to refuse it. +CommandGuard = Callable[[str, Dict[str, Any]], None] + +# ``(messages, cancel) -> None``. Summarises old turns in place when the +# conversation nears the model's context budget; a no-op when compaction is off +# or the conversation is short. It takes the cancel signal because compacting +# calls the model itself, so Stop has to reach it too. +ContextCompactor = Callable[[List[Dict[str, Any]], "CancelFn"], None] + +# ``(action) -> approved``. Blocks the worker thread while a human decides. +PermissionRequest = Callable[[Dict[str, Any]], bool] + + +# --------------------------------------------------------------------------- # +# Ports. +# --------------------------------------------------------------------------- # +@runtime_checkable +class ModelCallPort(Protocol): + """One call to the model, with the app's retry/recovery behaviour applied.""" + + def call(self, messages: List[Dict[str, Any]], tools: Sequence[Any], + on_text: Optional[Callable[[str], None]] = None, + on_reasoning: Optional[Callable[[str], None]] = None, + cancel: Optional[CancelFn] = None) -> Dict[str, Any]: + """Return the canonical assistant message (content plus tool calls).""" + + +@runtime_checkable +class ToolRuntimePort(Protocol): + """The tools a turn may call, and the folder its files land in.""" + + def specs(self, allowed_tools: Optional[Sequence[str]] = None) -> Sequence[Any]: + """Tool specs to advertise to the model, already filtered. + + Returns opaque objects (the provider layer's ``ToolSpec``); the service + only ever reads ``.name`` off them, which is what keeps this layer free + of a provider import. + """ + + def preview(self, name: str, args: Dict[str, Any]) -> Optional[ToolPreview]: + """Human-readable description of a call that is about to run.""" + + def execute(self, name: str, args: Dict[str, Any], + on_output: Optional[Callable[[str], None]] = None, + cancel: Optional[CancelFn] = None) -> Dict[str, Any]: + """Run one tool call. + + Returns the runtime's own result mapping: ``ok``, ``output``, optionally + ``path``/``produced`` for files it created, and ``plan_steps`` for the + plan tool. + """ + + def snapshot(self) -> Any: + """Opaque record of the output folder before the turn started.""" + + def finalize(self, before: Any, cancelled: bool = False + ) -> Tuple[Sequence[str], Sequence[str]]: + """Tidy the output folder; return ``(removed_paths, added_paths)``. + + Not read-only — it deletes the scratch sandbox and flattens sub-folders — + so the service only calls it for a turn that actually started. + """ + + +__all__ = [ + "PLAN_TOOL", "GATED_TOOLS", "REJECTED_OUTPUT", "REASONING_ONLY_NOTE", + "BUDGET_NOTE_TEMPLATE", "combine_instructions", + "EventSink", "CancelFn", "AttachmentReader", "PromptPreparer", "PromptGuard", + "CommandGuard", "ContextCompactor", "PermissionRequest", + "ModelCallPort", "ToolRuntimePort", +] diff --git a/application/model_routing/__init__.py b/application/model_routing/__init__.py new file mode 100644 index 0000000..2ff9e41 --- /dev/null +++ b/application/model_routing/__init__.py @@ -0,0 +1,54 @@ +"""Application model routing package: model route decisions and multi-provider balancing. + +Public surface (R03-T03 — the single routing entry point every chat surface uses): + +* :class:`RoutingApplicationService` — decides one turn's provider/model. +* :class:`RoutingRequest` / :class:`RoutingOutcome` — the immutable DTOs in and out. +* :class:`RoutingMode` — Off / Auto / Manual / Fallback. +* :func:`build_routing_application_service` — wires the service to a live + ``AppContext`` (engine + per-workspace mode + confirm timeout). + +Typical call site (see ``ui/chat_panel.py::_apply_routing``):: + + service = build_routing_application_service(self.ctx) + outcome = service.resolve( + RoutingRequest(surface="cowork", prompt=text, + current_provider=provider, current_model=model), + confirm=lambda decision, timeout: confirm_switch(self, decision, timeout), + ) + +Only ``core_routing_adapter`` touches ``core/routing``; the service and the DTOs +stay pure Python so the whole rule set is testable without Qt or the engine. +""" + +from .core_routing_adapter import ( + AppContextModeResolver, + CoreRoutingEngine, + build_routing_application_service, +) +from .routing_application_service import ( + ConfirmationCallback, + ModeResolver, + RoutingApplicationService, + RoutingDecisionPort, +) +from .routing_models import ( + RouteEvaluation, + RoutingMode, + RoutingOutcome, + RoutingRequest, +) + +__all__ = [ + "AppContextModeResolver", + "ConfirmationCallback", + "CoreRoutingEngine", + "ModeResolver", + "RouteEvaluation", + "RoutingApplicationService", + "RoutingDecisionPort", + "RoutingMode", + "RoutingOutcome", + "RoutingRequest", + "build_routing_application_service", +] diff --git a/application/model_routing/core_routing_adapter.py b/application/model_routing/core_routing_adapter.py new file mode 100644 index 0000000..4e4afd9 --- /dev/null +++ b/application/model_routing/core_routing_adapter.py @@ -0,0 +1,173 @@ +"""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: + """Bọc ``core/routing/service.py`` vào cổng quyết định định tuyến.""" + 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: + """Đọc chế độ định tuyến từ ``AppContext``, để tầng application không phải biết + hình dạng của context. + """ + 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", +] diff --git a/application/model_routing/routing_application_service.py b/application/model_routing/routing_application_service.py new file mode 100644 index 0000000..6db95a6 --- /dev/null +++ b/application/model_routing/routing_application_service.py @@ -0,0 +1,240 @@ +"""The one place that decides how a turn is routed (R03-T03). + +Before this service, ``ui/chat_panel.py#L638``, ``ui/co4e_tab.py`` and +``ui/folder_tab.py`` each carried their own copy of the same eight-step dance: +clear last turn's override → read the surface's mode → bail on "off" → call the +routing engine → check ``should_switch`` → resolve the target → show the Manual +confirm dialog → publish the override and a status line. Three copies meant +three chances to drift, and none of them could be tested without a Qt widget. + +The dance now lives here, once, in pure Python: + +* the routing engine is reached through :class:`RoutingDecisionPort`; +* the surface's Off/Auto/Manual/Fallback mode through :class:`ModeResolver`; +* the Manual-mode confirmation through a ``confirm`` callback supplied per call, + so the Qt dialog stays in the presentation layer where it belongs. + +Every failure path degrades to "keep the current model": a routing problem must +never be the reason a user cannot send a message. +""" + +from __future__ import annotations + +import logging +from typing import Any, Callable, Optional, Protocol, runtime_checkable + +from .routing_models import ( + RouteEvaluation, + RoutingMode, + RoutingOutcome, + RoutingRequest, +) + +logger = logging.getLogger("cowork_local.application.model_routing") + +# Asks the user to approve a Manual-mode switch. Receives the underlying +# decision object (for rendering) plus the timeout in seconds; returns True to +# approve. Supplied by the caller so this module never imports a UI toolkit. +ConfirmationCallback = Callable[[Any, float], bool] + + +@runtime_checkable +class RoutingDecisionPort(Protocol): + """The routing engine, as this service needs it. + + Narrowed to a single method on purpose: the concrete engine + (``core/routing/service.py::RoutingService``) exposes assessment, + persistence and scheduling too, none of which a turn-time decision needs. + """ + + def evaluate(self, request: RoutingRequest, mode: RoutingMode) -> RouteEvaluation: + """Rank candidates for ``request`` and report whether to switch.""" + + +@runtime_checkable +class ModeResolver(Protocol): + """Resolves the effective routing mode for a surface. + + In the app this reads the active workspace's per-surface override with the + global default behind it (``AppContext.project_routing_mode``); in tests it + is a two-line stub. + """ + + def mode_for(self, surface: str) -> RoutingMode: + """Effective mode for ``surface``.""" + + +class RoutingApplicationService: + """Turn-time routing decisions for every chat surface.""" + + # Matches DEFAULT_CONFIG["routing"]["confirm_timeout_sec"]; used only when + # no timeout provider is wired, so a bare service is still usable in tests. + DEFAULT_CONFIRM_TIMEOUT_SEC = 60.0 + + def __init__( + self, + decision_port: RoutingDecisionPort, + mode_resolver: Optional[ModeResolver] = None, + *, + confirm_timeout_sec: Optional[Callable[[], float]] = None, + ) -> None: + """``mode_resolver`` để None thì mọi bề mặt đều coi như đang ở chế độ mặc định. + ``confirm_timeout_sec`` là hàm chứ không phải số: người dùng đổi thiết lập + giữa chừng thì lần hỏi sau phải theo giá trị mới. + """ + self._decision_port = decision_port + self._mode_resolver = mode_resolver + # A callable rather than a number: the timeout lives in mutable config + # the user can change in Settings between two turns. + self._confirm_timeout_sec = confirm_timeout_sec + + # -- public API ------------------------------------------------------ # + def resolve( + self, + request: RoutingRequest, + confirm: Optional[ConfirmationCallback] = None, + ) -> RoutingOutcome: + """Decide this turn's provider/model. + + Returns a :class:`RoutingOutcome`; ``provider``/``model`` are ``None`` + whenever the surface should keep its own selection. Never raises — an + unexpected failure is logged and reported as "keep current", because a + broken assessment store must not block chatting. + """ + mode = request.mode or self._resolve_mode(request.surface) + try: + return self._resolve_unguarded(request, mode, confirm) + except Exception: # noqa: BLE001 — routing must never break a turn + logger.exception("routing.resolve failed — keeping the current model") + return RoutingOutcome.keep_current(mode, reason="routing error — keeping current model") + + def confirm_timeout(self) -> float: + """Seconds to wait for a Manual-mode confirmation. + + Falls back to the built-in default when the provider is missing or + returns something unusable, so a corrupted config value cannot produce a + zero-second dialog that instantly declines every switch. + """ + if self._confirm_timeout_sec is None: + return self.DEFAULT_CONFIRM_TIMEOUT_SEC + try: + value = float(self._confirm_timeout_sec()) + except (TypeError, ValueError): + return self.DEFAULT_CONFIRM_TIMEOUT_SEC + return value if value > 0 else self.DEFAULT_CONFIRM_TIMEOUT_SEC + + # -- internals ------------------------------------------------------- # + def _resolve_mode(self, surface: str) -> RoutingMode: + """The surface's configured mode, defaulting to OFF when unresolvable — + routing stays opt-in, so "we don't know" must mean "don't switch".""" + if self._mode_resolver is None: + return RoutingMode.OFF + try: + return RoutingMode.parse(self._mode_resolver.mode_for(surface)) + except Exception: # noqa: BLE001 — a config read must not break a turn + logger.exception("routing: could not resolve mode for surface %r", surface) + return RoutingMode.OFF + + def _resolve_unguarded( + self, + request: RoutingRequest, + mode: RoutingMode, + confirm: Optional[ConfirmationCallback], + ) -> RoutingOutcome: + """The decision flow proper; :meth:`resolve` owns the safety net.""" + # 1. Routing disabled, or nothing to classify -> keep the selection. + if mode is RoutingMode.OFF: + return RoutingOutcome.keep_current(mode, reason="routing off") + if not request.has_prompt: + return RoutingOutcome.keep_current(mode, reason="empty prompt — nothing to route") + + # 2. Ask the engine. FALLBACK is evaluated with AUTO's ranking because + # it needs the same candidate list; only the accept/reject rule below + # differs, so the engine stays unaware of the extra mode. + engine_mode = RoutingMode.AUTO if mode is RoutingMode.FALLBACK else mode + evaluation = self._decision_port.evaluate(request, engine_mode) + + # 3. Apply the mode's own accept rule to the engine's verdict. + if mode is RoutingMode.FALLBACK: + accepted, reason = self._fallback_verdict(evaluation) + else: + accepted, reason = evaluation.should_switch, evaluation.reason + + if not accepted or not evaluation.has_target: + return RoutingOutcome.keep_current( + mode, + reason=reason or evaluation.reason, + task_type=evaluation.task_type, + decision=evaluation.decision, + ) + + # 4. Manual mode asks first; a decline or a timeout keeps the current + # model (and is reported as such, so the surface can tell the two + # cases apart from "nothing better was found"). + if mode is RoutingMode.MANUAL and not self._approved(evaluation, confirm): + return RoutingOutcome.keep_current( + mode, + reason="switch declined by user or confirmation timed out", + task_type=evaluation.task_type, + declined=True, + decision=evaluation.decision, + ) + + # 5. Publish the override for THIS turn only. The provider falls back to + # the request's current provider when the engine named a model but no + # provider (same-provider switch). + return RoutingOutcome( + mode=mode, + switched=True, + provider=evaluation.target_provider or request.current_provider, + model=evaluation.target_model or "", + task_type=evaluation.task_type, + score_gain=evaluation.score_gain, + reason=reason or evaluation.reason, + decision=evaluation.decision, + ) + + @staticmethod + def _fallback_verdict(evaluation: RouteEvaluation) -> tuple: + """FALLBACK's accept rule: switch ONLY to rescue an unusable selection. + + The user's pinned model wins as long as it can serve the turn, even when + a higher-scoring candidate exists — that is the whole point of the mode. + A switch happens only when the current model is not a usable candidate + (never assessed, marked unavailable, or its last probe failed) and the + engine has something to move to. + """ + if evaluation.current_is_usable: + return False, "fallback mode — current model is healthy, keeping it" + if not evaluation.has_target: + return False, "fallback mode — current model unusable and no replacement available" + return True, "fallback mode — current model unavailable, switching to the best alternative" + + def _approved( + self, + evaluation: RouteEvaluation, + confirm: Optional[ConfirmationCallback], + ) -> bool: + """Run the Manual-mode confirmation callback. + + No callback means no way to ask, and silently switching in Manual mode + would violate the mode's contract — so a missing callback is treated as + "not approved". A callback that raises is treated the same way, since a + broken dialog must not auto-approve a model change. + """ + if confirm is None: + logger.warning("routing: manual mode without a confirmation callback — keeping current model") + return False + try: + return bool(confirm(evaluation.decision, self.confirm_timeout())) + except Exception: # noqa: BLE001 + logger.exception("routing: confirmation callback failed — keeping current model") + return False + + +__all__ = [ + "ConfirmationCallback", + "ModeResolver", + "RoutingApplicationService", + "RoutingDecisionPort", +] diff --git a/application/model_routing/routing_models.py b/application/model_routing/routing_models.py new file mode 100644 index 0000000..8f9808c --- /dev/null +++ b/application/model_routing/routing_models.py @@ -0,0 +1,158 @@ +"""Pure-Python DTOs exchanged with :mod:`routing_application_service`. + +These types are the vocabulary the chat surfaces (Cowork chat, Co4E, AI-Edit) +now speak instead of each re-deriving routing state from raw config lookups and +``core/routing`` internals. + +Layer rules (``docs/architecture/ADR-001-layered-architecture.md``): application +code is 100% pure Python. Nothing here imports PySide6, and nothing here imports +``core.routing`` either — the concrete routing engine is reached only through +the adapter in :mod:`core_routing_adapter`, which keeps this module trivially +testable with plain fakes. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import Any, Optional, Tuple + + +class RoutingMode(str, Enum): + """The four routing behaviours a surface can be in (R03-T03). + + ``OFF``/``AUTO``/``MANUAL`` map 1:1 onto the existing per-surface toggle and + onto ``core/routing/models.py::SwitchMode``. ``FALLBACK`` is new and + deliberately NOT an optimisation mode: it keeps whatever model the user + chose and only re-routes when that model cannot serve the turn, which is the + behaviour a resilience-minded workspace wants (never surprise me, but never + leave me stuck either). + """ + + OFF = "off" + AUTO = "auto" + MANUAL = "manual" + FALLBACK = "fallback" + + @classmethod + def parse(cls, raw: Any, default: "RoutingMode" = None) -> "RoutingMode": + """Best-effort coercion from config/UI strings. + + Routing must never break a turn, so an unrecognised value degrades to + ``default`` (``OFF`` unless told otherwise) instead of raising — the same + defensive posture ``config.routing_mode_for`` already takes. + """ + fallback = default if default is not None else cls.OFF + if isinstance(raw, cls): + return raw + try: + return cls(str(raw or "").strip().lower()) + except ValueError: + return fallback + + +@dataclass(frozen=True) +class RoutingRequest: + """Everything needed to decide how ONE turn should be routed. + + Frozen: the request is captured from live UI state (the selected model, the + typed prompt) and then handed to code that may run on a worker thread. An + immutable snapshot means the user changing the model picker mid-turn cannot + retroactively alter the decision that was already made — the same rationale + behind R04's ``ConversationExecutionRequest``. + """ + + surface: str # "cowork" | "co4e" | "ai_edit" | ... + prompt: str # the user's text; drives task classification + current_provider: str # provider the surface would use as-is + current_model: str = "" # model the surface would use ("" = provider default) + mode: Optional[RoutingMode] = None # explicit override; None -> resolve per surface + # Pre-classified task type ("coding", "qa", ...). AI-Edit always knows its + # turns are coding work, so it pins this and skips prompt classification. + task_type: Optional[str] = None + required_capabilities: Tuple[str, ...] = () # e.g. ("vision",) + + @property + def has_prompt(self) -> bool: + """Whether there is anything to classify. An empty prompt cannot be + routed meaningfully, so every surface short-circuits on it.""" + return bool((self.prompt or "").strip()) + + +@dataclass(frozen=True) +class RouteEvaluation: + """A routing engine's verdict, normalised away from ``core/routing`` types. + + The adapter flattens ``RouteResult``/``SwitchDecision`` into these plain + fields so the application service never touches Pydantic models or enums + owned by another layer. ``decision`` still carries the original object + because the Manual-mode confirm dialog renders its ``reason``. + """ + + task_type: str + should_switch: bool + target_provider: Optional[str] = None + target_model: Optional[str] = None + score_gain: float = 0.0 + reason: str = "" + # False when the currently selected model is not a usable candidate for this + # task (unranked, unavailable, or failed its last probe) — the single signal + # FALLBACK mode acts on. + current_is_usable: bool = True + decision: Any = None # original SwitchDecision, for the UI dialog + + @property + def has_target(self) -> bool: + """A switch is only actionable when the engine named a model to move to.""" + return bool(self.target_model or self.target_provider) + + +@dataclass(frozen=True) +class RoutingOutcome: + """What the calling surface should actually do for this turn. + + A surface needs exactly three things from routing — "which provider/model do + I build?", "do I tell the user?" and "was I told to stand down?" — so those + are the fields here, and nothing else. ``provider``/``model`` are ``None`` + when the surface should keep its own selection untouched. + """ + + mode: RoutingMode + switched: bool = False + provider: Optional[str] = None + model: Optional[str] = None + task_type: str = "" + score_gain: float = 0.0 + reason: str = "" + # True when Manual mode proposed a switch and the user declined or the + # confirmation timed out. Distinct from "no switch proposed" so a surface + # can tell "routing had nothing to offer" from "the user said no". + declined: bool = False + decision: Any = field(default=None, repr=False) + + @property + def should_notify(self) -> bool: + """Whether the surface should post the "switched model" status bubble. + Only an executed switch is worth interrupting the transcript for.""" + return self.switched + + @classmethod + def keep_current( + cls, + mode: RoutingMode, + *, + reason: str = "", + task_type: str = "", + declined: bool = False, + decision: Any = None, + ) -> "RoutingOutcome": + """The no-change outcome — the single constructor for every path that + leaves the surface's own model selection in place (routing off, empty + prompt, no better candidate, user declined, internal error).""" + return cls( + mode=mode, switched=False, provider=None, model=None, + task_type=task_type, reason=reason, declined=declined, decision=decision, + ) + + +__all__ = ["RoutingMode", "RoutingRequest", "RouteEvaluation", "RoutingOutcome"] diff --git a/application/monitoring/__init__.py b/application/monitoring/__init__.py new file mode 100644 index 0000000..92c9201 --- /dev/null +++ b/application/monitoring/__init__.py @@ -0,0 +1,19 @@ +"""Read-only query services for monitoring/dashboard screens (EPIC R08). + +⚠️ Ownership note (R08-T13): per ``docs/refactor/Feature_Architecture_ +Proposal.md``'s file-split diagram, ``dashboard_query_service.py`` lives +under ``application/monitoring/`` alongside the Dashboard split — but the +SAME document's "Ranh giới phân hệ" table assigns ``application/monitoring/`` +to Team Nam (R08-T07→T10, Monitoring's own 8-tab split). This directory did +not exist yet when Team Hoa reached R08-T13, so creating it here does not +collide with any file Team Nam has written — same situation R06-T02 flagged +for ``infrastructure/persistence/json/atomic_write.py`` vs. Team Nam's +planned ``atomic_json_file.py``. Team Nam should confirm when they start +R08-T07→T10 whether ``DashboardQueryService`` belongs here permanently or +should move once Monitoring's own query service exists. +""" + +from .dashboard_query_service import DashboardQueryService +from .monitoring_query_service import MonitoringQueryService + +__all__ = ["DashboardQueryService", "MonitoringQueryService"] diff --git a/application/monitoring/dashboard_query_service.py b/application/monitoring/dashboard_query_service.py new file mode 100644 index 0000000..9de2971 --- /dev/null +++ b/application/monitoring/dashboard_query_service.py @@ -0,0 +1,120 @@ +"""DashboardQueryService - read-only usage/cost queries for the Dashboard +screen (R08-T13, extracted from ``ui/dashboard_tab.py::DashboardTab``, lines +196-199/256-261/284-322 of the original 437-line file: ``_pricing``, +``_period_range``'s date-math, and the ``period_totals``/``period_breakdown`` +calls ``_refresh_chart`` made directly). + +``ui/dashboard_tab.py`` called ``core/usage_tracker.py``/``core/model_ +pricing.py`` directly from FIVE different methods spread across what is now +three widgets (``token_usage_card_widget.py``, ``usage_chart_widget.py``, +``habits_widget.py``) — each recomputing the same merged pricing dict. This +service is the one place that merge happens now; the three widgets share it +instead of each calling ``core.usage_tracker``/``core.model_pricing`` on +their own. + +Pure Python: no Qt. Wraps ``core/usage_tracker.py`` (a plain-Python module +already) rather than reimplementing any of its date/cost math. +""" +from __future__ import annotations + +from datetime import date, timedelta +from typing import Any, Dict, List, Tuple + + +class DashboardQueryService: + """Usage/cost queries scoped to one ``AppContext``. + + Args: + ctx: ``AppContext`` — read for ``ctx.config`` (pricing table, + currency, budget) and nothing else; this class does no I/O of + its own beyond what ``core.usage_tracker`` already does. + directory: Optional custom usage directory. If None, uses default USAGE_DIR. + """ + + def __init__(self, ctx: Any, directory: Optional[Path] = None) -> None: + """``directory`` để None thì đọc thư mục telemetry mặc định; test trỏ nó vào + ``tmp_path`` để không chạm dữ liệu thật. + """ + self.ctx = ctx + self._directory = directory + + def pricing(self) -> Dict[str, Any]: + """The merged price table (defaults + user overrides), synced from + Monitoring's model-pricing table first so cost figures always agree + between the two screens.""" + from cowork_local.core import model_pricing as mp + from cowork_local.core import usage_tracker as ut + + mp.sync_to_usage(self.ctx.config) + return {**ut.DEFAULT_PRICING, **(self.ctx.config.data.get("usage") or {})} + + def period_range(self, granularity: str, offset: int) -> Tuple[date, date]: + """The SELECTED period as an inclusive ``(start, end)`` date range — + drives every widget on the screen (cards, chart, habits).""" + from cowork_local.core import usage_tracker as ut + + start, end = ut.period_bounds(granularity, offset) + return start, end - timedelta(days=1) # load_events end is inclusive + + def summary(self, start: date, end: date) -> Dict[str, Any]: + """Everything the stat cards + habits panel need for one period: + the raw events, ``usage_tracker.summarize``'s aggregate stats, the + per-bucket costs, and their total — computed once so both widgets + read the same numbers instead of loading events twice.""" + from cowork_local.core import usage_tracker as ut + + events = ut.load_events(start, end, directory=self._directory) + pricing = self.pricing() + stats = ut.summarize(events) + costs = ut.cost_usd_events(events, pricing) + return { + "events": events, + "pricing": pricing, + "stats": stats, + "costs": costs, + "total_cost": sum(costs.values()), + } + + def chart_series(self, granularity: str, offset: int, metric: str + ) -> List[Tuple[str, float]]: + """``(label, value)`` points for the spline chart — WEEK -> 7 days, + MONTH -> weeks, YEAR -> 12 months, in whichever ``metric`` + ("tokens" | "cost") was selected.""" + from cowork_local.core import usage_tracker as ut + + events = ut.load_events(directory=self._directory) # all events; breakdown slices by period + pricing = self.pricing() + parts = ut.period_breakdown(events, granularity, pricing, offset=offset) + mi = 0 if metric == "tokens" else 1 # (label, tokens, cost) -> +1 for the value + return [(row[0], float(row[mi + 1])) for row in parts] + + def period_totals(self, granularity: str, offset: int) -> Tuple[float, float]: + """``(tokens, cost)`` totals for one period — used to compute the + vs-previous-period delta the chart's reference line shows.""" + from cowork_local.core import usage_tracker as ut + + events = ut.load_events(directory=self._directory) + return ut.period_totals(events, granularity, self.pricing(), offset) + + def period_range_label(self, granularity: str, offset: int) -> str: + """Nhãn hiển thị của một kỳ (tuần/tháng/năm cộng độ lệch).""" + from cowork_local.core import usage_tracker as ut + + return ut.period_range_label(granularity, offset) + + def budget_status(self): + """Tình trạng ngân sách: đã dùng bao nhiêu, còn lại bao nhiêu, có vượt ngưỡng chưa.""" + from cowork_local.core import usage_tracker as ut + + return ut.budget_status(self.ctx.config) + + def set_budget(self, amount: float, currency: str) -> None: + """Đặt hạn mức ngân sách mới — mở một chu kỳ đếm mới, chi tiêu trước đó không + còn được tính vào. + """ + from cowork_local.core import usage_tracker as ut + + ut.set_budget(self.ctx.config, amount, currency) + + +__all__ = ["DashboardQueryService"] diff --git a/application/monitoring/dto/__init__.py b/application/monitoring/dto/__init__.py new file mode 100644 index 0000000..d81abbf --- /dev/null +++ b/application/monitoring/dto/__init__.py @@ -0,0 +1,3 @@ +"""DTO của phân hệ Giám sát: hình dạng dữ liệu mà tầng application trả cho +giao diện, không phụ thuộc nguồn đọc. +""" diff --git a/application/monitoring/dto/audit_event_dto.py b/application/monitoring/dto/audit_event_dto.py new file mode 100644 index 0000000..8bd6dca --- /dev/null +++ b/application/monitoring/dto/audit_event_dto.py @@ -0,0 +1,51 @@ +"""Application-layer view of an audit event — decoupled from the +infrastructure ``CanonicalAuditEvent`` so ``application/`` doesn't need to +share a concrete class with ``infrastructure/`` (only the shape). Field names +match the canonical audit schema (see +``infrastructure/telemetry/audit_logger.py``) 1:1. +""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict + + +@dataclass(frozen=True) +class AuditEventDTO: + """Một sự kiện kiểm toán ở dạng tầng application dùng — không phụ thuộc khuôn + lưu trên đĩa, nên đổi định dạng nhật ký không kéo theo sửa giao diện. + """ + ts: str + kind: str + name: str + ok: bool + detail: str + agent_role: str = "" + account: str = "" + role: str = "" + machine: str = "" + + @classmethod + def from_raw(cls, raw: Dict[str, Any]) -> "AuditEventDTO": + """Tolerant of missing keys — accepts both a + ``CanonicalAuditEvent.to_dict()`` result and any historical raw + ``.jsonl`` row.""" + return cls( + ts=str(raw.get("ts", "")), + kind=str(raw.get("kind", "")), + name=str(raw.get("name", "")), + ok=bool(raw.get("ok", False)), + detail=str(raw.get("detail", "")), + agent_role=str(raw.get("agent_role", "")), + account=str(raw.get("account", "")), + role=str(raw.get("role", "")), + machine=str(raw.get("machine", "")), + ) + + def to_dict(self) -> Dict[str, Any]: + """Bản ghi dưới dạng dict cho lớp giao diện.""" + return { + "ts": self.ts, "kind": self.kind, "agent_role": self.agent_role, + "name": self.name, "ok": self.ok, "detail": self.detail, + "account": self.account, "role": self.role, "machine": self.machine, + } diff --git a/application/monitoring/monitoring_query_service.py b/application/monitoring/monitoring_query_service.py new file mode 100644 index 0000000..1a35451 --- /dev/null +++ b/application/monitoring/monitoring_query_service.py @@ -0,0 +1,60 @@ +"""Read-only query service over audit events — filter + sort + pagination. + +Pure Python: no PySide6 import, no UI code. Depends only on an injected +``AuditEventRepository`` (see ``repository/audit_event_repository.py``), so it +is fully unit-testable with ``InMemoryAuditEventRepository`` and independent +of file I/O or Qt. +""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import List, Optional + +from .dto.audit_event_dto import AuditEventDTO +from .repository.audit_event_repository import AuditEventRepository + + +@dataclass(frozen=True) +class Page: + """Một trang kết quả truy vấn nhật ký: các mục, tổng số, số trang và cỡ trang.""" + items: List[AuditEventDTO] + total: int + page: int + page_size: int + + @property + def has_more(self) -> bool: + """Còn trang sau nữa không.""" + return self.page * self.page_size < self.total + + +class MonitoringQueryService: + """Read-only. Callers ask for a filtered/sorted/paginated slice of the + audit log; this service never writes anything.""" + + def __init__(self, repository: AuditEventRepository) -> None: + """Nhận kho sự kiện kiểm toán qua tham số — bản thật đọc đĩa, bản test nằm + trong bộ nhớ. + """ + self._repository = repository + + def query(self, kind: Optional[str] = None, ok: Optional[bool] = None, + text: Optional[str] = None, sort_by: str = "ts", + descending: bool = True, page: int = 1, page_size: int = 50) -> Page: + """Lọc theo loại/kết quả/từ khoá, sắp xếp rồi cắt thành một trang.""" + events = self._repository.load(kind=kind) + + if ok is not None: + events = [e for e in events if e.ok == ok] + if text: + needle = text.lower() + events = [e for e in events + if needle in e.name.lower() or needle in e.detail.lower()] + + events = sorted(events, key=lambda e: getattr(e, sort_by, ""), reverse=descending) + + total = len(events) + page = max(1, page) + start = (page - 1) * page_size + items = events[start:start + page_size] if page_size > 0 else events + return Page(items=items, total=total, page=page, page_size=page_size) diff --git a/application/monitoring/repository/__init__.py b/application/monitoring/repository/__init__.py new file mode 100644 index 0000000..099e195 --- /dev/null +++ b/application/monitoring/repository/__init__.py @@ -0,0 +1 @@ +"""Cổng đọc dữ liệu của phân hệ Giám sát — hợp đồng, không phải cài đặt.""" diff --git a/application/monitoring/repository/audit_event_repository.py b/application/monitoring/repository/audit_event_repository.py new file mode 100644 index 0000000..81fe1d7 --- /dev/null +++ b/application/monitoring/repository/audit_event_repository.py @@ -0,0 +1,53 @@ +"""Audit-event repository — the boundary between ``MonitoringQueryService`` +and where events actually live. ``CanonicalAuditEventRepository`` is the real +adapter (wraps an injected ``CanonicalAuditLogger``); ``InMemoryAuditEventRepository`` +is a constructor-injected test double, following this repo's existing +``Fake*``/``Recording*`` convention (see ``tests/routing/*``, +``tests/test_project_context_mcp_template.py``) rather than ``unittest.mock``. +""" +from __future__ import annotations + +from typing import List, Optional, Protocol + +from ..dto.audit_event_dto import AuditEventDTO + + +class AuditEventRepository(Protocol): + """Cổng đọc nhật ký kiểm toán mà tầng application dùng. + + Chỉ là hợp đồng: bản cài đặt thật đọc từ file cục bộ hoặc thư mục chia sẻ, + còn test truyền vào bộ giả. + """ + def load(self, kind: Optional[str] = None) -> List[AuditEventDTO]: + """Đọc sự kiện kiểm toán, lọc theo loại nếu có.""" + ... + + +class CanonicalAuditEventRepository: + """Adapter over ``infrastructure.telemetry.audit_logger.CanonicalAuditLogger`` + — the only place this application service reaches into infrastructure.""" + + def __init__(self, audit_logger) -> None: + """Bọc bộ ghi nhật ký kiểm toán chuẩn để đọc sự kiện ra.""" + self._audit_logger = audit_logger + + def load(self, kind: Optional[str] = None) -> List[AuditEventDTO]: + """Đọc sự kiện từ nhật ký và đổi sang DTO của tầng application.""" + events = self._audit_logger.load_events(kind=kind) + return [AuditEventDTO.from_raw(e.to_dict()) for e in events] + + +class InMemoryAuditEventRepository: + """Test double — holds a fixed list of events, no file I/O.""" + + def __init__(self, events: List[AuditEventDTO]) -> None: + """Nhận sẵn danh sách sự kiện. Chép lại chứ không giữ tham chiếu: bên gọi sửa + danh sách gốc thì kết quả test không được đổi theo. + """ + self._events = list(events) + + def load(self, kind: Optional[str] = None) -> List[AuditEventDTO]: + """Trả về danh sách đã nạp sẵn, lọc theo loại nếu có.""" + if kind is None: + return list(self._events) + return [e for e in self._events if e.kind == kind] diff --git a/application/scheduling/__init__.py b/application/scheduling/__init__.py new file mode 100644 index 0000000..18013dc --- /dev/null +++ b/application/scheduling/__init__.py @@ -0,0 +1,6 @@ +"""Application services for Schedule Task (EPIC R07).""" + +from .ai_task_planner_service import AiTaskPlannerService +from .task_application_service import MoveResult, RunNowResult, TaskApplicationService + +__all__ = ["TaskApplicationService", "RunNowResult", "MoveResult", "AiTaskPlannerService"] diff --git a/application/scheduling/ai_task_planner_service.py b/application/scheduling/ai_task_planner_service.py new file mode 100644 index 0000000..4371f7d --- /dev/null +++ b/application/scheduling/ai_task_planner_service.py @@ -0,0 +1,100 @@ +"""AiTaskPlannerService - AI-generate / import task lists, outside the widget +(R07-T05). + +``ui/schedule_task_tab.py``'s ``_AiCreateDialog`` already delegates the +actual planning to two existing pure functions — +``core/ai_task_planner.py::plan_tasks`` (natural-language description -> +task dicts, via the active provider) and +``core/task_import.py::import_tasks`` (Excel/CSV/JSON -> task dicts) — so +this service does not reimplement either. What it DOES own is one small +piece of business logic that currently only exists inside the dialog's +``AgentWorker`` job closure (``_generate``'s ``job()``): every AI-generated +task must carry the SAME file/link attachments the user attached to the +request, so they're available again at run time, not just visible to the +planner while it drafts the task list. Leaving that step trapped in a Qt +worker closure means it can only be exercised by driving the real dialog; +here it's a plain, independently testable method. + +Pure Python: no Qt import. The provider is a constructor-injected factory +(``() -> Provider``, no arguments — matches ``AppContext.build_active_ +provider``), the same dependency-inversion shape +``application/conversations/conversation_application_service.py`` (R04-T03) +uses for ITS provider factory. +""" +from __future__ import annotations + +from pathlib import Path +from typing import Any, Callable, Dict, List, Optional, Sequence, Union + +ProviderFactory = Callable[[], Any] +CancelFn = Callable[[], bool] + + +class AiTaskPlannerService: + """AI task generation + file/Excel/CSV/JSON import, for + ``presentation/scheduling/ai_task_creator_dialog.py`` and + ``ai_task_import_dialog.py`` (R08-T11) to call instead of importing + ``core.ai_task_planner``/``core.task_import`` directly. + + Args: + provider_factory: ``() -> Provider``. Production passes + ``AppContext.build_active_provider``; tests pass a lambda + returning a :class:`FakeProvider`. + """ + + def __init__(self, provider_factory: Optional[ProviderFactory] = None) -> None: + """``provider_factory`` là hàm dựng provider, gọi lúc cần chứ không dựng sẵn — + provider có thể bị đổi giữa hai lần lập kế hoạch. + """ + self._provider_factory = provider_factory + + def plan( + self, + description: str, + *, + file_paths: Sequence[str] = (), + links: Sequence[str] = (), + provider: Any = None, + cancel: Optional[CancelFn] = None, + ) -> List[Dict[str, Any]]: + """Turn ``description`` into a list of NOT-yet-saved task dicts. + + ``provider`` overrides the constructor's factory for this one call + (useful for tests, or a caller that already resolved a provider); + omit it to use the injected factory. Raises ``RuntimeError`` when + no provider is available at all, or when the model's reply had no + parseable task list (same error ``core.ai_task_planner.plan_tasks`` + already raises). + """ + resolved = provider if provider is not None else self._resolve_provider() + from cowork_local.core.ai_task_planner import plan_tasks + + planned = plan_tasks(resolved, description, cancel=cancel) + # Attachments apply to EVERY generated task so they're still there + # when the task actually runs, not just while the planner drafts it + # (see module docstring — this used to only happen inside the + # dialog's worker closure). + for task in planned: + task["input"]["file_paths"] = list(file_paths) + task["input"]["links"] = list(links) + return planned + + def import_file(self, path: Union[str, Path]) -> List[Dict[str, Any]]: + """Excel/CSV/JSON -> NOT-yet-saved task dicts, auto-chained in file + order. Raises ``ValueError`` with a human-readable message on an + unusable/unsupported file (same contract + ``core.task_import.import_tasks`` already has).""" + from cowork_local.core.task_import import import_tasks + + return import_tasks(path) + + def _resolve_provider(self) -> Any: + """Provider dùng để lập kế hoạch; chưa cấu hình thì báo lỗi rõ ràng ngay tại + đây thay vì để lỗi nổ ra ở tận tầng HTTP. + """ + if self._provider_factory is None: + raise RuntimeError("No provider available to plan tasks.") + return self._provider_factory() + + +__all__ = ["AiTaskPlannerService"] diff --git a/application/scheduling/task_application_service.py b/application/scheduling/task_application_service.py new file mode 100644 index 0000000..cd62a85 --- /dev/null +++ b/application/scheduling/task_application_service.py @@ -0,0 +1,176 @@ +"""TaskApplicationService - task CRUD + dispatch, outside the widget (R07-T04). + +``ui/schedule_task_tab.py`` currently does all of this by importing +``core/tasks.py`` module functions directly and calling +``self.scheduler.run_now(...)`` inline inside Qt slot methods +(``_run_now``, ``_context_menu``'s duplicate/pause/delete branches, +``_on_task_dropped``'s per-lane business rules). None of it is Qt — it's +plain CRUD plus a few small rules ("a manual task never auto-runs", +"dropping a card on Done disables its schedule so it won't re-fire", +"dropping on Scheduled with no time set needs the editor, not a silent +no-op") — but it can only be exercised today by driving the real widget. + +This service is the seam ``presentation/scheduling/kanban_board_widget.py`` +(R08-T11) calls instead: same rules, same +:class:`~infrastructure.persistence.json.task_repository_impl.TaskRepository` +underneath, testable with no Qt at all. + +Pure Python: no Qt import. ``run_now`` dispatch is a plain injected callable +(production wires ``TaskScheduler.run_now``; tests inject a stub), the same +constructor-injection shape ``application/conversations/conversation_ +application_service.py`` (R04-T03) uses for its provider factory. +""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Callable, Dict, List, Optional + +# "TaskRepository" here is a Protocol-shaped name, not an import: this module +# only calls .get/.save/.delete/.duplicate, so any object with that shape +# (the real infrastructure.persistence.json.task_repository_impl.TaskRepository, +# or a test double) works without this file importing infrastructure/ at +# module scope. +RunNowFn = Callable[[str], bool] + + +@dataclass +class RunNowResult: + """Outcome of asking a task to run immediately. + + ``reason`` is one of ``""`` (ok), ``"not_found"``, ``"manual_task"`` + (manual tasks never auto-run — spec: they exist to be run by a human), + ``"no_scheduler"`` (no ``run_now`` callable was wired in), or + ``"already_running"`` (the scheduler's own dedupe rejected it). + """ + + ok: bool + reason: str = "" + + +@dataclass +class MoveResult: + """Outcome of dropping a task card onto a Kanban lane + (``move_to_status``). The caller (kanban widget) uses the flags to decide + what to show — a full re-render, a "task is running" toast, or opening + the task editor — without re-deriving the business rule itself.""" + + task: Optional[Dict[str, Any]] + blocked: bool = False # dropped while already running — ignored + ran_now: bool = False # dropped on the Running lane — dispatched + run_now_result: Optional[RunNowResult] = None + needs_schedule: bool = False # dropped on Scheduled with no run_at set — needs editing + + +class TaskApplicationService: + """CRUD + dispatch for Schedule Task, backed by a ``TaskRepository``. + + Args: + repository: a ``TaskRepository``-shaped object (``.get``, ``.save``, + ``.delete``, ``.duplicate``). Production passes + ``infrastructure.persistence.json.task_repository_impl. + TaskRepository()``; tests pass one scoped to a ``tmp_path``. + run_now: ``(task_id) -> bool``. Production passes + ``TaskScheduler.run_now``; ``None`` means no scheduler is wired + (matches the widget's own "no scheduler" guard today). + """ + + def __init__(self, repository: Any, run_now: Optional[RunNowFn] = None) -> None: + """``run_now`` để None thì service chỉ đọc/ghi task, không chạy được cái nào — + đúng cho ngữ cảnh không có scheduler (test, hay màn chỉ xem). + """ + self._repository = repository + self._run_now = run_now + + # -- single-task actions ------------------------------------------------ # + def run_now(self, task_id: str) -> RunNowResult: + """Dispatch ``task_id`` immediately. A "Run now" always counts as + manual approval (spec §13) — this is the ONE path that bypasses + ``execution.requires_approval``, same as the scheduler's own + ``run_now`` already does.""" + task = self._repository.get(task_id) + if task is None: + return RunNowResult(False, "not_found") + if task.get("task_type") == "manual": + return RunNowResult(False, "manual_task") + if self._run_now is None: + return RunNowResult(False, "no_scheduler") + ok = self._run_now(task_id) + return RunNowResult(ok, "" if ok else "already_running") + + def duplicate(self, task_id: str) -> Optional[Dict[str, Any]]: + """A saved copy with a fresh identity — see + ``core/tasks.py::duplicate_task`` for what's preserved/reset.""" + task = self._repository.get(task_id) + if task is None: + return None + dup = self._repository.duplicate(task) + self._repository.save(dup) + return dup + + def toggle_pause(self, task_id: str) -> Optional[Dict[str, Any]]: + """Pause a task, or resume a paused one back to Backlog (matches + ``ui/schedule_task_tab.py``'s context-menu action exactly — resuming + does NOT restore whatever status the task had before pausing, only + Backlog, so the user re-schedules explicitly rather than a stale + schedule silently re-firing).""" + task = self._repository.get(task_id) + if task is None: + return None + task["status"] = "backlog" if task.get("status") == "paused" else "paused" + self._repository.save(task) + return task + + def delete(self, task_id: str) -> bool: + """Xoá một task; trả về ``False`` nếu id không tồn tại.""" + if self._repository.get(task_id) is None: + return False + self._repository.delete(task_id) + return True + + def bulk_delete(self, task_ids: List[str]) -> int: + """Delete every id in ``task_ids``; returns how many actually + existed (mirrors ``_confirm_and_delete_selected``'s best-effort + loop — a stale id in the selection doesn't abort the rest).""" + return sum(1 for tid in task_ids if self.delete(tid)) + + # -- Kanban drag/drop ----------------------------------------------------- # + def move_to_status(self, task_id: str, new_status: str) -> Optional[MoveResult]: + """Apply the business rule behind dropping a card into a lane + (``ui/schedule_task_tab.py::_on_task_dropped``, moved here so it's + testable without a real ``QListWidget`` drag gesture): + + * already running -> the drop is ignored (a running task can't be + re-filed by dragging it). + * dropped on Running -> runs it now (counts as manual approval). + * dropped on Done -> marks it done AND disables its schedule, so a + repeating task marked done by hand doesn't quietly re-fire later. + * dropped on Scheduled with no ``run_at`` set yet -> saved as-is but + flagged ``needs_schedule`` — the caller should open the editor + rather than leave a Scheduled card that will never actually run. + * anything else -> plain status change. + """ + task = self._repository.get(task_id) + if task is None: + return None + if task.get("status") == "running": + return MoveResult(task=task, blocked=True) + if new_status == "running": + result = self.run_now(task_id) + return MoveResult(task=self._repository.get(task_id), ran_now=True, run_now_result=result) + if new_status == "done": + task["status"] = "done" + task["schedule"]["enabled"] = False + self._repository.save(task) + return MoveResult(task=task) + task["status"] = new_status + if new_status == "scheduled" and not task["schedule"].get("enabled"): + if task["schedule"].get("run_at"): + task["schedule"]["enabled"] = True + else: + self._repository.save(task) + return MoveResult(task=task, needs_schedule=True) + self._repository.save(task) + return MoveResult(task=task) + + +__all__ = ["TaskApplicationService", "RunNowResult", "MoveResult"] diff --git a/application/workflows/__init__.py b/application/workflows/__init__.py new file mode 100644 index 0000000..8a620bb --- /dev/null +++ b/application/workflows/__init__.py @@ -0,0 +1 @@ +"""Application workflows package: Co4E graph execution orchestration.""" diff --git a/application/workflows/co4e_run_history.py b/application/workflows/co4e_run_history.py new file mode 100644 index 0000000..606b036 --- /dev/null +++ b/application/workflows/co4e_run_history.py @@ -0,0 +1,99 @@ +"""Đọc/ghi file lịch sử run của Co4E — tách khỏi ``co4e_workflow_service.py``. + +``Co4EWorkflowService`` lo vòng đời các run đang chạy; chỗ này lo đúng một +việc: đưa ``RunRecord`` ra đĩa và lấy lại được. Tách ra vì hành vi đọc/ghi ở +đây có những ràng buộc rất riêng — được ghi lại nguyên vẹn bên dưới — mà trộn +lẫn vào file điều phối thì không ai đọc tới. + +DTO ở ``domain/workflows/run_record.py`` không được chạm đĩa, nên việc này +nằm ở tầng application chứ không nằm trong domain. +""" +from __future__ import annotations + +import json +from pathlib import Path +from typing import Dict, List, Tuple + +from ...domain.workflows.run_record import RunRecord +from ...infrastructure.persistence.json.atomic_json_file import AtomicJsonFile + +#: Giữ N run gần nhất trên đĩa. Lịch sử chỉ để người dùng nhìn lại, không +#: phải sổ kiểm toán — để nó lớn vô hạn thì mỗi lần lưu lại phải tuần tự hoá +#: cả file, và lần lưu ấy nằm ngay trên đường đi của mọi sự kiện tiến độ. +HISTORY_CAP = 500 + + +class RunHistoryStore: + """Một file JSON chứa lịch sử run, kèm hai quy ước phải giữ nguyên. + + **Không cách ly file hỏng.** Bản đầu dùng ``AtomicJsonFile.read()``, nhưng + review thấy nó đổi hành vi thật so với ``Co4ERunManager`` cũ: gặp JSON + hỏng, ``AtomicJsonFile.read()`` ĐỔI TÊN file thành ``.bad-`` rồi + mới trả về mặc định, trong khi bản cũ chỉ bắt lỗi và ĐỂ NGUYÊN file tại + chỗ. Đó là thay đổi quan sát được trên đĩa mà không test nào khoá lại và + không có chú thích báo trước — Lâm (N3) quyết ngày 24/08: giữ hành vi cũ. + Vì thế :meth:`load` đọc thủ công bằng ``json.loads``. + + **Ghi hỏng không được làm vỡ luồng gọi.** :meth:`save` nuốt ``OSError``, + đúng như ``core/co4e_run_manager.py::_save_history``. Nó nằm trên đường đi + của mọi hook tiến độ (``_on_event``/``_on_finished``/``_on_failed``); để + lỗi ghi đĩa (đầy đĩa, mất quyền) ném ra là vỡ cả lượt xử lý sự kiện đang + chạy, chỉ vì lịch sử lần này không lưu được. Người dùng vẫn thấy Flow + Status đúng trong phiên hiện tại, chỉ là bản ghi trên đĩa lùi một bước. + + Ghi thì vẫn qua ``AtomicJsonFile``: bản tự viết bằng tmp + ``replace`` + thiếu ``fsync`` (dữ liệu có thể còn trong bộ đệm khi mất điện) và + ``Path.replace`` thỉnh thoảng bị Defender từ chối trên Windows. + """ + + def __init__(self, path: Path): + """Trỏ vào một file JSON. Chưa tồn tại cũng không sao — :meth:`load` coi như + lịch sử rỗng và :meth:`save` tự tạo thư mục cha. + """ + self.path = Path(path) + + def load(self) -> Tuple[Dict[str, RunRecord], int]: + """Đọc lịch sử; trả về ``({id: RunRecord}, số thứ tự lớn nhất đã dùng)``. + + Số thứ tự trả kèm để bên gọi sinh id tiếp theo không đụng vào id đã có + trong lịch sử — không có nó thì sau mỗi lần khởi động lại, ``run1`` + mới sẽ ghi đè ``run1`` cũ. + + File không có, không đọc được, hay JSON hỏng đều trả về rỗng: mất lịch + sử là chuyện chấp nhận được, chặn ứng dụng khởi động thì không. Từng + bản ghi hỏng cũng bị bỏ riêng lẻ, để một dòng lỗi không kéo theo cả + file. + """ + try: + data = json.loads(self.path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return {}, 0 + + runs: Dict[str, RunRecord] = {} + max_seq = 0 + for rec in data.get("runs", []): + try: + record = RunRecord.from_dict(rec) + except Exception: + continue + if not record.id: + continue + runs[record.id] = record + if record.id.startswith("run") and record.id[3:].isdigit(): + max_seq = max(max_seq, int(record.id[3:])) + return runs, max_seq + + def save(self, runs: List[RunRecord]) -> None: + """Ghi ``HISTORY_CAP`` run gần nhất xuống đĩa, ghi nguyên tử. + + Lỗi ghi bị nuốt có chủ ý — xem docstring của lớp. + """ + payload = {"runs": [r.to_dict() for r in runs[-HISTORY_CAP:]]} + try: + self.path.parent.mkdir(parents=True, exist_ok=True) + AtomicJsonFile(self.path).write(payload) + except OSError: + pass + + +__all__ = ["RunHistoryStore", "HISTORY_CAP"] diff --git a/application/workflows/co4e_workflow_service.py b/application/workflows/co4e_workflow_service.py new file mode 100644 index 0000000..d2b0c70 --- /dev/null +++ b/application/workflows/co4e_workflow_service.py @@ -0,0 +1,388 @@ +"""``Co4EWorkflowService`` — nửa "hành vi" tách ra từ ``Co4ERunManager`` cũ. + +Bối cảnh: ``core/co4e_run_manager.py::Co4ERunManager`` là một ``QObject`` gộp +chung dữ liệu run (nay là ``domain/workflows/run_record.py::RunRecord``), logic +chạy job trên ``AgentWorker``/``QThread``, và logic đọc/ghi lịch sử ra đĩa. File +này là phần còn lại sau khi tách DTO: quản lý vòng đời nhiều run cùng lúc, các +hook nhận sự kiện từ worker, và lưu/nạp lịch sử — nhưng THUẦN PYTHON, không kế +thừa ``QObject`` và không tự dựng ``QThread`` (``application/`` cấm PySide6). + +Hai điều thay ``Signal`` cũ: + * ``changed = Signal()`` -> danh sách callback ``self._changed_callbacks`` + + ``on_changed(cb)`` để đăng ký; mọi chỗ code cũ gọi ``self.changed.emit()`` + nay gọi ``self._emit_changed()``, gọi callback theo ĐÚNG thứ tự đã đăng ký. + * ``event = Signal(str, dict)`` -> ``self._event_callbacks`` + ``on_event(cb)``, + tương tự, thay ``self.event.emit(rid, ev)`` bằng ``self._emit_event(rid, ev)``. + * ``self.changed.connect(self._save_history)`` (lớp cũ tự nối signal của + chính nó vào slot riêng, trong ``__init__``) -> ở đây gọi thẳng + ``self._save_history()`` làm bước ĐẦU TIÊN bên trong ``_emit_changed()``, + trước khi chạy các callback đã đăng ký từ bên ngoài. Chọn cách "gọi thẳng" + (thay vì "đăng ký như callback đầu tiên") vì nó khớp với thứ tự nối cũ + (``_save_history`` luôn được nối sớm nhất trong ``__init__`` nên luôn chạy + trước mọi slot ngoài nối sau) mà không cần một danh sách callback nội bộ + riêng chỉ để chứa đúng một phần tử cố định. + +``start()`` KHÔNG tự tạo ``AgentWorker``/``QThread`` — nó nhận một ``runner`` +(``WorkflowRunner`` Protocol, mặc định ``None``) tiêm qua constructor. Adapter +Qt thật (bọc ``AgentWorker`` — xem ``core/worker.py``) là việc của widget ở +``presentation/``, không viết ở đây; test dùng fake chạy đồng bộ +(``tests/fakes/fake_co4e_workflow_service.py`` hoặc fake cục bộ trong +``tests/test_co4e_workflow_service.py``). + +KHÔNG xoá/sửa ``core/co4e_run_manager.py`` — lớp cũ tiếp tục chạy song song +cho tới khi widget Co4E Studio thật (``ui/co4e_tab.py``) chuyển hẳn sang dùng +service này. + +SEAM · dựng 2026-08-25 · chưa nối dây (F-05) +------------------------------------------------------------ +Được nối khi: ``ui/co4e_tab.py`` bỏ ``Co4ERunManager`` và nhận service này qua ``build_co4e_tab(ctx, workflow_service)``. +Để dormant thì sao: Hai bản cùng giữ vòng đời run đang chạy song song. Càng +để lâu thì sửa một lỗi lại phải sửa hai nơi — và đến một lúc sẽ có người +quên nơi thứ hai. + +Cổng ``scripts/check_orphan_modules.py`` đếm tuổi seam từ ngày trên +và nhắc khi quá ``SEAM_MAX_AGE_DAYS``. Đổi nội dung dòng đó thì cổng +đọc theo — đừng sửa ngày để làm im lời nhắc. +""" +from __future__ import annotations + +import os +from datetime import datetime +from pathlib import Path +from typing import Callable, Dict, List, Optional, Protocol, Set + +from ...core.co4e import CO4E_DIR, STEP_DONE, STEP_ERROR, STEP_PLANNED, Workflow, slugify, workflow_to_dict +from ...domain.workflows.run_record import RunRecord +from .co4e_run_history import RunHistoryStore + +_TERMINAL_NODE = {STEP_DONE, STEP_ERROR, STEP_PLANNED} + + +def _now_str() -> str: + """Mốc thời gian hiện tại dạng 'YYYY-MM-DD HH:MM' — đúng định dạng lịch sử run đang lưu.""" + return datetime.now().strftime("%Y-%m-%d %H:%M") + + +def _current_user() -> str: + """Best-effort creator name for a run (signed-in MS365 identity -> OS user).""" + return os.environ.get("USERNAME") or os.environ.get("USER") or "you" + + +# ---- ports (Protocol) — thay QThread thật bằng thứ tiêm được --------------- +class RunnerJob(Protocol): + """Bề mặt tối thiểu mà job workflow cần từ 'worker' của nó. + + Tương ứng ``AgentWorker.emit_event``/``AgentWorker.is_cancelled`` cũ + (``core/worker.py``) — giữ nguyên chữ ký đó để hàm job bên trong + ``co4e_runner.run_workflow`` không phải đổi khi runner đứng sau là + ``AgentWorker``/``QThread`` thật (adapter ở presentation/) hay là fake + đồng bộ trong test. + """ + + def emit_event(self, ev: dict) -> None: + """Đẩy một sự kiện tiến độ từ luồng nền về service.""" + ... + + def is_cancelled(self) -> bool: + """``True`` khi người dùng đã bấm dừng — thân job phải tự kiểm để thoát sớm.""" + ... + + +class RunWorkerHandle(Protocol): + """Điều khiển một job đang chạy nền — tương ứng phần + ``AgentWorker.request_stop()`` cũ mà ``Co4ERunManager.stop()`` gọi.""" + + def request_stop(self) -> None: + """Xin dừng run. Chỉ là yêu cầu: job đang chạy phải tự thấy qua + ``is_cancelled()`` rồi thoát, không ai giết luồng giữa chừng. + """ + ... + + +class WorkflowRunner(Protocol): + """Cổng chạy một job nền, tiêm qua constructor ``Co4EWorkflowService``. + + Thay cho việc service tự ``AgentWorker(job); worker.start()`` (cần + ``QThread`` -> cấm ở ``application/``). Bên gọi ``start()`` truyền vào + ``job`` với đúng chữ ký cũ (``job(worker) -> Optional[dict]``); runner chịu + trách nhiệm chạy nó (nền thật hay đồng bộ) và gọi lại ba callback tương ứng + ba signal cũ của ``AgentWorker`` (``event``/``finished_ok``/``failed``). + """ + + def start(self, run_id: str, job: Callable[[RunnerJob], Optional[dict]], + on_event: Callable[[dict], None], + on_finished: Callable[[Optional[dict]], None], + on_failed: Callable[[str], None]) -> RunWorkerHandle: + """Chạy ``job`` và trả về tay cầm để dừng nó.""" + ... + + +class Co4EWorkflowService: + """Tầng application: vòng đời nhiều run Co4E cùng lúc, thuần Python. + + Vai trò: đây là nơi ``build_co4e_tab(ctx, workflow_service)`` + (``presentation/co4e/co4e_tab.py``) sẽ lấy ``workflow_service`` thật một + khi widget Co4E Studio được lắp lại để dùng nó — hiện widget thật + (``ui/co4e_tab.py``) vẫn dùng ``Co4ERunManager`` cũ song song. + """ + + def __init__(self, ctx, *, history_path: Optional[Path] = None, + runner: Optional[WorkflowRunner] = None): + """Dựng service. + + ``runner`` để None nghĩa là chưa có ai chạy được run — đúng trạng thái hiện + nay, vì adapter Qt thật thuộc về tầng ``presentation/`` và chưa được nối. + Test tiêm runner chạy đồng bộ vào đây. + """ + self.ctx = ctx + self._runs: Dict[str, RunRecord] = {} + self._worker_handles: Dict[str, RunWorkerHandle] = {} + self._seq = 0 + self._output_root: Optional[Path] = None # thư mục output co4e của workspace đang chọn + self._project_id: str = "" # workspace đang chọn — Flow Status lọc theo no + self._runner = runner + # DTO domain khong duoc cham dia (xem domain/workflows/run_record.py), + # nen viec doc/ghi file lich su nam o tang application — cu the la + # co4e_run_history.py::RunHistoryStore. + self._history_path_value = ( + Path(history_path) if history_path is not None else (CO4E_DIR / "run_history.json") + ) + self._history = RunHistoryStore(self._history_path_value) + self._changed_callbacks: List[Callable[[], None]] = [] + self._event_callbacks: List[Callable[[str, dict], None]] = [] + self._load_history() # khoi phuc lich su cu de Flow Status + # giu du lich su qua cac lan restart + + # ---- callback thay Signal --------------------------------------------- + def on_changed(self, cb: Callable[[], None]) -> None: + """Đăng ký callback gọi mỗi khi danh sách run đổi — thay cho signal Qt cũ.""" + self._changed_callbacks.append(cb) + + def on_event(self, cb: Callable[[str, dict], None]) -> None: + """Đăng ký callback nhận sự kiện tiến độ của từng run — thay cho signal Qt cũ.""" + self._event_callbacks.append(cb) + + def _emit_changed(self) -> None: + """Lưu lịch sử rồi báo mọi người đăng ký.""" + self._save_history() # xem docstring dau file: giu dung thu tu ban Qt cu + for cb in self._changed_callbacks: + cb() + + def _emit_event(self, run_id: str, ev) -> None: + """Chuyển một sự kiện tiến độ tới mọi callback đã đăng ký.""" + for cb in self._event_callbacks: + cb(run_id, ev) + + # ---- persistence -------------------------------------------------- + def _load_history(self) -> None: + """Khôi phục lịch sử run từ đĩa lúc khởi động. + + Lấy luôn số thứ tự lớn nhất đã dùng để ``_next_id()`` không sinh trùng + id với run cũ. + """ + self._runs, self._seq = self._history.load() + + def _save_history(self) -> None: + """Ghi lịch sử xuống đĩa. Lỗi ghi bị nuốt có chủ ý — xem + ``co4e_run_history.py::RunHistoryStore``. + """ + self._history.save(list(self._runs.values())) + + # ---- lifecycle ---------------------------------------------------- + def _next_id(self) -> str: + """Sinh id run kế tiếp ('run1', 'run2', ...), không đụng id đã có trong lịch sử.""" + self._seq += 1 + return f"run{self._seq}" + + def start(self, wf: Workflow, *, skill_map: Optional[Dict[str, str]] = None, + plan_mode: bool = False, only_nodes: Optional[set] = None, + seed_outputs: Optional[Dict[str, str]] = None, + manual: bool = False, label: Optional[str] = None) -> str: + """Đăng ký một run mới và giao job cho ``self._runner`` (nếu có). + + Không tự thực thi AI thật ở đây: khi ``self._runner`` là ``None`` + (mặc định), run được ghi nhận nhưng không job nào được giao đi — dùng + cho test/khi chưa lắp adapter Qt thật. + """ + run_id = self._next_id() + total = len(only_nodes) if only_nodes else len(wf.nodes) + record = RunRecord(run_id, wf.id, label or wf.name, total, plan_mode, manual, + created_by=_current_user(), created_at=_now_str(), + project_id=self._project_id) + # workflow_to_dict() tu dung dataclasses.asdict() de dung ca cay (node, + # step, sub-agent) -> ban than no da la mot "deep copy" sang dict moi, + # khong con giu tham chieu toi wf.nodes/wf.edges song. Vi vay KHONG can + # deepcopy(wf) truoc nhu ban Qt cu (RunHandle.wf giu nguyen doi tuong + # Workflow) -- xem doc string dau file domain/workflows/run_record.py + # ve ly do snapshot o day la dict tho chu khong phai doi tuong. + record.wf = workflow_to_dict(wf) + nodes = list(wf.nodes) + edges = list(wf.edges) + out_dir = self._out_dir(wf) + record.out_dir = str(out_dir) + ctx = self.ctx + sk = dict(skill_map or {}) + only: Optional[Set[str]] = set(only_nodes) if only_nodes else None + seed = dict(seed_outputs or {}) + run_label = record.name + self._runs[run_id] = record + + if self._runner is not None: + def job(worker: RunnerJob): + from ...core import co4e_runner + return co4e_runner.run_workflow( + ctx, nodes, edges, out_dir, worker.emit_event, worker.is_cancelled, + plan_mode=plan_mode, skill_map=sk, only_nodes=only, seed_outputs=seed, + usage_label=run_label) + + self._worker_handles[run_id] = self._runner.start( + run_id, job, + on_event=lambda ev, rid=run_id: self._on_event(rid, ev), + on_finished=lambda _r=None, rid=run_id: self._on_finished(rid), + on_failed=lambda e, rid=run_id: self._on_failed(rid, e), + ) + self._emit_changed() + return run_id + + # ---- worker callbacks (goi tu runner, thay slot Qt cu) ----------------- + def _on_event(self, run_id: str, ev) -> None: + """Nhận sự kiện từ job đang chạy và cập nhật bản ghi run.""" + record = self._runs.get(run_id) + if record is not None and isinstance(ev, dict): + t = ev.get("type") + if t == "node_status": + record.node_status[ev.get("node_id")] = ev.get("status") + record.done = sum(1 for s in record.node_status.values() if s in _TERMINAL_NODE) + self._emit_changed() + elif t == "run_done": + if record.status == "running": + record.status = "done" if ev.get("ok", True) else "error" + self._emit_changed() + # quirk co y giu nguyen (xem test_on_event_unknown_run_id... trong ca + # test cu lan test moi): re-emit VO DIEU KIEN, ke ca run_id la hoac ev + # khong phai dict/None -- khac _on_finished/_on_failed la no-op hoan + # toan khi run_id la. + # + # Khac biet CO CHU Y so voi ban Qt cu: Signal(str, dict) cua PySide6 ep + # ev=None thanh {} khi giao cho slot (tac dung phu cua kieu Signal khai + # bao cung). O day khong con Signal nen callback nhan DUNG gia tri ev + # goc (None neu goi voi None) -- khong gia lap lai viec ep kieu do vi + # no la tac dung phu cua Qt, khong phai quy tac nghiep vu can giu. + self._emit_event(run_id, ev) + + def _on_finished(self, run_id: str) -> None: + """Job kết thúc mà không phát ``run_done``: chốt trạng thái về 'done'.""" + record = self._runs.get(run_id) + if record is not None and record.status == "running": + # job returned without a run_done event (shouldn't happen) — settle it + record.status = "done" + self._emit_changed() + + def _on_failed(self, run_id: str, err: str) -> None: + """Job ném lỗi: ghi lỗi vào bản ghi và báo ra ngoài một sự kiện ``run_error``.""" + record = self._runs.get(run_id) + if record is not None: + record.status = "error" + record.error = str(err) + self._emit_event(run_id, {"type": "run_error", "error": str(err)}) + self._emit_changed() + + # ---- control -------------------------------------------------------- + def stop(self, run_id: str) -> None: + """Yêu cầu dừng một run đang chạy và đánh dấu 'stopped'.""" + record = self._runs.get(run_id) + worker = self._worker_handles.get(run_id) + if record is not None and worker is not None and record.running: + worker.request_stop() + record.status = "stopped" + self._emit_changed() + + def stop_all(self) -> None: + """Dừng mọi run của workspace đang chọn (Flow Status vốn lọc theo project).""" + # Only the CURRENT workspace's runs (Flow Status is per-project). + for run_id in [r for r, rec in self._runs.items() if self._belongs(rec)]: + self.stop(run_id) + + def rename(self, run_id: str, new_name: str) -> None: + """Rename a run in the Flow Status history (and its kept workflow snapshot), + then persist + refresh views. No-op on a blank name / unknown run.""" + record = self._runs.get(run_id) + new_name = (new_name or "").strip() + if record is None or not new_name or new_name == record.name: + return + record.name = new_name + # DTO doi: RunHandle.wf cu la doi tuong Workflow (gan record.wf.name), + # RunRecord.wf o day la dict tho (xem domain/workflows/run_record.py) + # nen doi truc tiep khoa "name" cua dict thay vi thuoc tinh doi tuong. + if record.wf is not None: + record.wf["name"] = new_name + self._emit_changed() + + def remove(self, run_id: str) -> None: + """Xoá một run khỏi lịch sử; đang chạy thì dừng trước.""" + record = self._runs.get(run_id) + if record is not None and record.running: + self.stop(run_id) + self._runs.pop(run_id, None) + self._worker_handles.pop(run_id, None) + self._emit_changed() + + def clear_finished(self) -> None: + """Xoá mọi run đã kết thúc của workspace đang chọn, giữ nguyên run đang chạy.""" + # Only clear finished runs of the CURRENT workspace. + for run_id in [r for r, rec in self._runs.items() if not rec.running and self._belongs(rec)]: + self._runs.pop(run_id, None) + self._worker_handles.pop(run_id, None) + self._emit_changed() + + # ---- queries ---------------------------------------------------------- + def _belongs(self, r: RunRecord) -> bool: + """Whether a run belongs to the currently-selected workspace.""" + return getattr(r, "project_id", "") == self._project_id + + def runs(self) -> List[RunRecord]: + """Runs of the CURRENT workspace only — Flow Status is per-project.""" + return [r for r in self._runs.values() if self._belongs(r)] + + def all_runs(self) -> List[RunRecord]: + """Every tracked run across all workspaces (background tracking).""" + return list(self._runs.values()) + + def get(self, run_id: str) -> Optional[RunRecord]: + """Lấy một run theo id; ``None`` nếu không có.""" + return self._runs.get(run_id) + + def active_count(self) -> int: + """Số run đang chạy của workspace đang chọn — dùng cho huy hiệu trên tab.""" + return sum(1 for r in self._runs.values() if r.running and self._belongs(r)) + + def set_current_project(self, project_id: str) -> None: + """Filter Flow Status (and new runs) to this workspace. Runs started while + this is set are tagged with it; the Runs view shows only matching runs.""" + pid = project_id or "" + if pid != self._project_id: + self._project_id = pid + self._emit_changed() # re-render Flow Status for the new workspace + + def set_output_root(self, root: Optional[Path]) -> None: + """Point flow outputs at the SELECTED workspace's co4e folder (set by the + Co4E tab when a project is chosen). ``None`` → fall back to the global + Cowork output dir.""" + self._output_root = Path(root) if root else None + + def _out_dir(self, wf: Workflow) -> Path: + """Thư mục ghi kết quả của một luồng, tạo sẵn nếu chưa có.""" + # Flow deliverables are written into the SELECTED workspace (the active + # project's folder) so they land where the user works with files (Folder + # tab), not in the config/install folder. One subfolder per flow keeps + # runs tidy. Falls back to the global Cowork output dir when no workspace + # is selected. + base = self._output_root + if base is None: + try: + base = self.ctx.config.cowork_output_dir() / "co4e" + except Exception: # noqa: BLE001 - fall back to the config dir if unavailable + base = CO4E_DIR / "runs" / "co4e" + d = Path(base) / slugify(wf.name or "flow") + d.mkdir(parents=True, exist_ok=True) + return d diff --git a/application/workspaces/__init__.py b/application/workspaces/__init__.py new file mode 100644 index 0000000..595901d --- /dev/null +++ b/application/workspaces/__init__.py @@ -0,0 +1,17 @@ +"""Workspace file operations for non-agent-loop callers (EPIC R06, R08).""" + +from .ai_edit_output import parse_ai_output, split_code_block +from .file_preview_helpers import is_probably_text, pptx_available, read_text +from .file_workspace_service import FileWorkspaceService +from .graph_index_service import extract_file_contents, pdf_to_markdown + +__all__ = [ + "FileWorkspaceService", + "read_text", + "is_probably_text", + "pptx_available", + "split_code_block", + "parse_ai_output", + "pdf_to_markdown", + "extract_file_contents", +] diff --git a/application/workspaces/ai_edit_output.py b/application/workspaces/ai_edit_output.py new file mode 100644 index 0000000..0482f93 --- /dev/null +++ b/application/workspaces/ai_edit_output.py @@ -0,0 +1,40 @@ +"""Parse an AI file-edit reply into its parts (R08-T12, moved out of +``ui/folder_tab.py`` — that file's module-level ``_split_code_block``/ +``_parse_ai_output``, lines 1536-1562 of the original 1587-line file). Pure +string parsing, no Qt — used by ``presentation/folder/ai_file_editor_dialog.py`` +to turn a model's raw reply into a proposed edit. +""" +from __future__ import annotations + +import re +from typing import List, Optional, Tuple + + +def split_code_block(text: str) -> Tuple[Optional[str], str]: + """Split an AI reply into ``(file_content, summary)``. ``file_content`` + is the first fenced code block (the edited file); ``summary`` is any + prose before it. Returns ``(None, text)`` when there's no code block.""" + m = re.search(r"```[^\n]*\n(.*?)```", text or "", re.DOTALL) + if not m: + return None, (text or "") + return m.group(1), (text[:m.start()].strip()) + + +def parse_ai_output(text: str) -> Tuple[Optional[str], Optional[str], str, List[Tuple[str, str]]]: + """Parse an AI edit reply into ``(target, content, summary, image_gens)``. + ``FILE: `` names a NEW file to create; ``IMAGE_GEN: => + `` lines request generated illustration images (relative paths).""" + content, summary = split_code_block(text) + target = None + m = re.search(r"(?mi)^\s*FILE:\s*(.+?)\s*$", text or "") + if m: + target = m.group(1).strip().strip("`\"'") + image_gens = [] + for gm in re.finditer(r"(?mi)^\s*IMAGE_GEN:\s*(.+?)\s*=>\s*(\S+)\s*$", text or ""): + image_gens.append((gm.group(1).strip(), gm.group(2).strip().strip("`\"'"))) + # Strip the directive lines out of the shown summary. + summary = re.sub(r"(?mi)^\s*(FILE|IMAGE_GEN):\s*.+?$", "", summary).strip() + return target, content, summary, image_gens + + +__all__ = ["split_code_block", "parse_ai_output"] diff --git a/application/workspaces/file_preview_helpers.py b/application/workspaces/file_preview_helpers.py new file mode 100644 index 0000000..3f1c78f --- /dev/null +++ b/application/workspaces/file_preview_helpers.py @@ -0,0 +1,62 @@ +"""Pure helpers for previewing a file (R08-T12, moved out of +``ui/folder_tab.py`` — that file's module-level functions +``_read_text``/``_is_probably_text``/``_pptx_available``, lines 1519-1533 and +1565-1587 of the original 1587-line file). No Qt, no widget state — the +"is this file text? is pptx editing available?" questions the preview +manager asks before it decides how to render something. +""" +from __future__ import annotations + +from pathlib import Path + +_PPTX_READY = None # cached: pptx-editing library available (after auto-install) + + +def pptx_available() -> bool: + """True when python-pptx is importable. If it's MISSING, auto-download & + install it (via deps.ensure_module) so pptx editing 'just works' — cached + so the (one-time) install is attempted only once.""" + global _PPTX_READY + if _PPTX_READY is None: + try: + from cowork_local.core.deps import ensure_module + + _PPTX_READY = ensure_module("pptx", "python-pptx") is not None + except Exception: # noqa: BLE001 + _PPTX_READY = False + return _PPTX_READY + + +def read_text(path: str) -> str: + """Đọc tệp dạng văn bản, thay ký tự hỏng thay vì ném lỗi; không đọc được thì + trả về chuỗi rỗng. + """ + try: + return Path(path).read_text(encoding="utf-8", errors="replace") + except OSError as exc: + return f"[could not read file: {exc}]" + + +def is_probably_text(path: str) -> bool: + """Đoán tệp này có phải văn bản không, bằng cách tìm byte NUL trong phần đầu. + + Đoán sai theo hướng "là văn bản" sẽ hiện một màn hình ký tự rác, nên phép + thử cố tình bảo thủ. + """ + try: + with open(path, "rb") as f: + chunk = f.read(4096) + except OSError: + return False + if b"\x00" in chunk: + return False + try: + chunk.decode("utf-8") + return True + except UnicodeDecodeError: + # Latin-ish text still edits fine via errors="replace"; only reject on + # a hard binary signal (NUL above), so most source files pass. + return True + + +__all__ = ["pptx_available", "read_text", "is_probably_text"] diff --git a/application/workspaces/file_workspace_service.py b/application/workspaces/file_workspace_service.py new file mode 100644 index 0000000..c4252dd --- /dev/null +++ b/application/workspaces/file_workspace_service.py @@ -0,0 +1,84 @@ +"""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 + """Nhận một ``WorkspaceSession`` — mọi đường dẫn về sau đều bị nó chặn trong + phạm vi cho phép. + """ + 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"] diff --git a/application/workspaces/graph_index_service.py b/application/workspaces/graph_index_service.py new file mode 100644 index 0000000..a63558c --- /dev/null +++ b/application/workspaces/graph_index_service.py @@ -0,0 +1,91 @@ +"""Temporary file-content extraction for Graph-RAG Q&A (R08-T14, moved out +of ``ui/structure_graph_view.py`` — that file's module-level +``_pdf_to_markdown``/``_extract_file_contents``, lines 964-1034 of the +original 1035-line file). Runs inside the ask worker's job function so the +answer is synthesized from real file content, not just the graph structure. + +Pure Python: no Qt. Best-effort throughout (never raises) — a failed +extraction degrades to "no content for this file", not a broken Q&A turn. +""" +from __future__ import annotations + +from pathlib import Path +from typing import Dict, List, Optional, Tuple + + +def pdf_to_markdown(pdf_path: str, out_dir: str) -> Optional[str]: + """Convert a PDF to Markdown with opendataloader-pdf when available + (richer structure than a plain text dump). Best-effort — returns None + if the package isn't installed or the call fails, so the caller falls + back to ``core/doc_extract.py``.""" + try: + import opendataloader_pdf # optional; auto-installed elsewhere if present + except Exception: # noqa: BLE001 + try: + from cowork_local.core.deps import ensure_module + if ensure_module("opendataloader_pdf", "opendataloader-pdf") is None: + return None + import opendataloader_pdf # noqa: F811 + except Exception: # noqa: BLE001 + return None + out = Path(out_dir) + out.mkdir(parents=True, exist_ok=True) + for call in ( + lambda: opendataloader_pdf.convert(input_path=[str(pdf_path)], output_dir=str(out), + generate_markdown=True), + lambda: opendataloader_pdf.convert(input_path=str(pdf_path), output_dir=str(out)), + lambda: opendataloader_pdf.convert(str(pdf_path), str(out)), + ): + try: + call() + break + except TypeError: + continue + except Exception: # noqa: BLE001 + return None + mds = list(out.rglob(Path(pdf_path).stem + "*.md")) or list(out.rglob("*.md")) + for md in mds: + try: + return md.read_text(encoding="utf-8", errors="replace") + except OSError: + continue + return None + + +def extract_file_contents(paths: List[str], cache: Dict[str, str], tmp_dir: str, + max_files: int = 15, max_total: int = 120_000 + ) -> Tuple[str, Dict[str, str]]: + """Read the ACTUAL content of ``paths`` (PDF -> markdown via + opendataloader when available, else ``doc_extract`` for office/pdf/ + text). Returns ``(block, cache)`` — ``block`` is the concatenated + content for the prompt (bounded), ``cache`` maps path -> text for + reuse. Never raises.""" + from cowork_local.core import doc_extract + + cache = dict(cache or {}) + parts, total = [], 0 + for p in paths[:max_files]: + if total >= max_total: + break + text = cache.get(p) + if text is None: + try: + if Path(p).suffix.lower() == ".pdf": + text = pdf_to_markdown(p, tmp_dir) + if not text: + text, _n = doc_extract.extract_text(p) + else: + text, _n = doc_extract.extract_text(p) + except Exception: # noqa: BLE001 + text = "" + cache[p] = text or "" + text = cache.get(p) or "" + if not text: + continue + chunk = text[: max(0, max_total - total)] + total += len(chunk) + parts.append(f'--- {Path(p).name} ({p}) ---\n{chunk}') + return ("\n\n".join(parts), cache) + + +__all__ = ["pdf_to_markdown", "extract_file_contents"] diff --git a/config.py b/config.py index ba07910..9eda41b 100644 --- a/config.py +++ b/config.py @@ -16,6 +16,7 @@ import json import os from dataclasses import dataclass, field from pathlib import Path + from typing import Any, Dict, List CONFIG_DIR = Path.home() / ".cowork_local" @@ -273,6 +274,11 @@ def _deep_merge(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any def _apply_env_overrides(data: Dict[str, Any]) -> Dict[str, Any]: + """Cho phép biến môi trường ghi đè cấu hình. + + Dùng khi chạy trong container/CI: đặt endpoint và khoá qua biến môi trường mà + không phải sửa file cấu hình. + """ data = copy.deepcopy(data) oc = data["providers"]["openai_compat"] if os.getenv("OPENAI_API_KEY"): @@ -343,274 +349,45 @@ def _migrate_connectors(data: Dict[str, Any]) -> None: data["mcp_servers"] = [] # migrated — the UI no longer manages this -@dataclass -class AppConfig: - """In-memory view of the configuration with load/save helpers.""" +# Deferred: JsonConfigRepository's own import chain (infrastructure.persistence +# .json -> task_repository_impl -> core.tasks) reads CONFIG_DIR back from this +# module, so importing it before CONFIG_DIR exists here is a circular import. +from .infrastructure.config.json_config_repository import JsonConfigRepository - data: Dict[str, Any] = field(default_factory=lambda: copy.deepcopy(DEFAULT_CONFIG)) - path: Path = CONFIG_PATH - # ---- persistence ------------------------------------------------- +class AppConfig(JsonConfigRepository): + """Vỏ tương thích — R02 đã thay lớp này bằng :class:`JsonConfigRepository`. + + Ngày 25/08 app chuyển hẳn sang repository (ghi nguyên tử, khoá nằm trong + kho bí mật của hệ điều hành). Nhưng cái tên ``AppConfig`` còn nằm ở 41 file + — 23 checker trong ``tools/`` và 18 file test, trong đó có test của cả ba + người. Sửa hết 41 chỗ trong một commit là đổi thứ không cần đổi và làm + review không đọc nổi. + + Nên giữ tên, đổi ruột: mọi lối vào đều dẫn tới repository. + + Bỏ hẳn được khi ``tools/`` và ``tests/`` chuyển sang gọi + ``presentation.shell.bootstrap.build_context()``. + """ + + def __init__(self, data=None, path: Path = CONFIG_PATH, **kw): + """Mở cấu hình từ đĩa, hoặc dựng thẳng từ dict khi truyền ``data``. + + Dạng ``AppConfig(data=..., path=...)`` là để 13 file test dựng cấu hình mà + không chạm đĩa; giữ nguyên vì bỏ đi là phải sửa cả 13 file. + """ + if data is None: + super().__init__(Path(path), **kw) + return + # Dạng AppConfig(data=..., path=...) mà 13 file test đang dùng: dựng + # thẳng từ dict, không đụng đĩa. + built = JsonConfigRepository.from_data(data, Path(path)) + self.__dict__.update(built.__dict__) + @classmethod - def load(cls, path: Path = CONFIG_PATH) -> "AppConfig": - merged = copy.deepcopy(DEFAULT_CONFIG) - if path.exists(): - try: - stored = json.loads(path.read_text(encoding="utf-8")) - merged = _deep_merge(merged, stored) - except (json.JSONDecodeError, OSError): - # Corrupt config should never block startup. - merged = copy.deepcopy(DEFAULT_CONFIG) - merged = _apply_env_overrides(merged) - # "unlocked" is a runtime-only Settings-panel state (see the "ms365" - # comment in DEFAULT_CONFIG) — never trust a stored/hand-edited value, - # every launch starts locked. - merged.setdefault("ms365", {})["unlocked"] = False - _migrate_connectors(merged) # office→ms365 + legacy mcp_servers→other - return cls(data=merged, path=path) + def load(cls, path: Path = CONFIG_PATH) -> "JsonConfigRepository": + """Điểm vào cũ. Giờ đi qua Composition Root nên checker và app dùng + chung một đường dựng — kể cả phần ráp kho bí mật.""" + from .presentation.shell.bootstrap import build_config + return build_config(Path(path)) - def save(self) -> None: - self.path.parent.mkdir(parents=True, exist_ok=True) - to_write = self.data - if self.data.get("ms365", {}).get("unlocked"): - # Defense in depth: even if some caller saves without having gone - # through the Settings dialog's own auto-lock-after-save flow, the - # unlock state must never reach disk. - to_write = copy.deepcopy(self.data) - to_write["ms365"]["unlocked"] = False - self.path.write_text( - json.dumps(to_write, indent=2, ensure_ascii=False), encoding="utf-8" - ) - - # ---- convenience accessors -------------------------------------- - @property - def active_provider(self) -> str: - # Migrate configs that still point at a removed provider (e.g. an older - # install saved "ollama") to a supported one, so the app never tries to - # build an unknown provider. - val = self.data.get("active_provider", "openai_compat") - return val if val in PROVIDER_LABELS else "openai_compat" - - @active_provider.setter - def active_provider(self, value: str) -> None: - self.data["active_provider"] = value - - def provider_conf(self, name: str | None = None) -> Dict[str, Any]: - name = name or self.active_provider - return self.data["providers"].get(name, {}) - - @property - def ca_bundle(self) -> str: - """Path to a custom CA/certificate PEM file, or '' for normal validation. - - Used as ``requests``' ``verify=`` argument for every outbound HTTPS call - — see the "tls_ca_bundle" comment above for when this is needed.""" - return (self.data.get("tls_ca_bundle") or "").strip() - - @ca_bundle.setter - def ca_bundle(self, value: str) -> None: - self.data["tls_ca_bundle"] = (value or "").strip() - - # ---- Microsoft 365 connections (Settings-panel lock, see DEFAULT_CONFIG) -- - @property - def ms365(self) -> Dict[str, Any]: - return self.data.setdefault("ms365", copy.deepcopy(DEFAULT_CONFIG["ms365"])) - - # ---- Login / RBAC / shared cross-machine store (see DEFAULT_CONFIG) ------ - @property - def auth(self) -> Dict[str, Any]: - return self.data.setdefault("auth", copy.deepcopy(DEFAULT_CONFIG["auth"])) - - @property - def shared_dir(self) -> str: - return (self.auth.get("shared_dir") or "").strip() - - def ms365_try_unlock(self, code: str) -> bool: - """Unlock the MS365 Settings group for this session if ``code`` matches. - - This is a client-side UI lock (prevents casually toggling a sensitive - section), NOT Microsoft authentication — see the DEFAULT_CONFIG - comment. Never persisted as unlocked; see ``save()``.""" - if (code or "") and code == self.ms365.get("unlock_code", ""): - self.data["ms365"]["unlocked"] = True - return True - return False - - def ms365_lock(self) -> None: - self.data.setdefault("ms365", {})["unlocked"] = False - - @property - def theme(self) -> str: - return self.data.get("theme", "dark") - - @theme.setter - def theme(self, value: str) -> None: - self.data["theme"] = value - - @property - def language(self) -> str: - from .i18n import DEFAULT_LANGUAGE, LANGUAGES - val = self.data.get("language", DEFAULT_LANGUAGE) - return val if val in LANGUAGES else DEFAULT_LANGUAGE - - @language.setter - def language(self, value: str) -> None: - self.data["language"] = value - - @property - def code(self) -> Dict[str, Any]: - return self.data["code"] - - @property - def tools_disabled(self) -> list: - """Built-in agent tool names the admin has turned off (Monitoring → Tools).""" - return self.data.setdefault("tools", {}).setdefault("disabled", []) - - def set_tool_enabled(self, name: str, enabled: bool) -> None: - """Enable/disable a built-in agent tool by name and persist it.""" - disabled = set(self.tools_disabled) - if enabled: - disabled.discard(name) - else: - disabled.add(name) - self.data.setdefault("tools", {})["disabled"] = sorted(disabled) - self.save() - - @property - def connect_external(self) -> bool: - """Master switch (Monitoring → Tools → Connector): when off, the agent - connects to NO external connectors (CAD/CAE/MS365/Other MCP + REST). - Defaults ON so existing setups keep working.""" - return bool(self.data.setdefault("tools", {}).get("connect_external", True)) - - def set_connect_external(self, enabled: bool) -> None: - self.data.setdefault("tools", {})["connect_external"] = bool(enabled) - self.save() - - # ---- one-time seeding bookkeeping (built-in skill library / flows) ------- - @property - def seeded_library_skills(self) -> List[str]: - """Slugs of bundled library skills already seeded into the user's Skill - Manager — so a user-deleted one is never silently re-seeded.""" - return list(self.data.setdefault("seeded_library_skills", [])) - - @seeded_library_skills.setter - def seeded_library_skills(self, slugs) -> None: - self.data["seeded_library_skills"] = list(dict.fromkeys(slugs or [])) - - @property - def seeded_builtin_flows(self) -> List[str]: - """Ids of built-in Co4E flows already seeded (same respect-user-deletion - rule as seeded_library_skills).""" - return list(self.data.setdefault("seeded_builtin_flows", [])) - - @seeded_builtin_flows.setter - def seeded_builtin_flows(self, ids) -> None: - self.data["seeded_builtin_flows"] = list(dict.fromkeys(ids or [])) - - @property - def teams(self) -> Dict[str, Any]: - return self.data["teams"] - - @property - def history(self) -> Dict[str, Any]: - return self.data["history"] - - @property - def codebase_memory(self) -> Dict[str, Any]: - return self.data["codebase_memory"] - - @property - def agent_security(self) -> Dict[str, Any]: - return self.data["agent_security"] - - @property - def mcp_servers(self) -> List[Dict[str, Any]]: - return self.data.setdefault("mcp_servers", []) - - @property - def ext_connectors(self) -> Dict[str, List[Dict[str, Any]]]: - """Unified Connectors (MCP), grouped by category CAD/CAE/MS365/Other — - see core/ext_connectors.py for the per-entry shape and CATEGORIES.""" - d = self.data.setdefault("ext_connectors", {"cad": [], "cae": [], "ms365": [], "other": []}) - for cat in ("cad", "cae", "ms365", "other"): - d.setdefault(cat, []) - return d - - @property - def cowork(self) -> Dict[str, Any]: - return self.data["cowork"] - - @property - def routing(self) -> Dict[str, Any]: - """Auto Model Assessment & Routing behaviour config (see DEFAULT_CONFIG). - - Always returns a dict with every expected key present, backfilling any - missing sub-keys from the defaults so older configs upgrade seamlessly.""" - d = self.data.setdefault("routing", copy.deepcopy(DEFAULT_CONFIG["routing"])) - for k, v in DEFAULT_CONFIG["routing"].items(): - d.setdefault(k, copy.deepcopy(v)) - d.setdefault("surface_modes", {}) - for surface in ("cowork", "co4e", "ai_edit"): - d["surface_modes"].setdefault(surface, "") - return d - - def routing_mode_for(self, surface: str) -> str: - """Effective Off/Auto/Manual mode for a chat surface. - - A per-surface override ("auto"/"manual"/"off") wins; an empty override - falls back to the global ``switch_mode``.""" - routing = self.routing - override = (routing.get("surface_modes", {}) or {}).get(surface, "") - mode = override or routing.get("switch_mode", "off") - return mode if mode in ("off", "auto", "manual") else "off" - - def set_routing_mode_for(self, surface: str, mode: str) -> None: - """Persist a chat surface's Off/Auto/Manual toggle selection.""" - mode = mode if mode in ("off", "auto", "manual") else "off" - self.routing.setdefault("surface_modes", {})[surface] = mode - self.save() - - @property - def structure(self) -> Dict[str, Any]: - return self.data.setdefault("structure", {"max_nodes": 400, "max_edges": 400}) - - @property - def monitoring_visibility(self) -> Dict[str, bool]: - return self.data.setdefault( - "monitoring_visibility", copy.deepcopy(DEFAULT_CONFIG["monitoring_visibility"])) - - def cowork_output_dir(self) -> Path: - """Where Cowork saves generated files (OneDrive folder by default).""" - custom = (self.cowork.get("output_dir") or "").strip() - if custom: - return Path(custom).expanduser() - from . import paths # local import avoids any import cycle - root = paths.primary_onedrive_root() - if root is not None: - return root / "CoworkLocal" / "output" - return CONFIG_DIR / "output" / "cowork" - - def history_dir(self) -> Path: - """Resolve where conversation history is stored. - - When a project is open, its history is stored INSIDE the project's - workspace folder (``_project_history_dir``, set by the Workspace screen) - so that sharing/syncing that folder shares the history — another machine - opening the same folder sees the conversations and can continue them. - Otherwise: Local (default) or OneDrive.""" - rt = getattr(self, "_project_history_dir", None) - if rt: - return Path(rt) - custom = (self.history.get("custom_dir") or "").strip() - if custom: - return Path(custom).expanduser() - if self.history.get("location") == "onedrive": - from . import paths # local import avoids any import cycle - root = paths.primary_onedrive_root() - if root is not None: - return root / "CoworkLocal" / "history" - return HISTORY_DIR - - def model_label(self) -> str: - return str(self.provider_conf().get("model", "?")) diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..16a1562 --- /dev/null +++ b/conftest.py @@ -0,0 +1,32 @@ +"""Root pytest conftest — loaded before ``tests/conftest.py``. + +This checkout lives on disk as ``Refactor`` (not ``cowork_local``), while +``tests/`` imports everything as ``from cowork_local... import ...`` and +``tests/conftest.py`` makes that resolve by putting this repo's *parent* +directory on ``sys.path`` (expecting the repo root itself to be named +``cowork_local``). A sibling folder literally named ``cowork_local`` (an +unrelated, older checkout) already exists next to this one, so without this +file Python would silently import THAT folder instead of this repository +whenever a test does ``import cowork_local``. + +Registering the alias here — before ``tests/conftest.py`` touches +``sys.path`` — caches this repository in ``sys.modules['cowork_local']`` +first, so the later ``sys.path`` mutation has nothing left to do (imports +are cached by name; the first successful import of a given name wins for +the rest of the process). +""" +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +_ROOT = Path(__file__).resolve().parent + +if "cowork_local" not in sys.modules: + spec = importlib.util.spec_from_file_location( + "cowork_local", _ROOT / "__init__.py", submodule_search_locations=[str(_ROOT)], + ) + module = importlib.util.module_from_spec(spec) + sys.modules["cowork_local"] = module + spec.loader.exec_module(module) diff --git a/core/accounts.py b/core/accounts.py index 9c8a212..378e1bf 100644 --- a/core/accounts.py +++ b/core/accounts.py @@ -35,6 +35,7 @@ _LAST_LOGIN_PATH = CONFIG_DIR / "last_login.json" def save_last_login(username: str, role: str) -> None: + """Nhớ tài khoản đăng nhập gần nhất để lần mở sau điền sẵn.""" try: _LAST_LOGIN_PATH.parent.mkdir(parents=True, exist_ok=True) _LAST_LOGIN_PATH.write_text( @@ -44,6 +45,7 @@ def save_last_login(username: str, role: str) -> None: def load_last_login() -> Optional[Tuple[str, str]]: + """Cặp (tên đăng nhập, vai trò) của lần đăng nhập gần nhất; ``None`` nếu chưa có.""" try: data = json.loads(_LAST_LOGIN_PATH.read_text(encoding="utf-8")) username, role = data.get("username", ""), data.get("role", "") @@ -61,6 +63,7 @@ CODE_LENGTH = 12 @dataclass class Account: + """Một tài khoản người dùng: tên đăng nhập, vai trò, tên hiển thị và nhóm.""" username: str role: str display_name: str = "" @@ -73,6 +76,7 @@ class Account: def accounts_dir(shared_dir: str) -> Path: + """Thư mục chứa tài khoản, nằm trong thư mục chia sẻ của đội.""" return Path(shared_dir).expanduser() / "accounts" @@ -93,6 +97,7 @@ def generate_code(existing_codes: Optional[Set[str]] = None) -> str: def save_account(account: Account, directory: Path) -> Path: + """Ghi một tài khoản ra ``.json`` (tên file đã được làm sạch).""" directory.mkdir(parents=True, exist_ok=True) path = directory / f"{_safe_username(account.username)}.json" path.write_text(json.dumps(asdict(account), ensure_ascii=False, indent=2), encoding="utf-8") @@ -100,6 +105,7 @@ def save_account(account: Account, directory: Path) -> Path: def load_account(username: str, directory: Path) -> Optional[Account]: + """Đọc một tài khoản theo tên đăng nhập; không có thì trả ``None``.""" path = directory / f"{_safe_username(username)}.json" if not path.exists(): return None @@ -112,6 +118,7 @@ def load_account(username: str, directory: Path) -> Optional[Account]: def list_accounts(directory: Path) -> List[Account]: + """Liệt kê mọi tài khoản trong thư mục; thư mục chưa có thì trả list rỗng.""" if not directory.exists(): return [] out: List[Account] = [] @@ -124,6 +131,7 @@ def list_accounts(directory: Path) -> List[Account]: def delete_account(username: str, directory: Path) -> bool: + """Xoá file tài khoản; trả về ``True`` nếu có file để xoá.""" path = directory / f"{_safe_username(username)}.json" try: path.unlink() @@ -133,6 +141,7 @@ def delete_account(username: str, directory: Path) -> bool: def find_by_username(username: str, directory: Path) -> Optional[Account]: + """Bí danh của :func:`load_account`, giữ cho mã cũ gọi theo tên này vẫn chạy.""" return load_account(username, directory) diff --git a/core/admin_agents.py b/core/admin_agents.py index 30c6d4f..092228f 100644 --- a/core/admin_agents.py +++ b/core/admin_agents.py @@ -74,6 +74,7 @@ _KIND_PROMPTS = { @dataclass class AdminAgent: + """Một agent chuyên trách do quản trị cấu hình: prompt riêng, provider và model riêng.""" agent_id: str name: str task_kind: str = "cowork" @@ -85,6 +86,9 @@ class AdminAgent: updated_by: str = "" def effective_prompt(self) -> str: + """Prompt hệ thống thật sự dùng: prompt mặc định theo loại việc, rồi tới phần + quản trị viết thêm. + """ parts = [_KIND_PROMPTS.get(self.task_kind, ""), (self.prompt or "").strip()] return "\n\n".join(p for p in parts if p) @@ -98,12 +102,17 @@ def agents_admin_dir(shared_dir: str = "") -> Path: def _slug(name: str) -> str: + """Định danh an toàn cho tên file, suy từ tên agent.""" s = re.sub(r"[^\w\-]+", "-", (name or "").strip().lower()).strip("-") return s or "agent" def new_agent(name: str, task_kind: str = "cowork", prompt: str = "", provider: str = "", model: str = "", updated_by: str = "") -> AdminAgent: + """Tạo một agent quản trị mới; loại việc lạ thì rơi về 'cowork'. + + Id ghép slug với 6 ký tự ngẫu nhiên để hai agent trùng tên không đè file nhau. + """ return AdminAgent( agent_id=f"{_slug(name)}-{uuid.uuid4().hex[:6]}", name=name.strip(), task_kind=task_kind if task_kind in TASK_KINDS else "cowork", @@ -113,6 +122,7 @@ def new_agent(name: str, task_kind: str = "cowork", prompt: str = "", def save_agent(agent: AdminAgent, directory: Path) -> Path: + """Ghi một agent ra ``.json``.""" directory.mkdir(parents=True, exist_ok=True) path = directory / f"{agent.agent_id}.json" path.write_text(json.dumps(asdict(agent), ensure_ascii=False, indent=2), encoding="utf-8") @@ -120,6 +130,7 @@ def save_agent(agent: AdminAgent, directory: Path) -> Path: def load_agent(agent_id: str, directory: Path) -> Optional[AdminAgent]: + """Đọc một agent theo id; không có thì trả ``None``.""" path = directory / f"{agent_id}.json" if not path.exists(): return None @@ -132,6 +143,7 @@ def load_agent(agent_id: str, directory: Path) -> Optional[AdminAgent]: def list_agents(directory: Path, enabled_only: bool = False) -> List[AdminAgent]: + """Liệt kê agent trong thư mục; ``enabled_only`` chỉ lấy agent đang bật.""" if not directory.exists(): return [] out: List[AdminAgent] = [] @@ -165,6 +177,7 @@ def ensure_help_agent(directory: Path) -> AdminAgent: def delete_agent(agent_id: str, directory: Path) -> bool: + """Xoá file agent; trả về ``True`` nếu có file để xoá.""" try: (directory / f"{agent_id}.json").unlink() return True diff --git a/core/agent_command.py b/core/agent_command.py index d3271b2..5da822f 100644 --- a/core/agent_command.py +++ b/core/agent_command.py @@ -31,6 +31,7 @@ _CMD = re.compile(r"(? str: + """Định danh an toàn suy từ tên agent (dùng chung hàm với Co4E).""" from .co4e import slugify return slugify(name) @@ -45,6 +46,11 @@ def collect_agents(shared_dir: str = "") -> List[dict]: seen: set[str] = set() def _add(slug: str, name: str, desc: str, persona: str, source: str) -> None: + """Thêm một agent vào danh sách gộp; bỏ qua nếu trùng slug hoặc thiếu persona. + + Agent không có persona thì không dùng được — thêm vào chỉ làm bảng gợi ý dài + ra mà chọn vào lại không chạy. + """ if not slug or slug in seen or not persona.strip(): return seen.add(slug) @@ -69,6 +75,7 @@ def collect_agents(shared_dir: str = "") -> List[dict]: def _persona_block(agent: dict) -> str: + """Khối prompt mô tả một agent, chèn vào đầu lượt chat khi người dùng gõ ``/agent:``.""" return f"## Agent: {agent['name']}\n{agent['persona']}" diff --git a/core/agent_roles.py b/core/agent_roles.py index 7f605b4..4e2d6c6 100644 --- a/core/agent_roles.py +++ b/core/agent_roles.py @@ -37,6 +37,7 @@ HELP = "help" class AgentRole(NamedTuple): + """Một vai trò agent: khoá, nhãn hiển thị và prompt mặc định.""" key: str label: str description: str @@ -61,5 +62,6 @@ ROLES: Dict[str, AgentRole] = { def label_for(role_key: str) -> str: + """Nhãn của một vai trò; khoá lạ thì trả về chính khoá, rỗng thì trả về "—".""" role = ROLES.get(role_key) return role.label if role else (role_key or "—") diff --git a/core/agent_security.py b/core/agent_security.py index 62554f6..8877301 100644 --- a/core/agent_security.py +++ b/core/agent_security.py @@ -27,27 +27,12 @@ from __future__ import annotations import json import re -from dataclasses import dataclass from typing import List, Optional from ..providers.base import Provider from . import security_rules - - -class SecurityBlocked(RuntimeError): - """A guardrail refused an action. ``verdict`` carries the full detail for - the admin alert; ``str(exc)`` is the short, user-facing reason.""" - - def __init__(self, verdict: "SecurityVerdict"): - super().__init__(verdict.reason or f"Blocked by agent security ({verdict.layer}).") - self.verdict = verdict - - -@dataclass -class SecurityVerdict: - allowed: bool - reason: str = "" - layer: str = "" # "prompt" | "attachment" | "command" +from .agent_security_alert import notify_admin +from .agent_security_types import SecurityBlocked, SecurityVerdict def combined_rules_text(config, max_chars: int = 8000, agent_kind: str = "cowork") -> str: @@ -164,6 +149,10 @@ def _ai_verdict(provider: Provider, system_prompt: str, content: str, layer: str def validate_prompt(provider: Provider, user_text: str, rules_text: str) -> SecurityVerdict: + """Nhờ model xét prompt người dùng theo bộ luật an toàn. + + Prompt rỗng thì cho qua ngay, khỏi tốn một lượt gọi. + """ if not (user_text or "").strip(): return SecurityVerdict(True, "", "prompt") system = _PROMPT_SYSTEM.format(rules=rules_text or "(no additional rules configured)") @@ -172,6 +161,7 @@ def validate_prompt(provider: Provider, user_text: str, rules_text: str) -> Secu def validate_attachment(provider: Provider, filename: str, content: str, rules_text: str) -> SecurityVerdict: + """Nhờ model xét nội dung một tệp đính kèm theo bộ luật an toàn.""" if not (content or "").strip(): return SecurityVerdict(True, "", "attachment") system = _ATTACHMENT_SYSTEM.format(rules=rules_text or "(no additional rules configured)") @@ -180,6 +170,11 @@ def validate_attachment(provider: Provider, filename: str, content: str, def validate_command(provider: Provider, command: str, rules_text: str, ai_enabled: bool) -> SecurityVerdict: + """Nhờ model xét một lệnh shell theo bộ luật an toàn. + + ``ai_enabled=False`` thì cho qua — người dùng đã tắt lớp xét bằng AI, bộ luật + tĩnh vẫn chạy ở chỗ khác. + """ if not ai_enabled: return SecurityVerdict(True, "", "command") system = _COMMAND_SYSTEM.format(rules=rules_text or "(no additional rules configured)") @@ -188,6 +183,7 @@ def validate_command(provider: Provider, command: str, # ---- call-site convenience wrappers (used by chat_agent.py / code_agent.py) -- def _security_conf(config) -> dict: + """Nhóm cấu hình ``agent_security``; không có config thì trả dict rỗng.""" return (config.data.get("agent_security", {}) if config is not None else {}) @@ -236,7 +232,6 @@ def enforce_prompt(provider: Provider, messages: List[dict], config, emit, emit({"type": "notice", "level": "warning", "text": f"🛡 Yêu cầu bị chặn bởi Agent Security: {verdict.reason}"}) from . import audit_log - from .agent_security_alert import notify_admin audit_log.record("security_block", "prompt", False, verdict.reason) notify_admin(config, verdict, detail=user_text[:1000]) @@ -266,7 +261,6 @@ def enforce_command(provider: Provider, name: str, args: dict, config, emit, emit({"type": "notice", "level": "warning", "text": f"🛡 Lệnh bị chặn bởi Agent Security ({verdict.layer}): {verdict.reason}"}) from . import audit_log - from .agent_security_alert import notify_admin audit_log.record("security_block", name, False, f"{verdict.layer}: {verdict.reason}") notify_admin(config, verdict, detail=command) diff --git a/core/agent_security_alert.py b/core/agent_security_alert.py index 2d337a9..68e59b4 100644 --- a/core/agent_security_alert.py +++ b/core/agent_security_alert.py @@ -12,7 +12,7 @@ from __future__ import annotations from typing import Tuple from . import ms365_graph -from .agent_security import SecurityVerdict +from .agent_security_types import SecurityVerdict from .ms365_auth import Ms365AuthError, get_access_token diff --git a/core/agent_security_types.py b/core/agent_security_types.py new file mode 100644 index 0000000..af28aab --- /dev/null +++ b/core/agent_security_types.py @@ -0,0 +1,35 @@ +"""Shared value types for the Agent Security guardrails. + +``SecurityVerdict``/``SecurityBlocked`` used to be defined in +``agent_security.py``, which forced ``agent_security_alert.py`` (which only +needs the *type*, to annotate/read ``notify_admin``'s ``verdict`` argument) to +import from it — while ``agent_security.py`` itself needed to call +``agent_security_alert.notify_admin()``, an architectural cycle only avoided +at runtime by deferring that second import inside a function body. + +Hoisting the shared type into this dependency-free leaf module lets both +sides import it directly, so ``agent_security.py`` can import +``agent_security_alert`` at module top level too — no cycle, no deferred +imports needed for this pair. +""" +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass +class SecurityVerdict: + """Kết quả một lớp kiểm an toàn: cho qua hay không, lý do, và lớp nào ra phán quyết.""" + allowed: bool + reason: str = "" + layer: str = "" # "prompt" | "attachment" | "command" + + +class SecurityBlocked(RuntimeError): + """A guardrail refused an action. ``verdict`` carries the full detail for + the admin alert; ``str(exc)`` is the short, user-facing reason.""" + + def __init__(self, verdict: SecurityVerdict): + """Lấy lý do trong phán quyết làm thông điệp; không có lý do thì ghi rõ lớp nào chặn.""" + super().__init__(verdict.reason or f"Blocked by agent security ({verdict.layer}).") + self.verdict = verdict diff --git a/core/ai_task_planner.py b/core/ai_task_planner.py index fe325b1..22bd0a6 100644 --- a/core/ai_task_planner.py +++ b/core/ai_task_planner.py @@ -54,6 +54,10 @@ def _extract_json(text: str) -> Optional[dict]: def _clamp(value, allowed, default): + """Ép một giá trị về tập hợp lệ; ngoài tập thì lấy mặc định. + + Cần vì model hay trả về giá trị gần đúng ('High' thay vì 'high'). + """ return value if value in allowed else default diff --git a/core/appcontainer_sandbox.py b/core/appcontainer_sandbox.py index b872867..3b14ba5 100644 --- a/core/appcontainer_sandbox.py +++ b/core/appcontainer_sandbox.py @@ -48,6 +48,7 @@ class AppContainerSandbox: display_name: str = "CoworkLocal Sandbox", description: str = "Isolated execution environment for Cowork Local agent", ): + """Đặt tên và mô tả cho hồ sơ AppContainer; chưa tạo gì trên máy.""" self.profile_name = profile_name self.display_name = display_name self.description = description diff --git a/core/audit_log.py b/core/audit_log.py index 505a5f2..dab7fd8 100644 --- a/core/audit_log.py +++ b/core/audit_log.py @@ -6,15 +6,23 @@ storage systems). One JSON line per event, one file per day under ``~/.cowork_local/audit/`` — same on-disk shape as ``usage_tracker.py`` (day-sharded ``.jsonl``, append-only, ``record()`` never raises so audit logging can never break a chat turn). + +This module is now a thin, backward-compatible wrapper around +:class:`infrastructure.telemetry.audit_logger.CanonicalAuditLogger` — every +existing call site (``agent_security.py``, ``chat_agent.py``, ``tools.py``, +``ext_connectors.py``, ``mcp_client.py``, ``ms365_local.py``, +``permissions.py``, ``ui/structure_graph_view.py``, ``app.py``) keeps calling +``audit_log.set_identity``/``record``/``load_events`` exactly as before; only +the implementation moved. """ from __future__ import annotations -import json -from datetime import date, datetime +from datetime import date from pathlib import Path from typing import Any, Dict, List, Optional from ..config import CONFIG_DIR +from ..infrastructure.telemetry.audit_logger import CanonicalAuditLogger AUDIT_DIR = CONFIG_DIR / "audit" @@ -23,66 +31,21 @@ AUDIT_DIR = CONFIG_DIR / "audit" # action), "mcp_call" (a call to an external MCP server's tool). Kind = str -# Process-global identity — who's logged in, their role, and this machine's -# name — set once right after login (app.py::run()), mirroring -# usage_tracker.py's identical pattern. NOT thread-local: fixed per process. -_identity_account = "" -_identity_role = "" -_identity_machine = "" -_identity_shared_dir = "" +_logger = CanonicalAuditLogger(AUDIT_DIR) def set_identity(account: str, machine: str, role: str = "", shared_dir: str = "") -> None: """Called once after login succeeds. ``shared_dir``, when reachable, makes every subsequent :func:`record` ALSO best-effort-append to the shared cross-machine telemetry store (see :mod:`telemetry_shared`).""" - global _identity_account, _identity_role, _identity_machine, _identity_shared_dir - _identity_account = account or "" - _identity_role = role or "" - _identity_machine = machine or "" - _identity_shared_dir = shared_dir or "" + _logger.set_identity(account, machine, role=role, shared_dir=shared_dir) def record(kind: Kind, name: str, ok: bool, detail: str = "", agent_role: str = "") -> None: """Append one audit event. Never raises — audit logging must never break a chat turn, a permission decision, or a tool call.""" - try: - now = datetime.now() - event = { - "ts": now.isoformat(timespec="seconds"), - "kind": kind, - "agent_role": agent_role or "", - "name": name or "", - "ok": bool(ok), - "detail": (detail or "")[:2000], # bounded — never let a huge blob bloat the log - "account": _identity_account, - "role": _identity_role, - "machine": _identity_machine, - } - AUDIT_DIR.mkdir(parents=True, exist_ok=True) - path = AUDIT_DIR / f"{now.strftime('%Y-%m-%d')}.jsonl" - with path.open("a", encoding="utf-8") as f: - f.write(json.dumps(event, ensure_ascii=False) + "\n") - _write_shared(event, now) - except Exception: # noqa: BLE001 - pass - - -def _write_shared(event: Dict[str, Any], now: datetime) -> None: - """Best-effort mirror of ``event`` into the shared cross-machine store — - one file PER MACHINE per day, so no two machines ever write the same - file. Never raises.""" - if not _identity_shared_dir or not _identity_machine: - return - try: - shared = Path(_identity_shared_dir).expanduser() / "telemetry" / "audit" - shared.mkdir(parents=True, exist_ok=True) - path = shared / f"{_identity_machine}-{now.strftime('%Y-%m-%d')}.jsonl" - with path.open("a", encoding="utf-8") as f: - f.write(json.dumps(event, ensure_ascii=False) + "\n") - except Exception: # noqa: BLE001 - pass + _logger.record(kind, name, ok, detail=detail, agent_role=agent_role) def load_events(start: Optional[date] = None, end: Optional[date] = None, @@ -91,25 +54,5 @@ def load_events(start: Optional[date] = None, end: Optional[date] = None, """Events between ``start``/``end`` (inclusive; None = unbounded), optionally filtered to one ``kind`` — this IS how each Monitoring Dashboard panel gets its own slice of the same underlying log.""" - directory = directory or AUDIT_DIR - if not directory.exists(): - return [] - events: List[Dict[str, Any]] = [] - for path in sorted(directory.glob("*.jsonl")): - try: - day = datetime.strptime(path.stem, "%Y-%m-%d").date() - except ValueError: - continue - if (start and day < start) or (end and day > end): - continue - try: - for line in path.read_text(encoding="utf-8").splitlines(): - if not line.strip(): - continue - event = json.loads(line) - if kind is not None and event.get("kind") != kind: - continue - events.append(event) - except (OSError, json.JSONDecodeError): - continue - return events + events = _logger.load_events(start=start, end=end, kind=kind, directory=directory) + return [e.to_dict() for e in events] diff --git a/core/chat_agent.py b/core/chat_agent.py index 20b3d26..3b41cb9 100644 --- a/core/chat_agent.py +++ b/core/chat_agent.py @@ -11,6 +11,8 @@ import re from pathlib import Path 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 . import agent_roles 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. _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] CancelFn = Callable[[], bool] @@ -126,6 +135,12 @@ _UNSAFE = re.compile(r'[\\/:*?"<>|\x00-\x1f]+') def _safe_filename(name: str) -> str: + """Làm sạch tên tệp do model đề xuất: bỏ đường dẫn, thay ký tự cấm, không bao + giờ trả về chuỗi rỗng. + + Model hay trả về tên có dấu ``/`` hoặc ``..`` — ghi thẳng là thoát khỏi thư + mục làm việc. + """ base = Path(str(name)).name.strip() base = _UNSAFE.sub("_", base).strip(" _.") or "output.txt" if "." not in base: @@ -298,17 +313,23 @@ def run_chat( emit: EmitFn, cancel: Optional[CancelFn] = None, ) -> Dict[str, Any]: + """Chạy một lượt chat thuần (không có tool) và phát nội dung dần ra ngoài. + + Tự chèn prompt hệ thống nếu tin nhắn đầu chưa phải ``system``. + """ if not messages or messages[0].get("role") != "system": messages.insert(0, {"role": "system", "content": COWORK_SYSTEM_PROMPT}) # Rulebase: always attach security rules so the agent follows them every turn _apply_security_rules(messages, load_rules()) def on_text(piece: str) -> None: + """Đẩy từng mẩu câu trả lời ra ngoài.""" emit({"type": "text", "delta": piece}) def on_reasoning(piece: str) -> None: # Stream the model's reasoning so the UI can show a live, collapsible # "Thinking" box (and keep the indicator active). + """Đẩy từng mẩu suy luận nội bộ ra ngoài, để giao diện hiện hộp "Đang nghĩ".""" emit({"type": "reasoning", "delta": piece}) assistant = provider.chat(messages, tools=None, on_text=on_text, cancel=cancel, @@ -388,6 +409,19 @@ def run_cowork( jira=(security_config.data.get("jira") if security_config else None)) extra_tools = extra_tools or [] 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. # Built-in tools the admin disabled (Monitoring → Tools) are filtered out. from .tools import enabled_tool_specs @@ -489,6 +523,18 @@ def run_cowork( preview = {"kind": "info", "title": name, "text": str(args)} emit({"type": "tool_proposed", "id": tc_id, "name": name, "args": args, "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) emit({"type": "tool_result", "id": tc_id, "name": name, "ok": result.get("ok", False), "output": result.get("output", "")}) @@ -528,16 +574,18 @@ def run_cowork( # Permission Management (Sandbox Security Layer) — only when a # gate was actually supplied (Settings: "confirm before running # commands"); None preserves the pre-existing auto-run behavior. - if gate is not None and name in ("run_command", "install_package"): - approved = gate.request({"name": name, "args": args, "preview": preview}) - if not approved: - result = {"ok": False, "output": "Rejected by user."} - evt = {"type": "tool_result", "id": tc_id, "name": name, - "ok": False, "output": result["output"]} - emit(evt) - messages.append({"role": "tool", "tool_call_id": tc_id, - "name": name, "content": result["output"]}) - continue + # R05-T03: gating is now capability-driven (see + # turn_tool_policy above) instead of a literal name tuple. + if not turn_tool_policy.allow( + name, gate, {"name": name, "args": args, "preview": preview} + ): + result = {"ok": False, "output": "Rejected by user."} + evt = {"type": "tool_result", "id": tc_id, "name": name, + "ok": False, "output": result["output"]} + emit(evt) + messages.append({"role": "tool", "tool_call_id": tc_id, + "name": name, "content": result["output"]}) + continue if name == "save_file": result = _do_save_file(output_dir, title, args) diff --git a/core/co4e.py b/core/co4e.py index e0337c4..ff64646 100644 --- a/core/co4e.py +++ b/core/co4e.py @@ -18,6 +18,8 @@ existing ``core/skills.py`` registry. """ from __future__ import annotations +from ..infrastructure.persistence.json.atomic_json_file import AtomicJsonFile + import json from dataclasses import asdict, dataclass, field from pathlib import Path @@ -52,6 +54,9 @@ RUN_MODES = ("auto", "plan", "manual") def slugify(value: str) -> str: + """Chuyển một chuỗi thành slug an toàn cho tên file: chỉ chữ/số/gạch, gộp gạch + liên tiếp. Rỗng thì trả về 'step' để không bao giờ sinh ra tên file trống. + """ s = "".join(c if (c.isalnum() or c in "-_") else "-" for c in (value or "").strip().lower()) return "-".join(filter(None, s.split("-"))) or "step" @@ -86,11 +91,13 @@ class Step: @property def is_parallel(self) -> bool: + """Bước này có chạy nhiều sub-agent song song hay không.""" return self.variant == "parallel" @dataclass class Node: + """Một node trên khung vẽ: id, toạ độ, và bước (:class:`Step`) mà nó đại diện.""" id: str x: float = 0.0 y: float = 0.0 @@ -99,6 +106,7 @@ class Node: @dataclass class Edge: + """Một cạnh nối hai node, quy định thứ tự chạy giữa chúng.""" id: str source: str target: str @@ -106,6 +114,7 @@ class Edge: @dataclass class Workflow: + """Một luồng Co4E: danh sách node, cạnh, và cờ đánh dấu đây có phải mẫu không.""" id: str name: str = "Untitled flow" is_template: bool = False @@ -130,6 +139,10 @@ class CustomAgent: # ---- (de)serialization --------------------------------------------------- def step_from_dict(d: dict) -> Step: + """Dựng :class:`Step` từ dict đọc trên đĩa. + + Lọc bỏ khoá lạ để file luồng của phiên bản mới hơn không làm vỡ bản cũ. + """ d = dict(d or {}) subs = d.pop("sub_agents", None) or [] known = Step().__dict__.keys() @@ -143,11 +156,13 @@ def step_from_dict(d: dict) -> Step: def node_from_dict(d: dict) -> Node: + """Dựng :class:`Node` từ dict đọc trên đĩa.""" return Node(id=str(d.get("id", "")), x=float(d.get("x", 0) or 0), y=float(d.get("y", 0) or 0), data=step_from_dict(d.get("data", {}))) def workflow_from_dict(d: dict) -> Workflow: + """Dựng :class:`Workflow` từ dict đọc trên đĩa.""" return Workflow( id=str(d.get("id", "")), name=d.get("name", "Untitled flow"), @@ -159,6 +174,7 @@ def workflow_from_dict(d: dict) -> Workflow: def workflow_to_dict(wf: Workflow) -> dict: + """Chuyển một luồng thành dict để ghi JSON.""" return { "id": wf.id, "name": wf.name, "is_template": wf.is_template, "nodes": [{"id": n.id, "x": n.x, "y": n.y, "data": _step_dict(n.data)} for n in wf.nodes], @@ -167,16 +183,19 @@ def workflow_to_dict(wf: Workflow) -> dict: def _step_dict(step: Step) -> dict: + """Chuyển một bước thành dict; ``asdict`` đã tự chuyển ``sub_agents`` thành list dict.""" d = asdict(step) # asdict already turns sub_agents into list[dict] return d def agent_to_dict(a: CustomAgent) -> dict: + """Chuyển một agent tự tạo thành dict để ghi JSON.""" return asdict(a) def agent_from_dict(d: dict) -> CustomAgent: + """Dựng :class:`CustomAgent` từ dict, lọc bỏ khoá lạ.""" known = CustomAgent(id="").__dict__.keys() d = {k: v for k, v in (d or {}).items() if k in known} d.setdefault("id", "") @@ -191,32 +210,43 @@ _counter = {"n": 0} def _mint_id(prefix: str) -> str: + """Sinh id tăng dần dạng ``_000001``.""" _counter["n"] += 1 return f"{prefix}_{_counter['n']:06d}" def new_node_id() -> str: + """Id mới cho một node.""" return _mint_id("node") def new_edge_id(source: str, target: str) -> str: + """Id cạnh suy ra TỪ cặp nguồn/đích. + + Cố ý không ngẫu nhiên: nhờ vậy nối lại đúng cặp node đó luôn cho ra cùng + một id, và không thể sinh ra hai cạnh trùng nhau. + """ return f"e_{source}__{target}" def new_workflow(name: str = "Untitled flow") -> Workflow: + """Tạo một luồng rỗng với id mới.""" return Workflow(id=_mint_id("wf"), name=name) def new_custom_agent(name: str = "") -> CustomAgent: + """Tạo một agent tự tạo rỗng với id mới.""" return CustomAgent(id=_mint_id("agent"), name=name) # ---- workflow store ------------------------------------------------------ def workflows_dir() -> Path: + """Thư mục chứa file luồng.""" return WORKFLOWS_DIR def list_workflows(directory: Optional[Path] = None) -> List[Workflow]: + """Liệt kê mọi luồng đã lưu; thư mục chưa có thì trả list rỗng.""" directory = directory or WORKFLOWS_DIR if not directory.exists(): return [] @@ -230,14 +260,18 @@ def list_workflows(directory: Optional[Path] = None) -> List[Workflow]: def save_workflow(wf: Workflow, directory: Optional[Path] = None) -> Path: + """Ghi một luồng ra ``.json``, tự tạo thư mục nếu chưa có.""" directory = directory or WORKFLOWS_DIR directory.mkdir(parents=True, exist_ok=True) path = directory / f"{wf.id}.json" - path.write_text(json.dumps(workflow_to_dict(wf), ensure_ascii=False, indent=2), encoding="utf-8") + # Tiêu chí nghiệm thu A: mọi thao tác ghi tệp đi qua AtomicJsonFile. Trước + # đây ghi thẳng, nên tắt máy giữa lúc lưu là mất luôn workflow. + AtomicJsonFile(path).write(workflow_to_dict(wf)) return path def get_workflow(wf_id: str, directory: Optional[Path] = None) -> Optional[Workflow]: + """Đọc một luồng theo id; ``None`` nếu không có.""" directory = directory or WORKFLOWS_DIR path = directory / f"{wf_id}.json" if not path.exists(): @@ -276,6 +310,7 @@ def tr_copy_suffix() -> str: def delete_workflow(wf_id: str, directory: Optional[Path] = None) -> None: + """Xoá file luồng theo id; không có thì bỏ qua.""" directory = directory or WORKFLOWS_DIR path = directory / f"{wf_id}.json" if path.exists(): @@ -287,10 +322,12 @@ def delete_workflow(wf_id: str, directory: Optional[Path] = None) -> None: # ---- custom-agent store -------------------------------------------------- def agents_dir() -> Path: + """Thư mục chứa file agent tự tạo.""" return AGENTS_DIR def list_custom_agents(directory: Optional[Path] = None) -> List[CustomAgent]: + """Liệt kê mọi agent tự tạo; thư mục chưa có thì trả list rỗng.""" directory = directory or AGENTS_DIR if not directory.exists(): return [] @@ -304,14 +341,16 @@ def list_custom_agents(directory: Optional[Path] = None) -> List[CustomAgent]: def save_custom_agent(agent: CustomAgent, directory: Optional[Path] = None) -> Path: + """Ghi một agent tự tạo ra ``.json``.""" directory = directory or AGENTS_DIR directory.mkdir(parents=True, exist_ok=True) path = directory / f"{agent.id}.json" - path.write_text(json.dumps(agent_to_dict(agent), ensure_ascii=False, indent=2), encoding="utf-8") + AtomicJsonFile(path).write(agent_to_dict(agent)) return path def delete_custom_agent(agent_id: str, directory: Optional[Path] = None) -> None: + """Xoá file agent tự tạo theo id; không có thì bỏ qua.""" directory = directory or AGENTS_DIR path = directory / f"{agent_id}.json" if path.exists(): @@ -336,6 +375,11 @@ def compute_waves(nodes: List[Node], edges: List[Edge]) -> Dict[str, int]: limit = len(nodes) + 1 def depth(nid: str, seen: frozenset) -> int: + """Độ sâu của một node = lớp chạy của nó. + + Có nhớ kết quả và chặn theo ``limit``: đồ thị có vòng sẽ khiến đệ quy chạy + mãi, nên gặp node đã thấy trong nhánh hiện tại thì dừng. + """ if nid in wave: return wave[nid] if nid in seen or len(seen) > limit: @@ -356,12 +400,14 @@ def connected_component_count(nodes: List[Node], edges: List[Edge]) -> int: parent = {n.id: n.id for n in nodes} def find(x): + """Tìm gốc của một phần tử, kèm nén đường đi (union-find).""" while parent[x] != x: parent[x] = parent[parent[x]] x = parent[x] return x def union(a, b): + """Gộp hai tập hợp lại làm một (union-find).""" ra, rb = find(a), find(b) if ra != rb: parent[ra] = rb @@ -375,6 +421,7 @@ def connected_component_count(nodes: List[Node], edges: List[Edge]) -> int: # ---- run-stage compilation ---------------------------------------------- @dataclass class RunStage: + """Một chặng chạy: ứng với một node, hoặc một nhánh song song / bước gộp của nó.""" id: str # node id, or "__p" / "__pjoin" node_id: str # which canvas node this stage maps back onto wave: int @@ -391,6 +438,7 @@ PLAN_MODE_PREAMBLE = ( def build_skills_block(skills: List[str], skill_map: Dict[str, str]) -> str: + """Ghép nội dung các skill được chọn thành một khối chèn vào prompt.""" parts = [] for name in skills or []: content = (skill_map.get(name) or "").strip() @@ -403,6 +451,9 @@ def build_skills_block(skills: List[str], skill_map: Dict[str, str]) -> str: def _shared_prompt_parts(step: Step, skill_map: Dict[str, str], extra_context: str) -> str: + """Phần prompt dùng chung cho cả ba loại chặng: chỉ dẫn của bước, khối skill, + và ngữ cảnh thêm từ các bước trước. + """ parts = [] if step.instructions.strip(): parts.append(step.instructions.strip()) @@ -419,6 +470,7 @@ def _shared_prompt_parts(step: Step, skill_map: Dict[str, str], extra_context: s def build_step_prompt(step: Step, skill_map: Dict[str, str], extra_context: str = "") -> str: + """Prompt cho một bước chạy tuần tự bình thường.""" head = f'You are the {step.role} agent for the workflow step "{step.label}".' body = _shared_prompt_parts(step, skill_map, extra_context) return f"{head}\n{body}".strip() @@ -426,6 +478,11 @@ def build_step_prompt(step: Step, skill_map: Dict[str, str], extra_context: str def build_subagent_prompt(step: Step, sub: SubAgent, peers: List[str], skill_map: Dict[str, str], extra_context: str = "") -> str: + """Prompt cho một sub-agent chạy song song. + + Nói rõ nó đang chạy CÙNG LÚC với những ai và phải ở trong phạm vi của mình — + không có câu đó, các sub-agent hay làm chồng việc của nhau. + """ peer_txt = ", ".join(p for p in peers if p) or "peers" head = (f'You are the "{sub.agent}" agent working concurrently (in parallel with ' f'{peer_txt}) on the workflow step "{step.label}". Stay within your own scope.') @@ -439,6 +496,7 @@ def build_subagent_prompt(step: Step, sub: SubAgent, peers: List[str], def build_join_prompt(step: Step, skill_map: Dict[str, str], extra_context: str = "") -> str: + """Prompt cho bước gộp: hợp nhất đầu ra của các sub-agent thành một kết quả.""" head = (f'You are the coordinator for the parallel step "{step.label}". Consolidate the ' f"outputs of the sub-agents (provided above as prior outputs) into one coherent result.") body = _shared_prompt_parts(step, skill_map, extra_context) @@ -457,6 +515,9 @@ def compile_run_stages(nodes: List[Node], edges: List[Edge], stages: List[RunStage] = [] def finalize(prompt: str, preset: str) -> tuple: + """Chốt prompt của một chặng: áp phạm vi theo preset, và thêm lời mở đầu chế + độ lập kế hoạch nếu đang chạy ở chế độ đó. + """ scope = PRESET_SCOPES.get(preset) if plan_mode: prompt = PLAN_MODE_PREAMBLE + prompt diff --git a/core/co4e_builtins.py b/core/co4e_builtins.py index 179f849..71b661d 100644 --- a/core/co4e_builtins.py +++ b/core/co4e_builtins.py @@ -15,6 +15,7 @@ from .co4e import ( @dataclass class BuiltinAgent: + """Một agent dựng sẵn của Co4E: slug, tên, vai trò và prompt mặc định.""" slug: str name: str role: str diff --git a/core/co4e_run_manager.py b/core/co4e_run_manager.py index c92662f..c253daf 100644 --- a/core/co4e_run_manager.py +++ b/core/co4e_run_manager.py @@ -13,6 +13,8 @@ the run that is currently open. """ from __future__ import annotations +from ..infrastructure.persistence.json.atomic_json_file import AtomicJsonFile + import json from pathlib import Path from typing import Dict, List, Optional @@ -26,6 +28,7 @@ _HISTORY_CAP = 500 # keep the most-recent N runs on disk def _now_str() -> str: + """Mốc thời gian hiện tại dạng 'YYYY-MM-DD HH:MM' cho lịch sử run.""" from datetime import datetime return datetime.now().strftime("%Y-%m-%d %H:%M") @@ -42,6 +45,11 @@ class RunHandle: def __init__(self, run_id: str, wf_id: str, name: str, total: int, plan_mode: bool, manual: bool, created_by: str = "", created_at: str = "", project_id: str = ""): + """Một lượt chạy workflow đang sống trong bộ nhớ. + + ``total`` âm bị kẹp về 0 — số bước không thể âm, và để lọt xuống thì thanh + tiến độ vẽ ngược. + """ self.id = run_id self.wf_id = wf_id self.name = name @@ -62,9 +70,11 @@ class RunHandle: @property def running(self) -> bool: + """Lượt chạy này còn đang chạy hay không.""" return self.status == "running" def progress_text(self) -> str: + """Chuỗi tiến độ 'xong/tổng'; chưa biết tổng thì hiện trạng thái.""" return f"{self.done}/{self.total}" if self.total else self.status # ---- persistence ------------------------------------------------------ @@ -85,6 +95,7 @@ class RunHandle: @classmethod def from_record(cls, rec: dict) -> "RunHandle": + """Dựng lại một ``RunHandle`` từ bản ghi đọc trong lịch sử trên đĩa.""" from .co4e import workflow_from_dict rec = dict(rec or {}) h = cls(str(rec.get("id", "")), str(rec.get("wf_id", "")), @@ -107,10 +118,18 @@ class RunHandle: class Co4ERunManager(QObject): + """Quản lý vòng đời nhiều lượt chạy luồng Co4E cùng lúc. + + Flow Status lọc theo project, nên hầu hết truy vấn ở đây chỉ tính run thuộc + workspace ĐANG chọn — xem ``_belongs``. + """ changed = Signal() # any run's status/progress changed → refresh views event = Signal(str, dict) # (run_id, ev) — node-level events, for mirroring def __init__(self, ctx): + """Dựng bộ quản lý run và khôi phục lịch sử cũ ngay, để tab Flow Status có nội + dung ngay khi mở chứ không trống cho tới lần chạy đầu tiên. + """ super().__init__() self.ctx = ctx self._runs: Dict[str, RunHandle] = {} @@ -124,10 +143,12 @@ class Co4ERunManager(QObject): # ---- persistence ------------------------------------------------------ def _history_path(self) -> Path: + """Đường dẫn file lịch sử run.""" from .co4e import CO4E_DIR return CO4E_DIR / "run_history.json" def _load_history(self) -> None: + """Khôi phục lịch sử run từ đĩa lúc khởi động; file hỏng thì bỏ qua lặng lẽ.""" path = self._history_path() try: data = json.loads(path.read_text(encoding="utf-8")) @@ -147,20 +168,22 @@ class Co4ERunManager(QObject): self._seq = max_seq # avoid minting ids that collide with history def _save_history(self) -> None: + """Ghi ``_HISTORY_CAP`` run gần nhất xuống đĩa.""" path = self._history_path() runs = list(self._runs.values())[-_HISTORY_CAP:] payload = {"runs": [h.to_record() for h in runs]} try: path.parent.mkdir(parents=True, exist_ok=True) - tmp = path.with_suffix(".json.tmp") - tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2), - encoding="utf-8") - tmp.replace(path) # atomic — never leaves a half-written file + # AtomicJsonFile thay cho tmp+replace tự viết: bản cũ thiếu fsync + # (dữ liệu có thể còn trong bộ đệm khi mất điện) và dùng thẳng + # Path.replace, vốn thỉnh thoảng bị Defender từ chối trên Windows. + AtomicJsonFile(path).write(payload) except OSError: pass # ---- lifecycle -------------------------------------------------------- def _next_id(self) -> str: + """Sinh id run kế tiếp dạng 'runN'.""" self._seq += 1 return f"run{self._seq}" @@ -196,6 +219,7 @@ class Co4ERunManager(QObject): run_label = handle.name def job(worker: AgentWorker): + """Chạy nền: thực thi luồng, chuyển tiếp sự kiện tiến độ và cờ huỷ.""" return co4e_runner.run_workflow( ctx, nodes, edges, out_dir, worker.emit_event, worker.is_cancelled, plan_mode=plan_mode, skill_map=sk, only_nodes=only, seed_outputs=seed, @@ -213,6 +237,7 @@ class Co4ERunManager(QObject): # ---- worker callbacks ------------------------------------------------- def _on_event(self, run_id: str, ev: dict) -> None: + """Nhận sự kiện từ luồng đang chạy và cập nhật trạng thái/tiến độ của run.""" handle = self._runs.get(run_id) if handle is not None and isinstance(ev, dict): t = ev.get("type") @@ -227,6 +252,10 @@ class Co4ERunManager(QObject): self.event.emit(run_id, ev) def _on_finished(self, run_id: str) -> None: + """Job kết thúc mà không phát ``run_done``: chốt trạng thái về 'done'. + + Lẽ ra không xảy ra, nhưng thiếu bước này thì run kẹt ở 'running' mãi. + """ handle = self._runs.get(run_id) if handle is not None and handle.status == "running": # job returned without a run_done event (shouldn't happen) — settle it @@ -234,6 +263,7 @@ class Co4ERunManager(QObject): self.changed.emit() def _on_failed(self, run_id: str, err: str) -> None: + """Job ném lỗi: ghi lỗi vào bản ghi run và báo ra ngoài.""" handle = self._runs.get(run_id) if handle is not None: handle.status = "error" @@ -243,6 +273,7 @@ class Co4ERunManager(QObject): # ---- control ---------------------------------------------------------- def stop(self, run_id: str) -> None: + """Yêu cầu dừng một run đang chạy.""" handle = self._runs.get(run_id) if handle is not None and handle.worker is not None and handle.running: handle.worker.request_stop() @@ -251,6 +282,7 @@ class Co4ERunManager(QObject): def stop_all(self) -> None: # Only the CURRENT workspace's runs (Flow Status is per-project). + """Dừng mọi run của workspace đang chọn.""" for run_id in [r for r, h in self._runs.items() if self._belongs(h)]: self.stop(run_id) @@ -267,6 +299,7 @@ class Co4ERunManager(QObject): self.changed.emit() def remove(self, run_id: str) -> None: + """Xoá một run khỏi lịch sử; đang chạy thì dừng trước.""" handle = self._runs.get(run_id) if handle is not None and handle.running: self.stop(run_id) @@ -275,6 +308,7 @@ class Co4ERunManager(QObject): def clear_finished(self) -> None: # Only clear finished runs of the CURRENT workspace. + """Xoá mọi run đã kết thúc của workspace đang chọn, giữ nguyên run đang chạy.""" for run_id in [r for r, h in self._runs.items() if not h.running and self._belongs(h)]: self._runs.pop(run_id, None) self.changed.emit() @@ -293,9 +327,11 @@ class Co4ERunManager(QObject): return list(self._runs.values()) def get(self, run_id: str) -> Optional[RunHandle]: + """Bản ghi của một run theo id; ``None`` nếu không có.""" return self._runs.get(run_id) def active_count(self) -> int: + """Số run đang chạy của workspace đang chọn.""" return sum(1 for h in self._runs.values() if h.running and self._belongs(h)) def set_current_project(self, project_id: str) -> None: @@ -318,6 +354,11 @@ class Co4ERunManager(QObject): # tab), not in the config/install folder. One subfolder per flow keeps # runs tidy. Falls back to the global Cowork output dir when no workspace # is selected. + """Thư mục ghi kết quả của một luồng, tạo sẵn nếu chưa có. + + Ưu tiên thư mục của workspace đang chọn để file rơi đúng chỗ người dùng làm + việc (màn Thư mục), không rơi vào thư mục cài đặt. + """ from .co4e import slugify base = self._output_root if base is None: diff --git a/core/co4e_runner.py b/core/co4e_runner.py index 5308700..96acad9 100644 --- a/core/co4e_runner.py +++ b/core/co4e_runner.py @@ -29,6 +29,9 @@ CancelFn = Callable[[], bool] def _predecessors(nodes: List[Node], edges: List[Edge]) -> Dict[str, List[str]]: + """Bảng ``{node: các node đứng trước}`` — dùng để gom đầu ra của bước trước làm + ngữ cảnh cho bước sau. + """ ids = {n.id for n in nodes} preds: Dict[str, List[str]] = {n.id: [] for n in nodes} for e in edges: @@ -38,6 +41,7 @@ def _predecessors(nodes: List[Node], edges: List[Edge]) -> Dict[str, List[str]]: def _label_of(nodes: List[Node], node_id: str) -> str: + """Nhãn hiển thị của một node; trả về chính id nếu không tìm thấy.""" for n in nodes: if n.id == node_id: return n.data.label @@ -62,6 +66,10 @@ def _attachments_text(node, out_dir=None) -> str: parts, budget = [], _MAX_ATTACH_CHARS def _read_into(path, label, indent=""): + """Đọc một tệp đính kèm vào phần ngữ cảnh, trừ dần vào hạn mức ký tự chung. + + Có hạn mức vì vài tệp lớn là đủ đẩy cả lượt chạy vượt cửa sổ ngữ cảnh. + """ nonlocal budget name = _P(path).name if is_image(path): @@ -97,6 +105,7 @@ def _attachments_text(node, out_dir=None) -> str: def _last_assistant_text(messages: List[dict]) -> str: + """Nội dung trả lời cuối cùng của assistant; '' nếu không có.""" for m in reversed(messages): if m.get("role") == "assistant" and m.get("content"): return str(m["content"]) @@ -245,6 +254,7 @@ def run_workflow(ctx, nodes: List[Node], edges: List[Edge], out_dir: Path, # Group compiled stages by wave, preserving per-node context threading. def extra_context_for(node_id: str) -> Dict[str, str]: + """Ngữ cảnh thêm cho một bước: tệp đính kèm của nó cộng đầu ra của các bước đứng trước.""" parts = [] att = _attachments_text(by_id.get(node_id), out_dir) if att: diff --git a/core/code_agent.py b/core/code_agent.py index c95420f..088df89 100644 --- a/core/code_agent.py +++ b/core/code_agent.py @@ -12,6 +12,8 @@ import re from pathlib import Path 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 . import agent_roles from . import agent_security @@ -29,6 +31,11 @@ _TOOL_LINE = re.compile(r"@@TOOL\s+(\w+)\s+(\{.*\})", re.DOTALL) def code_system_prompt(workdir: Path, has_memory: bool = False, plan: bool = False, has_plan_tool: bool = False, has_ms365: bool = False) -> str: + """Prompt hệ thống cho Code agent, ghép theo năng lực thật của lượt chạy. + + Chỉ liệt kê những tool đang BẬT, và thêm ghi chú chế độ lập kế hoạch khi cần — + nói với model về một tool nó không có sẽ khiến nó gọi rồi báo lỗi. + """ names = ", ".join(t.name for t in TOOL_SPECS) plan_note = ("PLAN MODE: only analyze and propose a detailed plan; do NOT write files or run " "commands. When the user asks to gencode/implement, the app switches to ACT.\n" @@ -225,6 +232,14 @@ def run_code( # read/list ms365 tools count as "read-only, never confirm". Names are # the MCP-qualified "ms365__*" form the agent sees (see ms365_tools.py). 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). 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) @@ -297,10 +312,11 @@ def run_code( agent_security.enforce_command(provider, name, args, security_config, emit, agent_kind="code") - if name in gated_tools: - approved = gate.request({"id": tc_id, "name": name, "args": args, "preview": preview}) - else: - approved = True # read-only tools (incl. codebase memory) never confirm + # read-only tools (incl. codebase memory) never consult the gate — + # code_tool_policy.requires_confirmation(name) is False for them. + approved = code_tool_policy.allow( + name, gate, {"id": tc_id, "name": name, "args": args, "preview": preview} + ) if cancel(): return messages diff --git a/core/codebase_memory.py b/core/codebase_memory.py index 1684908..7c2acd3 100644 --- a/core/codebase_memory.py +++ b/core/codebase_memory.py @@ -26,6 +26,7 @@ _INDEX_TIMEOUT = 900 class CodebaseMemoryError(RuntimeError): + """Lỗi khi gọi công cụ codebase-memory-mcp bên ngoài.""" pass @@ -75,14 +76,24 @@ def _extract_json(text: str): class CodebaseMemory: + """Vỏ bọc quanh CLI ``codebase-memory-mcp``: đánh chỉ mục và tra cứu mã nguồn. + + Đây là phần mềm ngoài, có thể không được cài — luôn kiểm :meth:`available` + trước khi dùng. + """ def __init__(self, binary_path: str = ""): + """Tìm file thực thi codebase-memory; không có thì ``available`` là False và + mọi lượt gọi về sau tự bỏ qua. + """ self.binary = resolve_binary(binary_path) @property def available(self) -> bool: + """Đã tìm thấy CLI trên máy chưa.""" return self.binary is not None def _run(self, tool: str, args: Dict[str, Any], timeout: int) -> Dict[str, Any]: + """Gọi một tool của CLI và trả kết quả JSON; chưa cài thì báo lỗi kèm hướng dẫn.""" if not self.binary: raise CodebaseMemoryError( "codebase-memory-mcp is not installed. See the instructions in Settings." @@ -107,12 +118,15 @@ class CodebaseMemory: # ---- high level ops --------------------------------------------- def index_repository(self, repo_path: str) -> Dict[str, Any]: + """Đánh chỉ mục một repository (chạy lâu — dùng hạn giờ dài hơn).""" return self._run("index_repository", {"repo_path": str(repo_path)}, _INDEX_TIMEOUT) def list_projects(self) -> Dict[str, Any]: + """Danh sách project đã được đánh chỉ mục.""" return self._run("list_projects", {}, _QUERY_TIMEOUT) def call(self, tool: str, args: Dict[str, Any]) -> Dict[str, Any]: + """Gọi một tool bất kỳ, tự chọn hạn giờ theo loại việc.""" timeout = _INDEX_TIMEOUT if tool == "index_repository" else _QUERY_TIMEOUT return self._run(tool, args, timeout) @@ -187,6 +201,9 @@ def make_executor(mem: CodebaseMemory): """Return an executor(name, args) -> {ok, output} for cmem_* tools.""" def execute(name: str, args: Dict[str, Any]) -> Dict[str, Any]: + """Bộ thực thi tool codebase-memory cho agent; tên tool lạ thì trả về lỗi thay + vì ném ngoại lệ. + """ cli_tool = _CLI_NAME.get(name) if not cli_tool: return {"ok": False, "output": f"Unsupported codebase-memory tool: {name}"} diff --git a/core/codebase_memory_ui.py b/core/codebase_memory_ui.py index 446f350..dc4024f 100644 --- a/core/codebase_memory_ui.py +++ b/core/codebase_memory_ui.py @@ -33,6 +33,9 @@ class CmemUiError(RuntimeError): asset) — a different remedy than a generic startup/timeout failure.""" def __init__(self, message: str, no_ui_build: bool = False): + """``no_ui_build`` đánh dấu trường hợp riêng: chạy được nhưng bản cài không kèm + phần giao diện — thông báo cho người dùng phải khác hẳn lỗi chạy thường. + """ super().__init__(message) self.no_ui_build = no_ui_build @@ -41,16 +44,21 @@ class CodebaseMemoryUiServer: """One ``codebase-memory-mcp --ui`` process, started on demand.""" def __init__(self, binary_path: str = "", port: int = DEFAULT_PORT): + """Chuẩn bị chỗ chạy máy chủ giao diện; chưa khởi động tiến trình nào.""" self.binary = resolve_binary(binary_path) self.port = port self._proc: Optional[subprocess.Popen] = None @property def url(self) -> str: + """Địa chỉ để mở giao diện. Chỉ nghe trên 127.0.0.1 — đây là công cụ cục bộ, + không mở ra mạng. + """ return f"http://127.0.0.1:{self.port}/" @property def running(self) -> bool: + """Tiến trình máy chủ còn sống không.""" return self._proc is not None and self._proc.poll() is None def start(self, repo_path: str = "") -> str: @@ -75,6 +83,9 @@ class CodebaseMemoryUiServer: no_ui_event = threading.Event() def _reader() -> None: + """Chạy nền: đọc đầu ra của tiến trình, giữ lại để báo lỗi và bật cờ khi thấy + dấu hiệu bản cài không có phần giao diện. + """ try: stream = self._proc.stdout if stream is None: @@ -111,6 +122,11 @@ class CodebaseMemoryUiServer: raise CmemUiError(f"Hết thời gian chờ UI trên cổng {self.port}.") def stop(self) -> None: + """Dừng máy chủ. Xin dừng tử tế trước, quá 3 giây thì buộc tắt. + + Mọi lỗi đều bị nuốt có chủ ý: đây là dọn dẹp lúc thoát, ném lỗi ở đây chỉ + làm kẹt đường thoát của cả ứng dụng. + """ proc, self._proc = self._proc, None if proc is not None and proc.poll() is None: try: diff --git a/core/context_budget.py b/core/context_budget.py index 6f4301c..b26b737 100644 --- a/core/context_budget.py +++ b/core/context_budget.py @@ -33,6 +33,9 @@ _MODEL_LIMITS = { def model_context_limit(model: str) -> int: + """Cửa sổ ngữ cảnh (token) của một model, dò theo tiền tố tên dài nhất khớp + trong bảng; không khớp gì thì lấy ``DEFAULT_LIMIT``. + """ m = (model or "").lower() best = 0 limit = DEFAULT_LIMIT @@ -43,6 +46,7 @@ def model_context_limit(model: str) -> int: def _ctx_conf(config) -> Dict[str, Any]: + """Nhóm cấu hình ``context``; không có config thì trả dict rỗng.""" if config is None: return {} try: @@ -59,11 +63,13 @@ def context_limit(config, model: str = "") -> int: def auto_compact_enabled(config) -> bool: + """Có tự nén lịch sử khi gần đầy ngữ cảnh không (mặc định bật).""" conf = _ctx_conf(config) return bool(conf.get("auto_compact", True)) def threshold(config) -> float: + """Ngưỡng nén, tính theo tỉ lệ cửa sổ ngữ cảnh đã dùng (mặc định 0,8).""" conf = _ctx_conf(config) try: t = float(conf.get("compact_threshold", DEFAULT_THRESHOLD)) @@ -73,6 +79,9 @@ def threshold(config) -> float: def _msg_text(m: Dict[str, Any]) -> str: + """Rút phần văn bản của một tin nhắn, kể cả khi nội dung là danh sách block + (tin nhắn có ảnh). + """ c = m.get("content", "") if isinstance(c, str): return c @@ -81,11 +90,17 @@ def _msg_text(m: Dict[str, Any]) -> str: def estimate_messages_tokens(messages: List[Dict[str, Any]]) -> int: + """Ước lượng tổng token của cả danh sách tin nhắn.""" return sum(estimate_tokens(_msg_text(m)) for m in messages) def should_compact(messages: List[Dict[str, Any]], limit: int, thresh: float = DEFAULT_THRESHOLD) -> bool: + """Đã đến lúc nén lịch sử chưa. + + Không nén khi hội thoại còn quá ngắn: nén một cuộc mới vài lượt thì mất nội + dung mà chẳng tiết kiệm được bao nhiêu. + """ if limit <= 0 or len(messages) <= _KEEP_RECENT + 2: return False return estimate_messages_tokens(messages) > limit * thresh @@ -99,6 +114,7 @@ _SUMMARY_PROMPT = ( def _summarize(provider, middle: List[Dict[str, Any]], cancel=None) -> str: + """Nhờ model tóm tắt phần giữa của hội thoại thành một đoạn ngắn.""" convo = "\n\n".join(f"[{m.get('role', '?')}] {_msg_text(m)}" for m in middle) try: a = provider.chat([{"role": "system", "content": _SUMMARY_PROMPT}, diff --git a/core/cron.py b/core/cron.py index 9d59cc1..4084011 100644 --- a/core/cron.py +++ b/core/cron.py @@ -15,10 +15,14 @@ _SEARCH_DAYS = 366 * 2 # give up after two years (an expression that never fir class CronError(ValueError): + """Biểu thức cron sai cú pháp.""" pass def _parse_field(spec: str, lo: int, hi: int) -> Set[int]: + """Đọc một trường cron thành tập giá trị: hỗ trợ ``*``, danh sách ``a,b``, + khoảng ``a-b`` và bước ``*/n``. + """ values: Set[int] = set() for part in spec.split(","): part = part.strip() @@ -55,7 +59,13 @@ def _parse_field(spec: str, lo: int, hi: int) -> Set[int]: class Cron: + """Biểu thức cron 5 trường (phút, giờ, ngày, tháng, thứ).""" def __init__(self, expression: str): + """Phân tích một biểu thức cron 5 trường. + + Sai số trường là ném ``CronError`` ngay tại đây chứ không đợi tới lúc chạy: + lịch sai giờ khó phát hiện hơn nhiều so với một lỗi lúc nhập. + """ fields = (expression or "").split() if len(fields) != 5: raise CronError("Cron expression needs exactly 5 fields: " @@ -69,6 +79,11 @@ class Cron: self._dow_star = fields[4].strip() == "*" def _day_matches(self, dt: datetime) -> bool: + """Ngày này có khớp biểu thức không. + + Theo chuẩn cron: khi cả trường NGÀY và trường THỨ đều được đặt cụ thể thì + khớp một trong hai là đủ (OR), chứ không phải cả hai (AND). + """ if dt.month not in self.months: return False cron_dow = (dt.weekday() + 1) % 7 # Python Mon=0 → cron Sun=0 diff --git a/core/custom_agents.py b/core/custom_agents.py index 34cfc6f..c07a2f2 100644 --- a/core/custom_agents.py +++ b/core/custom_agents.py @@ -21,6 +21,14 @@ AGENTS_DIR = CONFIG_DIR / "agents" @dataclass class CustomAgent: + """Một agent do người dùng tự tạo: tên, mô tả, prompt mặc định và tuỳ chọn + provider/model riêng. + + Bỏ trống ``provider``/``model`` nghĩa là dùng theo bước gọi nó hoặc theo cấu + hình chung — nhờ vậy một agent viết một lần chạy được với mọi provider. + + Đã được ``core/co4e.py`` thay thế; giữ lại làm bản đối chiếu. + """ name: str description: str = "" prompt: str = "" # default task; a Flow sub-agent can still override it @@ -29,16 +37,25 @@ class CustomAgent: @property def slug(self) -> str: + """Tên rút gọn an toàn để đặt tên file, ví dụ "Trợ lý Code" -> "tro-ly-code". + Tên không còn ký tự hợp lệ nào thì rơi về "agent". + """ keep = "-_" s = "".join(c if (c.isalnum() or c in keep) else "-" for c in self.name.strip().lower()) return "-".join(filter(None, s.split("-"))) or "agent" def agents_dir() -> Path: + """Thư mục chứa file agent tự tạo.""" return AGENTS_DIR def list_agents(directory: Path = AGENTS_DIR) -> List[CustomAgent]: + """Đọc mọi agent trong thư mục, sắp theo tên file. + + File hỏng bị bỏ riêng lẻ chứ không làm hỏng cả danh sách — một file sai + không được phép làm mất hết agent còn lại. + """ if not directory.exists(): return [] agents: List[CustomAgent] = [] @@ -58,6 +75,11 @@ def list_agents(directory: Path = AGENTS_DIR) -> List[CustomAgent]: def save_agent(agent: CustomAgent, directory: Path = AGENTS_DIR, old_name: str = "") -> Path: + """Ghi một agent xuống đĩa. + + Truyền ``old_name`` khi đổi tên: file cũ bị xoá trước, nếu không sẽ có hai + file cùng nội dung với hai tên khác nhau. + """ directory.mkdir(parents=True, exist_ok=True) if old_name and old_name != agent.name: delete_agent(old_name, directory) @@ -67,6 +89,9 @@ def save_agent(agent: CustomAgent, directory: Path = AGENTS_DIR, old_name: str = def delete_agent(name: str, directory: Path = AGENTS_DIR) -> None: + """Xoá file của một agent theo tên. Không có file thì thôi; lỗi xoá bị nuốt, + không chặn giao diện. + """ path = directory / f"{CustomAgent(name=name).slug}.json" if path.exists(): try: diff --git a/core/custom_icons.py b/core/custom_icons.py index 6ccc69f..84dd629 100644 --- a/core/custom_icons.py +++ b/core/custom_icons.py @@ -18,15 +18,18 @@ _MAX_BYTES = 200_000 def icons_dir() -> Path: + """Thư mục chứa icon do người dùng thêm.""" return ICONS_DIR def slugify(name: str) -> str: + """Định danh an toàn cho tên file icon; rỗng thì trả về 'icon'.""" s = "".join(c if (c.isalnum() or c in "-_") else "-" for c in (name or "").strip().lower()) return "-".join(filter(None, s.split("-"))) or "icon" def list_custom(directory: Optional[Path] = None) -> List[str]: + """Tên các icon tự thêm; thư mục chưa có thì trả list rỗng.""" directory = directory or ICONS_DIR if not directory.exists(): return [] @@ -69,6 +72,7 @@ def add_from_file(path, name: str = "", directory: Optional[Path] = None) -> str def delete_custom(name: str, directory: Optional[Path] = None) -> None: + """Xoá một icon tự thêm; không có thì bỏ qua.""" directory = directory or ICONS_DIR path = directory / f"{slugify(name)}.svg" if path.exists(): diff --git a/core/d3_graph.py b/core/d3_graph.py index f7340f3..20b30c2 100644 --- a/core/d3_graph.py +++ b/core/d3_graph.py @@ -18,6 +18,7 @@ _CDN_D3 = '