Files
CyberStrikeAI/internal/agent/agent_trace.go
T
Sycun 983c981b92 fix: preserve human intent through planning and restart while keeping legacy edits
Mark generated execution prompts, populate deterministic user sections from actual human entries, preserve canonical ledger on overflow/transient restarts, fit long human entries under their formatted budget, and keep unchanged historical keys editable. Add real reduction-to-snapshot Cookie recovery and production entry-point regression tests.
2026-10-12 19:34:24 +08:00

289 lines
9.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package agent
import (
"encoding/json"
"strings"
)
const ModelFacingTraceVersionKey = "cyberstrike_model_facing_trace_version"
const SummarizationContentTypeKey = "_eino_summarization_content_type"
const OriginalUserIntentEntriesKey = "_csai_original_user_intent_entries"
// IsModelFacingTraceJSON reports whether a persisted trace was produced from the final
// model-boundary state. Legacy traces have no version marker and require one-time migration.
func IsModelFacingTraceJSON(traceInputJSON string) bool {
var raw []map[string]interface{}
if err := json.Unmarshal([]byte(strings.TrimSpace(traceInputJSON)), &raw); err != nil {
return false
}
for _, msg := range raw {
extra, _ := msg["extra"].(map[string]interface{})
v, ok := extra[ModelFacingTraceVersionKey]
if !ok {
continue
}
switch n := v.(type) {
case float64:
return n >= 1
case int:
return n >= 1
}
}
return false
}
// ParseTraceMessages 解析落库的 last_react_input(OpenAI 风格 messages JSON 数组)。
func ParseTraceMessages(traceInputJSON string) ([]ChatMessage, error) {
traceInputJSON = strings.TrimSpace(traceInputJSON)
if traceInputJSON == "" {
return nil, nil
}
var raw []map[string]interface{}
if err := json.Unmarshal([]byte(traceInputJSON), &raw); err != nil {
return nil, err
}
modelFacing := IsModelFacingTraceJSON(traceInputJSON)
out := make([]ChatMessage, 0, len(raw))
for _, msgMap := range raw {
msg := ChatMessage{}
role, _ := msgMap["role"].(string)
if role == "" {
continue
}
msg.Role = role
msg.ModelFacingTrace = modelFacing
extra, _ := msgMap["extra"].(map[string]interface{})
if strings.EqualFold(role, "user") {
kind, _ := extra[SummarizationContentTypeKey].(string)
if kind == "summary" || kind == "skills" {
msg.SummarizationKind = kind
}
generatedKind, _ := extra[GeneratedContextTypeKey].(string)
if IsGeneratedExecutionContext(generatedKind) {
msg.GeneratedContextKind = generatedKind
}
}
if strings.EqualFold(role, "system") {
msg.OriginalUserIntentEntries = traceUserIntentEntries(extra[OriginalUserIntentEntriesKey])
}
if content, ok := msgMap["content"].(string); ok {
msg.Content = content
}
// Native summaries may carry text only in user_input_multi_content.
if msg.Content == "" {
if parts, ok := msgMap["user_input_multi_content"].([]interface{}); ok {
var texts []string
for _, rawPart := range parts {
part, _ := rawPart.(map[string]interface{})
if part["type"] == "text" {
if text, ok := part["text"].(string); ok {
texts = append(texts, text)
}
}
}
msg.Content = strings.Join(texts, "\n")
}
}
if rc, ok := msgMap["reasoning_content"].(string); ok && strings.TrimSpace(rc) != "" {
msg.ReasoningContent = rc
}
if toolCallsRaw, ok := msgMap["tool_calls"]; ok && toolCallsRaw != nil {
if toolCallsArray, ok := toolCallsRaw.([]interface{}); ok {
for _, tcRaw := range toolCallsArray {
tcMap, ok := tcRaw.(map[string]interface{})
if !ok {
continue
}
toolCall := ToolCall{}
if id, ok := tcMap["id"].(string); ok {
toolCall.ID = id
}
if toolType, ok := tcMap["type"].(string); ok {
toolCall.Type = toolType
}
if funcMap, ok := tcMap["function"].(map[string]interface{}); ok {
toolCall.Function = FunctionCall{}
if name, ok := funcMap["name"].(string); ok {
toolCall.Function.Name = name
}
if argsRaw, ok := funcMap["arguments"]; ok {
if argsStr, ok := argsRaw.(string); ok {
var argsMap map[string]interface{}
if err := json.Unmarshal([]byte(argsStr), &argsMap); err == nil {
toolCall.Function.Arguments = argsMap
}
} else if argsMap, ok := argsRaw.(map[string]interface{}); ok {
toolCall.Function.Arguments = argsMap
}
}
}
if toolCall.ID != "" {
msg.ToolCalls = append(msg.ToolCalls, toolCall)
}
}
}
}
if toolCallID, ok := msgMap["tool_call_id"].(string); ok {
msg.ToolCallID = toolCallID
}
if tn, ok := msgMap["tool_name"].(string); ok && strings.TrimSpace(tn) != "" {
msg.ToolName = strings.TrimSpace(tn)
} else if tn, ok := msgMap["name"].(string); ok && strings.TrimSpace(tn) != "" && strings.EqualFold(msg.Role, "tool") {
msg.ToolName = strings.TrimSpace(tn)
}
out = append(out, msg)
}
return out, nil
}
// traceUserIntentEntries accepts only the host's structured ledger. Malformed
// metadata never falls back to parsing arbitrary system/assistant/tool text.
func traceUserIntentEntries(value interface{}) []string {
entries, ok := value.([]interface{})
if !ok {
return nil
}
out := make([]string, 0, len(entries))
for _, entry := range entries {
text, ok := entry.(string)
if !ok {
return nil
}
if strings.TrimSpace(text) != "" {
out = append(out, text)
}
}
return out
}
// TraceMessagesForResume drops old instructions while carrying the structured
// human ledger separately. historyToMessages renders it under current budgets.
func TraceMessagesForResume(msgs []ChatMessage) []ChatMessage {
out := make([]ChatMessage, 0, len(msgs))
for _, msg := range msgs {
if strings.EqualFold(msg.Role, "system") {
if len(msg.OriginalUserIntentEntries) == 0 {
continue
}
msg.Content = ""
msg.ToolCalls = nil
msg.ReasoningContent = ""
}
out = append(out, msg)
}
return out
}
// ExtractLastUserTurnMessages trims at the last human turn, not an internally
// generated user-role summary. Preserve the host ledger across the trim.
func ExtractLastUserTurnMessages(msgs []ChatMessage) []ChatMessage {
lastUser := 0
var ledger *ChatMessage
for i := range msgs {
m := msgs[i]
if strings.EqualFold(m.Role, "user") && m.SummarizationKind == "" && !IsGeneratedExecutionContext(m.GeneratedContextKind) {
lastUser = i
}
if strings.EqualFold(m.Role, "system") && len(m.OriginalUserIntentEntries) > 0 {
ledger = &m
}
}
out := TraceMessagesForResume(msgs[lastUser:])
if ledger != nil && lastUser > 0 {
out = append(TraceMessagesForResume([]ChatMessage{*ledger}), out...)
}
return out
}
// ExtractLastUserTurnTraceJSON retains the host ledger and source markers in
// attack-chain history without mistaking a generated summary for a human turn.
func ExtractLastUserTurnTraceJSON(traceInputJSON string) string {
var arr []map[string]interface{}
if err := json.Unmarshal([]byte(traceInputJSON), &arr); err != nil {
return traceInputJSON
}
lastUser := 0
var ledger map[string]interface{}
for i, m := range arr {
role, _ := m["role"].(string)
extra, _ := m["extra"].(map[string]interface{})
kind, _ := extra[SummarizationContentTypeKey].(string)
generatedKind, _ := extra[GeneratedContextTypeKey].(string)
if strings.EqualFold(role, "user") && kind != "summary" && kind != "skills" && !IsGeneratedExecutionContext(generatedKind) {
lastUser = i
}
if strings.EqualFold(role, "system") {
if entries := traceUserIntentEntries(extra[OriginalUserIntentEntriesKey]); len(entries) > 0 {
ledger = map[string]interface{}{"role": "system", "extra": map[string]interface{}{OriginalUserIntentEntriesKey: entries}}
}
}
}
if lastUser <= 0 {
return traceInputJSON
}
trimmed := make([]map[string]interface{}, 0, len(arr)-lastUser+1)
if ledger != nil {
trimmed = append(trimmed, ledger)
}
trimmed = append(trimmed, arr[lastUser:]...)
b, err := json.Marshal(trimmed)
if err != nil {
return traceInputJSON
}
return string(b)
}
// MergeAssistantTraceOutput 将 last_react_output 合并进轨迹最后一条 assistant(与 loadHistoryFromAgentTrace 一致)。
func MergeAssistantTraceOutput(msgs []ChatMessage, assistantOut string) []ChatMessage {
assistantOut = strings.TrimSpace(assistantOut)
if assistantOut == "" || len(msgs) == 0 {
return msgs
}
out := append([]ChatMessage(nil), msgs...)
last := &out[len(out)-1]
if strings.EqualFold(last.Role, "assistant") && len(last.ToolCalls) == 0 {
last.Content = assistantOut
return out
}
out = append(out, ChatMessage{
Role: "assistant",
Content: assistantOut,
})
return out
}
// MessagesToTraceJSON persists allowlisted internal provenance without adding
// unsupported fields to ChatMessage.MarshalJSON's provider request payload.
func MessagesToTraceJSON(msgs []ChatMessage) (string, error) {
filtered := make([]map[string]interface{}, 0, len(msgs))
for _, m := range TraceMessagesForResume(msgs) {
raw, err := json.Marshal(m)
if err != nil {
return "", err
}
var data map[string]interface{}
if err := json.Unmarshal(raw, &data); err != nil {
return "", err
}
extra := make(map[string]interface{})
if m.ModelFacingTrace {
extra[ModelFacingTraceVersionKey] = 1
}
if strings.EqualFold(m.Role, "user") && (m.SummarizationKind == "summary" || m.SummarizationKind == "skills") {
extra[SummarizationContentTypeKey] = m.SummarizationKind
}
if strings.EqualFold(m.Role, "user") && IsGeneratedExecutionContext(m.GeneratedContextKind) {
extra[GeneratedContextTypeKey] = m.GeneratedContextKind
}
if strings.EqualFold(m.Role, "system") && len(m.OriginalUserIntentEntries) > 0 {
extra[OriginalUserIntentEntriesKey] = m.OriginalUserIntentEntries
}
if len(extra) > 0 {
data["extra"] = extra
}
filtered = append(filtered, data)
}
b, err := json.Marshal(filtered)
return string(b), err
}