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
+61 -5
View File
@@ -1,17 +1,73 @@
"""Wiring for the AI-workforce workstream (team bootstrap).
Foundation default: a no-op. The engine agent replaces the body with the real bootstrap;
the function name and return type are FROZEN.
The org wizard calls ``build_workforce_bootstrap(session)`` after it commits a
new organization, then ``bootstrap_team`` creates the starting team. The team
is built on the *caller's* session, so the employees and the "waiting for a
model" note land in the same transaction as the organization.
"""
from __future__ import annotations
from sqlalchemy.ext.asyncio import AsyncSession
from app.application.ports.workforce_bootstrap import NullWorkforceBootstrap, WorkforceBootstrapPort
from app.application.ports.workforce_bootstrap import WorkforceBootstrapPort
_null_bootstrap = NullWorkforceBootstrap()
class _EngineWorkforceBootstrap:
"""The real bootstrap: one manager, planner, developer and tester.
Everything is built here, lazily, from the session: ``container.py`` is a
frozen composition root and the engine must not widen it, so the repos and
ports the manager needs are assembled where they are used.
"""
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def bootstrap_team(self, *, organization_id: str, project_id: str) -> None:
from app.application.container import get_model_provider
from app.application.container_org import build_memory, build_model_grants
from app.application.engine.manager import ManagerTurns
from app.infrastructure.repositories.ai_control import (
AiModelRepository,
ModelCredentialRepository,
RateCardRepository,
)
from app.infrastructure.repositories.audit import UsageLedgerRepository
from app.infrastructure.repositories.engine import (
EmployeeProfileRepository,
PipelineStateRepository,
)
from app.infrastructure.repositories.workforce import (
EmployeeRepository,
MessageRepository,
TaskRepository,
ThreadRepository,
)
session = self.session
manager = ManagerTurns(
provider=get_model_provider(),
employees=EmployeeRepository(session),
messages=MessageRepository(session),
tasks=TaskRepository(session),
threads=ThreadRepository(session),
models=AiModelRepository(session),
credentials=ModelCredentialRepository(session),
rate_cards=RateCardRepository(session),
ledger=UsageLedgerRepository(session),
grants=build_model_grants(session),
memory=build_memory(session),
profiles=EmployeeProfileRepository(session),
states=PipelineStateRepository(session),
)
await manager.bootstrap_team(
organization_id=organization_id, project_id=project_id
)
def build_workforce_bootstrap(session: AsyncSession) -> WorkforceBootstrapPort:
return _null_bootstrap
return _EngineWorkforceBootstrap(session)
__all__ = ["build_workforce_bootstrap"]
+19 -1
View File
@@ -186,4 +186,22 @@ class RunFeed:
return stream
__all__ = ["RunFeed"]
async def emit_team_changed(
*, organization_id: str, change: str, employee_id: str
) -> None:
"""The team changed (hired, fired, re-tiered): the workforce page reloads."""
await publish(
RealtimeEvent(
type="team.changed",
channels=(org_channel(organization_id),),
organization_id=organization_id,
data={
"change": change,
"employee_id": employee_id,
"organization_id": organization_id,
},
)
)
__all__ = ["RunFeed", "emit_team_changed"]
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -59,6 +59,8 @@ _PROTOCOL_HINTS: dict[tuple[RoleKey, Mode], str] = {
"تو مدیر هستی و کار را ارزیابی می‌کنی.\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'
@@ -92,7 +94,8 @@ _PROTOCOL_HINTS: dict[tuple[RoleKey, Mode], str] = {
'[[KNOWLEDGE kind="rule|fact" key="..."]'
"]]متن[[/KNOWLEDGE]]\n"
"پاسخ متنی‌ات برای کاربر نمایش داده می‌شود؛ برچسب‌ها کنارش اجرا می‌شوند. "
"کارها را اتمیک بساز و نگو که کدام فایل‌ها را هم‌زمان تغییر می‌دهند."
"کارها را اتمیک بساز و طوری تقسیم کن که دو کار هم‌زمان فایل‌های یکسان را "
"تغییر ندهند."
),
(RoleKey.PLANNER, Mode.WORK): (
"تو برنامه‌ریز هستی.\n"
+6 -4
View File
@@ -104,6 +104,7 @@ BODY_TAGS: frozenset[TagName] = frozenset(
TagName.EDIT_FILE,
TagName.KNOWLEDGE,
TagName.ASK_USER,
TagName.REVISE,
TagName.CREATE_TASK,
TagName.SPLIT,
TagName.PLAN_SPEC,
@@ -282,14 +283,15 @@ _EDIT_BLOCK_RE = re.compile(
def assign_of(tag: Tag) -> AssignTag:
"""Read an ``ASSIGN`` tag. ``employee=""`` means "pick for me"."""
employee = tag.attr("employee", "").strip()
if not employee and not tag.attr("task_id", "").strip():
task_id = tag.attr("task_id", "").strip()
# ``employee=""`` is a deliberate "pick the least busy one"; a tag with
# neither attribute at all is a mistake we quote back to the model.
if "employee" not in tag.attrs and "task_id" not in tag.attrs:
raise ProtocolError("[[ASSIGN]] needs employee=... or task_id=...")
pipeline = tag.attr("pipeline", "").strip()
if pipeline and pipeline not in ASSIGNABLE_PIPELINES:
raise ProtocolError(f"unknown pipeline: {pipeline}")
return AssignTag(
employee=employee, pipeline=pipeline, task_id=tag.attr("task_id", "").strip()
)
return AssignTag(employee=employee, pipeline=pipeline, task_id=task_id)
def task_draft_of(tag: Tag) -> TaskDraft:
+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",
]
@@ -27,8 +27,6 @@ from app.application.ports import (
from app.application.ports.realtime import (
emit_approval_pending,
emit_artifact_created,
emit_chat_pending,
emit_message_created,
emit_run_status,
emit_run_step,
emit_task_created,
@@ -51,7 +49,6 @@ from app.domain.workforce.models import (
EmployeeStatus,
EmployeeToolPermission,
Message,
MessageRole,
PromptVersion,
RoleKey,
Run,
@@ -364,67 +361,48 @@ class WorkforceService:
user_id: str,
employee_id: str | None = None,
) -> tuple[Message, Message]:
"""Append the user's message, then produce the assistant reply.
"""Append the user's message, then let the manager answer it.
Returns ``(user_message, assistant_message)`` so the caller can render
both without a second round-trip.
The manager's turn is the engine's job (``engine/manager.py``): it
replies, and it may create tasks, change the team or pause work in the
same breath. This method stays the single entry point the router calls;
it builds the engine's manager from the same session, so the message,
the task and the usage events commit together.
"""
thread = await self.get_thread_or_404(
organization_id=organization_id, thread_id=thread_id
)
if not content.strip():
raise ValidationError("متن پیام نمی‌تواند خالی باشد.")
user_message = await self.messages.create(
Message(thread_id=thread.id, role=MessageRole.USER, content=content)
)
employee: Employee | None = None
if employee_id:
employee = await self.get_employee_or_404(
organization_id=organization_id, employee_id=employee_id
)
else:
employee = await self._default_manager(organization_id)
# The manager is about to call a model. Tell the thread so the UI can
# show a thinking indicator immediately.
await emit_chat_pending(thread_id=thread.id, organization_id=organization_id)
history = await self.messages.list_for_thread(thread.id)
system_prompt = await self._system_prompt_for(employee)
reply = await self._complete(
manager = self._manager_turns()
return await manager.chat(
organization_id=organization_id,
employee=employee,
project_id=thread.project_id,
thread_id=thread_id,
content=content,
user_id=user_id,
system=system_prompt,
messages=[
ChatMessage(role=m.role.value, content=m.content)
for m in history
if m.role in (MessageRole.USER, MessageRole.ASSISTANT)
],
employee_id=employee_id,
)
assistant_message = await self.messages.create(
Message(
thread_id=thread.id,
role=MessageRole.ASSISTANT,
content=reply,
employee_id=employee.id if employee else None,
)
def _manager_turns(self):
"""The engine's manager service, wired on this service's session.
Ports that take a session (model grants, memory) are built here rather
than injected: ``container.py`` is a frozen composition root, and the
engine must not widen a constructor it does not own. Sharing the
session is the point — a reply, a task and its usage events belong to
one transaction.
"""
from app.application.container_org import build_memory, build_model_grants
from app.application.engine.manager import ManagerTurns
return ManagerTurns(
provider=self.provider,
employees=self.employees,
messages=self.messages,
tasks=self.tasks,
threads=self.threads,
models=self.models,
credentials=self.credentials,
rate_cards=self.rate_cards,
ledger=self.ledger,
grants=build_model_grants(self.tasks.session),
memory=build_memory(self.tasks.session),
)
# Live push: any browser on this thread sees the reply arrive, and the
# pending indicator clears, without waiting for a reload.
await emit_message_created(
thread_id=thread.id,
organization_id=organization_id,
message_id=assistant_message.id,
role=MessageRole.ASSISTANT.value,
content=reply,
employee_id=employee.id if employee else None,
)
return user_message, assistant_message
# ------------------------------------------------------------------ #
# Tasks #