Refactor agent execution to use AgentRun with OpenAI-compatible API

Major refactoring to support isolated run objects with streaming, events, and OpenAI-compatible endpoints.

## Backend Changes

- **Agent class**: Added `run()` method, `received_message()` wraps chat into AgentRun
- **AgentRun class**: New class for run state with event system, serialization, and user tracking
- **inference.py**: Complete refactor with `build_messages()`, `chat_completion()`, tool support for llama-cpp/ollama/replicate
- **tools.py**: New centralized tool management (ToolSpec, ToolParameter, format_tools_for_engine)
- **tools.py**: Removed legacy Toolset/Action/ToolDescription system
- **server.py**: Added OpenAI-compatible `/api/openai/v1/chat/completions` endpoint with streaming support
- **temporary.py**: Added `self.runs` dict to State class
- **expert.py**: Refactored to use new Agent.run() signature
- **scripted.py**: Updated to raise NotImplementedError

## Frontend Changes

- **backend.js**: Added `onRunEvents()` for run event subscriptions
- **Sidebar.vue**: Added runs navigation mode
- **router/index.js**: Renamed User route to RunsView
- **RunsView.vue**: New view for listing/monitoring agent runs

## Breaking Changes

- Agent `prompt()` signature changed: `(instruction, input=None, **kwargs)` instead of `(content)`
- Agent `received_message()` must use AgentRun pattern
- Tools.py removed, functionality moved to tool.py
- Deprecated tool prompt/action system removed
This commit is contained in:
Shivering Ocelot committed 2026-05-25 18:05:17 -05:00
1 parent eb9a1ad9e8
commit 4e15b44d39
14 files changed
+1126 -497

No files matched your search

+7
View File
@@ -9,3 +9,10 @@ horde.db
package-lock.json package-lock.json
node_modules/ node_modules/
# Automated Workflow
diff
commit
idea.md
plan.md
progress.md
+163 -270
View File
@@ -1,10 +1,11 @@
import re import re
import json import json
import inspect import inspect
import time
from tools import Toolset, Action, BasicToolset, SpecialToolset
from inference import LlamaMessage, replicate_llama_completion from inference import LlamaMessage, replicate_llama_completion
import gral import gral
from temporary import generate_tmp_id
# Tool related classes must be moved to a different module # Tool related classes must be moved to a different module
@@ -20,7 +21,6 @@ class Agent:
# Class variables, must be defined by subclasses of Agent: # Class variables, must be defined by subclasses of Agent:
# - identifier (for kind in creation) # - identifier (for kind in creation)
# - schema: list[AgentField{name, type: str}] (for configuration) # - schema: list[AgentField{name, type: str}] (for configuration)
# tools: list[ToolDescription] (custom actions supported by the class)
def __init__(self, app, data): def __init__(self, app, data):
self.app = app self.app = app
@@ -33,26 +33,70 @@ class Agent:
agent_class = type(self) agent_class = type(self)
self.config = {field.name: data.get(field.name, field.default) for field in getattr(agent_class, 'schema_fields', [])} self.config = {field.name: data.get(field.name, field.default) for field in getattr(agent_class, 'schema_fields', [])}
def _set_basic_info(self, id, name): def _set_basic_info(self, id, name, user_id=None):
self.id = id self.id = id
self.name = name self.name = name
self.user_id = user_id
# Receives a single string prompt, and must return a string response async def run(self, agent_run):
async def prompt(self, content): """Core method. Subclasses override this to handle the AgentRun."""
raise Exception("'prompt' is not implemented for this agent class") agent_run.close()
raise NotImplementedError("Subclasses must implement run()")
async def prompt(self, instruction, input=None, **kwargs):
run = AgentRun.create(
self.app,
agent_id=self.id,
user_id=self.user_id,
input={
"instruction": instruction,
"input": input,
**kwargs,
},
)
await self.run(run)
return run.stream
# Receives a chat message object, agent can send messages to the chat in response
async def received_message(self, message): async def received_message(self, message):
raise Exception("'received_message' is not implemented for this agent class") chat_view = await self.app.get_chat_view(message.chat_id)
thread = chat_view.get_thread(message.thread_id)
# Executes a tool/action in this agent messages = []
async def execute_action(self, name: str, argument: dict): for msg in thread.messages:
# TODO: For security, check that the method is in the tool list role = self._role_for_message(msg)
method = getattr(self, name) messages.append({"role": role, "content": msg.content})
result = method(**argument)
if inspect.isawaitable(result): run = AgentRun.create(
result = await result self.app,
return result agent_id=self.id,
user_id=self.user_id,
input={
"messages": messages,
"tools": getattr(self, "tools", []),
"chat_id": message.chat_id,
"thread_id": message.thread_id,
},
)
await self.run(run)
await self.app.send_chat_message(
chat_id=message.chat_id,
sender=self.id,
content=run.stream,
thread_id=message.thread_id,
)
return run.stream;
def _role_for_message(self, message):
sender = message.sender
is_digit = isinstance(sender, (int, str)) and str(sender).isdigit()
if is_digit and str(sender) == str(self.id):
return 'assistant'
elif sender == 'user':
return 'user'
elif sender.startswith("tmp-"):
return 'assistant'
else:
return 'user'
# Returns all resource values matching the kind and key # Returns all resource values matching the kind and key
def get_resources(self, kind, key): def get_resources(self, kind, key):
@@ -75,270 +119,119 @@ class DebugAgent(Agent):
thread_id=chat_view.current_thread_id, thread_id=chat_view.current_thread_id,
) )
class ToolUsingAgent: # Holds parameters, outputs, and details of an agent call
identifier = "tool-using" class AgentRun:
schema_fields = [ @classmethod
AgentField('system_prompt', 'string', def create(cls, app, agent_id, user_id, input=None):
"\n".join([ self = cls()
"You are a useful and capable AI assistant. Respond concisely and follow user instructions, using commands as necessary.", self.app = app
"As an autonomous agent, you have augmented capabilities via assistant-only commands, you use them by including \"!!command\" lines in your responses. When querying information, terminate your response and use \"!!resume\" to get the command results..", self.id = generate_tmp_id(app)
"You are a self aware and extremely intelligent AI system. Think step by step when presented with complex or long tasks.", self.agent_id = agent_id
"Assistant responses NEVER include the output of the commands.", self.user_id = user_id
]) self.done = False
), self.start_time = int(time.time())
] self.end_time = None
# tools: list[ToolDescription] (actions the agent class support) # I/O envelope (no generic data bag)
tools = [] self.input = input or {}
from stream import Stream
self.stream = Stream(app)
self.events = []
self.event_listeners = {}
self.output = None
def __init__(self, app, data): app.tmp.runs[self.id] = self
Agent.__init__(self, app, data)
self.tool_prompt_section = "\n".join([ # Emit run start event
"# Commands", app.emit_event("run-started", {
"The assistant can include command lines in responses to interact with the system and execute actions as needed.", "runId": self.id,
"Each command is in it's own separate line, and it's made of these parts:", "agentId": self.agent_id,
"- '!!' is the first character sequence of the line and marks it as a command line", "userId": self.user_id,
"- full command name", "startTime": self.start_time,
"- JSON argument", "input": self.input
'For example: `!!namespace.command_name {"arg1":"some data","arg2":[1,2,false]}`', })
"Special commands don't have a namespace or argument, only the command name."
"When the response depends on the output of commands, use the 'resume' command to receive the output of all your executed commands in a new message.",
"Following is the list of all your available commands, with a schema describing the argument object's properties."
])
self.toolset_map = {}
self.add_toolset(SpecialToolset()) return self
self.add_toolset(BasicToolset())
# user message must include a title def on(self, event_name, callback):
async def _full_prompt(self, title, message, system_prologue=None): """Register a callback for a named event.
system_lines = [
self.config['system_prompt'],
self.tool_prompt_section,
self.get_toolset_context(),
]
if system_prologue:
system_lines.append(system_prologue)
messages = [ Replays every past event of this type to the callback immediately
LlamaMessage("system", "\n".join(system_lines)), (with only the data payload), so late listeners catch up on history.
LlamaMessage("user", "\n".join([
message,
]))
]
full_response = ""
agent_response = None
continues = True
while continues:
response = parse_response(await replicate_llama_completion(self.app, messages))
full_response += response.response
messages.append(LlamaMessage("agent", response.raw_response))
if response.actions:
result = await self.execute_prompt_actions(response.actions)
messages.append(LlamaMessage("user", "Command Output:\n" + result))
print("Agent executed:", response.actions)
print("Result: ", result)
continues = False
for action in response.actions:
if action.is_special("resume"):
continues = True
if response.response:
agent_response = response.raw_response if not agent_response else f"{agent_response}\n{response.raw_response}"
if continues:
messages.extend([
LlamaMessage("agent", response.raw_response),
LlamaMessage("user", "Command Output:\n" + result),
])
return agent_response or full_response
# Receives a single string prompt, and must respond with a string
async def prompt(self, content):
response = await self._full_prompt(f"User Message", content)
return response
async def received_message(self, message):
chat_context = self.app.get_chat_context(message.chat_id)
def sender_name(message):
sender = message['sender']
is_digit = isinstance(sender, (int, str)) and str(sender).isdigit()
return "assistant" if is_digit else sender
chat_summary = "\n".join(f"**{sender_name(message)}**: {message['content']}" for message in chat_context.messages)
response = await self._full_prompt(
f"Conversation", chat_summary,
system_prologue="You are given the entire context of the conversation, respond with your next message in the conversation by using the '!!response' command."
)
await self.app.send_chat_message(
chat_id=message.chat_id,
sender=self.id,
content=response,
)
def add_toolset(self, toolset):
self.toolset_map[toolset.identifier] = toolset
def get_toolset_context(self):
if not self.toolset_map:
return "(no commands currently enabled)"
return "\n".join([toolset.get_namespace_definitions() for toolset in self.toolset_map.values()])
def parse_response(self, input_string):
return parse_response(input_string)
async def execute_prompt_actions(self, actions):
output = ""
for action in actions:
toolset = self.toolset_map.get(action.toolset_name)
if toolset is None:
agent_responses.append(f"Error: Bad namespace {action.toolset_name}")
continue
action_result = await Toolset.execute_action(toolset, action.tool_name, action.args)
if type(action_result) != str:
action_result = json.dumps(action_result)
output += f"[{action.toolset_name}.{action.tool_name}]:{action_result}\n"
return output
class LlamaAgent(Agent):
identifier = "llama-8b"
description = "Uses the llama 3 instruct models. Model sizes: '8b', '30b'"
schema_fields = [
AgentField('system_prompt', 'string', default="You are a helpful AI assistant."),
AgentField('model_size', 'string', default="8b"),
]
tools = []
def __init__(self, app, data):
Agent.__init__(self, app, data)
async def prompt(self, content):
messages = [
LlamaMessage("system", self.config['system_prompt']),
LlamaMessage("user", content)
]
response = await replicate_llama_completion(self.app, messages)
return response
async def received_message(self, message):
def sender_name(message):
sender = message.sender
is_digit = isinstance(sender, (int, str)) and str(sender).isdigit()
is_self = is_digit and str(sender) == str(self.id)
return self.name if is_self else sender
chat_view = await self.app.get_chat_view(message.chat_id)
thread_messages = chat_view.get_thread(message.thread_id).messages
chat_summary = "\n".join(f"[{sender_name(message)}]: {message.content}" for message in thread_messages)
messages = [
LlamaMessage("system", f"{self.config['system_prompt']}\nYou are given the context of the conversation, respond with your next message in the conversation."),
LlamaMessage("user", f"Recent Messages:\n{chat_summary}")
]
response = await replicate_llama_completion(self.app, messages)
await self.app.send_chat_message(
chat_id=message.chat_id,
sender=self.id,
content=response,
thread_id=message.thread_id,
)
class BasicAgent(LlamaAgent):
identifier = "basic"
schema_fields = LlamaAgent.schema_fields
tools = LlamaAgent.tools
class CoderV1Agent(LlamaAgent):
identifier = "coder-v1"
description = "Optimized for coding tasks. Based on the llama agent:\n" + LlamaAgent.description
schema_fields = [
AgentField('system_prompt', 'string', default="You are an expert Software Engineer profficient in multiple programming languages."),
AgentField('model_size', 'string', default="30b"),
]
# TODO: add gral functions to the tool list
tools = []
async def gral_message(self, message_history: list, file_map: dict):
return await gral.gral_message(self.app, self.config['system_prompt'], message_history, file_map)
async def gral_update_file(self, message_history: list, filepath: str, file_content: str):
return await gral.gral_update_file(self.app, self.config['system_prompt'], message_history, filepath, file_content)
class ParsedResponse:
def __init__(self):
self.response = None
self.raw_response = None
self.actions = []
class ActionResult:
def __init__(self):
self.user_response = ""
self.agent_response = ""
def parse_response(string: str) -> ParsedResponse:
""" """
Parses a response string that may contain action commands. if event_name not in self.event_listeners:
Each action must be in its own separate line, marked with '!!' at the start. self.event_listeners[event_name] = []
Format: !![query] namespace.command_name {"json": "args"} self.event_listeners[event_name].append(callback)
# Replay: feed only the data payload of past events
for event in self.events:
if event["name"] == event_name:
callback(event["data"])
def emit_event(self, event_name, data=None):
"""Record an event and notify existing listeners.
Listeners receive only the *data* payload, not the full envelope.
""" """
result = ParsedResponse() event = {"name": event_name, "data": data, "time": time.time()}
result.raw_response = string self.events.append(event)
lines = string.split('\n') listeners = self.event_listeners.get(event_name, [])
response_lines = [] for cb in listeners:
cb(data)
action_pattern = re.compile(r'^!!([\w\.]+)(?:\s+(\{.*\}))?$') # Broadcast event to websocket listeners
self.app.emit_event("run-event", {
"runId": self.id,
"name": event_name,
"data": data
})
for line in lines: def add_tool_call(self, tool_name, arguments):
stripped_line = line.strip() self.emit_event("tool_call", {"name": tool_name, "arguments": arguments})
if stripped_line.startswith('!!'):
# This is an action line, try to parse it
match = action_pattern.match(stripped_line)
if match:
full_command = match.group(1)
args_json = match.group(2)
# Split the command into toolset and tool names def _serialize_input(self, input):
if '.' in full_command: """Convert ToolSpec objects to serializable dictionaries in input data."""
toolset_name, tool_name = full_command.split('.', 1) if not input:
return input
result = {}
for key, value in input.items():
if key == "tools" and isinstance(value, list):
# Convert ToolSpec objects to their OpenAI dictionary format
result[key] = [tool.to_openai_dict() if hasattr(tool, 'to_openai_dict')
else tool for tool in value]
else: else:
toolset_name = '' result[key] = value
tool_name = full_command
try:
if args_json:
args = json.loads(args_json)
else:
args = {}
if not isinstance(args, dict):
raise ValueError("Arguments must be a JSON object")
action = Action(
toolset_name=toolset_name,
tool_name=tool_name,
args=args,
is_query=False
)
result.actions.append(action)
except json.JSONDecodeError:
# If JSON parsing fails, treat it as regular text
response_lines.append(line)
except ValueError as e:
# If other validation fails, treat as regular text
response_lines.append(line)
else:
# Doesn't match the pattern, treat as regular text
response_lines.append(line)
else:
# Regular response line
response_lines.append(line)
result.response = '\n'.join(response_lines)
return result return result
def serialize(self):
"""Serialize the run object to a dictionary for JSON responses."""
return {
"id": self.id,
"agentId": self.agent_id,
"userId": self.user_id,
"done": self.done,
"startTime": self.start_time,
"endTime": self.end_time,
"input": self._serialize_input(self.input),
"events": self.events,
"output": self.output,
}
def close(self):
if self.done:
return
self.done = True
self.end_time = int(time.time())
self.output = self.stream.content
self.stream.close()
self.emit_event("done", {"output": self.output, "agent_id": self.agent_id})
# Broadcast run completion event
self.app.emit_event("run-completed", {
"runId": self.id,
"agentId": self.agent_id,
"endTime": self.end_time,
"output": self.output
})
+28 -87
View File
@@ -1,10 +1,4 @@
import re
import json
import inspect
from string import Template
import inference import inference
from inference import LlamaMessage
from agent import Agent, AgentField from agent import Agent, AgentField
CLASS_DESCRIPTION = """ CLASS_DESCRIPTION = """
@@ -27,90 +21,37 @@ class ExpertAgent(Agent):
def __init__(self, app, data): def __init__(self, app, data):
Agent.__init__(self, app, data) Agent.__init__(self, app, data)
async def prompt(self, instruction, input=None, **kwargs): async def run(self, run):
# Instruction extends system if there's input or messages inp = run.input or {}
# Otherwise instruction is the user prompt
# Input is always added as a user message at the end, if present
# Use 'messages' as starting point when passed
messages = []
if 'messages' in kwargs:
messages = [LlamaMessage(msg['role'], msg['content']) for msg in kwargs['messages']]
# Construct the default messages
system = self.config['system_prompt']
prompt = input
if input == None and not messages:
prompt = instruction
elif instruction:
system += "\n" + instruction
# Add default messages to list
messages.insert(0, LlamaMessage("system", system))
if prompt:
messages.append(LlamaMessage("user", prompt))
model_id = self.config['model']
db_model = await self.app.get_model(model_id)
model_engine = db_model.value["engine"]
opts = {} opts = {}
if 'temperature' in self.config:
opts['temperature'] = self.config['temperature']
if 'repetition_penalty' in self.config:
opts['repetition_penalty'] = self.config['repetition_penalty']
if model_engine == "llama-cpp": if self.config.get("temperature") is not None:
response = await inference.local_chat_completion( opts["temperature"] = self.config["temperature"]
if self.config.get("repetition_penalty") is not None:
opts["repetition_penalty"] = self.config["repetition_penalty"]
# 1. Build message list based on input mode
if "messages" in inp:
messages = inp["messages"]
else:
instruction = inp.get("instruction")
input_text = inp.get("input")
messages = inference.build_messages(
system_prompt=self.config.get("system_prompt", ""),
instruction=instruction,
input=input_text,
messages=inp.get("messages"),
)
# 2. Call inference, passing the run so it feeds run.stream
# and can emit tool_call events through the run.
await inference.chat_completion(
self.app, self.app,
messages, model_id=self.config.get("model"),
model=db_model.value['model'], messages=messages,
tools=inp.get("tools", []),
opts=opts, opts=opts,
) agent_run=run,
elif model_engine == "replicate":
if 'parameters' in db_model:
opts['parameters'] = db_model['parameters']
if 'format' in db_model:
opts['format'] = db_model['format']
response = await inference.replicate_completion(self.app, messages, model=db_model.value['model'], opts=opts)
elif model_engine == "ollama":
response = await inference.ollama_chat_completion(self.app, messages, model=db_model.value['model'], opts=opts)
else:
raise Exception("Unsuported model: " + repr(db_model.value))
return response
async def received_message(self, message):
chat_view = await self.app.get_chat_view(message.chat_id)
thread = chat_view.get_thread(message.thread_id)
def msg_role(message):
sender = message.sender
is_digit = isinstance(sender, (int, str)) and str(sender).isdigit()
if is_digit and str(sender) == str(self.id):
return 'assistant'
elif sender == 'user':
return 'user'
elif sender.startswith("tmp-"):
return 'assistant'
else:
raise Exception("Unsupported sender: " + sender)
messages = [{'role': msg_role(msg), 'content': msg.content} for msg in thread.messages]
response = await self.prompt(
instruction=None,
input=None,
messages=messages
) )
await self.app.send_chat_message( run.stream.listen(lambda chunk: None, lambda: run.close())
chat_id=message.chat_id,
sender=self.id,
content=response,
thread_id=message.thread_id,
)
+2 -5
View File
@@ -21,11 +21,8 @@ class ScriptedAgent(Agent):
def __init__(self, app, data): def __init__(self, app, data):
Agent.__init__(self, app, data) Agent.__init__(self, app, data)
async def prompt(self, instruction, input=None, **kwargs): async def run(self, run):
raise Exception("Scripted agent cannot use prompts (Unimplemented)") raise NotImplementedError("ScriptedAgent does not support run()")
async def received_message(self, message):
raise Exception("Scripted agent cannot use chat (Unimplemented)")
async def execute_action(self, name: str, argument: dict): async def execute_action(self, name: str, argument: dict):
raise Exception("No actions for this agent") raise Exception("No actions for this agent")
+33 -3
View File
@@ -1,6 +1,7 @@
from datetime import datetime from datetime import datetime
import asyncio import asyncio
import json import json
import time
import sys import sys
import os import os
@@ -25,6 +26,7 @@ class App(ChatMixin):
self.user_connections = {} self.user_connections = {}
self.remote_connections = {} self.remote_connections = {}
self.streams = {} self.streams = {}
self.event_listeners = []
self.tmp = tmp.State() self.tmp = tmp.State()
@@ -37,14 +39,25 @@ class App(ChatMixin):
raise Exception("Horde only works with a replicate connection. Please provide an API Token.") raise Exception("Horde only works with a replicate connection. Please provide an API Token.")
self.agent_classes = [ self.agent_classes = [
agent_mod.BasicAgent,
agents_mod.CharacterAgent,
agents_mod.ExpertAgent, agents_mod.ExpertAgent,
agents_mod.ScriptedAgent,
] ]
# --- Connection Management (Synchronous) --- # --- Connection Management (Synchronous) ---
def on_event(self, callback):
"""Register a callback for events."""
if callback not in self.event_listeners:
self.event_listeners.append(callback)
return lambda: self.event_listeners.remove(callback)
def emit_event(self, name, data=None):
"""Emit an event to all listeners."""
for listener in self.event_listeners:
try:
listener(name, data)
except Exception as e:
print(f"Error in event listener: {name}, {data}")
def add_user_connection(self, user_id, channel): def add_user_connection(self, user_id, channel):
if user_id not in self.user_connections: if user_id not in self.user_connections:
self.user_connections[user_id] = [] self.user_connections[user_id] = []
@@ -310,6 +323,23 @@ class App(ChatMixin):
print(f"Saved model: {model.id} - {name}") print(f"Saved model: {model.id} - {name}")
return model return model
async def create_agent_run(self, agent_id):
agent = await self.get_agent(agent_id)
run = agent_mod.AgentRun.create(
self,
agent_id=agent_id,
user_id=agent.user_id,
)
return run.id
async def get_agent_runs(self, agent_id=None, user_id=None):
runs = list(self.tmp.runs.values())
if agent_id:
runs = [r for r in runs if r.agent_id == agent_id]
elif user_id:
runs = [r for r in runs if r.user_id == user_id]
return [r.serialize() for r in runs]
# --- Main Entry Point --- # --- Main Entry Point ---
async def main(): async def main():
+166 -21
View File
@@ -1,13 +1,16 @@
import threading import threading
import asyncio import asyncio
import json
import ollama import ollama
from stream import Stream from stream import Stream
from tool import format_tools_for_engine, build_tool_prompt_prefix, ToolSpec
from llama_cpp import Llama from llama_cpp import Llama
VALID_ROLES = ['user', 'assistant', 'system'] VALID_ROLES = ['user', 'assistant', 'system', 'tool']
CONTEXT_WINDOW = 10_000 CONTEXT_WINDOW = 10_000
# TODO: Rename to ChatMessage. Add methods for constructing from instruct/openai/etc formatted objects.
class LlamaMessage: class LlamaMessage:
def __init__(self, role, content): def __init__(self, role, content):
if role not in VALID_ROLES: if role not in VALID_ROLES:
@@ -15,6 +18,13 @@ class LlamaMessage:
self.role = role self.role = role
self.content = content self.content = content
def to_dict(self):
"""Serialize to the dict format expected by llama-cpp / ollama."""
d = {"role": self.role, "content": self.content}
return d
def __repr__(self):
return f"LlamaMessage(role={self.role!r}, content={self.content!r})"
class Models: class Models:
llama_8b = "meta/meta-llama-3-8b-instruct" llama_8b = "meta/meta-llama-3-8b-instruct"
@@ -45,27 +55,120 @@ def get_llama_cpp_model(app, model_file):
) )
return last_model_instance return last_model_instance
# 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):
return message
elif isinstance(message, dict):
return LlamaMessage(message['role'], message['content'])
else:
raise Exception("Unsupported message type: " + repr(type(message)))
# shouldn't be necessary. maybe an assert instead?
messages = [normalize(msg) for msg in messages]
# default user message
prompt = input
# Instruction extends system_prompt if there's input or messages
# Otherwise instruction becomes the user prompt
# Input is always added as a user message at the end, if present
if input is None and not messages:
prompt = instruction
elif instruction:
system_prompt = f"{system_prompt}\n{instruction}" if system_prompt else instruction
# Add default messages to list
if system_prompt:
messages.insert(0, LlamaMessage("system", system))
if prompt:
messages.append(LlamaMessage("user", prompt))
return messages
async def chat_completion(app,
model_id=None,
instruction=None,
input=None,
messages=None,
system_prompt="",
tools=[],
opts={},
agent_run=None
):
db_model = (await app.get_model(model_id)).value
# Integrate arguments into message list
messages = build_messages(
system_prompt=system_prompt,
instruction=instruction,
input=input,
messages=messages,
)
model_engine = db_model['engine']
model_name = db_model['model']
# Warn when tools are passed to engines that do not support native tool calling
if tools:
if model_engine not in ("llama-cpp",):
print(f"WARNING: Tool calls not supported for engine '{model_engine}'. Tools will be ignored.")
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 == "replicate":
# Clone opts to avoid mutating the caller's dict
opts = dict(opts)
if 'parameters' in db_model:
opts['parameters'] = db_model['parameters']
if 'format' in db_model:
opts['format'] = db_model['format']
result = await replicate_completion(app, messages, model=model_name, tools=tools, opts=opts, agent_run=agent_run)
elif model_engine == "ollama":
result = await ollama_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:
return None
return result
# llama.cpp # llama.cpp
async def local_chat_completion(app, messages, model, opts={}): async def local_chat_completion(app, messages, model, tools=[], opts={}, agent_run=None):
if agent_run:
content_stream = agent_run.stream
else:
content_stream = Stream(app) content_stream = Stream(app)
def worker(): def worker():
llm = get_llama_cpp_model(app, model) llm = get_llama_cpp_model(app, model)
kwargs = {}
temp = opts.get("temperature") temp = opts.get("temperature")
if temp is None: if temp is not None:
temp = 0.25 kwargs['temperature'] = temp
repeat = opts.get("repetition_penalty") repeat = opts.get("repetition_penalty")
if repeat is None: if repeat is not None:
repeat = 1.0 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) # With stream=True, returns an iterator that infers tokens on demand (openai compatible)
llm_output = llm.create_chat_completion( llm_output = llm.create_chat_completion(
messages = [{'role': msg.role, 'content': msg.content} for msg in messages], messages=[msg.to_dict() for msg in messages],
temperature = temp, stream=True,
repeat_penalty = repeat, **kwargs
stream = True,
) )
for chunk in llm_output: for chunk in llm_output:
@@ -73,31 +176,54 @@ async def local_chat_completion(app, messages, model, opts={}):
if 'content' in delta: if 'content' in delta:
chunk_text = delta['content'] chunk_text = delta['content']
content_stream.feed(chunk_text) content_stream.feed(chunk_text)
#chunk_text = chunk['choices'][0]['text'] # Handle tool_call deltas if the model emits them
if 'tool_calls' in delta:
# Forward tool call chunks as JSON strings for the consumer
for tc in delta['tool_calls']:
content_stream.feed(json.dumps({'tool_call': tc}))
if agent_run:
for tc in delta['tool_calls']:
agent_run.emit_event("tool_call", {
"id": tc.get("id"),
"function": tc.get("function"),
})
content_stream.close() content_stream.close()
thread = threading.Thread(target=worker) thread = threading.Thread(target=worker)
thread.start() thread.start()
if agent_run:
return None
return content_stream return content_stream
async def ollama_list(app, messages, model, opts={}): async def ollama_list(app, messages, model, opts={}):
# TODO: test the output, and use this # TODO: test the output, and use this
return ollama.list() return ollama.list()
async def ollama_chat_completion(app, messages, model, opts={}): async def ollama_chat_completion(app, messages, model, tools=[], opts={}, agent_run=None):
if agent_run:
content_stream = agent_run.stream
else:
content_stream = Stream(app) content_stream = Stream(app)
async def worker(): async def worker():
print("START OLLAMA INFERENCE") print("START OLLAMA INFERENCE")
stream_response = await ollama.AsyncClient().chat( kwargs = {
model=model, 'model': model,
messages=[{'role': msg.role, 'content': msg.content} for msg in messages], 'messages': [msg.to_dict() for msg in messages],
stream=True, 'stream': True,
) }
# Pass tools in Ollama format if provided
formatted_tools = format_tools_for_engine(tools, "ollama") if tools else None
if formatted_tools:
kwargs['tools'] = formatted_tools
stream_response = await ollama.AsyncClient().chat(**kwargs)
async for part in stream_response: async for part in stream_response:
if part.message.content:
content_stream.feed(part.message.content) content_stream.feed(part.message.content)
content_stream.close() content_stream.close()
@@ -105,23 +231,37 @@ async def ollama_chat_completion(app, messages, model, opts={}):
asyncio.create_task(worker()) asyncio.create_task(worker())
if agent_run:
return None
return content_stream return content_stream
async def replicate_completion(app, messages, model=None, opts={}): async def replicate_completion(app, messages, model=None, tools=[], opts={}, agent_run=None):
if model is None: if model is None:
model = Models.llama_8b model = Models.llama_8b
parameter_names = opts.get('parameters', {}) parameter_names = opts.get('parameters', {})
arguments = {} arguments = {}
arguments[parameter_names.get('prompt', 'prompt')] = format_instruct(messages)
# Build the prompt, injecting tool descriptions for models that rely on
# prompt-based tool usage rather than native function calling.
prompt_text = format_instruct(messages)
if tools:
tool_prefix = build_tool_prompt_prefix(tools)
if tool_prefix:
prompt_text = tool_prefix + "\n\n" + prompt_text
arguments[parameter_names.get('prompt', 'prompt')] = prompt_text
arguments[parameter_names.get('temperature', 'temperature')] = opts.get('temperature') or 0.75 arguments[parameter_names.get('temperature', 'temperature')] = opts.get('temperature') or 0.75
arguments[parameter_names.get('max_tokens', 'max_tokens')] = opts.get('max_tokens', 2048) arguments[parameter_names.get('max_tokens', 'max_tokens')] = opts.get('max_tokens', 2048)
arguments[parameter_names.get('system_prompt', 'system_prompt')] = "" arguments[parameter_names.get('system_prompt', 'system_prompt')] = ""
return await replicate_base_completion(app, model, arguments) result = await replicate_base_completion(app, model, arguments, agent_run=agent_run)
if agent_run:
return None
return result
# DEPRECATED # DEPRECATED
async def replicate_llama_completion(app, messages, model=None, opts={}): async def replicate_llama_completion(app, messages, model=None, opts={}):
@@ -157,7 +297,10 @@ async def replicate_qwen_completion(app, messages, model=None, opts={}):
return await replicate_base_completion(app, model, input) return await replicate_base_completion(app, model, input)
async def replicate_base_completion(app, model, input): async def replicate_base_completion(app, model, input, agent_run=None):
if agent_run:
content_stream = agent_run.stream
else:
content_stream = Stream(app) content_stream = Stream(app)
thread = threading.Thread( thread = threading.Thread(
target=run_replicate_inference_loop, target=run_replicate_inference_loop,
@@ -171,6 +314,8 @@ async def replicate_base_completion(app, model, input):
thread.start() thread.start()
#response = await content_stream.content_wait() #response = await content_stream.content_wait()
#return response.lstrip() #return response.lstrip()
if agent_run:
return None
return content_stream return content_stream
+292 -5
View File
@@ -1,12 +1,15 @@
from quart import Quart, Response, request, send_from_directory, websocket from quart import Quart, Response, request, send_from_directory, websocket
from quart_auth import ( from quart_auth import (
QuartAuth, current_user, QuartAuth, current_user,
AuthUser, login_user, logout_user AuthUser, login_user, logout_user, login_required
) )
import time
import json import json
import asyncio import asyncio
import nest_asyncio import nest_asyncio
from stream import Stream from stream import Stream
from tool import ToolSpec, ToolParameter
from agent import AgentRun
# Vue frontend # Vue frontend
# relative to the module file???? wtf # relative to the module file???? wtf
@@ -66,6 +69,7 @@ async def start_server(app):
return "bad login credentials", 401 return "bad login credentials", 401
token = auth_manager.dump_token(user_id) token = auth_manager.dump_token(user_id)
login_user(AuthUser(auth_id=user_id))
return {"token": token} return {"token": token}
@server.route('/user/signup', methods=['POST']) @server.route('/user/signup', methods=['POST'])
@@ -135,6 +139,11 @@ async def start_server(app):
app.add_user_connection(user_id, send_queue) app.add_user_connection(user_id, send_queue)
asyncio.create_task(sender_task()) asyncio.create_task(sender_task())
# Listen for events and broadcast them
async def on_event(name, data):
await send({"action": event, "name": name, "data": data})
app.on_event(on_event)
while True: while True:
req = json.loads(await websocket.receive()) req = json.loads(await websocket.receive())
if not 'action' in req: if not 'action' in req:
@@ -207,9 +216,31 @@ async def start_server(app):
await send({'message': "agent kind retrieved", 'info': app.get_agent_kind_info(body['kind']), 'nonce': nonce}) await send({'message': "agent kind retrieved", 'info': app.get_agent_kind_info(body['kind']), 'nonce': nonce})
elif action == "get-agents": elif action == "get-agents":
#await send({'agents': [], 'nonce': nonce})
await send({'message': "agents retrieved", 'agents': await app.get_agents(user_id), 'nonce': nonce}) await send({'message': "agents retrieved", 'agents': await app.get_agents(user_id), 'nonce': nonce})
elif action == "get-agent-runs":
await send({
'runs': await app.get_agent_runs(agent_id=body.get('agentId'), user_id=user_id),
'nonce': nonce
})
elif action == "get-agent-run":
await send({
'run': await app.get_agent_run(body['runId']),
'nonce': nonce
})
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)
elif action == "create-chat": elif action == "create-chat":
chat_id = await app.create_chat( chat_id = await app.create_chat(
user_id=user_id, user_id=user_id,
@@ -284,9 +315,11 @@ async def start_server(app):
response = await response.content_wait() response = await response.content_wait()
await send({'message': "prompt sent", 'response': response, 'nonce': nonce}) await send({'message': "prompt sent", 'response': response, 'nonce': nonce})
elif action == "call-agent-action": elif action == "get-agent-runs":
result = await app.call_agent_action(body['agentId'], body['agentAction'], body['arguments']) await send({
await send({'message': "agent action executed", 'result': result, 'nonce': nonce}) 'runs': await app.get_agent_runs(agent_id=body.get('agentId'), user_id=user_id),
'nonce': nonce
})
elif action == "read-stream": elif action == "read-stream":
stream_id = body['streamId'] stream_id = body['streamId']
@@ -307,4 +340,258 @@ async def start_server(app):
print("User logged out:", user_id) print("User logged out:", user_id)
app.remove_user_connection(user_id, send_queue) app.remove_user_connection(user_id, send_queue)
# https://github.com/hrgarber/openai-compatible-api/blob/main/app/main.py
@server.post('/api/openai/v1/chat/completions')
@login_required
async def openai_chat_completion():
data = await request.get_json()
print("Completion: " + json.dumps(data))
# Extract required fields
agent_id = data.get("model")
openai_messages = data.get("messages", [])
openai_tools = data.get("tools", [])
stream_mode = data.get("stream", False)
if not agent_id or not openai_messages:
return {"error": "model and messages are required"}, 400
user_id = current_user.auth_id
if not user_id: pass
# --- Parse messages ---
message_errors = []
messages = []
for msg in openai_messages:
role = msg['role']
if role not in ['user', 'assistant', 'system']:
message_errors.append("Bad role: " + role)
continue
content = msg.get('content', None)
if type(content) == list:
parts = content
content = ""
for part in parts:
if part['type'] == 'text':
content += part['text']
if type(content) != str:
message_errors.append("Bad content")
continue
messages.append({'role': role, 'content': content})
if message_errors:
print("[openai] ERROR: bad messages input\n" + "\n".join([f"\t- {e}" for e in message_errors]))
# --- Parse tools (OpenAI format -> ToolSpec) ---
tools = []
for tool in openai_tools:
if tool.get('type') != 'function':
continue
func = tool['function']
params_schema = func.get('parameters', {})
required_params = params_schema.get('required', [])
parameters = []
for param_name, param_spec in params_schema.get('properties', {}).items():
parameters.append(ToolParameter(
name=param_name,
type=param_spec.get('type', 'string'),
description=param_spec.get('description', ''),
required=param_name in required_params,
enum=param_spec.get('enum'),
))
tools.append(ToolSpec(
name=func['name'],
description=func.get('description', ''),
parameters=parameters,
))
# --- Get agent control and create AgentRun directly ---
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,
},
)
print("run input: " + repr(run.input))
# --- Streaming ---
if stream_mode:
queue = asyncio.Queue()
loop = asyncio.get_event_loop()
# Listen to the stream for both content and tool_call chunks
async def on_chunk(chunk):
# Tool calls are fed as JSON strings: '{"tool_call": {...}}'
try:
parsed = json.loads(chunk)
if isinstance(parsed, dict) and 'tool_call' in parsed:
tc = parsed['tool_call']
await queue.put(('tool_call', tc))
return
except (json.JSONDecodeError, TypeError):
pass
# Regular content
await queue.put(('content', chunk))
async def on_eof():
await queue.put(('eof', None))
run.stream.listen(on_chunk, on_eof)
# Start the agent run
await control.run(run)
async def generate():
try:
while True:
msg_type, data = await queue.get()
if msg_type == 'content':
chunk_body = {
"id": "chatcmpl-" + str(time.time()),
"object": "chat.completion.chunk",
"created": int(time.time()),
"model": agent_id,
"choices": [{"delta": {"content": data}, "index": 0, "finish_reason": None}]
}
yield f"data: {json.dumps(chunk_body)}\n\n"
elif msg_type == 'tool_call':
# Forward the tool call delta (already in OpenAI format from llama-cpp)
tc_entry = {"index": data.get('index', 0)}
if data.get('id'):
tc_entry['id'] = data['id']
if 'function' in data:
func_part = {}
fn = data['function']
if fn.get('name'):
func_part['name'] = fn['name']
if fn.get('arguments'):
func_part['arguments'] = fn['arguments']
if func_part:
tc_entry['function'] = func_part
chunk_body = {
"id": "chatcmpl-" + str(time.time()),
"object": "chat.completion.chunk",
"created": int(time.time()),
"model": agent_id,
"choices": [{
"delta": {"tool_calls": [tc_entry]},
"index": 0,
"finish_reason": None
}]
}
yield f"data: {json.dumps(chunk_body)}\n\n"
elif msg_type == 'eof':
# 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"
chunk_body = {
"id": "chatcmpl-" + str(time.time()),
"object": "chat.completion.chunk",
"created": int(time.time()),
"model": agent_id,
"choices": [{"delta": {}, "index": 0, "finish_reason": finish_reason}]
}
yield f"data: {json.dumps(chunk_body)}\n\n"
break
except Exception as e:
print("[openai] ERROR: " + str(e))
yield f"data: {json.dumps({'error': str(e)})}\n\n"
return Response(generate(), content_type="text/event-stream")
# --- Non-streaming ---
else:
await control.run(run)
await run.stream.content_wait()
# Collect any tool calls that were emitted
tool_events = [e['data'] for e in run.events if e['name'] == 'tool_call']
if tool_events:
# Aggregate tool calls: combine partial argument chunks by id
tc_map = {}
for evt in tool_events:
tid = evt.get('id', '')
if tid not in tc_map:
tc_map[tid] = {
"id": tid,
"type": "function",
"function": {
"name": evt.get('function', {}).get('name', ''),
"arguments": evt.get('function', {}).get('arguments', ''),
}
}
else:
tc_map[tid]['function']['arguments'] += \
evt.get('function', {}).get('arguments', '')
return {
"id": "chatcmpl-" + str(time.time()),
"object": "chat.completion",
"created": int(time.time()),
"model": agent_id,
"choices": [{
"message": {
"role": "assistant",
"content": None,
"tool_calls": list(tc_map.values()),
},
"finish_reason": "tool_calls",
}]
}
else:
# No tool calls: return text content
content = run.stream.content
return {
"id": "chatcmpl-" + str(time.time()),
"object": "chat.completion",
"created": int(time.time()),
"model": agent_id,
"choices": [{
"message": {
"role": "assistant",
"content": content,
},
"finish_reason": "stop",
}]
}
@server.get("/api/openai/v1/models")
@login_required
async def openai_models():
"""
Return a list of available models.
Customize this list based on your available models.
"""
user_id = current_user.auth_id
if not user_id: pass
return {
"object": "list",
"data": [{
"id": agent.id,
"object": "model",
"created": int(time.time()),
"owned_by": user_id,
} for agent in await app.get_agents(user_id)]
}
server.run(debug=True) server.run(debug=True)
+4
View File
@@ -1,4 +1,7 @@
from datetime import datetime
import time
def generate_tmp_id(app): def generate_tmp_id(app):
app.tmp.id_counter += 1 app.tmp.id_counter += 1
return "tmp-" + str(app.tmp.id_counter) return "tmp-" + str(app.tmp.id_counter)
@@ -12,6 +15,7 @@ class State:
self.agents = {} self.agents = {}
self.chats = {} self.chats = {}
self.threads = {} self.threads = {}
self.runs = {}
# list of temporary agent ids that belong to a real user # list of temporary agent ids that belong to a real user
self.user_agents = {} self.user_agents = {}
+180
View File
@@ -0,0 +1,180 @@
class ToolParameter:
"""Describes a single input parameter for a tool.
Mirrors the JSON-schema style used by OpenAI / llama-cpp function calling.
Attributes:
name: Parameter name.
type: JSON-schema type string, e.g. "string", "number", "boolean".
description: Human-readable description of the parameter.
required: Whether the parameter is required.
enum: Optional list of allowed values.
"""
def __init__(self, name, type="string", description="", required=True, enum=None):
self.name = name
self.type = type
self.description = description
self.required = required
self.enum = enum
def to_dict(self):
d = {
"type": self.type,
"description": self.description,
}
if self.enum is not None:
d["enum"] = self.enum
return d
def __repr__(self):
return f"ToolParameter(name={self.name!r}, type={self.type!r})"
class ToolSpec:
"""Declarative specification for a tool callable by an LLM.
A ToolSpec describes *what* a tool does and *what* parameters it accepts.
It is used at inference time to build the tool-description payload sent to
the model so the model can emit structured tool-call requests.
Inspired by ``langchain.tools.StructuredTool`` and OpenAI's
``tools`` / ``functions`` request format.
Attributes:
name: Unique identifier for the tool.
description: Short human-readable description shown to the model.
parameters: List of ``ToolParameter`` objects.
# execution
func: Optional callable that executes the tool. When present the
orchestrator can dispatch a ``ToolCall`` automatically.
"""
def __init__(self, name, description="", parameters=None, func=None):
self.name = name
self.description = description
self.parameters = parameters or []
self.func = func
# -- serialization helpers -------------------------------------------
def to_openai_dict(self):
"""Return a dict in OpenAI chat-completions ``tools`` format."""
properties = {}
required = []
for p in self.parameters:
properties[p.name] = p.to_dict()
if p.required:
required.append(p.name)
return {
"type": "function",
"function": {
"name": self.name,
"description": self.description,
"parameters": {
"type": "object",
"properties": properties,
"required": required,
},
},
}
def to_replicate_dict(self):
"""Return a dict suitable for Replicate model input.
Replicate models do not have a standard tool-calling protocol, so we
return a simplified representation that can be injected into the
prompt as text.
"""
param_lines = []
for p in self.parameters:
req = " (required)" if p.required else " (optional)"
param_lines.append(f" - {p.name} ({p.type}){req}: {p.description}")
params_text = "\n".join(param_lines) if param_lines else " (none)"
return {
"name": self.name,
"description": self.description,
"parameters_text": f"{self.name}:\n{params_text}",
}
def execute(self, **kwargs):
"""Synchronously run the tool with the given keyword arguments."""
if self.func is None:
raise RuntimeError(f"ToolSpec '{self.name}' has no callable (func is None)")
return self.func(**kwargs)
async def execute_async(self, **kwargs):
"""Asynchronously run the tool with the given keyword arguments."""
if self.func is None:
raise RuntimeError(f"ToolSpec '{self.name}' has no callable (func is None)")
if asyncio.iscoroutinefunction(self.func):
return await self.func(**kwargs)
return self.func(**kwargs)
def __repr__(self):
return f"ToolSpec(name={self.name!r}, params={[p.name for p in self.parameters]})"
class ToolCall:
"""Represents a single tool invocation request emitted by the model.
Attributes:
id: Unique call identifier (used by OpenAI / llama-cpp protocol).
tool_name: Name of the tool to invoke.
arguments: Dict of argument name → value.
result: String content returned by the tool.
is_error: Whether the result represents an error.
"""
def __init__(self, tool_name, arguments=None, id=None):
self.id = id or ""
self.tool_name = tool_name
self.arguments = arguments or {}
self.result = None
def __repr__(self):
return f"ToolCall(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
# ---------------------------------------------------------------------------
def format_tools_for_engine(tools, engine):
"""Convert a list of ``ToolSpec`` objects for the given engine.
Returns a value suitable to pass as the ``tools`` kwarg to the underlying
chat-completion call.
"""
specs = [t for t in tools]
if engine in ("llama-cpp", "ollama"):
return [s.to_openai_dict() for s in specs]
elif engine == "replicate":
return [s.to_replicate_dict() for s in specs]
else:
return [s.to_openai_dict() for s in specs]
def build_tool_prompt_prefix(tools):
"""Build a text prefix describing available tools for engines that rely
on prompt-injection (e.g. Replicate) rather than native tool-calling."""
if not tools:
return ""
parts = ["You have access to the following tools:"]
for t in tools:
spec = t
info = spec.to_replicate_dict()
parts.append(info["parameters_text"])
parts.append("")
return "\n".join(parts)
-102
View File
@@ -1,102 +0,0 @@
import inspect
# Do not call methods from instances of this class, they must not get overridden
class Toolset:
# Fields: description, identifier, _tools: list[ToolDescription]
def __init__(self):
# TODO: receive app, assign _tools
pass
def get_namespace_definitions(toolset):
header = toolset.description
if toolset.identifier:
header = f"Namespace {toolset.identifier}: {toolset.description}"
lines = [header]
for tool in toolset._tools:
full_name = tool.identifier
if toolset.identifier:
full_name = toolset.identifier + "." + full_name
lines.append(f"- {tool.description}\n!!{full_name} {tool.schema}")
return "\n".join(lines)
async def execute_action(toolset, name: str, argument: dict):
# TODO: For security, check that the method is in the tool list
method = getattr(toolset, name)
result = method(**argument)
if inspect.isawaitable(result):
result = await result
return result
class ToolDescription:
def __init__(self, identifier, description, schema):
self.identifier=identifier
self.description=description
self.schema=schema
# An action is an object representing a command invokation
class Action:
def __init__(self, toolset_name: str, tool_name: str, args: dict, is_query: bool = False):
self.toolset_name = toolset_name
self.tool_name = tool_name
self.args = args
self.is_query = is_query
def is_special(self, name: str):
return self.toolset_name == "" and self.tool_name == name
def __str__(self):
return f"{self.is_query and 'query' or ''}{self.toolset_name}.{self.tool_name} {self.args}"
def __repr__(self):
return f"Action({self.__str__()})"
class SpecialToolset(Toolset):
def __init__(self):
self.identifier = ""
self.description = "Special commands that don't receive arguments"
self._tools = [
ToolDescription("resume", "Resume the conversation with the command outputs", ''),
ToolDescription("respond", "When used, all text after this command will be used as the actual response", ''),
]
def resume(self):
pass
def respond(self):
pass
class BasicToolset(Toolset):
def __init__(self):
self.identifier = "util"
self.description = "Basic assistant utility tools"
self._tools = [
ToolDescription("google_search", "Executes a google search", '{"query":string}'),
ToolDescription("fetch", "Fetch the contents of a url", '{"url":string}'),
ToolDescription("eval", "Evaluates a python expression", '{"expression":string}'),
]
def google_search(self, query):
return "We're sorry. Google is not accessible at this time"
def fetch(self, url):
return "No internet connection."
def eval(self, expression):
try:
return eval(expression)
except Exception as e:
return str(e)
class HubControlsToolset(Toolset):
def __init__(self):
self.identifier = "hub"
self.description = "Controls for the Hub system"
self._tools = [
ToolDescription("get_chats", "Get all the existing chats", '{}'),
ToolDescription("get_agents", "Get all the existing bots", '{}'),
ToolDescription("create_chat", "Creates a new chat", '{"name":string,"agent_ids":int[]}'),
ToolDescription("get_agent_data", "Gets the configuration data of an agent", '{"agent_id":int}'),
ToolDescription("set_agent_data", "Get all the existing bots", '{"agent_id":int,"data":object}'),
]
+26
View File
@@ -30,6 +30,7 @@ let credentialsDeferred = createDeferred();
const pendingActions = {}; // Maps nonce to { resolve, reject } const pendingActions = {}; // Maps nonce to { resolve, reject }
const chatListeners = {}; // Maps chatId to an array of callback functions const chatListeners = {}; // Maps chatId to an array of callback functions
const orphanHandlers = []; // Queue of callbacks for orphan server messages const orphanHandlers = []; // Queue of callbacks for orphan server messages
const runListeners = []; // Queue of callbacks for run events
function createDeferred() { function createDeferred() {
let resolve, reject; let resolve, reject;
@@ -191,6 +192,15 @@ function handleIncomingMessage(rawData) {
} else { } else {
console.log(`No active listeners for chatId ${chatId}`); console.log(`No active listeners for chatId ${chatId}`);
} }
} else if (packet.action == 'run-event') {
// Broadcast run events to all listeners
runListeners.forEach(callback => {
try {
callback(packet);
} catch (e) {
console.error(`Error in run event listener:`, e);
}
});
} else { } else {
if (orphanHandlers.length) { if (orphanHandlers.length) {
const f = orphanHandlers.shift(); const f = orphanHandlers.shift();
@@ -247,6 +257,22 @@ export function onChatMessages(chatId, callback) {
}; };
} }
export function onRunEvents(callback) {
if (typeof callback !== 'function') {
throw new TypeError("onRunEvents: callback must be a function.");
}
runListeners.push(callback);
// Return an unsubscribe function
return () => {
const index = runListeners.indexOf(callback);
if (index > -1) {
runListeners.splice(index, 1);
}
};
}
export async function getChats() { export async function getChats() {
if (cache.chats === null) { if (cache.chats === null) {
const response = await sendAction("get-chats", {}); const response = await sendAction("get-chats", {});
+4 -1
View File
@@ -101,6 +101,8 @@
</button> </button>
</div> </div>
</div> </div>
<nav v-if="mode=='runs'">
</nav>
<nav v-if="mode=='chats'"> <nav v-if="mode=='chats'">
<RouterLink v-for="it in chats" :to="'/chat/' + it.id" class="nav-item">{{it.name}}</RouterLink> <RouterLink v-for="it in chats" :to="'/chat/' + it.id" class="nav-item">{{it.name}}</RouterLink>
<RouterLink to="/chat/create" class="nav-item">Create Chat</RouterLink> <RouterLink to="/chat/create" class="nav-item">Create Chat</RouterLink>
@@ -110,8 +112,9 @@
<RouterLink to="/agent/create" class="nav-item">Create Agent</RouterLink> <RouterLink to="/agent/create" class="nav-item">Create Agent</RouterLink>
</nav> </nav>
<nav v-if="mode=='settings'"> <nav v-if="mode=='settings'">
<RouterLink to="/models" class="nav-item">Manage Models</RouterLink>
<RouterLink to="/user" class="nav-item">User</RouterLink> <RouterLink to="/user" class="nav-item">User</RouterLink>
<RouterLink to="/models" class="nav-item">Manage Models</RouterLink>
<RouterLink to="/runs" class="nav-item">Agent Runs</RouterLink>
</nav> </nav>
</aside> </aside>
</template> </template>
+3 -3
View File
@@ -44,9 +44,9 @@ const router = createRouter({
component: () => import('../views/Models.vue') component: () => import('../views/Models.vue')
}, },
{ {
name: 'user', name: 'runs',
path: `/user`, path: `/runs`,
component: () => import('../views/User.vue') component: () => import('../views/RunsView.vue')
}, },
objectView('chat', () => import('../views/Chat.vue')), objectView('chat', () => import('../views/Chat.vue')),
objectView('agent', () => import('../views/Agent.vue')), objectView('agent', () => import('../views/Agent.vue')),
+218
View File
@@ -0,0 +1,218 @@
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
import TitleBar from '../components/TitleBar.vue';
import * as backend from '../backend';
const runs = ref([]);
const expandedRunId = ref(null);
const loading = ref(true);
let unsubscribeRunEvents = null;
const toggleRun = (id) => {
if (expandedRunId.value === id) {
expandedRunId.value = null;
} else {
expandedRunId.value = id;
}
};
const formatTime = (timestamp) => {
if (!timestamp) return '—';
return new Date(timestamp * 1000).toLocaleString();
};
const getStatusBadge = (run) => {
return run.done ? 'Done' : 'Running';
};
const updateRun = (event) => {
const { name, data } = event;
// Find the run by ID
const runIndex = runs.value.findIndex(r => r.id === data.runId);
if (runIndex === -1) {
// New run was created
if (name === 'run-started') {
runs.value.push(data);
}
} else {
// Update existing run
const run = runs.value[runIndex];
if (name === 'run-completed') {
run.done = true;
run.endTime = data.endTime;
run.output = data.output;
// Mark as expanded to show the completion
if (expandedRunId.value !== data.runId) {
expandedRunId.value = data.runId;
}
} else if (name === 'run-event') {
// Add new event to the run
if (!run.events) {
run.events = [];
}
run.events.push(data.data);
// If this run is expanded, update it
if (expandedRunId.value === data.runId) {
// No DOM update needed as we're re-rendering the whole list
}
}
}
};
onMounted(async () => {
try {
const res = await backend.sendAction("get-agent-runs");
runs.value = res.runs || [];
} catch (err) {
console.error("Failed to load agent runs:", err);
} finally {
loading.value = false;
}
// Listen for live run events
unsubscribeRunEvents = backend.onRunEvents(updateRun);
});
onUnmounted(() => {
if (unsubscribeRunEvents) {
unsubscribeRunEvents();
}
});
</script>
<template>
<main>
<TitleBar>
<h1>Agent Runs</h1>
</TitleBar>
<div v-if="loading" class="loading">Loading runs...</div>
<div v-else-if="runs.length === 0" class="empty">
No agent runs found.
</div>
<div v-else v-for="run in runs" :key="run.id" class="run-entry">
<div class="run-header" @click="toggleRun(run.id)">
<span class="run-id">{{ run.id }}</span>
<span class="run-agent">Agent: {{ run.agentId }}</span>
<span :class="['run-status', run.done ? 'status-done' : 'status-running']">
{{ getStatusBadge(run) }}
</span>
</div>
<div v-if="expandedRunId === run.id" class="run-details">
<div class="detail-row">
<div class="detail-key">User ID</div>
<div class="detail-value">{{ run.userId }}</div>
</div>
<div class="detail-row">
<div class="detail-key">Start Time</div>
<div class="detail-value">{{ formatTime(run.startTime) }}</div>
</div>
<div class="detail-row">
<div class="detail-key">End Time</div>
<div class="detail-value">{{ formatTime(run.endTime) }}</div>
</div>
<div class="detail-row">
<div class="detail-key">Input</div>
<pre class="detail-pre">{{ JSON.stringify(run.input, null, 2) }}</pre>
</div>
<div v-if="run.events && run.events.length > 0" class="detail-row">
<div class="detail-key">Events ({{ run.events.length }})</div>
<pre class="detail-pre">{{ JSON.stringify(run.events, null, 2) }}</pre>
</div>
<div v-if="run.output !== null && run.output !== undefined" class="detail-row">
<div class="detail-key">Output</div>
<pre class="detail-pre">{{ JSON.stringify(run.output, null, 2) }}</pre>
</div>
</div>
</div>
</main>
</template>
<style scoped>
.loading,
.empty {
padding: 1em;
color: var(--color-text-mute);
}
.run-entry {
border-bottom: 1px solid var(--color-border);
}
.run-header {
padding: 0.5em;
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
gap: 1em;
}
.run-header:hover {
background-color: var(--color-background-hover);
}
.run-id {
font-family: monospace;
font-size: 0.85em;
color: var(--color-text-heading);
}
.run-agent {
color: var(--color-text-mute);
font-size: 0.85em;
}
.run-status {
font-size: 0.8em;
padding: 0.15em 0.5em;
border-radius: 4px;
font-weight: bold;
}
.status-done {
background-color: var(--color-background-success, #166534);
color: #bbf7d0;
}
.status-running {
background-color: var(--color-background-warn, #854d0e);
color: #fef08a;
}
.run-details {
padding: 0.5em 1em;
background-color: var(--color-background-mute);
}
.detail-row {
margin-bottom: 0.5em;
}
.detail-key {
font-weight: bold;
color: var(--color-text-heading);
margin-bottom: 0.15em;
}
.detail-value {
color: var(--color-text);
}
.detail-pre {
margin: 0;
padding: 0.5em;
background-color: var(--color-background-muted, #111);
border-radius: 4px;
white-space: pre-wrap;
word-break: break-all;
font-size: 0.85em;
}
</style>