composeai 0.1.0__py3-none-any.whl

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.
composeai/__init__.py ADDED
@@ -0,0 +1,33 @@
1
+ """composeai: a radically simple, functional framework for multi-agent AI workflows.
2
+
3
+ Typed agent functions, pipe/aggregate/map composition with build-time type
4
+ checks, always-on local tracing, and durable, resumable flows.
5
+ """
6
+
7
+ from .agentfn import agent, prompt
8
+ from .combinators import aggregate, map, pipe
9
+ from .flow import flow, resume, task
10
+ from .hitl import Interrupt, approve, ask_human
11
+ from .models.compatible import openai_compatible
12
+ from .runs import Budget, Run
13
+ from .tools import tool
14
+
15
+ __version__ = "0.1.0"
16
+
17
+ __all__ = [
18
+ "Budget",
19
+ "Interrupt",
20
+ "Run",
21
+ "agent",
22
+ "aggregate",
23
+ "approve",
24
+ "ask_human",
25
+ "flow",
26
+ "map",
27
+ "openai_compatible",
28
+ "pipe",
29
+ "prompt",
30
+ "resume",
31
+ "task",
32
+ "tool",
33
+ ]
composeai/_encoding.py ADDED
@@ -0,0 +1,240 @@
1
+ """JSON-safe encode/decode for journal and trace persistence.
2
+
3
+ ``to_jsonable``/``from_jsonable`` turn a restricted set of Python values
4
+ into plain ``dict``/``list``/scalar JSON trees (and back), tagging
5
+ non-trivial types with a reserved ``"$kind"`` key so they can be
6
+ rehydrated. Deliberately **no pickle** and **no dynamic import**:
7
+ decoding data that references an unregistered type raises
8
+ :class:`~composeai.errors.SerializationError` naming the missing type
9
+ rather than importing it -- guessing and importing arbitrary dotted
10
+ paths found in persisted data is a code-execution hole (this exact
11
+ issue was found and fixed in a prior audit). Callers must import the
12
+ module that defines a type -- or call :func:`register_serializable` --
13
+ before decoding data that references it.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import dataclasses
19
+ import datetime
20
+ import enum
21
+ from typing import Any
22
+
23
+ from pydantic import BaseModel
24
+
25
+ from .errors import SerializationError
26
+
27
+ _KIND = "$kind"
28
+ _TYPE = "$type"
29
+ _VALUE = "value"
30
+
31
+ # Type registry: never used to import anything, only to look up classes
32
+ # that some part of this process has already imported and registered.
33
+ _REGISTRY: dict[str, type] = {}
34
+
35
+
36
+ def register_serializable(cls: type) -> type:
37
+ """Register ``cls`` so :func:`from_jsonable` can rehydrate it by tag.
38
+
39
+ Encoding a pydantic model, dataclass, or enum auto-registers its
40
+ class. Call this explicitly in a process that decodes data it never
41
+ encoded (e.g. a fresh journal reader started in a new process).
42
+ Returns ``cls`` unchanged, so it can also be used as a decorator.
43
+ """
44
+ _REGISTRY[_type_tag(cls)] = cls
45
+ return cls
46
+
47
+
48
+ def _type_tag(cls: type) -> str:
49
+ return f"{cls.__module__}:{cls.__qualname__}"
50
+
51
+
52
+ def _child_path(path: str, key: str) -> str:
53
+ return f"{path}.{key}" if path else key
54
+
55
+
56
+ def _index_path(path: str, index: int) -> str:
57
+ return f"{path}[{index}]"
58
+
59
+
60
+ def _is_namedtuple(obj: Any) -> bool:
61
+ """Best-effort, standard-library-idiomatic NamedTuple detection.
62
+
63
+ ``isinstance(obj, tuple)`` is true for NamedTuple instances too (they're
64
+ tuple subclasses) -- this must be checked *before* the generic tuple
65
+ branch, or a NamedTuple silently degrades to a plain tuple.
66
+ """
67
+ return (
68
+ isinstance(obj, tuple)
69
+ and type(obj) is not tuple
70
+ and hasattr(type(obj), "_fields")
71
+ and hasattr(type(obj), "_asdict")
72
+ )
73
+
74
+
75
+ def to_jsonable(obj: Any) -> Any:
76
+ """Convert ``obj`` into a plain JSON-safe tree.
77
+
78
+ Raises :class:`SerializationError` naming the offending path if any
79
+ value nested in ``obj`` isn't supported.
80
+ """
81
+ return _encode(obj, "")
82
+
83
+
84
+ def _encode(obj: Any, path: str) -> Any:
85
+ if obj is None or isinstance(obj, (bool, int, float, str)):
86
+ return obj
87
+
88
+ if isinstance(obj, BaseModel):
89
+ register_serializable(type(obj))
90
+ return {
91
+ _KIND: "pydantic",
92
+ _TYPE: _type_tag(type(obj)),
93
+ _VALUE: obj.model_dump(mode="json"),
94
+ }
95
+
96
+ if isinstance(obj, enum.Enum):
97
+ register_serializable(type(obj))
98
+ return {_KIND: "enum", _TYPE: _type_tag(type(obj)), _VALUE: obj.name}
99
+
100
+ if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
101
+ register_serializable(type(obj))
102
+ non_init = [field.name for field in dataclasses.fields(obj) if not field.init]
103
+ if non_init:
104
+ raise SerializationError(
105
+ f"{path or '<root>'}: dataclass {type(obj).__name__!r} has non-init "
106
+ f"field(s) {non_init!r} (declared with field(init=False), typically "
107
+ "computed in __post_init__) -- these can't round-trip the journal: "
108
+ "encoding would capture the computed value, but decoding calls "
109
+ "cls(**fields), which rejects any keyword the real __init__ doesn't "
110
+ "accept. Exclude non-init fields from what you journal (e.g. store a "
111
+ "plain dict/dataclass of only the init fields), or give this "
112
+ "dataclass a classmethod constructor you call explicitly on decode."
113
+ )
114
+ value = {
115
+ field.name: _encode(getattr(obj, field.name), _child_path(path, field.name))
116
+ for field in dataclasses.fields(obj)
117
+ }
118
+ return {_KIND: "dataclass", _TYPE: _type_tag(type(obj)), _VALUE: value}
119
+
120
+ if isinstance(obj, datetime.datetime):
121
+ return {_KIND: "datetime", _VALUE: obj.isoformat()}
122
+ if isinstance(obj, datetime.date):
123
+ return {_KIND: "date", _VALUE: obj.isoformat()}
124
+ if isinstance(obj, datetime.time):
125
+ return {_KIND: "time", _VALUE: obj.isoformat()}
126
+
127
+ if _is_namedtuple(obj):
128
+ raise SerializationError(
129
+ f"{path or '<root>'}: {type(obj).__name__!r} is a NamedTuple, which the "
130
+ "journal encoder doesn't support -- it's a tuple subclass, so encoding it "
131
+ "as a plain tuple would silently lose field-name access on decode (an "
132
+ "AttributeError on the replayed value where the original worked). Convert "
133
+ "it to a dataclass, a dict, or a plain tuple before journaling it."
134
+ )
135
+ if isinstance(obj, tuple):
136
+ return {
137
+ _KIND: "tuple",
138
+ _VALUE: [_encode(v, _index_path(path, i)) for i, v in enumerate(obj)],
139
+ }
140
+ if isinstance(obj, frozenset):
141
+ return {
142
+ _KIND: "frozenset",
143
+ _VALUE: [_encode(v, _index_path(path, i)) for i, v in enumerate(obj)],
144
+ }
145
+ if isinstance(obj, set):
146
+ return {
147
+ _KIND: "set",
148
+ _VALUE: [_encode(v, _index_path(path, i)) for i, v in enumerate(obj)],
149
+ }
150
+
151
+ if isinstance(obj, list):
152
+ return [_encode(v, _index_path(path, i)) for i, v in enumerate(obj)]
153
+
154
+ if isinstance(obj, dict):
155
+ for key in obj:
156
+ if not isinstance(key, str):
157
+ raise SerializationError(
158
+ f"{path or '<root>'}: dict keys must be str, got {key!r}"
159
+ )
160
+ encoded = {k: _encode(v, _child_path(path, k)) for k, v in obj.items()}
161
+ if _KIND in obj:
162
+ # A plain dict that happens to use our reserved key: escape it
163
+ # so decoding is unambiguous.
164
+ return {_KIND: "dict", _VALUE: encoded}
165
+ return encoded
166
+
167
+ raise SerializationError(f"{path or '<root>'}: {type(obj)!r} is not JSON-serializable")
168
+
169
+
170
+ def from_jsonable(data: Any) -> Any:
171
+ """Inverse of :func:`to_jsonable`.
172
+
173
+ Raises :class:`SerializationError` if a ``$type`` tag isn't in the
174
+ registry (see :func:`register_serializable`). Never imports modules
175
+ to resolve a type.
176
+ """
177
+ if isinstance(data, dict):
178
+ if _KIND in data:
179
+ return _decode_tagged(data)
180
+ return {k: from_jsonable(v) for k, v in data.items()}
181
+ if isinstance(data, list):
182
+ return [from_jsonable(v) for v in data]
183
+ return data
184
+
185
+
186
+ def _decode_tagged(data: dict[str, Any]) -> Any:
187
+ kind = data.get(_KIND)
188
+ try:
189
+ if kind == "dict":
190
+ return {k: from_jsonable(v) for k, v in data[_VALUE].items()}
191
+ if kind == "pydantic":
192
+ cls = _resolve_type(data[_TYPE])
193
+ value = {k: from_jsonable(v) for k, v in data[_VALUE].items()}
194
+ return cls.model_validate(value)
195
+ if kind == "dataclass":
196
+ cls = _resolve_type(data[_TYPE])
197
+ value = {k: from_jsonable(v) for k, v in data[_VALUE].items()}
198
+ return cls(**value)
199
+ if kind == "enum":
200
+ cls = _resolve_type(data[_TYPE])
201
+ return cls[data[_VALUE]]
202
+ if kind == "datetime":
203
+ return datetime.datetime.fromisoformat(data[_VALUE])
204
+ if kind == "date":
205
+ return datetime.date.fromisoformat(data[_VALUE])
206
+ if kind == "time":
207
+ return datetime.time.fromisoformat(data[_VALUE])
208
+ if kind == "tuple":
209
+ return tuple(from_jsonable(v) for v in data[_VALUE])
210
+ if kind == "set":
211
+ return {from_jsonable(v) for v in data[_VALUE]}
212
+ if kind == "frozenset":
213
+ return frozenset(from_jsonable(v) for v in data[_VALUE])
214
+ except SerializationError:
215
+ raise # already the right type (e.g. _resolve_type's "unregistered type") -- pass through
216
+ except (KeyError, TypeError) as exc:
217
+ # A tagged value missing an expected key (e.g. `{"$kind": "dataclass",
218
+ # "value": {...}}` with no "$type" -- DB corruption, a manual edit, a
219
+ # future schema/version mismatch, ...) used to raise a raw KeyError/
220
+ # TypeError here instead of this module's own SerializationError, so
221
+ # callers that specifically catch SerializationError (the module's
222
+ # documented contract) didn't catch this.
223
+ raise SerializationError(
224
+ f"corrupted journal/state row (kind={kind!r}): {type(exc).__name__}: {exc}"
225
+ ) from exc
226
+
227
+ raise SerializationError(f"Unknown {_KIND!r} tag: {kind!r}")
228
+
229
+
230
+ def _resolve_type(tag: str) -> Any:
231
+ cls = _REGISTRY.get(tag)
232
+ if cls is not None:
233
+ return cls
234
+ module_hint = tag.split(":", 1)[0]
235
+ raise SerializationError(
236
+ f"Cannot decode unregistered type {tag!r}: import the module that "
237
+ f"defines it (e.g. `import {module_hint}`) or call "
238
+ f"register_serializable(...) before decoding this data. "
239
+ f"Types are never imported automatically, by design (security)."
240
+ )
composeai/_ids.py ADDED
@@ -0,0 +1,41 @@
1
+ """Sortable unique identifiers (ULID-style) for composeai.
2
+
3
+ A ``new_ulid()`` result is a 26-character Crockford Base32 string encoding
4
+ 128 bits: a 48-bit Unix-millisecond timestamp followed by 80 bits of
5
+ cryptographically random data from :func:`os.urandom`. Because the
6
+ timestamp occupies the most significant bits, two IDs generated at least
7
+ 1 millisecond apart sort lexicographically in time order -- useful for
8
+ run/span/message identifiers that should also read as roughly
9
+ chronological when listed.
10
+
11
+ Stdlib only; no third-party dependency.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ import time
18
+
19
+ # Crockford's Base32 alphabet: excludes I, L, O, U to avoid visual ambiguity.
20
+ _ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
21
+ _TIMESTAMP_BYTES = 6 # 48 bits
22
+ _RANDOM_BYTES = 10 # 80 bits
23
+ _ENCODED_LENGTH = 26 # 128 bits / 5 bits-per-char (rounded up)
24
+
25
+
26
+ def new_ulid() -> str:
27
+ """Return a new 26-character, lexicographically time-sortable ID."""
28
+ timestamp_ms = int(time.time() * 1000)
29
+ ts_bytes = timestamp_ms.to_bytes(_TIMESTAMP_BYTES, byteorder="big")
30
+ rand_bytes = os.urandom(_RANDOM_BYTES)
31
+ return _encode(ts_bytes + rand_bytes)
32
+
33
+
34
+ def _encode(data: bytes) -> str:
35
+ """Encode 16 raw bytes (128 bits) as 26 Crockford Base32 characters."""
36
+ value = int.from_bytes(data, byteorder="big")
37
+ chars = [""] * _ENCODED_LENGTH
38
+ for i in range(_ENCODED_LENGTH - 1, -1, -1):
39
+ chars[i] = _ALPHABET[value & 0x1F]
40
+ value >>= 5
41
+ return "".join(chars)
composeai/_schema.py ADDED
@@ -0,0 +1,147 @@
1
+ """Shared JSON Schema post-processing for ``@tool`` and ``@agent`` structured output.
2
+
3
+ Both derive their schemas from pydantic (``create_model`` / ``TypeAdapter``) and
4
+ both need the same treatment before handing a schema to a model: strict
5
+ ``additionalProperties: false`` everywhere -- including nested ``$defs`` --
6
+ and no pydantic-generated ``title`` noise (models rely on ``description``,
7
+ not ``title``, and a stray auto-title only wastes tokens).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import dataclasses
13
+ import enum
14
+ import inspect
15
+ from collections.abc import Callable
16
+ from typing import Any, get_args, get_type_hints
17
+
18
+ from pydantic import BaseModel
19
+
20
+ from ._encoding import register_serializable
21
+
22
+
23
+ def resolve_annotations(fn: Callable[..., Any], *, include_extras: bool = True) -> dict[str, Any]:
24
+ """Best-effort ``typing.get_type_hints`` for ``fn``.
25
+
26
+ ``get_type_hints`` resolves string-form annotations (e.g. under
27
+ ``from __future__ import annotations``) against ``fn.__globals__``, but
28
+ it cannot see names from an enclosing function's local/closure scope --
29
+ a common pattern in tests that define a small model right next to the
30
+ function using it. When that lookup fails, fall back to each
31
+ parameter's raw annotation from :func:`inspect.signature`: on every
32
+ supported Python version, an annotation that's already a real object
33
+ (the common case without the ``__future__`` import, and -- since
34
+ Python 3.14's lazy annotation evaluation -- even local/closure names
35
+ under it) survives untouched; only an unresolved string annotation
36
+ combining *both* the ``__future__`` import *and* a local/closure name
37
+ would still come back as a plain string here.
38
+ """
39
+ try:
40
+ return get_type_hints(fn, include_extras=include_extras)
41
+ except NameError:
42
+ pass
43
+
44
+ sig = inspect.signature(fn)
45
+ raw: dict[str, Any] = {
46
+ name: param.annotation
47
+ for name, param in sig.parameters.items()
48
+ if param.annotation is not inspect.Parameter.empty
49
+ }
50
+ if sig.return_annotation is not inspect.Signature.empty:
51
+ raw["return"] = sig.return_annotation
52
+ return raw
53
+
54
+
55
+ def register_annotation_types(fn: Callable[..., Any]) -> None:
56
+ """Register every pydantic model / dataclass / enum reachable from ``fn``'s annotations.
57
+
58
+ Walks :func:`resolve_annotations` (every parameter plus the return
59
+ type), recursing into generic args (``typing.get_args`` -- e.g. the
60
+ ``Inner`` in ``list[Inner]`` or ``dict[str, Outer]``) and, for
61
+ pydantic models and dataclasses, into their own field types. Called by
62
+ ``@task``/``@flow``/``@agent`` decoration so a *fresh process* (e.g. a
63
+ resumed flow started via a new ``python`` invocation) can decode
64
+ journaled values referencing these types via
65
+ :func:`~composeai._encoding.from_jsonable` without composeai ever
66
+ importing a type dynamically -- that would be a code-execution hole,
67
+ so decoding an unregistered type always raises instead (see
68
+ :mod:`composeai._encoding`).
69
+ """
70
+ hints = resolve_annotations(fn, include_extras=True)
71
+ seen: set[type] = set()
72
+ for value in hints.values():
73
+ _register_type_recursive(value, seen)
74
+
75
+
76
+ def _register_type_recursive(annotation: Any, seen: set[type]) -> None:
77
+ if isinstance(annotation, type):
78
+ if annotation in seen:
79
+ return
80
+ seen.add(annotation)
81
+ if issubclass(annotation, BaseModel):
82
+ register_serializable(annotation)
83
+ for field_info in annotation.model_fields.values():
84
+ _register_type_recursive(field_info.annotation, seen)
85
+ elif dataclasses.is_dataclass(annotation):
86
+ register_serializable(annotation)
87
+ # `dataclasses.fields(annotation)[i].type` is the field's *raw*
88
+ # annotation -- under `from __future__ import annotations` (or
89
+ # any other postponed-evaluation source), that's an unresolved
90
+ # string (e.g. `"Inner"`), not the actual class object. Recursing
91
+ # on it directly means `isinstance(..., type)` is always False
92
+ # and `get_args(...)` always empty, so a dataclass field typed
93
+ # with another custom dataclass/enum/pydantic model silently
94
+ # never gets registered -- exactly the gap the pydantic branch
95
+ # above doesn't have, since `model_fields[...].annotation` is
96
+ # always pydantic-resolved to a real type object already.
97
+ # `typing.get_type_hints` resolves the same way `model_fields`
98
+ # does; fall back to the raw (possibly-string) annotation if
99
+ # resolution itself fails (e.g. a name only in a caller's local
100
+ # scope -- see `resolve_annotations`'s own docstring for the
101
+ # same trade-off).
102
+ try:
103
+ hints = get_type_hints(annotation, include_extras=True)
104
+ except NameError:
105
+ hints = {}
106
+ for field in dataclasses.fields(annotation):
107
+ _register_type_recursive(hints.get(field.name, field.type), seen)
108
+ elif issubclass(annotation, enum.Enum):
109
+ register_serializable(annotation)
110
+ return
111
+
112
+ for arg in get_args(annotation):
113
+ _register_type_recursive(arg, seen)
114
+
115
+
116
+ def seal_schema(schema: Any) -> None:
117
+ """Recursively strip auto-generated ``title`` keys and seal every object schema.
118
+
119
+ Mutates ``schema`` in place. "Object schema" means any nested dict with
120
+ ``"type": "object"`` -- including entries under ``$defs`` -- which gets
121
+ ``additionalProperties: false`` set, *unless* it already has a
122
+ dict-valued ``additionalProperties`` (a real schema, not a boolean --
123
+ what pydantic generates for an open mapping like ``dict[str, V]``/
124
+ ``Mapping[str, V]``, describing the permitted *value* type). Overwriting
125
+ that with ``False`` would turn a schema that legitimately accepts any
126
+ string-keyed dict of ``V`` into one that only ever matches ``{}`` --
127
+ silently forbidding the model from ever populating the field/argument
128
+ it's meant to constrain, not forbid. A boolean (or absent)
129
+ ``additionalProperties`` is still always forced to ``False`` (the
130
+ fixed-shape-object case this was designed for -- pydantic models and
131
+ dataclasses). Non-dict values (e.g. entries of a ``"required"`` list)
132
+ are left untouched.
133
+ """
134
+ if not isinstance(schema, dict):
135
+ return
136
+ # Auto-generated titles are always strings; a dict under a "title" key is
137
+ # a *property named title* (inside a "properties" map) and must survive.
138
+ if isinstance(schema.get("title"), str):
139
+ schema.pop("title")
140
+ if schema.get("type") == "object" and not isinstance(schema.get("additionalProperties"), dict):
141
+ schema["additionalProperties"] = False
142
+ for value in schema.values():
143
+ if isinstance(value, dict):
144
+ seal_schema(value)
145
+ elif isinstance(value, list):
146
+ for item in value:
147
+ seal_schema(item)