feat(remotes): full remote walkthrough, complete local remotes with editable functions

This commit is contained in:
Shivering Ocelot committed 2026-09-02 12:33:34 -05:00
1 parent 5009a4eb4c
commit da1420d04b
4 files changed
+206 -48

No files matched your search

+6
View File
@@ -149,6 +149,12 @@ class App(ChatMixin):
tools.append(RemoteToolSpec(connection, fn, name=prefix + fn.name))
return tools
def resolve_remote_call(self, request_id, result=None, error=None):
if result is not None:
RemoteConnection.resolve(request_id, result);
else:
RemoteConnection.reject(request_id, error);
# --- Agent Methods (Asynchronous) ---
async def get_agent(self, agent_id):
+17 -12
View File
@@ -39,7 +39,9 @@ def parse_function_specs(specs):
)
return functions
# Global call dictionary
# requestId -> asyncio.Future
pending_calls = {}
class RemoteConnection:
"""A remote connected by a session via the 'remote-connect' action.
@@ -57,7 +59,6 @@ class RemoteConnection:
self.name = name
self.functions = functions # dict[str, RemoteFunction]
self.send = send # async callable(dict) over the ws
self.pending_calls = {} # requestId -> asyncio.Future
self._request_ids = itertools.count()
def describe(self):
@@ -88,11 +89,12 @@ class RemoteConnection:
# be routed even when a session serves several remotes
request_id = f"{self.name}#{next(self._request_ids)}"
future = asyncio.get_running_loop().create_future()
self.pending_calls[request_id] = future
pending_calls[request_id] = future
try:
await self.send({
"action": "remote-call",
"requestId": request_id,
"remotename": self.name,
"function": function,
"args": args,
})
@@ -101,32 +103,35 @@ class RemoteConnection:
raise RemoteError(
f"remote call '{function}' on '{self.name}' timed out after {timeout}s")
finally:
self.pending_calls.pop(request_id, None)
pending_calls.pop(request_id, None)
def resolve(self, request_id, result):
@classmethod
def resolve(cls, request_id, result):
"""Complete a pending call with the remote's result. No-op for
unknown request ids (results are fanned out over a session's
connections, see the remote-call-result handler)."""
future = self.pending_calls.get(request_id)
future = pending_calls.get(request_id)
if future is not None and not future.done():
future.set_result(result)
else:
raise Exception(f"remote request id '{request_id}' not found")
def reject(self, request_id, error):
@classmethod
def reject(cls, request_id, error):
"""Fail a pending call with an error reported by the remote. No-op
for unknown request ids."""
future = self.pending_calls.get(request_id)
future = pending_calls.get(request_id)
if future is not None and not future.done():
future.set_exception(RemoteError(str(error)))
else:
raise Exception(f"remote request id '{request_id}' not found")
def close(self):
"""Fail all pending calls; called by App.disconnectRemote on manual
disconnect and on session close. Callers waiting in call() get
RemoteError."""
self.send = None
for future in self.pending_calls.values():
if not future.done():
future.set_exception(RemoteError(f"remote '{self.name}' disconnected"))
self.pending_calls.clear()
# TODO: Fail all pending calls from this remote. Is it necessary though? they will timeout.
class RemoteToolSpec:
+12 -11
View File
@@ -285,11 +285,17 @@ async def start_server(app, shutdown_event):
# Invoke a function of one of the user's connected remotes.
# Expected failures (unknown remote/function, timeout,
# remote-side error) are replied, not raised.
try:
result = await app.callRemote(user_id, body['remotename'], body['function'], body.get('args', {}))
await send({'result': result, 'nonce': nonce})
except Exception as e:
await send({'error': str(e), 'nonce': nonce})
async def _handle_remote_call(user_id, remotename, func_name, args, send, nonce):
try:
result = await app.callRemote(user_id, remotename, func_name, args)
await send({'result': result, 'nonce': nonce})
except Exception as e:
await send({'error': str(e), 'nonce': nonce})
asyncio.create_task(_handle_remote_call(
user_id, body['remotename'], body['function'], body.get('args', {}), send, nonce
))
elif action == "remote-call-result":
# A session serving remotes replies to a downstream call.
@@ -297,12 +303,7 @@ async def start_server(app, shutdown_event):
# requestIds are '<remotename>#<n>' and resolve/reject
# are no-ops for unknown ids, so fanning out over this
# session's connections routes the reply correctly.
if 'error' in req:
for conn in session_connections.values():
conn.reject(req.get('requestId'), req['error'])
else:
for conn in session_connections.values():
conn.resolve(req.get('requestId'), req.get('result'))
app.resolve_remote_call(body['requestId'], body.get('result'), error=body.get('error'))
elif action == "create-chat":
chat_id = await app.create_chat(
+171 -25
View File
@@ -99,14 +99,43 @@ const STORAGE_KEY = 'local_remotes';
let connectListenerCancel = null;
let disconnectListenerCancel = null;
// Listen for remote-call requests destined for local remotes.
// Persists for the session, not just while the tab is visible.
// TODO: restore after disconnections
let callListenerCancel = null;
onMounted(async () => {
await fetchRemotes();
let connectListenerCancel = backend.onAction('remote-connected', ev => {
remotes.value.push(ev.data.remote);
});
let disconnectListenerCancel = backend.onAction('remote-disconnected', ev => {
// TODO
remotes.value = remotes.value.filter((r) => r.name != ev.data.remotename);
});
if (!callListenerCancel) {
callListenerCancel = backend.onAction('remote-call', async ({ requestId, remotename, function: fnName, args }) => {
console.log(`Received remote call for: ${remotename}, ${fnName}`);
const local = localRemoteList.value.find(r => r.name == remotename && r.connected);
if (!local) return;
const funcDef = local.functions.find(f => f.name == fnName);
if (!funcDef) {
await backend.sendAction('remote-call-result', { requestId, error: `Function "${fnName}" not found on local remote` });
return;
}
try {
// TODO: Upgrade from eval to a properly scoped Function constructor or sandboxes
const fnBody = funcDef.body || 'return {};';
const scopeKeys = Object.keys(args);
const scopeValues = scopeKeys.map(k => args[k]);
const createFunc = new Function(...scopeKeys, fnBody);
const result = await createFunc(...scopeValues);
await backend.sendAction('remote-call-result', { requestId, result });
} catch (e) {
await backend.sendAction('remote-call-result', { requestId, error: String(e) });
}
});
}
});
onUnmounted(async () => {
@@ -176,17 +205,17 @@ async function doCall() {
return;
}
try {
callResult.value = await backend.sendAction('remote-call', {
callResult.value = (await backend.sendAction('remote-call', {
remotename: activeRemoteName.value,
function: activeFunction.value.name,
args: argsParsed,
});
})).result;
} catch (e) {
callError.value = e.message || String(e);
}
}
// ---- Local Remotes (unchanged from previous iteration) ----
// ---- Local Remotes ----
function loadLocalRemoteList() {
let data = [];
try {
@@ -196,7 +225,10 @@ function loadLocalRemoteList() {
if (Array.isArray(parsed)) data = parsed;
}
} catch (e) {}
data.forEach(r => r.connected = false);
data.forEach(r => {
r.connected = false
r.functions = r.functions || [];
});
localRemoteList.value = data;
}
@@ -213,10 +245,20 @@ async function connectLocalRemote(remote) {
ui.alert("Cannot connect remote with empty name");
return;
}
let result = await backend.sendAction('remote-connect', {
remotename: remote.name,
functions: remote.functions,
functions: remote.functions?.map(f => ({
name: f.name,
description: '',
parameters: {
type: 'object',
properties: f.arguments.reduce((acc, arg) => {
acc[arg.name] = { type: arg.type, description: '' };
return acc;
}, {}),
required: f.arguments.map(a => a.name)
}
})) || [],
});
if (result.error) {
@@ -247,7 +289,43 @@ function removeLocalRemote(remote) {
saveLocalRemote();
}
function addFunctionToRemote(remote) {
if (!remote.functions) remote.functions = [];
remote.functions.push({
name: `function_${Date.now()}`,
arguments: [{ name: 'arg0', type: 'string' }],
body: '// Return your result object\nreturn {};\n'
});
saveLocalRemote();
}
function removeFunctionFromRemote(remote, index) {
remote.functions.splice(index, 1);
saveLocalRemote();
}
function addArgument(fn) {
const idx = fn.arguments.length;
fn.arguments.push({ name: `arg${idx}`, type: 'string' });
saveLocalRemote();
}
function removeArgument(fn, argIdx) {
fn.arguments.splice(argIdx, 1);
saveLocalRemote();
}
loadLocalRemoteList();
// ---- UI State and Helpers ---- //
const fnExpandedSet = ref(new Set());
function toggleFnExpanded(index, fIdx) {
const key = `${index}-${fIdx}`;
const newSet = new Set(fnExpandedSet.value);
if (newSet.has(key)) newSet.delete(key); else newSet.add(key);
fnExpandedSet.value = newSet;
}
</script>
<template>
@@ -334,23 +412,80 @@ loadLocalRemoteList();
<!-- Local Remotes Section -->
<!-- ========================== -->
<section class="section">
<h2>Local Remotes</h2>
<div class="section-header">
<h2>Local Remotes</h2>
</div>
<div class="remote-list">
<div v-for="(remote, index) in localRemoteList" :key="index" class="remote-entry">
<div v-if="remote.connected" class="view-mode">
<span class="status-text">Connected as: {{ remote.name }}</span>
<button class="btn btn-secondary" @click="disconnectLocalRemote(remote)">Disconnect</button>
<div v-for="(remote, index) in localRemoteList" :key="index" class="card">
<!-- Name / Connect bar -->
<div class="card-header">
<div class="view-mode" style="display:flex;align-items:center;gap:0.75rem;">
<input
v-if="!remote.connected"
type="text"
v-model="remote.name"
placeholder="Enter remote name..."
class="input-field"
/>
<span v-else><strong>{{ remote.name }}</strong></span>
<template v-if="!remote.connected">
<button class="btn btn-primary" @click="connectLocalRemote(remote)">Connect</button>
</template>
<template v-else>
<span class="status-dot active"></span>
<button class="btn btn-secondary" @click="disconnectLocalRemote(remote)">Disconnect</button>
</template>
<button class="btn btn-danger" @click="removeLocalRemote(remote)">Delete</button>
</div>
</div>
<div v-else class="view-mode">
<input
type="text"
v-model="remote.name"
placeholder="Enter remote name..."
class="input-field"
/>
<button class="btn btn-primary" @click="connectLocalRemote(remote)">Connect</button>
<!-- Expanded: function definitions -->
<div class="card-body">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.5rem;">
<strong>Functions</strong>
<button class="btn btn-primary" @click="addFunctionToRemote(remote)">+ Add Function</button>
</div>
<div v-if="!remote.functions || remote.functions.length === 0" style="color:var(--color-text-mute);font-size:0.85em;">No functions yet.</div>
<div v-for="(fn, fIdx) in remote.functions" :key="fIdx" class="func-def-block">
<div class="func-def-header">
<strong>{{ fn.name }}</strong>
<button class="btn btn-danger btn-sm" @click="removeFunctionFromRemote(remote, fIdx)">Remove</button>
<span style="cursor:pointer;" @click.stop="toggleFnExpanded(index, fIdx)">
{{ fnExpandedSet.has(`${index}-${fIdx}`) ? 'collapse' : 'expand' }}
</span>
</div>
<!-- Arguments table -->
<div class="func-def-row">
<div class="args-table">
<table>
<thead><tr><th>Name</th><th>Type</th><th></th></tr></thead>
<tbody>
<tr v-for="(arg, aIdx) in fn.arguments" :key="aIdx">
<td><input type="text" v-model="arg.name" class="input-small" :disabled="remote.connected" /></td>
<td>
<select v-model="arg.type" class="input-small" :disabled="remote.connected">
<option value="string">string</option>
<option value="number">number</option>
<option value="boolean">boolean</option>
<option value="object">object</option>
<option value="array">array</option>
</select>
</td>
<td><button class="btn btn-danger btn-xs" @click="removeArgument(fn, aIdx)">×</button></td>
</tr>
</tbody>
</table>
<button class="btn btn-secondary btn-xs" @click="addArgument(fn)" :disabled="remote.connected">+ Arg</button>
</div>
</div>
<!-- JS body -->
<div v-if="fnExpandedSet.has(`${index}-${fIdx}`)" class="func-def-row">
<textarea v-model="fn.body" rows="4" placeholder="return {};" spellcheck="false" class="js-editor" :readonly="remote.connected"></textarea>
</div>
</div>
</div>
<button class="btn btn-danger" @click="removeLocalRemote(remote)">Delete</button>
</div>
</div>
<button class="btn btn-primary" @click="createLocalRemote">+ Create Local Remote</button>
@@ -390,9 +525,8 @@ loadLocalRemoteList();
.call-inputs label { display: block; font-weight: bold; margin-bottom: 0.25rem; }
.call-inputs textarea { width: 100%; padding: 0.5rem; border: 1px solid var(--color-border); border-radius: var(--border-radius); resize: vertical; font-family: monospace; font-size: 0.85em; margin-bottom: 0.5rem; }
.output-block { margin-bottom: 0.75rem; }
.output-block.success pre { background: var(--color-success-bg, #dcfce7); padding: 0.5rem; border-radius: var(--border-radius); overflow-x: auto; }
.output-block.error { color: #ef4444; }
.output-block.error pre { background: var(--color-error-bg, #fee2e2); padding: 0.5rem; border-radius: var(--border-radius); overflow-x: auto; }
.output-block.success pre { background: var(--color-success-bg, #3c5c47); padding: 0.5rem; border-radius: var(--border-radius); overflow-x: auto; }
.output-block.error pre { background: var(--color-error-bg, #5e4242); padding: 0.5rem; border-radius: var(--border-radius); overflow-x: auto; }
.output-placeholder { padding: 1em; color: var(--color-text-mute); font-style: italic; }
/* ---------- Buttons (shared across sections) ---------- */
@@ -401,10 +535,22 @@ loadLocalRemoteList();
.btn-secondary { background: var(--color-muted, #9ca3af); color: #fff; }
.btn-danger { background: var(--color-error, #ef4444); color: #fff; }
/* ---------- Local remotes (preserved from original) ---------- */
/* ---------- Local remotes ---------- */
.remote-list { margin-top: 1rem; }
.remote-entry { display: flex; align-items: center; gap: 1rem; padding: 1rem; border: 1px solid var(--color-border); border-radius: var(--border-radius); margin-bottom: 1rem; background-color: var(--color-background-soft); }
.view-mode { display: flex; align-items: center; gap: 1rem; flex-grow: 1; }
.status-text { font-weight: bold; }
.input-field { flex-grow: 1; padding: 0.5rem; border: 1px solid var(--color-border); border-radius: var(--border-radius); }
.func-def-block { border: 1px solid var(--color-border); border-radius: var(--border-radius); margin-bottom: 0.5rem; padding: 0.5rem; background: var(--color-background); }
.func-def-header { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.35rem; font-family: monospace; }
.func-def-row { margin-bottom: 0.25rem; }
.args-table table { width: 100%; border-collapse: collapse; font-size: 0.85em; }
.args-table th { text-align: left; color: var(--color-text-mute); padding: 0.2em 0.3em; border-bottom: 1px solid var(--color-border); }
.args-table td { padding: 0.2em 0.3em; }
.args-table input[type="text"] { width: 6em; padding: 0.2em 0.4em; border: 1px solid var(--color-border); border-radius: 4px; font-size: 0.85em; }
.args-table select { padding: 0.2em 0.4em; border: 1px solid var(--color-border); border-radius: 4px; font-size: 0.85em; }
.js-editor { width: 100%; min-height: 3em; max-height: 30em; max-width: 100%; resize: both; padding: 0.5rem; border: 1px solid var(--color-border); border-radius: var(--border-radius); font-family: monospace; font-size: 0.85em; box-sizing: border-box; }
.btn-sm { padding: 0.25em 0.6em; font-size: 0.8em; }
.btn-xs { padding: 0.15em 0.4em; font-size: 0.75em; }
.input-small { padding: 0.2em 0.4em; border: 1px solid var(--color-border); border-radius: 4px; font-size: 0.85em; }
</style>