-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtools.ts
More file actions
220 lines (215 loc) · 9.31 KB
/
Copy pathtools.ts
File metadata and controls
220 lines (215 loc) · 9.31 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
// Toolset for the agent (ax ReAct functions). Mirrors Claude Code's core:
// bash, read, write, edit, glob, grep. ax executes these in a loop during
// forward() and emits a `Tool: <name>` span per call -> nested in motel.
// Each call also emits a ToolEvent so the TUI can render tool activity live.
//
// NOTE: unsandboxed — runs real shell/fs in the process cwd. Local-dev only.
import { AxFunctionError, type AxFunction } from "@ax-llm/ax"
import { $ } from "bun"
const cap = (s: string, n = 20000) => (s.length > n ? `${s.slice(0, n)}\n…[truncated ${s.length - n} chars]` : s)
// Throw AxFunctionError so ax records a `function.error` span event, marks the
// tool result isError (-> red row in the TUI), and feeds the message back to the
// model as fixing instructions. Returning an "error:" string instead would make
// ax mark the Tool span green -> the failure is invisible in motel and the UI.
const fail = (field: string, message: string): never => {
throw new AxFunctionError([{ field, message }])
}
const readText = async (path: string) => {
try {
return await Bun.file(path).text()
} catch (e: any) {
return fail("path", `cannot read ${path}: ${e.message}`)
}
}
type RgOpts = { pattern: string; output_mode?: "content" | "files_with_matches" | "count"; glob?: string | undefined; context?: number | undefined; where: string }
const buildRgArgs = ({ pattern, output_mode = "files_with_matches", glob, context, where }: RgOpts): Array<string> => {
const args = ["--hidden", "--max-columns", "500"]
if (output_mode === "files_with_matches") args.push("-l")
else if (output_mode === "count") args.push("-c")
else {
args.push("-n")
if (context && context > 0) args.push("-C", String(context))
}
if (glob) args.push("--glob", glob)
if (pattern.startsWith("-")) args.push("-e", pattern)
else args.push(pattern)
args.push(where)
return args
}
// BASE_TOOLS = the file/shell/web tools. These are the ONLY tools an orchestration
// LEAF may carry: the structural one-level recursion guard (rlm-workflow.ts) builds its
// sub-run leaf gens with BASE_TOOLS, never BASE_TOOLS+RLM_WORKFLOW_TOOLS, so a leaf physically
// cannot re-orchestrate. The main chat gen (agent.ts) gets BASE_TOOLS + RLM_WORKFLOW_TOOLS.
export const BASE_TOOLS: Array<AxFunction> = [
{
name: "bash",
description:
"Run a shell command and return combined stdout+stderr. Use for running code, git, builds, tests, installing deps, listing files, anything a terminal can do.",
parameters: {
type: "object",
properties: { command: { type: "string", description: "shell command to run" } },
required: ["command"],
},
func: async ({ command }: { command: string }) => {
try {
const out = await $`bash -c ${command}`.text()
return cap(out) || "(no output)"
} catch (e: any) {
return cap(`exit ${e.exitCode ?? "?"}\n${e.stdout?.toString?.() ?? ""}\n${e.stderr?.toString?.() ?? e.message ?? e}`)
}
},
},
{
name: "read_file",
description:
"Read a file's contents. Optionally read a specific line range with offset and limit. Useful for large files.",
parameters: {
type: "object",
properties: {
path: { type: "string", description: "file path" },
offset: { type: "number", description: "1-indexed line number to start reading from" },
limit: { type: "number", description: "maximum number of lines to read" },
},
required: ["path"],
},
func: async ({ path, offset, limit }: { path: string; offset?: number; limit?: number }) => {
const raw = await readText(path)
const lines = raw.split("\n")
const total = lines.length
const start = offset && offset > 0 ? offset - 1 : 0
const end = limit && limit > 0 ? start + limit : total
const selected = lines.slice(start, end)
const prefix = offset || limit ? `[lines ${start + 1}-${Math.min(end, total)} of ${total}]\n` : ""
return cap(prefix + selected.join("\n"), 40000)
},
},
{
name: "write_file",
description: "Create or overwrite a file with the given content.",
parameters: {
type: "object",
properties: { path: { type: "string", description: "file path" }, content: { type: "string", description: "file content" } },
required: ["path", "content"],
},
func: async ({ path, content }: { path: string; content: string }) => {
try {
await Bun.write(path, content)
return `wrote ${content.length} bytes to ${path}`
} catch (e: any) {
return fail("path", `cannot write ${path}: ${e.message}`)
}
},
},
{
name: "edit_file",
description:
"Replace old_string with new_string in a file. old_string must match exactly. Set replace_all to true to replace every occurrence.",
parameters: {
type: "object",
properties: {
path: { type: "string", description: "file path" },
old_string: { type: "string", description: "exact text to replace" },
new_string: { type: "string", description: "replacement text" },
replace_all: { type: "boolean", description: "replace all occurrences" },
},
required: ["path", "old_string", "new_string"],
},
func: async ({ path, old_string, new_string, replace_all }: { path: string; old_string: string; new_string: string; replace_all?: boolean }) => {
try {
const cur = await Bun.file(path).text()
if (!cur.includes(old_string)) return fail("old_string", `old_string not found in ${path}`)
const next = replace_all ? cur.split(old_string).join(new_string) : cur.replace(old_string, new_string)
await Bun.write(path, next)
return `edited ${path}`
} catch (e: any) {
if (e instanceof AxFunctionError) throw e
return fail("path", `cannot edit ${path}: ${e.message}`)
}
},
},
{
name: "glob",
description: "Find files by glob pattern (e.g. 'src/**/*.ts').",
parameters: { type: "object", properties: { pattern: { type: "string", description: "glob pattern" } }, required: ["pattern"] },
func: async ({ pattern }: { pattern: string }) => {
try {
const hits = await Array.fromAsync(new Bun.Glob(pattern).scan({ dot: false }))
return cap(hits.slice(0, 200).join("\n"), 8000) || "(no matches)"
} catch (e: any) {
return fail("pattern", `bad glob '${pattern}': ${e.message}`)
}
},
},
{
name: "grep",
description:
"Search file contents with ripgrep. Supports output_mode: 'files_with_matches' (default), 'content', or 'count'. Use context, head_limit, and offset to control results.",
parameters: {
type: "object",
properties: {
pattern: { type: "string", description: "regex pattern" },
path: { type: "string", description: "dir or file to search (default cwd)" },
output_mode: { type: "string", enum: ["content", "files_with_matches", "count"], description: "output mode" },
glob: { type: "string", description: "glob filter (e.g. '*.ts')" },
context: { type: "number", description: "lines of context around each match" },
head_limit: { type: "number", description: "limit output lines/entries" },
offset: { type: "number", description: "skip first N lines/entries" },
},
required: ["pattern"],
},
func: async ({
pattern,
path,
output_mode = "files_with_matches",
glob,
context,
head_limit,
offset,
}: {
pattern: string
path?: string
output_mode?: "content" | "files_with_matches" | "count"
glob?: string
context?: number
head_limit?: number
offset?: number
}) => {
const args = buildRgArgs({ pattern, output_mode, glob, context, where: path ?? "." })
try {
const out = await $`rg ${args}`.text()
const lines = out.split("\n").filter((l) => l.length > 0)
const start = offset && offset > 0 ? offset : 0
const limit = head_limit && head_limit > 0 ? head_limit : 250
const sliced = lines.slice(start, start + limit)
const suffix = lines.length - start > limit ? `\n…[truncated ${lines.length - start - limit} more]` : ""
return cap(sliced.join("\n") + suffix, 12000) || "(no matches)"
} catch (e: any) {
// rg exit 1 = no matches (not an error). exit >=2 = real failure
// (bad regex, unreadable path) -> surface as a tool error, not a silent
// "(no matches)" that hides the bug from the model and the trace.
if (e?.exitCode === 1) return "(no matches)"
return fail("pattern", `ripgrep error: ${String(e?.stderr ?? e?.message ?? e).slice(0, 500)}`)
}
},
},
{
name: "web_fetch",
description: "Fetch a URL and return the response body as text. Useful for reading docs, issues, or raw files.",
parameters: {
type: "object",
properties: { url: { type: "string", description: "URL to fetch" } },
required: ["url"],
},
func: async ({ url }: { url: string }) => {
try {
const res = await fetch(url, { redirect: "follow" })
if (!res.ok) return fail("url", `HTTP ${res.status} ${res.statusText}`)
const text = await res.text()
return cap(text, 20000) || "(empty response)"
} catch (e: any) {
if (e instanceof AxFunctionError) throw e
return fail("url", `fetch failed: ${e.message}`)
}
},
},
]