@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.
- package/dist/acp-server/adapter.js +12 -12
- package/dist/acp-server/http.js +1 -1
- package/dist/acp-server/session-storage.d.ts +13 -6
- package/dist/acp-server/session-storage.js +94 -59
- package/dist/runner/agent-runner.d.ts +3 -1
- package/dist/runner/hooks/executor.js +1 -1
- package/dist/runner/hooks/predefined/compaction-tool.js +31 -8
- package/dist/runner/hooks/predefined/tool-response-compactor.js +2 -2
- package/dist/runner/langchain/index.d.ts +1 -0
- package/dist/runner/langchain/index.js +151 -27
- package/dist/runner/langchain/tools/artifacts.d.ts +68 -0
- package/dist/runner/langchain/tools/artifacts.js +469 -0
- package/dist/runner/langchain/tools/browser.js +15 -3
- package/dist/runner/langchain/tools/filesystem.d.ts +8 -4
- package/dist/runner/langchain/tools/filesystem.js +118 -82
- package/dist/runner/langchain/tools/generate_image.d.ts +19 -0
- package/dist/runner/langchain/tools/generate_image.js +54 -14
- package/dist/runner/langchain/tools/subagent.js +2 -2
- package/dist/runner/langchain/tools/todo.js +3 -0
- package/dist/runner/langchain/tools/web_search.js +6 -0
- package/dist/runner/session-context.d.ts +40 -0
- package/dist/runner/session-context.js +69 -0
- package/dist/runner/tools.d.ts +2 -2
- package/dist/runner/tools.js +2 -0
- package/dist/scaffold/project-scaffold.js +7 -3
- package/dist/telemetry/setup.js +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/utils/context-size-calculator.d.ts +1 -2
- package/dist/utils/context-size-calculator.js +2 -6
- package/dist/utils/token-counter.js +2 -2
- 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(
|
|
55
|
-
const { code } = await runSandboxed("rg --version",
|
|
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
|
-
/**
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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,
|
|
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
|
|
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 (
|
|
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
|
|
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"
|
|
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"
|
|
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"
|
|
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"
|
|
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.
|
|
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".
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
const
|
|
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(
|
|
205
|
-
const { stdout, stderr, code } = await runSandboxed(cmd,
|
|
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
|
|
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
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
const
|
|
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
|
|
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
|
-
//
|
|
257
|
-
const
|
|
258
|
-
|
|
259
|
-
const { stderr, code } = await runSandboxed(cmd,
|
|
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 ${
|
|
296
|
+
return `Successfully wrote ${Buffer.byteLength(content, "utf8")} bytes to ${target}`;
|
|
264
297
|
}, {
|
|
265
298
|
name: "Write",
|
|
266
|
-
description: "Writes a file to the
|
|
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
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
17
|
-
return
|
|
20
|
+
_directGenaiClient = new GoogleGenAI({ apiKey });
|
|
21
|
+
return _directGenaiClient;
|
|
18
22
|
}
|
|
19
|
-
|
|
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
|
-
|
|
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
|
|
77
|
-
|
|
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(
|
|
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
|
|
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: \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 *
|
|
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
|
|
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
|
+
}
|