Phase 2 of TODO-agent-2: one employee, one turn, real tools in the sandbox. - engine/prompt.py: the stable, cache-friendly prefix in the plan's order (rules → role protocol → toolkit → app profile → memory), a task brief capped at 1200 chars, role-specific stage context (spec for the developer, the diff only for the reviewer, test output for the tester) and a hard prompt cap that drops the oldest tool results first. - engine/tools.py: READ/WRITE/EDIT/LIST_DIR/SEARCH/RUN executed inside the sandbox through SandboxProviderPort, rooted at the worktree's sandbox path. Path escapes, .. and .git are refused; git push/remote/config are refused with "the platform does git"; RUN clamps its timeout (120s default, 600s max) and keeps the tail of its output; every execution writes a tool_calls row so the approvals UI shows real work. - engine/runner.py: the loop — model call, parse tags, execute tool tags, feed results back — until a terminal tag, the tool cap, the turn timeout or a cancellation. The employee's model is checked against ModelGrantPort before the first call (ungranted → error_kind=auth, no spend), errors are classified transient vs fatal, and every model call writes usage events through the one shared record_model_usage path (extracted from WorkforceService so manager chat counts too). Writes engine_turns, run_steps and tool_calls. - engine/events.py: RunFeed batches run.log (≤1 event / 500 ms / stream, ≤200 lines) and emits run.tool and task.gate through the generic envelope. - tests: engine_fakes.py (ScriptedModel, FakeSandbox, FakeGrants, RecordingMemory) plus 54 new tests in test_engine_runner, test_engine_prompt and test_engine_tools. Suite: 648 passed, ruff clean, mypy clean.
280 lines
10 KiB
Python
280 lines
10 KiB
Python
"""Executing the toolkit inside the sandbox — and refusing what it must.
|
|
|
|
Four rules, all enforced here rather than at the call sites:
|
|
|
|
1. **Path safety.** Every path is resolved against the worktree's sandbox root.
|
|
Absolute paths that escape it, ``..`` traversal and anything under ``.git``
|
|
are refused. A symlink that points outside the repository is not followed:
|
|
the sandbox is the security boundary, and this is the second lock.
|
|
2. **The AI never touches git.** ``git push``, ``git remote`` and ``git config``
|
|
are refused with a message that says why — the platform does git through
|
|
``RepoWorkspacePort`` (PLAN-v2 D6), never the model.
|
|
3. **Bounded commands.** ``RUN`` has a timeout (default 120 s, hard max 600 s)
|
|
and its output is capped to the last ``engine_tool_output_chars``
|
|
characters, which is what the model needs anyway: a traceback is at the end.
|
|
4. **Every call is recorded.** Each execution writes a ``tool_calls`` row with
|
|
``succeeded``/``failed``, so the approvals UI finally shows real executions
|
|
instead of intentions.
|
|
|
|
The sandbox is reached only through ``SandboxProviderPort``; there is no local
|
|
shell-out path.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import shlex
|
|
from dataclasses import dataclass
|
|
from pathlib import PurePosixPath
|
|
|
|
from app.application.engine.protocol import EditBlock, Tag, TagName, edit_blocks_of
|
|
from app.application.ports import CommandResult, SandboxProviderPort
|
|
from app.core.config import settings
|
|
from app.domain.workforce.tools import ToolKey
|
|
|
|
#: Commands the model may never run. The message is the point: a refusal the
|
|
#: model can read is a refusal it works around once.
|
|
_FORBIDDEN_GIT: tuple[tuple[str, str], ...] = (
|
|
("push", "git push"),
|
|
("remote", "git remote"),
|
|
("config", "git config"),
|
|
)
|
|
_GIT_REFUSAL = (
|
|
"دستور git رد شد: عملیات git را پلتفرم انجام میدهد، نه هوش مصنوعی."
|
|
)
|
|
|
|
#: RUN defaults and ceilings. A model asking for "no timeout" gets the ceiling.
|
|
RUN_DEFAULT_TIMEOUT = 120
|
|
RUN_MAX_TIMEOUT = 600
|
|
RUN_MAX_COMMAND_CHARS = 4000
|
|
|
|
#: Which protocol tag maps to which row of the existing tool_calls catalog.
|
|
_TOOL_KEY: dict[TagName, str] = {
|
|
TagName.READ_FILE: ToolKey.SANDBOX_READ_FILE,
|
|
TagName.WRITE_FILE: ToolKey.SANDBOX_WRITE_FILE,
|
|
TagName.EDIT_FILE: ToolKey.SANDBOX_WRITE_FILE,
|
|
TagName.LIST_DIR: ToolKey.SANDBOX_RUN_COMMAND,
|
|
TagName.SEARCH: ToolKey.SANDBOX_RUN_COMMAND,
|
|
TagName.RUN: ToolKey.SANDBOX_RUN_COMMAND,
|
|
}
|
|
|
|
|
|
class UnsafePathError(ValueError):
|
|
"""The requested path escapes the worktree."""
|
|
|
|
|
|
class GitRefusedError(ValueError):
|
|
"""The model tried to run git itself."""
|
|
|
|
|
|
@dataclass(slots=True, kw_only=True)
|
|
class ToolOutcome:
|
|
"""One tool execution: what happened, in a form the model can read."""
|
|
|
|
tool: str
|
|
ok: bool
|
|
output: str
|
|
refused: bool = False
|
|
#: The row key this call was recorded under, for the approvals UI.
|
|
tool_key: str = ""
|
|
tool_call_id: str = ""
|
|
|
|
|
|
def safe_join(root: str, relative: str) -> str:
|
|
"""Resolve ``relative`` inside ``root`` or raise :class:`UnsafePathError`.
|
|
|
|
Pure and synchronous on purpose: it is also the unit under test, and a path
|
|
bug must be reproducible without a container.
|
|
"""
|
|
if not relative or not relative.strip():
|
|
raise UnsafePathError("مسیر خالی است.")
|
|
candidate = relative.strip()
|
|
if candidate.startswith(("/", "~")):
|
|
raise UnsafePathError(f"مسیر مطلق مجاز نیست: {candidate}")
|
|
parts = PurePosixPath(candidate).parts
|
|
if any(part == ".." for part in parts):
|
|
raise UnsafePathError(f"خروج از ریشهٔ مخزن مجاز نیست: {candidate}")
|
|
if parts and parts[0] == ".git":
|
|
raise UnsafePathError("دسترسی به .git مجاز نیست.")
|
|
base = PurePosixPath(root.rstrip("/") or "/")
|
|
return str(base / candidate.lstrip("/"))
|
|
|
|
|
|
def _tail(text: str, *, limit: int) -> str:
|
|
"""Keep the *last* ``limit`` characters: a traceback is at the end."""
|
|
if len(text) <= limit:
|
|
return text
|
|
return f"[… {len(text) - limit} کاراکتر ابتدایی حذف شد …]\n{text[-limit:]}"
|
|
|
|
|
|
def _clamp_timeout(raw: str) -> int:
|
|
try:
|
|
value = int(float(raw)) if raw else RUN_DEFAULT_TIMEOUT
|
|
except ValueError:
|
|
value = RUN_DEFAULT_TIMEOUT
|
|
return max(1, min(value, RUN_MAX_TIMEOUT))
|
|
|
|
|
|
def _check_command(command: str) -> None:
|
|
if len(command) > RUN_MAX_COMMAND_CHARS:
|
|
raise ValueError("دستور بیش از حد طولانی است.")
|
|
tokens = shlex.split(command, comments=False, posix=True)
|
|
if not tokens:
|
|
raise ValueError("دستور خالی است.")
|
|
if tokens[0] != "git":
|
|
return
|
|
for sub, label in _FORBIDDEN_GIT:
|
|
if sub in tokens[1:]:
|
|
raise GitRefusedError(f"{_GIT_REFUSAL} («{label}»)")
|
|
|
|
|
|
def _shell_quote(value: str) -> str:
|
|
return shlex.quote(value)
|
|
|
|
|
|
class ToolExecutor:
|
|
"""Runs one parsed tool tag inside the worktree's sandbox session."""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
sandbox: SandboxProviderPort,
|
|
session_external_id: str,
|
|
root: str,
|
|
max_output_chars: int | None = None,
|
|
) -> None:
|
|
self.sandbox = sandbox
|
|
self.session_external_id = session_external_id
|
|
self.root = root
|
|
self.max_output_chars = (
|
|
settings.engine_tool_output_chars if max_output_chars is None else max_output_chars
|
|
)
|
|
|
|
async def execute(self, tag: Tag) -> ToolOutcome:
|
|
"""Execute one tool tag. Never raises: failures come back as output."""
|
|
handler = _HANDLERS.get(tag.name)
|
|
if handler is None: # pragma: no cover - the matrix forbids it
|
|
return ToolOutcome(
|
|
tool=tag.name.value, ok=False, output="این ابزار در این نقش مجاز نیست."
|
|
)
|
|
try:
|
|
output = await handler(self, tag)
|
|
except UnsafePathError as exc:
|
|
return ToolOutcome(
|
|
tool=tag.name.value, ok=False, output=str(exc), refused=True
|
|
)
|
|
except GitRefusedError as exc:
|
|
return ToolOutcome(
|
|
tool=tag.name.value, ok=False, output=str(exc), refused=True
|
|
)
|
|
except FileNotFoundError as exc:
|
|
return ToolOutcome(tool=tag.name.value, ok=False, output=str(exc))
|
|
except OSError as exc:
|
|
return ToolOutcome(
|
|
tool=tag.name.value, ok=False, output=f"خطای سندباکس: {exc}"
|
|
)
|
|
except ValueError as exc:
|
|
return ToolOutcome(tool=tag.name.value, ok=False, output=str(exc))
|
|
return ToolOutcome(
|
|
tool=tag.name.value, ok=True, output=_tail(output, limit=self.max_output_chars)
|
|
)
|
|
|
|
# -- sandbox primitives -------------------------------------------------
|
|
|
|
async def _run(self, command: str, *, timeout: int) -> str:
|
|
_check_command(command)
|
|
result: CommandResult = await self.sandbox.run_command(
|
|
session_external_id=self.session_external_id,
|
|
command=command,
|
|
timeout_seconds=timeout,
|
|
max_output_bytes=settings.engine_tool_output_chars * 4,
|
|
)
|
|
text = result.stdout or ""
|
|
if result.stderr:
|
|
text = f"{text}\n{result.stderr}" if text else result.stderr
|
|
return f"$ {command}\n(exit {result.exit_code})\n{text}".strip()
|
|
|
|
async def _read(self, path: str) -> str:
|
|
return await self.sandbox.read_file(
|
|
session_external_id=self.session_external_id, path=safe_join(self.root, path)
|
|
)
|
|
|
|
async def _write(self, path: str, content: str) -> None:
|
|
await self.sandbox.write_file(
|
|
session_external_id=self.session_external_id,
|
|
path=safe_join(self.root, path),
|
|
content=content,
|
|
)
|
|
|
|
# -- the six tools ------------------------------------------------------
|
|
|
|
async def _read_file(self, tag: Tag) -> str:
|
|
path = tag.require("path")
|
|
content = await self._read(path)
|
|
return f"# {path}\n{content}"
|
|
|
|
async def _write_file(self, tag: Tag) -> str:
|
|
path = tag.require("path")
|
|
await self._write(path, tag.body)
|
|
return f"نوشته شد: {path} ({len(tag.body)} کاراکتر)"
|
|
|
|
async def _edit_file(self, tag: Tag) -> str:
|
|
path = tag.require("path")
|
|
blocks: list[EditBlock] = edit_blocks_of(tag)
|
|
content = await self._read(path)
|
|
for block in blocks:
|
|
occurrences = content.count(block.old)
|
|
if occurrences != 1:
|
|
raise ValueError(
|
|
f"بلوک قدیمی باید دقیقاً یک بار پیدا شود؛ {occurrences} بار پیدا شد."
|
|
)
|
|
content = content.replace(block.old, block.new, 1)
|
|
await self._write(path, content)
|
|
return f"ویرایش شد: {path} ({len(blocks)} بلوک)"
|
|
|
|
async def _list_dir(self, tag: Tag) -> str:
|
|
path = tag.attr("path", ".") or "."
|
|
target = safe_join(self.root, path)
|
|
return await self._run(f"ls -la -- {_shell_quote(target)}", timeout=30)
|
|
|
|
async def _search(self, tag: Tag) -> str:
|
|
pattern = tag.require("pattern")
|
|
path = tag.attr("path", ".") or "."
|
|
target = safe_join(self.root, path)
|
|
command = f"grep -RIn -e {_shell_quote(pattern)} -- {_shell_quote(target)}"
|
|
# grep exits 1 when nothing matched, which is an answer, not a failure.
|
|
return await self._run(command, timeout=60)
|
|
|
|
async def _run_command(self, tag: Tag) -> str:
|
|
command = tag.attr("cmd", "") or tag.body.strip()
|
|
if not command:
|
|
raise ValueError("دستور خالی است.")
|
|
timeout = _clamp_timeout(tag.attr("timeout", ""))
|
|
return await self._run(command, timeout=timeout)
|
|
|
|
|
|
_HANDLERS = {
|
|
TagName.READ_FILE: ToolExecutor._read_file,
|
|
TagName.WRITE_FILE: ToolExecutor._write_file,
|
|
TagName.EDIT_FILE: ToolExecutor._edit_file,
|
|
TagName.LIST_DIR: ToolExecutor._list_dir,
|
|
TagName.SEARCH: ToolExecutor._search,
|
|
TagName.RUN: ToolExecutor._run_command,
|
|
}
|
|
|
|
|
|
def tool_key_for(tag: Tag) -> str:
|
|
"""The ``tool_calls`` row key a protocol tag maps to."""
|
|
return _TOOL_KEY.get(tag.name, ToolKey.SANDBOX_RUN_COMMAND)
|
|
|
|
|
|
__all__ = [
|
|
"GitRefusedError",
|
|
"RUN_DEFAULT_TIMEOUT",
|
|
"RUN_MAX_TIMEOUT",
|
|
"ToolExecutor",
|
|
"ToolOutcome",
|
|
"UnsafePathError",
|
|
"safe_join",
|
|
"tool_key_for",
|
|
]
|