agent: support basic openapi tools (incl. from fastify sandbox)
This commit is contained in:
parent
85820f4401
commit
6880f1d4c0
6 changed files with 167 additions and 37 deletions
|
@ -108,15 +108,25 @@ The agent can use tools written in Python, or (soon) exposed under OpenAPI endpo
|
|||
so we provide a script to run them in a Docker-sandboxed environment, exposed as an OpenAPI server:
|
||||
|
||||
```bash
|
||||
examples/openai/run_sandboxed_tools.sh \
|
||||
examples/agent/tools/unsafe_python_tools.py 6666 &
|
||||
PORT=9999 examples/openai/run_sandboxed_tools.sh \
|
||||
examples/agent/tools/unsafe_python_tools.py &
|
||||
|
||||
python -m examples.openai.reactor \
|
||||
--model ~/AI/Models/mixtral-8x7b-instruct-v0.1.Q4_K_M.gguf \
|
||||
--tools http://localhost:6666 \
|
||||
python -m examples.agent \
|
||||
--tools http://localhost:9999 \
|
||||
--goal "Whats cos(123) / 23 * 12.6 ?"
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>Show output</summary>
|
||||
|
||||
```
|
||||
💭 Calculate the expression using Python
|
||||
⚙️ execute_python(source="import math\nresult = math.cos(123) / 23 * 12.6") -> {'result': -0.4864525314920599}
|
||||
➡️ "-0.4864525314920599"
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
- [fastify.py](./fastify.py) turns a python module into an OpenAPI endpoint using FastAPI
|
||||
|
||||
- [run_sandboxed_tools.sh](./run_sandboxed_tools.sh) builds and runs a Docker environment with fastify inside it, and exposes its port locally
|
||||
|
@ -125,7 +135,6 @@ so we provide a script to run them in a Docker-sandboxed environment, exposed as
|
|||
|
||||
```bash
|
||||
python -m examples.agent \
|
||||
--model ~/AI/Models/mixtral-8x7b-instruct-v0.1.Q4_K_M.gguf \
|
||||
--tools examples/agent/tools/example_summaries.py \
|
||||
--format PyramidalSummary \
|
||||
--goal "Create a pyramidal summary of Mankind's recent advancements"
|
||||
|
@ -156,7 +165,8 @@ python -m examples.openai \
|
|||
# python -m examples.openai --model mixtral.gguf
|
||||
|
||||
# Agent itself:
|
||||
python -m examples.agent --endpoint http://localhost:8080 \
|
||||
python -m examples.agent \
|
||||
--endpoint http://localhost:8080 \
|
||||
--tools examples/agent/tools/example_summaries.py \
|
||||
--format PyramidalSummary \
|
||||
--goal "Create a pyramidal summary of Mankind's recent advancements"
|
||||
|
|
|
@ -1,21 +1,25 @@
|
|||
import atexit
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
from time import sleep
|
||||
import typer
|
||||
from pydantic import Json, TypeAdapter
|
||||
from pydantic import BaseModel, Json, TypeAdapter
|
||||
from typing import Annotated, Callable, List, Union, Optional, Type
|
||||
import json, requests
|
||||
|
||||
from examples.json_schema_to_grammar import SchemaConverter
|
||||
from examples.agent.openapi_client import OpenAPIMethod, openapi_methods_from_endpoint
|
||||
from examples.agent.tools.std_tools import StandardTools
|
||||
from examples.openai.api import ChatCompletionRequest, ChatCompletionResponse, Message, ResponseFormat, Tool, ToolFunction
|
||||
from examples.agent.utils import collect_functions, load_module
|
||||
from examples.openai.prompting import ToolsPromptStyle
|
||||
|
||||
def _get_params_schema(fn: Callable, verbose):
|
||||
converter = SchemaConverter(prop_order={}, allow_fetch=False, dotall=False, raw_pattern=False)
|
||||
if isinstance(fn, OpenAPIMethod):
|
||||
return fn.parameters_schema
|
||||
|
||||
# converter = SchemaConverter(prop_order={}, allow_fetch=False, dotall=False, raw_pattern=False)
|
||||
schema = TypeAdapter(fn).json_schema()
|
||||
# Do NOT call converter.resolve_refs(schema) here. Let the server resolve local refs.
|
||||
if verbose:
|
||||
|
@ -81,9 +85,7 @@ def completion_with_tool_usage(
|
|||
headers=headers,
|
||||
json=request.model_dump(),
|
||||
)
|
||||
if response.status_code != 200:
|
||||
raise Exception(f"Request failed ({response.status_code}): {response.text}")
|
||||
|
||||
response.raise_for_status()
|
||||
response_json = response.json()
|
||||
response = ChatCompletionResponse(**response_json)
|
||||
if verbose:
|
||||
|
@ -101,8 +103,9 @@ def completion_with_tool_usage(
|
|||
if content:
|
||||
print(f'💭 {content}')
|
||||
|
||||
pretty_call = f'{tool_call.function.name}({", ".join(f"{k}={v}" for k, v in tool_call.function.arguments.items())})'
|
||||
pretty_call = f'{tool_call.function.name}({", ".join(f"{k}={v.model_dump_json() if isinstance(v, BaseModel) else json.dumps(v)}" for k, v in tool_call.function.arguments.items())})'
|
||||
sys.stdout.write(f'⚙️ {pretty_call}')
|
||||
sys.stdout.flush()
|
||||
tool_result = tool_map[tool_call.function.name](**tool_call.function.arguments)
|
||||
sys.stdout.write(f" -> {tool_result}\n")
|
||||
messages.append(Message(
|
||||
|
@ -188,6 +191,9 @@ def main(
|
|||
tool_functions = []
|
||||
types = {}
|
||||
for f in tools:
|
||||
if f.startswith('http://') or f.startswith('https://'):
|
||||
tool_functions.extend(openapi_methods_from_endpoint(f))
|
||||
else:
|
||||
module = load_module(f)
|
||||
tool_functions.extend(collect_functions(module))
|
||||
types.update({
|
||||
|
|
|
@ -27,7 +27,7 @@ def bind_functions(app, module):
|
|||
|
||||
print(f'INFO: Binding /{k}')
|
||||
try:
|
||||
app.post(k)(v)
|
||||
app.post('/' + k)(v)
|
||||
except Exception as e:
|
||||
print(f'WARNING: Failed to bind /{k}\n\t{e}')
|
||||
|
||||
|
|
88
examples/agent/openapi_client.py
Normal file
88
examples/agent/openapi_client.py
Normal file
|
@ -0,0 +1,88 @@
|
|||
|
||||
import json
|
||||
import requests
|
||||
import urllib
|
||||
|
||||
|
||||
class OpenAPIMethod:
|
||||
def __init__(self, url, name, descriptor, catalog):
|
||||
self.url = url
|
||||
self.__name__ = name
|
||||
|
||||
assert 'post' in descriptor, 'Only POST methods are supported'
|
||||
post_descriptor = descriptor['post']
|
||||
|
||||
self.__doc__ = post_descriptor['description']
|
||||
parameters = post_descriptor.get('parameters', [])
|
||||
request_body = post_descriptor.get('requestBody')
|
||||
|
||||
self.parameters = {p['name']: p for p in parameters}
|
||||
assert all(param['in'] == 'query' for param in self.parameters.values()), f'Only query path parameters are supported (path: {path}, descriptor: {json.dumps(descriptor)})'
|
||||
|
||||
self.body = None
|
||||
self.body_name = None
|
||||
if request_body:
|
||||
assert 'application/json' in request_body['content'], f'Only application/json is supported for request body (path: {path}, descriptor: {json.dumps(descriptor)})'
|
||||
self.body = dict(
|
||||
required=request_body['required'],
|
||||
schema=request_body['content']['application/json']['schema'],
|
||||
)
|
||||
|
||||
self.body_name = 'body'
|
||||
i = 2
|
||||
while self.body_name in self.parameters:
|
||||
self.body_name = f'body{i}'
|
||||
i += 1
|
||||
|
||||
self.parameters_schema = dict(
|
||||
type='object',
|
||||
properties={
|
||||
**({
|
||||
self.body_name: self.body['schema']
|
||||
} if self.body else {}),
|
||||
**{
|
||||
name: param['schema']
|
||||
for name, param in self.parameters.items()
|
||||
}
|
||||
},
|
||||
components=catalog.get('components'),
|
||||
required=[name for name, param in self.parameters.items() if param['required']] + ([self.body_name] if self.body and self.body['required'] else [])
|
||||
)
|
||||
|
||||
def __call__(self, **kwargs):
|
||||
if self.body:
|
||||
body = kwargs.pop(self.body_name, None)
|
||||
if self.body['required']:
|
||||
assert body is not None, f'Missing required body parameter: {self.body_name}'
|
||||
else:
|
||||
body = None
|
||||
|
||||
query_params = {}
|
||||
for name, param in self.parameters.items():
|
||||
value = kwargs.pop(name, None)
|
||||
if param['required']:
|
||||
assert value is not None, f'Missing required parameter: {name}'
|
||||
|
||||
assert param['in'] == 'query', 'Only query parameters are supported'
|
||||
query_params[name] = value
|
||||
|
||||
params = "&".join(f"{name}={urllib.parse.quote(value)}" for name, value in query_params.items())
|
||||
url = f'{self.url}?{params}'
|
||||
response = requests.post(url, json=body)
|
||||
response.raise_for_status()
|
||||
response_json = response.json()
|
||||
|
||||
return response_json
|
||||
|
||||
|
||||
def openapi_methods_from_endpoint(url):
|
||||
catalog_url = f'{url}/openapi.json'
|
||||
catalog_response = requests.get(catalog_url)
|
||||
catalog_response.raise_for_status()
|
||||
catalog = catalog_response.json()
|
||||
|
||||
methods = [
|
||||
OpenAPIMethod(url=f'{url}{path}', name=path.replace('/', ' ').strip().replace(' ', '_'), descriptor=descriptor, catalog=catalog)
|
||||
for path, descriptor in catalog['paths'].items()
|
||||
]
|
||||
return methods
|
|
@ -1,8 +1,28 @@
|
|||
import math
|
||||
import json
|
||||
import sys
|
||||
import types
|
||||
from typing import Dict, Union
|
||||
|
||||
def eval_python_expression(expr: str) -> float:
|
||||
def execute_python(source: str) -> Union[Dict, str]:
|
||||
"""
|
||||
Evaluate a Python expression reliably.
|
||||
This can be used to compute complex nested mathematical expressions, or any python, really.
|
||||
Evaluate a Python program and return the globals it declared.
|
||||
Can be used to compute mathematical expressions.
|
||||
|
||||
Args:
|
||||
source: contain valid, executable and pure Python code. Should also import any required Python packages.
|
||||
For example: "import math\nresult = math.cos(2) * 10"
|
||||
|
||||
Returns:
|
||||
dict | str: A dictionary containing variables declared, or an error message if an exception occurred.
|
||||
"""
|
||||
return eval(expr)
|
||||
namespace = {}
|
||||
sys.stderr.write(f"Executing Python program:\n{source}\n")
|
||||
exec(source, namespace)
|
||||
results = {
|
||||
k: v
|
||||
for k, v in namespace.items()
|
||||
if not k.startswith('_') and not isinstance(v, type) and not callable(v) and not isinstance(v, types.ModuleType)
|
||||
}
|
||||
sys.stderr.write(f"Results: {json.dumps(results, indent=2)}\n")
|
||||
|
||||
return results
|
||||
|
|
|
@ -580,7 +580,13 @@ class ThoughtfulStepsToolsChatHandler(ChatHandler):
|
|||
# args.response_schema = args.response_schema or {}
|
||||
converter = SchemaConverter(prop_order={}, allow_fetch=False, dotall=False, raw_pattern=False)
|
||||
|
||||
response_schema = args.response_schema or {"type": "string"}
|
||||
response_schema = converter.resolve_refs(args.response_schema or {"type": "string"}, 'response')
|
||||
tool_parameter_schemas = {
|
||||
tool.function.name: converter.resolve_refs(tool.function.parameters, tool.function.name)
|
||||
for tool in self.args.tools
|
||||
}
|
||||
# sys.stderr.write(f"# RESOLVED RESPONSE SCHEMA: {json.dumps(response_schema, indent=2)}\n")
|
||||
# sys.stderr.write(f"# RESOLVED TOOL PARAMETER SCHEMA: {json.dumps(tool_parameter_schemas, indent=2)}\n")
|
||||
converter.visit(
|
||||
_make_bespoke_schema(
|
||||
response_schema,
|
||||
|
@ -589,12 +595,12 @@ class ThoughtfulStepsToolsChatHandler(ChatHandler):
|
|||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"const": tool.function.name},
|
||||
"arguments": tool.function.parameters,
|
||||
"name": {"const": tool_name},
|
||||
"arguments": tool_parameters,
|
||||
},
|
||||
"required": ["name", "arguments"]
|
||||
}
|
||||
for tool in self.args.tools
|
||||
for tool_name, tool_parameters in tool_parameter_schemas.items()
|
||||
]
|
||||
},
|
||||
parallel_calls=parallel_calls,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue