Files
hub/backend/src/agent.py
T

363 lines
12 KiB
Python

import re
import inspect
import time
import asyncio
import traceback
from temporary import generate_tmp_id
from stream import Stream
# Tool related classes must be moved to a different module
class AgentField:
def __init__(self, name, tp, default=None):
self.name=name
self.type=tp
self.default=default
# Instances are created and destroyed constantly, agents must not depend solely on in-memory data
# to be useful and consistent
class Agent:
# Class variables, must be defined by subclasses of Agent:
# - identifier (for kind in creation)
# - schema: list[AgentField{name, type: str}] (for configuration)
def __init__(self, app, data, resources=[]):
self.app = app
# These variables are set by '_set_basic_info'
self.id = None
self.name = None
# Auto populated based on the schema fields
agent_class = type(self)
self.config = {field.name: data.get(field.name, field.default) for field in getattr(agent_class, 'schema_fields', [])}
self.resources = resources
# should be in expert init?
self.tools = []
for resource in self.resources:
if resource.kind != 'tool': continue
if resource.value.get('type') == 'native' and 'name' in resource.value:
tool_cls = next((t for t in self.app.tool_classes if t.tool_name() == resource.value['name']), None)
if tool_cls:
tool = tool_cls()
spec = tool.get_spec().clone()
spec.name = resource.key
self.tools.append(spec)
def _set_basic_info(self, id, name, user_id):
self.id = id
self.name = name
self.user_id = user_id
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,
},
)
out_stream = Stream(self.app)
def on_output_event(stream):
def on_chunk(chunk):
out_stream.feed(chunk)
stream.listen(on_chunk, lambda: None)
def on_done(data):
out_stream.close()
run.on('output', on_output_event)
run.on('done', on_done)
await self.run(run)
return out_stream
async def received_message(self, message):
chat = await self.app.get_chat(message.chat_id)
thread = chat.get_thread(message.thread_id)
thread.skip_list += ['log', 'resource']
await thread.load_page()
messages = []
for msg in thread.messages:
role = self._role_for_message(msg)
messages.append({"role": role, "content": msg.content})
remotes = []
def add_remote(name):
if name not in remotes and (self.user_id, name) in self.app.remotes:
remotes.append(name)
remote_messages = await thread.fetch_kind_page('resource', 'remote')
for msg in remote_messages:
add_remote(msg.content)
run = AgentRun.create(
self.app,
agent_id=self.id,
user_id=self.user_id,
input={
"messages": messages,
"chat_id": message.chat_id,
"thread_id": message.thread_id,
"remotes": remotes,
},
)
async def on_output_event(stream):
print("[agent] run output " + repr(stream))
new_message = await self.app.send_chat_message(
sender=self.id,
chat_id=message.chat_id,
thread_id=message.thread_id,
content=stream,
)
run.emit_event('chat-message', {
'messageId': new_message['id'],
'chatId': message.chat_id,
'threadId': message.thread_id,
})
async def on_reasoning_event(stream):
print("[agent] run output " + repr(stream))
new_message = await self.app.send_chat_message(
sender=self.id,
chat_id=message.chat_id,
thread_id=message.thread_id,
content=stream,
kind="log",
subkind="reasoning",
)
run.emit_event('chat-message', {
'messageId': new_message['id'],
'chatId': message.chat_id,
'threadId': message.thread_id,
})
async def on_toolcall_event(stream):
print("[agent] run output " + repr(stream))
new_message = await self.app.send_chat_message(
sender=self.id,
chat_id=message.chat_id,
thread_id=message.thread_id,
content=stream,
kind="log",
subkind="tool-call",
)
run.emit_event('chat-message', {
'messageId': new_message['id'],
'chatId': message.chat_id,
'threadId': message.thread_id,
})
await run.on_async('reasoning', on_reasoning_event)
await run.on_async('output', on_output_event)
await run.on_async('tool-call', on_toolcall_event)
await self.run(run)
return run
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 the key when given
def get_resources(self, kind, key=None):
return [r for r in self.resources
if r.kind == kind and (key is None or r.key == key)]
class DebugAgent(Agent):
identifier = "debug"
schema_fields = []
tools = []
async def received_message(self, message):
chat_view = await self.app.get_chat_view(message.chat_id)
response = chat_view.debug_string()
await self.app.send_chat_message(
chat_id=message.chat_id,
sender=self.id,
content=response,
thread_id=chat_view.current_thread_id,
)
# 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
# I/O envelope (no generic data bag)
self.input = input or {}
self.events = []
self.event_listeners = {}
# Default Streams
self.output = None
self.reasoning = None
# Cancellation tracking
# You should use 'cancelled', task and thread are for
# external tasks that cannot be cancelled reliably
self.task = None # For asyncio tasks
self.thread = None
self.cancelled = False
app.tmp.runs[self.id] = self
# Emit run start event
self.emit_event('created', {
"agentId": self.agent_id,
"userId": self.user_id,
"startTime": self.start_time,
"input": self.input,
})
return self
def on(self, event_name, callback):
"""Register a callback for a named event.
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)
# Replay: feed only the data payload of past events
for event in self.events:
if event["name"] == event_name:
callback(event["data"])
async def on_async(self, event_name, callback):
event_loop = asyncio.get_event_loop()
def new_callback(data):
event_loop.create_task(callback(data))
self.on(event_name, new_callback)
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.
"""
event = {"name": event_name, "data": data, "time": time.time()}
self.events.append(event)
listeners = self.event_listeners.get(event_name, [])
for cb in listeners:
try:
cb(data)
except Exception as e:
print(f"ERROR: handling event '{event_name}' in run {self.id}\n" + str(e))
print(traceback.format_exc())
# Broadcast event to websocket listeners
self.app.emit_event("run-event", {
"runId": self.id,
"name": event_name,
"data": data
})
def add_tools(self, tools):
if "tools" in self.input:
self.input["tools"] += tools
else:
self.input["tools"] = tools
def add_tool_call(self, tool_name, arguments):
self.emit_event("tool_call", {"name": tool_name, "arguments": arguments})
def _serialize_input(self, input):
"""Convert ToolSpec objects to serializable dictionaries in input data."""
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:
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 cancel(self):
"""Cancel the agent run and stop any ongoing inference."""
if self.cancelled or self.done:
return
self.cancelled = True
print(f"Cancelling agent run {self.id}")
# Cancel asyncio task if exists
if self.task and not self.task.done():
self.task.cancel()
# Thread cannot be forcefully cancelled, but we can signal
if self.thread and self.thread.is_alive():
# The thread should be checking self.cancelled in its loop
print(f"Signalled thread to stop for run {self.id}")
# Close the stream and mark as done
self.close()
# Emit cancellation event
self.emit_event("cancelled", {})
# Broadcast cancellation event
self.app.emit_event("run-cancelled", {
"runId": self.id,
"agentId": self.agent_id,
"endTime": int(time.time()),
})
def close(self):
if self.done:
return
self.done = True
self.end_time = int(time.time())
if self.output: self.output.close()
if self.reasoning: self.reasoning.close()
self.emit_event("done", {})