419 lines
19 KiB
Swift
419 lines
19 KiB
Swift
import AgentKit
|
|
import Foundation
|
|
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, Bool) ->
|
|
AgentToolRegistry
|
|
private let ai: AIConfigurationModel
|
|
private let skills: SkillLibraryModel
|
|
private let agentRunRepository: any AgentRunPersisting
|
|
private let authorize: @MainActor () -> Bool
|
|
private let makeRegistry: RegistryFactory
|
|
private let title: @MainActor (ConversationTitleContext) async throws -> String
|
|
private let agentCompaction = AgentCompactionService()
|
|
private var titledConversationIDs = Set<UUID>()
|
|
private var preparationGeneration: UInt64 = 0
|
|
private var preparing = Set<UUID>()
|
|
|
|
public func cancelPendingRuns() { preparationGeneration &+= 1 }
|
|
|
|
public init(
|
|
ai: AIConfigurationModel, skills: SkillLibraryModel, repository: any AgentRunPersisting,
|
|
authorize: @escaping @MainActor () -> Bool, makeRegistry: @escaping RegistryFactory,
|
|
title: @escaping @MainActor (ConversationTitleContext) async throws -> String
|
|
) {
|
|
self.ai = ai
|
|
self.skills = skills
|
|
agentRunRepository = repository
|
|
self.authorize = authorize
|
|
self.makeRegistry = makeRegistry
|
|
self.title = title
|
|
}
|
|
|
|
public func sendAgentDraft(
|
|
_ conversation: ConversationModel,
|
|
context: SessionContext = .none,
|
|
includesTerminalSuggestion: Bool = false
|
|
) {
|
|
guard authorize() else { return }
|
|
let draft = conversation.composer.draft.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard conversation.canSendDraft, !conversation.isArchived, !conversation.isStreaming,
|
|
!conversation.isCompacting, !conversation.isHistoryChanging,
|
|
conversation.composer.pendingAttachments.isEmpty,
|
|
preparing.insert(conversation.id).inserted
|
|
else { return }
|
|
let generation = preparationGeneration
|
|
// The *returned* text, not `draft`: attachments are folded in there, and
|
|
// running on the pre-fold string would show the model a turn that does
|
|
// not match the one the conversation recorded.
|
|
let prompt = conversation.appendUserTurnContent(draft)
|
|
conversation.composer.draft = ""
|
|
// Compaction happens *after* the turn is recorded and before the run,
|
|
// so the message the user just typed is inside the kept window rather
|
|
// than being the first thing summarized away. When nothing needs
|
|
// compacting this is a synchronous check that starts the run in the same
|
|
// tick, which is what keeps the composer feeling immediate.
|
|
Task { [weak self, weak conversation] in
|
|
guard let self, let conversation else { return }
|
|
defer { preparing.remove(conversation.id) }
|
|
await compactIfNeeded(conversation)
|
|
guard generation == preparationGeneration, !Task.isCancelled else {
|
|
do {
|
|
try await agentRunRepository.save(
|
|
AgentRunSnapshot(
|
|
conversationID: conversation.id, state: .interrupted,
|
|
permissionMode: ai.resolve(conversation: conversation.permissionMode),
|
|
messages: conversation.transcript, finishedAt: .now))
|
|
conversation.handle(.runState(.interrupted))
|
|
} catch {
|
|
conversation.messages.append(ChatMessage(role: .assistant, text: error.localizedDescription))
|
|
}
|
|
return
|
|
}
|
|
startAgentRun(
|
|
conversation, promptID: prompt.id, prompt: prompt.text,
|
|
authoredPrompt: prompt.authoredText, promptImages: prompt.images,
|
|
promptContextAttachments: prompt.contextAttachments, context: context,
|
|
includesTerminalSuggestion: includesTerminalSuggestion
|
|
)
|
|
}
|
|
}
|
|
|
|
public func startAgentRun(
|
|
_ conversation: ConversationModel,
|
|
promptID: UUID? = nil,
|
|
prompt: String? = nil,
|
|
authoredPrompt: String? = nil,
|
|
promptImages: [AgentImageAttachment]? = nil,
|
|
promptContextAttachments: [AgentContextAttachment]? = nil,
|
|
context: SessionContext = .none,
|
|
includesTerminalSuggestion: Bool = false
|
|
) {
|
|
guard authorize() else { return }
|
|
guard !conversation.isStreaming, !conversation.isArchived, !conversation.isHistoryChanging else { return }
|
|
let lastUserTurn = conversation.transcript.last(where: { $0.role == .user })
|
|
let userPrompt =
|
|
prompt
|
|
?? lastUserTurn?.text
|
|
?? ""
|
|
let userImages =
|
|
promptImages
|
|
?? lastUserTurn?.images
|
|
?? []
|
|
let userContextAttachments =
|
|
promptContextAttachments
|
|
?? lastUserTurn?.contextAttachments
|
|
?? []
|
|
guard !userPrompt.isEmpty || !userImages.isEmpty || !userContextAttachments.isEmpty
|
|
else { return }
|
|
guard let chat = ai.resolve(conversation: conversation.model) else {
|
|
conversation.messages.append(
|
|
ChatMessage(
|
|
role: .assistant, text: String(localized: "No Chat model is configured.")
|
|
))
|
|
return
|
|
}
|
|
guard let secret = ai.secret(for: chat.provider.effectiveCredentialRef), !secret.isEmpty else {
|
|
conversation.messages.append(
|
|
ChatMessage(
|
|
role: .assistant, text: String(localized: "The Chat model has no available credential.")
|
|
))
|
|
return
|
|
}
|
|
|
|
let mode = ai.resolve(conversation: conversation.permissionMode)
|
|
let nativeSearch =
|
|
ai.webSearch.isEnabled && ai.webSearch.prefersBuiltIn
|
|
&& chat.provider.supportsNativeWebSearch(model: chat.model)
|
|
let model = AgentProviderClient(
|
|
provider: chat.provider, model: chat.model, secret: secret,
|
|
reasoning: conversation.reasoningEffort,
|
|
webSearch: nativeSearch
|
|
)
|
|
let reviewer: (any SecurityReviewing)? = {
|
|
guard mode == .approveForMe, let review = ai.resolve(.securityReview) else { return nil }
|
|
guard let reviewSecret = ai.secret(for: review.provider.effectiveCredentialRef),
|
|
!reviewSecret.isEmpty
|
|
else { return nil }
|
|
let reasoning = SecurityReviewClient.preferredReasoning(
|
|
model: review.model, provider: review.provider
|
|
)
|
|
return SecurityReviewClient(
|
|
model: AgentProviderClient(
|
|
provider: review.provider, model: review.model, secret: reviewSecret,
|
|
reasoning: reasoning
|
|
))
|
|
}()
|
|
// 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`.
|
|
let channel = AgentEventChannel(repository: agentRunRepository)
|
|
// 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)
|
|
},
|
|
event: channel.emit
|
|
)
|
|
|
|
// Read once, here, for the reason `isPlanning` below is: a run's posture
|
|
// is fixed when it starts, so enabling a skill while the agent is
|
|
// thinking must not change what this run was told it had. Empty when the
|
|
// model cannot call tools, which is what keeps the catalog block from
|
|
// naming a tool this run was never given.
|
|
let skillCatalog =
|
|
chat.model.abilities.contains(.toolCall)
|
|
? skills.catalog : AgentSkillCatalog()
|
|
|
|
let registry = makeRegistry(
|
|
conversation, chat.model, chat.provider, skillCatalog, channel, includesTerminalSuggestion)
|
|
// 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 isPlanning = conversation.isPlanning
|
|
// 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.contextPipeline = { sessionContext, promptID, planning in
|
|
AgentContextPipeline.chat(
|
|
sessionContext: sessionContext, before: promptID,
|
|
isPlanning: planning, skills: skillCatalog,
|
|
planContract: TermindAgentPolicy.planContract
|
|
)
|
|
}
|
|
let interaction = WeakAgentUserInteraction(conversation)
|
|
let runtime = AgentRuntime(
|
|
model: model, registry: registry,
|
|
approval: approval,
|
|
repository: agentRunRepository,
|
|
userInteraction: interaction,
|
|
services: AgentToolServices()
|
|
.setting(interaction.sshInteraction, for: .sshInteraction),
|
|
hooks: [TermindAgentPolicy.planModeHook(isPlanning: isPlanning)],
|
|
configuration: configuration,
|
|
channel: channel
|
|
)
|
|
// No second Shell Integration gate here. `Pane.remoteSystemInfo` already
|
|
// withholds the probe when the setting is off, 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: promptID ?? lastUserTurn?.id ?? UUID(), prompt: userPrompt,
|
|
authoredPrompt: authoredPrompt ?? lastUserTurn?.authoredText,
|
|
promptImages: userImages,
|
|
promptContextAttachments: userContextAttachments,
|
|
permissionMode: mode, isPlanning: isPlanning,
|
|
systemPrompt: TermindAgentPolicy.systemPrompt,
|
|
priorMessages: conversation.pendingPriorMessages,
|
|
sessionContext: context.modelContext
|
|
))
|
|
scheduleTitle(for: conversation)
|
|
}
|
|
|
|
/// 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.isStreaming, !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 {
|
|
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)
|
|
}
|
|
|
|
/// Compacts before a run when the conversation is close to the window.
|
|
///
|
|
/// Threshold only. Recovering a turn that has *already* overflowed means
|
|
/// recognising four providers' distinct context-length errors, which is its
|
|
/// own piece of work — this is what keeps a conversation from getting there.
|
|
private func compactIfNeeded(_ conversation: ConversationModel) async {
|
|
guard let usage = conversation.usage else { return }
|
|
let window = ai.resolve(conversation: conversation.model)?.model.contextLength
|
|
guard
|
|
AgentCompaction.shouldCompact(
|
|
contextTokens: usage.contextTokens, contextWindow: window,
|
|
reserveTokens: compactionBudget.reserveTokens
|
|
)
|
|
else { return }
|
|
await compact(conversation, isManual: false)
|
|
}
|
|
|
|
/// 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.secret(for: resolved.provider.effectiveCredentialRef), !secret.isEmpty
|
|
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) {
|
|
conversation.runDidFinish = { [weak self, weak conversation] in
|
|
guard let self, let conversation,
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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
|
|
)
|
|
}
|
|
|
|
}
|