fix(agent): keep compacted sandbox streams in persisted history

shell_exec with display_type was collapsed to a one-line omit placeholder,
so the next turn and a history reload lost stdout/stderr. Keep a capped
copy of the streams in both Output and Data.
This commit is contained in:
wizardchen
2026-08-26 19:52:33 +08:00
committed by lyingbug
parent 24560046ce
commit 87d8e8a9b6
2 changed files with 185 additions and 17 deletions
+107 -16
View File
@@ -13,9 +13,11 @@ var persistStripFields = map[string][]string{
"grep_results": {"chunk_results"},
}
// persistStripFieldsByTool drops binary / duplicate blobs. stdout/stderr stay
// (compacted separately) so a history reload can still render the card.
var persistStripFieldsByTool = map[string][]string{
ToolShellExec: {"stdout", "stderr", "content", "content_base64"},
ToolReadSandboxFile: {"stdout", "stderr", "content", "content_base64"},
ToolShellExec: {"content", "content_base64"},
ToolReadSandboxFile: {"content", "content_base64"},
}
// clientStripFieldsByTool is the lighter omit list for live SSE. The UI
@@ -92,6 +94,9 @@ func StreamContentForToolResult(toolName string, success bool, errMsg string, da
if !success {
return errMsg
}
if isSandboxContentTool(toolName) {
return compactShellExecHeadline(data)
}
if ShouldOmitRawToolOutput(toolName, data) {
return compactToolSummary(success, errMsg, data)
}
@@ -116,12 +121,17 @@ func SanitizeAgentStepsForStorage(steps []types.AgentStep) []types.AgentStep {
continue
}
result := *tc.Result
if ShouldOmitRawToolOutput(tc.Name, result.Data) {
result.Output = compactToolSummary(result.Success, result.Error, result.Data)
} else if isSandboxContentTool(tc.Name) {
if isSandboxContentTool(tc.Name) {
// display_type is for the live card; history still needs the
// command, exit, and a head+tail of the streams. Replacing
// that with a one-line "output omitted" leaves the next turn
// (and a reload of the card) with no structure at all.
result.Output = compactHistoricalSandboxOutput(result.Output)
} else if ShouldOmitRawToolOutput(tc.Name, result.Data) {
result.Output = compactToolSummary(result.Success, result.Error, result.Data)
}
result.Data = SanitizeToolDataForPersist(tc.Name, result.Data)
compactSandboxStreamFields(result.Data)
toolCalls[j].Result = &result
}
out[i].ToolCalls = toolCalls
@@ -140,10 +150,12 @@ func CompactToolOutputForHistory(toolName string, result *types.ToolResult) stri
}
return "Error: tool call failed"
}
if result.Output != "" && !ShouldOmitRawToolOutput(toolName, result.Data) {
if isSandboxContentTool(toolName) {
return compactHistoricalSandboxOutput(result.Output)
if isSandboxContentTool(toolName) {
if rebuilt := compactSandboxHistory(result); rebuilt != "" {
return rebuilt
}
}
if result.Output != "" && !ShouldOmitRawToolOutput(toolName, result.Data) {
return result.Output
}
return compactToolSummary(result.Success, result.Error, result.Data)
@@ -164,6 +176,90 @@ func compactHistoricalSandboxOutput(output string) string {
return output[:head] + marker + output[len(output)-tail:]
}
func compactSandboxStreamFields(data map[string]interface{}) {
if data == nil {
return
}
for _, key := range []string{"stdout", "stderr"} {
raw, ok := data[key]
if !ok || raw == nil {
continue
}
s, ok := raw.(string)
if !ok || s == "" {
continue
}
data[key] = compactHistoricalSandboxOutput(s)
}
}
func compactSandboxHistory(result *types.ToolResult) string {
if result == nil {
return ""
}
if result.Output != "" && !isOmittedHistoryPlaceholder(result.Output) {
return compactHistoricalSandboxOutput(result.Output)
}
if rebuilt := rebuildShellExecHistory(result.Data); rebuilt != "" {
return rebuilt
}
return compactHistoricalSandboxOutput(result.Output)
}
func isOmittedHistoryPlaceholder(output string) bool {
return strings.Contains(output, "omitted from history")
}
func compactShellExecHeadline(data map[string]interface{}) string {
exit := intField(data, "exit_code")
cmd := stringField(data, "command")
if cmd == "" {
return fmt.Sprintf("shell_exec exit=%d", exit)
}
const maxCmd = 240
if len(cmd) > maxCmd {
cmd = cmd[:maxCmd] + "..."
}
return fmt.Sprintf("shell_exec exit=%d command=%s", exit, cmd)
}
func rebuildShellExecHistory(data map[string]interface{}) string {
if data == nil {
return ""
}
stdout := stringField(data, "stdout")
stderr := stringField(data, "stderr")
if stdout == "" && stderr == "" {
return ""
}
var b strings.Builder
fmt.Fprintf(&b, "shell_exec exit=%d", intField(data, "exit_code"))
if cmd := stringField(data, "command"); cmd != "" {
fmt.Fprintf(&b, " command=%s", cmd)
}
if wd := stringField(data, "work_dir"); wd != "" {
fmt.Fprintf(&b, " work_dir=%s", wd)
}
b.WriteByte('\n')
if stdout != "" {
b.WriteString("## Stdout\n```\n")
b.WriteString(stdout)
if !strings.HasSuffix(stdout, "\n") {
b.WriteByte('\n')
}
b.WriteString("```\n")
}
if stderr != "" {
b.WriteString("## Stderr\n```\n")
b.WriteString(stderr)
if !strings.HasSuffix(stderr, "\n") {
b.WriteByte('\n')
}
b.WriteString("```\n")
}
return compactHistoricalSandboxOutput(b.String())
}
func compactToolSummary(success bool, errMsg string, data map[string]interface{}) string {
if !success {
if errMsg != "" {
@@ -206,15 +302,10 @@ func compactToolSummary(success bool, errMsg string, data map[string]interface{}
return fmt.Sprintf("Semantic search returned %d result(s) (details omitted from history)", count)
}
case "shell_exec":
exit := intField(data, "exit_code")
cmd := stringField(data, "command")
if cmd != "" {
if len(cmd) > 80 {
cmd = cmd[:80] + "..."
}
return fmt.Sprintf("shell_exec exit=%d command=%s (output omitted from history)", exit, cmd)
if rebuilt := rebuildShellExecHistory(data); rebuilt != "" {
return rebuilt
}
return fmt.Sprintf("shell_exec exit=%d (output omitted from history)", exit)
return compactShellExecHeadline(data)
case "attachment_parsing":
parsed := intField(data, "parsed_count")
skipped := intField(data, "skipped_count")
+78 -1
View File
@@ -119,7 +119,13 @@ func TestSandboxToolPersistenceStripsDuplicatePayloadsAndCompactsHistory(t *test
}
}
assert(len(result.Output) <= historicalSandboxOutputChars, "persisted shell output must be capped")
for _, key := range []string{"stdout", "stderr", "content", "content_base64"} {
assert(strings.Contains(result.Output, "shell output"),
"persisted output must keep the stream, not a one-line omit")
stdout, _ := result.Data["stdout"].(string)
stderr, _ := result.Data["stderr"].(string)
assert(stdout != "" && len(stdout) <= historicalSandboxOutputChars, "stdout should be kept and capped")
assert(stderr != "" && len(stderr) <= historicalSandboxOutputChars, "stderr should be kept and capped")
for _, key := range []string{"content", "content_base64"} {
_, exists := result.Data[key]
assert(!exists, key+" should be stripped")
}
@@ -128,6 +134,55 @@ func TestSandboxToolPersistenceStripsDuplicatePayloadsAndCompactsHistory(t *test
"historical replay must independently cap legacy raw output")
}
func TestSanitizeAgentStepsForStorage_shellExecKeepsStructuredOutput(t *testing.T) {
skillDir := "/opt/weknora/tenant/skills/smart-charts"
command := skillDir + "/.venv/bin/python " + skillDir + "/plot.py"
stdout := "README.md\ncharts.py\nrequirements.txt\n"
markdown := "=== Shell Exec ===\n**Command**: `" + command + "`\n" +
"**Work Dir**: " + skillDir + "\n**Exit Code**: 0\n\n" +
"## Stdout\n\n```\n" + stdout + "```\n"
steps := []types.AgentStep{{
ToolCalls: []types.ToolCall{{
Name: ToolShellExec,
Result: &types.ToolResult{
Success: true,
Output: markdown,
Data: map[string]interface{}{
"display_type": "shell_exec",
"command": command,
"work_dir": skillDir,
"exit_code": 0,
"stdout": stdout,
"stderr": "",
},
},
}},
}}
sanitized := SanitizeAgentStepsForStorage(steps)
result := sanitized[0].ToolCalls[0].Result
if strings.Contains(result.Output, "omitted from history") {
t.Fatalf("structured shell_exec must not collapse to an omit line, got %q", result.Output)
}
if !strings.Contains(result.Output, "README.md") {
t.Fatalf("persisted output should keep stdout structure, got %q", result.Output)
}
if got, _ := result.Data["stdout"].(string); got != stdout {
t.Fatalf("persisted stdout should remain for the UI card, got %#v", result.Data["stdout"])
}
history := CompactToolOutputForHistory(ToolShellExec, result)
if strings.Contains(history, "omitted from history") {
t.Fatalf("history replay must keep the streams, got %q", history)
}
if !strings.Contains(history, "README.md") {
t.Fatalf("history replay should keep stdout, got %q", history)
}
if !strings.Contains(history, "plot.py") {
t.Fatalf("history replay should keep the full command, got %q", history)
}
}
func TestSanitizeToolResultForClientKeepsShellStreams(t *testing.T) {
meta := SanitizeToolResultForClient(ToolShellExec, &types.ToolResult{
Success: true,
@@ -153,3 +208,25 @@ func TestSanitizeToolResultForClientKeepsShellStreams(t *testing.T) {
t.Fatalf("display_type should remain, got %#v", meta["display_type"])
}
}
func TestCompactToolOutputForHistory_recoversStreamsFromPlaceholder(t *testing.T) {
history := CompactToolOutputForHistory(ToolShellExec, &types.ToolResult{
Success: true,
Output: "shell_exec exit=0 command=ls (output omitted from history)",
Data: map[string]interface{}{
"display_type": "shell_exec",
"command": "ls /opt/weknora/tenant/skills/smart-charts",
"exit_code": 0,
"stdout": "SKILL.md\nplot.py\n",
},
})
if strings.Contains(history, "omitted from history") {
t.Fatalf("should rebuild from stdout instead of the omit placeholder, got %q", history)
}
if !strings.Contains(history, "SKILL.md") {
t.Fatalf("rebuilt history should keep stdout, got %q", history)
}
if !strings.Contains(history, "smart-charts") {
t.Fatalf("rebuilt history should keep the command, got %q", history)
}
}