Batch of fixes for defects tracked in "Task Tracking Template.xlsx" (sheet Defect Management), verified against the sheet's Root Cause/Cach xu ly columns before this commit: - DF-002: Co4E node status not reflected after tab switch + missing edit-lock on running/done nodes (node_property_panel.py, co4e_runs.py, co4e_workflow_crud.py, co4e_canvas_widget.py, co4e_flow_tabs.py, canvas_items.py) - DF-003: hide the run.bat console window unless the app exits with an error (run.bat, scripts/console_visibility.ps1 - new) - DF-004: floating Help Assistant icon covering the Send button after a window resize (presentation/shell/main_window.py) - DF-005: "block network" toggle didn't stop ICMP/raw-socket tools like ping (infrastructure/filesystem/command_tools.py, security/command_risk_classifier.py) - DF-006: Monitoring "gay nang khi log lon" - root cause was re-reading the ENTIRE audit log history every 3s tick, not missing pagination; bounded to a 30-day window (presentation/monitoring/monitoring_tab.py) AND added the "So dong/trang" page-size control the ticket also asked for (presentation/monitoring/shared/event_table.py, shared/filter_scaffold.py, tabs/action_logs_tab.py, tabs/mcp_tab.py, tabs/security_events_tab.py, i18n/agents_admin_tab.py) - DF-007: support choosing a OneDrive/SharePoint folder as a project's working directory via Microsoft Graph, downloaded as a local mirror with manual sync (core/projects.py, core/ms365_graph.py, core/cloud_workspace_sync.py - new, ui/ms365_signin_dialog.py - new, ui/cloud_folder_picker_dialog.py - new, i18n/cloud_workspace.py - new, ui/workspace_tab.py) - DF-008: AI-edit instruction box was a fixed-height single-line QLineEdit; replaced with an auto-expanding, Enter-to-send/Shift+Enter-newline input (presentation/folder/ai_file_editor_dialog.py) - DF-011: run_command failed with WinError 267 for a project whose per-turn output directory had never been created (application/conversations/core_runtime_adapter.py) DF-009 (AI-edit Apply/Discard buttons easy to miss) and DF-010 (AI reply language - dev-confirmed not a bug) are intentionally NOT part of this commit: DF-009 has no code fix yet (still "Assigned" in the sheet, only a UX recommendation was recorded), DF-010 was rejected as expected behavior. Tests: tests/test_cloud_workspace_sync.py, tests/test_ms365_cloud_dialogs.py, tests/test_ai_file_editor_input.py, tests/test_monitoring_page_size.py (all new, all passing). Full suite: 896 passed, 13 known-and-documented failures unrelated to this change (an existing core/audit_log.py bug, this checkout not being a git repo before now, and a repo/subprocess folder-naming mismatch affecting ~66 characterization tests) - see the sheet's DF-006 Evidence column for details.
346 lines
16 KiB
Python
346 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)
|
|
# Every sandboxed tool (run_command included) gets this as its cwd —
|
|
# it must exist BEFORE the first tool call, same as the older
|
|
# run_cowork() (core/chat_agent.py) already does at its output_dir.
|
|
# Without this, a per-turn ".turns/<id>" folder that was never created
|
|
# makes run_command's subprocess.Popen(cwd=...) fail immediately with
|
|
# WinError 267 ("directory name is invalid") before the command even
|
|
# starts — no network, no output, just an opaque OS error.
|
|
self._output_dir.mkdir(parents=True, exist_ok=True)
|
|
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",
|
|
]
|