@townco/agent 0.1.72 → 0.1.74

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,6 +1,6 @@
1
1
  import { MultiServerMCPClient } from "@langchain/mcp-adapters";
2
2
  import { context, propagation, trace } from "@opentelemetry/api";
3
- import { loadAuthCredentials } from "@townco/core/auth";
3
+ import { getShedAuth } from "@townco/core/auth";
4
4
  import { AIMessageChunk, createAgent, ToolMessage, tool, } from "langchain";
5
5
  import { z } from "zod";
6
6
  import { SUBAGENT_MODE_KEY } from "../../acp-server/adapter";
@@ -15,7 +15,7 @@ import { makeGenerateImageTool } from "./tools/generate_image";
15
15
  import { SUBAGENT_TOOL_NAME } from "./tools/subagent";
16
16
  import { hashQuery, queryToToolCallId, subagentEvents, } from "./tools/subagent-connections";
17
17
  import { TODO_WRITE_TOOL_NAME, todoWrite } from "./tools/todo";
18
- import { makeWebSearchTools } from "./tools/web_search";
18
+ import { makeTownWebSearchTools, makeWebSearchTools } from "./tools/web_search";
19
19
  const _logger = createLogger("agent-runner");
20
20
  const getWeather = tool(({ city }) => `It's always sunny in ${city}!`, {
21
21
  name: "get_weather",
@@ -30,6 +30,7 @@ export const TOOL_REGISTRY = {
30
30
  todo_write: todoWrite,
31
31
  get_weather: getWeather,
32
32
  web_search: () => makeWebSearchTools(),
33
+ town_web_search: () => makeTownWebSearchTools(),
33
34
  filesystem: () => makeFilesystemTools(process.cwd()),
34
35
  generate_image: () => makeGenerateImageTool(),
35
36
  browser: () => makeBrowserTools(),
@@ -96,12 +97,9 @@ export class LangchainAgent {
96
97
  // Clear the buffer after flushing
97
98
  pendingToolCallNotifications.length = 0;
98
99
  }
99
- // Set session_id as a base attribute so all spans in this invocation include it
100
- telemetry.setBaseAttributes({
101
- "agent.session_id": req.sessionId,
102
- });
103
100
  const subagentUpdateQueue = [];
104
101
  let subagentUpdateResolver = null;
102
+ const subagentMessagesQueue = [];
105
103
  // Listen for subagent connection events - resolve any waiting promise immediately
106
104
  const onSubagentConnection = (event) => {
107
105
  _logger.info("Received subagent connection event", {
@@ -121,6 +119,15 @@ export class LangchainAgent {
121
119
  }
122
120
  };
123
121
  subagentEvents.on("connection", onSubagentConnection);
122
+ // Listen for subagent messages events (for session storage)
123
+ const onSubagentMessages = (event) => {
124
+ _logger.info("Received subagent messages event", {
125
+ toolCallId: event.toolCallId,
126
+ messageCount: event.messages.length,
127
+ });
128
+ subagentMessagesQueue.push(event);
129
+ };
130
+ subagentEvents.on("messages", onSubagentMessages);
124
131
  // Helper to get next subagent update (returns immediately if queued, otherwise waits)
125
132
  const waitForSubagentUpdate = () => {
126
133
  if (subagentUpdateQueue.length > 0) {
@@ -149,12 +156,34 @@ export class LangchainAgent {
149
156
  },
150
157
  };
151
158
  }
159
+ // Also yield any pending messages updates
160
+ while (subagentMessagesQueue.length > 0) {
161
+ const messagesUpdate = subagentMessagesQueue.shift();
162
+ _logger.info("Yielding queued subagent messages update", {
163
+ toolCallId: messagesUpdate.toolCallId,
164
+ messageCount: messagesUpdate.messages.length,
165
+ });
166
+ yield {
167
+ sessionUpdate: "tool_call_update",
168
+ toolCallId: messagesUpdate.toolCallId,
169
+ _meta: {
170
+ messageId: req.messageId,
171
+ subagentMessages: messagesUpdate.messages,
172
+ },
173
+ };
174
+ }
152
175
  }
176
+ // Add agent.session_id as a base attribute so it propagates to all child spans
177
+ // We'll clear this in a finally block to prevent cross-contamination
178
+ telemetry.setBaseAttributes({
179
+ "agent.session_id": req.sessionId,
180
+ });
153
181
  // Start telemetry span for entire invocation
154
182
  const invocationSpan = telemetry.startSpan("agent.invoke", {
155
183
  "agent.model": this.definition.model,
156
184
  "agent.subagent": meta?.[SUBAGENT_MODE_KEY] === true,
157
185
  "agent.message_id": req.messageId,
186
+ "agent.session_id": req.sessionId,
158
187
  }, parentContext);
159
188
  // Create a context with the invocation span as active
160
189
  // This will be used when creating child spans (tool calls)
@@ -166,7 +195,29 @@ export class LangchainAgent {
166
195
  sessionId: req.sessionId,
167
196
  messageId: req.messageId,
168
197
  });
198
+ // Yield the invocation span to the adapter so it can use it for parenting hook spans
199
+ if (invocationSpan) {
200
+ yield {
201
+ sessionUpdate: "__invocation_span",
202
+ invocationSpan,
203
+ };
204
+ }
205
+ // Declare otelCallbacks outside try block so it's accessible in catch
206
+ let otelCallbacks = null;
169
207
  try {
208
+ // Determine effective model early so we can detect provider for callbacks
209
+ // Use override model if provided (Town Hall comparison feature)
210
+ const effectiveModel = req.configOverrides?.model ?? this.definition.model;
211
+ const provider = detectProvider(effectiveModel);
212
+ // Create OTEL callbacks for instrumentation early so we can use them during tool wrapping
213
+ // Track iteration index across LLM calls in this invocation
214
+ const iterationIndexRef = { current: 0 };
215
+ otelCallbacks = makeOtelCallbacks({
216
+ provider,
217
+ model: effectiveModel,
218
+ parentContext: invocationContext,
219
+ iterationIndexRef,
220
+ });
170
221
  // Track todo_write tool call IDs to suppress their tool_call notifications
171
222
  const todoWriteToolCallIds = new Set();
172
223
  // --------------------------------------------------------------------------
@@ -365,19 +416,34 @@ export class LangchainAgent {
365
416
  : wrappedTools;
366
417
  // Wrap tools with tracing so each tool executes within its own span context.
367
418
  // This ensures subagent spans are children of the Task tool span.
368
- const finalTools = filteredTools.map((t) => wrapToolWithTracing(t));
419
+ // Pass the context getter so tools can nest under the current iteration span.
420
+ let finalTools = filteredTools.map((t) => wrapToolWithTracing(t, otelCallbacks?.getCurrentIterationContext ??
421
+ (() => invocationContext)));
422
+ // Apply tool overrides if provided (Town Hall comparison feature)
423
+ if (req.configOverrides?.tools && req.configOverrides.tools.length > 0) {
424
+ const allowedToolNames = new Set(req.configOverrides.tools);
425
+ finalTools = finalTools.filter((t) => allowedToolNames.has(t.name));
426
+ _logger.debug("Applied tool override filter", {
427
+ requested: req.configOverrides.tools,
428
+ filtered: finalTools.map((t) => t.name),
429
+ });
430
+ }
369
431
  // Create the model instance using the factory
370
432
  // This detects the provider from the model string:
371
433
  // - "gemini-2.0-flash" → Google Generative AI
372
434
  // - "vertex-gemini-2.0-flash" → Vertex AI (strips prefix)
373
435
  // - "claude-sonnet-4-5-20250929" → Anthropic
374
- const model = createModelFromString(this.definition.model);
436
+ const model = createModelFromString(effectiveModel);
375
437
  const agentConfig = {
376
438
  model,
377
439
  tools: finalTools,
378
440
  };
379
- if (this.definition.systemPrompt) {
380
- agentConfig.systemPrompt = this.definition.systemPrompt;
441
+ // Use override system prompt if provided (Town Hall comparison feature)
442
+ const effectiveSystemPrompt = req.configOverrides?.systemPrompt !== undefined
443
+ ? req.configOverrides.systemPrompt
444
+ : this.definition.systemPrompt;
445
+ if (effectiveSystemPrompt) {
446
+ agentConfig.systemPrompt = effectiveSystemPrompt;
381
447
  }
382
448
  // Inject system prompt with optional TodoWrite instructions
383
449
  const hasTodoWrite = builtInNames.includes("todo_write");
@@ -385,8 +451,6 @@ export class LangchainAgent {
385
451
  agentConfig.systemPrompt = `${agentConfig.systemPrompt ?? ""}\n\n${TODO_WRITE_INSTRUCTIONS}`;
386
452
  }
387
453
  const agent = createAgent(agentConfig);
388
- // Add logging callbacks for model requests
389
- const provider = detectProvider(this.definition.model);
390
454
  // Build messages from context history if available, otherwise use just the prompt
391
455
  let messages;
392
456
  // Helper to convert content blocks to LangChain format
@@ -477,12 +541,6 @@ export class LangchainAgent {
477
541
  },
478
542
  ];
479
543
  }
480
- // Create OTEL callbacks for instrumentation
481
- const otelCallbacks = makeOtelCallbacks({
482
- provider,
483
- model: this.definition.model,
484
- parentContext: invocationContext,
485
- });
486
544
  // Create the stream within the invocation context so AsyncLocalStorage
487
545
  // propagates the context to all tool executions and callbacks
488
546
  const stream = context.with(invocationContext, () => agent.stream({ messages }, {
@@ -613,6 +671,7 @@ export class LangchainAgent {
613
671
  const matchingTool = finalTools.find((t) => t.name === toolCall.name);
614
672
  let prettyName = matchingTool?.prettyName;
615
673
  const icon = matchingTool?.icon;
674
+ const verbiage = matchingTool?.verbiage;
616
675
  // For the Task tool, use the displayName (or agentName as fallback) as the prettyName
617
676
  if (toolCall.name === SUBAGENT_TOOL_NAME &&
618
677
  toolCall.args &&
@@ -654,6 +713,7 @@ export class LangchainAgent {
654
713
  messageId: req.messageId,
655
714
  ...(prettyName ? { prettyName } : {}),
656
715
  ...(icon ? { icon } : {}),
716
+ ...(verbiage ? { verbiage } : {}),
657
717
  ...(batchId ? { batchId } : {}),
658
718
  },
659
719
  });
@@ -672,6 +732,7 @@ export class LangchainAgent {
672
732
  messageId: req.messageId,
673
733
  ...(prettyName ? { prettyName } : {}),
674
734
  ...(icon ? { icon } : {}),
735
+ ...(verbiage ? { verbiage } : {}),
675
736
  ...(batchId ? { batchId } : {}),
676
737
  },
677
738
  });
@@ -888,12 +949,15 @@ export class LangchainAgent {
888
949
  yield* yieldPendingSubagentUpdates();
889
950
  // Now that content streaming is complete, yield all buffered tool call notifications
890
951
  yield* flushPendingToolCalls();
891
- // Clean up subagent connection listener
952
+ // Clean up subagent event listeners
892
953
  subagentEvents.off("connection", onSubagentConnection);
954
+ subagentEvents.off("messages", onSubagentMessages);
893
955
  // Cancel any pending wait
894
956
  if (subagentUpdateResolver) {
895
957
  subagentUpdateResolver = null;
896
958
  }
959
+ // Clean up any remaining iteration span
960
+ otelCallbacks?.cleanup();
897
961
  // Log successful completion
898
962
  telemetry.log("info", "Agent invocation completed", {
899
963
  sessionId: req.sessionId,
@@ -907,8 +971,11 @@ export class LangchainAgent {
907
971
  };
908
972
  }
909
973
  catch (error) {
910
- // Clean up subagent connection listener on error
974
+ // Clean up subagent event listeners on error
911
975
  subagentEvents.off("connection", onSubagentConnection);
976
+ subagentEvents.off("messages", onSubagentMessages);
977
+ // Clean up any remaining iteration span
978
+ otelCallbacks?.cleanup();
912
979
  // Log error and end span with error status
913
980
  telemetry.log("error", "Agent invocation failed", {
914
981
  error: error instanceof Error ? error.message : String(error),
@@ -917,6 +984,10 @@ export class LangchainAgent {
917
984
  telemetry.endSpan(invocationSpan, error instanceof Error ? error : new Error(String(error)));
918
985
  throw error;
919
986
  }
987
+ finally {
988
+ // Clear agent.session_id from base attributes to prevent cross-contamination
989
+ telemetry.clearBaseAttribute("agent.session_id");
990
+ }
920
991
  }
921
992
  }
922
993
  const modelRequestSchema = z.object({
@@ -928,17 +999,17 @@ const makeMcpToolsClient = (mcpConfigs) => {
928
999
  const mcpServers = mcpConfigs?.map((config) => {
929
1000
  if (typeof config === "string") {
930
1001
  // String configs use the centralized MCP proxy with auth
931
- const credentials = loadAuthCredentials();
932
- if (!credentials) {
933
- throw new Error("Not logged in. Run 'town login' first to use cloud MCP servers.");
1002
+ const shedAuth = getShedAuth();
1003
+ if (!shedAuth) {
1004
+ throw new Error("Not logged in. Run 'town login' or set SHED_API_KEY to use cloud MCP servers.");
934
1005
  }
935
- const proxyUrl = process.env.MCP_PROXY_URL ?? `${credentials.shed_url}/mcp_proxy`;
1006
+ const proxyUrl = process.env.MCP_PROXY_URL ?? `${shedAuth.shedUrl}/mcp_proxy`;
936
1007
  return [
937
1008
  config,
938
1009
  {
939
1010
  url: `${proxyUrl}?server=${config}`,
940
1011
  headers: {
941
- Authorization: `Bearer ${credentials.access_token}`,
1012
+ Authorization: `Bearer ${shedAuth.accessToken}`,
942
1013
  },
943
1014
  },
944
1015
  ];
@@ -1031,18 +1102,22 @@ export { makeSubagentsTool } from "./tools/subagent.js";
1031
1102
  * This ensures the tool executes within its own span context,
1032
1103
  * so any child operations (like subagent spawning) become children
1033
1104
  * of the tool span rather than the parent invocation span.
1105
+ * @param originalTool The tool to wrap
1106
+ * @param getIterationContext Function that returns the current iteration context
1034
1107
  */
1035
- function wrapToolWithTracing(originalTool) {
1108
+ function wrapToolWithTracing(originalTool, getIterationContext) {
1036
1109
  const wrappedFunc = async (input) => {
1037
1110
  const toolInputJson = JSON.stringify(input);
1111
+ // Get the current iteration context so the tool span is created as a child
1112
+ const iterationContext = getIterationContext();
1038
1113
  const toolSpan = telemetry.startSpan("agent.tool_call", {
1039
1114
  "tool.name": originalTool.name,
1040
1115
  "tool.input": toolInputJson,
1041
- });
1116
+ }, iterationContext);
1042
1117
  // Create a context with the tool span as active
1043
1118
  const spanContext = toolSpan
1044
- ? trace.setSpan(context.active(), toolSpan)
1045
- : context.active();
1119
+ ? trace.setSpan(iterationContext, toolSpan)
1120
+ : iterationContext;
1046
1121
  try {
1047
1122
  // Execute within the tool span's context
1048
1123
  const result = await context.with(spanContext, () => originalTool.invoke(input));
@@ -1,7 +1,7 @@
1
1
  import { ChatAnthropic } from "@langchain/anthropic";
2
2
  import { ChatGoogleGenerativeAI } from "@langchain/google-genai";
3
3
  import { ChatVertexAI } from "@langchain/google-vertexai";
4
- import { loadAuthCredentials } from "@townco/core/auth";
4
+ import { getShedAuth } from "@townco/core/auth";
5
5
  import { createLogger } from "../../logger.js";
6
6
  const logger = createLogger("model-factory");
7
7
  /**
@@ -24,17 +24,14 @@ export function createModelFromString(modelString) {
24
24
  // Check for town- prefix for proxied models via shed
25
25
  if (modelString.startsWith("town-")) {
26
26
  const actualModel = modelString.slice(5); // strip "town-"
27
- const credentials = loadAuthCredentials();
28
- if (!credentials) {
29
- throw new Error("Not logged in. Run 'town login' first.");
27
+ const shedAuth = getShedAuth();
28
+ if (!shedAuth) {
29
+ throw new Error("Not logged in. Run 'town login' or set SHED_API_KEY.");
30
30
  }
31
- const shedUrl = credentials.shed_url ??
32
- process.env.TOWN_SHED_URL ??
33
- "http://localhost:3000";
34
31
  return new ChatAnthropic({
35
32
  model: actualModel,
36
- anthropicApiUrl: `${shedUrl}/api/anthropic`,
37
- apiKey: credentials.access_token,
33
+ anthropicApiUrl: `${shedAuth.shedUrl}/api/anthropic`,
34
+ apiKey: shedAuth.accessToken,
38
35
  });
39
36
  }
40
37
  // Check if the model string uses provider prefix format
@@ -4,6 +4,9 @@ export interface OtelCallbackOptions {
4
4
  provider: string;
5
5
  model: string;
6
6
  parentContext: Context;
7
+ iterationIndexRef: {
8
+ current: number;
9
+ };
7
10
  }
8
11
  /**
9
12
  * Creates OpenTelemetry callback handlers for LangChain LLM calls.
@@ -15,4 +18,7 @@ export interface OtelCallbackOptions {
15
18
  * @param opts.parentContext - The parent OTEL context to create child spans under
16
19
  * @returns CallbackHandlerMethods object that can be passed to LangChain
17
20
  */
18
- export declare function makeOtelCallbacks(opts: OtelCallbackOptions): CallbackHandlerMethods;
21
+ export declare function makeOtelCallbacks(opts: OtelCallbackOptions): CallbackHandlerMethods & {
22
+ cleanup: () => void;
23
+ getCurrentIterationContext: () => Context;
24
+ };
@@ -5,11 +5,15 @@ import { telemetry } from "../../telemetry/index.js";
5
5
  * Creates spans for each LLM request to track model invocations and token usage.
6
6
  */
7
7
  /**
8
- * Map to store active spans by their LangChain run ID
8
+ * Map to store active chat spans by their LangChain run ID
9
9
  *
10
10
  * There's a memory leak opportunity here, but we are OK with that for now.
11
11
  */
12
12
  const spansByRunId = new Map();
13
+ /**
14
+ * Map to store active iteration spans by their LangChain run ID
15
+ */
16
+ const iterationSpansByRunId = new Map();
13
17
  /**
14
18
  * Serializes LangChain messages to a JSON string for span attributes.
15
19
  * Extracts role and content from each message.
@@ -88,19 +92,41 @@ function serializeOutput(output) {
88
92
  * @returns CallbackHandlerMethods object that can be passed to LangChain
89
93
  */
90
94
  export function makeOtelCallbacks(opts) {
95
+ // Track the iteration span for this specific invocation
96
+ let localIterationSpan = null;
97
+ let currentIterationContext = opts.parentContext;
91
98
  return {
92
99
  /**
93
100
  * Called when a chat model/LLM request starts.
94
- * Creates a new OTEL span for this LLM request.
101
+ * Creates iteration span first, then chat span as its child.
95
102
  */
96
103
  async handleChatModelStart(_llm, messages, runId, _parentRunId, _extraParams, tags, _metadata) {
97
104
  // Extract system prompt and serialize messages
98
105
  const systemPrompt = extractSystemPrompt(messages);
99
106
  const serializedMessages = serializeMessages(messages);
100
- // Create span within the parent context (invocation span)
107
+ // Close previous iteration span if it exists (tools from previous iteration are done)
108
+ if (localIterationSpan) {
109
+ telemetry.endSpan(localIterationSpan);
110
+ localIterationSpan = null;
111
+ }
112
+ // Create iteration span within the parent context (invocation span)
113
+ const iterationIndex = opts.iterationIndexRef.current;
114
+ const iterationSpan = context.with(opts.parentContext, () => telemetry.startSpan("agent.iteration", {
115
+ "agent.iteration.index": iterationIndex,
116
+ "langchain.run_id": runId,
117
+ }));
118
+ // Track as current iteration span (will be closed when next iteration starts or invocation ends)
119
+ localIterationSpan = iterationSpan;
120
+ // Create context with iteration span as active
121
+ const iterationContext = iterationSpan
122
+ ? trace.setSpan(opts.parentContext, iterationSpan)
123
+ : opts.parentContext;
124
+ // Store current iteration context so tools can use it
125
+ currentIterationContext = iterationContext;
126
+ // Create chat span as child of iteration span
101
127
  // Following OpenTelemetry GenAI semantic conventions:
102
128
  // https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-spans/
103
- const span = context.with(opts.parentContext, () => telemetry.startSpan(`chat ${opts.model}`, {
129
+ const chatSpan = context.with(iterationContext, () => telemetry.startSpan(`chat ${opts.model}`, {
104
130
  "gen_ai.operation.name": "chat",
105
131
  "gen_ai.provider.name": opts.provider,
106
132
  "gen_ai.request.model": opts.model,
@@ -114,10 +140,14 @@ export function makeOtelCallbacks(opts) {
114
140
  ? { "langchain.tags": tags.join(",") }
115
141
  : {}),
116
142
  }));
117
- if (span) {
118
- spansByRunId.set(runId, span);
143
+ // Store both spans
144
+ if (iterationSpan) {
145
+ iterationSpansByRunId.set(runId, iterationSpan);
146
+ }
147
+ if (chatSpan) {
148
+ spansByRunId.set(runId, chatSpan);
119
149
  // Emit log for LLM request with trace context
120
- const spanContext = span.spanContext();
150
+ const spanContext = chatSpan.spanContext();
121
151
  telemetry.log("info", "LLM Request", {
122
152
  "gen_ai.operation.name": "chat",
123
153
  "gen_ai.provider.name": opts.provider,
@@ -129,14 +159,16 @@ export function makeOtelCallbacks(opts) {
129
159
  span_id: spanContext.spanId,
130
160
  });
131
161
  }
162
+ // Increment iteration index for next LLM call
163
+ opts.iterationIndexRef.current++;
132
164
  },
133
165
  /**
134
166
  * Called when an LLM request completes successfully.
135
- * Records token usage and ends the span.
167
+ * Records token usage and ends both chat and iteration spans.
136
168
  */
137
169
  async handleLLMEnd(output, runId) {
138
- const span = spansByRunId.get(runId);
139
- if (!span)
170
+ const chatSpan = spansByRunId.get(runId);
171
+ if (!chatSpan)
140
172
  return;
141
173
  // Extract token usage from LLM output
142
174
  // The structure varies by provider but LangChain normalizes it
@@ -147,15 +179,15 @@ export function makeOtelCallbacks(opts) {
147
179
  (tokenUsage.totalTokens != null
148
180
  ? tokenUsage.totalTokens - inputTokens
149
181
  : 0);
150
- telemetry.recordTokenUsage(inputTokens, outputTokens, span);
182
+ telemetry.recordTokenUsage(inputTokens, outputTokens, chatSpan);
151
183
  }
152
184
  // Serialize output and attach to span
153
185
  const serializedOutput = serializeOutput(output);
154
- telemetry.setSpanAttributes(span, {
186
+ telemetry.setSpanAttributes(chatSpan, {
155
187
  "gen_ai.output.messages": serializedOutput,
156
188
  });
157
189
  // Emit log for LLM response with trace context
158
- const spanContext = span.spanContext();
190
+ const spanContext = chatSpan.spanContext();
159
191
  telemetry.log("info", "LLM Response", {
160
192
  "gen_ai.operation.name": "chat",
161
193
  "gen_ai.output.messages": serializedOutput,
@@ -171,19 +203,47 @@ export function makeOtelCallbacks(opts) {
171
203
  trace_id: spanContext.traceId,
172
204
  span_id: spanContext.spanId,
173
205
  });
174
- telemetry.endSpan(span);
206
+ // End chat span
207
+ telemetry.endSpan(chatSpan);
175
208
  spansByRunId.delete(runId);
209
+ // DON'T close iteration span here - tools will execute after this
210
+ // The iteration span will be closed when:
211
+ // 1. Next iteration starts (in handleChatModelStart)
212
+ // 2. Invocation ends (caller must close remaining span)
213
+ iterationSpansByRunId.delete(runId);
176
214
  },
177
215
  /**
178
216
  * Called when an LLM request fails with an error.
179
- * Records the error and ends the span with error status.
217
+ * Records the error and ends both chat and iteration spans.
180
218
  */
181
219
  async handleLLMError(error, runId) {
182
- const span = spansByRunId.get(runId);
183
- if (!span)
184
- return;
185
- telemetry.endSpan(span, error instanceof Error ? error : new Error(String(error)));
186
- spansByRunId.delete(runId);
220
+ const chatSpan = spansByRunId.get(runId);
221
+ if (chatSpan) {
222
+ telemetry.endSpan(chatSpan, error instanceof Error ? error : new Error(String(error)));
223
+ spansByRunId.delete(runId);
224
+ }
225
+ // Close iteration span on error
226
+ if (localIterationSpan) {
227
+ telemetry.endSpan(localIterationSpan, error instanceof Error ? error : new Error(String(error)));
228
+ localIterationSpan = null;
229
+ }
230
+ iterationSpansByRunId.delete(runId);
231
+ },
232
+ /**
233
+ * Get the current iteration context for tool span creation
234
+ */
235
+ getCurrentIterationContext() {
236
+ return currentIterationContext;
237
+ },
238
+ /**
239
+ * Cleanup function to close any remaining iteration span
240
+ * Should be called when invocation completes
241
+ */
242
+ cleanup() {
243
+ if (localIterationSpan) {
244
+ telemetry.endSpan(localIterationSpan);
245
+ localIterationSpan = null;
246
+ }
187
247
  },
188
248
  };
189
249
  }
@@ -190,6 +190,11 @@ export function makeFilesystemTools(workingDirectory) {
190
190
  });
191
191
  grep.prettyName = "Codebase Search";
192
192
  grep.icon = "Search";
193
+ grep.verbiage = {
194
+ active: "Searching for {query}",
195
+ past: "Searched for {query}",
196
+ paramKey: "pattern",
197
+ };
193
198
  const read = tool(async ({ file_path, offset, limit }) => {
194
199
  await ensureSandbox(resolvedWd);
195
200
  assertAbsolutePath(file_path, "file_path");
@@ -234,6 +239,11 @@ export function makeFilesystemTools(workingDirectory) {
234
239
  });
235
240
  read.prettyName = "Read File";
236
241
  read.icon = "FileText";
242
+ read.verbiage = {
243
+ active: "Reading {file}",
244
+ past: "Read {file}",
245
+ paramKey: "file_path",
246
+ };
237
247
  const write = tool(async ({ file_path, content }) => {
238
248
  await ensureSandbox(resolvedWd);
239
249
  assertAbsolutePath(file_path, "file_path");
@@ -263,5 +273,10 @@ export function makeFilesystemTools(workingDirectory) {
263
273
  });
264
274
  write.prettyName = "Write File";
265
275
  write.icon = "Edit";
276
+ write.verbiage = {
277
+ active: "Writing {file}",
278
+ past: "Wrote {file}",
279
+ paramKey: "file_path",
280
+ };
266
281
  return [grep, read, write];
267
282
  }
@@ -7,6 +7,35 @@ export interface SubagentConnectionInfo {
7
7
  port: number;
8
8
  sessionId: string;
9
9
  }
10
+ /**
11
+ * Sub-agent tool call tracked during streaming
12
+ */
13
+ export interface SubagentToolCall {
14
+ id: string;
15
+ title: string;
16
+ prettyName?: string | undefined;
17
+ icon?: string | undefined;
18
+ status: "pending" | "in_progress" | "completed" | "failed";
19
+ }
20
+ /**
21
+ * Content block for sub-agent messages
22
+ */
23
+ export type SubagentContentBlock = {
24
+ type: "text";
25
+ text: string;
26
+ } | {
27
+ type: "tool_call";
28
+ toolCall: SubagentToolCall;
29
+ };
30
+ /**
31
+ * Sub-agent message accumulated during streaming
32
+ */
33
+ export interface SubagentMessage {
34
+ id: string;
35
+ content: string;
36
+ contentBlocks: SubagentContentBlock[];
37
+ toolCalls: SubagentToolCall[];
38
+ }
10
39
  /**
11
40
  * Event emitter for subagent connection events.
12
41
  * The runner listens to these events and emits tool_call_update.
@@ -26,3 +55,8 @@ export declare function hashQuery(query: string): string;
26
55
  * Emits an event that the runner can listen to.
27
56
  */
28
57
  export declare function emitSubagentConnection(queryHash: string, connectionInfo: SubagentConnectionInfo): void;
58
+ /**
59
+ * Called by the subagent tool when it completes with accumulated messages.
60
+ * Emits an event with the messages for session storage.
61
+ */
62
+ export declare function emitSubagentMessages(queryHash: string, messages: SubagentMessage[]): void;
@@ -12,6 +12,11 @@ export const subagentEvents = new EventEmitter();
12
12
  * Set by the runner when it sees a subagent tool_call.
13
13
  */
14
14
  export const queryToToolCallId = new Map();
15
+ /**
16
+ * Maps query hash to resolved toolCallId (preserved after connection event).
17
+ * Used to correlate messages when the tool completes.
18
+ */
19
+ const queryToResolvedToolCallId = new Map();
15
20
  /**
16
21
  * Generate a hash from the query string for correlation.
17
22
  */
@@ -46,7 +51,8 @@ export function emitSubagentConnection(queryHash, connectionInfo) {
46
51
  toolCallId,
47
52
  ...connectionInfo,
48
53
  });
49
- // Clean up the mapping
54
+ // Preserve the toolCallId for message emission, but remove from pending lookup
55
+ queryToResolvedToolCallId.set(queryHash, toolCallId);
50
56
  queryToToolCallId.delete(queryHash);
51
57
  }
52
58
  else {
@@ -56,3 +62,28 @@ export function emitSubagentConnection(queryHash, connectionInfo) {
56
62
  });
57
63
  }
58
64
  }
65
+ /**
66
+ * Called by the subagent tool when it completes with accumulated messages.
67
+ * Emits an event with the messages for session storage.
68
+ */
69
+ export function emitSubagentMessages(queryHash, messages) {
70
+ const toolCallId = queryToResolvedToolCallId.get(queryHash);
71
+ if (toolCallId) {
72
+ logger.info("Emitting subagent messages for storage", {
73
+ queryHash,
74
+ toolCallId,
75
+ messageCount: messages.length,
76
+ });
77
+ subagentEvents.emit("messages", {
78
+ toolCallId,
79
+ messages,
80
+ });
81
+ // Clean up the resolved mapping
82
+ queryToResolvedToolCallId.delete(queryHash);
83
+ }
84
+ else {
85
+ logger.warn("No resolved toolCallId for messages emission", {
86
+ queryHash,
87
+ });
88
+ }
89
+ }