CI / checks (push) Skipped
CI / checks (pull_request) Successful in 47s
Route selection sniffed model names: anything starting "claude-" or "anthropic/" took the vendor-native passthrough, everything else took the normalized route. That works only while an alias names its vendor. A semantic role alias like "remote-architecture" is meant to point at Anthropic today and somewhere else tomorrow, and the name says nothing either way -- so it would have taken the normalized route regardless of what was behind it, silently losing the fidelity A13 wants for delegations. LiteLLM already knows the answer. /model/info reports each alias's backing model, provider, and declared tool support, so the agent asks at startup and routes on the provider it discovers. That keeps vendor knowledge where A5 already put it -- in the proxy -- rather than duplicating it as name patterns here. There is deliberately no name-based fallback: PASSTHROUGH_PREFIXES and has_native_adapter are gone, and select_client now requires a catalog. A second heuristic could only ever disagree with the proxy. An unknown model raises instead of guessing a route. Misconfiguration is therefore loud and early. build_agent validates every role against the catalog before it clones anything, naming all the bad ones at once. Discovery is per key -- /model/info returns only what the presented token may use -- so the same check catches a role pointed at a real model this key cannot reach. An unreachable proxy is reported separately, since an outage and a typo call for different responses from the operator. Two things now get recorded rather than inferred: the resolved model is pinned alongside the role mapping at task start, for the reason A11 already pins roles, and DelegationStarted carries backing_model so A16 eval history is not averaged across whatever an alias happened to point at. A model LiteLLM declares tool-less warns at startup rather than refusing; A15 expects weak local models to be accommodated, and they are observed driving tool loops anyway. Verified live: all four aliases the production key can see route correctly through discovery, and each chosen route works. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
138 lines
3.2 KiB
Python
138 lines
3.2 KiB
Python
"""Canonical transcript schema (spec A10).
|
|
|
|
The durable record is this event log, never a provider-shaped ``messages``
|
|
array; provider request bodies are rendered from it at call time. Canonical
|
|
tool-call IDs are minted locally — provider IDs are mapped at the boundary and
|
|
never used as a join key.
|
|
|
|
Transcript appends are the conversation record itself, not state-machine
|
|
mutations, so they are deliberately outside the A9 state-change taxonomy.
|
|
"""
|
|
|
|
import uuid
|
|
from dataclasses import asdict, dataclass, fields
|
|
from typing import Any
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class UserMessage:
|
|
text: str
|
|
source: str # e.g. a Matrix event ID
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class AssistantText:
|
|
text: str
|
|
model: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ToolCall:
|
|
call_id: str # canonical, minted locally via mint_call_id()
|
|
tool: str
|
|
arguments: dict[str, Any]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ToolResult:
|
|
call_id: str
|
|
result: str
|
|
success: bool
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class PlanSubmitted:
|
|
plan: str
|
|
model: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class GateOpened:
|
|
gate: str
|
|
reason: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class DelegationStarted:
|
|
brief: str
|
|
role: str
|
|
model: str # the alias asked for, e.g. "remote-architecture"
|
|
caps: dict[str, Any] # turns / tokens / wall-clock limits
|
|
# What the alias resolved to at task start. An alias is free to change
|
|
# models between tasks, so eval history (A16) keyed on the alias alone
|
|
# would average results across whatever it happened to point at.
|
|
backing_model: str = ""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class DelegationCompleted:
|
|
handback: str
|
|
artifacts: list[str]
|
|
tokens: int
|
|
duration_s: float
|
|
exit_status: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Compaction:
|
|
summary: str
|
|
model: str
|
|
replaces: list[int] # [first_id, last_id] inclusive range replaced
|
|
|
|
|
|
CanonicalEvent = (
|
|
UserMessage
|
|
| AssistantText
|
|
| ToolCall
|
|
| ToolResult
|
|
| PlanSubmitted
|
|
| GateOpened
|
|
| DelegationStarted
|
|
| DelegationCompleted
|
|
| Compaction
|
|
)
|
|
|
|
EVENT_TYPES: dict[str, type] = {
|
|
"user_message": UserMessage,
|
|
"assistant_text": AssistantText,
|
|
"tool_call": ToolCall,
|
|
"tool_result": ToolResult,
|
|
"plan_submitted": PlanSubmitted,
|
|
"gate_opened": GateOpened,
|
|
"delegation_started": DelegationStarted,
|
|
"delegation_completed": DelegationCompleted,
|
|
"compaction": Compaction,
|
|
}
|
|
|
|
_TYPE_NAMES = {cls: name for name, cls in EVENT_TYPES.items()}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class StoredEvent:
|
|
id: int
|
|
ts: str
|
|
event: CanonicalEvent
|
|
|
|
|
|
def mint_call_id() -> str:
|
|
return f"call-{uuid.uuid4().hex}"
|
|
|
|
|
|
def event_type(event: CanonicalEvent) -> str:
|
|
return _TYPE_NAMES[type(event)]
|
|
|
|
|
|
def to_payload(event: CanonicalEvent) -> dict[str, Any]:
|
|
return asdict(event)
|
|
|
|
|
|
def from_payload(type_name: str, payload: dict[str, Any]) -> CanonicalEvent:
|
|
cls = EVENT_TYPES.get(type_name)
|
|
if cls is None:
|
|
raise ValueError(f"unknown canonical event type: {type_name!r}")
|
|
known = {f.name for f in fields(cls)}
|
|
unknown = payload.keys() - known
|
|
if unknown:
|
|
raise ValueError(f"unknown fields for {type_name}: {sorted(unknown)}")
|
|
return cls(**payload)
|