Join captured Assistant failure persistence
This commit is contained in:
1 parent
80505ccc0e
commit
7e8427d94d
4 files changed
+435
-51
No files matched your search
@@ -50,9 +50,15 @@ public final class AssistantExecutionController {
|
||||
}
|
||||
|
||||
private struct PreparationState {
|
||||
enum Phase {
|
||||
case preparing
|
||||
case savingFailure
|
||||
}
|
||||
|
||||
var id: UUID
|
||||
var conversation: ConversationModel
|
||||
var permissionMode: AgentPermissionMode
|
||||
var phase = Phase.preparing
|
||||
var task: Task<Void, Never>?
|
||||
var historyTask: Task<ConversationModel.ComposedUserTurn?, any Error>?
|
||||
var cancellationTask: Task<Void, Never>?
|
||||
@@ -60,6 +66,12 @@ public final class AssistantExecutionController {
|
||||
var cancellationRequested = false
|
||||
}
|
||||
|
||||
private enum StartResult {
|
||||
case runtimeStarted
|
||||
case failurePersistenceStarted
|
||||
case abandoned
|
||||
}
|
||||
|
||||
public struct PreparedAgentEdit {
|
||||
fileprivate var capture: RunCapture
|
||||
fileprivate var replacement: AgentTranscriptMessage
|
||||
@@ -339,10 +351,10 @@ public final class AssistantExecutionController {
|
||||
private func performStartAgentRun(
|
||||
_ conversation: ConversationModel, capture: RunCapture,
|
||||
memorySession: (context: AgentMemoryContext, access: any AgentMemoryAccessing)? = nil
|
||||
) {
|
||||
) -> StartResult {
|
||||
guard isCurrent(capture, for: conversation), !conversation.isStreaming,
|
||||
!conversation.isArchived, !conversation.isHistoryChanging
|
||||
else { return }
|
||||
else { return .abandoned }
|
||||
let operationID = UUID().uuidString.lowercased()
|
||||
let conversationID = conversation.id.uuidString.lowercased()
|
||||
guard let chat = capture.chat else {
|
||||
@@ -352,15 +364,11 @@ public final class AssistantExecutionController {
|
||||
"conversation_id": "\(conversationID)", "error_code": "missing_model",
|
||||
"operation_id": "\(operationID)", "outcome": "failure", "stage": "preparing",
|
||||
])
|
||||
recordFailedRun(
|
||||
conversation,
|
||||
promptID: capture.prompt.id, prompt: capture.prompt.text,
|
||||
authoredPrompt: capture.prompt.authoredText, images: capture.prompt.images,
|
||||
contextAttachments: capture.prompt.contextAttachments,
|
||||
permissionMode: capture.permissionMode,
|
||||
beginFailedRunPersistence(
|
||||
conversation, capture: capture,
|
||||
reason: String(localized: "No Chat model is configured.")
|
||||
)
|
||||
return
|
||||
return .failurePersistenceStarted
|
||||
}
|
||||
guard let secret = capture.secret else {
|
||||
logger.error(
|
||||
@@ -369,15 +377,11 @@ public final class AssistantExecutionController {
|
||||
"conversation_id": "\(conversationID)", "error_code": "missing_credential",
|
||||
"operation_id": "\(operationID)", "outcome": "failure", "stage": "preparing",
|
||||
])
|
||||
recordFailedRun(
|
||||
conversation,
|
||||
promptID: capture.prompt.id, prompt: capture.prompt.text,
|
||||
authoredPrompt: capture.prompt.authoredText, images: capture.prompt.images,
|
||||
contextAttachments: capture.prompt.contextAttachments,
|
||||
permissionMode: capture.permissionMode,
|
||||
beginFailedRunPersistence(
|
||||
conversation, capture: capture,
|
||||
reason: String(localized: "The Chat model has no available credential.")
|
||||
)
|
||||
return
|
||||
return .failurePersistenceStarted
|
||||
}
|
||||
|
||||
let model = AgentProviderClient(
|
||||
@@ -391,7 +395,7 @@ public final class AssistantExecutionController {
|
||||
// events had no defined order against the tool events they belong
|
||||
// between, and its durable writes had no defined order against
|
||||
// anything. See `AgentEventChannel`.
|
||||
guard let channel = capture.channel, let baseRegistry = capture.baseRegistry else { return }
|
||||
guard let channel = capture.channel, let baseRegistry = capture.baseRegistry else { return .abandoned }
|
||||
let skillCatalog = capture.skillCatalog
|
||||
let skillRegistry = AgentToolCatalog.registry(
|
||||
builtIn: .init(
|
||||
@@ -508,6 +512,7 @@ public final class AssistantExecutionController {
|
||||
"planning": "\(isPlanning)", "stage": "running", "tool_count": "\(registry.registeredTools.count)",
|
||||
])
|
||||
scheduleTitle(for: conversation, isPlanning: isPlanning, operationID: operationID)
|
||||
return .runtimeStarted
|
||||
}
|
||||
|
||||
private func canSubmit(to conversation: ConversationModel) -> Bool {
|
||||
@@ -574,6 +579,7 @@ public final class AssistantExecutionController {
|
||||
}
|
||||
if supersedePreparation {
|
||||
if let current = preparations[conversation.id] {
|
||||
guard current.phase != .savingFailure else { return nil }
|
||||
_ = requestCancellation(
|
||||
for: conversation, expectedID: current.id,
|
||||
recordsInterruption: false)
|
||||
@@ -597,8 +603,12 @@ public final class AssistantExecutionController {
|
||||
guard isCurrent(capture, for: conversation) else { return }
|
||||
let needsAsync = (compactBeforeRun && capture.shouldCompact) || capture.memorySessionID != nil
|
||||
guard needsAsync else {
|
||||
performStartAgentRun(conversation, capture: capture)
|
||||
retirePreparation(capture.id, for: conversation)
|
||||
switch performStartAgentRun(conversation, capture: capture) {
|
||||
case .runtimeStarted, .abandoned:
|
||||
retirePreparation(capture.id, for: conversation)
|
||||
case .failurePersistenceStarted:
|
||||
break
|
||||
}
|
||||
return
|
||||
}
|
||||
let task = Task { [weak self, weak conversation] in
|
||||
@@ -620,8 +630,12 @@ public final class AssistantExecutionController {
|
||||
await finishCancelledPreparation(capture, for: conversation)
|
||||
return
|
||||
}
|
||||
performStartAgentRun(conversation, capture: capture, memorySession: memorySession)
|
||||
retirePreparation(capture.id, for: conversation)
|
||||
switch performStartAgentRun(conversation, capture: capture, memorySession: memorySession) {
|
||||
case .runtimeStarted, .abandoned:
|
||||
retirePreparation(capture.id, for: conversation)
|
||||
case .failurePersistenceStarted:
|
||||
break
|
||||
}
|
||||
}
|
||||
guard var state = preparations[conversation.id], state.id == capture.id else {
|
||||
task.cancel()
|
||||
@@ -690,6 +704,9 @@ public final class AssistantExecutionController {
|
||||
guard var state = preparations[conversation.id],
|
||||
expectedID == nil || state.id == expectedID
|
||||
else { return Task {} }
|
||||
if state.phase == .savingFailure {
|
||||
return state.task ?? Task {}
|
||||
}
|
||||
state.recordsInterruption = state.recordsInterruption || recordsInterruption
|
||||
state.cancellationRequested = true
|
||||
state.task?.cancel()
|
||||
@@ -764,35 +781,43 @@ public final class AssistantExecutionController {
|
||||
retirePreparation(preparationID, for: conversation)
|
||||
}
|
||||
|
||||
private func recordFailedRun(
|
||||
_ conversation: ConversationModel,
|
||||
promptID: UUID,
|
||||
prompt: String,
|
||||
authoredPrompt: String?,
|
||||
images: [AgentImageAttachment],
|
||||
contextAttachments: [AgentContextAttachment],
|
||||
permissionMode: AgentPermissionMode,
|
||||
reason: String
|
||||
private func beginFailedRunPersistence(
|
||||
_ conversation: ConversationModel, capture: RunCapture, reason: String
|
||||
) {
|
||||
guard var state = preparations[conversation.id], state.id == capture.id else { return }
|
||||
var messages = conversation.pendingPriorMessages
|
||||
if !messages.contains(where: { $0.id == promptID }) {
|
||||
if !messages.contains(where: { $0.id == capture.prompt.id }) {
|
||||
messages.append(
|
||||
AgentTranscriptMessage(
|
||||
id: promptID, role: .user, text: prompt,
|
||||
authoredText: authoredPrompt, images: images,
|
||||
contextAttachments: contextAttachments,
|
||||
contextSnapshot: conversation.transcript.first(where: { $0.id == promptID })?.contextSnapshot
|
||||
id: capture.prompt.id, role: .user, text: capture.prompt.text,
|
||||
authoredText: capture.prompt.authoredText, images: capture.prompt.images,
|
||||
contextAttachments: capture.prompt.contextAttachments,
|
||||
contextSnapshot: capture.contextSnapshot
|
||||
))
|
||||
}
|
||||
let now = Date.now
|
||||
let snapshot = AgentRunSnapshot(
|
||||
conversationID: conversation.id, state: .failed,
|
||||
permissionMode: permissionMode,
|
||||
permissionMode: capture.permissionMode,
|
||||
messages: messages, startedAt: now, finishedAt: now,
|
||||
failure: reason
|
||||
)
|
||||
conversation.recordFailedRun(snapshot)
|
||||
Task { [agentRunRepository] in try? await agentRunRepository.save(snapshot) }
|
||||
conversation.beginAgentPreparationFailurePersistence(id: capture.id)
|
||||
state.phase = .savingFailure
|
||||
let preparationID = capture.id
|
||||
let task = Task { @MainActor [self, conversation, agentRunRepository] in
|
||||
do {
|
||||
try await agentRunRepository.save(snapshot)
|
||||
guard preparations[conversation.id]?.id == preparationID else { return }
|
||||
conversation.recordFailedRun(snapshot)
|
||||
} catch {
|
||||
guard preparations[conversation.id]?.id == preparationID else { return }
|
||||
conversation.messages.append(ChatMessage(role: .assistant, text: error.localizedDescription))
|
||||
}
|
||||
retirePreparation(preparationID, for: conversation)
|
||||
}
|
||||
state.task = task
|
||||
preparations[conversation.id] = state
|
||||
}
|
||||
|
||||
public func contextSnapshot(
|
||||
|
||||
@@ -884,6 +884,11 @@ public final class ConversationModel: Identifiable {
|
||||
/// there is no runtime to accept steering until preparation completes.
|
||||
public private(set) var isPreparingAgentRun = false
|
||||
|
||||
/// True only after a captured configuration failure has committed to its
|
||||
/// terminal snapshot save. Ordinary prepared edits remain supersedable,
|
||||
/// while history mutations must not overtake this durable boundary.
|
||||
public private(set) var isPersistingAgentPreparationFailure = false
|
||||
|
||||
public var isAgentRunActive: Bool { isStreaming || isPreparingAgentRun }
|
||||
|
||||
/// The current task list, whole. Replaced rather than patched — see
|
||||
@@ -1081,12 +1086,16 @@ public final class ConversationModel: Identifiable {
|
||||
}
|
||||
|
||||
public func canEditUserMessage(_ id: UUID) -> Bool {
|
||||
guard !isStreaming, !isCompacting, !isArchived, !isHistoryChanging else { return false }
|
||||
guard !isStreaming, !isCompacting, !isArchived, !isHistoryChanging,
|
||||
!isPersistingAgentPreparationFailure
|
||||
else { return false }
|
||||
return transcript.contains { $0.id == id && $0.role == .user && !$0.isCompaction }
|
||||
}
|
||||
|
||||
public func canForkAssistantMessage(_ id: UUID) -> Bool {
|
||||
guard !isStreaming, !isCompacting, !isArchived, !isHistoryChanging else { return false }
|
||||
guard !isStreaming, !isCompacting, !isArchived, !isHistoryChanging,
|
||||
!isPersistingAgentPreparationFailure
|
||||
else { return false }
|
||||
return transcript.contains { $0.id == id && $0.role == .assistant }
|
||||
}
|
||||
|
||||
@@ -2032,6 +2041,12 @@ public final class ConversationModel: Identifiable {
|
||||
agentPreparationID = nil
|
||||
cancelAgentPreparation = nil
|
||||
isPreparingAgentRun = false
|
||||
isPersistingAgentPreparationFailure = false
|
||||
}
|
||||
|
||||
func beginAgentPreparationFailurePersistence(id: UUID) {
|
||||
guard agentPreparationID == id else { return }
|
||||
isPersistingAgentPreparationFailure = true
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
|
||||
Reference in new issue
Block a user