Files
Agent-Squad/squad/hitl.go
T
weichenandCursor 9537321782 Close the squad P1 gaps the audit listed for this batch
Why: parallel groups dropped a hold, HITL timeouts were unused, the planner failed closed on any model error, and event sequence numbers restarted at 1.
Evidence: GOWORK=off go test -count=1 -race ./squad/ ./squadintent/ ./store/ ./platform/ passed, including TestR2ExpiredDecisionUsesCancel, TestR2IntentStepSkipsRoleRegistry, TestR2PlannerFallsBackAndCapsSteps, TestR2TaskFileUsesLatestBlackboard, TestR2FanoutCancelDoesNotPanic, TestR2EventSeqContinuesAfterRestart, TestR2JournalModeWAL, and TestR2GateReloadSeesNewIntent.
Impact: a failed or held parallel sibling cancels the rest; expired requests synthesize cancel or fail; the intent gate follows Registry.Reload.
Refs: Q6, Q10, Q11, Q12, Q13, Q17, Q18, Q19, I9
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 13:10:39 +00:00

167 lines
6.4 KiB
Go

package squad
import (
"crypto/rand"
"encoding/hex"
"time"
)
// HitlMode HITL 策略模式。
type HitlMode string
const (
// HitlInteractive 默认:挂起并等待人工回应。
HitlInteractive HitlMode = "interactive"
// HitlAuto 无值守:阻塞缺口按角色 fallback 假设兜底(未声明则步骤失败);
// 高风险决策(计划确认/权限升级)不静默猜测,仍转为失败并留痕。
HitlAuto HitlMode = "auto"
// HitlDisabled 不挂起:所有本应挂起的点直接按失败处理。
HitlDisabled HitlMode = "disabled"
)
// HitlPolicy HITL 策略(全局默认 → 角色覆盖 → Goal 覆盖,窄者胜)。
type HitlPolicy struct {
Mode HitlMode `json:"mode,omitempty"`
// TimeoutAfter 非零时,pending 请求超过该时长未回应自动过期;
// 过期动作由 ExpireAction 决定(fail / cancel)。
TimeoutAfter time.Duration `json:"timeout_after,omitempty"`
// ExpireAction 过期动作:fail(默认,运行失败留痕)/ cancel(运行取消)。
ExpireAction string `json:"expire_action,omitempty"`
}
func (p HitlPolicy) mode() HitlMode {
if p.Mode == "" {
return HitlInteractive
}
return p.Mode
}
// HumanRequestKind 人工请求类型。
type HumanRequestKind string
const (
HitlClarification HumanRequestKind = "clarification" // 规划歧义澄清
HitlConfirmation HumanRequestKind = "confirmation" // 计划/高风险步骤确认
HitlGap HumanRequestKind = "gap" // 阻塞缺口
HitlAdjustment HumanRequestKind = "adjustment" // 动态调整审批
HitlPermission HumanRequestKind = "permission" // 权限升级审批
HitlQualityReview HumanRequestKind = "quality_review" // 质检超限人工裁决
HitlEscalation HumanRequestKind = "escalation" // leader/成员升级问人(委派不了/需要拍板)
)
// Severity 人工请求紧急度(inbox 语义:action_required 需要人做决定,
// attention 建议关注;用于通知分层与降噪)。
const (
SeverityActionRequired = "action_required"
SeverityAttention = "attention"
)
// WaitCondition 挂起请求的自动唤醒条件(等事件而非只等人)。
// 条件满足时引擎合成一条自动决策续跑——唤醒报告的是事实,不是业务完成。
type WaitCondition struct {
Type string `json:"type"` // run_done | field_set
RunID string `json:"run_id,omitempty"` // run_done:等待该运行到达终态
Field string `json:"field,omitempty"` // field_set:等待黑板出现该字段
}
// MaxWakeupFires 单个挂起请求自动唤醒次数上限(防失控护栏)。
const MaxWakeupFires = 5
// HumanRequest 一次结构化人工请求(可持久化、可恢复、按 ID 幂等回应)。
type HumanRequest struct {
ID string `json:"id"`
RunID string `json:"run_id"`
StepID string `json:"step_id,omitempty"`
Kind HumanRequestKind `json:"kind"`
Severity string `json:"severity,omitempty"` // action_required(默认)/ attention
Question string `json:"question"`
// Reason 区分同一 Kind 的来源:plan、step_approval、interrupted。空值保持旧行为。
Reason string `json:"reason,omitempty"`
Options []HumanOption `json:"options,omitempty"`
Context string `json:"context,omitempty"` // 脱敏摘要(计划/缺口/质检报告)
// WaitCondition 非空时,条件满足可自动唤醒(人工回应仍然优先)。
WaitCondition *WaitCondition `json:"wait_condition,omitempty"`
// WakeupFires 已自动唤醒次数(护栏:达到 MaxWakeupFires 后过期)。
WakeupFires int `json:"wakeup_fires,omitempty"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
Status string `json:"status"` // pending, answered, expired, cancelled
AnsweredAt *time.Time `json:"answered_at,omitempty"`
}
// HumanOption 一个可选项;Value 为回应时提交的 choice。
type HumanOption struct {
Value string `json:"value"`
Label string `json:"label"`
}
// RespondDecision 对一次 HumanRequest 的人工决策。
type RespondDecision struct {
RequestID string `json:"request_id"`
// Answer 自由文本回答(clarification/gap)。
Answer string `json:"answer,omitempty"`
// Choice 选项值(confirmation/adjustment/permission/quality_review 常用):
// approve / reject / retry / skip / abort 等,或 HumanOption.Value。
Choice string `json:"choice,omitempty"`
// EditPlan 确认计划时的修改指令(附加在 Answer 上传递给引擎)。
EditPlan string `json:"edit_plan,omitempty"`
}
// newRequestID 生成请求 ID。
func newRequestID() string {
b := make([]byte, 8)
_, _ = rand.Read(b)
return "hr_" + hex.EncodeToString(b)
}
// expiredDecision 在请求超过 TimeoutAfter 或 ExpiresAt 时合成过期决策。
// ExpireAction 为 cancel 时选择 cancel,否则 fail。未到期返回 false。
func expiredDecision(req HumanRequest, policy HitlPolicy, now time.Time) (RespondDecision, bool) {
var deadline time.Time
switch {
case req.ExpiresAt != nil:
deadline = *req.ExpiresAt
case policy.TimeoutAfter > 0 && !req.CreatedAt.IsZero():
deadline = req.CreatedAt.Add(policy.TimeoutAfter)
default:
return RespondDecision{}, false
}
if now.Before(deadline) {
return RespondDecision{}, false
}
choice := "fail"
if policy.ExpireAction == "cancel" {
choice = "cancel"
}
return RespondDecision{RequestID: req.ID, Choice: choice, Answer: "expired"}, true
}
// hitlGate 引擎内部:一次挂起的完整上下文(落盘 pending.json)。
type hitlGate struct {
Request HumanRequest `json:"request"`
Plan *SquadPlan `json:"plan,omitempty"` // confirmation 时携带候选计划
Steps []StepSpec `json:"steps,omitempty"` // adjustment:建议追加的步骤
SkipIDs []string `json:"skip_ids,omitempty"` // adjustment:建议跳过的步骤
PrevStatus RunStatus `json:"prev_status"` // 回应后恢复到的状态
}
// mergeHitlPolicy 按全局 → 角色 → Goal 顺序取窄者(后设置者覆盖)。
func mergeHitlPolicy(base HitlPolicy, overrides ...*HitlPolicy) HitlPolicy {
out := base
for _, ov := range overrides {
if ov == nil {
continue
}
if ov.Mode != "" {
out.Mode = ov.Mode
}
if ov.TimeoutAfter != 0 {
out.TimeoutAfter = ov.TimeoutAfter
}
if ov.ExpireAction != "" {
out.ExpireAction = ov.ExpireAction
}
}
return out
}