Skip to content

Commit 474e0bd

Browse files
committed
refactor(agent): model structural terminal finish reasons
1 parent fc636ee commit 474e0bd

31 files changed

Lines changed: 726 additions & 185 deletions

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,13 @@ let weatherTool = try Tool<WeatherParams, String, EmptyContext>(
4040

4141
let agent = Agent(client: client, tools: [weatherTool])
4242
let result = try await agent.run(userMessage: "What's the weather in SF?", context: EmptyContext())
43-
print(result.content)
43+
if let content = result.content {
44+
print(content)
45+
}
4446
```
4547

48+
`result.content` is optional. Completed runs return finish-tool content, while structural terminal reasons such as max iterations or token budget exhaustion surface through `result.finishReason` with no final content.
49+
4650
---
4751

4852
## Documentation

Sources/AgentRunKit/Core/Agent.swift

Lines changed: 131 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,12 @@ extension Agent {
178178
}
179179

180180
if let tokenBudget = options.tokenBudget, totalUsage.total > tokenBudget {
181-
throw AgentError.tokenBudgetExceeded(budget: tokenBudget, used: totalUsage.total)
181+
return makeTerminalResult(
182+
reason: .tokenBudgetExceeded(budget: tokenBudget, used: totalUsage.total),
183+
tokenUsage: totalUsage,
184+
iterations: iteration,
185+
history: messages
186+
)
182187
}
183188

184189
let pruneCalls = response.toolCalls.filter { $0.name == "prune_context" }
@@ -194,7 +199,12 @@ extension Agent {
194199
}
195200
}
196201

197-
throw AgentError.maxIterationsReached(iterations: configuration.maxIterations)
202+
return makeTerminalResult(
203+
reason: .maxIterationsReached(limit: configuration.maxIterations),
204+
tokenUsage: totalUsage,
205+
iterations: configuration.maxIterations,
206+
history: messages
207+
)
198208
}
199209

200210
func stream(
@@ -216,7 +226,7 @@ extension Agent {
216226
}
217227
}
218228

219-
private extension Agent {
229+
extension Agent {
220230
func stream(
221231
userMessage: ChatMessage,
222232
history: [ChatMessage],
@@ -258,17 +268,14 @@ private extension Agent {
258268
continuation: AsyncThrowingStream<StreamEvent, Error>.Continuation
259269
) async throws {
260270
var messages = buildInitialMessages(
261-
userMessage: userMessage, history: history,
262-
systemPromptOverride: options.systemPromptOverride
271+
userMessage: userMessage, history: history, systemPromptOverride: options.systemPromptOverride
263272
)
264273
var totalUsage = TokenUsage()
265274
var lastTotalTokens: Int?
266275
var sessionAllowlist: Set<String> = []
267276
let policy = StreamPolicy.agent
268277
let processor = StreamProcessor(client: client, toolDefinitions: toolDefinitions, policy: policy)
269-
var compactor = ContextCompactor(
270-
client: client, toolDefinitions: toolDefinitions, configuration: configuration
271-
)
278+
var compactor = ContextCompactor(client: client, toolDefinitions: toolDefinitions, configuration: configuration)
272279
var budgetPhase = try makeBudgetPhase()
273280

274281
for iterationNumber in 1 ... configuration.maxIterations {
@@ -277,63 +284,57 @@ private extension Agent {
277284
let compacted = await compactor.compactOrTruncateIfNeeded(
278285
&messages, lastTotalTokens: lastTotalTokens, totalUsage: &totalUsage
279286
)
280-
if compacted, let totalTokens = lastTotalTokens, let windowSize = client.contextWindowSize {
281-
continuation.yield(.make(.compacted(totalTokens: totalTokens, windowSize: windowSize)))
282-
}
287+
emitCompactionEventIfNeeded(compacted, lastTotalTokens: lastTotalTokens, continuation: continuation)
283288
let iteration = try await processor.process(
284-
messages: messages,
285-
totalUsage: &totalUsage,
286-
continuation: continuation,
289+
messages: messages, totalUsage: &totalUsage, continuation: continuation,
287290
requestContext: options.requestContext
288291
)
289292

290293
if let usage = iteration.usage {
291-
lastTotalTokens = usage.total
292294
continuation.yield(.make(.iterationCompleted(usage: usage, iteration: iterationNumber)))
293295
}
294-
295296
messages.append(.assistant(iteration.toAssistantMessage()))
296297

297-
let filteredTools = policy.executableToolCalls(from: iteration.toolCalls)
298+
let filteredTools = StreamPolicy.agent.executableToolCalls(from: iteration.toolCalls)
298299
let pruneCalls = filteredTools.filter { $0.name == "prune_context" }
299300
let regularCalls = filteredTools.filter { $0.name != "prune_context" }
300-
let shouldTerminate = policy.shouldTerminateAfterIteration(toolCalls: iteration.toolCalls)
301301
let budgetUsage = try requireBudgetUsage(iteration.usage, budgetPhase: budgetPhase)
302302

303303
if let budgetUsage {
304-
applyBudgetPhase(
305-
&budgetPhase,
306-
usage: budgetUsage,
307-
messages: &messages,
308-
continuation: continuation
309-
)
304+
applyBudgetPhase(&budgetPhase, usage: budgetUsage, messages: &messages, continuation: continuation)
310305
}
311306

312-
if shouldTerminate {
313-
let finishEvent = try parseFinishEvent(
314-
from: iteration.toolCalls, tokenUsage: totalUsage, history: messages
315-
)
316-
continuation.yield(finishEvent)
317-
continuation.finish()
307+
if try finishIfTerminated(iteration, usage: totalUsage, history: messages, continuation: continuation) {
318308
return
319309
}
320310

321311
executePruneCalls(pruneCalls, messages: &messages, continuation: continuation)
322312
try await executeStreamingAndAppendResults(
323-
regularCalls, context: context, messages: &messages,
313+
regularCalls,
314+
context: context,
315+
messages: &messages,
324316
continuation: continuation,
325-
approvalHandler: options.approvalHandler, allowlist: &sessionAllowlist
317+
approvalHandler: options.approvalHandler,
318+
allowlist: &sessionAllowlist
326319
)
327320

328-
if let tokenBudget = options.tokenBudget, totalUsage.total > tokenBudget {
329-
continuation.finish(
330-
throwing: AgentError.tokenBudgetExceeded(budget: tokenBudget, used: totalUsage.total)
331-
)
321+
if finishIfOverBudget(
322+
options.tokenBudget, totalUsage: totalUsage, history: messages, continuation: continuation
323+
) {
332324
return
333325
}
326+
lastTotalTokens = iteration.usage?.total
334327
}
335328

336-
continuation.finish(throwing: AgentError.maxIterationsReached(iterations: configuration.maxIterations))
329+
finishStreaming(
330+
continuation: continuation,
331+
event: makeFinishedEvent(
332+
tokenUsage: totalUsage,
333+
content: nil,
334+
reason: .maxIterationsReached(limit: configuration.maxIterations),
335+
history: messages
336+
)
337+
)
337338
}
338339

339340
func buildInitialMessages(
@@ -354,20 +355,25 @@ private extension Agent {
354355
from toolCalls: [ToolCall], tokenUsage: TokenUsage, history: [ChatMessage]
355356
) throws -> StreamEvent {
356357
guard let finishCall = toolCalls.first(where: { $0.name == "finish" }) else {
357-
return .make(.finished(tokenUsage: tokenUsage, content: nil, reason: nil, history: history))
358+
return makeFinishedEvent(
359+
tokenUsage: tokenUsage,
360+
content: nil,
361+
reason: nil,
362+
history: history
363+
)
358364
}
359365
let decoded: FinishArguments
360366
do {
361367
decoded = try JSONDecoder().decode(FinishArguments.self, from: finishCall.argumentsData)
362368
} catch {
363369
throw AgentError.finishDecodingFailed(message: String(describing: error))
364370
}
365-
return .make(.finished(
371+
return makeFinishedEvent(
366372
tokenUsage: tokenUsage,
367373
content: decoded.content,
368374
reason: FinishReason(decoded.reason ?? "completed"),
369375
history: history
370-
))
376+
)
371377
}
372378

373379
func parseFinishResult(
@@ -391,4 +397,89 @@ private extension Agent {
391397
history: history
392398
)
393399
}
400+
401+
private func makeFinishedEvent(
402+
tokenUsage: TokenUsage,
403+
content: String?,
404+
reason: FinishReason?,
405+
history: [ChatMessage]
406+
) -> StreamEvent {
407+
.make(.finished(
408+
tokenUsage: tokenUsage,
409+
content: content,
410+
reason: reason,
411+
history: history
412+
))
413+
}
414+
415+
private func makeTerminalResult(
416+
reason: FinishReason,
417+
tokenUsage: TokenUsage,
418+
iterations: Int,
419+
history: [ChatMessage]
420+
) -> AgentResult {
421+
AgentResult(
422+
finishReason: reason,
423+
content: nil,
424+
totalTokenUsage: tokenUsage,
425+
iterations: iterations,
426+
history: history
427+
)
428+
}
429+
430+
private func emitCompactionEventIfNeeded(
431+
_ compacted: Bool,
432+
lastTotalTokens: Int?,
433+
continuation: AsyncThrowingStream<StreamEvent, Error>.Continuation
434+
) {
435+
guard compacted, let totalTokens = lastTotalTokens, let windowSize = client.contextWindowSize else {
436+
return
437+
}
438+
continuation.yield(.make(.compacted(totalTokens: totalTokens, windowSize: windowSize)))
439+
}
440+
441+
private func finishIfTerminated(
442+
_ iteration: StreamIteration,
443+
usage: TokenUsage,
444+
history: [ChatMessage],
445+
continuation: AsyncThrowingStream<StreamEvent, Error>.Continuation
446+
) throws -> Bool {
447+
guard StreamPolicy.agent.shouldTerminateAfterIteration(toolCalls: iteration.toolCalls) else {
448+
return false
449+
}
450+
try finishStreaming(
451+
continuation: continuation,
452+
event: parseFinishEvent(from: iteration.toolCalls, tokenUsage: usage, history: history)
453+
)
454+
return true
455+
}
456+
457+
private func finishIfOverBudget(
458+
_ tokenBudget: Int?,
459+
totalUsage: TokenUsage,
460+
history: [ChatMessage],
461+
continuation: AsyncThrowingStream<StreamEvent, Error>.Continuation
462+
) -> Bool {
463+
guard let tokenBudget, totalUsage.total > tokenBudget else {
464+
return false
465+
}
466+
finishStreaming(
467+
continuation: continuation,
468+
event: makeFinishedEvent(
469+
tokenUsage: totalUsage,
470+
content: nil,
471+
reason: .tokenBudgetExceeded(budget: tokenBudget, used: totalUsage.total),
472+
history: history
473+
)
474+
)
475+
return true
476+
}
477+
478+
private func finishStreaming(
479+
continuation: AsyncThrowingStream<StreamEvent, Error>.Continuation,
480+
event: StreamEvent
481+
) {
482+
continuation.yield(event)
483+
continuation.finish()
484+
}
394485
}

Sources/AgentRunKit/Core/AgentError.swift

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ public enum MalformedStreamReason: Sendable, Equatable, CustomStringConvertible
2323

2424
/// Errors thrown by the agent loop.
2525
public enum AgentError: Error, Sendable, Equatable, LocalizedError {
26-
case maxIterationsReached(iterations: Int)
2726
case toolNotFound(name: String)
2827
case toolDecodingFailed(tool: String, message: String)
2928
case toolEncodingFailed(tool: String, message: String)
@@ -35,14 +34,11 @@ public enum AgentError: Error, Sendable, Equatable, LocalizedError {
3534
case malformedStream(MalformedStreamReason)
3635
case schemaInferenceFailed(type: String, message: String)
3736
case maxDepthExceeded(depth: Int)
38-
case tokenBudgetExceeded(budget: Int, used: Int)
3937
case contextBudgetUsageUnavailable
4038
case contextBudgetWindowSizeUnavailable
4139

4240
public var errorDescription: String? {
4341
switch self {
44-
case let .maxIterationsReached(iterations):
45-
"Agent reached maximum iterations (\(iterations))"
4642
case let .toolNotFound(name):
4743
"Tool '\(name)' not found"
4844
case let .toolDecodingFailed(tool, message):
@@ -65,8 +61,6 @@ public enum AgentError: Error, Sendable, Equatable, LocalizedError {
6561
"Schema inference failed for '\(type)': \(message)"
6662
case let .maxDepthExceeded(depth):
6763
"Sub-agent max depth exceeded (current depth: \(depth))"
68-
case let .tokenBudgetExceeded(budget, used):
69-
"Token budget exceeded (budget: \(budget), used: \(used))"
7064
case .contextBudgetUsageUnavailable:
7165
"Context budget requires provider-reported token usage for every budgeted turn"
7266
case .contextBudgetWindowSizeUnavailable:
@@ -81,14 +75,12 @@ public enum AgentError: Error, Sendable, Equatable, LocalizedError {
8175
case let .toolTimeout(tool): "Error: Tool '\(tool)' timed out."
8276
case let .toolExecutionFailed(tool, message): "Error: Tool '\(tool)' failed: \(message)"
8377
case let .toolEncodingFailed(tool, message): "Error: Failed to encode '\(tool)' output: \(message)"
84-
case let .maxIterationsReached(count): "Error: Agent reached maximum iterations (\(count))."
8578
case let .finishDecodingFailed(message): "Error: Failed to decode finish arguments: \(message)"
8679
case let .structuredOutputDecodingFailed(message): "Error: Failed to decode structured output: \(message)"
8780
case let .llmError(transportError): "Error: LLM request failed: \(transportError)"
8881
case let .malformedStream(reason): "Error: Malformed stream: \(reason)"
8982
case let .schemaInferenceFailed(type, message): "Error: Schema inference failed for '\(type)': \(message)"
9083
case let .maxDepthExceeded(depth): "Error: Sub-agent max depth exceeded (current depth: \(depth))."
91-
case let .tokenBudgetExceeded(budget, used): "Error: Token budget exceeded (budget: \(budget), used: \(used))."
9284
case .contextBudgetUsageUnavailable:
9385
"Error: Context budget requires provider-reported token usage for every budgeted turn."
9486
case .contextBudgetWindowSizeUnavailable:

0 commit comments

Comments
 (0)