Files
weichen 6d76111a16
ci / vet-and-test (pull_request) Successful in 24s
ci / docker-checkout-hostgateway (pull_request) Successful in 0s
Add leader progress ledger, replan cap, and mailbox with actor attribution (Q2 WP2)
Why: Q2 acceptance requires leader runs to detect stuck delegation loops and
cap replans instead of spinning to the turn limit, and a mailbox primitive
whose messages can never resolve a pending permission approval (acceptance 3).

Evidence: squad conformance tests (leader_mailbox_conformance_test.go):
ledger stuck->replan, replan cap finalize, mailbox delivery after hold,
mailbox-cannot-approve-permission (structural + actor second defense),
post guards; full suite green with -race.

Impact: LeaderState gains Progress/StuckTurns/Replans/MaxReplans/ReplanNote
(persisted in leader.json); RunState gains Mailbox (persisted in
runstate.json); RespondDecision gains Actor - non-human actors are rejected
on choice gates; step briefs and leader task briefs include pending mail.

Refs: Q2 plan WP2 (2026-10-07-agent-stack-q2q3-plans.md); task card
.task/feature-agent-stack-q2.md
2026-10-08 00:34:46 +00:00

178 lines
6.8 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"`
// Actor 决议发起方(Q2):"" 或 human = 人工(默认,兼容既有调用方);
// agent/system = 非人工。非人工 actor 的 choice 决议在
// permission/confirmation/quality_review 门上一律被拒绝(验收三)。
Actor string `json:"actor,omitempty"`
}
// Actor 归属常量。
const (
ActorHuman = "human"
ActorAgent = "agent"
ActorSystem = "system"
)
// 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
}