Make tools presentation better

This commit is contained in:
Kaede Akino committed 2026-09-23 19:09:39 +08:00
1 parent f8065e8dd0
commit c1239e9106
8 files changed
+742 -100

No files matched your search

+463 -2
View File
@@ -1,6 +1,467 @@
{
"sourceLanguage" : "en",
"strings" : {
"provide" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "提供"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "提供"
}
}
}
},
"a suggestion" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "建议"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "建議"
}
}
}
},
"%lld suggestions" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"variations" : {
"plural" : {
"one" : {
"stringUnit" : {
"state" : "translated",
"value" : "%lld suggestion"
}
},
"other" : {
"stringUnit" : {
"state" : "translated",
"value" : "%lld suggestions"
}
}
}
}
},
"zh-Hans" : {
"variations" : {
"plural" : {
"other" : {
"stringUnit" : {
"state" : "translated",
"value" : "%lld条建议"
}
}
}
}
},
"zh-Hant" : {
"variations" : {
"plural" : {
"other" : {
"stringUnit" : {
"state" : "translated",
"value" : "%lld條建議"
}
}
}
}
}
}
},
"%@ (%lld times)" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "%1$@(%2$lld次)"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "%1$@(%2$lld次)"
}
}
}
},
"a file" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "文件"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "檔案"
}
}
}
},
"a directory" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "目录"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "目錄"
}
}
}
},
"%lld files" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"variations" : {
"plural" : {
"one" : {
"stringUnit" : {
"state" : "translated",
"value" : "%lld file"
}
},
"other" : {
"stringUnit" : {
"state" : "translated",
"value" : "%lld files"
}
}
}
}
},
"zh-Hans" : {
"variations" : {
"plural" : {
"other" : {
"stringUnit" : {
"state" : "translated",
"value" : "%lld个文件"
}
}
}
}
},
"zh-Hant" : {
"variations" : {
"plural" : {
"other" : {
"stringUnit" : {
"state" : "translated",
"value" : "%lld個檔案"
}
}
}
}
}
}
},
"%lld directories" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"variations" : {
"plural" : {
"one" : {
"stringUnit" : {
"state" : "translated",
"value" : "%lld directory"
}
},
"other" : {
"stringUnit" : {
"state" : "translated",
"value" : "%lld directories"
}
}
}
}
},
"zh-Hans" : {
"variations" : {
"plural" : {
"other" : {
"stringUnit" : {
"state" : "translated",
"value" : "%lld个目录"
}
}
}
}
},
"zh-Hant" : {
"variations" : {
"plural" : {
"other" : {
"stringUnit" : {
"state" : "translated",
"value" : "%lld個目錄"
}
}
}
}
}
}
},
"%lld scratch files" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"variations" : {
"plural" : {
"one" : {
"stringUnit" : {
"state" : "translated",
"value" : "%lld scratch file"
}
},
"other" : {
"stringUnit" : {
"state" : "translated",
"value" : "%lld scratch files"
}
}
}
}
},
"zh-Hans" : {
"variations" : {
"plural" : {
"other" : {
"stringUnit" : {
"state" : "translated",
"value" : "%lld个暂存文件"
}
}
}
}
},
"zh-Hant" : {
"variations" : {
"plural" : {
"other" : {
"stringUnit" : {
"state" : "translated",
"value" : "%lld個暫存檔案"
}
}
}
}
}
}
},
"%lld scratch items" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"variations" : {
"plural" : {
"one" : {
"stringUnit" : {
"state" : "translated",
"value" : "%lld scratch item"
}
},
"other" : {
"stringUnit" : {
"state" : "translated",
"value" : "%lld scratch items"
}
}
}
}
},
"zh-Hans" : {
"variations" : {
"plural" : {
"other" : {
"stringUnit" : {
"state" : "translated",
"value" : "%lld个暂存项"
}
}
}
}
},
"zh-Hant" : {
"variations" : {
"plural" : {
"other" : {
"stringUnit" : {
"state" : "translated",
"value" : "%lld個暫存項目"
}
}
}
}
}
}
},
"%lld URLs" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"variations" : {
"plural" : {
"one" : {
"stringUnit" : {
"state" : "translated",
"value" : "%lld URL"
}
},
"other" : {
"stringUnit" : {
"state" : "translated",
"value" : "%lld URLs"
}
}
}
}
},
"zh-Hans" : {
"variations" : {
"plural" : {
"other" : {
"stringUnit" : {
"state" : "translated",
"value" : "%lld个网址"
}
}
}
}
},
"zh-Hant" : {
"variations" : {
"plural" : {
"other" : {
"stringUnit" : {
"state" : "translated",
"value" : "%lld個網址"
}
}
}
}
}
}
},
"%lld plans" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"variations" : {
"plural" : {
"one" : {
"stringUnit" : {
"state" : "translated",
"value" : "%lld plan"
}
},
"other" : {
"stringUnit" : {
"state" : "translated",
"value" : "%lld plans"
}
}
}
}
},
"zh-Hans" : {
"variations" : {
"plural" : {
"other" : {
"stringUnit" : {
"state" : "translated",
"value" : "%lld份计划"
}
}
}
}
},
"zh-Hant" : {
"variations" : {
"plural" : {
"other" : {
"stringUnit" : {
"state" : "translated",
"value" : "%lld份計畫"
}
}
}
}
}
}
},
"%lld secrets" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"variations" : {
"plural" : {
"one" : {
"stringUnit" : {
"state" : "translated",
"value" : "%lld secret"
}
},
"other" : {
"stringUnit" : {
"state" : "translated",
"value" : "%lld secrets"
}
}
}
}
},
"zh-Hans" : {
"variations" : {
"plural" : {
"other" : {
"stringUnit" : {
"state" : "translated",
"value" : "%lld项机密信息"
}
}
}
}
},
"zh-Hant" : {
"variations" : {
"plural" : {
"other" : {
"stringUnit" : {
"state" : "translated",
"value" : "%lld項機密資訊"
}
}
}
}
}
}
},
"" : {
"localizations" : {
"zh-Hans" : {
@@ -25219,13 +25680,13 @@
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "生成建议"
"value" : "建议"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "產生建議"
"value" : "建議"
}
}
}
@@ -17,7 +17,13 @@ nonisolated extension AgentToolDescriptor.Presentation.ActivityKind {
public static let metrics = Self("metrics")
public static let remoteCommand = Self("remoteCommand")
public static let remoteFile = Self("remoteFile")
public static let remoteDirectory = Self("remoteDirectory")
public static let sshCommand = Self("sshCommand")
public static let suggestion = Self("suggestion")
}
nonisolated extension AgentToolDescriptor.Presentation.ActionKind {
public static let provide = Self("provide")
}
nonisolated extension AgentToolDescriptor.Presentation.PhaseKind {
@@ -105,6 +105,7 @@ nonisolated public enum AgentToolPresentation {
for card: AgentToolCard,
locale: Locale
) -> String {
if isTerminalSuggestion(card) { return activityName(.suggestion, locale: locale) }
guard let presentation = card.descriptor.presentation else {
return humanized(card.descriptor.qualifiedName)
}
@@ -120,7 +121,7 @@ nonisolated public enum AgentToolPresentation {
case .arrayCount, .localizedArrayCount, .semanticArrayCount:
return operation(for: card, locale: locale).text
case .semanticLabel(let kind), .semanticArgument(_, let kind):
return activityName(kind, locale: locale)
return activityName(resolvedActivityKind(kind, for: card), locale: locale)
}
}
@@ -496,6 +497,9 @@ nonisolated public enum AgentToolPresentation {
private static func operation(
for card: AgentToolCard, locale: Locale
) -> RenderedOperation {
if isTerminalSuggestion(card) {
return RenderedOperation(text: activityName(.suggestion, locale: locale), isArgumentValue: false)
}
guard let presentation = card.descriptor.presentation else {
if isChinese(locale),
let recovered = recoveredBuiltInOperation(
@@ -572,7 +576,7 @@ nonisolated public enum AgentToolPresentation {
)
case .semanticLabel(let kind):
return RenderedOperation(
text: activityName(kind, locale: locale), isArgumentValue: false
text: activityName(resolvedActivityKind(kind, for: card), locale: locale), isArgumentValue: false
)
case .semanticArgument(let key, let fallback):
guard let value = card.invocation.call.arguments.objectValue?[key]?.stringValue else {
@@ -580,7 +584,8 @@ nonisolated public enum AgentToolPresentation {
return RenderedOperation(text: "", isArgumentValue: false)
}
return RenderedOperation(
text: activityName(fallback, locale: locale), isArgumentValue: false
text: activityName(resolvedActivityKind(fallback, for: card), locale: locale),
isArgumentValue: false
)
}
let oneLine = value.split(whereSeparator: \.isNewline).joined(separator: " ")
@@ -589,7 +594,8 @@ nonisolated public enum AgentToolPresentation {
return RenderedOperation(text: "", isArgumentValue: false)
}
return RenderedOperation(
text: activityName(fallback, locale: locale), isArgumentValue: false
text: activityName(resolvedActivityKind(fallback, for: card), locale: locale),
isArgumentValue: false
)
}
return RenderedOperation(text: oneLine, isArgumentValue: true)
@@ -642,9 +648,17 @@ nonisolated public enum AgentToolPresentation {
_ cards: [AgentToolCard], locale: Locale
) -> String {
guard let first = cards.first else { return "" }
if isTerminalSuggestion(first) {
let activity =
cards.count == 1
? activityName(.suggestion, locale: locale)
: localized("\(cards.count) suggestions", locale: locale)
return actionSummary(
.provide, state: first.state, activity: activity, isArgumentValue: false, target: nil, locale: locale)
}
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)
let activity = repeatedActivity(operation.batchActivity, count: cards.count, locale: locale)
return AgentKnowledgeToolPresentation.title(
actionSummary(
operation.action, state: first.state, activity: activity,
@@ -654,15 +668,13 @@ nonisolated public enum AgentToolPresentation {
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
: semanticBatchActivity(for: first, count: cards.count, locale: locale)
?? RenderedOperation(
text: repeatedActivity(
humanized(first.descriptor.qualifiedName), count: cards.count, locale: locale),
isArgumentValue: false
)
}
if let actionKind = actionKind(for: first) {
return actionSummary(
actionKind, state: first.state, activity: activity.text,
isArgumentValue: activity.isArgumentValue,
@@ -673,17 +685,12 @@ nonisolated public enum AgentToolPresentation {
}
let semanticActivity = semanticBatchActivity(
presentation.activity, count: cards.count, locale: locale
for: first, 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,
@@ -707,25 +714,43 @@ nonisolated public enum AgentToolPresentation {
/// 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,
for card: AgentToolCard,
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
let kind: AgentToolDescriptor.Presentation.ActivityKind
switch card.descriptor.presentation?.activity {
case .semanticArgument(_, let value), .semanticLabel(let value):
kind = resolvedActivityKind(value, for: card)
case .none, .argument, .label:
guard card.descriptor.target != .mcp, let metadata = legacyMetadata[card.descriptor.name] else {
return nil
}
kind = metadata.activity
default: return nil
}
let text: String
switch kind {
case .command, .sshCommand: text = localized("\(count) commands", locale: locale)
case .remoteFile: text = localized("\(count) files", locale: locale)
case .remoteDirectory: text = localized("\(count) directories", locale: locale)
case .scratchFile: text = localized("\(count) scratch files", locale: locale)
case .scratchEntry: text = localized("\(count) scratch items", locale: locale)
case .url: text = localized("\(count) URLs", locale: locale)
case .plan: text = localized("\(count) plans", locale: locale)
case .secret: text = localized("\(count) secrets", locale: locale)
case .suggestion: text = localized("\(count) suggestions", locale: locale)
case .taskList: text = localized("the task list \(count) times", locale: locale)
// Lists, searches, comparisons and waits can revisit the same objects.
// Count the repetitions without inventing a number of results or files.
default: text = repeatedActivity(activityName(kind, locale: locale), count: count, locale: locale)
}
return RenderedOperation(text: text, isArgumentValue: false)
}
private static func repeatedActivity(_ activity: String, count: Int, locale: Locale) -> String {
count == 1 ? activity : localized("\(activity) (\(count) times)", locale: locale)
}
private static func replacingQuotedActivity(in template: String, with activity: String) -> String {
@@ -766,7 +791,7 @@ nonisolated public enum AgentToolPresentation {
text: countDescription(count, unit: unit, locale: locale),
isArgumentValue: false
)
case .arrayCount(let key, _, _, let fallback):
case .arrayCount(let key, let singular, let plural, let fallback):
let count = cards.reduce(0) { total, card in
total + (card.invocation.call.arguments.objectValue?[key]?.arrayValue?.count ?? 0)
}
@@ -780,46 +805,36 @@ nonisolated public enum AgentToolPresentation {
)
}
return RenderedOperation(
text: cards.count == 1 ? fallback : toolCallCount(cards.count, locale: locale),
text: count == 1 ? singular : "\(count) \(plural)",
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
)
return RenderedOperation(
text: repeatedActivity(
legacyActivity(fallback, descriptorName: cards[0].descriptor.name, locale: locale),
count: 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
)
return RenderedOperation(
text: repeatedActivity(
legacyActivity(label, descriptorName: cards[0].descriptor.name, locale: locale),
count: 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),
text: repeatedActivity(
activityName(resolvedActivityKind(fallback, for: cards[0]), locale: locale),
count: 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),
text: repeatedActivity(activityName(kind, locale: locale), count: cards.count, locale: locale),
isArgumentValue: false
)
}
@@ -837,10 +852,6 @@ nonisolated public enum AgentToolPresentation {
}
}
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 {
@@ -876,6 +887,7 @@ nonisolated public enum AgentToolPresentation {
private static func actionKind(
for card: AgentToolCard
) -> AgentToolDescriptor.Presentation.ActionKind? {
if isTerminalSuggestion(card) { return .provide }
if let kind = card.descriptor.presentation?.actionKind { return kind }
return legacyMetadata[card.descriptor.name]?.action
?? (card.descriptor.target == .mcp ? .run : nil)
@@ -970,26 +982,6 @@ nonisolated public enum AgentToolPresentation {
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
@@ -1089,7 +1081,8 @@ nonisolated public enum AgentToolPresentation {
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 .present: .init(infinitive: "present", progressive: "Presenting", past: "Presented")
case .provide: .init(infinitive: "provide", progressive: "Providing", past: "Provided")
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")
@@ -1135,6 +1128,7 @@ nonisolated public enum AgentToolPresentation {
case .list: localized("list", locale: locale)
case .move: localized("move", locale: locale)
case .present: localized("present", locale: locale)
case .provide: localized("provide", locale: locale)
case .pull: localized("pull", locale: locale)
case .push: localized("push", locale: locale)
case .read: localized("read", locale: locale)
@@ -1165,7 +1159,8 @@ nonisolated public enum AgentToolPresentation {
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 .remoteFile: localized("a file", locale: locale)
case .remoteDirectory: localized("a directory", 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)
@@ -1175,7 +1170,8 @@ nonisolated public enum AgentToolPresentation {
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 .sshCommand: localized("a command", locale: locale)
case .suggestion: localized("a suggestion", locale: locale)
case .taskList: localized("the task list", locale: locale)
case .url: localized("a URL", locale: locale)
case .web: localized("the web", locale: locale)
@@ -1194,10 +1190,31 @@ nonisolated public enum AgentToolPresentation {
) -> 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 }
let oldFallback =
(metadata.activity == .sshCommand && fallback == "the SSH command")
|| (metadata.activity == .remoteFile && fallback == "a remote file")
guard metadata.localizesAnyFallback || fallback == legacyFallback || oldFallback else { return fallback }
return activityName(metadata.activity, locale: locale)
}
/// Older semantic descriptors used the file noun for directory operations too.
private static func resolvedActivityKind(
_ kind: AgentToolDescriptor.Presentation.ActivityKind, for card: AgentToolCard
) -> AgentToolDescriptor.Presentation.ActivityKind {
if kind == .remoteFile, card.descriptor.target == .host,
["sftp_list", "sftp_mkdir"].contains(card.descriptor.name)
{
return .remoteDirectory
}
return kind
}
/// Suggestions create a user-operated card; historical descriptors must not
/// turn their command argument into an execution claim in the transcript.
private static func isTerminalSuggestion(_ card: AgentToolCard) -> Bool {
card.descriptor.target == .user && card.descriptor.name == "terminal_suggest"
}
private struct LegacyMetadata {
var argumentKey: String?
var activity: AgentToolDescriptor.Presentation.ActivityKind
@@ -1209,11 +1226,12 @@ nonisolated public enum AgentToolPresentation {
"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),
"get_metrics": .init(argumentKey: nil, activity: .metrics, action: .read),
"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),
"terminal_suggest": .init(argumentKey: nil, activity: .suggestion, action: .provide),
"sftp_list": .init(
argumentKey: "path", activity: .remoteFile, action: .list,
argumentKey: "path", activity: .remoteDirectory, action: .list,
localizesAnyFallback: true
),
"sftp_stat": .init(
@@ -1229,7 +1247,7 @@ nonisolated public enum AgentToolPresentation {
localizesAnyFallback: true
),
"sftp_mkdir": .init(
argumentKey: "path", activity: .remoteFile, action: .create,
argumentKey: "path", activity: .remoteDirectory, action: .create,
localizesAnyFallback: true
),
"sftp_move": .init(
@@ -1250,6 +1268,11 @@ nonisolated public enum AgentToolPresentation {
argumentKey: "path", activity: .scratchWorkspace, action: .list
),
"scratch_read": .init(argumentKey: "path", activity: .scratchFile, action: .read),
"scratch_search": .init(argumentKey: "query", activity: .scratchWorkspace, action: .search),
"scratch_copy": .init(argumentKey: "from", activity: .scratchEntry, action: .copy),
"scratch_move": .init(argumentKey: "from", activity: .scratchEntry, action: .move),
"scratch_fetch": .init(argumentKey: "url", activity: .url, action: .fetch),
"sftp_copy": .init(argumentKey: "source_path", activity: .remoteFile, action: .copy),
"scratch_write": .init(argumentKey: "path", activity: .scratchFile, action: .write),
"scratch_edit": .init(argumentKey: "path", activity: .scratchFile, action: .edit),
"scratch_diff": .init(
@@ -84,7 +84,7 @@ nonisolated public struct SFTPListTool: SFTPToolDefinition {
id: "builtin.sftp_list", present: present
)
public static let presentation = AgentToolDescriptor.Presentation(
symbol: "folder", activity: .semanticArgument(key: "path", fallback: .remoteFile),
symbol: "folder", activity: .semanticArgument(key: "path", fallback: .remoteDirectory),
output: .automatic, actionKind: .list
)
public let authentication: AgentSSHAuthenticationRegistry
@@ -223,7 +223,7 @@ nonisolated public struct SFTPMkdirTool: SFTPToolDefinition {
)
public static let presentation = AgentToolDescriptor.Presentation(
symbol: "folder.badge.plus",
activity: .semanticArgument(key: "path", fallback: .remoteFile),
activity: .semanticArgument(key: "path", fallback: .remoteDirectory),
output: .automatic, actionKind: .create
)
public let authentication: AgentSSHAuthenticationRegistry
@@ -143,8 +143,8 @@ nonisolated public struct TerminalSuggestTool: AgentToolDefinition, AgentToolSch
required: ["command"], target: .user, approvalPolicy: .approve,
concurrency: .parallel,
presentation: .init(
symbol: "terminal", activity: .semanticArgument(key: "command", fallback: .command),
groupsWithAdjacentTools: false, actionKind: .suggest
symbol: "terminal", activity: .semanticLabel(.suggestion),
groupsWithAdjacentTools: false, actionKind: .provide
)
)
}
@@ -65,7 +65,8 @@ import Testing
write.state = .failed
let title = AgentToolPresentation.batchSummary(for: [read, read, write], locale: english)
#expect(title.contains("Read persistent memory"))
#expect(title.contains("2 tool calls"))
#expect(title.contains("persistent memory (2 times)"))
#expect(!title.contains("tool calls"))
#expect(title.contains("failed to update persistent memory"))
#expect(!title.contains("skill"))
}
+150 -1
View File
@@ -5,12 +5,12 @@ import TermindDomain
import TermindInfrastructure
import TermindPersistence
import TermindPresentation
import TermindRemoteInfrastructure
import Testing
import struct TermindDomain.Host
@testable import Termind
@testable import TermindRemoteInfrastructure
@Suite
struct AgentToolPresentationTests {
@@ -917,6 +917,155 @@ struct AgentToolPresentationTests {
#expect(try JSONDecoder().decode(type(of: activity), from: data) == activity)
}
@Test
func batchesDescribeTheObjectsOfEachActionForCurrentAndHistoricalTools() {
let cases:
[(
String, AgentToolDescriptor.Presentation.ActivityKind,
AgentToolDescriptor.Presentation.ActionKind, String
)] = [
("ssh_exec", .sshCommand, .run, "Ran 2 commands"),
("terminal_suggest", .command, .suggest, "Provided 2 suggestions"),
("scratch_push", .remoteFile, .push, "Pushed 2 files"),
("scratch_pull", .remoteFile, .pull, "Pulled 2 files"),
("sftp_read", .remoteFile, .read, "Read 2 files"),
("sftp_write", .remoteFile, .write, "Wrote 2 files"),
("sftp_copy", .remoteFile, .copy, "Copied 2 files"),
("sftp_move", .remoteFile, .move, "Moved 2 files"),
("sftp_delete", .remoteFile, .delete, "Deleted 2 files"),
("sftp_stat", .remoteFile, .inspect, "Inspected 2 files"),
("sftp_chmod", .remoteFile, .changePermissions, "Changed permissions for 2 files"),
// Persisted semantic descriptors used remoteFile for directories.
("sftp_list", .remoteFile, .list, "Listed 2 directories"),
("sftp_mkdir", .remoteFile, .create, "Created 2 directories"),
("scratch_read", .scratchFile, .read, "Read 2 scratch files"),
("scratch_write", .scratchFile, .write, "Wrote 2 scratch files"),
("scratch_edit", .scratchFile, .edit, "Edited 2 scratch files"),
("scratch_copy", .scratchEntry, .copy, "Copied 2 scratch items"),
("scratch_move", .scratchEntry, .move, "Moved 2 scratch items"),
("scratch_delete", .scratchEntry, .delete, "Deleted 2 scratch items"),
("scratch_diff", .scratchFiles, .compare, "Compared scratch files (2 times)"),
("scratch_list", .scratchWorkspace, .list, "Listed the scratch workspace (2 times)"),
("scratch_search", .scratchWorkspace, .search, "Searched the scratch workspace (2 times)"),
("fetch", .url, .fetch, "Fetched 2 URLs"),
("scratch_fetch", .url, .fetch, "Fetched 2 URLs"),
("web_search", .web, .search, "Searched the web (2 times)"),
("list_hosts", .hosts, .list, "Listed hosts (2 times)"),
("get_metrics", .metrics, .read, "Read host metrics (2 times)"),
("ssh_wait", .remoteCommand, .wait, "Waited for the remote command (2 times)"),
("present_plan", .plan, .present, "Presented 2 plans"),
("manage_tasks", .taskList, .update, "Updated the task list 2 times"),
("request_user_secret", .secret, .request, "Requested 2 secrets"),
]
for (name, kind, action, expected) in cases {
let current = AgentToolDescriptor.Presentation(
symbol: "wrench", activity: .semanticArgument(key: "path", fallback: kind), actionKind: action)
let legacy = AgentToolDescriptor.Presentation(
symbol: "wrench", activity: .argument(key: "path", fallback: "legacy activity"))
for presentation in [current, legacy, nil] {
var card = makeCard(
name: name, target: name == "terminal_suggest" ? .user : .host, presentation: presentation)
card.state = .completed
#expect(
AgentToolPresentation.batchSummary(for: [card, card], locale: Locale(identifier: "en")) == expected,
"\(name): \(String(describing: presentation))")
for language in ["zh-Hans", "zh-Hant"] {
let title = AgentToolPresentation.batchSummary(
for: [card, card], locale: Locale(identifier: language))
#expect(!title.contains("tool calls"))
#expect(!title.contains("工具调用"))
#expect(!title.contains("工具呼叫"))
#expect(!title.contains("legacy activity"))
}
}
}
}
@Test
func executionAndSuggestionUseDifferentVerbsInAllSupportedLanguages() {
let english = Locale(identifier: "en")
for activity: AgentToolDescriptor.Presentation.Activity in [
.semanticArgument(key: "command", fallback: .sshCommand),
.argument(key: "command", fallback: "the SSH command"),
] {
var command = makeCard(
name: "ssh_exec", target: .host, arguments: ["command": .string("pwd")],
presentation: .init(symbol: "terminal", activity: activity, actionKind: .run))
command.state = .completed
#expect(AgentToolPresentation.batchSummary(for: [command], locale: english) == "Ran a command")
#expect(AgentToolPresentation.summary(for: command, locale: english) == "Ran pwd")
}
let legacy = AgentToolDescriptor.Presentation(
symbol: "terminal", activity: .semanticArgument(key: "command", fallback: .command), actionKind: .suggest)
for presentation in [TerminalSuggestTool().descriptor.presentation, legacy, nil] {
var suggestion = makeCard(
name: "terminal_suggest", target: .user, arguments: ["command": .string("pwd")],
presentation: presentation)
suggestion.state = .completed
#expect(AgentToolPresentation.summary(for: suggestion, locale: english) == "Provided a suggestion")
#expect(AgentToolPresentation.batchSummary(for: [suggestion], locale: english) == "Provided a suggestion")
for (language, expected) in [("zh-Hans", "已提供2条建议"), ("zh-Hant", "已提供2條建議")] {
#expect(
AgentToolPresentation.batchSummary(
for: [suggestion, suggestion], locale: Locale(identifier: language))
== expected)
}
}
}
@Test
func fileBatchesPreserveEveryLifecycleStateAndMixedOutcomes() {
var card = makeCard(
name: "sftp_write", target: .host, presentation: SFTPWriteTool.presentation)
let english = Locale(identifier: "en")
let states: [(AgentToolCard.State, String)] = [
(.proposed, "Preparing to write 2 files"), (.reviewing, "Preparing to write 2 files"),
(.waitingForApproval, "Waiting to write 2 files"), (.waitingForUser, "Waiting to write 2 files"),
(.running, "Writing 2 files"), (.completed, "Wrote 2 files"),
(.denied, "Did not write 2 files"), (.failed, "Failed to write 2 files"),
(.interrupted, "Interrupted while writing 2 files"),
]
for (state, expected) in states {
card.state = state
#expect(AgentToolPresentation.batchSummary(for: [card, card], locale: english) == expected)
}
card.state = .completed
var failed = card
failed.state = .failed
#expect(
AgentToolPresentation.batchSummary(for: [card, card, failed], locale: english)
== "Wrote 2 files, failed to write a file")
for (language, expected) in [("zh-Hans", "已写入2个文件,写入文件失败"), ("zh-Hant", "已寫入2個檔案,寫入檔案失敗")] {
#expect(
AgentToolPresentation.batchSummary(for: [card, card, failed], locale: Locale(identifier: language))
== expected)
}
}
@Test
func batchesCountArrayElementsAndKeepUnknownToolObjects() {
for activity: AgentToolDescriptor.Presentation.Activity in [
.semanticArrayCount(key: "questions", unit: .question, fallback: .question),
.arrayCount(key: "questions", singular: "a question", plural: "questions", fallback: "a question"),
] {
var card = makeCard(
name: "request_user_input", target: .user,
arguments: ["questions": .array([.object([:]), .object([:])])],
presentation: .init(symbol: "questionmark", activity: activity, actionKind: .ask))
card.state = .completed
#expect(
AgentToolPresentation.batchSummary(for: [card, card], locale: Locale(identifier: "en"))
== "Asked 4 questions")
}
var custom = makeCard(
name: "scratch_push", target: .mcp,
presentation: .init(symbol: "wrench", activity: .label("artifacts"), actionKind: .push))
custom.state = .completed
#expect(
AgentToolPresentation.batchSummary(for: [custom, custom], locale: Locale(identifier: "en"))
== "Pushed artifacts (2 times)")
}
private func makeCard(
name: String, target: AgentToolDescriptor.Target,
arguments: [String: AgentJSONValue] = [:],
+2
View File
@@ -208,6 +208,8 @@ struct AppLanguageTests {
// `%lld results` is not here: `web_search`'s card moved to AgentKit
// with the runtime, and so did the plural resource behind it.
"%lld commands", "%lld hosts", "%lld items", "%lld questions",
"%lld files", "%lld directories", "%lld scratch files", "%lld scratch items",
"%lld URLs", "%lld plans", "%lld secrets", "%lld suggestions",
"the task list %lld times",
"%lld tool calls", "%@ — %lld tools",
] {