Files
smart-org/apps/api/app/application/engine/tools.py
T
mjalmoudy94 c6488bfadf feat(engine): the turn runner — prompt builder, sandbox tools, agent loop
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.
2026-10-14 16:32:52 +03:30

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",
]