Files
NetMesh/infrastructure/ai/harness/agentEventAdapter.ts

318 lines
9.5 KiB
TypeScript
Raw Permalink Normal View History

import type { AgentEvent, AgentEventListener } from './types';
import { resolveStreamChunkToolCallId } from '../aiChatStreamingSupport';
let eventCounter = 0;
function nextEventId(prefix: string): string {
eventCounter += 1;
return `${prefix}-${Date.now()}-${eventCounter}`;
}
export interface StreamEventContext {
sessionId: string;
chatSessionId?: string;
turnId?: string;
}
export interface CattyStreamChunk {
type: string;
text?: string;
textDelta?: string;
delta?: string;
toolCallId?: string;
toolName?: string;
input?: unknown;
args?: unknown;
output?: unknown;
result?: unknown;
error?: unknown;
approved?: boolean;
stepNumber?: number;
}
const STEP_HANDLE_NOTICE_RE = /^\[step \d+\] Tool output handles available:/;
function isStepHandleNoticeMessage(content: unknown): boolean {
return typeof content === 'string' && STEP_HANDLE_NOTICE_RE.test(content);
}
function isToolResultError(output: unknown): boolean {
if (output == null) return false;
if (typeof output === 'object') {
const obj = output as Record<string, unknown>;
if ('error' in obj && typeof obj.error === 'string') return true;
if ('ok' in obj && obj.ok === false) return true;
}
if (typeof output === 'string') {
try {
const parsed = JSON.parse(output) as Record<string, unknown>;
if ('error' in parsed && typeof parsed.error === 'string') return true;
if ('ok' in parsed && parsed.ok === false) return true;
} catch {
return false;
}
}
return false;
}
export function mapSdkStreamEventToAgentEvents(
event: Record<string, unknown>,
ctx: StreamEventContext,
): AgentEvent[] {
const base = {
sessionId: ctx.sessionId,
chatSessionId: ctx.chatSessionId,
backend: 'external-sdk' as const,
timestamp: Date.now(),
turnId: ctx.turnId,
};
switch (event.type) {
case 'text-delta':
return [{
...base,
id: nextEventId('model-delta'),
type: 'model_delta',
text: String(event.text ?? event.textDelta ?? event.delta ?? ''),
}];
case 'thinking-delta':
case 'reasoning-delta':
return [{
...base,
id: nextEventId('reasoning-delta'),
type: 'reasoning_delta',
text: String(event.text ?? event.textDelta ?? event.delta ?? ''),
}];
case 'tool-call':
return [{
...base,
id: nextEventId('tool-call'),
type: 'tool_call',
toolCallId: String(event.toolCallId ?? event.id ?? ''),
toolName: String(event.toolName ?? event.name ?? 'unknown'),
args: (event.args ?? event.input ?? {}) as Record<string, unknown>,
}];
case 'tool-result':
{
const output = event.result ?? event.output ?? '';
return [{
...base,
id: nextEventId('tool-result'),
type: 'tool_result',
toolCallId: String(event.toolCallId ?? ''),
toolName: typeof event.toolName === 'string' ? event.toolName : undefined,
result: typeof output === 'string' ? output : JSON.stringify(output),
isError: Boolean(event.isError) || isToolResultError(output),
}];
}
case 'file-change':
return [{
...base,
id: nextEventId('file-change'),
type: 'file_change',
itemId: String(event.itemId ?? ''),
status: event.status === 'failed' ? 'failed' : 'completed',
changes: Array.isArray(event.changes)
? event.changes as Array<{ path: string; kind: 'add' | 'delete' | 'update' }>
: [],
}];
case 'web-search':
return [{
...base,
id: nextEventId('web-search'),
type: 'web_search',
itemId: String(event.itemId ?? ''),
query: String(event.query ?? ''),
status: event.status === 'completed' ? 'completed' : 'running',
}];
case 'plan-update':
return [{
...base,
id: nextEventId('plan-update'),
type: 'plan_update',
itemId: String(event.itemId ?? ''),
status: event.status === 'completed' ? 'completed' : 'running',
items: Array.isArray(event.items)
? event.items as Array<{ text: string; completed: boolean }>
: [],
}];
case 'warning':
return [{
...base,
id: nextEventId('warning'),
type: 'error',
message: String(event.message ?? 'Unknown SDK warning'),
recoverable: true,
}];
case 'usage': {
const promptTokens = Number(event.inputTokens) || 0;
const completionTokens = Number(event.outputTokens) || 0;
return [{
...base,
id: nextEventId('usage'),
type: 'usage',
promptTokens,
cachedPromptTokens: Number(event.cachedInputTokens) || 0,
completionTokens,
reasoningTokens: Number(event.reasoningTokens) || 0,
totalTokens: Number(event.totalTokens) || promptTokens + completionTokens,
estimated: false,
}];
}
case 'error':
return [{
...base,
id: nextEventId('error'),
type: 'error',
message: String(event.error ?? event.message ?? 'Unknown SDK error'),
recoverable: false,
}];
default:
return [];
}
}
export function mapCattyStreamChunkToAgentEvents(
chunk: CattyStreamChunk,
ctx: StreamEventContext,
): AgentEvent[] {
const base = {
sessionId: ctx.sessionId,
chatSessionId: ctx.chatSessionId,
backend: 'catty' as const,
timestamp: Date.now(),
turnId: ctx.turnId,
};
if (chunk.type === 'text' || chunk.type === 'text-delta') {
const text = chunk.text ?? chunk.textDelta ?? '';
if (!text) return [];
return [{ ...base, id: nextEventId('model-delta'), type: 'model_delta', text }];
}
if (chunk.type === 'reasoning' || chunk.type === 'reasoning-start' || chunk.type === 'reasoning-delta') {
const text = chunk.text ?? chunk.textDelta ?? chunk.delta ?? '';
if (!text) return [];
return [{ ...base, id: nextEventId('reasoning-delta'), type: 'reasoning_delta', text }];
}
if (chunk.type === 'tool-call' && chunk.toolCallId && chunk.toolName) {
return [{
...base,
id: nextEventId('tool-call'),
type: 'tool_call',
toolCallId: chunk.toolCallId,
toolName: chunk.toolName,
args: (chunk.input ?? chunk.args ?? {}) as Record<string, unknown>,
}];
}
if (chunk.type === 'tool-result' && chunk.toolCallId) {
const output = chunk.output ?? chunk.result;
const resultText = typeof output === 'string' ? output : JSON.stringify(output ?? '');
return [{
...base,
id: nextEventId('tool-result'),
type: 'tool_result',
toolCallId: chunk.toolCallId,
toolName: typeof chunk.toolName === 'string' ? chunk.toolName : undefined,
result: resultText,
isError: isToolResultError(output),
}];
}
if (chunk.type === 'tool-error' && chunk.toolCallId) {
const resultText = chunk.error instanceof Error
? JSON.stringify({ error: chunk.error.message })
: typeof chunk.error === 'string'
? JSON.stringify({ error: chunk.error })
: JSON.stringify({ error: String(chunk.error ?? 'Tool execution failed.') });
return [{
...base,
id: nextEventId('tool-result'),
type: 'tool_result',
toolCallId: chunk.toolCallId,
toolName: typeof chunk.toolName === 'string' ? chunk.toolName : undefined,
result: resultText,
isError: true,
}];
}
if (chunk.type === 'error') {
const message = chunk.error instanceof Error
? chunk.error.message
: String(chunk.error ?? 'Unknown stream error');
return [{ ...base, id: nextEventId('error'), type: 'error', message }];
}
if (chunk.type === 'tool-approval-request') {
const toolCallId = resolveStreamChunkToolCallId(chunk);
const toolName = chunk.toolName ?? chunk.toolCall?.toolName;
if (!toolCallId || !toolName) return [];
return [{
...base,
id: nextEventId('approval-requested'),
type: 'approval_requested',
toolCallId,
toolName,
args: (chunk.input ?? chunk.args ?? chunk.toolCall?.input ?? {}) as Record<string, unknown>,
}];
}
if (chunk.type === 'tool-approval-response') {
const toolCallId = resolveStreamChunkToolCallId(chunk);
if (!toolCallId) return [];
const approved = chunk.approved === true;
const events: AgentEvent[] = [{
...base,
id: nextEventId('approval-resolved'),
type: 'approval_resolved',
toolCallId,
toolName: String(chunk.toolName ?? chunk.toolCall?.toolName ?? 'unknown'),
outcome: approved ? 'approved' : 'denied',
}];
if (!approved) {
events.push({
...base,
id: nextEventId('tool-result'),
type: 'tool_result',
toolCallId,
result: JSON.stringify({ error: chunk.reason ?? 'Tool execution denied.' }),
isError: true,
});
}
return events;
}
if (chunk.type === 'tool-output-denied' && chunk.toolCallId) {
return [
{
...base,
id: nextEventId('approval-resolved'),
type: 'approval_resolved',
toolCallId: chunk.toolCallId,
toolName: String(chunk.toolName ?? 'unknown'),
outcome: 'denied',
},
{
...base,
id: nextEventId('tool-result'),
type: 'tool_result',
toolCallId: chunk.toolCallId,
result: JSON.stringify({ error: 'Tool execution denied.' }),
isError: true,
},
];
}
return [];
}
export { isStepHandleNoticeMessage };
export function createHarnessEventSink(
listener: AgentEventListener,
): AgentEventListener {
return (event) => listener(event);
}