diff --git a/examples/agent/README.md b/examples/agent/README.md index 48a4e9931..6cdabd0e2 100644 --- a/examples/agent/README.md +++ b/examples/agent/README.md @@ -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 ?" ``` +
+ Show output + + ``` + 💭 Calculate the expression using Python + ⚙️ execute_python(source="import math\nresult = math.cos(123) / 23 * 12.6") -> {'result': -0.4864525314920599} + ➡️ "-0.4864525314920599" + ``` + +
+ - [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" diff --git a/examples/agent/agent.py b/examples/agent/agent.py index 7249a1fbd..ca5d2bd9c 100644 --- a/examples/agent/agent.py +++ b/examples/agent/agent.py @@ -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)) diff --git a/examples/agent/fastify.py b/examples/agent/fastify.py index ccffe9d84..0cfd5f868 100644 --- a/examples/agent/fastify.py +++ b/examples/agent/fastify.py @@ -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}') diff --git a/examples/agent/openapi_client.py b/examples/agent/openapi_client.py new file mode 100644 index 000000000..0a6980b73 --- /dev/null +++ b/examples/agent/openapi_client.py @@ -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 diff --git a/examples/agent/tools/unsafe_python_tools.py b/examples/agent/tools/unsafe_python_tools.py index 2b2d60e51..4a8a103c5 100644 --- a/examples/agent/tools/unsafe_python_tools.py +++ b/examples/agent/tools/unsafe_python_tools.py @@ -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 diff --git a/examples/openai/prompting.py b/examples/openai/prompting.py index 3de252069..10f68fdce 100644 --- a/examples/openai/prompting.py +++ b/examples/openai/prompting.py @@ -54,7 +54,7 @@ class ChatTemplate(BaseModel): template: str eos_token: str bos_token: str - + inferred_tool_style: Annotated[Optional['ToolsPromptStyle'], Field(exclude=True)] = None expects_stringified_function_arguments: Annotated[Optional[bool], Field(exclude=True)] = None expects_strict_user_assistant_alternance: Annotated[Optional[bool], Field(exclude=True)] = None @@ -103,7 +103,7 @@ class ChatTemplate(BaseModel): # if self.inferred_tool_style == ToolsPromptStyle.TYPESCRIPT_FUNCTIONARY_V2: user_msg = Message(role="user", content="Hey") assistant_msg = Message(role="assistant", content="I, Robot") - + self.expects_strict_user_assistant_alternance = not succeeds([assistant_msg, user_msg]) and succeeds([user_msg, assistant_msg]) thought = "Precious thought" @@ -193,7 +193,7 @@ class ChatHandler(ABC): @abstractmethod def parse(self, s: str) -> Optional[Message]: raise NotImplementedError() - + def add_system_prompt(self, messages: list[Message], system_prompt: Message) -> list[Message]: assert system_prompt.role == "system" @@ -233,7 +233,7 @@ class ChatHandler(ABC): }, indent=2) ) # Fall through to benefit from role normalization - + if m.tool_calls: if not self.args.chat_template.formats_tool_call or not self.args.chat_template.formats_tool_call_content: return Message( @@ -276,9 +276,9 @@ class ChatHandler(ABC): return Message(role="user", content=f'[{m.role.upper()}]{m.content}[/{m.role.upper()}]') else: return m - + messages=[normalize(m) for m in messages] - + if self.args.chat_template.expects_strict_user_assistant_alternance: new_messages=[] current_role = 'user' @@ -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,