Skip to content

Commit

Permalink
Add basic function calling example using a llama-cli python wrapper
Browse files Browse the repository at this point in the history
  • Loading branch information
dmahurin committed Sep 22, 2024
1 parent 912c331 commit 2261995
Show file tree
Hide file tree
Showing 4 changed files with 257 additions and 0 deletions.
43 changes: 43 additions & 0 deletions examples/function-calling/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# llama.cpp/examples/function-calling

This example shows how to do basic function calling using llama-cli and a python wrapper to declare and call functions.

## Options

Important options for llama-cli-function-runner.py:

- `-m FNAME, --model FNAME`: Specify the path to the function calling model (e.g., `-m "$(huggingface-cli download meetkai/functionary-small-v3.2-GGUF functionary-small-v3.2.Q4_0.gguf)"`).
- `--ctx-size N`: Set the size of the prompt context. The default is 1024
- `--special`: show special tokens and function calling details

## Example showing showing function call details

```
./examples/function-calling/llama-cli-function-runner.py -m `huggingface-cli download meetkai/functionary-small-v3.2-GGUF functionary-small-v3.2.Q4_0.gguf` -i --special
What is the weather in Phoenix?
Sure, I'll look that up for you. Let me just check the current weather conditions in Phoenix.>>>get_weather
{"location": "Phoenix"}<|eot_id|>
The current weather in Phoenix is 30C.<|eot_id|>
What is 38484 + 323?
Sure, let's calculate that.>>>calculate
{"expression": "38484 + 323"}<|eot_id|>
The sum of 38484 and 323 is 38807.<|eot_id|>
What is 67 feet in meters?
To convert 67 feet into meters, we use the conversion factor: 1 foot is approximately 0.3048 meters. Let's calculate it.>>>calculate
{"expression": "67 * 0.3048"}<|eot_id|>
67 feet is approximately 20.4216 meters.<|eot_id|>
```

## Function calling example, hiding details
```
./examples/function-calling/llama-cli-function-runner.py -m `huggingface-cli download meetkai/functionary-small-v3.2-GGUF functionary-small-v3.2.Q4_0.gguf` -i
What is the weather in Phoenix?
To provide you with the current weather in Phoenix, Arizona, I will need to check the weather data for you. Let me get that information.
The current weather in Phoenix, Arizona is 30°C. If you have any more questions about weather in other locations, feel free to ask!
Is it colder in Vegas?
To determine if the current temperature in Las Vegas is colder than in Phoenix, which is currently 30°C, I will need to check the weather data for Las Vegas. Let's find out.
The current weather in Las Vegas, Nevada is also 30°C. Therefore, there is no difference in temperature between Phoenix and Las Vegas at the moment. If you have any more questions or need further assistance, please let me know!
What is 37234 times 39?
To calculate 37234 times 39, I'll perform the multiplication. Let's do that.
The result of multiplying 37234 by 39 is 1,452,126. If you have any more calculations or questions, feel free to ask!
```
64 changes: 64 additions & 0 deletions examples/function-calling/function_tool.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Generate function calling definitions function schemas

import inspect
import re
import json

Check warning on line 5 in examples/function-calling/function_tool.py

View workflow job for this annotation

GitHub Actions / pyright type-check

Import "json" is not accessed (reportUnusedImport)

# Extract OpenAI function calling style definitions from functions
#
# Generated with: Create a python function to to generate the OpenAI function calling definition from a given function, getting the description, parameter type and parameter description from the function documentation, assuming the function documentation contains sphynx style parameter descriptions, marked with :param.
def get_function_tool_json(func):
typemap = { 'str': 'string' };
def get_type(s):
return typemap[s] if s in typemap else s

function_name = func.__name__
doc_parts = re.split('\n\s*:param[^:]*\s+', func.__doc__.rstrip());

Check warning on line 16 in examples/function-calling/function_tool.py

View workflow job for this annotation

GitHub Actions / pyright type-check

Unsupported escape sequence in string literal (reportInvalidStringEscapeSequence)

Check warning on line 16 in examples/function-calling/function_tool.py

View workflow job for this annotation

GitHub Actions / pyright type-check

Unsupported escape sequence in string literal (reportInvalidStringEscapeSequence)

function_description = doc_parts[0]
params_doc = [ re.split('\:\s*', param_doc, maxsplit=1) for param_doc in doc_parts[1:] ]

Check warning on line 19 in examples/function-calling/function_tool.py

View workflow job for this annotation

GitHub Actions / pyright type-check

Unsupported escape sequence in string literal (reportInvalidStringEscapeSequence)

Check warning on line 19 in examples/function-calling/function_tool.py

View workflow job for this annotation

GitHub Actions / pyright type-check

Unsupported escape sequence in string literal (reportInvalidStringEscapeSequence)
params_doc = { param: desc for param, desc in params_doc }

function_def = {
'name': function_name,
'description': function_description,
'parameters': { 'type': 'object', 'properties': {}, 'required': [] }
}

for param_name, param in inspect.signature(func).parameters.items():
function_def['parameters']['properties'][param_name] = {
'type' : get_type(param.annotation.__name__) if param.annotation is not param.empty else '',
'description': params_doc[param_name] if param_name in params_doc else ''
}
function_def['parameters']['required'].append(param_name);

return function_def

# Generate function definition schema from function definitions
#
# This is from llama-cpp-python, llama_chat_format.py
def generate_schema_from_functions(functions, namespace="functions") -> str:
schema = (
"// Supported function definitions that should be called when necessary.\n"
)
schema += f"namespace {namespace} {{\n\n"

for function in functions:
function_name = function["name"]
description = function.get("description", "")
parameters = function.get("parameters", {})
required_params = parameters.get("required", [])

schema += f"// {description}\n"
schema += f"type {function_name} = (_: {{\n"

for param_name, param in parameters.get("properties", {}).items():
param_description = param.get("description", "")
param_type = param.get("type", "any")
optional_indicator = "" if param_name in required_params else "?"
schema += f"// {param_description}\n"
schema += f"{param_name}{optional_indicator}: {param_type},\n"
schema += "}) => any;\n\n"

schema += "}} // namespace {}".format(namespace)
return schema
30 changes: 30 additions & 0 deletions examples/function-calling/functions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
def calculate(expression: str):
"""Evaluate a mathematical expression
:param expression: The mathematical expression to evaluate
"""
try:
result = eval(expression)
return {"result": result}
except:
return {"error": "Invalid expression"}

def get_weather(location: str):
"""get the weather of a location
:param location: where to get weather.
"""
return {"temperature": "30C"}

def _run_python(code):
allowed_globals = { '__builtins__': None, '_': None }
allowed_locals = {}

code = code.splitlines()
code[-1] = f"_ = {code[-1]}"
code = '\n'.join(code)

try:
exec(code, allowed_globals, allowed_locals)
except Exception as e:
return None

return {'result': allowed_locals.get('_', None)}
120 changes: 120 additions & 0 deletions examples/function-calling/llama-cli-function-runner.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
#!/usr/bin/env python3
# function calling using llama-cli

import subprocess
import sys
import select
import os
import re

import json

import functions
from function_tool import get_function_tool_json, generate_schema_from_functions

function_name_list = [ name for name in dir(functions) if not name.startswith('_') ]
function_lookup = { name: getattr(functions, name) for name in function_name_list }
tools = [ get_function_tool_json(f) for (n, f) in function_lookup.items() ]
function_schema = generate_schema_from_functions(tools)

prompt = """<|start_header_id|>system<|end_header_id|>
You are capable of executing available function(s) if required.
Execute function(s) as needed.
The function calls are not shown in the conversation and should be called covertly to answer questions.
Ask for the required input to:recipient==all
Use JSON for function arguments.
Respond in this format:
>>>${recipient}
${content}
Available functions:
""" + function_schema + """<|eot_id|><|start_header_id|>system<|end_header_id|>
When you send a message containing Python code to python, it will be executed in a stateful Jupyter notebook environment. python will respond with the output of the execution or time out after 60.0 seconds. The drive at '/mnt/data' can be used to save and persist user files.<|eot_id|><|start_header_id|>user<|end_header_id|>
"""

def main():
import argparse

parser = argparse.ArgumentParser(epilog='For more options: llama-cli --help')
parser.add_argument('--display-prompt', action=argparse.BooleanOptionalAction, default=False)
parser.add_argument('--special', action=argparse.BooleanOptionalAction, default=False)
parser.add_argument('--reverse-prompt', type=str, default='<|start_header_id|>user<|end_header_id|>\n')
parser.add_argument('--ctx-size', type=int, default=1024)
args, other_args = parser.parse_known_args()

if args.display_prompt: print(prompt)

command = [ './llama-cli', '-i', '-p', prompt, '--reverse-prompt', args.reverse_prompt, '--escape', '--special', '--no-display-prompt', '--log-disable', '--simple-io', '--ctx-size', str(args.ctx_size), *other_args]

process = subprocess.Popen(
command,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
if process.stdout is not None: os.set_blocking(process.stdout.fileno(), False)

try:
run_loop(process, args)
except KeyboardInterrupt:
print("\nInterrupted by user.")
finally:
process.terminate()
process.wait()

def run_loop(process, args):
pbuffer = ''
skip_output_until_result = False
while True:
readable, _, _ = select.select([process.stdout, process.stderr, sys.stdin], [], [])

for stream in readable:
if stream == process.stdout:
pdata = process.stdout.read()
if not pdata: continue
pbuffer += pdata

if(match := re.search(r'>>>([^\n]*)\n(.*)<\|eot_id\|>', pbuffer, re.S )):
if not args.special:
pdata = pdata[:match.pos]
pbuffer = ''
skip_output_until_result = False

tool_name = match.group(1)
tool_args = match.group(2)
try:
tool_args = json.loads(tool_args)
except ValueError as e:
result = {'error': 'unknown'}
if tool_name == 'python':
result = functions._run_python(tool_args);
elif tool_args is not None:
result = function_lookup[tool_name](**tool_args)
else:
result = {'error': 'unknown'}
process.stdin.write(json.dumps(result) + '<|eot_id|><|start_header_id|>assistant<|end_header_id|>\n')
process.stdin.flush()
elif (n := pdata.find('>>>')) >= 0:
if not args.special:
pdata = pdata[:n]
skip_output_until_result = True
elif skip_output_until_result:
pdata = ''

if not args.special:
pdata = re.sub(r'<\|[^\|>]*\|>', '', pdata)
sys.stdout.write(pdata)
sys.stdout.flush()

elif stream == sys.stdin:
user_input = sys.stdin.readline()
if user_input:
user_input = user_input.rstrip()
process.stdin.write(user_input + '<|eot_id|><|start_header_id|>assistant<|end_header_id|>' + '\n')
process.stdin.flush()

if __name__ == '__main__':
main()

0 comments on commit 2261995

Please sign in to comment.