From 5adf08c3c980c82a9cb5e41b12623e95ea2b53fc Mon Sep 17 00:00:00 2001 From: Ayman Hamed Date: Mon, 24 Aug 2026 18:38:45 +0300 Subject: [PATCH] Progress nudge: turn-budget checkpoints against slow-burn thrash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit At configurable fractions of maxTurns (default 50% and 80%), a transient system note is injected into that call only: re-read the objective, stop grinding a single stubborn subproblem, switch approach or report. Catches the failure loop detection can't see — hundreds of slightly-different attempts at one subgoal, observed live as 2×200-turn runs on one flaky XCUITest while the actual task sat untouched. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/SwiftAgentKit/Core/Agent.swift | 44 +++++++++- .../SwiftAgentKitTests.swift | 80 +++++++++++++++++++ 2 files changed, 122 insertions(+), 2 deletions(-) diff --git a/Sources/SwiftAgentKit/Core/Agent.swift b/Sources/SwiftAgentKit/Core/Agent.swift index ac5e503..1853994 100644 --- a/Sources/SwiftAgentKit/Core/Agent.swift +++ b/Sources/SwiftAgentKit/Core/Agent.swift @@ -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 @@ -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 @@ -146,6 +154,7 @@ public struct AgentConfig: Sendable { self.maxVerificationRetries = maxVerificationRetries self.loopDetection = loopDetection self.parallelToolCalls = parallelToolCalls + self.progressNudgeFractions = progressNudgeFractions } } @@ -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 { + 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: []) } @@ -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) @@ -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)) diff --git a/Tests/SwiftAgentKitTests/SwiftAgentKitTests.swift b/Tests/SwiftAgentKitTests/SwiftAgentKitTests.swift index 043f7bc..0a78130 100644 --- a/Tests/SwiftAgentKitTests/SwiftAgentKitTests.swift +++ b/Tests/SwiftAgentKitTests/SwiftAgentKitTests.swift @@ -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 { + 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]") }) +}