Make tools presentation better
This commit is contained in:
1 parent
f8065e8dd0
commit
c1239e9106
8 files changed
+742
-100
No files matched your search
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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] = [:],
|
||||
|
||||
@@ -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",
|
||||
] {
|
||||
|
||||
Reference in new issue
Block a user