Files
smart-org/apps/api/app/application/engine/manager.py
T
mjalmoudy94andClaude Sonnet 5.5 fef5a9deab feat(integration): wire v2 workstreams together (engine policy/budget, sandbox, tenant checks, order route, docs)
Engine honours org policy and monthly budget, legacy runner skips engine tasks,
sandbox session provisioned on demand, project fallback for threads, reviewer
fallback, tenant checks on project ids, memory reflection respects grants,
expiry worker, MCP pipeline_status, login redirect, sharing settings, e2e smoke.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 20:57:56 +03:30

1093 lines
42 KiB
Python

"""The manager AI: orders become tasks, tasks become team work.
This is the layer that turns a user's sentence into the engine's machinery, and
it is deliberately the *only* place that interprets the manager's tags:
* **3.1 chat** — the user talks to the manager; the manager replies and may
emit ``CREATE_TASK`` (≤ 3), ``PAUSE_TASK`` / ``RESUME_TASK`` / ``CANCEL_TASK``
and ``HIRE`` / ``UPDATE_EMPLOYEE`` / ``FIRE``. Tasks get a pipeline state at
``pending_manager_review`` and are linked to the thread. The prompt asks for
**atomic tasks that do not touch the same files**, and the chat's model calls
are billed like every other call.
* **3.2 team** — hiring picks a *granted* model for the requested neutral tier,
respects the employee cap and the "last manager" rule, and publishes
``team.changed``. Users choosing from the UI give a role and a tier only;
model identity never crosses the API.
* **3.3 triage** — ``ASSIGN`` (pipeline + assignee), ``REJECT`` or ``ASK_USER``.
Assignee selection defaults to the **least busy** active employee with the
role, and a name is matched fuzzily **inside the org only**.
* **3.4 final / error** — ``COMPLETE`` → ``merge_request``, ``REVISE`` → back to
the developer with ``cycle + 1``; ``RESOLVE_ERROR`` retries with a hint,
``CANCEL`` ends the task.
* **3.5 knowledge** — ``KNOWLEDGE`` tags are recorded through ``MemoryPort``.
* **3.6 bootstrap** — a new org gets manager + planner + developer + tester on
the strongest / balanced / fast granted tiers; with nothing granted the
employees are created inactive and a system message says so. Idempotent.
Every query here is tenant-scoped: the manager of org A can never assign an
employee of org B, even by exact name.
"""
from __future__ import annotations
import contextlib
import time
from dataclasses import dataclass
from app.application.engine.events import emit_team_changed
from app.application.engine.prompt import TurnContext, TurnMessage, build_turn_prompt
from app.application.engine.protocol import (
AssignTag,
ParsedReply,
TagName,
assign_of,
knowledge_items,
parse_reply,
task_drafts,
team_changes,
)
from app.application.engine.runner import (
BillingScope,
TurnRequest,
TurnResult,
TurnRunner,
call_model,
classify_error,
)
from app.application.engine.state import advance
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.realtime import emit_task_created
from app.core.config import settings
from app.core.errors import ConflictError, NotFoundError, ValidationError
from app.domain.workforce.engine import EmployeeProfile, ErrorKind
from app.domain.workforce.models import (
Employee,
EmployeeStatus,
Message,
MessageRole,
RoleKey,
Task,
TaskStatus,
Thread,
)
from app.domain.workforce.pipeline import (
Mode,
PipelineKey,
Risk,
Stage,
TaskKind,
TaskPipelineState,
coarse_status,
resolve_pipeline,
)
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,
StaleStageError,
)
from app.infrastructure.repositories.workforce import (
EmployeeRepository,
MessageRepository,
TaskRepository,
ThreadRepository,
)
#: How long a chat turn may take. Chat is interactive, so it is much shorter
#: than a task turn's budget.
CHAT_TURN_TIMEOUT_SECONDS = 120
#: How many times a chat reply may be corrected for protocol errors.
CHAT_MAX_ATTEMPTS = 2
#: The message a new organization sees when the owner has granted nothing yet.
NO_MODEL_GRANTED_MESSAGE = "منتظر تخصیص مدل توسط مالک سامانه"
#: Bootstrap tiers per role, in the plan's order (strongest / balanced / fast).
BOOTSTRAP_TIERS: dict[RoleKey, str] = {
RoleKey.MANAGER: "strong",
RoleKey.PLANNER: "balanced",
RoleKey.DEVELOPER: "balanced",
RoleKey.TESTER: "fast",
}
#: When a tier is not granted, fall back down this ladder rather than failing.
_TIER_FALLBACK: dict[str, tuple[str, ...]] = {
"strong": ("strong", "balanced", "fast"),
"balanced": ("balanced", "fast"),
"fast": ("fast", "balanced"),
}
#: Roles a manager may hire through chat.
HIRABLE_ROLES: tuple[RoleKey, ...] = (
RoleKey.MANAGER,
RoleKey.PLANNER,
RoleKey.DEVELOPER,
RoleKey.REVIEWER,
RoleKey.TESTER,
)
#: Persian labels, so a prompt can show a human-readable roster.
_ROLE_LABELS: dict[RoleKey, str] = {
RoleKey.MANAGER: "مدیر",
RoleKey.PLANNER: "برنامه‌ریز",
RoleKey.DEVELOPER: "برنامه‌نویس",
RoleKey.REVIEWER: "بازبین",
RoleKey.TESTER: "آزمونگر",
RoleKey.RESEARCHER: "پژوهشگر",
RoleKey.CONTENT_CREATOR: "تولیدکنندهٔ محتوا",
RoleKey.SUPPORT: "پشتیبان",
}
#: The system thread's title, where "no model granted" style notes live.
SYSTEM_THREAD_TITLE = "پیام‌های سامانه"
@dataclass(slots=True, kw_only=True)
class ManagerOutcome:
"""What a manager turn decided, in the engine's own words."""
acted: bool
decision: str = "idle"
stage: Stage | None = None
message: str = ""
employee_id: str | None = None
pipeline: PipelineKey | None = None
#: The runner's error vocabulary, so the scheduler can tell a transient
#: provider failure from a permanent one without re-classifying a string.
error_kind: str = ""
def _fuzzy_match(name: str, candidates: list[str]) -> str | None:
"""Fuzzy name matching **inside one organization**.
Exact match first, then a case-insensitive containment either way. Ties are
resolved by the caller's ordering (least busy first), never by luck.
"""
wanted = name.strip().lower()
if not wanted:
return None
for candidate in candidates:
if candidate.lower() == wanted:
return candidate
for candidate in candidates:
if wanted in candidate.lower():
return candidate
for candidate in candidates:
if candidate.lower() in wanted:
return candidate
return None
def _pick_granted_model(granted, tier: str, *, fallback: bool = True) -> str | None:
"""The granted model for a tier.
``fallback=False`` is the strict mode an explicit hire uses: the tier the
caller asked for, or nothing. The default is the bootstrap's "whatever is
granted" behaviour, where a degraded team beats no team.
"""
for candidate in _TIER_FALLBACK.get(tier, (tier,)) if fallback else (tier,):
for model in granted:
if model.tier == candidate:
return model.ai_model_id
return None
def _bootstrap_name(role: RoleKey) -> str:
return f"{_ROLE_LABELS.get(role, role.value)} یک"
class ManagerTurns:
"""The manager's voice: chat, triage, final verdict, error handling, team."""
def __init__(
self,
*,
provider: ModelProviderPort,
employees: EmployeeRepository,
messages: MessageRepository,
tasks: TaskRepository,
threads: ThreadRepository,
models: AiModelRepository,
credentials: ModelCredentialRepository,
rate_cards: RateCardRepository,
ledger: UsageLedgerRepository,
grants: ModelGrantPort,
memory: MemoryPort | None = None,
profiles: EmployeeProfileRepository | None = None,
states: PipelineStateRepository | None = None,
runner: TurnRunner | None = None,
feed=None,
) -> None:
self.provider = provider
self.employees = employees
self.messages = messages
self.tasks = tasks
self.threads = threads
self.models = models
self.credentials = credentials
self.rate_cards = rate_cards
self.ledger = ledger
self.grants = grants
self.memory = memory or NullMemory()
self.profiles = profiles
self.states = states
self.runner = runner
self.feed = feed
# ------------------------------------------------------------------ #
# 3.6 bootstrap #
# ------------------------------------------------------------------ #
async def bootstrap_team(self, *, organization_id: str, project_id: str) -> None:
"""Create the starting team of a new organization. Idempotent.
With no granted model the employees still exist — inactive — so the
organization sees its team and the reason it cannot work yet, rather
than an empty page and a mystery.
"""
if await self.employees.list_for_org(organization_id, limit=1):
return
granted = await self.grants.granted_models(organization_id=organization_id)
for role in BOOTSTRAP_TIERS:
model_id = _pick_granted_model(granted, BOOTSTRAP_TIERS[role])
employee = await self.employees.create(
Employee(
organization_id=organization_id,
name=_bootstrap_name(role),
role_key=role,
project_id=project_id,
status=(
EmployeeStatus.ACTIVE if model_id else EmployeeStatus.PAUSED
),
)
)
if self.profiles is not None:
await self.profiles.upsert(
EmployeeProfile(
employee_id=employee.id,
ai_model_id=model_id,
roles=[role.value],
hired_by="manager",
)
)
if not granted:
await self._post_system_message(
organization_id=organization_id,
project_id=project_id,
content=NO_MODEL_GRANTED_MESSAGE,
)
# ------------------------------------------------------------------ #
# 3.2 team management #
# ------------------------------------------------------------------ #
async def hire(
self,
*,
organization_id: str,
role: RoleKey,
tier: str,
name: str = "",
project_id: str | None = None,
hired_by: str = "manager",
) -> Employee:
"""Hire one employee on a granted model of the requested tier."""
if role not in HIRABLE_ROLES:
raise ValidationError("این نقش قابل استخدام نیست.")
if not tier:
raise ValidationError("سطح کارمند را انتخاب کنید.")
# The owner's per-org policy may lower the global cap, never raise it.
policy = await self.grants.get_org_policy(organization_id)
cap = min(settings.engine_max_employees, policy.max_employees)
count = await self.employees.count_for_org(organization_id)
if count >= cap:
raise ConflictError(f"سقف تعداد کارمندان ({cap}) پر شده است.")
granted = await self.grants.granted_models(organization_id=organization_id)
# A hire is an explicit request for a tier: no silent downgrade, or the
# manager would think it hired a strong employee and got a fast one.
model_id = _pick_granted_model(granted, tier, fallback=False)
if model_id is None:
raise ConflictError("مدلی از این سطح به سازمان شما اختصاص داده نشده است.")
employee = await self.employees.create(
Employee(
organization_id=organization_id,
name=(name or _bootstrap_name(role)).strip(),
role_key=role,
project_id=project_id or await self._project_id_for(organization_id),
)
)
if self.profiles is not None:
await self.profiles.upsert(
EmployeeProfile(
employee_id=employee.id,
ai_model_id=model_id,
roles=[role.value],
hired_by=hired_by,
)
)
await emit_team_changed(
organization_id=organization_id, change="hired", employee_id=employee.id
)
return employee
async def fire(self, *, organization_id: str, employee_id: str) -> Employee:
"""Archive one employee. The last manager may not be fired."""
employee = await self.employees.get_for_org(organization_id, employee_id)
if employee is None:
raise NotFoundError("کارمند یافت نشد.")
if employee.role_key is RoleKey.MANAGER:
managers = [
e
for e in await self.employees.list_for_org(organization_id)
if e.role_key is RoleKey.MANAGER and e.status is EmployeeStatus.ACTIVE
]
if len(managers) <= 1:
raise ConflictError("آخرین مدیر سازمان را نمی‌توان حذف کرد.")
employee.status = EmployeeStatus.ARCHIVED
updated = await self.employees.update(employee)
if self.profiles is not None:
await self.profiles.set_busy(employee_id=employee_id, task_id=None)
await emit_team_changed(
organization_id=organization_id, change="fired", employee_id=employee_id
)
return updated
async def update_tier(
self, *, organization_id: str, employee_id: str, tier: str
) -> Employee:
"""Re-point an employee at a granted model of another tier."""
employee = await self.employees.get_for_org(organization_id, employee_id)
if employee is None:
raise NotFoundError("کارمند یافت نشد.")
granted = await self.grants.granted_models(organization_id=organization_id)
model_id = _pick_granted_model(granted, tier, fallback=False)
if model_id is None:
raise ConflictError("مدلی از این سطح به سازمان شما اختصاص داده نشده است.")
if self.profiles is not None:
await self.profiles.set_model(employee_id=employee_id, ai_model_id=model_id)
await emit_team_changed(
organization_id=organization_id, change="updated", employee_id=employee_id
)
return employee
# ------------------------------------------------------------------ #
# 3.1 manager chat #
# ------------------------------------------------------------------ #
async def chat(
self,
*,
organization_id: str,
thread_id: str,
content: str,
user_id: str,
employee_id: str | None = None,
) -> tuple[Message, Message]:
"""The manager hears the order, answers it, and may act on it.
The reply the user reads is the prose *outside* the tags; the tags are
executed beside it. That separation is what lets one message create a
task and still read like a conversation.
"""
thread = await self.threads.get_for_org(organization_id, thread_id)
if thread is None:
raise NotFoundError("گفتگو یافت نشد.")
if not content.strip():
raise ValidationError("متن پیام نمی‌تواند خالی باشد.")
user_message = await self.messages.create(
Message(thread_id=thread.id, role=MessageRole.USER, content=content)
)
employee = await self._manager_of(organization_id, employee_id)
extra = "\n\n".join(
part
for part in (
await self._roster_text(organization_id),
await self._tasks_text(organization_id),
await self._conversation_text(thread),
)
if part
)
reply_text = ""
parsed: ParsedReply | None = None
deadline = time.monotonic() + CHAT_TURN_TIMEOUT_SECONDS
context = self._chat_context(
organization_id=organization_id,
thread=thread,
content=content,
extra=extra,
history=[],
)
for _attempt in range(CHAT_MAX_ATTEMPTS):
model = await self._model_for(employee, organization_id)
if model is None:
await self._system_note(
thread, "مدیری با مدل فعلاً در دسترس نیست."
)
break
try:
response = await call_model(
provider=self.provider,
credentials=self.credentials,
rate_cards=self.rate_cards,
ledger=self.ledger,
model=model,
prompt=build_turn_prompt(context),
scope=BillingScope(
organization_id=organization_id,
project_id=thread.project_id,
employee_id=employee.id if employee else None,
user_id=user_id,
),
)
except Exception as exc: # noqa: BLE001 - chat must always answer
await self._system_note(
thread, f"مدیر موقتاً پاسخ نمی‌دهد ({classify_error(exc).value})."
)
break
reply_text = response.content
parsed = parse_reply(
response.content, role=RoleKey.MANAGER, mode=Mode.CHAT
)
if not parsed.errors or parsed.tags:
break
# A second chance, with the protocol errors quoted back to it.
context = self._chat_context(
organization_id=organization_id,
thread=thread,
content=content,
extra=extra,
history=[TurnMessage(kind="result", text="\n".join(parsed.errors))],
)
if time.monotonic() > deadline:
break
actions: list[str] = []
if parsed is not None and parsed.tags:
actions = await self._dispatch_chat_tags(
parsed=parsed,
organization_id=organization_id,
thread=thread,
user_id=user_id,
)
await self._record_knowledge(parsed, organization_id=organization_id)
body = (parsed.prose if parsed else "") or reply_text
if actions:
body = f"{body}\n\n" + "\n".join(f"• {action}" for action in actions)
assistant_message = await self.messages.create(
Message(
thread_id=thread.id,
role=MessageRole.ASSISTANT,
content=body.strip() or "دستور دریافت شد.",
employee_id=employee.id if employee else None,
)
)
return user_message, assistant_message
def _chat_context(
self,
*,
organization_id: str,
thread: Thread,
content: str,
extra: str,
history: list[TurnMessage],
) -> TurnContext:
return TurnContext(
organization_id=organization_id,
task_id=thread.id,
project_id=thread.project_id,
task_title="گفتگو با مدیر سازمان",
task_description=content,
stage=Stage.PENDING_MANAGER_REVIEW,
role=RoleKey.MANAGER,
mode=Mode.CHAT,
stage_context=extra,
history=history,
)
async def _dispatch_chat_tags(
self,
*,
parsed: ParsedReply,
organization_id: str,
thread: Thread,
user_id: str,
) -> list[str]:
"""Execute the chat's tags, returning one Persian line per action."""
actions: list[str] = []
for draft in task_drafts(parsed):
kind = (
TaskKind(draft.kind)
if draft.kind in {k.value for k in TaskKind}
else TaskKind.FEATURE
)
# A thread made by the web UI has no project; the org's project is the fallback.
project_id = thread.project_id or await self._project_id_for(organization_id)
task = await self.tasks.create(
Task(
organization_id=organization_id,
title=draft.title[:200],
description=draft.description,
project_id=project_id,
thread_id=thread.id,
created_by_user_id=user_id,
)
)
if self.states is not None:
await self.states.create(
TaskPipelineState(
task_id=task.id,
organization_id=organization_id,
project_id=project_id,
pipeline=resolve_pipeline(
manager_choice=draft.pipeline or None,
task_kind=kind,
has_active_reviewer=await self._has_active_reviewer(organization_id),
),
kind=kind,
risk=Risk.MEDIUM,
stage=Stage.PENDING_MANAGER_REVIEW,
depends_on=list(draft.depends_on),
)
)
await emit_task_created(
task_id=task.id,
organization_id=organization_id,
title=task.title,
status=TaskStatus.NEW.value,
)
actions.append(f"کار ساخته شد: {task.title}")
for change in team_changes(parsed):
try:
if change.action is TagName.HIRE:
role = (
RoleKey(change.role)
if change.role in {r.value for r in RoleKey}
else RoleKey.DEVELOPER
)
employee = await self.hire(
organization_id=organization_id,
role=role,
tier=change.tier or "balanced",
name=change.name,
project_id=thread.project_id,
)
actions.append(f"کارمند استخدام شد: {employee.name}")
elif change.action in (TagName.FIRE, TagName.UPDATE_EMPLOYEE):
employee_id = change.employee_id or await self._employee_id_by_name(
organization_id, change.name
)
if change.action is TagName.FIRE:
employee = await self.fire(
organization_id=organization_id, employee_id=employee_id
)
actions.append(f"کارمند حذف شد: {employee.name}")
else:
await self.update_tier(
organization_id=organization_id,
employee_id=employee_id,
tier=change.tier or "balanced",
)
actions.append("سطح کارمند تغییر کرد.")
except (ConflictError, NotFoundError, ValidationError) as exc:
actions.append(str(exc))
for tag in parsed.tags:
if tag.name not in (TagName.PAUSE_TASK, TagName.RESUME_TASK, TagName.CANCEL_TASK):
continue
task_id = tag.attr("task_id", "").strip()
state = await self._state_for(organization_id, task_id)
if state is None or self.states is None:
actions.append("کاری با این شناسه پیدا نشد.")
continue
if tag.name is TagName.CANCEL_TASK:
target = Stage.CANCELLED
done = "کار لغو شد."
elif tag.name is TagName.PAUSE_TASK:
target = Stage.PAUSED
done = "کار متوقف شد."
else:
target = Stage.PENDING_MANAGER_REVIEW
done = "کار از سر گرفته شد."
try:
await advance(
self.states,
task_id=task_id,
expected_from=state.stage,
to=target,
)
except ConflictError as exc:
actions.append(str(exc))
continue
if self.tasks is not None:
await self.tasks.set_status(task_id, coarse_status(target))
actions.append(done)
return actions
# ------------------------------------------------------------------ #
# 3.3 triage #
# ------------------------------------------------------------------ #
async def triage(
self,
*,
task: Task,
run_id: str,
employee: Employee,
stage_context: str = "",
) -> ManagerOutcome:
"""The manager's triage turn: assign, reject, or ask the user."""
roster = await self._roster_text(task.organization_id)
result = await self._run(
await self._request(
task=task,
employee=employee,
role=RoleKey.MANAGER,
mode=Mode.TRIAGE,
stage=Stage.PENDING_MANAGER_REVIEW,
run_id=run_id,
extra_context="\n\n".join(p for p in (stage_context, roster) if p),
),
employee,
)
if not result.ok:
return ManagerOutcome(
acted=False,
message=result.error,
stage=Stage.PENDING_MANAGER_REVIEW,
error_kind=result.error_kind,
)
parsed = parse_reply(result.reply_text, role=RoleKey.MANAGER, mode=Mode.TRIAGE)
assign = parsed.first(TagName.ASSIGN)
if assign is not None:
return await self._apply_assign(task, assign)
reject = parsed.first(TagName.REJECT)
if reject is not None:
await self._move(task, Stage.CANCELLED)
return ManagerOutcome(
acted=True,
decision="rejected",
stage=Stage.CANCELLED,
message=reject.attr("reason", ""),
)
ask = parsed.first(TagName.ASK_USER)
if ask is not None:
await self._move(task, Stage.PENDING_USER_DECISION)
if task.thread_id:
await self.messages.create(
Message(
thread_id=task.thread_id,
role=MessageRole.ASSISTANT,
content=ask.body.strip(),
employee_id=employee.id,
)
)
return ManagerOutcome(
acted=True,
decision="asked_user",
stage=Stage.PENDING_USER_DECISION,
message=ask.body.strip(),
)
return ManagerOutcome(
acted=False, stage=Stage.PENDING_MANAGER_REVIEW, message=result.reply_text
)
async def _apply_assign(self, task: Task, tag) -> ManagerOutcome:
"""Apply an ``ASSIGN``: pick the assignee and the pipeline."""
try:
assign: AssignTag = assign_of(tag)
except ValueError as exc:
return ManagerOutcome(
acted=False, message=str(exc), stage=Stage.PENDING_MANAGER_REVIEW
)
assignee = await self._pick_assignee(
task.organization_id, assign.employee or ""
)
if assignee is None:
return ManagerOutcome(
acted=False,
decision="idle",
message="کارمند مناسبی برای این کار پیدا نشد.",
stage=Stage.PENDING_MANAGER_REVIEW,
)
state = await self._state_for(task.organization_id, task.id)
pipeline = resolve_pipeline(
manager_choice=assign.pipeline or None,
task_kind=state.kind if state else None,
has_active_reviewer=await self._has_active_reviewer(task.organization_id),
)
first_stage = (
Stage.PENDING_ANALYSIS
if pipeline is not PipelineKey.CODE_TEST
else Stage.PENDING_IMPLEMENTATION
)
await self.tasks.set_assignee(task.id, assignee.id)
if state is not None and self.states is not None:
# A lost race here is fine: the advance below still moves the task,
# and the pipeline column is a hint the next triage re-reads anyway.
with contextlib.suppress(StaleStageError):
await self.states.patch(
task_id=task.id,
expected_version=state.version,
pipeline=str(pipeline),
)
await self._move(task, first_stage)
return ManagerOutcome(
acted=True,
decision="assigned",
stage=first_stage,
employee_id=assignee.id,
pipeline=pipeline,
)
# ------------------------------------------------------------------ #
# 3.4 final verdict and error handling #
# ------------------------------------------------------------------ #
async def final_review(
self,
*,
task: Task,
run_id: str,
employee: Employee,
spec: str = "",
diff_text: str = "",
test_output: str = "",
gate_summary: str = "",
) -> ManagerOutcome:
"""The manager's final verdict: merge or send it back."""
extra = "\n\n".join(p for p in (spec, diff_text, test_output, gate_summary) if p)
result = await self._run(
await self._request(
task=task,
employee=employee,
role=RoleKey.MANAGER,
mode=Mode.FINAL,
stage=Stage.PENDING_FINAL_REVIEW,
run_id=run_id,
extra_context=extra,
),
employee,
)
if not result.ok:
return ManagerOutcome(
acted=False,
message=result.error,
stage=Stage.PENDING_FINAL_REVIEW,
error_kind=result.error_kind,
)
parsed = parse_reply(result.reply_text, role=RoleKey.MANAGER, mode=Mode.FINAL)
if parsed.has(TagName.COMPLETE):
await self._move(task, Stage.MERGE_REQUEST)
return ManagerOutcome(
acted=True, decision="completed", stage=Stage.MERGE_REQUEST
)
revise = parsed.first(TagName.REVISE)
if revise is not None:
state = await self._state_for(task.organization_id, task.id)
if state is not None and self.states is not None:
await self.states.patch(
task_id=task.id, expected_version=state.version, cycle=state.cycle + 1
)
await self._move(task, Stage.PENDING_IMPLEMENTATION)
return ManagerOutcome(
acted=True,
decision="revised",
stage=Stage.PENDING_IMPLEMENTATION,
message=revise.body.strip(),
)
return ManagerOutcome(
acted=False, stage=Stage.PENDING_FINAL_REVIEW, message=result.reply_text
)
async def error_review(
self,
*,
task: Task,
run_id: str,
employee: Employee,
error_summary: str,
hint: str = "",
) -> ManagerOutcome:
"""The manager's error turn: retry with a hint, or give up."""
detail = f"خطا:\n{error_summary}"
if hint:
detail += f"\n\nراهنمایی قبلی: {hint}"
result = await self._run(
await self._request(
task=task,
employee=employee,
role=RoleKey.MANAGER,
mode=Mode.ERROR,
stage=Stage.ERROR_REVIEW,
run_id=run_id,
extra_context=detail,
),
employee,
)
if not result.ok:
return ManagerOutcome(
acted=False,
message=result.error,
stage=Stage.ERROR_REVIEW,
error_kind=result.error_kind,
)
parsed = parse_reply(result.reply_text, role=RoleKey.MANAGER, mode=Mode.ERROR)
resolve = parsed.first(TagName.RESOLVE_ERROR)
if resolve is not None:
await self._move(task, Stage.PENDING_IMPLEMENTATION)
return ManagerOutcome(
acted=True,
decision="resolved",
stage=Stage.PENDING_IMPLEMENTATION,
message=resolve.attr("hint", ""),
)
cancel = parsed.first(TagName.CANCEL)
if cancel is not None:
await self._move(task, Stage.CANCELLED)
return ManagerOutcome(
acted=True,
decision="cancelled",
stage=Stage.CANCELLED,
message=cancel.attr("reason", ""),
)
return ManagerOutcome(
acted=False, stage=Stage.ERROR_REVIEW, message=result.reply_text
)
# ------------------------------------------------------------------ #
# internals #
# ------------------------------------------------------------------ #
async def _run(self, request: TurnRequest, employee: Employee) -> TurnResult:
if self.runner is None:
return TurnResult(
ok=False,
error_kind=ErrorKind.UNAVAILABLE.value,
error="موتور اجرا در دسترس نیست.",
)
return await self.runner.run_turn(request)
async def _request(
self,
*,
task: Task,
employee: Employee,
role: RoleKey,
mode: Mode,
stage: Stage,
run_id: str,
extra_context: str = "",
) -> TurnRequest:
return TurnRequest(
organization_id=task.organization_id,
task=task,
role=role,
mode=mode,
stage=stage,
run_id=run_id,
employee=employee,
model_id=await self._model_id_for(employee, task.organization_id),
extra_context=extra_context,
)
async def _model_id_for(
self, employee: Employee | None, organization_id: str
) -> str | None:
"""The model id an employee runs on: its profile, or a granted tier."""
if employee is None:
return None
model_id = getattr(employee, "model_id", None)
if not model_id and self.profiles is not None:
profile = await self.profiles.get(employee.id)
model_id = profile.ai_model_id if profile else None
if not model_id:
granted = await self.grants.granted_models(organization_id=organization_id)
model_id = _pick_granted_model(
granted, BOOTSTRAP_TIERS.get(employee.role_key, "balanced")
)
return model_id
async def _model_for(self, employee: Employee | None, organization_id: str):
"""Resolve the model an employee runs on, or ``None``.
A manager without a granted model cannot answer: the caller says so in
the thread rather than raising into the user's face.
"""
if employee is None:
return None
model_id = await self._model_id_for(employee, organization_id)
if not model_id:
return None
if not await self.grants.is_granted(
organization_id=organization_id, ai_model_id=model_id
):
return None
return await self.models.get(model_id)
async def _manager_of(
self, organization_id: str, employee_id: str | None
) -> Employee | None:
if employee_id:
return await self.employees.get_for_org(organization_id, employee_id)
for employee in await self.employees.list_for_org(organization_id):
if employee.role_key is RoleKey.MANAGER and employee.is_available:
return employee
return None
async def _pick_assignee(
self, organization_id: str, wanted: str
) -> Employee | None:
"""The employee who takes the task: least busy, or the named one.
The name is matched **inside this organization only** — a name from
another tenant resolves to nothing, which is the isolation rule the
tests enforce.
"""
roster = [
e
for e in await self.employees.list_for_org(organization_id)
if e.is_available and e.role_key is not RoleKey.MANAGER
]
if not roster:
return None
if wanted:
match = _fuzzy_match(wanted, [e.name for e in roster])
for employee in roster:
if employee.name == match:
return employee
return None
loads = [(await self.tasks.count_open_for_employee(e.id), e) for e in roster]
loads.sort(key=lambda pair: pair[0])
return loads[0][1]
async def _employee_id_by_name(self, organization_id: str, name: str) -> str:
roster = await self.employees.list_for_org(organization_id)
match = _fuzzy_match(name, [e.name for e in roster])
for employee in roster:
if employee.name == match:
return employee.id
raise NotFoundError("کارمندی با این نام پیدا نشد.")
async def _roster_text(self, organization_id: str) -> str:
"""The team as prompt text: names and roles, never model identity."""
lines = ["تیم سازمان:"]
for employee in await self.employees.list_for_org(organization_id):
status = "فعال" if employee.is_available else "غیرفعال"
label = _ROLE_LABELS.get(employee.role_key, employee.role_key.value)
lines.append(f"- {employee.name} ({label}) — {status}")
return "\n".join(lines)
async def _tasks_text(self, organization_id: str) -> str:
"""The organization's open tasks, so the manager can act on them by id."""
lines = ["کارهای باز:"]
for task in await self.tasks.list_for_org(organization_id, limit=20):
state = await self._state_for(organization_id, task.id)
stage = state.stage.value if state else task.status.value
lines.append(f"- {task.id}: {task.title} [{stage}]")
return "\n".join(lines)
async def _conversation_text(self, thread: Thread) -> str:
history = await self.messages.list_for_thread(thread.id)
lines = [
f"{m.role.value}: {m.content}"
for m in history[-12:]
if m.role is not MessageRole.SYSTEM
]
return ("گفتگو تا کنون:\n" + "\n".join(lines)) if lines else ""
async def _state_for(self, organization_id: str, task_id: str) -> TaskPipelineState | None:
if self.states is None:
return None
state = await self.states.get(task_id)
if state is None or state.organization_id != organization_id:
return None
return state
async def _move(self, task: Task, to: Stage) -> None:
"""Move the pipeline state and keep the coarse status truthful."""
state = await self._state_for(task.organization_id, task.id)
if state is not None and self.states is not None:
try:
await advance(
self.states, task_id=task.id, expected_from=state.stage, to=to
)
except ConflictError:
return
await self.tasks.set_status(task.id, coarse_status(to))
async def _record_knowledge(
self, parsed: ParsedReply | None, *, organization_id: str
) -> None:
"""3.5: a manager's KNOWLEDGE tags become org memory."""
if parsed is None:
return
for item in knowledge_items(parsed):
await self.memory.record(
organization_id=organization_id,
kind=item.kind if item.kind in ("rule", "fact") else "rule",
content=item.content,
source="manager",
key=item.key or None,
)
async def _post_system_message(
self, *, organization_id: str, project_id: str | None, content: str
) -> None:
thread = await self._system_thread(organization_id, project_id)
await self.messages.create(
Message(thread_id=thread.id, role=MessageRole.SYSTEM, content=content)
)
async def _system_thread(
self, organization_id: str, project_id: str | None
) -> Thread:
for thread in await self.threads.list_for_org(organization_id, limit=20):
if thread.title == SYSTEM_THREAD_TITLE:
return thread
return await self.threads.create(
Thread(
organization_id=organization_id,
title=SYSTEM_THREAD_TITLE,
project_id=project_id,
)
)
async def _system_note(self, thread: Thread, content: str) -> None:
await self.messages.create(
Message(thread_id=thread.id, role=MessageRole.SYSTEM, content=content)
)
async def _project_id_for(self, organization_id: str) -> str | None:
for employee in await self.employees.list_for_org(organization_id, limit=1):
return employee.project_id
return None
async def _has_active_reviewer(self, organization_id: str) -> bool:
"""A review stage needs an active reviewer to act on it, or the task dead-ends."""
return any(
e.role_key is RoleKey.REVIEWER and e.is_available
for e in await self.employees.list_for_org(organization_id)
)
__all__ = [
"BOOTSTRAP_TIERS",
"CHAT_TURN_TIMEOUT_SECONDS",
"HIRABLE_ROLES",
"NO_MODEL_GRANTED_MESSAGE",
"SYSTEM_THREAD_TITLE",
"ManagerOutcome",
"ManagerTurns",
]