Skip to content

Runtime internals reference

Terse, per-module reference for invariants, protocol contracts, and non-obvious design rationale that used to live as long-form docstrings/comments in the runtime source (lionagi/state/, lionagi/service/, lionagi/providers/, lionagi/tools/, lionagi/agent/, lionagi/testing/, lionagi/dispatch/, lionagi/hooks/). The source now carries a 1-2 line pointer; this file carries the substance. Organized by module path.

lionagi/state/

state/engine.py

  • make_readonly_engine() — read-only AsyncEngine over an existing SQLite file, opened through SQLite's own URI read-only mode (mode=ro), not through make_engine(). This matters because: (1) no schema/PRAGMA write ever reaches the file — the OS-level open is read-only and SQLite raises on any write attempt; (2) none of make_engine()'s mutating connect-time PRAGMAs (journal_mode, synchronous, wal_autocheckpoint) are applied here on purpose, since they persist into the database file itself; (3) only busy_timeout (session-scoped, never persisted) and query_only (belt-and-suspenders — SQLite itself rejects any write statement) are set. SQLite only — a genuinely read-only Postgres connection should use a read-only DB role instead; there is no equivalent "connect without side effects" mode to fake at this layer for Postgres.
  • has_wal_reset_fix() — SQLite documents a data race between a starting checkpoint and a commit that resets the WAL file: the checkpoint misses the reset, mis-sets a WAL-index header field, and a later checkpoint then skips part of the committed transaction, corrupting the database. It reaches every WAL-mode release from 3.7.0 up to and including 3.51.2, fixed in 3.51.3 with backports on the 3.44 and 3.50 branches. Exposure needs two connections writing or checkpointing at the same instant, which is exactly what this store does — hence the startup warning when an unfixed SQLite is linked.

state/health.py

classify_session_health() — pure function; process_alive is tri-state: True = observed alive, False = confirmed dead (positive evidence — a recorded pid that is no longer running), None = unknown (no recorded pid and no process match, the normal case for externally-driven sessions mirrored into the DB). Decision order matters:

  1. Terminal sessions (completed/failed/timed_out/aborted/cancelled): done means done unless stale locks were left behind (→ ZOMBIE). has_artifacts alone is not zombie evidence — artifacts are a good outcome; stale locks are the operational signal.
  2. Orphan check runs first among the non-terminal branches: a session advertised but never producing a single message AND no artifacts on disk crashed before doing anything; transitioning it to failed is harmless, deleting it is also safe.
  3. Confirmed dead (process_alive is False) outranks the activity guard below it — the process is gone no matter how fresh the last message is.
  4. Unknown liveness (no matchable pid) trusts recent messages more than process visibility, because externally-driven sessions (mirrored into the DB from another process) never expose a matchable pid — an unmatched process only means dead once activity has also gone quiet past the kind-aware threshold.

state/transitions.py

Guarded compare-and-swap state transitions (ADR-0059) — a minimal counterpart to the entity-agnostic transition() API proposed in ADR-0058. Carries the same request/result shape and reason-code discipline so ADR-0058 can absorb it as a refactor, not a migration. Scoped to entity_type='dispatch' (dispatch_outbox) and entity_type='schedule_run' (schedule_runs) only — not a general TransitionStore. The guarded read/CAS/vocabulary/write algorithm itself lives in lionagi.state.lifecycle (shared with StateDB.update_status()); this module keeps its own narrower entity-type boundary, its guard/patch column allowlist, and the legacy TransitionResult return shape.

  • _ENTITY_TABLES"schedule_run" is ADR-0071 D2's generalized task-application entity (schedule_runs table, schedule_id now nullable), registered here so ALL status movement on it routes through this guarded CAS store rather than a second, parallel implementation.
  • _GUARD_PATCH_COLUMNS — guard/patch column names are interpolated directly into SQL text (values stay bound params) inside the lifecycle service. Production call sites pass literal dicts, but this module is a generic surface, so a per-entity allowlist closes the latent injection surface instead of trusting the caller's dict keys outright.
  • transition()UPDATE ... WHERE id=:id AND status=:from, writing the row status and an atomic status_transitions append inside one transaction. A mismatched current state reports a conflict rather than raising or silently overwriting (the CAS guard). An undeclared status move (per the shared policy registry's edge graph) raises ValueError — this surface has no override mechanism, unlike StateDB.update_status(). guard adds extra column = :expected equality constraints to the WHERE clause beyond status — required whenever a transition can be a same-state no-op (e.g. delivering -> delivering recovery claims), where the status guard alone would match trivially and let two concurrent callers both believe they won the claim; callers pass the value they read before the transition as the expected guard value, and only the caller whose guard value still matches at UPDATE time wins. patch adds extra column = :value assignments to the SET clause, applied atomically with the status change and the status_transitions append — for callers (e.g. an operator-forced retry resetting attempt counters) that would otherwise need a second, non-atomic write.
  • "rejected" is unreachable through this surface: run_legacy_transition() passes raise_on_undeclared_edge=True, so an undeclared edge (terminal or not) raises ValueError above rather than resolving to "rejected", and TransitionRequest carries no override field to trigger the override path either.

state/claude_mirror.py

Mirrors Claude Code session transcripts (~/.claude/projects/*.jsonl) into StateDB. A Claude Code session is just another writer to state.db: each JSONL event maps to one or more lionagi messages, written under a session/branch/progression with deterministic ids so re-reading the same transcript never duplicates rows. The studio SSE reader polls the same tables, so mirrored sessions stream live in the dashboard and the VS Code extension with no studio-side change.

  • mirror_session() — re-calling with already-seen events is a no-op: message ids are deterministic (upsert), and progression appends dedupe. Creates the session/branch on first call with a rich row (project, model, agent_name) so it groups correctly in the runs explorer. Live/idle transitions are owned by reconcile_session_status, not this writer. Scaffold writes (progressions → session → branch) are INSERT OR IGNORE and re-run every call in dependency order, so if an earlier pass died mid-scaffold (e.g. the branch write raised after the session row committed) the next pass repairs the partial state instead of skipping scaffolding just because the session row now exists. The project-backfill branch (elif project and not existing.get("project")) exists because INSERT OR IGNORE never updates an existing row — without it, an already-seen "(no project)" session stays that way forever; the backfill write does not disturb the liveness clock.
  • reconcile_session_status() — a mirror session's completed means dormant, not terminal: when the transcript resumes, the next reconcile brings it back to running. Liveness is judged by last_message_at (the timestamp of the newest mirrored message) so an idle session converges to completed before the reaper can mark it failed, and an active one shows running (a live spinner in studio and the VS Code extension). It must NOT read updated_at: the status write below bumps updated_at, so keying liveness off it would let a just-marked completed session read as fresh again on the next pass and oscillate back to running. ADR-0035's integrity floor treats every session terminal status (not just completed) as terminal on the sessions table for orchestrated runs, so reactivating a mirror session out of any of them goes through the sanctioned override path — a real, deliberate, well-understood write (not a repair), attributed to the recorded automated override identity, landing in admin_events like any other override. A mirror session that is idle and already sitting on a non-completed terminal status (e.g. independently marked failed or cancelled) is left alone rather than rewritten to completed — only a live transcript resuming can justify pulling it back to running.
  • link_session_lineage() — a continued conversation (after compaction, --resume, or a fresh window picking up an earlier thread) starts a new transcript whose first message points, via parentUuid, at the last message of the session it continues. When the mirror resolves that pointer to a different session it stores a lineage link on the child's node_metadata so studio and the VS Code extension can show provenance and walk the chain back. Written without moving the liveness clock; idempotent (re-linking rewrites the same value).

state/completion_evidence.py

Completion-trust gate: cheap, local, no-network evidence that a run produced something. A session can exit its loop cleanly with nothing to show for it — no commits, no artifacts, no diff — and stamping that completed makes the status meaningless as evidence of delivered work. This module gives the teardown path a lightweight git-based signal to fall back on when no artifact contract caught the emptiness: is HEAD ahead of the base ref, or does the working tree carry uncommitted changes? A probe that actually runs and fails (transient error, timeout, git hiccup) must never be read as "ran and found nothing" — that would silently turn a git error into a false completed_empty on real work. Only a probe that succeeds is allowed to report an absence of evidence; any decisive failure bails the whole check out as unchecked (checked=False) so the caller keeps trusting completed.

state/lifecycle/

Unified lifecycle transition service: one guarded read-check-write-history algorithm shared by every managed entity type's status transitions, replacing per-surface transition logic. Public surface: immutable command/result records (models), the policy registry (policy), the SQLAlchemy transaction implementation (service), and the StateDB/legacy-transition compatibility mapping (adapters).

  • service.pytransition() is the public entry point, enforcing the policy's declared-edge graph: an undeclared move is a "rejected" outcome with a rejection audit row, not a raise, so callers get the same outcome shape for terminal-exit and undeclared-edge refusals alike; a valid override is the audited escape hatch for either. _transition() accepts additional keyword-only parameters outside the public TransitionCommand shape, used only by lionagi.state.lifecycle.adapters to keep the two legacy compatibility wrappers behaviorally identical to their pre-existing selves: extra_guard (an arbitrary per-column WHERE-clause guard, e.g. dispatch's delivering -> delivering crash-recovery claim guarding on attempt, which the public typed command has no generic field for), enforce_edges (StateDB.update_status() never enforced a declared-edge graph, only terminal-exit-requires-override and vocabulary membership, so it calls with enforce_edges=False; the legacy lionagi.state.transitions.transition() did enforce one, for schedule_run, so it calls with enforce_edges=True), and raise_on_unguarded_conflict (an unguarded zero-row UPDATE is a storage anomaly StateDB.update_status() has always raised RuntimeError on, from inside the transaction so a same-transaction rollback still occurs). A self-edge's required_guard_fields must be satisfied by either extra_guard covering those exact columns or a generic expected_version guard (updated_at, which the write always bumps) — either is an equally strong optimistic-concurrency guard against two callers holding the same snapshot both winning a crash-recovery claim; missing both is a caller-contract violation, so it raises rather than returning a conflict/rejected outcome. Commit (via the _tx() context exit) happens strictly before the terminal-callback registry push, so a handler can never delay, observe-before-commit, or roll back the write. _write()'s write_reason_columns mirrors legacy per-surface behavior: StateDB.update_status() always denormalized the reason onto the entity row's own status_reason_* columns (the default); the legacy lionagi.state.transitions.transition() surface never did, and dispatch_outbox (only reachable through that surface) doesn't even have those columns.
  • callbacks.py — a RunTerminalEnvelope is constructed by the lifecycle service only after a guarded transition commits and lands on a terminal status for an execution entity (session, invocation, schedule_run, play); the registry then pushes it to every matching handler concurrently under one shared deadline (best-effort — a handler failure, timeout, or cancellation is logged and swallowed, never affecting the already-committed transition or delaying the caller past budget). Within schema_version == 1 the envelope's guaranteed fields never change name/type/semantics/ requiredness; new optional fields may be added without a version bump. register()'s override marks a per-run override: for any envelope it matches, only override registrations fire, replacing any non-override match for that run's scope only — other envelopes the override doesn't match are unaffected. In emit()'s handler fan-out, a plain (non-async-def) handler is offloaded to a worker thread rather than run directly (which would block the event loop and starve the shared move_on_after deadline); abandon_on_cancel=True is required (not the default) so the deadline can still cut the await short without waiting for the thread to finish on its own — the thread itself is only abandoned, not killed, and may keep running in the background after return.
  • notify_settings.py — resolves notify.on_terminal (a string compatibility form, or a mapping {enabled, adapter: {kind: exec|python, ...}, filter: {kinds, ids}}) into a handler installable on a TerminalCallbackRegistry. Precedence is per-run override > project settings > global settings > disabled; absent key and explicit enabled: false are both disabled. No configuration shape ever reaches a shell: a plain command string is POSIX-word-split (shlex.split) and launched via asyncio.create_subprocess_exec, never create_subprocess_shell; a string that fails to split or needs shell features (pipes, redirection, conjunction, variable expansion) warns with a migration diagnostic and resolves to disabled, as does any resolution producing an empty argv. A resolution error never fails or delays the run it would have described. In the exec adapter, on cancellation the registry's own shared deadline races the call's identical wait_for timeout (the outer one started first and typically wins); either way the child (launched with start_new_session=True, its own process group) must be reaped or it orphans a live subprocess, so cleanup runs inside a shielded CancelScope since the enclosing scope is already cancelled.
  • deliveries.py — acknowledgment is durable state written only by a named reconciliation consumer, never by the in-process push path (TerminalCallbackRegistry stays fire-and-forget and records nothing here). The reconciliation query is a read-only anti-join: terminal transitions on execution entities with no delivery row yet for the requesting consumer, with no age filter on either side — a late-committing older row, or an event from a long-offline consumer, stays unacknowledged indefinitely (this module never expires an unacked event on its own).
  • schema_meta.py's session_controls table — apply/stamp ordering is verb-classed: pause/resume are idempotent (apply, then stamp — safe to re-apply on a poller crash); message is not (stamp 'applying', then apply, then finalize — a crash surfaces as an unapplied 'applying' row rather than risking a double injection). 'stop' is schema-reserved and rejected by the current poller as unsupported; no CLI verb emits it yet.
  • policy.py's ImmutableEdgeMap — deliberately not a dict subclass: dict's C-level mutators reach the underlying storage without going through Python-level overrides (dict.__setitem__(m, ...), inherited __ior__, re-invoking __init__), so a subclass can never actually guarantee immutability. Wrapping a private dict behind the Mapping interface leaves no inherited mutation surface at all — no __setitem__, update, or __ior__ to reach, and re-invoking __init__ is refused — while pickle/copy.deepcopy still round-trip via __reduce__ (reconstructing through the constructor) and dataclasses.asdict() deep-copies the map rather than raising. PolicyRegistry.register() wraps every policy's edge map this way before storing, so a caller holding a policy from get() cannot mutate global transition behavior for the process.
  • notify_settings.py stderr/argv redaction contract — a notify.on_terminal adapter's argv routinely carries secrets (webhook URLs, tokens passed as args), and its stderr is adapter-controlled free text whose most common leak shape is the adapter echoing its own invocation back on failure. No surface (warn-channel line, persisted notify_outcome.json, or the log) carries raw argument values or an unfiltered stderr line: adapters are identified by argv[0]'s basename, and any argument value appearing verbatim in a stderr or exception snippet is replaced (longest values first, so a substring never leaves a partial value behind) before that snippet goes anywhere. This is not a general secret scanner — a secret the adapter obtains elsewhere and prints cannot be recognized. Argument values shorter than MIN_REDACTABLE_ARG_LEN (4 chars) are never redacted, since replacing them would corrupt unrelated text (a bare -v or 0 occurs everywhere). Raw adapter stderr is captured to an owner-only file and referenced by path instead of surfacing directly, for the same reason.
  • notify_settings.py's register_run_notify_outcome_scope() — returning None says only that nothing was registered; a notifier this run asked for and could not have is recorded onto the run before returning (an unsuccessful outcome carrying the reason). The two benign cases — nothing configured, and this entity excluded by the configured filter — write nothing, which is what tells them apart from a refusal. The scoped registration is an override, so it dispatches on its own match rather than deferring to the process-wide registration's filter, and therefore re-applies the configured filter itself.

state/artifact_verifier.py

  • _resolve_produced() — an entry naming a directory is matched exactly. A bare filename is matched at the root first, then in any immediate subdirectory: in a multi-agent run each worker writes into its own subdirectory, and which worker produces a given artifact is decided when the plan is cast, so the author of a playbook contract cannot name that directory in advance — requiring one would make a bare filename impossible to satisfy. Declaring what is expected and knowing who produces it are held by different parties; only the first belongs in the contract. Subdirectories are searched in sorted order, so a filename produced by more than one worker resolves to the same one on every run rather than to whatever the filesystem happened to list first.

state/reasons.py

  • RunReasons.COMPLETED_SPAWN_REFUSED — the planned DAG reached completion, but one or more reactive SpawnRequests were refused after the run exhausted its spawn capacity. Status remains completed; the non-clean reason and refused_spawn evidence distinguish it from a run that never requested more work. The same reason is preserved when child sessions roll up to an invocation.
  • ScheduleReasons — the schedule.skipped. prefix is not the full set of reasons a skipped schedule_run can carry. DEFERRED_CAPACITY and BUDGET_EXHAUSTED land on skipped rows without that prefix, and a third path — the task-admission path — stamps a schedule_run to skipped with the admission decision's own code, falling back to RunReasons.SKIPPED_WAITER_CAP_EXCEEDED. No enumeration here can be closed: the admission writer takes its code from a decision object rather than a literal, and the only bound anywhere in the system is VALID_REASON_CODES, the union across every reason class in this module. A consumer filtering skipped rows by the schedule.skipped. prefix silently drops capacity deferrals, budget exhaustion, and admission rejections.
  • SKIPPED_OVERLAP — stamped when a fire arrives while the previous run of the same schedule is still going.
  • SKIPPED_MISSED_FIRE — stamped for a fire whose due instant passed while the scheduler was not running, on a schedule whose missed-fire policy is not to run it late. Detection is once per process start, not continuous — the missed-fire sweep runs in the tick loop's preamble, before the loop begins, and never again for the life of the process. A scheduler that stalls while still running records nothing at all. The row's timestamp is therefore bounded by time-to-restart, not by tick interval — closer to a restart timestamp than a detection latency, and not comparable to a fired row's lateness.
  • SKIPPED_PRECONDITION — no code path evaluates a precondition and stamps this; it is the default reason attached to a schedule_run that moves to skipped without an explicit code, so in practice it means "skipped, and the writer gave no reason." Treating it as evidence a precondition was checked and failed is reading a fallback as a finding.
  • DEFERRED_CAPACITY — same kind of trap: these rows are sampled, not one-per-event. The scheduler counts every deferral and records only the first, then one every N deferrals after that (N is a scheduler-module constant), so sustained saturation does not flood schedule_runs. Counting these rows undercounts deferrals, and a row's timestamp is the sampled deferral's rather than the first one's in that stretch.

state/schema.sql / state/schema_migrations.py

  • The first_msg_id/last_msg_id/system_msg_id message-pointer indexes — measured on a 3.9 GB store, indexing these columns took a message delete from 8.47 ms/row to 0.86 ms/row. Not partial indexes: the search SQLite runs for a foreign key is not the query planner's, and only a plain index is certain to serve it.

lionagi/plugins/registry.py

Combines discovery + trust + settings into one snapshot, two-stage lazy like EndpointRegistry._ensure_loaded. Stage 1 (_ensure_loaded): manifests are scanned/parsed the first time any consumer asks, cached for the process. Stage 2 (activate_target): a declared target/module is imported only when that capability is actually invoked, never as a side effect of discovery or an unrelated capability firing. Eligibility (compatible + enabled) and trust are revalidated fresh on every call, re-reading plugin.yaml, settings, and every declared file from disk — an already-activated target stops being handed out the moment the plugin is disabled or a declared file/manifest changes, not just refusing brand-new activations. The specific target file is read exactly once: that read's hash (checked against the currently recorded trust entry) and the bytes that get compiled/exec'd are the same read_bytes() call (_read_and_verify_target_bytes), never a hash-then- reopen sequence that would leave a TOCTOU window for the file to be swapped. _exec_bundle_module compiles the pre-read bytes directly rather than going through importlib's spec_from_file_location/exec_module path, which writes/reads a __pycache__ .pyc validated by second-granularity mtime — two writes within the same wall-clock second are indistinguishable to it, so a re-import right after a re-trusted edit could silently execute stale bytecode. Import results (success or failure) are cached per (plugin, target, content hash), so re-trusting changed content is a guaranteed cache miss rather than depending on an earlier call having evicted the old entry. _rescan() re-reads and re-parses plugin.yaml itself rather than reusing the cached record.manifest — a stale cached manifest object always re-derives the same "trusted" hash regardless of what's actually on disk, since the manifest hash is computed by re-serializing the parsed object, not by re-reading the file. _target_resolution_map builds target -> (module_path, attr) from the manifest's own typed capability lists using the same parse_tool_target split the hashing path (discovery ._collect_declared_paths) uses — two independently written splitting expressions could disagree on where a path ends and a callable begins; one shared parser can't. Nothing in this module runs at import lionagi time.

lionagi/service/connections/mcp_wrapper.py

Security-critical admission-control gate for MCP (Model Context Protocol) tool descriptors: decides whether an externally-supplied MCP tool schema is safe to register, defending specifically against a generic command/process/script executor (a "bash"-shaped tool) masquerading as something narrower via an insufficiently-bounded JSON Schema. This is orthogonal to two other security mechanisms: MCPSecurityConfig governs transport authorization (is this command/URL allowed to connect at all), and PermissionPolicy governs invocation-time authorization keyed by tool name. This admission rule sits before both: it rejects a caller-shaped generic executor descriptor before the tool is ever admitted into the registry, regardless of transport settings.

Identifier/key classification

  • _is_identifier_like_key() — identifies dynamic-but-benign resource identifiers (service_id, resource_path, request_id, matching *_id/*_path/*_uri/*_url/ *_uuid/*_slug). Excluded from the strong-executor-name "must be affirmatively bounded" fallback: a tool with a strong executor name but whose only free-form field is an identifier-shaped key is NOT treated as executor-shaped — a fixed-operation tool addressing a resource by free-form ID differs legitimately from one taking a free-form command/script, even though the identifier field itself is an unbounded string.
  • _EXEC_TAINTED_KEY_TOKENS — tokens that make an otherwise identifier-shaped key still executor-shaped. A key like executable_path/script_path/command_path lexically matches the identifier-suffix exemption but its root token names an executor channel — a caller-controlled executor target, not a benign locator — so it must NOT be exempted from the strong-name fallback.
  • _is_exec_tainted_key() — companion to the above: true when a key's own _-split tokens name an executor channel (command, cmd, shell, script, program, binary, executable, argv, args), overriding the identifier-suffix exemption.
  • _UNBOUNDED_NON_OBJECT_TYPES — non-object types (string/number/integer/array) whose instances aren't intrinsically finite. A root type union including one, without a top-level enum/const pinning the instance, has a branch admitting an arbitrary value, bypassing every object-shaped constraint entirely because the instance never has to be an object.
  • _ANNOTATION_ONLY_REF_SIBLING_KEYWORDS / _has_structural_ref_siblings() — keywords that, as siblings of $ref, are annotation-only and never constrain the instance (description, title, $comment, examples, default, $defs, definitions). Per Draft 2020-12, $ref siblings are NOT discarded — they constrain the same instance and must be evaluated in their own right, so a non-annotation sibling forces the sufficiency proof to also evaluate it as its own schema node.

Keyword registry for the sufficiency proof

Answers exactly one question: is this schema document provably closed against an undeclared value? A per-property "is this value a key channel" discriminator — deciding which positions deserve scrutiny — is the same defect class as enumerating "dangerous keywords," re-entered through the traversal axis: omit the conditional applicators (if/then/else/not) or array applicators (items/prefixItems) and a property carrying one is never visited, so the allowlist check that would have denied it never runs. The fix: classify EVERY Draft 2020-12 keyword into EXACTLY ONE of four classes (inert/ bounding/modeled/denied), then walk the ENTIRE document — every property value, composition branch, $ref target, $defs entry — UNCONDITIONALLY, checking each node's own keywords against the registry. There is no longer a discriminator deciding "should this node be visited"; a keyword not in the registry is UNKNOWN and fails closed unless its value provably cannot carry a subschema.

  • _INERT_ANNOTATION_KEYWORDS (contentSchema caveat) — contentSchema is inert with an individually-argued exception, not by default: its value is a mapping describing the DECODED content of a string instance, like contentEncoding/contentMediaType — none assert anything about the instance itself, but ONLY while the content-assertion vocabulary stays disabled (the default dialect this module validates against). A future dialect enabling that vocabulary would require contentSchema to leave this class — a maintenance tripwire.
  • _DENIED_APPLICATOR_KEYWORDS — applicators recognized BY NAME but deliberately NOT modeled (patternProperties, propertyNames, unevaluatedProperties, unevaluatedItems, dependentSchemas, if, then, else, not, contains, items, prefixItems, $dynamicRef, $dynamicAnchor, $recursiveRef, $recursiveAnchor): presence denies the node unless the bounded-array proof establishes that every prefixItems position and the items tail is enum/const-bounded (recursively for nested arrays), the tail is explicit, and unevaluatedItems cannot reopen evaluation. The structural walker then still visits each bounded item subschema, so an unrelated denied or unknown applicator remains fail-closed.
  • _classify_keyword() — classifies a keyword into inert/bounding/modeled/denied; unrecognized names are UNKNOWN. The registry is a CLOSED enumeration, not a spelling heuristic — an unseen keyword fails closed rather than being guessed at.

Object-boundedness proof

  • _property_value_may_be_object_shaped() — true when a declared property's VALUE could itself resolve to an OBJECT instance, requiring the boundedness proof to recurse. Deliberately narrow: only answers "does closedness apply here," never "is a keyword modeled" — that's answered totally and unconditionally by _structural_coverage_insufficient regardless of whether this gate recurses, so an omission here (e.g. a value reachable only through a DENIED applicator) is harmless. A scalar/array/annotation/free-form-string property value returns False on purpose — it remains the walker's territory by key name (service_id: {"type": "string"} stays admitted).
  • _schema_is_insufficient() — top-level gate: insufficient if EITHER the object-boundedness proof OR the structural-coverage proof fails. The two proofs are deliberately independent (type/closedness vs. keyword coverage), combined by OR.
  • _object_boundedness_insufficient() — recursive, union-aware TYPE-GATE + CLOSEDNESS check, orthogonal to _structural_coverage_insufficient (never denies on keyword identity, only on whether the instance is provably constrained to a closed, finite object shape). Binding order of checks (applicator delegation MUST run before omitted-type denial, or a legitimately-type-omitting applicator-root would false-deny): (1) budget/depth cap fails closed; (2) type excludes "object" → insufficient; (3) type union has a free-form alternative with no const/enum pin → insufficient; (4) APPLICATOR DELEGATION — $ref (local only, intersect structural siblings), then oneOf/anyOf (UNION: every branch must prove sufficient), then allOf (INTERSECTION: one bounded branch suffices), each recursing; (5) top-level const/enum pins the instance → sufficient regardless of type; (6) LEAF-OBJECT branch: omitted type (no const/enum) → insufficient (bare non-object never reaches object keywords); non-empty properties only bounded if CLOSED (additionalProperties: False or itself enum/const-restricted); empty/absent properties still needs additionalProperties: False; once the outer object is closed, each declared property's own object-shaped value is re-checked by this same predicate recursively, or insufficient — non-object-shaped values are left to the walker's key-name policy since any denied applicator they carry is caught independently by _structural_coverage_insufficient. Returns True (fail closed) for external/cyclic/unresolvable/exhausted references or compositions.
  • Type-array handling: a Draft 2020-12 type array (e.g. ["object","null"]) is still an object schema if "object" is among its types, and properties must still be inspected; only excluding "object" entirely is insufficient.
  • Type-union weak-link: a type union including "object" plus a free-form alternative (string/number/integer/array, absent enum/const) is only as bounded as its LEAST-bounded branch — an instance satisfying the non-object branch never reaches the object-specific keywords checked below.
  • $ref sibling re-check: Draft 2020-12 evaluates $ref siblings; a closed reference target doesn't make the node sufficient if a sibling keyword (evaluated against the same instance) reopens it. Pure annotation siblings contribute nothing and must not force an otherwise-open target's insufficiency onto an unrelated property.
  • const/enum pin: a top-level const/enum pins the instance to literal value(s), satisfying the type-gate regardless of type (or its absence).
  • Closedness default trap: additionalProperties defaults to permissive (implicit true); a fixed operation enum/const does NOT stop an undeclared command property from riding alongside it unless additionalProperties is explicitly false (or itself enum/const- restricted). This is the concrete attack shape: a schema that looks locked down because one property is enum-restricted while every other key name is still wide open.

Structural-coverage proof

  • _structural_coverage_insufficient() — total, registry-driven traversal: does any schema-bearing position carry a keyword the sufficiency proof does not model? Independent of _object_boundedness_insufficient's type-gate/closedness reasoning — applies neither. A scalar leaf {"type": "string"} is sufficient on its own, preserving a free-form identifier- key property (service_id) alongside a fixed operation. Every schema-bearing position is visited UNCONDITIONALLY — every properties value, additionalProperties schema (Mapping-valued), composition branch, resolved local $ref target, and $defs/definitions entry (even unreferenced ones, a deliberate fail-closed choice). Totality argument: every position is reached by exactly one of three routes — (i) under a chain of MODELED applicators (this function's recursion visits it); (ii) under a DENIED applicator (ancestor already returned True before looking inside); or (iii) under an UNKNOWN keyword (checked for subschema-shaped value and denied there). No fourth route exists. The unconditional $defs visit specifically guards against a future reference, or tooling resolving $defs by convention rather than explicit $ref, smuggling an unmodeled keyword through an entry never inspected.
  • _property_is_bounded() — deliberately has no array carve-out: array boundedness needs BOTH prefixItems members AND the items (rest-of-array) schema checked together (see _array_reaches_free_form); a bounded items alone says nothing about an unbounded prefixItems member alongside it.
  • _item_schema_reaches_free_form_string() — true when an array's items/prefixItems member schema may admit an arbitrary string, i.e. a free-form argv-shaped channel. Item schemas are ALWAYS treated conservatively (unlike keyed object properties, which get the identifier-suffix exemption): an opaque/unconstrained/malformed item shape is presumed free-form. Local $ref and allOf/anyOf/oneOf/if/then/else/not composition are resolved/ recursed so indirection is still caught. Critically, a nested type: "array" item is NOT automatically bounded — it's only bounded if its own item schema is bounded, otherwise a caller can smuggle a free-form channel one array level deeper (args: [["sh", "-c", ...]]).
  • _array_reaches_free_form() — true when an array-shaped node admits a free-form element. Relies on Draft 2020-12 semantics: prefixItems validates only prefix positions; items validates everything at-or-after. A MISSING items keyword defaults to true (unconstrained rest), so an array whose prefixItems are all enum/const-bounded is STILL free-form unless items is explicitly present and bounded (or false). Every prefixItems member is checked too — bounded items says nothing about an unbounded prefix member.

Walker (_walk_schema)

The walker is a WHITELIST, not blacklist. Closing specific evasions one at a time (nested properties, anyOf, $ref, additionalProperties, patternProperties; if/then/else, not, items, prefixItems) leaves every future keyword free to reopen the same bypass class — enumerating "keywords we deny" loses that arms race by construction. Instead the walker enumerates keywords it affirmatively understands; any other subschema-shaped key is unresolvable — future/unsupported keywords (unevaluatedProperties, dependentSchemas, contains, propertyNames) deny by default for executor-signaling tools instead of admitting by default.

  • _SCALAR_ONLY_SCHEMA_KEYWORDS — keywords whose value never carries a subschema (or, for $defs/definitions, only reachable via $ref resolution). contentSchema included on the same footing as its _INERT_ANNOTATION_KEYWORDS inclusion — the two caveats must be kept in sync if the content-assertion vocabulary assumption ever changes.
  • _UNRESOLVABLE_REFERENCE_KEYWORDS$dynamicRef/$recursiveRef are schema-bearing like $ref but their VALUE is a plain string, not a Mapping — so _could_carry_subschema's Mapping-shape test never flags them, and a command channel reachable only behind one would otherwise be SILENTLY ADMITTED. Recognized by keyword identity instead, always treated as unresolvable — deliberately conservative since dynamic-scope $dynamicAnchor resolution isn't something this walker can safely reproduce.
  • _NUMERIC_BOUND_KEYWORDS — explicit enumeration, deliberately NOT a min*/max* spelling heuristic: a prefix test would exempt an arbitrary unknown key like minCustomThing from the could-carry-subschema check purely by name, reopening the whitelist bypass.
  • _could_carry_subschema() — true when an unrecognized keyword's value is shaped like it could hold a schema (mapping, or list at any depth containing one). Recurses through nested lists under the walker's depth cap so a schema-bearing value can't be laundered past the whitelist via extra list nesting. Charges every element against the shared node budget and fails closed on exhaustion, preventing a pathologically wide/deep value from being used as a denial-of-service against the admission check itself.
  • _is_inert_annotation_value() — true when a value cannot itself carry a subschema (recursively scalar, or list/mapping of such with no schema-vocabulary key anywhere inside). A vendor annotation with genuinely descriptive metadata is inert; one embedding real schema vocabulary is not, regardless of key spelling — shape decides inertness, not the key. Fails closed (not-inert) on budget exhaustion, mirroring _could_carry_subschema.
  • _is_vendor_annotation_keyword() — true for annotation-only keywords whose value carries no schema-bearing content. Narrow on two axes: (1) only the x- vendor-extension convention and $comment are even considered; (2) even for those, the value must be demonstrably inert — a vendor extension whose value is itself schema-shaped is exactly the hidden channel this walker exists to catch, so it is NOT exempted just for starting with x-. NEVER exempts $-prefixed keywords generally — that's exactly where real reference/applicator keywords live, so a blanket $* exemption would reopen the reference-bypass class this walker closes.
  • _mark_unknown_schema_keywords() — whitelist enforcement: any unrecognized keyword with a subschema-shaped value is unresolvable, applied to every schema node the classifier inspects (including leaf-treated property schemas). Two carve-outs: (1) $dynamicRef/$recursiveRef anywhere makes the node unresolvable outright regardless of value shape; (2) a vendor-extension keyword is exempt even when Mapping-valued, since it's never an applicator. Value inspection shares the walker's node budget; exhaustion fails closed via the same helpers, preventing an enormous/deeply nested non-subschema value from burning unbounded CPU as a side channel around the traversal budget.
  • _OBJECT_CONTAINER_KEYWORDS — presence of any of these (properties, patternProperties, additionalProperties, $ref, allOf, anyOf, oneOf, if, then, else, not) on a property's own schema means the property IS ITSELF a restated/composed schema — recurse rather than treat as a scalar leaf.
  • _ARRAY_ITEM_KEYWORDS — an array property can be BOTH a free-form leaf channel in its own right AND hide a command channel inside an object-shaped item (items/prefixItems); both must be checked, so — unlike object-applicator keywords — these do NOT short-circuit leaf classification.
  • _SchemaWalkResult.unresolvable — true when a channel could not be proven bounded (unresolvable/cyclic $ref, budget/depth trip, malformed shape, unrecognized keyword). Fed into fail-closed handling specifically for tools whose name/description signals an executor; otherwise it's just insufficient evidence, not an automatic denial.
  • _SchemaWalkResult.nodes_visited — total-work budget companion to the depth cap, bounding runtime against a harmless but extremely wide fan-out (e.g. tens of thousands of anyOf branches) that the depth cap alone wouldn't stop.
  • _consume_node_budget() — counts one unit of walker work; returns False once the total-node budget is exceeded so callers can stop iterating early — this is the mechanism that actually enforces _MAX_SCHEMA_WALK_NODES per-iteration, not just once at entry.
  • _composition_branch_reaches_free_form() — true when any composition/conditional/$ref branch of a keyed property resolves to a free-form leaf. {"anyOf": [{"type": "string"}]} or {"if": ..., "then": {"type": "string"}} constrains the same instance the key names, so the key is unbounded even though the leaf type is indirect — without this, wrapping a plain string schema in one applicator layer would strip the key-to-leaf-type association the classifier relies on, evading detection with one layer of indirection.
  • _consider_property() — leaf-shaped property schema never reaches _walk_schema, so the unknown-keyword whitelist is enforced directly here too (redundant for container properties, already walked, but harmless — closes the coverage gap for leaf properties that would otherwise skip the whitelist entirely). A container property is not itself a command value; the walker recurses into its reachable properties instead of classifying the container's key — but BEFORE recursing, it first attributes to the key any free-form leaf reachable purely through composition/conditional branches, because those constrain the key's own value even though expressed indirectly (ordering matters so a composed free-form leaf isn't missed just because the property also looks like a container). Array handling walks items/prefixItems for a hidden object-shaped command channel nested in array elements, but deliberately falls through to the leaf free-form check rather than returning early — the array property itself may ALSO be free-form (e.g. argv: array-of-strings), so both checks must run.
  • _walk_schema() — bounded, cycle-safe, budgeted traversal collecting classifier evidence. Recognizes properties (including nested objects), allOf/anyOf/oneOf, if/then/else/not, items/prefixItems, local $ref resolution, patternProperties, and additionalProperties (both as a scalar free-form map channel and, when object-valued, as a nested subschema). Any other subschema-shaped keyword is unresolvable — the walker's instance of the whitelist rationale above. Draft 2020-12 evaluates $ref siblings; after resolving/recursing into the target, the function falls through (no early return) so every other keyword on this node is still walked for a free-form channel reachable alongside the reference, mirroring the same rule in _object_boundedness_insufficient. An object-valued additionalProperties map-entry schema is a reachable subschema in its own right; walked so a command channel hidden behind a dynamic (unnamed) map key isn't missed — but no fixed key name exists for a free-form additionalProperties map channel, so it only counts as executor-shaped evidence when corroborated by the tool's own name or description, mirroring the same corroboration requirement unbounded-script-payload demands of payload keys (an uncorroborated free-form map alone is too weak a signal to deny registration).

Top-level classification and API

  • _classify_generic_executor() — an unbounded command-shaped field (has_free_form_command) is dangerous on its own; unrelated extra properties do NOT make it safe, and — unlike the strong-name/description signals — no corroboration is required to deny it. Highest-priority, least-corroborated denial reason (unbounded-command-input), checked first. A strong executor identity (e.g. bash, exec, run_command) must be AFFIRMATIVELY demonstrated safe: empty/no schema, every property bounded via enum/const, or only auxiliary/identifier-like free-form fields. An unresolvable channel or a remaining executor-shaped free-form property leaves the identity uncorroborated — i.e. the default posture for a strong executor name is DENY unless the schema actively proves itself safe, the inverse burden-of-proof from ordinary tools (executor-identity-with-insufficient- schema).
  • _SYNTHETIC_MCP_WRAPPER_PARAMETERS / is_synthetic_mcp_wrapper_schema()create_mcp_tool() wraps every MCP tool in async def mcp_callable(**kwargs). When a Tool is built without an explicit tool_schema (e.g. constructed directly rather than via server discovery), function_to_schema() reflects that wrapper into this exact deterministic shape. It carries NO information from the remote server — it's a fixed artifact of the wrapper's own signature/docstring — and must not be treated as remote descriptor metadata by the admission rule; is_synthetic_mcp_wrapper_schema detects and exempts this case. mcp_tool_name is the key under which the tool was registered in Tool.mcp_config — the identity create_mcp_tool() used to name/document the wrapper, distinguishing it from advertised_name (what the caller claims the tool is called).
  • validate_mcp_tool_admission() — raises PermissionError when an MCP descriptor exposes a generic executor. Explicitly PURE and SYNCHRONOUS: no reads of MCPSecurityConfig, environment, files, pool state, or client acquisition — zero side effects, trivially unit-testable, impossible to bypass via mocking transport state. Registration-time admission control ONLY; does not change transport authorization (MCPSecurityConfig) or invocation-time permissions (PermissionPolicy) — those remain separate, independently enforced gates.
  • MCPConnectionPool.load_config() — returns the server names declared in THIS file only. The pool accumulates configs ACROSS loads (_configs is process-global class state), so callers meaning "the servers from the file I just loaded" must use this method's return value rather than enumerating _configs afterward, since it may contain servers from earlier, unrelated calls in the same process.

lionagi/agent/, lionagi/dispatch/, lionagi/hooks/

agent/factory.py

  • _chain_pre_hooks() — security-hook composition contract (ADR-0086 delta row 1): every security control (PermissionPolicy pre-hook, guard_destructive/guard_paths) is adapted into a GateResult evaluator run through the shared gate pass runner — each control evaluates exactly once per pass, and an evaluator that raises unexpectedly is treated as a fail-closed deny. When user pre-hooks are present, the security pass runs twice (before user hooks, then again after against the final possibly-mutated args), so a user hook can never rewrite arguments past a control that already approved them.
  • _resolve_mcp_path() — shared by _load_mcp and _forward_mcp_to_cli_request so both agree on the authoritative .mcp.json and trust gate: explicit spec.mcp_config_path always wins; project-scoped .lionagi/.mcp.json/.mcp.json only considered when trust_project_settings=True; user-home ~/.lionagi/.mcp.json trusted unconditionally. An explicit mcp_config_path that doesn't resolve raises ConfigurationError (declared intent = configuration error, not a soft no-op); only auto-discovered candidates fall through silently.
  • _forward_mcp_to_cli_request() — two-"island" MCP design: _load_mcp only reaches lionagi-native branch.acts tools (inert for CLI providers, which spawn their own subprocess and never call back into branch.acts); this function reaches the second island — the CLI subprocess's own per-turn request kwargs (ClaudeCodeRequest.mcp_servers, forwarded via as_cmd_args() as --mcp-config). It deliberately sets mcp_servers (a plain dict field) rather than mcp_config (path field): mcp_config's validator unconditionally rejects absolute paths, and both resolved candidates here are always absolute, so using mcp_config would raise ValidationError on the very next turn. An explicit spec.mcp_servers=[] with no resolvable config file still forwards {} (forcing zero MCP servers) rather than leaving the CLI to fall back to its own discovery.
  • apply_forwarded_mcp_servers() — the transport application itself, on a plain kwargs dict: _forward_mcp_to_cli_request resolves a config file and filters it, then delegates here, and so do the three CLI spawn paths that resolve a set of their own (build_chat_model for a plain li agent leg, the resume path in cli/agent.py, and _hand_mcp_servers for every flow/fanout worker). One function so that "can this provider be given a set?" has one answer — provider_accepts_forwarded_mcp — instead of each site re-deriving it from a provider name. Codex takes the set as -c mcp_servers.<name>.<field> overrides, with env and http_headers (possible secrets) routed to a 0600 profile file instead of argv. exclusive says the set is the whole set: the Claude lane adds strict_mcp_config, codex disables every server it would otherwise load by name, since -c mcp_servers={} merges rather than replaces.
  • Mutating chat_model.endpoint.config.kwargs in place would corrupt any other Branch sharing the same iModel instance (Branch keeps a caller-supplied chat_model by reference, not copy). The fix copies chat_model before mutating, sharing share_session/share_executor with the original so only the endpoint config (MCP filter) becomes branch-local.

agent/gate.py

GateResult is the one immutable verdict shape every tool-invocation security control produces (ADR-0086 delta row 1); adapters convert PermissionPolicy, guard_destructive/ guard_paths, and the session-level gate into that shape. Legacy hooks signal denial by raising PermissionError and an argument rewrite by returning a dict; any other exception is treated as an evaluator failure and turned into a fail-closed deny rather than propagating uncaught.

A control that cannot reach a verdict at all — misconfigured, or its backend unreachable — raises ControlUnavailableError, which subclasses PermissionError so existing callers keep failing closed on it unchanged. The gate catches it ahead of PermissionError and sets GateResult.errored, which separates "your configuration cannot answer this" from "the answer is no": both refuse the call, and an operator acts on them differently. errored has two readers, the pass logging at error level where a plain denial logs nothing, and GateDeniedError's message. PermissionPolicy.to_pre_hook raises it for an escalate decision with no on_escalate configured; an escalation a configured handler declined is an ordinary denial and says so.

agent/hooks.py

_resolve_against_any_root() / guard_paths() — multi-root path-containment contract: a relative path formed against the first allowed root is accepted as long as the resolved location falls under any configured root, not only the first. A symlink or protected- basename denial fails identically against every root and always surfaces over a generic denial. guard_paths validation is check-time only — a TOCTOU race (swapping a validated file for a symlink after the check passes) is explicitly out of scope.

agent/nudge.py

NudgeEngine.evaluate()/_merge() — firing bookkeeping (once/cooldown state) is committed only for rules whose message actually survives the token-cap merge; a message dropped by the cap must never be treated as delivered. A rule whose condition or render raises is skipped and logged without breaking other rules.

agent/spec.py

_wire_secure_guards() — guards are registered into the security_pre bucket, not the ordinary user pre bucket, so they participate in the same security→user→security recheck as an explicit PermissionPolicy (ties to the same ADR-0086 double-pass contract as _chain_pre_hooks above).

dispatch/outbox.py

Durability and delivery are separate guarantees: an outbox row persists in state.db independent of consumer liveness; a surviving producer (Studio daemon scheduler tick) re-attempts the notify template until success/backoff-exhaustion/max_attempts. Transport is a shell command template (ADR-0059 D3), argv-safe: payload/deliver_to are substituted as whole argv elements (never string-interpolated), and the template always runs via exec (no shell), so shell metacharacters are inert.

  • enqueue_dispatch() — idempotent on dedup_key (re-enqueue with the same key returns the existing row id). max_attempts bounds delivery regardless of ack_required: an ack-required row that keeps sending successfully but never gets acked still exhausts at max_attempts sends (dead_letter/DEAD_LETTER_ACK_TIMEOUT) rather than re-delivering forever. expires_at is an additional optional bound on top of max_attempts.
  • deliver_due_dispatches() / _deliver_one_due_row() — ack-required rows loop back to pending (not delivered) on transport success, so the same due-scan re-attempts with backoff until acked/expired/max_attempts exhausted; the default tier stops at delivered on first success. delivering rows are re-scanned for crash recovery, but a claim is exclusive only for _CLAIM_LEASE_SECONDS — the guarded attempt-counter CAS in transition() (guard on pre-claim attempt, not just status) prevents two overlapping scans from double-running the transport for the same attempt. Race hardening: the due-row snapshot and each row's transition() call are separate transactions, so a concurrent purge_dispatch(es) can delete a snapshotted row; transition() raises LookupError in that case, caught per-row so one purged row is skipped without aborting the rest of the batch. Every transition the scan writes carries the caller's actor, defaulting to the scheduler tick's own identity; a driver other than the scheduler passes its own so the history does not attribute its writes to the scheduler.
  • purge_dispatch() / purge_dispatches()purge_dispatch accepts any status (naming an exact id is already deliberate non-bulk intent) and writes one admin_events audit row on success. purge_dispatches requires status and/or before (bare call raises ValueError, guards against accidental full-table delete); status semantics are deliberately asymmetric — an explicit status is honored exactly as given (including in-flight pending/delivering, treated as deliberate operator override), while a status-less call defaults to terminal-only (delivered/acked/dead_letter/expired) so it can never implicitly sweep in-flight rows a live scheduler tick may still claim. Distinct from the automatic terminal-only retention sweep in db_maintenance.prune_old_data. Always writes an admin_events row, including on dry_run calls, and preserves status_transitions rows for purged ids.

dispatch/revival.py

This is a plain library call, not a new schedule action_kind: wiring a first-class action_kind through the scheduler's fire/subprocess-spawn machinery would require rebuilding the schedules.action_kind CHECK constraint (the same SQLite rename-rebuild migration _drop_legacy_action_kind_check performs for the existing enum) — heavier machinery than a library call needs. Any schedule action that can call a Python function can invoke it; nothing in the module assumes a dedicated action_kind.

hooks/builtins.py

persist_session_end() — in the normal CLI flow, teardown_persist() always stamps the terminal status via _teardown_common()'s update_status() call before emitting SESSION_END, so the session row is already terminal by the time this handler runs. In that case only pure usage fields are written (input/output tokens, cost, turns, duration) — the status/reason_code/ended_at transition is skipped (avoids a duplicate status_transitions row and a double-fire clobbering an already-recorded status), and node_metadata is left untouched since _teardown_common() already owns it for a terminal row and update_session() does a plain column SET, not a merge (writing {"error": ...} here would clobber richer existing data). Related: persist_session_start's explicit reason_code on the "running" transition avoids tripping a deprecation shim that would otherwise raise and get swallowed by the bus, silently dropping all the provenance fields passed alongside it.

lionagi/providers/

_cli_subprocess.py

ndjson_from_cli PGID capture: the process-group id must be captured immediately after spawn, not at teardown — if the child has already exited and been reaped by the time teardown runs, os.getpgid(proc.pid) raises ProcessLookupError. Since start_new_session=True, pgid == proc.pid, so capturing proc.pid right after spawn is equivalent and safe. The actual pid-guard/platform check lives in aterminate_process_group.

on_spawn and SpawnedProcess. A caller that has to supervise a leg it did not itself spawn passes on_spawn, which is called exactly once with a SpawnedProcess as soon as the child exists and before any output is read. Three things about that record are load-bearing:

  • It carries a start time, not just two integers. pid and pgid are both recyclable — once a process is reaped the kernel may hand its numbers to anything — so a consumer holding only those cannot tell this child from a stranger that arrived later, and signalling on that basis reaches the stranger. create_time binds them, it is readable only while the child is known to exist, and a consumer acting on the record later must compare a live read against it. None means nothing was established and is never a claim about the process; this mirrors what lionagi/mcp/jobs.py does with pid_create_time.
  • The three fields are one observation, within limits worth stating. The group and the start time are separate reads by pid, so observe_spawned() brackets the group read between two start-time reads and drops create_time to None if they disagree, the same shape as _pinned_member in lionagi/mcp/jobs.py. That rejects a replacement arriving during the observation. It does not speak for one that arrived before the first read, and the window where that is possible is a child that exits and is reaped between the spawn call returning and the first probe — covered not by the bracket but by the probe, which answers None for both a reaped pid and a zombie.
  • It may be a coroutine function, and the result is awaited. A durable recorder is written in async style, and Callable[..., None] does not reject an async def at runtime: an un-awaited one returns a coroutine that is dropped, so the leg runs entirely unrecorded with nothing raised. The await completes before the first byte of output is read, so a consumer acting on the stream is never ahead of the record.
  • Its failure is not swallowed, including CancelledError and KeyboardInterrupt. A recorder that fails has no record of a child that is now running, so the child is terminated and the exception propagates. A guard written against Exception would let a cancellation through and leave the child alive.
  • That termination does not depend on surviving a second cancellation. The graceful path sends SIGTERM and waits out a grace before escalating, and that wait is itself a cancellation point: a runner being torn down is exactly where a second cancellation arrives, and a child ignoring SIGTERM would then outlive an escalation that never ran. So a synchronous SIGKILL backstop runs in a finally when the graceful path did not complete — no await, so nothing can interpose. It runs under the same evidence rule as the pass it is backing up rather than under anything about the direct child: an unreaped child is signalled on its own unrecyclable pid, a reaped one only where the group answers with a live member. Signalling a pid asyncio has reaped, with nothing to say the group still holds it, is how a stranger's group gets killed.

  • The child exists even when nothing recorded it. The OS has already started the child by the time create_subprocess_exec resumes, so a cancellation landing in the window before it returns leaves a running leg that this process holds no handle for and that no callback has seen — unreachable by teardown and by any later sweep over the records. The creation is therefore shielded so the handle still arrives, and a done-callback ends that child's group from outside the coroutine that unwound. The callback retrieves the task's exception either way, or a cancelled spawn is reported at exit as a never-retrieved failure.

end_child_group() is what every teardown path calls, and it differs from the graceful helper on two counts. It drains the group rather than the process: the graceful helper returns as soon as the process it holds a handle to is gone, and a descendant that ignores SIGTERM outlives a parent that does not, so the group is read afterwards and killed if anyone is still in it. And it cannot be interrupted into leaving something running, via the synchronous backstop described above.

Every signal fires only on positive evidence that the group id is still this child's, because the other direction is worse than an orphan. There are exactly two things that establish it: the child has not been waited, in which case its pid cannot have been reissued; or the group answers with a live member, and an occupied group is never reissued. Nothing else counts, and the graceful helper is therefore reached only on the not-yet-waited path. It signals the id it is handed without checking anything, so calling it after a normal drain would send SIGTERM to whatever now holds a recycled id.

The escalation keys on that membership evidence rather than on whether the direct child is dead. Those are different facts: a leader that died to SIGTERM sets returncode while a descendant ignoring SIGTERM is still in its group, and a backstop gated on the leader's liveness reads that as nothing left to do.

Both checks matter, and checking only one leaves a hole where the other applies. An earlier version of this had only the membership check, so the cancellation backstop refused to signal wherever the process table could not be read — and on that path the direct child has not been waited, so identity was never in question. The refusal is right after the reap and wrong before it, and it read as caution rather than as a defect.

After the reap, a scan that could not read the whole process table and saw no members leaves emptiness unproved, and nothing signals on that: an unprovable group and a reissued one look identical from here. That refusal is logged rather than silent, because it is the one outcome where something may still be running and nothing was done about it. It is a real platform limit rather than a gap waiting to be closed: once the child is reaped, only a surviving member pins the id, and proving one exists is the enumeration that was unavailable.

The one case teardown cannot reach. A cancellation landing inside create_subprocess_exec, after the OS has made the child but before the call returns, leaves a process whose pid was never handed to anyone here. Interpreter shutdown cancels pending tasks and does exactly this. asyncio closes the transport on that path, which ends the direct child but not the group it leads. Recording the handle as soon as the creation call returns was tried and removed: it covers only the window between the call returning and the caller resuming, which is not where the cancellation lands. Reaching it needs the pid before the creation call returns, which means driving loop.subprocess_exec with a protocol that records transport.get_pid() in connection_made — declined here because it pins this file to stdlib classes outside that module's __all__ across every supported Python.

Nothing recovers it afterwards either, and an earlier version of this section said otherwise: that the orphan sits in the record the caller writes and a later sweep still finds it. It does not. on_spawn fires only once the creation call has returned, which is the thing that did not happen, so in this window there is no record of any kind — the same emptiness the bullet above describes, written up two ways that contradicted each other. This is a stated hole rather than a handled one. The test for the window asserts the emptiness rather than assuming it, so the claim cannot quietly come back, and the log line on that path says what was lost and why.

The group in the record is the initial one. A process group is not a containment boundary: a child or descendant that calls setsid() leaves it and the record then says nothing about that process. A caller who needs "nothing the leg started survives" must either require non-daemonizing CLIs or use a platform containment primitive.

Runtime-only request fields. env and on_spawn on ClaudeCodeRequest / CodexCodeRequest are SkipJsonSchema[...] with exclude=True and repr=False. Each qualifier answers a different channel and none implies the others: exclude keeps them out of dumps, SkipJsonSchema keeps them out of the generated schema (a callable has none, so model_json_schema() raises without it, which breaks every path that persists a request), and repr=False keeps a complete child environment out of log lines and exception text.

Those qualifiers all govern the model, and the model is not the only thing that prints a child environment. Pydantic renders the input of a failing mode="before" validator verbatim, and a model-level one holds the whole raw mapping, so a request rejected for a reason having nothing to do with env — an empty prompt, say — quotes every variable beside the reason. The redaction therefore happens at the top of every model-level before-validator and before anything there can raise. Carrying it on the value rather than at each error site is what stops a validator added later from reopening the channel by not knowing about it, and it is why the two providers each redact at their own validator entry.

The substitute is deliberately not a mapping. A dict subclass with a quiet __repr__ closes the rendering channel and leaves the serialization one wide open: str(err) and err.errors() go through repr, but err.json() walks the structure and writes out every key and value, and err.json() is what a structured logger emits. Redacted is neither dict nor Mapping, so there is nothing for pydantic to walk, and it reports <env: N variable(s)> in every channel.

on_spawn is wrapped for the same reason. A bound method carries its receiver into its own repr, so a callback bound to a supervisor renders whatever that supervisor holds — and receivers that render their attributes are the common case here. Both models unwrap the carrier in a field validator; without that, pydantic rejects a perfectly good callback as not callable, which is a failure a leak test alone would never show.

Two rejections stay explicit because the redaction cannot reach them:

  • An env that is not a mapping at all is left alone by the substitution, so the field validator rejects it itself rather than leaving it for pydantic to quote.
  • A mapping with bad entries is rejected as TypeError, not ValueError: pydantic converts ValueError and AssertionError into a ValidationError that quotes the rejected input and lets everything else propagate untouched. The message names string keys, because a string key is a variable name and naming it is what makes the error actionable; a key of any other type is not a name and is reported by position and type only. Values are never printed.

_runtime_state_fields on the endpoint does two jobs for those fields, and both are about a value that must stay in memory:

  • Surviving create_payload. It rebuilds the request from to_dict(request), and that dump omits excluded fields by construction. A field not named here is silently lost, so a caller passing a fully populated request model gets one where the runtime wiring reverted to its defaults.
  • Staying out of what gets written to disk. iModel(**kwargs) forwards anything it does not recognise into EndpointConfig.kwargs, which is a supported way to configure an endpoint and also exactly what Endpoint.to_dict serializes — so it reaches iModel.to_dict, Branch.to_dict, and the run snapshots. A child environment left there is a credential in a saved file, and a callback left there is a function something is about to JSON-encode. _init_runtime_state() moves the named values out of config.kwargs into _runtime_state at construction, where create_payload reads them at the same precedence they had before.

The drain is not the whole of it, and the part it misses is the public one. A drain has to be reached, and it hangs off the endpoint's own to_dict. EndpointConfig is public and inherits Pydantic's model_dump, so a caller that logs or persists the config directly never goes through an endpoint at all. Two supported routes also put a runtime value back into kwargs after the endpoint has already drained it once: a post-construction EndpointConfig.update(), which puts unknown keys straight back, and iModel.from_dict, which assigns a freshly hydrated config over the drained one (imodel.py, in the match_endpoint branch). Between either of those and the next endpoint-level dump, the value is resting in a serializable field.

So the names are declared on EndpointConfig itself, as RUNTIME_STATE_NAMES, and that model excludes them from its own kwargs serializer. The set of callers that can reach a drain is open-ended because the model is public, where a serializer runs on every dump that model has. The endpoint re-exports the same tuple rather than keeping its own — one list decides what gets written down, and a second copy would eventually be the one that fell behind.

A serializer is not every route out, though, and the two it misses are both public. dict(config) and list(config) go through BaseModel.__iter__, which yields the raw values held in __dict__ without running a field serializer at all; json.dumps(dict(config), default=str) is an ordinary way to write an object to a log or a file. repr walks kwargs whole for the same reason, so a config excluded from every dump still prints its environment into a traceback, a log line, or a debugger. Both are closed on the model itself, through __iter__ and __repr_args__, which is what keeps the answer independent of which caller is asking.

What that covers is the conversion API: the ways this object offers to turn itself into something else. It is not a claim about reflection. config.__dict__ and pickle read the instance's raw state and still contain these values, deliberately — the runtime value has to live somewhere for the endpoint to use it, and a rule that emptied every raw read would take the working value with it. The line is between a route that produces a structure for something else to hold, which is what ends up in a log or a file, and a route that reads the object's own memory.

The three channels do not all leave the same thing behind. A dump and a dict() omit the key, since both are structures a config can be rebuilt from and a placeholder string would hydrate as a real value of the wrong type. repr reports that a value is set without its contents, because nothing is rebuilt from a repr and a reader asking why an environment was not applied is answered by the key alone.

copy_runtime_state_to carries _runtime_state shallowly on purpose. iModel.copy deep copies the config, and a deep copy of a bound callback rebinds it to a copied receiver, so the original supervisor would quietly stop hearing from the copy's legs while everything still looked wired.

That transfer only carries what the source is actually holding, which is why iModel.copy drains the source endpoint before copying its config rather than after. A value that arrived through update() is still sitting in config.kwargs and has never been drained, so the source's _runtime_state is empty: the new endpoint drains the copied kwargs correctly at construction, and then the transfer overwrites that with the empty mapping. The result is a copy whose child gets a default environment and whose spawns are reported to nobody. Draining first means the copied config has nothing runtime-only left in it and the live objects move across whole. Reading the source's state this way does not take it away — a drain relocates a value without changing which one wins in create_payload — so the original keeps working after being copied.

The endpoint-instance route. iModel(endpoint=<instance>, ...) is a supported signature, and it takes a branch that keeps the endpoint and discards every other keyword. For most keywords that only loses configuration the endpoint already has. For these two it hands the child a default environment and leaves the supervisor hearing nothing, with nothing raised and nothing logged, which reads exactly like a working leg. A caller who hands over a finished endpoint has missed the window below entirely, so adopt_runtime_state() places the values on the instance — the way that same branch already treats provider and base_url — and an endpoint with no runtime state to hold them refuses rather than dropping them. A None is the absence of a value there, not an instruction to clear one.

The same deep copy sits on the construction path: Endpoint.__init__ does config.model_copy(deep=True) before any subclass code runs, so an endpoint built from a prepared EndpointConfig would hold a callback bound to a copy of the supervisor. The constructors therefore call take_supplied_runtime_state() before super().__init__(), which reads the declared names off the object the caller actually handed in, and those values win over what the copy produced. Nothing is skipped — the copy still happens and the values still leave the serialized config — this only decides which of the two receivers the endpoint ends up notifying. A test written with a plain nested function cannot see any of this: deepcopy of a function returns the same object, and only a bound method has a receiver to rebind.

_secret_resolution.py

Every CLI provider authenticates from its child's own environment: a codex model_providers entry names an env_key and the CLI reads that variable itself. When the value is kept in a keychain or a vault rather than exported, the spawning process has nothing to pass and the child dies on a missing variable that says nothing about where the value was meant to come from.

secrets.lookup in ~/.lionagi/settings.yaml names a command that prints one secret to stdout, and the variables it may be asked for:

secrets:
  lookup:
    argv: [security, find-generic-password, -s, "{name}", -a, lionagi, -w]
    names: [OPENROUTER_API_KEY]

fill_declared_secrets() is awaited once inside ndjson_from_cli, which is the single spawn seam for all four CLI providers, so this is wired in one place rather than four. It is purely additive: with nothing configured it returns the caller's env unchanged — None included, so an inheriting child stays inheriting instead of being frozen to a snapshot — and a lookup that fails leaves the child to fail exactly as it already did.

  • Global settings only (load_settings(include_project=False)). The project-local file is the content of whatever tree happens to be checked out, and a repository must not get to name the program that reads this machine's secret store. Where a secret lives is a property of the machine.
  • A refusal is distinguishable from silence. SecretLookupResolution.reason is set iff a lookup was configured and rejected; nothing configured and enabled: false both carry no reason. Otherwise a typo'd block and an unconfigured machine both arrive as "the environment was not changed". Reasons are short stable identifiers and never interpolate configured values.
  • Every refusal is total. One malformed name rejects the whole block rather than being dropped from it, because a silently skipped name leaves the block reading as configured while resolving less than it says.
  • argv is a list, never a command string, so nothing is ever split and no shape reaches a shell. At least one argument after the program must contain {name} (a lookup that cannot say which secret it wants is refused), and argv[0] must not, since the program to run may not vary with the variable being looked up.
  • A variable that already has a value is never looked up and never overwritten, so exporting one is still how a single run overrides the store.
  • The value reaches the child's environment and nothing else. Never a file, never an argv, never a log line: _run_lookup reports only the program name, the variable name and the exit status, and discards stdout on every failure path — a store that prints its errors to stdout uses the same channel the secret arrives on.

anthropic/claude_code.py

  • CLI flag metadata protocol. Every CLI-mappable ClaudeCodeRequest field carries a json_schema_extra dict built by _cli()/make_cli_flag(), consumed by build_declarative_cli_args(). Kind semantics: value--flag <str(val)>; bool--flag when truthy else omitted; bool_pair--flag when True, --neg-flag when False, omitted when None; list_args → one flag followed by many positional args; json_value → dict/list serialized to a JSON string; repeat → the flag repeated once per item. Anyone adding a new CLI-mappable field must pick the right kind or the arg won't round-trip.
  • mcp_servers None-vs-{} invariant. Defaults to None, not {}, specifically so a request that never touched the field is distinguishable from a caller that explicitly forwarded an empty server selection. The is not None check (not truthiness) in as_cmd_args() means mcp_servers={} still emits --mcp-config {"mcpServers":{}}, forcing zero MCP servers, rather than silently omitting the flag and letting the CLI fall back to its own MCP discovery. Flattening this to a truthiness check would silently break "explicitly disable all MCP servers."
  • Repo-containment scope for add_dir. The write-target path containment check (contain_paths_in_repo) covers system_prompt_file, append_system_prompt_file, mcp_config, settings — deliberately excluding add_dir. add_dir is a read-only grant validated separately by _validate_add_dir; absolute paths there are intentional grants, not escapes, and must not be rejected by the write-target containment logic.

google/gemini_code.py

Google folded Gemini Code Assist CLI into Antigravity (agy). This provider drives agy in headless print mode (--output-format json), which emits exactly one terminal JSON object (one NDJSON record) consumed unchanged by the shared ndjson_from_cli plumbing. conversation_id is stored as session.session_id so native resume works via --conversation. The public names/aliases gemini-code / gemini-cli / gemini_cli are kept for backward compat even though the underlying binary is agy.

  • resolve_agy_model resolution/precedence rules. agy has no separate effort flag; effort is expressed only via a Low/Medium/High suffix baked into the model display name. lionagi's effort scale (none|minimal|low|medium|high|xhigh|max) is clamped onto Gemini 3.1 Pro's Low/High-only range via _clamp_gemini_effort. An exact, already-(...)-qualified model (a concrete agy display name) wins over effort by default. reapply_effort=True exists specifically to let a new effort replace the suffix baked into a persisted prior resolution (e.g. li agent -r ... --effort ...), while a model the caller explicitly typed in the current turn still wins regardless.
  • No per-tool stdout events. stream_gemini_cli's on_tool_use/on_tool_result callbacks are accepted for interface parity with other CLI providers but never fire in this transport, because agy's json output surfaces no per-tool events on stdout (they exist only in the per-session transcript file).
  • Relative-path/stdin quirks. agy resolves relative --add-dir entries against the process cwd and has no -C-style flag to change that, so the resolved workspace must be passed as the subprocess cwd. Default stdin is DEVNULL since print mode reads nothing from stdin.
  • streams_first_output_early stays False. agy's json print mode only yields output after the entire result object arrives (no incremental streaming), so a healthy long-running call looks identical to a dead/hung worker to a first-chunk watchdog — the endpoint can't opt into the early-first-output fast path other CLI providers use.

openai/_chat_schemas.py

uses_developer_messages — the system-vs-developer message-role gate is deliberately conservative and prefix-based: only o1/o3/o4/gpt-5 families (including dated variants, matched by prefix after stripping any provider prefix) are gated onto developer. Unknown or missing models fail closed and keep system — the default behavior on an unrecognized model string is the safe, backward-compatible one, not the newer developer role.

groq/audio_transcription.py, openai/audio.py, openai/images.py

_replayable_file_factory retry-safety contract (identical helper duplicated in all three files): returns a zero-arg callable that produces a fresh file object for each retry attempt. Bytes/bytearray are snapshotted once and re-wrapped in a new BytesIO per attempt; a seekable stream is seeked back to its starting position before each attempt; a non-seekable stream cannot be replayed safely, so when a retry could occur (require_replayable=True) it raises before any network I/O rather than silently resending an exhausted stream (single-shot endpoints get the raw stream handed through once). The stream is snapshotted once and its position restored immediately — handing the live stream object to each attempt isn't sufficient, because an explicit RetryConfig retry re-invokes _call, which rebuilds this factory around the now-consumed stream (already at EOF), and would silently upload an empty file on retry without this snapshot.

openai/codex.py

  • cwd/-C double-resolution gotcha. _ndjson_from_cli deliberately does NOT pass cwd= to ndjson_from_cli, because the Codex CLI already receives the workspace via the -C <repo> argument emitted by as_cmd_args(). Setting cwd= as well would make the CLI resolve -C repo from inside repo, producing a bogus repo/repo path.
  • Error envelope shape varies by event type. Codex CLI's error payload location differs by event type: "error"-type events carry a top-level "message" key with no nested "error" key, while "turn.failed" events nest the message under error.message — both must be checked. The raw error value is captured before null-normalization (_raw_err) specifically because the benign-EOS check further down must distinguish an explicit "error": null (a malformed envelope — a real error) from the bare {} sentinel (benign EOF).
  • Benign-EOS sentinel on resumed sessions. Some Codex CLI versions emit {"type": "error", "error": {}} when a resumed session ends normally — this is a benign end-of-stream, not a real failure, and is tagged so run() treats it as clean EOS rather than raising RunFailed. All of the following must hold for the benign-EOS classification: type == "error" exactly ("turn.failed" is never considered benign); the raw payload is exactly {} (an explicit null, once normalised to {}, must NOT qualify, hence checking _raw_err pre-normalization); and no other failure-indicating keys (code/message/ status) are present in the outer event. Getting any of these three conditions wrong either misclassifies a real failure as benign or vice versa.

pi/cli.py

  • _PI_MODEL_PROVIDER_MAP design rationale. Model-name prefixes are mapped to pi --provider values, but only for unambiguous prefixes where the model name uniquely identifies the provider. Ambiguous family names (llama, gemma, mistral — available on multiple providers) are deliberately omitted from the map, so callers must set the provider explicitly or let pi resolve it itself, rather than the code guessing wrong. strip=True entries remove the prefix from the model string, needed for openrouter/-style routing.
  • Pi CLI arg-parsing quirk. Pi's CLI arg parser has no -- terminator support, so the prompt is passed as a bare positional argument. Prompts starting with - or @ may be misparsed by Pi's own CLI as flags/file-references — callers should avoid leading dashes (or @) in prompts passed through this provider.
  • Dual meaning of the "done" event type. Pi CLI overloads the "done" event type: both the top-level AgentEvent.done (true end-of-stream) and a top-level AssistantMessageEvent.done (an individual assistant message finishing) use the same "done" string, and both may carry a final message with model/usage info that must be captured via _remember_assistant_message.
  • streams_first_output_early stays False. Pi's transport emits an "agent_start" event right after spawn, but stream() discards raw dict events and only yields its first StreamChunk once a PiChunk actually carries text/thinking/tool content — so the first output a caller can observe may lag the process spawn by the model's full "thinking" time, making the early-first-output optimization unsafe here (same gotcha class as gemini_code.py's agy note above).

lionagi/service/ (remaining)

connections/registry.py

EndpointRegistry.match() — on a registry miss, consults the plugin registry (ADR-0088 D3) before falling back to the generic OpenAI-compatible endpoint: _consult_plugin_providers() imports every ACTIVE plugin's declared provider module (never at import time or discovery, preserving import-time O(1)), exclusively through PluginRegistry.activate_target — never a direct importlib call on plugin code — so the trust/enabled/active chokepoints enforced there apply here too. Each activation is cached by the plugin registry itself, so repeated misses are cheap. A plugin supplying no matching provider (or none at all) leaves the fallback identical to the no-plugin case. _revalidate_plugin_entry keeps a plugin-sourced registry entry available only while its declared target remains trusted, removing it on PluginActivationError.

Retry & sentinel-exclusion contract (connections/endpoint.py, providers.py, resilience.py)

  • endpoint.py — 4xx (non-429) client errors are wrapped in _NonRetryableClientError so the original aiohttp.ClientResponseError stays inspectable via __cause__ while retry logic sees an excluded type. The sentinel must stay excluded until whichever retry layer is active (the outer retry_config-driven wrapper in call(), or the native path in _call_aiohttp()) has given up — unwrapping earlier would let a broad retry_exceptions=(aiohttp.ClientError,) config replay a 400/401/403 the transport contract intends as single-shot. Request bodies are rebuilt inside the per-attempt function rather than before retry orchestration, because FormData/BytesIO/file-stream bodies can be consumed by the first POST and must not be silently replayed on a later attempt. In the native (no-RetryConfig) path, config.max_retries is a total-attempt cap (formerly backoff's max_tries), but retry_with_backoff runs max_retries+1 attempts internally, so the call subtracts one to preserve the configured total attempt count. _can_retry(): true when an explicit RetryConfig wraps the call, or the native path's total-attempt cap allows a second attempt; single-shot endpoints (max_retries<=1, no RetryConfig) never replay a body, so callers may hand over non-replayable inputs like one-shot streams only in that case.
  • resilience.py — in retry_with_backoff, exclude_exceptions membership is checked per-instance (except exclude_exceptions) rather than per-type, which correctly handles subclass hierarchies — e.g. retry_on=(OSError,), exclude=(ConnectionError,) must not retry a ConnectionError even though it IS-A OSError.

connections/agentic_endpoint.py

streams_first_output_early — true when a CLI/agentic transport emits its first StreamChunk shortly after the subprocess spawns (e.g. an ndjson "system"/"init" event), making a stalled first chunk a reliable dead-worker signal. False for transports that buffer all output until the run completes, where a slow-but-healthy call is indistinguishable from a dead one until the whole result arrives. This flag gates run.py's default first-output and between-chunk watchdogs (LIONAGI_WORKER_LIVENESS_TIMEOUT and LIONAGI_WORKER_IDLE_TIMEOUT).

connections/endpoint_config.py

_FIELD_KEYS_BY_CLASS is keyed on id(cls) rather than the class object itself, because a dict lookup by class object would go through __eq__/__hash__, which a custom metaclass may override; each cache entry retains a strong reference to the class (keeping the id stable) and is only served when the stored class is the lookup class, guarding against id reuse after garbage collection. The cached value is the set of accepted field keys (including declared aliases) computed once per class from model_json_schema(), since subclasses may add fields/aliases and rebuilding the JSON schema on every EndpointConfig construction would cost more than the rest of validation combined.

imodel.py

  • stream() — the finally block pops the in-flight call from executor.pile without yielding inside the finally, because yielding inside a generator's finally would swallow a CancelledError arriving during generator cleanup, which would break anyio.fail_after timeout enforcement for callers wrapping the stream.
  • copy(share_session, share_executor) — creates a new iModel with the same config but a fresh ID. share_session=True carries the CLI provider's session_id onto the copy so cross-turn continuation is preserved (only applies when both endpoints are AgenticEndpoint). share_executor=True reuses the exact same RateLimitedAPIExecutor instance instead of building a fresh one, so a caller-supplied executor's rate limits and queue capacity stay shared between original and copy. Default (False/False) gives the copy its own independent executor — this is what Branch.clone() relies on for CLI providers, where each cloned branch needs its own session and queue rather than contending with the parent's. circuit_breaker and retry_config objects are shared by reference (not deep-copied) between original and copy; only config is deep-copied.

manager.py

iModelManager.shutdown() — without explicitly closing every registered iModel, each one's background rate-limit replenisher task stays scheduled and prevents anyio.run/asyncio.run from returning at process exit. Idempotent; per-model failures (including CancelledError) are logged and swallowed so one broken endpoint's shutdown failure can't block the others.

providers.py

normalize_effort() must be called once at every boundary where a raw effort string enters lionagi (CLI flag, profile frontmatter, orchestration spec) because the clamp tables downstream are keyed on lowercase effort levels and silently misclamp (no raise) on an un-normalized value like "High". Codex reasoning-effort ceilings are model-dependent per the codex CLI's live model list: gpt-5.6-sol/gpt-5.6-terra accept max/ultra, gpt-5.6-luna accepts max only, and every earlier model tops out at xhigh; unrecognized (future) models intentionally pass through unclamped so a genuinely supported new tier is never silently degraded. agy (the Antigravity CLI) has no effort flag/kwarg at all — effort is expressed only as a Low/Medium/High suffix baked into the resolved --model name, and Gemini 3.1 Pro has no Medium tier, so lionagi's 8-level none|minimal|low|medium|high|xhigh|max|ultra vocabulary collapses onto this 3-tier scale via _GEMINI_EFFORT_CLAMP.

rate_limited_processor.py

  • start_replenishing() — its cancellation handler wraps await self.start() too, so a cancel arriving before the main loop is reached is still caught inside the task instead of surfacing as an uncaught error on stop(). The periodic re-drive of the queue (if not self.queue.empty(): await self.process()) exists because process() re-enqueues rate-limited events instead of dropping them, but forward() is one-shot — without re-driving on each refresh, deferred events would sit PENDING until the caller's invoke() safety timeout instead of actually retrying once the budget replenishes.
  • stop() — Python 3.11+ re-raises CancelledError on await task after task.cancel() even though the task body already suppressed it internally; this is swallowed explicitly so callers closing multiple iModels in sequence don't abort on the first one's close.
  • handle_denied() — rate-limit denial is a deferral, not a rejection — returning False makes the base process() re-enqueue the event (stays PENDING) for retry once the limit replenishes, rather than terminalizing it the way a permission rejection would.

lionagi/testing/

testing/_endpoint.py

ScriptedEndpoint.copy_runtime_state_to() — when an iModel is cloned, the script must be deep-copied, not shared, so the clone gets an independent positional cursor. If the script were shared, positional matching would cross-contaminate between clones — clone A consuming response entry 0 would advance the shared cursor, so clone B's first call would incorrectly receive entry 1 instead of entry 0. Recorded calls (self.calls) are shallow-copied instead, since each clone only needs its own future calls tracked, not a defensively-copied history.

testing/_script.py

  • _build_entry() — response entries are dispatched to their concrete subclass (TextResponse, ToolCallResponse, StructuredResponse, StreamResponse, ErrorResponse) by manually branching on the type field rather than relying on Pydantic v2's discriminated- union support. Deliberate: Pydantic v2 won't reliably select a discriminated-union member when fields beyond the discriminator (type) differ between candidate models, so manual dispatch is used to get clearer validation errors when a script entry is malformed.
  • ScriptModel.next() — response-entry matching has a two-phase precedence contract that test authors writing scripts rely on. Phase 1: unless mode == "positional", every entry with a non-empty when: matcher is checked (skipping entries already served by a when: match) and the first one whose predicate matches (call_index, after_calls, prompt_contains, prompt_regex, has_tool) is returned; if mode == "when_only" and nothing matched, it raises immediately. Phase 2: falls back to positional order over entries that do NOT have a when: matcher, advancing an internal cursor; raises ScriptExhaustedError once positional entries are exhausted. This order (when-matchers-first, then positional) is the core scripted- fixture replay semantic and isn't obvious from the fixture's public surface alone.

lionagi/tools/

sandbox_backend.py

The sandbox backend seam (ADR-0090): backend divergence (local worktree vs. Daytona vs. future backends) is absorbed entirely in provision() and capabilities(); run_cell()'s signature never changes per backend. A Cell declares a kind: prompt_cell (the provider call runs host-side, already authenticated — no secrets ever cross into the box) or exec_cell (untrusted code runs inside the box, secrets injected explicitly). Callers must read capabilities() to decide what a backend can do and must never branch on a backend's name/type — this is the load-bearing security/extensibility contract of the whole seam. _SAFE_ENV_KEYS: run_cell's subprocess never blanket-inherits the host environment (credential-leak vector); only PATH, HOME, PYTHONPATH, VIRTUAL_ENV are forwarded automatically, plus whatever cell.env explicitly allow-lists.

khive_injection.py

Reference ContextProvider (ADR-0008): recalls/optionally composes from a khive daemon over the same MCP transport lionagi already uses for tool servers (service.connections.mcp_wrapper) — no new transport, and no khive/MCP import at module load, so the core import path stays clean without the mcp extra installed. Every recall emits brain.auto_feedback in the same round-trip with the policy's explicit profile_id (khive's auto_feedback does no binding resolution, so an implicit/default profile mis-attributes the event). writeback() is a separate opt-in POST-turn hook — rule-based tool error/resolution pairs written to memory.remember at capped, low-provenance salience, invoked by the operate() Middle (not provide()), and it is NOT the nudge plane. Both provide() and writeback() fully swallow transport failures (logged only) so a turn always proceeds.

KhiveInjectionPolicy.namespace, when set, is threaded onto every khive verb the provider emits (recall, compose, auto_feedback, remember) to isolate its writes to a named store. auto_feedback WRITES to the live brain store, so an unpinned "read-only" caller still mutates posteriors — pinning a namespace is required, not optional, wherever writes must stay isolated. Currently only the write verb honors namespace (read verbs reject unknown params); this is forward-wired for when reads grow namespace scoping too.

sandbox.py

Git worktree lifecycle (_cleanup_worktree_sync, _merge_sync, sandbox_merge) is retry-safe: a resource that's already absent counts as cleaned up, so a partial failure (e.g. worktree removed but branch deletion blocked by another checkout) can be completed by a later retry instead of failing forever on the step that already succeeded. SandboxSession.is_active only flips to False once both resources are actually gone — a partial failure keeps the session marked active so a caller can't mistake it for cleaned up. Merge additionally refuses when repo_root is in a detached HEAD state, isn't checked out on the session's recorded base branch (no auto-checkout), or targets a protected branch name (main/master/release*) unless the caller explicitly opts in via allow_protected. (git rev-parse --abbrev-ref HEAD returns the literal string "HEAD" when the repo is detached — not an actual branch name — a quirk both _merge_sync and create_sandbox special-case, since unhandled it would let a merge move a detached HEAD forward with no branch ref pointing at the result, or let create_sandbox record a nonexistent branch as its merge target.)

_list_untracked_files() uses git ls-files --others --exclude-standard -z (NUL-delimited raw paths) rather than git status --porcelain, which quotes/escapes paths with spaces or special characters (breaking naive line[3:] slicing) and reports an untracked directory as a single ?? dir/ entry instead of the files inside it.

communication/messenger.py

_fire() — two related logging design decisions: (1) for the help event specifically, a raising coordinator callback is caught and logged rather than propagated, because the whole point of help is fire-and-continue — it must never surface as an unhandled exception on the emitting worker's tool-call turn. (2) When no callback is registered at all for an event, that's logged at debug (not warning) — a mis-wired coordinator should stay discoverable during bring-up, but debug-level avoids spamming normal runs where some events are legitimately unused.

coding.py

CodingToolkit.__init__'s sandbox_allow_protected — whether the bound sandbox tool's merge action may target a protected branch name (main/master/release*) is an operator-level trust decision, not something the agent can request per call. It's deliberately absent from SandboxRequest so an in-band agent can never self-approve merging into a protected branch — set it only when composing the agent from code you control (e.g. a CI job that always merges into main).

_invalidate_stale_reads() drops file_state entries whose backing reader-read result was evicted/compacted by the context tool; otherwise the read-before-edit guard would stay "satisfied" for a read the model can no longer actually see, letting it edit blind.

_subprocess.py

_subprocess_sync()env=None inherits the full parent environment; callers pass an explicit mapping to scope less-trusted commands (e.g. the ADR-0090 sandbox-backend seam) to a minimal environment.