Feature/delta team/epic r04 (#7)
CI / test (push) Canceled after 0s

## Summary

epic r04 - begin refactor

## Change Type

- [x] Cowork feature
- [ ] Bug fix
- [ ] Core AI contribution
- [ ] Test / hardening
- [ ] 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?

What is intentionally NOT included?

## Validation

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

Commands / evidence:

## Security Impact

Permission / credential / network / customer data impact:

## Compatibility

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

## Reviewer Notes

Anything Cowork reviewers should pay attention to.

---------

Co-authored-by: Anh Tran Nguyen Minh <anhtnm1@fpt.com>
Co-authored-by: Huong Le Thi Thien <huongltt35@fpt.com>
Co-authored-by: Nam Pham Dinh Thanh <nampdt@fpt.com>
Co-authored-by: Vu Dam Tuan <vudt15@fpt.com>
Co-authored-by: Hiep Ha Van <hiephv3@fpt.com>
Co-authored-by: Lam Hoang Van <lamhv7@fpt.com>
Reviewed-on: #7
Co-authored-by: Duy Le Huu <duylh19@fpt.com>
This commit was merged in pull request #7.
This commit is contained in:
2026-08-31 05:15:13 +00:00
committed by gitea-admin
co-authored by anhtnm1 huongltt35 Nam Pham Dinh Thanh vudt15 Hiep Ha Van lamhv7
parent 86c27e2e79
commit f9f6bc01fd
496 changed files with 68421 additions and 19688 deletions
+9
View File
@@ -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 ``<username>.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)
+13
View File
@@ -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 ``<agent_id>.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
+7
View File
@@ -31,6 +31,7 @@ _CMD = re.compile(r"(?<!\S)/agent(?::([\w\-.]+))?(?=$|[\s.,;:!?)\]}»”’'\"
def _slug(name: str) -> 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']}"
+2
View File
@@ -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 "—")
+13 -19
View File
@@ -27,27 +27,12 @@ from __future__ import annotations
import json
import re
from dataclasses import dataclass
from typing import List, Optional
from ..providers.base import Provider
from . import security_rules
class SecurityBlocked(RuntimeError):
"""A guardrail refused an action. ``verdict`` carries the full detail for
the admin alert; ``str(exc)`` is the short, user-facing reason."""
def __init__(self, verdict: "SecurityVerdict"):
super().__init__(verdict.reason or f"Blocked by agent security ({verdict.layer}).")
self.verdict = verdict
@dataclass
class SecurityVerdict:
allowed: bool
reason: str = ""
layer: str = "" # "prompt" | "attachment" | "command"
from .agent_security_alert import notify_admin
from .agent_security_types import SecurityBlocked, SecurityVerdict
def combined_rules_text(config, max_chars: int = 8000, agent_kind: str = "cowork") -> str:
@@ -164,6 +149,10 @@ def _ai_verdict(provider: Provider, system_prompt: str, content: str, layer: str
def validate_prompt(provider: Provider, user_text: str, rules_text: str) -> SecurityVerdict:
"""Nhờ model xét prompt người dùng theo bộ luật an toàn.
Prompt rỗng thì cho qua ngay, khỏi tốn một lượt gọi.
"""
if not (user_text or "").strip():
return SecurityVerdict(True, "", "prompt")
system = _PROMPT_SYSTEM.format(rules=rules_text or "(no additional rules configured)")
@@ -172,6 +161,7 @@ def validate_prompt(provider: Provider, user_text: str, rules_text: str) -> Secu
def validate_attachment(provider: Provider, filename: str, content: str,
rules_text: str) -> SecurityVerdict:
"""Nhờ model xét nội dung một tệp đính kèm theo bộ luật an toàn."""
if not (content or "").strip():
return SecurityVerdict(True, "", "attachment")
system = _ATTACHMENT_SYSTEM.format(rules=rules_text or "(no additional rules configured)")
@@ -180,6 +170,11 @@ def validate_attachment(provider: Provider, filename: str, content: str,
def validate_command(provider: Provider, command: str,
rules_text: str, ai_enabled: bool) -> SecurityVerdict:
"""Nhờ model xét một lệnh shell theo bộ luật an toàn.
``ai_enabled=False`` thì cho qua — người dùng đã tắt lớp xét bằng AI, bộ luật
tĩnh vẫn chạy ở chỗ khác.
"""
if not ai_enabled:
return SecurityVerdict(True, "", "command")
system = _COMMAND_SYSTEM.format(rules=rules_text or "(no additional rules configured)")
@@ -188,6 +183,7 @@ def validate_command(provider: Provider, command: str,
# ---- call-site convenience wrappers (used by chat_agent.py / code_agent.py) --
def _security_conf(config) -> dict:
"""Nhóm cấu hình ``agent_security``; không có config thì trả dict rỗng."""
return (config.data.get("agent_security", {}) if config is not None else {})
@@ -236,7 +232,6 @@ def enforce_prompt(provider: Provider, messages: List[dict], config, emit,
emit({"type": "notice", "level": "warning",
"text": f"🛡 Yêu cầu bị chặn bởi Agent Security: {verdict.reason}"})
from . import audit_log
from .agent_security_alert import notify_admin
audit_log.record("security_block", "prompt", False, verdict.reason)
notify_admin(config, verdict, detail=user_text[:1000])
@@ -266,7 +261,6 @@ def enforce_command(provider: Provider, name: str, args: dict, config, emit,
emit({"type": "notice", "level": "warning",
"text": f"🛡 Lệnh bị chặn bởi Agent Security ({verdict.layer}): {verdict.reason}"})
from . import audit_log
from .agent_security_alert import notify_admin
audit_log.record("security_block", name, False, f"{verdict.layer}: {verdict.reason}")
notify_admin(config, verdict, detail=command)
+1 -1
View File
@@ -12,7 +12,7 @@ from __future__ import annotations
from typing import Tuple
from . import ms365_graph
from .agent_security import SecurityVerdict
from .agent_security_types import SecurityVerdict
from .ms365_auth import Ms365AuthError, get_access_token
+35
View File
@@ -0,0 +1,35 @@
"""Shared value types for the Agent Security guardrails.
``SecurityVerdict``/``SecurityBlocked`` used to be defined in
``agent_security.py``, which forced ``agent_security_alert.py`` (which only
needs the *type*, to annotate/read ``notify_admin``'s ``verdict`` argument) to
import from it — while ``agent_security.py`` itself needed to call
``agent_security_alert.notify_admin()``, an architectural cycle only avoided
at runtime by deferring that second import inside a function body.
Hoisting the shared type into this dependency-free leaf module lets both
sides import it directly, so ``agent_security.py`` can import
``agent_security_alert`` at module top level too — no cycle, no deferred
imports needed for this pair.
"""
from __future__ import annotations
from dataclasses import dataclass
@dataclass
class SecurityVerdict:
"""Kết quả một lớp kiểm an toàn: cho qua hay không, lý do, và lớp nào ra phán quyết."""
allowed: bool
reason: str = ""
layer: str = "" # "prompt" | "attachment" | "command"
class SecurityBlocked(RuntimeError):
"""A guardrail refused an action. ``verdict`` carries the full detail for
the admin alert; ``str(exc)`` is the short, user-facing reason."""
def __init__(self, verdict: SecurityVerdict):
"""Lấy lý do trong phán quyết làm thông điệp; không có lý do thì ghi rõ lớp nào chặn."""
super().__init__(verdict.reason or f"Blocked by agent security ({verdict.layer}).")
self.verdict = verdict
+4
View File
@@ -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
+1
View File
@@ -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
+15 -72
View File
@@ -6,15 +6,23 @@ storage systems).
One JSON line per event, one file per day under ``~/.cowork_local/audit/`` —
same on-disk shape as ``usage_tracker.py`` (day-sharded ``.jsonl``, append-only,
``record()`` never raises so audit logging can never break a chat turn).
This module is now a thin, backward-compatible wrapper around
:class:`infrastructure.telemetry.audit_logger.CanonicalAuditLogger` — every
existing call site (``agent_security.py``, ``chat_agent.py``, ``tools.py``,
``ext_connectors.py``, ``mcp_client.py``, ``ms365_local.py``,
``permissions.py``, ``ui/structure_graph_view.py``, ``app.py``) keeps calling
``audit_log.set_identity``/``record``/``load_events`` exactly as before; only
the implementation moved.
"""
from __future__ import annotations
import json
from datetime import date, datetime
from datetime import date
from pathlib import Path
from typing import Any, Dict, List, Optional
from ..config import CONFIG_DIR
from ..infrastructure.telemetry.audit_logger import CanonicalAuditLogger
AUDIT_DIR = CONFIG_DIR / "audit"
@@ -23,66 +31,21 @@ AUDIT_DIR = CONFIG_DIR / "audit"
# action), "mcp_call" (a call to an external MCP server's tool).
Kind = str
# Process-global identity — who's logged in, their role, and this machine's
# name — set once right after login (app.py::run()), mirroring
# usage_tracker.py's identical pattern. NOT thread-local: fixed per process.
_identity_account = ""
_identity_role = ""
_identity_machine = ""
_identity_shared_dir = ""
_logger = CanonicalAuditLogger(AUDIT_DIR)
def set_identity(account: str, machine: str, role: str = "", shared_dir: str = "") -> None:
"""Called once after login succeeds. ``shared_dir``, when reachable,
makes every subsequent :func:`record` ALSO best-effort-append to the
shared cross-machine telemetry store (see :mod:`telemetry_shared`)."""
global _identity_account, _identity_role, _identity_machine, _identity_shared_dir
_identity_account = account or ""
_identity_role = role or ""
_identity_machine = machine or ""
_identity_shared_dir = shared_dir or ""
_logger.set_identity(account, machine, role=role, shared_dir=shared_dir)
def record(kind: Kind, name: str, ok: bool, detail: str = "",
agent_role: str = "") -> None:
"""Append one audit event. Never raises — audit logging must never break
a chat turn, a permission decision, or a tool call."""
try:
now = datetime.now()
event = {
"ts": now.isoformat(timespec="seconds"),
"kind": kind,
"agent_role": agent_role or "",
"name": name or "",
"ok": bool(ok),
"detail": (detail or "")[:2000], # bounded — never let a huge blob bloat the log
"account": _identity_account,
"role": _identity_role,
"machine": _identity_machine,
}
AUDIT_DIR.mkdir(parents=True, exist_ok=True)
path = AUDIT_DIR / f"{now.strftime('%Y-%m-%d')}.jsonl"
with path.open("a", encoding="utf-8") as f:
f.write(json.dumps(event, ensure_ascii=False) + "\n")
_write_shared(event, now)
except Exception: # noqa: BLE001
pass
def _write_shared(event: Dict[str, Any], now: datetime) -> None:
"""Best-effort mirror of ``event`` into the shared cross-machine store —
one file PER MACHINE per day, so no two machines ever write the same
file. Never raises."""
if not _identity_shared_dir or not _identity_machine:
return
try:
shared = Path(_identity_shared_dir).expanduser() / "telemetry" / "audit"
shared.mkdir(parents=True, exist_ok=True)
path = shared / f"{_identity_machine}-{now.strftime('%Y-%m-%d')}.jsonl"
with path.open("a", encoding="utf-8") as f:
f.write(json.dumps(event, ensure_ascii=False) + "\n")
except Exception: # noqa: BLE001
pass
_logger.record(kind, name, ok, detail=detail, agent_role=agent_role)
def load_events(start: Optional[date] = None, end: Optional[date] = None,
@@ -91,25 +54,5 @@ def load_events(start: Optional[date] = None, end: Optional[date] = None,
"""Events between ``start``/``end`` (inclusive; None = unbounded),
optionally filtered to one ``kind`` — this IS how each Monitoring
Dashboard panel gets its own slice of the same underlying log."""
directory = directory or AUDIT_DIR
if not directory.exists():
return []
events: List[Dict[str, Any]] = []
for path in sorted(directory.glob("*.jsonl")):
try:
day = datetime.strptime(path.stem, "%Y-%m-%d").date()
except ValueError:
continue
if (start and day < start) or (end and day > end):
continue
try:
for line in path.read_text(encoding="utf-8").splitlines():
if not line.strip():
continue
event = json.loads(line)
if kind is not None and event.get("kind") != kind:
continue
events.append(event)
except (OSError, json.JSONDecodeError):
continue
return events
events = _logger.load_events(start=start, end=end, kind=kind, directory=directory)
return [e.to_dict() for e in events]
+58 -10
View File
@@ -11,6 +11,8 @@ import re
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional
from ..application.conversations.tool_policy_gateway import ToolPolicyGateway
from ..domain.tools import ToolCapability, default_registry
from ..providers.base import Provider, ToolSpec
from . import agent_roles
from . import agent_security
@@ -27,6 +29,13 @@ from .tools import TOOL_SPECS, ToolContext, _snapshot, describe_action, execute_
# Generator / helper scripts — never a final deliverable in Cowork's output.
_SCRIPT_EXTS = {".py", ".pyw", ".js", ".mjs", ".cjs", ".ts", ".sh", ".bat", ".ps1", ".rb", ".pl"}
# R05-T03/T04: replaces the literal ``name in ("run_command",
# "install_package")`` check below with a capability lookup — EXECUTE is
# exactly the capability those two (and only those two) built-in tools carry
# (see domain/tools/tool_registry.py::BUILT_IN_CAPABILITIES). Copied per-turn
# into ``turn_tool_policy`` inside run_cowork() once extra_tools are known.
_COWORK_TOOL_REGISTRY = default_registry(TOOL_SPECS)
EmitFn = Callable[[Dict[str, Any]], None]
CancelFn = Callable[[], bool]
@@ -126,6 +135,12 @@ _UNSAFE = re.compile(r'[\\/:*?"<>|\x00-\x1f]+')
def _safe_filename(name: str) -> str:
"""Làm sạch tên tệp do model đề xuất: bỏ đường dẫn, thay ký tự cấm, không bao
giờ trả về chuỗi rỗng.
Model hay trả về tên có dấu ``/`` hoặc ``..`` — ghi thẳng là thoát khỏi thư
mục làm việc.
"""
base = Path(str(name)).name.strip()
base = _UNSAFE.sub("_", base).strip(" _.") or "output.txt"
if "." not in base:
@@ -298,17 +313,23 @@ def run_chat(
emit: EmitFn,
cancel: Optional[CancelFn] = None,
) -> Dict[str, Any]:
"""Chạy một lượt chat thuần (không có tool) và phát nội dung dần ra ngoài.
Tự chèn prompt hệ thống nếu tin nhắn đầu chưa phải ``system``.
"""
if not messages or messages[0].get("role") != "system":
messages.insert(0, {"role": "system", "content": COWORK_SYSTEM_PROMPT})
# Rulebase: always attach security rules so the agent follows them every turn
_apply_security_rules(messages, load_rules())
def on_text(piece: str) -> None:
"""Đẩy từng mẩu câu trả lời ra ngoài."""
emit({"type": "text", "delta": piece})
def on_reasoning(piece: str) -> None:
# Stream the model's reasoning so the UI can show a live, collapsible
# "Thinking" box (and keep the indicator active).
"""Đẩy từng mẩu suy luận nội bộ ra ngoài, để giao diện hiện hộp "Đang nghĩ"."""
emit({"type": "reasoning", "delta": piece})
assistant = provider.chat(messages, tools=None, on_text=on_text, cancel=cancel,
@@ -388,6 +409,19 @@ def run_cowork(
jira=(security_config.data.get("jira") if security_config else None))
extra_tools = extra_tools or []
extra_names = {t.name for t in extra_tools}
# R05-T04: MCP servers (core/mcp_client.py) and unified connectors
# (core/ext_connectors.py) — everything that arrives here as extra_tools —
# advertise no standard risk metadata, so each is tagged with the same
# conservative default (WRITE|EXECUTE|NETWORK) domain/tools/tool_registry.py
# uses for any unclassified tool. Copying the built-in registry per turn
# (cheap - under 20 entries) rather than mutating the shared module-level
# one keeps different turns' extra_tools from leaking into each other.
from ..domain.tools import ToolDescriptor, ToolRegistry
from ..domain.tools.tool_registry import UNKNOWN_SOURCE_CAPABILITIES
_turn_registry = ToolRegistry(_COWORK_TOOL_REGISTRY.all())
for _spec in extra_tools:
_turn_registry.register(ToolDescriptor.from_spec(_spec, UNKNOWN_SOURCE_CAPABILITIES))
turn_tool_policy = ToolPolicyGateway(_turn_registry, ToolCapability.EXECUTE)
# update_plan drives the Plan panel (above Output); it produces no file.
# Built-in tools the admin disabled (Monitoring → Tools) are filtered out.
from .tools import enabled_tool_specs
@@ -489,6 +523,18 @@ def run_cowork(
preview = {"kind": "info", "title": name, "text": str(args)}
emit({"type": "tool_proposed", "id": tc_id, "name": name, "args": args,
"preview": preview})
# R05-T04: MCP/connector tools used to run with NO permission
# check at all — this is what closes that gap. Same policy,
# same gate object as the built-in tools below.
if not turn_tool_policy.allow(
name, gate, {"name": name, "args": args, "preview": preview}
):
result = {"ok": False, "output": "Rejected by user."}
emit({"type": "tool_result", "id": tc_id, "name": name,
"ok": False, "output": result["output"]})
messages.append({"role": "tool", "tool_call_id": tc_id, "name": name,
"content": result["output"]})
continue
result = extra_executor(name, args)
emit({"type": "tool_result", "id": tc_id, "name": name,
"ok": result.get("ok", False), "output": result.get("output", "")})
@@ -528,16 +574,18 @@ def run_cowork(
# Permission Management (Sandbox Security Layer) — only when a
# gate was actually supplied (Settings: "confirm before running
# commands"); None preserves the pre-existing auto-run behavior.
if gate is not None and name in ("run_command", "install_package"):
approved = gate.request({"name": name, "args": args, "preview": preview})
if not approved:
result = {"ok": False, "output": "Rejected by user."}
evt = {"type": "tool_result", "id": tc_id, "name": name,
"ok": False, "output": result["output"]}
emit(evt)
messages.append({"role": "tool", "tool_call_id": tc_id,
"name": name, "content": result["output"]})
continue
# R05-T03: gating is now capability-driven (see
# turn_tool_policy above) instead of a literal name tuple.
if not turn_tool_policy.allow(
name, gate, {"name": name, "args": args, "preview": preview}
):
result = {"ok": False, "output": "Rejected by user."}
evt = {"type": "tool_result", "id": tc_id, "name": name,
"ok": False, "output": result["output"]}
emit(evt)
messages.append({"role": "tool", "tool_call_id": tc_id,
"name": name, "content": result["output"]})
continue
if name == "save_file":
result = _do_save_file(output_dir, title, args)
+63 -2
View File
@@ -18,6 +18,8 @@ existing ``core/skills.py`` registry.
"""
from __future__ import annotations
from ..infrastructure.persistence.json.atomic_json_file import AtomicJsonFile
import json
from dataclasses import asdict, dataclass, field
from pathlib import Path
@@ -52,6 +54,9 @@ RUN_MODES = ("auto", "plan", "manual")
def slugify(value: str) -> str:
"""Chuyển một chuỗi thành slug an toàn cho tên file: chỉ chữ/số/gạch, gộp gạch
liên tiếp. Rỗng thì trả về 'step' để không bao giờ sinh ra tên file trống.
"""
s = "".join(c if (c.isalnum() or c in "-_") else "-" for c in (value or "").strip().lower())
return "-".join(filter(None, s.split("-"))) or "step"
@@ -86,11 +91,13 @@ class Step:
@property
def is_parallel(self) -> bool:
"""Bước này có chạy nhiều sub-agent song song hay không."""
return self.variant == "parallel"
@dataclass
class Node:
"""Một node trên khung vẽ: id, toạ độ, và bước (:class:`Step`) mà nó đại diện."""
id: str
x: float = 0.0
y: float = 0.0
@@ -99,6 +106,7 @@ class Node:
@dataclass
class Edge:
"""Một cạnh nối hai node, quy định thứ tự chạy giữa chúng."""
id: str
source: str
target: str
@@ -106,6 +114,7 @@ class Edge:
@dataclass
class Workflow:
"""Một luồng Co4E: danh sách node, cạnh, và cờ đánh dấu đây có phải mẫu không."""
id: str
name: str = "Untitled flow"
is_template: bool = False
@@ -130,6 +139,10 @@ class CustomAgent:
# ---- (de)serialization ---------------------------------------------------
def step_from_dict(d: dict) -> Step:
"""Dựng :class:`Step` từ dict đọc trên đĩa.
Lọc bỏ khoá lạ để file luồng của phiên bản mới hơn không làm vỡ bản cũ.
"""
d = dict(d or {})
subs = d.pop("sub_agents", None) or []
known = Step().__dict__.keys()
@@ -143,11 +156,13 @@ def step_from_dict(d: dict) -> Step:
def node_from_dict(d: dict) -> Node:
"""Dựng :class:`Node` từ dict đọc trên đĩa."""
return Node(id=str(d.get("id", "")), x=float(d.get("x", 0) or 0),
y=float(d.get("y", 0) or 0), data=step_from_dict(d.get("data", {})))
def workflow_from_dict(d: dict) -> Workflow:
"""Dựng :class:`Workflow` từ dict đọc trên đĩa."""
return Workflow(
id=str(d.get("id", "")),
name=d.get("name", "Untitled flow"),
@@ -159,6 +174,7 @@ def workflow_from_dict(d: dict) -> Workflow:
def workflow_to_dict(wf: Workflow) -> dict:
"""Chuyển một luồng thành dict để ghi JSON."""
return {
"id": wf.id, "name": wf.name, "is_template": wf.is_template,
"nodes": [{"id": n.id, "x": n.x, "y": n.y, "data": _step_dict(n.data)} for n in wf.nodes],
@@ -167,16 +183,19 @@ def workflow_to_dict(wf: Workflow) -> dict:
def _step_dict(step: Step) -> dict:
"""Chuyển một bước thành dict; ``asdict`` đã tự chuyển ``sub_agents`` thành list dict."""
d = asdict(step)
# asdict already turns sub_agents into list[dict]
return d
def agent_to_dict(a: CustomAgent) -> dict:
"""Chuyển một agent tự tạo thành dict để ghi JSON."""
return asdict(a)
def agent_from_dict(d: dict) -> CustomAgent:
"""Dựng :class:`CustomAgent` từ dict, lọc bỏ khoá lạ."""
known = CustomAgent(id="").__dict__.keys()
d = {k: v for k, v in (d or {}).items() if k in known}
d.setdefault("id", "")
@@ -191,32 +210,43 @@ _counter = {"n": 0}
def _mint_id(prefix: str) -> str:
"""Sinh id tăng dần dạng ``<prefix>_000001``."""
_counter["n"] += 1
return f"{prefix}_{_counter['n']:06d}"
def new_node_id() -> str:
"""Id mới cho một node."""
return _mint_id("node")
def new_edge_id(source: str, target: str) -> str:
"""Id cạnh suy ra TỪ cặp nguồn/đích.
Cố ý không ngẫu nhiên: nhờ vậy nối lại đúng cặp node đó luôn cho ra cùng
một id, và không thể sinh ra hai cạnh trùng nhau.
"""
return f"e_{source}__{target}"
def new_workflow(name: str = "Untitled flow") -> Workflow:
"""Tạo một luồng rỗng với id mới."""
return Workflow(id=_mint_id("wf"), name=name)
def new_custom_agent(name: str = "") -> CustomAgent:
"""Tạo một agent tự tạo rỗng với id mới."""
return CustomAgent(id=_mint_id("agent"), name=name)
# ---- workflow store ------------------------------------------------------
def workflows_dir() -> Path:
"""Thư mục chứa file luồng."""
return WORKFLOWS_DIR
def list_workflows(directory: Optional[Path] = None) -> List[Workflow]:
"""Liệt kê mọi luồng đã lưu; thư mục chưa có thì trả list rỗng."""
directory = directory or WORKFLOWS_DIR
if not directory.exists():
return []
@@ -230,14 +260,18 @@ def list_workflows(directory: Optional[Path] = None) -> List[Workflow]:
def save_workflow(wf: Workflow, directory: Optional[Path] = None) -> Path:
"""Ghi một luồng ra ``<id>.json``, tự tạo thư mục nếu chưa có."""
directory = directory or WORKFLOWS_DIR
directory.mkdir(parents=True, exist_ok=True)
path = directory / f"{wf.id}.json"
path.write_text(json.dumps(workflow_to_dict(wf), ensure_ascii=False, indent=2), encoding="utf-8")
# Tiêu chí nghiệm thu A: mọi thao tác ghi tệp đi qua AtomicJsonFile. Trước
# đây ghi thẳng, nên tắt máy giữa lúc lưu là mất luôn workflow.
AtomicJsonFile(path).write(workflow_to_dict(wf))
return path
def get_workflow(wf_id: str, directory: Optional[Path] = None) -> Optional[Workflow]:
"""Đọc một luồng theo id; ``None`` nếu không có."""
directory = directory or WORKFLOWS_DIR
path = directory / f"{wf_id}.json"
if not path.exists():
@@ -276,6 +310,7 @@ def tr_copy_suffix() -> str:
def delete_workflow(wf_id: str, directory: Optional[Path] = None) -> None:
"""Xoá file luồng theo id; không có thì bỏ qua."""
directory = directory or WORKFLOWS_DIR
path = directory / f"{wf_id}.json"
if path.exists():
@@ -287,10 +322,12 @@ def delete_workflow(wf_id: str, directory: Optional[Path] = None) -> None:
# ---- custom-agent store --------------------------------------------------
def agents_dir() -> Path:
"""Thư mục chứa file agent tự tạo."""
return AGENTS_DIR
def list_custom_agents(directory: Optional[Path] = None) -> List[CustomAgent]:
"""Liệt kê mọi agent tự tạo; thư mục chưa có thì trả list rỗng."""
directory = directory or AGENTS_DIR
if not directory.exists():
return []
@@ -304,14 +341,16 @@ def list_custom_agents(directory: Optional[Path] = None) -> List[CustomAgent]:
def save_custom_agent(agent: CustomAgent, directory: Optional[Path] = None) -> Path:
"""Ghi một agent tự tạo ra ``<id>.json``."""
directory = directory or AGENTS_DIR
directory.mkdir(parents=True, exist_ok=True)
path = directory / f"{agent.id}.json"
path.write_text(json.dumps(agent_to_dict(agent), ensure_ascii=False, indent=2), encoding="utf-8")
AtomicJsonFile(path).write(agent_to_dict(agent))
return path
def delete_custom_agent(agent_id: str, directory: Optional[Path] = None) -> None:
"""Xoá file agent tự tạo theo id; không có thì bỏ qua."""
directory = directory or AGENTS_DIR
path = directory / f"{agent_id}.json"
if path.exists():
@@ -336,6 +375,11 @@ def compute_waves(nodes: List[Node], edges: List[Edge]) -> Dict[str, int]:
limit = len(nodes) + 1
def depth(nid: str, seen: frozenset) -> int:
"""Độ sâu của một node = lớp chạy của nó.
Có nhớ kết quả và chặn theo ``limit``: đồ thị có vòng sẽ khiến đệ quy chạy
mãi, nên gặp node đã thấy trong nhánh hiện tại thì dừng.
"""
if nid in wave:
return wave[nid]
if nid in seen or len(seen) > limit:
@@ -356,12 +400,14 @@ def connected_component_count(nodes: List[Node], edges: List[Edge]) -> int:
parent = {n.id: n.id for n in nodes}
def find(x):
"""Tìm gốc của một phần tử, kèm nén đường đi (union-find)."""
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
def union(a, b):
"""Gộp hai tập hợp lại làm một (union-find)."""
ra, rb = find(a), find(b)
if ra != rb:
parent[ra] = rb
@@ -375,6 +421,7 @@ def connected_component_count(nodes: List[Node], edges: List[Edge]) -> int:
# ---- run-stage compilation ----------------------------------------------
@dataclass
class RunStage:
"""Một chặng chạy: ứng với một node, hoặc một nhánh song song / bước gộp của nó."""
id: str # node id, or "<node>__p<i>" / "<node>__pjoin"
node_id: str # which canvas node this stage maps back onto
wave: int
@@ -391,6 +438,7 @@ PLAN_MODE_PREAMBLE = (
def build_skills_block(skills: List[str], skill_map: Dict[str, str]) -> str:
"""Ghép nội dung các skill được chọn thành một khối chèn vào prompt."""
parts = []
for name in skills or []:
content = (skill_map.get(name) or "").strip()
@@ -403,6 +451,9 @@ def build_skills_block(skills: List[str], skill_map: Dict[str, str]) -> str:
def _shared_prompt_parts(step: Step, skill_map: Dict[str, str], extra_context: str) -> str:
"""Phần prompt dùng chung cho cả ba loại chặng: chỉ dẫn của bước, khối skill,
và ngữ cảnh thêm từ các bước trước.
"""
parts = []
if step.instructions.strip():
parts.append(step.instructions.strip())
@@ -419,6 +470,7 @@ def _shared_prompt_parts(step: Step, skill_map: Dict[str, str], extra_context: s
def build_step_prompt(step: Step, skill_map: Dict[str, str], extra_context: str = "") -> str:
"""Prompt cho một bước chạy tuần tự bình thường."""
head = f'You are the {step.role} agent for the workflow step "{step.label}".'
body = _shared_prompt_parts(step, skill_map, extra_context)
return f"{head}\n{body}".strip()
@@ -426,6 +478,11 @@ def build_step_prompt(step: Step, skill_map: Dict[str, str], extra_context: str
def build_subagent_prompt(step: Step, sub: SubAgent, peers: List[str],
skill_map: Dict[str, str], extra_context: str = "") -> str:
"""Prompt cho một sub-agent chạy song song.
Nói rõ nó đang chạy CÙNG LÚC với những ai và phải ở trong phạm vi của mình —
không có câu đó, các sub-agent hay làm chồng việc của nhau.
"""
peer_txt = ", ".join(p for p in peers if p) or "peers"
head = (f'You are the "{sub.agent}" agent working concurrently (in parallel with '
f'{peer_txt}) on the workflow step "{step.label}". Stay within your own scope.')
@@ -439,6 +496,7 @@ def build_subagent_prompt(step: Step, sub: SubAgent, peers: List[str],
def build_join_prompt(step: Step, skill_map: Dict[str, str], extra_context: str = "") -> str:
"""Prompt cho bước gộp: hợp nhất đầu ra của các sub-agent thành một kết quả."""
head = (f'You are the coordinator for the parallel step "{step.label}". Consolidate the '
f"outputs of the sub-agents (provided above as prior outputs) into one coherent result.")
body = _shared_prompt_parts(step, skill_map, extra_context)
@@ -457,6 +515,9 @@ def compile_run_stages(nodes: List[Node], edges: List[Edge],
stages: List[RunStage] = []
def finalize(prompt: str, preset: str) -> tuple:
"""Chốt prompt của một chặng: áp phạm vi theo preset, và thêm lời mở đầu chế
độ lập kế hoạch nếu đang chạy ở chế độ đó.
"""
scope = PRESET_SCOPES.get(preset)
if plan_mode:
prompt = PLAN_MODE_PREAMBLE + prompt
+1
View File
@@ -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
+45 -4
View File
@@ -13,6 +13,8 @@ the run that is currently open.
"""
from __future__ import annotations
from ..infrastructure.persistence.json.atomic_json_file import AtomicJsonFile
import json
from pathlib import Path
from typing import Dict, List, Optional
@@ -26,6 +28,7 @@ _HISTORY_CAP = 500 # keep the most-recent N runs on disk
def _now_str() -> str:
"""Mốc thời gian hiện tại dạng 'YYYY-MM-DD HH:MM' cho lịch sử run."""
from datetime import datetime
return datetime.now().strftime("%Y-%m-%d %H:%M")
@@ -42,6 +45,11 @@ class RunHandle:
def __init__(self, run_id: str, wf_id: str, name: str, total: int,
plan_mode: bool, manual: bool, created_by: str = "", created_at: str = "",
project_id: str = ""):
"""Một lượt chạy workflow đang sống trong bộ nhớ.
``total`` âm bị kẹp về 0 — số bước không thể âm, và để lọt xuống thì thanh
tiến độ vẽ ngược.
"""
self.id = run_id
self.wf_id = wf_id
self.name = name
@@ -62,9 +70,11 @@ class RunHandle:
@property
def running(self) -> bool:
"""Lượt chạy này còn đang chạy hay không."""
return self.status == "running"
def progress_text(self) -> str:
"""Chuỗi tiến độ 'xong/tổng'; chưa biết tổng thì hiện trạng thái."""
return f"{self.done}/{self.total}" if self.total else self.status
# ---- persistence ------------------------------------------------------
@@ -85,6 +95,7 @@ class RunHandle:
@classmethod
def from_record(cls, rec: dict) -> "RunHandle":
"""Dựng lại một ``RunHandle`` từ bản ghi đọc trong lịch sử trên đĩa."""
from .co4e import workflow_from_dict
rec = dict(rec or {})
h = cls(str(rec.get("id", "")), str(rec.get("wf_id", "")),
@@ -107,10 +118,18 @@ class RunHandle:
class Co4ERunManager(QObject):
"""Quản lý vòng đời nhiều lượt chạy luồng Co4E cùng lúc.
Flow Status lọc theo project, nên hầu hết truy vấn ở đây chỉ tính run thuộc
workspace ĐANG chọn — xem ``_belongs``.
"""
changed = Signal() # any run's status/progress changed → refresh views
event = Signal(str, dict) # (run_id, ev) — node-level events, for mirroring
def __init__(self, ctx):
"""Dựng bộ quản lý run và khôi phục lịch sử cũ ngay, để tab Flow Status có nội
dung ngay khi mở chứ không trống cho tới lần chạy đầu tiên.
"""
super().__init__()
self.ctx = ctx
self._runs: Dict[str, RunHandle] = {}
@@ -124,10 +143,12 @@ class Co4ERunManager(QObject):
# ---- persistence ------------------------------------------------------
def _history_path(self) -> Path:
"""Đường dẫn file lịch sử run."""
from .co4e import CO4E_DIR
return CO4E_DIR / "run_history.json"
def _load_history(self) -> None:
"""Khôi phục lịch sử run từ đĩa lúc khởi động; file hỏng thì bỏ qua lặng lẽ."""
path = self._history_path()
try:
data = json.loads(path.read_text(encoding="utf-8"))
@@ -147,20 +168,22 @@ class Co4ERunManager(QObject):
self._seq = max_seq # avoid minting ids that collide with history
def _save_history(self) -> None:
"""Ghi ``_HISTORY_CAP`` run gần nhất xuống đĩa."""
path = self._history_path()
runs = list(self._runs.values())[-_HISTORY_CAP:]
payload = {"runs": [h.to_record() for h in runs]}
try:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".json.tmp")
tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2),
encoding="utf-8")
tmp.replace(path) # atomic — never leaves a half-written file
# AtomicJsonFile thay cho tmp+replace tự viết: bản cũ thiếu fsync
# (dữ liệu có thể còn trong bộ đệm khi mất điện) và dùng thẳng
# Path.replace, vốn thỉnh thoảng bị Defender từ chối trên Windows.
AtomicJsonFile(path).write(payload)
except OSError:
pass
# ---- lifecycle --------------------------------------------------------
def _next_id(self) -> str:
"""Sinh id run kế tiếp dạng 'runN'."""
self._seq += 1
return f"run{self._seq}"
@@ -196,6 +219,7 @@ class Co4ERunManager(QObject):
run_label = handle.name
def job(worker: AgentWorker):
"""Chạy nền: thực thi luồng, chuyển tiếp sự kiện tiến độ và cờ huỷ."""
return co4e_runner.run_workflow(
ctx, nodes, edges, out_dir, worker.emit_event, worker.is_cancelled,
plan_mode=plan_mode, skill_map=sk, only_nodes=only, seed_outputs=seed,
@@ -213,6 +237,7 @@ class Co4ERunManager(QObject):
# ---- worker callbacks -------------------------------------------------
def _on_event(self, run_id: str, ev: dict) -> None:
"""Nhận sự kiện từ luồng đang chạy và cập nhật trạng thái/tiến độ của run."""
handle = self._runs.get(run_id)
if handle is not None and isinstance(ev, dict):
t = ev.get("type")
@@ -227,6 +252,10 @@ class Co4ERunManager(QObject):
self.event.emit(run_id, ev)
def _on_finished(self, run_id: str) -> None:
"""Job kết thúc mà không phát ``run_done``: chốt trạng thái về 'done'.
Lẽ ra không xảy ra, nhưng thiếu bước này thì run kẹt ở 'running' mãi.
"""
handle = self._runs.get(run_id)
if handle is not None and handle.status == "running":
# job returned without a run_done event (shouldn't happen) — settle it
@@ -234,6 +263,7 @@ class Co4ERunManager(QObject):
self.changed.emit()
def _on_failed(self, run_id: str, err: str) -> None:
"""Job ném lỗi: ghi lỗi vào bản ghi run và báo ra ngoài."""
handle = self._runs.get(run_id)
if handle is not None:
handle.status = "error"
@@ -243,6 +273,7 @@ class Co4ERunManager(QObject):
# ---- control ----------------------------------------------------------
def stop(self, run_id: str) -> None:
"""Yêu cầu dừng một run đang chạy."""
handle = self._runs.get(run_id)
if handle is not None and handle.worker is not None and handle.running:
handle.worker.request_stop()
@@ -251,6 +282,7 @@ class Co4ERunManager(QObject):
def stop_all(self) -> None:
# Only the CURRENT workspace's runs (Flow Status is per-project).
"""Dừng mọi run của workspace đang chọn."""
for run_id in [r for r, h in self._runs.items() if self._belongs(h)]:
self.stop(run_id)
@@ -267,6 +299,7 @@ class Co4ERunManager(QObject):
self.changed.emit()
def remove(self, run_id: str) -> None:
"""Xoá một run khỏi lịch sử; đang chạy thì dừng trước."""
handle = self._runs.get(run_id)
if handle is not None and handle.running:
self.stop(run_id)
@@ -275,6 +308,7 @@ class Co4ERunManager(QObject):
def clear_finished(self) -> None:
# Only clear finished runs of the CURRENT workspace.
"""Xoá mọi run đã kết thúc của workspace đang chọn, giữ nguyên run đang chạy."""
for run_id in [r for r, h in self._runs.items() if not h.running and self._belongs(h)]:
self._runs.pop(run_id, None)
self.changed.emit()
@@ -293,9 +327,11 @@ class Co4ERunManager(QObject):
return list(self._runs.values())
def get(self, run_id: str) -> Optional[RunHandle]:
"""Bản ghi của một run theo id; ``None`` nếu không có."""
return self._runs.get(run_id)
def active_count(self) -> int:
"""Số run đang chạy của workspace đang chọn."""
return sum(1 for h in self._runs.values() if h.running and self._belongs(h))
def set_current_project(self, project_id: str) -> None:
@@ -318,6 +354,11 @@ class Co4ERunManager(QObject):
# tab), not in the config/install folder. One subfolder per flow keeps
# runs tidy. Falls back to the global Cowork output dir when no workspace
# is selected.
"""Thư mục ghi kết quả của một luồng, tạo sẵn nếu chưa có.
Ưu tiên thư mục của workspace đang chọn để file rơi đúng chỗ người dùng làm
việc (màn Thư mục), không rơi vào thư mục cài đặt.
"""
from .co4e import slugify
base = self._output_root
if base is None:
+10
View File
@@ -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:
+20 -4
View File
@@ -12,6 +12,8 @@ import re
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional
from ..application.conversations.tool_policy_gateway import ToolPolicyGateway
from ..domain.tools import ToolCapability, ToolDescriptor, ToolRegistry
from ..providers.base import Provider
from . import agent_roles
from . import agent_security
@@ -29,6 +31,11 @@ _TOOL_LINE = re.compile(r"@@TOOL\s+(\w+)\s+(\{.*\})", re.DOTALL)
def code_system_prompt(workdir: Path, has_memory: bool = False, plan: bool = False,
has_plan_tool: bool = False, has_ms365: bool = False) -> str:
"""Prompt hệ thống cho Code agent, ghép theo năng lực thật của lượt chạy.
Chỉ liệt kê những tool đang BẬT, và thêm ghi chú chế độ lập kế hoạch khi cần —
nói với model về một tool nó không có sẽ khiến nó gọi rồi báo lỗi.
"""
names = ", ".join(t.name for t in TOOL_SPECS)
plan_note = ("PLAN MODE: only analyze and propose a detailed plan; do NOT write files or run "
"commands. When the user asks to gencode/implement, the app switches to ACT.\n"
@@ -225,6 +232,14 @@ def run_code(
# read/list ms365 tools count as "read-only, never confirm". Names are
# the MCP-qualified "ms365__*" form the agent sees (see ms365_tools.py).
gated_tools = WRITE_TOOLS | MS365_WRITE_TOOLS
# R05-T03/T04: ``gated_tools`` stays the authoritative name set (unchanged),
# but the actual confirm decision now goes through the same
# ToolPolicyGateway class run_cowork uses, instead of a separate
# hand-rolled ``if name in gated_tools`` + direct ``gate.request(...)``.
code_tool_policy = ToolPolicyGateway(
ToolRegistry(ToolDescriptor(n, "", {}, ToolCapability.WRITE) for n in gated_tools),
ToolCapability.WRITE,
)
# In PLAN mode, don't advertise write/run tools (analysis only).
advertised = [t for t in all_tools if t.name not in gated_tools] if plan else all_tools
has_memory = any(t.name.startswith("cmem_") for t in extra_tools)
@@ -297,10 +312,11 @@ def run_code(
agent_security.enforce_command(provider, name, args, security_config, emit,
agent_kind="code")
if name in gated_tools:
approved = gate.request({"id": tc_id, "name": name, "args": args, "preview": preview})
else:
approved = True # read-only tools (incl. codebase memory) never confirm
# read-only tools (incl. codebase memory) never consult the gate —
# code_tool_policy.requires_confirmation(name) is False for them.
approved = code_tool_policy.allow(
name, gate, {"id": tc_id, "name": name, "args": args, "preview": preview}
)
if cancel():
return messages
+17
View File
@@ -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}"}
+16
View File
@@ -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:
+16
View File
@@ -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},
+15
View File
@@ -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
+25
View File
@@ -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:
+4
View File
@@ -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():
+1
View File
@@ -18,6 +18,7 @@ _CDN_D3 = '<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.8.5/d3.min.j
def build_html(graph) -> str:
"""Dựng trang HTML D3 cho đồ thị: nhét dữ liệu node/cạnh vào bản mẫu."""
html = TEMPLATE.read_text(encoding="utf-8")
# Inline a bundled d3 (offline) if present; else keep the CDN reference.
+13
View File
@@ -29,6 +29,7 @@ _ACTIVE_PIDS: set[int] = set()
def active_pids() -> List[int]:
"""Pid của các tiến trình con đang chạy — dùng để dọn sạch khi thoát app."""
with _active_pids_lock:
return sorted(_ACTIVE_PIDS)
@@ -142,6 +143,11 @@ def _run_cancellable_body(
proc: "subprocess.Popen", cancel: CancelFn, timeout: Optional[float],
on_output: Optional[Callable[[str], None]], limits: Optional[Dict[str, float]],
) -> Tuple[Optional[int], str, bool, bool, bool]:
"""Chạy một tiến trình con có thể huỷ giữa chừng, có hạn giờ và có giới hạn tài nguyên.
Trên Windows gắn tiến trình vào một Job Object để khi giết là giết cả cây
tiến trình con — giết mỗi tiến trình cha sẽ để lại đám con mồ côi.
"""
job_handle = None
if sys.platform == "win32":
from .win_job import assign_process, create_job_object
@@ -158,6 +164,11 @@ def _run_cancellable_body(
collected: Dict[str, list] = {"out": [], "err": []}
def _read_stream(stream, key: str) -> None:
"""Đọc một luồng đầu ra theo từng dòng ở luồng riêng.
Phải đọc song song stdout và stderr: đọc lần lượt sẽ kẹt khi tiến trình con
làm đầy bộ đệm của luồng còn lại.
"""
try:
for line in iter(stream.readline, ""):
collected[key].append(line)
@@ -239,6 +250,7 @@ def network_blocked_env(base_env: Optional[Dict[str, str]] = None) -> Dict[str,
def _can_pip() -> bool:
# A PyInstaller/py2exe build has no usable pip; don't attempt installs there.
"""Bản đóng gói (PyInstaller) không có pip dùng được — đừng thử cài gì ở đó."""
return not getattr(sys, "frozen", False)
@@ -267,6 +279,7 @@ def ensure_module(module: str, package: str | None = None):
def venv_python_path(venv_dir: Path) -> Path:
"""Đường dẫn tới ``python`` trong một virtualenv, khác nhau giữa Windows và POSIX."""
return venv_dir / ("Scripts/python.exe" if sys.platform == "win32" else "bin/python")
+21
View File
@@ -23,6 +23,7 @@ IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".tiff", ".tif",
def is_image(path) -> bool:
"""Đuôi tệp này có phải ảnh không."""
return Path(path).suffix.lower() in IMAGE_EXTS
@@ -164,6 +165,10 @@ def extract_text(path, progress=None) -> tuple[str | None, str]:
# Office Open XML (docx / xlsx / pptx)
# --------------------------------------------------------------------------
def _docx(p: Path) -> str:
"""Trích văn bản từ .docx bằng cách đọc thẳng XML trong gói zip.
Không cần thư viện ngoài — .docx vốn là một file zip chứa XML.
"""
with zipfile.ZipFile(p) as z:
xml = z.read("word/document.xml").decode("utf-8", "replace")
out: list[str] = []
@@ -179,6 +184,7 @@ def _docx(p: Path) -> str:
def _pptx(p: Path) -> str:
"""Trích văn bản từ .pptx, đi theo đúng thứ tự slide."""
out: list[str] = []
with zipfile.ZipFile(p) as z:
slides = [n for n in z.namelist() if re.match(r"ppt/slides/slide\d+\.xml$", n)]
@@ -192,6 +198,11 @@ def _pptx(p: Path) -> str:
def _xlsx(p: Path) -> str:
"""Trích văn bản từ .xlsx, có phân giải bảng chuỗi dùng chung.
Excel lưu chuỗi trong một bảng riêng và ô chỉ giữ chỉ số — đọc thẳng ô sẽ ra
toàn số.
"""
with zipfile.ZipFile(p) as z:
names = z.namelist()
shared: list[str] = []
@@ -235,6 +246,7 @@ def _xlsx(p: Path) -> str:
# OpenDocument (odt / ods / odp)
# --------------------------------------------------------------------------
def _odf(p: Path) -> str:
"""Trích văn bản từ tài liệu OpenDocument (.odt/.ods/.odp)."""
with zipfile.ZipFile(p) as z:
xml = z.read("content.xml").decode("utf-8", "replace")
xml = re.sub(r"<text:line-break\s*/>", "\n", xml)
@@ -249,6 +261,10 @@ def _odf(p: Path) -> str:
# PDF + LibreOffice fallback
# --------------------------------------------------------------------------
def _pdf(p: Path, progress=None) -> tuple[str | None, str]:
"""Trích văn bản từ PDF bằng ``pypdf``, tự cài nếu thiếu.
Trả về (văn bản, ghi chú); văn bản là ``None`` khi không trích được.
"""
from .deps import ensure_module
# Auto-install pypdf when missing (no manual install needed); fall back to
@@ -369,6 +385,11 @@ def _office_com_to_pdf(src: Path, pdf: Path) -> str | None:
def _soffice_to_text(p: Path) -> tuple[str | None, str]:
"""Cách dự phòng cuối: nhờ LibreOffice chuyển tài liệu sang văn bản.
Dùng cho định dạng không có bộ đọc riêng; không cài LibreOffice thì trả về
lý do để chỗ gọi hiện ra.
"""
soffice = find_soffice()
if not soffice:
return None, "no extractor available (install LibreOffice)"
+4
View File
@@ -21,6 +21,10 @@ _RUN_PREVIEW_CHARS = 40
def _run_style(font) -> str:
"""Mô tả định dạng một đoạn chữ (đậm, nghiêng, cỡ, màu) thành chuỗi ngắn.
Dùng để AI sửa tài liệu mà vẫn giữ được định dạng gốc.
"""
bits: list[str] = []
try:
if font.name:
+11
View File
@@ -79,6 +79,7 @@ def new_connector(category: str, preset_id: str = "", name: str = "") -> Dict[st
def _redact(entry: Dict[str, Any]) -> Dict[str, Any]:
"""Bản sao đã che các trường nhạy cảm (khoá, token) — dùng khi ghi log/kiểm toán."""
out = dict(entry)
for k in _SENSITIVE_KEYS:
if out.get(k):
@@ -92,6 +93,11 @@ class RestApiConnector:
vendor documents without this app knowing that vendor's API shape."""
def __init__(self, entry: Dict[str, Any]):
"""Đọc một khai báo connector REST.
``base_url`` luôn được chuẩn hoá thành có đúng một dấu ``/`` ở cuối, để ghép
đường dẫn về sau không sinh ra ``//`` hay dính liền.
"""
self.id = entry.get("id") or entry.get("name", "")
self.display_name = entry.get("name") or self.id
self.base_url = (entry.get("base_url") or "").rstrip("/") + "/"
@@ -100,6 +106,9 @@ class RestApiConnector:
self.auth_scheme = entry.get("auth_scheme") or "Bearer"
def tool_spec(self) -> ToolSpec:
"""Khai báo tool để đưa cho model; tên tool có tiền tố là id connector nên hai
connector không đụng tên nhau.
"""
return ToolSpec(
name=f"{self.id}{_SEP}http_request",
description=(
@@ -122,6 +131,7 @@ class RestApiConnector:
)
def call(self, args: Dict[str, Any]) -> Dict[str, Any]:
"""Gọi API theo tham số model đưa ra, đi qua lớp TLS có ghim chứng chỉ nội bộ."""
from .tls_trust import request_any_method as tls_request
method = str(args.get("method", "GET")).upper()
@@ -153,6 +163,7 @@ class RestApiConnector:
return {"ok": ok, "output": f"HTTP {resp.status_code}\n{text}"}
def test_connection(self) -> Tuple[bool, str]:
"""Thử kết nối tới endpoint; trả về (thành công, thông điệp)."""
from .tls_trust import request as tls_request
if not self.base_url.strip("/"):
+24
View File
@@ -30,6 +30,7 @@ class SubAgent:
@dataclass
class FlowStep:
"""Một bước trong luồng cũ: prompt, skill áp dụng, và danh sách agent chạy song song."""
name: str
prompt: str = ""
skill: str = "" # skill name to apply on this step ("" = none)
@@ -44,11 +45,13 @@ class FlowStep:
@property
def is_parallel(self) -> bool:
"""Bước này có chạy nhiều agent song song hay không."""
return bool(self.parallel_agents)
@dataclass
class Flow:
"""Một luồng cũ: tên, mô tả, và danh sách bước chạy tuần tự."""
name: str
description: str = ""
steps: List[FlowStep] = field(default_factory=list)
@@ -77,6 +80,11 @@ class FlowRunStatus:
substeps: List[dict] = field(default_factory=list)
def state_of(self, i: int) -> str:
"""Trạng thái hiển thị của bước thứ ``i``: xong, đang chạy, lỗi hay còn chờ.
Chỉ bước ngay TRƯỚC con trỏ mới được đánh dấu lỗi — các bước xong trước đó
vẫn là xong.
"""
if i < self.done:
if self.last_error and i == self.done - 1:
return STEP_ERROR
@@ -97,11 +105,13 @@ class FlowRunStatus:
def _slug(name: str) -> str:
"""Định danh an toàn cho tên file, suy từ tên luồng."""
s = "".join(c if (c.isalnum() or c in "-_") else "-" for c in name.strip().lower())
return "-".join(filter(None, s.split("-"))) or "flow"
def flows_dir() -> Path:
"""Thư mục chứa file luồng cũ."""
return FLOWS_DIR
@@ -126,11 +136,13 @@ def default_req_to_demo() -> Flow:
def to_dict(flow: Flow) -> dict:
"""Chuyển một luồng thành dict để ghi JSON."""
return {"name": flow.name, "description": flow.description,
"steps": [asdict(s) for s in flow.steps]}
def from_dict(data: dict) -> Flow:
"""Dựng :class:`Flow` từ dict đọc trên đĩa, lọc bỏ khoá lạ."""
steps = []
for raw in data.get("steps", []):
raw = dict(raw)
@@ -142,6 +154,7 @@ def from_dict(data: dict) -> Flow:
def list_flows(directory: Path = FLOWS_DIR) -> List[Flow]:
"""Liệt kê mọi luồng đã lưu; thư mục chưa có thì trả list rỗng."""
if not directory.exists():
return []
flows: List[Flow] = []
@@ -154,6 +167,11 @@ def list_flows(directory: Path = FLOWS_DIR) -> List[Flow]:
def save_flow(flow: Flow, directory: Path = FLOWS_DIR, old_name: str = "") -> Path:
"""Ghi một luồng xuống đĩa.
Đổi tên thì XOÁ file cũ trước — tên file suy từ tên luồng, không xoá sẽ để
lại một bản sao dưới tên cũ.
"""
directory.mkdir(parents=True, exist_ok=True)
if old_name and old_name != flow.name:
delete_flow(old_name, directory)
@@ -163,6 +181,7 @@ def save_flow(flow: Flow, directory: Path = FLOWS_DIR, old_name: str = "") -> Pa
def delete_flow(name: str, directory: Path = FLOWS_DIR) -> None:
"""Xoá file luồng theo tên; không có thì bỏ qua."""
path = directory / f"{_slug(name)}.json"
if path.exists():
try:
@@ -269,19 +288,23 @@ class FlowRunner:
@property
def step_index(self) -> int:
"""Chỉ số bước đang chạy."""
return self._index
def current_step(self) -> Optional[FlowStep]:
"""Bước đang chạy; ``None`` khi đã hết bước."""
if 0 <= self._index < len(self.flow.steps):
return self.flow.steps[self._index]
return None
def start(self) -> FlowAction:
"""Bắt đầu chạy luồng và trả về hành động đầu tiên cần thực hiện."""
if self.current_step() is None:
return FlowAction(kind="done")
return self._step_action()
def _step_action(self) -> FlowAction:
"""Hành động cho bước hiện tại: chạy một agent, hay chia ra nhiều agent song song."""
step = self.current_step()
self._phase = "step"
if step.is_parallel:
@@ -326,6 +349,7 @@ class FlowRunner:
return self._advance(compact=compact)
def _advance(self, compact: bool) -> FlowAction:
"""Sang bước kế tiếp; hết bước thì báo luồng đã xong."""
self._index += 1
if self.current_step() is None:
return FlowAction(kind="done", compact=compact)
+18
View File
@@ -28,6 +28,12 @@ class GraphServer:
"""Lazy singleton-per-instance localhost server for the D3 graph page."""
def __init__(self) -> None:
"""Chuẩn bị máy chủ; chưa mở cổng nào.
Một token ngẫu nhiên được sinh ngay lúc này và mọi yêu cầu đều phải mang
nó: máy chủ nghe trên localhost, nhưng mọi tiến trình khác trên cùng máy đều
gọi được localhost.
"""
self._html = _PLACEHOLDER
self._token = secrets.token_urlsafe(16)
self._lock = threading.Lock()
@@ -37,6 +43,7 @@ class GraphServer:
# ---- content / callbacks ----------------------------------------
def set_html(self, html: str) -> None:
"""Đặt nội dung HTML sẽ phục vụ; có khoá vì luồng nền ghi còn luồng HTTP đọc."""
with self._lock:
self._html = html
@@ -47,10 +54,12 @@ class GraphServer:
# ---- lifecycle ----------------------------------------------------
@property
def running(self) -> bool:
"""Máy chủ có đang chạy không."""
return self._httpd is not None
@property
def url(self) -> str:
"""URL đầy đủ kèm token; '' nếu chưa chạy."""
if self._httpd is None:
return ""
port = self._httpd.server_address[1]
@@ -63,14 +72,22 @@ class GraphServer:
server = self
class Handler(BaseHTTPRequestHandler):
"""Handler HTTP: chỉ phục vụ đúng trang đồ thị, và chỉ khi token khớp."""
def log_message(self, *_a) -> None: # keep the GUI console silent
"""Tắt log của thư viện chuẩn — nếu không, console GUI bị ngập request."""
pass
def _authorized(self, query: dict) -> bool:
"""Kiểm token trong query, so sánh theo kiểu chống dò thời gian.
Máy chủ này nghe trên localhost nhưng vẫn cần token: mọi tiến trình khác
trên cùng máy đều gọi được nó.
"""
supplied = (query.get("t") or [""])[0]
return secrets.compare_digest(supplied, server._token)
def do_GET(self) -> None: # noqa: N802 - stdlib naming
"""Trả trang đồ thị khi token đúng; sai token thì trả 403."""
parsed = urlparse(self.path)
query = parse_qs(parsed.query)
if not self._authorized(query):
@@ -108,6 +125,7 @@ class GraphServer:
return self.url
def stop(self) -> None:
"""Dừng máy chủ và giải phóng cổng."""
httpd, self._httpd = self._httpd, None
if httpd is not None:
httpd.shutdown()
+7
View File
@@ -15,6 +15,7 @@ from typing import List, Optional
@dataclass
class Group:
"""Một nhóm người dùng: id, tên, và tài khoản quản trị nhóm."""
group_id: str
name: str
subadmin_username: str = ""
@@ -23,16 +24,19 @@ class Group:
def groups_dir(shared_dir: str) -> Path:
"""Thư mục chứa nhóm, nằm trong thư mục chia sẻ của đội."""
return Path(shared_dir).expanduser() / "groups"
def new_group(name: str, subadmin_username: str = "") -> Group:
"""Tạo một nhóm mới với id ngẫu nhiên và mốc thời gian tạo."""
return Group(group_id=uuid.uuid4().hex, name=name.strip() or "Group",
subadmin_username=subadmin_username,
created=datetime.now().isoformat(timespec="seconds"))
def save_group(group: Group, directory: Path) -> Path:
"""Ghi một nhóm ra ``<group_id>.json``."""
directory.mkdir(parents=True, exist_ok=True)
path = directory / f"{group.group_id}.json"
path.write_text(json.dumps(asdict(group), ensure_ascii=False, indent=2), encoding="utf-8")
@@ -40,6 +44,7 @@ def save_group(group: Group, directory: Path) -> Path:
def load_group(group_id: str, directory: Path) -> Optional[Group]:
"""Đọc một nhóm theo id; id được làm sạch trước để không thoát khỏi thư mục."""
safe_id = re.sub(r"[^\w\-]", "", group_id or "")
path = directory / f"{safe_id}.json"
if not path.exists():
@@ -53,6 +58,7 @@ def load_group(group_id: str, directory: Path) -> Optional[Group]:
def list_groups(directory: Path) -> List[Group]:
"""Liệt kê mọi nhóm trong thư mục; thư mục chưa có thì trả list rỗng."""
if not directory.exists():
return []
out: List[Group] = []
@@ -65,6 +71,7 @@ def list_groups(directory: Path) -> List[Group]:
def delete_group(group_id: str, directory: Path) -> bool:
"""Xoá file nhóm; id rỗng hoặc không có file thì trả ``False``."""
safe_id = re.sub(r"[^\w\-]", "", group_id or "")
if not safe_id:
return False
+30 -6
View File
@@ -16,14 +16,17 @@ from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List
from ..config import HISTORY_DIR
def new_session_id() -> str:
"""Id phiên mới theo mốc thời gian, chính xác tới mili giây."""
return datetime.now().strftime("%Y%m%d-%H%M%S-%f")[:-3]
def derive_title(messages: List[Dict[str, Any]]) -> str:
"""Suy tiêu đề hội thoại từ tin nhắn đầu tiên của người dùng.
Dùng khi người dùng chưa tự đặt tên — cắt gọn cho vừa một dòng danh sách.
"""
for m in messages:
if m.get("role") == "user" and m.get("content"):
text = " ".join(m["content"].split())
@@ -42,6 +45,12 @@ def save_conversation(
outputs: List[str] | None = None,
project_id: str = "",
) -> Path:
"""Ghi một hội thoại xuống ``<kind>__<session_id>.json``.
Ghi nguyên tử (R06-T02). Cờ ghim và project_id của lần lưu trước được GIỮ
LẠI: hàm này bị gọi tự động sau mỗi lượt chat, ghi đè chúng sẽ âm thầm bỏ
ghim và đẩy hội thoại ra khỏi project của nó.
"""
directory.mkdir(parents=True, exist_ok=True)
path = directory / f"{kind}__{session_id}.json"
pinned = False # preserve pin flag + project across autosaves
@@ -66,11 +75,14 @@ def save_conversation(
"outputs": list(outputs or []),
"messages": messages,
}
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
# R06-T02: atomic write - see infrastructure/persistence/json/atomic_write.py.
from ..infrastructure.persistence.json.atomic_write import write_json
write_json(path, payload)
return path
def delete_conversation(path) -> None:
"""Xoá file hội thoại; không có thì bỏ qua."""
try:
Path(path).unlink()
except OSError:
@@ -78,18 +90,27 @@ def delete_conversation(path) -> None:
def rename_conversation(path, new_title: str) -> None:
"""Đổi tiêu đề một hội thoại và ghi lại (nguyên tử)."""
from ..infrastructure.persistence.json.atomic_write import write_json
data = load_conversation(path)
data["title"] = new_title
Path(path).write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
write_json(Path(path), data)
def set_pinned(path, pinned: bool) -> None:
"""Ghim/bỏ ghim một hội thoại để nó nằm trên đầu danh sách."""
from ..infrastructure.persistence.json.atomic_write import write_json
data = load_conversation(path)
data["pinned"] = bool(pinned)
Path(path).write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
write_json(Path(path), data)
def load_conversation(path: Path) -> Dict[str, Any]:
"""Đọc một hội thoại; file hỏng hoặc không đọc được thì trả về dict rỗng thay
vì ném lỗi — một file hỏng không được phép làm chết cả danh sách lịch sử.
"""
try:
data = json.loads(Path(path).read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
@@ -111,13 +132,16 @@ def _matches_query(query: str, title: str, messages: List[Dict[str, Any]]) -> bo
return False
def list_conversations(directory: Path = HISTORY_DIR, query: str = "") -> List[Dict[str, Any]]:
def list_conversations(directory: Optional[Path] = None, query: str = "") -> List[Dict[str, Any]]:
"""List saved conversations, most recent first (pinned always on top).
``query`` (from the sidebar's search box), when non-empty, keeps only
conversations whose title OR any message's content contains it
(case-insensitive) — since every file is already parsed to build the
metadata below, this search costs no extra I/O over listing alone."""
if directory is None:
from ..config import HISTORY_DIR
directory = HISTORY_DIR
if not directory or not directory.exists():
return []
q = (query or "").strip().lower()
+5
View File
@@ -44,6 +44,11 @@ def _package_calendar(country: str, year: int):
def is_holiday(d: date, country: Optional[str]) -> bool:
"""Ngày này có phải ngày nghỉ của một quốc gia không.
Không đặt quốc gia thì luôn trả ``False`` — không suy đoán lịch nghỉ thay
người dùng.
"""
country = (country or "").strip().upper()
if not country:
return False
+4
View File
@@ -28,6 +28,10 @@ _IMAGE_MODEL_MARKERS = (
def looks_like_image_model(name: str) -> bool:
"""Đoán một model có sinh ảnh được không, dựa trên dấu hiệu trong tên.
Đoán theo tên vì không provider nào khai báo năng lực này qua API.
"""
n = (name or "").lower()
return any(m in n for m in _IMAGE_MODEL_MARKERS)
+4
View File
@@ -20,11 +20,13 @@ _KEY_RE = re.compile(r"\b([A-Z][A-Z0-9]+-\d+)\b")
def _conf(config: Dict[str, Any] | None) -> Dict[str, str]:
"""Ba trường cấu hình Jira đã cắt khoảng trắng: base_url, email, api_token."""
return {k: str((config or {}).get(k, "") or "").strip()
for k in ("base_url", "email", "api_token")}
def configured(config: Dict[str, Any] | None) -> bool:
"""Đã cấu hình đủ ba trường để gọi Jira chưa."""
c = _conf(config)
return bool(c["base_url"] and c["email"] and c["api_token"])
@@ -82,6 +84,7 @@ def get_issue_by_url(config: Dict[str, Any] | None, url: str) -> str:
def _get(config: Dict[str, Any], path: str, params: dict = None):
"""Gọi Jira REST API bằng xác thực cơ bản, qua lớp TLS có ghim chứng chỉ nội bộ."""
from . import tls_trust
c = _conf(config)
@@ -97,6 +100,7 @@ def _get(config: Dict[str, Any], path: str, params: dict = None):
def _fmt_issue(it: dict) -> str:
"""Một dòng tóm tắt issue: mã, trạng thái và tiêu đề."""
f = it.get("fields", {}) or {}
status = (f.get("status") or {}).get("name", "?")
assignee = (f.get("assignee") or {}).get("displayName", "unassigned")
+7
View File
@@ -48,6 +48,9 @@ _DOC_SUFFIXES = {".pdf", ".doc", ".docx", ".docm", ".xls", ".xlsx", ".xlsm",
def _html_to_text(html: str) -> str:
"""Rút văn bản đọc được từ HTML: bỏ script/style, đổi thẻ thành xuống dòng rồi
gộp khoảng trắng thừa.
"""
text = _SCRIPT_STYLE_RE.sub(" ", html)
text = _TAG_RE.sub("\n", text)
text = _WS_RE.sub(" ", text)
@@ -81,6 +84,10 @@ _ONEDRIVE_HOSTS = {"1drv.ms", "onedrive.live.com"}
def _is_share_link(url: str) -> bool:
"""Link này có phải link chia sẻ SharePoint/OneDrive không.
Loại link đó cần đi qua đường xác thực MS365 thay vì tải HTTP thường.
"""
host = (urlparse(url).hostname or "").lower()
return bool(_SHAREPOINT_HOST_RE.search(host)) or host in _ONEDRIVE_HOSTS
+25
View File
@@ -27,6 +27,7 @@ _SEP = "__"
class McpServerError(RuntimeError):
"""Lỗi khi nối hoặc gọi một MCP server."""
pass
@@ -35,6 +36,9 @@ class McpServerConnection:
def __init__(self, name: str, command: str, args: Optional[List[str]] = None,
env: Optional[Dict[str, str]] = None):
"""Ghi nhận cách khởi động một máy chủ MCP; chưa chạy tiến trình nào cho tới
lần dùng đầu tiên.
"""
self.name = name
self.command = command
self.args = list(args or [])
@@ -59,6 +63,7 @@ class McpServerConnection:
raise McpServerError(f"MCP server '{self.name}' failed to start: {self._start_error}")
def _run_loop(self) -> None:
"""Thân luồng nền: dựng vòng lặp asyncio riêng và giữ nó chạy."""
loop = asyncio.new_event_loop()
self._loop = loop
asyncio.set_event_loop(loop)
@@ -79,6 +84,7 @@ class McpServerConnection:
loop.close()
async def _connect(self) -> None:
"""Khởi động tiến trình con và bắt tay phiên MCP."""
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
@@ -93,6 +99,10 @@ class McpServerConnection:
self._session = session
async def _aclose(self) -> None:
"""Đóng các context đã mở theo THỨ TỰ NGƯỢC.
Đóng xuôi sẽ đóng transport trước phiên và treo ở bước dọn dẹp.
"""
for cm in reversed(self._cm_stack):
try:
await cm.__aexit__(None, None, None)
@@ -101,11 +111,19 @@ class McpServerConnection:
self._cm_stack.clear()
def stop(self) -> None:
"""Dừng kết nối: tắt vòng lặp asyncio và chờ luồng nền kết thúc."""
if self._loop is not None and self._loop.is_running():
self._loop.call_soon_threadsafe(self._loop.stop)
if self._thread is not None:
self._thread.join(timeout=5)
def is_alive(self) -> bool:
"""True while the connection's background thread (and therefore its
event loop and subprocess) is still running — used by
``infrastructure/mcp/mcp_source_manager.py`` (R05-T05) to tell a live
cached connection from one whose subprocess already died."""
return self._thread is not None and self._thread.is_alive()
# ---- tools -----------------------------------------------------------
def list_tool_specs(self) -> List[ToolSpec]:
"""The server's tools, wrapped as :class:`ToolSpec` — the same shape
@@ -134,6 +152,10 @@ class McpServerConnection:
return {"ok": ok, "output": output}
def _run_coro(self, coro):
"""Chạy một coroutine trên vòng lặp của kết nối và chờ kết quả.
Đây là cầu nối duy nhất giữa mã đồng bộ của app và phiên MCP bất đồng bộ.
"""
if self._loop is None:
raise McpServerError(f"MCP server '{self.name}' is not connected")
future = asyncio.run_coroutine_threadsafe(coro, self._loop)
@@ -159,6 +181,9 @@ def build_mcp_tools(servers: List[McpServerConnection]) -> Tuple[List[ToolSpec],
return [], None
def executor(name: str, args: Dict[str, Any]) -> Dict[str, Any]:
"""Bộ thực thi cho tool MCP: định tuyến theo tên về đúng server và ghi nhật ký
kiểm toán cho mỗi lần gọi.
"""
from . import audit_log
server = routing.get(name)
+27 -3
View File
@@ -32,6 +32,14 @@ _SYMBOL_CCY = {"₫": "VND", "vnd": "VND", "đ": "VND",
_DEFAULT_UNIT = "Million tokens"
# Flat USD/1M-token fallback rates used by turn_cost_usd() when a model isn't
# in the price table. Owned here (not usage_tracker.DEFAULT_PRICING) so this
# module never needs to import usage_tracker — usage_tracker imports this
# module instead, keeping the dependency one-directional. Values match
# usage_tracker.DEFAULT_PRICING's price_per_mtok_in_usd/out_usd exactly.
_FALLBACK_RATE_IN_USD = 0.5
_FALLBACK_RATE_OUT_USD = 1.5
# ---- currency ------------------------------------------------------------
def _rates(config) -> Dict[str, float]:
@@ -57,6 +65,9 @@ _DIGITS = {"VND": 0, "JPY": 1, "USD": 4}
def format_price(amount: float, ccy: str) -> str:
"""Định dạng số tiền kèm ký hiệu tiền tệ, số chữ số thập phân theo từng loại
tiền (VND 0, JPY 1, USD 4).
"""
ccy = (ccy or "USD").upper()
return f"{amount:,.{_DIGITS.get(ccy, 2)}f} {_SYMBOLS.get(ccy, '')}".strip()
@@ -91,14 +102,21 @@ def parse_price(text: Any) -> tuple:
# ---- store ---------------------------------------------------------------
def _bucket(config) -> Dict[str, Any]:
"""Nhóm cấu hình ``model_pricing``; tự tạo nếu chưa có."""
return config.data.setdefault("model_pricing", {})
def list_entries(config) -> List[Dict[str, Any]]:
"""Danh sách dòng đơn giá đã lưu (bản sao, sửa không ảnh hưởng cấu hình)."""
return list(_bucket(config).get("entries", []) or [])
def save_entries(config, entries: List[Dict[str, Any]]) -> None:
"""Ghi lại toàn bộ bảng đơn giá và đồng bộ sang bộ tính chi phí.
Đồng bộ ngay tại đây để Tổng quan và Dashboard không hiện số tiền tính theo
bảng giá cũ.
"""
_bucket(config)["entries"] = [dict(e) for e in entries]
sync_to_usage(config) # keep the cost engine (Overview + Dashboard) in sync
@@ -107,6 +125,7 @@ def _norm_entry(model: str, ctx_len: str = "", max_out: str = "",
in_price=0.0, in_ccy: Optional[str] = None, in_unit: str = _DEFAULT_UNIT,
out_price=0.0, out_ccy: Optional[str] = None, out_unit: str = _DEFAULT_UNIT,
default_ccy: str = "USD") -> Dict[str, Any]:
"""Chuẩn hoá một dòng đơn giá về đúng khuôn lưu trữ, điền mặc định cho ô trống."""
return {
"model": str(model).strip(),
"context_length": str(ctx_len).strip(),
@@ -138,9 +157,11 @@ def turn_cost_usd(model: str, in_tok: int, out_tok: int, config) -> float:
switches models (a different model → its own row / rates)."""
rates = usd_rates_for(model, config)
if rates is None:
from . import usage_tracker as ut
p = {**ut.DEFAULT_PRICING, **((getattr(config, "data", {}) or {}).get("usage") or {})}
rates = {"in": float(p["price_per_mtok_in_usd"]), "out": float(p["price_per_mtok_out_usd"])}
usage = (getattr(config, "data", {}) or {}).get("usage") or {}
rates = {
"in": float(usage.get("price_per_mtok_in_usd", _FALLBACK_RATE_IN_USD)),
"out": float(usage.get("price_per_mtok_out_usd", _FALLBACK_RATE_OUT_USD)),
}
return (in_tok or 0) / 1e6 * rates["in"] + (out_tok or 0) / 1e6 * rates["out"]
@@ -179,6 +200,7 @@ def format_tokens(n: int) -> str:
def add_entry(config, entry: Dict[str, Any]) -> None:
"""Thêm một dòng đơn giá; đã có model đó thì THAY THẾ chứ không thêm trùng."""
entries = list_entries(config)
entries = [e for e in entries if e.get("model") != entry.get("model")] # replace same model
entries.append(entry)
@@ -243,6 +265,7 @@ def import_table(path: str | Path, default_ccy: str = "USD") -> List[Dict[str, A
def _rows_from_xlsx(path: Path) -> List[List[Any]]:
"""Đọc các dòng từ file Excel (lấy giá trị đã tính, không lấy công thức)."""
from openpyxl import load_workbook
try:
wb = load_workbook(str(path), data_only=True)
@@ -253,6 +276,7 @@ def _rows_from_xlsx(path: Path) -> List[List[Any]]:
def _rows_from_csv(path: Path) -> List[List[Any]]:
"""Đọc các dòng từ file CSV, chấp nhận BOM của Excel."""
try:
text = path.read_text(encoding="utf-8-sig")
except OSError as exc:
+10
View File
@@ -57,10 +57,12 @@ SCOPES: List[str] = [
class Ms365AuthError(Exception):
"""Lỗi khi đăng nhập hoặc lấy token Microsoft 365."""
pass
def _load_cache():
"""Nạp kho token đã lưu từ đĩa (nếu có)."""
import msal
cache = msal.SerializableTokenCache()
@@ -84,6 +86,7 @@ def _load_cache():
def _save_cache(cache) -> None:
"""Ghi kho token xuống đĩa, chỉ khi nó thật sự thay đổi."""
if not cache.has_state_changed:
return
serialized = cache.serialize()
@@ -105,6 +108,9 @@ def _save_cache(cache) -> None:
def _app(tenant_id: str, client_id: str):
"""Dựng ứng dụng MSAL cho tenant/client đã cấu hình; thiếu ``msal`` thì báo lỗi
kèm hướng dẫn cài.
"""
try:
import msal
except ImportError as exc:
@@ -192,6 +198,7 @@ def get_access_token(tenant_id: str, client_id: str) -> str:
# The UI calls these with no args for the "connect like Claude" flow; they read
# the optional config overrides so a custom Azure app still works.
def _ids(config=None):
"""Cặp (tenant_id, client_id) đọc từ cấu hình MS365."""
ms365 = (config.ms365 if config is not None else {}) or {}
return ms365.get("tenant_id", ""), ms365.get("client_id", "")
@@ -203,6 +210,7 @@ def current_identity(config=None) -> str:
def is_signed_in(config=None) -> bool:
"""Đã có tài khoản MS365 đăng nhập sẵn hay chưa."""
return signed_in_account(*_ids(config)) is not None
@@ -213,10 +221,12 @@ def sign_in(on_code: Callable[[dict], None], config=None) -> dict:
def sign_out_default(config=None) -> None:
"""Đăng xuất tài khoản MS365 theo cấu hình hiện tại."""
sign_out(*_ids(config))
def sign_out(tenant_id: str, client_id: str) -> None:
"""Đăng xuất và xoá token của một tenant/client khỏi kho."""
try:
app, cache = _app(tenant_id, client_id)
for acc in app.get_accounts():
+24
View File
@@ -22,14 +22,17 @@ TIMEOUT = 30
class Ms365GraphError(Exception):
"""Lỗi khi gọi Microsoft Graph API."""
pass
class TeamsLinkError(Exception):
"""Link Teams không phân giải được thành team/channel/chat hợp lệ."""
pass
def _headers(token: str, extra: Optional[dict] = None) -> Dict[str, str]:
"""Header cho một lượt gọi Graph: Bearer token cộng phần thêm (nếu có)."""
h = {"Authorization": f"Bearer {token}"}
if extra:
h.update(extra)
@@ -37,6 +40,9 @@ def _headers(token: str, extra: Optional[dict] = None) -> Dict[str, str]:
def _request(method: str, url: str, token: str, **kwargs) -> requests.Response:
"""Gọi Graph API, tự ghép ``GRAPH_BASE`` cho đường dẫn tương đối và đổi lỗi HTTP
thành :class:`Ms365GraphError` kèm thông điệp đọc được.
"""
if not url.startswith("http"):
url = f"{GRAPH_BASE}{url}"
headers = _headers(token, kwargs.pop("headers", None))
@@ -73,6 +79,7 @@ def _path_segment(path: str) -> str:
# ---- Outlook ---------------------------------------------------------------
def list_mail(token: str, top: int = 10, folder: str = "inbox") -> List[dict]:
"""Danh sách thư trong một thư mục hộp thư (mặc định Inbox)."""
resp = _request("GET", f"/me/mailFolders/{quote(folder)}/messages"
f"?$top={int(top)}&$select=subject,from,receivedDateTime,bodyPreview,webLink",
token)
@@ -80,6 +87,7 @@ def list_mail(token: str, top: int = 10, folder: str = "inbox") -> List[dict]:
def send_mail(token: str, to: str, subject: str, body: str) -> None:
"""Gửi một email qua tài khoản đang đăng nhập."""
payload = {
"message": {
"subject": subject,
@@ -91,6 +99,7 @@ def send_mail(token: str, to: str, subject: str, body: str) -> None:
def list_calendar_events(token: str, top: int = 10) -> List[dict]:
"""Danh sách sự kiện lịch sắp tới, xếp theo thời gian bắt đầu."""
resp = _request("GET", f"/me/events?$top={int(top)}"
"&$select=subject,start,end,organizer,location&$orderby=start/dateTime",
token)
@@ -99,38 +108,45 @@ def list_calendar_events(token: str, top: int = 10) -> List[dict]:
# ---- Teams ------------------------------------------------------------------
def list_teams(token: str) -> List[dict]:
"""Các team mà người dùng đang tham gia."""
resp = _request("GET", "/me/joinedTeams", token)
return resp.json().get("value", [])
def list_channels(token: str, team_id: str) -> List[dict]:
"""Các kênh trong một team."""
resp = _request("GET", f"/teams/{quote(team_id)}/channels", token)
return resp.json().get("value", [])
def list_channel_messages(token: str, team_id: str, channel_id: str, top: int = 20) -> List[dict]:
"""Tin nhắn gần đây trong một kênh."""
resp = _request("GET", f"/teams/{quote(team_id)}/channels/{quote(channel_id)}/messages"
f"?$top={int(top)}", token)
return resp.json().get("value", [])
def send_channel_message(token: str, team_id: str, channel_id: str, text: str) -> None:
"""Gửi tin nhắn vào một kênh Teams."""
payload = {"body": {"content": text}}
_request("POST", f"/teams/{quote(team_id)}/channels/{quote(channel_id)}/messages", token,
json=payload)
def get_channel(token: str, team_id: str, channel_id: str) -> dict:
"""Thông tin một kênh Teams."""
resp = _request("GET", f"/teams/{quote(team_id)}/channels/{quote(channel_id)}", token)
return resp.json()
def get_chat(token: str, chat_id: str) -> dict:
"""Thông tin một cuộc trò chuyện Teams."""
resp = _request("GET", f"/chats/{quote(chat_id)}", token)
return resp.json()
def send_chat_message(token: str, chat_id: str, text: str) -> None:
"""Gửi tin nhắn vào một cuộc trò chuyện Teams."""
_request("POST", f"/chats/{quote(chat_id)}/messages", token, json={"body": {"content": text}})
@@ -160,17 +176,20 @@ def parse_teams_link(url: str) -> Dict[str, str]:
# ---- OneDrive -----------------------------------------------------------
def list_onedrive_files(token: str, path: str = "") -> List[dict]:
"""Liệt kê tệp/thư mục trong OneDrive; ``path`` rỗng là thư mục gốc."""
url = "/me/drive/root/children" if not path else f"/me/drive/root:/{_path_segment(path)}:/children"
resp = _request("GET", url, token)
return resp.json().get("value", [])
def read_onedrive_file(token: str, path: str, max_chars: int = 50_000) -> str:
"""Đọc nội dung một tệp OneDrive dưới dạng văn bản, cắt ở ``max_chars``."""
resp = _request("GET", f"/me/drive/root:/{_path_segment(path)}:/content", token)
return resp.content.decode("utf-8", errors="replace")[:max_chars]
def write_onedrive_file(token: str, path: str, content: str) -> dict:
"""Ghi nội dung văn bản vào một tệp OneDrive (tạo mới hoặc ghi đè)."""
resp = _request("PUT", f"/me/drive/root:/{_path_segment(path)}:/content", token,
data=content.encode("utf-8"),
headers={"Content-Type": "text/plain"})
@@ -197,11 +216,13 @@ def read_shared_file(token: str, share_url: str, max_chars: int = 50_000) -> str
# ---- SharePoint --------------------------------------------------------
def list_sharepoint_sites(token: str, query: str) -> List[dict]:
"""Tìm site SharePoint theo từ khoá."""
resp = _request("GET", f"/sites?search={quote(query)}", token)
return resp.json().get("value", [])
def list_sharepoint_files(token: str, site_id: str, path: str = "") -> List[dict]:
"""Liệt kê tệp/thư mục trong thư viện tài liệu của một site SharePoint."""
url = (f"/sites/{quote(site_id)}/drive/root/children" if not path
else f"/sites/{quote(site_id)}/drive/root:/{_path_segment(path)}:/children")
resp = _request("GET", url, token)
@@ -210,18 +231,21 @@ def list_sharepoint_files(token: str, site_id: str, path: str = "") -> List[dict
# ---- Teams meeting transcripts ------------------------------------------
def find_online_meeting(token: str, join_url: str) -> List[dict]:
"""Tìm cuộc họp online theo link tham gia."""
resp = _request("GET", f"/me/onlineMeetings?$filter=JoinWebUrl eq '{quote(join_url, safe='')}'",
token)
return resp.json().get("value", [])
def list_meeting_transcripts(token: str, meeting_id: str) -> List[dict]:
"""Danh sách bản ghi lời thoại của một cuộc họp."""
resp = _request("GET", f"/me/onlineMeetings/{quote(meeting_id)}/transcripts", token)
return resp.json().get("value", [])
def get_meeting_transcript_content(token: str, meeting_id: str, transcript_id: str,
max_chars: int = 50_000) -> str:
"""Nội dung một bản ghi lời thoại, cắt ở ``max_chars``."""
resp = _request(
"GET",
f"/me/onlineMeetings/{quote(meeting_id)}/transcripts/{quote(transcript_id)}/content"
+8
View File
@@ -28,10 +28,12 @@ _PREFIX = "ms365_local"
def _roots() -> List[Path]:
"""Mọi thư mục OneDrive tìm thấy trên máy."""
return paths.detect_onedrive_roots()
def _primary_root() -> Optional[Path]:
"""Thư mục OneDrive chính; ``None`` nếu không có."""
return paths.primary_onedrive_root()
@@ -45,6 +47,7 @@ def _resolve_under(root: Path, rel: str) -> Path:
def _list_dir(base: Path, rel: str) -> dict:
"""Liệt kê nội dung một thư mục con của OneDrive, chặn thoát ra ngoài gốc."""
target = _resolve_under(base, rel)
if not target.exists():
raise FileNotFoundError(f"Not found: {rel or '.'}")
@@ -60,6 +63,7 @@ def _list_dir(base: Path, rel: str) -> dict:
def _read_file(base: Path, rel: str) -> str:
"""Đọc một tệp trong OneDrive dưới dạng văn bản, chặn thoát ra ngoài gốc."""
target = _resolve_under(base, rel)
if not target.is_file():
raise FileNotFoundError(f"Not a file: {rel}")
@@ -68,6 +72,7 @@ def _read_file(base: Path, rel: str) -> str:
def _write_file(base: Path, rel: str, content: str) -> dict:
"""Ghi một tệp trong OneDrive, tự tạo thư mục cha, chặn thoát ra ngoài gốc."""
target = _resolve_under(base, rel)
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content or "", encoding="utf-8")
@@ -125,6 +130,9 @@ def build_ms365_local_tools(config) -> Tuple[List[ToolSpec], Optional[Callable[[
]
def executor(name: str, args: dict) -> dict:
"""Bộ thực thi tool MS365 cục bộ (đọc/ghi thẳng thư mục OneDrive đồng bộ trên
máy, không cần đăng nhập Graph), ghi nhật ký kiểm toán cho mỗi lần gọi.
"""
ok = False
detail = ""
try:
+6
View File
@@ -168,6 +168,9 @@ _MAX_OUTPUT_CHARS = 20_000
def _dump(data: Any) -> str:
"""Kết quả tool dưới dạng JSON đã cắt ở ``_MAX_OUTPUT_CHARS`` — một hộp thư đầy
sẽ nuốt trọn cửa sổ ngữ cảnh nếu trả về nguyên vẹn.
"""
text = json.dumps(data, ensure_ascii=False, indent=2, default=str)
if len(text) > _MAX_OUTPUT_CHARS:
text = text[:_MAX_OUTPUT_CHARS] + f"\n…(truncated to {_MAX_OUTPUT_CHARS} chars)…"
@@ -197,6 +200,9 @@ def build_ms365_tools(config) -> Tuple[List[ToolSpec], Optional[Callable[[str, d
return [], None
def executor(name: str, args: dict) -> dict:
"""Bộ thực thi các tool MS365 (mail, lịch, Teams, OneDrive, SharePoint), gói lỗi
thành kết quả thay vì ném ra.
"""
args = args or {}
try:
token = get_access_token(tenant_id, client_id)
+13
View File
@@ -13,8 +13,19 @@ RequestFn = Callable[[Dict[str, Any]], None]
class PermissionGate:
"""Cổng phê duyệt tool: chặn lượt chạy lại và chờ người dùng đồng ý.
Ba chế độ: 'auto' cho qua hết, 'confirm' hỏi trước mỗi lệnh có rủi ro, và
'deny' chặn thẳng. Dùng ``threading.Event`` để luồng nền đứng chờ trong khi
luồng giao diện hiện hộp thoại.
"""
def __init__(self, mode: str = "confirm", on_request: Optional[RequestFn] = None,
agent_role: str = ""):
"""``mode`` quyết định cách xử: hỏi, cho qua hết, hay chặn hết.
``on_request`` là hàm hiện hộp thoại; để None (không có giao diện) thì cổng
rơi về quyết định mặc định của ``mode`` thay vì treo mãi.
"""
self.mode = mode
self.on_request = on_request
self.agent_role = agent_role
@@ -22,6 +33,7 @@ class PermissionGate:
self._approved = False
def set_mode(self, mode: str) -> None:
"""Đổi chế độ phê duyệt giữa chừng."""
self.mode = mode
def request(self, action: Dict[str, Any]) -> bool:
@@ -43,6 +55,7 @@ class PermissionGate:
return self._approved
def resolve(self, approved: bool) -> None:
"""Người dùng đã trả lời: ghi kết quả và đánh thức luồng đang chờ."""
self._approved = approved
self._event.set()
+10
View File
@@ -37,6 +37,7 @@ _KEEP_PREFIX = "(" # image values like "(keep …)" mean "don't change"
def is_available() -> bool:
"""Máy đã cài ``python-pptx`` chưa — không có thì mọi tính năng PowerPoint tắt."""
try:
import pptx # noqa: F401
return True
@@ -45,10 +46,12 @@ def is_available() -> bool:
def _in(emu) -> float:
"""Đổi đơn vị EMU của Office sang inch, làm tròn 2 chữ số."""
return round((emu or 0) / _EMU_PER_IN, 2)
def _kind(shape) -> str:
"""Loại hình khối trong slide: ảnh, bảng, biểu đồ hay hộp văn bản."""
from pptx.enum.shapes import MSO_SHAPE_TYPE
try:
if shape.shape_type == MSO_SHAPE_TYPE.PICTURE:
@@ -182,6 +185,11 @@ def _apply_font(shape, spec: str) -> bool:
def _parse(text: str) -> Dict[Tuple[int, int], dict]:
"""Đọc dạng văn bản đánh dấu của slide trở lại thành cấu trúc.
Đây là khuôn trung gian giữa PowerPoint và ô soạn thảo: người dùng (và AI)
sửa văn bản, hàm này dựng lại thành thao tác trên deck.
"""
blocks: Dict[Tuple[int, int], dict] = {}
cur: Tuple[int, int] | None = None
fields: dict = {}
@@ -189,6 +197,7 @@ def _parse(text: str) -> Dict[Tuple[int, int], dict]:
textbuf: List[str] = []
def _flush():
"""Chốt khối đang đọc dở và đưa vào kết quả."""
if cur is not None:
if in_text:
fields["text"] = "\n".join(textbuf).strip("\n")
@@ -221,6 +230,7 @@ def _parse(text: str) -> Dict[Tuple[int, int], dict]:
def _pair(val: str):
"""Đọc chuỗi 'a, b' thành cặp số (dùng cho toạ độ và kích thước)."""
try:
a, b = (x.strip() for x in val.split(",", 1))
return float(a), float(b)
+12 -3
View File
@@ -44,6 +44,11 @@ STARTER_PROJECT_NAME = "My Workspace"
@dataclass
class Project:
"""Một project: id, tên, mô tả, chỉ dẫn chung và thư mục sandbox.
Chỉ dẫn chung được chèn vào MỌI lượt chat thuộc project, nên đây là chỗ đặt
bối cảnh dùng lại thay vì gõ lại ở từng tin nhắn.
"""
project_id: str
name: str
description: str = ""
@@ -92,6 +97,7 @@ def ensure_starter_project(directory: Path = None) -> Project:
def _slugify(name: str) -> str:
"""Định danh an toàn cho tên file, suy từ tên project."""
s = "".join(c if (c.isalnum() or c in "-_") else "-" for c in name.strip().lower())
s = "-".join(filter(None, s.split("-")))
return s or "project"
@@ -115,11 +121,14 @@ def new_project(name: str, description: str = "", instructions: str = "",
def save_project(project: Project, directory: Path = None) -> Path:
"""Ghi một project ra ``<project_id>.json`` (ghi nguyên tử)."""
directory = directory or PROJECTS_DIR
directory.mkdir(parents=True, exist_ok=True)
path = directory / f"{project.project_id}.json"
path.write_text(json.dumps(asdict(project), ensure_ascii=False, indent=2),
encoding="utf-8")
# R06-T02: atomic write — a crash/kill between truncate and write used to
# leave a half-written project.json that load_project() then silently
# treats as "missing" (see infrastructure/persistence/json/atomic_write.py).
from ..infrastructure.persistence.json.atomic_write import write_json
write_json(path, asdict(project))
return path
+5
View File
@@ -73,6 +73,11 @@ LLMClassifier = Callable[[str], str]
def _heuristic_scores(text: str) -> dict[TaskType, int]:
"""Chấm điểm loại việc bằng từ khoá, không cần gọi model.
Bước lọc rẻ đứng trước bộ phân loại bằng AI: phần lớn câu hỏi phân loại được
ngay tại đây mà không tốn lượt gọi nào.
"""
low = (text or "").lower()
scores: dict[TaskType, int] = {tt: 0 for tt in TaskType}
for tt, entries in _COMPILED.items():
+6
View File
@@ -25,6 +25,7 @@ class CompletionResult:
@property
def ok(self) -> bool:
"""Lượt dò có thành công không (không có lỗi)."""
return self.error is None
@@ -37,6 +38,7 @@ class ProbeClient(Protocol):
model_id: str,
messages: List[Dict[str, Any]],
) -> CompletionResult:
"""Gọi một model và trả về kết quả kèm số token, độ trễ và lỗi (nếu có)."""
...
@@ -59,6 +61,7 @@ class AppProbeClient:
"""
def __init__(self, ctx: Any) -> None:
"""Giữ ``AppContext`` để dựng provider lúc cần thăm dò."""
self.ctx = ctx
def complete(
@@ -67,6 +70,9 @@ class AppProbeClient:
model_id: str,
messages: List[Dict[str, Any]],
) -> CompletionResult:
"""Gọi model qua provider thật; lỗi được gói vào kết quả chứ không ném ra —
một model hỏng không được làm dừng cả lượt chấm điểm danh mục.
"""
try:
prov = self.ctx.build_provider_for(provider, model_id or None)
# Non-streaming: no on_text/on_reasoning callbacks. cancel=None.
+1
View File
@@ -138,6 +138,7 @@ class ModelAssessment(BaseModel):
@property
def key(self) -> str:
"""Khoá định danh của model được chấm điểm (provider + model id)."""
return self.metadata.key
def fit_for(self, task_type: TaskType) -> float:
+3
View File
@@ -126,6 +126,9 @@ def check_and_update(
call_count = {"n": 0}
def _tick() -> None:
"""Một nhịp đếm trong lúc chờ người dùng xác nhận đổi model — đếm lùi và tự
quyết định khi hết giờ.
"""
call_count["n"] += 1
pairs = [(p, m) for (p, m, _tier) in candidates]
+12
View File
@@ -70,6 +70,7 @@ _SCORE_RE = re.compile(r'"score"\s*:\s*([0-9]*\.?[0-9]+)')
def _clamp01(x: float) -> float:
"""Chặn một số về khoảng 0..1."""
return min(1.0, max(0.0, float(x)))
@@ -108,6 +109,10 @@ def make_judge(
"""
def judge(task_type: TaskType, prompt: str, answer: str) -> float:
"""Chấm điểm câu trả lời của một model theo rubric, trả về điểm 0..1.
Cắt câu trả lời ở 4000 ký tự để một lượt chấm không tự nó tràn ngữ cảnh.
"""
rubric = _JUDGE_RUBRIC.format(
task=task_type.value, prompt=prompt, answer=(answer or "")[:4000]
)
@@ -160,11 +165,17 @@ class _PerProviderSemaphores:
"""Lazily-created, per-provider bounded semaphores for rate-limit safety."""
def __init__(self, limit: int) -> None:
"""Giới hạn số lượt thăm dò song song TRÊN MỖI provider.
Đếm riêng từng provider chứ không đếm chung: một provider chậm không được
phép chiếm hết suất của những provider còn lại.
"""
self._limit = max(1, int(limit))
self._sems: Dict[str, threading.Semaphore] = {}
self._lock = threading.Lock()
def get(self, provider: str) -> threading.Semaphore:
"""Semaphore của một provider, tạo lười ở lần dùng đầu."""
with self._lock:
sem = self._sems.get(provider)
if sem is None:
@@ -201,6 +212,7 @@ def probe_candidates(
results_lock = threading.Lock()
def _one(provider: str, model_id: str, task_type: TaskType) -> None:
"""Dò một cặp (provider, model) cho một loại việc, tôn trọng giới hạn song song."""
sem = sems.get(provider)
with sem:
if call_counter is not None:
+10
View File
@@ -33,6 +33,7 @@ class RoutingScheduler(QObject):
reassess_finished = Signal(int) # number of models assessed
def __init__(self, ctx: Any, service: Any, parent: Optional[QObject] = None) -> None:
"""Dựng bộ hẹn giờ chạy thăm dò định kỳ. Chưa chạy cho tới khi gọi ``start()``."""
super().__init__(parent)
self.ctx = ctx
self.service = service
@@ -49,16 +50,19 @@ class RoutingScheduler(QObject):
self._timer.start()
def stop(self) -> None:
"""Dừng hẹn giờ."""
self._timer.stop()
# -- tick ----------------------------------------------------------- #
def _interval_hours(self) -> float:
"""Chu kỳ chấm điểm lại, tính bằng giờ; giá trị lạ thì coi như tắt."""
try:
return float(self.ctx.config.routing.get("reassess_interval_hours", 24) or 0)
except Exception: # noqa: BLE001
return 24.0
def _hours_since_last(self) -> Optional[float]:
"""Số giờ kể từ lần chấm điểm gần nhất; ``None`` nếu chưa chấm lần nào."""
last = self.service.store.last_updated()
if not last:
return None # never assessed
@@ -87,6 +91,11 @@ class RoutingScheduler(QObject):
return False
def is_due(self) -> bool:
"""Đã đến lúc chấm điểm lại chưa.
Tắt định tuyến ở mọi bề mặt thì KHÔNG dò — dò model là lượt gọi có tính phí,
không được tiêu tiền cho một tính năng người dùng đã tắt.
"""
if not self._routing_enabled_anywhere():
return False # routing off everywhere → don't probe (would be wasted cost)
interval = self._interval_hours()
@@ -111,6 +120,7 @@ class RoutingScheduler(QObject):
self.reassess_started.emit()
def _done(result) -> None:
"""Chấm điểm xong: báo ra ngoài số model đã đánh giá."""
self.reassess_finished.emit(len(result or {}))
self.service.reassess_background(on_done=_done)
+7
View File
@@ -27,6 +27,7 @@ class RankedCandidate:
@property
def key(self) -> str:
"""Khoá định danh của ứng viên (provider + model)."""
return self.assessment.key
@@ -40,6 +41,7 @@ class Ranking:
@property
def best(self) -> Optional[RankedCandidate]:
"""Ứng viên đứng đầu; ``None`` nếu không có ứng viên nào."""
return self.ranked[0] if self.ranked else None
def score_of(self, key: str) -> float:
@@ -66,6 +68,7 @@ class Ranking:
def _has_capabilities(assessment: ModelAssessment, required: Set[str]) -> bool:
"""Model này có đủ mọi năng lực mà lượt chạy đòi hỏi không."""
return required.issubset(assessment.metadata.capabilities)
@@ -99,6 +102,10 @@ def rank_models(
scored.append(RankedCandidate(assessment=a, score=score))
def _sort_key(c: RankedCandidate):
"""Khoá sắp xếp ứng viên: điểm cao trước, cùng điểm thì rẻ hơn trước.
Model chưa biết giá bị xếp cuối (coi như vô cùng đắt) chứ không phải miễn phí.
"""
cost = c.assessment.metadata.avg_cost_per_1k
cost = cost if cost is not None else float("inf")
# score desc, then cheaper, then model id for determinism.
+18
View File
@@ -64,6 +64,7 @@ class RouteResult:
@property
def should_switch(self) -> bool:
"""Có nên đổi sang model khác cho lượt này không."""
return self.decision.should_switch
@property
@@ -89,6 +90,9 @@ class RoutingService:
client: Optional[ProbeClient] = None,
clock: Optional[Callable[[], float]] = None,
) -> None:
"""``store``/``client``/``clock`` đều tiêm được: test thay đồng hồ để tua thời
gian mà không phải chờ thật, và thay client để không gọi mạng.
"""
self.ctx = ctx
self.store = store or AssessmentStore()
self._client = client # None → lazily build AppProbeClient(ctx)
@@ -100,6 +104,7 @@ class RoutingService:
# -- config helpers ------------------------------------------------- #
@property
def _routing_cfg(self) -> Dict[str, Any]:
"""Nhóm cấu hình định tuyến hiện tại."""
return self.ctx.config.routing
def get_routing_config(self) -> Dict[str, Any]:
@@ -125,6 +130,7 @@ class RoutingService:
return dict(cfg)
def _policy(self) -> Policy:
"""Chính sách chấm điểm đang chọn; giá trị lạ thì rơi về 'balanced'."""
raw = (self._routing_cfg.get("policy") or "balanced").lower()
try:
return Policy(raw)
@@ -132,6 +138,7 @@ class RoutingService:
return Policy.BALANCED
def _client_or_build(self) -> ProbeClient:
"""Client dò model, dựng lười để chưa bật định tuyến thì không tốn gì."""
if self._client is None:
self._client = AppProbeClient(self.ctx)
return self._client
@@ -160,6 +167,9 @@ class RoutingService:
seen = set()
def _add(provider: str, model_id: str, tier: Optional[str]) -> None:
"""Thêm một ứng viên (provider, model) vào danh sách, bỏ qua mục thiếu thông tin
hoặc trùng.
"""
if not provider or not model_id:
return
key = candidate_key(provider, model_id)
@@ -246,6 +256,11 @@ class RoutingService:
) -> threading.Thread:
"""Run :meth:`reassess` on a daemon thread (non-Qt, headless-safe)."""
def _run() -> None:
"""Chạy nền: chấm điểm lại danh mục model.
Nuốt mọi ngoại lệ có chủ ý — một lần chấm điểm hỏng không được phép làm
chết ứng dụng, vì đây là việc chạy ngầm người dùng không yêu cầu.
"""
try:
result = self.reassess(policy)
except Exception: # noqa: BLE001 — never let a reassess crash the app
@@ -262,10 +277,12 @@ class RoutingService:
return t
def is_reassessing(self) -> bool:
"""Có đang chấm điểm lại danh mục model hay không."""
return self._reassessing
# -- query ---------------------------------------------------------- #
def assessments(self) -> Dict[str, ModelAssessment]:
"""Bảng điểm model đã lưu, đọc từ kho đánh giá."""
return self.store.load()
def status(self) -> Dict[str, Any]:
@@ -347,6 +364,7 @@ class RoutingService:
return self.pending.resolve(request_id, approve, run)
def get_pending(self, request_id: str) -> Optional[PendingSwitch]:
"""Đề nghị đổi model đang chờ người dùng xác nhận; ``None`` nếu không có."""
return self.pending.get(request_id)
def sweep_pending(self) -> List[str]:
+7
View File
@@ -68,6 +68,11 @@ class AssessmentStore:
store_path: Optional[Path] = None,
history_dir: Optional[Path] = None,
) -> None:
"""``store_path`` để None thì dùng file mặc định trong thư mục cấu hình.
Import ``CONFIG_DIR`` muộn ngay trong thân hàm: nạp nó lúc import module sẽ
kéo theo cả cây cấu hình vào mọi test dùng lớp này.
"""
if store_path is None:
from ...config import CONFIG_DIR # lazy: avoids import cost in tests
store_path = CONFIG_DIR / _DEFAULT_STORE_NAME
@@ -107,9 +112,11 @@ class AssessmentStore:
return out
def last_updated(self) -> Optional[str]:
"""Mốc thời gian lần chấm điểm gần nhất; ``None`` nếu chưa chấm lần nào."""
return self.load_raw().get("last_updated")
def policy(self) -> str:
"""Chính sách chấm điểm đã lưu; chưa có thì mặc định 'balanced'."""
return self.load_raw().get("policy") or Policy.BALANCED.value
# -- write ---------------------------------------------------------- #
+3
View File
@@ -141,6 +141,7 @@ class PendingSwitchRegistry:
_RESOLVE_WAIT_SEC = 600.0
def __init__(self, clock: Callable[[], float] = time.time) -> None:
"""``clock`` tiêm được để test kiểm hết hạn mà không phải chờ thật."""
self._items: Dict[str, PendingSwitch] = {}
self._events: Dict[str, threading.Event] = {}
self._running: Set[str] = set()
@@ -180,6 +181,7 @@ class PendingSwitchRegistry:
return ps
def _maybe_expire_locked(self, ps: PendingSwitch) -> None:
"""Đánh dấu hết hạn nếu đã quá hạn chờ. Gọi trong lúc đang giữ khoá."""
if ps.status == SwitchStatus.PENDING and self._clock() >= ps.expires_at:
ps.status = SwitchStatus.EXPIRED
@@ -270,6 +272,7 @@ class PendingSwitchRegistry:
return removed
def pending_ids(self) -> List[str]:
"""Id các đề nghị còn đang chờ (đã loại những cái vừa hết hạn)."""
with self._lock:
return [
rid for rid, ps in self._items.items()
+1
View File
@@ -44,6 +44,7 @@ class SandboxManager:
"""Central sandbox manager that selects and routes to the right backend."""
def __init__(self, config: Optional[ExecutionConfig] = None):
"""Chưa dựng backend nào — chúng được tạo muộn, lúc thật sự cần chạy lệnh."""
self.config = config or ExecutionConfig()
self._backends: Dict[str, Any] = {}
+15
View File
@@ -36,6 +36,7 @@ LIBRARY_DIR = Path(__file__).resolve().parent.parent / "skill_library"
@dataclass
class Skill:
"""Một Skill: tên, mô tả, phần chỉ dẫn chèn vào prompt, và cờ bật/tắt."""
name: str
description: str = ""
instructions: str = ""
@@ -43,12 +44,14 @@ class Skill:
@property
def slug(self) -> str:
"""Định danh an toàn cho tên file, suy từ tên skill."""
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 "skill"
def skills_dir() -> Path:
"""Thư mục chứa skill của người dùng."""
return SKILLS_DIR
@@ -180,6 +183,9 @@ def builtin_skills() -> List[Skill]:
def _builtin_slugs() -> set[str]:
"""Tập slug của các skill dựng sẵn — dùng để không gieo trùng và không cho sửa
chúng như skill thường.
"""
return {s.slug for s in builtin_skills()}
@@ -277,6 +283,11 @@ def list_skills(directory: Path | None = None) -> List[Skill]:
def save_skill(skill: Skill, directory: Path | None = None, old_name: str = "") -> Path:
"""Ghi một skill xuống đĩa.
Đổi tên thì XOÁ file cũ trước — tên file suy từ tên skill, không xoá sẽ để
lại một bản sao dưới tên cũ.
"""
directory = directory or SKILLS_DIR
directory.mkdir(parents=True, exist_ok=True)
if old_name and old_name != skill.name:
@@ -287,6 +298,7 @@ def save_skill(skill: Skill, directory: Path | None = None, old_name: str = "")
def delete_skill(name: str, directory: Path | None = None) -> None:
"""Xoá file skill theo tên; không có thì bỏ qua."""
directory = directory or SKILLS_DIR
path = directory / f"{Skill(name=name).slug}.json"
if path.exists():
@@ -304,6 +316,9 @@ def _load_skill_from_zip(path: Path) -> "Skill | None":
import zipfile
def _rank(n: str) -> int:
"""Thứ tự ưu tiên khi gói zip có nhiều file Markdown: ``skill.md`` trước, rồi
tới file ở gốc gói, cuối cùng mới tới file nằm sâu.
"""
low = n.lower()
if low.endswith("skill.md"):
return 0
+30
View File
@@ -35,6 +35,7 @@ MAX_JSON_KEYS_PER_LEVEL = 200 # cap per object, so one huge JSON can't flood th
@dataclass
class GNode:
"""Một node trong đồ thị cấu trúc: thư mục, tệp, lớp, hàm, phương thức hay mục tài liệu."""
id: str
label: str
kind: str # dir | file | class | function | method | module | section
@@ -44,6 +45,7 @@ class GNode:
@dataclass
class GEdge:
"""Một cạnh trong đồ thị cấu trúc, kèm LOẠI quan hệ (chứa / định nghĩa / import…)."""
source: str
target: str
type: str = "" # contains | defines | method | imports | subsection
@@ -51,6 +53,11 @@ class GEdge:
@dataclass
class StructureGraph:
"""Đồ thị cấu trúc mã nguồn, có trần số node/cạnh.
Chạm trần thì bật cờ ``truncated`` và ngừng thêm — đồ thị quá lớn làm treo
khung vẽ, thà hiện một phần kèm cảnh báo còn hơn đứng hình.
"""
nodes: List[GNode] = field(default_factory=list)
edges: List[GEdge] = field(default_factory=list)
truncated: bool = False
@@ -58,9 +65,13 @@ class StructureGraph:
max_edges: int = 0 # 0 = unlimited
def __post_init__(self):
"""Dựng sẵn tập id node để kiểm tra một cạnh có hợp lệ không trong thời gian
hằng số, thay vì quét cả danh sách node cho từng cạnh.
"""
self._ids = {n.id for n in self.nodes}
def add_node(self, node: GNode) -> bool:
"""Thêm một node; trả về ``False`` nếu trùng id hoặc đã chạm trần."""
if node.id in self._ids:
return False
if self.max_nodes and len(self.nodes) >= self.max_nodes:
@@ -71,6 +82,7 @@ class StructureGraph:
return True
def add_edge(self, source: str, target: str, type_: str = "") -> None:
"""Thêm một cạnh; bỏ qua nếu một trong hai đầu chưa có node, hoặc đã chạm trần."""
if source in self._ids and target in self._ids:
if self.max_edges and len(self.edges) >= self.max_edges:
self.truncated = True
@@ -78,6 +90,7 @@ class StructureGraph:
self.edges.append(GEdge(source, target, type_))
def has(self, node_id: str) -> bool:
"""Đồ thị đã có node với id này chưa."""
return node_id in self._ids
@@ -144,6 +157,7 @@ def _add_generic_file(graph: StructureGraph, dir_id: str, fpath: Path, root: Pat
def _rel(path: Path, root: Path) -> str:
"""Đường dẫn tương đối so với thư mục gốc; nằm ngoài gốc thì trả nguyên đường dẫn."""
try:
return str(path.relative_to(root))
except ValueError:
@@ -151,6 +165,7 @@ def _rel(path: Path, root: Path) -> str:
def _add_python_file(graph: StructureGraph, dir_id: str, fpath: Path, root: Path) -> None:
"""Thêm một tệp Python vào đồ thị: node tệp, các lớp, hàm, phương thức và import."""
file_id = f"file:{fpath}"
if not graph.add_node(GNode(file_id, fpath.name, "file", _rel(fpath, root), str(fpath))):
return
@@ -184,6 +199,7 @@ def _add_python_file(graph: StructureGraph, dir_id: str, fpath: Path, root: Path
def _module_imports(tree: ast.AST) -> List[str]:
"""Tên các module mà một cây AST import vào."""
mods: List[str] = []
for node in ast.walk(tree):
if isinstance(node, ast.Import):
@@ -199,6 +215,7 @@ def _module_imports(tree: ast.AST) -> List[str]:
def _add_doc_file(graph: StructureGraph, dir_id: str, fpath: Path, root: Path) -> None:
"""Thêm một tệp tài liệu (Markdown…) vào đồ thị, tách theo cấp tiêu đề."""
file_id = f"file:{fpath}"
fp = str(fpath)
if not graph.add_node(GNode(file_id, fpath.name, "file", _rel(fpath, root), fp)):
@@ -254,6 +271,7 @@ def _add_json_file(graph: StructureGraph, dir_id: str, fpath: Path, root: Path)
def _json_scalar_preview(value) -> str:
"""Chuỗi xem trước ngắn cho một giá trị JSON, để nhãn node không quá dài."""
if isinstance(value, dict):
return f"{{…}} ({len(value)} keys)"
if isinstance(value, list):
@@ -262,6 +280,11 @@ def _json_scalar_preview(value) -> str:
def _add_json_value(graph: StructureGraph, parent_id: str, fp: str, value, depth: int) -> None:
"""Thêm cấu trúc một giá trị JSON vào đồ thị, chặn ở ``MAX_JSON_DEPTH``.
Có trần độ sâu vì JSON lồng sâu sẽ sinh ra hàng nghìn node mà chẳng nói lên
điều gì về cấu trúc dự án.
"""
if depth >= MAX_JSON_DEPTH:
return
if isinstance(value, dict):
@@ -325,6 +348,9 @@ def build_from_codebase_memory(mem, repo_path, mode: str = "all",
def _iter_results(res):
"""Duyệt kết quả trả về từ bộ nhớ mã nguồn, chấp nhận nhiều khuôn khoá khác nhau
(``results`` / ``nodes`` / ``items`` / ``data``).
"""
if isinstance(res, dict):
for key in ("results", "nodes", "items", "data"):
val = res.get(key)
@@ -339,6 +365,10 @@ def _iter_results(res):
# Layout (layered by distance from roots)
# --------------------------------------------------------------------------
def layered_layout(graph: StructureGraph, col_w: int = 280, row_h: int = 64) -> Tuple[Dict[str, Tuple[int, int]], Dict[str, int]]:
"""Xếp đồ thị thành các cột theo bậc phụ thuộc, trong cột xếp dọc.
Trả về (toạ độ từng node, lớp của từng node).
"""
indeg = {n.id: 0 for n in graph.nodes}
adj = defaultdict(list)
for e in graph.edges:
+2
View File
@@ -42,10 +42,12 @@ _TRUE = {"yes", "y", "true", "1", "x", "có", "co"}
def _bool(value) -> bool:
"""Đọc giá trị đúng/sai từ ô Excel, chấp nhận nhiều cách ghi."""
return str(value or "").strip().lower() in _TRUE
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."""
v = str(value or "").strip().lower()
return v if v in allowed else default
+82 -19
View File
@@ -2,7 +2,9 @@
``execute_task`` dispatches by ``task_type`` to the app's existing engines:
- ``cowork`` → ``chat_agent.run_cowork`` (documents/answers, real files)
- ``cowork`` → ``ConversationApplicationService`` (documents/answers, real
files) — the same turn engine the interactive Cowork chat
runs on since R04-T05
- ``co4e_code`` → ``code_agent.run_code`` (code agent with file/command tools)
- ``script`` → local subprocess with a timeout
- ``flow`` → the task's own simple step list, run sequentially, each
@@ -36,16 +38,21 @@ CancelFn = Callable[[], bool]
def new_run_id() -> str:
"""Id lượt chạy mới: mốc thời gian cộng 6 ký tự ngẫu nhiên (chống trùng khi hai
task khởi động cùng giây).
"""
return datetime.now().strftime("%Y%m%d-%H%M%S-") + uuid.uuid4().hex[:6]
def artifact_dir(task_id: str, run_id: str) -> Path:
"""Thư mục hiện vật của một lượt chạy, tạo sẵn cả thư mục con ``generated_files``."""
d = ARTIFACTS_DIR / task_id / run_id
(d / "generated_files").mkdir(parents=True, exist_ok=True)
return d
def _last_assistant_text(messages) -> str:
"""Nội dung trả lời cuối cùng của assistant trong hội thoại; '' nếu không có."""
for m in reversed(messages or []):
if m.get("role") == "assistant" and (m.get("content") or "").strip():
return m["content"]
@@ -62,6 +69,7 @@ _OUTPUT_MODE_HINTS = {
def _output_mode_hint(task: Dict[str, Any]) -> str:
"""Câu hướng dẫn định dạng đầu ra tương ứng chế độ output của task."""
return _OUTPUT_MODE_HINTS.get(task.get("output", {}).get("output_mode", "text"), "")
@@ -102,6 +110,9 @@ def _project_folder_input_text(project: Optional[projects.Project], max_files: i
def _build_prompt(task: Dict[str, Any], tasks_dir: Path = None,
project: Optional[projects.Project] = None,
max_files: int = 10) -> str:
"""Ghép prompt cho một task: mô tả, dữ liệu vào đã phân giải, chỉ dẫn chung của
project, và gợi ý định dạng đầu ra.
"""
parts = [task.get("description") or task.get("title") or ""]
extra = resolve_input_text(task, tasks_dir)
if extra:
@@ -162,6 +173,30 @@ _TIMEOUT_NOTICE_TMPL = (
)
_UNATTENDED_PREFIX = (
"This runs unattended (Schedule Task) — no one is watching live. Use "
"update_plan to track your steps and keep it accurate: mark a step "
"'error' (not silently skip it) if it genuinely can't be completed."
)
def _unattended_prompt(prompt: str, *, skill_text: str = "",
agent_instructions: str = "") -> str:
"""Assemble the user message an unattended run sends.
The order is load-bearing and used to be encoded as three successive
rebindings of ``prompt``, each prepending its own block: the plan reminder
must lead (it is the instruction that keeps a run without a human watching
honest), then the chosen skill's rules, then the Admin agent's persona, and
the task's own words last. Routing it through ``combine_instructions`` keeps
that order in one readable expression and drops the absent blocks instead of
leaving blank lines behind.
"""
from ..application.conversations.turn_runtime import combine_instructions
return combine_instructions(_UNATTENDED_PREFIX, skill_text, agent_instructions, prompt)
def _cancel_with_timeout(cancel: CancelFn, timeout_sec: Optional[int]) -> Tuple[CancelFn, Callable[[], bool]]:
"""Wrap ``cancel`` so it also fires once ``timeout_sec`` of wall-clock time
elapses. ``timed_out()`` tells the caller whether THAT is why it stopped
@@ -176,6 +211,7 @@ def _cancel_with_timeout(cancel: CancelFn, timeout_sec: Optional[int]) -> Tuple[
state = {"timed_out": False}
def wrapped() -> bool:
"""Cờ huỷ có thêm hạn giờ: người dùng bấm Dừng HOẶC quá thời gian cho phép."""
if cancel():
return True
if time.monotonic() >= deadline:
@@ -218,36 +254,29 @@ def _run_agent(ctx, task_type: str, prompt: str, out_dir: Path,
# default, see state.build_provider_for). A legacy Admin-agent preset
# (task.admin_agent_id), if still set on an older task, keeps working and
# takes precedence — it pins the provider/model AND prepends instructions.
agent_instructions = ""
if admin_agent is not None:
from .admin_agents import build_agent_provider
provider = build_agent_provider(ctx, admin_agent)
agent_instructions = admin_agent.effective_prompt()
if agent_instructions:
prompt = f"{agent_instructions}\n\n{prompt}"
elif provider_name or model:
# An explicit per-task provider/model override.
provider = ctx.build_provider_for(provider_name or None, model or None)
else:
# Neither overridden → the machine's own Settings default, exactly as before.
provider = ctx.build_active_provider()
# A chosen skill's instructions are prepended so this unattended run follows
# A chosen skill's instructions are applied so this unattended run follows
# them, mirroring how the interactive chat applies /skill.
skill_text = ""
if skill_slug:
from .skills import skill_prefix_for
skill_text = skill_prefix_for(skill_slug)
if skill_text:
prompt = f"{skill_text}\n\n{prompt}"
# This is an UNATTENDED run (no human watching to catch a half-finished
# job) — push the agent to actually use the Plan checklist so completion
# can be verified afterward, instead of just trusting "no exception".
prompt = (
"This runs unattended (Schedule Task) — no one is watching live. Use "
"update_plan to track your steps and keep it accurate: mark a step "
"'error' (not silently skip it) if it genuinely can't be completed.\n\n"
f"{prompt}"
)
# Assemble reminder + skill + persona + the task's own words in one place
# (see _unattended_prompt for why that order matters).
prompt = _unattended_prompt(prompt, skill_text=skill_text,
agent_instructions=agent_instructions)
messages = [{"role": "user", "content": prompt}]
session_id = new_session_id()
project_id = project.project_id if project is not None else ""
@@ -261,6 +290,7 @@ def _run_agent(ctx, task_type: str, prompt: str, out_dir: Path,
last_plan_steps: List[Dict[str, str]] = []
def emit_and_autosave(ev):
"""Chuyển tiếp sự kiện tiến độ và tự lưu hội thoại tại các mốc an toàn."""
emit(ev)
if not isinstance(ev, dict):
return
@@ -273,10 +303,41 @@ def _run_agent(ctx, task_type: str, prompt: str, out_dir: Path,
watched_cancel, timed_out = _cancel_with_timeout(cancel, timeout_sec)
try:
if task_type == "cowork":
from .chat_agent import run_cowork
run_cowork(provider, messages, out_dir, emit_and_autosave, watched_cancel,
security_config=ctx.config, agent_role=agent_roles.TASK,
project_context=project_context)
# R04-T05: the unattended run shares the interactive turn engine
# instead of calling run_cowork itself, so there is exactly one place
# where a turn's lifecycle is defined. Everything unattended-specific
# stays here (the plan reminder above, the History autosave in
# emit_and_autosave, the timeout notice below).
from ..application.conversations.core_runtime_adapter import (
build_cowork_conversation_service,
legacy_event_sink,
)
from ..domain.agents.conversation_execution_request import (
ConversationExecutionRequest,
)
# No extra_tools/extra_executor and no permission gate: a scheduled
# run gets no MCP connectors and nobody is there to approve a
# command, which is exactly what run_cowork was called with.
service = build_cowork_conversation_service(
provider, out_dir, emit_and_autosave, title=title,
project_context=project_context, security_config=ctx.config,
agent_role=agent_roles.TASK,
)
request = ConversationExecutionRequest(
# The artifact folder is named by the run id, which identifies
# this attempt in the audit log.
turn_id=out_dir.name or session_id, session_id=session_id,
surface="task", title=title, project_id=project_id,
prompt=prompt, output_dir=out_dir,
agent_role=agent_roles.TASK, unattended=True,
timeout_sec=timeout_sec,
)
# ``messages`` is handed over so the History autosave in
# emit_and_autosave (and the final save in the finally block below)
# keep reading the live conversation as it grows.
service.execute(request, legacy_event_sink(emit_and_autosave),
cancel=watched_cancel, messages=messages)
else:
from .code_agent import run_code
limits, block_network = agent_security.sandbox_settings(ctx.config)
@@ -298,6 +359,7 @@ def _run_agent(ctx, task_type: str, prompt: str, out_dir: Path,
def _run_script(command: str, out_dir: Path, timeout_sec: int) -> str:
"""Chạy một task kiểu script bằng shell trong thư mục kết quả, có hạn giờ."""
if not command.strip():
raise RuntimeError("Script task has no command configured.")
proc = subprocess.run(command, shell=True, cwd=str(out_dir),
@@ -406,6 +468,7 @@ def _run_co4e_flow(ctx, task: Dict[str, Any], wf, gen_dir: Path,
outputs: Dict[str, str] = {}
def _emit(ev):
"""Chuyển tiếp sự kiện của luồng Co4E về dạng sự kiện task."""
if not isinstance(ev, dict):
return
t = ev.get("type")
+9
View File
@@ -73,6 +73,7 @@ def auto_chain_in_order(tasks: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
# ---- CSV -----------------------------------------------------------------
def _import_csv(path: Path) -> List[Dict[str, Any]]:
"""Đọc danh sách task từ file CSV (chấp nhận BOM của Excel)."""
try:
text = path.read_text(encoding="utf-8-sig")
except OSError as exc:
@@ -111,6 +112,7 @@ def _import_csv(path: Path) -> List[Dict[str, Any]]:
# ---- JSON ----------------------------------------------------------------
def _import_json(path: Path) -> List[Dict[str, Any]]:
"""Đọc danh sách task từ file JSON."""
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
@@ -138,6 +140,11 @@ def _import_json(path: Path) -> List[Dict[str, Any]]:
def _pick(d: Dict[str, Any], *keys, default=""):
"""Lấy giá trị đầu tiên khác rỗng trong các khoá được nêu.
File nhập từ nhiều nguồn đặt tên cột khác nhau (``title``/``name``/``Tiêu đề``),
nên phải thử lần lượt.
"""
for k in keys:
if k in d and d[k] not in (None, ""):
return d[k]
@@ -145,6 +152,7 @@ def _pick(d: Dict[str, Any], *keys, default=""):
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."""
v = str(value or "").strip().lower()
return v if v in allowed else default
@@ -213,6 +221,7 @@ _TRUE = {"yes", "y", "true", "1", "x", "có", "co"}
def _truthy(value) -> bool:
"""Đọc giá trị đúng/sai từ nhiều kiểu ghi khác nhau (bool, 'yes', '1', 'có'…)."""
if isinstance(value, bool):
return value
return str(value or "").strip().lower() in _TRUE
+48 -10
View File
@@ -17,7 +17,7 @@ from datetime import datetime
from pathlib import Path
from typing import Dict, Optional
from PySide6.QtCore import QCoreApplication, QObject, QTimer, Signal
from PySide6.QtCore import QObject, Signal
from .tasks import (
advance_after_run, chain_action, dependencies_met, due_tasks, format_run_at,
@@ -31,6 +31,13 @@ STOP_WAIT_SECS = 10.0
class TaskScheduler(QObject):
"""Bộ chạy task theo lịch: cứ mỗi nhịp lại tìm task tới hạn và chạy chúng ở
luồng nền.
Đồng hồ được tiêm qua ``clock=`` (R07-T03) nên test chạy được mà không cần
``QTimer`` thật, và các checker UI vô hiệu hoá được nó để việc dựng cửa sổ
không vô tình chạy task thật của người dùng.
"""
tasks_changed = Signal() # any status/log change → UI refresh
task_started = Signal(str) # task_id
task_finished = Signal(str, bool) # task_id, ok
@@ -39,22 +46,38 @@ class TaskScheduler(QObject):
# which fires before the worker thread has even begun).
history_ready = Signal(str) # task_id
def __init__(self, ctx, tasks_dir: Optional[Path] = None, parent=None):
def __init__(self, ctx, tasks_dir: Optional[Path] = None, parent=None, clock=None):
"""``clock`` để None thì tự dựng ``QtSchedulerClock`` thật, nên mọi chỗ gọi cũ
không phải sửa; test tiêm ``FakeClock`` để điều khiển nhịp bằng tay.
"""
super().__init__(parent)
self.ctx = ctx
self.tasks_dir = tasks_dir # None → default TASKS_DIR
self._workers: Dict[str, AgentWorker] = {} # task_id → running worker
self._retries: Dict[str, int] = {}
self._session_ids: Dict[str, str] = {} # task_id → its run's History session id
self._timer = QTimer(self)
self._timer.setInterval(TICK_MS)
self._timer.timeout.connect(self.tick)
# R07-T03: the QTimer this class used to own directly is now behind a
# small clock interface (start/stop/pump) — see
# infrastructure/qt/qt_scheduler_clock.py::QtSchedulerClock. Defaulting to a
# real one here keeps every existing production call site (which
# never passes `clock=`) unchanged; tests inject
# tests/fakes/fake_clock.py::FakeClock to control ticks by hand with
# no Qt event loop running. Imported lazily so importing core.tasks/
# core.task_scheduler for the Qt-free logic doesn't require the Qt
# adapter module to even exist in a headless test context.
if clock is None:
from ..infrastructure.qt.qt_scheduler_clock import QtSchedulerClock
clock = QtSchedulerClock(self)
self._clock = clock
# ---- lifecycle ----------------------------------------------------
def start(self) -> None:
"""Bắt đầu chạy: thu dọn task còn kẹt từ lần chạy trước, đuổi kịp task đã quá
hạn, rồi bật nhịp đếm.
"""
self._recover_orphans()
self.tick() # catch up overdue tasks right at app start
self._timer.start()
self._clock.start(TICK_MS, self.tick)
def stop(self) -> None:
"""Request every running worker to stop, then WAIT (bounded) for them
@@ -67,15 +90,15 @@ class TaskScheduler(QObject):
``_on_done`` (the only place that writes the run into the task's
history) never runs. The task's real output can already be sitting on
disk while its history stays stuck on "running" forever. Pumping
``processEvents()`` here lets that queued signal actually get
delivered before the app finishes quitting.
the clock here lets that queued signal actually get delivered before
the app finishes quitting.
"""
self._timer.stop()
self._clock.stop()
deadline = time.monotonic() + STOP_WAIT_SECS
while self._workers and time.monotonic() < deadline:
for w in list(self._workers.values()):
w.request_stop()
QCoreApplication.processEvents()
self._clock.pump()
for w in list(self._workers.values()):
w.wait(50)
# Anything still alive past the deadline is abandoned here;
@@ -101,6 +124,7 @@ class TaskScheduler(QObject):
# ---- tick / dispatch ----------------------------------------------
def tick(self) -> None:
"""Một nhịp: chạy mọi task đã tới hạn tại thời điểm này."""
now = datetime.now()
changed = False
for task in due_tasks(list_tasks(self.tasks_dir), now):
@@ -137,6 +161,7 @@ class TaskScheduler(QObject):
return True
def is_running(self, task_id: str) -> bool:
"""Task này có đang chạy không."""
return task_id in self._workers
def running_count(self) -> int:
@@ -153,6 +178,7 @@ class TaskScheduler(QObject):
# ---- internals -----------------------------------------------------
def _start(self, task: dict) -> None:
"""Khởi động một task ở luồng nền và đánh dấu trạng thái 'running'."""
tid = task["task_id"]
run_id = new_run_id()
task["status"] = "running"
@@ -160,6 +186,7 @@ class TaskScheduler(QObject):
self.task_started.emit(tid)
def job(worker: AgentWorker):
"""Chạy nền: thực thi task, chuyển tiếp sự kiện tiến độ và cờ huỷ."""
return execute_task(self.ctx, task, run_id,
emit=worker.emit_event, cancel=worker.is_cancelled,
tasks_dir=self.tasks_dir)
@@ -187,6 +214,7 @@ class TaskScheduler(QObject):
self.history_ready.emit(task_id)
def _on_done(self, task_id: str, run_id: str, result: dict) -> None:
"""Task chạy xong: ghi kết quả, tính lần chạy kế tiếp, và kích hoạt task nối tiếp."""
self._workers.pop(task_id, None)
self._session_ids.pop(task_id, None)
task = load_task(task_id, self.tasks_dir)
@@ -239,6 +267,11 @@ class TaskScheduler(QObject):
self._start(task)
def _apply_chain(self, task: dict, verb: str, next_id: str) -> None:
"""Kích hoạt task nối tiếp theo luật ``run_next``.
Task kế đang tạm dừng thì BỎ QUA — trình sửa task có cảnh báo trước về
điều này.
"""
nxt = load_task(next_id, self.tasks_dir)
if not nxt or nxt.get("status") == "paused":
return # paused next task is skipped (warned about in the editor)
@@ -255,6 +288,11 @@ class TaskScheduler(QObject):
save_task(nxt, self.tasks_dir)
def _notify(self, task: dict, ok: bool, error: str) -> None:
"""Gửi nhắc việc qua Teams hoặc Outlook khi task kết thúc.
Đã chọn kênh thì báo cả khi chạy xong LẪN khi lỗi — im lặng lúc lỗi là
kiểu hỏng tệ nhất của một tác vụ chạy nền.
"""
ex = task["execution"]
channel = ex.get("notify_channel", "none")
# A chosen channel notifies on BOTH completion and error; the legacy
+42 -71
View File
@@ -98,10 +98,12 @@ DEFAULT_TASK: Dict[str, Any] = {
def _now_str() -> str:
"""Mốc thời gian hiện tại theo đúng định dạng lưu trong file task."""
return datetime.now().strftime(_TIME_FMT)
def parse_run_at(value: Optional[str]) -> Optional[datetime]:
"""Đọc chuỗi thời gian chạy thành ``datetime``; sai định dạng thì trả ``None``."""
if not value:
return None
try:
@@ -111,6 +113,7 @@ def parse_run_at(value: Optional[str]) -> Optional[datetime]:
def format_run_at(dt: datetime) -> str:
"""Ghi ``datetime`` thành chuỗi thời gian chạy."""
return dt.strftime(_TIME_FMT)
@@ -143,19 +146,29 @@ def _normalize(task: Dict[str, Any]) -> Dict[str, Any]:
# ---- repository ----------------------------------------------------------
def task_path(task_id: str, directory: Path = None) -> Path:
"""Đường dẫn file JSON của một task."""
return (directory or TASKS_DIR) / f"{task_id}.json"
def save_task(task: Dict[str, Any], directory: Path = None) -> Path:
"""Ghi task xuống đĩa (ghi nguyên tử) và cập nhật ``updated_at``."""
directory = directory or TASKS_DIR
directory.mkdir(parents=True, exist_ok=True)
task["updated_at"] = datetime.now().isoformat(timespec="seconds")
path = task_path(task["task_id"], directory)
path.write_text(json.dumps(task, ensure_ascii=False, indent=2), encoding="utf-8")
# R07-T01: atomic write — same class of bug already fixed in
# core/projects.py and core/history.py at R06-T02 (plain write_text has a
# gap between truncate and write; a crash there leaves a half-written
# tasks/<id>.json that load_task() then silently treats as "missing",
# dropping the task). Lazy import to match the existing call sites and
# avoid a core -> infrastructure import at module load time.
from ..infrastructure.persistence.json.atomic_write import write_json
write_json(path, task)
return path
def load_task(task_id: str, directory: Path = None) -> Optional[Dict[str, Any]]:
"""Đọc một task theo id và chuẩn hoá; không có hoặc hỏng thì trả ``None``."""
path = task_path(task_id, directory)
try:
return _normalize(json.loads(path.read_text(encoding="utf-8")))
@@ -164,6 +177,7 @@ def load_task(task_id: str, directory: Path = None) -> Optional[Dict[str, Any]]:
def list_tasks(directory: Path = None) -> List[Dict[str, Any]]:
"""Liệt kê mọi task trong thư mục; thư mục chưa có thì trả list rỗng."""
directory = directory or TASKS_DIR
if not directory.exists():
return []
@@ -178,6 +192,7 @@ def list_tasks(directory: Path = None) -> List[Dict[str, Any]]:
def delete_task(task_id: str, directory: Path = None) -> None:
"""Xoá file task; không có thì bỏ qua."""
try:
task_path(task_id, directory).unlink()
except OSError:
@@ -287,36 +302,37 @@ def chain_error(tasks: List[Dict[str, Any]], task_id: str,
# ---- schedule math --------------------------------------------------------
def _is_excluded_day(dt: datetime, sched: Dict[str, Any]) -> bool:
"""True when ``dt`` falls on a day this schedule must skip: a weekend
(working_days_only) or a public holiday of the configured country."""
if sched.get("working_days_only") and dt.weekday() >= 5: # 5=Sat, 6=Sun
return True
if sched.get("skip_holidays"):
# R07-T02: the actual date/cron math now lives in
# domain/tasks/schedule_calculator.py::ScheduleCalculator (pure Python, unit
# tested on its own — see tests/unit/test_schedule_calculator.py). Everything
# below is a thin wrapper kept for backward compatibility: task_scheduler.py,
# task_executors.py and ui/task_editor_dialog.py all still import these
# module-level names from core.tasks, and core/holiday_calendar.py::is_holiday
# / core/cron.py::Cron are only wired in HERE (lazily, matching the previous
# lazy-import style) — domain/ is not allowed to import core/ (ADR-001 I2).
_calculator: Optional[Any] = None
def _get_calculator():
"""Bộ tính lịch (:class:`ScheduleCalculator`), dựng một lần rồi dùng lại.
Dựng lười để ``core/tasks.py`` không kéo theo cả module cron ở mỗi lần
import.
"""
global _calculator
if _calculator is None:
from .cron import Cron
from .holiday_calendar import is_holiday
from ..domain.tasks.schedule_calculator import ScheduleCalculator
if is_holiday(dt.date(), sched.get("holiday_country", "")):
return True
return False
def _add_month(dt: datetime) -> datetime:
import calendar
year = dt.year + (1 if dt.month == 12 else 0)
month = 1 if dt.month == 12 else dt.month + 1
day = min(dt.day, calendar.monthrange(year, month)[1])
return dt.replace(year=year, month=month, day=day)
_calculator = ScheduleCalculator(is_holiday=is_holiday, make_cron=Cron)
return _calculator
def shift_off_excluded_days(dt: datetime, sched: Dict[str, Any]) -> datetime:
"""Push ``dt`` forward one day at a time until it lands on an allowed day
(same time of day) — used for one-time schedules set on a weekend/holiday."""
guard = 0
while _is_excluded_day(dt, sched) and guard < 400:
dt += timedelta(days=1)
guard += 1
return dt
return _get_calculator().shift_off_excluded_days(dt, sched)
def compute_next_run(task: Dict[str, Any], after: datetime) -> Optional[datetime]:
@@ -324,57 +340,12 @@ def compute_next_run(task: Dict[str, Any], after: datetime) -> Optional[datetime
(daily / weekly / monthly / cron), or None for one-shot schedules.
Occurrences on excluded days (weekends with working_days_only, public
holidays with skip_holidays+holiday_country) are skipped forward."""
sched = task.get("schedule", {})
repeat = sched.get("repeat_type", "none")
if repeat == "cron":
from .cron import Cron, CronError
try:
cron = Cron(sched.get("cron_expression") or "")
except CronError:
return None
nxt = cron.next_after(after)
guard = 0
while nxt is not None and _is_excluded_day(nxt, sched) and guard < 400:
nxt = cron.next_after(nxt)
guard += 1
return nxt
base = parse_run_at(sched.get("run_at"))
if base is None:
return None
if repeat == "daily":
advance = lambda d: d + timedelta(days=1) # noqa: E731
elif repeat == "weekly":
advance = lambda d: d + timedelta(weeks=1) # noqa: E731
elif repeat == "monthly":
advance = _add_month
else:
return None
nxt = base
while nxt <= after:
nxt = advance(nxt)
guard = 0
while _is_excluded_day(nxt, sched) and guard < 400:
nxt = advance(nxt)
guard += 1
return nxt
return _get_calculator().compute_next_run(task, after)
def due_tasks(tasks: List[Dict[str, Any]], now: datetime) -> List[Dict[str, Any]]:
"""Tasks that should start now: Scheduled + schedule enabled + run_at due."""
due = []
for t in tasks:
if t.get("status") != "scheduled":
continue
sched = t.get("schedule", {})
if not sched.get("enabled"):
continue
run_at = parse_run_at(sched.get("run_at"))
if run_at is not None and run_at <= now:
due.append(t)
return due
return _get_calculator().due_tasks(tasks, now)
# ---- post-run bookkeeping (pure; scheduler applies + saves) ---------------
+12
View File
@@ -17,12 +17,21 @@ ACCENT = "F37021"
class TeamsNotifier:
"""Gửi thông báo lên Microsoft Teams qua webhook.
Tự thử hai khuôn thẻ: Adaptive Card (webhook Workflows mới) rồi tới
MessageCard (webhook Connector cũ) — hai loại webhook không nhận chung một khuôn.
"""
def __init__(self, webhook_url: str = "", ca_bundle: str = ""):
"""``webhook_url`` rỗng nghĩa là chưa cấu hình — mọi lượt gửi về sau lặng lẽ bỏ
qua thay vì lỗi.
"""
self.webhook_url = (webhook_url or "").strip()
self.ca_bundle = (ca_bundle or "").strip()
@property
def configured(self) -> bool:
"""Đã cấu hình webhook hợp lệ chưa."""
return self.webhook_url.startswith("http")
def send(
@@ -113,6 +122,7 @@ class TeamsNotifier:
@staticmethod
def _explain(resp) -> str:
"""Đổi phản hồi lỗi của Teams thành câu đọc được, kèm mã HTTP và 200 ký tự thân."""
code = resp.status_code
body = (getattr(resp, "text", "") or "")[:200]
if code == 405:
@@ -127,6 +137,7 @@ class TeamsNotifier:
@staticmethod
def _message_card(title: str, text: str, facts: Optional[Dict[str, str]]) -> Dict:
"""Dựng payload khuôn MessageCard (webhook Connector cũ)."""
section: Dict = {"activityTitle": title, "text": text}
if facts:
section["facts"] = [{"name": k, "value": v} for k, v in facts.items()]
@@ -140,6 +151,7 @@ class TeamsNotifier:
@staticmethod
def _adaptive_card(title: str, text: str, facts: Optional[Dict[str, str]]) -> Dict:
"""Dựng payload khuôn Adaptive Card (webhook Workflows mới)."""
body: List[Dict] = [
{"type": "TextBlock", "text": title, "weight": "Bolder", "size": "Medium"},
{"type": "TextBlock", "text": text, "wrap": True},
+5
View File
@@ -19,6 +19,11 @@ from typing import Any, Dict, List, Optional
def _load_events(shared_dir: str, subdir: str, start: Optional[date],
end: Optional[date]) -> List[Dict[str, Any]]:
"""Đọc sự kiện telemetry từ thư mục chia sẻ của đội, lọc theo khoảng ngày.
Thư mục chưa có thì trả list rỗng — máy chưa đồng bộ xong không được làm vỡ
màn Giám sát.
"""
directory = Path(shared_dir).expanduser() / "telemetry" / subdir
if not directory.exists():
return []
+7
View File
@@ -53,15 +53,22 @@ def looks_like_cert_trust_error(exc: BaseException) -> bool:
def _host_port(url: str) -> tuple[str, int]:
"""Cặp (host, port) rút từ URL; không có port thì mặc định 443."""
parsed = urlparse(url)
return parsed.hostname or "", parsed.port or 443
def _slug(host: str) -> str:
"""Tên file an toàn suy từ host."""
return re.sub(r"[^a-zA-Z0-9.-]", "_", host) or "host"
def trusted_cert_path(url: str) -> Path:
"""Đường dẫn file PEM ghim chứng chỉ cho một host.
Mỗi host một file: gateway nội bộ dùng chứng chỉ tự ký, ghim đúng chứng chỉ
đã thấy lần đầu (trust on first use) thay vì tắt kiểm chứng chỉ.
"""
host, _port = _host_port(url)
return TRUST_DIR / f"{_slug(host)}.pem"
+42 -312
View File
@@ -3,79 +3,29 @@
Every path is resolved relative to the working directory and must stay inside
it (path-traversal is rejected). ``run_command`` executes inside the workdir
with a timeout and captured output.
R05-T02: the actual handlers (``read_file``/``list_dir``/``write_file``/
``edit_file``/``run_command``/``install_package``/``fetch_url``/
``jira_search``/``jira_get_issue``) now live in
``infrastructure/filesystem/{file_tools,command_tools,fetch_tools}.py``, split
out of what used to be one big if/elif chain here. This module is the
strangler-fig shim (ADR-001 section 4): it re-exports ``ToolContext``/
``ToolError`` (actually defined in
``infrastructure/filesystem/tool_context.py`` now) so every existing
``from .tools import ToolContext`` keeps working, and ``execute_tool``
dispatches through a small ``{name: handler}`` table built from the moved
modules instead of the chain itself.
"""
from __future__ import annotations
import ast
import difflib
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional
from ..infrastructure.filesystem import command_tools, fetch_tools, file_tools
from ..infrastructure.filesystem.command_tools import _snapshot # noqa: F401 - re-export, core/chat_agent.py imports this name
from ..infrastructure.filesystem.tool_context import CancelFn, ToolContext, ToolError # noqa: F401 - re-export
from ..providers.base import ToolSpec
CancelFn = Callable[[], bool]
MAX_READ_BYTES = 200_000
COMMAND_TIMEOUT = 120 # seconds
class ToolError(Exception):
pass
def _flatten_rel(rel: str) -> str:
"""Collapse a sub-folder path down to a bare filename so the file lands in the
workdir root — EXCEPT the ``.scratch`` sandbox subtree, which is preserved.
Used by the Cowork agent (flatten_writes=True) so it can never create a
per-session / per-chat / per-task output sub-folder: every deliverable stays
directly in the single configured Output folder."""
parts = Path(rel).parts
if parts and parts[0] == ".scratch":
return rel # temporary sandbox is allowed (and cleaned up afterwards)
return Path(rel).name or rel
@dataclass
class ToolContext:
workdir: Path
flatten_writes: bool = False # Cowork: force every write into the workdir root
sandbox: bool = False # Code tab: isolate run_command/install_package into <workdir>/.venv
# Sandbox Security Layer — Settings' "Resource Limits" (cpu_percent/memory_mb/
# disk_mb), applied to every run_command/install_package this context runs.
# None (default) = no limits, matching pre-existing behavior.
resource_limits: Optional[Dict[str, float]] = None
# Sandbox Security Layer — Settings' "Block network for agent commands"
# (policy-level, see deps.py::network_blocked_env). False (default) =
# unrestricted, matching pre-existing behavior.
block_network: bool = False
# Whether the fetch_url tool may read URLs — SEPARATE from block_network
# (reading a web page/share link for info is safe; running networked shell
# commands is the risk). Defaults True; set from agent_security.allow_url_fetch.
allow_url_fetch: bool = True
# Jira read connector config (base_url/email/api_token) — None disables the
# jira_* tools' ability to connect. Populated from config.data["jira"].
jira: Optional[Dict[str, Any]] = None
def resolve(self, rel: str) -> Path:
"""Resolve ``rel`` inside the workdir, rejecting escapes."""
if rel in ("", "."):
return self.workdir
candidate = (self.workdir / rel).expanduser()
try:
resolved = candidate.resolve()
except OSError as exc:
raise ToolError(f"Invalid path: {rel} ({exc})")
root = self.workdir.resolve()
if resolved != root and root not in resolved.parents:
raise ToolError(
f"Refused: '{rel}' is outside the working folder ({root})."
)
return resolved
# --------------------------------------------------------------------------
# Tool specs advertised to the model
# --------------------------------------------------------------------------
@@ -192,6 +142,23 @@ TOOL_SPECS: List[ToolSpec] = [
# Actions gated by the permission gate in confirm mode (auto-approved in Auto-run).
WRITE_TOOLS = {"write_file", "edit_file", "run_command", "install_package"}
# name -> handler(ctx, args[, cancel, on_output]) — built once from the split
# infrastructure modules. Replaces the if/elif chain execute_tool used to be.
_HANDLERS: Dict[str, Callable[..., Dict[str, Any]]] = {
"read_file": file_tools.read_file,
"list_dir": file_tools.list_dir,
"write_file": file_tools.write_file,
"edit_file": file_tools.edit_file,
"run_command": command_tools.run_command,
"install_package": command_tools.install_package,
"fetch_url": fetch_tools.fetch_url,
"jira_search": fetch_tools.jira_search,
"jira_get_issue": fetch_tools.jira_get_issue,
}
# Handlers that accept the long-running (cancel, on_output) signature — every
# other handler takes just (ctx, args).
_CANCELLABLE = {"run_command", "install_package"}
def enabled_tool_specs(security_config=None) -> List[ToolSpec]:
"""The built-in TOOL_SPECS minus any the admin turned OFF in Monitoring →
@@ -221,6 +188,7 @@ def combine_tool_sources(*sources):
return [], None
def combined_executor(name: str, args: Dict[str, Any]) -> Dict[str, Any]:
"""Định tuyến một lời gọi tool về đúng nguồn của nó (dựng sẵn, MCP hay connector)."""
executor = routing.get(name)
if executor is None:
return {"ok": False, "output": f"Unknown tool: {name}"}
@@ -301,27 +269,14 @@ def execute_tool(ctx: ToolContext, name: str, args: Dict[str, Any],
labels WHICH agent role made it."""
from . import audit_log
handler = _HANDLERS.get(name)
try:
if name == "read_file":
result = _read_file(ctx, args)
elif name == "list_dir":
result = _list_dir(ctx, args)
elif name == "write_file":
result = _write_file(ctx, args)
elif name == "edit_file":
result = _edit_file(ctx, args)
elif name == "run_command":
result = _run_command(ctx, args, cancel, on_output)
elif name == "install_package":
result = _install_package(ctx, args, cancel, on_output)
elif name == "fetch_url":
result = _fetch_url(ctx, args)
elif name == "jira_search":
result = _jira_search(ctx, args)
elif name == "jira_get_issue":
result = _jira_get_issue(ctx, args)
else:
if handler is None:
result = {"ok": False, "output": f"Tool not found: {name}"}
elif name in _CANCELLABLE:
result = handler(ctx, args, cancel, on_output)
else:
result = handler(ctx, args)
except ToolError as exc:
result = {"ok": False, "output": str(exc)}
except Exception as exc: # defensive: a tool must never crash the agent
@@ -331,235 +286,10 @@ def execute_tool(ctx: ToolContext, name: str, args: Dict[str, Any],
return result
def _fetch_url(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
"""Fetch a URL's text content (web page / online document / SharePoint-
OneDrive share link) via link_fetch — the same parser task-link attachments
use. Honors the Sandbox Security Layer's "Block network" policy."""
url = str(args.get("url", "")).strip()
if not url:
return {"ok": False, "output": "fetch_url: 'url' is required."}
if not url.lower().startswith(("http://", "https://")):
return {"ok": False, "output": f"fetch_url: not an http(s) URL: {url}"}
if not ctx.allow_url_fetch:
return {"ok": False,
"output": ("fetch_url: URL fetching is turned off in Settings → Security "
"(\"Allow the agent to fetch URLs\").")}
# A pasted Jira issue link on the CONNECTED Jira host is read via the
# authenticated API (so private issues resolve, not a login page). Public
# links / any other URL fall through to the normal fetcher below.
from . import jira_tool
if jira_tool.is_jira_issue_url(ctx.jira, url):
return {"ok": True, "output": jira_tool.get_issue_by_url(ctx.jira, url)}
from .link_fetch import fetch_link_preview
return {"ok": True, "output": fetch_link_preview(url)}
def _jira_search(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
from . import jira_tool
out = jira_tool.search(ctx.jira, str(args.get("jql", "")),
int(args.get("max_results", 25) or 25))
return {"ok": not out.lower().startswith(("jira is not configured", "jira search failed")),
"output": out}
def _jira_get_issue(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
from . import jira_tool
out = jira_tool.get_issue(ctx.jira, str(args.get("key", "")))
return {"ok": not out.lower().startswith(("jira is not configured", "could not fetch")),
"output": out}
def _read_file(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
target = ctx.resolve(str(args.get("path", "")))
if not target.exists():
return {"ok": False, "output": f"File not found: {args.get('path')}"}
data = target.read_bytes()[:MAX_READ_BYTES]
text = data.decode("utf-8", errors="replace")
return {"ok": True, "output": text}
def _list_dir(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
rel = str(args.get("path", ".") or ".")
target = ctx.resolve(rel)
# A missing/not-yet-created path is NOT a tool failure — report it as an
# ordinary result so the agent can create it or pick another path and keep
# going. Returning ok=False here surfaced a false "tool failed: list_dir" in
# Co4E flows and could stall a step on a recoverable situation.
if not target.exists():
return {"ok": True, "output": f"(path '{rel}' does not exist yet — create it or use another path)"}
if target.is_file():
return {"ok": True, "output": f"('{rel}' is a file, not a directory)"}
entries = []
for child in sorted(target.iterdir(), key=lambda p: (p.is_file(), p.name.lower())):
marker = "/" if child.is_dir() else ""
entries.append(f"{child.name}{marker}")
return {"ok": True, "output": "\n".join(entries) or "(empty folder)"}
def _check_python_syntax(target: Path, content: str) -> str:
"""Return a short warning if ``content`` is invalid Python, else ''.
Catches syntax errors the instant a .py file is written/edited — before the
agent wastes a whole run_command round-trip just to get the same error back
from a traceback."""
if target.suffix.lower() not in (".py", ".pyw"):
return ""
try:
ast.parse(content, filename=str(target))
return ""
except SyntaxError as exc:
return f"\n⚠ Syntax error at line {exc.lineno}: {exc.msg} — fix this before running the file."
def _write_file(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
rel = str(args.get("path", ""))
if ctx.flatten_writes:
rel = _flatten_rel(rel)
target = ctx.resolve(rel)
content = str(args.get("content", ""))
target.parent.mkdir(parents=True, exist_ok=True)
# A .xlsx is a binary package — build a REAL workbook from the content
# (CSV/TSV/Markdown-table/JSON) rather than writing raw text (which corrupts it).
if target.suffix.lower() in (".xlsx", ".xlsm"):
from . import xlsx_write
if xlsx_write.build_xlsx_from_text(target, content):
return {"ok": True, "path": str(target),
"output": f"Wrote spreadsheet {rel} ({target.name})."}
return {"ok": False, "output": "Could not build the .xlsx (openpyxl unavailable) — "
"write a .csv instead, or use a generator script."}
target.write_text(content, encoding="utf-8")
warning = _check_python_syntax(target, content)
return {"ok": True, "path": str(target),
"output": f"Wrote {len(content)} chars to {rel}.{warning}"}
def _edit_file(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
"""Replace an exact snippet inside an existing file (precise patch edit)."""
rel = str(args.get("path", ""))
if ctx.flatten_writes:
rel = _flatten_rel(rel)
target = ctx.resolve(rel)
if not target.exists():
return {"ok": False,
"output": f"File not found: {rel} — use write_file to create it."}
old = str(args.get("old_string", ""))
new = str(args.get("new_string", ""))
replace_all = bool(args.get("replace_all", False))
if not old:
return {"ok": False, "output": "old_string is empty — provide the exact text to replace."}
try:
text = target.read_text(encoding="utf-8", errors="replace")
except OSError as exc:
return {"ok": False, "output": f"Could not read file: {exc}"}
count = text.count(old)
if count == 0:
return {"ok": False, "output": ("old_string not found. Read the file and copy the exact "
"text to replace, including indentation/whitespace.")}
if count > 1 and not replace_all:
return {"ok": False, "output": (f"old_string appears {count} times — add surrounding "
"context to make it unique, or set replace_all=true.")}
updated = text.replace(old, new) if replace_all else text.replace(old, new, 1)
target.write_text(updated, encoding="utf-8")
n = count if replace_all else 1
warning = _check_python_syntax(target, updated)
return {"ok": True,
"output": f"Edited {args.get('path')} ({n} replacement{'' if n == 1 else 's'}).{warning}"}
def _sandbox_python(ctx: ToolContext, cancel: Optional[CancelFn] = None,
on_output: Optional[Callable[[str], None]] = None) -> Optional[str]:
"""Lazily create/reuse this ctx's project sandbox venv (Code tab only —
``ctx.sandbox``); returns its python path, or None to use the app's own."""
if not ctx.sandbox:
return None
from .deps import ensure_project_venv
py = ensure_project_venv(ctx.workdir, cancel=cancel, on_output=on_output)
return str(py) if py else None
def _install_package(ctx: ToolContext, args: Dict[str, Any], cancel: Optional[CancelFn] = None,
on_output: Optional[Callable[[str], None]] = None) -> Dict[str, Any]:
from .deps import pip_install
package = str(args.get("package", "")).strip()
if not package:
return {"ok": False, "output": "No package specified."}
python = _sandbox_python(ctx, cancel, on_output)
ok, detail = pip_install(package, cancel=cancel, on_output=on_output, python=python)
head = f"Installed {package}." if ok else f"Could not install {package}."
return {"ok": ok, "output": f"{head}\n{detail}"}
_SNAPSHOT_SKIP = {".git", "__pycache__", "node_modules", ".scratch", ".venv",
".idea", ".mypy_cache", ".pytest_cache"}
def _snapshot(workdir: Path) -> Dict[str, Any]:
"""Map of file path -> (mtime, size) under the workdir (noise dirs skipped)."""
snap: Dict[str, Any] = {}
try:
for dirpath, dirnames, filenames in os.walk(str(workdir)):
dirnames[:] = [d for d in dirnames if d not in _SNAPSHOT_SKIP]
for fn in filenames:
full = os.path.join(dirpath, fn)
try:
st = os.stat(full)
snap[full] = (st.st_mtime_ns, st.st_size)
except OSError:
pass
if len(snap) > 5000:
return snap
except OSError:
pass
return snap
def _run_command(ctx: ToolContext, args: Dict[str, Any],
cancel: Optional[CancelFn] = None,
on_output: Optional[Callable[[str], None]] = None) -> Dict[str, Any]:
from .deps import network_blocked_env, run_cancellable, sandbox_env
from .sandbox_manager import SandboxManager, ExecutionConfig
from ..security.command_risk_classifier import classify_command
command = str(args.get("command", "")).strip()
if not command:
return {"ok": False, "output": "Empty command."}
# --- Security validation pipeline ---
risk = classify_command(command, is_cowork_mode=ctx.flatten_writes)
if risk.blocked:
denial = "Command blocked by security policy: " + "; ".join(risk.reasons)
return {"ok": False, "output": denial}
# Route through SandboxManager for risk-based isolation
mgr = SandboxManager(ExecutionConfig(
enabled=True,
block_network_by_default=ctx.block_network,
is_cowork_mode=ctx.flatten_writes,
))
sandbox_result = mgr.run(
command=command,
workdir=str(ctx.workdir),
block_network=ctx.block_network,
timeout_sec=COMMAND_TIMEOUT,
cancel=cancel,
)
# Sandbox ALWAYS executes (never double-run). Return its result directly.
if sandbox_result.get("sandbox") == "blocked":
return {"ok": False, "output": sandbox_result.get("stderr", "Command blocked")}
out = sandbox_result.get("stdout", "").strip() or "(no output)"
err = sandbox_result.get("stderr", "")
rc = sandbox_result.get("returncode", -1)
if err:
out = f"{out}\n{err}" if out else err
return {"ok": sandbox_result.get("ok", False), "output": f"[exit {rc}]\n{out}"}
def _short_json(obj: Any, limit: int = 500) -> str:
"""Chuỗi JSON đã cắt ngắn để đưa vào log hoặc bong bóng chat, tránh nhấn chìm
màn hình bằng một kết quả dài.
"""
import json
text = json.dumps(obj, ensure_ascii=False, indent=2)
return text if len(text) <= limit else text[:limit] + " …"
+56
View File
@@ -0,0 +1,56 @@
"""Dựng câu nhắc cho AI phân tích mức dùng — R09-T02.
Chỉ sinh văn bản. Tách riêng vì đây là phần dễ đổi nhất (câu chữ, cột hiển
thị) và không liên quan tới việc ghi nhận hay tính tiền.
"""
from __future__ import annotations
import json
import threading
from datetime import date, datetime
from pathlib import Path
from typing import Any, Dict, List, Optional
from ..config import CONFIG_DIR
from . import model_pricing as mp
from .usage_periods import period_breakdown, period_range_label
_AI_ANALYSIS_HEADERS = {
"vi": ("Nhận xét thói quen", "Cách viết prompt tiết kiệm hơn", "Hành động giảm token"),
"en": ("Usage habits", "Writing more efficient prompts", "Actions to cut token usage"),
"ja": ("利用傾向", "より効率的なプロンプトの書き方", "トークン削減のためのアクション"),
}
def build_ai_analysis_prompt(summary: Dict[str, Any], language: str = "vi") -> str:
"""The prompt sent to the model for '✨ AI analyze my usage': aggregated
numbers only — never raw prompt contents — asking for concrete habits
feedback and token-saving recommendations, in the CURRENTLY SELECTED
display language (headers included — not just the model's free-text reply,
which would otherwise leave the section titles in Vietnamese regardless of
the app's language setting)."""
lang_names = {"vi": "Vietnamese", "ja": "Japanese", "en": "English"}
h1, h2, h3 = _AI_ANALYSIS_HEADERS.get(language, _AI_ANALYSIS_HEADERS["vi"])
top = "\n".join(f"- {label}: {tok:,} tokens"
for label, tok in summary.get("top_labels", []))
by_source = ", ".join(f"{k}={v:,}" for k, v in summary.get("by_source", []))
return (
"You are a token-efficiency coach for an AI desktop app (chat tabs + "
"scheduled agent tasks). Analyze this usage summary and give the user "
"practical advice, replying in "
f"{lang_names.get(language, 'Vietnamese')}.\n\n"
f"Period stats: {summary.get('turns', 0)} turns, "
f"input={summary.get('in', 0):,} tokens, output={summary.get('out', 0):,}, "
f"cache={summary.get('cache', 0):,}, "
f"avg per prompt={summary.get('avg_per_turn', 0):,}.\n"
f"Top consumers:\n{top or '- (none)'}\n"
f"By area: {by_source or '(none)'}\n"
f"Busiest day: {summary.get('busiest_day')} · busiest hour: {summary.get('busiest_hour')}\n\n"
"Reply with EXACTLY these 3 short sections, in markdown, using THESE "
f"section headers verbatim (already in {lang_names.get(language, 'Vietnamese')}):\n"
f"1. **{h1}** — 2-3 bullet points about the usage pattern.\n"
f"2. **{h2}** — 3 concrete prompt-writing tips "
"tailored to the numbers above (e.g. long inputs → attach less / summarize "
"first; many small turns → batch questions).\n"
f"3. **{h3}** — 2-3 app-level actions (compact history, "
"smaller model for simple tasks, reuse task outputs instead of re-asking).\n"
"Keep the whole reply under 250 words."
)
+102
View File
@@ -0,0 +1,102 @@
"""Bảng giá và quy đổi token thành tiền — R09-T02.
Tách khỏi ``usage_tracker.py``: ghi nhận mức dùng và tính tiền là hai việc
khác nhau. Bảng giá đổi theo nhà cung cấp, cách ghi nhận thì không.
"""
from __future__ import annotations
import json
import threading
from datetime import date, datetime
from pathlib import Path
from typing import Any, Dict, List, Optional
from ..config import CONFIG_DIR
from . import model_pricing as mp
DEFAULT_PRICING = {
"price_per_mtok_in_usd": 0.5, # USD per 1M input tokens (flat fallback rate)
"price_per_mtok_out_usd": 1.5, # USD per 1M output tokens
"price_per_mtok_cache_usd": 0.1, # USD per 1M cached tokens
"currency": "USD", # display currency: USD | VND | JPY
"usd_to_vnd": 25000.0,
"usd_to_jpy": 150.0,
# Per-model price table (USD / 1M tokens): {model: {"in","out","cache"}}.
# Events whose model has an entry are costed with ITS rates; everything
# else falls back to the flat price_per_mtok_* rates above. Edited in the
# Monitoring Overview's pricing table.
"model_prices": {},
# Reference URL of the price list the table was filled from (set in
# Settings; shown as a link beside the table — informational only, the
# app never scrapes it).
"pricing_url": "",
}
_CURRENCY_FMT = {"USD": ("$", 4), "VND": ("₫", 0), "JPY": ("¥", 1)}
SUPPORTED_CURRENCIES = tuple(_CURRENCY_FMT)
def cost_usd(summary: Dict[str, Any], pricing: Dict[str, Any]) -> Dict[str, float]:
"""Quy số token thành tiền (USD) theo bảng đơn giá, tách riêng vào/ra/cache."""
p = {**DEFAULT_PRICING, **(pricing or {})}
return {
"in": summary.get("in", 0) / 1e6 * float(p["price_per_mtok_in_usd"]),
"out": summary.get("out", 0) / 1e6 * float(p["price_per_mtok_out_usd"]),
"cache": summary.get("cache", 0) / 1e6 * float(p["price_per_mtok_cache_usd"]),
}
def cost_usd_events(events: List[Dict[str, Any]], pricing: Dict[str, Any]) -> Dict[str, float]:
"""Per-bucket USD cost computed EVENT BY EVENT so the per-model price
table applies: an event whose ``model`` has an entry in
``pricing["model_prices"]`` is costed with that model's own rates; any
other event uses the flat ``price_per_mtok_*`` rates. With an empty
table this equals ``cost_usd(summarize(events), pricing)`` exactly."""
p = {**DEFAULT_PRICING, **(pricing or {})}
table = p.get("model_prices") or {}
flat = {"in": float(p["price_per_mtok_in_usd"]),
"out": float(p["price_per_mtok_out_usd"]),
"cache": float(p["price_per_mtok_cache_usd"])}
out = {"in": 0.0, "out": 0.0, "cache": 0.0}
for e in events:
rates = table.get(e.get("model", "")) or {}
for bucket in ("in", "out", "cache"):
try:
rate = float(rates.get(bucket, flat[bucket]))
except (TypeError, ValueError):
rate = flat[bucket]
out[bucket] += e.get(bucket, 0) / 1e6 * rate
return out
def format_cost(usd: float, pricing: Dict[str, Any], digits: Optional[int] = None) -> str:
"""Format a USD amount in the display currency. ``digits`` caps the number
of decimal places (e.g. ``digits=2`` for the Total cost / Budget cards, so
USD shows $1.23 not the default up-to-4 $1.2345) — never ADDS decimals to a
currency that uses fewer (VND stays whole, JPY one place)."""
p = {**DEFAULT_PRICING, **(pricing or {})}
cur = p.get("currency", "USD")
rate = {"USD": 1.0, "VND": float(p["usd_to_vnd"]), "JPY": float(p["usd_to_jpy"])}.get(cur, 1.0)
symbol, cur_digits = _CURRENCY_FMT.get(cur, ("$", 2))
if digits is not None:
cur_digits = min(cur_digits, digits)
value = usd * rate
return f"{symbol}{value:,.{cur_digits}f}"
def format_cost_compact(usd: float, pricing: Dict[str, Any]) -> str:
"""Compact cost format for the Dashboard chart's y-axis/endpoint labels —
always 2 decimals (not format_cost's up-to-4 for USD) and abbreviated with
K/M above 1,000/1,000,000, same convention as ``fmt_tokens``. The chart's
y-axis label box is narrow; the longer full-precision string used to
overflow it, visually clipping/obscuring the leading currency symbol."""
p = {**DEFAULT_PRICING, **(pricing or {})}
cur = p.get("currency", "USD")
rate = {"USD": 1.0, "VND": float(p["usd_to_vnd"]), "JPY": float(p["usd_to_jpy"])}.get(cur, 1.0)
symbol, _digits = _CURRENCY_FMT.get(cur, ("$", 2))
value = usd * rate
sign = "-" if value < 0 else ""
value = abs(value)
if value >= 1_000_000:
body = f"{value / 1_000_000:,.2f}M"
elif value >= 1_000:
body = f"{value / 1_000:,.2f}K"
else:
body = f"{value:,.2f}"
return f"{sign}{symbol}{body}"
+147
View File
@@ -0,0 +1,147 @@
"""Gộp mức dùng theo khoảng thời gian — R09-T02.
Ngày / tuần / tháng / quý: ranh giới khoảng, nhãn hiển thị, chuỗi số vẽ biểu
đồ. Thuần tính toán trên danh sách sự kiện, không đụng đĩa.
"""
from __future__ import annotations
import json
import threading
from datetime import date, datetime
from pathlib import Path
from typing import Any, Dict, List, Optional
from ..config import CONFIG_DIR
from . import model_pricing as mp
from .usage_cost import cost_usd_events
def bucketed_series(events: List[Dict[str, Any]], granularity: str = "day",
pricing: Dict[str, Any] = None, last: int = None) -> List[tuple]:
"""Group usage events into time buckets → ordered ``[(label, tokens, cost_usd)]``.
``granularity``: ``day`` (YYYY-MM-DD) · ``month`` (YYYY-MM) · ``year`` (YYYY).
``last`` keeps only the most recent N buckets (for the dashboard chart)."""
from collections import OrderedDict
pricing = pricing or {}
def _key(ts: Any) -> str:
"""Khoá gom nhóm của một mốc thời gian theo độ mịn (tuần/tháng/năm)."""
s = str(ts or "")[:10]
if granularity == "year":
return s[:4]
if granularity == "month":
return s[:7]
return s
buckets: "OrderedDict[str, List[Dict[str, Any]]]" = OrderedDict()
for e in sorted(events, key=lambda ev: str(ev.get("ts", ""))):
k = _key(e.get("ts"))
if k:
buckets.setdefault(k, []).append(e)
out = []
for k, evs in buckets.items():
tokens = sum(int(e.get("in", 0) or 0) + int(e.get("out", 0) or 0)
+ int(e.get("cache", 0) or 0) for e in evs)
cost = sum(cost_usd_events(evs, pricing).values())
out.append((k, tokens, cost))
if last and len(out) > last:
out = out[-last:]
return out
def period_bounds(gran: str, offset: int, today: Optional[date] = None) -> tuple:
"""[start, end) dates of the period ``offset`` periods from the current one
(0 = current, -1 = the previous week/month/year). Weeks run Mon→Sun."""
from datetime import timedelta
today = today or date.today()
if gran == "week":
monday = today - timedelta(days=today.weekday()) # Monday of this week
start = monday + timedelta(weeks=offset)
return start, start + timedelta(days=7)
if gran == "year":
y = today.year + offset
return date(y, 1, 1), date(y + 1, 1, 1)
# month (default)
base = today.year * 12 + (today.month - 1) + offset
y, m = divmod(base, 12)
y2, m2 = divmod(base + 1, 12)
return date(y, m + 1, 1), date(y2, m2 + 1, 1)
def _period_label(gran: str, start: date) -> str:
"""Nhãn hiển thị của một kỳ: thứ Hai đầu tuần, YYYY-MM, hoặc năm."""
if gran == "week":
return start.isoformat() # the week's Monday (YYYY-MM-DD)
if gran == "year":
return str(start.year)
return start.strftime("%Y-%m")
def _sum_between(events: List[Dict[str, Any]], start: date, end: date,
pricing: Dict[str, Any]) -> tuple:
"""Tổng token và chi phí của các sự kiện trong khoảng ``[start, end)``."""
lo, hi = start.isoformat(), end.isoformat()
evs = [e for e in events if lo <= str(e.get("ts", ""))[:10] < hi]
tokens = sum(int(e.get("in", 0) or 0) + int(e.get("out", 0) or 0)
+ int(e.get("cache", 0) or 0) for e in evs)
cost = sum(cost_usd_events(evs, pricing).values()) if evs else 0.0
return tokens, cost
def period_totals(events: List[Dict[str, Any]], gran: str, pricing: Dict[str, Any],
offset: int = 0, today: Optional[date] = None) -> tuple:
"""(tokens, cost_usd) for the single period ``offset`` periods from now."""
start, end = period_bounds(gran, offset, today)
return _sum_between(events, start, end, pricing)
def period_window(events: List[Dict[str, Any]], gran: str, pricing: Dict[str, Any],
count: int, offset: int = 0, today: Optional[date] = None) -> List[tuple]:
"""``count`` consecutive, ZERO-FILLED periods ending at (current + offset),
ordered oldest→newest → ``[(label, tokens, cost_usd)]``. ``offset`` (≤ 0)
pages the window into the past for the Dashboard's prev/next navigation."""
out = []
for i in range(count - 1, -1, -1):
start, end = period_bounds(gran, offset - i, today)
tok, cost = _sum_between(events, start, end, pricing)
out.append((_period_label(gran, start), tok, cost))
return out
def period_breakdown(events: List[Dict[str, Any]], gran: str, pricing: Dict[str, Any],
offset: int = 0, today: Optional[date] = None) -> List[tuple]:
"""Break the SELECTED period (``offset`` periods from now) into its sub-parts
→ ``[(label, tokens, cost_usd)]``:
· week → 7 days Mon→Sun (label ``MM/DD``)
· month → weeks W1…Wn (7-day chunks from the 1st)
· year → 12 months (label ``01``…``12``)."""
from datetime import timedelta
start, end = period_bounds(gran, offset, today)
out = []
if gran == "week":
for i in range(7):
d = start + timedelta(days=i)
tok, cost = _sum_between(events, d, d + timedelta(days=1), pricing)
out.append((d.strftime("%m/%d"), tok, cost))
elif gran == "year":
for m in range(1, 13):
ms = date(start.year, m, 1)
me = date(start.year + 1, 1, 1) if m == 12 else date(start.year, m + 1, 1)
tok, cost = _sum_between(events, ms, me, pricing)
out.append((f"{m:02d}", tok, cost))
else: # month → weeks W1..Wn
ndays = (end - start).days
wk, day = 1, 1
while day <= ndays:
ws = date(start.year, start.month, day)
we = date(start.year, start.month, day + 7) if day + 7 <= ndays else end
tok, cost = _sum_between(events, ws, we, pricing)
out.append((f"W{wk}", tok, cost))
wk += 1
day += 7
return out
def period_range_label(gran: str, offset: int, today: Optional[date] = None) -> str:
"""Human label for the selected period (shown in the Dashboard header) —
week → MM/DD – MM/DD, month → YYYY/MM, year → YYYY."""
from datetime import timedelta
start, end = period_bounds(gran, offset, today)
if gran == "week":
last_day = end - timedelta(days=1)
return f"{start.strftime('%m/%d')} – {last_day.strftime('%m/%d')}"
if gran == "year":
return str(start.year)
return start.strftime("%Y/%m")
+29 -241
View File
@@ -12,6 +12,17 @@ The turn's source/label is set by the caller ON THE WORKER THREAD via
"""
from __future__ import annotations
# Giữ đường vào cũ: nhiều nơi import mấy tên này thẳng từ usage_tracker.
from .usage_ai_report import build_ai_analysis_prompt # noqa: F401
from .usage_cost import ( # noqa: F401
DEFAULT_PRICING, SUPPORTED_CURRENCIES, cost_usd, cost_usd_events,
format_cost, format_cost_compact,
)
from .usage_periods import ( # noqa: F401
bucketed_series, period_bounds, period_breakdown, period_range_label,
period_totals, period_window,
)
import json
import threading
from datetime import date, datetime
@@ -19,6 +30,7 @@ from pathlib import Path
from typing import Any, Dict, List, Optional
from ..config import CONFIG_DIR
from . import model_pricing as mp
USAGE_DIR = CONFIG_DIR / "usage"
@@ -49,6 +61,18 @@ def set_context(source: str, label: str = "") -> None:
_local.label = label
def current_context() -> tuple:
"""The ``(source, label)`` currently tagged on THIS thread.
Public counterpart to :func:`set_context`, added for
``infrastructure/telemetry/usage_sink.py``: a subscriber that needs to
attribute one event to a different surface must be able to save the
caller's context and put it back afterwards, instead of leaving the worker
thread permanently retagged.
"""
return getattr(_local, "source", "") or "", getattr(_local, "label", "") or ""
# ---- per-thread usage accumulator -----------------------------------------
# A step/run that wants to know its OWN token/cost (not the all-time file total)
# calls begin_accumulation(), reads accumulated() before/after a unit of work,
@@ -79,6 +103,11 @@ def end_accumulation() -> None:
def estimate_tokens(text: str) -> int:
"""Ước lượng số token của một đoạn văn bản theo tỉ lệ 4 ký tự ≈ 1 token.
Ước lượng thô là đủ: con số này chỉ dùng để quyết định khi nào nén lịch sử,
không dùng để tính tiền (tiền lấy từ số token thật provider trả về).
"""
return max(0, len(text or "") // 4)
@@ -191,198 +220,30 @@ def summarize(events: List[Dict[str, Any]]) -> Dict[str, Any]:
# ---- cost ------------------------------------------------------------------
DEFAULT_PRICING = {
"price_per_mtok_in_usd": 0.5, # USD per 1M input tokens (flat fallback rate)
"price_per_mtok_out_usd": 1.5, # USD per 1M output tokens
"price_per_mtok_cache_usd": 0.1, # USD per 1M cached tokens
"currency": "USD", # display currency: USD | VND | JPY
"usd_to_vnd": 25000.0,
"usd_to_jpy": 150.0,
# Per-model price table (USD / 1M tokens): {model: {"in","out","cache"}}.
# Events whose model has an entry are costed with ITS rates; everything
# else falls back to the flat price_per_mtok_* rates above. Edited in the
# Monitoring Overview's pricing table.
"model_prices": {},
# Reference URL of the price list the table was filled from (set in
# Settings; shown as a link beside the table — informational only, the
# app never scrapes it).
"pricing_url": "",
}
_CURRENCY_FMT = {"USD": ("$", 4), "VND": ("₫", 0), "JPY": ("¥", 1)}
# Currencies the display picker offers — exactly the ones format_cost() can
# actually convert to (symbol/precision above + a usd_to_* rate below).
SUPPORTED_CURRENCIES = tuple(_CURRENCY_FMT)
def cost_usd(summary: Dict[str, Any], pricing: Dict[str, Any]) -> Dict[str, float]:
p = {**DEFAULT_PRICING, **(pricing or {})}
return {
"in": summary.get("in", 0) / 1e6 * float(p["price_per_mtok_in_usd"]),
"out": summary.get("out", 0) / 1e6 * float(p["price_per_mtok_out_usd"]),
"cache": summary.get("cache", 0) / 1e6 * float(p["price_per_mtok_cache_usd"]),
}
def cost_usd_events(events: List[Dict[str, Any]], pricing: Dict[str, Any]) -> Dict[str, float]:
"""Per-bucket USD cost computed EVENT BY EVENT so the per-model price
table applies: an event whose ``model`` has an entry in
``pricing["model_prices"]`` is costed with that model's own rates; any
other event uses the flat ``price_per_mtok_*`` rates. With an empty
table this equals ``cost_usd(summarize(events), pricing)`` exactly."""
p = {**DEFAULT_PRICING, **(pricing or {})}
table = p.get("model_prices") or {}
flat = {"in": float(p["price_per_mtok_in_usd"]),
"out": float(p["price_per_mtok_out_usd"]),
"cache": float(p["price_per_mtok_cache_usd"])}
out = {"in": 0.0, "out": 0.0, "cache": 0.0}
for e in events:
rates = table.get(e.get("model", "")) or {}
for bucket in ("in", "out", "cache"):
try:
rate = float(rates.get(bucket, flat[bucket]))
except (TypeError, ValueError):
rate = flat[bucket]
out[bucket] += e.get(bucket, 0) / 1e6 * rate
return out
def bucketed_series(events: List[Dict[str, Any]], granularity: str = "day",
pricing: Dict[str, Any] = None, last: int = None) -> List[tuple]:
"""Group usage events into time buckets → ordered ``[(label, tokens, cost_usd)]``.
``granularity``: ``day`` (YYYY-MM-DD) · ``month`` (YYYY-MM) · ``year`` (YYYY).
``last`` keeps only the most recent N buckets (for the dashboard chart)."""
from collections import OrderedDict
pricing = pricing or {}
def _key(ts: Any) -> str:
s = str(ts or "")[:10]
if granularity == "year":
return s[:4]
if granularity == "month":
return s[:7]
return s
buckets: "OrderedDict[str, List[Dict[str, Any]]]" = OrderedDict()
for e in sorted(events, key=lambda ev: str(ev.get("ts", ""))):
k = _key(e.get("ts"))
if k:
buckets.setdefault(k, []).append(e)
out = []
for k, evs in buckets.items():
tokens = sum(int(e.get("in", 0) or 0) + int(e.get("out", 0) or 0)
+ int(e.get("cache", 0) or 0) for e in evs)
cost = sum(cost_usd_events(evs, pricing).values())
out.append((k, tokens, cost))
if last and len(out) > last:
out = out[-last:]
return out
def period_bounds(gran: str, offset: int, today: Optional[date] = None) -> tuple:
"""[start, end) dates of the period ``offset`` periods from the current one
(0 = current, -1 = the previous week/month/year). Weeks run Mon→Sun."""
from datetime import timedelta
today = today or date.today()
if gran == "week":
monday = today - timedelta(days=today.weekday()) # Monday of this week
start = monday + timedelta(weeks=offset)
return start, start + timedelta(days=7)
if gran == "year":
y = today.year + offset
return date(y, 1, 1), date(y + 1, 1, 1)
# month (default)
base = today.year * 12 + (today.month - 1) + offset
y, m = divmod(base, 12)
y2, m2 = divmod(base + 1, 12)
return date(y, m + 1, 1), date(y2, m2 + 1, 1)
def _period_label(gran: str, start: date) -> str:
if gran == "week":
return start.isoformat() # the week's Monday (YYYY-MM-DD)
if gran == "year":
return str(start.year)
return start.strftime("%Y-%m")
def _sum_between(events: List[Dict[str, Any]], start: date, end: date,
pricing: Dict[str, Any]) -> tuple:
lo, hi = start.isoformat(), end.isoformat()
evs = [e for e in events if lo <= str(e.get("ts", ""))[:10] < hi]
tokens = sum(int(e.get("in", 0) or 0) + int(e.get("out", 0) or 0)
+ int(e.get("cache", 0) or 0) for e in evs)
cost = sum(cost_usd_events(evs, pricing).values()) if evs else 0.0
return tokens, cost
def period_totals(events: List[Dict[str, Any]], gran: str, pricing: Dict[str, Any],
offset: int = 0, today: Optional[date] = None) -> tuple:
"""(tokens, cost_usd) for the single period ``offset`` periods from now."""
start, end = period_bounds(gran, offset, today)
return _sum_between(events, start, end, pricing)
def period_window(events: List[Dict[str, Any]], gran: str, pricing: Dict[str, Any],
count: int, offset: int = 0, today: Optional[date] = None) -> List[tuple]:
"""``count`` consecutive, ZERO-FILLED periods ending at (current + offset),
ordered oldest→newest → ``[(label, tokens, cost_usd)]``. ``offset`` (≤ 0)
pages the window into the past for the Dashboard's prev/next navigation."""
out = []
for i in range(count - 1, -1, -1):
start, end = period_bounds(gran, offset - i, today)
tok, cost = _sum_between(events, start, end, pricing)
out.append((_period_label(gran, start), tok, cost))
return out
def period_breakdown(events: List[Dict[str, Any]], gran: str, pricing: Dict[str, Any],
offset: int = 0, today: Optional[date] = None) -> List[tuple]:
"""Break the SELECTED period (``offset`` periods from now) into its sub-parts
→ ``[(label, tokens, cost_usd)]``:
· week → 7 days Mon→Sun (label ``MM/DD``)
· month → weeks W1…Wn (7-day chunks from the 1st)
· year → 12 months (label ``01``…``12``)."""
from datetime import timedelta
start, end = period_bounds(gran, offset, today)
out = []
if gran == "week":
for i in range(7):
d = start + timedelta(days=i)
tok, cost = _sum_between(events, d, d + timedelta(days=1), pricing)
out.append((d.strftime("%m/%d"), tok, cost))
elif gran == "year":
for m in range(1, 13):
ms = date(start.year, m, 1)
me = date(start.year + 1, 1, 1) if m == 12 else date(start.year, m + 1, 1)
tok, cost = _sum_between(events, ms, me, pricing)
out.append((f"{m:02d}", tok, cost))
else: # month → weeks W1..Wn
ndays = (end - start).days
wk, day = 1, 1
while day <= ndays:
ws = date(start.year, start.month, day)
we = date(start.year, start.month, day + 7) if day + 7 <= ndays else end
tok, cost = _sum_between(events, ws, we, pricing)
out.append((f"W{wk}", tok, cost))
wk += 1
day += 7
return out
def period_range_label(gran: str, offset: int, today: Optional[date] = None) -> str:
"""Human label for the selected period (shown in the Dashboard header) —
week → MM/DD – MM/DD, month → YYYY/MM, year → YYYY."""
from datetime import timedelta
start, end = period_bounds(gran, offset, today)
if gran == "week":
last_day = end - timedelta(days=1)
return f"{start.strftime('%m/%d')} – {last_day.strftime('%m/%d')}"
if gran == "year":
return str(start.year)
return start.strftime("%Y/%m")
def set_budget(config, amount: float, currency: Optional[str] = None) -> None:
@@ -397,7 +258,6 @@ def set_budget(config, amount: float, currency: Optional[str] = None) -> None:
order and ``budget_set_at`` only has 1-second resolution, so a timestamp
cutoff could mis-include/exclude an event recorded in that same second —
the count baseline is exact regardless of timing."""
from . import model_pricing as mp
usage = config.data.setdefault("usage", {})
ccy = (currency or usage.get("currency") or "USD").upper()
usage["budget_amount_usd"] = mp.convert(float(amount or 0), ccy, "USD", config)
@@ -442,83 +302,11 @@ def budget_status(config) -> Optional[Dict[str, Any]]:
}
def format_cost(usd: float, pricing: Dict[str, Any], digits: Optional[int] = None) -> str:
"""Format a USD amount in the display currency. ``digits`` caps the number
of decimal places (e.g. ``digits=2`` for the Total cost / Budget cards, so
USD shows $1.23 not the default up-to-4 $1.2345) — never ADDS decimals to a
currency that uses fewer (VND stays whole, JPY one place)."""
p = {**DEFAULT_PRICING, **(pricing or {})}
cur = p.get("currency", "USD")
rate = {"USD": 1.0, "VND": float(p["usd_to_vnd"]), "JPY": float(p["usd_to_jpy"])}.get(cur, 1.0)
symbol, cur_digits = _CURRENCY_FMT.get(cur, ("$", 2))
if digits is not None:
cur_digits = min(cur_digits, digits)
value = usd * rate
return f"{symbol}{value:,.{cur_digits}f}"
def format_cost_compact(usd: float, pricing: Dict[str, Any]) -> str:
"""Compact cost format for the Dashboard chart's y-axis/endpoint labels —
always 2 decimals (not format_cost's up-to-4 for USD) and abbreviated with
K/M above 1,000/1,000,000, same convention as ``fmt_tokens``. The chart's
y-axis label box is narrow; the longer full-precision string used to
overflow it, visually clipping/obscuring the leading currency symbol."""
p = {**DEFAULT_PRICING, **(pricing or {})}
cur = p.get("currency", "USD")
rate = {"USD": 1.0, "VND": float(p["usd_to_vnd"]), "JPY": float(p["usd_to_jpy"])}.get(cur, 1.0)
symbol, _digits = _CURRENCY_FMT.get(cur, ("$", 2))
value = usd * rate
sign = "-" if value < 0 else ""
value = abs(value)
if value >= 1_000_000:
body = f"{value / 1_000_000:,.2f}M"
elif value >= 1_000:
body = f"{value / 1_000:,.2f}K"
else:
body = f"{value:,.2f}"
return f"{sign}{symbol}{body}"
_AI_ANALYSIS_HEADERS = {
"vi": ("Nhận xét thói quen", "Cách viết prompt tiết kiệm hơn", "Hành động giảm token"),
"en": ("Usage habits", "Writing more efficient prompts", "Actions to cut token usage"),
"ja": ("利用傾向", "より効率的なプロンプトの書き方", "トークン削減のためのアクション"),
}
def build_ai_analysis_prompt(summary: Dict[str, Any], language: str = "vi") -> str:
"""The prompt sent to the model for '✨ AI analyze my usage': aggregated
numbers only — never raw prompt contents — asking for concrete habits
feedback and token-saving recommendations, in the CURRENTLY SELECTED
display language (headers included — not just the model's free-text reply,
which would otherwise leave the section titles in Vietnamese regardless of
the app's language setting)."""
lang_names = {"vi": "Vietnamese", "ja": "Japanese", "en": "English"}
h1, h2, h3 = _AI_ANALYSIS_HEADERS.get(language, _AI_ANALYSIS_HEADERS["vi"])
top = "\n".join(f"- {label}: {tok:,} tokens"
for label, tok in summary.get("top_labels", []))
by_source = ", ".join(f"{k}={v:,}" for k, v in summary.get("by_source", []))
return (
"You are a token-efficiency coach for an AI desktop app (chat tabs + "
"scheduled agent tasks). Analyze this usage summary and give the user "
"practical advice, replying in "
f"{lang_names.get(language, 'Vietnamese')}.\n\n"
f"Period stats: {summary.get('turns', 0)} turns, "
f"input={summary.get('in', 0):,} tokens, output={summary.get('out', 0):,}, "
f"cache={summary.get('cache', 0):,}, "
f"avg per prompt={summary.get('avg_per_turn', 0):,}.\n"
f"Top consumers:\n{top or '- (none)'}\n"
f"By area: {by_source or '(none)'}\n"
f"Busiest day: {summary.get('busiest_day')} · busiest hour: {summary.get('busiest_hour')}\n\n"
"Reply with EXACTLY these 3 short sections, in markdown, using THESE "
f"section headers verbatim (already in {lang_names.get(language, 'Vietnamese')}):\n"
f"1. **{h1}** — 2-3 bullet points about the usage pattern.\n"
f"2. **{h2}** — 3 concrete prompt-writing tips "
"tailored to the numbers above (e.g. long inputs → attach less / summarize "
"first; many small turns → batch questions).\n"
f"3. **{h3}** — 2-3 app-level actions (compact history, "
"smaller model for simple tasks, reuse task outputs instead of re-asking).\n"
"Keep the whole reply under 250 words."
)
+1
View File
@@ -158,6 +158,7 @@ class WindowsSandboxVM:
pass
def _error(self, message: str) -> Dict[str, Any]:
"""Kết quả lỗi theo đúng khuôn chung của bộ chạy sandbox."""
return {
"ok": False,
"stdout": "",
+24
View File
@@ -18,12 +18,23 @@ Job = Callable[["AgentWorker"], Optional[Dict[str, Any]]]
class AgentWorker(QThread):
"""Luồng nền chạy một lượt agent, nối kết quả về giao diện qua signal Qt.
Mọi việc chậm (gọi model, chạy tool, trích tệp) đều phải nằm trong đây —
chạy ở luồng giao diện là cả cửa sổ đứng hình.
"""
event = Signal(dict) # streaming/agent events
permission_requested = Signal(dict) # confirm-mode tool action awaiting approval
finished_ok = Signal(dict) # job completed
failed = Signal(str) # job raised
def __init__(self, job: Job, parent=None):
"""Bọc một hàm thành luồng nền.
``stop_event`` để công khai vì provider cần truyền thẳng nó vào
``Event.wait()`` — nhờ vậy bấm Dừng là dừng ngay, không phải đợi hết lượt
chờ mạng hiện tại.
"""
super().__init__(parent)
self._job = job
self.stop_event = threading.Event() # public for provider Event.wait() — immediate Stop
@@ -31,12 +42,15 @@ class AgentWorker(QThread):
# -- helpers used from inside the job (worker thread) --------------
def is_cancelled(self) -> bool:
"""``True`` khi người dùng đã bấm Dừng — job phải tự thoát sớm."""
return self.stop_event.is_set()
def emit_event(self, ev: Dict[str, Any]) -> None:
"""Đẩy một sự kiện tiến độ về giao diện."""
self.event.emit(ev)
def new_gate(self, mode: str, agent_role: str = "") -> PermissionGate:
"""Dựng cổng phê duyệt cho lượt này (chế độ hỏi trước khi chạy tool)."""
self.gate = PermissionGate(
mode, on_request=lambda action: self.permission_requested.emit(action),
agent_role=agent_role,
@@ -45,16 +59,26 @@ class AgentWorker(QThread):
# -- control from the UI thread -----------------------------------
def request_stop(self) -> None:
"""Yêu cầu dừng: bật cờ huỷ và giải phóng cổng phê duyệt đang chờ.
Phải huỷ cả cổng, nếu không job sẽ kẹt mãi ở chỗ chờ người dùng bấm Đồng ý.
"""
self.stop_event.set()
if self.gate:
self.gate.cancel()
def resolve_permission(self, approved: bool) -> None:
"""Trả lời một yêu cầu phê duyệt tool đang chờ."""
if self.gate:
self.gate.resolve(approved)
# -- thread body ---------------------------------------------------
def run(self) -> None: # noqa: D401
"""Thân luồng: chạy job rồi phát ``finished_ok``, lỗi thì phát ``failed``.
Bắt mọi ngoại lệ: một lỗi lọt ra khỏi đây sẽ giết luồng mà giao diện không
nhận được tín hiệu nào — người dùng thấy nút Dừng quay mãi.
"""
try:
result = self._job(self)
self.finished_ok.emit(result or {})
+1
View File
@@ -33,6 +33,7 @@ def _openpyxl():
def is_available() -> bool:
"""Máy đã cài ``openpyxl`` chưa — không có thì mọi tính năng Excel tắt."""
return _openpyxl() is not None