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.
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:
PLATFORM_RULES— five short platform rules (never leaks model/infra detail, git belongs to the platform, ask a human when in doubt).- 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). 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.- The app profile text (language/framework/commands — the caller passes text, never the row, so no secret can leak in).
MemoryPort.build_context(org, role, query=task text, budget_chars=2500).- 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. - 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_DIRandSEARCHrun as quotedls/grep -RIncommands.RUN cmd timeout— timeout defaults to 120 s and is clamped to 600 s; the output is capped to the lastengine_tool_output_chars(8,000) because a traceback is at the end.git push,git remoteandgit configare refused with a Persian message that says the platform does git.- Every execution writes a
tool_callsrow (mapped onto the existingToolKeycatalog: read/write/run) withsucceeded/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 futureemployee_profiles.ai_model_id) and is checked againstModelGrantPortbefore 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_usagewas extracted fromWorkforceService._record_usageinto a module-level function inworkforce_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/timeoutare transient (TurnResult.retryable),auth/model/protocolare not.- Cancellation is checked between steps, both through the caller's
should_cancelhook 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) andtool_calls. TERMINAL_TAGSnames 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; atool_callsrow per execution (succeeded and failed); theengine_turnsrow; 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
WorkforceService._record_usagenow delegates to the new module-levelrecord_model_usage; behaviour is identical and the existing 14 workforce tests still pass. This keeps one billing code path instead of a copy.- 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. TERMINAL_TAGStreats 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.