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
44 changes: 42 additions & 2 deletions Sources/SwiftAgentKit/Core/Agent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,13 @@ public struct AgentConfig: Sendable {
/// agent uses its normal trim-based context handling.
public var contextManager: ContextManager?

/// Fractions of `maxTurns` at which a one-line progress note is injected
/// into the model's context ("you have used N of M turns — re-check the
/// objective, stop grinding a single subproblem"). Catches slow-burn
/// thrash that loop detection (same call, same args) can't see. Empty
/// disables. Default [0.5, 0.8].
public var progressNudgeFractions: [Double]

/// When `true`, tools marked `requiresConfirmation` run WITHOUT prompting via
/// `AgentCallbacks.onToolConfirmation` — the agent has full autonomy. Default
/// `false` (confirmation-gated). Can also be flipped at runtime with
Expand Down Expand Up @@ -124,7 +131,8 @@ public struct AgentConfig: Sendable {
maxSubAgentConcurrency: Int = 1,
maxVerificationRetries: Int = 3,
loopDetection: LoopDetectionConfig? = .default,
parallelToolCalls: Bool = false
parallelToolCalls: Bool = false,
progressNudgeFractions: [Double] = [0.5, 0.8]
) {
self.provider = provider
self.model = model
Expand All @@ -146,6 +154,7 @@ public struct AgentConfig: Sendable {
self.maxVerificationRetries = maxVerificationRetries
self.loopDetection = loopDetection
self.parallelToolCalls = parallelToolCalls
self.progressNudgeFractions = progressNudgeFractions
}
}

Expand Down Expand Up @@ -604,6 +613,28 @@ public actor Agent {
/// 2. Enter the ReAct loop (if tools are registered and maxTurns > 0)
/// 3. Return the final response
///
/// Turn numbers at which progress nudges fire. Only interior turns qualify
/// (a nudge at turn 1 or the final turn is noise), each fraction once.
static func nudgeTurns(maxTurns: Int, fractions: [Double]) -> Set<Int> {
guard maxTurns > 0 else { return [] }
return Set(fractions.compactMap { fraction -> Int? in
guard fraction > 0, fraction < 1 else { return nil }
let turn = Int((Double(maxTurns) * fraction).rounded())
return turn > 1 && turn < maxTurns ? turn : nil
})
}

/// The transient progress note injected at nudge turns.
static func progressNudge(turn: Int, maxTurns: Int) -> String {
"""
[Progress check] You have used \(turn) of \(maxTurns) turns. Re-read the \
objective and your plan. If most recent turns went into one stubborn \
subproblem (e.g. one failing test), STOP grinding it: summarize what you \
tried, state the blocker, and either switch approach or finish with a \
report and a question. Do not repeat an approach that has already failed.
"""
}

public func run(_ query: String) async throws -> String {
try await run(query, images: [])
}
Expand Down Expand Up @@ -758,6 +789,8 @@ public actor Agent {
// 3. Agent loop
if config.maxTurns > 0 && !registeredTools.isEmpty {
// ReAct loop with tools
var pendingNudges = Self.nudgeTurns(maxTurns: config.maxTurns,
fractions: config.progressNudgeFractions)
while totalTurns < config.maxTurns {
if isCancelled {
emit(.cancelled)
Expand All @@ -766,7 +799,14 @@ public actor Agent {
totalTurns += 1

// Get messages for LLM call (trimmed to context window)
let messagesForLLM = conversation.messagesForLLMCall()
var messagesForLLM = conversation.messagesForLLMCall()
// Budget checkpoint: transient system note for THIS call only
// (not appended to the conversation), nudging the model to
// reassess instead of grinding one subproblem to the turn cap.
if pendingNudges.remove(totalTurns) != nil {
messagesForLLM.append(.system(
Self.progressNudge(turn: totalTurns, maxTurns: config.maxTurns)))
}
let removedCount = conversation.allMessages().count - messagesForLLM.count
if removedCount > 0 {
emit(.historyTrimmed(removedCount: removedCount, remainingCount: messagesForLLM.count))
Expand Down
80 changes: 80 additions & 0 deletions Tests/SwiftAgentKitTests/SwiftAgentKitTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2576,3 +2576,83 @@ private func tempArtifactDir() -> URL {
let listed = await store.list(limit: 5)
#expect(listed.first?.toolName == "run_shell")
}

// MARK: - Progress nudge (turn-budget checkpoint)

@Test func testNudgeTurnSchedule() {
#expect(Agent.nudgeTurns(maxTurns: 200, fractions: [0.5, 0.8]) == [100, 160])
#expect(Agent.nudgeTurns(maxTurns: 40, fractions: [0.5, 0.8]) == [20, 32])
// Interior turns only: no nudge at turn 1 or at/after the cap.
#expect(Agent.nudgeTurns(maxTurns: 2, fractions: [0.5, 0.8]).isEmpty)
#expect(Agent.nudgeTurns(maxTurns: 0, fractions: [0.5]).isEmpty)
// Out-of-range fractions ignored; empty disables.
#expect(Agent.nudgeTurns(maxTurns: 100, fractions: [0, 1.0, 1.5]).isEmpty)
#expect(Agent.nudgeTurns(maxTurns: 100, fractions: []).isEmpty)
}

private final class RequestCapturingProvider: LLMProvider, @unchecked Sendable {
static let name = "capture-mock"
static let providerName = "capture-mock"
let configuration = LLMProviderConfiguration(
name: RequestCapturingProvider.providerName, baseURL: URL(string: "inproc://x")!)
private let lock = NSLock()
private(set) var requests: [LLMRequest] = []
private var remainingToolCalls: Int
init(toolCallTurns: Int) { remainingToolCalls = toolCallTurns }

func complete(_ request: LLMRequest) async throws -> LLMResponse {
let callTool: Bool = lock.withLock {
requests.append(request)
if remainingToolCalls > 0 { remainingToolCalls -= 1; return true }
return false
}
if callTool {
return LLMResponse(text: "", finishReason: .toolCalls,
toolCalls: [LLMToolCall(id: UUID().uuidString, name: "noop", arguments: "{}")],
request: request, providerName: Self.providerName)
}
return LLMResponse(text: "done", finishReason: .stop,
request: request, providerName: Self.providerName)
}

func stream(_ request: LLMRequest) -> AsyncThrowingStream<LLMStreamChunk, Error> {
AsyncThrowingStream { continuation in
Task {
let response = try await self.complete(request)
if !response.text.isEmpty { continuation.yield(.text(response.text)) }
continuation.yield(.finish(reason: .stop, usage: nil))
continuation.finish()
}
}
}
}

private struct NoopTool: AgentTool {
let name = "noop"
let description = "does nothing"
let parameters = ToolParameters(properties: [:], required: [])
func execute(parameters: [String: Any]) async throws -> AgentToolResult {
.success(toolCallId: "", toolName: name, result: "ok")
}
}

@Test func testProgressNudgeInjectedAtScheduledTurnOnly() async throws {
// maxTurns 4, fraction 0.5 → nudge exactly at turn 2. The model tool-calls
// 3 times then answers, so we capture 4 requests.
let provider = RequestCapturingProvider(toolCallTurns: 3)
let agent = Agent(config: AgentConfig(
provider: provider, maxTurns: 4, tools: [NoopTool()],
loopDetection: nil, progressNudgeFractions: [0.5]))
_ = try await agent.run("do the thing")

#expect(provider.requests.count == 4)
func hasNudge(_ r: LLMRequest) -> Bool {
r.messages.contains { $0.content.contains("[Progress check]") }
}
#expect(!hasNudge(provider.requests[0]))
#expect(hasNudge(provider.requests[1])) // turn 2
#expect(!hasNudge(provider.requests[2])) // fires once
#expect(!hasNudge(provider.requests[3]))
// Transient: the note never lands in the persistent conversation.
#expect(!agent.conversation.allMessages().contains { $0.content.contains("[Progress check]") })
}
Loading