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:
mjalmoudy94
2026-10-14 16:32:52 +03:30
parent 9c0b911a00
commit c6488bfadf
13 changed files with 2712 additions and 19 deletions
+189
View File
@@ -0,0 +1,189 @@
"""The engine's live feed: batched ``run.log`` / ``run.tool`` / ``task.gate``.
PLAN-v2 §5 requires log events to be *batched* — at most one event per 500 ms
per stream, at most 200 lines per event. A model loop that emits one WebSocket
event per tool call turns a chatty agent into a flood that buries the browser,
so the rule is not a nicety.
``RunFeed`` buffers log lines per run and publishes them at the boundaries the
runner chooses: between steps (rate-limited) and at the end of a turn (always).
Tool results and gate verdicts are their own event types and never wait behind
the log buffer — a command's output is the thing the live log panel exists for.
Publishing goes through the generic envelope rather than a typed emitter
because ``ports/realtime.py`` is a frozen contract and these three event types
have no typed helper there.
"""
from __future__ import annotations
import time
from dataclasses import dataclass, field
from app.application.ports.realtime import (
RealtimeEvent,
org_channel,
publish,
run_channel,
task_channel,
)
from app.core.config import settings
@dataclass(slots=True, kw_only=True)
class _Stream:
"""One run's buffer: the lines waiting, and when we last published."""
run_id: str
task_id: str
organization_id: str
lines: list[str] = field(default_factory=list)
last_publish: float = 0.0
#: Set when the buffer reached its line cap, so the next flush must not
#: wait for the interval to elapse.
overflow: bool = False
class RunFeed:
"""Batches engine output into bounded realtime events, per run."""
def __init__(
self, *, min_interval: float | None = None, max_lines: int | None = None
) -> None:
self._min_interval = (
settings.engine_log_flush_seconds if min_interval is None else min_interval
)
self._max_lines = (
settings.engine_log_max_lines_per_event if max_lines is None else max_lines
)
self._streams: dict[str, _Stream] = {}
#: Events published, by type — so a test can assert "one batched log
#: event, not one per line" without touching the realtime bus.
self.published: dict[str, int] = {}
# -- buffering ----------------------------------------------------------
def log(self, *, run_id: str, task_id: str, organization_id: str, line: str) -> None:
"""Buffer one log line. Nothing is published until a flush."""
stream = self._stream(run_id, task_id, organization_id)
stream.lines.append(line)
if len(stream.lines) >= self._max_lines:
# The buffer is full: the next flush must not wait for the timer.
stream.overflow = True
async def tool(
self,
*,
run_id: str,
task_id: str,
organization_id: str,
tool: str,
ok: bool,
summary: str,
) -> None:
"""Report one executed tool call as a single, self-contained event."""
await self._emit(
RealtimeEvent(
type="run.tool",
channels=(
run_channel(run_id),
task_channel(task_id),
org_channel(organization_id),
),
organization_id=organization_id,
data={
"run_id": run_id,
"task_id": task_id,
"tool": tool,
"ok": ok,
"summary": summary[:2000],
},
)
)
async def gate(
self,
*,
task_id: str,
organization_id: str,
gate: str,
ok: bool,
detail: str,
) -> None:
"""Report one gate verdict, with the evidence attached."""
await self._emit(
RealtimeEvent(
type="task.gate",
channels=(task_channel(task_id), org_channel(organization_id)),
organization_id=organization_id,
data={
"task_id": task_id,
"gate": gate,
"ok": ok,
"detail": detail[:8000],
},
)
)
# -- flushing -----------------------------------------------------------
async def flush(self, *, run_id: str, force: bool = False) -> None:
"""Publish a run's buffered lines, unless the interval has not elapsed.
``force`` is for the end of a turn: whatever is buffered then is the
last thing the browser will see until the next turn, so it goes out.
"""
stream = self._streams.get(run_id)
if stream is None or not stream.lines:
return
now = time.monotonic()
elapsed = now - stream.last_publish
if not force and not stream.overflow and elapsed < self._min_interval:
return
await self._publish(stream, force=force or stream.overflow)
async def flush_all(self) -> None:
for stream in list(self._streams.values()):
await self._publish(stream, force=True)
# -- internals ----------------------------------------------------------
async def _publish(self, stream: _Stream, *, force: bool) -> None:
if force:
lines, stream.lines = list(stream.lines), []
else:
lines, stream.lines = stream.lines[: self._max_lines], stream.lines[self._max_lines :]
stream.overflow = False
if not lines:
return
stream.last_publish = time.monotonic()
await self._emit(
RealtimeEvent(
type="run.log",
channels=(
run_channel(stream.run_id),
task_channel(stream.task_id),
org_channel(stream.organization_id),
),
organization_id=stream.organization_id,
data={"run_id": stream.run_id, "task_id": stream.task_id, "lines": lines},
)
)
async def _emit(self, event: RealtimeEvent) -> None:
self.published[event.type] = self.published.get(event.type, 0) + 1
# The realtime publisher is documented as never raising; a counter
# keeps tests honest even when the bus is a NullPublisher.
await publish(event)
def _stream(self, run_id: str, task_id: str, organization_id: str) -> _Stream:
stream = self._streams.get(run_id)
if stream is None:
stream = _Stream(
run_id=run_id, task_id=task_id, organization_id=organization_id
)
self._streams[run_id] = stream
return stream
__all__ = ["RunFeed"]
+299
View File
@@ -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",
]
+582
View File
@@ -0,0 +1,582 @@
"""One employee, one turn: the loop every agent in the platform runs.
``run_turn`` is the whole agent in one function: call the model, parse its
tags, execute the tools it asked for, feed the results back, repeat — until a
terminal protocol tag appears, the tool-call cap is hit, the turn times out, or
somebody cancels the run.
The rules that make it safe to let a model drive work:
* **The employee's model is a granted model.** ``ModelGrantPort.is_granted``
is checked *before* the first call; an ungranted model fails with
``error_kind=auth`` and a message for the manager, never a silent retry.
* **Every model call is billed.** Usage events are written through the same
``record_model_usage`` path ``WorkforceService`` uses, so nothing the engine
spends escapes the ledger — manager chat included.
* **Errors are classified, not swallowed.** ``rate_limit``, ``unavailable``,
``network`` and ``timeout`` are transient and the scheduler retries them;
``auth``, ``model`` and ``protocol`` are not, and they go to the manager's
error turn. The vocabulary lives in the domain
(``app/domain/workforce/engine.py``), not here.
* **A cancellation is honoured between steps.** The loop asks the run (and the
caller's hook) before every model call and every tool batch, so
``cancel_run`` stops work in progress instead of after it.
* **The turn leaves a record.** ``engine_turns`` (who ran, what came back,
what it cost), ``run_steps`` (the timeline the UI renders) and ``tool_calls``
(the executions the approvals UI shows) are all written here.
Only tool tags are executed here. Every other tag (``ASSIGN``, ``COMPLETE``,
``CREATE_TASK``, ``KNOWLEDGE``…) is a *decision*: the caller interprets it
against the pipeline, which is what keeps this loop free of orchestration.
"""
from __future__ import annotations
import asyncio
import time
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from app.application.engine.events import RunFeed
from app.application.engine.prompt import (
TurnContext,
TurnMessage,
build_turn_prompt,
protocol_error_feedback,
)
from app.application.engine.protocol import TOOL_TAGS, Tag, TagName, parse_reply
from app.application.engine.tools import ToolExecutor, ToolOutcome, tool_key_for
from app.application.ports import ModelProviderPort
from app.application.ports.memory import MemoryPort, NullMemory
from app.application.ports.model_grants import ModelGrantPort
from app.application.ports.sandbox_provider import SandboxProviderPort
from app.application.services.workforce_service import record_model_usage
from app.core.config import settings
from app.core.errors import ProviderUnavailableError
from app.domain.ai_control.models import AiModel, RateUnit
from app.domain.workforce.engine import EngineTurn, ErrorKind, is_transient
from app.domain.workforce.models import (
Employee,
RoleKey,
Run,
RunStatus,
Task,
ToolCall,
ToolCallStatus,
)
from app.domain.workforce.pipeline import Mode, Stage
from app.infrastructure.repositories.ai_control import (
AiModelRepository,
ModelCredentialRepository,
RateCardRepository,
)
from app.infrastructure.repositories.audit import UsageLedgerRepository
from app.infrastructure.repositories.engine import (
EngineTurnRepository,
PipelineStateRepository,
)
from app.infrastructure.repositories.workforce import (
RunRepository,
RunStepRepository,
ToolCallRepository,
)
#: The tag that ends each role's turn. A role whose reply has none of these
#: keeps working: the caller decides what a reply without a decision means.
TERMINAL_TAGS: dict[tuple[RoleKey, Mode], tuple[TagName, ...]] = {
(RoleKey.MANAGER, Mode.TRIAGE): (TagName.ASSIGN, TagName.REJECT, TagName.ASK_USER),
(RoleKey.MANAGER, Mode.FINAL): (TagName.COMPLETE, TagName.REVISE),
(RoleKey.MANAGER, Mode.ERROR): (TagName.RESOLVE_ERROR, TagName.CANCEL),
# Chat has no worktree: the manager's reply is consumed by the chat use
# case, which is why these are the tags the chat layer looks for.
(RoleKey.MANAGER, Mode.CHAT): (
TagName.CREATE_TASK,
TagName.PAUSE_TASK,
TagName.RESUME_TASK,
TagName.CANCEL_TASK,
TagName.HIRE,
TagName.UPDATE_EMPLOYEE,
TagName.FIRE,
),
(RoleKey.PLANNER, Mode.WORK): (TagName.PLAN_SPEC,),
(RoleKey.DEVELOPER, Mode.WORK): (TagName.WORK_REPORT,),
(RoleKey.REVIEWER, Mode.WORK): (TagName.REVIEW_PASS, TagName.REVIEW_CHANGES),
(RoleKey.TESTER, Mode.WORK): (TagName.TEST_PASS, TagName.TEST_FAIL),
}
@dataclass(slots=True, kw_only=True)
class TurnResult:
"""What one turn produced — or why it could not.
``retryable`` is the scheduler's answer to "try again or escalate": it
reads the domain's transient set, so a rate limit and a bad key are never
treated the same way twice.
"""
ok: bool
error_kind: str = ""
error: str = ""
reply_text: str = ""
tags: tuple[Tag, ...] = ()
tool_calls: int = 0
model_calls: int = 0
#: True when the turn ended on a timeout, a cancellation or the tool cap
#: rather than on a protocol decision.
incomplete: bool = False
turn_id: str = ""
@property
def retryable(self) -> bool:
return bool(self.error_kind) and is_transient(self.error_kind)
@dataclass(slots=True, kw_only=True)
class TurnRequest:
"""Everything ``run_turn`` needs. One object, no hidden state."""
organization_id: str
task: Task
role: RoleKey
mode: Mode
stage: Stage
run_id: str
employee: Employee | None = None
#: The granted model this employee runs on (``employee_profiles.ai_model_id``).
model_id: str | None = None
#: The worktree this turn works in, as the sandbox sees it.
sandbox_root: str = "/workspace"
session_external_id: str | None = None
extra_context: str = ""
history: list[TurnMessage] = field(default_factory=list)
#: Checked between steps; returning True ends the turn as cancelled.
should_cancel: Callable[[], Awaitable[bool]] | None = None
user_id: str | None = None
def classify_error(exc: BaseException) -> ErrorKind:
"""Sort a failure into the engine's error vocabulary.
The providers raise one broad error type, so the classification reads the
message rather than pretending each provider has its own exception tree.
A failure that cannot be classified counts as ``unavailable`` (transient):
retrying a genuine outage is right, and a permanent failure reveals itself
on the second attempt.
"""
if isinstance(exc, asyncio.TimeoutError):
return ErrorKind.TIMEOUT
text = str(exc).lower()
if "timeout" in text or "timed out" in text:
return ErrorKind.TIMEOUT
if "rate" in text or "429" in text or "quota" in text:
return ErrorKind.RATE_LIMIT
if any(word in text for word in ("auth", "credential", "unauthor", "401", "403")):
return ErrorKind.AUTH
if "model" in text:
return ErrorKind.MODEL
if any(word in text for word in ("connection", "network", "unreachable", "dns")):
return ErrorKind.NETWORK
return ErrorKind.UNAVAILABLE
class TurnRunner:
"""Runs one turn at a time. One instance per process is enough."""
def __init__(
self,
*,
provider: ModelProviderPort,
models: AiModelRepository,
credentials: ModelCredentialRepository,
rate_cards: RateCardRepository,
ledger: UsageLedgerRepository,
grants: ModelGrantPort,
sandbox: SandboxProviderPort | None = None,
memory: MemoryPort | None = None,
turns: EngineTurnRepository | None = None,
run_steps: RunStepRepository | None = None,
tool_calls: ToolCallRepository | None = None,
runs: RunRepository | None = None,
states: PipelineStateRepository | None = None,
feed: RunFeed | None = None,
) -> None:
self.provider = provider
self.models = models
self.credentials = credentials
self.rate_cards = rate_cards
self.ledger = ledger
self.grants = grants
self.sandbox = sandbox
self.memory = memory or NullMemory()
self.turns = turns
self.run_steps = run_steps
self.tool_calls = tool_calls
self.runs = runs
self.states = states
self.feed = feed or RunFeed()
# ------------------------------------------------------------------ #
async def run_turn(self, request: TurnRequest) -> TurnResult:
"""Run one employee turn to completion (or to a clean failure)."""
started = time.monotonic()
deadline = started + settings.engine_turn_timeout_seconds
history: list[TurnMessage] = list(request.history)
tool_calls_made = 0
model_calls = 0
reply_text = ""
tags: tuple[Tag, ...] = ()
turn_row = await self._open_turn_row(request)
try:
model = await self._resolve_model(request)
except _ModelUnavailable as exc:
return await self._fail(
request, turn_row, exc.kind.value, str(exc), tool_calls_made, model_calls
)
while True:
if await self._cancelled(request):
return await self._cancelled_result(request, turn_row, tool_calls_made, model_calls)
if time.monotonic() > deadline:
return await self._fail(
request,
turn_row,
ErrorKind.TIMEOUT.value,
"مهلت نوبت تمام شد.",
tool_calls_made,
model_calls,
incomplete=True,
)
prompt = await self._build_prompt(request, history)
try:
response = await self._complete(request, model, prompt)
except ProviderUnavailableError as exc:
return await self._fail(
request,
turn_row,
classify_error(exc).value,
str(exc),
tool_calls_made,
model_calls,
)
model_calls += 1
reply_text = response.content
self.feed.log(
run_id=request.run_id,
task_id=request.task.id,
organization_id=request.organization_id,
line=f"{request.role.value}/{request.mode.value}: {response.content[:400]}",
)
parsed = parse_reply(response.content, role=request.role, mode=request.mode)
if parsed.errors:
# The model learns the protocol from what it just got wrong.
history.append(
TurnMessage(kind="result", text=protocol_error_feedback(parsed.errors))
)
self.feed.log(
run_id=request.run_id,
task_id=request.task.id,
organization_id=request.organization_id,
line=f"خطای پروتکل: {parsed.errors[0]}",
)
await self.feed.flush(run_id=request.run_id)
continue
requested_tools = [tag for tag in parsed.tags if tag.name in TOOL_TAGS]
tags = self._terminal_tags(request, parsed.tags)
if not requested_tools:
# Nothing left to execute: the reply is a decision, and the
# caller reads it out of ``TurnResult.tags``.
break
for tag in requested_tools:
if tool_calls_made >= settings.engine_max_tool_calls_per_turn:
return await self._fail(
request,
turn_row,
ErrorKind.PROTOCOL.value,
"سقف فراخوانی ابزار در این نوبت پر شد.",
tool_calls_made,
model_calls,
incomplete=True,
)
if await self._cancelled(request):
return await self._cancelled_result(
request, turn_row, tool_calls_made, model_calls
)
outcome = await self._execute_tool(request, tag)
tool_calls_made += 1
history.append(
TurnMessage(
kind="result", text=f"[[{outcome.tool}]] → {outcome.output}"
)
)
await self._step(
request,
kind="tool",
title=f"{outcome.tool}: {'موفق' if outcome.ok else 'ناموفق'}",
payload={"tool": outcome.tool, "ok": outcome.ok},
)
await self.feed.flush(run_id=request.run_id)
await self.feed.flush(run_id=request.run_id, force=True)
await self._close_turn_row(
turn_row, ok=True, tool_calls=tool_calls_made, reply=reply_text, tags=tags
)
return TurnResult(
ok=True,
reply_text=reply_text,
tags=tags,
tool_calls=tool_calls_made,
model_calls=model_calls,
turn_id=turn_row.id if turn_row else "",
)
# -- model ------------------------------------------------------------- #
async def _resolve_model(self, request: TurnRequest) -> AiModel:
"""The employee's granted model, or a clean auth failure."""
model_id = request.model_id
if not model_id:
raise _ModelUnavailable(
ErrorKind.AUTH, "برای این کارمند مدلی انتخاب نشده است."
)
granted = await self.grants.is_granted(
organization_id=request.organization_id, ai_model_id=model_id
)
if not granted:
raise _ModelUnavailable(
ErrorKind.AUTH,
"مدل این کارمند به سازمان شما اختصاص داده نشده است.",
)
model = await self.models.get(model_id)
if model is None or not model.is_active:
raise _ModelUnavailable(ErrorKind.MODEL, "مدل این کارمند موجود نیست.")
return model
async def _complete(self, request: TurnRequest, model: AiModel, prompt):
"""One model call, billed exactly like every other model call."""
pool = await self.credentials.list_active_for_provider(model.provider_id)
credential = pool[0] if pool else None
response = await self.provider.complete(
model=model,
credential=credential,
credentials=pool or None,
messages=prompt.messages,
system=prompt.system,
)
for unit, tokens in (
(RateUnit.MODEL_INPUT_TOKENS, float(response.input_tokens)),
(RateUnit.MODEL_OUTPUT_TOKENS, float(response.output_tokens)),
):
await record_model_usage(
ledger=self.ledger,
rate_cards=self.rate_cards,
organization_id=request.organization_id,
unit=unit,
quantity=tokens,
project_id=request.task.project_id,
user_id=request.user_id,
employee_id=request.employee.id if request.employee else None,
run_id=request.run_id,
)
return response
# -- tools ------------------------------------------------------------- #
async def _execute_tool(self, request: TurnRequest, tag: Tag) -> ToolOutcome:
"""Run one tool tag in the sandbox and record the call."""
if self.sandbox is None or not request.session_external_id:
return ToolOutcome(
tool=tag.name.value,
ok=False,
output="سندباکسی برای این نوبت در دسترس نیست.",
refused=True,
)
executor = ToolExecutor(
sandbox=self.sandbox,
session_external_id=request.session_external_id,
root=request.sandbox_root,
)
outcome = await executor.execute(tag)
await self._record_tool_call(request, tag, outcome)
await self.feed.tool(
run_id=request.run_id,
task_id=request.task.id,
organization_id=request.organization_id,
tool=outcome.tool,
ok=outcome.ok,
summary=outcome.output,
)
return outcome
async def _record_tool_call(self, request: TurnRequest, tag: Tag, outcome: ToolOutcome) -> None:
"""Persist the execution so the approvals UI shows real work."""
if self.tool_calls is None:
return
call = await self.tool_calls.create(
ToolCall(
run_id=request.run_id,
tool_key=tool_key_for(tag),
arguments_json={"tag": tag.name.value, "attrs": dict(tag.attrs)},
result_json={"ok": outcome.ok, "output": outcome.output[:4000]},
status=ToolCallStatus.SUCCEEDED if outcome.ok else ToolCallStatus.FAILED,
requires_approval=False,
)
)
outcome.tool_call_id = call.id
# -- records ----------------------------------------------------------- #
async def _open_turn_row(self, request: TurnRequest):
prompt = await self._build_prompt(request, list(request.history))
if self.turns is None:
return None
return await self.turns.create(
EngineTurn(
task_id=request.task.id,
run_id=request.run_id,
employee_id=request.employee.id if request.employee else None,
role=request.role,
mode=request.mode,
stage=request.stage,
prompt_chars=prompt.total_chars,
)
)
async def _close_turn_row(
self, turn_row, *, ok: bool, tool_calls: int, reply: str, tags: tuple[Tag, ...]
) -> None:
if turn_row is None or self.turns is None:
return
counts: dict[str, int] = {}
for tag in tags:
counts[tag.name.value] = counts.get(tag.name.value, 0) + 1
turn_row.ok = ok
turn_row.tool_calls = tool_calls
turn_row.reply_excerpt = reply[: settings.engine_reply_excerpt_chars]
turn_row.tags = counts
await self.turns.finish(
turn_row.id,
ok=ok,
tool_calls=tool_calls,
error_kind="",
reply_excerpt=turn_row.reply_excerpt,
tags=counts,
)
async def _fail(
self,
request: TurnRequest,
turn_row,
error_kind: str,
message: str,
tool_calls: int,
model_calls: int,
*,
incomplete: bool = False,
) -> TurnResult:
await self.feed.flush(run_id=request.run_id, force=True)
if turn_row is not None and self.turns is not None:
turn_row.ok = False
await self.turns.finish(
turn_row.id,
ok=False,
tool_calls=tool_calls,
error_kind=error_kind or ErrorKind.UNAVAILABLE.value,
)
await self._step(
request,
kind="error",
title="نوبت ناموفق بود.",
payload={"error_kind": error_kind, "message": message[:2000]},
)
return TurnResult(
ok=False,
error_kind=error_kind,
error=message,
tool_calls=tool_calls,
model_calls=model_calls,
incomplete=incomplete,
turn_id=turn_row.id if turn_row else "",
)
async def _cancelled_result(
self, request: TurnRequest, turn_row, tool_calls: int, model_calls: int
) -> TurnResult:
return await self._fail(
request,
turn_row,
ErrorKind.CANCELLED.value,
"اجرا لغو شد.",
tool_calls,
model_calls,
incomplete=True,
)
# -- helpers ----------------------------------------------------------- #
async def _build_prompt(self, request: TurnRequest, history: list[TurnMessage]):
memory_context = await self.memory.build_context(
organization_id=request.organization_id,
role=request.role.value,
query=f"{request.task.title}\n{request.task.description}",
budget_chars=2500,
)
return build_turn_prompt(
TurnContext(
organization_id=request.organization_id,
task_id=request.task.id,
project_id=request.task.project_id,
task_title=request.task.title,
task_description=request.task.description,
stage=request.stage,
role=request.role,
mode=request.mode,
memory_context=memory_context,
stage_context=request.extra_context,
history=history,
)
)
async def _cancelled(self, request: TurnRequest) -> bool:
"""Ask the caller's hook and the run itself whether to stop."""
if request.should_cancel is not None and await request.should_cancel():
return True
if self.runs is None:
return False
run: Run | None = await self.runs.get(request.run_id)
return run is not None and run.status in (
RunStatus.CANCELLED,
RunStatus.FAILED,
RunStatus.TIMED_OUT,
)
async def _step(
self, request: TurnRequest, *, kind: str, title: str, payload: dict
) -> None:
"""Append to the run timeline the UI renders."""
if self.run_steps is None:
return
await self.run_steps.append(
run_id=request.run_id, kind=kind, title=title, payload=payload
)
@staticmethod
def _terminal_tags(request: TurnRequest, tags: tuple[Tag, ...]) -> tuple[Tag, ...]:
wanted = TERMINAL_TAGS.get((request.role, request.mode), ())
return tuple(tag for tag in tags if tag.name in wanted)
class _ModelUnavailable(Exception):
"""The employee's model is missing or not granted to this organization."""
def __init__(self, kind: ErrorKind, message: str) -> None:
super().__init__(message)
self.kind = kind
__all__ = ["TERMINAL_TAGS", "TurnRequest", "TurnResult", "TurnRunner", "classify_error"]
+279
View File
@@ -0,0 +1,279 @@
"""Executing the toolkit inside the sandbox — and refusing what it must.
Four rules, all enforced here rather than at the call sites:
1. **Path safety.** Every path is resolved against the worktree's sandbox root.
Absolute paths that escape it, ``..`` traversal and anything under ``.git``
are refused. A symlink that points outside the repository is not followed:
the sandbox is the security boundary, and this is the second lock.
2. **The AI never touches git.** ``git push``, ``git remote`` and ``git config``
are refused with a message that says why — the platform does git through
``RepoWorkspacePort`` (PLAN-v2 D6), never the model.
3. **Bounded commands.** ``RUN`` has a timeout (default 120 s, hard max 600 s)
and its output is capped to the last ``engine_tool_output_chars``
characters, which is what the model needs anyway: a traceback is at the end.
4. **Every call is recorded.** Each execution writes a ``tool_calls`` row with
``succeeded``/``failed``, so the approvals UI finally shows real executions
instead of intentions.
The sandbox is reached only through ``SandboxProviderPort``; there is no local
shell-out path.
"""
from __future__ import annotations
import shlex
from dataclasses import dataclass
from pathlib import PurePosixPath
from app.application.engine.protocol import EditBlock, Tag, TagName, edit_blocks_of
from app.application.ports import CommandResult, SandboxProviderPort
from app.core.config import settings
from app.domain.workforce.tools import ToolKey
#: Commands the model may never run. The message is the point: a refusal the
#: model can read is a refusal it works around once.
_FORBIDDEN_GIT: tuple[tuple[str, str], ...] = (
("push", "git push"),
("remote", "git remote"),
("config", "git config"),
)
_GIT_REFUSAL = (
"دستور git رد شد: عملیات git را پلتفرم انجام می‌دهد، نه هوش مصنوعی."
)
#: RUN defaults and ceilings. A model asking for "no timeout" gets the ceiling.
RUN_DEFAULT_TIMEOUT = 120
RUN_MAX_TIMEOUT = 600
RUN_MAX_COMMAND_CHARS = 4000
#: Which protocol tag maps to which row of the existing tool_calls catalog.
_TOOL_KEY: dict[TagName, str] = {
TagName.READ_FILE: ToolKey.SANDBOX_READ_FILE,
TagName.WRITE_FILE: ToolKey.SANDBOX_WRITE_FILE,
TagName.EDIT_FILE: ToolKey.SANDBOX_WRITE_FILE,
TagName.LIST_DIR: ToolKey.SANDBOX_RUN_COMMAND,
TagName.SEARCH: ToolKey.SANDBOX_RUN_COMMAND,
TagName.RUN: ToolKey.SANDBOX_RUN_COMMAND,
}
class UnsafePathError(ValueError):
"""The requested path escapes the worktree."""
class GitRefusedError(ValueError):
"""The model tried to run git itself."""
@dataclass(slots=True, kw_only=True)
class ToolOutcome:
"""One tool execution: what happened, in a form the model can read."""
tool: str
ok: bool
output: str
refused: bool = False
#: The row key this call was recorded under, for the approvals UI.
tool_key: str = ""
tool_call_id: str = ""
def safe_join(root: str, relative: str) -> str:
"""Resolve ``relative`` inside ``root`` or raise :class:`UnsafePathError`.
Pure and synchronous on purpose: it is also the unit under test, and a path
bug must be reproducible without a container.
"""
if not relative or not relative.strip():
raise UnsafePathError("مسیر خالی است.")
candidate = relative.strip()
if candidate.startswith(("/", "~")):
raise UnsafePathError(f"مسیر مطلق مجاز نیست: {candidate}")
parts = PurePosixPath(candidate).parts
if any(part == ".." for part in parts):
raise UnsafePathError(f"خروج از ریشهٔ مخزن مجاز نیست: {candidate}")
if parts and parts[0] == ".git":
raise UnsafePathError("دسترسی به .git مجاز نیست.")
base = PurePosixPath(root.rstrip("/") or "/")
return str(base / candidate.lstrip("/"))
def _tail(text: str, *, limit: int) -> str:
"""Keep the *last* ``limit`` characters: a traceback is at the end."""
if len(text) <= limit:
return text
return f"[… {len(text) - limit} کاراکتر ابتدایی حذف شد …]\n{text[-limit:]}"
def _clamp_timeout(raw: str) -> int:
try:
value = int(float(raw)) if raw else RUN_DEFAULT_TIMEOUT
except ValueError:
value = RUN_DEFAULT_TIMEOUT
return max(1, min(value, RUN_MAX_TIMEOUT))
def _check_command(command: str) -> None:
if len(command) > RUN_MAX_COMMAND_CHARS:
raise ValueError("دستور بیش از حد طولانی است.")
tokens = shlex.split(command, comments=False, posix=True)
if not tokens:
raise ValueError("دستور خالی است.")
if tokens[0] != "git":
return
for sub, label in _FORBIDDEN_GIT:
if sub in tokens[1:]:
raise GitRefusedError(f"{_GIT_REFUSAL} («{label}»)")
def _shell_quote(value: str) -> str:
return shlex.quote(value)
class ToolExecutor:
"""Runs one parsed tool tag inside the worktree's sandbox session."""
def __init__(
self,
*,
sandbox: SandboxProviderPort,
session_external_id: str,
root: str,
max_output_chars: int | None = None,
) -> None:
self.sandbox = sandbox
self.session_external_id = session_external_id
self.root = root
self.max_output_chars = (
settings.engine_tool_output_chars if max_output_chars is None else max_output_chars
)
async def execute(self, tag: Tag) -> ToolOutcome:
"""Execute one tool tag. Never raises: failures come back as output."""
handler = _HANDLERS.get(tag.name)
if handler is None: # pragma: no cover - the matrix forbids it
return ToolOutcome(
tool=tag.name.value, ok=False, output="این ابزار در این نقش مجاز نیست."
)
try:
output = await handler(self, tag)
except UnsafePathError as exc:
return ToolOutcome(
tool=tag.name.value, ok=False, output=str(exc), refused=True
)
except GitRefusedError as exc:
return ToolOutcome(
tool=tag.name.value, ok=False, output=str(exc), refused=True
)
except FileNotFoundError as exc:
return ToolOutcome(tool=tag.name.value, ok=False, output=str(exc))
except OSError as exc:
return ToolOutcome(
tool=tag.name.value, ok=False, output=f"خطای سندباکس: {exc}"
)
except ValueError as exc:
return ToolOutcome(tool=tag.name.value, ok=False, output=str(exc))
return ToolOutcome(
tool=tag.name.value, ok=True, output=_tail(output, limit=self.max_output_chars)
)
# -- sandbox primitives -------------------------------------------------
async def _run(self, command: str, *, timeout: int) -> str:
_check_command(command)
result: CommandResult = await self.sandbox.run_command(
session_external_id=self.session_external_id,
command=command,
timeout_seconds=timeout,
max_output_bytes=settings.engine_tool_output_chars * 4,
)
text = result.stdout or ""
if result.stderr:
text = f"{text}\n{result.stderr}" if text else result.stderr
return f"$ {command}\n(exit {result.exit_code})\n{text}".strip()
async def _read(self, path: str) -> str:
return await self.sandbox.read_file(
session_external_id=self.session_external_id, path=safe_join(self.root, path)
)
async def _write(self, path: str, content: str) -> None:
await self.sandbox.write_file(
session_external_id=self.session_external_id,
path=safe_join(self.root, path),
content=content,
)
# -- the six tools ------------------------------------------------------
async def _read_file(self, tag: Tag) -> str:
path = tag.require("path")
content = await self._read(path)
return f"# {path}\n{content}"
async def _write_file(self, tag: Tag) -> str:
path = tag.require("path")
await self._write(path, tag.body)
return f"نوشته شد: {path} ({len(tag.body)} کاراکتر)"
async def _edit_file(self, tag: Tag) -> str:
path = tag.require("path")
blocks: list[EditBlock] = edit_blocks_of(tag)
content = await self._read(path)
for block in blocks:
occurrences = content.count(block.old)
if occurrences != 1:
raise ValueError(
f"بلوک قدیمی باید دقیقاً یک بار پیدا شود؛ {occurrences} بار پیدا شد."
)
content = content.replace(block.old, block.new, 1)
await self._write(path, content)
return f"ویرایش شد: {path} ({len(blocks)} بلوک)"
async def _list_dir(self, tag: Tag) -> str:
path = tag.attr("path", ".") or "."
target = safe_join(self.root, path)
return await self._run(f"ls -la -- {_shell_quote(target)}", timeout=30)
async def _search(self, tag: Tag) -> str:
pattern = tag.require("pattern")
path = tag.attr("path", ".") or "."
target = safe_join(self.root, path)
command = f"grep -RIn -e {_shell_quote(pattern)} -- {_shell_quote(target)}"
# grep exits 1 when nothing matched, which is an answer, not a failure.
return await self._run(command, timeout=60)
async def _run_command(self, tag: Tag) -> str:
command = tag.attr("cmd", "") or tag.body.strip()
if not command:
raise ValueError("دستور خالی است.")
timeout = _clamp_timeout(tag.attr("timeout", ""))
return await self._run(command, timeout=timeout)
_HANDLERS = {
TagName.READ_FILE: ToolExecutor._read_file,
TagName.WRITE_FILE: ToolExecutor._write_file,
TagName.EDIT_FILE: ToolExecutor._edit_file,
TagName.LIST_DIR: ToolExecutor._list_dir,
TagName.SEARCH: ToolExecutor._search,
TagName.RUN: ToolExecutor._run_command,
}
def tool_key_for(tag: Tag) -> str:
"""The ``tool_calls`` row key a protocol tag maps to."""
return _TOOL_KEY.get(tag.name, ToolKey.SANDBOX_RUN_COMMAND)
__all__ = [
"GitRefusedError",
"RUN_DEFAULT_TIMEOUT",
"RUN_MAX_TIMEOUT",
"ToolExecutor",
"ToolOutcome",
"UnsafePathError",
"safe_join",
"tool_key_for",
]
@@ -102,6 +102,58 @@ _SYSTEM_PROMPT = (
)
async def record_model_usage(
*,
ledger: UsageLedgerRepository,
rate_cards: RateCardRepository,
organization_id: str,
unit: RateUnit,
quantity: float,
project_id: str | None = None,
user_id: str | None = None,
employee_id: str | None = None,
run_id: str | None = None,
) -> None:
"""Write one usage event for a model call, and push it live.
**Every** model call goes through here — the run engine, the manager chat
and the engine's turn runner alike. Billing reads the internal ledger and
never the provider's invoice, so a call that is not recorded here is a
call the platform never bills for.
The pricing rule: a missing rate card is a misconfiguration, not a free
lunch, so the placeholder price is recorded and flagged as unpriced rather
than silently charging zero.
"""
card = await rate_cards.get_by_unit(unit)
unit_price = card.customer_price if card else _UNPRICED
internal_cost = card.internal_cost_estimate if card else 0.0
await ledger.record(
usage=UsageRecord(
organization_id=organization_id,
project_id=project_id,
user_id=user_id,
employee_id=employee_id,
run_id=run_id,
unit=unit,
quantity=quantity,
unit_price=unit_price,
internal_cost_estimate=quantity * internal_cost,
total_price=quantity * unit_price,
metadata_json={"priced": card is not None},
)
)
# Live push: the billing page and the sidebar credit badge react to
# consumption as it happens instead of at the next page load.
await emit_usage_recorded(
organization_id=organization_id,
unit=unit.value if hasattr(unit, "value") else str(unit),
quantity=quantity,
run_id=run_id,
employee_id=employee_id,
)
class WorkforceService:
def __init__(
self,
@@ -928,25 +980,17 @@ class WorkforceService:
employee_id: str | None = None,
run_id: str | None = None,
) -> None:
card = await self.rate_cards.get_by_unit(unit)
unit_price = card.customer_price if card else _UNPRICED
internal_cost = card.internal_cost_estimate if card else 0.0
await self.ledger.record(
usage=UsageRecord(
organization_id=organization_id,
project_id=project_id,
user_id=user_id,
employee_id=employee_id,
run_id=run_id,
unit=unit,
quantity=quantity,
unit_price=unit_price,
internal_cost_estimate=quantity * internal_cost,
total_price=quantity * unit_price,
metadata_json={"priced": card is not None},
)
await record_model_usage(
ledger=self.ledger,
rate_cards=self.rate_cards,
organization_id=organization_id,
unit=unit,
quantity=quantity,
project_id=project_id,
user_id=user_id,
employee_id=employee_id,
run_id=run_id,
)
# Live push: the billing page and the sidebar credit badge react to
# consumption as it happens instead of at the next page load.
await emit_usage_recorded(
organization_id=organization_id,
+11
View File
@@ -148,6 +148,17 @@ class Settings(BaseSettings):
engine_max_cycles: int = 6
engine_max_employees: int = 20
engine_tick_seconds: float = 10.0
#: Hard cap on a turn's total prompt characters. The builder truncates the
#: oldest tool results first (never the protocol, never the task brief).
engine_prompt_char_cap: int = 24000
#: How much of one tool's output is fed back to the model, and how much of
#: a reply is stored in ``engine_turns.reply_excerpt``.
engine_tool_output_chars: int = 8000
engine_reply_excerpt_chars: int = 4000
#: Real-time log batching (PLAN-v2 §5): one event per stream per interval,
#: at most this many lines per event.
engine_log_flush_seconds: float = 0.5
engine_log_max_lines_per_event: int = 200
# <<< agent-2
# >>> agent-3 (delivery & infra): add settings ONLY between these markers
+1
View File
@@ -33,6 +33,7 @@ class ErrorKind(StrEnum):
MODEL = "model"
PROTOCOL = "protocol"
BUDGET = "budget"
CANCELLED = "cancelled"
TRANSIENT_ERROR_KINDS: frozenset[ErrorKind] = frozenset(
@@ -408,14 +408,26 @@ class EngineTurnRepository:
return [_turn(r) for r in (await self.session.execute(stmt)).scalars()]
async def finish(
self, turn_id: str, *, ok: bool, tool_calls: int, error_kind: str = ""
self,
turn_id: str,
*,
ok: bool,
tool_calls: int,
error_kind: str = "",
reply_excerpt: str | None = None,
tags: dict | None = None,
) -> None:
"""Close a turn: outcome, tool count and the bounded reply excerpt."""
row = await self.session.get(EngineTurnRow, turn_id)
if row is None:
return
row.ok = ok
row.tool_calls = tool_calls
row.error_kind = error_kind
if reply_excerpt is not None:
row.reply_excerpt = reply_excerpt
if tags is not None:
row.tags = dict(tags)
row.finished_at = utcnow()
await self.session.flush()