From 04b676bf06de458b48fb055479ee3f7f2e92abed Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Tue, 15 Sep 2026 09:21:25 +0800 Subject: [PATCH] Add Model retry --- Frameworks/Resources/Localizable.xcstrings | 32 +++++ .../Agent/AssistantExecutionController.swift | 56 +++++++-- .../Agent/ConversationModel.swift | 109 +++++++++++++++++- .../SwiftDataAgentRunRepository.swift | 5 +- .../AssistantTranscriptEntry.swift | 4 + ...AssistantTranscriptPresentationState.swift | 3 +- Memory/ARCHITECTURE.md | 8 ++ .../AI/MobileAssistantConversation.swift | 44 ++++++- .../AI/MobileAssistantTranscriptStyle.swift | 10 +- TFI/Features/AI/MobileAssistantWorkRow.swift | 23 +++- TFM/Features/AI/TranscriptListView.swift | 9 ++ TFM/Features/AI/TranscriptNativeRows.swift | 90 +++++++++++++-- TFMTests/ArchitectureBoundaryTests.swift | 1 + Termind.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 4 +- 15 files changed, 363 insertions(+), 37 deletions(-) diff --git a/Frameworks/Resources/Localizable.xcstrings b/Frameworks/Resources/Localizable.xcstrings index dcb6fb57..00893fb4 100644 --- a/Frameworks/Resources/Localizable.xcstrings +++ b/Frameworks/Resources/Localizable.xcstrings @@ -3101,6 +3101,22 @@ } } }, + "Agent error" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Agent错误" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "Agent錯誤" + } + } + } + }, "Archive" : { "localizations" : { "zh-Hans" : { @@ -17838,6 +17854,22 @@ } } }, + "Reconnecting… %lld/%lld" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在重新连接…%1$lld/%2$lld" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在重新連線…%1$lld/%2$lld" + } + } + } + }, "Reconnecting · attempt %lld" : { "extractionState" : "stale", "localizations" : { diff --git a/Frameworks/TermindApplication/Agent/AssistantExecutionController.swift b/Frameworks/TermindApplication/Agent/AssistantExecutionController.swift index b8870a5c..db9f9006 100644 --- a/Frameworks/TermindApplication/Agent/AssistantExecutionController.swift +++ b/Frameworks/TermindApplication/Agent/AssistantExecutionController.swift @@ -115,17 +115,27 @@ public final class AssistantExecutionController { guard !userPrompt.isEmpty || !userImages.isEmpty || !userContextAttachments.isEmpty else { return } guard let chat = ai.resolve(conversation: conversation.model) else { - conversation.messages.append( - ChatMessage( - role: .assistant, text: String(localized: "No Chat model is configured.") - )) + recordFailedRun( + conversation, + promptID: promptID ?? lastUserTurn?.id ?? UUID(), + prompt: userPrompt, + authoredPrompt: authoredPrompt ?? lastUserTurn?.authoredText, + images: userImages, + contextAttachments: userContextAttachments, + reason: String(localized: "No Chat model is configured.") + ) return } guard let secret = ai.secret(for: chat.provider.effectiveCredentialRef), !secret.isEmpty else { - conversation.messages.append( - ChatMessage( - role: .assistant, text: String(localized: "The Chat model has no available credential.") - )) + recordFailedRun( + conversation, + promptID: promptID ?? lastUserTurn?.id ?? UUID(), + prompt: userPrompt, + authoredPrompt: authoredPrompt ?? lastUserTurn?.authoredText, + images: userImages, + contextAttachments: userContextAttachments, + reason: String(localized: "The Chat model has no available credential.") + ) return } @@ -268,6 +278,36 @@ public final class AssistantExecutionController { scheduleTitle(for: conversation) } + private func recordFailedRun( + _ conversation: ConversationModel, + promptID: UUID, + prompt: String, + authoredPrompt: String?, + images: [AgentImageAttachment], + contextAttachments: [AgentContextAttachment], + reason: String + ) { + var messages = conversation.pendingPriorMessages + if !messages.contains(where: { $0.id == promptID }) { + messages.append( + AgentTranscriptMessage( + id: promptID, role: .user, text: prompt, + authoredText: authoredPrompt, images: images, + contextAttachments: contextAttachments, + contextSnapshot: conversation.transcript.first(where: { $0.id == promptID })?.contextSnapshot + )) + } + let now = Date.now + let snapshot = AgentRunSnapshot( + conversationID: conversation.id, state: .failed, + permissionMode: ai.resolve(conversation: conversation.permissionMode), + messages: messages, startedAt: now, finishedAt: now, + failure: reason + ) + conversation.recordFailedRun(snapshot) + Task { [agentRunRepository] in try? await agentRunRepository.save(snapshot) } + } + public func contextSnapshot( for conversation: ConversationModel, context: SessionContext, includesTerminalSuggestion: Bool ) -> AgentTurnContextSnapshot { diff --git a/Frameworks/TermindApplication/Agent/ConversationModel.swift b/Frameworks/TermindApplication/Agent/ConversationModel.swift index 9534e76e..fa9db8fb 100644 --- a/Frameworks/TermindApplication/Agent/ConversationModel.swift +++ b/Frameworks/TermindApplication/Agent/ConversationModel.swift @@ -277,6 +277,8 @@ nonisolated public struct ChatMessage: Identifiable, Hashable, Sendable { public var contextChips: [ContextChipReference] = [] public var images: [AgentImageAttachment] = [] public var commands: [SuggestedCommand] = [] + /// Provider retry state is live presentation, never conversation history. + public var retryProgress: AgentModelRetryProgress? /// True while tokens are still arriving, so the view can show a caret. public var isStreaming: Bool = false /// Orders this message against the tool cards, which are a separate list. @@ -287,7 +289,8 @@ nonisolated public struct ChatMessage: Identifiable, Hashable, Sendable { public init( id: UUID = UUID(), role: Role, text: String, reasoning: [AgentReasoningBlock] = [], contextChips: [ContextChipReference] = [], images: [AgentImageAttachment] = [], - commands: [SuggestedCommand] = [], isStreaming: Bool = false, createdAt: Date = .now + commands: [SuggestedCommand] = [], retryProgress: AgentModelRetryProgress? = nil, + isStreaming: Bool = false, createdAt: Date = .now ) { self.id = id self.role = role @@ -296,6 +299,7 @@ nonisolated public struct ChatMessage: Identifiable, Hashable, Sendable { self.contextChips = contextChips self.images = images self.commands = commands + self.retryProgress = retryProgress self.isStreaming = isStreaming self.createdAt = createdAt } @@ -374,6 +378,24 @@ nonisolated public struct PendingSteeringMessage: Identifiable, Hashable, Sendab } } +nonisolated public struct AgentRunFailureNotice: Identifiable, Hashable, Sendable { + public var runID: UUID + public var reason: String + public var createdAt: Date + + public var id: UUID { + AgentTranscriptMessage.toolResultID( + runID: runID, callID: "__termind_agent_failure_notice__" + ) + } + + public init(runID: UUID, reason: String, createdAt: Date) { + self.runID = runID + self.reason = reason + self.createdAt = createdAt + } +} + /// One row of a conversation, whichever of the two lists it came from. nonisolated public enum TimelineItem: Identifiable, Hashable, Sendable { case message(ChatMessage) @@ -383,6 +405,7 @@ nonisolated public enum TimelineItem: Identifiable, Hashable, Sendable { case plan(AgentPlanCard) case compaction(AgentCompactionMark) case compactionActivity(AgentCompactionActivity) + case failure(AgentRunFailureNotice) public var id: UUID { switch self { @@ -393,6 +416,7 @@ nonisolated public enum TimelineItem: Identifiable, Hashable, Sendable { case .plan(let card): card.id case .compaction(let mark): mark.id case .compactionActivity(let activity): activity.id + case .failure(let failure): failure.id } } @@ -407,6 +431,7 @@ nonisolated public enum TimelineItem: Identifiable, Hashable, Sendable { case .plan(let card): card.createdAt case .compaction(let mark): mark.createdAt case .compactionActivity(let activity): activity.createdAt + case .failure(let failure): failure.createdAt } } @@ -418,6 +443,7 @@ nonisolated public enum TimelineItem: Identifiable, Hashable, Sendable { switch self { case .work: -1 case .compaction, .compactionActivity: 1 + case .failure: 2 default: 0 } } @@ -498,10 +524,12 @@ nonisolated public struct AgentWorkGroup: Identifiable, Hashable, Sendable { /// The provider summary currently receiving deltas. The view scans this /// text instead of showing a second generic Working indicator outside. public var activeReasoningBlockID: AgentReasoningBlock.ID? + public var retryProgress: AgentModelRetryProgress? public init( run: AgentRunSummary, items: [AgentWorkItem], orderedAt: Date, anchorMessageID: UUID? = nil, - appearsAfterAnswer: Bool, activeReasoningBlockID: AgentReasoningBlock.ID? = nil + appearsAfterAnswer: Bool, activeReasoningBlockID: AgentReasoningBlock.ID? = nil, + retryProgress: AgentModelRetryProgress? = nil ) { self.run = run self.items = items @@ -509,10 +537,20 @@ nonisolated public struct AgentWorkGroup: Identifiable, Hashable, Sendable { self.anchorMessageID = anchorMessageID self.appearsAfterAnswer = appearsAfterAnswer self.activeReasoningBlockID = activeReasoningBlockID + self.retryProgress = retryProgress } } nonisolated public enum AgentWorkPresentation { + public static func reconnectingTitle( + _ progress: AgentModelRetryProgress, locale: Locale = .current + ) -> String { + AppLocalization.string( + "Reconnecting… \(progress.attempt)/\(progress.maximumAttempts)", + locale: locale + ) + } + public static func isExpandedByDefault( for state: AgentRunState, appearsAfterAnswer: Bool = false, isCompact _: Bool = false @@ -1303,6 +1341,7 @@ public final class ConversationModel: Identifiable { let activeReasoningBlockID = processMessages.first(where: { $0.id == activeReasoningMessageID && $0.isStreaming && $0.text.isEmpty })?.reasoning.last?.id + let retryProgress = processMessages.compactMap(\.retryProgress).last var items = reasoningItems + visibleProcess.map(AgentWorkItem.message) + cards.map(AgentWorkItem.tool) @@ -1357,7 +1396,8 @@ public final class ConversationModel: Identifiable { return AgentWorkGroup( run: run, items: items, orderedAt: orderedAt, anchorMessageID: anchorID, appearsAfterAnswer: answerAt != nil, - activeReasoningBlockID: activeReasoningBlockID + activeReasoningBlockID: activeReasoningBlockID, + retryProgress: retryProgress ) }.sorted { $0.orderedAt < $1.orderedAt } } @@ -1432,8 +1472,17 @@ public final class ConversationModel: Identifiable { } items += groups.filter { !emittedGroups.contains($0.id) }.map(TimelineItem.work) let commands = toolCards.compactMap(\.terminalSuggestion) + let failures = runSummaries.values.compactMap { run -> AgentRunFailureNotice? in + guard run.state == .failed, let reason = run.failure, !reason.isEmpty else { + return nil + } + return AgentRunFailureNotice( + runID: run.id, reason: reason, + createdAt: run.finishedAt ?? run.startedAt + ) + } guard - !commands.isEmpty || !plans.isEmpty || !compactions.isEmpty + !commands.isEmpty || !plans.isEmpty || !compactions.isEmpty || !failures.isEmpty || compactionActivity != nil else { return items @@ -1446,6 +1495,7 @@ public final class ConversationModel: Identifiable { commands.map(TimelineItem.command) + plans.map(TimelineItem.plan) + compactions.map(TimelineItem.compaction) + + failures.map(TimelineItem.failure) if let compactionActivity { extra.append(.compactionActivity(compactionActivity)) } return (items + extra).sorted { $0.orderedAt == $1.orderedAt @@ -1829,6 +1879,25 @@ public final class ConversationModel: Identifiable { } } + /// Records a request that could not construct a runtime, such as a missing + /// model or credential, using the same durable run failure UI as a provider + /// error that arrived after dispatch. + public func recordFailedRun(_ snapshot: AgentRunSnapshot) { + guard snapshot.conversationID == id, snapshot.state == .failed, + let failure = snapshot.failure + else { return } + if let prompt = snapshot.messages.last(where: { $0.role == .user }) { + messageRunIDs[prompt.id] = snapshot.id + } + runSummaries[snapshot.id] = AgentRunSummary( + id: snapshot.id, state: .failed, startedAt: snapshot.startedAt, + finishedAt: snapshot.finishedAt, failure: failure + ) + reportedRunState = .failed + updatedAt = snapshot.finishedAt ?? .now + runDidFinish?() + } + /// Retains the ordered event consumer until the cancellation snapshot is durable. public func stopAndWait() async { guard let runtime else { @@ -2316,6 +2385,7 @@ public final class ConversationModel: Identifiable { continue } messages[index].isStreaming = false + messages[index].retryProgress = nil } contextEstimateTracker.clearStreamingText() publishContextEstimateImmediately() @@ -2367,6 +2437,30 @@ public final class ConversationModel: Identifiable { createdAt: started.createdAt )) } + case .modelRetryScheduled(let progress): + activeReasoningMessageID = nil + if let index = messages.firstIndex(where: { $0.id == progress.messageID }) { + messages[index].text = "" + messages[index].reasoning = [] + messages[index].commands = [] + messages[index].retryProgress = progress + messages[index].isStreaming = true + } else { + messages.append( + ChatMessage( + id: progress.messageID, role: .assistant, text: "", + retryProgress: progress, isStreaming: true + )) + } + messageRunIDs[progress.messageID] = progress.runID + contextEstimateTracker.clearStreamingText() + publishContextEstimateImmediately() + case .modelRetryRecovered(let progress): + if let index = messages.firstIndex(where: { $0.id == progress.messageID }), + messages[index].retryProgress == progress + { + messages[index].retryProgress = nil + } case .messageDelta(let id, let text): if let activeRunID, messageRunIDs[id] != activeRunID { messageRunIDs[id] = activeRunID @@ -2412,6 +2506,7 @@ public final class ConversationModel: Identifiable { if let index = messages.firstIndex(where: { $0.id == message.id }) { messages[index].text = message.text messages[index].reasoning = message.reasoning + messages[index].retryProgress = nil messages[index].isStreaming = false } else { messages.append( @@ -2508,7 +2603,11 @@ public final class ConversationModel: Identifiable { case .tasksUpdated(let updated): tasks = updated case .failed(let reason): - messages.append(ChatMessage(role: .assistant, text: reason)) + guard let activeRunID, var run = runSummaries[activeRunID] else { break } + run.state = .failed + run.finishedAt = .now + run.failure = reason + runSummaries[activeRunID] = run } } diff --git a/Frameworks/TermindPersistence/SwiftDataAgentRunRepository.swift b/Frameworks/TermindPersistence/SwiftDataAgentRunRepository.swift index c6557404..c008f934 100644 --- a/Frameworks/TermindPersistence/SwiftDataAgentRunRepository.swift +++ b/Frameworks/TermindPersistence/SwiftDataAgentRunRepository.swift @@ -259,7 +259,7 @@ public final class SwiftDataAgentRunRepository: AgentRunPersisting, Sendable { guard let state = AgentRunState(rawValue: run.state) else { return nil } return AgentRunSummary( id: run.id, state: state, startedAt: run.startedAt, - finishedAt: run.finishedAt + finishedAt: run.finishedAt, failure: run.failure ) }, toolCards: cards, @@ -456,7 +456,8 @@ public final class SwiftDataAgentRunRepository: AgentRunPersisting, Sendable { AgentRunSnapshot( id: summary.id, conversationID: value.id, state: summary.state, permissionMode: .askForApproval, messages: [], - startedAt: summary.startedAt, finishedAt: summary.finishedAt + startedAt: summary.startedAt, finishedAt: summary.finishedAt, + failure: summary.failure ))) } let validRunIDs = Set(summaries.map(\.id)) diff --git a/Frameworks/TermindPresentation/AssistantTranscriptEntry.swift b/Frameworks/TermindPresentation/AssistantTranscriptEntry.swift index 36f538c7..3fd00388 100644 --- a/Frameworks/TermindPresentation/AssistantTranscriptEntry.swift +++ b/Frameworks/TermindPresentation/AssistantTranscriptEntry.swift @@ -14,6 +14,7 @@ nonisolated public struct AssistantTranscriptEntry: Identifiable, Hashable, Send case plan case compaction case compactionActivity + case failure case interrupted } @@ -27,6 +28,7 @@ nonisolated public struct AssistantTranscriptEntry: Identifiable, Hashable, Send case plan(AgentPlanCard) case compaction(AgentCompactionMark) case compactionActivity(AgentCompactionActivity) + case failure(AgentRunFailureNotice) case interrupted } @@ -63,6 +65,7 @@ nonisolated public struct AssistantTranscriptEntry: Identifiable, Hashable, Send case .plan(let card): .plan(card) case .compaction(let mark): .compaction(mark) case .compactionActivity(let activity): .compactionActivity(activity) + case .failure(let failure): .failure(failure) } return Self(id: item.id, content: content, hasLeadingSpacing: index > 0) } @@ -93,6 +96,7 @@ nonisolated public struct AssistantTranscriptEntry: Identifiable, Hashable, Send case .plan: .plan case .compaction: .compaction case .compactionActivity: .compactionActivity + case .failure: .failure case .interrupted: .interrupted } } diff --git a/Frameworks/TermindPresentation/AssistantTranscriptPresentationState.swift b/Frameworks/TermindPresentation/AssistantTranscriptPresentationState.swift index 7ae7acf3..c6183904 100644 --- a/Frameworks/TermindPresentation/AssistantTranscriptPresentationState.swift +++ b/Frameworks/TermindPresentation/AssistantTranscriptPresentationState.swift @@ -9,11 +9,12 @@ nonisolated public enum AssistantTranscriptDisclosureID: Hashable, Sendable { case reasoning(UUID) case plan(UUID) case compactionActivity(UUID) + case failure(UUID) fileprivate var sourceID: UUID { switch self { case .work(let id), .tool(let id), .toolBatch(let id), .reasoning(let id), .plan(let id), - .compactionActivity(let id): + .compactionActivity(let id), .failure(let id): id } } diff --git a/Memory/ARCHITECTURE.md b/Memory/ARCHITECTURE.md index 01f3b405..5bae5799 100644 --- a/Memory/ARCHITECTURE.md +++ b/Memory/ARCHITECTURE.md @@ -176,6 +176,14 @@ preflight, card creation, hooks, authorization, then execution, and OpenAI Responses/Chat Completions, Anthropic Messages, and Gemini. Models without tool capability receive no tools; only recognized chat-purpose models appear in conversation pickers, and unknown purpose values safely default to chat. +Ordinary planning and acting runs have no turn or tool-call count ceiling; they end +when the model finishes, the user stops, a hook terminates, or an error occurs. +Guardian remains separately bounded so unavailable or inconclusive review fails closed. +Each ordinary model turn retries only transient provider transport/service failures +five times at 1/2/4/8/16 seconds. A retry reuses the logical message and request, +abandons its partial presentation, and cannot replay a tool because tool dispatch begins +only after a complete model response. Retry progress is ephemeral; final run failures +remain durable and render as error rows rather than assistant-authored text. AgentKit's callable `fetch` and `scratch_fetch` tools are `.ask` because each contacts a caller-selected URL. Explicitly enabled `web_search` remains pre-approved; a provider-native search card is a post-hoc record of a search the diff --git a/TFI/Features/AI/MobileAssistantConversation.swift b/TFI/Features/AI/MobileAssistantConversation.swift index 2632a7b9..a3172caf 100644 --- a/TFI/Features/AI/MobileAssistantConversation.swift +++ b/TFI/Features/AI/MobileAssistantConversation.swift @@ -646,6 +646,8 @@ struct MobileAssistantConversation: View { timelineRow(.compaction(mark)) case .compactionActivity(let activity): timelineRow(.compactionActivity(activity)) + case .failure(let failure): + agentFailureRow(failure) case .interrupted: Label("This run was interrupted and was not resumed.", systemImage: "pause.circle") .font(.caption).foregroundStyle(.secondary) @@ -669,6 +671,8 @@ struct MobileAssistantConversation: View { case .compactionActivity(let activity): compactionActivityRow(activity) case .compaction(let mark): compactionRow(mark) + case .failure(let failure): + agentFailureRow(failure) } } @@ -736,6 +740,39 @@ struct MobileAssistantConversation: View { } } + private func agentFailureRow(_ failure: AgentRunFailureNotice) -> some View { + DisclosureGroup(isExpanded: disclosureBinding(for: .failure(failure.id))) { + HStack(alignment: .top, spacing: 12) { + Rectangle() + .fill(Color(uiColor: .separator)) + .frame(width: 1) + .accessibilityHidden(true) + Text(failure.reason) + .font(.caption.monospaced()) + .foregroundStyle(.red) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + Button("Copy", systemImage: "doc.on.doc") { + UIPasteboard.general.string = failure.reason + } + .labelStyle(.iconOnly) + .buttonStyle(.plain) + .foregroundStyle(.secondary) + } + .padding(.leading, 24) + } label: { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle") + .foregroundStyle(.red) + Text("Agent error") + .foregroundStyle(.primary) + } + .font(.callout) + } + .tint(.secondary) + .accessibilityHint(Text("Show failure details")) + } + private var imageTileSize: CGFloat { sizeClass == .compact ? 104 : 160 } private func messageRow(_ message: ChatMessage) -> some View { @@ -798,7 +835,12 @@ struct MobileAssistantConversation: View { } } if message.isStreaming && message.text.isEmpty && message.reasoning.isEmpty { - MobileAssistantActivity(cyclesText: true) + MobileAssistantActivity( + cyclesText: message.retryProgress == nil, + reconnectingTitle: message.retryProgress.map { + AgentWorkPresentation.reconnectingTitle($0, locale: locale) + } + ) } if !message.isStreaming { messageActions(message) } ForEach(message.commands) { command in MobileAssistantCommandRow(command: command) } diff --git a/TFI/Features/AI/MobileAssistantTranscriptStyle.swift b/TFI/Features/AI/MobileAssistantTranscriptStyle.swift index 2dea8b97..7743af21 100644 --- a/TFI/Features/AI/MobileAssistantTranscriptStyle.swift +++ b/TFI/Features/AI/MobileAssistantTranscriptStyle.swift @@ -80,6 +80,7 @@ struct MobileAssistantScanning: ViewModifier { struct MobileAssistantActivity: View { var cyclesText = false + var reconnectingTitle: String? = nil @State private var startedAt = Date.now @Environment(\.mobileListRowIsDisplayed) private var displayed @Environment(\.mobileListReduceMotion) private var reduceMotion @@ -87,7 +88,9 @@ struct MobileAssistantActivity: View { var body: some View { Group { - if cyclesText && displayed && !reduceMotion && sceneIsActive { + if let reconnectingTitle { + Text(reconnectingTitle) + } else if cyclesText && displayed && !reduceMotion && sceneIsActive { TimelineView(.animation(minimumInterval: AssistantTranscriptStyle.activityTextDuration)) { context in let titles: [LocalizedStringKey] = ["Working…", "Thinking…", "Reasoning…"] let index = @@ -102,7 +105,10 @@ struct MobileAssistantActivity: View { Text("Working…") } } - .foregroundStyle(.secondary) + .foregroundStyle( + reconnectingTitle == nil + ? AnyShapeStyle(.secondary) : AnyShapeStyle(.tertiary) + ) .modifier(MobileAssistantScanning(active: true)) .frame(maxWidth: .infinity, alignment: .leading) } diff --git a/TFI/Features/AI/MobileAssistantWorkRow.swift b/TFI/Features/AI/MobileAssistantWorkRow.swift index e8b77f3d..a2c62d4a 100644 --- a/TFI/Features/AI/MobileAssistantWorkRow.swift +++ b/TFI/Features/AI/MobileAssistantWorkRow.swift @@ -36,7 +36,11 @@ struct MobileAssistantWorkRow: View { .modifier(MobileAssistantScanning(active: group.activeReasoningBlockID == block.id)) case .message(let message): if message.text.isEmpty && message.isStreaming { - MobileAssistantActivity() + MobileAssistantActivity( + reconnectingTitle: group.retryProgress.map { + AgentWorkPresentation.reconnectingTitle($0, locale: locale) + } + ) } else if !message.text.isEmpty { MobileAssistantMarkdown(text: message.text) } @@ -54,11 +58,20 @@ struct MobileAssistantWorkRow: View { private func workTitle(now: Date) -> some View { Text( - AgentWorkPresentation.title( - for: group.run.state, from: group.run.startedAt, to: group.run.finishedAt ?? now, locale: locale - ) + group.retryProgress.map { + AgentWorkPresentation.reconnectingTitle($0, locale: locale) + } + ?? AgentWorkPresentation.title( + for: group.run.state, from: group.run.startedAt, + to: group.run.finishedAt ?? now, locale: locale + ) ) - .font(.callout).foregroundStyle(.secondary).monospacedDigit() + .font(.callout) + .foregroundStyle( + group.retryProgress == nil + ? AnyShapeStyle(.secondary) : AnyShapeStyle(.tertiary) + ) + .monospacedDigit() } } diff --git a/TFM/Features/AI/TranscriptListView.swift b/TFM/Features/AI/TranscriptListView.swift index 868924b6..bbdc41d1 100644 --- a/TFM/Features/AI/TranscriptListView.swift +++ b/TFM/Features/AI/TranscriptListView.swift @@ -442,6 +442,15 @@ struct TranscriptListView: NSViewRepresentable { .configure { [weak self] row, entry, context in self?.configure(row, entry: entry, width: context.width, measurement: false) } + ListRow(TranscriptFailureRow.self) + .when { $0.kind == .failure } + .estimatedHeight(48) + .height { [weak self] entry, context in + self?.height(for: entry, width: context.width) ?? 48 + } + .configure { [weak self] row, entry, context in + self?.configure(row, entry: entry, width: context.width, measurement: false) + } ListRow(TranscriptInterruptedRow.self) .when { $0.kind == .interrupted } .estimatedHeight(96) diff --git a/TFM/Features/AI/TranscriptNativeRows.swift b/TFM/Features/AI/TranscriptNativeRows.swift index 5c5d2117..991ac5bf 100644 --- a/TFM/Features/AI/TranscriptNativeRows.swift +++ b/TFM/Features/AI/TranscriptNativeRows.swift @@ -179,6 +179,7 @@ class TranscriptNativeRow: ListRowView { case .plan: TranscriptPlanRow() case .compaction: TranscriptCompactionRow() case .compactionActivity: TranscriptCompactionActivityRow() + case .failure: TranscriptFailureRow() case .interrupted: TranscriptInterruptedRow() } } @@ -370,6 +371,7 @@ class TranscriptNativeRow: ListRowView { @MainActor final class TranscriptPlanRow: TranscriptNativeRow {} @MainActor final class TranscriptCompactionRow: TranscriptNativeRow {} @MainActor final class TranscriptCompactionActivityRow: TranscriptNativeRow {} +@MainActor final class TranscriptFailureRow: TranscriptNativeRow {} @MainActor final class TranscriptInterruptedRow: TranscriptNativeRow {} @MainActor @@ -441,13 +443,21 @@ private enum NativeTranscriptFactory { stack.addArrangedSubview(markdownView) } if message.isStreaming, message.text.isEmpty { + let retry = message.retryProgress + let title = retry.map { + AgentWorkPresentation.reconnectingTitle( + $0, locale: context.configuration.locale + ) + } ?? String(localized: "Working…") let activity = NativeActivityLabel( - text: String(localized: "Working…"), - alternatives: [ - String(localized: "Working…"), - String(localized: "Thinking…"), - String(localized: "Reasoning…"), - ], + text: title, + alternatives: retry == nil + ? [ + String(localized: "Working…"), + String(localized: "Thinking…"), + String(localized: "Reasoning…"), + ] : [title], + color: retry == nil ? .secondaryLabelColor : .tertiaryLabelColor, reduceMotion: context.configuration.reduceMotion, measurement: context.measurement ) @@ -527,6 +537,8 @@ private enum NativeTranscriptFactory { return compaction(mark) case .compactionActivity(let activity): return compactionActivity(activity, context: context) + case .failure(let failure): + return failureRow(failure, context: context) } } @@ -834,7 +846,12 @@ private enum NativeTranscriptFactory { ) let expanded = context.presentation.disclosures.isExpanded(.work(group.id), default: defaultExpanded) let titleProvider = { - AgentWorkPresentation.title( + if let retry = group.retryProgress { + return AgentWorkPresentation.reconnectingTitle( + retry, locale: context.configuration.locale + ) + } + return AgentWorkPresentation.title( for: group.run.state, from: group.run.startedAt, to: group.run.finishedAt ?? .now, @@ -844,8 +861,9 @@ private enum NativeTranscriptFactory { let header = NativeDisclosureHeader( symbol: nil, title: titleProvider(), - titleColor: .secondaryLabelColor, + titleColor: group.retryProgress == nil ? .secondaryLabelColor : .tertiaryLabelColor, expanded: expanded, + active: group.retryProgress != nil, reduceMotion: context.configuration.reduceMotion, measurement: context.measurement, lineLimit: 1, @@ -877,10 +895,17 @@ private enum NativeTranscriptFactory { workItems.addArrangedSubview(reasoning) case .message(let message): if message.text.isEmpty, message.isStreaming { + let title = group.retryProgress.map { + AgentWorkPresentation.reconnectingTitle( + $0, locale: context.configuration.locale + ) + } ?? String(localized: "Working…") workItems.addArrangedSubview( NativeActivityLabel( - text: String(localized: "Working…"), - alternatives: [String(localized: "Working…")], + text: title, + alternatives: [title], + color: group.retryProgress == nil + ? .secondaryLabelColor : .tertiaryLabelColor, reduceMotion: context.configuration.reduceMotion, measurement: context.measurement )) @@ -1149,6 +1174,51 @@ private enum NativeTranscriptFactory { return stack } + private static func failureRow( + _ failure: AgentRunFailureNotice, + context: NativeTranscriptContext + ) -> NSView { + let stack = NativeStack.vertical() + let disclosureID = AssistantTranscriptDisclosureID.failure(failure.id) + let expanded = context.presentation.disclosures.isExpanded(disclosureID) + let header = NativeDisclosureHeader( + symbol: "exclamationmark.triangle", + title: String(localized: "Agent error"), + iconColor: .systemRed, + titleColor: .labelColor, + expanded: expanded, + reduceMotion: context.configuration.reduceMotion, + measurement: context.measurement, + lineLimit: 1 + ) + header.toolTip = String(localized: "Show failure details") + header.handler = { + context.presentation.disclosures.toggleDisclosure(disclosureID) + context.invalidate(context.entryID, false) + } + stack.addArrangedSubview(header) + if expanded { + let detail = NativeStack.horizontal(spacing: Metrics.Space.m) + detail.edgeInsets = .init( + top: 0, left: Metrics.Sidebar.rowIcon, bottom: 0, right: 0 + ) + detail.addArrangedSubview(NativeVerticalRule()) + detail.addArrangedSubview( + NativeLabel( + failure.reason, + font: .monospacedSystemFont( + ofSize: NSFont.smallSystemFontSize, + weight: .regular + ), + color: .systemRed, + selectable: true + )) + detail.addArrangedSubview(NativeCopyButton(text: failure.reason)) + stack.addArrangedSubview(detail) + } + return stack + } + private static func markdown( source: String, slot: String, diff --git a/TFMTests/ArchitectureBoundaryTests.swift b/TFMTests/ArchitectureBoundaryTests.swift index 09cc1b83..ea17d431 100644 --- a/TFMTests/ArchitectureBoundaryTests.swift +++ b/TFMTests/ArchitectureBoundaryTests.swift @@ -538,6 +538,7 @@ struct ArchitectureBoundaryTests { "AgentRuntime", "AgentToolExecutor", "AgentToolScheduler", "AgentTurnDriver", "AgentToolCatalog", "AgentToolRegistry", "AgentEventChannel", "AgentContextPipeline", "AgentSecretLifecycle", "AgentApprovalBroker", "SecretBroker", "AgentProviderClient", + "AgentModelRetryPolicy", "AgentModelRetryProgress", "MCPClient", "MCPServer", "ModelProvider", "AIModel", "AgentSkill", ] let declaration = try Regex( diff --git a/Termind.xcodeproj/project.pbxproj b/Termind.xcodeproj/project.pbxproj index 8edd8456..b512aec7 100644 --- a/Termind.xcodeproj/project.pbxproj +++ b/Termind.xcodeproj/project.pbxproj @@ -2595,7 +2595,7 @@ repositoryURL = "https://github.com/AkinoKaede/AgentKit.git"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 0.9.2; + minimumVersion = 0.11.0; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/Termind.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Termind.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 53b93966..db53280c 100644 --- a/Termind.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Termind.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/AkinoKaede/AgentKit.git", "state" : { - "revision" : "ad72d1fd7f868d75fe417cf58c8f31c279ca09f7", - "version" : "0.9.2" + "revision" : "8ac6e78bab3185a0a6fc9d1af1bfeb2f8db57ca9", + "version" : "0.11.0" } }, {