Files
Agent-System/internal/squad/process_bridge.go
T
agent-bot 2cd52da85d
CI / go-vet (pull_request) Successful in 12s
CI / short-tests-report (pull_request) Failing after 35s
CI / backup-restore-drill (pull_request) Successful in 36s
CI / web (pull_request) Successful in 33s
refactor(squad): typed ProgressSink port replaces direct stream dependency
KTD1 second step (behavior preserved):
- squad defines ProgressSink (EmitPhase/Detail/Deliverable/Summary/Briefing)
  with its own DetailEvent/ToolEvent types; zero internal/stream imports left
- bizdev assembly maps port to SSE streamer (squad_progress_adapter) and
  injects via ctx at the four squad entry points
- process_bridge tests rewritten as port-contract tests (captureSink)
- verified: build+vet clean, squad/bizdev/archtest green
2026-09-29 05:51:32 +00:00

406 lines
9.8 KiB
Go

package squad
import (
"fmt"
"strings"
"sync"
"time"
"chat-agent-system/internal/agent"
)
// 过程流常量:节流窗口 / 单帧上限 / 工具文本截断(设计文档 F.1)。
const (
processFlushInterval = 200 * time.Millisecond
processMaxFrameBytes = 8 * 1024
processToolTrimBytes = 4 * 1024
)
// detailCategory 过程事件类别(与 stream.DetailPayload.Category 枚举对齐)。
const (
detailCategoryAgentThought = "agent_thought"
detailCategoryToolCall = "tool_call"
)
// deltaStream 一个聚合项的增量缓冲(节流冲刷)。
// 只被 bridge 主路径使用;timer 回调仅触发 flush 任务。
type deltaStream struct {
mu sync.Mutex
itemID string
buf strings.Builder
timer *time.Timer
done bool
}
func (d *deltaStream) append(text string) {
d.mu.Lock()
defer d.mu.Unlock()
if d.done || text == "" {
return
}
d.buf.WriteString(text)
}
// takeIfReady 返回并清空缓冲;由外部节拍调用(非定时器自转)。
func (d *deltaStream) take() string {
d.mu.Lock()
defer d.mu.Unlock()
out := d.buf.String()
d.buf.Reset()
return out
}
func (d *deltaStream) finish() {
d.mu.Lock()
defer d.mu.Unlock()
if d.timer != nil {
d.timer.Stop()
d.timer = nil
}
d.done = true
}
// processBridge 把 agent.ProcessEvent 翻译为 stream.EventDetail 增量帧。
// 每个 step(小队成员的一次执行)独立持有一个 bridge;无跨 step 共享状态,并行安全。
type processBridge struct {
sink ProgressSink
step StepSpec
friendly string // 角色显示名
flushTick *time.Ticker
stopCh chan struct{}
stopOnce sync.Once
wg sync.WaitGroup
mu sync.Mutex
thoughtSeq int
toolSeq int
thought *thoughtBlock // 当前思考块(nil = 尚未开始)
currentTool *toolBlock
emitted bool // 是否已发出过事件(Stop 只在发过时才有意义)
}
// thoughtBlock 一个 message 周期内的思考聚合项。
type thoughtBlock struct {
itemID string
stream *deltaStream
title string
}
// toolBlock 一次工具调用的聚合项。
type toolBlock struct {
itemID string
name string
started time.Time
input strings.Builder
}
// newProcessBridge 创建过程桥。返回 nil 表示该 step 无流式需求(sink 缺失),
// 调用方对 nil bridge 调用任何方法都是安全 no-op。
func newProcessBridge(sink ProgressSink, step StepSpec, friendlyRole string) *processBridge {
if sink == nil {
return nil
}
b := &processBridge{
sink: sink,
step: step,
friendly: friendlyRole,
stopCh: make(chan struct{}),
}
// 定时冲刷循环:所有 delta 缓冲统一按 flushInterval 节拍冲刷。
b.flushTick = time.NewTicker(processFlushInterval)
b.wg.Add(1)
go func() {
defer b.wg.Done()
for {
select {
case <-b.stopCh:
return
case <-b.flushTick.C:
b.flushPending()
}
}
}()
return b
}
// Stop 结束 bridge:冲刷残余、定稿当前项、停止节拍循环。幂等(可安全多次调用)。
func (b *processBridge) Stop() {
if b == nil {
return
}
b.stopOnce.Do(func() {
b.flushPending()
b.mu.Lock()
th := b.thought
tl := b.currentTool
b.mu.Unlock()
if th != nil {
b.finishThought(th)
}
if tl != nil {
b.finishTool(tl, "succeeded", "")
}
close(b.stopCh)
b.flushTick.Stop()
b.wg.Wait()
})
}
// Handle 把一个 agent.ProcessEvent 翻译为 detail 事件流。
// 调用方保证事件到达顺序与 agentx 循环一致(同 step 内串行)。
func (b *processBridge) Handle(ev agent.ProcessEvent) {
if b == nil {
return
}
select {
case <-b.stopCh:
return // Stop 后忽略(防御:Execute 返回后的残余事件)
default:
}
switch ev.Type {
case "reasoning_delta":
b.onReasoningDelta(ev.Text)
case "text_delta":
// P0 决策(设计文档 C.4):任务正文不发 detail——终态走既有 step_output
// 通道,避免与最终 deliverable 重复推送大文本。
case "tool_call_delta":
b.onToolCallDelta(ev)
case "tool_execution_start":
b.onToolStart(ev.Tool)
case "tool_execution_end":
b.onToolEnd(ev.Tool, ev.Detail)
case "message_end":
b.onMessageEnd()
}
}
func (b *processBridge) onReasoningDelta(text string) {
b.mu.Lock()
if b.thought == nil {
b.thoughtSeq++
b.thought = &thoughtBlock{
itemID: fmt.Sprintf("thought#%s#%d", b.step.ID, b.thoughtSeq),
stream: &deltaStream{},
title: fmt.Sprintf("%s思考过程 %d", b.friendly, b.thoughtSeq),
}
b.emitted = true
}
th := b.thought
b.mu.Unlock()
th.stream.append(text)
}
func (b *processBridge) onToolCallDelta(ev agent.ProcessEvent) {
b.mu.Lock()
t := b.currentTool
if t == nil {
// 模型在 tool_execution_start 前就吐参数增量:只预建内部块(不发 calling 帧,
// 正式的 calling 帧由随后的 tool_execution_start 发出)。
b.toolSeq++
t = &toolBlock{
itemID: fmt.Sprintf("tool#%s#%d", b.step.ID, b.toolSeq),
name: ev.Tool,
started: time.Now(),
}
b.currentTool = t
b.emitted = true
}
b.mu.Unlock()
t.input.WriteString(ev.Detail)
}
func (b *processBridge) onToolStart(name string) {
b.mu.Lock()
var cur *toolBlock
if b.currentTool != nil {
// 已由 tool_call_delta 预建(罕见时序):复用同一聚合项,只补名与时间。
cur = b.currentTool
if cur.name == "" {
cur.name = name
}
cur.started = time.Now()
} else {
b.toolSeq++
cur = &toolBlock{
itemID: fmt.Sprintf("tool#%s#%d", b.step.ID, b.toolSeq),
name: name,
started: time.Now(),
}
b.currentTool = cur
}
b.emitted = true
itemID, toolName := cur.itemID, cur.name
b.mu.Unlock()
// tool_execution_start 是正式的"开始调用"信号:无论是否预建,都要发 calling 帧。
b.emitDetail(&DetailEvent{
Category: detailCategoryToolCall,
StepID: b.step.ID,
Role: b.step.Role,
ItemID: itemID,
Tool: &ToolEvent{Name: toolName, Status: "calling"},
Title: fmt.Sprintf("%s调用工具 %s", b.friendly, toolName),
Collapsible: true,
})
}
func (b *processBridge) onToolEnd(name, result string) {
b.mu.Lock()
t := b.currentTool
b.currentTool = nil
b.mu.Unlock()
if t == nil {
return
}
status := "succeeded"
if strings.Contains(strings.ToLower(result), "error") || strings.Contains(strings.ToLower(result), "failed") {
status = "failed"
}
duration := time.Since(t.started).Milliseconds()
info := &ToolEvent{
Name: name,
Status: status,
Input: trimProcessText(t.input.String()),
Output: trimProcessText(result),
DurationMs: duration,
}
b.emitDetail(&DetailEvent{
Category: detailCategoryToolCall,
StepID: b.step.ID,
Role: b.step.Role,
ItemID: t.itemID,
Tool: info,
Collapsible: true,
})
// 工具项即时定稿(状态帧 + done 标记合并语义:终帧即最后状态)
b.emitDetail(&DetailEvent{
Category: detailCategoryToolCall,
StepID: b.step.ID,
Role: b.step.Role,
ItemID: t.itemID,
Tool: info,
Done: true,
Collapsible: true,
})
}
func (b *processBridge) onMessageEnd() {
b.mu.Lock()
th := b.thought
b.thought = nil
b.mu.Unlock()
if th != nil {
b.finishThought(th)
}
}
// flushPending 冲刷当前思考块缓冲(节拍或 Stop 调用)。
func (b *processBridge) flushPending() {
if b == nil {
return
}
b.mu.Lock()
th := b.thought
b.mu.Unlock()
if th == nil {
return
}
text := th.stream.take()
if text == "" {
return
}
if len(text) > processMaxFrameBytes {
text = text[:processMaxFrameBytes] + "\n…(截断)"
}
b.emitDetail(&DetailEvent{
Category: detailCategoryAgentThought,
StepID: b.step.ID,
Role: b.step.Role,
ItemID: th.itemID,
Delta: text,
Collapsible: true,
})
}
func (b *processBridge) finishThought(th *thoughtBlock) {
th.stream.finish()
// 冲残余
if text := th.stream.take(); text != "" {
b.emitDetail(&DetailEvent{
Category: detailCategoryAgentThought,
StepID: b.step.ID,
Role: b.step.Role,
ItemID: th.itemID,
Delta: text,
Collapsible: true,
})
}
b.emitDetail(&DetailEvent{
Category: detailCategoryAgentThought,
StepID: b.step.ID,
Role: b.step.Role,
ItemID: th.itemID,
Title: th.title,
Done: true,
Collapsible: true,
})
}
func (b *processBridge) finishTool(t *toolBlock, status, result string) {
b.emitDetail(&DetailEvent{
Category: detailCategoryToolCall,
StepID: b.step.ID,
Role: b.step.Role,
ItemID: t.itemID,
Tool: &ToolEvent{
Name: t.name,
Status: status,
Input: trimProcessText(t.input.String()),
Output: trimProcessText(result),
DurationMs: time.Since(t.started).Milliseconds(),
},
Done: true,
Collapsible: true,
})
}
func (b *processBridge) emitDetail(p *DetailEvent) {
if b == nil || b.sink == nil {
return
}
b.sink.EmitDetail(*p)
}
// trimProcessText 工具入参/结果截断(防超大帧;保留首尾)。
func trimProcessText(s string) string {
if len(s) <= processToolTrimBytes {
return s
}
headN := processToolTrimBytes / 2
tailN := processToolTrimBytes / 4
head := s[:headN]
tail := s[len(s)-tailN:]
return head + "\n…(中间省略 " + fmt.Sprint(len(s)-headN-tailN) + " 字节)…\n" + tail
}
// AttachProcessBridge 给 agent.Request 装配过程桥(若 sink 缺失或无流式需求则原样返回)。
// 返回的 stop 函数在 Execute 返回后必须调用一次(定稿与冲刷残余)。
func AttachProcessBridge(sink ProgressSink, step StepSpec, req *agent.Request) (stop func()) {
if sink == nil || req == nil {
return func() {}
}
bridge := newProcessBridge(sink, step, FriendlyRoleName(step.Role))
if bridge == nil {
return func() {}
}
prev := req.OnEvent
if prev == nil {
req.OnEvent = bridge.Handle
} else {
req.OnEvent = func(ev agent.ProcessEvent) {
prev(ev)
bridge.Handle(ev)
}
}
return bridge.Stop
}