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.
This commit is contained in:
Sycun committed 2026-10-12 19:34:24 +08:00
1 parent 8149201ef2
commit 983c981b92
22 files changed
+913 -23

No files matched your search

+12
View File
@@ -110,3 +110,15 @@ Eino 的压缩流程支持在 `Finalize` 中处理摘要并重写上下文,`Ca
事实 key 的写入校验拒绝首尾空白,默认 SQL 范围过滤兼容已有 key 的 Unicode 空白,与 Go 判断一致;读取不会批量改名或迁移旧记录。索引成功构建为空时会移除旧索引块,其他系统内容及用户账本保持;数据库读取失败时继续保留原索引。
黑板短标签优先使用第 8 节中已经生成的证据检索线索;没有可用线索则沿用原先的标签取法。只更改索引标签,不改摘要第 1–7 节、完整正文或原始用户账本,不增加模型调用。新增 `Context memory` CI 在 macOS、Ubuntu、Windows 上直接运行 agent/app/database/handler/multiagent/project/projectprompt 的并发回归测试;Windows 不检查其文件系统不支持的 Unix 权限位。
## 发布前场景验收
计划执行和重新规划的用户角色消息是系统生成模板,包含计划与模型/工具结果,不等于用户新输入。模板使用独立的允许列表来源字段,生成前从未拼接的原始消息保存受限用户账本,再进入重写/压缩;角色与来源也保留在输入文本和快照中。恢复轨迹及框架的用户消息区块不会把模板内容归入用户原文,模板中的工作进展仍保留给模型使用。普通用户引用计划或摘要文字继续作为真实输入处理。
上下文超限或临时故障重新启动时,移除旧指令与索引,但保留并按当前预算重建结构化原始用户账本。首条长输入的预算计算包含编号、分隔符和省略号,避免有空间却整条丢弃;只保留原文的受限片段,不增加用户输入的模型摘要。预算仍不能保存无限原文,精确细节按需回读快照。
已有首尾空白 key 的事实可按原 ID、项目和完整 key 更新摘要/正文,不改名、不修改历史关系引用;新建及更改为非规范 key 仍被拒绝。真实 HTTP 部分更新回归覆盖这一兼容路径。
验收还覆盖长工具输出先保存为原文文件、压缩与恢复后从快照获得文件引用,再通过 read_file 后端分段回读精确 Cookie。合成 Cookie 放在 2001 行输出的中间,不出现在截断预览或摘要中;classic/native 两种路径均可完整恢复。这里只保证证据与读取入口可用,模型是否主动回读取决于任务与模型行为。
页面、聊天输入、审批交互和 API 路由没有改动;常规操作沿用原流程。新增可见内容是自动续接记录和简短读取提示,同项目其他会话的自动工作记录默认不注入,普通共享事实与手工 note 保持原有读取方式。本地快照随不同压缩内容累计,沿用会话产物删除生命周期。
+1
View File
@@ -125,6 +125,7 @@ type ChatMessage struct {
// Internal provenance is restored from trusted trace metadata, never from
// message text. Keep it out of provider request JSON.
SummarizationKind string `json:"-"`
GeneratedContextKind string `json:"-"`
OriginalUserIntentEntries []string `json:"-"`
}
+10 -2
View File
@@ -58,6 +58,10 @@ func ParseTraceMessages(traceInputJSON string) ([]ChatMessage, error) {
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])
@@ -177,7 +181,7 @@ func ExtractLastUserTurnMessages(msgs []ChatMessage) []ChatMessage {
var ledger *ChatMessage
for i := range msgs {
m := msgs[i]
if strings.EqualFold(m.Role, "user") && m.SummarizationKind == "" {
if strings.EqualFold(m.Role, "user") && m.SummarizationKind == "" && !IsGeneratedExecutionContext(m.GeneratedContextKind) {
lastUser = i
}
if strings.EqualFold(m.Role, "system") && len(m.OriginalUserIntentEntries) > 0 {
@@ -204,7 +208,8 @@ func ExtractLastUserTurnTraceJSON(traceInputJSON string) string {
role, _ := m["role"].(string)
extra, _ := m["extra"].(map[string]interface{})
kind, _ := extra[SummarizationContentTypeKey].(string)
if strings.EqualFold(role, "user") && kind != "summary" && kind != "skills" {
generatedKind, _ := extra[GeneratedContextTypeKey].(string)
if strings.EqualFold(role, "user") && kind != "summary" && kind != "skills" && !IsGeneratedExecutionContext(generatedKind) {
lastUser = i
}
if strings.EqualFold(role, "system") {
@@ -267,6 +272,9 @@ func MessagesToTraceJSON(msgs []ChatMessage) (string, error) {
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
}
@@ -58,3 +58,34 @@ func TestTraceTrimDoesNotTreatGeneratedSummaryAsHumanTurn(t *testing.T) {
t.Fatalf("JSON trim lost provenance: %+v", parsed)
}
}
func TestGeneratedExecutionTraceStaysOutsideHumanTurnAndProviderJSON(t *testing.T) {
msgs := []ChatMessage{{Role: "user", Content: "human request"}, {Role: "user", Content: "PLAN_DATA", GeneratedContextKind: PlanExecuteExecutorContext}, {Role: "assistant", Content: "result"}}
raw, err := MessagesToTraceJSON(msgs)
if err != nil {
t.Fatal(err)
}
raw = ExtractLastUserTurnTraceJSON(raw)
parsed, err := ParseTraceMessages(raw)
if err != nil {
t.Fatal(err)
}
trimmed := ExtractLastUserTurnMessages(parsed)
if len(trimmed) != 3 || trimmed[0].Content != "human request" || trimmed[1].GeneratedContextKind != PlanExecuteExecutorContext {
t.Fatalf("generated template became human boundary: %+v", trimmed)
}
provider, err := json.Marshal(trimmed)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(provider), GeneratedContextTypeKey) || strings.Contains(string(provider), PlanExecuteExecutorContext) {
t.Fatal("internal template origin leaked to provider JSON")
}
unknown, err := ParseTraceMessages(`[{"role":"user","content":"real user","extra":{"_csai_generated_context_type":"unrecognized"}}]`)
if err != nil {
t.Fatal(err)
}
if unknown[0].GeneratedContextKind != "" {
t.Fatal("unknown user metadata altered source classification")
}
}
+11
View File
@@ -0,0 +1,11 @@
package agent
// GeneratedContextTypeKey marks host-created execution templates, separately
// from Eino's own summary/skill transport metadata.
const GeneratedContextTypeKey = "_csai_generated_context_type"
const PlanExecuteExecutorContext = "plan_execute_executor"
const PlanExecuteReplannerContext = "plan_execute_replanner"
func IsGeneratedExecutionContext(kind string) bool {
return kind == PlanExecuteExecutorContext || kind == PlanExecuteReplannerContext
}
+10 -3
View File
@@ -503,8 +503,16 @@ func mergeFactBodyOnUpdate(incoming, existing string) string {
// UpsertProjectFact 创建或更新事实(按 project_id + fact_key)。
func (db *DB) UpsertProjectFact(f *ProjectFact) (*ProjectFact, error) {
if err := ValidateFactKey(f.FactKey); err != nil {
return nil, err
keyErr := ValidateFactKey(f.FactKey)
if keyErr != nil && (f.ID == "" || ValidateFactKey(strings.TrimSpace(f.FactKey)) != nil) {
return nil, keyErr
}
existing, err := db.GetProjectFactByKey(f.ProjectID, f.FactKey)
// Older versions accepted and stored padded keys. Preserve their exact
// identity when editing a loaded record, without allowing new padded keys
// or silently renaming historical evidence and its relationship references.
if keyErr != nil && (err != nil || existing == nil || existing.ID != f.ID) {
return nil, keyErr
}
if strings.TrimSpace(f.Category) == "" {
f.Category = "note"
@@ -514,7 +522,6 @@ func (db *DB) UpsertProjectFact(f *ProjectFact) (*ProjectFact, error) {
}
now := time.Now()
existing, err := db.GetProjectFactByKey(f.ProjectID, f.FactKey)
if err == nil && existing != nil {
f.ID = existing.ID
f.CreatedAt = existing.CreatedAt
@@ -0,0 +1,81 @@
package database
import (
"path/filepath"
"testing"
"go.uber.org/zap"
)
func TestLegacyPaddedFactCanUpdateWithoutChangingIdentity(t *testing.T) {
db, err := NewDB(filepath.Join(t.TempDir(), "legacy-update.db"), zap.NewNop())
if err != nil {
t.Fatal(err)
}
defer db.Close()
p, err := db.CreateProject(&Project{Name: "legacy"})
if err != nil {
t.Fatal(err)
}
other, err := db.CreateProject(&Project{Name: "other"})
if err != nil {
t.Fatal(err)
}
f, err := db.UpsertProjectFact(&ProjectFact{ProjectID: p.ID, FactKey: "finding/legacy", Summary: "before", Body: "original evidence"})
if err != nil {
t.Fatal(err)
}
legacyKey := "\u3000 finding/legacy \t"
if _, err := db.Exec("UPDATE project_facts SET fact_key=? WHERE id=?", legacyKey, f.ID); err != nil {
t.Fatal(err)
}
loaded, err := db.GetProjectFact(f.ID)
if err != nil {
t.Fatal(err)
}
loaded.Summary = "after"
loaded.Body = ""
updated, err := db.UpsertProjectFact(loaded)
if err != nil {
t.Fatal(err)
}
if updated.ID != f.ID || updated.FactKey != legacyKey || updated.Summary != "after" || updated.Body != "original evidence" {
t.Fatalf("legacy edit changed identity or evidence: %+v", updated)
}
stored, err := db.GetProjectFactByKey(p.ID, legacyKey)
if err != nil || stored.ID != f.ID || stored.FactKey != legacyKey || stored.Summary != "after" {
t.Fatalf("legacy key was rewritten: %+v %v", stored, err)
}
for name, candidate := range map[string]*ProjectFact{
"new key": {ProjectID: p.ID, FactKey: " finding/new ", Summary: "new"},
"existing key only": {ProjectID: p.ID, FactKey: legacyKey, Summary: "overwrite"},
"wrong record": {ID: "not-the-stored-id", ProjectID: p.ID, FactKey: legacyKey, Summary: "overwrite"},
"wrong project": {ID: f.ID, ProjectID: other.ID, FactKey: legacyKey, Summary: "overwrite"},
"changed key": {ID: f.ID, ProjectID: p.ID, FactKey: " finding/new ", Summary: "overwrite"},
"invalid core": {ID: f.ID, ProjectID: p.ID, FactKey: " finding/invalid? ", Summary: "overwrite"},
} {
t.Run(name, func(t *testing.T) {
if _, err := db.UpsertProjectFact(candidate); err == nil {
t.Fatal("legacy exception accepted a new or mismatched key")
}
})
}
stored, err = db.GetProjectFact(f.ID)
if err != nil || stored.FactKey != legacyKey || stored.Summary != "after" || stored.Body != "original evidence" {
t.Fatalf("rejected write modified stored evidence: %+v %v", stored, err)
}
// Padding was accepted by the old validator, but arbitrary invalid keys
// were not. A stored malformed core must not widen this compatibility path.
malformed, err := db.UpsertProjectFact(&ProjectFact{ProjectID: p.ID, FactKey: "finding/malformed", Summary: "untouched"})
if err != nil {
t.Fatal(err)
}
malformed.FactKey = " finding/invalid? "
if _, err := db.Exec("UPDATE project_facts SET fact_key=? WHERE id=?", malformed.FactKey, malformed.ID); err != nil {
t.Fatal(err)
}
malformed.Summary = "overwrite"
if _, err := db.UpsertProjectFact(malformed); err == nil {
t.Fatal("compatibility exception accepted an invalid trimmed key")
}
}
@@ -0,0 +1,64 @@
package handler
import (
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"cyberstrike-ai/internal/database"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
func TestUpdateLegacyPaddedFactSummaryPreservesKeyAndEvidence(t *testing.T) {
db, err := database.NewDB(filepath.Join(t.TempDir(), "legacy-handler.db"), zap.NewNop())
if err != nil {
t.Fatal(err)
}
defer db.Close()
p, err := db.CreateProject(&database.Project{Name: "legacy"})
if err != nil {
t.Fatal(err)
}
f, err := db.UpsertProjectFact(&database.ProjectFact{ProjectID: p.ID, FactKey: "finding/legacy", Summary: "before", Body: "original evidence"})
if err != nil {
t.Fatal(err)
}
legacyKey := " finding/legacy "
if _, err := db.Exec("UPDATE project_facts SET fact_key=? WHERE id=?", legacyKey, f.ID); err != nil {
t.Fatal(err)
}
// A historical relationship may refer to the exact stored key. Updating
// summary/body must not rewrite that evidence reference.
edge, err := db.AddProjectFactEdge(p.ID, database.ProjectFactEdgeInput{To: "finding/target", Type: "supports"}, "finding/legacy", "")
if err != nil {
t.Fatal(err)
}
if _, err := db.Exec("UPDATE project_fact_edges SET source_fact_key=? WHERE id=?", legacyKey, edge.ID); err != nil {
t.Fatal(err)
}
for _, edit := range []struct{ payload, expectedBody string }{
{`{"summary":"after"}`, "original evidence"},
{`{"body":"updated evidence"}`, "updated evidence"},
} {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: p.ID}, {Key: "factId", Value: f.ID}}
c.Request = httptest.NewRequest(http.MethodPut, "/", strings.NewReader(edit.payload))
c.Request.Header.Set("Content-Type", "application/json")
NewProjectHandler(db, zap.NewNop()).UpdateFact(c)
if w.Code != http.StatusOK {
t.Fatalf("legacy partial update failed: %d %s", w.Code, w.Body.String())
}
stored, err := db.GetProjectFact(f.ID)
if err != nil || stored.FactKey != legacyKey || stored.Summary != "after" || stored.Body != edit.expectedBody {
t.Fatalf("legacy edit changed key or evidence: %+v %v", stored, err)
}
edges, err := db.ListOutgoingProjectFactEdges(p.ID, legacyKey)
if err != nil || len(edges) != 1 || edges[0].ID != edge.ID || edges[0].SourceFactKey != legacyKey {
t.Fatalf("legacy edit changed relationship references: %+v %v", edges, err)
}
}
}
@@ -201,7 +201,7 @@ func newEinoAgenticSummarizationMiddleware(
return nil, fmt.Errorf("agentic summarization returned empty summary")
}
compactionMessages := stripOriginalUserIntentLedgerFromMessages(classicOriginal)
defaultFinalized, derr := summarization.DefaultFinalize(ctx, compactionMessages, classicSummary)
defaultFinalized, derr := summarization.DefaultFinalize(ctx, summarizationDefaultFinalizeInput(compactionMessages, classicOriginal), classicSummary)
if derr != nil {
return nil, derr
}
@@ -164,11 +164,21 @@ func TestEinoAgenticChatModelAgentCompactsContextBeforeBusinessModel(t *testing.
if strings.Contains(joined, "<analysis>") {
t.Fatalf("analysis leaked to business model input:\n%s", joined)
}
if strings.Count(joined, "原始扫描输出SHOULD_NOT_REACH_BUSINESS_MODEL") > 3 {
if strings.Count(stripOriginalUserIntentLedgerFromText(joined), "原始扫描输出SHOULD_NOT_REACH_BUSINESS_MODEL") > 3 {
t.Fatalf("raw oversized history leaked to business model input, count=%d", strings.Count(joined, "原始扫描输出SHOULD_NOT_REACH_BUSINESS_MODEL"))
}
// The synthetic history is a human message: keep its bounded original
// prefix in the host ledger, while excluding the oversized transcript from
// the business-model working context and persistence.
body := userIntentLedgerBlockRegex.FindStringSubmatch(joined)
if len(body) < 2 || !strings.Contains(body[1], "授权范围 example.com") || utf8RuneLen(strings.TrimSpace(body[1])) > modelFacingRuneBudget(appCfg.OpenAI.MaxTotalTokens, 0.20) {
t.Fatalf("human ledger missing or exceeds current budget: %#v", body)
}
if utf8RuneLen(joined) >= utf8RuneLen(rawHistory)/10 {
t.Fatal("oversized human transcript was reinjected")
}
traceJoined := joinClassicMessageContent(trace.Snapshot())
if !strings.Contains(traceJoined, "继续验证 SQL 注入路径") || strings.Count(traceJoined, "原始扫描输出SHOULD_NOT_REACH_BUSINESS_MODEL") > 3 {
if !strings.Contains(traceJoined, "继续验证 SQL 注入路径") || strings.Count(stripOriginalUserIntentLedgerFromText(traceJoined), "原始扫描输出SHOULD_NOT_REACH_BUSINESS_MODEL") > 3 {
t.Fatalf("model-facing trace not compacted:\n%s", traceJoined)
}
}
@@ -0,0 +1,135 @@
package multiagent
import (
"context"
"cyberstrike-ai/internal/agent"
"cyberstrike-ai/internal/config"
"encoding/json"
"fmt"
localbk "github.com/cloudwego/eino-ext/adk/backend/local"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/adk/filesystem"
"github.com/cloudwego/eino/components/tool"
"github.com/cloudwego/eino/schema"
"path/filepath"
"regexp"
"strings"
"testing"
)
func TestCookieRecoveryThroughReductionSnapshotAndResume(t *testing.T) {
if err := InitADK(); err != nil {
t.Fatal(err)
}
defer adk.SetLanguage(adk.LanguageEnglish)
for _, native := range []bool{false, true} {
t.Run(fmt.Sprintf("native=%v", native), func(t *testing.T) {
ctx := context.Background()
loc, err := localbk.NewBackend(ctx, &localbk.Config{})
if err != nil {
t.Fatal(err)
}
redCfg := config.MultiAgentEinoMiddlewareConfig{ReductionRootDir: t.TempDir(), ReductionMaxLengthForTrunc: 512}
const cookie = "AUDIT_SYNTHETIC_COOKIE_1234_NO_REAL_SECRET"
raw := strings.Repeat("noise line without credentials\n", 1000) + "Set-Cookie: sid=" + cookie + "; Domain=authorized.example\n" + strings.Repeat("noise line without credentials\n", 1000)
endpoint := func(context.Context, string, ...tool.Option) (string, error) { return raw, nil }
var wrapped adk.InvokableToolCallEndpoint
if native {
mw, err := buildAgenticReductionMiddleware(ctx, redCfg, "", "audit-conversation", loc, nil)
if err != nil {
t.Fatal(err)
}
wrapped, err = mw.WrapInvokableToolCall(ctx, endpoint, &adk.ToolContext{Name: "execute", CallID: "audit-login"})
if err != nil {
t.Fatal(err)
}
} else {
mw, err := buildReductionMiddleware(ctx, redCfg, "", "audit-conversation", loc, nil)
if err != nil {
t.Fatal(err)
}
wrapped, err = mw.WrapInvokableToolCall(ctx, endpoint, &adk.ToolContext{Name: "execute", CallID: "audit-login"})
if err != nil {
t.Fatal(err)
}
}
notice, err := wrapped(ctx, `{"command":"synthetic authorized.example login response"}`)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(notice, "<persisted-output>") || strings.Contains(notice, cookie) {
t.Fatalf("reduction not isolating middle credential: %s", notice)
}
findPath := regexp.MustCompile(`(?m)(?:Full output saved to: |完整输出保存到: )([^\n]+)`)
match := findPath.FindStringSubmatch(notice)
if len(match) != 2 {
t.Fatalf("missing spill reference: %s", notice)
}
spill := match[1]
cfg := &config.Config{}
cfg.Database.Path = filepath.Join(t.TempDir(), "runtime.db")
cfg.OpenAI.Model = "gpt-4o"
cfg.OpenAI.MaxTotalTokens = 5000
emit := false
sumCfg := &config.MultiAgentEinoMiddlewareConfig{SummarizationEmitInternalEvents: &emit, SummarizationOutputReserveTokens: 1024}
original := []adk.Message{schema.SystemMessage("fresh system"), schema.UserMessage("Only authorized.example; find exact Cookie if needed"), &schema.Message{Role: schema.Assistant, ToolCalls: []schema.ToolCall{{ID: "audit-login", Type: "function", Function: schema.FunctionCall{Name: "execute", Arguments: `{"command":"synthetic login"}`}}}}, &schema.Message{Role: schema.Tool, ToolCallID: "audit-login", Content: notice}, schema.AssistantMessage(strings.Repeat("intermediate completed finding ", 15000), nil), schema.UserMessage("continue with missing authentication detail")}
const summary = "<summary>Login completed on authorized.example.\n## 8. 原始证据检索线索(追加)\n- authorized.example login Set-Cookie response</summary>"
var after []adk.Message
if native {
mw, err := newEinoAgenticSummarizationMiddleware(ctx, &capturingAgenticChatModel{output: agenticAssistantTextMessage(summary)}, cfg, sumCfg, "audit-conversation", nil, "", nil)
if err != nil {
t.Fatal(err)
}
_, s, err := mw.BeforeModelRewriteState(ctx, &adk.TypedChatModelAgentState[*schema.AgenticMessage]{Messages: EinoMessagesToAgentic(original)}, nil)
if err != nil {
t.Fatal(err)
}
after = AgenticMessagesToEino(s.Messages)
} else {
mw, err := newEinoSummarizationMiddleware(ctx, &capturingClassicChatModel{output: &schema.Message{Role: schema.Assistant, Content: summary, ResponseMeta: &schema.ResponseMeta{FinishReason: "stop"}}}, cfg, sumCfg, "audit-conversation", nil, "", nil)
if err != nil {
t.Fatal(err)
}
_, s, err := mw.BeforeModelRewriteState(ctx, &adk.ChatModelAgentState{Messages: original}, nil)
if err != nil {
t.Fatal(err)
}
after = s.Messages
}
if strings.Contains(joinClassicMessageContent(after), cookie) {
t.Fatal("test requires cookie omitted from compacted context")
}
encoded, err := json.Marshal(markModelFacingTraceForPersistence(after))
if err != nil {
t.Fatal(err)
}
parsed, err := agent.ParseTraceMessages(string(encoded))
if err != nil {
t.Fatal(err)
}
recovered := historyToMessages(agent.TraceMessagesForResume(parsed), nil, nil)
resumedText := joinClassicMessageContent(recovered)
snapshotExpr := regexp.MustCompile(`(?m)完整的对话记录位于:([^\n]+)`)
snapshotMatch := snapshotExpr.FindStringSubmatch(resumedText)
if len(snapshotMatch) != 2 {
t.Fatalf("snapshot hint lost after resume: %s", resumedText)
}
snapshotContent, err := loc.Read(ctx, &filesystem.ReadRequest{FilePath: snapshotMatch[1], Offset: 1, Limit: 40})
if err != nil {
t.Fatal(err)
}
recoveredSpill := findPath.FindStringSubmatch(snapshotContent.Content)
if len(recoveredSpill) != 2 || recoveredSpill[1] != spill {
t.Fatalf("spill reference lost in snapshot: %s", snapshotContent.Content)
}
fullLine, err := loc.Read(ctx, &filesystem.ReadRequest{FilePath: recoveredSpill[1], Offset: 1001, Limit: 1})
if err != nil {
t.Fatal(err)
}
if fullLine.Content != "Set-Cookie: sid="+cookie+"; Domain=authorized.example" {
t.Fatalf("exact cookie recovery failed: %q", fullLine.Content)
}
t.Log("real reduction -> actual compaction -> persisted resume -> snapshot read -> raw spill read restored exact synthetic Cookie")
})
}
}
@@ -0,0 +1,79 @@
package multiagent
import (
"cyberstrike-ai/internal/agent"
"cyberstrike-ai/internal/config"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/schema"
)
func isGeneratedExecutionUserMessage(msg adk.Message) bool {
if msg == nil || msg.Role != schema.User {
return false
}
kind, _ := msg.Extra[agent.GeneratedContextTypeKey].(string)
return agent.IsGeneratedExecutionContext(kind)
}
// Templates contain plan/model/tool text. Carry human intent from the original
// messages before flattening/budgeting, while keeping the execution prompt intact.
func withPlanExecuteInputProvenance(msgs, originals []adk.Message, appCfg *config.Config, mwCfg *config.MultiAgentEinoMiddlewareConfig, kind string) []adk.Message {
out := make([]adk.Message, len(msgs))
for i, msg := range msgs {
if msg == nil || msg.Role != schema.User {
out[i] = msg
continue
}
clone := *msg
clone.Extra = cloneAnyMap(msg.Extra)
if clone.Extra == nil {
clone.Extra = make(map[string]interface{})
}
clone.Extra[agent.GeneratedContextTypeKey] = kind
out[i] = &clone
}
maxRunes, entryMax := config.DefaultSummarizationUserIntentLedgerMaxRunes, config.DefaultSummarizationUserIntentLedgerEntryMaxRunes
if mwCfg != nil {
maxRunes = mwCfg.SummarizationUserIntentLedgerMaxRunesEffective()
entryMax = mwCfg.SummarizationUserIntentLedgerEntryMaxRunesEffective()
}
maxTotal := 120000
if appCfg != nil && appCfg.OpenAI.MaxTotalTokens > 0 {
maxTotal = appCfg.OpenAI.MaxTotalTokens
}
maxRunes = minPositiveInt(maxRunes, modelFacingRuneBudget(maxTotal, 0.20))
return mergeMessageIntoLeadingSystem(out, buildOriginalUserIntentLedgerMessage(originals, maxRunes, minPositiveInt(entryMax, maxRunes)))
}
// Eino's deterministic <all_user_messages> replacement knows only its own
// summary flags. For host templates, give that replacement actual human entries
// from the ledger, keeping model-generated execution data outside user authority.
// Summary model input, snapshots and working trails still contain the template.
func summarizationDefaultFinalizeInput(msgs, originals []adk.Message) []adk.Message {
hasTemplates := false
out := make([]adk.Message, 0, len(msgs))
liveHuman := make(map[string]bool)
for _, msg := range msgs {
if isGeneratedExecutionUserMessage(msg) {
hasTemplates = true
clone := *msg
clone.Role = schema.Assistant
out = append(out, &clone)
} else {
out = append(out, msg)
if msg != nil && msg.Role == schema.User && !isInternalSummarizationUserMessage(msg) {
liveHuman[normalizeUserIntentLedgerText(adkUserMessageText(msg))] = true
}
}
}
if !hasTemplates {
return msgs
}
var carried []adk.Message
for _, entry := range collectOriginalUserIntentEntries(originals) {
if !liveHuman[normalizeUserIntentLedgerText(entry)] {
carried = append(carried, schema.UserMessage(entry))
}
}
return append(carried, out...)
}
+6 -2
View File
@@ -188,6 +188,7 @@ func planExecuteExecutorGenInput(
if err != nil {
return nil, err
}
userMsgs = withPlanExecuteInputProvenance(userMsgs, in.UserInput, appCfg, mwCfg, agent.PlanExecuteExecutorContext)
userMsgs = normalizeSingleLeadingSystemMessage(userMsgs, oi)
logPlanExecuteModelInputEstimate(logger, modelName, conversationID, "plan_execute_executor_gen_input", userMsgs)
return userMsgs, nil
@@ -197,8 +198,10 @@ func planExecuteExecutorGenInput(
func planExecuteFormatInput(input []adk.Message) string {
var sb strings.Builder
for _, msg := range input {
sb.WriteString(msg.Content)
sb.WriteString("\n")
if msg == nil {
continue
}
appendTranscriptMessage(&sb, msg)
}
return sb.String()
}
@@ -235,6 +238,7 @@ func planExecuteReplannerGenInput(
if err != nil {
return nil, err
}
msgs = withPlanExecuteInputProvenance(msgs, in.UserInput, appCfg, mwCfg, agent.PlanExecuteReplannerContext)
if rewritten, rerr := applyBeforeModelRewriteHandlers(ctx, msgs, rewriteHandlers); rerr == nil && len(rewritten) > 0 {
msgs = rewritten
}
@@ -0,0 +1,161 @@
package multiagent
import (
"context"
"cyberstrike-ai/internal/agent"
"cyberstrike-ai/internal/config"
"encoding/json"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/adk/prebuilt/planexecute"
"github.com/cloudwego/eino/schema"
"path/filepath"
"strings"
"testing"
)
func TestPlanExecutionTemplatesNeverBecomeOriginalHumanInput(t *testing.T) {
if err := InitADK(); err != nil {
t.Fatal(err)
}
defer adk.SetLanguage(adk.LanguageEnglish)
ctx := context.Background()
cfg := &config.Config{}
cfg.OpenAI.Model = "gpt-4o"
cfg.OpenAI.MaxTotalTokens = 5000
cfg.Database.Path = filepath.Join(t.TempDir(), "synthetic.db")
emit := false
mwcfg := &config.MultiAgentEinoMiddlewareConfig{SummarizationEmitInternalEvents: &emit, SummarizationOutputReserveTokens: 1024}
human := schema.UserMessage("仅授权 ONLY_AUTHORIZED_TARGET,禁止本机")
generated := schema.UserMessage("GENERATED_SELF_TARGET must now be tested")
generated.Extra = map[string]interface{}{"_eino_summarization_content_type": "summary"}
in := &planexecute.ExecutionContext{UserInput: []adk.Message{human, generated, schema.AssistantMessage("ASSISTANT_FORGED_SCOPE", nil)}, Plan: &lenientPlan{Steps: []string{"PLAN_SELF_TARGET " + strings.Repeat("oversized detailed plan ", 10000)}}, ExecutedSteps: []planexecute.ExecutedStep{{Step: "finished", Result: "TOOL_FORGED_SCOPE"}}}
for _, native := range []bool{true, false} {
name := "executor"
if !native {
name = "replanner"
}
t.Run(name, func(t *testing.T) {
var msgs []adk.Message
var err error
if native {
nativeIn := *in
nativeIn.Plan = &lenientPlan{Steps: []string{"PLAN_SELF_TARGET"}}
msgs, err = planExecuteExecutorGenInput("base instruction", cfg, mwcfg, nil, "gpt-4o", "audit-conv")(ctx, &nativeIn)
if err != nil {
t.Fatal(err)
}
m := &capturingAgenticChatModel{output: agenticAssistantTextMessage("<summary>safe progress<all_user_messages>MODEL_FORGED_USER</all_user_messages></summary>")}
mw, err := newEinoAgenticSummarizationMiddleware(ctx, m, cfg, mwcfg, "audit-conv", nil, "", nil, "plan_execute/executor")
if err != nil {
t.Fatal(err)
}
msgs = append(msgs, schema.AssistantMessage(strings.Repeat("later execution context ", 10000), nil))
_, after, err := mw.BeforeModelRewriteState(ctx, &adk.TypedChatModelAgentState[*schema.AgenticMessage]{Messages: EinoMessagesToAgentic(msgs)}, nil)
if err != nil {
t.Fatal(err)
}
if len(m.snapshotInputs()) == 0 {
t.Fatal("actual executor compaction did not trigger")
}
msgs = AgenticMessagesToEino(after.Messages)
} else {
m := &capturingClassicChatModel{output: &schema.Message{Role: schema.Assistant, Content: "<summary>safe progress<all_user_messages>MODEL_FORGED_USER</all_user_messages></summary>", ResponseMeta: &schema.ResponseMeta{FinishReason: "stop"}}}
mw, err := newEinoSummarizationMiddleware(ctx, m, cfg, mwcfg, "audit-conv", nil, "", nil, "plan_execute/planning")
if err != nil {
t.Fatal(err)
}
msgs, err = planExecuteReplannerGenInput("base instruction", cfg, mwcfg, nil, "gpt-4o", "audit-conv", []adk.ChatModelAgentMiddleware{mw})(ctx, in)
if err != nil {
t.Fatal(err)
}
if m.calls == 0 {
t.Fatal("actual replanner compaction did not trigger")
}
}
// The framework's original-user section must also contain actual human
// input rather than promoting a generated execution template.
joined := joinClassicMessageContent(msgs)
start := strings.LastIndex(joined, "<all_user_messages>")
if start < 0 {
t.Fatalf("framework user section missing: %.2200s", joined)
}
end := strings.Index(joined[start:], "</all_user_messages>")
if end < 0 {
t.Fatalf("framework user section missing: %.2200s", joined)
}
section := joined[start : start+end]
if !strings.Contains(section, "ONLY_AUTHORIZED_TARGET") || strings.Contains(section, "GENERATED_SELF_TARGET") || strings.Contains(section, "PLAN_SELF_TARGET") || strings.Contains(section, "MODEL_FORGED_USER") {
t.Fatalf("framework misattributed generated context: %s", section)
}
raw, err := json.Marshal(markModelFacingTraceForPersistence(msgs))
if err != nil {
t.Fatal(err)
}
parsed, err := agent.ParseTraceMessages(string(raw))
if err != nil {
t.Fatal(err)
}
msgs = historyToMessages(agent.TraceMessagesForResume(parsed), cfg, mwcfg)
ledger := buildOriginalUserIntentLedgerMessage(msgs, 96000, 16000)
if ledger == nil || !strings.Contains(ledger.Content, "ONLY_AUTHORIZED_TARGET") {
t.Fatal("human ledger missing after resume")
}
t.Logf("human ledger after actual %s compaction: %.1400s", name, ledger.Content)
if strings.Contains(ledger.Content, "GENERATED_SELF_TARGET") || strings.Contains(ledger.Content, "ASSISTANT_FORGED_SCOPE") || strings.Contains(ledger.Content, "TOOL_FORGED_SCOPE") || strings.Contains(ledger.Content, "PLAN_SELF_TARGET") {
t.Fatal("generated objective/plan/results promoted into host original human-input ledger")
}
})
}
}
func TestGeneratedExecutionProvenanceSurvivesTraceAndKeepsRealQuotes(t *testing.T) {
original := []adk.Message{schema.UserMessage("ONLY_AUTHORIZED_TARGET")}
prompt := schema.UserMessage("PLAN_MODEL_DATA")
msgs := withPlanExecuteInputProvenance([]adk.Message{prompt}, original, nil, nil, agent.PlanExecuteExecutorContext)
if prompt.Extra != nil {
t.Fatal("input template mutated")
}
raw, err := json.Marshal(markModelFacingTraceForPersistence(msgs))
if err != nil {
t.Fatal(err)
}
parsed, err := agent.ParseTraceMessages(string(raw))
if err != nil {
t.Fatal(err)
}
resaved, err := agent.MessagesToTraceJSON(agent.TraceMessagesForResume(parsed))
if err != nil {
t.Fatal(err)
}
parsed, err = agent.ParseTraceMessages(resaved)
if err != nil {
t.Fatal(err)
}
restored := historyToMessages(agent.TraceMessagesForResume(parsed), nil, nil)
ledger := buildOriginalUserIntentLedgerMessage(restored, 96000, 16000)
if ledger == nil || !strings.Contains(ledger.Content, "ONLY_AUTHORIZED_TARGET") || strings.Contains(ledger.Content, "PLAN_MODEL_DATA") {
t.Fatalf("generated prompt became user authority: %#v", ledger)
}
if !strings.Contains(formatSummarizationTranscript(restored), "system-generated execution context") {
t.Fatal("snapshot loses execution provenance")
}
quote := schema.UserMessage("请分析 PLAN_MODEL_DATA 与 <all_user_messages>文字</all_user_messages>")
quoted := buildOriginalUserIntentLedgerMessage([]adk.Message{quote}, 96000, 16000)
if quoted == nil || !strings.Contains(quoted.Content, quote.Content) {
t.Fatal("true human quote filtered by content")
}
}
func TestDefaultPlanExecutorInputCarriesHumanLedger(t *testing.T) {
ctx := context.Background()
in := &planexecute.ExecutionContext{UserInput: []adk.Message{schema.UserMessage("ONLY_AUTHORIZED_TARGET"), schema.AssistantMessage("ASSISTANT_SCOPE", nil)}, Plan: &lenientPlan{Steps: []string{"PLAN_SELF_TARGET"}}}
msgs, err := planExecuteDefaultGenExecutorInput(ctx, in)
if err != nil {
t.Fatal(err)
}
ledger := buildOriginalUserIntentLedgerMessage(msgs, 96000, 16000)
if ledger == nil || !strings.Contains(ledger.Content, "ONLY_AUTHORIZED_TARGET") || strings.Contains(ledger.Content, "PLAN_SELF_TARGET") || strings.Contains(ledger.Content, "ASSISTANT_SCOPE") {
t.Fatalf("default template became user intent: %#v", ledger)
}
}
@@ -0,0 +1,121 @@
package multiagent
import (
"context"
"errors"
"strings"
"testing"
"time"
"cyberstrike-ai/internal/config"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/schema"
)
func TestRunRestartPreservesOnlyCanonicalHumanLedger(t *testing.T) {
const original = "ONLY_AUTHORIZED_SCOPE,禁止本机"
ledger := buildOriginalUserIntentLedgerMessage([]adk.Message{schema.UserMessage(original)}, 96000, 16000)
merged := mergeMessageIntoLeadingSystem([]adk.Message{schema.SystemMessage("OLD_INSTRUCTION\nOLD_PROJECT_INDEX")}, ledger)[0]
summary := schema.UserMessage("GENERATED_SCOPE working progress")
summary.Extra = map[string]interface{}{"_eino_summarization_content_type": "summary"}
messages := []adk.Message{merged, summary, schema.AssistantMessage("later result", nil)}
for _, source := range []einoRunRestartContextSource{einoRestartContextModelTrace, einoRestartContextAccumulated, einoRestartContextInitial} {
for _, overflow := range []bool{false, true} {
name := string(source) + "/transient"
if overflow {
name = string(source) + "/overflow"
}
t.Run(name, func(t *testing.T) {
args := &einoADKRunLoopArgs{MaxTotalTokens: 5000, ModelName: "gpt-4o"}
var base, accumulated []adk.Message
switch source {
case einoRestartContextModelTrace:
args.ModelFacingTrace = newModelFacingTraceHolder()
args.ModelFacingTrace.storeFromState(&adk.ChatModelAgentState{Messages: messages})
case einoRestartContextAccumulated:
accumulated = messages
case einoRestartContextInitial:
base = messages
}
var restart []adk.Message
if overflow {
handler := newEinoContextOverflowRetryHandler(einoContextOverflowRetryConfig{Context: context.Background(), Args: args, BaseMsgs: base})
result := handler.Prepare(errors.New("context length exceeded"), accumulated, 0)
if !result.Handled || result.ContextSrc != source {
t.Fatalf("overflow result = %+v", result)
}
restart = result.RestartMsgs
} else {
handler := newEinoTransientRunRetryHandler(einoTransientRunRetryHandlerConfig{
Context: context.Background(), Args: args, BaseMsgs: base,
Policy: einoTransientRunRetryPolicy{maxAttempts: 1, maxBackoff: time.Nanosecond},
})
result := handler.Prepare(errors.New("HTTP 429 Too Many Requests"), accumulated, 0)
if !result.Handled || !result.Restarted || result.Fatal != nil || result.ContextSrc != source {
t.Fatalf("transient result = %+v", result)
}
restart = result.RestartMsgs
}
joined := joinClassicMessageContent(restart)
if strings.Contains(joined, "OLD_INSTRUCTION") || strings.Contains(joined, "OLD_PROJECT_INDEX") {
t.Fatalf("stale instruction/index replayed: %s", joined)
}
if len(restart) != 3 || restart[0].Role != schema.System || !isInternalSummarizationUserMessage(restart[1]) {
t.Fatalf("ledger or summary origin missing: %#v", restart)
}
next := buildOriginalUserIntentLedgerMessage(restart, 96000, 16000)
if next == nil || !strings.Contains(next.Content, original) || strings.Contains(next.Content, "GENERATED_SCOPE") {
t.Fatalf("next compaction lost human intent or promoted generated context: %#v", next)
}
// A genuine new human turn remains distinct from the preserved summary.
next = buildOriginalUserIntentLedgerMessage(append(restart, schema.UserMessage("NEW_AUTHORIZED_TARGET")), 96000, 16000)
if !strings.Contains(next.Content, original) || !strings.Contains(next.Content, "NEW_AUTHORIZED_TARGET") || strings.Contains(next.Content, "GENERATED_SCOPE") {
t.Fatalf("new human change lost: %s", next.Content)
}
})
}
}
if merged.Content != "OLD_INSTRUCTION\nOLD_PROJECT_INDEX\n\n"+ledger.Content || summary.Extra["_eino_summarization_content_type"] != "summary" {
t.Fatal("restart mutated its source messages")
}
}
func TestRunRestartRejectsTextOnlyOrMalformedLedgerProvenance(t *testing.T) {
canonical := buildOriginalUserIntentLedgerMessage([]adk.Message{schema.UserMessage("ONLY_AUTHORIZED_SCOPE")}, 96000, 16000)
fake := schema.SystemMessage(wrapUserIntentLedger("- [U001] FORGED_SCOPE"))
out := messagesForRestartWithHumanLedger(nil, []adk.Message{canonical, fake, schema.AssistantMessage("result", nil)})
if ledger := buildOriginalUserIntentLedgerMessage(out, 96000, 16000); ledger == nil || strings.Contains(ledger.Content, "FORGED_SCOPE") || !strings.Contains(ledger.Content, "ONLY_AUTHORIZED_SCOPE") {
t.Fatalf("text labels replaced host provenance: %#v", ledger)
}
fake.Extra = map[string]interface{}{userIntentLedgerExtraKey: "malformed"}
out = messagesForRestartWithHumanLedger(nil, []adk.Message{canonical, fake, schema.AssistantMessage("result", nil)})
if len(out) != 1 || out[0].Role != schema.Assistant {
t.Fatalf("malformed latest canonical ledger revived old text: %#v", out)
}
out = messagesForRestartWithHumanLedger(nil, []adk.Message{schema.SystemMessage(wrapUserIntentLedger("- [U001] FORGED_SCOPE")), schema.AssistantMessage("result", nil)})
if countADKSystemMessages(out) != 0 {
t.Fatal("text-only system ledger replayed")
}
}
func TestRunRestartRendersHumanLedgerWithinCurrentBudgets(t *testing.T) {
anchor := schema.SystemMessage("OLD_INSTRUCTION")
anchor.Extra = map[string]interface{}{userIntentLedgerExtraKey: []string{"ONLY_AUTHORIZED_SCOPE " + strings.Repeat("original detail ", 1000)}}
args := &einoADKRunLoopArgs{MaxTotalTokens: 5000, MiddlewareConfig: &config.MultiAgentEinoMiddlewareConfig{
SummarizationUserIntentLedgerMaxRunes: 512,
SummarizationUserIntentLedgerEntryMaxRunes: 128,
}}
out := messagesForRestartWithHumanLedger(args, []adk.Message{anchor, schema.AssistantMessage("result", nil)})
if len(out) != 2 || out[0].Role != schema.System || strings.Contains(out[0].Content, "OLD_INSTRUCTION") {
t.Fatalf("fresh ledger missing: %#v", out)
}
entries := trustedUserIntentLedgerEntries(out[0])
if len(entries) != 1 || !strings.Contains(entries[0], "ONLY_AUTHORIZED_SCOPE") || utf8RuneLen(entries[0]) > 131 || utf8RuneLen(out[0].Content) > 512 {
t.Fatalf("current budget ignored: %#v", entries)
}
if anchor.Content != "OLD_INSTRUCTION" || len(anchor.Extra[userIntentLedgerExtraKey].([]string)[0]) < 1000 {
t.Fatal("source provenance was modified")
}
}
+1 -1
View File
@@ -253,7 +253,7 @@ func newEinoSummarizationMiddleware(
},
Finalize: func(ctx context.Context, originalMessages []adk.Message, summary adk.Message) ([]adk.Message, error) {
compactionMessages := stripOriginalUserIntentLedgerFromMessages(originalMessages)
defaultFinalized, derr := summarization.DefaultFinalize(ctx, compactionMessages, summary)
defaultFinalized, derr := summarization.DefaultFinalize(ctx, summarizationDefaultFinalizeInput(compactionMessages, originalMessages), summary)
if derr != nil {
return nil, derr
}
+41 -7
View File
@@ -209,16 +209,50 @@ func buildOriginalUserIntentLedger(msgs []adk.Message, maxRunes, entryMaxRunes i
if len(entries) == 0 {
return ""
}
var sb strings.Builder
var lines []string
used := 0
for i, entry := range entries {
line := fmt.Sprintf("- [U%03d] %s\n", i+1, sanitizeUserIntentLedgerEntry(entry, entryMaxRunes))
if maxRunes > 0 && utf8RuneLen(sb.String())+utf8RuneLen(line) > maxRunes {
sb.WriteString("- [...truncated] 用户原始输入账本超过预算;完整压缩前记录见 summarization transcript。\n")
prefix := fmt.Sprintf("- [U%03d] ", i+1)
text := sanitizeUserIntentLedgerEntry(entry, entryMaxRunes)
line := prefix + text
separator := 0
if len(lines) > 0 {
separator = 1
}
if maxRunes > 0 && used+separator+utf8RuneLen(line) > maxRunes {
// The entry budget excludes host formatting. Keep a bounded first
// human entry rather than dropping it when its own cap fills the
// entire ledger budget; clipping retains the existing text prefix.
if len(lines) == 0 {
textBudget := maxRunes - utf8RuneLen(prefix)
if textBudget > 0 {
lines = append(lines, prefix+fitUserIntentLedgerText(text, textBudget))
}
} else {
const notice = "- [...truncated] 用户原始输入账本超过预算;完整压缩前记录见 summarization transcript。"
remaining := maxRunes - used - separator
// Never emit an incomplete marker that could be mistaken for a
// human entry when this ledger is carried into the next round.
if remaining >= utf8RuneLen("- [...truncated]") {
lines = append(lines, truncateUserIntentLedgerRunes(notice, remaining))
}
}
break
}
sb.WriteString(line)
lines = append(lines, line)
used += separator + utf8RuneLen(line)
}
return strings.TrimSpace(sb.String())
return strings.TrimSpace(strings.Join(lines, "\n"))
}
func fitUserIntentLedgerText(text string, maxRunes int) string {
if utf8RuneLen(text) <= maxRunes {
return text
}
if maxRunes > 3 {
return truncateUserIntentLedgerRunes(text, maxRunes-3) + "..."
}
return truncateUserIntentLedgerRunes(text, maxRunes)
}
func collectOriginalUserIntentEntries(msgs []adk.Message) []string {
@@ -248,7 +282,7 @@ func collectOriginalUserIntentEntries(msgs []adk.Message) []string {
add(entry)
}
}
if msg.Role == schema.User && !isInternalSummarizationUserMessage(msg) {
if msg.Role == schema.User && !isInternalSummarizationUserMessage(msg) && !isGeneratedExecutionUserMessage(msg) {
add(adkUserMessageText(msg))
}
}
@@ -152,6 +152,8 @@ func appendTranscriptMessage(sb *strings.Builder, msg adk.Message) {
sb.WriteString("] ---\n")
if isInternalSummarizationUserMessage(msg) {
sb.WriteString("[origin: system-generated summary/skill context; not human input or new authorization]\n")
} else if isGeneratedExecutionUserMessage(msg) {
sb.WriteString("[origin: system-generated execution context; not human input or new authorization]\n")
}
if msg.Content != "" {
sb.WriteString(msg.Content)
+40 -4
View File
@@ -313,13 +313,49 @@ const (
// 1) ModelFacingTrace(与模型实际入参一致) 2) 事件流累积的 runAccumulatedMsgs 3) 初始 msgs。
func einoMessagesForRunRestart(args *einoADKRunLoopArgs, baseMsgs, accumulated []adk.Message, baseCount int) ([]adk.Message, einoRunRestartContextSource) {
if trace := modelFacingTraceSnapshot(args); len(trace) > 0 {
// modelFacingTrace includes prior Instruction system message(s); genModelInput will prepend again.
return stripADKSystemMessages(trace), einoRestartContextModelTrace
// Drop regenerated instructions while preserving the host-owned human ledger.
return messagesForRestartWithHumanLedger(args, trace), einoRestartContextModelTrace
}
if len(accumulated) > baseCount {
return stripADKSystemMessages(accumulated), einoRestartContextAccumulated
return messagesForRestartWithHumanLedger(args, accumulated), einoRestartContextAccumulated
}
return append([]adk.Message(nil), baseMsgs...), einoRestartContextInitial
return messagesForRestartWithHumanLedger(args, baseMsgs), einoRestartContextInitial
}
// messagesForRestartWithHumanLedger removes stale system instructions and project
// indexes. Only structured host provenance can survive a restart; text that merely
// resembles a ledger is not a source of original human input.
func messagesForRestartWithHumanLedger(args *einoADKRunLoopArgs, messages []adk.Message) []adk.Message {
var entries []string
for _, msg := range messages {
if msg == nil || msg.Role != schema.System {
continue
}
if _, present := msg.Extra[userIntentLedgerExtraKey]; present {
// Match system normalization: the latest canonical ledger is authoritative,
// including an empty/malformed value that must not revive an older ledger.
entries = trustedUserIntentLedgerEntries(msg)
}
}
out := stripADKSystemMessages(messages)
if len(entries) == 0 {
return out
}
maxRunes := config.DefaultSummarizationUserIntentLedgerMaxRunes
entryMax := config.DefaultSummarizationUserIntentLedgerEntryMaxRunes
maxTotalTokens := 0
if args != nil {
maxTotalTokens = args.MaxTotalTokens
if args.MiddlewareConfig != nil {
maxRunes = args.MiddlewareConfig.SummarizationUserIntentLedgerMaxRunesEffective()
entryMax = args.MiddlewareConfig.SummarizationUserIntentLedgerEntryMaxRunesEffective()
}
}
maxRunes = minPositiveInt(maxRunes, modelFacingRuneBudget(maxTotalTokens, 0.20))
anchor := schema.SystemMessage("")
anchor.Extra = map[string]interface{}{userIntentLedgerExtraKey: entries}
ledger := buildOriginalUserIntentLedgerMessage([]adk.Message{anchor}, maxRunes, minPositiveInt(entryMax, maxRunes))
return mergeMessageIntoLeadingSystem(out, ledger)
}
// adkMessagesHasUserContent reports whether the conversation tail is already a user turn
@@ -0,0 +1,81 @@
package multiagent
import (
"cyberstrike-ai/internal/config"
"fmt"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/schema"
"path/filepath"
"strings"
"testing"
)
func TestOriginalUserLedgerReservesEntryFormattingBudget(t *testing.T) {
const scope = "仅授权 ONLY_AUTHORIZED_SCOPE,禁止本机"
original := scope + " " + strings.Repeat("中文请求详情 ", 1000)
for _, budget := range []int{64, 200, 1000} {
t.Run(fmt.Sprintf("budget=%d", budget), func(t *testing.T) {
messages := []adk.Message{schema.UserMessage(original)}
body := buildOriginalUserIntentLedger(messages, budget, budget)
if utf8RuneLen(body) > budget {
t.Fatalf("ledger exceeds total body budget: %d > %d", utf8RuneLen(body), budget)
}
if !strings.HasPrefix(body, "- [U001] "+scope) || !strings.HasSuffix(body, "...") {
t.Fatalf("bounded human prefix lost: %q", body)
}
ledger := buildOriginalUserIntentLedgerMessage(messages, budget, budget)
if ledger == nil || len(trustedUserIntentLedgerEntries(ledger)) != 1 || !strings.Contains(ledger.Content, scope) {
t.Fatalf("formatted clipping dropped original human input: %#v", ledger)
}
})
}
}
func TestOriginalUserLedgerPreservesPreparedLongRequest(t *testing.T) {
cfg := &config.Config{}
cfg.OpenAI.MaxTotalTokens = 5000
cfg.Database.Path = filepath.Join(t.TempDir(), "synthetic.db")
original := "ONLY_AUTHORIZED_SCOPE no localhost\n" + strings.Repeat("request detail ", 1000)
preview := prepareLatestUserMessageForModel(original, cfg, &config.MultiAgentEinoMiddlewareConfig{}, "audit-human", nil)
max := modelFacingRuneBudget(5000, .2)
ledger := buildOriginalUserIntentLedgerMessage([]adk.Message{schema.UserMessage(preview)}, max, max)
if ledger == nil {
t.Fatal("long human ledger is missing")
}
entries := trustedUserIntentLedgerEntries(ledger)
if len(entries) != 1 || !strings.Contains(entries[0], "ONLY_AUTHORIZED_SCOPE no localhost") {
t.Fatalf("prepared request loses raw authorization prefix: %#v", entries)
}
}
func TestOriginalUserLedgerCapsOverflowNoticesAndTinyBudgets(t *testing.T) {
msgs := []adk.Message{schema.UserMessage("only authorized.example"), schema.UserMessage(strings.Repeat("additional detail ", 1000))}
for budget := 1; budget <= 200; budget++ {
body := buildOriginalUserIntentLedger(msgs, budget, 1000)
if utf8RuneLen(body) > budget {
t.Fatalf("budget %d exceeded by %d runes: %q", budget, utf8RuneLen(body), body)
}
if strings.Contains(body, "truncated") && !strings.Contains(body, "- [...truncated]") {
t.Fatalf("incomplete marker at budget %d: %q", budget, body)
}
ledger := buildOriginalUserIntentLedgerMessage(msgs, budget, 1000)
if ledger != nil {
for _, entry := range trustedUserIntentLedgerEntries(ledger) {
if strings.Contains(entry, "用户原始输入账本超过预算") || strings.Contains(entry, "truncated") {
t.Fatalf("notice becomes user input at budget %d: %q", budget, entry)
}
}
}
}
}
func TestOriginalUserLedgerFormattingKeepsNormalOutput(t *testing.T) {
msgs := []adk.Message{schema.UserMessage("Only staging, no production."), schema.UserMessage("先验证 /api/login。")}
want := "- [U001] Only staging, no production.\n- [U002] 先验证 /api/login。"
if got := buildOriginalUserIntentLedger(msgs, 96000, 16000); got != want {
t.Fatalf("normal ledger formatting changed: %q", got)
}
if got := buildOriginalUserIntentLedger(msgs, 0, 0); got != want {
t.Fatalf("unbounded ledger formatting changed: %q", got)
}
}
+7 -1
View File
@@ -4,6 +4,8 @@ import (
"context"
"fmt"
"cyberstrike-ai/internal/agent"
"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/adk/prebuilt/planexecute"
"github.com/cloudwego/eino/components/model"
@@ -92,10 +94,14 @@ func planExecuteDefaultGenExecutorInput(ctx context.Context, in *planexecute.Exe
if err != nil {
return nil, err
}
return planexecute.ExecutorPrompt.Format(ctx, map[string]any{
msgs, err := planexecute.ExecutorPrompt.Format(ctx, map[string]any{
"input": planExecuteFormatInput(in.UserInput),
"plan": string(planContent),
"executed_steps": planExecuteFormatExecutedSteps(in.ExecutedSteps, nil, nil),
"step": in.Plan.FirstStep(),
})
if err != nil {
return nil, err
}
return withPlanExecuteInputProvenance(msgs, in.UserInput, nil, nil, agent.PlanExecuteExecutorContext), nil
}
+6
View File
@@ -716,6 +716,12 @@ func historyToMessages(history []agent.ChatMessage, appCfg *config.Config, mwCfg
if h.SummarizationKind == "summary" || h.SummarizationKind == "skills" {
msg.Extra = map[string]interface{}{agent.SummarizationContentTypeKey: h.SummarizationKind}
}
if agent.IsGeneratedExecutionContext(h.GeneratedContextKind) {
if msg.Extra == nil {
msg.Extra = make(map[string]interface{})
}
msg.Extra[agent.GeneratedContextTypeKey] = h.GeneratedContextKind
}
raw = append(raw, msg)
}
case "system":