Files
Termind/Frameworks/TermindApplication/Agent/AssistantChatCollaboration.swift

626 lines
27 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import AgentKit
import Foundation
import TermindDomain
nonisolated public struct AssistantChatCreationRelation: Hashable, Sendable {
public init(
conversationID: UUID, sourceConversationID: UUID,
sourceRunID: UUID, sourceToolCallID: String
) {
self.conversationID = conversationID
self.sourceConversationID = sourceConversationID
self.sourceRunID = sourceRunID
self.sourceToolCallID = sourceToolCallID
}
public var conversationID: UUID
public var sourceConversationID: UUID
public var sourceRunID: UUID
public var sourceToolCallID: String
}
nonisolated public struct AssistantChatDeliveryReceipt: Identifiable, Hashable, Sendable {
public init(
id: UUID = UUID(), sourceConversationID: UUID, targetConversationID: UUID,
sourceRunID: UUID, sourceToolCallID: String, messageID: UUID,
state: AgentMessageDeliveryState, createdAt: Date = .now,
updatedAt: Date = .now, failure: String? = nil
) {
self.id = id
self.sourceConversationID = sourceConversationID
self.targetConversationID = targetConversationID
self.sourceRunID = sourceRunID
self.sourceToolCallID = sourceToolCallID
self.messageID = messageID
self.state = state
self.createdAt = createdAt
self.updatedAt = updatedAt
self.failure = failure
}
public var id: UUID
public var sourceConversationID: UUID
public var targetConversationID: UUID
public var sourceRunID: UUID
public var sourceToolCallID: String
public var messageID: UUID
public var state: AgentMessageDeliveryState
public var createdAt: Date
public var updatedAt: Date
public var failure: String?
}
@MainActor public protocol AssistantChatCollaborationStoring {
/// Whether delivery becomes durable in the same transaction that persists the
/// origin message. Stores with this guarantee must not be marked delivered by
/// the earlier in-memory runtime event.
var confirmsDeliveryDuringMessagePersistence: Bool { get }
func receipt(sourceRunID: UUID, sourceToolCallID: String) throws -> AssistantChatDeliveryReceipt?
func save(_ receipt: AssistantChatDeliveryReceipt) throws
func updateReceipt(
messageID: UUID, state: AgentMessageDeliveryState, failure: String?
) throws
func receipts(targetConversationID: UUID) throws -> [AssistantChatDeliveryReceipt]
func save(_ relation: AssistantChatCreationRelation) throws
func creationRelations(
conversationIDs: Set<UUID>
) throws -> [UUID: AssistantChatCreationRelation]
func deleteRelation(conversationID: UUID) throws
}
@MainActor
public final class InMemoryAssistantChatCollaborationStore: AssistantChatCollaborationStoring {
private var storedReceipts: [UUID: AssistantChatDeliveryReceipt] = [:]
private var relations: [UUID: AssistantChatCreationRelation] = [:]
public init() {}
public var confirmsDeliveryDuringMessagePersistence: Bool { false }
public func receipt(sourceRunID: UUID, sourceToolCallID: String) throws
-> AssistantChatDeliveryReceipt?
{
storedReceipts.values.first {
$0.sourceRunID == sourceRunID && $0.sourceToolCallID == sourceToolCallID
}
}
public func save(_ receipt: AssistantChatDeliveryReceipt) throws {
if let existing = try self.receipt(
sourceRunID: receipt.sourceRunID, sourceToolCallID: receipt.sourceToolCallID)
{
guard existing.state != .delivered || receipt.state == .delivered else { return }
var updated = receipt
updated.id = existing.id
storedReceipts[existing.id] = updated
} else {
storedReceipts[receipt.id] = receipt
}
}
public func updateReceipt(
messageID: UUID, state: AgentMessageDeliveryState, failure: String?
) throws {
guard let id = storedReceipts.first(where: { $0.value.messageID == messageID })?.key,
var receipt = storedReceipts[id]
else { return }
guard receipt.state != .delivered || state == .delivered else { return }
receipt.state = state
receipt.failure = failure
receipt.updatedAt = .now
storedReceipts[id] = receipt
}
public func receipts(targetConversationID: UUID) throws -> [AssistantChatDeliveryReceipt] {
storedReceipts.values.filter { $0.targetConversationID == targetConversationID }
.sorted { $0.createdAt < $1.createdAt }
}
public func save(_ relation: AssistantChatCreationRelation) throws {
if relations[relation.conversationID] == nil { relations[relation.conversationID] = relation }
}
public func creationRelations(
conversationIDs: Set<UUID>
) throws -> [UUID: AssistantChatCreationRelation] {
relations.filter { conversationIDs.contains($0.key) }
}
public func deleteRelation(conversationID: UUID) throws {
relations[conversationID] = nil
}
}
/// Shared local-device coordination used by both native app adapters.
@MainActor
public final class AssistantChatCoordinator: AgentChatModelCatalog, AgentChatCoordinating {
private let conversations: AssistantConversationStore
private let ai: AIConfigurationModel
private let modelSelections: AssistantModelSelectionController
private let collaborationStore: any AssistantChatCollaborationStoring
private let prepareDelivery:
@MainActor (String, AgentMessageOrigin, ConversationModel) -> AssistantExecutionController
.PreparedAgentDelivery?
private let persistDelivery:
@MainActor (AssistantExecutionController.PreparedAgentDelivery, ConversationModel) async throws -> Void
private let startDelivery:
@MainActor (AssistantExecutionController.PreparedAgentDelivery, ConversationModel) -> Void
private let cancelDelivery:
@MainActor (AssistantExecutionController.PreparedAgentDelivery, ConversationModel) async -> Void
private var waiters: [UUID: (ids: Set<UUID>, continuation: AsyncStream<Void>.Continuation)] = [:]
public init(
conversations: AssistantConversationStore,
ai: AIConfigurationModel,
modelSelections: AssistantModelSelectionController,
collaborationStore: any AssistantChatCollaborationStoring,
prepareDelivery:
@escaping @MainActor (
String, AgentMessageOrigin, ConversationModel
) -> AssistantExecutionController.PreparedAgentDelivery?,
persistDelivery:
@escaping @MainActor (
AssistantExecutionController.PreparedAgentDelivery, ConversationModel
) async throws -> Void,
startDelivery:
@escaping @MainActor (
AssistantExecutionController.PreparedAgentDelivery, ConversationModel
) -> Void,
cancelDelivery:
@escaping @MainActor (
AssistantExecutionController.PreparedAgentDelivery, ConversationModel
) async -> Void
) {
self.conversations = conversations
self.ai = ai
self.modelSelections = modelSelections
self.collaborationStore = collaborationStore
self.prepareDelivery = prepareDelivery
self.persistDelivery = persistDelivery
self.startDelivery = startDelivery
self.cancelDelivery = cancelDelivery
}
public func configuredChatModels() async throws -> [AgentChatModelDescription] {
ai.enabledModelProviders.flatMap { provider in
provider.chatModels.map { model in
AgentChatModelDescription(
reference: Self.reference(
AIModelRef(modelProviderID: provider.id, modelID: model.id)),
name: model.label, provider: provider.name,
abilities: model.abilities.map(\.rawValue).sorted(),
contextWindow: model.contextLength ?? 0,
supportedReasoning: ModelCapabilityResolver.reasoning(
model: model, provider: provider
).supported.map(\.rawValue),
isReady: ai.modelSecret(for: provider) != nil
&& !provider.inferenceURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
)
}
}
}
public func createChat(
prompt: String, model: String?, title: String?, source: AgentChatSourceContext,
sourceRunID: UUID, sourceToolCallID: String
) async throws -> AgentJSONValue {
if let existing = try existingDelivery(
sourceRunID: sourceRunID, sourceToolCallID: sourceToolCallID)
{
return existing
}
let target = ConversationModel()
target.permissionMode = source.permissionMode
target.isPlanning = source.mode == .planning
target.customTitle = title?.trimmingCharacters(in: .whitespacesAndNewlines).nonempty
let selected: AIModelRef
if let model {
guard let explicit = Self.modelReference(model), ai.lookupChat(explicit) != nil else {
throw AgentToolError.invalidArguments("The requested Chat model is unavailable.")
}
selected = explicit
} else {
guard let inherited = Self.modelReference(source.modelReference), ai.lookupChat(inherited) != nil else {
throw AgentToolError.invalidArguments("The source Chat model is unavailable.")
}
selected = inherited
}
target.model = selected
try modelSelections.prepareForSend(target)
let origin = AgentMessageOrigin(
sourceConversationID: source.conversationID, sourceRunID: sourceRunID,
sourceToolCallID: sourceToolCallID, sourceTitle: source.title)
guard let prepared = prepareDelivery(prompt, origin, target) else {
throw AgentToolError.invalidArguments("The new chat could not be prepared.")
}
let receipt = AssistantChatDeliveryReceipt(
sourceConversationID: source.conversationID, targetConversationID: target.id,
sourceRunID: sourceRunID, sourceToolCallID: sourceToolCallID,
messageID: prepared.message.id, state: .queued)
do {
try collaborationStore.save(
AssistantChatCreationRelation(
conversationID: target.id, sourceConversationID: source.conversationID,
sourceRunID: sourceRunID, sourceToolCallID: sourceToolCallID))
try collaborationStore.save(receipt)
try await persistDelivery(prepared, target)
} catch {
await cancelDelivery(prepared, target)
try? collaborationStore.updateReceipt(
messageID: prepared.message.id, state: .undelivered,
failure: error.localizedDescription)
try? collaborationStore.deleteRelation(conversationID: target.id)
throw error
}
try? collaborationStore.updateReceipt(
messageID: prepared.message.id, state: .delivered, failure: nil)
configure(target)
conversations.insert(target)
startDelivery(prepared, target)
return chatJSON(target, status: "accepted", receiptID: receipt.id)
}
public func listChats(cursor: String?, includeArchived: Bool) async throws -> AgentJSONValue {
attachAll()
let offset = try Self.offset(cursor)
let candidates = conversations.conversations
.filter { includeArchived || !$0.isArchived }
.sorted { $0.updatedAt > $1.updatedAt }
guard offset <= candidates.count else {
throw AgentToolError.invalidArguments("The chat cursor is invalid.")
}
let page = Array(candidates.dropFirst(offset).prefix(20))
let relations = try collaborationStore.creationRelations(
conversationIDs: Set(page.map(\.id)))
var result: [String: AgentJSONValue] = [
"chats": .array(
page.map { conversation in
listChatJSON(
conversation,
relation: relations[conversation.id])
}),
"count": .number(Double(page.count)),
]
let next = offset + page.count
if next < candidates.count { result["next_cursor"] = .string(String(next)) }
return .object(result)
}
public func readChat(id: UUID, cursor: String?) async throws -> AgentJSONValue {
guard let conversation = conversations.conversation(id: id) else {
throw AgentToolError.invalidArguments("The chat does not exist.")
}
configure(conversation)
let parts = cursor?.split(separator: ":", omittingEmptySubsequences: false).map(String.init) ?? []
guard parts.count <= 2 else { throw AgentToolError.invalidArguments("The chat cursor is invalid.") }
let offset = try Self.offset(parts.first)
let lineOffset = try parts.count == 2 ? Self.offset(parts[1]) : 1
guard lineOffset > 0 else { throw AgentToolError.invalidArguments("The chat cursor is invalid.") }
let visible = conversation.messages
guard offset <= visible.count else {
throw AgentToolError.invalidArguments("The chat cursor is invalid.")
}
let page = Array(visible.dropFirst(offset).prefix(20))
if lineOffset > 1 {
guard let first = page.first, lineOffset <= AgentTruncateResult.logicalLines(first.text).count else {
throw AgentToolError.invalidArguments("The chat cursor is invalid.")
}
}
let messages = page.enumerated().map { index, message -> AgentJSONValue in
let start = index == 0 ? lineOffset : 1
let text =
start == 1 ? message.text : AgentTruncateResult.logicalLines(message.text).dropFirst(start - 1).joined()
return .object([
"role": .string(message.role == .user ? "user" : "assistant"),
"text": .string(text),
"line_offset": .number(Double(start)),
"agent_origin": .bool(message.origin != nil),
])
}
let toolRows = conversation.toolCards.suffix(20).map { card in
AgentJSONValue.object([
"name": .string(card.descriptor.name),
"state": .string(card.state.rawValue),
])
}
let receipts = (try? collaborationStore.receipts(targetConversationID: id)) ?? []
var result = chatObject(conversation)
result["messages"] = .array(messages)
result["message_count"] = .number(Double(page.count))
result["message_offset"] = .number(Double(offset))
result["tool_summaries"] = .array(toolRows)
result["deliveries"] = .array(receipts.suffix(20).map(receiptJSON))
result["truncated"] = .bool(false)
let next = offset + page.count
if next < visible.count { result["next_cursor"] = .string(String(next)) }
return .object(result)
}
public func sendToChat(
id: UUID, message: String, source: AgentChatSourceContext,
sourceRunID: UUID, sourceToolCallID: String
) async throws -> AgentJSONValue {
if let existing = try existingDelivery(
sourceRunID: sourceRunID, sourceToolCallID: sourceToolCallID)
{
return existing
}
guard id != source.conversationID else {
throw AgentToolError.invalidArguments("A chat cannot send a message to itself.")
}
guard let target = conversations.conversation(id: id), !target.isArchived else {
throw AgentToolError.invalidArguments("The target chat is unavailable.")
}
guard !target.isCompacting, !target.isHistoryChanging, !target.isPreparingAgentRun else {
throw AgentToolError.invalidArguments("The target chat is busy changing history.")
}
if source.mode == .planning, !target.effectiveIsPlanning {
throw AgentToolError.invalidArguments("Plan mode can send only to another chat in Plan mode.")
}
configure(target)
let origin = AgentMessageOrigin(
sourceConversationID: source.conversationID, sourceRunID: sourceRunID,
sourceToolCallID: sourceToolCallID, sourceTitle: source.title)
let incoming = AgentTranscriptMessage(
role: .user, text: message, authoredText: nil, origin: origin)
if target.isStreaming {
var receipt = AssistantChatDeliveryReceipt(
sourceConversationID: source.conversationID, targetConversationID: target.id,
sourceRunID: sourceRunID, sourceToolCallID: sourceToolCallID,
messageID: incoming.id, state: .queued)
try collaborationStore.save(receipt)
guard target.submitIncomingAgentSteering(incoming) else {
receipt.state = .undelivered
receipt.failure = "The target run ended before delivery."
receipt.updatedAt = .now
try collaborationStore.save(receipt)
return receiptJSON(receipt)
}
return chatJSON(target, status: "queued", receiptID: receipt.id)
}
guard let prepared = prepareDelivery(message, origin, target) else {
throw AgentToolError.invalidArguments("The target chat could not accept the message.")
}
let receipt = AssistantChatDeliveryReceipt(
sourceConversationID: source.conversationID, targetConversationID: target.id,
sourceRunID: sourceRunID, sourceToolCallID: sourceToolCallID,
messageID: prepared.message.id, state: .queued)
do {
try collaborationStore.save(receipt)
try await persistDelivery(prepared, target)
} catch {
await cancelDelivery(prepared, target)
try? collaborationStore.updateReceipt(
messageID: prepared.message.id, state: .undelivered,
failure: error.localizedDescription)
throw error
}
try? collaborationStore.updateReceipt(
messageID: prepared.message.id, state: .delivered, failure: nil)
startDelivery(prepared, target)
return chatJSON(target, status: "delivered", receiptID: receipt.id)
}
public func waitForChats(
ids: [UUID], timeoutMilliseconds: Int, source: AgentChatSourceContext,
untilUserInterjects: @escaping @concurrent @Sendable () async -> Void
) async throws -> AgentJSONValue {
attachAll()
let missing = ids.filter { conversations.conversation(id: $0) == nil }
guard missing.isEmpty else {
throw AgentToolError.invalidArguments(
"One or more target chats no longer exist: \(missing.map(\.uuidString).joined(separator: ", ")).")
}
let ready = readyChats(ids)
if !ready.isEmpty || timeoutMilliseconds == 0 {
return waitJSON(ids: ids, timedOut: ready.isEmpty)
}
let stream = makeWaitStream(ids: Set(ids))
let outcome = await withTaskGroup(of: Int.self, returning: Int.self) { group in
group.addTask {
for await _ in stream { return 0 }
return 0
}
group.addTask {
try? await Task.sleep(for: .milliseconds(timeoutMilliseconds))
return 1
}
group.addTask {
await untilUserInterjects()
return 2
}
let first = await group.next() ?? 1
group.cancelAll()
return first
}
return waitJSON(ids: ids, timedOut: outcome == 1, interrupted: outcome == 2)
}
private func configure(_ conversation: ConversationModel) {
let conversationID = conversation.id
conversation.chatStateDidChange = { [weak self] id in self?.signal(id) }
conversation.agentMessageDeliveryChanged = { [weak self] message, state in
guard let self else { return }
if state != .delivered || !self.collaborationStore.confirmsDeliveryDuringMessagePersistence {
try? self.collaborationStore.updateReceipt(
messageID: message.id, state: state,
failure: state == .undelivered ? "The target run ended before delivery." : nil)
}
self.signal(conversationID)
}
reconcileReceipts(for: conversation)
}
private func attachAll() { conversations.conversations.forEach(configure) }
private func signal(_ id: UUID) {
guard let conversation = conversations.conversation(id: id), isReady(conversation) else { return }
for (_, waiter) in waiters where waiter.ids.contains(id) {
waiter.continuation.yield()
waiter.continuation.finish()
}
}
private func makeWaitStream(ids: Set<UUID>) -> AsyncStream<Void> {
let id = UUID()
return AsyncStream { continuation in
waiters[id] = (ids, continuation)
continuation.onTermination = { [weak self] _ in
Task { @MainActor in self?.waiters[id] = nil }
}
}
}
private func readyChats(_ ids: [UUID]) -> [ConversationModel] {
ids.compactMap { conversations.conversation(id: $0) }.filter(isReady)
}
private func isReady(_ conversation: ConversationModel) -> Bool {
!conversation.isPreparingAgentRun
&& (conversation.runState.isTerminal || conversation.runState == .waitingForUser
|| conversation.runState == .waitingForApproval)
}
private func waitJSON(
ids: [UUID], timedOut: Bool, interrupted: Bool = false
) -> AgentJSONValue {
.object([
"timed_out": .bool(timedOut),
"interrupted": .bool(interrupted),
"chats": .array(
ids.compactMap { id in
conversations.conversation(id: id).map { chatJSON($0) }
}),
])
}
private func chatJSON(
_ conversation: ConversationModel, status: String? = nil, receiptID: UUID? = nil
) -> AgentJSONValue {
var object = chatObject(conversation)
if let status { object["status"] = .string(status) }
if let receiptID { object["receipt_id"] = .string(receiptID.uuidString) }
return .object(object)
}
private func listChatJSON(
_ conversation: ConversationModel,
relation: AssistantChatCreationRelation?
) -> AgentJSONValue {
var object = chatObject(conversation)
object["creator"] = creatorJSON(for: conversation, relation: relation)
return .object(object)
}
private func creatorJSON(
for conversation: ConversationModel,
relation: AssistantChatCreationRelation?
) -> AgentJSONValue {
guard let relation else {
return .object(["kind": .string("user")])
}
let savedTitle = conversation.messages.lazy.compactMap(\.origin).first { origin in
origin.sourceConversationID == relation.sourceConversationID
&& origin.sourceRunID == relation.sourceRunID
&& origin.sourceToolCallID == relation.sourceToolCallID
}?.sourceTitle
let title = conversations.conversation(id: relation.sourceConversationID)?.derivedTitle ?? savedTitle
return .object([
"kind": .string("agent"),
"chat_id": .string(relation.sourceConversationID.uuidString),
"title": title.map(AgentJSONValue.string) ?? .null,
])
}
private func chatObject(_ conversation: ConversationModel) -> [String: AgentJSONValue] {
[
"chat_id": .string(conversation.id.uuidString),
"title": .string(conversation.derivedTitle),
"model": .string(conversation.model.flatMap { ai.lookupChat($0)?.model.label } ?? ""),
"status": .string(conversation.isPreparingAgentRun ? "preparing" : conversation.runState.rawValue),
"updated_at": .string(conversation.updatedAt.ISO8601Format()),
"archived": .bool(conversation.isArchived),
]
}
private func receiptJSON(_ receipt: AssistantChatDeliveryReceipt) -> AgentJSONValue {
let title = conversations.conversation(id: receipt.targetConversationID)?.derivedTitle ?? ""
return .object([
"chat_id": .string(receipt.targetConversationID.uuidString),
"title": .string(title),
"status": .string(receipt.state.rawValue),
"receipt_id": .string(receipt.id.uuidString),
])
}
private func existingDelivery(sourceRunID: UUID, sourceToolCallID: String) throws -> AgentJSONValue? {
guard
var receipt = try collaborationStore.receipt(
sourceRunID: sourceRunID, sourceToolCallID: sourceToolCallID)
else { return nil }
guard let target = conversations.conversation(id: receipt.targetConversationID) else {
if receipt.state == .queued {
receipt.state = .undelivered
receipt.failure = "The target chat no longer exists."
receipt.updatedAt = .now
try collaborationStore.save(receipt)
}
return receiptJSON(receipt)
}
configure(target)
receipt =
try collaborationStore.receipt(
sourceRunID: sourceRunID, sourceToolCallID: sourceToolCallID) ?? receipt
return chatJSON(target, status: receipt.state.rawValue, receiptID: receipt.id)
}
/// A queued reservation survives a crash. Once the conversation is restored,
/// the durable prompt proves delivery; an absent prompt with no live owner proves
/// it can no longer be delivered.
private func reconcileReceipts(for conversation: ConversationModel) {
guard let receipts = try? collaborationStore.receipts(targetConversationID: conversation.id) else {
return
}
let messageIDs = Set(conversation.transcript.map(\.id)).union(conversation.messages.map(\.id))
for receipt in receipts where receipt.state == .queued {
if messageIDs.contains(receipt.messageID),
!collaborationStore.confirmsDeliveryDuringMessagePersistence
{
try? collaborationStore.updateReceipt(
messageID: receipt.messageID, state: .delivered, failure: nil)
} else if !conversation.isAgentRunActive {
try? collaborationStore.updateReceipt(
messageID: receipt.messageID, state: .undelivered,
failure: "The pending delivery did not survive restart.")
}
}
}
public static func reference(_ ref: AIModelRef) -> String {
"\(ref.modelProviderID.uuidString)::\(ref.modelID)"
}
private static func modelReference(_ value: String) -> AIModelRef? {
guard let boundary = value.range(of: "::"),
let providerID = UUID(uuidString: String(value[..<boundary.lowerBound]))
else { return nil }
return AIModelRef(
modelProviderID: providerID, modelID: String(value[boundary.upperBound...]))
}
private static func offset(_ cursor: String?) throws -> Int {
guard let cursor else { return 0 }
guard let value = Int(cursor), value >= 0, value <= 10_000_000 else {
throw AgentToolError.invalidArguments("The chat cursor is invalid.")
}
return value
}
}
extension String {
fileprivate var nonempty: String? { isEmpty ? nil : self }
}