Add deliverable-choice chain: confirm_choice tool, notice contract, backstops

Why: after material organization completes, weak models report and end the turn without offering the user a choice of formal deliverables, and even after a user picks one the model may close the turn without starting the background task.
Evidence: new tests (notice-choice chain, deliverable backstop, free-input path, invalid args, notice contract) pass; full go test ./... exit 0 (local, SELF).
Impact: new confirm_choice tool issues a HITL choice card and routes the answer into a hard delegate instruction; material notices instruct the model to report then offer choices; stop hook adds two one-shot backstops (choice not offered in material notice runs; choice made but no background delegation).
Refs: Dev task .task/feature-material-intake-flow.md I2.
This commit is contained in:
2026-10-11 16:53:53 +00:00
parent 02858f486b
commit 42867aabf4
6 changed files with 246 additions and 1 deletions
+29
View File
@@ -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")
}
}
+12
View File
@@ -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("</system-reminder>")
msg := core.Message{
ConversationID: convID, Role: core.RoleSystem,
+3
View File
@@ -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{
+90 -1
View File
@@ -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,
}
+101
View File
@@ -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: "<system-reminder>\n后台子代理「材料整理」已结束。\n状态:completed\n整理完成\n</system-reminder>"}}}}
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))
}
}
+11
View File
@@ -31,6 +31,17 @@ func systemCheckReason(body string) string {
// 与职责提醒;ensureMaterial 幂等保证只续跑一轮。
const materialBackstopReason = "<system-reminder>\n这是系统的终轮自动处理,不是用户发言。系统已自动启动材料整理后台任务(收件箱有进度卡「材料整理」,完成后结果自动回投并汇报)。请在当前回复里告知用户这一点(一两句话即可),并补充你现在就能给的即时反馈——每份材料一句话概览或初步判断。不要用 delegate_task 重复派发材料整理,也不要等待整理完成再回复。\n</system-reminder>"
// materialNoticeMarker 材料整理完成通知的稳定标记:runmgr 的通知正文自然
// 含此串;runtime 依此识别「本回合负有发起交付物选择卡的职责」。
const materialNoticeMarker = "「材料整理」已结束"
// deliverableBackstopReason 用户已选定交付物但后台任务未启动的硬性续跑指令。
const deliverableBackstopReason = "<system-reminder>\n这是系统的终轮自动检查,不是用户发言。用户已经在选择卡上做出决定,但对应的后台任务还没有启动。立即调用 delegate_task(background=true)启动它:任务描述基于材料整理结果定制,指明输出文件名与必须覆盖的要点;启动后用一句话向用户确认即可,不要等待完成,也不要再次询问用户。\n</system-reminder>"
// materialNoticeBackstopReason 材料整理通知回合未发起选择卡的应急指令
// (弱模型不发 confirm_choice 时兜底,一次性)。
const materialNoticeBackstopReason = "<system-reminder>\n这是系统的终轮自动检查,不是用户发言。材料整理已完成并汇报,但还没有让用户选择正式交付物。立即调用 confirm_choice 工具发起选择卡:问题「基于整理结果,接下来生成什么?」;选项从整理结果的「建议后续处理」取 3-4 项(description 写一句材料依据,最贴合的一项 recommended=true),允许自由输入;取不出建议时用默认选项:材料摘要、评审报告、需求文档、架构方案。发起后简短收尾即可。\n</system-reminder>"
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