From 2d967a7e3278a5d2b34a0124605825876ce3937a Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Sat, 17 Oct 2026 23:03:20 +0800 Subject: [PATCH] fix(assistant): preserve structured tool results across restart Redact decoded tool result fields without cutting their JSON envelopes. Preserve frozen model projections and recover damaged historical presentation from valid retained output. Do not mistake shorter redacted content for truncation. Use Output as the command output section heading with localized equivalents. --- Frameworks/Resources/Localizable.xcstrings | 41 +++++++++++++++++++ .../TermindPersistence/AgentPersistence.swift | 31 +++++++++----- .../AgentPersistenceRedactor.swift | 30 ++++++++++++++ .../PersistentAgentMessageReplay.swift | 6 ++- .../SwiftDataAgentRunRepository.swift | 32 ++++++++------- .../SSHExecToolPresentation.swift | 2 +- .../TermindPresentation/Strings/SSHExec.swift | 1 + 7 files changed, 116 insertions(+), 27 deletions(-) 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" }