Keep original materials and cleared results reachable across turns
ci / vet-and-test (pull_request) Successful in 48s
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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,4 +12,4 @@ description: 动态注入的当前环境事实(占位符随运行填充)
|
||||
- 本轮附带文档:{{attachments}}。文档全文已注入上下文(超长时会标注截断)。
|
||||
- 交付物默认样式:{{deliverableStyle}}。本轮的明确指令、上传的模板或参考文档优先于该默认值。
|
||||
- 技能目录:{{skillsDir}}。环境变量 AGENT_SKILLS_DIR 指向这里;Python 已能 `import housestyle`。
|
||||
- 本轮原件(工作区相对路径):{{inputs}}。模板和参考文档用这些路径,不要向用户索取已经上传的文件。
|
||||
- 会话工作区文件(相对路径,跨轮有效):{{inputs}}。原件旁的 <原名>.md 是解析出的纯文本,需要原始信息时优先读它(不要只依赖你上一轮生成的产物,也不要向用户索取已经上传的文件)。模板和参考文档用这些路径。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)。
|
||||
|
||||
@@ -88,4 +88,3 @@ func TestPermissionAskRoutesToHITL(t *testing.T) {
|
||||
t.Fatalf("no verdict = %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+12
-10
@@ -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
|
||||
|
||||
@@ -138,7 +138,6 @@ func TestSpawnBackgroundRecordsDetach(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// N3 WP1:done.json 语义分流——优雅停机写的 interrupted 不挡续跑(一次且仅一次);
|
||||
// completed 终态挡续跑。
|
||||
func TestResumeAfterGracefulInterruptMarker(t *testing.T) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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},
|
||||
|
||||
Reference in New Issue
Block a user