Skip to content

Commit 29e58e3

Browse files
authored
Support tool calling (#4)
1 parent 08406a9 commit 29e58e3

18 files changed

+1966
-674
lines changed

.github/workflows/publish-to-pypi.yml

Lines changed: 71 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -10,71 +10,106 @@ permissions:
1010
id-token: write # Required for trusted publishing to PyPI
1111

1212
jobs:
13-
build-wheels:
14-
name: Build wheels on macOS for Python ${{ matrix.python-version }}
13+
build-sdist:
14+
name: Build source distribution
1515
runs-on: macos-26
16-
strategy:
17-
matrix:
18-
# Build wheels for all supported Python versions
19-
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]
20-
fail-fast: false
2116

2217
steps:
2318
- name: Checkout code
2419
uses: actions/checkout@v4
2520

26-
- name: Set up Python ${{ matrix.python-version }}
21+
- name: Set up Python
2722
uses: actions/setup-python@v5
2823
with:
29-
python-version: ${{ matrix.python-version }}
30-
31-
- name: Check macOS version
32-
run: |
33-
echo "macOS version:"
34-
sw_vers
35-
echo "Architecture:"
36-
uname -m
37-
echo "Python version:"
38-
python --version
24+
python-version: "3.12"
3925

4026
- name: Install uv
4127
uses: astral-sh/setup-uv@v4
4228
with:
4329
enable-cache: true
4430

45-
- name: Build wheel
46-
env:
47-
MACOSX_DEPLOYMENT_TARGET: "26.0"
48-
ARCHFLAGS: "-arch arm64"
49-
_PYTHON_HOST_PLATFORM: "macosx-26.0-arm64"
31+
- name: Build source distribution
5032
run: |
51-
uv build --wheel
33+
uv build --sdist
5234
53-
- name: Verify wheel contents
35+
- name: Verify source distribution
5436
run: |
5537
echo "Build artifacts:"
5638
ls -lh dist/
5739
echo ""
58-
echo "Checking for dylib in wheel:"
59-
if unzip -l dist/*.whl | grep -q "libfoundation_models.dylib"; then
60-
echo "✓ libfoundation_models.dylib found in wheel"
61-
unzip -l dist/*.whl | grep -E "(dylib|\.so)"
40+
echo "Verifying Swift source is included for building:"
41+
if tar -tzf dist/*.tar.gz | grep -q "foundation_models.swift"; then
42+
echo "✓ Swift source found in sdist"
6243
else
63-
echo "✗ ERROR: libfoundation_models.dylib NOT found in wheel!"
64-
echo "Full wheel contents:"
65-
unzip -l dist/*.whl
44+
echo "✗ ERROR: Swift source NOT found in sdist!"
6645
exit 1
6746
fi
6847
69-
- name: Upload wheel
48+
- name: Upload sdist
7049
uses: actions/upload-artifact@v4
7150
with:
72-
name: wheel-${{ matrix.python-version }}
73-
path: dist/*.whl
51+
name: sdist
52+
path: dist/*.tar.gz
53+
54+
# TODO: Re-enable wheel building once PyPI supports macOS 26.0 wheels
55+
# Currently PyPI rejects macosx_26_0 platform tags
56+
# See: https://github.com/pypi/warehouse/issues/...
57+
#
58+
# build-wheels:
59+
# name: Build wheels on macOS for Python ${{ matrix.python-version }}
60+
# runs-on: macos-26
61+
# strategy:
62+
# matrix:
63+
# python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]
64+
# fail-fast: false
65+
#
66+
# steps:
67+
# - name: Checkout code
68+
# uses: actions/checkout@v4
69+
#
70+
# - name: Set up Python ${{ matrix.python-version }}
71+
# uses: actions/setup-python@v5
72+
# with:
73+
# python-version: ${{ matrix.python-version }}
74+
#
75+
# - name: Install uv
76+
# uses: astral-sh/setup-uv@v4
77+
# with:
78+
# enable-cache: true
79+
#
80+
# - name: Build wheel
81+
# env:
82+
# MACOSX_DEPLOYMENT_TARGET: "26.0"
83+
# ARCHFLAGS: "-arch arm64"
84+
# _PYTHON_HOST_PLATFORM: "macosx-26.0-arm64"
85+
# run: |
86+
# uv build --wheel
87+
#
88+
# - name: Verify wheel contents
89+
# run: |
90+
# echo "Build artifacts:"
91+
# ls -lh dist/
92+
# echo ""
93+
# echo "Checking for dylib in wheel:"
94+
# if unzip -l dist/*.whl | grep -q "libfoundation_models.dylib"; then
95+
# echo "✓ libfoundation_models.dylib found in wheel"
96+
# unzip -l dist/*.whl | grep -E "(dylib|\.so)"
97+
# else
98+
# echo "✗ ERROR: libfoundation_models.dylib NOT found in wheel!"
99+
# echo "Full wheel contents:"
100+
# unzip -l dist/*.whl
101+
# exit 1
102+
# fi
103+
#
104+
# - name: Upload wheel
105+
# uses: actions/upload-artifact@v4
106+
# with:
107+
# name: wheel-${{ matrix.python-version }}
108+
# path: dist/*.whl
74109

75110
publish:
76111
name: Publish to PyPI
77-
needs: [build-wheels]
112+
needs: [build-sdist] # Only wait for sdist now
78113
runs-on: ubuntu-latest
79114

80115
steps:
@@ -88,20 +123,6 @@ jobs:
88123
run: |
89124
echo "Downloaded artifacts:"
90125
ls -lh dist/
91-
echo ""
92-
echo "Verifying each wheel contains dylib:"
93-
for wheel in dist/*.whl; do
94-
echo "Checking $wheel:"
95-
ls -lh "$wheel"
96-
if unzip -l "$wheel" | grep -q "libfoundation_models.dylib"; then
97-
echo " ✓ dylib present"
98-
else
99-
echo " ✗ ERROR: dylib missing in $wheel!"
100-
echo "Wheel contents:"
101-
unzip -l "$wheel"
102-
exit 1
103-
fi
104-
done
105126
106127
- name: Publish to PyPI
107128
uses: pypa/gh-action-pypi-publish@release/v1

MANIFEST.in

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,12 @@
1-
# This file is for sdist builds only (not used for wheels)
2-
# Wheels use pyproject.toml [tool.setuptools.package-data] instead
1+
# Controls what goes into the source distribution (.tar.gz)
2+
# The source distribution must contain all files needed to build the wheel
33

44
# Documentation
55
include README.md
66
include LICENSE
77

8-
# Exclude build artifacts and source files from wheels
9-
global-exclude *.pyc
10-
global-exclude __pycache__
11-
global-exclude *.so
12-
global-exclude .DS_Store
13-
global-exclude *.a
14-
global-exclude *.swift
15-
global-exclude *.h
16-
global-exclude lib/*
8+
# Swift source needed for compilation
9+
include applefoundationmodels/swift/*.swift
10+
11+
# Exclude the lib build directory if it exists
12+
prune lib

README.md

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,105 @@ with Client() as client:
157157
print(person.name, person.age, person.city) # Alice 28 Paris
158158
```
159159

160+
### Tool Calling
161+
162+
Tool calling allows the model to call your Python functions to access real-time data, perform actions, or integrate with external systems. Tools work with a simple decorator-based API:
163+
164+
```python
165+
from applefoundationmodels import Client
166+
167+
with Client() as client:
168+
session = client.create_session()
169+
170+
# Register a tool with the @session.tool decorator
171+
@session.tool(description="Get current weather for a location")
172+
def get_weather(location: str, units: str = "celsius") -> str:
173+
"""Fetch weather information from your weather API."""
174+
# Your implementation here
175+
return f"Weather in {location}: 22°{units[0].upper()}, sunny"
176+
177+
@session.tool()
178+
def calculate(expression: str) -> float:
179+
"""Evaluate a mathematical expression safely."""
180+
# Your implementation here
181+
return eval(expression) # Use safe_eval in production!
182+
183+
# The model will automatically call tools when needed
184+
response = session.generate(
185+
"What's the weather in Paris and what's 15 times 23?"
186+
)
187+
print(response)
188+
# "The weather in Paris is 22°C and sunny. 15 times 23 equals 345."
189+
190+
# View the full conversation including tool calls
191+
for entry in session.transcript:
192+
print(f"{entry['type']}: {entry.get('content', '')}")
193+
```
194+
195+
**Features:**
196+
- **Automatic schema generation** from Python type hints
197+
- **Parallel tool execution** when the model calls multiple tools
198+
- **Full transcript access** showing all tool calls and outputs
199+
- **Error handling** with detailed error information
200+
- **Type-safe** with complete type annotations
201+
202+
**Schema Extraction:**
203+
204+
The library automatically extracts JSON schemas from your Python functions:
205+
206+
```python
207+
@session.tool(description="Search documentation")
208+
def search_docs(query: str, limit: int = 10, category: str = "all") -> list:
209+
"""Search the documentation database."""
210+
# Implementation...
211+
return results
212+
213+
# Automatically generates:
214+
# {
215+
# "name": "search_docs",
216+
# "description": "Search documentation",
217+
# "parameters": {
218+
# "type": "object",
219+
# "properties": {
220+
# "query": {"type": "string"},
221+
# "limit": {"type": "integer"},
222+
# "category": {"type": "string"}
223+
# },
224+
# "required": ["query"]
225+
# }
226+
# }
227+
```
228+
229+
**Transcript Access:**
230+
231+
View the complete conversation history including tool interactions:
232+
233+
```python
234+
# After generating with tools
235+
for entry in session.transcript:
236+
match entry['type']:
237+
case 'prompt':
238+
print(f"User: {entry['content']}")
239+
case 'tool_calls':
240+
for call in entry['tool_calls']:
241+
print(f"Calling tool: {call['id']}")
242+
case 'tool_output':
243+
print(f"Tool result: {entry['content']}")
244+
case 'response':
245+
print(f"Assistant: {entry['content']}")
246+
```
247+
248+
**Supported Parameter Types:**
249+
250+
Tool calling works with various parameter signatures:
251+
- No parameters
252+
- Single parameters (string, int, float, bool)
253+
- Multiple parameters with mixed types
254+
- Optional parameters with default values
255+
- Lists and nested objects
256+
257+
See `examples/tool_calling_comprehensive.py` for complete examples of all supported patterns.
258+
160259
### Generation Parameters
161260

162261
```python
@@ -254,6 +353,10 @@ class Session:
254353
def generate_structured(prompt: str, schema: dict, **params) -> dict: ...
255354
async def generate_stream(prompt: str, **params) -> AsyncIterator[str]: ...
256355

356+
def tool(description: str = None, name: str = None) -> Callable: ...
357+
@property
358+
def transcript() -> List[dict]: ...
359+
257360
def get_history() -> List[dict]: ...
258361
def clear_history() -> None: ...
259362
def add_message(role: str, content: str) -> None: ...
@@ -305,6 +408,7 @@ All exceptions inherit from `FoundationModelsError`:
305408
- `GuardrailViolationError` - Content blocked by safety filters
306409
- `ToolNotFoundError` - Tool not registered
307410
- `ToolExecutionError` - Tool execution failed
411+
- `ToolCallError` - Tool call error (validation, schema, etc.)
308412
- `UnknownError` - Unknown error
309413

310414
## Examples
@@ -313,8 +417,8 @@ See the `examples/` directory for complete working examples:
313417

314418
- `basic_chat.py` - Simple conversation
315419
- `streaming_chat.py` - Async streaming
316-
- `tool_calling.py` - Tool registration (coming soon)
317420
- `structured_output.py` - JSON schema validation
421+
- `tool_calling_comprehensive.py` - Complete tool calling demonstration with all parameter types
318422

319423
## Development
320424

applefoundationmodels/_foundationmodels.pxd

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ cdef extern from "../applefoundationmodels/swift/foundation_models.h":
2222
AI_ERROR_JSON_PARSE = -5
2323
AI_ERROR_GENERATION = -6
2424
AI_ERROR_TIMEOUT = -7
25+
AI_ERROR_TOOL_NOT_FOUND = -11
26+
AI_ERROR_TOOL_EXECUTION = -12
2527
AI_ERROR_UNKNOWN = -99
2628

2729
# Availability status
@@ -35,6 +37,12 @@ cdef extern from "../applefoundationmodels/swift/foundation_models.h":
3537
# Callback type for streaming
3638
ctypedef void (*ai_stream_callback_t)(const char *chunk)
3739

40+
# Callback type for tool execution
41+
ctypedef int32_t (*ai_tool_callback_t)(const char *tool_name,
42+
const char *arguments_json,
43+
char *result_buffer,
44+
int32_t buffer_size)
45+
3846
# Core library functions
3947
int32_t apple_ai_init() nogil
4048
void apple_ai_cleanup() nogil
@@ -47,6 +55,11 @@ cdef extern from "../applefoundationmodels/swift/foundation_models.h":
4755
# Session management
4856
int32_t apple_ai_create_session(const char *instructions_json) nogil
4957

58+
# Tool calling
59+
int32_t apple_ai_register_tools(const char *tools_json,
60+
ai_tool_callback_t callback) nogil
61+
char *apple_ai_get_transcript() nogil
62+
5063
# Text generation
5164
char *apple_ai_generate(const char *prompt,
5265
double temperature,

applefoundationmodels/_foundationmodels.pyi

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ def get_history() -> List[Any]: ...
4343
def clear_history() -> None: ...
4444
def add_message(role: str, content: str) -> None: ...
4545

46+
# Tool calling
47+
def register_tools(tools: Dict[str, Callable]) -> None: ...
48+
def get_transcript() -> List[Dict[str, Any]]: ...
49+
4650
# Statistics
4751
def get_stats() -> Dict[str, Any]: ...
4852
def reset_stats() -> None: ...

0 commit comments

Comments
 (0)