feat(chat): support chat resources, implement chat-based remote discovery via resource messages
This commit is contained in:
1 parent
da1420d04b
commit
30afcf2803
10 files changed
+142
-85
No files matched your search
@@ -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,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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):
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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' }}
|
||||||
|
|||||||
Reference in new issue
Block a user