ADR-0136: Moving the packages built on the core into lionhub, and removing their old import paths in 2.0¶
- Status: Accepted (2026-10-25), amended 2026-10-25 (see Amendments 1 and 2); Amendment 3 (2026-10-31) is Proposed
- Kind: Aspirational
- Implementation-status: partial. Wave 1 (D6) has landed for its four packages: #3752 adds the
lionhubroot and thelionagi._moved.MOVEDtable and its finder, and moveslionagi.orchestration; #3753 moveslionagi.engines; #3754 moveslionagi.dispatchandlionagi.work, with ADR-0001 Amendment 2 for D5's class paths.integrationsis held (D6). Wave 2 has begun: #3769 moveslionagi.mcptolionhub.serve.mcp(Amendment 1 A4 item 1). The rest of wave 2, wave 3 (D6) and the removal release (D4) remain. - Area: governance
- Date: 2026-10-25
- Relations: follows ADR-0122 D1 (layer order), D7's interim single-distribution rule and distribution gates, and D8 (compatibility is explicit and finite); amends ADR-0122 D7's post-split compatibility-distribution requirement as stated in D1; is the explicitly versioned compatibility decision that ADR-0125 D4 requires before a public import path is removed; amends ADR-0001 (Amendment 2: the new prefixes of moved packages join the class-path allow-list); updates ADR-0120's citations of the dispatch outbox to its new file; ADR-0138 records the Studio backend's form (D7, Amendment 2); Amendment 3 (Proposed) amends D6 row 3 and replaces Amendment 1 A5's providers-last order, with ADR-0140 as the providers discovery contract
Amendment 3 (2026-10-31): providers move first, with finite interim imports¶
This amendment replaces Amendment 1 A5's providers-last order and amends D6 row 3 explicitly. It preserves D1's single distribution through 1.x, D3's one table and finder, D4's removal in 2.0.0, and D5's identity, persisted-name and logging requirements. The maintainer requires existing 0.x callers to continue working unchanged throughout 1.x, without a new install or registration step.
D6 row 3 currently requires: "Both endpoint registration (lionagi/service/connections/registry.py) and token-budget lookup (lionagi/service/token_budget.py) use that contract instead of the module paths they list today." This amendment relaxes that prerequisite: the discovery contract lands after the package move and before 1.0. Between those changes, both files retain their module lists with their 46 provider paths rewritten to the new root, as the listed interim edges in C2. This puts the rename-only move with no behavior change first and the discovery behavior change second. The move carries an exact inventory test; a release gate prevents 1.0 while any of those 46 interim edges remains.
C1. Prerequisites and the new order¶
Before the providers package moves:
- Relocate provider errors to
lionagi._provider_errors, secret resolution tolionagi.service.accounts._secret_resolution, subagent admission tolionagi.service._subagent_admission, and the shared chat schema tolionagi.service.connections._chat_schemas, one change each. Preserve behavior and old names with specific D3 rows, using the first release that contains each move and removal in 2.0.0. Tests must support module rows before those rows land. The later broad providers row must not override these specific destinations._codex_profilestays with the providers. For moves, D3's finder replaces A5's on-disk re-exports and returns the same objects, verified by identity tests. - Accept ADR-0140 before dependent discovery implementation merges. Implement its catalog, metadata, failure and collision behavior after the package move and before 1.0. The move only rewrites the existing module lists; the subsequent contract change replaces both lists with the shared catalog.
- Preserve A5 item 1's provider-facing settings seam: secret resolution uses the existing
lionagi._settingsreader withinclude_project=Falseand the existing refusal/failure behavior. The tool interface and gate-contract work may wait until the tools/agent/casts move. No provider may gain an import of moved agent settings. - Make the four module-scope
_codex_profileimports inagent/factory.pylazy at their actual uses in a separate preparatory change. The other provider references listed for that file in C2 are already lazy. C2's inventory test lands in the package move, not before it; ADR-0140's contract failure checks land with the subsequent contract implementation.
The package and contract changes are then:
- Move
lionagi/providerscompletely tolionhub/src/lionhub/providerswith one broad compatibility row. Keep the implementation diff to renames, import rewrites, path docstrings, pinned logger names and the 46 module-string root rewrites, without behavior changes. - Implement ADR-0140 D1–D5 after that move and before 1.0. Write
lionhub.providers.manifestdirectly at its final path, add_provider_compatagainst it, and delete the 46 interim edges. - Move
tools,agentandcastsunder A5 item 1's coupled-move rule, including its separate target rows, root exports, installed-wheel data and old-pickle guarantees. Their tool interface, gate/settings and casts request-contract seams remain prerequisites, as does removal of the CLI catalog from core. This amendment does not require those unrelated seams before providers. - Move
hooks.externaltolionhub.hooks.external, after agent, under A1 and A5. - Move LNDL and its Middle under A3, after the fence-extraction seam. Its root-export compatibility exception is unchanged.
The plugin runtime and session hook bus stay in core under A1. Amendment 2's retained Studio application is unchanged. The CLI is still under lionagi at the development head and remains there at the providers move; its imports become hub-to-hub only when the CLI moves. The providers move does not wait for that CLI move; the CLI's own prerequisites remain in force.
C2. Exact interim imports and their removal¶
D2's statement that no core code initiates a hub import is amended for the paths below through their specified removal steps. These are dependency edges under ADR-0122 D1, including lazy references and the CLI module-top import. They are not permission for any other file in these packages to name the hub.
In the move change, add test_provider_interim_edge_inventory and rename D2's package-set test to test_only_packages_with_listed_interim_edges_name_lionhub. D2 now means "packages that hold a listed interim edge", since operations and service never move. The file-and-target inventory is the authority for lionhub.providers.* targets, the interim edges this move creates, and it lists the existing CLI and Studio provider edges as rows like every other core consumer; the package set is its projection. The wave-1 hub edges that cli and studio already hold (lionhub.orchestration*, lionhub.serve*) are not inventory rows: only the package-set test counts them, and they leave with the CLI move and with the application extraction under Amendment 2; _moved.py's D3 rows are data to both tests, as they are to the package-set test today. Both tests include ordinary, relative, string, type-only and lazy references and reject new or stale exceptions. Paths below are relative to lionagi/; the two target lists are frozen from the pre-move source and only their root changes.
| Core consumer | Allowed target and purpose after the move | Change ending the exception |
|---|---|---|
service/connections/registry.py | The 34 lionhub.providers.* strings in _import_all_providers' _modules list; the separate core scripted-provider entry is not one of them | Delete the list and its 34 edges in the contract change, before 1.0. |
service/token_budget.py | The 12 lionhub.providers.* values in _PROVIDER_MODULES | Delete the table and its 12 edges in the contract change, before 1.0. |
agent/factory.py | Existing references to lionhub.providers.openai._codex_profile, lionhub.providers.openai.codex, and lionhub.providers._cli_subprocess; all lazy after the preparatory change | Remove these core edges and agent from the set in the tools/agent/casts move. References then belong to the hub. The shared schema reference already points down into core. |
operations/run/run.py | Existing lazy lionhub.providers.google.gemini_code request/print-timeout behavior | Before 2.0, replace the import with an endpoint-supplied request-preparation port; remove operations from the set in that change. The operation stays in core. |
service/accounts/_router.py | Existing lazy lionhub.providers.anthropic._prompt_cache helpers | Before 2.0, replace both imports with adapter-supplied cache evidence and server-tool projection through a core-owned port. |
service/providers.py | Existing lazy lionhub.providers.openai._codex_profile effort-default lookup | Before 2.0, replace this import with provider configuration/effort policy supplied by the selected adapter. |
cli/_providers.py:45 | Module-top lionhub.providers.openai._codex_profile; also existing lazy lionhub.providers.google.gemini_code references at lines 200 and 320 | Remove these core edges in the CLI move; only then are they hub-to-hub. |
cli/agent.py | Existing lazy lionhub.providers.google.gemini_code reference at line 2152, and the lazy lionhub.lndl.middle reference at line 1026 that the LNDL move adds for the API leg's LNDL loop (Amendment 1 A3; test_lndl_interim_edge_inventory) | Remove these core edges in the CLI move; only then are they hub-to-hub. |
cli/orchestrate/_orchestration.py | Existing lazy lionhub.providers.openai._codex_profile reference at line 852 | Remove this core edge in the CLI move; only then is it hub-to-hub. |
studio/operator/engine.py | Existing lazy lionhub.providers.anthropic.claude_code (line 405) and lionhub.providers.google.gemini_code (line 679) references | Remove these edges, and studio from the set, with the application extraction under Amendment 2 at 2.0. |
service/connections/_provider_compat.py (added in the contract change, absent at the move) | One lionhub.providers.manifest source, used by registry and token budgeting | Delete at 2.0; roots pass composed catalogs. |
This amendment also relaxes D6 row 3's "a seam in the core for each, first" for operations/run/run.py, service/accounts/_router.py and service/providers.py. Their seams are deferred to before 2.0 so the behavior-preserving package move does not depend on request and policy redesign. These three existing implementation edges are temporary exceptions, not completed seams. Their replacement must preserve timeout precedence, cache-certificate and account-budget behavior, child-environment selection, explicit effort overrides and unknown-model handling. It may not copy these provider implementations or model catalogs into core. The 2.0 release guard requires none of these imports or automatic lookup paths to remain, plus behavior tests using injected policies.
At the providers-move head, LIONHUB_USERS_UNDER_LIONAGI is exactly {agent, cli, operations, service, studio}. The inventory test lands at that head and requires this exact set, with no alternative based on a presumed earlier CLI move.
The CLI and agent members leave on their respective moves. Operations leaves when its request-preparation import is removed. Service remains while any listed source file names the hub: the two module lists leave in the contract change before 1.0, which adds the automatic manifest source through 1.x. Service leaves when its final edge is removed at 2.0. Studio remains through 1.x and leaves with the application extraction under Amendment 2. At 2.0 the set is empty. Earlier deletion of an edge shrinks the file inventory immediately; it never leaves a stale exception.
C3. Compatibility, failure and change evidence¶
ADR-0140 defines discovery, lazy selection, key ownership and duplicates, contract versions, missing/failed provider behavior and context-window metadata under the amended D6 row 3 timing. This amendment adopts the following reading of D6 row 3's "shown with lionhub present and absent": present means the unchanged call resolves; absent means it reports at the public boundary, never a silent fallback. A fresh installed process resolves the unchanged provider constructor with the hub present and no earlier hub import. With the hub physically absent in 1.x, the same constructor reports an incomplete bundled installation at its public boundary. At 2.0 the boundary distinguishes an absent lionhub distribution from an installed hub without a composed provider catalog, as specified in ADR-0140 D4. Explicitly injected adapters remain usable without the hub.
The interim module lists, subsequent automatic loader and legacy registry publication are explicit temporary exceptions to ADR-0122's layer and composition rules. In 2.0 roots select and validate provider fragments and pass immutable catalogs or ports; importing or installing the hub does not activate it. ADR-0088 D8 entry points remain deferred.
D6's six per-change items apply with these amendments:
- Keep package relocation to renames, import rewrites, path docstrings and pinned logger names. The 46 module-string root rewrites belong to the move. Discovery, declaration and failure changes land separately afterward and before 1.0; the three deferred policy seams land before 2.0. The move carries fresh-process checks of every existing
lionagi.providers.*import and every built-iniModel(provider=...)key with supported prerequisites and no earlier hub import. - Include compatibility rows and CHANGELOG lines for the four lower modules and provider package, with actual deprecation release numbers and removal in 2.0.0.
- Rewrite repository callers and configuration/type strings to canonical paths, including
LazyTypestrings. The finder is for external old references, not internal discovery. - Report the import graph at the change head and exact package and file exception sets. For this move, the enumerated C2 consumers replace the original assertion that no core consumer imports the moved package. No other core consumer is authorized.
- Report D5's persisted-data population searched, old-path counts, and applicable class tests or AST evidence. A source census does not establish that external stored data contains no old path. Preserve old pickle and patch identity and the longer-prefix core destinations.
- Update moved-file citations that executable checks depend on; historical records retain their historical paths.
The four lower-module homes do not authorize provider-specific core growth. The existing shared schema carries model-dependent validation and a warning filter; retaining its behavior during relocation is a bounded compatibility choice, not proof of a pure contract. Full ADR-0122 compliance requires separate cleanup with parity evidence. Admission and secret resolution remain explicit services and must not run on import. Importing these modules by their canonical core paths must not load the hub; their old paths load the moved parent providers packages after the broad row lands.
MAY: retain only C2's listed interim edges through their removal steps and perform the four lower-module moves. MAY NOT: enlarge the exemption to other consumers, hide references in computed strings, use the finder for internal provider discovery, change logger names, or silently substitute a generic endpoint when a declared provider fails. Verify by: exact package and file inventories at every head, the 1.0 gate deleting all 46 interim edges, ADR-0140's fresh-wheel matrix, D3–D5 compatibility checks, and the 2.0 guard requiring no automatic hub path or compatibility row.
Amendment 2 (2026-10-25): the Studio boundary is set in wave 2 and the application extracted at 2.0¶
This amendment preserves D1's single-distribution transition and its promise that every existing lionagi.* import path remains available until 2.0 with its existing dependency requirements. It changes the timing of the Studio application extraction in D6, D7, and Amendment 1 A4. It withdraws D7's requirement that the backend record define how the container image gets and installs its backend and frontend from versioned public sources, with its release and rollback steps; how the application and its frontend are distributed after extraction is decided outside these records. It also extends the existing studio exemption in D2 until that extraction: D2's exempt set is {cli, studio} until B2 step 6 moves the CLI, {studio} from that step through the last 1.x release, and empty from 2.0. No core package gains permission to depend on the application or to name lionhub.
B1. Keep application imports available through 1.x¶
The Studio application remains at its current lionagi.studio paths in the lionagi wheel through 1.x. This includes its application routes, Operator, app factory and app export, and module launch surface. The daemon, scheduler, and shared execution code still move to lionhub during wave 2. The application moves to apps/studio/backend/src/ at 2.0, outside both library wheels and never into lionhub. D7's target destination is unchanged.
The backend record (ADR-0138) defines and establishes the application attachment, route ownership, lifecycle, identity authority, and shared services during wave 2. An explicitly selected application attaches to the daemon; a daemon started without it does not import application modules. Keeping application files in the wheel for compatibility does not make them a daemon dependency. Existing Studio launch commands continue to select the bundled application through 1.x without a new install step.
D3 rows are added only for modules or complete subpackages actually moved into the wheel's hub root. No broad lionagi.studio row may cover application modules retained on disk. Shared definitions extracted from a mixed module retain their old exports where necessary, refer to one implementation, and preserve object and state ownership. Retained modules are not represented as completed moves. The original application paths remain canonical until extraction, so importing those retained paths is not an exception to D4's prohibition on importing a path already moved by the table.
The wave-2 release documents the retained application paths and their removal in 2.0. A checked inventory covers those paths independently of D3's moved-module table. Fresh-install tests preserve their imports and the app and module launch surfaces throughout 1.x. A release guard requires removal of the retained application and its exemption at 2.0, when tests require the old application imports to fail. D4's compatibility rows still end at 2.0. The application's 2.0 extraction adds no runtime alias and needs no row whose destination was unavailable during 1.x. D5's persisted-name inventory, conversion, and release documentation obligations also cover the application paths removed at 2.0.
B2. Replace Amendment 1 A4's wave-2 order¶
- MCP remains moved to
lionhub.serve.mcpunder A4 item 1. Its compatibility and module-execution checks are unchanged. - Accept the CLI record and the Studio backend record before any change depends on their destinations or boundary. The backend record allocates every dependency listed in A4 item 2, states the 1.x bundled installation, and states the behavior any 2.0 application installation must keep.
- Break the CLI and Studio cycle and move the populated CLI catalog, its selection and loading entry points, and shared command logic as specified in A4 item 3. A Studio launcher may select the retained application explicitly; daemon-only and local schedule commands must not import it. The application calls shared services rather than private CLI command modules. No core seed names a moved target.
- Move the scheduler to
lionhub.orchestration.schedulerwith its compatibility rows and wheel-owned helpers, after or together with their extraction. Record both import directions and every retained Studio module, as specified in A4 item 4. - Move the daemon and its API to
lionhub.serve. Establish the backend record's attachment, route split, identity authority, lifecycle, and shared-service boundaries while the application remains in the wheel. The unused HTTP module list and loading path leavelionagi._autoby deletion in this step. The CLI catalog has already left in step 3. Test local schedule validation and export on a fresh installed wheel outside the checkout, with the application disabled and its imports refused by the test. This is the 1.x proof of backend independence; application files remain installed under D1. - Move the CLI to
lionhub.serve.cliin the form its record chooses and change thelientry-point target. Preserve the fresh-subprocess checks of old and new module invocation from A4 item 6. Studio commands select the retained application through the explicit attachment; local commands use wheel services.
Steps 3 through 5 may contain preparatory service extraction and explicitly specified behavior fixes before package moves. The moves themselves retain D6's restriction to relocation and import rewrites. In particular, a lifecycle cleanup correction or client authentication correction is not hidden in a move. Every intermediate state keeps D2 and D4. D2's exempt set is {cli, studio} before step 6 and {studio} after it, rather than the empty set the original D6 order reached at the end of wave 2. The retained application is not core code, even though its compatibility path is under lionagi. The test continues to require exact agreement with the packages that actually name lionhub; it does not gain a blanket exemption for retained compatibility files elsewhere.
B3. Complete extraction at 2.0¶
At 2.0, move the retained application to apps/studio/backend/src/, update its imports and activation source, and remove its old import paths and the studio exemption. The route, identity, state, and lifecycle contracts are already established by wave 2 and do not change because files move. The CLI record and D1's distribution release record determine console-script and package ownership. The backend record states the installation behavior the extracted application must keep; how the application and its frontend are distributed after extraction is outside this record and the backend record. A fresh core-and-hub installation with the application physically absent must pass the local schedule validation and export test required by A4 item 5.
The remaining lionagi package contains no application code and names no lionhub module after the compatibility removals. D7's statement that an extracted backend needs its own install or build step applies from this extraction. During 1.x the application still comes from the lionagi wheel; the studio extra continues to add dependencies. No second application distribution is introduced before 2.0, and this amendment does not change the other residency and seam decisions in Amendment 1.
Amendment 1 (2026-10-25): where the hook bus and the plugin runtime live, the casts and LNDL seams, and the order of waves 2 and 3¶
Facts below were measured at development-line commit 6bcd28a0. Line counts include every newline in the named Python files. Import-site counts use an AST walk over absolute, relative, nested and type-only imports, counting each import statement once.
A1. The session hook bus and the plugin runtime stay in the core¶
D6 places hooks and plugins in wave 3 and D7 gives them lionhub/hooks/ and lionhub/resources/. This amendment narrows both.
The hook bus. lionagi.hooks.bus, loader, persist, builtins and _message_retry (953 lines) are the session's event bus and its default persistence handlers. They import only ln, session and state. Session.hooks builds the bus on first use (session/session.py:137-146) with four default handlers (session start, session end, branch create, branch end; hooks/loader.py:25-30, 81-104), and operations and Branch take HookPoint from it at 9 sites (operations/_api_hooks.py:150, 179, 219, operations/act/act.py:162, 194, 220, operations/chat/chat.py:28, operations/run/run.py:451, session/branch.py:417). A bus moved out of the core would need its default builder supplied by a bootstrap, and an injected bus that starts empty would drop those defaults. These five modules and the package initializer stay in the core under their current paths, with no table row.
lionagi.hooks.external (883 lines) runs configured hook commands and reads plugin settings. It is what D7's lionhub/hooks/ slot describes, and it moves there in wave 3 as lionhub.hooks.external, one module with one table row. The core hooks initializer does not import it, before or after the move. In the same change, the compatibility tests enumerate a row's own module and walk descendants only when the destination is a package (today they read __path__ from every destination), and module identity, old pickles, warning attribution, removal version and stale-file checks cover the row.
The plugin runtime. lionagi.plugins (6 modules, 1,458 lines) is the core's extension mechanism under ADR-0088. Its consumers are tool misses and diagnostics for registered tools in ActionManager (protocols/action/manager.py:197-215, 251), provider misses and provider revalidation in EndpointRegistry (service/connections/registry.py:336, 368-370, 421, 422, 448-475). A provider miss currently consults every active declared provider module, not only the requested one. Discovery is lazy, trust is read on first use, and no caller registers anything (ADR-0088 D3). The runtime imports nothing above the core. Moving it would need an automatic bootstrap that preserves all of this in a fresh process; ADR-0088 D8 defers entry points, and no other bootstrap is chosen here. An injected runtime that starts empty would change caller behavior. So the runtime stays in the core under its current paths, with no table row. Plugin authoring and management commands may move with the CLI.
D7 changes accordingly. lionhub/hooks/ holds external. lionhub/resources/ loses registry, plugin; it is created only when code other than the runtime needs it, under D7's rule against empty modules.
A2. The casts seam¶
The core imports lionagi.casts.emission at 5 sites: SpawnRequest at operations/flow.py:1126, and EscalationRequest at operations/flow.py:1162, 1228, 1306 and session/signal.py:280. Both classes (casts/emission.py:401, 437) are request contracts that the flow runner and the session signal consume. They are defined once in a core module at or below session, and lionagi.casts.emission imports and re-exports the same class objects. The seam keeps the schemas, the defaults, the refusal of extra fields, the four operation values, and EscalationRequest's legacy blocking input and property (casts/emission.py:422-434). Neither class derives from Element (both are Pydantic models), so D5's class-path condition does not apply to them; pickles that name the old module still load, and the seam change shows it with a test. This paragraph's line numbers are at c4abb363.
lionagi/_auto.py:157-161 declares a CLI command whose module is lionagi.casts.surfaces. That declaration belongs to the CLI's catalog and leaves the core with it (A4, step 3).
A3. The LNDL seams¶
- Fence extraction.
operations/parse/parse.py:315importsextract_lndl_blocksto pull LNDL blocks out of a reply and join them in source order. A core extractor takes the language name as an argument, and LNDL's extractor is built on it. The change keeps every current rule oflndl/extract.py:12-27: only fences whose language is LNDL, matched case-insensitively (JSON fences are not taken); backtick and tilde fences; a closing fence identical to the opening one (same character, same length) at the same indentation, so a shorter or longer interior fence stays content; CRLF normalized inside blocks; trailing block newlines kept; blocks joined in source order; and the reply returned unchanged when it has no block. Its line numbers are atc4abb363. - The LNDL Middle.
operations/lndl_middle/lndl_middle.py:16imports 15 LNDL names. It is LNDL's opt-in Middle, so it moves with the language tolionhub.lndl.middle, with a table row forlionagi.operations.lndl_middle. - Root exports.
lionagi/__init__.py:86-94exports nine LNDL names lazily through_LAZY_MAP. They stay lazy compatibility names until 2.0, and their entries keep naming the old module paths, which the table serves. This is a stated exception, limited to these nine entries, to D4's rule that no repository module imports an old name and to D2's rule that the core initiates no hub import; it adds no hub name outside the table. A plainimport lionagistill imports no hub module. The first root access that loads an old module emits D3's warning at the external caller's line: the finder's frame walk treats the lazy-import helper (ln/_lazy_init.py) as import machinery, which it does not do today. Root access and a direct old import share one module and one warning history. The move change removes the root'sTYPE_CHECKINGimports of those names, which D3 already lists among the things that change without a warning. Tests enumerate the nine exports and check identity. At 2.0 a release guard that does not read the table, since the rows are gone by then, requires the nine names to be absent from_LAZY_MAP,__all__and root attribute access, and the release notes name both the root names and the module paths. D5 is unchanged: a class path names the defining module, never the root export, and oldlionagi.lndl.*pickle paths keep loading.
The LNDL move amends ADR-0051 D1, which names lionagi.lndl as the language's package, and the file list in ADR-0024 that names the moved files.
A4. Wave 2, in order¶
D6's prerequisite that state stop importing cli is met at 6bcd28a0: the last three imports were state/lifecycle/notify_settings.py:41, 383 and state/provenance.py:19 at 1e7be920.
Two edges decide the order. Studio imports CLI code at 32 statements in 14 files: _util 8, _runs 5, _providers 3, _project 3, _code_identity 2, _logging 1, _argtypes 1, orchestrate 4, and team, status, kill, engine and mirror 1 each, counting imports of names from the CLI package as well as from its submodules. The CLI imports Studio code at 11 statements in 4 files (cli/machine_schedule.py, cli/main.py, cli/mirror.py, cli/monitor.py), including the schedule declaration and export services, and studio/scheduler/subprocess.py:279 builds its parser with the CLI's add_orchestrate_subparser.
- MCP to
lionhub.serve.mcp, with one table row. Landed. The alias loader implements module execution, sopython -m lionagi.mcpruns the moved module under its old name. D2's set stays{cli, studio}. - Records. The CLI record and the Studio backend record (D7, DEFERRED) are accepted before any change that depends on their destinations or on the backend boundary. The backend record allocates every scheduler and CLI dependency on Studio code: scheduler state and its cycle with scheduler coordination (
studio/scheduler/engine.py:43,studio/services/scheduler_state.py:26), configuration, project resolution, maintenance, lifecycle reapers, schedule declaration and export, and shared command parsing. Logic the wheel's daemon or CLI needs stays in the wheel through shared modules or core interfaces; application routes and operator presentation move to the backend. - Break the CLI and Studio cycle, and take the CLI catalog out of the core. The helpers both sides use move to one shared module set with public names. The command logic and parsing Studio calls move to the
lionhub.orchestrationmodules D7 lists (flow, spawn, job, wait, kill, team, notify), and both sides import them from there. Before the first CLI command target moves, the populated CLI seed catalog and its selection and loading entry points leavelionagi/_auto.pyand their callers are rewritten; this includes thestudio,schedule(_auto.py:175, 180) andcastsdeclarations. A fresh CLI invocation discovers the same commands with no prior hub import and no caller registration. The generic declaration types may stay in the core if they name no hub module. Wrapper commands may stay at their old paths until their own move. The step is done whenlionagi.studioimports no privatelionagi.climodule, the remaining edges run one way, and no seed in the core names a moved target. - The scheduler to
lionhub.orchestration.scheduler, with its rows, together with the wheel-owned helpers the backend record assigns, or after they are extracted. The change records the import graph in both directions and the Studio modules that remain. - The daemon to
lionhub.serve, and the Studio application (operator, routes, app shell) toapps/studio/backendin the form the backend record chooses. The HTTP module list inlionagi/_auto.pyleaves the core in the same change. The CLI's local schedule validation and export keep working with the backend absent, checked on a fresh install. - The CLI to
lionhub.serve.cliin the form the CLI record chooses, and thelientry point's target changes. Old and newpython -minvocations are checked in fresh subprocesses: arguments, package-relative imports and exit status.
Every intermediate change keeps D2 and D4: no package under lionagi/ names lionhub outside the exempt set, and no repository module imports an old path. After step 6, D2's set is empty.
A5. Wave 3, in order¶
Seams first, one change each. Each core definition keeps the existing defaults and behavior, and each old module re-exports the same objects.
- The tool interface, and the gate and settings contracts. The core settings reader keeps the user-only mode (
include_project=False) and failure handling that provider secret lookup uses (providers/_secret_resolution.py:101-104). Its line numbers are atc4abb363. - The casts request contracts (A2).
- LNDL fence extraction (A3).
- The providers record (D6), with no source change.
Then the moves:
tools,agentandcastsin one change, after the seams and after the CLI catalog has left the core (A4, step 3).tools/coding.pyimportsagentat 6 sites andagentimportstoolsat 12 sites in five files (agent/permissions.py:14at module scope, andagent/factory.py,context_fold.py,hooks.py,nudge.py), so neither can move first without a package underlionagi/naminglionhub.castsmay join this change or follow it:agentimportscastsandcastsdoes not importagent.agentgoes tolionhub.agent;castsgoes tolionhub.agent.castswith its own row, so neither package initializer overwrites the other (agent/__init__.pyexports eagerly,casts/__init__.pylazily). The change keeps casts' root exports, its packagedpacks/default.yaml(checked from an installed wheel, sincecasts/catalog.py:21-27returnsNoneon a load failure), and old pickle paths.providers/_secret_resolution.py:101reads settings through the core reader from the settings seam, so D2's set does not grow. Its line numbers are atc4abb363.lionagi.hooks.externaltolionhub.hooks.external(A1). It goes afteragentbecauseagent/factory.pyandagent/settings.pyimport it, as does the CLI'shookscommand; nothing imports it the other way.- LNDL and its Middle to
lionhub.lndl(A3). providerslast, under its record.
Each move change carries D6's six items, unchanged.
What this amendment does not decide¶
- The CLI's home and the owner of
liat 2.0, and the Studio backend's form: each remains its own record (D7). A4 states what those records must allocate, not their answers. - The providers contract: its own record (D6).
- Entry-point discovery or any other automatic plugin bootstrap: still deferred by ADR-0088 D8. If one is adopted later, the residency in A1 can be revisited by a new amendment.
The maintainer asked for a layout that separates the core from the packages built on it, a first migration that breaks no caller, and the removal of the compatibility paths by lionagi 2.0 under a concrete deprecation plan. This record decides how.
Context¶
Line counts are wc -l at main 1e7be920; the import graph is an AST walk over 611 files that counts absolute, relative and in-function imports.
P1. The core and the code built on it share one package. lionagi/ holds the core (protocols, messages, session, operations, the service layer, ln) beside orchestration patterns (520 lines), the orchestration engines (5,998), the durable dispatch outbox (808), work forms (475), the CLI (32,958), the MCP server (8,155) and Studio (45,130). ADR-0125 moves the core toward lion1's runtime shape. While application code sits inside the core's package, every core change also has to be read against it, and the core's own size and seams are hard to see. Taking the code built on the core out of the core's package comes first.
P2. The layer law is not enforced. ADR-0122 D1 says a lower layer never imports a higher one, and its Phase 0 adds a static checker. At 1e7be920 no import-rule check exists under tests/ or scripts/. Inside one package an inversion is one relative import away and reads like any other.
P3. Moving a module renames it. Every import lionagi.engines… outside this repository, every unittest.mock.patch("lionagi.engines…") string, every pickle and every serialized class path names the old module. A plain move breaks all of them in the release that ships it.
P4. A second distribution now would tie two release contracts together. Old paths kept in lionagi would forward to lionhub, and lionhub imports lionagi, so each distribution would depend on the other during the transition. Compatible version requirements can resolve such a cycle, but the split would keep ownership running both ways and require coordinated releases and upgrades while the boundary is still changing. ADR-0122 D7 also measured a cycle between cli and studio (14 files one way, 3 the other), and its seven gates for a split have not been met. Providers stay in lionagi during wave 1; moving them later also needs discovery and installation contracts that keep iModel(provider="openai") working throughout 1.x (D6).
P5. Serialized data names classes by module path. Element data records its class in metadata["lion_class"], and deserialization imports a module only under an allowed prefix (ADR-0001 Amendment 1, default lionagi.). After a move, data written under the new path is refused by a process that has not already imported the class; data written under the old path still has to load; and the metadata check that compares lion_class with the class's own path rejects old-path metadata.
P6. Compatibility paths need an end. ADR-0122 D8 makes compatibility "explicit and finite", and ADR-0125 D4 forbids removing a public name without its own versioned decision. Without a removal release written into the code and checked by a test, old paths stay indefinitely.
| Concern | Decision |
|---|---|
| Distribution | D1: one lionagi distribution ships two top-level packages, lionagi (the core) and lionhub, until 2.0. |
| Layering | D2: the core never names lionhub; a test lists the packages under lionagi/ that still do, each of which moves later. |
| Old import paths | D3: one table, lionagi._moved.MOVED, served by a meta-path finder that returns the moved module object itself under its old name, with one DeprecationWarning. |
| Deprecation plan | D4: each row records the first release that deprecates it, 0.37.0 for wave 1, and removal in 2.0.0; a test fails the release that reaches the removal version; the CHANGELOG carries the table. |
| Persisted names | D5: serialized class paths load under either path until 2.0 and are saved under the new one, nested elements included; logger names do not change; each move searches persisted data first; 2.0 ships a published mapping and a converter. |
| Order | D6: three waves by import graph: packages the core never imports, then the CLI, MCP and Studio once their cycle is broken, then packages the core imports, each after a core seam. |
| Layout | D7: all of lionhub lives under lionhub/src/lionhub/; the target layout and where each part comes from. |
Out of scope:
- The Studio backend's form (its own process beside the daemon, or a plugin the daemon loads): DEFERRED to its own record before any wave-2 change depends on the backend boundary; D7 lists what that record decides, including how the free container image is built.
- Where the CLI lives and who owns
liat 2.0: DEFERRED to its own record before the wave-2 CLI move. Until 2.0 thelionagidistribution providesliwherever the code sits (D7). - The 2.0 release date: the maintainer's. This record fixes the version, not the date.
- Changing core mechanisms: ADR-0125 and later records. The moves here change no behaviour, including the older safeguards in the core that current models may no longer need.
- Which concepts from other projects are ported, and when: each port is its own change (D7).
- Editions outside the open-source package: the hosted and desktop editions are outside this repository.
Decision¶
D1 — One distribution, two import roots, until 2.0¶
The lionagi wheel ships two regular top-level packages. lionagi is the core and the import root it always was. lionhub holds the packages built on the core and ships in the same wheel.
[tool.hatch.build.targets.wheel]
packages = ["lionagi", "lionhub/src/lionhub"]
lionagi/ the core
lionhub/src/lionhub/ the second import root
__init__.py defines nothing (__all__ = ())
orchestration/ wave 1 (D6)
Exact semantics:
pip install lionagiinstalls both packages. No install has one without the other, soimport lionhubworks whereverlionagiis installed.lionhubhas no version of its own; it is the distribution's. Nolionhubdistribution is published before 2.0.- A non-editable install of the base dependency set imports
lionhub.orchestration, and one moved module under both names, in the CIcore-installjob. The job runs outside the checkout and asserts thatlionagiandlionhubboth load from the installed environment, since run from the repository root Python would import the source tree'slionagiinstead. - An editable install built by hatchling without
dev-mode-exactputs each package's parent directory onsys.path: the repository root forlionagiandlionhub/srcforlionhub(hatchling 1.32build_editable_detection,add_to_path(os.path.dirname(...))). Every directory inlionhub/srcis then importable as a top-level package, which is why D7 allows nothing there exceptlionhub/. The first wave-1 change checks an editable install from a clean environment: one module imported through its old path and its new path, the same object. - Every place that lists source roots names both: the wheel
packages; pyright'sincludeandextraPaths; ruff'ssrc, solionhubsorts as first-party; the lint, format, coverage and hygiene paths inscripts/ci.sh; the documentation-source roots of the skill linter; the Studio container build, which copieslionhub/src/lionhub/besidelionagi/; and the source roots oftests/test_moved_modules.pyandtests/operations/test_graph_entrypoint_conformance.py; and the CI change filters of the container job and the import and startup jobs, which includelionhub/**. A root missing from one of them is a package that tool does not see. Each root ships its ownpy.typedmarker. - ADR-0122 D7 keeps the
lionagitop-level namespace intact. It stays intact: every existinglionagi.*path imports until 2.0 (D3).lionhubis a second regular package, not a namespace package, which that record does not approve. - At 2.0
lionhubgets its own project file and distribution and depends onlionagi, and thelionagiwheel no longer contains thelionhubpackage. By then no table row is left (D4), solionagiimports nothing fromlionhuband no cycle exists. This amends ADR-0122 D7's requirement thatlionagistay a compatibility distribution for a documented window after a split: the 1.x line is the transition window, and the 2.0lionagidistribution is the core alone. Changing imports during 1.x is one migration step; code that useslionhubalso addslionhubto its declared requirements for 2.0. Before the split, its release record provides the installation matrix, compatible version constraints, the one distribution that owns each package file and console script, and tested fresh-install, upgrade, uninstall and rollback procedures that keep a bundled pre-2.0lionhuband a separately installed one from owning the same files. A core-only 2.0 install keeps the core and adapters passed in by the caller; capabilities that moved tolionhubneed it installed. The record decides the release mechanics against ADR-0122 D7's seven gates.
Why this way:
- A separate distribution now could keep installs working by declaring the dependencies both ways. It would add release, version-compatibility and upgrade obligations before ADR-0122 D7's gates are met (P4). The cycle itself can be resolved; the reason to wait is the unfinished boundary and release contract.
- A subpackage,
lionagi.hub, keeps one import root but moves every caller twice: tolionagi.hub.xnow and tolionhub.xat the split. A second top-level root now means the 2.0 split changes distribution metadata and leaves import paths alone. - One wheel keeps every install unchanged: nobody installs anything new and nothing that imported before fails. The layer rule that a package boundary would have enforced is enforced by a test (D2).
D2 — The core never names lionhub¶
# tests/test_moved_modules.py
LIONHUB_USERS_UNDER_LIONAGI = frozenset({"cli", "studio"})
_LIONHUB_NAME = re.compile(r"lionhub(\.[A-Za-z_][A-Za-z0-9_]*)*")
test_only_packages_that_move_later_name_lionhub walks every .py under lionagi/ except _moved.py. It collects each import lionhub…, each absolute from lionhub… import, and each string constant that is exactly a lionhub dotted name, groups the hits by top-level package under lionagi/, and requires that set of packages to equal LIONHUB_USERS_UNDER_LIONAGI. test_the_lionhub_check_sees_each_form_of_reference runs the collector over fixed source and requires the import, the from-import and the dynamic-import string to be seen, and the near misses (lionhubx.orchestration, and the prefix string lionhub.) not to be.
Exact semantics:
- A core package that starts naming
lionhubfails the test. - A listed package that stops naming
lionhubalso fails it, so the set shrinks with the tree and never carries a stale exemption. Each move change sets it to what the tree does at its head; wave 1 ends at{cli, studio}, both of which move in wave 2. - Dynamic imports count, as ADR-0122 D1 requires:
importlib.import_module("lionhub…")is a string constant naming alionhubmodule. Imports underTYPE_CHECKINGcount because they are import statements. lionagi/_moved.pynameslionhubonly as table data (D3) and is excluded by name.- The class-path allow-list in
lionagi/ln/_utils.pytakes its added prefixes from the table (D5) and names nolionhubmodule itself. That is deliberate and recorded here rather than hidden: the allow-list is trust policy, and the core imports alionhubmodule through it only when serialized data names one (ADR-0001 Amendment 2). The core imports nolionhubmodule on any path of its own.
Until wave 2, imports between the two roots run both ways: lionhub imports the core, and the two listed packages, lionagi.cli and lionagi.studio, import lionhub. That is the accepted interim shape, and the listed set is where it is recorded. Both roots ship in one wheel (D1), so lionagi has no second distribution to declare as a dependency, and the CI core-install job imports lionhub from a non-editable install of the base dependency set. The edges into lionhub end when wave 2 moves the two packages out of the core.
Why this way: inside one distribution the package boundary enforces nothing (D1), so a test does. A 40-line AST test runs in the existing CI test job and needs no new tool; an import-linting dependency for one rule was weighed and lost on that. ADR-0122 D6's full static checker subsumes this test when it lands.
D3 — Old import paths are one table and one finder¶
# lionagi/_moved.py
class Moved(NamedTuple):
old: str
new: str
deprecated_in: str
removed_in: str
MOVED: tuple[Moved, ...] = (
Moved("lionagi.orchestration", "lionhub.orchestration", "0.37.0", "2.0.0"),
Moved("lionagi.engines", "lionhub.orchestration.engines", "0.37.0", "2.0.0"),
Moved("lionagi.dispatch", "lionhub.orchestration.dispatch", "0.37.0", "2.0.0"),
Moved("lionagi.work", "lionhub.orchestration.work", "0.37.0", "2.0.0"),
)
def resolve(fullname: str) -> str | None: ... # the current name, or None if it did not move
def still_on_disk(fullname: str) -> bool: ... # a module file still at the old location
def install() -> None: ... # the finder first on sys.meta_path, once
The warning, one per old module:
{old} moved to {new}; the old import path is removed in lionagi {removed_in}. Import from {new} instead.
lionagi/__init__.py calls install(). Importing any lionagi.* name imports lionagi first, so the finder is in place before an old name is looked up.
Exact semantics:
- Lookup: the row with the longest
oldthat equals the name or is followed by.in it.lionagi.orchestrationxmatches no row and goes to the normal import system. - First import of an old name: the import system creates a placeholder for the old name, and the loader imports the new name and replaces the placeholder in
sys.moduleswith that module object, which the import system then returns. The loader writes nothing onto the moved module, its__spec__,__name__,__package__and loader included, so imports through two old names of one module, at the same time in two threads, cannot disturb each other. OneDeprecationWarning, attributed to the code that wrote the import, for animportstatement and forimportlib.import_module. - Later imports of the old name: a
sys.moduleshit, no warning. - Spec: the module keeps its real
__spec__and__name__(the new name), soimportlib.reloadand the relative imports inside it resolve against the new name. - Before the first import:
importlib.util.find_specon an old name returns a spec that describes it the way the new module is described, a package with the new package's search locations where the new module is one, and resource reads through the old name, such aspkgutil.get_data, go to the new module's loader. An old name whose new module does not exist gets no spec. - Below a prefix:
lionagi.engines.codingmaps tolionhub.orchestration.engines.coding; a name with no module under the new prefix raisesModuleNotFoundError. - A module file still at the old location in
lionagi/is never served: the finder steps aside, a copied-but-not-moved package imports as its stale self, andtest_no_old_module_is_still_on_diskfails. Half a move cannot read as a move. - Nested rows:
lionagi.enginesmoved inside the destination oflionagi.orchestration. The namelionagi.orchestration.enginesnever existed, but the orchestration row maps it tolionhub.orchestration.engines, so it is served and is the same module object aslionagi.engines. Declining it would let the normal path finder load a second copy of the package from the__path__oflionagi.orchestration, which is the new package's directory. - Identity: classes, functions and module globals are the same objects under both names, so
isinstance,unittest.mock.patchon an old dotted path and pickles naming an old module keep working. - Laziness:
import lionagiimports nolionhubmodule; old names resolve on demand (test_importing_lionagi_imports_nothing_from_lionhub, in a fresh interpreter, with a second import through an old name showing the probe seeslionhubmodules when they load). - Scope: the finder covers imports, old-path module identity, and spec and resource lookup before the first import. Logger names, entry points and other strings that name a module are D5's.
- What it does not cover: there are no files at the old locations, so listing
lionagi.__path__withpkgutil.iter_modulesorpkgutil.walk_packagesdoes not show a moved package, and a static type checker reading the installed wheel finds no module at an old path. Code that enumerates packages lists the new package instead; typed code changes its imports. No code in this repository enumerateslionagi.__path__. The moved modules and their objects also report the new path in__name__,__module__,__file__and tracebacks. None of these warns, and the CHANGELOG entry for the moves names them. - Cost: the finder is first on
sys.meta_path, so every import in a process that importedlionagipasses one prefix scan over the table (four rows in wave 1) before the normal finders.
Why this way:
- Star-import files at the old paths copy public names only, miss the private names callers and tests patch, and make
mock.patch("lionagi.engines.x.y")patch the copy rather than the object the code uses. They also leave one file per old module to keep and later delete. - Module
__getattr__facades in the old packages lose identity for submodules the same way, and need the old package directories to stay, whichstill_on_diskexists to rule out. - Eager aliasing in
lionagi/__init__.py(registering every new module under its old name at import) keeps identity but imports all oflionhubon everyimport lionagi, against the lazy-import contract. - Generated files at the old locations, so that
pkgutillists the moved packages and type checkers find the old paths. Any such file is a module or package on disk at an old location, which the stale-copy rule above exists to keep out, and a generated package would need its own__path__handling to keep one module object under both names. Type stubs generated from the table remain possible later without changing the runtime mechanism. - Writing the old-name spec onto the moved module while it loads, then restoring it: two old names of one module imported at the same time in two threads interleave the save and the restore, and leave an old-name spec on the moved module.
- One table makes removal the deletion of rows (D4), and the tests enumerate the table, so a move without a row, or a row without its tests, cannot pass.
D4 — The deprecation plan¶
- Deprecated in the first release that contains each move. Wave-1 rows use 0.37.0, the first release cut from the development line that contains them; if that release takes another number, the rows change with it before it ships. Rows added by later waves use their own first release, and a later move leaves callers a documented migration window before 2.0.0.
- Removed in 2.0.0, every row. The version is fixed here; the date is the maintainer's. The old paths survive 1.0, so callers can change import paths at any point across the 1.x line, apart from the runtime changes 1.0 brings (ADR-0125).
- Warning class:
DeprecationWarning, with the text in D3. Python shows it by default in code run as__main__and under pytest, and hides it in imports made inside other installed packages, so an application sees it where it imports and a library sees it in its own test run.FutureWarning, shown to every end user of an application, was weighed and lost: end users cannot act on a library's import path. - Tests,
tests/test_moved_modules.py, run by the CItestjob on Python 3.10 to 3.14 on every pull request, over every row and every module under each moved package: test_the_table_has_moves_and_every_package_has_modulestest_old_name_is_the_new_module_and_warns_once: same object, real spec, one warning naming both paths and the removal release, attributed to the importing filetest_import_statement_warning_points_at_the_importing_linetest_objects_are_shared_and_pickles_naming_the_old_path_loadtest_reload_through_the_old_name_keeps_the_real_spectest_two_old_names_imported_at_once_leave_the_module_spec_alone: two old names of one module imported in two threads, with the loader steps interleaved in a fixed ordertest_an_old_package_not_yet_imported_is_described_as_a_package: spec and resource reads through an old name in a fresh interpretertest_finder_is_installed_once_and_first_on_the_import_pathtest_importing_lionagi_imports_nothing_from_lionhubtest_names_outside_the_table_are_left_to_the_normal_import_systemtest_no_old_module_is_still_on_diskandtest_finder_steps_aside_for_a_module_still_at_its_old_pathtest_a_package_moved_inside_another_has_one_module_under_every_nametest_no_source_file_imports_an_old_name, withtest_the_reference_check_sees_each_form_of_old_reference: no module in either root imports an old path (relative imports resolved), so only code outside this repository ever reaches the findertest_removal_release_is_ahead_of_this_version: fails oncelionagi.__version__reaches a row'sremoved_in, so the 2.0.0 version change cannot pass CI while any row remainstest_changelog_names_every_move: each row's line, with its removal release, is in the CHANGELOG- D2's and D5's tests
The CI core-install job imports lionhub.orchestration and checks that an old path is the same module object as its new path, from a non-editable install with base dependencies only, run outside the checkout (D1). 5. CHANGELOG: the release that adds rows has a Changed entry that says what moved and why, and a table of old path, new path and removal release, one line per row. 6. The removal: one change at 2.0 deletes every row, lionagi/_moved.py, its install() call, the old-path resolution in Element (D5) and the tests that enumerate the table, after exporting the table as the published mapping D5 requires. Tests that old imports fail, and that data converted by the published procedure loads without the table, replace the compatibility tests. The CHANGELOG's Removed section repeats the import table and the installation change (D1). Item 4's version test is what forces the change.
D5 — Persisted names¶
The class-path allow-list is lionagi. plus the new prefix of each moved package, taken from the table, which in wave 1 adds lionhub.orchestration. (ADR-0001 Amendment 2). The metadata check in lionagi/protocols/generic/element.py reads an old path as the class's path at its new module and returns the metadata naming that path:
def _current_path(lion_class: Any) -> Any:
"""The dotted path a ``lion_class`` names today: one written under a module that has
since moved (``lionagi._moved.MOVED``) reads as the class's path at its new module."""
if isinstance(lion_class, str):
return _resolve_moved(lion_class) or lion_class
return lion_class
# in the validator
if "lion_class" in val:
current = _current_path(val["lion_class"])
if current != cls.class_name(full=True): ... # a different class: refused
if current != val["lion_class"]:
val = {**val, "lion_class": current}
Exact semantics:
- Data written before a move names the old path.
Element.from_dictloads it through the old module name (D3) whether or not the process has imported the class;model_validateon the class accepts it; saving the loaded object writes the new path. That holds for an element nested inside another one as well, since the nested element is saved from the metadata the check returned. It covers class paths in element metadata only: other strings in user data that name a moved module, such as a stored callable path, are not rewritten, and keep loading until 2.0 through the old name. - Data written after a move names the new path and loads in a process that has not imported
lionhub, because the moved packages' prefixes are allowed. - A path outside the allow-list is still refused, the error names
register_type_prefix, and nothing fromlionhubis imported. The tests refuselionhubx.andlionhub_x., which share letters with an allowed prefix, a class in the top-levellionhubpackage, andlionhub.canon, alionhubpackage outside the table. A malformed path under an allowed prefix (an empty segment or a leading dot) is refused before anything is imported. - What the prefixes reach: every module under a moved package's new prefix also imports by its old name under
lionagi.(D3), with the same parent packages, so the added prefixes make no new import-time code reachable from data. A module added later under a moved prefix is admitted the way a newlionagimodule is. Admitting anything else inlionhub, a new top-level package such ascanonincluded, is a new allow-list decision (ADR-0001 Amendment 2), as is the 2.0 distribution split.test_the_allow_list_adds_only_the_moved_packages_to_lionagifails when the added prefixes stop matching the table. The prefixes check names, not distributions: an allowed module's top-level code runs when data names it, as for anylionagi.module. - Tests: for every
Elementsubclass a moved module defines, a fresh interpreter, asserted to have imported nothing fromlionhubbefore reading the data, loads data naming the new path and data naming the old one, and checks the loaded class and that saving it gives the new-path data back;model_validateis checked with old metadata. The form sample nests an element whose metadata names its own class, so the nested path is checked too (test_the_form_sample_nests_an_element_that_names_its_class).test_every_moved_element_class_has_a_samplefails a moved class that has no sample instance. Removing the moved prefixes from the allow-list, restoring the exact-match metadata check, or returning the metadata unchanged fails these tests. - Condition on each move: a move of a module that defines a registered or serialized class lands with these tests in the same change. A move with no such class says so in its description, with the AST read that shows it.
- Pickles: as D3; they load under the old module name until 2.0.
- Logger names do not change. A moved module whose logger took its name from
__name__gets that old name as a literal in the move (lionagi.orchestration.patterns,lionagi.dispatch.outbox); the engines already used the literallionagi.engines. Logging configured on thelionagilogger therefore still captures them. They are a logging-configuration surface, not import paths; the old-reference test skipsgetLoggerarguments for this reason. Renaming them belongs to the 2.0 split and its release notes. - Run-store rows: the dispatch outbox records a dispatch kind and a destination, never a module path, so stored rows are unaffected.
- Search before each move: the change searches persisted data that could name the moved modules (saved runs and branches, profiles and playbooks, run stores) and reports in its description the population searched, how many records name an old path, and how many of those are
lion_classvalues. - Entry points and configuration strings in this repository that name a moved module are rewritten in the move change.
- At 2.0: the table goes (D4), so neither old imports nor old class paths resolve at run time. Before the table is deleted it is published as a fixed, versioned old-to-new mapping, with a standalone conversion procedure for the supported stored formats. The procedure covers nested class paths and declared class, type and callable fields outside
lion_class, works on files it is given, keeps the originals, reports references it cannot resolve, and never replaces matching text wholesale. The mapping is data for that procedure; it registers no import alias. Loading and saving under a 1.x release is a migration only for formats whose conversion has been shown complete; the 2.0 release notes list those formats and the manual steps for other configuration and third-party entry-point strings. Removing the table is not evidence that stored data has migrated.
D6 — Order: three waves by import graph¶
| Wave | Packages | Imported today by | Prerequisite |
|---|---|---|---|
| 1 | orchestration, engines, dispatch, work (7,801 lines) | cli, studio and each other | none: the core never imports them |
| 1, held | integrations (548) | nothing | open outside contributions that edit it land or close first, so their authors do not rebase across a move |
| 2 | cli, mcp, studio | each other; state imports cli in 2 files | the cli/studio cycle broken (Studio imports private lionagi.cli modules 32 times) and the state-to-cli inversion removed; the Studio backend goes to apps/studio/backend, outside the wheel; the daemon and its scheduler go into lionhub |
| 3 | providers, tools, agent, casts, hooks, plugins, lndl | the core: providers by service (3 files) and operations (2); tools, agent, casts by session (1 each); hooks by operations (4) and session (2); plugins by service (1) and protocols (1); lndl by operations (2) | a seam in the core for each, first. For providers (amended by Amendment 3), the record before the move specifies discovery, key ownership and duplicates, contract versions, lazy selection, behaviour when a provider is missing or fails, and context-window metadata. Both endpoint registration (lionagi/service/connections/registry.py) and token-budget lookup (lionagi/service/token_budget.py) use that contract instead of the module paths they list today. A fresh process resolves today's iModel(provider=...) call throughout 1.x with no earlier lionhub import and no new registration step for the caller, shown with lionhub present and absent; a core-only 2.0 install reports an absent provider at the public boundary rather than falling back silently |
Wave 1's destinations are lionhub.orchestration, lionhub.orchestration.engines, lionhub.orchestration.dispatch and lionhub.orchestration.work.
Every move change carries:
- The files moved with no edits beyond import rewrites, a package docstring that names its own path, and the logger names D5 keeps, so the change shows them as renames.
- Its table rows and CHANGELOG lines.
- Every caller in this repository on the new path (D4 item 4).
- The import graph at its head in its description: which packages import the moved one, showing the core is not among them, and D2's set at that head.
- D5's persisted-name search, and whether D5's class condition applies, with the tests or the AST read.
- Citations of moved files in records a test checks against the code (ADR-0120's outbox lines) moved to the new path. Other records, archived records and recorded benchmark runs keep the paths they were written with.
lionhub's paths become public with the first release that ships them. Until then they can change on the development line at no cost to anyone; from then on they are kept until 2.0, and the table maps only paths under lionagi. (test_no_old_module_is_still_on_disk asserts it). D7's layout for the wave-1 packages, and the lookup behaviour D3 states for old names, are therefore settled before 0.37.0 ships.
Waves 2 and 3 start with their own change lists. Packages move one change each, or one change per set that must move together.
D7 — lionhub's layout¶
Everything in lionhub lives under lionhub/src/lionhub/, and lionhub/src/ holds nothing else. An editable install makes every directory in lionhub/src/ a top-level package (D1), and generic names such as tools, hooks or providers there would collide with unrelated packages of the same name.
The target layout follows the maintainer's draft; this record's departures are listed after it.
lionhub/src/lionhub/
orchestration/ patterns, prompts <- lionagi.orchestration wave 1
engines/ <- lionagi.engines wave 1
dispatch/ <- lionagi.dispatch wave 1
work/ <- lionagi.work wave 1
flow, spawn, job, wait, kill, team, notify <- CLI orchestration, MCP jobs wave 2
schedule, scheduler/ <- the Studio scheduler wave 2
serve/ cli, daemon, mcp, server <- lionagi.cli, lionagi.mcp wave 2
providers/ anthropic/, openai/, gemini/, openrouter, groq, ollama, ...
<- lionagi.providers wave 3
tools/ context, skill, memory, web, watch, computer/
<- lionagi.tools wave 3
agent/ cast, role, seat, communication, inbox <- lionagi.agent, lionagi.casts wave 3
hooks/ gate, guard, hooks.toml, scripts/ <- lionagi.hooks wave 3
resources/ registry, plugin <- lionagi.plugins wave 3
lndl/ <- lionagi.lndl wave 3
integrations/ ruvector/, khive/ (new) <- lionagi.integrations held
canon/ charter, policy, evidence, governance, enforcement, authorization,
runtime/, dsl/, services/ new code (concept ports, below)
Departures from the draft:
- Wave-1 packages keep their package form and module names (
engines/,dispatch/,work/) instead of the draft's single-file slotsengine.py,notify.pyandtask.py. A whole-package move needs one table row and changes no module's content. Folding 5,998 lines of engines into one file, or the outbox into a notify module, is a refactor; riding on a move, it would make the move's rename diff unreadable, and after 0.37.0 it would cost a second set of compatibility paths. - Spelling: the draft's
integerationsbecomesintegrationsandspwanbecomesspawn. These become public import paths, kept until 2.0 once shipped. - The draft's directories directly under
lionhub/src/move underlionhub/src/lionhub/(the rule above). - Slots with no code yet (
orchestration/escalate,orchestration/task,tools/discovery,tools/knowledge,resources/mod, thee2bandflycomputer backends) are created with their first code, not as empty modules: an empty public module is an import path that promises nothing and still has to be kept until 2.0.
Concept ports. tools/watch from lion1, tools/computer/ following khive-computer, and the canon/ slots (governance, guard, enforcement) from a governance library enter as new code written from those concepts (ADR-0125 D1), not as copies. Each port is its own change with its own tests. A port is not a move, so it adds no table row.
DEFERRED: the CLI's home and who owns li at 2.0. The draft puts it in serve/cli. Until 2.0 the lionagi distribution declares li wherever the code sits, and only the entry point's target changes when the CLI moves. Before the wave-2 CLI move, its record chooses the CLI's public path, the one distribution that declares li from 2.0, and what a core-only 2.0 install has: no li, or a launcher with no dependencies that says what to install. It documents the install command for the working CLI and tests fresh installs and upgrades for missing, stale and doubly owned scripts. Where the code sits does not by itself decide who owns the script. D2 already fixes that the CLI leaves the core package, because it imports lionhub.
DEFERRED: the Studio backend's form. The Studio application (operator, routes, app shell, frontend connection) goes to apps/studio/backend, outside the wheel, and not into lionhub; the daemon and its scheduler go into lionhub. Two forms remain:
- A plugin the daemon loads: one port and one lifecycle, and simpler pairing for a browser page. Against it: backend code runs inside the daemon's process.
- Its own process beside the daemon: a clean boundary. Against it: two ports and two lifecycles.
There is no plugin host today. The fixed HTTP module list in lionagi/_auto.py and its loader have no caller yet, unlike the CLI declarations in the same module, and are a candidate starting point for one; the running app mounts its routes through lionagi.studio.registry.
A separate record chooses the form before any wave-2 change depends on the backend boundary, even a change filed as a CLI move. It defines activation and absence, version compatibility, route ownership, start, stop and readiness, failure and cancellation isolation, state ownership, and how the browser and the CLI connect and authenticate. The same record defines how the free container image gets and installs its backend and frontend from versioned public sources, reproducibly and without private credentials, with its supported versions and release and rollback steps: moving the backend outside the wheel needs an explicit install or build step, since .[studio] adds server dependencies but does not install the backend. Wave 1 does not depend on the backend's form.
Consequences¶
- The core's package loses 7,801 lines in wave 1 and, through waves 2 and 3, the CLI, MCP, Studio and integration code; ADR-0125's core changes are read against a smaller package.
- A layer inversion is a test failure (D2), before ADR-0122's full checker exists.
- Code outside this repository gets one warning per old module, naming the new path and the removal release, and imports, patches and pickles that name an old path keep working before 2.0. What changes without a warning is listed in D3: listing
lionagi.__path__, static type checking of old paths, and the new path in__name__,__module__,__file__and tracebacks. - Code that depends on
lionhubadds it to its requirements at 2.0 (D1). - Contributors work with two import roots, and the source roots are listed in every place D1 names; a new place that lists roots and misses
lionhubis a tool that silently skips it. - The finder is import machinery that runs first for every import in a process that imported
lionagi. Its per-import cost is one scan of the table, which grows with the waves. - The warning's attribution depends on CPython's import frames (the finder skips the frozen importlib frames when it computes
stacklevel). It is tested on 3.10 to 3.14; a Python that changes that frame layout misattributes the warning, and the tests catch it. - Reversal costs: D1 is cheap to reverse until 2.0. A table row is cheap to add; removing one before its version is a compatibility break. D4's 2.0.0 is a promise to callers once 0.37.0 ships. D7's paths for shipped packages are fixed from the release that ships them until 2.0.
Alternatives considered¶
- Separate
lionagiandlionhubdistributions now. It would have givenlionhubits own release cadence from the start, and declaring the dependencies both ways could keep old callers working while the compatibility layer andlionhubdepend on each other. That needs compatible version constraints, coordinated publication and upgrade tests while the boundary is still changing (P4). No benefit in independent releases or install size has been shown that would justify that cost before ADR-0122 D7's gates are met. - A
lionagi[hub]extra installing a separatelionhub. A separate distribution with a name on it: an old path then fails for anyone without the extra, a break in the first release. - Old paths that resolve only when
lionhubis installed, else anImportErrornaming the install. A good shape for two distributions; inside one wheel there is no install withoutlionhub, so it has nothing to do. - A subpackage,
lionagi.hub. Moves every caller twice (D1). - Namespace packages. ADR-0122 D7 does not approve them, and two regular packages need no namespace machinery.
- Star-import files, module
__getattr__facades, eager aliasing. D3, "Why this way". - No removal, keeping the old paths indefinitely. Against ADR-0122 D8; the table and the finder would become permanent import machinery.
- Rewriting persisted data on upgrade. ADR-0122 D8: persisted data is not rewritten merely because Python ownership moved. The library also cannot find every file its users saved.
- Moving every non-core package in one change. Too large to read as one change, and it would move packages the core imports before the core has seams for them, making the core import
lionhub. One wave per layer of the import graph keeps each change's reach readable. - An import-linting tool for D2. A new dependency and configuration for one rule; ADR-0122 D6's checker subsumes the test when it lands.