Skip to content

Commit 8299fac

Browse files
author
ochafik
committed
tool-call: adapt very simple agent + docker isolation from ggml-org#6389
1 parent 10f9fe8 commit 8299fac

File tree

4 files changed

+414
-0
lines changed

4 files changed

+414
-0
lines changed

examples/tool-call/README.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Agents / Tool Calling w/ llama.cpp
2+
3+
- Install prerequisite: [uv](https://docs.astral.sh/uv/) (used to simplify python deps)
4+
5+
- Run `llama-server` w/ jinja templates:
6+
7+
```bash
8+
# make -j LLAMA_CURL=1 llama-server
9+
./llama-server \
10+
-mu https://huggingface.co/lmstudio-community/Meta-Llama-3.1-70B-Instruct-GGUF/resolve/main/Meta-Llama-3.1-70B-Instruct-Q4_K_M.gguf \
11+
--jinja \
12+
-c 8192 -fa
13+
```
14+
15+
- Run some tools inside a docker container
16+
17+
```bash
18+
docker run --rm -it \
19+
-p "8088:8088" \
20+
-v $PWD/examples/tool-call:/src \
21+
ghcr.io/astral-sh/uv:python3.12-alpine \
22+
uv run /src/fastify.py --port 8088 /src/tools.py
23+
```
24+
25+
- Verify which tools have been exposed: http://localhost:8088/docs
26+
27+
- Run the agent with a given goal:
28+
29+
```bash
30+
uv run examples/tool-call/agent.py \
31+
--tool-endpoint http://localhost:8088 \
32+
--goal "What is the sum of 2535 squared and 32222000403 then multiplied by one and a half. What's a third of the result?"
33+
```

examples/tool-call/agent.py

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
# /// script
2+
# requires-python = ">=3.11"
3+
# dependencies = [
4+
# "fastapi",
5+
# "openai",
6+
# "pydantic",
7+
# "requests",
8+
# "uvicorn",
9+
# "typer",
10+
# ]
11+
# ///
12+
import json
13+
import openai
14+
from pydantic import BaseModel
15+
import requests
16+
import sys
17+
import typer
18+
from typing import Annotated, List, Optional
19+
import urllib
20+
21+
22+
class OpenAPIMethod:
23+
def __init__(self, url, name, descriptor, catalog):
24+
self.url = url
25+
self.__name__ = name
26+
27+
assert 'post' in descriptor, 'Only POST methods are supported'
28+
post_descriptor = descriptor['post']
29+
30+
self.__doc__ = post_descriptor.get('description', '')
31+
parameters = post_descriptor.get('parameters', [])
32+
request_body = post_descriptor.get('requestBody')
33+
34+
self.parameters = {p['name']: p for p in parameters}
35+
assert all(param['in'] == 'query' for param in self.parameters.values()), f'Only query path parameters are supported (path: {url}, descriptor: {json.dumps(descriptor)})'
36+
37+
self.body = None
38+
if request_body:
39+
assert 'application/json' in request_body['content'], f'Only application/json is supported for request body (path: {url}, descriptor: {json.dumps(descriptor)})'
40+
41+
body_name = 'body'
42+
i = 2
43+
while body_name in self.parameters:
44+
body_name = f'body{i}'
45+
i += 1
46+
47+
self.body = dict(
48+
name=body_name,
49+
required=request_body['required'],
50+
schema=request_body['content']['application/json']['schema'],
51+
)
52+
53+
self.parameters_schema = dict(
54+
type='object',
55+
properties={
56+
**({
57+
self.body['name']: self.body['schema']
58+
} if self.body else {}),
59+
**{
60+
name: param['schema']
61+
for name, param in self.parameters.items()
62+
}
63+
},
64+
components=catalog.get('components'),
65+
required=[name for name, param in self.parameters.items() if param['required']] + ([self.body['name']] if self.body and self.body['required'] else [])
66+
)
67+
68+
def __call__(self, **kwargs):
69+
if self.body:
70+
body = kwargs.pop(self.body['name'], None)
71+
if self.body['required']:
72+
assert body is not None, f'Missing required body parameter: {self.body["name"]}'
73+
else:
74+
body = None
75+
76+
query_params = {}
77+
for name, param in self.parameters.items():
78+
value = kwargs.pop(name, None)
79+
if param['required']:
80+
assert value is not None, f'Missing required parameter: {name}'
81+
82+
assert param['in'] == 'query', 'Only query parameters are supported'
83+
query_params[name] = value
84+
85+
params = "&".join(f"{name}={urllib.parse.quote(value)}" for name, value in query_params.items())
86+
url = f'{self.url}?{params}'
87+
response = requests.post(url, json=body)
88+
response.raise_for_status()
89+
response_json = response.json()
90+
91+
return response_json
92+
93+
94+
def main(
95+
goal: Annotated[str, typer.Option()],
96+
api_key: Optional[str] = None,
97+
tool_endpoint: Optional[List[str]] = None,
98+
format: Annotated[Optional[str], typer.Option(help="The output format: either a Python type (e.g. 'float' or a Pydantic model defined in one of the tool files), or a JSON schema, e.g. '{\"format\": \"date\"}'")] = None,
99+
max_iterations: Optional[int] = 10,
100+
parallel_calls: Optional[bool] = False,
101+
verbose: bool = False,
102+
# endpoint: Optional[str] = None,
103+
endpoint: str = "http://localhost:8080/v1/",
104+
):
105+
106+
openai.api_key = api_key
107+
openai.base_url = endpoint
108+
109+
tool_map = {}
110+
tools = []
111+
112+
for url in (tool_endpoint or []):
113+
assert url.startswith('http://') or url.startswith('https://'), f'Tools must be URLs, not local files: {url}'
114+
115+
catalog_url = f'{url}/openapi.json'
116+
catalog_response = requests.get(catalog_url)
117+
catalog_response.raise_for_status()
118+
catalog = catalog_response.json()
119+
120+
for path, descriptor in catalog['paths'].items():
121+
fn = OpenAPIMethod(url=f'{url}{path}', name=path.replace('/', ' ').strip().replace(' ', '_'), descriptor=descriptor, catalog=catalog)
122+
tool_map[fn.__name__] = fn
123+
if verbose:
124+
sys.stderr.write(f'# PARAMS SCHEMA ({fn.__name__}): {json.dumps(fn.parameters_schema, indent=2)}\n')
125+
tools.append(dict(
126+
type="function",
127+
function=dict(
128+
name=fn.__name__,
129+
description=fn.__doc__ or '',
130+
parameters=fn.parameters_schema,
131+
)
132+
)
133+
)
134+
135+
sys.stdout.write(f'🛠️ {", ".join(tool_map.keys())}\n')
136+
137+
messages = [
138+
dict(
139+
role="user",
140+
content=goal,
141+
)
142+
]
143+
144+
i = 0
145+
while (max_iterations is None or i < max_iterations):
146+
147+
response = openai.chat.completions.create(
148+
model="gpt-4o",
149+
messages=messages,
150+
tools=tools,
151+
)
152+
153+
if verbose:
154+
sys.stderr.write(f'# RESPONSE: {response}\n')
155+
156+
assert len(response.choices) == 1
157+
choice = response.choices[0]
158+
159+
content = choice.message.content
160+
if choice.finish_reason == "tool_calls":
161+
messages.append(choice.message)
162+
for tool_call in choice.message.tool_calls:
163+
if content:
164+
print(f'💭 {content}')
165+
166+
args = json.loads(tool_call.function.arguments)
167+
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 args.items())})'
168+
sys.stdout.write(f'⚙️ {pretty_call}')
169+
sys.stdout.flush()
170+
tool_result = tool_map[tool_call.function.name](**args)
171+
sys.stdout.write(f" → {tool_result}\n")
172+
messages.append(dict(
173+
tool_call_id=tool_call.id,
174+
role="tool",
175+
name=tool_call.function.name,
176+
content=f'{tool_result}',
177+
# content=f'{pretty_call} = {tool_result}',
178+
))
179+
else:
180+
assert content
181+
print(content)
182+
183+
i += 1
184+
185+
if max_iterations is not None:
186+
raise Exception(f"Failed to get a valid response after {max_iterations} tool calls")
187+
188+
if __name__ == '__main__':
189+
typer.run(main)

examples/tool-call/fastify.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# /// script
2+
# requires-python = ">=3.11"
3+
# dependencies = [
4+
# "fastapi",
5+
# "uvicorn",
6+
# "typer",
7+
# ]
8+
# ///
9+
'''
10+
Binds the functions of a python script as a FastAPI server.
11+
'''
12+
import os
13+
import sys
14+
import fastapi, uvicorn
15+
from pathlib import Path
16+
import typer
17+
from typing import List
18+
19+
import importlib.util
20+
21+
22+
def _load_source_as_module(source):
23+
i = 0
24+
while (module_name := f'mod_{i}') in sys.modules:
25+
i += 1
26+
27+
spec = importlib.util.spec_from_file_location(module_name, source)
28+
assert spec, f'Failed to load {source} as module'
29+
module = importlib.util.module_from_spec(spec)
30+
sys.modules[module_name] = module
31+
assert spec.loader, f'{source} spec has no loader'
32+
spec.loader.exec_module(module)
33+
return module
34+
35+
36+
def _load_module(f: str):
37+
if f.endswith('.py'):
38+
sys.path.insert(0, str(Path(f).parent))
39+
return _load_source_as_module(f)
40+
else:
41+
return importlib.import_module(f)
42+
43+
44+
def main(files: List[str], host: str = '0.0.0.0', port: int = 8000):
45+
app = fastapi.FastAPI()
46+
47+
for f in files:
48+
print(f'Binding functions from {f}')
49+
module = _load_module(f)
50+
for k in dir(module):
51+
if k.startswith('_'):
52+
continue
53+
if k == k.capitalize():
54+
continue
55+
v = getattr(module, k)
56+
if not callable(v) or isinstance(v, type):
57+
continue
58+
if not hasattr(v, '__annotations__'):
59+
continue
60+
61+
vt = type(v)
62+
if vt.__module__ == 'langchain_core.tools' and vt.__name__.endswith('Tool') and hasattr(v, 'func') and callable(v.func):
63+
v = v.func
64+
65+
print(f'INFO: Binding /{k}')
66+
try:
67+
app.post('/' + k)(v)
68+
except Exception as e:
69+
print(f'WARNING: Failed to bind /{k}\n\t{e}')
70+
71+
print(f'INFO: CWD = {os.getcwd()}')
72+
uvicorn.run(app, host=host, port=port)
73+
74+
75+
if __name__ == '__main__':
76+
typer.run(main)

0 commit comments

Comments
 (0)