Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion packages/agent-core-v2/src/_base/di/collection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,15 @@ export interface CollectionToken<T> {

const _collectionTokens = new Map<string, CollectionToken<unknown>>();
const _collectionTokenSet = new WeakSet<object>();
const _collectionValidators = new WeakMap<
object,
(value: unknown, existing: readonly unknown[]) => void
>();

export function collection<T>(name: string): CollectionToken<T> {
export function collection<T>(
name: string,
options: { readonly validate?: (value: T, existing: readonly T[]) => void } = {},
): CollectionToken<T> {
const existing = _collectionTokens.get(name);
if (existing !== undefined) {
return existing as CollectionToken<T>;
Expand All @@ -61,6 +68,12 @@ export function collection<T>(name: string): CollectionToken<T> {
Object.defineProperty(token, 'name', { value: name, enumerable: false, configurable: true });
_collectionTokens.set(name, token as CollectionToken<unknown>);
_collectionTokenSet.add(token);
if (options.validate !== undefined) {
_collectionValidators.set(
token,
options.validate as (value: unknown, existing: readonly unknown[]) => void,
);
}
return token;
}

Expand Down Expand Up @@ -119,6 +132,10 @@ export class CollectionStore {
records = new Map();
this._records.set(token as CollectionToken<unknown>, records);
}
_collectionValidators.get(token)?.(
value,
[...records.values()].map((entry) => entry.value),
);
const record: StoredRecord = {
id: ++this._nextId,
value,
Expand Down
2 changes: 2 additions & 0 deletions packages/agent-core-v2/src/app/feature/featureManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import type {
ServiceRecipe,
} from '#/_base/di/fiber';
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
import type { ContributedFeatureService } from './featureServiceContribution';

export interface ManagedUnitInfo {
readonly name: string;
Expand All @@ -46,6 +47,7 @@ export interface IFeatureManager {
updateUnit(name: string, config?: unknown): Promise<void>;

units(): readonly ManagedUnitInfo[];
contributedServices(): readonly ContributedFeatureService[];
readonly onDidChangeUnits: Event<void>;
}

Expand Down
14 changes: 13 additions & 1 deletion packages/agent-core-v2/src/app/feature/featureManagerService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
* the previous handle (retract-then-assemble is the caller's cascade).
*/

import type { CollectionView } from '#/_base/di/collection';
import { Emitter, type Event } from '#/_base/event';
import type {
FiberHandle,
Expand All @@ -23,6 +24,10 @@ import {
IFeatureManager,
type ManagedUnitInfo,
} from './featureManager';
import {
FeatureServiceContribution,
type ContributedFeatureService,
} from './featureServiceContribution';

export class FeatureManagerService extends Service implements IFeatureManager {
declare readonly _serviceBrand: undefined;
Expand All @@ -31,7 +36,10 @@ export class FeatureManagerService extends Service implements IFeatureManager {
private readonly _onDidChangeUnits = new Emitter<void>();
readonly onDidChangeUnits: Event<void> = this._onDidChangeUnits.event;

constructor() {
constructor(
@FeatureServiceContribution
private readonly _contributedServices: CollectionView<ContributedFeatureService>,
) {
super();
this._register(this._onDidChangeUnits);
}
Expand Down Expand Up @@ -97,6 +105,10 @@ export class FeatureManagerService extends Service implements IFeatureManager {
}
return infos;
}

contributedServices(): readonly ContributedFeatureService[] {
return this._contributedServices.items;
}
}

registerScopedService(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { collection } from '#/_base/di/collection';
import type { ServiceIdentifier } from '#/_base/di/instantiation';
import type { LifecycleScope } from '#/app/scopes';

export interface ContributedFeatureService {
readonly scope: LifecycleScope;
readonly id: ServiceIdentifier<unknown>;
}

export const FeatureServiceContribution = collection<ContributedFeatureService>(
'feature-service',
{
validate(value, existing) {
if (existing.some((entry) => entry.scope === value.scope && entry.id === value.id)) {
throw new Error(
`Service ${String(value.id)} is already contributed at scope ${value.scope}`,
);
}
},
},
);
5 changes: 2 additions & 3 deletions packages/agent-core-v2/src/features/feature.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
AgentProfileContribution,
AGENT_PROFILE_SOURCE_PRIORITY,
} from '#/app/agentProfileCatalog/agentProfileContribution';
import { FeatureServiceContribution } from '#/app/feature/featureServiceContribution';
import type { AgentProfile } from '#/app/agentProfileCatalog/agentProfileCatalog';
import type { ConfigSchema, RegisterSectionOptions } from '#/app/config/config';
import { ConfigSectionContribution } from '#/app/config/configSectionContributions';
Expand All @@ -43,8 +44,6 @@ import {
type AnyAgentTool,
} from '#/agent/toolRegistry/toolContribution';

import { recordContributedService } from './featureRegistry';

export abstract class Feature extends Service {
contribute<T>(token: CollectionToken<T>, value: T): FiberHandle {
return this.provide(token, value);
Expand All @@ -68,7 +67,7 @@ export abstract class Feature extends Service {
ctor: ServiceClassRecipe,
opts?: FiberProvideOptions,
): FiberHandle {
recordContributedService(scope, id);
this.provide(FeatureServiceContribution, { scope, id });
return this.provide(ScopeUnits(scope), {
name: `${this.name}:${String(id)}`,
apply(fiber: Fiber): void {
Expand Down
21 changes: 0 additions & 21 deletions packages/agent-core-v2/src/features/featureRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
*/

import type { ServiceClassRecipe } from '#/_base/di/fiber';
import type { ServiceIdentifier } from '#/_base/di/instantiation';

const _featureRecipes: ServiceClassRecipe[] = [];

Expand All @@ -26,23 +25,3 @@ export function getFeatureRecipes(): readonly ServiceClassRecipe[] {
export function _clearFeatureRecipesForTests(): void {
_featureRecipes.length = 0;
}

const _contributedServices: { scope: string; id: ServiceIdentifier<unknown> }[] = [];

export function recordContributedService(scope: string, id: ServiceIdentifier<unknown>): void {
if (_contributedServices.some((entry) => entry.scope === scope && entry.id === id)) {
return;
}
_contributedServices.push({ scope, id });
}

export function getContributedServices(): ReadonlyArray<{
scope: string;
id: ServiceIdentifier<unknown>;
}> {
return _contributedServices;
}

export function _clearContributedServicesForTests(): void {
_contributedServices.length = 0;
}
1 change: 1 addition & 0 deletions packages/agent-core-v2/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ export * from '#/app/capability/capabilityService';
export * from '#/app/capability/errors';
export * from '#/app/capability/types';
export * from '#/app/feature/featureManager';
export * from '#/app/feature/featureServiceContribution';
import '#/app/feature/featureManagerService';
export * from '#/features/feature';
export * from '#/features/featureAssembly';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import { IFeatureAssemblyService } from '#/features/featureAssembly';
import { FeatureAssemblyService } from '#/features/featureAssemblyService';
import {
_clearFeatureRecipesForTests,
getContributedServices,
registerFeature,
} from '#/features/featureRegistry';

Expand Down Expand Up @@ -53,9 +52,9 @@ describe('DebugEventsFeature — App-scope introspection service', () => {
const manager = host.app.accessor.get(IFeatureManager);
expect(manager.units().map((unit) => unit.name)).toContain('debugEvents');
expect(
getContributedServices().some(
(entry) => entry.scope === LifecycleScope.App && entry.id === IDebugEventsService,
),
manager
.contributedServices()
.some((entry) => entry.scope === LifecycleScope.App && entry.id === IDebugEventsService),
).toBe(true);

const result = host.app.accessor.get(IDebugEventsService).subscriptions();
Expand All @@ -68,6 +67,11 @@ describe('DebugEventsFeature — App-scope introspection service', () => {
await host.app.instantiation.cascade.whenIdle();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(() => host.app.accessor.get(IDebugEventsService)).toThrow();
expect(
manager
.contributedServices()
.some((entry) => entry.scope === LifecycleScope.App && entry.id === IDebugEventsService),
).toBe(false);
host.dispose();
});
});
82 changes: 81 additions & 1 deletion packages/agent-core-v2/test/features/feature.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { createDecorator, ScopeActivation } from '#/_base/di/instantiation';
import { type InstantiationService } from '#/_base/di/instantiationService';
import {
_clearScopedRegistryForTests,
getScopedServiceDescriptors,
registerScopedService,
type Scope,
} from '#/_base/di/scope';
Expand All @@ -20,7 +21,10 @@ import { AgentToolContribution } from '#/agent/toolRegistry/toolContribution';
import { Feature } from '#/features/feature';
import { IFeatureAssemblyService } from '#/features/featureAssembly';
import { FeatureAssemblyService } from '#/features/featureAssemblyService';
import { _clearFeatureRecipesForTests, registerFeature } from '#/features/featureRegistry';
import {
_clearFeatureRecipesForTests,
registerFeature,
} from '#/features/featureRegistry';
import type { AgentTool, ToolExecution } from '#/tool/toolContract';

interface IGreeter {
Expand Down Expand Up @@ -138,6 +142,82 @@ describe('Feature — built-in capability assembly (src/features)', () => {
host.dispose();
});

it('rejects duplicate service contributions until the provider unloads', async () => {
class FirstFeature extends Feature {
static override readonly name = 'first-feature';

constructor() {
super();
this.contributeAgentService(IGreeter, GreeterService);
}
}
class SecondFeature extends Feature {
static override readonly name = 'second-feature';

constructor() {
super();
this.contributeAgentService(IGreeter, GreeterService);
}
}
registerFeature(FirstFeature);

const host = createScopedTestHost();
const manager = host.app.accessor.get(IFeatureManager);
const agent = host.child(LifecycleScope.Agent, 'agent-1');
const original = agent.accessor.get(IGreeter);
expect(() => manager.provideUnit(SecondFeature)).toThrow(
/Service test-feature-greeter is already contributed at scope agent/,
);
expect(manager.units().map((unit) => unit.name)).toEqual(['first-feature']);
expect(
manager
.contributedServices()
.filter((entry) => entry.scope === LifecycleScope.Agent && entry.id === IGreeter),
).toHaveLength(1);
expect(collectionViewOf(host.app, ScopeUnits(LifecycleScope.Agent)).items).toHaveLength(1);
expect(agent.accessor.get(IGreeter)).toBe(original);

await manager.unprovideUnit('first-feature');
await host.app.instantiation.cascade.whenIdle();
expect(() => manager.provideUnit(SecondFeature)).not.toThrow();
expect(manager.units().map((unit) => unit.name)).toEqual(['second-feature']);
expect(agent.accessor.get(IGreeter).greet()).toBe('hi');

host.dispose();
});

it('isolates equal service contributions between App roots', async () => {
class SharedFeature extends Feature {
static override readonly name = 'shared-feature';

constructor() {
super();
this.contributeAgentService(IGreeter, GreeterService);
}
}
registerFeature(SharedFeature);

const first = createScopedTestHost();
const second = createScopedTestHost();
const firstManager = first.app.accessor.get(IFeatureManager);
const secondManager = second.app.accessor.get(IFeatureManager);
const firstAgent = first.child(LifecycleScope.Agent, 'agent-1');
const secondAgent = second.child(LifecycleScope.Agent, 'agent-1');
expect(firstManager.contributedServices()).toHaveLength(1);
expect(secondManager.contributedServices()).toHaveLength(1);
expect(firstAgent.accessor.get(IGreeter)).not.toBe(secondAgent.accessor.get(IGreeter));

await firstManager.unprovideUnit('shared-feature');
await first.app.instantiation.cascade.whenIdle();
expect(firstManager.contributedServices()).toHaveLength(0);
expect(secondManager.contributedServices()).toHaveLength(1);
expect(() => firstAgent.accessor.get(IGreeter)).toThrow();
expect(secondAgent.accessor.get(IGreeter).greet()).toBe('hi');

first.dispose();
second.dispose();
});

it('materializes a per-scope class recipe contributed through contribute()', () => {
class SoloAgentUnit extends Service {
static override readonly name = 'solo-feature/agent';
Expand Down
6 changes: 4 additions & 2 deletions packages/agent-core-v2/test/harness/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { expect, vi } from 'vitest';
import { toDisposable } from '#/_base/di/lifecycle';
import type { IInstantiationService } from '#/_base/di/instantiation';
import type { IAgentScopeHandle } from '#/_base/di/scope';
import { getContributedServices } from '#/features/featureRegistry';
import { IFeatureManager } from '#/app/feature/featureManager';
import { Emitter, Event } from '#/_base/event';
import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle';
import type { Promisable, PromisifyMethods } from '#/_base/utils/types';
Expand Down Expand Up @@ -910,7 +910,9 @@ function reassertServiceOverrides(
instantiation: IInstantiationService,
): void {
const contributed = new Set(
getContributedServices()
instantiation
.invokeFunction((accessor) => accessor.get(IFeatureManager))
.contributedServices()
.filter((entry) => entry.scope === scope)
.map((entry) => entry.id),
);
Expand Down
14 changes: 10 additions & 4 deletions packages/kap-server/src/transport/channelRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,12 @@

import {
Disposable,
getContributedServices,
getScopedServiceDescriptors,
IFeatureManager,
LifecycleScope,
} from '@moonshot-ai/agent-core-v2';

import type { ScopedEntry, ServiceIdentifier } from '@moonshot-ai/agent-core-v2';
import type { Scope, ScopedEntry, ServiceIdentifier } from '@moonshot-ai/agent-core-v2';

export interface ChannelMethodDescriptor {
readonly name: string;
Expand Down Expand Up @@ -87,10 +87,16 @@ function scopedServiceNameIndex(): Map<string, ServiceIdentifier<unknown>> {
}

/** Resolve a wire name to its `ServiceIdentifier` anywhere in the DI registry. */
export function resolveAnyScopedServiceId(name: string): ServiceIdentifier<unknown> | undefined {
export function resolveAnyScopedServiceId(
core: Scope,
name: string,
): ServiceIdentifier<unknown> | undefined {
return (
scopedServiceNameIndex().get(name) ??
getContributedServices().find((entry) => entry.id.toString() === name)?.id
core.accessor
.get(IFeatureManager)
.contributedServices()
.find((entry) => entry.id.toString() === name)?.id
);
}

Expand Down
4 changes: 2 additions & 2 deletions packages/kap-server/src/transport/dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ export async function resolveService(
scopeKind: ScopeKind,
params: Record<string, string>,
serviceName: string,
lookup: ChannelLookup = resolveAnyScopedServiceId,
lookup: ChannelLookup = (name) => resolveAnyScopedServiceId(core, name),
): Promise<object> {
const scope = await resolveScope(core, scopeKind, params);
if (scope === undefined) {
Expand Down Expand Up @@ -128,7 +128,7 @@ export async function dispatch(
serviceName: string,
method: string,
arg: unknown,
lookup: ChannelLookup = resolveAnyScopedServiceId,
lookup: ChannelLookup = (name) => resolveAnyScopedServiceId(core, name),
): Promise<unknown> {
const service = await resolveService(core, scopeKind, params, serviceName, lookup);
const member = (service as Record<string, unknown>)[method];
Expand Down
Loading
Loading