Files
cowork-local/presentation/chat/chat_agents.py
T
a03a740ea1
CI / test (push) Canceled after 0s
Feature/perf ui logic (#13)
## Summary

Nhánh `feature/perf-ui-logic`: tối ưu hiệu năng/UI, sửa lỗi workspace và điều hướng, và làm cho công tắc **"Block network for agent-run commands"** chặn thật mọi đường ra mạng của app, **trừ nhà cung cấp AI**.

**Chặn mạng (b78d483, 8c497cf, 10b8379)**
- Bộ kiểm tra chung `application/network/network_guard.py`, nối vào cấu hình đang chạy ở Composition Root: đổi công tắc trong Settings là có hiệu lực ngay.
- Lệnh shell của agent và task script chạy trong **Windows AppContainer không có quyền mạng**: kernel chặn socket, ping, DNS, Invoke-WebRequest… Không cần quyền admin. Không cô lập được thì lệnh bị từ chối, không chạy khi mạng còn mở. macOS dùng `sandbox-exec`, Linux dùng `unshare --net`.
- Bật chặn thì: dừng MCP đang chạy, không khởi động server mới, từ chối lời gọi connector; Microsoft 365 (đăng nhập, Graph, đồng bộ cloud, rules, mail), Teams, nút Test REST/Jira/MCP, link đính kèm task, pip tự cài và tài nguyên web trong xem trước HTML đều bị từ chối.
- Vẫn dùng được: chat, tải danh sách model, thử model; tool OneDrive đã đồng bộ trên máy.
- Công tắc **mặc định tắt** khi mở app lần đầu; nhãn giữ nguyên như cũ.
- Xem trước HTML trong tab Folder giờ hiện được ảnh/CSS/JS từ web khi mạng mở (trước đây trang `file://` không tải được).
- Sửa lỗi app văng khi chuyển tab Graph → Folder: profile WebEngine của trang xem trước bị huỷ trước trang (`0xc0000409` trong Qt6Core.dll); giờ dùng một profile chung thuộc QApplication.
- Không cấp quyền AppContainer kế thừa lên thư mục chứa PySide6 (nếu có, Chromium không nạp được `Qt6WebEngineCore.dll` và tab Graph trắng).
- Cột mục lục trong Settings tính độ rộng theo kiểu chữ của mục đang chọn, "Sandbox Security Layer" không còn bị cắt.

**Các commit khác trong nhánh**
- `b7a41b3` mỗi thư mục làm việc chỉ thuộc về một project · `bbdf146` bật nút Sửa project khi đã có project đang mở
- `35f24e0`, `cc8d5c8`, `2e3e719`, `c699beb` canh hàng / khoảng cách thanh điều hướng
- `2759ed9` không refresh workspace khi chuyển tab Cowork · `7607f44` checkpoint hiệu năng và UI
- `8548c1e` chặn tool mạng của agent · `caf3b74` renderer GraphRAG native trên macOS · `c00b83c` khoảng cách metadata hàng project · `a04f8a9` ẩn picker workspace cloud

## Change Type

- [x] Cowork feature
- [x] Bug fix
- [ ] Core AI contribution
- [x] Test / hardening
- [x] Performance
- [ ] Documentation

## Related Work

Cowork Task:

Core Repo: http://34.143.229.138/gitea-admin/fsg-ai-core-assets

Core AI Issue:

Core Task:

Related PR:

## Scope

What is intentionally included?
- Mọi đường ra mạng do app tự mở, trừ nhà cung cấp AI (xem Summary).
- Test: `tests/test_network_guard_lanes.py` (có bài chạy AppContainer thật trên Windows), `tests/ui/test_html_preview_remote_images.py`.

What is intentionally NOT included?
- Chặn cả nhà cung cấp AI / chạy model trên máy (Phương án 2).
- Terminal người dùng tự gõ trong tab Folder, sinh ảnh, cơ chế tự tin chứng chỉ lạ (`tls_trust`).
- Huy hiệu trạng thái "đang chặn" trên thanh trên cùng.

## Validation

- [x] Unit tests
- [x] Integration tests
- [x] Manual verification
- [x] Regression check

Commands / evidence:
- `python -m pytest tests/test_network_guard_lanes.py tests/test_sandbox_block_network.py tests/ui -q` → chỉ còn 1 bài fail, fail cả trên `b7a41b3` (nhãn `ProjectRow` 'Project' chưa dịch, `tests/ui/test_i18n_khong_con_chu_cu.py`).
- `python -m pytest tests -q --ignore=tests/ui` → 4 bài fail, cả 4 cũng fail trên `b7a41b3` (`test_canonical_audit_logger`, 2 bài `test_mcp_audit_security`, `test_monitoring_tab_container`).
- Chạy cả `tests` trong một lượt thì treo ở các test dựng MainWindow trong `tests/ui`; `b7a41b3` cũng treo đúng chỗ đó.
- `check_imports.py` và `check_orphan_modules.py` PASS. `check_loc.py` báo 9 file quá dài, giống hệt trước khi sửa (không file nào do nhánh này làm dài thêm).
- Kiểm tra tay trên Windows 11: trong AppContainer, Python báo `WinError 10013`, ping/nslookup/PowerShell/curl đều không ra được mạng; cmd, git, python chạy bình thường.
- Kiểm tra tay trên Windows 11: xem trước HTML tải được 4/4 tài nguyên web khi mạng mở, 0/4 khi bật chặn; tab Graph hoạt động; tạo/huỷ trang xem trước nhiều lần không còn cảnh báo profile của Qt.

## Security Impact

Permission / credential / network / customer data impact:
- Network: khi bật công tắc, chỉ nhà cung cấp AI còn ra mạng; nội dung chat vẫn gửi tới nhà cung cấp AI.
- Permission: lần đầu chạy lệnh trong sandbox, app **thêm quyền (ACE) cho SID AppContainer** trên thư mục làm việc (ghi), thư mục cài Python gốc (đọc), gốc venv và `Scripts` (đọc). Không xoá quyền nào. Thư mục chứa PySide6 không bao giờ nhận quyền kế thừa; một quyền kế thừa sai trên venv (từ bản dev trước) được tự gỡ.
- Credential: không đổi. Khi chặn, trạng thái đăng nhập M365 được đọc thẳng từ kho token trên máy, không dựng MSAL.

## Compatibility

- [x] No breaking change
- [ ] Breaking change documented

Ghi chú: `block_network` mặc định đổi từ bật sang tắt cho cấu hình mới; máy đã lưu `true` thì giữ nguyên. Khi đang chặn, lệnh dùng công cụ cài trong thư mục người dùng (ngoài Program Files) có thể báo Access denied; thư viện trong venv của app không dùng được trong sandbox.

## Reviewer Notes

- `infrastructure/sandbox/appcontainer_process.py` gọi Win32 bằng ctypes (CreateAppContainerProfile, CreateProcessW với SECURITY_CAPABILITIES) và dùng `icacls` để cấp quyền: nên xem kỹ phần cấp quyền.
- `tests/conftest.py` thêm fixture autouse gỡ `network_guard` sau mỗi test, vì `build_context()` gắn cổng này ở mức process.
- `core/task_executors.py` đang đúng bằng trần LOC nên `_run_script` được tách sang `core/task_script.py`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: minhanhpkpro <minhanhpkpro@gmail.com>
Co-authored-by: Duy Le Huu <duylh19@fpt.com>
Co-authored-by: thanhnv <thanhnv.ip@gmail.com>
Reviewed-on: #13
2026-09-20 12:26:03 +00:00

268 lines
13 KiB
Python

"""Chọn agent, skill và định tuyến model cho khung chat — R08-T06.
``_apply_routing`` quyết định lượt này chạy bằng model nào: người dùng
chọn tay, hay để bộ định tuyến tự chọn theo chính sách.
``_note_agent_switch`` ghi lại việc đổi agent giữa chừng vào chính mạch
hội thoại — không ghi thì đọc lại transcript sẽ thấy giọng đổi đột ngột
mà không hiểu vì sao.
"""
from __future__ import annotations
from typing import Any, Dict
from PySide6.QtCore import Qt, Signal
from ...core.worker import AgentWorker
from ...i18n import tr
class ChatAgentsMixin:
"""Trộn vào ChatPanel."""
def _agent_signature(self) -> str:
"""Identifies WHAT will run the next turn (admin agent id, or plain
provider:model) — comparing this across turns is how a genuine
mid-conversation switch is detected."""
agent = getattr(self, "_admin_agent", None)
if agent is not None:
return f"{self._ADMIN_AGENT_PREFIX}{agent.agent_id}"
return f"{self.ctx.config.active_provider}:{self._model}"
def _current_agent_label(self) -> str:
"""Human-friendly name of what will run the next turn — for the visible
'auto-switched model' notice in the transcript."""
agent = getattr(self, "_admin_agent", None)
if agent is not None:
return agent.name
return self._model or tr("chat.provider_default_short")
def _on_agent_changed(self, _i: int) -> None:
"""Đổi agent trong bộ chọn: agent quản trị thì lấy cả provider/model riêng của nó."""
data = self.agent_combo.currentData() or ""
if isinstance(data, str) and data.startswith(self._ADMIN_AGENT_PREFIX):
# An Admin-defined agent preset (Monitoring → Agents Admin): runs
# on its pinned model (or the Settings default when unpinned) and
# injects its instructions into every turn of this tab.
from ...core import admin_agents
agent_id = data[len(self._ADMIN_AGENT_PREFIX):]
self._admin_agent = admin_agents.load_agent(
agent_id, admin_agents.agents_admin_dir(self.ctx.config.shared_dir))
self._agent_user_override = True
self._agent_provider = self.ctx.config.active_provider
self._model = (self._admin_agent.model if self._admin_agent else "") or ""
if self._admin_agent is not None:
self.status_message.emit(f"{self.session_name} agent: {self._admin_agent.name}")
self._note_agent_switch()
return
self._admin_agent = None
new = data or "" # "" → provider default
if new != self._model:
# A deliberate pick by the user — remember it until the provider changes.
self._agent_user_override = True
self._agent_provider = self.ctx.config.active_provider
self._model = new
if self._model:
self.status_message.emit(f"{self.session_name} agent: {self._model}")
self._note_agent_switch()
def _note_agent_switch(self) -> None:
"""Flag a pending review note for the NEXT turn when the selection
genuinely changed mid-conversation (there's already history AND this
isn't just the initial default being applied)."""
sig = self._agent_signature()
last = getattr(self, "_last_turn_agent_signature", None)
if last is not None and sig != last and self.messages:
self._pending_agent_switch_review = True
def admin_agent_prompt(self) -> str:
"""The selected admin agent's instructions ('' when a plain model is
selected) — appended to the project context of every turn."""
agent = getattr(self, "_admin_agent", None)
return agent.effective_prompt() if agent is not None else ""
def refresh_agents(self) -> None:
"""Fetch the model list from the active provider (in the background) and
fill the per-tab Agent combo — called at start and on provider change.
The default follows Settings; see state.resolve_agent_default."""
from ...state import resolve_agent_default
name = self.ctx.config.active_provider
setting_model = self.ctx.config.provider_conf(name).get("model", "")
keep, self._agent_user_override = resolve_agent_default(
name, setting_model, self._model, self._agent_provider, self._agent_user_override)
self._model = keep
self._agent_provider = name
def job(worker: AgentWorker):
"""Chạy nền: hỏi provider danh sách model để đổ vào bộ chọn agent."""
error = ""
try:
prov = self.ctx.build_provider_for(name)
models = list(getattr(prov, "list_models", lambda: [])() or [])
if not models:
error = getattr(prov, "last_error", "")
except Exception as exc: # noqa: BLE001 - never break the UI over a model list
models, error = [], str(exc)
return {"models": models, "keep": keep, "error": error}
def done(result) -> None:
"""Đổ danh sách vào bộ chọn, giữ nguyên lựa chọn trước đó nếu còn."""
self._populate_agents(result.get("models", []), result.get("keep", ""))
# Surface the REAL reason models didn't load (network/auth/config)
# instead of silently falling back to "(provider default)".
err = result.get("error", "")
if err:
self.status_message.emit(tr("chatpanel.agent_list_error", err=err))
# Model discovery can involve a provider/network request. Constructing
# the chat panel during startup must not wait for it; schedule it after
# the first event-loop turn so the initial shell can paint immediately.
from PySide6.QtCore import QTimer
if getattr(self, "_agent_refresh_pending", False):
return
self._agent_refresh_pending = True
def start_worker() -> None:
self._agent_refresh_pending = False
w = AgentWorker(job)
w.finished_ok.connect(done)
self._agent_worker = w
w.start()
QTimer.singleShot(0, start_worker)
def _populate_agents(self, models, keep: str) -> None:
"""Đổ danh sách vào bộ chọn Agent.
Bộ chọn này thực chất là bộ chọn MODEL: danh sách model của provider đang
dùng, cộng thêm các agent quản trị có provider/model riêng.
"""
self.agent_combo.blockSignals(True)
self.agent_combo.clear()
# The Agent picker is a MODEL picker — the raw model list of the active
# provider. Admin-defined agents (Monitoring → Agents Admin) are NOT
# listed here: they are system-management presets, not a model/agent to
# pick for a Cowork conversation. To apply a work agent's persona, use
# the /agent command (built-in + custom Flow agents).
items = list(dict.fromkeys([m for m in models if m])) # dedupe, keep order
if keep and keep not in items:
items.insert(0, keep)
for m in items:
self.agent_combo.addItem(m, m)
if not items and self.agent_combo.count() == 0:
# No models found and none configured — placeholder with data=None so
# we fall back to the provider's default model (never a fake name).
self.agent_combo.addItem(tr("chat.provider_default_item"), None)
keep_data = (f"{self._ADMIN_AGENT_PREFIX}{self._admin_agent.agent_id}"
if getattr(self, "_admin_agent", None) is not None else keep)
idx = self.agent_combo.findData(keep_data) if keep_data else -1
if idx >= 0:
self.agent_combo.setCurrentIndex(idx)
self.agent_combo.blockSignals(False)
data = self.agent_combo.currentData() or ""
if not (isinstance(data, str) and data.startswith(self._ADMIN_AGENT_PREFIX)):
self._model = data or ""
def build_provider(self):
"""Provider for THIS tab: the selected admin agent's pinned
provider/model when one is selected, else the tab's selected model
(or the provider's configured default when none is chosen)."""
agent = getattr(self, "_admin_agent", None)
if agent is not None:
from ...core.admin_agents import build_agent_provider
return build_agent_provider(self.ctx, agent)
# An Auto/Manual routing override (set by _apply_routing for this turn)
# wins over the tab's own provider/model selection.
provider = self._routed_provider or self.ctx.config.active_provider
model = self._routed_model or self._model or None
return self.ctx.build_provider_for(provider, model)
def _apply_routing(self, text: str, turn: Dict[str, Any]) -> None:
"""Auto Model Routing hook — run once per outgoing message.
Since R03-T04 the Off/Auto/Manual/Fallback rules live in
``application/model_routing/routing_application_service.py``; the copy
that used to sit here (and again in Co4E and AI-Edit) is gone. What
remains is the widget's own job: snapshot the tab's provider/model into
a request, host the Manual-mode modal, and render the outcome by setting
``self._routed_provider``/``self._routed_model`` for THIS turn (honoured
by :meth:`build_provider`) plus a status bubble.
Never raises — a routing failure must never block sending a message; it
just falls back to the tab's own model.
"""
# Recompute fresh each message; clear any previous turn's override.
self._routed_provider = None
self._routed_model = None
# An explicitly-pinned Admin agent takes precedence over routing.
if getattr(self, "_admin_agent", None) is not None:
return
try:
from ...application.model_routing import (
RoutingRequest,
build_routing_application_service,
)
from ...ui.routing_toggle import confirm_switch
# The model the tab WOULD use without routing — the picker's choice,
# or the provider's configured default when nothing is picked.
cur_provider = self.ctx.config.active_provider
cur_model = self._model or self.ctx.config.provider_conf(cur_provider).get("model", "")
outcome = build_routing_application_service(self.ctx).resolve(
RoutingRequest(
surface=self.kind, # per-workspace mode key ("cowork"/…)
prompt=text,
current_provider=cur_provider,
current_model=cur_model,
),
# Manual mode only: the modal stays in the presentation layer so
# the application service never imports Qt.
confirm=lambda decision, timeout: confirm_switch(self, decision, timeout),
)
if not outcome.switched:
return # off / nothing better / declined → keep the tab's model
self._routed_provider = outcome.provider
self._routed_model = outcome.model
notice = self.chat_view.add_status(tr(
"routing.switched_notice",
model=outcome.model, task=outcome.task_type,
gain=f"{outcome.score_gain:.2f}"))
turn["bubbles"].append(notice)
except Exception: # noqa: BLE001 — routing must never block a chat turn
self._routed_provider = None
self._routed_model = None
def _apply_skill_command(self, text: str):
"""Parse a leading ``/skill`` command typed in the chat box.
Returns ``(prefix, request, info)`` — see ``core.skills.parse_skill_command``."""
try:
from ...core.skills import parse_skill_command
return parse_skill_command(text)
except Exception:
return "", text, "Could not read skills from the Skills manager."
def _apply_agent_command(self, text: str):
"""Parse a ``/agent`` command typed in the chat box (Cowork parity with
Co4E): apply a named agent PERSONA to the turn. Returns
``(prefix, request, info)`` — see ``core.agent_command.parse_agent_command``."""
try:
from ...core.agent_command import parse_agent_command
return parse_agent_command(text, self.ctx.config.shared_dir)
except Exception: # noqa: BLE001
return "", text, "Could not read the agent catalog."
def _open_skills_manager(self) -> None:
"""Open the Skills manager (add / edit / delete / enable skills)."""
from ...ui.skills_dialog import SkillsDialog
SkillsDialog(self, self.ctx).exec()
self._skills_changed()
self.status_message.emit(tr("chatpanel.skills_updated"))
def _skills_changed(self) -> None:
"""Hook after skills were edited (Code tab refreshes its Skills button)."""