# 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` (`<<>>` 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`.