From dbaf2de878cfba597ec924c8bbaa933863798deb Mon Sep 17 00:00:00 2001 From: chris-colinsky Date: Mon, 1 Jun 2026 19:07:59 -0700 Subject: [PATCH 1/4] Rename examples and reorder catalog by topic Drop the NN- numeric prefix from all 13 example directories and their corresponding walk-through docs. Regroup the catalog at docs/examples/index.md and the mkdocs nav into seven topical sections: Foundations, Composition, Concurrency, Prompts, Tool use, Reliability, Observability. The numbers were never load- bearing; they implied a strict reading order the catalog never actually needed. Cross-references between examples are rewritten by-name ("the observer-hooks example", "the fan-out-with-retry example"); no more "Example NN" references in docs, walk-throughs, example sources, READMEs, CHANGELOG. Walk-through H1s drop the prefix; "Run with:" docstring blocks updated to the new paths. examples/README.md brought up to date with the post-v0.11.0 catalog under the same topical grouping; entries for chat-with-multimodal, langfuse-observability, and production-observability were missing. tests/test_examples_smoke.py's DEMOS list switches to flat names; pytest parametrize IDs follow automatically. Folds in the em-dash sweep across example source files and examples/README.md to match the convention established in the v0.11.0-cycle docs sweep. --- CHANGELOG.md | 11 +- README.md | 2 +- RELEASING.md | 2 +- docs/concepts/llms.md | 8 +- docs/concepts/observability.md | 2 +- ...-multimodal.md => chat-with-multimodal.md} | 23 +-- ...tion.md => checkpointing-and-migration.md} | 4 +- ...apping.md => explicit-subgraph-mapping.md} | 12 +- ...ut-with-retry.md => fan-out-with-retry.md} | 6 +- .../{00-hello-world.md => hello-world.md} | 6 +- docs/examples/index.md | 117 ++++++++----- ...rvability.md => langfuse-observability.md} | 4 +- ...timodal-prompt.md => multimodal-prompt.md} | 8 +- ...ested-subgraphs.md => nested-subgraphs.md} | 4 +- ...03-observer-hooks.md => observer-hooks.md} | 4 +- ...allel-branches.md => parallel-branches.md} | 10 +- ...ability.md => production-observability.md} | 12 +- ...-subgraphs.md => routing-and-subgraphs.md} | 4 +- docs/examples/{09-tool-use.md => tool-use.md} | 6 +- .../caller-supplied-trace-identifiers.md | 2 +- docs/patterns/session-as-checkpoint-resume.md | 2 +- docs/patterns/tool-dispatch-as-node.md | 4 +- examples/README.md | 157 ++++++++++++------ .../main.py | 20 ++- .../main.py | 2 +- .../main.py | 18 +- .../main.py | 18 +- .../{00-hello-world => hello-world}/main.py | 6 +- .../main.py | 14 +- .../main.py | 20 +-- .../prompts/production/describe-equipment.j2 | 0 .../prompts/production/describe-surface.j2 | 0 .../production/describe-equipment.j2 | 0 .../production/describe-surface.j2 | 0 .../main.py | 16 +- .../main.py | 26 +-- .../main.py | 18 +- .../main.py | 14 +- .../main.py | 48 +++--- examples/{09-tool-use => tool-use}/main.py | 14 +- mkdocs.yml | 33 ++-- src/openarmature/AGENTS.md | 34 ++-- .../caller-supplied-trace-identifiers.md | 2 +- .../_patterns/session-as-checkpoint-resume.md | 2 +- .../_patterns/tool-dispatch-as-node.md | 4 +- tests/test_examples_smoke.py | 26 +-- 46 files changed, 420 insertions(+), 325 deletions(-) rename docs/examples/{11-chat-with-multimodal.md => chat-with-multimodal.md} (89%) rename docs/examples/{08-checkpointing-and-migration.md => checkpointing-and-migration.md} (98%) rename docs/examples/{02-explicit-subgraph-mapping.md => explicit-subgraph-mapping.md} (89%) rename docs/examples/{05-fan-out-with-retry.md => fan-out-with-retry.md} (97%) rename docs/examples/{00-hello-world.md => hello-world.md} (96%) rename docs/examples/{10-langfuse-observability.md => langfuse-observability.md} (98%) rename docs/examples/{07-multimodal-prompt.md => multimodal-prompt.md} (96%) rename docs/examples/{04-nested-subgraphs.md => nested-subgraphs.md} (98%) rename docs/examples/{03-observer-hooks.md => observer-hooks.md} (98%) rename docs/examples/{06-parallel-branches.md => parallel-branches.md} (94%) rename docs/examples/{12-production-observability.md => production-observability.md} (95%) rename docs/examples/{01-routing-and-subgraphs.md => routing-and-subgraphs.md} (97%) rename docs/examples/{09-tool-use.md => tool-use.md} (97%) rename examples/{11-chat-with-multimodal => chat-with-multimodal}/main.py (97%) rename examples/{08-checkpointing-and-migration => checkpointing-and-migration}/main.py (99%) rename examples/{02-explicit-subgraph-mapping => explicit-subgraph-mapping}/main.py (93%) rename examples/{05-fan-out-with-retry => fan-out-with-retry}/main.py (96%) rename examples/{00-hello-world => hello-world}/main.py (97%) rename examples/{10-langfuse-observability => langfuse-observability}/main.py (96%) rename examples/{07-multimodal-prompt => multimodal-prompt}/main.py (96%) rename examples/{07-multimodal-prompt => multimodal-prompt}/prompts/production/describe-equipment.j2 (100%) rename examples/{07-multimodal-prompt => multimodal-prompt}/prompts/production/describe-surface.j2 (100%) rename examples/{07-multimodal-prompt => multimodal-prompt}/prompts_fallback/production/describe-equipment.j2 (100%) rename examples/{07-multimodal-prompt => multimodal-prompt}/prompts_fallback/production/describe-surface.j2 (100%) rename examples/{04-nested-subgraphs => nested-subgraphs}/main.py (96%) rename examples/{03-observer-hooks => observer-hooks}/main.py (95%) rename examples/{06-parallel-branches => parallel-branches}/main.py (95%) rename examples/{12-production-observability => production-observability}/main.py (97%) rename examples/{01-routing-and-subgraphs => routing-and-subgraphs}/main.py (92%) rename examples/{09-tool-use => tool-use}/main.py (97%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 64e836e3..e317d01d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,17 +10,18 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). The - **README and docs homepage refreshed around reasons-to-choose.** Replaced the 10-bullet "Why OpenArmature" feature inventory in `README.md` with 5 differentiating reasons (LLM-infused workflows to agents on one engine; crash-safe resume by contract; destination-pluggable observability with OTel + Langfuse, no SaaS lock-in; compile-time topology checks; spec + conformance). The docs homepage (`docs/index.md`) card grid carries the same five plus a sixth card retained from the previous grid for async-first / LLM-agnostic: workflows-to-agents, crash-safe, pluggable observability, bad-graphs-don't-compile, parallelism (fan-out + parallel-branches + nested correctness), async-first. - **Docs sweep: stale references and em-dash normalization.** Fixed three definite stale references (`spec_version='0.26.0'` in the Langfuse example output now reads `'0.38.0'`; the dangling `v0.16.1` qualifier dropped from the parallel-branches concept page; `compiled.attach_observer` corrected to `graph.attach_observer` in `non-obvious-shapes.md` for variable-name consistency with the rest of the docs). Swept em dashes out of the user-facing docs (130 instances across 17 files) per the convention set during the patterns expansion. mkdocs strict build clean; no broken intra-docs links. -- **Example 08 (checkpointing-and-migration) grows a crash-and-resume drama.** The first invoke of the v1 graph now hits a simulated transient failure inside `size_crew` (raises a `RuntimeError` on its first attempt only). The example catches `NodeException` at the `invoke()` boundary, prints what's saved on disk (`define_objective`'s position is already in `completed_positions`), then re-invokes with `resume_invocation=`. The retried `size_crew` succeeds, `draft_timeline` runs, and the pipeline finishes - dramatizing the synchronous-checkpoint-by-contract reliability claim from the README pitch. The existing v1->v2 migration phase rides on top of the crash-survived checkpoint, so both reliability stories compose in one demo. Walk-through doc rewritten to cover both phases. +- **The checkpointing-and-migration example grows a crash-and-resume drama.** The first invoke of the v1 graph now hits a simulated transient failure inside `size_crew` (raises a `RuntimeError` on its first attempt only). The example catches `NodeException` at the `invoke()` boundary, prints what's saved on disk (`define_objective`'s position is already in `completed_positions`), then re-invokes with `resume_invocation=`. The retried `size_crew` succeeds, `draft_timeline` runs, and the pipeline finishes - dramatizing the synchronous-checkpoint-by-contract reliability claim from the README pitch. The existing v1->v2 migration phase rides on top of the crash-survived checkpoint, so both reliability stories compose in one demo. Walk-through doc rewritten to cover both phases. +- **Examples renamed and catalog reorganized by topic.** All 13 example directories drop their numeric prefixes (`examples/00-hello-world/` -> `examples/hello-world/`, etc.); the corresponding `docs/examples/NN-name.md` files do the same. The catalog at `docs/examples/index.md` and the mkdocs nav are regrouped into seven topical sections: Foundations, Composition, Concurrency, Prompts, Tool use, Reliability, Observability. Catalog entries and walk-through H1s drop the number prefix; cross-references between examples are rewritten by-name (no more "Example 03"). The `examples/README.md` is brought up to date with the post-v0.11.0 catalog (entries for chat-with-multimodal, langfuse-observability, production-observability now present) under the same grouping. `tests/test_examples_smoke.py`'s DEMOS list updated; the pytest parametrize IDs follow the new names. Em dashes scrubbed from the example sources and `examples/README.md` to match the convention established in the v0.11.0-cycle docs sweep. ### Added -- **Production observability example.** `examples/12-production-observability/` demonstrates the production-grade observability stack end-to-end: `OTelObserver` + `LangfuseObserver` attached to the same graph (proposal 0031), `trace_input_from_state` / `trace_output_from_state` caller hooks on the Langfuse observer (proposal 0043 §8.4.1) deriving domain dicts from State, the built-in `TimingMiddleware` recording per-node duration via an `on_complete` callback, and `invoke(metadata={...})` carrying multi-tenant identifiers (tenantId / requestId / featureFlag) that both observers pick up at once. `InMemoryLangfuseClient` + `InMemorySpanExporter` capture in-process so the demo prints what each backend would have ingested without needing real production credentials. -- **Chat-with-multimodal example.** `examples/11-chat-with-multimodal/` demonstrates `ChatPrompt` + `PlaceholderSegment` (proposal 0046) end-to-end: a four-turn lunar-mission Q&A conversation with conversation memory threaded through state, one mid-conversation turn attaching a photograph via `ImageURLBlockTemplate`, the agent processing the multimodal turn naturally without changing the chat-history shape. Complementary to example 09 (tool use); chat history threading and tool calling are separate primitives. -- **`docs/examples/index.md` catalog now lists example 10.** A pre-existing gap (the Langfuse-observability example was missing from the catalog) caught and fixed alongside the example 11 entry. +- **Production observability example.** `examples/production-observability/` demonstrates the production-grade observability stack end-to-end: `OTelObserver` + `LangfuseObserver` attached to the same graph (proposal 0031), `trace_input_from_state` / `trace_output_from_state` caller hooks on the Langfuse observer (proposal 0043 §8.4.1) deriving domain dicts from State, the built-in `TimingMiddleware` recording per-node duration via an `on_complete` callback, and `invoke(metadata={...})` carrying multi-tenant identifiers (tenantId / requestId / featureFlag) that both observers pick up at once. `InMemoryLangfuseClient` + `InMemorySpanExporter` capture in-process so the demo prints what each backend would have ingested without needing real production credentials. +- **Chat-with-multimodal example.** `examples/chat-with-multimodal/` demonstrates `ChatPrompt` + `PlaceholderSegment` (proposal 0046) end-to-end: a four-turn lunar-mission Q&A conversation with conversation memory threaded through state, one mid-conversation turn attaching a photograph via `ImageURLBlockTemplate`, the agent processing the multimodal turn naturally without changing the chat-history shape. Complementary to the tool-use example; chat history threading and tool calling are separate primitives. +- **`docs/examples/index.md` catalog now lists the langfuse-observability example.** A pre-existing gap (it was missing from the catalog) caught and fixed alongside the chat-with-multimodal entry. - **PyPI + spec-version shields on the docs homepage.** `docs/index.md` now carries dynamic shields for the published PyPI version and the pinned spec version, sourced from `img.shields.io`. Both auto-update on every publish or spec bump; no maintenance burden. Mirrors the same shield URLs the README already uses. - **vLLM production deployment notes.** `docs/model-providers/vllm.md` grows a "Production deployment" section covering the `VLLM_HTTP_TIMEOUT_KEEP_ALIVE` gotcha (vLLM's stock 5s uvicorn keep-alive lapses pooled OA-side httpx connections and surfaces as `ProviderUnavailable`; widen to roughly 300s), a systemd unit skeleton, and the three throughput knobs that interact with OA's shared connection pool (`--max-model-len`, `--max-num-seqs`, `--gpu-memory-utilization`). The existing "Tool calling" section grows a `--tool-call-parser` family table verified against vLLM's docs (Llama 3.x / Llama 4 / Mistral / Hermes / Qwen3 / DeepSeek V3 / GPT-OSS), plus explicit "not supported here" callouts for Anthropic / Gemini (proprietary cloud) and mainstream Gemma (no vLLM parser). - **Three new patterns docs.** `docs/patterns/state-migration-on-resume.md`, `docs/patterns/caller-supplied-trace-identifiers.md`, and `docs/patterns/observer-state-reconciliation.md` graduate the corresponding entries from `docs/agent/non-obvious-shapes.md` into full pattern recipes with code snippets and "when this is right / when it isn't" guidance. The programmatic patterns API (`openarmature.patterns.list()` / `get(name)`) grows from 4 to 7 entries. -- **HyperDX OTel integration test path and "Production swap" docs in example 03.** `examples/03-observer-hooks/main.py`'s module docstring grows a "Production swap" section showing how to substitute the demo's `SimpleSpanProcessor` + `ConsoleSpanExporter` for `BatchSpanProcessor` + `OTLPSpanExporter` pointed at HyperDX (or any other OTLP-HTTP collector). A new opt-in integration test (`tests/integration/test_otel_hyperdx_export.py`, gated by `HYPERDX_API_KEY` + `HYPERDX_OTLP_ENDPOINT` env vars and `@pytest.mark.integration`) drives the same production export path end-to-end against a live endpoint. `opentelemetry-exporter-otlp-proto-http` lands as a dev-only dep; not promoted to a public extras group yet. +- **HyperDX OTel integration test path and "Production swap" docs in the observer-hooks example.** `examples/observer-hooks/main.py`'s module docstring grows a "Production swap" section showing how to substitute the demo's `SimpleSpanProcessor` + `ConsoleSpanExporter` for `BatchSpanProcessor` + `OTLPSpanExporter` pointed at HyperDX (or any other OTLP-HTTP collector). A new opt-in integration test (`tests/integration/test_otel_hyperdx_export.py`, gated by `HYPERDX_API_KEY` + `HYPERDX_OTLP_ENDPOINT` env vars and `@pytest.mark.integration`) drives the same production export path end-to-end against a live endpoint. `opentelemetry-exporter-otlp-proto-http` lands as a dev-only dep; not promoted to a public extras group yet. ### Changed (breaking) diff --git a/README.md b/README.md index c4fcf3d5..9f43e211 100644 --- a/README.md +++ b/README.md @@ -164,7 +164,7 @@ async def main() -> None: asyncio.run(main()) ``` -Set `LLM_API_KEY=sk-...` and run. To swap providers, point `LLM_BASE_URL` and `LLM_MODEL` at OpenRouter, vLLM, LM Studio, llama.cpp, or anything else that speaks the OpenAI Chat Completions wire format. The example also lives at [`examples/00-hello-world/main.py`](./examples/00-hello-world/main.py); see [`examples/`](./examples/) for more runnable demos. +Set `LLM_API_KEY=sk-...` and run. To swap providers, point `LLM_BASE_URL` and `LLM_MODEL` at OpenRouter, vLLM, LM Studio, llama.cpp, or anything else that speaks the OpenAI Chat Completions wire format. The example also lives at [`examples/hello-world/main.py`](./examples/hello-world/main.py); see [`examples/`](./examples/) for more runnable demos. A few things to notice: diff --git a/RELEASING.md b/RELEASING.md index 4a336c7b..faaa1e44 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -112,7 +112,7 @@ Minimum smoke set: - [ ] Version string matches the rc tag. - [ ] At least one example runs to completion against a real LLM - endpoint (`examples/00-hello-world/main.py` is the quickest). + endpoint (`examples/hello-world/main.py` is the quickest). - [ ] The optional `[otel]` extra installs cleanly and `import openarmature.observability.otel` succeeds. - [ ] If any docs changed in this release, the live docs site diff --git a/docs/concepts/llms.md b/docs/concepts/llms.md index 4118df9f..b48dfff7 100644 --- a/docs/concepts/llms.md +++ b/docs/concepts/llms.md @@ -334,7 +334,7 @@ Wiring the loop as a graph cycle: a `call_llm` node, a to `call_llm` when `tool_calls` are present and forward to a termination node when they aren't. A turn cap on the routing function prevents runaway loops on a model that stays in tool-calling forever. -See [`09 - Tool use`](../examples/09-tool-use.md) for the runnable +See [`09 - Tool use`](../examples/tool-use.md) for the runnable shape. ### Controlling tool-call behavior with `tool_choice` @@ -591,10 +591,10 @@ classifier won't do this for them. - [API reference: `openarmature.llm`](../reference/llm.md) for the full surface: message types, `Response`, `RuntimeConfig`, every error class, validation helpers. -- [Examples: 00 - Hello, world](../examples/00-hello-world.md) for a +- [Examples: 00 - Hello, world](../examples/hello-world.md) for a runnable graph exercising both `response_schema` forms in one pipeline. -- [Examples: 09 - Tool use](../examples/09-tool-use.md) for the +- [Examples: 09 - Tool use](../examples/tool-use.md) for the agent-loop pattern with two local tools. -- [Examples: 07 - Multimodal prompt](../examples/07-multimodal-prompt.md) +- [Examples: 07 - Multimodal prompt](../examples/multimodal-prompt.md) for content blocks alongside versioned prompts. diff --git a/docs/concepts/observability.md b/docs/concepts/observability.md index bedd6a67..1314027e 100644 --- a/docs/concepts/observability.md +++ b/docs/concepts/observability.md @@ -732,7 +732,7 @@ observer = LangfuseObserver( The adapter bridges `langfuse>=4.6`'s unified `start_observation` API onto our `LangfuseClient` Protocol; the observer code is the same in tests and production. See -[`examples/10-langfuse-observability`](../examples/10-langfuse-observability.md) +[`examples/langfuse-observability`](../examples/langfuse-observability.md) for a runnable demo. !!! note "Langfuse SDK version compatibility" diff --git a/docs/examples/11-chat-with-multimodal.md b/docs/examples/chat-with-multimodal.md similarity index 89% rename from docs/examples/11-chat-with-multimodal.md rename to docs/examples/chat-with-multimodal.md index ae8eb133..489dbf40 100644 --- a/docs/examples/11-chat-with-multimodal.md +++ b/docs/examples/chat-with-multimodal.md @@ -1,4 +1,4 @@ -# 11 - Chat with multi-turn memory and a multimodal turn +# Chat with multi-turn memory and a multimodal turn A lunar-mission Q&A assistant that maintains conversation context across four turns. One mid-conversation turn includes an attached @@ -56,12 +56,12 @@ turn's `render()` injects the grown history into the placeholder. loops back to itself until the script-supplied user turns are exhausted, then routes to `END`. The cycle is [`respond → respond → respond → … → END`](../concepts/graphs.md). -- Complementary to [example 09 (tool use)](09-tool-use.md): chat +- Complementary to [the tool-use example](tool-use.md): chat history threading and tool calling are separate primitives. - Example 09 shows the LLM emitting tool calls and the framework - dispatching them; this example shows how the prompt-management - layer composes a multi-turn conversation. A production chat agent - often combines both. + The tool-use example shows the LLM emitting tool calls and the + framework dispatching them; this example shows how the + prompt-management layer composes a multi-turn conversation. A + production chat agent often combines both. ## How to run @@ -69,10 +69,10 @@ turn's `render()` injects the grown history into the placeholder. uv sync --group examples --all-extras # Clean conversation output only (default). -LLM_API_KEY=sk-... uv run python examples/11-chat-with-multimodal/main.py +LLM_API_KEY=sk-... uv run python examples/chat-with-multimodal/main.py # With OTel JSON spans streaming to stderr alongside the chat. -LLM_API_KEY=sk-... uv run python examples/11-chat-with-multimodal/main.py --traces +LLM_API_KEY=sk-... uv run python examples/chat-with-multimodal/main.py --traces ``` `LLM_MODEL` must point at a vision-capable model. The default @@ -83,14 +83,15 @@ The conversation streams to stdout as each turn completes (a small visual delay between turns lets the human reader follow along). The `--traces` flag opts in to the OTel observer with a console exporter; without it the chat runs without any observer attached. -Example 03 owns the observer-hooks story end-to-end; this example's -headline is the chat shape, not the observability wiring. +The observer-hooks example owns that story end-to-end; this +example's headline is the chat shape, not the observability +wiring. The demo is illustrative only: it runs four pre-scripted user turns sequentially in one process. A real chat-server runtime would manage one invocation per turn with the chat history persisted across sessions (e.g., via a checkpointer keyed on session_id); -that's [example 08 (checkpointing)](08-checkpointing-and-migration.md)'s +that's [the checkpointing-and-migration example](checkpointing-and-migration.md)'s territory, combined with this one's chat shape. ## The graph diff --git a/docs/examples/08-checkpointing-and-migration.md b/docs/examples/checkpointing-and-migration.md similarity index 98% rename from docs/examples/08-checkpointing-and-migration.md rename to docs/examples/checkpointing-and-migration.md index b449d40b..f477f953 100644 --- a/docs/examples/08-checkpointing-and-migration.md +++ b/docs/examples/checkpointing-and-migration.md @@ -1,4 +1,4 @@ -# 08 - Checkpointing and migration +# Checkpointing and migration A lunar-mission planning pipeline that writes a SQLite checkpoint after every step, survives a simulated mid-pipeline crash, and then @@ -90,7 +90,7 @@ migration runs once on load, and execution continues at ```bash uv sync --group examples -LLM_API_KEY=sk-... uv run python examples/08-checkpointing-and-migration/main.py +LLM_API_KEY=sk-... uv run python examples/checkpointing-and-migration/main.py ``` The SQLite database is created in a `TemporaryDirectory` that's diff --git a/docs/examples/02-explicit-subgraph-mapping.md b/docs/examples/explicit-subgraph-mapping.md similarity index 89% rename from docs/examples/02-explicit-subgraph-mapping.md rename to docs/examples/explicit-subgraph-mapping.md index d3addf06..aad6f717 100644 --- a/docs/examples/02-explicit-subgraph-mapping.md +++ b/docs/examples/explicit-subgraph-mapping.md @@ -1,4 +1,4 @@ -# 02 - Explicit subgraph mapping +# Explicit subgraph mapping Compare two topics by running the *same* compiled analysis subgraph on each, with each call site writing into disjoint parent fields. @@ -29,16 +29,16 @@ call would clobber the first. has no idea which side of the comparison it's running for. The mapping at each call site is what wires the subgraph's neutral names to the parent's per-side fields. -- The contrast with example 01: there a custom - `ProjectionStrategy` carried one field in. Here the two sites - need to be similar-but-different, and `ExplicitMapping` is the - zero-boilerplate way to express that. +- The contrast with the routing-and-subgraphs example: there a + custom `ProjectionStrategy` carried one field in. Here the two + sites need to be similar-but-different, and `ExplicitMapping` + is the zero-boilerplate way to express that. ## How to run ```bash uv sync --group examples -LLM_API_KEY=sk-... uv run python examples/02-explicit-subgraph-mapping/main.py "Apollo 11" "Apollo 17" +LLM_API_KEY=sk-... uv run python examples/explicit-subgraph-mapping/main.py "Apollo 11" "Apollo 17" ``` Or pass a single `"X vs Y"` arg, or no args (defaults to diff --git a/docs/examples/05-fan-out-with-retry.md b/docs/examples/fan-out-with-retry.md similarity index 97% rename from docs/examples/05-fan-out-with-retry.md rename to docs/examples/fan-out-with-retry.md index 7fffe0b7..d4b91fd9 100644 --- a/docs/examples/05-fan-out-with-retry.md +++ b/docs/examples/fan-out-with-retry.md @@ -1,4 +1,4 @@ -# 05 - Fan-out with retry +# Fan-out with retry Summarize a batch of lunar-mission headlines in parallel, with per-headline retry and timing middleware wrapping each instance's @@ -72,14 +72,14 @@ for the full contract. ```bash uv sync --group examples -LLM_API_KEY=sk-... uv run python examples/05-fan-out-with-retry/main.py +LLM_API_KEY=sk-... uv run python examples/fan-out-with-retry/main.py ``` To exercise the collect path with a synthetic failure: ```bash COLLECT_MODE=1 LLM_API_KEY=sk-... \ - uv run python examples/05-fan-out-with-retry/main.py + uv run python examples/fan-out-with-retry/main.py ``` ## The graph diff --git a/docs/examples/00-hello-world.md b/docs/examples/hello-world.md similarity index 96% rename from docs/examples/00-hello-world.md rename to docs/examples/hello-world.md index 246bf340..e17dbd60 100644 --- a/docs/examples/00-hello-world.md +++ b/docs/examples/hello-world.md @@ -1,4 +1,4 @@ -# 00 - Hello, world +# Hello, world The smallest possible LLM-routed pipeline: classify a query, then either plan research on it or summarize it in one sentence. Sets the @@ -39,7 +39,7 @@ well-established. ```bash uv sync --group examples -LLM_API_KEY=sk-... uv run python examples/00-hello-world/main.py +LLM_API_KEY=sk-... uv run python examples/hello-world/main.py ``` To point at a local OpenAI-compatible server, override `LLM_BASE_URL` @@ -48,7 +48,7 @@ and (often) `LLM_MODEL`: ```bash LLM_BASE_URL=http://localhost:8000 LLM_MODEL=Qwen2.5-7B-Instruct \ LLM_API_KEY= \ - uv run python examples/00-hello-world/main.py + uv run python examples/hello-world/main.py ``` ## The graph diff --git a/docs/examples/index.md b/docs/examples/index.md index ed0f55f9..3290eb64 100644 --- a/docs/examples/index.md +++ b/docs/examples/index.md @@ -12,51 +12,77 @@ in the repo. ## Catalog -- [**00 - Hello, world**](00-hello-world.md). Classify a query and - route to one of two follow-up nodes. Smallest possible LLM-routed - pipeline; introduces typed state, reducers, conditional edges, and - structured output. -- [**01 - Routing and subgraphs**](01-routing-and-subgraphs.md). - Question-answering assistant that branches into a short-answer node - or a research subgraph, then copy-edits the result. -- [**02 - Explicit subgraph mapping**](02-explicit-subgraph-mapping.md). - Run the same analysis subgraph against two parent fields by mapping - them explicitly at each call site. -- [**03 - Observer hooks**](03-observer-hooks.md). Attach - observability to a `draft → review → finalize` pipeline without - touching any node code, including OpenTelemetry spans. -- [**04 - Nested subgraphs**](04-nested-subgraphs.md). Question - answering against a small baked-in document corpus with two levels - of subgraph nesting. -- [**05 - Fan-out with retry**](05-fan-out-with-retry.md). Summarize a - batch of news headlines in parallel, with retry + timing middleware - wrapping each per-instance subgraph run. -- [**06 - Parallel branches**](06-parallel-branches.md). Enrich an - article with three independent analyses (summary, sentiment, tags) - running concurrently, each with its own state schema. -- [**07 - Multimodal prompt**](07-multimodal-prompt.md). Two analyses - of a lunar-mission photograph using versioned prompt templates, +Grouped by what they teach. New to OpenArmature? Read the +foundations entry first, then jump to whichever section maps to +the problem you're trying to solve. + +### Foundations + +- [**Hello, world**](hello-world.md). Classify a query and route + to one of two follow-up nodes. Smallest possible LLM-routed + pipeline; introduces typed state, reducers, conditional edges, + and structured output. + +### Composition + +- [**Routing and subgraphs**](routing-and-subgraphs.md). Question- + answering assistant that branches into a short-answer node or a + research subgraph, then copy-edits the result. +- [**Explicit subgraph mapping**](explicit-subgraph-mapping.md). + Run the same analysis subgraph against two parent fields by + mapping them explicitly at each call site. +- [**Nested subgraphs**](nested-subgraphs.md). Question answering + against a small baked-in document corpus with two levels of + subgraph nesting. + +### Concurrency + +- [**Fan-out with retry**](fan-out-with-retry.md). Summarize a + batch of news headlines in parallel, with retry + timing + middleware wrapping each per-instance subgraph run. +- [**Parallel branches**](parallel-branches.md). Enrich an + article with three independent analyses (summary, sentiment, + tags) running concurrently, each with its own state schema. + +### Prompts + +- [**Multimodal prompt**](multimodal-prompt.md). Two analyses of + a lunar-mission photograph using versioned prompt templates, multimodal user messages, and a prompt-group trace. -- [**08 - Checkpointing and migration**](08-checkpointing-and-migration.md). - Resume a saved invocation under an upgraded state schema, with a - v1→v2 migration backfilling new fields. -- [**09 - Tool use**](09-tool-use.md). Lunar-mission assistant that - calls local Python tools to answer questions mixing fact recall and - physics arithmetic. -- [**10 - Langfuse observability**](10-langfuse-observability.md). - Send LLM-call observability natively to Langfuse with a prompt- - linkage demonstration on a mission-briefing Q&A pipeline. -- [**11 - Chat with multimodal**](11-chat-with-multimodal.md). Four- - turn lunar-mission conversation with conversation memory threaded - through `ChatPrompt` + `PlaceholderSegment`. One turn attaches a - photograph; the agent processes it without changing the chat +- [**Chat with multimodal**](chat-with-multimodal.md). Four-turn + lunar-mission conversation with conversation memory threaded + through `ChatPrompt` + `PlaceholderSegment`. One turn attaches + a photograph; the agent processes it without changing the chat shape. -- [**12 - Production observability**](12-production-observability.md). + +### Tool use + +- [**Tool use**](tool-use.md). Lunar-mission assistant that calls + local Python tools to answer questions mixing fact recall and + physics arithmetic. + +### Reliability + +- [**Checkpointing and migration**](checkpointing-and-migration.md). + Survive a simulated mid-pipeline crash and resume from the saved + checkpoint, then re-resume under an upgraded state schema with + a v1->v2 migration backfilling new fields. + +### Observability + +- [**Observer hooks**](observer-hooks.md). Attach observability + to a `draft -> review -> finalize` pipeline without touching + any node code, including OpenTelemetry spans. +- [**Langfuse observability**](langfuse-observability.md). Send + LLM-call observability natively to Langfuse with a + prompt-linkage demonstration on a mission-briefing Q&A + pipeline. +- [**Production observability**](production-observability.md). Dual OTel + Langfuse observers attached to one graph, caller - hooks deriving domain-shaped `trace.input` / `trace.output` from - State, built-in `TimingMiddleware` recording per-node duration, - multi-tenant caller-supplied metadata propagating to both - observers in one `invoke()` call. + hooks deriving domain-shaped `trace.input` / `trace.output` + from State, built-in `TimingMiddleware` recording per-node + duration, multi-tenant caller-supplied metadata propagating to + both observers in one `invoke()` call. ## Configuration @@ -91,10 +117,11 @@ server expects. # Install the examples dep group. uv sync --group examples -# Demos 03 and 07 also want the OTel SDK for their OTelObserver. +# The observer-hooks and multimodal-prompt demos also want the +# OTel SDK for their OTelObserver. uv sync --group examples --all-extras # Run any demo. -LLM_API_KEY=sk-... uv run python examples/00-hello-world/main.py -LLM_API_KEY=sk-... uv run python examples/01-routing-and-subgraphs/main.py "what year did the moon landing happen" +LLM_API_KEY=sk-... uv run python examples/hello-world/main.py +LLM_API_KEY=sk-... uv run python examples/routing-and-subgraphs/main.py "what year did the moon landing happen" ``` diff --git a/docs/examples/10-langfuse-observability.md b/docs/examples/langfuse-observability.md similarity index 98% rename from docs/examples/10-langfuse-observability.md rename to docs/examples/langfuse-observability.md index 5899a791..1a5c4ccb 100644 --- a/docs/examples/10-langfuse-observability.md +++ b/docs/examples/langfuse-observability.md @@ -1,4 +1,4 @@ -# 10 - Langfuse observability +# Langfuse observability Send LLM call observability to Langfuse natively: Trace at the top, Span observations for graph nodes, Generation observations with input, @@ -47,7 +47,7 @@ manual wiring at the call site. ```bash uv sync --group examples -LLM_API_KEY=sk-... uv run python examples/10-langfuse-observability/main.py \ +LLM_API_KEY=sk-... uv run python examples/langfuse-observability/main.py \ "what year did Apollo 11 land" ``` diff --git a/docs/examples/07-multimodal-prompt.md b/docs/examples/multimodal-prompt.md similarity index 96% rename from docs/examples/07-multimodal-prompt.md rename to docs/examples/multimodal-prompt.md index 48ccdbd0..fb9cd661 100644 --- a/docs/examples/07-multimodal-prompt.md +++ b/docs/examples/multimodal-prompt.md @@ -1,4 +1,4 @@ -# 07 - Multimodal prompt +# Multimodal prompt Two independent analyses of a lunar-mission photograph using versioned prompt templates, a fallback prompt backend, and a @@ -51,7 +51,7 @@ The image source can be a URL (default) or a local file. Setting ```bash uv sync --group examples --all-extras -LLM_API_KEY=sk-... uv run python examples/07-multimodal-prompt/main.py +LLM_API_KEY=sk-... uv run python examples/multimodal-prompt/main.py ``` (`--all-extras` pulls in `opentelemetry-sdk` for the OTel observer @@ -61,7 +61,7 @@ To use a local file instead of the default URL: ```bash IMAGE_PATH=./photo.jpg LLM_API_KEY=sk-... \ - uv run python examples/07-multimodal-prompt/main.py + uv run python examples/multimodal-prompt/main.py ``` Override `MISSION` to set the prompt variable for a different @@ -69,7 +69,7 @@ mission name: ```bash MISSION="Apollo 17" LLM_API_KEY=sk-... \ - uv run python examples/07-multimodal-prompt/main.py + uv run python examples/multimodal-prompt/main.py ``` The model must be vision-capable. `gpt-4o-mini` (the default) is. diff --git a/docs/examples/04-nested-subgraphs.md b/docs/examples/nested-subgraphs.md similarity index 98% rename from docs/examples/04-nested-subgraphs.md rename to docs/examples/nested-subgraphs.md index c839ab77..8f6641cc 100644 --- a/docs/examples/04-nested-subgraphs.md +++ b/docs/examples/nested-subgraphs.md @@ -1,4 +1,4 @@ -# 04 - Nested subgraphs +# Nested subgraphs Question answering against a tiny baked-in document corpus, with two levels of subgraph nesting: outer coordinator, middle doc-QA, @@ -44,7 +44,7 @@ extracts a span. ```bash uv sync --group examples -LLM_API_KEY=sk-... uv run python examples/04-nested-subgraphs/main.py \ +LLM_API_KEY=sk-... uv run python examples/nested-subgraphs/main.py \ "what happened on Apollo 13?" ``` diff --git a/docs/examples/03-observer-hooks.md b/docs/examples/observer-hooks.md similarity index 98% rename from docs/examples/03-observer-hooks.md rename to docs/examples/observer-hooks.md index ca74567f..e9707bd5 100644 --- a/docs/examples/03-observer-hooks.md +++ b/docs/examples/observer-hooks.md @@ -1,4 +1,4 @@ -# 03 - Observer hooks +# Observer hooks Add observability to a `draft → review → finalize` pipeline without touching any node code. Three observer flavors run side-by-side: a @@ -48,7 +48,7 @@ bodies knows or cares that they're attached. ```bash uv sync --group examples --all-extras -LLM_API_KEY=sk-... uv run python examples/03-observer-hooks/main.py \ +LLM_API_KEY=sk-... uv run python examples/observer-hooks/main.py \ "what year did the moon landing happen" ``` diff --git a/docs/examples/06-parallel-branches.md b/docs/examples/parallel-branches.md similarity index 94% rename from docs/examples/06-parallel-branches.md rename to docs/examples/parallel-branches.md index 41500124..618dd5ca 100644 --- a/docs/examples/06-parallel-branches.md +++ b/docs/examples/parallel-branches.md @@ -1,4 +1,4 @@ -# 06 - Parallel branches +# Parallel branches Enrich a lunar-mission news article with three independent analyses (one-sentence summary, sentiment label, topic tags) @@ -6,9 +6,9 @@ running concurrently as separate subgraphs. ## Overview -Where fan-out (example 05) runs N copies of *one* subgraph against -different inputs, parallel-branches runs M *heterogeneous* -subgraphs against the same input. Different state schemas, +Where fan-out (the fan-out-with-retry example) runs N copies of +*one* subgraph against different inputs, parallel-branches runs +M *heterogeneous* subgraphs against the same input. Different state schemas, different middleware, different topologies per branch, one dispatch. @@ -56,7 +56,7 @@ and the parent fans in once all three complete. ```bash uv sync --group examples -LLM_API_KEY=sk-... uv run python examples/06-parallel-branches/main.py +LLM_API_KEY=sk-... uv run python examples/parallel-branches/main.py ``` The article is baked into the example. diff --git a/docs/examples/12-production-observability.md b/docs/examples/production-observability.md similarity index 95% rename from docs/examples/12-production-observability.md rename to docs/examples/production-observability.md index b77dc83a..cc504f33 100644 --- a/docs/examples/12-production-observability.md +++ b/docs/examples/production-observability.md @@ -1,4 +1,4 @@ -# 12 - Production observability with dual observers and timing middleware +# Production observability with dual observers and timing middleware A single-turn lunar-mission Q&A endpoint instrumented the way you'd ship it: BOTH OTel and Langfuse observers attached to the same @@ -79,7 +79,7 @@ sees the same logical events represented two ways. ```bash uv sync --group examples --all-extras -LLM_API_KEY=sk-... uv run python examples/12-production-observability/main.py +LLM_API_KEY=sk-... uv run python examples/production-observability/main.py ``` `LLM_MODEL` defaults to `gpt-4o-mini`. The pipeline is single-turn @@ -184,7 +184,7 @@ langfuse_observer = LangfuseObserver( ``` Same observer call surface, real exporters underneath. Node and -graph code don't change. Example 03 shows the OTel-only side at -finer granularity (`force_flush`, log bridging, error handling); -example 10 shows the Langfuse + `LangfusePromptBackend` prompt-linkage -side. +graph code don't change. The observer-hooks example shows the +OTel-only side at finer granularity (`force_flush`, log bridging, +error handling); the langfuse-observability example shows the +Langfuse + `LangfusePromptBackend` prompt-linkage side. diff --git a/docs/examples/01-routing-and-subgraphs.md b/docs/examples/routing-and-subgraphs.md similarity index 97% rename from docs/examples/01-routing-and-subgraphs.md rename to docs/examples/routing-and-subgraphs.md index b3064bdd..2721b3e8 100644 --- a/docs/examples/01-routing-and-subgraphs.md +++ b/docs/examples/routing-and-subgraphs.md @@ -1,4 +1,4 @@ -# 01 - Routing and subgraphs +# Routing and subgraphs A question-answering assistant. Classify the question, then either give a one-shot quick answer or run a multi-step research @@ -40,7 +40,7 @@ strategically important?"* (usually routes to research). ```bash uv sync --group examples -LLM_API_KEY=sk-... uv run python examples/01-routing-and-subgraphs/main.py \ +LLM_API_KEY=sk-... uv run python examples/routing-and-subgraphs/main.py \ "why is the lunar south pole strategically important?" ``` diff --git a/docs/examples/09-tool-use.md b/docs/examples/tool-use.md similarity index 97% rename from docs/examples/09-tool-use.md rename to docs/examples/tool-use.md index e3c5f9bc..c507f516 100644 --- a/docs/examples/09-tool-use.md +++ b/docs/examples/tool-use.md @@ -1,4 +1,4 @@ -# 09 - Tool use +# Tool use A lunar-mission assistant that calls local Python tools to answer questions mixing fact recall and physics arithmetic about Apollo @@ -54,13 +54,13 @@ A `MAX_TURNS` cap (5 here) prevents runaway loops. ```bash uv sync --group examples -LLM_API_KEY=sk-... uv run python examples/09-tool-use/main.py +LLM_API_KEY=sk-... uv run python examples/tool-use/main.py ``` Pass a question on the command line: ```bash -LLM_API_KEY=sk-... uv run python examples/09-tool-use/main.py \ +LLM_API_KEY=sk-... uv run python examples/tool-use/main.py \ "When was Apollo 17 launched?" ``` diff --git a/docs/patterns/caller-supplied-trace-identifiers.md b/docs/patterns/caller-supplied-trace-identifiers.md index 4b440e1f..bf21a120 100644 --- a/docs/patterns/caller-supplied-trace-identifiers.md +++ b/docs/patterns/caller-supplied-trace-identifiers.md @@ -132,7 +132,7 @@ spec-reserved key in flat Langfuse `trace.metadata`. - [Observability concept page](../concepts/observability.md): how OTel attributes and Langfuse metadata propagate. -- [`examples/10-langfuse-observability`](../examples/10-langfuse-observability.md): +- [`examples/langfuse-observability`](../examples/langfuse-observability.md): runnable example exercising the metadata propagation path. - Spec: [observability](https://openarmature.org/capabilities/observability/), the propagation contract for caller-supplied metadata. diff --git a/docs/patterns/session-as-checkpoint-resume.md b/docs/patterns/session-as-checkpoint-resume.md index 13705347..74b36f9d 100644 --- a/docs/patterns/session-as-checkpoint-resume.md +++ b/docs/patterns/session-as-checkpoint-resume.md @@ -109,6 +109,6 @@ state and the session table holds the join keys. `resume_invocation`, schema migration. - [State and reducers](../concepts/state-and-reducers.md): `merge` and `append` reducer strategies. -- [`examples/08-checkpointing-and-migration`](../examples/08-checkpointing-and-migration.md): +- [`examples/checkpointing-and-migration`](../examples/checkpointing-and-migration.md): single-resume baseline. - Spec: [pipeline-utilities](https://openarmature.org/capabilities/pipeline-utilities/) diff --git a/docs/patterns/tool-dispatch-as-node.md b/docs/patterns/tool-dispatch-as-node.md index a852174d..5b12f080 100644 --- a/docs/patterns/tool-dispatch-as-node.md +++ b/docs/patterns/tool-dispatch-as-node.md @@ -91,7 +91,7 @@ The `MAX_TURNS` cap prevents runaway loops; the conditional edge short-circuits to `present` when the cap is hit or when the model returns no `tool_calls`. -See [`examples/09-tool-use`](../examples/09-tool-use.md) for a +See [`examples/tool-use`](../examples/tool-use.md) for a runnable version with full tool definitions, defensive handling for malformed `ToolCall.arguments`, and trace output. @@ -125,6 +125,6 @@ for malformed `ToolCall.arguments`, and trace output. contract. - [State and reducers](../concepts/state-and-reducers.md): `append` reducer semantics. -- [`examples/09-tool-use`](../examples/09-tool-use.md): runnable +- [`examples/tool-use`](../examples/tool-use.md): runnable reference implementation. - Spec: [llm-provider](https://openarmature.org/capabilities/llm-provider/) diff --git a/examples/README.md b/examples/README.md index 5017f043..7d138ba1 100644 --- a/examples/README.md +++ b/examples/README.md @@ -6,52 +6,52 @@ End-to-end demo projects for `openarmature`. Each is a standalone ## Demos -### [`00-hello-world/`](./00-hello-world/main.py) +Grouped by what they teach. + +### Foundations + +#### [`hello-world/`](./hello-world/main.py) Classify a query with an LLM and route to one of two follow-up nodes. Demonstrates: typed `State` with three reducer policies, the `OpenAIProvider` from `openarmature.llm`, structured output via a -Pydantic class (`response_schema=Classification` → `Response.parsed` +Pydantic class (`response_schema=Classification` -> `Response.parsed` as a `Classification` instance), conditional routing on a parsed field, and a compile-time observer. -### [`01-routing-and-subgraphs/`](./01-routing-and-subgraphs/main.py) +### Composition + +#### [`routing-and-subgraphs/`](./routing-and-subgraphs/main.py) -Question-answering assistant — classify, then short-answer or +Question-answering assistant: classify, then short-answer or research-subgraph, then copy-edit. Demonstrates: conditional edges, `SubgraphNode`, custom `ProjectionStrategy`, the `merge` reducer. -### [`02-explicit-subgraph-mapping/`](./02-explicit-subgraph-mapping/main.py) +#### [`explicit-subgraph-mapping/`](./explicit-subgraph-mapping/main.py) Compare two topics by running the same analysis subgraph on each. Demonstrates: `ExplicitMapping` for reusing one compiled subgraph at multiple parent sites with disjoint parent fields. -### [`03-observer-hooks/`](./03-observer-hooks/main.py) - -Add observability to a `draft → review → finalize` pipeline without -changing any node code. Demonstrates: `attach_observer`, `NodeEvent`, -namespace chaining across subgraph boundaries, function-shaped and -class-shaped observers, plus the `OTelObserver` running alongside -the plain observer (same hook, different backend). - -### [`04-nested-subgraphs/`](./04-nested-subgraphs/main.py) +#### [`nested-subgraphs/`](./nested-subgraphs/main.py) Question answering against a tiny baked-in document corpus, with two -levels of subgraph nesting: outer coordinator → doc-QA subgraph → +levels of subgraph nesting: outer coordinator -> doc-QA subgraph -> section-extract subgraph. A depth-aware observer prints the descent and return. -### [`05-fan-out-with-retry/`](./05-fan-out-with-retry/main.py) +### Concurrency + +#### [`fan-out-with-retry/`](./fan-out-with-retry/main.py) Summarize a batch of news headlines in parallel. Each per-headline -run goes through a `summarize → classify` subgraph wrapped in retry -middleware (transient failures don't tank the batch) and timing -middleware (per-instance duration captured). Demonstrates: +run goes through a `summarize -> classify` subgraph wrapped in +retry middleware (transient failures don't tank the batch) and +timing middleware (per-instance duration captured). Demonstrates: `add_fan_out_node` with `items_field` mode, `extra_outputs` collecting a parallel list, `instance_middleware`, concurrency cap. -### [`06-parallel-branches/`](./06-parallel-branches/main.py) +#### [`parallel-branches/`](./parallel-branches/main.py) Enrich an article with three independent analyses (summary, sentiment, topic tags) running concurrently. Each analysis is a @@ -61,33 +61,36 @@ Demonstrates: `add_parallel_branches_node`, `BranchSpec` per branch with input/output projection, heterogeneous branch state schemas, per-branch middleware. -### [`07-multimodal-prompt/`](./07-multimodal-prompt/main.py) +### Prompts + +#### [`multimodal-prompt/`](./multimodal-prompt/main.py) -Two independent analyses of a lunar-mission photograph — describe -the surface, describe the equipment — using versioned prompt +Two independent analyses of a lunar-mission photograph (describe +the surface, describe the equipment) using versioned prompt templates and a multimodal user message. Templates load from `FilesystemPromptBackend` with a primary + fallback chain; both -renders are grouped under one observability `PromptGroup` so a trace -UI can render them as one logical unit. Image source switches -between `ImageSourceURL` and `ImageSourceInline(base64_data=...)` -via env var. Demonstrates: `PromptManager` with composite backends, -prompt fetch + render with template variables, `PromptGroup` + +renders are grouped under one observability `PromptGroup` so a +trace UI can render them as one logical unit. Image source +switches between `ImageSourceURL` and +`ImageSourceInline(base64_data=...)` via env var. Demonstrates: +`PromptManager` with composite backends, prompt fetch + render +with template variables, `PromptGroup` + `with_active_prompt_group`, `with_active_prompt` nesting, -multimodal `UserMessage` carrying both text and image content blocks. - -### [`08-checkpointing-and-migration/`](./08-checkpointing-and-migration/main.py) - -A lunar-mission planning pipeline that checkpoints after every step, -then resumes the saved record under an upgraded state schema. Phase -one invokes a v1 graph against `MissionPlanStateV1`; the -`SQLiteCheckpointer` (JSON mode) writes records to a temp DB. Phase -two registers a v1→v2 migration backfilling a new `risk_assessment` -field, builds a v2 graph with one new node, and resumes from the v1 -invocation. Demonstrates: `SQLiteCheckpointer(serialization="json")`, -`with_checkpointer`, save-on-completed-event, `State.schema_version`, -`with_state_migration`, `invoke(resume_invocation=...)`. +multimodal `UserMessage` carrying both text and image content +blocks. + +#### [`chat-with-multimodal/`](./chat-with-multimodal/main.py) + +Four-turn lunar-mission conversation with conversation memory +threaded through `ChatPrompt` + `PlaceholderSegment`. One turn +attaches a photograph; the agent processes it without changing +the chat shape. Demonstrates: `ChatPrompt`, `ContentSegment`, +`PlaceholderSegment`, history threading via the `append` reducer +on `list[Message]`, conditional self-loop for multi-turn cycles. -### [`09-tool-use/`](./09-tool-use/main.py) +### Tool use + +#### [`tool-use/`](./tool-use/main.py) A lunar-mission assistant that calls local Python tools to answer questions mixing fact recall and physics arithmetic. Defines two @@ -96,11 +99,61 @@ tools (`lookup_mission` reading a baked-in record store, model via `complete(tools=...)`, dispatches `assistant.tool_calls` to the local functions, and feeds the results back as `ToolMessage` entries. The agent loop is a graph cycle: -`call_llm → dispatch_tools → call_llm` via a conditional edge, with -a hard turn cap to prevent runaway loops. Demonstrates: `Tool` -definitions with JSON Schema parameters, `complete(tools=...)`, -parsing `ToolCall` records, `ToolMessage(tool_call_id=...)` round- -trip, multi-turn tool-calling loop as a graph cycle. +`call_llm -> dispatch_tools -> call_llm` via a conditional edge, +with a hard turn cap to prevent runaway loops. Demonstrates: +`Tool` definitions with JSON Schema parameters, +`complete(tools=...)`, parsing `ToolCall` records, +`ToolMessage(tool_call_id=...)` round-trip, multi-turn +tool-calling loop as a graph cycle. + +### Reliability + +#### [`checkpointing-and-migration/`](./checkpointing-and-migration/main.py) + +A lunar-mission planning pipeline that survives a simulated +mid-pipeline crash, then resumes the saved record under an +upgraded state schema. Phase one invokes a v1 graph against +`MissionPlanStateV1`; the `SQLiteCheckpointer` (JSON mode) writes +records to a temp DB synchronously after every node completes. +`size_crew` raises on its first call to simulate a transient +infrastructure failure; a second invoke with `resume_invocation=` +picks up cleanly. Phase two registers a v1->v2 migration +backfilling a new `risk_assessment` field, builds a v2 graph with +one new node, and resumes from the (now-completed) v1 invocation. +Demonstrates: `SQLiteCheckpointer(serialization="json")`, +`with_checkpointer`, save-on-completed-event, `NodeException` at +the invoke boundary, `State.schema_version`, +`with_state_migration`, `invoke(resume_invocation=...)`. + +### Observability + +#### [`observer-hooks/`](./observer-hooks/main.py) + +Add observability to a `draft -> review -> finalize` pipeline +without changing any node code. Demonstrates: `attach_observer`, +`NodeEvent`, namespace chaining across subgraph boundaries, +function-shaped and class-shaped observers, plus the +`OTelObserver` running alongside the plain observer (same hook, +different backend). + +#### [`langfuse-observability/`](./langfuse-observability/main.py) + +Send LLM-call observability natively to Langfuse with a +prompt-linkage demonstration on a mission-briefing Q&A pipeline. +Demonstrates: `LangfuseObserver` attached at the graph level, +`LangfusePromptBackend` for prompt fetch, automatic +Generation -> Prompt link via `observability_entities`. + +#### [`production-observability/`](./production-observability/main.py) + +Dual OTel + Langfuse observers attached to one graph, caller hooks +deriving domain-shaped `trace.input` / `trace.output` from State, +built-in `TimingMiddleware` recording per-node duration via an +`on_complete` callback, multi-tenant caller-supplied metadata +propagating to both observers in one `invoke()` call. The +production-grade observability shape, end-to-end, with in-memory +captures so the demo prints what each backend would have ingested +without needing real production credentials. ## Configuration @@ -109,7 +162,7 @@ defaults shown: | Env var | Default | Notes | | --- | --- | --- | -| `LLM_BASE_URL` | `https://api.openai.com` | **Host root only** — the provider adds the path. | +| `LLM_BASE_URL` | `https://api.openai.com` | **Host root only**; the provider adds the path. | | `LLM_MODEL` | `gpt-4o-mini` | Any model the bound endpoint exposes. | | `LLM_API_KEY` | (none) | Required; pass empty for local servers that don't authenticate. | @@ -130,12 +183,14 @@ example code: # From the repo root, install the examples dep group: uv sync --group examples -# Demo 03 also wants the OTel SDK for its OTelObserver: +# The observer-hooks, multimodal-prompt, chat-with-multimodal, +# and production-observability demos also want the OTel SDK for +# their OTelObserver: uv sync --group examples --all-extras # Run any demo: -LLM_API_KEY=sk-... uv run python examples/00-hello-world/main.py -LLM_API_KEY=sk-... uv run python examples/01-routing-and-subgraphs/main.py "what year did the moon landing happen" +LLM_API_KEY=sk-... uv run python examples/hello-world/main.py +LLM_API_KEY=sk-... uv run python examples/routing-and-subgraphs/main.py "what year did the moon landing happen" ``` For a local OpenAI-compatible server (vLLM, LM Studio, llama.cpp, diff --git a/examples/11-chat-with-multimodal/main.py b/examples/chat-with-multimodal/main.py similarity index 97% rename from examples/11-chat-with-multimodal/main.py rename to examples/chat-with-multimodal/main.py index 8218ea54..e49d18aa 100644 --- a/examples/11-chat-with-multimodal/main.py +++ b/examples/chat-with-multimodal/main.py @@ -15,7 +15,7 @@ into render() on each turn. The same chat template carries an optional ImageURLBlockTemplate when the user's current turn includes an image (lunar mission photograph), so multimodal turns work -without bespoke handling. Complementary to example 09 (tool calling) +without bespoke handling. Complementary to the tool-use example, which exercises a different LLM-side primitive entirely. **What's interesting in the implementation:** @@ -40,9 +40,10 @@ current chat template, calls the LLM, and updates state. - `LangfusePromptBackend` is intentionally not used here: chat history threading is the headline demonstration, not prompt - backend complexity. Example 07 owns the multi-backend prompt - story (filesystem primary + fallback); example 10 owns the - Langfuse-backend integration. + backend complexity. The multimodal-prompt example owns the + multi-backend prompt story (filesystem primary + fallback); + the langfuse-observability example owns the Langfuse-backend + integration. - Error handling at the invoke() boundary. `main()` catches `NodeException` (the graph engine's wrapper) and inspects `exc.__cause__` (Python's standard exception chain) for @@ -75,24 +76,25 @@ uv sync --group examples --all-extras # Clean conversation output only (default). - LLM_API_KEY=sk-... uv run python examples/11-chat-with-multimodal/main.py + LLM_API_KEY=sk-... uv run python examples/chat-with-multimodal/main.py # With OTel JSON spans streaming to stderr alongside the chat. - LLM_API_KEY=sk-... uv run python examples/11-chat-with-multimodal/main.py --traces + LLM_API_KEY=sk-... uv run python examples/chat-with-multimodal/main.py --traces (``--all-extras`` pulls in ``opentelemetry-sdk`` for the OTel observer.) The conversation transcript streams to stdout as each turn closes, with a short visual delay between turns (~``_TURN_DELAY_S``). Pass ``--traces`` to also see the OTel observer attached and node + LLM spans dumped to stderr; the OTel side is optional supporting -infrastructure, not the headline of this example (example 03 owns -the observer-hooks story). +infrastructure, not the headline of this example (the +observer-hooks example owns that story). The demo is illustrative only: it runs four pre-scripted user turns sequentially in one process. A real chat-server runtime would manage one invocation per turn with the chat history persisted across sessions (e.g., via a checkpointer keyed on session_id); -that's example 08's territory, combined with this one's chat shape. +that's the checkpointing-and-migration example's territory, +combined with this one's chat shape. """ from __future__ import annotations diff --git a/examples/08-checkpointing-and-migration/main.py b/examples/checkpointing-and-migration/main.py similarity index 99% rename from examples/08-checkpointing-and-migration/main.py rename to examples/checkpointing-and-migration/main.py index ed405d7d..fe70f131 100644 --- a/examples/08-checkpointing-and-migration/main.py +++ b/examples/checkpointing-and-migration/main.py @@ -60,7 +60,7 @@ Run with: uv sync --group examples - cd examples/08-checkpointing-and-migration + cd examples/checkpointing-and-migration LLM_API_KEY=sk-... uv run python main.py """ diff --git a/examples/02-explicit-subgraph-mapping/main.py b/examples/explicit-subgraph-mapping/main.py similarity index 93% rename from examples/02-explicit-subgraph-mapping/main.py rename to examples/explicit-subgraph-mapping/main.py index 6e8b3a4f..a06c7a7d 100644 --- a/examples/02-explicit-subgraph-mapping/main.py +++ b/examples/explicit-subgraph-mapping/main.py @@ -6,12 +6,12 @@ then synthesizing a verdict. **Demonstrates:** One compiled subgraph reused at two parent sites with -per-site `ExplicitMapping` — the canonical way to express "run the same +per-site `ExplicitMapping`; the canonical way to express "run the same subgraph twice on disjoint parent fields" without writing per-site projection classes that mirror each other. Without explicit input/output mapping, both sites would have to read from -and write to the same parent fields under name matching — making "run the +and write to the same parent fields under name matching; making "run the same subgraph twice on different inputs" structurally impossible. The two analyze_a/analyze_b sites here share the SAME compiled subgraph value but project different parent fields in and different parent fields out. @@ -27,7 +27,7 @@ Run with: uv sync --group examples - cd examples/02-explicit-subgraph-mapping + cd examples/explicit-subgraph-mapping LLM_API_KEY=sk-... uv run python main.py "Apollo 11" "Apollo 17" LLM_API_KEY=sk-... uv run python main.py "Apollo program vs Artemis program" """ @@ -70,11 +70,11 @@ def _get_provider() -> OpenAIProvider: # ---------------------------------------------------------------------------- # State schemas: parent and subgraph # ---------------------------------------------------------------------------- -# The parent compares two topics — call them A and B — and needs to capture a +# The parent compares two topics; call them A and B; and needs to capture a # summary and a score for EACH topic. So the parent schema declares paired # fields: a_summary/a_score and b_summary/b_score. # -# The subgraph speaks in a single set of names — `topic`, `summary`, `score` — +# The subgraph speaks in a single set of names; `topic`, `summary`, `score` # because it has no idea which side of the comparison it's running for. The # mapping at each call site is what wires the subgraph's neutral names to the # parent's per-side fields. @@ -82,7 +82,7 @@ def _get_provider() -> OpenAIProvider: # This separation is the whole point. If the parent and subgraph shared field # names (`summary`, `score`) and we relied on default field-name # matching, the two subgraph calls would BOTH write to a single -# `parent.summary` field — the second call would clobber the first, and the +# `parent.summary` field; the second call would clobber the first, and the # comparator at the end would have no way to see both. With explicit mapping # the two sites address disjoint parent fields and can't collide. @@ -124,7 +124,7 @@ async def _chat(system: str, user: str) -> str: # ---------------------------------------------------------------------------- # Subgraph nodes # ---------------------------------------------------------------------------- -# Two nodes — `summarize` then `score` — running against the subgraph's own +# Two nodes; `summarize` then `score`; running against the subgraph's own # state. They're written entirely against `AnalysisState`; they don't know # (and can't know) that the parent calls them twice with different mappings. # That's the encapsulation a subgraph buys. @@ -207,14 +207,14 @@ async def synthesize(s: ComparisonState) -> Mapping[str, Any]: # sites would write `parent.summary` and clobber each other. # # - A custom `ProjectionStrategy` (the 01-routing-and-subgraphs approach) -# would have to differ per call site — you'd write two distinct projection +# would have to differ per call site; you'd write two distinct projection # classes that do the same thing in mirror image. That's exactly the # boilerplate `ExplicitMapping` removes. # # - The subgraph can't rename its own fields to avoid the clash: its schema # is fixed at compile time and shared across both call sites. Wrong layer. # -# Note also that `trace` is included in both `outputs` mappings — so each +# Note also that `trace` is included in both `outputs` mappings; so each # subgraph run's per-node trace is appended to the parent's `trace` field via # the parent's `append` reducer. The final `trace` will show the subgraph # nodes running TWICE (once per analyze site), interleaved with the outer diff --git a/examples/05-fan-out-with-retry/main.py b/examples/fan-out-with-retry/main.py similarity index 96% rename from examples/05-fan-out-with-retry/main.py rename to examples/fan-out-with-retry/main.py index ffe65638..ad5ac608 100644 --- a/examples/05-fan-out-with-retry/main.py +++ b/examples/fan-out-with-retry/main.py @@ -12,7 +12,7 @@ This is the canonical fan-out shape: N similar tasks, N runtime-determined from state, the work independent enough to run concurrently. The per-instance subgraph (summarize → classify) is a complete pipeline in -its own right — it would also work standalone against a single headline. +its own right; it would also work standalone against a single headline. **What's interesting in the implementation:** @@ -27,7 +27,7 @@ per-instance: a failure on headline 3 doesn't restart headlines 0-2. - ``concurrency=3`` caps how many instances run in flight at once. Use this to be polite to the upstream API. -- ``error_policy`` defaults to ``"fail_fast"`` — the first instance +- ``error_policy`` defaults to ``"fail_fast"``; the first instance failure (after retries exhaust) raises and cancels siblings. Set the ``COLLECT_MODE`` env var to switch to ``"collect"``: each instance runs independently and per-instance failures land in @@ -40,7 +40,7 @@ the sentinel, ``COLLECT_MODE`` would have nothing to capture. - A ``TimingRecord`` is captured per instance via an ``on_complete`` callback. ``TimingRecord`` carries the per-call duration but not the - ``fan_out_index`` — that index lives on observer NodeEvents instead. + ``fan_out_index``; that index lives on observer NodeEvents instead. The demo prints captured durations in completion order plus a wall-clock vs sum-of-durations comparison that shows concurrency actually parallelized the work. @@ -60,7 +60,7 @@ Run with: uv sync --group examples - cd examples/05-fan-out-with-retry + cd examples/fan-out-with-retry LLM_API_KEY=sk-... uv run python main.py """ @@ -134,7 +134,7 @@ async def _chat(system: str, user: str) -> str: class BatchState(State): """Outer graph: list of headlines goes in, parallel lists of summaries and topic tags come out. ``instance_errors`` only populates under - ``error_policy="collect"`` — each failed instance contributes one + ``error_policy="collect"``; each failed instance contributes one record naming its ``fan_out_index`` and the exception category.""" headlines: list[str] = Field(default_factory=list) @@ -145,7 +145,7 @@ class BatchState(State): class HeadlineState(State): - """Per-instance subgraph state — one headline, its summary, its topic.""" + """Per-instance subgraph state; one headline, its summary, its topic.""" headline: str = "" summary: str = "" @@ -213,11 +213,11 @@ def build_headline_subgraph() -> CompiledGraph[HeadlineState]: # # Timing's on_complete callback fires once per successful (or final-failure) # instance. ``TimingRecord`` carries duration + outcome but not -# ``fan_out_index`` — the index lives on observer NodeEvents, not in the +# ``fan_out_index``; the index lives on observer NodeEvents, not in the # middleware's record. The demo prints the captured timings in completion # order to show "this is what middleware-level timing gives you out of the # box." For per-instance correlation against the input list, use an -# observer instead (see example 03). +# observer instead (see the observer-hooks example). # Captured timings, populated by the on_complete callback below. @@ -305,7 +305,7 @@ async def fan_out_config_observer(event: ObserverEvent) -> None: started / completed pair (the dispatch wrapper); inner-instance events carry ``fan_out_index`` but not ``fan_out_config``. Reading the config gives observability layers a record of how the dispatch - actually resolved at runtime — useful when ``count`` or + actually resolved at runtime; useful when ``count`` or ``concurrency`` are callable resolvers whose value isn't visible in code. """ diff --git a/examples/00-hello-world/main.py b/examples/hello-world/main.py similarity index 97% rename from examples/00-hello-world/main.py rename to examples/hello-world/main.py index c34d2330..6629da7a 100644 --- a/examples/00-hello-world/main.py +++ b/examples/hello-world/main.py @@ -12,7 +12,7 @@ - Pydantic class (``Classification``, ``Summary``): typed instance on ``Response.parsed``. - JSON Schema dict (``research``): raw dict on ``Response.parsed``. -- ``RuntimeConfig`` for per-call sampling knobs — every ``complete()`` +- ``RuntimeConfig`` for per-call sampling knobs; every ``complete()`` here passes ``config=RuntimeConfig(temperature=0.0)`` to reduce sampling variance across runs. Temperature 0 isn't a strict determinism guarantee (providers vary at the infra level) but it's @@ -33,7 +33,7 @@ Run with: uv sync --group examples - LLM_API_KEY=sk-... uv run python examples/00-hello-world/main.py + LLM_API_KEY=sk-... uv run python examples/hello-world/main.py """ from __future__ import annotations @@ -91,7 +91,7 @@ class PipelineState(State): _provider_instance: OpenAIProvider | None = None # Per-call sampling knobs. The demo sets temperature 0 to reduce -# variance across invocations — the run is "as reproducible as the +# variance across invocations; the run is "as reproducible as the # API allows" but not strictly deterministic (providers vary at the # infra level even at temp 0). Useful for tutorial output; production # usually wants some sampling variety. diff --git a/examples/10-langfuse-observability/main.py b/examples/langfuse-observability/main.py similarity index 96% rename from examples/10-langfuse-observability/main.py rename to examples/langfuse-observability/main.py index 12bf6561..6738b3e5 100644 --- a/examples/10-langfuse-observability/main.py +++ b/examples/langfuse-observability/main.py @@ -4,7 +4,7 @@ and Artemis missions. The pipeline fetches a versioned prompt template, renders it with the user's question, sends it to the model, and stores the response. The team running this in production wants to validate the -prompt is doing what it should — see exactly what messages went out, +prompt is doing what it should; see exactly what messages went out, what the model returned, what the token usage was, and (critically) which prompt version produced which response so they can A/B test prompt revisions safely. @@ -19,7 +19,7 @@ manually. The example uses the bundled ``InMemoryLangfuseClient`` recorder so the -demo runs without a Langfuse account — at the end we print the captured +demo runs without a Langfuse account; at the end we print the captured Trace + Observation tree. Swapping to a real ``langfuse.Langfuse()`` client is a one-line constructor change via ``LangfuseSDKAdapter`` (see the comment near the observer build below). The adapter bridges the @@ -39,7 +39,7 @@ Run with: uv sync --group examples - cd examples/10-langfuse-observability + cd examples/langfuse-observability LLM_API_KEY=sk-... uv run python main.py "what year did Apollo 11 land" LLM_API_KEY=sk-... uv run python main.py "compare the Artemis II crew to Apollo 8's" """ @@ -244,8 +244,8 @@ async def main() -> None: question = " ".join(sys.argv[1:]) or "what year did Apollo 11 land" # The bundled in-memory client captures everything the observer - # would have sent to Langfuse — Trace, Observations, Generation - # fields — without needing a Langfuse account. For production: + # would have sent to Langfuse; Trace, Observations, Generation + # fields; without needing a Langfuse account. For production: # # from langfuse import Langfuse # from openarmature.observability.langfuse import LangfuseSDKAdapter @@ -264,7 +264,7 @@ async def main() -> None: # disable_llm_payload=False opts in to capturing the input messages # and output content on Generation observations. Default is True - # for the same privacy reason the OTel observer's flag exists — + # for the same privacy reason the OTel observer's flag exists # payloads may contain PII the operator hasn't audited. Flip it # deliberately here because the demo's whole point is showing what # the model saw and returned. @@ -291,7 +291,7 @@ async def main() -> None: print(f"prompt: mission-briefing {final.prompt_version}") print() print("─── captured Langfuse trace ─────────────────────────────────") - # Exactly one Trace per invocation — the LangfuseObserver opens it + # Exactly one Trace per invocation; the LangfuseObserver opens it # on the first node event and the trace id equals the framework- # minted invocation_id so cross-system lookups land directly. assert len(client.traces) == 1, f"expected 1 trace, got {len(client.traces)}" diff --git a/examples/07-multimodal-prompt/main.py b/examples/multimodal-prompt/main.py similarity index 96% rename from examples/07-multimodal-prompt/main.py rename to examples/multimodal-prompt/main.py index d023f343..e11fc8f5 100644 --- a/examples/07-multimodal-prompt/main.py +++ b/examples/multimodal-prompt/main.py @@ -33,17 +33,17 @@ - ``PromptGroup(group_name=..., members=[result_a, result_b])`` wraps two ``PromptResult`` instances under one observability identifier. Because the prompts are INDEPENDENT analyses of the same input, - both can be rendered upfront with real variables — no placeholder + both can be rendered upfront with real variables; no placeholder renders, no asymmetric "first call computes the second's input" shape. - ``with_active_prompt_group(group)`` propagates the group name via ContextVar; the attached ``OTelObserver`` stamps ``openarmature.prompt.group_name`` onto every LLM-call span fired - inside the block. Confirm in the console output — the two + inside the block. Confirm in the console output; the two ``openarmature.llm.complete`` spans both carry ``openarmature.prompt.group_name = "lunar-image-analysis"``. - ``with_active_prompt(result)`` (inside the group's scope) propagates - the per-call prompt identifiers — name, version, label, + the per-call prompt identifiers; name, version, label, template_hash, rendered_hash. The two layers compose: each LLM span carries the group identifier AND the per-call prompt identifiers. The console output makes both visible. @@ -66,7 +66,7 @@ Run with: uv sync --group examples --all-extras - cd examples/07-multimodal-prompt + cd examples/multimodal-prompt LLM_API_KEY=sk-... uv run python main.py LLM_API_KEY=sk-... IMAGE_PATH=./my-photo.jpg uv run python main.py @@ -140,9 +140,9 @@ def _get_provider() -> OpenAIProvider: # construct, holds no per-call state, and is safe to share across nodes. # # Two backends are wired here: -# - primary: ``prompts/`` — ships describe-surface and +# - primary: ``prompts/``; ships describe-surface and # describe-equipment. -# - fallback: ``prompts_fallback/`` — ships shorter variants of +# - fallback: ``prompts_fallback/``; ships shorter variants of # both prompts so the safety net covers the whole pipeline. The # fallback path fires when the primary raises # ``PromptStoreUnavailable`` (e.g., a remote primary like @@ -234,7 +234,7 @@ def _extract_rendered_text(rendered: PromptResult) -> str: async def describe_surface(s: AnalysisState) -> Mapping[str, Any]: # Each node fetches + renders its own prompt. Both prompts take # only the ``mission`` variable, so neither depends on the other's - # output — the two analyses are independent. + # output; the two analyses are independent. rendered = await _PROMPT_MANAGER.get( "describe-surface", variables={"mission": s.mission}, @@ -253,7 +253,7 @@ async def describe_surface(s: AnalysisState) -> Mapping[str, Any]: # rendered_hash) via ContextVar. An OTel observer would stamp # those onto the LLM-call span fired inside the block. The # outer ``with_active_prompt_group`` (set in main()) ALSO stamps - # a ``group_name`` onto the same span — the two layers compose + # a ``group_name`` onto the same span; the two layers compose # so observers see both per-call AND per-group attribution. with with_active_prompt(rendered): response = await _get_provider().complete([multimodal_message]) @@ -322,7 +322,7 @@ async def main() -> None: # Pre-render both prompts with the real ``mission`` variable so # the PromptGroup can be built once at invoke entry. Both renders - # are honest — the nodes use the same fetch+render path inside, + # are honest; the nodes use the same fetch+render path inside, # so no placeholder identities sneak into the group's metadata. surface_member = await _PROMPT_MANAGER.get( "describe-surface", @@ -356,7 +356,7 @@ async def main() -> None: # ``with_active_prompt_group`` propagates the group_name to # observers for the duration of the invoke. Inside the nodes, # ``with_active_prompt`` adds the per-call prompt identifiers - # alongside it — both layers stamp attributes on the same + # alongside it; both layers stamp attributes on the same # LLM-call span. The OTel observer above captures both. with with_active_prompt_group(group): final = await graph.invoke( diff --git a/examples/07-multimodal-prompt/prompts/production/describe-equipment.j2 b/examples/multimodal-prompt/prompts/production/describe-equipment.j2 similarity index 100% rename from examples/07-multimodal-prompt/prompts/production/describe-equipment.j2 rename to examples/multimodal-prompt/prompts/production/describe-equipment.j2 diff --git a/examples/07-multimodal-prompt/prompts/production/describe-surface.j2 b/examples/multimodal-prompt/prompts/production/describe-surface.j2 similarity index 100% rename from examples/07-multimodal-prompt/prompts/production/describe-surface.j2 rename to examples/multimodal-prompt/prompts/production/describe-surface.j2 diff --git a/examples/07-multimodal-prompt/prompts_fallback/production/describe-equipment.j2 b/examples/multimodal-prompt/prompts_fallback/production/describe-equipment.j2 similarity index 100% rename from examples/07-multimodal-prompt/prompts_fallback/production/describe-equipment.j2 rename to examples/multimodal-prompt/prompts_fallback/production/describe-equipment.j2 diff --git a/examples/07-multimodal-prompt/prompts_fallback/production/describe-surface.j2 b/examples/multimodal-prompt/prompts_fallback/production/describe-surface.j2 similarity index 100% rename from examples/07-multimodal-prompt/prompts_fallback/production/describe-surface.j2 rename to examples/multimodal-prompt/prompts_fallback/production/describe-surface.j2 diff --git a/examples/04-nested-subgraphs/main.py b/examples/nested-subgraphs/main.py similarity index 96% rename from examples/04-nested-subgraphs/main.py rename to examples/nested-subgraphs/main.py index ec2cf203..4467167a 100644 --- a/examples/04-nested-subgraphs/main.py +++ b/examples/nested-subgraphs/main.py @@ -16,7 +16,7 @@ about a question + final answer, the middle picks one document from CORPUS and synthesizes, the inner cares about a single doc + an extracted span. That separation is the whole reason the middle and inner pieces are -subgraphs and not flat nodes — each is a self-contained, reusable +subgraphs and not flat nodes; each is a self-contained, reusable sub-pipeline with its own inputs and outputs. **Configuration** (env vars; OpenAI defaults shown): @@ -28,7 +28,7 @@ Run with: uv sync --group examples - cd examples/04-nested-subgraphs + cd examples/nested-subgraphs LLM_API_KEY=sk-... uv run python main.py "what year did humans first land on the moon?" LLM_API_KEY=sk-... uv run python main.py "what happened on Apollo 13?" LLM_API_KEY=sk-... uv run python main.py "who was on the Artemis II crew?" @@ -121,7 +121,7 @@ async def _chat(system: str, user: str) -> str: # --------------------------------------------------------------------------- -# State schemas — one per layer, each scoped to its layer's job. +# State schemas; one per layer, each scoped to its layer's job. # --------------------------------------------------------------------------- @@ -138,7 +138,7 @@ class DocQAState(State): The corpus itself is module-level configuration, not per-invocation state. Nodes reach into ``CORPUS`` directly rather than carrying it - through state — typical for application config that doesn't change + through state; typical for application config that doesn't change between calls. """ @@ -221,7 +221,7 @@ async def pick_doc(s: DocQAState) -> Mapping[str, Any]: reply = content.strip().strip('"').strip("'").lower() # Permissive match: the model may paraphrase ("Apollo 11 article") or # return only part of the title. Accept either direction of containment - # over the lowercased strings — strict equality is too brittle for + # over the lowercased strings; strict equality is too brittle for # free-form output. A production app would constrain the model with # response_schema (see 00-hello-world) so the reply is guaranteed to be # a valid title. @@ -323,9 +323,9 @@ def build_graph() -> CompiledGraph[OuterState]: # --------------------------------------------------------------------------- -# Observer — formats events so the descent through layers is visible. +# Observer; formats events so the descent through layers is visible. # -# The same observer fires for every node at every depth — including the +# The same observer fires for every node at every depth; including the # inner section-extract subgraph at depth 3. Indentation in the printed # output makes the descent and return obvious. # --------------------------------------------------------------------------- @@ -334,7 +334,7 @@ def build_graph() -> CompiledGraph[OuterState]: def _fmt_state(state: Any) -> str: """Compact one-line dump of whichever state class the event carries.""" if state is None: - return "—" + return "-" dumped = state.model_dump() # Hide the trace (already visible in the printed order). Print the # remaining fields as a compact summary. diff --git a/examples/03-observer-hooks/main.py b/examples/observer-hooks/main.py similarity index 95% rename from examples/03-observer-hooks/main.py rename to examples/observer-hooks/main.py index a60eb0df..b201f128 100644 --- a/examples/03-observer-hooks/main.py +++ b/examples/observer-hooks/main.py @@ -11,7 +11,7 @@ 3. The **OTel observer** wired to a console span exporter, so the same boundaries surface as OpenTelemetry spans. -**Demonstrates:** Observer hooks — registering graph-attached and +**Demonstrates:** Observer hooks; registering graph-attached and invocation-scoped observers, the `NodeEvent` shape, namespace chaining across a subgraph boundary, the `drain()` call required for short-lived processes, and how observers see structured pre/post state without nodes @@ -30,7 +30,7 @@ Run with: uv sync --group examples --all-extras - cd examples/03-observer-hooks + cd examples/observer-hooks LLM_API_KEY=sk-... uv run python main.py "what year did the moon landing happen" LLM_API_KEY=sk-... uv run python main.py "explain why NASA is returning to the moon with Artemis" @@ -124,7 +124,7 @@ def _get_provider() -> OpenAIProvider: # fields we want to flow across the boundary (`draft`, `revised`, `trace`) # and each adds its own (`question` outside, `critique` inside). Keeping # the fields aligned by name lets the subgraph's outputs flow back through -# default field-name matching — except for `draft`, which we +# default field-name matching; except for `draft`, which we # DO need to project IN. Hence the `inputs={"draft": "draft"}` mapping # below; absent `outputs` falls back to field-name matching for the way # back, projecting `revised` and `trace`. @@ -189,7 +189,7 @@ async def critique(s: ReviewState) -> Mapping[str, Any]: content = await _chat( system=( "Read the draft answer below and write one short paragraph " - "criticizing it — what's missing, what's vague, what could be " + "criticizing it; what's missing, what's vague, what could be " "tightened. No preamble." ), user=s.draft, @@ -222,7 +222,7 @@ def build_review_subgraph() -> CompiledGraph[ReviewState]: # ---------------------------------------------------------------------------- # Observer 1: console tracer (graph-attached) # ---------------------------------------------------------------------------- -# A bare async function. Conforms to the `Observer` Protocol structurally — +# A bare async function. Conforms to the `Observer` Protocol structurally # any `async def(event: NodeEvent) -> None` works. Graph-attached observers # fire on every invocation of the compiled graph until removed. @@ -249,7 +249,7 @@ async def console_tracer(event: ObserverEvent) -> None: pre = event.pre_state.model_dump() post = event.post_state.model_dump() if event.post_state is not None else {} - # Keys whose values changed during this node — gives the reader a + # Keys whose values changed during this node; gives the reader a # field-level view of what each node DID without nodes having to log. changed = {k: post[k] for k in post if pre.get(k) != post[k]} print(f"[step={event.step}] {namespace} → {changed}", file=sys.stderr) @@ -266,7 +266,7 @@ async def console_tracer(event: ObserverEvent) -> None: # A class with an `async __call__` method. Same Protocol; class-shaped # observers are useful when you want per-invocation state that isn't a # global. We pass the instance to `invoke(observers=[...])` so it fires -# only for THAT call — graph-attached observers are persistent across +# only for THAT call; graph-attached observers are persistent across # calls; invocation-scoped observers are per-call. @@ -307,7 +307,7 @@ def build_graph() -> CompiledGraph[AnswerState]: # Leaving `outputs` absent falls back to default field-name # matching, which projects subgraph.revised → parent.revised # and subgraph.trace → parent.trace via the parent's append - # reducer. (Subgraph.critique is discarded — no parent field + # reducer. (Subgraph.critique is discarded; no parent field # of that name.) inputs={"draft": "draft"}, ), @@ -328,9 +328,9 @@ def build_graph() -> CompiledGraph[AnswerState]: # # 1. Build the graph. # 2. Attach graph-level observers (console_tracer + OTelObserver here) -# — these fire on every invoke of this compiled graph. +# ; these fire on every invoke of this compiled graph. # 3. For each invoke, optionally pass invocation-scoped observers -# (metrics here) — they fire only for THAT invocation. +# (metrics here); they fire only for THAT invocation. # 4. await drain() before exiting. The graph dispatches events to a # background queue; without drain, a short-lived process can exit # before the queue's worker has delivered them. In a long-running @@ -347,7 +347,7 @@ def build_graph() -> CompiledGraph[AnswerState]: async def main() -> None: question = " ".join(sys.argv[1:]) or "what year did the moon landing happen" - # OTel observer with a console span exporter — every span prints to + # OTel observer with a console span exporter; every span prints to # stdout as a JSON blob when it closes. SimpleSpanProcessor exports # synchronously which is right for a short-lived demo; production # would use BatchSpanProcessor against a real OTLP exporter. The @@ -358,7 +358,7 @@ async def main() -> None: # ``resource`` stamps ``service.name`` on every emitted span so # downstream backends (Honeycomb, Tempo, HyperDX, Langfuse) can # filter by service. Setting it on the observer is the explicit - # path — the OTel SDK alternative (reading the + # path; the OTel SDK alternative (reading the # ``OTEL_SERVICE_NAME`` / ``OTEL_RESOURCE_ATTRIBUTES`` env vars) # has to be set BEFORE the observer constructs, which is easy # to get wrong. @@ -388,7 +388,7 @@ async def main() -> None: finally: # Required for short-lived processes: invoke() returns when the # graph reaches END regardless of whether the observer queue has - # finished. The try/finally also matters on the failure path — + # finished. The try/finally also matters on the failure path # the engine dispatches a failure event with `error` populated # BEFORE propagating, and that event is exactly what a debugging # user would want to see. Without `finally`, an invoke that diff --git a/examples/06-parallel-branches/main.py b/examples/parallel-branches/main.py similarity index 95% rename from examples/06-parallel-branches/main.py rename to examples/parallel-branches/main.py index 2f407efc..5697940f 100644 --- a/examples/06-parallel-branches/main.py +++ b/examples/parallel-branches/main.py @@ -6,10 +6,10 @@ and a short list of topic tags. The three analyses don't depend on each other, so dispatch them in parallel. Each analysis is its own subgraph with its own state schema (the summary subgraph doesn't care about -sentiment, the topic extractor doesn't care about either) — which is +sentiment, the topic extractor doesn't care about either); which is exactly the shape parallel-branches is for. -Where fan-out (example 05) runs N copies of ONE subgraph against +Where fan-out (the fan-out-with-retry example) runs N copies of ONE subgraph against different inputs, parallel-branches runs M heterogeneous subgraphs against the same input. Different schemas, different middleware, different topologies per branch; one dispatch. @@ -27,13 +27,13 @@ ``article`` into each branch's input field name. - The sentiment branch wraps its subgraph in ``RetryMiddleware`` to show per-branch middleware composition. The other two branches run - bare. Per-branch middleware is heterogeneous — branch A may have + bare. Per-branch middleware is heterogeneous; branch A may have retry + timing, branch B nothing, branch C something custom. - Branch insertion order determines fan-in order: when two branches contribute to the same parent field, the parent's reducer applies them in the order the branches were declared in the ``branches`` mapping (not in completion order). The three branches here write - disjoint parent fields, so the order doesn't affect the result — + disjoint parent fields, so the order doesn't affect the result but the property holds and would matter if they overlapped. - A ``branch_attribution_observer`` reads ``NodeEvent.branch_name`` on inner-node events. ``branch_name`` is populated only for @@ -51,7 +51,7 @@ Run with: uv sync --group examples - cd examples/06-parallel-branches + cd examples/parallel-branches LLM_API_KEY=sk-... uv run python main.py """ @@ -158,7 +158,7 @@ class TopicsState(State): # --------------------------------------------------------------------------- -# Branch subgraphs — each is one node, but each has its own scope. +# Branch subgraphs; each is one node, but each has its own scope. # --------------------------------------------------------------------------- @@ -245,7 +245,7 @@ async def branch_attribution_observer(event: ObserverEvent) -> None: """Print which branch each inner-node event came from. NodeEvent carries ``branch_name`` on events from nodes that - execute INSIDE a parallel-branches branch — it's the per-event + execute INSIDE a parallel-branches branch; it's the per-event attribution that says "this came from branch X." Outermost-graph nodes (receive, enrich, present) carry no branch_name. The observer skips events with no branch attribution and prints @@ -315,7 +315,7 @@ async def main() -> None: graph.attach_observer(branch_attribution_observer) print("=" * 72) - print("Lunar-mission article enrichment — three independent analyses in parallel") + print("Lunar-mission article enrichment; three independent analyses in parallel") print("=" * 72) print() print(f"Article ({len(ARTICLE)} chars):") @@ -337,7 +337,7 @@ async def main() -> None: print() print(f" wall-clock: {wall_ms:7.1f} ms") print() - print("The three branches ran in parallel — wall-clock is closer to the") + print("The three branches ran in parallel; wall-clock is closer to the") print("slowest single branch than to the sum of all three.") finally: await graph.drain() diff --git a/examples/12-production-observability/main.py b/examples/production-observability/main.py similarity index 97% rename from examples/12-production-observability/main.py rename to examples/production-observability/main.py index 6531ebcd..6be228d2 100644 --- a/examples/12-production-observability/main.py +++ b/examples/production-observability/main.py @@ -42,10 +42,11 @@ ``BatchSpanProcessor(OTLPSpanExporter(...))`` respectively; the observer call surface doesn't change. -Complementary to example 03 (observer hooks with three observers -side-by-side) and example 10 (Langfuse observer + LangfusePromptBackend -prompt linkage). This example's headline is the production-shape -wiring, not the hook surface or the prompt linkage. +Complementary to the observer-hooks example (three observers +side-by-side) and the langfuse-observability example (Langfuse +observer + LangfusePromptBackend prompt linkage). This example's +headline is the production-shape wiring, not the hook surface or +the prompt linkage. **Configuration** (env vars; OpenAI defaults shown): @@ -57,7 +58,7 @@ Run with: uv sync --group examples --all-extras - LLM_API_KEY=sk-... uv run python examples/12-production-observability/main.py + LLM_API_KEY=sk-... uv run python examples/production-observability/main.py (``--all-extras`` pulls in ``opentelemetry-sdk`` for the OTel observer and ``[langfuse]`` for the Langfuse observer's record types.) @@ -204,7 +205,8 @@ def build_graph(): TimingMiddleware wraps the respond node so wall-clock duration is captured per call. No other middleware (RetryMiddleware lives - in examples 05 / 06; this one's scope is observability).""" + in the fan-out-with-retry / parallel-branches examples; this + one's scope is observability).""" timing = TimingMiddleware(node_name="respond", on_complete=_emit_timing) return ( GraphBuilder(BriefingState) diff --git a/examples/01-routing-and-subgraphs/main.py b/examples/routing-and-subgraphs/main.py similarity index 92% rename from examples/01-routing-and-subgraphs/main.py rename to examples/routing-and-subgraphs/main.py index 5afdbaea..5ef11ada 100644 --- a/examples/01-routing-and-subgraphs/main.py +++ b/examples/routing-and-subgraphs/main.py @@ -17,7 +17,7 @@ 2. **Subgraphs.** One of those branches is an entire sub-graph (plan → gather → synthesize) wrapped as a single node in the parent graph. 3. **A custom `ProjectionStrategy`.** The default projection (`FieldNameMatching`) - doesn't carry parent state *into* the subgraph — the subgraph starts + doesn't carry parent state *into* the subgraph; the subgraph starts from its own schema's defaults. To pass the user's question in (and shape what comes back out), we write a `ProjectionStrategy` by hand. @@ -28,14 +28,14 @@ **Configuration** (env vars; OpenAI defaults shown): - ``LLM_BASE_URL`` defaults to ``https://api.openai.com``. **Host root - only** — the provider adds the path itself. + only**; the provider adds the path itself. - ``LLM_MODEL`` defaults to ``gpt-4o-mini``. - ``LLM_API_KEY`` required (empty for local servers that don't authenticate). Run with: uv sync --group examples - cd examples/01-routing-and-subgraphs + cd examples/routing-and-subgraphs LLM_API_KEY=sk-... uv run python main.py "what year did the moon landing happen" LLM_API_KEY=sk-... uv run python main.py "why is the lunar south pole strategically important?" """ @@ -82,7 +82,7 @@ def _get_provider() -> OpenAIProvider: # ---------------------------------------------------------------------------- # The outer graph and the subgraph each have their OWN `State` subclass. This # is one of openarmature's stronger opinions: a subgraph isn't a namespace -# inside the parent's schema — it's a separate pipeline with its own field +# inside the parent's schema; it's a separate pipeline with its own field # shape. The boundary between them is an explicit translation step # (a `ProjectionStrategy`), not implicit aliasing. # @@ -152,7 +152,7 @@ async def _chat(system: str, user: str) -> str: # (b) Every node contributes to `tallies` via the `merge` reducer. Each # return dict carries a small `{"tallies": {...}}` fragment; the # reducer accumulates them into one dict on the final state. This is -# the same pattern used for metrics/counts across a pipeline — +# the same pattern used for metrics/counts across a pipeline # compose by emitting fragments, not by read-modify-write. # # (c) No node calls a subsequent node. `classify` doesn't know whether @@ -208,13 +208,13 @@ async def format_final(s: AssistantState) -> Mapping[str, Any]: # Conditional edge function # ---------------------------------------------------------------------------- # The conditional edge fn is called with the state AFTER `classify`'s update -# has been merged — so `s.route` reflects what `classify` wrote. The return +# has been merged; so `s.route` reflects what `classify` wrote. The return # value MUST be a declared node name (as a string) OR the `END` sentinel. # Anything else raises `RoutingError` at runtime. # # Small but important: the fn is synchronous. It's a routing decision, not a # place to do IO. (If you need async routing logic, do it in the producing -# node and write the decision into a state field — which is exactly what +# node and write the decision into a state field; which is exactly what # `classify` does here.) # # Default case: we fall back to "quick_answer" if classify returned something @@ -233,11 +233,11 @@ def route_from_classification(s: AssistantState) -> str: # ---------------------------------------------------------------------------- # The subgraph is itself a full openarmature graph. It has its own state # class (`ResearchState`), its own nodes, its own edges, its own entry. When -# compiled, it becomes a `CompiledGraph` — and a `CompiledGraph` can be +# compiled, it becomes a `CompiledGraph`; and a `CompiledGraph` can be # wrapped as a node in an outer graph via `builder.add_subgraph_node(...)`. # # Why use a subgraph here at all? You could flatten these three nodes into -# the outer graph — plan_research, gather, synthesize as peers of classify +# the outer graph; plan_research, gather, synthesize as peers of classify # and quick_answer. You'd lose two things: # # 1. **Encapsulation.** The outer graph cares about "research produces @@ -251,7 +251,7 @@ def route_from_classification(s: AssistantState) -> str: # or composed inside yet another subgraph. # # The `ResearchState` is intentionally narrower than `AssistantState`. The -# subgraph shouldn't know or care about `route` or `tallies` — those are +# subgraph shouldn't know or care about `route` or `tallies`; those are # outer-graph concerns. This is what separate schemas buys you. @@ -341,14 +341,14 @@ def build_research_subgraph() -> CompiledGraph[ResearchState]: # # - `project_out`: GOOD. It looks at the subgraph's final state, picks the # fields whose names also exist on the parent, and returns them as a -# partial update — the parent's reducers then merge. That's how the +# partial update; the parent's reducers then merge. That's how the # subgraph's `trace` list flows back into the outer `trace` via the # outer's `append` reducer. # # - `project_in`: DELIBERATELY LIMITED. It builds a fresh subgraph state -# from its schema's defaults — `subgraph_state_cls()`. The parent's +# from its schema's defaults; `subgraph_state_cls()`. The parent's # state is ignored. Subgraphs don't see the outer world unless the -# author opts in — encapsulation is the point. +# author opts in; encapsulation is the point. # # For this demo we absolutely need the question in the subgraph. So we write # a projection class that implements the `ProjectionStrategy` Protocol (see @@ -358,7 +358,7 @@ def build_research_subgraph() -> CompiledGraph[ResearchState]: # Teaching moment: this is the pattern for ALL non-trivial subgraph use. The # default is fine for "inner computation shares the parent's field names" # cases; anything else needs a custom projection. There's no runtime check -# that this is well-formed — a projection that returns a field the parent +# that this is well-formed; a projection that returns a field the parent # doesn't declare will surface at the boundary as a `StateValidationError` # (extra="forbid" on the parent catches it). @@ -367,10 +367,10 @@ def build_research_subgraph() -> CompiledGraph[ResearchState]: class QuestionProjection: """Pass `question` INTO the subgraph; pull `answer` and `trace` OUT. - Signatures are typed directly against `AssistantState` and `ResearchState` - — `ProjectionStrategy[ParentT, ChildT]` is a generic Protocol, so - structural conformance is checked at the `_: ProjectionStrategy[...]` - annotation below without inheritance. + Signatures are typed directly against `AssistantState` and `ResearchState` + ; `ProjectionStrategy[ParentT, ChildT]` is a generic Protocol, so + structural conformance is checked at the `_: ProjectionStrategy[...]` + annotation below without inheritance. """ def project_in( @@ -387,8 +387,8 @@ def project_out( parent_state: AssistantState, subgraph_state_cls: type[ResearchState], ) -> Mapping[str, Any]: - # Bring `answer` back — merged via parent's `last_write_wins`. - # Bring `trace` back — merged via parent's `append` reducer, which + # Bring `answer` back; merged via parent's `last_write_wins`. + # Bring `trace` back; merged via parent's `append` reducer, which # concatenates the subgraph's trace entries after the parent's. # Bump a tally so we can see the research branch ran. return { @@ -409,23 +409,23 @@ def project_out( # ---------------------------------------------------------------------------- # Four things to notice below: # -# 1. `.add_subgraph_node("research", ..., projection=QuestionProjection())` — +# 1. `.add_subgraph_node("research", ..., projection=QuestionProjection())` # this is the only new method on `GraphBuilder` vs 01-linear-pipeline. It # registers a compiled graph as a node, under the given name, with the # given projection. # -# 2. `.add_conditional_edge("classify", route_from_classification)` — the +# 2. `.add_conditional_edge("classify", route_from_classification)`; the # conditional edge. Exactly one outgoing edge per node still applies; # a conditional IS that one edge. Compile will fail with # `MultipleOutgoingEdges` if you mix a static and a conditional from # the same source. # # 3. Both branches (`quick_answer` and `research`) merge back into -# `format_final`. You can fan out and fan in freely — the single- +# `format_final`. You can fan out and fan in freely; the single- # outgoing-edge rule is per node, not "no multiple predecessors". # # 4. `.compile()` at the end runs all the same structural checks as -# before — PLUS the reachability check understands conditional edges +# before; PLUS the reachability check understands conditional edges # (conservatively: a conditional from X is treated as reaching every # node, which keeps the unreachable check sound). diff --git a/examples/09-tool-use/main.py b/examples/tool-use/main.py similarity index 97% rename from examples/09-tool-use/main.py rename to examples/tool-use/main.py index 9fa91ff1..01ed2739 100644 --- a/examples/09-tool-use/main.py +++ b/examples/tool-use/main.py @@ -5,8 +5,8 @@ **Use case:** A user asks something that mixes a factual recall ("when did Apollo 13 splash down?") with a small computation ("what's the delta-v for a Hohmann transfer from a 300 km Earth orbit to lunar -distance?"). Neither belongs in the model's prompt — facts get stale and -arithmetic is unreliable from the model alone — so the agent defines two +distance?"). Neither belongs in the model's prompt; facts get stale and +arithmetic is unreliable from the model alone; so the agent defines two local tools and lets the model call them. The agent loops: send messages + tools to the model, dispatch any @@ -35,7 +35,7 @@ - The loop is just a conditional edge on the graph: ``call_llm`` → ``dispatch_tools`` → back to ``call_llm`` when the model wants more tools, or → ``present`` when it's done. No special "agent - framework" abstraction — tool-calling composes with the existing + framework" abstraction; tool-calling composes with the existing graph mechanics. - A ``MAX_TURNS`` cap prevents runaway loops if a model stays in tool-calling forever. Production agents typically pair the cap with @@ -50,7 +50,7 @@ Run with: uv sync --group examples - cd examples/09-tool-use + cd examples/tool-use LLM_API_KEY=sk-... uv run python main.py LLM_API_KEY=sk-... uv run python main.py "When was Apollo 17 launched?" """ @@ -103,7 +103,7 @@ def _get_provider() -> OpenAIProvider: # --------------------------------------------------------------------------- -# Tool 1 — lookup_mission: read a small baked-in fact-record for a +# Tool 1; lookup_mission: read a small baked-in fact-record for a # named lunar mission. Stand-in for a real lookup against a doc store # or knowledge base. # --------------------------------------------------------------------------- @@ -159,7 +159,7 @@ def lookup_mission(name: str) -> str: # --------------------------------------------------------------------------- -# Tool 2 — compute_delta_v: Hohmann transfer delta-v between two +# Tool 2; compute_delta_v: Hohmann transfer delta-v between two # circular orbits around a body with known gravitational parameter. # The textbook formula; rough but illustrative. # --------------------------------------------------------------------------- @@ -377,7 +377,7 @@ async def main() -> None: ] print("=" * 72) - print("Lunar-mission assistant — tool-calling loop") + print("Lunar-mission assistant; tool-calling loop") print("=" * 72) print() print(f" question: {question}") diff --git a/mkdocs.yml b/mkdocs.yml index 38428d19..3b76a595 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -130,19 +130,26 @@ nav: - openarmature.observability: reference/observability.md - Examples: - examples/index.md - - Hello, world: examples/00-hello-world.md - - Routing and subgraphs: examples/01-routing-and-subgraphs.md - - Explicit subgraph mapping: examples/02-explicit-subgraph-mapping.md - - Observer hooks: examples/03-observer-hooks.md - - Nested subgraphs: examples/04-nested-subgraphs.md - - Fan-out with retry: examples/05-fan-out-with-retry.md - - Parallel branches: examples/06-parallel-branches.md - - Multimodal prompt: examples/07-multimodal-prompt.md - - Checkpointing and migration: examples/08-checkpointing-and-migration.md - - Tool use: examples/09-tool-use.md - - Langfuse observability: examples/10-langfuse-observability.md - - Chat with multimodal: examples/11-chat-with-multimodal.md - - Production observability: examples/12-production-observability.md + - Foundations: + - Hello, world: examples/hello-world.md + - Composition: + - Routing and subgraphs: examples/routing-and-subgraphs.md + - Explicit subgraph mapping: examples/explicit-subgraph-mapping.md + - Nested subgraphs: examples/nested-subgraphs.md + - Concurrency: + - Fan-out with retry: examples/fan-out-with-retry.md + - Parallel branches: examples/parallel-branches.md + - Prompts: + - Multimodal prompt: examples/multimodal-prompt.md + - Chat with multimodal: examples/chat-with-multimodal.md + - Tool use: + - Tool use: examples/tool-use.md + - Reliability: + - Checkpointing and migration: examples/checkpointing-and-migration.md + - Observability: + - Observer hooks: examples/observer-hooks.md + - Langfuse observability: examples/langfuse-observability.md + - Production observability: examples/production-observability.md - Patterns: - patterns/index.md - Parameterized entry point: patterns/parameterized-entry-point.md diff --git a/src/openarmature/AGENTS.md b/src/openarmature/AGENTS.md index 4bbe2144..283601a1 100644 --- a/src/openarmature/AGENTS.md +++ b/src/openarmature/AGENTS.md @@ -648,7 +648,7 @@ spec-reserved key in flat Langfuse `trace.metadata`. - [Observability concept page](https://openarmature.ai/concepts/observability/): how OTel attributes and Langfuse metadata propagate. -- [`examples/10-langfuse-observability`](https://openarmature.ai/examples/10-langfuse-observability/): +- [`examples/langfuse-observability`](https://openarmature.ai/examples/langfuse-observability/): runnable example exercising the metadata propagation path. - Spec: [observability](https://openarmature.org/capabilities/observability/), the propagation contract for caller-supplied metadata. @@ -1001,7 +1001,7 @@ state and the session table holds the join keys. `resume_invocation`, schema migration. - [State and reducers](https://openarmature.ai/concepts/state-and-reducers/): `merge` and `append` reducer strategies. -- [`examples/08-checkpointing-and-migration`](https://openarmature.ai/examples/08-checkpointing-and-migration/): +- [`examples/checkpointing-and-migration`](https://openarmature.ai/examples/checkpointing-and-migration/): single-resume baseline. - Spec: [pipeline-utilities](https://openarmature.org/capabilities/pipeline-utilities/) @@ -1229,7 +1229,7 @@ The `MAX_TURNS` cap prevents runaway loops; the conditional edge short-circuits to `present` when the cap is hit or when the model returns no `tool_calls`. -See [`examples/09-tool-use`](https://openarmature.ai/examples/09-tool-use/) for a +See [`examples/tool-use`](https://openarmature.ai/examples/tool-use/) for a runnable version with full tool definitions, defensive handling for malformed `ToolCall.arguments`, and trace output. @@ -1263,7 +1263,7 @@ for malformed `ToolCall.arguments`, and trace output. contract. - [State and reducers](https://openarmature.ai/concepts/state-and-reducers/): `append` reducer semantics. -- [`examples/09-tool-use`](https://openarmature.ai/examples/09-tool-use/): runnable +- [`examples/tool-use`](https://openarmature.ai/examples/tool-use/): runnable reference implementation. - Spec: [llm-provider](https://openarmature.org/capabilities/llm-provider/) @@ -1454,19 +1454,19 @@ If a parent field is populated by BOTH direct node writes AND fan-out collection _Runnable example programs shipped in the source tree at `examples/`. The full code is not bundled here (each example is 300+ lines); read the file at the listed path to see the canonical shape for that use case._ -- **`examples/00-hello-world/main.py`** — Hello-world demo: a 3-node graph where each node makes an LLM call with structured output. Classify a query, then either plan research or write a one-sentence summary. -- **`examples/01-routing-and-subgraphs/main.py`** — openarmature demo: conditional routing + subgraph with a custom projection. -- **`examples/02-explicit-subgraph-mapping/main.py`** — openarmature demo: same compiled subgraph reused at two sites in one parent graph, each site with its own ExplicitMapping. -- **`examples/03-observer-hooks/main.py`** — openarmature demo: observer hooks for structured logging, per-call metrics, and OTel spans. -- **`examples/04-nested-subgraphs/main.py`** — openarmature demo: question answering against a tiny document corpus, with two levels of subgraph nesting. -- **`examples/05-fan-out-with-retry/main.py`** — openarmature demo: summarize a batch of lunar-mission headlines in parallel, with per-headline retries and timing. -- **`examples/06-parallel-branches/main.py`** — openarmature demo: enrich a lunar-mission news article with three independent analyses running concurrently. -- **`examples/07-multimodal-prompt/main.py`** — openarmature demo: two independent analyses of a lunar-mission photograph using versioned prompt templates, a fallback prompt backend, and a multimodal user message. -- **`examples/08-checkpointing-and-migration/main.py`** — openarmature demo: a lunar-mission planning pipeline that survives a mid-pipeline crash and later resumes under an upgraded state schema. -- **`examples/09-tool-use/main.py`** — openarmature demo: a lunar-mission assistant that calls local Python functions as tools to answer fact and physics questions about Apollo / Artemis missions. -- **`examples/10-langfuse-observability/main.py`** — openarmature demo: Langfuse observer + prompt linkage on a lunar mission Q&A pipeline. -- **`examples/11-chat-with-multimodal/main.py`** — openarmature demo: multi-turn chat with conversation memory and a multimodal turn, using ChatPrompt + PlaceholderSegment. -- **`examples/12-production-observability/main.py`** — openarmature demo: production observability with dual OTel + Langfuse observers, caller hooks for trace.input/output, and the canonical TimingMiddleware. +- **`examples/chat-with-multimodal/main.py`** — openarmature demo: multi-turn chat with conversation memory and a multimodal turn, using ChatPrompt + PlaceholderSegment. +- **`examples/checkpointing-and-migration/main.py`** — openarmature demo: a lunar-mission planning pipeline that survives a mid-pipeline crash and later resumes under an upgraded state schema. +- **`examples/explicit-subgraph-mapping/main.py`** — openarmature demo: same compiled subgraph reused at two sites in one parent graph, each site with its own ExplicitMapping. +- **`examples/fan-out-with-retry/main.py`** — openarmature demo: summarize a batch of lunar-mission headlines in parallel, with per-headline retries and timing. +- **`examples/hello-world/main.py`** — Hello-world demo: a 3-node graph where each node makes an LLM call with structured output. Classify a query, then either plan research or write a one-sentence summary. +- **`examples/langfuse-observability/main.py`** — openarmature demo: Langfuse observer + prompt linkage on a lunar mission Q&A pipeline. +- **`examples/multimodal-prompt/main.py`** — openarmature demo: two independent analyses of a lunar-mission photograph using versioned prompt templates, a fallback prompt backend, and a multimodal user message. +- **`examples/nested-subgraphs/main.py`** — openarmature demo: question answering against a tiny document corpus, with two levels of subgraph nesting. +- **`examples/observer-hooks/main.py`** — openarmature demo: observer hooks for structured logging, per-call metrics, and OTel spans. +- **`examples/parallel-branches/main.py`** — openarmature demo: enrich a lunar-mission news article with three independent analyses running concurrently. +- **`examples/production-observability/main.py`** — openarmature demo: production observability with dual OTel + Langfuse observers, caller hooks for trace.input/output, and the canonical TimingMiddleware. +- **`examples/routing-and-subgraphs/main.py`** — openarmature demo: conditional routing + subgraph with a custom projection. +- **`examples/tool-use/main.py`** — openarmature demo: a lunar-mission assistant that calls local Python functions as tools to answer fact and physics questions about Apollo / Artemis missions. ## Discovery cross-references diff --git a/src/openarmature/_patterns/caller-supplied-trace-identifiers.md b/src/openarmature/_patterns/caller-supplied-trace-identifiers.md index faac79d1..b25d1c6d 100644 --- a/src/openarmature/_patterns/caller-supplied-trace-identifiers.md +++ b/src/openarmature/_patterns/caller-supplied-trace-identifiers.md @@ -132,7 +132,7 @@ spec-reserved key in flat Langfuse `trace.metadata`. - [Observability concept page](https://openarmature.ai/concepts/observability/): how OTel attributes and Langfuse metadata propagate. -- [`examples/10-langfuse-observability`](https://openarmature.ai/examples/10-langfuse-observability/): +- [`examples/langfuse-observability`](https://openarmature.ai/examples/langfuse-observability/): runnable example exercising the metadata propagation path. - Spec: [observability](https://openarmature.org/capabilities/observability/), the propagation contract for caller-supplied metadata. diff --git a/src/openarmature/_patterns/session-as-checkpoint-resume.md b/src/openarmature/_patterns/session-as-checkpoint-resume.md index 77c98ead..f41d5cae 100644 --- a/src/openarmature/_patterns/session-as-checkpoint-resume.md +++ b/src/openarmature/_patterns/session-as-checkpoint-resume.md @@ -109,6 +109,6 @@ state and the session table holds the join keys. `resume_invocation`, schema migration. - [State and reducers](https://openarmature.ai/concepts/state-and-reducers/): `merge` and `append` reducer strategies. -- [`examples/08-checkpointing-and-migration`](https://openarmature.ai/examples/08-checkpointing-and-migration/): +- [`examples/checkpointing-and-migration`](https://openarmature.ai/examples/checkpointing-and-migration/): single-resume baseline. - Spec: [pipeline-utilities](https://openarmature.org/capabilities/pipeline-utilities/) diff --git a/src/openarmature/_patterns/tool-dispatch-as-node.md b/src/openarmature/_patterns/tool-dispatch-as-node.md index 7145a702..633d913f 100644 --- a/src/openarmature/_patterns/tool-dispatch-as-node.md +++ b/src/openarmature/_patterns/tool-dispatch-as-node.md @@ -91,7 +91,7 @@ The `MAX_TURNS` cap prevents runaway loops; the conditional edge short-circuits to `present` when the cap is hit or when the model returns no `tool_calls`. -See [`examples/09-tool-use`](https://openarmature.ai/examples/09-tool-use/) for a +See [`examples/tool-use`](https://openarmature.ai/examples/tool-use/) for a runnable version with full tool definitions, defensive handling for malformed `ToolCall.arguments`, and trace output. @@ -125,6 +125,6 @@ for malformed `ToolCall.arguments`, and trace output. contract. - [State and reducers](https://openarmature.ai/concepts/state-and-reducers/): `append` reducer semantics. -- [`examples/09-tool-use`](https://openarmature.ai/examples/09-tool-use/): runnable +- [`examples/tool-use`](https://openarmature.ai/examples/tool-use/): runnable reference implementation. - Spec: [llm-provider](https://openarmature.org/capabilities/llm-provider/) diff --git a/tests/test_examples_smoke.py b/tests/test_examples_smoke.py index 5e10f333..ea1c0dbe 100644 --- a/tests/test_examples_smoke.py +++ b/tests/test_examples_smoke.py @@ -30,19 +30,19 @@ EXAMPLES_DIR = Path(__file__).parent.parent / "examples" DEMOS = [ - "00-hello-world", - "01-routing-and-subgraphs", - "02-explicit-subgraph-mapping", - "03-observer-hooks", - "04-nested-subgraphs", - "05-fan-out-with-retry", - "06-parallel-branches", - "07-multimodal-prompt", - "08-checkpointing-and-migration", - "09-tool-use", - "10-langfuse-observability", - "11-chat-with-multimodal", - "12-production-observability", + "hello-world", + "routing-and-subgraphs", + "explicit-subgraph-mapping", + "nested-subgraphs", + "fan-out-with-retry", + "parallel-branches", + "multimodal-prompt", + "chat-with-multimodal", + "tool-use", + "checkpointing-and-migration", + "observer-hooks", + "langfuse-observability", + "production-observability", ] From d8a997081dcc88a4bbb9b45dfeb714e880e7c475 Mon Sep 17 00:00:00 2001 From: chris-colinsky Date: Mon, 1 Jun 2026 19:14:34 -0700 Subject: [PATCH 2/4] Fix grammar from em-dash sweep in renames PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit dropped trailing em dashes (` —` at end-of-line) on the assumption they were stylistic emphasis. They weren't; they were carrying the punctuation load a colon, semicolon, or comma would otherwise do. Seven affected sentences (in explicit-subgraph-mapping, langfuse-observability, observer-hooks x2, parallel-branches, routing-and-subgraphs x2) became ungrammatical clause-smashes. Now properly punctuated. Also fixes a pre-existing stale comment in routing-and-subgraphs that referenced `01-linear-pipeline` (a long-removed example name); updated to "the hello-world example" since that's the corresponding Foundations entry under the new catalog. Surfaced by self-review of commit dbaf2de. --- examples/explicit-subgraph-mapping/main.py | 5 +++-- examples/langfuse-observability/main.py | 2 +- examples/observer-hooks/main.py | 9 +++++---- examples/parallel-branches/main.py | 2 +- examples/routing-and-subgraphs/main.py | 10 +++++----- 5 files changed, 15 insertions(+), 13 deletions(-) diff --git a/examples/explicit-subgraph-mapping/main.py b/examples/explicit-subgraph-mapping/main.py index a06c7a7d..009da1b8 100644 --- a/examples/explicit-subgraph-mapping/main.py +++ b/examples/explicit-subgraph-mapping/main.py @@ -74,8 +74,9 @@ def _get_provider() -> OpenAIProvider: # summary and a score for EACH topic. So the parent schema declares paired # fields: a_summary/a_score and b_summary/b_score. # -# The subgraph speaks in a single set of names; `topic`, `summary`, `score` -# because it has no idea which side of the comparison it's running for. The +# The subgraph speaks in a single set of names (`topic`, `summary`, +# `score`) because it has no idea which side of the comparison it's +# running for. The # mapping at each call site is what wires the subgraph's neutral names to the # parent's per-side fields. # diff --git a/examples/langfuse-observability/main.py b/examples/langfuse-observability/main.py index 6738b3e5..a2fdc756 100644 --- a/examples/langfuse-observability/main.py +++ b/examples/langfuse-observability/main.py @@ -264,7 +264,7 @@ async def main() -> None: # disable_llm_payload=False opts in to capturing the input messages # and output content on Generation observations. Default is True - # for the same privacy reason the OTel observer's flag exists + # for the same privacy reason the OTel observer's flag exists: # payloads may contain PII the operator hasn't audited. Flip it # deliberately here because the demo's whole point is showing what # the model saw and returned. diff --git a/examples/observer-hooks/main.py b/examples/observer-hooks/main.py index b201f128..a80f4f9b 100644 --- a/examples/observer-hooks/main.py +++ b/examples/observer-hooks/main.py @@ -222,9 +222,10 @@ def build_review_subgraph() -> CompiledGraph[ReviewState]: # ---------------------------------------------------------------------------- # Observer 1: console tracer (graph-attached) # ---------------------------------------------------------------------------- -# A bare async function. Conforms to the `Observer` Protocol structurally -# any `async def(event: NodeEvent) -> None` works. Graph-attached observers -# fire on every invocation of the compiled graph until removed. +# A bare async function. Conforms to the `Observer` Protocol +# structurally: any `async def(event: NodeEvent) -> None` works. +# Graph-attached observers fire on every invocation of the compiled +# graph until removed. async def console_tracer(event: ObserverEvent) -> None: @@ -388,7 +389,7 @@ async def main() -> None: finally: # Required for short-lived processes: invoke() returns when the # graph reaches END regardless of whether the observer queue has - # finished. The try/finally also matters on the failure path + # finished. The try/finally also matters on the failure path: # the engine dispatches a failure event with `error` populated # BEFORE propagating, and that event is exactly what a debugging # user would want to see. Without `finally`, an invoke that diff --git a/examples/parallel-branches/main.py b/examples/parallel-branches/main.py index 5697940f..b1937a56 100644 --- a/examples/parallel-branches/main.py +++ b/examples/parallel-branches/main.py @@ -33,7 +33,7 @@ contribute to the same parent field, the parent's reducer applies them in the order the branches were declared in the ``branches`` mapping (not in completion order). The three branches here write - disjoint parent fields, so the order doesn't affect the result + disjoint parent fields, so the order doesn't affect the result, but the property holds and would matter if they overlapped. - A ``branch_attribution_observer`` reads ``NodeEvent.branch_name`` on inner-node events. ``branch_name`` is populated only for diff --git a/examples/routing-and-subgraphs/main.py b/examples/routing-and-subgraphs/main.py index 5ef11ada..47cefd90 100644 --- a/examples/routing-and-subgraphs/main.py +++ b/examples/routing-and-subgraphs/main.py @@ -152,7 +152,7 @@ async def _chat(system: str, user: str) -> str: # (b) Every node contributes to `tallies` via the `merge` reducer. Each # return dict carries a small `{"tallies": {...}}` fragment; the # reducer accumulates them into one dict on the final state. This is -# the same pattern used for metrics/counts across a pipeline +# the same pattern used for metrics/counts across a pipeline: # compose by emitting fragments, not by read-modify-write. # # (c) No node calls a subsequent node. `classify` doesn't know whether @@ -409,10 +409,10 @@ def project_out( # ---------------------------------------------------------------------------- # Four things to notice below: # -# 1. `.add_subgraph_node("research", ..., projection=QuestionProjection())` -# this is the only new method on `GraphBuilder` vs 01-linear-pipeline. It -# registers a compiled graph as a node, under the given name, with the -# given projection. +# 1. `.add_subgraph_node("research", ..., projection=QuestionProjection())`: +# this is the only new method on `GraphBuilder` vs the hello-world +# example. It registers a compiled graph as a node, under the given +# name, with the given projection. # # 2. `.add_conditional_edge("classify", route_from_classification)`; the # conditional edge. Exactly one outgoing edge per node still applies; From 4c2ba58eac54c4cddb57de9fb674fb30f98c2f40 Mon Sep 17 00:00:00 2001 From: chris-colinsky Date: Tue, 2 Jun 2026 08:03:53 -0700 Subject: [PATCH 3/4] Polish docs nav and homepage badges After the rename PR introduced nested subsection groups in the left nav (the new topical grouping under Examples), the existing nav CSS was no longer sufficient. This commit: - Hides the redundant tab label inside the sidebar (the active tab name is already shown above the content via ``navigation.tabs``; Material was rendering it again as a ``.md-nav__title`` at the top of the level-1 nav). - Adds a 1px vertical guide line on sub-sections that have children, via a ``::before`` pseudo-element on the level-2 nav. Bottom-stops 5px short of the nav's lower edge. Excludes Getting Started (whose single ``index.md`` child got rolled up by ``navigation.indexes`` leaving its level-2 nav empty). - Shifts sub-section labels and their children's UL 10px LEFT to tighten the layout while leaving the guide line in place. - Adds a padding-left override on label-form and direct-link-form ``.md-nav__link`` items so they align horizontally with the container-form labels. Material renders Documentation sub-sections as ``
``, which matches the base padding rule twice (once on the outer div, once on the inner a), giving the text 0.75rem of layered offset. Examples sub-sections (````) match the rule once, so without this override their text would sit 7.5px left of the Documentation equivalents. Also adds a Python-version shield to the docs homepage, mirroring the README's shield: img.shields.io/python/required- version-toml reads pyproject.toml's ``requires-python`` directly so the badge auto-updates if the floor moves. Verified across all three tabs via headless Chromium: all sub-section / top-level page text lands at 14.5px from the level-1 nav's left edge. --- docs/index.md | 1 + docs/stylesheets/extra.css | 104 +++++++++++++++++++++++++++++++++++-- 2 files changed, 101 insertions(+), 4 deletions(-) diff --git a/docs/index.md b/docs/index.md index c18138dc..2873c475 100644 --- a/docs/index.md +++ b/docs/index.md @@ -11,6 +11,7 @@ buy-in from every node. [![PyPI](https://img.shields.io/pypi/v/openarmature.svg?color=blue)](https://pypi.org/project/openarmature/){target="_blank" rel="noopener"} [![spec](https://img.shields.io/badge/dynamic/toml?url=https://raw.githubusercontent.com/LunarCommand/openarmature-python/main/pyproject.toml&query=%24.tool.openarmature.spec_version&label=spec&color=9D4EDD)](https://github.com/LunarCommand/openarmature-spec){target="_blank" rel="noopener"} +[![python](https://img.shields.io/python/required-version-toml?tomlFilePath=https://raw.githubusercontent.com/LunarCommand/openarmature-python/main/pyproject.toml&label=python&color=blue)](https://pypi.org/project/openarmature/){target="_blank" rel="noopener"} [Get started](getting-started/index.md){ .md-button .md-button--primary } [View on GitHub](https://github.com/LunarCommand/openarmature-python){ .md-button target="_blank" rel="noopener" } diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css index 3eda4f72..be908956 100644 --- a/docs/stylesheets/extra.css +++ b/docs/stylesheets/extra.css @@ -200,14 +200,110 @@ /* Indent nested nav items so section hierarchy reads at a glance. * Material's ``navigation.sections`` layout renders section labels * and their children flush-left at the same horizontal position; - * fine for short sections but hard to scan when one runs long (the - * Examples section has 11 entries). A small left-pad on the nested - * links shows the hierarchy without altering typography. Scoped to - * the primary nav so the right-hand TOC sidebar is untouched. */ + * a small left-pad on the nested links shows the hierarchy without + * altering typography. Scoped to the primary nav so the right-hand + * TOC sidebar is untouched. */ .md-nav--primary .md-nav .md-nav__link { padding-left: 0.375rem; } +/* Hide the active tab's title inside the sidebar. The top tabs + * (Documentation / Examples / Patterns) are shown above the + * content via ``navigation.tabs``; Material also renders the + * active tab's label as a ``.md-nav__title`` at the top of the + * sidebar, which is redundant. The level-1 nav is the tab's + * content; hiding its ``.md-nav__title`` drops the repeated + * label cleanly. */ +.md-nav--primary .md-nav[data-md-level="1"] > .md-nav__title { + display: none; +} + +/* Vertical guide line + extra indent next to the *children* of an + * expandable sub-section group (Concepts / Model Providers / + * Reference under Documentation; Foundations / Composition / + * etc. under Examples). Material renders the section's toggle in + * two different shapes: + * + * - ```` when the + * section has no ``index.md`` of its own (Examples' topical + * groups are like this). + * - ``