Story-1288 · pramaan-docs ADR-117 · inference framework swap

Atlas: BAML → Pydantic AI

Branch issue-1288-baml-to-pydantic-ai · Story: pramaan-io/pramaan-spec#1288 · Provenance: pramaan-docs ADR-117 (accepted 2026-06-14), builds on ADR-116, amends ADR-110

🛰️ 1 · Satellite — why this exists

Atlas's "soul" — his prompts and typed I/O — lived in BAML: a bespoke .baml DSL with its own compiler, plus a baml-cli generate step that produced a large committed baml_client/ package the brain imported. That is a second language and a codegen artifact bolted onto a stack that is otherwise pure Python + Pydantic everywhere (pramaan-functions models, the agent-event contracts, the wizard input).

WHY this friction never paid for itself at our scale: every prompt or type change meant "remember to regenerate, then commit the generated diff" — a recurring drift trap in this repo's history. And BAML re-derives Pydantic models from its DSL anyway, so we were paying a translation layer to arrive at the thing we already speak.

The decision (ADR-117): own the prompts and models directly in Pydantic AI. One language end-to-end, no DSL, no codegen, no committed generated client, no regenerate-or-drift failure mode. This is an implementation swap, not a behaviour change — the contracts above and below the brain are frozen.

✈️ 2 · Airplane — the shape of the swap

BEFORE                                    AFTER (pramaan-docs ADR-117)
─────────────────────────────            ────────────────────────────────────
baml_src/atlas.baml   (DSL: types        models.py   (hand-owned Pydantic models)
  + prompts + tests)                      prompts.py  (system prompts + builders)
        │ baml-cli generate                      │  (no codegen — plain Python)
        ▼                                         ▼
baml_client/  (committed generated)       Pydantic AI Agent(output_type=…)
        │ b.stream.AtlasIntake(…)                 │ agent.run_stream(prompt, model=…)
        ▼                                         ▼
ClientRegistry (runtime model swap)       provider.py  build_model(config)
  bedrock | openrouter                      bedrock → BedrockConverseModel
                                            openrouter → OpenAIChatModel(OpenRouter)

Five BAML functions → five Pydantic AI agents. ~16 BAML classes + 1 enum → hand-owned Pydantic models. The ClientRegistry model-swap → first-class Model objects selected per-run from config. Everything the broker and the browser see is unchanged.

🔬 3 · Microscope — what moved, and WHY each piece

ConcernBefore (BAML)After (Pydantic AI)
Typesbaml_src/atlas.baml classes → generated baml_client/types.pymodels.py — hand-owned Pydantic, 1:1 with the BAML classes. String unions → Literal, optionals preserved, AtlasTrigger → StrEnum. Version stamps (prompt_version/schema_version) carry defaults so they are guaranteed regardless of model output.
Promptsprompt #"…"# blocks with {{ _.role() }} + Jinjaprompts.py — a static *_SYSTEM constant per capability + a build_*_user_prompt builder for the dynamic content. Substance ported verbatim (ADR-117 D3).
Provider switchclient_registry.py builds a ClientRegistry; aws-bedrock or openai-genericprovider.py build_model(config): bedrock → BedrockConverseModel(region); openrouter → OpenAIChatModel at the OpenRouter base_url, key from OPENROUTER_API_KEY (fail-fast). All env knobs unchanged (ADR-116).
Call sitesb.stream.AtlasIntake(… baml_options={client_registry, collector})agent.py: _intake_agent.run_stream(prompt, model=…) + result.stream_output(); the other four via .run(…). Model + settings passed per-run from (env-overridden) config.
Usage / costBAML Collector.usageUsageCollector holds Pydantic AI result.usage (input_tokens/output_tokens) — the runtime reads it identically; token_cost_usd math unchanged.
Deps / CI / lintbaml-py==0.222.0; ruff exclude baml_client; isort first-party baml_clientpydantic-ai-slim[bedrock,openai] in requirements.in + agent pyproject.toml (locks regenerated); ruff exclude dropped; isort first-party now the agent's own modules.
Deletedbaml_src/, baml_client/, client_registry.py, baml-py dep. Grep over agents/ requirements.in .github/ AGENTS.md is clean of baml.

🔬 4 · The one subtle bit — streaming cadence parity

BAML streamed partial AtlasTurns, then yielded one final complete turn via get_final_response() — and only by then was token usage known, so the runtime stamped the real cost on the final turn. Pydantic AI's result.usage is likewise populated only after the stream is consumed. To preserve the exact cadence, stream_atlas_turn streams the partials, captures usage, then re-yields the final turn — so the runtime's per-yield cost stamp lands on the final turn.completed exactly as before.

Before — agent.py (BAML)

stream = b.stream.AtlasIntake(…)
async for partial in stream:
    yield partial
yield await stream.get_final_response()

After — agent.py (Pydantic AI)

async with _intake_agent.run_stream(p, model=…) as r:
    async for partial in r.stream_output():
        final = partial; yield partial
    collector.usage = r.usage
if final is not None:
    yield final   # restamp cost on final turn

✅ 5 · Behaviour preserved — the evidence

This is the whole point: same events, same truth boundary, same prompt substance, same knobs. The existing Atlas test suite was ported off baml_client imports onto the new Pydantic models and stays green — 45 passed.

Invariant (ADR-117 hard constraint)Proof it survived
agent_event_stream.v1 shapes + orderingtest_atlas_event_stream.py: run.started → turn.delta → model.usage → turn.completed → run.completed; golden fixture still well-formed; four-key envelope intact.
turn.delta streaming cadence + model.usageEnd-to-end offline run through the real agent (a Pydantic AI FunctionModel injected) emits multiple turn.delta then a cost-stamped turn.completed.
Truth boundary (requires_counsel_approval, frozen source_snapshot)test_atlas_runtime.py: proposal wrapped with is_proposal + counsel-approval flag + verbatim snapshot; test_atlas_agent.py proves the snapshot is byte-for-byte counsel's raw words.
Anti-hallucination · STEP-1 contradiction · minor-units (/ 100) · assume-and-flag · matter_type priorPorted verbatim into prompts.py (INTAKE_SYSTEM + _value_line). Backward-compat covered: test_*_matter_type_absent_is_none, test_matter_type_not_in_source_snapshot.
Flag-gated reflection (reflection.enabled)test_atlas_reflection.py: flag OFF → byte-identical stream (no extra call); flag ON + failed audit → corrected draft replaces the fabricated one and turn.reflection rides the stream; clarification turns skip it.
Fail-closed (a bad parse must not corrupt a run)Retry-once then safe _error_turn() preserved in stream_atlas_turn; explain/reframe/staleness keep their safe fallbacks; test_brain_failure_emits_run_failed_last + missing-input raises before any event.
Provider-switch (ADR-116) + provenanceprovider.py keeps bedrock default + openrouter (env key, fail-fast); provenance() still stamps provider + region + model on every result.
Brain doctrine (ADR-110 §6)Stateless; no DB/queue/bucket; egress is Bedrock + OpenRouter only. No boto3 s3·sqs·dynamodb introduced.
Result  cd agents/atlas && pytest tests -q → 45 passed. ruff check agents/ tests/ scripts/ → clean. grep -rn baml agents/ requirements.in .github/ AGENTS.md → empty. BAML is gone; Atlas's five capabilities run through Pydantic AI agents with identical behaviour.
Follow-up (out of scope)  The observability runbook (docs/atlas-observability-flow.html) and the prompt-debug memory describe filtering the runtime log group for BAML log lines — post-migration the LLM call no longer emits those. Wiring Pydantic AI / Logfire structured logging into the AgentCore runtime log group is a separate operational ticket.