From 95850084335448b6449cfe90283c0dc3f83d7e3c Mon Sep 17 00:00:00 2001 From: Shivering Ocelot Date: Mon, 8 Jun 2026 05:18:38 -0500 Subject: [PATCH] wip: add tools to agent control and improve tool class --- backend/src/agent.py | 22 ++++++++- backend/src/agents/expert.py | 8 +++- backend/src/app.py | 6 ++- backend/src/features/__init__.py | 0 backend/src/features/calc.py | 38 +++++++++++++++ backend/src/tool.py | 82 ++++++++++++++++++++++++++++---- frontend/src/runs.js | 1 + 7 files changed, 144 insertions(+), 13 deletions(-) create mode 100644 backend/src/features/__init__.py create mode 100644 backend/src/features/calc.py diff --git a/backend/src/agent.py b/backend/src/agent.py index bf2c172..f3d8ef2 100644 --- a/backend/src/agent.py +++ b/backend/src/agent.py @@ -21,7 +21,7 @@ class Agent: # - identifier (for kind in creation) # - schema: list[AgentField{name, type: str}] (for configuration) - def __init__(self, app, data): + def __init__(self, app, data, resources=[]): self.app = app # These variables are set by '_set_basic_info' @@ -31,6 +31,18 @@ class Agent: # 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 + + 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 @@ -110,7 +122,7 @@ class Agent: # Returns all resource values matching the kind and key def get_resources(self, kind, key): - self.app + pass # TODO lol class DebugAgent(Agent): @@ -226,6 +238,12 @@ class AgentRun: self.emit_event('reasoning', self.reasoning) if chunk: self.reasoning.feed(chunk) + + 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}) diff --git a/backend/src/agents/expert.py b/backend/src/agents/expert.py index ec07dd6..5b25b8c 100644 --- a/backend/src/agents/expert.py +++ b/backend/src/agents/expert.py @@ -16,12 +16,16 @@ class ExpertAgent(Agent): AgentField('repetition_penalty', 'number', default=None), ] - def __init__(self, app, data): - Agent.__init__(self, app, data) + def __init__(self, *args): + Agent.__init__(self, *args) async def run(self, run): opts = {} + agent_tools = self.tools + print('agent tools ' + repr(agent_tools)) + run.add_tools(agent_tools) + if self.config.get("temperature") is not None: opts["temperature"] = self.config["temperature"] if self.config.get("repetition_penalty") is not None: diff --git a/backend/src/app.py b/backend/src/app.py index 9238ffa..e335ce8 100644 --- a/backend/src/app.py +++ b/backend/src/app.py @@ -41,6 +41,9 @@ class App(ChatMixin): agents_mod.ExpertAgent, ] + from features.calc import Calc + self.tool_classes = [Calc] + # --- Connection Management (Synchronous) --- def on_event(self, callback): @@ -231,12 +234,13 @@ class App(ChatMixin): async def get_agent_control(self, agent_id): agent = await self.get_agent(agent_id) + resources = await self.get_agent_resources(agent_id) agent_class = next((kind for kind in self.agent_classes if kind.identifier == agent.kind), None) if agent_class is None: raise Exception(f"invalid agent kind: {agent.kind}") - control = agent_class(self, agent.data) + control = agent_class(self, agent.data, resources) control._set_basic_info(agent_id, agent.name, agent.user_id) return control diff --git a/backend/src/features/__init__.py b/backend/src/features/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/features/calc.py b/backend/src/features/calc.py new file mode 100644 index 0000000..7458a59 --- /dev/null +++ b/backend/src/features/calc.py @@ -0,0 +1,38 @@ +import sys +import os +from tool import Tool + +class Calc(Tool): + """ + A calculator tool that evaluates mathematical expressions. + + This tool can evaluate basic arithmetic operations (+, -, *, /, **, etc.) + and more complex mathematical expressions using Python's eval function. + """ + + def run(self, expression: str) -> str: + """ + Evaluate a mathematical expression and return the result. + + Args: + expression: A string containing a mathematical expression to evaluate + + Returns: + The result of the expression as a string + + Raises: + Exception: If the expression cannot be evaluated + """ + try: + # Only allow mathematical operations for safety + allowed_names = { + 'abs': abs, 'round': round, + 'min': min, 'max': max, + 'sum': sum, 'pow': pow, + 'int': int, 'float': float, + '__builtins__': {} + } + result = eval(expression, {"__builtins__": None}, allowed_names) + return str(result) + except Exception as e: + return f"Error evaluating expression: {str(e)}" \ No newline at end of file diff --git a/backend/src/tool.py b/backend/src/tool.py index fd4cd8c..7f052d5 100644 --- a/backend/src/tool.py +++ b/backend/src/tool.py @@ -1,3 +1,6 @@ +import inspect +from typing import get_type_hints + class ToolParameter: """Describes a single input parameter for a tool. @@ -55,6 +58,14 @@ class ToolSpec: self.description = description self.parameters = parameters or [] self.func = func + + def clone (self): + return ToolSpec( + name = self.name, + description = self.description, + parameters = self.parameters, + func = self.func, + ) # -- serialization helpers ------------------------------------------- @@ -119,26 +130,81 @@ class Tool: """Base class for tools that implement their logic in a 'run' method. Subclasses should override 'run' to provide the tool's functionality. + + The Tool class provides a method to_spec() that automatically generates + a ToolSpec from the class name, docstring, and run method signature. + This allows tools to be used with LLM function calling without manually + creating ToolSpec objects. The generated ToolSpec is cached at initialization. """ - def __init__(self): - pass + def __init__(self, name=None): + # Generate and cache the ToolSpec during initialization + self.name = name or self.__class__.tool_name() + self._spec = self._generate_spec() def run(self, **kwargs): """Execute the tool. To be overridden by subclasses.""" raise NotImplementedError("Tool subclasses must override the 'run' method.") + + @classmethod + def tool_name(cls): + return ''.join(['_'+c.lower() if c.isupper() else c for c in cls.__name__]).lstrip('_') - def to_spec(self): - """Generate a ToolSpec from this tool instance.""" + + def _generate_spec(self): + """Generate a ToolSpec from this tool instance. + + Automatically creates a ToolSpec by: + - Using the class name (converted to snake_case) as the tool name + - Using the class docstring as the description + - Inspecting the run method signature to create parameters + """ + + # Use the class docstring as the description + description = inspect.getdoc(self.__class__) or "" + + # Get parameters from the run method signature + parameters = [] + run_signature = inspect.signature(self.run) + type_hints = get_type_hints(self.run) + + for param_name, param in run_signature.parameters.items(): + # Skip 'self' parameter + if param_name == 'self': + continue + + # Get type information + param_type = type_hints.get(param_name, "string") + if isinstance(param_type, type): + type_name = param_type.__name__.lower() + else: + type_name = str(param_type).lower() + + # Check if parameter is required (no default or default is None) + is_required = param.default is inspect.Parameter.empty or param.default is None + + # Create ToolParameter + tool_param = ToolParameter( + name=param_name, + type=type_name, + description="", + required=is_required + ) + parameters.append(tool_param) + return ToolSpec( - name=None, # TODO: Computed from class name, or from class attribute - description=None, # TODO: From class doc comment - parameters=None, # TODO: Class attribute + name=self.name, + description=description, + parameters=parameters, func=self.run, ) + def get_spec(self): + """Return the cached ToolSpec for this tool.""" + return self._spec + def __repr__(self): - return f"Tool(name={self.name!r}, params={[p.name for p in self.parameters]})" + return f"Tool(name={self._spec.name!r}, params={[p.name for p in self._spec.parameters]})" class ToolCall: """Represents a single tool invocation request emitted by the model. diff --git a/frontend/src/runs.js b/frontend/src/runs.js index f683ae9..67db622 100644 --- a/frontend/src/runs.js +++ b/frontend/src/runs.js @@ -95,6 +95,7 @@ async function init() { runs = res.runs || []; // Listen for run events + if (unsubscribeRunEvents) unsubscribeRunEvents(); unsubscribeRunEvents = backend.onAction('run-event', updateRun); }