mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-31 02:14:58 +00:00
refactor(agent-core-v2): consolidate wire services (#1680)
This commit is contained in:
parent
9eff230f97
commit
26d499bca7
135 changed files with 2697 additions and 3322 deletions
|
|
@ -20,15 +20,15 @@ export function isSafeAgentId(id: string): boolean {
|
|||
}
|
||||
|
||||
interface StateJson {
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
createdAt?: string | number;
|
||||
updatedAt?: string | number;
|
||||
title?: string;
|
||||
isCustomTitle?: boolean;
|
||||
lastPrompt?: string;
|
||||
// Agent metadata comes from an untrusted state.json (a corrupt or imported
|
||||
// bundle may hold non-object entries like `{ "main": null }`), so the value
|
||||
// type allows null and inventoryAgents skips anything that isn't an object.
|
||||
agents?: Record<string, { homedir: string; type: 'main' | 'sub' | 'independent'; parentAgentId: string | null; swarmItem?: string } | null>;
|
||||
agents?: Record<string, { type: 'main' | 'sub' | 'independent'; parentAgentId?: string | null; swarmItem?: string } | null>;
|
||||
custom?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
|
|
@ -103,7 +103,7 @@ async function readImportedDetail(home: string, importId: string): Promise<Sessi
|
|||
// `agents` map. When the inventory comes back empty, fall back to probing
|
||||
// `agents/*` on disk so routes that require an agent (wire/context/…) still
|
||||
// resolve `main`.
|
||||
let agents = await inventoryAgents(sessionDir, state, true);
|
||||
let agents = await inventoryAgents(sessionDir, state);
|
||||
if (agents.length === 0) {
|
||||
agents = await discoverAgentsFromDisk(sessionDir);
|
||||
}
|
||||
|
|
@ -254,7 +254,7 @@ async function readSessionIndex(home: string): Promise<Map<string, SessionIndexE
|
|||
return out;
|
||||
}
|
||||
|
||||
async function inventoryAgents(sessionDir: string, state: StateJson, deriveHomedir = false): Promise<AgentInfo[]> {
|
||||
async function inventoryAgents(sessionDir: string, state: StateJson): Promise<AgentInfo[]> {
|
||||
const result: AgentInfo[] = [];
|
||||
for (const [id, meta] of Object.entries(state.agents ?? {})) {
|
||||
if (!isSafeAgentId(id)) continue;
|
||||
|
|
@ -280,11 +280,8 @@ async function inventoryAgents(sessionDir: string, state: StateJson, deriveHomed
|
|||
result.push({
|
||||
agentId: id,
|
||||
type: meta.type,
|
||||
parentAgentId: meta.parentAgentId,
|
||||
// For imported bundles the persisted homedir is the exporting machine's
|
||||
// absolute path; re-derive it from the local extraction so blob reads
|
||||
// (which join homedir) resolve under the imported directory.
|
||||
homedir: deriveHomedir ? join(sessionDir, 'agents', id) : meta.homedir,
|
||||
parentAgentId: meta.parentAgentId ?? null,
|
||||
homedir: join(sessionDir, 'agents', id),
|
||||
wireExists: readable,
|
||||
wireRecordCount: info.count,
|
||||
wireProtocolVersion: info.protocolVersion,
|
||||
|
|
@ -360,7 +357,8 @@ async function scanWire(path: string): Promise<{ count: number; protocolVersion:
|
|||
return { count, protocolVersion };
|
||||
}
|
||||
|
||||
function parseTs(input: string | undefined): number {
|
||||
function parseTs(input: string | number | undefined): number {
|
||||
if (typeof input === 'number') return Number.isFinite(input) ? input : 0;
|
||||
if (!input) return 0;
|
||||
const n = Date.parse(input);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
|
|
|
|||
13
apps/vis/server/test/fixtures/build.ts
vendored
13
apps/vis/server/test/fixtures/build.ts
vendored
|
|
@ -1,9 +1,8 @@
|
|||
import { cp, mkdir, readFile, writeFile, rm } from 'node:fs/promises';
|
||||
import { cp, mkdir, writeFile, rm } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
/** Copy a fixture session into a temp dir, rewriting state.json.agents.*.homedir
|
||||
* to the real path so wire-reader / agent-tree can resolve them. */
|
||||
/** Copy a fixture session into a temporary KIMI_CODE_HOME. */
|
||||
export async function buildSessionFixture(name: string): Promise<{
|
||||
home: string;
|
||||
sessionDir: string;
|
||||
|
|
@ -16,14 +15,6 @@ export async function buildSessionFixture(name: string): Promise<{
|
|||
await mkdir(sessionsDir, { recursive: true });
|
||||
await cp(src, sessionDir, { recursive: true });
|
||||
|
||||
// Rewrite homedir placeholders.
|
||||
const statePath = join(sessionDir, 'state.json');
|
||||
const state = JSON.parse(await readFile(statePath, 'utf8'));
|
||||
for (const id of Object.keys(state.agents)) {
|
||||
state.agents[id].homedir = join(sessionDir, 'agents', id);
|
||||
}
|
||||
await writeFile(statePath, JSON.stringify(state, null, 2));
|
||||
|
||||
// Write session_index.jsonl.
|
||||
await writeFile(
|
||||
join(home, 'session_index.jsonl'),
|
||||
|
|
|
|||
|
|
@ -234,6 +234,53 @@ describe('session-store', () => {
|
|||
expect(sub.parentAgentId).toBe('main');
|
||||
});
|
||||
|
||||
it('ignores persisted agent homedirs and uses the standard paths', async () => {
|
||||
const { home, sessionDir, cleanup: c } = await buildSessionFixture('sample-main');
|
||||
cleanup = c;
|
||||
const { readFile, writeFile } = await import('node:fs/promises');
|
||||
const { join } = await import('node:path');
|
||||
const statePath = join(sessionDir, 'state.json');
|
||||
const state = JSON.parse(await readFile(statePath, 'utf8'));
|
||||
delete state.agents.main.parentAgentId;
|
||||
await writeFile(statePath, JSON.stringify(state));
|
||||
|
||||
const detail = await readSessionDetail(home, 'session_fixture');
|
||||
|
||||
expect(
|
||||
detail!.agents
|
||||
.map(({ agentId, homedir, parentAgentId }) => ({ agentId, homedir, parentAgentId }))
|
||||
.toSorted((a, b) => a.agentId.localeCompare(b.agentId)),
|
||||
).toEqual([
|
||||
{
|
||||
agentId: 'agent-0',
|
||||
homedir: join(sessionDir, 'agents', 'agent-0'),
|
||||
parentAgentId: 'main',
|
||||
},
|
||||
{
|
||||
agentId: 'main',
|
||||
homedir: join(sessionDir, 'agents', 'main'),
|
||||
parentAgentId: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads v2 epoch millisecond timestamps', async () => {
|
||||
const { home, sessionDir, cleanup: c } = await buildSessionFixture('sample-main');
|
||||
cleanup = c;
|
||||
const { readFile, writeFile } = await import('node:fs/promises');
|
||||
const { join } = await import('node:path');
|
||||
const statePath = join(sessionDir, 'state.json');
|
||||
const state = JSON.parse(await readFile(statePath, 'utf8'));
|
||||
state.createdAt = 1_784_012_345_678;
|
||||
state.updatedAt = 1_784_023_456_789;
|
||||
await writeFile(statePath, JSON.stringify(state));
|
||||
|
||||
const [summary] = await listSessions(home);
|
||||
|
||||
expect(summary!.createdAt).toBe(state.createdAt);
|
||||
expect(summary!.updatedAt).toBe(state.updatedAt);
|
||||
});
|
||||
|
||||
it('surfaces swarmItem from state.json onto AgentInfo (null when absent)', async () => {
|
||||
const { home, sessionDir, cleanup: c } = await buildSessionFixture('sample-main');
|
||||
cleanup = c;
|
||||
|
|
|
|||
|
|
@ -94,10 +94,9 @@ const DOMAIN_LAYER = new Map([
|
|||
['os/backends', 6],
|
||||
// L2 — data & cross-cutting capabilities
|
||||
['records', 2],
|
||||
['wireRecord', 2],
|
||||
// `wire` is the scope-agnostic Model/Op/Signal state-machine layer: it
|
||||
// consumes `persistence/interface` (L1) and is consumed by the scope tiers,
|
||||
// so it sits in L2 beside the other data/cross-cutting layers.
|
||||
// `wire` owns the Agent-scoped replayable-state aggregate plus its pure
|
||||
// Model/Op/record/migration language. It consumes only L1 infrastructure
|
||||
// and same-layer blob storage, and is consumed by the scope tiers.
|
||||
['wire', 2],
|
||||
['blob', 2],
|
||||
['file', 2],
|
||||
|
|
@ -316,7 +315,6 @@ const ALLOWED_EXCEPTIONS = new Set([
|
|||
'cron>agentLifecycle',
|
||||
'cron>sessionContext',
|
||||
'todo>agentLifecycle',
|
||||
'wireRecord>hooks',
|
||||
// L3/L4 type-sharing: tool contract + execution hook contexts now live in
|
||||
// `tool`; the remaining upward import is a `loop` error/event helper.
|
||||
'contextMemory>agentTask',
|
||||
|
|
@ -345,9 +343,6 @@ const ALLOWED_EXCEPTIONS = new Set([
|
|||
'btw>agentLifecycle',
|
||||
'toolExecutor>loop',
|
||||
'userTool>profile',
|
||||
'wireRecord>contextMemory',
|
||||
'wireRecord>loop',
|
||||
'wireRecord>tool',
|
||||
'hostFolderBrowser>os/backends',
|
||||
'filestore>persistence/backends',
|
||||
'process>os/backends',
|
||||
|
|
|
|||
|
|
@ -2,15 +2,16 @@
|
|||
* `activity` domain (L4) — Agent / Session activity kernel contracts.
|
||||
*
|
||||
* Defines the authoritative activity state machines shared by the Agent and
|
||||
* Session scopes. `IAgentActivityService` is the Agent-scope lane machine: it
|
||||
* Session scopes. `IAgentActivityService` is the Agent-scope activity machine: it
|
||||
* owns turn admission (`begin`/`tryBegin`), cancellation, background-activity
|
||||
* registration and disposal settlement, and is the sole dispatcher of the
|
||||
* `activityLane` wire Model (`activityOps`). `ISessionActivityKernel` is the
|
||||
* registration, disposal settlement, and the live activity projection emitted
|
||||
* as `agent.activity.updated`. `ISessionActivityKernel` is the
|
||||
* Session-scope lifecycle lane + admission table that the Agent kernel consults
|
||||
* synchronously on every `begin` (child-injects-parent), so admission stays
|
||||
* atomic inside a single event-loop turn. The `ActivityLease` returned by
|
||||
* `begin` carries the turn's `AbortSignal` and is the only path back to `idle`
|
||||
* (`lease.end`). Multi-scope domain: `IAgentActivityService` bound at Agent
|
||||
* `begin` carries the turn's `AbortSignal`; `lease.end` releases the active
|
||||
* turn independently of the Agent lifecycle. Multi-scope domain:
|
||||
* `IAgentActivityService` bound at Agent
|
||||
* scope, `ISessionActivityKernel` bound at Session scope.
|
||||
*/
|
||||
|
||||
|
|
@ -19,7 +20,7 @@ import type { IDisposable } from '#/_base/di/lifecycle';
|
|||
import type { PromptOrigin } from '#/agent/contextMemory/types';
|
||||
import type { TurnEndReason } from '@moonshot-ai/protocol';
|
||||
|
||||
export type AgentLane = 'initializing' | 'idle' | 'turn' | 'disposing' | 'disposed';
|
||||
export type AgentLifecycleState = 'initializing' | 'ready' | 'disposing' | 'disposed';
|
||||
|
||||
export interface BeginOptions {
|
||||
readonly origin?: PromptOrigin;
|
||||
|
|
@ -45,7 +46,7 @@ export interface BackgroundActivityRef {
|
|||
export interface IAgentActivityService {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
||||
lane(): AgentLane;
|
||||
isIdle(): boolean;
|
||||
|
||||
begin(kind: 'turn', opts?: BeginOptions): ActivityLease;
|
||||
|
||||
|
|
@ -144,9 +145,15 @@ export interface ActivityLastTurnState {
|
|||
readonly at: number;
|
||||
}
|
||||
|
||||
export interface AgentActivitySnapshot {
|
||||
readonly lane: AgentLane;
|
||||
export interface AgentActivityState {
|
||||
readonly lifecycle: AgentLifecycleState;
|
||||
readonly turn?: ActivityTurnState;
|
||||
readonly lastTurn?: ActivityLastTurnState;
|
||||
readonly background: readonly BackgroundActivityRef[];
|
||||
}
|
||||
|
||||
declare module '#/app/event/eventBus' {
|
||||
interface DomainEventMap {
|
||||
'agent.activity.updated': AgentActivityState & { readonly type: 'agent.activity.updated' };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,87 +1,23 @@
|
|||
/**
|
||||
* `activity` domain (L4) — wire Model (`LaneModel`) and the `activity.set_lane`
|
||||
* Op that holds the Agent activity lane.
|
||||
* `activity` domain (L4) — Session activity lane wire state.
|
||||
*
|
||||
* The lane is a live-only Model (`persist: false`): nothing is persisted or
|
||||
* replayed, so a resumed agent starts back at `idle`. The Agent kernel
|
||||
* (`agentActivityService`) is the sole dispatcher of `setLane`; `apply` returns
|
||||
* the SAME reference when the incoming state is unchanged under `laneEqual`
|
||||
* (which ignores the `since` / `at` timestamps) so redundant dispatches do not
|
||||
* flood subscribers. The Op derives no event here — the outward snapshot event
|
||||
* is emitted by the projector so there is a single event source (PR5). The
|
||||
* initial lane is `idle` (fresh agents accept turns immediately); the
|
||||
* half-replay window is gated at the Session kernel (`restoring`), not here.
|
||||
* Consumed by the Agent-scope `agentActivityService` and (PR5) the projector.
|
||||
* The Session kernel projects its live lane and active lease count into the
|
||||
* non-persisted `SessionLaneModel`. Agent activity state is owned directly by
|
||||
* `IAgentActivityService` and is not duplicated in wire state.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import { defineModel } from '#/wire/model';
|
||||
import type { PromptOrigin } from '#/agent/contextMemory/types';
|
||||
|
||||
import type { AgentLane, BackgroundActivityRef, SessionLane } from './activity';
|
||||
|
||||
export interface LaneTurnState {
|
||||
readonly turnId: number;
|
||||
readonly origin: PromptOrigin;
|
||||
readonly ending: boolean;
|
||||
readonly endingReason?: 'aborted' | 'max_steps' | 'error';
|
||||
readonly since: number;
|
||||
}
|
||||
|
||||
export interface LaneLastTurnState {
|
||||
readonly turnId: number;
|
||||
readonly reason: 'completed' | 'cancelled' | 'failed';
|
||||
readonly at: number;
|
||||
}
|
||||
|
||||
export interface LaneModelState {
|
||||
readonly lane: AgentLane;
|
||||
readonly turn?: LaneTurnState;
|
||||
readonly lastTurn?: LaneLastTurnState;
|
||||
readonly background: readonly BackgroundActivityRef[];
|
||||
}
|
||||
|
||||
export const LaneModel = defineModel<LaneModelState>('activityLane', () => ({
|
||||
lane: 'idle',
|
||||
background: [],
|
||||
}));
|
||||
import type { SessionLane } from './activity';
|
||||
|
||||
declare module '#/wire/types' {
|
||||
interface TransientOpMap {
|
||||
'activity.set_lane': typeof setLane;
|
||||
'activity.set_session_lane': typeof setSessionLane;
|
||||
}
|
||||
}
|
||||
|
||||
export const setLane = LaneModel.defineOp('activity.set_lane', {
|
||||
schema: z.object({ next: z.custom<LaneModelState>() }),
|
||||
persist: false,
|
||||
apply: (s, p) => (laneEqual(s, p.next) ? s : p.next),
|
||||
});
|
||||
|
||||
export function laneEqual(a: LaneModelState, b: LaneModelState): boolean {
|
||||
if (a.lane !== b.lane) return false;
|
||||
if (a.background.length !== b.background.length) return false;
|
||||
if ((a.turn === undefined) !== (b.turn === undefined)) return false;
|
||||
if (a.turn !== undefined && b.turn !== undefined) {
|
||||
if (
|
||||
a.turn.turnId !== b.turn.turnId ||
|
||||
a.turn.ending !== b.turn.ending ||
|
||||
a.turn.endingReason !== b.turn.endingReason
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if ((a.lastTurn === undefined) !== (b.lastTurn === undefined)) return false;
|
||||
if (a.lastTurn !== undefined && b.lastTurn !== undefined) {
|
||||
if (a.lastTurn.turnId !== b.lastTurn.turnId || a.lastTurn.reason !== b.lastTurn.reason) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export interface SessionLaneModelState {
|
||||
readonly lane: SessionLane;
|
||||
readonly activeLeases: number;
|
||||
|
|
|
|||
|
|
@ -1,15 +1,16 @@
|
|||
/**
|
||||
* `activity` domain (L4) — `IAgentActivityService` implementation.
|
||||
*
|
||||
* Owns the Agent activity lane (`idle ⇄ turn(active|ending)`, plus `disposing`
|
||||
* / `disposed`) and is the sole dispatcher of the `activityLane` wire Model
|
||||
* (`activity.set_lane`). `begin('turn')` atomically consults the Session kernel
|
||||
* Owns the Agent lifecycle (`initializing → ready → disposing → disposed`) and
|
||||
* its independent active turn, then projects lifecycle, turn, stream, retry,
|
||||
* approval, tool-call and background state onto `agent.activity.updated`.
|
||||
* `begin('turn')` atomically consults the Session kernel
|
||||
* (`ISessionActivityKernel.admitTurn`, child-injects-parent), reads the next
|
||||
* turn id from the `turn` `TurnModel`, enters the turn lane and returns an
|
||||
* turn id from the `turn` `TurnModel`, records the active turn and returns an
|
||||
* `ActivityLease`; the lease's `AbortSignal` is the only cancellation channel,
|
||||
* and `lease.end()` is the only path back to `idle`. Background activities
|
||||
* (`registerBackground`) are tracked so disposal can abort and await them. The
|
||||
* lane starts at `initializing` and is driven to `idle` by `markReady()` once
|
||||
* lifecycle starts at `initializing` and is driven to `ready` by `markReady()` once
|
||||
* the agent bootstrap (`agentLifecycle.create`) finishes; until then `begin`
|
||||
* rejects with `activity.initializing`. The half-replay window on resume is
|
||||
* gated by the Session kernel (`restoring`). Bound at Agent scope.
|
||||
|
|
@ -19,25 +20,33 @@ import { Disposable, type IDisposable } from '#/_base/di/lifecycle';
|
|||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import { userCancellationReason } from '#/_base/utils/abort';
|
||||
import { IEventBus } from '#/app/event/eventBus';
|
||||
import { ErrorCodes, Error2 } from '#/errors';
|
||||
import { USER_PROMPT_ORIGIN } from '#/agent/contextMemory/types';
|
||||
import type { PromptOrigin } from '#/agent/contextMemory/types';
|
||||
import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext';
|
||||
import { TurnModel } from '#/agent/loop/turnOps';
|
||||
import { IAgentWireService } from '#/wire/tokens';
|
||||
import type { IWireService } from '#/wire/wireService';
|
||||
import { IWireService } from '#/wire/wire';
|
||||
|
||||
import type {
|
||||
ActivityRetryState,
|
||||
ActivityLease,
|
||||
AgentLane,
|
||||
ActivityLastTurnState,
|
||||
ActivityTurnState,
|
||||
AgentActivityState,
|
||||
AgentLifecycleState,
|
||||
ApprovalRef,
|
||||
BackgroundActivityRef,
|
||||
BeginOptions,
|
||||
ToolCallRef,
|
||||
TurnPhase,
|
||||
} from './activity';
|
||||
import { IAgentActivityService, ISessionActivityKernel } from './activity';
|
||||
import { type LaneLastTurnState, setLane } from './activityOps';
|
||||
|
||||
let nextBackgroundId = 0;
|
||||
|
||||
type ActivityEndingReason = NonNullable<ActivityTurnState['endingReason']>;
|
||||
|
||||
interface BackgroundEntry {
|
||||
readonly ref: BackgroundActivityRef;
|
||||
readonly controller: AbortController;
|
||||
|
|
@ -51,7 +60,7 @@ class LeaseImpl implements ActivityLease {
|
|||
private readonly controller = new AbortController();
|
||||
private _ending = false;
|
||||
private _ended = false;
|
||||
private _endingReason: 'aborted' | 'max_steps' | 'error' | undefined;
|
||||
private _endingReason: ActivityEndingReason | undefined;
|
||||
registration: IDisposable = Disposable.None;
|
||||
|
||||
constructor(
|
||||
|
|
@ -72,7 +81,7 @@ class LeaseImpl implements ActivityLease {
|
|||
return this._ending;
|
||||
}
|
||||
|
||||
get endingReason(): 'aborted' | 'max_steps' | 'error' | undefined {
|
||||
get endingReason(): ActivityEndingReason | undefined {
|
||||
return this._endingReason;
|
||||
}
|
||||
|
||||
|
|
@ -83,6 +92,12 @@ class LeaseImpl implements ActivityLease {
|
|||
this.controller.abort(reason ?? userCancellationReason());
|
||||
}
|
||||
|
||||
markInterrupted(reason: ActivityEndingReason): void {
|
||||
if (this._ending || this._ended) return;
|
||||
this._ending = true;
|
||||
this._endingReason = reason;
|
||||
}
|
||||
|
||||
end(outcome: 'completed' | 'cancelled' | 'failed', detail?: { error?: unknown }): void {
|
||||
if (this._ended) return;
|
||||
this._ended = true;
|
||||
|
|
@ -96,44 +111,110 @@ class LeaseImpl implements ActivityLease {
|
|||
export class AgentActivityService extends Disposable implements IAgentActivityService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
private _lane: AgentLane = 'initializing';
|
||||
private _lifecycle: AgentLifecycleState = 'initializing';
|
||||
private _step = 0;
|
||||
private _phase: TurnPhase = 'running';
|
||||
private _stream: 'assistant' | 'thinking' | 'tool_call' | undefined;
|
||||
private _retry: ActivityRetryState | undefined;
|
||||
private _currentState: AgentActivityState = { lifecycle: 'initializing', background: [] };
|
||||
private activeLease: LeaseImpl | undefined;
|
||||
private lastTurn: LaneLastTurnState | undefined;
|
||||
private lastTurn: ActivityLastTurnState | undefined;
|
||||
private readonly background = new Map<string, BackgroundEntry>();
|
||||
private readonly pendingApprovals = new Map<string, ApprovalRef>();
|
||||
private readonly activeToolCalls = new Map<string, ToolCallRef>();
|
||||
private readonly settleWaiters: Array<() => void> = [];
|
||||
|
||||
constructor(
|
||||
@IAgentWireService private readonly wire: IWireService,
|
||||
@IWireService private readonly wire: IWireService,
|
||||
@ISessionActivityKernel private readonly sessionKernel: ISessionActivityKernel,
|
||||
@IAgentScopeContext private readonly scopeContext: IAgentScopeContext,
|
||||
@IEventBus private readonly eventBus: IEventBus,
|
||||
) {
|
||||
super();
|
||||
this._register(
|
||||
this.eventBus.subscribe('turn.step.started', (e) => this.onStepStarted(e.step)),
|
||||
);
|
||||
this._register(
|
||||
this.eventBus.subscribe('assistant.delta', () => this.onDelta('assistant')),
|
||||
);
|
||||
this._register(
|
||||
this.eventBus.subscribe('thinking.delta', () => this.onDelta('thinking')),
|
||||
);
|
||||
this._register(
|
||||
this.eventBus.subscribe('tool.call.delta', () => this.onDelta('tool_call')),
|
||||
);
|
||||
this._register(
|
||||
this.eventBus.subscribe('tool.call.started', (e) =>
|
||||
this.onToolCallStarted(e.toolCallId, e.name),
|
||||
),
|
||||
);
|
||||
this._register(
|
||||
this.eventBus.subscribe('tool.result', (e) => this.onToolResult(e.toolCallId)),
|
||||
);
|
||||
this._register(
|
||||
this.eventBus.subscribe('turn.step.retrying', (e) => {
|
||||
this._phase = 'retrying';
|
||||
this._stream = undefined;
|
||||
this._retry = {
|
||||
failedAttempt: e.failedAttempt,
|
||||
nextAttempt: e.nextAttempt,
|
||||
maxAttempts: e.maxAttempts,
|
||||
delayMs: e.delayMs,
|
||||
errorName: e.errorName,
|
||||
statusCode: e.statusCode,
|
||||
};
|
||||
this.publishActivity();
|
||||
}),
|
||||
);
|
||||
this._register(
|
||||
this.eventBus.subscribe('turn.step.completed', () => {
|
||||
this.resetStepState();
|
||||
this.publishActivity();
|
||||
}),
|
||||
);
|
||||
this._register(
|
||||
this.eventBus.subscribe('turn.step.interrupted', (e) =>
|
||||
this.onStepInterrupted(e.turnId, e.reason),
|
||||
),
|
||||
);
|
||||
this._register(this.eventBus.subscribe('turn.ended', () => this.resetTurnState()));
|
||||
this._register(
|
||||
this.eventBus.subscribe('permission.approval.requested', (e) =>
|
||||
this.onApprovalRequested(e.toolCallId),
|
||||
),
|
||||
);
|
||||
this._register(
|
||||
this.eventBus.subscribe('permission.approval.resolved', (e) =>
|
||||
this.onApprovalResolved(e.toolCallId),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
lane(): AgentLane {
|
||||
return this._lane;
|
||||
isIdle(): boolean {
|
||||
return this._lifecycle === 'ready' && this.activeLease === undefined;
|
||||
}
|
||||
|
||||
begin(kind: 'turn', opts?: BeginOptions): ActivityLease {
|
||||
if (kind !== 'turn') {
|
||||
throw new Error2(ErrorCodes.NOT_IMPLEMENTED, `Unsupported activity kind: ${String(kind)}`);
|
||||
}
|
||||
switch (this._lane) {
|
||||
case 'turn':
|
||||
throw new Error2(
|
||||
ErrorCodes.ACTIVITY_AGENT_BUSY,
|
||||
`Cannot begin a new turn while turn ${this.activeLease?.turnId ?? '?'} is active`,
|
||||
{ details: { turnId: this.activeLease?.turnId } },
|
||||
);
|
||||
switch (this._lifecycle) {
|
||||
case 'disposing':
|
||||
throw new Error2(ErrorCodes.ACTIVITY_DISPOSING, 'Agent is disposing');
|
||||
case 'disposed':
|
||||
throw new Error2(ErrorCodes.ACTIVITY_DISPOSED, 'Agent is disposed');
|
||||
case 'initializing':
|
||||
throw new Error2(ErrorCodes.ACTIVITY_INITIALIZING, 'Agent is still restoring');
|
||||
case 'idle':
|
||||
case 'ready':
|
||||
break;
|
||||
}
|
||||
if (this.activeLease !== undefined) {
|
||||
throw new Error2(
|
||||
ErrorCodes.ACTIVITY_AGENT_BUSY,
|
||||
`Cannot begin a new turn while turn ${this.activeLease.turnId} is active`,
|
||||
{ details: { turnId: this.activeLease.turnId } },
|
||||
);
|
||||
}
|
||||
|
||||
const turnId = opts?.turnId ?? this.wire.getModel(TurnModel).nextTurnId;
|
||||
const origin = opts?.origin ?? USER_PROMPT_ORIGIN;
|
||||
|
|
@ -141,8 +222,7 @@ export class AgentActivityService extends Disposable implements IAgentActivitySe
|
|||
lease.registration = this.sessionKernel.admitTurn(this.scopeContext.agentId, lease);
|
||||
|
||||
this.activeLease = lease;
|
||||
this._lane = 'turn';
|
||||
this.publishLane();
|
||||
this.publishActivity();
|
||||
return lease;
|
||||
}
|
||||
|
||||
|
|
@ -156,9 +236,9 @@ export class AgentActivityService extends Disposable implements IAgentActivitySe
|
|||
}
|
||||
|
||||
markReady(): void {
|
||||
if (this._lane !== 'initializing') return;
|
||||
this._lane = 'idle';
|
||||
this.publishLane();
|
||||
if (this._lifecycle !== 'initializing') return;
|
||||
this._lifecycle = 'ready';
|
||||
this.publishActivity();
|
||||
}
|
||||
|
||||
cancel(reason?: unknown): boolean {
|
||||
|
|
@ -166,7 +246,7 @@ export class AgentActivityService extends Disposable implements IAgentActivitySe
|
|||
if (lease === undefined) return false;
|
||||
if (lease.ending) return true;
|
||||
lease.markEnding(reason);
|
||||
this.publishLane();
|
||||
this.publishActivity();
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -179,10 +259,10 @@ export class AgentActivityService extends Disposable implements IAgentActivitySe
|
|||
signal: controller.signal,
|
||||
};
|
||||
this.background.set(id, { ref, controller });
|
||||
this.publishLane();
|
||||
this.publishActivity();
|
||||
const dispose = (): void => {
|
||||
if (this.background.delete(id)) {
|
||||
this.publishLane();
|
||||
this.publishActivity();
|
||||
}
|
||||
this.maybeSettle();
|
||||
};
|
||||
|
|
@ -190,20 +270,20 @@ export class AgentActivityService extends Disposable implements IAgentActivitySe
|
|||
}
|
||||
|
||||
beginDisposal(): void {
|
||||
if (this._lane === 'disposing' || this._lane === 'disposed') return;
|
||||
this._lane = 'disposing';
|
||||
if (this._lifecycle === 'disposing' || this._lifecycle === 'disposed') return;
|
||||
this._lifecycle = 'disposing';
|
||||
this.activeLease?.markEnding();
|
||||
for (const entry of this.background.values()) {
|
||||
entry.controller.abort();
|
||||
}
|
||||
this.publishLane();
|
||||
this.publishActivity();
|
||||
this.maybeSettle();
|
||||
}
|
||||
|
||||
settled(): Promise<void> {
|
||||
if (this._lane === 'disposed') return Promise.resolve();
|
||||
if (this._lifecycle === 'disposed') return Promise.resolve();
|
||||
if (
|
||||
this._lane !== 'disposing' &&
|
||||
this._lifecycle !== 'disposing' &&
|
||||
this.activeLease === undefined &&
|
||||
this.background.size === 0
|
||||
) {
|
||||
|
|
@ -224,48 +304,147 @@ export class AgentActivityService extends Disposable implements IAgentActivitySe
|
|||
lease.registration.dispose();
|
||||
lease.registration = Disposable.None;
|
||||
this.lastTurn = { turnId: lease.turnId, reason: outcome, at: Date.now() };
|
||||
if (this._lane === 'disposing') {
|
||||
if (this._lifecycle === 'disposing') {
|
||||
this.maybeSettle();
|
||||
return;
|
||||
}
|
||||
this._lane = 'idle';
|
||||
this.publishLane();
|
||||
this.publishActivity();
|
||||
this.maybeSettle();
|
||||
}
|
||||
|
||||
private maybeSettle(): void {
|
||||
if (this.activeLease !== undefined || this.background.size > 0) return;
|
||||
if (this._lane === 'disposing') {
|
||||
this._lane = 'disposed';
|
||||
this.publishLane();
|
||||
if (this._lifecycle === 'disposing') {
|
||||
this._lifecycle = 'disposed';
|
||||
this.publishActivity();
|
||||
}
|
||||
if (this.settleWaiters.length === 0) return;
|
||||
const waiters = this.settleWaiters.splice(0);
|
||||
for (const resolve of waiters) resolve();
|
||||
}
|
||||
|
||||
private publishLane(): void {
|
||||
const lease = this.activeLease;
|
||||
this.wire.dispatch(
|
||||
setLane({
|
||||
next: {
|
||||
lane: this._lane,
|
||||
turn:
|
||||
lease === undefined
|
||||
? undefined
|
||||
: {
|
||||
turnId: lease.turnId,
|
||||
origin: lease.origin,
|
||||
ending: lease.ending,
|
||||
endingReason: lease.endingReason,
|
||||
since: lease.since,
|
||||
},
|
||||
lastTurn: this.lastTurn,
|
||||
background: [...this.background.values()].map((entry) => entry.ref),
|
||||
},
|
||||
}),
|
||||
);
|
||||
private onStepStarted(step: number): void {
|
||||
this._step = step;
|
||||
this.resetStepState();
|
||||
this.publishActivity();
|
||||
}
|
||||
|
||||
private onStepInterrupted(turnId: number, reason: string): void {
|
||||
if (reason !== 'aborted' && reason !== 'max_steps' && reason !== 'error') return;
|
||||
const lease = this.activeLease;
|
||||
if (lease === undefined || lease.turnId !== turnId) return;
|
||||
lease.markInterrupted(reason);
|
||||
this.publishActivity();
|
||||
}
|
||||
|
||||
private onDelta(stream: 'assistant' | 'thinking' | 'tool_call'): void {
|
||||
this._phase = 'streaming';
|
||||
this._stream = stream;
|
||||
this._retry = undefined;
|
||||
this.publishActivity();
|
||||
}
|
||||
|
||||
private onToolCallStarted(toolCallId: string, name: string): void {
|
||||
this._phase = 'tool_call';
|
||||
this._stream = undefined;
|
||||
this._retry = undefined;
|
||||
this.activeToolCalls.set(toolCallId, { toolCallId, name, since: Date.now() });
|
||||
this.publishActivity();
|
||||
}
|
||||
|
||||
private onToolResult(toolCallId: string): void {
|
||||
this.activeToolCalls.delete(toolCallId);
|
||||
this._phase = this.activeToolCalls.size === 0 ? 'running' : 'tool_call';
|
||||
this._stream = undefined;
|
||||
this._retry = undefined;
|
||||
this.publishActivity();
|
||||
}
|
||||
|
||||
private resetTurnState(): void {
|
||||
this._step = 0;
|
||||
this.resetStepState();
|
||||
this.pendingApprovals.clear();
|
||||
this.activeToolCalls.clear();
|
||||
}
|
||||
|
||||
private onApprovalRequested(toolCallId: string): void {
|
||||
this.pendingApprovals.set(toolCallId, {
|
||||
approvalId: toolCallId,
|
||||
toolCallId,
|
||||
since: Date.now(),
|
||||
});
|
||||
this.publishActivity();
|
||||
}
|
||||
|
||||
private onApprovalResolved(toolCallId: string): void {
|
||||
this.pendingApprovals.delete(toolCallId);
|
||||
this.publishActivity();
|
||||
}
|
||||
|
||||
private resetStepState(): void {
|
||||
this._phase = 'running';
|
||||
this._stream = undefined;
|
||||
this._retry = undefined;
|
||||
}
|
||||
|
||||
private publishActivity(): void {
|
||||
const lease = this.activeLease;
|
||||
const turn =
|
||||
lease === undefined
|
||||
? undefined
|
||||
: {
|
||||
turnId: lease.turnId,
|
||||
origin: lease.origin,
|
||||
phase: this._phase,
|
||||
stream: this._stream,
|
||||
step: this._step,
|
||||
ending: lease.ending,
|
||||
endingReason: lease.endingReason,
|
||||
retry: this._retry,
|
||||
pendingApprovals: [...this.pendingApprovals.values()],
|
||||
activeToolCalls: [...this.activeToolCalls.values()],
|
||||
since: lease.since,
|
||||
};
|
||||
const state: AgentActivityState = {
|
||||
lifecycle: this._lifecycle,
|
||||
turn,
|
||||
lastTurn: this.lastTurn,
|
||||
background: [...this.background.values()].map((entry) => entry.ref),
|
||||
};
|
||||
if (activityEqual(this._currentState, state)) return;
|
||||
this._currentState = state;
|
||||
this.eventBus.publish({ type: 'agent.activity.updated', ...state });
|
||||
}
|
||||
}
|
||||
|
||||
function activityEqual(a: AgentActivityState, b: AgentActivityState): boolean {
|
||||
if (a.lifecycle !== b.lifecycle) return false;
|
||||
if (a.background.length !== b.background.length) return false;
|
||||
if ((a.turn === undefined) !== (b.turn === undefined)) return false;
|
||||
if (a.turn !== undefined && b.turn !== undefined) {
|
||||
const ta = a.turn;
|
||||
const tb = b.turn;
|
||||
if (
|
||||
ta.turnId !== tb.turnId ||
|
||||
ta.phase !== tb.phase ||
|
||||
ta.stream !== tb.stream ||
|
||||
ta.step !== tb.step ||
|
||||
ta.ending !== tb.ending ||
|
||||
ta.endingReason !== tb.endingReason ||
|
||||
ta.pendingApprovals.length !== tb.pendingApprovals.length ||
|
||||
ta.activeToolCalls.length !== tb.activeToolCalls.length
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (ta.retry?.nextAttempt !== tb.retry?.nextAttempt) return false;
|
||||
}
|
||||
if ((a.lastTurn === undefined) !== (b.lastTurn === undefined)) return false;
|
||||
if (a.lastTurn !== undefined && b.lastTurn !== undefined) {
|
||||
if (a.lastTurn.turnId !== b.lastTurn.turnId || a.lastTurn.reason !== b.lastTurn.reason) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
registerScopedService(
|
||||
|
|
|
|||
|
|
@ -15,8 +15,7 @@ import { IAgentLoopService } from '#/agent/loop/loop';
|
|||
import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder';
|
||||
import { IEventBus } from '#/app/event/eventBus';
|
||||
import type { ContextMessage } from '#/agent/contextMemory/types';
|
||||
import { IAgentWireService } from '#/wire/tokens';
|
||||
import type { IWireService } from '#/wire/wireService';
|
||||
import { IWireService } from '#/wire/wire';
|
||||
import {
|
||||
IAgentContextInjectorService,
|
||||
type ContextInjectionProvider,
|
||||
|
|
@ -38,7 +37,7 @@ export class AgentContextInjectorService extends Disposable implements IAgentCon
|
|||
@IAgentLoopService loopService: IAgentLoopService,
|
||||
@IAgentSystemReminderService private readonly reminders: IAgentSystemReminderService,
|
||||
@IEventBus private readonly eventBus: IEventBus,
|
||||
@IAgentWireService wire: IWireService,
|
||||
@IWireService wire: IWireService,
|
||||
) {
|
||||
super();
|
||||
this._register(
|
||||
|
|
@ -52,12 +51,17 @@ export class AgentContextInjectorService extends Disposable implements IAgentCon
|
|||
this.isNewTurn = true;
|
||||
}),
|
||||
);
|
||||
this._register(this.eventBus.subscribe('context.spliced', (e) => {
|
||||
this.handleSplice(e);
|
||||
}));
|
||||
this._register(wire.onRestored(() => {
|
||||
this.resyncPositions();
|
||||
}));
|
||||
this._register(
|
||||
this.eventBus.subscribe('context.spliced', (e) => {
|
||||
this.handleSplice(e);
|
||||
}),
|
||||
);
|
||||
this._register(
|
||||
wire.hooks.onDidRestore.register('context-injector', async (_ctx, next) => {
|
||||
this.resyncPositions();
|
||||
await next();
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
register(
|
||||
|
|
|
|||
|
|
@ -10,11 +10,11 @@
|
|||
* changes the measured prefix — `clear` resets it, `applyCompaction` adopts
|
||||
* `tokensAfter`, and `undo` rebases it (to an estimate when the measured
|
||||
* aggregate is truncated); `append` leaves the measured prefix untouched since
|
||||
* new messages are the unmeasured tail (see `contextSizeService`). Every
|
||||
* mutation still fires `onSpliced` from the live path only (replay rebuilds
|
||||
* the Model silently and never invokes these methods), so existing subscribers
|
||||
* (context-injector, task-notification) observe the same
|
||||
* splice-shaped change events regardless of which Op was persisted. Messages
|
||||
* new messages are the unmeasured tail (see `contextSizeService`).
|
||||
* Splice-shaped mutations publish `context.spliced` from the live path only
|
||||
* (replay rebuilds the Model silently and never invokes these methods), so
|
||||
* existing subscribers observe the same change regardless of which Op was
|
||||
* persisted. Messages
|
||||
* are persisted without local ids — the on-disk record matches v1's field set
|
||||
* and public message ids are derived from the transcript index. Blob
|
||||
* dehydrate/rehydrate is declared on `ContextModel.blobs`. Bound at
|
||||
|
|
@ -27,9 +27,8 @@ import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
|||
import { estimateTokensForMessages } from '#/_base/utils/tokens';
|
||||
import { IEventBus } from '#/app/event/eventBus';
|
||||
import { ContextSizeModel, contextSizeMeasured } from '#/agent/contextSize/contextSizeOps';
|
||||
import { IAgentWireService } from '#/wire/tokens';
|
||||
import { IWireService } from '#/wire/wire';
|
||||
import type { Op } from '#/wire/op';
|
||||
import type { IWireService } from '#/wire/wireService';
|
||||
|
||||
import {
|
||||
IAgentContextMemoryService,
|
||||
|
|
@ -66,7 +65,7 @@ export class AgentContextMemoryService extends Disposable implements IAgentConte
|
|||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
constructor(
|
||||
@IAgentWireService private readonly wire: IWireService,
|
||||
@IWireService private readonly wire: IWireService,
|
||||
@IEventBus private readonly eventBus: IEventBus,
|
||||
) {
|
||||
super();
|
||||
|
|
@ -86,6 +85,7 @@ export class AgentContextMemoryService extends Disposable implements IAgentConte
|
|||
appendLoopEvent(event: LoopRecordedEvent): void {
|
||||
this.wire.dispatch(contextAppendLoopEvent({ event }));
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
const deleteCount = this.get().length;
|
||||
if (deleteCount === 0) return;
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ import { z } from 'zod';
|
|||
|
||||
import type { ContentPart } from '#/app/llmProtocol/message';
|
||||
import { defineModel, type PartsTransformer } from '#/wire/model';
|
||||
import type { PersistedRecord } from '#/wire/wireService';
|
||||
import type { WireRecord } from '#/wire/record';
|
||||
|
||||
import {
|
||||
buildContextCompactionShape,
|
||||
|
|
@ -70,9 +70,9 @@ async function dehydrateMessages(
|
|||
}
|
||||
|
||||
async function dehydrateRecord(
|
||||
record: PersistedRecord,
|
||||
record: WireRecord,
|
||||
transform: PartsTransformer,
|
||||
): Promise<PersistedRecord> {
|
||||
): Promise<WireRecord> {
|
||||
if (record.type === 'context.append_message') {
|
||||
const message = record['message'] as ContextMessage | undefined;
|
||||
if (message === undefined) return record;
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@
|
|||
*/
|
||||
|
||||
import { type ContentPart, type ToolCall } from '#/app/llmProtocol/message';
|
||||
import type { PersistedRecord } from '#/wire/wireService';
|
||||
import type { WireRecord } from '#/wire/record';
|
||||
|
||||
import {
|
||||
COMPACT_USER_MESSAGE_MAX_TOKENS,
|
||||
|
|
@ -49,6 +49,11 @@ export interface ContextTranscript {
|
|||
readonly foldedLength: number;
|
||||
}
|
||||
|
||||
export interface ContextTranscriptReducer {
|
||||
add(record: WireRecord): void;
|
||||
result(): ContextTranscript;
|
||||
}
|
||||
|
||||
interface MutableMessage {
|
||||
id?: string;
|
||||
role: ContextMessage['role'];
|
||||
|
|
@ -64,7 +69,13 @@ interface MutableEntry {
|
|||
time?: number;
|
||||
}
|
||||
|
||||
export function reduceContextTranscript(records: Iterable<PersistedRecord>): ContextTranscript {
|
||||
export function reduceContextTranscript(records: Iterable<WireRecord>): ContextTranscript {
|
||||
const reducer = createContextTranscriptReducer();
|
||||
for (const record of records) reducer.add(record);
|
||||
return reducer.result();
|
||||
}
|
||||
|
||||
export function createContextTranscriptReducer(): ContextTranscriptReducer {
|
||||
const transcript: MutableEntry[] = [];
|
||||
let foldedLength = 0;
|
||||
let clearFloor = 0;
|
||||
|
|
@ -176,7 +187,7 @@ export function reduceContextTranscript(records: Iterable<PersistedRecord>): Con
|
|||
resetOpenState();
|
||||
};
|
||||
|
||||
for (const record of records) {
|
||||
const add = (record: WireRecord): void => {
|
||||
switch (record.type) {
|
||||
case 'context.append_message': {
|
||||
const entry = toMutableEntry(record['message'] as ContextMessage, record.time);
|
||||
|
|
@ -212,12 +223,15 @@ export function reduceContextTranscript(records: Iterable<PersistedRecord>): Con
|
|||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
entries: transcript.map((e) => e.message),
|
||||
times: transcript.map((e) => e.time),
|
||||
foldedLength,
|
||||
add,
|
||||
result: () => ({
|
||||
entries: transcript.map((e) => e.message),
|
||||
times: transcript.map((e) => e.time),
|
||||
foldedLength,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -237,7 +251,7 @@ function toMutableEntry(message: ContextMessage, time: number | undefined): Muta
|
|||
}
|
||||
|
||||
function recoverFoldedLength(
|
||||
record: PersistedRecord,
|
||||
record: WireRecord,
|
||||
transcript: readonly MutableEntry[],
|
||||
clearFloor: number,
|
||||
foldedLength: number,
|
||||
|
|
@ -258,7 +272,7 @@ function recoverFoldedLength(
|
|||
return keptUserMessages.length + 1;
|
||||
}
|
||||
|
||||
function readCompactionSummaryText(record: PersistedRecord): string {
|
||||
function readCompactionSummaryText(record: WireRecord): string {
|
||||
const summary = record['summary'];
|
||||
if (typeof summary === 'string') return summary;
|
||||
const contextSummary = record['contextSummary'];
|
||||
|
|
@ -281,7 +295,7 @@ function textOfParts(content: readonly ContentPart[]): string {
|
|||
return text;
|
||||
}
|
||||
|
||||
function readNumber(record: PersistedRecord, key: string): number | undefined {
|
||||
function readNumber(record: WireRecord, key: string): number | undefined {
|
||||
const value = record[key];
|
||||
return typeof value === 'number' ? value : undefined;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
* the v2 live loop emits the same records (`LoopService` →
|
||||
* `ContextMemory.appendLoopEvent`), keeping the on-disk shape byte-compatible.
|
||||
* This fold turns them into assistant / tool messages — at live dispatch time
|
||||
* and again when `WireService.replay` restores a session. Without it, replay
|
||||
* and again when `WireService.restore` restores an Agent. Without it, restore
|
||||
* would skip those records (no Op is registered for the type) and the restored
|
||||
* `ContextModel` — and every consumer built on it (`/messages`, `/snapshot`,
|
||||
* live resume) — would show only the user prompts.
|
||||
|
|
|
|||
|
|
@ -26,8 +26,7 @@ import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'
|
|||
import type { ContextMessage } from '#/agent/contextMemory/types';
|
||||
import type { Message } from '#/app/llmProtocol/message';
|
||||
import type { TokenUsage } from '#/app/llmProtocol/usage';
|
||||
import { IAgentWireService } from '#/wire/tokens';
|
||||
import type { IWireService } from '#/wire/wireService';
|
||||
import { IWireService } from '#/wire/wire';
|
||||
|
||||
import { IAgentContextSizeService, type ContextSize } from './contextSize';
|
||||
import { ContextSizeModel, contextSizeMeasured } from './contextSizeOps';
|
||||
|
|
@ -39,7 +38,7 @@ export class AgentContextSizeService extends Disposable implements IAgentContext
|
|||
|
||||
constructor(
|
||||
@IAgentContextMemoryService private readonly context: IAgentContextMemoryService,
|
||||
@IAgentWireService private readonly wire: IWireService,
|
||||
@IWireService private readonly wire: IWireService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,15 +24,15 @@
|
|||
* the in-flight worker promise — stays OUT of the Model (live-only service
|
||||
* members): none of it can be resumed, and a session never restores mid-flight.
|
||||
* A `running` phase stranded by a crash is reset to `idle` by the service's
|
||||
* `wire.onRestored` handler (mirroring `goal`'s post-replay normalization).
|
||||
* `wire.hooks.onDidRestore` hook (mirroring `goal`'s post-replay normalization).
|
||||
*
|
||||
* The `compaction.*` events publish to `IEventBus` (`compaction.started` via the
|
||||
* `begin` Op's `toEvent`; the rest directly from the service); they are
|
||||
* declared here via interface-merge (`error` is already declared by `mcp`, so
|
||||
* it is not re-declared). The `full_compaction.*` record shapes are registered in
|
||||
* `PersistedOpMap` (`#/wire/types`, below) because the records still
|
||||
* ride the per-agent `wire.jsonl` log read by `wireRecord.restore()` /
|
||||
* `getRecords()`. Consumed by the Agent-scope `fullCompactionService`.
|
||||
* ride the per-agent `wire.jsonl` journal restored by `IWireService`.
|
||||
* Consumed by the Agent-scope `fullCompactionService`.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
|
|
|||
|
|
@ -39,8 +39,7 @@ import { IEventBus } from '#/app/event/eventBus';
|
|||
import type { CompactionFinishedEvent } from '#/app/telemetry/events';
|
||||
import { ITelemetryService } from '#/app/telemetry/telemetry';
|
||||
import { ErrorCodes, Error2, isCodedError, isError2, toKimiErrorPayload, unwrapErrorCause } from "#/errors";
|
||||
import { IAgentWireService } from '#/wire/tokens';
|
||||
import type { IWireService } from '#/wire/wireService';
|
||||
import { IWireService } from '#/wire/wire';
|
||||
import compactionInstructionTemplate from './compaction-instruction.md?raw';
|
||||
import {
|
||||
IAgentFullCompactionService,
|
||||
|
|
@ -123,7 +122,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
|
|||
@IInstantiationService private readonly instantiation: IInstantiationService,
|
||||
@ISessionTodoService private readonly todo: ISessionTodoService,
|
||||
@ITelemetryService private readonly telemetry: ITelemetryService,
|
||||
@IAgentWireService private readonly wire: IWireService,
|
||||
@IWireService private readonly wire: IWireService,
|
||||
@IEventBus private readonly eventBus: IEventBus,
|
||||
@IAgentActivityService private readonly activity: IAgentActivityService,
|
||||
@ILogService private readonly log: ILogService,
|
||||
|
|
@ -131,7 +130,12 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
|
|||
) {
|
||||
super();
|
||||
this.strategy = new RuntimeCompactionStrategy(() => this.resolveModelContextWithEffectiveMax());
|
||||
this._register(this.wire.onRestored(() => this.normalizeAfterReplay()));
|
||||
this._register(
|
||||
this.wire.hooks.onDidRestore.register('full-compaction', async (_ctx, next) => {
|
||||
this.normalizeAfterReplay();
|
||||
await next();
|
||||
}),
|
||||
);
|
||||
this._register(
|
||||
this.eventBus.subscribe('turn.started', () => this.resetForTurn()),
|
||||
);
|
||||
|
|
@ -266,7 +270,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
|
|||
if (history.length === 0) {
|
||||
throw new Error2(ErrorCodes.COMPACTION_UNABLE, 'No messages to compact in current history.');
|
||||
}
|
||||
if (source === 'manual' && this.activity.lane() !== 'idle') {
|
||||
if (source === 'manual' && !this.activity.isIdle()) {
|
||||
throw new Error2(
|
||||
ErrorCodes.COMPACTION_UNABLE,
|
||||
'Cannot compact while a turn is active. Wait for it to finish, then retry.',
|
||||
|
|
|
|||
|
|
@ -14,8 +14,8 @@
|
|||
* replay). Each `apply` returns the same reference when nothing changes so the
|
||||
* wire's reference-equality gate stays quiet. The `goal.updated` fact is
|
||||
* published live to `IEventBus` by the service (declared here via
|
||||
* interface-merge); `wire.replay` rebuilds the Model silently and the
|
||||
* service's `wire.onRestored`
|
||||
* interface-merge); `wire.restore` rebuilds the Model silently and the
|
||||
* service's `wire.hooks.onDidRestore`
|
||||
* forces a replayed `active` goal back to `paused`. Consumed by the Agent-scope
|
||||
* `goalService`.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -5,13 +5,14 @@
|
|||
* `GoalModel` (`GoalState | null`) through the `goal.create` / `goal.update` /
|
||||
* `goal.clear` Ops (`wire.dispatch`), reads it through `wire.getModel`,
|
||||
* publishes `goal.updated` live to `IEventBus`, and forces a replayed `active`
|
||||
* goal back to `paused` via `wire.onRestored`. The accumulated `wallClockMs`
|
||||
* lives in the Model (set from each Op payload, never by `Date.now()` inside
|
||||
* `apply`); the `wallClockResumedAt` cursor is a live-only field, reset on
|
||||
* replay and (re)started on the live path. A `forked` wire Op clears the Model
|
||||
* goal back to `paused` via `wire.hooks.onDidRestore`. The accumulated
|
||||
* `wallClockMs` lives in the Model (set from each Op payload, never by
|
||||
* `Date.now()` inside `apply`); the `wallClockResumedAt` cursor is a live-only
|
||||
* field, reset on replay and (re)started on the live path. A `forked` wire Op
|
||||
* clears the Model
|
||||
* at a fork boundary; the `goal.*` payload shapes are registered in
|
||||
* `PersistedOpMap` (`#/wire/types`) inside `goalOps` because they still ride
|
||||
* the shared wire log read by `getRecords()` and replayed into the Model.
|
||||
* the Agent wire journal restored into the Model.
|
||||
* Injects reminders through
|
||||
* `contextInjector`, drives continuation turns by enqueueing `newTurn`
|
||||
* `StepRequest`s onto `loop` (the continuation message materializes when the
|
||||
|
|
@ -52,9 +53,8 @@ import {
|
|||
toKimiErrorPayload,
|
||||
type KimiErrorPayload,
|
||||
} from '#/errors';
|
||||
import { IAgentWireService } from '#/wire/tokens';
|
||||
import { defineDerivedModel } from '#/wire/model';
|
||||
import type { IWireService } from '#/wire/wireService';
|
||||
import { IWireService } from '#/wire/wire';
|
||||
import { defineModel } from '#/wire/model';
|
||||
import { IEventBus } from '#/app/event/eventBus';
|
||||
|
||||
import { IAgentGoalService, type GoalReasonInput, type ResumeGoalInput } from './goal';
|
||||
|
|
@ -157,20 +157,22 @@ interface PendingContinuation {
|
|||
turnId?: number;
|
||||
}
|
||||
|
||||
const GoalForkNoticeModel = defineDerivedModel<GoalForkNoticeState>(
|
||||
const GoalForkNoticeModel = defineModel<GoalForkNoticeState>(
|
||||
'goalForkNotice',
|
||||
() => ({ goalPresent: false, reminderPending: false }),
|
||||
{
|
||||
'goal.create': (state) => ({ ...state, goalPresent: true }),
|
||||
'goal.clear': (state) => ({ ...state, goalPresent: false }),
|
||||
forked: (state) => ({
|
||||
goalPresent: false,
|
||||
reminderPending: state.goalPresent || state.reminderPending,
|
||||
}),
|
||||
'context.append_message': (state, payload: { message?: ContextMessage }) =>
|
||||
state.reminderPending && isGoalForkClearedReminder(payload.message)
|
||||
? { ...state, reminderPending: false }
|
||||
: state,
|
||||
reducers: {
|
||||
'goal.create': (state) => ({ ...state, goalPresent: true }),
|
||||
'goal.clear': (state) => ({ ...state, goalPresent: false }),
|
||||
forked: (state) => ({
|
||||
goalPresent: false,
|
||||
reminderPending: state.goalPresent || state.reminderPending,
|
||||
}),
|
||||
'context.append_message': (state, payload: { message?: ContextMessage }) =>
|
||||
state.reminderPending && isGoalForkClearedReminder(payload.message)
|
||||
? { ...state, reminderPending: false }
|
||||
: state,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -195,7 +197,7 @@ export class AgentGoalService extends Disposable implements IAgentGoalService {
|
|||
private pendingContinuation?: PendingContinuation;
|
||||
|
||||
constructor(
|
||||
@IAgentWireService private readonly wire: IWireService,
|
||||
@IWireService private readonly wire: IWireService,
|
||||
@IEventBus private readonly eventBus: IEventBus,
|
||||
@IAgentSystemReminderService private readonly reminders: IAgentSystemReminderService,
|
||||
@ITelemetryService private readonly telemetry: ITelemetryService,
|
||||
|
|
@ -214,8 +216,12 @@ export class AgentGoalService extends Disposable implements IAgentGoalService {
|
|||
dynamicInjector,
|
||||
),
|
||||
);
|
||||
this._register(this.wire.attach(GoalForkNoticeModel));
|
||||
this._register(this.wire.onRestored(() => this.normalizeAfterReplay()));
|
||||
this._register(
|
||||
this.wire.hooks.onDidRestore.register('goal', async (_ctx, next) => {
|
||||
this.normalizeAfterReplay();
|
||||
await next();
|
||||
}),
|
||||
);
|
||||
this._register(
|
||||
this.eventBus.subscribe('turn.started', (e) => this.handleTurnLaunched(e.turnId)),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -61,9 +61,8 @@ import { applyCompletionBudget, resolveCompletionBudget } from '#/app/model/comp
|
|||
import type { Protocol } from '#/app/protocol/protocol';
|
||||
import type { ApiErrorEvent } from '#/app/telemetry/events';
|
||||
import { ITelemetryService } from '#/app/telemetry/telemetry';
|
||||
import { IAgentWireService } from '#/wire/tokens';
|
||||
import { IWireService } from '#/wire/wire';
|
||||
import type { PayloadOf } from '#/wire/types';
|
||||
import type { IWireService } from '#/wire/wireService';
|
||||
import { THINKING_SECTION, type ThinkingConfig } from '#/agent/profile/configSection';
|
||||
import { resolveThinkingKeep } from '#/agent/profile/thinking';
|
||||
|
||||
|
|
@ -143,7 +142,7 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService {
|
|||
@IConfigService private readonly config: IConfigService,
|
||||
@ILogService private readonly log: ILogService,
|
||||
@ITelemetryService private readonly telemetry: ITelemetryService,
|
||||
@IAgentWireService private readonly wire: IWireService,
|
||||
@IWireService private readonly wire: IWireService,
|
||||
@IFaultInjectionService private readonly faultInjection: IFaultInjectionService,
|
||||
) {}
|
||||
|
||||
|
|
|
|||
|
|
@ -66,8 +66,7 @@ import type {
|
|||
TurnStartedEvent as TurnStartedTelemetryEvent,
|
||||
} from '#/app/telemetry/events';
|
||||
import { ITelemetryService } from '#/app/telemetry/telemetry';
|
||||
import { IAgentWireService } from '#/wire/tokens';
|
||||
import type { IWireService } from '#/wire/wireService';
|
||||
import { IWireService } from '#/wire/wire';
|
||||
import { LOOP_CONTROL_SECTION, type LoopControl } from './configSection';
|
||||
import {
|
||||
createMaxStepsExceededError,
|
||||
|
|
@ -132,7 +131,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService {
|
|||
@IAgentToolExecutorService private readonly toolExecutor: IAgentToolExecutorService,
|
||||
@IConfigService private readonly config: IConfigService,
|
||||
@IAgentActivityService private readonly activity: IAgentActivityService,
|
||||
@IAgentWireService private readonly wire: IWireService,
|
||||
@IWireService private readonly wire: IWireService,
|
||||
@ITelemetryService private readonly telemetry: ITelemetryService,
|
||||
@IAgentTelemetryContextService private readonly telemetryContext: IAgentTelemetryContextService,
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -24,8 +24,7 @@ import type { McpServerEntry } from './connection-manager';
|
|||
import { IAgentMcpService } from './mcp';
|
||||
import { qualifyMcpToolName } from './tool-naming';
|
||||
import type { MCPClient, MCPToolDefinition } from './types';
|
||||
import { IAgentWireService } from '#/wire/tokens';
|
||||
import type { IWireService } from '#/wire/wireService';
|
||||
import { IWireService } from '#/wire/wire';
|
||||
import {
|
||||
McpDiscoveryModel,
|
||||
mcpToolsDiscovered,
|
||||
|
|
@ -58,7 +57,7 @@ export class AgentMcpService extends Disposable implements IAgentMcpService {
|
|||
@IAgentToolRegistryService private readonly registry: IAgentToolRegistryService,
|
||||
@IEventBus private readonly eventBus: IEventBus,
|
||||
@IAgentToolExecutorService toolExecutor: IAgentToolExecutorService,
|
||||
@IAgentWireService private readonly wire: IWireService,
|
||||
@IWireService private readonly wire: IWireService,
|
||||
@ITelemetryService private readonly telemetry: ITelemetryService,
|
||||
) {
|
||||
super();
|
||||
|
|
@ -72,8 +71,12 @@ export class AgentMcpService extends Disposable implements IAgentMcpService {
|
|||
},
|
||||
),
|
||||
);
|
||||
this._register(this.wire.onRestored(() => this.flushPendingDiscoveries()));
|
||||
this._register(this.wire.onEmission(() => this.flushPendingDiscoveries()));
|
||||
this._register(
|
||||
this.wire.hooks.onDidRestore.register('mcp', async (_ctx, next) => {
|
||||
this.flushPendingDiscoveries();
|
||||
await next();
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
get oauthService() {
|
||||
|
|
|
|||
|
|
@ -2,11 +2,10 @@
|
|||
* `permissionMode` domain (L3) — wire Model (`PermissionModeModel`) and the
|
||||
* `permission.set_mode` Op (`setMode`) for the agent's permission mode.
|
||||
*
|
||||
* Declares the mode as a scalar `wire` Model (initial `manual`) plus the single
|
||||
* Op that replaces it; `defineOp` registers the Op into the global registry at
|
||||
* import, so `wire.dispatch(setMode({ mode }))` mutates the model and
|
||||
* `wire.replay` rebuilds it from persisted records (skipping every other record
|
||||
* type). Consumed by the Agent-scope `permissionModeService`.
|
||||
* Declares the mode as a scalar `wire` Model (initial `manual`) plus a replay
|
||||
* marker that distinguishes an explicit persisted mode from the default. The
|
||||
* single Op replaces the mode and sets that marker. Consumed by the Agent-scope
|
||||
* `permissionModeService` and session bootstrap.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
|
@ -15,6 +14,11 @@ import type { PermissionMode } from '#/agent/permissionPolicy/types';
|
|||
import { defineModel } from '#/wire/model';
|
||||
|
||||
export const PermissionModeModel = defineModel<PermissionMode>('permissionMode', () => 'manual');
|
||||
export const PermissionModeConfiguredModel = defineModel<boolean>(
|
||||
'permissionMode.configured',
|
||||
() => false,
|
||||
{ reducers: { 'permission.set_mode': () => true } },
|
||||
);
|
||||
|
||||
declare module '#/wire/types' {
|
||||
interface PersistedOpMap {
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@
|
|||
* Holds the agent's permission mode (`manual` / `auto`) in the `wire`
|
||||
* `PermissionModeModel`, mutating it only through the `permission.set_mode` Op
|
||||
* (`wire.dispatch(setMode({ mode }))`) and reading it through `wire.getModel`.
|
||||
* The `onDidChangeMode` event is driven by a `wire.subscribe` on that model
|
||||
* (firing only on actual changes), and mode-aware reminders are registered
|
||||
* through the permission-mode injection helper. Bound at Agent scope.
|
||||
* `setMode` emits `onDidChangeMode` after an actual change, and mode-aware
|
||||
* reminders are registered through the permission-mode injection helper. Bound
|
||||
* at Agent scope.
|
||||
*/
|
||||
|
||||
import type { PermissionMode } from '#/agent/permissionPolicy/types';
|
||||
|
|
@ -16,10 +16,13 @@ import { InstantiationType } from '#/_base/di/extensions';
|
|||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import { Emitter, type Event } from '#/_base/event';
|
||||
import { PermissionModeInjection } from '#/agent/permissionMode/injection/permissionModeInjection';
|
||||
import { IAgentWireService } from '#/wire/tokens';
|
||||
import type { IWireService } from '#/wire/wireService';
|
||||
import { IWireService } from '#/wire/wire';
|
||||
import { IAgentPermissionModeService, type PermissionModeChangedContext } from './permissionMode';
|
||||
import { PermissionModeModel, setMode } from './permissionModeOps';
|
||||
import {
|
||||
PermissionModeConfiguredModel,
|
||||
PermissionModeModel,
|
||||
setMode,
|
||||
} from './permissionModeOps';
|
||||
|
||||
export class AgentPermissionModeService extends Disposable implements IAgentPermissionModeService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
|
@ -28,16 +31,10 @@ export class AgentPermissionModeService extends Disposable implements IAgentPerm
|
|||
readonly onDidChangeMode: Event<PermissionModeChangedContext> = this._onDidChangeMode.event;
|
||||
|
||||
constructor(
|
||||
@IAgentWireService private readonly wire: IWireService,
|
||||
@IWireService private readonly wire: IWireService,
|
||||
@IInstantiationService instantiation: IInstantiationService,
|
||||
) {
|
||||
super();
|
||||
this._register(
|
||||
wire.subscribe(PermissionModeModel, (mode, previousMode) => {
|
||||
if (mode === previousMode) return;
|
||||
this._onDidChangeMode.fire({ mode, previousMode });
|
||||
}),
|
||||
);
|
||||
this._register(instantiation.createInstance(PermissionModeInjection, this));
|
||||
}
|
||||
|
||||
|
|
@ -46,7 +43,11 @@ export class AgentPermissionModeService extends Disposable implements IAgentPerm
|
|||
}
|
||||
|
||||
setMode(mode: PermissionMode): void {
|
||||
const previousMode = this.mode;
|
||||
const changed = mode !== previousMode;
|
||||
if (!changed && this.wire.getModel(PermissionModeConfiguredModel)) return;
|
||||
this.wire.dispatch(setMode({ mode }));
|
||||
if (changed) this._onDidChangeMode.fire({ mode, previousMode });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,8 +11,7 @@
|
|||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
|
||||
import { IAgentWireService } from '#/wire/tokens';
|
||||
import type { IWireService } from '#/wire/wireService';
|
||||
import { IWireService } from '#/wire/wire';
|
||||
import {
|
||||
IAgentPermissionRulesService,
|
||||
type PermissionApprovalResultRecord,
|
||||
|
|
@ -27,7 +26,7 @@ import {
|
|||
export class AgentPermissionRulesService implements IAgentPermissionRulesService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
constructor(@IAgentWireService private readonly wire: IWireService) {}
|
||||
constructor(@IWireService private readonly wire: IWireService) {}
|
||||
|
||||
get rules(): readonly PermissionRule[] {
|
||||
return [...this.wire.getModel(PermissionRulesModel).rules];
|
||||
|
|
|
|||
|
|
@ -21,8 +21,7 @@ import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext';
|
|||
import { IAgentTelemetryContextService } from '#/app/telemetry/agentTelemetryContext';
|
||||
import { IHostFileSystem } from '#/os/interface/hostFileSystem';
|
||||
import { ISessionContext } from '#/session/sessionContext/sessionContext';
|
||||
import { IAgentWireService } from '#/wire/tokens';
|
||||
import type { IWireService } from '#/wire/wireService';
|
||||
import { IWireService } from '#/wire/wire';
|
||||
import {
|
||||
IAgentPlanService,
|
||||
type PlanData,
|
||||
|
|
@ -43,13 +42,18 @@ export class AgentPlanService extends Disposable implements IAgentPlanService {
|
|||
@IHostFileSystem private readonly hostFs: IHostFileSystem,
|
||||
@IAgentContextInjectorService dynamicInjector: IAgentContextInjectorService,
|
||||
@IAgentTelemetryContextService private readonly telemetryContext: IAgentTelemetryContextService,
|
||||
@IAgentWireService private readonly wire: IWireService,
|
||||
@IWireService private readonly wire: IWireService,
|
||||
@ISessionContext private readonly sessionCtx: ISessionContext,
|
||||
@IAgentScopeContext private readonly agentCtx: IAgentScopeContext,
|
||||
) {
|
||||
super();
|
||||
|
||||
this._register(this.wire.onRestored(() => this.restoreTelemetryMode()));
|
||||
this._register(
|
||||
this.wire.hooks.onDidRestore.register('plan', async (_ctx, next) => {
|
||||
this.restoreTelemetryMode();
|
||||
await next();
|
||||
}),
|
||||
);
|
||||
|
||||
this._register(new PlanModeInjection(dynamicInjector, this, this.context));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,9 +54,8 @@ import type { ResolvedAgentProfile, SystemPromptContext } from '#/agent/profile/
|
|||
import type { WarningEvent } from '@moonshot-ai/protocol';
|
||||
import { ITelemetryService } from '#/app/telemetry/telemetry';
|
||||
import { IAgentTelemetryContextService } from '#/app/telemetry/agentTelemetryContext';
|
||||
import { IAgentWireService } from '#/wire/tokens';
|
||||
import { IWireService } from '#/wire/wire';
|
||||
import type { PayloadOf } from '#/wire/types';
|
||||
import type { IWireService } from '#/wire/wireService';
|
||||
import { IEventBus } from '#/app/event/eventBus';
|
||||
import { prepareSystemPromptContext } from './context';
|
||||
import type {
|
||||
|
|
@ -105,7 +104,7 @@ export class AgentProfileService implements IAgentProfileService {
|
|||
private activeProfile: ResolvedAgentProfile | undefined;
|
||||
|
||||
constructor(
|
||||
@IAgentWireService private readonly wire: IWireService,
|
||||
@IWireService private readonly wire: IWireService,
|
||||
@IEventBus private readonly eventBus: IEventBus,
|
||||
@ITelemetryService private readonly telemetry: ITelemetryService,
|
||||
@IAgentTelemetryContextService private readonly telemetryContext: IAgentTelemetryContextService,
|
||||
|
|
|
|||
|
|
@ -27,8 +27,7 @@ import type { ContentPart } from '#/app/llmProtocol/message';
|
|||
import { IEventBus } from '#/app/event/eventBus';
|
||||
import { ErrorCodes, Error2 } from '#/errors';
|
||||
import { OrderedHookSlot } from '#/hooks';
|
||||
import { IAgentWireService } from '#/wire/tokens';
|
||||
import type { IWireService } from '#/wire/wireService';
|
||||
import { IWireService } from '#/wire/wire';
|
||||
|
||||
import {
|
||||
IAgentPromptService,
|
||||
|
|
@ -73,7 +72,7 @@ export class AgentPromptService implements IAgentPromptService {
|
|||
@IInstantiationService private readonly instantiation: IInstantiationService,
|
||||
@IAgentLoopService private readonly loop: IAgentLoopService,
|
||||
@IAgentToolExecutorService toolExecutor: IAgentToolExecutorService,
|
||||
@IAgentWireService private readonly wire: IWireService,
|
||||
@IWireService private readonly wire: IWireService,
|
||||
@IEventBus private readonly eventBus: IEventBus,
|
||||
) {
|
||||
toolExecutor.hooks.onDidExecuteTool.register('prompt-service-delivery', async (ctx, next) => {
|
||||
|
|
|
|||
|
|
@ -1,107 +0,0 @@
|
|||
/**
|
||||
* `replayBuilder` domain — `ReplayTimelineModel`, a derived wire model that
|
||||
* folds heterogeneous Ops from multiple domains into a single ordered timeline.
|
||||
*
|
||||
* This is the v2 replacement for v1's imperative `ReplayBuilder` class: instead
|
||||
* of each domain service pushing records into a mutable accumulator, the model
|
||||
* declares which Op types it reduces and the wire engine folds them
|
||||
* automatically — during both `replay` (silent) and `dispatch` (live).
|
||||
*
|
||||
* The timeline entries are op-native: they carry the raw op payloads, not the
|
||||
* v1 `AgentReplayRecordPayload` DTO shape. The projection to the SDK/edge DTO
|
||||
* (e.g. computing `GoalSnapshot` from `GoalState`) is a read-time concern, not
|
||||
* a reduce-time concern.
|
||||
*/
|
||||
|
||||
import {
|
||||
contextAppendMessage,
|
||||
contextApplyCompaction,
|
||||
} from '#/agent/contextMemory/contextOps';
|
||||
import {
|
||||
fullCompactionBegin,
|
||||
fullCompactionCancel,
|
||||
fullCompactionComplete,
|
||||
} from '#/agent/fullCompaction/compactionOps';
|
||||
import { clearGoal, createGoal, updateGoal } from '#/agent/goal/goalOps';
|
||||
import { planModeCancel, planModeEnter, planModeExit } from '#/agent/plan/planOps';
|
||||
import { configUpdate } from '#/agent/profile/profileOps';
|
||||
import type { PermissionMode } from '#/agent/permissionPolicy/types';
|
||||
import { setMode } from '#/agent/permissionMode/permissionModeOps';
|
||||
import type { PermissionApprovalResultRecord } from '#/agent/permissionRules/permissionRules';
|
||||
import { recordApprovalResult } from '#/agent/permissionRules/permissionRulesOps';
|
||||
import { type DerivedModelDef, defineDerivedModel } from '#/wire/model';
|
||||
import type { ModelReducers, OpPayload, OpType, PayloadOf } from '#/wire/types';
|
||||
|
||||
type TimelineMapperMap = {
|
||||
[K in OpType]?: (payload: OpPayload<K>) => unknown;
|
||||
};
|
||||
|
||||
type TimelineEntry<M> = {
|
||||
[K in keyof M]: M[K] extends (...args: never[]) => infer E ? E : never;
|
||||
}[keyof M];
|
||||
|
||||
type ErasedTimelineMapper<E> = (payload: unknown) => E;
|
||||
|
||||
function defineDerivedTimeline<const M extends TimelineMapperMap>(
|
||||
name: string,
|
||||
mappers: M & Record<Exclude<keyof M, OpType>, never>,
|
||||
): DerivedModelDef<readonly TimelineEntry<M>[]> {
|
||||
type E = TimelineEntry<M>;
|
||||
const entries = Object.entries(mappers) as [OpType, ErasedTimelineMapper<E>][];
|
||||
const reducers = Object.fromEntries(
|
||||
entries.map(
|
||||
([opType, mapper]) =>
|
||||
[opType, (state: readonly E[], payload: unknown) => [...state, mapper(payload)]] as const,
|
||||
),
|
||||
) as ModelReducers<readonly E[]>;
|
||||
return defineDerivedModel(name, () => [], reducers);
|
||||
}
|
||||
|
||||
export const ReplayTimelineModel = defineDerivedTimeline('agent.replayTimeline', {
|
||||
[contextAppendMessage.type]: (p: PayloadOf<typeof contextAppendMessage>) =>
|
||||
({ type: contextAppendMessage.type, payload: p }) as const,
|
||||
|
||||
[contextApplyCompaction.type]: (p: PayloadOf<typeof contextApplyCompaction>) =>
|
||||
({ type: contextApplyCompaction.type, payload: p }) as const,
|
||||
|
||||
[fullCompactionBegin.type]: (p: PayloadOf<typeof fullCompactionBegin>) =>
|
||||
({ type: fullCompactionBegin.type, payload: p }) as const,
|
||||
|
||||
[fullCompactionCancel.type]: () =>
|
||||
({ type: fullCompactionCancel.type }) as const,
|
||||
|
||||
[fullCompactionComplete.type]: (p: PayloadOf<typeof fullCompactionComplete>) =>
|
||||
({ type: fullCompactionComplete.type, payload: p }) as const,
|
||||
|
||||
[createGoal.type]: (p: PayloadOf<typeof createGoal>) =>
|
||||
({ type: createGoal.type, payload: p }) as const,
|
||||
|
||||
[updateGoal.type]: (p: PayloadOf<typeof updateGoal>) =>
|
||||
({ type: updateGoal.type, payload: p }) as const,
|
||||
|
||||
[clearGoal.type]: () =>
|
||||
({ type: clearGoal.type }) as const,
|
||||
|
||||
[planModeEnter.type]: (p: PayloadOf<typeof planModeEnter>) =>
|
||||
({ type: planModeEnter.type, payload: p }) as const,
|
||||
|
||||
[planModeCancel.type]: (p: PayloadOf<typeof planModeCancel>) =>
|
||||
({ type: planModeCancel.type, payload: p }) as const,
|
||||
|
||||
[planModeExit.type]: (p: PayloadOf<typeof planModeExit>) =>
|
||||
({ type: planModeExit.type, payload: p }) as const,
|
||||
|
||||
[configUpdate.type]: (p: PayloadOf<typeof configUpdate>) =>
|
||||
({ type: configUpdate.type, payload: p }) as const,
|
||||
|
||||
[setMode.type]: (p: { mode: PermissionMode }) =>
|
||||
({ type: setMode.type, payload: p }) as const,
|
||||
|
||||
[recordApprovalResult.type]: (p: PermissionApprovalResultRecord) =>
|
||||
({ type: recordApprovalResult.type, payload: p }) as const,
|
||||
});
|
||||
|
||||
type InferTimelineEntry<D> = D extends DerivedModelDef<readonly (infer E)[]> ? E : never;
|
||||
|
||||
export type ReplayTimelineEntry = InferTimelineEntry<typeof ReplayTimelineModel>;
|
||||
export type ReplayTimeline = readonly ReplayTimelineEntry[];
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
/**
|
||||
* `runtime` domain (L4) — Agent-scope live phase contract.
|
||||
*
|
||||
* Defines the public contract of the agent's whole live phase: the `AgentPhase`
|
||||
* discriminated union (each variant carries its own ancillary fields) and the
|
||||
* `IAgentRuntimeService` used to read the current phase via `phase()`. The
|
||||
* phase is the agent-level, fine-grained counterpart of the session-level
|
||||
* `sessionActivity` status: it splits `running` into waiting / streaming /
|
||||
* tool_call / retrying and adds `interrupted` / `ended`. Agent-scoped — one
|
||||
* instance per agent.
|
||||
*/
|
||||
|
||||
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
|
||||
import type { TurnEndedEvent } from '@moonshot-ai/protocol';
|
||||
|
||||
export type AgentPhase =
|
||||
| { readonly kind: 'idle' }
|
||||
| {
|
||||
readonly kind: 'running';
|
||||
readonly turnId: number;
|
||||
readonly step: number;
|
||||
readonly stepId: string;
|
||||
readonly since: number;
|
||||
}
|
||||
| {
|
||||
readonly kind: 'streaming';
|
||||
readonly turnId: number;
|
||||
readonly step: number;
|
||||
readonly stepId: string;
|
||||
readonly stream: 'assistant' | 'thinking' | 'tool_call';
|
||||
readonly toolCallId?: string;
|
||||
readonly toolName?: string;
|
||||
readonly since: number;
|
||||
}
|
||||
| {
|
||||
readonly kind: 'tool_call';
|
||||
readonly turnId: number;
|
||||
readonly step: number;
|
||||
readonly toolCallId: string;
|
||||
readonly name: string;
|
||||
readonly since: number;
|
||||
}
|
||||
| {
|
||||
readonly kind: 'retrying';
|
||||
readonly turnId: number;
|
||||
readonly step: number;
|
||||
readonly stepId: string;
|
||||
readonly failedAttempt: number;
|
||||
readonly nextAttempt: number;
|
||||
readonly maxAttempts: number;
|
||||
readonly delayMs: number;
|
||||
readonly errorName?: string;
|
||||
readonly statusCode?: number;
|
||||
readonly since: number;
|
||||
}
|
||||
| {
|
||||
readonly kind: 'awaiting_approval';
|
||||
readonly turnId: number;
|
||||
readonly step?: number;
|
||||
readonly approval: unknown;
|
||||
readonly since: number;
|
||||
}
|
||||
| {
|
||||
readonly kind: 'interrupted';
|
||||
readonly turnId: number;
|
||||
readonly step?: number;
|
||||
readonly reason: 'aborted' | 'max_steps' | 'error';
|
||||
readonly message?: string;
|
||||
readonly at: number;
|
||||
}
|
||||
| {
|
||||
readonly kind: 'ended';
|
||||
readonly turnId: number;
|
||||
readonly reason: TurnEndedEvent['reason'];
|
||||
readonly durationMs?: number;
|
||||
readonly at: number;
|
||||
};
|
||||
|
||||
export interface IAgentRuntimeService {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
||||
phase(): AgentPhase;
|
||||
}
|
||||
|
||||
export const IAgentRuntimeService: ServiceIdentifier<IAgentRuntimeService> =
|
||||
createDecorator<IAgentRuntimeService>('agentRuntimeService');
|
||||
|
|
@ -1,140 +0,0 @@
|
|||
/**
|
||||
* `runtime` domain (L4) — wire Model (`RuntimeModel`) and the `runtime.set_phase`
|
||||
* Op (`setRuntimePhase`) that holds the agent's whole live phase.
|
||||
*
|
||||
* Declares the phase as a single-field wire Model (`{ phase }`, initial
|
||||
* `{ kind: 'idle' }`) plus one Op whose `apply` is a pure, edge-triggered
|
||||
* replacement: it returns the SAME reference when the incoming phase is
|
||||
* unchanged under `phaseEqual` (which ignores `since` / `at` timestamps), so the
|
||||
* wire's reference-equality gate stays quiet and high-frequency deltas do not
|
||||
* flood subscribers. The Op is live-only because `runtime.set_phase` is not a
|
||||
* v1 record type: nothing is persisted or replayed, and resumed agents start
|
||||
* back at `idle`. The `agent.status.updated` `phase` slice is derived from
|
||||
* the Op's `toEvent` (published on `dispatch`, never on `replay`). Consumed
|
||||
* by the Agent-scope `runtimeService`.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import { defineModel } from '#/wire/model';
|
||||
|
||||
import type { AgentPhase } from './runtime';
|
||||
|
||||
export interface RuntimeModelState {
|
||||
readonly phase: AgentPhase;
|
||||
}
|
||||
|
||||
export const RuntimeModel = defineModel<RuntimeModelState>('runtime', () => ({
|
||||
phase: { kind: 'idle' },
|
||||
}));
|
||||
|
||||
declare module '#/wire/types' {
|
||||
interface TransientOpMap {
|
||||
'runtime.set_phase': typeof setRuntimePhase;
|
||||
'activity.set_snapshot': typeof setActivitySnapshot;
|
||||
}
|
||||
}
|
||||
|
||||
export const setRuntimePhase = RuntimeModel.defineOp('runtime.set_phase', {
|
||||
schema: z.object({ phase: z.custom<AgentPhase>() }),
|
||||
persist: false,
|
||||
apply: (s, p) => (phaseEqual(s.phase, p.phase) ? s : { phase: p.phase }),
|
||||
toEvent: (p) => ({ type: 'agent.status.updated' as const, phase: p.phase }),
|
||||
});
|
||||
|
||||
export function phaseEqual(a: AgentPhase, b: AgentPhase): boolean {
|
||||
if (a.kind !== b.kind) return false;
|
||||
switch (a.kind) {
|
||||
case 'idle':
|
||||
return true;
|
||||
case 'running': {
|
||||
const c = b as typeof a;
|
||||
return a.turnId === c.turnId && a.step === c.step && a.stepId === c.stepId;
|
||||
}
|
||||
case 'streaming': {
|
||||
const c = b as typeof a;
|
||||
return (
|
||||
a.turnId === c.turnId &&
|
||||
a.step === c.step &&
|
||||
a.stepId === c.stepId &&
|
||||
a.stream === c.stream &&
|
||||
a.toolCallId === c.toolCallId
|
||||
);
|
||||
}
|
||||
case 'tool_call': {
|
||||
const c = b as typeof a;
|
||||
return a.turnId === c.turnId && a.toolCallId === c.toolCallId;
|
||||
}
|
||||
case 'retrying': {
|
||||
const c = b as typeof a;
|
||||
return (
|
||||
a.turnId === c.turnId &&
|
||||
a.step === c.step &&
|
||||
a.failedAttempt === c.failedAttempt &&
|
||||
a.nextAttempt === c.nextAttempt
|
||||
);
|
||||
}
|
||||
case 'awaiting_approval': {
|
||||
const c = b as typeof a;
|
||||
return a.turnId === c.turnId;
|
||||
}
|
||||
case 'interrupted': {
|
||||
const c = b as typeof a;
|
||||
return a.turnId === c.turnId && a.reason === c.reason;
|
||||
}
|
||||
case 'ended': {
|
||||
const c = b as typeof a;
|
||||
return a.turnId === c.turnId && a.reason === c.reason;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
import type { AgentActivitySnapshot } from '#/activity/activity';
|
||||
|
||||
export const ActivityModel = defineModel<AgentActivitySnapshot>('activity', () => ({
|
||||
lane: 'idle',
|
||||
background: [],
|
||||
}));
|
||||
|
||||
export const setActivitySnapshot = ActivityModel.defineOp('activity.set_snapshot', {
|
||||
schema: z.object({ next: z.custom<AgentActivitySnapshot>() }),
|
||||
persist: false,
|
||||
apply: (s, p) => (snapshotEqual(s, p.next) ? s : p.next),
|
||||
toEvent: (p) => ({ type: 'agent.activity.updated' as const, ...p.next }),
|
||||
});
|
||||
|
||||
export function snapshotEqual(a: AgentActivitySnapshot, b: AgentActivitySnapshot): boolean {
|
||||
if (a.lane !== b.lane) return false;
|
||||
if (a.background.length !== b.background.length) return false;
|
||||
if ((a.turn === undefined) !== (b.turn === undefined)) return false;
|
||||
if (a.turn !== undefined && b.turn !== undefined) {
|
||||
const ta = a.turn;
|
||||
const tb = b.turn;
|
||||
if (
|
||||
ta.turnId !== tb.turnId ||
|
||||
ta.phase !== tb.phase ||
|
||||
ta.stream !== tb.stream ||
|
||||
ta.step !== tb.step ||
|
||||
ta.ending !== tb.ending ||
|
||||
ta.endingReason !== tb.endingReason ||
|
||||
ta.pendingApprovals.length !== tb.pendingApprovals.length ||
|
||||
ta.activeToolCalls.length !== tb.activeToolCalls.length
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (ta.retry?.nextAttempt !== tb.retry?.nextAttempt) return false;
|
||||
}
|
||||
if ((a.lastTurn === undefined) !== (b.lastTurn === undefined)) return false;
|
||||
if (a.lastTurn !== undefined && b.lastTurn !== undefined) {
|
||||
if (a.lastTurn.turnId !== b.lastTurn.turnId || a.lastTurn.reason !== b.lastTurn.reason) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
declare module '#/app/event/eventBus' {
|
||||
interface DomainEventMap {
|
||||
'agent.activity.updated': AgentActivitySnapshot & { readonly type: 'agent.activity.updated' };
|
||||
}
|
||||
}
|
||||
|
|
@ -1,345 +0,0 @@
|
|||
/**
|
||||
* `runtime` domain (L4) — `IAgentRuntimeService` implementation and the Agent
|
||||
* activity projector.
|
||||
*
|
||||
* Folds the agent's live activity into a structured `AgentActivitySnapshot`
|
||||
* (`ActivityModel`, mutated only through the `activity.set_snapshot` Op) and a
|
||||
* legacy `AgentPhase` (`RuntimeModel`, through `runtime.set_phase`). Inputs:
|
||||
* the `activity` kernel's `LaneModel` (authoritative lane / turn / lastTurn /
|
||||
* background) plus the existing `IEventBus` facts (step / stream / retry /
|
||||
* approval / tool-call). The snapshot adds a pending-approval SET and an
|
||||
* active-tool-call SET (keyed by id), so a parallel approval resolve no longer
|
||||
* drops the still-waiting ones (矛盾 d) and parallel tool calls are all
|
||||
* visible. Subscriptions are edge-triggered: `publishSnapshot` only dispatches
|
||||
* when `snapshotEqual` says it changed. Live-only — `wire.replay` stays silent
|
||||
* and resumes into `idle`. Bound at Agent scope.
|
||||
*/
|
||||
|
||||
import { Disposable } from '#/_base/di/lifecycle';
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import { IEventBus } from '#/app/event/eventBus';
|
||||
import type { PermissionApprovalRequestContext } from '#/agent/permissionGate/permissionGateService';
|
||||
import type { TurnEndedEvent } from '@moonshot-ai/protocol';
|
||||
import { IAgentWireService } from '#/wire/tokens';
|
||||
import type { IWireService } from '#/wire/wireService';
|
||||
import type {
|
||||
ActivityRetryState,
|
||||
AgentActivitySnapshot,
|
||||
ApprovalRef,
|
||||
ToolCallRef,
|
||||
TurnPhase,
|
||||
} from '#/activity/activity';
|
||||
import { LaneModel } from '#/activity/activityOps';
|
||||
|
||||
import { type AgentPhase, IAgentRuntimeService } from './runtime';
|
||||
import {
|
||||
phaseEqual,
|
||||
RuntimeModel,
|
||||
setActivitySnapshot,
|
||||
setRuntimePhase,
|
||||
} from './runtimeOps';
|
||||
|
||||
interface TurnCursor {
|
||||
readonly turnId: number;
|
||||
readonly step: number;
|
||||
readonly stepId: string;
|
||||
}
|
||||
|
||||
export class AgentRuntimeService extends Disposable implements IAgentRuntimeService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
private cursor: TurnCursor = { turnId: -1, step: 0, stepId: '' };
|
||||
private current: AgentPhase = { kind: 'idle' };
|
||||
private priorForApproval: AgentPhase | undefined;
|
||||
private subPhase: TurnPhase = 'running';
|
||||
private subStream: 'assistant' | 'thinking' | 'tool_call' | undefined;
|
||||
private subRetry: ActivityRetryState | undefined;
|
||||
private readonly pendingApprovals = new Map<string, ApprovalRef>();
|
||||
private readonly activeToolCalls = new Map<string, ToolCallRef>();
|
||||
|
||||
constructor(
|
||||
@IAgentWireService private readonly wire: IWireService,
|
||||
@IEventBus private readonly eventBus: IEventBus,
|
||||
) {
|
||||
super();
|
||||
this._register(this.eventBus.subscribe('turn.started', (e) => this.onTurnStarted(e.turnId)));
|
||||
this._register(
|
||||
this.eventBus.subscribe('turn.step.started', (e) =>
|
||||
this.onStepStarted(e.turnId, e.step, e.stepId ?? ''),
|
||||
),
|
||||
);
|
||||
this._register(
|
||||
this.eventBus.subscribe('assistant.delta', () => this.onDelta('assistant')),
|
||||
);
|
||||
this._register(
|
||||
this.eventBus.subscribe('thinking.delta', () => this.onDelta('thinking')),
|
||||
);
|
||||
this._register(
|
||||
this.eventBus.subscribe('tool.call.delta', (e) =>
|
||||
this.onToolCallDelta(e.toolCallId, e.name),
|
||||
),
|
||||
);
|
||||
this._register(
|
||||
this.eventBus.subscribe('tool.call.started', (e) =>
|
||||
this.onToolCallStarted(e.toolCallId, e.name),
|
||||
),
|
||||
);
|
||||
this._register(
|
||||
this.eventBus.subscribe('tool.result', (e) => this.onToolResult(e.toolCallId)),
|
||||
);
|
||||
this._register(
|
||||
this.eventBus.subscribe('turn.step.retrying', (e) => {
|
||||
this.subPhase = 'retrying';
|
||||
this.subStream = undefined;
|
||||
this.subRetry = {
|
||||
failedAttempt: e.failedAttempt,
|
||||
nextAttempt: e.nextAttempt,
|
||||
maxAttempts: e.maxAttempts,
|
||||
delayMs: e.delayMs,
|
||||
errorName: e.errorName,
|
||||
statusCode: e.statusCode,
|
||||
};
|
||||
this.setPhase({
|
||||
kind: 'retrying',
|
||||
turnId: e.turnId,
|
||||
step: e.step,
|
||||
stepId: e.stepId ?? '',
|
||||
failedAttempt: e.failedAttempt,
|
||||
nextAttempt: e.nextAttempt,
|
||||
maxAttempts: e.maxAttempts,
|
||||
delayMs: e.delayMs,
|
||||
errorName: e.errorName,
|
||||
statusCode: e.statusCode,
|
||||
since: Date.now(),
|
||||
});
|
||||
this.publishSnapshot();
|
||||
}),
|
||||
);
|
||||
this._register(
|
||||
this.eventBus.subscribe('turn.step.interrupted', (e) =>
|
||||
this.setPhase({
|
||||
kind: 'interrupted',
|
||||
turnId: e.turnId,
|
||||
step: e.step,
|
||||
reason: e.reason as 'aborted' | 'max_steps' | 'error',
|
||||
message: e.message,
|
||||
at: Date.now(),
|
||||
}),
|
||||
),
|
||||
);
|
||||
this._register(
|
||||
this.eventBus.subscribe('turn.step.completed', () => {
|
||||
this.subPhase = 'running';
|
||||
this.subStream = undefined;
|
||||
this.subRetry = undefined;
|
||||
this.setPhase(this.running());
|
||||
this.publishSnapshot();
|
||||
}),
|
||||
);
|
||||
this._register(
|
||||
this.eventBus.subscribe('turn.ended', (e) =>
|
||||
this.onTurnEnded(e.turnId, e.reason, e.durationMs),
|
||||
),
|
||||
);
|
||||
this._register(
|
||||
this.eventBus.subscribe('permission.approval.requested', (e) =>
|
||||
this.onApprovalRequested(e),
|
||||
),
|
||||
);
|
||||
this._register(
|
||||
this.eventBus.subscribe('permission.approval.resolved', (e) =>
|
||||
this.onApprovalResolved(e.toolCallId),
|
||||
),
|
||||
);
|
||||
this._register(this.wire.subscribe(LaneModel, () => this.publishSnapshot()));
|
||||
}
|
||||
|
||||
phase(): AgentPhase {
|
||||
return this.wire.getModel(RuntimeModel).phase;
|
||||
}
|
||||
|
||||
private onTurnStarted(turnId: number): void {
|
||||
this.cursor = { turnId, step: 0, stepId: '' };
|
||||
this.priorForApproval = undefined;
|
||||
this.subPhase = 'running';
|
||||
this.subStream = undefined;
|
||||
this.subRetry = undefined;
|
||||
this.pendingApprovals.clear();
|
||||
this.activeToolCalls.clear();
|
||||
this.setPhase(this.running());
|
||||
this.publishSnapshot();
|
||||
}
|
||||
|
||||
private onStepStarted(turnId: number, step: number, stepId: string): void {
|
||||
this.cursor = { turnId, step, stepId };
|
||||
this.subPhase = 'running';
|
||||
this.subStream = undefined;
|
||||
this.subRetry = undefined;
|
||||
this.setPhase(this.running());
|
||||
this.publishSnapshot();
|
||||
}
|
||||
|
||||
private onDelta(stream: 'assistant' | 'thinking'): void {
|
||||
this.subPhase = 'streaming';
|
||||
this.subStream = stream;
|
||||
this.subRetry = undefined;
|
||||
this.setPhase({
|
||||
kind: 'streaming',
|
||||
turnId: this.cursor.turnId,
|
||||
step: this.cursor.step,
|
||||
stepId: this.cursor.stepId,
|
||||
stream,
|
||||
since: Date.now(),
|
||||
});
|
||||
this.publishSnapshot();
|
||||
}
|
||||
|
||||
private onToolCallDelta(toolCallId: string, name: string | undefined): void {
|
||||
this.subPhase = 'streaming';
|
||||
this.subStream = 'tool_call';
|
||||
this.subRetry = undefined;
|
||||
this.setPhase({
|
||||
kind: 'streaming',
|
||||
turnId: this.cursor.turnId,
|
||||
step: this.cursor.step,
|
||||
stepId: this.cursor.stepId,
|
||||
stream: 'tool_call',
|
||||
toolCallId,
|
||||
toolName: name,
|
||||
since: Date.now(),
|
||||
});
|
||||
this.publishSnapshot();
|
||||
}
|
||||
|
||||
private onToolCallStarted(toolCallId: string, name: string): void {
|
||||
this.subPhase = 'tool_call';
|
||||
this.subStream = undefined;
|
||||
this.subRetry = undefined;
|
||||
this.activeToolCalls.set(toolCallId, { toolCallId, name, since: Date.now() });
|
||||
this.setPhase({
|
||||
kind: 'tool_call',
|
||||
turnId: this.cursor.turnId,
|
||||
step: this.cursor.step,
|
||||
toolCallId,
|
||||
name,
|
||||
since: Date.now(),
|
||||
});
|
||||
this.publishSnapshot();
|
||||
}
|
||||
|
||||
private onToolResult(toolCallId: string): void {
|
||||
this.activeToolCalls.delete(toolCallId);
|
||||
this.subPhase = 'running';
|
||||
this.subStream = undefined;
|
||||
this.subRetry = undefined;
|
||||
this.setPhase(this.running());
|
||||
this.publishSnapshot();
|
||||
}
|
||||
|
||||
private onTurnEnded(
|
||||
turnId: number,
|
||||
reason: TurnEndedEvent['reason'],
|
||||
durationMs: number | undefined,
|
||||
): void {
|
||||
this.setPhase({ kind: 'ended', turnId, reason, durationMs, at: Date.now() });
|
||||
this.cursor = { turnId: -1, step: 0, stepId: '' };
|
||||
this.priorForApproval = undefined;
|
||||
this.subPhase = 'running';
|
||||
this.subStream = undefined;
|
||||
this.subRetry = undefined;
|
||||
this.pendingApprovals.clear();
|
||||
this.activeToolCalls.clear();
|
||||
this.publishSnapshot();
|
||||
}
|
||||
|
||||
private onApprovalRequested(approval: PermissionApprovalRequestContext): void {
|
||||
this.priorForApproval = this.current;
|
||||
this.pendingApprovals.set(approval.toolCallId, {
|
||||
approvalId: approval.toolCallId,
|
||||
toolCallId: approval.toolCallId,
|
||||
since: Date.now(),
|
||||
});
|
||||
this.setPhase({
|
||||
kind: 'awaiting_approval',
|
||||
turnId: approval.turnId,
|
||||
step: this.cursor.step || undefined,
|
||||
approval,
|
||||
since: Date.now(),
|
||||
});
|
||||
this.publishSnapshot();
|
||||
}
|
||||
|
||||
private onApprovalResolved(toolCallId: string): void {
|
||||
this.pendingApprovals.delete(toolCallId);
|
||||
const resume = this.priorForApproval;
|
||||
this.priorForApproval = undefined;
|
||||
if (this.pendingApprovals.size > 0) {
|
||||
this.setPhase({
|
||||
kind: 'awaiting_approval',
|
||||
turnId: this.cursor.turnId,
|
||||
step: this.cursor.step || undefined,
|
||||
approval: undefined,
|
||||
since: Date.now(),
|
||||
});
|
||||
} else if (resume !== undefined && resume.kind !== 'idle' && resume.kind !== 'ended') {
|
||||
this.setPhase(resume);
|
||||
} else {
|
||||
this.setPhase(this.running());
|
||||
}
|
||||
this.publishSnapshot();
|
||||
}
|
||||
|
||||
private running(): AgentPhase {
|
||||
return {
|
||||
kind: 'running',
|
||||
turnId: this.cursor.turnId,
|
||||
step: this.cursor.step,
|
||||
stepId: this.cursor.stepId,
|
||||
since: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
private setPhase(phase: AgentPhase): void {
|
||||
if (phaseEqual(this.current, phase)) return;
|
||||
this.current = phase;
|
||||
this.wire.dispatch(setRuntimePhase({ phase }));
|
||||
}
|
||||
|
||||
private publishSnapshot(): void {
|
||||
const lane = this.wire.getModel(LaneModel);
|
||||
const turn =
|
||||
lane.turn === undefined
|
||||
? undefined
|
||||
: {
|
||||
turnId: lane.turn.turnId,
|
||||
origin: lane.turn.origin,
|
||||
phase: this.subPhase,
|
||||
stream: this.subStream,
|
||||
step: this.cursor.step,
|
||||
ending: lane.turn.ending,
|
||||
endingReason: lane.turn.endingReason,
|
||||
retry: this.subRetry,
|
||||
pendingApprovals: [...this.pendingApprovals.values()],
|
||||
activeToolCalls: [...this.activeToolCalls.values()],
|
||||
since: lane.turn.since,
|
||||
};
|
||||
const snapshot = {
|
||||
lane: lane.lane,
|
||||
turn,
|
||||
lastTurn: lane.lastTurn,
|
||||
background: lane.background,
|
||||
};
|
||||
this.wire.dispatch(
|
||||
setActivitySnapshot({ next: snapshot as unknown as AgentActivitySnapshot }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
registerScopedService(
|
||||
LifecycleScope.Agent,
|
||||
IAgentRuntimeService,
|
||||
AgentRuntimeService,
|
||||
InstantiationType.Eager,
|
||||
'runtime',
|
||||
);
|
||||
|
|
@ -26,8 +26,7 @@ import { isUserActivatableSkillType, type SkillDefinition } from '#/app/skillCat
|
|||
import { IAgentPromptService } from '#/agent/prompt/prompt';
|
||||
import { ITelemetryService } from '#/app/telemetry/telemetry';
|
||||
import type { Turn } from '#/agent/loop/loop';
|
||||
import { IAgentWireService } from '#/wire/tokens';
|
||||
import type { IWireService } from '#/wire/wireService';
|
||||
import { IWireService } from '#/wire/wire';
|
||||
import { IAgentSkillService, type SkillActivationInput } from './skill';
|
||||
import { skillActivate } from './skillOps';
|
||||
import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog';
|
||||
|
|
@ -38,7 +37,7 @@ export class AgentSkillService extends Disposable implements IAgentSkillService
|
|||
constructor(
|
||||
@ISessionSkillCatalog private readonly skillCatalog: ISessionSkillCatalog,
|
||||
@IAgentPromptService private readonly prompt: IAgentPromptService,
|
||||
@IAgentWireService private readonly wire: IWireService,
|
||||
@IWireService private readonly wire: IWireService,
|
||||
@ITelemetryService private readonly telemetry: ITelemetryService,
|
||||
@ISessionContext private readonly sessionContext: ISessionContext,
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -21,8 +21,7 @@ import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
|||
import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory';
|
||||
import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder';
|
||||
import { IEventBus } from '#/app/event/eventBus';
|
||||
import { IAgentWireService } from '#/wire/tokens';
|
||||
import type { IWireService } from '#/wire/wireService';
|
||||
import { IWireService } from '#/wire/wire';
|
||||
import SWARM_MODE_ENTER_REMINDER from './enter-reminder.md?raw';
|
||||
import SWARM_MODE_EXIT_REMINDER from './exit-reminder.md?raw';
|
||||
import { IAgentSwarmService, type SwarmModeTrigger } from './swarm';
|
||||
|
|
@ -32,7 +31,7 @@ export class AgentSwarmService extends Disposable implements IAgentSwarmService
|
|||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
constructor(
|
||||
@IAgentWireService private readonly wire: IWireService,
|
||||
@IWireService private readonly wire: IWireService,
|
||||
@IAgentSystemReminderService private readonly reminders: IAgentSystemReminderService,
|
||||
@IAgentContextMemoryService private readonly context: IAgentContextMemoryService,
|
||||
@IEventBus private readonly eventBus: IEventBus,
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
* carries no non-determinism. The live `ManagedTask` (the running process, its
|
||||
* `AbortController`, output ring, timers) stays OUT of the Model (live-only);
|
||||
* the Model is the restore seed for `ghosts`, applied by the service's single
|
||||
* `wire.onRestored` handler before disk load + reconcile. The Ops are
|
||||
* `wire.hooks.onDidRestore` hook before disk load + reconcile. The Ops are
|
||||
* live-only because task records are not v1 wire types; the durable registry
|
||||
* lives in `AgentTaskPersistence` and is reconciled on resume. Consumed by the
|
||||
* Agent-scope `taskService`.
|
||||
|
|
|
|||
|
|
@ -9,10 +9,10 @@
|
|||
* session-level task root without writing back to it, reads
|
||||
* limits through `config`, records lifecycle and broadcasts through `wire`
|
||||
* (`task.started` / `task.terminated` Ops into `TaskModel`, plus the matching
|
||||
* signals), restores ghosts through a single `wire.onRestored` handler (wire
|
||||
* replay -> disk load -> reconcile, in that order), delivers live terminal
|
||||
* notifications by enqueueing `TaskNotificationStepRequest`s onto `loop` with
|
||||
* `activeOrNewTurn` admission (mid-turn ones fold into the active turn's
|
||||
* signals), restores ghosts through a single `wire.hooks.onDidRestore` hook
|
||||
* (wire replay -> disk load -> reconcile, in that order), delivers live
|
||||
* terminal notifications by enqueueing `TaskNotificationStepRequest`s onto
|
||||
* `loop` with `activeOrNewTurn` admission (mid-turn ones fold into the active turn's
|
||||
* following step; idle ones launch a fresh turn themselves, matching v1's
|
||||
* `turn.steer`, so the model consumes the notification without waiting for
|
||||
* the user), silently appends restored notifications through `contextMemory`,
|
||||
|
|
@ -58,12 +58,8 @@ import { ISessionContext } from '#/session/sessionContext/sessionContext';
|
|||
import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore';
|
||||
import { IFileSystemStorageService } from '#/persistence/interface/storage';
|
||||
import { ITelemetryService } from '#/app/telemetry/telemetry';
|
||||
import {
|
||||
IAgentWireRecordService,
|
||||
type PersistedWireRecord,
|
||||
} from '#/agent/wireRecord/wireRecord';
|
||||
import { IAgentWireService } from '#/wire/tokens';
|
||||
import type { IWireService } from '#/wire/wireService';
|
||||
import { defineModel } from '#/wire/model';
|
||||
import { IWireService } from '#/wire/wire';
|
||||
import {
|
||||
IAgentTaskService,
|
||||
type AgentTaskNotificationContext,
|
||||
|
|
@ -108,6 +104,21 @@ interface AgentTaskNotificationBuildContext {
|
|||
readonly notification: AgentTaskNotification;
|
||||
}
|
||||
|
||||
const TaskNotificationDeliveryModel = defineModel<readonly string[]>(
|
||||
'task.notificationDelivery',
|
||||
() => [],
|
||||
{
|
||||
reducers: {
|
||||
'context.append_message': (state, payload: { message?: unknown }) => {
|
||||
const origin = taskOriginFromMessage(payload.message);
|
||||
if (origin === undefined) return state;
|
||||
const key = notificationKey(origin);
|
||||
return state.includes(key) ? state : [...state, key];
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
interface ManagedTask {
|
||||
readonly taskId: string;
|
||||
readonly task: AgentTask | undefined;
|
||||
|
|
@ -215,8 +226,7 @@ export class AgentTaskService extends Disposable implements IAgentTaskService {
|
|||
@ISessionContext session: ISessionContext,
|
||||
@IAgentScopeContext scopeContext: IAgentScopeContext,
|
||||
@ITaskService private readonly taskService: ITaskService,
|
||||
@IAgentWireRecordService wireRecord: IAgentWireRecordService,
|
||||
@IAgentWireService private readonly wire: IWireService,
|
||||
@IWireService private readonly wire: IWireService,
|
||||
@IEventBus private readonly eventBus: IEventBus,
|
||||
@IAgentContextInjectorService injector: IAgentContextInjectorService,
|
||||
@IAgentLoopService private readonly loop: IAgentLoopService,
|
||||
|
|
@ -234,11 +244,12 @@ export class AgentTaskService extends Disposable implements IAgentTaskService {
|
|||
fallbackRoot,
|
||||
);
|
||||
this._register(
|
||||
this.wire.onRestored(async () => {
|
||||
for (const record of wireRecord.getRecords()) {
|
||||
this.markDeliveredNotificationsFromRecord(record);
|
||||
this.wire.hooks.onDidRestore.register('task', async (_ctx, next) => {
|
||||
for (const key of this.wire.getModel(TaskNotificationDeliveryModel)) {
|
||||
this.deliveredNotificationKeys.add(key);
|
||||
}
|
||||
await this.restoreAfterReplay();
|
||||
await next();
|
||||
}),
|
||||
);
|
||||
this._register(
|
||||
|
|
@ -281,12 +292,6 @@ export class AgentTaskService extends Disposable implements IAgentTaskService {
|
|||
}
|
||||
}
|
||||
|
||||
private markDeliveredNotificationsFromRecord(record: PersistedWireRecord): void {
|
||||
for (const origin of taskOriginsFromRecord(record)) {
|
||||
this.markDeliveredNotification(origin);
|
||||
}
|
||||
}
|
||||
|
||||
registerTask(task: AgentTask, options: RegisterAgentTaskOptions = {}): string {
|
||||
const detached = options.detached ?? true;
|
||||
const timeoutMs = options.timeoutMs ?? task.timeoutMs;
|
||||
|
|
@ -1214,21 +1219,10 @@ function notificationKey(origin: TaskNotificationOrigin): string {
|
|||
return `${origin.taskId}\0${origin.status}\0${origin.notificationId}`;
|
||||
}
|
||||
|
||||
function taskOriginsFromRecord(record: PersistedWireRecord): readonly TaskNotificationOrigin[] {
|
||||
const raw = record as {
|
||||
readonly type: string;
|
||||
readonly message?: unknown;
|
||||
};
|
||||
if (raw.type === 'context.append_message') {
|
||||
return taskOriginFromMessage(raw.message);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function taskOriginFromMessage(message: unknown): readonly TaskNotificationOrigin[] {
|
||||
if (typeof message !== 'object' || message === null) return [];
|
||||
function taskOriginFromMessage(message: unknown): TaskNotificationOrigin | undefined {
|
||||
if (typeof message !== 'object' || message === null) return undefined;
|
||||
const origin = (message as { readonly origin?: unknown }).origin;
|
||||
return isTaskOrigin(origin) ? [origin] : [];
|
||||
return isTaskOrigin(origin) ? origin : undefined;
|
||||
}
|
||||
|
||||
function buildAgentTaskNotificationBody(info: AgentTaskInfo): string {
|
||||
|
|
|
|||
|
|
@ -15,9 +15,9 @@
|
|||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import type { AgentPhase } from '@moonshot-ai/protocol';
|
||||
|
||||
import { addUsage, type TokenUsage } from '#/app/llmProtocol/usage';
|
||||
import type { AgentPhase } from '#/agent/runtime/runtime';
|
||||
import { defineModel } from '#/wire/model';
|
||||
|
||||
import type { UsageStatus } from './usage';
|
||||
|
|
|
|||
|
|
@ -19,8 +19,7 @@ import { Emitter, type Event } from '#/_base/event';
|
|||
|
||||
import type { LLMRequestSource } from '#/agent/llmRequester/llmRequester';
|
||||
import { IEventBus } from '#/app/event/eventBus';
|
||||
import { IAgentWireService } from '#/wire/tokens';
|
||||
import type { IWireService } from '#/wire/wireService';
|
||||
import { IWireService } from '#/wire/wire';
|
||||
import type { UsageRecordedContext, UsageStatus } from './usage';
|
||||
import { IAgentUsageService } from './usage';
|
||||
import {
|
||||
|
|
@ -41,7 +40,7 @@ export class AgentUsageService extends Disposable implements IAgentUsageService
|
|||
private currentTurn: TokenUsage | undefined;
|
||||
|
||||
constructor(
|
||||
@IAgentWireService private readonly wire: IWireService,
|
||||
@IWireService private readonly wire: IWireService,
|
||||
@IEventBus private readonly eventBus?: IEventBus,
|
||||
) {
|
||||
super();
|
||||
|
|
|
|||
|
|
@ -13,8 +13,9 @@
|
|||
* reference-equality gate stays quiet. The side effects — `registry.register`
|
||||
* and `profile.addActiveTool` (and the matching dispose / `removeActiveTool`) —
|
||||
* are NOT part of `apply`: they run after `wire.dispatch` on the live path and
|
||||
* are re-derived from the rebuilt Model by `wire.onRestored` after replay, so a
|
||||
* resumed agent re-registers exactly the tools the persisted ops describe.
|
||||
* are re-derived from the rebuilt Model by `wire.hooks.onDidRestore` after
|
||||
* restore, so a resumed agent re-registers exactly the tools the persisted ops
|
||||
* describe.
|
||||
* Consumed by the Agent-scope `userToolService`.
|
||||
*/
|
||||
|
||||
|
|
|
|||
|
|
@ -7,8 +7,9 @@
|
|||
* (`wire.dispatch(...)`). The live side effects — `registry.register` +
|
||||
* `profile.addActiveTool` (and the matching dispose / `removeActiveTool`) — run
|
||||
* after the dispatch, and are re-derived from the rebuilt Model by
|
||||
* `wire.onRestored` after `wire.replay`, so a resumed agent re-registers exactly
|
||||
* the tools the persisted ops describe without re-firing any live notification.
|
||||
* `wire.hooks.onDidRestore` after `wire.restore`, so a resumed agent re-registers
|
||||
* exactly the tools the persisted ops describe without re-firing any live
|
||||
* notification.
|
||||
* The restore re-registers into the tool registry only: the active-tool set is
|
||||
* owned by the persisted `ActiveToolsModel`, so the ephemeral `addActiveTool`
|
||||
* overlay is not rebuilt (it is live-only by design). The per-tool
|
||||
|
|
@ -28,8 +29,7 @@ import type {
|
|||
} from '#/tool/toolContract';
|
||||
import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry';
|
||||
import { ISessionInteractionService } from '#/session/interaction/interaction';
|
||||
import { IAgentWireService } from '#/wire/tokens';
|
||||
import type { IWireService } from '#/wire/wireService';
|
||||
import { IWireService } from '#/wire/wire';
|
||||
|
||||
import { IAgentUserToolService, type UserToolRegistration } from './userTool';
|
||||
import { registerUserTool, unregisterUserTool, UserToolModel } from './userToolOps';
|
||||
|
|
@ -50,10 +50,15 @@ export class AgentUserToolService extends Disposable implements IAgentUserToolSe
|
|||
@IAgentToolRegistryService private readonly registry: IAgentToolRegistryService,
|
||||
@IAgentProfileService private readonly profile: IAgentProfileService,
|
||||
@ISessionInteractionService private readonly interaction: ISessionInteractionService,
|
||||
@IAgentWireService private readonly wire: IWireService,
|
||||
@IWireService private readonly wire: IWireService,
|
||||
) {
|
||||
super();
|
||||
this._register(this.wire.onRestored(() => this.restoreRegisteredTools()));
|
||||
this._register(
|
||||
this.wire.hooks.onDidRestore.register('user-tool', async (_ctx, next) => {
|
||||
this.restoreRegisteredTools();
|
||||
await next();
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
list(): readonly UserToolRegistration[] {
|
||||
|
|
|
|||
|
|
@ -1,44 +0,0 @@
|
|||
/**
|
||||
* `wireRecord` domain (L2), Agent scope — the `IAgentWireService` binding.
|
||||
*
|
||||
* Thin Agent-scope adapter over the scope-agnostic `WireService`: derives the
|
||||
* persistence addressing (`logScope` / `logKey`) from `IAgentScopeContext`
|
||||
* instead of receiving it as constructor options, so no per-agent scope seed
|
||||
* is required. `WireService` itself stays scope-agnostic; a future
|
||||
* Session-scope wire binds the same way.
|
||||
*/
|
||||
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import { IAgentBlobService } from '#/agent/blob/agentBlobService';
|
||||
import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext';
|
||||
import { IEventBus } from '#/app/event/eventBus';
|
||||
import { IAppendLogStore } from '#/persistence/interface/appendLogStore';
|
||||
import { IAgentWireService } from '#/wire/tokens';
|
||||
import { WireService } from '#/wire/wireServiceImpl';
|
||||
|
||||
import { WIRE_RECORD_FILENAME } from './wireRecordService';
|
||||
|
||||
export class AgentWireService extends WireService {
|
||||
constructor(
|
||||
@IAgentScopeContext scopeContext: IAgentScopeContext,
|
||||
@IAppendLogStore log?: IAppendLogStore,
|
||||
@IAgentBlobService blobService?: IAgentBlobService,
|
||||
@IEventBus eventBus?: IEventBus,
|
||||
) {
|
||||
super(
|
||||
{ logScope: scopeContext.scope(), logKey: WIRE_RECORD_FILENAME },
|
||||
log,
|
||||
blobService,
|
||||
eventBus,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
registerScopedService(
|
||||
LifecycleScope.Agent,
|
||||
IAgentWireService,
|
||||
AgentWireService,
|
||||
InstantiationType.Eager,
|
||||
'wireRecord',
|
||||
);
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
/**
|
||||
* `wireRecord` domain error codes — record persistence failures.
|
||||
*/
|
||||
|
||||
import { registerErrorDomain, type ErrorDomain } from '#/_base/errors/codes';
|
||||
|
||||
export const WireRecordErrors = {
|
||||
codes: {
|
||||
RECORDS_WRITE_FAILED: 'records.write_failed',
|
||||
},
|
||||
} as const satisfies ErrorDomain;
|
||||
|
||||
registerErrorDomain(WireRecordErrors);
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
/**
|
||||
* `wireRecord` domain (L6) — wire-log metadata envelope op.
|
||||
*
|
||||
* Declares a marker-only wire Model and the `metadata` Op whose flattened
|
||||
* record carries the wire-protocol envelope (`protocol_version`, `created_at`)
|
||||
* as the first record of each agent `wire.jsonl`. It is the only persisted
|
||||
* record that opts out of the `time` stamp, matching v1. Defined through the
|
||||
* low-level `wire` registry so `WireService` can persist the envelope through
|
||||
* the same append path as every other Op. `metadataRecord()` is the single
|
||||
* shared factory for the envelope — restore-time healing and fork-time log
|
||||
* copies both use it instead of hand-rolling the shape. Scope-agnostic.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import { defineModel } from '#/wire/model';
|
||||
import {
|
||||
AGENT_WIRE_PROTOCOL_VERSION,
|
||||
} from '#/agent/wireRecord/migration/migration';
|
||||
import type { WireRecordMetadata } from './wireRecord';
|
||||
|
||||
const MetadataModel = defineModel<null>('wire.metadata', () => null);
|
||||
|
||||
declare module '#/wire/types' {
|
||||
interface PersistedOpMap {
|
||||
metadata: typeof wireMetadata;
|
||||
}
|
||||
}
|
||||
|
||||
export const wireMetadata = MetadataModel.defineOp('metadata', {
|
||||
schema: z.object({ protocol_version: z.string(), created_at: z.number() }),
|
||||
stamp: false,
|
||||
apply: (s) => s,
|
||||
});
|
||||
|
||||
/** A fresh metadata envelope stamped at the current protocol version. */
|
||||
export function metadataRecord(): WireRecordMetadata {
|
||||
return {
|
||||
type: 'metadata',
|
||||
protocol_version: AGENT_WIRE_PROTOCOL_VERSION,
|
||||
created_at: Date.now(),
|
||||
};
|
||||
}
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
/**
|
||||
* `wireRecord` contract (L6) — the persisted wire journal's public surface.
|
||||
*
|
||||
* Defines the on-disk record vocabulary (the `metadata` envelope and the
|
||||
* migration records) and `IAgentWireRecordService`. `seal` starts a fresh log
|
||||
* with the `metadata` envelope at agent creation (a no-op once any record
|
||||
* exists) so released v1 builds — whose replay hard-rejects a non-empty log
|
||||
* lacking the envelope — can read sessions on a shared `KIMI_CODE_HOME`;
|
||||
* legacy envelope-less logs are healed by `restore`, never by `seal`. Bound
|
||||
* at Agent scope.
|
||||
*/
|
||||
|
||||
import { createDecorator } from '#/_base/di/instantiation';
|
||||
|
||||
import type { WireMigrationRecord } from '#/agent/wireRecord/migration/migration';
|
||||
|
||||
export * from '#/agent/wireRecord/migration/migration';
|
||||
|
||||
export interface WireRecordMetadata {
|
||||
readonly type: 'metadata';
|
||||
readonly protocol_version: string;
|
||||
readonly created_at: number;
|
||||
readonly time?: number;
|
||||
}
|
||||
|
||||
export type PersistedWireRecord = WireRecordMetadata | WireMigrationRecord;
|
||||
|
||||
export interface WireRecordRestoreOptions {
|
||||
readonly rewriteMigratedRecords?: boolean;
|
||||
}
|
||||
|
||||
export interface WireRecordRestoreResult {
|
||||
readonly warning?: string;
|
||||
}
|
||||
|
||||
export interface IAgentWireRecordService {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
||||
seal(): Promise<void>;
|
||||
getRecords(): readonly PersistedWireRecord[];
|
||||
restore(
|
||||
records?: readonly PersistedWireRecord[],
|
||||
options?: WireRecordRestoreOptions,
|
||||
): Promise<WireRecordRestoreResult>;
|
||||
flush(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export const IAgentWireRecordService = createDecorator<IAgentWireRecordService>('agentWireRecordService');
|
||||
|
|
@ -1,193 +0,0 @@
|
|||
/**
|
||||
* `wireRecord` domain (L2) — `IAgentWireRecordService` implementation.
|
||||
*
|
||||
* Restores and retains the owning agent's wire journal, applies protocol
|
||||
* migrations, rejects non-empty unversioned logs, and awaits durable atomic
|
||||
* rewrites before restore completes. Seals fresh logs with the `metadata`
|
||||
* envelope at creation (`seal`) so released v1 builds — whose replay
|
||||
* hard-rejects envelope-less logs — can read sessions on a shared
|
||||
* `KIMI_CODE_HOME`; legacy envelope-less logs are healed on `restore`.
|
||||
* Tracks live records through `wire`, uses `agent/scopeContext` for storage
|
||||
* addressing, and persists through the `appendLog` access-pattern store.
|
||||
* Bound at Agent scope.
|
||||
*/
|
||||
|
||||
import { relative } from 'pathe';
|
||||
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { Disposable } from '#/_base/di/lifecycle';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import { onUnexpectedError } from '#/_base/errors/unexpectedError';
|
||||
import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext';
|
||||
import { IAppendLogStore } from '#/persistence/interface/appendLogStore';
|
||||
import { IAgentWireService } from '#/wire/tokens';
|
||||
import type { IWireService } from '#/wire/wireService';
|
||||
import {
|
||||
AGENT_WIRE_PROTOCOL_VERSION,
|
||||
applyWireMigrations,
|
||||
isNewerWireVersion,
|
||||
resolveWireMigrations,
|
||||
type WireMigration,
|
||||
type WireMigrationRecord,
|
||||
} from '#/agent/wireRecord/migration/migration';
|
||||
import { metadataRecord } from './metadataOps';
|
||||
import {
|
||||
IAgentWireRecordService,
|
||||
type PersistedWireRecord,
|
||||
type WireRecordMetadata,
|
||||
type WireRecordRestoreOptions,
|
||||
type WireRecordRestoreResult,
|
||||
} from './wireRecord';
|
||||
|
||||
export class AgentWireRecordService extends Disposable implements IAgentWireRecordService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
private readonly records: PersistedWireRecord[] = [];
|
||||
private readonly wireScope: string;
|
||||
|
||||
constructor(
|
||||
@IAgentScopeContext scopeContext: IAgentScopeContext,
|
||||
@IAppendLogStore private readonly log?: IAppendLogStore,
|
||||
@IAgentWireService private readonly wire?: IWireService,
|
||||
) {
|
||||
super();
|
||||
this.wireScope = scopeContext.scope();
|
||||
if (this.log !== undefined) {
|
||||
this._register(this.log.acquire(this.wireScope, WIRE_RECORD_FILENAME));
|
||||
}
|
||||
if (wire !== undefined) {
|
||||
this._register(
|
||||
wire.onEmission((emission) => {
|
||||
if (emission.type === 'record' && emission.record.type !== 'metadata') {
|
||||
this.records.push(emission.record as PersistedWireRecord);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
getRecords(): readonly PersistedWireRecord[] {
|
||||
return [...this.records];
|
||||
}
|
||||
|
||||
async seal(): Promise<void> {
|
||||
if (this.log === undefined) return;
|
||||
if (await hasAnyRecord(this.log, this.wireScope, WIRE_RECORD_FILENAME)) return;
|
||||
this.log.append(this.wireScope, WIRE_RECORD_FILENAME, metadataRecord(), {
|
||||
onError: onUnexpectedError,
|
||||
});
|
||||
}
|
||||
|
||||
async restore(
|
||||
records?: readonly PersistedWireRecord[],
|
||||
options: WireRecordRestoreOptions = {},
|
||||
): Promise<WireRecordRestoreResult> {
|
||||
const fromPersistence = records === undefined;
|
||||
const source =
|
||||
records ??
|
||||
(this.log !== undefined
|
||||
? this.log.read<PersistedWireRecord>(this.wireScope, WIRE_RECORD_FILENAME)
|
||||
: undefined);
|
||||
if (source === undefined) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const rewriteMigratedRecords =
|
||||
fromPersistence && (options.rewriteMigratedRecords ?? true);
|
||||
const restoredRecords: PersistedWireRecord[] | undefined =
|
||||
rewriteMigratedRecords ? [] : undefined;
|
||||
let migrations: readonly WireMigration[] = [];
|
||||
let shouldRewrite = false;
|
||||
let warning: string | undefined;
|
||||
|
||||
const collected: PersistedWireRecord[] = [];
|
||||
for await (const record of source) {
|
||||
collected.push(record);
|
||||
}
|
||||
|
||||
let sourceRecords = collected;
|
||||
const firstRecord = sourceRecords[0];
|
||||
if (firstRecord !== undefined) {
|
||||
if (firstRecord.type !== 'metadata') {
|
||||
// Envelope-less log: a fresh agent (creation no longer seals the log)
|
||||
// or a pre-envelope legacy log. Heal it in place: synthesize the
|
||||
// envelope at the current protocol version — records written by
|
||||
// current builds need no migration — and rewrite so the invariant
|
||||
// holds from now on.
|
||||
sourceRecords = [metadataRecord(), ...sourceRecords];
|
||||
shouldRewrite = fromPersistence;
|
||||
} else {
|
||||
if (!isWireRecordMetadata(firstRecord)) {
|
||||
throw new Error('WireRecord restore expected metadata protocol_version');
|
||||
}
|
||||
const readVersion = firstRecord.protocol_version;
|
||||
if (isNewerWireVersion(readVersion)) {
|
||||
warning = `Session wire protocol version ${readVersion} is newer than the current version ${AGENT_WIRE_PROTOCOL_VERSION}. Records will be restored without migration.`;
|
||||
shouldRewrite = false;
|
||||
} else {
|
||||
migrations = resolveWireMigrations(readVersion);
|
||||
shouldRewrite = readVersion !== AGENT_WIRE_PROTOCOL_VERSION;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const migratedRecords = applyWireMigrations(
|
||||
sourceRecords as WireMigrationRecord[],
|
||||
migrations,
|
||||
) as PersistedWireRecord[];
|
||||
for (let migratedRecord of migratedRecords) {
|
||||
if (migratedRecord.type === 'metadata') {
|
||||
migratedRecord = {
|
||||
...migratedRecord,
|
||||
protocol_version: AGENT_WIRE_PROTOCOL_VERSION,
|
||||
};
|
||||
}
|
||||
restoredRecords?.push(migratedRecord);
|
||||
if (migratedRecord.type === 'metadata') continue;
|
||||
this.records.push(migratedRecord);
|
||||
}
|
||||
|
||||
if (shouldRewrite && restoredRecords !== undefined && this.log !== undefined) {
|
||||
await this.log.rewrite(this.wireScope, WIRE_RECORD_FILENAME, restoredRecords);
|
||||
}
|
||||
return warning === undefined ? {} : { warning };
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
await this.wire?.flush();
|
||||
await this.log?.flush();
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.log?.close();
|
||||
}
|
||||
}
|
||||
|
||||
registerScopedService(
|
||||
LifecycleScope.Agent,
|
||||
IAgentWireRecordService,
|
||||
AgentWireRecordService,
|
||||
InstantiationType.Eager,
|
||||
'wireRecord',
|
||||
);
|
||||
|
||||
function isWireRecordMetadata(record: PersistedWireRecord): record is WireRecordMetadata {
|
||||
return record.type === 'metadata' && typeof record['protocol_version'] === 'string';
|
||||
}
|
||||
|
||||
async function hasAnyRecord(log: IAppendLogStore, scope: string, key: string): Promise<boolean> {
|
||||
for await (const record of log.read(scope, key)) {
|
||||
void record;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export const WIRE_RECORD_FILENAME = 'wire.jsonl';
|
||||
|
||||
export function missingWireMetadataError(): Error {
|
||||
return new Error('WireRecord restore expected metadata as the first record');
|
||||
}
|
||||
|
||||
export function wireRecordScope(homedir: string, homeDir: string): string {
|
||||
return relative(homeDir, homedir);
|
||||
}
|
||||
|
|
@ -7,8 +7,7 @@
|
|||
* `publish(event)` and consumers `subscribe(handler)` (all events) or
|
||||
* `subscribe(type, handler)` (one type). It is bound at Agent scope — one
|
||||
* instance per agent — so a subscription sees only that agent's events (the
|
||||
* server fans out per agent and tags `agentId` / `sessionId`, exactly like the
|
||||
* former `IAgentWireService.onEmission`). Process-global events (model catalog,
|
||||
* server fans out per agent and tags `agentId` / `sessionId`). Process-global events (model catalog,
|
||||
* session lifecycle, auth) stay on the legacy `IEventService` (`./event`),
|
||||
* which is retained as the global channel; its payload type is re-exported from
|
||||
* the barrel as `GlobalEvent`. Domains declare their agent-event shapes by
|
||||
|
|
|
|||
|
|
@ -7,14 +7,12 @@
|
|||
* The native `IAgentContextMemoryService` (Agent scope, serving `/api/v2`
|
||||
* `messages:*`) holds the model's CURRENT, folded context and is NOT the full
|
||||
* transcript: after a compaction it collapses into `[...keptUserMessages,
|
||||
* compaction_summary]`. The full transcript is reduced from the main agent's
|
||||
* in-memory record journal (`IAgentWireRecordService.getRecords()`), which
|
||||
* `ISessionLifecycleService.resume` seeds from `wire.jsonl` and live dispatch
|
||||
* then keeps current — so neither a live nor a cold session is read back from
|
||||
* disk here. The `ContextMessage → Message` projection is shared with the
|
||||
* `snapshot` and `:undo` edges via `contextMemory/messageProjection`. Bound at
|
||||
* App scope — a stateless dispatcher that resolves the target session/agent per
|
||||
* call.
|
||||
* compaction_summary]`. The full transcript is reduced on demand by streaming
|
||||
* the main agent's `wire.jsonl`; the service does not make every live Agent
|
||||
* retain its raw journal in memory. The `ContextMessage → Message` projection
|
||||
* is shared with the `snapshot` and `:undo` edges via
|
||||
* `contextMemory/messageProjection`. Bound at App scope — a stateless
|
||||
* dispatcher that resolves the target session/agent per call.
|
||||
*
|
||||
* Error contract (mapped at the route layer):
|
||||
* - `session.not_found` → 40401
|
||||
|
|
|
|||
|
|
@ -5,13 +5,10 @@
|
|||
* its main agent), sources the transcript, and projects it into the v1 wire
|
||||
* shape.
|
||||
*
|
||||
* History source is the main agent's in-memory record journal
|
||||
* (`IAgentWireRecordService.getRecords()`), seeded from `wire.jsonl` by
|
||||
* `ISessionLifecycleService.resume` and then kept current as live dispatch
|
||||
* appends each record — so a transcript read never re-reads the file. The
|
||||
* journal is reduced by `reduceContextTranscript` (the same reducer v1's
|
||||
* `MessageService` uses), which keeps the full history across compactions
|
||||
* (inserting a summary marker instead of folding) — unlike the live
|
||||
* History is streamed from the main agent's append log after its pending wire
|
||||
* writes are flushed. The journal is folded incrementally by the same
|
||||
* transcript reducer v1's `MessageService` uses, keeping full history across
|
||||
* compactions (inserting a summary marker instead of folding) — unlike the live
|
||||
* `IAgentContextMemoryService.get()`, whose folded context collapses into
|
||||
* `[...keptUserMessages, compaction_summary]` and would lose the prefix.
|
||||
* `foldedLength` is what the live history length WOULD be from the journal's
|
||||
|
|
@ -28,17 +25,19 @@ import { type IAgentScopeHandle, LifecycleScope, registerScopedService } from '#
|
|||
import { IAgentBlobService } from '#/agent/blob/agentBlobService';
|
||||
import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory';
|
||||
import {
|
||||
reduceContextTranscript,
|
||||
createContextTranscriptReducer,
|
||||
type ContextTranscript,
|
||||
} from '#/agent/contextMemory/contextTranscript';
|
||||
import { toProtocolMessage } from '#/agent/contextMemory/messageProjection';
|
||||
import type { ContextMessage } from '#/agent/contextMemory/types';
|
||||
import { IAgentWireRecordService } from '#/agent/wireRecord/wireRecord';
|
||||
import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext';
|
||||
import { IAppendLogStore } from '#/persistence/interface/appendLogStore';
|
||||
import { IWireService } from '#/wire/wire';
|
||||
import { AGENT_WIRE_RECORD_KEY, type WireRecord } from '#/wire/record';
|
||||
import { ISessionIndex } from '#/app/sessionIndex/sessionIndex';
|
||||
import { ISessionLifecycleService } from '#/app/sessionLifecycle/sessionLifecycle';
|
||||
import { ErrorCodes, Error2 } from '#/errors';
|
||||
import { ensureMainAgent } from '#/session/agentLifecycle/mainAgent';
|
||||
import type { PersistedRecord } from '#/wire/wireService';
|
||||
|
||||
import { IMessageLegacyService, type MessageListQuery } from './messageLegacy';
|
||||
|
||||
|
|
@ -51,6 +50,7 @@ export class MessageLegacyService implements IMessageLegacyService {
|
|||
constructor(
|
||||
@ISessionLifecycleService private readonly lifecycle: ISessionLifecycleService,
|
||||
@ISessionIndex private readonly index: ISessionIndex,
|
||||
@IAppendLogStore private readonly appendLog: IAppendLogStore,
|
||||
) {}
|
||||
|
||||
async list(sessionId: string, query: MessageListQuery): Promise<PageResponse<Message>> {
|
||||
|
|
@ -105,7 +105,7 @@ export class MessageLegacyService implements IMessageLegacyService {
|
|||
if (session === undefined) return [];
|
||||
const agent = await ensureMainAgent(session);
|
||||
|
||||
const transcript = this.readTranscript(agent);
|
||||
const transcript = await this.readTranscript(agent);
|
||||
const contextMessages = agent.accessor.get(IAgentContextMemoryService).get();
|
||||
const merged = mergeLiveTail(transcript, contextMessages);
|
||||
const entries = await this.rehydrate(agent, merged.messages);
|
||||
|
|
@ -143,11 +143,14 @@ export class MessageLegacyService implements IMessageLegacyService {
|
|||
return changed ? out : messages;
|
||||
}
|
||||
|
||||
private readTranscript(agent: IAgentScopeHandle): ContextTranscript {
|
||||
const records = agent
|
||||
.accessor.get(IAgentWireRecordService)
|
||||
.getRecords() as readonly PersistedRecord[];
|
||||
return reduceContextTranscript(records);
|
||||
private async readTranscript(agent: IAgentScopeHandle): Promise<ContextTranscript> {
|
||||
await agent.accessor.get(IWireService).flush();
|
||||
const scope = agent.accessor.get(IAgentScopeContext).scope();
|
||||
const reducer = createContextTranscriptReducer();
|
||||
for await (const record of this.appendLog.read<WireRecord>(scope, AGENT_WIRE_RECORD_KEY)) {
|
||||
reducer.add(record);
|
||||
}
|
||||
return reducer.result();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
* version facts, and wire-log activity timestamps discovered during export.
|
||||
*/
|
||||
|
||||
import { AGENT_WIRE_PROTOCOL_VERSION } from '#/agent/wireRecord/wireRecord';
|
||||
import { WIRE_PROTOCOL_VERSION } from '#/wire/migration/migration';
|
||||
|
||||
import type {
|
||||
ExportSessionManifest,
|
||||
|
|
@ -14,8 +14,6 @@ import type {
|
|||
} from './sessionExport';
|
||||
import type { SessionWireScan } from './wire-scan';
|
||||
|
||||
export const WIRE_PROTOCOL_VERSION = AGENT_WIRE_PROTOCOL_VERSION;
|
||||
|
||||
export interface ExportSessionManifestSummary {
|
||||
readonly id: string;
|
||||
readonly title?: string | undefined;
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import { InstantiationType } from '#/_base/di/extensions';
|
|||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import { ILogService } from '#/_base/log/log';
|
||||
import { resolveGlobalLogPath } from '#/_base/log/logConfig';
|
||||
import { IAgentWireRecordService } from '#/agent/wireRecord/wireRecord';
|
||||
import { IWireService } from '#/wire/wire';
|
||||
import { IBootstrapService } from '#/app/bootstrap/bootstrap';
|
||||
import { ISessionIndex, type SessionSummary } from '#/app/sessionIndex/sessionIndex';
|
||||
import { ISessionLifecycleService } from '#/app/sessionLifecycle/sessionLifecycle';
|
||||
|
|
@ -126,7 +126,7 @@ export class SessionExportService implements ISessionExportService {
|
|||
const agents = handle.accessor.get(IAgentLifecycleService);
|
||||
for (const agent of agents.list()) {
|
||||
await this.warnIfFails('export agent wire flush failed', () =>
|
||||
agent.accessor.get(IAgentWireRecordService).flush(),
|
||||
agent.accessor.get(IWireService).flush(),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,8 +13,8 @@
|
|||
* roots are remembered through `workspaceRegistry`. On create / fork the
|
||||
* session is also appended to the shared `session_index.jsonl` so v1 clients
|
||||
* (TUI, export) can discover sessions created by the v2 engine. Fork flushes
|
||||
* live agent logs and rejects non-empty logs without a protocol metadata
|
||||
* envelope instead of stamping legacy data as current.
|
||||
* live Agent wire journals, normalizes a missing protocol envelope, and
|
||||
* appends the fork boundary before restoring the target Agent.
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
|
@ -35,15 +35,8 @@ import { unwrapErrorCause } from '#/_base/errors/errors';
|
|||
import { Emitter, type Event } from '#/_base/event';
|
||||
import { encodeWorkDirKey } from '#/_base/utils/workdir-slug';
|
||||
import { ISessionActivityKernel } from '#/activity/activity';
|
||||
import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory';
|
||||
import { DEFAULT_PLAN_MODE_SECTION } from '#/agent/plan/configSection';
|
||||
import { IAgentPlanService } from '#/agent/plan/plan';
|
||||
import {
|
||||
IAgentWireRecordService,
|
||||
type PersistedWireRecord,
|
||||
} from '#/agent/wireRecord/wireRecord';
|
||||
import { metadataRecord } from '#/agent/wireRecord/metadataOps';
|
||||
import { WIRE_RECORD_FILENAME, wireRecordScope } from '#/agent/wireRecord/wireRecordService';
|
||||
import { IBootstrapService } from '#/app/bootstrap/bootstrap';
|
||||
import { CRON_SESSION_TAG, type CronTask } from '#/app/cron/cronTask';
|
||||
import { ICronTaskPersistence } from '#/app/cron/cronTaskPersistence';
|
||||
|
|
@ -74,8 +67,12 @@ import { ISessionCronService } from '#/session/cron/sessionCronService';
|
|||
import { ISessionMetadata, type SessionMeta } from '#/session/sessionMetadata/sessionMetadata';
|
||||
import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog';
|
||||
import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext';
|
||||
import { IAgentWireService } from '#/wire/tokens';
|
||||
import type { PersistedRecord } from '#/wire/wireService';
|
||||
import { IWireService } from '#/wire/wire';
|
||||
import {
|
||||
AGENT_WIRE_RECORD_KEY,
|
||||
createWireMetadataRecord,
|
||||
type WireRecord,
|
||||
} from '#/wire/record';
|
||||
|
||||
import {
|
||||
type CreateChildSessionOptions,
|
||||
|
|
@ -247,15 +244,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
|
|||
});
|
||||
const agents = handle.accessor.get(IAgentLifecycleService);
|
||||
if (agents.get(MAIN_AGENT_ID) === undefined) {
|
||||
const main = await agents.create({ agentId: MAIN_AGENT_ID });
|
||||
// Resolve context memory BEFORE restoring so its reducers are registered;
|
||||
// otherwise the wire replay applies context records into a void and the
|
||||
// restored transcript never lands in context memory.
|
||||
main.accessor.get(IAgentContextMemoryService);
|
||||
const mainWireRecord = main.accessor.get(IAgentWireRecordService);
|
||||
await mainWireRecord.restore();
|
||||
const records = mainWireRecord.getRecords() as readonly PersistedRecord[];
|
||||
await main.accessor.get(IAgentWireService).replay(...records);
|
||||
await agents.create({ agentId: MAIN_AGENT_ID });
|
||||
}
|
||||
await this.announceCreated({ sessionId, handle, source: 'resume' });
|
||||
return handle;
|
||||
|
|
@ -370,10 +359,10 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
|
|||
const sourceAgents = sourceMeta?.agents ?? {};
|
||||
const agentIds = Object.keys(sourceAgents);
|
||||
for (const agentId of agentIds) {
|
||||
const sourceHomedir = sourceAgents[agentId]!.homedir;
|
||||
await this.copyAgentWire({
|
||||
sourceHandle,
|
||||
sourceHomedir,
|
||||
sourceWorkspaceId: workspaceId,
|
||||
sourceSessionId: sourceId,
|
||||
agentId,
|
||||
targetWorkspaceId: targetCtx.workspaceId,
|
||||
targetSessionId: targetCtx.sessionId,
|
||||
|
|
@ -394,15 +383,11 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
|
|||
|
||||
for (const agentId of agentIds) {
|
||||
const sourceAgent = sourceAgents[agentId]!;
|
||||
const agentHandle = await target.accessor.get(IAgentLifecycleService).create({
|
||||
await target.accessor.get(IAgentLifecycleService).create({
|
||||
agentId,
|
||||
forkedFrom: sourceAgent.forkedFrom,
|
||||
labels: labelsFromAgentMeta(sourceAgent),
|
||||
});
|
||||
const forkWireRecord = agentHandle.accessor.get(IAgentWireRecordService);
|
||||
await forkWireRecord.restore();
|
||||
const forkRecords = forkWireRecord.getRecords() as readonly PersistedRecord[];
|
||||
await agentHandle.accessor.get(IAgentWireService).replay(...forkRecords);
|
||||
}
|
||||
|
||||
await this.appendSessionIndexEntry(targetId, workspace.root);
|
||||
|
|
@ -459,7 +444,8 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
|
|||
|
||||
private async copyAgentWire(args: {
|
||||
readonly sourceHandle: ISessionScopeHandle | undefined;
|
||||
readonly sourceHomedir: string;
|
||||
readonly sourceWorkspaceId: string;
|
||||
readonly sourceSessionId: string;
|
||||
readonly agentId: string;
|
||||
readonly targetWorkspaceId: string;
|
||||
readonly targetSessionId: string;
|
||||
|
|
@ -469,34 +455,34 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
|
|||
.get(IAgentLifecycleService)
|
||||
.get(args.agentId);
|
||||
if (agentHandle !== undefined) {
|
||||
await agentHandle.accessor.get(IAgentWireRecordService).flush();
|
||||
await agentHandle.accessor.get(IWireService).flush();
|
||||
}
|
||||
}
|
||||
|
||||
const records = await collect(
|
||||
this.appendLogStore.read<PersistedWireRecord>(
|
||||
wireRecordScope(args.sourceHomedir, this.bootstrap.homeDir),
|
||||
WIRE_RECORD_FILENAME,
|
||||
this.appendLogStore.read<WireRecord>(
|
||||
this.bootstrap.agentScope(
|
||||
args.sourceWorkspaceId,
|
||||
args.sourceSessionId,
|
||||
args.agentId,
|
||||
),
|
||||
AGENT_WIRE_RECORD_KEY,
|
||||
),
|
||||
);
|
||||
// Keep the copied log well-formed for the target's first restore: prepend
|
||||
// the metadata envelope when the source lacks one (restore() would heal it
|
||||
// anyway, but the forked copy should be valid on its own).
|
||||
if (records.length === 0) {
|
||||
records.push(metadataRecord());
|
||||
records.push(createWireMetadataRecord());
|
||||
} else if (records[0]?.type !== 'metadata') {
|
||||
records.unshift(metadataRecord());
|
||||
records.unshift(createWireMetadataRecord());
|
||||
}
|
||||
records.push(forkedRecord());
|
||||
|
||||
const targetHomedir = this.bootstrap.agentHomedir(
|
||||
args.targetWorkspaceId,
|
||||
args.targetSessionId,
|
||||
args.agentId,
|
||||
);
|
||||
await this.appendLogStore.rewrite(
|
||||
wireRecordScope(targetHomedir, this.bootstrap.homeDir),
|
||||
WIRE_RECORD_FILENAME,
|
||||
this.bootstrap.agentScope(
|
||||
args.targetWorkspaceId,
|
||||
args.targetSessionId,
|
||||
args.agentId,
|
||||
),
|
||||
AGENT_WIRE_RECORD_KEY,
|
||||
records,
|
||||
);
|
||||
}
|
||||
|
|
@ -520,7 +506,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
|
|||
): Promise<void> {
|
||||
for (const entry of entries) {
|
||||
const rel = relBase === '' ? entry.name : `${relBase}/${entry.name}`;
|
||||
if (rel === 'state.json' || rel === 'logs' || entry.name === WIRE_RECORD_FILENAME) {
|
||||
if (rel === 'state.json' || rel === 'logs' || entry.name === AGENT_WIRE_RECORD_KEY) {
|
||||
continue;
|
||||
}
|
||||
if (entry.isSymbolicLink === true) continue;
|
||||
|
|
@ -597,8 +583,8 @@ function createSessionId(): string {
|
|||
return `session_${randomUUID()}`;
|
||||
}
|
||||
|
||||
function forkedRecord(): PersistedWireRecord {
|
||||
return { type: 'forked', time: Date.now() } as PersistedWireRecord;
|
||||
function forkedRecord(): WireRecord {
|
||||
return { type: 'forked', time: Date.now() };
|
||||
}
|
||||
|
||||
function forkCustomMetadata(
|
||||
|
|
|
|||
|
|
@ -34,7 +34,6 @@ import { StorageErrors } from '#/persistence/interface/storage';
|
|||
import { TerminalErrors } from '#/os/interface/terminalErrors';
|
||||
import { UsageErrors } from '#/agent/usage/errors';
|
||||
import { WireErrors } from '#/wire/errors';
|
||||
import { WireRecordErrors } from '#/agent/wireRecord/errors';
|
||||
|
||||
export * from '#/_base/errors/codes';
|
||||
export * from '#/_base/errors/errorMessage';
|
||||
|
|
@ -67,7 +66,6 @@ export { StorageErrors } from '#/persistence/interface/storage';
|
|||
export { TerminalErrors } from '#/os/interface/terminalErrors';
|
||||
export { UsageErrors } from '#/agent/usage/errors';
|
||||
export { WireErrors } from '#/wire/errors';
|
||||
export { WireRecordErrors } from '#/agent/wireRecord/errors';
|
||||
|
||||
export const ErrorCodes = {
|
||||
...CoreErrors.codes,
|
||||
|
|
@ -97,5 +95,4 @@ export const ErrorCodes = {
|
|||
...TerminalErrors.codes,
|
||||
...UsageErrors.codes,
|
||||
...WireErrors.codes,
|
||||
...WireRecordErrors.codes,
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -19,9 +19,10 @@ export * from '#/_base/log/logConfig';
|
|||
export * from '#/_base/log/formatter';
|
||||
export * from '#/_base/log/fileLog';
|
||||
export * from '#/_base/log/logService';
|
||||
export { IAgentWireService, ISessionWireService } from '#/wire/tokens';
|
||||
export { type IWireService, type WireEmission } from '#/wire/wireService';
|
||||
export { defineDerivedModel, type DerivedModelDef } from '#/wire/model';
|
||||
export * from '#/wire/wire';
|
||||
export * from '#/wire/wireService';
|
||||
export * from '#/wire/record';
|
||||
export * from '#/wire/migration/migration';
|
||||
export * from '#/session/sessionLog/sessionLogService';
|
||||
export * from '#/app/telemetry/telemetry';
|
||||
export * from '#/app/telemetry/events';
|
||||
|
|
@ -189,9 +190,6 @@ export * from '#/agent/swarm/swarm';
|
|||
export * from '#/agent/swarm/swarmService';
|
||||
export * from '#/agent/usage/usage';
|
||||
export * from '#/agent/usage/usageService';
|
||||
export * from '#/agent/runtime/runtime';
|
||||
export * from '#/agent/runtime/runtimeOps';
|
||||
export * from '#/agent/runtime/runtimeService';
|
||||
export * from '#/agent/toolDedupe/toolDedupe';
|
||||
export * from '#/agent/toolDedupe/toolDedupeService';
|
||||
import '#/agent/toolSelect/flag';
|
||||
|
|
@ -425,7 +423,6 @@ export * from '#/agent/prompt/promptService';
|
|||
import '#/app/messageLegacy/errors';
|
||||
export * from '#/app/messageLegacy/messageLegacy';
|
||||
export * from '#/app/messageLegacy/messageLegacyService';
|
||||
export * from '#/agent/replayBuilder/replayTimelineModel';
|
||||
export * from '#/agent/replayBuilder/types';
|
||||
export * from '#/agent/shellCommand/shellCommand';
|
||||
export * from '#/agent/shellCommand/shellCommandService';
|
||||
|
|
@ -464,7 +461,3 @@ export type { ToolContribution, ToolContributionOptions } from '#/agent/toolRegi
|
|||
export * from '#/agent/userTool/userTool';
|
||||
export * from '#/agent/userTool/userToolOps';
|
||||
export * from '#/agent/userTool/userToolService';
|
||||
export * from '#/agent/wireRecord/wireRecord';
|
||||
export * from '#/agent/wireRecord/wireRecordService';
|
||||
export * from '#/agent/wireRecord/agentWireService';
|
||||
export * from '#/agent/wireRecord/metadataOps';
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
* It uses raw `node:fs` rather than `kaos`: the storage kernel needs direct
|
||||
* control over append offsets, fsync, atomic rename and streaming, which the
|
||||
* agent-execution-environment abstraction does not expose. Higher-level code
|
||||
* (`wireRecord`, `blobStore`) goes through the Store / Storage interfaces above
|
||||
* (wire journal, blob store) goes through the Store / Storage interfaces above
|
||||
* this backend, never `node:fs` directly.
|
||||
*/
|
||||
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import { IConfigService } from '#/app/config/config';
|
|||
import { IEventBus } from '#/app/event/eventBus';
|
||||
import { ErrorCodes, Error2 } from '#/errors';
|
||||
import { DEFAULT_PERMISSION_MODE_SECTION } from '#/agent/permissionMode/configSection';
|
||||
import { PermissionModeConfiguredModel } from '#/agent/permissionMode/permissionModeOps';
|
||||
import type { PermissionMode } from '#/agent/permissionPolicy/types';
|
||||
import { IAgentToolDedupeService } from '#/agent/toolDedupe/toolDedupe';
|
||||
import { IAgentTaskService } from '#/agent/task/task';
|
||||
|
|
@ -49,14 +50,19 @@ import { IAgentToolSelectService } from '#/agent/toolSelect/toolSelect';
|
|||
import { IAgentToolSelectAnnouncementsService } from '#/agent/toolSelect/toolSelectAnnouncements';
|
||||
import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode';
|
||||
import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory';
|
||||
import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector';
|
||||
import { IAgentFullCompactionService } from '#/agent/fullCompaction/fullCompaction';
|
||||
import { IAgentGoalService } from '#/agent/goal/goal';
|
||||
import { IAgentPlanService } from '#/agent/plan/plan';
|
||||
import { IAgentUserToolService } from '#/agent/userTool/userTool';
|
||||
import { IAgentBuiltinToolsRegistrar } from '#/agent/toolRegistry/builtinToolsRegistrar';
|
||||
import { IAgentMediaToolsRegistrar } from '#/agent/media/mediaTools';
|
||||
import { IImageConfigBridge } from '#/agent/media/imageConfigBridge';
|
||||
import { IAgentMcpService } from '#/agent/mcp/mcp';
|
||||
import { IAgentExternalHooksService } from '#/agent/externalHooks/externalHooks';
|
||||
import { IAgentPluginService } from '#/agent/plugin/agentPlugin';
|
||||
import { IAgentWireRecordService } from '#/agent/wireRecord/wireRecord';
|
||||
import { ISessionInteractionService } from '#/session/interaction/interaction';
|
||||
import { IWireService } from '#/wire/wire';
|
||||
import {
|
||||
type AgentListFilter,
|
||||
type CreateAgentOptions,
|
||||
|
|
@ -144,9 +150,6 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle
|
|||
|
||||
private async doCreate(agentId: string, opts: CreateAgentOptions): Promise<IAgentScopeHandle> {
|
||||
const mcpReady = this.sessionMcp.ensureMcpReady();
|
||||
// Per-agent homedir → the wire-record persistence key (`hashKey(homedir)`).
|
||||
// Bootstrap computes it under the session dir, mirroring v1's
|
||||
// `<sessionDir>/agents/<id>`; business code never assembles the path itself.
|
||||
const agentHomedir = this.bootstrap.agentHomedir(
|
||||
this.ctx.workspaceId,
|
||||
this.ctx.sessionId,
|
||||
|
|
@ -163,13 +166,14 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle
|
|||
agentId,
|
||||
// The only per-agent seed: identity facts. Every other agent-scope
|
||||
// service either derives its configuration from `IAgentScopeContext`
|
||||
// (wire, wireRecord, blob) or resolves it through the scope tree (the
|
||||
// (wire, blob) or resolves it through the scope tree (the
|
||||
// session's shared MCP manager via `ISessionMcpService`).
|
||||
{ extra: [[IAgentScopeContext, makeAgentScopeContext({ agentId, agentScope })]] },
|
||||
) as IAgentScopeHandle;
|
||||
this.handles.set(agentId, handle);
|
||||
try {
|
||||
await handle.accessor.get(IAgentWireRecordService).seal();
|
||||
const wire = handle.accessor.get(IWireService);
|
||||
await wire.seal();
|
||||
await this.sessionMetadata.registerAgent(agentId, {
|
||||
homedir: agentHomedir,
|
||||
type: agentId === 'main' ? 'main' : 'sub',
|
||||
|
|
@ -180,6 +184,7 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle
|
|||
this.onDidCreateEmitter.fire(handle);
|
||||
this.igniteEagerServices(handle);
|
||||
await mcpReady;
|
||||
await wire.restore();
|
||||
await this.bindBootstrap(handle, opts);
|
||||
// Bootstrap (profile binding and the force-instantiated observer
|
||||
// services) is complete: drive the activity kernel `initializing → idle`
|
||||
|
|
@ -232,6 +237,13 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle
|
|||
handle.accessor.get(IAgentToolSelectAnnouncementsService);
|
||||
handle.accessor.get(IAgentStepRetryService);
|
||||
handle.accessor.get(IAgentLoopContinuationService);
|
||||
handle.accessor.get(IAgentContextMemoryService);
|
||||
handle.accessor.get(IAgentContextInjectorService);
|
||||
handle.accessor.get(IAgentGoalService);
|
||||
handle.accessor.get(IAgentPlanService);
|
||||
handle.accessor.get(IAgentTaskService);
|
||||
handle.accessor.get(IAgentUserToolService);
|
||||
handle.accessor.get(IAgentFullCompactionService);
|
||||
}
|
||||
|
||||
private async bindBootstrap(
|
||||
|
|
@ -241,12 +253,14 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle
|
|||
if (opts.binding !== undefined) {
|
||||
await handle.accessor.get(IAgentProfileService).bind(opts.binding);
|
||||
}
|
||||
// Every fresh agent starts from the configured default permission posture;
|
||||
// dispatchers that want a specific mode (subagent inheritance) set it on
|
||||
// the child themselves after creation. On resume the wire replay
|
||||
// overwrites this with the persisted mode.
|
||||
// Apply the configured default only when restore found no persisted mode.
|
||||
// A resumed Agent's journal owns its permission posture; callers that need
|
||||
// an explicit override (for example subagent inheritance) do so after
|
||||
// creation through the permission service.
|
||||
const wire = handle.accessor.get(IWireService);
|
||||
const permissionMode = this.config.get<PermissionMode>(DEFAULT_PERMISSION_MODE_SECTION);
|
||||
if (permissionMode !== undefined) {
|
||||
const hasRestoredPermissionMode = wire.getModel(PermissionModeConfiguredModel);
|
||||
if (permissionMode !== undefined && !hasRestoredPermissionMode) {
|
||||
handle.accessor.get(IAgentPermissionModeService).setMode(permissionMode);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
* (tick / coalesce / jitter / cursor), persists mutations through the
|
||||
* App-scoped `ICronTaskPersistence`, mirrors mutations as `cron.add` /
|
||||
* `cron.delete` / `cron.cursor` Ops on the main agent's `wire` (cross-scope
|
||||
* borrow) so `wire.replay` can rebuild the `CronModel`, publishes `cron.fired`
|
||||
* borrow) so wire restore can rebuild the `CronModel`, publishes `cron.fired`
|
||||
* to the main agent's `IEventBus`, steers the main agent
|
||||
* through `IAgentPromptService` when a task fires, and registers the cron
|
||||
* tools (`CronCreate` / `CronList` / `CronDelete`) into the main agent's
|
||||
|
|
@ -39,7 +39,7 @@ import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'
|
|||
import type { ContextMessage } from '#/agent/contextMemory/types';
|
||||
import { IAgentPromptService } from '#/agent/prompt/prompt';
|
||||
import type { Op } from '#/wire/op';
|
||||
import { IAgentWireService } from '#/wire/tokens';
|
||||
import { IWireService } from '#/wire/wire';
|
||||
import { type DomainEvent, IEventBus } from '#/app/event/eventBus';
|
||||
import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry';
|
||||
import { IAgentLoopService, type Turn } from '#/agent/loop/loop';
|
||||
|
|
@ -91,13 +91,13 @@ export class SessionCronServiceImpl extends Disposable implements ISessionCronSe
|
|||
this._register(
|
||||
this.agentLifecycle.onDidCreate((handle) => {
|
||||
if (handle.id !== 'main') return;
|
||||
void this.bindMainAgent(handle);
|
||||
this.bindMainAgent(handle);
|
||||
}),
|
||||
);
|
||||
|
||||
const existingMain = this.agentLifecycle.get('main');
|
||||
if (existingMain) {
|
||||
void this.bindMainAgent(existingMain);
|
||||
this.bindMainAgent(existingMain);
|
||||
}
|
||||
|
||||
this._register(
|
||||
|
|
@ -107,24 +107,23 @@ export class SessionCronServiceImpl extends Disposable implements ISessionCronSe
|
|||
);
|
||||
}
|
||||
|
||||
private async bindMainAgent(handle: IAgentScopeHandle): Promise<void> {
|
||||
await this.config.ready;
|
||||
this.resolveClocks();
|
||||
const wire = handle.accessor.get(IAgentWireService);
|
||||
private bindMainAgent(handle: IAgentScopeHandle): void {
|
||||
const wire = handle.accessor.get(IWireService);
|
||||
this._register(
|
||||
wire.onRestored(() => {
|
||||
wire.hooks.onDidRestore.register('cron', async (_ctx, next) => {
|
||||
await this.config.ready;
|
||||
this.resolveClocks();
|
||||
this.tasks.clear();
|
||||
for (const [id, task] of wire.getModel(CronModel)) {
|
||||
this.tasks.set(id, task as CronTask);
|
||||
}
|
||||
void this.loadFromStore({ replace: false }).then(() => this.start());
|
||||
await this.loadFromStore({ replace: false });
|
||||
await this.start();
|
||||
await next();
|
||||
}),
|
||||
);
|
||||
|
||||
this.registerCronTools(handle);
|
||||
|
||||
await this.loadFromStore();
|
||||
await this.start();
|
||||
}
|
||||
|
||||
private registerCronTools(handle: IAgentScopeHandle): void {
|
||||
|
|
@ -485,7 +484,7 @@ export class SessionCronServiceImpl extends Disposable implements ISessionCronSe
|
|||
private dispatchCron(op: Op): void {
|
||||
const mainHandle = this.agentLifecycle.get('main');
|
||||
if (!mainHandle) return;
|
||||
mainHandle.accessor.get(IAgentWireService).dispatch(op);
|
||||
mainHandle.accessor.get(IWireService).dispatch(op);
|
||||
}
|
||||
|
||||
private signalCron(event: DomainEvent): void {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
* subagent finishes, reloads `AGENTS.md` through the `profile` context helper
|
||||
* (over the os `hostFs` + host home dir, with the `bootstrap` brand dir) and
|
||||
* appends an `init`-variant system reminder to the main agent via
|
||||
* `systemReminder`, then flushes the main agent's `wireRecord` log. Bound at
|
||||
* `systemReminder`, then flushes the main agent's wire journal. Bound at
|
||||
* Session scope.
|
||||
*
|
||||
* Port of v1 `Session.generateAgentsMd()`. The main-agent lookup is a hard
|
||||
|
|
@ -31,7 +31,7 @@ import { IAgentProfileService } from '#/agent/profile/profile';
|
|||
import { loadAgentsMd } from '#/agent/profile/context';
|
||||
import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode';
|
||||
import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder';
|
||||
import { IAgentWireRecordService } from '#/agent/wireRecord/wireRecord';
|
||||
import { IWireService } from '#/wire/wire';
|
||||
import { ErrorCodes, Error2 } from '#/errors';
|
||||
import { IAgentLifecycleService, MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle';
|
||||
import { emitAgentRunSpawned, mirrorAgentRun } from '#/session/subagent/mirrorAgentRun';
|
||||
|
|
@ -117,7 +117,7 @@ export class SessionInitService implements ISessionInitService {
|
|||
kind: 'injection',
|
||||
variant: 'init',
|
||||
});
|
||||
await main.accessor.get(IAgentWireRecordService).flush();
|
||||
await main.accessor.get(IWireService).flush();
|
||||
} catch (error) {
|
||||
// User cancellations (Ctrl+C → cancelInit) must surface as aborts, not
|
||||
// as init failures — the TUI resets quietly on `isAbortError`.
|
||||
|
|
|
|||
|
|
@ -13,7 +13,9 @@ import type { Event } from '#/_base/event';
|
|||
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
|
||||
|
||||
export interface AgentMeta {
|
||||
readonly homedir: string;
|
||||
/** Absolute standard path retained for older v1 readers. Current readers
|
||||
* derive the agent directory from the session scope and ignore this field. */
|
||||
readonly homedir?: string;
|
||||
readonly type?: 'main' | 'sub' | 'independent';
|
||||
readonly parentAgentId?: string | null;
|
||||
readonly forkedFrom?: string;
|
||||
|
|
|
|||
|
|
@ -4,21 +4,17 @@
|
|||
* Holds the session's shared todo list as a stateless facade over the main
|
||||
* agent's `TodoModel`: `getTodos` reads `wire.getModel(TodoModel)` live, and
|
||||
* every mutation only dispatches a `tools.update_store` Op to the main agent's
|
||||
* wire (the
|
||||
* single source of truth and replayable timeline); `onDidChange` is bridged
|
||||
* from `wire.subscribe(TodoModel)`. The service keeps no list copy of its own,
|
||||
* so the live view and the post-replay view can never drift. Binds the
|
||||
* wire (the single source of truth and replayable timeline), then emits
|
||||
* `onDidChange` from the rebuilt Model. The service keeps no list copy of its
|
||||
* own, so the live view and the post-replay view can never drift. Binds the
|
||||
* `TodoListTool` and the stale-todo reminder into every agent (`onDidCreate`),
|
||||
* and the model subscription into the main agent (`onDidCreateMain`),
|
||||
* borrowing each agent's services through its `IAgentScopeHandle.accessor`.
|
||||
* Per-agent bindings are disposed when the agent is disposed. Bound at Session
|
||||
* scope.
|
||||
* Per-agent bindings are disposed when the agent is disposed. Bound at
|
||||
* Session scope.
|
||||
*
|
||||
* Debt: the session's todo list is still persisted on the MAIN agent's wire (a
|
||||
* Session → Agent edge), so it follows the main agent's lifetime. Once
|
||||
* `ISessionWireService` is wired up with its own log + replay, move `TodoModel`
|
||||
* there — swap `@IAgentWireService` for `@ISessionWireService` and drop the
|
||||
* main-agent subscription. The stateless facade makes that a one-line change.
|
||||
* The session owns the todo facade and tool bindings, while the main Agent wire
|
||||
* owns the replayable state. This is an explicit cross-scope orchestration
|
||||
* boundary: there is no second session-level wire aggregate or journal.
|
||||
*/
|
||||
|
||||
import { Disposable, toDisposable, type IDisposable } from '#/_base/di/lifecycle';
|
||||
|
|
@ -30,7 +26,7 @@ import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInj
|
|||
import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory';
|
||||
import { IAgentProfileService } from '#/agent/profile/profile';
|
||||
import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle';
|
||||
import { IAgentWireService } from '#/wire/tokens';
|
||||
import { IWireService } from '#/wire/wire';
|
||||
|
||||
import { ISessionTodoService } from './sessionTodo';
|
||||
import { TodoModel, todoSet } from './todoOps';
|
||||
|
|
@ -55,7 +51,6 @@ export class SessionTodoService extends Disposable implements ISessionTodoServic
|
|||
this._register(
|
||||
this.agentLifecycle.onDidCreate((handle) => {
|
||||
this.bindAgent(handle);
|
||||
if (handle.id === MAIN_AGENT_ID) this.bindMainWire(handle);
|
||||
}),
|
||||
);
|
||||
this._register(
|
||||
|
|
@ -64,7 +59,6 @@ export class SessionTodoService extends Disposable implements ISessionTodoServic
|
|||
|
||||
for (const handle of this.agentLifecycle.list()) {
|
||||
this.bindAgent(handle);
|
||||
if (handle.id === MAIN_AGENT_ID) this.bindMainWire(handle);
|
||||
}
|
||||
|
||||
this._register(
|
||||
|
|
@ -79,7 +73,7 @@ export class SessionTodoService extends Disposable implements ISessionTodoServic
|
|||
getTodos(): readonly TodoItem[] {
|
||||
const main = this.agentLifecycle.get(MAIN_AGENT_ID);
|
||||
if (main === undefined) return [];
|
||||
return main.accessor.get(IAgentWireService).getModel(TodoModel);
|
||||
return main.accessor.get(IWireService).getModel(TodoModel);
|
||||
}
|
||||
|
||||
setTodos(todos: readonly TodoItem[]): void {
|
||||
|
|
@ -97,16 +91,9 @@ export class SessionTodoService extends Disposable implements ISessionTodoServic
|
|||
private dispatchTodoSet(todos: readonly TodoItem[]): void {
|
||||
const main = this.agentLifecycle.get(MAIN_AGENT_ID);
|
||||
if (main === undefined) return;
|
||||
const wire = main.accessor.get(IAgentWireService);
|
||||
const wire = main.accessor.get(IWireService);
|
||||
wire.dispatch(todoSet({ key: 'todo', value: todos }));
|
||||
}
|
||||
|
||||
private bindMainWire(handle: IAgentScopeHandle): void {
|
||||
const wire = handle.accessor.get(IAgentWireService);
|
||||
const disposable = wire.subscribe(TodoModel, (state) => {
|
||||
this.onDidChangeEmitter.fire(state);
|
||||
});
|
||||
this.trackAgentBinding(handle.id, disposable);
|
||||
this.onDidChangeEmitter.fire(wire.getModel(TodoModel));
|
||||
}
|
||||
|
||||
private bindAgent(handle: IAgentScopeHandle): void {
|
||||
|
|
|
|||
|
|
@ -11,10 +11,10 @@
|
|||
* render, the stale reminder, the compaction summary) can trust the Model
|
||||
* without re-validating. Consumed cross-scope by the Session-scope
|
||||
* `SessionTodoService`: it dispatches to the MAIN agent's wire (the single
|
||||
* source of truth and replayable timeline) and, on `wire.onRestored`, reads the
|
||||
* rebuilt Model back from that same wire. The Ops register into the global
|
||||
* `OP_REGISTRY` at import time, so they are in place before the main agent
|
||||
* replays.
|
||||
* source of truth and replayable timeline), and `getTodos` reads the rebuilt
|
||||
* Model back from that same wire after restore. The Ops register into the
|
||||
* global `OP_REGISTRY` at import time, so they are in place before the main
|
||||
* agent restores.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
*
|
||||
* Aggregates the wire domain's coded errors: `DuplicateOpError` (thrown by
|
||||
* `defineOp` in `op.ts`) and `CycleError` (thrown by the dispatch drain in
|
||||
* `wireServiceImpl.ts`) stay co-located with their throw sites but extend
|
||||
* `wireService.ts`) stay co-located with their throw sites but extend
|
||||
* `WireError`; `wire.unknown_record` is constructed here for replay-time
|
||||
* reporting of records whose Op type is absent from `OP_REGISTRY`.
|
||||
*/
|
||||
|
|
@ -17,6 +17,7 @@ export const WireErrors = {
|
|||
WIRE_DUPLICATE_OP: 'wire.duplicate_op',
|
||||
WIRE_CYCLE: 'wire.cycle',
|
||||
WIRE_UNKNOWN_RECORD: 'wire.unknown_record',
|
||||
RECORDS_WRITE_FAILED: 'records.write_failed',
|
||||
},
|
||||
info: {
|
||||
'wire.duplicate_op': {
|
||||
|
|
@ -37,6 +38,11 @@ export const WireErrors = {
|
|||
public: true,
|
||||
action: 'The record was written by a newer version; upgrade or drop it.',
|
||||
},
|
||||
'records.write_failed': {
|
||||
title: 'Wire journal write failed',
|
||||
retryable: false,
|
||||
public: true,
|
||||
},
|
||||
},
|
||||
} as const satisfies ErrorDomain;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import type { WireRecord } from '#/wire/record';
|
||||
|
||||
import { migrateV1_0ToV1_1 } from './v1.1';
|
||||
import { migrateV1_1ToV1_2 } from './v1.2';
|
||||
import { migrateV1_2ToV1_3 } from './v1.3';
|
||||
|
|
@ -10,12 +12,9 @@ export {
|
|||
migrateV1_3ToV1_4,
|
||||
};
|
||||
|
||||
export const AGENT_WIRE_PROTOCOL_VERSION = '1.4';
|
||||
export const WIRE_PROTOCOL_VERSION = '1.4';
|
||||
|
||||
export interface WireMigrationRecord {
|
||||
readonly type: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
export type WireMigrationRecord = WireRecord;
|
||||
|
||||
export interface WireMigration {
|
||||
readonly sourceVersion: string;
|
||||
|
|
@ -31,17 +30,17 @@ const MIGRATIONS: readonly WireMigration[] = [
|
|||
];
|
||||
|
||||
export function isNewerWireVersion(readVersion: string): boolean {
|
||||
return compareWireVersions(readVersion, AGENT_WIRE_PROTOCOL_VERSION) > 0;
|
||||
return compareWireVersions(readVersion, WIRE_PROTOCOL_VERSION) > 0;
|
||||
}
|
||||
|
||||
export function resolveWireMigrations(readVersion: string): readonly WireMigration[] {
|
||||
if (compareWireVersions(readVersion, AGENT_WIRE_PROTOCOL_VERSION) >= 0) {
|
||||
if (compareWireVersions(readVersion, WIRE_PROTOCOL_VERSION) >= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const migrations: WireMigration[] = [];
|
||||
let version = readVersion;
|
||||
while (compareWireVersions(version, AGENT_WIRE_PROTOCOL_VERSION) < 0) {
|
||||
while (compareWireVersions(version, WIRE_PROTOCOL_VERSION) < 0) {
|
||||
const migration = findMigration(version);
|
||||
if (migration === undefined) {
|
||||
throw new Error(`Missing wire migration for version ${version}`);
|
||||
|
|
@ -29,11 +29,10 @@
|
|||
* cast happens once inside `WireService`.
|
||||
*
|
||||
* A primary Model may register cross-model reducers keyed by foreign op types:
|
||||
* `WireService.execute` runs them on both dispatch and replay, so v1-derived
|
||||
* `WireService` runs them on both dispatch and restore, so v1-derived
|
||||
* restore effects can stay replayable without persisting extra records.
|
||||
*
|
||||
* `DeepReadonly<T>` recursively maps a state type to its deeply-readonly view
|
||||
* for the references returned by `getModel` / `subscribe`: functions pass
|
||||
* for the references returned by `getModel`: functions pass
|
||||
* through, `Map` / `Set` widen to `ReadonlyMap` / `ReadonlySet`, arrays and
|
||||
* tuples widen to `ReadonlyArray`, plain objects become a readonly mapped type,
|
||||
* and primitives are unchanged. It pairs with the runtime `Object.freeze`
|
||||
|
|
@ -42,12 +41,12 @@
|
|||
|
||||
import { bindDefineOp, type DefineOpFn } from '#/wire/op';
|
||||
import type { ModelReducers } from '#/wire/types';
|
||||
import type { PersistedRecord } from '#/wire/wireService';
|
||||
import type { WireRecord } from '#/wire/record';
|
||||
|
||||
export type PartsTransformer = (parts: readonly unknown[]) => Promise<readonly unknown[]>;
|
||||
|
||||
export interface ModelBlobCodec<S> {
|
||||
dehydrate(record: PersistedRecord, transform: PartsTransformer): PersistedRecord | Promise<PersistedRecord>;
|
||||
dehydrate(record: WireRecord, transform: PartsTransformer): WireRecord | Promise<WireRecord>;
|
||||
rehydrate(state: S, transform: PartsTransformer): S | Promise<S>;
|
||||
}
|
||||
|
||||
|
|
@ -95,22 +94,6 @@ export function defineModel<S>(
|
|||
return def;
|
||||
}
|
||||
|
||||
export interface DerivedModelDef<S> {
|
||||
readonly name: string;
|
||||
readonly initial: () => S;
|
||||
readonly reducers: Readonly<ModelReducers<S>>;
|
||||
readonly blobs?: ModelBlobCodec<S>;
|
||||
}
|
||||
|
||||
export function defineDerivedModel<S>(
|
||||
name: string,
|
||||
initial: () => S,
|
||||
reducers: ModelReducers<S>,
|
||||
opts?: { blobs?: ModelBlobCodec<S> },
|
||||
): DerivedModelDef<S> {
|
||||
return { name, initial, reducers, blobs: opts?.blobs };
|
||||
}
|
||||
|
||||
export type DeepReadonly<T> = T extends (...args: infer A) => infer R
|
||||
? (...args: A) => R
|
||||
: T extends ReadonlyMap<infer K, infer V>
|
||||
|
|
|
|||
|
|
@ -8,12 +8,12 @@
|
|||
* callable (`goalCreate(payload)`) and inspectable (`goalCreate.apply`,
|
||||
* `goalCreate.type`). Every Op carries a mandatory pure `apply` and may carry
|
||||
* an optional `toEvent` that derives an `IEventBus` fact from the payload and
|
||||
* the post-apply state (published by `WireService` on `dispatch`, never on
|
||||
* `replay`). A mandatory `schema` (zod, declared before `apply`) is the
|
||||
* the post-apply state (published by `WireService` on live `dispatch`,
|
||||
* never during `restore`). A mandatory `schema` (zod, declared before `apply`) is the
|
||||
* payload's single source of truth: `P` is inferred from it, so Op authors
|
||||
* never restate payload interfaces, and it is stored on the descriptor for
|
||||
* payload validation at wire boundaries; the runtime paths (`dispatch` /
|
||||
* `replay`) never consult it. The descriptor's payload is erased
|
||||
* `restore`) never consult it. The descriptor's payload is erased
|
||||
* to `any` on `Op.descriptor` (mirroring `OP_REGISTRY`) so `Op` stays
|
||||
* covariant in `P` — a heterogeneous batch of Ops, each with a different
|
||||
* payload type, stays assignable to the single `dispatch(...ops: Op[])` rest
|
||||
|
|
@ -23,8 +23,7 @@
|
|||
* definition into the `types.ts` registries (which map op types to `typeof`
|
||||
* the Op); registration constrains only the persistence policy — a registered
|
||||
* type must honor its map, an unregistered type keeps its free `persist`
|
||||
* option. Descriptors may opt out of timestamp stamping (`stamp: false`) for
|
||||
* the metadata envelope. Scope-agnostic.
|
||||
* option. Scope-agnostic.
|
||||
*/
|
||||
|
||||
import type { z } from 'zod';
|
||||
|
|
@ -50,7 +49,6 @@ export interface OpDescriptor<K extends string, S, P> {
|
|||
readonly apply: (state: S, payload: P) => S;
|
||||
readonly toEvent?: (payload: P, state: S) => unknown;
|
||||
readonly persist?: boolean;
|
||||
readonly stamp?: boolean;
|
||||
}
|
||||
|
||||
export interface Op<K extends string = string, P = unknown> {
|
||||
|
|
@ -67,7 +65,6 @@ interface OpBehaviorOptions<S, P> {
|
|||
readonly schema: z.ZodType<P>;
|
||||
readonly apply: (state: S, payload: P) => S;
|
||||
readonly toEvent?: (payload: P, state: S) => unknown;
|
||||
readonly stamp?: boolean;
|
||||
}
|
||||
|
||||
type RegisteredOpConstraint<K extends string> = K extends ConflictingOpType
|
||||
|
|
@ -122,7 +119,6 @@ export function defineOp<const K extends string, S, P>(
|
|||
apply: behavior.apply,
|
||||
toEvent: behavior.toEvent,
|
||||
persist: behavior.persist,
|
||||
stamp: behavior.stamp,
|
||||
};
|
||||
OP_REGISTRY.set(type, descriptor);
|
||||
const factory = (payload: P): Op<K, P> => ({ type, payload, descriptor });
|
||||
|
|
|
|||
59
packages/agent-core-v2/src/wire/record.ts
Normal file
59
packages/agent-core-v2/src/wire/record.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
/**
|
||||
* `wire` domain (L2) — the persisted journal record language.
|
||||
*
|
||||
* A `WireRecord` is the flat JSONL representation of one persisted Op. The
|
||||
* first line of an Agent journal is a `WireMetadataRecord`; metadata is a
|
||||
* journal envelope, not an Op, so it never enters the model reducer registry.
|
||||
* This module owns only pure encoding and decoding.
|
||||
*/
|
||||
|
||||
import type { Op } from '#/wire/op';
|
||||
|
||||
import { WIRE_PROTOCOL_VERSION } from './migration/migration';
|
||||
|
||||
export const AGENT_WIRE_RECORD_KEY = 'wire.jsonl';
|
||||
|
||||
export interface WireRecord {
|
||||
readonly type: string;
|
||||
readonly time?: number;
|
||||
readonly [key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface WireMetadataRecord extends WireRecord {
|
||||
readonly type: 'metadata';
|
||||
readonly protocol_version: string;
|
||||
readonly created_at: number;
|
||||
}
|
||||
|
||||
export function createWireMetadataRecord(now = Date.now()): WireMetadataRecord {
|
||||
return {
|
||||
type: 'metadata',
|
||||
protocol_version: WIRE_PROTOCOL_VERSION,
|
||||
created_at: now,
|
||||
};
|
||||
}
|
||||
|
||||
export function isWireMetadataRecord(record: WireRecord): record is WireMetadataRecord {
|
||||
return (
|
||||
record.type === 'metadata' &&
|
||||
typeof record['protocol_version'] === 'string' &&
|
||||
typeof record['created_at'] === 'number'
|
||||
);
|
||||
}
|
||||
|
||||
export function opToWireRecord(op: Op, now = Date.now()): WireRecord {
|
||||
const payload = op.payload;
|
||||
const record: Record<string, unknown> =
|
||||
payload !== null && typeof payload === 'object' && !Array.isArray(payload)
|
||||
? { type: op.type, ...(payload as Record<string, unknown>) }
|
||||
: { type: op.type, payload };
|
||||
if (record['time'] === undefined) record['time'] = now;
|
||||
return record as WireRecord;
|
||||
}
|
||||
|
||||
export function wireRecordToPayload(record: WireRecord): unknown {
|
||||
const { type: _type, time: _time, ...payload } = record;
|
||||
return Object.keys(payload).length === 1 && 'payload' in payload
|
||||
? payload['payload']
|
||||
: payload;
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
/**
|
||||
* `wire` domain (L2) — scope-specific DI tokens (`IAgentWireService`,
|
||||
* `ISessionWireService`) over the single `IWireService` contract.
|
||||
*
|
||||
* One `WireService` implementation serves every scope; per-scope isolation
|
||||
* comes from distinct tokens, each seeded with its own persistence key at scope
|
||||
* creation. Domain services inject the token for their scope
|
||||
* (`@IAgentWireService`, `@ISessionWireService`). No App-scope token yet — add
|
||||
* one when a use case appears.
|
||||
*/
|
||||
|
||||
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
|
||||
|
||||
import type { IWireService } from './wireService';
|
||||
|
||||
export const IAgentWireService: ServiceIdentifier<IWireService> =
|
||||
createDecorator<IWireService>('agentWireService');
|
||||
|
||||
export const ISessionWireService: ServiceIdentifier<IWireService> =
|
||||
createDecorator<IWireService>('sessionWireService');
|
||||
36
packages/agent-core-v2/src/wire/wire.ts
Normal file
36
packages/agent-core-v2/src/wire/wire.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
/**
|
||||
* `wire` domain (L2) — the single Agent-scoped wire aggregate contract.
|
||||
*
|
||||
* The service owns one Agent's replayable model state and its journal as one
|
||||
* consistency boundary: restore reads, validates, migrates, rewrites, replays,
|
||||
* rehydrates, and then runs the ordered restore hook. Seal initializes a fresh
|
||||
* journal before session metadata makes the Agent visible to legacy readers.
|
||||
* Live dispatch applies an Op and appends its record. Callers do not coordinate
|
||||
* journal and model state through separate services.
|
||||
*/
|
||||
|
||||
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
|
||||
import type { Hooks } from '#/hooks';
|
||||
|
||||
import type { DeepReadonly, ModelDef } from './model';
|
||||
import type { Op } from './op';
|
||||
|
||||
export type WireHooks = {
|
||||
readonly onDidRestore: Record<string, never>;
|
||||
};
|
||||
|
||||
export interface IWireService {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
||||
readonly hooks: Hooks<WireHooks>;
|
||||
|
||||
dispatch(...ops: Op[]): void;
|
||||
seal(): Promise<void>;
|
||||
restore(): Promise<void>;
|
||||
flush(): Promise<void>;
|
||||
|
||||
getModel<S>(model: ModelDef<S>): DeepReadonly<S>;
|
||||
}
|
||||
|
||||
export const IWireService: ServiceIdentifier<IWireService> =
|
||||
createDecorator<IWireService>('wireService');
|
||||
|
|
@ -1,67 +1,313 @@
|
|||
/**
|
||||
* `wire` domain (L2) — `IWireService` contract and its supporting types
|
||||
* (`PersistedRecord`, `OpGroup`, `ModelChange`).
|
||||
* `wire` domain (L2) — `IWireService` implementation.
|
||||
*
|
||||
* The scope-agnostic state-machine engine: `dispatch` persists + applies +
|
||||
* notifies (OpGroup `{ silent: false }`), `replay` (async — rehydrates blob
|
||||
* references via `ModelDef.blobs` first) applies only (`{ silent: true }`);
|
||||
* `flush` drains the serialized persist queue. Reads go through `getModel` /
|
||||
* `subscribe`; the live append-log record stream streams via `onEmission`,
|
||||
* restore completion via `onRestored`, and Op-derived facts flow out through
|
||||
* `IEventBus` (see `op.ts` `toEvent`). A single implementation serves every
|
||||
* scope — instances are isolated per scope through the distinct DI tokens in
|
||||
* `tokens`, each seeded with its own persistence key. `PersistedRecord` is the
|
||||
* on-the-wire append-log shape (`wire.jsonl`): intentionally flat
|
||||
* (`{ type, ...payload }`, optional `time`) so it stays byte-compatible with the
|
||||
* existing wire journal (`{ type, time?, ...fields }`) — payload fields
|
||||
* sit at the top level next to `type`, never nested under a `payload` key; the
|
||||
* index signature keeps it scope-agnostic and domains narrow via their Op
|
||||
* payload types. Scope-agnostic.
|
||||
* `WireService` is the sole runtime owner of an Agent wire aggregate. It
|
||||
* combines the model reducer engine with the `wire.jsonl` journal protocol,
|
||||
* including creation-time sealing, metadata, migrations, atomic healing
|
||||
* rewrites, blob dehydration and rehydration plus an ordered post-restore hook.
|
||||
* It is bound at Agent scope because the aggregate identity is the Agent
|
||||
* identity.
|
||||
*/
|
||||
|
||||
import type { IDisposable } from '#/_base/di/lifecycle';
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
import type { DeepReadonly, DerivedModelDef, ModelDef } from './model';
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { BugIndicatingError } from '#/_base/errors/errors';
|
||||
import { onUnexpectedError } from '#/_base/errors/unexpectedError';
|
||||
import { Disposable } from '#/_base/di/lifecycle';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import { IAgentBlobService } from '#/agent/blob/agentBlobService';
|
||||
import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext';
|
||||
import { type DomainEvent, IEventBus } from '#/app/event/eventBus';
|
||||
import type { ContentPart } from '#/app/llmProtocol/message';
|
||||
import { OrderedHookSlot } from '#/hooks';
|
||||
import { IAppendLogStore } from '#/persistence/interface/appendLogStore';
|
||||
import { StorageError, StorageErrors } from '#/persistence/interface/storage';
|
||||
|
||||
import { IWireService } from './wire';
|
||||
import { WireError, WireErrors } from './errors';
|
||||
import {
|
||||
WIRE_PROTOCOL_VERSION,
|
||||
isNewerWireVersion,
|
||||
migrateWireRecord,
|
||||
resolveWireMigrations,
|
||||
type WireMigration,
|
||||
} from './migration/migration';
|
||||
import type { DeepReadonly, ModelDef, PartsTransformer } from './model';
|
||||
import { MODEL_CROSS_REDUCERS } from './model';
|
||||
import type { Op } from './op';
|
||||
import { OP_REGISTRY } from './op';
|
||||
import {
|
||||
AGENT_WIRE_RECORD_KEY,
|
||||
createWireMetadataRecord,
|
||||
isWireMetadataRecord,
|
||||
opToWireRecord,
|
||||
wireRecordToPayload,
|
||||
type WireRecord,
|
||||
} from './record';
|
||||
|
||||
export interface PersistedRecord {
|
||||
readonly type: string;
|
||||
readonly time?: number;
|
||||
readonly [key: string]: unknown;
|
||||
const MAX_DRAIN = 100;
|
||||
|
||||
export class CycleError extends WireError {
|
||||
constructor(readonly depth: number, readonly opTypes: readonly string[]) {
|
||||
super(
|
||||
WireErrors.codes.WIRE_CYCLE,
|
||||
`Wire dispatch cascade exceeded MAX_DRAIN (${depth}); possible op cycle`,
|
||||
{ details: { depth, opTypes: opTypes.slice(0, 20) } },
|
||||
);
|
||||
this.name = 'CycleError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface OpGroup {
|
||||
interface ModelInstance {
|
||||
state: any;
|
||||
}
|
||||
|
||||
interface OpGroup {
|
||||
readonly ops: readonly Op[];
|
||||
readonly silent: boolean;
|
||||
}
|
||||
|
||||
export interface ModelChange<S> {
|
||||
readonly state: S;
|
||||
readonly prev: S;
|
||||
type RestorePhase = 'new' | 'restoring' | 'ready' | 'failed';
|
||||
|
||||
export class WireService extends Disposable implements IWireService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
readonly hooks: IWireService['hooks'] = {
|
||||
onDidRestore: new OrderedHookSlot(),
|
||||
};
|
||||
|
||||
private readonly models = new Map<ModelDef<any>, ModelInstance>();
|
||||
private readonly wireScope: string;
|
||||
|
||||
private restorePhase: RestorePhase = 'new';
|
||||
private dispatching = false;
|
||||
private queue: Op[] = [];
|
||||
private drainDepth = 0;
|
||||
private persistQueue: Promise<void> | undefined;
|
||||
|
||||
constructor(
|
||||
@IAgentScopeContext scopeContext: IAgentScopeContext,
|
||||
@IAppendLogStore private readonly log: IAppendLogStore,
|
||||
@IAgentBlobService private readonly blobService: IAgentBlobService,
|
||||
@IEventBus private readonly eventBus: IEventBus,
|
||||
) {
|
||||
super();
|
||||
this.wireScope = scopeContext.scope();
|
||||
this._register(this.log.acquire(this.wireScope, AGENT_WIRE_RECORD_KEY));
|
||||
}
|
||||
|
||||
getModel<S>(model: ModelDef<S>): DeepReadonly<S> {
|
||||
return this.ensureModel(model).state as DeepReadonly<S>;
|
||||
}
|
||||
|
||||
dispatch(...ops: Op[]): void {
|
||||
if (ops.length === 0) return;
|
||||
if (this.dispatching) {
|
||||
this.queue.push(...ops);
|
||||
return;
|
||||
}
|
||||
this.dispatching = true;
|
||||
try {
|
||||
this.execute({ ops, silent: false });
|
||||
while (this.queue.length > 0) {
|
||||
if (++this.drainDepth > MAX_DRAIN) {
|
||||
throw new CycleError(this.drainDepth, this.queue.map((op) => op.type));
|
||||
}
|
||||
this.execute({ ops: this.queue.splice(0), silent: false });
|
||||
}
|
||||
} finally {
|
||||
this.queue.length = 0;
|
||||
this.dispatching = false;
|
||||
this.drainDepth = 0;
|
||||
}
|
||||
}
|
||||
|
||||
async seal(): Promise<void> {
|
||||
for await (const record of this.log.read(this.wireScope, AGENT_WIRE_RECORD_KEY)) {
|
||||
void record;
|
||||
return;
|
||||
}
|
||||
this.appendRecord(createWireMetadataRecord());
|
||||
}
|
||||
|
||||
async restore(): Promise<void> {
|
||||
if (
|
||||
this.restorePhase === 'restoring' ||
|
||||
this.restorePhase === 'failed' ||
|
||||
this.restorePhase === 'ready'
|
||||
) {
|
||||
throw new BugIndicatingError(`Agent wire restore called while phase is ${this.restorePhase}`);
|
||||
}
|
||||
this.restorePhase = 'restoring';
|
||||
try {
|
||||
const source = this.log.read<WireRecord>(this.wireScope, AGENT_WIRE_RECORD_KEY);
|
||||
let migrations: readonly WireMigration[] = [];
|
||||
let rewrittenRecords: WireRecord[] | undefined;
|
||||
let newerWireVersion = false;
|
||||
let recordIndex = 0;
|
||||
let hasRecords = false;
|
||||
|
||||
for await (const sourceRecord of source) {
|
||||
if (!hasRecords) {
|
||||
hasRecords = true;
|
||||
if (sourceRecord.type !== 'metadata') {
|
||||
rewrittenRecords = [createWireMetadataRecord()];
|
||||
} else if (!isWireMetadataRecord(sourceRecord)) {
|
||||
throw new StorageError(
|
||||
StorageErrors.codes.STORAGE_CORRUPTED,
|
||||
'Agent wire metadata is malformed',
|
||||
{ details: { scope: this.wireScope, key: AGENT_WIRE_RECORD_KEY } },
|
||||
);
|
||||
} else if (isNewerWireVersion(sourceRecord.protocol_version)) {
|
||||
newerWireVersion = true;
|
||||
} else {
|
||||
migrations = resolveWireMigrations(sourceRecord.protocol_version);
|
||||
if (sourceRecord.protocol_version !== WIRE_PROTOCOL_VERSION) {
|
||||
rewrittenRecords = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const migratedRecord = migrateWireRecord(sourceRecord, migrations);
|
||||
const record =
|
||||
!newerWireVersion && migratedRecord.type === 'metadata'
|
||||
? { ...migratedRecord, protocol_version: WIRE_PROTOCOL_VERSION }
|
||||
: migratedRecord;
|
||||
rewrittenRecords?.push(record);
|
||||
if (record.type === 'metadata') continue;
|
||||
|
||||
this.replayRecord(record, recordIndex);
|
||||
recordIndex++;
|
||||
}
|
||||
|
||||
if (!hasRecords) {
|
||||
rewrittenRecords = [createWireMetadataRecord()];
|
||||
}
|
||||
if (rewrittenRecords !== undefined) {
|
||||
await this.log.rewrite(this.wireScope, AGENT_WIRE_RECORD_KEY, rewrittenRecords);
|
||||
}
|
||||
|
||||
await this.rehydrateModels();
|
||||
this.restorePhase = 'ready';
|
||||
await this.hooks.onDidRestore.run({});
|
||||
} catch (error) {
|
||||
this.restorePhase = 'failed';
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
await this.persistQueue;
|
||||
await this.log.flush();
|
||||
}
|
||||
|
||||
private replayRecord(record: WireRecord, index: number): void {
|
||||
const descriptor = OP_REGISTRY.get(record.type);
|
||||
if (descriptor === undefined) {
|
||||
onUnexpectedError(
|
||||
new WireError(
|
||||
WireErrors.codes.WIRE_UNKNOWN_RECORD,
|
||||
`Unknown wire record type '${record.type}' skipped during restore`,
|
||||
{ details: { type: record.type, index } },
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.execute({
|
||||
ops: [{ type: record.type, payload: wireRecordToPayload(record), descriptor }],
|
||||
silent: true,
|
||||
});
|
||||
}
|
||||
|
||||
private execute(group: OpGroup): void {
|
||||
for (const op of group.ops) {
|
||||
const inst = this.ensureModel(op.descriptor.model);
|
||||
const prev = inst.state;
|
||||
inst.state = Object.freeze(op.descriptor.apply(prev, op.payload));
|
||||
if (!group.silent) {
|
||||
if (op.descriptor.persist !== false) {
|
||||
const record = opToWireRecord(op);
|
||||
this.appendToJournal(record, op.descriptor.model);
|
||||
}
|
||||
const event = op.descriptor.toEvent?.(op.payload, inst.state);
|
||||
if (event !== undefined) {
|
||||
this.eventBus.publish(event as DomainEvent);
|
||||
}
|
||||
}
|
||||
const crossReducers = MODEL_CROSS_REDUCERS.get(op.type);
|
||||
if (crossReducers !== undefined) {
|
||||
for (const entry of crossReducers) {
|
||||
if (entry.model === op.descriptor.model) continue;
|
||||
const crossInst = this.ensureModel(entry.model);
|
||||
crossInst.state = Object.freeze(entry.reducer(crossInst.state, op.payload));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private ensureModel<S>(def: ModelDef<S>): ModelInstance {
|
||||
let inst = this.models.get(def);
|
||||
if (inst === undefined) {
|
||||
inst = { state: Object.freeze(def.initial()) };
|
||||
this.models.set(def, inst);
|
||||
}
|
||||
return inst;
|
||||
}
|
||||
|
||||
private appendToJournal(record: WireRecord, model: ModelDef<any>): void {
|
||||
const dehydrate = model.blobs?.dehydrate?.bind(model.blobs);
|
||||
if (dehydrate === undefined && this.persistQueue === undefined) {
|
||||
try {
|
||||
this.appendRecord(record);
|
||||
} catch (error) {
|
||||
onUnexpectedError(error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const transform: PartsTransformer = (parts) =>
|
||||
this.blobService.offloadParts(
|
||||
parts as readonly ContentPart[],
|
||||
) as Promise<readonly unknown[]>;
|
||||
const queued = (this.persistQueue ?? Promise.resolve())
|
||||
.then(async () => {
|
||||
let output = record;
|
||||
if (dehydrate !== undefined) {
|
||||
const prepared = dehydrate(record, transform);
|
||||
output = await prepared;
|
||||
}
|
||||
this.appendRecord(output);
|
||||
})
|
||||
.catch((error: unknown) => onUnexpectedError(error));
|
||||
this.persistQueue = queued;
|
||||
void queued.then(() => {
|
||||
if (this.persistQueue === queued) this.persistQueue = undefined;
|
||||
});
|
||||
}
|
||||
|
||||
private appendRecord(record: WireRecord): void {
|
||||
this.log.append(this.wireScope, AGENT_WIRE_RECORD_KEY, record, {
|
||||
onError: onUnexpectedError,
|
||||
});
|
||||
}
|
||||
|
||||
private async rehydrateModels(): Promise<void> {
|
||||
const transform: PartsTransformer = (parts) =>
|
||||
this.blobService.loadParts(
|
||||
parts as readonly ContentPart[],
|
||||
) as Promise<readonly unknown[]>;
|
||||
for (const [def, inst] of this.models) {
|
||||
if (def.blobs?.rehydrate === undefined) continue;
|
||||
const result = def.blobs.rehydrate(inst.state, transform);
|
||||
inst.state = Object.freeze(await result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface ReplayResult {
|
||||
readonly unknownRecords: number;
|
||||
}
|
||||
|
||||
export interface WireEmission {
|
||||
readonly type: 'record';
|
||||
readonly record: PersistedRecord;
|
||||
}
|
||||
|
||||
export interface IWireService {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
||||
dispatch(...ops: Op[]): void;
|
||||
replay(...records: PersistedRecord[]): Promise<ReplayResult>;
|
||||
flush(): Promise<void>;
|
||||
|
||||
attach<S>(model: DerivedModelDef<S>): IDisposable;
|
||||
getModel<S>(model: ModelDef<S> | DerivedModelDef<S>): DeepReadonly<S>;
|
||||
subscribe<S>(
|
||||
model: ModelDef<S> | DerivedModelDef<S>,
|
||||
handler: (state: DeepReadonly<S>, prev: DeepReadonly<S>) => void,
|
||||
): IDisposable;
|
||||
onEmission(handler: (emission: WireEmission) => void): IDisposable;
|
||||
onRestored(handler: () => void | Promise<void>): IDisposable;
|
||||
}
|
||||
registerScopedService(
|
||||
LifecycleScope.Agent,
|
||||
IWireService,
|
||||
WireService,
|
||||
InstantiationType.Eager,
|
||||
'wire',
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,403 +0,0 @@
|
|||
/**
|
||||
* `wire` domain (L2) — `WireService`, the single scope-agnostic implementation
|
||||
* of `IWireService`, plus its construction options (`WireServiceOptions`)
|
||||
* and the coded `CycleError`.
|
||||
*
|
||||
* One class serves every scope: per-scope isolation comes from the distinct DI
|
||||
* tokens in `tokens`, each seeded with its own `WireServiceOptions`
|
||||
* (`logScope` / `logKey`) as the leading (non-service) constructor argument
|
||||
* through a `SyncDescriptor`, mirroring `WireRecordServiceOptions`. `dispatch`
|
||||
* and `replay` both lower to one primitive, `execute(OpGroup)` — apply-all THEN
|
||||
* onChange-all, so a subscriber never observes a partially-applied group — with
|
||||
* `dispatch` adding persistence + emission + Op-derived `IEventBus` events
|
||||
* (`silent: false`) and `replay` staying silent (apply only, skipping
|
||||
* unknown record types, then `onRestored`). A reentrancy guard (`dispatching` +
|
||||
* `queue` + `drain`, capped by `MAX_DRAIN = 100`) lets onChange handlers enqueue
|
||||
* further ops without reentering `execute`; a cascade past the cap throws
|
||||
* `CycleError` (`wire.cycle`), co-located here like `DuplicateOpError`
|
||||
* (`wire.duplicate_op` in `op.ts`) — both extend `WireError` from
|
||||
* `wire/errors.ts`. After every
|
||||
* `apply` the new state is `Object.freeze`d — the runtime half of the
|
||||
* immutability guarantee whose compile-time half is `DeepReadonly`. Internally
|
||||
* each per-model instance is erased to `any` (the same localized erasure as
|
||||
* `OP_REGISTRY`) and restored at the public boundary; an Op's optional `toEvent`
|
||||
* derives an `IEventBus` fact on `dispatch` (never on `replay`).
|
||||
*
|
||||
* Persists each dispatched op through `persistence` (`IAppendLogStore`) as a
|
||||
* flat `{ type, ...payload }` record — scalar / array payloads nested so a
|
||||
* JSONL line stays an object, stamped with `time` unless the op opts out
|
||||
* (`stamp: false`, only the `metadata` envelope), with `type` / `time`
|
||||
* stripped back out on replay. Ops declared `persist: false` apply and notify
|
||||
* like any other but never reach the emission stream or the log — the on-disk
|
||||
* record vocabulary stays exactly v1's. After each op, cross-model reducers
|
||||
* registered via `defineModel(..., { reducers })` (`MODEL_CROSS_REDUCERS`)
|
||||
* fold the op into foreign primary models on both dispatch and replay.
|
||||
*
|
||||
* Blob handling is driven by each `ModelDef`'s optional `blobs` codec
|
||||
* (`ModelBlobCodec`), which declares two symmetric directions:
|
||||
*
|
||||
* - **Dehydrate (dispatch → persist)**: `model.blobs.dehydrate(record, transform)`
|
||||
* lets the model traverse its own record structure, pass each `ContentPart[]`
|
||||
* through `transform` (which offloads oversized inline data to blob storage),
|
||||
* and return the transformed record. `apply` and the live emission still see
|
||||
* the original inline payload. Records whose model has no `blobs` codec
|
||||
* short-circuit synchronously (no queue, no microtask).
|
||||
*
|
||||
* - **Rehydrate (replay → model)**: after all records are applied,
|
||||
* `rehydrateModels` calls `model.blobs.rehydrate(state, transform)` on each
|
||||
* model that declares a `blobs` codec, replacing blobref URLs with inline data
|
||||
* *only* in the surviving final state — skipping I/O for data later removed by
|
||||
* compaction (a 20×+ speedup for long sessions with many images).
|
||||
*
|
||||
* Scope-agnostic.
|
||||
*/
|
||||
|
||||
import { Disposable, toDisposable, type IDisposable } from '#/_base/di/lifecycle';
|
||||
import { onUnexpectedError } from '#/_base/errors/unexpectedError';
|
||||
import { Emitter } from '#/_base/event';
|
||||
import { IAgentBlobService } from '#/agent/blob/agentBlobService';
|
||||
import { type DomainEvent, IEventBus } from '#/app/event/eventBus';
|
||||
import type { ContentPart } from '#/app/llmProtocol/message';
|
||||
import { IAppendLogStore } from '#/persistence/interface/appendLogStore';
|
||||
|
||||
import { WireError, WireErrors } from './errors';
|
||||
import type { DeepReadonly, DerivedModelDef, ModelDef, PartsTransformer } from './model';
|
||||
import { MODEL_CROSS_REDUCERS } from './model';
|
||||
import type { Op } from './op';
|
||||
import { OP_REGISTRY } from './op';
|
||||
import type {
|
||||
IWireService,
|
||||
ModelChange,
|
||||
OpGroup,
|
||||
PersistedRecord,
|
||||
ReplayResult,
|
||||
WireEmission,
|
||||
} from './wireService';
|
||||
|
||||
const MAX_DRAIN = 100;
|
||||
|
||||
export class CycleError extends WireError {
|
||||
constructor(readonly depth: number, readonly opTypes: readonly string[]) {
|
||||
super(
|
||||
WireErrors.codes.WIRE_CYCLE,
|
||||
`Wire dispatch cascade exceeded MAX_DRAIN (${depth}); possible op cycle`,
|
||||
{
|
||||
details: { depth, opTypes: opTypes.slice(0, 20) },
|
||||
},
|
||||
);
|
||||
this.name = 'CycleError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface WireServiceOptions {
|
||||
readonly logScope: string;
|
||||
readonly logKey: string;
|
||||
}
|
||||
|
||||
interface ModelInstance {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
state: any;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
emitter: Emitter<ModelChange<any>>;
|
||||
}
|
||||
|
||||
interface ReducerEntry {
|
||||
readonly inst: ModelInstance;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
readonly reducer: (state: any, payload: any) => any;
|
||||
}
|
||||
|
||||
export class WireService extends Disposable implements IWireService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
private readonly models = new Map<ModelDef<any>, ModelInstance>();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
private readonly derivedModels = new Map<DerivedModelDef<any>, ModelInstance>();
|
||||
private readonly reducerIndex = new Map<string, ReducerEntry[]>();
|
||||
private readonly emissionEmitter = this._register(new Emitter<WireEmission>());
|
||||
private readonly restoredHandlers = new Set<() => void | Promise<void>>();
|
||||
|
||||
private dispatching = false;
|
||||
private queue: Op[] = [];
|
||||
private drainDepth = 0;
|
||||
private persistQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
constructor(
|
||||
private readonly options: WireServiceOptions,
|
||||
@IAppendLogStore private readonly log?: IAppendLogStore,
|
||||
@IAgentBlobService private readonly blobService?: IAgentBlobService,
|
||||
@IEventBus private readonly eventBus?: IEventBus,
|
||||
) {
|
||||
super();
|
||||
if (this.log !== undefined) {
|
||||
this._register(this.log.acquire(this.options.logScope, this.options.logKey));
|
||||
}
|
||||
}
|
||||
|
||||
getModel<S>(model: ModelDef<S> | DerivedModelDef<S>): DeepReadonly<S> {
|
||||
if ('reducers' in model) {
|
||||
const inst = this.derivedModels.get(model);
|
||||
return (inst?.state ?? Object.freeze(model.initial())) as DeepReadonly<S>;
|
||||
}
|
||||
return this.ensureModel(model).state as DeepReadonly<S>;
|
||||
}
|
||||
|
||||
subscribe<S>(
|
||||
model: ModelDef<S> | DerivedModelDef<S>,
|
||||
handler: (state: DeepReadonly<S>, prev: DeepReadonly<S>) => void,
|
||||
): IDisposable {
|
||||
const inst = 'reducers' in model
|
||||
? this.derivedModels.get(model)
|
||||
: this.ensureModel(model);
|
||||
if (inst === undefined) return { dispose: () => {} };
|
||||
return inst.emitter.event((change) =>
|
||||
handler(change.state as DeepReadonly<S>, change.prev as DeepReadonly<S>),
|
||||
);
|
||||
}
|
||||
|
||||
onEmission(handler: (emission: WireEmission) => void): IDisposable {
|
||||
return this.emissionEmitter.event(handler);
|
||||
}
|
||||
|
||||
onRestored(handler: () => void | Promise<void>): IDisposable {
|
||||
this.restoredHandlers.add(handler);
|
||||
return toDisposable(() => this.restoredHandlers.delete(handler));
|
||||
}
|
||||
|
||||
attach<S>(model: DerivedModelDef<S>): IDisposable {
|
||||
const inst: ModelInstance = {
|
||||
state: Object.freeze(model.initial()),
|
||||
emitter: new Emitter<ModelChange<unknown>>(),
|
||||
};
|
||||
this._register(inst.emitter);
|
||||
this.derivedModels.set(model, inst);
|
||||
|
||||
for (const [opType, reducer] of Object.entries(model.reducers)) {
|
||||
if (reducer === undefined) continue;
|
||||
let list = this.reducerIndex.get(opType);
|
||||
if (list === undefined) {
|
||||
list = [];
|
||||
this.reducerIndex.set(opType, list);
|
||||
}
|
||||
list.push({ inst, reducer });
|
||||
}
|
||||
|
||||
return {
|
||||
dispose: () => {
|
||||
this.derivedModels.delete(model);
|
||||
for (const [opType, list] of this.reducerIndex) {
|
||||
const filtered = list.filter((e) => e.inst !== inst);
|
||||
if (filtered.length === 0) {
|
||||
this.reducerIndex.delete(opType);
|
||||
} else if (filtered.length !== list.length) {
|
||||
this.reducerIndex.set(opType, filtered);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
dispatch(...ops: Op[]): void {
|
||||
if (ops.length === 0) return;
|
||||
if (this.dispatching) {
|
||||
this.queue.push(...ops);
|
||||
return;
|
||||
}
|
||||
this.dispatching = true;
|
||||
try {
|
||||
this.execute({ ops, silent: false });
|
||||
while (this.queue.length > 0) {
|
||||
if (++this.drainDepth > MAX_DRAIN) {
|
||||
throw new CycleError(this.drainDepth, this.queue.map((op) => op.type));
|
||||
}
|
||||
this.execute({ ops: this.queue.splice(0), silent: false });
|
||||
}
|
||||
} finally {
|
||||
this.queue.length = 0;
|
||||
this.dispatching = false;
|
||||
this.drainDepth = 0;
|
||||
}
|
||||
}
|
||||
|
||||
async replay(...records: PersistedRecord[]): Promise<ReplayResult> {
|
||||
const ops: Op[] = [];
|
||||
let unknownRecords = 0;
|
||||
for (let index = 0; index < records.length; index++) {
|
||||
const record = records[index]!;
|
||||
const descriptor = OP_REGISTRY.get(record.type);
|
||||
if (descriptor === undefined) {
|
||||
unknownRecords++;
|
||||
onUnexpectedError(
|
||||
new WireError(
|
||||
WireErrors.codes.WIRE_UNKNOWN_RECORD,
|
||||
`Unknown wire record type '${record.type}' skipped during replay`,
|
||||
{ details: { type: record.type, index } },
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
ops.push({ type: record.type, payload: recordToPayload(record), descriptor });
|
||||
}
|
||||
this.execute({ ops, silent: true });
|
||||
await this.rehydrateModels();
|
||||
await this.fireRestored();
|
||||
return { unknownRecords };
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
await this.persistQueue;
|
||||
await this.log?.flush();
|
||||
}
|
||||
|
||||
private execute(group: OpGroup): void {
|
||||
const changes: { inst: ModelInstance; change: ModelChange<unknown> }[] = [];
|
||||
|
||||
for (const op of group.ops) {
|
||||
const inst = this.ensureModel(op.descriptor.model);
|
||||
const prev = inst.state;
|
||||
inst.state = Object.freeze(op.descriptor.apply(prev, op.payload));
|
||||
if (!group.silent) {
|
||||
if (op.descriptor.persist !== false) {
|
||||
const record = this.toRecord(op);
|
||||
this.emissionEmitter.fire({ type: 'record', record });
|
||||
this.appendToWireLog(record, op.descriptor.model);
|
||||
}
|
||||
const event = op.descriptor.toEvent?.(op.payload, inst.state);
|
||||
if (event !== undefined && this.eventBus !== undefined) {
|
||||
this.eventBus.publish(event as DomainEvent);
|
||||
}
|
||||
}
|
||||
if (inst.state !== prev) {
|
||||
changes.push({ inst, change: { state: inst.state, prev } });
|
||||
}
|
||||
|
||||
const entries = this.reducerIndex.get(op.type);
|
||||
if (entries !== undefined) {
|
||||
for (const entry of entries) {
|
||||
const dPrev = entry.inst.state;
|
||||
entry.inst.state = Object.freeze(entry.reducer(dPrev, op.payload));
|
||||
if (entry.inst.state !== dPrev) {
|
||||
changes.push({ inst: entry.inst, change: { state: entry.inst.state, prev: dPrev } });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const crossReducers = MODEL_CROSS_REDUCERS.get(op.type);
|
||||
if (crossReducers !== undefined) {
|
||||
for (const entry of crossReducers) {
|
||||
if (entry.model === op.descriptor.model) continue;
|
||||
const crossInst = this.ensureModel(entry.model);
|
||||
const crossPrev = crossInst.state;
|
||||
crossInst.state = Object.freeze(entry.reducer(crossPrev, op.payload));
|
||||
if (crossInst.state !== crossPrev) {
|
||||
changes.push({
|
||||
inst: crossInst,
|
||||
change: { state: crossInst.state, prev: crossPrev },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!group.silent) {
|
||||
for (const { inst, change } of changes) {
|
||||
inst.emitter.fire(change);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private ensureModel<S>(def: ModelDef<S>): ModelInstance {
|
||||
let inst = this.models.get(def);
|
||||
if (inst === undefined) {
|
||||
inst = {
|
||||
state: Object.freeze(def.initial()),
|
||||
emitter: new Emitter<ModelChange<unknown>>(),
|
||||
};
|
||||
this._register(inst.emitter);
|
||||
this.models.set(def, inst);
|
||||
}
|
||||
return inst;
|
||||
}
|
||||
|
||||
private toRecord(op: Op): PersistedRecord {
|
||||
const payload = op.payload;
|
||||
const record: Record<string, unknown> =
|
||||
payload !== null && typeof payload === 'object' && !Array.isArray(payload)
|
||||
? { type: op.type, ...(payload as Record<string, unknown>) }
|
||||
: { type: op.type, payload };
|
||||
if (op.descriptor.stamp !== false && record['time'] === undefined) {
|
||||
record['time'] = Date.now();
|
||||
}
|
||||
return record as PersistedRecord;
|
||||
}
|
||||
|
||||
private async fireRestored(): Promise<void> {
|
||||
for (const handler of Array.from(this.restoredHandlers)) {
|
||||
try {
|
||||
await handler();
|
||||
} catch (error) {
|
||||
onUnexpectedError(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
private appendToWireLog(record: PersistedRecord, model: ModelDef<any>): void {
|
||||
if (this.log === undefined) return;
|
||||
if (this.blobService === undefined) {
|
||||
this.log.append(this.options.logScope, this.options.logKey, record, {
|
||||
onError: onUnexpectedError,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const dehydrate = model.blobs?.dehydrate?.bind(model.blobs);
|
||||
const transform: PartsTransformer = (parts) =>
|
||||
this.blobService!.offloadParts(
|
||||
parts as readonly ContentPart[],
|
||||
) as Promise<readonly unknown[]>;
|
||||
this.persistQueue = this.persistQueue
|
||||
.then(async () => {
|
||||
let out = record;
|
||||
if (dehydrate !== undefined) {
|
||||
const prepared = dehydrate(record, transform);
|
||||
out = isPromise(prepared) ? await prepared : prepared;
|
||||
}
|
||||
this.log?.append(this.options.logScope, this.options.logKey, out, {
|
||||
onError: onUnexpectedError,
|
||||
});
|
||||
})
|
||||
.catch((error: unknown) => onUnexpectedError(error));
|
||||
}
|
||||
|
||||
private async rehydrateModels(): Promise<void> {
|
||||
if (this.blobService === undefined) return;
|
||||
const transform: PartsTransformer = (parts) =>
|
||||
this.blobService!.loadParts(
|
||||
parts as readonly ContentPart[],
|
||||
) as Promise<readonly unknown[]>;
|
||||
for (const [def, inst] of this.models) {
|
||||
if (def.blobs?.rehydrate === undefined) continue;
|
||||
const result = def.blobs.rehydrate(inst.state, transform);
|
||||
inst.state = Object.freeze(isPromise(result) ? await result : result);
|
||||
}
|
||||
for (const [def, inst] of this.derivedModels) {
|
||||
if (def.blobs?.rehydrate === undefined) continue;
|
||||
const result = def.blobs.rehydrate(inst.state, transform);
|
||||
inst.state = Object.freeze(isPromise(result) ? await result : result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function recordToPayload(record: PersistedRecord): unknown {
|
||||
const payload: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(record)) {
|
||||
if (key === 'type' || key === 'time') continue;
|
||||
payload[key] = record[key];
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function isPromise<T>(value: T | Promise<T>): value is Promise<T> {
|
||||
return value !== null && typeof (value as Promise<T>).then === 'function';
|
||||
}
|
||||
|
|
@ -1,11 +1,9 @@
|
|||
/**
|
||||
* `activity` kernel unit tests — drives the real `AgentActivityService` with a
|
||||
* stub Session kernel and an in-memory wire service.
|
||||
* stub Session kernel, event bus and in-memory wire service.
|
||||
*
|
||||
* Asserts the PR1 turn-lane contract: `begin` admits a turn and rejects a
|
||||
* concurrent one with `activity.agent_busy`, `cancel` moves the lane to
|
||||
* `turn(ending)` and aborts the lease signal, and `lease.end` returns the lane
|
||||
* to `idle` (idempotently). Run:
|
||||
* Asserts turn admission and lifecycle transitions plus the live projection of
|
||||
* streaming, tool calls, approvals, retries and step interruptions. Run:
|
||||
* `pnpm test -- test/activity/activity.test.ts`
|
||||
*/
|
||||
|
||||
|
|
@ -18,55 +16,72 @@ import { IAgentActivityService, ISessionActivityKernel } from '#/activity/activi
|
|||
import type { ActivityLease } from '#/activity/activity';
|
||||
import { AgentActivityService } from '#/activity/agentActivityService';
|
||||
import { SessionActivityKernel } from '#/activity/sessionActivityKernel';
|
||||
import type { PermissionApprovalRequestContext } from '#/agent/permissionGate/permissionGateService';
|
||||
import { IAgentScopeContext, makeAgentScopeContext } from '#/agent/scopeContext/scopeContext';
|
||||
import { type DomainEvent, IEventBus } from '#/app/event/eventBus';
|
||||
import { EventBusService } from '#/app/event/eventBusService';
|
||||
import { ErrorCodes } from '#/errors';
|
||||
import { IAgentWireService, ISessionWireService } from '#/wire/tokens';
|
||||
import type { IWireService } from '#/wire/wireService';
|
||||
import { WireService } from '#/wire/wireServiceImpl';
|
||||
|
||||
import { stubSessionActivityKernel } from './stubs';
|
||||
import { registerTestAgentWireServices } from '../wire/stubs';
|
||||
|
||||
describe('AgentActivityService (turn lane)', () => {
|
||||
let disposables: DisposableStore;
|
||||
let ix: TestInstantiationService;
|
||||
let activity: IAgentActivityService;
|
||||
let eventBus: IEventBus;
|
||||
|
||||
beforeEach(() => {
|
||||
disposables = new DisposableStore();
|
||||
ix = createServices(disposables, {
|
||||
additionalServices: (reg) => {
|
||||
reg.defineInstance(
|
||||
IAgentWireService,
|
||||
disposables.add(new WireService({ logScope: 'wire', logKey: 'activity' })),
|
||||
);
|
||||
registerTestAgentWireServices(reg, 'wire/activity');
|
||||
reg.defineInstance(ISessionActivityKernel, stubSessionActivityKernel());
|
||||
reg.defineInstance(
|
||||
IAgentScopeContext,
|
||||
makeAgentScopeContext({ agentId: 'agent', agentScope: 'agent' }),
|
||||
);
|
||||
reg.define(IEventBus, EventBusService);
|
||||
reg.define(IAgentActivityService, AgentActivityService);
|
||||
},
|
||||
});
|
||||
activity = ix.get(IAgentActivityService);
|
||||
eventBus = ix.get(IEventBus);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
disposables.dispose();
|
||||
});
|
||||
|
||||
function collectActivity(): DomainEvent<'agent.activity.updated'>[] {
|
||||
const snapshots: DomainEvent<'agent.activity.updated'>[] = [];
|
||||
disposables.add(
|
||||
eventBus.subscribe('agent.activity.updated', (snapshot) => snapshots.push(snapshot)),
|
||||
);
|
||||
return snapshots;
|
||||
}
|
||||
|
||||
function startTurn(): ActivityLease {
|
||||
activity.markReady();
|
||||
const lease = activity.begin('turn', { turnId: 1 });
|
||||
eventBus.publish({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } });
|
||||
eventBus.publish({ type: 'turn.step.started', turnId: 1, step: 1, stepId: 's1' });
|
||||
return lease;
|
||||
}
|
||||
|
||||
it('starts initializing and admits a turn only after markReady', () => {
|
||||
expect(activity.lane()).toBe('initializing');
|
||||
expect(activity.isIdle()).toBe(false);
|
||||
expect(() => activity.begin('turn')).toThrowError(
|
||||
expect.objectContaining({ code: ErrorCodes.ACTIVITY_INITIALIZING }),
|
||||
);
|
||||
activity.markReady();
|
||||
expect(activity.lane()).toBe('idle');
|
||||
expect(activity.isIdle()).toBe(true);
|
||||
const lease: ActivityLease = activity.begin('turn');
|
||||
expect(lease.kind).toBe('turn');
|
||||
expect(lease.signal.aborted).toBe(false);
|
||||
expect(activity.lane()).toBe('turn');
|
||||
expect(activity.isIdle()).toBe(false);
|
||||
lease.end('completed');
|
||||
expect(activity.lane()).toBe('idle');
|
||||
expect(activity.isIdle()).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a concurrent begin with activity.agent_busy', () => {
|
||||
|
|
@ -85,17 +100,155 @@ describe('AgentActivityService (turn lane)', () => {
|
|||
lease.end('completed');
|
||||
});
|
||||
|
||||
it('cancel aborts the lease signal and keeps the lane until end', () => {
|
||||
it('cancel aborts the lease signal and keeps the turn active until end', () => {
|
||||
activity.markReady();
|
||||
const lease = activity.begin('turn');
|
||||
expect(activity.cancel('stop')).toBe(true);
|
||||
expect(lease.signal.aborted).toBe(true);
|
||||
expect(lease.ending).toBe(true);
|
||||
expect(activity.lane()).toBe('turn');
|
||||
expect(activity.isIdle()).toBe(false);
|
||||
lease.end('cancelled');
|
||||
expect(activity.lane()).toBe('idle');
|
||||
expect(activity.isIdle()).toBe(true);
|
||||
});
|
||||
|
||||
it('publishes lifecycle independently from turn activity', () => {
|
||||
const states: Array<{ lifecycle: string; hasTurn: boolean; ending?: boolean }> = [];
|
||||
disposables.add(
|
||||
eventBus.subscribe('agent.activity.updated', (state) => {
|
||||
states.push({
|
||||
lifecycle: state.lifecycle,
|
||||
hasTurn: state.turn !== undefined,
|
||||
ending: state.turn?.ending,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
activity.markReady();
|
||||
const lease = activity.begin('turn');
|
||||
activity.cancel();
|
||||
lease.end('cancelled');
|
||||
|
||||
expect(states).toEqual([
|
||||
{ lifecycle: 'ready', hasTurn: false, ending: undefined },
|
||||
{ lifecycle: 'ready', hasTurn: true, ending: false },
|
||||
{ lifecycle: 'ready', hasTurn: true, ending: true },
|
||||
{ lifecycle: 'ready', hasTurn: false, ending: undefined },
|
||||
]);
|
||||
});
|
||||
|
||||
it('publishes the first streaming delta and suppresses equivalent deltas', () => {
|
||||
const snapshots = collectActivity();
|
||||
const lease = startTurn();
|
||||
const baseline = snapshots.length;
|
||||
|
||||
eventBus.publish({ type: 'assistant.delta', turnId: 1, delta: 'he' });
|
||||
eventBus.publish({ type: 'assistant.delta', turnId: 1, delta: 'llo' });
|
||||
|
||||
expect(snapshots).toHaveLength(baseline + 1);
|
||||
expect(snapshots.at(-1)?.turn).toMatchObject({ phase: 'streaming', stream: 'assistant' });
|
||||
lease.end('completed');
|
||||
});
|
||||
|
||||
it('projects active tool calls until their results arrive', () => {
|
||||
const snapshots = collectActivity();
|
||||
const lease = startTurn();
|
||||
|
||||
eventBus.publish({ type: 'tool.call.started', turnId: 1, toolCallId: 'c1', name: 'Read', args: {} });
|
||||
eventBus.publish({ type: 'tool.call.started', turnId: 1, toolCallId: 'c2', name: 'Write', args: {} });
|
||||
expect(snapshots.at(-1)?.turn?.activeToolCalls.map((tool) => tool.toolCallId)).toEqual([
|
||||
'c1',
|
||||
'c2',
|
||||
]);
|
||||
|
||||
eventBus.publish({ type: 'tool.result', turnId: 1, toolCallId: 'c1', output: 'ok', isError: false });
|
||||
expect(snapshots.at(-1)?.turn?.activeToolCalls.map((tool) => tool.toolCallId)).toEqual(['c2']);
|
||||
lease.end('completed');
|
||||
});
|
||||
|
||||
it('projects all pending approvals until each is resolved', () => {
|
||||
const snapshots = collectActivity();
|
||||
const lease = startTurn();
|
||||
const approval = (toolCallId: string): PermissionApprovalRequestContext =>
|
||||
({
|
||||
toolCallId,
|
||||
toolName: 'Read',
|
||||
action: 'read',
|
||||
display: {},
|
||||
turnId: 1,
|
||||
toolInput: { path: '/tmp/example' },
|
||||
}) as unknown as PermissionApprovalRequestContext;
|
||||
|
||||
eventBus.publish({ type: 'permission.approval.requested', ...approval('c1') });
|
||||
eventBus.publish({ type: 'permission.approval.requested', ...approval('c2') });
|
||||
expect(snapshots.at(-1)?.turn?.pendingApprovals.map((item) => item.toolCallId)).toEqual([
|
||||
'c1',
|
||||
'c2',
|
||||
]);
|
||||
|
||||
eventBus.publish({
|
||||
type: 'permission.approval.resolved',
|
||||
...approval('c1'),
|
||||
decision: 'approved',
|
||||
});
|
||||
expect(snapshots.at(-1)?.turn?.pendingApprovals.map((item) => item.toolCallId)).toEqual(['c2']);
|
||||
lease.end('completed');
|
||||
});
|
||||
|
||||
it('projects retry state for the active turn', () => {
|
||||
const snapshots = collectActivity();
|
||||
const lease = startTurn();
|
||||
|
||||
eventBus.publish({
|
||||
type: 'turn.step.retrying',
|
||||
turnId: 1,
|
||||
step: 1,
|
||||
stepId: 's1',
|
||||
failedAttempt: 1,
|
||||
nextAttempt: 2,
|
||||
maxAttempts: 3,
|
||||
delayMs: 500,
|
||||
errorName: 'RateLimitError',
|
||||
errorMessage: 'slow down',
|
||||
statusCode: 429,
|
||||
});
|
||||
|
||||
expect(snapshots.at(-1)?.turn).toMatchObject({
|
||||
phase: 'retrying',
|
||||
retry: {
|
||||
failedAttempt: 1,
|
||||
nextAttempt: 2,
|
||||
maxAttempts: 3,
|
||||
delayMs: 500,
|
||||
errorName: 'RateLimitError',
|
||||
statusCode: 429,
|
||||
},
|
||||
});
|
||||
lease.end('completed');
|
||||
});
|
||||
|
||||
it.each(['max_steps', 'error'] as const)(
|
||||
'projects %s as the ending reason when a step is interrupted',
|
||||
(reason) => {
|
||||
const snapshots = collectActivity();
|
||||
const lease = startTurn();
|
||||
|
||||
eventBus.publish({
|
||||
type: 'turn.step.interrupted',
|
||||
turnId: 1,
|
||||
step: 1,
|
||||
reason,
|
||||
});
|
||||
|
||||
expect(snapshots.at(-1)?.turn).toMatchObject({
|
||||
turnId: 1,
|
||||
step: 1,
|
||||
ending: true,
|
||||
endingReason: reason,
|
||||
});
|
||||
lease.end('failed');
|
||||
},
|
||||
);
|
||||
|
||||
it('cancel is a no-op when idle', () => {
|
||||
activity.markReady();
|
||||
expect(activity.cancel()).toBe(false);
|
||||
|
|
@ -106,19 +259,22 @@ describe('AgentActivityService (turn lane)', () => {
|
|||
const lease = activity.begin('turn');
|
||||
lease.end('completed');
|
||||
expect(() => lease.end('completed')).not.toThrow();
|
||||
expect(activity.lane()).toBe('idle');
|
||||
expect(activity.isIdle()).toBe(true);
|
||||
});
|
||||
|
||||
it('beginDisposal aborts the in-flight lease and settles after end', async () => {
|
||||
const states = collectActivity();
|
||||
activity.markReady();
|
||||
const lease = activity.begin('turn');
|
||||
activity.beginDisposal();
|
||||
expect(lease.signal.aborted).toBe(true);
|
||||
expect(activity.lane()).toBe('disposing');
|
||||
expect(activity.isIdle()).toBe(false);
|
||||
expect(states.at(-1)).toMatchObject({ lifecycle: 'disposing', turn: { turnId: lease.turnId } });
|
||||
const settled = activity.settled();
|
||||
lease.end('cancelled');
|
||||
await settled;
|
||||
expect(activity.lane()).toBe('disposed');
|
||||
expect(activity.isIdle()).toBe(false);
|
||||
expect(states.at(-1)).toMatchObject({ lifecycle: 'disposed', turn: undefined });
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -126,25 +282,9 @@ describe('SessionActivityKernel (session lane)', () => {
|
|||
let host: ReturnType<typeof createScopedTestHost>;
|
||||
let kernel: ISessionActivityKernel;
|
||||
|
||||
function stubWire(): IWireService {
|
||||
return {
|
||||
_serviceBrand: undefined,
|
||||
dispatch: () => undefined,
|
||||
replay: () => Promise.resolve(),
|
||||
flush: () => Promise.resolve(),
|
||||
attach: () => ({ dispose: () => undefined }),
|
||||
getModel: (model: { initial: () => unknown }) => model.initial(),
|
||||
subscribe: () => ({ dispose: () => undefined }),
|
||||
onEmission: () => ({ dispose: () => undefined }),
|
||||
onRestored: () => ({ dispose: () => undefined }),
|
||||
} as unknown as IWireService;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
host = createScopedTestHost();
|
||||
const session = host.child(LifecycleScope.Session, 'session', [
|
||||
[ISessionWireService, stubWire()],
|
||||
]);
|
||||
const session = host.child(LifecycleScope.Session, 'session');
|
||||
kernel = session.accessor.get(ISessionActivityKernel);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import { IAgentProfileService } from '#/agent/profile/profile';
|
|||
import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder';
|
||||
import { AgentSystemReminderService } from '#/agent/systemReminder/systemReminderService';
|
||||
import { IEventBus } from '#/app/event/eventBus';
|
||||
import { IAgentWireService } from '#/wire/tokens';
|
||||
import { IWireService } from '#/wire/wire';
|
||||
import { registerContextMemoryServices, type StubContextMemory } from '../contextMemory/stubs';
|
||||
import { stubLoopWithHooks, stubWire } from '../loop/stubs';
|
||||
|
||||
|
|
@ -71,7 +71,7 @@ describe('AgentContextInjectorService', () => {
|
|||
strict: true,
|
||||
additionalServices: (reg) => {
|
||||
reg.defineInstance(IAgentLoopService, stubLoopWithHooks());
|
||||
reg.defineInstance(IAgentWireService, stubWire());
|
||||
reg.defineInstance(IWireService, stubWire());
|
||||
reg.define(IAgentSystemReminderService, AgentSystemReminderService);
|
||||
reg.define(IAgentContextInjectorService, AgentContextInjectorService);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -3,8 +3,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|||
|
||||
import { estimateTokensForMessages } from '#/_base/utils/tokens';
|
||||
import type { ContextMessage } from '#/agent/contextMemory/types';
|
||||
import { IAgentWireService } from '#/wire/tokens';
|
||||
import type { IWireService } from '#/wire/wireService';
|
||||
import { IWireService } from '#/wire/wire';
|
||||
import {
|
||||
IAgentContextMemoryService,
|
||||
IAgentContextSizeService,
|
||||
|
|
@ -25,7 +24,7 @@ describe('Agent context', () => {
|
|||
context = ctx.get(IAgentContextMemoryService);
|
||||
contextSize = ctx.get(IAgentContextSizeService);
|
||||
profile = ctx.get(IAgentProfileService);
|
||||
wire = ctx.get(IAgentWireService);
|
||||
wire = ctx.get(IWireService);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import {
|
|||
} from '#/agent/contextMemory/contextTranscript';
|
||||
import type { LoopRecordedEvent } from '#/agent/contextMemory/loopEventFold';
|
||||
import type { ContextMessage, PromptOrigin } from '#/agent/contextMemory/types';
|
||||
import type { PersistedRecord } from '#/wire/wireService';
|
||||
import type { WireRecord } from '#/wire/record';
|
||||
|
||||
function userMessage(text: string, origin?: PromptOrigin): ContextMessage {
|
||||
return {
|
||||
|
|
@ -29,15 +29,15 @@ function assistantMessage(text: string): ContextMessage {
|
|||
return { role: 'assistant', content: [{ type: 'text', text }], toolCalls: [] };
|
||||
}
|
||||
|
||||
function appendMessage(message: ContextMessage): PersistedRecord {
|
||||
function appendMessage(message: ContextMessage): WireRecord {
|
||||
return { type: 'context.append_message', message };
|
||||
}
|
||||
|
||||
function loopEvent(event: LoopRecordedEvent): PersistedRecord {
|
||||
function loopEvent(event: LoopRecordedEvent): WireRecord {
|
||||
return { type: 'context.append_loop_event', event };
|
||||
}
|
||||
|
||||
function assistantStep(uuid: string, text: string): PersistedRecord[] {
|
||||
function assistantStep(uuid: string, text: string): WireRecord[] {
|
||||
return [
|
||||
loopEvent({ type: 'step.begin', uuid }),
|
||||
loopEvent({ type: 'content.part', stepUuid: uuid, part: { type: 'text', text } }),
|
||||
|
|
@ -50,7 +50,7 @@ function compaction(
|
|||
compactedCount: number,
|
||||
keptUserMessageCount?: number,
|
||||
keptHeadUserMessageCount?: number,
|
||||
): PersistedRecord {
|
||||
): WireRecord {
|
||||
return {
|
||||
type: 'context.apply_compaction',
|
||||
summary,
|
||||
|
|
@ -63,7 +63,7 @@ function compaction(
|
|||
};
|
||||
}
|
||||
|
||||
function undo(count: number): PersistedRecord {
|
||||
function undo(count: number): WireRecord {
|
||||
return { type: 'context.undo', count };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,12 +6,10 @@ import { TestInstantiationService } from '#/_base/di/test';
|
|||
import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory';
|
||||
import type { ContextMessage } from '#/agent/contextMemory/types';
|
||||
import { AgentContextMemoryService } from '#/agent/contextMemory/contextMemoryService';
|
||||
import { IAgentWireRecordService } from '#/agent/wireRecord/wireRecord';
|
||||
import { IEventBus } from '#/app/event/eventBus';
|
||||
import { EventBusService } from '#/app/event/eventBusService';
|
||||
import { IAgentWireService } from '#/wire/tokens';
|
||||
import { WireService } from '#/wire/wireServiceImpl';
|
||||
import { stubWireRecord } from './stubs';
|
||||
|
||||
import { registerTestAgentWire } from '../../wire/stubs';
|
||||
|
||||
function textMessage(role: ContextMessage['role'], text: string): ContextMessage {
|
||||
return {
|
||||
|
|
@ -35,9 +33,8 @@ describe('message history (IAgentContextMemoryService)', () => {
|
|||
beforeEach(() => {
|
||||
disposables = new DisposableStore();
|
||||
ix = disposables.add(new TestInstantiationService());
|
||||
ix.stub(IAgentWireRecordService, stubWireRecord());
|
||||
ix.set(IAgentWireService, new SyncDescriptor(WireService, [{ logScope: 'wire', logKey: 'message' }]));
|
||||
ix.set(IEventBus, new SyncDescriptor(EventBusService));
|
||||
registerTestAgentWire(ix, 'wire/message-history', { eventBus: ix.get(IEventBus) });
|
||||
ix.set(IAgentContextMemoryService, new SyncDescriptor(AgentContextMemoryService));
|
||||
});
|
||||
afterEach(() => disposables.dispose());
|
||||
|
|
|
|||
|
|
@ -32,9 +32,10 @@ import { AppendLogStore } from '#/persistence/backends/node-fs/appendLogStore';
|
|||
import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService';
|
||||
import { IAppendLogStore } from '#/persistence/interface/appendLogStore';
|
||||
import { IFileSystemStorageService } from '#/persistence/interface/storage';
|
||||
import { IAgentWireService } from '#/wire/tokens';
|
||||
import type { IWireService, PersistedRecord } from '#/wire/wireService';
|
||||
import { WireService } from '#/wire/wireServiceImpl';
|
||||
import { IWireService } from '#/wire/wire';
|
||||
import { AGENT_WIRE_RECORD_KEY, type WireRecord } from '#/wire/record';
|
||||
|
||||
import { registerTestAgentWire, restoreTestAgentWire, testWireScope } from '../../wire/stubs';
|
||||
|
||||
const SCOPE = 'wire';
|
||||
const KEY = 'ctx-live';
|
||||
|
|
@ -152,26 +153,25 @@ function buildHost(key: string): Host {
|
|||
const ix = disposables.add(new TestInstantiationService());
|
||||
ix.stub(IFileSystemStorageService, new InMemoryStorageService());
|
||||
ix.set(IAppendLogStore, new SyncDescriptor(AppendLogStore));
|
||||
ix.set(
|
||||
IAgentWireService,
|
||||
new SyncDescriptor(WireService, [
|
||||
{ logScope: SCOPE, logKey: key },
|
||||
]),
|
||||
);
|
||||
ix.stub(IAgentBlobService, blob);
|
||||
ix.set(IEventBus, new SyncDescriptor(EventBusService));
|
||||
ix.set(IAgentContextMemoryService, new SyncDescriptor(AgentContextMemoryService));
|
||||
const wire = registerTestAgentWire(ix, testWireScope(SCOPE, key), {
|
||||
log: ix.get(IAppendLogStore),
|
||||
blob,
|
||||
eventBus: ix.get(IEventBus),
|
||||
});
|
||||
return {
|
||||
wire: ix.get(IAgentWireService),
|
||||
wire,
|
||||
svc: ix.get(IAgentContextMemoryService),
|
||||
log: ix.get(IAppendLogStore),
|
||||
eventBus: ix.get(IEventBus),
|
||||
};
|
||||
}
|
||||
|
||||
async function readRecords(log: IAppendLogStore, key = KEY): Promise<PersistedRecord[]> {
|
||||
const out: PersistedRecord[] = [];
|
||||
for await (const record of log.read<PersistedRecord>(SCOPE, key)) {
|
||||
async function readRecords(log: IAppendLogStore, key = KEY): Promise<WireRecord[]> {
|
||||
const out: WireRecord[] = [];
|
||||
for await (const record of log.read<WireRecord>(testWireScope(SCOPE, key), AGENT_WIRE_RECORD_KEY)) {
|
||||
out.push(record);
|
||||
}
|
||||
return out;
|
||||
|
|
@ -236,7 +236,7 @@ describe('AgentContextMemoryService (wire-backed)', () => {
|
|||
});
|
||||
|
||||
it('folds v1 context.append_loop_event records into the ContextModel on replay', async () => {
|
||||
const records: PersistedRecord[] = [
|
||||
const records: WireRecord[] = [
|
||||
{ type: 'context.append_message', message: userMessage('q') },
|
||||
{ type: 'context.append_loop_event', event: { type: 'step.begin', uuid: 's1', turnId: '0', step: 1 } },
|
||||
{
|
||||
|
|
@ -276,7 +276,12 @@ describe('AgentContextMemoryService (wire-backed)', () => {
|
|||
];
|
||||
|
||||
const replay = buildHost(REPLAY_KEY);
|
||||
await replay.wire.replay(...records);
|
||||
await restoreTestAgentWire(
|
||||
replay.wire,
|
||||
replay.log,
|
||||
testWireScope(SCOPE, REPLAY_KEY),
|
||||
records,
|
||||
);
|
||||
|
||||
const model = replay.wire.getModel(ContextModel) as readonly ContextMessage[];
|
||||
expect(model.map((message) => message.role)).toEqual(['user', 'assistant', 'tool']);
|
||||
|
|
@ -290,7 +295,7 @@ describe('AgentContextMemoryService (wire-backed)', () => {
|
|||
});
|
||||
|
||||
it('replays v1 context.apply_compaction records with contextSummary as the model summary', async () => {
|
||||
const records: PersistedRecord[] = [
|
||||
const records: WireRecord[] = [
|
||||
{ type: 'context.append_message', message: userMessage('old') },
|
||||
{ type: 'context.append_message', message: userMessage('tail') },
|
||||
{
|
||||
|
|
@ -304,7 +309,12 @@ describe('AgentContextMemoryService (wire-backed)', () => {
|
|||
];
|
||||
|
||||
const replay = buildHost(REPLAY_KEY);
|
||||
await replay.wire.replay(...records);
|
||||
await restoreTestAgentWire(
|
||||
replay.wire,
|
||||
replay.log,
|
||||
testWireScope(SCOPE, REPLAY_KEY),
|
||||
records,
|
||||
);
|
||||
|
||||
const model = replay.wire.getModel(ContextModel) as readonly ContextMessage[];
|
||||
expect(model.map(textOf)).toEqual(['model-facing summary', 'tail']);
|
||||
|
|
@ -315,7 +325,7 @@ describe('AgentContextMemoryService (wire-backed)', () => {
|
|||
});
|
||||
|
||||
it('replays new context.apply_compaction records with kept user messages before contextSummary', async () => {
|
||||
const records: PersistedRecord[] = [
|
||||
const records: WireRecord[] = [
|
||||
{ type: 'context.append_message', message: userMessage('old user') },
|
||||
{
|
||||
type: 'context.append_message',
|
||||
|
|
@ -338,7 +348,12 @@ describe('AgentContextMemoryService (wire-backed)', () => {
|
|||
];
|
||||
|
||||
const replay = buildHost(REPLAY_KEY);
|
||||
await replay.wire.replay(...records);
|
||||
await restoreTestAgentWire(
|
||||
replay.wire,
|
||||
replay.log,
|
||||
testWireScope(SCOPE, REPLAY_KEY),
|
||||
records,
|
||||
);
|
||||
|
||||
const model = replay.wire.getModel(ContextModel) as readonly ContextMessage[];
|
||||
expect(model.map((message) => message.role)).toEqual(['user', 'user', 'user']);
|
||||
|
|
@ -349,7 +364,7 @@ describe('AgentContextMemoryService (wire-backed)', () => {
|
|||
});
|
||||
|
||||
it('replays pre-contextSummary kept-user records without adding a new prefix', async () => {
|
||||
const records: PersistedRecord[] = [
|
||||
const records: WireRecord[] = [
|
||||
{ type: 'context.append_message', message: userMessage('old user') },
|
||||
{ type: 'context.append_message', message: userMessage('recent user') },
|
||||
{
|
||||
|
|
@ -363,7 +378,12 @@ describe('AgentContextMemoryService (wire-backed)', () => {
|
|||
];
|
||||
|
||||
const replay = buildHost(REPLAY_KEY);
|
||||
await replay.wire.replay(...records);
|
||||
await restoreTestAgentWire(
|
||||
replay.wire,
|
||||
replay.log,
|
||||
testWireScope(SCOPE, REPLAY_KEY),
|
||||
records,
|
||||
);
|
||||
|
||||
const model = replay.wire.getModel(ContextModel) as readonly ContextMessage[];
|
||||
expect(model.map(textOf)).toEqual(['old user', 'recent user', 'OLD SUMMARY']);
|
||||
|
|
@ -380,7 +400,7 @@ describe('AgentContextMemoryService (wire-backed)', () => {
|
|||
toolCalls: [],
|
||||
origin: { kind: 'compaction_summary' },
|
||||
};
|
||||
const records: PersistedRecord[] = [
|
||||
const records: WireRecord[] = [
|
||||
{ type: 'context.append_message', message: userMessage('old') },
|
||||
{ type: 'context.append_message', message: userMessage('tail') },
|
||||
{
|
||||
|
|
@ -391,7 +411,12 @@ describe('AgentContextMemoryService (wire-backed)', () => {
|
|||
];
|
||||
|
||||
const replay = buildHost(REPLAY_KEY);
|
||||
await replay.wire.replay(...records);
|
||||
await restoreTestAgentWire(
|
||||
replay.wire,
|
||||
replay.log,
|
||||
testWireScope(SCOPE, REPLAY_KEY),
|
||||
records,
|
||||
);
|
||||
|
||||
const model = replay.wire.getModel(ContextModel) as readonly ContextMessage[];
|
||||
expect(model).toHaveLength(2);
|
||||
|
|
@ -420,7 +445,12 @@ describe('AgentContextMemoryService (wire-backed)', () => {
|
|||
expect(mediaUrl(persisted)).not.toContain(big);
|
||||
|
||||
const replay = buildHost(REPLAY_KEY);
|
||||
await replay.wire.replay(...records);
|
||||
await restoreTestAgentWire(
|
||||
replay.wire,
|
||||
replay.log,
|
||||
testWireScope(SCOPE, REPLAY_KEY),
|
||||
records,
|
||||
);
|
||||
expect(blob.loadCalls).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const rebuilt = replay.wire.getModel(ContextModel) as readonly ContextMessage[];
|
||||
|
|
@ -446,7 +476,12 @@ describe('AgentContextMemoryService (wire-backed)', () => {
|
|||
disposables.add(replay.eventBus.subscribe('context.spliced', (event) => {
|
||||
replayed.push({ start: event.start, deleteCount: event.deleteCount });
|
||||
}));
|
||||
await replay.wire.replay(...records);
|
||||
await restoreTestAgentWire(
|
||||
replay.wire,
|
||||
replay.log,
|
||||
testWireScope(SCOPE, REPLAY_KEY),
|
||||
records,
|
||||
);
|
||||
expect(replayed).toHaveLength(0);
|
||||
expect(replay.wire.getModel(ContextModel) as readonly ContextMessage[]).toHaveLength(2);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/**
|
||||
* `contextMemory` test stubs — shared doubles for `IAgentContextMemoryService` and its
|
||||
* collaborator (`IAgentWireRecordService`).
|
||||
* collaborator (`IWireService`).
|
||||
*
|
||||
* Lives under `test/` (not `src/`) so test-support code stays out of the
|
||||
* production tree. Import from a relative path (`./stubs` or
|
||||
|
|
@ -19,18 +19,9 @@ import type { LoopRecordedEvent } from '#/agent/contextMemory/loopEventFold';
|
|||
import type { ContextMessage } from '#/agent/contextMemory/types';
|
||||
import { IEventBus } from '#/app/event/eventBus';
|
||||
import { EventBusService } from '#/app/event/eventBusService';
|
||||
import { IAgentWireRecordService } from '#/agent/wireRecord/wireRecord';
|
||||
import { IWireService } from '#/wire/wire';
|
||||
|
||||
export function stubWireRecord(): IAgentWireRecordService {
|
||||
return {
|
||||
_serviceBrand: undefined,
|
||||
seal: () => Promise.resolve(),
|
||||
restore: () => Promise.resolve({}),
|
||||
flush: () => Promise.resolve(),
|
||||
close: () => Promise.resolve(),
|
||||
getRecords: () => [],
|
||||
};
|
||||
}
|
||||
import { stubAgentWire } from '../../wire/stubs';
|
||||
|
||||
export interface StubContextMemory extends IAgentContextMemoryService {
|
||||
readonly messages: readonly ContextMessage[];
|
||||
|
|
@ -124,7 +115,7 @@ class StubContextMemoryService implements IAgentContextMemoryService {
|
|||
}
|
||||
|
||||
export function registerContextMemoryServices(reg: ServiceRegistration): void {
|
||||
reg.defineInstance(IAgentWireRecordService, stubWireRecord());
|
||||
reg.defineInstance(IWireService, stubAgentWire());
|
||||
reg.define(IEventBus, EventBusService);
|
||||
reg.define(IAgentContextMemoryService, StubContextMemoryService);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,9 +15,10 @@ import { AppendLogStore } from '#/persistence/backends/node-fs/appendLogStore';
|
|||
import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService';
|
||||
import { IAppendLogStore } from '#/persistence/interface/appendLogStore';
|
||||
import { IFileSystemStorageService } from '#/persistence/interface/storage';
|
||||
import { IAgentWireService } from '#/wire/tokens';
|
||||
import type { IWireService, PersistedRecord } from '#/wire/wireService';
|
||||
import { WireService } from '#/wire/wireServiceImpl';
|
||||
import { IWireService } from '#/wire/wire';
|
||||
import { AGENT_WIRE_RECORD_KEY, type WireRecord } from '#/wire/record';
|
||||
|
||||
import { registerTestAgentWire, restoreTestAgentWire, testWireScope } from '../../wire/stubs';
|
||||
|
||||
const SCOPE = 'wire';
|
||||
const KEY = 'full-compaction-test';
|
||||
|
|
@ -30,9 +31,12 @@ function buildHost(key: string): { wire: IWireService; log: IAppendLogStore; eve
|
|||
const ix = disposables.add(new TestInstantiationService());
|
||||
ix.stub(IFileSystemStorageService, new InMemoryStorageService());
|
||||
ix.set(IAppendLogStore, new SyncDescriptor(AppendLogStore));
|
||||
ix.set(IAgentWireService, new SyncDescriptor(WireService, [{ logScope: SCOPE, logKey: key }]));
|
||||
ix.set(IEventBus, new SyncDescriptor(EventBusService));
|
||||
return { wire: ix.get(IAgentWireService), log: ix.get(IAppendLogStore), eventBus: ix.get(IEventBus) };
|
||||
const wire = registerTestAgentWire(ix, testWireScope(SCOPE, key), {
|
||||
log: ix.get(IAppendLogStore),
|
||||
eventBus: ix.get(IEventBus),
|
||||
});
|
||||
return { wire, log: ix.get(IAppendLogStore), eventBus: ix.get(IEventBus) };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
@ -44,9 +48,10 @@ beforeEach(() => {
|
|||
|
||||
afterEach(() => disposables.dispose());
|
||||
|
||||
async function readRecords(key = KEY): Promise<PersistedRecord[]> {
|
||||
const out: PersistedRecord[] = [];
|
||||
for await (const record of log.read<PersistedRecord>(SCOPE, key)) {
|
||||
async function readRecords(key = KEY): Promise<WireRecord[]> {
|
||||
await wire.flush();
|
||||
const out: WireRecord[] = [];
|
||||
for await (const record of log.read<WireRecord>(testWireScope(SCOPE, key), AGENT_WIRE_RECORD_KEY)) {
|
||||
out.push(record);
|
||||
}
|
||||
return out;
|
||||
|
|
@ -97,7 +102,7 @@ describe('fullCompaction ops (wire-backed)', () => {
|
|||
expect(wire.getModel(CompactionModel)).toBe(running);
|
||||
});
|
||||
|
||||
it('replay rebuilds the phase silently (no emissions, no subscriber notifications)', async () => {
|
||||
it('replay rebuilds the phase silently', async () => {
|
||||
wire.dispatch(fullCompactionBegin({ source: 'manual' }));
|
||||
wire.dispatch(fullCompactionComplete({}));
|
||||
const records = await readRecords();
|
||||
|
|
@ -107,27 +112,36 @@ describe('fullCompaction ops (wire-backed)', () => {
|
|||
host.eventBus.subscribe((e) => {
|
||||
emissions.push(e.type);
|
||||
});
|
||||
let modelChanges = 0;
|
||||
host.wire.subscribe(CompactionModel, () => {
|
||||
modelChanges += 1;
|
||||
});
|
||||
|
||||
await host.wire.replay(...records);
|
||||
await restoreTestAgentWire(
|
||||
host.wire,
|
||||
host.log,
|
||||
testWireScope(SCOPE, 'full-compaction-replay'),
|
||||
records,
|
||||
);
|
||||
expect(host.wire.getModel(CompactionModel).phase).toBe('idle');
|
||||
expect(emissions).toEqual([]);
|
||||
expect(modelChanges).toBe(0);
|
||||
|
||||
const stranded = buildHost('full-compaction-stranded');
|
||||
await stranded.wire.replay({ type: 'full_compaction.begin', source: 'auto' });
|
||||
await restoreTestAgentWire(
|
||||
stranded.wire,
|
||||
stranded.log,
|
||||
testWireScope(SCOPE, 'full-compaction-stranded'),
|
||||
[{ type: 'full_compaction.begin', source: 'auto' }],
|
||||
);
|
||||
expect(stranded.wire.getModel(CompactionModel).phase).toBe('running');
|
||||
});
|
||||
|
||||
it('replays legacy complete payloads that carried accounting numbers', async () => {
|
||||
const host = buildHost('full-compaction-legacy-complete-replay');
|
||||
|
||||
await host.wire.replay(
|
||||
{ type: 'full_compaction.begin', source: 'manual' },
|
||||
{ type: 'full_compaction.complete', compactedCount: 1, tokensBefore: 50, tokensAfter: 10 },
|
||||
await restoreTestAgentWire(
|
||||
host.wire,
|
||||
host.log,
|
||||
testWireScope(SCOPE, 'full-compaction-legacy-complete-replay'),
|
||||
[
|
||||
{ type: 'full_compaction.begin', source: 'manual' },
|
||||
{ type: 'full_compaction.complete', compactedCount: 1, tokensBefore: 50, tokensAfter: 10 },
|
||||
],
|
||||
);
|
||||
|
||||
expect(host.wire.getModel(CompactionModel).phase).toBe('idle');
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import { IAgentLoopService, type AfterStepContext, type EnqueueReceipt, type Ste
|
|||
import { MessageStepRequest } from '#/agent/loop/stepRequest';
|
||||
import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor';
|
||||
import { IAgentUsageService } from '#/agent/usage/usage';
|
||||
import type { PersistedWireRecord } from '#/agent/wireRecord/wireRecord';
|
||||
import type { WireRecord } from '#/wire/record';
|
||||
import { type DomainEvent, IEventBus } from '#/app/event/eventBus';
|
||||
import { APIConnectionError, APIStatusError } from '#/app/llmProtocol/errors';
|
||||
import type { ToolCall } from '#/app/llmProtocol/message';
|
||||
|
|
@ -38,7 +38,7 @@ import { recordingTelemetry, type TelemetryRecord } from '../../app/telemetry/st
|
|||
import { stubLoopWithHooks, type StubLoop } from '../loop/stubs';
|
||||
|
||||
type GoalServiceTestManager = IAgentGoalService & AgentGoalService;
|
||||
type GoalRecord = PersistedWireRecord & { type: `goal.${string}` };
|
||||
type GoalRecord = WireRecord & { type: `goal.${string}` };
|
||||
type AgentEvent = DomainEvent;
|
||||
type GoalUpdatedEvent = Extract<AgentEvent, { type: 'goal.updated' }>;
|
||||
type TurnEndedInput = {
|
||||
|
|
@ -53,17 +53,17 @@ const zeroUsage: TokenUsage = {
|
|||
output: 0,
|
||||
};
|
||||
|
||||
function goalRecords(records: readonly PersistedWireRecord[]): readonly GoalRecord[] {
|
||||
function goalRecords(records: readonly WireRecord[]): readonly GoalRecord[] {
|
||||
return records.filter((record): record is GoalRecord => record.type.startsWith('goal.'));
|
||||
}
|
||||
|
||||
async function restoreGoalRecords(
|
||||
ctx: TestAgentContext,
|
||||
goals: IAgentGoalService,
|
||||
records: readonly PersistedWireRecord[],
|
||||
records: readonly WireRecord[],
|
||||
): Promise<void> {
|
||||
goals.getGoal();
|
||||
await ctx.restore(records as readonly PersistedWireRecord[]);
|
||||
await ctx.restore(records as readonly WireRecord[]);
|
||||
}
|
||||
|
||||
function makeTurn(id: number): Turn {
|
||||
|
|
@ -146,7 +146,7 @@ describe('AgentGoalService', () => {
|
|||
let ctx: TestAgentContext;
|
||||
let context: IAgentContextMemoryService;
|
||||
let goals: GoalServiceTestManager;
|
||||
let records: PersistedWireRecord[];
|
||||
let records: WireRecord[];
|
||||
let events: GoalUpdatedEvent[];
|
||||
let telemetry: TelemetryRecord[];
|
||||
|
||||
|
|
@ -241,7 +241,7 @@ describe('AgentGoalService', () => {
|
|||
it('replaces an existing goal when replace is set', async () => {
|
||||
const first = await goals.createGoal({ objective: 'first' });
|
||||
const second = await goals.createGoal({ objective: 'second', replace: true });
|
||||
await ctx.wireRecord.flush();
|
||||
await ctx.wire.flush();
|
||||
|
||||
expect(second.goalId).not.toBe(first.goalId);
|
||||
expect(goals.getGoal().goal?.objective).toBe('second');
|
||||
|
|
@ -469,7 +469,7 @@ describe('AgentGoalService', () => {
|
|||
await goals.markBlocked({ reason: 'stuck' });
|
||||
await goals.resumeGoal();
|
||||
await goals.cancelGoal();
|
||||
await ctx.wireRecord.flush();
|
||||
await ctx.wire.flush();
|
||||
|
||||
const recordsWithoutMetadata = goalRecords(records);
|
||||
expect(recordsWithoutMetadata).toEqual([
|
||||
|
|
@ -548,7 +548,7 @@ describe('AgentGoalService', () => {
|
|||
status: 'paused',
|
||||
terminalReason: 'Paused after agent resume',
|
||||
});
|
||||
expect(goalRecords(records)).toEqual([
|
||||
expect(goalRecords(records).filter((record) => record.type === 'goal.update')).toEqual([
|
||||
expect.objectContaining({
|
||||
type: 'goal.update',
|
||||
status: 'paused',
|
||||
|
|
|
|||
|
|
@ -21,9 +21,10 @@ import { AppendLogStore } from '#/persistence/backends/node-fs/appendLogStore';
|
|||
import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService';
|
||||
import { IAppendLogStore } from '#/persistence/interface/appendLogStore';
|
||||
import { IFileSystemStorageService } from '#/persistence/interface/storage';
|
||||
import { IAgentWireService } from '#/wire/tokens';
|
||||
import type { IWireService, PersistedRecord } from '#/wire/wireService';
|
||||
import { WireService } from '#/wire/wireServiceImpl';
|
||||
import { IWireService } from '#/wire/wire';
|
||||
import { AGENT_WIRE_RECORD_KEY, type WireRecord } from '#/wire/record';
|
||||
|
||||
import { registerTestAgentWire, restoreTestAgentWire, testWireScope } from '../../wire/stubs';
|
||||
|
||||
const SCOPE = 'wire';
|
||||
const KEY = 'goal-test';
|
||||
|
|
@ -102,7 +103,6 @@ function buildHost(key: string): {
|
|||
const ix = disposables.add(new TestInstantiationService());
|
||||
ix.stub(IFileSystemStorageService, new InMemoryStorageService());
|
||||
ix.set(IAppendLogStore, new SyncDescriptor(AppendLogStore));
|
||||
ix.set(IAgentWireService, new SyncDescriptor(WireService, [{ logScope: SCOPE, logKey: key }]));
|
||||
ix.set(IEventBus, new SyncDescriptor(EventBusService));
|
||||
ix.stub(IAgentLoopService, createLoopStub());
|
||||
ix.stub(IAgentUsageService, {
|
||||
|
|
@ -114,9 +114,13 @@ function buildHost(key: string): {
|
|||
ix.stub(ITelemetryService, createTelemetryStub());
|
||||
ix.stub(IAgentToolExecutorService, createToolExecutorStub());
|
||||
ix.stub(IConfigService, createConfigStub());
|
||||
ix.set(IAgentGoalService, new SyncDescriptor(AgentGoalService, [{}]));
|
||||
ix.set(IAgentGoalService, new SyncDescriptor(AgentGoalService));
|
||||
const wire = registerTestAgentWire(ix, testWireScope(SCOPE, key), {
|
||||
log: ix.get(IAppendLogStore),
|
||||
eventBus: ix.get(IEventBus),
|
||||
});
|
||||
return {
|
||||
wire: ix.get(IAgentWireService),
|
||||
wire,
|
||||
svc: ix.get(IAgentGoalService),
|
||||
log: ix.get(IAppendLogStore),
|
||||
eventBus: ix.get(IEventBus),
|
||||
|
|
@ -134,9 +138,10 @@ beforeEach(() => {
|
|||
|
||||
afterEach(() => disposables.dispose());
|
||||
|
||||
async function readRecords(key = KEY): Promise<PersistedRecord[]> {
|
||||
const out: PersistedRecord[] = [];
|
||||
for await (const record of log.read<PersistedRecord>(SCOPE, key)) {
|
||||
async function readRecords(key = KEY): Promise<WireRecord[]> {
|
||||
await wire.flush();
|
||||
const out: WireRecord[] = [];
|
||||
for await (const record of log.read<WireRecord>(testWireScope(SCOPE, key), AGENT_WIRE_RECORD_KEY)) {
|
||||
out.push(record);
|
||||
}
|
||||
return out;
|
||||
|
|
@ -179,24 +184,17 @@ describe('AgentGoalService (wire-backed)', () => {
|
|||
expect(records.map((record) => record.type)).toEqual(['goal.create', 'goal.clear']);
|
||||
});
|
||||
|
||||
it('goal.updated signal and model subscription are live-only and silent on replay', async () => {
|
||||
it('goal.updated is live-only and silent on replay', async () => {
|
||||
const signals: string[] = [];
|
||||
const sub = eventBus.subscribe((e) => {
|
||||
if (e.type === 'goal.updated') {
|
||||
signals.push(e.type);
|
||||
}
|
||||
});
|
||||
let modelChanges = 0;
|
||||
const modelSub = wire.subscribe(GoalModel, () => {
|
||||
modelChanges += 1;
|
||||
});
|
||||
|
||||
await svc.createGoal({ objective: 'work' });
|
||||
await svc.pauseGoal();
|
||||
expect(signals.length).toBeGreaterThanOrEqual(2);
|
||||
expect(modelChanges).toBeGreaterThanOrEqual(2);
|
||||
sub.dispose();
|
||||
modelSub.dispose();
|
||||
|
||||
const records = await readRecords();
|
||||
const host = buildHost('goal-replay');
|
||||
|
|
@ -206,37 +204,44 @@ describe('AgentGoalService (wire-backed)', () => {
|
|||
replaySignals.push(e.type);
|
||||
}
|
||||
});
|
||||
let replayModelChanges = 0;
|
||||
host.wire.subscribe(GoalModel, () => {
|
||||
replayModelChanges += 1;
|
||||
});
|
||||
|
||||
await host.wire.replay(...records);
|
||||
await restoreTestAgentWire(
|
||||
host.wire,
|
||||
host.log,
|
||||
testWireScope(SCOPE, 'goal-replay'),
|
||||
records,
|
||||
);
|
||||
expect(modelOf(host.wire)?.status).toBe('paused');
|
||||
expect(replaySignals).toEqual([]);
|
||||
expect(replayModelChanges).toBe(0);
|
||||
});
|
||||
|
||||
it('onRestored forces a replayed active goal to paused after replay', async () => {
|
||||
it('onDidRestore forces a replayed active goal to paused after replay', async () => {
|
||||
const created = await svc.createGoal({ objective: 'resume me' });
|
||||
const records = await readRecords();
|
||||
|
||||
const host = buildHost('goal-restore');
|
||||
void host.svc;
|
||||
|
||||
await host.wire.replay(...records);
|
||||
await restoreTestAgentWire(
|
||||
host.wire,
|
||||
host.log,
|
||||
testWireScope(SCOPE, 'goal-restore'),
|
||||
records,
|
||||
);
|
||||
expect(modelOf(host.wire)?.status).toBe('paused');
|
||||
expect(modelOf(host.wire)?.terminalReason).toBe('Paused after agent resume');
|
||||
expect(modelOf(host.wire)?.goalId).toBe(created.goalId);
|
||||
|
||||
const written = await (async () => {
|
||||
const out: PersistedRecord[] = [];
|
||||
for await (const record of host.log.read<PersistedRecord>(SCOPE, 'goal-restore')) {
|
||||
const out: WireRecord[] = [];
|
||||
for await (const record of host.log.read<WireRecord>(
|
||||
testWireScope(SCOPE, 'goal-restore'),
|
||||
AGENT_WIRE_RECORD_KEY,
|
||||
)) {
|
||||
out.push(record);
|
||||
}
|
||||
return out;
|
||||
})();
|
||||
expect(written).toEqual([
|
||||
expect(written.filter((record) => record.type === 'goal.update')).toEqual([
|
||||
expect.objectContaining({
|
||||
type: 'goal.update',
|
||||
status: 'paused',
|
||||
|
|
|
|||
|
|
@ -246,7 +246,7 @@ async function flushedGoalReminderRecords(
|
|||
ctx: TestAgentContext,
|
||||
persistence: InMemoryWireRecordPersistence,
|
||||
) {
|
||||
await ctx.wireRecord.flush();
|
||||
await ctx.wire.flush();
|
||||
return goalReminderRecords(persistence);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -40,11 +40,12 @@ import type { LLMEvent, LLMRequestInput, Model } from '#/app/model/modelInstance
|
|||
import { ITelemetryService } from '#/app/telemetry/telemetry';
|
||||
import { ILogService } from '#/_base/log/log';
|
||||
import { Error2, ErrorCodes } from '#/errors';
|
||||
import { IAgentWireService } from '#/wire/tokens';
|
||||
import type { PersistedRecord } from '#/wire/wireService';
|
||||
import { WireService } from '#/wire/wireServiceImpl';
|
||||
import { IWireService } from '#/wire/wire';
|
||||
import type { WireRecord } from '#/wire/record';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { recordingWireLog, registerTestAgentWire } from '../../wire/stubs';
|
||||
|
||||
const capabilities: ModelCapability = {
|
||||
image_in: false,
|
||||
video_in: false,
|
||||
|
|
@ -193,21 +194,15 @@ function createService(
|
|||
ix.stub(IConfigService, config);
|
||||
ix.stub(ILogService, log);
|
||||
ix.stub(ITelemetryService, telemetry);
|
||||
ix.set(
|
||||
IAgentWireService,
|
||||
new SyncDescriptor(WireService, [{ logScope: 'wire', logKey: 'strict-resend' }]),
|
||||
);
|
||||
const records: WireRecord[] = [];
|
||||
registerTestAgentWire(ix, 'wire/llm-requester', { log: recordingWireLog(records) });
|
||||
ix.set(IFaultInjectionService, new SyncDescriptor(FaultInjectionService));
|
||||
ix.set(IAgentLLMRequesterService, new SyncDescriptor(AgentLLMRequesterService));
|
||||
|
||||
const records: PersistedRecord[] = [];
|
||||
disposables.add(
|
||||
ix.get(IAgentWireService).onEmission((emission) => records.push(emission.record)),
|
||||
);
|
||||
|
||||
return {
|
||||
service: ix.get(IAgentLLMRequesterService),
|
||||
faultInjection: ix.get(IFaultInjectionService),
|
||||
wire: ix.get(IWireService),
|
||||
records,
|
||||
};
|
||||
}
|
||||
|
|
@ -424,7 +419,7 @@ describe('AgentLLMRequesterService media-degraded resend', () => {
|
|||
|
||||
it('records repeated-413 recovery projections on the sticky later request', async () => {
|
||||
const calls = { value: 0 };
|
||||
const { service, records } = createService(
|
||||
const { service, wire, records } = createService(
|
||||
createModel(calls, BODY_TOO_LARGE_413, [BODY_TOO_LARGE_413]),
|
||||
{
|
||||
project: (messages: readonly ContextMessage[]) => messages,
|
||||
|
|
@ -436,6 +431,7 @@ describe('AgentLLMRequesterService media-degraded resend', () => {
|
|||
|
||||
await service.request({ source: { type: 'turn', turnId: 1, step: 1 } });
|
||||
await service.request({ source: { type: 'turn', turnId: 1, step: 2 } });
|
||||
await wire.flush();
|
||||
|
||||
expect(
|
||||
records
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -10,7 +10,7 @@ import type { ContentPart } from '#/app/llmProtocol/message';
|
|||
import type { ContextMessage, PromptOrigin } from '#/agent/contextMemory/types';
|
||||
import { createHooks } from '#/hooks';
|
||||
import type { Op } from '#/wire/op';
|
||||
import type { IWireService } from '#/wire/wireService';
|
||||
import type { IWireService } from '#/wire/wire';
|
||||
|
||||
export interface StubLoopOptions { readonly hasActiveTurn?: boolean; readonly currentId?: string | number; readonly pendingTurnResult?: boolean }
|
||||
export type StubLoop = IAgentLoopService & {
|
||||
|
|
@ -75,5 +75,5 @@ export function stubLoopWithHooks(options: StubLoopOptions = {}): StubLoop {
|
|||
return stub;
|
||||
}
|
||||
export type StubWire = IWireService & { readonly ops: readonly Op[]; readonly steered: readonly { readonly input: readonly ContentPart[]; readonly origin?: PromptOrigin }[] };
|
||||
export function stubWire(): StubWire { const ops: Op[] = []; const steered: { input: readonly ContentPart[]; origin?: PromptOrigin }[] = []; return { _serviceBrand: undefined, ops, steered, dispatch: (...incoming: Op[]) => { for (const op of incoming) { ops.push(op); if (op.type === 'turn.steer') steered.push(op.payload as never); } }, replay: async () => {}, signal: () => {}, flush: async () => {}, attach: () => toDisposable(() => {}), getModel: () => ({}), subscribe: () => toDisposable(() => {}), onEmission: () => toDisposable(() => {}), onRestored: () => toDisposable(() => {}) } as unknown as StubWire; }
|
||||
export function stubWire(): StubWire { const ops: Op[] = []; const steered: { input: readonly ContentPart[]; origin?: PromptOrigin }[] = []; return { _serviceBrand: undefined, hooks: createHooks(['onDidRestore']), ops, steered, dispatch: (...incoming: Op[]) => { for (const op of incoming) { ops.push(op); if (op.type === 'turn.steer') steered.push(op.payload as never); } }, replay: async () => {}, signal: () => {}, flush: async () => {}, getModel: () => ({}), subscribe: () => toDisposable(() => {}), onEmission: () => toDisposable(() => {}) } as unknown as StubWire; }
|
||||
export function stubToolExecutor(): IAgentToolExecutorService { return { _serviceBrand: undefined, execute: async function* () {}, hooks: createHooks(['onBeforeExecuteTool', 'onDidExecuteTool']) as IAgentToolExecutorService['hooks'], recordDupType: () => {}, registerUnavailableToolDescriber: () => ({ dispose() {} }), registerMissingToolDescriber: () => ({ dispose() {} }) }; }
|
||||
|
|
|
|||
|
|
@ -16,11 +16,9 @@ import { ISessionMcpService } from '#/session/mcp/sessionMcp';
|
|||
import { ISessionContext } from '#/session/sessionContext/sessionContext';
|
||||
import type { McpOAuthService } from '#/agent/mcp/oauth/service';
|
||||
import type { MCPClient, MCPToolDefinition } from '#/agent/mcp/types';
|
||||
import { IAgentWireService } from '#/wire/tokens';
|
||||
import { WireService } from '#/wire/wireServiceImpl';
|
||||
import { IWireService } from '#/wire/wire';
|
||||
import type { WireRecord } from '#/wire/record';
|
||||
import { McpDiscoveryModel } from '#/agent/mcp/mcpDiscoveryOps';
|
||||
import { AGENT_WIRE_PROTOCOL_VERSION } from '#/agent/wireRecord/wireRecord';
|
||||
import { wireMetadata } from '#/agent/wireRecord/metadataOps';
|
||||
import { AgentToolExecutorService } from '#/agent/toolExecutor/toolExecutorService';
|
||||
import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor';
|
||||
import { IAgentToolResultTruncationService } from '#/agent/toolResultTruncation/toolResultTruncation';
|
||||
|
|
@ -33,6 +31,8 @@ import { createTestAgent, mcpServices, type TestAgentContext } from '../../harne
|
|||
import { recordingTelemetry, type TelemetryRecord } from '../../app/telemetry/stubs';
|
||||
import { stubLoopWithHooks } from '../loop/stubs';
|
||||
import { stubToolResultTruncationService } from '../toolResultTruncation/stubs';
|
||||
import { recordingWireLog, registerTestAgentWire } from '../../wire/stubs';
|
||||
|
||||
import { discoverTools, executeTool, fakeMcpClient } from './stubs';
|
||||
|
||||
const MCP_OUTPUT_TRUNCATED_TEXT =
|
||||
|
|
@ -156,13 +156,15 @@ describe('AgentMcpService', () => {
|
|||
let ix: TestInstantiationService;
|
||||
let events: DomainEvent[];
|
||||
let telemetryEvents: TelemetryRecord[];
|
||||
let wire: WireService;
|
||||
let wire: IWireService;
|
||||
let wireRecordListeners: Set<(record: WireRecord) => void>;
|
||||
|
||||
beforeEach(() => {
|
||||
disposables = new DisposableStore();
|
||||
ix = disposables.add(new TestInstantiationService());
|
||||
events = [];
|
||||
telemetryEvents = [];
|
||||
wireRecordListeners = new Set();
|
||||
ix.stub(IEventBus, {
|
||||
publish: (event) => {
|
||||
events.push(event);
|
||||
|
|
@ -174,8 +176,12 @@ describe('AgentMcpService', () => {
|
|||
ix.set(IAgentToolExecutorService, new SyncDescriptor(AgentToolExecutorService));
|
||||
ix.stub(IAgentToolResultTruncationService, stubToolResultTruncationService());
|
||||
ix.stub(IAgentLoopService, stubLoopWithHooks());
|
||||
wire = disposables.add(new WireService({ logScope: 'mcp-test', logKey: 'wire.jsonl' }));
|
||||
ix.stub(IAgentWireService, wire);
|
||||
wire = registerTestAgentWire(ix, 'mcp-test', {
|
||||
eventBus: ix.get(IEventBus),
|
||||
log: recordingWireLog([], (record) => {
|
||||
for (const listener of wireRecordListeners) listener(record);
|
||||
}),
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
disposables.dispose();
|
||||
|
|
@ -573,12 +579,13 @@ describe('AgentMcpService', () => {
|
|||
off: { dispose(): void };
|
||||
} {
|
||||
const records: { type: string; [key: string]: unknown }[] = [];
|
||||
const off = wire.onEmission((e) => {
|
||||
if (e.record.type === 'mcp.tools_discovered') {
|
||||
records.push(e.record as { type: string; [key: string]: unknown });
|
||||
const listener = (record: WireRecord): void => {
|
||||
if (record.type === 'mcp.tools_discovered') {
|
||||
records.push(record as { type: string; [key: string]: unknown });
|
||||
}
|
||||
});
|
||||
return { records, off };
|
||||
};
|
||||
wireRecordListeners.add(listener);
|
||||
return { records, off: toDisposable(() => wireRecordListeners.delete(listener)) };
|
||||
}
|
||||
|
||||
it('records tools/list once after restore and dedups unchanged reconnects', async () => {
|
||||
|
|
@ -598,7 +605,8 @@ describe('AgentMcpService', () => {
|
|||
try {
|
||||
manager.connect('grafana');
|
||||
expect(records).toHaveLength(0);
|
||||
await wire.replay();
|
||||
await wire.restore();
|
||||
await wire.flush();
|
||||
expect(records).toHaveLength(1);
|
||||
expect(records[0]).toMatchObject({
|
||||
type: 'mcp.tools_discovered',
|
||||
|
|
@ -613,6 +621,7 @@ describe('AgentMcpService', () => {
|
|||
|
||||
manager.setResolved('grafana', client, await discoverTools(client), new Set(), rawTools);
|
||||
manager.connect('grafana');
|
||||
await wire.flush();
|
||||
expect(records).toHaveLength(2);
|
||||
} finally {
|
||||
off.dispose();
|
||||
|
|
@ -636,7 +645,8 @@ describe('AgentMcpService', () => {
|
|||
try {
|
||||
manager.connect('grafana');
|
||||
expect(records).toHaveLength(0);
|
||||
await wire.replay();
|
||||
await wire.restore();
|
||||
await wire.flush();
|
||||
expect(records).toHaveLength(1);
|
||||
} finally {
|
||||
off.dispose();
|
||||
|
|
@ -662,7 +672,8 @@ describe('AgentMcpService', () => {
|
|||
manager.connect('grafana');
|
||||
enabledNames.clear();
|
||||
enabledNames.add('mutated_after_observation');
|
||||
await wire.replay();
|
||||
await wire.restore();
|
||||
await wire.flush();
|
||||
|
||||
expect(records).toHaveLength(1);
|
||||
expect(records[0]).toMatchObject({
|
||||
|
|
@ -676,41 +687,6 @@ describe('AgentMcpService', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('flushes a parked discovery after the first live wire record on a fresh session', async () => {
|
||||
const manager = new FakeMcpManager();
|
||||
const client = fakeMcpClient([RAW_QUERY]);
|
||||
const rawTools = await client.listTools();
|
||||
manager.setResolved(
|
||||
'grafana',
|
||||
client,
|
||||
await discoverTools(client),
|
||||
new Set(['query_range']),
|
||||
rawTools,
|
||||
);
|
||||
createService(manager);
|
||||
|
||||
const { records, off } = collectDiscoveries();
|
||||
try {
|
||||
manager.connect('grafana');
|
||||
expect(records).toHaveLength(0);
|
||||
wire.dispatch(
|
||||
wireMetadata({
|
||||
protocol_version: AGENT_WIRE_PROTOCOL_VERSION,
|
||||
created_at: 1,
|
||||
}),
|
||||
);
|
||||
expect(records).toHaveLength(1);
|
||||
expect(records[0]).toMatchObject({
|
||||
type: 'mcp.tools_discovered',
|
||||
serverName: 'grafana',
|
||||
tools: rawTools,
|
||||
enabledNames: ['query_range'],
|
||||
});
|
||||
} finally {
|
||||
off.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('re-records when only the collision outcome changes', async () => {
|
||||
const manager = new FakeMcpManager();
|
||||
const occupant = fakeMcpClient([RAW_QUERY]);
|
||||
|
|
@ -724,7 +700,8 @@ describe('AgentMcpService', () => {
|
|||
);
|
||||
createService(manager);
|
||||
manager.connect('graf.ana');
|
||||
await wire.replay();
|
||||
await wire.restore();
|
||||
await wire.flush();
|
||||
|
||||
const { records, off } = collectDiscoveries();
|
||||
try {
|
||||
|
|
@ -738,11 +715,13 @@ describe('AgentMcpService', () => {
|
|||
rawTools,
|
||||
);
|
||||
manager.connect('graf_ana');
|
||||
await wire.flush();
|
||||
expect(records).toHaveLength(1);
|
||||
expect(records[0]!['collisions']).toHaveLength(1);
|
||||
|
||||
manager.disconnect('graf.ana');
|
||||
manager.connect('graf_ana');
|
||||
await wire.flush();
|
||||
expect(records).toHaveLength(2);
|
||||
expect(records[1]!['collisions']).toBeUndefined();
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -16,9 +16,10 @@ import { AppendLogStore } from '#/persistence/backends/node-fs/appendLogStore';
|
|||
import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService';
|
||||
import { IAppendLogStore } from '#/persistence/interface/appendLogStore';
|
||||
import { IFileSystemStorageService } from '#/persistence/interface/storage';
|
||||
import { IAgentWireService } from '#/wire/tokens';
|
||||
import type { PersistedRecord } from '#/wire/wireService';
|
||||
import { WireService } from '#/wire/wireServiceImpl';
|
||||
import { IWireService } from '#/wire/wire';
|
||||
import { AGENT_WIRE_RECORD_KEY, type WireRecord } from '#/wire/record';
|
||||
|
||||
import { registerTestAgentWire, restoreTestAgentWire, testWireScope } from '../../wire/stubs';
|
||||
|
||||
const SCOPE = 'wire';
|
||||
const KEY = 'permission-mode-test';
|
||||
|
|
@ -56,18 +57,19 @@ beforeEach(() => {
|
|||
ix = disposables.add(new TestInstantiationService());
|
||||
ix.stub(IFileSystemStorageService, new InMemoryStorageService());
|
||||
ix.set(IAppendLogStore, new SyncDescriptor(AppendLogStore));
|
||||
ix.set(IAgentWireService, new SyncDescriptor(WireService, [{ logScope: SCOPE, logKey: KEY }]));
|
||||
ix.stub(IAgentContextInjectorService, injectorStub);
|
||||
ix.set(IAgentPermissionModeService, new SyncDescriptor(AgentPermissionModeService));
|
||||
log = ix.get(IAppendLogStore);
|
||||
registerTestAgentWire(ix, testWireScope(SCOPE, KEY), { log });
|
||||
svc = ix.get(IAgentPermissionModeService);
|
||||
});
|
||||
|
||||
afterEach(() => disposables.dispose());
|
||||
|
||||
async function readRecords(): Promise<PersistedRecord[]> {
|
||||
const out: PersistedRecord[] = [];
|
||||
for await (const record of log.read<PersistedRecord>(SCOPE, KEY)) {
|
||||
async function readRecords(): Promise<WireRecord[]> {
|
||||
await ix.get(IWireService).flush();
|
||||
const out: WireRecord[] = [];
|
||||
for await (const record of log.read<WireRecord>(testWireScope(SCOPE, KEY), AGENT_WIRE_RECORD_KEY)) {
|
||||
out.push(record);
|
||||
}
|
||||
return out;
|
||||
|
|
@ -103,6 +105,9 @@ describe('AgentPermissionModeService (wire-backed)', () => {
|
|||
|
||||
expect(svc.mode).toBe('manual');
|
||||
|
||||
svc.setMode('manual');
|
||||
expect(changes).toEqual([]);
|
||||
|
||||
svc.setMode('auto');
|
||||
expect(svc.mode).toBe('auto');
|
||||
expect(changes).toEqual([{ mode: 'auto', previousMode: 'manual' }]);
|
||||
|
|
@ -121,6 +126,14 @@ describe('AgentPermissionModeService (wire-backed)', () => {
|
|||
expect('payload' in records[0]!).toBe(false);
|
||||
});
|
||||
|
||||
it('persists an explicitly configured manual mode when it matches the initial value', async () => {
|
||||
svc.setMode('manual');
|
||||
|
||||
expect(await readRecords()).toEqual([
|
||||
{ type: 'permission.set_mode', mode: 'manual', time: expect.any(Number) },
|
||||
]);
|
||||
});
|
||||
|
||||
it('registers auto-mode reminder injection through the injection service', async () => {
|
||||
expect(registeredInjection?.name).toBe('permission_mode');
|
||||
|
||||
|
|
@ -186,21 +199,25 @@ describe('AgentPermissionModeService (wire-backed)', () => {
|
|||
const ix2 = disposables.add(new TestInstantiationService());
|
||||
ix2.stub(IFileSystemStorageService, new InMemoryStorageService());
|
||||
ix2.set(IAppendLogStore, new SyncDescriptor(AppendLogStore));
|
||||
ix2.set(
|
||||
IAgentWireService,
|
||||
new SyncDescriptor(WireService, [{ logScope: SCOPE, logKey: 'permission-mode-replay' }]),
|
||||
);
|
||||
const log2 = ix2.get(IAppendLogStore);
|
||||
const fresh = ix2.get(IAgentWireService);
|
||||
const fresh = registerTestAgentWire(ix2, testWireScope(SCOPE, 'permission-mode-replay'), {
|
||||
log: log2,
|
||||
});
|
||||
|
||||
void fresh.replay({ type: 'permission.set_mode', mode: 'auto' });
|
||||
await restoreTestAgentWire(
|
||||
fresh,
|
||||
log2,
|
||||
testWireScope(SCOPE, 'permission-mode-replay'),
|
||||
[{ type: 'permission.set_mode', mode: 'auto' }],
|
||||
);
|
||||
|
||||
expect(fresh.getModel(PermissionModeModel)).toBe('auto');
|
||||
|
||||
const written: PersistedRecord[] = [];
|
||||
for await (const record of log2.read<PersistedRecord>(SCOPE, 'permission-mode-replay')) {
|
||||
const written: WireRecord[] = [];
|
||||
for await (const record of log2.read<WireRecord>(testWireScope(SCOPE, 'permission-mode-replay'), AGENT_WIRE_RECORD_KEY)) {
|
||||
written.push(record);
|
||||
}
|
||||
expect(written).toEqual([]);
|
||||
expect(written[0]).toMatchObject({ type: 'metadata' });
|
||||
expect(written.slice(1)).toEqual([{ type: 'permission.set_mode', mode: 'auto' }]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,9 +10,10 @@ import { AppendLogStore } from '#/persistence/backends/node-fs/appendLogStore';
|
|||
import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService';
|
||||
import { IAppendLogStore } from '#/persistence/interface/appendLogStore';
|
||||
import { IFileSystemStorageService } from '#/persistence/interface/storage';
|
||||
import { IAgentWireService } from '#/wire/tokens';
|
||||
import type { PersistedRecord } from '#/wire/wireService';
|
||||
import { WireService } from '#/wire/wireServiceImpl';
|
||||
import { IWireService } from '#/wire/wire';
|
||||
import { AGENT_WIRE_RECORD_KEY, type WireRecord } from '#/wire/record';
|
||||
|
||||
import { registerTestAgentWire, restoreTestAgentWire, testWireScope } from '../../wire/stubs';
|
||||
|
||||
const SCOPE = 'wire';
|
||||
const KEY = 'permission-rules-test';
|
||||
|
|
@ -41,17 +42,18 @@ beforeEach(() => {
|
|||
ix = disposables.add(new TestInstantiationService());
|
||||
ix.stub(IFileSystemStorageService, new InMemoryStorageService());
|
||||
ix.set(IAppendLogStore, new SyncDescriptor(AppendLogStore));
|
||||
ix.set(IAgentWireService, new SyncDescriptor(WireService, [{ logScope: SCOPE, logKey: KEY }]));
|
||||
ix.set(IAgentPermissionRulesService, new SyncDescriptor(AgentPermissionRulesService));
|
||||
log = ix.get(IAppendLogStore);
|
||||
registerTestAgentWire(ix, testWireScope(SCOPE, KEY), { log });
|
||||
svc = ix.get(IAgentPermissionRulesService);
|
||||
});
|
||||
|
||||
afterEach(() => disposables.dispose());
|
||||
|
||||
async function readRecords(): Promise<PersistedRecord[]> {
|
||||
const out: PersistedRecord[] = [];
|
||||
for await (const record of log.read<PersistedRecord>(SCOPE, KEY)) {
|
||||
async function readRecords(): Promise<WireRecord[]> {
|
||||
await ix.get(IWireService).flush();
|
||||
const out: WireRecord[] = [];
|
||||
for await (const record of log.read<WireRecord>(testWireScope(SCOPE, KEY), AGENT_WIRE_RECORD_KEY)) {
|
||||
out.push(record);
|
||||
}
|
||||
return out;
|
||||
|
|
@ -120,27 +122,27 @@ describe('AgentPermissionRulesService (wire-backed)', () => {
|
|||
const ix2 = disposables.add(new TestInstantiationService());
|
||||
ix2.stub(IFileSystemStorageService, new InMemoryStorageService());
|
||||
ix2.set(IAppendLogStore, new SyncDescriptor(AppendLogStore));
|
||||
ix2.set(
|
||||
IAgentWireService,
|
||||
new SyncDescriptor(WireService, [{ logScope: SCOPE, logKey: 'permission-rules-replay' }]),
|
||||
);
|
||||
const log2 = ix2.get(IAppendLogStore);
|
||||
const fresh = ix2.get(IAgentWireService);
|
||||
const fresh = registerTestAgentWire(ix2, testWireScope(SCOPE, 'permission-rules-replay'), {
|
||||
log: log2,
|
||||
});
|
||||
|
||||
let changes = 0;
|
||||
disposables.add(fresh.subscribe(PermissionRulesModel, () => (changes += 1)));
|
||||
|
||||
void fresh.replay(...records);
|
||||
await restoreTestAgentWire(
|
||||
fresh,
|
||||
log2,
|
||||
testWireScope(SCOPE, 'permission-rules-replay'),
|
||||
records,
|
||||
);
|
||||
|
||||
expect(fresh.getModel(PermissionRulesModel)).toEqual({
|
||||
rules: [],
|
||||
sessionApprovalRulePatterns: ['Bash(rm *)'],
|
||||
});
|
||||
expect(changes).toBe(0);
|
||||
const written: PersistedRecord[] = [];
|
||||
for await (const record of log2.read<PersistedRecord>(SCOPE, 'permission-rules-replay')) {
|
||||
const written: WireRecord[] = [];
|
||||
for await (const record of log2.read<WireRecord>(testWireScope(SCOPE, 'permission-rules-replay'), AGENT_WIRE_RECORD_KEY)) {
|
||||
written.push(record);
|
||||
}
|
||||
expect(written).toEqual([]);
|
||||
expect(written[0]).toMatchObject({ type: 'metadata' });
|
||||
expect(written.slice(1)).toEqual(records);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -15,9 +15,10 @@ import { AppendLogStore } from '#/persistence/backends/node-fs/appendLogStore';
|
|||
import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService';
|
||||
import { IAppendLogStore } from '#/persistence/interface/appendLogStore';
|
||||
import { IFileSystemStorageService } from '#/persistence/interface/storage';
|
||||
import { IAgentWireService } from '#/wire/tokens';
|
||||
import type { IWireService, PersistedRecord } from '#/wire/wireService';
|
||||
import { WireService } from '#/wire/wireServiceImpl';
|
||||
import { IWireService } from '#/wire/wire';
|
||||
import { AGENT_WIRE_RECORD_KEY, type WireRecord } from '#/wire/record';
|
||||
|
||||
import { registerTestAgentWire, restoreTestAgentWire, testWireScope } from '../../wire/stubs';
|
||||
|
||||
const SCOPE = 'wire';
|
||||
const KEY = 'plan-test';
|
||||
|
|
@ -30,9 +31,12 @@ function buildHost(key: string): { wire: IWireService; log: IAppendLogStore; eve
|
|||
const ix = disposables.add(new TestInstantiationService());
|
||||
ix.stub(IFileSystemStorageService, new InMemoryStorageService());
|
||||
ix.set(IAppendLogStore, new SyncDescriptor(AppendLogStore));
|
||||
ix.set(IAgentWireService, new SyncDescriptor(WireService, [{ logScope: SCOPE, logKey: key }]));
|
||||
ix.set(IEventBus, new SyncDescriptor(EventBusService));
|
||||
return { wire: ix.get(IAgentWireService), log: ix.get(IAppendLogStore), eventBus: ix.get(IEventBus) };
|
||||
const wire = registerTestAgentWire(ix, testWireScope(SCOPE, key), {
|
||||
log: ix.get(IAppendLogStore),
|
||||
eventBus: ix.get(IEventBus),
|
||||
});
|
||||
return { wire, log: ix.get(IAppendLogStore), eventBus: ix.get(IEventBus) };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
@ -44,9 +48,10 @@ beforeEach(() => {
|
|||
|
||||
afterEach(() => disposables.dispose());
|
||||
|
||||
async function readRecords(key = KEY): Promise<PersistedRecord[]> {
|
||||
const out: PersistedRecord[] = [];
|
||||
for await (const record of log.read<PersistedRecord>(SCOPE, key)) {
|
||||
async function readRecords(key = KEY): Promise<WireRecord[]> {
|
||||
await wire.flush();
|
||||
const out: WireRecord[] = [];
|
||||
for await (const record of log.read<WireRecord>(testWireScope(SCOPE, key), AGENT_WIRE_RECORD_KEY)) {
|
||||
out.push(record);
|
||||
}
|
||||
return out;
|
||||
|
|
@ -116,7 +121,7 @@ describe('plan ops (wire-backed)', () => {
|
|||
expect(wire.getModel(PlanModel)).toBe(active);
|
||||
});
|
||||
|
||||
it('replay rebuilds active state silently (no emissions, no subscriber notifications)', async () => {
|
||||
it('replay rebuilds active state silently', async () => {
|
||||
wire.dispatch(planModeEnter({ id: 'p1' }));
|
||||
const records = await readRecords();
|
||||
|
||||
|
|
@ -125,23 +130,27 @@ describe('plan ops (wire-backed)', () => {
|
|||
host.eventBus.subscribe((e) => {
|
||||
emissions.push(e.type);
|
||||
});
|
||||
let modelChanges = 0;
|
||||
host.wire.subscribe(PlanModel, () => {
|
||||
modelChanges += 1;
|
||||
});
|
||||
|
||||
await host.wire.replay(...records);
|
||||
await restoreTestAgentWire(
|
||||
host.wire,
|
||||
host.log,
|
||||
testWireScope(SCOPE, 'plan-replay'),
|
||||
records,
|
||||
);
|
||||
expect(host.wire.getModel(PlanModel)).toEqual({
|
||||
active: true,
|
||||
id: 'p1',
|
||||
});
|
||||
expect(emissions).toEqual([]);
|
||||
expect(modelChanges).toBe(0);
|
||||
|
||||
const cancelled = buildHost('plan-replay-cancel');
|
||||
await cancelled.wire.replay(
|
||||
await restoreTestAgentWire(
|
||||
cancelled.wire,
|
||||
cancelled.log,
|
||||
testWireScope(SCOPE, 'plan-replay-cancel'),
|
||||
[
|
||||
{ type: 'plan_mode.enter', id: 'p1', planFilePath: '/w/plan/p1.md' },
|
||||
{ type: 'plan_mode.cancel', id: 'p1' },
|
||||
],
|
||||
);
|
||||
expect(cancelled.wire.getModel(PlanModel).active).toBe(false);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|||
|
||||
import { DEFAULT_AGENT_PROFILE_NAME, IAgentProfileCatalogService } from '#/app/agentProfileCatalog/agentProfileCatalog';
|
||||
import { IAgentProfileService } from '#/agent/profile/profile';
|
||||
import { IAgentWireService } from '#/wire/tokens';
|
||||
import { IWireService } from '#/wire/wire';
|
||||
|
||||
import {
|
||||
InMemoryWireRecordPersistence,
|
||||
|
|
@ -74,7 +74,7 @@ describe('AgentProfileService.bind', () => {
|
|||
},
|
||||
});
|
||||
const svc = ctx.get(IAgentProfileService);
|
||||
await ctx.get(IAgentWireService).flush();
|
||||
await ctx.get(IWireService).flush();
|
||||
const start = persistence.records.length;
|
||||
|
||||
await svc.bind({
|
||||
|
|
@ -83,7 +83,7 @@ describe('AgentProfileService.bind', () => {
|
|||
thinking: 'low',
|
||||
cwd: homeDir,
|
||||
});
|
||||
await ctx.get(IAgentWireService).flush();
|
||||
await ctx.get(IWireService).flush();
|
||||
|
||||
const records = persistence.records
|
||||
.slice(start)
|
||||
|
|
|
|||
|
|
@ -25,9 +25,10 @@ import { IFileSystemStorageService } from '#/persistence/interface/storage';
|
|||
import { ISessionContext } from '#/session/sessionContext/sessionContext';
|
||||
import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog';
|
||||
import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext';
|
||||
import { IAgentWireService } from '#/wire/tokens';
|
||||
import type { IWireService, PersistedRecord } from '#/wire/wireService';
|
||||
import { WireService } from '#/wire/wireServiceImpl';
|
||||
import { IWireService } from '#/wire/wire';
|
||||
import { AGENT_WIRE_RECORD_KEY, type WireRecord } from '#/wire/record';
|
||||
|
||||
import { registerTestAgentWire, restoreTestAgentWire, testWireScope } from '../../wire/stubs';
|
||||
|
||||
const SCOPE = 'wire';
|
||||
const KEY = 'profile-test';
|
||||
|
|
@ -92,7 +93,6 @@ function buildHost(key: string): {
|
|||
const host = disposables.add(new TestInstantiationService());
|
||||
host.stub(IFileSystemStorageService, new InMemoryStorageService());
|
||||
host.set(IAppendLogStore, new SyncDescriptor(AppendLogStore));
|
||||
host.set(IAgentWireService, new SyncDescriptor(WireService, [{ logScope: SCOPE, logKey: key }]));
|
||||
host.stub(ITelemetryService, createTelemetryStub());
|
||||
host.stub(IAgentTelemetryContextService, new AgentTelemetryContextService());
|
||||
host.stub(IConfigService, createConfigStub());
|
||||
|
|
@ -105,9 +105,12 @@ function buildHost(key: string): {
|
|||
host.stub(IAgentProfileCatalogService, stubUnused());
|
||||
host.stub(ISessionSkillCatalog, stubUnused());
|
||||
host.set(IAgentProfileService, new SyncDescriptor(AgentProfileService));
|
||||
const wire = registerTestAgentWire(host, testWireScope(SCOPE, key), {
|
||||
log: host.get(IAppendLogStore),
|
||||
});
|
||||
return {
|
||||
ix: host,
|
||||
wire: host.get(IAgentWireService),
|
||||
wire,
|
||||
svc: host.get(IAgentProfileService),
|
||||
log: host.get(IAppendLogStore),
|
||||
};
|
||||
|
|
@ -126,9 +129,10 @@ beforeEach(() => {
|
|||
|
||||
afterEach(() => disposables.dispose());
|
||||
|
||||
async function readRecords(key = KEY): Promise<PersistedRecord[]> {
|
||||
const out: PersistedRecord[] = [];
|
||||
for await (const record of log.read<PersistedRecord>(SCOPE, key)) {
|
||||
async function readRecords(key = KEY): Promise<WireRecord[]> {
|
||||
await wire.flush();
|
||||
const out: WireRecord[] = [];
|
||||
for await (const record of log.read<WireRecord>(testWireScope(SCOPE, key), AGENT_WIRE_RECORD_KEY)) {
|
||||
out.push(record);
|
||||
}
|
||||
return out;
|
||||
|
|
@ -256,17 +260,26 @@ describe('AgentProfileService (wire-backed config.update)', () => {
|
|||
},
|
||||
});
|
||||
|
||||
await host.wire.replay(...records);
|
||||
await restoreTestAgentWire(
|
||||
host.wire,
|
||||
host.log,
|
||||
testWireScope(SCOPE, 'profile-replay'),
|
||||
records,
|
||||
);
|
||||
expect(modelOf(host.wire).cwd).toBe('/work');
|
||||
expect(modelOf(host.wire).profileName).toBe(DEFAULT_AGENT_PROFILE_NAME);
|
||||
expect(replayChdir).toBe(0);
|
||||
expect(replayEmits).toBe(0);
|
||||
|
||||
const written: PersistedRecord[] = [];
|
||||
for await (const record of host.log.read<PersistedRecord>(SCOPE, 'profile-replay')) {
|
||||
const written: WireRecord[] = [];
|
||||
for await (const record of host.log.read<WireRecord>(
|
||||
testWireScope(SCOPE, 'profile-replay'),
|
||||
AGENT_WIRE_RECORD_KEY,
|
||||
)) {
|
||||
written.push(record);
|
||||
}
|
||||
expect(written).toEqual([]);
|
||||
expect(written[0]).toMatchObject({ type: 'metadata' });
|
||||
expect(written.slice(1)).toEqual(records);
|
||||
});
|
||||
|
||||
it('replay rebuilds the resolved thinkingLevel without re-reading config', async () => {
|
||||
|
|
@ -274,14 +287,24 @@ describe('AgentProfileService (wire-backed config.update)', () => {
|
|||
const records = await readRecords();
|
||||
|
||||
const host = buildHost('profile-replay-thinking');
|
||||
await host.wire.replay(...records);
|
||||
await restoreTestAgentWire(
|
||||
host.wire,
|
||||
host.log,
|
||||
testWireScope(SCOPE, 'profile-replay-thinking'),
|
||||
records,
|
||||
);
|
||||
expect(modelOf(host.wire).thinkingLevel).toBe('on');
|
||||
});
|
||||
|
||||
it('replays legacy config.update thinkingLevel records', async () => {
|
||||
const host = buildHost('profile-replay-legacy-thinking-level');
|
||||
|
||||
await host.wire.replay({ type: 'config.update', thinkingLevel: 'high' });
|
||||
await restoreTestAgentWire(
|
||||
host.wire,
|
||||
host.log,
|
||||
testWireScope(SCOPE, 'profile-replay-legacy-thinking-level'),
|
||||
[{ type: 'config.update', thinkingLevel: 'high' }],
|
||||
);
|
||||
|
||||
expect(modelOf(host.wire).thinkingLevel).toBe('high');
|
||||
});
|
||||
|
|
@ -289,11 +312,16 @@ describe('AgentProfileService (wire-backed config.update)', () => {
|
|||
it('returns the persisted effort when a replayed model alias no longer resolves', async () => {
|
||||
const host = buildHost('profile-replay-removed-model');
|
||||
|
||||
await host.wire.replay({
|
||||
type: 'config.update',
|
||||
modelAlias: 'removed-model',
|
||||
thinkingEffort: 'high',
|
||||
});
|
||||
await restoreTestAgentWire(
|
||||
host.wire,
|
||||
host.log,
|
||||
testWireScope(SCOPE, 'profile-replay-removed-model'),
|
||||
[{
|
||||
type: 'config.update',
|
||||
modelAlias: 'removed-model',
|
||||
thinkingEffort: 'high',
|
||||
}],
|
||||
);
|
||||
|
||||
expect(host.svc.getEffectiveThinkingLevel()).toBe('high');
|
||||
});
|
||||
|
|
@ -302,7 +330,12 @@ describe('AgentProfileService (wire-backed config.update)', () => {
|
|||
const host = buildHost('profile-replay-conflicting-thinking-aliases');
|
||||
|
||||
await expect(
|
||||
host.wire.replay({ type: 'config.update', thinkingEffort: 'low', thinkingLevel: 'high' }),
|
||||
restoreTestAgentWire(
|
||||
host.wire,
|
||||
host.log,
|
||||
testWireScope(SCOPE, 'profile-replay-conflicting-thinking-aliases'),
|
||||
[{ type: 'config.update', thinkingEffort: 'low', thinkingLevel: 'high' }],
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
code: 'profile.thinking_alias_conflict',
|
||||
name: 'ProfileError',
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ import { IEventBus } from '#/app/event/eventBus';
|
|||
import { EventBusService } from '#/app/event/eventBusService';
|
||||
import { ErrorCodes, Error2 } from '#/errors';
|
||||
import { createHooks } from '#/hooks';
|
||||
import { IAgentWireService } from '#/wire/tokens';
|
||||
import { IWireService } from '#/wire/wire';
|
||||
|
||||
import { stubContextMemory } from '../contextMemory/stubs';
|
||||
import { stubLoopWithHooks, stubToolExecutor, stubWire } from '../loop/stubs';
|
||||
|
|
@ -49,7 +49,7 @@ function harness() {
|
|||
strict: true, additionalServices: (reg) => {
|
||||
reg.defineInstance(IAgentContextMemoryService, context);
|
||||
reg.defineInstance(IAgentLoopService, loop);
|
||||
reg.defineInstance(IAgentWireService, stubWire());
|
||||
reg.defineInstance(IWireService, stubWire());
|
||||
reg.defineInstance(IAgentToolExecutorService, stubToolExecutor());
|
||||
reg.defineInstance(IAgentFullCompactionService, fullCompaction);
|
||||
reg.define(IEventBus, EventBusService);
|
||||
|
|
|
|||
|
|
@ -1,228 +0,0 @@
|
|||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { SyncDescriptor } from '#/_base/di/descriptors';
|
||||
import { DisposableStore } from '#/_base/di/lifecycle';
|
||||
import { TestInstantiationService } from '#/_base/di/test';
|
||||
import { USER_PROMPT_ORIGIN } from '#/agent/contextMemory/types';
|
||||
import type { PermissionApprovalRequestContext } from '#/agent/permissionGate/permissionGateService';
|
||||
import { type AgentPhase, IAgentRuntimeService } from '#/agent/runtime/runtime';
|
||||
import { AgentRuntimeService } from '#/agent/runtime/runtimeService';
|
||||
import { RuntimeModel } from '#/agent/runtime/runtimeOps';
|
||||
import { type DomainEvent, IEventBus } from '#/app/event/eventBus';
|
||||
import { EventBusService } from '#/app/event/eventBusService';
|
||||
import { AppendLogStore } from '#/persistence/backends/node-fs/appendLogStore';
|
||||
import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService';
|
||||
import { IAppendLogStore } from '#/persistence/interface/appendLogStore';
|
||||
import { IFileSystemStorageService } from '#/persistence/interface/storage';
|
||||
import { IAgentWireService } from '#/wire/tokens';
|
||||
import type { PersistedRecord } from '#/wire/wireService';
|
||||
import { WireService } from '#/wire/wireServiceImpl';
|
||||
|
||||
const SCOPE = 'wire';
|
||||
const KEY = 'runtime-test';
|
||||
|
||||
let disposables: DisposableStore;
|
||||
let ix: TestInstantiationService;
|
||||
let log: IAppendLogStore;
|
||||
let eventBus: IEventBus;
|
||||
let svc: IAgentRuntimeService;
|
||||
|
||||
beforeEach(() => {
|
||||
disposables = new DisposableStore();
|
||||
ix = disposables.add(new TestInstantiationService());
|
||||
ix.stub(IFileSystemStorageService, new InMemoryStorageService());
|
||||
ix.set(IAppendLogStore, new SyncDescriptor(AppendLogStore));
|
||||
ix.set(IAgentWireService, new SyncDescriptor(WireService, [{ logScope: SCOPE, logKey: KEY }]));
|
||||
ix.set(IEventBus, new SyncDescriptor(EventBusService));
|
||||
ix.set(IAgentRuntimeService, new SyncDescriptor(AgentRuntimeService));
|
||||
log = ix.get(IAppendLogStore);
|
||||
eventBus = ix.get(IEventBus);
|
||||
svc = ix.get(IAgentRuntimeService);
|
||||
});
|
||||
|
||||
afterEach(() => disposables.dispose());
|
||||
|
||||
function collect(): AgentPhase[] {
|
||||
const phases: AgentPhase[] = [];
|
||||
disposables.add(
|
||||
eventBus.subscribe('agent.status.updated', (e: DomainEvent<'agent.status.updated'>) => {
|
||||
if (e.phase !== undefined) phases.push(e.phase);
|
||||
}),
|
||||
);
|
||||
return phases;
|
||||
}
|
||||
|
||||
async function readRecords(): Promise<PersistedRecord[]> {
|
||||
const out: PersistedRecord[] = [];
|
||||
for await (const record of log.read<PersistedRecord>(SCOPE, KEY)) {
|
||||
out.push(record);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function startTurn(turnId = 1, step = 1, stepId = 's1'): void {
|
||||
eventBus.publish({ type: 'turn.started', turnId, origin: USER_PROMPT_ORIGIN });
|
||||
eventBus.publish({ type: 'turn.step.started', turnId, step, stepId });
|
||||
}
|
||||
|
||||
const approval = {
|
||||
toolCallId: 'c1',
|
||||
toolName: 'Read',
|
||||
action: 'read',
|
||||
display: {},
|
||||
turnId: 1,
|
||||
toolInput: { path: '/tmp/x' },
|
||||
} as unknown as PermissionApprovalRequestContext;
|
||||
|
||||
describe('AgentRuntimeService', () => {
|
||||
it('starts idle', () => {
|
||||
expect(svc.phase()).toEqual({ kind: 'idle' });
|
||||
});
|
||||
|
||||
it('turn.started then turn.step.started → running with step cursor', () => {
|
||||
const phases = collect();
|
||||
startTurn();
|
||||
|
||||
expect(svc.phase()).toMatchObject({ kind: 'running', turnId: 1, step: 1, stepId: 's1' });
|
||||
expect(phases.map((p) => p.kind)).toEqual(['running', 'running']);
|
||||
});
|
||||
|
||||
it('first assistant.delta enters streaming(assistant); subsequent deltas are debounced', () => {
|
||||
const phases = collect();
|
||||
startTurn();
|
||||
const baseline = phases.length;
|
||||
|
||||
eventBus.publish({ type: 'assistant.delta', turnId: 1, delta: 'he' });
|
||||
eventBus.publish({ type: 'assistant.delta', turnId: 1, delta: 'llo' });
|
||||
|
||||
expect(svc.phase()).toMatchObject({ kind: 'streaming', stream: 'assistant' });
|
||||
expect(phases.length).toBe(baseline + 1);
|
||||
});
|
||||
|
||||
it('thinking.delta switches the stream variant', () => {
|
||||
startTurn();
|
||||
eventBus.publish({ type: 'assistant.delta', turnId: 1, delta: 'x' });
|
||||
eventBus.publish({ type: 'thinking.delta', turnId: 1, delta: 'hmm' });
|
||||
|
||||
expect(svc.phase()).toMatchObject({ kind: 'streaming', stream: 'thinking' });
|
||||
});
|
||||
|
||||
it('tool.call.delta → streaming(tool_call); tool.call.started → tool_call; tool.result → running', () => {
|
||||
const phases = collect();
|
||||
startTurn();
|
||||
|
||||
eventBus.publish({
|
||||
type: 'tool.call.delta',
|
||||
turnId: 1,
|
||||
toolCallId: 'c1',
|
||||
name: 'Read',
|
||||
argumentsPart: '{',
|
||||
});
|
||||
expect(svc.phase()).toMatchObject({
|
||||
kind: 'streaming',
|
||||
stream: 'tool_call',
|
||||
toolCallId: 'c1',
|
||||
toolName: 'Read',
|
||||
});
|
||||
|
||||
eventBus.publish({ type: 'tool.call.started', turnId: 1, toolCallId: 'c1', name: 'Read', args: {} });
|
||||
expect(svc.phase()).toMatchObject({ kind: 'tool_call', toolCallId: 'c1', name: 'Read' });
|
||||
|
||||
eventBus.publish({ type: 'tool.result', turnId: 1, toolCallId: 'c1', output: 'ok', isError: false });
|
||||
expect(svc.phase()).toMatchObject({ kind: 'running', turnId: 1, step: 1 });
|
||||
expect(phases.map((p) => p.kind)).toEqual([
|
||||
'running',
|
||||
'running',
|
||||
'streaming',
|
||||
'tool_call',
|
||||
'running',
|
||||
]);
|
||||
});
|
||||
|
||||
it('turn.step.retrying → retrying with the backoff fields', () => {
|
||||
startTurn();
|
||||
eventBus.publish({
|
||||
type: 'turn.step.retrying',
|
||||
turnId: 1,
|
||||
step: 1,
|
||||
stepId: 's1',
|
||||
failedAttempt: 1,
|
||||
nextAttempt: 2,
|
||||
maxAttempts: 3,
|
||||
delayMs: 500,
|
||||
errorName: 'RateLimitError',
|
||||
errorMessage: 'slow down',
|
||||
statusCode: 429,
|
||||
});
|
||||
|
||||
expect(svc.phase()).toMatchObject({
|
||||
kind: 'retrying',
|
||||
failedAttempt: 1,
|
||||
nextAttempt: 2,
|
||||
maxAttempts: 3,
|
||||
delayMs: 500,
|
||||
errorName: 'RateLimitError',
|
||||
statusCode: 429,
|
||||
});
|
||||
});
|
||||
|
||||
it('turn.step.interrupted → interrupted(reason)', () => {
|
||||
startTurn();
|
||||
eventBus.publish({ type: 'turn.step.interrupted', turnId: 1, step: 1, reason: 'aborted' });
|
||||
|
||||
expect(svc.phase()).toMatchObject({ kind: 'interrupted', reason: 'aborted' });
|
||||
});
|
||||
|
||||
it('turn.ended → ended(reason)', () => {
|
||||
startTurn();
|
||||
eventBus.publish({ type: 'turn.ended', turnId: 1, reason: 'completed', durationMs: 123 });
|
||||
|
||||
expect(svc.phase()).toMatchObject({ kind: 'ended', turnId: 1, reason: 'completed', durationMs: 123 });
|
||||
});
|
||||
|
||||
it('permission approval requests pause into awaiting_approval and resolve back to the prior phase', () => {
|
||||
startTurn();
|
||||
eventBus.publish({ type: 'assistant.delta', turnId: 1, delta: 'hi' });
|
||||
expect(svc.phase().kind).toBe('streaming');
|
||||
|
||||
eventBus.publish({ type: 'permission.approval.requested', ...approval });
|
||||
expect(svc.phase().kind).toBe('awaiting_approval');
|
||||
|
||||
eventBus.publish({ type: 'permission.approval.resolved', ...approval, decision: 'approved' });
|
||||
expect(svc.phase()).toMatchObject({ kind: 'streaming', stream: 'assistant' });
|
||||
});
|
||||
|
||||
it('never persists runtime.set_phase (live-only)', async () => {
|
||||
startTurn();
|
||||
eventBus.publish({ type: 'assistant.delta', turnId: 1, delta: 'a' });
|
||||
eventBus.publish({ type: 'assistant.delta', turnId: 1, delta: 'b' });
|
||||
eventBus.publish({ type: 'assistant.delta', turnId: 1, delta: 'c' });
|
||||
eventBus.publish({ type: 'turn.ended', turnId: 1, reason: 'completed', durationMs: 1 });
|
||||
|
||||
expect(await readRecords()).toEqual([]);
|
||||
});
|
||||
|
||||
it('fresh replay leaves the phase at idle silently (no persisted phase records)', async () => {
|
||||
startTurn();
|
||||
eventBus.publish({ type: 'assistant.delta', turnId: 1, delta: 'hi' });
|
||||
eventBus.publish({ type: 'turn.ended', turnId: 1, reason: 'completed', durationMs: 5 });
|
||||
const records = await readRecords();
|
||||
expect(records).toEqual([]);
|
||||
|
||||
const ix2 = disposables.add(new TestInstantiationService());
|
||||
ix2.stub(IFileSystemStorageService, new InMemoryStorageService());
|
||||
ix2.set(IAppendLogStore, new SyncDescriptor(AppendLogStore));
|
||||
ix2.set(IAgentWireService, new SyncDescriptor(WireService, [{ logScope: SCOPE, logKey: 'runtime-replay' }]));
|
||||
ix2.set(IEventBus, new SyncDescriptor(EventBusService));
|
||||
const fresh = ix2.get(IAgentWireService);
|
||||
const bus2 = ix2.get(IEventBus);
|
||||
|
||||
const emitted: DomainEvent[] = [];
|
||||
disposables.add(bus2.subscribe((e) => emitted.push(e)));
|
||||
|
||||
await fresh.replay(...records);
|
||||
|
||||
expect(fresh.getModel(RuntimeModel).phase).toEqual({ kind: 'idle' });
|
||||
expect(emitted.filter((e) => e.type === 'agent.status.updated')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -19,10 +19,9 @@ import {
|
|||
import { ITelemetryService } from '#/app/telemetry/telemetry';
|
||||
import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry';
|
||||
import type { Turn } from '#/agent/loop/loop';
|
||||
import { IAgentWireService } from '#/wire/tokens';
|
||||
import { WireService } from '#/wire/wireServiceImpl';
|
||||
import { executeTool } from '../../tools/fixtures/execute-tool';
|
||||
import { stubSkill } from '../../app/skillCatalog/stubs';
|
||||
import { registerTestAgentWireServices } from '../../wire/stubs';
|
||||
|
||||
const COMMIT_SKILL = stubSkill('commit', {
|
||||
description: 'commit changes',
|
||||
|
|
@ -72,10 +71,7 @@ describe('AgentSkillService', () => {
|
|||
undo: () => 0,
|
||||
clear: () => {},
|
||||
});
|
||||
reg.defineInstance(
|
||||
IAgentWireService,
|
||||
new WireService({ logScope: 'wire', logKey: 'skill-test' }),
|
||||
);
|
||||
registerTestAgentWireServices(reg, 'wire/skill-test');
|
||||
reg.definePartialInstance(ITelemetryService, { track: () => {}, track2: () => {} });
|
||||
reg.definePartialInstance(IAgentToolRegistryService, {
|
||||
register: () => ({ dispose: () => {} }),
|
||||
|
|
@ -167,10 +163,7 @@ describe('SkillTool', () => {
|
|||
undo: () => 0,
|
||||
clear: () => {},
|
||||
});
|
||||
reg.defineInstance(
|
||||
IAgentWireService,
|
||||
new WireService({ logScope: 'wire', logKey: 'skill-test' }),
|
||||
);
|
||||
registerTestAgentWireServices(reg, 'wire/skill-test');
|
||||
reg.definePartialInstance(ITelemetryService, { track: () => {}, track2: () => {} });
|
||||
reg.definePartialInstance(IAgentToolRegistryService, {
|
||||
register: () => ({ dispose: () => {} }),
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'
|
|||
import { DEFAULT_SUBAGENT_TIMEOUT_MS } from '#/session/subagent/configSection';
|
||||
import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle';
|
||||
import { ISessionSwarmService, type SessionSwarmRunResult, type SessionSwarmTask } from '#/session/swarm/sessionSwarm';
|
||||
import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext';
|
||||
import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder';
|
||||
import { AgentSystemReminderService } from '#/agent/systemReminder/systemReminderService';
|
||||
import { IAgentSwarmService } from '#/agent/swarm/swarm';
|
||||
|
|
@ -20,19 +19,17 @@ import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry';
|
|||
import { AgentToolRegistryService } from '#/agent/toolRegistry/toolRegistryService';
|
||||
import { IAgentLoopService } from '#/agent/loop/loop';
|
||||
import { IConfigService } from '#/app/config/config';
|
||||
import { IAgentWireRecordService } from '#/agent/wireRecord/wireRecord';
|
||||
import { AppendLogStore } from '#/persistence/backends/node-fs/appendLogStore';
|
||||
import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService';
|
||||
import { IAppendLogStore } from '#/persistence/interface/appendLogStore';
|
||||
import { IFileSystemStorageService } from '#/persistence/interface/storage';
|
||||
import { IAgentWireService } from '#/wire/tokens';
|
||||
import type { PersistedRecord } from '#/wire/wireService';
|
||||
import { WireService } from '#/wire/wireServiceImpl';
|
||||
import { AGENT_WIRE_RECORD_KEY, type WireRecord } from '#/wire/record';
|
||||
import { type DomainEvent, IEventBus } from '#/app/event/eventBus';
|
||||
import { EventBusService } from '#/app/event/eventBusService';
|
||||
|
||||
import { stubContextMemory, stubWireRecord } from '../contextMemory/stubs';
|
||||
import { stubContextMemory } from '../contextMemory/stubs';
|
||||
import { executeTool } from '../../tools/fixtures/execute-tool';
|
||||
import { registerTestAgentWire, restoreTestAgentWire, testWireScope } from '../../wire/stubs';
|
||||
import { stubLoopWithHooks } from '../loop/stubs';
|
||||
|
||||
const signal = new AbortController().signal;
|
||||
|
|
@ -78,13 +75,8 @@ describe('AgentSwarmService', () => {
|
|||
disposables = new DisposableStore();
|
||||
ix = disposables.add(new TestInstantiationService());
|
||||
ix.stub(IAgentContextMemoryService, stubContextMemory());
|
||||
ix.stub(IAgentWireRecordService, stubWireRecord());
|
||||
ix.stub(IFileSystemStorageService, new InMemoryStorageService());
|
||||
ix.set(IAppendLogStore, new SyncDescriptor(AppendLogStore));
|
||||
ix.set(
|
||||
IAgentWireService,
|
||||
new SyncDescriptor(WireService, [{ logScope: 'wire', logKey: 'swarm-test' }]),
|
||||
);
|
||||
ix.set(IEventBus, new SyncDescriptor(EventBusService));
|
||||
ix.stub(IAgentLoopService, stubLoopWithHooks());
|
||||
ix.set(IAgentToolRegistryService, new SyncDescriptor(AgentToolRegistryService));
|
||||
|
|
@ -94,7 +86,10 @@ describe('AgentSwarmService', () => {
|
|||
run: async () => [],
|
||||
cancel: () => {},
|
||||
});
|
||||
ix.stub(IAgentScopeContext, makeAgentScopeContext({ agentId: 'main', agentScope: '' }));
|
||||
registerTestAgentWire(ix, testWireScope('wire', 'swarm-test'), {
|
||||
log: ix.get(IAppendLogStore),
|
||||
eventBus: ix.get(IEventBus),
|
||||
});
|
||||
ix.set(IAgentSystemReminderService, new SyncDescriptor(AgentSystemReminderService));
|
||||
ix.set(IAgentSwarmService, new SyncDescriptor(AgentSwarmService));
|
||||
});
|
||||
|
|
@ -123,8 +118,11 @@ describe('AgentSwarmService', () => {
|
|||
swarm.enter('manual');
|
||||
|
||||
const log = ix.get(IAppendLogStore);
|
||||
const records: PersistedRecord[] = [];
|
||||
for await (const record of log.read<PersistedRecord>('wire', 'swarm-test')) {
|
||||
const records: WireRecord[] = [];
|
||||
for await (const record of log.read<WireRecord>(
|
||||
testWireScope('wire', 'swarm-test'),
|
||||
AGENT_WIRE_RECORD_KEY,
|
||||
)) {
|
||||
records.push(record);
|
||||
}
|
||||
expect(records).toEqual([
|
||||
|
|
@ -134,12 +132,15 @@ describe('AgentSwarmService', () => {
|
|||
const ix2 = disposables.add(new TestInstantiationService());
|
||||
ix2.stub(IFileSystemStorageService, new InMemoryStorageService());
|
||||
ix2.set(IAppendLogStore, new SyncDescriptor(AppendLogStore));
|
||||
ix2.set(
|
||||
IAgentWireService,
|
||||
new SyncDescriptor(WireService, [{ logScope: 'wire', logKey: 'swarm-replay' }]),
|
||||
const fresh = registerTestAgentWire(ix2, testWireScope('wire', 'swarm-replay'), {
|
||||
log: ix2.get(IAppendLogStore),
|
||||
});
|
||||
await restoreTestAgentWire(
|
||||
fresh,
|
||||
ix2.get(IAppendLogStore),
|
||||
testWireScope('wire', 'swarm-replay'),
|
||||
records,
|
||||
);
|
||||
const fresh = ix2.get(IAgentWireService);
|
||||
void fresh.replay(...records);
|
||||
expect(fresh.getModel(SwarmModel)).toBe('manual');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,9 +11,10 @@ import { AppendLogStore } from '#/persistence/backends/node-fs/appendLogStore';
|
|||
import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService';
|
||||
import { IAppendLogStore } from '#/persistence/interface/appendLogStore';
|
||||
import { IFileSystemStorageService } from '#/persistence/interface/storage';
|
||||
import { IAgentWireService } from '#/wire/tokens';
|
||||
import type { IWireService, PersistedRecord } from '#/wire/wireService';
|
||||
import { WireService } from '#/wire/wireServiceImpl';
|
||||
import { IWireService } from '#/wire/wire';
|
||||
import { AGENT_WIRE_RECORD_KEY, type WireRecord } from '#/wire/record';
|
||||
|
||||
import { registerTestAgentWire, restoreTestAgentWire, testWireScope } from '../../wire/stubs';
|
||||
|
||||
const SCOPE = 'wire';
|
||||
const KEY = 'task-test';
|
||||
|
|
@ -26,9 +27,12 @@ function buildHost(key: string): { wire: IWireService; log: IAppendLogStore; eve
|
|||
const ix = disposables.add(new TestInstantiationService());
|
||||
ix.stub(IFileSystemStorageService, new InMemoryStorageService());
|
||||
ix.set(IAppendLogStore, new SyncDescriptor(AppendLogStore));
|
||||
ix.set(IAgentWireService, new SyncDescriptor(WireService, [{ logScope: SCOPE, logKey: key }]));
|
||||
ix.set(IEventBus, new SyncDescriptor(EventBusService));
|
||||
return { wire: ix.get(IAgentWireService), log: ix.get(IAppendLogStore), eventBus: ix.get(IEventBus) };
|
||||
const wire = registerTestAgentWire(ix, testWireScope(SCOPE, key), {
|
||||
log: ix.get(IAppendLogStore),
|
||||
eventBus: ix.get(IEventBus),
|
||||
});
|
||||
return { wire, log: ix.get(IAppendLogStore), eventBus: ix.get(IEventBus) };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
@ -40,9 +44,10 @@ beforeEach(() => {
|
|||
|
||||
afterEach(() => disposables.dispose());
|
||||
|
||||
async function readRecords(key = KEY): Promise<PersistedRecord[]> {
|
||||
const out: PersistedRecord[] = [];
|
||||
for await (const record of log.read<PersistedRecord>(SCOPE, key)) {
|
||||
async function readRecords(key = KEY): Promise<WireRecord[]> {
|
||||
await wire.flush();
|
||||
const out: WireRecord[] = [];
|
||||
for await (const record of log.read<WireRecord>(testWireScope(SCOPE, key), AGENT_WIRE_RECORD_KEY)) {
|
||||
out.push(record);
|
||||
}
|
||||
return out;
|
||||
|
|
@ -84,29 +89,28 @@ describe('task ops (wire-backed)', () => {
|
|||
expect(after.get('t1')?.status).toBe('running');
|
||||
});
|
||||
|
||||
it('replay rebuilds the task map from legacy task.* records silently (no emissions, no subscriber notifications)', async () => {
|
||||
const records: PersistedRecord[] = [
|
||||
it('replay rebuilds the task map from legacy task.* records silently', async () => {
|
||||
const records: WireRecord[] = [
|
||||
{ type: 'task.started', info: info('t1', 'running') },
|
||||
{ type: 'task.terminated', info: info('t1', 'completed') },
|
||||
{ type: 'task.started', info: info('t2', 'running') },
|
||||
] as unknown as PersistedRecord[];
|
||||
] as unknown as WireRecord[];
|
||||
|
||||
const host = buildHost('task-replay');
|
||||
const emissions: string[] = [];
|
||||
host.eventBus.subscribe((e) => {
|
||||
emissions.push(e.type);
|
||||
});
|
||||
let modelChanges = 0;
|
||||
host.wire.subscribe(TaskModel, () => {
|
||||
modelChanges += 1;
|
||||
});
|
||||
|
||||
await host.wire.replay(...records);
|
||||
await restoreTestAgentWire(
|
||||
host.wire,
|
||||
host.log,
|
||||
testWireScope(SCOPE, 'task-replay'),
|
||||
records,
|
||||
);
|
||||
const model = host.wire.getModel(TaskModel);
|
||||
expect(model.size).toBe(2);
|
||||
expect(model.get('t1')?.status).toBe('completed');
|
||||
expect(model.get('t2')?.status).toBe('running');
|
||||
expect(emissions).toEqual([]);
|
||||
expect(modelChanges).toBe(0);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -38,15 +38,14 @@ import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStor
|
|||
import { IFileSystemStorageService } from '#/persistence/interface/storage';
|
||||
import { ITelemetryService } from '#/app/telemetry/telemetry';
|
||||
import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry';
|
||||
import { IAgentWireRecordService } from '#/agent/wireRecord/wireRecord';
|
||||
import { IAgentWireService } from '#/wire/tokens';
|
||||
import type { IWireService } from '#/wire/wireService';
|
||||
import { createHooks } from '#/hooks';
|
||||
import { IWireService, type WireHooks } from '#/wire/wire';
|
||||
import { IEventBus } from '#/app/event/eventBus';
|
||||
import { EventBusService } from '#/app/event/eventBusService';
|
||||
import { ITaskService } from '#/app/task/task';
|
||||
import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService';
|
||||
|
||||
import { stubContextMemory, stubWireRecord } from '../contextMemory/stubs';
|
||||
import { stubContextMemory } from '../contextMemory/stubs';
|
||||
import { stubLoopWithHooks } from '../loop/stubs';
|
||||
import type { TaskServiceTestManager } from './stubs';
|
||||
|
||||
|
|
@ -60,24 +59,21 @@ function fakeProcessTask(): AgentTask {
|
|||
};
|
||||
}
|
||||
|
||||
type RestoreHandler = Parameters<IWireService['onRestored']>[0];
|
||||
type RestoreHook = IWireService['hooks']['onDidRestore'];
|
||||
|
||||
function stubWireService(captureRestored?: (handler: RestoreHandler) => void): IWireService {
|
||||
function stubWireService(captureRestoreHook?: (hook: RestoreHook) => void): IWireService {
|
||||
const hooks = createHooks<WireHooks, keyof WireHooks>(['onDidRestore']);
|
||||
captureRestoreHook?.(hooks.onDidRestore);
|
||||
return {
|
||||
_serviceBrand: undefined,
|
||||
hooks,
|
||||
dispatch: () => {},
|
||||
replay: async () => {},
|
||||
signal: () => {},
|
||||
seal: async () => {},
|
||||
restore: async () => {},
|
||||
flush: async () => {},
|
||||
attach: () => toDisposable(() => {}),
|
||||
getModel: () => new Map(),
|
||||
getModel: (model) => model.initial() as never,
|
||||
subscribe: () => toDisposable(() => {}),
|
||||
onEmission: () => toDisposable(() => {}),
|
||||
onRestored: (handler: RestoreHandler) => {
|
||||
captureRestored?.(handler);
|
||||
return toDisposable(() => {});
|
||||
},
|
||||
} as unknown as IWireService;
|
||||
} as IWireService;
|
||||
}
|
||||
|
||||
describe('AgentTaskService', () => {
|
||||
|
|
@ -91,8 +87,7 @@ describe('AgentTaskService', () => {
|
|||
ix = disposables.add(new TestInstantiationService());
|
||||
eventBus = disposables.add(new EventBusService());
|
||||
injectionProviders = new Map();
|
||||
ix.stub(IAgentWireRecordService, stubWireRecord());
|
||||
ix.stub(IAgentWireService, stubWireService());
|
||||
ix.stub(IWireService, stubWireService());
|
||||
ix.stub(IEventBus, eventBus);
|
||||
ix.stub(IAgentContextInjectorService, {
|
||||
register: (name, provider) => {
|
||||
|
|
@ -395,11 +390,10 @@ describe('AgentTaskService', () => {
|
|||
agentId: string,
|
||||
docs: IAtomicDocumentStore,
|
||||
bytes: IFileSystemStorageService,
|
||||
captureRestored?: (handler: RestoreHandler) => void,
|
||||
captureRestoreHook?: (hook: RestoreHook) => void,
|
||||
): TestInstantiationService {
|
||||
const ix = disposables.add(new TestInstantiationService());
|
||||
ix.stub(IAgentWireRecordService, stubWireRecord());
|
||||
ix.stub(IAgentWireService, stubWireService(captureRestored));
|
||||
ix.stub(IWireService, stubWireService(captureRestoreHook));
|
||||
ix.stub(IEventBus, disposables.add(new EventBusService()));
|
||||
ix.stub(IAgentContextInjectorService, {
|
||||
register: () => toDisposable(() => {}),
|
||||
|
|
@ -503,12 +497,12 @@ describe('AgentTaskService', () => {
|
|||
'output.log',
|
||||
new TextEncoder().encode('legacy output'),
|
||||
);
|
||||
let restore!: RestoreHandler;
|
||||
const main = buildAgentIx('main', docs, bytes, (handler) => {
|
||||
restore = handler;
|
||||
let restoreHook!: RestoreHook;
|
||||
const main = buildAgentIx('main', docs, bytes, (hook) => {
|
||||
restoreHook = hook;
|
||||
}).get(IAgentTaskService);
|
||||
|
||||
await restore();
|
||||
await restoreHook.run({});
|
||||
|
||||
expect(main.list(false)).toEqual([
|
||||
expect.objectContaining({ taskId, description: 'legacy task', status: 'completed' }),
|
||||
|
|
@ -540,12 +534,12 @@ describe('AgentTaskService', () => {
|
|||
status: 'completed',
|
||||
detached: true,
|
||||
});
|
||||
let restore!: RestoreHandler;
|
||||
const subagent = buildAgentIx('agent-1', docs, bytes, (handler) => {
|
||||
restore = handler;
|
||||
let restoreHook!: RestoreHook;
|
||||
const subagent = buildAgentIx('agent-1', docs, bytes, (hook) => {
|
||||
restoreHook = hook;
|
||||
}).get(IAgentTaskService);
|
||||
|
||||
await restore();
|
||||
await restoreHook.run({});
|
||||
|
||||
expect(subagent.list(false)).toEqual([]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -18,14 +18,11 @@ import { IAgentToolExecutorService, type ToolExecutionResult } from '#/agent/too
|
|||
import { AgentToolExecutorService } from '#/agent/toolExecutor/toolExecutorService';
|
||||
import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry';
|
||||
import { AgentToolRegistryService } from '#/agent/toolRegistry/toolRegistryService';
|
||||
import { IAgentWireRecordService } from '#/agent/wireRecord/wireRecord';
|
||||
import { IAgentWireService } from '#/wire/tokens';
|
||||
import { WireService } from '#/wire/wireServiceImpl';
|
||||
import { stubWireRecord } from '../contextMemory/stubs';
|
||||
import { registerLogServices } from '../../_base/log/stubs';
|
||||
import { recordingTelemetry, type TelemetryRecord } from '../../app/telemetry/stubs';
|
||||
import { stubLoopWithHooks } from '../loop/stubs';
|
||||
import { registerToolResultTruncationServices } from '../toolResultTruncation/stubs';
|
||||
import { registerTestAgentWireServices } from '../../wire/stubs';
|
||||
|
||||
const { REMINDER_TEXT_1, REMINDER_TEXT_3, makeReminderText2 } = toolDedupeTesting;
|
||||
const ZERO_USAGE = emptyUsage();
|
||||
|
|
@ -57,6 +54,7 @@ function createHarness(telemetry: ITelemetryService = recordingTelemetry(telemet
|
|||
const loop = stubLoopWithHooks();
|
||||
const ix = createServices(disposables, {
|
||||
additionalServices: (reg) => {
|
||||
registerTestAgentWireServices(reg, 'wire/tool-dedupe');
|
||||
reg.defineInstance(ITelemetryService, telemetry);
|
||||
reg.defineInstance(IEventBus, noopEventBus);
|
||||
const homedir = '/tmp/tool-dedupe-homedir';
|
||||
|
|
@ -77,17 +75,11 @@ function createHarness(telemetry: ITelemetryService = recordingTelemetry(telemet
|
|||
} satisfies IAgentScopeContext);
|
||||
reg.defineInstance(IBootstrapService, {
|
||||
homeDir: homedir,
|
||||
agentHomedir: () => homedir,
|
||||
} as unknown as IBootstrapService);
|
||||
reg.defineInstance(IAgentLoopService, loop);
|
||||
reg.define(IAgentToolRegistryService, AgentToolRegistryService);
|
||||
reg.define(IAgentToolExecutorService, AgentToolExecutorService);
|
||||
registerToolResultTruncationServices(reg);
|
||||
reg.defineInstance(IAgentWireRecordService, stubWireRecord());
|
||||
reg.defineInstance(
|
||||
IAgentWireService,
|
||||
disposables.add(new WireService({ logScope: 'wire', logKey: 'tool-dedupe' })),
|
||||
);
|
||||
reg.define(IAgentToolDedupeService, AgentToolDedupeService);
|
||||
registerLogServices(reg);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -19,14 +19,11 @@ import { AgentToolExecutorService, parseToolCallArguments } from '#/agent/toolEx
|
|||
import { IAgentToolResultTruncationService } from '#/agent/toolResultTruncation/toolResultTruncation';
|
||||
import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry';
|
||||
import { AgentToolRegistryService } from '#/agent/toolRegistry/toolRegistryService';
|
||||
import { IAgentWireRecordService } from '#/agent/wireRecord/wireRecord';
|
||||
import { IAgentWireService } from '#/wire/tokens';
|
||||
import { WireService } from '#/wire/wireServiceImpl';
|
||||
import { IEventBus } from '#/app/event/eventBus';
|
||||
import { ITelemetryService } from '#/app/telemetry/telemetry';
|
||||
import { stubWireRecord } from '../contextMemory/stubs';
|
||||
import { registerLogServices } from '../../_base/log/stubs';
|
||||
import { recordingTelemetry, type TelemetryRecord } from '../../app/telemetry/stubs';
|
||||
import { registerTestAgentWireServices } from '../../wire/stubs';
|
||||
|
||||
type ToolExecutorEvent =
|
||||
| { readonly type: 'tool.result'; readonly toolCallId: string; readonly result: ToolResult };
|
||||
|
|
@ -48,13 +45,9 @@ beforeEach(() => {
|
|||
truncateForModel = async (input) => input.result;
|
||||
ix = createServices(disposables, {
|
||||
additionalServices: (reg) => {
|
||||
registerTestAgentWireServices(reg, 'wire/tool-executor');
|
||||
reg.define(IAgentToolRegistryService, AgentToolRegistryService);
|
||||
reg.define(IAgentToolExecutorService, AgentToolExecutorService);
|
||||
reg.defineInstance(IAgentWireRecordService, stubWireRecord());
|
||||
reg.defineInstance(
|
||||
IAgentWireService,
|
||||
disposables.add(new WireService({ logScope: 'wire', logKey: 'tool-executor' })),
|
||||
);
|
||||
reg.defineInstance(ITelemetryService, recordingTelemetry(telemetryEvents));
|
||||
reg.defineInstance(IAgentToolResultTruncationService, {
|
||||
_serviceBrand: undefined,
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue