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
14 changes: 7 additions & 7 deletions packages/agent-core-v2/docs/state-manifest.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@
// agentsMdReminder.known src/agent/agentsMdReminder/agentsMdReminderService.ts
// agentsMdReminder.seeded src/agent/agentsMdReminder/agentsMdReminderService.ts
// contextProjector.lastRepairSignature src/agent/contextProjector/contextProjectorService.ts
// dateChange.seed src/agent/dateChange/dateChangeService.ts
// dateChange.seed src/features/dateChange/dateChangeService.ts
// externalHooks.stopHookContinuationUsed src/agent/externalHooks/externalHooksService.ts
// fullCompaction.activeTurnId src/agent/fullCompaction/fullCompactionService.ts
// fullCompaction.compactionCountInTurn src/agent/fullCompaction/fullCompactionService.ts
Expand Down Expand Up @@ -999,12 +999,6 @@ export interface AgentStateSnapshot {
'agentsMdReminder.seeded': boolean;
// src/agent/contextProjector/contextProjectorService.ts
'contextProjector.lastRepairSignature': string | null;
// src/agent/dateChange/dateChangeService.ts
'dateChange.seed': /* DateDisclosure — packages/agent-core-v2/src/agent/dateChange/dateChangeService.ts */ {
readonly localDate: string;
readonly timeZone: string;
readonly renderGeneration: number;
} | undefined;
// src/agent/externalHooks/externalHooksService.ts
'externalHooks.stopHookContinuationUsed': boolean;
// src/agent/fullCompaction/fullCompactionService.ts
Expand Down Expand Up @@ -1192,6 +1186,12 @@ export interface AgentStateSnapshot {
inputCacheCreation: number;
} | undefined;
'usage.currentTurnId': number | undefined;
// src/features/dateChange/dateChangeService.ts
'dateChange.seed': /* DateDisclosure — packages/agent-core-v2/src/features/dateChange/dateChangeService.ts */ {
readonly localDate: string;
readonly timeZone: string;
readonly renderGeneration: number;
} | undefined;
// src/features/plan/injection/planModeInjection.ts
'plan.wasActive': boolean;
}
Expand Down
16 changes: 13 additions & 3 deletions packages/agent-core-v2/src/_base/state/stateRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
* Persistence and replay are out of scope here. Scope-agnostic.
*/

import { Disposable } from '../di/lifecycle';
import { Disposable, type IDisposable, toDisposable } from '../di/lifecycle';
import { BugIndicatingError } from '../errors/errors';
import { Emitter, type Event } from '../event';

Expand All @@ -55,7 +55,7 @@ export interface StateInspection {
}

export interface IStateRegistry {
register<T>(key: StateKey<T>): void;
register<T>(key: StateKey<T>): IDisposable;
has(key: StateKey<unknown>): boolean;
get<T>(key: StateKey<T>): T;
set<T>(key: StateKey<T>, value: T): void;
Expand All @@ -69,18 +69,28 @@ export interface IStateRegistry {
// NOTE: stays Disposable — its own 'get' collides with the Fiber
export class StateRegistry extends Disposable implements IStateRegistry {
private readonly values = new Map<string, unknown>();
private readonly registrations = new Map<string, object>();
private readonly keyEmitters = new Map<string, Emitter<unknown>>();
private readonly anyEmitter = this._register(new Emitter<StateChange>());
readonly onDidChangeAny: Event<StateChange> = this.anyEmitter.event;

protected readonly inspectScope: string = 'unknown';
protected inspectParent?: IStateRegistry;

register<T>(key: StateKey<T>): void {
register<T>(key: StateKey<T>): IDisposable {
if (this.values.has(key.name)) {
throw new BugIndicatingError(`state key '${key.name}' is already registered`);
}
const registration = {};
this.registrations.set(key.name, registration);
this.values.set(key.name, key.initial());
return toDisposable(() => {
if (this.registrations.get(key.name) !== registration) return;
this.registrations.delete(key.name);
this.values.delete(key.name);
this.keyEmitters.get(key.name)?.dispose();
this.keyEmitters.delete(key.name);
});
}

has(key: StateKey<unknown>): boolean {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { ScopeActivation } from '#/_base/di/instantiation';
import { Feature } from '#/features/feature';
import { registerFeature } from '#/features/featureRegistry';

import { IAgentDateChangeService } from './dateChange';
import { AgentDateChangeService } from './dateChangeService';

export class DateChangeFeature extends Feature {
static override readonly name = 'dateChange';

constructor() {
super();
this.contributeAgentService(IAgentDateChangeService, AgentDateChangeService, {
activation: ScopeActivation.OnScopeCreated,
});
}
}

registerFeature(DateChangeFeature);
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,13 @@
*/

import { Disposable } from '#/_base/di/lifecycle';
import { LifecycleScope } from '#/app/scopes';
import { ScopeActivation, registerScopedService } from '#/_base/di/scope';
import { defineState } from '#/_base/state/stateRegistry';
import {
IAgentContextInjectorService,
type ContextInjectionContext,
type ContextInjectionResult,
} from '#/agent/contextInjector/contextInjector';
import { pickDisclosureBaseline } from '#/agent/contextInjector/disclosureBaseline';
import { pickDisclosureBaseline } from './disclosureBaseline';
import { IAgentProfileService } from '#/agent/profile/profile';
import { IAgentStateService } from '#/agent/state/agentState';
import { IHostClock } from '#/os/interface/hostClock';
Expand All @@ -50,7 +48,7 @@ export class AgentDateChangeService extends Disposable implements IAgentDateChan
@ISessionContext private readonly sessionContext: ISessionContext,
) {
super();
this.states.register(dateChangeSeedKey);
this._register(this.states.register(dateChangeSeedKey));
this._register(
injector.register<DateInjectionDisclosure>(
DATE_CHANGE_INJECTION_VARIANT,
Expand Down Expand Up @@ -135,11 +133,3 @@ function currentDateDisclosure(clock: IHostClock): Omit<DateDisclosure, 'renderG
timeZone,
};
}

registerScopedService(
LifecycleScope.Agent,
IAgentDateChangeService,
AgentDateChangeService,
ScopeActivation.OnScopeCreated,
'dateChange',
);
5 changes: 3 additions & 2 deletions packages/agent-core-v2/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -564,8 +564,9 @@ export * from '#/agent/contextMemory/contextTranscript';
export * from '#/agent/contextMemory/types';
export * from '#/agent/systemReminder/systemReminder';
export * from '#/agent/systemReminder/systemReminderService';
export * from '#/agent/dateChange/dateChange';
export * from '#/agent/dateChange/dateChangeService';
export * from '#/features/dateChange/dateChange';
export * from '#/features/dateChange/dateChangeService';
import '#/features/dateChange/dateChangeFeature';
export * from '#/agent/contextProjector/contextProjector';
export * from '#/agent/contextProjector/contextProjectorService';
export * from '#/agent/tokenCounting/tokenCounting';
Expand Down
46 changes: 46 additions & 0 deletions packages/agent-core-v2/test/_base/state/stateRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,52 @@ describe('StateRegistry', () => {
expect(() => registry.register(countKey)).toThrow(BugIndicatingError);
});

it('removes the key and value when its registration is disposed', () => {
const registry = new StateRegistry();
const registration = registry.register(countKey);
registry.set(countKey, 42);

registration.dispose();

expect(registry.has(countKey)).toBe(false);
expect(registry.entries()).toEqual([]);
expect(() => registry.get(countKey)).toThrow(BugIndicatingError);
expect(() => registry.set(countKey, 1)).toThrow(BugIndicatingError);
});

it('re-registers with the initial value and ignores stale disposal', () => {
const registry = new StateRegistry();
const first = registry.register(countKey);
registry.set(countKey, 42);
first.dispose();

const second = registry.register(countKey);
expect(registry.get(countKey)).toBe(0);

first.dispose();
expect(registry.has(countKey)).toBe(true);
second.dispose();
expect(registry.has(countKey)).toBe(false);
});

it('isolates listeners between registrations', () => {
const registry = new StateRegistry();
const first = registry.register(countKey);
const oldSeen: number[] = [];
registry.onDidChange(countKey)((value) => oldSeen.push(value));
registry.set(countKey, 1);
first.dispose();

const second = registry.register(countKey);
const newSeen: number[] = [];
registry.onDidChange(countKey)((value) => newSeen.push(value));
registry.set(countKey, 2);

expect(oldSeen).toEqual([1]);
expect(newSeen).toEqual([2]);
second.dispose();
});

it('rejects get and set on an unregistered key', () => {
const registry = new StateRegistry();
expect(() => registry.get(countKey)).toThrow(BugIndicatingError);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,20 @@ import { join } from 'pathe';

import { afterEach, beforeEach, describe, expect, it } from 'vitest';

import { FiberState } from '#/_base/di/fiber';
import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory';
import type { ContextMessage } from '#/agent/contextMemory/types';
import { IAgentLoopService } from '#/agent/loop/loop';
import { IAgentProfileService } from '#/agent/profile/profile';
import { IAgentStateService } from '#/agent/state/agentState';
import {
DEFAULT_AGENT_PROFILE_NAME,
type EnvironmentDisclosureSnapshot,
} from '#/app/agentProfileCatalog/agentProfileCatalog';
import { IFeatureManager } from '#/app/feature/featureManager';
import { IAgentDateChangeService } from '#/features/dateChange/dateChange';
import { DateChangeFeature } from '#/features/dateChange/dateChangeFeature';
import { dateChangeSeedKey } from '#/features/dateChange/dateChangeService';
import { IHostClock } from '#/os/interface/hostClock';
import { ISessionContext } from '#/session/sessionContext/sessionContext';

Expand All @@ -33,7 +39,7 @@ import {
InMemoryWireRecordPersistence,
type TestAgentContext,
} from '../../harness';
import { runWillBeginStepHooks } from '../loop/stubs';
import { runWillBeginStepHooks } from '../../agent/loop/stubs';

const TEST_TIME_ZONE = 'Asia/Shanghai';
const INITIAL_INSTANT = '2026-07-29T04:00:00.000Z';
Expand Down Expand Up @@ -443,4 +449,42 @@ describe('AgentDateChangeService', () => {
await runWillBeginStepHooks(loop);
expect(dateReminders(context)).toHaveLength(0);
});

it('withdraws and restores the eager service, provider, and seed with the Feature', async () => {
const manager = ctx.get(IFeatureManager);
const states = ctx.get(IAgentStateService);
updateSystemPromptWithoutDate(profile, ctx.get(ISessionContext).cwd);

expect(manager.units().find((unit) => unit.name === 'dateChange')?.state).toBe(
FiberState.Active,
);
expect(ctx.get(IAgentDateChangeService)).toBeDefined();
expect(states.has(dateChangeSeedKey)).toBe(true);
await runWillBeginStepHooks(loop);
expect(states.get(dateChangeSeedKey)).toMatchObject({ localDate: '2026-07-29' });

await manager.unprovideUnit('dateChange');
expect(() => ctx.get(IAgentDateChangeService)).toThrow();
expect(states.has(dateChangeSeedKey)).toBe(false);

clock.set('2026-07-30T04:00:00.000Z');
await runWillBeginStepHooks(loop);
expect(dateReminders(context)).toHaveLength(0);

manager.provideUnit(DateChangeFeature);
expect(ctx.get(IAgentDateChangeService)).toBeDefined();
expect(states.has(dateChangeSeedKey)).toBe(true);
expect(states.get(dateChangeSeedKey)).toBeUndefined();

await runWillBeginStepHooks(loop);
expect(dateReminders(context)).toHaveLength(0);
expect(states.get(dateChangeSeedKey)).toMatchObject({ localDate: '2026-07-30' });

clock.set('2026-07-31T04:00:00.000Z');
await runWillBeginStepHooks(loop);
expect(dateReminders(context)).toHaveLength(1);
expect(messageText(dateReminders(context)[0] as ContextMessage)).toContain(
"Today's date is now 2026-07-31",
);
});
});
Loading