From da1420d04b2348c8a5184ab1b767ae261e250776 Mon Sep 17 00:00:00 2001 From: Shivering Ocelot Date: Wed, 2 Sep 2026 12:33:34 -0500 Subject: [PATCH] feat(remotes): full remote walkthrough, complete local remotes with editable functions --- backend/src/app.py | 6 + backend/src/remote.py | 29 +++-- backend/src/server.py | 23 ++-- frontend/src/views/Remotes.vue | 196 ++++++++++++++++++++++++++++----- 4 files changed, 206 insertions(+), 48 deletions(-) diff --git a/backend/src/app.py b/backend/src/app.py index b0542e0..00b0f88 100644 --- a/backend/src/app.py +++ b/backend/src/app.py @@ -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): diff --git a/backend/src/remote.py b/backend/src/remote.py index 1a6f937..158ee86 100644 --- a/backend/src/remote.py +++ b/backend/src/remote.py @@ -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: diff --git a/backend/src/server.py b/backend/src/server.py index 63ef5eb..21aaec7 100644 --- a/backend/src/server.py +++ b/backend/src/server.py @@ -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 '#' 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( diff --git a/frontend/src/views/Remotes.vue b/frontend/src/views/Remotes.vue index 00e3989..21fc088 100644 --- a/frontend/src/views/Remotes.vue +++ b/frontend/src/views/Remotes.vue @@ -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; +}