Clarify agent tool availability contracts

This commit is contained in:
Kaede Akino committed 2026-09-23 09:33:15 +08:00
1 parent 771118ecb0
commit f79c4905d0
17 files changed
+322 -89

No files matched your search

+21 -2
View File
@@ -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)
}
}
+41 -6
View File
@@ -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
View File
@@ -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 }
+5 -4
View File
@@ -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
)
}
}
+2 -2
View File
@@ -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
)
}
}
+2 -2
View File
@@ -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
+145 -26
View File
@@ -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
+19 -1
View File
@@ -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"))
}
}
+32 -1
View File
@@ -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")
}