Files
Termind/Frameworks/TermindApplication/Agent/AssistantChatCollaboration.swift
T
Kaede Akino 7e78bc00c4 feat(agent): unify output truncation and scratch capture
Integrate AgentKit 2.0.1 with shared scratch storage, whole-line tool policies, generated capture paths, and external MCP projection bypass. Update ssh-exec instructions and document the complete tool policy inventory.

Keep compact expansion controls left aligned and display the AgentKit truncation marker above them on both platforms. Cover narrow and wide native layouts, both button states, and marker ordering.
2026-10-17 22:51:26 +08:00

623 lines
27 KiB
Swift

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