Files

121 lines
5.6 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import AgentKit
import Foundation
import TermindApplication
import TermindDomain
import struct TermindDomain.Host
nonisolated public struct GetMetricsTool: AgentToolDefinition, AgentToolSchemaBuilding {
public static let presenter = AgentToolDetailPresenter(id: "builtin.get_metrics", present: present)
public static let sections: Set<String> = ["devices", "gpu", "processes", "filesystems"]
public let hosts: AgentHostResolver
public let metrics: any MetricsSnapshotReading
public var descriptor: AgentToolDescriptor {
Self.descriptor(
TermindToolName.getMetrics,
"Read cached Termind host metrics and a five-minute trend summary without connecting or sampling. "
+ "Use list_hosts for host_id. Optional details may be missing or stale; no data is refreshed. "
+ "Times are UTC ISO 8601, sizes are bytes, rates are bytes/second, utilization is percent. "
+ "Process CPU is 100% per logical core; only the top 20 cached processes are returned.",
properties: [
"host_id": Self.string(),
"include": Self.array(
items: .object([
"type": .string("string"),
"enum": .array(Self.sections.sorted().map(AgentJSONValue.string)),
]), max: 4),
], required: ["host_id"], target: .host, approvalPolicy: .approve, concurrency: .parallel,
presentation: .init(
symbol: "gauge.with.dots.needle.67percent", activity: .semanticLabel(.metrics),
output: .json, actionKind: .read
)
)
}
public func preflight(_ invocation: AgentToolInvocation) async throws -> AgentToolPreflight {
var checked = invocation
let arguments = try Arguments(invocation)
checked.targetHostID = try arguments.uuid("host_id")
_ = try Self.includes(arguments)
return AgentToolPreflight(invocation: checked, approvalPolicy: .approve)
}
public func execute(_ invocation: AgentToolInvocation, context: AgentToolExecutionContext) async throws
-> AgentToolResult
{
let arguments = try Arguments(invocation)
let hostID = try arguments.uuid("host_id")
let includes = try Self.includes(arguments)
guard let host = await hosts.listing().hosts.first(where: { $0.id == hostID }) else {
throw AgentToolError.invalidHost
}
guard host.protocols.contains(where: { $0.name == .ssh }) else {
throw HostProtocolUnavailableError(useCase: .metrics)
}
let snapshot = await metrics.snapshot(for: hostID)
return Self.result(
invocation,
MetricsToolOutput.make(hostID: hostID, name: host.displayName, snapshot: snapshot, include: includes)
)
}
private static func includes(_ arguments: Arguments) throws -> Set<String> {
guard let value = arguments.object["include"] else { return [] }
guard let array = value.arrayValue, array.count <= 4,
array.allSatisfy({ $0.stringValue.map(sections.contains) == true })
else {
throw AgentToolError.invalidArguments("include must contain only devices, gpu, processes, or filesystems.")
}
return Set(array.compactMap(\.stringValue))
}
public static func present(_ input: AgentToolDetailInput) -> [AgentToolDetail.Item] {
guard let object = input.result.objectValue else {
return [.message(input.result.encodedString, .secondary)]
}
func field(_ key: String.LocalizationValue, _ value: String) -> AgentToolDetail.Item {
.field(.init(label: AppLocalization.string(key, locale: input.locale), value: value))
}
var items = [field("Host", object["display_name"]?.stringValue ?? "—")]
let state = object["status"]?.stringValue ?? "not_monitored"
let stateKey: String.LocalizationValue =
switch state {
case "live": "Live"
case "paused": "Paused"
case "sleeping": "Sleeping"
case "stopped": "Stopped"
case "connecting": "Connecting…"
case "collecting": "Connected — collecting metrics…"
case "awaiting_authentication": "Authentication required"
case "failed": "Metrics collection failed"
default: "Not monitored"
}
items.append(field("Status", AppLocalization.string(stateKey, locale: input.locale)))
if let overview = object["overview"]?.objectValue {
if let date = overview["sampled_at"]?.stringValue { items.append(field("Sampled at", date)) }
for (key, label) in [("cpu_percent", "CPU"), ("memory_percent", "Memory")] {
let value = overview[key]?.numberValue.map { String(format: "%.1f%%", $0) } ?? "—"
items.append(field(String.LocalizationValue(label), value))
}
if let total = overview["swap_total_bytes"]?.numberValue, total > 0 {
let value = overview["swap_percent"]?.numberValue.map { String(format: "%.1f%%", $0) } ?? "—"
items.append(field("Swap", value))
}
if overview["stale"]?.boolValue == true {
items.append(
.message(AppLocalization.string("Cached metrics are stale", locale: input.locale), .warning))
}
}
return items
}
public init(hosts: AgentHostResolver, metrics: any MetricsSnapshotReading) {
self.hosts = hosts
self.metrics = metrics
}
}