Add Model retry
This commit is contained in:
1 parent
11b963008e
commit
04b676bf06
15 files changed
+363
-37
No files matched your search
@@ -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" : {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user