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