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>
338 lines
16 KiB
Python
338 lines
16 KiB
Python
"""Wires :class:`ConversationApplicationService` to the existing runtime (R04-T03).
|
|
|
|
The service is written against the narrow seams in :mod:`turn_runtime` so it can
|
|
be tested with plain fakes. This module supplies the real implementations — the
|
|
provider call with its recovery pass, the tool/sandbox runtime, the security
|
|
guards, context compaction — and is therefore the ONLY file in
|
|
``application/conversations/`` that knows ``core/*`` exists. Same shape (and
|
|
same reason) as ``application/model_routing/core_routing_adapter.py`` in R03.
|
|
|
|
Every ``core`` import is deferred into a method body: importing the tool runtime
|
|
pulls in ``requests``, ``psutil`` and the sandbox stack, and code that merely
|
|
*builds* a service must not pay for that.
|
|
|
|
Faithfulness notes — two places where this reproduces a quirk of the current
|
|
runtime rather than the behaviour one would design fresh. Both are marked
|
|
inline: the MS365 system-prompt paragraph keys off the CONFIGURED extra tools
|
|
(not the advertised subset), and the ``tool_result`` path falls back to the
|
|
call's own ``path`` argument resolved against the workdir.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
|
|
|
|
from ...domain.agents.agent_event import PlanStep, ToolPreview
|
|
from .conversation_application_service import ConversationApplicationService
|
|
from .turn_runtime import PLAN_TOOL, EventSink
|
|
|
|
# Legacy emit: the dict-based callback every current caller already owns.
|
|
LegacyEmit = Callable[[Dict[str, Any]], None]
|
|
|
|
|
|
def legacy_event_sink(emit: LegacyEmit) -> EventSink:
|
|
"""Adapt a typed :class:`EventSink` onto the legacy dict ``emit``.
|
|
|
|
This is what lets R04 land without touching the presentation layer: the
|
|
service thinks in typed events, ``ui/chat_panel.py::_on_event`` keeps
|
|
receiving exactly the dicts it already dispatches on. Deleted in R08 once
|
|
the widget consumes events directly.
|
|
"""
|
|
return lambda event: emit(event.to_legacy_dict())
|
|
|
|
|
|
class CoreModelCall:
|
|
""":class:`ModelCallPort` over ``code_agent._call_provider_with_recovery``.
|
|
|
|
Not ``provider.chat`` directly: the recovery wrapper adds the one bounded
|
|
retry that hides a dropped connection or a momentarily unreachable gateway,
|
|
and losing it would be a visible regression on flaky corporate networks.
|
|
"""
|
|
|
|
def __init__(self, provider: Any) -> None:
|
|
"""Bọc một provider của ``core/`` vào cổng ``ModelCallPort``."""
|
|
self._provider = provider
|
|
|
|
def call(self, messages, tools, on_text=None, on_reasoning=None, cancel=None):
|
|
"""Gọi model một lượt, có tự phục hồi khi tràn context hoặc bị giới hạn tốc độ."""
|
|
from ...core.code_agent import _call_provider_with_recovery
|
|
|
|
return _call_provider_with_recovery(self._provider, messages, tools, on_text,
|
|
cancel, on_reasoning)
|
|
|
|
|
|
class CoreToolRuntime:
|
|
""":class:`ToolRuntimePort` over ``core/tools.py`` + Cowork's file tools."""
|
|
|
|
def __init__(self, output_dir: Path, *, title: str = "",
|
|
extra_tools: Optional[Sequence[Any]] = None, extra_executor=None,
|
|
security_config: Any = None, agent_role: str = "") -> None:
|
|
"""Bọc bộ tool của ``core/`` vào cổng ``ToolRuntimePort``.
|
|
|
|
Tên các tool phụ được gom sẵn vào một ``set`` ngay tại đây: mỗi lượt gọi tool
|
|
đều phải tra tên, tra trên danh sách sẽ chậm dần theo số tool.
|
|
"""
|
|
self._output_dir = Path(output_dir)
|
|
self._title = title
|
|
self._extra_tools = list(extra_tools or ())
|
|
self._extra_names = {getattr(t, "name", "") for t in self._extra_tools}
|
|
# The connector executor MCP/REST tools are routed to; None when the
|
|
# turn has no connectors enabled.
|
|
self._extra_executor = extra_executor
|
|
self._security_config = security_config
|
|
self._agent_role = agent_role
|
|
self._ctx: Any = None # built on first use (see _tool_context)
|
|
|
|
# -- the configured extra tools, for the system-prompt hints ---------- #
|
|
@property
|
|
def extra_names(self) -> frozenset:
|
|
"""Tên các tool bổ sung (MCP, connector) ngoài bộ dựng sẵn."""
|
|
return frozenset(self._extra_names)
|
|
|
|
def _tool_context(self):
|
|
"""The sandboxed ``ToolContext`` every built-in tool call runs inside.
|
|
|
|
Built once per turn and cached: it carries the resource limits and the
|
|
network policy, so re-deriving it mid-turn could let a Settings change
|
|
take effect halfway through work already in flight.
|
|
"""
|
|
if self._ctx is None:
|
|
from ...core import agent_security
|
|
from ...core.tools import ToolContext
|
|
|
|
limits, block_network = agent_security.sandbox_settings(self._security_config)
|
|
self._ctx = ToolContext(
|
|
self._output_dir, flatten_writes=True, # keep every file in the Output root
|
|
resource_limits=limits, block_network=block_network,
|
|
allow_url_fetch=agent_security.url_fetch_allowed(self._security_config),
|
|
jira=(self._security_config.data.get("jira") if self._security_config else None),
|
|
)
|
|
return self._ctx
|
|
|
|
# -- ToolRuntimePort -------------------------------------------------- #
|
|
def specs(self, allowed_tools: Optional[Sequence[str]] = None) -> List[Any]:
|
|
"""Advertised tools: Cowork's own two, the enabled built-ins, then MCP.
|
|
|
|
``allowed_tools`` restricts the list so a read-only step literally cannot
|
|
write. ``update_plan`` and the connector tools always survive the filter:
|
|
the plan tool has no side effects, and connectors are opted into
|
|
explicitly rather than governed by the built-in capability scope.
|
|
"""
|
|
from ...core.chat_agent import SAVE_FILE_SPEC
|
|
from ...core.plan import UPDATE_PLAN_SPEC
|
|
from ...core.tools import enabled_tool_specs
|
|
|
|
specs = ([SAVE_FILE_SPEC, UPDATE_PLAN_SPEC]
|
|
+ list(enabled_tool_specs(self._security_config))
|
|
+ self._extra_tools)
|
|
if allowed_tools is None:
|
|
return specs
|
|
allow = set(allowed_tools) | {PLAN_TOOL} | self._extra_names
|
|
return [t for t in specs if getattr(t, "name", "") in allow]
|
|
|
|
def preview(self, name: str, args: Dict[str, Any]) -> Optional[ToolPreview]:
|
|
"""What the user sees before the call runs."""
|
|
# A connector call has no local diff to show, so it renders as the plain
|
|
# argument dump the runtime already used.
|
|
if name in self._extra_names:
|
|
return ToolPreview(kind="info", title=name, text=str(args))
|
|
if name == "save_file":
|
|
return self._save_file_preview(args)
|
|
from ...core.tools import describe_action
|
|
|
|
raw = describe_action(self._tool_context(), name, args)
|
|
return ToolPreview.from_dict(raw)
|
|
|
|
def _save_file_preview(self, args: Dict[str, Any]) -> ToolPreview:
|
|
"""A before/after diff for the file the agent is about to write.
|
|
|
|
A brand-new file renders all-green (before is empty); an overwrite shows
|
|
the real change, so saving a file reads like editing one.
|
|
"""
|
|
import difflib
|
|
|
|
from ...core.chat_agent import _structure_summary, _titled_filename
|
|
|
|
fname = _titled_filename(self._title, args.get("filename", "output.txt"))
|
|
content = str(args.get("content", ""))
|
|
summary = _structure_summary(fname, content)
|
|
old = ""
|
|
existing = self._output_dir / fname
|
|
if existing.exists():
|
|
try:
|
|
old = existing.read_text(encoding="utf-8", errors="replace")
|
|
except OSError:
|
|
pass # unreadable existing file: show it as a fresh write
|
|
diff = "".join(difflib.unified_diff(
|
|
old.splitlines(keepends=True), content.splitlines(keepends=True),
|
|
fromfile=f"a/{fname}", tofile=f"b/{fname}",
|
|
)) or content[:4000]
|
|
return ToolPreview(kind="diff", title=f"Save {fname}",
|
|
text=f"{summary}\n\n{diff[:4000]}")
|
|
|
|
def execute(self, name: str, args: Dict[str, Any], on_output=None,
|
|
cancel=None) -> Dict[str, Any]:
|
|
"""Run one tool call and return the runtime's result mapping."""
|
|
if name == PLAN_TOOL:
|
|
return self._execute_plan(args)
|
|
if name in self._extra_names and self._extra_executor is not None:
|
|
# Connector results carry no local file, so no path/produced keys —
|
|
# matching what the runtime reports for an MCP call today.
|
|
result = self._extra_executor(name, args) or {}
|
|
return {"ok": bool(result.get("ok", False)), "output": result.get("output", "")}
|
|
if name == "save_file":
|
|
from ...core.chat_agent import _do_save_file
|
|
|
|
return dict(_do_save_file(self._output_dir, self._title, args))
|
|
|
|
from ...core.tools import execute_tool
|
|
|
|
ctx = self._tool_context()
|
|
result = dict(execute_tool(ctx, name, args, cancel=cancel, on_output=on_output,
|
|
agent_role=self._agent_role))
|
|
# Quirk preserved: a tool that wrote the file named in its OWN arguments
|
|
# (write_file/edit_file) does not report a path, so the runtime derives
|
|
# one from the argument. Dropping this would empty the Output list.
|
|
if not result.get("path") and isinstance(args, dict) and args.get("path"):
|
|
result["path"] = str(ctx.workdir / str(args["path"]))
|
|
return result
|
|
|
|
def _execute_plan(self, args: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Apply an ``update_plan`` call: validate the steps and audit them.
|
|
|
|
Produces no file and no chat bubble; the service turns the returned
|
|
steps into a single plan event.
|
|
"""
|
|
from ...core import agent_roles, audit_log
|
|
from ...core.plan import normalize_plan_steps
|
|
|
|
steps = normalize_plan_steps(args.get("steps"))
|
|
audit_log.record("tool_call", PLAN_TOOL, True, f"{len(steps)} step(s)",
|
|
agent_role=agent_roles.PLANNER)
|
|
return {"ok": True, "output": "Plan updated.",
|
|
"plan_steps": [PlanStep(title=s["title"], status=s["status"]) for s in steps]}
|
|
|
|
def snapshot(self) -> Any:
|
|
"""Ảnh chụp thư mục kết quả trước lượt chạy — dùng để biết tệp nào mới sinh ra."""
|
|
from ...core.tools import _snapshot
|
|
|
|
return _snapshot(self._output_dir)
|
|
|
|
def finalize(self, before: Any, cancelled: bool = False
|
|
) -> Tuple[List[str], List[str]]:
|
|
"""Drop the scratch sandbox and flatten deliverables into the root.
|
|
|
|
Returns ``(gone, arrived)``: a file that MOVED counts as both, because
|
|
the Output list keys entries by path and must drop the old one.
|
|
"""
|
|
from ...core.chat_agent import _cleanup_cowork_intermediates
|
|
|
|
removed, moved = _cleanup_cowork_intermediates(self._output_dir, before,
|
|
cancelled=cancelled)
|
|
gone = list(removed) + [old for old, _new in moved]
|
|
arrived = [new for _old, new in moved]
|
|
return gone, arrived
|
|
|
|
|
|
def build_cowork_conversation_service(
|
|
provider: Any,
|
|
output_dir: Path,
|
|
emit: LegacyEmit,
|
|
*,
|
|
title: str = "",
|
|
project_context: str = "",
|
|
extra_tools: Optional[Sequence[Any]] = None,
|
|
extra_executor=None,
|
|
security_config: Any = None,
|
|
gate: Any = None,
|
|
agent_role: str = "",
|
|
) -> ConversationApplicationService:
|
|
"""A service wired to the real runtime, ready to execute a Cowork turn.
|
|
|
|
``emit`` is the legacy dict callback: the guards and the compactor publish
|
|
their own notices through it directly (exactly as they do now), while the
|
|
service's typed events reach it via :func:`legacy_event_sink`.
|
|
|
|
``gate`` present means the workspace asked to confirm commands; pass the
|
|
request with ``gate_mode="confirm"`` so the two agree. A gate of ``None``
|
|
keeps the pre-existing auto-run behaviour.
|
|
"""
|
|
from ...core import agent_roles
|
|
|
|
tools = CoreToolRuntime(
|
|
output_dir, title=title, extra_tools=extra_tools, extra_executor=extra_executor,
|
|
security_config=security_config, agent_role=agent_role or agent_roles.COWORK,
|
|
)
|
|
|
|
def prepare_prompt(messages: List[Dict[str, Any]], advertised: Tuple[str, ...]) -> None:
|
|
"""Insert the system prompt, then fold in skills, rules and project text.
|
|
|
|
``advertised`` is unused on purpose: the runtime decides the MS365
|
|
paragraph from the CONFIGURED connector tools, not from the subset a
|
|
capability scope left advertised. Changing that changes the prompt the
|
|
model sees, so it stays as-is here and belongs to R05's tool-policy work.
|
|
"""
|
|
from ...core.chat_agent import (
|
|
COWORK_TOOL_PROMPT,
|
|
OPENDATALOADER_PDF_PROMPT,
|
|
_apply_project_context,
|
|
_apply_security_rules,
|
|
_apply_skills,
|
|
)
|
|
from ...core.deps import _can_pip
|
|
from ...core.java_runtime import find_java
|
|
from ...core.security_rules import load_rules
|
|
from ...core.skills import active_skills_text
|
|
|
|
if not messages or messages[0].get("role") != "system":
|
|
system = COWORK_TOOL_PROMPT
|
|
if any(n.startswith("ms365_") for n in tools.extra_names):
|
|
system += ("\nThe user has signed in to Microsoft 365 and enabled some ms365__* "
|
|
"tools (Outlook / Teams / OneDrive / SharePoint / meeting transcripts, "
|
|
"via the built-in MS365 MCP server). Use them whenever the request "
|
|
"involves that data — don't say you can't access it.")
|
|
if find_java() is not None and _can_pip():
|
|
# Only advertise the Java-backed PDF extractor when BOTH the JVM
|
|
# and pip are available, so the agent is never steered into a
|
|
# command that cannot work on this machine.
|
|
system += "\n\n" + OPENDATALOADER_PDF_PROMPT
|
|
messages.insert(0, {"role": "system", "content": system})
|
|
_apply_skills(messages, active_skills_text())
|
|
_apply_security_rules(messages, load_rules())
|
|
_apply_project_context(messages, project_context)
|
|
|
|
def prompt_guard(messages: List[Dict[str, Any]]) -> None:
|
|
"""Chốt an toàn cho prompt trước khi gửi: quét dấu hiệu tiêm lệnh."""
|
|
from ...core import agent_security
|
|
|
|
agent_security.enforce_prompt(provider, messages, security_config, emit)
|
|
|
|
def command_guard(name: str, args: Dict[str, Any]) -> None:
|
|
"""Chốt an toàn cho lệnh shell trước khi chạy: phân loại rủi ro và chặn/hỏi."""
|
|
from ...core import agent_security
|
|
|
|
agent_security.enforce_command(provider, name, args, security_config, emit)
|
|
|
|
def compact(messages: List[Dict[str, Any]], cancel) -> None:
|
|
"""Nén lịch sử hội thoại khi gần đầy cửa sổ ngữ cảnh."""
|
|
from ...core import context_budget
|
|
|
|
context_budget.maybe_compact(provider, messages, security_config,
|
|
emit=emit, cancel=cancel)
|
|
|
|
return ConversationApplicationService(
|
|
CoreModelCall(provider), tools,
|
|
prepare_prompt=prepare_prompt,
|
|
prompt_guard=prompt_guard,
|
|
command_guard=command_guard,
|
|
compact=compact,
|
|
permission_request=(gate.request if gate is not None else None),
|
|
)
|
|
|
|
|
|
__all__ = [
|
|
"LegacyEmit", "legacy_event_sink", "CoreModelCall", "CoreToolRuntime",
|
|
"build_cowork_conversation_service",
|
|
]
|