plugin: lvmh-agent extension — mirror, replay, steer prompts; smoke 25/25, e2e 13/13 vs real pi
This commit is contained in:
@@ -0,0 +1,617 @@
|
||||
/**
|
||||
* lvmh-agent — pi extension that mirrors session events to the lvmh daemon.
|
||||
*
|
||||
* Wire contract: PROTOCOL.md (envelope v1) at the repo root.
|
||||
*
|
||||
* Environment:
|
||||
* LVMH_URL full agent websocket URL, e.g. ws://alarm:8686/agent/ws
|
||||
* LVMH_TOKEN shared bearer secret
|
||||
* LVMH_AGENT set to "1" by the daemon when spawning container sessions
|
||||
* (marks session.agent=true in the handshake snapshot)
|
||||
* LVMH_REPO "group/project" spawn metadata for container sessions
|
||||
*
|
||||
* If LVMH_URL or LVMH_TOKEN is unset the extension is fully inert.
|
||||
*
|
||||
* Bulletproofing rules implemented here (see selftest.md):
|
||||
* - no npm dependencies: Node built-in global WebSocket (undici) + node:fs
|
||||
* - every event handler is wrapped by sub(): sync throws are caught and
|
||||
* returned promise rejections are swallowed; nothing ever reaches pi
|
||||
* - all sends go through a bounded queue (drop-oldest); persisted-kind
|
||||
* events are additionally kept in a bounded replay buffer and resent
|
||||
* after welcome.lastSeq on reconnect
|
||||
* - reconnect with exponential backoff + jitter, capped, forever
|
||||
* - errors are logged to ~/.pi/lvmh-agent.log only; stdout stays clean
|
||||
* - session_shutdown closes the socket and stops all timers immediately
|
||||
*/
|
||||
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
const PROTOCOL_VERSION: number = 1;
|
||||
const ENV_URL: string = "LVMH_URL";
|
||||
const ENV_TOKEN: string = "LVMH_TOKEN";
|
||||
const ENV_AGENT: string = "LVMH_AGENT";
|
||||
const ENV_REPO: string = "LVMH_REPO";
|
||||
|
||||
const SEND_QUEUE_MAX: number = 1000;
|
||||
const REPLAY_BUFFER_MAX: number = 10000;
|
||||
const BACKOFF_BASE_MS: number = 1000;
|
||||
const BACKOFF_MAX_MS: number = 30000;
|
||||
const BACKOFF_JITTER_MS: number = 1000;
|
||||
const RESULT_PREVIEW_MAX_CHARS: number = 2000;
|
||||
const LOG_LINE_MAX_CHARS: number = 500;
|
||||
|
||||
const TRANSIENT_TYPES: ReadonlySet<string> = new Set(["message_update"]);
|
||||
|
||||
interface Frame {
|
||||
v: number;
|
||||
sessionId: string;
|
||||
seq: number;
|
||||
ts: number;
|
||||
type: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface SessionSnapshot {
|
||||
id: string;
|
||||
name: string | null;
|
||||
cwd: string;
|
||||
model: string | null;
|
||||
provider: string | null;
|
||||
agent: boolean;
|
||||
repo: string | null;
|
||||
startedAt: number;
|
||||
}
|
||||
|
||||
/** Per-session seq counters survive reconnects and instance rebinds in-process. */
|
||||
const seqCounters: Map<string, number> = new Map();
|
||||
const sessionStartTs: Map<string, number> = new Map();
|
||||
|
||||
const logPath: string = path.join(os.homedir(), ".pi", "lvmh-agent.log");
|
||||
|
||||
/** Best-effort append to the log file. Never throws, never touches stdout. */
|
||||
function log(message: unknown): void {
|
||||
try {
|
||||
const line = `${new Date().toISOString()} ${String(message).slice(0, LOG_LINE_MAX_CHARS)}\n`;
|
||||
fs.appendFile(logPath, line, () => undefined);
|
||||
} catch {
|
||||
// even appendFile argument validation failures must stay silent
|
||||
}
|
||||
}
|
||||
|
||||
function nextSeq(sessionId: string): number {
|
||||
const n: number = (seqCounters.get(sessionId) ?? 0) + 1;
|
||||
seqCounters.set(sessionId, n);
|
||||
return n;
|
||||
}
|
||||
|
||||
/** Concatenate text blocks of a pi content array (or pass through a string). */
|
||||
function textOfContent(content: unknown): string {
|
||||
if (typeof content === "string") return content;
|
||||
if (!Array.isArray(content)) return "";
|
||||
let out = "";
|
||||
for (const block of content) {
|
||||
if (block !== null && typeof block === "object" && (block as { type?: unknown }).type === "text") {
|
||||
const text = (block as { text?: unknown }).text;
|
||||
if (typeof text === "string") out += text;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function safeJsonStringify(value: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(value) ?? "null";
|
||||
} catch {
|
||||
return "null";
|
||||
}
|
||||
}
|
||||
|
||||
/** Map a pi AgentMessage to the protocol Message shape (message_end payload). */
|
||||
function mapMessage(message: unknown): Record<string, unknown> | null {
|
||||
if (message === null || typeof message !== "object") return null;
|
||||
const msg = message as {
|
||||
role?: unknown;
|
||||
content?: unknown;
|
||||
timestamp?: unknown;
|
||||
toolCallId?: unknown;
|
||||
};
|
||||
const role = typeof msg.role === "string" ? msg.role : "unknown";
|
||||
const content = msg.content;
|
||||
const toolCalls: Array<{ id: string; name: string; argsJson: string }> = [];
|
||||
let thinking = "";
|
||||
if (Array.isArray(content)) {
|
||||
for (const block of content) {
|
||||
if (block === null || typeof block !== "object") continue;
|
||||
const b = block as { type?: unknown; id?: unknown; name?: unknown; arguments?: unknown };
|
||||
if (b.type === "toolCall") {
|
||||
toolCalls.push({
|
||||
id: String(b.id ?? ""),
|
||||
name: String(b.name ?? ""),
|
||||
argsJson: safeJsonStringify(b.arguments),
|
||||
});
|
||||
} else if (b.type === "thinking" && typeof (b as { thinking?: unknown }).thinking === "string") {
|
||||
thinking += (b as { thinking: string }).thinking;
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
role,
|
||||
id: `${role}-${typeof msg.timestamp === "number" ? msg.timestamp : Date.now()}`,
|
||||
text: textOfContent(content),
|
||||
thinking: thinking.length > 0 ? thinking : null,
|
||||
toolCalls,
|
||||
toolCallId: role === "toolResult" && typeof msg.toolCallId === "string" ? msg.toolCallId : null,
|
||||
};
|
||||
}
|
||||
|
||||
export default function (pi: ExtensionAPI): void {
|
||||
const rawUrl: string | undefined = process.env[ENV_URL];
|
||||
const rawToken: string | undefined = process.env[ENV_TOKEN];
|
||||
if (!rawUrl || !rawToken) return; // inert: no daemon configured
|
||||
const url: string = rawUrl;
|
||||
const token: string = rawToken;
|
||||
if (typeof WebSocket === "undefined") {
|
||||
log("global WebSocket unavailable (need Node >= 22); extension inert");
|
||||
return;
|
||||
}
|
||||
|
||||
let stopped: boolean = false;
|
||||
let ws: WebSocket | null = null;
|
||||
let wsOpen: boolean = false;
|
||||
let greeted: boolean = false;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let backoffAttempt: number = 0;
|
||||
|
||||
let currentSessionId: string | null = null;
|
||||
let snapshot: SessionSnapshot | null = null;
|
||||
let lastStreamText: string = "";
|
||||
let lastCtx: ExtensionContext | undefined;
|
||||
|
||||
let sendQueue: Frame[] = [];
|
||||
let replayBuf: Frame[] = [];
|
||||
let droppedEvents: number = 0;
|
||||
|
||||
/**
|
||||
* Subscribe with a bulletproof wrapper: the inner handler may be sync or
|
||||
* async; sync throws are caught here and returned promises that reject are
|
||||
* swallowed. A failing handler logs and yields undefined — it can never
|
||||
* throw into or reject on pi.
|
||||
*/
|
||||
function sub(
|
||||
event: string,
|
||||
handler: (event: any, ctx: ExtensionContext) => unknown,
|
||||
): void {
|
||||
(pi as { on: (e: string, h: (event: any, ctx: ExtensionContext) => unknown) => void }).on(
|
||||
event,
|
||||
(evt: any, ctx: ExtensionContext): undefined => {
|
||||
lastCtx = ctx;
|
||||
try {
|
||||
const result = handler(evt, ctx);
|
||||
if (result !== null && typeof result === "object" && typeof (result as Promise<unknown>).then === "function") {
|
||||
(result as Promise<unknown>).then(undefined, (err: unknown) => {
|
||||
log(`async handler error (${event}): ${err}`);
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
log(`handler error (${event}): ${err}`);
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function enqueue(frame: Frame): void {
|
||||
sendQueue.push(frame);
|
||||
while (sendQueue.length > SEND_QUEUE_MAX) {
|
||||
const dropped: Frame | undefined = sendQueue.shift();
|
||||
if (dropped !== undefined) {
|
||||
droppedEvents++;
|
||||
log(`send queue overflow, dropped ${dropped.type} seq=${dropped.seq}`);
|
||||
}
|
||||
}
|
||||
flush();
|
||||
}
|
||||
|
||||
function flush(): void {
|
||||
while (!stopped && ws !== null && wsOpen && greeted && sendQueue.length > 0) {
|
||||
const frame: Frame | undefined = sendQueue.shift();
|
||||
if (frame === undefined) return;
|
||||
let text: string;
|
||||
try {
|
||||
text = JSON.stringify(frame);
|
||||
} catch (err) {
|
||||
log(`serialize error (${frame.type}): ${err}`);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
ws.send(text);
|
||||
} catch (err) {
|
||||
log(`send error (${frame.type}): ${err}`);
|
||||
handleDisconnect();
|
||||
scheduleReconnect(); // in case no onclose follows the failed send
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Persisted-kind events only (message_update is live-only per protocol). */
|
||||
function emit(type: string, payload: Record<string, unknown>): void {
|
||||
if (stopped || currentSessionId === null) return;
|
||||
const frame: Frame = {
|
||||
v: PROTOCOL_VERSION,
|
||||
sessionId: currentSessionId,
|
||||
seq: nextSeq(currentSessionId),
|
||||
ts: Date.now(),
|
||||
type,
|
||||
...payload,
|
||||
};
|
||||
if (!TRANSIENT_TYPES.has(type)) {
|
||||
replayBuf.push(frame);
|
||||
while (replayBuf.length > REPLAY_BUFFER_MAX) {
|
||||
replayBuf.shift();
|
||||
droppedEvents++;
|
||||
log(`replay buffer overflow (cap ${REPLAY_BUFFER_MAX}), dropped oldest event`);
|
||||
}
|
||||
}
|
||||
enqueue(frame);
|
||||
}
|
||||
|
||||
function handleDisconnect(): void {
|
||||
const socket: WebSocket | null = ws;
|
||||
ws = null;
|
||||
wsOpen = false;
|
||||
greeted = false;
|
||||
try {
|
||||
socket?.close();
|
||||
} catch {
|
||||
// close failures are meaningless here
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleReconnect(): void {
|
||||
if (stopped || reconnectTimer !== null || ws !== null) return;
|
||||
const expMs: number = BACKOFF_BASE_MS * 2 ** backoffAttempt;
|
||||
const waitMs: number =
|
||||
Math.min(Number.isFinite(expMs) ? expMs : BACKOFF_MAX_MS, BACKOFF_MAX_MS) +
|
||||
Math.floor(Math.random() * BACKOFF_JITTER_MS);
|
||||
backoffAttempt++;
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
connect();
|
||||
}, waitMs);
|
||||
(reconnectTimer as { unref?: () => void }).unref?.();
|
||||
}
|
||||
|
||||
function connect(): void {
|
||||
if (stopped || ws !== null || reconnectTimer !== null) return;
|
||||
let socket: WebSocket;
|
||||
try {
|
||||
socket = new WebSocket(url, { headers: { Authorization: `Bearer ${token}` } });
|
||||
} catch (err) {
|
||||
log(`ws construct error: ${err}`);
|
||||
scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
ws = socket;
|
||||
// Stale-socket guards: after a session switch an old socket's callbacks
|
||||
// can still fire; they must never touch the state of the new socket.
|
||||
socket.onopen = () => {
|
||||
if (ws !== socket) return;
|
||||
try {
|
||||
wsOpen = true;
|
||||
sendHello();
|
||||
flush();
|
||||
} catch (err) {
|
||||
log(`onopen error: ${err}`);
|
||||
handleDisconnect();
|
||||
scheduleReconnect();
|
||||
}
|
||||
};
|
||||
socket.onmessage = (ev: unknown) => {
|
||||
if (ws !== socket) return;
|
||||
try {
|
||||
onMessage(ev);
|
||||
} catch (err) {
|
||||
log(`onmessage error: ${err}`);
|
||||
}
|
||||
};
|
||||
socket.onerror = () => {
|
||||
// undici always follows onerror with onclose; logging happens there
|
||||
};
|
||||
socket.onclose = () => {
|
||||
if (ws !== socket) return;
|
||||
try {
|
||||
log(`ws closed after backoff attempt #${backoffAttempt}`);
|
||||
handleDisconnect();
|
||||
scheduleReconnect();
|
||||
} catch (err) {
|
||||
log(`onclose error: ${err}`);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function sendHello(): void {
|
||||
if (ws === null || currentSessionId === null || snapshot === null) return;
|
||||
const hello: Frame = {
|
||||
v: PROTOCOL_VERSION,
|
||||
sessionId: currentSessionId,
|
||||
seq: 0,
|
||||
ts: Date.now(),
|
||||
type: "hello",
|
||||
session: snapshot,
|
||||
};
|
||||
ws.send(JSON.stringify(hello));
|
||||
}
|
||||
|
||||
function onWelcome(frame: Record<string, unknown>): void {
|
||||
if (typeof frame.sessionId === "string" && frame.sessionId !== currentSessionId) {
|
||||
log(`welcome for foreign session ${String(frame.sessionId)}, ignoring`);
|
||||
return;
|
||||
}
|
||||
const lastSeq: number = typeof frame.lastSeq === "number" && Number.isFinite(frame.lastSeq) ? frame.lastSeq : 0;
|
||||
backoffAttempt = 0;
|
||||
greeted = true;
|
||||
// Post-restart safety: never reuse seq numbers the daemon already has.
|
||||
if (currentSessionId !== null) {
|
||||
const counter: number = seqCounters.get(currentSessionId) ?? 0;
|
||||
if (counter <= lastSeq) seqCounters.set(currentSessionId, lastSeq);
|
||||
}
|
||||
// Replay persisted events the daemon is missing, before queued live
|
||||
// frames. Persisted-kind frames still sitting in the queue are dropped
|
||||
// here: they are already in replayBuf, so sending both would duplicate
|
||||
// them (only transient message_update frames survive the merge).
|
||||
const replay: Frame[] = replayBuf.filter((f) => f.seq > lastSeq);
|
||||
sendQueue = replay.concat(sendQueue.filter((f) => TRANSIENT_TYPES.has(f.type)));
|
||||
while (sendQueue.length > SEND_QUEUE_MAX) {
|
||||
const dropped: Frame | undefined = sendQueue.shift();
|
||||
if (dropped !== undefined) {
|
||||
droppedEvents++;
|
||||
log(`send queue overflow during replay, dropped ${dropped.type} seq=${dropped.seq}`);
|
||||
}
|
||||
}
|
||||
if (droppedEvents > 0) {
|
||||
emit("buffer_overflow", { dropped: droppedEvents });
|
||||
droppedEvents = 0;
|
||||
}
|
||||
flush();
|
||||
}
|
||||
|
||||
function onMessage(ev: unknown): void {
|
||||
const data = (ev as { data?: unknown }).data;
|
||||
let frame: unknown;
|
||||
try {
|
||||
frame = JSON.parse(typeof data === "string" ? data : "");
|
||||
} catch {
|
||||
log("malformed json frame from daemon");
|
||||
return;
|
||||
}
|
||||
if (frame === null || typeof frame !== "object") return;
|
||||
const f = frame as { v?: unknown; type?: unknown; message?: unknown; sessionId?: unknown };
|
||||
if (f.v !== PROTOCOL_VERSION) return;
|
||||
if (f.type === "welcome") onWelcome(f as Record<string, unknown>);
|
||||
else if (f.type === "prompt") deliverPrompt(f);
|
||||
else if (f.type === "abort") doAbort();
|
||||
// unknown types are ignored (forward compatibility)
|
||||
}
|
||||
|
||||
function deliverPrompt(frame: { message?: unknown; sessionId?: unknown }): void {
|
||||
const message = frame.message;
|
||||
if (typeof message !== "string" || message.length === 0) return;
|
||||
if (typeof frame.sessionId === "string" && frame.sessionId !== currentSessionId) return;
|
||||
try {
|
||||
pi.sendUserMessage(message, { deliverAs: "steer" });
|
||||
} catch (err) {
|
||||
log(`sendUserMessage failed: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
function doAbort(): void {
|
||||
try {
|
||||
const maybeAbort = (pi as unknown as { abort?: () => void }).abort;
|
||||
if (typeof maybeAbort === "function") maybeAbort.call(pi);
|
||||
else lastCtx?.abort();
|
||||
} catch (err) {
|
||||
log(`abort failed: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
function buildSnapshot(ctx: ExtensionContext, sessionId: string): SessionSnapshot {
|
||||
const sm = ctx.sessionManager as {
|
||||
getSessionName?: () => string | undefined;
|
||||
getCwd?: () => string;
|
||||
getHeader?: () => { timestamp?: unknown } | null;
|
||||
};
|
||||
let name: string | null = null;
|
||||
let cwd: string = ctx.cwd;
|
||||
let startedAt: number | undefined = sessionStartTs.get(sessionId);
|
||||
try {
|
||||
name = sm.getSessionName?.() ?? null;
|
||||
cwd = sm.getCwd?.() ?? ctx.cwd;
|
||||
if (startedAt === undefined) {
|
||||
const headerTs = sm.getHeader?.()?.timestamp;
|
||||
startedAt = typeof headerTs === "string" ? Date.parse(headerTs) : NaN;
|
||||
if (!Number.isFinite(startedAt)) startedAt = Date.now();
|
||||
sessionStartTs.set(sessionId, startedAt);
|
||||
}
|
||||
} catch (err) {
|
||||
log(`snapshot fallback (${String(err).slice(0, 80)}): using ctx.cwd/now`);
|
||||
if (startedAt === undefined) {
|
||||
startedAt = Date.now();
|
||||
sessionStartTs.set(sessionId, startedAt);
|
||||
}
|
||||
}
|
||||
const model = ctx.model as { id?: unknown; provider?: unknown } | undefined;
|
||||
return {
|
||||
id: sessionId,
|
||||
name,
|
||||
cwd,
|
||||
model: model !== undefined && typeof model.id === "string" ? model.id : null,
|
||||
provider: model !== undefined && typeof model.provider === "string" ? model.provider : null,
|
||||
agent: process.env[ENV_AGENT] === "1",
|
||||
repo: process.env[ENV_REPO] ?? null,
|
||||
startedAt,
|
||||
};
|
||||
}
|
||||
|
||||
sub("session_start", (_event: any, ctx: ExtensionContext) => {
|
||||
stopped = false;
|
||||
let sessionId: string | null = null;
|
||||
try {
|
||||
const id = (ctx.sessionManager as { getSessionId?: () => string }).getSessionId?.();
|
||||
sessionId = typeof id === "string" && id.length > 0 ? id : null;
|
||||
} catch (err) {
|
||||
log(`getSessionId failed: ${err}`);
|
||||
}
|
||||
if (sessionId === null) {
|
||||
log("no session id; lvmh mirroring disabled for this session");
|
||||
return;
|
||||
}
|
||||
if (sessionId !== currentSessionId) {
|
||||
sendQueue = [];
|
||||
replayBuf = [];
|
||||
droppedEvents = 0;
|
||||
lastStreamText = "";
|
||||
handleDisconnect(); // drop any socket bound to the previous session
|
||||
try {
|
||||
if (reconnectTimer !== null) clearTimeout(reconnectTimer);
|
||||
} catch {
|
||||
// clearable timers never throw in practice
|
||||
}
|
||||
reconnectTimer = null;
|
||||
}
|
||||
currentSessionId = sessionId;
|
||||
snapshot = buildSnapshot(ctx, sessionId);
|
||||
connect();
|
||||
});
|
||||
|
||||
sub("session_shutdown", () => {
|
||||
stopped = true;
|
||||
try {
|
||||
if (reconnectTimer !== null) clearTimeout(reconnectTimer);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
reconnectTimer = null;
|
||||
const socket: WebSocket | null = ws;
|
||||
ws = null;
|
||||
wsOpen = false;
|
||||
greeted = false;
|
||||
try {
|
||||
if (socket !== null && socket.readyState === WebSocket.OPEN && currentSessionId !== null) {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
v: PROTOCOL_VERSION,
|
||||
sessionId: currentSessionId,
|
||||
seq: 0,
|
||||
ts: Date.now(),
|
||||
type: "bye",
|
||||
reason: "shutdown",
|
||||
}),
|
||||
);
|
||||
}
|
||||
socket?.close();
|
||||
} catch {
|
||||
// best-effort goodbye; nothing to flush, exit fast
|
||||
}
|
||||
});
|
||||
|
||||
sub("session_info_changed", (event: { name?: unknown }) => {
|
||||
if (snapshot === null) return;
|
||||
const name = typeof event.name === "string" ? event.name : null;
|
||||
if (name === snapshot.name) return;
|
||||
snapshot.name = name;
|
||||
emit("session_info", { session: snapshot });
|
||||
});
|
||||
|
||||
sub("model_select", (event: { model?: { id?: unknown; provider?: unknown } }) => {
|
||||
if (snapshot === null) return;
|
||||
const model = event.model;
|
||||
const id = model !== undefined && typeof model.id === "string" ? model.id : null;
|
||||
const provider = model !== undefined && typeof model.provider === "string" ? model.provider : null;
|
||||
if (id === snapshot.model && provider === snapshot.provider) return;
|
||||
snapshot.model = id;
|
||||
snapshot.provider = provider;
|
||||
emit("session_info", { session: snapshot });
|
||||
});
|
||||
|
||||
sub("message_start", (event: { message?: unknown }) => {
|
||||
const mapped = mapMessage(event.message);
|
||||
if (mapped === null) return;
|
||||
if (mapped.role === "assistant") lastStreamText = "";
|
||||
emit("message_start", { message: { role: mapped.role, id: mapped.id } });
|
||||
});
|
||||
|
||||
sub("message_update", (event: { message?: unknown }) => {
|
||||
const mapped = mapMessage(event.message);
|
||||
if (mapped === null || mapped.role !== "assistant") return;
|
||||
const text = typeof mapped.text === "string" ? mapped.text : "";
|
||||
// Delta only: pi hands us accumulated text; diff against what we sent.
|
||||
const delta: string = text.startsWith(lastStreamText) ? text.slice(lastStreamText.length) : text;
|
||||
lastStreamText = text;
|
||||
if (delta.length > 0) emit("message_update", { delta });
|
||||
});
|
||||
|
||||
sub("message_end", (event: { message?: unknown }) => {
|
||||
const mapped = mapMessage(event.message);
|
||||
if (mapped === null) return;
|
||||
lastStreamText = "";
|
||||
emit("message_end", { message: mapped });
|
||||
});
|
||||
|
||||
sub("tool_execution_start", (event: { toolCallId?: unknown; toolName?: unknown; args?: unknown }) => {
|
||||
emit("tool_execution_start", {
|
||||
toolCallId: String(event.toolCallId ?? ""),
|
||||
toolName: String(event.toolName ?? ""),
|
||||
args: event.args ?? null,
|
||||
});
|
||||
});
|
||||
|
||||
sub("tool_execution_update", (event: { toolCallId?: unknown; toolName?: unknown; partialResult?: { content?: unknown } }) => {
|
||||
const partial: string = textOfContent(event.partialResult?.content).slice(0, RESULT_PREVIEW_MAX_CHARS);
|
||||
emit("tool_execution_update", {
|
||||
toolCallId: String(event.toolCallId ?? ""),
|
||||
toolName: String(event.toolName ?? ""),
|
||||
partial,
|
||||
});
|
||||
});
|
||||
|
||||
sub("tool_execution_end", (event: { toolCallId?: unknown; toolName?: unknown; result?: { content?: unknown }; isError?: unknown }) => {
|
||||
const preview: string = textOfContent(event.result?.content).slice(0, RESULT_PREVIEW_MAX_CHARS);
|
||||
emit("tool_execution_end", {
|
||||
toolCallId: String(event.toolCallId ?? ""),
|
||||
toolName: String(event.toolName ?? ""),
|
||||
isError: event.isError === true,
|
||||
resultPreview: preview,
|
||||
});
|
||||
});
|
||||
|
||||
sub("agent_start", () => {
|
||||
emit("agent_start", {});
|
||||
});
|
||||
|
||||
sub("agent_end", (event: { messages?: unknown }) => {
|
||||
let inputTokens = 0;
|
||||
let outputTokens = 0;
|
||||
let totalCost = 0;
|
||||
let seen = false;
|
||||
if (Array.isArray(event.messages)) {
|
||||
for (const m of event.messages) {
|
||||
if (m === null || typeof m !== "object" || (m as { role?: unknown }).role !== "assistant") continue;
|
||||
const u = (m as { usage?: { input?: unknown; output?: unknown; cost?: { total?: unknown } } }).usage;
|
||||
if (u === undefined) continue;
|
||||
seen = true;
|
||||
inputTokens += typeof u.input === "number" ? u.input : 0;
|
||||
outputTokens += typeof u.output === "number" ? u.output : 0;
|
||||
totalCost += u.cost !== undefined && typeof u.cost.total === "number" ? u.cost.total : 0;
|
||||
}
|
||||
}
|
||||
emit("agent_end", { usage: seen ? { inputTokens, outputTokens, totalCost } : {} });
|
||||
});
|
||||
|
||||
sub("agent_settled", () => {
|
||||
emit("agent_settled", {});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user