Skip to content

Commit e454e39

Browse files
committed
add(responses): typed custom tool call streaming and projection
1 parent c482c16 commit e454e39

4 files changed

Lines changed: 315 additions & 189 deletions

File tree

Sources/AgentRunKit/LLM/ResponsesAPIClient.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,13 @@ extension ResponsesAPIClient {
273273
toolCalls.append(ToolCall(
274274
id: call.callId, name: call.name, arguments: call.arguments
275275
))
276+
case let .opaque(opaque) where opaque.type == "custom_tool_call":
277+
guard case let .object(fields) = opaque.raw,
278+
case let .string(callId) = fields["call_id"],
279+
case let .string(name) = fields["name"]
280+
else { break }
281+
let input = if case let .string(text) = fields["input"] { text } else { "" }
282+
toolCalls.append(ToolCall(id: callId, name: name, arguments: input, kind: .custom))
276283
case let .reasoning(reasoning):
277284
reasoningDetails.append(reasoning.raw)
278285
let value = reasoning.raw

Sources/AgentRunKit/LLM/ResponsesAPIStreaming.swift

Lines changed: 32 additions & 189 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,8 @@ extension ResponsesAPIClient {
4141
stallTimeout: Duration?,
4242
continuation: AsyncThrowingStream<RunStreamElement, Error>.Continuation
4343
) async throws where S.Element == UInt8 {
44-
let completionState = StreamCompletionState()
45-
let semanticState = ResponsesStreamingSemanticState()
44+
let completionState = ResponsesStreamCompletionState()
45+
let semanticState = ResponsesStreamState()
4646
try await processSSEStream(bytes: bytes, stallTimeout: stallTimeout) { [self] line in
4747
let didComplete = try await handleSSELine(
4848
line,
@@ -64,7 +64,7 @@ extension ResponsesAPIClient {
6464
private func handleSSELine(
6565
_ line: String,
6666
messagesCount: Int,
67-
semanticState: ResponsesStreamingSemanticState,
67+
semanticState: ResponsesStreamState,
6868
continuation: AsyncThrowingStream<RunStreamElement, Error>.Continuation
6969
) async throws -> Bool {
7070
try Task.checkCancellation()
@@ -88,7 +88,7 @@ extension ResponsesAPIClient {
8888
_ type: String,
8989
data: Data,
9090
messagesCount: Int,
91-
semanticState: ResponsesStreamingSemanticState,
91+
semanticState: ResponsesStreamState,
9292
continuation: AsyncThrowingStream<RunStreamElement, Error>.Continuation
9393
) async throws -> Bool {
9494
switch type {
@@ -104,8 +104,9 @@ extension ResponsesAPIClient {
104104
semanticState: semanticState,
105105
continuation: continuation
106106
)
107-
case "response.function_call_arguments.delta":
108-
try await handleFunctionCallArgsDelta(
107+
case "response.function_call_arguments.delta",
108+
"response.custom_tool_call_input.delta":
109+
try await handleToolCallArgsDelta(
109110
data: data,
110111
semanticState: semanticState,
111112
continuation: continuation
@@ -139,7 +140,7 @@ extension ResponsesAPIClient {
139140

140141
private func handleTextDelta(
141142
data: Data,
142-
semanticState: ResponsesStreamingSemanticState,
143+
semanticState: ResponsesStreamState,
143144
continuation: AsyncThrowingStream<RunStreamElement, Error>.Continuation
144145
) async throws {
145146
let event = try Self.sseDecoder.decode(
@@ -154,33 +155,44 @@ extension ResponsesAPIClient {
154155

155156
private func handleOutputItemAdded(
156157
data: Data,
157-
semanticState: ResponsesStreamingSemanticState,
158+
semanticState: ResponsesStreamState,
158159
continuation: AsyncThrowingStream<RunStreamElement, Error>.Continuation
159160
) async throws {
160161
let event = try Self.sseDecoder.decode(
161162
OutputItemAddedEvent.self, from: data
162163
)
163-
guard event.item.type == "function_call",
164-
let callId = event.item.callId,
165-
let name = event.item.name
166-
else { return }
164+
let kind: ToolCallKind
165+
switch event.item.type {
166+
case "function_call":
167+
kind = .function
168+
case "custom_tool_call":
169+
kind = .custom
170+
case "mcp_call", "computer_call", "apply_patch_call":
171+
throw AgentError.llmError(.featureUnsupported(
172+
provider: "responses",
173+
feature: "\(event.item.type) streaming"
174+
))
175+
default:
176+
return
177+
}
178+
guard let callId = event.item.callId, let name = event.item.name else { return }
167179
let delta = StreamDelta.toolCallStart(
168180
index: event.outputIndex,
169181
id: callId,
170182
name: name,
171-
kind: .function
183+
kind: kind
172184
)
173185
await semanticState.record(delta)
174186
continuation.yield(.delta(delta))
175187
}
176188

177-
private func handleFunctionCallArgsDelta(
189+
private func handleToolCallArgsDelta(
178190
data: Data,
179-
semanticState: ResponsesStreamingSemanticState,
191+
semanticState: ResponsesStreamState,
180192
continuation: AsyncThrowingStream<RunStreamElement, Error>.Continuation
181193
) async throws {
182194
let event = try Self.sseDecoder.decode(
183-
FunctionCallArgsDeltaEvent.self, from: data
195+
ToolCallArgsDeltaEvent.self, from: data
184196
)
185197
if !event.delta.isEmpty {
186198
let delta = StreamDelta.toolCallDelta(
@@ -194,7 +206,7 @@ extension ResponsesAPIClient {
194206

195207
private func handleReasoningSummaryDelta(
196208
data: Data,
197-
semanticState: ResponsesStreamingSemanticState,
209+
semanticState: ResponsesStreamState,
198210
continuation: AsyncThrowingStream<RunStreamElement, Error>.Continuation
199211
) async throws {
200212
let event = try Self.sseDecoder.decode(
@@ -216,7 +228,7 @@ extension ResponsesAPIClient {
216228

217229
private func handleOutputItemDone(
218230
data: Data,
219-
semanticState: ResponsesStreamingSemanticState,
231+
semanticState: ResponsesStreamState,
220232
continuation: AsyncThrowingStream<RunStreamElement, Error>.Continuation
221233
) async throws {
222234
let value = try Self.sseDecoder.decode(
@@ -231,7 +243,7 @@ extension ResponsesAPIClient {
231243
private func handleCompleted(
232244
data: Data,
233245
messagesCount: Int,
234-
semanticState: ResponsesStreamingSemanticState,
246+
semanticState: ResponsesStreamState,
235247
continuation: AsyncThrowingStream<RunStreamElement, Error>.Continuation
236248
) async throws -> Bool {
237249
let event: CompletedEvent
@@ -291,7 +303,7 @@ private struct OutputItemStub: Decodable {
291303
enum CodingKeys: String, CodingKey { case type, callId = "call_id", name }
292304
}
293305

294-
private struct FunctionCallArgsDeltaEvent: Decodable {
306+
private struct ToolCallArgsDeltaEvent: Decodable {
295307
let outputIndex: Int
296308
let delta: String
297309
enum CodingKeys: String, CodingKey { case outputIndex = "output_index", delta }
@@ -323,172 +335,3 @@ private struct OutputItemDoneItem: Decodable {
323335
private struct CompletedEvent: Decodable { let response: ResponsesAPIResponse }
324336
private struct FailedEvent: Decodable { let response: FailedResponseBody }
325337
private struct FailedResponseBody: Decodable { let error: ResponsesErrorDetail? }
326-
327-
private actor StreamCompletionState {
328-
private(set) var isCompleted = false
329-
func markCompleted() {
330-
isCompleted = true
331-
}
332-
}
333-
334-
private actor ResponsesStreamingSemanticState {
335-
private var content = ""
336-
private var reasoning = ""
337-
private var reasoningDetails: [JSONValue] = []
338-
private var toolCalls: [Int: ResponsesStreamedToolCall] = [:]
339-
private var lastSummaryPart: (outputIndex: Int, summaryIndex: Int)?
340-
private var summaryPartCount = 0
341-
342-
func summaryPartSeparator(forOutput outputIndex: Int?, summary summaryIndex: Int?) -> String? {
343-
guard let summaryIndex else { return nil }
344-
if let outputIndex {
345-
let current = (outputIndex, summaryIndex)
346-
defer { lastSummaryPart = current }
347-
guard let last = lastSummaryPart,
348-
current.0 != last.outputIndex || current.1 != last.summaryIndex
349-
else { return nil }
350-
return "\n"
351-
}
352-
defer { summaryPartCount += 1 }
353-
return summaryPartCount > 0 ? "\n" : nil
354-
}
355-
356-
func record(_ delta: StreamDelta) {
357-
switch delta {
358-
case let .content(text):
359-
content += text
360-
case let .reasoning(text):
361-
reasoning += text
362-
case let .reasoningDetails(details):
363-
reasoningDetails.append(contentsOf: details)
364-
case let .toolCallStart(index, id, name, _):
365-
toolCalls[index] = ResponsesStreamedToolCall(
366-
id: id,
367-
name: name,
368-
arguments: toolCalls[index]?.arguments ?? ""
369-
)
370-
case let .toolCallDelta(index, arguments):
371-
let existing = toolCalls[index]
372-
toolCalls[index] = ResponsesStreamedToolCall(
373-
id: existing?.id,
374-
name: existing?.name,
375-
arguments: (existing?.arguments ?? "") + arguments
376-
)
377-
case .audioData, .audioTranscript, .audioStarted, .finished:
378-
break
379-
}
380-
}
381-
382-
func reconciliationDeltas(
383-
response: ResponsesAPIResponse,
384-
projection: ResponsesAPIClient.ResponsesTurnProjection
385-
) throws -> [StreamDelta] {
386-
let target = try ResponsesCompletedSemanticTarget(response: response, projection: projection)
387-
var deltas: [StreamDelta] = []
388-
389-
guard let reasoningSuffix = utf8Suffix(of: target.reasoning, afterPrefix: reasoning) else {
390-
throw AgentError.malformedStream(.finalizedSemanticStateDiverged)
391-
}
392-
if !reasoningSuffix.isEmpty {
393-
reasoning += reasoningSuffix
394-
deltas.append(.reasoning(reasoningSuffix))
395-
}
396-
397-
guard target.reasoningDetails.count >= reasoningDetails.count,
398-
Array(target.reasoningDetails.prefix(reasoningDetails.count)) == reasoningDetails
399-
else {
400-
throw AgentError.malformedStream(.finalizedSemanticStateDiverged)
401-
}
402-
let reasoningDetailSuffix = Array(target.reasoningDetails.dropFirst(reasoningDetails.count))
403-
if !reasoningDetailSuffix.isEmpty {
404-
reasoningDetails += reasoningDetailSuffix
405-
deltas.append(.reasoningDetails(reasoningDetailSuffix))
406-
}
407-
408-
guard let contentSuffix = utf8Suffix(of: target.content, afterPrefix: content) else {
409-
throw AgentError.malformedStream(.finalizedSemanticStateDiverged)
410-
}
411-
if !contentSuffix.isEmpty {
412-
content += contentSuffix
413-
deltas.append(.content(contentSuffix))
414-
}
415-
416-
let targetIndices = Set(target.toolCalls.keys)
417-
guard Set(toolCalls.keys).isSubset(of: targetIndices) else {
418-
throw AgentError.malformedStream(.finalizedSemanticStateDiverged)
419-
}
420-
421-
for (index, targetCall) in target.toolCalls.sorted(by: { $0.key < $1.key }) {
422-
if let existing = toolCalls[index] {
423-
guard existing.id == nil || existing.id == targetCall.id,
424-
existing.name == nil || existing.name == targetCall.name,
425-
let argumentsSuffix = utf8Suffix(
426-
of: targetCall.arguments,
427-
afterPrefix: existing.arguments
428-
)
429-
else {
430-
throw AgentError.malformedStream(.finalizedSemanticStateDiverged)
431-
}
432-
if existing.id == nil, let id = targetCall.id, let name = targetCall.name {
433-
deltas.append(.toolCallStart(index: index, id: id, name: name, kind: .function))
434-
}
435-
if !argumentsSuffix.isEmpty {
436-
toolCalls[index] = ResponsesStreamedToolCall(
437-
id: existing.id ?? targetCall.id,
438-
name: existing.name ?? targetCall.name,
439-
arguments: existing.arguments + argumentsSuffix
440-
)
441-
deltas.append(.toolCallDelta(index: index, arguments: argumentsSuffix))
442-
}
443-
} else if let id = targetCall.id, let name = targetCall.name {
444-
toolCalls[index] = targetCall
445-
deltas.append(.toolCallStart(index: index, id: id, name: name, kind: .function))
446-
if !targetCall.arguments.isEmpty {
447-
deltas.append(.toolCallDelta(index: index, arguments: targetCall.arguments))
448-
}
449-
}
450-
}
451-
452-
return deltas
453-
}
454-
}
455-
456-
private func utf8Suffix(of target: String, afterPrefix prefix: String) -> String? {
457-
let targetBytes = Array(target.utf8)
458-
let prefixBytes = Array(prefix.utf8)
459-
guard targetBytes.starts(with: prefixBytes) else { return nil }
460-
return String(bytes: targetBytes.dropFirst(prefixBytes.count), encoding: .utf8)
461-
}
462-
463-
private struct ResponsesStreamedToolCall: Equatable {
464-
let id: String?
465-
let name: String?
466-
let arguments: String
467-
}
468-
469-
private struct ResponsesCompletedSemanticTarget {
470-
let content: String
471-
let reasoning: String
472-
let reasoningDetails: [JSONValue]
473-
let toolCalls: [Int: ResponsesStreamedToolCall]
474-
475-
init(
476-
response: ResponsesAPIResponse,
477-
projection: ResponsesAPIClient.ResponsesTurnProjection
478-
) throws {
479-
content = projection.content
480-
reasoning = projection.reasoning?.content ?? ""
481-
reasoningDetails = projection.reasoningDetails ?? []
482-
483-
var indexedToolCalls: [Int: ResponsesStreamedToolCall] = [:]
484-
for (index, outputItem) in response.output.enumerated() {
485-
guard case let .functionCall(call) = outputItem else { continue }
486-
indexedToolCalls[index] = ResponsesStreamedToolCall(
487-
id: call.callId,
488-
name: call.name,
489-
arguments: call.arguments
490-
)
491-
}
492-
toolCalls = indexedToolCalls
493-
}
494-
}

0 commit comments

Comments
 (0)