diff --git a/FrameworkTests/AgentSteeringTests.swift b/FrameworkTests/AgentSteeringTests.swift index de047989..1c37fbfe 100644 --- a/FrameworkTests/AgentSteeringTests.swift +++ b/FrameworkTests/AgentSteeringTests.swift @@ -188,8 +188,11 @@ struct AgentSteeringTests { @Test func steeringCapturesTheSendingTerminalAndDoesNotChangeTheRunningContextUntilDelivery() { let conversation = ConversationModel() + let memorySessionID = UUID() let first = AgentTurnContextSnapshot( - sessionContext: "host A", skillCatalog: "fixed skills", planContract: "fixed mode") + sessionContext: "host A", skillCatalog: "fixed skills", planContract: "fixed mode", + toolAvailability: ["terminal_suggest": false, "future_surface_tool": false], + memorySessionID: memorySessionID) conversation.start( runtime: AgentRuntime( model: HangingAgentModel(), tools: [], @@ -201,18 +204,34 @@ struct AgentSteeringTests { conversation.composer.draft = "inspect here" conversation.submitSteeringDraft( context: SessionContext(hostName: "B", workingDirectory: "/srv"), - includesTerminalSuggestion: true) + terminalSuggestionsAvailable: true) let queued = try! #require(conversation.pendingSteering.first?.message) #expect(conversation.activeContextSnapshot == first) #expect(queued.contextSnapshot?.sessionContext?.contains("host_name: B") == true) #expect(queued.contextSnapshot?.skillCatalog == first.skillCatalog) #expect(queued.contextSnapshot?.planContract == first.planContract) + #expect(queued.contextSnapshot?.memorySessionID == memorySessionID) + #expect(queued.contextSnapshot?.toolAvailability?["terminal_suggest"] == true) + #expect(queued.contextSnapshot?.toolAvailability?["future_surface_tool"] == false) conversation.handle(.steeringDelivered(queued)) #expect(conversation.activeContextSnapshot == queued.contextSnapshot) #expect(conversation.transcript.last?.text == "inspect here") conversation.stop() } + @Test + func steeringKeepsLegacyUnknownContextUnknown() { + let conversation = ConversationModel() + startRun(conversation) + #expect(conversation.activeContextSnapshot == nil) + conversation.composer.draft = "inspect here" + + conversation.submitSteeringDraft(terminalSuggestionsAvailable: true) + + #expect(conversation.pendingSteering.first?.message.contextSnapshot == nil) + conversation.stop() + } + private func startRun(_ conversation: ConversationModel) { conversation.start( runtime: AgentRuntime( diff --git a/Frameworks/TermindApplication/Agent/AssistantExecutionController.swift b/Frameworks/TermindApplication/Agent/AssistantExecutionController.swift index 3372dc45..6b2505c0 100644 --- a/Frameworks/TermindApplication/Agent/AssistantExecutionController.swift +++ b/Frameworks/TermindApplication/Agent/AssistantExecutionController.swift @@ -7,7 +7,7 @@ import TermindDomain @MainActor public final class AssistantExecutionController { public typealias RegistryFactory = - @MainActor (ConversationModel, AIModel, ModelProvider, AgentSkillCatalog, AgentEventChannel, Bool) -> + @MainActor (ConversationModel, AIModel, ModelProvider, AgentSkillCatalog, AgentEventChannel) -> AgentToolRegistry private let ai: AIConfigurationModel private let skills: SkillLibraryModel @@ -46,7 +46,7 @@ public final class AssistantExecutionController { public func sendAgentDraft( _ conversation: ConversationModel, context: SessionContext = .none, - includesTerminalSuggestion: Bool = false + terminalSuggestionsAvailable: Bool = false ) { guard authorize() else { return } let draft = conversation.composer.draft.trimmingCharacters(in: .whitespacesAndNewlines) @@ -62,7 +62,7 @@ public final class AssistantExecutionController { let prompt = conversation.appendUserTurnContent( draft, contextSnapshot: contextSnapshot( - for: conversation, context: context, includesTerminalSuggestion: includesTerminalSuggestion)) + for: conversation, context: context, terminalSuggestionsAvailable: terminalSuggestionsAvailable)) 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 @@ -90,7 +90,7 @@ public final class AssistantExecutionController { conversation, promptID: prompt.id, prompt: prompt.text, authoredPrompt: prompt.authoredText, promptImages: prompt.images, promptContextAttachments: prompt.contextAttachments, context: context, - includesTerminalSuggestion: includesTerminalSuggestion + terminalSuggestionsAvailable: terminalSuggestionsAvailable ) } } @@ -103,7 +103,7 @@ public final class AssistantExecutionController { promptImages: [AgentImageAttachment]? = nil, promptContextAttachments: [AgentContextAttachment]? = nil, context: SessionContext? = nil, - includesTerminalSuggestion: Bool = false + terminalSuggestionsAvailable: Bool = false ) { guard authorize(), !conversation.isStreaming, !conversation.isArchived else { return } guard preparingMemory.insert(conversation.id).inserted else { return } @@ -120,14 +120,15 @@ public final class AssistantExecutionController { performStartAgentRun( conversation, promptID: promptID, prompt: prompt, authoredPrompt: authoredPrompt, promptImages: promptImages, promptContextAttachments: promptContextAttachments, - context: context, includesTerminalSuggestion: includesTerminalSuggestion, memorySession: session) + context: context, terminalSuggestionsAvailable: terminalSuggestionsAvailable, memorySession: session + ) } } else { preparingMemory.remove(conversation.id) performStartAgentRun( conversation, promptID: promptID, prompt: prompt, authoredPrompt: authoredPrompt, promptImages: promptImages, promptContextAttachments: promptContextAttachments, - context: context, includesTerminalSuggestion: includesTerminalSuggestion) + context: context, terminalSuggestionsAvailable: terminalSuggestionsAvailable) } } @@ -139,7 +140,7 @@ public final class AssistantExecutionController { promptImages: [AgentImageAttachment]? = nil, promptContextAttachments: [AgentContextAttachment]? = nil, context: SessionContext? = nil, - includesTerminalSuggestion: Bool = false, + terminalSuggestionsAvailable: Bool = false, memorySession: (context: AgentMemoryContext, access: any AgentMemoryAccessing)? = nil ) { guard authorize() else { return } @@ -225,7 +226,7 @@ public final class AssistantExecutionController { ? skills.catalog : AgentSkillCatalog() let baseRegistry = makeRegistry( - conversation, chat.model, chat.provider, skillCatalog, channel, includesTerminalSuggestion) + conversation, chat.model, chat.provider, skillCatalog, channel) let skillRegistry = AgentToolCatalog.registry( builtIn: .init( groups: [.skills, .memory], skills: skillCatalog, skillInventory: skills.inventory, @@ -288,7 +289,8 @@ public final class AssistantExecutionController { let frozenContext = lastUserTurn?.contextSnapshot ?? contextSnapshot( - for: conversation, context: context ?? .none, includesTerminalSuggestion: includesTerminalSuggestion) + for: conversation, context: context ?? .none, + terminalSuggestionsAvailable: terminalSuggestionsAvailable) 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 @@ -384,7 +386,7 @@ public final class AssistantExecutionController { } public func contextSnapshot( - for conversation: ConversationModel, context: SessionContext, includesTerminalSuggestion: Bool + for conversation: ConversationModel, context: SessionContext, terminalSuggestionsAvailable: Bool ) -> AgentTurnContextSnapshot { let catalog = ai.resolve(conversation: conversation.model)?.model.abilities.contains(.toolCall) == true @@ -393,7 +395,7 @@ public final class AssistantExecutionController { sessionContext: context.modelContext, skillCatalog: catalog.isEmpty ? nil : catalog.catalogBlock, planContract: conversation.isPlanning ? TermindAgentPolicy.planContract : nil, - toolAvailability: ["terminal_suggest": includesTerminalSuggestion], + toolAvailability: ["terminal_suggest": terminalSuggestionsAvailable], memorySessionID: memory?.isEnabled == true ? (conversation.transcript.first(where: { $0.contextSnapshot?.memorySessionID != nil })? .contextSnapshot?.memorySessionID ?? conversation.id) : nil diff --git a/Frameworks/TermindApplication/Agent/ConversationModel.swift b/Frameworks/TermindApplication/Agent/ConversationModel.swift index 9083a8ed..cf5ec485 100644 --- a/Frameworks/TermindApplication/Agent/ConversationModel.swift +++ b/Frameworks/TermindApplication/Agent/ConversationModel.swift @@ -1958,12 +1958,16 @@ public final class ConversationModel: Identifiable { /// later be shown a conversation that never happened. The bubble appears /// when the runtime reports the message delivered, which is where the model /// really saw it. - public func submitSteeringDraft(context: SessionContext = .none, includesTerminalSuggestion: Bool = false) { + public func submitSteeringDraft(context: SessionContext = .none, terminalSuggestionsAvailable: Bool = false) { let text = composer.draft.trimmingCharacters(in: .whitespacesAndNewlines) guard canSendDraft, let runtime else { return } let composed = composed(text) var snapshot = activeContextSnapshot?.withSessionContext(context.modelContext) - snapshot?.toolAvailability = ["terminal_suggest": includesTerminalSuggestion] + if snapshot != nil { + var availability = snapshot?.toolAvailability ?? [:] + availability["terminal_suggest"] = terminalSuggestionsAvailable + snapshot?.toolAvailability = availability + } let pending = PendingSteeringMessage( draft: text, attachments: composer.attachments, message: AgentTranscriptMessage( diff --git a/Frameworks/TermindRemoteInfrastructure/Agent/AgentBuiltInSSHTools.swift b/Frameworks/TermindRemoteInfrastructure/Agent/AgentBuiltInSSHTools.swift index e0668d37..01d938ff 100644 --- a/Frameworks/TermindRemoteInfrastructure/Agent/AgentBuiltInSSHTools.swift +++ b/Frameworks/TermindRemoteInfrastructure/Agent/AgentBuiltInSSHTools.swift @@ -13,9 +13,10 @@ nonisolated public struct ListHostsTool: AgentToolDefinition, AgentToolSchemaBui Self.descriptor( "list_hosts", """ - List Vault Hosts and temporary terminal targets without credentials, including each target's enabled \ - protocol list. SSH and SFTP tools require a returned protocol entry named ssh; Telnet targets are \ - discoverable but no Agent Telnet execution tool is currently available. + List Vault Hosts and temporary terminal targets without credentials, even when no session context \ + is available, including each target's enabled protocol list. SSH and SFTP tools require a returned \ + protocol entry named ssh; Telnet targets are discoverable but no Agent Telnet execution tool is currently \ + available. """, properties: [:], required: [], target: .host, approvalPolicy: .approve, concurrency: .parallel, @@ -132,7 +133,8 @@ nonisolated public struct TerminalSuggestTool: AgentToolDefinition, AgentToolSch """ Create a command card when the user wants a suggestion or needs to interact with a PTY. \ Only the user can insert or run the card. This tool does not execute commands or return \ - execution output. To run a remote command yourself, use ssh_exec. + execution output, and the sending surface may mark it unavailable at execution time. To run a remote \ + command yourself, use ssh_exec. """, properties: [ "command": Self.string(max: 32_768), diff --git a/Frameworks/TermindRemoteInfrastructure/Agent/AssistantToolRegistry.swift b/Frameworks/TermindRemoteInfrastructure/Agent/AssistantToolRegistry.swift index 45c979ca..01839111 100644 --- a/Frameworks/TermindRemoteInfrastructure/Agent/AssistantToolRegistry.swift +++ b/Frameworks/TermindRemoteInfrastructure/Agent/AssistantToolRegistry.swift @@ -11,8 +11,7 @@ public enum AssistantToolRegistry { skills skillCatalog: AgentSkillCatalog, channel: AgentEventChannel, ai: AIConfigurationModel, hosts: AgentHostResolver, metrics: any MetricsSnapshotReading, connections: AgentConnectionPool, - authentication: AgentSSHAuthenticationRegistry, background: AgentBackgroundCommands, - includesTerminalSuggestion: Bool = false + authentication: AgentSSHAuthenticationRegistry, background: AgentBackgroundCommands ) -> AgentToolRegistry { let native = ai.webSearch.isEnabled && ai.webSearch.prefersBuiltIn && provider.supportsNativeWebSearch(model: model) @@ -60,7 +59,6 @@ public enum AssistantToolRegistry { hosts: hosts, metrics: metrics, connections: connections, authentication: authentication, background: background, - includesTerminalSuggestion: includesTerminalSuggestion, workspace: scratch )) ) diff --git a/Frameworks/TermindRemoteInfrastructure/Agent/TermindAgentTools.swift b/Frameworks/TermindRemoteInfrastructure/Agent/TermindAgentTools.swift index c265c57a..45d8fd02 100644 --- a/Frameworks/TermindRemoteInfrastructure/Agent/TermindAgentTools.swift +++ b/Frameworks/TermindRemoteInfrastructure/Agent/TermindAgentTools.swift @@ -15,7 +15,6 @@ nonisolated public struct TermindAgentToolEnvironment: Sendable { public var connections: AgentConnectionPool public var authentication: AgentSSHAuthenticationRegistry public var background: AgentBackgroundCommands - public var includesTerminalSuggestion: Bool /// Shared with the runtime's scratch group. `scratch_pull` and /// `scratch_push` are ours rather than the runtime's precisely because they /// are the two ends that touch a host, and they must stage into the same @@ -28,7 +27,6 @@ nonisolated public struct TermindAgentToolEnvironment: Sendable { connections: AgentConnectionPool, authentication suppliedAuthentication: AgentSSHAuthenticationRegistry? = nil, background: AgentBackgroundCommands = AgentBackgroundCommands(), - includesTerminalSuggestion: Bool = false, workspace: AgentScratchWorkspace? = nil ) { self.metrics = metrics @@ -37,7 +35,6 @@ nonisolated public struct TermindAgentToolEnvironment: Sendable { authentication = suppliedAuthentication ?? AgentSSHAuthenticationRegistry(resolver: hosts) self.background = background - self.includesTerminalSuggestion = includesTerminalSuggestion self.workspace = workspace } } diff --git a/TFI/Application/Agent/MobileAssistantModel.swift b/TFI/Application/Agent/MobileAssistantModel.swift index bd021200..c5295634 100644 --- a/TFI/Application/Agent/MobileAssistantModel.swift +++ b/TFI/Application/Agent/MobileAssistantModel.swift @@ -11,6 +11,8 @@ import TermindRemoteInfrastructure @MainActor @Observable final class MobileAssistantModel { + static let terminalSuggestionsAvailable = false + let store = AssistantConversationStore() let speech: SpeechTranscriptionCoordinator let configuration: AIConfigurationModel @@ -63,7 +65,7 @@ final class MobileAssistantModel { execution = AssistantExecutionController( ai: configuration, skills: skills, repository: repository, memory: memory, authorize: authorize, - makeRegistry: { conversation, model, provider, catalog, channel, _ in + makeRegistry: { conversation, model, provider, catalog, channel in AssistantToolRegistry.make( conversation: conversation, model: model, provider: provider, skills: catalog, channel: channel, ai: configuration, hosts: resolver, metrics: metrics, @@ -116,16 +118,19 @@ final class MobileAssistantModel { !conversation.isCompacting, conversation.composer.pendingAttachments.isEmpty, authorize() else { return } if conversation.isStreaming { - conversation.submitSteeringDraft() + conversation.submitSteeringDraft( + terminalSuggestionsAvailable: Self.terminalSuggestionsAvailable) return } guard conversation.canSendDraft else { return } if library.selectedConversation == nil { guard let started = library.startDraftConversation() else { return } configure(library.draftConversation) - execution.startAgentRun(started) + execution.startAgentRun( + started, terminalSuggestionsAvailable: Self.terminalSuggestionsAvailable) } else { - execution.sendAgentDraft(conversation) + execution.sendAgentDraft( + conversation, terminalSuggestionsAvailable: Self.terminalSuggestionsAvailable) } } @@ -211,12 +216,14 @@ final class MobileAssistantModel { let prompt = try await operations.edit( conversation, messageID: messageID, text: text, contextSnapshot: execution.contextSnapshot( - for: conversation, context: .none, includesTerminalSuggestion: false)) + for: conversation, context: .none, + terminalSuggestionsAvailable: Self.terminalSuggestionsAvailable)) else { return } execution.startAgentRun( conversation, promptID: prompt.id, prompt: prompt.text, authoredPrompt: prompt.authoredText, promptImages: prompt.images, - promptContextAttachments: prompt.contextAttachments) + promptContextAttachments: prompt.contextAttachments, + terminalSuggestionsAvailable: Self.terminalSuggestionsAvailable) } } diff --git a/TFITests/MobileAssistantTests.swift b/TFITests/MobileAssistantTests.swift index 5c844b3a..262124eb 100644 --- a/TFITests/MobileAssistantTests.swift +++ b/TFITests/MobileAssistantTests.swift @@ -224,12 +224,39 @@ struct MobileAssistantTests { #expect(conversation.composer.draft.isEmpty) #expect(conversation.messages.map(\.text).contains("Follow-up question")) + #expect(conversation.transcript.last?.contextSnapshot?.toolAvailability?["terminal_suggest"] == false) #expect(await eventually { conversation.runState == .failed }) #expect(conversation.messages.map(\.role) == [.user, .user]) #expect(conversation.transcript.map(\.role) == [.user, .user]) try expectMissingModelFailure(in: conversation) } + @Test func steeringFromMobileSceneDisablesTerminalSuggestionsInTheQueuedSnapshot() async throws { + let app = try MobileAppModel(inMemory: true) + let assistant = app.assistant.makeSceneModel() + let conversation = ConversationModel() + assistant.library.insertAndSelect(conversation) + conversation.start( + runtime: AgentRuntime( + model: MobileDelayedAgentModel(), tools: [], + approval: AgentApprovalBroker(reviewer: nil, manualApproval: { _ in .allow }) + ), + request: AgentRunRequest( + conversationID: conversation.id, prompt: "start", + permissionMode: .askForApproval, + contextSnapshot: .init( + toolAvailability: ["terminal_suggest": true, "future_surface_tool": false])) + ) + conversation.composer.draft = "Inspect again" + + assistant.send() + + let snapshot = try #require(conversation.pendingSteering.first?.message.contextSnapshot) + #expect(snapshot.toolAvailability?["terminal_suggest"] == false) + #expect(snapshot.toolAvailability?["future_surface_tool"] == false) + #expect(await eventually { conversation.runState.isTerminal }) + } + @Test func slashCatalogShowsAlphabetizedCompactPlanAndSkillCommandsOnMobile() throws { let app = try MobileAppModel(inMemory: true) let assistant = app.assistant.makeSceneModel() @@ -343,12 +370,11 @@ struct MobileAssistantTests { model.abilities = [.toolCall] let provider = ModelProvider(name: "Test") let conversation = ConversationModel() - func registry(_ model: AIModel, includesTerminalSuggestion: Bool = false) -> AgentToolRegistry { + func registry(_ model: AIModel) -> AgentToolRegistry { AssistantToolRegistry.make( conversation: conversation, model: model, provider: provider, skills: app.skills.catalog, channel: channel, ai: app.ai, hosts: hosts, metrics: app.metrics.core, - connections: connections, authentication: authentication, background: background, - includesTerminalSuggestion: includesTerminalSuggestion) + connections: connections, authentication: authentication, background: background) } let mobileDescriptors = registry(model).descriptors let names = Set(mobileDescriptors.map(\.name)) @@ -359,8 +385,6 @@ struct MobileAssistantTests { ] { #expect(names.contains(name), "Missing tool: \(name)") } - // Surface availability is enforced at execution, not by changing the cached tool definitions. - #expect(mobileDescriptors == registry(model, includesTerminalSuggestion: true).descriptors) let exec = try #require(mobileDescriptors.first { $0.name == "ssh_exec" }) #expect(exec.summary.contains("discover host_id with list_hosts")) #expect(exec.summary.contains("do not call request_user_secret for SSH login")) @@ -370,7 +394,6 @@ struct MobileAssistantTests { ) model.abilities = [] #expect(registry(model).descriptors.isEmpty) - #expect(registry(model, includesTerminalSuggestion: true).descriptors.isEmpty) } @Test func backgroundExpirationBlocksNewRunsUntilCleanupCompletes() async throws { @@ -408,6 +431,18 @@ struct MobileAssistantTests { } } +private struct MobileDelayedAgentModel: AgentModelStreaming { + func stream(_ request: AgentModelRequest) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + Task { + try? await Task.sleep(for: .milliseconds(200)) + continuation.yield(.finished(.completed)) + continuation.finish() + } + } + } +} + @MainActor private func expectMissingModelFailure(in conversation: ConversationModel) throws { // In-memory apps have no Chat model. That must produce a failure row, not an authored answer. diff --git a/TFM/App/AppModel.swift b/TFM/App/AppModel.swift index 9a497715..2b3e42af 100644 --- a/TFM/App/AppModel.swift +++ b/TFM/App/AppModel.swift @@ -394,13 +394,13 @@ final class AppModel { @ObservationIgnored private lazy var assistantExecution = AssistantExecutionController( ai: ai, skills: skills, repository: agentRunRepository, memory: memory, authorize: { [weak self] in self?.proAccess.require(.assistant) == true }, - makeRegistry: { [weak self] conversation, model, provider, skills, channel, includes in + makeRegistry: { [weak self] conversation, model, provider, skills, channel in guard let self else { return AgentToolRegistry([AnyAgentTool]()) } return AssistantToolRegistry.make( conversation: conversation, model: model, provider: provider, skills: skills, channel: channel, ai: ai, hosts: agentHosts, metrics: serverMetrics, connections: agentConnections, authentication: agentAuthentication, - background: agentBackgroundCommands, includesTerminalSuggestion: includes) + background: agentBackgroundCommands) }, title: { [weak self] context in guard let self else { throw CancellationError() } @@ -410,23 +410,23 @@ final class AppModel { func sendAgentDraft( _ conversation: ConversationModel, context: SessionContext = .none, - includesTerminalSuggestion: Bool = false + terminalSuggestionsAvailable: Bool = false ) { assistantExecution.sendAgentDraft( - conversation, context: context, includesTerminalSuggestion: includesTerminalSuggestion) + conversation, context: context, terminalSuggestionsAvailable: terminalSuggestionsAvailable) } func startAgentRun( _ conversation: ConversationModel, promptID: UUID? = nil, prompt: String? = nil, authoredPrompt: String? = nil, promptImages: [AgentImageAttachment]? = nil, promptContextAttachments: [AgentContextAttachment]? = nil, - context: SessionContext? = nil, includesTerminalSuggestion: Bool = false + context: SessionContext? = nil, terminalSuggestionsAvailable: Bool = false ) { assistantExecution.startAgentRun( conversation, promptID: promptID, prompt: prompt, authoredPrompt: authoredPrompt, promptImages: promptImages, promptContextAttachments: promptContextAttachments, - context: context, includesTerminalSuggestion: includesTerminalSuggestion) + context: context, terminalSuggestionsAvailable: terminalSuggestionsAvailable) } @discardableResult @@ -477,10 +477,10 @@ final class AppModel { fork: { [weak self, weak window] id, messageID, inSidebar in self?.forkConversation(id, at: messageID, selectingIn: window, inSidebar: inSidebar) }, - edit: { [weak self, weak window] id, messageID, text, context, includes in + edit: { [weak self, weak window] id, messageID, text, context, terminalSuggestionsAvailable in self?.editConversation( id, messageID: messageID, text: text, context: context, - includesTerminalSuggestion: includes, reportingTo: window + terminalSuggestionsAvailable: terminalSuggestionsAvailable, reportingTo: window ) } ) @@ -597,7 +597,7 @@ final class AppModel { private func editConversation( _ id: UUID, messageID: UUID, text value: String, - context: SessionContext, includesTerminalSuggestion: Bool, reportingTo window: WindowModel? + context: SessionContext, terminalSuggestionsAvailable: Bool, reportingTo window: WindowModel? ) { guard let conversation = conversation(id) else { return } Task { [weak window] in @@ -607,13 +607,13 @@ final class AppModel { conversation, messageID: messageID, text: value, contextSnapshot: assistantExecution.contextSnapshot( for: conversation, context: context, - includesTerminalSuggestion: includesTerminalSuggestion)) + terminalSuggestionsAvailable: terminalSuggestionsAvailable)) else { return } startAgentRun( conversation, promptID: prompt.id, prompt: prompt.text, authoredPrompt: prompt.authoredText, promptImages: prompt.images, promptContextAttachments: prompt.contextAttachments, - context: context, includesTerminalSuggestion: includesTerminalSuggestion) + context: context, terminalSuggestionsAvailable: terminalSuggestionsAvailable) } catch { window?.assistantOperationError = error.localizedDescription } } } diff --git a/TFM/Application/MCPServer/MCPServerTools.swift b/TFM/Application/MCPServer/MCPServerTools.swift index a23dd78b..91061e60 100644 --- a/TFM/Application/MCPServer/MCPServerTools.swift +++ b/TFM/Application/MCPServer/MCPServerTools.swift @@ -206,7 +206,7 @@ final class MCPServerTools { AnyAgentTool(MCPDownloadTool(downloads: downloads)), AnyAgentTool(MCPDownloadStatusTool(downloads: downloads)), ] - ).filtering { + ).available(in: .acting).filtering { exportedNames.contains($0.name) && !disabledTools.contains($0.name) } } diff --git a/TFM/Features/AI/ConversationTranscriptView.swift b/TFM/Features/AI/ConversationTranscriptView.swift index 2b6b2ffc..cce59ecd 100644 --- a/TFM/Features/AI/ConversationTranscriptView.swift +++ b/TFM/Features/AI/ConversationTranscriptView.swift @@ -171,7 +171,7 @@ struct ConversationView: View { conversation: conversation, maxContentWidth: isCompact ? nil : Metrics.Chat.contentMaxWidth, sessionContext: sessionContext, - includesTerminalSuggestion: showsTerminalActions, + terminalSuggestionsAvailable: showsTerminalActions, onNewChat: onNewChat, onSend: onSend ) @@ -321,7 +321,7 @@ private struct ConversationTranscriptSurface: View { messageID: id, text: text, context: sessionContext, - includesTerminalSuggestion: showsTerminalActions + terminalSuggestionsAvailable: showsTerminalActions ) }, forkAssistantMessage: { id in @@ -341,7 +341,7 @@ private struct ConversationTranscriptSurface: View { conversation.appendUserTurn(prompt) app.startAgentRun( conversation, prompt: prompt, context: sessionContext, - includesTerminalSuggestion: showsTerminalActions) + terminalSuggestionsAvailable: showsTerminalActions) }, canDeliverCommand: { guard let workspace = window.selectedWorkspace else { return false } diff --git a/TFM/Features/AI/ConversationViews.swift b/TFM/Features/AI/ConversationViews.swift index a80c451c..ac2dae56 100644 --- a/TFM/Features/AI/ConversationViews.swift +++ b/TFM/Features/AI/ConversationViews.swift @@ -506,8 +506,9 @@ struct ComposerView: View { /// Defaulting to `.none` is what makes a composer outside a Workspace — the /// Assistant destination — session-free by construction rather than by remembering. var sessionContext: SessionContext = .none - /// Only the Workspace sidebar gives its agent a command-card tool. - var includesTerminalSuggestion = false + /// The registry stays stable across surfaces; the Workspace sidebar permits + /// command-card execution while standalone Assistant surfaces do not. + var terminalSuggestionsAvailable = false var onNewChat: (() -> Void)? var onSend: (() -> Void)? @@ -992,7 +993,7 @@ struct ComposerView: View { } if conversation.isStreaming { conversation.submitSteeringDraft( - context: sessionContext, includesTerminalSuggestion: includesTerminalSuggestion) + context: sessionContext, terminalSuggestionsAvailable: terminalSuggestionsAvailable) return } if let onSend { @@ -1000,7 +1001,7 @@ struct ComposerView: View { } else { app.sendAgentDraft( conversation, context: sessionContext, - includesTerminalSuggestion: includesTerminalSuggestion + terminalSuggestionsAvailable: terminalSuggestionsAvailable ) } } diff --git a/TFM/Features/Assistant/AssistantView.swift b/TFM/Features/Assistant/AssistantView.swift index 2f94ff4f..fe78a78b 100644 --- a/TFM/Features/Assistant/AssistantView.swift +++ b/TFM/Features/Assistant/AssistantView.swift @@ -213,12 +213,12 @@ struct AssistantPanelView: View { conversation: window.sidebar.assistantDraft, prompt: "Ask anything…", sessionContext: window.sidebar.sessionContext, - includesTerminalSuggestion: true, + terminalSuggestionsAvailable: true, onSend: { if let conversation = window.sendAssistantSidebarDraft() { app.startAgentRun( conversation, context: window.sidebar.sessionContext, - includesTerminalSuggestion: true + terminalSuggestionsAvailable: true ) } } diff --git a/TFM/Features/Workspaces/WindowModel.swift b/TFM/Features/Workspaces/WindowModel.swift index 1c500113..5d005c17 100644 --- a/TFM/Features/Workspaces/WindowModel.swift +++ b/TFM/Features/Workspaces/WindowModel.swift @@ -743,9 +743,9 @@ final class WindowModel { } func editAssistantUserMessage( _ id: ConversationModel.ID, messageID: UUID, text: String, - context: SessionContext = .none, includesTerminalSuggestion: Bool = false + context: SessionContext = .none, terminalSuggestionsAvailable: Bool = false ) { - assistantCommands?.edit(id, messageID, text, context, includesTerminalSuggestion) + assistantCommands?.edit(id, messageID, text, context, terminalSuggestionsAvailable) } @discardableResult diff --git a/TFMTests/AgentBuiltInToolAvailabilityTests.swift b/TFMTests/AgentBuiltInToolAvailabilityTests.swift index 7511a1a4..bcb318c7 100644 --- a/TFMTests/AgentBuiltInToolAvailabilityTests.swift +++ b/TFMTests/AgentBuiltInToolAvailabilityTests.swift @@ -11,11 +11,66 @@ import Testing @testable import Termind +private actor SurfaceToolExecutionLog { + private var names: [String] = [] + + func record(_ name: String) { names.append(name) } + func snapshot() -> Set { Set(names) } +} + +private actor SurfaceAvailabilityModel: AgentModelStreaming { + private var turn = 0 + + private func nextTurn() -> Int { + defer { turn += 1 } + return turn + } + + nonisolated func stream( + _ request: AgentModelRequest + ) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + Task { + if await nextTurn() == 0 { + for (index, name) in ["terminal_suggest", "ssh_exec", "sftp_read"].enumerated() { + continuation.yield( + .toolCallSnapshot( + id: "call-\(index)", providerItemID: nil, + name: name, arguments: "{}")) + } + continuation.yield(.finished(.toolCalls)) + } else { + continuation.yield(.textDelta("done")) + continuation.yield(.finished(.completed)) + } + continuation.finish() + } + } + } +} + +private func surfaceTool(_ name: String, log: SurfaceToolExecutionLog) -> AnyAgentTool { + AnyAgentTool( + descriptor: AgentToolDescriptor( + name: name, summary: name, + inputSchema: .object([ + "type": .string("object"), "properties": .object([:]), + "required": .array([]), "additionalProperties": .bool(false), + ]), + target: .host, approvalPolicy: .approve, concurrency: .parallel + ), + execute: { invocation, _ in + await log.record(name) + return AgentToolResult(callID: invocation.call.id, content: "ok") + } + ) +} + @Suite @MainActor struct AgentBuiltInToolAvailabilityTests { @Test - func terminalSuggestionDefinitionIsStableAcrossSurfaces() throws { + func termindCatalogHasStableDefinitionsAndExactRunModeSets() async throws { let hosts = AgentHostResolver(store: HostLibraryModel()) let connections = AgentConnectionPool( configuration: { _ in throw AgentToolError.invalidHost }, @@ -24,46 +79,110 @@ struct AgentBuiltInToolAvailabilityTests { openSFTP: { _ in throw AgentToolError.invalidHost }, sweepInterval: nil ) + let workspace = AgentScratchWorkspace( + conversationID: UUID(), base: FileManager.default.temporaryDirectory + ) - let mainAssistant = TermindAgentTools.tools( + let catalog = TermindAgentTools.tools( in: .init( - hosts: hosts, metrics: TestMetricsReader(), connections: connections - ) - ).map(\.descriptor) - let workspaceSidebar = TermindAgentTools.tools( - in: .init( - hosts: hosts, metrics: TestMetricsReader(), connections: connections, includesTerminalSuggestion: true + hosts: hosts, metrics: TestMetricsReader(), connections: connections, + workspace: workspace ) ).map(\.descriptor) - #expect(mainAssistant.contains { $0.name == "get_metrics" }) - #expect(workspaceSidebar.contains { $0.name == "get_metrics" }) - #expect(mainAssistant.contains { $0.name == "terminal_suggest" }) - #expect(mainAssistant == workspaceSidebar) - #expect(workspaceSidebar.contains { $0.name == "terminal_suggest" }) + #expect(catalog.contains { $0.name == "get_metrics" }) + #expect(catalog.contains { $0.name == "terminal_suggest" }) let registry = AgentToolRegistry( TermindAgentTools.tools( in: .init( - hosts: hosts, metrics: TestMetricsReader(), connections: connections + hosts: hosts, metrics: TestMetricsReader(), connections: connections, + workspace: workspace ) ) ) - let planning = Set(registry.available(in: .planning).descriptors.map(\.name)) - let acting = Set(registry.available(in: .acting).descriptors.map(\.name)) + let planningRegistry = registry.available(in: .planning) + let actingRegistry = registry.available(in: .acting) + let planning = Set(planningRegistry.descriptors.map(\.name)) + let acting = Set(actingRegistry.descriptors.map(\.name)) #expect(registry.available(in: .reviewing).descriptors.isEmpty) - #expect(planning.contains("ssh_exec")) - #expect(!planning.contains("sftp_write")) - #expect(!planning.contains("sftp_copy")) - #expect(acting.contains("sftp_copy")) - #expect(acting.isSuperset(of: ["ssh_exec", "sftp_write"])) - let exec = try #require(mainAssistant.first { $0.name == "ssh_exec" }) + #expect( + planning == [ + "get_metrics", "list_hosts", "ssh_exec", "ssh_wait", "terminal_suggest", + "sftp_list", "sftp_stat", "sftp_read", "scratch_pull", + ]) + #expect( + acting == [ + "get_metrics", "list_hosts", "ssh_exec", "ssh_wait", "terminal_suggest", + "sftp_list", "sftp_stat", "sftp_read", "sftp_write", "sftp_mkdir", + "sftp_move", "sftp_copy", "sftp_chmod", "sftp_delete", "scratch_pull", + "scratch_push", + ]) + let exec = try #require(catalog.first { $0.name == "ssh_exec" }) #expect(registry.available(in: .planning).descriptors.first { $0.name == "ssh_exec" } == exec) #expect(exec.summary.contains("discover host_id with list_hosts")) #expect(exec.summary.contains("do not call request_user_secret for SSH login")) #expect( - mainAssistant.first { $0.name == "list_hosts" }?.summary.contains("no session context") == true + catalog.first { $0.name == "list_hosts" }?.summary.contains("no session context") == true ) + + let planningExec = try #require(planningRegistry["ssh_exec"]) + let actingExec = try #require(actingRegistry["ssh_exec"]) + let hostID = UUID() + func invocation(_ command: String) -> AgentToolInvocation { + AgentToolInvocation( + runID: UUID(), + call: AgentToolCall( + id: UUID().uuidString, name: "ssh_exec", + arguments: .object([ + "host_id": .string(hostID.uuidString), "command": .string(command), + ]))) + } + + let destructive = invocation("rm -rf /var/log/nginx") + #expect(try await planningExec.preflight(destructive).approvalPolicy == .deny) + #expect(try await actingExec.preflight(destructive).approvalPolicy == .ask) + + let read = invocation("systemctl status nginx") + let planningRead = try await planningExec.preflight(read) + let actingRead = try await actingExec.preflight(read) + #expect(planningRead.approvalPolicy == .approve) + #expect(planningRead.concurrency == .parallel) + #expect(actingRead.approvalPolicy == .approve) + #expect(actingRead.concurrency == .parallel) + } + + @Test(arguments: [false, true]) + func contextSnapshotGatesOnlyTerminalSuggestionsAtExecution(terminalSuggestionsAvailable: Bool) async { + let log = SurfaceToolExecutionLog() + let runtime = AgentRuntime( + model: SurfaceAvailabilityModel(), + tools: [ + surfaceTool("terminal_suggest", log: log), + surfaceTool("ssh_exec", log: log), + surfaceTool("sftp_read", log: log), + ], + approval: AgentApprovalBroker(reviewer: nil, manualApproval: { _ in .allow }) + ) + var results: [String: AgentToolResult] = [:] + for await event in await runtime.start( + AgentRunRequest( + conversationID: UUID(), prompt: "inspect", permissionMode: .askForApproval, + contextSnapshot: .init( + toolAvailability: ["terminal_suggest": terminalSuggestionsAvailable])) + ) { + if case .toolFinished(let invocation, let result) = event { + results[invocation.call.name] = result + } + } + + let executed = await log.snapshot() + #expect(executed.contains("ssh_exec")) + #expect(executed.contains("sftp_read")) + #expect(results["ssh_exec"]?.isError == false) + #expect(results["sftp_read"]?.isError == false) + #expect(executed.contains("terminal_suggest") == terminalSuggestionsAvailable) + #expect(results["terminal_suggest"]?.isError == !terminalSuggestionsAvailable) } @Test @@ -100,10 +219,10 @@ struct AgentBuiltInToolAvailabilityTests { } @Test(arguments: [true, false]) - func standaloneContextKeepsHostDiscoveryInstructions(includesTerminalSuggestion: Bool) { + func standaloneContextKeepsHostDiscoveryInstructions(terminalSuggestionsAvailable: Bool) { let snapshot = AgentTurnContextSnapshot( sessionContext: SessionContext.none.modelContext, - toolAvailability: ["terminal_suggest": includesTerminalSuggestion] + toolAvailability: ["terminal_suggest": terminalSuggestionsAvailable] ) let context = AgentContextSnapshotReplay().transform( .init( @@ -143,7 +262,7 @@ struct AgentBuiltInToolAvailabilityTests { additional: TermindAgentTools.tools( in: .init( hosts: hosts, metrics: TestMetricsReader(), connections: connections, - includesTerminalSuggestion: true, workspace: workspace + workspace: workspace )) ).descriptors diff --git a/TFMTests/AgentHostProtocolListingTests.swift b/TFMTests/AgentHostProtocolListingTests.swift index 0cdf0ae4..ab69e707 100644 --- a/TFMTests/AgentHostProtocolListingTests.swift +++ b/TFMTests/AgentHostProtocolListingTests.swift @@ -1,3 +1,5 @@ +import AgentKit +import Foundation import TermindApplication import TermindDomain import TermindRemoteInfrastructure @@ -6,7 +8,7 @@ import Testing @Suite @MainActor struct AgentHostProtocolListingTests { @Test - func listingIncludesTelnetOnlyHostsWithProtocolMetadata() { + func listingIncludesTelnetOnlyHostsWithProtocolMetadata() async throws { let ssh = Host(name: "SSH", host: "ssh.invalid") let telnet = Host( name: "Telnet", host: "telnet.invalid", @@ -18,5 +20,21 @@ struct AgentHostProtocolListingTests { #expect(listing.hosts[0].protocols.map(\.name) == [.ssh]) #expect(listing.hosts[1].protocols.map(\.name) == [.telnet]) #expect(!resolver.canResolve(telnet.id)) + + let invocation = AgentToolInvocation( + runID: UUID(), + call: .init(id: "list", name: "list_hosts", arguments: .object([:]))) + let result = try await ListHostsTool(hosts: resolver).execute( + invocation, + context: AgentToolExecutionContext( + runID: invocation.runID, userIntent: "Discover hosts", + secretBroker: SecretBroker(), userInteraction: UnavailableAgentUserInteraction(), + preflightMetadata: [:], authorize: { _ in .allow })) + let payload = try #require(result.untrustedPayload) + let hosts = try #require( + try AgentJSONValue.decode(Data(payload.utf8)).objectValue?["hosts"]?.arrayValue) + let telnetJSON = try #require( + hosts.first { $0.objectValue?["id"] == .string(telnet.id.uuidString) }?.objectValue) + #expect(telnetJSON["protocols"]?.arrayValue?.first?.objectValue?["name"] == .string("telnet")) } } diff --git a/TFMTests/MCPServerToolsTests.swift b/TFMTests/MCPServerToolsTests.swift index 138e76a1..4b54d3e4 100644 --- a/TFMTests/MCPServerToolsTests.swift +++ b/TFMTests/MCPServerToolsTests.swift @@ -97,12 +97,43 @@ struct MCPServerToolsTests { } @Test - func exportsTheCompleteHostToolSet() { + func exportsTheCompleteActingHostSubsetAndTransferTools() { let server = makeMCPServerTools() let descriptors = server.descriptors + let resolver = AgentHostResolver(store: HostLibraryModel()) + let connections = AgentConnectionPool( + configuration: { _ in throw MCPTestFailure.unused }, + routeFingerprint: { _ in throw MCPTestFailure.unused }, + openConnection: { _ in throw MCPTestFailure.unused }, + openSFTP: { _ in throw MCPTestFailure.unused }, + sweepInterval: nil + ) + let acting = AgentToolRegistry( + TermindAgentTools.tools( + in: .init( + hosts: resolver, metrics: TestMetricsReader(), connections: connections, + authentication: AgentSSHAuthenticationRegistry(resolver: resolver), + background: AgentBackgroundCommands(sweepInterval: nil) + )) + ).available(in: .acting) + let transferNames: Set = [ + "sftp_upload", "sftp_upload_status", "sftp_download", "sftp_download_status", + ] + let runtimeNames: Set = ["request_user_secret"] + let sharedNames = MCPServerTools.exportedNames.subtracting(transferNames).subtracting(runtimeNames) + let excludedNames: Set = [ + "terminal_suggest", "scratch_pull", "scratch_push", "scratch_list", "scratch_read", + "scratch_write", "scratch_edit", "scratch_diff", "scratch_delete", "fetch", "web_search", + "skill_read_file", "skill_install", "skill_manage", "present_plan", "manage_tasks", + ] #expect(descriptors.count == 18) #expect(Set(descriptors.map(\.name)) == MCPServerTools.exportedNames) + #expect(excludedNames.isDisjoint(with: MCPServerTools.exportedNames)) + #expect(sharedNames.isSubset(of: Set(acting.descriptors.map(\.name)))) + for name in sharedNames { + #expect(server.descriptors.first { $0.name == name } == acting[name]?.descriptor) + } for descriptor in descriptors { #expect(descriptor.inputSchema.objectValue?["type"]?.stringValue == "object") }