@flue/sdk 0.3.10 → 0.4.0
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/README.md +15 -24
- package/dist/abort-Bg3qsAkU.mjs +43 -0
- package/dist/app.d.mts +106 -0
- package/dist/app.mjs +4 -0
- package/dist/client.d.mts +9 -3
- package/dist/client.mjs +10 -24
- package/dist/cloudflare/index.d.mts +10 -6
- package/dist/cloudflare/index.mjs +388 -26
- package/dist/cloudflare-model-BeiZ1pLz.d.mts +6 -0
- package/dist/config.d.mts +133 -0
- package/dist/config.mjs +195 -0
- package/dist/flue-app-CG8i4wNG.d.mts +184 -0
- package/dist/flue-app-DeTOZjPs.mjs +730 -0
- package/dist/index.d.mts +41 -19
- package/dist/index.mjs +451 -539
- package/dist/internal.d.mts +8 -274
- package/dist/internal.mjs +16 -430
- package/dist/{mcp-B13ZPduG.mjs → mcp-2SW_tpox.mjs} +19 -33
- package/dist/{mcp-CKMPhMDe.d.mts → mcp-C3UBXVkR.d.mts} +1 -1
- package/dist/node/index.d.mts +8 -12
- package/dist/node/index.mjs +94 -64
- package/dist/providers-DeFRIwp0.mjs +158 -0
- package/dist/result-K1IRhWKM.mjs +685 -0
- package/dist/sandbox.d.mts +25 -4
- package/dist/sandbox.mjs +44 -62
- package/dist/{session-CNOAfV45.mjs → session-CO_uGVOk.mjs} +490 -264
- package/dist/types-BAmV4f3Q.d.mts +727 -0
- package/package.json +13 -1
- package/dist/agent-BTB0809P.mjs +0 -453
- package/dist/command-helpers-5DpOaRIB.d.mts +0 -21
- package/dist/command-helpers-hTZKWK13.mjs +0 -37
- package/dist/types-CKcp6T-y.d.mts +0 -509
|
@@ -0,0 +1,685 @@
|
|
|
1
|
+
import { Type } from "@mariozechner/pi-ai";
|
|
2
|
+
import { toJsonSchema } from "@valibot/to-json-schema";
|
|
3
|
+
import * as v from "valibot";
|
|
4
|
+
|
|
5
|
+
//#region src/context.ts
|
|
6
|
+
/** Parse optional YAML frontmatter (--- delimited). Basic `key: value` only. */
|
|
7
|
+
function parseFrontmatterFile(content, defaultName) {
|
|
8
|
+
const frontmatterMatch = content.match(/^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/);
|
|
9
|
+
if (!frontmatterMatch) return {
|
|
10
|
+
name: defaultName,
|
|
11
|
+
description: "",
|
|
12
|
+
body: content.trim(),
|
|
13
|
+
frontmatter: {}
|
|
14
|
+
};
|
|
15
|
+
const rawFrontmatter = frontmatterMatch[1] ?? "";
|
|
16
|
+
const body = frontmatterMatch[2] ?? "";
|
|
17
|
+
const frontmatter = {};
|
|
18
|
+
for (const line of rawFrontmatter.split("\n")) {
|
|
19
|
+
const match = line.match(/^(\w+):\s*(.+)$/);
|
|
20
|
+
if (match?.[1] && match[2]) frontmatter[match[1]] = match[2].trim();
|
|
21
|
+
}
|
|
22
|
+
return {
|
|
23
|
+
name: frontmatter.name || defaultName,
|
|
24
|
+
description: frontmatter.description || "",
|
|
25
|
+
body: body.trim(),
|
|
26
|
+
frontmatter
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/** Read AGENTS.md (and CLAUDE.md if present) from a directory. Returns concatenated contents. */
|
|
30
|
+
async function readAgentsMd(env, basePath) {
|
|
31
|
+
const parts = [];
|
|
32
|
+
for (const filename of ["AGENTS.md", "CLAUDE.md"]) {
|
|
33
|
+
const filePath = basePath.endsWith("/") ? basePath + filename : `${basePath}/${filename}`;
|
|
34
|
+
if (await env.exists(filePath)) {
|
|
35
|
+
const content = await env.readFile(filePath);
|
|
36
|
+
parts.push(content.trim());
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return parts.join("\n\n");
|
|
40
|
+
}
|
|
41
|
+
/** Path to the skills directory under a given base path. */
|
|
42
|
+
function skillsDirIn(basePath) {
|
|
43
|
+
return basePath.endsWith("/") ? `${basePath}.agents/skills` : `${basePath}/.agents/skills`;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Resolve a skill referenced by relative path under `.agents/skills/`,
|
|
47
|
+
* returning the absolute filesystem path or `null` if the file doesn't
|
|
48
|
+
* exist.
|
|
49
|
+
*
|
|
50
|
+
* The relative path is taken as-is — no extension is auto-appended.
|
|
51
|
+
* Callers reference the full filename, e.g. `'triage/reproduce.md'`.
|
|
52
|
+
*
|
|
53
|
+
* Used by `session.skill()` when the caller passes a path-shaped name
|
|
54
|
+
* (contains `/` or ends in `.md`/`.markdown`). Path-based references
|
|
55
|
+
* bypass the skill registry entirely — the model is given the resolved
|
|
56
|
+
* path and reads the file directly. We don't parse the file here
|
|
57
|
+
* because nothing on the server side needs the frontmatter for these
|
|
58
|
+
* skills; only the model does, and it reads the file itself.
|
|
59
|
+
*/
|
|
60
|
+
async function resolveSkillFilePath(env, basePath, relPath) {
|
|
61
|
+
const filePath = `${skillsDirIn(basePath)}/${relPath}`;
|
|
62
|
+
if (!await env.exists(filePath)) return null;
|
|
63
|
+
return filePath;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Discover skills from `.agents/skills/<name>/SKILL.md` under basePath.
|
|
67
|
+
*
|
|
68
|
+
* Skill bodies are intentionally not retained — at call time the model
|
|
69
|
+
* reads the file from disk itself, which keeps relative references
|
|
70
|
+
* inside the skill resolvable from where they live and lets users edit
|
|
71
|
+
* skill files mid-session without re-initialising the agent. We parse
|
|
72
|
+
* the frontmatter here only to populate the system-prompt's "Available
|
|
73
|
+
* Skills" registry (name + description).
|
|
74
|
+
*/
|
|
75
|
+
async function discoverLocalSkills(env, basePath) {
|
|
76
|
+
const skillsDir = skillsDirIn(basePath);
|
|
77
|
+
if (!await env.exists(skillsDir)) return {};
|
|
78
|
+
const skills = {};
|
|
79
|
+
const entries = await env.readdir(skillsDir);
|
|
80
|
+
for (const entry of entries) {
|
|
81
|
+
const skillDir = `${skillsDir}/${entry}`;
|
|
82
|
+
try {
|
|
83
|
+
if (!(await env.stat(skillDir)).isDirectory) continue;
|
|
84
|
+
} catch {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const skillMdPath = `${skillDir}/SKILL.md`;
|
|
88
|
+
if (!await env.exists(skillMdPath)) continue;
|
|
89
|
+
const parsed = parseFrontmatterFile(await env.readFile(skillMdPath), entry);
|
|
90
|
+
skills[parsed.name] = {
|
|
91
|
+
name: parsed.name,
|
|
92
|
+
description: parsed.description
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
return skills;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Headless-mode preamble. Included once at the top of every session's
|
|
99
|
+
* system prompt so the model knows it's running without a human operator
|
|
100
|
+
* before the first turn — and doesn't get reminded of it on every
|
|
101
|
+
* `prompt()` / `skill()` call. Previously this lived in
|
|
102
|
+
* `result.ts:buildPromptText` / `buildSkillPrompt` and was inlined into
|
|
103
|
+
* each per-call user message; that was redundant noise once the harness
|
|
104
|
+
* gained tool-call shape (it can't ask questions or wait for input
|
|
105
|
+
* regardless of what the user message says).
|
|
106
|
+
*/
|
|
107
|
+
const HEADLESS_PREAMBLE = "You are running in headless mode with no human operator. Work autonomously — never ask questions, never wait for user input. Make your best judgment and proceed independently.";
|
|
108
|
+
function composeSystemPrompt(agentsMd, skills, env) {
|
|
109
|
+
const parts = [HEADLESS_PREAMBLE];
|
|
110
|
+
if (agentsMd) parts.push("", agentsMd);
|
|
111
|
+
const skillEntries = Object.values(skills);
|
|
112
|
+
if (skillEntries.length > 0) {
|
|
113
|
+
parts.push("", "## Available Skills", "", "Each skill below is documented in a markdown file under `.agents/skills/` (relative to your working directory). The default location is `.agents/skills/<name>/SKILL.md`. When asked to run a skill, read its file from disk and follow the instructions there literally — the skill body is not provided inline.", "");
|
|
114
|
+
for (const skill of skillEntries) {
|
|
115
|
+
const desc = skill.description ? ` — ${skill.description}` : "";
|
|
116
|
+
parts.push(`- **${skill.name}**${desc}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (env) {
|
|
120
|
+
const date = (/* @__PURE__ */ new Date()).toLocaleDateString("en-US", {
|
|
121
|
+
weekday: "short",
|
|
122
|
+
year: "numeric",
|
|
123
|
+
month: "short",
|
|
124
|
+
day: "numeric"
|
|
125
|
+
});
|
|
126
|
+
parts.push("", `Date: ${date}`);
|
|
127
|
+
parts.push(`Working directory: ${env.cwd}`);
|
|
128
|
+
if (env.directoryListing && env.directoryListing.length > 0) parts.push("", "Directory structure:", env.directoryListing.join("\n"));
|
|
129
|
+
}
|
|
130
|
+
return parts.join("\n");
|
|
131
|
+
}
|
|
132
|
+
/** Discover AGENTS.md, local skills, and directory listing from the session's cwd. */
|
|
133
|
+
async function discoverSessionContext(env) {
|
|
134
|
+
const cwd = env.cwd;
|
|
135
|
+
const agentsMd = await readAgentsMd(env, cwd);
|
|
136
|
+
const skills = await discoverLocalSkills(env, cwd);
|
|
137
|
+
let directoryListing;
|
|
138
|
+
try {
|
|
139
|
+
directoryListing = await env.readdir(cwd);
|
|
140
|
+
} catch {}
|
|
141
|
+
return {
|
|
142
|
+
systemPrompt: composeSystemPrompt(agentsMd, skills, {
|
|
143
|
+
cwd,
|
|
144
|
+
directoryListing
|
|
145
|
+
}),
|
|
146
|
+
skills
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
//#endregion
|
|
151
|
+
//#region src/agent.ts
|
|
152
|
+
const MAX_READ_LINES = 2e3;
|
|
153
|
+
const MAX_READ_BYTES = 50 * 1024;
|
|
154
|
+
const MAX_GREP_MATCHES = 100;
|
|
155
|
+
const MAX_GREP_LINE_LENGTH = 500;
|
|
156
|
+
const MAX_GLOB_RESULTS = 1e3;
|
|
157
|
+
const BUILTIN_TOOL_NAMES = new Set([
|
|
158
|
+
"read",
|
|
159
|
+
"write",
|
|
160
|
+
"edit",
|
|
161
|
+
"bash",
|
|
162
|
+
"grep",
|
|
163
|
+
"glob",
|
|
164
|
+
"task"
|
|
165
|
+
]);
|
|
166
|
+
function createTools(env, options) {
|
|
167
|
+
const tools = [
|
|
168
|
+
createReadTool(env),
|
|
169
|
+
createWriteTool(env),
|
|
170
|
+
createEditTool(env),
|
|
171
|
+
createBashTool(env),
|
|
172
|
+
createGrepTool(env),
|
|
173
|
+
createGlobTool(env)
|
|
174
|
+
];
|
|
175
|
+
if (options?.task) tools.push(createTaskTool(options.task, options.roles ?? {}));
|
|
176
|
+
return tools;
|
|
177
|
+
}
|
|
178
|
+
const ReadParams = Type.Object({
|
|
179
|
+
path: Type.String({ description: "Path to the file to read" }),
|
|
180
|
+
offset: Type.Optional(Type.Number({ description: "Line number to start from (1-indexed)" })),
|
|
181
|
+
limit: Type.Optional(Type.Number({ description: "Maximum number of lines to read" }))
|
|
182
|
+
});
|
|
183
|
+
function createReadTool(env) {
|
|
184
|
+
return {
|
|
185
|
+
name: "read",
|
|
186
|
+
label: "Read File",
|
|
187
|
+
description: "Read a file or list a directory. For files, output is truncated to 2000 lines or 50KB — use offset/limit for large files. For directories, returns the list of entries.",
|
|
188
|
+
parameters: ReadParams,
|
|
189
|
+
async execute(_toolCallId, params, signal) {
|
|
190
|
+
throwIfAborted(signal);
|
|
191
|
+
try {
|
|
192
|
+
if ((await env.stat(params.path)).isDirectory) {
|
|
193
|
+
const entries = await env.readdir(params.path);
|
|
194
|
+
return {
|
|
195
|
+
content: [{
|
|
196
|
+
type: "text",
|
|
197
|
+
text: entries.join("\n") || "(empty directory)"
|
|
198
|
+
}],
|
|
199
|
+
details: {
|
|
200
|
+
path: params.path,
|
|
201
|
+
isDirectory: true,
|
|
202
|
+
entries: entries.length
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
} catch {}
|
|
207
|
+
const allLines = (await env.readFile(params.path)).split("\n");
|
|
208
|
+
const startLine = params.offset ? Math.max(0, params.offset - 1) : 0;
|
|
209
|
+
if (startLine >= allLines.length) throw new Error(`Offset ${params.offset} is beyond end of file (${allLines.length} lines total)`);
|
|
210
|
+
const endLine = params.limit ? startLine + params.limit : allLines.length;
|
|
211
|
+
const { text: truncatedText, wasTruncated } = truncateHead(allLines.slice(startLine, endLine), MAX_READ_LINES, MAX_READ_BYTES);
|
|
212
|
+
let output = truncatedText;
|
|
213
|
+
if (wasTruncated) {
|
|
214
|
+
const shownEnd = startLine + truncatedText.split("\n").length;
|
|
215
|
+
output += `\n\n[Showing lines ${startLine + 1}-${shownEnd} of ${allLines.length}. Use offset=${shownEnd + 1} to continue.]`;
|
|
216
|
+
}
|
|
217
|
+
return {
|
|
218
|
+
content: [{
|
|
219
|
+
type: "text",
|
|
220
|
+
text: output
|
|
221
|
+
}],
|
|
222
|
+
details: {
|
|
223
|
+
path: params.path,
|
|
224
|
+
lines: allLines.length
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
const WriteParams = Type.Object({
|
|
231
|
+
path: Type.String({ description: "Path to the file to write" }),
|
|
232
|
+
content: Type.String({ description: "Content to write to the file" })
|
|
233
|
+
});
|
|
234
|
+
function createWriteTool(env) {
|
|
235
|
+
return {
|
|
236
|
+
name: "write",
|
|
237
|
+
label: "Write File",
|
|
238
|
+
description: "Write content to a file. Creates the file and parent directories if they do not exist.",
|
|
239
|
+
parameters: WriteParams,
|
|
240
|
+
async execute(_toolCallId, params, signal) {
|
|
241
|
+
throwIfAborted(signal);
|
|
242
|
+
const resolved = env.resolvePath(params.path);
|
|
243
|
+
const dir = resolved.replace(/\/[^/]*$/, "");
|
|
244
|
+
if (dir && dir !== resolved) await env.mkdir(dir, { recursive: true });
|
|
245
|
+
await env.writeFile(resolved, params.content);
|
|
246
|
+
return {
|
|
247
|
+
content: [{
|
|
248
|
+
type: "text",
|
|
249
|
+
text: `Successfully wrote ${params.content.length} bytes to ${params.path}`
|
|
250
|
+
}],
|
|
251
|
+
details: {
|
|
252
|
+
path: params.path,
|
|
253
|
+
size: params.content.length
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
const EditParams = Type.Object({
|
|
260
|
+
path: Type.String({ description: "Path to the file to edit" }),
|
|
261
|
+
oldText: Type.String({ description: "Exact text to find (must be unique)" }),
|
|
262
|
+
newText: Type.String({ description: "Replacement text" }),
|
|
263
|
+
replaceAll: Type.Optional(Type.Boolean({ description: "Replace all occurrences" }))
|
|
264
|
+
});
|
|
265
|
+
function createEditTool(env) {
|
|
266
|
+
return {
|
|
267
|
+
name: "edit",
|
|
268
|
+
label: "Edit File",
|
|
269
|
+
description: "Edit a file using exact text replacement. The oldText must match a unique region of the file. Use replaceAll to replace all occurrences.",
|
|
270
|
+
parameters: EditParams,
|
|
271
|
+
async execute(_toolCallId, params, signal) {
|
|
272
|
+
throwIfAborted(signal);
|
|
273
|
+
const content = await env.readFile(params.path);
|
|
274
|
+
if (params.replaceAll) {
|
|
275
|
+
const newContent = content.replaceAll(params.oldText, params.newText);
|
|
276
|
+
if (newContent === content) throw new Error(`Could not find the text in ${params.path}. No changes made.`);
|
|
277
|
+
await env.writeFile(params.path, newContent);
|
|
278
|
+
const count = content.split(params.oldText).length - 1;
|
|
279
|
+
return {
|
|
280
|
+
content: [{
|
|
281
|
+
type: "text",
|
|
282
|
+
text: `Replaced ${count} occurrences in ${params.path}`
|
|
283
|
+
}],
|
|
284
|
+
details: {
|
|
285
|
+
path: params.path,
|
|
286
|
+
replacements: count
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
const occurrences = countOccurrences(content, params.oldText);
|
|
291
|
+
if (occurrences === 0) throw new Error(`Could not find the exact text in ${params.path}. Make sure your oldText matches exactly, including whitespace and indentation.`);
|
|
292
|
+
if (occurrences > 1) throw new Error(`Found ${occurrences} occurrences of the text in ${params.path}. Provide more surrounding context to make the match unique, or use replaceAll.`);
|
|
293
|
+
const newContent = content.replace(params.oldText, params.newText);
|
|
294
|
+
await env.writeFile(params.path, newContent);
|
|
295
|
+
return {
|
|
296
|
+
content: [{
|
|
297
|
+
type: "text",
|
|
298
|
+
text: `Successfully edited ${params.path}`
|
|
299
|
+
}],
|
|
300
|
+
details: { path: params.path }
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
const BashParams = Type.Object({
|
|
306
|
+
command: Type.String({ description: "Bash command to execute" }),
|
|
307
|
+
timeout: Type.Optional(Type.Number({ description: "Timeout in seconds" }))
|
|
308
|
+
});
|
|
309
|
+
function createBashTool(env) {
|
|
310
|
+
return {
|
|
311
|
+
name: "bash",
|
|
312
|
+
label: "Run Command",
|
|
313
|
+
description: "Execute a bash command. Returns stdout and stderr. Output is truncated to the last 2000 lines or 50KB.",
|
|
314
|
+
parameters: BashParams,
|
|
315
|
+
async execute(_toolCallId, params, signal) {
|
|
316
|
+
throwIfAborted(signal);
|
|
317
|
+
const timeoutSignal = typeof params.timeout === "number" ? AbortSignal.timeout(params.timeout * 1e3) : void 0;
|
|
318
|
+
const execSignal = signal && timeoutSignal ? AbortSignal.any([signal, timeoutSignal]) : signal ?? timeoutSignal;
|
|
319
|
+
const timedOut = () => formatBashResult({
|
|
320
|
+
stdout: "",
|
|
321
|
+
stderr: `[flue] Command timed out after ${params.timeout} seconds.`,
|
|
322
|
+
exitCode: 124
|
|
323
|
+
}, params.command);
|
|
324
|
+
try {
|
|
325
|
+
const result = await env.exec(params.command, {
|
|
326
|
+
timeout: params.timeout,
|
|
327
|
+
signal: execSignal
|
|
328
|
+
});
|
|
329
|
+
if (timeoutSignal?.aborted && !signal?.aborted) return timedOut();
|
|
330
|
+
return formatBashResult(result, params.command);
|
|
331
|
+
} catch (err) {
|
|
332
|
+
if (timeoutSignal?.aborted && !signal?.aborted) return timedOut();
|
|
333
|
+
throw err;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
const TaskParams = Type.Object({
|
|
339
|
+
description: Type.Optional(Type.String({ description: "Short human-readable label for the delegated work" })),
|
|
340
|
+
prompt: Type.String({ description: "Focused instructions for the child agent" }),
|
|
341
|
+
role: Type.Optional(Type.String({ description: "Role to use for the child agent" })),
|
|
342
|
+
cwd: Type.Optional(Type.String({ description: "Working directory for the child agent. AGENTS.md and skills are discovered from here." }))
|
|
343
|
+
});
|
|
344
|
+
function createTaskTool(runTask, roles) {
|
|
345
|
+
const roleNames = Object.keys(roles);
|
|
346
|
+
return {
|
|
347
|
+
name: "task",
|
|
348
|
+
label: "Run Task",
|
|
349
|
+
description: "Delegate a focused task to a detached child agent with its own context. Use this for independent research, file exploration, or parallel work. The task returns only its final answer to this conversation." + (roleNames.length > 0 ? ` Available roles: ${roleNames.join(", ")}.` : " No roles are currently defined."),
|
|
350
|
+
parameters: TaskParams,
|
|
351
|
+
async execute(_toolCallId, params, signal) {
|
|
352
|
+
throwIfAborted(signal);
|
|
353
|
+
return runTask(params, signal);
|
|
354
|
+
}
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
function formatBashResult(result, command) {
|
|
358
|
+
const { text: output } = truncateTail((result.stdout + (result.stderr ? "\n" + result.stderr : "")).trim(), MAX_READ_LINES, MAX_READ_BYTES);
|
|
359
|
+
const exitLine = `Command exited with code ${result.exitCode}`;
|
|
360
|
+
return {
|
|
361
|
+
content: [{
|
|
362
|
+
type: "text",
|
|
363
|
+
text: result.exitCode === 0 ? output || "(no output)" : `${output || "(no output)"}\n\n${exitLine}`
|
|
364
|
+
}],
|
|
365
|
+
details: {
|
|
366
|
+
command,
|
|
367
|
+
exitCode: result.exitCode
|
|
368
|
+
}
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
const GrepParams = Type.Object({
|
|
372
|
+
pattern: Type.String({ description: "Search pattern (regex)" }),
|
|
373
|
+
path: Type.Optional(Type.String({ description: "Directory or file to search (default: .)" })),
|
|
374
|
+
include: Type.Optional(Type.String({ description: "Glob filter, e.g. \"*.ts\"" }))
|
|
375
|
+
});
|
|
376
|
+
function createGrepTool(env) {
|
|
377
|
+
return {
|
|
378
|
+
name: "grep",
|
|
379
|
+
label: "Search Files",
|
|
380
|
+
description: "Search file contents for a regex pattern. Returns matching lines with file paths and line numbers.",
|
|
381
|
+
parameters: GrepParams,
|
|
382
|
+
async execute(_toolCallId, params, signal) {
|
|
383
|
+
throwIfAborted(signal);
|
|
384
|
+
const searchPath = params.path || ".";
|
|
385
|
+
let cmd = `grep -rn ${shellQuote(params.pattern)} ${shellQuote(searchPath)}`;
|
|
386
|
+
if (params.include) cmd = `grep -rn --include=${shellQuote(params.include)} ${shellQuote(params.pattern)} ${shellQuote(searchPath)}`;
|
|
387
|
+
const result = await env.exec(cmd);
|
|
388
|
+
if (result.exitCode === 1 && !result.stdout.trim()) return {
|
|
389
|
+
content: [{
|
|
390
|
+
type: "text",
|
|
391
|
+
text: "No matches found."
|
|
392
|
+
}],
|
|
393
|
+
details: { matchCount: 0 }
|
|
394
|
+
};
|
|
395
|
+
if (result.exitCode > 1) throw new Error(`grep failed: ${result.stderr}`);
|
|
396
|
+
const lines = result.stdout.trim().split("\n");
|
|
397
|
+
let finalOutput = lines.slice(0, MAX_GREP_MATCHES).map((line) => line.length > MAX_GREP_LINE_LENGTH ? line.slice(0, MAX_GREP_LINE_LENGTH) + "..." : line).join("\n");
|
|
398
|
+
if (lines.length > MAX_GREP_MATCHES) finalOutput += `\n\n[Showing ${MAX_GREP_MATCHES} of ${lines.length} matches. Narrow your search.]`;
|
|
399
|
+
return {
|
|
400
|
+
content: [{
|
|
401
|
+
type: "text",
|
|
402
|
+
text: finalOutput
|
|
403
|
+
}],
|
|
404
|
+
details: { matchCount: Math.min(lines.length, MAX_GREP_MATCHES) }
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
const GlobParams = Type.Object({
|
|
410
|
+
pattern: Type.String({ description: "Filename pattern, e.g. \"*.ts\"" }),
|
|
411
|
+
path: Type.Optional(Type.String({ description: "Directory to search in (default: .)" }))
|
|
412
|
+
});
|
|
413
|
+
function createGlobTool(env) {
|
|
414
|
+
return {
|
|
415
|
+
name: "glob",
|
|
416
|
+
label: "Find Files",
|
|
417
|
+
description: "Find files by filename pattern using shell find -name semantics. Returns matching file paths.",
|
|
418
|
+
parameters: GlobParams,
|
|
419
|
+
async execute(_toolCallId, params, signal) {
|
|
420
|
+
throwIfAborted(signal);
|
|
421
|
+
const cmd = `find ${shellQuote(params.path || ".")} -type f -name ${shellQuote(params.pattern)} 2>/dev/null | head -${MAX_GLOB_RESULTS}`;
|
|
422
|
+
const result = await env.exec(cmd);
|
|
423
|
+
if (result.exitCode !== 0 && !result.stdout.trim()) return {
|
|
424
|
+
content: [{
|
|
425
|
+
type: "text",
|
|
426
|
+
text: "No files found matching pattern."
|
|
427
|
+
}],
|
|
428
|
+
details: { matchCount: 0 }
|
|
429
|
+
};
|
|
430
|
+
const paths = result.stdout.trim().split("\n").filter(Boolean);
|
|
431
|
+
if (paths.length === 0) return {
|
|
432
|
+
content: [{
|
|
433
|
+
type: "text",
|
|
434
|
+
text: "No files found matching pattern."
|
|
435
|
+
}],
|
|
436
|
+
details: { matchCount: 0 }
|
|
437
|
+
};
|
|
438
|
+
return {
|
|
439
|
+
content: [{
|
|
440
|
+
type: "text",
|
|
441
|
+
text: paths.join("\n")
|
|
442
|
+
}],
|
|
443
|
+
details: { matchCount: paths.length }
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
function throwIfAborted(signal) {
|
|
449
|
+
if (signal?.aborted) throw new Error("Operation aborted");
|
|
450
|
+
}
|
|
451
|
+
function countOccurrences(str, substr) {
|
|
452
|
+
let count = 0;
|
|
453
|
+
let pos = str.indexOf(substr, 0);
|
|
454
|
+
while (pos !== -1) {
|
|
455
|
+
count++;
|
|
456
|
+
pos = str.indexOf(substr, pos + substr.length);
|
|
457
|
+
}
|
|
458
|
+
return count;
|
|
459
|
+
}
|
|
460
|
+
function shellQuote(arg) {
|
|
461
|
+
return `'${arg.replace(/'/g, `'\\''`)}'`;
|
|
462
|
+
}
|
|
463
|
+
function truncateHead(lines, maxLines, maxBytes) {
|
|
464
|
+
let result = "";
|
|
465
|
+
let lineCount = 0;
|
|
466
|
+
let wasTruncated = false;
|
|
467
|
+
for (const line of lines) {
|
|
468
|
+
if (lineCount >= maxLines) {
|
|
469
|
+
wasTruncated = true;
|
|
470
|
+
break;
|
|
471
|
+
}
|
|
472
|
+
const next = lineCount === 0 ? line : "\n" + line;
|
|
473
|
+
if (result.length + next.length > maxBytes) {
|
|
474
|
+
wasTruncated = true;
|
|
475
|
+
break;
|
|
476
|
+
}
|
|
477
|
+
result += next;
|
|
478
|
+
lineCount++;
|
|
479
|
+
}
|
|
480
|
+
return {
|
|
481
|
+
text: result,
|
|
482
|
+
wasTruncated
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
function truncateTail(text, maxLines, maxBytes) {
|
|
486
|
+
const lines = text.split("\n");
|
|
487
|
+
if (lines.length <= maxLines && text.length <= maxBytes) return {
|
|
488
|
+
text,
|
|
489
|
+
wasTruncated: false
|
|
490
|
+
};
|
|
491
|
+
let result = lines.slice(-maxLines).join("\n");
|
|
492
|
+
if (result.length > maxBytes) result = result.slice(-maxBytes);
|
|
493
|
+
return {
|
|
494
|
+
text: result,
|
|
495
|
+
wasTruncated: true
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
//#endregion
|
|
500
|
+
//#region src/result.ts
|
|
501
|
+
/**
|
|
502
|
+
* Names of the SDK-injected tools used to capture schema-typed results.
|
|
503
|
+
* Surfaced for diagnostics and logging; not part of the public API.
|
|
504
|
+
*/
|
|
505
|
+
const FINISH_TOOL_NAME = "finish";
|
|
506
|
+
const GIVE_UP_TOOL_NAME = "give_up";
|
|
507
|
+
/** Footer appended to user prompts/skill bodies when a `result` schema is set. */
|
|
508
|
+
function buildResultFooter() {
|
|
509
|
+
return [
|
|
510
|
+
"",
|
|
511
|
+
`When the task is complete, call the \`${FINISH_TOOL_NAME}\` tool with your final answer as its arguments. The arguments are validated against the required schema; if validation fails you will receive an error and may try again.`,
|
|
512
|
+
`If you determine that you cannot complete the task or cannot produce a result that conforms to the required schema, call the \`${GIVE_UP_TOOL_NAME}\` tool with a clear \`reason\`.`,
|
|
513
|
+
`Do not respond with the answer in plain text — only a successful \`${FINISH_TOOL_NAME}\` (or \`${GIVE_UP_TOOL_NAME}\`) call counts.`
|
|
514
|
+
].join("\n");
|
|
515
|
+
}
|
|
516
|
+
/** Follow-up prompt sent when the LLM ends a turn without calling `finish` or `give_up`. */
|
|
517
|
+
function buildResultFollowUpPrompt() {
|
|
518
|
+
return [`You ended your turn without calling \`${FINISH_TOOL_NAME}\` or \`${GIVE_UP_TOOL_NAME}\`.`, `Either call \`${FINISH_TOOL_NAME}\` with your final answer, or call \`${GIVE_UP_TOOL_NAME}\` with a reason if you cannot determine the answer.`].join(" ");
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* Build the user-facing prompt text for a `session.skill('<name>')` call,
|
|
522
|
+
* where `<name>` is a name registered in the session's skill registry.
|
|
523
|
+
*
|
|
524
|
+
* The system prompt's "Available Skills" list tells the model where the
|
|
525
|
+
* skill lives (`.agents/skills/<name>/SKILL.md`) and how to run it (read
|
|
526
|
+
* the file, follow its instructions). The per-call message just names
|
|
527
|
+
* the skill plus any arguments — no inlined body, no path hint.
|
|
528
|
+
*/
|
|
529
|
+
function buildSkillByNamePrompt(name, args, schema) {
|
|
530
|
+
const parts = [`Run the skill named "${name}".`];
|
|
531
|
+
if (args && Object.keys(args).length > 0) parts.push("", "Arguments:", JSON.stringify(args, null, 2));
|
|
532
|
+
if (schema) parts.push(buildResultFooter());
|
|
533
|
+
return parts.join("\n");
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* Build the user-facing prompt text for a `session.skill('<path>')` call,
|
|
537
|
+
* where `<path>` is a relative path under `.agents/skills/` (e.g.
|
|
538
|
+
* `'triage/reproduce.md'`). Path-based references bypass the registry
|
|
539
|
+
* — the skill isn't named in the system prompt's "Available Skills"
|
|
540
|
+
* list — so we hand the model the resolved absolute path explicitly.
|
|
541
|
+
*/
|
|
542
|
+
function buildSkillByPathPrompt(relPath, resolvedPath, args, schema) {
|
|
543
|
+
const parts = [
|
|
544
|
+
`Run the skill file \`${relPath}\`.`,
|
|
545
|
+
"",
|
|
546
|
+
`The file can be found at ${resolvedPath}.`
|
|
547
|
+
];
|
|
548
|
+
if (args && Object.keys(args).length > 0) parts.push("", "Arguments:", JSON.stringify(args, null, 2));
|
|
549
|
+
if (schema) parts.push(buildResultFooter());
|
|
550
|
+
return parts.join("\n");
|
|
551
|
+
}
|
|
552
|
+
function buildPromptText(text, schema) {
|
|
553
|
+
const parts = [text];
|
|
554
|
+
if (schema) parts.push(buildResultFooter());
|
|
555
|
+
return parts.join("\n");
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* Produce the per-call `finish` and `give_up` tool pair for a given valibot schema.
|
|
559
|
+
*
|
|
560
|
+
* - `finish`'s parameters are derived from the schema via `@valibot/to-json-schema`.
|
|
561
|
+
* Non-object top-level schemas are wrapped in a `{ result: <schema> }` envelope
|
|
562
|
+
* because every LLM provider expects tool arguments to be a top-level object.
|
|
563
|
+
* - Pi-agent-core validates args against the JSON Schema before calling `execute`.
|
|
564
|
+
* Inside `execute` we additionally run `valibot.safeParse` to enforce
|
|
565
|
+
* valibot-specific refinements and to obtain the parsed output (transforms,
|
|
566
|
+
* defaults, coercion). On valibot failure we throw — pi-agent-core surfaces
|
|
567
|
+
* the throw as a tool-error tool-result, so the LLM can self-correct.
|
|
568
|
+
* - First successful `finish` (or `give_up`) call wins. Subsequent calls return
|
|
569
|
+
* a tool error rather than throwing, to keep the conversation transcript natural.
|
|
570
|
+
* - Successful calls set `terminate: true` so pi-agent-core ends the loop after
|
|
571
|
+
* the current tool batch.
|
|
572
|
+
*/
|
|
573
|
+
function createResultTools(schema) {
|
|
574
|
+
let outcome = { type: "pending" };
|
|
575
|
+
const wrapped = needsEnvelope(schema);
|
|
576
|
+
const innerJsonSchema = stripJsonSchemaMeta(toJsonSchema(schema, { errorMode: "ignore" }));
|
|
577
|
+
const finishParameters = wrapped ? {
|
|
578
|
+
type: "object",
|
|
579
|
+
properties: { result: innerJsonSchema },
|
|
580
|
+
required: ["result"],
|
|
581
|
+
additionalProperties: false
|
|
582
|
+
} : innerJsonSchema;
|
|
583
|
+
return {
|
|
584
|
+
tools: [{
|
|
585
|
+
name: FINISH_TOOL_NAME,
|
|
586
|
+
label: FINISH_TOOL_NAME,
|
|
587
|
+
description: `Call this tool when the task is complete. Provide your final answer as the arguments. The arguments are validated against the required schema; if validation fails you will receive an error message and may try again. The first successful \`${FINISH_TOOL_NAME}\` call wins — once the task is finished, do not call \`${FINISH_TOOL_NAME}\` again.`,
|
|
588
|
+
parameters: finishParameters,
|
|
589
|
+
async execute(_toolCallId, params) {
|
|
590
|
+
if (outcome.type !== "pending") return alreadyDoneToolError(outcome);
|
|
591
|
+
const candidate = wrapped ? params.result : params;
|
|
592
|
+
const parsed = v.safeParse(schema, candidate);
|
|
593
|
+
if (!parsed.success) {
|
|
594
|
+
const issues = parsed.issues.map((i) => i.message + (i.path ? ` (at ${formatIssuePath(i.path)})` : "")).join("; ");
|
|
595
|
+
throw new Error(`Result does not match the required schema: ${issues}. Please call \`${FINISH_TOOL_NAME}\` again with a corrected payload.`);
|
|
596
|
+
}
|
|
597
|
+
outcome = {
|
|
598
|
+
type: "finished",
|
|
599
|
+
value: parsed.output
|
|
600
|
+
};
|
|
601
|
+
return {
|
|
602
|
+
content: [{
|
|
603
|
+
type: "text",
|
|
604
|
+
text: "Result accepted. The task is complete."
|
|
605
|
+
}],
|
|
606
|
+
details: {
|
|
607
|
+
tool: FINISH_TOOL_NAME,
|
|
608
|
+
result: parsed.output
|
|
609
|
+
},
|
|
610
|
+
terminate: true
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
}, {
|
|
614
|
+
name: GIVE_UP_TOOL_NAME,
|
|
615
|
+
label: GIVE_UP_TOOL_NAME,
|
|
616
|
+
description: "Call this tool only if you have determined that you cannot complete the task or cannot produce a result that conforms to the required schema. Provide a clear `reason`. This ends the task with a failure.",
|
|
617
|
+
parameters: {
|
|
618
|
+
type: "object",
|
|
619
|
+
properties: { reason: {
|
|
620
|
+
type: "string",
|
|
621
|
+
minLength: 1,
|
|
622
|
+
description: "A clear explanation of why the task cannot be completed."
|
|
623
|
+
} },
|
|
624
|
+
required: ["reason"],
|
|
625
|
+
additionalProperties: false
|
|
626
|
+
},
|
|
627
|
+
async execute(_toolCallId, params) {
|
|
628
|
+
if (outcome.type !== "pending") return alreadyDoneToolError(outcome);
|
|
629
|
+
const reason = params.reason;
|
|
630
|
+
if (typeof reason !== "string" || reason.trim().length === 0) throw new Error(`\`${GIVE_UP_TOOL_NAME}\` requires a non-empty \`reason\` string.`);
|
|
631
|
+
outcome = {
|
|
632
|
+
type: "gave_up",
|
|
633
|
+
reason
|
|
634
|
+
};
|
|
635
|
+
return {
|
|
636
|
+
content: [{
|
|
637
|
+
type: "text",
|
|
638
|
+
text: "Acknowledged."
|
|
639
|
+
}],
|
|
640
|
+
details: {
|
|
641
|
+
tool: GIVE_UP_TOOL_NAME,
|
|
642
|
+
reason
|
|
643
|
+
},
|
|
644
|
+
terminate: true
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
}],
|
|
648
|
+
getOutcome: () => outcome
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
function needsEnvelope(schema) {
|
|
652
|
+
return schema.type !== "object";
|
|
653
|
+
}
|
|
654
|
+
function stripJsonSchemaMeta(jsonSchema) {
|
|
655
|
+
const { $schema: _schema, ...rest } = jsonSchema;
|
|
656
|
+
return rest;
|
|
657
|
+
}
|
|
658
|
+
function formatIssuePath(path) {
|
|
659
|
+
return path.map((p) => typeof p.key === "number" ? `[${p.key}]` : `.${String(p.key ?? "?")}`).join("").replace(/^\./, "");
|
|
660
|
+
}
|
|
661
|
+
function alreadyDoneToolError(outcome) {
|
|
662
|
+
return {
|
|
663
|
+
content: [{
|
|
664
|
+
type: "text",
|
|
665
|
+
text: `${outcome.type === "finished" ? "A result was already submitted; the task is complete." : "The task was already given up; it cannot be resumed."} Do not call this tool again.`
|
|
666
|
+
}],
|
|
667
|
+
details: { alreadyDone: true }
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
/**
|
|
671
|
+
* Thrown when the LLM calls the `give_up` tool, indicating it cannot produce a
|
|
672
|
+
* result that conforms to the required schema. Carries the LLM-supplied
|
|
673
|
+
* `reason` and the assistant transcript leading up to the give-up.
|
|
674
|
+
*/
|
|
675
|
+
var ResultUnavailableError = class extends Error {
|
|
676
|
+
constructor(reason, assistantText) {
|
|
677
|
+
super(`The agent gave up: ${reason}`);
|
|
678
|
+
this.reason = reason;
|
|
679
|
+
this.assistantText = assistantText;
|
|
680
|
+
this.name = "ResultUnavailableError";
|
|
681
|
+
}
|
|
682
|
+
};
|
|
683
|
+
|
|
684
|
+
//#endregion
|
|
685
|
+
export { buildSkillByPathPrompt as a, createTools as c, parseFrontmatterFile as d, resolveSkillFilePath as f, buildSkillByNamePrompt as i, formatBashResult as l, buildPromptText as n, createResultTools as o, skillsDirIn as p, buildResultFollowUpPrompt as r, BUILTIN_TOOL_NAMES as s, ResultUnavailableError as t, discoverSessionContext as u };
|