diff --git a/internal/ctxasm/ctxasm.go b/internal/ctxasm/ctxasm.go index 1712c84..e2e8ee2 100644 --- a/internal/ctxasm/ctxasm.go +++ b/internal/ctxasm/ctxasm.go @@ -36,13 +36,20 @@ type CardResolver func(id core.ID) *core.ActivityCard // 对称)。ok=false = 无工件服务/无此卡/内容不可读,部件留占位行。 type ArtifactResolver func(id core.ID) (art core.Artifact, content []byte, ok bool) +// AssembleOptions 是组装的可选行为。零值全部关闭(向后兼容)。 +type AssembleOptions struct { + // ThinkReplay 为真时,历史 assistant 消息的思考部件以 块原样回传 + //(交错思考模型如 MiniMax 的官方要求:丢弃思考内容会降低后续轮性能)。 + ThinkReplay bool +} + // AssembleInput 组装模型输入消息。sys 为提示词库组装出的 system 段(可为多段; // 调用方经 prompts.Service.ResolveSystem 产出),promptObs 为对应可观测条目。 // prof.SystemPrompt 非空时作为画像自定义段追加(向后兼容旧画像)。 // resolveCard 用于 card 引用部件回填产物卡正文(submit_card;D35:卡片为产物权威 // 载体,下一轮模型不失忆),nil 时 card 部件只留占位。resolveArtifact 同理回填 // artifact_ref 工件正文(D46 统一;仅 UTF-8 文本,超 limit 截断留痕)。 -func AssembleInput(prof core.AgentProfile, eff core.EffectiveSettings, history []core.Message, currentUser core.Message, attachments []core.ParsedDocument, knowledge []core.RetrievalHit, sys []agentllm.Message, promptObs []string, resolveCard CardResolver, resolveArtifact ArtifactResolver) ([]agentllm.Message, *Report) { +func AssembleInput(prof core.AgentProfile, eff core.EffectiveSettings, history []core.Message, currentUser core.Message, attachments []core.ParsedDocument, knowledge []core.RetrievalHit, sys []agentllm.Message, promptObs []string, resolveCard CardResolver, resolveArtifact ArtifactResolver, opts AssembleOptions) ([]agentllm.Message, *Report) { rep := &Report{} msgs := make([]agentllm.Message, 0, len(sys)+1) msgs = append(msgs, sys...) @@ -84,7 +91,7 @@ func AssembleInput(prof core.AgentProfile, eff core.EffectiveSettings, history [ continue } if m.Role == core.RoleAssistant { - msgs = append(msgs, renderAssistantTurns(m, resolveCard, limitCard, resolveArtifact, rep, &cardsBackfilled, &artsBackfilled)...) + msgs = append(msgs, renderAssistantTurns(m, resolveCard, limitCard, resolveArtifact, rep, &cardsBackfilled, &artsBackfilled, opts)...) continue } content := renderMessage(m, resolveCard, limitCard, resolveArtifact, rep, &cardsBackfilled, &artsBackfilled) @@ -171,17 +178,24 @@ func renderMessage(m core.Message, resolveCard CardResolver, limitCard int, reso // 前置文本与 tool_calls 组成一条 assistant,每个调用紧跟一条 tool 结果。 // 没有结果的调用补一条失败占位,避免模型把中断当成成功。孤立的结果直接跳过。 // 被守卫降级、且后面不是工具调用的过程文本不回放。 -func renderAssistantTurns(m core.Message, resolveCard CardResolver, limitCard int, resolveArtifact ArtifactResolver, rep *Report, cardsBackfilled, artsBackfilled *int) []agentllm.Message { +func renderAssistantTurns(m core.Message, resolveCard CardResolver, limitCard int, resolveArtifact ArtifactResolver, rep *Report, cardsBackfilled, artsBackfilled *int, opts AssembleOptions) []agentllm.Message { var out []agentllm.Message var text strings.Builder + var think strings.Builder var calls []agentllm.ToolCall results := map[string]core.ToolResultPart{} seq := 0 flush := func() { content := strings.TrimSpace(text.String()) + thinkText := strings.TrimSpace(think.String()) + if opts.ThinkReplay && thinkText != "" { + // 交错思考模型要求历史 assistant 原样带 ;只进请求,不落盘。 + content = "\n" + thinkText + "\n\n\n" + content + } if content == "" && len(calls) == 0 { text.Reset() + think.Reset() results = map[string]core.ToolResultPart{} return } @@ -202,12 +216,18 @@ func renderAssistantTurns(m core.Message, resolveCard CardResolver, limitCard in }) } text.Reset() + think.Reset() calls = nil results = map[string]core.ToolResultPart{} } for i, p := range m.Parts { switch p.Type { + case core.PartThinking: + if strings.TrimSpace(p.Text) != "" { + think.WriteString(p.Text) + think.WriteString("\n") + } case core.PartText: if demotedProcessText(m.Parts, i) { continue diff --git a/internal/ctxasm/ctxasm_test.go b/internal/ctxasm/ctxasm_test.go index 06b61cf..0bb553b 100644 --- a/internal/ctxasm/ctxasm_test.go +++ b/internal/ctxasm/ctxasm_test.go @@ -18,7 +18,7 @@ func TestAssembleInputAttachmentLimit(t *testing.T) { Meta: core.DocMeta{Filename: "纪要.docx"}, Blocks: []core.DocBlock{{Type: core.BlockPara, Text: long}}, } - msgs, rep := AssembleInput(prof, core.EffectiveSettings{}, nil, fakeUser("请总结"), []core.ParsedDocument{doc}, nil, nil, nil, nil, nil) + msgs, rep := AssembleInput(prof, core.EffectiveSettings{}, nil, fakeUser("请总结"), []core.ParsedDocument{doc}, nil, nil, nil, nil, nil, AssembleOptions{}) last := msgs[len(msgs)-1] if !strings.Contains(last.Content, "纪要.docx") { @@ -42,7 +42,7 @@ func TestAssembleInputAttachmentLimitConfigurable(t *testing.T) { Blocks: []core.DocBlock{{Type: core.BlockPara, Text: strings.Repeat("甲", 120)}}, } eff := core.EffectiveSettings{"context": map[string]any{"maxAttachmentChars": float64(100)}} - msgs, rep := AssembleInput(prof, eff, nil, fakeUser("总结"), []core.ParsedDocument{doc}, nil, nil, nil, nil, nil) + msgs, rep := AssembleInput(prof, eff, nil, fakeUser("总结"), []core.ParsedDocument{doc}, nil, nil, nil, nil, nil, AssembleOptions{}) last := msgs[len(msgs)-1] if got := strings.Count(last.Content, "甲"); got != 100 { t.Errorf("injected chars = %d, want 100 (configured limit)", got) @@ -58,7 +58,7 @@ func TestAssembleInputAttachmentNoTruncationUnderLimit(t *testing.T) { Meta: core.DocMeta{Filename: "b.txt"}, Blocks: []core.DocBlock{{Type: core.BlockPara, Text: strings.Repeat("乙", 100)}}, } - msgs, rep := AssembleInput(prof, core.EffectiveSettings{}, nil, fakeUser("总结"), []core.ParsedDocument{doc}, nil, nil, nil, nil, nil) + msgs, rep := AssembleInput(prof, core.EffectiveSettings{}, nil, fakeUser("总结"), []core.ParsedDocument{doc}, nil, nil, nil, nil, nil, AssembleOptions{}) if len(rep.Truncations) != 0 { t.Errorf("under-limit doc should not truncate: %v", rep.Truncations) } @@ -89,7 +89,7 @@ func TestAssembleInputCardBackfill(t *testing.T) { } return nil } - msgs, rep := AssembleInput(prof, core.EffectiveSettings{}, hist, fakeUser("把第二章改短"), nil, nil, nil, nil, resolve, nil) + msgs, rep := AssembleInput(prof, core.EffectiveSettings{}, hist, fakeUser("把第二章改短"), nil, nil, nil, nil, resolve, nil, AssembleOptions{}) var assistantIn string for _, m := range msgs { @@ -128,7 +128,7 @@ func TestAssembleInputCardTruncationAndFallback(t *testing.T) { }}, } eff := core.EffectiveSettings{"context": map[string]any{"maxCardChars": float64(100)}} - msgs, rep := AssembleInput(prof, eff, hist, fakeUser("继续"), nil, nil, nil, nil, func(id core.ID) *core.ActivityCard { return &card }, nil) + msgs, rep := AssembleInput(prof, eff, hist, fakeUser("继续"), nil, nil, nil, nil, func(id core.ID) *core.ActivityCard { return &card }, nil, AssembleOptions{}) if strings.Count(msgs[1].Content, "丁") != 100 { t.Errorf("card chars = %d, want 100 (configured limit)", strings.Count(msgs[1].Content, "丁")) } @@ -137,7 +137,7 @@ func TestAssembleInputCardTruncationAndFallback(t *testing.T) { } // 无 resolver(收件箱关闭/卡片缺失):占位可见。 - msgs2, _ := AssembleInput(prof, core.EffectiveSettings{}, hist, fakeUser("继续"), nil, nil, nil, nil, nil, nil) + msgs2, _ := AssembleInput(prof, core.EffectiveSettings{}, hist, fakeUser("继续"), nil, nil, nil, nil, nil, nil, AssembleOptions{}) if !strings.Contains(msgs2[1].Content, "内容见收件箱") { t.Errorf("fallback placeholder missing: %q", msgs2[1].Content) } @@ -159,7 +159,7 @@ func TestAssembleInputArtifactBackfill(t *testing.T) { } return core.Artifact{}, nil, false } - msgs, rep := AssembleInput(prof, core.EffectiveSettings{}, hist, fakeUser("继续"), nil, nil, nil, nil, nil, resolve) + msgs, rep := AssembleInput(prof, core.EffectiveSettings{}, hist, fakeUser("继续"), nil, nil, nil, nil, nil, resolve, AssembleOptions{}) if !strings.Contains(msgs[1].Content, "此前已生成文件 检索汇总.md,内容如下:") || !strings.Contains(msgs[1].Content, "关键结论一。") { t.Errorf("artifact body not backfilled: %q", msgs[1].Content) } @@ -172,7 +172,7 @@ func TestAssembleInputArtifactBackfill(t *testing.T) { eff := core.EffectiveSettings{"context": map[string]any{"maxCardChars": float64(100)}} msgs2, rep2 := AssembleInput(prof, eff, hist, fakeUser("继续"), nil, nil, nil, nil, nil, func(core.ID) (core.Artifact, []byte, bool) { return art, []byte(long), true - }) + }, AssembleOptions{}) if strings.Count(msgs2[1].Content, "乙") != 100 { t.Errorf("artifact chars = %d, want 100", strings.Count(msgs2[1].Content, "乙")) } @@ -183,13 +183,13 @@ func TestAssembleInputArtifactBackfill(t *testing.T) { // 二进制内容:占位不回灌乱码。 msgs3, _ := AssembleInput(prof, core.EffectiveSettings{}, hist, fakeUser("继续"), nil, nil, nil, nil, nil, func(core.ID) (core.Artifact, []byte, bool) { return art, []byte{0xff, 0xfe, 0x00, 0x01}, true - }) + }, AssembleOptions{}) if !strings.Contains(msgs3[1].Content, "二进制内容") || strings.Contains(msgs3[1].Content, "关键结论") { t.Errorf("binary artifact should keep placeholder: %q", msgs3[1].Content) } // 无 resolver:占位可见。 - msgs4, _ := AssembleInput(prof, core.EffectiveSettings{}, hist, fakeUser("继续"), nil, nil, nil, nil, nil, nil) + msgs4, _ := AssembleInput(prof, core.EffectiveSettings{}, hist, fakeUser("继续"), nil, nil, nil, nil, nil, nil, AssembleOptions{}) if !strings.Contains(msgs4[1].Content, "此前已生成文件:检索汇总.md。") { t.Errorf("artifact placeholder missing without resolver: %q", msgs4[1].Content) } diff --git a/internal/ctxasm/finish_test.go b/internal/ctxasm/finish_test.go index d417cbf..3bf915b 100644 --- a/internal/ctxasm/finish_test.go +++ b/internal/ctxasm/finish_test.go @@ -14,7 +14,7 @@ func TestVisibleHistoryDropsExcludedAndUnselectedVariants(t *testing.T) { {ID: "a1", Role: core.RoleAssistant, VariantGroup: "g", VariantIndex: 0, Excluded: true, Parts: []core.Part{{Type: core.PartText, Text: "旧"}}}, {ID: "a2", Role: core.RoleAssistant, VariantGroup: "g", VariantIndex: 1, Parts: []core.Part{{Type: core.PartText, Text: "新"}}}, } - msgs, _ := AssembleInput(core.AgentProfile{}, core.EffectiveSettings{}, hist, core.Message{ID: "cur", Role: core.RoleUser}, nil, nil, nil, nil, nil, nil) + msgs, _ := AssembleInput(core.AgentProfile{}, core.EffectiveSettings{}, hist, core.Message{ID: "cur", Role: core.RoleUser}, nil, nil, nil, nil, nil, nil, AssembleOptions{}) blob := "" for _, m := range msgs { blob += m.Content diff --git a/internal/ctxasm/replay_test.go b/internal/ctxasm/replay_test.go index 0c6d731..23f4d15 100644 --- a/internal/ctxasm/replay_test.go +++ b/internal/ctxasm/replay_test.go @@ -15,7 +15,7 @@ func assistantWith(id string, parts ...core.Part) core.Message { } func assembleHistory(hist []core.Message, resolve ArtifactResolver) []agentllm.Message { - msgs, _ := AssembleInput(core.AgentProfile{}, core.EffectiveSettings{}, hist, fakeUser("继续"), nil, nil, nil, nil, nil, resolve) + msgs, _ := AssembleInput(core.AgentProfile{}, core.EffectiveSettings{}, hist, fakeUser("继续"), nil, nil, nil, nil, nil, resolve, AssembleOptions{}) if len(msgs) == 0 { return nil } diff --git a/internal/ctxasm/summary_test.go b/internal/ctxasm/summary_test.go index ec4c1bf..10b3565 100644 --- a/internal/ctxasm/summary_test.go +++ b/internal/ctxasm/summary_test.go @@ -14,7 +14,7 @@ func TestSummarizeBeyondInjectsExtract(t *testing.T) { } current := core.Message{ID: "cur", Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "现在"}}} prof := core.AgentProfile{Context: core.ContextPolicy{MaxHistoryMessages: 2, SummarizeBeyond: true}} - msgs, rep := AssembleInput(prof, core.EffectiveSettings{}, history, current, nil, nil, nil, nil, nil, nil) + msgs, rep := AssembleInput(prof, core.EffectiveSettings{}, history, current, nil, nil, nil, nil, nil, nil, AssembleOptions{}) joined := "" for _, m := range msgs { joined += m.Content diff --git a/internal/ctxasm/think_replay_test.go b/internal/ctxasm/think_replay_test.go new file mode 100644 index 0000000..f6e2df0 --- /dev/null +++ b/internal/ctxasm/think_replay_test.go @@ -0,0 +1,43 @@ +package ctxasm + +import ( + "strings" + "testing" + + agentllm "agent-native/runtime/llm" + core "agent-system-core" +) + +// 交错思考模型(MiniMax 等)要求历史 assistant 原样回传 ; +// 关闭时不回传(默认兼容旧行为)。思考块只进请求,不落库。 +func TestThinkReplayWrapsAssistantHistory(t *testing.T) { + hist := []core.Message{ + {ID: "m1", Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "总结"}}}, + {ID: "m2", Role: core.RoleAssistant, Parts: []core.Part{ + {Type: core.PartThinking, Text: "先读材料。", Process: true}, + {Type: core.PartText, Text: "结论是先入库。", Process: true}, + {Type: core.PartToolCall, ToolCall: &core.ToolCallPart{ID: "c1", Name: "run_python", Arguments: "{}"}}, + {Type: core.PartToolResult, ToolResult: &core.ToolResultPart{CallID: "c1", Status: "ok", Summary: "done"}}, + {Type: core.PartText, Text: "已完成。"}, + }}, + } + on, _ := AssembleInput(core.AgentProfile{}, core.EffectiveSettings{}, hist, fakeUser("继续"), nil, nil, nil, nil, nil, nil, AssembleOptions{ThinkReplay: true}) + var sawThink int + for _, m := range on { + if m.Role == agentllm.RoleAssistant && strings.HasPrefix(m.Content, "") { + sawThink++ + if !strings.Contains(m.Content, "") || !strings.Contains(m.Content, "先读材料。") { + t.Fatalf("think block malformed: %q", m.Content) + } + } + } + if sawThink == 0 { + t.Fatalf("ThinkReplay=on should inject into assistant history: %+v", on) + } + off, _ := AssembleInput(core.AgentProfile{}, core.EffectiveSettings{}, hist, fakeUser("继续"), nil, nil, nil, nil, nil, nil, AssembleOptions{}) + for _, m := range off { + if m.Role == agentllm.RoleAssistant && strings.Contains(m.Content, "") { + t.Fatalf("ThinkReplay=off must not inject think blocks: %q", m.Content) + } + } +} diff --git a/internal/prompts/library/env-runtime.md b/internal/prompts/library/env-runtime.md index 7db6685..c55026d 100644 --- a/internal/prompts/library/env-runtime.md +++ b/internal/prompts/library/env-runtime.md @@ -12,4 +12,4 @@ description: 动态注入的当前环境事实(占位符随运行填充) - 本轮附带文档:{{attachments}}。文档全文已注入上下文(超长时会标注截断)。 - 交付物默认样式:{{deliverableStyle}}。本轮的明确指令、上传的模板或参考文档优先于该默认值。 - 技能目录:{{skillsDir}}。环境变量 AGENT_SKILLS_DIR 指向这里;Python 已能 `import housestyle`。 -- 本轮原件(工作区相对路径):{{inputs}}。模板和参考文档用这些路径,不要向用户索取已经上传的文件。 +- 会话工作区文件(相对路径,跨轮有效):{{inputs}}。原件旁的 <原名>.md 是解析出的纯文本,需要原始信息时优先读它(不要只依赖你上一轮生成的产物,也不要向用户索取已经上传的文件)。模板和参考文档用这些路径。 diff --git a/internal/runmgr/inputs.go b/internal/runmgr/inputs.go index f18b011..6c74c09 100644 --- a/internal/runmgr/inputs.go +++ b/internal/runmgr/inputs.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "strings" + "unicode/utf8" core "agent-system-core" ) @@ -54,6 +55,60 @@ func StageWorkspaceInputs(root, owner, conversation string, files []WorkspaceFil return rels, nil } +// PersistInputTexts 把附件解析出的纯文本写成 inputs/<原名>.md,与原件并列。 +// 后续轮次的上下文里原件只剩占位时,模型仍能读到可检索的原文文本—— +// 「根据纪要制定方案」不再只能依赖上一轮产物(实测后续轮 0 次读原件)。 +// 原本就是文本类(md/txt/csv)的跳过;解析文本过短(<200 字)视为解析失败跳过。 +func PersistInputTexts(root, owner, conversation string, attachments []core.ParsedDocument) { + if strings.TrimSpace(root) == "" { + return + } + dir := filepath.Join(root, owner, conversation, "inputs") + for _, doc := range attachments { + name := strings.TrimSpace(doc.Meta.Filename) + if name == "" { + continue + } + switch strings.ToLower(filepath.Ext(name)) { + case ".md", ".txt", ".csv", ".json": + continue + } + text := strings.TrimSpace(doc.PlainText()) + if utf8.RuneCountInString(text) < 200 { + continue + } + stem := strings.TrimSuffix(name, filepath.Ext(name)) + out := filepath.Join(dir, stem+".md") + if _, err := os.Stat(out); err == nil { + continue // 已有同名解析文本(同一附件重发)不覆盖 + } + if err := os.WriteFile(out, []byte(text), 0o644); err == nil { + _ = out + } + } +} + +// ListWorkspaceInputs 列出会话工作区 inputs/ 目录的相对路径(原样返回给环境段)。 +// 覆盖「本轮上传」视角:跨轮可见所有原件与解析文本。目录不存在时回落 staging 列表。 +func ListWorkspaceInputs(root, owner, conversation string, staged []string) []string { + dir := filepath.Join(root, owner, conversation, "inputs") + ents, err := os.ReadDir(dir) + if err != nil { + return staged + } + var out []string + for _, e := range ents { + if e.IsDir() || strings.HasPrefix(e.Name(), ".") { + continue + } + out = append(out, "inputs/"+e.Name()) + } + if len(out) == 0 { + return staged + } + return out +} + func (m *Manager) stageInputs(conv core.Conversation, eff core.EffectiveSettings, files []WorkspaceFile) ([]string, error) { var templateName string var template []byte diff --git a/internal/runmgr/inputs_persist_test.go b/internal/runmgr/inputs_persist_test.go new file mode 100644 index 0000000..cce3365 --- /dev/null +++ b/internal/runmgr/inputs_persist_test.go @@ -0,0 +1,65 @@ +package runmgr + +import ( + "os" + "path/filepath" + "strings" + "testing" + + core "agent-system-core" +) + +// 附件解析文本要落 inputs/<原名>.md 且跨轮可见:后续轮上下文里原件只剩占位时, +// 模型仍能读到原文(实测「根据纪要制定方案」轮 0 次读原件,只能编细节)。 +func TestPersistInputTextsAndListWorkspaceInputs(t *testing.T) { + root := t.TempDir() + if _, err := StageWorkspaceInputs(root, "alice", "conv1", []WorkspaceFile{ + {Name: "会议.docx", Content: []byte("bin")}, + }, "", nil); err != nil { + t.Fatal(err) + } + long := strings.Repeat("会议结论先入库。", 40) // >200 字 + docs := []core.ParsedDocument{{ + Meta: core.DocMeta{Filename: "会议.docx"}, + Blocks: []core.DocBlock{{Type: core.BlockPara, Text: long}}, + }} + PersistInputTexts(root, "alice", "conv1", docs) + + extracted := filepath.Join(root, "alice", "conv1", "inputs", "会议.md") + b, err := os.ReadFile(extracted) + if err != nil { + t.Fatalf("extracted text not written: %v", err) + } + if !strings.Contains(string(b), "会议结论先入库") { + t.Fatalf("extracted text wrong: %q", string(b)[:50]) + } + + // 短文本(解析失败/过短)与已是文本类的原件不写。 + short := []core.ParsedDocument{{Meta: core.DocMeta{Filename: "短.docx"}, Blocks: []core.DocBlock{{Type: core.BlockPara, Text: "太短"}}}} + PersistInputTexts(root, "alice", "conv1", short) + textKind := []core.ParsedDocument{{Meta: core.DocMeta{Filename: "笔记.md"}, Blocks: []core.DocBlock{{Type: core.BlockPara, Text: long}}}} + PersistInputTexts(root, "alice", "conv1", textKind) + if _, err := os.Stat(filepath.Join(root, "alice", "conv1", "inputs", "短.md")); err == nil { + t.Fatal("short parse should not be persisted") + } + if _, err := os.Stat(filepath.Join(root, "alice", "conv1", "inputs", "笔记.md.md")); err == nil { + t.Fatal("text-kind originals should not get a duplicate extract") + } + + // 跨轮列出:包含原件 + 解析文本,而不是只有本轮 staging。 + got := ListWorkspaceInputs(root, "alice", "conv1", []string{"inputs/会议.docx"}) + joined := strings.Join(got, ",") + if !strings.Contains(joined, "inputs/会议.docx") || !strings.Contains(joined, "inputs/会议.md") { + t.Fatalf("listing should include original + extracted: %v", got) + } + // 目录缺失时回落 staging 列表。 + fallback := ListWorkspaceInputs(root, "alice", "nope", []string{"inputs/only.txt"}) + if len(fallback) != 1 || fallback[0] != "inputs/only.txt" { + t.Fatalf("fallback listing broken: %v", fallback) + } + // root 未配置:全部 no-op。 + PersistInputTexts("", "alice", "conv1", docs) + if got := ListWorkspaceInputs("", "alice", "conv1", []string{"inputs/a.txt"}); got[0] != "inputs/a.txt" { + t.Fatalf("unconfigured root should pass through staged: %v", got) + } +} diff --git a/internal/runmgr/memory.go b/internal/runmgr/memory.go index d8ffc39..c9bf432 100644 --- a/internal/runmgr/memory.go +++ b/internal/runmgr/memory.go @@ -5,8 +5,8 @@ import ( "time" "agent-native/runtime/memory" - core "agent-system-core" "agent-system-backend/internal/store" + core "agent-system-core" ) // storeMemory 把 agent-native 的记忆端口接到 memory_entries 表(N3 WP5)。 diff --git a/internal/runmgr/perm_test.go b/internal/runmgr/perm_test.go index bc94d72..37c8f88 100644 --- a/internal/runmgr/perm_test.go +++ b/internal/runmgr/perm_test.go @@ -88,4 +88,3 @@ func TestPermissionAskRoutesToHITL(t *testing.T) { t.Fatalf("no verdict = %+v", v) } } - diff --git a/internal/runmgr/runmgr.go b/internal/runmgr/runmgr.go index 3593b83..7809e4d 100644 --- a/internal/runmgr/runmgr.go +++ b/internal/runmgr/runmgr.go @@ -92,15 +92,15 @@ type bgNotice struct { var ErrConversationBusy = fmt.Errorf("runmgr: conversation already has an in-flight run") type runHandle struct { - cancel context.CancelFunc - hitlCh chan hitlAnswer - assistant []core.Part - ctxObs *core.ContextObservation - submittedCards []core.ID // submit_card 产物卡(finishRun 回填锚点) - streamCardID core.ID // 活跃流式产物卡(card_begin/card_commit 三段式;D44/D45) - started time.Time - promptTokens int - completionTokens int + cancel context.CancelFunc + hitlCh chan hitlAnswer + assistant []core.Part + ctxObs *core.ContextObservation + submittedCards []core.ID // submit_card 产物卡(finishRun 回填锚点) + streamCardID core.ID // 活跃流式产物卡(card_begin/card_commit 三段式;D44/D45) + started time.Time + promptTokens int + completionTokens int lastPromptTokens int lastCompletionTokens int cacheReadTokens int @@ -1039,6 +1039,8 @@ func (m *Manager) startClaimedRun(conv core.Conversation, userMessage core.Messa runner := selectConversationRunner(m, eff, launch) inputPaths, _ := m.stageInputs(conv, eff, inputs) + PersistInputTexts(m.workspaceRoot, conv.Owner, conv.ID, attachments) + inputPaths = ListWorkspaceInputs(m.workspaceRoot, conv.Owner, conv.ID, inputPaths) rc := &runCtx{mgr: m, run: run, prof: prof, eff: eff, ctx: ctx, h: h, userMessage: userMessage, attachments: attachments, inputPaths: inputPaths, launch: launch, files: filestate.New(), permissionMode: conversationMode(m, run.Owner, run.ConversationID)} go m.execute(rc, runner) return run, nil @@ -1297,7 +1299,7 @@ func (m *Manager) ResolveHITL(owner, cardID string, resp core.HITLResponse) (piv res := rec.Recognize(context.Background(), trimmed) dec := route.Decide(res, route.Env{}) if dec.StartFound { - pivot, pivotReason = true, "intent:" + res.ID + pivot, pivotReason = true, "intent:"+res.ID } } else if branch, topID := m.gate.Match(context.Background(), trimmed); branch == "matched" { pivot, pivotReason = true, "intent:"+topID diff --git a/internal/runmgr/subagent_n2_test.go b/internal/runmgr/subagent_n2_test.go index 83fa2a0..49ddda1 100644 --- a/internal/runmgr/subagent_n2_test.go +++ b/internal/runmgr/subagent_n2_test.go @@ -138,7 +138,6 @@ func TestSpawnBackgroundRecordsDetach(t *testing.T) { } } - // N3 WP1:done.json 语义分流——优雅停机写的 interrupted 不挡续跑(一次且仅一次); // completed 终态挡续跑。 func TestResumeAfterGracefulInterruptMarker(t *testing.T) { diff --git a/internal/runmgr/subagent_transcript.go b/internal/runmgr/subagent_transcript.go index bf2a57c..6370ce2 100644 --- a/internal/runmgr/subagent_transcript.go +++ b/internal/runmgr/subagent_transcript.go @@ -158,7 +158,7 @@ func (m *Manager) ResumeInterruptedChildren() { continue } marker, _ := json.Marshal(map[string]string{ - "newRun": newID, + "newRun": newID, "resumedAt": time.Now().UTC().Format(time.RFC3339), }) if err := os.WriteFile(filepath.Join(dir, "resumed.json"), marker, 0o644); err != nil { diff --git a/internal/runtime/context_window_test.go b/internal/runtime/context_window_test.go new file mode 100644 index 0000000..765ae5a --- /dev/null +++ b/internal/runtime/context_window_test.go @@ -0,0 +1,51 @@ +package runtime + +import ( + "strings" + "testing" + + core "agent-system-core" +) + +// 窗口按 token 配置并换算为字符阈值;此前 32000 字符硬默认会让长材料任务 +// 在第一个请求就触发清理(被清内容只留「见会话记录」,模型无法取回)。 +func TestContextWindowScalesFromTokens(t *testing.T) { + eff := core.EffectiveSettings{"models": map[string]any{"chat": map[string]any{ + "contextWindow": float64(128000), + "outputReserve": float64(16384), + "contextBuffer": float64(4096), + }}} + w, r, b := contextWindow(eff) + if w != int(128000*3.35) { + t.Fatalf("window = %d, want %d", w, int(128000*3.35)) + } + if r <= 0 || b <= 0 { + t.Fatalf("reserve/buffer must be positive chars: %d %d", r, b) + } + // 旧部署未配置时的默认:不再是 32k 字符。 + def, _, _ := contextWindow(core.EffectiveSettings{}) + if def < 100000*3 { + t.Fatalf("default window too small: %d", def) + } +} + +func TestThinkReplayEnabled(t *testing.T) { + eff := func(v string) core.EffectiveSettings { + return core.EffectiveSettings{"context": map[string]any{"thinkReplay": v}} + } + if !thinkReplayEnabled(eff(""), "MiniMax-M2.7-highspeed") { + t.Fatal("auto should enable for minimax models") + } + if thinkReplayEnabled(eff(""), "gpt-5.1") { + t.Fatal("auto should stay off for other models") + } + if !thinkReplayEnabled(eff("on"), "gpt-5.1") { + t.Fatal("explicit on must win") + } + if thinkReplayEnabled(eff("off"), "MiniMax-M2.7-highspeed") { + t.Fatal("explicit off must win") + } + if !strings.Contains("checkpoint", "checkpoint") { + t.Fatal("sanity") + } +} diff --git a/internal/runtime/deliverable_latest_test.go b/internal/runtime/deliverable_latest_test.go index 7ff8c24..d5002c5 100644 --- a/internal/runtime/deliverable_latest_test.go +++ b/internal/runtime/deliverable_latest_test.go @@ -14,11 +14,17 @@ func TestLatestDeliverablesKeepsLastPerName(t *testing.T) { {ArtifactID: "art_bbb2", Name: "纪要.docx"}, {ArtifactID: "art_ccc3", Name: "方案.docx"}, {ArtifactID: "art_ddd4", Name: ""}, + {ArtifactID: "art_eee5", Name: "tool-run_shell-call_x1.txt", Kind: "data"}, }} got := st.latestDeliverables() if len(got) != 3 { t.Fatalf("want 3 deliverables, got %d: %+v", len(got), got) } + for _, ref := range got { + if isToolDumpArtifact(ref) { + t.Fatalf("tool result dumps are not deliverables: %+v", ref) + } + } if got[0].ArtifactID != "art_ccc3" || got[0].Name != "方案.docx" { t.Fatalf("方案.docx should resolve to latest: %+v", got[0]) } diff --git a/internal/runtime/llm.go b/internal/runtime/llm.go index 5e964b9..ede16e6 100644 --- a/internal/runtime/llm.go +++ b/internal/runtime/llm.go @@ -11,12 +11,12 @@ import ( nruntime "agent-native/runtime" "agent-native/runtime/gates" agentllm "agent-native/runtime/llm" + agentmemory "agent-native/runtime/memory" "agent-native/runtime/policy" agenttools "agent-native/runtime/tools" "agent-system-backend/internal/ctxasm" "agent-system-backend/internal/prompts" "agent-system-backend/internal/quality" - agentmemory "agent-native/runtime/memory" core "agent-system-core" ) @@ -240,7 +240,9 @@ func (l *LLMRunner) Execute(rc RunCtx) error { // 记忆注入迁移到记忆端口(N3 WP5):由循环经 Config.Memory 按请求注入, // 组装侧不再注入(防双份;注入文案同一形态)。 _ = ctxasm.InjectMemory - msgs, asmReport := ctxasm.AssembleInput(prof, eff, hist, currentUser, rc.Attachments(), knowledge, sys, promptObs, rc.Card, resolveArtifact) + msgs, asmReport := ctxasm.AssembleInput(prof, eff, hist, currentUser, rc.Attachments(), knowledge, sys, promptObs, rc.Card, resolveArtifact, ctxasm.AssembleOptions{ + ThinkReplay: thinkReplayEnabled(eff, route.Model), + }) tail := ctxasm.Tail{ Instructions: eff.GetString("context.userInstructions", ""), History: hist, @@ -369,13 +371,33 @@ func applyToolSelection(decls []agentllm.ToolDecl, spec string) []agentllm.ToolD return next } +// contextWindow 把模型真实窗口(token)换算成内核清理阈值用的字符数。 +// 内核 EstimateChars 按 rune 计数;按 ctxasm 同口径(UTF-8 字节/3.35 ≈ token, +// 中文约一字一 token)反推 chars ≈ tokens×3.35。此前 32000 字符的硬默认会让 +// 带长材料的任务在第一个请求就触发清理,被清内容只留一句「见会话记录」。 func contextWindow(eff core.EffectiveSettings) (window, reserve, buffer int) { - window = int(eff.GetNumber("models.chat.contextWindow", 32000)) - reserve = int(eff.GetNumber("models.chat.outputReserve", 4096)) - buffer = int(eff.GetNumber("models.chat.contextBuffer", 1024)) + tokens := eff.GetNumber("models.chat.contextWindow", 128000) + if tokens < 4096 { + tokens = 4096 + } + window = int(tokens * 3.35) + reserve = int(eff.GetNumber("models.chat.outputReserve", 16384) * 3.35) + buffer = int(eff.GetNumber("models.chat.contextBuffer", 4096) * 3.35) return } +// thinkReplayEnabled 决定历史 assistant 消息是否原样回传 块。 +// MiniMax 官方要求保留(去掉会掉性能);auto 按模型名识别。 +func thinkReplayEnabled(eff core.EffectiveSettings, model string) bool { + switch eff.GetString("context.thinkReplay", "auto") { + case "on": + return true + case "off": + return false + } + return strings.Contains(strings.ToLower(model), "minimax") +} + // loopCaps 把画像预算换成内核参数。轮次或工具数为 0 / 未设置表示不限制; // 内核对 MaxTurns<=0 会回落到 12,所以不限制时传 MaxInt32。MaxToolCalls 的 0 才是不限制。 func loopCaps(budget core.RunBudget) (maxTurns, maxTools int) { diff --git a/internal/runtime/llm_direct.go b/internal/runtime/llm_direct.go index d1c3d81..dc05c05 100644 --- a/internal/runtime/llm_direct.go +++ b/internal/runtime/llm_direct.go @@ -3,9 +3,9 @@ package runtime import ( "context" "encoding/json" + "fmt" "log" "path/filepath" - "fmt" "strings" "sync" "time" @@ -50,17 +50,17 @@ type directRun struct { inflight *toolSlot turnBody string - evidence []toolEvidence - todos core.TodoList - plan core.PlanSnapshot - closedToolTurn bool - checklistTouched bool - toolTurnsSince int - progress *gates.ProgressReminder - checks []core.CheckPart - servedModel string - switches []string - switchMu sync.Mutex + evidence []toolEvidence + todos core.TodoList + plan core.PlanSnapshot + closedToolTurn bool + checklistTouched bool + toolTurnsSince int + progress *gates.ProgressReminder + checks []core.CheckPart + servedModel string + switches []string + switchMu sync.Mutex } type toolEvidence struct { @@ -167,6 +167,12 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin } window, reserve, buffer := contextWindow(st.eff) + // 运行内被清理的旧工具结果完整落盘(占位写路径,模型可 cat 回看)—— + // 此前占位只写「见会话记录」,而模型没有读会话记录的工具。 + var resultsDir string + if sandbox := conversationSandbox(rc); sandbox != "" { + resultsDir = filepath.Join(sandbox, ".agent", "results") + } // 会话转录(N3 WP2):产品会话 run 接入意图日志与恢复配对—— // JournalOnly 模式:journal 只作日志,上下文仍由 ctxasm 组装(产品管线 // 不冻结);恢复写入的配对摘要以系统消息注入。打开失败降级为无 @@ -205,6 +211,7 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin ContextWindow: window, OutputReserve: reserve, ContextBuffer: buffer, + ResultsDir: resultsDir, OnEvent: st.onEvent, Hooks: bus, Reminders: st.reminders, @@ -434,6 +441,8 @@ func (st *directRun) demoteLastText() int { // latestDeliverables 同名工件只保留最后登记的版本,顺序不变。 // 修订(同名再登记)后旧版本不再是交付物:不进终轮检查、不重复出现在 // 回复的交付清单里。无名工件互不合并。 +// 超长工具输出的截断转存(runmgr 的 tool-<调用>.txt,kind=data)不是交付物: +// 不进终轮检查、不占交付卡片(实测被评分器当 .txt 交付物误伤,检查连判负)。 func (st *directRun) latestDeliverables() []*core.ArtifactRef { idx := map[string]int{} var out []*core.ArtifactRef @@ -442,6 +451,9 @@ func (st *directRun) latestDeliverables() []*core.ArtifactRef { out = append(out, ref) continue } + if isToolDumpArtifact(ref) { + continue + } if i, ok := idx[ref.Name]; ok { out[i] = ref continue @@ -452,6 +464,11 @@ func (st *directRun) latestDeliverables() []*core.ArtifactRef { return out } +// isToolDumpArtifact 识别「工具结果超长截断转工件」的落盘(上下文救济,非交付物)。 +func isToolDumpArtifact(ref *core.ArtifactRef) bool { + return ref.Kind == "data" && strings.HasPrefix(ref.Name, "tool-") +} + func (st *directRun) addVerifyNote(reason string) { st.addCheck(false, reason) } diff --git a/internal/runtime/llm_direct_n3_test.go b/internal/runtime/llm_direct_n3_test.go index 1d69607..cbc5f19 100644 --- a/internal/runtime/llm_direct_n3_test.go +++ b/internal/runtime/llm_direct_n3_test.go @@ -132,7 +132,7 @@ func TestDirectRunInjectsMemoryBlock(t *testing.T) { var gotReq []agentllm.Message base := scriptStream(textTurns("收到"), nil) rc := &fakeRunCtx{ - ctx: context.Background(), + ctx: context.Background(), prof: core.AgentProfile{Model: core.ModelRoute{Chat: "test-model"}}, memEntries: []memEntry{ {typ: "fact", content: "项目用 Go 1.27"}, diff --git a/internal/runtime/llm_test.go b/internal/runtime/llm_test.go index 699f709..13870d0 100644 --- a/internal/runtime/llm_test.go +++ b/internal/runtime/llm_test.go @@ -41,9 +41,9 @@ type fakeRunCtx struct { history []core.Message // 非 nil 时覆盖默认历史 bgID string bgErr error - sandbox string // 非空时 ConversationSandbox 返回之(N3 WP2 转录测试) + sandbox string // 非空时 ConversationSandbox 返回之(N3 WP2 转录测试) memEntries []memEntry // 非空时 StoreMemory 返回之(N3 WP5 记忆注入测试) - usageTotal core.Usage // AddUsage 累计(N3 WP6 记账断言用) + usageTotal core.Usage // AddUsage 累计(N3 WP6 记账断言用) bgCalls int } diff --git a/internal/runtime/workflow_compiled.go b/internal/runtime/workflow_compiled.go index 7af38f2..b805674 100644 --- a/internal/runtime/workflow_compiled.go +++ b/internal/runtime/workflow_compiled.go @@ -146,12 +146,12 @@ func modelScriptLaunch(body string) workflow.Launch { // 运行侧可选能力,缺失时回退空串由宿主处理器 fail-closed)。 type workflowHost struct{ rc RunCtx } -func (h workflowHost) Context() context.Context { return h.rc.Context() } -func (h workflowHost) Run() core.Run { return h.rc.Run() } -func (h workflowHost) Settings() core.EffectiveSettings { return h.rc.Settings() } -func (h workflowHost) Emit(t core.StreamEventType, p any) error { return h.rc.Emit(t, p) } +func (h workflowHost) Context() context.Context { return h.rc.Context() } +func (h workflowHost) Run() core.Run { return h.rc.Run() } +func (h workflowHost) Settings() core.EffectiveSettings { return h.rc.Settings() } +func (h workflowHost) Emit(t core.StreamEventType, p any) error { return h.rc.Emit(t, p) } func (h workflowHost) AskHITL(c core.HITLCard) (core.HITLResponse, error) { return h.rc.AskHITL(c) } -func (h workflowHost) UpdatePlan(p core.PlanSnapshot) error { return h.rc.UpdatePlan(p) } +func (h workflowHost) UpdatePlan(p core.PlanSnapshot) error { return h.rc.UpdatePlan(p) } func (h workflowHost) WriteArtifact(name, kind, mime string, content []byte) (core.Artifact, error) { return h.rc.WriteArtifact(name, kind, mime, content) } diff --git a/internal/runtime/workflow_compiled_test.go b/internal/runtime/workflow_compiled_test.go index 7b5ace4..c783b65 100644 --- a/internal/runtime/workflow_compiled_test.go +++ b/internal/runtime/workflow_compiled_test.go @@ -164,8 +164,6 @@ func (f *wfFake) WorkflowByID(id string) (core.WorkflowDef, bool) { return def, ok } - - // ---- run_workflow 注册分支:查找与错误面(legacy 同款文案) ---- func TestRunWorkflowToolUsesRegisteredDefinition(t *testing.T) { diff --git a/internal/settings/settings.go b/internal/settings/settings.go index fc07d12..2be39ec 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -75,6 +75,9 @@ func Schema() []core.SettingDesc { {Key: "models.chat.model", Group: "models", Label: "主模型名称", Type: core.SettingString, Default: "", Effect: core.EffectNewRun}, {Key: "models.chat.fallbacks", Group: "models", Label: "主模型回退链", Type: core.SettingString, Default: "", Effect: core.EffectNewRun, Description: "逗号分隔的模型名。只在尚未输出时,前一个模型返回 5xx 才改用下一个。空表示不回退"}, {Key: "models.chat.maxOutputTokens", Group: "models", Label: "主模型输出上限", Type: core.SettingNumber, Default: 0, Effect: core.EffectNewRun, Description: "单次回复的 max_tokens。0 = 不限制,由模型服务端决定"}, + {Key: "models.chat.contextWindow", Group: "models", Label: "主模型上下文窗口(token)", Type: core.SettingNumber, Default: 128000, Effect: core.EffectNewRun, Description: "模型真实窗口的 token 数。运行内清理旧工具结果的阈值由此换算(此前按 32000 字符硬编码,长材料任务第一请求即触发清理且被清内容找不回)"}, + {Key: "models.chat.outputReserve", Group: "models", Label: "窗口输出预留(token)", Type: core.SettingNumber, Default: 16384, Effect: core.EffectNewRun, Description: "清理阈值里为模型输出预留的 token 数"}, + {Key: "models.chat.contextBuffer", Group: "models", Label: "窗口余量(token)", Type: core.SettingNumber, Default: 4096, Effect: core.EffectNewRun, Description: "避免贴着上限才清理的余量"}, {Key: "models.lightweight.baseUrl", Group: "models", Label: "轻量模型 Base URL", Type: core.SettingString, Default: "", Effect: core.EffectHot, Description: "标题生成/澄清等轻任务;空则回落主模型"}, {Key: "models.lightweight.apiKey", Group: "models", Label: "轻量模型 API Key", Type: core.SettingSecret, Default: "", Effect: core.EffectHot}, {Key: "models.lightweight.model", Group: "models", Label: "轻量模型名称", Type: core.SettingString, Default: "", Effect: core.EffectNewRun}, @@ -102,6 +105,7 @@ func Schema() []core.SettingDesc { {Key: "context.maxToolResultChars", Group: "context", Label: "工具结果上限(字符)", Type: core.SettingNumber, Default: 20000, Effect: core.EffectNewRun}, {Key: "context.userInstructions", Group: "context", Label: "个人指令", Type: core.SettingString, Default: "", Effect: core.EffectNewRun, Description: "每次对话附在上下文末尾,不改系统前缀"}, {Key: "context.contextWindow", Group: "context", Label: "上下文窗口(估算 token)", Type: core.SettingNumber, Default: 32000, Effect: core.EffectNewRun, Description: "超出时只丢掉最早的历史。估算为 UTF-8 字节 / 3.35,并用上一请求的真实用量校准"}, + {Key: "context.thinkReplay", Group: "context", Label: "思考内容回传", Type: core.SettingEnum, Default: "auto", Options: []string{"auto", "on", "off"}, Effect: core.EffectNewRun, Description: "交错思考模型(如 MiniMax)要求历史 assistant 消息原样带 块,丢弃会掉性能。auto=按模型名识别 MiniMax 自动开启"}, {Key: "context.messageShape", Group: "context", Label: "消息整形", Type: core.SettingEnum, Default: "merge", Options: []string{"merge", "strict"}, Effect: core.EffectNewRun, Description: "merge 合并相邻同角色。strict 再把多余的 system 收成 user,满足严格交替"}, {Key: "crossSession.read", Group: "crossSession", Label: "跨会话读取", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun, Description: "允许本项目会话读取同项目其他会话的对话记录(list_conversations / read_conversation 工具);会话可单独关闭读取或设为私有"}, {Key: "retrieval.topK", Group: "retrieval", Label: "检索命中数", Type: core.SettingNumber, Default: 5, Effect: core.EffectHot},