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
1 change: 1 addition & 0 deletions packages/agent-core-v2/scripts/check-import-boundaries.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const HUMAN_VOCABULARY = new Set([
'interaction/machine',
'interaction/facade',
'utils/watch',
'xstate2',
]);

const V2_ONLY_FIRST_SEGMENTS = new Set([
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createActor, type ActorLogic, type AnyActorRef, type Snapshot } from 'xstate';
import { createActor, type ActorLogic, type AnyActorRef, type Snapshot } from '#human/xstate2';

import { BugIndicatingError } from '#/_base/errors/errors';
import { onUnexpectedError } from '#/_base/errors/unexpectedError';
Expand Down
51 changes: 27 additions & 24 deletions packages/agent-core-v2/src/human/test/usage/machine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ import type { UsageEmitted } from '#/usage/machine';
import { createUsagePlugin } from '#/usage/plugin';
import type { UsageRecord } from '#/usage/usage';
import { createTimingPlugin } from '#/timing/plugin';
import {
xstateInspectionCollector,
type XstateInspectionEnvelope,
} from '#/xstateInspection';

const model: LlmModel = { provider: 'test', model: 'test-model', capability: UNKNOWN_CAPABILITY };

Expand All @@ -30,32 +34,31 @@ function record(
return { usage: usage(inputOther, output), model: extra?.model, turnId: extra?.turnId, at: 0 };
}

describe('usage machine', () => {
it('accumulates total, byModel and byTurn across usage.record events', () => {
const actor = createActor(createUsageMachine());
actor.start();

actor.send({ type: 'usage.record', record: record(10, 2, { model, turnId: 1 }) });
actor.send({ type: 'usage.record', record: record(5, 3, { model, turnId: 2 }) });
actor.send({ type: 'usage.record', record: record(100, 0) });

const { records, summary } = actor.getSnapshot().context;
expect(records).toHaveLength(3);
expect(summary.total).toEqual({
inputOther: 115,
output: 5,
inputCacheRead: 0,
inputCacheCreation: 0,
});
expect(summary.byModel).toEqual({
'test-model': { inputOther: 15, output: 5, inputCacheRead: 0, inputCacheCreation: 0 },
});
expect(summary.byTurn).toEqual({
1: { inputOther: 10, output: 2, inputCacheRead: 0, inputCacheCreation: 0 },
2: { inputOther: 5, output: 3, inputCacheRead: 0, inputCacheCreation: 0 },
});
describe('xstate inspection collector', () => {
it('publishes JSON-safe scalar envelopes with no machine context', () => {
const envelopes: XstateInspectionEnvelope[] = [];
const unsubscribe = xstateInspectionCollector.subscribe((envelope) => envelopes.push(envelope));
try {
const actor = createActor(createUsageMachine());
actor.start();
actor.send({ type: 'usage.record', record: record(10, 2, { model, turnId: 1 }) });
} finally {
unsubscribe();
}
const delivered = envelopes.filter((envelope) => envelope.eventType === 'usage.record');
expect(delivered.length).toBeGreaterThan(0);
for (const envelope of delivered) {
expect(typeof envelope.actorSessionId).toBe('string');
expect(typeof envelope.timestamp).toBe('number');
}
expect(delivered.find((envelope) => envelope.type === '@xstate.microstep')?.stateValue).toBeDefined();
const serialized = JSON.stringify(envelopes);
expect(serialized).not.toContain('inputOther');
expect(JSON.parse(serialized)).toEqual(envelopes);
});
});

describe('usage machine', () => {
it('groups byModel by baseUrl + model, ignoring provider', () => {
const actor = createActor(createUsageMachine());
actor.start();
Expand Down
3 changes: 3 additions & 0 deletions packages/agent-core-v2/src/human/xstate2.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { createActor as createXStateActor } from 'xstate';
import type { Actor, ActorOptions, AnyActorLogic, InspectionEvent } from 'xstate';

import { xstateInspectionCollector } from '#/xstateInspection';

export * from 'xstate';

function reportUnhandled(event: InspectionEvent): void {
Expand All @@ -10,7 +12,7 @@
if (event.event.type.startsWith('xstate.')) {
return;
}
console.warn(

Check warning on line 15 in packages/agent-core-v2/src/human/xstate2.ts

View workflow job for this annotation

GitHub Actions / lint

eslint(no-console)

Unexpected console statement.
`[agent-core] unhandled event "${event.event.type}" in actor "${event.actorRef.sessionId}"`,
);
}
Expand All @@ -24,6 +26,7 @@
...options,
inspect: (event) => {
reportUnhandled(event);
xstateInspectionCollector.publish(event);
if (typeof inspect === 'function') {
inspect(event);
} else {
Expand Down
66 changes: 66 additions & 0 deletions packages/agent-core-v2/src/human/xstateInspection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import type { InspectionEvent } from 'xstate';

export type XstateInspectionEventType = InspectionEvent['type'];

export interface XstateInspectionEnvelope {
readonly type: XstateInspectionEventType;
readonly timestamp: number;
readonly actorSessionId: string;
readonly actorId?: string;
readonly logicId?: string;
readonly eventType?: string;
readonly stateValue?: unknown;
}

export type XstateInspectionListener = (envelope: XstateInspectionEnvelope) => void;

export interface XstateInspectionCollector {
subscribe(listener: XstateInspectionListener): () => void;
publish(event: InspectionEvent): void;
}

function scalar(value: unknown): string | undefined {
return typeof value === 'string' ? value : undefined;
}

function toEnvelope(event: InspectionEvent, now: () => number): XstateInspectionEnvelope {
const actorRef = event.actorRef as { id?: unknown; logic?: unknown };
const logic = actorRef.logic as { id?: unknown } | undefined;
const snapshot = 'snapshot' in event ? (event.snapshot as { value?: unknown }) : undefined;
return {
type: event.type,
timestamp: now(),
actorSessionId: event.actorRef.sessionId,
actorId: scalar(actorRef.id),
logicId: scalar(logic?.id),
Comment on lines +33 to +35

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Include runtime ownership in inspection envelopes

When multiple Kimi sessions or agents are active, these fields cannot associate an inspected actor with its owning runtime: createMachineEngine creates each root actor without an application session/agent ID, while nested actors reuse IDs such as turn and tool. Because the projection also discards XState's source/parent relationship, consumers cannot reconstruct which child belongs to which root, so concurrent state-machine views will mix unrelated actors. Include an owning session/agent identifier and parent/source actor session ID in the envelope.

Useful? React with 👍 / 👎.

eventType:
'event' in event
? event.event.type
: event.type === '@xstate.action'
? event.action.type
: undefined,
stateValue: snapshot?.value,
};
}

export function createXstateInspectionCollector(input?: {
now?: () => number;
}): XstateInspectionCollector {
const now = input?.now ?? Date.now;
const listeners = new Set<XstateInspectionListener>();
return {
subscribe(listener) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
publish(event) {
if (listeners.size === 0) return;
const envelope = toEnvelope(event, now);
for (const listener of listeners) listener(envelope);
},
};
}

export const xstateInspectionCollector = createXstateInspectionCollector();
12 changes: 10 additions & 2 deletions packages/kap-server/src/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import { extractWsBearerToken } from './transport/ws/bearerProtocol';
import { SessionEventBroadcaster } from './transport/ws/v1/sessionEventBroadcaster';
import type { ConfigWarningItem } from './transport/ws/v1/events';
import { registerWsV1, WS_PATH as WS_PATH_V1 } from './transport/ws/v1/registerWsV1';
import { registerWsDebug, WS_DEBUG_PATH } from './transport/ws/debug/registerWsDebug';
import { getServerVersion } from './version';
import { classify } from './security/bindClassify';
import {
Expand Down Expand Up @@ -303,6 +304,9 @@ export async function startServer(opts: ServerStartOptions): Promise<RunningServ
}

const close = async (): Promise<void> => {
if (wssDebug !== undefined) {
for (const client of wssDebug.clients) client.terminate();
}
configChangedPublisher.close();
await remoteControlManager.close();
await app.close();
Expand Down Expand Up @@ -473,6 +477,7 @@ export async function startServer(opts: ServerStartOptions): Promise<RunningServ
broadcaster,
logger,
});
const wssDebug = debugEndpoints ? registerWsDebug() : undefined;

const handleUpgrade = async (
req: IncomingMessage,
Expand All @@ -481,7 +486,9 @@ export async function startServer(opts: ServerStartOptions): Promise<RunningServ
): Promise<void> => {
const url = req.url ?? '';
const isV1 = url === WS_PATH_V1 || url.startsWith(`${WS_PATH_V1}?`);
if (!isV1) {
const isDebug = url === WS_DEBUG_PATH || url.startsWith(`${WS_DEBUG_PATH}?`);
const wss = isV1 ? wssV1 : isDebug ? wssDebug : undefined;
if (wss === undefined) {
socket.destroy();
return;
}
Expand Down Expand Up @@ -543,7 +550,7 @@ export async function startServer(opts: ServerStartOptions): Promise<RunningServ
}

(socket as Socket).setNoDelay(true);
wssV1.handleUpgrade(req, socket, head, (ws) => wssV1.emit('connection', ws, req));
wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req));
};
app.server.on('upgrade', (req, socket, head) => {
void handleUpgrade(req, socket, head).catch((error: unknown) =>
Expand All @@ -554,6 +561,7 @@ export async function startServer(opts: ServerStartOptions): Promise<RunningServ
app.addHook('onClose', async () => {
connectionRegistry.closeAll('server shutting down');
wssV1.close();
wssDebug?.close();
await broadcaster.close();
});

Expand Down
39 changes: 39 additions & 0 deletions packages/kap-server/src/transport/ws/debug/registerWsDebug.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { XstateInspectionCollector } from '@moonshot-ai/agent-core-v2/human/xstateInspection';
import { WebSocketServer } from 'ws';

import { selectWsBearerProtocol } from '../bearerProtocol';
import { WsConnectionDebug } from './wsConnectionDebug';

export const WS_DEBUG_PATH = '/api/v1/debug/ws';

export interface RegisterWsDebugOptions {
readonly collector?: XstateInspectionCollector;
readonly heartbeatIntervalMs?: number;
readonly flushIntervalMs?: number;
readonly highWaterMarkBytes?: number;
}

export function registerWsDebug(opts: RegisterWsDebugOptions = {}): WebSocketServer {
const wss = new WebSocketServer({ noServer: true, handleProtocols: selectWsBearerProtocol });
const connections = new Set<WsConnectionDebug>();

wss.on('connection', (socket) => {
const conn = new WsConnectionDebug({
socket,
collector: opts.collector,
heartbeatIntervalMs: opts.heartbeatIntervalMs,
flushIntervalMs: opts.flushIntervalMs,
highWaterMarkBytes: opts.highWaterMarkBytes,
});
connections.add(conn);
socket.on('close', () => {
connections.delete(conn);
});
});

wss.on('close', () => {
for (const conn of connections) conn.close();
});

return wss;
}
114 changes: 114 additions & 0 deletions packages/kap-server/src/transport/ws/debug/wsConnectionDebug.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import {
xstateInspectionCollector,
type XstateInspectionCollector,
type XstateInspectionEnvelope,
} from '@moonshot-ai/agent-core-v2/human/xstateInspection';
import type { WebSocket } from 'ws';

const DEFAULT_HEARTBEAT_INTERVAL_MS = 10_000;
const HEARTBEAT_MISS_LIMIT = 2;
const DEFAULT_FLUSH_INTERVAL_MS = 16;
const DEFAULT_HIGH_WATER_MARK_BYTES = 1 << 20;

export interface WsConnectionDebugOptions {
readonly socket: WebSocket;
readonly collector?: XstateInspectionCollector;
readonly heartbeatIntervalMs?: number;
readonly flushIntervalMs?: number;
readonly highWaterMarkBytes?: number;
}

export class WsConnectionDebug {
private readonly socket: WebSocket;
private readonly heartbeatIntervalMs: number;
private readonly flushIntervalMs: number;
private readonly highWaterMarkBytes: number;
private readonly unsubscribe: () => void;

private closed = false;
private outbound: XstateInspectionEnvelope[] = [];
private flushTimer?: ReturnType<typeof setTimeout>;
private heartbeatTimer?: ReturnType<typeof setInterval>;
private lastPongAt = Date.now();

constructor(opts: WsConnectionDebugOptions) {
this.socket = opts.socket;
this.heartbeatIntervalMs = opts.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
this.flushIntervalMs = opts.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS;
this.highWaterMarkBytes = opts.highWaterMarkBytes ?? DEFAULT_HIGH_WATER_MARK_BYTES;

this.socket.on('close', () => this.onClose());
this.socket.on('error', () => this.onClose());
this.socket.on('pong', () => {
this.lastPongAt = Date.now();
});

const collector = opts.collector ?? xstateInspectionCollector;
this.unsubscribe = collector.subscribe((envelope) => this.onEnvelope(envelope));

this.heartbeatTimer = setInterval(() => this.onHeartbeat(), this.heartbeatIntervalMs);
this.heartbeatTimer.unref?.();
}

private onEnvelope(envelope: XstateInspectionEnvelope): void {
if (this.closed) return;
if (this.socket.bufferedAmount > this.highWaterMarkBytes) return;
this.outbound.push(envelope);
this.scheduleFlush();
Comment on lines +53 to +57

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Bound the queued and flushed inspection data

With a slow or non-reading debug client and a synchronous burst of inspection events, bufferedAmount may remain below the limit while an arbitrarily large outbound array accumulates; the subsequent flush then sends the entire captured batch without rechecking the high-water mark. This defeats the advertised backpressure limit and can grow both process memory and the WebSocket send buffer far beyond 1 MiB. Track queued bytes or cap the queue, and stop/batch the flush once bufferedAmount reaches the limit.

Useful? React with 👍 / 👎.

}

private scheduleFlush(): void {
if (this.flushTimer !== undefined) return;
this.flushTimer = setTimeout(() => {
this.flushTimer = undefined;
this.flush();
}, this.flushIntervalMs);
this.flushTimer.unref?.();
}

private flush(): void {
if (this.outbound.length === 0) return;
if (this.closed || this.socket.readyState !== this.socket.OPEN) {
this.outbound = [];
return;
}
const envelopes = this.outbound;
this.outbound = [];
for (const envelope of envelopes) {
if (this.closed || this.socket.readyState !== this.socket.OPEN) return;
try {
this.socket.send(JSON.stringify(envelope));
} catch {
}
}
}

private onHeartbeat(): void {
if (Date.now() - this.lastPongAt >= this.heartbeatIntervalMs * HEARTBEAT_MISS_LIMIT) {
this.close();
return;
}
try {
this.socket.ping();
} catch {
}
}

close(): void {
if (this.closed) return;
try {
this.socket.close(1000);
} catch {
}
this.onClose();
}

private onClose(): void {
if (this.closed) return;
this.closed = true;
if (this.flushTimer !== undefined) clearTimeout(this.flushTimer);
if (this.heartbeatTimer !== undefined) clearInterval(this.heartbeatTimer);
this.outbound = [];
this.unsubscribe();
}
}
Loading
Loading