split tool definition from run harness
This commit is contained in:
1 parent
ff65f18f80
commit
10d8737e74
2 files changed
+266
-273
No files matched your search
+12
-273
@@ -6,229 +6,28 @@
|
|||||||
*
|
*
|
||||||
* Given a prompt for a target agent it:
|
* Given a prompt for a target agent it:
|
||||||
* 1. Resolves the `read` / `write` permission scopes.
|
* 1. Resolves the `read` / `write` permission scopes.
|
||||||
* 2. Collects the `open` files and directories as prompt context.
|
* 2. Assembles the tool specs from `tools` + `defaultTools`.
|
||||||
* 3. Assembles the tool specs from `tools` + `defaultTools`.
|
* 3. Runs the prompt/tool-call loop: send the prompt, execute any tool
|
||||||
* 4. Runs the prompt/tool-call loop: send the prompt, execute any tool
|
|
||||||
* calls locally, feed the results back, repeat until the agent
|
* calls locally, feed the results back, repeat until the agent
|
||||||
* returns a plain response.
|
* returns a plain response.
|
||||||
|
*
|
||||||
|
* Permission scopes and tool definitions live in 'tools.js'.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as fs from 'fs';
|
import { resolveScope, buildTools } from './tools.js';
|
||||||
import * as path from 'path';
|
|
||||||
|
|
||||||
const MAX_READ_BYTES = 10*1024;
|
|
||||||
const MAX_ITERATIONS = 20;
|
const MAX_ITERATIONS = 20;
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ *
|
/* ------------------------------------------------------------------ *
|
||||||
* Scopes / permissions
|
* Tool executor
|
||||||
* ------------------------------------------------------------------ */
|
* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalize a scope descriptor into an absolute allow-list.
|
|
||||||
*
|
|
||||||
* null | true -> null (allow everything)
|
|
||||||
* false | [] -> [] (allow nothing)
|
|
||||||
* [entry, ...] -> resolved absolute paths
|
|
||||||
*
|
|
||||||
* @param {?boolean|string[]} scope
|
|
||||||
* @param {string} root
|
|
||||||
* @returns {?string[]}
|
|
||||||
*/
|
|
||||||
function resolveScope(scope, root) {
|
|
||||||
if (scope === true || scope === null || scope === undefined) return null;
|
|
||||||
if (!Array.isArray(scope)) return [];
|
|
||||||
return scope.map((entry) => path.resolve(root, entry));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* True when `target` is equal to or nested beneath an allowed path.
|
|
||||||
* A `null` allow-list grants unrestricted access.
|
|
||||||
*/
|
|
||||||
function isAllowed(target, allowed) {
|
|
||||||
if (allowed === null || allowed === undefined) return true;
|
|
||||||
return allowed.some(
|
|
||||||
(base) => target === base || target.startsWith(base + path.sep)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ *
|
|
||||||
* Default tool handlers
|
|
||||||
*
|
|
||||||
* Every handler receives the parsed call `args` and a context of
|
|
||||||
* `{root, scope}`; the scope is the resolved allow-list for the tool.
|
|
||||||
* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
function readFile(args, { root, scope }) {
|
|
||||||
const file = args.path;
|
|
||||||
const abs = path.resolve(root, file);
|
|
||||||
if (!isAllowed(abs, scope)) throw new Error(`read denied: ${file}`);
|
|
||||||
|
|
||||||
let lines = fs.readFileSync(abs, 'utf8').split('\n');
|
|
||||||
const from = args.lineStart != null ? args.lineStart - 1 : 0;
|
|
||||||
const to = args.lineEnd != null ? args.lineEnd : lines.length;
|
|
||||||
const lineCount = lines.length;
|
|
||||||
lines = lines.slice(from, to);
|
|
||||||
|
|
||||||
let content = lines.join('\n');
|
|
||||||
const bytes = Buffer.from(content, 'utf8');
|
|
||||||
if (bytes.length > MAX_READ_BYTES) {
|
|
||||||
throw new Error(`too large: ${bytes.length} bytes requested (max ${MAX_READ_BYTES}, ${lineCount} lines in ${file})`);
|
|
||||||
}
|
|
||||||
return content;
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeFile(args, { root, scope }) {
|
|
||||||
const file = args.path;
|
|
||||||
const abs = path.resolve(root, file);
|
|
||||||
if (!isAllowed(abs, scope)) throw new Error(`write denied: ${file}`);
|
|
||||||
|
|
||||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
||||||
fs.writeFileSync(abs, args.content ?? '');
|
|
||||||
return `wrote ${file}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateFile(args, { root, scope }) {
|
|
||||||
const file = args.path;
|
|
||||||
const abs = path.resolve(root, file);
|
|
||||||
if (!isAllowed(abs, scope)) throw new Error(`write denied: ${file}`);
|
|
||||||
|
|
||||||
const content = fs.readFileSync(abs, 'utf8');
|
|
||||||
const search = args.search;
|
|
||||||
const replace = args.replace ?? '';
|
|
||||||
|
|
||||||
let updated;
|
|
||||||
if (args.global) {
|
|
||||||
updated = content.split(search).join(replace);
|
|
||||||
} else {
|
|
||||||
const at = content.indexOf(search);
|
|
||||||
updated =
|
|
||||||
at < 0
|
|
||||||
? content
|
|
||||||
: content.slice(0, at) + replace + content.slice(at + search.length);
|
|
||||||
}
|
|
||||||
fs.writeFileSync(abs, updated);
|
|
||||||
return `updated ${file}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function listDir(args, { root, scope }) {
|
|
||||||
const dir = args.path || '.';
|
|
||||||
const abs = path.resolve(root, dir);
|
|
||||||
if (!isAllowed(abs, scope)) throw new Error(`read denied: ${dir}`);
|
|
||||||
|
|
||||||
return fs
|
|
||||||
.readdirSync(abs, { withFileTypes: true })
|
|
||||||
.map((entry) => (entry.isDirectory() ? `${entry.name}/` : entry.name));
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ *
|
|
||||||
* Default tools, keyed by name
|
|
||||||
* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
const DEFAULT_TOOLS = {
|
|
||||||
'read-file': {
|
|
||||||
scope: 'read',
|
|
||||||
handler: readFile,
|
|
||||||
description: 'Read up to 10KB from a file, with optional line range.',
|
|
||||||
parameters: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
path: { type: 'string' },
|
|
||||||
lineStart: { type: 'number' },
|
|
||||||
lineEnd: { type: 'number' },
|
|
||||||
},
|
|
||||||
required: ['path'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'write-file': {
|
|
||||||
scope: 'write',
|
|
||||||
handler: writeFile,
|
|
||||||
description: 'Write the entire content of a file.',
|
|
||||||
parameters: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
path: { type: 'string' },
|
|
||||||
content: { type: 'string' },
|
|
||||||
},
|
|
||||||
required: ['path', 'content'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'update-file': {
|
|
||||||
scope: 'write',
|
|
||||||
handler: updateFile,
|
|
||||||
description: 'Replace `search` with `replace` in a file.',
|
|
||||||
parameters: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
path: { type: 'string' },
|
|
||||||
search: { type: 'string' },
|
|
||||||
replace: { type: 'string' },
|
|
||||||
global: { type: 'boolean' },
|
|
||||||
},
|
|
||||||
required: ['path', 'search'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'list-dir': {
|
|
||||||
scope: 'read',
|
|
||||||
handler: listDir,
|
|
||||||
description: 'List a directory. Directories are suffixed with "/".',
|
|
||||||
parameters: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
path: { type: 'string' },
|
|
||||||
},
|
|
||||||
required: [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve the `defaultTools` selection to a list of default tool names.
|
|
||||||
* `null` / `true` select every default; `false` / `[]` select none.
|
|
||||||
*/
|
|
||||||
function selectDefaults(selection) {
|
|
||||||
if (selection === true || selection === null || selection === undefined) {
|
|
||||||
return Object.keys(DEFAULT_TOOLS);
|
|
||||||
}
|
|
||||||
if (!Array.isArray(selection)) return [];
|
|
||||||
return selection.filter((name) => name in DEFAULT_TOOLS);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Assemble the tool bundle: selected defaults plus any custom `tools`.
|
|
||||||
* A custom tool whose name matches a default overrides it.
|
|
||||||
*/
|
|
||||||
function buildTools({ read, write, tools, defaultTools }) {
|
|
||||||
const bundle = [];
|
|
||||||
|
|
||||||
for (const name of selectDefaults(defaultTools)) {
|
|
||||||
const def = DEFAULT_TOOLS[name];
|
|
||||||
bundle.push({
|
|
||||||
name,
|
|
||||||
description: def.description,
|
|
||||||
parameters: def.parameters,
|
|
||||||
scope: def.scope === 'read' ? read : write,
|
|
||||||
handler: def.handler,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(tools)) {
|
|
||||||
for (const tool of tools) {
|
|
||||||
if (!tool || !tool.name) continue;
|
|
||||||
const idx = bundle.findIndex((t) => t.name === tool.name);
|
|
||||||
if (idx >= 0) bundle[idx] = tool;
|
|
||||||
else bundle.push(tool);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return bundle;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a name -> execution function for the bundle. Unknown tools and
|
* Build a name -> execution function for the bundle. Unknown tools and
|
||||||
* handler failures are reported as tool-result strings rather than thrown,
|
* handler failures are reported as tool-result strings rather than thrown,
|
||||||
* so the loop can hand them back to the agent.
|
* so the loop can hand them back to the agent.
|
||||||
*/
|
*/
|
||||||
function makeExecutor(bundle, root) {
|
function makeExecutor(bundle) {
|
||||||
const byName = new Map(bundle.map((tool) => [tool.name, tool]));
|
const byName = new Map(bundle.map((tool) => [tool.name, tool]));
|
||||||
|
|
||||||
return function execute(name, args) {
|
return function execute(name, args) {
|
||||||
@@ -238,7 +37,7 @@ function makeExecutor(bundle, root) {
|
|||||||
return `Error: tool '${name}' has no handler`;
|
return `Error: tool '${name}' has no handler`;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return tool.handler(args || {}, { root, scope: tool.scope });
|
return tool.handler(args || {});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
return `Error: ${err.message}`;
|
return `Error: ${err.message}`;
|
||||||
@@ -246,58 +45,6 @@ function makeExecutor(bundle, root) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ *
|
|
||||||
* Open context
|
|
||||||
* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read the `open` entries as prompt context. Files carry their content,
|
|
||||||
* directories carry a listing. Entries outside the read scope are skipped.
|
|
||||||
*/
|
|
||||||
function collectContext(open, root, read) {
|
|
||||||
if (open == null || open === false) return [];
|
|
||||||
|
|
||||||
const entries = open === true ? ['.'] : Array.isArray(open) ? open : [open];
|
|
||||||
const context = [];
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
const abs = path.resolve(root, entry);
|
|
||||||
if (!isAllowed(abs, read)) continue;
|
|
||||||
|
|
||||||
let stat;
|
|
||||||
try {
|
|
||||||
stat = fs.statSync(abs);
|
|
||||||
} catch (err) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stat.isDirectory()) {
|
|
||||||
const listing = fs
|
|
||||||
.readdirSync(abs, { withFileTypes: true })
|
|
||||||
.map((e) => (e.isDirectory() ? `${e.name}/` : e.name));
|
|
||||||
context.push({ path: entry, type: 'dir', content: listing.join('\n') });
|
|
||||||
} else {
|
|
||||||
context.push({
|
|
||||||
path: entry,
|
|
||||||
type: 'file',
|
|
||||||
content: fs.readFileSync(abs, 'utf8'),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return context;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatContext(context) {
|
|
||||||
return context
|
|
||||||
.map((entry) =>
|
|
||||||
entry.type === 'dir'
|
|
||||||
? `Directory: ${entry.path}\n${entry.content}`
|
|
||||||
: `File: ${entry.path}\n${entry.content}`
|
|
||||||
)
|
|
||||||
.join('\n\n---\n\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ *
|
/* ------------------------------------------------------------------ *
|
||||||
* Tool-call extraction
|
* Tool-call extraction
|
||||||
* ------------------------------------------------------------------ */
|
* ------------------------------------------------------------------ */
|
||||||
@@ -342,7 +89,6 @@ function extractToolCalls(res) {
|
|||||||
* @param {object[]} [options.messages] Prior messages for the agent.
|
* @param {object[]} [options.messages] Prior messages for the agent.
|
||||||
* @param {?boolean|string[]} [options.read] Readable files/dirs (null = all).
|
* @param {?boolean|string[]} [options.read] Readable files/dirs (null = all).
|
||||||
* @param {?boolean|string[]} [options.write] Writable files/dirs (null = all).
|
* @param {?boolean|string[]} [options.write] Writable files/dirs (null = all).
|
||||||
* @param {?boolean|string[]} [options.open] Paths opened as context.
|
|
||||||
* @param {object[]} [options.tools] Extra tools to pass the agent.
|
* @param {object[]} [options.tools] Extra tools to pass the agent.
|
||||||
* @param {?boolean|string[]} [options.defaultTools] Default tool selection.
|
* @param {?boolean|string[]} [options.defaultTools] Default tool selection.
|
||||||
* @returns {Promise<any>} The agent's final response.
|
* @returns {Promise<any>} The agent's final response.
|
||||||
@@ -354,7 +100,6 @@ async function run({
|
|||||||
messages,
|
messages,
|
||||||
read = null,
|
read = null,
|
||||||
write = null,
|
write = null,
|
||||||
open = null,
|
|
||||||
tools = null,
|
tools = null,
|
||||||
defaultTools = true,
|
defaultTools = true,
|
||||||
verify = null,
|
verify = null,
|
||||||
@@ -365,12 +110,10 @@ async function run({
|
|||||||
if (!agentId) throw new Error('run: agentId is required');
|
if (!agentId) throw new Error('run: agentId is required');
|
||||||
if (!Gral || !Gral.horde) throw new Error('run: horde connection unavailable');
|
if (!Gral || !Gral.horde) throw new Error('run: horde connection unavailable');
|
||||||
|
|
||||||
const readScope = resolveScope(read, root);
|
|
||||||
const writeScope = resolveScope(write, root);
|
|
||||||
|
|
||||||
const bundle = buildTools({
|
const bundle = buildTools({
|
||||||
read: readScope,
|
root,
|
||||||
write: writeScope,
|
read: resolveScope(read, root),
|
||||||
|
write: resolveScope(write, root),
|
||||||
tools,
|
tools,
|
||||||
defaultTools,
|
defaultTools,
|
||||||
});
|
});
|
||||||
@@ -382,13 +125,9 @@ async function run({
|
|||||||
parameters,
|
parameters,
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
const execute = makeExecutor(bundle, root);
|
const execute = makeExecutor(bundle);
|
||||||
|
|
||||||
const context = collectContext(open, root, readScope);
|
|
||||||
const history = Array.isArray(messages) ? messages.slice() : [];
|
const history = Array.isArray(messages) ? messages.slice() : [];
|
||||||
if (context.length) {
|
|
||||||
history.unshift({ role: 'system', content: formatContext(context) });
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let step = 0; step < MAX_ITERATIONS; step++) {
|
for (let step = 0; step < MAX_ITERATIONS; step++) {
|
||||||
const res = await Gral.horde.sendAction('send-agent-prompt', {
|
const res = await Gral.horde.sendAction('send-agent-prompt', {
|
||||||
|
|||||||
+254
@@ -0,0 +1,254 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* tools.js — tool definitions for the `Gral.run` harness.
|
||||||
|
*
|
||||||
|
* A tool is a subclass of `Tool`: its name is inferred from the class
|
||||||
|
* name, while its description, parameters and permission scope are class
|
||||||
|
* properties and its behaviour is the `run` method. Instances are bound
|
||||||
|
* to a resolved allow-list at construction.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
const MAX_READ_BYTES = 10 * 1024;
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ *
|
||||||
|
* Permissions
|
||||||
|
* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a scope descriptor into an absolute allow-list.
|
||||||
|
*
|
||||||
|
* null | true -> null (allow everything)
|
||||||
|
* false | [] -> [] (allow nothing)
|
||||||
|
* [entry, ...] -> resolved absolute paths
|
||||||
|
*
|
||||||
|
* @param {?boolean|string[]} scope
|
||||||
|
* @param {string} root
|
||||||
|
* @returns {?string[]}
|
||||||
|
*/
|
||||||
|
export function resolveScope(scope, root) {
|
||||||
|
if (scope === true || scope === null || scope === undefined) return null;
|
||||||
|
if (!Array.isArray(scope)) return [];
|
||||||
|
return scope.map((entry) => path.resolve(root, entry));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when `target` is equal to or nested beneath an allowed path.
|
||||||
|
* A `null` allow-list grants unrestricted access.
|
||||||
|
*/
|
||||||
|
function isAllowed(target, allowed) {
|
||||||
|
if (allowed === null || allowed === undefined) return true;
|
||||||
|
return allowed.some(
|
||||||
|
(base) => target === base || target.startsWith(base + path.sep)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ *
|
||||||
|
* Tool base class
|
||||||
|
* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
/** `ReadFile` -> `read-file`. */
|
||||||
|
const kebab = (name) => name.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base class for a tool. A subclass declares:
|
||||||
|
*
|
||||||
|
* static scope 'read' or 'write' — the permission it requires
|
||||||
|
* static description one-line summary sent to the agent
|
||||||
|
* static parameters JSON schema of its arguments
|
||||||
|
*
|
||||||
|
* and implements `run(args)`. The tool name is inferred from the class
|
||||||
|
* name; instances hold the root and resolved allow-list they act under.
|
||||||
|
*/
|
||||||
|
export class Tool {
|
||||||
|
constructor(root, allow) {
|
||||||
|
this.root = root;
|
||||||
|
this.allow = allow;
|
||||||
|
}
|
||||||
|
|
||||||
|
get name() { return kebab(this.constructor.name); }
|
||||||
|
get description() { return this.constructor.description; }
|
||||||
|
get parameters() { return this.constructor.parameters; }
|
||||||
|
|
||||||
|
/** Bundle interface: handler receives only the call arguments. */
|
||||||
|
handler(args) {
|
||||||
|
return this.run(args ?? {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve `file` against the root, asserting the given permission. */
|
||||||
|
resolve(file, verb) {
|
||||||
|
const abs = path.resolve(this.root, file);
|
||||||
|
if (!isAllowed(abs, this.allow)) throw new Error(`${verb} denied: ${file}`);
|
||||||
|
return abs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ *
|
||||||
|
* Default tools
|
||||||
|
* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
class ReadFile extends Tool {
|
||||||
|
static scope = 'read';
|
||||||
|
static description = 'Read up to 10KB from a file, with optional line range.';
|
||||||
|
static parameters = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
path: { type: 'string' },
|
||||||
|
lineStart: { type: 'number' },
|
||||||
|
lineEnd: { type: 'number' },
|
||||||
|
},
|
||||||
|
required: ['path'],
|
||||||
|
};
|
||||||
|
|
||||||
|
run({ path: file, lineStart, lineEnd }) {
|
||||||
|
const abs = this.resolve(file, 'read');
|
||||||
|
|
||||||
|
const lines = fs.readFileSync(abs, 'utf8').split('\n');
|
||||||
|
const from = lineStart != null ? lineStart - 1 : 0;
|
||||||
|
const to = lineEnd != null ? lineEnd : lines.length;
|
||||||
|
|
||||||
|
const content = lines.slice(from, to).join('\n');
|
||||||
|
const bytes = Buffer.from(content, 'utf8');
|
||||||
|
if (bytes.length > MAX_READ_BYTES) {
|
||||||
|
throw new Error(
|
||||||
|
`too large: ${bytes.length} bytes requested ` +
|
||||||
|
`(max ${MAX_READ_BYTES}, ${lines.length} lines in ${file})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class WriteFile extends Tool {
|
||||||
|
static scope = 'write';
|
||||||
|
static description = 'Write the entire content of a file.';
|
||||||
|
static parameters = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
path: { type: 'string' },
|
||||||
|
content: { type: 'string' },
|
||||||
|
},
|
||||||
|
required: ['path', 'content'],
|
||||||
|
};
|
||||||
|
|
||||||
|
run({ path: file, content }) {
|
||||||
|
const abs = this.resolve(file, 'write');
|
||||||
|
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||||
|
fs.writeFileSync(abs, content ?? '');
|
||||||
|
return `wrote ${file}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class UpdateFile extends Tool {
|
||||||
|
static scope = 'write';
|
||||||
|
static description = 'Replace `search` with `replace` in a file.';
|
||||||
|
static parameters = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
path: { type: 'string' },
|
||||||
|
search: { type: 'string' },
|
||||||
|
replace: { type: 'string' },
|
||||||
|
global: { type: 'boolean' },
|
||||||
|
},
|
||||||
|
required: ['path', 'search'],
|
||||||
|
};
|
||||||
|
|
||||||
|
run({ path: file, search, replace = '', global }) {
|
||||||
|
const abs = this.resolve(file, 'write');
|
||||||
|
const content = fs.readFileSync(abs, 'utf8');
|
||||||
|
|
||||||
|
let updated;
|
||||||
|
if (global) {
|
||||||
|
updated = content.split(search).join(replace);
|
||||||
|
} else {
|
||||||
|
const at = content.indexOf(search);
|
||||||
|
updated =
|
||||||
|
at < 0
|
||||||
|
? content
|
||||||
|
: content.slice(0, at) + replace + content.slice(at + search.length);
|
||||||
|
}
|
||||||
|
fs.writeFileSync(abs, updated);
|
||||||
|
return `updated ${file}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ListDir extends Tool {
|
||||||
|
static scope = 'read';
|
||||||
|
static description = 'List a directory. Directories are suffixed with "/".';
|
||||||
|
static parameters = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
path: { type: 'string' },
|
||||||
|
},
|
||||||
|
required: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
run({ path: dir }) {
|
||||||
|
const abs = this.resolve(dir || '.', 'read');
|
||||||
|
|
||||||
|
return fs
|
||||||
|
.readdirSync(abs, { withFileTypes: true })
|
||||||
|
.map((entry) => (entry.isDirectory() ? `${entry.name}/` : entry.name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ *
|
||||||
|
* Tool bundle
|
||||||
|
* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
/** Defaults, keyed by the name inferred from each class. */
|
||||||
|
const DEFAULT_TOOLS = Object.fromEntries(
|
||||||
|
[ReadFile, WriteFile, UpdateFile, ListDir].map((Cls) => [kebab(Cls.name), Cls])
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the `defaultTools` selection to a list of default tool names.
|
||||||
|
* `null` / `true` select every default; `false` / `[]` select none.
|
||||||
|
*/
|
||||||
|
function selectDefaults(selection) {
|
||||||
|
if (selection === true || selection === null || selection === undefined) {
|
||||||
|
return Object.keys(DEFAULT_TOOLS);
|
||||||
|
}
|
||||||
|
if (!Array.isArray(selection)) return [];
|
||||||
|
return selection.filter((name) => name in DEFAULT_TOOLS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adapt a plain-object tool to the bundle interface, binding its handler
|
||||||
|
* to the resolved scope so it only receives the call arguments.
|
||||||
|
*/
|
||||||
|
function wrap(tool, root) {
|
||||||
|
const { name, description, parameters, scope, handler } = tool;
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
parameters,
|
||||||
|
handler: (args) => handler(args ?? {}, { root, scope }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assemble the tool bundle: selected defaults plus any custom `tools`.
|
||||||
|
* A custom tool whose name matches a default overrides it. Custom tools
|
||||||
|
* are plain `{name, description, parameters, scope, handler}` objects, or
|
||||||
|
* ready-made `Tool` instances.
|
||||||
|
*/
|
||||||
|
export function buildTools({ root, read, write, tools = null, defaultTools = true }) {
|
||||||
|
const bundle = new Map();
|
||||||
|
|
||||||
|
for (const name of selectDefaults(defaultTools)) {
|
||||||
|
const Cls = DEFAULT_TOOLS[name];
|
||||||
|
const scope = Cls.scope === 'read' ? read : write;
|
||||||
|
bundle.set(name, new Cls(root, scope));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const tool of Array.isArray(tools) ? tools : []) {
|
||||||
|
if (!tool || !tool.name) continue;
|
||||||
|
bundle.set(tool.name, tool instanceof Tool ? tool : wrap(tool, root));
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...bundle.values()];
|
||||||
|
}
|
||||||
Reference in new issue
Block a user