Files
Termind/TFI/Application/Agent/MobileAssistantModel.swift
T
Kaede Akino c1ae0dc0a2 feat(assistant): add cross-chat agent collaboration
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.
2026-09-27 19:08:29 +08:00

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)
}
}