115 lines
5.3 KiB
Markdown
115 lines
5.3 KiB
Markdown
# Remotes
|
|
|
|
## Overview
|
|
|
|
A **session** is one authenticated `/ws` connection. Authentication is
|
|
uniform — token or username/password — and every session is a user
|
|
connection that receives event broadcasts and can send any action.
|
|
|
|
A **remote** is a named set of functions served by a session:
|
|
|
|
- Connected by that session with the `remote-connect` action (backed by the
|
|
`App.connectRemote` method), stored in `App.remotes` scoped to the user.
|
|
- **Lifetime**: connected until `remote-disconnect` (manual, allowed from any
|
|
session of the same user) or until the serving session closes — session
|
|
close disconnects every remote it connected. Nothing is persisted.
|
|
- **No overriding**: connecting a name that is already connected for that
|
|
user fails with an error reply.
|
|
|
|
**Local Remotes** — purely user-side constructs, entirely implemented and
|
|
stored in the frontend (`Remotes.vue`). They never touch the backend.
|
|
|
|
Agents never link to remotes. A prompt passes remote names in
|
|
`options.remotes`; `ExpertAgent.run` expands them into run-scoped tools.
|
|
Every function of a remote becomes a tool named exactly after the function,
|
|
prefixed only when the run input asks for it.
|
|
|
|
This also implements the existing `server.py` TODO ("remotes aren't separate
|
|
connections — any connection can create a remotes and listen for requests
|
|
sent to them").
|
|
|
|
The frontend is already implemented and out of scope; the backend serves the
|
|
two actions it uses (`remote-list`, `remote-call`) with the shapes below.
|
|
|
|
## Wire protocol
|
|
|
|
Every message in both directions uses the existing action envelope. There is
|
|
no separate `type`-keyed protocol.
|
|
|
|
Upstream (session → backend):
|
|
|
|
| action | payload | success reply | error reply |
|
|
|---|---|---|---|
|
|
| `remote-connect` | body `{remotename, tools}` | `{'message': 'remote connected', 'remotename', 'nonce'}` | `{'error': "remote 'x' is already connected", 'nonce'}` |
|
|
| `remote-disconnect` | body `{remotename}` | `{'message': 'remote disconnected', 'remotename', 'nonce'}` | `{'error': "no connected remote named 'x'", 'nonce'}` |
|
|
| `remote-list` | — | `{'remotes': [...], 'nonce'}` | — |
|
|
| `remote-call` | body `{remotename, function, args}` | `{'result': <JSON value>, 'nonce'}` | `{'error': '...', 'nonce'}` |
|
|
| `remote-call-result` | top-level `{requestId, result}` or `{requestId, error}` | none | none |
|
|
|
|
`tools` entries (function specs) and the `remote-list` entries share one
|
|
shape: `{name, description, parameters}` with `parameters` a full OpenAI
|
|
tool-parameter schema dict.
|
|
|
|
Downstream (backend → the session serving the remote):
|
|
|
|
~~~json
|
|
{"action": "remote-call", "requestId": "weather-service#0", "function": "get_forecast", "args": {"city": "Rome"}}
|
|
~~~
|
|
|
|
Result routing: requestIds are `'<remotename>#<n>'`, unique among a user's
|
|
remotes. A `remote-call-result` is fanned out over the receiving session's
|
|
own connections; `resolve`/`reject` ignore unknown ids, so one session can
|
|
serve several remotes without ambiguity. Fan-out is session-scoped, so
|
|
identical id strings belonging to another user's remote can never be
|
|
resolved from this session.
|
|
|
|
Example connect + call round trip:
|
|
|
|
~~~json
|
|
{"action": "remote-connect", "nonce": "c1", "body": {"remotename": "weather-service", "tools": [{"name": "get_forecast", "description": "Retrieve weather forecast for a location.", "parameters": {"type": "object", "properties": {"city": {"type": "string", "description": "City name."}}, "required": ["city"]}}]}}
|
|
~~~
|
|
|
|
~~~json
|
|
{"action": "remote-call", "nonce": "c2", "body": {"remotename": "weather-service", "function": "get_forecast", "args": {"city": "Rome"}}}
|
|
~~~
|
|
|
|
~~~json
|
|
{"action": "remote-call-result", "requestId": "weather-service#0", "result": {"forecast": "sunny", "city": "Rome"}}
|
|
~~~
|
|
|
|
Calls not answered within `REMOTE_CALL_TIMEOUT` (30s, constant in
|
|
`remote.py`) fail; calls pending when the remote disconnects fail
|
|
immediately. All failures surface as `RemoteError`, which becomes an error
|
|
tool-result string for the model — never an exception that kills a run, and
|
|
never a killed caller session.
|
|
|
|
## The prompt contract
|
|
|
|
~~~json
|
|
{
|
|
"action": "send-agent-prompt",
|
|
"body": {
|
|
"agentId": 7,
|
|
"instruction": "What's the weather in Rome? Use your tools.",
|
|
"options": { "remotes": ["weather-service", { "name": "code-repo-indexer", "prefix": "repo_" }] }
|
|
}
|
|
}
|
|
~~~
|
|
|
|
- `Agent.prompt` already splats options into the run input
|
|
(`input={"instruction": ..., "input": ..., **kwargs}`), so `options.remotes`
|
|
lands in `run.input["remotes"]`. No server or app change needed.
|
|
- Each entry is a remote name or `{"name": ..., "prefix": ...}`. Every
|
|
function of the remote becomes one tool named `prefix + function_name`
|
|
(empty prefix by default).
|
|
- Collisions: the agent's native tools are listed before remote tools and
|
|
the expert lookup takes the first match, so native tools win. Pass a
|
|
`prefix` to disambiguate.
|
|
- Remotes are resolved once, at run start. A remote not connected at that
|
|
moment is skipped with a console warning and the run proceeds (remotes
|
|
disconnect independently of prompts). If a remote disconnects mid-run,
|
|
its pending calls fail and the model sees error tool results.
|
|
- Existing expert rule unchanged: if the run input already carries its own
|
|
`tools`, those replace the agent toolset entirely and remotes are not in
|
|
play.
|
|
- Users only see their own remotes — the registry key includes `user_id`. |