diff --git a/Frameworks/Resources/Localizable.xcstrings b/Frameworks/Resources/Localizable.xcstrings index ff5432fc..01ab18d8 100644 --- a/Frameworks/Resources/Localizable.xcstrings +++ b/Frameworks/Resources/Localizable.xcstrings @@ -49062,6 +49062,47 @@ } } }, + "Output" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ausgabe" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sortie" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "出力" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "출력" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "输出" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "輸出" + } + } + } + }, "Output shortened for display" : { "extractionState" : "manual", "localizations" : { diff --git a/Frameworks/TermindPersistence/AgentPersistence.swift b/Frameworks/TermindPersistence/AgentPersistence.swift index 6b273133..5698d747 100644 --- a/Frameworks/TermindPersistence/AgentPersistence.swift +++ b/Frameworks/TermindPersistence/AgentPersistence.swift @@ -84,11 +84,10 @@ public final class PersistentAgentMessage { id = message.id self.runID = runID role = message.role.rawValue - text = AgentPersistenceRedactor.visibleText( - AgentSecretLifecycle.persistedResultText( - toolName: message.toolName, original: message.text - ) - ) + text = + message.role == .tool + ? AgentPersistenceRedactor.toolResultText(message.text, toolName: message.toolName) + : AgentPersistenceRedactor.visibleText(message.text) authoredText = message.authoredText.map(AgentPersistenceRedactor.visibleText) imagesData = (try? JSONEncoder().encode(message.images)) ?? Data() reasoningData = AgentPersistenceRedactor.encoded( @@ -216,15 +215,25 @@ public final class PersistentAgentToolResult { runID = invocation.runID invocationID = invocation.id callID = result.callID - content = AgentPersistenceRedactor.visibleText( - AgentSecretLifecycle.persistedResultText( - toolName: invocation.call.name, original: result.content - ) - ) + content = AgentPersistenceRedactor.toolResultText(result.content, toolName: invocation.call.name) isError = result.isError - isTruncated = result.isTruncated || content.count < result.content.count + isTruncated = result.isTruncated metadataData = AgentPersistenceRedactor.encoded(result.metadata) } + + var restoredResult: AgentToolResult { + var result = AgentToolResult( + callID: callID, content: content, isError: isError, isTruncated: isTruncated, + metadata: (try? JSONDecoder().decode([String: AgentJSONValue].self, from: metadataData)) ?? [:]) + result.content = AgentPersistenceRedactor.restoredToolResultText(content, fallbacks: [result.modelContent]) + if result.content != content { + result.isTruncated = true + if result.content.hasPrefix(AgentToolResult.untrustedDataOpeningMarker) { + result.metadata["untrusted"] = .bool(true) + } + } + return result + } } @Model diff --git a/Frameworks/TermindPersistence/AgentPersistenceRedactor.swift b/Frameworks/TermindPersistence/AgentPersistenceRedactor.swift index c459d1f0..5faf2238 100644 --- a/Frameworks/TermindPersistence/AgentPersistenceRedactor.swift +++ b/Frameworks/TermindPersistence/AgentPersistenceRedactor.swift @@ -12,6 +12,36 @@ public nonisolated enum AgentPersistenceRedactor { AgentSensitiveDataRedactor.redactedJSON(value) } + static func toolResultText(_ text: String, toolName: String?) -> String { + let text = AgentSecretLifecycle.persistedResultText(toolName: toolName, original: text) + guard let value = decodedToolResult(text) else { + return AgentSensitiveDataRedactor.visibleText(text) + } + // Tool/runtime policies own truncation. Cutting a serialized envelope or + // redacting its escape sequences would destroy the retained presentation. + let payload = redactedJSON(value).encodedString + return text.hasPrefix(AgentToolResult.untrustedDataOpeningMarker) + ? AgentToolResult.untrustedDataOpeningMarker + payload + AgentToolResult.untrustedDataClosingMarker + : payload + } + + static func restoredToolResultText(_ text: String, fallbacks: [String?]) -> String { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard + trimmed.hasPrefix(AgentToolResult.untrustedDataOpeningMarker) + || trimmed.hasPrefix("{") || trimmed.hasPrefix("["), + decodedToolResult(text) == nil + else { return text } + // Earlier writers could corrupt JSON while leaving the frozen model + // projection intact. Recover only from valid structured data, never guess. + return fallbacks.compactMap { $0 }.first { decodedToolResult($0) != nil } ?? text + } + + private static func decodedToolResult(_ text: String) -> AgentJSONValue? { + let result = AgentToolResult(callID: "", content: text, metadata: ["untrusted": .bool(true)]) + return try? AgentJSONValue.decode(Data((result.untrustedPayload ?? text).utf8)) + } + public static func encoded(_ value: T) -> Data { (try? JSONEncoder().encode(value)) ?? Data() } diff --git a/Frameworks/TermindPersistence/PersistentAgentMessageReplay.swift b/Frameworks/TermindPersistence/PersistentAgentMessageReplay.swift index 66d241bf..7ea3d9ea 100644 --- a/Frameworks/TermindPersistence/PersistentAgentMessageReplay.swift +++ b/Frameworks/TermindPersistence/PersistentAgentMessageReplay.swift @@ -12,7 +12,11 @@ public final class PersistentAgentMessageReplay { public init(_ message: AgentTranscriptMessage) { messageID = message.id contextData = message.contextSnapshot.map(AgentPersistenceRedactor.encoded) ?? Data() - modelText = message.modelText.map(AgentPersistenceRedactor.visibleText) + modelText = message.modelText.map { + message.role == .tool + ? AgentPersistenceRedactor.toolResultText($0, toolName: message.toolName) + : AgentPersistenceRedactor.visibleText($0) + } } public var contextSnapshot: AgentTurnContextSnapshot? { diff --git a/Frameworks/TermindPersistence/SwiftDataAgentRunRepository.swift b/Frameworks/TermindPersistence/SwiftDataAgentRunRepository.swift index c1a24209..99c927cc 100644 --- a/Frameworks/TermindPersistence/SwiftDataAgentRunRepository.swift +++ b/Frameworks/TermindPersistence/SwiftDataAgentRunRepository.swift @@ -247,8 +247,20 @@ public final class SwiftDataAgentRunRepository: AgentRunPersisting, Sendable { (try? JSONDecoder().decode( [AgentReasoningBlock].self, from: row.reasoningData )) ?? [] + var text = row.text + var modelText = replayByMessage[row.id]?.modelText + if role == .tool { + let result = results.last { $0.runID == row.runID && $0.callID == row.toolCallID }? + .restoredResult + modelText = + modelText.map { + AgentPersistenceRedactor.restoredToolResultText($0, fallbacks: [result?.modelContent]) + } ?? result?.modelContent + text = AgentPersistenceRedactor.restoredToolResultText( + text, fallbacks: [result?.content, modelText]) + } return AgentTranscriptMessage( - id: row.id, role: role, text: row.text, + id: row.id, role: role, text: text, authoredText: row.authoredText, images: images, contextAttachments: contextAttachments, reasoning: reasoning, @@ -256,7 +268,7 @@ public final class SwiftDataAgentRunRepository: AgentRunPersisting, Sendable { toolCallID: row.toolCallID, toolName: row.toolName, isError: row.isError, isCompaction: row.isCompaction, contextSnapshot: replayByMessage[row.id]?.contextSnapshot, - modelText: replayByMessage[row.id]?.modelText, + modelText: modelText, origin: originByMessage[row.id], createdAt: row.createdAt ) @@ -805,13 +817,13 @@ public final class SwiftDataAgentRunRepository: AgentRunPersisting, Sendable { // The last recorded one: a call id is unique to its invocation, // so more than one row can only mean a rewritten result. guard let stored = results.last(where: { $0.callID == call.id }) else { continue } + let result = stored.restoredResult recovered.append( AgentTranscriptMessage( id: AgentTranscriptMessage.toolResultID(runID: stored.runID, callID: call.id), - role: .tool, text: stored.content, toolCallID: call.id, + role: .tool, text: result.content, toolCallID: call.id, toolName: names[call.id] ?? call.name, isError: stored.isError, - modelText: (try? JSONDecoder().decode([String: AgentJSONValue].self, from: stored.metadataData))?[ - AgentToolResult.modelContentKey]?.stringValue + modelText: result.modelContent )) } } @@ -846,15 +858,7 @@ public final class SwiftDataAgentRunRepository: AgentRunPersisting, Sendable { sourceMessageID: row.sourceMessageID, targetHostID: row.targetHostID, createdAt: row.createdAt ) - let result = results.last(where: { $0.invocationID == row.id }).map { stored in - AgentToolResult( - callID: stored.callID, content: stored.content, - isError: stored.isError, isTruncated: stored.isTruncated, - metadata: (try? JSONDecoder().decode( - [String: AgentJSONValue].self, from: stored.metadataData - )) ?? [:] - ) - } + let result = results.last(where: { $0.invocationID == row.id })?.restoredResult let descriptor = AgentToolDescriptor( name: row.toolName, summary: row.toolName, inputSchema: .object(["type": .string("object")]), diff --git a/Frameworks/TermindPresentation/SSHExecToolPresentation.swift b/Frameworks/TermindPresentation/SSHExecToolPresentation.swift index 3e742064..8ddf2b4f 100644 --- a/Frameworks/TermindPresentation/SSHExecToolPresentation.swift +++ b/Frameworks/TermindPresentation/SSHExecToolPresentation.swift @@ -72,7 +72,7 @@ nonisolated enum SSHExecToolPresentation { } let stdout = object["output"]?.stringValue ?? object["stdout"]?.stringValue ?? "" let stderr = object["stderr"]?.stringValue ?? "" - if !stdout.isEmpty { items.append(block(object["output"] == nil ? S.stdout : S.commandOutput, stdout)) } + if !stdout.isEmpty { items.append(block(object["output"] == nil ? S.stdout : S.output, stdout)) } if !stderr.isEmpty { items.append(block(S.stderr, stderr)) } if ["ssh_exec", "ssh_wait", "ssh_exec_run", "ssh_exec_output"].contains(card.descriptor.name), stdout.isEmpty, stderr.isEmpty diff --git a/Frameworks/TermindPresentation/Strings/SSHExec.swift b/Frameworks/TermindPresentation/Strings/SSHExec.swift index de6f68f2..efc224ee 100644 --- a/Frameworks/TermindPresentation/Strings/SSHExec.swift +++ b/Frameworks/TermindPresentation/Strings/SSHExec.swift @@ -5,6 +5,7 @@ extension AppStrings { public static var handle: LocalizedStringResource { "Handle" } public static var command: LocalizedStringResource { "Command" } public static var commandOutput: LocalizedStringResource { "the command output" } + public static var output: LocalizedStringResource { "Output" } public static var stdout: LocalizedStringResource { "Standard output" } public static var stderr: LocalizedStringResource { "Standard error" } public static var exitCode: LocalizedStringResource { "Exit code" }