Split command execution into run, output, stdin, cancel and list tools for the assistant and MCP. Preserve bounded output tails and use conversation-scoped handles and secret storage with AgentKit 1.20.0. Add the built-in ssh-exec skill with timestamped log redirection and text/secret stdin workflows. Expand long tool output without adjacent copy controls, update localized presentation, and refresh regression coverage and the website tool name. BREAKING CHANGE: Replace ssh_exec and ssh_wait with ssh_exec_run and ssh_exec_output. Move stdin to ssh_exec_stdin and remove execution timeout and cwd arguments. Use run_in_background to control foreground execution.
163 lines
6.7 KiB
Swift
163 lines
6.7 KiB
Swift
import AgentKit
|
|
import Foundation
|
|
|
|
/// Durable conversation mutations commit before changing observable state.
|
|
@MainActor
|
|
public final class AssistantConversationOperations {
|
|
private let repository: any AgentRunPersisting
|
|
private let modelSelections: AssistantModelSelectionController?
|
|
private var pending = Set<UUID>()
|
|
|
|
public init(
|
|
repository: any AgentRunPersisting,
|
|
modelSelections: AssistantModelSelectionController? = nil
|
|
) {
|
|
self.repository = repository
|
|
self.modelSelections = modelSelections
|
|
}
|
|
|
|
public func setArchived(_ value: Bool, conversation: ConversationModel) async throws {
|
|
await conversation.cancelPendingAgentPreparationAndWait()
|
|
guard !conversation.isStreaming, !conversation.isCompacting,
|
|
pending.insert(conversation.id).inserted
|
|
else { return }
|
|
conversation.isHistoryChanging = true
|
|
defer {
|
|
pending.remove(conversation.id)
|
|
conversation.isHistoryChanging = false
|
|
}
|
|
var metadata = conversation.metadata
|
|
metadata.archivedAt = value ? .now : nil
|
|
if value { metadata.isPinned = false }
|
|
try await repository.setConversationMetadata(metadata, conversationID: conversation.id)
|
|
conversation.archivedAt = metadata.archivedAt
|
|
conversation.isPinned = metadata.isPinned
|
|
}
|
|
|
|
public func rename(_ conversation: ConversationModel, title: String) async throws {
|
|
let title = String(title.trimmingCharacters(in: .whitespacesAndNewlines).prefix(80))
|
|
guard !title.isEmpty, pending.insert(conversation.id).inserted else { return }
|
|
conversation.isHistoryChanging = true
|
|
defer {
|
|
pending.remove(conversation.id)
|
|
conversation.isHistoryChanging = false
|
|
}
|
|
var metadata = conversation.metadata
|
|
metadata.customTitle = title
|
|
try await repository.setConversationMetadata(metadata, conversationID: conversation.id)
|
|
conversation.customTitle = title
|
|
}
|
|
|
|
public func move(
|
|
_ conversation: ConversationModel, pinned: Bool, before nextID: UUID?, library: AssistantLibraryModel
|
|
) async throws {
|
|
guard !conversation.isArchived,
|
|
pending.insert(conversation.id).inserted
|
|
else { return }
|
|
conversation.isHistoryChanging = true
|
|
defer {
|
|
pending.remove(conversation.id)
|
|
conversation.isHistoryChanging = false
|
|
}
|
|
guard let updates = library.orderUpdates(moving: conversation.id, toPinned: pinned, before: nextID) else {
|
|
return
|
|
}
|
|
try await repository.reorderConversations(updates)
|
|
library.applyOrder(updates)
|
|
}
|
|
|
|
@discardableResult
|
|
public func delete(_ conversation: ConversationModel) async throws -> (deleted: Bool, cleanupError: String?) {
|
|
await conversation.cancelPendingAgentPreparationAndWait()
|
|
guard !conversation.isStreaming, !conversation.isCompacting,
|
|
pending.insert(conversation.id).inserted
|
|
else { return (false, nil) }
|
|
conversation.isHistoryChanging = true
|
|
defer {
|
|
pending.remove(conversation.id)
|
|
conversation.isHistoryChanging = false
|
|
}
|
|
try await repository.deleteConversation(conversation.id)
|
|
await conversation.secretBroker.discardAll()
|
|
do {
|
|
try await AgentScratchWorkspace(
|
|
conversationID: conversation.id,
|
|
applicationName: TermindAgentPolicy.scratchDirectoryName
|
|
).removeScope()
|
|
return (true, nil)
|
|
} catch { return (true, error.localizedDescription) }
|
|
}
|
|
|
|
public func fork(_ source: ConversationModel, at messageID: UUID, title: String) async throws -> ConversationModel?
|
|
{
|
|
guard let fork = source.forked(throughAssistantMessage: messageID, title: title),
|
|
pending.insert(source.id).inserted
|
|
else { return nil }
|
|
source.isHistoryChanging = true
|
|
defer {
|
|
pending.remove(source.id)
|
|
source.isHistoryChanging = false
|
|
}
|
|
let stored = StoredAgentConversation(
|
|
id: fork.id, messages: fork.transcript, modelTranscript: fork.transcript,
|
|
compactions: fork.compactionRecords, messageRunIDs: fork.messageRunIDs,
|
|
runs: Array(fork.runSummaries.values),
|
|
toolCards: fork.toolCards.map {
|
|
StoredAgentToolCard(
|
|
invocation: $0.invocation, descriptor: $0.descriptor,
|
|
state: $0.state.rawValue, result: $0.result, review: $0.review)
|
|
}, lastRunState: .completed, title: "", metadata: fork.metadata)
|
|
do {
|
|
try await Task.detached(priority: .utility) {
|
|
try AgentScratchWorkspace.copyScope(
|
|
from: source.id, to: fork.id,
|
|
applicationName: TermindAgentPolicy.scratchDirectoryName)
|
|
}.value
|
|
try await repository.createConversation(stored)
|
|
do {
|
|
try modelSelections?.persistFork(fork)
|
|
} catch {
|
|
try? await repository.deleteConversation(fork.id)
|
|
throw error
|
|
}
|
|
return fork
|
|
} catch {
|
|
try? await AgentScratchWorkspace(
|
|
conversationID: fork.id, applicationName: TermindAgentPolicy.scratchDirectoryName
|
|
).removeScope()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
public func edit(
|
|
_ conversation: ConversationModel, messageID: UUID, text: String,
|
|
contextSnapshot: AgentTurnContextSnapshot? = nil
|
|
) async throws -> ConversationModel
|
|
.ComposedUserTurn?
|
|
{
|
|
guard var replacement = conversation.editedUserMessage(messageID, authoredText: text) else { return nil }
|
|
replacement.contextSnapshot = contextSnapshot
|
|
return try await edit(conversation, messageID: messageID, replacement: replacement)
|
|
}
|
|
|
|
public func edit(
|
|
_ conversation: ConversationModel, messageID: UUID,
|
|
replacement: AgentTranscriptMessage
|
|
) async throws -> ConversationModel.ComposedUserTurn? {
|
|
try Task.checkCancellation()
|
|
guard conversation.canApplyUserMessageEdit(messageID, replacement: replacement),
|
|
pending.insert(conversation.id).inserted
|
|
else { return nil }
|
|
conversation.isHistoryChanging = true
|
|
defer {
|
|
pending.remove(conversation.id)
|
|
conversation.isHistoryChanging = false
|
|
}
|
|
try Task.checkCancellation()
|
|
try await repository.truncateConversation(
|
|
conversation.id, fromUserMessageID: messageID, replacingWith: replacement)
|
|
conversation.isHistoryChanging = false
|
|
return conversation.applyPersistedUserMessageEdit(messageID, replacement: replacement)
|
|
}
|
|
}
|