Hồi quy đã vá
-------------
F-12 Kéo–thả hoặc dán tệp vào ô chat ném NameError. R08 tách `_Input` sang
`chat_input_box.py` nhưng để `_paths_from_mime()` ở lại
`composer_widget.py`, nên hai hàm sự kiện Qt gọi một cái tên không tồn
tại. Bốn hàm dùng chung chuyển sang `composer_mime.py` — module thứ ba
là chỗ duy nhất không lặp lại được lỗi này. Đo lại: cả thả lẫn dán đều
gắn 1 tệp, khớp bản trước refactor.
F-01 Đổi provider thì bộ chọn model AI-Edit không làm gì. Hook cũ kiểm
`folder.ai_model_combo`, thuộc tính R08-T12 đã dời sang
`ai_panel.resolver`. Làm mới vô điều kiện, đúng như tab cũ: lần lấy đầu
tiên hỏng thì đổi provider chính là lúc phải thử lại.
F-07 Hàng chọn kỳ của Dashboard bị đẩy xuống dưới các thẻ số liệu. Hàng này
lọc CẢ BA thẻ con chứ không riêng biểu đồ, nên để nó nằm dưới là bắt
người dùng đọc con số trước khi thấy con số đó tính cho kỳ nào. Kèm
theo: `TokenUsageCardWidget` bị bỏ sót `setContentsMargins(0,0,0,0)`
mà hai thẻ con còn lại đã có, đẩy cả hàng thẻ lệch 9px.
`check_layout_geometry` nay khớp TỪNG BYTE với bản trước refactor.
F-11 Hai lớp khai trùng tên phương thức; Python giữ bản sau nên bản đầu là
mã chết. `co4e_tab.py::showEvent` bản đầu gọi `_narrow_guard.attach()`
và không bao giờ chạy.
Tách file (F-09)
----------------
Bốn file chạm trần 400 dòng, mỗi lần cắt ra một trách nhiệm thật:
graph_renderer.py -> graph_scene_builder.py + graph_export.py
co4e_workflow_service.py -> co4e_run_history.py
json_config_repository.py -> config_sections.py
agents_admin_tab.py -> shared/agent_kind_visuals.py
File cuối còn xoá 3 bản sao của hàm đã có trong `shared/formatters.py`,
giống hệt đến từng dòng — nay định dạng thời gian và avatar không lệch nhau
giữa các bảng Giám sát nữa.
Docstring
---------
41,6% -> 100% (3.478/3.478 định nghĩa production), kể cả module dormant và
phương thức dunder. Toàn bộ phần bổ sung viết bằng tiếng Việt; comment tiếng
Anh có sẵn giữ nguyên — dịch ngược là một đợt riêng.
Seam chưa nối dây (F-05)
------------------------
9 seam mang nhãn `SEAM · dựng <ngày>` kèm hai câu: được nối khi nào, và để
dormant thì hỏng gì. Ngày lấy từ lịch sử git, không phải hạn tự đặt. Gate O
đọc nhãn đó và nhắc khi quá 30 ngày.
859 test xanh · 4/4 cổng CASAN · 19/24 checker khớp từng byte bản cũ.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
296 lines
13 KiB
Python
296 lines
13 KiB
Python
"""Sandboxed file/command tools used by the Code agent.
|
|
|
|
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 difflib
|
|
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
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Tool specs advertised to the model
|
|
# --------------------------------------------------------------------------
|
|
TOOL_SPECS: List[ToolSpec] = [
|
|
ToolSpec(
|
|
name="read_file",
|
|
description="Read the contents of a text file in the working folder.",
|
|
parameters={
|
|
"type": "object",
|
|
"properties": {"path": {"type": "string", "description": "Relative path"}},
|
|
"required": ["path"],
|
|
},
|
|
),
|
|
ToolSpec(
|
|
name="list_dir",
|
|
description="List files and subfolders at a path (defaults to the workdir root).",
|
|
parameters={
|
|
"type": "object",
|
|
"properties": {"path": {"type": "string", "description": "Relative path, default '.'"}},
|
|
},
|
|
),
|
|
ToolSpec(
|
|
name="write_file",
|
|
description=("Create a NEW file or fully rewrite one. Creates parent folders if needed. "
|
|
"For small changes to an existing file, prefer edit_file."),
|
|
parameters={
|
|
"type": "object",
|
|
"properties": {
|
|
"path": {"type": "string"},
|
|
"content": {"type": "string", "description": "Full file content"},
|
|
},
|
|
"required": ["path", "content"],
|
|
},
|
|
),
|
|
ToolSpec(
|
|
name="edit_file",
|
|
description=("Make a precise in-place edit to an EXISTING file by replacing an exact "
|
|
"snippet — preferred over write_file for small changes. 'old_string' must "
|
|
"match the file byte-for-byte (include enough surrounding context to be "
|
|
"unique). Set 'replace_all' to replace every occurrence."),
|
|
parameters={
|
|
"type": "object",
|
|
"properties": {
|
|
"path": {"type": "string", "description": "Relative path to an existing file"},
|
|
"old_string": {"type": "string", "description": "Exact text to find (with context)"},
|
|
"new_string": {"type": "string", "description": "Replacement text"},
|
|
"replace_all": {"type": "boolean", "description": "Replace all occurrences (default false)"},
|
|
},
|
|
"required": ["path", "old_string", "new_string"],
|
|
},
|
|
),
|
|
ToolSpec(
|
|
name="run_command",
|
|
description="Run a shell command in the working folder and return stdout/stderr.",
|
|
parameters={
|
|
"type": "object",
|
|
"properties": {"command": {"type": "string", "description": "Command to run"}},
|
|
"required": ["command"],
|
|
},
|
|
),
|
|
ToolSpec(
|
|
name="install_package",
|
|
description=("Install a Python package (pip) into the app's environment so the task can "
|
|
"use it. Use this to add any missing library yourself — never ask the user "
|
|
"to install libraries by hand."),
|
|
parameters={
|
|
"type": "object",
|
|
"properties": {
|
|
"package": {"type": "string",
|
|
"description": "pip package spec, e.g. 'requests' or 'pandas==2.2.0'"},
|
|
},
|
|
"required": ["package"],
|
|
},
|
|
),
|
|
ToolSpec(
|
|
name="fetch_url",
|
|
description=("Fetch a web page or an online document by URL and return its text content. "
|
|
"Use this whenever the user shares a link or the task needs information from "
|
|
"the web. Supports normal http(s) pages, direct document links (PDF/Office — "
|
|
"parsed to text), SharePoint/OneDrive share links, and Jira issue links — a "
|
|
"pasted Jira URL is read via the connected Jira account automatically (no need "
|
|
"to ask for the issue key)."),
|
|
parameters={
|
|
"type": "object",
|
|
"properties": {"url": {"type": "string", "description": "The http(s) URL to fetch"}},
|
|
"required": ["url"],
|
|
},
|
|
),
|
|
ToolSpec(
|
|
name="jira_search",
|
|
description=("Search Jira issues with a JQL query and return a summary list. Use this to "
|
|
"read/gather info from Jira (e.g. 'project = ABX AND status = \"In Progress\"'). "
|
|
"Read-only."),
|
|
parameters={
|
|
"type": "object",
|
|
"properties": {
|
|
"jql": {"type": "string", "description": "Jira Query Language expression"},
|
|
"max_results": {"type": "integer", "description": "Max issues to return (default 25)"},
|
|
},
|
|
"required": ["jql"],
|
|
},
|
|
),
|
|
ToolSpec(
|
|
name="jira_get_issue",
|
|
description="Read one Jira issue's details (summary, status, assignee, description) by key, e.g. ABX-123.",
|
|
parameters={
|
|
"type": "object",
|
|
"properties": {"key": {"type": "string", "description": "Issue key, e.g. ABX-123"}},
|
|
"required": ["key"],
|
|
},
|
|
),
|
|
]
|
|
|
|
# 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 →
|
|
Tools (``config.tools_disabled``). Passing None (or a config without the
|
|
field) returns them all — unchanged from before this governance layer."""
|
|
disabled = set(getattr(security_config, "tools_disabled", None) or [])
|
|
if not disabled:
|
|
return list(TOOL_SPECS)
|
|
return [t for t in TOOL_SPECS if t.name not in disabled]
|
|
|
|
|
|
def combine_tool_sources(*sources):
|
|
"""Merge several ``(tools, executor)`` pairs — e.g. codebase-memory tools
|
|
plus ``AppContext.build_mcp_tools`` (which since the MCP upgrade already
|
|
includes MS365 via the built-in server) — into the ONE ``extra_tools``/
|
|
``extra_executor`` pair ``run_cowork``/``run_code`` accept. A source
|
|
with no tools or no executor is skipped."""
|
|
all_tools: List[ToolSpec] = []
|
|
routing: Dict[str, Callable] = {}
|
|
for tools, executor in sources:
|
|
if not tools or executor is None:
|
|
continue
|
|
for spec in tools:
|
|
all_tools.append(spec)
|
|
routing[spec.name] = executor
|
|
if not all_tools:
|
|
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}"}
|
|
return executor(name, args)
|
|
|
|
return all_tools, combined_executor
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Preview (for the permission dialog) and execution
|
|
# --------------------------------------------------------------------------
|
|
def describe_action(ctx: ToolContext, name: str, args: Dict[str, Any]) -> Dict[str, str]:
|
|
"""Return a human preview of a proposed tool call."""
|
|
if name == "run_command":
|
|
return {"kind": "command", "title": "Run command", "text": str(args.get("command", ""))}
|
|
if name == "fetch_url":
|
|
return {"kind": "info", "title": "Fetch URL", "text": str(args.get("url", ""))}
|
|
if name == "jira_search":
|
|
return {"kind": "info", "title": "Jira search", "text": str(args.get("jql", ""))}
|
|
if name == "jira_get_issue":
|
|
return {"kind": "info", "title": "Jira read issue", "text": str(args.get("key", ""))}
|
|
if name == "install_package":
|
|
return {"kind": "command", "title": "Install Python package",
|
|
"text": f"pip install {args.get('package', '')}"}
|
|
if name == "write_file":
|
|
path = str(args.get("path", ""))
|
|
new = str(args.get("content", ""))
|
|
old = ""
|
|
try:
|
|
target = ctx.resolve(path)
|
|
if target.exists():
|
|
old = target.read_text(encoding="utf-8", errors="replace")
|
|
except (ToolError, OSError):
|
|
pass
|
|
diff = "".join(difflib.unified_diff(
|
|
old.splitlines(keepends=True), new.splitlines(keepends=True),
|
|
fromfile=f"a/{path}", tofile=f"b/{path}",
|
|
)) or f"(new file) {path}\n\n{new[:2000]}"
|
|
verb = "Overwrite" if old else "Create file"
|
|
return {"kind": "diff", "title": f"{verb}: {path}", "text": diff}
|
|
if name == "edit_file":
|
|
path = str(args.get("path", ""))
|
|
old_s = str(args.get("old_string", ""))
|
|
new_s = str(args.get("new_string", ""))
|
|
replace_all = bool(args.get("replace_all", False))
|
|
before = after = ""
|
|
try:
|
|
target = ctx.resolve(path)
|
|
if target.exists():
|
|
before = target.read_text(encoding="utf-8", errors="replace")
|
|
except (ToolError, OSError):
|
|
pass
|
|
if old_s and old_s in before:
|
|
after = before.replace(old_s, new_s) if replace_all else before.replace(old_s, new_s, 1)
|
|
diff = "".join(difflib.unified_diff(
|
|
before.splitlines(keepends=True), after.splitlines(keepends=True),
|
|
fromfile=f"a/{path}", tofile=f"b/{path}",
|
|
))
|
|
if not diff:
|
|
diff = f"Edit: {path}\n- {old_s[:1000]}\n+ {new_s[:1000]}"
|
|
return {"kind": "diff", "title": f"Edit: {path}", "text": diff}
|
|
return {"kind": "info", "title": name, "text": _short_json(args)}
|
|
|
|
|
|
def execute_tool(ctx: ToolContext, name: str, args: Dict[str, Any],
|
|
cancel: Optional[CancelFn] = None,
|
|
on_output: Optional[Callable[[str], None]] = None,
|
|
agent_role: str = "") -> Dict[str, Any]:
|
|
"""Run a tool and return ``{"ok": bool, "output": str}``.
|
|
|
|
``cancel`` is only used by the long-running tools (``run_command``,
|
|
``install_package``) so the Stop button can interrupt a running subprocess
|
|
instead of waiting for it to finish or time out. ``on_output``, likewise
|
|
only used by those two, streams live stdout/stderr lines as they arrive.
|
|
|
|
``agent_role`` tags the resulting audit-log entry (see ``audit_log.py`` /
|
|
``agent_roles.py``) — every call is recorded there regardless, this only
|
|
labels WHICH agent role made it."""
|
|
from . import audit_log
|
|
|
|
handler = _HANDLERS.get(name)
|
|
try:
|
|
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
|
|
result = {"ok": False, "output": f"Error running {name}: {exc}"}
|
|
audit_log.record("tool_call", name, bool(result.get("ok")),
|
|
str(result.get("output", ""))[:500], agent_role=agent_role)
|
|
return result
|
|
|
|
|
|
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] + " …"
|