Compare commits

..
13 Commits
21 changed files with 1148 additions and 469 deletions

No files matched your search

+87 -59
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,8 +35,9 @@ 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:
@@ -45,6 +45,15 @@ class Agent:
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):
+28 -46
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
if len(messages) == pre_len: break
cancel_tools = False
for tc in tool_calls: for tc in tool_calls:
tool = next((t for t in tools if t.name == tc.tool_name), None) tool = next((t for t in tools if t.name == tc.tool_name), None)
if tool is None: if tool is None:
print(f"[agent] unknown tool name: {tc.tool_name}") print(f"[agent] unknown tool name: {tc.tool_name}")
continue 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) tc.result = await tool.execute_async(**tc.arguments)
messages.append(inference.LlamaMessage('tool', tc)) else:
tool_calls.clear() tc.tool = tool
# Buffer time to let the provider rest before the next call
await asyncio.sleep(2) # If not executing tools, respond to end the turn
elif content or not reasoning: if not (tool_calls and execute_tools) and turn_response or cancel_tools: break
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", ""),
+2 -1
View File
@@ -47,7 +47,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) ---
+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,
} }
+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) drive = await db.Drive.create(user_id=user_id, name=drive_name)
# Check collision # 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") raise Exception("Duplicated file")
# 2. Prepare disk path # 2. Prepare disk path
@@ -82,7 +82,7 @@ async def delete_file(file_id):
return abs_path return abs_path
async def resolve_file(user_id, drive_name, filename): 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 # Eager load drive and verify ownership
drive = await db.Drive.get_or_none(user_id=user_id, name=drive_name) drive = await db.Drive.get_or_none(user_id=user_id, name=drive_name)
if drive.user_id != user_id: 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"]
+201 -164
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,47 +205,38 @@ 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 worker():
async def message_loop(name, value=None): stream = None
nonlocal content_stream, agent_run try:
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) llm = get_llama_cpp_model(app, model)
kwargs = {} kwargs = {}
@@ -205,56 +250,59 @@ async def local_chat_completion(app, messages, model, tools=[], opts={}, agent_r
kwargs['repeat_penalty'] = repeat kwargs['repeat_penalty'] = repeat
# Format tools for llama-cpp (OpenAI-compatible function format) # Format tools for llama-cpp (OpenAI-compatible function format)
formatted_tools = format_tools_for_engine(tools, "llama-cpp") if tools else None formatted_tools = format_tools(tools) if tools else None
if formatted_tools: if formatted_tools:
kwargs['tools'] = formatted_tools kwargs['tools'] = formatted_tools
kwargs['tool_choice'] = 'auto' kwargs['tool_choice'] = 'auto'
# With stream=True, returns an iterator that infers tokens on demand (openai compatible) # With stream=True, returns an iterator that infers tokens on demand (openai compatible)
llm_output = llm.create_chat_completion( iterator = await loop.run_in_executor(None, lambda: llm.create_chat_completion(
messages=[msg.to_dict() for msg in messages], messages=[msg.to_dict() for msg in messages],
stream=True, stream=True,
**kwargs **kwargs
) ))
for chunk in llm_output: sentinel = object()
# Check for cancellation while True:
if agent_run and agent_run.cancelled: chunk = await loop.run_in_executor(None, next, iterator, sentinel)
print(f"Cancelling local inference for run {agent_run.id}") if chunk is sentinel:
break break
delta = chunk['choices'][0]['delta'] delta = chunk['choices'][0]['delta']
if 'content' in delta: if 'content' in delta:
send_message('output', delta['content']) if stream is None:
# Handle tool_call deltas if the model emits them stream = Stream(app)
emit(Message('assistant', stream))
stream.feed(delta['content'])
if 'tool_calls' in delta: if 'tool_calls' in delta:
# Forward tool call chunks as JSON strings for the consumer for tc in delta['tool_calls']:
send_message("tool-call", { emit(Message('tool', tc))
"id": tc.get("id"), except Exception:
"function": tc.get("function"), print(traceback.format_exc())
}) finally:
if stream: stream.close()
emit(None)
send_message('close') spawn(worker())
thread = threading.Thread(target=worker) while True:
if agent_run: message = await queue.get()
agent_run.thread = thread if message is None:
thread.start() break
yield message
if agent_run:
return None
return content_stream
async def openrouter_chat_completion(app, messages, model, tools=[], opts={}, agent_run=None): async def openrouter_chat_completion(app, messages, model, tools=[], opts={}):
if not agent_run: if not app.openrouter_client:
raise Exception("agent_run required") raise Exception("OPENROUTER_API_KEY environment variable is not set")
# TODO: Separate as InferenceRun class queue = asyncio.Queue()
async def worker():
content_stream = None content_stream = None
reasoning_stream = None reasoning_stream = None
last_channel = None last_channel = None
def open_channel(channel): def open_channel(channel):
nonlocal last_channel, content_stream, reasoning_stream nonlocal last_channel, content_stream, reasoning_stream
if channel != last_channel: if channel != last_channel:
@@ -264,33 +312,7 @@ async def openrouter_chat_completion(app, messages, model, tools=[], opts={}, ag
reasoning_stream = None reasoning_stream = None
last_channel = channel last_channel = channel
try:
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():
print("START OPENROUTER INFERENCE")
if not app.openrouter_client:
raise Exception("OPENROUTER_API_KEY environment variable is not set")
kwargs = { kwargs = {
'model': model, 'model': model,
'messages': format_openai(messages), 'messages': format_openai(messages),
@@ -301,15 +323,15 @@ async def openrouter_chat_completion(app, messages, model, tools=[], opts={}, ag
} }
# Pass tools in OpenAI-compatible format if provided # Pass tools in OpenAI-compatible format if provided
formatted_tools = format_tools_for_engine(tools, "openrouter") if tools else None formatted_tools = format_tools(tools) if tools else None
if formatted_tools: if formatted_tools:
kwargs['tools'] = formatted_tools kwargs['tools'] = formatted_tools
temp = agent_run.input.get("temperature") temp = opts.get("temperature")
if temp is not None: if temp is not None:
kwargs['temperature'] = temp kwargs['temperature'] = temp
reasoning = agent_run.input.get("reasoning_effort") reasoning = opts.get("reasoning_effort")
if reasoning is not None: if reasoning is not None:
kwargs['reasoning'] = {"effort": reasoning} kwargs['reasoning'] = {"effort": reasoning}
@@ -320,19 +342,22 @@ async def openrouter_chat_completion(app, messages, model, tools=[], opts={}, ag
tool_calls = [] tool_calls = []
async for event in stream_response: 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: if event.choices and len(event.choices) > 0:
delta = event.choices[0].delta delta = event.choices[0].delta
if delta.content:
emit('output', delta.content)
if delta.reasoning: if delta.reasoning:
emit('reasoning', 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: if delta.tool_calls:
print("[openrouter] tool call:" + repr(delta.tool_calls))
for tc in delta.tool_calls: for tc in delta.tool_calls:
if tc.index >= len(tool_calls): if tc.index >= len(tool_calls):
tool_calls.append(ToolCall(tool_name="", arguments="", id="")) tool_calls.append(ToolCall(tool_name="", arguments="", id=""))
@@ -346,36 +371,40 @@ async def openrouter_chat_completion(app, messages, model, tools=[], opts={}, ag
tool_call.tool_name += tc.function.name tool_call.tool_name += tc.function.name
if tc.function.arguments: if tc.function.arguments:
tool_call.arguments += tc.function.arguments tool_call.arguments += tc.function.arguments
else: if event.usage:
print("[openrouter] no event choices: " + repr(event)) 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: for tc in tool_calls:
try: try:
arguments = json.loads(tc.arguments) tc.arguments = json.loads(tc.arguments)
tc.arguments = arguments queue.put_nowait(Message('tool', tc))
emit('tool-call', tc)
except Exception: except Exception:
print("[openrouter] Bad tool call arguments for " + repr(tc)) print("[openrouter] Bad tool call arguments for " + repr(tc))
print(traceback.format_exc()) print(traceback.format_exc())
except Exception:
emit('close')
print("END OPENROUTER INFERENCE")
def handle_done(task):
try:
task.result()
except asyncio.CancelledError:
print("[openrouter] inference cancelled")
except Exception as e:
print(f"[openrouter] Worker error: {e}")
print(traceback.format_exc()) print(traceback.format_exc())
finally:
queue.put_nowait(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 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():
stream = Stream(app)
emit(Message('assistant', stream))
try:
await asyncio.sleep(DELAY) await asyncio.sleep(DELAY)
content_stream.feed(random.choice(VERBS)) stream.feed(random.choice(VERBS))
await asyncio.sleep(DELAY) await asyncio.sleep(DELAY)
content_stream.feed(f" {random.randint(1, 99)} ") stream.feed(f" {random.randint(1, 99)} ")
await asyncio.sleep(DELAY) await asyncio.sleep(DELAY)
content_stream.feed(random.choice(VERBS)) stream.feed(random.choice(VERBS))
await asyncio.sleep(DELAY) await asyncio.sleep(DELAY)
content_stream.close() finally:
agent_run.emit_event('inference-end', {}) 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
+94 -61
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_eof(): async def on_reasoning(chunk):
# on_done doesn't work?
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)) await queue.put(('reasoning', chunk))
async def on_eof(): pass def on_eof(): pass
stream.listen(on_chunk, on_eof)
def on_tool_call(tool_call): async def drain():
deferred = route_tool_call(tool_call, tool_names) 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' msg_type = 'deferred_call' if deferred else 'tool_call'
loop.create_task(queue.put((msg_type, 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))
def on_done(output): loop.create_task(drain())
loop.create_task(queue.put(('eof', None))) return queue, state
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)
continue if entry is not None:
calls.append(entry['call']) calls.append(entry['call'])
continue
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,
+72 -32
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:
try:
msg = await send_queue.get() msg = await send_queue.get()
await websocket.send(encode_json(msg)) 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,13 +68,54 @@ 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()
))
finally:
print("User logged out:", user_id)
sender_task.cancel()
if ws_task in app.ws_tasks:
app.ws_tasks.remove(ws_task)
# Unregister the event listener: the send closure would
# otherwise keep queueing events for a dead socket forever
if stop_listening:
stop_listening()
# All remotes served by this session are disconnected with it.
# disconnectRemote is idempotent, so remotes that were already
# 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)
# 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'}) await send({'error': 'no action'})
continue return
action = req['action'] action = req['action']
body = req.get('body', {}) body = req.get('body', {})
nonce = req.get('nonce', None) nonce = req.get('nonce', None)
print(f"[ws] req @{user_id} {action}")
async def agent_id_from_body (allow_none=False): async def agent_id_from_body (allow_none=False):
if 'agentId' in body: if 'agentId' in body:
@@ -84,7 +128,7 @@ def register(server, app, auth_manager):
raise Exception("field 'agentId' or 'agentname' required") raise Exception("field 'agentId' or 'agentname' required")
if action == "ping": if action == "ping":
await send({'message': "pong"}) await send({'message': "pong", 'nonce': nonce})
elif action == "create-agent": elif action == "create-agent":
name = body['name'] name = body['name']
@@ -150,6 +194,10 @@ def register(server, app, auth_manager):
await app.save_agent_resource(await agent_id_from_body(), body['kind'], body['key'], body['value']) await app.save_agent_resource(await agent_id_from_body(), body['kind'], body['key'], body['value'])
await send({'message': "resource saved", 'nonce': nonce}) 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": elif action == "get-agent-runs":
await send({ await send({
'runs': await app.get_agent_runs(agent_id=await agent_id_from_body(allow_none=True), user_id=user_id), 'runs': await app.get_agent_runs(agent_id=await agent_id_from_body(allow_none=True), user_id=user_id),
@@ -268,7 +316,7 @@ def register(server, app, auth_manager):
elif action == "update-chat-message": elif action == "update-chat-message":
message = await app.update_chat_message( message = await app.update_chat_message(
message_id=body['messageId'], message_id=body['messageId'],
content=body['content'], content=body.get('content'),
kind=body.get('kind'), kind=body.get('kind'),
subkind=body.get('subkind'), subkind=body.get('subkind'),
format=body.get('format'), format=body.get('format'),
@@ -289,23 +337,30 @@ def register(server, app, auth_manager):
) )
await send({'message': "chat thread created", 'threadId': thread_id, 'nonce': nonce}) await send({'message': "chat thread created", 'threadId': thread_id, 'nonce': nonce})
elif action == "send-agent-prompt": elif action == "prompt-agent":
if 'instruction' not in body and 'input' not in body: if 'instruction' not in body and 'input' not in body:
await send({'error': "instruction or input required"}) await send({'error': "instruction or input required", 'nonce': nonce})
continue return
response = await app.send_agent_prompt( # 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(), await agent_id_from_body(),
body.get('instruction'), body.get('instruction'),
body.get('input'), body.get('input'),
body.get('options', {}) body.get('options', {})
) )
if isinstance(response, Stream): try:
if body.get('stream'): async for msg in generator:
response = {"streamId": response.id} await send({'message': 'agent-message', 'data': msg, 'nonce': nonce})
else: finally:
response = await response.content_wait() # Disconnect / cancellation point: closes the run task.
await send({'message': "prompt sent", 'response': response, 'nonce': nonce}) await generator.aclose()
await send({'eof': True, 'nonce': nonce})
elif action == "read-stream": elif action == "read-stream":
stream_id = body['streamId'] stream_id = body['streamId']
@@ -322,18 +377,3 @@ def register(server, app, auth_manager):
else: else:
await send({'error': 'unknown action: ' + action, 'nonce': nonce}) await send({'error': 'unknown action: ' + action, 'nonce': nonce})
finally:
print("User logged out:", user_id)
sender_task.cancel()
if ws_task in app.ws_tasks:
app.ws_tasks.remove(ws_task)
# Unregister the event listener: the send closure would
# otherwise keep queueing events for a dead socket forever
if stop_listening:
stop_listening()
# All remotes served by this session are disconnected with it.
# disconnectRemote is idempotent, so remotes that were already
# 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)
+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)
+13 -11
View File
@@ -1,27 +1,29 @@
# Drives # 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 ## 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. - **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. - **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. - **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 ## Backend interface
Download links can't set headers, so the GET/DELETE file routes additionally accept the token as a `?token=` query parameter. 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 | | Route | Behavior |
| --- | --- | | --- | --- |
| `GET /files` | List the user's drives: `[{id, name}]` | | `GET /files` | List the user's drives: `[{id, name}]`. Not implemented in `drives.py`. |
| `GET /files/<drive>` | List files: `[{id, name, size, mime, created}]` | | `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}`. | | `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. | | `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 `{fileId}`. | | `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 ## 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. - `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)` - `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. - `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 ## 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. - 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. - 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 ## 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. - 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. - 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
@@ -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;
}
+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, // Drive endpoints are plain HTTP routes instead of WebSocket actions,
// so they authenticate with the bearer token in the Authorization header. // 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 () { export function getDrives () {
return webFetch('/files'); return webFetch('/files');
} }
@@ -387,10 +389,10 @@ export function getDriveFiles (driveName) {
return webFetch(`/files/${driveName}`); return webFetch(`/files/${driveName}`);
} }
export function uploadDriveFile (driveName, file) { export function uploadDriveFile (driveName, file, path) {
const body = new FormData(); const body = new FormData();
body.append('file', file, file.name); body.append('file', file, file.name);
return webFetch(`/files/${driveName}`, { return webFetch(`/files/${driveName}/${path}`, {
method: 'POST', method: 'POST',
body, body,
}); });
+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 = {};
+232 -14
View File
@@ -2,15 +2,20 @@
import { ref, computed, watch } from 'vue'; import { ref, computed, watch } from 'vue';
import { marked } from "marked"; import { marked } from "marked";
import * as backend from '../backend'; import * as backend from '../backend';
import * as iconoir from '@iconoir/vue';
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>`;
} }
} }
@@ -29,6 +34,12 @@ const props = defineProps({
logExpanded: { logExpanded: {
type: Boolean, type: Boolean,
required: false, 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')]; return ['resource', 'resource-' + (msg.subkind || 'generic')];
} else if (msg.type == 'log') { } else if (msg.type == 'log') {
return ['log']; return ['log'];
} else if (msg.type == 'log-group') {
return ['log-group'];
} else if (msg.type == 'thread-header') { } else if (msg.type == 'thread-header') {
let cls = ['thread']; let cls = ['thread'];
if (!msg.active) cls.push(msg.isFork ? 'thread-fork' : 'thread-closed') 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}) }, {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> </script>
<template> <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"> <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>
@@ -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> <a v-if="logLong" class="log-toggle" @click.prevent="$emit('toggle-log')">{{logExpanded ? '(collapse)' : '(expand)'}}</a>
</template> </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'"> <template v-if="message.type == 'thread-header'">
<div class="thread-title"> <div class="thread-title">
{{ message.threadId == null ? 'New Thread' : `Thread ${message.threadId}` }} {{ message.threadId == null ? 'New Thread' : `Thread ${message.threadId}` }}
@@ -189,14 +320,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;
@@ -223,6 +346,67 @@ watch(() => [props.message.format, props.message.content], ([newFormat, newConte
user-select: none; 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 { .resource {
border-radius: var(--border-radius); border-radius: var(--border-radius);
border: 1px dashed var(--color-border); border: 1px dashed var(--color-border);
@@ -288,4 +472,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 {
+136 -55
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,15 +157,36 @@
} }
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
// 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 { } else {
historyEntry.output = res.response; 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) {
if (event.key == "Enter" && event.ctrlKey) { if (event.key == "Enter" && event.ctrlKey) {
@@ -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
agentId: agent.value.id, for (let r of resources.value) {
...resource if (r.kind == targetResource.value.kind && r.key == newKey) {
}); ui.alert(`Conflict: ${targetResource.value.kind} '${newKey}'`);
tools.value.push(resource); return;
}
} }
async function saveAddingResource() { // Keys can't be updated directly, we move the resource
let resource = { await backend.sendAction("delete-agent-resource", {
kind: addingResource.value.kind, agentId: agent.value.id,
key: addingResource.value.key, ...targetResource.value
value: JSON.parse(addingResource.value.value), });
};
addingResource.value = null;
targetResource.value.key = newKey;
await backend.sendAction("save-agent-resource", {
agentId: agent.value.id,
...targetResource.value,
});
}
async function saveResource () {
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> </div>
<section v-if="addingResource"> <JsonEditor :schema="{type:'object'}" v-model="targetResource.value" />
<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> </section>
<div v-else><button @click="addingResource={}">Add Resource</button></div> <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> </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>
+38 -2
View File
@@ -1,6 +1,6 @@
<script setup> <script setup>
import { import {
ref, watch, ref, computed, watch,
onMounted, onUnmounted, onMounted, onUnmounted,
} from "vue"; } from "vue";
@@ -30,6 +30,29 @@
creatingThread, creatingThread,
} = session; } = 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 // UI state coupled to the document itself
// Message ids of logs currently expanded // Message ids of logs currently expanded
@@ -130,6 +153,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 +193,12 @@
} }
break; 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="row" style="overflow: auto;">
<div class="column"> <div class="column">
<div class="messages-container" ref="chatContainerElem"> <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 <Message
:key="msg.id" :key="msg.id"
:message="msg" :message="msg"
@@ -213,6 +248,7 @@
:log-expanded="expandedLogs.has(msg.id)" :log-expanded="expandedLogs.has(msg.id)"
@toggle-log="expandedLogs.toggle(msg.id)" @toggle-log="expandedLogs.toggle(msg.id)"
:expanded-logs="expandedLogs"
:cancellable="messageRunMap[msg.id]" :cancellable="messageRunMap[msg.id]"
@cancel="session.cancelMessageRun(msg.id)" @cancel="session.cancelMessageRun(msg.id)"