Compare commits

...
13 Commits
21 changed files with 1496 additions and 817 deletions

No files matched your search

+94 -66
View File
@@ -5,7 +5,6 @@ import asyncio
import traceback
from temporary import generate_tmp_id
from stream import Stream
# Tool related classes must be moved to a different module
@@ -36,15 +35,25 @@ class Agent:
# should be in expert init?
self.tools = []
self.remotes = []
for resource in self.resources:
if resource.kind != 'tool': continue
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)
if tool_cls:
tool = tool_cls()
spec = tool.get_spec().clone()
spec.name = resource.key
self.tools.append(spec)
if resource.kind == 'tool':
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)
if tool_cls:
tool = tool_cls()
spec = tool.get_spec().clone()
spec.name = resource.key
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):
self.id = id
@@ -56,6 +65,10 @@ class Agent:
agent_run.close()
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):
run = AgentRun.create(
self.app,
@@ -67,19 +80,48 @@ class Agent:
**kwargs,
},
)
out_stream = Stream(self.app)
def on_output_event(stream):
def on_chunk(chunk):
out_stream.feed(chunk)
stream.listen(on_chunk, lambda: None)
def on_done(data):
out_stream.close()
run.on('output', on_output_event)
queue = asyncio.Queue()
def on_inference(msg):
queue.put_nowait(('message', msg))
def on_done(_data):
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)
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):
chat = await self.app.get_chat(message.chat_id)
@@ -113,13 +155,28 @@ class Agent:
},
)
async def on_output_event(stream):
print("[agent] run output " + repr(stream))
async def on_inference(msg):
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(
sender=self.id,
chat_id=message.chat_id,
thread_id=message.thread_id,
content=stream,
**kwargs
)
run.emit_event('chat-message', {
'messageId': new_message['id'],
@@ -127,41 +184,7 @@ class Agent:
'threadId': message.thread_id,
})
async def on_reasoning_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="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 run.on_async('inference', on_inference)
await self.run(run)
return run
@@ -184,16 +207,21 @@ class Agent:
if r.kind == kind and (key is None or r.key == key)]
def get_tools(self, run=None):
"""Collect the tools available to this agent.
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)
remotes = list(self.remotes)
if run is not None and 'remotes' in run.input:
tools += self.app.getRemoteTools(self.user_id, run.input['remotes'])
return tools
remotes += list(run.input['remotes'])
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):
+34 -52
View File
@@ -1,5 +1,6 @@
import inference
from agent import Agent, AgentField
from tool import ToolSpec
import asyncio
@@ -20,62 +21,47 @@ async def chat_completion_loop(app, run, messages, model_id=None, tools=None):
tools = list(tools or [])
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:
# Reset per-iteration state
output_stream = None
reasoning_stream = None
inference_end.clear()
await inference.chat_completion(
tool_calls = []
turn_response = False
generator = inference.chat_completion(
app,
model_id=model_id,
messages=messages,
tools=tools,
agent_run=run,
opts=run.input,
)
await inference_end.wait()
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
pre_len = len(messages)
async for msg in generator:
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:
for tc in tool_calls:
tool = next((t for t in tools if t.name == tc.tool_name), None)
if tool is None:
print(f"[agent] unknown tool name: {tc.tool_name}")
continue
tc.result = await tool.execute_async(**tc.arguments)
messages.append(inference.LlamaMessage('tool', tc))
tool_calls.clear()
# Buffer time to let the provider rest before the next call
await asyncio.sleep(2)
elif content or not reasoning:
break
# Avoid infinite loops from server failures
if len(messages) == pre_len: break
cancel_tools = False
for tc in tool_calls:
tool = next((t for t in tools if t.name == tc.tool_name), None)
if tool is None:
print(f"[agent] unknown tool name: {tc.tool_name}")
continue
if execute_tools:
# Only known 'non-callable' tool spec
if isinstance(tool, ToolSpec) and not tool.func:
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()
@@ -105,13 +91,9 @@ class ExpertAgent(Agent):
# Provide the agent's default tools unless the run supplies its own.
# Only agent-provided tools are executable within the loop.
tools = run.input.get("tools", [])
if tools:
run.input['tool_policy'] = 'ignore'
else:
if run.input.get('tool_policy') != 'caller':
tools += self.get_tools(run)
print("Final tool object: " + repr(run.input.get('tools', [])))
# Build the initial message list
messages = inference.build_messages(
system_prompt=self.config.get("system_prompt", ""),
+2 -1
View File
@@ -47,7 +47,8 @@ class App(ChatMixin, AgentMixin):
]
from features.calc import Calc
self.tool_classes = [Calc]
from features.web import WebSearch, WebFetch
self.tool_classes = [Calc, WebSearch, WebFetch]
# --- Connection Management (Synchronous) ---
+49 -4
View File
@@ -126,13 +126,21 @@ class AgentMixin:
async def save_agent_resource(self, agent_id, kind, key, value):
await db.AgentResource.update_or_create(
{'value': value},
agent_id=agent_id,
kind=kind,
key=key,
value=value,
)
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 ---
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]
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)
response = await control.prompt(instruction, input, **options)
return response
async for msg in control.prompt(instruction, input, **options):
yield msg
async def call_agent_action(self, agent_id, action, args):
control = await self.get_agent_control(agent_id)
+1
View File
@@ -13,6 +13,7 @@ def message_json(msg):
"sender": msg.sender,
"kind": msg.kind,
"subkind": msg.subkind,
"format": msg.format,
"content": msg.content,
"threadId": msg.thread_id,
}
+2 -2
View File
@@ -27,7 +27,7 @@ async def upload_file(user_id, drive_name, filename, stream, mime_type=None):
drive = await db.Drive.create(user_id=user_id, name=drive_name)
# Check collision
if await db.Drive.get_or_none(drive_id=drive.id, filename=filename):
if await db.DriveFile.get_or_none(drive_id=drive.id, filename=filename):
raise Exception("Duplicated file")
# 2. Prepare disk path
@@ -82,7 +82,7 @@ async def delete_file(file_id):
return abs_path
async def resolve_file(user_id, drive_name, filename):
"""Resolve file to absolute disk path after permission check. Returns (path, mime)."""
"""Resolve file to absolute disk path after permission check. Returns (abs_path, file.id)."""
# Eager load drive and verify ownership
drive = await db.Drive.get_or_none(user_id=user_id, name=drive_name)
if drive.user_id != user_id:
+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 traceback
import random
import inspect
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 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
# TODO: Rename to ChatMessage. Add methods for constructing from instruct/openai/etc formatted objects.
class LlamaMessage:
def __init__(self, role, content, reasoning=None):
# Background inference tasks are kept referenced here so they aren't GC'd mid-flight.
_background_tasks = set()
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:
raise Exception("message role must be one of: " + ",".join(VALID_ROLES))
self.role = role
self.content = content
self.reasoning = reasoning
def to_dict(self):
"""Serialize to the dict format expected by llama-cpp / ollama."""
d = {"role": self.role, "content": self.content}
if self.reasoning:
d["reasoning"] = self.reasoning
return d
return {"role": self.role, "content": self.content}
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:
llama_8b = "meta/meta-llama-3-8b-instruct"
llama_70b = "meta/meta-llama-3-70b-instruct"
qwen3_8b = "lucataco/qwen3-vl-8b-instruct:39e893666996acf464cff75688ad49ac95ef54e9f1c688fbc677330acc478e11"
qwen3_235b = "qwen/qwen3-235b-a22b-instruct-2507"
def format_tools(tools):
specs = []
for t in tools:
if isinstance(t, dict):
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):
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:
content = msg.content
if isinstance(content, Stream):
_stream_count += 1
content = content.content
if msg.role == 'tool':
if not last_assistant_message:
# Create one implicitly
last_assistant_message = {'role': 'assistant', 'content': ''}
tool_calls = last_assistant_message.get('tool_calls')
last = last_assistant_message()
tool_calls = last.get('tool_calls')
if tool_calls == None:
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({
'id': msg.content.id,
'id': tc.id,
'type': 'function',
'function': {
'name': msg.content.tool_name,
'arguments': json.dumps(msg.content.arguments),
'name': tc.tool_name,
'arguments': json.dumps(tc.arguments),
},
})
formatted.append({
'role': 'tool',
'tool_call_id': msg.content.id,
'content': json.dumps(msg.content.result),
'tool_call_id': tc.id,
'content': json.dumps(tc.result),
})
else:
fmsg = {'role': msg.role, 'content': msg.content}
elif msg.role == 'reasoning':
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)
if msg.role == 'assistant':
last_assistant_message = fmsg
else:
_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
@@ -81,9 +132,12 @@ def safe_queue(callback):
queue = asyncio.Queue()
loop = asyncio.get_running_loop()
async def read_loop():
nonlocal callback
while True:
args = await queue.get()
await callback(*args)
task = callback(*args)
if inspect.isawaitable(task):
await task
loop.create_task(read_loop())
def send(*args):
@@ -116,10 +170,10 @@ def get_llama_cpp_model(app, model_file):
# Generate a conversation based on agent inputs
def build_messages(system_prompt="", instruction=None, input=None, messages=None):
def normalize(message):
if isinstance(message, LlamaMessage):
if isinstance(message, Message):
return message
elif isinstance(message, dict):
return LlamaMessage(message['role'], message['content'])
return Message(message['role'], message['content'])
else:
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
if system_prompt:
messages.insert(0, LlamaMessage("system", system_prompt))
messages.insert(0, Message("system", system_prompt))
if prompt:
messages.append(LlamaMessage("user", prompt))
messages.append(Message("user", prompt))
return messages
@@ -151,231 +205,206 @@ async def chat_completion(app,
messages=None,
tools=[],
opts={},
agent_run=None
):
# TESTING OVERRIDE
# return await mock_completion(app, agent_run)
db_model = (await app.get_model(model_id)).value
model_engine = db_model['engine']
model_name = db_model['model']
if model_engine == "llama-cpp":
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))
print(f"[inference] chat completion: {model_engine} '{model_name}', {len(messages)} messages, {len(tools)} tools")
if agent_run:
return None
return result
if TEST_MOCK:
generator = mock_completion(app)
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
async def local_chat_completion(app, messages, model, tools=[], opts={}, agent_run=None):
if not agent_run:
raise Exception("agent_run required")
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 local_chat_completion(app, messages, model, tools=[], opts={}):
queue = asyncio.Queue()
loop = asyncio.get_running_loop()
emit = safe_queue(queue.put_nowait)
async def worker():
print("START OPENROUTER INFERENCE")
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):
stream = None
try:
task.result()
except asyncio.CancelledError:
print("[openrouter] inference cancelled")
except Exception as e:
print(f"[openrouter] Worker error: {e}")
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(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())
finally:
if stream: stream.close()
emit(None)
task = asyncio.create_task(worker())
task.add_done_callback(handle_done)
agent_run.task = task
spawn(worker())
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',
'merge','purge','index','probe','flush','bind','warm','drain']
OBJECTS = ['nodes','packets','tokens','frames','shards','clusters',
@@ -383,20 +412,28 @@ async def mock_completion(app, agent_run=None):
'handles','slots','cursors','segments']
DELAY = 0.3
content_stream = Stream(app)
if agent_run:
agent_run.emit_event('output', content_stream)
queue = asyncio.Queue()
emit = safe_queue(queue.put_nowait)
async def content_task ():
await asyncio.sleep(DELAY)
content_stream.feed(random.choice(VERBS))
await asyncio.sleep(DELAY)
content_stream.feed(f" {random.randint(1, 99)} ")
await asyncio.sleep(DELAY)
content_stream.feed(random.choice(VERBS))
await asyncio.sleep(DELAY)
content_stream.close()
agent_run.emit_event('inference-end', {})
async def worker():
stream = Stream(app)
emit(Message('assistant', stream))
try:
await asyncio.sleep(DELAY)
stream.feed(random.choice(VERBS))
await asyncio.sleep(DELAY)
stream.feed(f" {random.randint(1, 99)} ")
await asyncio.sleep(DELAY)
stream.feed(random.choice(VERBS))
await asyncio.sleep(DELAY)
finally:
stream.close()
emit(None)
asyncio.create_task(content_task())
if not agent_run: return content_stream
spawn(worker())
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
from quart import Response, request
from quart_auth import login_required, current_user
from tool import ToolSpec, ToolParameter
from agent import AgentRun
from tool import ToolSpec, ToolParameter, ToolCall
from utils import encode_json
# ---------------------------------------------------------------------------
@@ -49,66 +48,72 @@ def route_tool_call(call, tool_names):
return deferred
async def execute_deferred(function_call):
async def execute_deferred(tc):
"""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
infrastructure (ToolSpec.execute_async), recording the result or error on
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):
"""Wire the run's output events into a single queue.
def messages_to_queue(generator, tool_names):
"""Drive a prompt generator into a single queue.
The queue receives ('content'|'reasoning'|'tool_call'|'deferred_call', data)
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()
loop = asyncio.get_event_loop()
state = {'tool_call': False, 'error': None}
def on_output(stream):
async def on_chunk(chunk):
await queue.put(('content', chunk))
async def on_eof():
# on_done doesn't work?
async def on_content(chunk):
await queue.put(('content', chunk))
async def on_reasoning(chunk):
await queue.put(('reasoning', chunk))
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))
stream.listen(on_chunk, on_eof)
def on_reasoning(stream):
async def on_chunk(chunk):
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
loop.create_task(drain())
return queue, state
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."""
return {
"id": "chatcmpl-" + str(time.time()),
"object": "chat.completion.chunk",
"created": int(time.time()),
"model": agent_id,
"usage": usage,
"choices": [{"delta": delta, "index": 0, "finish_reason": finish_reason}]
}
@@ -124,8 +129,8 @@ def register(server, app):
# Extract required fields
agent_id = data.get("model")
openai_messages = data.get("messages", [])
openai_tools = data.get("tools", [])
openai_messages = data.get("messages") or []
openai_tools = data.get("tools") or []
stream_mode = data.get("stream", False)
if not agent_id or not openai_messages:
@@ -164,20 +169,36 @@ def register(server, app):
# message carries the output.
call.result = content
entry['resolved'] = True
messages.append({'role': 'tool', 'content': call.result, 'tool_call_id': call.id})
messages.append({'role': 'tool', 'content': call})
continue
if role == 'assistant' and msg.get('tool_calls'):
# Rebuild the assistant's tool call turn from the cache so
# unknown or expired calls are silently dropped and the agent
# receives the original call objects, which carry their own
# output once resolved.
# Rebuild the assistant's tool call turn from the request.
# Calls already in the cache (persistent conversation) are
# reused as-is. Client-declared function calls carry their
# 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 = []
for tc in msg['tool_calls']:
entry = tool_call_cache.get(tc.get('id', ''))
if entry is None:
call_id = tc.get('id', '')
entry = tool_call_cache.get(call_id)
if entry is not None:
calls.append(entry['call'])
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({
'role': 'assistant',
'content': content if type(content) == str else None,
@@ -226,30 +247,30 @@ def register(server, app):
# tool and gets deferred.
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)
run = AgentRun.create(
app,
agent_id=agent_id,
user_id=user_id,
input={
"messages": messages,
"tools": tools,
# Never execute tools inside the run: emit them instead so
# the handler can send client tools back and defer native
# ones.
"defer_tool_execution": True,
},
# The OpenAI inputs ride as kwargs so they land in run.input, which
# ExpertAgent.run reads ("tools", "tool_policy", "messages").
generator = control.prompt(
None,
input=None,
messages=messages,
tools=tools,
# Never execute tools inside the run: emit them instead so
# the handler can send client tools back and defer native
# ones.
tool_policy="ignore",
)
print("run input: " + repr(run.input))
queue = attach_run_handlers(run, tool_names)
await control.run(run)
queue, state = messages_to_queue(generator, tool_names)
usage = {'completion_tokens': 0, 'prompt_tokens': 0, 'total_tokens': 0, 'cost': 0}
# --- Streaming ---
if stream_mode:
async def generate():
nonlocal usage
try:
tool_call_count = 0
while True:
@@ -281,19 +302,26 @@ def register(server, app):
"type": "tool",
}]}
elif msg_type == 'usage':
usage = data
elif msg_type == 'eof':
if state['error']:
raise state['error']
# Determine finish_reason — was there a tool call?
finish_reason = "tool_calls" if any(
e['name'] == 'tool_call' for e in run.events
) else "stop"
finish_reason = "tool_calls" if state['tool_call'] else "stop"
yield f"data: {json.dumps(format_chunk(agent_id, {}, finish_reason))}\n\n"
break
if delta:
yield f"data: {json.dumps(format_chunk(agent_id, delta))}\n\n"
else:
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:
print("[openai] ERROR: " + str(e))
print(traceback.format_exc())
@@ -309,6 +337,8 @@ def register(server, app):
while True:
msg_type, data = await queue.get()
if msg_type == 'eof':
if state['error']:
raise state['error']
break
elif msg_type == 'content':
content += data
@@ -327,6 +357,8 @@ def register(server, app):
# Deferred calls stay opaque to the client: only the
# call id and type go on the wire.
tool_calls.append({"id": data.id, "type": "tool"})
elif msg_type == 'usage':
usage = data
else:
raise Exception(f"Unrecognized msg_type: {msg_type}. {repr(data)}")
except Exception as e:
@@ -346,6 +378,7 @@ def register(server, app):
"object": "chat.completion",
"created": int(time.time()),
"model": agent_id,
"usage": usage,
"choices": [{
"message": message,
"finish_reason": finish_reason,
+301 -261
View File
@@ -1,7 +1,6 @@
import json, asyncio
import json, asyncio, traceback
from quart import websocket
from utils import encode_json
from stream import Stream # isinstance check in send-agent-prompt
def register(server, app, auth_manager):
@server.websocket('/ws')
@@ -40,10 +39,14 @@ def register(server, app, auth_manager):
send_queue = asyncio.Queue()
async def sender_impl():
# This thread MUST NOT crash
nonlocal send_queue
while True:
msg = await send_queue.get()
await websocket.send(encode_json(msg))
try:
msg = await send_queue.get()
await websocket.send(encode_json(msg))
except Exception as err:
print(traceback.format_exc())
async def send(msg):
await send_queue.put(msg)
@@ -65,263 +68,10 @@ def register(server, app, auth_manager):
stop_listening = app.on_event(on_event)
while True:
req = json.loads(await websocket.receive())
if not 'action' in req:
await send({'error': 'no action'})
continue
action = req['action']
body = req.get('body', {})
nonce = req.get('nonce', None)
asyncio.create_task(handle_request(
app, user_id, session_connections, send, await websocket.receive()
))
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:
print("User logged out:", user_id)
sender_task.cancel()
@@ -336,4 +86,294 @@ def register(server, app, auth_manager):
# disconnected manually (possibly from another session) are safe.
for remotename in list(session_connections):
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
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):
@@ -218,24 +218,17 @@ class ToolCall:
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.tool_name = tool_name
self.arguments = arguments
self.result = None
self.result = result
def __repr__(self):
id_prefix = self.id + ', ' if self.id else ''
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
+30 -5
View File
@@ -1,14 +1,39 @@
import json
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_unknown(value):
if isinstance(value, ToolSpec):
return value.to_openai_dict()
elif isinstance(value, Stream):
return {'streamId': value.id}
# All complex values are transformed to a dict
value = encode_value(value)
# otherwise simply show the repr
if isinstance(value, dict):
return value
else:
return repr(value)
return json.dumps(value, default=encode_unknown)
+14 -12
View File
@@ -1,27 +1,29 @@
# Drives
Per-user file storage: a **drive** is a named container owned by a user, and each **file** is a database record plus a blob on disk. Everything is addressed by *drive name + filename* — the random file id never appears in URLs. Drives bypass the WebSocket action protocol entirely: all endpoints are plain REST (routes in `backend/src/server/web.py`, logic in `backend/src/drives.py`), so downloads can be plain `<a>` links.
Per-user file storage: a **drive** is a named container owned by a user, and each **file** is a database record plus a blob on disk. Client-facing routes are addressed by *drive name + filename* — the random file id never appears in URLs — though several `drives.py` helpers below are id-addressed internally. Drives bypass the WebSocket action protocol entirely: all endpoints are plain REST (routes in `backend/src/server/web.py`, logic in `backend/src/drives.py`), so downloads can be plain `<a>` links.
## Logical model
- **Drive** — user-scoped, addressed by name. Created implicitly on first upload to a new name; there is no create/rename/delete-drive endpoint.
- **File** — readable `filename`, mime type, size, `created`/`accessed` timestamps.
- **Folders are virtual** — a filename may contain slashes (`docs/api/readme.md`); the Drives view splits names to render a tree, but storage stays flat.
- **Identity** — `DriveFile.id` is a random hex string (`secrets.token_hex(8)`) that doubles as the on-disk filename, so user-facing names never touch the filesystem.
- **Identity** — `DriveFile.id` is a random hex string (`secrets.token_hex(8)`, 16 hex chars) that doubles as the on-disk filename, so user-facing names never touch the filesystem.
## Backend interface
Download links can't set headers, so the GET/DELETE file routes additionally accept the token as a `?token=` query parameter.
Client-facing file routes are drive+filename-addressed, but the `drives.py` helpers behind them are mixed: `upload_file` accepts drive+filename directly, while `get_metadata(file_id)` and `delete_file(file_id)` are id-addressed, so their routes must first resolve filename → id (via the drive's file records) before calling them. The two listing routes are not implemented in `drives.py` at all — their logic lives elsewhere.
| Route | Behavior |
| --- | --- |
| `GET /files` | List the user's drives: `[{id, name}]` |
| `GET /files/<drive>` | List files: `[{id, name, size, mime, created}]` |
| `GET /files` | List the user's drives: `[{id, name}]`. Not implemented in `drives.py`. |
| `GET /files/<drive>` | List files: `[{id, name, size, mime, created}]`. Not implemented in `drives.py`. |
| `POST /files/<drive>/<filename>` | Multipart upload (field `file`) in a single request. The URL tail — `<path:filename>`, slashes allowed — becomes the stored filename; the part's own name is the fallback. Returns `{fileId}`. |
| `GET /files/<drive>/<filename>` | Serve the bytes with their stored mime type, or return `{id, name, size, mime, created, accessed}` when `?metadata` is present. |
| `DELETE /files/<drive>/<filename>` | Delete the record, then the blob. Returns `{fileId}`. |
| `GET /files/<drive>/<filename>` | Serve the bytes, with the mime type looked up from the file record (not from `resolve_file`), or return `{id, name, size, mime, created, accessed}` when `?metadata` is present. |
| `DELETE /files/<drive>/<filename>` | Delete the record, then the blob. Returns the removed absolute disk path (`abs_path`). |
Every resolve (download, metadata, delete) stamps `accessed_at`.
Only `resolve_file` stamps `accessed_at`; `get_metadata` and `delete_file` never touch it.
## Frontend interface
@@ -29,20 +31,20 @@ In `frontend/src/backend.js`:
- `webFetch(path, options)` — generic REST helper: sets `Authorization: Bearer` (waiting for login if needed), parses JSON, throws on non-2xx. Downloads bypass it.
- `getDrives()`, `getDriveFiles(driveName)`, `deleteDriveFile(driveName, filename)`
- `uploadDriveFile(driveName, file)` — multipart POST of the whole file.
- `uploadDriveFile(driveName, file, path)` — multipart-POSTs the whole file to `/files/<driveName>/<path>`, where `path` is the destination filename including any virtual folder prefix (`docs/api/readme.md`). The composite path belongs in the URL, since the upload route only exists at `/files/<drive>/<path:filename>`.
- `driveFileUrl(driveName, filename)` — GET URL carrying `?token=`, used for download anchors and for opening a file in a tab.
`frontend/src/views/Drives.vue` lazy-loads a drive's files on first expand, rebuilds the folder tree from the flat file list on every load (open folders live on the drive object, so they survive reloads), uploads through a file picker, and deletes with confirmation.
`frontend/src/views/Drives.vue` lazy-loads a drive's files on first expand, rebuilds the folder tree from the flat file list on every load (open folders live on the drive object, so they survive reloads), uploads through a file picker (building the target path from the picked folder and passing it as `uploadDriveFile`'s third argument), and deletes with confirmation.
## Implementation notes
- Models in `backend/src/database.py` (migration `0002_auto_20260921_1840`): `Drive(id, user_id, name, created_at)` and `DriveFile(id char(64) pk, drive_id, filename, mime_type, size_bytes, accessed_at, created_at)`, table `drive_files`. Ownership rides the `User → Drive → File` chain and is checked before any disk access.
- Models in `backend/src/database.py` (migration `0002_auto_20260921_1840`): `Drive(id, user_id, name, created_at)` and `DriveFile(id, drive_id, filename, mime_type, size_bytes, accessed_at, created_at)`, table `drive_files`. The `id` is a 16-char hex string, not `char(64)`. Ownership rides the `User → Drive → File` chain; it is checked in `resolve_file` before disk access, but `delete_file` and `get_metadata` take a bare `file_id` and perform no ownership check of their own — their routes must guard them.
- Disk layout: `{data_dir}/drives/{drive_id}/{file_id}` — keyed by drive id, not by user or name. `init_drives(data_directory)`, called from `main()`, creates the root.
- Upload streams to disk in 8 KiB chunks and only then writes the DB record; delete removes the record first, then the blob.
## Known issues
- `uploadDriveFile` POSTs to `/files/<drive>`, where no POST route exists, and drops the folder-path argument `Drives.vue` passes it — UI uploads fail, and folder-targeted upload is not wired up.
- Drives are only created implicitly, and the view only uploads into existing drives, so the first drive cannot be created from the frontend.
- Uploads are meant to refuse a filename already used in the drive, but the check queries the `Drive` model with `DriveFile`'s fields, so it errors instead of detecting duplicates.
- `resolve_file` dereferences the drive without a None check, so a nonexistent drive yields a 500 instead of a 404.
- `resolve_file` dereferences the drive without a None check, so a nonexistent drive yields a 500 instead of a 404.
- `delete_file` and `get_metadata` take a bare `file_id` with no ownership check; if their routes don't resolve and authorize the file against the owning user, any authenticated user could delete or inspect another user's file by id.
+10
View File
@@ -94,4 +94,14 @@ input:disabled, textarea:disabled, select:disabled {
.with-sidebar-expanded .when-sidebar-collapsed {
display: none;
}
/* Content */
h1, h2, h3, h4, h5, h6 {
margin: 1em 0 0.5em;
font-weight: bold;
}
p, ul, il {
margin: 0.5em 0;
}
+4 -2
View File
@@ -379,6 +379,8 @@ export function invalidateCache({ chats=false, agents=false } = {}) {
// Drive endpoints are plain HTTP routes instead of WebSocket actions,
// so they authenticate with the bearer token in the Authorization header.
// TODO: Next time these functions need updating, move them directly to views/Drives.vue
export function getDrives () {
return webFetch('/files');
}
@@ -387,10 +389,10 @@ export function getDriveFiles (driveName) {
return webFetch(`/files/${driveName}`);
}
export function uploadDriveFile (driveName, file) {
export function uploadDriveFile (driveName, file, path) {
const body = new FormData();
body.append('file', file, file.name);
return webFetch(`/files/${driveName}`, {
return webFetch(`/files/${driveName}/${path}`, {
method: 'POST',
body,
});
+27
View File
@@ -545,6 +545,33 @@ export class ChatSession {
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
// chronological position and replacing duplicates
addMessageBulk (messages) {
+12
View File
@@ -1,5 +1,6 @@
<script setup>
import { ref, computed, watch } from 'vue'
import * as ui from '../ui'
const props = defineProps({
/** 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(() => {
let fields = [];
let skip = {};
+232 -14
View File
@@ -2,15 +2,20 @@
import { ref, computed, watch } from 'vue';
import { marked } from "marked";
import * as backend from '../backend';
import * as iconoir from '@iconoir/vue';
marked.use({
renderer: {
code (code, infostring, escaped) {
let langElem = infostring ?
`<div class="language">${infostring}</div>` : "";
code ({lang, text}) {
let langElem = lang ?
`<div class="code-lang">${lang}</div>` : "";
const div = document.createElement('div');
div.textContent = text;
const escaped = div.innerHTML;
return `<div class="code">
${langElem}
<div class="content">${code}</div>
<div class="code-content">${escaped}</div>
</div>`;
}
}
@@ -29,6 +34,12 @@ const props = defineProps({
logExpanded: {
type: Boolean,
required: false,
},
// Toggle set of expanded message ids, shared with the parent view.
// Used by log-groups to expand/collapse the previewed log.
expandedLogs: {
type: Object,
required: false,
}
});
@@ -41,6 +52,8 @@ function chatEntryClass(msg) {
return ['resource', 'resource-' + (msg.subkind || 'generic')];
} else if (msg.type == 'log') {
return ['log'];
} else if (msg.type == 'log-group') {
return ['log-group'];
} else if (msg.type == 'thread-header') {
let cls = ['thread'];
if (!msg.active) cls.push(msg.isFork ? 'thread-fork' : 'thread-closed')
@@ -104,14 +117,110 @@ watch(() => [props.message.format, props.message.content], ([newFormat, newConte
}
}, {immediate: true})
// === Log groups === //
// A log-group collects consecutive log messages. Each one is shown as a square
// icon (which selects it as the preview); the selected log is previewed below.
const logs = computed(() =>
props.message.type == 'log-group' ? (props.message.logs || []) : []
);
// Icon used for a log's square button, chosen by log subkind
function logIcon(subkind) {
if (subkind == 'reasoning') return iconoir.Brain;
if (subkind == 'tool-call') return iconoir.Wrench;
return iconoir.Terminal;
}
// Null means "default to the last log in the group"
const selectedLogId = ref(null);
const selectedLog = computed(() => {
const arr = logs.value;
if (!arr.length) return null;
if (selectedLogId.value != null) {
const found = arr.find(l => l.id == selectedLogId.value);
if (found) return found;
}
return arr[arr.length - 1];
});
// Streamed logs resolve their content through a shared per-stream cache, so
// switching the preview between logs still shows up-to-date content.
const boundStreams = new Set();
const streamContents = ref({});
function bindLogStreams() {
for (const log of logs.value) {
if (log.format != 'stream') continue;
const streamId = parseInt(log.content);
if (boundStreams.has(streamId)) continue;
boundStreams.add(streamId);
const stream = backend.readStream(streamId);
const update = () => {
streamContents.value = { ...streamContents.value, [streamId]: stream.partialContent };
};
update();
stream.on('data', update);
}
}
watch(logs, bindLogStreams, { immediate: true });
const selectedLogText = computed(() => {
const log = selectedLog.value;
if (!log) return '';
if (log.format == 'stream') return streamContents.value[parseInt(log.content)] ?? '';
return log.content ?? '';
});
const groupLogLong = computed(() => {
if (!selectedLog.value) return false;
const lines = String(selectedLogText.value ?? '').split('\n');
return lines.length > LOG_MAX_LINES || lines.some(line => line.length > LOG_MAX_LENGTH);
});
const groupExpanded = computed(() =>
selectedLog.value != null && !!props.expandedLogs?.has(selectedLog.value.id)
);
// Collapsed preview keeps the *last* N characters (the tail), not the head;
// CSS then clips the top so only the bottom lines show.
const groupLogContent = computed(() => {
const text = String(selectedLogText.value ?? '');
if (groupLogLong.value && !groupExpanded.value) {
return text.slice(-(LOG_MAX_LENGTH * LOG_MAX_LINES));
}
return text;
});
const groupLogMeta = computed(() => groupLogLong.value ? {expanded: groupExpanded.value} : null);
function toggleGroupLog() {
if (selectedLog.value) props.expandedLogs?.toggle(selectedLog.value.id);
}
function onContextMenu() {
if (props.message.type == 'log-group') {
if (selectedLog.value) emit('open-menu', selectedLog.value, groupLogMeta.value);
} else {
emit('open-menu', props.message, logMeta.value);
}
}
</script>
<template>
<div :class="chatEntryClass(message)" @contextmenu.prevent="$emit('open-menu', message, logMeta)" @click="handleClick(message)">
<div :class="chatEntryClass(message)" @contextmenu.prevent="onContextMenu" @click="handleClick(message)">
<template v-if="message.type == 'message' || message.type == null">
<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>
</template>
@@ -131,6 +240,28 @@ watch(() => [props.message.format, props.message.content], ([newFormat, newConte
<a v-if="logLong" class="log-toggle" @click.prevent="$emit('toggle-log')">{{logExpanded ? '(collapse)' : '(expand)'}}</a>
</template>
<template v-if="message.type == 'log-group'">
<div class="log-icons">
<button
v-for="log in logs"
:key="log.id"
type="button"
class="icon"
:class="{ highlight: selectedLog && log.id == selectedLog.id }"
:title="(log.sender || '') + (log.subkind ? ' [' + log.subkind + ']' : '')"
@click="selectedLogId = log.id"
>
<component :is="logIcon(log.subkind)" />
</button>
</div>
<div v-if="selectedLog" class="log-preview">
<div class="log-sender">{{selectedLog.sender}}<span v-if="selectedLog.subkind"> [{{selectedLog.subkind}}]</span></div>
<div class="content" :class="{ clipped: !groupExpanded }">{{groupLogContent}}</div>
<a v-if="groupLogLong" class="log-toggle" @click.prevent="toggleGroupLog()">{{groupExpanded ? '(collapse)' : '(expand)'}}</a>
</div>
</template>
<template v-if="message.type == 'thread-header'">
<div class="thread-title">
{{ message.threadId == null ? 'New Thread' : `Thread ${message.threadId}` }}
@@ -189,14 +320,6 @@ watch(() => [props.message.format, props.message.content], ([newFormat, newConte
margin-right: 2em;
}
.message .content {
white-space: preserve;
}
.message ul, .message ol {
white-space: normal;
}
.message .sender,
.note .sender {
font-weight: bold;
@@ -223,6 +346,67 @@ watch(() => [props.message.format, props.message.content], ([newFormat, newConte
user-select: none;
}
/* === Log groups === */
/* Completely transparent container; stacks icons above the preview and
stretches to the full width of the chat */
.log-group {
align-self: stretch;
width: 100%;
padding: 0.6em 1.4em;
background: transparent;
display: flex;
flex-direction: column;
gap: 0.4em;
}
/* Square buttons (sized by the global `button.icon`) that wrap to the width,
each line centered within the container */
.log-icons {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.3em;
}
.log-icons .highlight {
color: var(--color-highlight);
border-color: var(--color-highlight);
}
.log-preview {
font-family: monospace;
font-size: 80%;
max-width: 70em;
}
.log-preview .log-sender {
font-weight: bold;
}
.log-preview .content {
white-space: pre-wrap;
word-wrap: anywhere;
width: fit-content;
max-width: 70em;
line-height: 1.3;
}
/* Clipping shows only the bottom lines: the content is bottom-aligned, so any
vertical overflow is clipped off the top. ~6 lines at line-height 1.3. */
.log-preview .content.clipped {
display: flex;
flex-direction: column;
justify-content: flex-end;
max-height: 7.8em;
overflow: hidden;
}
.log-preview .log-toggle {
cursor: pointer;
user-select: none;
}
.resource {
border-radius: var(--border-radius);
border: 1px dashed var(--color-border);
@@ -288,4 +472,38 @@ watch(() => [props.message.format, props.message.content], ([newFormat, newConte
font-style: normal;
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>
+1 -1
View File
@@ -23,8 +23,8 @@
<style scoped>
:slotted(h1) {
font-size: 160%;
font-weight: bold;
padding: 0 0.4em;
margin: 0;
}
.main-header {
+138 -57
View File
@@ -10,6 +10,8 @@
import * as backend from '../backend';
import * as ui from '../ui';
import * as iconoir from '@iconoir/vue';
const props = defineProps({
id: {
type: String,
@@ -32,8 +34,7 @@
});
// UI State
const addingTool = ref(null);
const addingResource = ref(null);
const targetResource = ref(null);
// === State Management === //
@@ -92,8 +93,7 @@
// Replace with a backend call
backend.sendAction('get-agent-resources', {agentname}).then(res => {
tools.value = res.resources.filter(r => r.kind == 'tool');
resources.value = res.resources.filter(r => !['tool', 'config'].includes(r.kind));
resources.value = res.resources;
});
}
@@ -157,14 +157,35 @@
}
promptHistory.value.push(historyEntry);
let res = await backend.sendAction("send-agent-prompt", payload);
if (res.error) {
historyEntry.error = res.error;
} else {
historyEntry.output = res.response;
// prompt-agent is a multiple-result action (like read-stream): one packet
// per generated inference.Message, followed by a terminal eof packet. The
// callback runs once per packet. Assistant/reasoning content is an open
// 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) {
@@ -237,34 +258,66 @@
});
}
async function saveAddingTool() {
let resource = {
kind: 'tool',
key: addingTool.value.name,
value: JSON.parse(addingTool.value.value),
};
addingTool.value = null;
async function updateResourceKey () {
let newKey = await ui.input("Enter new resource key");
if (!newKey || newKey == targetResource.value.key) return;
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,
...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() {
let resource = {
kind: addingResource.value.kind,
key: addingResource.value.key,
value: JSON.parse(addingResource.value.value),
};
addingResource.value = null;
async function saveResource () {
let res = await backend.sendAction("save-agent-resource", {
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>
@@ -327,36 +380,31 @@
<JsonEditor :schema="config.schema" v-model="config.object" />
<button type="submit" @click="saveConfig()">Save</button>
</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>
<h3>Resources</h3>
<div v-for="resource in resources">
<div class="label">{{ resource.kind }}:{{ resource.key }}</div>
<pre>{{ JSON.stringify(resource.value, null, 2) }}</pre>
<div class="row">
<section class="resource-detail column" v-if="targetResource">
<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>
<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>
</main>
</template>
@@ -398,4 +446,37 @@
resize: vertical;
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>
+39 -3
View File
@@ -1,6 +1,6 @@
<script setup>
import {
ref, watch,
ref, computed, watch,
onMounted, onUnmounted,
} from "vue";
@@ -30,6 +30,29 @@
creatingThread,
} = session;
// Consecutive log messages collapse into a single "log-group" entry, which
// Message.vue renders as a container of icons plus a preview.
const displayEntries = computed(() => {
const entries = chatEntries.value || [];
const out = [];
let group = null;
for (const entry of entries) {
if (entry.type == 'log') {
if (!group) {
group = { id: 'log-group-' + entry.id, type: 'log-group', logs: [] };
out.push(group);
}
group.logs.push(entry);
} else {
group = null;
out.push(entry);
}
}
return out;
});
// UI state coupled to the document itself
// Message ids of logs currently expanded
@@ -130,6 +153,12 @@
{value: 'pin', title: "Pin message"},
{value: 'edit', title: "Edit in-place"},
{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
@@ -164,6 +193,12 @@
}
break;
}
default: {
if (selected?.startsWith('format:')) {
session.setMessageFormat(message, selected.slice('format:'.length));
}
break;
}
}
}
@@ -204,7 +239,7 @@
<div class="row" style="overflow: auto;">
<div class="column">
<div class="messages-container" ref="chatContainerElem">
<div class="message-container" v-for="msg in chatEntries">
<div class="message-container" v-for="msg in displayEntries">
<Message
:key="msg.id"
:message="msg"
@@ -213,6 +248,7 @@
:log-expanded="expandedLogs.has(msg.id)"
@toggle-log="expandedLogs.toggle(msg.id)"
:expanded-logs="expandedLogs"
:cancellable="messageRunMap[msg.id]"
@cancel="session.cancelMessageRun(msg.id)"
@@ -341,4 +377,4 @@
height: 4em;
cursor: pointer;
}
</style>
</style>