"""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' 'اگر خودت باید کارمند را انتخاب کنی، بنویس [[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"]]<<>>[[/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", ]