initial implementation
This commit is contained in:
1 parent
536b308096
commit
a08c41a426
7 files changed
+949
-20
No files matched your search
+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