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 <cursoragent@cursor.com>
This commit is contained in:
2026-10-01 16:11:51 +00:00
co-authored by Cursor
parent 34da08874b
commit 9a563df970
5 changed files with 177 additions and 4 deletions
@@ -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 问用户。
+1
View File
@@ -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 {
+16 -1
View File
@@ -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 关闭);内容直接写在对话回复里"
+75
View File
@@ -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 {
+56 -3
View File
@@ -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)
}
}