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,299 @@
|
||||
"""Building one turn's prompt: a stable prefix and a bounded tail.
|
||||
|
||||
The order is not cosmetic, it is a caching decision. A provider that supports
|
||||
prompt caching (agy does) can reuse the *prefix* across every turn of a task
|
||||
and every task of a role, so everything that changes rarely goes first and
|
||||
everything that changes every turn goes last:
|
||||
|
||||
1. platform rules — short, and never change
|
||||
2. the role protocol — allowed tags plus a worked example, per role/mode
|
||||
3. the toolkit description — what the tools do and what they refuse
|
||||
4. the app profile — language, framework, commands (never a secret)
|
||||
5. org memory — what this organization learned about itself
|
||||
6. the task brief (capped) plus the stage-specific context the role needs:
|
||||
the planner's spec for the developer, **the diff only** for the reviewer,
|
||||
the test output for the tester
|
||||
7. the conversation so far in this turn — the tool results, newest last
|
||||
|
||||
The whole prompt has a hard character cap. When it is exceeded the builder
|
||||
drops the **oldest tool results first**, and never the protocol, the memory
|
||||
context or the task brief: a model that forgets the rules is worse than a
|
||||
model that forgot what it read three calls ago.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from app.application.engine.protocol import (
|
||||
TOOL_TAGS,
|
||||
allowed_tags,
|
||||
)
|
||||
from app.application.ports import ChatMessage
|
||||
from app.core.config import settings
|
||||
from app.domain.workforce.models import RoleKey
|
||||
from app.domain.workforce.pipeline import Mode, Stage
|
||||
|
||||
#: The task brief is capped before anything else, so a chatty task
|
||||
#: description can never squeeze the protocol out of the prompt.
|
||||
MAX_TASK_BRIEF_CHARS = 1200
|
||||
|
||||
#: Marker written in place of a tool result that was dropped for space.
|
||||
DROPPED = "[… نتیجهٔ قبلی بهخاطر محدودیت طول حذف شد …]"
|
||||
|
||||
#: Platform rules. Deliberately short: this is the cacheable prefix, and the
|
||||
#: role protocol below carries the format rules.
|
||||
PLATFORM_RULES = (
|
||||
"قوانین پلتفرم:\n"
|
||||
"۱. تو یک کارمند هوش مصنوعی درون سامانه هستی و فقط با برچسبهای مجاز کار میکنی.\n"
|
||||
"۲. نام مدل، ارائهدهنده، حساب یا هر جزئیات زیرساختی را هرگز فاش نکن.\n"
|
||||
"۳. هر تغییری در مخزن را پلتفرم با git انجام میدهد؛ تو فایلها را ویرایش و "
|
||||
"دستورها را در سندباکس اجرا میکنی.\n"
|
||||
"۴. اگر به تصمیم انسان نیاز بود، از ASK_USER استفاده کن و علت را بنویس.\n"
|
||||
"۵. فارسی، کوتاه و عملی پاسخ بده."
|
||||
)
|
||||
|
||||
#: What the tags look like, with a worked example per role/mode.
|
||||
_PROTOCOL_HINTS: dict[tuple[RoleKey, Mode], str] = {
|
||||
(RoleKey.MANAGER, Mode.TRIAGE): (
|
||||
"تو مدیر هستی و کار را ارزیابی میکنی.\n"
|
||||
"برچسبهای مجاز:\n"
|
||||
'[[ASSIGN employee="نام کارمند" pipeline="plan_code_test"/]]\n'
|
||||
"[[REJECT reason=\"دلیل\"/]]\n"
|
||||
"[[ASK_USER]]سؤالت را اینجا بنویس[[/ASK_USER]]\n"
|
||||
'[[KNOWLEDGE kind="rule" key="..."]]قاعدهای که یاد گرفتی[[/KNOWLEDGE]]\n'
|
||||
"دقیقاً یکی از این برچسبها را انتخاب کن."
|
||||
),
|
||||
(RoleKey.MANAGER, Mode.FINAL): (
|
||||
"تو مدیر هستی و تصمیم نهایی را میگیری.\n"
|
||||
"برچسبهای مجاز:\n"
|
||||
"[[COMPLETE/]] یا [[REVISE]]دلیل بازگشت کار[[/REVISE]]\n"
|
||||
'[[KNOWLEDGE kind="rule" key="..."]]قاعدهای که یاد گرفتی[[/KNOWLEDGE]]'
|
||||
),
|
||||
(RoleKey.MANAGER, Mode.ERROR): (
|
||||
"تو مدیر هستی و خطای یک کار را بررسی میکنی.\n"
|
||||
"برچسبهای مجاز:\n"
|
||||
'[[RESOLVE_ERROR hint="راهنمایی برای برنامهنویس"/]] یا '
|
||||
'[[CANCEL reason="دلیل لغو"/]]\n'
|
||||
'[[KNOWLEDGE kind="lesson" key="..."]]درس آموختهشده[[/KNOWLEDGE]]'
|
||||
),
|
||||
(RoleKey.MANAGER, Mode.CHAT): (
|
||||
"تو مدیر هستی و با کاربر گفتگو میکنی.\n"
|
||||
"برچسبهای مجاز:\n"
|
||||
'[[CREATE_TASK title="عنوان کار" kind="feature|bugfix|refactor|docs|analysis" '
|
||||
'priority="normal|high" depends_on="id1,id2" pipeline="..."]'
|
||||
"]]توضیح اختیاری[[/CREATE_TASK]]\n"
|
||||
'[[PAUSE_TASK task_id="..."/]] [[RESUME_TASK task_id="..."/]] '
|
||||
'[[CANCEL_TASK task_id="..."/]]\n'
|
||||
'[[HIRE role="developer|tester|reviewer|planner" '
|
||||
'tier="fast|balanced|strong" name="نام کارمند"/]]\n'
|
||||
'[[UPDATE_EMPLOYEE employee_id="..." tier="..."/]] '
|
||||
'[[FIRE employee_id="..."/]]\n'
|
||||
'[[KNOWLEDGE kind="rule|fact" key="..."]'
|
||||
"]]متن[[/KNOWLEDGE]]\n"
|
||||
"پاسخ متنیات برای کاربر نمایش داده میشود؛ برچسبها کنارش اجرا میشوند. "
|
||||
"کارها را اتمیک بساز و نگو که کدام فایلها را همزمان تغییر میدهند."
|
||||
),
|
||||
(RoleKey.PLANNER, Mode.WORK): (
|
||||
"تو برنامهریز هستی.\n"
|
||||
"برچسبهای مجاز:\n"
|
||||
"[[PLAN_SPEC]]برنامهٔ اجرا: مراحل، فایلهای درگیر، ریسکها[[/PLAN_SPEC]]\n"
|
||||
'[[SPLIT title="عنوان کار جدا" kind="..."]]توضیح[[/SPLIT]]\n'
|
||||
"[[ASK_USER]]سؤال[[/ASK_USER]]\n"
|
||||
"برای دیدن فایلها از ابزارهای خواندن استفاده کن و با PLAN_SPEC پایان بده."
|
||||
),
|
||||
(RoleKey.DEVELOPER, Mode.WORK): (
|
||||
"تو برنامهنویس هستی.\n"
|
||||
"برچسبهای مجاز:\n"
|
||||
"[[WORK_REPORT]]خلاصهٔ تغییرات، فایلهای ویرایششده و نتیجهٔ تستها[[/WORK_REPORT]]\n"
|
||||
"[[ASK_USER]]سؤال[[/ASK_USER]]\n"
|
||||
"قبل از WORK_REPORT حتماً تستها را با RUN اجرا کن."
|
||||
),
|
||||
(RoleKey.REVIEWER, Mode.WORK): (
|
||||
"تو بازبین هستی و diff را میبینی.\n"
|
||||
"برچسبهای مجاز:\n"
|
||||
"[[REVIEW_PASS]]نظر مثبت[[/REVIEW_PASS]] یا "
|
||||
"[[REVIEW_CHANGES]]آنچه باید اصلاح شود[[/REVIEW_CHANGES]]"
|
||||
),
|
||||
(RoleKey.TESTER, Mode.WORK): (
|
||||
"تو آزمونگر هستی.\n"
|
||||
"برچسبهای مجاز:\n"
|
||||
'[[TEST_PASS]]نتیجه[[/TEST_PASS]] یا [[TEST_PASS waive="دلیل رهاسازی"]]\n'
|
||||
"[[TEST_FAIL]]خروجی خطا[[/TEST_FAIL]]\n"
|
||||
"دستور تست را خودت با RUN اجرا کن."
|
||||
),
|
||||
}
|
||||
|
||||
#: What each tool does and what it refuses. Shown to every working role.
|
||||
TOOLKIT_DESCRIPTION = (
|
||||
"ابزارها (داخل سندباکس اجرا میشوند):\n"
|
||||
'[[READ_FILE path="app/main.py"/]]\n'
|
||||
'[[WRITE_FILE path="app/x.py"]]محتوای کامل فایل[[/WRITE_FILE]]\n'
|
||||
'[[EDIT_FILE path="app/x.py"]]<<<OLD\nکد قدیمی\n===\nکد جدید\nNEW>>>[[/EDIT_FILE]]\n'
|
||||
'[[LIST_DIR path="app"/]] [[SEARCH pattern="TODO" path="app"/]]\n'
|
||||
'[[RUN cmd="pytest -q" timeout="120"/]]\n'
|
||||
"قواعد: مسیرها نسبی و داخل پروژهاند؛ git push/remote/config توسط پلتفرم انجام "
|
||||
"میشود و رد میشود؛ خروجی RUN بریده میشود."
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class TurnMessage:
|
||||
"""One entry of the in-turn conversation: a tool call or its result."""
|
||||
|
||||
kind: str # tool | result | note
|
||||
text: str
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class TurnContext:
|
||||
"""Everything the builder needs about this turn."""
|
||||
|
||||
organization_id: str
|
||||
task_id: str
|
||||
project_id: str | None = None
|
||||
task_title: str = ""
|
||||
task_description: str = ""
|
||||
stage: Stage = Stage.PENDING_IMPLEMENTATION
|
||||
role: RoleKey = RoleKey.DEVELOPER
|
||||
mode: Mode = Mode.WORK
|
||||
app_profile_text: str = ""
|
||||
memory_context: str = ""
|
||||
#: The planner's spec, shown to the developer (and the reviewer).
|
||||
spec: str = ""
|
||||
#: The diff, shown to the reviewer.
|
||||
diff_text: str = ""
|
||||
#: The latest test output, shown to the tester and the manager.
|
||||
test_output: str = ""
|
||||
#: Free-form stage context from the scheduler (e.g. the error hint).
|
||||
stage_context: str = ""
|
||||
history: list[TurnMessage] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class TurnPrompt:
|
||||
"""The prompt for one model call: a stable prefix and a bounded tail."""
|
||||
|
||||
system: str
|
||||
messages: list[ChatMessage] = field(default_factory=list)
|
||||
total_chars: int = 0
|
||||
|
||||
|
||||
def _stage_context_for(ctx: TurnContext) -> str:
|
||||
"""The role-specific part of the brief: only what this role must see."""
|
||||
parts: list[str] = []
|
||||
if ctx.stage_context:
|
||||
parts.append(ctx.stage_context.strip())
|
||||
if ctx.role is RoleKey.DEVELOPER and ctx.spec:
|
||||
parts.append(f"برنامهٔ تأییدشده:\n{ctx.spec.strip()}")
|
||||
if ctx.role is RoleKey.REVIEWER and ctx.diff_text:
|
||||
# The diff only: a reviewer that reads the whole tree re-reviews
|
||||
# everything and finds nothing.
|
||||
parts.append(f"diff تغییرات:\n{ctx.diff_text.strip()}")
|
||||
if ctx.role is RoleKey.TESTER and ctx.test_output:
|
||||
parts.append(f"آخرین خروجی تست:\n{ctx.test_output.strip()}")
|
||||
if ctx.role is RoleKey.MANAGER and ctx.mode is Mode.FINAL:
|
||||
if ctx.spec:
|
||||
parts.append(f"برنامهٔ کار:\n{ctx.spec.strip()}")
|
||||
if ctx.diff_text:
|
||||
parts.append(f"خلاصهٔ diff:\n{ctx.diff_text.strip()}")
|
||||
if ctx.test_output:
|
||||
parts.append(f"نتیجهٔ تست:\n{ctx.test_output.strip()}")
|
||||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
def _history_messages(history: list[TurnMessage], *, budget: int) -> list[ChatMessage]:
|
||||
"""Render the turn's history, dropping the oldest tool results first.
|
||||
|
||||
The budget is what is left of the cap after the prefix and the brief, so
|
||||
the caller decides how much room the conversation may take. Tool calls are
|
||||
the model's own words and results are the environment's: both are rendered
|
||||
as ``assistant``/``user`` exchange — providers have no sandbox-specific
|
||||
role, and pretending otherwise is what makes tool loops fragile.
|
||||
"""
|
||||
rendered: list[ChatMessage] = []
|
||||
for item in history:
|
||||
role = "user" if item.kind == "result" else "assistant"
|
||||
rendered.append(ChatMessage(role=role, content=item.text))
|
||||
total = sum(len(m.content) for m in rendered)
|
||||
if total <= budget:
|
||||
return rendered
|
||||
# Walk from the oldest, dropping results (never the calls, so the model
|
||||
# still sees what it did) until the rest fits.
|
||||
for index, msg in enumerate(rendered):
|
||||
if total <= budget:
|
||||
break
|
||||
if history[index].kind != "result":
|
||||
continue
|
||||
saved = len(msg.content)
|
||||
msg.content = DROPPED
|
||||
total -= saved - len(DROPPED)
|
||||
if total <= budget:
|
||||
return rendered
|
||||
# Last resort, and it should not happen in practice: keep the newest
|
||||
# messages, because the newest result answers the model's last question.
|
||||
kept: list[ChatMessage] = []
|
||||
remaining = budget
|
||||
for msg in reversed(rendered):
|
||||
if remaining <= 0:
|
||||
break
|
||||
if len(msg.content) > remaining:
|
||||
msg.content = msg.content[-remaining:]
|
||||
kept.append(msg)
|
||||
remaining -= len(msg.content)
|
||||
kept.reverse()
|
||||
return kept
|
||||
|
||||
|
||||
def build_turn_prompt(ctx: TurnContext) -> TurnPrompt:
|
||||
"""Build the system prefix and the user tail for one turn."""
|
||||
granted = allowed_tags(role=ctx.role, mode=ctx.mode)
|
||||
protocol_hint = _PROTOCOL_HINTS.get((ctx.role, ctx.mode), "با برچسبهای مجاز پاسخ بده.")
|
||||
tag_list = "، ".join(sorted(t.value for t in granted))
|
||||
|
||||
system = "\n\n".join(
|
||||
[
|
||||
PLATFORM_RULES,
|
||||
f"نقش: {ctx.role.value} ({ctx.mode.value}) — مرحلهٔ {ctx.stage.value}\n"
|
||||
f"برچسبهای مجاز: {tag_list}\n\n{protocol_hint}",
|
||||
TOOLKIT_DESCRIPTION if granted & TOOL_TAGS else "",
|
||||
ctx.app_profile_text.strip(),
|
||||
ctx.memory_context.strip(),
|
||||
]
|
||||
).strip()
|
||||
|
||||
brief_parts = [f"عنوان کار: {ctx.task_title}"]
|
||||
if ctx.task_description:
|
||||
brief_parts.append(f"توضیحات: {ctx.task_description}")
|
||||
brief = "\n".join(brief_parts)[:MAX_TASK_BRIEF_CHARS]
|
||||
tail_parts = [brief]
|
||||
stage_context = _stage_context_for(ctx)
|
||||
if stage_context:
|
||||
tail_parts.append(stage_context)
|
||||
tail = "\n\n".join(tail_parts)
|
||||
|
||||
budget = max(settings.engine_prompt_char_cap - len(system) - len(tail), 500)
|
||||
messages = _history_messages(ctx.history, budget=budget)
|
||||
messages.append(ChatMessage(role="user", content=tail or "ادامه بده."))
|
||||
return TurnPrompt(
|
||||
system=system,
|
||||
messages=messages,
|
||||
total_chars=len(system) + sum(len(m.content) for m in messages),
|
||||
)
|
||||
|
||||
|
||||
def protocol_error_feedback(errors: tuple[str, ...]) -> str:
|
||||
"""The protocol errors of the last reply, as a message for the next one."""
|
||||
return "\n".join(errors)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MAX_TASK_BRIEF_CHARS",
|
||||
"PLATFORM_RULES",
|
||||
"TOOLKIT_DESCRIPTION",
|
||||
"TurnContext",
|
||||
"TurnMessage",
|
||||
"TurnPrompt",
|
||||
"build_turn_prompt",
|
||||
"protocol_error_feedback",
|
||||
]
|
||||
Reference in New Issue
Block a user