mirror of
https://github.com/Tencent/WeKnora.git
synced 2026-09-01 14:53:07 +08:00
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:
+107
-16
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user