File size: 3,412 Bytes
7b2dfc5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
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
  }
}