feat(engine): the turn runner — prompt builder, sandbox tools, agent loop
Phase 2 of TODO-agent-2: one employee, one turn, real tools in the sandbox. - engine/prompt.py: the stable, cache-friendly prefix in the plan's order (rules → role protocol → toolkit → app profile → memory), a task brief capped at 1200 chars, role-specific stage context (spec for the developer, the diff only for the reviewer, test output for the tester) and a hard prompt cap that drops the oldest tool results first. - engine/tools.py: READ/WRITE/EDIT/LIST_DIR/SEARCH/RUN executed inside the sandbox through SandboxProviderPort, rooted at the worktree's sandbox path. Path escapes, .. and .git are refused; git push/remote/config are refused with "the platform does git"; RUN clamps its timeout (120s default, 600s max) and keeps the tail of its output; every execution writes a tool_calls row so the approvals UI shows real work. - engine/runner.py: the loop — model call, parse tags, execute tool tags, feed results back — until a terminal tag, the tool cap, the turn timeout or a cancellation. The employee's model is checked against ModelGrantPort before the first call (ungranted → error_kind=auth, no spend), errors are classified transient vs fatal, and every model call writes usage events through the one shared record_model_usage path (extracted from WorkforceService so manager chat counts too). Writes engine_turns, run_steps and tool_calls. - engine/events.py: RunFeed batches run.log (≤1 event / 500 ms / stream, ≤200 lines) and emits run.tool and task.gate through the generic envelope. - tests: engine_fakes.py (ScriptedModel, FakeSandbox, FakeGrants, RecordingMemory) plus 54 new tests in test_engine_runner, test_engine_prompt and test_engine_tools. Suite: 648 passed, ruff clean, mypy clean.
This commit is contained in:
@@ -102,6 +102,58 @@ _SYSTEM_PROMPT = (
|
||||
)
|
||||
|
||||
|
||||
async def record_model_usage(
|
||||
*,
|
||||
ledger: UsageLedgerRepository,
|
||||
rate_cards: RateCardRepository,
|
||||
organization_id: str,
|
||||
unit: RateUnit,
|
||||
quantity: float,
|
||||
project_id: str | None = None,
|
||||
user_id: str | None = None,
|
||||
employee_id: str | None = None,
|
||||
run_id: str | None = None,
|
||||
) -> None:
|
||||
"""Write one usage event for a model call, and push it live.
|
||||
|
||||
**Every** model call goes through here — the run engine, the manager chat
|
||||
and the engine's turn runner alike. Billing reads the internal ledger and
|
||||
never the provider's invoice, so a call that is not recorded here is a
|
||||
call the platform never bills for.
|
||||
|
||||
The pricing rule: a missing rate card is a misconfiguration, not a free
|
||||
lunch, so the placeholder price is recorded and flagged as unpriced rather
|
||||
than silently charging zero.
|
||||
"""
|
||||
card = await rate_cards.get_by_unit(unit)
|
||||
unit_price = card.customer_price if card else _UNPRICED
|
||||
internal_cost = card.internal_cost_estimate if card else 0.0
|
||||
await ledger.record(
|
||||
usage=UsageRecord(
|
||||
organization_id=organization_id,
|
||||
project_id=project_id,
|
||||
user_id=user_id,
|
||||
employee_id=employee_id,
|
||||
run_id=run_id,
|
||||
unit=unit,
|
||||
quantity=quantity,
|
||||
unit_price=unit_price,
|
||||
internal_cost_estimate=quantity * internal_cost,
|
||||
total_price=quantity * unit_price,
|
||||
metadata_json={"priced": card is not None},
|
||||
)
|
||||
)
|
||||
# Live push: the billing page and the sidebar credit badge react to
|
||||
# consumption as it happens instead of at the next page load.
|
||||
await emit_usage_recorded(
|
||||
organization_id=organization_id,
|
||||
unit=unit.value if hasattr(unit, "value") else str(unit),
|
||||
quantity=quantity,
|
||||
run_id=run_id,
|
||||
employee_id=employee_id,
|
||||
)
|
||||
|
||||
|
||||
class WorkforceService:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -928,25 +980,17 @@ class WorkforceService:
|
||||
employee_id: str | None = None,
|
||||
run_id: str | None = None,
|
||||
) -> None:
|
||||
card = await self.rate_cards.get_by_unit(unit)
|
||||
unit_price = card.customer_price if card else _UNPRICED
|
||||
internal_cost = card.internal_cost_estimate if card else 0.0
|
||||
await self.ledger.record(
|
||||
usage=UsageRecord(
|
||||
organization_id=organization_id,
|
||||
project_id=project_id,
|
||||
user_id=user_id,
|
||||
employee_id=employee_id,
|
||||
run_id=run_id,
|
||||
unit=unit,
|
||||
quantity=quantity,
|
||||
unit_price=unit_price,
|
||||
internal_cost_estimate=quantity * internal_cost,
|
||||
total_price=quantity * unit_price,
|
||||
metadata_json={"priced": card is not None},
|
||||
)
|
||||
await record_model_usage(
|
||||
ledger=self.ledger,
|
||||
rate_cards=self.rate_cards,
|
||||
organization_id=organization_id,
|
||||
unit=unit,
|
||||
quantity=quantity,
|
||||
project_id=project_id,
|
||||
user_id=user_id,
|
||||
employee_id=employee_id,
|
||||
run_id=run_id,
|
||||
)
|
||||
# Live push: the billing page and the sidebar credit badge react to
|
||||
# consumption as it happens instead of at the next page load.
|
||||
await emit_usage_recorded(
|
||||
organization_id=organization_id,
|
||||
|
||||
Reference in New Issue
Block a user