ales27pm's picture
Add autonomous on-device iOS assistant
7b2dfc5 verified
Raw
History Blame Contribute Delete
3.41 kB
import Foundation
enum ToolCallParserError: LocalizedError, Sendable, Equatable {
case multipleCalls
case malformedCall
case contentOutsideCall
case invalidPayload
case unexpectedFields([String])
case missingName
case missingArguments
var errorDescription: String? {
switch self {
case .multipleCalls:
"The model requested more than one tool in a single turn."
case .malformedCall:
"The model returned an incomplete tool-call envelope."
case .contentOutsideCall:
"The model mixed a tool call with other response content."
case .invalidPayload:
"The tool call was not valid JSON."
case .unexpectedFields(let fields):
"The tool call included unexpected fields: \(fields.joined(separator: ", "))."
case .missingName:
"The tool call did not include a valid function name."
case .missingArguments:
"The tool call arguments must be a JSON object."
}
}
}
struct ToolCallParser: Sendable {
private static let openingTag = "<tool_call>"
private static let closingTag = "</tool_call>"
static func parse(_ text: String) throws -> AgentToolCall? {
let openingRanges = text.ranges(of: openingTag)
let closingRanges = text.ranges(of: closingTag)
if openingRanges.isEmpty && closingRanges.isEmpty {
return nil
}
guard openingRanges.count == 1, closingRanges.count == 1 else {
throw ToolCallParserError.multipleCalls
}
guard
let opening = openingRanges.first,
let closing = closingRanges.first,
opening.upperBound <= closing.lowerBound
else {
throw ToolCallParserError.malformedCall
}
let prefix = text[..<opening.lowerBound]
let suffix = text[closing.upperBound...]
guard
prefix.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
suffix.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
else {
throw ToolCallParserError.contentOutsideCall
}
let payloadText = String(text[opening.upperBound..<closing.lowerBound])
.trimmingCharacters(in: .whitespacesAndNewlines)
guard let data = payloadText.data(using: .utf8) else {
throw ToolCallParserError.invalidPayload
}
let value: JSONValue
do {
value = try JSONDecoder().decode(JSONValue.self, from: data)
} catch {
throw ToolCallParserError.invalidPayload
}
guard case .object(let payload) = value else {
throw ToolCallParserError.invalidPayload
}
let unexpected = Set(payload.keys)
.subtracting(["name", "arguments"])
.sorted()
guard unexpected.isEmpty else {
throw ToolCallParserError.unexpectedFields(unexpected)
}
guard
let rawName = payload["name"]?.stringValue?
.trimmingCharacters(in: .whitespacesAndNewlines),
!rawName.isEmpty
else {
throw ToolCallParserError.missingName
}
guard case .object(let arguments) = payload["arguments"] else {
throw ToolCallParserError.missingArguments
}
return AgentToolCall(name: rawName, arguments: arguments)
}
}
private extension String {
func ranges(of substring: String) -> [Range<String.Index>] {
var result: [Range<String.Index>] = []
var searchRange = startIndex..<endIndex
while let range = range(of: substring, range: searchRange) {
result.append(range)
searchRange = range.upperBound..<endIndex
}
return result
}
}