@townco/agent 0.1.19 → 0.1.20

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.
@@ -0,0 +1,261 @@
1
+ import { spawn } from "node:child_process";
2
+ import { once } from "node:events";
3
+ import * as fs from "node:fs/promises";
4
+ import * as path from "node:path";
5
+ import { SandboxManager, } from "@anthropic-ai/sandbox-runtime";
6
+ import { tool } from "langchain";
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
+ }
34
+ /** Small shell-escape for args that we pass via `shell: true`. */
35
+ function shEscape(s) {
36
+ return `'${s.replace(/'/g, `'\\''`)}'`;
37
+ }
38
+ /** Run a command string inside the sandbox, returning { stdout, stderr, code }. */
39
+ async function runSandboxed(cmd, cwd) {
40
+ const wrapped = await SandboxManager.wrapWithSandbox(cmd);
41
+ const child = spawn(wrapped, { shell: true, cwd });
42
+ const stdout = [];
43
+ const stderr = [];
44
+ child.stdout?.on("data", (d) => stdout.push(Buffer.from(d)));
45
+ child.stderr?.on("data", (d) => stderr.push(Buffer.from(d)));
46
+ const [code] = (await once(child, "exit"));
47
+ return {
48
+ stdout: Buffer.concat(stdout),
49
+ stderr: Buffer.concat(stderr),
50
+ code: code ?? 1,
51
+ };
52
+ }
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);
56
+ if (code !== 0) {
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
+ }
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);
77
+ 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;
81
+ if (searchPath) {
82
+ assertAbsolutePath(searchPath, "path");
83
+ assertWithinWorkingDirectory(searchPath, resolvedWd);
84
+ target = path.resolve(searchPath);
85
+ }
86
+ // Build rg command
87
+ const parts = ["rg"];
88
+ // Output format
89
+ const mode = output_mode ?? "files_with_matches";
90
+ if (mode === "files_with_matches") {
91
+ parts.push("--files-with-matches");
92
+ }
93
+ else if (mode === "count") {
94
+ parts.push("--count");
95
+ }
96
+ else if (mode === "content") {
97
+ // Default content mode
98
+ if (lineNumbers !== false) {
99
+ parts.push("--line-number");
100
+ }
101
+ if (context !== undefined) {
102
+ parts.push(`-C${context}`);
103
+ }
104
+ else {
105
+ if (before !== undefined)
106
+ parts.push(`-B${before}`);
107
+ if (after !== undefined)
108
+ parts.push(`-A${after}`);
109
+ }
110
+ }
111
+ // Search options
112
+ if (ignoreCase)
113
+ parts.push("-i");
114
+ if (multiline)
115
+ parts.push("-U", "--multiline-dotall");
116
+ if (fileType)
117
+ parts.push("--type", fileType);
118
+ if (glob)
119
+ parts.push("-g", shEscape(glob));
120
+ // Pattern and target
121
+ parts.push(shEscape(pattern), shEscape(target));
122
+ // Head limit (done via pipe)
123
+ let cmd = parts.join(" ");
124
+ if (head_limit !== undefined) {
125
+ cmd = `${cmd} | head -n ${head_limit}`;
126
+ }
127
+ const { stdout, stderr, code } = await runSandboxed(cmd, resolvedWd);
128
+ // rg returns non-zero on "no matches" — treat as empty results
129
+ if (code !== 0 && stdout.length === 0) {
130
+ const err = stderr.toString("utf8");
131
+ // If stderr looks like a real error (not just "no matches"), surface it
132
+ if (err && !/no such file or directory|nothing matched/i.test(err)) {
133
+ throw new Error(`ripgrep failed:\n${err}`);
134
+ }
135
+ return mode === "count" ? "0" : "";
136
+ }
137
+ return stdout.toString("utf8");
138
+ }, {
139
+ 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',
141
+ schema: z.object({
142
+ pattern: z
143
+ .string()
144
+ .describe("The regular expression pattern to search for in file contents"),
145
+ path: z
146
+ .string()
147
+ .optional()
148
+ .describe("File or directory to search in (rg PATH). Defaults to current working directory."),
149
+ glob: z
150
+ .string()
151
+ .optional()
152
+ .describe('Glob pattern to filter files (e.g. "*.js", "*.{ts,tsx}") - maps to rg --glob'),
153
+ output_mode: z
154
+ .enum(["content", "files_with_matches", "count"])
155
+ .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".'),
157
+ "-B": z
158
+ .number()
159
+ .optional()
160
+ .describe('Number of lines to show before each match (rg -B). Requires output_mode: "content", ignored otherwise.'),
161
+ "-A": z
162
+ .number()
163
+ .optional()
164
+ .describe('Number of lines to show after each match (rg -A). Requires output_mode: "content", ignored otherwise.'),
165
+ "-C": z
166
+ .number()
167
+ .optional()
168
+ .describe('Number of lines to show before and after each match (rg -C). Requires output_mode: "content", ignored otherwise.'),
169
+ "-n": z
170
+ .boolean()
171
+ .optional()
172
+ .describe('Show line numbers in output (rg -n). Requires output_mode: "content", ignored otherwise.'),
173
+ "-i": z
174
+ .boolean()
175
+ .optional()
176
+ .describe("Case insensitive search (rg -i)"),
177
+ type: z
178
+ .string()
179
+ .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."),
181
+ head_limit: z
182
+ .number()
183
+ .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.'),
185
+ multiline: z
186
+ .boolean()
187
+ .optional()
188
+ .describe("Enable multiline mode where . matches newlines and patterns can span lines (rg -U --multiline-dotall). Default: false."),
189
+ }),
190
+ });
191
+ const read = tool(async ({ file_path, offset, limit }) => {
192
+ await ensureSandbox(resolvedWd);
193
+ assertAbsolutePath(file_path, "file_path");
194
+ assertWithinWorkingDirectory(file_path, resolvedWd);
195
+ const target = path.resolve(file_path);
196
+ // Read the file using sandboxed cat
197
+ const cmd = `cat ${shEscape(target)}`;
198
+ const { stdout, stderr, code } = await runSandboxed(cmd, resolvedWd);
199
+ if (code !== 0) {
200
+ throw new Error(`Read failed for ${file_path}:\n${stderr.toString("utf8") || "Unknown error"}`);
201
+ }
202
+ // Handle offset and limit
203
+ let lines = stdout.toString("utf8").split(/\r?\n/);
204
+ if (offset !== undefined) {
205
+ lines = lines.slice(offset);
206
+ }
207
+ if (limit !== undefined) {
208
+ lines = lines.slice(0, limit);
209
+ }
210
+ // Truncate long lines
211
+ const truncatedLines = lines.map((line) => line.length > 2000 ? `${line.slice(0, 2000)}...` : line);
212
+ // Format with line numbers (cat -n style)
213
+ const startLine = (offset ?? 0) + 1;
214
+ const formatted = truncatedLines
215
+ .map((line, idx) => `${startLine + idx}→${line}`)
216
+ .join("\n");
217
+ return formatted;
218
+ }, {
219
+ name: "Read",
220
+ 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.",
221
+ schema: z.object({
222
+ file_path: z.string().describe("The absolute path to the file to read"),
223
+ offset: z
224
+ .number()
225
+ .optional()
226
+ .describe("The line number to start reading from. Only provide if the file is too large to read at once"),
227
+ limit: z
228
+ .number()
229
+ .optional()
230
+ .describe("The number of lines to read. Only provide if the file is too large to read at once."),
231
+ }),
232
+ });
233
+ const write = tool(async ({ file_path, content }) => {
234
+ await ensureSandbox(resolvedWd);
235
+ assertAbsolutePath(file_path, "file_path");
236
+ assertWithinWorkingDirectory(file_path, resolvedWd);
237
+ const target = path.resolve(file_path);
238
+ const dir = path.dirname(target);
239
+ // Make sure parent exists (in *parent* process, just for convenience of here-doc).
240
+ // This does not write file contents; the write itself happens inside the sandbox.
241
+ await fs.mkdir(dir, { recursive: true });
242
+ // Safe here-doc to avoid shell interpolation
243
+ const cmd = `bash -c 'mkdir -p ${shEscape(dir)} && cat > ${shEscape(target)} <<'EOF'\n` +
244
+ `${content}\nEOF\n'`;
245
+ const { stderr, code } = await runSandboxed(cmd, resolvedWd);
246
+ if (code !== 0) {
247
+ throw new Error(`Write failed for ${file_path}:\n${stderr.toString("utf8") || "Unknown error"}`);
248
+ }
249
+ return `Successfully wrote ${Buffer.byteLength(content, "utf8")} bytes to ${file_path}`;
250
+ }, {
251
+ name: "Write",
252
+ 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.",
253
+ schema: z.object({
254
+ file_path: z
255
+ .string()
256
+ .describe("The absolute path to the file to write (must be absolute, not relative)"),
257
+ content: z.string().describe("The content to write to the file"),
258
+ }),
259
+ });
260
+ return [grep, read, write];
261
+ }
@@ -1,10 +1,13 @@
1
1
  import { z } from "zod";
2
2
  /** Built-in tool types. */
3
- export declare const zBuiltInToolType: z.ZodUnion<readonly [z.ZodLiteral<"todo_write">, z.ZodLiteral<"get_weather">, z.ZodLiteral<"web_search">]>;
3
+ export declare const zBuiltInToolType: z.ZodUnion<readonly [z.ZodLiteral<"todo_write">, z.ZodLiteral<"get_weather">, z.ZodLiteral<"web_search">, z.ZodLiteral<"filesystem">]>;
4
4
  /** Tool type - can be a built-in tool string or custom tool object. */
5
- export declare const zToolType: z.ZodUnion<readonly [z.ZodUnion<readonly [z.ZodLiteral<"todo_write">, z.ZodLiteral<"get_weather">, z.ZodLiteral<"web_search">]>, z.ZodObject<{
5
+ export declare const zToolType: z.ZodUnion<readonly [z.ZodUnion<readonly [z.ZodLiteral<"todo_write">, z.ZodLiteral<"get_weather">, z.ZodLiteral<"web_search">, z.ZodLiteral<"filesystem">]>, z.ZodObject<{
6
6
  type: z.ZodLiteral<"custom">;
7
7
  modulePath: z.ZodString;
8
+ }, z.core.$strip>, z.ZodObject<{
9
+ type: z.ZodLiteral<"filesystem">;
10
+ working_directory: z.ZodOptional<z.ZodString>;
8
11
  }, z.core.$strip>]>;
9
12
  export type ToolType = z.infer<typeof zToolType>;
10
13
  export type BuiltInToolType = z.infer<typeof zBuiltInToolType>;
@@ -4,11 +4,21 @@ export const zBuiltInToolType = z.union([
4
4
  z.literal("todo_write"),
5
5
  z.literal("get_weather"),
6
6
  z.literal("web_search"),
7
+ z.literal("filesystem"),
7
8
  ]);
8
9
  /** Custom tool schema. */
9
10
  const zCustomTool = z.object({
10
11
  type: z.literal("custom"),
11
12
  modulePath: z.string(),
12
13
  });
14
+ /** Filesystem tool schema. */
15
+ const zFilesystemTool = z.object({
16
+ type: z.literal("filesystem"),
17
+ working_directory: z.string().optional(),
18
+ });
13
19
  /** Tool type - can be a built-in tool string or custom tool object. */
14
- export const zToolType = z.union([zBuiltInToolType, zCustomTool]);
20
+ export const zToolType = z.union([
21
+ zBuiltInToolType,
22
+ zCustomTool,
23
+ zFilesystemTool,
24
+ ]);
@@ -5,6 +5,9 @@ export interface TemplateVars {
5
5
  tools: Array<string | {
6
6
  type: "custom";
7
7
  modulePath: string;
8
+ } | {
9
+ type: "filesystem";
10
+ working_directory?: string | undefined;
8
11
  }>;
9
12
  systemPrompt: string | null;
10
13
  hasWebSearch: boolean;
@@ -1,5 +1,5 @@
1
1
  export function getTemplateVars(name, definition) {
2
- const tools = definition.tools || [];
2
+ const tools = definition.tools ?? [];
3
3
  return {
4
4
  name,
5
5
  model: definition.model,
@@ -13,6 +13,7 @@ export function generatePackageJson(vars) {
13
13
  const dependencies = {
14
14
  "@townco/agent": "^0.1.14",
15
15
  "@agentclientprotocol/sdk": "^0.5.1",
16
+ "@anthropic-ai/sandbox-runtime": "^0.0.2",
16
17
  "@langchain/anthropic": "^1.0.0",
17
18
  "@langchain/core": "^1.0.3",
18
19
  "@langchain/exa": "^0.1.0",
@@ -105,7 +106,15 @@ export function generateTsConfig() {
105
106
  export function generateReadme(vars) {
106
107
  const toolsList = vars.tools.length > 0
107
108
  ? vars.tools
108
- .map((tool) => (typeof tool === "string" ? tool : tool.modulePath))
109
+ .map((tool) => {
110
+ if (typeof tool === "string")
111
+ return tool;
112
+ if (tool.type === "custom")
113
+ return tool.modulePath;
114
+ if (tool.type === "filesystem")
115
+ return `filesystem${tool.working_directory ? ` (${tool.working_directory})` : ""}`;
116
+ return "";
117
+ })
109
118
  .join(", ")
110
119
  : "None";
111
120
  const envVars = vars.hasWebSearch