composeai 0.1.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 (78) hide show
  1. composeai-0.1.0/.gitignore +11 -0
  2. composeai-0.1.0/LICENSE +21 -0
  3. composeai-0.1.0/PKG-INFO +299 -0
  4. composeai-0.1.0/README.md +275 -0
  5. composeai-0.1.0/compose.md +121 -0
  6. composeai-0.1.0/examples/quickstart_agent.py +72 -0
  7. composeai-0.1.0/examples/research_flow.py +103 -0
  8. composeai-0.1.0/examples/streaming_chat.py +45 -0
  9. composeai-0.1.0/pyproject.toml +41 -0
  10. composeai-0.1.0/src/composeai/__init__.py +33 -0
  11. composeai-0.1.0/src/composeai/_encoding.py +240 -0
  12. composeai-0.1.0/src/composeai/_ids.py +41 -0
  13. composeai-0.1.0/src/composeai/_schema.py +147 -0
  14. composeai-0.1.0/src/composeai/agentfn.py +1177 -0
  15. composeai-0.1.0/src/composeai/cli.py +885 -0
  16. composeai-0.1.0/src/composeai/combinators.py +535 -0
  17. composeai-0.1.0/src/composeai/errors.py +96 -0
  18. composeai-0.1.0/src/composeai/events.py +172 -0
  19. composeai-0.1.0/src/composeai/flow.py +591 -0
  20. composeai-0.1.0/src/composeai/hitl.py +113 -0
  21. composeai-0.1.0/src/composeai/messages.py +202 -0
  22. composeai-0.1.0/src/composeai/models/__init__.py +3 -0
  23. composeai-0.1.0/src/composeai/models/anthropic.py +485 -0
  24. composeai-0.1.0/src/composeai/models/base.py +128 -0
  25. composeai-0.1.0/src/composeai/models/compatible.py +589 -0
  26. composeai-0.1.0/src/composeai/models/openai.py +638 -0
  27. composeai-0.1.0/src/composeai/models/prices.py +127 -0
  28. composeai-0.1.0/src/composeai/models/registry.py +105 -0
  29. composeai-0.1.0/src/composeai/py.typed +0 -0
  30. composeai-0.1.0/src/composeai/runs.py +1426 -0
  31. composeai-0.1.0/src/composeai/testing.py +653 -0
  32. composeai-0.1.0/src/composeai/tools.py +301 -0
  33. composeai-0.1.0/src/composeai/tracing.py +625 -0
  34. composeai-0.1.0/tests/conformance/__init__.py +0 -0
  35. composeai-0.1.0/tests/conformance/contract.py +141 -0
  36. composeai-0.1.0/tests/conftest.py +58 -0
  37. composeai-0.1.0/tests/fixtures/subprocess_flow_defs.py +62 -0
  38. composeai-0.1.0/tests/fixtures/subprocess_hitl_agent_defs.py +34 -0
  39. composeai-0.1.0/tests/fixtures/subprocess_hitl_agent_run_a.py +36 -0
  40. composeai-0.1.0/tests/fixtures/subprocess_hitl_agent_run_b.py +39 -0
  41. composeai-0.1.0/tests/fixtures/subprocess_hitl_flow_defs.py +42 -0
  42. composeai-0.1.0/tests/fixtures/subprocess_hitl_run_a.py +29 -0
  43. composeai-0.1.0/tests/fixtures/subprocess_hitl_run_b.py +31 -0
  44. composeai-0.1.0/tests/fixtures/subprocess_run_a.py +18 -0
  45. composeai-0.1.0/tests/fixtures/subprocess_run_b.py +33 -0
  46. composeai-0.1.0/tests/test_agent.py +754 -0
  47. composeai-0.1.0/tests/test_agent_cache.py +195 -0
  48. composeai-0.1.0/tests/test_anthropic.py +1101 -0
  49. composeai-0.1.0/tests/test_budget.py +283 -0
  50. composeai-0.1.0/tests/test_cassettes.py +420 -0
  51. composeai-0.1.0/tests/test_cli.py +1181 -0
  52. composeai-0.1.0/tests/test_combinators.py +685 -0
  53. composeai-0.1.0/tests/test_compatible.py +963 -0
  54. composeai-0.1.0/tests/test_encoding.py +337 -0
  55. composeai-0.1.0/tests/test_errors.py +89 -0
  56. composeai-0.1.0/tests/test_events.py +191 -0
  57. composeai-0.1.0/tests/test_flow.py +952 -0
  58. composeai-0.1.0/tests/test_hitl_agent.py +633 -0
  59. composeai-0.1.0/tests/test_hitl_flow.py +345 -0
  60. composeai-0.1.0/tests/test_hitl_subprocess.py +154 -0
  61. composeai-0.1.0/tests/test_ids.py +51 -0
  62. composeai-0.1.0/tests/test_live_smoke.py +82 -0
  63. composeai-0.1.0/tests/test_messages.py +258 -0
  64. composeai-0.1.0/tests/test_models_base.py +160 -0
  65. composeai-0.1.0/tests/test_openai.py +1240 -0
  66. composeai-0.1.0/tests/test_package.py +12 -0
  67. composeai-0.1.0/tests/test_prices.py +169 -0
  68. composeai-0.1.0/tests/test_registry.py +152 -0
  69. composeai-0.1.0/tests/test_render.py +289 -0
  70. composeai-0.1.0/tests/test_resume_subprocess.py +96 -0
  71. composeai-0.1.0/tests/test_runstore.py +684 -0
  72. composeai-0.1.0/tests/test_schema_register.py +256 -0
  73. composeai-0.1.0/tests/test_streaming.py +383 -0
  74. composeai-0.1.0/tests/test_testing.py +329 -0
  75. composeai-0.1.0/tests/test_tools.py +486 -0
  76. composeai-0.1.0/tests/test_tracing.py +553 -0
  77. composeai-0.1.0/tests/test_tracing_sink.py +126 -0
  78. composeai-0.1.0/uv.lock +674 -0
@@ -0,0 +1,11 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .compose/
8
+ .pytest_cache/
9
+ .ruff_cache/
10
+ .superpowers/
11
+ docs/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Alexander Nordgren
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,299 @@
1
+ Metadata-Version: 2.4
2
+ Name: composeai
3
+ Version: 0.1.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: openai>=1.60; extra == 'all'
12
+ Provides-Extra: anthropic
13
+ Requires-Dist: anthropic>=0.40; extra == 'anthropic'
14
+ Provides-Extra: dev
15
+ Requires-Dist: anthropic>=0.40; extra == 'dev'
16
+ Requires-Dist: build; extra == 'dev'
17
+ Requires-Dist: openai>=1.60; extra == 'dev'
18
+ Requires-Dist: pyright>=1.1.380; extra == 'dev'
19
+ Requires-Dist: pytest>=8; extra == 'dev'
20
+ Requires-Dist: ruff>=0.5; extra == 'dev'
21
+ Provides-Extra: openai
22
+ Requires-Dist: openai>=1.60; extra == 'openai'
23
+ Description-Content-Type: text/markdown
24
+
25
+ # composeai — 𝑓(𝑔(𝑥))
26
+
27
+ A radically simple, functional framework for multi-agent AI workflows.
28
+
29
+ - **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.
30
+ - **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.
31
+ - **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:
32
+
33
+ ```
34
+ trace 01KXC6RDB8E29NZHEAC2F54M11 — ok — [$0.0150 · 2.9k tok · 3ms]
35
+ └─ ◆ researcher [$0.0150 · 2.9k tok · 3ms]
36
+ ├─ ▸ anthropic/claude-sonnet-5 [$0.0075 · 1.5k tok · 0ms]
37
+ ├─ ⚙ count_words [0ms]
38
+ └─ ▸ anthropic/claude-sonnet-5 [$0.0075 · 1.5k tok · 0ms]
39
+ ```
40
+
41
+ - **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.
42
+ - **Streaming and tracing are the same event bus.** `.stream()` yields token deltas interleaved with the very span events the trace is built from, so a live UI and the trace can never disagree.
43
+
44
+ Runtime dependencies: **pydantic + the standard library**. Provider SDKs are optional extras. Python ≥ 3.10.
45
+
46
+ ## Install
47
+
48
+ ```bash
49
+ pip install composeai # core: pydantic + stdlib only
50
+ pip install "composeai[anthropic]" # + the Anthropic SDK
51
+ pip install "composeai[openai]" # + the OpenAI SDK
52
+ pip install "composeai[all]" # both
53
+ ```
54
+
55
+ ## Quickstart
56
+
57
+ ```python
58
+ import composeai as compose
59
+ from pydantic import BaseModel, Field
60
+
61
+
62
+ class FactSheet(BaseModel):
63
+ topic: str
64
+ key_facts: list[str] = Field(description="Three crisp, verifiable facts")
65
+
66
+
67
+ @compose.tool
68
+ def count_words(text: str) -> int:
69
+ """Count the words in a piece of text.
70
+
71
+ Args:
72
+ text: The text whose words should be counted.
73
+ """
74
+ return len(text.split())
75
+
76
+
77
+ @compose.agent(model="anthropic/claude-sonnet-5", tools=[count_words])
78
+ def researcher(topic: str) -> FactSheet:
79
+ """You are a meticulous researcher. Extract crisp, verifiable facts."""
80
+ return compose.prompt(f"Build a fact sheet about: {topic}")
81
+
82
+
83
+ facts = researcher("quantum computing") # -> FactSheet (or raises)
84
+ ```
85
+
86
+ 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 the type. `compose.prompt(...)` marks the body's return value as the prompt — it's a typed no-op that keeps static type checkers happy about the body returning a prompt where the annotation declares the output type (a bare `str` works identically at runtime). Tools run in a loop until the model produces the final answer.
87
+
88
+ Need more than the output? `.run()` returns the whole `Run`:
89
+
90
+ ```python
91
+ run = researcher.run("quantum computing")
92
+ run.output # the FactSheet
93
+ run.usage # tokens + USD cost, rolled up across every LLM call
94
+ run.trace.print() # the tree shown above
95
+ ```
96
+
97
+ Every agent also takes an optional spend cap: `researcher.run(topic, budget=compose.Budget(usd=0.50, tokens=200_000))` raises `BudgetExceededError` at the first LLM-call boundary past the cap.
98
+
99
+ ## Composition
100
+
101
+ ```python
102
+ @compose.agent(model="openai/gpt-5.6-luna")
103
+ def copywriter(sheet: FactSheet) -> str:
104
+ """Turn a fact sheet into one punchy line."""
105
+ return f"Write one punchy line from: {sheet.model_dump_json()}"
106
+
107
+
108
+ write_post = compose.pipe(researcher, copywriter) # types checked HERE
109
+ post = write_post("quantum computing")
110
+ ```
111
+
112
+ A mismatch never gets to run (and never spends a token):
113
+
114
+ ```python
115
+ compose.pipe(researcher, researcher)
116
+ # CompositionTypeError: pipe(): stage 1 (researcher) returns FactSheet
117
+ # but stage 2 (researcher) expects str
118
+ ```
119
+
120
+ Fan-out is a function too — branches run in parallel threads:
121
+
122
+ ```python
123
+ audits = compose.aggregate(words=lambda s: len(s.split()), chars=len)
124
+ audits("count these words") # {'words': 3, 'chars': 17}
125
+
126
+ compose.map(summarize, sources) # one stage over many items, order preserved
127
+ ```
128
+
129
+ Stages are agents, pipelines, aggregates, or plain Python callables — routing is an `if` statement, not a graph edge class.
130
+
131
+ `aggregate()`/`map()`/an agent's parallel tool calls have no *per-branch* timeout of their own — a single hung branch (a tool body blocked on a network call with no read timeout, a provider HTTP call that never returns) blocks the whole combinator, and therefore the enclosing run, until it finishes or the process is killed. Give an individual stage its own bound with `@task(timeout=...)` (or build one into a tool/agent body) if it needs one; there's no combinator-level equivalent.
132
+
133
+ ## Durable flows & human-in-the-loop
134
+
135
+ ```python
136
+ @compose.flow
137
+ def research(topic: str) -> str:
138
+ sources = fetch_sources(topic) # journaled step
139
+ summaries = compose.map(summarize, sources) # parallel, journaled steps
140
+ draft = editor(summaries) # a whole agent run = one step
141
+ if compose.approve("publish", payload={"draft": draft}): # named interrupt
142
+ return publish(draft)
143
+ return f"kept as draft: {draft}"
144
+
145
+
146
+ run = research.run("quantum computing")
147
+ run.status # "paused"
148
+ run.pending # id='publish' kind='approval' question=None payload={'draft': ...}
149
+ ```
150
+
151
+ Pausing is not an error — the process may simply exit. Later, from **any** process:
152
+
153
+ ```python
154
+ run = compose.resume(run.id, answers={"publish": True})
155
+ run.status # "completed"
156
+ ```
157
+
158
+ On resume, finished steps replay from the journal (the `editor` agent's LLM calls are not re-made and not re-billed); only the unfinished tail runs:
159
+
160
+ ```
161
+ └─ ▶ research [3ms]
162
+ ├─ • fetch_sources [0ms] (replayed)
163
+ ├─ ⇉ map(summarize) [2ms]
164
+ │ ├─ • summarize [0ms] (replayed)
165
+ │ └─ • summarize [0ms] (replayed)
166
+ ├─ ◆ editor [0ms] (replayed)
167
+ └─ • publish [0ms]
168
+ ```
169
+
170
+ Interrupts are *named* (`approve("publish")`, `ask_human("style", "Formal or casual?")`), never positional — answers are journaled under that name, so resuming is idempotent and order-independent. The same mechanism gates tools: `@compose.tool(requires_approval=True)` pauses the agent mid-loop until `resume(run_id, answers={"tool_name": True})` (or `False` to deny — the model sees "denied by user" and carries on). The same crash-recovery works with no interrupt at all: if a flow dies halfway, `resume(run_id)` re-runs it and the journal skips what already happened.
171
+
172
+ ## Streaming
173
+
174
+ ```python
175
+ stream = poet.stream("event buses")
176
+
177
+ for event in stream:
178
+ if event.kind == "text_delta" and event.text:
179
+ print(event.text, end="", flush=True)
180
+
181
+ stream.run.trace.print() # blocks until settled; the full trace, same events
182
+ ```
183
+
184
+ One bus carries `text_delta` / `thinking_delta` / `tool_call_started` / `tool_args_delta` / `span_started` / `span_finished` / `run_finished` — pipelines and flows stream the same way (`write_post.stream(...)`, `research.stream(...)`).
185
+
186
+ ## The CLI
187
+
188
+ Everything above persisted to `./.compose` (override with `COMPOSE_DIR`). The `compose` CLI reads it:
189
+
190
+ ```console
191
+ $ compose runs
192
+ 01KXC6RD flow research completed 9s ago $0.0012 760 tok
193
+ 01KXC6RD agent researcher completed 9s ago $0.0150 2920 tok
194
+ 01KXC6RD agent researcher completed 9s ago $0.0150 2920 tok
195
+
196
+ $ compose trace 01KXC6RDF5CNYVXCRVCQWJDBRR # or: compose trace --last
197
+ trace 01KXC6RDF6TQE8PPKA0XSECBKS — paused — [$0.0012 · 760 tok · 127ms]
198
+ ├─ ▶ research [$0.0012 · 760 tok · 3ms] ⏸ paused
199
+ │ ├─ • fetch_sources [0ms]
200
+ │ ├─ ⇉ map(summarize) [2ms]
201
+ │ │ ├─ • summarize [0ms]
202
+ │ │ └─ • summarize [2ms]
203
+ │ ├─ ◆ editor [$0.0012 · 760 tok · 0ms]
204
+ │ │ └─ ▸ anthropic/claude-haiku-4-5 [$0.0012 · 760 tok · 0ms]
205
+ │ └─ ⏸ publish [0ms] ⏸ paused
206
+ └─ ▶ research [3ms]
207
+ ├─ • fetch_sources [0ms] (replayed)
208
+ ...
209
+
210
+ $ compose costs --by model # or --by name / --by day, --since 7d
211
+ costs by model
212
+ claude-haiku-4-5 calls=1 tokens=760 cost=$0.0012
213
+ claude-sonnet-5 calls=4 tokens=5840 cost=$0.0300
214
+ TOTAL calls=5 tokens=6600 cost=$0.0312
215
+
216
+ $ compose diff 01KXC6RDB8TS2SPXGKKXV7Z64G 01KXC6RDBC6Z1H2DSBMFAKF01D
217
+
218
+ diff 01KXC6RD -> 01KXC6RD
219
+ Δcost: +$0.0000
220
+ Δtokens: +0
221
+ Δduration: -2ms
222
+
223
+ ◆ researcher Δcost=+$0.0000 Δtokens=+0 Δdur=-2ms
224
+ ▸ anthropic/claude-sonnet-5 Δcost=+$0.0000 Δtokens=+0 Δdur=-0ms (output changed)
225
+ ...
226
+ ```
227
+
228
+ `compose trace` on a paused run also prints its pending interrupts and the exact `resume(...)` call to answer them. `compose runs -q "search terms"` full-text-searches span payloads; `compose export RUN_ID --cassette x.json` turns a run's recorded LLM calls into a replayable test fixture (below). `compose path` prints the state directory.
229
+
230
+ ## Test kit
231
+
232
+ `FakeModel` scripts an agent without a provider or network — each item is one model turn:
233
+
234
+ ```python
235
+ from composeai.testing import FakeModel
236
+
237
+ model = FakeModel([
238
+ {"tool_calls": [{"name": "count_words", "arguments": {"text": "a b c"}}]}, # turn 1
239
+ {"json": {"topic": "quantum computing", "key_facts": ["fact one"]}}, # turn 2
240
+ ])
241
+
242
+ @compose.agent(model=model, tools=[count_words])
243
+ def researcher(topic: str) -> FactSheet:
244
+ """You are a meticulous researcher."""
245
+ return compose.prompt(f"Build a fact sheet about: {topic}")
246
+ ```
247
+
248
+ Plain strings script text turns; `.stream()` synthesizes word-level deltas from the same script. Every request the agent made is recorded in `model.requests` for assertions.
249
+
250
+ **Cassettes** record real model traffic once and replay it offline forever. Re-export the bundled pytest fixture from your `conftest.py`:
251
+
252
+ ```python
253
+ from composeai.testing import cassette # noqa: F401
254
+ ```
255
+
256
+ ```python
257
+ def test_researcher(cassette):
258
+ with cassette("tests/cassettes/researcher.json"):
259
+ facts = researcher("quantum computing")
260
+ ```
261
+
262
+ Run once with `COMPOSE_RECORD=1` against the real provider and commit the JSON; afterwards the test replays it with no key, no network, and no SDK constructed. (`compose export` builds the same file from an already-persisted run.)
263
+
264
+ **`@compose.agent(model=..., cache=True)`** is for iterating locally against a real provider: identical requests are answered from a filesystem cache under `{COMPOSE_DIR}/cache/`, report zero usage (a hit is never re-billed), and tag their span `cached=true`. Applies to non-streaming calls only.
265
+
266
+ ## Rules of the road
267
+
268
+ The contracts composeai holds you to — and the ones it holds itself to:
269
+
270
+ - **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.
271
+ - **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.
272
+ - **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.
273
+ - **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.
274
+ - **`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.
275
+ - **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.
276
+ - **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.
277
+
278
+ ## vs. the alternatives
279
+
280
+ | | LangChain / LangGraph | composeai |
281
+ | :--- | :--- | :--- |
282
+ | **Core architecture** | Configuration & state graphs (`Runnable`, `StateGraph`) | Plain typed functions, composed with `pipe`/`aggregate`/`map` |
283
+ | **Learning curve** | A proprietary class ecosystem | Decorators on regular functions and pydantic types |
284
+ | **Wiring bugs surface** | At runtime, mid-graph | At composition time, before any API call |
285
+ | **Debugging** | Deeply nested framework traces | A breakpoint between two functions; local trace trees with exact costs |
286
+ | **Observability** | External/SaaS platforms, opt-in callbacks | Always-on local SQLite tracing + a CLI, zero setup |
287
+ | **Durability & HITL** | Separate checkpointer/orchestrator machinery | Journaled `@flow` + named interrupts, one `resume()` |
288
+ | **Dependencies** | Heavy transitive footprint | pydantic + stdlib; provider SDKs as optional extras |
289
+
290
+ ## Roadmap
291
+
292
+ - OpenTelemetry exporter (the span model already tracks `gen_ai.*` attribute conventions)
293
+ - Async API (`await agent(...)`, async tools)
294
+ - TypeScript sibling package
295
+ - 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
296
+
297
+ ## License
298
+
299
+ MIT
@@ -0,0 +1,275 @@
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
+
9
+ ```
10
+ trace 01KXC6RDB8E29NZHEAC2F54M11 — ok — [$0.0150 · 2.9k tok · 3ms]
11
+ └─ ◆ researcher [$0.0150 · 2.9k tok · 3ms]
12
+ ├─ ▸ anthropic/claude-sonnet-5 [$0.0075 · 1.5k tok · 0ms]
13
+ ├─ ⚙ count_words [0ms]
14
+ └─ ▸ anthropic/claude-sonnet-5 [$0.0075 · 1.5k tok · 0ms]
15
+ ```
16
+
17
+ - **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.
18
+ - **Streaming and tracing are the same event bus.** `.stream()` yields token deltas interleaved with the very span events the trace is built from, so a live UI and the trace can never disagree.
19
+
20
+ Runtime dependencies: **pydantic + the standard library**. Provider SDKs are optional extras. Python ≥ 3.10.
21
+
22
+ ## Install
23
+
24
+ ```bash
25
+ pip install composeai # core: pydantic + stdlib only
26
+ pip install "composeai[anthropic]" # + the Anthropic SDK
27
+ pip install "composeai[openai]" # + the OpenAI SDK
28
+ pip install "composeai[all]" # both
29
+ ```
30
+
31
+ ## Quickstart
32
+
33
+ ```python
34
+ import composeai as compose
35
+ from pydantic import BaseModel, Field
36
+
37
+
38
+ class FactSheet(BaseModel):
39
+ topic: str
40
+ key_facts: list[str] = Field(description="Three crisp, verifiable facts")
41
+
42
+
43
+ @compose.tool
44
+ def count_words(text: str) -> int:
45
+ """Count the words in a piece of text.
46
+
47
+ Args:
48
+ text: The text whose words should be counted.
49
+ """
50
+ return len(text.split())
51
+
52
+
53
+ @compose.agent(model="anthropic/claude-sonnet-5", tools=[count_words])
54
+ def researcher(topic: str) -> FactSheet:
55
+ """You are a meticulous researcher. Extract crisp, verifiable facts."""
56
+ return compose.prompt(f"Build a fact sheet about: {topic}")
57
+
58
+
59
+ facts = researcher("quantum computing") # -> FactSheet (or raises)
60
+ ```
61
+
62
+ 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 the type. `compose.prompt(...)` marks the body's return value as the prompt — it's a typed no-op that keeps static type checkers happy about the body returning a prompt where the annotation declares the output type (a bare `str` works identically at runtime). Tools run in a loop until the model produces the final answer.
63
+
64
+ Need more than the output? `.run()` returns the whole `Run`:
65
+
66
+ ```python
67
+ run = researcher.run("quantum computing")
68
+ run.output # the FactSheet
69
+ run.usage # tokens + USD cost, rolled up across every LLM call
70
+ run.trace.print() # the tree shown above
71
+ ```
72
+
73
+ Every agent also takes an optional spend cap: `researcher.run(topic, budget=compose.Budget(usd=0.50, tokens=200_000))` raises `BudgetExceededError` at the first LLM-call boundary past the cap.
74
+
75
+ ## Composition
76
+
77
+ ```python
78
+ @compose.agent(model="openai/gpt-5.6-luna")
79
+ def copywriter(sheet: FactSheet) -> str:
80
+ """Turn a fact sheet into one punchy line."""
81
+ return f"Write one punchy line from: {sheet.model_dump_json()}"
82
+
83
+
84
+ write_post = compose.pipe(researcher, copywriter) # types checked HERE
85
+ post = write_post("quantum computing")
86
+ ```
87
+
88
+ A mismatch never gets to run (and never spends a token):
89
+
90
+ ```python
91
+ compose.pipe(researcher, researcher)
92
+ # CompositionTypeError: pipe(): stage 1 (researcher) returns FactSheet
93
+ # but stage 2 (researcher) expects str
94
+ ```
95
+
96
+ Fan-out is a function too — branches run in parallel threads:
97
+
98
+ ```python
99
+ audits = compose.aggregate(words=lambda s: len(s.split()), chars=len)
100
+ audits("count these words") # {'words': 3, 'chars': 17}
101
+
102
+ compose.map(summarize, sources) # one stage over many items, order preserved
103
+ ```
104
+
105
+ Stages are agents, pipelines, aggregates, or plain Python callables — routing is an `if` statement, not a graph edge class.
106
+
107
+ `aggregate()`/`map()`/an agent's parallel tool calls have no *per-branch* timeout of their own — a single hung branch (a tool body blocked on a network call with no read timeout, a provider HTTP call that never returns) blocks the whole combinator, and therefore the enclosing run, until it finishes or the process is killed. Give an individual stage its own bound with `@task(timeout=...)` (or build one into a tool/agent body) if it needs one; there's no combinator-level equivalent.
108
+
109
+ ## Durable flows & human-in-the-loop
110
+
111
+ ```python
112
+ @compose.flow
113
+ def research(topic: str) -> str:
114
+ sources = fetch_sources(topic) # journaled step
115
+ summaries = compose.map(summarize, sources) # parallel, journaled steps
116
+ draft = editor(summaries) # a whole agent run = one step
117
+ if compose.approve("publish", payload={"draft": draft}): # named interrupt
118
+ return publish(draft)
119
+ return f"kept as draft: {draft}"
120
+
121
+
122
+ run = research.run("quantum computing")
123
+ run.status # "paused"
124
+ run.pending # id='publish' kind='approval' question=None payload={'draft': ...}
125
+ ```
126
+
127
+ Pausing is not an error — the process may simply exit. Later, from **any** process:
128
+
129
+ ```python
130
+ run = compose.resume(run.id, answers={"publish": True})
131
+ run.status # "completed"
132
+ ```
133
+
134
+ On resume, finished steps replay from the journal (the `editor` agent's LLM calls are not re-made and not re-billed); only the unfinished tail runs:
135
+
136
+ ```
137
+ └─ ▶ research [3ms]
138
+ ├─ • fetch_sources [0ms] (replayed)
139
+ ├─ ⇉ map(summarize) [2ms]
140
+ │ ├─ • summarize [0ms] (replayed)
141
+ │ └─ • summarize [0ms] (replayed)
142
+ ├─ ◆ editor [0ms] (replayed)
143
+ └─ • publish [0ms]
144
+ ```
145
+
146
+ Interrupts are *named* (`approve("publish")`, `ask_human("style", "Formal or casual?")`), never positional — answers are journaled under that name, so resuming is idempotent and order-independent. The same mechanism gates tools: `@compose.tool(requires_approval=True)` pauses the agent mid-loop until `resume(run_id, answers={"tool_name": True})` (or `False` to deny — the model sees "denied by user" and carries on). The same crash-recovery works with no interrupt at all: if a flow dies halfway, `resume(run_id)` re-runs it and the journal skips what already happened.
147
+
148
+ ## Streaming
149
+
150
+ ```python
151
+ stream = poet.stream("event buses")
152
+
153
+ for event in stream:
154
+ if event.kind == "text_delta" and event.text:
155
+ print(event.text, end="", flush=True)
156
+
157
+ stream.run.trace.print() # blocks until settled; the full trace, same events
158
+ ```
159
+
160
+ One bus carries `text_delta` / `thinking_delta` / `tool_call_started` / `tool_args_delta` / `span_started` / `span_finished` / `run_finished` — pipelines and flows stream the same way (`write_post.stream(...)`, `research.stream(...)`).
161
+
162
+ ## The CLI
163
+
164
+ Everything above persisted to `./.compose` (override with `COMPOSE_DIR`). The `compose` CLI reads it:
165
+
166
+ ```console
167
+ $ compose runs
168
+ 01KXC6RD flow research completed 9s ago $0.0012 760 tok
169
+ 01KXC6RD agent researcher completed 9s ago $0.0150 2920 tok
170
+ 01KXC6RD agent researcher completed 9s ago $0.0150 2920 tok
171
+
172
+ $ compose trace 01KXC6RDF5CNYVXCRVCQWJDBRR # or: compose trace --last
173
+ trace 01KXC6RDF6TQE8PPKA0XSECBKS — paused — [$0.0012 · 760 tok · 127ms]
174
+ ├─ ▶ research [$0.0012 · 760 tok · 3ms] ⏸ paused
175
+ │ ├─ • fetch_sources [0ms]
176
+ │ ├─ ⇉ map(summarize) [2ms]
177
+ │ │ ├─ • summarize [0ms]
178
+ │ │ └─ • summarize [2ms]
179
+ │ ├─ ◆ editor [$0.0012 · 760 tok · 0ms]
180
+ │ │ └─ ▸ anthropic/claude-haiku-4-5 [$0.0012 · 760 tok · 0ms]
181
+ │ └─ ⏸ publish [0ms] ⏸ paused
182
+ └─ ▶ research [3ms]
183
+ ├─ • fetch_sources [0ms] (replayed)
184
+ ...
185
+
186
+ $ compose costs --by model # or --by name / --by day, --since 7d
187
+ costs by model
188
+ claude-haiku-4-5 calls=1 tokens=760 cost=$0.0012
189
+ claude-sonnet-5 calls=4 tokens=5840 cost=$0.0300
190
+ TOTAL calls=5 tokens=6600 cost=$0.0312
191
+
192
+ $ compose diff 01KXC6RDB8TS2SPXGKKXV7Z64G 01KXC6RDBC6Z1H2DSBMFAKF01D
193
+
194
+ diff 01KXC6RD -> 01KXC6RD
195
+ Δcost: +$0.0000
196
+ Δtokens: +0
197
+ Δduration: -2ms
198
+
199
+ ◆ researcher Δcost=+$0.0000 Δtokens=+0 Δdur=-2ms
200
+ ▸ anthropic/claude-sonnet-5 Δcost=+$0.0000 Δtokens=+0 Δdur=-0ms (output changed)
201
+ ...
202
+ ```
203
+
204
+ `compose trace` on a paused run also prints its pending interrupts and the exact `resume(...)` call to answer them. `compose runs -q "search terms"` full-text-searches span payloads; `compose export RUN_ID --cassette x.json` turns a run's recorded LLM calls into a replayable test fixture (below). `compose path` prints the state directory.
205
+
206
+ ## Test kit
207
+
208
+ `FakeModel` scripts an agent without a provider or network — each item is one model turn:
209
+
210
+ ```python
211
+ from composeai.testing import FakeModel
212
+
213
+ model = FakeModel([
214
+ {"tool_calls": [{"name": "count_words", "arguments": {"text": "a b c"}}]}, # turn 1
215
+ {"json": {"topic": "quantum computing", "key_facts": ["fact one"]}}, # turn 2
216
+ ])
217
+
218
+ @compose.agent(model=model, tools=[count_words])
219
+ def researcher(topic: str) -> FactSheet:
220
+ """You are a meticulous researcher."""
221
+ return compose.prompt(f"Build a fact sheet about: {topic}")
222
+ ```
223
+
224
+ Plain strings script text turns; `.stream()` synthesizes word-level deltas from the same script. Every request the agent made is recorded in `model.requests` for assertions.
225
+
226
+ **Cassettes** record real model traffic once and replay it offline forever. Re-export the bundled pytest fixture from your `conftest.py`:
227
+
228
+ ```python
229
+ from composeai.testing import cassette # noqa: F401
230
+ ```
231
+
232
+ ```python
233
+ def test_researcher(cassette):
234
+ with cassette("tests/cassettes/researcher.json"):
235
+ facts = researcher("quantum computing")
236
+ ```
237
+
238
+ Run once with `COMPOSE_RECORD=1` against the real provider and commit the JSON; afterwards the test replays it with no key, no network, and no SDK constructed. (`compose export` builds the same file from an already-persisted run.)
239
+
240
+ **`@compose.agent(model=..., cache=True)`** is for iterating locally against a real provider: identical requests are answered from a filesystem cache under `{COMPOSE_DIR}/cache/`, report zero usage (a hit is never re-billed), and tag their span `cached=true`. Applies to non-streaming calls only.
241
+
242
+ ## Rules of the road
243
+
244
+ The contracts composeai holds you to — and the ones it holds itself to:
245
+
246
+ - **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.
247
+ - **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.
248
+ - **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.
249
+ - **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.
250
+ - **`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.
251
+ - **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.
252
+ - **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.
253
+
254
+ ## vs. the alternatives
255
+
256
+ | | LangChain / LangGraph | composeai |
257
+ | :--- | :--- | :--- |
258
+ | **Core architecture** | Configuration & state graphs (`Runnable`, `StateGraph`) | Plain typed functions, composed with `pipe`/`aggregate`/`map` |
259
+ | **Learning curve** | A proprietary class ecosystem | Decorators on regular functions and pydantic types |
260
+ | **Wiring bugs surface** | At runtime, mid-graph | At composition time, before any API call |
261
+ | **Debugging** | Deeply nested framework traces | A breakpoint between two functions; local trace trees with exact costs |
262
+ | **Observability** | External/SaaS platforms, opt-in callbacks | Always-on local SQLite tracing + a CLI, zero setup |
263
+ | **Durability & HITL** | Separate checkpointer/orchestrator machinery | Journaled `@flow` + named interrupts, one `resume()` |
264
+ | **Dependencies** | Heavy transitive footprint | pydantic + stdlib; provider SDKs as optional extras |
265
+
266
+ ## Roadmap
267
+
268
+ - OpenTelemetry exporter (the span model already tracks `gen_ai.*` attribute conventions)
269
+ - Async API (`await agent(...)`, async tools)
270
+ - TypeScript sibling package
271
+ - 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
272
+
273
+ ## License
274
+
275
+ MIT