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:
@@ -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 #
|
||||
|
||||
Reference in New Issue
Block a user