commit 536b30809649b917d3182a4836c9f4f6780ad2c6 Author: Shivering Ocelot Date: Fri Sep 4 19:38:02 2026 -0500 initial idea diff --git a/idea.txt b/idea.txt new file mode 100644 index 0000000..4ece3f9 --- /dev/null +++ b/idea.txt @@ -0,0 +1,20 @@ +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/ref/Chat.vue b/ref/Chat.vue new file mode 100644 index 0000000..50ac630 --- /dev/null +++ b/ref/Chat.vue @@ -0,0 +1,643 @@ + + + + + diff --git a/ref/horde.mjs b/ref/horde.mjs new file mode 100644 index 0000000..d413ab2 --- /dev/null +++ b/ref/horde.mjs @@ -0,0 +1,163 @@ +// 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 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; + } + + 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; + + 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(); + } + }; + + 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); + } + }; + + socket.onerror = (event) => { + console.error('WebSocket error:', event.message || event.error || event); + }; + } + + close() { + this.stopped = true; + if (this.reconnectTimeout) clearTimeout(this.reconnectTimeout); + 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:', raw); + } +} \ No newline at end of file diff --git a/ref/remotes.md b/ref/remotes.md new file mode 100644 index 0000000..2b61760 --- /dev/null +++ b/ref/remotes.md @@ -0,0 +1,87 @@ +# Remotes + +## Overview + +A **remote** is a collection of functions exposed to an AI agent through the WebSocket session. Remotes are scoped to a user and live in memory during the server's lifetime. + +## Architecture & Function Lifecycle + +Remote functions are not standard Python methods attached to the agent. Instead, they are definitions (schemas) wrapped into adapters that fit the expert agent's tool loop. + +1. **Definition (`backend/src/remote.py`)**: + * A **`RemoteFunction`** represents a function exposed by a remote. It contains the function's name, description, and its OpenAI-compatible schema for input parameters. + * A **`RemoteToolSpec`** wraps a `RemoteFunction` to make it compatible with the agent's tool system. It exposes the required interface: `.name`, `.description`, `.to_openai_dict()` (for the LLM), and `.execute_async(**args)` (for execution). + * A **`RemoteConnection`** manages the connection state. It holds the function definitions and an internal queue of pending calls. + +2. **Injection**: + * When a run starts (see **Agent Integration** below), the expert agent requests tools for specific remotes. The server resolves these remote names into `RemoteToolSpec` objects and adds them to the run's input tools list. + +3. **Execution**: + * When the AI decides to use a remote tool, the expert agent's loop calls `execute_async` on the `RemoteToolSpec`. + * This triggers `RemoteConnection.call()`, which sends a request over the WebSocket to the session that owns the remote. + * The remote executes the function code (for local remotes) or accepts an RPC call (for external remotes). + * Results are sent back through `remote-call-result` actions. Pending calls time out after 30 seconds if no response is received. + +4. **Lifecycle**: + * Remotes are established when a user connects them via the UI (`remote-connect`). + * They persist for the session lifetime unless manually disconnected (`remote-disconnect`) or if the session serving them closes. + * Collisions are handled by scoping connections to `(user_id, name)`. + +## API Reference + +All endpoints are actions sent to the backend via the WebSocket. + +### `remote-connect` +Registers a new remote connection for the current user. + +**Payload**: +* `remotename` (string): The unique name for this remote collection. +* `functions` (array of objects): List of function specs. Each object must contain: + * `name` (string): The function identifier. + * `description` (string): Text visible to the AI. + * `parameters` (object): OpenAI tool parameter schema (`type`, `properties`, `required`). + +### `remote-disconnect` +Removes a connected remote. + +**Payload**: +* `remotename` (string): The name of the remote to remove. + +### `remote-list` +Requests the list of remotes currently registered for the authenticated user. + +**Payload**: (Empty) + +### `remote-call` +Invokes a specific function on a remote. + +**Payload**: +* `remotename` (string): The target remote. +* `function` (string): The function name. +* `args` (object): The arguments to pass to the function, matching the schema defined in `remote-connect`. + +### `remote-call-result` +Sent by the server to the active session to deliver the outcome of a pending `remote-call`. + +**Payload**: +* `requestId` (string): A unique identifier for this specific request. +* `result` (any): The return value of the function. +* `error` (string): An error message if execution failed. + +## Agent Integration + +Remotes are integrated into the agent workflow in two stages: receiving context and executing tools. + +### Chat Integration (`Agent.received_message`) +When a user sends a message to a chat thread, the frontend sends a special system resource to mark which remotes should be available for that conversation. + +* In `backend/src/agent.py`, the `received_message` method scans the thread's resources. +* It looks for items with `subkind: 'remote'`. +* It extracts the content of these resources (the remote names) and passes them into the agent run context. + +### Execution Integration (`ExpertAgent.run`) +In `backend/src/agents/expert.py`, the `run` method prepares for execution. + +* It retrieves the list of remotes from the run input. +* It calls `app.getRemoteTools(user_id, remotes)` to resolve these names into active tool specifications. +* These tools are injected into the inference loop. When the AI selects a tool, the expert agent executes it. If a remote fails (timeout or error), the failure is returned as a string error, allowing the AI to retry or handle the failure naturally. \ No newline at end of file diff --git a/ref/runtime.mjs b/ref/runtime.mjs new file mode 100644 index 0000000..aef1731 --- /dev/null +++ b/ref/runtime.mjs @@ -0,0 +1,179 @@ +// Remote runtime: loads function modules from directories, watches them for +// changes, and registers remotes on a horde connection. + +import fs from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const FUNCTION_EXTENSIONS = ['.mjs', '.js']; + +// A directory of function modules. Each module exports `spec` +// ({ name, description, parameters }) and a default async function (args, ctx). +export class Functions { + constructor(dir) { + this.dir = dir; + this.map = new Map(); // spec.name -> { spec, fn, file } + } + + async scan() { + const map = new Map(); + let entries = []; + try { + entries = fs.readdirSync(this.dir, { withFileTypes: true }); + } catch { + this.map = map; // no directory yet: no functions + return map; + } + + // Load .mjs first so it wins over a same-named .js module + entries.sort((a, b) => + FUNCTION_EXTENSIONS.indexOf(path.extname(a.name)) - + FUNCTION_EXTENSIONS.indexOf(path.extname(b.name))); + + for (const entry of entries) { + if (!entry.isFile()) continue; + if (!FUNCTION_EXTENSIONS.includes(path.extname(entry.name))) continue; + const file = path.join(this.dir, entry.name); + try { + // Cache-busting query: node caches modules by URL + const mod = await import(`${pathToFileURL(file).href}?t=${Date.now()}`); + const { spec } = mod; + if (!spec || typeof spec.name !== 'string') { + throw new Error('module must export a `spec` object with a `name` string'); + } + if (typeof mod.default !== 'function') { + throw new Error('module must export a default function'); + } + if (map.has(spec.name)) continue; + map.set(spec.name, { spec, fn: mod.default, file }); + } catch (err) { + console.error(`Failed to load function module ${file}: ${err.message}`); + } + } + + this.map = map; + return map; + } + + specs() { + return [...this.map.values()].map((f) => f.spec); + } + + has(name) { + return this.map.has(name); + } + + async call(name, args, ctx) { + const fn = this.map.get(name); + if (!fn) throw new Error(`Unknown function: ${name}`); + return await fn.fn(args, ctx); + } +} + +// Watches a directory using fs.watch, with a polling fallback every 5 seconds. +export class Watcher { + constructor(dir, onChange, intervalMs = 5000) { + this.dir = dir; + this.onChange = onChange; + this.intervalMs = intervalMs; + this.fsWatcher = null; + this.timer = null; + this.signature = null; + this.debounce = null; + } + + signatureOf() { + try { + return fs.readdirSync(this.dir, { withFileTypes: true }) + .filter((e) => e.isFile()) + .map((e) => { + const st = fs.statSync(path.join(this.dir, e.name)); + return `${e.name}:${st.mtimeMs}`; + }) + .sort() + .join('|'); + } catch { + return null; + } + } + + check() { + const signature = this.signatureOf(); + if (signature !== this.signature) { + const changed = this.signature !== null; + this.signature = signature; + if (changed) this.onChange(); + } + } + + start() { + this.signature = this.signatureOf(); + try { + this.fsWatcher = fs.watch(this.dir, () => { + if (this.debounce) clearTimeout(this.debounce); + this.debounce = setTimeout(() => this.check(), 100); + }); + } catch (err) { + console.warn(`fs.watch unavailable for ${this.dir}, relying on polling: ${err.message}`); + } + this.timer = setInterval(() => this.check(), this.intervalMs); + } + + stop() { + if (this.fsWatcher) this.fsWatcher.close(); + if (this.timer) clearInterval(this.timer); + if (this.debounce) clearTimeout(this.debounce); + } +} + +// A named collection of functions in a directory, registered on the hub. +export class Remote { + constructor({ horde, name, functionsDir, ctx = null, fallback = null }) { + this.horde = horde; + this.name = name; + this.ctx = ctx; + this.fallback = fallback; // shared Functions instance (center functions) + this.functions = new Functions(functionsDir); + this.watcher = null; + } + + // (Re)scans functions and (re)registers the remote. Returns success. + async register() { + await this.functions.scan(); + try { + await this.horde.sendAction('remote-connect', { + remotename: this.name, + functions: this.functions.specs(), + }); + console.log(`Registered remote '${this.name}' with ${this.functions.map.size} function(s)`); + } catch (err) { + console.error(`Failed to register remote '${this.name}': ${err.message}`); + return false; + } + if (!this.watcher) { + this.watcher = new Watcher(this.functions.dir, () => this.register()); + this.watcher.start(); + } + return true; + } + + async disconnect() { + if (this.watcher) this.watcher.stop(); + try { + await this.horde.sendAction('remote-disconnect', { remotename: this.name }); + } catch { + // socket may already be gone + } + } + + // Handles an incoming remote-call. Falls back to shared center functions. + async call(functionName, args) { + if (this.functions.has(functionName)) { + return await this.functions.call(functionName, args, this.ctx); + } + if (this.fallback) { + return await this.fallback.call(functionName, args, this.ctx); + } + throw new Error(`Unknown function '${functionName}' on remote '${this.name}'`); + } +} \ No newline at end of file