[Story-1218] Shared agent contracts package (pramaan-docs ADR-110 T4)

Story 1218Epic None19 files · +1542/−1

PR #309 · pramaan-io/pramaan-functions · merged 2026-06-12

Why this matters

Two services. Two copies of every type. One silent drift between them.

What breaks when the HTTP layer and the brain each keep their own private dictionary?

Adding one field becomes a cross-repo release.

The fix — Same word, same meaning — across the broker and the brain.

How it flows

sequenceDiagram
  autonumber
  participant broker as Broker (HTTP layer)
  participant brain as Brain (Atlas agent)
  broker->>broker: 1. Mint run identity + one-shot grant
  broker->>brain: 2. Hand over the invoke form
  brain->>brain: 3. Run the agent
  brain->>broker: 4. Stream events as numbered ledger lines
  broker->>broker: 5. parse_event_line — halt on unknown
  brain->>brain: 6. HITL pause on RunBlocked
  broker-->>brain: 7. Resume with same grant (one shot)
  brain->>brain: 8. Reducer folds to terminal + price
  brain->>broker: 9. Terminal event or failure envelope

failure_envelope.py · +249 — new methods

Methodin → outwhat it does
mark_failure_context(error, failure_stage, failure_kind, failed_component) → BaseException
build_failure_envelope(error, run_id, firm_id, matter_id, failure_stage, failed_component, created_at) → dict[str, Any]
failure_event_payload(error, envelope) → dict[str, Any]

What changed, file by file

Builds the canonical failure envelope; classifies exceptions by kind and stage so broker and brain share one diagnosis. Relocated from the rails.

new methodsignature
mark_failure_context(error, failure_stage, failure_kind, failed_component) → BaseException
build_failure_envelope(error, run_id, firm_id, matter_id, failure_stage, failed_component, created_at) → dict[str, Any]
failure_event_payload(error, envelope) → dict[str, Any]
_classify_kind(error, stage) → str
_sanitized_summary(kind, stage) → str
_recovery_recommendation(kind, stage) → str
_user_visible_message(kind, stage) → str
_is_retryable(kind) → bool
_retry_after_seconds(kind) → int
_internal_debug_ref(raw_error_class, raw_message) → str
_context_value(error, name) → str | None
_valid_kind(value) → str
_valid_stage(value) → str
_default_component(stage) → str

Defines eight event types as a Pydantic discriminated union and parse_event_line rejecting unknown schemas. One source of truth for the agent vocabulary.

new classsignature
Usage(class)
RunStartedPayload(class)
TurnDeltaPayload(class)
TurnCompletedPayload(class)
ModelUsagePayload(class)
ToolRequestedPayload(class)
BlockApproval(class)
RunBlockedPayload(class)
RunCompletedPayload(class)
RunFailedPayload(class)
_EventBase(class)
RunStartedEvent(class)
TurnDeltaEvent(class)
TurnCompletedEvent(class)
ModelUsageEvent(class)
ToolRequestedEvent(class)
RunBlockedEvent(class)
RunCompletedEvent(class)
RunFailedEvent(class)
parse_event_line(line) → AgentStreamEvent
New fields: input_tokens : intoutput_tokens : intmodel_id : strprompt_version : strschema_version : strusage : Usagetool_name : strinput_sha256 : strtool_input_hash : strrisk_tier : strreason : strhuman_summary : strapproval : BlockApprovalstop_reason : strerror_class : strerror_message : strfailure_stage : str | Nonefailure_kind : str | Nonerun_id : strpayload : RunFailedPayload_event_adapter : TypeAdapter[AgentStreamEvent]

Folds an event stream into a terminal run state with immutability, HITL hold/release, and cost-abort. Relocated verbatim from the consumer side.

new methodsignature
reduce_agent_run(events) → dict[str, Any]
_run_failed_status(payload) → str

Defines HITL risk-tier types and resolves a tool to a risk decision. Fail-closed default denies any unlisted tool rather than auto-approving.

new classsignature
ToolRiskTier(class)
ToolRiskPolicyEntry(tier, reason, ToolRiskDecision, tool_name, matched_policy)
ToolRiskPolicy(version, default, tools)
get_tool_risk_decision(tool_name, metadata) → ToolRiskDecision
load_hitl_risk_policy() → ToolRiskPolicy
_parse_entry(raw_entry, policy_key) → ToolRiskPolicyEntry
_non_empty_tool_name(tool_name) → str

Defines the broker-to-brain AgentInvoke envelope: InvokeMeta set by the broker, plus an optional one-shot ApprovalGrant bound to a single tool input hash.

new classsignature
ApprovalGrant(class)
InvokeMeta(class)
HistoryMessage(class)
AgentInvoke(class)
New fields: approval_request_id : UUIDtool_name : strtool_input_hash : strrun_id : UUIDfirm_id : UUIDuser_id : UUIDcorrelation_id : str | Noneform_id : str | Nonematter_id : UUID | Nonechat_session_id : UUID | Noneapproval : ApprovalGrant | Nonerole : strtext : stragent_key : strmode : strmeta : InvokeMetacounsel_message : str | None

Concrete fail-closed policy listing known tools and their risk reasons; default section denies any tool not named.

New fields: reason : Irreversible external court filing that must not run without human approval.

Per-model Bedrock pricing in USD per token, relocated from Atlas config. Feeds the cost cap with the real model rates.

new methodsignature
token_cost_usd(model_id, input_tokens, output_tokens) → float

ContractModel base class for every Pydantic contract in the package; one config for extra-field handling, serialization, and validation behavior across all contracts.

new classsignature
ContractModel(class)

Files changed (19)

File±
docs/walkthroughs/story-1218-walkthrough.html+52
requirements.in+3
requirements.lock+76 −1
src/shared/agent_contracts/__init__.py ★+137
src/shared/agent_contracts/_base.py+21
src/shared/agent_contracts/events.py ★+157
src/shared/agent_contracts/failure_envelope.py ★+249
src/shared/agent_contracts/hitl_risk_policy.yaml+32
src/shared/agent_contracts/invoke.py+76
src/shared/agent_contracts/pricing.py+27
src/shared/agent_contracts/reducer.py+137
src/shared/agent_contracts/risk_policy.py+121
tests/unit/agent_contracts/__init__.py+0
tests/unit/agent_contracts/test_events.py+112
tests/unit/agent_contracts/test_failure_envelope.py+96
tests/unit/agent_contracts/test_invoke.py+90
tests/unit/agent_contracts/test_pricing.py+21
tests/unit/agent_contracts/test_reducer.py+100
tests/unit/agent_contracts/test_risk_policy.py+35