Why: Simple tasks should finish as one assistant step, while complex tasks stay in leader mode, and every kernel step needs the host's tools and events. Evidence: go test ./squad/ -count=1 passed, including direct route, leader entry, skill override, and delegation score fields. Impact: Hosts can inject HostTools and OnEvent; a Router can choose direct or plan without changing product roles. Refs: deliverable-quality-system B2 Co-authored-by: Cursor <cursoragent@cursor.com>
1010 lines
34 KiB
Go
1010 lines
34 KiB
Go
package squad
|
||
|
||
import (
|
||
"context"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"sort"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"error-code-system/errcode"
|
||
)
|
||
|
||
// Options 引擎组装选项(组合根装配;库内零产品依赖)。
|
||
type Options struct {
|
||
DataDir string // 数据根:runs/、roles.d/、blueprints.d/ 所在目录(必填)
|
||
Registry *RoleRegistry
|
||
Blueprints *BlueprintRegistry
|
||
Planner Planner
|
||
Backends *BackendResolver
|
||
Evaluator StepEvaluator
|
||
Checker Checker
|
||
Sink ProgressSink
|
||
// DefaultBackend 角色未声明 Backend 时的默认智能体名(默认 stub)。
|
||
DefaultBackend string
|
||
// StepTimeout 单步执行超时(默认 10 分钟)。
|
||
StepTimeout time.Duration
|
||
// HitlPolicy 全局 HITL 策略(角色/Goal 可覆盖)。
|
||
HitlPolicy HitlPolicy
|
||
// RepairBudget 质检失败自动修复次数(默认 1)。
|
||
RepairBudget int
|
||
// IntentGate 可选意图闸门:Run 入口一次确定性裁决(nil = 不启用,
|
||
// 行为与原实现一致)。见 intent.go;实现方为 squadintent 适配器。
|
||
IntentGate IntentGate
|
||
// IntentCatalog 可选能力目录(squadintent 渲染):注入 PlanInput 与 leader 提示词,
|
||
// 并用于 intent 步骤的确定性校验。nil = 未启用目录驱动编排。
|
||
IntentCatalog *IntentCatalog
|
||
// IntentRunner 系统功能步执行器(squadintent 提供)。目录启用而 Runner 缺席时,
|
||
// intent 步骤 fail-closed。
|
||
IntentRunner IntentRunner
|
||
// Router 可选分诊。direct = 单步 assistant,不进多角色;plan = leader 委派。
|
||
// nil = 保持调用方传入的 RunOptions.Mode。
|
||
Router Router
|
||
}
|
||
|
||
// engineDeps 驱动器依赖(由引擎从 Options 派生)。
|
||
type engineDeps struct {
|
||
Registry *RoleRegistry
|
||
Backends *BackendResolver
|
||
Evaluator StepEvaluator
|
||
Checker Checker
|
||
Sink ProgressSink
|
||
Workspaces *WorkspaceEngine
|
||
StepTimeout time.Duration
|
||
HitlPolicy HitlPolicy
|
||
IntentRunner IntentRunner
|
||
}
|
||
|
||
// RunOptions 单次运行的可选项。
|
||
type RunOptions struct {
|
||
// TaskKey 显式指定蓝图模板键;空则按 Goal.Text 匹配或自主规划。
|
||
TaskKey string `json:"task_key,omitempty"`
|
||
// Backend 运行级默认后端(覆盖引擎 DefaultBackend)。
|
||
Backend string `json:"backend,omitempty"`
|
||
// Mode 编排模式:standard(默认,一次性计划顺序执行)| leader
|
||
// (leader-delegation:leader 角色逐步委派成员,成员产出后唤醒 leader 再评估)。
|
||
Mode string `json:"mode,omitempty"`
|
||
// LeaderRole leader 模式下的 leader 角色 key(必须已注册或在计划编制内)。
|
||
LeaderRole string `json:"leader_role,omitempty"`
|
||
// MaxLeaderTurns leader 模式每运行的 leader 轮次上限(默认 8,防失控)。
|
||
MaxLeaderTurns int `json:"max_leader_turns,omitempty"`
|
||
}
|
||
|
||
// SquadEngine 小队引擎门面:Run / Respond / Resume / Inspect / ListRuns。
|
||
// 每个运行同一时刻只允许一个控制操作(Run/Respond 互斥)。
|
||
type SquadEngine struct {
|
||
opts Options
|
||
deps engineDeps
|
||
ws *WorkspaceEngine
|
||
|
||
mu sync.Mutex
|
||
runLocks map[string]*sync.Mutex
|
||
}
|
||
|
||
// NewEngine 组装引擎并应用默认值。
|
||
func NewEngine(opts Options) (*SquadEngine, error) {
|
||
if opts.DataDir == "" {
|
||
return nil, fmt.Errorf("DataDir is required")
|
||
}
|
||
if opts.Registry == nil {
|
||
opts.Registry = NewRoleRegistry()
|
||
}
|
||
if opts.Blueprints == nil {
|
||
opts.Blueprints = NewBlueprintRegistry()
|
||
}
|
||
if opts.Planner == nil {
|
||
opts.Planner = NewRulePlanner()
|
||
}
|
||
if opts.Backends == nil {
|
||
opts.Backends = NewBackendResolver()
|
||
}
|
||
if opts.Evaluator == nil {
|
||
opts.Evaluator = NoOpEvaluator{}
|
||
}
|
||
if opts.Checker == nil {
|
||
opts.Checker = MinimalChecker{}
|
||
}
|
||
if opts.Sink == nil {
|
||
opts.Sink = NopSink{}
|
||
}
|
||
if opts.DefaultBackend == "" {
|
||
opts.DefaultBackend = "stub"
|
||
}
|
||
if opts.StepTimeout == 0 {
|
||
opts.StepTimeout = 10 * time.Minute
|
||
}
|
||
if opts.RepairBudget == 0 {
|
||
opts.RepairBudget = 1
|
||
}
|
||
if opts.HitlPolicy.Mode == "" {
|
||
opts.HitlPolicy.Mode = HitlInteractive
|
||
}
|
||
ws := &WorkspaceEngine{DataDir: opts.DataDir}
|
||
deps := engineDeps{
|
||
Registry: opts.Registry, Backends: opts.Backends, Evaluator: opts.Evaluator,
|
||
Checker: opts.Checker, Sink: opts.Sink, Workspaces: ws,
|
||
StepTimeout: opts.StepTimeout, HitlPolicy: opts.HitlPolicy,
|
||
IntentRunner: opts.IntentRunner,
|
||
}
|
||
return &SquadEngine{opts: opts, deps: deps, ws: ws, runLocks: map[string]*sync.Mutex{}}, nil
|
||
}
|
||
|
||
// Registry 暴露角色注册表(只读使用)。
|
||
func (e *SquadEngine) Registry() *RoleRegistry { return e.opts.Registry }
|
||
|
||
// Blueprints 暴露蓝图注册表。
|
||
func (e *SquadEngine) Blueprints() *BlueprintRegistry { return e.opts.Blueprints }
|
||
|
||
func (e *SquadEngine) runLock(runID string) *sync.Mutex {
|
||
e.mu.Lock()
|
||
defer e.mu.Unlock()
|
||
l, ok := e.runLocks[runID]
|
||
if !ok {
|
||
l = &sync.Mutex{}
|
||
e.runLocks[runID] = l
|
||
}
|
||
return l
|
||
}
|
||
|
||
// Run 发起一次运行:规划(含澄清/确认挂起)→ 执行 → 收口。
|
||
func (e *SquadEngine) Run(ctx context.Context, goal Goal, runOpts RunOptions) (*RunResult, error) {
|
||
if strings.TrimSpace(goal.Text) == "" {
|
||
return nil, fmt.Errorf("goal text is required")
|
||
}
|
||
runID := fmt.Sprintf("run_%s", time.Now().UTC().Format("20060102T150405.000000000"))
|
||
lock := e.runLock(runID)
|
||
lock.Lock()
|
||
defer lock.Unlock()
|
||
|
||
meta := RunMetadata{
|
||
ID: runID, Goal: goal, Status: string(RunPlanning),
|
||
Backend: firstNonEmpty(runOpts.Backend, e.opts.DefaultBackend),
|
||
Planner: e.opts.Planner.Name(),
|
||
CreatedAt: time.Now(), UpdatedAt: time.Now(),
|
||
Metadata: map[string]string{
|
||
"task_key": runOpts.TaskKey,
|
||
"mode": runOpts.Mode,
|
||
},
|
||
}
|
||
if runOpts.Mode == "leader" {
|
||
meta.Metadata["leader_role"] = runOpts.LeaderRole
|
||
if runOpts.MaxLeaderTurns > 0 {
|
||
meta.Metadata["max_leader_turns"] = fmt.Sprintf("%d", runOpts.MaxLeaderTurns)
|
||
}
|
||
}
|
||
e.deps.Sink.Emit(Event{Type: EvRunStarted, RunID: runID, Text: goal.Text, At: time.Now()})
|
||
|
||
// 意图闸门(可选,入口一次裁决;显式 TaskKey 优先,不重复过闸)。
|
||
// nil 决策 = 不干预走原路径;闸门系统错误与守卫拒绝同样 fail-closed 成失败运行。
|
||
if e.opts.IntentGate != nil && runOpts.TaskKey == "" {
|
||
dec, derr := e.opts.IntentGate.Decide(ctx, goal, runOpts)
|
||
if derr != nil {
|
||
e.deps.Sink.Emit(Event{Type: EvRunFailed, RunID: runID, Text: derr.Error(), At: time.Now()})
|
||
return e.failNew(&meta, derr)
|
||
}
|
||
if dec != nil {
|
||
if rej := dec.Reject; rej != nil {
|
||
if dec.IntentID != "" {
|
||
meta.Metadata["intent_id"] = dec.IntentID
|
||
}
|
||
err := errcode.New(errcode.SquadIntentRefused, rej.Reason, "guidance", rej.Guidance)
|
||
e.deps.Sink.Emit(Event{Type: EvRunFailed, RunID: runID, Text: err.Error(), At: time.Now()})
|
||
return e.failNew(&meta, err)
|
||
}
|
||
if dec.BlueprintKey != "" {
|
||
if _, ok := e.opts.Blueprints.Get(dec.BlueprintKey); !ok {
|
||
err := errcode.New(errcode.SquadBlueprintNotFound,
|
||
fmt.Sprintf("意图 %s 定向的蓝图 %s 不存在", dec.IntentID, dec.BlueprintKey))
|
||
e.deps.Sink.Emit(Event{Type: EvRunFailed, RunID: runID, Text: err.Error(), At: time.Now()})
|
||
return e.failNew(&meta, err)
|
||
}
|
||
runOpts.TaskKey = dec.BlueprintKey
|
||
meta.Metadata["intent_id"] = dec.IntentID
|
||
meta.Metadata["intent_blueprint"] = dec.BlueprintKey
|
||
}
|
||
}
|
||
}
|
||
|
||
st := &runState{Meta: meta, Approved: map[string]bool{}, RepairBudgetRemaining: e.opts.RepairBudget}
|
||
return e.planAndExecute(ctx, st, runOpts)
|
||
}
|
||
|
||
// planAndExecute 规划 → 挂起或执行;st 可为已有运行状态(澄清续跑复用)。
|
||
func (e *SquadEngine) planAndExecute(ctx context.Context, st *runState, runOpts RunOptions) (*RunResult, error) {
|
||
drv := &flowDriver{opts: e.deps}
|
||
meta := &st.Meta
|
||
|
||
if e.opts.Router != nil && runOpts.Mode == "" && meta.Metadata["route"] == "" {
|
||
dec, rerr := e.opts.Router.Route(ctx, meta.Goal)
|
||
if rerr != nil {
|
||
return e.failNew(meta, rerr)
|
||
}
|
||
applyRoute(meta, dec, runOpts.Mode)
|
||
}
|
||
|
||
var planOut *PlanOutput
|
||
var err error
|
||
if meta.Metadata["route_direct"] == "1" {
|
||
plan, perr := directAssistantPlan(e.opts.Registry, meta.Goal)
|
||
if perr != nil {
|
||
return e.failNew(meta, perr)
|
||
}
|
||
planOut = &PlanOutput{Plan: plan}
|
||
} else {
|
||
bpMatch, _ := e.opts.Blueprints.Match(meta.Goal.Text, runOpts.TaskKey)
|
||
planOut, err = e.opts.Planner.Plan(ctx, PlanInput{
|
||
Goal: meta.Goal, Blueprint: bpPtr(bpMatch), Registry: e.opts.Registry, RunID: meta.ID,
|
||
IntentCatalog: e.opts.IntentCatalog,
|
||
})
|
||
if err != nil {
|
||
return e.failNew(meta, err)
|
||
}
|
||
}
|
||
// 目录驱动编排的确定性校验:intent 步骤命中目录、必填槽位齐、枚举合法(fail-closed)。
|
||
if err := validateIntentSteps(planOut.Plan, e.opts.IntentCatalog); err != nil {
|
||
e.deps.Sink.Emit(Event{Type: EvRunFailed, RunID: meta.ID, Text: err.Error(), At: time.Now()})
|
||
return e.failNew(meta, err)
|
||
}
|
||
// 规划歧义:挂起澄清(auto/disabled 模式直接失败,不猜计划)。
|
||
if planOut.Clarification != nil {
|
||
if drv.policy(st).mode() != HitlInteractive {
|
||
return e.failNew(meta, fmt.Errorf("planner requested clarification but hitl mode is %s", st.policy().mode()))
|
||
}
|
||
var qs []string
|
||
for _, q := range planOut.Clarification.Questions {
|
||
qs = append(qs, "- "+q.Text)
|
||
}
|
||
opts := []HumanOption{}
|
||
if len(planOut.Clarification.Questions) == 1 && len(planOut.Clarification.Questions[0].Options) > 0 {
|
||
opts = planOut.Clarification.Questions[0].Options
|
||
}
|
||
req := &HumanRequest{
|
||
ID: newRequestID(), RunID: meta.ID, Kind: HitlClarification, Severity: SeverityActionRequired,
|
||
Question: "规划器无法确定如何执行该目标,请补充信息:",
|
||
Options: opts,
|
||
Context: fmt.Sprintf("原因:%s\n问题:\n%s", planOut.Clarification.Reason, strings.Join(qs, "\n")),
|
||
CreatedAt: time.Now(), Status: "pending",
|
||
}
|
||
gate := &hitlGate{Request: *req, PrevStatus: RunPlanning}
|
||
// 澄清挂起也需要工作区承载 pending.json。
|
||
if err := e.materializeWorkspace(meta, st, &SquadPlan{Workflow: WorkflowPlan{WorkflowID: NewPlanID(), Status: "pending", Steps: nil}}); err != nil {
|
||
return nil, err
|
||
}
|
||
if err := st.saveGate(gate); err != nil {
|
||
return nil, err
|
||
}
|
||
st.Meta.Status = string(RunAwaitingClarification)
|
||
_ = st.save()
|
||
e.deps.Sink.Emit(Event{Type: EvHumanInputRequired, RunID: meta.ID, Text: req.Question, Data: marshalJSON(req), At: time.Now()})
|
||
return &RunResult{RunID: meta.ID, Status: RunAwaitingClarification, Pending: req, WorkDir: st.Meta.WorkDir}, nil
|
||
}
|
||
|
||
// 计划确认:interactive 模式先挂起。leader 模式例外——规划步骤会被
|
||
// leader 委派循环取代,派生编制本身不构成执行承诺,无需确认。
|
||
if planOut.NeedsConfirmation && drv.policy(st).mode() == HitlInteractive && meta.Metadata["mode"] != "leader" {
|
||
if err := e.materializeWorkspace(meta, st, planOut.Plan); err != nil {
|
||
return nil, err
|
||
}
|
||
var lines []string
|
||
for _, s := range planOut.Plan.Workflow.Steps {
|
||
lines = append(lines, fmt.Sprintf("- %s [%s] %s(产出 %v)", s.ID, s.Role, s.Title, s.Targets))
|
||
}
|
||
var roles []string
|
||
for _, a := range planOut.Plan.Squad.Agents {
|
||
roles = append(roles, fmt.Sprintf("- %s(%s,backend=%s model=%s)", a.Role, a.Name, a.Backend, a.Model))
|
||
}
|
||
req := &HumanRequest{
|
||
ID: newRequestID(), RunID: meta.ID, Kind: HitlConfirmation, Severity: SeverityActionRequired,
|
||
Question: "计划已生成,需要人工确认后执行:",
|
||
Options: []HumanOption{
|
||
{Value: "approve", Label: "批准并执行"},
|
||
{Value: "abort", Label: "放弃执行"},
|
||
},
|
||
Context: fmt.Sprintf("确认原因:%s\n编制:\n%s\n步骤:\n%s",
|
||
planOut.ConfirmReason, strings.Join(roles, "\n"), strings.Join(lines, "\n")),
|
||
CreatedAt: time.Now(), Status: "pending",
|
||
}
|
||
gate := &hitlGate{Request: *req, Plan: planOut.Plan, PrevStatus: RunPlanning}
|
||
if err := st.saveGate(gate); err != nil {
|
||
return nil, err
|
||
}
|
||
st.Meta.Status = string(RunAwaitingConfirmation)
|
||
_ = st.save()
|
||
e.deps.Sink.Emit(Event{Type: EvHumanInputRequired, RunID: meta.ID, Text: req.Question, Data: marshalJSON(req), At: time.Now()})
|
||
return &RunResult{RunID: meta.ID, Status: RunAwaitingConfirmation, Pending: req, Plan: &planOut.Plan.Workflow, WorkDir: st.Meta.WorkDir}, nil
|
||
}
|
||
|
||
if err := e.materializeWorkspace(meta, st, planOut.Plan); err != nil {
|
||
return nil, err
|
||
}
|
||
// leader-delegation 模式:忽略规划步骤,进 leader 委派循环。
|
||
if meta.Metadata["mode"] == "leader" {
|
||
e.initLeaderState(st)
|
||
out, gate, err := e.runLeaderLoop(ctx, st)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if gate == nil {
|
||
e.finalizeEvent(out)
|
||
}
|
||
return out, nil
|
||
}
|
||
out, _, err := drv.advance(ctx, st)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
e.finalizeEvent(out)
|
||
return out, nil
|
||
}
|
||
|
||
// initLeaderState 按运行选项初始化 leader 循环状态。
|
||
func (e *SquadEngine) initLeaderState(st *runState) {
|
||
leaderRole := st.Meta.Metadata["leader_role"]
|
||
if leaderRole == "" {
|
||
if _, ok := findAgent(st.Plan.Squad.Agents, "leader"); ok {
|
||
leaderRole = "leader"
|
||
}
|
||
}
|
||
maxTurns := DefaultMaxLeaderTurns
|
||
if v := st.Meta.Metadata["max_leader_turns"]; v != "" {
|
||
if n := parseIntDefault(v, DefaultMaxLeaderTurns); n > 0 {
|
||
maxTurns = n
|
||
}
|
||
}
|
||
st.Leader = &LeaderState{LeaderRole: leaderRole, MaxTurns: maxTurns}
|
||
st.saveLeader()
|
||
}
|
||
|
||
func parseIntDefault(s string, def int) int {
|
||
n := 0
|
||
for _, c := range s {
|
||
if c < '0' || c > '9' {
|
||
return def
|
||
}
|
||
n = n*10 + int(c-'0')
|
||
}
|
||
return n
|
||
}
|
||
|
||
// isLeaderRun 该运行是否处于 leader-delegation 模式。
|
||
func (e *SquadEngine) isLeaderRun(st *runState) bool {
|
||
return st.Meta.Metadata["mode"] == "leader"
|
||
}
|
||
|
||
// resumeAfterHold 挂起解除后的统一续跑入口(按模式分发)。
|
||
func (e *SquadEngine) resumeAfterHold(ctx context.Context, st *runState) (*RunResult, error) {
|
||
if e.isLeaderRun(st) {
|
||
out, gate, err := e.runLeaderLoop(ctx, st)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if gate == nil {
|
||
e.finalizeEvent(out)
|
||
}
|
||
return out, nil
|
||
}
|
||
drv := &flowDriver{opts: e.deps}
|
||
out, _, err := drv.advance(ctx, st)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
e.finalizeEvent(out)
|
||
return out, nil
|
||
}
|
||
|
||
// materializeWorkspace 落盘工作区与子工作区(已存在则只更新计划并重建待执行子目录)。
|
||
func (e *SquadEngine) materializeWorkspace(meta *RunMetadata, st *runState, plan *SquadPlan) error {
|
||
if st.Meta.WorkDir == "" {
|
||
st.Meta = *meta
|
||
wsDir, err := e.ws.PrepareWorkspace(&st.Meta, plan)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
st.Template = plan.Template
|
||
bb, _ := loadBlackboard(wsDir)
|
||
st.BB = *bb
|
||
} else {
|
||
// 复用既有工作区:磁盘黑板为权威(澄清续跑/计划更新场景)。
|
||
if bb, err := loadBlackboard(st.Meta.WorkDir); err == nil {
|
||
st.BB = *bb
|
||
}
|
||
if st.Template == "" {
|
||
st.Template = plan.Template
|
||
}
|
||
}
|
||
st.Plan = *plan
|
||
// 编制补全:步骤引用但编制缺失的角色从注册表解析(蓝图/派生计划常见)。
|
||
if err := ensureSquadCoversSteps(&st.Plan, e.opts.Registry); err != nil {
|
||
return err
|
||
}
|
||
if err := e.ws.BuildSubWorkspaces(st.Meta.WorkDir, &st.Plan, &st.BB); err != nil {
|
||
return err
|
||
}
|
||
return st.save()
|
||
}
|
||
|
||
// ensureSquadCoversSteps 确保编制覆盖全部步骤角色。
|
||
func ensureSquadCoversSteps(plan *SquadPlan, reg *RoleRegistry) error {
|
||
for _, step := range plan.Workflow.Steps {
|
||
if _, ok := findAgent(plan.Squad.Agents, step.Role); ok {
|
||
continue
|
||
}
|
||
spec, ok := reg.Get(step.Role)
|
||
if !ok {
|
||
return fmt.Errorf("step %s references role %s not registered", step.ID, step.Role)
|
||
}
|
||
plan.Squad.Agents = append(plan.Squad.Agents, spec)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (e *SquadEngine) failNew(meta *RunMetadata, err error) (*RunResult, error) {
|
||
return &RunResult{RunID: meta.ID, Status: RunFailed, Error: err.Error()}, nil
|
||
}
|
||
|
||
// Respond 应用人工决策并续跑。按请求 ID 幂等:重复回应同一请求返回当前状态。
|
||
func (e *SquadEngine) Respond(ctx context.Context, runID string, decision RespondDecision) (*RunResult, error) {
|
||
lock := e.runLock(runID)
|
||
lock.Lock()
|
||
defer lock.Unlock()
|
||
|
||
wsDir := filepath.Join(e.opts.DataDir, "runs", runID)
|
||
st, err := loadRunState(wsDir)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("load run %s: %w", runID, err)
|
||
}
|
||
gate, err := loadGate(wsDir)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if gate == nil {
|
||
return nil, fmt.Errorf("run %s has no pending human request", runID)
|
||
}
|
||
if decision.RequestID != "" && decision.RequestID != gate.Request.ID {
|
||
// 幂等:回应已处理过的请求 → 返回当前状态。
|
||
if gate.Request.Status != "pending" {
|
||
return e.Inspect(runID)
|
||
}
|
||
return nil, fmt.Errorf("request id mismatch: expected %s", gate.Request.ID)
|
||
}
|
||
|
||
req := &gate.Request
|
||
req.Status = "answered"
|
||
now := time.Now()
|
||
req.AnsweredAt = &now
|
||
e.deps.Sink.Emit(Event{Type: EvHumanResponded, RunID: runID, Text: firstNonEmpty(decision.Choice, decision.Answer),
|
||
Data: marshalJSON(decision), At: time.Now()})
|
||
_ = st.saveGate(nil)
|
||
|
||
drv := &flowDriver{opts: e.deps}
|
||
switch req.Kind {
|
||
case HitlClarification:
|
||
return e.respondClarification(ctx, st, gate, decision)
|
||
case HitlConfirmation:
|
||
switch strings.ToLower(decision.Choice) {
|
||
case "approve":
|
||
if gate.Plan != nil {
|
||
if err := e.materializeWorkspace(&st.Meta, st, gate.Plan); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
if e.isLeaderRun(st) {
|
||
e.initLeaderState(st)
|
||
}
|
||
st.Meta.Status = string(RunExecuting)
|
||
_ = st.save()
|
||
return e.resumeAfterHold(ctx, st)
|
||
default: // abort / reject
|
||
return e.cancelRun(st, "人工拒绝计划"+appendAnswer(decision)), nil
|
||
}
|
||
case HitlGap:
|
||
switch strings.ToLower(decision.Choice) {
|
||
case "resolved", "":
|
||
answer := decision.Answer
|
||
for _, g := range st.BB.UnresolvedBlockingGaps() {
|
||
st.BB.ResolveGapByID(g.ID, answerFor(g, answer))
|
||
}
|
||
if answer != "" {
|
||
st.Clarifications = append(st.Clarifications, "缺口补充:"+answer)
|
||
}
|
||
case "skip":
|
||
for i := range st.BB.Gaps {
|
||
st.BB.Gaps[i].Blocking = false
|
||
}
|
||
case "abort":
|
||
return e.cancelRun(st, "人工中止(阻塞缺口)"), nil
|
||
default:
|
||
return nil, fmt.Errorf("unknown choice %q for gap request", decision.Choice)
|
||
}
|
||
st.Meta.Status = string(RunExecuting)
|
||
_ = st.save()
|
||
return e.resumeAfterHold(ctx, st)
|
||
case HitlEscalation:
|
||
switch strings.ToLower(decision.Choice) {
|
||
case "abort":
|
||
return e.cancelRun(st, "人工中止(leader 升级问人)"), nil
|
||
default: // 自由文本回答 = 人的决定
|
||
answer := strings.TrimSpace(decision.Answer)
|
||
if answer == "" {
|
||
for _, o := range gate.Request.Options {
|
||
if strings.EqualFold(o.Value, decision.Choice) {
|
||
answer = o.Label
|
||
}
|
||
}
|
||
}
|
||
if answer != "" {
|
||
st.Clarifications = append(st.Clarifications, "升级问答:"+answer)
|
||
st.BB.Facts = append(st.BB.Facts, FactItem{
|
||
ID: fmt.Sprintf("fact_%d", len(st.BB.Facts)+1), Claim: answer, Source: "human",
|
||
})
|
||
if st.Meta.WorkDir != "" {
|
||
_ = e.ws.UpdateGoalFile(st.Meta.WorkDir, &st.Meta.Goal)
|
||
}
|
||
}
|
||
st.Meta.Status = string(RunExecuting)
|
||
_ = st.save()
|
||
return e.resumeAfterHold(ctx, st)
|
||
}
|
||
case HitlAdjustment:
|
||
switch strings.ToLower(decision.Choice) {
|
||
case "approve":
|
||
applyAdjustment(st, gate.Steps, gate.SkipIDs)
|
||
case "reject", "":
|
||
// 忽略调整
|
||
case "abort":
|
||
return e.cancelRun(st, "人工中止(拒绝动态调整)"), nil
|
||
default:
|
||
return nil, fmt.Errorf("unknown choice %q for adjustment request", decision.Choice)
|
||
}
|
||
st.Meta.Status = string(RunExecuting)
|
||
_ = st.save()
|
||
return e.resumeAfterHold(ctx, st)
|
||
case HitlPermission:
|
||
switch strings.ToLower(decision.Choice) {
|
||
case "approve":
|
||
// 授权记到角色(gate 上下文带角色名)。
|
||
role := gate.Request.StepID
|
||
for _, s := range st.Plan.Workflow.Steps {
|
||
if s.ID == gate.Request.StepID {
|
||
role = s.Role
|
||
}
|
||
}
|
||
st.Approved["role:"+role] = true
|
||
st.Meta.Status = string(RunExecuting)
|
||
_ = st.save()
|
||
return e.resumeAfterHold(ctx, st)
|
||
case "reject":
|
||
// 步骤失败(关键性语义由 failStep 处理)。
|
||
for i := range st.Plan.Workflow.Steps {
|
||
if st.Plan.Workflow.Steps[i].ID == gate.Request.StepID {
|
||
st.Plan.Workflow.Steps[i].Status = "failed"
|
||
st.Plan.Workflow.Steps[i].Error = "permission denied by human"
|
||
st.Meta.Status = string(RunExecuting)
|
||
_ = st.save()
|
||
out, _, err := drv.failStepResume(ctx, st, i)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
e.finalizeEvent(out)
|
||
return out, nil
|
||
}
|
||
}
|
||
return e.cancelRun(st, "权限被拒绝且步骤不可定位"), nil
|
||
default:
|
||
return e.cancelRun(st, "人工中止(权限审批)"), nil
|
||
}
|
||
case HitlQualityReview:
|
||
switch strings.ToLower(decision.Choice) {
|
||
case "approve":
|
||
return e.manualPass(st, decision)
|
||
case "retry":
|
||
// 追加一次修复预算并重跑交付步骤。
|
||
st.RepairBudgetRemaining++
|
||
st.Meta.Status = string(RunExecuting)
|
||
_ = st.save()
|
||
deliverStepID := lastCompletedStepID(st)
|
||
out, _, err := drv.rerunForRepair(ctx, st, deliverStepID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
e.finalizeEvent(out)
|
||
return out, nil
|
||
default:
|
||
st.Meta.Status = string(RunFailed)
|
||
st.Meta.CompletedAt = &now
|
||
wf := st.Workflow()
|
||
wf.Status = "failed"
|
||
st.setWorkflow(wf)
|
||
_ = st.save()
|
||
e.deps.Sink.Emit(Event{Type: EvRunFailed, RunID: runID, Text: "人工裁决质检失败", At: time.Now()})
|
||
return &RunResult{RunID: runID, Status: RunFailed, Plan: planPtr(st),
|
||
Error: "quality review rejected by human", WorkDir: st.Meta.WorkDir}, nil
|
||
}
|
||
default:
|
||
return nil, fmt.Errorf("unsupported request kind %q", req.Kind)
|
||
}
|
||
}
|
||
|
||
// respondClarification 澄清回应:答案并入目标后重新规划。
|
||
func (e *SquadEngine) respondClarification(ctx context.Context, st *runState, gate *hitlGate, decision RespondDecision) (*RunResult, error) {
|
||
choice := strings.ToLower(decision.Choice)
|
||
if choice == "abort" {
|
||
return e.cancelRun(st, "人工中止(澄清阶段)"), nil
|
||
}
|
||
answer := strings.TrimSpace(decision.Answer)
|
||
if answer == "" && len(gate.Request.Options) > 0 {
|
||
// 无自由文本时尝试取选项 Label。
|
||
for _, o := range gate.Request.Options {
|
||
if strings.EqualFold(o.Value, decision.Choice) {
|
||
answer = o.Label
|
||
}
|
||
}
|
||
}
|
||
if answer != "" {
|
||
st.Clarifications = append(st.Clarifications, answer)
|
||
st.Meta.Goal.Text = st.Meta.Goal.Text + "\n【人工澄清】" + answer
|
||
if st.Meta.WorkDir != "" {
|
||
_ = e.ws.UpdateGoalFile(st.Meta.WorkDir, &st.Meta.Goal)
|
||
}
|
||
}
|
||
if choice == "" || choice == "continue" || choice == "minimal" {
|
||
if choice == "minimal" {
|
||
// 人工选择最小编制:引导注册 4 个通用角色(backend 用运行默认)。
|
||
e.bootstrapMinimalSquad(st)
|
||
}
|
||
st.Meta.Status = string(RunPlanning)
|
||
_ = st.save()
|
||
runOpts := RunOptions{TaskKey: st.Meta.Metadata["task_key"]}
|
||
return e.planAndExecute(ctx, st, runOpts)
|
||
}
|
||
return e.cancelRun(st, "人工中止(澄清阶段)"), nil
|
||
}
|
||
|
||
// bootstrapMinimalSquad 注册通用最小编制(organizer/specialist/writer/leader)。
|
||
func (e *SquadEngine) bootstrapMinimalSquad(st *runState) {
|
||
backend := st.Meta.Backend
|
||
if backend == "" {
|
||
backend = e.opts.DefaultBackend
|
||
}
|
||
for _, spec := range []RoleSpec{
|
||
{Role: "organizer", Name: "梳理专员", Backend: backend, Keywords: []string{"整理", "梳理", "事实"},
|
||
Responsibilities: []string{"梳理目标背景与输入,回填事实与缺口"}},
|
||
{Role: "specialist", Name: "专项专员", Backend: backend, Keywords: []string{"分析", "推演", "方案"},
|
||
Responsibilities: []string{"围绕目标开展专业分析与推演"}},
|
||
{Role: "writer", Name: "撰写专员", Backend: backend, Keywords: []string{"撰写", "成文", "报告"},
|
||
Responsibilities: []string{"汇总产出并撰写交付物"}},
|
||
{Role: "leader", Name: "审核专员", Backend: backend, Keywords: []string{"审核", "终审", "把关"},
|
||
Responsibilities: []string{"一致性终审与交付把关"}},
|
||
} {
|
||
_ = e.opts.Registry.Register(spec)
|
||
}
|
||
}
|
||
|
||
// manualPass 人工放行质检:标记 ManualPass 并收口。
|
||
func (e *SquadEngine) manualPass(st *runState, decision RespondDecision) (*RunResult, error) {
|
||
report := e.deps.Checker.Check(CheckRequest{
|
||
RunID: st.Meta.ID, Workspace: st.Meta.WorkDir, Deliverable: "output.md",
|
||
Plan: planPtr(st), Squad: &st.Plan.Squad, Template: st.Template,
|
||
})
|
||
report.ManualPass = true
|
||
note := strings.TrimSpace(decision.Answer)
|
||
if note != "" {
|
||
report.Violations = append(report.Violations, "人工放行说明:"+note)
|
||
}
|
||
status := RunCompleted
|
||
if countStatus(st, "failed") > 0 || countStatus(st, "skipped") > 0 {
|
||
status = RunDegraded
|
||
}
|
||
now := time.Now()
|
||
st.Meta.Status = string(status)
|
||
st.Meta.CompletedAt = &now
|
||
wf := st.Workflow()
|
||
wf.Status = string(status)
|
||
st.setWorkflow(wf)
|
||
_ = st.save()
|
||
manifest := buildManifest(st, "output.md", *report)
|
||
_ = writeJSON(filepath.Join(st.Meta.WorkDir, "manifest.json"), manifest)
|
||
e.deps.Sink.Emit(Event{Type: EvRunCompleted, RunID: st.Meta.ID, Text: string(status), At: time.Now()})
|
||
return &RunResult{RunID: st.Meta.ID, Status: status, Plan: planPtr(st),
|
||
Deliverable: "output.md", Quality: report, WorkDir: st.Meta.WorkDir,
|
||
Summary: summarize(st, *report, status) + "(质检人工放行)"}, nil
|
||
}
|
||
|
||
func (e *SquadEngine) cancelRun(st *runState, reason string) *RunResult {
|
||
now := time.Now()
|
||
st.Meta.Status = string(RunCancelled)
|
||
st.Meta.CompletedAt = &now
|
||
wf := st.Workflow()
|
||
wf.Status = "cancelled"
|
||
st.setWorkflow(wf)
|
||
_ = st.save()
|
||
e.deps.Sink.Emit(Event{Type: EvRunCancelled, RunID: st.Meta.ID, Text: reason, At: time.Now()})
|
||
return &RunResult{RunID: st.Meta.ID, Status: RunCancelled, Plan: planPtr(st),
|
||
Summary: reason, WorkDir: st.Meta.WorkDir}
|
||
}
|
||
|
||
// failStepResume 权限拒绝后的失败步骤处理(关键失败/非关键降级)。
|
||
func (d *flowDriver) failStepResume(ctx context.Context, st *runState, idx int) (*RunResult, *hitlGate, error) {
|
||
return d.failStep(ctx, st, idx)
|
||
}
|
||
|
||
// Resume 恢复运行:有挂起则返回挂起状态;未完成则继续推进(重启恢复入口)。
|
||
func (e *SquadEngine) Resume(ctx context.Context, runID string) (*RunResult, error) {
|
||
lock := e.runLock(runID)
|
||
lock.Lock()
|
||
defer lock.Unlock()
|
||
|
||
wsDir := filepath.Join(e.opts.DataDir, "runs", runID)
|
||
st, err := loadRunState(wsDir)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("load run %s: %w", runID, err)
|
||
}
|
||
if gate, _ := loadGate(wsDir); gate != nil && gate.Request.Status == "pending" {
|
||
return &RunResult{RunID: runID, Status: RunStatus(st.Meta.Status), Plan: planPtr(st),
|
||
Pending: &gate.Request, WorkDir: st.Meta.WorkDir}, nil
|
||
}
|
||
if RunStatus(st.Meta.Status).Terminal() {
|
||
return e.Inspect(runID)
|
||
}
|
||
return e.resumeAfterHold(ctx, st)
|
||
}
|
||
|
||
// Inspect 查看运行当前状态(含挂起请求与黑板)。
|
||
func (e *SquadEngine) Inspect(runID string) (*RunResult, error) {
|
||
wsDir := filepath.Join(e.opts.DataDir, "runs", runID)
|
||
st, err := loadRunState(wsDir)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("load run %s: %w", runID, err)
|
||
}
|
||
out := &RunResult{
|
||
RunID: runID, Status: RunStatus(st.Meta.Status), Plan: planPtr(st),
|
||
Squad: &st.Plan.Squad, WorkDir: st.Meta.WorkDir, Summary: st.Meta.Status,
|
||
}
|
||
if gate, _ := loadGate(wsDir); gate != nil {
|
||
out.Pending = &gate.Request
|
||
}
|
||
if data, err := os.ReadFile(filepath.Join(wsDir, "output.md")); err == nil && len(data) > 0 {
|
||
out.Deliverable = "output.md"
|
||
}
|
||
if data, err := os.ReadFile(filepath.Join(wsDir, "manifest.json")); err == nil {
|
||
var m Manifest
|
||
if json.Unmarshal(data, &m) == nil {
|
||
out.Quality = &m.QualityReport
|
||
}
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// Blackboard 查看运行黑板。
|
||
func (e *SquadEngine) Blackboard(runID string) (*BlackboardState, error) {
|
||
wsDir := filepath.Join(e.opts.DataDir, "runs", runID)
|
||
return loadBlackboard(wsDir)
|
||
}
|
||
|
||
// ListRuns 列出全部运行(按创建时间倒序)。
|
||
func (e *SquadEngine) ListRuns() ([]RunMetadata, error) {
|
||
runsDir := filepath.Join(e.opts.DataDir, "runs")
|
||
entries, err := os.ReadDir(runsDir)
|
||
if err != nil {
|
||
if os.IsNotExist(err) {
|
||
return nil, nil
|
||
}
|
||
return nil, err
|
||
}
|
||
var out []RunMetadata
|
||
for _, ent := range entries {
|
||
if !ent.IsDir() {
|
||
continue
|
||
}
|
||
st, err := loadRunState(filepath.Join(runsDir, ent.Name()))
|
||
if err != nil {
|
||
continue
|
||
}
|
||
out = append(out, st.Meta)
|
||
}
|
||
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) })
|
||
return out, nil
|
||
}
|
||
|
||
// Pending 返回运行当前挂起请求(无则 nil)。
|
||
func (e *SquadEngine) Pending(runID string) (*HumanRequest, error) {
|
||
gate, err := loadGate(filepath.Join(e.opts.DataDir, "runs", runID))
|
||
if err != nil || gate == nil {
|
||
return nil, err
|
||
}
|
||
return &gate.Request, nil
|
||
}
|
||
|
||
// Steer 向运行中的任务追加人工补充指令:在下一次步骤边界注入当前运行的
|
||
// prompt(步骤间注入,不中断正在执行的步骤),落盘 steering.json。
|
||
func (e *SquadEngine) Steer(runID, text string) error {
|
||
if strings.TrimSpace(text) == "" {
|
||
return fmt.Errorf("steering text is empty")
|
||
}
|
||
lock := e.runLock(runID)
|
||
lock.Lock()
|
||
defer lock.Unlock()
|
||
|
||
st, err := loadRunState(filepath.Join(e.opts.DataDir, "runs", runID))
|
||
if err != nil {
|
||
return fmt.Errorf("load run %s: %w", runID, err)
|
||
}
|
||
if RunStatus(st.Meta.Status).Terminal() {
|
||
return fmt.Errorf("run %s already terminal (%s)", runID, st.Meta.Status)
|
||
}
|
||
st.Steering = append(st.Steering, strings.TrimSpace(text))
|
||
_ = writeJSON(filepath.Join(st.Meta.WorkDir, "steering.json"), st.Steering)
|
||
e.deps.Sink.Emit(Event{Type: "steered", RunID: runID, Text: truncate(text, 80), At: time.Now()})
|
||
return nil
|
||
}
|
||
|
||
// SetBlackboardField 写入黑板字段(外部事实注入;可满足 field_set 等待条件)。
|
||
func (e *SquadEngine) SetBlackboardField(runID, key, value string) error {
|
||
if strings.TrimSpace(key) == "" {
|
||
return fmt.Errorf("field key is empty")
|
||
}
|
||
lock := e.runLock(runID)
|
||
lock.Lock()
|
||
defer lock.Unlock()
|
||
|
||
st, err := loadRunState(filepath.Join(e.opts.DataDir, "runs", runID))
|
||
if err != nil {
|
||
return fmt.Errorf("load run %s: %w", runID, err)
|
||
}
|
||
st.BB.Fields[key] = value
|
||
if err := saveBlackboard(st.Meta.WorkDir, &st.BB); err != nil {
|
||
return err
|
||
}
|
||
e.deps.Sink.Emit(Event{Type: "blackboard_field_set", RunID: runID,
|
||
Text: key + "=" + truncate(value, 60), At: time.Now()})
|
||
return nil
|
||
}
|
||
|
||
// SweepWakeups 扫描所有挂起运行:等待条件(run_done/field_set)满足时
|
||
// 合成自动决策续跑。护栏:同一请求自动唤醒次数达 MaxWakeupFires 即自动中止。
|
||
// 由 serve 模式周期调用,也可在任何运行终态后手动调用。返回唤醒数。
|
||
func (e *SquadEngine) SweepWakeups(ctx context.Context) int {
|
||
runs, err := e.ListRuns()
|
||
if err != nil {
|
||
return 0
|
||
}
|
||
woken := 0
|
||
for _, meta := range runs {
|
||
if !RunStatus(meta.Status).IsHeld() {
|
||
continue
|
||
}
|
||
gate, err := loadGate(filepath.Join(e.opts.DataDir, "runs", meta.ID))
|
||
if err != nil || gate == nil || gate.Request.Status != "pending" || gate.Request.WaitCondition == nil {
|
||
continue
|
||
}
|
||
cond := gate.Request.WaitCondition
|
||
met, fact := e.evaluateWaitCondition(meta.ID, cond)
|
||
if !met {
|
||
continue
|
||
}
|
||
gate.Request.WakeupFires++
|
||
if gate.Request.WakeupFires >= MaxWakeupFires {
|
||
// 护栏耗尽:自动中止,不无限自动续跑。
|
||
_, _ = e.Respond(ctx, meta.ID, RespondDecision{RequestID: gate.Request.ID, Choice: "abort",
|
||
Answer: "自动唤醒次数达上限,自动中止"})
|
||
woken++
|
||
continue
|
||
}
|
||
_ = writeJSON(filepath.Join(e.opts.DataDir, "runs", meta.ID, "pending.json"), gate)
|
||
decision := RespondDecision{RequestID: gate.Request.ID, Answer: "[自动唤醒] " + fact}
|
||
switch gate.Request.Kind {
|
||
case HitlConfirmation, HitlPermission, HitlQualityReview:
|
||
decision.Choice = "approve" // 事实型条件满足视为放行依据
|
||
default:
|
||
decision.Choice = "resolved"
|
||
}
|
||
if _, err := e.Respond(ctx, meta.ID, decision); err == nil {
|
||
woken++
|
||
e.deps.Sink.Emit(Event{Type: "wakeup_fired", RunID: meta.ID, Text: fact, At: time.Now()})
|
||
}
|
||
}
|
||
return woken
|
||
}
|
||
|
||
// evaluateWaitCondition 评估等待条件;返回是否满足与事实描述。
|
||
func (e *SquadEngine) evaluateWaitCondition(runID string, cond *WaitCondition) (bool, string) {
|
||
switch cond.Type {
|
||
case "run_done":
|
||
if cond.RunID == "" || cond.RunID == runID {
|
||
return false, "" // 自引用不触发
|
||
}
|
||
target, err := e.Inspect(cond.RunID)
|
||
if err != nil {
|
||
return false, ""
|
||
}
|
||
if target.Status.Terminal() {
|
||
return true, fmt.Sprintf("等待的运行 %s 已进入终态 %s(唤醒报告事实,非业务完成)", cond.RunID, target.Status)
|
||
}
|
||
case "field_set":
|
||
if cond.Field == "" {
|
||
return false, ""
|
||
}
|
||
bb, err := e.Blackboard(runID)
|
||
if err != nil {
|
||
return false, ""
|
||
}
|
||
if v, ok := bb.Fields[cond.Field]; ok && strings.TrimSpace(v) != "" {
|
||
return true, fmt.Sprintf("黑板字段 %s 已设置:%s", cond.Field, truncate(v, 80))
|
||
}
|
||
}
|
||
return false, ""
|
||
}
|
||
|
||
func (e *SquadEngine) finalizeEvent(out *RunResult) {
|
||
if out == nil {
|
||
return
|
||
}
|
||
switch out.Status {
|
||
case RunCompleted, RunDegraded:
|
||
e.deps.Sink.Emit(Event{Type: EvRunCompleted, RunID: out.RunID, Text: out.Summary, At: time.Now()})
|
||
case RunFailed:
|
||
e.deps.Sink.Emit(Event{Type: EvRunFailed, RunID: out.RunID, Text: out.Error, At: time.Now()})
|
||
}
|
||
}
|
||
|
||
// ---- 小工具 ----
|
||
|
||
func sha256File(path string) string {
|
||
data, err := os.ReadFile(path)
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
sum := sha256.Sum256(data)
|
||
return hex.EncodeToString(sum[:])
|
||
}
|
||
|
||
func marshalJSON(v any) json.RawMessage {
|
||
data, err := json.Marshal(v)
|
||
if err != nil {
|
||
return json.RawMessage(`{}`)
|
||
}
|
||
return data
|
||
}
|
||
|
||
func bpPtr(bp Blueprint) *Blueprint {
|
||
if bp.Key == "" {
|
||
return nil
|
||
}
|
||
return &bp
|
||
}
|
||
|
||
func planPtr2(p *SquadPlan) *WorkflowPlan {
|
||
wf := p.Workflow
|
||
return &wf
|
||
}
|
||
|
||
func answerFor(g GapItem, answer string) string {
|
||
if answer == "" {
|
||
return "人工确认按现状继续"
|
||
}
|
||
return answer
|
||
}
|
||
|
||
func appendAnswer(d RespondDecision) string {
|
||
if strings.TrimSpace(d.Answer) == "" {
|
||
return ""
|
||
}
|
||
return "(" + d.Answer + ")"
|
||
}
|
||
|
||
func lastCompletedStepID(st *runState) string {
|
||
id := ""
|
||
for _, s := range st.Plan.Workflow.Steps {
|
||
if s.Status == "completed" {
|
||
id = s.ID
|
||
}
|
||
}
|
||
return id
|
||
}
|