merge: merge origin/gamma/refactor and origin/feature/teamhoa/r05-r06 into feature/delta-team/epic-R04
This commit is contained in:
+12
-1
@@ -1 +1,12 @@
|
||||
"""application/ — Điều phối use-case. KHÔNG import PySide6. Gọi domain + interface hạ tầng."""
|
||||
"""Application layer - pure Python use-case orchestration.
|
||||
|
||||
Sits between ``presentation/`` (Qt widgets) and ``domain/`` (entities). A module
|
||||
here answers "what has to happen, in what order" for one use case - route a
|
||||
turn, run a conversation - without knowing whether a human, a scheduler or a
|
||||
test triggered it.
|
||||
|
||||
Hard rule (ADR-001 I1/I3, enforced by ``scripts/check_imports.py``): no
|
||||
PySide6/PyQt imports and no reach into ``presentation/``/``ui/``. Results travel
|
||||
back up through plain-Python callbacks; turning those into Qt signals is the
|
||||
presentation layer's job.
|
||||
"""
|
||||
|
||||
@@ -1 +1,12 @@
|
||||
"""Application conversations package: turn lifecycle orchestration and agent execution."""
|
||||
"""Application conversations package: turn lifecycle orchestration, agent execution, and tool approval policy."""
|
||||
|
||||
from .conversation_application_service import (
|
||||
ConversationApplicationService,
|
||||
)
|
||||
from .tool_policy_gateway import ConfirmGate, ToolPolicyGateway
|
||||
|
||||
__all__ = [
|
||||
"ConversationApplicationService",
|
||||
"ToolPolicyGateway",
|
||||
"ConfirmGate",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""ToolPolicyGateway - one confirm/deny decision path for every tool call
|
||||
(R05-T03).
|
||||
|
||||
Today "does this tool call need the user's OK first" is answered by a
|
||||
different hand-written check per engine:
|
||||
|
||||
* ``core/chat_agent.py::run_cowork`` — ``name in ("run_command",
|
||||
"install_package")``, a literal tuple.
|
||||
* ``core/code_agent.py::run_code`` — ``name in (WRITE_TOOLS | MS365_WRITE_TOOLS)``,
|
||||
a set built from two other hand-maintained sets.
|
||||
* MCP/connector tools (``core/mcp_client.py``, ``core/ext_connectors.py``) —
|
||||
no check at all; ``chat_agent.py`` calls ``extra_executor(name, args)``
|
||||
directly.
|
||||
|
||||
Three answers to the same question, and the third one is a real gap: an MCP
|
||||
tool that deletes files or calls an external API today runs with zero
|
||||
confirmation even when the user turned "confirm before running commands" on.
|
||||
|
||||
This gateway answers the question from data (:class:`~domain.tools.tool_descriptor.ToolCapability`
|
||||
via a :class:`~domain.tools.tool_registry.ToolRegistry`) instead of a literal
|
||||
name list, so registering a tool with the right capability is what gates it -
|
||||
nothing to remember at each new call site. R05-T04 is what actually registers
|
||||
MCP/connector tools with a capability; this module only needs the mechanism
|
||||
to exist.
|
||||
|
||||
Pure Python: no Qt, no direct dialog. The actual approval prompt stays exactly
|
||||
what it is today - a ``gate`` object with a ``.request(payload) -> bool``
|
||||
method, supplied by the presentation layer (Settings' "confirm before running
|
||||
commands" wires it up, or None for auto-run) - this module only decides
|
||||
WHEN to ask it, never how to render the question.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Optional, Protocol
|
||||
|
||||
from cowork_local.domain.tools import ToolCapability, ToolRegistry
|
||||
|
||||
|
||||
class ConfirmGate(Protocol):
|
||||
"""Shape of the existing ``PermissionGate`` both engines already use."""
|
||||
|
||||
def request(self, payload: Dict[str, Any]) -> bool: ...
|
||||
|
||||
|
||||
class ToolPolicyGateway:
|
||||
"""Decides whether a tool call needs approval, for ONE calling surface.
|
||||
|
||||
``gated_capabilities`` is what makes this per-surface: Cowork only ever
|
||||
asked about ``run_command``/``install_package`` (capability ``EXECUTE``),
|
||||
while the Code tab additionally confirms plain file writes (capability
|
||||
``WRITE``). Passing the wrong set here would silently change which tools
|
||||
prompt for approval - see the callers in ``core/chat_agent.py`` and
|
||||
``core/code_agent.py`` for the exact sets that preserve today's behavior.
|
||||
"""
|
||||
|
||||
def __init__(self, registry: ToolRegistry, gated_capabilities: ToolCapability) -> None:
|
||||
self._registry = registry
|
||||
self._gated_capabilities = gated_capabilities
|
||||
|
||||
def requires_confirmation(self, name: str) -> bool:
|
||||
"""True when ``name``'s declared capabilities overlap this surface's
|
||||
gated set. An unregistered tool never requires confirmation through
|
||||
this path - callers that must fail safe on unknown tools check
|
||||
``name in registry`` themselves (see R05-T04's MCP wrapping, which
|
||||
registers every tool it exposes before any call can reach here)."""
|
||||
return bool(self._registry.capabilities_for(name) & self._gated_capabilities)
|
||||
|
||||
def allow(self, name: str, gate: Optional[ConfirmGate], payload: Dict[str, Any]) -> bool:
|
||||
"""True when the call may proceed.
|
||||
|
||||
``gate is None`` preserves each engine's existing "no gate wired -
|
||||
auto-run" behavior; a tool outside ``gated_capabilities`` is never
|
||||
asked about, matching read-only tools "never confirm" today.
|
||||
``payload`` is whatever ``gate.request(...)`` already expects at that
|
||||
call site (the two engines use slightly different dict shapes) - this
|
||||
gateway only decides WHETHER to call it, never reshapes the payload.
|
||||
"""
|
||||
if gate is None or not self.requires_confirmation(name):
|
||||
return True
|
||||
return bool(gate.request(payload))
|
||||
|
||||
|
||||
__all__ = ["ToolPolicyGateway", "ConfirmGate"]
|
||||
@@ -1 +1,5 @@
|
||||
"""Application workspaces package: File workspace and AI file editor services."""
|
||||
"""Workspace file operations for non-agent-loop callers (EPIC R06)."""
|
||||
|
||||
from .file_workspace_service import FileWorkspaceService
|
||||
|
||||
__all__ = ["FileWorkspaceService"]
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""FileWorkspaceService - the safe file operations File Explorer and the AI
|
||||
File Editor need, outside the agent tool loop (R06-T05).
|
||||
|
||||
``ui/folder_tab.py`` (File Explorer) and the AI File Editor dialog need the
|
||||
exact same guarantees the agent's tools already have — path containment
|
||||
inside the workspace, precise context-anchored edits, syntax warnings on a
|
||||
bad Python write — but today that logic only exists wired to a model's tool
|
||||
call (``core/tools.py::execute_tool``). A UI action that isn't a tool call
|
||||
(browsing the tree, applying an AI-suggested diff from a review dialog) has
|
||||
no equivalent entry point of its own.
|
||||
|
||||
This service IS that entry point. It reuses ``core/tools.py::execute_tool``
|
||||
verbatim - same dispatch table, same ``ToolContext`` containment check, same
|
||||
audit-log entry, same Python-syntax warning on write/edit - rather than
|
||||
re-implementing any of it, so a fix to one path fixes both. It only adds the
|
||||
:class:`~domain.workspaces.workspace_session.WorkspaceSession` seam: which
|
||||
workspace root a call is scoped to is decided by the session, not by
|
||||
whichever folder a widget happens to have open.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
|
||||
class FileWorkspaceService:
|
||||
"""File operations scoped to one :class:`WorkspaceSession`.
|
||||
|
||||
Read-only by name (``list_tree``/``read_preview``) vs. writing
|
||||
(``write_file``/``apply_edit``) mirrors the same READ/WRITE split
|
||||
``domain/tools/tool_registry.py`` uses for the agent's own tools - a
|
||||
caller that only wants to browse never accidentally has write access.
|
||||
"""
|
||||
|
||||
def __init__(self, session) -> None: # WorkspaceSession - see module docstring
|
||||
self._session = session
|
||||
|
||||
def list_tree(self, rel: str = ".") -> Dict[str, Any]:
|
||||
"""Entries at ``rel`` (default: the workspace root)."""
|
||||
return self._execute("list_dir", {"path": rel})
|
||||
|
||||
def read_preview(self, rel: str) -> Dict[str, Any]:
|
||||
"""A text file's content (truncated by
|
||||
``infrastructure/filesystem/file_tools.py::MAX_READ_BYTES``, same as
|
||||
the agent's ``read_file`` tool)."""
|
||||
return self._execute("read_file", {"path": rel})
|
||||
|
||||
def write_file(self, rel: str, content: str) -> Dict[str, Any]:
|
||||
"""Create or fully overwrite ``rel``."""
|
||||
return self._execute("write_file", {"path": rel, "content": content})
|
||||
|
||||
def apply_edit(self, rel: str, old_string: str, new_string: str,
|
||||
replace_all: bool = False) -> Dict[str, Any]:
|
||||
"""Replace an exact snippet in an existing file - the same
|
||||
context-anchored algorithm the agent's ``edit_file`` tool uses, so an
|
||||
AI-suggested diff applies with the same precision and the same
|
||||
"old_string not found / ambiguous" failure messages either path
|
||||
would give the caller."""
|
||||
return self._execute("edit_file", {
|
||||
"path": rel, "old_string": old_string, "new_string": new_string,
|
||||
"replace_all": replace_all,
|
||||
})
|
||||
|
||||
# -- internals --------------------------------------------------------- #
|
||||
def _tool_context(self):
|
||||
"""A ``ToolContext`` scoped to this session's workspace root.
|
||||
``flatten_writes=False`` (unlike Cowork's agent context) - File
|
||||
Explorer must preserve whatever subfolder structure the user is
|
||||
actually browsing, not collapse every write into the root."""
|
||||
from cowork_local.infrastructure.filesystem.tool_context import ToolContext
|
||||
|
||||
return ToolContext(self._session.workspace_root, flatten_writes=False)
|
||||
|
||||
def _execute(self, name: str, args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Dispatch through ``core/tools.py::execute_tool`` - see the module
|
||||
docstring for why this delegates instead of reimplementing."""
|
||||
from cowork_local.core.tools import execute_tool
|
||||
|
||||
return execute_tool(self._tool_context(), name, args)
|
||||
|
||||
|
||||
__all__ = ["FileWorkspaceService"]
|
||||
Reference in New Issue
Block a user