feat(intent): Phase 4 run control, subtypes, and limited IntentPlan

Add RUN_STATUS_QUERY/RUN_RESUME/RUN_RESTART with matcher and coordinator
handlers; write QUERY_PROGRESS/SUPPLEMENT_INFO subtypes to V2 frame; support
safe query+query multi-intent plans via IntentPlan dual-write.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
WeiCHEN
2026-09-13 02:50:21 +00:00
committed by weichen
co-authored by Cursor
parent dda6a06e4a
commit 9cb129d3e8
14 changed files with 484 additions and 9 deletions
+9
View File
@@ -205,6 +205,15 @@ func (e *CoordinatorEngine) ProcessIntent(
case intent.IntentUnsupportedAction:
coordRes, coordErr = e.handleUnsupportedAction(ctx, top, userCtx)
case intent.IntentRunStatusQuery:
coordRes, coordErr = e.handleRunStatusQuery(ctx, top, userCtx)
case intent.IntentRunResume:
coordRes, coordErr = e.handleRunResume(ctx, top, userCtx)
case intent.IntentRunRestart:
coordRes, coordErr = e.handleRunRestart(ctx, top, userCtx)
default:
nextExp := BuildNextExpectation(userCtx.Project.CurrentStage, userCtx.Project.Name)
coordRes = &CoordinationResult{
+98
View File
@@ -0,0 +1,98 @@
package coordinator
import (
"context"
"fmt"
"chat-agent-system/internal/intent"
)
func (e *CoordinatorEngine) handleRunStatusQuery(_ context.Context, _ *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) {
workDir := userCtx.WorkDir
reply := "📊 **运行状态查询(只读)**\n\n"
if workDir != "" {
reply += fmt.Sprintf("• 最近工作区:`%s`\n• 您可以在任务面板查看该运行的阶段、失败原因与产物摘要。\n", workDir)
} else {
reply += "• 当前会话未绑定具体工作区。请指定要查询的运行任务,或先在界面打开失败任务详情。\n"
}
return &CoordinationResult{
Status: StatusAccepted,
Intent: intent.IntentRunStatusQuery,
NaturalReply: reply,
UpdatedContext: userCtx,
}, nil
}
func (e *CoordinatorEngine) handleRunResume(_ context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) {
workDir := cand.FilledSlots[intent.SlotWorkDir]
if workDir == "" {
workDir = userCtx.WorkDir
}
if workDir == "" {
msg := cand.ClarificationQuestion
if msg == "" {
msg = "续跑需要绑定目标工作区。请说明要恢复哪一次失败运行,或在工作区面板选择「继续」。"
}
return &CoordinationResult{
Status: StatusClarificationRequired,
Intent: intent.IntentRunResume,
ClarificationPrompt: msg,
NaturalReply: msg,
UpdatedContext: userCtx,
}, nil
}
msg := fmt.Sprintf(
"已理解您要续跑工作区 `%s`(复用已有上下文)。请在任务面板确认「继续」,或回复「确认续跑」后我将为您发起 resume 请求。",
workDir,
)
return &CoordinationResult{
Status: StatusClarificationRequired,
Intent: intent.IntentRunResume,
NaturalReply: msg,
ClarificationPrompt: msg,
UpdatedContext: userCtx,
NextExpectation: &intent.PendingExpectation{
ExpectedIntent: intent.IntentRunResume,
TargetAction: "run.resume",
Prompt: "确认续跑长任务",
AllowAffirmative: true,
Payload: map[string]string{
intent.SlotWorkDir: workDir,
},
DefaultOption: &intent.ExpectationOption{
OptionID: "confirm_resume",
Title: "确认续跑",
Intent: intent.IntentRunResume,
Slots: map[string]string{
intent.SlotWorkDir: workDir,
},
},
},
}, nil
}
func (e *CoordinatorEngine) handleRunRestart(_ context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) {
msg := "已理解您希望重新运行任务(将创建新工作区,保留原始意图但不复用失败现场)。这是高风险操作,请确认后再执行。"
if cand.ClarificationQuestion != "" {
msg = cand.ClarificationQuestion
}
return &CoordinationResult{
Status: StatusClarificationRequired,
Intent: intent.IntentRunRestart,
NaturalReply: msg,
ClarificationPrompt: msg,
UpdatedContext: userCtx,
NextExpectation: &intent.PendingExpectation{
ExpectedIntent: intent.IntentRunRestart,
TargetAction: "run.restart",
Prompt: "确认重新运行长任务",
AllowAffirmative: false,
DefaultOption: &intent.ExpectationOption{
OptionID: "confirm_restart",
Title: "确认重新运行",
Intent: intent.IntentRunRestart,
},
},
}, nil
}
+59
View File
@@ -0,0 +1,59 @@
package coordinator_test
import (
"context"
"strings"
"testing"
"chat-agent-system/internal/coordinator"
"chat-agent-system/internal/intent"
)
func TestEngine_RunStatusQuery_WithWorkDir(t *testing.T) {
rec := intent.NewRecognizer()
engine := coordinator.NewCoordinatorEngine(nil)
recRes, err := rec.Recognize(context.Background(), "这次任务运行状态怎么样了?", nil)
if err != nil {
t.Fatalf("recognize: %v", err)
}
userCtx := &coordinator.UserProjectContext{
User: coordinator.UserInfo{ID: "1", Name: "用户"},
Project: coordinator.ProjectInfo{Name: "项目"},
WorkDir: "/data/dev/ws-123",
BlackboardFacts: map[string]string{},
}
res, err := engine.ProcessIntent(context.Background(), recRes, userCtx)
if err != nil {
t.Fatalf("ProcessIntent: %v", err)
}
if !strings.Contains(res.NaturalReply, "ws-123") {
t.Errorf("expected workdir in reply: %s", res.NaturalReply)
}
}
func TestEngine_RunResume_MissingWorkDirClarify(t *testing.T) {
rec := intent.NewRecognizer()
engine := coordinator.NewCoordinatorEngine(nil)
recRes, err := rec.Recognize(context.Background(), "继续上次失败的任务", nil)
if err != nil {
t.Fatalf("recognize: %v", err)
}
userCtx := &coordinator.UserProjectContext{
User: coordinator.UserInfo{ID: "1", Name: "用户"},
Project: coordinator.ProjectInfo{Name: "项目"},
BlackboardFacts: map[string]string{},
}
res, err := engine.ProcessIntent(context.Background(), recRes, userCtx)
if err != nil {
t.Fatalf("ProcessIntent: %v", err)
}
if res.Status != coordinator.StatusClarificationRequired {
t.Fatalf("expected CLARIFICATION_REQUIRED, got %s", res.Status)
}
}
+4 -3
View File
@@ -141,8 +141,9 @@ type RouteDecision struct {
func RouteIntent(it intent.IntentType, profile TaskProfile) RouteDecision {
// R0:确定性路由表——与既有意图分发完全等价(默认即现状)
switch it {
case intent.IntentTriggerTask, intent.IntentAdvanceStage, intent.IntentTriggerWorkflow:
return RouteDecision{Channel: ChannelSquadFixed, Confidence: 0.95, Reason: "标准任务/阶段推进/工作流运行指令,进入小队受控执行", Profile: profile}
case intent.IntentTriggerTask, intent.IntentAdvanceStage, intent.IntentTriggerWorkflow,
intent.IntentRunResume, intent.IntentRunRestart:
return RouteDecision{Channel: ChannelSquadFixed, Confidence: 0.95, Reason: "标准任务/阶段推进/工作流运行/长任务续跑指令,进入小队受控执行", Profile: profile}
case intent.IntentSwitchWorkflow:
return RouteDecision{Channel: ChannelTemplate, Confidence: 0.95, Reason: "工作流切换需创建人确认,走模板与策略门禁", Profile: profile}
case intent.IntentTriggerAdhocTask:
@@ -153,7 +154,7 @@ func RouteIntent(it intent.IntentType, profile TaskProfile) RouteDecision {
intent.IntentDocGovProposalDecide, intent.IntentQueryRejectReason,
intent.IntentQueryProgress, intent.IntentQueryRegulations, intent.IntentQuerySimilarCases,
intent.IntentQueryUserRights, intent.IntentQueryWorkflowStatus, intent.IntentQueryDocStatus,
intent.IntentHelpCapabilities, intent.IntentUnsupportedAction,
intent.IntentHelpCapabilities, intent.IntentUnsupportedAction, intent.IntentRunStatusQuery,
intent.IntentSupplementInfo, intent.IntentDeclineExpectation:
return RouteDecision{Channel: ChannelTemplate, Confidence: 0.95, Reason: "查询/治理/补充类意图,走规则模板与知识库直答", Profile: profile}
}
+24 -2
View File
@@ -3,6 +3,21 @@ package intent
import "strings"
// branchToRelation 将三分支映射为 ExpectationRelation
func mapQueryProgressSubtype(topic string) string {
switch topic {
case "risk":
return "risk"
case "testing_analysis", "technical_design":
return "technical"
case "talk_script":
return "communication"
case "progress":
return "status"
default:
return topic
}
}
func branchToRelation(branch ResolutionBranch, cand *CandidateIntent) ExpectationRelation {
switch branch {
case BranchExpectationHit:
@@ -60,8 +75,15 @@ func BuildIntentFrameV2(res *IntentRecognitionResult, registry *ActionRegistry)
}
}
if top.FilledSlots != nil {
if topic, ok := top.FilledSlots[SlotQueryTopic]; ok && top.Intent == IntentQueryProgress {
frame.Subtype = topic
switch top.Intent {
case IntentQueryProgress:
if topic, ok := top.FilledSlots[SlotQueryTopic]; ok {
frame.Subtype = mapQueryProgressSubtype(topic)
}
case IntentSupplementInfo:
if action, ok := top.FilledSlots[SlotSupplementAction]; ok {
frame.Subtype = action
}
}
}
frame.Evidence = buildEvidenceFromCandidate(top, res)
+4 -2
View File
@@ -84,7 +84,8 @@ func isWriteIntent(t IntentType) bool {
IntentDocGovClaim, IntentDocGovSubmitReview, IntentDocGovReviewApprove, IntentDocGovReviewReject,
IntentDocGovFinalApprove, IntentDocGovFinalReject, IntentDocGovProposalDecide,
IntentConfirmAction, IntentExecuteStageAdvance,
IntentSwitchWorkflow, IntentTriggerWorkflow:
IntentSwitchWorkflow, IntentTriggerWorkflow,
IntentRunResume, IntentRunRestart:
return true
default:
return false
@@ -96,7 +97,8 @@ func isQueryIntent(t IntentType) bool {
switch t {
case IntentQueryProgress, IntentQueryRegulations, IntentQuerySimilarCases,
IntentQueryUserRights, IntentQueryRejectReason,
IntentQueryWorkflowStatus, IntentQueryDocStatus, IntentHelpCapabilities:
IntentQueryWorkflowStatus, IntentQueryDocStatus, IntentHelpCapabilities,
IntentRunStatusQuery:
return true
default:
return false
+99
View File
@@ -0,0 +1,99 @@
package intent
import (
"context"
"fmt"
"strings"
)
var planConnectors = []string{",然后", ",然后", ",并", ",并", ",接着", ",接着", ";再", ";再"}
// splitOnPlanConnectors 按有限连接词拆分多意图输入
func splitOnPlanConnectors(input string) []string {
for _, sep := range planConnectors {
if idx := strings.Index(input, sep); idx > 0 {
left := strings.TrimSpace(input[:idx])
right := strings.TrimSpace(input[idx+len(sep):])
if left != "" && right != "" {
return []string{left, right}
}
}
}
return nil
}
// buildLimitedIntentPlan 构建有限多意图计划(仅 query+query / query+generate 等安全组合)
func buildLimitedIntentPlan(ctx context.Context, r *RuleBasedRecognizer, input string, snap *ContextSnapshot, primary *IntentRecognitionResult) *IntentPlan {
parts := splitOnPlanConnectors(input)
if len(parts) < 2 {
return nil
}
var steps []IntentPlanStep
for i, part := range parts {
subRes, err := r.Recognize(ctx, part, snap)
if err != nil || subRes == nil || subRes.TopIntent == nil {
return nil
}
frame := BuildIntentFrameV2(subRes, DefaultRegistry())
if frame == nil {
return nil
}
// 高风险写动作组合初期不支持
if isWriteIntent(subRes.TopIntent.Intent) {
return nil
}
stepID := fmt.Sprintf("%d", i+1)
var depends []string
if i > 0 {
depends = []string{fmt.Sprintf("%d", i)}
}
steps = append(steps, IntentPlanStep{
StepID: stepID,
Frame: *frame,
DependsOn: depends,
OnFailure: "abort",
})
}
if len(steps) < 2 {
return nil
}
// 至少一个查询步骤
hasQuery := false
for _, s := range steps {
if s.Frame.Goal == GoalQuery || isQueryIntent(s.Frame.LegacyIntent) {
hasQuery = true
break
}
}
if !hasQuery {
return nil
}
if primary != nil && primary.IntentFrameV2 != nil {
primary.IntentFrameV2.Relation = RelationExpectationPlusNew
}
return &IntentPlan{
Steps: steps,
Atomicity: "sequential",
NeedsConfirm: false,
FailureMode: "stop_on_first_failure",
}
}
func inferSupplementSubtype(input string, slots map[string]string) string {
if strings.Contains(input, "附件") || strings.Contains(input, "材料") || strings.Contains(input, "上传") {
return "attach"
}
if strings.Contains(input, "纠正") || strings.Contains(input, "更正") ||
(strings.Contains(input, "改为") && strings.Contains(input, "原来")) {
return "correct"
}
if strings.Contains(input, "修改为") || strings.Contains(input, "改为") || strings.Contains(input, "改成") {
if slots[SlotFactKey] != "" {
return "correct"
}
}
return "add"
}
+64
View File
@@ -0,0 +1,64 @@
package intent_test
import (
"context"
"testing"
"chat-agent-system/internal/intent"
)
func TestLimitedIntentPlan_QueryThenQuery(t *testing.T) {
rec := intent.NewRecognizer()
res, err := rec.Recognize(context.Background(), "查一下项目风险,然后查相关法规", nil)
if err != nil {
t.Fatalf("recognize: %v", err)
}
if res.IntentPlan == nil {
t.Fatal("expected intent_plan for query+query combination")
}
if len(res.IntentPlan.Steps) != 2 {
t.Fatalf("expected 2 steps, got %d", len(res.IntentPlan.Steps))
}
if res.IntentPlan.Steps[0].Frame.Goal != intent.GoalQuery {
t.Errorf("step1 goal want query got %s", res.IntentPlan.Steps[0].Frame.Goal)
}
if res.IntentPlan.Steps[1].Frame.Goal != intent.GoalQuery {
t.Errorf("step2 goal want query got %s", res.IntentPlan.Steps[1].Frame.Goal)
}
}
func TestSupplementSubtype_InV2Frame(t *testing.T) {
rec := intent.NewRecognizer()
res, err := rec.Recognize(context.Background(), "客户反馈附件已上传,请入库", nil)
if err != nil {
t.Fatalf("recognize: %v", err)
}
if res.IntentFrameV2 == nil || res.IntentFrameV2.Subtype != "attach" {
t.Errorf("expected subtype attach, got %+v", res.IntentFrameV2)
}
}
func TestRunMatcher_ResumeRequiresClarification(t *testing.T) {
rec := intent.NewRecognizer()
res, err := rec.Recognize(context.Background(), "继续上次失败的任务", nil)
if err != nil {
t.Fatalf("recognize: %v", err)
}
if res.TopIntent.Intent != intent.IntentRunResume {
t.Fatalf("expected RUN_RESUME, got %s", res.TopIntent.Intent)
}
if !res.RequiresClarifying && res.TopIntent.ClarificationQuestion == "" {
t.Error("expected clarification when work_dir missing")
}
}
func TestRunMatcher_StatusQuery(t *testing.T) {
rec := intent.NewRecognizer()
res, err := rec.Recognize(context.Background(), "上次任务运行状态怎么样了?", nil)
if err != nil {
t.Fatalf("recognize: %v", err)
}
if res.TopIntent.Intent != intent.IntentRunStatusQuery {
t.Fatalf("expected RUN_STATUS_QUERY, got %s", res.TopIntent.Intent)
}
}
+12 -2
View File
@@ -186,7 +186,13 @@ func (r *RuleBasedRecognizer) Recognize(ctx context.Context, input string, userC
candidates = append(candidates, *cand)
}
// 领域 4: 工作流控制与状态查询 (Phase 3 — 优先于进度查询,避免「测试经理」等词误判)
// 领域 4: 长任务运行控制 (Phase 4)
if cand := matchRunControl(trimmed, userCtx); cand != nil {
cand.Branch = BranchNewIntentPreempt
candidates = append(candidates, *cand)
}
// 领域 5: 工作流控制与状态查询 (Phase 3 — 优先于进度查询,避免「测试经理」等词误判)
if cand := matchQueryWorkflowStatus(trimmed, userCtx); cand != nil {
cand.Branch = BranchNewIntentPreempt
candidates = append(candidates, *cand)
@@ -291,6 +297,9 @@ func (r *RuleBasedRecognizer) Recognize(ctx context.Context, input string, userC
RequiresClarifying: requiresClarifying,
}
finalizeRecognitionResult(result)
if plan := buildLimitedIntentPlan(ctx, r, trimmed, userCtx, result); plan != nil {
result.IntentPlan = plan
}
return result, nil
}
@@ -1085,6 +1094,7 @@ func matchSupplementInfo(input string) *CandidateIntent {
slots = make(map[string]string)
}
slots[SlotSupplementDoc] = input
slots[SlotSupplementAction] = inferSupplementSubtype(input, slots)
return &CandidateIntent{
Intent: IntentSupplementInfo,
@@ -1095,7 +1105,7 @@ func matchSupplementInfo(input string) *CandidateIntent {
}
func matchQueryProgress(input string) *CandidateIntent {
if isWorkflowControlInput(input) {
if isWorkflowControlInput(input) || isRunControlInput(input) {
return nil
}
patterns := []string{
+4
View File
@@ -174,6 +174,10 @@ func registerBuiltinActions(r *ActionRegistry) {
{Key: "document.status.query", Domain: "document_gov", Object: "controlled_document", Goal: GoalQuery, Risk: RiskLow, LegacyIntent: IntentQueryDocStatus, CapabilityKey: "VIEW_DOC", DialogueActs: []DialogueAct{DialogueActAsk}},
{Key: "conversation.help", Domain: "conversation", Object: "system_ui", Goal: GoalQuery, Risk: RiskLow, LegacyIntent: IntentHelpCapabilities, DialogueActs: []DialogueAct{DialogueActAsk}},
{Key: "conversation.unsupported", Domain: "conversation", Object: "system_ui", Goal: GoalConverse, Risk: RiskLow, LegacyIntent: IntentUnsupportedAction},
{Key: "run.status.query", Domain: "project", Object: "workflow_run", Goal: GoalQuery, Risk: RiskLow, LegacyIntent: IntentRunStatusQuery, DialogueActs: []DialogueAct{DialogueActAsk}},
{Key: "run.resume", Domain: "project", Object: "workflow_run", Goal: GoalControl, Risk: RiskMedium, LegacyIntent: IntentRunResume, RequiresConfirm: true, DialogueActs: []DialogueAct{DialogueActCommand}},
{Key: "run.restart", Domain: "project", Object: "workflow_run", Goal: GoalControl, Risk: RiskHigh, LegacyIntent: IntentRunRestart, RequiresConfirm: true, DialogueActs: []DialogueAct{DialogueActCommand}},
}
for _, s := range specs {
_ = r.Register(s)
+1
View File
@@ -14,6 +14,7 @@ func TestDefaultRegistry_CoversAllLegacyIntents(t *testing.T) {
IntentConfirmAction, IntentExecuteStageAdvance, IntentSecurityForbidden,
IntentSwitchWorkflow, IntentTriggerWorkflow, IntentQueryWorkflowStatus,
IntentQueryDocStatus, IntentHelpCapabilities, IntentUnsupportedAction,
IntentRunStatusQuery, IntentRunResume, IntentRunRestart,
}
for _, lt := range legacyIntents {
spec, ok := r.GetByLegacyIntent(lt)
+79
View File
@@ -0,0 +1,79 @@
package intent
import "strings"
func isRunControlInput(input string) bool {
runMarkers := []string{
"继续", "续跑", "接着跑", "继续上次", "继续任务", "恢复运行", "恢复任务",
"重来", "重新运行", "重启", "从头开始", "重新跑",
"运行状态", "任务状态", "执行状态", "跑到哪", "失败了吗", "完成了吗", "任务进度",
"上次任务", "这次运行", "工作区",
}
for _, m := range runMarkers {
if strings.Contains(input, m) {
return true
}
}
return false
}
func matchRunControl(input string, snap *ContextSnapshot) *CandidateIntent {
if !isRunControlInput(input) {
return nil
}
// 避免与挂起预期裸肯定词冲突(「继续」单独出现且 pending 存在时由 expectation 处理)
if strings.TrimSpace(input) == "继续" || strings.TrimSpace(input) == "接着" {
return nil
}
slots := map[string]string{}
if snap != nil && snap.SnapshotID != "" {
_ = snap.SnapshotID
}
restartMarkers := []string{"重来", "重新运行", "重启", "从头开始", "重新跑", "重新执行"}
for _, m := range restartMarkers {
if strings.Contains(input, m) {
slots[SlotRunAction] = "restart"
return &CandidateIntent{
Intent: IntentRunRestart,
Confidence: 0.91,
FilledSlots: slots,
Reasoning: "用户请求重新启动长任务运行(将创建新工作区,保留原意图)",
}
}
}
resumeMarkers := []string{"继续", "续跑", "接着跑", "继续上次", "继续任务", "恢复运行", "恢复任务"}
for _, m := range resumeMarkers {
if strings.Contains(input, m) {
slots[SlotRunAction] = "resume"
cand := &CandidateIntent{
Intent: IntentRunResume,
Confidence: 0.90,
FilledSlots: slots,
Reasoning: "用户请求续跑/恢复已有长任务工作区",
}
if snap == nil || (len(slots[SlotWorkDir]) == 0) {
cand.ClarificationQuestion = "请说明要续跑的具体任务或失败阶段;若您指的是最近一次失败运行,请确认后我将基于该工作区继续。"
}
return cand
}
}
if detectDialogueAct(input) == DialogueActAsk ||
strings.Contains(input, "状态") || strings.Contains(input, "失败了吗") ||
strings.Contains(input, "完成了吗") || strings.Contains(input, "跑到哪") {
slots[SlotRunAction] = "status"
return &CandidateIntent{
Intent: IntentRunStatusQuery,
Confidence: 0.89,
FilledSlots: map[string]string{
SlotRunAction: "status",
SlotQueryTopic: "run_status",
},
Reasoning: "用户查询长任务/工作流运行状态(只读)",
}
}
return nil
}
+11
View File
@@ -49,6 +49,11 @@ const (
// 对话修复:帮助 / 暂不支持
IntentHelpCapabilities IntentType = "HELP_CAPABILITIES"
IntentUnsupportedAction IntentType = "UNSUPPORTED_ACTION"
// 长任务运行控制(Phase 4)
IntentRunStatusQuery IntentType = "RUN_STATUS_QUERY"
IntentRunResume IntentType = "RUN_RESUME"
IntentRunRestart IntentType = "RUN_RESTART"
)
// ResolutionBranch 三分支意图消解类别
@@ -118,6 +123,7 @@ type IntentRecognitionResult struct {
// V2 双写:迁移期与 TopIntent 并存,Router/Coordinator 默认仍消费 TopIntent。
IntentFrameV2 *IntentFrameV2 `json:"intent_frame_v2,omitempty"`
RankedFrames *RankedIntentSet `json:"ranked_frames,omitempty"`
IntentPlan *IntentPlan `json:"intent_plan,omitempty"`
}
// 常见槽位 Key 常量定义
@@ -141,6 +147,11 @@ const (
// 工作流槽位
SlotTargetWorkflow = "target_workflow" // product_manager | test_manager | main
// 长任务/运行槽位
SlotWorkDir = "work_dir"
SlotRunAction = "run_action" // status | resume | restart
SlotSupplementAction = "supplement_action" // add | correct | attach
)
// CardAction 交互卡片按钮动作
+16
View File
@@ -75,3 +75,19 @@ type CandidateConflict struct {
}
const IntentFrameV2Version = "v2.0"
// IntentPlan 有限多意图执行计划(Phase 4:仅支持可验证组合)
type IntentPlan struct {
Steps []IntentPlanStep `json:"steps"`
Atomicity string `json:"atomicity"` // sequential
NeedsConfirm bool `json:"needs_confirm,omitempty"`
FailureMode string `json:"failure_mode,omitempty"` // stop_on_first_failure
}
// IntentPlanStep 计划步骤
type IntentPlanStep struct {
StepID string `json:"step_id"`
Frame IntentFrameV2 `json:"frame"`
DependsOn []string `json:"depends_on,omitempty"`
OnFailure string `json:"on_failure,omitempty"` // abort | skip
}