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.
This commit is contained in:
mjalmoudy94
2026-10-14 18:12:56 +03:30
parent c6488bfadf
commit 581f703910
15 changed files with 2385 additions and 99 deletions
+89 -23
View File
@@ -46,7 +46,7 @@ from app.application.engine.prompt import (
)
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 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
@@ -179,6 +179,62 @@ def classify_error(exc: BaseException) -> ErrorKind:
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."""
@@ -224,6 +280,7 @@ class TurnRunner:
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)
@@ -272,6 +329,17 @@ class TurnRunner:
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))
@@ -358,32 +426,22 @@ class TurnRunner:
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(
"""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,
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,
prompt=prompt,
scope=BillingScope(
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,
user_id=request.user_id,
run_id=request.run_id,
)
return response
),
)
# -- tools ------------------------------------------------------------- #
@@ -579,4 +637,12 @@ class _ModelUnavailable(Exception):
self.kind = kind
__all__ = ["TERMINAL_TAGS", "TurnRequest", "TurnResult", "TurnRunner", "classify_error"]
__all__ = [
"TERMINAL_TAGS",
"BillingScope",
"TurnRequest",
"TurnResult",
"TurnRunner",
"call_model",
"classify_error",
]