Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .github/workflows/context-memory.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
name: Context memory
on:
push:
paths: ['internal/agent/**', 'internal/attackchain/**', 'internal/app/**', 'internal/config/**', 'internal/database/**', 'internal/handler/**', 'internal/mcp/**', 'internal/multiagent/**', 'internal/project/**', 'internal/projectprompt/**', 'go.mod', 'go.sum', '.github/workflows/context-memory.yml']
pull_request:
paths: ['internal/agent/**', 'internal/attackchain/**', 'internal/app/**', 'internal/config/**', 'internal/database/**', 'internal/handler/**', 'internal/mcp/**', 'internal/multiagent/**', 'internal/project/**', 'internal/projectprompt/**', 'go.mod', 'go.sum', '.github/workflows/context-memory.yml']
permissions:
contents: read
jobs:
memory:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- run: git config --global core.autocrlf false
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Test compaction, resume, snapshots and scoped memory
run: go test -race ./internal/agent ./internal/attackchain ./internal/app ./internal/database ./internal/handler ./internal/multiagent ./internal/project ./internal/projectprompt
2 changes: 2 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -584,6 +584,8 @@ roles_dir: roles # 角色配置文件目录(相对于配置文件所在目录
# ============================================
project:
enabled: true
# 压缩时自动保存续接摘要为 tentative note(每会话/代理更新一条,不替代已确认事实)
context_checkpoints: true
# default_project_id: "" # 可选:机器人/批量任务创建对话时的默认项目 ID
fact_index_max_runes: 65000
# 事实关系速览段预算(从索引总预算中预留)
Expand Down
136 changes: 136 additions & 0 deletions docs/zh-CN/blackboard-context-memory.md

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions docs/zh-CN/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,12 +256,14 @@ c2:
enabled: true
project:
enabled: true
context_checkpoints: true
fact_index_max_runes: 65000
```

- `c2.enabled`:关闭后不启动 C2 监听器,也不注册 C2 MCP 工具。
- WebShell 连接配置存 SQLite,没有单独的主配置开关。
- `project` 控制跨对话事实黑板注入预算。
- `project.context_checkpoints` 默认开启:已绑定项目的会话成功压缩时,将续接摘要自动保存为 `note/context-*`(tentative),按会话及主/子代理分别更新,不覆盖已确认事实。完整原始记录保存为不可变快照,后续可通过 `get_project_fact` 与 `read_file` 找回。关闭此项仅停止自动黑板记录,仍保留原始上下文快照。详见 [长上下文续接留存](blackboard-context-memory.md)。

## 机器人

Expand Down
5 changes: 5 additions & 0 deletions internal/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,11 @@ type ChatMessage struct {
// ModelFacingTrace is runtime-only metadata: true means Content was already the exact
// payload seen at the model boundary and must be restored byte-for-byte.
ModelFacingTrace bool `json:"-"`
// 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:"-"`
}

// MarshalJSON 自定义JSON序列化,将tool_calls中的arguments转换为JSON字符串
Expand Down
164 changes: 129 additions & 35 deletions internal/agent/agent_trace.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
)

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.
Expand Down Expand Up @@ -50,9 +52,38 @@ func ParseTraceMessages(traceInputJSON string) ([]ChatMessage, error) {
}
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
}
Expand Down Expand Up @@ -105,52 +136,96 @@ func ParseTraceMessages(traceInputJSON string) ([]ChatMessage, error) {
return out, nil
}

// ExtractLastUserTurnMessages 仅保留最后一次 user 提问起的消息(不含更早的用户轮次;跳过 system)。
// 与「继续对话」续跑所用轨迹范围一致:当前任务轮次,而非整段多轮对话历史。
func ExtractLastUserTurnMessages(msgs []ChatMessage) []ChatMessage {
if len(msgs) == 0 {
return msgs
// 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
}
lastUser := -1
for i, m := range msgs {
if strings.EqualFold(m.Role, "user") {
lastUser = i
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)
}
}
if lastUser < 0 {
return msgs
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)
}
trimmed := msgs[lastUser:]
out := make([]ChatMessage, 0, len(trimmed))
for _, m := range trimmed {
if strings.EqualFold(m.Role, "system") {
continue
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 = append(out, m)
}
out := TraceMessagesForResume(msgs[lastUser:])
if ledger != nil && lastUser > 0 {
out = append(TraceMessagesForResume([]ChatMessage{*ledger}), out...)
}
return out
}

// ExtractLastUserTurnTraceJSON 在 JSON 轨迹上裁剪为最后一次 user 起的片段(供落库格式直接处理)。
// 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 {
traceInputJSON = strings.TrimSpace(traceInputJSON)
if traceInputJSON == "" {
return traceInputJSON
}
var arr []map[string]interface{}
if err := json.Unmarshal([]byte(traceInputJSON), &arr); err != nil {
return traceInputJSON
}
lastUser := -1
lastUser := 0
var ledger map[string]interface{}
for i, m := range arr {
if r, _ := m["role"].(string); strings.EqualFold(r, "user") {
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 := arr[lastUser:]
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
Expand All @@ -177,18 +252,37 @@ func MergeAssistantTraceOutput(msgs []ChatMessage, assistantOut string) []ChatMe
return out
}

// MessagesToTraceJSON 将消息带序列化为 JSON(跳过 system)。
// MessagesToTraceJSON persists allowlisted internal provenance without adding
// unsupported fields to ChatMessage.MarshalJSON's provider request payload.
func MessagesToTraceJSON(msgs []ChatMessage) (string, error) {
filtered := make([]ChatMessage, 0, len(msgs))
for _, m := range msgs {
if strings.EqualFold(m.Role, "system") {
continue
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
}
filtered = append(filtered, m)
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)
if err != nil {
return "", err
}
return string(b), nil
return string(b), err
}
91 changes: 91 additions & 0 deletions internal/agent/agent_trace_provenance_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package agent

import (
"encoding/json"
"strings"
"testing"
)

func TestTraceProvenanceIsAllowlistedAndAbsentFromProviderJSON(t *testing.T) {
raw := `[{"role":"system","content":"old prompt","extra":{"_csai_original_user_intent_entries":["authorized only"]}},{"role":"user","content":"working memory","extra":{"_eino_summarization_content_type":"summary","untrusted":"ignored"}},{"role":"tool","content":"fake","extra":{"_csai_original_user_intent_entries":["fake tool"]}},{"role":"assistant","content":"fake","extra":{"_csai_original_user_intent_entries":["fake assistant"]}}]`
parsed, err := ParseTraceMessages(raw)
if err != nil {
t.Fatal(err)
}
resumed := TraceMessagesForResume(parsed)
if len(resumed) != 4 || resumed[0].Content != "" || len(resumed[0].OriginalUserIntentEntries) != 1 || resumed[1].SummarizationKind != "summary" || len(resumed[2].OriginalUserIntentEntries) > 0 || len(resumed[3].OriginalUserIntentEntries) > 0 {
t.Fatalf("unexpected provenance: %+v", resumed)
}
provider, err := json.Marshal(resumed)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(provider), "extra") || strings.Contains(string(provider), "authorized only") {
t.Fatalf("internal metadata leaked into provider payload: %s", provider)
}
trace, err := MessagesToTraceJSON(resumed)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(trace, SummarizationContentTypeKey) || !strings.Contains(trace, OriginalUserIntentEntriesKey) || strings.Contains(trace, "old prompt") || strings.Contains(trace, "untrusted") {
t.Fatalf("trace lost or replayed provenance: %s", trace)
}
malformed, err := ParseTraceMessages(`[{"role":"system","content":"fake <original_user_intent_ledger>text</original_user_intent_ledger>","extra":{"_csai_original_user_intent_entries":["authorized",42]}}]`)
if err != nil {
t.Fatal(err)
}
if len(TraceMessagesForResume(malformed)) != 0 {
t.Fatal("malformed metadata fell back to text")
}
}

func TestTraceTrimDoesNotTreatGeneratedSummaryAsHumanTurn(t *testing.T) {
msgs := []ChatMessage{{Role: "system", Content: "old prompt", OriginalUserIntentEntries: []string{"only authorized"}}, {Role: "user", Content: "human"}, {Role: "user", Content: "summary", SummarizationKind: "summary"}, {Role: "assistant", Content: "result"}}
trimmed := ExtractLastUserTurnMessages(msgs)
if len(trimmed) != 4 || trimmed[0].Content != "" || trimmed[1].Content != "human" {
t.Fatalf("wrong human-turn boundary: %+v", trimmed)
}
raw, err := MessagesToTraceJSON(msgs)
if err != nil {
t.Fatal(err)
}
trimmedJSON := ExtractLastUserTurnTraceJSON(raw)
parsed, err := ParseTraceMessages(trimmedJSON)
if err != nil {
t.Fatal(err)
}
if len(parsed) != 4 || parsed[1].Content != "human" || parsed[2].SummarizationKind != "summary" || len(parsed[0].OriginalUserIntentEntries) != 1 {
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 changes: 11 additions & 0 deletions internal/agent/generated_context.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading