@townco/agent 0.1.112 → 0.1.113

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.
@@ -1411,7 +1411,22 @@ export class AgentAcpAdapter {
1411
1411
  if ("sessionUpdate" in msg &&
1412
1412
  msg.sessionUpdate === "tool_call_update") {
1413
1413
  const updateMsg = msg;
1414
+ logger.info("[SUBAGENT] Adapter received tool_call_update", {
1415
+ sessionId: params.sessionId,
1416
+ toolCallId: updateMsg.toolCallId,
1417
+ status: updateMsg.status,
1418
+ hasMeta: !!updateMsg._meta,
1419
+ hasSubagentMessages: !!updateMsg._meta?.subagentMessages,
1420
+ subagentMessageCount: updateMsg._meta?.subagentMessages?.length ||
1421
+ 0,
1422
+ });
1414
1423
  const toolCallBlock = contentBlocks.find((block) => block.type === "tool_call" && block.id === updateMsg.toolCallId);
1424
+ logger.info("[SUBAGENT] Tool call block lookup result", {
1425
+ sessionId: params.sessionId,
1426
+ toolCallId: updateMsg.toolCallId,
1427
+ found: !!toolCallBlock,
1428
+ blockStatus: toolCallBlock?.status,
1429
+ });
1415
1430
  if (toolCallBlock) {
1416
1431
  if (updateMsg.status) {
1417
1432
  toolCallBlock.status =
@@ -1444,19 +1459,30 @@ export class AgentAcpAdapter {
1444
1459
  toolCallBlock.subagentSessionId = meta.subagentSessionId;
1445
1460
  }
1446
1461
  if (meta?.subagentMessages) {
1447
- logger.info("Storing subagent messages for session replay", {
1462
+ logger.info("[SUBAGENT] Storing subagent messages for session replay", {
1463
+ sessionId: params.sessionId,
1448
1464
  toolCallId: updateMsg.toolCallId,
1449
1465
  messageCount: meta.subagentMessages.length,
1466
+ contentPreview: meta.subagentMessages[0]?.content?.substring(0, 100),
1450
1467
  });
1451
1468
  toolCallBlock.subagentMessages = meta.subagentMessages;
1469
+ logger.info("[SUBAGENT] Successfully stored messages", {
1470
+ sessionId: params.sessionId,
1471
+ toolCallId: updateMsg.toolCallId,
1472
+ storedCount: toolCallBlock.subagentMessages?.length,
1473
+ });
1452
1474
  }
1453
1475
  }
1454
1476
  // Forward tool_call_update with _meta to the client (for subagent connection info, etc.)
1455
1477
  if (updateMsg._meta) {
1456
- logger.info("Forwarding tool_call_update with _meta to client", {
1478
+ logger.info("[SUBAGENT] Forwarding tool_call_update with _meta to client", {
1479
+ sessionId: params.sessionId,
1457
1480
  toolCallId: updateMsg.toolCallId,
1458
1481
  status: updateMsg.status,
1459
- _meta: updateMsg._meta,
1482
+ hasSubagentMessages: !!updateMsg._meta
1483
+ ?.subagentMessages,
1484
+ subagentMessageCount: updateMsg._meta?.subagentMessages
1485
+ ?.length || 0,
1460
1486
  });
1461
1487
  this.connection.sessionUpdate({
1462
1488
  sessionId: params.sessionId,
@@ -1467,22 +1493,9 @@ export class AgentAcpAdapter {
1467
1493
  _meta: updateMsg._meta,
1468
1494
  },
1469
1495
  });
1470
- }
1471
- // Forward tool_call_update with _meta to the client (for subagent connection info, etc.)
1472
- if (updateMsg._meta) {
1473
- logger.info("Forwarding tool_call_update with _meta to client", {
1474
- toolCallId: updateMsg.toolCallId,
1475
- status: updateMsg.status,
1476
- _meta: updateMsg._meta,
1477
- });
1478
- this.connection.sessionUpdate({
1496
+ logger.info("[SUBAGENT] Successfully forwarded to client", {
1479
1497
  sessionId: params.sessionId,
1480
- update: {
1481
- sessionUpdate: "tool_call_update",
1482
- toolCallId: updateMsg.toolCallId,
1483
- status: updateMsg.status,
1484
- _meta: updateMsg._meta,
1485
- },
1498
+ toolCallId: updateMsg.toolCallId,
1486
1499
  });
1487
1500
  }
1488
1501
  }
@@ -406,6 +406,27 @@ export function createAcpHttpApp(agent, agentDir, agentName) {
406
406
  allowMethods: ["GET", "POST", "OPTIONS"],
407
407
  }));
408
408
  app.get("/health", (c) => c.json({ ok: true }));
409
+ // Receive browser console logs and write to agent.log
410
+ const browserLogger = createLogger("gui-console");
411
+ app.post("/logs/browser", async (c) => {
412
+ try {
413
+ const { logs } = await c.req.json();
414
+ if (Array.isArray(logs)) {
415
+ for (const log of logs) {
416
+ const level = log.level;
417
+ const method = browserLogger[level];
418
+ if (typeof method === "function") {
419
+ method.call(browserLogger, log.message, log.metadata);
420
+ }
421
+ }
422
+ }
423
+ return c.json({ ok: true });
424
+ }
425
+ catch (error) {
426
+ logger.error("Failed to process browser logs", { error });
427
+ return c.json({ ok: false }, 400);
428
+ }
429
+ });
409
430
  // List available sessions
410
431
  app.get("/sessions", async (c) => {
411
432
  if (!agentDir || !agentName) {
@@ -1,3 +1,4 @@
1
+ import { EventEmitter } from "node:events";
1
2
  import { mkdir } from "node:fs/promises";
2
3
  import * as path from "node:path";
3
4
  import { MultiServerMCPClient } from "@langchain/mcp-adapters";
@@ -11,7 +12,7 @@ import { telemetry } from "../../telemetry/index.js";
11
12
  import { calculateContextSize } from "../../utils/context-size-calculator.js";
12
13
  import { getModelContextWindow } from "../hooks/constants.js";
13
14
  import { isContextOverflowError } from "../hooks/predefined/context-validator.js";
14
- import { bindGeneratorToAbortSignal, bindGeneratorToEmitUpdate, bindGeneratorToSessionContext, getAbortSignal, runWithAbortSignal, } from "../session-context";
15
+ import { bindGeneratorToAbortSignal, bindGeneratorToEmitUpdate, bindGeneratorToInvocationContext, bindGeneratorToSessionContext, getAbortSignal, getInvocationContext, runWithAbortSignal, } from "../session-context";
15
16
  import { loadCustomToolModule, } from "../tool-loader.js";
16
17
  import { createModelFromString, detectProvider } from "./model-factory.js";
17
18
  import { makeOtelCallbacks } from "./otel-callbacks.js";
@@ -19,7 +20,7 @@ import { makeBrowserTools } from "./tools/browser";
19
20
  import { makeDocumentExtractTool } from "./tools/document_extract";
20
21
  import { makeTownE2BTools } from "./tools/e2b";
21
22
  import { SUBAGENT_TOOL_NAME } from "./tools/subagent";
22
- import { hashQuery, queryToToolCallId, subagentEvents, } from "./tools/subagent-connections";
23
+ import { hashQuery, queryToToolCallId, } from "./tools/subagent-connections";
23
24
  import { makeTodoWriteTool, TODO_WRITE_TOOL_NAME } from "./tools/todo";
24
25
  import { makeTownWebSearchTools, makeWebSearchTools } from "./tools/web_search";
25
26
  const _logger = createLogger("agent-runner");
@@ -82,6 +83,14 @@ export class LangchainAgent {
82
83
  async *invokeInternal(req) {
83
84
  // Start with the base generator
84
85
  let generator = this.invokeWithContext(req);
86
+ // Create invocation-scoped context for subagent event routing
87
+ // This must be done here so it's available before invokeWithContext starts
88
+ const subagentInvocationContext = {
89
+ invocationId: req.sessionId,
90
+ subagentEventEmitter: new EventEmitter(),
91
+ };
92
+ // Bind invocation context first so it's available to all nested operations
93
+ generator = bindGeneratorToInvocationContext(subagentInvocationContext, generator);
85
94
  // Bind abort signal if available (for cancellation support)
86
95
  if (req.abortSignal) {
87
96
  generator = bindGeneratorToAbortSignal(req.abortSignal, generator);
@@ -108,6 +117,11 @@ export class LangchainAgent {
108
117
  return yield* generator;
109
118
  }
110
119
  async *invokeWithContext(req) {
120
+ // Get subagent invocation context from AsyncLocalStorage (created in invokeInternal)
121
+ const subagentInvCtx = getInvocationContext();
122
+ if (!subagentInvCtx) {
123
+ throw new Error("No subagent invocation context available - invokeWithContext called outside of invocation binding");
124
+ }
111
125
  // Derive the parent OTEL context for this invocation.
112
126
  // If this is a subagent and the parent process propagated an OTEL trace
113
127
  // context via sessionMeta.otelTraceContext, use that as the parent;
@@ -140,78 +154,50 @@ export class LangchainAgent {
140
154
  // Clear the buffer after flushing
141
155
  pendingToolCallNotifications.length = 0;
142
156
  }
143
- const subagentUpdateQueue = [];
144
- let subagentUpdateResolver = null;
145
157
  const subagentMessagesQueue = [];
146
- // Listen for subagent connection events - resolve any waiting promise immediately
147
- const onSubagentConnection = (event) => {
148
- _logger.info("Received subagent connection event", {
149
- toolCallId: event.toolCallId,
150
- port: event.port,
151
- sessionId: event.sessionId,
152
- });
153
- if (subagentUpdateResolver) {
154
- // If someone is waiting, resolve immediately
155
- const resolver = subagentUpdateResolver;
156
- subagentUpdateResolver = null;
157
- resolver(event);
158
- }
159
- else {
160
- // Otherwise queue for later
161
- subagentUpdateQueue.push(event);
162
- }
163
- };
164
- subagentEvents.on("connection", onSubagentConnection);
165
- // Listen for subagent messages events (for session storage)
158
+ const completedSubagentToolCalls = new Set();
159
+ const activeSubagentToolCalls = new Set();
160
+ // Listen for subagent messages events (for live streaming)
161
+ // Use the invocation-scoped EventEmitter to ensure messages route correctly
166
162
  const onSubagentMessages = (event) => {
167
- _logger.info("Received subagent messages event", {
163
+ _logger.info("Received subagent messages event from scoped emitter", {
168
164
  toolCallId: event.toolCallId,
169
165
  messageCount: event.messages.length,
166
+ completed: event.completed,
167
+ invocationId: subagentInvCtx.invocationId,
168
+ sessionId: req.sessionId,
170
169
  });
171
- subagentMessagesQueue.push(event);
172
- };
173
- subagentEvents.on("messages", onSubagentMessages);
174
- // Helper to get next subagent update (returns immediately if queued, otherwise waits)
175
- const waitForSubagentUpdate = () => {
176
- if (subagentUpdateQueue.length > 0) {
177
- // biome-ignore lint/style/noNonNullAssertion: We check length > 0, so shift() will return a value
178
- return Promise.resolve(subagentUpdateQueue.shift());
170
+ // Track completion
171
+ if (event.completed) {
172
+ completedSubagentToolCalls.add(event.toolCallId);
173
+ _logger.info("✓ Subagent stream completed", {
174
+ toolCallId: event.toolCallId,
175
+ sessionId: req.sessionId,
176
+ totalCompleted: completedSubagentToolCalls.size,
177
+ });
179
178
  }
180
- return new Promise((resolve) => {
181
- subagentUpdateResolver = resolve;
182
- });
179
+ subagentMessagesQueue.push(event);
183
180
  };
184
- // Helper to check and yield all pending subagent updates
181
+ subagentInvCtx.subagentEventEmitter.on("messages", onSubagentMessages);
182
+ // Helper to check and yield all pending subagent message updates
185
183
  async function* yieldPendingSubagentUpdates() {
186
- while (subagentUpdateQueue.length > 0) {
187
- const update = subagentUpdateQueue.shift();
188
- if (!update)
189
- continue;
190
- _logger.info("Yielding queued subagent connection update", {
191
- toolCallId: update.toolCallId,
192
- subagentPort: update.port,
193
- subagentSessionId: update.sessionId,
194
- });
195
- yield {
196
- sessionUpdate: "tool_call_update",
197
- toolCallId: update.toolCallId,
198
- _meta: {
199
- messageId: req.messageId,
200
- subagentPort: update.port,
201
- subagentSessionId: update.sessionId,
202
- },
203
- };
204
- }
205
- // Also yield any pending messages updates
184
+ _logger.info("[SUBAGENT] yieldPendingSubagentUpdates called", {
185
+ sessionId: req.sessionId,
186
+ queueLength: subagentMessagesQueue.length,
187
+ });
206
188
  while (subagentMessagesQueue.length > 0) {
207
189
  const messagesUpdate = subagentMessagesQueue.shift();
208
190
  if (!messagesUpdate)
209
191
  continue;
210
- _logger.info("Yielding queued subagent messages update", {
192
+ _logger.info("[SUBAGENT] Yielding queued subagent messages update", {
193
+ sessionId: req.sessionId,
211
194
  toolCallId: messagesUpdate.toolCallId,
212
195
  messageCount: messagesUpdate.messages.length,
196
+ contentPreview: messagesUpdate.messages[0]?.content?.substring(0, 100),
197
+ hasContentBlocks: (messagesUpdate.messages[0]?.contentBlocks?.length ?? 0) > 0,
198
+ toolCallCount: messagesUpdate.messages[0]?.toolCalls?.length ?? 0,
213
199
  });
214
- yield {
200
+ const updateToYield = {
215
201
  sessionUpdate: "tool_call_update",
216
202
  toolCallId: messagesUpdate.toolCallId,
217
203
  _meta: {
@@ -219,6 +205,17 @@ export class LangchainAgent {
219
205
  subagentMessages: messagesUpdate.messages,
220
206
  },
221
207
  };
208
+ _logger.info("[SUBAGENT] About to yield update object", {
209
+ sessionId: req.sessionId,
210
+ toolCallId: messagesUpdate.toolCallId,
211
+ updateType: updateToYield.sessionUpdate,
212
+ hasMetaSubagentMessages: !!updateToYield._meta?.subagentMessages,
213
+ });
214
+ yield updateToYield;
215
+ _logger.info("[SUBAGENT] Successfully yielded update", {
216
+ sessionId: req.sessionId,
217
+ toolCallId: messagesUpdate.toolCallId,
218
+ });
222
219
  }
223
220
  }
224
221
  // Add agent.session_id as a base attribute so it propagates to all child spans
@@ -871,53 +868,63 @@ export class LangchainAgent {
871
868
  callbacks: [otelCallbacks],
872
869
  }));
873
870
  _logger.info("agent.stream created, starting iteration");
874
- // Use manual iteration with Promise.race to interleave stream events with subagent updates
875
- const streamIterator = (await stream)[Symbol.asyncIterator]();
876
- let streamDone = false;
877
- let pendingStreamPromise = null;
878
- while (!streamDone) {
879
- // Get or create the stream promise (reuse if still pending from last iteration)
880
- const nextStreamPromise = pendingStreamPromise ?? streamIterator.next();
881
- pendingStreamPromise = nextStreamPromise; // Track it
882
- // Create subagent wait promise (only if no queued updates)
883
- const subagentPromise = waitForSubagentUpdate();
884
- // Use Promise.race, but we need to handle both outcomes
885
- const result = await Promise.race([
886
- nextStreamPromise.then((r) => ({
887
- type: "stream",
888
- result: r,
889
- })),
890
- subagentPromise.then((u) => ({ type: "subagent", update: u })),
891
- ]);
892
- if (result.type === "subagent") {
893
- // Got a subagent update - yield it immediately
894
- const update = result.update;
895
- _logger.info("Yielding subagent connection update (via race)", {
896
- toolCallId: update.toolCallId,
897
- subagentPort: update.port,
898
- subagentSessionId: update.sessionId,
871
+ // Merge the LangChain stream with subagent event stream
872
+ // This allows both to yield concurrently without polling
873
+ async function* mergeStreams() {
874
+ const streamIterator = (await stream)[Symbol.asyncIterator]();
875
+ const _pending = [];
876
+ let streamDone = false;
877
+ let subagentListenerActive = true;
878
+ // Create a promise that resolves when the next subagent event arrives
879
+ function createSubagentEventPromise() {
880
+ return new Promise((resolve) => {
881
+ const handler = (event) => {
882
+ subagentInvCtx?.subagentEventEmitter.off("messages", handler);
883
+ resolve({ source: "subagent", value: event, done: false });
884
+ };
885
+ subagentInvCtx?.subagentEventEmitter.once("messages", handler);
899
886
  });
900
- yield {
901
- sessionUpdate: "tool_call_update",
902
- toolCallId: update.toolCallId,
903
- _meta: {
904
- messageId: req.messageId,
905
- subagentPort: update.port,
906
- subagentSessionId: update.sessionId,
907
- },
908
- };
909
- // Continue - the stream promise is still pending, will be reused
910
- continue;
911
887
  }
912
- // Got a stream item - clear the pending promise
913
- pendingStreamPromise = null;
914
- const { done, value: streamItem } = result.result;
915
- if (done) {
916
- streamDone = true;
917
- break;
888
+ // Start listening for subagent events
889
+ let subagentPromise = createSubagentEventPromise();
890
+ while (!streamDone || subagentListenerActive) {
891
+ // Race between next stream item and next subagent event
892
+ const streamPromise = streamIterator.next().then((result) => ({
893
+ source: "stream",
894
+ value: result.value,
895
+ done: result.done ?? false,
896
+ }));
897
+ const result = await Promise.race([streamPromise, subagentPromise]);
898
+ if (result.source === "stream") {
899
+ if (result.done) {
900
+ streamDone = true;
901
+ // Continue to drain remaining subagent events
902
+ subagentListenerActive = false;
903
+ // Yield any remaining queued messages
904
+ while (subagentMessagesQueue.length > 0) {
905
+ yield { source: "subagent" };
906
+ }
907
+ break;
908
+ }
909
+ yield { source: "stream", value: result.value };
910
+ }
911
+ else if (result.source === "subagent") {
912
+ // Subagent event arrived - it's already in the queue
913
+ yield { source: "subagent" };
914
+ // Start listening for the next subagent event
915
+ subagentPromise = createSubagentEventPromise();
916
+ }
918
917
  }
919
- // Also yield any queued subagent updates before processing stream item
920
- yield* yieldPendingSubagentUpdates();
918
+ }
919
+ // Iterate through the merged stream
920
+ for await (const item of mergeStreams()) {
921
+ if (item.source === "subagent") {
922
+ // Yield any queued subagent messages
923
+ yield* yieldPendingSubagentUpdates();
924
+ continue;
925
+ }
926
+ // Process the stream item
927
+ const streamItem = item.value;
921
928
  // biome-ignore lint/suspicious/noExplicitAny: LangChain stream items are tuples with dynamic types
922
929
  const [streamMode, chunk] = streamItem;
923
930
  if (streamMode === "updates") {
@@ -1020,10 +1027,19 @@ export class LangchainAgent {
1020
1027
  typeof toolCall.args.query === "string") {
1021
1028
  const qHash = hashQuery(toolCall.args.query);
1022
1029
  queryToToolCallId.set(qHash, toolCall.id);
1023
- telemetry.log("info", "Registered subagent query hash mapping", {
1030
+ activeSubagentToolCalls.add(toolCall.id);
1031
+ telemetry.log("info", "✓ Registered subagent query hash mapping", {
1024
1032
  queryHash: qHash,
1025
1033
  toolCallId: toolCall.id,
1026
1034
  queryPreview: toolCall.args.query.slice(0, 50),
1035
+ timestamp: new Date().toISOString(),
1036
+ });
1037
+ }
1038
+ else {
1039
+ telemetry.log("warn", "✗ Subagent tool call missing query parameter", {
1040
+ toolCallId: toolCall.id,
1041
+ hasQuery: "query" in (toolCall.args || {}),
1042
+ argsKeys: Object.keys(toolCall.args || {}),
1027
1043
  });
1028
1044
  }
1029
1045
  }
@@ -1312,15 +1328,62 @@ export class LangchainAgent {
1312
1328
  }
1313
1329
  // Yield any remaining pending subagent connection updates after stream ends
1314
1330
  yield* yieldPendingSubagentUpdates();
1331
+ // Keep polling for subagent messages after stream ends
1332
+ // This ensures we capture messages that arrive after LangChain stream completes
1333
+ _logger.info("[SUBAGENT] Starting post-stream polling for subagent messages", {
1334
+ sessionId: req.sessionId,
1335
+ activeSubagentCount: activeSubagentToolCalls.size,
1336
+ });
1337
+ const checkInterval = 100; // Check every 100ms
1338
+ const maxIdleTime = 5000; // Stop if no messages for 5 seconds (fallback)
1339
+ const maxWaitTime = 300000; // Absolute max 5 minutes
1340
+ const startTime = Date.now();
1341
+ let lastMessageTime = Date.now();
1342
+ while (Date.now() - startTime < maxWaitTime) {
1343
+ // Check if all subagent tool calls have completed
1344
+ if (activeSubagentToolCalls.size > 0 &&
1345
+ completedSubagentToolCalls.size >= activeSubagentToolCalls.size) {
1346
+ _logger.info("[SUBAGENT] All subagent tool calls completed", {
1347
+ sessionId: req.sessionId,
1348
+ totalSubagents: activeSubagentToolCalls.size,
1349
+ completedCount: completedSubagentToolCalls.size,
1350
+ });
1351
+ break;
1352
+ }
1353
+ // Check if there are pending messages
1354
+ if (subagentMessagesQueue.length > 0) {
1355
+ _logger.info("[SUBAGENT] Found pending messages, yielding", {
1356
+ sessionId: req.sessionId,
1357
+ queueLength: subagentMessagesQueue.length,
1358
+ });
1359
+ yield* yieldPendingSubagentUpdates();
1360
+ lastMessageTime = Date.now();
1361
+ }
1362
+ // Stop if we haven't seen messages for maxIdleTime (fallback for no subagents)
1363
+ if (Date.now() - lastMessageTime > maxIdleTime) {
1364
+ _logger.info("[SUBAGENT] No new messages for 5 seconds, finishing", {
1365
+ sessionId: req.sessionId,
1366
+ hasActiveSubagents: activeSubagentToolCalls.size > 0,
1367
+ });
1368
+ break;
1369
+ }
1370
+ // Wait a bit before checking again
1371
+ await new Promise((resolve) => setTimeout(resolve, checkInterval));
1372
+ }
1373
+ if (Date.now() - startTime >= maxWaitTime) {
1374
+ _logger.warn("[SUBAGENT] Timeout waiting for subagents", {
1375
+ sessionId: req.sessionId,
1376
+ });
1377
+ }
1378
+ // Final yield of any remaining messages
1379
+ yield* yieldPendingSubagentUpdates();
1380
+ _logger.info("[SUBAGENT] Finished post-stream polling", {
1381
+ sessionId: req.sessionId,
1382
+ });
1315
1383
  // Now that content streaming is complete, yield all buffered tool call notifications
1316
1384
  yield* flushPendingToolCalls();
1317
- // Clean up subagent event listeners
1318
- subagentEvents.off("connection", onSubagentConnection);
1319
- subagentEvents.off("messages", onSubagentMessages);
1320
- // Cancel any pending wait
1321
- if (subagentUpdateResolver) {
1322
- subagentUpdateResolver = null;
1323
- }
1385
+ // Clean up subagent event listener from invocation-scoped emitter
1386
+ subagentInvCtx.subagentEventEmitter.off("messages", onSubagentMessages);
1324
1387
  // Clean up any remaining iteration span
1325
1388
  otelCallbacks?.cleanup();
1326
1389
  // Log successful completion
@@ -1336,9 +1399,8 @@ export class LangchainAgent {
1336
1399
  };
1337
1400
  }
1338
1401
  catch (error) {
1339
- // Clean up subagent event listeners on error
1340
- subagentEvents.off("connection", onSubagentConnection);
1341
- subagentEvents.off("messages", onSubagentMessages);
1402
+ // Clean up subagent event listener on error from invocation-scoped emitter
1403
+ subagentInvCtx.subagentEventEmitter.off("messages", onSubagentMessages);
1342
1404
  // Clean up any remaining iteration span
1343
1405
  otelCallbacks?.cleanup();
1344
1406
  // Check if this is a context overflow error - wrap it for retry handling
@@ -1,12 +1,3 @@
1
- import { EventEmitter } from "node:events";
2
- /**
3
- * Registry for subagent connection info.
4
- * Maps query hash to connection info so the runner can emit tool_call_update.
5
- */
6
- export interface SubagentConnectionInfo {
7
- port: number;
8
- sessionId: string;
9
- }
10
1
  /**
11
2
  * Sub-agent tool call tracked during streaming
12
3
  */
@@ -36,14 +27,11 @@ export interface SubagentMessage {
36
27
  contentBlocks: SubagentContentBlock[];
37
28
  toolCalls: SubagentToolCall[];
38
29
  }
39
- /**
40
- * Event emitter for subagent connection events.
41
- * The runner listens to these events and emits tool_call_update.
42
- */
43
- export declare const subagentEvents: EventEmitter<[never]>;
44
30
  /**
45
31
  * Maps query hash to toolCallId.
46
32
  * Set by the runner when it sees a subagent tool_call.
33
+ * This is still global because the registration happens in the parent runner
34
+ * before subagent execution begins.
47
35
  */
48
36
  export declare const queryToToolCallId: Map<string, string>;
49
37
  /**
@@ -51,12 +39,9 @@ export declare const queryToToolCallId: Map<string, string>;
51
39
  */
52
40
  export declare function hashQuery(query: string): string;
53
41
  /**
54
- * Called by the subagent tool when connection is established.
55
- * Emits an event that the runner can listen to.
56
- */
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.
42
+ * Called by the subagent tool during execution to emit incremental messages.
43
+ * Emits an event with the messages for live streaming to the UI.
44
+ * Uses the invocation context's EventEmitter to ensure messages go to the correct parent.
45
+ * @param completed - If true, signals that the subagent stream has finished
61
46
  */
62
- export declare function emitSubagentMessages(queryHash: string, messages: SubagentMessage[]): void;
47
+ export declare function emitSubagentMessages(queryHash: string, messages: SubagentMessage[], completed?: boolean): void;
@@ -1,22 +1,14 @@
1
1
  import { createHash } from "node:crypto";
2
- import { EventEmitter } from "node:events";
3
2
  import { createLogger } from "@townco/core";
3
+ import { getInvocationContext } from "../../session-context.js";
4
4
  const logger = createLogger("subagent-connections");
5
- /**
6
- * Event emitter for subagent connection events.
7
- * The runner listens to these events and emits tool_call_update.
8
- */
9
- export const subagentEvents = new EventEmitter();
10
5
  /**
11
6
  * Maps query hash to toolCallId.
12
7
  * Set by the runner when it sees a subagent tool_call.
8
+ * This is still global because the registration happens in the parent runner
9
+ * before subagent execution begins.
13
10
  */
14
11
  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();
20
12
  /**
21
13
  * Generate a hash from the query string for correlation.
22
14
  */
@@ -29,61 +21,45 @@ export function hashQuery(query) {
29
21
  return hash;
30
22
  }
31
23
  /**
32
- * Called by the subagent tool when connection is established.
33
- * Emits an event that the runner can listen to.
24
+ * Called by the subagent tool during execution to emit incremental messages.
25
+ * Emits an event with the messages for live streaming to the UI.
26
+ * Uses the invocation context's EventEmitter to ensure messages go to the correct parent.
27
+ * @param completed - If true, signals that the subagent stream has finished
34
28
  */
35
- export function emitSubagentConnection(queryHash, connectionInfo) {
36
- logger.info("emitSubagentConnection called", {
37
- queryHash,
38
- port: connectionInfo.port,
39
- sessionId: connectionInfo.sessionId,
40
- registeredHashes: Array.from(queryToToolCallId.keys()),
41
- });
29
+ export function emitSubagentMessages(queryHash, messages, completed = false) {
42
30
  const toolCallId = queryToToolCallId.get(queryHash);
43
- if (toolCallId) {
44
- logger.info("Found toolCallId for queryHash, emitting connection event", {
45
- queryHash,
46
- toolCallId,
47
- port: connectionInfo.port,
48
- sessionId: connectionInfo.sessionId,
49
- });
50
- subagentEvents.emit("connection", {
51
- toolCallId,
52
- ...connectionInfo,
53
- });
54
- // Preserve the toolCallId for message emission, but remove from pending lookup
55
- queryToResolvedToolCallId.set(queryHash, toolCallId);
56
- queryToToolCallId.delete(queryHash);
57
- }
58
- else {
59
- logger.warn("No toolCallId found for queryHash", {
31
+ const invocationCtx = getInvocationContext();
32
+ if (!invocationCtx) {
33
+ logger.warn("✗ No invocation context found - cannot emit subagent messages", {
60
34
  queryHash,
61
- registeredHashes: Array.from(queryToToolCallId.keys()),
35
+ hasToolCallId: !!toolCallId,
62
36
  });
37
+ return;
63
38
  }
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
39
  if (toolCallId) {
72
- logger.info("Emitting subagent messages for storage", {
40
+ const firstMessage = messages[0];
41
+ logger.info("✓ Emitting subagent messages for live streaming", {
73
42
  queryHash,
74
43
  toolCallId,
75
44
  messageCount: messages.length,
45
+ hasContent: firstMessage ? firstMessage.content.length > 0 : false,
46
+ hasToolCalls: firstMessage ? firstMessage.toolCalls.length > 0 : false,
47
+ completed,
48
+ invocationId: invocationCtx.invocationId,
76
49
  });
77
- subagentEvents.emit("messages", {
50
+ // Emit to the parent's invocation-scoped EventEmitter
51
+ invocationCtx.subagentEventEmitter.emit("messages", {
78
52
  toolCallId,
79
53
  messages,
54
+ completed,
80
55
  });
81
- // Clean up the resolved mapping
82
- queryToResolvedToolCallId.delete(queryHash);
83
56
  }
84
57
  else {
85
- logger.warn("No resolved toolCallId for messages emission", {
58
+ logger.warn("No toolCallId found for messages emission (RACE CONDITION)", {
86
59
  queryHash,
60
+ registeredHashes: Array.from(queryToToolCallId.keys()),
61
+ mapSize: queryToToolCallId.size,
62
+ invocationId: invocationCtx.invocationId,
87
63
  });
88
64
  }
89
65
  }