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:
@@ -0,0 +1,154 @@
|
||||
"""The prompt builder: a stable prefix, a bounded tail.
|
||||
|
||||
What these tests defend:
|
||||
|
||||
* the prefix order is the plan's order (rules → protocol → toolkit → app
|
||||
profile → memory), because that order is what makes prompt caching useful
|
||||
* the task brief is capped, and the history truncation drops the **oldest tool
|
||||
results first** — a model that forgets the rules is worse than one that
|
||||
forgot what it read three calls ago
|
||||
* nothing secret reaches the prompt: the builder only ever sees the strings it
|
||||
is handed, and the runner never hands it settings
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.application.engine.prompt import (
|
||||
PLATFORM_RULES,
|
||||
TOOLKIT_DESCRIPTION,
|
||||
TurnContext,
|
||||
TurnMessage,
|
||||
build_turn_prompt,
|
||||
)
|
||||
from app.core.config import settings
|
||||
from app.domain.workforce.models import RoleKey
|
||||
from app.domain.workforce.pipeline import Mode, Stage
|
||||
|
||||
|
||||
def _ctx(**kwargs) -> TurnContext:
|
||||
base = {
|
||||
"organization_id": "org-1",
|
||||
"task_id": "task-1",
|
||||
"project_id": "proj-1",
|
||||
"task_title": "ساخت صفحهٔ ورود",
|
||||
"task_description": "توضیح کار",
|
||||
"stage": Stage.PENDING_IMPLEMENTATION,
|
||||
"role": RoleKey.DEVELOPER,
|
||||
"mode": Mode.WORK,
|
||||
"app_profile_text": "زبان: پایتون\nدستور تست: pytest -q",
|
||||
"memory_context": "این سازمان تستها را با pytest اجرا میکند.",
|
||||
}
|
||||
base.update(kwargs)
|
||||
return TurnContext(**base)
|
||||
|
||||
|
||||
def test_the_prefix_keeps_the_plan_order():
|
||||
prompt = build_turn_prompt(_ctx())
|
||||
system = prompt.system
|
||||
positions = [
|
||||
system.index(PLATFORM_RULES.splitlines()[0]), # 1. platform rules
|
||||
system.index("تو برنامهنویس هستی"), # 2. the role protocol
|
||||
system.index(TOOLKIT_DESCRIPTION.splitlines()[0]), # 3. the toolkit
|
||||
system.index("دستور تست: pytest -q"), # 4. the app profile
|
||||
system.index("این سازمان"), # 5. org memory
|
||||
]
|
||||
assert positions == sorted(positions)
|
||||
assert prompt.messages[-1].role == "user" # 6/7. brief and history last
|
||||
|
||||
|
||||
def test_the_toolkit_is_only_shown_to_roles_that_have_tools():
|
||||
developer = build_turn_prompt(_ctx())
|
||||
manager = build_turn_prompt(
|
||||
_ctx(role=RoleKey.MANAGER, mode=Mode.TRIAGE, stage=Stage.PENDING_MANAGER_REVIEW)
|
||||
)
|
||||
assert "READ_FILE" in developer.system
|
||||
assert "READ_FILE" not in manager.system
|
||||
|
||||
|
||||
def test_the_task_brief_is_capped():
|
||||
prompt = build_turn_prompt(_ctx(task_description="x" * 5000))
|
||||
brief = prompt.messages[-1].content
|
||||
# The description is cut, the title survives.
|
||||
assert "ساخت صفحهٔ ورود" in brief
|
||||
assert len(brief) < 2000
|
||||
|
||||
|
||||
def test_stage_context_only_goes_to_the_role_that_needs_it():
|
||||
"""A reviewer sees the diff; a developer sees the spec; nobody sees both."""
|
||||
developer = build_turn_prompt(
|
||||
_ctx(spec="برنامهٔ برنامهریز", diff_text="diff --git a/x b/x")
|
||||
)
|
||||
assert "برنامهٔ برنامهریز" in developer.messages[-1].content
|
||||
assert "diff --git" not in developer.messages[-1].content
|
||||
|
||||
reviewer = build_turn_prompt(
|
||||
_ctx(
|
||||
role=RoleKey.REVIEWER,
|
||||
mode=Mode.WORK,
|
||||
stage=Stage.PENDING_REVIEW,
|
||||
spec="برنامهٔ برنامهریز",
|
||||
diff_text="diff --git a/x b/x",
|
||||
)
|
||||
)
|
||||
assert "diff --git" in reviewer.messages[-1].content
|
||||
assert "برنامهٔ برنامهریز" not in reviewer.messages[-1].content
|
||||
|
||||
|
||||
def test_history_drops_the_oldest_results_first():
|
||||
big = "y" * 4000
|
||||
history = [
|
||||
TurnMessage(kind="result", text=big), # oldest: dropped first
|
||||
TurnMessage(kind="result", text=big),
|
||||
TurnMessage(kind="result", text="نتیجهٔ تازه و مهم"),
|
||||
]
|
||||
settings.engine_prompt_char_cap = 9000 # keep the prefix small
|
||||
try:
|
||||
prompt = build_turn_prompt(_ctx(history=list(history)))
|
||||
finally:
|
||||
settings.engine_prompt_char_cap = 24000
|
||||
joined = "\n".join(m.content for m in prompt.messages)
|
||||
assert "نتیجهٔ تازه و مهم" in joined
|
||||
assert joined.count("y" * 4000) <= 1
|
||||
assert prompt.total_chars <= 9000 + 4000
|
||||
|
||||
|
||||
def test_a_tiny_cap_still_keeps_the_rules_and_the_brief():
|
||||
settings.engine_prompt_char_cap = 3000
|
||||
try:
|
||||
prompt = build_turn_prompt(
|
||||
_ctx(history=[TurnMessage(kind="result", text="z" * 20000)])
|
||||
)
|
||||
finally:
|
||||
settings.engine_prompt_char_cap = 24000
|
||||
# The prefix and the brief survive; only history space is sacrificed.
|
||||
assert "قوانین پلتفرم" in prompt.system
|
||||
assert "ساخت صفحهٔ ورود" in prompt.messages[-1].content
|
||||
|
||||
|
||||
def test_the_prompt_never_contains_the_settings(monkeypatch):
|
||||
monkeypatch.setattr(settings, "jwt_secret", "top-secret")
|
||||
monkeypatch.setattr(settings, "database_url", "sqlite:///private.db")
|
||||
prompt = build_turn_prompt(
|
||||
_ctx(history=[TurnMessage(kind="result", text="نتیجه")])
|
||||
)
|
||||
text = prompt.system + "\n" + "\n".join(m.content for m in prompt.messages)
|
||||
assert "top-secret" not in text
|
||||
assert "private.db" not in text
|
||||
assert PLATFORM_RULES.splitlines()[0] in text
|
||||
|
||||
|
||||
def test_manager_modes_get_their_own_protocol_hint():
|
||||
triage = build_turn_prompt(
|
||||
_ctx(role=RoleKey.MANAGER, mode=Mode.TRIAGE, stage=Stage.PENDING_MANAGER_REVIEW)
|
||||
)
|
||||
final = build_turn_prompt(
|
||||
_ctx(role=RoleKey.MANAGER, mode=Mode.FINAL, stage=Stage.PENDING_FINAL_REVIEW)
|
||||
)
|
||||
assert "ASSIGN" in triage.system
|
||||
assert "ASSIGN" not in final.system
|
||||
assert "COMPLETE" in final.system
|
||||
|
||||
|
||||
def test_the_toolkit_description_lists_every_tool_tag():
|
||||
for tool in ("READ_FILE", "WRITE_FILE", "EDIT_FILE", "LIST_DIR", "SEARCH", "RUN"):
|
||||
assert tool in TOOLKIT_DESCRIPTION
|
||||
Reference in New Issue
Block a user