Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ let package = Package(
.library(name: "SwiftAgentKitMac", targets: ["SwiftAgentKitMac"]),
],
dependencies: [
.package(url: "https://github.com/ayman3000/LLMProviderKit.git", from: "0.1.0-alpha.39"),
.package(url: "https://github.com/ayman3000/LLMProviderKit.git", from: "0.1.0-alpha.41"),
.package(url: "https://github.com/swiftlang/swift-syntax.git", from: "603.0.0"),
.package(url: "https://github.com/modelcontextprotocol/swift-sdk.git", from: "0.12.1"),
.package(url: "https://github.com/swiftlang/swift-docc-plugin.git", from: "1.4.0"),
Expand Down Expand Up @@ -55,6 +55,10 @@ let package = Package(
// that pin one request per streamed tool step.
.product(name: "LLMProviderKitOllama", package: "LLMProviderKit"),
.product(name: "LLMProviderKitOpenAI", package: "LLMProviderKit"),
// Anthropic wire test: the regression that the trailing note
// and the stop-reasoning nudge keep the request's roles
// alternating through AnthropicProvider.prepareRequest.
.product(name: "LLMProviderKitAnthropic", package: "LLMProviderKit"),
]
),

Expand Down
4 changes: 2 additions & 2 deletions Sources/SwiftAgentKit/Context/ArtifactTools.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ public struct ArtifactReadTool: AgentTool {
public var isReadOnly: Bool { true }
public let description = """
Read the full stored output of a previous tool call by its artifact id \
(shown in brackets in the tool ledger, e.g. artifact-abc123). Use `offset` \
(shown in brackets in the receipt that replaced the call, e.g. artifact-abc123). Use `offset` \
and `limit` to page through large outputs.
"""
public let parameters = ToolParameters(
Expand Down Expand Up @@ -71,7 +71,7 @@ public struct ArtifactSearchTool: AgentTool {
Pass ALL the terms you want to check in ONE call via `queries` (e.g. \
["error:", "TEST FAILED", "warning"]) instead of one call per term. Returns \
matching lines with 2 lines of context each. Use the artifact id from the \
tool ledger.
receipt that replaced the call.
"""
public let parameters = ToolParameters(
properties: [
Expand Down
327 changes: 236 additions & 91 deletions Sources/SwiftAgentKit/Context/ContextManager.swift

Large diffs are not rendered by default.

264 changes: 237 additions & 27 deletions Sources/SwiftAgentKit/Core/Agent.swift

Large diffs are not rendered by default.

25 changes: 22 additions & 3 deletions Sources/SwiftAgentKit/Core/AgentEvent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,10 @@ public enum AgentEvent: Sendable {

// MARK: - Memory

/// History was trimmed to fit the context window.
case historyTrimmed(removedCount: Int, remainingCount: Int)
/// History was trimmed: `removedCount` messages left out (or deleted,
/// by `reason`), `remainingCount` kept. `.overflow` is the signal to
/// compact: the request no longer fits without leaving steps out.
case historyTrimmed(removedCount: Int, remainingCount: Int, reason: HistoryTrimReason)

/// The older part of the history was replaced by a summary.
case contextCompacted(tokensBefore: Int, tokensAfter: Int, reason: CompactionReason)
Expand Down Expand Up @@ -223,4 +225,21 @@ public final class BlockObserver: AgentObserver {
public func onEvent(_ event: AgentEvent) {
block(event)
}
}
}
/// Why history was trimmed (`AgentEvent.historyTrimmed`).
public enum HistoryTrimReason: String, Sendable {
/// Without a ContextManager: this call sends the stored history trimmed
/// to fit the window. Nothing stored is deleted.
case fitToWindow
/// The message-count cap (`maxMessages`) deleted the oldest stored messages.
case messageCap
/// Without a ContextManager: the token budget deleted the oldest stored
/// messages after a step.
case tokenBudget
/// With a ContextManager: the sifted request, tool definitions counted,
/// passed `ContextManager.overflowFraction` of the window, so calls now
/// leave out the oldest whole steps (stored history is kept). Emitted
/// when that cut is set or moves, not on every call it holds. An app
/// that compacts should compact on it.
case overflow
}
29 changes: 24 additions & 5 deletions Sources/SwiftAgentKit/Memory/Conversation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -99,15 +99,34 @@ public class Conversation: @unchecked Sendable {

/// Get messages trimmed to fit the context window.
public func messagesForLLMCall() -> [AgentMessage] {
messagesForLLMCall(reservingTokens: 0)
}

/// Messages trimmed to fit `fitBudgetTokens` less `reservedTokens`: what
/// the call sends besides the messages (its tool definitions).
public func messagesForLLMCall(reservingTokens reservedTokens: Int) -> [AgentMessage] {
lock.lock()
defer { lock.unlock() }
return ensureContextWindowFits(messages: messages)
return ensureContextWindowFits(messages: messages, reserving: reservedTokens)
}

/// The fit's bound: 80% of the window less the output reserve. The 20%
/// left over absorbs the estimator's error.
public var fitBudgetTokens: Int {
max(0, Int(Double(contextWindow - outputReserve) * 0.8))
}

// MARK: - Trim

/// Trim history to stay within both message-count and token-budget limits.
public func trim() -> (removed: Int, remaining: Int) {
trim(byTokens: true)
}

/// Trim history: the message-count cap, and — when `byTokens` — the
/// token budget. An agent with a ContextManager passes false: ContextSift
/// bounds what is sent, and the token trim deleted stored history for good.
public func trim(byTokens: Bool) -> (removed: Int, remaining: Int) {
lock.lock()
defer { lock.unlock() }

Expand All @@ -122,7 +141,7 @@ public class Conversation: @unchecked Sendable {
}

// 2. Token-budget trim
trimmed = trimByTokens(trimmed)
if byTokens { trimmed = trimByTokens(trimmed) }

let removed = messages.count - trimmed.count
messages = trimmed
Expand Down Expand Up @@ -252,9 +271,9 @@ public class Conversation: @unchecked Sendable {
return trimmed + [lastUser]
}

/// Trim to fit within 80% of the context window.
private func ensureContextWindowFits(messages: [AgentMessage]) -> [AgentMessage] {
let budget = max(0, Int(Double(contextWindow - outputReserve) * 0.8))
/// Trim to fit within 80% of the context window, less `reserving` tokens.
private func ensureContextWindowFits(messages: [AgentMessage], reserving: Int = 0) -> [AgentMessage] {
let budget = max(0, fitBudgetTokens - reserving)
var trimmed = messages

while estimateTotalTokens(trimmed) > budget && trimmed.contains(where: { $0.role != .system }) {
Expand Down
12 changes: 3 additions & 9 deletions Sources/SwiftAgentKit/SubAgents/SubAgentSpawner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -138,15 +138,9 @@ public final class SubAgentSpawner: @unchecked Sendable {
config.systemPrompt = prompt

if let parentCM = config.contextManager {
config.contextManager = ContextManager(
store: parentCM.store,
maxActiveResultChars: parentCM.maxActiveResultChars,
ledgerEntries: parentCM.ledgerEntries,
summaryLength: parentCM.summaryLength,
inlineBudgetChars: parentCM.inlineBudgetChars,
keepLatestReadsInline: parentCM.keepLatestReadsInline,
readToolNames: parentCM.readToolNames
)
// Every setting, readIdentityParams included: without it a child's
// sim_ui/mac_ui reads were never protected.
config.contextManager = parentCM.childManager()
}

let child = Agent(config: config)
Expand Down
19 changes: 10 additions & 9 deletions Tests/SwiftAgentKitReplayTests/ContextChurnGuardTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,17 +53,18 @@ struct ContextChurnGuardTests {
contextManager: cm, loopDetection: nil)
_ = try await run.run("drive the app")

// The LAST request the loop built must contain the newest snapshots inline
// and must NOT carry the oldest one's full result in non-system messages
// (SNAP1 externalized to ledger — a summary appears in the system message,
// but the full inline content must be gone from tool/user/assistant messages).
// The LAST request the loop built must carry the newest snapshots as tool
// results and must NOT carry the oldest one's result as one: SNAP1 is
// evicted, and its receipt (which may quote its first characters)
// stands in its step's place as an assistant message.
let last = try #require(run.capturedRequests.last)
let nonSystemText = last.messages
.filter { $0.role != .system }
let toolText = last.messages
.filter { $0.role == .tool }
.map { $0.content }
.joined(separator: "\n")
#expect(nonSystemText.contains("SNAP3")) // active exchange kept inline
#expect(nonSystemText.contains("SNAP2")) // newest completed read protected inline
#expect(!nonSystemText.contains("SNAP1")) // oldest externalized to ledger
#expect(toolText.contains("SNAP3")) // active exchange kept inline
#expect(toolText.contains("SNAP2")) // newest completed read protected inline
#expect(!toolText.contains("SNAP1")) // oldest evicted
#expect(last.messages.contains { $0.role == .assistant && $0.content.contains(ContextManager.receiptHeader) })
}
}
80 changes: 80 additions & 0 deletions Tests/SwiftAgentKitReplayTests/ReceiptWireShapeTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import Testing
import Foundation
import LLMProviderKit
import LLMProviderKitAnthropic
import LLMProviderKitGemini
import SwiftAgentKit

/// Receipts in place make an assistant message followed by another
/// assistant message the normal shape of a sifted request. The two strictest
/// wires must still pair every tool result with its own call, and Gemini
/// must never see a model turn right before a function-call turn.
struct ReceiptWireShapeTests {
static func siftedMessages() async -> [LLMMessage] {
let manager = ContextManager(summaryLength: 40, inlineBudgetChars: 0)
var messages: [AgentMessage] = [.system("sys"), .user("do three things")]
for i in 1...3 {
messages.append(.assistant(content: "", toolCalls: [AgentToolCall(id: "c\(i)", name: "run_shell")]))
messages.append(.tool(results: [.success(toolCallId: "c\(i)", toolName: "run_shell",
result: "output \(i) " + String(repeating: "x", count: 200))]))
}
// A fourth step still running: its call and result stay inline.
messages.append(.assistant(content: "", toolCalls: [AgentToolCall(id: "c4", name: "run_shell")]))
messages.append(.tool(results: [.success(toolCallId: "c4", toolName: "run_shell", result: "output 4")]))
return await manager.modelMessages(messages) { $0 }
}

@Test func theSiftedShapeHasConsecutiveAssistantMessages() async {
#expect(await Self.siftedMessages().map(\.role)
== [.system, .user, .assistant, .assistant, .assistant, .assistant, .tool])
}

@Test func anthropicPairsEveryToolResultWithItsCall() async throws {
let request = LLMRequest(model: "claude-sonnet-4-6", messages: await Self.siftedMessages())
let provider = AnthropicProvider(configuration: AnthropicProvider.anthropic(apiKey: "k", model: "claude-sonnet-4-6"))
let body = try #require(try provider.prepareRequest(request, stream: false).httpBody)
let json = try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any])
let messages = try #require(json["messages"] as? [[String: Any]])
var results = 0
for (i, message) in messages.enumerated() {
guard let blocks = message["content"] as? [[String: Any]] else { continue }
for block in blocks where block["type"] as? String == "tool_result" {
results += 1
let previous = i > 0 ? (messages[i - 1]["content"] as? [[String: Any]] ?? []) : []
let ids = previous.filter { $0["type"] as? String == "tool_use" }.compactMap { $0["id"] as? String }
#expect(ids.contains(block["tool_use_id"] as? String ?? ""))
}
}
#expect(results == 1)
#expect(String(decoding: body, as: UTF8.self).contains("Tool calls in this step"))
}

@Test func geminiPairsEveryFunctionResponseWithItsCall() async throws {
let request = LLMRequest(model: "gemini-2.0-flash", messages: await Self.siftedMessages())
let provider = GeminiProvider(configuration: GeminiProvider.gemini(apiKey: "k", model: "gemini-2.0-flash"))
let body = try #require(try provider.prepareRequest(request, stream: false).httpBody)
let json = try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any])
let contents = try #require(json["contents"] as? [[String: Any]])
var responses = 0
for (i, content) in contents.enumerated() {
for part in content["parts"] as? [[String: Any]] ?? [] where part["functionResponse"] != nil {
responses += 1
let previous = i > 0 ? (contents[i - 1]["parts"] as? [[String: Any]] ?? []) : []
#expect(previous.contains { $0["functionCall"] != nil })
}
}
#expect(responses == 1)
}

/// Gemini rejects a function-call turn that follows a model turn; since
/// LLMProviderKit 0.1.0-alpha.40 a run of one role is one content.
@Test func geminiNeverSendsTwoContentsOfOneRoleInARow() async throws {
let request = LLMRequest(model: "gemini-2.0-flash", messages: await Self.siftedMessages())
let provider = GeminiProvider(configuration: GeminiProvider.gemini(apiKey: "k", model: "gemini-2.0-flash"))
let body = try #require(try provider.prepareRequest(request, stream: false).httpBody)
let json = try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any])
let roles = try #require(json["contents"] as? [[String: Any]]).compactMap { $0["role"] as? String }
// user (task), model (three receipts + the active call), user (its result)
#expect(roles == ["user", "model", "user"])
}
}
117 changes: 117 additions & 0 deletions Tests/SwiftAgentKitTests/AnthropicRoleAlternationTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import Testing
import Foundation
import LLMProviderKit
import LLMProviderKitAnthropic
@testable import SwiftAgentKit

/// Regression for the Anthropic 400 flagged in the task-14 review: a
/// tool-result turn followed, on the very next call, by a STORED
/// "stop reasoning, act now" nudge AND a transient progress note used to put
/// three consecutive `user`-mapped entries on the wire (tool_result, the
/// stored nudge, the trailing note) — all three built as `.user` messages by
/// `Agent`, none of them merged. Anthropic rejects consecutive same-role
/// messages with HTTP 400.
///
/// LLMProviderKit 0.1.0-alpha.41 fixes this in the adapter itself
/// (`AnthropicProvider.mergingConsecutiveRoles` merges any run of one role,
/// not just tool-result blocks). This test builds the actual wire body via
/// `AnthropicProvider.prepareRequest` — not `ScriptedProvider` at the
/// `LLMMessage` level — so a future regression in either SwiftAgentKit's
/// message ordering or the adapter's merge would fail here.
struct AnthropicRoleAlternationTests {
private struct EchoTool: AgentTool {
let name = "echo"
let description = "Echo."
let parameters = ToolParameters(properties: [
"n": ToolParameterProperty(type: "integer", description: "which"),
], required: ["n"])
func execute(parameters: [String: Any]) async throws -> AgentToolResult {
.success(toolCallId: "", toolName: name, result: "echoed")
}
}

/// Call 1: a tool call (produces a stored tool-result turn). Call 2:
/// reasons past `maxReasoningSeconds` without acting — stored as the
/// "stop reasoning, act now" nudge. Call 3: answers plainly, and is also
/// the turn `nudgeTurns(maxTurns: 4, fractions: [0.75])` picks (turn 3),
/// so its request carries both the stored nudge from call 2 AND the
/// transient progress note — the exact collision the review flagged.
private final class ToolThenStalledReasoningThenDoneProvider: LLMProvider, @unchecked Sendable {
static let name = "tool-then-stalled-reasoning"
let configuration = LLMProviderConfiguration(
name: name, baseURL: URL(string: "inprocess://t")!, defaultModel: "mock")
private let lock = NSLock()
private var calls = 0
private var requests: [LLMRequest] = []
var captured: [LLMRequest] { lock.withLock { requests } }

func complete(_ request: LLMRequest) async throws -> LLMResponse {
LLMResponse(text: "done", finishReason: .stop, request: request, providerName: Self.name)
}

func stream(_ request: LLMRequest) -> AsyncThrowingStream<LLMStreamChunk, Error> {
let n: Int = lock.withLock { calls += 1; requests.append(request); return calls }
return AsyncThrowingStream { continuation in
let task = Task {
switch n {
case 1:
continuation.yield(.toolCall(LLMToolCall(id: "e1", name: "echo", arguments: #"{"n":1}"#)))
continuation.yield(.finish(reason: .toolCalls, usage: nil))
case 2:
for _ in 0..<200 { // stopped early by maxReasoningSeconds
if Task.isCancelled { break }
continuation.yield(.reasoning("hmm "))
try? await Task.sleep(nanoseconds: 50_000_000)
}
continuation.yield(.finish(reason: .stop, usage: nil))
default:
continuation.yield(.text("done"))
continuation.yield(.finish(reason: .stop, usage: nil))
}
continuation.finish()
}
continuation.onTermination = { _ in task.cancel() }
}
}
}

/// The `messages` array of the wire body `AnthropicProvider.prepareRequest`
/// would send for `request`.
private static func anthropicMessages(_ request: LLMRequest) throws -> [[String: Any]] {
let provider = AnthropicProvider(configuration: AnthropicProvider.anthropic(apiKey: "k", model: "claude-sonnet-4-6"))
let body = try #require(try provider.prepareRequest(request, stream: false).httpBody)
let object = try JSONSerialization.jsonObject(with: body) as? [String: Any]
return try #require(object?["messages"] as? [[String: Any]])
}

@Test func rolesAlternateWithTheNoteLastOnTheCollisionTurn() async throws {
let provider = ToolThenStalledReasoningThenDoneProvider()
let agent = Agent(config: AgentConfig(
provider: provider, model: "mock", systemPrompt: "BASE", maxTurns: 4,
tools: [EchoTool()], contextManager: nil, loopDetection: nil,
progressNudgeFractions: [0.75], maxReasoningSeconds: 0.15))
var text = ""
for try await chunk in agent.runStreaming("go") { text += chunk }
#expect(text == "done")

let requests = provider.captured
#expect(requests.count == 3)

// Turn 3's request: tool_result (call 1), the stored stop-reasoning
// nudge (call 2's "continue"), and the transient progress note — all
// three built by Agent as `.user`-mapped content.
let messages = try Self.anthropicMessages(requests[2])
let roles = messages.compactMap { $0["role"] as? String }
#expect(roles.count == messages.count, "every message dict carries a role")
for (a, b) in zip(roles, roles.dropFirst()) {
#expect(a != b, "consecutive same-role entries were not merged: \(roles)")
}

// The note is the last text block of the last (merged) user entry.
let lastContent = try #require(messages.last?["content"] as? [[String: Any]])
let lastBlock = try #require(lastContent.last)
#expect(lastBlock["type"] as? String == "text")
let noteText = try #require(lastBlock["text"] as? String)
#expect(noteText == Agent.progressNudge(turn: 3, maxTurns: 4))
}
}
Loading
Loading