@townco/agent 0.1.87 → 0.1.98

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 (32) hide show
  1. package/dist/acp-server/adapter.d.ts +49 -0
  2. package/dist/acp-server/adapter.js +693 -5
  3. package/dist/acp-server/http.d.ts +7 -0
  4. package/dist/acp-server/http.js +53 -6
  5. package/dist/definition/index.d.ts +29 -0
  6. package/dist/definition/index.js +24 -0
  7. package/dist/runner/agent-runner.d.ts +16 -1
  8. package/dist/runner/agent-runner.js +2 -1
  9. package/dist/runner/e2b-sandbox-manager.d.ts +18 -0
  10. package/dist/runner/e2b-sandbox-manager.js +99 -0
  11. package/dist/runner/hooks/executor.d.ts +3 -1
  12. package/dist/runner/hooks/executor.js +21 -1
  13. package/dist/runner/hooks/predefined/compaction-tool.js +67 -2
  14. package/dist/runner/hooks/types.d.ts +5 -0
  15. package/dist/runner/index.d.ts +11 -0
  16. package/dist/runner/langchain/index.d.ts +10 -0
  17. package/dist/runner/langchain/index.js +227 -7
  18. package/dist/runner/langchain/model-factory.js +28 -1
  19. package/dist/runner/langchain/tools/artifacts.js +6 -3
  20. package/dist/runner/langchain/tools/e2b.d.ts +48 -0
  21. package/dist/runner/langchain/tools/e2b.js +305 -0
  22. package/dist/runner/langchain/tools/filesystem.js +63 -0
  23. package/dist/runner/langchain/tools/subagent.d.ts +8 -0
  24. package/dist/runner/langchain/tools/subagent.js +76 -4
  25. package/dist/runner/langchain/tools/web_search.d.ts +36 -14
  26. package/dist/runner/langchain/tools/web_search.js +33 -2
  27. package/dist/runner/session-context.d.ts +20 -0
  28. package/dist/runner/session-context.js +54 -0
  29. package/dist/runner/tools.d.ts +2 -2
  30. package/dist/runner/tools.js +1 -0
  31. package/dist/tsconfig.tsbuildinfo +1 -1
  32. package/package.json +8 -7
@@ -0,0 +1,305 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ import { getShedAuth } from "@townco/core/auth";
4
+ import { tool } from "langchain";
5
+ import { z } from "zod";
6
+ import { createLogger } from "../../../logger.js";
7
+ import { getSessionSandbox } from "../../e2b-sandbox-manager";
8
+ import { getSessionContext, getToolOutputDir, hasSessionContext, } from "../../session-context";
9
+ const logger = createLogger("e2b-tools");
10
+ // Cached API key from Town proxy
11
+ let _cachedApiKey = null;
12
+ let _apiKeyFetchPromise = null;
13
+ /**
14
+ * Get E2B API key from Town proxy (with caching).
15
+ */
16
+ async function getTownE2BApiKey() {
17
+ if (_cachedApiKey) {
18
+ return _cachedApiKey;
19
+ }
20
+ // Prevent concurrent fetches
21
+ if (_apiKeyFetchPromise) {
22
+ return _apiKeyFetchPromise;
23
+ }
24
+ _apiKeyFetchPromise = (async () => {
25
+ const shedAuth = getShedAuth();
26
+ if (!shedAuth) {
27
+ throw new Error("Not logged in. Run 'town login' or set SHED_API_KEY to use the town_e2b tools.");
28
+ }
29
+ const response = await fetch(`${shedAuth.shedUrl}/api/e2b/api-key`, {
30
+ method: "POST",
31
+ headers: {
32
+ "Content-Type": "application/json",
33
+ "x-api-key": shedAuth.accessToken,
34
+ },
35
+ });
36
+ if (!response.ok) {
37
+ const text = await response.text();
38
+ throw new Error(`Failed to get E2B API key from Town proxy: ${text}`);
39
+ }
40
+ const { apiKey } = await response.json();
41
+ _cachedApiKey = apiKey;
42
+ return apiKey;
43
+ })();
44
+ try {
45
+ return await _apiKeyFetchPromise;
46
+ }
47
+ finally {
48
+ _apiKeyFetchPromise = null;
49
+ }
50
+ }
51
+ /**
52
+ * Helper to save image artifacts from code execution results.
53
+ */
54
+ async function saveImageArtifact(base64Data, format) {
55
+ if (!hasSessionContext()) {
56
+ return "\n[Image generated but could not be saved - no session context]";
57
+ }
58
+ const { sessionId } = getSessionContext();
59
+ const toolOutputDir = getToolOutputDir("E2B");
60
+ await fs.mkdir(toolOutputDir, { recursive: true });
61
+ const timestamp = Date.now();
62
+ const fileName = `output-${timestamp}.${format}`;
63
+ const filePath = path.join(toolOutputDir, fileName);
64
+ const buffer = Buffer.from(base64Data, "base64");
65
+ await fs.writeFile(filePath, buffer);
66
+ // Generate URL for display
67
+ const port = process.env.PORT || "3100";
68
+ const hostname = process.env.BIND_HOST || "localhost";
69
+ const baseUrl = process.env.AGENT_BASE_URL || `http://${hostname}:${port}`;
70
+ const imageUrl = `${baseUrl}/static/.sessions/${sessionId}/artifacts/tool-E2B/${fileName}`;
71
+ return `\n[Image saved: ${imageUrl}]`;
72
+ }
73
+ function makeE2BToolsInternal(getSandbox) {
74
+ // Tool 1: Run Code (Python or JavaScript)
75
+ const runCode = tool(async ({ code, language = "python" }) => {
76
+ const sandbox = await getSandbox();
77
+ try {
78
+ const result = await sandbox.runCode(code, { language });
79
+ // Format output
80
+ let output = "";
81
+ if (result.logs?.stdout && result.logs.stdout.length > 0) {
82
+ output += result.logs.stdout.join("\n");
83
+ }
84
+ if (result.logs?.stderr && result.logs.stderr.length > 0) {
85
+ if (output)
86
+ output += "\n";
87
+ output += `[stderr]\n${result.logs.stderr.join("\n")}`;
88
+ }
89
+ if (result.error) {
90
+ if (output)
91
+ output += "\n";
92
+ output += `[error] ${result.error.name}: ${result.error.value}`;
93
+ }
94
+ // Handle result value (charts, images, etc.)
95
+ if (result.results && result.results.length > 0) {
96
+ for (const res of result.results) {
97
+ if (res.png) {
98
+ output += await saveImageArtifact(res.png, "png");
99
+ }
100
+ else if (res.jpeg) {
101
+ output += await saveImageArtifact(res.jpeg, "jpeg");
102
+ }
103
+ else if (res.svg) {
104
+ // Save SVG as text file
105
+ const toolOutputDir = getToolOutputDir("E2B");
106
+ await fs.mkdir(toolOutputDir, { recursive: true });
107
+ const fileName = `output-${Date.now()}.svg`;
108
+ const filePath = path.join(toolOutputDir, fileName);
109
+ await fs.writeFile(filePath, res.svg);
110
+ output += `\n[SVG saved: ${filePath}]`;
111
+ }
112
+ else if (res.text) {
113
+ if (output)
114
+ output += "\n";
115
+ output += res.text;
116
+ }
117
+ }
118
+ }
119
+ return output.trim() || "(no output)";
120
+ }
121
+ catch (error) {
122
+ logger.error("Error executing code", { error });
123
+ return `Error executing code: ${error instanceof Error ? error.message : String(error)}`;
124
+ }
125
+ }, {
126
+ name: "E2B_RunCode",
127
+ description: "Execute Python or JavaScript code in a secure cloud sandbox. " +
128
+ "The sandbox persists across calls in the same session, preserving variables and state. " +
129
+ "Supports data analysis, file processing, and visualization. " +
130
+ "Generated images are automatically saved to the session's artifacts directory.",
131
+ schema: z.object({
132
+ code: z.string().describe("The code to execute"),
133
+ language: z
134
+ .enum(["python", "javascript"])
135
+ .optional()
136
+ .default("python")
137
+ .describe("The programming language (default: python)"),
138
+ }),
139
+ });
140
+ // biome-ignore lint/suspicious/noExplicitAny: Need to add custom properties to LangChain tool
141
+ runCode.prettyName = "Run Code";
142
+ // biome-ignore lint/suspicious/noExplicitAny: Need to add custom properties to LangChain tool
143
+ runCode.icon = "Terminal";
144
+ // biome-ignore lint/suspicious/noExplicitAny: Need to add custom properties to LangChain tool
145
+ runCode.verbiage = {
146
+ active: "Executing {language} code",
147
+ past: "Executed {language} code",
148
+ paramKey: "language",
149
+ };
150
+ // Tool 2: Run Bash Command
151
+ const runBash = tool(async ({ command }) => {
152
+ const sandbox = await getSandbox();
153
+ try {
154
+ const result = await sandbox.commands.run(command);
155
+ let output = "";
156
+ if (result.stdout) {
157
+ output += result.stdout;
158
+ }
159
+ if (result.stderr) {
160
+ if (output)
161
+ output += "\n";
162
+ output += `[stderr]\n${result.stderr}`;
163
+ }
164
+ if (result.exitCode !== 0) {
165
+ output += `\n[exit code: ${result.exitCode}]`;
166
+ }
167
+ return output.trim() || "(no output)";
168
+ }
169
+ catch (error) {
170
+ logger.error("Error executing bash command", { error });
171
+ return `Error executing command: ${error instanceof Error ? error.message : String(error)}`;
172
+ }
173
+ }, {
174
+ name: "E2B_RunBash",
175
+ description: "Execute a bash command in the cloud sandbox. " +
176
+ "Use for system operations, package installation, file manipulation, etc. " +
177
+ "The sandbox filesystem persists across calls in the same session.",
178
+ schema: z.object({
179
+ command: z.string().describe("The bash command to execute"),
180
+ }),
181
+ });
182
+ // biome-ignore lint/suspicious/noExplicitAny: Need to add custom properties to LangChain tool
183
+ runBash.prettyName = "Run Bash";
184
+ // biome-ignore lint/suspicious/noExplicitAny: Need to add custom properties to LangChain tool
185
+ runBash.icon = "Terminal";
186
+ // biome-ignore lint/suspicious/noExplicitAny: Need to add custom properties to LangChain tool
187
+ runBash.verbiage = {
188
+ active: "Running: {command}",
189
+ past: "Ran: {command}",
190
+ paramKey: "command",
191
+ };
192
+ // Tool 3: Read File from Sandbox
193
+ const readSandboxFile = tool(async ({ path: filePath }) => {
194
+ const sandbox = await getSandbox();
195
+ try {
196
+ const content = await sandbox.files.read(filePath);
197
+ return content;
198
+ }
199
+ catch (error) {
200
+ logger.error("Error reading file from sandbox", { error, filePath });
201
+ return `Error reading file: ${error instanceof Error ? error.message : String(error)}`;
202
+ }
203
+ }, {
204
+ name: "E2B_ReadFile",
205
+ description: "Read a file from the cloud sandbox filesystem. " +
206
+ "Use to retrieve files created by code execution or bash commands.",
207
+ schema: z.object({
208
+ path: z.string().describe("The path to the file in the sandbox"),
209
+ }),
210
+ });
211
+ // biome-ignore lint/suspicious/noExplicitAny: Need to add custom properties to LangChain tool
212
+ readSandboxFile.prettyName = "Read Sandbox File";
213
+ // biome-ignore lint/suspicious/noExplicitAny: Need to add custom properties to LangChain tool
214
+ readSandboxFile.icon = "FileText";
215
+ // Tool 4: Write File to Sandbox
216
+ const writeSandboxFile = tool(async ({ path: filePath, content }) => {
217
+ const sandbox = await getSandbox();
218
+ try {
219
+ await sandbox.files.write(filePath, content);
220
+ return `Successfully wrote ${content.length} bytes to ${filePath}`;
221
+ }
222
+ catch (error) {
223
+ logger.error("Error writing file to sandbox", { error, filePath });
224
+ return `Error writing file: ${error instanceof Error ? error.message : String(error)}`;
225
+ }
226
+ }, {
227
+ name: "E2B_WriteFile",
228
+ description: "Write content to a file in the cloud sandbox filesystem. " +
229
+ "Use to create data files for code execution or save outputs.",
230
+ schema: z.object({
231
+ path: z.string().describe("The path to the file in the sandbox"),
232
+ content: z.string().describe("The content to write"),
233
+ }),
234
+ });
235
+ // biome-ignore lint/suspicious/noExplicitAny: Need to add custom properties to LangChain tool
236
+ writeSandboxFile.prettyName = "Write Sandbox File";
237
+ // biome-ignore lint/suspicious/noExplicitAny: Need to add custom properties to LangChain tool
238
+ writeSandboxFile.icon = "Edit";
239
+ // Tool 5: Download File from Sandbox to Artifacts
240
+ const downloadFromSandbox = tool(async ({ sandboxPath, fileName }) => {
241
+ if (!hasSessionContext()) {
242
+ throw new Error("E2B_DownloadFile requires session context");
243
+ }
244
+ const sandbox = await getSandbox();
245
+ const toolOutputDir = getToolOutputDir("E2B");
246
+ try {
247
+ // Read file from sandbox
248
+ const content = await sandbox.files.read(sandboxPath);
249
+ // Determine output filename
250
+ const outputFileName = fileName || path.basename(sandboxPath);
251
+ const outputPath = path.join(toolOutputDir, outputFileName);
252
+ // Ensure directory exists
253
+ await fs.mkdir(toolOutputDir, { recursive: true });
254
+ // Write to artifacts
255
+ await fs.writeFile(outputPath, content);
256
+ // Generate URL for display
257
+ const { sessionId } = getSessionContext();
258
+ const port = process.env.PORT || "3100";
259
+ const hostname = process.env.BIND_HOST || "localhost";
260
+ const baseUrl = process.env.AGENT_BASE_URL || `http://${hostname}:${port}`;
261
+ const fileUrl = `${baseUrl}/static/.sessions/${sessionId}/artifacts/tool-E2B/${outputFileName}`;
262
+ return `Downloaded ${sandboxPath} to artifacts.\nURL: ${fileUrl}`;
263
+ }
264
+ catch (error) {
265
+ logger.error("Error downloading file from sandbox", {
266
+ error,
267
+ sandboxPath,
268
+ });
269
+ return `Error downloading file: ${error instanceof Error ? error.message : String(error)}`;
270
+ }
271
+ }, {
272
+ name: "E2B_DownloadFile",
273
+ description: "Download a file from the cloud sandbox to the session's artifacts directory. " +
274
+ "Use to save generated files, plots, or outputs for the user to access.",
275
+ schema: z.object({
276
+ sandboxPath: z.string().describe("Path to the file in the sandbox"),
277
+ fileName: z
278
+ .string()
279
+ .optional()
280
+ .describe("Optional output filename (defaults to original name)"),
281
+ }),
282
+ });
283
+ // biome-ignore lint/suspicious/noExplicitAny: Need to add custom properties to LangChain tool
284
+ downloadFromSandbox.prettyName = "Download from Sandbox";
285
+ // biome-ignore lint/suspicious/noExplicitAny: Need to add custom properties to LangChain tool
286
+ downloadFromSandbox.icon = "Download";
287
+ return [
288
+ runCode,
289
+ runBash,
290
+ readSandboxFile,
291
+ writeSandboxFile,
292
+ downloadFromSandbox,
293
+ ];
294
+ }
295
+ /**
296
+ * Create E2B tools using Town proxy authentication.
297
+ * Fetches E2B API key from Town server using user credentials.
298
+ */
299
+ export function makeTownE2BTools() {
300
+ const getSandbox = async () => {
301
+ const apiKey = await getTownE2BApiKey();
302
+ return getSessionSandbox(apiKey);
303
+ };
304
+ return makeE2BToolsInternal(getSandbox);
305
+ }
@@ -219,6 +219,69 @@ export function makeFilesystemTools() {
219
219
  normalizedPath !== normalizedArtifactsDir) {
220
220
  throw new Error(`Path ${file_path} is outside the allowed artifacts directory`);
221
221
  }
222
+ // Check for binary file extensions before reading
223
+ const ext = path.extname(resolvedPath).toLowerCase();
224
+ const binaryExtensions = new Set([
225
+ // Images
226
+ ".jpg",
227
+ ".jpeg",
228
+ ".png",
229
+ ".gif",
230
+ ".bmp",
231
+ ".webp",
232
+ ".ico",
233
+ ".svg",
234
+ ".tiff",
235
+ ".tif",
236
+ // Audio
237
+ ".mp3",
238
+ ".wav",
239
+ ".ogg",
240
+ ".flac",
241
+ ".aac",
242
+ ".m4a",
243
+ // Video
244
+ ".mp4",
245
+ ".avi",
246
+ ".mov",
247
+ ".mkv",
248
+ ".webm",
249
+ ".wmv",
250
+ // Archives
251
+ ".zip",
252
+ ".tar",
253
+ ".gz",
254
+ ".rar",
255
+ ".7z",
256
+ ".bz2",
257
+ // Documents
258
+ ".pdf",
259
+ ".doc",
260
+ ".docx",
261
+ ".xls",
262
+ ".xlsx",
263
+ ".ppt",
264
+ ".pptx",
265
+ // Executables
266
+ ".exe",
267
+ ".dll",
268
+ ".so",
269
+ ".dylib",
270
+ ".bin",
271
+ // Other binary
272
+ ".wasm",
273
+ ".pyc",
274
+ ".class",
275
+ ".o",
276
+ ".a",
277
+ ]);
278
+ if (binaryExtensions.has(ext)) {
279
+ // Get file size for informative message
280
+ const statCmd = `stat -f%z ${shEscape(resolvedPath)} 2>/dev/null || stat -c%s ${shEscape(resolvedPath)} 2>/dev/null`;
281
+ const { stdout: sizeOut } = await runSandboxed(statCmd, sessionDir);
282
+ const fileSize = sizeOut.toString("utf8").trim();
283
+ return `Cannot read binary file: ${file_path} (${ext} file, ${fileSize} bytes). Binary files like images, audio, video, and archives cannot be read as text. If you need to work with this file, use it by path reference instead.`;
284
+ }
222
285
  // Read the file using sandboxed cat
223
286
  const cmd = `cat ${shEscape(resolvedPath)}`;
224
287
  const { stdout, stderr, code } = await runSandboxed(cmd, sessionDir);
@@ -1,4 +1,12 @@
1
+ import { type CitationSource } from "../../../acp-server/adapter.js";
1
2
  import type { DirectTool } from "../../tools.js";
3
+ /**
4
+ * Result returned from a subagent, including text and any citation sources.
5
+ */
6
+ export interface SubagentResult {
7
+ text: string;
8
+ sources: CitationSource[];
9
+ }
2
10
  /**
3
11
  * Name of the Task tool created by makeSubagentsTool
4
12
  */
@@ -5,7 +5,8 @@ import { PROTOCOL_VERSION } from "@agentclientprotocol/sdk";
5
5
  import { context, propagation, trace } from "@opentelemetry/api";
6
6
  import { createLogger as coreCreateLogger } from "@townco/core";
7
7
  import { z } from "zod";
8
- import { SUBAGENT_MODE_KEY } from "../../../acp-server/adapter.js";
8
+ import { SUBAGENT_MODE_KEY, } from "../../../acp-server/adapter.js";
9
+ import { getAbortSignal } from "../../session-context.js";
9
10
  import { findAvailablePort } from "./port-utils.js";
10
11
  import { emitSubagentConnection, emitSubagentMessages, hashQuery, } from "./subagent-connections.js";
11
12
  /**
@@ -183,6 +184,12 @@ assistant: "I'm going to use the Task tool to launch the greeting-responder agen
183
184
  * Internal function that spawns a subagent HTTP server and queries it.
184
185
  */
185
186
  async function querySubagent(agentName, agentPath, agentWorkingDirectory, query) {
187
+ // Get the abort signal from context (set by parent agent's cancellation)
188
+ const parentAbortSignal = getAbortSignal();
189
+ // Check if already cancelled before starting
190
+ if (parentAbortSignal?.aborted) {
191
+ throw new Error("Subagent query cancelled before starting");
192
+ }
186
193
  // Validate that the agent exists
187
194
  try {
188
195
  await fs.access(agentPath);
@@ -197,6 +204,24 @@ async function querySubagent(agentName, agentPath, agentWorkingDirectory, query)
197
204
  const logger = coreCreateLogger(`subagent:${port}:${agentName}`);
198
205
  let agentProcess = null;
199
206
  let sseAbortController = null;
207
+ let cleanupTriggered = false;
208
+ // Cleanup function to kill the process and abort SSE
209
+ const cleanup = () => {
210
+ if (cleanupTriggered)
211
+ return;
212
+ cleanupTriggered = true;
213
+ logger.info(`Cleaning up subagent on port ${port} (cancelled by parent)`);
214
+ if (sseAbortController) {
215
+ sseAbortController.abort();
216
+ }
217
+ if (agentProcess) {
218
+ agentProcess.kill("SIGTERM");
219
+ }
220
+ };
221
+ // Listen for parent abort signal
222
+ if (parentAbortSignal) {
223
+ parentAbortSignal.addEventListener("abort", cleanup, { once: true });
224
+ }
200
225
  try {
201
226
  // Get the parent's logs directory to pass to the subagent
202
227
  const parentLogsDir = process.env.TOWN_LOGS_DIR || path.join(process.cwd(), ".logs");
@@ -317,6 +342,8 @@ async function querySubagent(agentName, agentPath, agentWorkingDirectory, query)
317
342
  // Step 3: Connect to SSE for receiving streaming responses
318
343
  sseAbortController = new AbortController();
319
344
  let responseText = "";
345
+ // Track citation sources from subagent's web searches/fetches
346
+ const collectedSources = [];
320
347
  // Track full message structure for session storage
321
348
  const currentMessage = {
322
349
  id: `subagent-${Date.now()}`,
@@ -411,6 +438,26 @@ async function querySubagent(agentName, agentPath, agentWorkingDirectory, query)
411
438
  }
412
439
  }
413
440
  }
441
+ // Handle sources - collect citation sources from subagent's web searches
442
+ if (update.sessionUpdate === "sources" &&
443
+ Array.isArray(update.sources)) {
444
+ for (const source of update.sources) {
445
+ const citationSource = {
446
+ id: source.id,
447
+ url: source.url,
448
+ title: source.title,
449
+ toolCallId: source.toolCallId,
450
+ };
451
+ if (source.snippet)
452
+ citationSource.snippet = source.snippet;
453
+ if (source.favicon)
454
+ citationSource.favicon = source.favicon;
455
+ if (source.sourceName)
456
+ citationSource.sourceName = source.sourceName;
457
+ collectedSources.push(citationSource);
458
+ }
459
+ logger.info(`Collected ${update.sources.length} sources from subagent`);
460
+ }
414
461
  }
415
462
  catch {
416
463
  // Ignore malformed SSE data
@@ -457,8 +504,26 @@ async function querySubagent(agentName, agentPath, agentWorkingDirectory, query)
457
504
  reject(new Error(`Subagent query timed out after ${timeoutMs / 1000} seconds`));
458
505
  }, timeoutMs);
459
506
  });
460
- // Wait for prompt to complete with timeout
461
- await Promise.race([promptPromise, timeoutPromise, processErrorPromise]);
507
+ // Create cancellation promise that rejects when parent aborts
508
+ const cancellationPromise = parentAbortSignal
509
+ ? new Promise((_, reject) => {
510
+ if (parentAbortSignal.aborted) {
511
+ reject(new Error("Subagent query cancelled"));
512
+ }
513
+ parentAbortSignal.addEventListener("abort", () => reject(new Error("Subagent query cancelled")), { once: true });
514
+ })
515
+ : new Promise(() => { }); // Never resolves if no signal
516
+ // Wait for prompt to complete with timeout or cancellation
517
+ await Promise.race([
518
+ promptPromise,
519
+ timeoutPromise,
520
+ processErrorPromise,
521
+ cancellationPromise,
522
+ ]);
523
+ // Check if cancelled before processing results
524
+ if (parentAbortSignal?.aborted) {
525
+ throw new Error("Subagent query cancelled");
526
+ }
462
527
  // Give SSE a moment to flush remaining messages
463
528
  await new Promise((r) => setTimeout(r, 100));
464
529
  // Abort SSE connection
@@ -472,9 +537,16 @@ async function querySubagent(agentName, agentPath, agentWorkingDirectory, query)
472
537
  if (currentMessage.content || currentMessage.toolCalls.length > 0) {
473
538
  emitSubagentMessages(queryHash, [currentMessage]);
474
539
  }
475
- return responseText;
540
+ return {
541
+ text: responseText,
542
+ sources: collectedSources,
543
+ };
476
544
  }
477
545
  finally {
546
+ // Remove the abort listener to prevent memory leaks
547
+ if (parentAbortSignal) {
548
+ parentAbortSignal.removeEventListener("abort", cleanup);
549
+ }
478
550
  // Cleanup: abort SSE and kill process
479
551
  logger.info(`Shutting down subagent on port ${port}`);
480
552
  if (sseAbortController) {
@@ -1,4 +1,8 @@
1
1
  import { z } from "zod";
2
+ /** Reset the web search citation counter (call at start of each session) */
3
+ export declare function resetWebSearchCitationCounter(): void;
4
+ /** Get the current citation counter value */
5
+ export declare function getWebSearchCitationCounter(): number;
2
6
  /** Create web search tools using direct EXA_API_KEY */
3
7
  export declare function makeWebSearchTools(): readonly [import("langchain").DynamicStructuredTool<z.ZodObject<{
4
8
  query: z.ZodString;
@@ -6,13 +10,22 @@ export declare function makeWebSearchTools(): readonly [import("langchain").Dyna
6
10
  query: string;
7
11
  }, {
8
12
  query: string;
9
- }, string | import("exa-js").SearchResponse<{
10
- numResults: number;
11
- type: "auto";
12
- text: {
13
- maxCharacters: number;
14
- };
15
- }>>, import("langchain").DynamicStructuredTool<z.ZodObject<{
13
+ }, string | {
14
+ results: import("exa-js").SearchResult<{
15
+ numResults: number;
16
+ type: "auto";
17
+ text: {
18
+ maxCharacters: number;
19
+ };
20
+ }>[];
21
+ formattedForCitation: {
22
+ citationId: number;
23
+ url: string;
24
+ title: string | null;
25
+ text: string;
26
+ citationInstruction: string;
27
+ }[];
28
+ }>, import("langchain").DynamicStructuredTool<z.ZodObject<{
16
29
  url: z.ZodString;
17
30
  prompt: z.ZodString;
18
31
  }, z.core.$strip>, {
@@ -29,13 +42,22 @@ export declare function makeTownWebSearchTools(): readonly [import("langchain").
29
42
  query: string;
30
43
  }, {
31
44
  query: string;
32
- }, string | import("exa-js").SearchResponse<{
33
- numResults: number;
34
- type: "auto";
35
- text: {
36
- maxCharacters: number;
37
- };
38
- }>>, import("langchain").DynamicStructuredTool<z.ZodObject<{
45
+ }, string | {
46
+ results: import("exa-js").SearchResult<{
47
+ numResults: number;
48
+ type: "auto";
49
+ text: {
50
+ maxCharacters: number;
51
+ };
52
+ }>[];
53
+ formattedForCitation: {
54
+ citationId: number;
55
+ url: string;
56
+ title: string | null;
57
+ text: string;
58
+ citationInstruction: string;
59
+ }[];
60
+ }>, import("langchain").DynamicStructuredTool<z.ZodObject<{
39
61
  url: z.ZodString;
40
62
  prompt: z.ZodString;
41
63
  }, z.core.$strip>, {
@@ -30,6 +30,17 @@ function getTownExaClient() {
30
30
  _townExaClient = new Exa(shedAuth.accessToken, `${shedAuth.shedUrl}/api/exa`);
31
31
  return _townExaClient;
32
32
  }
33
+ // Track citation counter for web search results
34
+ // This is reset at the start of each session via resetWebSearchCitationCounter()
35
+ let webSearchCitationCounter = 0;
36
+ /** Reset the web search citation counter (call at start of each session) */
37
+ export function resetWebSearchCitationCounter() {
38
+ webSearchCitationCounter = 0;
39
+ }
40
+ /** Get the current citation counter value */
41
+ export function getWebSearchCitationCounter() {
42
+ return webSearchCitationCounter;
43
+ }
33
44
  function makeWebSearchToolsInternal(getClient) {
34
45
  const webSearch = tool(async ({ query }) => {
35
46
  const client = getClient();
@@ -43,7 +54,25 @@ function makeWebSearchToolsInternal(getClient) {
43
54
  if (!result.results || result.results.length === 0) {
44
55
  return `No results found for query: ${query}`;
45
56
  }
46
- return result;
57
+ // Format results with citation numbers so the LLM can reference them
58
+ // The adapter will extract sources using the same numbering
59
+ const formattedResults = result.results.map((r) => {
60
+ webSearchCitationCounter++;
61
+ return {
62
+ citationId: webSearchCitationCounter,
63
+ url: r.url,
64
+ title: r.title,
65
+ text: r.text,
66
+ };
67
+ });
68
+ // Return both the formatted results (for LLM) and raw results (for adapter extraction)
69
+ return {
70
+ results: result.results, // Raw results for adapter source extraction
71
+ formattedForCitation: formattedResults.map((r) => ({
72
+ ...r,
73
+ citationInstruction: `Use [[${r.citationId}]] when citing this source`,
74
+ })),
75
+ };
47
76
  }, {
48
77
  name: "WebSearch",
49
78
  description: "\n- Allows you to search the web and use the results to inform responses\n" +
@@ -51,11 +80,13 @@ function makeWebSearchToolsInternal(getClient) {
51
80
  "- Returns search result information formatted as search result blocks\n" +
52
81
  "- Use this tool for accessing information beyond your knowledge cutoff\n" +
53
82
  "- Searches are performed automatically within a single API call\n" +
83
+ "- Results include citation IDs - use [[N]] format to cite sources in your response\n" +
54
84
  "\n" +
55
85
  "Usage notes:\n" +
56
86
  " - Domain filtering is supported to include or block specific websites\n" +
57
87
  " - Web search is only available in the US\n" +
58
- ' - Account for "Today\'s date" in <env>. For example, if <env> says "Today\'s date: 2025-07-01", and the user wants the latest docs, do not use 2024 in the search query. Use 2025.\n',
88
+ ' - Account for "Today\'s date" in <env>. For example, if <env> says "Today\'s date: 2025-07-01", and the user wants the latest docs, do not use 2024 in the search query. Use 2025.\n' +
89
+ " - IMPORTANT: When using information from search results, cite the source using [[N]] format where N is the citationId\n",
59
90
  schema: z.object({
60
91
  query: z.string().describe("The search query to use"),
61
92
  }),
@@ -10,6 +10,26 @@ export interface SessionContext {
10
10
  /** Artifacts directory: <agentDir>/.sessions/<sessionId>/artifacts */
11
11
  artifactsDir: string;
12
12
  }
13
+ /**
14
+ * Run a function with an abort signal available via AsyncLocalStorage.
15
+ * Tools can access the signal using getAbortSignal().
16
+ */
17
+ export declare function runWithAbortSignal<T>(signal: AbortSignal, fn: () => T): T;
18
+ /**
19
+ * Bind an async generator to an abort signal context so that every iteration
20
+ * runs with the abort signal available.
21
+ */
22
+ export declare function bindGeneratorToAbortSignal<T, R, N = unknown>(signal: AbortSignal, generator: AsyncGenerator<T, R, N>): AsyncGenerator<T, R, N>;
23
+ /**
24
+ * Get the current abort signal.
25
+ * Returns undefined if called outside of an abort signal context.
26
+ */
27
+ export declare function getAbortSignal(): AbortSignal | undefined;
28
+ /**
29
+ * Check if the current operation has been aborted.
30
+ * Returns false if no abort signal is available.
31
+ */
32
+ export declare function isAborted(): boolean;
13
33
  /**
14
34
  * Run a function with session context available via AsyncLocalStorage.
15
35
  * Tools can access the context using getSessionContext().