Files
Termind/Frameworks/TermindPresentation/AgentToolPresentation.swift
T

1308 lines
59 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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: " ")
}
}