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:
1 parent
eb9a1ad9e8
commit
4e15b44d39
14 files changed
+1126
-497
No files matched your search
@@ -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
@@ -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
|
||||||
|
})
|
||||||
|
|
||||||
@@ -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,
|
|
||||||
)
|
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||||
@@ -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 = {}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
@@ -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}'),
|
|
||||||
]
|
|
||||||
@@ -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", {});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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')),
|
||||||
|
|||||||
@@ -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>
|
||||||
Reference in new issue
Block a user