@townco/agent 0.1.49 → 0.1.51

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.
Files changed (43) hide show
  1. package/dist/acp-server/adapter.d.ts +15 -0
  2. package/dist/acp-server/adapter.js +445 -67
  3. package/dist/acp-server/http.js +8 -1
  4. package/dist/acp-server/session-storage.d.ts +19 -0
  5. package/dist/acp-server/session-storage.js +9 -0
  6. package/dist/definition/index.d.ts +16 -4
  7. package/dist/definition/index.js +17 -4
  8. package/dist/index.d.ts +2 -1
  9. package/dist/index.js +10 -1
  10. package/dist/runner/agent-runner.d.ts +13 -2
  11. package/dist/runner/agent-runner.js +4 -0
  12. package/dist/runner/hooks/executor.d.ts +18 -1
  13. package/dist/runner/hooks/executor.js +74 -62
  14. package/dist/runner/hooks/predefined/compaction-tool.js +19 -3
  15. package/dist/runner/hooks/predefined/tool-response-compactor.d.ts +6 -0
  16. package/dist/runner/hooks/predefined/tool-response-compactor.js +461 -0
  17. package/dist/runner/hooks/registry.js +2 -0
  18. package/dist/runner/hooks/types.d.ts +39 -3
  19. package/dist/runner/hooks/types.js +9 -1
  20. package/dist/runner/langchain/index.d.ts +1 -0
  21. package/dist/runner/langchain/index.js +523 -321
  22. package/dist/runner/langchain/model-factory.js +1 -1
  23. package/dist/runner/langchain/otel-callbacks.d.ts +18 -0
  24. package/dist/runner/langchain/otel-callbacks.js +123 -0
  25. package/dist/runner/langchain/tools/subagent.js +21 -1
  26. package/dist/scaffold/link-local.d.ts +1 -0
  27. package/dist/scaffold/link-local.js +54 -0
  28. package/dist/scaffold/project-scaffold.js +1 -0
  29. package/dist/telemetry/index.d.ts +83 -0
  30. package/dist/telemetry/index.js +172 -0
  31. package/dist/telemetry/setup.d.ts +22 -0
  32. package/dist/telemetry/setup.js +141 -0
  33. package/dist/templates/index.d.ts +7 -0
  34. package/dist/tsconfig.tsbuildinfo +1 -1
  35. package/dist/utils/context-size-calculator.d.ts +29 -0
  36. package/dist/utils/context-size-calculator.js +78 -0
  37. package/dist/utils/index.d.ts +2 -0
  38. package/dist/utils/index.js +2 -0
  39. package/dist/utils/token-counter.d.ts +19 -0
  40. package/dist/utils/token-counter.js +44 -0
  41. package/index.ts +16 -1
  42. package/package.json +24 -7
  43. package/templates/index.ts +18 -6
@@ -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,568 @@ 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
+ // Wrap tools with response compaction if hook is configured
165
+ const hooks = this.definition.hooks ?? [];
166
+ const hasToolResponseHook = hooks.some((h) => h.type === "tool_response");
167
+ const noSession = req.sessionMeta?.[SUBAGENT_MODE_KEY] === true; // Subagents don't have session storage
168
+ // Track cumulative tool output tokens in this turn for proper context calculation
169
+ let cumulativeToolOutputTokens = 0;
170
+ let wrappedTools = enabledTools;
171
+ if (hasToolResponseHook && !noSession) {
172
+ const { countToolResultTokens } = await import("../../utils/token-counter.js");
173
+ const { toolResponseCompactor } = await import("../hooks/predefined/tool-response-compactor.js");
174
+ wrappedTools = enabledTools.map((originalTool) => {
175
+ const wrappedFunc = async (input) => {
176
+ // Execute the original tool
177
+ const result = await originalTool.invoke(input);
178
+ // Check if result should be compacted
179
+ const resultStr = typeof result === "string" ? result : JSON.stringify(result);
180
+ const rawOutput = { content: resultStr };
181
+ const outputTokens = countToolResultTokens(rawOutput);
182
+ // Skip compaction for small results (under 10k tokens)
183
+ if (outputTokens < 10000) {
184
+ // Still track this in cumulative total
185
+ cumulativeToolOutputTokens += outputTokens;
186
+ return result;
187
+ }
188
+ _logger.info("Tool wrapper: compacting large tool result", {
189
+ toolName: originalTool.name,
190
+ originalTokens: outputTokens,
191
+ cumulativeToolOutputTokens,
192
+ });
193
+ // Calculate current context including all tool outputs so far in this turn
194
+ // This ensures we account for multiple large tool calls in the same turn
195
+ const baseContextTokens = turnTokenUsage.inputTokens || 10000;
196
+ const currentTokens = baseContextTokens + cumulativeToolOutputTokens;
197
+ const maxTokens = 200000; // Claude's limit
198
+ // Build proper hook context with all required fields
199
+ const hookContext = {
200
+ session: {
201
+ messages: req.contextMessages || [],
202
+ context: [],
203
+ requestParams: {
204
+ hookSettings: hooks.find((h) => h.type === "tool_response")
205
+ ?.setting,
206
+ },
207
+ },
208
+ currentTokens,
209
+ maxTokens,
210
+ percentage: (currentTokens / maxTokens) * 100,
211
+ model: this.definition.model,
212
+ toolResponse: {
213
+ toolCallId: "pending",
214
+ toolName: originalTool.name,
215
+ toolInput: input,
216
+ rawOutput,
217
+ outputTokens,
218
+ },
219
+ };
220
+ // Call the tool response compactor directly
221
+ const hookResult = await toolResponseCompactor(hookContext);
222
+ // Extract modified output from metadata
223
+ if (hookResult?.metadata?.modifiedOutput) {
224
+ const modifiedOutput = hookResult.metadata
225
+ .modifiedOutput;
226
+ const compactedTokens = countToolResultTokens(modifiedOutput);
227
+ // Update cumulative total with the compacted size (not original!)
228
+ cumulativeToolOutputTokens += compactedTokens;
229
+ _logger.info("Tool wrapper: compaction complete", {
230
+ toolName: originalTool.name,
231
+ originalTokens: outputTokens,
232
+ compactedTokens,
233
+ reduction: `${((1 - compactedTokens / outputTokens) * 100).toFixed(1)}%`,
234
+ totalCumulativeTokens: cumulativeToolOutputTokens,
235
+ });
236
+ return typeof result === "string"
237
+ ? modifiedOutput.content
238
+ : JSON.stringify(modifiedOutput);
239
+ }
240
+ // No compaction happened, count original size
241
+ cumulativeToolOutputTokens += outputTokens;
242
+ return result;
243
+ };
244
+ // Create new tool with wrapped function
245
+ const wrappedTool = tool(wrappedFunc, {
246
+ name: originalTool.name,
247
+ description: originalTool.description,
248
+ schema: originalTool.schema,
249
+ });
250
+ // Preserve metadata
251
+ wrappedTool.prettyName = originalTool.prettyName;
252
+ wrappedTool.icon = originalTool.icon;
253
+ return wrappedTool;
254
+ });
255
+ }
256
+ // Filter tools if running in subagent mode
257
+ const isSubagent = req.sessionMeta?.[SUBAGENT_MODE_KEY] === true;
258
+ const finalTools = isSubagent
259
+ ? wrappedTools.filter((t) => t.name !== TODO_WRITE_TOOL_NAME && t.name !== TASK_TOOL_NAME)
260
+ : wrappedTools;
261
+ // Create the model instance using the factory
262
+ // This detects the provider from the model string:
263
+ // - "gemini-2.0-flash" → Google Generative AI
264
+ // - "vertex-gemini-2.0-flash" → Vertex AI (strips prefix)
265
+ // - "claude-sonnet-4-5-20250929" → Anthropic
266
+ const model = createModelFromString(this.definition.model);
267
+ const agentConfig = {
268
+ model,
269
+ tools: finalTools,
270
+ };
271
+ if (this.definition.systemPrompt) {
272
+ agentConfig.systemPrompt = this.definition.systemPrompt;
273
+ }
274
+ // Inject system prompt with optional TodoWrite instructions
275
+ const hasTodoWrite = builtInNames.includes("todo_write");
276
+ if (hasTodoWrite) {
277
+ agentConfig.systemPrompt = `${agentConfig.systemPrompt ?? ""}\n\n${TODO_WRITE_INSTRUCTIONS}`;
278
+ }
279
+ const agent = createAgent(agentConfig);
280
+ // Add logging callbacks for model requests
281
+ const provider = detectProvider(this.definition.model);
282
+ // Build messages from context history if available, otherwise use just the prompt
283
+ let messages;
284
+ if (req.contextMessages && req.contextMessages.length > 0) {
285
+ // Use context messages (already resolved from context entries)
286
+ // Convert to LangChain format
287
+ messages = req.contextMessages.map((msg) => ({
288
+ type: msg.role === "user" ? "human" : "ai",
289
+ // Extract text from content blocks
290
+ content: msg.content
291
+ .filter((block) => block.type === "text")
292
+ .map((block) => block.text)
293
+ .join(""),
294
+ }));
295
+ // Add the current prompt as the final human message
296
+ const currentPromptText = req.prompt
297
+ .filter((promptMsg) => promptMsg.type === "text")
298
+ .map((promptMsg) => promptMsg.text)
299
+ .join("\n");
300
+ messages.push({
301
+ type: "human",
302
+ content: currentPromptText,
303
+ });
304
+ }
113
305
  else {
114
- enabledTools.push(entry);
306
+ // Fallback: No context history, use just the prompt
307
+ messages = req.prompt
308
+ .filter((promptMsg) => promptMsg.type === "text")
309
+ .map((promptMsg) => ({
310
+ type: "human",
311
+ content: promptMsg.text,
312
+ }));
115
313
  }
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,
314
+ // Create OTEL callbacks for instrumentation
315
+ const otelCallbacks = makeOtelCallbacks({
316
+ provider,
317
+ model: this.definition.model,
318
+ parentContext: invocationContext,
171
319
  });
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,
320
+ // Create the stream within the invocation context so AsyncLocalStorage
321
+ // propagates the context to all tool executions and callbacks
322
+ const stream = context.with(invocationContext, () => agent.stream({ messages }, {
323
+ streamMode: ["updates", "messages"],
324
+ recursionLimit: 200,
325
+ callbacks: [otelCallbacks],
180
326
  }));
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,
327
+ for await (const streamItem of await stream) {
328
+ const [streamMode, chunk] = streamItem;
329
+ if (streamMode === "updates") {
330
+ const updatesChunk = modelRequestSchema.safeParse(chunk);
331
+ if (!updatesChunk.success) {
332
+ // Other kinds of updates are either handled in the 'messages'
333
+ // streamMode (tool calls), or we don't care about them so far (not
334
+ // known yet).
335
+ continue;
336
+ }
337
+ const updatesMessages = updatesChunk.data.model_request.messages;
338
+ if (!updatesMessages.every((m) => m instanceof AIMessageChunk)) {
339
+ throw new Error(`Unhandled updates message chunk types: ${JSON.stringify(updatesMessages)}`);
340
+ }
341
+ for (const msg of updatesMessages) {
342
+ // Extract token usage metadata if available
343
+ const tokenUsage = msg.usage_metadata
344
+ ? {
345
+ inputTokens: msg.usage_metadata.input_tokens,
346
+ outputTokens: msg.usage_metadata.output_tokens,
347
+ totalTokens: msg.usage_metadata.total_tokens,
348
+ }
349
+ : undefined;
350
+ // Record token usage in telemetry
351
+ if (tokenUsage) {
352
+ telemetry.recordTokenUsage(tokenUsage.inputTokens ?? 0, tokenUsage.outputTokens ?? 0, invocationSpan);
206
353
  }
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)}`);
354
+ // Accumulate token usage (deduplicate by message ID)
355
+ if (tokenUsage && msg.id && !countedMessageIds.has(msg.id)) {
356
+ turnTokenUsage.inputTokens += tokenUsage.inputTokens ?? 0;
357
+ turnTokenUsage.outputTokens += tokenUsage.outputTokens ?? 0;
358
+ turnTokenUsage.totalTokens += tokenUsage.totalTokens ?? 0;
359
+ countedMessageIds.add(msg.id);
360
+ }
361
+ for (const toolCall of msg.tool_calls ?? []) {
362
+ if (toolCall.id == null) {
363
+ throw new Error(`Tool call is missing id: ${JSON.stringify(toolCall)}`);
364
+ }
365
+ // Create tool span within the invocation context
366
+ // This makes the tool span a child of the invocation span
367
+ const toolSpan = context.with(invocationContext, () => telemetry.startSpan("agent.tool_call", {
368
+ "tool.name": toolCall.name,
369
+ "tool.id": toolCall.id,
370
+ }));
371
+ this.toolSpans.set(toolCall.id, toolSpan);
372
+ telemetry.log("info", `Tool call started: ${toolCall.name}`, {
373
+ toolCallId: toolCall.id,
374
+ toolName: toolCall.name,
375
+ toolArgs: JSON.stringify(toolCall.args),
376
+ });
377
+ // TODO: re-add this suppression of the todo_write tool call when we
378
+ // are rendering the agent-plan update in the UIs
379
+ // If this is a todo_write tool call, yield an agent-plan update
380
+ //if (toolCall.name === "todo_write" && toolCall.args?.todos) {
381
+ // const entries = toolCall.args.todos.flatMap((todo: unknown) => {
382
+ // const validation = todoItemSchema.safeParse(todo);
383
+ // if (!validation.success) {
384
+ // // Invalid todo - filter it out
385
+ // return [];
386
+ // }
387
+ // return [
388
+ // {
389
+ // content: validation.data.content,
390
+ // status: validation.data.status,
391
+ // priority: "medium" as const,
392
+ // },
393
+ // ];
394
+ // });
395
+ // yield {
396
+ // sessionUpdate: "plan",
397
+ // entries: entries,
398
+ // };
399
+ // // Track this tool call ID to suppress tool_call notifications
400
+ // todoWriteToolCallIds.add(toolCall.id);
401
+ // continue;
402
+ //}
403
+ const matchingTool = finalTools.find((t) => t.name === toolCall.name);
404
+ const prettyName = matchingTool?.prettyName;
405
+ const icon = matchingTool?.icon;
406
+ yield {
407
+ sessionUpdate: "tool_call",
408
+ toolCallId: toolCall.id,
409
+ title: toolCall.name,
410
+ kind: "other",
411
+ status: "pending",
412
+ rawInput: toolCall.args,
413
+ ...(tokenUsage ? { tokenUsage } : {}),
414
+ _meta: {
415
+ messageId: req.messageId,
416
+ ...(prettyName ? { prettyName } : {}),
417
+ ...(icon ? { icon } : {}),
418
+ },
419
+ };
420
+ yield {
421
+ sessionUpdate: "tool_call_update",
422
+ toolCallId: toolCall.id,
423
+ status: "in_progress",
424
+ ...(tokenUsage ? { tokenUsage } : {}),
425
+ _meta: { messageId: req.messageId },
426
+ };
211
427
  }
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
428
  }
263
429
  }
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,
430
+ else if (streamMode === "messages") {
431
+ const aiMessage = chunk[0];
432
+ if (aiMessage instanceof AIMessageChunk) {
433
+ // Extract token usage metadata if available
434
+ const messageTokenUsage = aiMessage.usage_metadata
435
+ ? {
436
+ inputTokens: aiMessage.usage_metadata.input_tokens,
437
+ outputTokens: aiMessage.usage_metadata.output_tokens,
438
+ totalTokens: aiMessage.usage_metadata.total_tokens,
439
+ }
440
+ : undefined;
441
+ // Accumulate token usage (deduplicate by message ID)
442
+ if (messageTokenUsage &&
443
+ aiMessage.id &&
444
+ !countedMessageIds.has(aiMessage.id)) {
445
+ turnTokenUsage.inputTokens += messageTokenUsage.inputTokens ?? 0;
446
+ turnTokenUsage.outputTokens +=
447
+ messageTokenUsage.outputTokens ?? 0;
448
+ turnTokenUsage.totalTokens += messageTokenUsage.totalTokens ?? 0;
449
+ countedMessageIds.add(aiMessage.id);
274
450
  }
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"
451
+ if (messageTokenUsage) {
452
+ const contentType = typeof aiMessage.content;
453
+ const contentIsArray = Array.isArray(aiMessage.content);
454
+ const contentLength = contentIsArray
282
455
  ? 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
- ? {
456
+ : typeof aiMessage.content === "string"
457
+ ? aiMessage.content.length
458
+ : -1;
459
+ _logger.debug("messageTokenUsage", {
460
+ messageTokenUsage,
461
+ contentType,
462
+ isArray: contentIsArray,
463
+ length: contentLength,
464
+ });
465
+ }
466
+ // If we have tokenUsage but no content, send a token-only chunk
467
+ if (messageTokenUsage &&
468
+ (typeof aiMessage.content === "string"
469
+ ? aiMessage.content === ""
470
+ : Array.isArray(aiMessage.content) &&
471
+ aiMessage.content.length === 0)) {
472
+ _logger.debug("sending token-only chunk", {
473
+ messageTokenUsage,
474
+ });
475
+ const msgToYield = {
316
476
  sessionUpdate: "agent_message_chunk",
317
477
  content: {
318
478
  type: "text",
319
- text: aiMessage.content,
479
+ text: "", // Empty text, just carrying tokenUsage
320
480
  },
321
481
  _meta: {
322
482
  tokenUsage: messageTokenUsage,
323
483
  },
324
- }
325
- : {
326
- sessionUpdate: "agent_message_chunk",
327
- content: {
328
- type: "text",
329
- text: aiMessage.content,
330
- },
331
484
  };
332
- yield msgToYield;
485
+ yield msgToYield;
486
+ continue; // Skip the rest of the processing for this chunk
487
+ }
488
+ if (typeof aiMessage.content === "string") {
489
+ const msgToYield = messageTokenUsage
490
+ ? {
491
+ sessionUpdate: "agent_message_chunk",
492
+ content: {
493
+ type: "text",
494
+ text: aiMessage.content,
495
+ },
496
+ _meta: {
497
+ tokenUsage: messageTokenUsage,
498
+ },
499
+ }
500
+ : {
501
+ sessionUpdate: "agent_message_chunk",
502
+ content: {
503
+ type: "text",
504
+ text: aiMessage.content,
505
+ },
506
+ };
507
+ yield msgToYield;
508
+ }
509
+ else if (Array.isArray(aiMessage.content)) {
510
+ for (const part of aiMessage.content) {
511
+ if (part.type === "text" && typeof part.text === "string") {
512
+ const msgToYield = messageTokenUsage
513
+ ? {
514
+ sessionUpdate: "agent_message_chunk",
515
+ content: {
516
+ type: "text",
517
+ text: part.text,
518
+ },
519
+ _meta: {
520
+ tokenUsage: messageTokenUsage,
521
+ },
522
+ }
523
+ : {
524
+ sessionUpdate: "agent_message_chunk",
525
+ content: {
526
+ type: "text",
527
+ text: part.text,
528
+ },
529
+ };
530
+ yield msgToYield;
531
+ }
532
+ else if (part.type === "tool_use") {
533
+ // We don't care about tool use chunks -- do nothing
534
+ }
535
+ else if (part.type === "input_json_delta") {
536
+ // We don't care about tool use input delta chunks -- do nothing
537
+ }
538
+ else {
539
+ throw new Error(`Unhandled AIMessageChunk content block type: ${part.type}\n${JSON.stringify(part)}`);
540
+ }
541
+ }
542
+ }
543
+ else {
544
+ throw new Error(`Unhandled AIMessageChunk content type: ${typeof aiMessage.content}`);
545
+ }
333
546
  }
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",
547
+ else if (aiMessage instanceof ToolMessage) {
548
+ if (typeof aiMessage.content === "string") {
549
+ if (todoWriteToolCallIds.has(aiMessage.tool_call_id)) {
550
+ // Skip tool_call_update for todo_write tools
551
+ continue;
552
+ }
553
+ // End telemetry span for this tool call
554
+ const toolSpan = this.toolSpans.get(aiMessage.tool_call_id);
555
+ if (toolSpan) {
556
+ telemetry.log("info", "Tool call completed", {
557
+ toolCallId: aiMessage.tool_call_id,
558
+ });
559
+ telemetry.endSpan(toolSpan);
560
+ this.toolSpans.delete(aiMessage.tool_call_id);
561
+ }
562
+ // Send status update (metadata only, no content)
563
+ yield {
564
+ sessionUpdate: "tool_call_update",
565
+ toolCallId: aiMessage.tool_call_id,
566
+ status: "completed",
567
+ _meta: { messageId: req.messageId },
568
+ };
569
+ // Send tool output separately (via direct SSE, bypassing PostgreSQL NOTIFY)
570
+ yield {
571
+ sessionUpdate: "tool_output",
572
+ toolCallId: aiMessage.tool_call_id,
573
+ content: [
574
+ {
575
+ type: "content",
350
576
  content: {
351
577
  type: "text",
352
- text: part.text,
578
+ text: aiMessage.content,
353
579
  },
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
- }
580
+ },
581
+ ],
582
+ rawOutput: { content: aiMessage.content },
583
+ _meta: { messageId: req.messageId },
584
+ };
366
585
  }
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;
586
+ else {
587
+ throw new Error(`Unhandled ToolMessage content type: ${typeof aiMessage.content}`);
377
588
  }
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
589
  }
402
590
  else {
403
- throw new Error(`Unhandled ToolMessage content type: ${typeof aiMessage.content}`);
591
+ throw new Error(`Unhandled message chunk type: ${JSON.stringify(aiMessage)}`);
404
592
  }
405
593
  }
406
594
  else {
407
- throw new Error(`Unhandled message chunk type: ${JSON.stringify(aiMessage)}`);
595
+ throw new Error(`Unhandled stream mode: ${streamMode}`);
408
596
  }
409
597
  }
410
- else {
411
- throw new Error(`Unhandled stream mode: ${streamMode}`);
412
- }
598
+ // Log successful completion
599
+ telemetry.log("info", "Agent invocation completed", {
600
+ sessionId: req.sessionId,
601
+ });
602
+ telemetry.endSpan(invocationSpan);
603
+ return {
604
+ stopReason: "end_turn",
605
+ _meta: {
606
+ tokenUsage: turnTokenUsage,
607
+ },
608
+ };
609
+ }
610
+ catch (error) {
611
+ // Log error and end span with error status
612
+ telemetry.log("error", "Agent invocation failed", {
613
+ error: error instanceof Error ? error.message : String(error),
614
+ sessionId: req.sessionId,
615
+ });
616
+ telemetry.endSpan(invocationSpan, error instanceof Error ? error : new Error(String(error)));
617
+ throw error;
413
618
  }
414
- return {
415
- stopReason: "end_turn",
416
- };
417
619
  }
418
620
  }
419
621
  const modelRequestSchema = z.object({