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:
|
so we provide a script to run them in a Docker-sandboxed environment, exposed as an OpenAPI server:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
examples/openai/run_sandboxed_tools.sh \
|
PORT=9999 examples/openai/run_sandboxed_tools.sh \
|
||||||
examples/agent/tools/unsafe_python_tools.py 6666 &
|
examples/agent/tools/unsafe_python_tools.py &
|
||||||
|
|
||||||
python -m examples.openai.reactor \
|
python -m examples.agent \
|
||||||
--model ~/AI/Models/mixtral-8x7b-instruct-v0.1.Q4_K_M.gguf \
|
--tools http://localhost:9999 \
|
||||||
--tools http://localhost:6666 \
|
|
||||||
--goal "Whats cos(123) / 23 * 12.6 ?"
|
--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
|
- [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
|
- [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
|
```bash
|
||||||
python -m examples.agent \
|
python -m examples.agent \
|
||||||
--model ~/AI/Models/mixtral-8x7b-instruct-v0.1.Q4_K_M.gguf \
|
|
||||||
--tools examples/agent/tools/example_summaries.py \
|
--tools examples/agent/tools/example_summaries.py \
|
||||||
--format PyramidalSummary \
|
--format PyramidalSummary \
|
||||||
--goal "Create a pyramidal summary of Mankind's recent advancements"
|
--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
|
# python -m examples.openai --model mixtral.gguf
|
||||||
|
|
||||||
# Agent itself:
|
# 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 \
|
--tools examples/agent/tools/example_summaries.py \
|
||||||
--format PyramidalSummary \
|
--format PyramidalSummary \
|
||||||
--goal "Create a pyramidal summary of Mankind's recent advancements"
|
--goal "Create a pyramidal summary of Mankind's recent advancements"
|
||||||
|
|
|
@ -1,21 +1,25 @@
|
||||||
import atexit
|
import atexit
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from time import sleep
|
from time import sleep
|
||||||
import typer
|
import typer
|
||||||
from pydantic import Json, TypeAdapter
|
from pydantic import BaseModel, Json, TypeAdapter
|
||||||
from typing import Annotated, Callable, List, Union, Optional, Type
|
from typing import Annotated, Callable, List, Union, Optional, Type
|
||||||
import json, requests
|
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.agent.tools.std_tools import StandardTools
|
||||||
from examples.openai.api import ChatCompletionRequest, ChatCompletionResponse, Message, ResponseFormat, Tool, ToolFunction
|
from examples.openai.api import ChatCompletionRequest, ChatCompletionResponse, Message, ResponseFormat, Tool, ToolFunction
|
||||||
from examples.agent.utils import collect_functions, load_module
|
from examples.agent.utils import collect_functions, load_module
|
||||||
from examples.openai.prompting import ToolsPromptStyle
|
from examples.openai.prompting import ToolsPromptStyle
|
||||||
|
|
||||||
def _get_params_schema(fn: Callable, verbose):
|
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()
|
schema = TypeAdapter(fn).json_schema()
|
||||||
# Do NOT call converter.resolve_refs(schema) here. Let the server resolve local refs.
|
# Do NOT call converter.resolve_refs(schema) here. Let the server resolve local refs.
|
||||||
if verbose:
|
if verbose:
|
||||||
|
@ -81,9 +85,7 @@ def completion_with_tool_usage(
|
||||||
headers=headers,
|
headers=headers,
|
||||||
json=request.model_dump(),
|
json=request.model_dump(),
|
||||||
)
|
)
|
||||||
if response.status_code != 200:
|
response.raise_for_status()
|
||||||
raise Exception(f"Request failed ({response.status_code}): {response.text}")
|
|
||||||
|
|
||||||
response_json = response.json()
|
response_json = response.json()
|
||||||
response = ChatCompletionResponse(**response_json)
|
response = ChatCompletionResponse(**response_json)
|
||||||
if verbose:
|
if verbose:
|
||||||
|
@ -101,8 +103,9 @@ def completion_with_tool_usage(
|
||||||
if content:
|
if content:
|
||||||
print(f'💭 {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.write(f'⚙️ {pretty_call}')
|
||||||
|
sys.stdout.flush()
|
||||||
tool_result = tool_map[tool_call.function.name](**tool_call.function.arguments)
|
tool_result = tool_map[tool_call.function.name](**tool_call.function.arguments)
|
||||||
sys.stdout.write(f" -> {tool_result}\n")
|
sys.stdout.write(f" -> {tool_result}\n")
|
||||||
messages.append(Message(
|
messages.append(Message(
|
||||||
|
@ -188,13 +191,16 @@ def main(
|
||||||
tool_functions = []
|
tool_functions = []
|
||||||
types = {}
|
types = {}
|
||||||
for f in tools:
|
for f in tools:
|
||||||
module = load_module(f)
|
if f.startswith('http://') or f.startswith('https://'):
|
||||||
tool_functions.extend(collect_functions(module))
|
tool_functions.extend(openapi_methods_from_endpoint(f))
|
||||||
types.update({
|
else:
|
||||||
k: v
|
module = load_module(f)
|
||||||
for k, v in module.__dict__.items()
|
tool_functions.extend(collect_functions(module))
|
||||||
if isinstance(v, type)
|
types.update({
|
||||||
})
|
k: v
|
||||||
|
for k, v in module.__dict__.items()
|
||||||
|
if isinstance(v, type)
|
||||||
|
})
|
||||||
|
|
||||||
if std_tools:
|
if std_tools:
|
||||||
tool_functions.extend(collect_functions(StandardTools))
|
tool_functions.extend(collect_functions(StandardTools))
|
||||||
|
|
|
@ -27,7 +27,7 @@ def bind_functions(app, module):
|
||||||
|
|
||||||
print(f'INFO: Binding /{k}')
|
print(f'INFO: Binding /{k}')
|
||||||
try:
|
try:
|
||||||
app.post(k)(v)
|
app.post('/' + k)(v)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f'WARNING: Failed to bind /{k}\n\t{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.
|
Evaluate a Python program and return the globals it declared.
|
||||||
This can be used to compute complex nested mathematical expressions, or any python, really.
|
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 {}
|
# args.response_schema = args.response_schema or {}
|
||||||
converter = SchemaConverter(prop_order={}, allow_fetch=False, dotall=False, raw_pattern=False)
|
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(
|
converter.visit(
|
||||||
_make_bespoke_schema(
|
_make_bespoke_schema(
|
||||||
response_schema,
|
response_schema,
|
||||||
|
@ -589,12 +595,12 @@ class ThoughtfulStepsToolsChatHandler(ChatHandler):
|
||||||
{
|
{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"name": {"const": tool.function.name},
|
"name": {"const": tool_name},
|
||||||
"arguments": tool.function.parameters,
|
"arguments": tool_parameters,
|
||||||
},
|
},
|
||||||
"required": ["name", "arguments"]
|
"required": ["name", "arguments"]
|
||||||
}
|
}
|
||||||
for tool in self.args.tools
|
for tool_name, tool_parameters in tool_parameter_schemas.items()
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
parallel_calls=parallel_calls,
|
parallel_calls=parallel_calls,
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue