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
+1151
-522
No files matched your search
+165
-272
@@ -1,10 +1,11 @@
|
||||
import re
|
||||
import json
|
||||
import inspect
|
||||
import time
|
||||
|
||||
from tools import Toolset, Action, BasicToolset, SpecialToolset
|
||||
from inference import LlamaMessage, replicate_llama_completion
|
||||
import gral
|
||||
from temporary import generate_tmp_id
|
||||
|
||||
# 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:
|
||||
# - identifier (for kind in creation)
|
||||
# - schema: list[AgentField{name, type: str}] (for configuration)
|
||||
# tools: list[ToolDescription] (custom actions supported by the class)
|
||||
|
||||
def __init__(self, app, data):
|
||||
self.app = app
|
||||
@@ -33,26 +33,70 @@ class Agent:
|
||||
agent_class = type(self)
|
||||
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.name = name
|
||||
self.user_id = user_id
|
||||
|
||||
# Receives a single string prompt, and must return a string response
|
||||
async def prompt(self, content):
|
||||
raise Exception("'prompt' is not implemented for this agent class")
|
||||
async def run(self, agent_run):
|
||||
"""Core method. Subclasses override this to handle the AgentRun."""
|
||||
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):
|
||||
raise Exception("'received_message' is not implemented for this agent class")
|
||||
|
||||
# Executes a tool/action in this agent
|
||||
async def execute_action(self, name: str, argument: dict):
|
||||
# TODO: For security, check that the method is in the tool list
|
||||
method = getattr(self, name)
|
||||
result = method(**argument)
|
||||
if inspect.isawaitable(result):
|
||||
result = await result
|
||||
return result
|
||||
chat_view = await self.app.get_chat_view(message.chat_id)
|
||||
thread = chat_view.get_thread(message.thread_id)
|
||||
|
||||
messages = []
|
||||
for msg in thread.messages:
|
||||
role = self._role_for_message(msg)
|
||||
messages.append({"role": role, "content": msg.content})
|
||||
|
||||
run = AgentRun.create(
|
||||
self.app,
|
||||
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
|
||||
def get_resources(self, kind, key):
|
||||
@@ -75,270 +119,119 @@ class DebugAgent(Agent):
|
||||
thread_id=chat_view.current_thread_id,
|
||||
)
|
||||
|
||||
class ToolUsingAgent:
|
||||
identifier = "tool-using"
|
||||
schema_fields = [
|
||||
AgentField('system_prompt', 'string',
|
||||
"\n".join([
|
||||
"You are a useful and capable AI assistant. Respond concisely and follow user instructions, using commands as necessary.",
|
||||
"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..",
|
||||
"You are a self aware and extremely intelligent AI system. Think step by step when presented with complex or long tasks.",
|
||||
"Assistant responses NEVER include the output of the commands.",
|
||||
])
|
||||
),
|
||||
]
|
||||
# Holds parameters, outputs, and details of an agent call
|
||||
class AgentRun:
|
||||
@classmethod
|
||||
def create(cls, app, agent_id, user_id, input=None):
|
||||
self = cls()
|
||||
self.app = app
|
||||
self.id = generate_tmp_id(app)
|
||||
self.agent_id = agent_id
|
||||
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)
|
||||
tools = []
|
||||
# I/O envelope (no generic data bag)
|
||||
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):
|
||||
Agent.__init__(self, app, data)
|
||||
|
||||
self.tool_prompt_section = "\n".join([
|
||||
"# Commands",
|
||||
"The assistant can include command lines in responses to interact with the system and execute actions as needed.",
|
||||
"Each command is in it's own separate line, and it's made of these parts:",
|
||||
"- '!!' is the first character sequence of the line and marks it as a command line",
|
||||
"- full command name",
|
||||
"- JSON argument",
|
||||
'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())
|
||||
self.add_toolset(BasicToolset())
|
||||
|
||||
# user message must include a title
|
||||
async def _full_prompt(self, title, message, system_prologue=None):
|
||||
system_lines = [
|
||||
self.config['system_prompt'],
|
||||
self.tool_prompt_section,
|
||||
self.get_toolset_context(),
|
||||
]
|
||||
if system_prologue:
|
||||
system_lines.append(system_prologue)
|
||||
app.tmp.runs[self.id] = self
|
||||
|
||||
messages = [
|
||||
LlamaMessage("system", "\n".join(system_lines)),
|
||||
LlamaMessage("user", "\n".join([
|
||||
message,
|
||||
]))
|
||||
]
|
||||
# Emit run start event
|
||||
app.emit_event("run-started", {
|
||||
"runId": self.id,
|
||||
"agentId": self.agent_id,
|
||||
"userId": self.user_id,
|
||||
"startTime": self.start_time,
|
||||
"input": self.input
|
||||
})
|
||||
|
||||
return self
|
||||
|
||||
full_response = ""
|
||||
agent_response = None
|
||||
def on(self, event_name, callback):
|
||||
"""Register a callback for a named event.
|
||||
|
||||
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))
|
||||
Replays every past event of this type to the callback immediately
|
||||
(with only the data payload), so late listeners catch up on history.
|
||||
"""
|
||||
if event_name not in self.event_listeners:
|
||||
self.event_listeners[event_name] = []
|
||||
self.event_listeners[event_name].append(callback)
|
||||
|
||||
if response.actions:
|
||||
result = await self.execute_prompt_actions(response.actions)
|
||||
messages.append(LlamaMessage("user", "Command Output:\n" + result))
|
||||
# Replay: feed only the data payload of past events
|
||||
for event in self.events:
|
||||
if event["name"] == event_name:
|
||||
callback(event["data"])
|
||||
|
||||
print("Agent executed:", response.actions)
|
||||
print("Result: ", result)
|
||||
def emit_event(self, event_name, data=None):
|
||||
"""Record an event and notify existing listeners.
|
||||
|
||||
continues = False
|
||||
for action in response.actions:
|
||||
if action.is_special("resume"):
|
||||
continues = True
|
||||
Listeners receive only the *data* payload, not the full envelope.
|
||||
"""
|
||||
event = {"name": event_name, "data": data, "time": time.time()}
|
||||
self.events.append(event)
|
||||
listeners = self.event_listeners.get(event_name, [])
|
||||
for cb in listeners:
|
||||
cb(data)
|
||||
|
||||
# Broadcast event to websocket listeners
|
||||
self.app.emit_event("run-event", {
|
||||
"runId": self.id,
|
||||
"name": event_name,
|
||||
"data": data
|
||||
})
|
||||
|
||||
if response.response:
|
||||
agent_response = response.raw_response if not agent_response else f"{agent_response}\n{response.raw_response}"
|
||||
def add_tool_call(self, tool_name, arguments):
|
||||
self.emit_event("tool_call", {"name": tool_name, "arguments": arguments})
|
||||
|
||||
if continues:
|
||||
messages.extend([
|
||||
LlamaMessage("agent", response.raw_response),
|
||||
LlamaMessage("user", "Command Output:\n" + result),
|
||||
])
|
||||
def _serialize_input(self, input):
|
||||
"""Convert ToolSpec objects to serializable dictionaries in input data."""
|
||||
if not input:
|
||||
return input
|
||||
|
||||
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.
|
||||
Each action must be in its own separate line, marked with '!!' at the start.
|
||||
Format: !![query] namespace.command_name {"json": "args"}
|
||||
"""
|
||||
result = ParsedResponse()
|
||||
result.raw_response = string
|
||||
lines = string.split('\n')
|
||||
response_lines = []
|
||||
|
||||
action_pattern = re.compile(r'^!!([\w\.]+)(?:\s+(\{.*\}))?$')
|
||||
|
||||
for line in lines:
|
||||
stripped_line = line.strip()
|
||||
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
|
||||
if '.' in full_command:
|
||||
toolset_name, tool_name = full_command.split('.', 1)
|
||||
else:
|
||||
toolset_name = ''
|
||||
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)
|
||||
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:
|
||||
# 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
|
||||
result[key] = value
|
||||
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
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user