Files
Termind/Frameworks/TermindRemoteInfrastructure/Agent/AgentBuiltInSSHTools.swift
T
Kaede Akino d41f236220 refactor: centralize tool names in TermindToolName registry
Tool names are protocol surface: they appear in the MCP export list,
persisted disabled-tool preferences, audit records, and presentation
registries. Spelling them as literals at each of those sites let one
place drift from another with no compile error to catch it.

Add TermindToolName as the single source of truth for the 25 names
Termind owns, and route every non-documentation reference through it.
The registry lives in TermindApplication because presentation cannot
import TermindRemoteInfrastructure, and TermindApplication cannot depend
on it either.

Names owned by AgentKit (request_user_secret, present_plan, the
scratch_* build tools) and retired aliases (ssh_exec, ssh_wait) stay as
literals, as do the prose in MCP instructions and descriptor summaries.
The legacyToolNames compatibility list is a historical data contract and
is unchanged.

This also removes the sshExecRunToolName and sshExecStdinToolName
constants from TermindAgentPolicy, which the registry replaces.
2026-10-18 18:20:51 +08:00

261 lines
11 KiB
Swift

import AgentKit
import Foundation
import TermindApplication
import TermindDomain
nonisolated public struct ListHostsTool: AgentToolDefinition, AgentToolSchemaBuilding {
public static let presenter = AgentToolDetailPresenter(
id: "builtin.list_hosts", present: present
)
public let hosts: AgentHostResolver
public var descriptor: AgentToolDescriptor {
Self.descriptor(
TermindToolName.listHosts,
"""
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,
presentation: .init(
symbol: "server.rack", activity: .semanticLabel(.hosts), output: .json,
actionKind: .list
)
)
}
public func execute(_ invocation: AgentToolInvocation, context: AgentToolExecutionContext) async throws
-> AgentToolResult
{
let listing = await hosts.listing()
let rows = listing.hosts.map { target in
let protocols = target.protocols.map { connectionProtocol in
AgentJSONValue.object([
"name": .string(connectionProtocol.name.rawValue),
"address": .string(connectionProtocol.address),
"connection_state": .string(connectionProtocol.connectionState),
])
}
return AgentJSONValue.object([
"id": .string(target.id.uuidString), "display_name": .string(target.displayName),
"address": .string(target.address), "tags": .array(target.tags.map(AgentJSONValue.string)),
"connection_state": .string(target.connectionState), "temporary": .bool(target.isTemporary),
"group_id": target.groupID.map { .string($0.uuidString) } ?? .null,
"group_path": target.groupPath.map(AgentJSONValue.string) ?? .null,
"preferred_terminal_protocol": .string(target.preferredTerminalProtocol.rawValue),
"protocols": .array(protocols),
])
}
func groupJSON(_ group: AgentHostGroupTarget) -> AgentJSONValue {
.object([
"id": .string(group.id.uuidString), "name": .string(group.name),
"path": .string(group.path), "children": .array(group.children.map(groupJSON)),
])
}
return Self.result(
invocation,
.object([
"groups": .array(listing.groups.map(groupJSON)), "hosts": .array(rows),
]))
}
public init(hosts: AgentHostResolver) {
self.hosts = hosts
}
}
nonisolated public struct TerminalSuggestTool: AgentToolDefinition, AgentToolSchemaBuilding {
public static let presenter = AgentToolDetailPresenter(
id: "builtin.terminal_suggest", present: present
)
public var descriptor: AgentToolDescriptor {
Self.descriptor(
TermindToolName.terminalSuggest,
"""
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, and the sending surface may mark it unavailable at execution time. To run a remote \
command yourself, use ssh_exec_run.
""",
properties: [
"command": Self.string(max: 32_768)
],
required: ["command"], target: .user, approvalPolicy: .approve,
concurrency: .parallel,
presentation: .init(
symbol: "terminal", activity: .semanticLabel(.suggestion),
groupsWithAdjacentTools: false, actionKind: .provide
)
)
}
public func execute(_ invocation: AgentToolInvocation, context: AgentToolExecutionContext) async throws
-> AgentToolResult
{
let arguments = try Arguments(invocation)
let command = try arguments.string("command")
guard !command.contains("\n"), !command.contains("\r"), !command.contains("\0") else {
throw AgentToolError.invalidArguments("A terminal suggestion must be one command line.")
}
return Self.result(
invocation,
.object([
"command": .string(command),
"risk": .string(CommandRiskClassifier.assess(command).risk.label),
"requires_user_action": .bool(true),
]))
}
}
nonisolated extension TerminalSuggestTool {
public static func present(_ input: AgentToolDetailInput) -> [AgentToolDetail.Item] {
typealias F = AgentToolDetailFormatting
let object = input.result.objectValue ?? [:]
let command =
input.arguments["command"]?.stringValue
?? object["command"]?.stringValue ?? ""
var items: [AgentToolDetail.Item] = [F.text("Command", command, locale: input.locale)]
let assessment = CommandRiskClassifier.assess(command)
items.append(F.field("Risk", assessment.risk.label, locale: input.locale))
items.append(contentsOf: assessment.reasons.map { .message($0, .warning) })
items.append(.message(F.localized("Requires user action", locale: input.locale), .warning))
return items
}
}
// MARK: - Tool-owned transcript details
nonisolated extension ListHostsTool {
private struct HostGroupDetailProjection {
let id: String
let title: String
}
public static func present(
_ input: AgentToolDetailInput
) -> [AgentToolDetail.Item] {
typealias F = AgentToolDetailFormatting
return F.objectItems(input) { object in
let hostValues = object["hosts"]?.arrayValue ?? []
let groupValues = object["groups"]?.arrayValue ?? []
var groups: [HostGroupDetailProjection] = []
func walk(_ rows: [AgentJSONValue], prefix: String = "") {
for row in rows {
guard let fields = row.objectValue else { continue }
let name =
F.nonempty(fields["name"]?.stringValue)
?? F.localized("Unnamed group", locale: input.locale)
let computed = prefix.isEmpty ? name : "\(prefix) / \(name)"
let title = F.nonempty(fields["path"]?.stringValue) ?? computed
if let id = fields["id"]?.stringValue {
groups.append(.init(id: id, title: title))
}
walk(fields["children"]?.arrayValue ?? [], prefix: computed)
}
}
walk(groupValues)
var grouped: [String: [AgentToolDetail.ListRow]] = [:]
var ungrouped: [AgentToolDetail.ListRow] = []
var temporary: [AgentToolDetail.ListRow] = []
var connected = 0
for hostValue in hostValues {
guard let host = hostValue.objectValue else { continue }
if host["connection_state"]?.stringValue == "connected" { connected += 1 }
let row = hostDetailRow(host, locale: input.locale)
if host["temporary"]?.boolValue == true {
temporary.append(row)
} else if let groupID = host["group_id"]?.stringValue,
groups.contains(where: { $0.id == groupID })
{
grouped[groupID, default: []].append(row)
} else {
ungrouped.append(row)
}
}
guard !hostValues.isEmpty || !groups.isEmpty else {
return [.message(F.localized("No hosts available", locale: input.locale), .secondary)]
}
func hostCount(_ count: Int) -> String {
String(localized: "\(count) hosts", locale: input.locale)
}
var summary = [hostCount(hostValues.count)]
if connected > 0 {
summary.append(String(localized: "\(connected) connected", locale: input.locale))
}
if !temporary.isEmpty {
summary.append(String(localized: "\(temporary.count) temporary", locale: input.locale))
}
var sections = groups.map { group in
let rows = grouped[group.id] ?? []
return AgentToolDetail.ListSection(
title: "\(group.title) · \(hostCount(rows.count))", rows: rows
)
}
if !groups.isEmpty || !ungrouped.isEmpty {
sections.append(
.init(
title: "\(F.localized("Ungrouped", locale: input.locale)) · \(hostCount(ungrouped.count))",
rows: ungrouped
))
}
if !temporary.isEmpty {
sections.append(
.init(
title: "\(F.localized("Temporary", locale: input.locale)) · \(hostCount(temporary.count))",
rows: temporary
))
}
return [
.groupedList(
.init(
summary: summary.joined(separator: " · "), sections: sections,
isScrollable: hostValues.count > 12
))
]
}
}
private static func hostDetailRow(
_ host: [String: AgentJSONValue], locale: Locale
) -> AgentToolDetail.ListRow {
typealias F = AgentToolDetailFormatting
let address = F.nonempty(host["address"]?.stringValue)
let name =
F.nonempty(host["display_name"]?.stringValue) ?? address
?? F.localized("Unnamed host", locale: locale)
let state = connectionDetailState(host["connection_state"]?.stringValue ?? "", locale: locale)
let subtitle = [address ?? F.localized("Address unavailable", locale: locale), state.label]
.joined(separator: " · ")
let tags = host["tags"]?.arrayValue?.compactMap { F.nonempty($0.stringValue) } ?? []
return .init(
title: name, subtitle: subtitle, badges: tags, symbol: "circle.fill", tint: state.tint
)
}
private static func connectionDetailState(
_ raw: String, locale: Locale
) -> (label: String, tint: AgentToolDetail.Tint) {
typealias F = AgentToolDetailFormatting
return switch raw {
case "connected": (F.localized("Connected", locale: locale), AgentToolDetail.Tint.success)
case "connecting": (F.localized("Connecting", locale: locale), AgentToolDetail.Tint.warning)
case "reconnecting": (F.localized("Reconnecting", locale: locale), AgentToolDetail.Tint.warning)
case "disconnected": (F.localized("Not connected", locale: locale), AgentToolDetail.Tint.secondary)
case "failed": (F.localized("Failed", locale: locale), AgentToolDetail.Tint.failure)
default:
(
F.nonempty(F.humanized(raw))?.capitalized
?? F.localized("Unknown", locale: locale),
AgentToolDetail.Tint.secondary
)
}
}
}