composeai 0.2.0__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. {composeai-0.2.0 → composeai-0.3.0}/.gitignore +1 -1
  2. composeai-0.3.0/PKG-INFO +145 -0
  3. composeai-0.3.0/README.md +117 -0
  4. composeai-0.3.0/docs/agents.md +144 -0
  5. composeai-0.3.0/docs/budgets.md +148 -0
  6. composeai-0.3.0/docs/composition.md +96 -0
  7. composeai-0.3.0/docs/flows.md +239 -0
  8. composeai-0.3.0/docs/index.md +81 -0
  9. composeai-0.3.0/docs/mcp.md +139 -0
  10. composeai-0.3.0/docs/observability.md +153 -0
  11. composeai-0.3.0/docs/providers.md +123 -0
  12. composeai-0.3.0/docs/testing.md +163 -0
  13. {composeai-0.2.0 → composeai-0.3.0}/pyproject.toml +4 -3
  14. {composeai-0.2.0 → composeai-0.3.0}/src/composeai/__init__.py +6 -2
  15. {composeai-0.2.0 → composeai-0.3.0}/src/composeai/agentfn.py +17 -1
  16. {composeai-0.2.0 → composeai-0.3.0}/src/composeai/cli.py +22 -32
  17. {composeai-0.2.0 → composeai-0.3.0}/src/composeai/combinators.py +33 -10
  18. {composeai-0.2.0 → composeai-0.3.0}/src/composeai/errors.py +11 -0
  19. {composeai-0.2.0 → composeai-0.3.0}/src/composeai/flow.py +49 -67
  20. composeai-0.3.0/src/composeai/mcp.py +530 -0
  21. {composeai-0.2.0 → composeai-0.3.0}/src/composeai/models/compatible.py +70 -7
  22. {composeai-0.2.0 → composeai-0.3.0}/src/composeai/runs.py +126 -10
  23. {composeai-0.2.0 → composeai-0.3.0}/src/composeai/testing.py +24 -10
  24. {composeai-0.2.0 → composeai-0.3.0}/src/composeai/tools.py +46 -5
  25. composeai-0.3.0/tests/fixtures/mcp_fixture_server.py +49 -0
  26. {composeai-0.2.0 → composeai-0.3.0}/tests/test_agent.py +33 -0
  27. {composeai-0.2.0 → composeai-0.3.0}/tests/test_agent_cache.py +53 -0
  28. {composeai-0.2.0 → composeai-0.3.0}/tests/test_cli.py +4 -3
  29. {composeai-0.2.0 → composeai-0.3.0}/tests/test_combinators.py +34 -0
  30. {composeai-0.2.0 → composeai-0.3.0}/tests/test_compatible.py +117 -1
  31. {composeai-0.2.0 → composeai-0.3.0}/tests/test_errors.py +8 -0
  32. {composeai-0.2.0 → composeai-0.3.0}/tests/test_flow.py +96 -0
  33. {composeai-0.2.0 → composeai-0.3.0}/tests/test_live_smoke.py +50 -0
  34. composeai-0.3.0/tests/test_mcp.py +48 -0
  35. composeai-0.3.0/tests/test_mcp_e2e.py +279 -0
  36. {composeai-0.2.0 → composeai-0.3.0}/tests/test_package.py +1 -1
  37. {composeai-0.2.0 → composeai-0.3.0}/tests/test_runstore.py +21 -0
  38. {composeai-0.2.0 → composeai-0.3.0}/tests/test_tools.py +70 -0
  39. composeai-0.3.0/uv.lock +1332 -0
  40. composeai-0.2.0/PKG-INFO +0 -372
  41. composeai-0.2.0/README.md +0 -348
  42. composeai-0.2.0/uv.lock +0 -674
  43. {composeai-0.2.0 → composeai-0.3.0}/.github/workflows/release.yml +0 -0
  44. {composeai-0.2.0 → composeai-0.3.0}/LICENSE +0 -0
  45. {composeai-0.2.0 → composeai-0.3.0}/compose.md +0 -0
  46. {composeai-0.2.0 → composeai-0.3.0}/examples/quickstart_agent.py +0 -0
  47. {composeai-0.2.0 → composeai-0.3.0}/examples/research_flow.py +0 -0
  48. {composeai-0.2.0 → composeai-0.3.0}/examples/streaming_chat.py +0 -0
  49. {composeai-0.2.0 → composeai-0.3.0}/scripts/release.sh +0 -0
  50. {composeai-0.2.0 → composeai-0.3.0}/src/composeai/_encoding.py +0 -0
  51. {composeai-0.2.0 → composeai-0.3.0}/src/composeai/_ids.py +0 -0
  52. {composeai-0.2.0 → composeai-0.3.0}/src/composeai/_schema.py +0 -0
  53. {composeai-0.2.0 → composeai-0.3.0}/src/composeai/events.py +0 -0
  54. {composeai-0.2.0 → composeai-0.3.0}/src/composeai/hitl.py +0 -0
  55. {composeai-0.2.0 → composeai-0.3.0}/src/composeai/messages.py +0 -0
  56. {composeai-0.2.0 → composeai-0.3.0}/src/composeai/models/__init__.py +0 -0
  57. {composeai-0.2.0 → composeai-0.3.0}/src/composeai/models/anthropic.py +0 -0
  58. {composeai-0.2.0 → composeai-0.3.0}/src/composeai/models/base.py +0 -0
  59. {composeai-0.2.0 → composeai-0.3.0}/src/composeai/models/openai.py +0 -0
  60. {composeai-0.2.0 → composeai-0.3.0}/src/composeai/models/prices.py +0 -0
  61. {composeai-0.2.0 → composeai-0.3.0}/src/composeai/models/registry.py +0 -0
  62. {composeai-0.2.0 → composeai-0.3.0}/src/composeai/py.typed +0 -0
  63. {composeai-0.2.0 → composeai-0.3.0}/src/composeai/tracing.py +0 -0
  64. {composeai-0.2.0 → composeai-0.3.0}/tests/conformance/__init__.py +0 -0
  65. {composeai-0.2.0 → composeai-0.3.0}/tests/conformance/contract.py +0 -0
  66. {composeai-0.2.0 → composeai-0.3.0}/tests/conftest.py +0 -0
  67. {composeai-0.2.0 → composeai-0.3.0}/tests/fixtures/subprocess_flow_defs.py +0 -0
  68. {composeai-0.2.0 → composeai-0.3.0}/tests/fixtures/subprocess_hitl_agent_defs.py +0 -0
  69. {composeai-0.2.0 → composeai-0.3.0}/tests/fixtures/subprocess_hitl_agent_run_a.py +0 -0
  70. {composeai-0.2.0 → composeai-0.3.0}/tests/fixtures/subprocess_hitl_agent_run_b.py +0 -0
  71. {composeai-0.2.0 → composeai-0.3.0}/tests/fixtures/subprocess_hitl_flow_defs.py +0 -0
  72. {composeai-0.2.0 → composeai-0.3.0}/tests/fixtures/subprocess_hitl_run_a.py +0 -0
  73. {composeai-0.2.0 → composeai-0.3.0}/tests/fixtures/subprocess_hitl_run_b.py +0 -0
  74. {composeai-0.2.0 → composeai-0.3.0}/tests/fixtures/subprocess_run_a.py +0 -0
  75. {composeai-0.2.0 → composeai-0.3.0}/tests/fixtures/subprocess_run_b.py +0 -0
  76. {composeai-0.2.0 → composeai-0.3.0}/tests/test_anthropic.py +0 -0
  77. {composeai-0.2.0 → composeai-0.3.0}/tests/test_budget.py +0 -0
  78. {composeai-0.2.0 → composeai-0.3.0}/tests/test_cassettes.py +0 -0
  79. {composeai-0.2.0 → composeai-0.3.0}/tests/test_encoding.py +0 -0
  80. {composeai-0.2.0 → composeai-0.3.0}/tests/test_events.py +0 -0
  81. {composeai-0.2.0 → composeai-0.3.0}/tests/test_hitl_agent.py +0 -0
  82. {composeai-0.2.0 → composeai-0.3.0}/tests/test_hitl_flow.py +0 -0
  83. {composeai-0.2.0 → composeai-0.3.0}/tests/test_hitl_subprocess.py +0 -0
  84. {composeai-0.2.0 → composeai-0.3.0}/tests/test_ids.py +0 -0
  85. {composeai-0.2.0 → composeai-0.3.0}/tests/test_messages.py +0 -0
  86. {composeai-0.2.0 → composeai-0.3.0}/tests/test_models_base.py +0 -0
  87. {composeai-0.2.0 → composeai-0.3.0}/tests/test_openai.py +0 -0
  88. {composeai-0.2.0 → composeai-0.3.0}/tests/test_prices.py +0 -0
  89. {composeai-0.2.0 → composeai-0.3.0}/tests/test_registry.py +0 -0
  90. {composeai-0.2.0 → composeai-0.3.0}/tests/test_render.py +0 -0
  91. {composeai-0.2.0 → composeai-0.3.0}/tests/test_resume_subprocess.py +0 -0
  92. {composeai-0.2.0 → composeai-0.3.0}/tests/test_schema_register.py +0 -0
  93. {composeai-0.2.0 → composeai-0.3.0}/tests/test_streaming.py +0 -0
  94. {composeai-0.2.0 → composeai-0.3.0}/tests/test_testing.py +0 -0
  95. {composeai-0.2.0 → composeai-0.3.0}/tests/test_tracing.py +0 -0
  96. {composeai-0.2.0 → composeai-0.3.0}/tests/test_tracing_sink.py +0 -0
@@ -8,5 +8,5 @@ build/
8
8
  .pytest_cache/
9
9
  .ruff_cache/
10
10
  .superpowers/
11
- docs/
11
+ plans/
12
12
  .env
@@ -0,0 +1,145 @@
1
+ Metadata-Version: 2.4
2
+ Name: composeai
3
+ Version: 0.3.0
4
+ Summary: Radically simple, functional framework for multi-agent AI workflows — typed agent functions, pipe composition, always-on local tracing, durable flows.
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.10
8
+ Requires-Dist: pydantic>=2.7
9
+ Provides-Extra: all
10
+ Requires-Dist: anthropic>=0.40; extra == 'all'
11
+ Requires-Dist: mcp<2,>=1.27; extra == 'all'
12
+ Requires-Dist: openai>=1.60; extra == 'all'
13
+ Provides-Extra: anthropic
14
+ Requires-Dist: anthropic>=0.40; extra == 'anthropic'
15
+ Provides-Extra: dev
16
+ Requires-Dist: anthropic>=0.40; extra == 'dev'
17
+ Requires-Dist: build; extra == 'dev'
18
+ Requires-Dist: mcp<2,>=1.27; extra == 'dev'
19
+ Requires-Dist: openai>=1.60; extra == 'dev'
20
+ Requires-Dist: pyright>=1.1.380; extra == 'dev'
21
+ Requires-Dist: pytest>=8; extra == 'dev'
22
+ Requires-Dist: ruff>=0.5; extra == 'dev'
23
+ Provides-Extra: mcp
24
+ Requires-Dist: mcp<2,>=1.27; extra == 'mcp'
25
+ Provides-Extra: openai
26
+ Requires-Dist: openai>=1.60; extra == 'openai'
27
+ Description-Content-Type: text/markdown
28
+
29
+ # composeai — 𝑓(𝑔(𝑥))
30
+
31
+ A radically simple, functional framework for multi-agent AI workflows.
32
+
33
+ - **Agents are typed functions.** The docstring is the system prompt, the body returns the user prompt, the return annotation is the structured output type. Calling one returns that type — or raises.
34
+ - **Composition is checked before it runs.** `pipe(researcher, copywriter)` verifies every stage boundary at build time; a wiring bug raises `CompositionTypeError` before a single API call is made.
35
+ - **Tracing is always on, and local.** Every run persists spans, token usage, and cost to a SQLite store on your disk (`./.compose`). No SaaS, no instrumentation, no opt-in — the trace is just there.
36
+ - **Flows are durable.** A `@flow` journals every step; if it crashes — or pauses on a **named interrupt** (`approve("publish")`) waiting for a human — `resume(run_id)` continues it in the same process or a brand-new one, days later, replaying finished steps without re-paying for them.
37
+ - **Every run can carry a spend cap.** `Budget(usd=..., tokens=...)` is enforced after every LLM call in a run's subtree, and stays cumulative across `resume()` — a run can't dodge its budget by crashing and getting resumed.
38
+ - **Streaming and tracing are the same event bus.** `.stream()` yields `text_delta`/`thinking_delta`/`tool_call_started`/`tool_args_delta` interleaved with the very `span_started`/`span_finished`/`run_finished` events the trace is built from — on agents, pipelines, and flows alike, so a live UI and the trace can never disagree.
39
+ - **MCP servers plug straight into `tools=`.** `compose.mcp_tools(command=..., ...)` connects to a Model Context Protocol server (stdio or streamable HTTP) and turns its tools into ordinary composeai `Tool` objects — indistinguishable from `@compose.tool` ones, including the same `requires_approval=` pause/resume.
40
+
41
+ Runtime dependencies: **pydantic + the standard library**. Provider SDKs are optional extras. Python ≥ 3.10.
42
+
43
+ ## Install
44
+
45
+ ```bash
46
+ pip install composeai # core: pydantic + stdlib only
47
+ pip install "composeai[anthropic]" # + the Anthropic SDK
48
+ pip install "composeai[openai]" # + the OpenAI SDK
49
+ pip install "composeai[all]" # both
50
+ ```
51
+
52
+ ## The 90-second tour
53
+
54
+ Define an agent as a typed function. The docstring is its system prompt, the return annotation is its structured output type:
55
+
56
+ ```python
57
+ import composeai as compose
58
+ from pydantic import BaseModel, Field
59
+
60
+
61
+ class FactSheet(BaseModel):
62
+ topic: str
63
+ key_facts: list[str] = Field(description="Three crisp, verifiable facts")
64
+
65
+
66
+ @compose.tool
67
+ def count_words(text: str) -> int:
68
+ """Count the words in a piece of text.
69
+
70
+ Args:
71
+ text: The text whose words should be counted.
72
+ """
73
+ return len(text.split())
74
+
75
+
76
+ @compose.agent(model="anthropic/claude-sonnet-5", tools=[count_words])
77
+ def researcher(topic: str) -> FactSheet:
78
+ """You are a meticulous researcher. Extract crisp, verifiable facts."""
79
+ return compose.prompt(f"Build a fact sheet about: {topic}")
80
+
81
+
82
+ facts = researcher("quantum computing") # -> FactSheet (or raises)
83
+ ```
84
+
85
+ Calling `researcher(...)` runs the whole loop and returns the validated `FactSheet`. Every call like this is automatically persisted, so you can inspect it from the command line right afterward:
86
+
87
+ ```console
88
+ $ compose trace --last
89
+ trace 01KXC6RDB8E29NZHEAC2F54M11 — ok — [$0.0150 · 2.9k tok · 3ms]
90
+ └─ ◆ researcher [$0.0150 · 2.9k tok · 3ms]
91
+ ├─ ▸ anthropic/claude-sonnet-5 [$0.0075 · 1.5k tok · 0ms]
92
+ ├─ ⚙ count_words [0ms]
93
+ └─ ▸ anthropic/claude-sonnet-5 [$0.0075 · 1.5k tok · 0ms]
94
+ ```
95
+
96
+ No accounts, no exporters, no instrumentation to wire up — the trace (and its cost) is just there, in `./.compose`, the moment the agent finishes. See [agents](docs/agents.md) for `.run()`/`.stream()`, repairs, and resilience knobs; [composition](docs/composition.md) and [flows](docs/flows.md) for wiring agents into pipelines and durable, resumable workflows; [budgets](docs/budgets.md) for the `Budget(usd=..., tokens=...)` cap shown above.
97
+
98
+ ## Documentation
99
+
100
+ | Page | What's there |
101
+ | :--- | :--- |
102
+ | [docs/index.md](docs/index.md) | Project overview, the 90-second tour, and where to go next |
103
+ | [docs/agents.md](docs/agents.md) | The `@agent` idiom, structured output and repairs, tools, resilience knobs, naming/replacing agents, `.run()`/`.stream()` |
104
+ | [docs/composition.md](docs/composition.md) | `pipe`, `aggregate`, `map`, build-time type checking, nesting combinators |
105
+ | [docs/flows.md](docs/flows.md) | `@task`/`@flow`, the journal, determinism, `resume()`, human-in-the-loop |
106
+ | [docs/providers.md](docs/providers.md) | Model strings vs `Model` instances, API keys, `openai_compatible`, pricing, reasoning-model gotchas |
107
+ | [docs/observability.md](docs/observability.md) | The local tracing model, every `compose` CLI command, `--import`, `COMPOSE_TRACE_CONTENT` |
108
+ | [docs/budgets.md](docs/budgets.md) | `Budget(usd=, tokens=)`, what counts, cumulative spend across `resume()`, `BudgetExceededError` |
109
+ | [docs/testing.md](docs/testing.md) | `FakeModel`, cassettes, `@agent(cache=True)`, `reset_registries()` |
110
+ | [docs/mcp.md](docs/mcp.md) | Connect MCP servers' tools to your agents |
111
+
112
+ ## Rules of the road
113
+
114
+ The contracts composeai holds you to — and the ones it holds itself to:
115
+
116
+ - **Flow bodies must be deterministic.** Replay works by re-running the body and substituting journaled step results in call order. Side effects, randomness, and clock reads belong inside `@task`/`@agent` steps, never in the flow body itself. Nothing detects a violation; it just won't replay correctly.
117
+ - **A step journals only after it returns.** If the process dies between a task's external side effect and its journal write, resume re-runs that task — make external side effects idempotent.
118
+ - **Journals are for pause/approval and crash recovery, not cross-release storage.** If a `@flow`'s source changes between pause and resume, `resume()` fails loud with `ResumeMismatchError` (the journal may no longer match the new call sequence); `allow_code_change=True` overrides it deliberately.
119
+ - **State lives at `COMPOSE_DIR`** (default `./.compose`). `COMPOSE_TRACE_CONTENT=0` stops *spans* from capturing input/output payloads — usage, status, and timing are always recorded regardless. This does **not** extend to anything composeai needs as functional state to actually work, all of which are written in full unconditionally, regardless of `COMPOSE_TRACE_CONTENT`: a paused agent's `agent_state` snapshot (durable pause/resume — `approve()`/`ask_human()`/`@tool(requires_approval=True)` — requires the full in-progress conversation, including tool call arguments and results, so `resume()` can continue exactly where it left off); the `@flow` journal (step results must be real to replay correctly); and the test kit's own on-disk artifacts — `record_cassette`/the `cassette` fixture and `@compose.agent(cache=True)`'s filesystem response cache both need a call's real request/response to be replayable or servable as a cache hit later. If your tools handle secrets/PII in their arguments or results, treat `{COMPOSE_DIR}` (`runs.db`, `cache/`, and any cassette files you commit) as sensitive (filesystem permissions, encryption at rest, etc.) rather than relying on `COMPOSE_TRACE_CONTENT` to keep it out of the store.
120
+ - **`temperature` is passthrough-only.** composeai never sets one for you, and modern Claude models reject sampling parameters with a 400 — leave it unset for Claude.
121
+ - **Cost is never fabricated.** Priced models get exact USD from a dated, in-repo price table; calls with no known price report `cost_usd=None`, and any total that includes them renders as a `≥$X` partial (or `-` when nothing was priceable) instead of a made-up number. USD budgets consequently can't see unpriced spend — set a token budget too if you need a hard cap.
122
+ - **Retries can re-stream.** With `retries > 0`, a provider error striking mid-stream re-runs the call from the start — consumers of `.stream()` may see the same deltas twice on one llm span. Render final outputs (or treat a fresh delta burst as a reset) if double-rendering matters.
123
+
124
+ ## vs. the alternatives
125
+
126
+ | | LangChain / LangGraph | composeai |
127
+ | :--- | :--- | :--- |
128
+ | **Core architecture** | Configuration & state graphs (`Runnable`, `StateGraph`) | Plain typed functions, composed with `pipe`/`aggregate`/`map` |
129
+ | **Learning curve** | A proprietary class ecosystem | Decorators on regular functions and pydantic types |
130
+ | **Wiring bugs surface** | At runtime, mid-graph | At composition time, before any API call |
131
+ | **Debugging** | Deeply nested framework traces | A breakpoint between two functions; local trace trees with exact costs |
132
+ | **Observability** | External/SaaS platforms, opt-in callbacks | Always-on local SQLite tracing + a CLI, zero setup |
133
+ | **Durability & HITL** | Separate checkpointer/orchestrator machinery | Journaled `@flow` + named interrupts, one `resume()` |
134
+ | **Dependencies** | Heavy transitive footprint | pydantic + stdlib; provider SDKs as optional extras |
135
+
136
+ ## Roadmap
137
+
138
+ - OpenTelemetry exporter (the span model already tracks `gen_ai.*` attribute conventions)
139
+ - Async API (`await agent(...)`, async tools)
140
+ - TypeScript sibling package
141
+ - Extended-thinking / reasoning request configuration (Anthropic `thinking` budget, OpenAI `reasoning.summary`/`encrypted_content`) -- today `ThinkingPart` only round-trips whatever a provider returns unprompted by default; there's no `ModelRequest` field to actually ask for it
142
+
143
+ ## License
144
+
145
+ MIT
@@ -0,0 +1,117 @@
1
+ # composeai — 𝑓(𝑔(𝑥))
2
+
3
+ A radically simple, functional framework for multi-agent AI workflows.
4
+
5
+ - **Agents are typed functions.** The docstring is the system prompt, the body returns the user prompt, the return annotation is the structured output type. Calling one returns that type — or raises.
6
+ - **Composition is checked before it runs.** `pipe(researcher, copywriter)` verifies every stage boundary at build time; a wiring bug raises `CompositionTypeError` before a single API call is made.
7
+ - **Tracing is always on, and local.** Every run persists spans, token usage, and cost to a SQLite store on your disk (`./.compose`). No SaaS, no instrumentation, no opt-in — the trace is just there.
8
+ - **Flows are durable.** A `@flow` journals every step; if it crashes — or pauses on a **named interrupt** (`approve("publish")`) waiting for a human — `resume(run_id)` continues it in the same process or a brand-new one, days later, replaying finished steps without re-paying for them.
9
+ - **Every run can carry a spend cap.** `Budget(usd=..., tokens=...)` is enforced after every LLM call in a run's subtree, and stays cumulative across `resume()` — a run can't dodge its budget by crashing and getting resumed.
10
+ - **Streaming and tracing are the same event bus.** `.stream()` yields `text_delta`/`thinking_delta`/`tool_call_started`/`tool_args_delta` interleaved with the very `span_started`/`span_finished`/`run_finished` events the trace is built from — on agents, pipelines, and flows alike, so a live UI and the trace can never disagree.
11
+ - **MCP servers plug straight into `tools=`.** `compose.mcp_tools(command=..., ...)` connects to a Model Context Protocol server (stdio or streamable HTTP) and turns its tools into ordinary composeai `Tool` objects — indistinguishable from `@compose.tool` ones, including the same `requires_approval=` pause/resume.
12
+
13
+ Runtime dependencies: **pydantic + the standard library**. Provider SDKs are optional extras. Python ≥ 3.10.
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ pip install composeai # core: pydantic + stdlib only
19
+ pip install "composeai[anthropic]" # + the Anthropic SDK
20
+ pip install "composeai[openai]" # + the OpenAI SDK
21
+ pip install "composeai[all]" # both
22
+ ```
23
+
24
+ ## The 90-second tour
25
+
26
+ Define an agent as a typed function. The docstring is its system prompt, the return annotation is its structured output type:
27
+
28
+ ```python
29
+ import composeai as compose
30
+ from pydantic import BaseModel, Field
31
+
32
+
33
+ class FactSheet(BaseModel):
34
+ topic: str
35
+ key_facts: list[str] = Field(description="Three crisp, verifiable facts")
36
+
37
+
38
+ @compose.tool
39
+ def count_words(text: str) -> int:
40
+ """Count the words in a piece of text.
41
+
42
+ Args:
43
+ text: The text whose words should be counted.
44
+ """
45
+ return len(text.split())
46
+
47
+
48
+ @compose.agent(model="anthropic/claude-sonnet-5", tools=[count_words])
49
+ def researcher(topic: str) -> FactSheet:
50
+ """You are a meticulous researcher. Extract crisp, verifiable facts."""
51
+ return compose.prompt(f"Build a fact sheet about: {topic}")
52
+
53
+
54
+ facts = researcher("quantum computing") # -> FactSheet (or raises)
55
+ ```
56
+
57
+ Calling `researcher(...)` runs the whole loop and returns the validated `FactSheet`. Every call like this is automatically persisted, so you can inspect it from the command line right afterward:
58
+
59
+ ```console
60
+ $ compose trace --last
61
+ trace 01KXC6RDB8E29NZHEAC2F54M11 — ok — [$0.0150 · 2.9k tok · 3ms]
62
+ └─ ◆ researcher [$0.0150 · 2.9k tok · 3ms]
63
+ ├─ ▸ anthropic/claude-sonnet-5 [$0.0075 · 1.5k tok · 0ms]
64
+ ├─ ⚙ count_words [0ms]
65
+ └─ ▸ anthropic/claude-sonnet-5 [$0.0075 · 1.5k tok · 0ms]
66
+ ```
67
+
68
+ No accounts, no exporters, no instrumentation to wire up — the trace (and its cost) is just there, in `./.compose`, the moment the agent finishes. See [agents](docs/agents.md) for `.run()`/`.stream()`, repairs, and resilience knobs; [composition](docs/composition.md) and [flows](docs/flows.md) for wiring agents into pipelines and durable, resumable workflows; [budgets](docs/budgets.md) for the `Budget(usd=..., tokens=...)` cap shown above.
69
+
70
+ ## Documentation
71
+
72
+ | Page | What's there |
73
+ | :--- | :--- |
74
+ | [docs/index.md](docs/index.md) | Project overview, the 90-second tour, and where to go next |
75
+ | [docs/agents.md](docs/agents.md) | The `@agent` idiom, structured output and repairs, tools, resilience knobs, naming/replacing agents, `.run()`/`.stream()` |
76
+ | [docs/composition.md](docs/composition.md) | `pipe`, `aggregate`, `map`, build-time type checking, nesting combinators |
77
+ | [docs/flows.md](docs/flows.md) | `@task`/`@flow`, the journal, determinism, `resume()`, human-in-the-loop |
78
+ | [docs/providers.md](docs/providers.md) | Model strings vs `Model` instances, API keys, `openai_compatible`, pricing, reasoning-model gotchas |
79
+ | [docs/observability.md](docs/observability.md) | The local tracing model, every `compose` CLI command, `--import`, `COMPOSE_TRACE_CONTENT` |
80
+ | [docs/budgets.md](docs/budgets.md) | `Budget(usd=, tokens=)`, what counts, cumulative spend across `resume()`, `BudgetExceededError` |
81
+ | [docs/testing.md](docs/testing.md) | `FakeModel`, cassettes, `@agent(cache=True)`, `reset_registries()` |
82
+ | [docs/mcp.md](docs/mcp.md) | Connect MCP servers' tools to your agents |
83
+
84
+ ## Rules of the road
85
+
86
+ The contracts composeai holds you to — and the ones it holds itself to:
87
+
88
+ - **Flow bodies must be deterministic.** Replay works by re-running the body and substituting journaled step results in call order. Side effects, randomness, and clock reads belong inside `@task`/`@agent` steps, never in the flow body itself. Nothing detects a violation; it just won't replay correctly.
89
+ - **A step journals only after it returns.** If the process dies between a task's external side effect and its journal write, resume re-runs that task — make external side effects idempotent.
90
+ - **Journals are for pause/approval and crash recovery, not cross-release storage.** If a `@flow`'s source changes between pause and resume, `resume()` fails loud with `ResumeMismatchError` (the journal may no longer match the new call sequence); `allow_code_change=True` overrides it deliberately.
91
+ - **State lives at `COMPOSE_DIR`** (default `./.compose`). `COMPOSE_TRACE_CONTENT=0` stops *spans* from capturing input/output payloads — usage, status, and timing are always recorded regardless. This does **not** extend to anything composeai needs as functional state to actually work, all of which are written in full unconditionally, regardless of `COMPOSE_TRACE_CONTENT`: a paused agent's `agent_state` snapshot (durable pause/resume — `approve()`/`ask_human()`/`@tool(requires_approval=True)` — requires the full in-progress conversation, including tool call arguments and results, so `resume()` can continue exactly where it left off); the `@flow` journal (step results must be real to replay correctly); and the test kit's own on-disk artifacts — `record_cassette`/the `cassette` fixture and `@compose.agent(cache=True)`'s filesystem response cache both need a call's real request/response to be replayable or servable as a cache hit later. If your tools handle secrets/PII in their arguments or results, treat `{COMPOSE_DIR}` (`runs.db`, `cache/`, and any cassette files you commit) as sensitive (filesystem permissions, encryption at rest, etc.) rather than relying on `COMPOSE_TRACE_CONTENT` to keep it out of the store.
92
+ - **`temperature` is passthrough-only.** composeai never sets one for you, and modern Claude models reject sampling parameters with a 400 — leave it unset for Claude.
93
+ - **Cost is never fabricated.** Priced models get exact USD from a dated, in-repo price table; calls with no known price report `cost_usd=None`, and any total that includes them renders as a `≥$X` partial (or `-` when nothing was priceable) instead of a made-up number. USD budgets consequently can't see unpriced spend — set a token budget too if you need a hard cap.
94
+ - **Retries can re-stream.** With `retries > 0`, a provider error striking mid-stream re-runs the call from the start — consumers of `.stream()` may see the same deltas twice on one llm span. Render final outputs (or treat a fresh delta burst as a reset) if double-rendering matters.
95
+
96
+ ## vs. the alternatives
97
+
98
+ | | LangChain / LangGraph | composeai |
99
+ | :--- | :--- | :--- |
100
+ | **Core architecture** | Configuration & state graphs (`Runnable`, `StateGraph`) | Plain typed functions, composed with `pipe`/`aggregate`/`map` |
101
+ | **Learning curve** | A proprietary class ecosystem | Decorators on regular functions and pydantic types |
102
+ | **Wiring bugs surface** | At runtime, mid-graph | At composition time, before any API call |
103
+ | **Debugging** | Deeply nested framework traces | A breakpoint between two functions; local trace trees with exact costs |
104
+ | **Observability** | External/SaaS platforms, opt-in callbacks | Always-on local SQLite tracing + a CLI, zero setup |
105
+ | **Durability & HITL** | Separate checkpointer/orchestrator machinery | Journaled `@flow` + named interrupts, one `resume()` |
106
+ | **Dependencies** | Heavy transitive footprint | pydantic + stdlib; provider SDKs as optional extras |
107
+
108
+ ## Roadmap
109
+
110
+ - OpenTelemetry exporter (the span model already tracks `gen_ai.*` attribute conventions)
111
+ - Async API (`await agent(...)`, async tools)
112
+ - TypeScript sibling package
113
+ - Extended-thinking / reasoning request configuration (Anthropic `thinking` budget, OpenAI `reasoning.summary`/`encrypted_content`) -- today `ThinkingPart` only round-trips whatever a provider returns unprompted by default; there's no `ModelRequest` field to actually ask for it
114
+
115
+ ## License
116
+
117
+ MIT
@@ -0,0 +1,144 @@
1
+ # Agents
2
+
3
+ An agent is a plain Python function decorated with `@compose.agent`: its docstring becomes the system prompt, its body returns the user prompt, and its return-type annotation becomes the structured output type that the model's reply is validated into.
4
+
5
+ ## The `@agent` idiom
6
+
7
+ ```python
8
+ import composeai as compose
9
+ from pydantic import BaseModel, Field
10
+
11
+
12
+ class FactSheet(BaseModel):
13
+ topic: str
14
+ key_facts: list[str] = Field(description="Three crisp, verifiable facts")
15
+
16
+
17
+ @compose.tool
18
+ def count_words(text: str) -> int:
19
+ """Count the words in a piece of text.
20
+
21
+ Args:
22
+ text: The text whose words should be counted.
23
+ """
24
+ return len(text.split())
25
+
26
+
27
+ @compose.agent(model="anthropic/claude-sonnet-5", tools=[count_words])
28
+ def researcher(topic: str) -> FactSheet:
29
+ """You are a meticulous researcher. Extract crisp, verifiable facts."""
30
+ return compose.prompt(f"Build a fact sheet about: {topic}")
31
+
32
+
33
+ facts = researcher("quantum computing") # -> FactSheet (or raises)
34
+ ```
35
+
36
+ The decorated function *is* the agent: docstring → system prompt, body → user prompt (or a full `list[Message]` conversation), return annotation → structured output schema, validated back into that type. `compose.prompt(...)` marks the body's returned value as the prompt — a typed no-op (declared `-> Any`) that keeps static type checkers happy about a body returning a `str` where the annotation promises `FactSheet`. Returning a bare `str` (or `list[Message]`) works identically at runtime; `prompt()` only exists for type-checker ergonomics. Tools run in a loop until the model produces a final answer.
37
+
38
+ `model=` is the only required argument, and it's keyword-only: either a `"provider/model-id"` string (resolved lazily) or an existing `Model` instance — including `FakeModel` from `composeai.testing`, for tests that need no network or provider SDK.
39
+
40
+ ## Structured output and `max_repairs=`
41
+
42
+ Failed structured output doesn't have to be fatal. With `@agent(max_repairs=2)`, if the model's final reply fails JSON parsing or schema validation, composeai appends the validation error as a corrective user message and re-asks — up to `max_repairs` times — instead of raising immediately:
43
+
44
+ ```python
45
+ @compose.agent(model="anthropic/claude-sonnet-5", max_repairs=2)
46
+ def researcher(topic: str) -> FactSheet:
47
+ """You are a meticulous researcher. Extract crisp, verifiable facts."""
48
+ return compose.prompt(f"Build a fact sheet about: {topic}")
49
+ ```
50
+
51
+ Each repair is a full-price LLM turn — the whole conversation is re-sent — and counts against `max_turns`, so it isn't free, but it's far cheaper (and more effective against small or local models) than a cold re-run. The default is `max_repairs=0` (fail fast). Every validation failure that reaches you, repaired or not, comes back as a `ComposeError` — a raw pydantic `ValidationError` never leaks out of `@agent`.
52
+
53
+ ## Tools
54
+
55
+ `@compose.tool` turns a plain, typed function into a model-callable tool. It builds a strict JSON Schema from the function's signature and parses a Google-style docstring for the tool's own description and its per-argument descriptions:
56
+
57
+ ```python
58
+ @compose.tool
59
+ def search_docs(query: str, limit: int = 5) -> str:
60
+ """Search internal documentation for matching pages.
61
+
62
+ Args:
63
+ query: The search query.
64
+ limit: Maximum number of results to return.
65
+ """
66
+ return f"{limit} results for {query!r}"
67
+ ```
68
+
69
+ Everything before a line reading exactly `Args:` becomes the tool description; each `name: description` line under `Args:` becomes that parameter's schema description. A tool with no docstring (and no explicit `description=`) raises `ConfigError` at decoration time — the model relies on the description to know when to call it.
70
+
71
+ `@tool(requires_approval=True)` gates the tool behind a human: the agent pauses mid-loop until `resume(run_id, answers={"tool_name": True})` (or `False` to deny — the model sees `"denied by user"` and carries on). See [flows](flows.md) for the full human-in-the-loop story.
72
+
73
+ `@tool(timeout=...)` (seconds) bounds one execution of the tool body. A timed-out call surfaces to the model as an `is_error` tool result — the agent keeps running and the model can react — it never aborts the run:
74
+
75
+ ```python
76
+ @compose.tool(timeout=5.0)
77
+ def fetch_url(url: str) -> str:
78
+ """Fetch a URL's contents.
79
+
80
+ Args:
81
+ url: The URL to fetch.
82
+ """
83
+ ...
84
+ ```
85
+
86
+ When the model requests several tool calls in one turn, they run in parallel with no blanket bound on the batch as a whole — `@tool(timeout=...)` is the only per-call guard, so an individual tool with no timeout set can run indefinitely alongside its siblings.
87
+
88
+ Tools can also come from MCP servers — see [mcp](mcp.md).
89
+
90
+ ## Resilience knobs
91
+
92
+ All of these are keyword-only arguments to `@compose.agent(...)`:
93
+
94
+ | Argument | Default | What it does |
95
+ | :--- | :--- | :--- |
96
+ | `retries` | `0` | Retry a failed provider call this many times before giving up (or falling back). |
97
+ | `fallback` | `None` | A second `"provider/model-id"` string or `Model`, resolved lazily and used only if every `retries` attempt against the primary model fails. |
98
+ | `timeout` | `None` | Seconds. Checked at turn boundaries only; an in-flight model call is never interrupted. Raises `AgentTimeoutError`. |
99
+ | `max_turns` | `10` | Maximum LLM turns (including repair turns) before raising `MaxTurnsExceededError`. |
100
+ | `max_tokens` | `16000` | Passed to the model on every call; hitting it before the response finishes raises `ComposeError`. |
101
+ | `temperature` | `None` | Passthrough-only — composeai never sets one for you. Modern Claude models reject sampling parameters outright, so leave it unset for Claude. |
102
+
103
+ Note that `@agent(timeout=...)` is unrelated to a model constructor's own `timeout=`: the agent's `timeout` is a turn-boundary watchdog, while the model's `timeout` bounds each individual HTTP request at the SDK-client level. See [providers](providers.md).
104
+
105
+ ## `name=` and `replace=`
106
+
107
+ `@agent(name=...)` overrides the registered/routing name (default: the function's `__name__`) — useful when two agents would otherwise share a function name. Agent names must be unique per process, since `resume()` uses the name to route a paused or crashed standalone agent run back to its definition; a duplicate name raises `ConfigError` at decoration time.
108
+
109
+ `@agent(replace=True)` re-binds an existing name instead of raising — handy for runtime-bound factories and test fixtures. **Warning:** standalone-agent resume has no fingerprint/staleness check (unlike `@flow`), so a paused run resumed after a `replace=True` rebind continues silently against the *new* definition.
110
+
111
+ ## `.run()` vs calling vs `.stream()`
112
+
113
+ Calling the agent directly (`researcher("quantum computing")`) is sugar for `researcher.run("quantum computing").output`. Need more than the output? `.run()` returns the whole `Run`:
114
+
115
+ ```python
116
+ run = researcher.run("quantum computing")
117
+ run.output # the FactSheet
118
+ run.usage # tokens + USD cost, rolled up across every LLM call
119
+ run.trace.print() # the trace tree
120
+ ```
121
+
122
+ Both the call form and `.run()` accept an optional keyword-only `budget`, enforced across every LLM call in the run — see [budgets](budgets.md):
123
+
124
+ ```python
125
+ researcher.run("quantum computing", budget=compose.Budget(usd=0.50, tokens=200_000))
126
+ ```
127
+
128
+ `.stream(...)` runs the same loop on a background thread and returns a `RunStream` for live consumption — token deltas interleaved with the same span events tracing already produces:
129
+
130
+ ```python
131
+ stream = researcher.stream("quantum computing")
132
+
133
+ for event in stream:
134
+ if event.kind == "text_delta" and event.text:
135
+ print(event.text, end="", flush=True)
136
+
137
+ stream.run.trace.print() # blocks until settled; the full trace, same events
138
+ ```
139
+
140
+ The full vocabulary an event's `kind` can take (`composeai.events.Event.kind`) is `span_started`, `text_delta`, `thinking_delta`, `tool_call_started`, `tool_args_delta`, `tool_call_finished`, `span_finished`, `paused`, and `run_finished` — the same events tracing is built from, on agents, pipelines, and flows alike, so a live UI and `compose trace` can never disagree about what happened.
141
+
142
+ ## See also
143
+
144
+ [composition](composition.md) wires agents together into pipelines with build-time type checking; [flows](flows.md) makes a sequence of agent calls durable and resumable; [testing](testing.md) covers `FakeModel` for testing agents with no network.
@@ -0,0 +1,148 @@
1
+ # Budgets
2
+
3
+ `Budget(usd=..., tokens=...)` caps spend across every LLM call in a run's subtree — pass it to `.run()`/`.stream()` (or the bare call form) on an `@agent`, a `pipe`/`aggregate`, or a `@flow`, and composeai raises `BudgetExceededError` the instant a call pushes the running total past the cap.
4
+
5
+ ## `Budget(usd=, tokens=)`
6
+
7
+ ```python
8
+ import composeai as compose
9
+ from composeai import Budget
10
+
11
+
12
+ @compose.agent(model="anthropic/claude-sonnet-5")
13
+ def researcher(topic: str) -> str:
14
+ """You are a researcher."""
15
+ return compose.prompt(f"Summarize: {topic}")
16
+
17
+
18
+ researcher.run("quantum computing", budget=Budget(usd=0.50, tokens=200_000))
19
+ ```
20
+
21
+ At least one of `usd`/`tokens` must be set — an empty `Budget()` couldn't enforce anything, so the constructor raises `ConfigError` immediately. Pass either alone, or both:
22
+
23
+ ```python
24
+ Budget(usd=1.0) # dollars only
25
+ Budget(tokens=100_000) # tokens only
26
+ Budget(usd=1.0, tokens=100_000) # both -- either crossing the line trips it
27
+ ```
28
+
29
+ Enforcement happens after every LLM call across the whole run's subtree, not just at the top level: an `@agent` nested inside a `pipe`, an `aggregate` branch, or a step inside a `@flow` all count against a budget passed to an enclosing `.run()` call. `check_budgets()` walks every active budget on the stack each time — a nested, tighter budget and an enclosing, looser one are both live at once (see "Nesting" below).
30
+
31
+ ## What counts
32
+
33
+ `tokens` is input+output tokens combined, summed across every LLM call in the subtree. This is exact regardless of pricing — a token budget sees every call's usage no matter what model it hit.
34
+
35
+ `usd` only counts calls with a known price. An adapter that can't price a call reports `Usage(cost_usd=None)`, and that call's cost is treated as `0` for budgeting purposes — a `usd` budget simply can't see spend it has no price for. This is the same unpriced-model caveat `compose costs` has: cost is never fabricated, so a call with no registered price is invisible to `Budget(usd=...)`, not silently counted as free spend against you.
36
+
37
+ If you're on an OpenAI-compatible server with a real bill (ollama.com, a hosted vLLM instance) and want `usd` to actually see that spend, register a price — either `input_price=`/`output_price=` on `compose.openai_compatible(...)`, or `composeai.register_price(provider, model, composeai.ModelPrice(input=..., output=...))` directly. See [providers](providers.md) for both forms. **Pass `tokens` too if you need a hard cap regardless of pricing** — it's the one budget dimension that never depends on a price table.
38
+
39
+ ## Cumulative spend across `resume()`
40
+
41
+ A `budget=` passed to a run's original `.run()` call stays enforced across every later `resume()` of that run: spend already persisted by earlier attempts counts against the cap, and replayed steps themselves cost nothing (they don't re-issue LLM calls, so they add zero to the running total). A run can't dodge its budget by pausing or crashing and getting resumed with the clock reset.
42
+
43
+ Worked example — a two-step flow under a 15-token budget, each step scripted to cost exactly 10 tokens (5 in + 5 out) via `FakeModel`:
44
+
45
+ ```python
46
+ import composeai as compose
47
+ from composeai import Budget, resume
48
+ from composeai.messages import Usage
49
+ from composeai.testing import FakeModel
50
+
51
+ model = FakeModel(["first", "second"], usage=Usage(input_tokens=5, output_tokens=5))
52
+
53
+
54
+ @compose.agent(model=model)
55
+ def spender(prompt: str) -> str:
56
+ """Spend some tokens."""
57
+ return prompt
58
+
59
+
60
+ @compose.flow
61
+ def two_step() -> str:
62
+ spender("one") # 10 tokens -- lifetime total 10, under the 15 cap
63
+ if not compose.approve("continue"):
64
+ return "stopped"
65
+ spender("two") # 10 more -- lifetime total 20, over the cap
66
+ return "done"
67
+
68
+
69
+ run = two_step.run(budget=Budget(tokens=15))
70
+ run.status # "paused" -- the first spend landed at 10, still under the cap
71
+
72
+ # resume() re-attempts the flow: the first step replays (0 new tokens,
73
+ # journaled), then the second step actually runs, adding 10 more on top of
74
+ # the persisted 10 from the paused attempt -- lifetime total 20 exceeds the
75
+ # 15-token cap, so this raises instead of quietly completing:
76
+ resume(run.id, answers={"continue": True}) # raises BudgetExceededError
77
+ ```
78
+
79
+ Without this accumulation, a resumed attempt would start counting from zero and could complete happily inside a cap the run's *total* spend has already blown past. The same rule applies one level down: a nested `@agent`'s *own* `budget=` (passed on its own `.run()` call, not the flow's) is cumulative across a pause/resume of that same step specifically — only that step's earlier attempts count against its own cap, never a sibling step's spend.
80
+
81
+ ## Per-call agent budgets vs. flow budgets (nesting)
82
+
83
+ A budget passed to an `@agent`'s own `.run()`/`.stream()` call and a budget passed to an enclosing `pipe`/`aggregate`/`@flow`'s `.run()` are both enforced simultaneously — nesting doesn't replace the outer cap, it adds a second one:
84
+
85
+ ```python
86
+ import composeai as compose
87
+ from composeai import Budget
88
+ from composeai.messages import Usage
89
+ from composeai.testing import FakeModel
90
+
91
+ inner_model = FakeModel(["inner out"], usage=Usage(input_tokens=10, output_tokens=10))
92
+ outer_model = FakeModel(["outer out"], usage=Usage(input_tokens=10, output_tokens=10))
93
+
94
+
95
+ @compose.agent(model=inner_model)
96
+ def inner_agent(x: str) -> str:
97
+ """Inner."""
98
+ return x
99
+
100
+
101
+ @compose.agent(model=outer_model)
102
+ def outer_agent(x: str) -> str:
103
+ """Outer."""
104
+ return x
105
+
106
+
107
+ def middle_stage(x: str) -> str:
108
+ # A tighter budget than the enclosing pipe's -- both are checked.
109
+ run = inner_agent.run(x, budget=Budget(tokens=5))
110
+ return run.output
111
+
112
+
113
+ pipeline = compose.pipe(outer_agent, middle_stage)
114
+ pipeline.run("go", budget=Budget(tokens=1000)) # generous outer cap...
115
+ # ...but inner_agent's own 5-token budget trips first, since it's tighter.
116
+ ```
117
+
118
+ Whichever budget's cap is crossed first raises — an inner, tighter budget commonly trips before an outer, looser one ever would, but there's no ordering rule beyond "whoever crosses their own line first."
119
+
120
+ ## `resume(budget=)` override
121
+
122
+ `resume(run_id, budget=...)` replaces the run's stored budget for this attempt **and every later one** — a plain last-write-wins update to the row, not journaled (the journal is first-write-wins, which would otherwise freeze the very first override forever):
123
+
124
+ ```python
125
+ resume(run.id, answers={"continue": True}, budget=Budget(usd=5.0))
126
+ ```
127
+
128
+ Prior attempts' real spend still counts against the new cap — raising the limit doesn't erase what was already spent, it only moves the ceiling. Omitting `budget=` (the default, `None`) keeps whatever budget was already stored; there's no way to *clear* a budget via `resume()` once one is set.
129
+
130
+ ## `BudgetExceededError` handling
131
+
132
+ `BudgetExceededError` is a `ComposeError`, raised by the budget check immediately after the LLM call that crossed the line — the call itself already happened (and was already billed/journaled); the error just stops the run from making another one. Its message names the span that tripped it, plus the configured cap and the usage that exceeded it:
133
+
134
+ ```python
135
+ from composeai.errors import BudgetExceededError
136
+
137
+ try:
138
+ researcher.run("quantum computing", budget=Budget(usd=0.50))
139
+ except BudgetExceededError as exc:
140
+ print(exc)
141
+ # budget exceeded on agent 'researcher': usd budget=0.5, used=$0.6231
142
+ ```
143
+
144
+ Inside a `@flow`, an uncaught `BudgetExceededError` fails that run the same way any other uncaught exception would — the run's row is marked `"failed"` and the exception propagates out of `.run()`/`resume()`. A common pattern is to catch it at the call site, inspect what already ran (`compose trace <run_id>`), and either raise the cap and `resume()`, or accept the partial result and stop.
145
+
146
+ ## See also
147
+
148
+ [agents](agents.md) covers `.run()`/`.stream()`'s other keyword-only arguments; [composition](composition.md) covers `budget=` on a top-level `pipe`/`aggregate` call; [flows](flows.md) covers `resume()` in full, including `answers=`/`allow_code_change=`; [providers](providers.md) covers `input_price=`/`register_price` for making `usd` spend visible on an unpriced or compatible-server model.