Files

264 lines
11 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
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
)
}
}
}