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