diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ac0d229 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.remote +.gral diff --git a/idea.txt b/idea.txt deleted file mode 100644 index 4ece3f9..0000000 --- a/idea.txt +++ /dev/null @@ -1,20 +0,0 @@ -Gral auto. It's gral but better, keke. - -gral is an existing chat, with advanced features for terminal based interactions and constrained context. I don't want to kill it, it's really good at what it does. -gral-auto is a single-run utility that runs a preprogrammed script, spawning agents with configurable tools and resources for concrete runs with defined deliverables. -it must provide an api to a '.gral/auto.js' script in the working directory and execute it. This api allows spawning AI agents on preprogrammed tasks. -for now just a prompt. we'll add more utilities later. it would be good that the design were extensible. - -actually, we will begin with this feature set also: -- each run has these cofigurable options: - - agent name, or agent id? - - write/read permission: file or directory list where writing and reading is enabled, as separate lists or in the data structures - - file context: list of initially open files - - verification callback: runs after the agent did the task, can either succeed, fail, or retry with modifications or feedback, and it's optional - - feedback callback? - - functions -- agents only have basic file operations, restricted by the default permission system -- functions can be defined in the auto script, which are exposed to the agent besides the default functions - -technical details: functions are implemented as remotes. a remote is created and connected for each task, and the agent is connected to the remote via the chat. -also, agents are managed via chat interactions. A single temporary chat is created for the auto, and each run creates a thread in the chat to interact with the agent. \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..56440b1 --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "gral", + "version": "0.1.0", + "description": "Gral — CLI automated task execution", + "bin": { + "gral": "src/main.js" + }, + "files": [ + "src" + ], + "engines": { + "node": ">=18" + }, + "private": true, + "type": "module" +} \ No newline at end of file diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..d3668a5 --- /dev/null +++ b/readme.md @@ -0,0 +1,27 @@ +# Gral + +Clone the repository, then install with `npm install -g .`. + +Usage: `gral -p `, or pass a file as a prompt instead with the `-f ` option. + +Gral uses the `.gral` directory from the working path for its configuration. The horde connection uses the 'host' and 'token' values stored in `.gral/config.json`, or loads them from the environment if not present. + +Every javascript module in `.gral/functions` exports a default function, it receives a single prompt string argument. These are the functions invoked by the gral command, you can list them with `gral -l`. + +The `Gral` global is available in the function module scopes, and exposes: +- `path`: the working directory +- `run`: the run command for sending prompts to agents + +The `run` function is the main harness functionality, it prompts agents with a tool bundle restricted based on the arguments. +- `agentId`: the target agent that will process the prompt +- `instruction` `input` and `messages`: these are sent to the horde 'prompt' action as the main input for the agent +- `read` and `write`: list of files and directories open for access during the run. `null` and `true` means allow all, `false` and `[]` means allow none. +- `open`: files and directories to open beforehand and send as context for the run +- `tools`: tools to pass the agent +- `defaultTools`: list of default tools to include in the list, special values are the same as read and write + +Default tools include these: +- `read-file`: read a file, max size of 1kb, with optional `lineStart` and `lineEnd` arguments +- `write-file`: write the entire content of a file, no write limit +- `update-file`: accepts `search` and `replace` arguments, and an optional `global` argument +- `list-dir`: lists the contents of a directory (directories are distinguished by their '/' prefix) diff --git a/src/horde.js b/src/horde.js new file mode 100644 index 0000000..8be4272 --- /dev/null +++ b/src/horde.js @@ -0,0 +1,204 @@ +// Horde hub WebSocket client for Node.js (>= 22, uses the global WebSocket). +// Modeled on the browser client in reference/backend.js: nonce-tracked actions, +// action listeners, orphan handlers for the auth response, auto-reconnect. + +export default class Horde { + constructor({ host, token = null }) { + this.host = host; + this.token = token; + this.socket = null; + this.nextNonce = 0; + this.pending = new Map(); // nonce -> { resolve, reject } + this.actionListeners = new Map(); // action -> [callback(packet)] + this.orphanHandlers = []; // callbacks for nonce-less messages (auth replies) + this.connectListeners = []; + this.reconnectTimeout = null; + this.stopped = false; + this.pingTimeout = null; // Tracks the heartbeat interval + } + + connect() { + if (this.stopped) return; + if (this.socket && (this.socket.readyState === WebSocket.CONNECTING || this.socket.readyState === WebSocket.OPEN)) return; + + const socket = new WebSocket(this.host + "/ws"); + this.socket = socket; + + let promiseInner = {}; + const connectPromise = new Promise((resolve, reject) => { + promiseInner = {resolve, reject}; + }); + + socket.onopen = async () => { + console.log(`Connected to ${this.host}`); + try { + if (this.token) { + socket.send(JSON.stringify({ token: this.token })); + const response = await this.waitOrphan(); + if (response && response.error) { + console.error('Hub authentication failed:', response.error); + socket.close(); + return; + } + } else { + console.warn('No auth token configured; the hub may reject this session'); + } + for (const cb of this.connectListeners) { + try { cb(); } catch (err) { console.error('Connect listener failed:', err); } + } + } catch (err) { + console.error('Error during hub login:', err); + socket.close(); + } + + // Start heartbeat + this.schedulePing(); + + if (promiseInner) { + promiseInner.resolve(); + promiseInner = null; + } + }; + + socket.onmessage = (event) => { + if (typeof event.data === 'string') this.handleMessage(event.data); + }; + + socket.onclose = () => { + this.socket = null; + this.clearPending(); + if (!this.stopped) { + console.log('Disconnected from hub. Reconnecting in 1s...'); + this.reconnectTimeout = setTimeout(() => this.connect(), 1000); + } + this.pingTimeout = null; + }; + + socket.onerror = (event) => { + console.error('WebSocket error:', event.message || event.error || event); + + if (promiseInner) { + promiseInner.reject(event.message || event.error || event); + promiseInner = null; + } + }; + + return connectPromise; + } + + close() { + this.stopped = true; + if (this.reconnectTimeout) clearTimeout(this.reconnectTimeout); + if (this.pingTimeout) clearTimeout(this.pingTimeout); + if (this.socket) { + this.socket.onclose = null; + this.socket.close(); + this.socket = null; + } + this.clearPending(); + } + + clearPending() { + for (const { reject } of this.pending.values()) { + reject(new Error('Connection lost')); + } + this.pending.clear(); + } + + onConnect(cb) { + this.connectListeners.push(cb); + } + + onAction(action, cb) { + let list = this.actionListeners.get(action); + if (!list) { + list = []; + this.actionListeners.set(action, list); + } + list.push(cb); + } + + waitOrphan() { + return new Promise((resolve) => this.orphanHandlers.push(resolve)); + } + + send(value) { + if (!this.socket || this.socket.readyState !== WebSocket.OPEN) { + throw new Error('WebSocket is not open'); + } + this.socket.send(JSON.stringify(value)); + } + + sendAction(action, body = {}) { + if (!this.socket || this.socket.readyState !== WebSocket.OPEN) { + return Promise.reject(new Error('WebSocket is not open')); + } + const nonce = this.nextNonce++; + const promise = new Promise((resolve, reject) => { + this.pending.set(nonce, { resolve, reject }); + }); + promise.catch(() => {}); // avoid unhandled rejections when nobody awaits + this.send({ action, nonce, body }); + return promise; + } + + handleMessage(raw) { + let packet; + try { + packet = JSON.parse(raw); + } catch (err) { + console.error('Failed to parse hub message:', raw, err); + return; + } + + if (packet.nonce != null) { + const pending = this.pending.get(packet.nonce); + if (!pending) { + console.warn(`Orphan response for nonce ${packet.nonce}`); + return; + } + this.pending.delete(packet.nonce); + if (packet.error) pending.reject(new Error(packet.error)); + else pending.resolve(packet); + return; + } + + if (packet.action) { + const listeners = this.actionListeners.get(packet.action); + if (listeners && listeners.length) { + for (const cb of listeners) { + try { cb(packet); } catch (err) { + console.error(`Listener for '${packet.action}' failed:`, err); + } + } + return; + } + } + + if (this.orphanHandlers.length) { + this.orphanHandlers.shift()(packet); + return; + } + + if (packet.error) console.error('Hub error:', packet.error); + else { + //console.warn('Ignored hub message:', packet.action); + } + } + + schedulePing() { + if (this.pingTimeout) clearTimeout(this.pingTimeout); + this.pingTimeout = setTimeout(() => { + if (this.stopped) { + this.pingTimeout = null; + return; + } + if (this.socket && this.socket.readyState === WebSocket.OPEN) { + this.sendAction("ping"); + this.schedulePing(); + } else { + this.pingTimeout = null; + } + }, 30_000); + } +} \ No newline at end of file diff --git a/src/main.js b/src/main.js new file mode 100644 index 0000000..4fd84e2 --- /dev/null +++ b/src/main.js @@ -0,0 +1,272 @@ +#!/usr/bin/env node +'use strict'; + +/* + * Gral — CLI automated task execution application. + * + * Sections: + * 1. Imports & constants + * 2. Context / API (global `Gral`) + * 3. Config loading + * 4. Function loading + * 5. Command line interface (parsing & help) + * 6. Entry point + */ + +/* ------------------------------------------------------------------ * + * 1. Imports & constants + * ------------------------------------------------------------------ */ + +import * as fs from 'fs'; +import * as path from 'path'; +import Horde from './horde.js'; +import run from './run.js'; + +const CONFIG_DIR = '.gral'; +const CONFIG_FILE = 'config.json'; +const FUNCTIONS_DIR = 'functions'; + +const DEFAULT_CONFIG = { + host: 'wss://hub.hordelab.com', + token: null, +}; + +// Optional environment overrides for host/token. +if (process.env.HORDE_HOST) DEFAULT_CONFIG.host = process.env.HORDE_HOST; +if (process.env.HORDE_TOKEN) DEFAULT_CONFIG.token = process.env.HORDE_TOKEN; + +/* ------------------------------------------------------------------ * + * 2. Context / API + * ------------------------------------------------------------------ */ + +/** + * Global execution context exposed to function modules as `Gral`. + * For now it only carries the current working directory. + */ +const Gral = { + path: process.cwd(), + // The function itself is defined in run.js + run, +}; + +global.Gral = Gral; + +/* ------------------------------------------------------------------ * + * 3. Config loading + * ------------------------------------------------------------------ */ + +/** + * Load the config from '.gral/config.json', falling back to defaults. + * Missing fields are filled from DEFAULT_CONFIG; the file may be absent. + * + * @param {string} root Directory the command was launched in. + * @returns {object} The effective config object. + */ +function loadConfig(root) { + const configPath = path.join(root, CONFIG_DIR, CONFIG_FILE); + + let stored = {}; + try { + stored = JSON.parse(fs.readFileSync(configPath, 'utf8')); + } catch (err) { + // Absent or unreadable config: rely on defaults. + stored = {}; + } + + return { ...DEFAULT_CONFIG, ...stored }; +} + +/* ------------------------------------------------------------------ * + * 4. Function loading + * ------------------------------------------------------------------ */ + +/** + * Load every '.js' module in '.gral/functions/', keyed by basename. + * Missing directory yields an empty set. + * + * @param {string} root Directory the command was launched in. + * @returns {Map} function name -> module default export. + */ +async function loadFunctions(root) { + const dir = path.join(root, CONFIG_DIR, FUNCTIONS_DIR); + const functions = new Map(); + + let entries = []; + try { + entries = fs.readdirSync(dir); + } catch (err) { + return functions; + } + + for (const entry of entries) { + if (!entry.endsWith('.js')) continue; + + const name = entry.slice(0, -'.js'.length); + const mod = await import(path.join(dir, entry)); + const fn = mod && mod.default ? mod.default : mod; + + if (typeof fn === 'function') { + functions.set(name, fn); + } + } + + return functions; +} + +/* ------------------------------------------------------------------ * + * 5. Command line interface + * ------------------------------------------------------------------ */ + +const HELP = [ + 'Gral — automated task execution', + '', + 'Usage:', + ' gral [-p PROMPT | -f PROMPT_FILE]', + '', + 'Options:', + ' -p, --prompt Prompt text passed to the function', + ' -f, --prompt-file Read the prompt from a file', + ' -l, --list List available functions', + ' -h, --help Show this help', +].join('\n'); + +/** + * Parse argv (excluding node + script) into a command object. + * + * @param {string[]} argv Process arguments after the script path. + * @returns {{functionName: ?string, prompt: ?string, list: boolean, help: boolean}} + */ +function parseArgs(argv) { + const command = { + functionName: null, + prompt: null, + list: false, + help: false, + }; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + + switch (arg) { + case '-p': + case '--prompt': + command.prompt = argv[++i] ?? null; + break; + case '-f': + case '--prompt-file': + command.prompt = readPromptFile(argv[++i]); + break; + case '-l': + case '--list': + command.list = true; + break; + case '-h': + case '--help': + command.help = true; + break; + default: + if (arg.startsWith('-')) { + // Unknown option: ignore per light error handling. + break; + } + if (command.functionName === null) { + command.functionName = arg; + } + } + } + + return command; +} + +/** + * Read the prompt from a file, returning null if it cannot be read. + * + * @param {?string} filePath Path to the prompt file. + * @returns {?string} + */ +function readPromptFile(filePath) { + if (!filePath) return null; + try { + return fs.readFileSync(filePath, 'utf8').trim(); + } catch (err) { + return null; + } +} + +/* ------------------------------------------------------------------ * + * 6. Entry point + * ------------------------------------------------------------------ */ + +/** + * Execute the selected function with the gathered input. + * + * @param {string[]} argv Process arguments after the script path. + * @returns {Promise} Exit code. + */ +async function main(argv) { + const command = parseArgs(argv); + const root = process.cwd(); + + // Context loaded up-front so functions see an accurate `Gral.path`. + let config = loadConfig(root); + Gral.path = root; + + const functions = await loadFunctions(root); + + let horde = new Horde({host: config.host, token: config.token}); + await horde.connect(); + + Gral.horde = horde; + + if (command.help) { + console.log(HELP); + return 0; + } + + if (command.list) { + const names = [...functions.keys()].sort(); + if (names.length === 0) { + console.log('No functions available.'); + } else { + console.log('Available functions:'); + for (const name of names) console.log(` ${name}`); + } + return 0; + } + + if (!command.functionName) { + console.error('Error: a function name is required.'); + console.error(HELP); + return 1; + } + + const fn = functions.get(command.functionName); + if (!fn) { + console.error(`Error: unknown function '${command.functionName}'.`); + return 1; + } + + if (command.prompt === null) { + console.error("Error: a prompt is required (-p PROMPT or -f PROMPT_FILE)."); + return 1; + } + + const input = { prompt: command.prompt }; + + try { + await fn(input); + } catch (err) { + console.error(`Error executing '${command.functionName}':`, err && err.message); + return 1; + } + + return 0; +} + +main(process.argv.slice(2)).then( + (code) => process.exit(code), + (err) => { + console.error(err); + process.exit(1); + } +); \ No newline at end of file diff --git a/src/run.js b/src/run.js new file mode 100644 index 0000000..3ac05d0 --- /dev/null +++ b/src/run.js @@ -0,0 +1,428 @@ +#!/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; \ No newline at end of file