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-onlyAsyncEngineover an existing SQLite file, opened through SQLite's own URI read-only mode (mode=ro), not throughmake_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 ofmake_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) onlybusy_timeout(session-scoped, never persisted) andquery_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:
- Terminal sessions (
completed/failed/timed_out/aborted/cancelled): done means done unless stale locks were left behind (→ZOMBIE).has_artifactsalone is not zombie evidence — artifacts are a good outcome; stale locks are the operational signal. - 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
failedis harmless, deleting it is also safe. - Confirmed dead (
process_alive is False) outranks the activity guard below it — the process is gone no matter how fresh the last message is. - 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_runstable,schedule_idnow 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 atomicstatus_transitionsappend 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) raisesValueError— this surface has no override mechanism, unlikeStateDB.update_status().guardadds extracolumn = :expectedequality constraints to the WHERE clause beyondstatus— required whenever a transition can be a same-state no-op (e.g.delivering -> deliveringrecovery 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.patchadds extracolumn = :valueassignments to the SET clause, applied atomically with the status change and thestatus_transitionsappend — 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()passesraise_on_undeclared_edge=True, so an undeclared edge (terminal or not) raisesValueErrorabove rather than resolving to"rejected", andTransitionRequestcarries 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 byreconcile_session_status, not this writer. Scaffold writes (progressions → session → branch) areINSERT OR IGNOREand 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 becauseINSERT OR IGNOREnever 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'scompletedmeans dormant, not terminal: when the transcript resumes, the next reconcile brings it back torunning. Liveness is judged bylast_message_at(the timestamp of the newest mirrored message) so an idle session converges tocompletedbefore the reaper can mark it failed, and an active one showsrunning(a live spinner in studio and the VS Code extension). It must NOT readupdated_at: the status write below bumpsupdated_at, so keying liveness off it would let a just-markedcompletedsession read as fresh again on the next pass and oscillate back torunning. ADR-0035's integrity floor treats every session terminal status (not justcompleted) 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 inadmin_eventslike any other override. A mirror session that is idle and already sitting on a non-completedterminal status (e.g. independently markedfailedorcancelled) is left alone rather than rewritten tocompleted— only a live transcript resuming can justify pulling it back torunning.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, viaparentUuid, at the last message of the session it continues. When the mirror resolves that pointer to a different session it stores alineagelink on the child'snode_metadataso 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.py—transition()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 publicTransitionCommandshape, used only bylionagi.state.lifecycle.adaptersto 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'sdelivering -> deliveringcrash-recovery claim guarding onattempt, 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 withenforce_edges=False; the legacylionagi.state.transitions.transition()did enforce one, for schedule_run, so it calls withenforce_edges=True), andraise_on_unguarded_conflict(an unguarded zero-row UPDATE is a storage anomalyStateDB.update_status()has always raisedRuntimeErroron, from inside the transaction so a same-transaction rollback still occurs). A self-edge'srequired_guard_fieldsmust be satisfied by eitherextra_guardcovering those exact columns or a genericexpected_versionguard (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()'swrite_reason_columnsmirrors legacy per-surface behavior:StateDB.update_status()always denormalized the reason onto the entity row's ownstatus_reason_*columns (the default); the legacylionagi.state.transitions.transition()surface never did, anddispatch_outbox(only reachable through that surface) doesn't even have those columns.callbacks.py— aRunTerminalEnvelopeis 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). Withinschema_version == 1the envelope's guaranteed fields never change name/type/semantics/ requiredness; new optional fields may be added without a version bump.register()'soverridemarks 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. Inemit()'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 sharedmove_on_afterdeadline);abandon_on_cancel=Trueis 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— resolvesnotify.on_terminal(a string compatibility form, or a mapping{enabled, adapter: {kind: exec|python, ...}, filter: {kinds, ids}}) into a handler installable on aTerminalCallbackRegistry. Precedence is per-run override > project settings > global settings > disabled; absent key and explicitenabled: falseare both disabled. No configuration shape ever reaches a shell: a plain command string is POSIX-word-split (shlex.split) and launched viaasyncio.create_subprocess_exec, nevercreate_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 identicalwait_fortimeout (the outer one started first and typically wins); either way the child (launched withstart_new_session=True, its own process group) must be reaped or it orphans a live subprocess, so cleanup runs inside a shieldedCancelScopesince 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 (TerminalCallbackRegistrystays 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'ssession_controlstable — apply/stamp ordering is verb-classed:pause/resumeare idempotent (apply, then stamp — safe to re-apply on a poller crash);messageis 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'sImmutableEdgeMap— deliberately not adictsubclass: 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 theMappinginterface leaves no inherited mutation surface at all — no__setitem__,update, or__ior__to reach, and re-invoking__init__is refused — whilepickle/copy.deepcopystill round-trip via__reduce__(reconstructing through the constructor) anddataclasses.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 fromget()cannot mutate global transition behavior for the process.notify_settings.pystderr/argv redaction contract — anotify.on_terminaladapter'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, persistednotify_outcome.json, or the log) carries raw argument values or an unfiltered stderr line: adapters are identified byargv[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 thanMIN_REDACTABLE_ARG_LEN(4 chars) are never redacted, since replacing them would corrupt unrelated text (a bare-vor0occurs 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'sregister_run_notify_outcome_scope()— returningNonesays 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 reactiveSpawnRequests were refused after the run exhausted its spawn capacity. Status remainscompleted; the non-clean reason andrefused_spawnevidence distinguish it from a run that never requested more work. The same reason is preserved when child sessions roll up to an invocation.ScheduleReasons— theschedule.skipped.prefix is not the full set of reasons a skippedschedule_runcan carry.DEFERRED_CAPACITYandBUDGET_EXHAUSTEDland on skipped rows without that prefix, and a third path — the task-admission path — stamps aschedule_runtoskippedwith the admission decision's own code, falling back toRunReasons.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 isVALID_REASON_CODES, the union across every reason class in this module. A consumer filtering skipped rows by theschedule.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 aschedule_runthat moves toskippedwithout 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 floodschedule_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_idmessage-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 likeexecutable_path/script_path/command_pathlexically 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 roottypeunion including one, without a top-levelenum/constpinning 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,$refsiblings 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(contentSchemacaveat) —contentSchemais inert with an individually-argued exception, not by default: its value is a mapping describing the DECODED content of a string instance, likecontentEncoding/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 requirecontentSchemato 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 everyprefixItemsposition and theitemstail is enum/const-bounded (recursively for nested arrays), the tail is explicit, andunevaluatedItemscannot 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_insufficientregardless 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)typeexcludes"object"→ insufficient; (3)typeunion has a free-form alternative with noconst/enumpin → insufficient; (4) APPLICATOR DELEGATION —$ref(local only, intersect structural siblings), thenoneOf/anyOf(UNION: every branch must prove sufficient), thenallOf(INTERSECTION: one bounded branch suffices), each recursing; (5) top-levelconst/enumpins the instance → sufficient regardless of type; (6) LEAF-OBJECT branch: omitted type (no const/enum) → insufficient (bare non-object never reaches object keywords); non-emptypropertiesonly bounded if CLOSED (additionalProperties: Falseor itself enum/const-restricted); empty/absent properties still needsadditionalProperties: 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. $refsibling re-check: Draft 2020-12 evaluates$refsiblings; 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:
additionalPropertiesdefaults to permissive (implicit true); a fixedoperationenum/const does NOT stop an undeclaredcommandproperty from riding alongside it unlessadditionalPropertiesis explicitlyfalse(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$reftarget, 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$defsvisit specifically guards against a future reference, or tooling resolving$defsby 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 BOTHprefixItemsmembers AND theitems(rest-of-array) schema checked together (see_array_reaches_free_form); a boundeditemsalone says nothing about an unboundedprefixItemsmember 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$refand allOf/anyOf/oneOf/if/then/else/not composition are resolved/ recursed so indirection is still caught. Critically, a nestedtype: "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:prefixItemsvalidates only prefix positions;itemsvalidates everything at-or-after. A MISSINGitemskeyword defaults totrue(unconstrained rest), so an array whose prefixItems are all enum/const-bounded is STILL free-form unlessitemsis explicitly present and bounded (orfalse). Every prefixItems member is checked too — boundeditemssays 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$refresolution).contentSchemaincluded on the same footing as its_INERT_ANNOTATION_KEYWORDSinclusion — the two caveats must be kept in sync if the content-assertion vocabulary assumption ever changes._UNRESOLVABLE_REFERENCE_KEYWORDS—$dynamicRef/$recursiveRefare schema-bearing like$refbut 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$dynamicAnchorresolution isn't something this walker can safely reproduce._NUMERIC_BOUND_KEYWORDS— explicit enumeration, deliberately NOT amin*/max*spelling heuristic: a prefix test would exempt an arbitrary unknown key likeminCustomThingfrom 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 thex-vendor-extension convention and$commentare 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 withx-. 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/$recursiveRefanywhere 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_NODESper-iteration, not just once at entry._composition_branch_reaches_free_form()— true when any composition/conditional/$refbranch 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$refresolution, 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$refsiblings; 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 requirementunbounded-script-payloaddemands 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 inasync def mcp_callable(**kwargs). When a Tool is built without an explicittool_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_schemadetects and exempts this case.mcp_tool_nameis the key under which the tool was registered inTool.mcp_config— the identitycreate_mcp_tool()used to name/document the wrapper, distinguishing it fromadvertised_name(what the caller claims the tool is called).validate_mcp_tool_admission()— raisesPermissionErrorwhen 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 (_configsis 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_configsafterward, 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 aGateResultevaluator 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_mcpand_forward_mcp_to_cli_requestso both agree on the authoritative.mcp.jsonand trust gate: explicitspec.mcp_config_pathalways wins; project-scoped.lionagi/.mcp.json/.mcp.jsononly considered whentrust_project_settings=True; user-home~/.lionagi/.mcp.jsontrusted unconditionally. An explicitmcp_config_paththat doesn't resolve raisesConfigurationError(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_mcponly reaches lionagi-nativebranch.actstools (inert for CLI providers, which spawn their own subprocess and never call back intobranch.acts); this function reaches the second island — the CLI subprocess's own per-turn request kwargs (ClaudeCodeRequest.mcp_servers, forwarded viaas_cmd_args()as--mcp-config). It deliberately setsmcp_servers(a plain dict field) rather thanmcp_config(path field):mcp_config's validator unconditionally rejects absolute paths, and both resolved candidates here are always absolute, so usingmcp_configwould raiseValidationErroron the very next turn. An explicitspec.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_requestresolves 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_modelfor a plainli agentleg, the resume path incli/agent.py, and_hand_mcp_serversfor 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, withenvandhttp_headers(possible secrets) routed to a 0600 profile file instead of argv.exclusivesays the set is the whole set: the Claude lane addsstrict_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.kwargsin 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, sharingshare_session/share_executorwith 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 ondedup_key(re-enqueue with the same key returns the existing row id).max_attemptsbounds delivery regardless ofack_required: an ack-required row that keeps sending successfully but never gets acked still exhausts atmax_attemptssends (dead_letter/DEAD_LETTER_ACK_TIMEOUT) rather than re-delivering forever.expires_atis an additional optional bound on top ofmax_attempts.deliver_due_dispatches()/_deliver_one_due_row()— ack-required rows loop back topending(notdelivered) on transport success, so the same due-scan re-attempts with backoff until acked/expired/max_attemptsexhausted; the default tier stops atdeliveredon first success.deliveringrows are re-scanned for crash recovery, but a claim is exclusive only for_CLAIM_LEASE_SECONDS— the guarded attempt-counter CAS intransition()(guard on pre-claimattempt, 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'stransition()call are separate transactions, so a concurrentpurge_dispatch(es)can delete a snapshotted row;transition()raisesLookupErrorin 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'sactor, 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_dispatchaccepts any status (naming an exact id is already deliberate non-bulk intent) and writes oneadmin_eventsaudit row on success.purge_dispatchesrequiresstatusand/orbefore(bare call raisesValueError, guards against accidental full-table delete); status semantics are deliberately asymmetric — an explicit status is honored exactly as given (including in-flightpending/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 indb_maintenance.prune_old_data. Always writes anadmin_eventsrow, including ondry_runcalls, and preservesstatus_transitionsrows 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.
pidandpgidare 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_timebinds 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.Nonemeans nothing was established and is never a claim about the process; this mirrors whatlionagi/mcp/jobs.pydoes withpid_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 dropscreate_timetoNoneif they disagree, the same shape as_pinned_memberinlionagi/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 answersNonefor 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 anasync defat 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
CancelledErrorandKeyboardInterrupt. 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 againstExceptionwould let a cancellation through and leave the child alive. -
That termination does not depend on surviving a second cancellation. The graceful path sends
SIGTERMand 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 ignoringSIGTERMwould then outlive an escalation that never ran. So a synchronousSIGKILLbackstop runs in afinallywhen the graceful path did not complete — noawait, 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_execresumes, 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
envthat 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, notValueError: pydantic convertsValueErrorandAssertionErrorinto aValidationErrorthat 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 fromto_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 intoEndpointConfig.kwargs, which is a supported way to configure an endpoint and also exactly whatEndpoint.to_dictserializes — so it reachesiModel.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 ofconfig.kwargsinto_runtime_stateat construction, wherecreate_payloadreads 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.reasonis set iff a lookup was configured and rejected; nothing configured andenabled: falseboth 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), andargv[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_lookupreports 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
ClaudeCodeRequestfield carries ajson_schema_extradict built by_cli()/make_cli_flag(), consumed bybuild_declarative_cli_args(). Kind semantics:value→--flag <str(val)>;bool→--flagwhen truthy else omitted;bool_pair→--flagwhen True,--neg-flagwhen 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_serversNone-vs-{}invariant. Defaults toNone, not{}, specifically so a request that never touched the field is distinguishable from a caller that explicitly forwarded an empty server selection. Theis not Nonecheck (not truthiness) inas_cmd_args()meansmcp_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) coverssystem_prompt_file,append_system_prompt_file,mcp_config,settings— deliberately excludingadd_dir.add_diris 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_modelresolution/precedence rules.agyhas 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-(...)-qualifiedmodel(a concrete agy display name) wins overeffortby default.reapply_effort=Trueexists specifically to let a neweffortreplace the suffix baked into a persisted prior resolution (e.g.li agent -r ... --effort ...), while amodelthe caller explicitly typed in the current turn still wins regardless.- No per-tool stdout events.
stream_gemini_cli'son_tool_use/on_tool_resultcallbacks are accepted for interface parity with other CLI providers but never fire in this transport, becauseagy's json output surfaces no per-tool events on stdout (they exist only in the per-session transcript file). - Relative-path/stdin quirks.
agyresolves relative--add-direntries against the process cwd and has no-C-style flag to change that, so the resolved workspace must be passed as the subprocesscwd. Default stdin isDEVNULLsince print mode reads nothing from stdin. streams_first_output_earlystays 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_clideliberately does NOT passcwd=tondjson_from_cli, because the Codex CLI already receives the workspace via the-C <repo>argument emitted byas_cmd_args(). Settingcwd=as well would make the CLI resolve-C repofrom insiderepo, producing a bogusrepo/repopath. - 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 undererror.message— both must be checked. The rawerrorvalue 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 sorun()treats it as clean EOS rather than raisingRunFailed. 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 explicitnull, once normalised to{}, must NOT qualify, hence checking_raw_errpre-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_MAPdesign rationale. Model-name prefixes are mapped topi --providervalues, 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 letpiresolve it itself, rather than the code guessing wrong.strip=Trueentries remove the prefix from the model string, needed foropenrouter/-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-levelAgentEvent.done(true end-of-stream) and a top-levelAssistantMessageEvent.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_earlystays False. Pi's transport emits an"agent_start"event right after spawn, butstream()discards raw dict events and only yields its firstStreamChunkonce aPiChunkactually 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 asgemini_code.py'sagynote 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_NonRetryableClientErrorso the originalaiohttp.ClientResponseErrorstays inspectable via__cause__while retry logic sees an excluded type. The sentinel must stay excluded until whichever retry layer is active (the outerretry_config-driven wrapper incall(), or the native path in_call_aiohttp()) has given up — unwrapping earlier would let a broadretry_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, becauseFormData/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_retriesis a total-attempt cap (formerly backoff'smax_tries), butretry_with_backoffrunsmax_retries+1attempts internally, so the call subtracts one to preserve the configured total attempt count._can_retry(): true when an explicitRetryConfigwraps the call, or the native path's total-attempt cap allows a second attempt; single-shot endpoints (max_retries<=1, noRetryConfig) never replay a body, so callers may hand over non-replayable inputs like one-shot streams only in that case.resilience.py— inretry_with_backoff,exclude_exceptionsmembership 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 aConnectionErroreven though it IS-AOSError.
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()— thefinallyblock pops the in-flight call fromexecutor.pilewithout yielding inside thefinally, because yielding inside a generator'sfinallywould swallow aCancelledErrorarriving during generator cleanup, which would breakanyio.fail_aftertimeout enforcement for callers wrapping the stream.copy(share_session, share_executor)— creates a newiModelwith the same config but a fresh ID.share_session=Truecarries the CLI provider'ssession_idonto the copy so cross-turn continuation is preserved (only applies when both endpoints areAgenticEndpoint).share_executor=Truereuses the exact sameRateLimitedAPIExecutorinstance 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 whatBranch.clone()relies on for CLI providers, where each cloned branch needs its own session and queue rather than contending with the parent's.circuit_breakerandretry_configobjects are shared by reference (not deep-copied) between original and copy; onlyconfigis 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 wrapsawait 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 onstop(). The periodic re-drive of the queue (if not self.queue.empty(): await self.process()) exists becauseprocess()re-enqueues rate-limited events instead of dropping them, butforward()is one-shot — without re-driving on each refresh, deferred events would sitPENDINGuntil the caller'sinvoke()safety timeout instead of actually retrying once the budget replenishes.stop()— Python 3.11+ re-raisesCancelledErroronawait taskaftertask.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 — returningFalsemakes the baseprocess()re-enqueue the event (staysPENDING) 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 thetypefield 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: unlessmode == "positional", every entry with a non-emptywhen:matcher is checked (skipping entries already served by awhen:match) and the first one whose predicate matches (call_index,after_calls,prompt_contains,prompt_regex,has_tool) is returned; ifmode == "when_only"and nothing matched, it raises immediately. Phase 2: falls back to positional order over entries that do NOT have awhen:matcher, advancing an internal cursor; raisesScriptExhaustedErroronce 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.