Path: blob/main/extensions/copilot/src/extension/chronicle/common/eventTranslator.ts
13399 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { generateUuid } from '../../../util/vs/base/common/uuid';6import { CopilotChatAttr, GenAiAttr, GenAiOperationName } from '../../../platform/otel/common/genAiAttributes';7import type { ICompletedSpanData } from '../../../platform/otel/common/otelService';8import type { SessionEvent, WorkingDirectoryContext } from './cloudSessionTypes';910// ── Content size limits (bytes) ─────────────────────────────────────────────────11// Truncate content before buffering to keep memory and payload sizes bounded.1213/** Maximum size for user message content. */14const MAX_USER_MESSAGE_SIZE = 10_240;1516/** Maximum size for assistant message content. */17const MAX_ASSISTANT_MESSAGE_SIZE = 10_240;1819/** Maximum size for tool result content blocks. */20const MAX_TOOL_RESULT_SIZE = 5_120;2122/** Maximum estimated JSON size for a single event before it is dropped. */23const MAX_EVENT_SIZE = 51_200;2425/**26* Truncate a string to a maximum byte length (UTF-8 approximation).27*/28function truncate(str: string, maxBytes: number): string {29if (str.length <= maxBytes) {30return str;31}32return str.slice(0, maxBytes) + '... [truncated]';33}3435/**36* Rough estimate of the JSON-serialized size of an event.37* Avoids the cost of actual serialization.38*/39function estimateEventSize(event: SessionEvent): number {40// Base overhead: id (36) + timestamp (24) + type (~30) + parentId (36) + structure (~50)41let size = 176;42const data = event.data;43for (const value of Object.values(data)) {44if (typeof value === 'string') {45size += value.length;46} else if (typeof value === 'object' && value !== null) {47// Rough estimate for nested objects48size += JSON.stringify(value).length;49} else {50size += 10;51}52}53return size;54}5556/**57* Tracks per-session state needed for event translation.58*/59export interface SessionTranslationState {60/** Whether session.start has been emitted. */61started: boolean;62/** ID of the last event emitted (for parentId chaining). */63lastEventId: string | null;64/** Number of events dropped due to size gate. */65droppedCount: number;66}6768/**69* Create a fresh translation state for a new session.70*/71export function createSessionTranslationState(): SessionTranslationState {72return { started: false, lastEventId: null, droppedCount: 0 };73}7475/**76* Translate a completed OTel span into zero or more cloud SessionEvents.77*78* Returns the events to buffer, or an empty array if the span is not relevant.79* Mutates `state` to track parentId chaining and session.start emission.80*81* @internal Exported for testing.82*/83export function translateSpan(84span: ICompletedSpanData,85state: SessionTranslationState,86context?: WorkingDirectoryContext,87): SessionEvent[] {88const operationName = span.attributes[GenAiAttr.OPERATION_NAME] as string | undefined;89const events: SessionEvent[] = [];9091if (operationName === GenAiOperationName.INVOKE_AGENT) {92// Extract user message first — needed for session.start summary93const userRequest = span.attributes[CopilotChatAttr.USER_REQUEST] as string | undefined;9495// First invoke_agent span → session.start96if (!state.started) {97state.started = true;98events.push(makeEvent(state, 'session.start', {99sessionId: getSessionId(span) ?? generateUuid(),100version: 1,101producer: 'vscode-copilot-chat',102copilotVersion: '1.0.0',103startTime: new Date(span.startTime).toISOString(),104selectedModel: span.attributes[GenAiAttr.REQUEST_MODEL] as string | undefined,105context: {106cwd: context?.cwd,107repository: context?.repository,108hostType: 'github',109branch: context?.branch,110headCommit: context?.headCommit,111},112}));113}114115// Emit user.message (matches CLI format)116if (userRequest) {117events.push(makeEvent(state, 'user.message', {118content: truncate(userRequest, MAX_USER_MESSAGE_SIZE),119source: 'chat',120agentMode: 'interactive',121}));122}123124// Extract assistant response + tool requests125const assistantText = extractAssistantText(span);126const toolRequests = extractToolRequests(span);127if (assistantText || toolRequests.length > 0) {128events.push(makeEvent(state, 'assistant.message', {129messageId: generateUuid(),130content: truncate(assistantText ?? '', MAX_ASSISTANT_MESSAGE_SIZE),131toolRequests: toolRequests.length > 0 ? toolRequests : undefined,132}));133134// Emit tool.execution_start for each tool request (matches CLI pattern)135for (const req of toolRequests) {136events.push(makeEvent(state, 'tool.execution_start', {137toolCallId: req.toolCallId,138toolName: req.name,139arguments: req.arguments,140}));141}142}143}144145if (operationName === GenAiOperationName.EXECUTE_TOOL) {146const toolName = span.attributes[GenAiAttr.TOOL_NAME] as string | undefined;147if (toolName) {148const toolCallId = (span.attributes[GenAiAttr.TOOL_CALL_ID] as string | undefined) ?? generateUuid();149const resultText = span.attributes['gen_ai.tool.result'] as string | undefined;150const success = span.status.code !== 2; // SpanStatusCode.ERROR = 2151const truncatedResult = resultText ? truncate(resultText, MAX_TOOL_RESULT_SIZE) : '';152153// Emit tool.execution_complete (matches CLI format exactly)154events.push(makeEvent(state, 'tool.execution_complete', {155toolCallId,156success,157result: success ? {158content: truncatedResult,159detailedContent: truncatedResult,160} : undefined,161error: !success ? {162message: truncatedResult || 'Tool execution failed',163code: 'failure',164} : undefined,165}));166}167}168169// Filter out oversized events170return events.filter(event => {171const size = estimateEventSize(event);172if (size > MAX_EVENT_SIZE) {173state.droppedCount++;174return false;175}176return true;177});178}179180/**181* Create a session.idle event (emitted when the chat session becomes idle).182*/183export function makeIdleEvent(state: SessionTranslationState): SessionEvent {184return makeEvent(state, 'session.idle', {}, true);185}186187/**188* Create a session.shutdown event (emitted when the chat session is disposed).189*/190export function makeShutdownEvent(state: SessionTranslationState): SessionEvent {191return makeEvent(state, 'session.shutdown', {});192}193194// ── Internal helpers ────────────────────────────────────────────────────────────195196function makeEvent(197state: SessionTranslationState,198type: string,199data: Record<string, unknown>,200ephemeral?: boolean,201): SessionEvent {202const id = generateUuid();203const event: SessionEvent = {204id,205timestamp: new Date().toISOString(),206parentId: state.lastEventId,207type,208data,209};210if (ephemeral) {211event.ephemeral = true;212}213state.lastEventId = id;214return event;215}216217function getSessionId(span: ICompletedSpanData): string | undefined {218return (span.attributes[CopilotChatAttr.CHAT_SESSION_ID] as string | undefined)219?? (span.attributes[GenAiAttr.CONVERSATION_ID] as string | undefined)220?? (span.attributes[CopilotChatAttr.SESSION_ID] as string | undefined);221}222223/**224* Extract assistant response text from gen_ai.output.messages attribute.225* Format: [{"role":"assistant","parts":[{"type":"text","content":"..."}]}]226*/227function extractAssistantText(span: ICompletedSpanData): string | undefined {228const raw = span.attributes[GenAiAttr.OUTPUT_MESSAGES] as string | undefined;229if (!raw) {230return undefined;231}232try {233const messages = JSON.parse(raw) as { role: string; parts: { type: string; content: string }[] }[];234const parts = messages235.filter(m => m.role === 'assistant')236.flatMap(m => m.parts)237.filter(p => p.type === 'text')238.map(p => p.content);239return parts.length > 0 ? parts.join('\n') : undefined;240} catch {241return undefined;242}243}244245/**246* Extract tool requests from gen_ai.output.messages (assistant messages with tool_calls).247* CLI format: [{toolCallId, name, arguments, type}]248*/249function extractToolRequests(span: ICompletedSpanData): { toolCallId: string; name: string; arguments: unknown; type: string }[] {250const raw = span.attributes[GenAiAttr.OUTPUT_MESSAGES] as string | undefined;251if (!raw) {252return [];253}254try {255const messages = JSON.parse(raw) as { role: string; parts: { type: string; toolCallId?: string; toolName?: string; args?: unknown }[] }[];256const toolParts = messages257.filter(m => m.role === 'assistant')258.flatMap(m => m.parts)259.filter(p => p.type === 'tool-call' && p.toolCallId && p.toolName);260return toolParts.map(p => ({261toolCallId: p.toolCallId!,262name: p.toolName!,263arguments: p.args ?? {},264type: 'function',265}));266} catch {267return [];268}269}270271272