@townco/agent 0.1.48 → 0.1.50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,13 @@
1
1
  import { MultiServerMCPClient } from "@langchain/mcp-adapters";
2
+ import { context, propagation, trace } from "@opentelemetry/api";
2
3
  import { createLogger } from "@townco/core";
3
4
  import { AIMessageChunk, createAgent, ToolMessage, tool, } from "langchain";
4
5
  import { z } from "zod";
5
6
  import { SUBAGENT_MODE_KEY } from "../../acp-server/adapter";
7
+ import { telemetry } from "../../telemetry/index.js";
6
8
  import { loadCustomToolModule, } from "../tool-loader.js";
7
- import { createModelFromString } from "./model-factory.js";
9
+ import { createModelFromString, detectProvider } from "./model-factory.js";
10
+ import { makeOtelCallbacks } from "./otel-callbacks.js";
8
11
  import { makeFilesystemTools } from "./tools/filesystem";
9
12
  import { TASK_TOOL_NAME } from "./tools/subagent";
10
13
  import { TODO_WRITE_TOOL_NAME, todoWrite } from "./tools/todo";
@@ -51,369 +54,549 @@ async function loadCustomTools(modulePaths) {
51
54
  }
52
55
  export class LangchainAgent {
53
56
  definition;
57
+ toolSpans = new Map();
54
58
  constructor(params) {
55
59
  this.definition = params;
56
60
  }
57
61
  async *invoke(req) {
58
- // Track todo_write tool call IDs to suppress their tool_call notifications
59
- const todoWriteToolCallIds = new Set();
60
- // --------------------------------------------------------------------------
61
- // Resolve tools: built-ins (string) + custom ({ type: "custom", modulePath })
62
- // + filesystem ({ type: "filesystem", working_directory? })
63
- // --------------------------------------------------------------------------
64
- const enabledTools = [];
65
- const toolDefs = this.definition.tools ?? [];
66
- const builtInNames = [];
67
- const customToolPaths = [];
68
- for (const t of toolDefs) {
69
- if (typeof t === "string") {
70
- builtInNames.push(t);
71
- }
72
- else if (t && typeof t === "object" && "type" in t) {
73
- const type = t.type;
74
- if (type === "custom" &&
75
- "modulePath" in t &&
76
- typeof t.modulePath === "string") {
77
- customToolPaths.push(t.modulePath);
78
- }
79
- else if (type === "filesystem") {
80
- const wd = t.working_directory ??
81
- process.cwd();
82
- enabledTools.push(...makeFilesystemTools(wd));
62
+ // Derive the parent OTEL context for this invocation.
63
+ // If this is a subagent and the parent process propagated an OTEL trace
64
+ // context via sessionMeta.otelTraceContext, use that as the parent;
65
+ // otherwise, fall back to the current active context.
66
+ let parentContext = context.active();
67
+ const meta = req.sessionMeta;
68
+ if (meta?.otelTraceContext) {
69
+ parentContext = propagation.extract(parentContext, meta.otelTraceContext);
70
+ }
71
+ // Track turn-level token usage from API responses
72
+ const turnTokenUsage = {
73
+ inputTokens: 0,
74
+ outputTokens: 0,
75
+ totalTokens: 0,
76
+ };
77
+ const countedMessageIds = new Set();
78
+ // Start telemetry span for entire invocation
79
+ const invocationSpan = telemetry.startSpan("agent.invoke", {
80
+ "agent.model": this.definition.model,
81
+ "agent.subagent": meta?.[SUBAGENT_MODE_KEY] === true,
82
+ "agent.session_id": req.sessionId,
83
+ "agent.message_id": req.messageId,
84
+ }, parentContext);
85
+ // Create a context with the invocation span as active
86
+ // This will be used when creating child spans (tool calls)
87
+ const invocationContext = invocationSpan
88
+ ? trace.setSpan(parentContext, invocationSpan)
89
+ : parentContext;
90
+ telemetry.log("info", "Agent invocation started", {
91
+ model: this.definition.model,
92
+ sessionId: req.sessionId,
93
+ messageId: req.messageId,
94
+ });
95
+ try {
96
+ // Track todo_write tool call IDs to suppress their tool_call notifications
97
+ const todoWriteToolCallIds = new Set();
98
+ // --------------------------------------------------------------------------
99
+ // Resolve tools: built-ins (string) + custom ({ type: "custom", modulePath })
100
+ // + filesystem ({ type: "filesystem", working_directory? })
101
+ // --------------------------------------------------------------------------
102
+ const enabledTools = [];
103
+ const toolDefs = this.definition.tools ?? [];
104
+ const builtInNames = [];
105
+ const customToolPaths = [];
106
+ for (const t of toolDefs) {
107
+ if (typeof t === "string") {
108
+ builtInNames.push(t);
83
109
  }
84
- else if (type === "direct") {
85
- // Handle direct tool objects (imported in code)
86
- // biome-ignore lint/suspicious/noExplicitAny: mlai unsure how to best type this
87
- const addedTool = tool(t.fn, {
88
- name: t.name,
89
- description: t.description,
90
- schema: t.schema,
91
- });
92
- addedTool.prettyName = t.prettyName;
93
- addedTool.icon = t.icon;
94
- enabledTools.push(addedTool);
110
+ else if (t && typeof t === "object" && "type" in t) {
111
+ const type = t.type;
112
+ if (type === "custom" &&
113
+ "modulePath" in t &&
114
+ typeof t.modulePath === "string") {
115
+ customToolPaths.push(t.modulePath);
116
+ }
117
+ else if (type === "filesystem") {
118
+ const wd = t.working_directory ??
119
+ process.cwd();
120
+ enabledTools.push(...makeFilesystemTools(wd));
121
+ }
122
+ else if (type === "direct") {
123
+ // Handle direct tool objects (imported in code)
124
+ // biome-ignore lint/suspicious/noExplicitAny: mlai unsure how to best type this
125
+ const addedTool = tool(t.fn, {
126
+ name: t.name,
127
+ description: t.description,
128
+ schema: t.schema,
129
+ });
130
+ addedTool.prettyName = t.prettyName;
131
+ addedTool.icon = t.icon;
132
+ enabledTools.push(addedTool);
133
+ }
95
134
  }
96
135
  }
97
- }
98
- // Built-in tools from registry
99
- for (const name of builtInNames) {
100
- const entry = TOOL_REGISTRY[name];
101
- if (!entry) {
102
- throw new Error(`Unknown built-in tool "${name}"`);
103
- }
104
- if (typeof entry === "function") {
105
- const result = entry();
106
- if (Array.isArray(result)) {
107
- enabledTools.push(...result);
136
+ // Built-in tools from registry
137
+ for (const name of builtInNames) {
138
+ const entry = TOOL_REGISTRY[name];
139
+ if (!entry) {
140
+ throw new Error(`Unknown built-in tool "${name}"`);
141
+ }
142
+ if (typeof entry === "function") {
143
+ const result = entry();
144
+ if (Array.isArray(result)) {
145
+ enabledTools.push(...result);
146
+ }
147
+ else {
148
+ enabledTools.push(result);
149
+ }
108
150
  }
109
151
  else {
110
- enabledTools.push(result);
152
+ enabledTools.push(entry);
111
153
  }
112
154
  }
155
+ // Custom tools loaded from modulePaths
156
+ if (customToolPaths.length > 0) {
157
+ const customTools = await loadCustomTools(customToolPaths);
158
+ enabledTools.push(...customTools);
159
+ }
160
+ // MCP tools
161
+ if ((this.definition.mcps?.length ?? 0) > 0) {
162
+ enabledTools.push(...(await makeMcpToolsClient(this.definition.mcps).getTools()));
163
+ }
164
+ // Filter tools if running in subagent mode
165
+ const isSubagent = req.sessionMeta?.[SUBAGENT_MODE_KEY] === true;
166
+ const finalTools = isSubagent
167
+ ? enabledTools.filter((t) => t.name !== TODO_WRITE_TOOL_NAME && t.name !== TASK_TOOL_NAME)
168
+ : enabledTools;
169
+ // Create the model instance using the factory
170
+ // This detects the provider from the model string:
171
+ // - "gemini-2.0-flash" → Google Generative AI
172
+ // - "vertex-gemini-2.0-flash" → Vertex AI (strips prefix)
173
+ // - "claude-sonnet-4-5-20250929" → Anthropic
174
+ const model = createModelFromString(this.definition.model);
175
+ const agentConfig = {
176
+ model,
177
+ tools: finalTools,
178
+ };
179
+ if (this.definition.systemPrompt) {
180
+ agentConfig.systemPrompt = this.definition.systemPrompt;
181
+ }
182
+ // Inject system prompt with optional TodoWrite instructions
183
+ const hasTodoWrite = builtInNames.includes("todo_write");
184
+ if (hasTodoWrite) {
185
+ agentConfig.systemPrompt = `${agentConfig.systemPrompt ?? ""}\n\n${TODO_WRITE_INSTRUCTIONS}`;
186
+ }
187
+ const agent = createAgent(agentConfig);
188
+ // Add logging callbacks for model requests
189
+ const provider = detectProvider(this.definition.model);
190
+ const loggingCallback = {
191
+ handleChatModelStart: async (_llm, messages, runId, parentRunId, extraParams) => {
192
+ _logger.info("Model request started", {
193
+ provider,
194
+ model: this.definition.model,
195
+ runId,
196
+ parentRunId,
197
+ messageCount: messages.length,
198
+ extraParams,
199
+ });
200
+ },
201
+ handleLLMEnd: async (output, runId, parentRunId, tags, extraParams) => {
202
+ // Extract token usage from output
203
+ const llmResult = output;
204
+ _logger.info("Model request completed", {
205
+ provider,
206
+ model: this.definition.model,
207
+ runId,
208
+ parentRunId,
209
+ tags,
210
+ tokenUsage: llmResult.llmOutput?.tokenUsage,
211
+ generationCount: llmResult.generations?.length,
212
+ extraParams,
213
+ });
214
+ },
215
+ handleLLMError: async (error, runId, parentRunId, tags) => {
216
+ _logger.error("Model request failed", {
217
+ provider,
218
+ model: this.definition.model,
219
+ runId,
220
+ parentRunId,
221
+ tags,
222
+ error: error.message,
223
+ stack: error.stack,
224
+ });
225
+ },
226
+ handleToolStart: async (_tool, input, runId, parentRunId, tags, metadata, runName) => {
227
+ if (process.env.DEBUG_TELEMETRY === "true") {
228
+ console.log(`[handleToolStart] runId=${runId}, runName=${runName}, parentRunId=${parentRunId}`);
229
+ console.log(`[handleToolStart] Active context span:`, trace.getSpan(context.active())?.spanContext());
230
+ }
231
+ _logger.info("Tool started", {
232
+ runId,
233
+ parentRunId,
234
+ runName,
235
+ tags,
236
+ metadata,
237
+ input: input.substring(0, 200), // Truncate for logging
238
+ });
239
+ },
240
+ handleToolEnd: async (_output, runId, parentRunId, tags) => {
241
+ if (process.env.DEBUG_TELEMETRY === "true") {
242
+ console.log(`[handleToolEnd] runId=${runId}, parentRunId=${parentRunId}`);
243
+ }
244
+ _logger.info("Tool completed", {
245
+ runId,
246
+ parentRunId,
247
+ tags,
248
+ });
249
+ },
250
+ handleToolError: async (error, runId, parentRunId, tags) => {
251
+ if (process.env.DEBUG_TELEMETRY === "true") {
252
+ console.log(`[handleToolError] runId=${runId}, error=${error.message}`);
253
+ }
254
+ _logger.error("Tool failed", {
255
+ runId,
256
+ parentRunId,
257
+ tags,
258
+ error: error.message,
259
+ stack: error.stack,
260
+ });
261
+ },
262
+ };
263
+ // Build messages from context history if available, otherwise use just the prompt
264
+ let messages;
265
+ if (req.contextMessages && req.contextMessages.length > 0) {
266
+ // Use context messages (already resolved from context entries)
267
+ // Convert to LangChain format
268
+ messages = req.contextMessages.map((msg) => ({
269
+ type: msg.role === "user" ? "human" : "ai",
270
+ // Extract text from content blocks
271
+ content: msg.content
272
+ .filter((block) => block.type === "text")
273
+ .map((block) => block.text)
274
+ .join(""),
275
+ }));
276
+ // Add the current prompt as the final human message
277
+ const currentPromptText = req.prompt
278
+ .filter((promptMsg) => promptMsg.type === "text")
279
+ .map((promptMsg) => promptMsg.text)
280
+ .join("\n");
281
+ messages.push({
282
+ type: "human",
283
+ content: currentPromptText,
284
+ });
285
+ }
113
286
  else {
114
- enabledTools.push(entry);
287
+ // Fallback: No context history, use just the prompt
288
+ messages = req.prompt
289
+ .filter((promptMsg) => promptMsg.type === "text")
290
+ .map((promptMsg) => ({
291
+ type: "human",
292
+ content: promptMsg.text,
293
+ }));
115
294
  }
116
- }
117
- // Custom tools loaded from modulePaths
118
- if (customToolPaths.length > 0) {
119
- const customTools = await loadCustomTools(customToolPaths);
120
- enabledTools.push(...customTools);
121
- }
122
- // MCP tools
123
- if ((this.definition.mcps?.length ?? 0) > 0) {
124
- enabledTools.push(...(await makeMcpToolsClient(this.definition.mcps).getTools()));
125
- }
126
- // Filter tools if running in subagent mode
127
- const isSubagent = req.sessionMeta?.[SUBAGENT_MODE_KEY] === true;
128
- const finalTools = isSubagent
129
- ? enabledTools.filter((t) => t.name !== TODO_WRITE_TOOL_NAME && t.name !== TASK_TOOL_NAME)
130
- : enabledTools;
131
- // Create the model instance using the factory
132
- // This detects the provider from the model string:
133
- // - "gemini-2.0-flash" → Google Generative AI
134
- // - "vertex-gemini-2.0-flash" → Vertex AI (strips prefix)
135
- // - "claude-sonnet-4-5-20250929" → Anthropic
136
- const model = createModelFromString(this.definition.model);
137
- const agentConfig = {
138
- model,
139
- tools: finalTools,
140
- };
141
- if (this.definition.systemPrompt) {
142
- agentConfig.systemPrompt = this.definition.systemPrompt;
143
- }
144
- // Inject system prompt with optional TodoWrite instructions
145
- const hasTodoWrite = builtInNames.includes("todo_write");
146
- if (hasTodoWrite) {
147
- agentConfig.systemPrompt = `${agentConfig.systemPrompt ?? ""}\n\n${TODO_WRITE_INSTRUCTIONS}`;
148
- }
149
- const agent = createAgent(agentConfig);
150
- // Build messages from context history if available, otherwise use just the prompt
151
- let messages;
152
- if (req.contextMessages && req.contextMessages.length > 0) {
153
- // Use context messages (already resolved from context entries)
154
- // Convert to LangChain format
155
- messages = req.contextMessages.map((msg) => ({
156
- type: msg.role === "user" ? "human" : "ai",
157
- // Extract text from content blocks
158
- content: msg.content
159
- .filter((block) => block.type === "text")
160
- .map((block) => block.text)
161
- .join(""),
162
- }));
163
- // Add the current prompt as the final human message
164
- const currentPromptText = req.prompt
165
- .filter((promptMsg) => promptMsg.type === "text")
166
- .map((promptMsg) => promptMsg.text)
167
- .join("\n");
168
- messages.push({
169
- type: "human",
170
- content: currentPromptText,
295
+ // Create OTEL callbacks for instrumentation
296
+ const otelCallbacks = makeOtelCallbacks({
297
+ provider,
298
+ model: this.definition.model,
299
+ parentContext: invocationContext,
171
300
  });
172
- }
173
- else {
174
- // Fallback: No context history, use just the prompt
175
- messages = req.prompt
176
- .filter((promptMsg) => promptMsg.type === "text")
177
- .map((promptMsg) => ({
178
- type: "human",
179
- content: promptMsg.text,
301
+ // Create the stream within the invocation context so AsyncLocalStorage
302
+ // propagates the context to all tool executions and callbacks
303
+ const stream = context.with(invocationContext, () => agent.stream({ messages }, {
304
+ streamMode: ["updates", "messages"],
305
+ recursionLimit: 200,
306
+ callbacks: [loggingCallback, otelCallbacks],
180
307
  }));
181
- }
182
- const stream = agent.stream({ messages }, {
183
- streamMode: ["updates", "messages"],
184
- recursionLimit: 200,
185
- });
186
- for await (const [streamMode, chunk] of await stream) {
187
- if (streamMode === "updates") {
188
- const updatesChunk = modelRequestSchema.safeParse(chunk);
189
- if (!updatesChunk.success) {
190
- // Other kinds of updates are either handled in the 'messages'
191
- // streamMode (tool calls), or we don't care about them so far (not
192
- // known yet).
193
- continue;
194
- }
195
- const updatesMessages = updatesChunk.data.model_request.messages;
196
- if (!updatesMessages.every((m) => m instanceof AIMessageChunk)) {
197
- throw new Error(`Unhandled updates message chunk types: ${JSON.stringify(updatesMessages)}`);
198
- }
199
- for (const msg of updatesMessages) {
200
- // Extract token usage metadata if available
201
- const tokenUsage = msg.usage_metadata
202
- ? {
203
- inputTokens: msg.usage_metadata.input_tokens,
204
- outputTokens: msg.usage_metadata.output_tokens,
205
- totalTokens: msg.usage_metadata.total_tokens,
308
+ for await (const streamItem of await stream) {
309
+ const [streamMode, chunk] = streamItem;
310
+ if (streamMode === "updates") {
311
+ const updatesChunk = modelRequestSchema.safeParse(chunk);
312
+ if (!updatesChunk.success) {
313
+ // Other kinds of updates are either handled in the 'messages'
314
+ // streamMode (tool calls), or we don't care about them so far (not
315
+ // known yet).
316
+ continue;
317
+ }
318
+ const updatesMessages = updatesChunk.data.model_request.messages;
319
+ if (!updatesMessages.every((m) => m instanceof AIMessageChunk)) {
320
+ throw new Error(`Unhandled updates message chunk types: ${JSON.stringify(updatesMessages)}`);
321
+ }
322
+ for (const msg of updatesMessages) {
323
+ // Extract token usage metadata if available
324
+ const tokenUsage = msg.usage_metadata
325
+ ? {
326
+ inputTokens: msg.usage_metadata.input_tokens,
327
+ outputTokens: msg.usage_metadata.output_tokens,
328
+ totalTokens: msg.usage_metadata.total_tokens,
329
+ }
330
+ : undefined;
331
+ // Record token usage in telemetry
332
+ if (tokenUsage) {
333
+ telemetry.recordTokenUsage(tokenUsage.inputTokens ?? 0, tokenUsage.outputTokens ?? 0, invocationSpan);
334
+ }
335
+ // Accumulate token usage (deduplicate by message ID)
336
+ if (tokenUsage && msg.id && !countedMessageIds.has(msg.id)) {
337
+ turnTokenUsage.inputTokens += tokenUsage.inputTokens ?? 0;
338
+ turnTokenUsage.outputTokens += tokenUsage.outputTokens ?? 0;
339
+ turnTokenUsage.totalTokens += tokenUsage.totalTokens ?? 0;
340
+ countedMessageIds.add(msg.id);
206
341
  }
207
- : undefined;
208
- for (const toolCall of msg.tool_calls ?? []) {
209
- if (toolCall.id == null) {
210
- throw new Error(`Tool call is missing id: ${JSON.stringify(toolCall)}`);
342
+ for (const toolCall of msg.tool_calls ?? []) {
343
+ if (toolCall.id == null) {
344
+ throw new Error(`Tool call is missing id: ${JSON.stringify(toolCall)}`);
345
+ }
346
+ // Create tool span within the invocation context
347
+ // This makes the tool span a child of the invocation span
348
+ const toolSpan = context.with(invocationContext, () => telemetry.startSpan("agent.tool_call", {
349
+ "tool.name": toolCall.name,
350
+ "tool.id": toolCall.id,
351
+ }));
352
+ this.toolSpans.set(toolCall.id, toolSpan);
353
+ telemetry.log("info", `Tool call started: ${toolCall.name}`, {
354
+ toolCallId: toolCall.id,
355
+ toolName: toolCall.name,
356
+ toolArgs: JSON.stringify(toolCall.args),
357
+ });
358
+ // TODO: re-add this suppression of the todo_write tool call when we
359
+ // are rendering the agent-plan update in the UIs
360
+ // If this is a todo_write tool call, yield an agent-plan update
361
+ //if (toolCall.name === "todo_write" && toolCall.args?.todos) {
362
+ // const entries = toolCall.args.todos.flatMap((todo: unknown) => {
363
+ // const validation = todoItemSchema.safeParse(todo);
364
+ // if (!validation.success) {
365
+ // // Invalid todo - filter it out
366
+ // return [];
367
+ // }
368
+ // return [
369
+ // {
370
+ // content: validation.data.content,
371
+ // status: validation.data.status,
372
+ // priority: "medium" as const,
373
+ // },
374
+ // ];
375
+ // });
376
+ // yield {
377
+ // sessionUpdate: "plan",
378
+ // entries: entries,
379
+ // };
380
+ // // Track this tool call ID to suppress tool_call notifications
381
+ // todoWriteToolCallIds.add(toolCall.id);
382
+ // continue;
383
+ //}
384
+ const matchingTool = finalTools.find((t) => t.name === toolCall.name);
385
+ const prettyName = matchingTool?.prettyName;
386
+ const icon = matchingTool?.icon;
387
+ yield {
388
+ sessionUpdate: "tool_call",
389
+ toolCallId: toolCall.id,
390
+ title: toolCall.name,
391
+ kind: "other",
392
+ status: "pending",
393
+ rawInput: toolCall.args,
394
+ ...(tokenUsage ? { tokenUsage } : {}),
395
+ _meta: {
396
+ messageId: req.messageId,
397
+ ...(prettyName ? { prettyName } : {}),
398
+ ...(icon ? { icon } : {}),
399
+ },
400
+ };
401
+ yield {
402
+ sessionUpdate: "tool_call_update",
403
+ toolCallId: toolCall.id,
404
+ status: "in_progress",
405
+ ...(tokenUsage ? { tokenUsage } : {}),
406
+ _meta: { messageId: req.messageId },
407
+ };
211
408
  }
212
- // TODO: re-add this suppression of the todo_write tool call when we
213
- // are rendering the agent-plan update in the UIs
214
- // If this is a todo_write tool call, yield an agent-plan update
215
- //if (toolCall.name === "todo_write" && toolCall.args?.todos) {
216
- // const entries = toolCall.args.todos.flatMap((todo: unknown) => {
217
- // const validation = todoItemSchema.safeParse(todo);
218
- // if (!validation.success) {
219
- // // Invalid todo - filter it out
220
- // return [];
221
- // }
222
- // return [
223
- // {
224
- // content: validation.data.content,
225
- // status: validation.data.status,
226
- // priority: "medium" as const,
227
- // },
228
- // ];
229
- // });
230
- // yield {
231
- // sessionUpdate: "plan",
232
- // entries: entries,
233
- // };
234
- // // Track this tool call ID to suppress tool_call notifications
235
- // todoWriteToolCallIds.add(toolCall.id);
236
- // continue;
237
- //}
238
- const matchingTool = finalTools.find((t) => t.name === toolCall.name);
239
- const prettyName = matchingTool?.prettyName;
240
- const icon = matchingTool?.icon;
241
- yield {
242
- sessionUpdate: "tool_call",
243
- toolCallId: toolCall.id,
244
- title: toolCall.name,
245
- kind: "other",
246
- status: "pending",
247
- rawInput: toolCall.args,
248
- ...(tokenUsage ? { tokenUsage } : {}),
249
- _meta: {
250
- messageId: req.messageId,
251
- ...(prettyName ? { prettyName } : {}),
252
- ...(icon ? { icon } : {}),
253
- },
254
- };
255
- yield {
256
- sessionUpdate: "tool_call_update",
257
- toolCallId: toolCall.id,
258
- status: "in_progress",
259
- ...(tokenUsage ? { tokenUsage } : {}),
260
- _meta: { messageId: req.messageId },
261
- };
262
409
  }
263
410
  }
264
- }
265
- else if (streamMode === "messages") {
266
- const aiMessage = chunk[0];
267
- if (aiMessage instanceof AIMessageChunk) {
268
- // Extract token usage metadata if available
269
- const messageTokenUsage = aiMessage.usage_metadata
270
- ? {
271
- inputTokens: aiMessage.usage_metadata.input_tokens,
272
- outputTokens: aiMessage.usage_metadata.output_tokens,
273
- totalTokens: aiMessage.usage_metadata.total_tokens,
411
+ else if (streamMode === "messages") {
412
+ const aiMessage = chunk[0];
413
+ if (aiMessage instanceof AIMessageChunk) {
414
+ // Extract token usage metadata if available
415
+ const messageTokenUsage = aiMessage.usage_metadata
416
+ ? {
417
+ inputTokens: aiMessage.usage_metadata.input_tokens,
418
+ outputTokens: aiMessage.usage_metadata.output_tokens,
419
+ totalTokens: aiMessage.usage_metadata.total_tokens,
420
+ }
421
+ : undefined;
422
+ // Accumulate token usage (deduplicate by message ID)
423
+ if (messageTokenUsage &&
424
+ aiMessage.id &&
425
+ !countedMessageIds.has(aiMessage.id)) {
426
+ turnTokenUsage.inputTokens += messageTokenUsage.inputTokens ?? 0;
427
+ turnTokenUsage.outputTokens +=
428
+ messageTokenUsage.outputTokens ?? 0;
429
+ turnTokenUsage.totalTokens += messageTokenUsage.totalTokens ?? 0;
430
+ countedMessageIds.add(aiMessage.id);
274
431
  }
275
- : undefined;
276
- if (messageTokenUsage) {
277
- const contentType = typeof aiMessage.content;
278
- const contentIsArray = Array.isArray(aiMessage.content);
279
- const contentLength = contentIsArray
280
- ? aiMessage.content.length
281
- : typeof aiMessage.content === "string"
432
+ if (messageTokenUsage) {
433
+ const contentType = typeof aiMessage.content;
434
+ const contentIsArray = Array.isArray(aiMessage.content);
435
+ const contentLength = contentIsArray
282
436
  ? aiMessage.content.length
283
- : -1;
284
- _logger.debug("messageTokenUsage", {
285
- messageTokenUsage,
286
- contentType,
287
- isArray: contentIsArray,
288
- length: contentLength,
289
- });
290
- }
291
- // If we have tokenUsage but no content, send a token-only chunk
292
- if (messageTokenUsage &&
293
- (typeof aiMessage.content === "string"
294
- ? aiMessage.content === ""
295
- : Array.isArray(aiMessage.content) &&
296
- aiMessage.content.length === 0)) {
297
- _logger.debug("sending token-only chunk", {
298
- messageTokenUsage,
299
- });
300
- const msgToYield = {
301
- sessionUpdate: "agent_message_chunk",
302
- content: {
303
- type: "text",
304
- text: "", // Empty text, just carrying tokenUsage
305
- },
306
- _meta: {
307
- tokenUsage: messageTokenUsage,
308
- },
309
- };
310
- yield msgToYield;
311
- continue; // Skip the rest of the processing for this chunk
312
- }
313
- if (typeof aiMessage.content === "string") {
314
- const msgToYield = messageTokenUsage
315
- ? {
437
+ : typeof aiMessage.content === "string"
438
+ ? aiMessage.content.length
439
+ : -1;
440
+ _logger.debug("messageTokenUsage", {
441
+ messageTokenUsage,
442
+ contentType,
443
+ isArray: contentIsArray,
444
+ length: contentLength,
445
+ });
446
+ }
447
+ // If we have tokenUsage but no content, send a token-only chunk
448
+ if (messageTokenUsage &&
449
+ (typeof aiMessage.content === "string"
450
+ ? aiMessage.content === ""
451
+ : Array.isArray(aiMessage.content) &&
452
+ aiMessage.content.length === 0)) {
453
+ _logger.debug("sending token-only chunk", {
454
+ messageTokenUsage,
455
+ });
456
+ const msgToYield = {
316
457
  sessionUpdate: "agent_message_chunk",
317
458
  content: {
318
459
  type: "text",
319
- text: aiMessage.content,
460
+ text: "", // Empty text, just carrying tokenUsage
320
461
  },
321
462
  _meta: {
322
463
  tokenUsage: messageTokenUsage,
323
464
  },
324
- }
325
- : {
326
- sessionUpdate: "agent_message_chunk",
327
- content: {
328
- type: "text",
329
- text: aiMessage.content,
330
- },
331
465
  };
332
- yield msgToYield;
466
+ yield msgToYield;
467
+ continue; // Skip the rest of the processing for this chunk
468
+ }
469
+ if (typeof aiMessage.content === "string") {
470
+ const msgToYield = messageTokenUsage
471
+ ? {
472
+ sessionUpdate: "agent_message_chunk",
473
+ content: {
474
+ type: "text",
475
+ text: aiMessage.content,
476
+ },
477
+ _meta: {
478
+ tokenUsage: messageTokenUsage,
479
+ },
480
+ }
481
+ : {
482
+ sessionUpdate: "agent_message_chunk",
483
+ content: {
484
+ type: "text",
485
+ text: aiMessage.content,
486
+ },
487
+ };
488
+ yield msgToYield;
489
+ }
490
+ else if (Array.isArray(aiMessage.content)) {
491
+ for (const part of aiMessage.content) {
492
+ if (part.type === "text" && typeof part.text === "string") {
493
+ const msgToYield = messageTokenUsage
494
+ ? {
495
+ sessionUpdate: "agent_message_chunk",
496
+ content: {
497
+ type: "text",
498
+ text: part.text,
499
+ },
500
+ _meta: {
501
+ tokenUsage: messageTokenUsage,
502
+ },
503
+ }
504
+ : {
505
+ sessionUpdate: "agent_message_chunk",
506
+ content: {
507
+ type: "text",
508
+ text: part.text,
509
+ },
510
+ };
511
+ yield msgToYield;
512
+ }
513
+ else if (part.type === "tool_use") {
514
+ // We don't care about tool use chunks -- do nothing
515
+ }
516
+ else if (part.type === "input_json_delta") {
517
+ // We don't care about tool use input delta chunks -- do nothing
518
+ }
519
+ else {
520
+ throw new Error(`Unhandled AIMessageChunk content block type: ${part.type}\n${JSON.stringify(part)}`);
521
+ }
522
+ }
523
+ }
524
+ else {
525
+ throw new Error(`Unhandled AIMessageChunk content type: ${typeof aiMessage.content}`);
526
+ }
333
527
  }
334
- else if (Array.isArray(aiMessage.content)) {
335
- for (const part of aiMessage.content) {
336
- if (part.type === "text" && typeof part.text === "string") {
337
- const msgToYield = messageTokenUsage
338
- ? {
339
- sessionUpdate: "agent_message_chunk",
340
- content: {
341
- type: "text",
342
- text: part.text,
343
- },
344
- _meta: {
345
- tokenUsage: messageTokenUsage,
346
- },
347
- }
348
- : {
349
- sessionUpdate: "agent_message_chunk",
528
+ else if (aiMessage instanceof ToolMessage) {
529
+ if (typeof aiMessage.content === "string") {
530
+ if (todoWriteToolCallIds.has(aiMessage.tool_call_id)) {
531
+ // Skip tool_call_update for todo_write tools
532
+ continue;
533
+ }
534
+ // End telemetry span for this tool call
535
+ const toolSpan = this.toolSpans.get(aiMessage.tool_call_id);
536
+ if (toolSpan) {
537
+ telemetry.log("info", "Tool call completed", {
538
+ toolCallId: aiMessage.tool_call_id,
539
+ });
540
+ telemetry.endSpan(toolSpan);
541
+ this.toolSpans.delete(aiMessage.tool_call_id);
542
+ }
543
+ // Send status update (metadata only, no content)
544
+ yield {
545
+ sessionUpdate: "tool_call_update",
546
+ toolCallId: aiMessage.tool_call_id,
547
+ status: "completed",
548
+ _meta: { messageId: req.messageId },
549
+ };
550
+ // Send tool output separately (via direct SSE, bypassing PostgreSQL NOTIFY)
551
+ yield {
552
+ sessionUpdate: "tool_output",
553
+ toolCallId: aiMessage.tool_call_id,
554
+ content: [
555
+ {
556
+ type: "content",
350
557
  content: {
351
558
  type: "text",
352
- text: part.text,
559
+ text: aiMessage.content,
353
560
  },
354
- };
355
- yield msgToYield;
356
- }
357
- else if (part.type === "tool_use") {
358
- // We don't care about tool use chunks -- do nothing
359
- }
360
- else if (part.type === "input_json_delta") {
361
- // We don't care about tool use input delta chunks -- do nothing
362
- }
363
- else {
364
- throw new Error(`Unhandled AIMessageChunk content block type: ${part.type}\n${JSON.stringify(part)}`);
365
- }
561
+ },
562
+ ],
563
+ rawOutput: { content: aiMessage.content },
564
+ _meta: { messageId: req.messageId },
565
+ };
366
566
  }
367
- }
368
- else {
369
- throw new Error(`Unhandled AIMessageChunk content type: ${typeof aiMessage.content}`);
370
- }
371
- }
372
- else if (aiMessage instanceof ToolMessage) {
373
- if (typeof aiMessage.content === "string") {
374
- if (todoWriteToolCallIds.has(aiMessage.tool_call_id)) {
375
- // Skip tool_call_update for todo_write tools
376
- continue;
567
+ else {
568
+ throw new Error(`Unhandled ToolMessage content type: ${typeof aiMessage.content}`);
377
569
  }
378
- // Send status update (metadata only, no content)
379
- yield {
380
- sessionUpdate: "tool_call_update",
381
- toolCallId: aiMessage.tool_call_id,
382
- status: "completed",
383
- _meta: { messageId: req.messageId },
384
- };
385
- // Send tool output separately (via direct SSE, bypassing PostgreSQL NOTIFY)
386
- yield {
387
- sessionUpdate: "tool_output",
388
- toolCallId: aiMessage.tool_call_id,
389
- content: [
390
- {
391
- type: "content",
392
- content: {
393
- type: "text",
394
- text: aiMessage.content,
395
- },
396
- },
397
- ],
398
- rawOutput: { content: aiMessage.content },
399
- _meta: { messageId: req.messageId },
400
- };
401
570
  }
402
571
  else {
403
- throw new Error(`Unhandled ToolMessage content type: ${typeof aiMessage.content}`);
572
+ throw new Error(`Unhandled message chunk type: ${JSON.stringify(aiMessage)}`);
404
573
  }
405
574
  }
406
575
  else {
407
- throw new Error(`Unhandled message chunk type: ${JSON.stringify(aiMessage)}`);
576
+ throw new Error(`Unhandled stream mode: ${streamMode}`);
408
577
  }
409
578
  }
410
- else {
411
- throw new Error(`Unhandled stream mode: ${streamMode}`);
412
- }
579
+ // Log successful completion
580
+ telemetry.log("info", "Agent invocation completed", {
581
+ sessionId: req.sessionId,
582
+ });
583
+ telemetry.endSpan(invocationSpan);
584
+ return {
585
+ stopReason: "end_turn",
586
+ _meta: {
587
+ tokenUsage: turnTokenUsage,
588
+ },
589
+ };
590
+ }
591
+ catch (error) {
592
+ // Log error and end span with error status
593
+ telemetry.log("error", "Agent invocation failed", {
594
+ error: error instanceof Error ? error.message : String(error),
595
+ sessionId: req.sessionId,
596
+ });
597
+ telemetry.endSpan(invocationSpan, error instanceof Error ? error : new Error(String(error)));
598
+ throw error;
413
599
  }
414
- return {
415
- stopReason: "end_turn",
416
- };
417
600
  }
418
601
  }
419
602
  const modelRequestSchema = z.object({