Add host-owned chat creation, model discovery, messaging, reading, and waiting with durable delivery receipts. Keep tool presentation compact, mark agent-authored messages, and prevent edits that would remove tool activity. Adopt the signed AgentKit 0.16.0 release in the package requirement, resolved revision, and submodule.
383 lines
16 KiB
Swift
383 lines
16 KiB
Swift
import AgentKit
|
|
import Foundation
|
|
import Logging
|
|
import Observation
|
|
import TermindApplication
|
|
import TermindDomain
|
|
import TermindRemoteInfrastructure
|
|
|
|
@MainActor
|
|
@Observable
|
|
private final class MobileAssistantSuspensionGate {
|
|
var isSuspending = false
|
|
}
|
|
|
|
/// Application-owned Assistant runs and services. Scene-local navigation lives in
|
|
/// `MobileAssistantSceneModel`; only the conversation store is shared here.
|
|
@MainActor
|
|
@Observable
|
|
final class MobileAssistantModel {
|
|
static let terminalSuggestionsAvailable = false
|
|
|
|
let store = AssistantConversationStore()
|
|
let speech: SpeechTranscriptionCoordinator
|
|
let configuration: AIConfigurationModel
|
|
let skills: SkillLibraryModel
|
|
private let authorize: @MainActor () -> Bool
|
|
private let repository: any AgentRunPersisting
|
|
fileprivate let modelSelections: AssistantModelSelectionController
|
|
private let operations: AssistantConversationOperations
|
|
private let chatCoordinator: AssistantChatCoordinator
|
|
var observeNotifications: ((ConversationModel) -> Void)? {
|
|
didSet { execution.observeNotifications = observeNotifications }
|
|
}
|
|
private let execution: AssistantExecutionController
|
|
private let connections: AgentConnectionPool
|
|
private let background: AgentBackgroundCommands
|
|
private let attachmentLoader: ContextAttachmentLoading
|
|
private var loadTask: Task<Void, Never>?
|
|
private var suspensionTask: Task<Void, Never>?
|
|
private let suspensionGate: MobileAssistantSuspensionGate
|
|
private var loaded = false
|
|
var isSuspending: Bool { suspensionGate.isSuspending }
|
|
var error: String?
|
|
|
|
var isRunning: Bool { store.conversations.contains { $0.isStreaming } }
|
|
|
|
func disconnectHost(_ id: UUID) {
|
|
Task { await connections.disconnect(hostID: id) }
|
|
}
|
|
|
|
func makeSceneModel() -> MobileAssistantSceneModel {
|
|
MobileAssistantSceneModel(runtime: self, library: AssistantLibraryModel(store: store))
|
|
}
|
|
|
|
init(
|
|
configuration: AIConfigurationModel, repository: any AgentRunPersisting, memory: MemoryModel? = nil,
|
|
modelSelectionStore: any AssistantModelSelectionStoring = InMemoryAssistantModelSelectionStore(),
|
|
chatCollaborationStore: any AssistantChatCollaborationStoring = InMemoryAssistantChatCollaborationStore(),
|
|
hostServices: AgentHostServices, metrics: any MetricsSnapshotReading, skills: SkillLibraryModel,
|
|
authorize: @escaping @MainActor () -> Bool,
|
|
speech: SpeechTranscriptionCoordinator,
|
|
attachmentLoader: ContextAttachmentLoading,
|
|
logger: Logger = ApplicationLogger.disabled("termind.agent")
|
|
) {
|
|
self.authorize = authorize
|
|
self.configuration = configuration
|
|
self.repository = repository
|
|
modelSelections = AssistantModelSelectionController(ai: configuration, store: modelSelectionStore)
|
|
self.skills = skills
|
|
self.speech = speech
|
|
self.attachmentLoader = attachmentLoader
|
|
operations = AssistantConversationOperations(repository: repository, modelSelections: modelSelections)
|
|
let resolver = hostServices.resolver
|
|
let connections = hostServices.connections
|
|
let authentication = hostServices.authentication
|
|
let background = hostServices.background
|
|
self.connections = connections
|
|
self.background = background
|
|
let suspensionGate = MobileAssistantSuspensionGate()
|
|
self.suspensionGate = suspensionGate
|
|
let titles = ConversationTitleController(ai: configuration)
|
|
final class CoordinatorBox {
|
|
weak var value: AssistantChatCoordinator?
|
|
}
|
|
let coordinatorBox = CoordinatorBox()
|
|
execution = AssistantExecutionController(
|
|
ai: configuration, skills: skills, repository: repository, memory: memory,
|
|
modelSelections: modelSelections,
|
|
authorize: authorize,
|
|
makeRegistry: { conversation, model, provider, catalog, channel, panes, source in
|
|
AssistantToolRegistry.make(
|
|
conversation: conversation, model: model, provider: provider,
|
|
skills: catalog, channel: channel, ai: configuration, hosts: resolver, metrics: metrics,
|
|
connections: connections, authentication: authentication, background: background,
|
|
panes: panes, chatModels: coordinatorBox.value,
|
|
chats: coordinatorBox.value, chatSource: source)
|
|
}, title: { try await titles.summarize($0) }, logger: logger)
|
|
chatCoordinator = AssistantChatCoordinator(
|
|
conversations: store, ai: configuration, modelSelections: modelSelections,
|
|
collaborationStore: chatCollaborationStore,
|
|
prepareDelivery: { [weak execution, suspensionGate] text, origin, conversation in
|
|
guard !suspensionGate.isSuspending else { return nil }
|
|
return execution?.prepareIncomingAgentMessage(text, origin: origin, in: conversation)
|
|
},
|
|
persistDelivery: { [weak execution] prepared, conversation in
|
|
guard let execution else { throw CancellationError() }
|
|
try await execution.persistPreparedAgentDelivery(prepared, in: conversation)
|
|
},
|
|
startDelivery: { [weak execution] prepared, conversation in
|
|
conversation.attachmentLoader = attachmentLoader
|
|
execution?.observeNotifications?(conversation)
|
|
execution?.startPreparedAgentDelivery(prepared, in: conversation)
|
|
},
|
|
cancelDelivery: { [weak execution] prepared, conversation in
|
|
await execution?.cancelPreparedAgentDelivery(prepared, in: conversation)
|
|
})
|
|
coordinatorBox.value = chatCoordinator
|
|
execution.reportModelSelectionError = { [weak self] message in self?.error = message }
|
|
}
|
|
|
|
func load() async {
|
|
if loaded { return }
|
|
if let loadTask {
|
|
await loadTask.value
|
|
return
|
|
}
|
|
let loadTask = Task { [weak self] in
|
|
guard let self else { return }
|
|
await loadOnce()
|
|
}
|
|
self.loadTask = loadTask
|
|
await loadTask.value
|
|
self.loadTask = nil
|
|
}
|
|
|
|
private func loadOnce() async {
|
|
do {
|
|
let restored = try await repository.loadConversations()
|
|
let selections = try modelSelections.selectionsForRestore(restored)
|
|
store.restore(restored, modelSelections: selections)
|
|
store.conversations.forEach(configure)
|
|
loaded = true
|
|
} catch {
|
|
self.error = error.localizedDescription
|
|
}
|
|
}
|
|
|
|
fileprivate func configure(_ conversation: ConversationModel) { conversation.attachmentLoader = attachmentLoader }
|
|
|
|
fileprivate func select(_ conversation: ConversationModel, in library: AssistantLibraryModel) {
|
|
speech.cancelAll()
|
|
library.select(conversation.id)
|
|
configure(conversation)
|
|
}
|
|
|
|
fileprivate func newConversation(in library: AssistantLibraryModel) {
|
|
speech.cancelAll()
|
|
_ = library.resetDraftConversation()
|
|
configure(library.draftConversation)
|
|
}
|
|
|
|
fileprivate func send(in library: AssistantLibraryModel, panes: AgentPaneAccess?) {
|
|
let conversation = library.selectedConversation ?? library.draftConversation
|
|
guard !isSuspending, !conversation.isArchived, !conversation.isHistoryChanging,
|
|
!conversation.isCompacting, conversation.composer.pendingAttachments.isEmpty, authorize()
|
|
else { return }
|
|
if conversation.isStreaming {
|
|
conversation.submitSteeringDraft(
|
|
terminalSuggestionsAvailable: Self.terminalSuggestionsAvailable)
|
|
return
|
|
}
|
|
guard conversation.canSendDraft else { return }
|
|
if library.selectedConversation == nil {
|
|
guard
|
|
execution.sendDraftConversation(
|
|
in: library,
|
|
terminalSuggestionsAvailable: Self.terminalSuggestionsAvailable, panes: panes) != nil
|
|
else { return }
|
|
configure(library.draftConversation)
|
|
} else {
|
|
execution.sendAgentDraft(
|
|
conversation, terminalSuggestionsAvailable: Self.terminalSuggestionsAvailable, panes: panes)
|
|
}
|
|
}
|
|
|
|
func suspendRecording() { speech.cancelAll() }
|
|
|
|
func expireBackground() {
|
|
guard !isSuspending else { return }
|
|
suspensionGate.isSuspending = true
|
|
execution.cancelPendingRuns()
|
|
speech.cancelAll()
|
|
let conversations = store.conversations
|
|
// Request every cancellation before awaiting a drain that may be held by persistence.
|
|
for conversation in conversations { conversation.stop() }
|
|
suspensionTask = Task {
|
|
for conversation in conversations { await conversation.stopAndWait() }
|
|
await background.closeAll()
|
|
await connections.closeAll()
|
|
suspensionGate.isSuspending = false
|
|
suspensionTask = nil
|
|
}
|
|
}
|
|
|
|
func waitForSuspension() async { await suspensionTask?.value }
|
|
|
|
func repairUnavailableModels() {
|
|
do {
|
|
try modelSelections.repairUnavailable(in: store.conversations)
|
|
} catch {
|
|
self.error = error.localizedDescription
|
|
}
|
|
}
|
|
|
|
@discardableResult
|
|
fileprivate func selectModel(_ value: AIModelRef, for conversation: ConversationModel) -> Bool {
|
|
guard let resolved = configuration.lookup(value) else { return false }
|
|
do {
|
|
try modelSelections.select(value, for: conversation)
|
|
} catch {
|
|
self.error = error.localizedDescription
|
|
return false
|
|
}
|
|
conversation.reasoningEffort = ModelCapabilityResolver.reasoning(
|
|
model: resolved.model, provider: resolved.provider
|
|
).clamp(conversation.reasoningEffort)
|
|
return true
|
|
}
|
|
|
|
fileprivate func archive(
|
|
_ conversation: ConversationModel, archived: Bool, in library: AssistantLibraryModel
|
|
) async {
|
|
await perform {
|
|
try await operations.setArchived(archived, conversation: conversation)
|
|
let selected = library.selectedConversation ?? library.draftConversation
|
|
if archived && conversation.isArchived && selected.id == conversation.id {
|
|
newConversation(in: library)
|
|
}
|
|
}
|
|
}
|
|
|
|
func rename(_ conversation: ConversationModel, title: String) async {
|
|
await perform { try await operations.rename(conversation, title: title) }
|
|
}
|
|
|
|
fileprivate func move(
|
|
_ conversation: ConversationModel, pinned: Bool, before: UUID? = nil,
|
|
in library: AssistantLibraryModel
|
|
) async {
|
|
await perform { try await operations.move(conversation, pinned: pinned, before: before, library: library) }
|
|
}
|
|
|
|
fileprivate func delete(_ conversation: ConversationModel, from library: AssistantLibraryModel) async {
|
|
guard !conversation.isStreaming, !conversation.isCompacting else { return }
|
|
await perform {
|
|
let result = try await operations.delete(conversation)
|
|
guard result.deleted else { return }
|
|
library.delete(conversation.id)
|
|
if library.selectedConversation == nil { _ = library.resetDraftConversation() }
|
|
configure(library.selectedConversation ?? library.draftConversation)
|
|
if let cleanupError = result.cleanupError { error = cleanupError }
|
|
}
|
|
}
|
|
|
|
fileprivate func fork(
|
|
_ conversation: ConversationModel, at messageID: UUID, in library: AssistantLibraryModel
|
|
) async {
|
|
await perform {
|
|
if let fork = try await operations.fork(
|
|
conversation, at: messageID, title: store.nextForkTitle(for: conversation))
|
|
{
|
|
configure(fork)
|
|
library.insertAndSelect(fork)
|
|
}
|
|
}
|
|
}
|
|
|
|
func edit(_ conversation: ConversationModel, messageID: UUID, text: String, panes: AgentPaneAccess?) {
|
|
guard !isSuspending else { return }
|
|
guard
|
|
let prepared = execution.prepareAgentEdit(
|
|
conversation, messageID: messageID, text: text,
|
|
terminalSuggestionsAvailable: Self.terminalSuggestionsAvailable, panes: panes)
|
|
else { return }
|
|
Task {
|
|
await perform {
|
|
_ = try await execution.performAgentEdit(
|
|
prepared, in: conversation, operations: operations)
|
|
}
|
|
}
|
|
}
|
|
|
|
func compact(_ conversation: ConversationModel, instructions: String = "") async {
|
|
guard !isSuspending else { return }
|
|
_ = await execution.compact(conversation, instructions: instructions)
|
|
}
|
|
|
|
fileprivate func implement(
|
|
_ card: AgentPlanCard, in conversation: ConversationModel, library: AssistantLibraryModel,
|
|
panes: AgentPaneAccess?
|
|
) {
|
|
guard !isSuspending else { return }
|
|
guard
|
|
execution.sendPlanImplementation(
|
|
card, in: conversation,
|
|
terminalSuggestionsAvailable: Self.terminalSuggestionsAvailable, panes: panes)
|
|
else { return }
|
|
select(conversation, in: library)
|
|
}
|
|
|
|
private func perform(_ operation: () async throws -> Void) async {
|
|
do { try await operation() } catch { self.error = error.localizedDescription }
|
|
}
|
|
}
|
|
|
|
/// Scene-local Assistant navigation over the app-wide conversation store and run services.
|
|
///
|
|
/// The runtime owns work that must survive navigation. The library owns only the selected
|
|
/// conversation and the New Chat draft, so two iPad windows can navigate independently
|
|
/// without duplicating a running conversation.
|
|
@MainActor
|
|
@Observable
|
|
final class MobileAssistantSceneModel {
|
|
private let runtime: MobileAssistantModel
|
|
let library: AssistantLibraryModel
|
|
var paneAccess: AgentPaneAccess?
|
|
|
|
init(runtime: MobileAssistantModel, library: AssistantLibraryModel? = nil) {
|
|
self.runtime = runtime
|
|
self.library = library ?? AssistantLibraryModel(store: runtime.store)
|
|
self.library.modelSelections = runtime.modelSelections
|
|
runtime.configure(self.library.draftConversation)
|
|
}
|
|
|
|
var conversation: ConversationModel { library.selectedConversation ?? library.draftConversation }
|
|
var speech: SpeechTranscriptionCoordinator { runtime.speech }
|
|
var configuration: AIConfigurationModel { runtime.configuration }
|
|
var skills: SkillLibraryModel { runtime.skills }
|
|
var error: String? {
|
|
get { runtime.error }
|
|
set { runtime.error = newValue }
|
|
}
|
|
|
|
func load() async { await runtime.load() }
|
|
func select(_ conversation: ConversationModel) { runtime.select(conversation, in: library) }
|
|
func newConversation() { runtime.newConversation(in: library) }
|
|
func send() { runtime.send(in: library, panes: paneAccess) }
|
|
@discardableResult
|
|
func selectModel(_ value: AIModelRef) -> Bool { runtime.selectModel(value, for: conversation) }
|
|
|
|
func archive(_ conversation: ConversationModel, archived: Bool) async {
|
|
await runtime.archive(conversation, archived: archived, in: library)
|
|
}
|
|
|
|
func rename(_ conversation: ConversationModel, title: String) async {
|
|
await runtime.rename(conversation, title: title)
|
|
}
|
|
|
|
func move(_ conversation: ConversationModel, pinned: Bool, before: UUID? = nil) async {
|
|
await runtime.move(conversation, pinned: pinned, before: before, in: library)
|
|
}
|
|
|
|
func delete(_ conversation: ConversationModel) async {
|
|
await runtime.delete(conversation, from: library)
|
|
}
|
|
|
|
func fork(_ conversation: ConversationModel, at messageID: UUID) async {
|
|
await runtime.fork(conversation, at: messageID, in: library)
|
|
}
|
|
|
|
func edit(_ conversation: ConversationModel, messageID: UUID, text: String) {
|
|
runtime.edit(conversation, messageID: messageID, text: text, panes: paneAccess)
|
|
}
|
|
|
|
func compact(_ conversation: ConversationModel, instructions: String = "") async {
|
|
await runtime.compact(conversation, instructions: instructions)
|
|
}
|
|
|
|
func implement(_ card: AgentPlanCard, in conversation: ConversationModel) {
|
|
runtime.implement(card, in: conversation, library: library, panes: paneAccess)
|
|
}
|
|
}
|