feat(chat): support chat resources, implement chat-based remote discovery via resource messages

This commit is contained in:
Shivering Ocelot committed 2026-09-02 16:34:12 -05:00
1 parent da1420d04b
commit 30afcf2803
10 files changed
+142 -85

No files matched your search

+9
View File
@@ -85,11 +85,19 @@ class Agent:
chat_view = await self.app.get_chat_view(message.chat_id) chat_view = await self.app.get_chat_view(message.chat_id)
thread = chat_view.get_thread(message.thread_id) thread = chat_view.get_thread(message.thread_id)
remotes = []
messages = [] messages = []
for msg in thread.messages: for msg in thread.messages:
role = self._role_for_message(msg) role = self._role_for_message(msg)
messages.append({"role": role, "content": msg.content}) messages.append({"role": role, "content": msg.content})
for msg in thread.resources:
# Remote declarations: content is the remote name
if msg.subkind == 'remote':
name = msg.content
if (self.user_id, name) in self.app.remotes and name not in remotes:
remotes.append(name)
run = AgentRun.create( run = AgentRun.create(
self.app, self.app,
agent_id=self.id, agent_id=self.id,
@@ -98,6 +106,7 @@ class Agent:
"messages": messages, "messages": messages,
"chat_id": message.chat_id, "chat_id": message.chat_id,
"thread_id": message.thread_id, "thread_id": message.thread_id,
"remotes": remotes,
}, },
) )
+14 -9
View File
@@ -24,19 +24,24 @@ class ExpertAgent(Agent):
async def run(self, run): async def run(self, run):
opts = {} opts = {}
# Inject remotes as tools
if 'remotes' in run.input:
run.input['tools'] = run.input.get('tools', []) + self.app.getRemoteTools(self.user_id, run.input['remotes'])
# Is this roon using *Agent's* tools, as opposed to run tools # Is this roon using *Agent's* tools, as opposed to run tools
using_agent_tools = False using_agent_tools = False
if 'tools' not in run.input: tools = run.input.get('tools', [])
if not tools:
# Agent tools will fully replace run tools if none are present, # Agent tools will fully replace run tools if none are present,
# otherwise they won't be used # otherwise they won't be used
if self.tools: if self.tools:
run.input['tools'] = self.tools tools = self.tools
using_agent_tools = True using_agent_tools = True
# Inject remotes as tools
if 'remotes' in run.input:
tools += self.app.getRemoteTools(self.user_id, run.input['remotes'])
print("Final tool object: " + repr(tools))
if tools: run.input['tools'] = tools
if self.config.get("temperature") is not None: if self.config.get("temperature") is not None:
opts["temperature"] = self.config["temperature"] opts["temperature"] = self.config["temperature"]
if self.config.get("repetition_penalty") is not None: if self.config.get("repetition_penalty") is not None:
@@ -83,8 +88,8 @@ class ExpertAgent(Agent):
await inference_end.wait() await inference_end.wait()
content = await output_stream.content_wait(); content = output_stream and await output_stream.content_wait();
reasoning = await reasoning_stream.content_wait(); reasoning = reasoning_stream and await reasoning_stream.content_wait();
if content or reasoning: if content or reasoning:
msg = inference.LlamaMessage('assistant', content or "") msg = inference.LlamaMessage('assistant', content or "")
@@ -95,7 +100,7 @@ class ExpertAgent(Agent):
if using_agent_tools and tool_calls: if using_agent_tools and tool_calls:
print("Will execute tool calls") print("Will execute tool calls")
for tc in tool_calls: for tc in tool_calls:
tool = next(t for t in self.tools if t.name == tc.tool_name) tool = next(t for t in tools if t.name == tc.tool_name)
if not tool: if not tool:
print(f"Bad agent tool name: {tc.name}") print(f"Bad agent tool name: {tc.name}")
continue continue
+3 -3
View File
@@ -138,15 +138,15 @@ class App(ChatMixin):
tools = [] tools = []
for spec in remote_specs or []: for spec in remote_specs or []:
if isinstance(spec, str): if isinstance(spec, str):
name, prefix = spec, '' name, prefix = spec, None
else: else:
name, prefix = spec.get('name'), spec.get('prefix', '') name, prefix = spec.get('name'), spec.get('prefix', None)
connection = self.remotes.get((user_id, name)) connection = self.remotes.get((user_id, name))
if connection is None: if connection is None:
print(f"WARNING: remote '{name}' requested by run input is not connected, skipping") print(f"WARNING: remote '{name}' requested by run input is not connected, skipping")
continue continue
for fn in connection.functions.values(): for fn in connection.functions.values():
tools.append(RemoteToolSpec(connection, fn, name=prefix + fn.name)) tools.append(fn.get_spec(connection, prefix=prefix))
return tools return tools
def resolve_remote_call(self, request_id, result=None, error=None): def resolve_remote_call(self, request_id, result=None, error=None):
+4
View File
@@ -152,6 +152,10 @@ class ChatMixin:
# Notification order depends on whether the content is a stream # Notification order depends on whether the content is a stream
async def notify_agents(): async def notify_agents():
# Not all messages are visible to agents
if message.kind == "resource":
return
# Fetch participant agents # Fetch participant agents
if tmp.is_tmp_id(chat_id): if tmp.is_tmp_id(chat_id):
agent_ids = self.tmp.chats[chat_id].participants agent_ids = self.tmp.chats[chat_id].participants
+3 -3
View File
@@ -53,7 +53,7 @@ class Thread:
self.messages = [] self.messages = []
self.notes = [] self.notes = []
self.memories = [] self.resources = []
class ChatView: class ChatView:
def __init__(self, app, chat_id, participant_type=None, participant_id=None): def __init__(self, app, chat_id, participant_type=None, participant_id=None):
@@ -157,8 +157,8 @@ class ChatView:
if message.kind == 'note': if message.kind == 'note':
thread.notes.insert(0, message) thread.notes.insert(0, message)
elif message.kind == 'memory': elif message.kind == 'resource':
thread.memories.insert(0, message) thread.resources.insert(0, message)
else: else:
thread.messages.insert(0, message) thread.messages.insert(0, message)
+2
View File
@@ -350,6 +350,8 @@ async def openrouter_chat_completion(app, messages, model, tools=[], opts={}, ag
if formatted_tools: if formatted_tools:
kwargs['tools'] = formatted_tools kwargs['tools'] = formatted_tools
print("all kwargs: " + repr(kwargs))
stream_response = await app.openrouter_client.chat.send_async(**kwargs) stream_response = await app.openrouter_client.chat.send_async(**kwargs)
# OpenAI format streams tool calls in parts # OpenAI format streams tool calls in parts
+28
View File
@@ -2,6 +2,8 @@ import asyncio
import itertools import itertools
import json import json
from tool import ToolSpec, ToolParameter
# Seconds to wait for a remote to answer a call before failing it # Seconds to wait for a remote to answer a call before failing it
REMOTE_CALL_TIMEOUT = 30 REMOTE_CALL_TIMEOUT = 30
@@ -24,6 +26,32 @@ class RemoteFunction:
"type": "object", "properties": {}, "required": [], "type": "object", "properties": {}, "required": [],
} }
def get_spec(self, connection, prefix=None):
async def call_function(**kwargs):
try:
result = await connection.call(self.name, kwargs)
except RemoteError as err:
result = {"error": str(err)}
# ToolCall.result is string content; remote results are JSON values
if not isinstance(result, str):
result = json.dumps(result)
return result
return ToolSpec(
name=(prefix or '') + self.name,
description=self.description,
parameters = [
ToolParameter(
name=name,
type=p.get("type", {}),
description=p.get("description"),
required=name in self.parameters.get('required', [])
) for (name, p) in self.parameters.get('properties', {}).items()
],
func=call_function
)
def parse_function_specs(specs): def parse_function_specs(specs):
"""Parse the 'tools' list of a remote-connect message into a """Parse the 'tools' list of a remote-connect message into a
+34
View File
@@ -32,6 +32,8 @@ function chatEntryClass(msg) {
return ['note']; return ['note'];
} else if (msg.type == 'message') { } else if (msg.type == 'message') {
return ['message', msg.sender == 'user' ? 'sent' : 'received']; return ['message', msg.sender == 'user' ? 'sent' : 'received'];
} else if (msg.type == 'resource') {
return ['resource', 'resource-' + (msg.subkind || 'generic')];
} else if (msg.type == 'log') { } else if (msg.type == 'log') {
return ['message', 'log']; return ['message', 'log'];
} else { } else {
@@ -39,6 +41,11 @@ function chatEntryClass(msg) {
} }
} }
function resourceLabel(msg) {
if (msg.subkind == 'remote') return 'Remote';
return msg.subkind || 'Resource';
}
const format = ref(null); const format = ref(null);
const content = ref(""); const content = ref("");
@@ -79,6 +86,11 @@ watch(() => [props.message.format, props.message.content], ([newFormat, newConte
<div class="content">{{content}}</div> <div class="content">{{content}}</div>
</template> </template>
<template v-if="message.type == 'resource'">
<div class="resource-label">{{ resourceLabel(message) }}</div>
<div class="content">{{content}}</div>
</template>
<template v-if="message.type == 'log'"> <template v-if="message.type == 'log'">
<div class="sender">Log ({{message.sender}})</div> <div class="sender">Log ({{message.sender}})</div>
<div class="content">{{content}}</div> <div class="content">{{content}}</div>
@@ -143,4 +155,26 @@ watch(() => [props.message.format, props.message.content], ([newFormat, newConte
font-family: monospace; font-family: monospace;
font-size: 80%; font-size: 80%;
} }
.resource {
align-self: center;
width: fit-content;
padding: 4px 12px;
border-radius: 12px;
border: 1px dashed var(--color-border);
background-color: var(--color-background-soft);
font-size: 85%;
display: flex;
gap: 0.5em;
align-items: baseline;
}
.resource .resource-label {
font-weight: bold;
opacity: 60%;
}
.resource .content {
font-family: monospace;
}
</style> </style>
+42 -2
View File
@@ -84,6 +84,7 @@
for (let entry of messages.value) { for (let entry of messages.value) {
if (entry.id == it.id) { if (entry.id == it.id) {
entry.type = it.kind; entry.type = it.kind;
entry.subkind = it.subkind;
entry.format = it.format; entry.format = it.format;
entry.content = it.content; entry.content = it.content;
entry.threadId = it.threadId; entry.threadId = it.threadId;
@@ -109,6 +110,7 @@
addMessage({ addMessage({
id: it.id, id: it.id,
type: it.kind, type: it.kind,
subkind: it.subkind,
format: it.format, format: it.format,
sender: it.sender, sender: it.sender,
content: it.content, content: it.content,
@@ -124,6 +126,7 @@
//id: it.id, //id: it.id,
id: _messageNonce++, id: _messageNonce++,
type: it.kind, type: it.kind,
subkind: it.subkind,
sender: it.sender, sender: it.sender,
content: it.content, content: it.content,
threadId: it.threadId, threadId: it.threadId,
@@ -296,8 +299,8 @@
// New message object with updated name // New message object with updated name
const message = Object.assign({}, _msg, {sender: senderName(_msg.sender)}); const message = Object.assign({}, _msg, {sender: senderName(_msg.sender)});
// Non-threaded messages and notes go directly to the entry list // Non-threaded messages, notes, and resources go directly to the entry list
if (message.threadId == null || message.type == 'note') { if (message.threadId == null) {
entries.push(message); entries.push(message);
} else { } else {
@@ -343,6 +346,40 @@
} }
} }
async function addRemoteResource () {
const res = await backend.sendAction('remote-list');
const remotes = res.remotes || [];
if (remotes.length == 0) {
ui.alert("No remotes connected");
return;
}
const selected = await ui.contextMenu(remotes.map(r => ({
value: r.name,
title: r.name + (r.description ? ` — ${r.description}` : ''),
})));
if (!selected) return;
const threadId = currentThreadId.value;
backend.sendAction("send-chat-message", {
chatId, content: selected, threadId,
kind: 'resource', subkind: 'remote',
}).then((response) => {
if (response['error']) {
ui.alert("Error adding remote: " + response['error']);
}
});
// Optimistic entry, matched against the server broadcast by content
addMessage({
type: 'resource',
subkind: 'remote',
sender: 'user',
content: selected,
threadId,
}, {unsent: true});
}
function editMessage (message) { function editMessage (message) {
let elem = inputFieldElem.value; let elem = inputFieldElem.value;
if (message) { if (message) {
@@ -475,6 +512,9 @@
<button @click="startNewThread()"> <button @click="startNewThread()">
New Thread New Thread
</button> </button>
<button @click="addRemoteResource()">
Add Remote
</button>
<button @click="noteMode=!noteMode"> <button @click="noteMode=!noteMode">
{{noteMode ? 'Cancel Note' : 'Make Note'}} {{noteMode ? 'Cancel Note' : 'Make Note'}}
</button> </button>
+3 -68
View File
@@ -5,72 +5,7 @@ import * as backend from '../backend';
import * as ui from '../ui'; import * as ui from '../ui';
/** Connected external tool remotes (initialized with mock data) */ /** Connected external tool remotes (initialized with mock data) */
const remotes = ref([/* const remotes = ref([]);
{
name: 'weather-service',
connected: true,
functions: [
{
name: 'get_forecast',
description: 'Retrieve weather forecast for a location.',
parameters: {
type: 'object',
properties: {
city: { type: 'string', description: 'City name or code.' },
days: { type: 'number', description: 'Number of days ahead.' }
},
required: ['city']
}
},
{
name: 'search_storms',
description: 'Find active storm systems near coordinates.',
parameters: {
type: 'object',
properties: {
lat: { type: 'number', description: 'Latitude.' },
lon: { type: 'number', description: 'Longitude.' },
radius_km: { type: 'number', description: 'Search radius.' }
},
required: ['lat', 'lon']
}
}
],
agentIds: []
},
{
name: 'code-repo-indexer',
connected: true,
functions: [
{
name: 'search_codebase',
description: 'Search repository code by query.',
parameters: {
type: 'object',
properties: {
query: { type: 'string', description: 'Search text.' },
lang: { type: 'string', description: 'Language filter.' },
path_regex: { type: 'string', description: 'Path pattern.' }
},
required: ['query']
}
},
{
name: 'find_symbol',
description: 'Locate a symbol definition in the codebase.',
parameters: {
type: 'object',
properties: {
symbol_name: { type: 'string', description: 'Name of the symbol.' },
file_path: { type: 'string', description: 'Restrict to a file.' }
},
required: ['symbol_name']
}
}
],
agentIds: []
}
*/]);
/** Tracks expanded remote panels using a Set to support multiple open sections */ /** Tracks expanded remote panels using a Set to support multiple open sections */
const expandedRemoteIds = ref(new Set()); const expandedRemoteIds = ref(new Set());
@@ -113,7 +48,7 @@ onMounted(async () => {
remotes.value = remotes.value.filter((r) => r.name != ev.data.remotename); remotes.value = remotes.value.filter((r) => r.name != ev.data.remotename);
}); });
if (!callListenerCancel) { if (callListenerCancel == null) {
callListenerCancel = backend.onAction('remote-call', async ({ requestId, remotename, function: fnName, args }) => { callListenerCancel = backend.onAction('remote-call', async ({ requestId, remotename, function: fnName, args }) => {
console.log(`Received remote call for: ${remotename}, ${fnName}`); console.log(`Received remote call for: ${remotename}, ${fnName}`);
const local = localRemoteList.value.find(r => r.name == remotename && r.connected); const local = localRemoteList.value.find(r => r.name == remotename && r.connected);
@@ -448,7 +383,7 @@ function toggleFnExpanded(index, fIdx) {
<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-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 v-for="(fn, fIdx) in remote.functions" :key="fIdx" class="func-def-block">
<div class="func-def-header"> <div class="func-def-header">
<strong>{{ fn.name }}</strong> <input type="text" v-model="fn.name" class="input-field"/>
<button class="btn btn-danger btn-sm" @click="removeFunctionFromRemote(remote, fIdx)">Remove</button> <button class="btn btn-danger btn-sm" @click="removeFunctionFromRemote(remote, fIdx)">Remove</button>
<span style="cursor:pointer;" @click.stop="toggleFnExpanded(index, fIdx)"> <span style="cursor:pointer;" @click.stop="toggleFnExpanded(index, fIdx)">
{{ fnExpandedSet.has(`${index}-${fIdx}`) ? 'collapse' : 'expand' }} {{ fnExpandedSet.has(`${index}-${fIdx}`) ? 'collapse' : 'expand' }}