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.
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.
| Concern | Before (BAML) | After (Pydantic AI) |
|---|---|---|
| Types | baml_src/atlas.baml classes → generated baml_client/types.py | models.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. |
| Prompts | prompt #"…"# blocks with {{ _.role() }} + Jinja | prompts.py — a static *_SYSTEM constant per capability + a build_*_user_prompt builder for the dynamic content. Substance ported verbatim (ADR-117 D3). |
| Provider switch | client_registry.py builds a ClientRegistry; aws-bedrock or openai-generic | provider.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 sites | b.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 / cost | BAML Collector.usage | UsageCollector holds Pydantic AI result.usage (input_tokens/output_tokens) — the runtime reads it identically; token_cost_usd math unchanged. |
| Deps / CI / lint | baml-py==0.222.0; ruff exclude baml_client; isort first-party baml_client | pydantic-ai-slim[bedrock,openai] in requirements.in + agent pyproject.toml (locks regenerated); ruff exclude dropped; isort first-party now the agent's own modules. |
| Deleted | baml_src/, baml_client/, client_registry.py, baml-py dep. Grep over agents/ requirements.in .github/ AGENTS.md is clean of baml. | |
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.
stream = b.stream.AtlasIntake(…)
async for partial in stream:
yield partial
yield await stream.get_final_response()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 turnThis 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 + ordering | test_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.usage | End-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 prior | Ported 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) + provenance | provider.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. |
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.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.