How Services Are Materialized and Disposed in agent-core-v2
agent-core-v2 implements a lightweight dependency-injection system that separates service registration from instance creation, materializing concrete objects on-demand via ServiceCollection.materialize() and cleaning them up through unmaterialize() when hierarchical scopes like Workspaces, Sessions, or Agents are disposed.
The agent-core-v2 engine in the MoonshotAI/kimi-code repository manages sophisticated service lifecycles across nested architectural layers. Understanding exactly how lazy descriptors transform into concrete instances—and how the framework guarantees deterministic cleanup—is critical for building stable agent applications that handle resource teardown correctly.
Service Registration and the SyncDescriptor Pattern
All services in agent-core-v2 are stored in a ServiceCollection that acts as the central registry for dependency injection. Each entry in this collection holds either a SyncDescriptor (the recipe for creating the service) or a ready-made instance.
When a provider registers a service, the entry contains the descriptor rather than the instance itself. This design enables lazy initialization. The descriptor remains dormant until the runtime explicitly requests the service, at which point the system can materialize the object on-demand.
In src/_base/di/serviceCollection.ts, the materialize(id, instance) method handles the transition from recipe to concrete object. According to the source code at lines 62-73, this method replaces the descriptor with the actual instance while preserving the entry's generation (uid) and maintaining a copy of the original descriptor in a recipe property for potential future reference.
Materializing Concrete Instances
When the runtime requires an actual object—such as when an agent first accesses its IEventBus—the InstantiationService orchestrates the materialization process. The service calls this._services.materialize(id, instance), which copies the newly created instance into the collection, making it available for all subsequent lookups within that scope.
The surrounding scope—whether a Workspace, Session, or Agent—retains a reference to the LedgerEntry so that the lifecycle of each materialized instance can be tracked independently. This ledger-based tracking ensures that the system knows exactly which services were materialized within each hierarchical boundary.
In src/_base/di/instantiationService.ts (lines 530-539), the materializedInstance logic triggers the actual storage of the concrete instance, bridging the gap between instantiation and persistent availability in the container.
Disposal and Un-Materialization
When an owning scope tears down—during workspace shutdown, session end, or provider withdrawal—the DI system must release resources and revert entries to their original state. This cleanup is handled through unmaterialization.
The ServiceCollection.unmaterialize(id) method, implemented at lines 75-84 in src/_base/di/serviceCollection.ts, restores the original SyncDescriptor to the entry, effectively "forgetting" the concrete instance. This operation is triggered by the InstantiationService at line 449 when a scope begins its disposal sequence.
This un-materialization is crucial for memory management, as it allows the JavaScript garbage collector to reclaim instance memory while keeping the service registration intact for potential future materialization in a new scope.
Scope-Aware Lifecycle Management
The DI container tracks disposables via LedgerEntry objects associated with each materialized service. When a scope's dispose() method is invoked—defined at lines 673-686 in src/_base/di/instantiationService.ts—the system iterates over its ledger and calls each registered disposer.
This guarantees that all materialized services are cleaned up in the correct order, respecting the hierarchical lifecycle of App → Workspace → Session → Agent. The src/_base/di/scopeUnits.ts file manages these per-scope views, ensuring that materialized units are torn down precisely when their containing scope dies, without affecting parent or sibling scopes.
Code Example: Complete Materialization Flow
import { SyncDescriptor, ServiceCollection } from './di/serviceCollection';
import { InstantiationService } from './di/instantiationService';
// 1️⃣ Register a service recipe (lazy registration)
const services = new ServiceCollection();
services.set(MyServiceId, new SyncDescriptor(MyServiceClass));
// 2️⃣ Materialize the service the first time it is required
const container = new InstantiationService(services);
const instance = await container.createInstance(MyServiceId); // triggers instantiation
services.materialize(MyServiceId, instance); // now stored as concrete instance
// 3️⃣ Use the materialized instance elsewhere
const myService = services.get(MyServiceId) as MyService; // returns the instance
// 4️⃣ Dispose the owning scope (e.g., a session ends)
await sessionScope.dispose(); // triggers unmaterialize()
// After disposal the entry reverts to the original descriptor
Summary
- ServiceCollection stores either SyncDescriptor recipes or concrete instances, enabling lazy initialization tracked via unique entry IDs.
- Materialization occurs through
ServiceCollection.materialize(id, instance)(lines 62-73), which swaps descriptors for instances while preserving generation metadata. - Un-materialization via
ServiceCollection.unmaterialize(id)(lines 75-84) restores original descriptors during scope teardown, allowing garbage collection. - InstantiationService (lines 530-539 and 673-686) orchestrates both creation and cleanup, iterating over ledger entries to dispose resources hierarchically.
- The system supports nested lifecycles (App → Workspace → Session → Agent) through scope-specific LedgerEntry tracking in
src/_base/di/scopeUnits.ts.
Frequently Asked Questions
What happens to materialized services when a scope is disposed?
When a scope dispose() runs, the InstantiationService iterates over the scope's ledger and calls ServiceCollection.unmaterialize(id) for each materialized entry. This restores the original SyncDescriptor and triggers any registered disposers, ensuring complete cleanup while preserving the service registration for future scopes.
How does materialize differ from instantiate in agent-core-v2?
Instantiate creates a new instance using the SyncDescriptor recipe, while materialize stores that instance in the ServiceCollection registry for reuse. Instantiation happens once per service ID within a scope, but materialization commits the instance to the collection, making it available to all subsequent get() calls without reconstruction.
Can a service be re-materialized after unmaterialization?
Yes. After unmaterialize(id) restores the SyncDescriptor, the service entry returns to its lazy state. The next request for that service ID triggers a fresh instantiation and materialization cycle. This pattern enables clean resource recycling when scopes restart without requiring re-registration of service recipes.
What is the role of SyncDescriptor in service materialization?
The SyncDescriptor acts as the immutable recipe or factory for service creation. It defines how to construct the service but defers actual instantiation until the runtime calls for materialization. During materialize(), the descriptor is archived to the recipe property and replaced by the concrete instance; during unmaterialize(), it is restored to active duty.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →