1# Author: Simon-Pierre Boucher — simon-pierre.boucher@uqo.ca2"""One agent turn: (role, prompt, schema, model) -> AgentTurn, through the provider interface.34Failures (provider error, validation failure after retries, spend cap) are recorded in ``AgentTurn.error``5with ``estimate=None`` / ``critiques=None``; nothing is ever fabricated. ``SpendCapExceeded`` is re-raised6so that the experiment runner stops gracefully.7"""8from __future__ import annotations910import builtins11from typing import Any1213from pydantic import BaseModel1415from ..providers import CostLedger, LLMProvider, get_provider16from ..providers.ledger import SpendCapExceeded17from ..schemas import AgentEstimate, AgentTurn, CritiqueSet18from .roles import Role192021class ProviderPool:22 """One provider adapter per model id (each with its own concurrency semaphore), sharing one ledger."""2324 def __init__(self, ledger: CostLedger | None = None, force_mock: bool | None = None):25 self.ledger = ledger or CostLedger()26 self.force_mock = force_mock27 self._providers: dict[str, LLMProvider] = {}2829 def get(self, model: str) -> LLMProvider:30 if model not in self._providers:31 self._providers[model] = get_provider(model, self.ledger, force_mock=self.force_mock)32 return self._providers[model]3334 @property35 def mode(self) -> str:36 return "mock" if self.force_mock else "live"373839async def run_turn(40 provider: LLMProvider,41 role: Role,42 view_prompt: str,43 schema: type[BaseModel],44 model: str,45 effort: str = "medium",46 round: int = 0, # noqa: A002 - mirrors AgentTurn.round47 kind: str = "estimate",48 *,49 max_tokens: int = 4096,50 tag: str = "",51) -> AgentTurn:52 """Run one structured call and wrap it as an AgentTurn. Never raises except for SpendCapExceeded."""53 turn = AgentTurn(role=role.id, round=round, kind=kind, model=model, provider=getattr(provider, "name", "unknown")) # type: ignore[arg-type]54 try:55 resp = await provider.complete(56 system=role.system_prompt(), user=view_prompt, schema=schema, model=model, effort=effort, max_tokens=max_tokens, tag=tag or f"{role.id}:{kind}:{round}"57 )58 except SpendCapExceeded:59 raise60 except Exception as e: # defensive: provider.complete already catches most errors61 turn.error = f"{type(e).__name__}: {e}"[:500]62 return turn63 turn.input_tokens = int(resp.input_tokens)64 turn.output_tokens = int(resp.output_tokens)65 turn.cached_tokens = int(resp.cached_tokens)66 turn.cost_usd = float(resp.cost_usd)67 turn.latency_s = builtins.round(float(resp.latency_s), 4)68 turn.prompt_hash = resp.prompt_hash69 turn.request_id = resp.request_id70 turn.validation_retries = int(resp.validation_retries)71 turn.error = resp.error72 parsed: Any = resp.parsed73 if parsed is None:74 turn.error = turn.error or "no parsed output"75 elif isinstance(parsed, AgentEstimate):76 turn.estimate = parsed77 elif isinstance(parsed, CritiqueSet):78 turn.critiques = parsed79 else:80 turn.error = f"unexpected schema {type(parsed).__name__}"81 return turn82