initial implementation
This commit is contained in:
1 parent
536b308096
commit
a08c41a426
7 files changed
+949
-20
No files matched your search
@@ -0,0 +1,2 @@
|
|||||||
|
.remote
|
||||||
|
.gral
|
||||||
@@ -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.
|
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Gral
|
||||||
|
|
||||||
|
Clone the repository, then install with `npm install -g .`.
|
||||||
|
|
||||||
|
Usage: `gral <function> -p <prompt>`, or pass a file as a prompt instead with the `-f <prompt-file>` 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)
|
||||||
+204
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+272
@@ -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<string, function>} 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 <function> [-p PROMPT | -f PROMPT_FILE]',
|
||||||
|
'',
|
||||||
|
'Options:',
|
||||||
|
' -p, --prompt <PROMPT> Prompt text passed to the function',
|
||||||
|
' -f, --prompt-file <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<number>} 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);
|
||||||
|
}
|
||||||
|
);
|
||||||
+428
@@ -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<any>} 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;
|
||||||
Reference in new issue
Block a user