diff --git a/CHANGELOG.md b/CHANGELOG.md index ea6b199b7..1fd05b61d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **File > Close All Tabs**, **Close Other Tabs**, and **Close Tabs for Other Databases** close a group of tabs in one step instead of one at a time. Closing every tab leaves the window open on its empty state with the connection still live, and each closed tab stays in **Recently Closed**. None of the three has a shortcut out of the box; bind them in **Settings > Keyboard**. (#1972) - AI Explain, Optimize, and Fix Error now answer with a walkthrough in the chat panel: a before/after SQL diff you can switch between unified and split, numbered steps anchored to the lines they change, and a follow-up prompt on any step. Optimize and Fix add an Apply to Editor button that asks before replacing your query. (#1945) - Create a connection from a project folder. Pick one from the welcome screen or File > Open Project Folder..., and TablePro reads the database settings it finds in `.env` files, `wp-config.php`, `prisma/schema.prisma`, `config/database.yml`, `docker-compose.yml`, `application.properties`, `application.yml`, and `appsettings.json`. A project that uses more than one engine gets a row for each. Review what it found, pick one, and the connection form opens filled in. Nothing is saved or connected until you save it. (#1959) +- A tool call limit you can raise or turn off, in **Settings > AI**. It defaults to 25 per reply, up from a fixed 10. Reaching it now pauses the reply and keeps everything the AI found, with **Continue** to carry on and **Adjust Limit** to change the setting. Before, a long investigation ended in "AI made too many tool calls in one response" and the partial answer was thrown away. Copilot runs its own tool loop and ignores this setting. (#1987) - A pin button on result tabs. It shows on the tab you are on and when you point at a tab, and stays visible once the result is pinned. Pinned results move to the front of the strip. Pinning was already there, but only as a right-click item and a View menu command, so nobody found it. (#1982) ### Changed @@ -20,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- A message sent in the AI panel right after launch is no longer replaced by the last saved conversation. Restoring that conversation reads from disk in the background, and it used to overwrite whatever you had already typed and sent. - A PostgreSQL partitioned table is listed once instead of once per partition. Expand it in the sidebar to see its partitions, and expand one again if it is subpartitioned. A table split into hundreds of partitions no longer buries the rest of the schema, and those partitions no longer fill up query autocomplete, Cmd+K, and the export picker. (#1984) - An SSH tunnel that cannot reach the database now says so, instead of leaving the database driver to report a timeout reading the server greeting. MySQL showed this as "Lost connection to server at 'handshake: reading initial communication packet', system error: 35", which named no cause. TablePro now checks the destination is reachable before the tunnel is handed over, and reports whether it was refused or timed out. The most common cause is the **Host** field holding the server's public address while the database only listens on `127.0.0.1`; the SSH server resolves that address from where it sits, so **Host** should be `localhost`. (#1981) - The macOS local network prompt now says why TablePro wants access. The app never declared a reason, so the prompt showed a generic message that was easy to deny, which left SSH tunnels and databases on your own network failing with "no route to host". diff --git a/TablePro/Models/AI/AIModels.swift b/TablePro/Models/AI/AIModels.swift index 742ef36f5..c514589ae 100644 --- a/TablePro/Models/AI/AIModels.swift +++ b/TablePro/Models/AI/AIModels.swift @@ -227,11 +227,15 @@ struct AISettings: Codable, Equatable, Sendable { var includeCurrentQuery: Bool var includeQueryResults: Bool var maxSchemaTables: Int + var maxToolRoundtrips: Int + var maxToolRoundtripsEnabled: Bool var defaultConnectionPolicy: AIConnectionPolicy var chatMode: AIChatMode static let defaultInlineSuggestionDebounceMs: Int = 500 static let inlineSuggestionDebounceRange: ClosedRange = 100...3_000 + static let defaultMaxToolRoundtrips: Int = 25 + static let maxToolRoundtripsRange: ClosedRange = 5...200 static let `default` = AISettings( enabled: true, @@ -243,6 +247,8 @@ struct AISettings: Codable, Equatable, Sendable { includeCurrentQuery: true, includeQueryResults: false, maxSchemaTables: 20, + maxToolRoundtrips: AISettings.defaultMaxToolRoundtrips, + maxToolRoundtripsEnabled: true, defaultConnectionPolicy: .askEachTime, chatMode: .ask ) @@ -257,6 +263,8 @@ struct AISettings: Codable, Equatable, Sendable { includeCurrentQuery: Bool = true, includeQueryResults: Bool = false, maxSchemaTables: Int = 20, + maxToolRoundtrips: Int = AISettings.defaultMaxToolRoundtrips, + maxToolRoundtripsEnabled: Bool = true, defaultConnectionPolicy: AIConnectionPolicy = .askEachTime, chatMode: AIChatMode = .ask ) { @@ -269,6 +277,8 @@ struct AISettings: Codable, Equatable, Sendable { self.includeCurrentQuery = includeCurrentQuery self.includeQueryResults = includeQueryResults self.maxSchemaTables = maxSchemaTables + self.maxToolRoundtrips = maxToolRoundtrips + self.maxToolRoundtripsEnabled = maxToolRoundtripsEnabled self.defaultConnectionPolicy = defaultConnectionPolicy self.chatMode = chatMode } @@ -286,6 +296,12 @@ struct AISettings: Codable, Equatable, Sendable { includeCurrentQuery = try container.decodeIfPresent(Bool.self, forKey: .includeCurrentQuery) ?? true includeQueryResults = try container.decodeIfPresent(Bool.self, forKey: .includeQueryResults) ?? false maxSchemaTables = try container.decodeIfPresent(Int.self, forKey: .maxSchemaTables) ?? 20 + maxToolRoundtrips = try container.decodeIfPresent( + Int.self, forKey: .maxToolRoundtrips + ) ?? AISettings.defaultMaxToolRoundtrips + maxToolRoundtripsEnabled = try container.decodeIfPresent( + Bool.self, forKey: .maxToolRoundtripsEnabled + ) ?? true defaultConnectionPolicy = try container.decodeIfPresent( AIConnectionPolicy.self, forKey: .defaultConnectionPolicy ) ?? .askEachTime @@ -309,6 +325,14 @@ struct AISettings: Codable, Equatable, Sendable { AISettings.inlineSuggestionDebounceRange.upperBound ) } + + var effectiveMaxToolRoundtrips: Int? { + guard maxToolRoundtripsEnabled else { return nil } + return min( + max(maxToolRoundtrips, AISettings.maxToolRoundtripsRange.lowerBound), + AISettings.maxToolRoundtripsRange.upperBound + ) + } } struct AITokenUsage: Codable, Equatable, Sendable { diff --git a/TablePro/ViewModels/AIChatViewModel+Persistence.swift b/TablePro/ViewModels/AIChatViewModel+Persistence.swift index 72b34596e..6366d2d65 100644 --- a/TablePro/ViewModels/AIChatViewModel+Persistence.swift +++ b/TablePro/ViewModels/AIChatViewModel+Persistence.swift @@ -13,10 +13,9 @@ extension AIChatViewModel { await MainActor.run { guard let self else { return } self.conversations = loaded - if let mostRecent = loaded.first { - self.activeConversationID = mostRecent.id - self.messages = mostRecent.messages.map { ChatTurn(wire: $0) } - } + guard self.messages.isEmpty, let mostRecent = loaded.first else { return } + self.activeConversationID = mostRecent.id + self.messages = mostRecent.messages.map { ChatTurn(wire: $0) } } } } diff --git a/TablePro/ViewModels/AIChatViewModel+Streaming.swift b/TablePro/ViewModels/AIChatViewModel+Streaming.swift index d5594efe6..9875e0e87 100644 --- a/TablePro/ViewModels/AIChatViewModel+Streaming.swift +++ b/TablePro/ViewModels/AIChatViewModel+Streaming.swift @@ -5,10 +5,11 @@ import Foundation import os +import SwiftUI import TableProPluginKit extension AIChatViewModel { - static let maxToolRoundtrips = 10 + static let hardToolRoundtripCeiling = 500 struct ToolRoundtripContinuation { let nextAssistantID: UUID @@ -31,7 +32,12 @@ extension AIChatViewModel { } func startStreaming() { - guard case .idle = streamingState else { return } + switch streamingState { + case .idle, .pausedAtToolLimit: + break + case .loading, .streaming, .awaitingApproval, .failed: + return + } let settings = services.appSettings.ai @@ -60,6 +66,39 @@ extension AIChatViewModel { } } + beginStreamingTurn( + resolved: resolved, + settings: settings, + includeWalkthroughDirective: pendingWalkthroughBeforeSQL != nil + ) + } + + func continueToolLoop() { + guard case .pausedAtToolLimit = streamingState else { return } + + let settings = services.appSettings.ai + let resolved = AIProviderFactory.resolve( + settings: settings, + overrideProviderId: selectedProviderId, + overrideModel: selectedModel + ) + guard let resolved else { + errorMessage = String(localized: "No AI provider configured. Go to Settings > AI to add one.") + return + } + + beginStreamingTurn( + resolved: resolved, + settings: settings, + includeWalkthroughDirective: pendingWalkthroughBeforeSQL != nil + ) + } + + private func beginStreamingTurn( + resolved: AIProviderFactory.ResolvedProvider, + settings: AISettings, + includeWalkthroughDirective: Bool + ) { let assistantMessage = ChatTurn( role: .assistant, blocks: [], @@ -96,7 +135,7 @@ extension AIChatViewModel { resolved: resolved, assistantID: assistantID, settings: settings, - includeWalkthroughDirective: self.pendingWalkthroughBeforeSQL != nil + includeWalkthroughDirective: includeWalkthroughDirective ) self.prepTask = nil } @@ -109,9 +148,14 @@ extension AIChatViewModel { resolved: AIProviderFactory.ResolvedProvider, assistantID: UUID, settings: AISettings, - includeWalkthroughDirective: Bool = false + includeWalkthroughDirective: Bool = false, + registry: ChatToolRegistry? = nil ) { let chatMode = settings.chatMode + let roundtripLimit = min( + settings.effectiveMaxToolRoundtrips ?? Self.hardToolRoundtripCeiling, + Self.hardToolRoundtripCeiling + ) streamingTask = Task.detached(priority: .userInitiated) { [weak self] in var currentAssistantID = assistantID do { @@ -128,10 +172,21 @@ extension AIChatViewModel { ) guard preflightOK else { return } - let toolSpecs = await MainActor.run { ChatToolRegistry.shared.allSpecs(for: chatMode) } + let toolSpecs = await MainActor.run { + (registry ?? ChatToolRegistry.shared).allSpecs(for: chatMode) + } var workingTurns = chatMessages + var executedRoundtrips = 0 + + while true { + if executedRoundtrips >= roundtripLimit { + await self.pauseAtToolLimit( + assistantID: currentAssistantID, + count: executedRoundtrips + ) + return + } - for roundtrip in 0.. [ToolUseBlock] { let initialBlocks = await MainActor.run { [weak self] () -> [ToolUseBlock] in guard let self else { return assembledBlocks } let initial = assembledBlocks.map { block -> ToolUseBlock in - let state = self.computeInitialApprovalState(for: block.name) + let state = self.computeInitialApprovalState(for: block.name, registry: registry) return ToolUseBlock( id: block.id, name: block.name, @@ -77,8 +78,11 @@ extension AIChatViewModel { } @MainActor - func computeInitialApprovalState(for toolName: String) -> ToolApprovalState { - let tool = ChatToolRegistry.shared.tool(named: toolName) + func computeInitialApprovalState( + for toolName: String, + registry: ChatToolRegistry? = nil + ) -> ToolApprovalState { + let tool = (registry ?? ChatToolRegistry.shared).tool(named: toolName) let toolMode = tool?.mode if toolMode == .readOnly { diff --git a/TablePro/ViewModels/AIChatViewModel.swift b/TablePro/ViewModels/AIChatViewModel.swift index 1bba56310..4c4165d48 100644 --- a/TablePro/ViewModels/AIChatViewModel.swift +++ b/TablePro/ViewModels/AIChatViewModel.swift @@ -17,6 +17,7 @@ final class AIChatViewModel { case loading case streaming(assistantID: UUID) case awaitingApproval + case pausedAtToolLimit(count: Int) case failed(AIProviderError?) } @@ -51,7 +52,7 @@ final class AIChatViewModel { switch streamingState { case .loading, .streaming: return true - case .idle, .awaitingApproval, .failed: + case .idle, .awaitingApproval, .pausedAtToolLimit, .failed: return false } } @@ -61,6 +62,13 @@ final class AIChatViewModel { return false } + var toolLimitPauseCount: Int? { + if case .pausedAtToolLimit(let count) = streamingState { return count } + return nil + } + + var isPausedAtToolLimit: Bool { toolLimitPauseCount != nil } + var lastError: AIProviderError? { if case .failed(let error) = streamingState { return error } return nil diff --git a/TablePro/Views/AIChat/AIChatMessageView.swift b/TablePro/Views/AIChat/AIChatMessageView.swift index 7a8117d9a..9a8b42e3e 100644 --- a/TablePro/Views/AIChat/AIChatMessageView.swift +++ b/TablePro/Views/AIChat/AIChatMessageView.swift @@ -15,6 +15,9 @@ struct AIChatMessageView: View { var onRetry: (() -> Void)? var onRegenerate: (() -> Void)? var onEdit: (() -> Void)? + var onContinue: (() -> Void)? + var onAdjustToolLimit: (() -> Void)? + var pausedToolCallCount: Int? private var attachedContextItems: [ContextItem] { message.blocks.compactMap { block in @@ -113,9 +116,38 @@ struct AIChatMessageView: View { .buttonStyle(.plain) .padding(.horizontal, 8) } + + toolLimitPauseRow + } + } + + @ViewBuilder + private var toolLimitPauseRow: some View { + if let onContinue, let onAdjustToolLimit, let pausedToolCallCount { + HStack(spacing: 8) { + Image(systemName: "pause.circle") + .foregroundStyle(.secondary) + Text(pausedDescription(count: pausedToolCallCount)) + .foregroundStyle(.secondary) + Spacer(minLength: 0) + Button(String(localized: "Adjust Limit")) { onAdjustToolLimit() } + .buttonStyle(.plain) + .foregroundStyle(Color.accentColor) + .help(String(localized: "Open AI settings to change the tool call limit.")) + Button(String(localized: "Continue")) { onContinue() } + .controlSize(.small) + .help(String(localized: "Resume with a fresh tool call budget.")) + } + .font(.caption) + .padding(.horizontal, 8) + .padding(.top, 2) } } + private func pausedDescription(count: Int) -> String { + String(format: String(localized: "Paused after %d tool calls."), count) + } + private var roleHeader: some View { HStack(spacing: 4) { Image(systemName: "sparkles") diff --git a/TablePro/Views/AIChat/AIChatPanelView.swift b/TablePro/Views/AIChat/AIChatPanelView.swift index 3d261b7e8..6caea9bcf 100644 --- a/TablePro/Views/AIChat/AIChatPanelView.swift +++ b/TablePro/Views/AIChat/AIChatPanelView.swift @@ -124,7 +124,13 @@ struct AIChatPanelView: View { onRetry: shouldShowRetry(for: message) ? { viewModel.retry() } : nil, onRegenerate: shouldShowRegenerate(for: message) ? { viewModel.regenerate() } : nil, onEdit: message.role == .user && !viewModel.isStreaming - ? { viewModel.editMessage(message) } : nil + ? { viewModel.editMessage(message) } : nil, + onContinue: shouldShowContinue(for: message) + ? { viewModel.continueToolLoop() } : nil, + onAdjustToolLimit: shouldShowContinue(for: message) + ? { WindowOpener.shared.openSettings(tab: .ai) } : nil, + pausedToolCallCount: shouldShowContinue(for: message) + ? viewModel.toolLimitPauseCount : nil ) .padding(.vertical, 4) .id(message.id) @@ -622,6 +628,12 @@ struct AIChatPanelView: View { && viewModel.canRetryLastFailure } + private func shouldShowContinue(for message: ChatTurn) -> Bool { + message.role == .assistant + && viewModel.isPausedAtToolLimit + && message.id == viewModel.messages.last(where: { $0.role == .assistant })?.id + } + private func shouldShowRegenerate(for message: ChatTurn) -> Bool { message.role == .assistant && message.id == viewModel.messages.last?.id diff --git a/TablePro/Views/Settings/AISettingsView.swift b/TablePro/Views/Settings/AISettingsView.swift index 285750456..7cbb49bea 100644 --- a/TablePro/Views/Settings/AISettingsView.swift +++ b/TablePro/Views/Settings/AISettingsView.swift @@ -26,6 +26,7 @@ struct AISettingsView: View { activeProviderSection providersSection inlineSuggestionsSection + agentSection contextSection CustomSlashCommandsSection(storage: CustomSlashCommandStorage.shared) privacySection @@ -248,6 +249,27 @@ struct AISettingsView: View { } } + // MARK: - Agent + + private var agentSection: some View { + Section { + Toggle("Limit tool calls per reply", isOn: $settings.maxToolRoundtripsEnabled) + Stepper( + String(format: String(localized: "Tool call limit: %d"), settings.maxToolRoundtrips), + value: $settings.maxToolRoundtrips, + in: AISettings.maxToolRoundtripsRange, + step: 5 + ) + .disabled(!settings.maxToolRoundtripsEnabled) + } header: { + Text("Agent") + } footer: { + Text("Agent mode calls tools in a loop until it finishes or hits this limit, then pauses so you can continue. Raising it costs more tokens per reply.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + // MARK: - Context private var contextSection: some View { diff --git a/TableProTests/Core/Storage/AppSettingsManagerMigrationTests.swift b/TableProTests/Core/Storage/AppSettingsManagerMigrationTests.swift index e989be1b0..faaa2398f 100644 --- a/TableProTests/Core/Storage/AppSettingsManagerMigrationTests.swift +++ b/TableProTests/Core/Storage/AppSettingsManagerMigrationTests.swift @@ -76,6 +76,8 @@ struct AppSettingsManagerMigrationTests { includeCurrentQuery: false, includeQueryResults: true, maxSchemaTables: 50, + maxToolRoundtrips: 40, + maxToolRoundtripsEnabled: false, defaultConnectionPolicy: .never ) let migrated = AppSettingsManager.migrateAI(input) @@ -85,6 +87,8 @@ struct AppSettingsManagerMigrationTests { #expect(migrated.includeCurrentQuery == false) #expect(migrated.includeQueryResults == true) #expect(migrated.maxSchemaTables == 50) + #expect(migrated.maxToolRoundtrips == 40) + #expect(migrated.maxToolRoundtripsEnabled == false) #expect(migrated.defaultConnectionPolicy == .never) } } diff --git a/TableProTests/Models/AISettingsTests.swift b/TableProTests/Models/AISettingsTests.swift index 1b4027777..cd697c1bb 100644 --- a/TableProTests/Models/AISettingsTests.swift +++ b/TableProTests/Models/AISettingsTests.swift @@ -56,6 +56,44 @@ struct AISettingsTests { #expect(settings.includeQueryResults == AISettings.default.includeQueryResults) } + @Test("Tool call limit defaults to 25 and is enabled") + func defaultToolRoundtripLimit() { + #expect(AISettings.default.maxToolRoundtrips == 25) + #expect(AISettings.default.maxToolRoundtripsEnabled == true) + #expect(AISettings.default.effectiveMaxToolRoundtrips == 25) + } + + @Test("Settings saved before the tool call limit existed decode to the default") + func decodingWithoutToolRoundtripKeysUsesDefault() throws { + let data = Data("{}".utf8) + let settings = try JSONDecoder().decode(AISettings.self, from: data) + #expect(settings.maxToolRoundtrips == AISettings.defaultMaxToolRoundtrips) + #expect(settings.maxToolRoundtripsEnabled == true) + } + + @Test("Stored tool call limit values are preserved on decode") + func storedToolRoundtripValuesAreRespected() throws { + let json = #"{"maxToolRoundtrips": 60, "maxToolRoundtripsEnabled": false}"# + let settings = try JSONDecoder().decode(AISettings.self, from: Data(json.utf8)) + #expect(settings.maxToolRoundtrips == 60) + #expect(settings.maxToolRoundtripsEnabled == false) + } + + @Test("Turning the limit off makes it unlimited") + func disabledLimitIsUnlimited() { + let settings = AISettings(maxToolRoundtrips: 30, maxToolRoundtripsEnabled: false) + #expect(settings.effectiveMaxToolRoundtrips == nil) + } + + @Test("Out of range tool call limits clamp to the supported range") + func toolRoundtripLimitClamps() { + let tooHigh = AISettings(maxToolRoundtrips: 5_000, maxToolRoundtripsEnabled: true) + #expect(tooHigh.effectiveMaxToolRoundtrips == AISettings.maxToolRoundtripsRange.upperBound) + + let tooLow = AISettings(maxToolRoundtrips: 0, maxToolRoundtripsEnabled: true) + #expect(tooLow.effectiveMaxToolRoundtrips == AISettings.maxToolRoundtripsRange.lowerBound) + } + @Test("Stored false values for context flags are preserved on decode") func storedFalseFlagsAreRespected() throws { let json = #"{"includeSchema": false, "includeCurrentQuery": false, "includeQueryResults": false}"# diff --git a/TableProTests/ViewModels/AIChatViewModelToolLoopTests.swift b/TableProTests/ViewModels/AIChatViewModelToolLoopTests.swift new file mode 100644 index 000000000..20656d7bd --- /dev/null +++ b/TableProTests/ViewModels/AIChatViewModelToolLoopTests.swift @@ -0,0 +1,249 @@ +// +// AIChatViewModelToolLoopTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +@testable import TablePro +import Testing + +@Suite("AIChatViewModel tool roundtrip loop", .serialized) +@MainActor +struct AIChatViewModelToolLoopTests { + private static let testLimit = 5 + private final class ScriptedTransport: ChatTransport, @unchecked Sendable { + private let rounds: [[ChatStreamEvent]] + private(set) var roundsRequested = 0 + + init(rounds: [[ChatStreamEvent]]) { + self.rounds = rounds + } + + func streamChat( + turns: [ChatTurnWire], + options: ChatTransportOptions + ) -> AsyncThrowingStream { + let index = roundsRequested + roundsRequested += 1 + let events = index < rounds.count ? rounds[index] : [.textDelta("done")] + return AsyncThrowingStream { continuation in + for event in events { + continuation.yield(event) + } + continuation.finish() + } + } + + func fetchAvailableModels() async throws -> [String] { [] } + + func testConnection() async throws -> Bool { true } + } + + private struct NoopTool: ChatTool { + let name = "noop" + let description = "" + let inputSchema: JsonValue = .object(["type": .string("object")]) + let mode: ChatToolMode = .readOnly + + func execute(input: JsonValue, context: ChatToolContext) async throws -> ChatToolResult { + ChatToolResult(content: "ok", isError: false) + } + } + + private static func toolRound(id: String) -> [ChatStreamEvent] { + [ + .toolUseStart(id: id, name: "noop", providerMetadata: nil), + .toolUseDelta(id: id, inputJSONDelta: "{}"), + .toolUseEnd(id: id) + ] + } + + private static func toolRounds(count: Int) -> [[ChatStreamEvent]] { + (1...count).map { toolRound(id: "u\($0)") } + } + + private static func makeSettings(limit: Int?, enabled: Bool) -> AISettings { + AISettings( + enabled: true, + includeSchema: false, + includeCurrentQuery: false, + includeQueryResults: false, + maxToolRoundtrips: limit ?? AISettings.defaultMaxToolRoundtrips, + maxToolRoundtripsEnabled: enabled, + chatMode: .agent + ) + } + + private static func makeResolved(_ transport: ChatTransport) -> AIProviderFactory.ResolvedProvider { + AIProviderFactory.ResolvedProvider( + provider: transport, + model: "test-model", + config: AIProviderConfig(name: "Test", type: .claude) + ) + } + + private func runLoop( + viewModel: AIChatViewModel, + transport: ChatTransport, + settings: AISettings + ) async { + let registry = ChatToolRegistry() + registry.register(NoopTool()) + let assistant = ChatTurn(role: .assistant, blocks: [], modelId: "test-model", providerId: nil) + viewModel.messages.append(assistant) + viewModel.streamingState = .streaming(assistantID: assistant.id) + viewModel.runStream( + chatMessages: [], + promptContext: nil, + resolved: Self.makeResolved(transport), + assistantID: assistant.id, + settings: settings, + registry: registry + ) + await viewModel.streamingTask?.value + } + + private func toolUseTurnCount(_ viewModel: AIChatViewModel) -> Int { + viewModel.messages.filter { turn in + turn.blocks.contains { block in + if case .toolUse = block.kind { return true } + return false + } + }.count + } + + @Test("Pauses after exactly the configured number of executed rounds") + func pausesAtConfiguredLimit() async { + let transport = ScriptedTransport(rounds: Self.toolRounds(count: 20)) + let viewModel = AIChatViewModel() + + await runLoop( + viewModel: viewModel, + transport: transport, + settings: Self.makeSettings(limit: Self.testLimit, enabled: true) + ) + + #expect(viewModel.isPausedAtToolLimit) + #expect(viewModel.toolLimitPauseCount == Self.testLimit) + #expect(toolUseTurnCount(viewModel) == Self.testLimit) + } + + @Test("Does not stream a round it cannot execute") + func doesNotWasteARound() async { + let transport = ScriptedTransport(rounds: Self.toolRounds(count: 20)) + let viewModel = AIChatViewModel() + + await runLoop( + viewModel: viewModel, + transport: transport, + settings: Self.makeSettings(limit: Self.testLimit, enabled: true) + ) + + #expect(transport.roundsRequested == Self.testLimit) + } + + @Test("Pausing leaves no error state and keeps the retry affordance hidden") + func pauseIsNotAFailure() async { + let transport = ScriptedTransport(rounds: Self.toolRounds(count: 20)) + let viewModel = AIChatViewModel() + + await runLoop( + viewModel: viewModel, + transport: transport, + settings: Self.makeSettings(limit: Self.testLimit, enabled: true) + ) + + #expect(viewModel.isPausedAtToolLimit) + #expect(viewModel.errorMessage == nil) + #expect(viewModel.lastMessageFailed == false) + #expect(viewModel.isStreaming == false) + } + + @Test("Pause preserves the transcript and ends on a tool result turn") + func pausePreservesTranscript() async { + let transport = ScriptedTransport(rounds: Self.toolRounds(count: 20)) + let viewModel = AIChatViewModel() + + await runLoop( + viewModel: viewModel, + transport: transport, + settings: Self.makeSettings(limit: Self.testLimit, enabled: true) + ) + + #expect(viewModel.isPausedAtToolLimit) + #expect(viewModel.messages.last?.role == .user) + let trailingBlocks = viewModel.messages.last?.blocks ?? [] + #expect(!trailingBlocks.isEmpty) + #expect(trailingBlocks.allSatisfy { block in + if case .toolResult = block.kind { return true } + return false + }) + #expect(viewModel.messages.allSatisfy { !$0.blocks.isEmpty }) + } + + @Test("No limit runs until the model stops calling tools") + func unlimitedRunsToCompletion() async { + let transport = ScriptedTransport(rounds: Self.toolRounds(count: 3) + [[.textDelta("all done")]]) + let viewModel = AIChatViewModel() + + await runLoop( + viewModel: viewModel, + transport: transport, + settings: Self.makeSettings(limit: Self.testLimit, enabled: false) + ) + + #expect(viewModel.isPausedAtToolLimit == false) + #expect(toolUseTurnCount(viewModel) == 3) + #expect(viewModel.messages.last?.plainText.contains("all done") == true) + } + + @Test("Continue resumes from the pause with a fresh budget") + func continueResumesFromPause() async { + let transport = ScriptedTransport(rounds: Self.toolRounds(count: 20)) + let viewModel = AIChatViewModel() + + await runLoop( + viewModel: viewModel, + transport: transport, + settings: Self.makeSettings(limit: Self.testLimit, enabled: true) + ) + #expect(viewModel.isPausedAtToolLimit) + let pausedMessageCount = viewModel.messages.count + + let resumeTransport = ScriptedTransport(rounds: [[.textDelta("finished")]]) + await runLoop( + viewModel: viewModel, + transport: resumeTransport, + settings: Self.makeSettings(limit: Self.testLimit, enabled: true) + ) + + #expect(viewModel.isPausedAtToolLimit == false) + #expect(viewModel.messages.count > pausedMessageCount) + #expect(viewModel.messages.last?.plainText.contains("finished") == true) + } + + @Test("continueToolLoop only acts while paused") + func continueIsInertWhenNotPaused() { + let viewModel = AIChatViewModel() + viewModel.streamingState = .idle + + viewModel.continueToolLoop() + + #expect(viewModel.messages.isEmpty) + #expect(viewModel.errorMessage == nil) + } + + @Test("Restoring the last conversation never replaces messages already in the panel") + func conversationRestoreDoesNotClobberInFlightMessages() async { + let viewModel = AIChatViewModel() + viewModel.messages.append(ChatTurn(role: .user, blocks: [.text("typed first")])) + + for _ in 0..<10 { + await Task.yield() + } + + #expect(viewModel.messages.count == 1) + #expect(viewModel.messages.first?.plainText == "typed first") + } +} diff --git a/docs/customization/settings.mdx b/docs/customization/settings.mdx index 81adbd058..23b58a2aa 100644 --- a/docs/customization/settings.mdx +++ b/docs/customization/settings.mdx @@ -116,6 +116,15 @@ API keys are stored in the macOS Keychain; removing a provider deletes its key. | **Enable inline suggestions while typing** | Off | Ghost-text SQL completions. Requires an active provider. Tab accepts, Escape dismisses | | **Debounce** | 500 ms | Pause after typing before requesting a suggestion, 100 to 3,000 ms in 50 ms steps | +### Agent + +| Setting | Default | Description | +|---------|---------|-------------| +| **Limit tool calls per reply** | On | Turn off to let the agent keep calling tools until it finishes | +| **Tool call limit** | 25 | Tool calls the AI may make in one reply before pausing, 5 to 200 in steps of 5 | + +Reaching the limit pauses the reply instead of failing it. Everything the AI found so far stays in the chat, and **Continue** picks up where it stopped with a fresh budget. GitHub Copilot manages its own tool loop, so this setting does not apply to it. + ### Context | Setting | Default | Description | diff --git a/docs/features/ai-assistant.mdx b/docs/features/ai-assistant.mdx index 1f70b5a2b..2e582102f 100644 --- a/docs/features/ai-assistant.mdx +++ b/docs/features/ai-assistant.mdx @@ -38,7 +38,7 @@ Open the inspector (`Cmd+Option+I` or the toolbar inspector button) and switch t AI chat panel -Type a question and press Return. Code blocks have **Copy** and **Insert** buttons; Insert fills the current query tab if it is empty, otherwise opens a new tab. Token counts show below each response. Failed responses show **Retry**, successful ones **Regenerate**, and **Stop** cancels a streaming reply. +Type a question and press Return. Code blocks have **Copy** and **Insert** buttons; Insert fills the current query tab if it is empty, otherwise opens a new tab. Token counts show below each response. Failed responses show **Retry**, successful ones **Regenerate**, a reply paused at the tool call limit shows **Continue**, and **Stop** cancels a streaming reply. Conversations auto-save and auto-title from your first message. The clock icon in the inspector header opens recent conversations; the pencil-and-square icon starts a new one. Editing a previously sent message puts its text and context attachments back in the composer and removes that turn and everything after it; send to re-run. @@ -52,7 +52,7 @@ The mode picker in the composer footer controls which tools the AI can call. The |------|----------------|-------------| | **Ask** | Read-only schema lookups: `list_connections`, `get_connection_status`, `list_databases`, `list_schemas`, `list_tables`, `describe_table`, `get_table_ddl`. | Questions, exploration, drafting queries you run yourself. | | **Edit** | All Ask tools, plus `execute_query` for `SELECT`, `INSERT`, `UPDATE`, `DELETE`. Destructive DDL (`DROP`, `TRUNCATE`, `ALTER...DROP`) stays blocked. | Letting the AI run the queries it proposes. | -| **Agent** | All tools, plus `confirm_destructive_operation` for destructive DDL. Runs tools in a loop, up to 10 round trips per turn. | Multi-step migrations and schema changes. | +| **Agent** | All tools, plus `confirm_destructive_operation` for destructive DDL. Runs tools in a loop, up to 25 tool calls per turn by default. | Multi-step migrations and schema changes. | Mode and [safe mode](/features/safe-mode) are independent gates. Agent mode does not bypass safe mode. @@ -73,6 +73,12 @@ If the connection's safe-mode level is **Silent**, write tools auto-approve. If Provider support for tool calling: every provider except Cursor. Ollama, llama.cpp, MLX, and custom endpoints depend on the model. +#### Tool Call Limit + +A reply may make 25 tool calls before it pauses. The pause keeps every tool call and result already in the reply, and shows **Continue** to carry on with a fresh budget, plus **Adjust Limit** to open the setting. Change the number, or turn the limit off, under **Agent** in [Settings > AI](/customization/settings#agent). A higher limit costs more tokens per reply and can dilute the answer as the conversation grows. + +GitHub Copilot runs its own tool loop, so the limit and the pause do not apply to it. + ### Attach Context with `@` Type `@` in the composer to open a picker at the caret: **Schema**, a specific **Table**, **Current Query**, **Query Results**, or a **Saved Query** ([favorites](/features/favorites)). Up/Down navigates, Return or Tab inserts, Escape dismisses. The `@` button in the composer footer offers the same items minus saved queries.