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 2249ee5..1db3034 100644 --- a/app.py +++ b/app.py @@ -61,6 +61,12 @@ 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) @@ -115,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/conversations/conversation_application_service.py b/application/conversations/conversation_application_service.py index fdc9046..400e441 100644 --- a/application/conversations/conversation_application_service.py +++ b/application/conversations/conversation_application_service.py @@ -75,6 +75,12 @@ class ConversationApplicationService: 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. diff --git a/application/conversations/core_runtime_adapter.py b/application/conversations/core_runtime_adapter.py index aabf714..0fea5ea 100644 --- a/application/conversations/core_runtime_adapter.py +++ b/application/conversations/core_runtime_adapter.py @@ -51,9 +51,11 @@ class CoreModelCall: """ 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, @@ -66,6 +68,11 @@ class CoreToolRuntime: 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 ()) @@ -80,6 +87,7 @@ class CoreToolRuntime: # -- 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): @@ -206,6 +214,7 @@ class CoreToolRuntime: "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) @@ -294,16 +303,19 @@ def build_cowork_conversation_service( _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, diff --git a/application/conversations/tool_policy_gateway.py b/application/conversations/tool_policy_gateway.py index 7d60ffd..6c66295 100644 --- a/application/conversations/tool_policy_gateway.py +++ b/application/conversations/tool_policy_gateway.py @@ -39,7 +39,10 @@ from cowork_local.domain.tools import ToolCapability, ToolRegistry class ConfirmGate(Protocol): """Shape of the existing ``PermissionGate`` both engines already use.""" - def request(self, payload: Dict[str, Any]) -> bool: ... + """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: @@ -54,6 +57,11 @@ class ToolPolicyGateway: """ 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 diff --git a/application/model_routing/core_routing_adapter.py b/application/model_routing/core_routing_adapter.py index f2fdfa8..4e4afd9 100644 --- a/application/model_routing/core_routing_adapter.py +++ b/application/model_routing/core_routing_adapter.py @@ -33,6 +33,7 @@ class CoreRoutingEngine: """ 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: @@ -126,6 +127,9 @@ class AppContextModeResolver: """ 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: diff --git a/application/model_routing/routing_application_service.py b/application/model_routing/routing_application_service.py index 9faf703..6db95a6 100644 --- a/application/model_routing/routing_application_service.py +++ b/application/model_routing/routing_application_service.py @@ -78,6 +78,10 @@ class RoutingApplicationService: *, 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 diff --git a/application/monitoring/dashboard_query_service.py b/application/monitoring/dashboard_query_service.py index 4a5194d..9de2971 100644 --- a/application/monitoring/dashboard_query_service.py +++ b/application/monitoring/dashboard_query_service.py @@ -32,6 +32,9 @@ class DashboardQueryService: """ 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 @@ -94,16 +97,21 @@ class DashboardQueryService: 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) diff --git a/application/monitoring/dto/__init__.py b/application/monitoring/dto/__init__.py index e69de29..d81abbf 100644 --- a/application/monitoring/dto/__init__.py +++ 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 index 31ba1f3..8bd6dca 100644 --- a/application/monitoring/dto/audit_event_dto.py +++ b/application/monitoring/dto/audit_event_dto.py @@ -12,6 +12,9 @@ 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 @@ -40,6 +43,7 @@ class AuditEventDTO: ) 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, diff --git a/application/monitoring/monitoring_query_service.py b/application/monitoring/monitoring_query_service.py index 0024f7e..1a35451 100644 --- a/application/monitoring/monitoring_query_service.py +++ b/application/monitoring/monitoring_query_service.py @@ -16,6 +16,7 @@ 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 @@ -23,6 +24,7 @@ class Page: @property def has_more(self) -> bool: + """Còn trang sau nữa không.""" return self.page * self.page_size < self.total @@ -31,11 +33,15 @@ class MonitoringQueryService: 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: diff --git a/application/monitoring/repository/__init__.py b/application/monitoring/repository/__init__.py index e69de29..099e195 100644 --- a/application/monitoring/repository/__init__.py +++ 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 index a08492c..81fe1d7 100644 --- a/application/monitoring/repository/audit_event_repository.py +++ b/application/monitoring/repository/audit_event_repository.py @@ -13,7 +13,13 @@ 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ó.""" ... @@ -22,9 +28,11 @@ class CanonicalAuditEventRepository: — 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] @@ -33,9 +41,13 @@ 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/ai_task_planner_service.py b/application/scheduling/ai_task_planner_service.py index 6fbf15e..4371f7d 100644 --- a/application/scheduling/ai_task_planner_service.py +++ b/application/scheduling/ai_task_planner_service.py @@ -43,6 +43,9 @@ class AiTaskPlannerService: """ 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( @@ -86,6 +89,9 @@ class AiTaskPlannerService: 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() diff --git a/application/scheduling/task_application_service.py b/application/scheduling/task_application_service.py index d96c28d..cd62a85 100644 --- a/application/scheduling/task_application_service.py +++ b/application/scheduling/task_application_service.py @@ -75,6 +75,9 @@ class TaskApplicationService: """ 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 @@ -118,6 +121,7 @@ class TaskApplicationService: 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) 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 index 3b2d3cb..d2b0c70 100644 --- a/application/workflows/co4e_workflow_service.py +++ b/application/workflows/co4e_workflow_service.py @@ -32,12 +32,20 @@ Qt thật (bọc ``AgentWorker`` — xem ``core/worker.py``) là việc của wi 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 -from ...infrastructure.persistence.json.atomic_json_file import AtomicJsonFile - -import json import os from datetime import datetime from pathlib import Path @@ -45,12 +53,13 @@ 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} -_HISTORY_CAP = 500 # giữ N run gần nhất trên đĩa 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") @@ -70,15 +79,24 @@ class RunnerJob(Protocol): đồng bộ trong test. """ - def emit_event(self, ev: dict) -> None: ... - def is_cancelled(self) -> bool: ... + 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: ... + 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): @@ -94,7 +112,9 @@ class WorkflowRunner(Protocol): 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: ... + on_failed: Callable[[str], None]) -> RunWorkerHandle: + """Chạy ``job`` và trả về tay cầm để dừng nó.""" + ... class Co4EWorkflowService: @@ -108,6 +128,12 @@ class Co4EWorkflowService: 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] = {} @@ -116,10 +142,12 @@ class Co4EWorkflowService: self._project_id: str = "" # workspace đang chọn — Flow Status lọc theo no self._runner = runner # DTO domain khong duoc cham dia (xem domain/workflows/run_record.py), - # nen viec doc/ghi file lich su nam o day, tang application. + # 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 @@ -127,71 +155,42 @@ class Co4EWorkflowService: # ---- 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 -------------------------------------------------- - # Doc/ghi thu cong (json.loads/write_text + tmp.replace), KHONG dung - # AtomicJsonFile — ban dau file nay dung AtomicJsonFile.read(), nhung - # review phat hien no doi hanh vi that so voi Co4ERunManager cu: gap - # JSON hong, AtomicJsonFile.read() ĐOI TEN file hong thanh - # ".bad-" (quarantine) roi moi tra ve mac dinh, trong - # khi ban cu chi bat loi va ĐE NGUYEN file hong tai cho, khong dong gi - # vao no. Day la mot thay doi quan sat duoc tren dia ma khong test nao - # khoa lai va khong co comment bao truoc — Lam (N3) da quyet 24/08: - # GIU HANH VI CU nguyen van (khong quarantine), vi day la buoc tach - # chi duoc phep doi hanh vi khi da noi ra ro rang va co lưới an toan, - # khong phai luc nay. def _load_history(self) -> None: - try: - data = json.loads(self._history_path_value.read_text(encoding="utf-8")) - except (OSError, ValueError): - return - max_seq = 0 - for rec in data.get("runs", []): - try: - record = RunRecord.from_dict(rec) - except Exception: - continue - if not record.id: - continue - self._runs[record.id] = record - if record.id.startswith("run") and record.id[3:].isdigit(): - max_seq = max(max_seq, int(record.id[3:])) - self._seq = max_seq # tranh sinh id trung voi lich su + """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: - runs = list(self._runs.values())[-_HISTORY_CAP:] - payload = {"runs": [r.to_dict() for r in runs]} - try: - self._history_path_value.parent.mkdir(parents=True, exist_ok=True) - # 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(self._history_path_value).write(payload) - except OSError: - # Giu dung hanh vi cu (core/co4e_run_manager.py::_save_history): - # mot lan luu that bai (day dia, mat quyen...) KHONG duoc phep - # chan luong goi cua moi hook (_on_event/_on_finished/_on_failed) - # dang di qua _emit_changed(). Bo try/except nay se lam mot loi - # ghi dia lam vo ca luot xu ly su kien dang chay, chi vi lich su - # khong luu duoc lan nay -- nguoi dung van thay Flow Status dung - # trong phien hien tai, chi la ban ghi tren dia lui lai mot buoc. - pass + """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}" @@ -247,6 +246,7 @@ class Co4EWorkflowService: # ---- 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") @@ -271,6 +271,7 @@ class Co4EWorkflowService: 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 @@ -278,6 +279,7 @@ class Co4EWorkflowService: 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" @@ -287,6 +289,7 @@ class Co4EWorkflowService: # ---- 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: @@ -295,6 +298,7 @@ class Co4EWorkflowService: 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) @@ -315,6 +319,7 @@ class Co4EWorkflowService: 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) @@ -323,6 +328,7 @@ class Co4EWorkflowService: 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) @@ -343,9 +349,11 @@ class Co4EWorkflowService: 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: @@ -363,6 +371,7 @@ class Co4EWorkflowService: 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 diff --git a/application/workspaces/file_preview_helpers.py b/application/workspaces/file_preview_helpers.py index 393395a..3f1c78f 100644 --- a/application/workspaces/file_preview_helpers.py +++ b/application/workspaces/file_preview_helpers.py @@ -28,6 +28,9 @@ def pptx_available() -> bool: 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: @@ -35,6 +38,11 @@ def read_text(path: str) -> str: 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) diff --git a/application/workspaces/file_workspace_service.py b/application/workspaces/file_workspace_service.py index 67ff35e..c4252dd 100644 --- a/application/workspaces/file_workspace_service.py +++ b/application/workspaces/file_workspace_service.py @@ -32,6 +32,9 @@ class FileWorkspaceService: """ 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]: diff --git a/config.py b/config.py index bfdacd4..df36bc3 100644 --- a/config.py +++ b/config.py @@ -275,6 +275,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"): @@ -361,6 +366,11 @@ class AppConfig(JsonConfigRepository): """ 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 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 917c771..8877301 100644 --- a/core/agent_security.py +++ b/core/agent_security.py @@ -149,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)") @@ -157,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)") @@ -165,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)") @@ -173,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 {}) diff --git a/core/agent_security_types.py b/core/agent_security_types.py index 044d01d..af28aab 100644 --- a/core/agent_security_types.py +++ b/core/agent_security_types.py @@ -19,6 +19,7 @@ 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" @@ -29,5 +30,6 @@ class SecurityBlocked(RuntimeError): 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/chat_agent.py b/core/chat_agent.py index eaa0917..3b41cb9 100644 --- a/core/chat_agent.py +++ b/core/chat_agent.py @@ -135,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: @@ -307,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, diff --git a/core/co4e.py b/core/co4e.py index 46e3682..ff64646 100644 --- a/core/co4e.py +++ b/core/co4e.py @@ -54,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" @@ -88,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 @@ -101,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 @@ -108,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 @@ -132,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() @@ -145,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"), @@ -161,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], @@ -169,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", "") @@ -193,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 [] @@ -232,6 +260,7 @@ 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" @@ -242,6 +271,7 @@ def save_workflow(wf: Workflow, directory: Optional[Path] = None) -> 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(): @@ -280,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(): @@ -291,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 [] @@ -308,6 +341,7 @@ 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" @@ -316,6 +350,7 @@ def save_custom_agent(agent: CustomAgent, directory: Optional[Path] = None) -> P 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(): @@ -340,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: @@ -360,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 @@ -379,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 @@ -395,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() @@ -407,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()) @@ -423,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() @@ -430,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.') @@ -443,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) @@ -461,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 1695401..c253daf 100644 --- a/core/co4e_run_manager.py +++ b/core/co4e_run_manager.py @@ -28,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") @@ -44,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 @@ -64,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 ------------------------------------------------------ @@ -87,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", "")), @@ -109,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] = {} @@ -126,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")) @@ -149,6 +168,7 @@ 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]} @@ -163,6 +183,7 @@ class Co4ERunManager(QObject): # ---- lifecycle -------------------------------------------------------- def _next_id(self) -> str: + """Sinh id run kế tiếp dạng 'runN'.""" self._seq += 1 return f"run{self._seq}" @@ -198,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, @@ -215,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") @@ -229,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 @@ -236,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" @@ -245,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() @@ -253,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) @@ -269,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) @@ -277,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() @@ -295,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: @@ -320,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 9cb47c6..088df89 100644 --- a/core/code_agent.py +++ b/core/code_agent.py @@ -31,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" 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 = '