#!/usr/bin/env node 'use strict'; /* * run.js — the harness behind `Gral.run`. * * 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 * calls locally, feed the results back, repeat until the agent * returns a plain response. */ import * as fs from 'fs'; import * as path from 'path'; const MAX_READ_BYTES = 1024; const MAX_ITERATIONS = 20; /* ------------------------------------------------------------------ * * Scopes / 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[]} */ 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; lines = lines.slice(from, to); let content = lines.join('\n'); const bytes = Buffer.from(content, 'utf8'); if (bytes.length > MAX_READ_BYTES) { content = bytes.slice(0, MAX_READ_BYTES).toString('utf8'); } 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 a file.', 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) { const byName = new Map(bundle.map((tool) => [tool.name, tool])); return function execute(name, args) { const tool = byName.get(name); if (!tool) return `Error: unknown tool '${name}'`; if (typeof tool.handler !== 'function') { return `Error: tool '${name}' has no handler`; } try { return tool.handler(args || {}, { root, scope: tool.scope }); } catch (err) { return `Error: ${err.message}`; } }; } /* ------------------------------------------------------------------ * * 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 * ------------------------------------------------------------------ */ /** * Normalize the tool calls returned by the prompt action. Accepts either * a flat `{id, name, arguments}` shape or a nested `{id, function:{...}}` * shape, and parses string `arguments` as JSON. */ function extractToolCalls(res) { const raw = res && (res.toolCalls || res.tool_calls); if (!Array.isArray(raw)) return []; return raw.map((call) => { const fn = call.function || call; let args = call.arguments !== undefined ? call.arguments : fn.arguments; if (typeof args === 'string') { try { args = JSON.parse(args); } catch (err) { args = {}; } } return { id: call.id, name: fn.name, args: args || {} }; }); } /* ------------------------------------------------------------------ * * Run * ------------------------------------------------------------------ */ /** * The harness behind `Gral.run`. Sends a prompt to a target agent with a * tool bundle restricted by the supplied arguments, then loops: while the * agent returns tool calls, execute them locally and send the results * back until a plain response is produced. * * @param {object} options * @param {string} options.agentId Target agent for the prompt. * @param {string} [options.instruction] Instruction for the agent. * @param {string} [options.input] Main input 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.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. */ async function run({ agentId, instruction, input, messages, read = null, write = null, open = null, tools = null, defaultTools = true, } = {}) { const Gral = global.Gral; const root = Gral && Gral.path ? Gral.path : process.cwd(); 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, tools, defaultTools, }); const specs = bundle.map(({ name, description, parameters }) => ({ type: 'function', function: { name, description, parameters, } })); const execute = makeExecutor(bundle, root); 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', { agentId, instruction, input, options: { messages: history, tools: specs, }, }); const calls = res.toolCalls || []; if (calls.length === 0) return res.response; if (res.response) { console.log(res.response); history.push({ role: 'assistant', content: res.response, }); } for (const call of calls) { call.result = execute(call.tool_name, call.arguments); history.push({ role: 'tool', content: call, }); } if (calls.length) { console.log('called: ' + calls.map(c => c.tool_name)); } } throw new Error(`run: exceeded ${MAX_ITERATIONS} tool-call iterations`); } export default run;