Files
smart-org/docs/plan/reports/agent-2-phase-2.md
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

7.6 KiB

Agent 2 — Phase 2 report: the agent turn runner (prompt, tools, loop)

Date: 2026-10-14 · Branch: dev · Status: done, all checks green

What was done

2.1 app/application/engine/prompt.py (new)

build_turn_prompt(TurnContext) -> TurnPrompt builds the stable prefix in exactly the plan's order, because that order is what makes prompt caching worth anything:

  1. PLATFORM_RULES — five short platform rules (never leaks model/infra detail, git belongs to the platform, ask a human when in doubt).
  2. The role protocol — the allowed-tag list for (role, mode) plus a worked example from _PROTOCOL_HINTS (manager triage / final / error / chat, planner, developer, reviewer, tester).
  3. TOOLKIT_DESCRIPTION — the six tool tags with the rules that matter (relative paths, git refused, output clipped). Shown only to roles that actually have tools: the manager never sees a file tool.
  4. The app profile text (language/framework/commands — the caller passes text, never the row, so no secret can leak in).
  5. MemoryPort.build_context(org, role, query=task text, budget_chars=2500).
  6. The task brief, capped at MAX_TASK_BRIEF_CHARS = 1200, plus the stage-specific context: the planner's spec for the developer, the diff only for the reviewer, the test output for the tester, and spec+diff+tests for the manager's final verdict. A reviewer never sees the whole tree.
  7. The in-turn conversation (tool results), newest last.

Hard cap engine_prompt_char_cap (new setting, 24,000 chars): when exceeded the builder drops the oldest tool results first, then clips from the front as a last resort. The rules, memory and brief are never the things that get cut.

2.2 app/application/engine/tools.py (new)

ToolExecutor.execute(tag) -> ToolOutcome runs the toolkit inside the sandbox through SandboxProviderPort, rooted at WorktreeRef.sandbox_path:

  • safe_join() — pure, synchronous, unit-tested: rejects absolute paths, .. traversal, ~, and anything under .git.
  • READ_FILE / WRITE_FILE (body = content) / EDIT_FILE (<<<OLD … === … NEW>>> blocks; OLD must appear exactly once, otherwise a clear failure instead of a half-applied edit).
  • LIST_DIR and SEARCH run as quoted ls / grep -RIn commands.
  • RUN cmd timeout — timeout defaults to 120 s and is clamped to 600 s; the output is capped to the last engine_tool_output_chars (8,000) because a traceback is at the end. git push, git remote and git config are refused with a Persian message that says the platform does git.
  • Every execution writes a tool_calls row (mapped onto the existing ToolKey catalog: read/write/run) with succeeded/failed, so the approvals UI finally shows real executions rather than intentions.
  • No failure raises: a refusal comes back as tool output the model reads.

2.3 app/application/engine/runner.py (new)

TurnRunner.run_turn(TurnRequest) -> TurnResult — the loop: model call → parse tags → execute tool tags → feed results back → repeat until a terminal tag, the tool-call cap (engine_max_tool_calls_per_turn), the turn timeout (engine_turn_timeout_seconds) or a cancellation.

  • The employee's model comes from request.model_id (the future employee_profiles.ai_model_id) and is checked against ModelGrantPort before the first call; ungranted or missing → error_kind=auth, zero model calls, zero spend.
  • Every model call writes usage events through the same code path the run engine uses: record_model_usage was extracted from WorkforceService._record_usage into a module-level function in workforce_service.py (my file) so there is exactly one billing path — the manager chat will use it too (agybot bug).
  • classify_error() maps provider failures onto the domain vocabulary: rate_limit / unavailable / network / timeout are transient (TurnResult.retryable), auth / model / protocol are not.
  • Cancellation is checked between steps, both through the caller's should_cancel hook and by re-reading the run row.
  • Records written: engine_turns (role, mode, stage, prompt chars, reply excerpt ≤ 4,000, tag counts, tool calls, ok/error_kind), run_steps (timeline entries for tools and errors) and tool_calls.
  • TERMINAL_TAGS names the decision tag of each role/mode; only tool tags are executed here — ASSIGN, COMPLETE, CREATE_TASK, KNOWLEDGE… are decisions the caller interprets, which keeps orchestration out of the loop.

app/application/engine/events.py (new)

RunFeed implements PLAN-v2 §5's batching for run.log, plus run.tool and task.gate (which have no typed emitter in the frozen realtime port, so they go through the generic envelope): at most one log event per 500 ms per stream, at most 200 lines per event, with a forced flush at the end of a turn.

New settings (inside the agent-2 block of config.py)

engine_prompt_char_cap, engine_tool_output_chars, engine_reply_excerpt_chars, engine_log_flush_seconds, engine_log_max_lines_per_event.

Tests

  • tests/engine_fakes.py (new): ScriptedModel (queued replies and queued exceptions, records every system prompt and message it was given), FakeSandbox (dict filesystem + scripted command results per command prefix, records every execution), FakeGrants, RecordingMemory.
  • tests/test_engine_runner.py (22 tests): write → run → report; tool results fed back into the next prompt; a tool_calls row per execution (succeeded and failed); the engine_turns row; path escapes refused (4 shapes); git refused (3 commands, nothing executed); EDIT_FILE applied and refused when not unique; the tool-call cap; the turn timeout; cancellation between steps; rate limit transient; ungranted model refused before any call; error classification; usage events per model call; no settings secret in any prompt.
  • tests/test_engine_prompt.py (11 tests): the prefix order; toolkit only for tool roles; brief cap; stage context per role; oldest-results-dropped-first truncation; tiny-cap keeps rules and brief; no secrets; per-mode protocol hints.
  • tests/test_engine_tools.py (11 tests): path safety matrix; git refusal; timeout clamp and default; output tail truncation; round trip; quoted list/search; tool-key mapping; malformed edit body.

Checks (PLAN-v2 §7.4)

cd apps/api && .venv/bin/python -m pytest -q   → 648 passed, 1 skipped (54 new)
cd apps/api && .venv/bin/ruff check .          → All checks passed!
cd apps/api && .venv/bin/mypy app              → Success: no issues found in 148 source files

No web files were touched, no migration was needed (no schema change).

Deviations & notes

  1. WorkforceService._record_usage now delegates to the new module-level record_model_usage; behaviour is identical and the existing 14 workforce tests still pass. This keeps one billing code path instead of a copy.
  2. The runner takes the sandbox session id from the request (session_external_id) and fails cleanly when there is none. Wiring the real session (workspace container per app, SandboxService) belongs to Phase 4 with the scheduler; deliberately not faked here.
  3. TERMINAL_TAGS treats the manager-chat tags as the chat layer's tags: the chat turn emits decisions, not work.

Open questions / CHANGE REQUESTs

  • None. Expected next: org automation policy behind ModelGrantPort (Phase 4).

Next

Phase 3 — manager chat (orders → tasks → team), triage/final/error turns, KNOWLEDGE → MemoryPort.record, WorkforceBootstrapPort.bootstrap_team, and tests/test_engine_manager.py.