@townco/agent 0.1.140 → 0.1.142

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.
@@ -9,14 +9,22 @@ import type { Sandbox } from "@e2b/code-interpreter";
9
9
  */
10
10
  export declare function getSessionSandbox(apiKey: string): Promise<Sandbox>;
11
11
  /**
12
- * Explicitly destroy a session's sandbox (called on session end).
13
- * Also clears the persisted sandboxId from storage.
12
+ * Pause a session's sandbox (called on session end or cleanup).
13
+ * The sandbox can be resumed later using the persisted sandboxId.
14
+ * State is preserved for up to 30 days.
14
15
  */
15
16
  export declare function destroySessionSandbox(sessionId: string): Promise<void>;
16
17
  /**
17
18
  * Check if a session has an active sandbox.
18
19
  */
19
20
  export declare function hasSessionSandbox(sessionId: string): boolean;
21
+ /**
22
+ * Clear the in-memory sandbox reference for a session.
23
+ * Call this when a sandbox operation fails due to the sandbox being
24
+ * paused/expired (e.g., "sandbox is probably not running anymore").
25
+ * The next call to getSessionSandbox() will attempt to reconnect.
26
+ */
27
+ export declare function clearStaleSandbox(sessionId: string): void;
20
28
  /**
21
29
  * Get an existing sandbox by sessionId without creating a new one.
22
30
  * Returns undefined if no sandbox exists for this session.
@@ -10,8 +10,8 @@ const sandboxActivity = new Map();
10
10
  const cleanupTimeouts = new Map();
11
11
  // Map sessionId -> Promise<Sandbox> for in-flight creations (prevents race condition)
12
12
  const sandboxCreationPromises = new Map();
13
- // Sandbox timeout in milliseconds (default: 15 minutes)
14
- const SANDBOX_TIMEOUT_MS = 15 * 60 * 1000;
13
+ // Sandbox timeout in milliseconds (default: 24 hours)
14
+ const SANDBOX_TIMEOUT_MS = 24 * 60 * 60 * 1000;
15
15
  /**
16
16
  * Collect environment variables that should be passed to E2B sandbox
17
17
  * for tool usage (image generation, etc.)
@@ -76,6 +76,7 @@ async function createSandboxForSession(sessionId, apiKey) {
76
76
  const { Sandbox: SandboxClass } = await import("@e2b/code-interpreter");
77
77
  const sandbox = await SandboxClass.connect(persistedSandboxId, {
78
78
  apiKey,
79
+ timeoutMs: SANDBOX_TIMEOUT_MS,
79
80
  });
80
81
  logger.info("Successfully reconnected to sandbox", {
81
82
  sessionId,
@@ -121,7 +122,11 @@ async function createSandboxForSession(sessionId, apiKey) {
121
122
  config.template = templateId;
122
123
  logger.info("Using custom E2B template", { templateId });
123
124
  }
124
- const sandbox = await SandboxClass.create(config);
125
+ const sandbox = await SandboxClass.betaCreate({
126
+ ...config,
127
+ autoPause: true,
128
+ timeoutMs: SANDBOX_TIMEOUT_MS,
129
+ });
125
130
  logger.info("Created new sandbox", {
126
131
  sessionId,
127
132
  sandboxId: sandbox.sandboxId,
@@ -197,18 +202,23 @@ export async function getSessionSandbox(apiKey) {
197
202
  }
198
203
  }
199
204
  /**
200
- * Explicitly destroy a session's sandbox (called on session end).
201
- * Also clears the persisted sandboxId from storage.
205
+ * Pause a session's sandbox (called on session end or cleanup).
206
+ * The sandbox can be resumed later using the persisted sandboxId.
207
+ * State is preserved for up to 30 days.
202
208
  */
203
209
  export async function destroySessionSandbox(sessionId) {
204
210
  const sandbox = sessionSandboxes.get(sessionId);
205
211
  if (sandbox) {
206
- logger.info("Destroying sandbox", { sessionId });
212
+ logger.info("Pausing sandbox", { sessionId, sandboxId: sandbox.sandboxId });
207
213
  try {
208
- await sandbox.kill();
214
+ await sandbox.betaPause();
215
+ logger.info("Sandbox paused successfully", {
216
+ sessionId,
217
+ sandboxId: sandbox.sandboxId,
218
+ });
209
219
  }
210
220
  catch (error) {
211
- logger.error("Error killing sandbox", { sessionId, error });
221
+ logger.error("Error pausing sandbox", { sessionId, error });
212
222
  }
213
223
  sessionSandboxes.delete(sessionId);
214
224
  sandboxActivity.delete(sessionId);
@@ -218,22 +228,7 @@ export async function destroySessionSandbox(sessionId) {
218
228
  clearTimeout(timeout);
219
229
  cleanupTimeouts.delete(sessionId);
220
230
  }
221
- // Clear persisted sandboxId from storage
222
- // We do this without requiring session context since this can be called
223
- // from HTTP endpoints that don't have session context
224
- try {
225
- // Try to get storage if we have context, but don't fail if we don't
226
- if (hasSessionContext()) {
227
- const storage = getSessionStorage();
228
- if (storage) {
229
- await storage.updateSandboxId(sessionId, undefined);
230
- logger.debug("Cleared persisted sandboxId", { sessionId });
231
- }
232
- }
233
- }
234
- catch (error) {
235
- logger.warn("Failed to clear persisted sandboxId", { sessionId, error });
236
- }
231
+ // Note: We intentionally keep the sandboxId in storage so we can resume later
237
232
  }
238
233
  }
239
234
  /**
@@ -267,6 +262,22 @@ function rescheduleCleanup(sessionId) {
267
262
  export function hasSessionSandbox(sessionId) {
268
263
  return sessionSandboxes.has(sessionId);
269
264
  }
265
+ /**
266
+ * Clear the in-memory sandbox reference for a session.
267
+ * Call this when a sandbox operation fails due to the sandbox being
268
+ * paused/expired (e.g., "sandbox is probably not running anymore").
269
+ * The next call to getSessionSandbox() will attempt to reconnect.
270
+ */
271
+ export function clearStaleSandbox(sessionId) {
272
+ logger.info("Clearing stale sandbox reference", { sessionId });
273
+ sessionSandboxes.delete(sessionId);
274
+ sandboxActivity.delete(sessionId);
275
+ const timeout = cleanupTimeouts.get(sessionId);
276
+ if (timeout) {
277
+ clearTimeout(timeout);
278
+ cleanupTimeouts.delete(sessionId);
279
+ }
280
+ }
270
281
  /**
271
282
  * Get an existing sandbox by sessionId without creating a new one.
272
283
  * Returns undefined if no sandbox exists for this session.
@@ -4,7 +4,7 @@ import { getShedAuth } from "@townco/core/auth";
4
4
  import { tool } from "langchain";
5
5
  import { z } from "zod";
6
6
  import { createLogger } from "../../../logger.js";
7
- import { getSessionSandbox } from "../../e2b-sandbox-manager";
7
+ import { clearStaleSandbox, getSessionSandbox, } from "../../e2b-sandbox-manager";
8
8
  import { getEmitUpdate, getSessionContext, getToolOutputDir, hasSessionContext, } from "../../session-context";
9
9
  const logger = createLogger("e2b-tools");
10
10
  // Cached API key from Town proxy
@@ -48,6 +48,45 @@ export async function getTownE2BApiKey() {
48
48
  _apiKeyFetchPromise = null;
49
49
  }
50
50
  }
51
+ /**
52
+ * Check if an error indicates the sandbox is stale (paused/expired).
53
+ * E2B throws this when the sandbox was auto-paused or timed out.
54
+ */
55
+ function isStaleSandboxError(error) {
56
+ if (error instanceof Error) {
57
+ const msg = error.message.toLowerCase();
58
+ return (msg.includes("sandbox is probably not running") ||
59
+ msg.includes("sandbox not found") ||
60
+ msg.includes("not running anymore"));
61
+ }
62
+ return false;
63
+ }
64
+ /**
65
+ * Execute a sandbox operation with automatic retry on stale sandbox errors.
66
+ * If the sandbox was auto-paused by E2B, this clears the stale reference
67
+ * and reconnects before retrying.
68
+ */
69
+ async function withSandboxRetry(getSandbox, operation) {
70
+ let sandbox = await getSandbox();
71
+ try {
72
+ return await operation(sandbox);
73
+ }
74
+ catch (error) {
75
+ if (isStaleSandboxError(error)) {
76
+ logger.info("Sandbox appears stale (auto-paused), clearing and reconnecting...");
77
+ // Clear the stale sandbox reference
78
+ if (hasSessionContext()) {
79
+ const { sessionId } = getSessionContext();
80
+ clearStaleSandbox(sessionId);
81
+ }
82
+ // Get a fresh sandbox (will reconnect/resume the paused sandbox)
83
+ sandbox = await getSandbox();
84
+ // Retry the operation
85
+ return await operation(sandbox);
86
+ }
87
+ throw error;
88
+ }
89
+ }
51
90
  /**
52
91
  * Helper to save image artifacts from code execution results.
53
92
  */
@@ -73,9 +112,8 @@ async function saveImageArtifact(base64Data, format) {
73
112
  function makeE2BToolsInternal(getSandbox) {
74
113
  // Tool 1: Run Code (Python or JavaScript)
75
114
  const runCode = tool(async ({ code, language = "python" }) => {
76
- const sandbox = await getSandbox();
77
115
  try {
78
- const result = await sandbox.runCode(code, { language });
116
+ const result = await withSandboxRetry(getSandbox, (sandbox) => sandbox.runCode(code, { language }));
79
117
  // Format output
80
118
  let output = "";
81
119
  if (result.logs?.stdout && result.logs.stdout.length > 0) {
@@ -174,9 +212,8 @@ function makeE2BToolsInternal(getSandbox) {
174
212
  };
175
213
  // Tool 2: Run Bash Command
176
214
  const runBash = tool(async ({ command }) => {
177
- const sandbox = await getSandbox();
178
215
  try {
179
- const result = await sandbox.commands.run(command);
216
+ const result = await withSandboxRetry(getSandbox, (sandbox) => sandbox.commands.run(command));
180
217
  let output = "";
181
218
  if (result.stdout) {
182
219
  output += result.stdout;
@@ -244,9 +281,8 @@ function makeE2BToolsInternal(getSandbox) {
244
281
  };
245
282
  // Tool 3: Read File from Sandbox
246
283
  const readSandboxFile = tool(async ({ path: filePath }) => {
247
- const sandbox = await getSandbox();
248
284
  try {
249
- const content = await sandbox.files.read(filePath);
285
+ const content = await withSandboxRetry(getSandbox, (sandbox) => sandbox.files.read(filePath));
250
286
  return content;
251
287
  }
252
288
  catch (error) {
@@ -267,9 +303,8 @@ function makeE2BToolsInternal(getSandbox) {
267
303
  readSandboxFile.icon = "FileText";
268
304
  // Tool 4: Write File to Sandbox
269
305
  const writeSandboxFile = tool(async ({ path: filePath, content }) => {
270
- const sandbox = await getSandbox();
271
306
  try {
272
- await sandbox.files.write(filePath, content);
307
+ await withSandboxRetry(getSandbox, (sandbox) => sandbox.files.write(filePath, content));
273
308
  // Emit file change notification
274
309
  const emitUpdate = getEmitUpdate();
275
310
  if (emitUpdate) {
@@ -312,13 +347,12 @@ function makeE2BToolsInternal(getSandbox) {
312
347
  if (!hasSessionContext()) {
313
348
  throw new Error("Sandbox_Share requires session context");
314
349
  }
315
- const sandbox = await getSandbox();
316
350
  const { sessionId } = getSessionContext();
317
351
  const toolOutputDir = getToolOutputDir("E2B");
318
352
  try {
319
353
  // Step 1: Download from sandbox to local artifacts
320
354
  // Use base64 encoding to safely transfer binary data
321
- const result = await sandbox.commands.run(`base64 ${sandboxPath}`);
355
+ const result = await withSandboxRetry(getSandbox, (sandbox) => sandbox.commands.run(`base64 ${sandboxPath}`));
322
356
  if (result.exitCode !== 0) {
323
357
  throw new Error(`Failed to read file: ${result.stderr}`);
324
358
  }
@@ -405,13 +439,19 @@ function makeE2BToolsInternal(getSandbox) {
405
439
  shareSandboxFile.icon = "Share";
406
440
  // Tool 6: Load Library Documents to Sandbox
407
441
  const loadLibraryDocuments = tool(async ({ document_ids }) => {
408
- const sandbox = await getSandbox();
409
442
  try {
410
443
  const libraryApiUrl = process.env.LIBRARY_API_URL;
411
444
  const libraryApiKey = process.env.LIBRARY_API_KEY;
412
445
  if (!libraryApiUrl || !libraryApiKey) {
413
446
  throw new Error("LIBRARY_API_URL and LIBRARY_API_KEY environment variables are required");
414
447
  }
448
+ // Get sandbox (with retry in case it was auto-paused) to ensure it's active
449
+ // The library API needs the sandbox to be running to upload files
450
+ const sandbox = await withSandboxRetry(getSandbox, async (s) => {
451
+ // Run a simple command to verify the sandbox is actually running
452
+ await s.commands.run("true");
453
+ return s;
454
+ });
415
455
  const response = await fetch(`${libraryApiUrl}/sandbox/upload_documents_to_sandbox`, {
416
456
  method: "POST",
417
457
  headers: {
@@ -475,7 +515,6 @@ function makeE2BToolsInternal(getSandbox) {
475
515
  };
476
516
  // Tool 7: Generate Image in Sandbox
477
517
  const generateImage = tool(async ({ prompt }) => {
478
- const sandbox = await getSandbox();
479
518
  try {
480
519
  // JavaScript script to call Gemini API using @google/genai
481
520
  const escapedPrompt = prompt
@@ -566,10 +605,13 @@ async function generateImage() {
566
605
 
567
606
  generateImage();
568
607
  `;
569
- await sandbox.files.write("/home/user/gen_img.js", script);
570
- // Install @google/genai if not already installed (should be pre-installed in template)
571
- await sandbox.commands.run("cd /home/user && npm install @google/genai");
572
- const result = await sandbox.commands.run("cd /home/user && node gen_img.js");
608
+ // Run all sandbox operations with retry support
609
+ const result = await withSandboxRetry(getSandbox, async (sandbox) => {
610
+ await sandbox.files.write("/home/user/gen_img.js", script);
611
+ // Install @google/genai if not already installed (should be pre-installed in template)
612
+ await sandbox.commands.run("cd /home/user && npm install @google/genai");
613
+ return sandbox.commands.run("cd /home/user && node gen_img.js");
614
+ });
573
615
  logger.info("Image generation command result", {
574
616
  exitCode: result.exitCode,
575
617
  stdout: result.stdout,
@@ -110,6 +110,31 @@ function getSourceName(url) {
110
110
  */
111
111
  function extractSourcesFromToolOutput(toolName, rawOutput, toolCallId, sourceCounter) {
112
112
  const sources = [];
113
+ // Check for pre-extracted sources from runner (extracted before compaction)
114
+ // These preserve URLs that may be stripped during LLM compaction
115
+ if (rawOutput._compactionMeta &&
116
+ typeof rawOutput._compactionMeta === "object") {
117
+ const meta = rawOutput._compactionMeta;
118
+ if (meta.preExtractedSources && meta.preExtractedSources.length > 0) {
119
+ logger.info("Using pre-extracted sources from runner", {
120
+ toolName,
121
+ toolCallId,
122
+ sourcesCount: meta.preExtractedSources.length,
123
+ });
124
+ for (const s of meta.preExtractedSources) {
125
+ sources.push({
126
+ id: s.id,
127
+ url: s.url,
128
+ title: s.title,
129
+ toolCallId,
130
+ ...(s.snippet && { snippet: s.snippet }),
131
+ ...(s.favicon && { favicon: s.favicon }),
132
+ ...(s.sourceName && { sourceName: s.sourceName }),
133
+ });
134
+ }
135
+ return sources;
136
+ }
137
+ }
113
138
  // Parse the actual output from the wrapper
114
139
  // The runner wraps results as { content: JSON.stringify(actualResult) }
115
140
  let actualOutput = rawOutput;