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.
This commit is contained in:
Kaede Akino committed 2026-10-17 23:03:20 +08:00
1 parent 7e78bc00c4
commit 2d967a7e32
7 files changed
+116 -27

No files matched your search

@@ -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" : {
@@ -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
@@ -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<T: Encodable>(_ value: T) -> Data {
(try? JSONEncoder().encode(value)) ?? Data()
}
@@ -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? {
@@ -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")]),
@@ -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
@@ -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" }