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.
This commit is contained in:
mjalmoudy94
2026-10-14 16:32:52 +03:30
parent 9c0b911a00
commit c6488bfadf
13 changed files with 2712 additions and 19 deletions
+551
View File
@@ -0,0 +1,551 @@
"""The turn runner: one employee turn, with a scripted model and a fake sandbox.
What these tests defend:
* the write → run → report loop really works: a file is written inside the
sandbox, a command runs there, and the report ends the turn
* the sandbox is the boundary: a path escape, a ``.git`` path and a git
command are refused *inside the tool*, with a message the model can read
* a turn is bounded: the tool-call cap and the turn timeout both stop it
* errors are classified, so a rate limit is retried later and an ungranted
model is not
* every model call writes usage events — including the ones a manager chat
makes, which agybot forgot
* no secret from the settings ever reaches a prompt
"""
from __future__ import annotations
from types import SimpleNamespace
import pytest
import pytest_asyncio
from sqlalchemy import select
from app.application.engine.runner import (
TurnRequest,
TurnRunner,
classify_error,
)
from app.application.ports import CommandResult
from app.core.config import settings
from app.core.errors import ProviderUnavailableError
from app.domain.ai_control.models import AiModel
from app.domain.base import new_id
from app.domain.workforce.engine import ErrorKind
from app.domain.workforce.models import RoleKey
from app.domain.workforce.pipeline import Mode, Stage
from app.infrastructure.db.models import (
AiProviderRow,
EmployeeRow,
OrganizationRow,
RunRow,
TaskRow,
UsageEventRow,
UserRow,
)
from app.infrastructure.repositories.ai_control import (
AiModelRepository,
ModelCredentialRepository,
RateCardRepository,
)
from app.infrastructure.repositories.audit import UsageLedgerRepository
from app.infrastructure.repositories.engine import (
EngineTurnRepository,
PipelineStateRepository,
)
from app.infrastructure.repositories.workforce import (
EmployeeRepository,
RunRepository,
RunStepRepository,
TaskRepository,
ToolCallRepository,
)
from tests.engine_fakes import (
FakeGrants,
FakeSandbox,
RecordingMemory,
ScriptedModel,
ScriptedReply,
)
DEVELOPER = {
"role": RoleKey.DEVELOPER,
"mode": Mode.WORK,
"stage": Stage.PENDING_IMPLEMENTATION,
}
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest_asyncio.fixture
async def _db(session_factory, seeded):
"""A tenant, a task, a run, a developer employee and a granted model."""
async with session_factory() as session:
user = UserRow(email=f"runner-{new_id()[:8]}@example.com", password_hash="x")
session.add(user)
await session.flush()
org = OrganizationRow(name="سازمان اجرا", slug=f"run-{user.id[:6]}", owner_user_id=user.id)
session.add(org)
await session.flush()
# The seeded provider row: the model must hang off a real one.
provider_id = (await session.execute(select(AiProviderRow.id).limit(1))).scalar_one()
model = await AiModelRepository(session).create(
AiModel(
provider_id=provider_id,
internal_name="scripted-model",
display_name_for_admin="مدل فیلم‌نویسی‌شده",
capabilities=["text", "code"],
is_active=True,
)
)
task = TaskRow(organization_id=org.id, title="ساخت صفحهٔ ورود")
session.add(task)
await session.flush()
run = RunRow(task_id=task.id)
session.add(run)
employee = EmployeeRow(organization_id=org.id, name="سارا", role_key="developer")
session.add(employee)
await session.commit()
yield SimpleNamespace(
session_factory=session_factory,
organization_id=org.id,
task_id=task.id,
run_id=run.id,
employee_id=employee.id,
model_id=model.id,
)
def _runner(session, *, model, sandbox, grants, feed=None) -> TurnRunner:
return TurnRunner(
provider=model,
models=AiModelRepository(session),
credentials=ModelCredentialRepository(session),
rate_cards=RateCardRepository(session),
ledger=UsageLedgerRepository(session),
grants=grants,
sandbox=sandbox,
memory=RecordingMemory(context="این سازمان تست‌ها را با pytest اجرا می‌کند."),
turns=EngineTurnRepository(session),
run_steps=RunStepRepository(session),
tool_calls=ToolCallRepository(session),
runs=RunRepository(session),
states=PipelineStateRepository(session),
feed=feed,
)
async def _request(db, *, sandbox_root="/workspace", session_id="sess-1", **kwargs) -> TurnRequest:
async with db.session_factory() as session:
task = await TaskRepository(session).get(db.task_id)
employee = await EmployeeRepository(session).get(db.employee_id)
return TurnRequest(
organization_id=db.organization_id,
task=task,
run_id=db.run_id,
employee=employee,
model_id=db.model_id,
sandbox_root=sandbox_root,
session_external_id=session_id,
**kwargs,
)
# ---------------------------------------------------------------------------
# The loop
# ---------------------------------------------------------------------------
async def test_write_then_run_then_report_ends_the_turn(_db, session_factory):
"""The whole agent loop: one file, one command, one report."""
scripted = ScriptedModel(
[
ScriptedReply(
content='[[WRITE_FILE path="app/login.py"]]print("login")[[/WRITE_FILE]]'
),
ScriptedReply(content='[[RUN cmd="pytest -q" timeout="30"/]]'),
ScriptedReply(content="[[WORK_REPORT]]صفحهٔ ورود نوشده و تست‌ها سبز شد.[[/WORK_REPORT]]"),
]
)
sandbox = FakeSandbox(
commands={"pytest": [CommandResult(exit_code=0, stdout="3 passed")]},
)
async with session_factory() as session:
runner = _runner(
session, model=scripted, sandbox=sandbox, grants=FakeGrants([_db.model_id])
)
request = await _request(_db, **DEVELOPER)
result = await runner.run_turn(request)
await session.commit()
assert result.ok is True
assert result.model_calls == 3
assert result.tool_calls == 2
assert result.tags[0].name.value == "WORK_REPORT"
# The file really was written inside the sandbox, rooted at the worktree.
assert sandbox.writes == [{"path": "/workspace/app/login.py", "content": 'print("login")'}]
assert [call["command"] for call in sandbox.executed] == ["pytest -q"]
assert scripted.calls == 3
async def test_tool_results_feed_back_into_the_next_prompt(_db, session_factory):
"""The model must see what its own command printed."""
scripted = ScriptedModel(
[
ScriptedReply(content='[[RUN cmd="pytest -q"/]]'),
ScriptedReply(content="[[WORK_REPORT]]تمام[[/WORK_REPORT]]"),
]
)
sandbox = FakeSandbox(
commands={"pytest": [CommandResult(exit_code=1, stdout="1 failed", stderr="boom")]}
)
async with session_factory() as session:
runner = _runner(
session, model=scripted, sandbox=sandbox, grants=FakeGrants([_db.model_id])
)
result = await runner.run_turn(await _request(_db, **DEVELOPER))
await session.commit()
assert result.ok is True
last_prompt = scripted.seen[-1]
feedback = "\n".join(m.content for m in last_prompt["messages"])
assert "1 failed" in feedback and "boom" in feedback
async def test_each_tool_call_writes_a_tool_calls_row(_db, session_factory):
"""The approvals UI finally sees real executions, not intentions."""
scripted = ScriptedModel(
[
ScriptedReply(content='[[READ_FILE path="app/x.py"/]]'),
ScriptedReply(content="[[WORK_REPORT]]تمام[[/WORK_REPORT]]"),
]
)
sandbox = FakeSandbox(files={"/workspace/app/x.py": "print(1)"})
async with session_factory() as session:
runner = _runner(
session, model=scripted, sandbox=sandbox, grants=FakeGrants([_db.model_id])
)
await runner.run_turn(await _request(_db, **DEVELOPER))
await session.commit()
calls = await ToolCallRepository(session).list_for_run(_db.run_id)
assert len(calls) == 1
assert calls[0].tool_key == "sandbox.read_file"
assert calls[0].status.value == "succeeded"
assert calls[0].result_json["ok"] is True
async def test_a_failed_tool_is_recorded_as_failed(_db, session_factory):
scripted = ScriptedModel(
[
ScriptedReply(content='[[READ_FILE path="app/missing.py"/]]'),
ScriptedReply(content="[[WORK_REPORT]]تمام[[/WORK_REPORT]]"),
]
)
sandbox = FakeSandbox()
async with session_factory() as session:
runner = _runner(
session, model=scripted, sandbox=sandbox, grants=FakeGrants([_db.model_id])
)
result = await runner.run_turn(await _request(_db, **DEVELOPER))
await session.commit()
calls = await ToolCallRepository(session).list_for_run(_db.run_id)
assert result.ok is True
assert calls[0].status.value == "failed"
async def test_the_turn_leaves_an_engine_turns_row(_db, session_factory):
scripted = ScriptedModel([ScriptedReply(content="[[WORK_REPORT]]تمام[[/WORK_REPORT]]")])
async with session_factory() as session:
runner = _runner(
session, model=scripted, sandbox=FakeSandbox(), grants=FakeGrants([_db.model_id])
)
result = await runner.run_turn(await _request(_db, **DEVELOPER))
await session.commit()
turns = await EngineTurnRepository(session).list_for_task(_db.task_id)
assert result.turn_id
assert len(turns) == 1
assert turns[0].ok is True
assert turns[0].role is RoleKey.DEVELOPER
assert turns[0].mode is Mode.WORK
assert turns[0].stage is Stage.PENDING_IMPLEMENTATION
assert turns[0].prompt_chars > 0
assert "[[WORK_REPORT]]" in turns[0].reply_excerpt
assert "تمام" in turns[0].reply_excerpt
assert turns[0].tags == {"WORK_REPORT": 1}
assert turns[0].finished_at is not None
# ---------------------------------------------------------------------------
# The sandbox boundary
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"path",
["../../etc/passwd", "/etc/passwd", ".git/config", "app/../../secret"],
)
async def test_a_path_escape_is_refused(_db, session_factory, path):
scripted = ScriptedModel(
[
ScriptedReply(content=f'[[READ_FILE path="{path}"/]]'),
ScriptedReply(content="[[WORK_REPORT]]تمام[[/WORK_REPORT]]"),
]
)
sandbox = FakeSandbox(files={"/etc/passwd": "root"})
async with session_factory() as session:
runner = _runner(
session, model=scripted, sandbox=sandbox, grants=FakeGrants([_db.model_id])
)
result = await runner.run_turn(await _request(_db, **DEVELOPER))
await session.commit()
# The refusal is fed back to the model, and nothing was read.
assert result.ok is True
assert sandbox.reads == []
assert "مجاز نیست" in scripted.all_text()
@pytest.mark.parametrize(
"command", ["git push origin dev", "git remote add x y", "git config user.email a@b.c"]
)
async def test_the_model_cannot_run_git(_db, session_factory, command):
scripted = ScriptedModel(
[
ScriptedReply(content=f'[[RUN cmd="{command}"/]]'),
ScriptedReply(content="[[WORK_REPORT]]تمام[[/WORK_REPORT]]"),
]
)
sandbox = FakeSandbox()
async with session_factory() as session:
runner = _runner(
session, model=scripted, sandbox=sandbox, grants=FakeGrants([_db.model_id])
)
result = await runner.run_turn(await _request(_db, **DEVELOPER))
await session.commit()
assert result.ok is True
assert sandbox.executed == []
assert "پلتفرم" in scripted.all_text()
async def test_edit_file_applies_a_search_replace_block(_db, session_factory):
scripted = ScriptedModel(
[
ScriptedReply(
content=(
"[[EDIT_FILE path=\"app/x.py\"]]<<<OLD\nold line\n===\n"
"new line\nNEW>>>[[/EDIT_FILE]]"
)
),
ScriptedReply(content="[[WORK_REPORT]]تمام[[/WORK_REPORT]]"),
]
)
sandbox = FakeSandbox(files={"/workspace/app/x.py": "before\nold line\nafter"})
async with session_factory() as session:
runner = _runner(
session, model=scripted, sandbox=sandbox, grants=FakeGrants([_db.model_id])
)
result = await runner.run_turn(await _request(_db, **DEVELOPER))
await session.commit()
assert result.ok is True
assert sandbox.files["/workspace/app/x.py"] == "before\nnew line\nafter"
async def test_edit_file_fails_when_the_old_text_is_not_unique(_db, session_factory):
scripted = ScriptedModel(
[
ScriptedReply(
content=(
"[[EDIT_FILE path=\"app/x.py\"]]<<<OLD\nline\n===\n"
"other\nNEW>>>[[/EDIT_FILE]]"
)
),
ScriptedReply(content="[[WORK_REPORT]]تمام[[/WORK_REPORT]]"),
]
)
sandbox = FakeSandbox(files={"/workspace/app/x.py": "line\nline"})
async with session_factory() as session:
runner = _runner(
session, model=scripted, sandbox=sandbox, grants=FakeGrants([_db.model_id])
)
result = await runner.run_turn(await _request(_db, **DEVELOPER))
await session.commit()
assert result.ok is True
assert sandbox.files["/workspace/app/x.py"] == "line\nline"
assert "دقیقاً یک بار" in scripted.all_text()
# ---------------------------------------------------------------------------
# Bounds: the tool cap and the timeout
# ---------------------------------------------------------------------------
async def test_the_tool_call_cap_stops_a_runaway_turn(_db, session_factory, monkeypatch):
monkeypatch.setattr(settings, "engine_max_tool_calls_per_turn", 2)
scripted = ScriptedModel([ScriptedReply(content='[[LIST_DIR path="."/]]') for _ in range(5)])
sandbox = FakeSandbox()
async with session_factory() as session:
runner = _runner(
session, model=scripted, sandbox=sandbox, grants=FakeGrants([_db.model_id])
)
result = await runner.run_turn(await _request(_db, **DEVELOPER))
await session.commit()
assert result.ok is False
assert result.incomplete is True
assert result.tool_calls == 2
assert result.error_kind == "protocol"
async def test_the_turn_timeout_stops_a_slow_loop(_db, session_factory, monkeypatch):
monkeypatch.setattr(settings, "engine_turn_timeout_seconds", 0)
scripted = ScriptedModel([ScriptedReply(content='[[LIST_DIR path="."/]]')] * 3)
async with session_factory() as session:
runner = _runner(
session, model=scripted, sandbox=FakeSandbox(), grants=FakeGrants([_db.model_id])
)
result = await runner.run_turn(await _request(_db, **DEVELOPER))
await session.commit()
assert result.ok is False
assert result.error_kind == "timeout"
async def test_a_cancelled_run_stops_between_steps(_db, session_factory):
scripted = ScriptedModel(
[
ScriptedReply(content='[[LIST_DIR path="."/]]'),
ScriptedReply(content="[[WORK_REPORT]]تمام[[/WORK_REPORT]]"),
]
)
async with session_factory() as session:
runner = _runner(
session, model=scripted, sandbox=FakeSandbox(), grants=FakeGrants([_db.model_id])
)
request = await _request(_db, **DEVELOPER)
# The hook cancels after the first model call has been made.
calls = {"n": 0}
async def should_cancel() -> bool:
calls["n"] += 1
return calls["n"] > 1
request.should_cancel = should_cancel
result = await runner.run_turn(request)
await session.commit()
assert result.ok is False
assert result.error_kind == "cancelled"
# ---------------------------------------------------------------------------
# Errors: transient versus fatal
# ---------------------------------------------------------------------------
async def test_a_rate_limit_is_transient_and_reported(_db, session_factory):
scripted = ScriptedModel([ScriptedReply(raises=ProviderUnavailableError("429 rate limited"))])
async with session_factory() as session:
runner = _runner(
session, model=scripted, sandbox=FakeSandbox(), grants=FakeGrants([_db.model_id])
)
result = await runner.run_turn(await _request(_db, **DEVELOPER))
await session.commit()
assert result.ok is False
assert result.error_kind == "rate_limit"
assert result.retryable is True
async def test_an_ungranted_model_is_refused_before_any_call(_db, session_factory):
scripted = ScriptedModel([ScriptedReply(content="[[WORK_REPORT]]تمام[[/WORK_REPORT]]")])
grants = FakeGrants(["some-other-model"])
async with session_factory() as session:
runner = _runner(session, model=scripted, sandbox=FakeSandbox(), grants=grants)
result = await runner.run_turn(await _request(_db, **DEVELOPER))
await session.commit()
assert result.ok is False
assert result.error_kind == "auth"
assert result.retryable is False
# The model was never called: an ungranted employee spends nothing.
assert scripted.calls == 0
assert grants.checks == [_db.model_id]
def test_error_classification():
assert classify_error(ProviderUnavailableError("429 rate limit")) is ErrorKind.RATE_LIMIT
assert classify_error(ProviderUnavailableError("auth failed")) is ErrorKind.AUTH
assert classify_error(ProviderUnavailableError("connection refused")) is ErrorKind.NETWORK
assert classify_error(TimeoutError()) is ErrorKind.TIMEOUT
assert classify_error(ProviderUnavailableError("boom")) is ErrorKind.UNAVAILABLE
# ---------------------------------------------------------------------------
# Billing and secrets
# ---------------------------------------------------------------------------
async def test_every_model_call_writes_usage_events(_db, session_factory):
"""agybot forgot the manager chat; the engine does not."""
scripted = ScriptedModel(
[
ScriptedReply(content='[[LIST_DIR path="."/]]', input_tokens=11, output_tokens=7),
ScriptedReply(
content="[[WORK_REPORT]]تمام[[/WORK_REPORT]]", input_tokens=13, output_tokens=9
),
]
)
from sqlalchemy import select
async with session_factory() as session:
runner = _runner(
session, model=scripted, sandbox=FakeSandbox(), grants=FakeGrants([_db.model_id])
)
await runner.run_turn(await _request(_db, **DEVELOPER))
await session.commit()
rows = (
(await session.execute(select(UsageEventRow).where(UsageEventRow.run_id == _db.run_id)))
.scalars()
.all()
)
assert len(rows) == 4 # two calls, input and output each
assert sum(r.quantity for r in rows if r.unit == "model_input_tokens") == 24
assert sum(r.quantity for r in rows if r.unit == "model_output_tokens") == 16
async def test_no_secret_from_settings_reaches_the_model(_db, session_factory, monkeypatch):
"""The prompt is built by the engine; the settings stay on the server."""
monkeypatch.setattr(settings, "jwt_secret", "super-secret-jwt-value")
monkeypatch.setattr(settings, "database_url", "postgresql+asyncpg://u:p@h/db")
scripted = ScriptedModel(
[
ScriptedReply(content='[[READ_FILE path="app/x.py"/]]'),
ScriptedReply(content="[[WORK_REPORT]]تمام[[/WORK_REPORT]]"),
]
)
sandbox = FakeSandbox(files={"/workspace/app/x.py": "print(1)"})
async with session_factory() as session:
runner = _runner(
session, model=scripted, sandbox=sandbox, grants=FakeGrants([_db.model_id])
)
await runner.run_turn(await _request(_db, **DEVELOPER))
await session.commit()
seen = scripted.all_text()
assert "super-secret-jwt-value" not in seen
assert "postgresql+asyncpg://u:p@h/db" not in seen
# The model still got the rules and the role it plays.
assert "قوانین پلتفرم" in seen
assert "WORK_REPORT" in seen