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
406 lines
9.8 KiB
Go
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
|
|
}
|