feat(stream): 打通小队过程事件到持久化 detail 帧
pi-native 与 runner NDJSON 把推理和工具调用投影为 detail,任务流始终落库;非 200 的流式尝试回落到一次性 /v1/run,避免改变既有错误分类。
This commit is contained in:
@@ -15,6 +15,19 @@ type Request struct {
|
||||
Timeout time.Duration `json:"timeout"`
|
||||
// ToolProfile is workspace (default, document tools) or reasoning (no tools).
|
||||
ToolProfile string `json:"tool_profile,omitempty"`
|
||||
// OnEvent 可选过程事件回调(nil = 调用方不需要过程,后端行为与现状完全一致)。
|
||||
// 实现方必须保证:回调在 Execute 返回前完成(同步调用或等待收尾),无泄漏 goroutine;
|
||||
// 回调内不得阻塞(消费方自行节流/异步入队)。
|
||||
OnEvent func(ev ProcessEvent)
|
||||
}
|
||||
|
||||
// ProcessEvent 对 agentx 事件与远程 runner NDJSON 过程帧的统一投影,
|
||||
// 供小队过程流式展示消费(见 .doc/system-design/2026-09-28-agent-process-streaming-and-subagent-view-design.md)。
|
||||
type ProcessEvent struct {
|
||||
Type string // text_delta | reasoning_delta | tool_call_delta | tool_execution_start | tool_execution_end | message_end | ...
|
||||
Text string // delta 文本(Type 为 *_delta 时)
|
||||
Tool string // 工具名(tool_* 时)
|
||||
Detail string // 工具参数/结果摘要(tool_execution_* 时)
|
||||
}
|
||||
|
||||
const (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
@@ -217,6 +218,14 @@ func (p *PiAgentBackend) executeRemote(ctx context.Context, runnerURL string, re
|
||||
}
|
||||
reqBody, _ := json.Marshal(p.runnerPayload(req, workDir, true, timeout))
|
||||
|
||||
// 过程流式路径(2026-09-28 设计 C.5):调用方带 OnEvent 时优先走 NDJSON 流式
|
||||
// 端点,实时转发 proc 帧;旧 runner 404 时回落一次性 /v1/run(无过程事件)。
|
||||
if req.OnEvent != nil {
|
||||
if res, attempted, err := p.executeRemoteStream(ctx, runnerURL, reqBody, req, expectedRunID, timeout); attempted {
|
||||
return res, err
|
||||
}
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, runnerURL+"/v1/run", bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return &Result{
|
||||
@@ -244,8 +253,85 @@ func (p *PiAgentBackend) executeRemote(ctx context.Context, runnerURL string, re
|
||||
if len(limited) > MaxResultBodyBytes {
|
||||
return &Result{Success: false, ErrorReason: "runner result body too large"}, nil
|
||||
}
|
||||
return decodeRemoteResult(limited, expectedRunID, workDir, req)
|
||||
}
|
||||
|
||||
decoded, decErr := DecodeRemoteRun(limited, expectedRunID, true)
|
||||
// executeRemoteStream 调用 runner /v1/run/stream:proc 帧实时转发 OnEvent,
|
||||
// result 终帧走与一次性路径完全相同的 DecodeRemoteRun 校验。
|
||||
// attempted=false 表示端点不可用(404/405),调用方应回落 /v1/run。
|
||||
func (p *PiAgentBackend) executeRemoteStream(ctx context.Context, runnerURL string, reqBody []byte, req Request, expectedRunID string, timeout time.Duration) (*Result, bool, error) {
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, runnerURL+"/v1/run/stream", bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, false, nil
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Accept", "application/x-ndjson")
|
||||
ApplyRunnerAuth(httpReq, p.cfg.PiAgentRunnerAuthToken)
|
||||
|
||||
// 无总超时的 client:超时由 ctx 控制,逐行流式读取期间保持连接。
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, false, nil // 网络错误也回落一次性路径
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
// 任何非 200(含旧 runner 404 与瞬态 5xx)都回落一次性路径,
|
||||
// 保证错误分类与既有 /v1/run 语义完全一致(不改变治理边界)。
|
||||
resp.Body.Close()
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
var resultBody []byte
|
||||
var sawResult bool
|
||||
sc := bufio.NewScanner(resp.Body)
|
||||
sc.Buffer(make([]byte, 0, 64*1024), MaxResultBodyBytes)
|
||||
for sc.Scan() {
|
||||
if ctx.Err() != nil {
|
||||
return &Result{Success: false, ErrorReason: "stream context cancelled", Retryable: true}, true, nil
|
||||
}
|
||||
line := strings.TrimSpace(sc.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
var frame struct {
|
||||
Type string `json:"type"`
|
||||
Ev *ProcessEvent `json:"ev,omitempty"`
|
||||
Body json.RawMessage `json:"body,omitempty"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(line), &frame); err != nil {
|
||||
continue
|
||||
}
|
||||
switch frame.Type {
|
||||
case "proc":
|
||||
if frame.Ev != nil && req.OnEvent != nil {
|
||||
req.OnEvent(*frame.Ev)
|
||||
}
|
||||
case "result":
|
||||
resultBody = frame.Body
|
||||
sawResult = true
|
||||
}
|
||||
if sawResult {
|
||||
break // result 是终帧
|
||||
}
|
||||
}
|
||||
if !sawResult {
|
||||
return &Result{
|
||||
Success: false,
|
||||
ErrorReason: "pi runner stream ended without result frame",
|
||||
Retryable: true,
|
||||
}, true, nil
|
||||
}
|
||||
res, decErr := decodeRemoteResult(resultBody, expectedRunID, req.WorkDir, req)
|
||||
if decErr != nil {
|
||||
return res, true, decErr
|
||||
}
|
||||
return res, true, nil
|
||||
}
|
||||
|
||||
// decodeRemoteResult 一次性与流式路径共用的终态解码与落盘。
|
||||
func decodeRemoteResult(body []byte, expectedRunID, workDir string, req Request) (*Result, error) {
|
||||
decoded, decErr := DecodeRemoteRun(body, expectedRunID, true)
|
||||
if decErr != nil {
|
||||
reason := "typed result rejected"
|
||||
msg := decErr.Error()
|
||||
|
||||
@@ -93,6 +93,7 @@ func (p *PiNativeBackend) Execute(ctx context.Context, req Request) (*Result, er
|
||||
defer cancel()
|
||||
|
||||
var logs strings.Builder
|
||||
onProcess := req.OnEvent
|
||||
runRes, err := agentx.Run(runCtx, agentx.Config{
|
||||
Model: model,
|
||||
SystemPrompt: nativeSystemPrompt(req, profile),
|
||||
@@ -110,6 +111,14 @@ func (p *PiNativeBackend) Execute(ctx context.Context, req Request) (*Result, er
|
||||
logs.WriteString(ev.Text)
|
||||
}
|
||||
logs.WriteByte('\n')
|
||||
if onProcess != nil {
|
||||
onProcess(ProcessEvent{
|
||||
Type: ev.Type,
|
||||
Text: ev.Text,
|
||||
Tool: ev.Tool,
|
||||
Detail: ev.Detail,
|
||||
})
|
||||
}
|
||||
},
|
||||
}, llm.Message{Role: llm.RoleUser, Content: req.Prompt})
|
||||
if err != nil {
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
type sseChoice struct {
|
||||
Delta struct {
|
||||
Content string `json:"content"`
|
||||
Reasoning string `json:"reasoning_content"`
|
||||
ToolCalls []struct {
|
||||
Index int `json:"index"`
|
||||
ID string `json:"id"`
|
||||
@@ -94,6 +95,17 @@ func emitParsed(ctx context.Context, r io.Reader, out chan<- Event) {
|
||||
case out <- Event{Kind: KindTextDelta, Text: choice.Delta.Content}:
|
||||
}
|
||||
}
|
||||
// reasoning_content(国产 OpenAI 兼容模型的思考链)与正文分流:
|
||||
// 只作为 reasoning_delta 事件外发,不计入 Message.Content(与 runner
|
||||
// stripThinkBlocks"正文无 think"语义对齐)。
|
||||
if choice.Delta.Reasoning != "" {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
emitAbort(out, content.String(), ctx.Err())
|
||||
return
|
||||
case out <- Event{Kind: KindReasoningDelta, ReasoningDelta: choice.Delta.Reasoning}:
|
||||
}
|
||||
}
|
||||
for _, tc := range choice.Delta.ToolCalls {
|
||||
cur, ok := calls[tc.Index]
|
||||
if !ok {
|
||||
|
||||
@@ -189,3 +189,36 @@ func TestStreamErrorString(t *testing.T) {
|
||||
t.Fatal("error string")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSSEReasoningDelta(t *testing.T) {
|
||||
body := strings.Join([]string{
|
||||
`data: {"choices":[{"delta":{"reasoning_content":"先分析需求"},"index":0}]}`,
|
||||
`data: {"choices":[{"delta":{"reasoning_content":"再检索标准"},"index":0}]}`,
|
||||
`data: {"choices":[{"delta":{"content":"结论"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":2,"total_tokens":3}}`,
|
||||
`data: [DONE]`,
|
||||
"",
|
||||
}, "\n")
|
||||
var reasoning []string
|
||||
var text []string
|
||||
var end Event
|
||||
for ev := range ParseSSE(context.Background(), strings.NewReader(body)) {
|
||||
switch ev.Kind {
|
||||
case KindReasoningDelta:
|
||||
reasoning = append(reasoning, ev.ReasoningDelta)
|
||||
case KindTextDelta:
|
||||
text = append(text, ev.Text)
|
||||
case KindMessageEnd:
|
||||
end = ev
|
||||
}
|
||||
}
|
||||
if strings.Join(reasoning, "") != "先分析需求再检索标准" {
|
||||
t.Fatalf("reasoning deltas %v", reasoning)
|
||||
}
|
||||
// 正文与思考链分流:Content 只含 text,不含 reasoning
|
||||
if strings.Join(text, "") != "结论" {
|
||||
t.Fatalf("text deltas %v", text)
|
||||
}
|
||||
if end.Message.Content != "结论" {
|
||||
t.Fatalf("end content %q must not include reasoning", end.Message.Content)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,10 +29,11 @@ const (
|
||||
type EventKind string
|
||||
|
||||
const (
|
||||
KindTextDelta EventKind = "text_delta"
|
||||
KindToolCallDelta EventKind = "tool_call_delta"
|
||||
KindMessageEnd EventKind = "message_end"
|
||||
KindError EventKind = "error"
|
||||
KindTextDelta EventKind = "text_delta"
|
||||
KindReasoningDelta EventKind = "reasoning_delta"
|
||||
KindToolCallDelta EventKind = "tool_call_delta"
|
||||
KindMessageEnd EventKind = "message_end"
|
||||
KindError EventKind = "error"
|
||||
)
|
||||
|
||||
// ToolCall is one function call requested by the assistant.
|
||||
@@ -72,6 +73,7 @@ type Usage struct {
|
||||
type Event struct {
|
||||
Kind EventKind
|
||||
Text string
|
||||
ReasoningDelta string
|
||||
ToolIndex int
|
||||
ToolID string
|
||||
ToolName string
|
||||
|
||||
@@ -22,6 +22,9 @@ type Event struct {
|
||||
Message llm.Message
|
||||
Tool string
|
||||
Text string
|
||||
// Detail carries tool arguments/results or reasoning deltas for
|
||||
// process-visualization consumers; log writers may ignore it.
|
||||
Detail string
|
||||
}
|
||||
|
||||
// Config drives one agent session.
|
||||
@@ -178,6 +181,10 @@ func consume(ctx context.Context, cfg Config, messages []llm.Message) (llm.Event
|
||||
switch ev.Kind {
|
||||
case llm.KindTextDelta:
|
||||
emit(cfg, Event{Type: "text_delta", Text: ev.Text})
|
||||
case llm.KindReasoningDelta:
|
||||
emit(cfg, Event{Type: "reasoning_delta", Text: ev.ReasoningDelta})
|
||||
case llm.KindToolCallDelta:
|
||||
emit(cfg, Event{Type: "tool_call_delta", Tool: ev.ToolName, Detail: ev.ArgumentsDelta})
|
||||
case llm.KindError:
|
||||
return ev, ev.Err
|
||||
case llm.KindMessageEnd:
|
||||
|
||||
@@ -473,6 +473,9 @@ func (s *Server) handleRunTaskStream(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
}
|
||||
// includeDetails 参数保留解析兼容,但 worker 侧已一律产出 detail 事件
|
||||
// (2026-09-28 过程流式设计 C.6:detail 全量持久化与推送,认知负荷由前端
|
||||
// 默认折叠保证;该参数不再控制任务通道的事件产出)。
|
||||
_ = includeDetails
|
||||
_ = requestID
|
||||
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
package squad
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"chat-agent-system/internal/agent"
|
||||
"chat-agent-system/internal/stream"
|
||||
)
|
||||
|
||||
// 过程流常量:节流窗口 / 单帧上限 / 工具文本截断(设计文档 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 {
|
||||
streamer *stream.Streamer
|
||||
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 无流式需求(streamer 缺失),
|
||||
// 调用方对 nil bridge 调用任何方法都是安全 no-op。
|
||||
func newProcessBridge(streamer *stream.Streamer, step StepSpec, friendlyRole string) *processBridge {
|
||||
if streamer == nil {
|
||||
return nil
|
||||
}
|
||||
b := &processBridge{
|
||||
streamer: streamer,
|
||||
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(&stream.DetailPayload{
|
||||
Category: detailCategoryToolCall,
|
||||
StepID: b.step.ID,
|
||||
Role: b.step.Role,
|
||||
ItemID: itemID,
|
||||
Tool: &stream.ToolCallInfo{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 := &stream.ToolCallInfo{
|
||||
Name: name,
|
||||
Status: status,
|
||||
Input: trimProcessText(t.input.String()),
|
||||
Output: trimProcessText(result),
|
||||
DurationMs: duration,
|
||||
}
|
||||
b.emitDetail(&stream.DetailPayload{
|
||||
Category: detailCategoryToolCall,
|
||||
StepID: b.step.ID,
|
||||
Role: b.step.Role,
|
||||
ItemID: t.itemID,
|
||||
Tool: info,
|
||||
Collapsible: true,
|
||||
})
|
||||
// 工具项即时定稿(状态帧 + done 标记合并语义:终帧即最后状态)
|
||||
b.emitDetail(&stream.DetailPayload{
|
||||
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(&stream.DetailPayload{
|
||||
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(&stream.DetailPayload{
|
||||
Category: detailCategoryAgentThought,
|
||||
StepID: b.step.ID,
|
||||
Role: b.step.Role,
|
||||
ItemID: th.itemID,
|
||||
Delta: text,
|
||||
Collapsible: true,
|
||||
})
|
||||
}
|
||||
b.emitDetail(&stream.DetailPayload{
|
||||
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(&stream.DetailPayload{
|
||||
Category: detailCategoryToolCall,
|
||||
StepID: b.step.ID,
|
||||
Role: b.step.Role,
|
||||
ItemID: t.itemID,
|
||||
Tool: &stream.ToolCallInfo{
|
||||
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 *stream.DetailPayload) {
|
||||
if b == nil || b.streamer == nil {
|
||||
return
|
||||
}
|
||||
b.streamer.Emit(stream.StreamEvent{Type: stream.EventDetail, Detail: 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 装配过程桥(若 streamer 缺失或无流式需求则原样返回)。
|
||||
// 返回的 stop 函数在 Execute 返回后必须调用一次(定稿与冲刷残余)。
|
||||
func AttachProcessBridge(streamer *stream.Streamer, step StepSpec, req *agent.Request) (stop func()) {
|
||||
if streamer == nil || req == nil {
|
||||
return func() {}
|
||||
}
|
||||
bridge := newProcessBridge(streamer, 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
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package squad
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"chat-agent-system/internal/agent"
|
||||
"chat-agent-system/internal/stream"
|
||||
)
|
||||
|
||||
func TestProcessBridgeNilStreamerIsNoop(t *testing.T) {
|
||||
step := StepSpec{ID: "step_01", Role: "regulatory"}
|
||||
req := &agent.Request{}
|
||||
stop := AttachProcessBridge(nil, step, req)
|
||||
if req.OnEvent != nil {
|
||||
t.Fatal("nil streamer must not attach OnEvent")
|
||||
}
|
||||
req.OnEvent = func(agent.ProcessEvent) {} // 直接赋值不应有 panic
|
||||
stop() // no-op stop 不 panic
|
||||
}
|
||||
|
||||
func TestProcessBridgeReasoningFlow(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
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"}
|
||||
req := &agent.Request{}
|
||||
stop := AttachProcessBridge(streamer, step, req)
|
||||
defer stop()
|
||||
|
||||
req.OnEvent(agent.ProcessEvent{Type: "reasoning_delta", Text: "第一段思考"})
|
||||
req.OnEvent(agent.ProcessEvent{Type: "reasoning_delta", Text: "继续推理"})
|
||||
req.OnEvent(agent.ProcessEvent{Type: "message_end"})
|
||||
stop()
|
||||
// 再发事件(stop 后)不应 panic 且不应再发帧
|
||||
req.OnEvent(agent.ProcessEvent{Type: "reasoning_delta", Text: "ignored"})
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
var deltas, dones int
|
||||
var joined strings.Builder
|
||||
for _, ev := range events {
|
||||
if ev.Type != stream.EventDetail || ev.Detail == nil {
|
||||
continue
|
||||
}
|
||||
if ev.Detail.Category != detailCategoryAgentThought {
|
||||
t.Fatalf("unexpected category %s", ev.Detail.Category)
|
||||
}
|
||||
if ev.Detail.Delta != "" {
|
||||
deltas++
|
||||
joined.WriteString(ev.Detail.Delta)
|
||||
}
|
||||
if ev.Detail.Done {
|
||||
dones++
|
||||
}
|
||||
if ev.Detail.StepID != step.ID || ev.Detail.Role != step.Role {
|
||||
t.Fatalf("step identity missing: %+v", ev.Detail)
|
||||
}
|
||||
}
|
||||
if joined.String() != "第一段思考继续推理" {
|
||||
t.Fatalf("joined delta %q", joined.String())
|
||||
}
|
||||
if dones != 1 {
|
||||
t.Fatalf("expected exactly 1 done frame, got %d", dones)
|
||||
}
|
||||
if deltas < 1 {
|
||||
t.Fatal("expected at least one delta frame")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessBridgeToolFlow(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
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"}
|
||||
b := newProcessBridge(streamer, step, "撰写专员")
|
||||
if b == nil {
|
||||
t.Fatal("bridge expected")
|
||||
}
|
||||
|
||||
b.Handle(agent.ProcessEvent{Type: "tool_call_delta", Tool: "read_file", Detail: `{"path":"a.md"`})
|
||||
b.Handle(agent.ProcessEvent{Type: "tool_execution_start", Tool: "read_file"})
|
||||
b.Handle(agent.ProcessEvent{Type: "tool_call_delta", Tool: "read_file", Detail: `}`})
|
||||
b.Handle(agent.ProcessEvent{Type: "tool_execution_end", Tool: "read_file", Detail: "file content ..."})
|
||||
b.Stop()
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
var calling, done, withInput int
|
||||
for _, ev := range events {
|
||||
if ev.Type != stream.EventDetail || ev.Detail == nil || ev.Detail.Category != detailCategoryToolCall {
|
||||
continue
|
||||
}
|
||||
if ev.Detail.Tool == nil {
|
||||
t.Fatal("tool_call frame must carry Tool info")
|
||||
}
|
||||
switch {
|
||||
case ev.Detail.Tool.Status == "calling":
|
||||
calling++
|
||||
case ev.Detail.Done:
|
||||
done++
|
||||
}
|
||||
if strings.Contains(ev.Detail.Tool.Input, "a.md") {
|
||||
withInput++
|
||||
}
|
||||
}
|
||||
if calling != 1 {
|
||||
t.Fatalf("calling frames %d", calling)
|
||||
}
|
||||
if done < 1 {
|
||||
t.Fatal("expected done frame for tool item")
|
||||
}
|
||||
if withInput == 0 {
|
||||
t.Fatal("tool input aggregate lost")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessBridgeToolFailureStatus(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
var events []stream.StreamEvent
|
||||
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_end", Tool: "write_file", Detail: "error: disk full"})
|
||||
b.Stop()
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
sawFailed := false
|
||||
for _, ev := range events {
|
||||
if ev.Detail != nil && ev.Detail.Tool != nil && ev.Detail.Tool.Status == "failed" {
|
||||
sawFailed = true
|
||||
}
|
||||
}
|
||||
if !sawFailed {
|
||||
t.Fatal("error result must map to failed status")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrimProcessText(t *testing.T) {
|
||||
small := strings.Repeat("a", 100)
|
||||
if got := trimProcessText(small); got != small {
|
||||
t.Fatal("small text must pass through")
|
||||
}
|
||||
big := strings.Repeat("b", processToolTrimBytes*4)
|
||||
got := trimProcessText(big)
|
||||
if len(got) >= len(big) {
|
||||
t.Fatal("big text must be trimmed")
|
||||
}
|
||||
if !strings.Contains(got, "…(中间省略") {
|
||||
t.Fatal("trim marker missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessBridgeConcurrentSteps(t *testing.T) {
|
||||
// 并行组成员各自持有独立 bridge,事件互不串扰(设计 F.4)。
|
||||
var mu sync.Mutex
|
||||
seen := map[string]string{} // itemID -> role
|
||||
streamer := stream.NewStreamer(true, func(ev stream.StreamEvent) {
|
||||
if ev.Type != stream.EventDetail || ev.Detail == nil {
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
seen[ev.Detail.ItemID] = ev.Detail.Role
|
||||
mu.Unlock()
|
||||
})
|
||||
b1 := newProcessBridge(streamer, StepSpec{ID: "p1", Role: "regulatory"}, "法规专员")
|
||||
b2 := newProcessBridge(streamer, StepSpec{ID: "p2", Role: "market"}, "市场专员")
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < 20; i++ {
|
||||
b1.Handle(agent.ProcessEvent{Type: "reasoning_delta", Text: "r1"})
|
||||
}
|
||||
b1.Stop()
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < 20; i++ {
|
||||
b2.Handle(agent.ProcessEvent{Type: "reasoning_delta", Text: "r2"})
|
||||
}
|
||||
b2.Stop()
|
||||
}()
|
||||
wg.Wait()
|
||||
time.Sleep(50 * time.Millisecond) // 等冲刷
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
for itemID, role := range seen {
|
||||
if !strings.Contains(itemID, "p1") && !strings.Contains(itemID, "p2") {
|
||||
t.Fatalf("unexpected item %s", itemID)
|
||||
}
|
||||
want := "regulatory"
|
||||
if strings.Contains(itemID, "p2") {
|
||||
want = "market"
|
||||
}
|
||||
if role != want {
|
||||
t.Fatalf("item %s mapped to role %s, want %s", itemID, role, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+47
-43
@@ -264,25 +264,25 @@ func (d *WorkflowDriver) Execute(ctx context.Context, wsDir string, backend agen
|
||||
for _, s := range parallelGroup {
|
||||
sCopy := s
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
mu.Lock()
|
||||
if groupErr == nil {
|
||||
groupErr = fmt.Errorf("parallel step panic: %v", rec)
|
||||
}
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
if err := d.runOneStep(runCtx, wsDir, sCopy, backend, timeout, opts.StepNotify, &mu, &producedFiles, ledger); err != nil {
|
||||
mu.Lock()
|
||||
if groupErr == nil {
|
||||
groupErr = err
|
||||
}
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
mu.Lock()
|
||||
if groupErr == nil {
|
||||
groupErr = fmt.Errorf("parallel step panic: %v", rec)
|
||||
}
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
if err := d.runOneStep(runCtx, wsDir, sCopy, backend, timeout, opts.StepNotify, &mu, &producedFiles, ledger); err != nil {
|
||||
mu.Lock()
|
||||
if groupErr == nil {
|
||||
groupErr = err
|
||||
}
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
if groupErr != nil {
|
||||
@@ -335,25 +335,25 @@ func (d *WorkflowDriver) Execute(ctx context.Context, wsDir string, backend agen
|
||||
Duration: time.Since(start),
|
||||
}, rErr
|
||||
}
|
||||
// 治理:受控迁移至执行态(legacy 重跑场景直接放行)
|
||||
markStepExecuting(wf, step.ID)
|
||||
if err := d.runOneStep(runCtx, wsDir, step, backend, timeout, opts.StepNotify, &mu, &producedFiles, ledger); err != nil {
|
||||
// A5 非关键步骤降级:记录 degraded 继续推进,收口统一标 degraded
|
||||
if !step.IsCritical() {
|
||||
markStepDegraded(wsDir, wf, step, err)
|
||||
stepsRun++
|
||||
// 治理:受控迁移至执行态(legacy 重跑场景直接放行)
|
||||
markStepExecuting(wf, step.ID)
|
||||
if err := d.runOneStep(runCtx, wsDir, step, backend, timeout, opts.StepNotify, &mu, &producedFiles, ledger); err != nil {
|
||||
// A5 非关键步骤降级:记录 degraded 继续推进,收口统一标 degraded
|
||||
if !step.IsCritical() {
|
||||
markStepDegraded(wsDir, wf, step, err)
|
||||
stepsRun++
|
||||
_ = d.SaveWorkflow(wsDir, wf)
|
||||
continue
|
||||
}
|
||||
wf.Status = "failed"
|
||||
_ = d.SaveWorkflow(wsDir, wf)
|
||||
continue
|
||||
_ = d.updateWorkspaceStatus(wsDir, "failed")
|
||||
return &WorkflowExecutionResult{
|
||||
Success: false,
|
||||
ErrorReason: err.Error(),
|
||||
Duration: time.Since(start),
|
||||
}, err
|
||||
}
|
||||
wf.Status = "failed"
|
||||
_ = d.SaveWorkflow(wsDir, wf)
|
||||
_ = d.updateWorkspaceStatus(wsDir, "failed")
|
||||
return &WorkflowExecutionResult{
|
||||
Success: false,
|
||||
ErrorReason: err.Error(),
|
||||
Duration: time.Since(start),
|
||||
}, err
|
||||
}
|
||||
stepsRun++
|
||||
// 治理:FSM 受控迁移 + 步骤归档锁定 + 软线提醒
|
||||
if tErr := TransitionStep(wf, step.ID, StepStateCompleted); tErr == nil {
|
||||
@@ -547,16 +547,20 @@ func (d *WorkflowDriver) runOneStep(
|
||||
Targets: targets,
|
||||
Timeout: stepTimeout,
|
||||
}
|
||||
// 过程流桥:把成员执行的思考/工具调用增量翻译为 detail 事件(streamer 缺失时为 no-op)。
|
||||
// 注意 retry 循环内多次 Execute 共用一个 bridge 语义(同 step 聚合),Stop 只在首次调用。
|
||||
stopProcessBridge := AttachProcessBridge(streamer, step, &req)
|
||||
|
||||
var (
|
||||
res *agent.Result
|
||||
execErr error
|
||||
matErr error
|
||||
duration int64
|
||||
ok bool
|
||||
lastAssess agent.FailureAssessment
|
||||
basePrompt = string(taskData)
|
||||
res *agent.Result
|
||||
execErr error
|
||||
matErr error
|
||||
duration int64
|
||||
ok bool
|
||||
lastAssess agent.FailureAssessment
|
||||
basePrompt = string(taskData)
|
||||
)
|
||||
defer stopProcessBridge()
|
||||
for attempt := 1; attempt <= agent.MaxTransientStepInvocations; attempt++ {
|
||||
onDisk, _ := os.ReadFile(prevOutPath)
|
||||
wroteThisInvocation := deliverableWroteThisInvocation(prevContent, onDisk)
|
||||
|
||||
@@ -69,9 +69,25 @@ type DetailPayload struct {
|
||||
Category string `json:"category"` // agent_thought, step_output, tool_call, log
|
||||
StepID string `json:"step_id,omitempty"` // 步骤标识 (如 tasks/01_regulatory)
|
||||
Role string `json:"role,omitempty"` // 对应角色
|
||||
Title string `json:"title"` // 详情标题
|
||||
Content string `json:"content"` // 完整推演正文或技术草稿
|
||||
Collapsible bool `json:"collapsible"` // 客户端是否应当默认折叠显示
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"` // 完整推演正文或技术草稿
|
||||
Collapsible bool `json:"collapsible"` // 客户端是否应当默认折叠显示
|
||||
// ---- 过程流扩展(2026-09-28 subagent 过程展示设计 B.2)----
|
||||
ItemID string `json:"item_id,omitempty"` // 聚合项 ID:一个思考块/一次工具调用
|
||||
// 增量模式:Delta 追加到 ItemID 项;Content 为快照对账帧;Done 定稿。
|
||||
Delta string `json:"delta,omitempty"`
|
||||
Done bool `json:"done,omitempty"`
|
||||
// ToolCallInfo 工具调用状态帧(Category == "tool_call" 时)。
|
||||
Tool *ToolCallInfo `json:"tool,omitempty"`
|
||||
}
|
||||
|
||||
// ToolCallInfo 一次工具调用的状态投影(thinking 流式展示用)。
|
||||
type ToolCallInfo struct {
|
||||
Name string `json:"name"` // write_file / read_file / ...
|
||||
Status string `json:"status"` // calling | succeeded | failed
|
||||
Input string `json:"input,omitempty"` // 入参摘要(截断)
|
||||
Output string `json:"output,omitempty"` // 结果摘要(截断)
|
||||
DurationMs int64 `json:"duration_ms,omitempty"`
|
||||
}
|
||||
|
||||
// DeliverablePayload A轨正式合规文档交付载荷
|
||||
|
||||
@@ -361,7 +361,10 @@ func (m *Manager) runWorker(ctx context.Context, ar *activeRun, taskReq bizdev.T
|
||||
}
|
||||
m.appendEvent(ar, ev)
|
||||
}
|
||||
ctxWithS, streamer := stream.WithListener(ctx, false, listener)
|
||||
// includeDetails=true:任务通道的 detail(思考链/工具调用过程)全量持久化并推送
|
||||
// (2026-09-28 过程流式设计 C.6——"刷新后过程消失"违背透明可追溯原则;
|
||||
// 认知负荷由前端默认折叠保证,旧前端忽略 detail 字段无破坏)。
|
||||
ctxWithS, streamer := stream.WithListener(ctx, true, listener)
|
||||
|
||||
var taskResult *bizdev.TaskRunResult
|
||||
var execErr error
|
||||
|
||||
Reference in New Issue
Block a user