Why: Q2 acceptance 2 requires the squad review topology to be equivalent to today's backend runReview flow (parallel finders, per-finding verification, drop unverified); handoff and group_chat complete 11 of 12 builtin topologies. Evidence: wp3_conformance_test.go mirrors backend review_test.go assertions (true finder concurrency via release gate, verify calls = 5 unique after dedupe, only PASS kept, dropped-count line, severity order); handoff records ownership transfer, target receives filtered history, finalize takes chain-tail output, unknown target role fails closed; group-chat rotate / close-marker / moderator conformance with budget stop and fail-closed unknown nominee. Full suite green with -race. Impact: review/group_chat drivers lit in the topology registry (task_board remains fail-closed until WP4); RunState gains Handoffs (persisted); RunOptions gains GroupChat*/Review* knobs; runOneStep validates handoff targets and injects handoff context; finalize prefers chain-tail deliverable. Parse/dedupe/verdict/severity/report formats ported verbatim from backend internal/runtime/review.go for byte-level report equivalence. Refs: Q2 plan WP3 (2026-10-07-agent-stack-q2q3-plans.md); task card .task/feature-agent-stack-q2.md
356 lines
12 KiB
Go
356 lines
12 KiB
Go
package squad
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// groupchat.go — group_chat 拓扑驱动(Q2 WP3)。
|
||
// 形态(计划 §三 WP3 与边界 §五):共享转录(BB 字段 transcript +
|
||
// group_chat/transcript.md 全量落盘);发言人策略只做**轮转**与**主持人**
|
||
// 两种(脚本可判定,LLM 选人策略不进库);预算停机(max_turns,缺省 8)。
|
||
// 蓝图步骤 = 参与者名册(按序,不直接执行,driver 标记 skipped 并派生轮次
|
||
// 步骤);任一发言含 [close] 标记即收口;主持人模式下主持人输出 JSON 决策
|
||
// ({"next":"role"} / {"action":"close"}),未知角色/非法 JSON fail-closed。
|
||
// 驱动幂等可恢复:轮次由已落盘的 gc 步骤数重建,挂起(权限/HITL)后
|
||
// Respond 重入续转。
|
||
|
||
func init() {
|
||
_ = RegisterTopologyDriver(TopologyGroupChat)
|
||
}
|
||
|
||
// DefaultMaxGroupChatTurns 群聊轮次预算缺省值(预算停机)。
|
||
const DefaultMaxGroupChatTurns = 8
|
||
|
||
// gcRosterSkipReason 名册步骤的 skipped 原因(非执行步骤,不算降级)。
|
||
const gcRosterSkipReason = "group_chat: 名册步骤(参与者由驱动接管)"
|
||
|
||
// gcCloseMarker 任一发言包含该标记(大小写不敏感)即收口。
|
||
const gcCloseMarker = "[close]"
|
||
|
||
// gcTranscriptCapBB 是 BB 转录字段的容量上限(TASK.md 快照只带近端)。
|
||
const gcTranscriptCapBB = 4000
|
||
|
||
// runGroupChatDriver group_chat 拓扑入口。
|
||
func (e *SquadEngine) runGroupChatDriver(ctx context.Context, st *RunState) (*RunResult, error) {
|
||
drv := &flowDriver{opts: e.deps}
|
||
|
||
// 名册:首次进入从蓝图步骤取参与者序列并持久化到元数据(恢复时直读)。
|
||
roster, err := e.gcRoster(st)
|
||
if err != nil {
|
||
return e.driverFail(st, err.Error()), nil
|
||
}
|
||
policy := orDefault(st.Meta.Metadata["group_chat_policy"], "rotate")
|
||
moderator := st.Meta.Metadata["group_chat_moderator"]
|
||
if policy != "rotate" && policy != "moderator" {
|
||
return e.driverFail(st, fmt.Sprintf("group_chat: 未知发言人策略 %q(只支持 rotate/moderator)", policy)), nil
|
||
}
|
||
if policy == "moderator" {
|
||
if moderator == "" || !containsString(roster, moderator) {
|
||
return e.driverFail(st, fmt.Sprintf("group_chat: 主持人 %q 不在名册内(fail-closed)", moderator)), nil
|
||
}
|
||
}
|
||
maxTurns := DefaultMaxGroupChatTurns
|
||
if v := st.Meta.Metadata["group_chat_max_turns"]; v != "" {
|
||
if n := parseIntDefault(v, DefaultMaxGroupChatTurns); n > 0 {
|
||
maxTurns = n
|
||
}
|
||
}
|
||
|
||
// 名册步骤标记 skipped(首次进入时;恢复重入无 pending 名册步骤)。
|
||
st.mu.Lock()
|
||
for i := range st.Plan.Workflow.Steps {
|
||
s := &st.Plan.Workflow.Steps[i]
|
||
if !strings.HasPrefix(s.ID, "gc") && (s.Status == "pending" || s.Status == "running") {
|
||
s.Status = "skipped"
|
||
s.Error = gcRosterSkipReason
|
||
}
|
||
}
|
||
_ = st.save()
|
||
st.mu.Unlock()
|
||
|
||
for {
|
||
// 恢复重入:上次挂起留下的未完 gc 轮次步骤优先续跑(不重新选人)。
|
||
if idx := e.gcHeldStepIdx(st); idx >= 0 {
|
||
out, gate, err := e.gcRunStep(ctx, drv, st, idx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if gate != nil {
|
||
if out == nil {
|
||
out = heldResult(st, gate)
|
||
}
|
||
return out, nil
|
||
}
|
||
if out != nil {
|
||
return out, nil
|
||
}
|
||
if e.gcMessageClosed(st, idx) {
|
||
drv.emit(st, Event{Type: EvPhase, Text: fmt.Sprintf("group_chat:%s 发出收口标记,停机", st.Plan.Workflow.Steps[idx].Role)})
|
||
break
|
||
}
|
||
continue
|
||
}
|
||
turns := e.gcTurnCount(st)
|
||
if turns >= maxTurns {
|
||
drv.emit(st, Event{Type: EvPhase, Text: fmt.Sprintf("group_chat:轮次预算 %d 用尽,停机收口", maxTurns)})
|
||
break
|
||
}
|
||
speaker, closed, derr := e.gcNextSpeaker(st, roster, policy, moderator, turns)
|
||
if derr != nil {
|
||
return e.driverFail(st, derr.Error()), nil
|
||
}
|
||
if closed {
|
||
drv.emit(st, Event{Type: EvPhase, Text: "group_chat:主持人收口"})
|
||
break
|
||
}
|
||
out, gate, err := e.gcRunTurn(ctx, drv, st, turns, speaker)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if gate != nil {
|
||
if out == nil {
|
||
out = heldResult(st, gate)
|
||
}
|
||
return out, nil
|
||
}
|
||
if out != nil { // 轮次步骤失败(关键)→ fail-closed 传播
|
||
return out, nil
|
||
}
|
||
if e.gcMessageClosed(st, len(st.Plan.Workflow.Steps)-1) {
|
||
drv.emit(st, Event{Type: EvPhase, Text: fmt.Sprintf("group_chat:%s 发出收口标记,停机", speaker)})
|
||
break
|
||
}
|
||
}
|
||
return e.gcFinish(st, drv), nil
|
||
}
|
||
|
||
// gcRoster 参与者名册(首次从蓝图步骤派生并写入元数据;恢复直读)。
|
||
func (e *SquadEngine) gcRoster(st *RunState) ([]string, error) {
|
||
if v := st.Meta.Metadata["group_chat_roster"]; v != "" {
|
||
return strings.Split(v, ","), nil
|
||
}
|
||
var roster []string
|
||
for _, s := range st.Plan.Workflow.Steps {
|
||
if s.Role != "" && !containsString(roster, s.Role) {
|
||
roster = append(roster, s.Role)
|
||
}
|
||
}
|
||
if len(roster) == 0 {
|
||
return nil, fmt.Errorf("group_chat: 名册为空(蓝图步骤即参与者)")
|
||
}
|
||
st.mu.Lock()
|
||
if st.Meta.Metadata == nil {
|
||
st.Meta.Metadata = map[string]string{}
|
||
}
|
||
st.Meta.Metadata["group_chat_roster"] = strings.Join(roster, ",")
|
||
_ = st.save()
|
||
st.mu.Unlock()
|
||
return roster, nil
|
||
}
|
||
|
||
// gcTurnCount 已完成的轮次数(预算与轮转序号的基准;恢复重入自然续算)。
|
||
func (e *SquadEngine) gcTurnCount(st *RunState) int {
|
||
n := 0
|
||
for _, s := range st.Plan.Workflow.Steps {
|
||
if strings.HasPrefix(s.ID, "gc") && s.Status == "completed" {
|
||
n++
|
||
}
|
||
}
|
||
return n
|
||
}
|
||
|
||
// gcHeldStepIdx 返回上次挂起留下的未完 gc 轮次步骤下标(无则 -1)。
|
||
func (e *SquadEngine) gcHeldStepIdx(st *RunState) int {
|
||
for i := range st.Plan.Workflow.Steps {
|
||
s := &st.Plan.Workflow.Steps[i]
|
||
if strings.HasPrefix(s.ID, "gc") && (s.Status == "pending" || s.Status == "running") {
|
||
return i
|
||
}
|
||
}
|
||
return -1
|
||
}
|
||
|
||
// gcNextSpeaker 决定下一发言人。rotate = 轮转;moderator = 主持人裁决
|
||
// (上一步是参与者发言 → 主持人接手;主持人决策 close → 收口,next → 指名,
|
||
// 非法 JSON / 未知角色 fail-closed)。
|
||
func (e *SquadEngine) gcNextSpeaker(st *RunState, roster []string, policy, moderator string, turns int) (string, bool, error) {
|
||
if policy == "rotate" {
|
||
return roster[turns%len(roster)], false, nil
|
||
}
|
||
last := e.gcLastStep(st)
|
||
if last == nil || last.Role == moderator {
|
||
// 首轮或上一步就是主持人(不该连续,防御性让主持人接手)。
|
||
if last != nil && last.Role == moderator {
|
||
// 解析主持人决策。
|
||
dec, err := e.gcParseDecision(st, last)
|
||
if err != nil {
|
||
return "", false, err
|
||
}
|
||
if dec.Action == "close" {
|
||
return "", true, nil
|
||
}
|
||
if !containsString(roster, dec.Next) {
|
||
return "", false, fmt.Errorf("group_chat: 主持人指名 %q 不在名册内(fail-closed)", dec.Next)
|
||
}
|
||
return dec.Next, false, nil
|
||
}
|
||
return moderator, false, nil
|
||
}
|
||
// 上一步是参与者发言 → 主持人接手裁决。
|
||
return moderator, false, nil
|
||
}
|
||
|
||
type gcDecision struct {
|
||
Action string `json:"action"`
|
||
Next string `json:"next"`
|
||
}
|
||
|
||
// gcParseDecision 宽松解析主持人 message.md 里的 JSON 决策(首 `{` 末 `}`)。
|
||
func (e *SquadEngine) gcParseDecision(st *RunState, step *StepSpec) (*gcDecision, error) {
|
||
msg := e.gcMessageOf(st, step)
|
||
start := strings.Index(msg, "{")
|
||
end := strings.LastIndex(msg, "}")
|
||
if start < 0 || end <= start {
|
||
return nil, fmt.Errorf("group_chat: 主持人输出无 JSON 决策(fail-closed):%q", truncateStr(msg, 80))
|
||
}
|
||
var dec gcDecision
|
||
if err := json.Unmarshal([]byte(msg[start:end+1]), &dec); err != nil {
|
||
return nil, fmt.Errorf("group_chat: 主持人决策解析失败(fail-closed): %v", err)
|
||
}
|
||
if dec.Action == "close" {
|
||
return &dec, nil
|
||
}
|
||
if strings.TrimSpace(dec.Next) == "" {
|
||
return nil, fmt.Errorf("group_chat: 主持人决策缺 next/action(fail-closed)")
|
||
}
|
||
return &dec, nil
|
||
}
|
||
|
||
// gcRunTurn 派生一轮发言步骤并执行。
|
||
func (e *SquadEngine) gcRunTurn(ctx context.Context, drv *flowDriver, st *RunState, turns int, speaker string) (*RunResult, *HitlGate, error) {
|
||
step := StepSpec{
|
||
ID: fmt.Sprintf("gc%02d", turns+1),
|
||
Role: speaker,
|
||
Title: fmt.Sprintf("群聊第 %d 轮:%s 发言(把发言写入 message.md;同意收口就写 [close])", turns+1, speaker),
|
||
Targets: []string{"message.md"},
|
||
SubWorkspace: fmt.Sprintf("group_chat/turn_%02d_%s", turns+1, speaker),
|
||
Status: "pending",
|
||
}
|
||
st.mu.Lock()
|
||
st.Plan.Workflow.Steps = append(st.Plan.Workflow.Steps, step)
|
||
idx := len(st.Plan.Workflow.Steps) - 1
|
||
_ = st.save()
|
||
st.mu.Unlock()
|
||
return e.gcRunStep(ctx, drv, st, idx)
|
||
}
|
||
|
||
// gcRunStep 执行一个轮次步骤:过门 → runOneStep → 转录并入共享状态。
|
||
// 挂起(gate)时步骤保持未完,恢复重入经 gcHeldStepIdx 续跑同一步骤。
|
||
func (e *SquadEngine) gcRunStep(ctx context.Context, drv *flowDriver, st *RunState, idx int) (*RunResult, *HitlGate, error) {
|
||
out, gate, err := drv.gateBeforeRun(ctx, st, idx)
|
||
if err != nil || gate != nil || out != nil {
|
||
return out, gate, err
|
||
}
|
||
step := st.Plan.Workflow.Steps[idx]
|
||
drv.emit(st, Event{Type: EvPhase, StepID: step.ID, Role: step.Role,
|
||
Text: fmt.Sprintf("group_chat:%s 发言(第 %d 轮)", step.Role, e.gcTurnCount(st)+1)})
|
||
out, gate, err = drv.runOneStep(ctx, st, idx)
|
||
if err != nil || gate != nil || out != nil {
|
||
return out, gate, err
|
||
}
|
||
// 转录并入共享状态:全量落盘 transcript.md,近端进 BB 字段(下一位
|
||
// 发言人的 TASK.md 黑板快照可见)。
|
||
msg := e.gcMessageOf(st, &st.Plan.Workflow.Steps[idx])
|
||
e.gcAppendTranscript(st, e.gcTurnCount(st), step.Role, msg)
|
||
return nil, nil, nil
|
||
}
|
||
|
||
// gcAppendTranscript 追加转录(全量文件 + BB 近端快照)。
|
||
func (e *SquadEngine) gcAppendTranscript(st *RunState, turn int, speaker, msg string) {
|
||
dir := filepath.Join(st.Meta.WorkDir, "group_chat")
|
||
_ = os.MkdirAll(dir, 0o755)
|
||
line := fmt.Sprintf("%d. %s:%s\n", turn, speaker, strings.TrimSpace(msg))
|
||
f, _ := os.OpenFile(filepath.Join(dir, "transcript.md"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
||
if f != nil {
|
||
_, _ = f.WriteString(line)
|
||
_ = f.Close()
|
||
}
|
||
st.mu.Lock()
|
||
if st.BB.Fields == nil {
|
||
st.BB.Fields = map[string]string{}
|
||
}
|
||
cur := st.BB.Fields["transcript"] + line
|
||
if len(cur) > gcTranscriptCapBB {
|
||
cur = cur[len(cur)-gcTranscriptCapBB:]
|
||
}
|
||
st.BB.Fields["transcript"] = cur
|
||
_ = st.save()
|
||
st.mu.Unlock()
|
||
}
|
||
|
||
// gcLastStep 最后一个已完成的派生轮次步骤(主持人决策解析的锚点)。
|
||
func (e *SquadEngine) gcLastStep(st *RunState) *StepSpec {
|
||
for i := len(st.Plan.Workflow.Steps) - 1; i >= 0; i-- {
|
||
s := &st.Plan.Workflow.Steps[i]
|
||
if strings.HasPrefix(s.ID, "gc") && s.Status == "completed" {
|
||
return s
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// gcMessageOf 读取某轮的 message.md(缺文件 = 空串)。
|
||
func (e *SquadEngine) gcMessageOf(st *RunState, step *StepSpec) string {
|
||
data, err := os.ReadFile(filepath.Join(st.Meta.WorkDir, filepath.FromSlash(step.SubWorkspace), "message.md"))
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
return string(data)
|
||
}
|
||
|
||
// gcMessageClosed 第 idx 步的发言是否带收口标记。
|
||
func (e *SquadEngine) gcMessageClosed(st *RunState, idx int) bool {
|
||
if idx < 0 || idx >= len(st.Plan.Workflow.Steps) {
|
||
return false
|
||
}
|
||
msg := e.gcMessageOf(st, &st.Plan.Workflow.Steps[idx])
|
||
return strings.Contains(strings.ToLower(msg), gcCloseMarker)
|
||
}
|
||
|
||
// gcFinish 收口:交付物 = 全量转录;写 output.md;状态落盘;构造结果。
|
||
func (e *SquadEngine) gcFinish(st *RunState, drv *flowDriver) *RunResult {
|
||
transcript := ""
|
||
if data, err := os.ReadFile(filepath.Join(st.Meta.WorkDir, "group_chat", "transcript.md")); err == nil {
|
||
transcript = string(data)
|
||
}
|
||
report := "# 群聊转录\n\n" + transcript
|
||
_ = os.WriteFile(filepath.Join(st.Meta.WorkDir, "output.md"), []byte(report), 0o644)
|
||
turns := e.gcTurnCount(st)
|
||
st.mu.Lock()
|
||
wf := st.Workflow()
|
||
wf.Status = string(RunCompleted)
|
||
st.setWorkflow(wf)
|
||
st.Meta.Status = string(RunCompleted)
|
||
now := time.Now()
|
||
st.Meta.CompletedAt = &now
|
||
_ = st.save()
|
||
st.mu.Unlock()
|
||
summary := fmt.Sprintf("group_chat:%d 轮发言,转录已收口", turns)
|
||
drv.emit(st, Event{Type: EvPhase, Text: summary})
|
||
return &RunResult{RunID: st.Meta.ID, Status: RunCompleted, Plan: planPtr(st),
|
||
Deliverable: "output.md", Summary: summary, WorkDir: st.Meta.WorkDir}
|
||
}
|
||
|
||
func truncateStr(s string, n int) string {
|
||
if len(s) <= n {
|
||
return s
|
||
}
|
||
return s[:n] + "…"
|
||
}
|