1308 lines
59 KiB
Swift
1308 lines
59 KiB
Swift
import AgentKit
|
||
import Foundation
|
||
import TermindApplication
|
||
|
||
/// Turns tool protocol data into the small amount of language the transcript needs.
|
||
/// Execution, approval and replay continue to use the original call and result unchanged.
|
||
nonisolated public enum AgentToolPresentation {
|
||
public enum BatchTint: Equatable, Sendable {
|
||
case secondary, warning, success, failure
|
||
}
|
||
|
||
public static let displayCharacterLimit = 8_192
|
||
public static let displayLineLimit = 120
|
||
|
||
nonisolated public struct OutputPreview: Equatable, Sendable {
|
||
public let displayed: String
|
||
public let full: String
|
||
public let isShortened: Bool
|
||
public let omittedCharacters: Int
|
||
}
|
||
|
||
public static func usesCompactDetails(for descriptor: AgentToolDescriptor) -> Bool {
|
||
guard let kind = AgentKnowledgeToolPresentation.kind(for: descriptor) else { return false }
|
||
return kind != .legacySkill
|
||
}
|
||
|
||
public static func symbol(for card: AgentToolCard) -> String {
|
||
return card.descriptor.presentation?.symbol ?? "wrench.and.screwdriver"
|
||
}
|
||
|
||
/// The live, user-only projection of a call waiting for approval.
|
||
///
|
||
/// Approval is the one place where the person needs to inspect exactly
|
||
/// what will be sent. Its arguments therefore remain unredacted here; the
|
||
/// projection is derived from the in-memory request and is never attached
|
||
/// to the durable tool card or model transcript.
|
||
public static func approval(
|
||
for request: AgentApprovalRequest,
|
||
presenter: AgentToolDetailPresenter? = nil,
|
||
hostName: String? = nil,
|
||
locale: Locale = .current
|
||
) -> AgentApprovalPresentation {
|
||
let card = AgentToolCard(
|
||
invocation: request.invocation,
|
||
descriptor: request.descriptor,
|
||
state: .waitingForApproval
|
||
)
|
||
return AgentApprovalPresentation(
|
||
symbol: symbol(for: card),
|
||
title: approvalSummary(for: card, hostName: hostName, locale: locale),
|
||
detail: AgentToolDetail(
|
||
items: approvalArgumentItems(
|
||
request.invocation.call.arguments, presenter: presenter,
|
||
hidesHostID: hostName != nil, locale: locale)
|
||
)
|
||
)
|
||
}
|
||
|
||
/// Approval chrome already communicates that the call is waiting. Keep its
|
||
/// action title short and move argument values into the reviewable detail
|
||
/// below, where long commands and paths can scroll without hiding actions.
|
||
private static func approvalSummary(
|
||
for card: AgentToolCard,
|
||
hostName: String?,
|
||
locale: Locale
|
||
) -> String {
|
||
if let operation = AgentKnowledgeToolPresentation.operation(for: card, locale: locale, approval: true) {
|
||
let action = localizedActionVerb(operation.action, locale: locale)
|
||
let title =
|
||
isChinese(locale)
|
||
? localized("Approval tool action: \(action) \(operation.activity)", locale: locale)
|
||
: uppercaseFirst(actionName(operation.action, form: .infinitive, locale: locale)) + " "
|
||
+ operation.activity
|
||
return AgentKnowledgeToolPresentation.title(title)
|
||
}
|
||
let activity = approvalActivity(for: card, locale: locale)
|
||
guard let kind = actionKind(for: card) else {
|
||
let action = localized("Run \(activity)", locale: locale)
|
||
guard let hostName, !hostName.isEmpty else { return action }
|
||
if isChinese(locale) {
|
||
return localized("Approval action target: \(action) \(hostName)", locale: locale)
|
||
}
|
||
return "\(action) on \(hostName)"
|
||
}
|
||
|
||
let action = localizedActionVerb(kind, locale: locale)
|
||
if isChinese(locale) {
|
||
guard let hostName, !hostName.isEmpty else {
|
||
return localized("Approval tool action: \(action) \(activity)", locale: locale)
|
||
}
|
||
return localized(
|
||
"Approval tool action target: \(action) \(activity) \(hostName)",
|
||
locale: locale
|
||
)
|
||
}
|
||
|
||
let rendered =
|
||
uppercaseFirst(actionName(kind, form: .infinitive, locale: locale))
|
||
+ (activity.isEmpty ? "" : " \(activity)")
|
||
guard let hostName, !hostName.isEmpty else { return rendered }
|
||
return "\(rendered) on \(hostName)"
|
||
}
|
||
|
||
private static func approvalActivity(
|
||
for card: AgentToolCard,
|
||
locale: Locale
|
||
) -> String {
|
||
guard let presentation = card.descriptor.presentation else {
|
||
return humanized(card.descriptor.qualifiedName)
|
||
}
|
||
switch presentation.activity {
|
||
case .label(let label):
|
||
return legacyActivity(
|
||
label, descriptorName: card.descriptor.name, locale: locale
|
||
)
|
||
case .argument(_, let fallback):
|
||
return legacyActivity(
|
||
fallback, descriptorName: card.descriptor.name, locale: locale
|
||
)
|
||
case .arrayCount, .localizedArrayCount, .semanticArrayCount:
|
||
return operation(for: card, locale: locale).text
|
||
case .semanticLabel(let kind), .semanticArgument(_, let kind):
|
||
return activityName(kind, locale: locale)
|
||
}
|
||
}
|
||
|
||
/// While work is live, use the icon of the tool(s) still in flight. After
|
||
/// completion, a homogeneous batch keeps its tool icon; mixed batches use
|
||
/// the generic tools symbol.
|
||
public static func batchSymbol(for cards: [AgentToolCard]) -> String {
|
||
let unfinished = cards.filter { !$0.state.isTerminal }
|
||
if let live = unfinished.first { return symbol(for: live) }
|
||
guard let first = cards.first else { return "wrench.and.screwdriver" }
|
||
let name = first.descriptor.qualifiedName
|
||
return cards.allSatisfy { $0.descriptor.qualifiedName == name }
|
||
? symbol(for: first) : "wrench.and.screwdriver"
|
||
}
|
||
|
||
public static func batchTint(for cards: [AgentToolCard]) -> BatchTint {
|
||
if let live = cards.first(where: { !$0.state.isTerminal }) {
|
||
return tint(for: live.state)
|
||
}
|
||
if !cards.isEmpty, cards.allSatisfy({ $0.state == .completed }) {
|
||
return .success
|
||
}
|
||
if !cards.isEmpty, cards.allSatisfy({ $0.state == .failed || $0.state == .denied }) {
|
||
return .failure
|
||
}
|
||
return .warning
|
||
}
|
||
|
||
/// Converts provider-authored Markdown into the one-line prose a tool
|
||
/// batch header can accommodate. Structure and emphasis remain available
|
||
/// in the live reasoning row; here only the visible words are reused.
|
||
public static func reasoningTitle(from markdown: String) -> String {
|
||
TermindMarkdownParser.plainText(markdown)
|
||
}
|
||
|
||
public static func summary(
|
||
for card: AgentToolCard,
|
||
hostName: String? = nil,
|
||
locale: Locale = .current
|
||
) -> String {
|
||
if let operation = AgentKnowledgeToolPresentation.operation(for: card, locale: locale) {
|
||
return AgentKnowledgeToolPresentation.title(
|
||
actionSummary(
|
||
operation.action, state: card.state, activity: operation.activity,
|
||
isArgumentValue: operation.isArgument, target: nil, locale: locale))
|
||
}
|
||
let operation = operation(for: card, locale: locale)
|
||
let phase: String
|
||
if card.state == .running {
|
||
phase = card.result?.presentationPhase ?? card.state.rawValue
|
||
} else if card.state == .reviewing {
|
||
phase = AgentToolCard.State.proposed.rawValue
|
||
} else {
|
||
phase = card.state.rawValue
|
||
}
|
||
if let phaseKind = phaseKind(for: card, phase: phase) {
|
||
return phaseSummary(
|
||
phaseKind, activity: operation.text, target: hostName, locale: locale
|
||
)
|
||
}
|
||
if let actionKind = actionKind(for: card) {
|
||
return actionSummary(
|
||
actionKind, state: card.state, activity: operation.text,
|
||
isArgumentValue: operation.isArgumentValue,
|
||
target: hostName, locale: locale
|
||
)
|
||
}
|
||
if let template = card.descriptor.presentation?.messages[phase] {
|
||
var rendered = template.replacingOccurrences(of: "{activity}", with: operation.text)
|
||
if let hostName, !hostName.isEmpty {
|
||
rendered = rendered.replacingOccurrences(of: "{target}", with: hostName)
|
||
} else {
|
||
// A host-shaped tool is not necessarily tied to one server. In
|
||
// particular, list_hosts and ssh_wait are not; a rejected call
|
||
// can also fail before its host_id becomes a resolved target.
|
||
rendered = rendered.replacingOccurrences(of: " on {target}", with: "")
|
||
rendered = rendered.replacingOccurrences(of: " to {target}", with: "")
|
||
rendered = rendered.replacingOccurrences(of: " with {target}", with: "")
|
||
rendered = rendered.replacingOccurrences(of: "{target}", with: "")
|
||
}
|
||
return rendered.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
}
|
||
|
||
let action: String
|
||
switch card.state {
|
||
case .proposed: action = localized("Preparing \(operation.text)", locale: locale)
|
||
case .waitingForApproval:
|
||
action = localized("Waiting to run \(operation.text)", locale: locale)
|
||
case .waitingForUser:
|
||
action = localized("Waiting to run \(operation.text)", locale: locale)
|
||
case .reviewing: action = localized("Preparing \(operation.text)", locale: locale)
|
||
case .running: action = localized("Running \(operation.text)", locale: locale)
|
||
case .completed: action = localized("Ran \(operation.text)", locale: locale)
|
||
case .denied: action = localized("Denied \(operation.text)", locale: locale)
|
||
case .failed: action = localized("Failed \(operation.text)", locale: locale)
|
||
case .interrupted: action = localized("Interrupted \(operation.text)", locale: locale)
|
||
}
|
||
|
||
if let hostName, !hostName.isEmpty {
|
||
return localized("\(action) on \(hostName)", locale: locale)
|
||
}
|
||
return action
|
||
}
|
||
|
||
/// A finished contiguous batch, phrased from the same presentation
|
||
/// metadata as its individual rows but with repeated activities counted.
|
||
/// Calls in different terminal states stay separate, so one failure can
|
||
/// never be rewritten as a successful action by the batch title.
|
||
public static func batchSummary(
|
||
for cards: [AgentToolCard], locale: Locale = .current
|
||
) -> String {
|
||
struct Key: Hashable {
|
||
let name: String
|
||
let state: AgentToolCard.State
|
||
let knowledgeAction: AgentToolDescriptor.Presentation.ActionKind?
|
||
}
|
||
|
||
var order: [Key] = []
|
||
var grouped: [Key: [AgentToolCard]] = [:]
|
||
for card in cards {
|
||
let key = Key(
|
||
name: card.descriptor.qualifiedName, state: card.state,
|
||
knowledgeAction: AgentKnowledgeToolPresentation.operation(for: card, locale: locale)?.action)
|
||
if grouped[key] == nil { order.append(key) }
|
||
grouped[key, default: []].append(card)
|
||
}
|
||
let clauses = order.compactMap { key in
|
||
grouped[key].map { batchClause($0, locale: locale) }
|
||
}
|
||
if locale.language.languageCode?.identifier == "zh" {
|
||
return clauses.joined(separator: ",")
|
||
}
|
||
return clauses.enumerated()
|
||
.map { index, clause in index == 0 ? clause : lowercaseFirst(clause) }
|
||
.joined(separator: ", ")
|
||
}
|
||
|
||
public static func output(from result: AgentToolResult, presentation: AgentToolDescriptor.Presentation?) -> String {
|
||
let raw = result.untrustedPayload ?? result.content
|
||
return output(raw, policy: presentation?.output ?? .automatic)
|
||
}
|
||
|
||
/// The denial reason is intentionally kept both on the review record and
|
||
/// in the tool result: people need the former and the model must receive
|
||
/// the latter. The transcript, however, should not print identical text
|
||
/// twice. Distinct execution failures remain visible below the review.
|
||
public static func detailOutput(
|
||
from result: AgentToolResult,
|
||
presentation: AgentToolDescriptor.Presentation?,
|
||
review: GuardianDecision?
|
||
) -> String? {
|
||
let rendered = output(from: result, presentation: presentation)
|
||
guard result.isError, let review, !review.allowsExecution,
|
||
normalized(rendered) == normalized(review.reason)
|
||
else { return rendered }
|
||
return nil
|
||
}
|
||
|
||
/// Projects protocol-shaped tool results into transcript-shaped content.
|
||
/// The model and persistence keep the original JSON; only this projection
|
||
/// is allowed to decide what a person sees.
|
||
public static func detail(
|
||
for card: AgentToolCard, presenter: AgentToolDetailPresenter? = nil, locale: Locale = .current
|
||
) -> AgentToolDetail? {
|
||
guard let result = card.result else { return nil }
|
||
|
||
// MCP output is remote-defined and intentionally keeps the established
|
||
// JSON presentation. Built-ins below never fall back to JSON syntax.
|
||
if card.descriptor.target == .mcp {
|
||
guard
|
||
let output = detailOutput(
|
||
from: result, presentation: card.descriptor.presentation,
|
||
review: card.review
|
||
)
|
||
else { return nil }
|
||
return AgentToolDetail(items: [
|
||
.text(.init(title: nil, text: output, style: .monospaced))
|
||
])
|
||
}
|
||
|
||
let raw = result.untrustedPayload ?? result.content
|
||
guard let value = try? AgentJSONValue.decode(Data(raw.utf8)) else {
|
||
guard
|
||
let output = detailOutput(
|
||
from: result, presentation: card.descriptor.presentation,
|
||
review: card.review
|
||
)
|
||
else { return nil }
|
||
let text = output.isEmpty ? String(localized: "Completed without output") : output
|
||
return AgentToolDetail(items: [
|
||
.message(text, result.isError ? .failure : .secondary)
|
||
])
|
||
}
|
||
|
||
// A failed/denied historical result may still contain success-shaped fields.
|
||
// Only successful execution is allowed to select a knowledge success presenter.
|
||
if usesCompactDetails(for: card.descriptor),
|
||
result.isError || card.state == .failed || card.state == .denied || card.state == .interrupted
|
||
{
|
||
if detailOutput(from: result, presentation: card.descriptor.presentation, review: card.review) == nil {
|
||
return nil
|
||
}
|
||
return AgentToolDetail(items: AgentToolDetailFormatting.genericItems(value, locale: locale))
|
||
}
|
||
guard let presenter else {
|
||
return AgentToolDetail(
|
||
items: AgentToolDetailFormatting.genericItems(value, locale: locale)
|
||
)
|
||
}
|
||
return AgentToolDetail(
|
||
items: presenter.present(
|
||
.init(
|
||
result: value,
|
||
arguments: card.invocation.call.arguments.objectValue ?? [:],
|
||
locale: locale
|
||
)))
|
||
}
|
||
|
||
/// A bounded rendering projection. The full transformed output stays beside
|
||
/// it for Copy, while the Text view only receives the small preview.
|
||
public static func preview(
|
||
_ full: String,
|
||
characterLimit: Int = displayCharacterLimit,
|
||
lineLimit: Int = displayLineLimit
|
||
) -> OutputPreview {
|
||
let lineCount = full.reduce(1) { $1 == "\n" ? $0 + 1 : $0 }
|
||
guard full.count > characterLimit || lineCount > lineLimit else {
|
||
return OutputPreview(
|
||
displayed: full, full: full, isShortened: false, omittedCharacters: 0
|
||
)
|
||
}
|
||
|
||
// Five eighths at the front and three eighths at the back keeps the
|
||
// command and initial diagnostics while retaining the final failure.
|
||
// Leave room for the marker itself; a final tightening pass below
|
||
// makes the bound exact even when the omitted count has many digits.
|
||
let contentCharacterLimit = max(2, characterLimit - min(80, characterLimit / 4))
|
||
let headCharacters = max(1, contentCharacterLimit * 5 / 8)
|
||
let tailCharacters = max(1, contentCharacterLimit - headCharacters)
|
||
let contentLineLimit = max(2, lineLimit - 1)
|
||
let headLines = max(1, contentLineLimit * 5 / 8)
|
||
let tailLines = max(1, contentLineLimit - headLines)
|
||
|
||
let headByCharacters = full.index(
|
||
full.startIndex,
|
||
offsetBy: min(headCharacters, full.count)
|
||
)
|
||
let tailByCharacters = full.index(
|
||
full.endIndex,
|
||
offsetBy: -min(tailCharacters, full.count)
|
||
)
|
||
let headByLines = boundaryAfterLines(headLines, in: full)
|
||
let tailByLines = boundaryBeforeLastLines(tailLines, in: full)
|
||
var headEnd = min(headByCharacters, headByLines)
|
||
var tailStart = max(tailByCharacters, tailByLines)
|
||
|
||
// The limits above normally leave a gap. Keep the fallback defensive
|
||
// for tiny custom limits used by unit tests.
|
||
tailStart = max(tailStart, headEnd)
|
||
var displayed = renderedPreview(full, headEnd: headEnd, tailStart: tailStart)
|
||
while displayed.count > characterLimit, headEnd > full.startIndex || tailStart < full.endIndex {
|
||
let overflow = displayed.count - characterLimit
|
||
let availableHead = full.distance(from: full.startIndex, to: headEnd)
|
||
let availableTail = full.distance(from: tailStart, to: full.endIndex)
|
||
let dropHead = min(availableHead, max(1, overflow * 5 / 8))
|
||
let dropTail = min(availableTail, max(0, overflow - dropHead))
|
||
headEnd = full.index(headEnd, offsetBy: -dropHead)
|
||
tailStart = full.index(tailStart, offsetBy: dropTail)
|
||
displayed = renderedPreview(full, headEnd: headEnd, tailStart: tailStart)
|
||
}
|
||
let omitted = full.distance(from: headEnd, to: tailStart)
|
||
return OutputPreview(
|
||
displayed: displayed, full: full,
|
||
isShortened: true, omittedCharacters: omitted
|
||
)
|
||
}
|
||
|
||
private static func approvalArgumentItems(
|
||
_ arguments: AgentJSONValue,
|
||
presenter: AgentToolDetailPresenter?,
|
||
hidesHostID: Bool,
|
||
locale: Locale
|
||
) -> [AgentToolDetail.Item] {
|
||
guard let object = arguments.objectValue else {
|
||
return [
|
||
.text(
|
||
.init(
|
||
title: localized("Arguments", locale: locale),
|
||
text: arguments.encodedString,
|
||
style: .monospaced
|
||
))
|
||
]
|
||
}
|
||
|
||
var visible = object
|
||
if hidesHostID { visible.removeValue(forKey: "host_id") }
|
||
if let projected = presenter?.presentArguments(
|
||
.init(arguments: visible, locale: locale)
|
||
) {
|
||
return projected
|
||
}
|
||
return AgentToolDetailFormatting.genericItems(.object(visible), locale: locale)
|
||
}
|
||
|
||
private static func renderedPreview(
|
||
_ full: String, headEnd: String.Index, tailStart: String.Index
|
||
) -> String {
|
||
let omitted = full.distance(from: headEnd, to: tailStart)
|
||
let marker = String(localized: "[… \(omitted) characters omitted …]")
|
||
return String(full[..<headEnd]) + "\n\(marker)\n" + String(full[tailStart...])
|
||
}
|
||
|
||
private static func boundaryAfterLines(_ count: Int, in text: String) -> String.Index {
|
||
guard count > 0 else { return text.startIndex }
|
||
var lines = 1
|
||
var index = text.startIndex
|
||
while index < text.endIndex {
|
||
if text[index] == "\n" {
|
||
if lines == count { return index }
|
||
lines += 1
|
||
}
|
||
index = text.index(after: index)
|
||
}
|
||
return index
|
||
}
|
||
|
||
private static func boundaryBeforeLastLines(_ count: Int, in text: String) -> String.Index {
|
||
var remaining = count
|
||
var index = text.endIndex
|
||
while index > text.startIndex, remaining > 0 {
|
||
index = text.index(before: index)
|
||
if text[index] == "\n" { remaining -= 1 }
|
||
}
|
||
if index < text.endIndex, text[index] == "\n" {
|
||
return text.index(after: index)
|
||
}
|
||
return index
|
||
}
|
||
|
||
private static func output(
|
||
_ raw: String, policy: AgentToolDescriptor.Presentation.Output
|
||
) -> String {
|
||
guard let value = try? AgentJSONValue.decode(Data(raw.utf8)) else { return raw }
|
||
|
||
if case .streams = policy, let object = value.objectValue {
|
||
let stdout = object["stdout"]?.stringValue ?? ""
|
||
let stderr = object["stderr"]?.stringValue ?? ""
|
||
if !stdout.isEmpty || !stderr.isEmpty {
|
||
if stdout.isEmpty { return stderr }
|
||
if stderr.isEmpty { return stdout }
|
||
return String(localized: "Standard output:\n\(stdout)\nStandard error:\n\(stderr)")
|
||
}
|
||
}
|
||
|
||
if case .field(let key) = policy, let text = value.objectValue?[key]?.stringValue {
|
||
return text
|
||
}
|
||
|
||
if case .automatic = policy, let object = value.objectValue {
|
||
for key in ["content", "text", "output", "body", "diff"] {
|
||
if let text = object[key]?.stringValue { return text }
|
||
}
|
||
}
|
||
|
||
return prettyJSON(value)
|
||
}
|
||
|
||
private struct RenderedOperation {
|
||
var text: String
|
||
var isArgumentValue: Bool
|
||
}
|
||
|
||
private static func operation(
|
||
for card: AgentToolCard, locale: Locale
|
||
) -> RenderedOperation {
|
||
guard let presentation = card.descriptor.presentation else {
|
||
if isChinese(locale),
|
||
let recovered = recoveredBuiltInOperation(
|
||
for: card, locale: locale
|
||
)
|
||
{
|
||
return recovered
|
||
}
|
||
return RenderedOperation(
|
||
text: humanized(card.descriptor.qualifiedName), isArgumentValue: false
|
||
)
|
||
}
|
||
switch presentation.activity {
|
||
case .label(let label):
|
||
return RenderedOperation(
|
||
text: legacyActivity(
|
||
label, descriptorName: card.descriptor.name, locale: locale
|
||
),
|
||
isArgumentValue: false
|
||
)
|
||
case .argument(let key, let fallback):
|
||
guard let value = card.invocation.call.arguments.objectValue?[key]?.stringValue else {
|
||
return RenderedOperation(
|
||
text: legacyActivity(
|
||
fallback, descriptorName: card.descriptor.name, locale: locale
|
||
),
|
||
isArgumentValue: false
|
||
)
|
||
}
|
||
let oneLine = value.split(whereSeparator: \.isNewline).joined(separator: " ")
|
||
guard !oneLine.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||
return RenderedOperation(
|
||
text: legacyActivity(
|
||
fallback, descriptorName: card.descriptor.name, locale: locale
|
||
),
|
||
isArgumentValue: false
|
||
)
|
||
}
|
||
return RenderedOperation(text: oneLine, isArgumentValue: true)
|
||
case .arrayCount(let key, let singular, let plural, let fallback):
|
||
guard let count = card.invocation.call.arguments.objectValue?[key]?.arrayValue?.count,
|
||
count > 0
|
||
else {
|
||
return RenderedOperation(
|
||
text: legacyActivity(
|
||
fallback, descriptorName: card.descriptor.name, locale: locale
|
||
),
|
||
isArgumentValue: false
|
||
)
|
||
}
|
||
if card.descriptor.name == "request_user_input" {
|
||
return RenderedOperation(
|
||
text: countDescription(count, unit: .question, locale: locale),
|
||
isArgumentValue: false
|
||
)
|
||
}
|
||
return RenderedOperation(
|
||
text: count == 1 ? singular : "\(count) \(plural)", isArgumentValue: false
|
||
)
|
||
case .localizedArrayCount(let key, let unit, let fallback):
|
||
guard let count = card.invocation.call.arguments.objectValue?[key]?.arrayValue?.count,
|
||
count > 0
|
||
else {
|
||
return RenderedOperation(
|
||
text: legacyActivity(
|
||
fallback, descriptorName: card.descriptor.name, locale: locale
|
||
),
|
||
isArgumentValue: false
|
||
)
|
||
}
|
||
return RenderedOperation(
|
||
text: countDescription(count, unit: unit, locale: locale),
|
||
isArgumentValue: false
|
||
)
|
||
case .semanticLabel(let kind):
|
||
return RenderedOperation(
|
||
text: activityName(kind, locale: locale), isArgumentValue: false
|
||
)
|
||
case .semanticArgument(let key, let fallback):
|
||
guard let value = card.invocation.call.arguments.objectValue?[key]?.stringValue else {
|
||
if card.descriptor.name == "web_search" {
|
||
return RenderedOperation(text: "", isArgumentValue: false)
|
||
}
|
||
return RenderedOperation(
|
||
text: activityName(fallback, locale: locale), isArgumentValue: false
|
||
)
|
||
}
|
||
let oneLine = value.split(whereSeparator: \.isNewline).joined(separator: " ")
|
||
guard !oneLine.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||
if card.descriptor.name == "web_search" {
|
||
return RenderedOperation(text: "", isArgumentValue: false)
|
||
}
|
||
return RenderedOperation(
|
||
text: activityName(fallback, locale: locale), isArgumentValue: false
|
||
)
|
||
}
|
||
return RenderedOperation(text: oneLine, isArgumentValue: true)
|
||
case .semanticArrayCount(let key, let unit, let fallback):
|
||
guard let count = card.invocation.call.arguments.objectValue?[key]?.arrayValue?.count,
|
||
count > 0
|
||
else {
|
||
return RenderedOperation(
|
||
text: activityName(fallback, locale: locale), isArgumentValue: false
|
||
)
|
||
}
|
||
return RenderedOperation(
|
||
text: countDescription(count, unit: unit, locale: locale),
|
||
isArgumentValue: false
|
||
)
|
||
}
|
||
}
|
||
|
||
/// Cards persisted before semantic presentations were stored still know
|
||
/// their tool name and arguments. Recover the same user-facing activity
|
||
/// from those stable protocol fields instead of exposing names such as
|
||
/// `web_search` in a translated summary.
|
||
private static func recoveredBuiltInOperation(
|
||
for card: AgentToolCard, locale: Locale
|
||
) -> RenderedOperation? {
|
||
if card.descriptor.name == "request_user_input" {
|
||
let count = card.invocation.call.arguments.objectValue?["questions"]?.arrayValue?.count
|
||
return RenderedOperation(
|
||
text: count.map { countDescription($0, unit: .question, locale: locale) }
|
||
?? activityName(.question, locale: locale),
|
||
isArgumentValue: false
|
||
)
|
||
}
|
||
guard let metadata = legacyMetadata[card.descriptor.name] else { return nil }
|
||
if let key = metadata.argumentKey,
|
||
let value = card.invocation.call.arguments.objectValue?[key]?.stringValue
|
||
{
|
||
let oneLine = value.split(whereSeparator: \.isNewline).joined(separator: " ")
|
||
if !oneLine.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||
return RenderedOperation(text: oneLine, isArgumentValue: true)
|
||
}
|
||
}
|
||
let text =
|
||
card.descriptor.name == "web_search"
|
||
? "" : activityName(metadata.activity, locale: locale)
|
||
return RenderedOperation(text: text, isArgumentValue: false)
|
||
}
|
||
|
||
private static func batchClause(
|
||
_ cards: [AgentToolCard], locale: Locale
|
||
) -> String {
|
||
guard let first = cards.first else { return "" }
|
||
if let operation = AgentKnowledgeToolPresentation.operation(for: first, locale: locale) {
|
||
if cards.count == 1 { return summary(for: first, locale: locale) }
|
||
let activity = operation.batchActivity + " · " + toolCallCount(cards.count, locale: locale)
|
||
return AgentKnowledgeToolPresentation.title(
|
||
actionSummary(
|
||
operation.action, state: first.state, activity: activity,
|
||
isArgumentValue: false, target: nil, locale: locale))
|
||
}
|
||
guard let presentation = first.descriptor.presentation else {
|
||
let activity =
|
||
cards.count == 1
|
||
? operation(for: first, locale: locale)
|
||
: RenderedOperation(
|
||
text: toolCallCount(cards.count, locale: locale), isArgumentValue: false
|
||
)
|
||
if let actionKind = actionKind(for: first) {
|
||
if isChinese(locale), cards.count > 1 {
|
||
return repeatedActionSummary(
|
||
actionKind, state: first.state, count: cards.count, locale: locale
|
||
)
|
||
}
|
||
return actionSummary(
|
||
actionKind, state: first.state, activity: activity.text,
|
||
isArgumentValue: activity.isArgumentValue,
|
||
target: nil, locale: locale
|
||
)
|
||
}
|
||
return fallbackAction(for: first.state, activity: activity.text, locale: locale)
|
||
}
|
||
|
||
let semanticActivity = semanticBatchActivity(
|
||
presentation.activity, count: cards.count, locale: locale
|
||
)
|
||
let activity =
|
||
semanticActivity
|
||
?? batchActivity(presentation.activity, cards: cards, locale: locale)
|
||
if let actionKind = actionKind(for: first) {
|
||
if isChinese(locale), cards.count > 1, semanticActivity == nil {
|
||
return repeatedActionSummary(
|
||
actionKind, state: first.state, count: cards.count, locale: locale
|
||
)
|
||
}
|
||
return actionSummary(
|
||
actionKind, state: first.state, activity: activity.text,
|
||
isArgumentValue: activity.isArgumentValue,
|
||
target: nil, locale: locale
|
||
)
|
||
}
|
||
if let template = presentation.messages[first.state.rawValue] {
|
||
return replacingQuotedActivity(in: template, with: activity.text)
|
||
.replacingOccurrences(of: "{activity}", with: activity.text)
|
||
.replacingOccurrences(of: " on {target}", with: "")
|
||
.replacingOccurrences(of: " to {target}", with: "")
|
||
.replacingOccurrences(of: " with {target}", with: "")
|
||
.replacingOccurrences(of: "{target}", with: "")
|
||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
}
|
||
return fallbackAction(for: first.state, activity: activity.text, locale: locale)
|
||
}
|
||
|
||
/// Repeated calls whose descriptor names a domain activity should count
|
||
/// that activity, not expose the implementation detail that it was a tool
|
||
/// call. A task-list call replaces the whole list, so its count describes
|
||
/// replacements rather than pretending to know how many tasks changed.
|
||
private static func semanticBatchActivity(
|
||
_ activity: AgentToolDescriptor.Presentation.Activity,
|
||
count: Int,
|
||
locale: Locale
|
||
) -> RenderedOperation? {
|
||
guard count > 1 else { return nil }
|
||
switch activity {
|
||
case .semanticArgument(_, let kind) where kind == .sshCommand:
|
||
return RenderedOperation(
|
||
text: localized("\(count) commands", locale: locale),
|
||
isArgumentValue: false
|
||
)
|
||
case .semanticLabel(let kind) where kind == .taskList:
|
||
return RenderedOperation(
|
||
text: localized("the task list \(count) times", locale: locale),
|
||
isArgumentValue: false
|
||
)
|
||
default:
|
||
return nil
|
||
}
|
||
}
|
||
|
||
private static func replacingQuotedActivity(in template: String, with activity: String) -> String {
|
||
[
|
||
"“{activity}”", "\"{activity}\"", "‘{activity}’", "'{activity}'",
|
||
"«{activity}»", "‹{activity}›", "「{activity}」", "『{activity}』",
|
||
].reduce(template) { text, quoted in
|
||
text.replacingOccurrences(of: quoted, with: activity)
|
||
}
|
||
}
|
||
|
||
private static func batchActivity(
|
||
_ activity: AgentToolDescriptor.Presentation.Activity,
|
||
cards: [AgentToolCard], locale: Locale
|
||
) -> RenderedOperation {
|
||
switch activity {
|
||
case .localizedArrayCount(let key, let unit, let fallback):
|
||
let count = cards.reduce(0) { total, card in
|
||
total + (card.invocation.call.arguments.objectValue?[key]?.arrayValue?.count ?? 0)
|
||
}
|
||
guard count > 0 else {
|
||
return RenderedOperation(text: fallback, isArgumentValue: false)
|
||
}
|
||
return RenderedOperation(
|
||
text: countDescription(count, unit: unit, locale: locale),
|
||
isArgumentValue: false
|
||
)
|
||
case .semanticArrayCount(let key, let unit, let fallback):
|
||
let count = cards.reduce(0) { total, card in
|
||
total + (card.invocation.call.arguments.objectValue?[key]?.arrayValue?.count ?? 0)
|
||
}
|
||
guard count > 0 else {
|
||
return RenderedOperation(
|
||
text: activityName(fallback, locale: locale), isArgumentValue: false
|
||
)
|
||
}
|
||
return RenderedOperation(
|
||
text: countDescription(count, unit: unit, locale: locale),
|
||
isArgumentValue: false
|
||
)
|
||
case .arrayCount(let key, _, _, let fallback):
|
||
let count = cards.reduce(0) { total, card in
|
||
total + (card.invocation.call.arguments.objectValue?[key]?.arrayValue?.count ?? 0)
|
||
}
|
||
guard count > 0 else {
|
||
return RenderedOperation(text: fallback, isArgumentValue: false)
|
||
}
|
||
if cards.first?.descriptor.name == "request_user_input" {
|
||
return RenderedOperation(
|
||
text: countDescription(count, unit: .question, locale: locale),
|
||
isArgumentValue: false
|
||
)
|
||
}
|
||
return RenderedOperation(
|
||
text: cards.count == 1 ? fallback : toolCallCount(cards.count, locale: locale),
|
||
isArgumentValue: false
|
||
)
|
||
case .argument(_, let fallback):
|
||
return cards.count == 1
|
||
? RenderedOperation(
|
||
text: legacyActivity(
|
||
fallback, descriptorName: cards[0].descriptor.name, locale: locale
|
||
),
|
||
isArgumentValue: false
|
||
)
|
||
: RenderedOperation(
|
||
text: toolCallCount(cards.count, locale: locale), isArgumentValue: false
|
||
)
|
||
case .label(let label):
|
||
return cards.count == 1
|
||
? RenderedOperation(
|
||
text: legacyActivity(
|
||
label, descriptorName: cards[0].descriptor.name, locale: locale
|
||
),
|
||
isArgumentValue: false
|
||
)
|
||
: RenderedOperation(
|
||
text: toolCallCount(cards.count, locale: locale), isArgumentValue: false
|
||
)
|
||
case .semanticArgument(_, let fallback):
|
||
if cards.count == 1, cards[0].descriptor.name == "web_search" {
|
||
return operation(for: cards[0], locale: locale)
|
||
}
|
||
return RenderedOperation(
|
||
text: cards.count == 1
|
||
? activityName(fallback, locale: locale)
|
||
: toolCallCount(cards.count, locale: locale),
|
||
isArgumentValue: false
|
||
)
|
||
case .semanticLabel(let kind):
|
||
return RenderedOperation(
|
||
text: cards.count == 1
|
||
? activityName(kind, locale: locale)
|
||
: toolCallCount(cards.count, locale: locale),
|
||
isArgumentValue: false
|
||
)
|
||
}
|
||
}
|
||
|
||
private static func countDescription(
|
||
_ count: Int, unit: AgentToolDescriptor.Presentation.CountedUnit,
|
||
locale: Locale
|
||
) -> String {
|
||
switch unit {
|
||
case .question: localized("\(count) questions", locale: locale)
|
||
// Open vocabulary — an app that adds a unit and no resource for it gets
|
||
// a plain count rather than a missing string.
|
||
default: "\(count)"
|
||
}
|
||
}
|
||
|
||
private static func toolCallCount(_ count: Int, locale: Locale) -> String {
|
||
localized("\(count) tool calls", locale: locale)
|
||
}
|
||
|
||
private static func fallbackAction(
|
||
for state: AgentToolCard.State, activity: String, locale: Locale
|
||
) -> String {
|
||
switch state {
|
||
case .proposed: localized("Preparing \(activity)", locale: locale)
|
||
case .waitingForApproval, .waitingForUser:
|
||
localized("Waiting to run \(activity)", locale: locale)
|
||
case .reviewing: localized("Preparing \(activity)", locale: locale)
|
||
case .running: localized("Running \(activity)", locale: locale)
|
||
case .completed: localized("Ran \(activity)", locale: locale)
|
||
case .denied: localized("Denied \(activity)", locale: locale)
|
||
case .failed: localized("Failed \(activity)", locale: locale)
|
||
case .interrupted: localized("Interrupted \(activity)", locale: locale)
|
||
}
|
||
}
|
||
|
||
private enum ActionForm { case infinitive, progressive, past }
|
||
|
||
private struct ActionForms {
|
||
var infinitive: String
|
||
var progressive: String
|
||
var past: String
|
||
|
||
subscript(form: ActionForm) -> String {
|
||
switch form {
|
||
case .infinitive: infinitive
|
||
case .progressive: progressive
|
||
case .past: past
|
||
}
|
||
}
|
||
}
|
||
|
||
private static func actionKind(
|
||
for card: AgentToolCard
|
||
) -> AgentToolDescriptor.Presentation.ActionKind? {
|
||
if let kind = card.descriptor.presentation?.actionKind { return kind }
|
||
return legacyMetadata[card.descriptor.name]?.action
|
||
?? (card.descriptor.target == .mcp ? .run : nil)
|
||
}
|
||
|
||
private static func phaseKind(
|
||
for card: AgentToolCard, phase: String
|
||
) -> AgentToolDescriptor.Presentation.PhaseKind? {
|
||
if let kind = card.descriptor.presentation?.phaseKinds[phase] { return kind }
|
||
guard card.descriptor.name == "ssh_exec" else { return nil }
|
||
return switch phase {
|
||
case "proposed": .sshPreparingCommand
|
||
case "waitingForApproval": .sshWaitingForCommand
|
||
case "waitingForUser": .sshWaitingForAuthentication
|
||
case "running": .sshConnecting
|
||
case "command": .sshRunningCommand
|
||
case "completed": .sshCompletedCommand
|
||
case "denied": .sshDeniedCommand
|
||
case "failed": .sshFailedCommand
|
||
case "interrupted": .sshInterruptedCommand
|
||
default: nil
|
||
}
|
||
}
|
||
|
||
private static func actionSummary(
|
||
_ kind: AgentToolDescriptor.Presentation.ActionKind,
|
||
state: AgentToolCard.State, activity: String, isArgumentValue: Bool,
|
||
target: String?, locale: Locale
|
||
) -> String {
|
||
if isChinese(locale) {
|
||
let action = localizedActionVerb(kind, locale: locale)
|
||
let displayedActivity = isArgumentValue ? "“\(activity)”" : activity
|
||
let rendered: String =
|
||
switch state {
|
||
case .proposed, .reviewing:
|
||
localized("Tool action preparing: \(action) \(displayedActivity)", locale: locale)
|
||
case .waitingForApproval, .waitingForUser:
|
||
localized("Tool action waiting: \(action) \(displayedActivity)", locale: locale)
|
||
case .running:
|
||
localized("Tool action running: \(action) \(displayedActivity)", locale: locale)
|
||
case .completed:
|
||
localized("Tool action completed: \(action) \(displayedActivity)", locale: locale)
|
||
case .denied:
|
||
localized("Tool action denied: \(action) \(displayedActivity)", locale: locale)
|
||
case .failed:
|
||
localized("Tool action failed: \(action) \(displayedActivity)", locale: locale)
|
||
case .interrupted:
|
||
localized("Tool action interrupted: \(action) \(displayedActivity)", locale: locale)
|
||
}
|
||
guard let target, !target.isEmpty else { return rendered }
|
||
return localized("Tool action target: \(rendered) \(target)", locale: locale)
|
||
}
|
||
if activity.isEmpty {
|
||
let rendered: String =
|
||
switch state {
|
||
case .proposed, .reviewing:
|
||
"Preparing to \(actionName(kind, form: .infinitive, locale: locale))"
|
||
case .waitingForApproval, .waitingForUser:
|
||
"Waiting to \(actionName(kind, form: .infinitive, locale: locale))"
|
||
case .running: actionName(kind, form: .progressive, locale: locale)
|
||
case .completed: actionName(kind, form: .past, locale: locale)
|
||
case .denied:
|
||
"Did not \(actionName(kind, form: .infinitive, locale: locale))"
|
||
case .failed:
|
||
"Failed to \(actionName(kind, form: .infinitive, locale: locale))"
|
||
case .interrupted:
|
||
"Interrupted while "
|
||
+ lowercaseFirst(actionName(kind, form: .progressive, locale: locale))
|
||
}
|
||
guard let target, !target.isEmpty else { return rendered }
|
||
return "\(rendered) on \(target)"
|
||
}
|
||
let rendered: String
|
||
switch state {
|
||
case .proposed, .reviewing:
|
||
rendered = "Preparing to \(actionName(kind, form: .infinitive, locale: locale)) \(activity)"
|
||
case .waitingForApproval, .waitingForUser:
|
||
rendered = "Waiting to \(actionName(kind, form: .infinitive, locale: locale)) \(activity)"
|
||
case .running:
|
||
rendered = "\(actionName(kind, form: .progressive, locale: locale)) \(activity)"
|
||
case .completed:
|
||
rendered = "\(actionName(kind, form: .past, locale: locale)) \(activity)"
|
||
case .denied:
|
||
rendered = "Did not \(actionName(kind, form: .infinitive, locale: locale)) \(activity)"
|
||
case .failed:
|
||
rendered = "Failed to \(actionName(kind, form: .infinitive, locale: locale)) \(activity)"
|
||
case .interrupted:
|
||
let action = lowercaseFirst(actionName(kind, form: .progressive, locale: locale))
|
||
rendered = "Interrupted while \(action) \(activity)"
|
||
}
|
||
guard let target, !target.isEmpty else { return rendered }
|
||
return "\(rendered) on \(target)"
|
||
}
|
||
|
||
private static func repeatedActionSummary(
|
||
_ kind: AgentToolDescriptor.Presentation.ActionKind,
|
||
state: AgentToolCard.State, count: Int, locale: Locale
|
||
) -> String {
|
||
let action = localizedActionVerb(kind, locale: locale)
|
||
switch state {
|
||
case .failed:
|
||
return localized("Repeated tool action failed: \(count) \(action)", locale: locale)
|
||
case .interrupted:
|
||
return localized(
|
||
"Repeated tool action interrupted: \(count) \(action)", locale: locale
|
||
)
|
||
default:
|
||
return actionSummary(
|
||
kind, state: state, activity: localized("\(count) times", locale: locale),
|
||
isArgumentValue: false, target: nil, locale: locale
|
||
)
|
||
}
|
||
}
|
||
|
||
private static func phaseSummary(
|
||
_ kind: AgentToolDescriptor.Presentation.PhaseKind,
|
||
activity: String, target: String?, locale: Locale
|
||
) -> String {
|
||
// Commands already carry their own shell punctuation. English reads
|
||
// more naturally without another pair of prose quotes around the
|
||
// entire command, while Chinese uses quotation marks to separate the
|
||
// command from the surrounding sentence.
|
||
if !isChinese(locale) {
|
||
return switch kind {
|
||
case .sshPreparingCommand:
|
||
target.map { "Preparing to run \(activity) on \($0)" }
|
||
?? "Preparing to run \(activity)"
|
||
case .sshWaitingForCommand:
|
||
target.map { "Waiting to run \(activity) on \($0)" }
|
||
?? "Waiting to run \(activity)"
|
||
case .sshWaitingForAuthentication:
|
||
target.map { "Waiting to authenticate with \($0)" }
|
||
?? "Waiting for authentication"
|
||
case .sshConnecting:
|
||
target.map { "Connecting to \($0)" } ?? "Connecting"
|
||
case .sshRunningCommand:
|
||
target.map { "Running \(activity) on \($0)" } ?? "Running \(activity)"
|
||
case .sshCompletedCommand:
|
||
target.map { "Ran \(activity) on \($0)" } ?? "Ran \(activity)"
|
||
case .sshDeniedCommand:
|
||
target.map { "Did not run \(activity) on \($0)" }
|
||
?? "Did not run \(activity)"
|
||
case .sshFailedCommand:
|
||
target.map { "Failed to run \(activity) on \($0)" }
|
||
?? "Failed to run \(activity)"
|
||
case .sshInterruptedCommand:
|
||
target.map { "Stopped waiting for \(activity) on \($0)" }
|
||
?? "Stopped waiting for \(activity)"
|
||
default: activity
|
||
}
|
||
}
|
||
switch kind {
|
||
case .sshPreparingCommand:
|
||
return target.map {
|
||
localized("Preparing to run “\(activity)” on \($0)", locale: locale)
|
||
} ?? localized("Preparing to run “\(activity)”", locale: locale)
|
||
case .sshWaitingForCommand:
|
||
return target.map {
|
||
localized("Waiting to run “\(activity)” on \($0)", locale: locale)
|
||
} ?? localized("Waiting to run “\(activity)”", locale: locale)
|
||
case .sshWaitingForAuthentication:
|
||
return target.map {
|
||
localized("Waiting to authenticate with \($0)", locale: locale)
|
||
} ?? localized("Waiting for authentication", locale: locale)
|
||
case .sshConnecting:
|
||
return target.map {
|
||
localized("Connecting to \($0)", locale: locale)
|
||
} ?? localized("Connecting", locale: locale)
|
||
case .sshRunningCommand:
|
||
return target.map {
|
||
localized("Running “\(activity)” on \($0)", locale: locale)
|
||
} ?? localized("Running “\(activity)”", locale: locale)
|
||
case .sshCompletedCommand:
|
||
return target.map {
|
||
localized("Ran “\(activity)” on \($0)", locale: locale)
|
||
} ?? localized("Ran “\(activity)”", locale: locale)
|
||
case .sshDeniedCommand:
|
||
return target.map {
|
||
localized("Did not run “\(activity)” on \($0)", locale: locale)
|
||
} ?? localized("Did not run “\(activity)”", locale: locale)
|
||
case .sshFailedCommand:
|
||
return target.map {
|
||
localized("Failed to run “\(activity)” on \($0)", locale: locale)
|
||
} ?? localized("Failed to run “\(activity)”", locale: locale)
|
||
case .sshInterruptedCommand:
|
||
return target.map {
|
||
localized("Stopped waiting for “\(activity)” on \($0)", locale: locale)
|
||
} ?? localized("Stopped waiting for “\(activity)”", locale: locale)
|
||
// Open vocabulary. A phase this app has no wording for falls back to the
|
||
// ordinary action lifecycle, which is what the caller does with `nil`.
|
||
default:
|
||
return activity
|
||
}
|
||
}
|
||
|
||
private static func actionName(
|
||
_ kind: AgentToolDescriptor.Presentation.ActionKind,
|
||
form: ActionForm, locale: Locale
|
||
) -> String {
|
||
if isChinese(locale) { return localizedActionVerb(kind, locale: locale) }
|
||
let forms: ActionForms =
|
||
switch kind {
|
||
case .ask: .init(infinitive: "ask", progressive: "Asking", past: "Asked")
|
||
case .compare: .init(infinitive: "compare", progressive: "Comparing", past: "Compared")
|
||
case .copy: .init(infinitive: "copy", progressive: "Copying", past: "Copied")
|
||
case .create: .init(infinitive: "create", progressive: "Creating", past: "Created")
|
||
case .delete: .init(infinitive: "delete", progressive: "Deleting", past: "Deleted")
|
||
case .edit: .init(infinitive: "edit", progressive: "Editing", past: "Edited")
|
||
case .fetch: .init(infinitive: "fetch", progressive: "Fetching", past: "Fetched")
|
||
case .inspect: .init(infinitive: "inspect", progressive: "Inspecting", past: "Inspected")
|
||
case .install: .init(infinitive: "install", progressive: "Installing", past: "Installed")
|
||
case .list: .init(infinitive: "list", progressive: "Listing", past: "Listed")
|
||
case .move: .init(infinitive: "move", progressive: "Moving", past: "Moved")
|
||
case .present: .init(infinitive: "present", progressive: "Writing", past: "Presented")
|
||
case .pull: .init(infinitive: "pull", progressive: "Pulling", past: "Pulled")
|
||
case .push: .init(infinitive: "push", progressive: "Pushing", past: "Pushed")
|
||
case .read: .init(infinitive: "read", progressive: "Reading", past: "Read")
|
||
case .request: .init(infinitive: "request", progressive: "Requesting", past: "Requested")
|
||
case .run: .init(infinitive: "run", progressive: "Running", past: "Ran")
|
||
case .search: .init(infinitive: "search", progressive: "Searching", past: "Searched")
|
||
case .suggest: .init(infinitive: "suggest", progressive: "Suggesting", past: "Suggested")
|
||
case .update: .init(infinitive: "update", progressive: "Updating", past: "Updated")
|
||
case .wait: .init(infinitive: "wait for", progressive: "Waiting for", past: "Waited for")
|
||
case .write: .init(infinitive: "write", progressive: "Writing", past: "Wrote")
|
||
case .changePermissions:
|
||
.init(
|
||
infinitive: "change permissions for",
|
||
progressive: "Changing permissions for",
|
||
past: "Changed permissions for"
|
||
)
|
||
case .use: .init(infinitive: "use", progressive: "Using", past: "Used")
|
||
// Open vocabulary. The raw value is the verb an app chose, so using it
|
||
// uninflected reads better than a generic "act on".
|
||
default:
|
||
.init(
|
||
infinitive: kind.rawValue,
|
||
progressive: "\(kind.rawValue.capitalized)ing",
|
||
past: "\(kind.rawValue.capitalized)ed"
|
||
)
|
||
}
|
||
return forms[form]
|
||
}
|
||
|
||
private static func localizedActionVerb(
|
||
_ kind: AgentToolDescriptor.Presentation.ActionKind, locale: Locale
|
||
) -> String {
|
||
switch kind {
|
||
case .ask: localized("ask", locale: locale)
|
||
case .compare: localized("compare", locale: locale)
|
||
case .copy: localized("copy", locale: locale)
|
||
case .create: localized("create", locale: locale)
|
||
case .delete: localized("delete", locale: locale)
|
||
case .edit: localized("edit", locale: locale)
|
||
case .fetch: localized("fetch", locale: locale)
|
||
case .inspect: localized("inspect", locale: locale)
|
||
case .install: localized("install", locale: locale)
|
||
case .list: localized("list", locale: locale)
|
||
case .move: localized("move", locale: locale)
|
||
case .present: localized("present", locale: locale)
|
||
case .pull: localized("pull", locale: locale)
|
||
case .push: localized("push", locale: locale)
|
||
case .read: localized("read", locale: locale)
|
||
case .request: localized("request", locale: locale)
|
||
case .run: localized("run", locale: locale)
|
||
case .search: localized("search", locale: locale)
|
||
case .suggest: localized("suggest", locale: locale)
|
||
case .update: localized("update", locale: locale)
|
||
case .wait: localized("wait for", locale: locale)
|
||
case .write: localized("write", locale: locale)
|
||
case .changePermissions: localized("change permissions for", locale: locale)
|
||
case .use: localized("use", locale: locale)
|
||
default: kind.rawValue
|
||
}
|
||
}
|
||
|
||
private static func isChinese(_ locale: Locale) -> Bool {
|
||
locale.language.languageCode?.identifier == "zh"
|
||
}
|
||
|
||
private static func activityName(
|
||
_ kind: AgentToolDescriptor.Presentation.ActivityKind, locale: Locale
|
||
) -> String {
|
||
switch kind {
|
||
case .command: localized("a command", locale: locale)
|
||
case .hosts: localized("hosts", locale: locale)
|
||
case .metrics: localized("host metrics", locale: locale)
|
||
case .plan: localized("a plan", locale: locale)
|
||
case .question: localized("a question", locale: locale)
|
||
case .remoteCommand: localized("the remote command", locale: locale)
|
||
case .remoteFile: localized("a remote file", locale: locale)
|
||
case .scratchEntry: localized("a scratch entry", locale: locale)
|
||
case .scratchFile: localized("a scratch file", locale: locale)
|
||
case .scratchFiles: localized("scratch files", locale: locale)
|
||
case .scratchWorkspace: localized("the scratch workspace", locale: locale)
|
||
case .secret: localized("a secret", locale: locale)
|
||
case .skill: localized("a skill", locale: locale)
|
||
case .skills: localized("skills", locale: locale)
|
||
case .memory: localized("persistent memory", locale: locale)
|
||
case .sessions: localized("saved conversations", locale: locale)
|
||
case .sshCommand: localized("the SSH command", locale: locale)
|
||
case .taskList: localized("the task list", locale: locale)
|
||
case .url: localized("a URL", locale: locale)
|
||
case .web: localized("the web", locale: locale)
|
||
default: kind.rawValue
|
||
}
|
||
}
|
||
|
||
private static func localized(
|
||
_ key: String.LocalizationValue, locale: Locale
|
||
) -> String {
|
||
AppLocalization.string(key, locale: locale)
|
||
}
|
||
|
||
private static func legacyActivity(
|
||
_ fallback: String, descriptorName: String, locale: Locale
|
||
) -> String {
|
||
guard let metadata = legacyMetadata[descriptorName] else { return fallback }
|
||
let legacyFallback = activityName(metadata.activity, locale: Locale(identifier: "en"))
|
||
guard metadata.localizesAnyFallback || fallback == legacyFallback else { return fallback }
|
||
return activityName(metadata.activity, locale: locale)
|
||
}
|
||
|
||
private struct LegacyMetadata {
|
||
var argumentKey: String?
|
||
var activity: AgentToolDescriptor.Presentation.ActivityKind
|
||
var action: AgentToolDescriptor.Presentation.ActionKind
|
||
var localizesAnyFallback = false
|
||
}
|
||
|
||
private static let legacyMetadata: [String: LegacyMetadata] = [
|
||
"fetch": .init(argumentKey: "url", activity: .url, action: .fetch),
|
||
"web_search": .init(argumentKey: "query", activity: .web, action: .search),
|
||
"list_hosts": .init(argumentKey: nil, activity: .hosts, action: .list),
|
||
"ssh_wait": .init(argumentKey: nil, activity: .remoteCommand, action: .wait),
|
||
"ssh_exec": .init(argumentKey: "command", activity: .sshCommand, action: .run),
|
||
"terminal_suggest": .init(argumentKey: "command", activity: .command, action: .suggest),
|
||
"sftp_list": .init(
|
||
argumentKey: "path", activity: .remoteFile, action: .list,
|
||
localizesAnyFallback: true
|
||
),
|
||
"sftp_stat": .init(
|
||
argumentKey: "path", activity: .remoteFile, action: .inspect,
|
||
localizesAnyFallback: true
|
||
),
|
||
"sftp_read": .init(
|
||
argumentKey: "path", activity: .remoteFile, action: .read,
|
||
localizesAnyFallback: true
|
||
),
|
||
"sftp_write": .init(
|
||
argumentKey: "path", activity: .remoteFile, action: .write,
|
||
localizesAnyFallback: true
|
||
),
|
||
"sftp_mkdir": .init(
|
||
argumentKey: "path", activity: .remoteFile, action: .create,
|
||
localizesAnyFallback: true
|
||
),
|
||
"sftp_move": .init(
|
||
argumentKey: "path", activity: .remoteFile, action: .move,
|
||
localizesAnyFallback: true
|
||
),
|
||
"sftp_chmod": .init(
|
||
argumentKey: "path", activity: .remoteFile, action: .changePermissions,
|
||
localizesAnyFallback: true
|
||
),
|
||
"sftp_delete": .init(
|
||
argumentKey: "path", activity: .remoteFile, action: .delete,
|
||
localizesAnyFallback: true
|
||
),
|
||
"request_user_input": .init(argumentKey: nil, activity: .question, action: .ask),
|
||
"request_user_secret": .init(argumentKey: "prompt", activity: .secret, action: .request),
|
||
"scratch_list": .init(
|
||
argumentKey: "path", activity: .scratchWorkspace, action: .list
|
||
),
|
||
"scratch_read": .init(argumentKey: "path", activity: .scratchFile, action: .read),
|
||
"scratch_write": .init(argumentKey: "path", activity: .scratchFile, action: .write),
|
||
"scratch_edit": .init(argumentKey: "path", activity: .scratchFile, action: .edit),
|
||
"scratch_diff": .init(
|
||
argumentKey: "path_a", activity: .scratchFiles, action: .compare
|
||
),
|
||
"scratch_delete": .init(
|
||
argumentKey: "path", activity: .scratchEntry, action: .delete
|
||
),
|
||
"scratch_pull": .init(
|
||
argumentKey: "remote_path", activity: .remoteFile, action: .pull
|
||
),
|
||
"scratch_push": .init(
|
||
argumentKey: "remote_path", activity: .remoteFile, action: .push
|
||
),
|
||
"present_plan": .init(argumentKey: "title", activity: .plan, action: .present),
|
||
"manage_tasks": .init(argumentKey: nil, activity: .taskList, action: .update),
|
||
]
|
||
|
||
private static func lowercaseFirst(_ text: String) -> String {
|
||
guard let first = text.first else { return text }
|
||
return first.lowercased() + String(text.dropFirst())
|
||
}
|
||
|
||
private static func uppercaseFirst(_ text: String) -> String {
|
||
guard let first = text.first else { return text }
|
||
return first.uppercased() + String(text.dropFirst())
|
||
}
|
||
|
||
public static func tint(for state: AgentToolCard.State) -> BatchTint {
|
||
switch state {
|
||
case .denied, .failed: .failure
|
||
case .reviewing, .waitingForApproval, .waitingForUser: .warning
|
||
case .completed: .success
|
||
case .proposed, .running, .interrupted: .secondary
|
||
}
|
||
}
|
||
|
||
private static func humanized(_ name: String) -> String {
|
||
name.replacingOccurrences(of: "_", with: " ")
|
||
.replacingOccurrences(of: "-", with: " ")
|
||
}
|
||
|
||
private static func prettyJSON(_ value: AgentJSONValue) -> String {
|
||
guard let object = try? JSONSerialization.jsonObject(with: value.encodedData),
|
||
let data = try? JSONSerialization.data(
|
||
withJSONObject: object, options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
|
||
)
|
||
else { return value.encodedString }
|
||
return String(decoding: data, as: UTF8.self)
|
||
}
|
||
|
||
private static func normalized(_ text: String) -> String {
|
||
text.split(whereSeparator: \.isWhitespace).joined(separator: " ")
|
||
}
|
||
}
|