From c1239e9106d26bec5c169bbe1b4eae0be2eeffc6 Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Wed, 23 Sep 2026 19:09:39 +0800 Subject: [PATCH] Make tools presentation better --- Frameworks/Resources/Localizable.xcstrings | 465 +++++++++++++++++- .../Agent/TermindAgentPresentation.swift | 6 + .../AgentToolPresentation.swift | 207 ++++---- .../Agent/AgentBuiltInSFTPTools.swift | 4 +- .../Agent/AgentBuiltInSSHTools.swift | 4 +- .../AgentKnowledgeToolPresentationTests.swift | 3 +- TFMTests/AgentToolPresentationTests.swift | 151 +++++- TFMTests/AppLanguageTests.swift | 2 + 8 files changed, 742 insertions(+), 100 deletions(-) diff --git a/Frameworks/Resources/Localizable.xcstrings b/Frameworks/Resources/Localizable.xcstrings index 97bbb532..9577acd9 100644 --- a/Frameworks/Resources/Localizable.xcstrings +++ b/Frameworks/Resources/Localizable.xcstrings @@ -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" : "建議" } } } diff --git a/Frameworks/TermindApplication/Agent/TermindAgentPresentation.swift b/Frameworks/TermindApplication/Agent/TermindAgentPresentation.swift index 95da7cdc..1c42195f 100644 --- a/Frameworks/TermindApplication/Agent/TermindAgentPresentation.swift +++ b/Frameworks/TermindApplication/Agent/TermindAgentPresentation.swift @@ -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 { diff --git a/Frameworks/TermindPresentation/AgentToolPresentation.swift b/Frameworks/TermindPresentation/AgentToolPresentation.swift index fbebacd6..de9cc74f 100644 --- a/Frameworks/TermindPresentation/AgentToolPresentation.swift +++ b/Frameworks/TermindPresentation/AgentToolPresentation.swift @@ -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( diff --git a/Frameworks/TermindRemoteInfrastructure/Agent/AgentBuiltInSFTPTools.swift b/Frameworks/TermindRemoteInfrastructure/Agent/AgentBuiltInSFTPTools.swift index e6e0d3b5..296299ea 100644 --- a/Frameworks/TermindRemoteInfrastructure/Agent/AgentBuiltInSFTPTools.swift +++ b/Frameworks/TermindRemoteInfrastructure/Agent/AgentBuiltInSFTPTools.swift @@ -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 diff --git a/Frameworks/TermindRemoteInfrastructure/Agent/AgentBuiltInSSHTools.swift b/Frameworks/TermindRemoteInfrastructure/Agent/AgentBuiltInSSHTools.swift index 01d938ff..79132658 100644 --- a/Frameworks/TermindRemoteInfrastructure/Agent/AgentBuiltInSSHTools.swift +++ b/Frameworks/TermindRemoteInfrastructure/Agent/AgentBuiltInSSHTools.swift @@ -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 ) ) } diff --git a/TFMTests/AgentKnowledgeToolPresentationTests.swift b/TFMTests/AgentKnowledgeToolPresentationTests.swift index 2781c089..cabcdd75 100644 --- a/TFMTests/AgentKnowledgeToolPresentationTests.swift +++ b/TFMTests/AgentKnowledgeToolPresentationTests.swift @@ -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")) } diff --git a/TFMTests/AgentToolPresentationTests.swift b/TFMTests/AgentToolPresentationTests.swift index c6c91e63..b142ace0 100644 --- a/TFMTests/AgentToolPresentationTests.swift +++ b/TFMTests/AgentToolPresentationTests.swift @@ -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] = [:], diff --git a/TFMTests/AppLanguageTests.swift b/TFMTests/AppLanguageTests.swift index c05e4671..f6502065 100644 --- a/TFMTests/AppLanguageTests.swift +++ b/TFMTests/AppLanguageTests.swift @@ -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", ] {