Keep original materials and cleared results reachable across turns
ci / vet-and-test (pull_request) Successful in 48s

Why: Real-run before/after evidence (phase01 card) showed later turns never
re-read the uploaded source (0 original reads in plan-followup) because the
attachment only existed in the current turn's injection and the context window
hard default was 32000 chars - a 25k-char transcript tripped the clear-on-first
request, replacing old tool results with a pointer the model has no tool to
follow. MiniMax is an interleaved-thinking model whose docs require <think>
blocks in assistant history; dropping them degrades later turns.

Evidence: SELF - new tests TestThinkReplayWrapsAssistantHistory,
TestContextWindowScalesFromTokens, TestThinkReplayEnabled,
TestPersistInputTextsAndListWorkspaceInputs; go test ./internal/... passes;
live before/after comparison on the golden task set recorded on the phase02
task card per the new policy clause.

Impact: models.chat.contextWindow/outputReserve/contextBuffer (tokens,
defaults 128k/16k/4k) drive the in-run clear threshold, converted to chars;
cleared tool results spill to <sandbox>/.agent/results with readable paths;
attachment parse text persists as inputs/<name>.md and the env section lists
all workspace inputs every turn; context.thinkReplay (auto|on|off, auto =
MiniMax) replays thinking blocks into request-side assistant history only.
This commit is contained in:
2026-10-09 14:28:39 +00:00
parent b68833f320
commit 43297795f5
22 changed files with 323 additions and 58 deletions
+23 -3
View File
@@ -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 消息的思考部件以 <think> 块原样回传
//(交错思考模型如 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 原样带 <think>;只进请求,不落盘。
content = "<think>\n" + thinkText + "\n</think>\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
+10 -10
View File
@@ -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)
}
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
}
+1 -1
View File
@@ -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
+43
View File
@@ -0,0 +1,43 @@
package ctxasm
import (
"strings"
"testing"
agentllm "agent-native/runtime/llm"
core "agent-system-core"
)
// 交错思考模型(MiniMax 等)要求历史 assistant 原样回传 <think>;
// 关闭时不回传(默认兼容旧行为)。思考块只进请求,不落库。
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, "<think>") {
sawThink++
if !strings.Contains(m.Content, "</think>") || !strings.Contains(m.Content, "先读材料。") {
t.Fatalf("think block malformed: %q", m.Content)
}
}
}
if sawThink == 0 {
t.Fatalf("ThinkReplay=on should inject <think> 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, "<think>") {
t.Fatalf("ThinkReplay=off must not inject think blocks: %q", m.Content)
}
}
}
+1 -1
View File
@@ -12,4 +12,4 @@ description: 动态注入的当前环境事实(占位符随运行填充)
- 本轮附带文档:{{attachments}}。文档全文已注入上下文(超长时会标注截断)。
- 交付物默认样式:{{deliverableStyle}}。本轮的明确指令、上传的模板或参考文档优先于该默认值。
- 技能目录:{{skillsDir}}。环境变量 AGENT_SKILLS_DIR 指向这里;Python 已能 `import housestyle`。
- 本轮原件(工作区相对路径):{{inputs}}。模板和参考文档用这些路径,不要向用户索取已经上传的文件。
- 会话工作区文件(相对路径,跨轮有效):{{inputs}}。原件旁的 <原名>.md 是解析出的纯文本,需要原始信息时优先读它(不要只依赖你上一轮生成的产物,也不要向用户索取已经上传的文件)。模板和参考文档用这些路径。
+55
View File
@@ -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
+65
View File
@@ -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)
}
}
+1 -1
View File
@@ -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)。
-1
View File
@@ -88,4 +88,3 @@ func TestPermissionAskRoutesToHITL(t *testing.T) {
t.Fatalf("no verdict = %+v", v)
}
}
+12 -10
View File
@@ -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
-1
View File
@@ -138,7 +138,6 @@ func TestSpawnBackgroundRecordsDetach(t *testing.T) {
}
}
// N3 WP1:done.json 语义分流——优雅停机写的 interrupted 不挡续跑(一次且仅一次);
// completed 终态挡续跑。
func TestResumeAfterGracefulInterruptMarker(t *testing.T) {
+1 -1
View File
@@ -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 {
+51
View File
@@ -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")
}
}
+27 -5
View File
@@ -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 消息是否原样回传 <think> 块。
// 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) {
+19 -12
View File
@@ -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,
+1 -1
View File
@@ -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"},
+2 -2
View File
@@ -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
}
+5 -5
View File
@@ -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)
}
@@ -164,8 +164,6 @@ func (f *wfFake) WorkflowByID(id string) (core.WorkflowDef, bool) {
return def, ok
}
// ---- run_workflow 注册分支:查找与错误面(legacy 同款文案) ----
func TestRunWorkflowToolUsesRegisteredDefinition(t *testing.T) {
+4
View File
@@ -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 消息原样带 <think> 块,丢弃会掉性能。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},