mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-10-20 22:41:23 +02:00
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.
289 lines
9.2 KiB
Go
289 lines
9.2 KiB
Go
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
|
||
}
|