ADR-0050: Foundational utility and typed adaptation strata¶
- Status: Accepted
- Kind: Retrospective
- Area: utilities
- Date: 2026-07-09
- Relations: none
Context¶
The utility stack is a high-fan-in part of LionAGI: small changes at this layer can alter serialization, hashing, field construction, file placement, and external-format adaptation throughout the package. The current implementation answers six concrete problems.
P1 — Shared primitives need a dependency-light import surface. Concurrency, deterministic JSON and hashing, conversion, fuzzy repair, process and network safeguards, path creation, and framework-neutral field specifications are needed by many higher layers. If each consumer chooses a different helper or imports an operations/session package to obtain it, behavior and dependencies drift. lionagi.ln is the explicit high-fan-in surface (lionagi/ln/__init__.py). The concurrent-execution surface is the largest of these fan-in primitives and is consumed directly by the action, operation, and orchestration layers: ordered batch execution with retry, timeout, and throttling (alcall, bcall, AlcallParams, BcallParams) and the lower coordination helpers it and other callers build on (gather, race, bounded_map, CompletionStream, retry) (lionagi/ln/_async_call.py, lionagi/ln/concurrency/patterns.py).
P2 — Omission is not one state. A missing key and a declared-but-unassigned parameter require different behavior from None, False, or an empty container. Undefined represents absence; Unset represents a parameter that exists but has not been assigned. Both must survive copying by identity and disappear from the serialization paths that treat them as missing (lionagi/ln/types/_sentinel.py, lionagi/ln/types/base.py, lionagi/models/hashable_model.py, lionagi/models/note.py).
P3 — Field meaning must precede framework materialization. Callers need to describe a field and an ordered output shape without importing Pydantic merely to construct the description. The same description must later become real Pydantic fields, validators, and model classes. Spec and Operable hold the framework-neutral description; FieldModel, model builders, and PydanticSpecAdapter materialize it (lionagi/ln/types/spec.py, lionagi/ln/types/operable.py, lionagi/models/field_model.py, lionagi/adapters/spec_adapters/pydantic_field.py).
P4 — Format support must extend subject classes without hard-coding every format. Node and Pile need different supported formats, and the PostgreSQL integration must remain optional. Keyed synchronous and asynchronous adapter registries provide that extension seam; missing and failed adaptations need typed, inspectable errors (lionagi/adapters/_base.py, lionagi/protocols/graph/node.py, lionagi/protocols/generic/pile.py).
P5 — A path constructor and a workspace policy are different contracts. Some callers need a convenient nested filename constructor. Other callers accept caller-controlled paths and must reject traversal, protected targets, unsafe path components, and symlink containment escapes. The current synchronous and asynchronous path creators do not provide equivalent safety: acreate_path() fixes the root before interpreting a slash-separated filename, while create_path() creates parents without the equivalent containment checks (lionagi/ln/_utils.py, lionagi/libs/path_safety.py).
P6 — Expensive or diagnostic utility work needs explicit bounds. Hashing and fuzzy JSON parsing can consume memory proportional to input; generated field types are cached; regex validation can backtrack; error context can accidentally become large. The implementation therefore contains limits that are part of observable behavior and must be recorded rather than left as unexplained constants.
The stack also contains acknowledged dependency exceptions. Operable.create_model() lazily imports the Pydantic spec adapter, which imports model code. Note imports generic nested-data helpers from libs, while schema utilities in libs import models. These are current facts, not the intended permanent direction.
| Concern | Decision |
|---|---|
| Primitive package boundary | D1: Keep ln as the explicit dependency-light primitive surface and libs as LionAGI policy/helpers, with current exception edges documented. |
| Missing-value states and serialization | D2: Preserve identity-based Undefined and Unset, with configured omission through parameter and model serializers. |
| Typed field descriptions and model emission | D3: Keep Spec/Operable framework-neutral and materialize Pydantic types through model/adapter code; retain the current lazy convenience edge. |
| External-format adaptation | D4: Use subject-class, string-keyed sync/async registries with typed failures and lazy optional integrations. |
| Path safety | D5: Keep policy-grade workspace containment separate from path construction and document the current sync/async asymmetry. |
| Numeric guardrails | D6: Keep shipped input, cache, regex, and diagnostic bounds as explicit contracts, including where the exact value has no recorded rationale. |
| Concurrent batch execution and coordination primitives | D7: Keep alcall/bcall as the ordered, input-order-preserving, dual-layer-retryable batch surface, and the lower ln.concurrency coordination functions as completion-ordered or first-result primitives with independent retry defaults. |
This ADR deliberately does not decide:
- operation, session, provider, or protocol behavior; those layers consume these utilities but do not belong to the utility contract;
- a new serialization or adapter wire format; this records the shipped extension and materialization seams rather than choosing new formats;
- the migration that removes the
ln/adapter andlibs/models exception edges; the retrospective deltas state the required deliverables; - a claim that
create_path()is safe for caller-controlled filenames; the current implementation does not support that claim; - filesystem race freedom after validation. The path checks validate the state they observe; they do not reserve a path or replace later open/write semantics.
Decision¶
D1 — Primitive and policy strata remain distinct¶
lionagi.ln is the compact common import surface. lionagi.libs owns workspace, file, schema, content-ingestion, and Pydantic-oriented validation policy, plus some generic helpers that remain placement exceptions. lionagi.models owns Pydantic model construction and serialization. lionagi.adapters owns external-format conversion seams.
The contract is the shipped module and dependency shape:
lionagi/
├── ln/
│ ├── concurrency/ cancellation, task groups, retry, synchronization
│ ├── fuzzy/ JSON extraction/repair and fuzzy key validation
│ ├── types/ sentinels, Params/DataClass, Spec, Operable, filters
│ ├── _async_call.py alcall/bcall ordered batch execution, AlcallParams/BcallParams
│ ├── _cache.py bounded caching helpers
│ ├── _hash.py stable and cryptographic hashing
│ ├── _json_dump.py deterministic orjson configuration
│ ├── _lazy_init.py lazy one-time initialization
│ ├── _list_call.py synchronous list-mapped calls
│ ├── _proc.py process-group termination
│ ├── _ssrf.py SSRF target checks
│ ├── _to_list.py to_list normalization (dropna/unique/flatten)
│ └── _utils.py conversion, import, locking, and path constructors
├── libs/
│ ├── path_safety.py workspace/path policy
│ ├── nested.py current generic nested-data exception
│ ├── schema/ schema reflection and generation
│ └── validate/ LionAGI validation/coercion policy
├── models/
│ ├── field_model.py Spec-compatible Pydantic field materialization
│ ├── hashable_model.py deterministic model serialization and hashing
│ └── note.py nested mapping model
└── adapters/
├── _base.py sync/async registry and typed errors
├── json_.py, csv_.py, toml_.py
├── async_postgres_adapter.py
└── spec_adapters/pydantic_field.py
lionagi/ln/__init__.py explicitly re-exports the public primitive names. It does not import operations or session behavior. The logical dependency graph, including deferred imports, is:
flowchart BT
libs --> ln
libs --> models
models --> ln
models --> libs
models -. lazy serialization default .-> protocols
adapters --> ln
adapters --> models
protocols --> adapters
ln -. lazy create_model .-> adapters Exact semantics:
- Importing
lionagi.lnexposes the names in its__all__; the package is not an automatic export of every private submodule symbol. - The arrows above are logical dependencies. The dotted arrows execute only when the relevant method is called; they are not all exercised during base import.
libsis not a synonym or compatibility facade forln. Its workspace and schema behavior may depend on models and policy that the primitive layer must not acquire.modelscurrently importslibs.nestedthroughNote;libs.schemaimports models. Contributors must account for that knot before moving either side.HashableModelinstalls its default serializer lazily. Its first serialization requiring the default importsElement; subsequent calls reuse the cached serializer (lionagi/models/hashable_model.py).
Why this way: a single primitive surface prevents helper drift without making workspace/schema policy a dependency of every low-level consumer. The current exceptions are documented because hiding them behind deferred imports would make cycle-sensitive refactors unsafe.
D2 — Two identity sentinels define omission¶
Undefined and Unset are the canonical states. UNDEFINED in lionagi/utils.py and lionagi/libs/nested.py aliases the same Undefined object; it is not a third state.
The contract (lionagi/ln/types/_sentinel.py, lionagi/ln/types/base.py) is:
Undefined: Final = UndefinedType()
Unset: Final = UnsetType()
def is_sentinel(
value: Any,
*,
none_as_sentinel: bool = False,
empty_as_sentinel: bool = False,
) -> bool: ...
@dataclass(slots=True, frozen=True)
class ModelConfig:
none_as_sentinel: bool = False
empty_as_sentinel: bool = False
strict: bool = False
prefill_unset: bool = True
use_enum_values: bool = False
serialize_exclude: frozenset[str] = frozenset()
@dataclass(slots=True, frozen=True, init=False)
class Params:
def __init__(self, **kwargs: Any): ...
def to_dict(self, exclude: set[str] = None) -> dict[str, str]: ...
def with_updates(self, **kwargs: Any) -> DataClass: ...
Pydantic-side serializers preserve the same omission meaning:
class HashableModel(BaseModel):
def to_dict(self, mode: Literal["python", "json", "db"] = "python", **kw) -> dict: ...
def to_json(self, decode: bool = True, **kw) -> bytes | str: ...
class Note(BaseModel):
content: dict[str, Any] = Field(default_factory=dict)
def to_dict(
self,
mode: Literal["python", "json"] = "python",
exclude_none: bool = False,
exclude_empty: bool = False,
) -> dict[str, Any]: ...
Exact semantics:
UndefinedType()andUnsetType()return one cached instance per sentinel subclass.copyanddeepcopyreturn that same instance; both objects are falsy and render asUndefinedorUnset.is_sentinel()always recognizes the two objects by identity. It recognizesNoneonly whennone_as_sentinel=True, and the shipped empty tuple, set, frozenset, dict, list, and empty string only whenempty_as_sentinel=True.Paramsrejects unknown constructor keys withValueError. Withprefill_unset=True, an allowed field that remainsUndefinedbecomesUnset. Withstrict=True, any value considered a sentinel by the configured policy raisesValueError("Missing required parameter: ...").Params.to_dict()omits configured sentinel values and requested exclusions; enum values are converted only whenuse_enum_values=True.default_kw()also flattens nestedkwargsandkwdictionaries into the returned mapping.HashableModel._to_dict()removes top-level sentinel values.mode="json"round-trips through deterministic JSON;mode="db"additionally renamesmetadatatonode_metadata. The inverse DB path renames it back.Note.to_dict()recursively removes sentinel values from dictionaries and lists.exclude_noneandexclude_emptyextend that recursive omission policy. The returned structure is a deep copy; JSON mode additionally runs recursive model and enum conversion.- Unsupported
HashableModelmodes raiseValueError. A missingNote.get()orNote.pop()path follows the nested helper contract: it returns the supplied default, or raises when the default remainsUNDEFINED. - Restart does not change sentinel identity within the new process. Serialized application data must not rely on a process address; the stable contract is the sentinel name and omission behavior.
Why this way: collapsing both states into None makes optional-null values indistinguishable from omission. Using identity rather than equality prevents ordinary user values from accidentally comparing equal to a sentinel.
D3 — Specs describe fields; Pydantic materializes them¶
Spec and Operable are dataclasses under ln.types; their module imports do not require Pydantic. The current Operable.create_model() convenience method crosses to the Pydantic adapter lazily.
The framework-neutral contract (lionagi/ln/types/spec.py, lionagi/ln/types/operable.py) is:
@dataclass(frozen=True, slots=True, init=False)
class Spec:
base_type: type
metadata: tuple[Meta, ...]
def __init__(
self,
base_type: type = None,
*args,
metadata: tuple[Meta, ...] = None,
**kw,
) -> None: ...
@dataclass(frozen=True, slots=True, init=False)
class Operable:
__op_fields__: tuple[Spec, ...]
name: str | None
def __init__(
self,
specs: tuple[Spec, ...] | list[Spec] = (),
*,
name: str | None = None,
): ...
def get_specs(
self,
*,
include: set[str] | None = None,
exclude: set[str] | None = None,
) -> tuple[Spec, ...]: ...
def create_model(
self,
adapter: Literal["pydantic"] = "pydantic",
model_name: str | None = None,
include: set[str] | None = None,
exclude: set[str] | None = None,
**kw,
): ...
The Pydantic materialization contract (lionagi/adapters/spec_adapters/pydantic_field.py) is:
class PydanticSpecAdapter(SpecAdapter):
@classmethod
def create_field(cls, spec: Spec) -> FieldInfo: ...
@classmethod
def create_validator(cls, spec: Spec) -> dict | None: ...
@classmethod
def create_model(
cls,
op: Operable,
model_name: str,
include: set[str] | None = None,
exclude: set[str] | None = None,
base_type: type[BaseModel] | None = None,
doc: str | None = None,
) -> type[BaseModel]: ...
FieldModel is the intermediate Pydantic-oriented field shape:
@dataclass(slots=True, frozen=True, init=False)
class FieldModel(Params):
_config = ModelConfig(prefill_unset=True, none_as_sentinel=True)
base_type: type[Any]
metadata: tuple[Meta, ...]
def create_field(self) -> Any: ...
@property
def annotation(self) -> type[Any]: ...
def to_spec(self) -> Spec: ...
Exact semantics:
Specrejects duplicate metadata keys, simultaneousdefaultanddefault_factory, a non-callable default factory, a non-callable validator, and abase_typethat is not a type or type annotation. An async default factory is accepted with a compatibility warning.Operableconverts an input list to a tuple, rejects non-Specitems, and rejects duplicate non-Nonespec names. Multiple unnamed specs are allowed.Operable.get()returnsUnseton a miss unless another default is supplied. Supplying bothincludeandexcludeis an error.excludepreserves original spec order;includeiterates the caller's set and therefore does not promise original order.create_model()supports only the key"pydantic". An unknown key raisesValueError. Import failure is re-raised with the explicit Pydantic installation message. The default generated class name ismodel_name, thenOperable.name, then"DynamicModel".PydanticSpecAdapterconverts each named spec to aFieldInfo, collects one Pydantic field validator per spec carryingvalidatormetadata, callsbuild_model_type(..., inherit_base=True), then callsmodel_rebuild(). Unnamed specs do not become model fields.FieldModelaccepts the legacy aliasesannotation -> base_typeandfield -> name. A callabledefaultbecomes a Pydanticdefault_factory. Unknown Pydantic field metadata is placed injson_schema_extra, except type objects, which are skipped because they are not JSON-schema serializable.- Nullable fields without an explicit default/default factory receive
default=None; listable fields wrap a non-list base annotation inlist[...].
Why this way: the neutral description can be constructed and inspected by lower layers, while Pydantic-specific imports and behavior remain behind the point that asks for a Pydantic class. The current convenience method is retained because it is public, but it is an exception rather than the desired dependency direction.
D4 — Adapters are keyed subject-class registries¶
Synchronous and asynchronous adapters are registered against the subject class that uses them. The registry selects an adapter with a runtime string key.
The contract (lionagi/adapters/_base.py) is:
class Adapter(Protocol[T]):
adapter_key: ClassVar[str]
obj_key: ClassVar[str] # compatibility spelling
@classmethod
def from_obj(cls, subj_cls: type[T], obj: Any, /, *, many: bool = False,
adapt_meth: str | Callable = "model_validate",
adapt_kw: dict[str, Any] | None = None, **kw: Any) -> T | list[T]: ...
@classmethod
def to_obj(cls, subj: T | list[T], /, *, many: bool = False,
adapt_meth: str | Callable = "model_dump",
adapt_kw: dict[str, Any] | None = None, **kw: Any) -> Any: ...
class Adaptable:
@classmethod
def register_adapter(cls, adapter_cls: type[Adapter]) -> None: ...
@classmethod
def adapt_from(cls, obj: Any, *, obj_key: str,
adapt_meth: str = "model_validate", **kw: Any) -> Any: ...
def adapt_to(self, *, obj_key: str,
adapt_meth: str = "model_dump", **kw: Any) -> Any: ...
class AsyncAdaptable:
@classmethod
def register_async_adapter(cls, adapter_cls: type[AsyncAdapter]) -> None: ...
@classmethod
async def adapt_from_async(cls, obj: Any, *, obj_key: str,
adapt_meth: str = "model_validate", **kw: Any) -> Any: ...
async def adapt_to_async(self, *, obj_key: str,
adapt_meth: str = "model_dump", **kw: Any) -> Any: ...
The typed error family is:
| Error | Default status | Meaning |
|---|---|---|
AdapterError | 500 | Base or uncategorized adapter failure. |
AdapterValidationError | 422 | Adapted data did not validate. |
AdapterParseError | 400 | External representation could not be parsed. |
AdapterNotFoundError | 404 | No adapter is registered under the requested key. |
AdapterConfigurationError | 500 | Adapter or registry configuration is invalid. |
AdapterResourceError | 404 | Required external resource is unavailable. |
AdapterConnectionError | 503 | Connection establishment or use failed. |
AdapterQueryError | 400 | External query execution failed. |
The shipped registrations are:
| Subject | Sync keys | Async key | Registration behavior |
|---|---|---|---|
Node | json, toml | lionagi_async_pg | JSON/TOML register when the node module loads; PostgreSQL is checked and registered only when that key is requested. |
Pile | csv, json | none by default | CSV/JSON register when the pile module loads. |
Exact semantics:
- Registering a class without
adapter_key/obj_keyraisesAdapterConfigurationError. Registering the same key again replaces that registry entry; there is no conflict error. - A missing sync or async key raises
AdapterNotFoundErrorand includesobj_keyin its details. An adapter returningNoneraisesAdapterErrorrather than being treated as a successful empty result. - Existing
AdapterError,KeyError,ImportError,AttributeError, andValueErrorinstances propagate from registry calls. Other exceptions are wrapped asAdapterErrorwith the original error text. AdapterBase._handle_error()maps the categoriesparse,validation,connection,query, andresourceto the corresponding typed class and preserves the original exception as the cause.- Error string rendering recursively redacts known credential keys and sensitive URL query parameters. Long string details are truncated under D6.
Nodeforcesto_dict(mode="db")on synchronous and asynchronous output andfrom_dicton input. Requestinglionagi_async_pgruns the availability check once. Missing optional dependencies or construction failures are swallowed and leave the key unregistered; the subsequent registry lookup therefore fails withAdapterNotFoundErrorinstead of making the base package unimportable.- Registry contents are in memory. On process restart, module import and lazy registration reconstruct them; there is no persisted registry state.
Why this way: the subject class defines which representations it supports, while adapters remain stateless conversion units. String keys allow optional and third-party formats, so missing-key behavior must stay explicit and tested.
D5 — Workspace path policy is stronger than path construction¶
Policy functions in lionagi/libs/path_safety.py and constructors in lionagi/ln/_utils.py remain distinct APIs.
The policy contract includes:
def resolve_workspace_path(path: str | Path, workspace_root: Path) -> Path: ...
def check_path_safe(value: str, field_name: str, *,
reject_absolute: bool = True, strip_at: bool = False) -> str: ...
def contain_and_resolve(path: str | Path, root: Path) -> Path: ...
def contain_path_in_root(value: str | Path, root: Path, field_name: str, *,
strip_at: bool = False) -> str: ...
def safe_join(root: Path, component: str) -> Path: ...
def validate_name(value: str, label: str = "name") -> str: ...
def validate_bare_name(name: str, label: str = "name") -> str: ...
def validate_path_component(component: str, label: str = "component") -> str: ...
The constructor contract is:
async def acreate_path(
directory: Path | AsyncPath | str,
filename: str,
extension: str | None = None,
timestamp: bool = False,
dir_exist_ok: bool = True,
file_exist_ok: bool = False,
time_prefix: bool = False,
timestamp_format: str | None = None,
random_hash_digits: int = 0,
timeout: float | None = None,
) -> AsyncPath: ...
def create_path(
directory: Path | str,
filename: str,
extension: str = None,
timestamp: bool = False,
dir_exist_ok: bool = True,
file_exist_ok: bool = False,
time_prefix: bool = False,
timestamp_format: str | None = None,
random_hash_digits: int = 0,
) -> Path: ...
Exact semantics:
resolve_workspace_path()expands~, resolves relative inputs beneath the workspace root, rejects a candidate that is itself a symlink, rejects a resolved containment escape, and rejects the protected basenames.env,.netrc,id_rsa,id_ed25519,id_ecdsa, and.htpasswd. Violations arePermissionError.check_path_safe()rejects NUL bytes,..components, and—by default—absolute and Windows drive-letter paths. Batch forms validate each element and return the original list on success. These validation failures areValueError.safe_join()accepts one non-empty component, rejects./.., slash, backslash, and NUL, resolves symlinks, and then checks containment.validate_name()additionally rejects glob metacharacters;validate_bare_name()restricts input to ASCII letters, digits, underscores, and hyphens.acreate_path()captures a resolvedbase_rootbefore a slash-separated filename can extend the directory. It rejects.or..components, backslashes, and any resolved directory/candidate outside that root. It creates parent directories, then raisesFileExistsErrorif the result exists andfile_exist_ok=False.create_path()accepts slash-separated filenames by moving their prefix underdirectory; it rejects backslashes but does not reject./..or perform fixed- root/symlink containment. It otherwise has the same extension, timestamp, random suffix, parent creation, and existing-file behavior.- An extension already present in
filenamewins over the separateextension. Timestamps default to%Y%m%d%H%M%S;time_prefixselects prefix versus suffix. A positiverandom_hash_digitsappends that many leading UUID-hex characters. acreate_path(timeout=None)has no internal deadline. A supplied timeout wraps the entire construction and raisesTimeoutErrorwhen the cancellation scope expires.create_path()has no timeout parameter.
Why this way: workspace containment is a policy decision and must be explicit at the call site. The async constructor has since acquired containment checks, while the older synchronous helper still preserves slash-separated construction behavior. Until the delta is resolved, they are not interchangeable for untrusted input.
D6 — Shipped numerical guardrails remain observable¶
The current limits are:
| Surface | Value/default | Behavior and rationale |
|---|---|---|
compute_hash() payload | 10 MiB (10 * 1024 * 1024) | Raises ValueError before hashing a larger encoded payload. The source records DoS prevention as the reason; the exact 10 MiB choice is inherited with no more specific recorded rationale. |
fuzzy_json() / JSON extraction input | 10 MiB by default | Rejects empty, non-string, or oversized input before repair/parsing. The source records memory-exhaustion prevention and calls 10 MiB generous for normal use; callers may pass a different max_size. |
Spec annotated-type cache | 10,000 entries by default | LRU-style bounded cache configured by LIONAGI_FIELD_CACHE_SIZE. It prevents unbounded generated-type retention; no rationale is recorded for exactly 10,000. |
FieldModel annotated-type cache | 10,000 entries by default | Thread-safe BoundedLRUCache, controlled by the same environment key. No rationale is recorded for exactly 10,000. |
FieldModel metadata warning | 10 items by default | LIONAGI_FIELD_META_LIMIT; exceeding it warns but does not reject. The warning asks callers to simplify field definitions; no rationale is recorded for exactly 10. |
| Adapter string detail | 500 characters | Error rendering truncates longer string values after redaction. This bounds diagnostic output; no rationale is recorded for exactly 500. |
acreate_path() timeout | None | No deadline unless supplied by the caller; there is no inherited numeric timeout. |
alcall()/AlcallParams retry | retry_attempts=0, retry_initial_delay=0, retry_backoff=1, retry_timeout=None | Zero retries, no inter-attempt delay, no growth, and no per-attempt deadline by default — a failing item makes exactly one attempt unless the caller opts in. No rationale beyond fail-fast-by-default is recorded. |
alcall() concurrency cap | max_concurrent=None (unbounded); max_concurrent=0 is also unbounded because the semaphore is only built if max_concurrent | No rationale is recorded for treating 0 as unbounded rather than raising; it follows from the truthiness check in the source, not a stated design choice. |
ln.concurrency.retry() | attempts=3, base_delay=0.1, max_delay=2.0, backoff_factor=2.0, jitter=0.1 | Independent of alcall's retry defaults (see D7); retries by default. No single rationale is recorded for the exact constants beyond the source's own "reasonable default backoff envelope" framing. |
bounded_map()/CompletionStream concurrency limit | no default; bounded_map(limit<=0) raises ValueError before scheduling any task | The guardrail is a required-positive invariant, not a numeric default — every caller must state a concrete bound. |
These are implementation contracts, not performance guarantees. Environment values are parsed with int() at import/construction time; malformed values fail rather than silently reverting. Changing a default changes memory, acceptance, or diagnostics and must be accompanied by tests and release documentation.
D7 — Batch execution is input-ordered; coordination primitives are completion-ordered¶
lionagi.ln splits concurrent execution into two layers. alcall/bcall apply one function across many items with input normalization, per-item retry, per-item timeout, a concurrency cap, and throttled task start, returning results in the original input order. Beneath them, ln.concurrency.patterns provides lower-level, general-purpose coordination primitives (gather, race, bounded_map, CompletionStream) plus a standalone retry() helper with its own, independently defaulted retry envelope.
The contract (lionagi/ln/_async_call.py, lionagi/ln/concurrency/patterns.py) is:
async def alcall(
input_: list[Any],
func: Callable[..., T],
/,
*,
input_flatten: bool = False,
input_dropna: bool = False,
input_unique: bool = False,
input_flatten_tuple_set: bool = False,
output_flatten: bool = False,
output_dropna: bool = False,
output_unique: bool = False,
output_flatten_tuple_set: bool = False,
delay_before_start: float = 0,
retry_initial_delay: float = 0,
retry_backoff: float = 1,
retry_default: Any = Unset,
retry_timeout: float | None = None,
retry_attempts: int = 0,
max_concurrent: int | None = None,
throttle_period: float | None = None,
return_exceptions: bool = False,
**kwargs: Any,
) -> list[T | BaseException]: ...
async def bcall(
input_: list[Any],
func: Callable[..., T],
/,
batch_size: int,
**kwargs: Any,
) -> AsyncGenerator[list[T | BaseException], None]: ...
@dataclass(slots=True, init=False, frozen=True)
class AlcallParams(Params):
_config: ClassVar[ModelConfig] = ModelConfig(none_as_sentinel=True)
_func: ClassVar[Any] = alcall
input_flatten: bool
input_dropna: bool
input_unique: bool
input_flatten_tuple_set: bool
output_flatten: bool
output_dropna: bool
output_unique: bool
output_flatten_tuple_set: bool
delay_before_start: float
retry_initial_delay: float
retry_backoff: float
retry_default: Any
retry_timeout: float
retry_attempts: int
max_concurrent: int
throttle_period: float
return_exceptions: bool
kw: dict[str, Any] = Unset
async def __call__(self, input_: list[Any], func: Callable[..., T], **kw) -> list[T]: ...
@dataclass(slots=True, init=False, frozen=True)
class BcallParams(AlcallParams):
_func: ClassVar[Any] = bcall
batch_size: int
async def __call__(self, input_: list[Any], func: Callable[..., T], **kw) -> list[T]: ...
The lower coordination layer:
async def gather(*aws: Awaitable[T], return_exceptions: bool = False) -> list[T | BaseException]: ...
async def race(*aws: Awaitable[T]) -> T: ...
async def bounded_map(
func: Callable[[T], Awaitable[R]],
items: Iterable[T],
*,
limit: int,
return_exceptions: bool = False,
) -> list[R | BaseException]: ...
class CompletionStream:
def __init__(
self,
aws: Sequence[Awaitable[T]],
*,
limit: int | None = None,
return_exceptions: bool = False,
): ...
async def __aenter__(self) -> "CompletionStream": ...
async def __aexit__(self, exc_type, exc_val, exc_tb) -> bool: ...
def __aiter__(self) -> "CompletionStream": ...
async def __anext__(self) -> tuple[int, T]: ...
async def retry(
fn: Callable[[], Awaitable[T]],
*,
attempts: int = 3,
base_delay: float = 0.1,
max_delay: float = 2.0,
backoff_factor: float = 2.0,
retry_on: tuple[type[BaseException], ...] = (Exception,),
jitter: float = 0.1,
) -> T: ...
Exact semantics:
alcallacceptsfuncas a single callable or a one-element iterable containing exactly one callable; anything else raisesValueError. Input is normalized before scheduling: ifinput_flattenorinput_dropnais set it routes throughto_list(); otherwise alistis used as-is, a PydanticBaseModel/msgspecStructinstance is wrapped as a single-element list (never iterated field by field), any other iterable is materialized to a list, and a non-iterable single value becomes a one-element list. An empty (post-normalization) input schedules no tasks and returns[]without creating a task group.- Results are written to a pre-sized
list[None] * n_itemsby original index inside each task, not appended in completion order — the returned list always preserves input order regardless of which item finishes first. This is the opposite ordering guarantee fromCompletionStream/race, which are explicitly completion-ordered. - Per item,
_execute_with_retrymakes one attempt, then up toretry_attemptsfurther attempts on any non-cancellationException(the cancellation exception class fromget_cancelled_exc_class()is never retried and always propagates immediately). With the defaultretry_attempts=0, a failing item makes exactly one attempt: ifretry_defaultis still the sentinelUnset, the exception propagates; if the caller supplied a concreteretry_default, that value is returned instead. Delay between attempts starts atretry_initial_delay(default0, so no sleep) and is multiplied byretry_backoff(default1, so no growth) after each failed attempt that had a nonzero delay. retry_timeoutwraps each individual attempt (not the whole retry sequence) inmove_on_after; expiry raisesTimeoutError, which is itself subject to the same retry policy as any other exception.max_concurrentgates concurrent execution with aSemaphore; the semaphore is only constructedif max_concurrentis truthy, somax_concurrent=0behaves identically tomax_concurrent=None(unbounded) rather than being rejected or meaning "no concurrency."throttle_period, when set, sleeps that many seconds after starting each task except the last before starting the next — it throttles task start spacing, not per-item execution time, and does not apply after the final item.return_exceptions=False(default): a failing task stores its exception atout[idx]and re-raises into the task group. The resultingBaseExceptionGroupis unwrapped bynon_cancel_subgroup: if every exception in the group is a cancellation, the original group re-raises unchanged; otherwise, if exactly one non-cancellation exception remains, it is raised directly (not wrapped in a group,from rest) for ergonomic single-exception catching; two or more non-cancellation exceptions raise the (sub)group itself, chainedfrom eg.return_exceptions=Truesuppresses all of this: failing items leave their exception object in the corresponding output slot and nothing propagates.input_unique/output_uniqueare delegated toto_list(..., unique=True), which requiresflatten=Truefor the same call; requesting*_uniquewithout the matching*_flattenraisesValueErrorfromto_list(), not fromalcallitself.*_dropnadropsNoneand sentinel-singleton entries only — aBaseExceptioninstance left in the output byreturn_exceptions=Trueis not itselfNone/a sentinel, sooutput_dropna=Truedoes not discard failed-item exceptions from the returned list.bcallslices its (always flattened-and-dropna'd, via an unconditionalto_list(input_, flatten=True, dropna=True)regardless of anyinput_flatten/input_dropnapassed in**kwargs) input into fixed-size chunks ofbatch_size(the final chunk may be shorter), and awaits one fullalcall(...)call per chunk before starting the next — batches never overlap.bcallis an async generator: it yields one per-batch result list at a time; a caller that wants all results as a single list must async-iterate and concatenate, which is exactly whatBcallParams.__call__does.AlcallParams/BcallParamsareParamssubclasses: only fields the caller explicitly sets at construction are recorded (unset fields read back as the sentinelUndefinedviagetattr(self, k, Undefined)and are excluded fromto_dict()/default_kw()), so callingAlcallParams(retry_attempts=2)(...)leaves every otheralcallparameter to fall back toalcall's own defaults rather than passing an explicit override.BcallParams.__call__popsbatch_sizeout of the merged kwargs before delegating (it is a positional parameter ofbcall) and collects every yielded batch into one flat list, so invoking aBcallParamsinstance returns a list, not the async generator that callingbcall()directly returns.gather()returns[]immediately for zero awaitables without creating a task group. Otherwise it follows the same pre-sized, index-written, ordered-result and exception-group-unwrapping behavior asalcall, keyed byreturn_exceptions.race()raisesValueErrorimmediately for zero awaitables. Otherwise every awaitable runs in its own task, each reporting(True, result)or(False, exception)into a capacity-1 memory stream;race()returns (or re-raises) whichever tuple arrives first and then cancels the task group's cancel scope, so the remaining tasks are cancelled at their next checkpoint. The winning exception is re-raised outside the task-group context specifically so it is not wrapped in anExceptionGroup.bounded_map()raisesValueErrorforlimit <= 0before touchingitems, and returns[]immediately for an empty (materialized)itemssequence before constructing aCapacityLimiter. Otherwise it is index-ordered likealcall/gather, gated by aCapacityLimiter(limit)rather than aSemaphore.CompletionStreamis an async context manager, not a coroutine:__aenter__opens a memory stream sized tolen(aws)and starts one task per awaitable, each optionally gated by a sharedCapacityLimiter(limit). Iterating it (async for idx, result in stream) yields(index, result)pairs in first-completed order — the opposite ordering guarantee fromalcall/gather/bounded_map. Withreturn_exceptions=True, a failing awaitable's exception is yielded as the result half of its tuple; with the defaultreturn_exceptions=False, it propagates and is raised through__aexit__instead. If the consumer stops iterating before every result has arrived, a still-running runner's attempt to send its result raisesanyio.ClosedResourceErrorinternally, which is caught and the result silently discarded — early exit is a supported, non-error path, not a resource leak.retry()is a standalone helper independent ofalcall's retry parameters: it defaults to retrying (attempts=3) rather thanalcall's default of no retry (retry_attempts=0). It validatesattempts >= 1,base_delay > 0,max_delay >= 0,jitter >= 0,backoff_factor >= 1.0, and rejects aretry_ontuple that includes the current async backend's cancellation exception type, all viaValueErrorbefore the first attempt. Backoff delay ismin(max_delay, base_delay * backoff_factor ** (attempt - 1)), jittered bydelay *= 1 + random() * jitter. When an effective deadline is active (a surroundingmove_on_at/fail_atscope), a delay that would cross the deadline raises immediately instead of sleeping past it.
Why this way: centralizing retry, timeout, throttling, and ordering in alcall/bcall keeps that logic out of every call site that needs to apply one function across many items — the same problem P1 names for the rest of ln. Preserving input order in alcall/gather/bounded_map matches how callers index results back against their original request list; race/CompletionStream exist specifically for the opposite case, where the caller wants the earliest result(s) rather than a positional one, so they are completion-ordered instead. The retry parameters on alcall and the standalone retry() helper are independently defaulted because they answer different questions: alcall's retry_attempts=0 default treats retry as an opt-in per-item safeguard for batch work, while retry()'s attempts=3 default targets a single call site that already decided it wants resilience. Collapsing them into one shared default would either make every batch call retry silently or make every explicit retry() caller need to opt in to what they already asked for.
Consequences¶
- Common primitives and the two-state sentinel convention remain available through stable, compact import surfaces. Framework-neutral field descriptions can be used before choosing Pydantic materialization.
- Deterministic JSON and hashing behavior is shared, but callers hashing content larger than 10 MiB must choose a different streaming design rather than expecting
compute_hash()to accept it. - Pydantic conversion and format adaptation can evolve without duplicating field semantics across protocol types. Adding a new
NodeorPileformat is localized to an adapter plus registration. - Runtime string keys are not statically closed. A typo and an unavailable optional integration both surface as
AdapterNotFoundError; callers must not treat a registry miss as a successful empty conversion. - The lazy
ln-to-adapter edge, thelibs/modelsknot, and compatibility aliases constrain file moves. Reversing D1 or D3 requires an import migration and cycle tests, not only relocation. - The synchronous and asynchronous path creators do not offer interchangeable safety guarantees. Code handling caller-controlled paths must use workspace-containment policy or the fixed-root async constructor.
- Callers get one ordered, retryable batch primitive (
alcall/bcall) and a separate set of completion-ordered coordination primitives, instead of choosing ordering behavior ad hoc; but a caller that wants completion order fromalcallitself has no parameter for that — they must switch toCompletionStream. The two independently defaulted retry mechanisms (alcall'sretry_attempts=0vs.retry()'sattempts=3) mean a contributor who assumes one governs the other will silently get the wrong retry behavior; this is now a recorded, testable contract rather than an implicit difference. - Contributors changing sentinel handling must test constructor validation, Python, JSON, DB, and recursive
Noteserialization; omission is distributed across those paths. - Reversing D4 would affect every adaptable subject and its error handling. Reversing a D6 bound affects accepted inputs or retained memory and therefore is not a private constant change.
Current-vs-ideal delta¶
| # | Delta | Size | Issue |
|---|---|---|---|
| 1 | Relocate Pydantic model emission behind a Pydantic-side factory, preserve Operable.create_model() through a documented compatibility shim for one minor release, and add a test that fails if any lionagi.ln module imports adapters, models, protocols, operations, or session code. | M | (filled at issue-open time) |
| 2 | Move generic nested-data and scalar-coercion helpers into lionagi.ln, retain their lionagi.libs import paths as deprecated re-exports for one minor release, replace internal UNDEFINED spellings with Undefined, and verify that Note and LNDL behavior is unchanged. | M | (filled at issue-open time) |
| 3 | Give create_path() and acreate_path() equivalent traversal and symlink-containment semantics, or rename and document the synchronous helper as unconstrained path construction; add symmetric tests for nested filenames, .. components, and symlink escapes. | S | (filled at issue-open time) |
Alternatives considered¶
Merge ln and libs¶
One package would remove the naming question and eliminate some imports between the two. It lost because workspace containment, schema generation, content ingestion, and Pydantic-oriented validation are policy-bearing dependencies. Moving them into the high-fan-in primitive surface would make low-level concurrency, hashing, and conversion consumers transitively depend on policy they do not use.
Split every helper into a separate public package¶
Fine-grained packages could make dependency ownership visually precise. They would also multiply import paths, public compatibility promises, and discovery cost without creating new process or persistence boundaries. The existing ln submodules already provide internal decomposition while ln.__all__ provides one supported surface.
Treat None as the only missing value¶
This would remove the sentinel classes and simplify signatures. It lost because a caller must be able to submit an explicit null while separately expressing an absent field or an unassigned declared parameter. Serialization and strict validation depend on those distinctions today.
Put Pydantic directly in Spec and Operable¶
Direct FieldInfo and BaseModel construction would make create_model() simpler and remove the adapter hop. It lost because field descriptions would then import and encode one materialization framework at the primitive layer. The adapter/model path is the existing place where framework-specific defaults, validators, and schema extras are interpreted.
Keep the lazy Pydantic edge as the permanent layer direction¶
The current method is convenient and avoids an immediate compatibility change. It lost as the ideal direction because the primitive package would permanently know its materialization mechanism. The retrospective contract retains the method; the delta requires a Pydantic-side factory plus a compatibility shim before removal.
Use a global adapter registry for every subject¶
A global format-to-adapter map would simplify registration lookup. It lost because Node and Pile intentionally support different formats and invoke different serialization methods. Subject-class registries prevent a key from implying support for types an adapter cannot reconstruct.
Eagerly import all optional adapters¶
Eager registration would make adapter availability visible immediately. It lost because importing the base package would then depend on optional database libraries and configuration. The lazy PostgreSQL path preserves base importability and converts unavailability into the standard missing-key failure.
Declare create_path() and acreate_path() equivalent¶
One documented contract would be easier for callers. It is rejected as a description of current code: the synchronous helper accepts slash-separated .. components and does not fix/check a root, while the asynchronous helper explicitly rejects traversal and resolved escapes. The delta must change code or naming before equivalence can be claimed.
Notes¶
Primary source anchors for this retrospective record are lionagi/ln/__init__.py, lionagi/ln/_async_call.py, lionagi/ln/concurrency/patterns.py, lionagi/ln/_hash.py, lionagi/ln/_utils.py, lionagi/ln/fuzzy/_fuzzy_json.py, lionagi/ln/types/_sentinel.py, lionagi/ln/types/base.py, lionagi/ln/types/spec.py, lionagi/ln/types/operable.py, lionagi/libs/path_safety.py, lionagi/libs/nested.py, lionagi/models/field_model.py, lionagi/models/hashable_model.py, lionagi/models/note.py, lionagi/adapters/_base.py, lionagi/adapters/spec_adapters/pydantic_field.py, lionagi/protocols/graph/node.py, and lionagi/protocols/generic/pile.py.