@townco/agent 0.1.81 → 0.1.83

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 (31) hide show
  1. package/dist/acp-server/adapter.js +12 -12
  2. package/dist/acp-server/http.js +1 -1
  3. package/dist/acp-server/session-storage.d.ts +13 -6
  4. package/dist/acp-server/session-storage.js +94 -59
  5. package/dist/runner/agent-runner.d.ts +3 -1
  6. package/dist/runner/hooks/executor.js +1 -1
  7. package/dist/runner/hooks/predefined/compaction-tool.js +31 -8
  8. package/dist/runner/hooks/predefined/tool-response-compactor.js +2 -2
  9. package/dist/runner/langchain/index.d.ts +1 -0
  10. package/dist/runner/langchain/index.js +151 -27
  11. package/dist/runner/langchain/tools/artifacts.d.ts +68 -0
  12. package/dist/runner/langchain/tools/artifacts.js +469 -0
  13. package/dist/runner/langchain/tools/browser.js +15 -3
  14. package/dist/runner/langchain/tools/filesystem.d.ts +8 -4
  15. package/dist/runner/langchain/tools/filesystem.js +118 -82
  16. package/dist/runner/langchain/tools/generate_image.d.ts +19 -0
  17. package/dist/runner/langchain/tools/generate_image.js +54 -14
  18. package/dist/runner/langchain/tools/subagent.js +2 -2
  19. package/dist/runner/langchain/tools/todo.js +3 -0
  20. package/dist/runner/langchain/tools/web_search.js +6 -0
  21. package/dist/runner/session-context.d.ts +40 -0
  22. package/dist/runner/session-context.js +69 -0
  23. package/dist/runner/tools.d.ts +2 -2
  24. package/dist/runner/tools.js +2 -0
  25. package/dist/scaffold/project-scaffold.js +7 -3
  26. package/dist/telemetry/setup.js +1 -1
  27. package/dist/tsconfig.tsbuildinfo +1 -1
  28. package/dist/utils/context-size-calculator.d.ts +1 -2
  29. package/dist/utils/context-size-calculator.js +2 -6
  30. package/dist/utils/token-counter.js +2 -2
  31. package/package.json +10 -10
@@ -5,32 +5,7 @@ import * as path from "node:path";
5
5
  import { SandboxManager, } from "@anthropic-ai/sandbox-runtime";
6
6
  import { tool } from "langchain";
7
7
  import { z } from "zod";
8
- /**
9
- * Lazily initialize Sandbox Runtime with write access limited to workingDirectory.
10
- * Read access defaults to Sandbox Runtime's defaults (read allowed everywhere),
11
- * but commands run with cwd=workingDirectory, so rg/reads are scoped naturally.
12
- */
13
- let initialized = false;
14
- async function ensureSandbox(workingDirectory) {
15
- if (initialized)
16
- return;
17
- const cfg = {
18
- network: {
19
- // No outbound network needed for Grep/Read/Write; block by default.
20
- allowedDomains: [],
21
- deniedDomains: [],
22
- },
23
- filesystem: {
24
- // Allow writes only within the configured sandbox directory.
25
- allowWrite: [workingDirectory],
26
- denyWrite: [],
27
- // Optional: harden reads a bit (deny common sensitive dirs)
28
- denyRead: ["~/.ssh", "~/.gnupg", "/etc/ssh"],
29
- },
30
- };
31
- await SandboxManager.initialize(cfg);
32
- initialized = true;
33
- }
8
+ import { getSessionContext, getToolOutputDir, hasSessionContext, } from "../../session-context";
34
9
  /** Small shell-escape for args that we pass via `shell: true`. */
35
10
  function shEscape(s) {
36
11
  return `'${s.replace(/'/g, `'\\''`)}'`;
@@ -50,38 +25,65 @@ async function runSandboxed(cmd, cwd) {
50
25
  code: code ?? 1,
51
26
  };
52
27
  }
28
+ /**
29
+ * Lazily initialize Sandbox Runtime with write access limited to session directory.
30
+ * Sandbox is initialized per-session based on the current session context.
31
+ */
32
+ const initializedSessions = new Set();
33
+ async function ensureSandbox(sessionDir) {
34
+ if (initializedSessions.has(sessionDir))
35
+ return;
36
+ const cfg = {
37
+ network: {
38
+ // No outbound network needed for Grep/Read/Write; block by default.
39
+ allowedDomains: [],
40
+ deniedDomains: [],
41
+ },
42
+ filesystem: {
43
+ // Allow writes only within the session directory.
44
+ allowWrite: [sessionDir],
45
+ denyWrite: [],
46
+ // Deny common sensitive dirs for reads
47
+ denyRead: ["~/.ssh", "~/.gnupg", "/etc/ssh"],
48
+ },
49
+ };
50
+ await SandboxManager.initialize(cfg);
51
+ initializedSessions.add(sessionDir);
52
+ }
53
53
  /** Check that ripgrep is available inside the sandbox. Throw with a helpful note if not. */
54
- async function assertRipgrep(workingDirectory) {
55
- const { code } = await runSandboxed("rg --version", workingDirectory);
54
+ async function assertRipgrep(sessionDir) {
55
+ const { code } = await runSandboxed("rg --version", sessionDir);
56
56
  if (code !== 0) {
57
57
  throw new Error("ripgrep (rg) is required for the Grep tool. Please install it (e.g., `brew install ripgrep` on macOS or your distro package on Linux).");
58
58
  }
59
59
  }
60
- /** Validate that a path is absolute */
61
- function assertAbsolutePath(filePath, paramName) {
62
- if (!path.isAbsolute(filePath)) {
63
- throw new Error(`${paramName} must be an absolute path, got: ${filePath}`);
64
- }
65
- }
66
- /** Validate that a path is within the working directory bounds */
67
- function assertWithinWorkingDirectory(filePath, workingDirectory) {
68
- const resolved = path.resolve(filePath);
69
- const normalizedWd = path.resolve(workingDirectory);
70
- if (!resolved.startsWith(normalizedWd + path.sep) &&
71
- resolved !== normalizedWd) {
72
- throw new Error(`Path ${filePath} is outside the allowed working directory ${workingDirectory}`);
73
- }
74
- }
75
- export function makeFilesystemTools(workingDirectory) {
76
- const resolvedWd = path.resolve(workingDirectory);
60
+ /**
61
+ * Create filesystem tools that are scoped to the current session.
62
+ * Tools use session context (via AsyncLocalStorage) to determine the session directory.
63
+ */
64
+ export function makeFilesystemTools() {
77
65
  const grep = tool(async ({ pattern, path: searchPath, glob, output_mode, "-B": before, "-A": after, "-C": context, "-n": lineNumbers, "-i": ignoreCase, type: fileType, head_limit, multiline, }) => {
78
- await ensureSandbox(resolvedWd);
79
- await assertRipgrep(resolvedWd);
80
- let target = resolvedWd;
66
+ if (!hasSessionContext()) {
67
+ throw new Error("Grep tool requires session context. Ensure the tool is called within a session.");
68
+ }
69
+ const { sessionDir, artifactsDir } = getSessionContext();
70
+ await ensureSandbox(sessionDir);
71
+ await assertRipgrep(sessionDir);
72
+ // Default to searching artifacts dir (where Write tool saves files)
73
+ let target = artifactsDir;
81
74
  if (searchPath) {
82
- assertAbsolutePath(searchPath, "path");
83
- assertWithinWorkingDirectory(searchPath, resolvedWd);
84
- target = path.resolve(searchPath);
75
+ // searchPath can be relative to artifacts dir or absolute (but must be within artifacts)
76
+ const resolvedPath = path.isAbsolute(searchPath)
77
+ ? searchPath
78
+ : path.join(artifactsDir, searchPath);
79
+ // Restrict access to only within artifactsDir
80
+ const normalizedPath = path.resolve(resolvedPath);
81
+ const normalizedArtifactsDir = path.resolve(artifactsDir);
82
+ if (!normalizedPath.startsWith(normalizedArtifactsDir + path.sep) &&
83
+ normalizedPath !== normalizedArtifactsDir) {
84
+ throw new Error(`Path ${searchPath} is outside the allowed artifacts directory`);
85
+ }
86
+ target = resolvedPath;
85
87
  }
86
88
  // Build rg command
87
89
  const parts = ["rg"];
@@ -124,7 +126,7 @@ export function makeFilesystemTools(workingDirectory) {
124
126
  if (head_limit !== undefined) {
125
127
  cmd = `${cmd} | head -n ${head_limit}`;
126
128
  }
127
- const { stdout, stderr, code } = await runSandboxed(cmd, resolvedWd);
129
+ const { stdout, stderr, code } = await runSandboxed(cmd, sessionDir);
128
130
  // rg returns non-zero on "no matches" — treat as empty results
129
131
  if (code !== 0 && stdout.length === 0) {
130
132
  const err = stderr.toString("utf8");
@@ -137,7 +139,7 @@ export function makeFilesystemTools(workingDirectory) {
137
139
  return stdout.toString("utf8");
138
140
  }, {
139
141
  name: "Grep",
140
- description: 'A powerful search tool built on ripgrep\n\n Usage:\n - ALWAYS use Grep for search tasks. NEVER invoke `grep` or `rg` as a Bash command. The Grep tool has been optimized for correct permissions and access.\n - Supports full regex syntax (e.g., "log.*Error", "function\\s+\\w+")\n - Filter files with glob parameter (e.g., "*.js", "**/*.tsx") or type parameter (e.g., "js", "py", "rust")\n - Output modes: "content" shows matching lines, "files_with_matches" shows only file paths (default), "count" shows match counts\n - Use Task tool for open-ended searches requiring multiple rounds\n - Pattern syntax: Uses ripgrep (not grep) - literal braces need escaping (use `interface\\{\\}` to find `interface{}` in Go code)\n - Multiline matching: By default patterns match within single lines only. For cross-line patterns like `struct \\{[\\s\\S]*?field`, use `multiline: true`\n',
142
+ description: 'A powerful search tool built on ripgrep, scoped to the current session directory.\n\n Usage:\n - ALWAYS use Grep for search tasks. NEVER invoke `grep` or `rg` as a Bash command.\n - Supports full regex syntax (e.g., "log.*Error", "function\\s+\\w+")\n - Filter files with glob parameter (e.g., "*.js", "**/*.tsx") or type parameter (e.g., "js", "py", "rust")\n - Output modes: "content" shows matching lines, "files_with_matches" shows only file paths (default), "count" shows match counts\n - Pattern syntax: Uses ripgrep (not grep) - literal braces need escaping\n - Multiline matching: By default patterns match within single lines only. For cross-line patterns, use `multiline: true`\n',
141
143
  schema: z.object({
142
144
  pattern: z
143
145
  .string()
@@ -145,7 +147,7 @@ export function makeFilesystemTools(workingDirectory) {
145
147
  path: z
146
148
  .string()
147
149
  .optional()
148
- .describe("File or directory to search in (rg PATH). Defaults to current working directory."),
150
+ .describe("File or directory to search in (relative to session directory or absolute within session). Defaults to session directory."),
149
151
  glob: z
150
152
  .string()
151
153
  .optional()
@@ -153,23 +155,23 @@ export function makeFilesystemTools(workingDirectory) {
153
155
  output_mode: z
154
156
  .enum(["content", "files_with_matches", "count"])
155
157
  .optional()
156
- .describe('Output mode: "content" shows matching lines (supports -A/-B/-C context, -n line numbers, head_limit), "files_with_matches" shows file paths (supports head_limit), "count" shows match counts (supports head_limit). Defaults to "files_with_matches".'),
158
+ .describe('Output mode: "content" shows matching lines, "files_with_matches" shows file paths (default), "count" shows match counts.'),
157
159
  "-B": z
158
160
  .number()
159
161
  .optional()
160
- .describe('Number of lines to show before each match (rg -B). Requires output_mode: "content", ignored otherwise.'),
162
+ .describe('Number of lines to show before each match (rg -B). Requires output_mode: "content".'),
161
163
  "-A": z
162
164
  .number()
163
165
  .optional()
164
- .describe('Number of lines to show after each match (rg -A). Requires output_mode: "content", ignored otherwise.'),
166
+ .describe('Number of lines to show after each match (rg -A). Requires output_mode: "content".'),
165
167
  "-C": z
166
168
  .number()
167
169
  .optional()
168
- .describe('Number of lines to show before and after each match (rg -C). Requires output_mode: "content", ignored otherwise.'),
170
+ .describe('Number of lines to show before and after each match (rg -C). Requires output_mode: "content".'),
169
171
  "-n": z
170
172
  .boolean()
171
173
  .optional()
172
- .describe('Show line numbers in output (rg -n). Requires output_mode: "content", ignored otherwise.'),
174
+ .describe('Show line numbers in output (rg -n). Requires output_mode: "content".'),
173
175
  "-i": z
174
176
  .boolean()
175
177
  .optional()
@@ -177,32 +179,49 @@ export function makeFilesystemTools(workingDirectory) {
177
179
  type: z
178
180
  .string()
179
181
  .optional()
180
- .describe("File type to search (rg --type). Common types: js, py, rust, go, java, etc. More efficient than include for standard file types."),
182
+ .describe("File type to search (rg --type). Common types: js, py, rust, go, java, etc."),
181
183
  head_limit: z
182
184
  .number()
183
185
  .optional()
184
- .describe('Limit output to first N lines/entries, equivalent to "| head -N". Works across all output modes: content (limits output lines), files_with_matches (limits file paths), count (limits count entries). When unspecified, shows all results from ripgrep.'),
186
+ .describe('Limit output to first N lines/entries, equivalent to "| head -N".'),
185
187
  multiline: z
186
188
  .boolean()
187
189
  .optional()
188
190
  .describe("Enable multiline mode where . matches newlines and patterns can span lines (rg -U --multiline-dotall). Default: false."),
189
191
  }),
190
192
  });
193
+ // biome-ignore lint/suspicious/noExplicitAny: Need to add custom properties to LangChain tool
191
194
  grep.prettyName = "Codebase Search";
195
+ // biome-ignore lint/suspicious/noExplicitAny: Need to add custom properties to LangChain tool
192
196
  grep.icon = "Search";
197
+ // biome-ignore lint/suspicious/noExplicitAny: Need to add custom properties to LangChain tool
193
198
  grep.verbiage = {
194
199
  active: "Searching for {query}",
195
200
  past: "Searched for {query}",
196
201
  paramKey: "pattern",
197
202
  };
198
203
  const read = tool(async ({ file_path, offset, limit }) => {
199
- await ensureSandbox(resolvedWd);
200
- assertAbsolutePath(file_path, "file_path");
201
- assertWithinWorkingDirectory(file_path, resolvedWd);
202
- const target = path.resolve(file_path);
204
+ if (!hasSessionContext()) {
205
+ throw new Error("Read tool requires session context. Ensure the tool is called within a session.");
206
+ }
207
+ const { sessionDir, artifactsDir } = getSessionContext();
208
+ await ensureSandbox(sessionDir);
209
+ // Resolve file path relative to artifacts dir (where Write tool saves files)
210
+ // All relative paths are resolved from artifactsDir
211
+ // Absolute paths must also be within artifactsDir for security
212
+ const resolvedPath = path.isAbsolute(file_path)
213
+ ? file_path
214
+ : path.join(artifactsDir, file_path);
215
+ // Restrict access to only within artifactsDir (not full sessionDir)
216
+ const normalizedPath = path.resolve(resolvedPath);
217
+ const normalizedArtifactsDir = path.resolve(artifactsDir);
218
+ if (!normalizedPath.startsWith(normalizedArtifactsDir + path.sep) &&
219
+ normalizedPath !== normalizedArtifactsDir) {
220
+ throw new Error(`Path ${file_path} is outside the allowed artifacts directory`);
221
+ }
203
222
  // Read the file using sandboxed cat
204
- const cmd = `cat ${shEscape(target)}`;
205
- const { stdout, stderr, code } = await runSandboxed(cmd, resolvedWd);
223
+ const cmd = `cat ${shEscape(resolvedPath)}`;
224
+ const { stdout, stderr, code } = await runSandboxed(cmd, sessionDir);
206
225
  if (code !== 0) {
207
226
  throw new Error(`Read failed for ${file_path}:\n${stderr.toString("utf8") || "Unknown error"}`);
208
227
  }
@@ -224,9 +243,11 @@ export function makeFilesystemTools(workingDirectory) {
224
243
  return formatted;
225
244
  }, {
226
245
  name: "Read",
227
- description: "Reads a file from the local filesystem. You can access any file directly by using this tool.\nAssume this tool is able to read all files on the machine. If the User provides a path to a file assume that path is valid. It is okay to read a file that does not exist; an error will be returned.\n\nUsage:\n- The file_path parameter must be an absolute path, not a relative path\n- By default, it reads up to 2000 lines starting from the beginning of the file\n- You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters\n- Any lines longer than 2000 characters will be truncated\n- Results are returned using cat -n format, with line numbers starting at 1\n- This tool allows Claude Code to read images (eg PNG, JPG, etc). When reading an image file the contents are presented visually as Claude Code is a multimodal LLM.\n- This tool can read PDF files (.pdf). PDFs are processed page by page, extracting both text and visual content for analysis.\n- This tool can read Jupyter notebooks (.ipynb files) and returns all cells with their outputs, combining code, text, and visualizations.\n- This tool can only read files, not directories. To read a directory, use an ls command via the Bash tool.\n- You can call multiple tools in a single response. It is always better to speculatively read multiple potentially useful files in parallel.\n- You will regularly be asked to read screenshots. If the user provides a path to a screenshot, ALWAYS use this tool to view the file at the path. This tool will work with all temporary file paths.\n- If you read a file that exists but has empty contents you will receive a system reminder warning in place of file contents.",
246
+ description: "Reads a file from the session directory.\n\nUsage:\n- The file_path can be relative to the session directory or an absolute path within the session\n- By default, it reads up to 2000 lines starting from the beginning of the file\n- You can optionally specify a line offset and limit (especially handy for long files)\n- Any lines longer than 2000 characters will be truncated\n- Results are returned with line numbers",
228
247
  schema: z.object({
229
- file_path: z.string().describe("The absolute path to the file to read"),
248
+ file_path: z
249
+ .string()
250
+ .describe("The path to the file to read (relative to session directory or absolute within session)"),
230
251
  offset: z
231
252
  .number()
232
253
  .optional()
@@ -237,42 +258,57 @@ export function makeFilesystemTools(workingDirectory) {
237
258
  .describe("The number of lines to read. Only provide if the file is too large to read at once."),
238
259
  }),
239
260
  });
261
+ // biome-ignore lint/suspicious/noExplicitAny: Need to add custom properties to LangChain tool
240
262
  read.prettyName = "Read File";
263
+ // biome-ignore lint/suspicious/noExplicitAny: Need to add custom properties to LangChain tool
241
264
  read.icon = "FileText";
265
+ // biome-ignore lint/suspicious/noExplicitAny: Need to add custom properties to LangChain tool
242
266
  read.verbiage = {
243
267
  active: "Reading {file}",
244
268
  past: "Read {file}",
245
269
  paramKey: "file_path",
246
270
  };
247
271
  const write = tool(async ({ file_path, content }) => {
248
- await ensureSandbox(resolvedWd);
249
- assertAbsolutePath(file_path, "file_path");
250
- assertWithinWorkingDirectory(file_path, resolvedWd);
251
- const target = path.resolve(file_path);
272
+ if (!hasSessionContext()) {
273
+ throw new Error("Write tool requires session context. Ensure the tool is called within a session.");
274
+ }
275
+ const { sessionDir } = getSessionContext();
276
+ const toolOutputDir = getToolOutputDir("Write");
277
+ await ensureSandbox(sessionDir);
278
+ // Ensure tool output directory exists
279
+ await fs.mkdir(toolOutputDir, { recursive: true });
280
+ // Resolve file path - Write always writes to tool-Write/ subdirectory
281
+ // If absolute path given, extract just the filename
282
+ const fileName = path.isAbsolute(file_path)
283
+ ? path.basename(file_path)
284
+ : file_path;
285
+ const target = path.join(toolOutputDir, fileName);
252
286
  const dir = path.dirname(target);
253
- // Make sure parent exists (in *parent* process, just for convenience of here-doc).
254
- // This does not write file contents; the write itself happens inside the sandbox.
287
+ // Make sure parent exists
255
288
  await fs.mkdir(dir, { recursive: true });
256
- // Safe here-doc to avoid shell interpolation
257
- const cmd = `bash -c 'mkdir -p ${shEscape(dir)} && cat > ${shEscape(target)} <<'EOF'\n` +
258
- `${content}\nEOF\n'`;
259
- const { stderr, code } = await runSandboxed(cmd, resolvedWd);
289
+ // Use base64 encoding to safely pass content through shell without escaping issues
290
+ const base64Content = Buffer.from(content, "utf8").toString("base64");
291
+ const cmd = `mkdir -p ${shEscape(dir)} && echo ${shEscape(base64Content)} | base64 -d > ${shEscape(target)}`;
292
+ const { stderr, code } = await runSandboxed(cmd, sessionDir);
260
293
  if (code !== 0) {
261
294
  throw new Error(`Write failed for ${file_path}:\n${stderr.toString("utf8") || "Unknown error"}`);
262
295
  }
263
- return `Successfully wrote ${Buffer.byteLength(content, "utf8")} bytes to ${file_path}`;
296
+ return `Successfully wrote ${Buffer.byteLength(content, "utf8")} bytes to ${target}`;
264
297
  }, {
265
298
  name: "Write",
266
- description: "Writes a file to the local filesystem.\n\nUsage:\n- This tool will overwrite the existing file if there is one at the provided path.\n- If this is an existing file, you MUST use the Read tool first to read the file's contents. This tool will fail if you did not read the file first.\n- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.\n- NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested by the User.\n- Only use emojis if the user explicitly requests it. Avoid writing emojis to files unless asked.",
299
+ description: "Writes a file to the session's tool-Write/ directory.\n\nUsage:\n- Files are written to the session-scoped tool-Write/ directory\n- Provide a filename (or relative path) - the file will be created in the session's output directory\n- This tool will overwrite any existing file with the same name\n- The full path where the file was written will be returned",
267
300
  schema: z.object({
268
301
  file_path: z
269
302
  .string()
270
- .describe("The absolute path to the file to write (must be absolute, not relative)"),
303
+ .describe("The filename or relative path for the file to write (will be placed in session's tool-Write/ directory)"),
271
304
  content: z.string().describe("The content to write to the file"),
272
305
  }),
273
306
  });
307
+ // biome-ignore lint/suspicious/noExplicitAny: Need to add custom properties to LangChain tool
274
308
  write.prettyName = "Write File";
309
+ // biome-ignore lint/suspicious/noExplicitAny: Need to add custom properties to LangChain tool
275
310
  write.icon = "Edit";
311
+ // biome-ignore lint/suspicious/noExplicitAny: Need to add custom properties to LangChain tool
276
312
  write.verbiage = {
277
313
  active: "Writing {file}",
278
314
  past: "Wrote {file}",
@@ -8,6 +8,7 @@ interface GenerateImageResult {
8
8
  mimeType?: string | undefined;
9
9
  error?: string | undefined;
10
10
  }
11
+ /** Create generate image tool using direct GEMINI_API_KEY/GOOGLE_API_KEY */
11
12
  export declare function makeGenerateImageTool(): import("langchain").DynamicStructuredTool<z.ZodObject<{
12
13
  prompt: z.ZodString;
13
14
  aspectRatio: z.ZodDefault<z.ZodOptional<z.ZodEnum<{
@@ -25,4 +26,22 @@ export declare function makeGenerateImageTool(): import("langchain").DynamicStru
25
26
  prompt: string;
26
27
  aspectRatio?: "1:1" | "3:4" | "4:3" | "9:16" | "16:9" | "5:4" | undefined;
27
28
  }, GenerateImageResult>;
29
+ /** Create generate image tool using Town proxy */
30
+ export declare function makeTownGenerateImageTool(): import("langchain").DynamicStructuredTool<z.ZodObject<{
31
+ prompt: z.ZodString;
32
+ aspectRatio: z.ZodDefault<z.ZodOptional<z.ZodEnum<{
33
+ "1:1": "1:1";
34
+ "3:4": "3:4";
35
+ "4:3": "4:3";
36
+ "9:16": "9:16";
37
+ "16:9": "16:9";
38
+ "5:4": "5:4";
39
+ }>>>;
40
+ }, z.core.$strip>, {
41
+ prompt: string;
42
+ aspectRatio: "1:1" | "3:4" | "4:3" | "9:16" | "16:9" | "5:4";
43
+ }, {
44
+ prompt: string;
45
+ aspectRatio?: "1:1" | "3:4" | "4:3" | "9:16" | "16:9" | "5:4" | undefined;
46
+ }, GenerateImageResult>;
28
47
  export {};
@@ -1,25 +1,53 @@
1
1
  import { mkdir, writeFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { GoogleGenAI } from "@google/genai";
4
+ import { getShedAuth } from "@townco/core/auth";
4
5
  import { tool } from "langchain";
5
6
  import { z } from "zod";
6
- let _genaiClient = null;
7
- function getGenAIClient() {
8
- if (_genaiClient) {
9
- return _genaiClient;
7
+ import { getSessionContext, getToolOutputDir, hasSessionContext, } from "../../session-context";
8
+ let _directGenaiClient = null;
9
+ let _townGenaiClient = null;
10
+ /** Get Google GenAI client using direct GEMINI_API_KEY/GOOGLE_API_KEY environment variable */
11
+ function getDirectGenAIClient() {
12
+ if (_directGenaiClient) {
13
+ return _directGenaiClient;
10
14
  }
11
15
  const apiKey = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY;
12
16
  if (!apiKey) {
13
17
  throw new Error("GEMINI_API_KEY or GOOGLE_API_KEY environment variable is required to use the generate_image tool. " +
14
18
  "Please set one of them to your Google AI API key.");
15
19
  }
16
- _genaiClient = new GoogleGenAI({ apiKey });
17
- return _genaiClient;
20
+ _directGenaiClient = new GoogleGenAI({ apiKey });
21
+ return _directGenaiClient;
18
22
  }
19
- export function makeGenerateImageTool() {
23
+ /** Get Google GenAI client using Town proxy with authenticated credentials */
24
+ function getTownGenAIClient() {
25
+ if (_townGenaiClient) {
26
+ return _townGenaiClient;
27
+ }
28
+ const shedAuth = getShedAuth();
29
+ if (!shedAuth) {
30
+ throw new Error("Not logged in. Run 'town login' or set SHED_API_KEY to use the town_generate_image tool.");
31
+ }
32
+ // Configure the client to use shed as proxy
33
+ // The SDK will send requests to {shedUrl}/api/gemini/{apiVersion}/{path}
34
+ _townGenaiClient = new GoogleGenAI({
35
+ apiKey: shedAuth.accessToken,
36
+ httpOptions: {
37
+ baseUrl: `${shedAuth.shedUrl}/api/gemini/`,
38
+ },
39
+ });
40
+ return _townGenaiClient;
41
+ }
42
+ function makeGenerateImageToolInternal(getClient) {
20
43
  const generateImage = tool(async ({ prompt, aspectRatio = "1:1" }) => {
21
44
  try {
22
- const client = getGenAIClient();
45
+ if (!hasSessionContext()) {
46
+ throw new Error("GenerateImage tool requires session context. Ensure the tool is called within a session.");
47
+ }
48
+ const { sessionId } = getSessionContext();
49
+ const toolOutputDir = getToolOutputDir("GenerateImage");
50
+ const client = getClient();
23
51
  // Use Gemini 3 Pro Image for image generation
24
52
  // Note: imageConfig is a valid API option but not yet in the TypeScript types
25
53
  // biome-ignore lint/suspicious/noExplicitAny: imageConfig not yet typed in @google/genai
@@ -73,21 +101,23 @@ export function makeGenerateImageTool() {
73
101
  ...(textResponse ? { textResponse } : {}),
74
102
  };
75
103
  }
76
- // Save image to disk in generated-images directory (relative to cwd)
77
- const outputDir = join(process.cwd(), "generated-images");
78
- await mkdir(outputDir, { recursive: true });
104
+ // Save image to session-scoped tool output directory
105
+ await mkdir(toolOutputDir, { recursive: true });
79
106
  // Generate unique filename
80
107
  const timestamp = Date.now();
81
108
  const extension = mimeType === "image/jpeg" ? "jpg" : "png";
82
109
  const fileName = `image-${timestamp}.${extension}`;
83
- const filePath = join(outputDir, fileName);
110
+ const filePath = join(toolOutputDir, fileName);
84
111
  // Save image to file
85
112
  const buffer = Buffer.from(imageData, "base64");
86
113
  await writeFile(filePath, buffer);
87
114
  // Create URL for the static file server
88
115
  // The agent HTTP server serves static files from the agent directory
116
+ // Use AGENT_BASE_URL if set (for production), otherwise construct from BIND_HOST/PORT
89
117
  const port = process.env.PORT || "3100";
90
- const imageUrl = `http://localhost:${port}/static/generated-images/${fileName}`;
118
+ const hostname = process.env.BIND_HOST || "localhost";
119
+ const baseUrl = process.env.AGENT_BASE_URL || `http://${hostname}:${port}`;
120
+ const imageUrl = `${baseUrl}/static/.sessions/${sessionId}/artifacts/tool-GenerateImage/${fileName}`;
91
121
  return {
92
122
  success: true,
93
123
  filePath,
@@ -116,7 +146,7 @@ export function makeGenerateImageTool() {
116
146
  "\n" +
117
147
  "Usage notes:\n" +
118
148
  " - Provide detailed, specific prompts for best results\n" +
119
- " - The generated image is saved and served via URL\n" +
149
+ " - The generated image is saved to the session directory and served via URL\n" +
120
150
  " - Always display the result using markdown: ![description](imageUrl)\n",
121
151
  schema: z.object({
122
152
  prompt: z
@@ -129,7 +159,17 @@ export function makeGenerateImageTool() {
129
159
  .describe("The aspect ratio of the generated image."),
130
160
  }),
131
161
  });
162
+ // biome-ignore lint/suspicious/noExplicitAny: Need to add custom properties to LangChain tool
132
163
  generateImage.prettyName = "Generate Image";
164
+ // biome-ignore lint/suspicious/noExplicitAny: Need to add custom properties to LangChain tool
133
165
  generateImage.icon = "Image";
134
166
  return generateImage;
135
167
  }
168
+ /** Create generate image tool using direct GEMINI_API_KEY/GOOGLE_API_KEY */
169
+ export function makeGenerateImageTool() {
170
+ return makeGenerateImageToolInternal(getDirectGenAIClient);
171
+ }
172
+ /** Create generate image tool using Town proxy */
173
+ export function makeTownGenerateImageTool() {
174
+ return makeGenerateImageToolInternal(getTownGenAIClient);
175
+ }
@@ -35,7 +35,7 @@ async function waitForServerReady(port, timeoutMs = 30000) {
35
35
  catch {
36
36
  // Server not ready yet
37
37
  }
38
- await new Promise((r) => setTimeout(r, baseDelay * Math.pow(1.5, attempt)));
38
+ await new Promise((r) => setTimeout(r, baseDelay * 1.5 ** attempt));
39
39
  attempt++;
40
40
  }
41
41
  throw new Error(`Subagent server at port ${port} did not become ready within ${timeoutMs}ms`);
@@ -330,7 +330,7 @@ async function querySubagent(agentName, agentPath, agentWorkingDirectory, query)
330
330
  try {
331
331
  const sseResponse = await fetch(`${baseUrl}/events`, {
332
332
  headers: { "X-Session-ID": sessionId },
333
- signal: sseAbortController.signal,
333
+ signal: sseAbortController?.signal,
334
334
  });
335
335
  if (!sseResponse.ok || !sseResponse.body) {
336
336
  throw new Error(`SSE connection failed: HTTP ${sseResponse.status}`);
@@ -80,8 +80,11 @@ When in doubt, use this tool. Being proactive with task management demonstrates
80
80
  }),
81
81
  });
82
82
  // Add metadata to the tool instance
83
+ // biome-ignore lint/suspicious/noExplicitAny: Need to add custom properties to LangChain tool
83
84
  todoWriteTool.prettyName = "Todo List";
85
+ // biome-ignore lint/suspicious/noExplicitAny: Need to add custom properties to LangChain tool
84
86
  todoWriteTool.icon = "CheckSquare";
87
+ // biome-ignore lint/suspicious/noExplicitAny: Need to add custom properties to LangChain tool
85
88
  todoWriteTool.verbiage = {
86
89
  active: "Updating to-do's",
87
90
  past: "Updated to-do's",
@@ -60,8 +60,11 @@ function makeWebSearchToolsInternal(getClient) {
60
60
  query: z.string().describe("The search query to use"),
61
61
  }),
62
62
  });
63
+ // biome-ignore lint/suspicious/noExplicitAny: Need to add custom properties to LangChain tool
63
64
  webSearch.prettyName = "Web Search";
65
+ // biome-ignore lint/suspicious/noExplicitAny: Need to add custom properties to LangChain tool
64
66
  webSearch.icon = "Globe";
67
+ // biome-ignore lint/suspicious/noExplicitAny: Need to add custom properties to LangChain tool
65
68
  webSearch.verbiage = {
66
69
  active: "Searching the web for {query}",
67
70
  past: "Searched the web for {query}",
@@ -138,8 +141,11 @@ function makeWebSearchToolsInternal(getClient) {
138
141
  prompt: z.string().describe("The prompt to run on the fetched content"),
139
142
  }),
140
143
  });
144
+ // biome-ignore lint/suspicious/noExplicitAny: Need to add custom properties to LangChain tool
141
145
  webFetch.prettyName = "Web Fetch";
146
+ // biome-ignore lint/suspicious/noExplicitAny: Need to add custom properties to LangChain tool
142
147
  webFetch.icon = "Link";
148
+ // biome-ignore lint/suspicious/noExplicitAny: Need to add custom properties to LangChain tool
143
149
  webFetch.verbiage = {
144
150
  active: "Fetching {url}",
145
151
  past: "Fetched {url}",
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Session context available to tools during agent execution.
3
+ * This provides deterministic, session-scoped file storage.
4
+ */
5
+ export interface SessionContext {
6
+ /** The unique session identifier */
7
+ sessionId: string;
8
+ /** Root directory for this session: <agentDir>/.sessions/<sessionId> */
9
+ sessionDir: string;
10
+ /** Artifacts directory: <agentDir>/.sessions/<sessionId>/artifacts */
11
+ artifactsDir: string;
12
+ }
13
+ /**
14
+ * Run a function with session context available via AsyncLocalStorage.
15
+ * Tools can access the context using getSessionContext().
16
+ */
17
+ export declare function runWithSessionContext<T>(ctx: SessionContext, fn: () => T): T;
18
+ /**
19
+ * Bind an async generator to a session context so that every iteration
20
+ * runs with the session context available.
21
+ */
22
+ export declare function bindGeneratorToSessionContext<T, R, N = unknown>(ctx: SessionContext, generator: AsyncGenerator<T, R, N>): AsyncGenerator<T, R, N>;
23
+ /**
24
+ * Get the current session context.
25
+ * Throws if called outside of a session context (e.g., tool called without session).
26
+ */
27
+ export declare function getSessionContext(): SessionContext;
28
+ /**
29
+ * Check if session context is available.
30
+ * Useful for tools that can operate with or without session context.
31
+ */
32
+ export declare function hasSessionContext(): boolean;
33
+ /**
34
+ * Get the output directory for a specific tool.
35
+ * Creates a deterministic path: <agentDir>/.sessions/<sessionId>/artifacts/tool-<ToolName>/
36
+ *
37
+ * @param toolName - The name of the tool (e.g., "Write", "GenerateImage")
38
+ * @returns Absolute path to the tool's output directory
39
+ */
40
+ export declare function getToolOutputDir(toolName: string): string;
@@ -0,0 +1,69 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import * as path from "node:path";
3
+ const sessionStorage = new AsyncLocalStorage();
4
+ /**
5
+ * Run a function with session context available via AsyncLocalStorage.
6
+ * Tools can access the context using getSessionContext().
7
+ */
8
+ export function runWithSessionContext(ctx, fn) {
9
+ return sessionStorage.run(ctx, fn);
10
+ }
11
+ /**
12
+ * Bind an async generator to a session context so that every iteration
13
+ * runs with the session context available.
14
+ */
15
+ export function bindGeneratorToSessionContext(ctx, generator) {
16
+ const boundNext = (value) => sessionStorage.run(ctx, () => generator.next(value));
17
+ const boundReturn = generator.return
18
+ ? (value) => sessionStorage.run(ctx, () => generator.return?.(value))
19
+ : undefined;
20
+ const boundThrow = generator.throw
21
+ ? (e) => sessionStorage.run(ctx, () => generator.throw?.(e))
22
+ : undefined;
23
+ // Create the bound generator with all required properties
24
+ const boundGenerator = {
25
+ next: boundNext,
26
+ return: boundReturn,
27
+ throw: boundThrow,
28
+ [Symbol.asyncIterator]() {
29
+ return this;
30
+ },
31
+ // Add Symbol.asyncDispose if supported (ES2023+)
32
+ [Symbol.asyncDispose]: async () => {
33
+ // Cleanup if needed - delegate to original generator if it has asyncDispose
34
+ if (Symbol.asyncDispose in generator) {
35
+ await generator[Symbol.asyncDispose]();
36
+ }
37
+ },
38
+ };
39
+ return boundGenerator;
40
+ }
41
+ /**
42
+ * Get the current session context.
43
+ * Throws if called outside of a session context (e.g., tool called without session).
44
+ */
45
+ export function getSessionContext() {
46
+ const ctx = sessionStorage.getStore();
47
+ if (!ctx) {
48
+ throw new Error("No session context available - tool called outside of session execution");
49
+ }
50
+ return ctx;
51
+ }
52
+ /**
53
+ * Check if session context is available.
54
+ * Useful for tools that can operate with or without session context.
55
+ */
56
+ export function hasSessionContext() {
57
+ return sessionStorage.getStore() !== undefined;
58
+ }
59
+ /**
60
+ * Get the output directory for a specific tool.
61
+ * Creates a deterministic path: <agentDir>/.sessions/<sessionId>/artifacts/tool-<ToolName>/
62
+ *
63
+ * @param toolName - The name of the tool (e.g., "Write", "GenerateImage")
64
+ * @returns Absolute path to the tool's output directory
65
+ */
66
+ export function getToolOutputDir(toolName) {
67
+ const ctx = getSessionContext();
68
+ return path.join(ctx.artifactsDir, `tool-${toolName}`);
69
+ }