// 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 ) } } }