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:
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 #
|
||||
|
||||
@@ -144,6 +144,10 @@ class Settings(BaseSettings):
|
||||
|
||||
# >>> agent-2 (AI workforce engine): add settings ONLY between these markers
|
||||
engine_max_tool_calls_per_turn: int = 40
|
||||
#: Consecutive malformed replies tolerated before a turn gives up. Without
|
||||
#: it a model that repeats the same protocol mistake would be called until
|
||||
#: the turn timeout, hundreds of paid calls later.
|
||||
engine_max_protocol_retries: int = 3
|
||||
engine_turn_timeout_seconds: int = 900
|
||||
engine_max_cycles: int = 6
|
||||
engine_max_employees: int = 20
|
||||
|
||||
@@ -482,6 +482,15 @@ class TaskRepository:
|
||||
await self.session.flush()
|
||||
return _task(row)
|
||||
|
||||
async def set_assignee(self, task_id: str, employee_id: str | None) -> Task | None:
|
||||
"""Point a task at an employee. The last step of the manager's triage."""
|
||||
row = await self.session.get(TaskRow, task_id)
|
||||
if row is None:
|
||||
return None
|
||||
row.assigned_employee_id = employee_id
|
||||
await self.session.flush()
|
||||
return _task(row)
|
||||
|
||||
async def set_status(self, task_id: str, status: TaskStatus) -> Task | None:
|
||||
row = await self.session.get(TaskRow, task_id)
|
||||
if row is None:
|
||||
|
||||
@@ -182,24 +182,41 @@ class FakeSandbox:
|
||||
|
||||
|
||||
class FakeGrants:
|
||||
"""A ``ModelGrantPort`` with an explicit allow-list."""
|
||||
"""A ``ModelGrantPort`` with an explicit allow-list.
|
||||
|
||||
def __init__(self, granted: list[str] | None = None) -> None:
|
||||
self._granted = list(granted or ["model-strong", "model-fast"])
|
||||
``granted`` is either a list of model ids (all ``balanced``) or a mapping
|
||||
of id → neutral tier, so a test can have a "strong" and a "fast" model and
|
||||
assert the engine picked the right one.
|
||||
"""
|
||||
|
||||
def __init__(self, granted: list[str] | dict[str, str] | None = None) -> None:
|
||||
if granted is None:
|
||||
self._tiers: dict[str, str] = {
|
||||
"model-strong": "strong",
|
||||
"model-fast": "fast",
|
||||
}
|
||||
elif isinstance(granted, dict):
|
||||
self._tiers = dict(granted)
|
||||
else:
|
||||
self._tiers = dict.fromkeys(granted, "balanced")
|
||||
self.checks: list[str] = []
|
||||
|
||||
@property
|
||||
def granted(self) -> list[str]:
|
||||
return list(self._tiers)
|
||||
|
||||
async def granted_models(self, *, organization_id: str) -> list[GrantedModel]:
|
||||
return [
|
||||
GrantedModel(ai_model_id=model_id, tier="balanced")
|
||||
for model_id in self._granted
|
||||
GrantedModel(ai_model_id=model_id, tier=tier)
|
||||
for model_id, tier in self._tiers.items()
|
||||
]
|
||||
|
||||
async def is_granted(self, *, organization_id: str, ai_model_id: str) -> bool:
|
||||
self.checks.append(ai_model_id)
|
||||
return ai_model_id in self._granted
|
||||
return ai_model_id in self._tiers
|
||||
|
||||
def grant(self, model_id: str) -> None:
|
||||
self._granted.append(model_id)
|
||||
def grant(self, model_id: str, tier: str = "balanced") -> None:
|
||||
self._tiers[model_id] = tier
|
||||
|
||||
|
||||
class RecordingMemory:
|
||||
|
||||
@@ -162,6 +162,20 @@ async def test_end_to_end_smoke_path(client: AsyncClient):
|
||||
assert "model" not in str(employee.json()).lower()
|
||||
|
||||
# -- 8. send a manager message ----------------------------------------- #
|
||||
# PLAN-v2 D3: the engine may only run on models the owner granted to this
|
||||
# organization, so the manager's chat needs a grant before it can answer.
|
||||
# Before the engine existed any active model would do; a chat without a
|
||||
# grant now correctly answers nothing but the "waiting for a model" note.
|
||||
models = await client.get("/api/admin/models", headers=headers)
|
||||
assert models.status_code == 200, models.text
|
||||
ai_model_id = models.json()[0]["id"]
|
||||
grant = await client.put(
|
||||
f"/api/admin/model-grants/{org_id}/models/{ai_model_id}",
|
||||
json={"daily_credit_cap": None},
|
||||
headers=headers,
|
||||
)
|
||||
assert grant.status_code in (200, 201), grant.text
|
||||
|
||||
thread = await client.post(
|
||||
f"/api/organizations/{org_id}/threads",
|
||||
json={"title": "گفتوگو با مدیر", "project_id": project_id},
|
||||
|
||||
@@ -0,0 +1,828 @@
|
||||
"""The manager AI: chat → tasks → team, triage, final verdict, bootstrap.
|
||||
|
||||
What these tests defend:
|
||||
|
||||
* an order becomes **at most three** tasks, each with its own pipeline state at
|
||||
``pending_manager_review`` and a link back to the thread
|
||||
* HIRE respects the employee cap and picks a **granted** model for the tier;
|
||||
the last manager cannot be fired
|
||||
* ``ASK_USER`` parks the task with a human and asks in the thread
|
||||
* triage assigns to the least busy employee, and a name is matched **inside
|
||||
the org only** — the manager of org A can never assign an employee of org B,
|
||||
even by exact name
|
||||
* ``COMPLETE`` merges, ``REVISE`` goes back to the developer with cycle + 1
|
||||
* ``KNOWLEDGE`` tags become org memory
|
||||
* bootstrap is idempotent, and with no grants it creates an inactive team plus
|
||||
the "waiting for the owner" message
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.application.container_org import build_memory
|
||||
from app.application.engine.manager import (
|
||||
NO_MODEL_GRANTED_MESSAGE,
|
||||
ManagerTurns,
|
||||
)
|
||||
from app.core.config import settings
|
||||
from app.core.errors import ConflictError, NotFoundError
|
||||
from app.domain.base import new_id
|
||||
from app.domain.workforce.engine import EmployeeProfile
|
||||
from app.domain.workforce.models import Employee, RoleKey
|
||||
from app.domain.workforce.pipeline import PipelineKey, Stage, TaskPipelineState
|
||||
from app.infrastructure.db.models import (
|
||||
AiModelRow,
|
||||
AiProviderRow,
|
||||
EmployeeRow,
|
||||
OrganizationRow,
|
||||
RunRow,
|
||||
TaskRow,
|
||||
ThreadRow,
|
||||
UserRow,
|
||||
)
|
||||
from app.infrastructure.db.models_org import OrgMemoryRow
|
||||
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,
|
||||
RunRepository,
|
||||
RunStepRepository,
|
||||
TaskRepository,
|
||||
ThreadRepository,
|
||||
ToolCallRepository,
|
||||
)
|
||||
from tests.engine_fakes import FakeGrants, ScriptedModel, ScriptedReply
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def world(session_factory, seeded):
|
||||
"""One organization with a manager, a developer, a granted model."""
|
||||
async with session_factory() as session:
|
||||
user = UserRow(email=f"mgr-{new_id()[:8]}@example.com", password_hash="x")
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
org = OrganizationRow(name="سازمان مدیر", slug=f"mgr-{user.id[:6]}", owner_user_id=user.id)
|
||||
session.add(org)
|
||||
await session.flush()
|
||||
provider_id = (await session.execute(select(AiProviderRow.id).limit(1))).scalar_one()
|
||||
strong = await AiModelRepository(session).create(
|
||||
AiModelRow(
|
||||
provider_id=provider_id,
|
||||
internal_name=f"strong-{new_id()[:4]}",
|
||||
display_name_for_admin="مدل قوی",
|
||||
capabilities=["text", "code", "reasoning"],
|
||||
input_price_per_1m=0.5,
|
||||
output_price_per_1m=3.0,
|
||||
is_active=True,
|
||||
)
|
||||
)
|
||||
fast = await AiModelRepository(session).create(
|
||||
AiModelRow(
|
||||
provider_id=provider_id,
|
||||
internal_name=f"fast-{new_id()[:4]}",
|
||||
display_name_for_admin="مدل سریع",
|
||||
capabilities=["text", "code"],
|
||||
input_price_per_1m=0.1,
|
||||
output_price_per_1m=0.5,
|
||||
is_active=True,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
yield _World(
|
||||
session_factory=session_factory,
|
||||
organization_id=org.id,
|
||||
user_id=user.id,
|
||||
strong_id=strong.id,
|
||||
fast_id=fast.id,
|
||||
)
|
||||
|
||||
|
||||
class _World:
|
||||
def __init__(self, **kwargs) -> None:
|
||||
self.__dict__.update(kwargs)
|
||||
|
||||
async def employee(self, session, name: str, role: RoleKey = RoleKey.DEVELOPER):
|
||||
return await EmployeeRepository(session).create(
|
||||
Employee(organization_id=self.organization_id, name=name, role_key=role)
|
||||
)
|
||||
|
||||
|
||||
async def _manager(world, session, *, scripted, grants, employee_ids=None) -> ManagerTurns:
|
||||
"""A manager service with real repositories and scripted everything else."""
|
||||
return ManagerTurns(
|
||||
provider=scripted,
|
||||
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=grants,
|
||||
memory=build_memory(session),
|
||||
profiles=EmployeeProfileRepository(session),
|
||||
states=PipelineStateRepository(session),
|
||||
runner=None,
|
||||
)
|
||||
|
||||
|
||||
async def _with_model_profile(world, session, employee_id: str, model_id: str) -> None:
|
||||
await EmployeeProfileRepository(session).upsert(
|
||||
EmployeeProfile(employee_id=employee_id, ai_model_id=model_id, roles=["manager"])
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3.1 chat: an order becomes tasks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_an_order_becomes_tasks_with_pipeline_state(world, session_factory):
|
||||
scripted = ScriptedModel(
|
||||
[
|
||||
ScriptedReply(
|
||||
content=(
|
||||
"باشه، دو کار میسازم.\n"
|
||||
'[[CREATE_TASK title="فرم ورود" kind="feature"]]'
|
||||
"ساخت فرم ورود با اعتبارسنجی[[/CREATE_TASK]]"
|
||||
'[[CREATE_TASK title="تست فرم" kind="feature" depends_on=""]]'
|
||||
"تست فرم ورود[[/CREATE_TASK]]"
|
||||
)
|
||||
)
|
||||
]
|
||||
)
|
||||
async with session_factory() as session:
|
||||
manager_id = (await world.employee(session, "مدیر یک", RoleKey.MANAGER)).id
|
||||
await world.employee(session, "سارا", RoleKey.DEVELOPER)
|
||||
await _with_model_profile(world, session, manager_id, world.strong_id)
|
||||
thread = await ThreadRepository(session).create(
|
||||
ThreadRow(organization_id=world.organization_id, title="گفتگو")
|
||||
)
|
||||
await session.commit()
|
||||
manager = await _manager(
|
||||
world, session, scripted=scripted, grants=FakeGrants({world.strong_id: "strong"})
|
||||
)
|
||||
user_message, assistant = await manager.chat(
|
||||
organization_id=world.organization_id,
|
||||
thread_id=thread.id,
|
||||
content="یک صفحهٔ ورود با تست بساز.",
|
||||
user_id=world.user_id,
|
||||
)
|
||||
await session.commit()
|
||||
tasks = await TaskRepository(session).list_for_org(world.organization_id)
|
||||
states = PipelineStateRepository(session)
|
||||
|
||||
assert "باشه" in assistant.content
|
||||
assert "کار ساخته شد: فرم ورود" in assistant.content
|
||||
assert len(tasks) == 2
|
||||
assert tasks[0].thread_id == thread.id
|
||||
for task in tasks:
|
||||
state = await states.get(task.id)
|
||||
assert state.stage is Stage.PENDING_MANAGER_REVIEW
|
||||
assert state.pipeline is PipelineKey.PLAN_CODE_TEST
|
||||
assert state.organization_id == world.organization_id
|
||||
# The task-created event went out, so the manager chat shows the cards live.
|
||||
assert scripted.calls == 1
|
||||
|
||||
|
||||
async def test_at_most_three_tasks_per_order(world, session_factory):
|
||||
scripted = ScriptedModel(
|
||||
[
|
||||
ScriptedReply(
|
||||
content="".join(
|
||||
f'[[CREATE_TASK title="کار {i}"]]توضیح[[/CREATE_TASK]]' for i in range(5)
|
||||
)
|
||||
)
|
||||
]
|
||||
)
|
||||
async with session_factory() as session:
|
||||
manager_id = (await world.employee(session, "مدیر یک", RoleKey.MANAGER)).id
|
||||
await _with_model_profile(world, session, manager_id, world.strong_id)
|
||||
thread = await ThreadRepository(session).create(
|
||||
ThreadRow(organization_id=world.organization_id, title="گفتگو")
|
||||
)
|
||||
await session.commit()
|
||||
manager = await _manager(
|
||||
world, session, scripted=scripted, grants=FakeGrants({world.strong_id: "strong"})
|
||||
)
|
||||
_user, assistant = await manager.chat(
|
||||
organization_id=world.organization_id,
|
||||
thread_id=thread.id,
|
||||
content="پنج کار بساز.",
|
||||
user_id=world.user_id,
|
||||
)
|
||||
await session.commit()
|
||||
tasks = await TaskRepository(session).list_for_org(world.organization_id)
|
||||
|
||||
# Three created, two refused by the protocol limit, and the user is told.
|
||||
assert len(tasks) == 3
|
||||
assert assistant.content.count("کار ساخته شد") == 3
|
||||
|
||||
|
||||
async def test_the_chat_prompt_asks_for_atomic_tasks(world, session_factory):
|
||||
scripted = ScriptedModel([ScriptedReply(content="سلام")])
|
||||
async with session_factory() as session:
|
||||
manager_id = (await world.employee(session, "مدیر یک", RoleKey.MANAGER)).id
|
||||
await _with_model_profile(world, session, manager_id, world.strong_id)
|
||||
thread = await ThreadRepository(session).create(
|
||||
ThreadRow(organization_id=world.organization_id, title="گفتگو")
|
||||
)
|
||||
await session.commit()
|
||||
manager = await _manager(
|
||||
world, session, scripted=scripted, grants=FakeGrants({world.strong_id: "strong"})
|
||||
)
|
||||
await manager.chat(
|
||||
organization_id=world.organization_id,
|
||||
thread_id=thread.id,
|
||||
content="یک کار بساز.",
|
||||
user_id=world.user_id,
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
prompt = scripted.all_text()
|
||||
assert "اتمیک" in prompt # atomic tasks that do not touch the same files
|
||||
assert "همزمان" in prompt
|
||||
assert "مدیر یک" in prompt # the roster is in the context
|
||||
|
||||
|
||||
async def test_a_chat_model_call_is_billed(world, session_factory):
|
||||
scripted = ScriptedModel([ScriptedReply(content="سلام", input_tokens=9, output_tokens=4)])
|
||||
from app.infrastructure.db.models import UsageEventRow
|
||||
|
||||
async with session_factory() as session:
|
||||
manager_id = (await world.employee(session, "مدیر یک", RoleKey.MANAGER)).id
|
||||
await _with_model_profile(world, session, manager_id, world.strong_id)
|
||||
thread = await ThreadRepository(session).create(
|
||||
ThreadRow(organization_id=world.organization_id, title="گفتگو")
|
||||
)
|
||||
await session.commit()
|
||||
manager = await _manager(
|
||||
world, session, scripted=scripted, grants=FakeGrants({world.strong_id: "strong"})
|
||||
)
|
||||
await manager.chat(
|
||||
organization_id=world.organization_id,
|
||||
thread_id=thread.id,
|
||||
content="سلام",
|
||||
user_id=world.user_id,
|
||||
)
|
||||
await session.commit()
|
||||
rows = (
|
||||
(
|
||||
await session.execute(
|
||||
select(UsageEventRow).where(
|
||||
UsageEventRow.organization_id == world.organization_id
|
||||
)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
|
||||
assert len(rows) == 2
|
||||
assert sum(r.quantity for r in rows if r.unit == "model_input_tokens") == 9
|
||||
assert sum(r.quantity for r in rows if r.unit == "model_output_tokens") == 4
|
||||
|
||||
|
||||
async def test_a_chat_without_grants_explains_itself(world, session_factory):
|
||||
"""No grant, no answer — and the user is told why, in Persian."""
|
||||
scripted = ScriptedModel([ScriptedReply(content="سلام")])
|
||||
async with session_factory() as session:
|
||||
manager_id = (await world.employee(session, "مدیر یک", RoleKey.MANAGER)).id
|
||||
await _with_model_profile(world, session, manager_id, world.strong_id)
|
||||
thread = await ThreadRepository(session).create(
|
||||
ThreadRow(organization_id=world.organization_id, title="گفتگو")
|
||||
)
|
||||
await session.commit()
|
||||
manager = await _manager(world, session, scripted=scripted, grants=FakeGrants({}))
|
||||
_user, assistant = await manager.chat(
|
||||
organization_id=world.organization_id,
|
||||
thread_id=thread.id,
|
||||
content="سلام",
|
||||
user_id=world.user_id,
|
||||
)
|
||||
await session.commit()
|
||||
messages = await MessageRepository(session).list_for_thread(thread.id)
|
||||
|
||||
assert scripted.calls == 0
|
||||
assert "دستور دریافت شد" in assistant.content
|
||||
assert any("مدیری با مدل" in m.content for m in messages)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3.2 team
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_hire_picks_a_granted_model_of_the_tier(world, session_factory):
|
||||
async with session_factory() as session:
|
||||
grants = FakeGrants({world.strong_id: "strong", world.fast_id: "fast"})
|
||||
manager = await _manager(world, session, scripted=ScriptedModel(), grants=grants)
|
||||
employee = await manager.hire(
|
||||
organization_id=world.organization_id, role=RoleKey.TESTER, tier="fast"
|
||||
)
|
||||
await session.commit()
|
||||
profile = await EmployeeProfileRepository(session).get(employee.id)
|
||||
|
||||
assert profile is not None
|
||||
assert profile.ai_model_id == world.fast_id
|
||||
assert profile.role_list() == ["tester"]
|
||||
assert profile.hired_by == "manager"
|
||||
|
||||
|
||||
async def test_hire_without_a_granted_model_is_refused(world, session_factory):
|
||||
async with session_factory() as session:
|
||||
manager = await _manager(
|
||||
world, session, scripted=ScriptedModel(), grants=FakeGrants({world.fast_id: "fast"})
|
||||
)
|
||||
with pytest.raises(ConflictError):
|
||||
await manager.hire(
|
||||
organization_id=world.organization_id, role=RoleKey.REVIEWER, tier="strong"
|
||||
)
|
||||
|
||||
|
||||
async def test_the_employee_cap_is_enforced(world, session_factory, monkeypatch):
|
||||
monkeypatch.setattr(settings, "engine_max_employees", 2)
|
||||
async with session_factory() as session:
|
||||
await world.employee(session, "یک")
|
||||
await world.employee(session, "دو")
|
||||
await session.commit()
|
||||
manager = await _manager(
|
||||
world, session, scripted=ScriptedModel(), grants=FakeGrants({world.fast_id: "fast"})
|
||||
)
|
||||
with pytest.raises(ConflictError):
|
||||
await manager.hire(
|
||||
organization_id=world.organization_id,
|
||||
role=RoleKey.TESTER,
|
||||
tier="fast",
|
||||
)
|
||||
|
||||
|
||||
async def test_the_last_manager_cannot_be_fired(world, session_factory):
|
||||
async with session_factory() as session:
|
||||
manager_row = await world.employee(session, "مدیر یک", RoleKey.MANAGER)
|
||||
await session.commit()
|
||||
manager = await _manager(world, session, scripted=ScriptedModel(), grants=FakeGrants({}))
|
||||
with pytest.raises(ConflictError):
|
||||
await manager.fire(organization_id=world.organization_id, employee_id=manager_row.id)
|
||||
# A second manager can be fired.
|
||||
second = await world.employee(session, "مدیر دو", RoleKey.MANAGER)
|
||||
await session.commit()
|
||||
fired = await manager.fire(organization_id=world.organization_id, employee_id=second.id)
|
||||
await session.commit()
|
||||
|
||||
assert fired.status.value == "archived"
|
||||
|
||||
|
||||
async def test_firing_a_foreign_employee_is_a_404(world, session_factory):
|
||||
"""Another tenant's employee id is indistinguishable from a missing one."""
|
||||
async with session_factory() as session:
|
||||
other_user = UserRow(email=f"other-{new_id()[:6]}@example.com", password_hash="x")
|
||||
session.add(other_user)
|
||||
await session.flush()
|
||||
other_org = OrganizationRow(
|
||||
name="سازمان دیگر", slug=f"oth-{other_user.id[:6]}", owner_user_id=other_user.id
|
||||
)
|
||||
session.add(other_org)
|
||||
await session.flush()
|
||||
stranger = await session.execute(select(EmployeeRow).limit(1))
|
||||
foreign = None
|
||||
if stranger.scalars().first() is not None:
|
||||
foreign = stranger.scalars().first().id
|
||||
await session.commit()
|
||||
|
||||
if foreign is None:
|
||||
async with session_factory() as session:
|
||||
foreign = (await world.employee(session, "بومی")).id
|
||||
await session.commit()
|
||||
|
||||
async with session_factory() as session:
|
||||
# The employee belongs to *this* org; the *other* org must not see it.
|
||||
manager = await _manager(world, session, scripted=ScriptedModel(), grants=FakeGrants({}))
|
||||
with pytest.raises(NotFoundError):
|
||||
await manager.fire(organization_id="org-does-not-exist", employee_id=foreign)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3.3 triage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _task_with_state(
|
||||
world, session, title: str = "کار", stage: Stage = Stage.PENDING_MANAGER_REVIEW
|
||||
) -> tuple[str, str]:
|
||||
task = TaskRow(organization_id=world.organization_id, title=title)
|
||||
session.add(task)
|
||||
await session.flush()
|
||||
run = RunRow(task_id=task.id)
|
||||
session.add(run)
|
||||
await session.flush()
|
||||
await PipelineStateRepository(session).create(
|
||||
TaskPipelineState(task_id=task.id, organization_id=world.organization_id, stage=stage)
|
||||
)
|
||||
await session.flush()
|
||||
return task.id, run.id
|
||||
|
||||
|
||||
async def test_triage_assigns_to_the_least_busy_employee(world, session_factory, monkeypatch):
|
||||
monkeypatch.setattr(settings, "engine_max_tool_calls_per_turn", 40)
|
||||
scripted = ScriptedModel(
|
||||
[ScriptedReply(content='[[ASSIGN employee="" pipeline="plan_code_test"/]]')]
|
||||
)
|
||||
async with session_factory() as session:
|
||||
manager_row = await world.employee(session, "مدیر یک", RoleKey.MANAGER)
|
||||
await _with_model_profile(world, session, manager_row.id, world.strong_id)
|
||||
busy = await world.employee(session, "مشغول", RoleKey.DEVELOPER)
|
||||
free = await world.employee(session, "آzاد", RoleKey.DEVELOPER)
|
||||
# The busy one already has two open tasks.
|
||||
for _ in range(2):
|
||||
session.add(
|
||||
TaskRow(
|
||||
organization_id=world.organization_id,
|
||||
title="کار دیگر",
|
||||
assigned_employee_id=busy.id,
|
||||
)
|
||||
)
|
||||
await session.flush()
|
||||
task_id, run_id = await _task_with_state(world, session)
|
||||
await session.commit()
|
||||
|
||||
from app.application.engine.runner import TurnRunner
|
||||
|
||||
runner = TurnRunner(
|
||||
provider=scripted,
|
||||
models=AiModelRepository(session),
|
||||
credentials=ModelCredentialRepository(session),
|
||||
rate_cards=RateCardRepository(session),
|
||||
ledger=UsageLedgerRepository(session),
|
||||
grants=FakeGrants({world.strong_id: "strong"}),
|
||||
memory=build_memory(session),
|
||||
turns=None,
|
||||
run_steps=RunStepRepository(session),
|
||||
tool_calls=ToolCallRepository(session),
|
||||
runs=RunRepository(session),
|
||||
states=PipelineStateRepository(session),
|
||||
)
|
||||
manager = await _manager(
|
||||
world, session, scripted=scripted, grants=FakeGrants({world.strong_id: "strong"})
|
||||
)
|
||||
manager.runner = runner
|
||||
task = await TaskRepository(session).get(task_id)
|
||||
outcome = await manager.triage(task=task, run_id=run_id, employee=manager_row)
|
||||
await session.commit()
|
||||
state = await PipelineStateRepository(session).get(task_id)
|
||||
refreshed = await TaskRepository(session).get(task_id)
|
||||
|
||||
assert outcome.acted is True
|
||||
assert outcome.decision == "assigned"
|
||||
assert outcome.employee_id == free.id
|
||||
assert state.stage is Stage.PENDING_ANALYSIS
|
||||
assert refreshed.assigned_employee_id == free.id
|
||||
|
||||
|
||||
async def test_a_named_employee_is_matched_inside_the_org(world, session_factory):
|
||||
scripted = ScriptedModel([ScriptedReply(content='[[ASSIGN employee="سارا"/]]')])
|
||||
async with session_factory() as session:
|
||||
manager_row = await world.employee(session, "مدیر یک", RoleKey.MANAGER)
|
||||
await _with_model_profile(world, session, manager_row.id, world.strong_id)
|
||||
sara = await world.employee(session, "سارا", RoleKey.DEVELOPER)
|
||||
await session.flush()
|
||||
task_id, run_id = await _task_with_state(world, session)
|
||||
await session.commit()
|
||||
from app.application.engine.runner import TurnRunner
|
||||
|
||||
runner = TurnRunner(
|
||||
provider=scripted,
|
||||
models=AiModelRepository(session),
|
||||
credentials=ModelCredentialRepository(session),
|
||||
rate_cards=RateCardRepository(session),
|
||||
ledger=UsageLedgerRepository(session),
|
||||
grants=FakeGrants({world.strong_id: "strong"}),
|
||||
memory=build_memory(session),
|
||||
states=PipelineStateRepository(session),
|
||||
)
|
||||
manager = await _manager(
|
||||
world, session, scripted=scripted, grants=FakeGrants({world.strong_id: "strong"})
|
||||
)
|
||||
manager.runner = runner
|
||||
task = await TaskRepository(session).get(task_id)
|
||||
outcome = await manager.triage(task=task, run_id=run_id, employee=manager_row)
|
||||
await session.commit()
|
||||
|
||||
assert outcome.employee_id == sara.id
|
||||
|
||||
|
||||
async def test_the_manager_of_org_a_cannot_assign_an_employee_of_org_b(world, session_factory):
|
||||
"""Cross-tenant assignment by exact name must resolve to nothing."""
|
||||
scripted = ScriptedModel([ScriptedReply(content='[[ASSIGN employee="دزد"/]]')])
|
||||
async with session_factory() as session:
|
||||
manager_row = await world.employee(session, "مدیر یک", RoleKey.MANAGER)
|
||||
await _with_model_profile(world, session, manager_row.id, world.strong_id)
|
||||
other_user = UserRow(email=f"b-{new_id()[:6]}@example.com", password_hash="x")
|
||||
session.add(other_user)
|
||||
await session.flush()
|
||||
other_org = OrganizationRow(
|
||||
name="سازمان ب", slug=f"b-{other_user.id[:6]}", owner_user_id=other_user.id
|
||||
)
|
||||
session.add(other_org)
|
||||
await session.flush()
|
||||
# The other org's employee is named exactly like the assignment asks.
|
||||
session.add(EmployeeRow(organization_id=other_org.id, name="دزد", role_key="developer"))
|
||||
await session.flush()
|
||||
task_id, run_id = await _task_with_state(world, session)
|
||||
await session.commit()
|
||||
from app.application.engine.runner import TurnRunner
|
||||
|
||||
runner = TurnRunner(
|
||||
provider=scripted,
|
||||
models=AiModelRepository(session),
|
||||
credentials=ModelCredentialRepository(session),
|
||||
rate_cards=RateCardRepository(session),
|
||||
ledger=UsageLedgerRepository(session),
|
||||
grants=FakeGrants({world.strong_id: "strong"}),
|
||||
memory=build_memory(session),
|
||||
states=PipelineStateRepository(session),
|
||||
)
|
||||
manager = await _manager(
|
||||
world, session, scripted=scripted, grants=FakeGrants({world.strong_id: "strong"})
|
||||
)
|
||||
manager.runner = runner
|
||||
task = await TaskRepository(session).get(task_id)
|
||||
outcome = await manager.triage(task=task, run_id=run_id, employee=manager_row)
|
||||
await session.commit()
|
||||
state = await PipelineStateRepository(session).get(task_id)
|
||||
|
||||
assert outcome.acted is False
|
||||
assert outcome.employee_id is None
|
||||
assert state.stage is Stage.PENDING_MANAGER_REVIEW
|
||||
|
||||
|
||||
async def test_ask_user_parks_the_task_and_asks_in_the_thread(world, session_factory):
|
||||
scripted = ScriptedModel([ScriptedReply(content="[[ASK_USER]]کدام درگاه پرداخت؟[[/ASK_USER]]")])
|
||||
async with session_factory() as session:
|
||||
manager_row = await world.employee(session, "مدیر یک", RoleKey.MANAGER)
|
||||
await _with_model_profile(world, session, manager_row.id, world.strong_id)
|
||||
thread = await ThreadRepository(session).create(
|
||||
ThreadRow(organization_id=world.organization_id, title="گفتگو")
|
||||
)
|
||||
await session.flush()
|
||||
task_id, run_id = await _task_with_state(world, session)
|
||||
task = await TaskRepository(session).get(task_id)
|
||||
task.thread_id = thread.id
|
||||
await session.commit()
|
||||
from app.application.engine.runner import TurnRunner
|
||||
|
||||
runner = TurnRunner(
|
||||
provider=scripted,
|
||||
models=AiModelRepository(session),
|
||||
credentials=ModelCredentialRepository(session),
|
||||
rate_cards=RateCardRepository(session),
|
||||
ledger=UsageLedgerRepository(session),
|
||||
grants=FakeGrants({world.strong_id: "strong"}),
|
||||
memory=build_memory(session),
|
||||
states=PipelineStateRepository(session),
|
||||
)
|
||||
manager = await _manager(
|
||||
world, session, scripted=scripted, grants=FakeGrants({world.strong_id: "strong"})
|
||||
)
|
||||
manager.runner = runner
|
||||
outcome = await manager.triage(task=task, run_id=run_id, employee=manager_row)
|
||||
await session.commit()
|
||||
state = await PipelineStateRepository(session).get(task_id)
|
||||
messages = await MessageRepository(session).list_for_thread(thread.id)
|
||||
|
||||
assert outcome.decision == "asked_user"
|
||||
assert state.stage is Stage.PENDING_USER_DECISION
|
||||
assert any(m.content == "کدام درگاه پرداخت؟" for m in messages)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3.4 final verdict
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_complete_moves_the_task_to_merge_request(world, session_factory):
|
||||
scripted = ScriptedModel([ScriptedReply(content="[[COMPLETE/]]")])
|
||||
async with session_factory() as session:
|
||||
manager_row = await world.employee(session, "مدیر یک", RoleKey.MANAGER)
|
||||
await _with_model_profile(world, session, manager_row.id, world.strong_id)
|
||||
task_id, run_id = await _task_with_state(world, session, stage=Stage.PENDING_FINAL_REVIEW)
|
||||
await session.commit()
|
||||
from app.application.engine.runner import TurnRunner
|
||||
|
||||
runner = TurnRunner(
|
||||
provider=scripted,
|
||||
models=AiModelRepository(session),
|
||||
credentials=ModelCredentialRepository(session),
|
||||
rate_cards=RateCardRepository(session),
|
||||
ledger=UsageLedgerRepository(session),
|
||||
grants=FakeGrants({world.strong_id: "strong"}),
|
||||
memory=build_memory(session),
|
||||
states=PipelineStateRepository(session),
|
||||
)
|
||||
manager = await _manager(
|
||||
world, session, scripted=scripted, grants=FakeGrants({world.strong_id: "strong"})
|
||||
)
|
||||
manager.runner = runner
|
||||
task = await TaskRepository(session).get(task_id)
|
||||
outcome = await manager.final_review(
|
||||
task=task,
|
||||
run_id=run_id,
|
||||
employee=manager_row,
|
||||
spec="برنامه",
|
||||
test_output="3 passed",
|
||||
)
|
||||
await session.commit()
|
||||
state = await PipelineStateRepository(session).get(task_id)
|
||||
|
||||
assert outcome.decision == "completed"
|
||||
assert state.stage is Stage.MERGE_REQUEST
|
||||
|
||||
|
||||
async def test_revise_sends_the_task_back_and_counts_the_cycle(world, session_factory):
|
||||
scripted = ScriptedModel([ScriptedReply(content="[[REVISE]]تستها قرمز است[[/REVISE]]")])
|
||||
async with session_factory() as session:
|
||||
manager_row = await world.employee(session, "مدیر یک", RoleKey.MANAGER)
|
||||
await _with_model_profile(world, session, manager_row.id, world.strong_id)
|
||||
task_id, run_id = await _task_with_state(world, session, stage=Stage.PENDING_FINAL_REVIEW)
|
||||
await session.commit()
|
||||
from app.application.engine.runner import TurnRunner
|
||||
|
||||
runner = TurnRunner(
|
||||
provider=scripted,
|
||||
models=AiModelRepository(session),
|
||||
credentials=ModelCredentialRepository(session),
|
||||
rate_cards=RateCardRepository(session),
|
||||
ledger=UsageLedgerRepository(session),
|
||||
grants=FakeGrants({world.strong_id: "strong"}),
|
||||
memory=build_memory(session),
|
||||
states=PipelineStateRepository(session),
|
||||
)
|
||||
manager = await _manager(
|
||||
world, session, scripted=scripted, grants=FakeGrants({world.strong_id: "strong"})
|
||||
)
|
||||
manager.runner = runner
|
||||
task = await TaskRepository(session).get(task_id)
|
||||
outcome = await manager.final_review(
|
||||
task=task, run_id=run_id, employee=manager_row, test_output="1 failed"
|
||||
)
|
||||
await session.commit()
|
||||
state = await PipelineStateRepository(session).get(task_id)
|
||||
|
||||
assert outcome.decision == "revised"
|
||||
assert state.stage is Stage.PENDING_IMPLEMENTATION
|
||||
assert state.cycle == 1
|
||||
|
||||
|
||||
async def test_resolve_error_retries_with_a_hint(world, session_factory):
|
||||
scripted = ScriptedModel(
|
||||
[ScriptedReply(content='[[RESOLVE_ERROR hint="ابتدا مهاجرت را بنویس"/]]')]
|
||||
)
|
||||
async with session_factory() as session:
|
||||
manager_row = await world.employee(session, "مدیر یک", RoleKey.MANAGER)
|
||||
await _with_model_profile(world, session, manager_row.id, world.strong_id)
|
||||
task_id, run_id = await _task_with_state(world, session)
|
||||
await session.commit()
|
||||
from app.application.engine.runner import TurnRunner
|
||||
|
||||
runner = TurnRunner(
|
||||
provider=scripted,
|
||||
models=AiModelRepository(session),
|
||||
credentials=ModelCredentialRepository(session),
|
||||
rate_cards=RateCardRepository(session),
|
||||
ledger=UsageLedgerRepository(session),
|
||||
grants=FakeGrants({world.strong_id: "strong"}),
|
||||
memory=build_memory(session),
|
||||
states=PipelineStateRepository(session),
|
||||
)
|
||||
manager = await _manager(
|
||||
world, session, scripted=scripted, grants=FakeGrants({world.strong_id: "strong"})
|
||||
)
|
||||
manager.runner = runner
|
||||
task = await TaskRepository(session).get(task_id)
|
||||
await PipelineStateRepository(session).advance(
|
||||
task_id=task_id, expected_from=Stage.PENDING_MANAGER_REVIEW, to=Stage.ERROR_REVIEW
|
||||
)
|
||||
await session.commit()
|
||||
outcome = await manager.error_review(
|
||||
task=task, run_id=run_id, employee=manager_row, error_summary="تست قرمز"
|
||||
)
|
||||
await session.commit()
|
||||
state = await PipelineStateRepository(session).get(task_id)
|
||||
|
||||
assert outcome.decision == "resolved"
|
||||
assert state.stage is Stage.PENDING_IMPLEMENTATION
|
||||
assert "مهاجرت" in outcome.message
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3.5 knowledge and 3.6 bootstrap
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_knowledge_tags_become_org_memory(world, session_factory):
|
||||
scripted = ScriptedModel(
|
||||
[
|
||||
ScriptedReply(
|
||||
content=('[[KNOWLEDGE kind="rule" key="deploy"]]قبل از پوش تست بگیر.[[/KNOWLEDGE]]')
|
||||
)
|
||||
]
|
||||
)
|
||||
async with session_factory() as session:
|
||||
manager_row = await world.employee(session, "مدیر یک", RoleKey.MANAGER)
|
||||
await _with_model_profile(world, session, manager_row.id, world.strong_id)
|
||||
thread = await ThreadRepository(session).create(
|
||||
ThreadRow(organization_id=world.organization_id, title="گفتگو")
|
||||
)
|
||||
await session.commit()
|
||||
manager = await _manager(
|
||||
world, session, scripted=scripted, grants=FakeGrants({world.strong_id: "strong"})
|
||||
)
|
||||
await manager.chat(
|
||||
organization_id=world.organization_id,
|
||||
thread_id=thread.id,
|
||||
content="یادت باشد.",
|
||||
user_id=world.user_id,
|
||||
)
|
||||
await session.commit()
|
||||
rows = (
|
||||
(
|
||||
await session.execute(
|
||||
select(OrgMemoryRow).where(
|
||||
OrgMemoryRow.organization_id == world.organization_id
|
||||
)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
|
||||
assert len(rows) == 1
|
||||
assert rows[0].kind == "rule"
|
||||
assert rows[0].source == "manager"
|
||||
assert rows[0].content == "قبل از پوش تست بگیر."
|
||||
|
||||
|
||||
async def test_bootstrap_creates_the_team_and_is_idempotent(world, session_factory):
|
||||
async with session_factory() as session:
|
||||
manager = await _manager(
|
||||
world,
|
||||
session,
|
||||
scripted=ScriptedModel(),
|
||||
grants=FakeGrants({world.strong_id: "strong", world.fast_id: "fast"}),
|
||||
)
|
||||
await manager.bootstrap_team(organization_id=world.organization_id, project_id=None)
|
||||
await session.commit()
|
||||
employees = await EmployeeRepository(session).list_for_org(world.organization_id)
|
||||
profiles = {}
|
||||
for employee in employees:
|
||||
profile = await EmployeeProfileRepository(session).get(employee.id)
|
||||
profiles[employee.role_key] = profile.ai_model_id if profile else None
|
||||
|
||||
# A second call changes nothing.
|
||||
await manager.bootstrap_team(organization_id=world.organization_id, project_id=None)
|
||||
await session.commit()
|
||||
again = await EmployeeRepository(session).list_for_org(world.organization_id)
|
||||
|
||||
assert len(employees) == 4
|
||||
assert len(again) == 4
|
||||
assert {e.role_key for e in employees} == {
|
||||
RoleKey.MANAGER,
|
||||
RoleKey.PLANNER,
|
||||
RoleKey.DEVELOPER,
|
||||
RoleKey.TESTER,
|
||||
}
|
||||
assert all(e.status.value == "active" for e in employees)
|
||||
# The manager runs on the strongest granted tier, the tester on the fast one.
|
||||
assert profiles[RoleKey.MANAGER] == world.strong_id
|
||||
assert profiles[RoleKey.TESTER] == world.fast_id
|
||||
|
||||
|
||||
async def test_bootstrap_without_grants_creates_an_inactive_team(world, session_factory):
|
||||
async with session_factory() as session:
|
||||
manager = await _manager(world, session, scripted=ScriptedModel(), grants=FakeGrants({}))
|
||||
await manager.bootstrap_team(organization_id=world.organization_id, project_id=None)
|
||||
await session.commit()
|
||||
employees = await EmployeeRepository(session).list_for_org(world.organization_id)
|
||||
threads = await ThreadRepository(session).list_for_org(world.organization_id)
|
||||
notes: list[str] = []
|
||||
for thread in threads:
|
||||
notes.extend(
|
||||
m.content for m in await MessageRepository(session).list_for_thread(thread.id)
|
||||
)
|
||||
|
||||
assert len(employees) == 4
|
||||
assert all(e.status.value == "paused" for e in employees)
|
||||
assert NO_MODEL_GRANTED_MESSAGE in notes
|
||||
@@ -37,6 +37,7 @@ from app.domain.workforce.models import RoleKey
|
||||
from app.domain.workforce.pipeline import Mode
|
||||
|
||||
MANAGER_TRIAGE = {"role": RoleKey.MANAGER, "mode": Mode.TRIAGE}
|
||||
MANAGER_FINAL = {"role": RoleKey.MANAGER, "mode": Mode.FINAL}
|
||||
MANAGER_CHAT = {"role": RoleKey.MANAGER, "mode": Mode.CHAT}
|
||||
DEVELOPER = {"role": RoleKey.DEVELOPER, "mode": Mode.WORK}
|
||||
PLANNER = {"role": RoleKey.PLANNER, "mode": Mode.WORK}
|
||||
@@ -249,6 +250,23 @@ def test_assign_requires_an_employee_or_a_task():
|
||||
assign_of(tag)
|
||||
|
||||
|
||||
def test_an_empty_employee_means_pick_for_me():
|
||||
"""``employee=""`` is a *deliberate* value, unlike an absent attribute."""
|
||||
tag = parse_reply('[[ASSIGN employee="" pipeline="plan_code_test"/]]', **MANAGER_TRIAGE).first(
|
||||
TagName.ASSIGN
|
||||
)
|
||||
assign = assign_of(tag)
|
||||
assert assign.employee == ""
|
||||
assert assign.pipeline == "plan_code_test"
|
||||
|
||||
|
||||
def test_revise_carries_a_body():
|
||||
"""The prompt asks for ``[[REVISE]]reason[[/REVISE]]``; so does the parser."""
|
||||
reply = parse_reply("[[REVISE]]تستها قرمز است[[/REVISE]]", **MANAGER_FINAL)
|
||||
assert reply.errors == ()
|
||||
assert reply.first(TagName.REVISE).body == "تستها قرمز است"
|
||||
|
||||
|
||||
def test_assign_refuses_an_unknown_pipeline():
|
||||
reply = parse_reply('[[ASSIGN employee="سارا" pipeline="plan_ship_test"/]]', **MANAGER_TRIAGE)
|
||||
with pytest.raises(ProtocolError):
|
||||
|
||||
@@ -407,6 +407,33 @@ async def test_the_tool_call_cap_stops_a_runaway_turn(_db, session_factory, monk
|
||||
assert result.error_kind == "protocol"
|
||||
|
||||
|
||||
async def test_a_model_that_repeats_its_protocol_mistake_gives_up(
|
||||
_db, session_factory, monkeypatch
|
||||
):
|
||||
"""A malformed reply is retried, but not forever.
|
||||
|
||||
The regression: a model that answers with a tag its role may not use got
|
||||
the error quoted back and another chance, with no cap — so a model that
|
||||
repeats itself burned paid calls until the 900 s turn timeout.
|
||||
"""
|
||||
monkeypatch.setattr(settings, "engine_max_protocol_retries", 2)
|
||||
# A tag the developer may not use: refused every single time.
|
||||
scripted = ScriptedModel([ScriptedReply(content="[[ASSIGN employee=\"سارا\"/]]")] * 9)
|
||||
async with session_factory() as session:
|
||||
runner = _runner(
|
||||
session, model=scripted, sandbox=FakeSandbox(), grants=FakeGrants([_db.model_id])
|
||||
)
|
||||
result = await runner.run_turn(await _request(_db, **DEVELOPER))
|
||||
await session.commit()
|
||||
|
||||
assert result.ok is False
|
||||
assert result.error_kind == "protocol"
|
||||
assert result.incomplete is True
|
||||
# 1 malformed reply + 2 retries, then it stops: never the whole script.
|
||||
assert result.model_calls == 3
|
||||
assert scripted.calls == 3
|
||||
|
||||
|
||||
async def test_the_turn_timeout_stops_a_slow_loop(_db, session_factory, monkeypatch):
|
||||
monkeypatch.setattr(settings, "engine_turn_timeout_seconds", 0)
|
||||
scripted = ScriptedModel([ScriptedReply(content='[[LIST_DIR path="."/]]')] * 3)
|
||||
|
||||
Reference in New Issue
Block a user