@townco/agent 0.1.119 → 0.1.120

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.
@@ -259,19 +259,7 @@ export function createAcpHttpApp(agent, agentDir, agentName) {
259
259
  const stream = sseStreams.get(msgSessionId);
260
260
  if (stream) {
261
261
  try {
262
- // Check if this is a sources message
263
- const isSourcesMsg = rawMsg &&
264
- typeof rawMsg === "object" &&
265
- "params" in rawMsg &&
266
- rawMsg.params?.update?.sessionUpdate === "sources";
267
- if (isSourcesMsg) {
268
- const sourcesCount = rawMsg.params?.update?.sources?.length;
269
- logger.info("🔶 SENDING LARGE SOURCES VIA DIRECT SSE", {
270
- sessionId: msgSessionId,
271
- sourcesCount,
272
- compressedSize,
273
- });
274
- }
262
+ // Send large messages directly via SSE
275
263
  await stream.writeSSE({
276
264
  event: "message",
277
265
  data: JSON.stringify(rawMsg),
@@ -811,21 +799,7 @@ export function createAcpHttpApp(agent, agentDir, agentName) {
811
799
  return;
812
800
  }
813
801
  }
814
- // Check if this is a sources message and log prominently
815
- const isSourcesMsg = json &&
816
- typeof json === "object" &&
817
- "params" in json &&
818
- json.params?.update?.sessionUpdate === "sources";
819
- if (isSourcesMsg) {
820
- logger.info("🔶 SENDING SOURCES VIA SSE", {
821
- sessionId,
822
- channel,
823
- sourcesCount: json.params?.update?.sources?.length,
824
- });
825
- }
826
- else {
827
- logger.trace("Sending SSE message", { sessionId, channel });
828
- }
802
+ logger.trace("Sending SSE message", { sessionId, channel });
829
803
  await stream.writeSSE({
830
804
  event: "message",
831
805
  data: JSON.stringify(json),
@@ -3,6 +3,9 @@ import type { Sandbox } from "@e2b/code-interpreter";
3
3
  * Get or create an E2B sandbox for the current session.
4
4
  * Sandboxes are session-scoped and reused across tool calls.
5
5
  * Attempts to reconnect to persisted sandboxes when resuming sessions.
6
+ *
7
+ * Uses promise caching to prevent race conditions when multiple tools
8
+ * are called in parallel - all concurrent calls will share the same sandbox.
6
9
  */
7
10
  export declare function getSessionSandbox(apiKey: string): Promise<Sandbox>;
8
11
  /**
@@ -8,6 +8,8 @@ const sessionSandboxes = new Map();
8
8
  const sandboxActivity = new Map();
9
9
  // Map sessionId -> cleanup timeout handle
10
10
  const cleanupTimeouts = new Map();
11
+ // Map sessionId -> Promise<Sandbox> for in-flight creations (prevents race condition)
12
+ const sandboxCreationPromises = new Map();
11
13
  // Sandbox timeout in milliseconds (default: 15 minutes)
12
14
  const SANDBOX_TIMEOUT_MS = 15 * 60 * 1000;
13
15
  /**
@@ -58,24 +60,10 @@ function getSessionStorage() {
58
60
  }
59
61
  }
60
62
  /**
61
- * Get or create an E2B sandbox for the current session.
62
- * Sandboxes are session-scoped and reused across tool calls.
63
- * Attempts to reconnect to persisted sandboxes when resuming sessions.
63
+ * Internal function to create or reconnect to a sandbox for a session.
64
+ * This should only be called from getSessionSandbox() with proper promise tracking.
64
65
  */
65
- export async function getSessionSandbox(apiKey) {
66
- if (!hasSessionContext()) {
67
- throw new Error("E2B tools require session context");
68
- }
69
- const { sessionId } = getSessionContext();
70
- // Check for existing in-memory sandbox
71
- let sandbox = sessionSandboxes.get(sessionId);
72
- if (sandbox) {
73
- // Update activity timestamp and reschedule cleanup
74
- sandboxActivity.set(sessionId, Date.now());
75
- rescheduleCleanup(sessionId);
76
- logger.debug("Reusing existing sandbox", { sessionId });
77
- return sandbox;
78
- }
66
+ async function createSandboxForSession(sessionId, apiKey) {
79
67
  // Try to reconnect to persisted sandbox
80
68
  const storage = getSessionStorage();
81
69
  const persistedSandboxId = storage?.getSandboxId(sessionId);
@@ -86,7 +74,9 @@ export async function getSessionSandbox(apiKey) {
86
74
  });
87
75
  try {
88
76
  const { Sandbox: SandboxClass } = await import("@e2b/code-interpreter");
89
- sandbox = await SandboxClass.connect(persistedSandboxId, { apiKey });
77
+ const sandbox = await SandboxClass.connect(persistedSandboxId, {
78
+ apiKey,
79
+ });
90
80
  logger.info("Successfully reconnected to sandbox", {
91
81
  sessionId,
92
82
  sandboxId: persistedSandboxId,
@@ -131,7 +121,7 @@ export async function getSessionSandbox(apiKey) {
131
121
  config.template = templateId;
132
122
  logger.info("Using custom E2B template", { templateId });
133
123
  }
134
- sandbox = await SandboxClass.create(config);
124
+ const sandbox = await SandboxClass.create(config);
135
125
  logger.info("Created new sandbox", {
136
126
  sessionId,
137
127
  sandboxId: sandbox.sandboxId,
@@ -167,6 +157,45 @@ logging.basicConfig(
167
157
  scheduleCleanup(sessionId);
168
158
  return sandbox;
169
159
  }
160
+ /**
161
+ * Get or create an E2B sandbox for the current session.
162
+ * Sandboxes are session-scoped and reused across tool calls.
163
+ * Attempts to reconnect to persisted sandboxes when resuming sessions.
164
+ *
165
+ * Uses promise caching to prevent race conditions when multiple tools
166
+ * are called in parallel - all concurrent calls will share the same sandbox.
167
+ */
168
+ export async function getSessionSandbox(apiKey) {
169
+ if (!hasSessionContext()) {
170
+ throw new Error("E2B tools require session context");
171
+ }
172
+ const { sessionId } = getSessionContext();
173
+ // Check for existing in-memory sandbox
174
+ const existingSandbox = sessionSandboxes.get(sessionId);
175
+ if (existingSandbox) {
176
+ // Update activity timestamp and reschedule cleanup
177
+ sandboxActivity.set(sessionId, Date.now());
178
+ rescheduleCleanup(sessionId);
179
+ logger.debug("Reusing existing sandbox", { sessionId });
180
+ return existingSandbox;
181
+ }
182
+ // Check for in-flight creation (prevents race condition when tools run in parallel)
183
+ const existingPromise = sandboxCreationPromises.get(sessionId);
184
+ if (existingPromise) {
185
+ logger.debug("Waiting for in-flight sandbox creation", { sessionId });
186
+ return existingPromise;
187
+ }
188
+ // Create new sandbox with promise tracking
189
+ const creationPromise = createSandboxForSession(sessionId, apiKey);
190
+ sandboxCreationPromises.set(sessionId, creationPromise);
191
+ try {
192
+ const sandbox = await creationPromise;
193
+ return sandbox;
194
+ }
195
+ finally {
196
+ sandboxCreationPromises.delete(sessionId);
197
+ }
198
+ }
170
199
  /**
171
200
  * Explicitly destroy a session's sandbox (called on session end).
172
201
  * Also clears the persisted sandboxId from storage.
@@ -50,11 +50,11 @@ export declare function makeTownE2BTools(): readonly [import("langchain").Dynami
50
50
  sandboxPath: string;
51
51
  fileName?: string | undefined;
52
52
  }, string>, import("langchain").DynamicStructuredTool<z.ZodObject<{
53
- document_id: z.ZodString;
53
+ document_ids: z.ZodArray<z.ZodString>;
54
54
  }, z.core.$strip>, {
55
- document_id: string;
55
+ document_ids: string[];
56
56
  }, {
57
- document_id: string;
57
+ document_ids: string[];
58
58
  }, string>, import("langchain").DynamicStructuredTool<z.ZodObject<{
59
59
  prompt: z.ZodString;
60
60
  }, z.core.$strip>, {
@@ -403,8 +403,8 @@ function makeE2BToolsInternal(getSandbox) {
403
403
  shareSandboxFile.prettyName = "Share from Sandbox";
404
404
  // biome-ignore lint/suspicious/noExplicitAny: Need to add custom properties to LangChain tool
405
405
  shareSandboxFile.icon = "Share";
406
- // Tool 6: Upload Library Document to Sandbox
407
- const uploadLibraryDocument = tool(async ({ document_id }) => {
406
+ // Tool 6: Load Library Documents to Sandbox
407
+ const loadLibraryDocuments = tool(async ({ document_ids }) => {
408
408
  const sandbox = await getSandbox();
409
409
  try {
410
410
  const libraryApiUrl = process.env.LIBRARY_API_URL;
@@ -412,14 +412,14 @@ function makeE2BToolsInternal(getSandbox) {
412
412
  if (!libraryApiUrl || !libraryApiKey) {
413
413
  throw new Error("LIBRARY_API_URL and LIBRARY_API_KEY environment variables are required");
414
414
  }
415
- const response = await fetch(`${libraryApiUrl}/sandbox/upload_document_to_sandbox`, {
415
+ const response = await fetch(`${libraryApiUrl}/sandbox/upload_documents_to_sandbox`, {
416
416
  method: "POST",
417
417
  headers: {
418
418
  "Content-Type": "application/json",
419
419
  Authorization: `Bearer ${libraryApiKey}`,
420
420
  },
421
421
  body: JSON.stringify({
422
- document_id,
422
+ document_ids,
423
423
  sandbox_id: sandbox.sandboxId,
424
424
  }),
425
425
  });
@@ -428,34 +428,50 @@ function makeE2BToolsInternal(getSandbox) {
428
428
  throw new Error(`Library API error: ${response.status} - ${text}`);
429
429
  }
430
430
  const result = await response.json();
431
- return result.path;
431
+ // Format the response
432
+ const successfulUploads = result.results
433
+ .filter((r) => r.status === "success")
434
+ .map((r) => r.file_path);
435
+ const failedUploads = result.results
436
+ .filter((r) => r.status === "error")
437
+ .map((r) => `${r.document_id}: ${r.error}`);
438
+ let output = `Status: ${result.status}\n`;
439
+ output += `Uploaded ${result.successful_uploads}/${result.total_requested} documents\n`;
440
+ if (successfulUploads.length > 0) {
441
+ output += `\nSuccessful uploads:\n${successfulUploads.join("\n")}`;
442
+ }
443
+ if (failedUploads.length > 0) {
444
+ output += `\nFailed uploads:\n${failedUploads.join("\n")}`;
445
+ }
446
+ return output;
432
447
  }
433
448
  catch (error) {
434
- logger.error("Error uploading library document to sandbox", {
449
+ logger.error("Error loading library documents to sandbox", {
435
450
  error,
436
- document_id,
451
+ document_ids,
437
452
  });
438
- return `Error uploading document: ${error instanceof Error ? error.message : String(error)}`;
453
+ return `Error loading documents: ${error instanceof Error ? error.message : String(error)}`;
439
454
  }
440
455
  }, {
441
- name: "Sandbox_UploadLibraryDocument",
442
- description: "Upload a document from the library to the cloud sandbox. " +
443
- "Use this to make library documents available for processing in the sandbox environment.",
456
+ name: "Sandbox_LoadLibraryDocuments",
457
+ description: "Load multiple documents from the library to the cloud sandbox. " +
458
+ "Use this to make library documents available for processing in the sandbox environment. " +
459
+ "Supports batch uploads - some documents may succeed while others fail.",
444
460
  schema: z.object({
445
- document_id: z
446
- .string()
447
- .describe("The ID of the document to upload from the library"),
461
+ document_ids: z
462
+ .array(z.string())
463
+ .describe("The IDs of the documents to load from the library"),
448
464
  }),
449
465
  });
450
466
  // biome-ignore lint/suspicious/noExplicitAny: Need to add custom properties to LangChain tool
451
- uploadLibraryDocument.prettyName = "Upload Library Document";
467
+ loadLibraryDocuments.prettyName = "Load Library Documents";
452
468
  // biome-ignore lint/suspicious/noExplicitAny: Need to add custom properties to LangChain tool
453
- uploadLibraryDocument.icon = "Upload";
469
+ loadLibraryDocuments.icon = "FileText";
454
470
  // biome-ignore lint/suspicious/noExplicitAny: Need to add custom properties to LangChain tool
455
- uploadLibraryDocument.verbiage = {
456
- active: "Uploading document {document_id}",
457
- past: "Uploaded document {document_id}",
458
- paramKey: "document_id",
471
+ loadLibraryDocuments.verbiage = {
472
+ active: "Loading {document_ids.length} documents",
473
+ past: "Loaded {document_ids.length} documents",
474
+ paramKey: "document_ids",
459
475
  };
460
476
  // Tool 7: Generate Image in Sandbox
461
477
  const generateImage = tool(async ({ prompt }) => {
@@ -634,7 +650,7 @@ generateImage();
634
650
  readSandboxFile,
635
651
  writeSandboxFile,
636
652
  shareSandboxFile,
637
- uploadLibraryDocument,
653
+ loadLibraryDocuments,
638
654
  generateImage,
639
655
  ];
640
656
  }
@@ -28,6 +28,12 @@ export interface SubagentMessage {
28
28
  content: string;
29
29
  contentBlocks: SubagentContentBlock[];
30
30
  toolCalls: SubagentToolCall[];
31
+ _meta?: {
32
+ semanticName?: string;
33
+ agentDefinitionName?: string;
34
+ currentActivity?: string;
35
+ statusGenerating?: boolean;
36
+ };
31
37
  }
32
38
  /**
33
39
  * Maps query hash to toolCallId.
@@ -2,6 +2,7 @@ import * as crypto from "node:crypto";
2
2
  import * as fs from "node:fs/promises";
3
3
  import { mkdir } from "node:fs/promises";
4
4
  import * as path from "node:path";
5
+ import { ChatAnthropic } from "@langchain/anthropic";
5
6
  import { context, propagation, trace } from "@opentelemetry/api";
6
7
  import { createLogger } from "@townco/core";
7
8
  import { z } from "zod";
@@ -10,6 +11,58 @@ import { makeRunnerFromDefinition } from "../../index.js";
10
11
  import { bindGeneratorToSessionContext, getAbortSignal, } from "../../session-context.js";
11
12
  import { emitSubagentMessages, hashQuery, } from "./subagent-connections.js";
12
13
  const logger = createLogger("subagent-tool", "debug");
14
+ /**
15
+ * Generate status message using Haiku (fast, cheap model)
16
+ */
17
+ async function generateStatusMessage(recentContent, toolCalls) {
18
+ try {
19
+ const activeTool = toolCalls.find((tc) => tc.status === "in_progress");
20
+ const model = new ChatAnthropic({
21
+ modelName: "claude-3-haiku-20240307",
22
+ temperature: 0.3,
23
+ maxTokens: 30,
24
+ });
25
+ const prompt = `Summarize the current activity in 5-7 words for a progress indicator:
26
+
27
+ Recent output: ${recentContent.slice(-500)}
28
+ ${activeTool ? `Active tool: ${activeTool.prettyName || activeTool.title}` : ""}
29
+
30
+ Requirements:
31
+ - Use present continuous tense (e.g., "Searching for...", "Analyzing...")
32
+ - Be specific but concise
33
+ - Focus on user-visible progress
34
+ - Return ONLY the status, no explanation
35
+
36
+ Status:`;
37
+ const response = await model.invoke(prompt);
38
+ const status = response.content.toString().trim().slice(0, 80);
39
+ return status;
40
+ }
41
+ catch (error) {
42
+ logger.warn("Failed to generate status message", { error });
43
+ return extractHeuristicStatus(recentContent, toolCalls);
44
+ }
45
+ }
46
+ /**
47
+ * Heuristic status extraction (fallback when LLM fails)
48
+ */
49
+ function extractHeuristicStatus(content, toolCalls) {
50
+ // Priority 1: Active tool
51
+ const activeTool = toolCalls.find((tc) => tc.status === "in_progress");
52
+ if (activeTool) {
53
+ return `${activeTool.prettyName || activeTool.title}...`;
54
+ }
55
+ // Priority 2: First complete sentence from recent content
56
+ if (content.length > 50) {
57
+ const lastAdded = content.slice(-200);
58
+ const firstSentence = lastAdded.match(/[^.!?]+[.!?]/)?.[0];
59
+ if (firstSentence && firstSentence.length > 10) {
60
+ return firstSentence.trim().slice(0, 80);
61
+ }
62
+ }
63
+ // Priority 3: Generic fallback
64
+ return "Processing...";
65
+ }
13
66
  /**
14
67
  * Helper to derive favicon URL from a domain
15
68
  */
@@ -311,23 +364,26 @@ assistant: "I'm going to use the Task tool to launch the greeting-responder agen
311
364
  .enum(agentNames)
312
365
  .describe("The name of the subagent to use"),
313
366
  query: z.string().describe("The query or task to send to the subagent"),
367
+ taskName: z
368
+ .string()
369
+ .describe("A concise 3-5 word name describing this specific task (e.g., 'Searching for React patterns', 'Analyzing API responses'). IMPORTANT: Be specific and action-oriented!"),
314
370
  }),
315
371
  // Expose subagent configs for metadata extraction by the adapter
316
372
  subagentConfigs,
317
373
  fn: async (input) => {
318
- const { agentName, query } = input;
374
+ const { agentName, query, taskName } = input;
319
375
  const agent = agentMap.get(agentName);
320
376
  if (!agent) {
321
377
  throw new Error(`Unknown agent: ${agentName}`);
322
378
  }
323
- return await querySubagent(agentName, agent.agentPath, agent.agentDir, query);
379
+ return await querySubagent(agentName, agent.agentPath, agent.agentDir, query, taskName);
324
380
  },
325
381
  };
326
382
  }
327
383
  /**
328
384
  * Internal function that runs a subagent in-process and queries it.
329
385
  */
330
- async function querySubagent(agentName, agentPath, agentWorkingDirectory, query) {
386
+ async function querySubagent(agentName, agentPath, agentWorkingDirectory, query, taskName) {
331
387
  // Get the abort signal from context (set by parent agent's cancellation)
332
388
  const parentAbortSignal = getAbortSignal();
333
389
  // Check if already cancelled before starting
@@ -358,6 +414,11 @@ async function querySubagent(agentName, agentPath, agentWorkingDirectory, query)
358
414
  const otelCarrier = {};
359
415
  if (activeSpan) {
360
416
  propagation.inject(trace.setSpan(activeCtx, activeSpan), otelCarrier);
417
+ // Set span attributes for observability
418
+ activeSpan.setAttributes({
419
+ "subagent.semantic_name": taskName,
420
+ "subagent.agent_definition": agentName,
421
+ });
361
422
  }
362
423
  // Create invoke request
363
424
  const invokeRequest = {
@@ -386,10 +447,37 @@ async function querySubagent(agentName, agentPath, agentWorkingDirectory, query)
386
447
  content: "",
387
448
  contentBlocks: [],
388
449
  toolCalls: [],
450
+ _meta: {
451
+ semanticName: taskName,
452
+ agentDefinitionName: agentName,
453
+ statusGenerating: true,
454
+ },
389
455
  };
390
456
  const toolCallMap = new Map();
391
457
  const toolNameMap = new Map(); // Map toolCallId -> toolName
392
458
  const queryHash = hashQuery(query);
459
+ // Emit initial message with semantic name immediately
460
+ emitSubagentMessages(queryHash, [currentMessage]);
461
+ // Track status updates for periodic generation
462
+ let lastStatusUpdate = Date.now();
463
+ let statusUpdateInProgress = false;
464
+ const STATUS_UPDATE_INTERVAL = 5000; // 5 seconds
465
+ // Fire async initial status generation (don't await)
466
+ generateStatusMessage(query, [])
467
+ .then((status) => {
468
+ if (currentMessage._meta) {
469
+ currentMessage._meta.currentActivity = status;
470
+ currentMessage._meta.statusGenerating = false;
471
+ }
472
+ // Emit update with status
473
+ emitSubagentMessages(queryHash, [currentMessage]);
474
+ })
475
+ .catch((error) => {
476
+ logger.warn("Initial status generation failed", { error });
477
+ if (currentMessage._meta) {
478
+ currentMessage._meta.statusGenerating = false;
479
+ }
480
+ });
393
481
  logger.info("[DEBUG] Starting subagent generator loop", {
394
482
  agentName,
395
483
  queryHash,
@@ -507,6 +595,29 @@ async function querySubagent(agentName, agentPath, agentWorkingDirectory, query)
507
595
  }
508
596
  shouldEmit = true; // Emit when sources are added
509
597
  }
598
+ // Periodic status update check
599
+ const now = Date.now();
600
+ const shouldUpdateStatus = !statusUpdateInProgress &&
601
+ now - lastStatusUpdate > STATUS_UPDATE_INTERVAL &&
602
+ currentMessage.content.length > 100; // Only if there's meaningful content
603
+ if (shouldUpdateStatus) {
604
+ statusUpdateInProgress = true;
605
+ lastStatusUpdate = now;
606
+ // Fire async status update (don't await)
607
+ generateStatusMessage(currentMessage.content, currentMessage.toolCalls)
608
+ .then((status) => {
609
+ if (currentMessage._meta) {
610
+ currentMessage._meta.currentActivity = status;
611
+ }
612
+ // Emit update
613
+ emitSubagentMessages(queryHash, [currentMessage]);
614
+ statusUpdateInProgress = false;
615
+ })
616
+ .catch((error) => {
617
+ logger.warn("Status update failed", { error });
618
+ statusUpdateInProgress = false;
619
+ });
620
+ }
510
621
  // Emit incremental update to parent (for live streaming)
511
622
  if (shouldEmit) {
512
623
  logger.debug("[SUBAGENT-ACCUMULATION] Emitting incremental update", {