U4 第二步:Squad 类型化 ProgressSink 端口,移除 internal/stream 依赖 #6

Merged
weichen merged 2 commits from feature/squad-progress-port-2026-09-29 into dev 2026-09-29 06:03:05 +00:00
9 changed files with 268 additions and 128 deletions
+2 -2
View File
@@ -330,10 +330,10 @@ func (s *Service) ListStages() []StageMeta {
} }
func (s *Service) InspectWorkspace(ctx context.Context, workDir string) (*squad.WorkspaceState, error) { func (s *Service) InspectWorkspace(ctx context.Context, workDir string) (*squad.WorkspaceState, error) {
return s.squad.InspectWorkspace(ctx, workDir) return s.squad.InspectWorkspace(withSquadProgress(ctx), workDir)
} }
// ResumeOrModifyWorkspace 动态调整任务并继续执行 // ResumeOrModifyWorkspace 动态调整任务并继续执行
func (s *Service) ResumeOrModifyWorkspace(ctx context.Context, req squad.ModifyWorkflowRequest) (*squad.SquadRunResult, error) { func (s *Service) ResumeOrModifyWorkspace(ctx context.Context, req squad.ModifyWorkflowRequest) (*squad.SquadRunResult, error) {
return s.squad.ResumeOrModifyWorkspace(ctx, req) return s.squad.ResumeOrModifyWorkspace(withSquadProgress(ctx), req)
} }
+1 -1
View File
@@ -706,7 +706,7 @@ func (s *Service) ExecuteTask(ctx context.Context, req TaskRunRequest) (*TaskRun
RunID: req.RunID, RunID: req.RunID,
} }
res, err := s.squad.ExecuteTask(ctx, squadReq) res, err := s.squad.ExecuteTask(withSquadProgress(ctx), squadReq)
if err != nil { if err != nil {
if res != nil { if res != nil {
out := &TaskRunResult{ out := &TaskRunResult{
+65
View File
@@ -0,0 +1,65 @@
package bizdev
import (
"context"
"chat-agent-system/internal/squad"
"chat-agent-system/internal/stream"
)
// squadProgressAdapter 把小队过程端口映射到产品 SSE 流(KTD1 装配侧)。
// 小队不感知 stream 包;SSE 事件形态、持久化与重放由产品侧全权负责。
type squadProgressAdapter struct {
streamer *stream.Streamer
}
func (a *squadProgressAdapter) EmitPhase(phaseID, message, role string, stepIndex, totalSteps, progress int) {
a.streamer.EmitPhase(phaseID, message, role, stepIndex, totalSteps, progress)
}
func (a *squadProgressAdapter) EmitDetail(p squad.DetailEvent) {
var tool *stream.ToolCallInfo
if p.Tool != nil {
tool = &stream.ToolCallInfo{
Name: p.Tool.Name,
Status: p.Tool.Status,
Input: p.Tool.Input,
Output: p.Tool.Output,
DurationMs: p.Tool.DurationMs,
}
}
a.streamer.Emit(stream.StreamEvent{Type: stream.EventDetail, Detail: &stream.DetailPayload{
Category: p.Category,
StepID: p.StepID,
Role: p.Role,
ItemID: p.ItemID,
Title: p.Title,
Content: p.Content,
Collapsible: p.Collapsible,
Delta: p.Delta,
Done: p.Done,
Tool: tool,
}})
}
func (a *squadProgressAdapter) EmitDeliverable(taskKey, title, filePath string, wordCount int, score float64) {
a.streamer.EmitDeliverable(taskKey, title, filePath, wordCount, score)
}
func (a *squadProgressAdapter) EmitSummary(title, content string, maturityScore int, maturityLevel string) {
a.streamer.EmitSummary(title, content, maturityScore, maturityLevel)
}
func (a *squadProgressAdapter) EmitBriefing(brief any) {
a.streamer.EmitBriefing(brief)
}
// withSquadProgress 在调用小队前装配过程端口:ctx 已有 Streamer 时注入适配器,
// 否则原样返回(小队对缺失端口全程 no-op,行为与旧版 streamer==nil 分支一致)。
func withSquadProgress(ctx context.Context) context.Context {
st := stream.FromContext(ctx)
if st == nil {
return ctx
}
return squad.WithProgress(ctx, &squadProgressAdapter{streamer: st})
}
+22 -23
View File
@@ -7,7 +7,6 @@ import (
"time" "time"
"chat-agent-system/internal/agent" "chat-agent-system/internal/agent"
"chat-agent-system/internal/stream"
) )
// 过程流常量:节流窗口 / 单帧上限 / 工具文本截断(设计文档 F.1)。 // 过程流常量:节流窗口 / 单帧上限 / 工具文本截断(设计文档 F.1)。
@@ -64,7 +63,7 @@ func (d *deltaStream) finish() {
// processBridge 把 agent.ProcessEvent 翻译为 stream.EventDetail 增量帧。 // processBridge 把 agent.ProcessEvent 翻译为 stream.EventDetail 增量帧。
// 每个 step(小队成员的一次执行)独立持有一个 bridge;无跨 step 共享状态,并行安全。 // 每个 step(小队成员的一次执行)独立持有一个 bridge;无跨 step 共享状态,并行安全。
type processBridge struct { type processBridge struct {
streamer *stream.Streamer sink ProgressSink
step StepSpec step StepSpec
friendly string // 角色显示名 friendly string // 角色显示名
flushTick *time.Ticker flushTick *time.Ticker
@@ -95,14 +94,14 @@ type toolBlock struct {
input strings.Builder input strings.Builder
} }
// newProcessBridge 创建过程桥。返回 nil 表示该 step 无流式需求(streamer 缺失), // newProcessBridge 创建过程桥。返回 nil 表示该 step 无流式需求(sink 缺失),
// 调用方对 nil bridge 调用任何方法都是安全 no-op。 // 调用方对 nil bridge 调用任何方法都是安全 no-op。
func newProcessBridge(streamer *stream.Streamer, step StepSpec, friendlyRole string) *processBridge { func newProcessBridge(sink ProgressSink, step StepSpec, friendlyRole string) *processBridge {
if streamer == nil { if sink == nil {
return nil return nil
} }
b := &processBridge{ b := &processBridge{
streamer: streamer, sink: sink,
step: step, step: step,
friendly: friendlyRole, friendly: friendlyRole,
stopCh: make(chan struct{}), stopCh: make(chan struct{}),
@@ -233,12 +232,12 @@ func (b *processBridge) onToolStart(name string) {
itemID, toolName := cur.itemID, cur.name itemID, toolName := cur.itemID, cur.name
b.mu.Unlock() b.mu.Unlock()
// tool_execution_start 是正式的"开始调用"信号:无论是否预建,都要发 calling 帧。 // tool_execution_start 是正式的"开始调用"信号:无论是否预建,都要发 calling 帧。
b.emitDetail(&stream.DetailPayload{ b.emitDetail(&DetailEvent{
Category: detailCategoryToolCall, Category: detailCategoryToolCall,
StepID: b.step.ID, StepID: b.step.ID,
Role: b.step.Role, Role: b.step.Role,
ItemID: itemID, ItemID: itemID,
Tool: &stream.ToolCallInfo{Name: toolName, Status: "calling"}, Tool: &ToolEvent{Name: toolName, Status: "calling"},
Title: fmt.Sprintf("%s调用工具 %s", b.friendly, toolName), Title: fmt.Sprintf("%s调用工具 %s", b.friendly, toolName),
Collapsible: true, Collapsible: true,
}) })
@@ -257,14 +256,14 @@ func (b *processBridge) onToolEnd(name, result string) {
status = "failed" status = "failed"
} }
duration := time.Since(t.started).Milliseconds() duration := time.Since(t.started).Milliseconds()
info := &stream.ToolCallInfo{ info := &ToolEvent{
Name: name, Name: name,
Status: status, Status: status,
Input: trimProcessText(t.input.String()), Input: trimProcessText(t.input.String()),
Output: trimProcessText(result), Output: trimProcessText(result),
DurationMs: duration, DurationMs: duration,
} }
b.emitDetail(&stream.DetailPayload{ b.emitDetail(&DetailEvent{
Category: detailCategoryToolCall, Category: detailCategoryToolCall,
StepID: b.step.ID, StepID: b.step.ID,
Role: b.step.Role, Role: b.step.Role,
@@ -273,7 +272,7 @@ func (b *processBridge) onToolEnd(name, result string) {
Collapsible: true, Collapsible: true,
}) })
// 工具项即时定稿(状态帧 + done 标记合并语义:终帧即最后状态) // 工具项即时定稿(状态帧 + done 标记合并语义:终帧即最后状态)
b.emitDetail(&stream.DetailPayload{ b.emitDetail(&DetailEvent{
Category: detailCategoryToolCall, Category: detailCategoryToolCall,
StepID: b.step.ID, StepID: b.step.ID,
Role: b.step.Role, Role: b.step.Role,
@@ -312,7 +311,7 @@ func (b *processBridge) flushPending() {
if len(text) > processMaxFrameBytes { if len(text) > processMaxFrameBytes {
text = text[:processMaxFrameBytes] + "\n…(截断)" text = text[:processMaxFrameBytes] + "\n…(截断)"
} }
b.emitDetail(&stream.DetailPayload{ b.emitDetail(&DetailEvent{
Category: detailCategoryAgentThought, Category: detailCategoryAgentThought,
StepID: b.step.ID, StepID: b.step.ID,
Role: b.step.Role, Role: b.step.Role,
@@ -326,7 +325,7 @@ func (b *processBridge) finishThought(th *thoughtBlock) {
th.stream.finish() th.stream.finish()
// 冲残余 // 冲残余
if text := th.stream.take(); text != "" { if text := th.stream.take(); text != "" {
b.emitDetail(&stream.DetailPayload{ b.emitDetail(&DetailEvent{
Category: detailCategoryAgentThought, Category: detailCategoryAgentThought,
StepID: b.step.ID, StepID: b.step.ID,
Role: b.step.Role, Role: b.step.Role,
@@ -335,7 +334,7 @@ func (b *processBridge) finishThought(th *thoughtBlock) {
Collapsible: true, Collapsible: true,
}) })
} }
b.emitDetail(&stream.DetailPayload{ b.emitDetail(&DetailEvent{
Category: detailCategoryAgentThought, Category: detailCategoryAgentThought,
StepID: b.step.ID, StepID: b.step.ID,
Role: b.step.Role, Role: b.step.Role,
@@ -347,12 +346,12 @@ func (b *processBridge) finishThought(th *thoughtBlock) {
} }
func (b *processBridge) finishTool(t *toolBlock, status, result string) { func (b *processBridge) finishTool(t *toolBlock, status, result string) {
b.emitDetail(&stream.DetailPayload{ b.emitDetail(&DetailEvent{
Category: detailCategoryToolCall, Category: detailCategoryToolCall,
StepID: b.step.ID, StepID: b.step.ID,
Role: b.step.Role, Role: b.step.Role,
ItemID: t.itemID, ItemID: t.itemID,
Tool: &stream.ToolCallInfo{ Tool: &ToolEvent{
Name: t.name, Name: t.name,
Status: status, Status: status,
Input: trimProcessText(t.input.String()), Input: trimProcessText(t.input.String()),
@@ -364,11 +363,11 @@ func (b *processBridge) finishTool(t *toolBlock, status, result string) {
}) })
} }
func (b *processBridge) emitDetail(p *stream.DetailPayload) { func (b *processBridge) emitDetail(p *DetailEvent) {
if b == nil || b.streamer == nil { if b == nil || b.sink == nil {
return return
} }
b.streamer.Emit(stream.StreamEvent{Type: stream.EventDetail, Detail: p}) b.sink.EmitDetail(*p)
} }
// trimProcessText 工具入参/结果截断(防超大帧;保留首尾)。 // trimProcessText 工具入参/结果截断(防超大帧;保留首尾)。
@@ -383,13 +382,13 @@ func trimProcessText(s string) string {
return head + "\n…(中间省略 " + fmt.Sprint(len(s)-headN-tailN) + " 字节)…\n" + tail return head + "\n…(中间省略 " + fmt.Sprint(len(s)-headN-tailN) + " 字节)…\n" + tail
} }
// AttachProcessBridge 给 agent.Request 装配过程桥(若 streamer 缺失或无流式需求则原样返回)。 // AttachProcessBridge 给 agent.Request 装配过程桥(若 sink 缺失或无流式需求则原样返回)。
// 返回的 stop 函数在 Execute 返回后必须调用一次(定稿与冲刷残余)。 // 返回的 stop 函数在 Execute 返回后必须调用一次(定稿与冲刷残余)。
func AttachProcessBridge(streamer *stream.Streamer, step StepSpec, req *agent.Request) (stop func()) { func AttachProcessBridge(sink ProgressSink, step StepSpec, req *agent.Request) (stop func()) {
if streamer == nil || req == nil { if sink == nil || req == nil {
return func() {} return func() {}
} }
bridge := newProcessBridge(streamer, step, FriendlyRoleName(step.Role)) bridge := newProcessBridge(sink, step, FriendlyRoleName(step.Role))
if bridge == nil { if bridge == nil {
return func() {} return func() {}
} }
+62 -56
View File
@@ -7,31 +7,48 @@ import (
"time" "time"
"chat-agent-system/internal/agent" "chat-agent-system/internal/agent"
"chat-agent-system/internal/stream"
) )
// captureSink 端口契约测试辅助:并发安全地捕获 DetailEvent。
type captureSink struct {
mu *sync.Mutex
events *[]DetailEvent
}
func (c *captureSink) EmitPhase(phaseID, message, role string, stepIndex, totalSteps, progress int) {}
func (c *captureSink) EmitDetail(p DetailEvent) {
c.mu.Lock()
*c.events = append(*c.events, p)
c.mu.Unlock()
}
func (c *captureSink) EmitDeliverable(taskKey, title, filePath string, wordCount int, score float64) {}
func (c *captureSink) EmitSummary(title, content string, maturityScore int, maturityLevel string) {}
func (c *captureSink) EmitBriefing(brief any) {}
func newCaptureSink() (*captureSink, *[]DetailEvent, *sync.Mutex) {
var mu sync.Mutex
var events []DetailEvent
return &captureSink{mu: &mu, events: &events}, &events, &mu
}
func TestProcessBridgeNilStreamerIsNoop(t *testing.T) { func TestProcessBridgeNilStreamerIsNoop(t *testing.T) {
step := StepSpec{ID: "step_01", Role: "regulatory"} step := StepSpec{ID: "step_01", Role: "regulatory"}
req := &agent.Request{} req := &agent.Request{}
stop := AttachProcessBridge(nil, step, req) stop := AttachProcessBridge(nil, step, req)
if req.OnEvent != nil { if req.OnEvent != nil {
t.Fatal("nil streamer must not attach OnEvent") t.Fatal("nil sink must not attach OnEvent")
} }
req.OnEvent = func(agent.ProcessEvent) {} // 直接赋值不应有 panic req.OnEvent = func(agent.ProcessEvent) {} // 直接赋值不应有 panic
stop() // no-op stop 不 panic stop() // no-op stop 不 panic
} }
func TestProcessBridgeReasoningFlow(t *testing.T) { func TestProcessBridgeReasoningFlow(t *testing.T) {
var mu sync.Mutex sink, events, mu := newCaptureSink()
var events []stream.StreamEvent
streamer := stream.NewStreamer(true, func(ev stream.StreamEvent) {
mu.Lock()
events = append(events, ev)
mu.Unlock()
})
step := StepSpec{ID: "step_02", Role: "engineer"} step := StepSpec{ID: "step_02", Role: "engineer"}
req := &agent.Request{} req := &agent.Request{}
stop := AttachProcessBridge(streamer, step, req) stop := AttachProcessBridge(sink, step, req)
defer stop() defer stop()
req.OnEvent(agent.ProcessEvent{Type: "reasoning_delta", Text: "第一段思考"}) req.OnEvent(agent.ProcessEvent{Type: "reasoning_delta", Text: "第一段思考"})
@@ -45,22 +62,19 @@ func TestProcessBridgeReasoningFlow(t *testing.T) {
defer mu.Unlock() defer mu.Unlock()
var deltas, dones int var deltas, dones int
var joined strings.Builder var joined strings.Builder
for _, ev := range events { for _, ev := range *events {
if ev.Type != stream.EventDetail || ev.Detail == nil { if ev.Category != detailCategoryAgentThought {
continue t.Fatalf("unexpected category %s", ev.Category)
} }
if ev.Detail.Category != detailCategoryAgentThought { if ev.Delta != "" {
t.Fatalf("unexpected category %s", ev.Detail.Category)
}
if ev.Detail.Delta != "" {
deltas++ deltas++
joined.WriteString(ev.Detail.Delta) joined.WriteString(ev.Delta)
} }
if ev.Detail.Done { if ev.Done {
dones++ dones++
} }
if ev.Detail.StepID != step.ID || ev.Detail.Role != step.Role { if ev.StepID != step.ID || ev.Role != step.Role {
t.Fatalf("step identity missing: %+v", ev.Detail) t.Fatalf("step identity missing: %+v", ev)
} }
} }
if joined.String() != "第一段思考继续推理" { if joined.String() != "第一段思考继续推理" {
@@ -75,15 +89,9 @@ func TestProcessBridgeReasoningFlow(t *testing.T) {
} }
func TestProcessBridgeToolFlow(t *testing.T) { func TestProcessBridgeToolFlow(t *testing.T) {
var mu sync.Mutex sink, events, mu := newCaptureSink()
var events []stream.StreamEvent
streamer := stream.NewStreamer(true, func(ev stream.StreamEvent) {
mu.Lock()
events = append(events, ev)
mu.Unlock()
})
step := StepSpec{ID: "step_03", Role: "writer"} step := StepSpec{ID: "step_03", Role: "writer"}
b := newProcessBridge(streamer, step, "撰写专员") b := newProcessBridge(sink, step, "撰写专员")
if b == nil { if b == nil {
t.Fatal("bridge expected") t.Fatal("bridge expected")
} }
@@ -97,20 +105,20 @@ func TestProcessBridgeToolFlow(t *testing.T) {
mu.Lock() mu.Lock()
defer mu.Unlock() defer mu.Unlock()
var calling, done, withInput int var calling, done, withInput int
for _, ev := range events { for _, ev := range *events {
if ev.Type != stream.EventDetail || ev.Detail == nil || ev.Detail.Category != detailCategoryToolCall { if ev.Category != detailCategoryToolCall {
continue continue
} }
if ev.Detail.Tool == nil { if ev.Tool == nil {
t.Fatal("tool_call frame must carry Tool info") t.Fatal("tool_call frame must carry Tool info")
} }
switch { switch {
case ev.Detail.Tool.Status == "calling": case ev.Tool.Status == "calling":
calling++ calling++
case ev.Detail.Done: case ev.Done:
done++ done++
} }
if strings.Contains(ev.Detail.Tool.Input, "a.md") { if strings.Contains(ev.Tool.Input, "a.md") {
withInput++ withInput++
} }
} }
@@ -126,14 +134,8 @@ func TestProcessBridgeToolFlow(t *testing.T) {
} }
func TestProcessBridgeToolFailureStatus(t *testing.T) { func TestProcessBridgeToolFailureStatus(t *testing.T) {
var mu sync.Mutex sink, events, mu := newCaptureSink()
var events []stream.StreamEvent b := newProcessBridge(sink, StepSpec{ID: "s", Role: "r"}, "测试")
streamer := stream.NewStreamer(true, func(ev stream.StreamEvent) {
mu.Lock()
events = append(events, ev)
mu.Unlock()
})
b := newProcessBridge(streamer, StepSpec{ID: "s", Role: "r"}, "测试")
b.Handle(agent.ProcessEvent{Type: "tool_execution_start", Tool: "write_file"}) b.Handle(agent.ProcessEvent{Type: "tool_execution_start", Tool: "write_file"})
b.Handle(agent.ProcessEvent{Type: "tool_execution_end", Tool: "write_file", Detail: "error: disk full"}) b.Handle(agent.ProcessEvent{Type: "tool_execution_end", Tool: "write_file", Detail: "error: disk full"})
b.Stop() b.Stop()
@@ -141,8 +143,8 @@ func TestProcessBridgeToolFailureStatus(t *testing.T) {
mu.Lock() mu.Lock()
defer mu.Unlock() defer mu.Unlock()
sawFailed := false sawFailed := false
for _, ev := range events { for _, ev := range *events {
if ev.Detail != nil && ev.Detail.Tool != nil && ev.Detail.Tool.Status == "failed" { if ev.Tool != nil && ev.Tool.Status == "failed" {
sawFailed = true sawFailed = true
} }
} }
@@ -170,16 +172,16 @@ func TestProcessBridgeConcurrentSteps(t *testing.T) {
// 并行组成员各自持有独立 bridge,事件互不串扰(设计 F.4)。 // 并行组成员各自持有独立 bridge,事件互不串扰(设计 F.4)。
var mu sync.Mutex var mu sync.Mutex
seen := map[string]string{} // itemID -> role seen := map[string]string{} // itemID -> role
streamer := stream.NewStreamer(true, func(ev stream.StreamEvent) { sink := &captureSink{mu: &mu}
if ev.Type != stream.EventDetail || ev.Detail == nil { sink.events = &[]DetailEvent{}
return capture := func(p DetailEvent) {
} sink.mu.Lock()
mu.Lock() *sink.events = append(*sink.events, p)
seen[ev.Detail.ItemID] = ev.Detail.Role sink.mu.Unlock()
mu.Unlock() }
}) _ = capture
b1 := newProcessBridge(streamer, StepSpec{ID: "p1", Role: "regulatory"}, "法规专员") b1 := newProcessBridge(sink, StepSpec{ID: "p1", Role: "regulatory"}, "法规专员")
b2 := newProcessBridge(streamer, StepSpec{ID: "p2", Role: "market"}, "市场专员") b2 := newProcessBridge(sink, StepSpec{ID: "p2", Role: "market"}, "市场专员")
var wg sync.WaitGroup var wg sync.WaitGroup
wg.Add(2) wg.Add(2)
go func() { go func() {
@@ -198,8 +200,12 @@ func TestProcessBridgeConcurrentSteps(t *testing.T) {
}() }()
wg.Wait() wg.Wait()
time.Sleep(50 * time.Millisecond) // 等冲刷 time.Sleep(50 * time.Millisecond) // 等冲刷
// 从捕获事件推导 itemID -> role 映射
mu.Lock() mu.Lock()
defer mu.Unlock() for _, ev := range *sink.events {
seen[ev.ItemID] = ev.Role
}
mu.Unlock()
for itemID, role := range seen { for itemID, role := range seen {
if !strings.Contains(itemID, "p1") && !strings.Contains(itemID, "p2") { if !strings.Contains(itemID, "p1") && !strings.Contains(itemID, "p2") {
t.Fatalf("unexpected item %s", itemID) t.Fatalf("unexpected item %s", itemID)
+66
View File
@@ -0,0 +1,66 @@
package squad
import "context"
// ProgressSink 是小队过程事件的输出端口(KTD1:SSE 映射、task_runs 持久事件、
// 权限与重放由产品侧实现;小队只产出有类型的进度/步骤/完成事件)。
//
// 实现方约定:方法不得阻塞(消费方自行异步入队);nil 安全由调用侧判断,
// ProgressFromContext 未注入时返回 nil,小队内部全部 no-op。
type ProgressSink interface {
// EmitPhase 报告阶段进展(装配、门禁、失败等里程碑)。
EmitPhase(phaseID, message, role string, stepIndex, totalSteps, progress int)
// EmitDetail 报告过程细节帧(思考增量、工具调用、步骤输出)。
EmitDetail(p DetailEvent)
// EmitDeliverable 报告正式交付物完成。
EmitDeliverable(taskKey, title, filePath string, wordCount int, score float64)
// EmitSummary 报告任务摘要(含成熟度)。
EmitSummary(title, content string, maturityScore int, maturityLevel string)
// EmitBriefing 报告参谋简报载荷(结构由产品侧定义)。
EmitBriefing(brief any)
}
// DetailEvent 是小队过程细节事件的唯一载荷形态;与产品 SSE detail 帧的字段
// 对应关系由产品侧适配器维护(squad 不 import stream 包)。
type DetailEvent struct {
Category string // agent_thought | step_output | tool_call | log
StepID string
Role string
ItemID string // 聚合项:一个思考块/一次工具调用
Title string
Content string // 完整快照(对账帧)
Collapsible bool
Delta string // 增量追加到 ItemID 项
Done bool // 定稿标记
Tool *ToolEvent
}
// ToolEvent 是工具调用状态帧(Category == tool_call 时)。
type ToolEvent struct {
Name string // write_file / read_file / ...
Status string // calling | succeeded | failed
Input string // 入参摘要(截断)
Output string // 结果摘要(截断)
DurationMs int64
}
type progressCtxKey struct{}
// WithProgress 向 ctx 注入小队过程端口(产品装配点调用)。
func WithProgress(ctx context.Context, sink ProgressSink) context.Context {
if sink == nil {
return ctx
}
return context.WithValue(ctx, progressCtxKey{}, sink)
}
// ProgressFromContext 提取小队过程端口;未注入返回 nil(调用侧 no-op)。
func ProgressFromContext(ctx context.Context) ProgressSink {
if ctx == nil {
return nil
}
if sink, ok := ctx.Value(progressCtxKey{}).(ProgressSink); ok {
return sink
}
return nil
}
+4 -5
View File
@@ -15,7 +15,6 @@ import (
"unicode/utf8" "unicode/utf8"
"chat-agent-system/internal/agent" "chat-agent-system/internal/agent"
"chat-agent-system/internal/stream"
"chat-agent-system/internal/workflowdef" "chat-agent-system/internal/workflowdef"
) )
@@ -601,7 +600,7 @@ func (q *QualityGate) EnsureQualityOrRepair(
// 治理:修复轮预算预扣(超限硬停,杜绝机械空转烧成本) // 治理:修复轮预算预扣(超限硬停,杜绝机械空转烧成本)
if lErr := ledger.ReserveRepair(); lErr != nil { if lErr := ledger.ReserveRepair(); lErr != nil {
_ = ledger.Save(wsDir) _ = ledger.Save(wsDir)
if st := stream.FromContext(ctx); st != nil { if st := ProgressFromContext(ctx); st != nil {
st.EmitPhase("governance", "修复预算硬停: "+lErr.Error(), "taskgov", 0, 0, 0) st.EmitPhase("governance", "修复预算硬停: "+lErr.Error(), "taskgov", 0, 0, 0)
} }
break break
@@ -656,12 +655,12 @@ func (q *QualityGate) EnsureQualityOrRepair(
"error": rerr.Error(), "error": rerr.Error(),
"mutated": strings.Join(mutated, ","), "mutated": strings.Join(mutated, ","),
}) })
if st := stream.FromContext(ctx); st != nil { if st := ProgressFromContext(ctx); st != nil {
st.EmitPhase("governance", "修复 containment 恢复失败: "+rerr.Error(), "taskgov", 0, 0, 0) st.EmitPhase("governance", "修复 containment 恢复失败: "+rerr.Error(), "taskgov", 0, 0, 0)
} }
return report, fmt.Errorf("restore parent inputs after containment: %w", rerr) return report, fmt.Errorf("restore parent inputs after containment: %w", rerr)
} }
if st := stream.FromContext(ctx); st != nil { if st := ProgressFromContext(ctx); st != nil {
st.EmitPhase("governance", "修复 containment 违规: 父级输入已被越界改写,已恢复运行前字节,本轮作废", "taskgov", 0, 0, 0) st.EmitPhase("governance", "修复 containment 违规: 父级输入已被越界改写,已恢复运行前字节,本轮作废", "taskgov", 0, 0, 0)
} }
} }
@@ -680,7 +679,7 @@ func (q *QualityGate) EnsureQualityOrRepair(
sim := OutputSimilarity(prevRepairContent, string(repairedBytes)) sim := OutputSimilarity(prevRepairContent, string(repairedBytes))
if sim >= 0.98 { if sim >= 0.98 {
if stallHinted { if stallHinted {
if st := stream.FromContext(ctx); st != nil { if st := ProgressFromContext(ctx); st != nil {
st.EmitPhase("governance", fmt.Sprintf("修复停滞止损: 连续两轮修复产物相似度 %.1f%%,终止机械重试", sim*100), "taskgov", 0, 0, 0) st.EmitPhase("governance", fmt.Sprintf("修复停滞止损: 连续两轮修复产物相似度 %.1f%%,终止机械重试", sim*100), "taskgov", 0, 0, 0)
} }
appendGovAudit(wsDir, "repair_stall_break", map[string]string{"attempt": fmt.Sprintf("%d", attempt), "similarity": fmt.Sprintf("%.3f", sim)}) appendGovAudit(wsDir, "repair_stall_break", map[string]string{"attempt": fmt.Sprintf("%d", attempt), "similarity": fmt.Sprintf("%.3f", sim)})
+17 -18
View File
@@ -11,7 +11,6 @@ import (
"chat-agent-system/internal/agent" "chat-agent-system/internal/agent"
"chat-agent-system/internal/quote" "chat-agent-system/internal/quote"
"chat-agent-system/internal/stream"
"chat-agent-system/internal/workflowdef" "chat-agent-system/internal/workflowdef"
) )
@@ -276,9 +275,9 @@ func (s *Service) ExecuteTask(ctx context.Context, req SquadRunRequest) (*SquadR
} }
// 3. 执行动态工作流调度 // 3. 执行动态工作流调度
streamer := stream.FromContext(ctx) sink := ProgressFromContext(ctx)
if streamer != nil { if sink != nil {
streamer.EmitPhase("squad_assembly", fmt.Sprintf("正在组建并启动【%s】智能体小队(后端: %s)...", TaskArtifactTitle(req.TaskKey), backendName), "leader", 0, 0, 5) sink.EmitPhase("squad_assembly", fmt.Sprintf("正在组建并启动【%s】智能体小队(后端: %s)...", TaskArtifactTitle(req.TaskKey), backendName), "leader", 0, 0, 5)
} }
if s.opts.NewBackend == nil { if s.opts.NewBackend == nil {
@@ -296,8 +295,8 @@ func (s *Service) ExecuteTask(ctx context.Context, req SquadRunRequest) (*SquadR
execRes, err := s.driver.Execute(ctx, workDir, backend, execOpts) execRes, err := s.driver.Execute(ctx, workDir, backend, execOpts)
if err != nil { if err != nil {
if streamer != nil { if sink != nil {
streamer.EmitPhase("task_failed", fmt.Sprintf("任务执行异常: %v", err), "leader", 0, 0, 0) sink.EmitPhase("task_failed", fmt.Sprintf("任务执行异常: %v", err), "leader", 0, 0, 0)
} }
return &SquadRunResult{ return &SquadRunResult{
TaskKey: req.TaskKey, TaskKey: req.TaskKey,
@@ -311,26 +310,26 @@ func (s *Service) ExecuteTask(ctx context.Context, req SquadRunRequest) (*SquadR
} }
// 4. 自动化质量门禁与修复 (QualityGate) // 4. 自动化质量门禁与修复 (QualityGate)
if streamer != nil { if sink != nil {
streamer.EmitPhase("quality_gate", "审核主管正在核验交付物规范并执行质量门禁把关...", "leader", 0, 0, 85) sink.EmitPhase("quality_gate", "审核主管正在核验交付物规范并执行质量门禁把关...", "leader", 0, 0, 85)
} }
maxRetries := repairBudgetForSquadTask(req) maxRetries := repairBudgetForSquadTask(req)
report, err := s.gate.EnsureQualityOrRepair(ctx, workDir, req.TaskKey, s.driver, backend, maxRetries, execOpts) report, err := s.gate.EnsureQualityOrRepair(ctx, workDir, req.TaskKey, s.driver, backend, maxRetries, execOpts)
if err != nil { if err != nil {
// 记录门禁错误但容错返回当前正文 // 记录门禁错误但容错返回当前正文
report = &QualityReport{Passed: false, Violations: []string{err.Error()}} report = &QualityReport{Passed: false, Violations: []string{err.Error()}}
if streamer != nil { if sink != nil {
streamer.EmitPhase("quality_gate", fmt.Sprintf("质量门禁审查告警: %v", err), "leader", 0, 0, 90) sink.EmitPhase("quality_gate", fmt.Sprintf("质量门禁审查告警: %v", err), "leader", 0, 0, 90)
} }
} else if streamer != nil { } else if sink != nil {
if report.Passed { if report.Passed {
streamer.EmitPhase("quality_gate", fmt.Sprintf("质量门禁审核通过(评分: %.1f / 100)", report.Score), "leader", 0, 0, 95) sink.EmitPhase("quality_gate", fmt.Sprintf("质量门禁审核通过(评分: %.1f / 100)", report.Score), "leader", 0, 0, 95)
} else { } else {
violations := strings.Join(report.Violations, ";") violations := strings.Join(report.Violations, ";")
if violations == "" { if violations == "" {
violations = "未满足质量门禁要求" violations = "未满足质量门禁要求"
} }
streamer.EmitPhase("quality_gate", fmt.Sprintf("质量门禁未通过(评分: %.1f / 100):%s", report.Score, violations), "leader", 0, 0, 95) sink.EmitPhase("quality_gate", fmt.Sprintf("质量门禁未通过(评分: %.1f / 100):%s", report.Score, violations), "leader", 0, 0, 95)
} }
} }
@@ -381,16 +380,16 @@ func (s *Service) ExecuteTask(ctx context.Context, req SquadRunRequest) (*SquadR
} }
} }
if streamer != nil { if sink != nil {
score := 0.0 score := 0.0
if report != nil { if report != nil {
score = report.Score score = report.Score
} }
streamer.EmitDeliverable(req.TaskKey, TaskArtifactTitle(req.TaskKey), finalOutPath, len(strings.Fields(string(finalBytes))), score) sink.EmitDeliverable(req.TaskKey, TaskArtifactTitle(req.TaskKey), finalOutPath, len(strings.Fields(string(finalBytes))), score)
if advisorBrief != nil { if advisorBrief != nil {
streamer.EmitPhase("advisor_briefing", "贴身业务参谋正在提炼关键决策摘要与风险雷达...", "coordinator", 0, 0, 98) sink.EmitPhase("advisor_briefing", "贴身业务参谋正在提炼关键决策摘要与风险雷达...", "coordinator", 0, 0, 98)
streamer.EmitSummary(TaskArtifactTitle(req.TaskKey)+"已生成", advisorBrief.MaturityHeadline, advisorBrief.MaturityScore, advisorBrief.MaturityLevel) sink.EmitSummary(TaskArtifactTitle(req.TaskKey)+"已生成", advisorBrief.MaturityHeadline, advisorBrief.MaturityScore, advisorBrief.MaturityLevel)
streamer.EmitBriefing(advisorBrief) sink.EmitBriefing(advisorBrief)
} }
} }
+29 -23
View File
@@ -13,7 +13,6 @@ import (
"time" "time"
"chat-agent-system/internal/agent" "chat-agent-system/internal/agent"
"chat-agent-system/internal/stream"
) )
// StepEvent 表示小队单步执行生命周期的事件 // StepEvent 表示小队单步执行生命周期的事件
@@ -244,7 +243,7 @@ func (d *WorkflowDriver) Execute(ctx context.Context, wsDir string, backend agen
for range parallelGroup { for range parallelGroup {
if rErr := ledger.ReserveStep(); rErr != nil { if rErr := ledger.ReserveStep(); rErr != nil {
_ = ledger.Save(wsDir) _ = ledger.Save(wsDir)
if st := stream.FromContext(ctx); st != nil { if st := ProgressFromContext(ctx); st != nil {
st.EmitPhase("governance", "预算硬停: "+rErr.Error(), "taskgov", 0, 0, 0) st.EmitPhase("governance", "预算硬停: "+rErr.Error(), "taskgov", 0, 0, 0)
} }
wf.Status = "failed" wf.Status = "failed"
@@ -315,7 +314,7 @@ func (d *WorkflowDriver) Execute(ctx context.Context, wsDir string, backend agen
} }
} }
if msg := ledger.CheckSoftLimit(); msg != "" { if msg := ledger.CheckSoftLimit(); msg != "" {
if st := stream.FromContext(ctx); st != nil { if st := ProgressFromContext(ctx); st != nil {
st.EmitPhase("governance", msg, "taskgov", 0, 0, 0) st.EmitPhase("governance", msg, "taskgov", 0, 0, 0)
} }
} }
@@ -327,7 +326,7 @@ func (d *WorkflowDriver) Execute(ctx context.Context, wsDir string, backend agen
// 治理:预算预扣(超限硬停) // 治理:预算预扣(超限硬停)
if rErr := ledger.ReserveStep(); rErr != nil { if rErr := ledger.ReserveStep(); rErr != nil {
_ = ledger.Save(wsDir) _ = ledger.Save(wsDir)
if st := stream.FromContext(ctx); st != nil { if st := ProgressFromContext(ctx); st != nil {
st.EmitPhase("governance", "预算硬停: "+rErr.Error(), "taskgov", 0, 0, 0) st.EmitPhase("governance", "预算硬停: "+rErr.Error(), "taskgov", 0, 0, 0)
} }
wf.Status = "failed" wf.Status = "failed"
@@ -368,7 +367,7 @@ func (d *WorkflowDriver) Execute(ctx context.Context, wsDir string, backend agen
} }
} }
if msg := ledger.CheckSoftLimit(); msg != "" { if msg := ledger.CheckSoftLimit(); msg != "" {
if st := stream.FromContext(ctx); st != nil { if st := ProgressFromContext(ctx); st != nil {
st.EmitPhase("governance", msg, "taskgov", 0, 0, 0) st.EmitPhase("governance", msg, "taskgov", 0, 0, 0)
} }
} }
@@ -491,10 +490,10 @@ func (d *WorkflowDriver) runOneStep(
Status: "started", Status: "started",
}) })
} }
streamer := stream.FromContext(ctx) sink := ProgressFromContext(ctx)
roleTitle := FriendlyRoleName(step.Role) roleTitle := FriendlyRoleName(step.Role)
if streamer != nil { if sink != nil {
streamer.EmitPhase("step_executing", fmt.Sprintf("%s正在进行:%s...", roleTitle, step.Title), step.Role, 0, 0, 0) sink.EmitPhase("step_executing", fmt.Sprintf("%s正在进行:%s...", roleTitle, step.Title), step.Role, 0, 0, 0)
} }
stepStart := time.Now() stepStart := time.Now()
@@ -527,7 +526,7 @@ func (d *WorkflowDriver) runOneStep(
mu.Unlock() mu.Unlock()
} }
if verdict := DetectRepetition(histEntries, step.ID); verdict.Detected { if verdict := DetectRepetition(histEntries, step.ID); verdict.Detected {
if st := stream.FromContext(ctx); st != nil { if st := ProgressFromContext(ctx); st != nil {
st.EmitPhase("governance", "重复识别强制终止: "+verdict.Reason, "taskgov", 0, 0, 0) st.EmitPhase("governance", "重复识别强制终止: "+verdict.Reason, "taskgov", 0, 0, 0)
} }
appendGovAudit(wsDir, "loop_aborted", map[string]string{"step_id": step.ID, "kind": verdict.Kind, "reason": verdict.Reason}) appendGovAudit(wsDir, "loop_aborted", map[string]string{"step_id": step.ID, "kind": verdict.Kind, "reason": verdict.Reason})
@@ -551,9 +550,9 @@ func (d *WorkflowDriver) runOneStep(
Targets: targets, Targets: targets,
Timeout: stepTimeout, Timeout: stepTimeout,
} }
// 过程流桥:把成员执行的思考/工具调用增量翻译为 detail 事件(streamer 缺失时为 no-op)。 // 过程流桥:把成员执行的思考/工具调用增量翻译为 detail 事件(sink 缺失时为 no-op)。
// 注意 retry 循环内多次 Execute 共用一个 bridge 语义(同 step 聚合),Stop 只在首次调用。 // 注意 retry 循环内多次 Execute 共用一个 bridge 语义(同 step 聚合),Stop 只在首次调用。
stopProcessBridge := AttachProcessBridge(streamer, step, &req) stopProcessBridge := AttachProcessBridge(sink, step, &req)
var ( var (
res *agent.Result res *agent.Result
@@ -577,8 +576,8 @@ func (d *WorkflowDriver) runOneStep(
req.Prompt = basePrompt + "\n\n【纠偏重试】你上一轮没有调用 write 工具写 " + deliverableName + req.Prompt = basePrompt + "\n\n【纠偏重试】你上一轮没有调用 write 工具写 " + deliverableName +
",本轮必须先用 write 工具把完整交付物写入当前目录 " + deliverableName + ",再结束。" ",本轮必须先用 write 工具把完整交付物写入当前目录 " + deliverableName + ",再结束。"
} }
if attempt > 1 && streamer != nil { if attempt > 1 && sink != nil {
streamer.EmitPhase( sink.EmitPhase(
"step_retrying", "step_retrying",
fmt.Sprintf("%s遇瞬时失败,发起全新步骤调用(%d/%d)", roleTitle, attempt, agent.MaxTransientStepInvocations), fmt.Sprintf("%s遇瞬时失败,发起全新步骤调用(%d/%d)", roleTitle, attempt, agent.MaxTransientStepInvocations),
step.Role, 0, 0, 0, step.Role, 0, 0, 0,
@@ -700,8 +699,8 @@ func (d *WorkflowDriver) runOneStep(
} }
if !ok && execErr != nil { if !ok && execErr != nil {
if streamer != nil { if sink != nil {
streamer.EmitPhase("step_failed", fmt.Sprintf("%s执行异常:%s", roleTitle, execErr.Error()), step.Role, 0, 0, 0) sink.EmitPhase("step_failed", fmt.Sprintf("%s执行异常:%s", roleTitle, execErr.Error()), step.Role, 0, 0, 0)
} }
if notify != nil { if notify != nil {
notify(StepEvent{ notify(StepEvent{
@@ -719,8 +718,8 @@ func (d *WorkflowDriver) runOneStep(
if !ok && res != nil && !res.Success { if !ok && res != nil && !res.Success {
errMsg := res.ErrorReason errMsg := res.ErrorReason
if streamer != nil { if sink != nil {
streamer.EmitPhase("step_failed", fmt.Sprintf("%s执行未成功:%s", roleTitle, errMsg), step.Role, 0, 0, 0) sink.EmitPhase("step_failed", fmt.Sprintf("%s执行未成功:%s", roleTitle, errMsg), step.Role, 0, 0, 0)
} }
if notify != nil { if notify != nil {
notify(StepEvent{ notify(StepEvent{
@@ -777,10 +776,17 @@ func (d *WorkflowDriver) runOneStep(
*producedFiles = append(*producedFiles, filepath.ToSlash(filepath.Join(step.SubWorkspace, deliverableName))) *producedFiles = append(*producedFiles, filepath.ToSlash(filepath.Join(step.SubWorkspace, deliverableName)))
mu.Unlock() mu.Unlock()
if streamer != nil { if sink != nil {
streamer.EmitPhase("step_completed", fmt.Sprintf("%s完成任务:%s (耗时 %dms)", roleTitle, step.Title, duration), step.Role, 0, 0, 0) sink.EmitPhase("step_completed", fmt.Sprintf("%s完成任务:%s (耗时 %dms)", roleTitle, step.Title, duration), step.Role, 0, 0, 0)
if stepData, err := os.ReadFile(targetPath); err == nil && len(stepData) > 0 { if stepData, err := os.ReadFile(targetPath); err == nil && len(stepData) > 0 {
streamer.EmitDetail("step_output", step.ID, step.Role, fmt.Sprintf("%s阶段性产出: %s", roleTitle, step.Title), string(stepData), true) sink.EmitDetail(DetailEvent{
Category: "step_output",
StepID: step.ID,
Role: step.Role,
Title: fmt.Sprintf("%s阶段性产出: %s", roleTitle, step.Title),
Content: string(stepData),
Collapsible: true,
})
} }
} }
@@ -976,9 +982,9 @@ func (d *WorkflowDriver) applyDynamicAdjustment(
newSteps = append(newSteps, wf.Steps[insertIdx:]...) newSteps = append(newSteps, wf.Steps[insertIdx:]...)
wf.Steps = newSteps wf.Steps = newSteps
streamer := stream.FromContext(ctx) sink := ProgressFromContext(ctx)
if streamer != nil { if sink != nil {
streamer.EmitPhase("dynamic_adjustment", fmt.Sprintf("单步评估发现缺口,动态追加任务步骤:%s (%s)", addStep.Title, FriendlyRoleName(addStep.Role)), addStep.Role, 0, 0, 0) sink.EmitPhase("dynamic_adjustment", fmt.Sprintf("单步评估发现缺口,动态追加任务步骤:%s (%s)", addStep.Title, FriendlyRoleName(addStep.Role)), addStep.Role, 0, 0, 0)
} }
} }
_ = d.SaveWorkflow(wsDir, wf) _ = d.SaveWorkflow(wsDir, wf)