From 9a563df970d782e92cc4569e9159936b89379cc4 Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 1 Oct 2026 16:11:51 +0000 Subject: [PATCH] Confirm model-planned workflows before they run Why: A goal should become steps only after the same limits as a declarative workflow, and only after the user accepts the plan. Evidence: go test ./internal/runtime/ ./internal/prompts/ passed. Over-limit and script steps are rejected before confirmation. A no choice does not execute. Impact: The planner profile is agent.sub.workflow-plan. run_workflow stays off subagent tool lists. Refs: feature/agent-harness-p4 Co-authored-by: Cursor --- .../library/agent-sub-workflow-plan.md | 29 +++++++ internal/prompts/prompts_test.go | 1 + internal/runtime/llm_direct.go | 17 ++++- internal/runtime/workflow.go | 75 +++++++++++++++++++ internal/runtime/workflow_exec_test.go | 59 ++++++++++++++- 5 files changed, 177 insertions(+), 4 deletions(-) create mode 100644 internal/prompts/library/agent-sub-workflow-plan.md diff --git a/internal/prompts/library/agent-sub-workflow-plan.md b/internal/prompts/library/agent-sub-workflow-plan.md new file mode 100644 index 0000000..2b6c31b --- /dev/null +++ b/internal/prompts/library/agent-sub-workflow-plan.md @@ -0,0 +1,29 @@ +--- +id: agent.sub.workflow-plan +layer: agent +version: 1 +title: 工作流规划子代理 +description: 把目标写成可校验的工作流 JSON,不执行步骤 +tools: knowledge_search, time_now +model: lightweight +maxTurns: 4 +--- +你是工作流规划子代理。只输出一个 JSON 对象,不要解释,不要写脚本。 + +对象字段: +- id:短标识,形如 plan.brief +- title:给人看的标题 +- steps:数组,最多 8 步 + +每步字段: +- id:步内唯一 +- title:这一步做什么 +- kind:只能是 prompt、tool、hitl、artifact、card +- prompt:prompt 或 artifact 步骤的文本,可用 {{input}}、{{slots.名称}}、{{steps.步骤id.output}} +- agent:prompt 步骤的子代理,缺省 general,只能是 general、explore、research、plan、verify、writer +- tool 与 args:tool 步骤。不能使用 run_python、run_shell、run_workflow、delegate_task、squad_orchestrate、review_findings +- hitl:hitl 步骤的提问,含 question、kind、choices +- artifact:artifact 步骤的文件名 +- card:card 步骤的 kind 与 title + +禁止 script 步骤。拿不准的步骤用 hitl 问用户。 diff --git a/internal/prompts/prompts_test.go b/internal/prompts/prompts_test.go index 1cc90af..e5d7db7 100644 --- a/internal/prompts/prompts_test.go +++ b/internal/prompts/prompts_test.go @@ -442,6 +442,7 @@ func TestSubagentOverrideRejected(t *testing.T) { {ID: "agent.sub.explore", Layer: core.LayerAgent, Title: "探索", Body: "正文", Tools: []string{"delegate_task"}}, {ID: "agent.sub.explore", Layer: core.LayerAgent, Title: "探索", Body: "正文", Tools: []string{"squad_orchestrate"}}, {ID: "agent.sub.explore", Layer: core.LayerAgent, Title: "探索", Body: "正文", Tools: []string{"review_findings"}}, + {ID: "agent.sub.explore", Layer: core.LayerAgent, Title: "探索", Body: "正文", Tools: []string{"run_workflow"}}, } for _, m := range cases { if err := svc.Save("alice", m); err == nil { diff --git a/internal/runtime/llm_direct.go b/internal/runtime/llm_direct.go index c38dd97..e3875f3 100644 --- a/internal/runtime/llm_direct.go +++ b/internal/runtime/llm_direct.go @@ -474,7 +474,10 @@ func (st *directRun) runRegisteredWorkflow(args string) string { if n != 1 { return "run_workflow 需要且只能指定 workflow、goal、script 之一" } - if strings.TrimSpace(a.Goal) != "" || strings.TrimSpace(a.Script) != "" { + if strings.TrimSpace(a.Goal) != "" { + return st.runPlannedWorkflow(strings.TrimSpace(a.Goal)) + } + if strings.TrimSpace(a.Script) != "" { return "这种启动方式还没接上" } lookup, ok := st.rc.(interface { @@ -498,6 +501,18 @@ func (st *directRun) runRegisteredWorkflow(args string) string { return res.Summary } +func (st *directRun) runPlannedWorkflow(goal string) string { + res, err := planWorkflow(st.rc, goal, WorkflowRunner{}) + st.pinSubagentArtifacts(res.Summary) + if err != nil { + if res.Summary != "" { + return res.Summary + } + return err.Error() + } + return res.Summary +} + func (st *directRun) submitCard(args string) string { if !st.cardflow { return "submit_card 未启用(设置 ui.cardflow 关闭);内容直接写在对话回复里" diff --git a/internal/runtime/workflow.go b/internal/runtime/workflow.go index 78aac9d..69f006b 100644 --- a/internal/runtime/workflow.go +++ b/internal/runtime/workflow.go @@ -1,6 +1,7 @@ package runtime import ( + "encoding/json" "fmt" "strings" @@ -9,6 +10,8 @@ import ( core "agent-system-core" ) +const workflowPlannerID = "agent.sub.workflow-plan" + // WorkflowRunner 执行启动时拷贝下来的工作流定义。 type WorkflowRunner struct { // JudgeStream 注入验收模型。nil 时按轻量模型构造客户端。 @@ -27,6 +30,78 @@ type workflowOutcome struct { Artifacts []*core.ArtifactRef } +// planWorkflow 让规划子代理写出步骤,校验通过后必须经用户确认才执行。 +func planWorkflow(rc RunCtx, goal string, runner WorkflowRunner) (workflowOutcome, error) { + sp, ok := rc.(interface { + SpawnAgent(agentID, name, task, hint string) (core.AgentResultPayload, error) + }) + if !ok { + return workflowOutcome{}, fmt.Errorf("workflow: 当前运行不能规划") + } + res, err := sp.SpawnAgent(workflowPlannerID, "规划", goal, "") + if err != nil { + return workflowOutcome{}, err + } + text := res.Text + if text == "" { + text = res.Summary + } + def, err := parseWorkflowDef(text) + if err != nil { + return workflowOutcome{}, err + } + known := map[string]bool{} + for _, tool := range rc.Tools() { + known[tool.Name] = true + } + if err := workflow.Validate(def, workflow.DynamicLimits(), func(name string) bool { return known[name] }); err != nil { + return workflowOutcome{}, err + } + resp, err := rc.AskHITL(core.HITLCard{ + Question: planPreview(def), Kind: core.HITLKindConfirm, AllowFreeInput: false, + Choices: []core.HITLChoice{ + {ID: "yes", Label: "执行", Description: "按这些步骤做", Impact: "会调用工具并可能写入工件"}, + {ID: "no", Label: "取消", Description: "不执行", Impact: "不会改动任何东西"}, + }, + }) + if err != nil { + return workflowOutcome{}, err + } + if resp.ChoiceID != "yes" { + return workflowOutcome{Summary: "未确认,未执行"}, nil + } + return runner.run(rc, workflow.Launch{Def: def}) +} + +func parseWorkflowDef(raw string) (core.WorkflowDef, error) { + s := stripFence(raw) + start := strings.Index(s, "{") + end := strings.LastIndex(s, "}") + if start < 0 || end < start { + return core.WorkflowDef{}, fmt.Errorf("workflow: 规划结果无法解析") + } + var def core.WorkflowDef + if err := json.Unmarshal([]byte(s[start:end+1]), &def); err != nil { + return core.WorkflowDef{}, fmt.Errorf("workflow: 规划结果无法解析") + } + if strings.TrimSpace(def.ID) == "" { + def.ID = "dynamic" + } + if strings.TrimSpace(def.Title) == "" { + def.Title = "临时工作流" + } + return def, nil +} + +func planPreview(def core.WorkflowDef) string { + var b strings.Builder + fmt.Fprintf(&b, "按这个计划执行「%s」?\n", def.Title) + for i, step := range def.Steps { + fmt.Fprintf(&b, "%d. %s(%s)\n", i+1, step.Title, step.Kind) + } + return strings.TrimRight(b.String(), "\n") +} + func (w WorkflowRunner) Execute(rc RunCtx) error { src, ok := rc.(workflowSource) if !ok { diff --git a/internal/runtime/workflow_exec_test.go b/internal/runtime/workflow_exec_test.go index b75138a..be6b325 100644 --- a/internal/runtime/workflow_exec_test.go +++ b/internal/runtime/workflow_exec_test.go @@ -2,6 +2,7 @@ package runtime import ( "context" + "fmt" "strings" "testing" @@ -22,6 +23,9 @@ type wfFake struct { assistant []core.Part arts map[string]core.Artifact byID map[string]core.WorkflowDef + spawnText string + spawnIDs []string + toolNames []string } func (f *wfFake) Context() context.Context { @@ -52,7 +56,12 @@ func (f *wfFake) SpawnSubagent(name, task, hint string) (core.AgentResultPayload } func (f *wfFake) SpawnAgent(agentID, name, task, hint string) (core.AgentResultPayload, error) { f.prompts = append(f.prompts, agentID+"|"+task) - return core.AgentResultPayload{Text: "正文 art_0123456789ab", Status: core.RunCompleted}, nil + f.spawnIDs = append(f.spawnIDs, agentID) + text := f.spawnText + if text == "" { + text = "正文 art_0123456789ab" + } + return core.AgentResultPayload{Text: text, Status: core.RunCompleted}, nil } func (f *wfFake) UpdatePlan(p core.PlanSnapshot) error { p.Steps = append([]core.PlanStep(nil), p.Steps...) @@ -71,8 +80,14 @@ func (f *wfFake) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult { } func (f *wfFake) History() []core.Message { return nil } func (f *wfFake) Attachments() []core.ParsedDocument { return nil } -func (f *wfFake) Tools() []core.ToolDesc { return nil } -func (f *wfFake) Subtask() string { return "" } +func (f *wfFake) Tools() []core.ToolDesc { + var out []core.ToolDesc + for _, name := range f.toolNames { + out = append(out, core.ToolDesc{Name: name}) + } + return out +} +func (f *wfFake) Subtask() string { return "" } func (f *wfFake) CreateCard(card core.ActivityCard) (core.ActivityCard, error) { card.ID = "card_1" f.cards = append(f.cards, card) @@ -223,3 +238,41 @@ func TestRunWorkflowToolUsesRegisteredDefinition(t *testing.T) { t.Fatalf("%s", got) } } + +func TestPlannedWorkflowNeedsConfirm(t *testing.T) { + plan := `{"id":"plan.brief","title":"简报","steps":[{"id":"ask","title":"确认","kind":"hitl","hitl":{"question":"可以吗?","kind":"confirm","allowFreeInput":false}}]}` + f := &wfFake{ + spawnText: plan, + hitl: []core.HITLResponse{{ChoiceID: "yes"}, {ChoiceID: "ok"}}, + } + st := &directRun{rc: f} + out := st.runRegisteredWorkflow(`{"goal":"写一份简报"}`) + if f.spawnIDs[0] != "agent.sub.workflow-plan" || !strings.Contains(f.hitlQ[0], "按这个计划执行") { + t.Fatalf("spawn %v questions %v", f.spawnIDs, f.hitlQ) + } + if !strings.Contains(out, "完成") || f.hitlQ[1] != "可以吗?" { + t.Fatalf("out %s questions %v", out, f.hitlQ) + } + + f2 := &wfFake{spawnText: plan, hitl: []core.HITLResponse{{ChoiceID: "no"}}} + out = (&directRun{rc: f2}).runRegisteredWorkflow(`{"goal":"写一份简报"}`) + if out != "未确认,未执行" || len(f2.hitlQ) != 1 { + t.Fatalf("out %s questions %v", out, f2.hitlQ) + } + + var steps []string + for i := 0; i < 9; i++ { + steps = append(steps, fmt.Sprintf(`{"id":"s%d","title":"步","kind":"hitl"}`, i)) + } + f3 := &wfFake{spawnText: `{"id":"too.big","title":"太多","steps":[` + strings.Join(steps, ",") + `]}`} + out = (&directRun{rc: f3}).runRegisteredWorkflow(`{"goal":"做很多事"}`) + if !strings.Contains(out, "超出上限") || len(f3.hitlQ) != 0 { + t.Fatalf("out %s questions %v", out, f3.hitlQ) + } + + f4 := &wfFake{spawnText: `{"id":"bad.script","title":"脚本","steps":[{"id":"run","title":"跑","kind":"script","script":"a.py"}]}`} + out = (&directRun{rc: f4}).runRegisteredWorkflow(`{"goal":"跑脚本"}`) + if !strings.Contains(out, "script") || len(f4.hitlQ) != 0 { + t.Fatalf("out %s questions %v", out, f4.hitlQ) + } +}