skills/apple-foundation-models/references/tool_calling.md

17 KiB

Tool Calling

Overview

Tool calling enables the language model to interact with external systems and APIs. The model can decide when to call tools, extract the necessary arguments, and incorporate tool results into its responses.

Tool Protocol

The Tool protocol defines the interface for creating custom tools:

protocol Tool {
    /// Description of what the tool does
    static var description: String { get }
    
    /// The arguments structure for this tool
    associatedtype Arguments: Decodable
    
    /// Execute the tool with the provided arguments
    func call(arguments: Arguments) async throws -> String
}

Basic Tool Implementation

Database Search Tool (From Apple Sample)

struct BreadDatabaseTool: Tool {
    let name = "searchBreadDatabase"
    let description = "Searches a local database for bread recipes."
    
    @Generable
    struct Arguments {
        @Guide(description: "The type of bread to search for")
        var searchTerm: String
        
        @Guide(description: "Maximum number of results to return", .range(1...10))
        var maxResults: Int?
    }
    
    func call(arguments: Arguments) async throws -> String {
        let limit = arguments.maxResults ?? 5
        
        // Query local database
        let recipes = database.searchRecipes(
            matching: arguments.searchTerm,
            limit: limit
        )
        
        // Format results for the model
        if recipes.isEmpty {
            return "No bread recipes found for '\(arguments.searchTerm)'"
        }
        
        let recipeList = recipes.map { recipe in
            "- \(recipe.name): \(recipe.description)"
        }.joined(separator: "\n")
        
        return "Found \(recipes.count) bread recipes:\n\(recipeList)"
    }
}

// Use the tool
let session = LanguageModelSession(
    model: model,
    tools: [BreadDatabaseTool()]
)

let response = try await session.respond(
    to: Prompt("Find three sourdough bread recipes")
)
// Model automatically calls BreadDatabaseTool and incorporates results

Weather Tool Example

struct WeatherTool: Tool {
    static let description = "Gets current weather information for a specified location"
    
    struct Arguments: Decodable {
        let location: String
        let units: String? // "fahrenheit" or "celsius", defaults to fahrenheit
    }
    
    func call(arguments: Arguments) async throws -> String {
        let units = arguments.units ?? "fahrenheit"
        
        // In a real implementation, call a weather API
        let weather = try await fetchWeather(
            location: arguments.location,
            units: units
        )
        
        return """
        Current weather in \(arguments.location):
        Temperature: \(weather.temperature)°\(units == "celsius" ? "C" : "F")
        Conditions: \(weather.conditions)
        Humidity: \(weather.humidity)%
        """
    }
    
    private func fetchWeather(location: String, units: String) async throws -> Weather {
        // Implementation: Call actual weather API
        // For demo purposes:
        return Weather(
            temperature: 72,
            conditions: "Sunny",
            humidity: 45
        )
    }
}

struct Weather {
    let temperature: Int
    let conditions: String
    let humidity: Int
}

Using the Weather Tool

let session = try await model.session()
let weatherTool = WeatherTool()

let prompt = Prompt {
    Instructions("You are a helpful assistant with access to weather information")
    "What's the weather like in San Francisco?"
}

let response = try await session.respond(
    to: prompt,
    withTools: [weatherTool]
)

print(response)
// The model calls WeatherTool and incorporates the result:
// "The weather in San Francisco is currently sunny with a temperature of 72°F and 45% humidity."

Advanced Tool Examples

Search Tool

struct SearchTool: Tool {
    static let description = "Searches the web for information on a given query"
    
    struct Arguments: Decodable {
        let query: String
        let maxResults: Int?
    }
    
    func call(arguments: Arguments) async throws -> String {
        let limit = arguments.maxResults ?? 5
        
        // Call search API (simplified)
        let results = try await performSearch(
            query: arguments.query,
            limit: limit
        )
        
        return results.map { result in
            "\(result.title): \(result.snippet) (\(result.url))"
        }.joined(separator: "\n\n")
    }
    
    private func performSearch(query: String, limit: Int) async throws -> [SearchResult] {
        // Implementation would call actual search API
        return []
    }
}

struct SearchResult {
    let title: String
    let snippet: String
    let url: String
}

Database Query Tool

struct DatabaseQueryTool: Tool {
    static let description = "Queries the user database for information"
    
    struct Arguments: Decodable {
        let table: String
        let conditions: [String: String]
        let columns: [String]?
    }
    
    let database: Database
    
    func call(arguments: Arguments) async throws -> String {
        let columns = arguments.columns ?? ["*"]
        
        let results = try await database.query(
            table: arguments.table,
            columns: columns,
            where: arguments.conditions
        )
        
        return formatResults(results)
    }
    
    private func formatResults(_ results: [[String: Any]]) -> String {
        // Format database results as readable text
        return results.map { row in
            row.map { "\($0.key): \($0.value)" }.joined(separator: ", ")
        }.joined(separator: "\n")
    }
}

Calculator Tool

struct CalculatorTool: Tool {
    static let description = "Performs mathematical calculations"
    
    struct Arguments: Decodable {
        let expression: String
    }
    
    func call(arguments: Arguments) async throws -> String {
        let result = try evaluateExpression(arguments.expression)
        return "Result: \(result)"
    }
    
    private func evaluateExpression(_ expression: String) throws -> Double {
        // Use NSExpression or implement custom parser
        let mathExpression = NSExpression(format: expression)
        guard let result = mathExpression.expressionValue(with: nil, context: nil) as? Double else {
            throw CalculatorError.invalidExpression
        }
        return result
    }
}

enum CalculatorError: Error {
    case invalidExpression
}

File System Tool

struct FileSystemTool: Tool {
    static let description = "Lists files and directories in a specified path"
    
    struct Arguments: Decodable {
        let path: String
        let includeHidden: Bool?
    }
    
    func call(arguments: Arguments) async throws -> String {
        let fileManager = FileManager.default
        let showHidden = arguments.includeHidden ?? false
        
        let contents = try fileManager.contentsOfDirectory(atPath: arguments.path)
        let filtered = showHidden ? contents : contents.filter { !$0.hasPrefix(".") }
        
        return "Files in \(arguments.path):\n" + filtered.joined(separator: "\n")
    }
}

API Request Tool

struct APIRequestTool: Tool {
    static let description = "Makes HTTP requests to external APIs"
    
    struct Arguments: Decodable {
        let url: String
        let method: String // "GET", "POST", etc.
        let headers: [String: String]?
        let body: String?
    }
    
    func call(arguments: Arguments) async throws -> String {
        guard let url = URL(string: arguments.url) else {
            throw APIError.invalidURL
        }
        
        var request = URLRequest(url: url)
        request.httpMethod = arguments.method
        
        if let headers = arguments.headers {
            for (key, value) in headers {
                request.setValue(value, forHTTPHeaderField: key)
            }
        }
        
        if let body = arguments.body {
            request.httpBody = body.data(using: .utf8)
        }
        
        let (data, response) = try await URLSession.shared.data(for: request)
        
        guard let httpResponse = response as? HTTPURLResponse,
              (200...299).contains(httpResponse.statusCode) else {
            throw APIError.requestFailed
        }
        
        return String(data: data, encoding: .utf8) ?? ""
    }
}

enum APIError: Error {
    case invalidURL
    case requestFailed
}

Multiple Tools

You can provide multiple tools for the model to choose from:

let session = try await model.session()

let tools: [Tool] = [
    WeatherTool(),
    SearchTool(),
    CalculatorTool()
]

let prompt = Prompt {
    Instructions("You are a helpful assistant with access to various tools")
    "What's 15% of the temperature in Miami?"
}

let response = try await session.respond(to: prompt, withTools: tools)
// Model will:
// 1. Call WeatherTool for Miami
// 2. Call CalculatorTool to compute 15% of the temperature
// 3. Formulate a response with both results

Tool Chaining

The model can call tools multiple times and chain results:

struct CurrencyConverterTool: Tool {
    static let description = "Converts currency amounts between different currencies"
    
    struct Arguments: Decodable {
        let amount: Double
        let from: String // Currency code (USD, EUR, etc.)
        let to: String
    }
    
    func call(arguments: Arguments) async throws -> String {
        let rate = try await getExchangeRate(from: arguments.from, to: arguments.to)
        let converted = arguments.amount * rate
        return "\(arguments.amount) \(arguments.from) = \(converted) \(arguments.to)"
    }
    
    private func getExchangeRate(from: String, to: String) async throws -> Double {
        // Call exchange rate API
        return 1.1 // Simplified
    }
}

let prompt = Prompt {
    Instructions("You help with travel planning")
    "I have $500 USD. What's that in EUR? Also, what's the weather like in Paris?"
}

let response = try await session.respond(
    to: prompt,
    withTools: [CurrencyConverterTool(), WeatherTool()]
)
// Model calls both tools and combines the information

Error Handling in Tools

struct DatabaseTool: Tool {
    static let description = "Queries the database"
    
    struct Arguments: Decodable {
        let query: String
    }
    
    func call(arguments: Arguments) async throws -> String {
        do {
            let results = try await database.execute(arguments.query)
            return formatResults(results)
        } catch let error as DatabaseError {
            // Return user-friendly error message
            switch error {
            case .connectionFailed:
                return "Error: Unable to connect to database"
            case .invalidQuery:
                return "Error: Invalid query syntax"
            case .accessDenied:
                return "Error: Access denied to requested data"
            }
        }
    }
}

Best Practices

1. Clear Tool Descriptions

// Good
struct EmailTool: Tool {
    static let description = "Sends an email to a specified recipient with a subject and body"
    // ...
}

// Less effective
struct EmailTool: Tool {
    static let description = "Email"
    // ...
}

2. Descriptive Argument Names

struct Arguments: Decodable {
    // Good
    let destinationEmail: String
    let messageSubject: String
    let messageBody: String
    
    // Less clear
    let to: String
    let subj: String
    let msg: String
}

3. Validate Arguments

func call(arguments: Arguments) async throws -> String {
    // Validate input
    guard arguments.email.contains("@") else {
        throw ToolError.invalidEmail
    }
    
    guard arguments.amount >= 0 else {
        throw ToolError.invalidAmount
    }
    
    // Proceed with tool logic
    // ...
}

4. Provide Useful Error Messages

func call(arguments: Arguments) async throws -> String {
    guard let data = try? await fetchData(arguments.id) else {
        return "Could not find data for ID \(arguments.id). Please check the ID and try again."
    }
    
    return formatData(data)
}

5. Return Structured Information

func call(arguments: Arguments) async throws -> String {
    let user = try await database.getUser(id: arguments.userId)
    
    return """
    User Information:
    - Name: \(user.name)
    - Email: \(user.email)
    - Status: \(user.status)
    - Last Login: \(user.lastLogin)
    """
}

6. Handle Rate Limiting

struct APITool: Tool {
    private let rateLimiter = RateLimiter(requestsPerSecond: 10)
    
    func call(arguments: Arguments) async throws -> String {
        try await rateLimiter.wait()
        
        // Make API request
        let result = try await makeRequest(arguments)
        return result
    }
}

7. Log Tool Usage

func call(arguments: Arguments) async throws -> String {
    logger.info("Tool called with arguments: \(arguments)")
    
    let result = try await performOperation(arguments)
    
    logger.info("Tool completed successfully")
    return result
}

Security Considerations

1. Validate and Sanitize Input

func call(arguments: Arguments) async throws -> String {
    // Sanitize file paths
    let sanitized = arguments.path
        .replacingOccurrences(of: "..", with: "")
        .trimmingCharacters(in: .whitespaces)
    
    // Validate path is within allowed directory
    guard sanitized.hasPrefix(allowedDirectory) else {
        throw SecurityError.unauthorizedPath
    }
    
    // Proceed safely
    return try await readFile(at: sanitized)
}

2. Use Least Privilege

struct ReadOnlyDatabaseTool: Tool {
    // Only allow SELECT queries, not INSERT/UPDATE/DELETE
    func call(arguments: Arguments) async throws -> String {
        guard arguments.query.lowercased().hasPrefix("select") else {
            throw SecurityError.unauthorizedOperation
        }
        
        return try await database.execute(arguments.query)
    }
}

3. Implement Timeouts

func call(arguments: Arguments) async throws -> String {
    try await withTimeout(seconds: 30) {
        return try await longRunningOperation(arguments)
    }
}

Testing Tools

// Unit test for a tool
func testWeatherTool() async throws {
    let tool = WeatherTool()
    let arguments = WeatherTool.Arguments(
        location: "San Francisco",
        units: "fahrenheit"
    )
    
    let result = try await tool.call(arguments: arguments)
    
    XCTAssertTrue(result.contains("San Francisco"))
    XCTAssertTrue(result.contains("°F"))
}

// Integration test with model
func testToolWithModel() async throws {
    let model = try await SystemLanguageModel()
    let session = try await model.session()
    
    let tool = WeatherTool()
    let prompt = Prompt(text: "What's the weather in Boston?")
    
    let response = try await session.respond(to: prompt, withTools: [tool])
    
    XCTAssertTrue(response.contains("Boston"))
}

Common Patterns

Tool Factory

struct ToolFactory {
    static func createTools(for context: AppContext) -> [Tool] {
        var tools: [Tool] = []
        
        if context.hasWeatherAccess {
            tools.append(WeatherTool())
        }
        
        if context.hasDatabaseAccess {
            tools.append(DatabaseQueryTool(database: context.database))
        }
        
        if context.hasSearchAccess {
            tools.append(SearchTool())
        }
        
        return tools
    }
}

Tool Result Caching

actor ToolCache {
    private var cache: [String: (result: String, timestamp: Date)] = [:]
    private let cacheTimeout: TimeInterval = 300 // 5 minutes
    
    func getCached(for key: String) -> String? {
        guard let cached = cache[key],
              Date().timeIntervalSince(cached.timestamp) < cacheTimeout else {
            return nil
        }
        return cached.result
    }
    
    func cache(result: String, for key: String) {
        cache[key] = (result, Date())
    }
}

struct CachedWeatherTool: Tool {
    static let description = "Gets current weather information"
    private let cache = ToolCache()
    
    struct Arguments: Decodable {
        let location: String
    }
    
    func call(arguments: Arguments) async throws -> String {
        let cacheKey = arguments.location.lowercased()
        
        if let cached = await cache.getCached(for: cacheKey) {
            return cached
        }
        
        let result = try await fetchWeather(location: arguments.location)
        await cache.cache(result: result, for: cacheKey)
        
        return result
    }
}