Files
Termind/Frameworks/TermindApplication/Agent/AssistantExecutionController.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

1182 lines
54 KiB
Swift

import AgentKit
import Foundation
import Logging
import TermindDomain
/// One runtime assembly and compaction policy for both app surfaces.
@MainActor
public final class AssistantExecutionController {
public typealias RegistryFactory =
@MainActor (
ConversationModel, AIModel, ModelProvider, AgentSkillCatalog,
AgentEventChannel, AgentPaneAccess?, AgentChatSourceContext
)
->
AgentToolRegistry
private let ai: AIConfigurationModel
private let skills: SkillLibraryModel
private let memory: MemoryModel?
private let agentRunRepository: any AgentRunPersisting
private let modelSelections: AssistantModelSelectionController?
public var observeNotifications: ((ConversationModel) -> Void)?
public var reportModelSelectionError: (@MainActor (String) -> Void)?
private let authorize: @MainActor () -> Bool
private let makeRegistry: RegistryFactory
private let title: @MainActor (ConversationTitleContext) async throws -> String
private let logger: Logger
private let agentCompaction = AgentCompactionService()
private let guardianSessions = GuardianSessionStore()
private var titledConversationIDs = Set<UUID>()
private var preparations: [UUID: PreparationState] = [:]
fileprivate struct GuardianCapture {
var provider: ModelProvider
var model: AIModel
var secret: String
var reasoning: ReasoningEffort
}
fileprivate struct RunCapture {
var id: UUID
var prompt: ConversationModel.ComposedUserTurn
var contextSnapshot: AgentTurnContextSnapshot
var permissionMode: AgentPermissionMode
var reasoning: ReasoningEffort
var chat: (provider: ModelProvider, model: AIModel)?
var secret: String?
var nativeSearch: Bool
var skillCatalog: AgentSkillCatalog
var skillInventory: [AgentSkill]
var channel: AgentEventChannel?
var baseRegistry: AgentToolRegistry?
var guardian: GuardianCapture?
var memorySessionID: UUID?
var shouldCompact: Bool
var compactionModel: (any AgentModelCompleting)?
var memoryReviewModel: (any AgentModelCompleting)?
}
private struct PreparationState {
enum Phase {
case preparing
case savingFailure
}
var id: UUID
var conversation: ConversationModel
var permissionMode: AgentPermissionMode
var phase = Phase.preparing
var task: Task<Void, Never>?
var historyTask: Task<ConversationModel.ComposedUserTurn?, any Error>?
var cancellationTask: Task<Void, Never>?
var recordsInterruption = false
var cancellationRequested = false
}
private enum StartResult {
case runtimeStarted
case failurePersistenceStarted
case abandoned
}
public struct PreparedAgentEdit {
fileprivate var capture: RunCapture
fileprivate var replacement: AgentTranscriptMessage
fileprivate var messageID: UUID
}
public struct PreparedAgentDelivery {
fileprivate var capture: RunCapture
public var message: AgentTranscriptMessage
}
@discardableResult
public func cancelPendingRuns() -> Task<Void, Never> {
var cancellationTasks: [Task<Void, Never>] = []
for conversationID in Array(preparations.keys) {
guard let state = preparations[conversationID] else { continue }
cancellationTasks.append(
requestCancellation(
for: state.conversation, expectedID: state.id,
recordsInterruption: true))
}
return Task { @MainActor in
for task in cancellationTasks {
await task.value
}
}
}
public init(
ai: AIConfigurationModel, skills: SkillLibraryModel, repository: any AgentRunPersisting,
memory: MemoryModel? = nil,
modelSelections: AssistantModelSelectionController? = nil,
reportModelSelectionError: (@MainActor (String) -> Void)? = nil,
observeNotifications: ((ConversationModel) -> Void)? = nil,
authorize: @escaping @MainActor () -> Bool, makeRegistry: @escaping RegistryFactory,
title: @escaping @MainActor (ConversationTitleContext) async throws -> String,
logger: Logger = ApplicationLogger.disabled("termind.agent")
) {
self.ai = ai
self.skills = skills
self.memory = memory
self.modelSelections = modelSelections
self.reportModelSelectionError = reportModelSelectionError
self.observeNotifications = observeNotifications
agentRunRepository = repository
self.authorize = authorize
self.makeRegistry = makeRegistry
self.title = title
self.logger = logger
}
@discardableResult
public func sendAgentDraft(
_ conversation: ConversationModel,
context: SessionContext = .none,
terminalSuggestionsAvailable: Bool = false,
panes: AgentPaneAccess? = nil
) -> Bool {
guard
let capture = captureDraft(
conversation, context: context,
terminalSuggestionsAvailable: terminalSuggestionsAvailable, panes: panes)
else { return false }
startPreparation(capture, for: conversation, compactBeforeRun: true)
return true
}
@discardableResult
public func sendNewConversation(
_ conversation: ConversationModel, in library: AssistantLibraryModel,
context: SessionContext = .none,
terminalSuggestionsAvailable: Bool = false,
panes: AgentPaneAccess? = nil
) -> ConversationModel? {
guard library.canPromote(conversation),
let capture = captureDraft(
conversation, context: context,
terminalSuggestionsAvailable: terminalSuggestionsAvailable, panes: panes)
else { return nil }
guard library.promoteCapturedConversation(conversation) else {
cancelCapturedTurn(capture, in: conversation)
return nil
}
startPreparation(capture, for: conversation, compactBeforeRun: true)
return conversation
}
@discardableResult
public func sendDraftConversation(
in library: AssistantLibraryModel,
context: SessionContext = .none,
terminalSuggestionsAvailable: Bool = false,
panes: AgentPaneAccess? = nil
) -> ConversationModel? {
let conversation = library.draftConversation
guard library.canPromote(conversation),
let capture = captureDraft(
conversation, context: context,
terminalSuggestionsAvailable: terminalSuggestionsAvailable, panes: panes)
else { return nil }
guard library.promoteCapturedDraftConversation(conversation) else {
cancelCapturedTurn(capture, in: conversation)
return nil
}
startPreparation(capture, for: conversation, compactBeforeRun: true)
return conversation
}
@discardableResult
public func sendAgentText(
_ text: String, in conversation: ConversationModel,
context: SessionContext = .none,
terminalSuggestionsAvailable: Bool = false,
panes: AgentPaneAccess? = nil
) -> Bool {
guard authorize(), canSubmit(to: conversation),
!text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
conversation.composer.pendingAttachments.isEmpty
else { return false }
let prompt = conversation.composeUserTurn(text)
let snapshot = contextSnapshot(
for: conversation, context: context,
terminalSuggestionsAvailable: terminalSuggestionsAvailable)
guard
let capture = captureRun(
conversation, prompt: prompt, contextSnapshot: snapshot,
beginPreparation: true, panes: panes)
else { return false }
let submittedPrompt = conversation.appendUserTurnContent(
capture.prompt, contextSnapshot: capture.contextSnapshot)
var submitted = capture
submitted.prompt = submittedPrompt
startPreparation(submitted, for: conversation, compactBeforeRun: true)
return true
}
@discardableResult
public func sendPlanImplementation(
_ card: AgentPlanCard, in conversation: ConversationModel,
context: SessionContext = .none,
terminalSuggestionsAvailable: Bool = false,
panes: AgentPaneAccess? = nil
) -> Bool {
guard authorize(), canSubmit(to: conversation),
conversation.composer.pendingAttachments.isEmpty,
let pending = conversation.plans.first(where: {
$0.id == card.id && $0.outcome == .pending
})
else { return false }
let prompt = conversation.composeUserTurn(
ConversationModel.implementationPrompt(for: pending.plan))
var snapshot = contextSnapshot(
for: conversation, context: context,
terminalSuggestionsAvailable: terminalSuggestionsAvailable)
snapshot.planContract = nil
guard
let capture = captureRun(
conversation, prompt: prompt, contextSnapshot: snapshot,
beginPreparation: true, panes: panes)
else { return false }
let submittedPrompt = conversation.appendUserTurnContent(
capture.prompt, contextSnapshot: capture.contextSnapshot)
conversation.resolvePlan(card.id, as: .implemented)
var submitted = capture
submitted.prompt = submittedPrompt
startPreparation(submitted, for: conversation, compactBeforeRun: true)
return true
}
public func prepareAgentEdit(
_ conversation: ConversationModel, messageID: UUID, text: String,
context: SessionContext = .none,
terminalSuggestionsAvailable: Bool = false,
panes: AgentPaneAccess? = nil
) -> PreparedAgentEdit? {
guard authorize(), !conversation.isStreaming, !conversation.isArchived,
!conversation.isCompacting, !conversation.isHistoryChanging,
var replacement = conversation.editedUserMessage(messageID, authoredText: text)
else { return nil }
let snapshot = contextSnapshot(
for: conversation, context: context,
terminalSuggestionsAvailable: terminalSuggestionsAvailable)
replacement.contextSnapshot = snapshot
let prompt = ConversationModel.ComposedUserTurn(
id: replacement.id, text: replacement.text,
authoredText: replacement.authoredText ?? text, images: replacement.images,
contextAttachments: replacement.contextAttachments)
guard
let capture = captureRun(
conversation, prompt: prompt, contextSnapshot: snapshot,
beginPreparation: true, supersedePreparation: true, panes: panes)
else { return nil }
return PreparedAgentEdit(capture: capture, replacement: replacement, messageID: messageID)
}
public func performAgentEdit(
_ prepared: PreparedAgentEdit, in conversation: ConversationModel,
operations: AssistantConversationOperations
) async throws -> Bool {
let capture = prepared.capture
guard isCurrent(capture, for: conversation) else { return false }
let historyTask = Task { @MainActor in
try Task.checkCancellation()
return try await operations.edit(
conversation, messageID: prepared.messageID, replacement: prepared.replacement)
}
guard var state = preparations[conversation.id], state.id == capture.id,
!state.cancellationRequested
else {
historyTask.cancel()
return false
}
state.historyTask = historyTask
preparations[conversation.id] = state
do {
guard let edited = try await historyTask.value else {
guard isCurrent(capture, for: conversation) else { return false }
retirePreparation(capture.id, for: conversation)
return false
}
guard isCurrent(capture, for: conversation) else { return false }
if var state = preparations[conversation.id], state.id == capture.id {
state.historyTask = nil
preparations[conversation.id] = state
}
var submitted = capture
submitted.prompt = edited
startPreparation(submitted, for: conversation, compactBeforeRun: false)
return true
} catch is CancellationError {
return false
} catch {
guard isCurrent(capture, for: conversation) else { return false }
retirePreparation(capture.id, for: conversation)
throw error
}
}
/// Captures the target's own run settings while keeping the incoming agent text
/// outside its composer, attachments, armed skill, and model picker state.
public func prepareIncomingAgentMessage(
_ text: String, origin: AgentMessageOrigin, in conversation: ConversationModel
) -> PreparedAgentDelivery? {
guard authorize(), canSubmit(to: conversation), !text.isEmpty else { return nil }
let snapshot = contextSnapshot(
for: conversation, context: .none,
terminalSuggestionsAvailable: false)
let prompt = conversation.composeIncomingAgentTurn(text, origin: origin)
guard
let capture = captureRun(
conversation, prompt: prompt, contextSnapshot: snapshot,
beginPreparation: true, panes: nil)
else { return nil }
let message = AgentTranscriptMessage(
id: prompt.id, role: .user, text: prompt.text,
authoredText: nil, contextSnapshot: snapshot, origin: origin)
return PreparedAgentDelivery(capture: capture, message: message)
}
/// Persists the incoming prompt as a staging run before the coordinator accepts
/// delivery. The real run adopts this row when it starts.
public func persistPreparedAgentDelivery(
_ prepared: PreparedAgentDelivery, in conversation: ConversationModel
) async throws {
let capture = prepared.capture
guard isCurrent(capture, for: conversation) else { throw CancellationError() }
try await agentRunRepository.save(
AgentRunSnapshot(
id: prepared.message.id, conversationID: conversation.id,
state: .completed, permissionMode: capture.permissionMode,
messages: [prepared.message], startedAt: prepared.message.createdAt,
finishedAt: prepared.message.createdAt))
// The prompt save is the acceptance boundary and, for durable stores,
// atomically confirms its delivery receipt. Metadata is useful but not
// allowed to turn that accepted message into an orphan outside the live
// library when a later write fails.
try? await agentRunRepository.setConversationMetadata(
conversation.metadata, conversationID: conversation.id)
}
public func startPreparedAgentDelivery(
_ prepared: PreparedAgentDelivery, in conversation: ConversationModel
) {
let capture = prepared.capture
guard isCurrent(capture, for: conversation) else { return }
guard
let prompt = conversation.appendIncomingAgentTurnContent(
capture.prompt, contextSnapshot: capture.contextSnapshot)
else {
Task { await cancelPreparedAgentDelivery(prepared, in: conversation) }
return
}
var submitted = capture
submitted.prompt = prompt
startPreparation(submitted, for: conversation, compactBeforeRun: true)
}
public func cancelPreparedAgentDelivery(
_ prepared: PreparedAgentDelivery, in conversation: ConversationModel
) async {
guard isCurrent(prepared.capture, for: conversation) else { return }
await requestCancellation(
for: conversation, expectedID: prepared.capture.id,
recordsInterruption: false
).value
}
private func captureDraft(
_ conversation: ConversationModel, context: SessionContext,
terminalSuggestionsAvailable: Bool, panes: AgentPaneAccess?
) -> RunCapture? {
guard authorize(), canSubmit(to: conversation), conversation.canSendDraft,
conversation.composer.pendingAttachments.isEmpty
else { return nil }
let draft = conversation.composer.draft.trimmingCharacters(in: .whitespacesAndNewlines)
let snapshot = contextSnapshot(
for: conversation, context: context,
terminalSuggestionsAvailable: terminalSuggestionsAvailable)
let preview = conversation.composeUserTurn(draft)
guard
var capture = captureRun(
conversation, prompt: preview, contextSnapshot: snapshot,
beginPreparation: true, panes: panes)
else { return nil }
capture.prompt = conversation.appendUserTurnContent(preview, contextSnapshot: snapshot)
conversation.composer.draft = ""
return capture
}
public func startAgentRun(
_ conversation: ConversationModel,
promptID: UUID? = nil,
prompt: String? = nil,
authoredPrompt: String? = nil,
promptImages: [AgentImageAttachment]? = nil,
promptContextAttachments: [AgentContextAttachment]? = nil,
panes: AgentPaneAccess? = nil
) {
guard authorize(), canSubmit(to: conversation) else { return }
let lastUserTurn = conversation.transcript.last(where: { $0.role == .user })
let resolvedPrompt = ConversationModel.ComposedUserTurn(
id: promptID ?? lastUserTurn?.id ?? UUID(),
text: prompt ?? lastUserTurn?.text ?? "",
authoredText: authoredPrompt ?? lastUserTurn?.authoredText ?? "",
images: promptImages ?? lastUserTurn?.images ?? [],
contextAttachments: promptContextAttachments ?? lastUserTurn?.contextAttachments ?? [],
origin: lastUserTurn?.origin)
guard
!resolvedPrompt.text.isEmpty || !resolvedPrompt.images.isEmpty
|| !resolvedPrompt.contextAttachments.isEmpty
else { return }
// A legacy absent snapshot is unknown. Do not manufacture historical
// session, skill, plan, memory, or surface facts from today's UI.
let snapshot = lastUserTurn?.contextSnapshot ?? AgentTurnContextSnapshot()
guard
let capture = captureRun(
conversation, prompt: resolvedPrompt, contextSnapshot: snapshot,
beginPreparation: true, panes: panes)
else { return }
startPreparation(capture, for: conversation, compactBeforeRun: false)
}
private func performStartAgentRun(
_ conversation: ConversationModel, capture: RunCapture,
memorySession: (context: AgentMemoryContext, access: any AgentMemoryAccessing)? = nil
) -> StartResult {
guard isCurrent(capture, for: conversation), !conversation.isStreaming,
!conversation.isArchived, !conversation.isHistoryChanging
else { return .abandoned }
let operationID = UUID().uuidString.lowercased()
let conversationID = conversation.id.uuidString.lowercased()
guard let chat = capture.chat else {
logger.error(
"agent.run.failed",
metadata: [
"conversation_id": "\(conversationID)", "error_code": "missing_model",
"operation_id": "\(operationID)", "outcome": "failure", "stage": "preparing",
])
beginFailedRunPersistence(
conversation, capture: capture,
reason: String(localized: "No Chat model is configured.")
)
return .failurePersistenceStarted
}
guard let secret = capture.secret else {
logger.error(
"agent.run.failed",
metadata: [
"conversation_id": "\(conversationID)", "error_code": "missing_credential",
"operation_id": "\(operationID)", "outcome": "failure", "stage": "preparing",
])
beginFailedRunPersistence(
conversation, capture: capture,
reason: String(localized: "The Chat model has no available credential.")
)
return .failurePersistenceStarted
}
let model = AgentProviderClient(
provider: chat.provider, model: chat.model, secret: secret,
reasoning: capture.reasoning,
webSearch: capture.nativeSearch, promptCacheKey: conversation.id.uuidString
)
// One channel for the whole run, shared by everything that publishes
// into it. The broker used to raise its review events through a closure
// of its own, each wrapped in an unstructured `Task` — so a review's
// events had no defined order against the tool events they belong
// between, and its durable writes had no defined order against
// anything. See `AgentEventChannel`.
guard let channel = capture.channel, let baseRegistry = capture.baseRegistry else { return .abandoned }
let skillCatalog = capture.skillCatalog
let skillRegistry = AgentToolCatalog.registry(
builtIn: .init(
groups: [.skills, .memory], skills: skillCatalog, skillInventory: capture.skillInventory,
readOnlySkillNames: AgentSkillLibrary.reservedNames,
skillPackageResolver: GitHubSkillPackageResolver(), skillLibrary: skills,
memory: memorySession?.access))
let registry =
chat.model.abilities.contains(.toolCall)
? AgentToolRegistry(skillRegistry.registeredTools + baseRegistry.registeredTools) : baseRegistry
let interaction = WeakAgentUserInteraction(conversation)
let services = AgentToolServices()
.setting(interaction.sshInteraction, for: .sshInteraction)
.setting(true, for: .sshInterjectionEnabled)
.setting(interaction.mcpAuthorization, for: .mcpAuthorization)
let guardianServices = AgentToolServices()
.setting(AgentSSHInteraction.unavailable, for: .sshInteraction)
let reviewer: (any GuardianReviewing)? = {
guard capture.permissionMode == .approveForMe, let review = capture.guardian else { return nil }
let reviewTools =
review.model.abilities.contains(.toolCall)
? registry : AgentToolRegistry([])
return GuardianClient(
model: AgentProviderClient(
provider: review.provider, model: review.model, secret: review.secret,
reasoning: review.reasoning,
promptCacheKey: TermindAgentPolicy.guardianCacheKey(conversationID: conversation.id)
),
sessions: guardianSessions,
sessionID: "\(conversation.id.uuidString):\(review.provider.id):\(review.model.id)",
tools: reviewTools,
services: guardianServices,
additionalPolicy: TermindAgentPolicy.guardianPolicy
)
}()
// Weakly. The conversation owns the runtime, the runtime owns the broker
// and the interaction handler, and both of those used to own the
// conversation back — so every chat that had ever run an agent stayed
// alive, with its runtime, for the life of the process. A conversation
// that is gone denies and cancels: fail closed.
let approval = AgentApprovalBroker(
reviewer: reviewer,
manualApproval: { [weak conversation] request in
guard let conversation else {
return .deny(String(localized: "The conversation was closed before this action was approved."))
}
return await conversation.requestApproval(request)
},
reviewTimeout: .seconds(90),
event: channel.emit
)
// The one place the resolved tool set exists. Recorded on the
// conversation so the context gauge can account for the schemas without
// rebuilding a registry on every keystroke.
conversation.noteToolSchemas(registry.descriptors)
// Read once, here: the posture a run enforces is the one it started
// under, and the user is free to leave plan mode while it is thinking.
let frozenContext = capture.contextSnapshot
let isPlanning = frozenContext.planContract != nil
// The catalog reaches the model boundary through the pipeline rather
// than through the system prompt, so the prompt stays byte-identical for
// every run and the provider's prefix cache survives a skill being
// enabled. See `AgentSkillCatalogInjection`.
var configuration = AgentLoopConfiguration()
// Termind draws the reply as it streams, so the bursts a provider
// delivers in need smoothing before they reach a view. A surface that
// only records events would leave this off — see `AgentStreamPacing`.
configuration.streamPacing = .animated
configuration.outputProjection = AgentToolOutputProjection(
workspace: AgentScratchWorkspace.shared(applicationName: TermindAgentPolicy.scratchDirectoryName))
configuration.contextSnapshot = { _, _ in frozenContext }
configuration.contextPipeline = { sessionContext, promptID, planning in
var pipeline = AgentContextPipeline.chat(
sessionContext: sessionContext, before: promptID,
isPlanning: planning, skills: skillCatalog,
planContract: TermindAgentPolicy.planContract
)
if let memorySession { pipeline.transforms.append(memorySession.context) }
return pipeline
}
let runtime = AgentRuntime(
model: model, registry: registry,
approval: approval,
repository: agentRunRepository,
secretBroker: conversation.secretBroker,
userInteraction: interaction,
services: services,
hooks: [TermindAgentPolicy.planModeHook(isPlanning: isPlanning)],
configuration: configuration,
channel: channel
)
// No second Shell Integration gate here. `Pane.remoteSystemInfo` already
// withholds the probe when integration was disabled at connection start, and `context` is the same
// value the chips were drawn from — so what is shown and what is sent
// cannot disagree.
conversation.start(
runtime: runtime,
request: AgentRunRequest(
conversationID: conversation.id,
promptID: capture.prompt.id, prompt: capture.prompt.text,
authoredPrompt: capture.prompt.origin == nil ? capture.prompt.authoredText : nil,
promptOrigin: capture.prompt.origin,
promptImages: capture.prompt.images,
promptContextAttachments: capture.prompt.contextAttachments,
permissionMode: capture.permissionMode, isPlanning: isPlanning,
systemPrompt: TermindAgentPolicy.systemPrompt,
priorMessages: conversation.pendingPriorMessages,
authorizationEvidence: GuardianEvidence.collect(from: conversation.transcript),
sessionContext: frozenContext.sessionContext, contextSnapshot: frozenContext
),
repository: agentRunRepository
)
logger.info(
"agent.run.started",
metadata: [
"conversation_id": "\(conversationID)", "operation_id": "\(operationID)",
"planning": "\(isPlanning)", "stage": "running", "tool_count": "\(registry.registeredTools.count)",
])
scheduleTitle(
for: conversation, isPlanning: isPlanning, operationID: operationID,
memoryToken: memorySession.flatMap { memory?.learningToken(for: $0.context) },
reviewModel: capture.memoryReviewModel)
return .runtimeStarted
}
private func canSubmit(to conversation: ConversationModel) -> Bool {
!conversation.isArchived && !conversation.isStreaming && !conversation.isCompacting
&& !conversation.isHistoryChanging && !conversation.isPreparingAgentRun
}
private func captureRun(
_ conversation: ConversationModel,
prompt: ConversationModel.ComposedUserTurn,
contextSnapshot: AgentTurnContextSnapshot,
beginPreparation: Bool,
supersedePreparation: Bool = false,
panes: AgentPaneAccess? = nil
) -> RunCapture? {
do {
try modelSelections?.prepareForSend(conversation)
} catch {
reportModelSelectionError?(error.localizedDescription)
return nil
}
let id = UUID()
let chat = ai.resolve(conversation: conversation.model)
let secret = chat.flatMap { ai.modelSecret(for: $0.provider) }
let permissionMode = ai.resolve(conversation: conversation.permissionMode)
let nativeSearch =
chat.map {
ai.webSearch.isEnabled && ai.webSearch.prefersBuiltIn
&& $0.provider.supportsNativeWebSearch(model: $0.model)
} ?? false
let skillCatalog =
chat?.model.abilities.contains(.toolCall) == true
? skills.catalog : AgentSkillCatalog()
let skillInventory = skills.inventory
let channel = chat != nil && secret != nil ? AgentEventChannel(repository: agentRunRepository) : nil
let baseRegistry = channel.flatMap { channel in
chat.map {
let modelReference = AIModelRef(
modelProviderID: $0.provider.id, modelID: $0.model.id)
return makeRegistry(
conversation, $0.model, $0.provider, skillCatalog, channel, panes,
AgentChatSourceContext(
conversationID: conversation.id, title: conversation.derivedTitle,
modelReference: AssistantChatCoordinator.reference(modelReference),
permissionMode: permissionMode,
mode: conversation.isPlanning ? .planning : .acting))
}
}
let guardian: GuardianCapture? = {
guard permissionMode == .approveForMe, let review = ai.resolve(.guardian),
let secret = ai.modelSecret(for: review.provider)
else { return nil }
return GuardianCapture(
provider: review.provider, model: review.model, secret: secret,
reasoning: GuardianClient.preferredReasoning(
model: review.model, provider: review.provider))
}()
let contextWindow = chat?.model.contextLength
let shouldCompact =
conversation.usage.map {
AgentCompaction.shouldCompact(
contextTokens: $0.contextTokens, contextWindow: contextWindow,
reserveTokens: compactionBudget.reserveTokens)
} ?? false
let capture = RunCapture(
id: id, prompt: prompt, contextSnapshot: contextSnapshot,
permissionMode: permissionMode, reasoning: conversation.reasoningEffort,
chat: chat, secret: secret, nativeSearch: nativeSearch,
skillCatalog: skillCatalog, skillInventory: skillInventory,
channel: channel, baseRegistry: baseRegistry, guardian: guardian,
memorySessionID: contextSnapshot.memorySessionID,
shouldCompact: shouldCompact, compactionModel: compactionModel(),
memoryReviewModel: memoryReviewModel(conversation))
guard beginPreparation else { return capture }
let cancellation: @MainActor () -> Task<Void, Never> = { [weak self, weak conversation] in
guard let self, let conversation else { return Task {} }
return self.requestCancellation(
for: conversation, expectedID: id, recordsInterruption: true)
}
if supersedePreparation {
if let current = preparations[conversation.id] {
guard current.phase != .savingFailure else { return nil }
_ = requestCancellation(
for: conversation, expectedID: current.id,
recordsInterruption: false)
}
preparations[conversation.id] = PreparationState(
id: id, conversation: conversation, permissionMode: permissionMode)
conversation.supersedeAgentPreparation(id: id, cancellation: cancellation)
return capture
}
let installed = conversation.beginAgentPreparation(id: id, cancellation: cancellation)
guard installed else { return nil }
preparations[conversation.id] = PreparationState(
id: id, conversation: conversation, permissionMode: permissionMode)
return capture
}
private func startPreparation(
_ capture: RunCapture, for conversation: ConversationModel,
compactBeforeRun: Bool
) {
guard isCurrent(capture, for: conversation) else { return }
observeNotifications?(conversation)
let needsAsync = (compactBeforeRun && capture.shouldCompact) || capture.memorySessionID != nil
guard needsAsync else {
switch performStartAgentRun(conversation, capture: capture) {
case .runtimeStarted, .abandoned:
retirePreparation(capture.id, for: conversation)
case .failurePersistenceStarted:
break
}
return
}
let task = Task { [weak self, weak conversation] in
guard let self, let conversation else { return }
if compactBeforeRun, capture.shouldCompact {
await compactCaptured(conversation, capture: capture)
}
guard !Task.isCancelled, isCurrent(capture, for: conversation) else {
await finishCancelledPreparation(capture, for: conversation)
return
}
let memorySession: (context: AgentMemoryContext, access: any AgentMemoryAccessing)?
if let memorySessionID = capture.memorySessionID, let memory {
memorySession = await memory.session(memorySessionID)
} else {
memorySession = nil
}
guard !Task.isCancelled, isCurrent(capture, for: conversation) else {
await finishCancelledPreparation(capture, for: conversation)
return
}
switch performStartAgentRun(conversation, capture: capture, memorySession: memorySession) {
case .runtimeStarted, .abandoned:
retirePreparation(capture.id, for: conversation)
case .failurePersistenceStarted:
break
}
}
guard var state = preparations[conversation.id], state.id == capture.id else {
task.cancel()
return
}
state.task = task
preparations[conversation.id] = state
}
private func compactCaptured(_ conversation: ConversationModel, capture: RunCapture) async {
guard let model = capture.compactionModel, !conversation.isStreaming, !conversation.isCompacting else { return }
conversation.beginCompaction()
guard let span = conversation.compactionSpan(keepRecentTokens: compactionBudget.keepRecentTokens) else {
conversation.endCompaction()
return
}
do {
if capture.contextSnapshot.planContract == nil, let memory,
let review = capture.memoryReviewModel
{
await memory.review(
conversationID: conversation.id, messages: conversation.transcript,
model: review, wait: true)
}
try Task.checkCancellation()
let summary = try await agentCompaction.summarize(
span.summarize, instructions: "",
toolResultLimit: compactionBudget.toolResultLimit, model: model)
try Task.checkCancellation()
guard isCurrent(capture, for: conversation),
let record = conversation.prepareCompaction(summary: summary, cut: span.cut)
else { throw CancellationError() }
try await persistAndApplyCompaction(record, to: conversation, cut: span.cut)
conversation.endCompaction()
} catch is CancellationError {
conversation.endCompaction()
} catch {
conversation.failCompaction(error.localizedDescription)
}
}
private func isCurrent(_ capture: RunCapture, for conversation: ConversationModel) -> Bool {
guard let state = preparations[conversation.id] else { return false }
return state.id == capture.id && !state.cancellationRequested
}
private func retirePreparation(_ id: UUID, for conversation: ConversationModel) {
guard preparations[conversation.id]?.id == id else { return }
preparations[conversation.id] = nil
conversation.finishAgentPreparation(id: id)
}
private func cancelCapturedTurn(_ capture: RunCapture, in conversation: ConversationModel) {
guard isCurrent(capture, for: conversation) else { return }
preparations[conversation.id]?.task?.cancel()
retirePreparation(capture.id, for: conversation)
}
/// Invalidates a captured run before returning to the caller. The task only
/// joins asynchronous work and persists the terminal snapshot; it never
/// decides whether the runtime may still start.
private func requestCancellation(
for conversation: ConversationModel, expectedID: UUID? = nil,
recordsInterruption: Bool
) -> Task<Void, Never> {
guard var state = preparations[conversation.id],
expectedID == nil || state.id == expectedID
else { return Task {} }
if state.phase == .savingFailure {
return state.task ?? Task {}
}
state.recordsInterruption = state.recordsInterruption || recordsInterruption
state.cancellationRequested = true
state.task?.cancel()
state.historyTask?.cancel()
if let cancellationTask = state.cancellationTask {
preparations[conversation.id] = state
return cancellationTask
}
preparations[conversation.id] = state
let preparationTask = state.task
let historyTask = state.historyTask
let preparationID = state.id
let cancellationTask = Task { @MainActor [weak self, weak conversation] in
guard let self, let conversation else { return }
if let historyTask {
_ = try? await historyTask.value
}
if let preparationTask {
await preparationTask.value
}
await finishCancelledPreparation(
preparationID, for: conversation)
}
state.cancellationTask = cancellationTask
preparations[conversation.id] = state
return cancellationTask
}
private func finishCancelledPreparation(
_ capture: RunCapture, for conversation: ConversationModel
) async {
guard let state = preparations[conversation.id], state.id == capture.id else { return }
if state.recordsInterruption {
let now = Date.now
do {
let snapshot = AgentRunSnapshot(
conversationID: conversation.id, state: .interrupted,
permissionMode: capture.permissionMode,
messages: conversation.transcript, startedAt: now, finishedAt: now)
try await agentRunRepository.save(snapshot)
guard preparations[conversation.id]?.id == capture.id else { return }
conversation.adoptStagedReplacementRun(snapshot)
conversation.handle(.runState(.interrupted))
} catch {
guard preparations[conversation.id]?.id == capture.id else { return }
conversation.messages.append(ChatMessage(role: .assistant, text: error.localizedDescription))
}
}
retirePreparation(capture.id, for: conversation)
}
private func finishCancelledPreparation(
_ preparationID: UUID, for conversation: ConversationModel
) async {
guard let state = preparations[conversation.id], state.id == preparationID else { return }
if state.recordsInterruption {
let now = Date.now
do {
let snapshot = AgentRunSnapshot(
conversationID: conversation.id, state: .interrupted,
permissionMode: state.permissionMode,
messages: conversation.transcript, startedAt: now, finishedAt: now)
try await agentRunRepository.save(snapshot)
guard preparations[conversation.id]?.id == preparationID else { return }
conversation.adoptStagedReplacementRun(snapshot)
conversation.handle(.runState(.interrupted))
} catch {
guard preparations[conversation.id]?.id == preparationID else { return }
conversation.messages.append(ChatMessage(role: .assistant, text: error.localizedDescription))
}
}
retirePreparation(preparationID, for: conversation)
}
private func beginFailedRunPersistence(
_ conversation: ConversationModel, capture: RunCapture, reason: String
) {
guard var state = preparations[conversation.id], state.id == capture.id else { return }
var messages = conversation.pendingPriorMessages
if !messages.contains(where: { $0.id == capture.prompt.id }) {
messages.append(
AgentTranscriptMessage(
id: capture.prompt.id, role: .user, text: capture.prompt.text,
authoredText: capture.prompt.authoredText, images: capture.prompt.images,
contextAttachments: capture.prompt.contextAttachments,
contextSnapshot: capture.contextSnapshot
))
}
let now = Date.now
let snapshot = AgentRunSnapshot(
conversationID: conversation.id, state: .failed,
permissionMode: capture.permissionMode,
messages: messages, startedAt: now, finishedAt: now,
failure: reason
)
conversation.beginAgentPreparationFailurePersistence(id: capture.id)
state.phase = .savingFailure
let preparationID = capture.id
let task = Task { @MainActor [self, conversation, agentRunRepository] in
do {
try await agentRunRepository.save(snapshot)
guard preparations[conversation.id]?.id == preparationID else { return }
conversation.recordFailedRun(snapshot)
} catch {
guard preparations[conversation.id]?.id == preparationID else { return }
conversation.messages.append(ChatMessage(role: .assistant, text: error.localizedDescription))
}
retirePreparation(preparationID, for: conversation)
}
state.task = task
preparations[conversation.id] = state
}
public func contextSnapshot(
for conversation: ConversationModel, context: SessionContext, terminalSuggestionsAvailable: Bool
) -> AgentTurnContextSnapshot {
let catalog =
ai.resolve(conversation: conversation.model)?.model.abilities.contains(.toolCall) == true
? skills.catalog : AgentSkillCatalog()
return AgentTurnContextSnapshot(
sessionContext: context.modelContext,
skillCatalog: catalog.isEmpty ? nil : catalog.catalogBlock,
planContract: conversation.isPlanning ? TermindAgentPolicy.planContract : nil,
toolAvailability: ["terminal_suggest": terminalSuggestionsAvailable],
memorySessionID: memory?.isEnabled == true
? (conversation.transcript.first(where: { $0.contextSnapshot?.memorySessionID != nil })?
.contextSnapshot?.memorySessionID ?? conversation.id) : nil
)
}
/// Which of the two web-search paths this turn gets, resolved once so the
/// request body and the tool list cannot disagree.
///
/// Native wins where it exists. It searches better than a snippet API can,
/// costs no second key, and needs no round trip through Termind — the model
/// simply knows things. What it gives up is visibility, which
/// `AgentRuntime.recordNativeWebSearch` buys back after the fact.
///
// MARK: - Compaction
/// Summarizes the older part of a conversation so it can keep going.
///
/// Never while a run is in flight: the transcript it would rewrite is the
/// one the runtime is appending to, and the two would disagree about what
/// the conversation is. `/compact` during a run is refused rather than
/// queued, because by the time the run ends the thing the user wanted
/// compacted has changed.
@discardableResult
public func compact(
_ conversation: ConversationModel,
instructions: String = "",
isManual: Bool = true
) async -> Bool {
guard authorize() else { return false }
guard !conversation.isAgentRunActive, !conversation.isCompacting else { return false }
conversation.beginCompaction()
guard
let span = conversation.compactionSpan(
keepRecentTokens: compactionBudget.keepRecentTokens
)
else {
if isManual {
conversation.failCompaction(
AgentCompactionError.nothingToCompact.localizedDescription
)
} else {
conversation.endCompaction()
}
return false
}
guard let model = compactionModel() else {
conversation.failCompaction(AgentCompactionError.missingModel.localizedDescription)
return false
}
do {
if !conversation.isPlanning, let memory, let review = memoryReviewModel(conversation) {
await memory.review(
conversationID: conversation.id, messages: conversation.transcript, model: review, wait: true)
}
let summary = try await agentCompaction.summarize(
span.summarize, instructions: instructions,
toolResultLimit: compactionBudget.toolResultLimit, model: model
)
guard
let record = conversation.prepareCompaction(
summary: summary, cut: span.cut
)
else {
throw AgentCompactionError.nothingToCompact
}
try await persistAndApplyCompaction(record, to: conversation, cut: span.cut)
conversation.endCompaction()
return true
} catch {
conversation.failCompaction(error.localizedDescription)
return false
}
}
/// The atomicity boundary for compaction: persistence happens first, and
/// the observable model changes only after that succeeds.
public func persistAndApplyCompaction(
_ record: AgentCompactionRecord, to conversation: ConversationModel, cut: Int
) async throws {
try await agentRunRepository.persistCompaction(
record, conversationID: conversation.id
)
conversation.applyCompaction(record, cut: cut)
}
/// Resolves the Chat model with reasoning clamped off, like titling does.
///
/// Summarizing is not a task that improves with a thinking budget, and this
/// runs on the user's own key at the moment their conversation is already
/// expensive.
private func compactionModel() -> (any AgentModelCompleting)? {
guard let resolved = ai.resolve(.chat) else { return nil }
guard let secret = ai.modelSecret(for: resolved.provider)
else { return nil }
let reasoning = ModelCapabilityResolver.reasoning(
model: resolved.model, provider: resolved.provider
).clamp(.off)
return AgentProviderClient(
provider: resolved.provider, model: resolved.model, secret: secret,
reasoning: reasoning
)
}
/// What a compaction is allowed to keep, reserve, and show the summarizer.
///
/// One value rather than three, so a future Settings control changes them
/// together — see `AgentCompaction.Budget`. Termind ships the defaults.
private var compactionBudget: AgentCompaction.Budget { .default }
/// Exactly one path is ever live. Offering both would put two tools in
/// front of the model for one job and leave it to guess which, and the
/// guess would decide whether the search shows up as a card beforehand or a
/// record afterwards.
/// Arms the one-shot summary for this conversation.
///
/// Titling is deliberately once per conversation, not per run: a chat list
/// whose rows rewrite themselves as the conversation grows is harder to scan
/// than one whose rows stay put. `titledConversationIDs` is what makes the
/// second run a no-op even though every run re-arms the hook.
private func scheduleTitle(
for conversation: ConversationModel, isPlanning: Bool, operationID: String,
memoryToken: UUID?, reviewModel: (any AgentModelCompleting)?
) {
let conversationID = conversation.id.uuidString.lowercased()
conversation.runDidFinish = { [weak self, weak conversation] in
guard let self, let conversation else { return }
let state = conversation.runState
logger.log(
level: state == .completed ? .info : state == .cancelled ? .warning : .error,
"agent.run.finished",
metadata: [
"conversation_id": "\(conversationID)", "operation_id": "\(operationID)",
"outcome": "\(state.rawValue)", "stage": "finished",
])
guard let context = Self.titleContext(for: conversation),
!titledConversationIDs.contains(conversation.id)
else { return }
titledConversationIDs.insert(conversation.id)
Task { [weak self, weak conversation] in
guard let self else { return }
guard let title = try? await title(context),
let conversation
else { return }
do {
try await agentRunRepository.setTitle(title, conversationID: conversation.id)
conversation.title = title
} catch {
titledConversationIDs.remove(conversation.id)
}
}
}
if !isPlanning, let memoryToken, let memory {
let historyEpoch = memory.learningEpoch(for: conversation.id)
if let reviewModel {
for (index, message) in conversation.transcript.enumerated() where message.role == .user {
memory.considerCorrection(
conversationID: conversation.id, message: message,
context: Array(conversation.transcript.prefix(index + 1).suffix(6)),
model: reviewModel, sessionToken: memoryToken, historyEpoch: historyEpoch)
}
}
conversation.memoryUserAccepted = { [weak conversation] message, context in
guard let conversation, let reviewModel else { return }
memory.considerCorrection(
conversationID: conversation.id, message: message, context: context,
model: reviewModel, sessionToken: memoryToken, historyEpoch: historyEpoch)
}
conversation.memoryToolBatchFinished = { [weak conversation] toolEvents, messages in
guard let conversation else { return }
Task {
await memory.recordActivity(
conversationID: conversation.id, userMessageIDs: [],
toolInvocations: toolEvents, messages: messages, model: reviewModel,
sessionToken: memoryToken, historyEpoch: historyEpoch)
}
}
conversation.memoryRunFinished = { [weak conversation] _, userIDs, toolEvents, messages in
guard let conversation else { return }
Task {
await memory.recordActivity(
conversationID: conversation.id, userMessageIDs: userIDs,
toolInvocations: toolEvents, messages: messages, model: reviewModel,
sessionToken: memoryToken, historyEpoch: historyEpoch)
}
}
} else {
conversation.memoryUserAccepted = nil
conversation.memoryRunFinished = nil
conversation.memoryToolBatchFinished = nil
}
}
private func memoryReviewModel(_ conversation: ConversationModel) -> (any AgentModelCompleting)? {
guard memory?.isEnabled == true else { return nil }
guard let resolved = ai.resolve(conversation: memory?.reviewModel ?? conversation.model),
let secret = ai.modelSecret(for: resolved.provider)
else { return nil }
return AgentProviderClient(
provider: resolved.provider, model: resolved.model, secret: secret,
reasoning: conversation.reasoningEffort)
}
/// The opening exchange, if this conversation has one worth naming.
///
/// Read from `transcript`, not `messages`. A run that failed puts its reason
/// in the visible list alone, so gating on `messages` would happily title a
/// conversation "The Chat model has no available credential."
///
/// Gated on there being a reply rather than on the run having *completed*,
/// so a run the user stopped after a full first answer still gets a title
/// instead of staying labelled by its opening line forever.
public static func titleContext(for conversation: ConversationModel) -> ConversationTitleContext? {
guard conversation.title == nil,
let firstUser = conversation.transcript.first(where: { $0.role == .user }),
let assistant = conversation.transcript.first(where: {
$0.role == .assistant && !$0.text.isEmpty
})?.text
else { return nil }
let user =
firstUser.text.isEmpty
? firstUser.images.first?.label
?? firstUser.contextAttachments.first?.label
?? ""
: firstUser.text
guard !user.isEmpty else { return nil }
return ConversationTitleContext(
firstUserMessage: user, firstAssistantMessage: assistant
)
}
}