diff --git a/src/run.js b/src/run.js index a1daf04..f84aea2 100644 --- a/src/run.js +++ b/src/run.js @@ -6,229 +6,28 @@ * * Given a prompt for a target agent it: * 1. Resolves the `read` / `write` permission scopes. - * 2. Collects the `open` files and directories as prompt context. - * 3. Assembles the tool specs from `tools` + `defaultTools`. - * 4. Runs the prompt/tool-call loop: send the prompt, execute any tool + * 2. Assembles the tool specs from `tools` + `defaultTools`. + * 3. Runs the prompt/tool-call loop: send the prompt, execute any tool * calls locally, feed the results back, repeat until the agent * returns a plain response. + * + * Permission scopes and tool definitions live in 'tools.js'. */ -import * as fs from 'fs'; -import * as path from 'path'; +import { resolveScope, buildTools } from './tools.js'; -const MAX_READ_BYTES = 10*1024; 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 * handler failures are reported as tool-result strings rather than thrown, * 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])); return function execute(name, args) { @@ -238,7 +37,7 @@ function makeExecutor(bundle, root) { return `Error: tool '${name}' has no handler`; } try { - return tool.handler(args || {}, { root, scope: tool.scope }); + return tool.handler(args || {}); } catch (err) { console.error(err); 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 * ------------------------------------------------------------------ */ @@ -342,7 +89,6 @@ function extractToolCalls(res) { * @param {object[]} [options.messages] Prior messages for the agent. * @param {?boolean|string[]} [options.read] Readable 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 {?boolean|string[]} [options.defaultTools] Default tool selection. * @returns {Promise} The agent's final response. @@ -354,7 +100,6 @@ async function run({ messages, read = null, write = null, - open = null, tools = null, defaultTools = true, verify = null, @@ -365,12 +110,10 @@ async function run({ if (!agentId) throw new Error('run: agentId is required'); if (!Gral || !Gral.horde) throw new Error('run: horde connection unavailable'); - const readScope = resolveScope(read, root); - const writeScope = resolveScope(write, root); - const bundle = buildTools({ - read: readScope, - write: writeScope, + root, + read: resolveScope(read, root), + write: resolveScope(write, root), tools, defaultTools, }); @@ -382,13 +125,9 @@ async function run({ parameters, } })); - const execute = makeExecutor(bundle, root); + const execute = makeExecutor(bundle); - const context = collectContext(open, root, readScope); 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++) { const res = await Gral.horde.sendAction('send-agent-prompt', { @@ -432,4 +171,4 @@ async function run({ throw new Error(`run: exceeded ${MAX_ITERATIONS} tool-call iterations`); } -export default run; \ No newline at end of file +export default run; diff --git a/src/tools.js b/src/tools.js new file mode 100644 index 0000000..f0d5240 --- /dev/null +++ b/src/tools.js @@ -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()]; +}