@townco/agent 0.1.88 → 0.1.101
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.
- package/dist/acp-server/adapter.d.ts +49 -0
- package/dist/acp-server/adapter.js +693 -5
- package/dist/acp-server/http.d.ts +7 -0
- package/dist/acp-server/http.js +53 -6
- package/dist/definition/index.d.ts +29 -0
- package/dist/definition/index.js +24 -0
- package/dist/runner/agent-runner.d.ts +16 -1
- package/dist/runner/agent-runner.js +2 -1
- package/dist/runner/e2b-sandbox-manager.d.ts +18 -0
- package/dist/runner/e2b-sandbox-manager.js +99 -0
- package/dist/runner/hooks/executor.d.ts +3 -1
- package/dist/runner/hooks/executor.js +21 -1
- package/dist/runner/hooks/predefined/compaction-tool.js +67 -2
- package/dist/runner/hooks/types.d.ts +5 -0
- package/dist/runner/index.d.ts +11 -0
- package/dist/runner/langchain/index.d.ts +10 -0
- package/dist/runner/langchain/index.js +227 -7
- package/dist/runner/langchain/model-factory.js +28 -1
- package/dist/runner/langchain/tools/artifacts.js +6 -3
- package/dist/runner/langchain/tools/e2b.d.ts +54 -0
- package/dist/runner/langchain/tools/e2b.js +360 -0
- package/dist/runner/langchain/tools/filesystem.js +63 -0
- package/dist/runner/langchain/tools/subagent.d.ts +8 -0
- package/dist/runner/langchain/tools/subagent.js +76 -4
- package/dist/runner/langchain/tools/web_search.d.ts +36 -14
- package/dist/runner/langchain/tools/web_search.js +33 -2
- package/dist/runner/session-context.d.ts +20 -0
- package/dist/runner/session-context.js +54 -0
- package/dist/runner/tools.d.ts +2 -2
- package/dist/runner/tools.js +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +8 -7
|
@@ -0,0 +1,360 @@
|
|
|
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
|
+
// Tool 6: Upload Library Document to Sandbox
|
|
288
|
+
const uploadLibraryDocument = tool(async ({ document_id }) => {
|
|
289
|
+
const sandbox = await getSandbox();
|
|
290
|
+
try {
|
|
291
|
+
const libraryApiUrl = process.env.LIBRARY_API_URL;
|
|
292
|
+
const libraryApiKey = process.env.LIBRARY_ROOT_API_KEY;
|
|
293
|
+
if (!libraryApiUrl || !libraryApiKey) {
|
|
294
|
+
throw new Error("LIBRARY_API_URL and LIBRARY_ROOT_API_KEY environment variables are required");
|
|
295
|
+
}
|
|
296
|
+
const response = await fetch(`${libraryApiUrl}/sandbox/upload_document_to_sandbox`, {
|
|
297
|
+
method: "POST",
|
|
298
|
+
headers: {
|
|
299
|
+
"Content-Type": "application/json",
|
|
300
|
+
Authorization: `Bearer ${libraryApiKey}`,
|
|
301
|
+
},
|
|
302
|
+
body: JSON.stringify({
|
|
303
|
+
document_id,
|
|
304
|
+
sandbox_id: sandbox.sandboxId,
|
|
305
|
+
}),
|
|
306
|
+
});
|
|
307
|
+
if (!response.ok) {
|
|
308
|
+
const text = await response.text();
|
|
309
|
+
throw new Error(`Library API error: ${response.status} - ${text}`);
|
|
310
|
+
}
|
|
311
|
+
const result = await response.json();
|
|
312
|
+
return result.path;
|
|
313
|
+
}
|
|
314
|
+
catch (error) {
|
|
315
|
+
logger.error("Error uploading library document to sandbox", {
|
|
316
|
+
error,
|
|
317
|
+
document_id,
|
|
318
|
+
});
|
|
319
|
+
return `Error uploading document: ${error instanceof Error ? error.message : String(error)}`;
|
|
320
|
+
}
|
|
321
|
+
}, {
|
|
322
|
+
name: "E2B_UploadLibraryDocument",
|
|
323
|
+
description: "Upload a document from the library to the cloud sandbox. " +
|
|
324
|
+
"Use this to make library documents available for processing in the sandbox environment.",
|
|
325
|
+
schema: z.object({
|
|
326
|
+
document_id: z
|
|
327
|
+
.string()
|
|
328
|
+
.describe("The ID of the document to upload from the library"),
|
|
329
|
+
}),
|
|
330
|
+
});
|
|
331
|
+
// biome-ignore lint/suspicious/noExplicitAny: Need to add custom properties to LangChain tool
|
|
332
|
+
uploadLibraryDocument.prettyName = "Upload Library Document";
|
|
333
|
+
// biome-ignore lint/suspicious/noExplicitAny: Need to add custom properties to LangChain tool
|
|
334
|
+
uploadLibraryDocument.icon = "Upload";
|
|
335
|
+
// biome-ignore lint/suspicious/noExplicitAny: Need to add custom properties to LangChain tool
|
|
336
|
+
uploadLibraryDocument.verbiage = {
|
|
337
|
+
active: "Uploading document {document_id}",
|
|
338
|
+
past: "Uploaded document {document_id}",
|
|
339
|
+
paramKey: "document_id",
|
|
340
|
+
};
|
|
341
|
+
return [
|
|
342
|
+
runCode,
|
|
343
|
+
runBash,
|
|
344
|
+
readSandboxFile,
|
|
345
|
+
writeSandboxFile,
|
|
346
|
+
downloadFromSandbox,
|
|
347
|
+
uploadLibraryDocument,
|
|
348
|
+
];
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Create E2B tools using Town proxy authentication.
|
|
352
|
+
* Fetches E2B API key from Town server using user credentials.
|
|
353
|
+
*/
|
|
354
|
+
export function makeTownE2BTools() {
|
|
355
|
+
const getSandbox = async () => {
|
|
356
|
+
const apiKey = await getTownE2BApiKey();
|
|
357
|
+
return getSessionSandbox(apiKey);
|
|
358
|
+
};
|
|
359
|
+
return makeE2BToolsInternal(getSandbox);
|
|
360
|
+
}
|
|
@@ -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
|
-
//
|
|
461
|
-
|
|
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
|
|
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 |
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
}
|
|
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 |
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
}
|
|
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>, {
|