Compare commits

...
13 Commits
23 changed files with 1846 additions and 799 deletions

No files matched your search

+1
View File
@@ -1,6 +1,7 @@
Quart==0.20.0 Quart==0.20.0
quart-auth quart-auth
quart-cors quart-cors
httpx
sqlalchemy sqlalchemy
nest_asyncio nest_asyncio
replicate replicate
+94 -66
View File
@@ -5,7 +5,6 @@ import asyncio
import traceback import traceback
from temporary import generate_tmp_id from temporary import generate_tmp_id
from stream import Stream
# Tool related classes must be moved to a different module # Tool related classes must be moved to a different module
@@ -36,15 +35,25 @@ class Agent:
# should be in expert init? # should be in expert init?
self.tools = [] self.tools = []
self.remotes = []
for resource in self.resources: for resource in self.resources:
if resource.kind != 'tool': continue if resource.kind == 'tool':
if resource.value.get('type') == 'native' and 'name' in resource.value: if resource.value.get('type') == 'native' and 'name' in resource.value:
tool_cls = next((t for t in self.app.tool_classes if t.tool_name() == resource.value['name']), None) tool_cls = next((t for t in self.app.tool_classes if t.tool_name() == resource.value['name']), None)
if tool_cls: if tool_cls:
tool = tool_cls() tool = tool_cls()
spec = tool.get_spec().clone() spec = tool.get_spec().clone()
spec.name = resource.key spec.name = resource.key
self.tools.append(spec) self.tools.append(spec)
elif resource.kind == 'remote':
value = resource.value or {}
if isinstance(value, str):
value = {'prefix': value}
self.remotes.append({
'name': resource.key,
'prefix': value.get('prefix'),
})
def _set_basic_info(self, id, name, user_id): def _set_basic_info(self, id, name, user_id):
self.id = id self.id = id
@@ -56,6 +65,10 @@ class Agent:
agent_run.close() agent_run.close()
raise NotImplementedError("Subclasses must implement run()") raise NotImplementedError("Subclasses must implement run()")
# Async generator: constructs a run, executes it, and yields every message
# the run emits (raw inference.Message objects: role + content) in arrival
# order. No projection: no delta collapsing, no tool filtering, no dropping
# of reasoning/usage. Consumers route or accumulate as they see fit.
async def prompt(self, instruction, input=None, **kwargs): async def prompt(self, instruction, input=None, **kwargs):
run = AgentRun.create( run = AgentRun.create(
self.app, self.app,
@@ -67,19 +80,48 @@ class Agent:
**kwargs, **kwargs,
}, },
) )
out_stream = Stream(self.app)
def on_output_event(stream): queue = asyncio.Queue()
def on_chunk(chunk):
out_stream.feed(chunk) def on_inference(msg):
stream.listen(on_chunk, lambda: None) queue.put_nowait(('message', msg))
def on_done(data):
out_stream.close() def on_done(_data):
run.on('output', on_output_event) queue.put_nowait(('done', None))
# Register listeners before scheduling the task. AgentRun.on replays
# past events of that name, so this both avoids a race and guarantees
# no early messages are lost.
run.on('inference', on_inference)
run.on('done', on_done) run.on('done', on_done)
await self.run(run)
return out_stream run.task = asyncio.create_task(self.run(run))
def on_task_done(task):
# Safety net: an agent that raises (or forgets run.close()) would
# otherwise leave the consumer awaiting forever.
queue.put_nowait(('task', task))
run.task.add_done_callback(on_task_done)
try:
while True:
kind, payload = await queue.get()
if kind == 'message':
yield payload
elif kind == 'done':
break
else: # task
if payload.cancelled():
break
exc = payload.exception()
if exc:
raise exc
break
finally:
if not run.task.done():
run.task.cancel()
async def received_message(self, message): async def received_message(self, message):
chat = await self.app.get_chat(message.chat_id) chat = await self.app.get_chat(message.chat_id)
@@ -113,13 +155,28 @@ class Agent:
}, },
) )
async def on_output_event(stream): async def on_inference(msg):
print("[agent] run output " + repr(stream)) kwargs = {'content': msg.content}
if msg.role == 'assistant':
# kind and subkind are default
pass
elif msg.role == 'reasoning':
kwargs['kind'] = "log"
kwargs['subkind'] = "reasoning"
elif msg.role == 'tool':
kwargs['kind'] = "log"
kwargs['subkind'] = "tool-call"
kwargs['content'] = repr(msg.content)
# Unknown message
else: return
new_message = await self.app.send_chat_message( new_message = await self.app.send_chat_message(
sender=self.id, sender=self.id,
chat_id=message.chat_id, chat_id=message.chat_id,
thread_id=message.thread_id, thread_id=message.thread_id,
content=stream, **kwargs
) )
run.emit_event('chat-message', { run.emit_event('chat-message', {
'messageId': new_message['id'], 'messageId': new_message['id'],
@@ -127,41 +184,7 @@ class Agent:
'threadId': message.thread_id, 'threadId': message.thread_id,
}) })
async def on_reasoning_event(stream): await run.on_async('inference', on_inference)
print("[agent] run output " + repr(stream))
new_message = await self.app.send_chat_message(
sender=self.id,
chat_id=message.chat_id,
thread_id=message.thread_id,
content=stream,
kind="log",
subkind="reasoning",
)
run.emit_event('chat-message', {
'messageId': new_message['id'],
'chatId': message.chat_id,
'threadId': message.thread_id,
})
async def on_toolcall_event(stream):
print("[agent] run output " + repr(stream))
new_message = await self.app.send_chat_message(
sender=self.id,
chat_id=message.chat_id,
thread_id=message.thread_id,
content=stream,
kind="log",
subkind="tool-call",
)
run.emit_event('chat-message', {
'messageId': new_message['id'],
'chatId': message.chat_id,
'threadId': message.thread_id,
})
await run.on_async('reasoning', on_reasoning_event)
await run.on_async('output', on_output_event)
await run.on_async('tool-call', on_toolcall_event)
await self.run(run) await self.run(run)
return run return run
@@ -184,16 +207,21 @@ class Agent:
if r.kind == kind and (key is None or r.key == key)] if r.kind == kind and (key is None or r.key == key)]
def get_tools(self, run=None): def get_tools(self, run=None):
"""Collect the tools available to this agent. remotes = list(self.remotes)
Returns the agent-configured tools (derived from its resources) plus
any remote tools referenced by the run input. When a run is given,
remote names in ``run.input['remotes']`` are resolved and appended.
"""
tools = list(self.tools)
if run is not None and 'remotes' in run.input: if run is not None and 'remotes' in run.input:
tools += self.app.getRemoteTools(self.user_id, run.input['remotes']) remotes += list(run.input['remotes'])
return tools
tools = list(self.tools) + self.app.getRemoteTools(self.user_id, remotes)
# Dedupe by tool name (agent-configured vs run-input vs native collisions)
seen, result = set(), []
for tool in tools:
if tool.name in seen:
continue
seen.add(tool.name)
result.append(tool)
return result
class DebugAgent(Agent): class DebugAgent(Agent):
+34 -52
View File
@@ -1,5 +1,6 @@
import inference import inference
from agent import Agent, AgentField from agent import Agent, AgentField
from tool import ToolSpec
import asyncio import asyncio
@@ -20,62 +21,47 @@ async def chat_completion_loop(app, run, messages, model_id=None, tools=None):
tools = list(tools or []) tools = list(tools or [])
execute_tools = run.input.get('tool_policy', 'auto') == 'auto' execute_tools = run.input.get('tool_policy', 'auto') == 'auto'
tool_calls = []
output_stream = None
reasoning_stream = None
inference_end = asyncio.Event()
def on_output(stream):
nonlocal output_stream
output_stream = stream
def on_reasoning(stream):
nonlocal reasoning_stream
reasoning_stream = stream
run.on('output', on_output)
run.on('reasoning', on_reasoning)
run.on('tool-call', tool_calls.append)
run.on('inference-end', lambda _: inference_end.set())
while True: while True:
# Reset per-iteration state tool_calls = []
output_stream = None turn_response = False
reasoning_stream = None generator = inference.chat_completion(
inference_end.clear()
await inference.chat_completion(
app, app,
model_id=model_id, model_id=model_id,
messages=messages, messages=messages,
tools=tools, tools=tools,
agent_run=run, opts=run.input,
) )
await inference_end.wait() pre_len = len(messages)
async for msg in generator:
content = output_stream and await output_stream.content_wait()
reasoning = reasoning_stream and await reasoning_stream.content_wait()
if content or reasoning:
msg = inference.LlamaMessage('assistant', content or "")
if reasoning:
msg.reasoning = reasoning
messages.append(msg) messages.append(msg)
run.emit_event('inference', msg)
if msg.role == 'tool':
tool_calls.append(msg.content)
if msg.role == 'assistant':
turn_response = True
if execute_tools and tool_calls: # Avoid infinite loops from server failures
for tc in tool_calls: if len(messages) == pre_len: break
tool = next((t for t in tools if t.name == tc.tool_name), None)
if tool is None: cancel_tools = False
print(f"[agent] unknown tool name: {tc.tool_name}") for tc in tool_calls:
continue tool = next((t for t in tools if t.name == tc.tool_name), None)
tc.result = await tool.execute_async(**tc.arguments) if tool is None:
messages.append(inference.LlamaMessage('tool', tc)) print(f"[agent] unknown tool name: {tc.tool_name}")
tool_calls.clear() continue
# Buffer time to let the provider rest before the next call if execute_tools:
await asyncio.sleep(2) # Only known 'non-callable' tool spec
elif content or not reasoning: if isinstance(tool, ToolSpec) and not tool.func:
break cancel_tools = True
tc.tool = tool
else:
tc.result = await tool.execute_async(**tc.arguments)
else:
tc.tool = tool
# If not executing tools, respond to end the turn
if not (tool_calls and execute_tools) and turn_response or cancel_tools: break
run.close() run.close()
@@ -105,13 +91,9 @@ class ExpertAgent(Agent):
# Provide the agent's default tools unless the run supplies its own. # Provide the agent's default tools unless the run supplies its own.
# Only agent-provided tools are executable within the loop. # Only agent-provided tools are executable within the loop.
tools = run.input.get("tools", []) tools = run.input.get("tools", [])
if tools: if run.input.get('tool_policy') != 'caller':
run.input['tool_policy'] = 'ignore'
else:
tools += self.get_tools(run) tools += self.get_tools(run)
print("Final tool object: " + repr(run.input.get('tools', [])))
# Build the initial message list # Build the initial message list
messages = inference.build_messages( messages = inference.build_messages(
system_prompt=self.config.get("system_prompt", ""), system_prompt=self.config.get("system_prompt", ""),
+13 -3
View File
@@ -22,6 +22,7 @@ from drives import init_drives
from remote import RemoteConnection, RemoteToolSpec, parse_function_specs from remote import RemoteConnection, RemoteToolSpec, parse_function_specs
from app_mixins import ChatMixin, AgentMixin from app_mixins import ChatMixin, AgentMixin
from channels import Channels
class App(ChatMixin, AgentMixin): class App(ChatMixin, AgentMixin):
def __init__(self, data_directory, replicate_token=None, openrouter_token=None): def __init__(self, data_directory, replicate_token=None, openrouter_token=None):
@@ -34,6 +35,9 @@ class App(ChatMixin, AgentMixin):
# active websocket tasks, for manual cancelling upon server shutdown # active websocket tasks, for manual cancelling upon server shutdown
self.ws_tasks = [] self.ws_tasks = []
# Channel bindings, in-memory transcripts, pollers and HTTP.
self.channels = Channels(self)
self.tmp = tmp.State() self.tmp = tmp.State()
# Clients # Clients
@@ -47,7 +51,8 @@ class App(ChatMixin, AgentMixin):
] ]
from features.calc import Calc from features.calc import Calc
self.tool_classes = [Calc] from features.web import WebSearch, WebFetch
self.tool_classes = [Calc, WebSearch, WebFetch]
# --- Connection Management (Synchronous) --- # --- Connection Management (Synchronous) ---
@@ -235,13 +240,16 @@ async def main():
openrouter_token=os.getenv("OPENROUTER_API_KEY") openrouter_token=os.getenv("OPENROUTER_API_KEY")
) )
# 3. Start Server # 3. Set up channel listeners (webhook reconciliation or long polling)
await horde_app.channels.setup()
# 4. Start Server
# manually trigger server shutdown, avoids hypercorn installing signal handlers # manually trigger server shutdown, avoids hypercorn installing signal handlers
shutdown_event = asyncio.Event() shutdown_event = asyncio.Event()
# TODO: get port from env or don't pass it (actually server should handle none) # TODO: get port from env or don't pass it (actually server should handle none)
server_task = asyncio.create_task(start_server(horde_app, port, shutdown_event)) server_task = asyncio.create_task(start_server(horde_app, port, shutdown_event))
# 4. Graceful Termination # 5. Graceful Termination
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
def interrupt(): def interrupt():
print("Interrupt Signal") print("Interrupt Signal")
@@ -249,6 +257,8 @@ async def main():
# Unblock websocket handlers parked in websocket.receive() # Unblock websocket handlers parked in websocket.receive()
for task in list(horde_app.ws_tasks): for task in list(horde_app.ws_tasks):
task.cancel() task.cancel()
# Stop channel pollers and any in-flight channel runs
horde_app.channels.shutdown()
for sig in (signal.SIGINT, signal.SIGTERM): for sig in (signal.SIGINT, signal.SIGTERM):
loop.add_signal_handler(sig, interrupt) loop.add_signal_handler(sig, interrupt)
+49 -4
View File
@@ -126,13 +126,21 @@ class AgentMixin:
async def save_agent_resource(self, agent_id, kind, key, value): async def save_agent_resource(self, agent_id, kind, key, value):
await db.AgentResource.update_or_create( await db.AgentResource.update_or_create(
{'value': value},
agent_id=agent_id, agent_id=agent_id,
kind=kind, kind=kind,
key=key, key=key,
value=value,
) )
print(f"Saved agent resource: {agent_id} - {kind} - {key}") print(f"Saved agent resource: {agent_id} - {kind} - {key}")
async def delete_agent_resource(self, agent_id, kind, key):
await db.AgentResource.filter(
agent_id=agent_id,
kind=kind,
key=key,
).delete()
print(f"Deleted agent resource: {agent_id} - {kind} - {key}")
# --- Control & Actions --- # --- Control & Actions ---
async def get_agent_control(self, agent_id): async def get_agent_control(self, agent_id):
@@ -172,10 +180,47 @@ class AgentMixin:
runs = [r for r in runs if r.user_id == user_id] runs = [r for r in runs if r.user_id == user_id]
return [r.serialize() for r in runs] return [r.serialize() for r in runs]
async def send_agent_prompt(self, agent_id, instruction, input=None, options={}): async def prompt_agent(self, agent_id, instruction, input=None, options={}):
# Mirror agent.prompt's generator so run ownership and lifecycle stay
# inside the mixin: callers just iterate and receive the same raw
# inference.Message objects. get_agent_control is awaited lazily on first
# iteration, keeping the control object alive for the generator's
# lifetime.
def normalize_tool(t):
# this function makes a lot of sense in ToolSpec itself...
from tool import ToolSpec, ToolParameter
if isinstance(t, dict):
params = []
d = t.get('parameters', {})
required = d.get('required', [])
props = d.get('properties', {})
for key, p in props.items():
params.append(ToolParameter(
name=key,
type=p.get('type'),
description=p.get('description'),
required=key in required,
))
return ToolSpec(
name=t['name'],
description=t['description'],
parameters=params,
)
else:
return t
if 'tools' in options:
options = {**options}
options['tools'] = [
normalize_tool(t) for t in options['tools']
]
control = await self.get_agent_control(agent_id) control = await self.get_agent_control(agent_id)
response = await control.prompt(instruction, input, **options) async for msg in control.prompt(instruction, input, **options):
return response yield msg
async def call_agent_action(self, agent_id, action, args): async def call_agent_action(self, agent_id, action, args):
control = await self.get_agent_control(agent_id) control = await self.get_agent_control(agent_id)
+1
View File
@@ -13,6 +13,7 @@ def message_json(msg):
"sender": msg.sender, "sender": msg.sender,
"kind": msg.kind, "kind": msg.kind,
"subkind": msg.subkind, "subkind": msg.subkind,
"format": msg.format,
"content": msg.content, "content": msg.content,
"threadId": msg.thread_id, "threadId": msg.thread_id,
} }
+445
View File
@@ -0,0 +1,445 @@
"""Channel integration, currently Telegram only.
A *channel* is an inbound/outbound pipe between an external messaging service
and an agent. The binding is stored as an ordinary ``AgentResource`` on the
responding agent::
kind = "channel"
key = "telegram"
value = {"token": "<bot token>", "secret": "<webhook secret>", "name": "..."}
Nothing else is persisted: a Telegram conversation never creates ``Chat`` /
``Thread`` / ``Message`` rows. The rolling transcript lives in memory here
(``Channels``) and only for the lifetime of the process.
Inbound delivery has two mutually exclusive modes, chosen at startup:
* **Webhook** when ``HUB_HOST`` is set: Telegram POSTs updates to
``/channel/telegram/<agent_id>`` (served by ``server/telegram.py``).
* **Long polling** otherwise: one background task per binding calls
``getUpdates``.
Both modes converge on :meth:`Channels.handle_update`.
Outbound is a single ``sendMessage`` call, exposed to the model as a
``ToolSpec`` carrying a ``func`` so the agent's run loop can execute it.
"""
import asyncio
import os
import time
from collections import deque
from datetime import datetime
from functools import partial
import httpx
import database as db
from tool import ToolSpec, ToolParameter
TELEGRAM_API = "https://api.telegram.org"
# Telegram long-poll timeout (server holds the request open this long).
POLL_TIMEOUT = 50
# Rolling transcript cap per conversation, in bytes (oldest trimmed first).
TRANSCRIPT_BYTE_CAP = 8 * 1024
# Telegram hard limit for a single text message.
MAX_MESSAGE_LENGTH = 4096
class Channels:
"""Owns channel bindings, in-memory transcripts, pollers and HTTP."""
def __init__(self, app):
self._app = app
self._client = None
self._tasks = []
self._transcripts = {} # (agent_id, chat_id) -> {transcript, lock}
self._last_update = {} # (agent_id, kind) -> update_id
# --- Startup / lifecycle ---
async def setup(self):
"""Query channel resources and register listeners where necessary.
Called once at startup, after ``db.init_database`` and App
construction. Webhook mode reconciles the webhook per binding; when
``HUB_HOST`` is absent we warn and start one long-polling task per
binding instead.
"""
resources = await db.AgentResource.filter(kind="channel", key="telegram").all()
if not resources:
print("[channel] no telegram channel bindings found")
return
hub_host = os.getenv("HUB_HOST")
if hub_host:
for resource in resources:
try:
await self._reconcile_webhook(resource, hub_host)
except Exception as e:
print(f"[channel] webhook setup failed for agent {resource.agent_id}: {e}")
else:
print("WARNING: HUB_HOST is not set; telegram channels fall back to long polling")
for resource in resources:
self._track(asyncio.create_task(self._poll(resource)))
def shutdown(self):
"""Cancel every tracked poller / in-flight run."""
for task in list(self._tasks):
task.cancel()
# --- Telegram HTTP ---
def _get_client(self):
if self._client is None:
self._client = httpx.AsyncClient()
return self._client
async def _api(self, token, method, payload=None, timeout=30):
"""Call a Telegram Bot API method and return its ``result``.
Raises on transport errors and on ``ok: false`` responses.
"""
url = f"{TELEGRAM_API}/bot{token}/{method}"
response = await self._get_client().post(url, json=payload or {}, timeout=timeout)
data = response.json()
if not data.get("ok"):
raise Exception(f"telegram {method} failed: {data.get('description')}")
return data.get("result")
async def get_binding(self, agent_id):
return await db.AgentResource.get_or_none(
agent_id=agent_id, kind="channel", key="telegram")
# --- Webhook mode ---
async def _reconcile_webhook(self, resource, hub_host):
value = resource.value or {}
token = value.get("token")
if not token:
print(f"[channel] telegram binding of agent {resource.agent_id} has no token, skipping")
return
url = f"https://{hub_host}/channel/telegram/{resource.agent_id}"
info = await self._api(token, "getWebhookInfo")
if info.get("url") == url:
print(f"[channel] webhook already registered for agent {resource.agent_id}")
return
payload = {"url": url, "allowed_updates": ["message"]}
secret = value.get("secret")
if secret:
payload["secret_token"] = secret
await self._api(token, "setWebhook", payload)
print(f"[channel] registered webhook {url} for agent {resource.agent_id}")
def accept_update(self, agent_id, kind, update_id):
"""Drop duplicate webhook updates by remembering the last update id."""
if update_id is None:
return True
key = (agent_id, kind)
last = self._last_update.get(key)
if last is not None and update_id <= last:
return False
self._last_update[key] = update_id
return True
def dispatch(self, agent_id, kind, payload):
"""Accept an inbound update and run it in the background.
Does the duplicate check, spawns the tracked run and returns
immediately so the caller (the webhook route) holds no channel
bookkeeping.
"""
if not self.accept_update(agent_id, kind, payload.get("update_id")):
return
self._track(asyncio.create_task(self.handle_update(agent_id, kind, payload)))
# --- Long-polling mode ---
async def _poll(self, resource):
"""Long-poll ``getUpdates`` for one telegram binding until cancelled.
``offset`` is recomputed from the last seen ``update_id`` so each
update is acknowledged and delivered exactly once. Transport errors
back off instead of busy-looping.
"""
value = resource.value or {}
token = value.get("token")
agent_id = resource.agent_id
if not token:
print(f"[channel] telegram binding of agent {agent_id} has no token, not polling")
return
# Webhooks and getUpdates are mutually exclusive: clear any webhook
# once before the first poll.
try:
await self._api(token, "deleteWebhook", {"drop_pending_updates": False})
except Exception as e:
print(f"[channel] deleteWebhook failed for agent {agent_id}: {e}")
offset = None
backoff = 1
print(f"[channel] polling telegram updates for agent {agent_id}")
while True:
try:
payload = {
"timeout": POLL_TIMEOUT,
"limit": 100,
"allowed_updates": ["message"],
}
if offset is not None:
payload["offset"] = offset
updates = await self._api(
token, "getUpdates", payload, timeout=POLL_TIMEOUT + 15)
backoff = 1
for update in updates:
offset = update["update_id"] + 1
await self.handle_update(agent_id, "telegram", update)
except asyncio.CancelledError:
print(f"[channel] polling stopped for agent {agent_id}")
raise
except Exception as e:
print(f"[channel] telegram polling error for agent {agent_id}: {e}")
await asyncio.sleep(backoff)
backoff = min(backoff * 2, 60)
# --- Inbound ---
async def handle_update(self, agent_id, kind, payload):
"""Handle one inbound update from a channel.
Minimal path: only plain text messages from non-bot senders are
consumed. Everything else (edited messages, media, callbacks) is
ignored.
"""
if kind != "telegram":
print(f"[channel] unsupported channel kind '{kind}'")
return
message = payload.get("message")
if not message:
return
text = message.get("text")
if not text:
return
sender = message.get("from") or {}
if sender.get("is_bot"):
return
chat = message.get("chat") or {}
chat_id = chat.get("id")
if chat_id is None:
return
binding = await self.get_binding(agent_id)
if binding is None:
print(f"[channel] no telegram binding for agent {agent_id}, dropping update")
return
sender_name = self._sender_name(sender, chat)
state = self._state(agent_id, chat_id)
async with state["lock"]:
state["transcript"].append({
"sender": sender_name,
"text": text,
"time": time.time(),
})
self._trim(state["transcript"])
transcript = self._render(state["transcript"])
await self.mark_as_read(agent_id, chat_id)
await self._run_agent(agent_id, kind, chat_id, chat, sender_name, transcript)
async def _run_agent(self, agent_id, kind, chat_id, chat, sender_name, transcript):
"""Fire one prompt run for a conversation event and drain it.
The run input is flattened to an instruction plus exactly two user
messages: a status/info message and the rendered conversation.
"""
instruction = (
"You are responding to a Telegram conversation. The messages below "
"are the recent conversation; the first user message carries channel "
"context. Reply to the latest message by calling the `send_message` "
"tool with the text to send. Do not add commentary outside the tool call."
)
title = chat.get("title") or sender_name
status = "\n".join([
"Channel: telegram",
f"Chat id: {chat_id}",
f"Chat title: {title}",
f"Chat type: {chat.get('type')}",
f"Current time: {datetime.now().isoformat(timespec='seconds')}",
f"Latest message from: {sender_name}",
])
# Signal presence for the lifetime of this run.
await self.set_online_status(agent_id, chat_id, online=True)
tool = ToolSpec(
name="send_message",
description="Send a text message back to this Telegram conversation.",
parameters=[ToolParameter(
name="text",
type="string",
description="The message text to send to the conversation.",
required=True,
)],
func=partial(self.send_message, agent_id, kind, chat_id),
)
messages = [
{"role": "user", "content": status},
{"role": "user", "content": transcript},
]
async def drain():
async for msg in self._app.prompt_agent(
agent_id,
instruction,
input=None,
options={"tools": [tool], "messages": messages},
):
self._log_message(kind, chat_id, msg)
task = self._track(asyncio.create_task(drain()))
try:
await task
except asyncio.CancelledError:
raise
except Exception as e:
print(f"[channel] run failed for agent {agent_id}: {e}")
def _log_message(self, kind, chat_id, msg):
content = msg.content
if not isinstance(content, str):
content = repr(content)
print(f"[channel:{kind}:{chat_id}] {msg.role}: {content}")
# --- Outbound ---
async def send_message(self, agent_id, kind, chat_id, text):
"""Send a text message to a channel conversation.
Returns a short result string so the model sees the tool outcome.
"""
if kind != "telegram":
return f"error: unsupported channel '{kind}'"
binding = await self.get_binding(agent_id)
if binding is None:
return "error: no telegram channel binding for this agent"
token = (binding.value or {}).get("token")
if not token:
return "error: telegram binding has no token"
text = (text or "").strip()
if not text:
return "error: message text is empty"
if len(text) > MAX_MESSAGE_LENGTH:
text = text[:MAX_MESSAGE_LENGTH]
# Typing indicator immediately before the message so the user sees
# the three-dot animation right as the reply is ready.
try:
await self._api(token, "sendAction", {"chat_id": chat_id, "action": "typing"})
except Exception as e:
print(f"[channel] sendAction typing failed for agent {agent_id}: {e}")
try:
await self._api(token, "sendMessage", {"chat_id": chat_id, "text": text})
return "ok"
except Exception as e:
print(f"[channel] sendMessage failed for agent {agent_id}: {e}")
return f"error: {e}"
async def send_typing(self, agent_id, chat_id, typing=True):
"""Send a typing action indicator to the chat.
Telegram exposes this via ``sendAction`` with ``typing`` (start) or
``cancel`` (stop). Failures are swallowed: an indicator is cosmetic
and must never break a run.
"""
binding = await self.get_binding(agent_id)
if binding is None:
return
token = (binding.value or {}).get("token")
if not token:
return
action = "typing" if typing else "cancel"
try:
await self._api(token, "sendAction", {"chat_id": chat_id, "action": action})
except Exception as e:
print(f"[channel] sendAction failed: {e}")
async def mark_as_read(self, agent_id, chat_id):
"""Mark an inbound message as read.
Telegram bots have no native read receipt; acceptance is already
handled by :meth:`accept_update`. This stays an explicit hook on the
inbound path so a provider that does support receipts can be wired in
here later.
"""
pass
async def set_online_status(self, agent_id, chat_id, online=True):
"""Signal presence for the conversation.
A single ``typing`` action marks the run as online; ``cancel`` clears
it. Delegates to :meth:`send_typing`.
"""
await self.send_typing(agent_id, chat_id, typing=online)
# --- In-memory transcript ---
def _state(self, agent_id, chat_id):
key = (agent_id, chat_id)
state = self._transcripts.get(key)
if state is None:
state = {"transcript": deque(), "lock": asyncio.Lock()}
self._transcripts[key] = state
return state
def _trim(self, transcript):
"""Drop oldest messages until the deque fits the byte cap."""
while transcript and self._size(transcript) > TRANSCRIPT_BYTE_CAP:
transcript.popleft()
def _size(self, transcript):
return sum(
len(m["sender"].encode("utf-8")) + len(m["text"].encode("utf-8")) + 2
for m in transcript
)
def _render(self, transcript):
if not transcript:
return "(no messages)"
return "\n".join(f"{m['sender']}: {m['text']}" for m in transcript)
# --- Parse helpers ---
def _sender_name(self, user, chat):
name = " ".join(filter(None, [user.get("first_name"), user.get("last_name")]))
username = user.get("username")
if name and username:
return f"{name} (@{username})"
if name:
return name
if username:
return f"@{username}"
return chat.get("title") or "unknown"
# --- Tracking ---
def _track(self, task):
self._tasks.append(task)
def discard(t):
if t in self._tasks:
self._tasks.remove(t)
task.add_done_callback(discard)
return task
+106
View File
@@ -0,0 +1,106 @@
"""Web tools: online search and page fetching.
Both tools subclass :class:`~backend.src.tool.Tool` and rely on the base
class's automatic ``ToolSpec`` generation (docstring -> description,
``run`` signature -> parameters).
"""
import re
import urllib.parse
import urllib.request
from html import unescape
from tool import Tool
def _strip_html(html: str) -> str:
"""Light HTML filter: drop scripts/styles, strip tags, collapse whitespace."""
html = re.sub(
r"<(script|style|noscript)[^>]*>.*?</\1>",
" ",
html,
flags=re.DOTALL | re.IGNORECASE,
)
text = re.sub(r"<[^>]+>", " ", html)
text = unescape(text)
return re.sub(r"\s+", " ", text).strip()
class WebSearch(Tool):
"""Search the web with DuckDuckGo. Returns titles, URLs and snippets."""
ENDPOINT = "https://html.duckduckgo.com/html/"
USER_AGENT = (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/124.0 Safari/537.36"
)
# Result anchors look like:
# <a class="result__a" href="//duckduckgo.com/l/?uddg=...">Title</a>
_RESULT_RE = re.compile(
r'<a[^>]*class="[^"]*\bresult__a\b[^"]*"[^>]*href="(?P<href>[^"]+)"[^>]*>'
r"(?P<title>.*?)</a>",
re.DOTALL,
)
_SNIPPET_RE = re.compile(
r'<a[^>]*class="[^"]*\bresult__snippet\b[^"]*"[^>]*>(?P<snippet>.*?)</a>',
re.DOTALL,
)
def run(self, query: str, max_results: int = 5):
body = urllib.parse.urlencode({"q": query}).encode("utf-8")
req = urllib.request.Request(
self.ENDPOINT,
data=body,
method="POST",
headers={
"User-Agent": self.USER_AGENT,
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "text/html",
},
)
with urllib.request.urlopen(req, timeout=15) as resp:
page = resp.read().decode("utf-8", errors="replace")
snippets = [m.group("snippet") for m in self._SNIPPET_RE.finditer(page)]
results = []
for i, match in enumerate(self._RESULT_RE.finditer(page)):
if len(results) >= max_results:
break
results.append({
"title": self._clean(match.group("title")),
"url": self._unwrap(match.group("href")),
"snippet": self._clean(snippets[i]) if i < len(snippets) else "",
})
return results
@staticmethod
def _clean(value: str) -> str:
return unescape(re.sub(r"<[^>]+>", "", value)).strip()
@staticmethod
def _unwrap(href: str) -> str:
# DDG wraps result links as //duckduckgo.com/l/?uddg=<url-encoded-target>
if "uddg=" in href:
target = urllib.parse.parse_qs(urllib.parse.urlparse(href).query).get(
"uddg", [""]
)[0]
if target:
return target
if href.startswith("//"):
return "https:" + href
return href
class WebFetch(Tool):
"""Fetch a web page and return its text content, lightly filtered."""
def run(self, url: str):
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
with urllib.request.urlopen(req, timeout=15) as resp:
html = resp.read().decode("utf-8", errors="replace")
return _strip_html(html)
__all__ = ["WebSearch", "WebFetch"]
+297 -260
View File
@@ -4,70 +4,121 @@ import json
import os import os
import traceback import traceback
import random import random
import inspect
from stream import Stream from stream import Stream
from tool import format_tools_for_engine, build_tool_prompt_prefix, ToolSpec, ToolCall from tool import Tool, ToolSpec, ToolCall
#from llama_cpp import Llama #from llama_cpp import Llama
from openrouter import OpenRouter from openrouter import OpenRouter
VALID_ROLES = ['user', 'assistant', 'system', 'tool'] # TODO: Collect usage data
TEST_MOCK = False
VALID_ROLES = ['user', 'assistant', 'system', 'tool', 'reasoning', 'usage']
CONTEXT_WINDOW = 10_000 CONTEXT_WINDOW = 10_000
# TODO: Rename to ChatMessage. Add methods for constructing from instruct/openai/etc formatted objects. # Background inference tasks are kept referenced here so they aren't GC'd mid-flight.
class LlamaMessage: _background_tasks = set()
def __init__(self, role, content, reasoning=None):
def spawn(coro):
task = asyncio.create_task(coro)
_background_tasks.add(task)
task.add_done_callback(_background_tasks.discard)
return task
# These reflect "emitted messages" from any actor, individual turn parts, rather than entire turns.
# role acts as a type and a sender simultaneously, and content is dynamic
class Message:
def __init__(self, role, content):
if role not in VALID_ROLES: if role not in VALID_ROLES:
raise Exception("message role must be one of: " + ",".join(VALID_ROLES)) raise Exception("message role must be one of: " + ",".join(VALID_ROLES))
self.role = role self.role = role
self.content = content self.content = content
self.reasoning = reasoning
def to_dict(self): def to_dict(self):
"""Serialize to the dict format expected by llama-cpp / ollama.""" return {"role": self.role, "content": self.content}
d = {"role": self.role, "content": self.content}
if self.reasoning:
d["reasoning"] = self.reasoning
return d
def __repr__(self): def __repr__(self):
return f"LlamaMessage(role={self.role!r}, content={self.content!r}, reasoning={self.reasoning!r})" return f"Message(role={self.role!r}, content={self.content!r})"
class Models: def format_tools(tools):
llama_8b = "meta/meta-llama-3-8b-instruct" specs = []
llama_70b = "meta/meta-llama-3-70b-instruct" for t in tools:
qwen3_8b = "lucataco/qwen3-vl-8b-instruct:39e893666996acf464cff75688ad49ac95ef54e9f1c688fbc677330acc478e11" if isinstance(t, dict):
qwen3_235b = "qwen/qwen3-235b-a22b-instruct-2507" specs.append(t)
elif isinstance(t, ToolSpec):
specs.append(t.to_openai_dict())
elif isinstance(t, Tool):
specs.append(t.get_spec().to_openai_dict())
return specs
def format_openai(messages): def format_openai(messages):
formatted = [] formatted = []
last_assistant_message = None def last_assistant_message():
if formatted and formatted[-1]['role'] == 'assistant':
return formatted[-1]
else:
msg = {'role': 'assistant', 'content': ''}
formatted.append(msg)
return msg
# For debugging
_stream_count = 0
_badrole_count = 0
_badroles = set()
for msg in messages: for msg in messages:
content = msg.content
if isinstance(content, Stream):
_stream_count += 1
content = content.content
if msg.role == 'tool': if msg.role == 'tool':
if not last_assistant_message: last = last_assistant_message()
# Create one implicitly tool_calls = last.get('tool_calls')
last_assistant_message = {'role': 'assistant', 'content': ''}
tool_calls = last_assistant_message.get('tool_calls')
if tool_calls == None: if tool_calls == None:
tool_calls = [] tool_calls = []
last_assistant_message['tool_calls'] = tool_calls last['tool_calls'] = tool_calls
tc = content
if isinstance(tc, dict):
tc = ToolCall(
tool_name=tc['tool_name'],
arguments=tc['arguments'],
result=tc['result'],
id=tc['id']
)
# Skip non-resolved calls
if not tc.result: continue
# TODO: Fix call id if missing
tool_calls.append({ tool_calls.append({
'id': msg.content.id, 'id': tc.id,
'type': 'function', 'type': 'function',
'function': { 'function': {
'name': msg.content.tool_name, 'name': tc.tool_name,
'arguments': json.dumps(msg.content.arguments), 'arguments': json.dumps(tc.arguments),
}, },
}) })
formatted.append({ formatted.append({
'role': 'tool', 'role': 'tool',
'tool_call_id': msg.content.id, 'tool_call_id': tc.id,
'content': json.dumps(msg.content.result), 'content': json.dumps(tc.result),
}) })
else: elif msg.role == 'reasoning':
fmsg = {'role': msg.role, 'content': msg.content} last = last_assistant_message()
reasoning = last.get('reasoning', '')
last['reasoning'] = reasoning + content
elif msg.role in ['user', 'assistant', 'system']:
fmsg = {'role': msg.role, 'content': content}
formatted.append(fmsg) formatted.append(fmsg)
if msg.role == 'assistant': else:
last_assistant_message = fmsg _badrole_count += 1
_badroles.add(msg.role)
_warnings = []
if _stream_count: _warnings.append(f"{_stream_count} streams in message list")
if _badrole_count: _warnings.append(f"{_badrole_count} messages with bad roles {_badroles}")
if _warnings: print(f"[inference] WARNING: " + ", ".join(_warnings))
return formatted return formatted
@@ -81,9 +132,12 @@ def safe_queue(callback):
queue = asyncio.Queue() queue = asyncio.Queue()
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
async def read_loop(): async def read_loop():
nonlocal callback
while True: while True:
args = await queue.get() args = await queue.get()
await callback(*args) task = callback(*args)
if inspect.isawaitable(task):
await task
loop.create_task(read_loop()) loop.create_task(read_loop())
def send(*args): def send(*args):
@@ -116,10 +170,10 @@ def get_llama_cpp_model(app, model_file):
# Generate a conversation based on agent inputs # Generate a conversation based on agent inputs
def build_messages(system_prompt="", instruction=None, input=None, messages=None): def build_messages(system_prompt="", instruction=None, input=None, messages=None):
def normalize(message): def normalize(message):
if isinstance(message, LlamaMessage): if isinstance(message, Message):
return message return message
elif isinstance(message, dict): elif isinstance(message, dict):
return LlamaMessage(message['role'], message['content']) return Message(message['role'], message['content'])
else: else:
raise Exception("Unsupported message type: " + repr(type(message))) raise Exception("Unsupported message type: " + repr(type(message)))
@@ -139,9 +193,9 @@ def build_messages(system_prompt="", instruction=None, input=None, messages=None
# Add default messages to list # Add default messages to list
if system_prompt: if system_prompt:
messages.insert(0, LlamaMessage("system", system_prompt)) messages.insert(0, Message("system", system_prompt))
if prompt: if prompt:
messages.append(LlamaMessage("user", prompt)) messages.append(Message("user", prompt))
return messages return messages
@@ -151,231 +205,206 @@ async def chat_completion(app,
messages=None, messages=None,
tools=[], tools=[],
opts={}, opts={},
agent_run=None
): ):
# TESTING OVERRIDE
# return await mock_completion(app, agent_run)
db_model = (await app.get_model(model_id)).value db_model = (await app.get_model(model_id)).value
model_engine = db_model['engine'] model_engine = db_model['engine']
model_name = db_model['model'] model_name = db_model['model']
if model_engine == "llama-cpp": print(f"[inference] chat completion: {model_engine} '{model_name}', {len(messages)} messages, {len(tools)} tools")
result = await local_chat_completion(app, messages, model=model_name, tools=tools, opts=opts, agent_run=agent_run)
elif model_engine == "openrouter":
result = await openrouter_chat_completion(app, messages, model=model_name, tools=tools, opts=opts, agent_run=agent_run)
else:
raise Exception("Unsupported model: " + repr(model))
if agent_run: if TEST_MOCK:
return None generator = mock_completion(app)
return result elif model_engine == "llama-cpp":
generator = local_chat_completion(app, messages, model=model_name, tools=tools, opts=opts)
elif model_engine == "openrouter":
generator = openrouter_chat_completion(app, messages, model=model_name, tools=tools, opts=opts)
else:
raise Exception("Unsupported model engine: " + repr(model_engine))
async for message in generator:
yield message
print(f"[inference] chat completion end: {model_engine} '{model_name}'")
# llama.cpp # llama.cpp
async def local_chat_completion(app, messages, model, tools=[], opts={}, agent_run=None): async def local_chat_completion(app, messages, model, tools=[], opts={}):
if not agent_run: queue = asyncio.Queue()
raise Exception("agent_run required") loop = asyncio.get_running_loop()
emit = safe_queue(queue.put_nowait)
content_stream = None
async def message_loop(name, value=None):
nonlocal content_stream, agent_run
if name == 'output':
if content_stream == None:
content_stream = Stream(app)
agent_run.emit_event('output', content_stream)
content_stream.feed(value)
elif name == 'close':
if content_stream:
content_stream.close()
agent_run.emit_event('inference-end', {})
send_message = safe_queue(message_loop)
def worker():
llm = get_llama_cpp_model(app, model)
kwargs = {}
temp = opts.get("temperature")
if temp is not None:
kwargs['temperature'] = temp
repeat = opts.get("repetition_penalty")
if repeat is not None:
kwargs['repeat_penalty'] = repeat
# Format tools for llama-cpp (OpenAI-compatible function format)
formatted_tools = format_tools_for_engine(tools, "llama-cpp") if tools else None
if formatted_tools:
kwargs['tools'] = formatted_tools
kwargs['tool_choice'] = 'auto'
# With stream=True, returns an iterator that infers tokens on demand (openai compatible)
llm_output = llm.create_chat_completion(
messages=[msg.to_dict() for msg in messages],
stream=True,
**kwargs
)
for chunk in llm_output:
# Check for cancellation
if agent_run and agent_run.cancelled:
print(f"Cancelling local inference for run {agent_run.id}")
break
delta = chunk['choices'][0]['delta']
if 'content' in delta:
send_message('output', delta['content'])
# Handle tool_call deltas if the model emits them
if 'tool_calls' in delta:
# Forward tool call chunks as JSON strings for the consumer
send_message("tool-call", {
"id": tc.get("id"),
"function": tc.get("function"),
})
send_message('close')
thread = threading.Thread(target=worker)
if agent_run:
agent_run.thread = thread
thread.start()
if agent_run:
return None
return content_stream
async def openrouter_chat_completion(app, messages, model, tools=[], opts={}, agent_run=None):
if not agent_run:
raise Exception("agent_run required")
# TODO: Separate as InferenceRun class
content_stream = None
reasoning_stream = None
last_channel = None
def open_channel(channel):
nonlocal last_channel, content_stream, reasoning_stream
if channel != last_channel:
if content_stream: content_stream.close()
if reasoning_stream: reasoning_stream.close()
content_stream = None
reasoning_stream = None
last_channel = channel
async def message_loop(name, value=None):
nonlocal content_stream, reasoning_stream, agent_run
open_channel(name) # Hack: use the message as the channel name
if name == 'output':
if content_stream == None:
content_stream = Stream(app)
agent_run.emit_event('output', content_stream)
content_stream.feed(value)
if name == 'reasoning':
if reasoning_stream == None:
reasoning_stream = Stream(app)
agent_run.emit_event('reasoning', reasoning_stream)
reasoning_stream.feed(value)
if name == 'tool-call':
agent_run.emit_event('tool-call', value)
elif name == 'close':
if content_stream: content_stream.close()
if reasoning_stream: reasoning_stream.close()
agent_run.emit_event('inference-end', {})
emit = safe_queue(message_loop)
async def worker(): async def worker():
print("START OPENROUTER INFERENCE") stream = None
if not app.openrouter_client:
raise Exception("OPENROUTER_API_KEY environment variable is not set")
kwargs = {
'model': model,
'messages': format_openai(messages),
'stream': True,
# Options
'timeout_ms': 45_000,
'retries': 3,
}
# Pass tools in OpenAI-compatible format if provided
formatted_tools = format_tools_for_engine(tools, "openrouter") if tools else None
if formatted_tools:
kwargs['tools'] = formatted_tools
temp = agent_run.input.get("temperature")
if temp is not None:
kwargs['temperature'] = temp
reasoning = agent_run.input.get("reasoning_effort")
if reasoning is not None:
kwargs['reasoning'] = {"effort": reasoning}
stream_response = await app.openrouter_client.chat.send_async(**kwargs)
# OpenAI format streams tool calls in parts
# We could, but I won't assume any tool call is complete until the message is over
tool_calls = []
async for event in stream_response:
if agent_run and agent_run.cancelled:
stream_response.cancel()
break
if event.choices and len(event.choices) > 0:
delta = event.choices[0].delta
if delta.content:
emit('output', delta.content)
if delta.reasoning:
emit('reasoning', delta.reasoning)
if delta.tool_calls:
print("[openrouter] tool call:" + repr(delta.tool_calls))
for tc in delta.tool_calls:
if tc.index >= len(tool_calls):
tool_calls.append(ToolCall(tool_name="", arguments="", id=""))
tool_call = tool_calls[tc.index]
if tc.id:
tool_call.id += tc.id
if tc.function:
if tc.function.name:
tool_call.tool_name += tc.function.name
if tc.function.arguments:
tool_call.arguments += tc.function.arguments
else:
print("[openrouter] no event choices: " + repr(event))
for tc in tool_calls:
try:
arguments = json.loads(tc.arguments)
tc.arguments = arguments
emit('tool-call', tc)
except Exception:
print("[openrouter] Bad tool call arguments for " + repr(tc))
print(traceback.format_exc())
emit('close')
print("END OPENROUTER INFERENCE")
def handle_done(task):
try: try:
task.result() llm = get_llama_cpp_model(app, model)
except asyncio.CancelledError:
print("[openrouter] inference cancelled") kwargs = {}
except Exception as e:
print(f"[openrouter] Worker error: {e}") temp = opts.get("temperature")
if temp is not None:
kwargs['temperature'] = temp
repeat = opts.get("repetition_penalty")
if repeat is not None:
kwargs['repeat_penalty'] = repeat
# Format tools for llama-cpp (OpenAI-compatible function format)
formatted_tools = format_tools(tools) if tools else None
if formatted_tools:
kwargs['tools'] = formatted_tools
kwargs['tool_choice'] = 'auto'
# With stream=True, returns an iterator that infers tokens on demand (openai compatible)
iterator = await loop.run_in_executor(None, lambda: llm.create_chat_completion(
messages=[msg.to_dict() for msg in messages],
stream=True,
**kwargs
))
sentinel = object()
while True:
chunk = await loop.run_in_executor(None, next, iterator, sentinel)
if chunk is sentinel:
break
delta = chunk['choices'][0]['delta']
if 'content' in delta:
if stream is None:
stream = Stream(app)
emit(Message('assistant', stream))
stream.feed(delta['content'])
if 'tool_calls' in delta:
for tc in delta['tool_calls']:
emit(Message('tool', tc))
except Exception:
print(traceback.format_exc()) print(traceback.format_exc())
finally:
if stream: stream.close()
emit(None)
task = asyncio.create_task(worker()) spawn(worker())
task.add_done_callback(handle_done)
agent_run.task = task while True:
message = await queue.get()
if message is None:
break
yield message
async def mock_completion(app, agent_run=None): async def openrouter_chat_completion(app, messages, model, tools=[], opts={}):
if not app.openrouter_client:
raise Exception("OPENROUTER_API_KEY environment variable is not set")
queue = asyncio.Queue()
async def worker():
content_stream = None
reasoning_stream = None
last_channel = None
def open_channel(channel):
nonlocal last_channel, content_stream, reasoning_stream
if channel != last_channel:
if content_stream: content_stream.close()
if reasoning_stream: reasoning_stream.close()
content_stream = None
reasoning_stream = None
last_channel = channel
try:
kwargs = {
'model': model,
'messages': format_openai(messages),
'stream': True,
# Options
'timeout_ms': 45_000,
'retries': 3,
}
# Pass tools in OpenAI-compatible format if provided
formatted_tools = format_tools(tools) if tools else None
if formatted_tools:
kwargs['tools'] = formatted_tools
temp = opts.get("temperature")
if temp is not None:
kwargs['temperature'] = temp
reasoning = opts.get("reasoning_effort")
if reasoning is not None:
kwargs['reasoning'] = {"effort": reasoning}
stream_response = await app.openrouter_client.chat.send_async(**kwargs)
# OpenAI format streams tool calls in parts
# We could, but I won't assume any tool call is complete until the message is over
tool_calls = []
async for event in stream_response:
if event.choices and len(event.choices) > 0:
delta = event.choices[0].delta
if delta.reasoning:
open_channel('reasoning')
if reasoning_stream is None:
reasoning_stream = Stream(app)
queue.put_nowait(Message('reasoning', reasoning_stream))
reasoning_stream.feed(delta.reasoning)
if delta.content:
open_channel('output')
if content_stream is None:
content_stream = Stream(app)
queue.put_nowait(Message('assistant', content_stream))
content_stream.feed(delta.content)
if delta.tool_calls:
for tc in delta.tool_calls:
if tc.index >= len(tool_calls):
tool_calls.append(ToolCall(tool_name="", arguments="", id=""))
tool_call = tool_calls[tc.index]
if tc.id:
tool_call.id += tc.id
if tc.function:
if tc.function.name:
tool_call.tool_name += tc.function.name
if tc.function.arguments:
tool_call.arguments += tc.function.arguments
if event.usage:
usage = {}
for key in ['completion_tokens', 'prompt_tokens', 'total_tokens', 'cost']:
if hasattr(event.usage, key):
value = getattr(event.usage, key)
if value != None:
usage[key] = value
queue.put_nowait(Message('usage', usage))
if content_stream: content_stream.close()
if reasoning_stream: reasoning_stream.close()
for tc in tool_calls:
try:
tc.arguments = json.loads(tc.arguments)
queue.put_nowait(Message('tool', tc))
except Exception:
print("[openrouter] Bad tool call arguments for " + repr(tc))
print(traceback.format_exc())
except Exception:
print(traceback.format_exc())
finally:
queue.put_nowait(None)
spawn(worker())
while True:
message = await queue.get()
if message is None:
break
yield message
async def mock_completion(app):
VERBS = ['fix','scan','sync','deploy','parse','render','cache','trace', VERBS = ['fix','scan','sync','deploy','parse','render','cache','trace',
'merge','purge','index','probe','flush','bind','warm','drain'] 'merge','purge','index','probe','flush','bind','warm','drain']
OBJECTS = ['nodes','packets','tokens','frames','shards','clusters', OBJECTS = ['nodes','packets','tokens','frames','shards','clusters',
@@ -383,20 +412,28 @@ async def mock_completion(app, agent_run=None):
'handles','slots','cursors','segments'] 'handles','slots','cursors','segments']
DELAY = 0.3 DELAY = 0.3
content_stream = Stream(app) queue = asyncio.Queue()
if agent_run: emit = safe_queue(queue.put_nowait)
agent_run.emit_event('output', content_stream)
async def content_task (): async def worker():
await asyncio.sleep(DELAY) stream = Stream(app)
content_stream.feed(random.choice(VERBS)) emit(Message('assistant', stream))
await asyncio.sleep(DELAY) try:
content_stream.feed(f" {random.randint(1, 99)} ") await asyncio.sleep(DELAY)
await asyncio.sleep(DELAY) stream.feed(random.choice(VERBS))
content_stream.feed(random.choice(VERBS)) await asyncio.sleep(DELAY)
await asyncio.sleep(DELAY) stream.feed(f" {random.randint(1, 99)} ")
content_stream.close() await asyncio.sleep(DELAY)
agent_run.emit_event('inference-end', {}) stream.feed(random.choice(VERBS))
await asyncio.sleep(DELAY)
finally:
stream.close()
emit(None)
asyncio.create_task(content_task()) spawn(worker())
if not agent_run: return content_stream
while True:
message = await queue.get()
if message is None:
break
yield message
+100 -67
View File
@@ -1,8 +1,7 @@
import json, time, traceback, asyncio, uuid import json, time, traceback, asyncio, uuid
from quart import Response, request from quart import Response, request
from quart_auth import login_required, current_user from quart_auth import login_required, current_user
from tool import ToolSpec, ToolParameter from tool import ToolSpec, ToolParameter, ToolCall
from agent import AgentRun
from utils import encode_json from utils import encode_json
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -49,66 +48,72 @@ def route_tool_call(call, tool_names):
return deferred return deferred
async def execute_deferred(function_call): async def execute_deferred(tc):
"""Execute a deferred (native) tool call. """Execute a deferred (native) tool call.
The output must be stored on the call itself (``function_call.result``). The output must be stored on the call itself (``tc.result``).
TODO: resolve the ToolSpec for ``function_call.tool_name`` from the TODO: resolve the ToolSpec for ``tc.tool_name`` from the
agent's native tools and dispatch through the existing tool agent's native tools and dispatch through the existing tool
infrastructure (ToolSpec.execute_async), recording the result or error on infrastructure (ToolSpec.execute_async), recording the result or error on
the call. Deferred execution needs more design work; stubbed for now. the call. Deferred execution needs more design work; stubbed for now.
""" """
pass tc.result = await tc.tool.execute_async(**tc.arguments)
def attach_run_handlers(run, tool_names): def messages_to_queue(generator, tool_names):
"""Wire the run's output events into a single queue. """Drive a prompt generator into a single queue.
The queue receives ('content'|'reasoning'|'tool_call'|'deferred_call', data) The queue receives ('content'|'reasoning'|'tool_call'|'deferred_call', data)
tuples and terminates with ('eof', None). Tool calls are routed (client vs tuples and terminates with ('eof', None). Tool calls are routed (client vs
native/deferred) here so both response modes share the same logic. native/deferred) here so both response modes share the same logic. The
returned ``state`` dict tracks whether any tool call was observed, which
drives ``finish_reason``.
""" """
queue = asyncio.Queue() queue = asyncio.Queue()
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
state = {'tool_call': False, 'error': None}
def on_output(stream): async def on_content(chunk):
async def on_chunk(chunk): await queue.put(('content', chunk))
await queue.put(('content', chunk)) async def on_reasoning(chunk):
async def on_eof(): await queue.put(('reasoning', chunk))
# on_done doesn't work? def on_eof(): pass
async def drain():
try:
async for msg in generator:
# assistant and reasoning are assumed to have a Stream
# tool is assumed to have a ToolCall
if msg.role == 'assistant':
msg.content.listen(on_content, on_eof)
elif msg.role == 'reasoning':
msg.content.listen(on_reasoning, on_eof)
elif msg.role == 'tool':
deferred = route_tool_call(msg.content, tool_names)
msg_type = 'deferred_call' if deferred else 'tool_call'
state['tool_call'] = True
await queue.put((msg_type, msg.content))
elif msg.role == 'usage':
await queue.put(('usage', msg.content))
except Exception as e:
# Surface the failure to the consumer via the eof drain.
state['error'] = e
finally:
await queue.put(('eof', None)) await queue.put(('eof', None))
stream.listen(on_chunk, on_eof)
def on_reasoning(stream): loop.create_task(drain())
async def on_chunk(chunk): return queue, state
await queue.put(('reasoning', chunk))
async def on_eof(): pass
stream.listen(on_chunk, on_eof)
def on_tool_call(tool_call):
deferred = route_tool_call(tool_call, tool_names)
msg_type = 'deferred_call' if deferred else 'tool_call'
loop.create_task(queue.put((msg_type, tool_call)))
def on_done(output):
loop.create_task(queue.put(('eof', None)))
run.on('output', on_output)
run.on('reasoning', on_reasoning)
run.on('tool-call', on_tool_call)
run.on('done', on_done)
return queue
def format_chunk(agent_id, delta, finish_reason=None): def format_chunk(agent_id, delta, finish_reason=None, usage=None):
"""Build one OpenAI-format streaming chunk body.""" """Build one OpenAI-format streaming chunk body."""
return { return {
"id": "chatcmpl-" + str(time.time()), "id": "chatcmpl-" + str(time.time()),
"object": "chat.completion.chunk", "object": "chat.completion.chunk",
"created": int(time.time()), "created": int(time.time()),
"model": agent_id, "model": agent_id,
"usage": usage,
"choices": [{"delta": delta, "index": 0, "finish_reason": finish_reason}] "choices": [{"delta": delta, "index": 0, "finish_reason": finish_reason}]
} }
@@ -124,8 +129,8 @@ def register(server, app):
# Extract required fields # Extract required fields
agent_id = data.get("model") agent_id = data.get("model")
openai_messages = data.get("messages", []) openai_messages = data.get("messages") or []
openai_tools = data.get("tools", []) openai_tools = data.get("tools") or []
stream_mode = data.get("stream", False) stream_mode = data.get("stream", False)
if not agent_id or not openai_messages: if not agent_id or not openai_messages:
@@ -164,20 +169,36 @@ def register(server, app):
# message carries the output. # message carries the output.
call.result = content call.result = content
entry['resolved'] = True entry['resolved'] = True
messages.append({'role': 'tool', 'content': call.result, 'tool_call_id': call.id}) messages.append({'role': 'tool', 'content': call})
continue continue
if role == 'assistant' and msg.get('tool_calls'): if role == 'assistant' and msg.get('tool_calls'):
# Rebuild the assistant's tool call turn from the cache so # Rebuild the assistant's tool call turn from the request.
# unknown or expired calls are silently dropped and the agent # Calls already in the cache (persistent conversation) are
# receives the original call objects, which carry their own # reused as-is. Client-declared function calls carry their
# output once resolved. # full payload on the wire, so they are reconstructed from
# the message when the cache is gone. Server-originated
# (deferred) calls only ever sent an id + type, so they can
# only be recovered from the cache and are dropped otherwise.
calls = [] calls = []
for tc in msg['tool_calls']: for tc in msg['tool_calls']:
entry = tool_call_cache.get(tc.get('id', '')) call_id = tc.get('id', '')
if entry is None: entry = tool_call_cache.get(call_id)
if entry is not None:
calls.append(entry['call'])
continue continue
calls.append(entry['call'])
func = tc.get('function')
if tc.get('type') == 'function' and func:
call = ToolCall(
id=call_id,
tool_name=func.get('name', ''),
arguments=json.loads(func.get('arguments') or '{}'),
)
# Seeding the cache lets the following role='tool'
# message resolve the call and attach its result.
cache_tool_call(call, deferred=False)
calls.append(call)
messages.append({ messages.append({
'role': 'assistant', 'role': 'assistant',
'content': content if type(content) == str else None, 'content': content if type(content) == str else None,
@@ -226,30 +247,30 @@ def register(server, app):
# tool and gets deferred. # tool and gets deferred.
tool_names = {spec.name for spec in tools} tool_names = {spec.name for spec in tools}
# --- Get agent control and create AgentRun directly --- # --- Get agent control and drive the run through the shared prompt generator ---
control = await app.get_agent_control(agent_id) control = await app.get_agent_control(agent_id)
run = AgentRun.create( # The OpenAI inputs ride as kwargs so they land in run.input, which
app, # ExpertAgent.run reads ("tools", "tool_policy", "messages").
agent_id=agent_id, generator = control.prompt(
user_id=user_id, None,
input={ input=None,
"messages": messages, messages=messages,
"tools": tools, tools=tools,
# Never execute tools inside the run: emit them instead so # Never execute tools inside the run: emit them instead so
# the handler can send client tools back and defer native # the handler can send client tools back and defer native
# ones. # ones.
"defer_tool_execution": True, tool_policy="ignore",
},
) )
print("run input: " + repr(run.input))
queue = attach_run_handlers(run, tool_names) queue, state = messages_to_queue(generator, tool_names)
await control.run(run)
usage = {'completion_tokens': 0, 'prompt_tokens': 0, 'total_tokens': 0, 'cost': 0}
# --- Streaming --- # --- Streaming ---
if stream_mode: if stream_mode:
async def generate(): async def generate():
nonlocal usage
try: try:
tool_call_count = 0 tool_call_count = 0
while True: while True:
@@ -281,19 +302,26 @@ def register(server, app):
"type": "tool", "type": "tool",
}]} }]}
elif msg_type == 'usage':
usage = data
elif msg_type == 'eof': elif msg_type == 'eof':
if state['error']:
raise state['error']
# Determine finish_reason — was there a tool call? # Determine finish_reason — was there a tool call?
finish_reason = "tool_calls" if any( finish_reason = "tool_calls" if state['tool_call'] else "stop"
e['name'] == 'tool_call' for e in run.events
) else "stop"
yield f"data: {json.dumps(format_chunk(agent_id, {}, finish_reason))}\n\n" yield f"data: {json.dumps(format_chunk(agent_id, {}, finish_reason))}\n\n"
break break
if delta:
yield f"data: {json.dumps(format_chunk(agent_id, delta))}\n\n"
else: else:
raise Exception(f"Unrecognized msg_type: {msg_type}. {repr(data)}") raise Exception(f"Unrecognized msg_type: {msg_type}. {repr(data)}")
if delta:
yield f"data: {json.dumps(format_chunk(agent_id, delta))}\n\n"
# Usage
yield f"data: {json.dumps(format_chunk(agent_id, {}, finish_reason, usage))}\n\n"
except Exception as e: except Exception as e:
print("[openai] ERROR: " + str(e)) print("[openai] ERROR: " + str(e))
print(traceback.format_exc()) print(traceback.format_exc())
@@ -309,6 +337,8 @@ def register(server, app):
while True: while True:
msg_type, data = await queue.get() msg_type, data = await queue.get()
if msg_type == 'eof': if msg_type == 'eof':
if state['error']:
raise state['error']
break break
elif msg_type == 'content': elif msg_type == 'content':
content += data content += data
@@ -327,6 +357,8 @@ def register(server, app):
# Deferred calls stay opaque to the client: only the # Deferred calls stay opaque to the client: only the
# call id and type go on the wire. # call id and type go on the wire.
tool_calls.append({"id": data.id, "type": "tool"}) tool_calls.append({"id": data.id, "type": "tool"})
elif msg_type == 'usage':
usage = data
else: else:
raise Exception(f"Unrecognized msg_type: {msg_type}. {repr(data)}") raise Exception(f"Unrecognized msg_type: {msg_type}. {repr(data)}")
except Exception as e: except Exception as e:
@@ -346,6 +378,7 @@ def register(server, app):
"object": "chat.completion", "object": "chat.completion",
"created": int(time.time()), "created": int(time.time()),
"model": agent_id, "model": agent_id,
"usage": usage,
"choices": [{ "choices": [{
"message": message, "message": message,
"finish_reason": finish_reason, "finish_reason": finish_reason,
+36
View File
@@ -0,0 +1,36 @@
"""Telegram webhook endpoint.
Telegram delivers updates to ``POST /channel/telegram/<agent_id>`` once a
webhook is registered for that agent's binding (see ``channels.py``). The
route is always served; it is only *useful* when ``HUB_HOST`` is set and
the webhook has been reconciled, but that is a startup concern, not a
routing one.
Mirrors ``server/openai.py``'s ``register(server, app)`` pattern.
"""
from quart import request
def register(server, app):
@server.post('/channel/telegram/<agent_id>')
async def telegram_webhook(agent_id):
try:
agent_id = int(agent_id)
except (TypeError, ValueError):
return "invalid agent id", 404
binding = await app.channels.get_binding(agent_id)
if binding is None:
return "no telegram channel for this agent", 404
secret = request.headers.get('X-Telegram-Bot-Api-Secret-Token')
expected = (binding.value or {}).get('secret')
if expected and secret != expected:
return "forbidden", 403
payload = await request.get_json(silent=True) or {}
# Never block the response on the run: Telegram retries on timeouts.
app.channels.dispatch(agent_id, 'telegram', payload)
return {"ok": True}
+3 -1
View File
@@ -2,6 +2,7 @@ from quart import Quart, request, send_from_directory, send_file, current_app
from quart_auth import QuartAuth, AuthUser, login_required, login_user, current_user from quart_auth import QuartAuth, AuthUser, login_required, login_user, current_user
from server.websocket import register as register_websocket from server.websocket import register as register_websocket
from server.openai import register as register_openai from server.openai import register as register_openai
from server.telegram import register as register_telegram
import database as db import database as db
import drives import drives
@@ -175,5 +176,6 @@ async def start_server(app, port, shutdown_event):
register_websocket(server, app, auth_manager) register_websocket(server, app, auth_manager)
register_openai(server, app) register_openai(server, app)
register_telegram(server, app)
await server.run_task(debug=True, shutdown_trigger=shutdown_event.wait, port=port) await server.run_task(debug=True, shutdown_trigger=shutdown_event.wait, host="0.0.0.0", port=port)
+300 -260
View File
@@ -1,7 +1,6 @@
import json, asyncio import json, asyncio, traceback
from quart import websocket from quart import websocket
from utils import encode_json from utils import encode_json
from stream import Stream # isinstance check in send-agent-prompt
def register(server, app, auth_manager): def register(server, app, auth_manager):
@server.websocket('/ws') @server.websocket('/ws')
@@ -40,10 +39,14 @@ def register(server, app, auth_manager):
send_queue = asyncio.Queue() send_queue = asyncio.Queue()
async def sender_impl(): async def sender_impl():
# This thread MUST NOT crash
nonlocal send_queue nonlocal send_queue
while True: while True:
msg = await send_queue.get() try:
await websocket.send(encode_json(msg)) msg = await send_queue.get()
await websocket.send(encode_json(msg))
except Exception as err:
print(traceback.format_exc())
async def send(msg): async def send(msg):
await send_queue.put(msg) await send_queue.put(msg)
@@ -65,263 +68,10 @@ def register(server, app, auth_manager):
stop_listening = app.on_event(on_event) stop_listening = app.on_event(on_event)
while True: while True:
req = json.loads(await websocket.receive()) asyncio.create_task(handle_request(
if not 'action' in req: app, user_id, session_connections, send, await websocket.receive()
await send({'error': 'no action'}) ))
continue
action = req['action']
body = req.get('body', {})
nonce = req.get('nonce', None)
async def agent_id_from_body (allow_none=False):
if 'agentId' in body:
return body['agentId']
elif 'agentname' in body:
return await app.agent_id_from_name(body['agentname'])
elif allow_none:
return None
else:
raise Exception("field 'agentId' or 'agentname' required")
if action == "ping":
await send({'message': "pong"})
elif action == "create-agent":
name = body['name']
agent_id = await app.create_agent(
user_id=user_id,
name=name,
agentname=body.get('agentname'),
description=body.get('description'),
picture=body.get('picture'),
kind=body['kind'],
data=body.get('data'),
temporary=body.get('temporary'),
)
await send({'message': "agent created", 'agentId': agent_id, 'nonce': nonce})
elif action == "update-agent":
agent_id = await agent_id_from_body()
await app.update_agent(
agent_id=agent_id,
name=body.get('name'),
agentname=body.get('agentname'),
description=body.get('description'),
picture=body.get('picture'),
data=body.get('data', {})
)
await send({'message': "agent updated", 'agentId': agent_id, 'nonce': nonce})
elif action == "get-models":
await send({
'models': [{"id": model.id, "name": model.name, "value": model.value, "kind": model.kind} for model in await app.get_models()],
'nonce': nonce
})
elif action == "save-model":
model = await app.save_model(
model_id=body.get("modelId"),
name=body.get("name"),
kind=body.get("kind"),
value=body.get("value"),
)
if model:
await send({'message': "model saved", 'modelId': model.id, 'nonce': nonce})
elif "modelId" in body:
await send({'message': "model deleted", 'modelId': body["modelId"], 'nonce': nonce})
elif action == "get-agent-config-fields":
await send({'fields': await app.get_agent_config_fields(await agent_id_from_body()), 'nonce': nonce})
elif action == "get-agent-kinds":
await send({'message': "agents retrieved", 'kinds': app.get_agent_kinds(), 'nonce': nonce})
elif action == "get-agent-kind-info":
await send({'message': "agent kind retrieved", 'info': app.get_agent_kind_info(body['kind']), 'nonce': nonce})
elif action == "get-agents":
await send({'message': "agents retrieved", 'agents': await app.get_agents(user_id), 'nonce': nonce})
elif action == "get-agent-resources":
values = await app.get_agent_resources(await agent_id_from_body(), body.get('kind'), body.get('key'))
await send({'resources': [{'kind': r.kind, 'key': r.key, 'value': r.value} for r in values], 'nonce': nonce})
elif action == "save-agent-resource":
await app.save_agent_resource(await agent_id_from_body(), body['kind'], body['key'], body['value'])
await send({'message': "resource saved", 'nonce': nonce})
elif action == "get-agent-runs":
await send({
'runs': await app.get_agent_runs(agent_id=await agent_id_from_body(allow_none=True), user_id=user_id),
'nonce': nonce
})
elif action == "cancel-agent-run":
await app.cancel_agent_run(body['runId'])
elif action == "remote-list":
await send({'remotes': app.listRemotes(user_id), 'nonce': nonce})
elif action == "remote-connect":
# Connect a remote served by THIS session's channel.
# Expected failures (duplicate name) are replied, not
# raised, so the session stays alive.
try:
connection = app.connectRemote(user_id, body['remotename'], body.get('functions', []), send)
session_connections[body['remotename']] = connection
await send({'message': "remote connected", 'remotename': body['remotename'], 'nonce': nonce})
except Exception as e:
await send({'error': str(e), 'nonce': nonce})
elif action == "remote-disconnect":
# Manual disconnect, allowed from any session of the user
connection = app.disconnectRemote(user_id, body['remotename'])
session_connections.pop(body['remotename'], None)
if connection is None:
await send({'error': "no connected remote named '" + str(body['remotename']) + "'", 'nonce': nonce})
else:
await send({'message': "remote disconnected", 'remotename': body['remotename'], 'nonce': nonce})
elif action == "remote-call":
# Invoke a function of one of the user's connected remotes.
# Expected failures (unknown remote/function, timeout,
# remote-side error) are replied, not raised.
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.
# Fields live at the message top level, not in 'body'.
# 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.
app.resolve_remote_call(body['requestId'], body.get('result'), error=body.get('error'))
elif action == "create-chat":
chat_id = await app.create_chat(
user_id=user_id,
name=body['name'],
description=body.get('description'),
picture=body.get('picture'),
members=body.get('members', []),
temporary=body.get('temporary'),
)
await send({'message': "chat created", 'chatId': chat_id, 'nonce': nonce})
elif action == "get-chats":
chats_data = await app.get_chats(user_id)
await send({'message': "chats retrieved", 'chats': chats_data, 'nonce': nonce})
elif action == "delete-chat":
chat_id = await app.delete_chat(
chat_id=body['chatId']
)
await send({'message': "chat deleted", 'chatId': chat_id, 'nonce': nonce})
elif action == "get-chat-messages":
messages_data = await app.get_chat_messages(
body['chatId'],
earliest_index=body.get('earliestIndex', 0)
)
await send({'message': "chat messages retrieved", 'messages': messages_data, 'nonce': nonce})
elif action == "get-chat-threads":
await send({'message': "threads retrieved", 'nonce': nonce,
'threads': await app.get_chat_threads(body['chatId'])
})
elif action == "get-chat-thread-heads":
messages_data = await app.get_chat_thread_heads(
body['chatId'],
limit_message_id=body.get('limitMessageId', None)
)
await send({'message': "chat messages retrieved", 'messages': messages_data, 'nonce': nonce})
elif action == "get-thread-messages":
messages_data = await app.get_thread_messages(
body['chatId'],
body['threadId'],
limit_message_id=body.get('limitMessageId', None)
)
await send({'message': "chat messages retrieved", 'messages': messages_data, 'nonce': nonce})
elif action == "send-chat-message":
message = await app.send_chat_message(
chat_id=body['chatId'],
thread_id=body.get('threadId'),
content=body['content'],
kind=body.get('kind'),
subkind=body.get('subkind'),
format=body.get('format'),
)
await send({'message': "message sent", 'messageId': message['id'], 'nonce': nonce})
elif action == "update-chat-message":
message = await app.update_chat_message(
message_id=body['messageId'],
content=body['content'],
kind=body.get('kind'),
subkind=body.get('subkind'),
format=body.get('format'),
)
await send({'message': "message sent", 'messageId': body['messageId'], 'nonce': nonce})
elif action == "create-chat-thread":
thread_id = await app.create_chat_thread(
chat_id=body['chatId'],
name=body.get('name'),
)
await send({'message': "chat thread created", 'threadId': thread_id, 'nonce': nonce})
elif action == "fork-chat-thread":
thread_id = await app.fork_chat_thread(
thread_id=body['threadId'],
message_id=body.get('messageId'),
)
await send({'message': "chat thread created", 'threadId': thread_id, 'nonce': nonce})
elif action == "send-agent-prompt":
if 'instruction' not in body and 'input' not in body:
await send({'error': "instruction or input required"})
continue
response = await app.send_agent_prompt(
await agent_id_from_body(),
body.get('instruction'),
body.get('input'),
body.get('options', {})
)
if isinstance(response, Stream):
if body.get('stream'):
response = {"streamId": response.id}
else:
response = await response.content_wait()
await send({'message': "prompt sent", 'response': response, 'nonce': nonce})
elif action == "read-stream":
stream_id = body['streamId']
print("Read Stream" + str(stream_id))
stream = app.streams.get(stream_id)
if stream is None:
await send({'error': 'bad stream id: ' + str(stream_id), 'nonce': nonce})
else:
async def chunk_fn(chunk):
await send({'data': chunk, 'eof': False, 'nonce': nonce})
async def eof_fn():
await send({'eof': True, 'fullData': stream.content, 'nonce': nonce})
stream.listen(chunk_fn, eof_fn)
else:
await send({'error': 'unknown action: ' + action, 'nonce': nonce})
finally: finally:
print("User logged out:", user_id) print("User logged out:", user_id)
sender_task.cancel() sender_task.cancel()
@@ -337,3 +87,293 @@ def register(server, app, auth_manager):
for remotename in list(session_connections): for remotename in list(session_connections):
app.disconnectRemote(user_id, remotename) app.disconnectRemote(user_id, remotename)
app.remove_user_connection(user_id, send_queue) app.remove_user_connection(user_id, send_queue)
# Error handling
async def handle_request(app, user_id, session_connections, send, req):
try:
req = json.loads(req)
await process_request(app, user_id, session_connections, send, req)
except Exception as err:
action = req.get('action') if isinstance(req, dict) else None
nonce = req.get('nonce') if isinstance(req, dict) else None
# Maintainers
print(f"[ws] req error @{user_id} {action!r}: {err!r}")
print(traceback.format_exc())
# Client — nonce makes this routable to the caller's promise/callback
try:
await send({'error': str(err), 'nonce': nonce})
except Exception:
print(traceback.format_exc())
# Actual processing
async def process_request(app, user_id, session_connections, send, req):
if 'action' not in req:
await send({'error': 'no action'})
return
action = req['action']
body = req.get('body', {})
nonce = req.get('nonce', None)
print(f"[ws] req @{user_id} {action}")
async def agent_id_from_body (allow_none=False):
if 'agentId' in body:
return body['agentId']
elif 'agentname' in body:
return await app.agent_id_from_name(body['agentname'])
elif allow_none:
return None
else:
raise Exception("field 'agentId' or 'agentname' required")
if action == "ping":
await send({'message': "pong", 'nonce': nonce})
elif action == "create-agent":
name = body['name']
agent_id = await app.create_agent(
user_id=user_id,
name=name,
agentname=body.get('agentname'),
description=body.get('description'),
picture=body.get('picture'),
kind=body['kind'],
data=body.get('data'),
temporary=body.get('temporary'),
)
await send({'message': "agent created", 'agentId': agent_id, 'nonce': nonce})
elif action == "update-agent":
agent_id = await agent_id_from_body()
await app.update_agent(
agent_id=agent_id,
name=body.get('name'),
agentname=body.get('agentname'),
description=body.get('description'),
picture=body.get('picture'),
data=body.get('data', {})
)
await send({'message': "agent updated", 'agentId': agent_id, 'nonce': nonce})
elif action == "get-models":
await send({
'models': [{"id": model.id, "name": model.name, "value": model.value, "kind": model.kind} for model in await app.get_models()],
'nonce': nonce
})
elif action == "save-model":
model = await app.save_model(
model_id=body.get("modelId"),
name=body.get("name"),
kind=body.get("kind"),
value=body.get("value"),
)
if model:
await send({'message': "model saved", 'modelId': model.id, 'nonce': nonce})
elif "modelId" in body:
await send({'message': "model deleted", 'modelId': body["modelId"], 'nonce': nonce})
elif action == "get-agent-config-fields":
await send({'fields': await app.get_agent_config_fields(await agent_id_from_body()), 'nonce': nonce})
elif action == "get-agent-kinds":
await send({'message': "agents retrieved", 'kinds': app.get_agent_kinds(), 'nonce': nonce})
elif action == "get-agent-kind-info":
await send({'message': "agent kind retrieved", 'info': app.get_agent_kind_info(body['kind']), 'nonce': nonce})
elif action == "get-agents":
await send({'message': "agents retrieved", 'agents': await app.get_agents(user_id), 'nonce': nonce})
elif action == "get-agent-resources":
values = await app.get_agent_resources(await agent_id_from_body(), body.get('kind'), body.get('key'))
await send({'resources': [{'kind': r.kind, 'key': r.key, 'value': r.value} for r in values], 'nonce': nonce})
elif action == "save-agent-resource":
await app.save_agent_resource(await agent_id_from_body(), body['kind'], body['key'], body['value'])
await send({'message': "resource saved", 'nonce': nonce})
elif action == "delete-agent-resource":
await app.delete_agent_resource(await agent_id_from_body(), body['kind'], body['key'])
await send({'message': "resource deleted", 'nonce': nonce})
elif action == "get-agent-runs":
await send({
'runs': await app.get_agent_runs(agent_id=await agent_id_from_body(allow_none=True), user_id=user_id),
'nonce': nonce
})
elif action == "cancel-agent-run":
await app.cancel_agent_run(body['runId'])
elif action == "remote-list":
await send({'remotes': app.listRemotes(user_id), 'nonce': nonce})
elif action == "remote-connect":
# Connect a remote served by THIS session's channel.
# Expected failures (duplicate name) are replied, not
# raised, so the session stays alive.
try:
connection = app.connectRemote(user_id, body['remotename'], body.get('functions', []), send)
session_connections[body['remotename']] = connection
await send({'message': "remote connected", 'remotename': body['remotename'], 'nonce': nonce})
except Exception as e:
await send({'error': str(e), 'nonce': nonce})
elif action == "remote-disconnect":
# Manual disconnect, allowed from any session of the user
connection = app.disconnectRemote(user_id, body['remotename'])
session_connections.pop(body['remotename'], None)
if connection is None:
await send({'error': "no connected remote named '" + str(body['remotename']) + "'", 'nonce': nonce})
else:
await send({'message': "remote disconnected", 'remotename': body['remotename'], 'nonce': nonce})
elif action == "remote-call":
# Invoke a function of one of the user's connected remotes.
# Expected failures (unknown remote/function, timeout,
# remote-side error) are replied, not raised.
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.
# Fields live at the message top level, not in 'body'.
# 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.
app.resolve_remote_call(body['requestId'], body.get('result'), error=body.get('error'))
elif action == "create-chat":
chat_id = await app.create_chat(
user_id=user_id,
name=body['name'],
description=body.get('description'),
picture=body.get('picture'),
members=body.get('members', []),
temporary=body.get('temporary'),
)
await send({'message': "chat created", 'chatId': chat_id, 'nonce': nonce})
elif action == "get-chats":
chats_data = await app.get_chats(user_id)
await send({'message': "chats retrieved", 'chats': chats_data, 'nonce': nonce})
elif action == "delete-chat":
chat_id = await app.delete_chat(
chat_id=body['chatId']
)
await send({'message': "chat deleted", 'chatId': chat_id, 'nonce': nonce})
elif action == "get-chat-messages":
messages_data = await app.get_chat_messages(
body['chatId'],
earliest_index=body.get('earliestIndex', 0)
)
await send({'message': "chat messages retrieved", 'messages': messages_data, 'nonce': nonce})
elif action == "get-chat-threads":
await send({'message': "threads retrieved", 'nonce': nonce,
'threads': await app.get_chat_threads(body['chatId'])
})
elif action == "get-chat-thread-heads":
messages_data = await app.get_chat_thread_heads(
body['chatId'],
limit_message_id=body.get('limitMessageId', None)
)
await send({'message': "chat messages retrieved", 'messages': messages_data, 'nonce': nonce})
elif action == "get-thread-messages":
messages_data = await app.get_thread_messages(
body['chatId'],
body['threadId'],
limit_message_id=body.get('limitMessageId', None)
)
await send({'message': "chat messages retrieved", 'messages': messages_data, 'nonce': nonce})
elif action == "send-chat-message":
message = await app.send_chat_message(
chat_id=body['chatId'],
thread_id=body.get('threadId'),
content=body['content'],
kind=body.get('kind'),
subkind=body.get('subkind'),
format=body.get('format'),
)
await send({'message': "message sent", 'messageId': message['id'], 'nonce': nonce})
elif action == "update-chat-message":
message = await app.update_chat_message(
message_id=body['messageId'],
content=body.get('content'),
kind=body.get('kind'),
subkind=body.get('subkind'),
format=body.get('format'),
)
await send({'message': "message sent", 'messageId': body['messageId'], 'nonce': nonce})
elif action == "create-chat-thread":
thread_id = await app.create_chat_thread(
chat_id=body['chatId'],
name=body.get('name'),
)
await send({'message': "chat thread created", 'threadId': thread_id, 'nonce': nonce})
elif action == "fork-chat-thread":
thread_id = await app.fork_chat_thread(
thread_id=body['threadId'],
message_id=body.get('messageId'),
)
await send({'message': "chat thread created", 'threadId': thread_id, 'nonce': nonce})
elif action == "prompt-agent":
if 'instruction' not in body and 'input' not in body:
await send({'error': "instruction or input required", 'nonce': nonce})
return
# Multiple-result action, same shape as read-stream: every
# generated message is its own packet carrying the request
# nonce, so the client callback runs once per message. The
# terminal eof packet resolves the action. Streams referenced
# by open assistant/reasoning messages are read by the
# client via the existing read-stream action.
generator = app.prompt_agent(
await agent_id_from_body(),
body.get('instruction'),
body.get('input'),
body.get('options', {})
)
try:
async for msg in generator:
await send({'message': 'agent-message', 'data': msg, 'nonce': nonce})
finally:
# Disconnect / cancellation point: closes the run task.
await generator.aclose()
await send({'eof': True, 'nonce': nonce})
elif action == "read-stream":
stream_id = body['streamId']
print("Read Stream" + str(stream_id))
stream = app.streams.get(stream_id)
if stream is None:
await send({'error': 'bad stream id: ' + str(stream_id), 'nonce': nonce})
else:
async def chunk_fn(chunk):
await send({'data': chunk, 'eof': False, 'nonce': nonce})
async def eof_fn():
await send({'eof': True, 'fullData': stream.content, 'nonce': nonce})
stream.listen(chunk_fn, eof_fn)
else:
await send({'error': 'unknown action: ' + action, 'nonce': nonce})
+3 -10
View File
@@ -149,7 +149,7 @@ class Tool:
@classmethod @classmethod
def tool_name(cls): def tool_name(cls):
return ''.join(['_'+c.lower() if c.isupper() else c for c in cls.__name__]).lstrip('_') return ''.join(['-'+c.lower() if c.isupper() else c for c in cls.__name__]).lstrip('-')
def _generate_spec(self): def _generate_spec(self):
@@ -218,24 +218,17 @@ class ToolCall:
is_error: Whether the result represents an error. is_error: Whether the result represents an error.
""" """
def __init__(self, tool_name, arguments={}, id=None): def __init__(self, tool_name, arguments={}, id=None, result=None):
self.id = id or "" self.id = id or ""
self.tool_name = tool_name self.tool_name = tool_name
self.arguments = arguments self.arguments = arguments
self.result = None self.result = result
def __repr__(self): def __repr__(self):
id_prefix = self.id + ', ' if self.id else '' id_prefix = self.id + ', ' if self.id else ''
return f"ToolCall({id_prefix}tool={self.tool_name!r}, args={self.arguments!r})" return f"ToolCall({id_prefix}tool={self.tool_name!r}, args={self.arguments!r})"
def call_message(self):
raise Exception("TODO: Llama message for tool call")
def result_message(self):
from inference import LlamaMessage
return LlamaMessage("tool", self.result)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Helpers to format tool specs per engine # Helpers to format tool specs per engine
+30 -5
View File
@@ -1,14 +1,39 @@
import json import json
from stream import Stream from stream import Stream
from tool import ToolSpec from tool import ToolSpec, ToolCall
from inference import Message
# TODO: Move to module 'format'
def encode_value(value):
if isinstance(value, ToolSpec):
return value.to_openai_dict()
elif isinstance(value, Message):
return {'role': value.role, 'content': encode_value(value.content)}
elif isinstance(value, ToolCall):
json = {
'id': value.id,
'tool_name': value.tool_name,
'arguments': encode_value(value.arguments),
}
if value.result != None:
json['result'] = encode_value(value.result),
return json
elif isinstance(value, Stream):
if value.open:
return {'streamId': value.id}
else:
return value.content
else:
return value
def encode_json(value): def encode_json(value):
def encode_unknown(value): def encode_unknown(value):
if isinstance(value, ToolSpec): # All complex values are transformed to a dict
return value.to_openai_dict() value = encode_value(value)
elif isinstance(value, Stream): # otherwise simply show the repr
return {'streamId': value.id} if isinstance(value, dict):
return value
else: else:
return repr(value) return repr(value)
return json.dumps(value, default=encode_unknown) return json.dumps(value, default=encode_unknown)
+91
View File
@@ -0,0 +1,91 @@
# Telegram Channel
Connect a Telegram bot to an agent so the agent can read incoming messages and
reply. Telegram is the only channel implemented today; the machinery lives in
`backend/src/app_mixins/channel.py` (logic) and `backend/src/server/telegram.py`
(webhook route).
An agent that owns a `channel`/`telegram` resource *is* the agent that answers.
You can bind the same bot token to several agents, and one agent can be bound to
several bots.
## 1. Create a bot
Talk to [@BotFather](https://t.me/BotFather), create a bot, and copy its token
(looks like `123456:ABC-DEF...`). Treat the token as a secret.
## 2. Choose a delivery mode
Telegram can push updates to a webhook, or the hub can pull them with long
polling. The choice is driven entirely by the `HUB_HOST` environment variable:
- **Webhook** — set `HUB_HOST` to a publicly reachable host. Telegram requires
HTTPS on ports 443, 80, 88 or 8443, so the hub must be reachable there.
- **Long polling** — leave `HUB_HOST` unset. The hub prints a warning and polls
`getUpdates` instead. No public endpoint or TLS certificate is needed.
The `POST /channel/telegram/<agent_id>` route is always served regardless of
mode; in polling mode it simply never receives traffic.
Webhooks and polling are mutually exclusive. A webhook is never registered when
`HUB_HOST` is unset, and the poller clears any existing webhook (`deleteWebhook`)
before it starts.
## 3. Bind the bot to an agent
Add a resource to the responding agent (via the resources UI or the
`save-agent-resource` action):
```
kind = "channel"
key = "telegram"
value = {"token": "<bot token>", "secret": "<random webhook secret>", "name": "<display>"}
```
- `token` — the BotFather token. Required.
- `secret` — a random string of `A-Z a-z 0-9 _ -`, 1–256 characters. Optional,
and only meaningful in webhook mode. When present, Telegram sends it back on
every request in the `X-Telegram-Bot-Api-Secret-Token` header and the route
rejects mismatches with `403`.
- `name` — a display label. Optional.
This is the only state the channel persists. Conversations are **not** stored —
no `Chat`, `Thread` or `Message` rows are created. The rolling transcript is
held in memory on the hub and discarded when the process exits.
## 4. Restart the hub
On startup the hub queries all `channel`/`telegram` resources and reconciles the
provider:
- **Webhook mode**: it calls `getWebhookInfo` for each binding and, when the
registered URL does not match `https://<HUB_HOST>/channel/telegram/<agent_id>`,
calls `setWebhook` with that URL, the `secret_token`, and
`allowed_updates=["message"]`. Verify with `getWebhookInfo`.
- **Polling mode**: it starts one background task per binding. Confirm the bot is
reachable with `getMe`, then send a test message.
## 5. Talk to the bot
Message the bot. On each text message the hub:
1. Appends the message to the conversation's in-memory transcript.
2. Runs the agent with a flattened input — an instruction, a status message
(channel, chat id/title/type, current time, sender), and the rendered
conversation.
3. The agent replies by calling the `send_message` tool, which issues a
`sendMessage` call to Telegram.
Non-text updates (edited messages, media, callbacks) are ignored. Messages
authored by other bots are ignored.
## 6. Troubleshooting
| Symptom | Cause |
|---|---|
| Webhook not set | `HUB_HOST` unset, or the host is not reachable over HTTPS. Check the startup log. |
| Polling active when you expected a webhook | `HUB_HOST` is unset. |
| `403` from the webhook route | `X-Telegram-Bot-Api-Secret-Token` does not match the resource's `secret`. |
| `404` from the webhook route | No `channel`/`telegram` resource on that agent. |
| No reply | The agent has no model/tool configuration, or the run errored — check the hub log for `[channel]` lines. |
| `Conflict: can't use getUpdates method while webhook is active` | A stale webhook from a previous deployment. Remove it (`deleteWebhook`) or set `HUB_HOST` so webhook mode takes over. |
+10
View File
@@ -95,3 +95,13 @@ input:disabled, textarea:disabled, select:disabled {
.with-sidebar-expanded .when-sidebar-collapsed { .with-sidebar-expanded .when-sidebar-collapsed {
display: none; display: none;
} }
/* Content */
h1, h2, h3, h4, h5, h6 {
margin: 1em 0 0.5em;
font-weight: bold;
}
p, ul, il {
margin: 0.5em 0;
}
+27
View File
@@ -545,6 +545,33 @@ export class ChatSession {
this.creatingThread.value = Boolean(fork); this.creatingThread.value = Boolean(fork);
} }
// Sets the render format of a message ("markdown", or "" for plain text).
// Omit `content` so the backend leaves the existing content untouched.
setMessageFormat (message, format) {
backend.sendAction("update-chat-message", {
messageId: message.id, format,
}).then(response => {
if (response && response.error) {
ui.alert("Error updating message format: " + response.error);
}
});
// No update event is broadcast (matching the content-edit path above), so
// patch the source message and rebuild the entries locally. Other sessions
// and stale views won't reflect this until reload — which is correct, since
// the format is persisted. If we ever broadcast edits, flip this to rely on
// the incoming update instead.
let thread = this.getThread(message.threadId);
let existing = thread.messages.find(msg => msg.id == message.id);
if (existing) {
existing.format = format;
} else {
console.error("Can't set format locally?", {messageId: message.id, thread});
}
this.computeChatEntries();
}
// Adds a bulk of messages to the message list, in their correct // Adds a bulk of messages to the message list, in their correct
// chronological position and replacing duplicates // chronological position and replacing duplicates
addMessageBulk (messages) { addMessageBulk (messages) {
+12
View File
@@ -1,5 +1,6 @@
<script setup> <script setup>
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import * as ui from '../ui'
const props = defineProps({ const props = defineProps({
/** JSON schema object describing the input structure */ /** JSON schema object describing the input structure */
@@ -55,6 +56,17 @@
}; };
} }
async function addField(field) {
let key = await ui.input("Enter new property name");
if (!key) return;
// Never mutate the field object in place.
currentValue.value = {
...(currentValue.value || {}),
[key]: null
};
}
const computedFields = computed(() => { const computedFields = computed(() => {
let fields = []; let fields = [];
let skip = {}; let skip = {};
+43 -13
View File
@@ -4,13 +4,17 @@ import { marked } from "marked";
import * as backend from '../backend'; import * as backend from '../backend';
marked.use({ marked.use({
renderer: { renderer: {
code (code, infostring, escaped) { code ({lang, text}) {
let langElem = infostring ? let langElem = lang ?
`<div class="language">${infostring}</div>` : ""; `<div class="code-lang">${lang}</div>` : "";
const div = document.createElement('div');
div.textContent = text;
const escaped = div.innerHTML;
return `<div class="code"> return `<div class="code">
${langElem} ${langElem}
<div class="content">${code}</div> <div class="code-content">${escaped}</div>
</div>`; </div>`;
} }
} }
@@ -111,7 +115,7 @@ watch(() => [props.message.format, props.message.content], ([newFormat, newConte
<template v-if="message.type == 'message' || message.type == null"> <template v-if="message.type == 'message' || message.type == null">
<div v-if="message.sender != 'user'" class="sender">{{message.sender}}</div> <div v-if="message.sender != 'user'" class="sender">{{message.sender}}</div>
<div v-if="format == 'markdown'" v-html="marked.parse(message.content)" class="content"></div> <div v-if="format == 'markdown'" v-html="marked.parse(message.content)" class="md-content" style="white-space: collapse;"></div>
<div v-else class="content">{{content}}</div> <div v-else class="content">{{content}}</div>
</template> </template>
@@ -189,14 +193,6 @@ watch(() => [props.message.format, props.message.content], ([newFormat, newConte
margin-right: 2em; margin-right: 2em;
} }
.message .content {
white-space: preserve;
}
.message ul, .message ol {
white-space: normal;
}
.message .sender, .message .sender,
.note .sender { .note .sender {
font-weight: bold; font-weight: bold;
@@ -288,4 +284,38 @@ watch(() => [props.message.format, props.message.content], ([newFormat, newConte
font-style: normal; font-style: normal;
font-weight: bold; font-weight: bold;
} }
.md-content {
white-space: collapse;
width: fit-content;
max-width: 50em;
line-height: normal;
word-wrap: anywhere;
}
</style>
<style>
/* Code blocks are generated at runtime, these styles cannot be scoped */
.code {
background-color: var(--color-surface-input);
border-radius: var(--border-radius);
margin: .5em 0;
padding: .5em;
}
.code-lang {
padding-bottom: .5em;
margin-top: -.5em;
font-weight: bold;
user-select: none;
}
.code-content {
white-space: pre;
font-family: monospace;
overflow-x: auto;
line-height: 1.6rem;
}
</style> </style>
+1 -1
View File
@@ -23,8 +23,8 @@
<style scoped> <style scoped>
:slotted(h1) { :slotted(h1) {
font-size: 160%; font-size: 160%;
font-weight: bold;
padding: 0 0.4em; padding: 0 0.4em;
margin: 0;
} }
.main-header { .main-header {
+138 -57
View File
@@ -10,6 +10,8 @@
import * as backend from '../backend'; import * as backend from '../backend';
import * as ui from '../ui'; import * as ui from '../ui';
import * as iconoir from '@iconoir/vue';
const props = defineProps({ const props = defineProps({
id: { id: {
type: String, type: String,
@@ -32,8 +34,7 @@
}); });
// UI State // UI State
const addingTool = ref(null); const targetResource = ref(null);
const addingResource = ref(null);
// === State Management === // // === State Management === //
@@ -92,8 +93,7 @@
// Replace with a backend call // Replace with a backend call
backend.sendAction('get-agent-resources', {agentname}).then(res => { backend.sendAction('get-agent-resources', {agentname}).then(res => {
tools.value = res.resources.filter(r => r.kind == 'tool'); resources.value = res.resources;
resources.value = res.resources.filter(r => !['tool', 'config'].includes(r.kind));
}); });
} }
@@ -157,14 +157,35 @@
} }
promptHistory.value.push(historyEntry); promptHistory.value.push(historyEntry);
let res = await backend.sendAction("send-agent-prompt", payload);
if (res.error) { // prompt-agent is a multiple-result action (like read-stream): one packet
historyEntry.error = res.error; // per generated inference.Message, followed by a terminal eof packet. The
} else { // callback runs once per packet. Assistant/reasoning content is an open
historyEntry.output = res.response; // stream ({streamId}); we read it live via read-stream.
historyEntry.output = "";
try {
await backend.sendAction("prompt-agent", payload, (packet, deferred) => {
if (packet.eof) {
deferred.resolve(packet);
} else {
const msg = packet.data;
if (msg.role !== 'assistant') return;
const content = msg.content;
if (content && content.streamId != null) {
const stream = backend.readStream(content.streamId);
stream.on('data', () => {
historyEntry.output = stream.partialContent;
});
} else if (typeof content === 'string') {
historyEntry.output += content;
}
}
});
} catch (e) {
historyEntry.error = e.message || String(e);
} finally {
historyEntry.loading = false;
} }
historyEntry.loading = false;
} }
function promptKeyDown (event, stage) { function promptKeyDown (event, stage) {
@@ -237,34 +258,66 @@
}); });
} }
async function saveAddingTool() { async function updateResourceKey () {
let resource = { let newKey = await ui.input("Enter new resource key");
kind: 'tool', if (!newKey || newKey == targetResource.value.key) return;
key: addingTool.value.name,
value: JSON.parse(addingTool.value.value),
};
addingTool.value = null;
let res = await backend.sendAction("save-agent-resource", { // Guard collisions, backend won't
for (let r of resources.value) {
if (r.kind == targetResource.value.kind && r.key == newKey) {
ui.alert(`Conflict: ${targetResource.value.kind} '${newKey}'`);
return;
}
}
// Keys can't be updated directly, we move the resource
await backend.sendAction("delete-agent-resource", {
agentId: agent.value.id, agentId: agent.value.id,
...resource ...targetResource.value
});
targetResource.value.key = newKey;
await backend.sendAction("save-agent-resource", {
agentId: agent.value.id,
...targetResource.value,
}); });
tools.value.push(resource);
} }
async function saveAddingResource() { async function saveResource () {
let resource = {
kind: addingResource.value.kind,
key: addingResource.value.key,
value: JSON.parse(addingResource.value.value),
};
addingResource.value = null;
let res = await backend.sendAction("save-agent-resource", { let res = await backend.sendAction("save-agent-resource", {
agentId: agent.value.id, agentId: agent.value.id,
...resource ...targetResource.value
}); });
resources.value.push(resource);
ui.alert(`Saved ${targetResource.value.kind} '${targetResource.value.key}'`);
if (targetResource.value.isNew) {
delete targetResource.value.isNew;
resources.value.push(targetResource.value);
}
}
async function deleteResource () {
if (!await ui.confirm(`Delete ${targetResource.value.kind} '${targetResource.value.key}'?`)) return;
let res = await backend.sendAction("delete-agent-resource", {
agentId: agent.value.id,
...targetResource.value
});
let index = resources.value.indexOf(targetResource.value);
if (index >= 0) resources.value.splice(index, 1);
targetResource.value = null;
}
async function createResource () {
let kind = await ui.input("Enter resource kind");
if (!kind) return ui.alert('Cancel new resource');
let key = await ui.input("Enter resource key");
if (!kind) return ui.alert('Cancel new resource');
targetResource.value = {kind, key, value: {}, isNew: true};
} }
</script> </script>
@@ -327,36 +380,31 @@
<JsonEditor :schema="config.schema" v-model="config.object" /> <JsonEditor :schema="config.schema" v-model="config.object" />
<button type="submit" @click="saveConfig()">Save</button> <button type="submit" @click="saveConfig()">Save</button>
</section> </section>
<section>
<h3>Tools</h3>
<div v-for="(resource, index) in tools">
<div class="label">{{ resource.key }}</div>
<pre>{{ JSON.stringify(resource.value, null, 2) }}</pre>
</div>
<section v-if="addingTool">
<input v-model="addingTool.name" placeholder="name"><br>
<textarea v-model="addingTool.value" placeholder="{}"></textarea>
<br>
<button @click="saveAddingTool()">Save</button>
<button @click="addingTool=null">Cancel</button>
</section>
<div v-else><button @click="addingTool={}">Add Tool</button></div>
</section>
<section> <section>
<h3>Resources</h3> <h3>Resources</h3>
<div v-for="resource in resources"> <div class="row">
<div class="label">{{ resource.kind }}:{{ resource.key }}</div> <section class="resource-detail column" v-if="targetResource">
<pre>{{ JSON.stringify(resource.value, null, 2) }}</pre> <div>
<span class="resource-type">{{ targetResource.kind }}</span>
{{ targetResource.key }}
</div>
<JsonEditor :schema="{type:'object'}" v-model="targetResource.value" />
</section>
<section class="resource-table">
<div class="resource-entry" v-for="resource in resources" @click="targetResource=resource">
<span class="label">{{ resource.kind }}</span> {{ resource.key }}
</div>
</section>
</div>
<div>
<template v-if="targetResource">
<button @click="updateResourceKey">Update Key</button>
<button @click="deleteResource">Delete</button>
<button @click="saveResource">Save</button>
<button @click="targetResource=null">Cancel</button>
</template>
<button v-else @click="createResource">Add Resource</button>
</div> </div>
<section v-if="addingResource">
<input v-model="addingResource.kind" placeholder="kind/domain"><br>
<input v-model="addingResource.key" placeholder="key"><br>
<textarea v-model="addingResource.value" placeholder="value"></textarea>
<br>
<button @click="saveAddingResource()">Save</button>
<button @click="addingResource=null">Cancel</button>
</section>
<div v-else><button @click="addingResource={}">Add Resource</button></div>
</section> </section>
</main> </main>
</template> </template>
@@ -398,4 +446,37 @@
resize: vertical; resize: vertical;
height: 5em; height: 5em;
} }
.resource-table {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(6em, 1fr));
height: fit-content;
flex: 1;
/*align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--color-border);*/
}
.resource-entry {
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
background-color: var(--color-surface-strong);
padding: .4rem .6rem;
width: max-content;
}
.resource-entry:hover {
border-color: var(--color-highlight);
}
.resource-type {
font-family: monospace;
border: 1px solid var(--color-border);
border-radius: 3px;
padding: .1rem .3rem;
}
/* style fix */
.row > section { margin-left: 0; }
.row > section:last-child { margin-right: 0; }
</style> </style>
+12
View File
@@ -130,6 +130,12 @@
{value: 'pin', title: "Pin message"}, {value: 'pin', title: "Pin message"},
{value: 'edit', title: "Edit in-place"}, {value: 'edit', title: "Edit in-place"},
{value: 'edit-fork', title: "Edit in new thread"}, {value: 'edit-fork', title: "Edit in new thread"},
// ContextMenu.vue uses null for "dismissed", so the "None" entry can't
// carry a null value; "format:" (empty suffix) is the sentinel for "".
{title: "Set format", entries: [
{value: 'format:markdown', title: "Markdown"},
{value: 'format:', title: "None"},
]},
]; ];
// Long logs can be expanded/collapsed // Long logs can be expanded/collapsed
@@ -164,6 +170,12 @@
} }
break; break;
} }
default: {
if (selected?.startsWith('format:')) {
session.setMessageFormat(message, selected.slice('format:'.length));
}
break;
}
} }
} }