agent: support basic openapi tools (incl. from fastify sandbox)

This commit is contained in:
Olivier Chafik 2024-04-09 23:40:11 +01:00 committed by ochafik
parent 85820f4401
commit 6880f1d4c0
6 changed files with 167 additions and 37 deletions

View file

@ -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"

View file

@ -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,13 +191,16 @@ def main(
tool_functions = []
types = {}
for f in tools:
module = load_module(f)
tool_functions.extend(collect_functions(module))
types.update({
k: v
for k, v in module.__dict__.items()
if isinstance(v, type)
})
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({
k: v
for k, v in module.__dict__.items()
if isinstance(v, type)
})
if std_tools:
tool_functions.extend(collect_functions(StandardTools))

View file

@ -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}')

View 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

View file

@ -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

View file

@ -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,