Files
mjalmoudy94 581f703910 feat(engine): the manager — chat orders, team, triage, verdicts, bootstrap
Phase 3 of the AI-workforce engine (TODO-agent-2):

- engine/manager.py: ManagerTurns.chat (CREATE_TASK/PAUSE/RESUME/CANCEL,
  HIRE/UPDATE_EMPLOYEE/FIRE, KNOWLEDGE), triage (ASSIGN/REJECT/ASK_USER),
  final_review (COMPLETE/REVISE +cycle), error_review (RESOLVE_ERROR/CANCEL),
  bootstrap_team; post_user_message now delegates to it.
- One billing path: call_model/BillingScope in runner.py is the single model
  entry point (fixes the agybot chat-billing bug).
- Team changes capped at 3/turn, employee cap, last manager cannot be fired,
  hires pick a granted model of the requested tier strictly.
- container_engine.py wires the real WorkforceBootstrapPort.
- Fixes: REVISE is a body tag (prompt and parser now agree); ASSIGN
  employee="" means pick-for-me; the runner's protocol-error retry is capped by
  engine_max_protocol_retries (was unbounded until the turn timeout).
- 20 new manager tests + regression tests; report in
  docs/plan/reports/agent-2-phase-3.md.
2026-10-14 18:12:56 +03:30

649 lines
24 KiB
Python

"""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, ModelResponse
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
@dataclass(slots=True, kw_only=True)
class BillingScope:
"""What a model call is billed to. One scope per call, never a default."""
organization_id: str
project_id: str | None = None
employee_id: str | None = None
user_id: str | None = None
run_id: str | None = None
async def call_model(
*,
provider: ModelProviderPort,
credentials: ModelCredentialRepository,
rate_cards: RateCardRepository,
ledger: UsageLedgerRepository,
model: AiModel,
prompt,
scope: BillingScope,
) -> ModelResponse:
"""Call the model and bill it. **The** single model entry point.
Every model call in the platform goes through here — a task turn, a
triage turn, a final verdict and the manager's chat alike. Billing reads
the internal ledger, never the provider's invoice, so a call that skips
this function is a call the platform never charges for (agybot's manager
chat bug).
"""
pool = await credentials.list_active_for_provider(model.provider_id)
credential = pool[0] if pool else None
response = await 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=ledger,
rate_cards=rate_cards,
organization_id=scope.organization_id,
unit=unit,
quantity=tokens,
project_id=scope.project_id,
user_id=scope.user_id,
employee_id=scope.employee_id,
run_id=scope.run_id,
)
return response
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
protocol_errors = 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:
protocol_errors += 1
if protocol_errors > settings.engine_max_protocol_retries:
return await self._fail(
request,
turn_row,
ErrorKind.PROTOCOL.value,
"پاسخ مدل مکرراً ناقض پروتکل بود.",
tool_calls_made,
model_calls,
incomplete=True,
)
# 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 through the one shared model entry point."""
return await call_model(
provider=self.provider,
credentials=self.credentials,
rate_cards=self.rate_cards,
ledger=self.ledger,
model=model,
prompt=prompt,
scope=BillingScope(
organization_id=request.organization_id,
project_id=request.task.project_id,
employee_id=request.employee.id if request.employee else None,
user_id=request.user_id,
run_id=request.run_id,
),
)
# -- 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",
"BillingScope",
"TurnRequest",
"TurnResult",
"TurnRunner",
"call_model",
"classify_error",
]