Clarify agent tool availability contracts
This commit is contained in:
1 parent
771118ecb0
commit
f79c4905d0
17 files changed
+322
-89
No files matched your search
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
))
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<AgentModelStreamEvent, any Error> {
|
||||
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.
|
||||
|
||||
+11
-11
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<String> { 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<AgentModelStreamEvent, any Error> {
|
||||
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
|
||||
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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<String> = [
|
||||
"sftp_upload", "sftp_upload_status", "sftp_download", "sftp_download_status",
|
||||
]
|
||||
let runtimeNames: Set<String> = ["request_user_secret"]
|
||||
let sharedNames = MCPServerTools.exportedNames.subtracting(transferNames).subtracting(runtimeNames)
|
||||
let excludedNames: Set<String> = [
|
||||
"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")
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user