diff --git a/internal/runmgr/material_test.go b/internal/runmgr/material_test.go index be21e08..b937637 100644 --- a/internal/runmgr/material_test.go +++ b/internal/runmgr/material_test.go @@ -178,3 +178,32 @@ func TestMaterialKeyStableAcrossOrder(t *testing.T) { t.Fatal("key should change with content") } } + +// 通知契约:材料整理完成的通知正文含交付物选择指令(confirm_choice); +// 普通后台任务的通知不含。 +func TestMaterialNoticeContract(t *testing.T) { + c, _ := newCardSpawnEnv(t, &captureRunner{parts: []core.Part{{Type: core.PartText, Text: "整理完成:要点已汇总。"}}}) + c.mgr.runner = noopRunner{} + c.attachments = materialDocs() + if note := c.mgr.materialIntake(c); !strings.Contains(note, "系统已自动启动材料整理后台任务") { + t.Fatalf("intake note = %q", note) + } + waitUntil(t, func() bool { return len(systemTexts(t, c)) >= 1 }) + joined := strings.Join(systemTexts(t, c), "\n") + if !strings.Contains(joined, "「材料整理」已结束") { + t.Fatalf("notice text = %q", joined) + } + if !strings.Contains(joined, "confirm_choice") { + t.Fatal("deliverable-choice instruction missing from material notice") + } + + plain, _ := newCardSpawnEnv(t, &captureRunner{parts: []core.Part{{Type: core.PartText, Text: "完成"}}}) + plain.mgr.runner = noopRunner{} + if _, err := plain.SpawnBackground("agent.sub.general", "普通任务", "做点事", ""); err != nil { + t.Fatal(err) + } + waitUntil(t, func() bool { return len(systemTexts(t, plain)) >= 1 }) + if got := strings.Join(systemTexts(t, plain), "\n"); strings.Contains(got, "confirm_choice") { + t.Fatal("plain background notice must not carry the choice instruction") + } +} diff --git a/internal/runmgr/runmgr.go b/internal/runmgr/runmgr.go index d63d9f6..f037255 100644 --- a/internal/runmgr/runmgr.go +++ b/internal/runmgr/runmgr.go @@ -2044,6 +2044,18 @@ func (m *Manager) startNoticeRun(convID string, items []bgNotice) error { for _, n := range items { fmt.Fprintf(&b, "后台子代理「%s」已结束。\n状态:%s\n%s\n", n.name, n.payload.Status, strings.TrimSpace(n.payload.Summary)) } + // 材料整理完成 → 交付物选择职责(I2):汇报要点后立即发起待确认选择卡。 + // runtime 侧对不发 confirm_choice 的弱模型另有 stop 兜底(一次性)。 + materialSeen := false + for _, n := range items { + if n.material { + materialSeen = true + break + } + } + if materialSeen { + b.WriteString("\n这批材料的系统整理已完成。处理方式:\n1) 先用不超过 5 行汇报整理结果要点(来自上面的摘要)。\n2) 然后立即调用 confirm_choice 工具发起交付物选择卡:问题如「基于整理结果,接下来生成什么?」;选项取整理结果中的「建议后续处理」(3-4 项,description 写一句材料依据,最贴合的一项设 recommended),允许自由输入。\n3) 用户答复后按工具结果指引,用 delegate_task 的 background=true 启动对应后台任务。\n不要重复派发材料整理任务。\n") + } b.WriteString("") msg := core.Message{ ConversationID: convID, Role: core.RoleSystem, diff --git a/internal/runtime/llm.go b/internal/runtime/llm.go index 4c7e069..d06dae1 100644 --- a/internal/runtime/llm.go +++ b/internal/runtime/llm.go @@ -67,6 +67,8 @@ const ( toolCardBegin = "card_begin" toolCardAppend = "card_append" toolCardCommit = "card_commit" + // confirm_choice 发起待确认选择卡(材料整理完成后选交付物;I2)。 + toolConfirmChoice = "confirm_choice" ) // resolveSystem 经提示词库组装 system 段(基座+环境+命中技能),并产出可观测条目 @@ -312,6 +314,7 @@ func (l *LLMRunner) Execute(rc RunCtx) error { agentllm.ToolDecl{Name: toolReview, Description: l.toolDesc(prof.Owner, toolReview, "对一段材料做评审:多个审查子代理并行找问题,再逐条独立验证,只保留验证通过的问题"), Parameters: json.RawMessage(`{"type":"object","properties":{"target":{"type":"string","description":"要评审的文本或工件"},"focus":{"type":"string","description":"评审重点"},"reviewers":{"type":"integer","minimum":1,"maximum":3,"description":"并行审查人数,1 到 3,缺省 1"}},"required":["target"]}`)}, agentllm.ToolDecl{Name: toolPlan, Description: l.toolDesc(prof.Owner, toolPlan, "创建或更新当前会话的计划(步骤列表)"), Parameters: json.RawMessage(`{"type":"object","properties":{"title":{"type":"string"},"steps":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"text":{"type":"string"},"status":{"type":"string","enum":["pending","in_progress","done","skipped"]}},"required":["id","text","status"]}}},"required":["title","steps"]}`)}, agentllm.ToolDecl{Name: toolTodos, Description: l.toolDesc(prof.Owner, toolTodos, "更新当前会话的待办清单"), Parameters: json.RawMessage(`{"type":"object","properties":{"items":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"text":{"type":"string"},"status":{"type":"string","enum":["pending","in_progress","done"]}},"required":["id","text","status"]}}},"required":["items"]}`)}, + agentllm.ToolDecl{Name: toolConfirmChoice, Description: l.toolDesc(prof.Owner, toolConfirmChoice, "向用户发起待确认选择卡并等待答复:3-4 个选项(各附一句依据与影响)+ 可选自由输入。材料整理完成后选正式交付物、或任何需要用户拍板的分叉都用它,不要在文本里罗列选项后干等"), Parameters: json.RawMessage(`{"type":"object","properties":{"question":{"type":"string","description":"要用户决定的问题,一句话"},"options":{"type":"array","minItems":2,"maxItems":4,"items":{"type":"object","properties":{"id":{"type":"string"},"label":{"type":"string","description":"选项名,如:会议摘要"},"description":{"type":"string","description":"一句依据(来自材料或上下文)"},"impact":{"type":"string","description":"选择后的产出与耗时影响"},"recommended":{"type":"boolean","description":"最贴合的一项设 true"}},"required":["id","label","description","impact"]}},"free_input":{"type":"boolean","description":"允许自由输入其他要求,默认 true"},"timeout_sec":{"type":"integer","description":"等待上限秒数,0=不限"}},"required":["question","options"]}`)}, ) if eff.GetString("runtime.entry", "direct") != "squad" { decls = append(decls, agentllm.ToolDecl{ diff --git a/internal/runtime/llm_direct.go b/internal/runtime/llm_direct.go index d7395bc..aa40273 100644 --- a/internal/runtime/llm_direct.go +++ b/internal/runtime/llm_direct.go @@ -86,6 +86,15 @@ type directRun struct { replied bool delegatedBackground bool materialChecked bool // 材料兜底只问一次(runCtx 侧另有幂等,双保险) + // 交付物确认链(I2):confirm_choice 发起过选择卡;用户答复后 + // pendingDeliverable 非空——回合结束仍未委派则硬性续跑一轮。 + confirmIssued bool + pendingDeliverable string + deliverableNudged bool + // 材料整理通知回合(历史含「材料整理」完成标记):结束仍无选择卡且未委派 + // 时,硬性要求发起 confirm_choice(弱模型应急,一次性)。 + materialNotice bool + materialNoticeNudged bool } type toolEvidence struct { @@ -134,6 +143,24 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin st.pinSubagentArtifacts(text) } st.servedModel = model + // 材料整理通知回合识别(I2):历史含「材料整理」完成标记的系统提醒—— + // 该回合负有发起交付物选择卡的职责,stop 钩子据此兜底。扫会话历史而非 + // 组装后的 msgs:系统提示词块会撑大 msgs,窗口不稳定。 + histMsgs := rc.History() + for i := len(histMsgs) - 1; i >= 0 && i >= len(histMsgs)-8; i-- { + if histMsgs[i].Role != core.RoleSystem { + continue + } + for _, p := range histMsgs[i].Parts { + if p.Type == core.PartText && strings.Contains(p.Text, materialNoticeMarker) { + st.materialNotice = true + break + } + } + if st.materialNotice { + break + } + } stream = agentllm.WithFallback(stream, ChatFallbacks(eff), st.noteModelSwitch) defer func() { if sink, ok := rc.(interface{ AddUsage(core.Usage) }); ok { @@ -601,6 +628,18 @@ func (st *directRun) stop(last agentllm.Message, info nruntime.StopInfo) nruntim } } } + // 交付物确认链(I2 混合兜底):用户已在选择卡拍板,但回合结束仍未委派 + // 后台任务 → 硬性续跑一轮要求启动(一次性)。 + if st.pendingDeliverable != "" && !st.delegatedBackground && !st.deliverableNudged { + st.deliverableNudged = true + return nruntime.StopDecision{Continue: true, Reason: deliverableBackstopReason} + } + // 材料通知回合应急(弱模型不发 confirm_choice):汇报了却没让用户选 + // 交付物、也没委派任何后台 → 硬性要求发起选择卡(一次性)。 + if st.materialNotice && !st.confirmIssued && !st.delegatedBackground && !st.materialNoticeNudged { + st.materialNoticeNudged = true + return nruntime.StopDecision{Continue: true, Reason: materialNoticeBackstopReason} + } if block, why := deliverableGateOf(st.turnBody, st.deliverables, st.rc); block { st.demoteLastText() if st.eff.GetBool("runtime.verify", false) { @@ -826,6 +865,56 @@ func (st *directRun) runToolCtx(ctx context.Context, name, args string) agenttoo out = orDefault(res.Text, orDefault(res.Summary, "子代理完成")) st.pinSubagentArtifacts(res.Summary + "\n" + res.Text) } + case toolConfirmChoice: + var a struct { + Question string `json:"question"` + Options []struct { + ID, Label, Description, Impact string + Recommended bool + } `json:"options"` + FreeInput *bool `json:"free_input"` + TimeoutSec int `json:"timeout_sec"` + } + if uerr := json.Unmarshal([]byte(call.Arguments), &a); uerr != nil || strings.TrimSpace(a.Question) == "" || len(a.Options) < 2 { + out = "confirm_choice 参数无效:需要 question 与 2-4 个选项(id/label/description/impact)" + break + } + card := core.HITLCard{Kind: core.HITLKindChoice, Question: strings.TrimSpace(a.Question), AllowFreeInput: a.FreeInput == nil || *a.FreeInput} + for _, o := range a.Options { + choice := core.HITLChoice{ID: strings.TrimSpace(o.ID), Label: strings.TrimSpace(o.Label), Description: o.Description, Impact: o.Impact, Recommended: o.Recommended} + if choice.ID == "" { + choice.ID = choice.Label + } + if strings.TrimSpace(choice.Impact) == "" { + choice.Impact = "按选项说明执行" + } + card.Choices = append(card.Choices, choice) + } + if a.TimeoutSec > 0 { + secs := a.TimeoutSec + card.TimeoutSec = &secs + } + st.confirmIssued = true + resp, herr := st.rc.AskHITL(card) + if herr != nil { + out = "选择等待结束(未获得答复):" + herr.Error() + "。请在回复里列出可选项,请用户直接答复。" + break + } + if id := strings.TrimSpace(resp.ChoiceID); id != "" { + label := id + for _, c := range card.Choices { + if c.ID == id { + label = c.Label + } + } + st.pendingDeliverable = label + out = "用户已选择:" + label + "。立即用 delegate_task 的 background=true 启动对应后台任务(任务描述基于材料整理结果定制,指明输出文件名与必须覆盖的要点),启动后给用户一句确认反馈即可,不要等待完成。" + } else if free := strings.TrimSpace(resp.FreeText); free != "" { + st.pendingDeliverable = free + out = "用户自由输入:" + free + "。若这是正式交付物要求,立即用 delegate_task 的 background=true 启动对应后台任务(描述结合材料整理结果);若是澄清或追问,先如实答复,再按需推进。" + } else { + out = "用户未给出明确答复。请在回复里列出可选项,请用户直接答复。" + } case toolReview: out = st.reviewFindings(call.Arguments) case toolPlan: @@ -1098,7 +1187,7 @@ func errString(err error) string { var serialTools = map[string]bool{ toolPlan: true, toolTodos: true, toolCard: true, toolCardBegin: true, toolCardAppend: true, toolCardCommit: true, - toolRunWorkflow: true, toolSquad: true, + toolRunWorkflow: true, toolSquad: true, toolConfirmChoice: true, "run_shell": true, "run_python": true, "register_artifact": true, "edit": true, "multi_edit": true, } diff --git a/internal/runtime/material_flow_test.go b/internal/runtime/material_flow_test.go index 88a6c59..0d03a9d 100644 --- a/internal/runtime/material_flow_test.go +++ b/internal/runtime/material_flow_test.go @@ -16,6 +16,10 @@ type matCtx struct { note string ensureN int lastNote string + // confirm_choice 断言(I2) + hitls []core.HITLCard + hitlFree string + hitlErr error } func (m *matCtx) MaterialNote() string { return m.note } @@ -28,6 +32,21 @@ func (m *matCtx) EnsureMaterial() string { return m.lastNote } +// AskHITL 覆盖:预置选择默认取第一个选项;hitlFree 模拟自由输入。 +func (m *matCtx) AskHITL(card core.HITLCard) (core.HITLResponse, error) { + m.hitls = append(m.hitls, card) + if m.hitlErr != nil { + return core.HITLResponse{}, m.hitlErr + } + if m.hitlFree != "" { + return core.HITLResponse{FreeText: m.hitlFree}, nil + } + if len(card.Choices) == 0 { + return core.HITLResponse{}, nil + } + return core.HITLResponse{ChoiceID: card.Choices[0].ID}, nil +} + func matAttachments() []core.ParsedDocument { return []core.ParsedDocument{ {Meta: core.DocMeta{Filename: "纪要.docx"}, Blocks: []core.DocBlock{{Type: core.BlockPara, Text: "决定 11 月上线"}}}, @@ -119,3 +138,85 @@ func TestStopMaterialBackstopSkippedWhenDelegated(t *testing.T) { t.Fatal("backstop should not fire when model delegated") } } + +func hitlTurn(call agentllm.ToolCall) []agentllm.Event { return textDeltaTurn("", call) } + +// 完整确认链:材料通知回合 → 汇报但没发选择卡(应急兜底)→ 发起 confirm_choice +// → 用户选择 → 模型委派后台 → 通过(无进一步拦截)。 +func TestMaterialNoticeChoiceChain(t *testing.T) { + rc := &matCtx{fakeRunCtx: &fakeRunCtx{ctx: context.Background(), prof: verifyOn(), bgID: "run_bg_d"}} + rc.history = []core.Message{{Role: core.RoleSystem, Parts: []core.Part{{Type: core.PartText, Text: "\n后台子代理「材料整理」已结束。\n状态:completed\n整理完成\n"}}}} + turns := [][]agentllm.Event{ + textDeltaTurn("整理完成:三份材料要点如下……"), // 未发选择卡 → 应急兜底 + hitlTurn(toolCall("confirm_choice", `{"question":"接下来生成什么?","options":[{"id":"a","label":"需求文档","description":"会议含明确需求项","impact":"产出需求文档"},{"id":"b","label":"会议摘要","description":"快速回顾","impact":"产出摘要"}]}`)), // AskHITL → 用户选第一个 + textDeltaTurn("好的,启动需求文档任务……", toolCall("delegate_task", `{"name":"生成需求文档","task":"基于整理结果生成需求文档","background":true}`)), + textDeltaTurn("已在后台启动需求文档,完成后自动汇报。"), + } + rec := runMaterial(t, turns, rc) + if requestsContaining(rec, "还没有让用户选择正式交付物") == 0 { + t.Fatal("notice backstop (confirm_choice nudge) missing") + } + if len(rc.hitls) != 1 || rc.hitls[0].Kind != core.HITLKindChoice { + t.Fatalf("hitl cards = %d", len(rc.hitls)) + } + if requestsContaining(rec, "用户已选择:需求文档") == 0 { + t.Fatal("choice answer not surfaced to model") + } + if requestsContaining(rec, "还没有启动") != 0 { + t.Fatal("deliverable backstop should not fire after model delegated") + } + if rc.ensureN != 0 { + t.Fatal("material backstop must not run in notice turns (no attachments)") + } +} + +// 用户已选定但模型未委派 → 硬性续跑一轮要求启动;第二轮委派后放行。 +func TestDeliverableBackstopForcesDelegation(t *testing.T) { + rc := &matCtx{fakeRunCtx: &fakeRunCtx{ctx: context.Background(), prof: verifyOn(), bgID: "run_bg_e"}} + turns := [][]agentllm.Event{ + hitlTurn(toolCall("confirm_choice", `{"question":"生成什么?","options":[{"id":"a","label":"会议摘要","description":"快","impact":"摘要"},{"id":"b","label":"评审报告","description":"全","impact":"报告"}]}`)), + textDeltaTurn("好的,马上生成会议摘要。"), // 未委派 → 兜底 + textDeltaTurn("这就启动。", toolCall("delegate_task", `{"name":"生成会议摘要","task":"生成摘要","background":true}`)), + textDeltaTurn("已在后台生成会议摘要,稍后回投。"), + } + rec := runMaterial(t, turns, rc) + // 只触发一次由脚本回合数锁死:再续跑一次会多一次模型调用,脚本耗尽报错。 + if requestsContaining(rec, "还没有启动") == 0 { + t.Fatal("deliverable backstop missing") + } + if requestsContaining(rec, "用户已选择:会议摘要") == 0 { + t.Fatal("choice answer not surfaced to model") + } +} + +// 自由输入也应回到同一回合并进入待交付状态。 +func TestConfirmChoiceFreeTextSetsPending(t *testing.T) { + rc := &matCtx{fakeRunCtx: &fakeRunCtx{ctx: context.Background(), prof: verifyOn()}, hitlFree: "做成对比表格"} + turns := [][]agentllm.Event{ + hitlTurn(toolCall("confirm_choice", `{"question":"生成什么?","options":[{"id":"a","label":"摘要","description":"快","impact":"摘要"},{"id":"b","label":"报告","description":"全","impact":"报告"}]}`)), + textDeltaTurn("收到,将输出对比表格。", toolCall("delegate_task", `{"name":"对比表格","task":"输出对比表格","background":true}`)), + textDeltaTurn("已在后台生成对比表格。"), + } + rec := runMaterial(t, turns, rc) + if requestsContaining(rec, "用户自由输入:做成对比表格") == 0 { + t.Fatal("free input not surfaced to model") + } + if requestsContaining(rec, "还没有启动") != 0 { + t.Fatal("no backstop expected when model delegated after free input") + } +} + +// 参数无效(选项不足)不建卡,直接给模型纠错说明。 +func TestConfirmChoiceRejectsInvalidArgs(t *testing.T) { + rc := &matCtx{fakeRunCtx: &fakeRunCtx{ctx: context.Background(), prof: verifyOn()}} + turns := [][]agentllm.Event{ + hitlTurn(toolCall("confirm_choice", `{"question":"选什么?"}`)), + textDeltaTurn("改用两个选项重试。", toolCall("confirm_choice", `{"question":"选什么?","options":[{"id":"a","label":"摘要","description":"快","impact":"摘要"},{"id":"b","label":"报告","description":"全","impact":"报告"}]}`)), + textDeltaTurn("这就启动摘要。", toolCall("delegate_task", `{"name":"生成摘要","task":"生成摘要","background":true}`)), + textDeltaTurn("已在后台生成摘要。"), + } + runMaterial(t, turns, rc) + if len(rc.hitls) != 1 { + t.Fatalf("invalid args should not create card; hitls = %d", len(rc.hitls)) + } +} diff --git a/internal/runtime/verify.go b/internal/runtime/verify.go index a55a782..836db62 100644 --- a/internal/runtime/verify.go +++ b/internal/runtime/verify.go @@ -31,6 +31,17 @@ func systemCheckReason(body string) string { // 与职责提醒;ensureMaterial 幂等保证只续跑一轮。 const materialBackstopReason = "\n这是系统的终轮自动处理,不是用户发言。系统已自动启动材料整理后台任务(收件箱有进度卡「材料整理」,完成后结果自动回投并汇报)。请在当前回复里告知用户这一点(一两句话即可),并补充你现在就能给的即时反馈——每份材料一句话概览或初步判断。不要用 delegate_task 重复派发材料整理,也不要等待整理完成再回复。\n" +// materialNoticeMarker 材料整理完成通知的稳定标记:runmgr 的通知正文自然 +// 含此串;runtime 依此识别「本回合负有发起交付物选择卡的职责」。 +const materialNoticeMarker = "「材料整理」已结束" + +// deliverableBackstopReason 用户已选定交付物但后台任务未启动的硬性续跑指令。 +const deliverableBackstopReason = "\n这是系统的终轮自动检查,不是用户发言。用户已经在选择卡上做出决定,但对应的后台任务还没有启动。立即调用 delegate_task(background=true)启动它:任务描述基于材料整理结果定制,指明输出文件名与必须覆盖的要点;启动后用一句话向用户确认即可,不要等待完成,也不要再次询问用户。\n" + +// materialNoticeBackstopReason 材料整理通知回合未发起选择卡的应急指令 +// (弱模型不发 confirm_choice 时兜底,一次性)。 +const materialNoticeBackstopReason = "\n这是系统的终轮自动检查,不是用户发言。材料整理已完成并汇报,但还没有让用户选择正式交付物。立即调用 confirm_choice 工具发起选择卡:问题「基于整理结果,接下来生成什么?」;选项从整理结果的「建议后续处理」取 3-4 项(description 写一句材料依据,最贴合的一项 recommended=true),允许自由输入;取不出建议时用默认选项:材料摘要、评审报告、需求文档、架构方案。发起后简短收尾即可。\n" + func (l *LLMRunner) runVerifier(rc RunCtx, body string, evidence []toolEvidence, refs []*core.ArtifactRef) (gates.Verdict, error) { route := ResolveLightweightModel(rc.Profile(), rc.Settings()) stream := l.VerifyStream