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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/kimi-inspect/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ A left icon rail (`src/components/NavRail.tsx`) switches top-level views:
- **Global message search** (`src/components/SearchView.tsx`) — cross-session full-text search over `POST /api/v1/search`, cursor-paged via a manual Load more; an exact-match checkbox maps to the API's `mode: 'literal'` substring search, which ignores sort and orders newest-first; a `live`/`index` badge on the results shows which server route served them (in-memory session transcript vs the persisted index).
- **Model Catalog** (`src/components/ModelCatalogView.tsx`) — every Provider with its Models and the default marker, via `IModelCatalog` / `IModelService` channel proxies. Expanding a Model opens the model inspector inside that view: provider/model config layers plus the resolved runtime view with per-value provenance (config / override / builtin / env / synthesized), served on demand by `IModelCatalog.inspect` — the same resolution pass the runtime's `get` serves, traced via `ResolutionTraceCollector` and assembled by `kosong/model/inspection.ts`.
- **App Services** (`src/components/AppServicesView.tsx`) — the app-scope Service reflection, full width, joined by the **Workspace Services** view (`src/components/WorkspaceServicesView.tsx`) — the workspace-scope counterpart with a left sidebar directory browser (`src/components/WorkspaceDirBrowser.tsx` — server-side fs browsing over the App-scope `IHostFolderBrowser`, marking entries that are registered workspaces with their `IWorkspaceTrust` trust state, and registering a picked folder on demand via `IWorkspaceService.createOrTouch`), its proxies riding the `/workspace/:id` route, which materializes the handler on demand via `IWorkspaceLifecycleService.handlerFor`.
- **DI view** (`src/components/DiInspectionView.tsx`) — the engine's Service × Effect × DI debug surface over the App-scope `IDebugLedgerService` / `IDebugGraphService` / `IDebugCascadeService`: the unit tree = ledger tree with unprovide / update / dispose triggers, the dependency DAG as a hand-rolled SVG, the cascade history, and the waiting area; the four panels poll on a short interval and refresh eagerly off the global `event.di.unit_changed` WS frame via `src/activity/di.ts`, which invalidates the `['di']` react-query prefix.
- **DI view** (`src/components/DiInspectionView.tsx`) — the engine's Service × Effect × DI debug surface over the App-scope `IDebugLedgerService` / `IDebugGraphService` / `IDebugEventsService` / `IDebugCascadeService`: the unit tree = ledger tree with unprovide / update / dispose triggers, the dependency DAG as Miller columns (`di/DiGraphPanel.tsx`), the event-subscription ledger (unit-book `on:<name>` entries + per-bus listener counts, `di/DiEventsPanel.tsx`), the cascade history, and the waiting area; the five panels poll on a short interval and refresh eagerly off the global `event.di.unit_changed` WS frame via `src/activity/di.ts`, which invalidates the `['di']` react-query prefix.

The **Agent scope** stays in the Chat view's right dock (`src/components/RightPanel.tsx`) across two tabs:

Expand Down
29 changes: 27 additions & 2 deletions apps/kimi-inspect/src/components/DiInspectionView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,16 @@
* with that service's direct dependencies; rows carry path-scoped
* relation bars (one per path ancestor with a direct edge) and a
* path-root background highlight;
* - Events: event subscriptions (`IDebugEventsService.subscriptions`) —
* unit-book ledger entries labeled `on:<name>` /
* `disposable:EventSubscription` per scope, plus per-bus listener counts
* as the fallback side (`di/DiEventsPanel.tsx`);
* - Cascade: the cross-scope cascade history rings
* (`IDebugCascadeService.history`), newest first;
* - Pending: the waiting area + sticky failures per scope
* (`IDebugCascadeService.pending`), with an `update` retry per failure.
*
* All four panels poll on a short interval and refresh eagerly when the
* All five panels poll on a short interval and refresh eagerly when the
* global `event.di.unit_changed` WS frame fires (`useDiQueryInvalidation`
* invalidates the `['di']` query prefix).
*/
Expand All @@ -30,6 +34,10 @@ import {
type DebugPendingGroup,
} from '@moonshot-ai/agent-core-v2/debug/debugCascade';
import { IDebugGraphService, type DebugGraph } from '@moonshot-ai/agent-core-v2/debug/debugGraph';
import {
IDebugEventsService,
type DebugEventSubscriptions,
} from '@moonshot-ai/agent-core-v2/features/debugEvents/debugEvents';
import {
IDebugLedgerService,
type DebugLedgerNode,
Expand All @@ -42,13 +50,15 @@ import { useDiQueryInvalidation } from '../activity/di';
import type { InspectClient } from '../channel';
import { useConnection } from '../connection';
import { ActionButton, Badge, ErrorLine } from '../ui';
import { DiEventsPanel } from './di/DiEventsPanel';
import { DiGraphPanel } from './di/DiGraphPanel';

type DiPanel = 'units' | 'graph' | 'cascade' | 'pending';
type DiPanel = 'units' | 'graph' | 'events' | 'cascade' | 'pending';

const PANELS: readonly { id: DiPanel; title: string }[] = [
{ id: 'units', title: 'Units' },
{ id: 'graph', title: 'Deps' },
{ id: 'events', title: 'Events' },
{ id: 'cascade', title: 'Cascade' },
{ id: 'pending', title: 'Pending' },
];
Expand Down Expand Up @@ -86,6 +96,8 @@ export function DiInspectionView() {
<UnitsPanel />
) : panel === 'graph' ? (
<GraphPanel />
) : panel === 'events' ? (
<EventsPanel />
) : panel === 'cascade' ? (
<CascadePanel />
) : (
Expand Down Expand Up @@ -384,6 +396,19 @@ function GraphPanel() {
return <DiGraphPanel graph={query.data as DebugGraph} />;
}

// ---------------------------------------------------------------------------
// Events panel — event subscriptions; rendering lives in di/DiEventsPanel.tsx
// ---------------------------------------------------------------------------

function EventsPanel() {
const query = useDiQuery('events', (klient) =>
klient.core(IDebugEventsService).subscriptions(),
);
const gate = panelGate(query);
if (gate !== null) return gate;
return <DiEventsPanel data={query.data as DebugEventSubscriptions} />;
}

// ---------------------------------------------------------------------------
// Cascade panel — the cross-scope cascade history rings, newest first
// ---------------------------------------------------------------------------
Expand Down
146 changes: 146 additions & 0 deletions apps/kimi-inspect/src/components/di/DiEventsPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
/**
* DI Events panel — event-subscription introspection
* (`IDebugEventsService.subscriptions`), two merged sides:
*
* - Subscriptions: the unit-book side — every materialized unit's ledger
* entries labeled as an event subscription (`on:<name>` from a named
* Emitter or the fiber `on` capability, `disposable:EventSubscription`
* from an unnamed one), grouped by scope path;
* - Bus listeners: the emitter-side fallback — per-`IEventBus` listener
* counts (`*` = the full stream) plus the global `IEventService` count,
* which also cover subscriptions never registered on a unit book.
*
* Pure React + Tailwind.
*/
import type {
DebugEventBusSnapshot,
DebugEventSubscription,
DebugEventSubscriptions,
} from '@moonshot-ai/agent-core-v2/features/debugEvents/debugEvents';

import { Badge } from '../../ui';

const KIND_TONES: Record<DebugEventSubscription['kind'], 'neutral' | 'sky' | 'violet'> = {
disposer: 'neutral',
effect: 'sky',
ledger: 'violet',
};

export function DiEventsPanel({ data }: { data: DebugEventSubscriptions }) {
const groups = groupByScope(data.subscriptions);
return (
<div>
<div className="mb-1 text-[10px] font-semibold tracking-wider text-neutral-600 uppercase">
subscriptions ({data.subscriptions.length})
</div>
{groups.length === 0 ? (
<div className="mb-4 text-[11px] text-neutral-600 italic">
no event subscriptions on any unit book
</div>
) : (
groups.map(([scopePath, subs]) => (
<div
key={scopePath}
className="mb-2 rounded-lg border border-neutral-800 bg-neutral-900/60"
>
<div className="border-b border-neutral-800/60 px-3 py-2">
<span className="font-mono text-[11px] text-neutral-200">{scopePath}</span>
<span className="ml-2 text-[10px] text-neutral-600">{subs.length}</span>
</div>
<div className="px-3 py-2">
{subs.map((sub, i) => (
<div
key={`${sub.unit}:${sub.label}:${i}`}
className="mb-1 flex items-center gap-2 rounded border border-neutral-800/70 bg-neutral-950/40 px-2 py-1.5"
>
<span
className="min-w-0 truncate font-mono text-[11px] text-neutral-200"
title={sub.unit}
>
{sub.unit}
</span>
{sub.uid !== undefined ? (
<span className="shrink-0 text-[10px] text-neutral-600">#{sub.uid}</span>
) : null}
<span
className="shrink-0 font-mono text-[10px] text-sky-400"
title={sub.label}
>
{sub.label}
</span>
<span className="ml-auto shrink-0">
<Badge tone={KIND_TONES[sub.kind]}>{sub.kind}</Badge>
</span>
</div>
))}
</div>
</div>
))
)}
<div className="mt-4 mb-1 text-[10px] font-semibold tracking-wider text-neutral-600 uppercase">
bus listeners
</div>
{data.buses.length === 0 && data.globalListeners === undefined ? (
<div className="text-[11px] text-neutral-600 italic">no materialized event buses</div>
) : (
<div className="rounded-lg border border-neutral-800 bg-neutral-900/60 px-3 py-2">
{data.globalListeners !== undefined ? (
<BusRow scopePath="app" type="eventService (global)" count={data.globalListeners} />
) : null}
{data.buses.flatMap((bus) => busRows(bus))}
</div>
)}
</div>
);
}

function groupByScope(
subs: readonly DebugEventSubscription[],
): [string, DebugEventSubscription[]][] {
const map = new Map<string, DebugEventSubscription[]>();
for (const sub of subs) {
const group = map.get(sub.scopePath) ?? [];
group.push(sub);
map.set(sub.scopePath, group);
}
return [...map.entries()];
}

function busRows(bus: DebugEventBusSnapshot) {
const rows = [
<BusRow key={`${bus.scopePath}:*`} scopePath={bus.scopePath} type="*" count={bus.all} />,
];
for (const type of Object.keys(bus.perType).toSorted()) {
rows.push(
<BusRow
key={`${bus.scopePath}:${type}`}
scopePath={bus.scopePath}
type={type}
count={bus.perType[type] ?? 0}
/>,
);
}
return rows;
}

function BusRow({
scopePath,
type,
count,
}: {
scopePath: string;
type: string;
count: number;
}) {
return (
<div className="flex items-center gap-2 py-0.5">
<span className="min-w-0 truncate font-mono text-[10px] text-neutral-500" title={scopePath}>
{scopePath}
</span>
<span className="shrink-0 font-mono text-[11px] text-neutral-200" title={type}>
{type}
</span>
<span className="ml-auto shrink-0 font-mono text-[11px] text-neutral-400">{count}</span>
</div>
);
}
4 changes: 4 additions & 0 deletions packages/agent-core-v2/src/_base/di/instantiation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,10 @@ export function createDecorator<T>(name: string): ServiceIdentifier<T> {
return id;
}

export function lookupServiceDecorator(name: string): ServiceIdentifier<unknown> | undefined {
return _util.serviceIds.get(name);
}

const SERVICE_IDENTIFIER_MARK = Symbol('serviceIdentifier');

export function isServiceIdentifier(thing: unknown): thing is ServiceIdentifier<unknown> {
Expand Down
8 changes: 8 additions & 0 deletions packages/agent-core-v2/src/_base/di/lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,15 @@
import { onUnexpectedError } from '../errors/unexpectedError';
import { Ledger, type LedgerEntry } from '../lifecycle/ledger';

export interface IDisposableDebugLabel {
readonly debugLabel?: string;
}

function disposableLabel(d: IDisposable): string {
const debugLabel = (d as IDisposableDebugLabel).debugLabel;
if (typeof debugLabel === 'string' && debugLabel.length > 0) {
return debugLabel;
}
return `disposable:${d.constructor?.name ?? 'anonymous'}`;
}

Expand Down
46 changes: 34 additions & 12 deletions packages/agent-core-v2/src/_base/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
* `onWill` events whose listeners register work via `waitUntil`), the
* `handleVetos` helper (for `onBefore*` veto events whose listeners answer
* with `veto(value, id)`), and event combinators (`once` / `map` / `filter`
* / `any`).
* / `any`). `Emitter` accepts an optional debug name that its
* `EventSubscription` carries as an `on:<name>` ledger label, so event
* subscriptions stay identifiable in unit-book introspection.
*/

import { onUnexpectedError, safelyCallListener } from './errors/unexpectedError';
Expand All @@ -13,6 +15,7 @@
DisposableStore,
combinedDisposable,
type IDisposable,
type IDisposableDebugLabel,
} from './di/lifecycle';
import { LinkedList } from './di/util/linkedList';

Expand All @@ -29,11 +32,31 @@
thisArg: unknown;
}

export class EventSubscription implements IDisposable, IDisposableDebugLabel {
readonly debugLabel: string | undefined;
private _removed = false;

constructor(
debugName: string | undefined,
private readonly _remove: () => void,
) {
this.debugLabel = debugName === undefined ? undefined : `on:${debugName}`;
}

dispose(): void {
if (this._removed) return;
this._removed = true;
this._remove();
}
}

export class Emitter<T> {
protected _listeners: Set<ListenerEntry<T>> | undefined;
private _disposed = false;
private _event: Event<T> | undefined;

constructor(public readonly debugName?: string) {}

get event(): Event<T> {
this._event ??= (listener, thisArg, disposables) => {
if (this._disposed) {
Expand All @@ -43,17 +66,12 @@
const entry: ListenerEntry<T> = { listener, thisArg };
this._listeners.add(entry);

let removed = false;
const subscription: IDisposable = {
dispose: () => {
if (removed) return;
removed = true;
if (this._disposed) {
return;
}
this._listeners?.delete(entry);
},
};
const subscription = new EventSubscription(this.debugName, () => {
if (this._disposed) {
return;
}
this._listeners?.delete(entry);
});

if (disposables !== undefined) {
if (disposables instanceof DisposableStore) {
Expand All @@ -67,6 +85,10 @@
return this._event;
}

get listenerCount(): number {
return this._listeners?.size ?? 0;
}

fire(value: T): void {
if (this._disposed || this._listeners === undefined) {
return;
Expand Down Expand Up @@ -169,15 +191,15 @@
}
promises.push(
valueOrPromise.then(
(value) => {
if (value) {
lazyValue = true;
}
},

Check warning on line 198 in packages/agent-core-v2/src/_base/event.ts

View workflow job for this annotation

GitHub Actions / lint

eslint(no-loop-func)

Function declared in a loop contains unsafe references to variable(s)
(error) => {
onError(error);
lazyValue = true;
},

Check warning on line 202 in packages/agent-core-v2/src/_base/event.ts

View workflow job for this annotation

GitHub Actions / lint

eslint(no-loop-func)

Function declared in a loop contains unsafe references to variable(s)
),
);
}
Expand Down
12 changes: 10 additions & 2 deletions packages/agent-core-v2/src/app/event/eventBusService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,22 @@ import { type DomainEvent, type DomainEventMap, IEventBus } from './eventBus';
export class EventBusService extends Service implements IEventBus {
declare readonly _serviceBrand: undefined;

private readonly allEmitter = this._register(new Emitter<DomainEvent>());
private readonly allEmitter = this._register(new Emitter<DomainEvent>('*'));
private readonly perType = new Map<keyof DomainEventMap, Emitter<DomainEvent>>();

publish(event: DomainEvent): void {
this.allEmitter.fire(event);
this.perType.get(event.type)?.fire(event);
}

listenerCounts(): { all: number; perType: Record<string, number> } {
const perType: Record<string, number> = {};
for (const [type, emitter] of this.perType) {
perType[String(type)] = emitter.listenerCount;
}
return { all: this.allEmitter.listenerCount, perType };
}

subscribe(handler: (event: DomainEvent) => void): IDisposable;
subscribe<K extends keyof DomainEventMap>(
type: K,
Expand All @@ -44,7 +52,7 @@ export class EventBusService extends Service implements IEventBus {
const type = typeOrHandler;
let emitter = this.perType.get(type);
if (emitter === undefined) {
emitter = this._register(new Emitter<DomainEvent>());
emitter = this._register(new Emitter<DomainEvent>(String(type)));
this.perType.set(type, emitter);
}
return emitter.event(handler as unknown as (event: DomainEvent) => void);
Expand Down
6 changes: 5 additions & 1 deletion packages/agent-core-v2/src/app/event/eventService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,13 @@ import { type DomainEvent, IEventService } from './event';
export class EventService extends Service implements IEventService {
declare readonly _serviceBrand: undefined;

private readonly emitter = this._register(new Emitter<DomainEvent>());
private readonly emitter = this._register(new Emitter<DomainEvent>('publish'));
readonly onDidPublish: Event<DomainEvent> = this.emitter.event;

get listenerCount(): number {
return this.emitter.listenerCount;
}

publish(event: DomainEvent): void {
this.emitter.fire(event);
}
Expand Down
Loading
Loading