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:
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" }
|
||||
|
||||
Reference in new issue
Block a user