Why: F14 backend half - tool events used the tool name as the call id and hardcoded status ok, so same-named tools could not be paired and failures displayed as success; parallel callbacks shared artifact state. Evidence: squad runner event tests pass (verify-u11c.json); pairing, error mapping and legacy passthrough cases added; artifact list locked. Impact: legacy empty-field events keep byte-identical output. Refs: feature/agent-stack-audit-remediation-b2 U11c
189 lines
6.7 KiB
Go
189 lines
6.7 KiB
Go
package runtime
|
||
|
||
import (
|
||
"strings"
|
||
"sync"
|
||
|
||
"agent-squad/squad"
|
||
core "agent-system-core"
|
||
)
|
||
|
||
// toolResultSummaryLen 是工具结果摘要的截断长度(与既有展示一致)。
|
||
const toolResultSummaryLen = 160
|
||
|
||
// toolEventID 是宿主侧的工具调用身份:优先用上游 CallID(并行同名工具据此配对,
|
||
// 而不是按到达顺序);空串 = legacy/未知,退回工具名,保持既有展示不变。
|
||
func toolEventID(ev squad.ProcessEvent) string {
|
||
if id := strings.TrimSpace(ev.CallID); id != "" {
|
||
return id
|
||
}
|
||
return ev.Tool
|
||
}
|
||
|
||
// toolEventOutcome 把上游 tool_execution_end 翻译成宿主展示的真实结局。
|
||
//
|
||
// 取值域与上游一致(ok | error | denied | canceled),不新增、不改写上游状态:
|
||
// - Status 非空:原样透传;
|
||
// - Status 为空且没有失败信号(ErrorReason 也空):legacy/未知,沿用旧的 "ok"
|
||
// 与 Detail 摘要(字节一致,不臆造结局);
|
||
// - Status 为空但有 ErrorReason:有失败信号而状态未知,报 error 而不是 ok;
|
||
// - 非 ok 时摘要取 ErrorReason 原文(截断),上游没给原因才回退 Detail。
|
||
func toolEventOutcome(ev squad.ProcessEvent) (status, summary string) {
|
||
status = strings.TrimSpace(ev.Status)
|
||
if status == "" {
|
||
if strings.TrimSpace(ev.ErrorReason) == "" {
|
||
return squad.ToolStatusOK, truncateRun(ev.Detail, toolResultSummaryLen)
|
||
}
|
||
status = squad.ToolStatusError
|
||
}
|
||
if status == squad.ToolStatusOK {
|
||
return status, truncateRun(ev.Detail, toolResultSummaryLen)
|
||
}
|
||
if strings.TrimSpace(ev.ErrorReason) != "" {
|
||
return status, truncateRun(ev.ErrorReason, toolResultSummaryLen)
|
||
}
|
||
return status, truncateRun(ev.Detail, toolResultSummaryLen)
|
||
}
|
||
|
||
// toolFailure 是一次未被恢复的工具失败(用于终局文本一致性检查,不覆盖工具部件)。
|
||
type toolFailure struct {
|
||
Tool string
|
||
CallID string
|
||
Status string
|
||
Summary string
|
||
}
|
||
|
||
// squadEventLog 是小队过程帧在宿主侧的聚合点。
|
||
//
|
||
// 并行工具调用会从多个 goroutine 回调进来(内核 executeParallel 逐个派发),
|
||
// 因此流式文本、工具部件、用量与 pending 配对表全部在同一把锁下更新:
|
||
// 既不丢条目,也不会把 A 调用的 start 与 B 调用的 end 交错写坏。
|
||
// 事件发射与部件追加在同一临界区内,保持到达顺序与最终部件顺序一致。
|
||
type squadEventLog struct {
|
||
rc RunCtx
|
||
|
||
mu sync.Mutex
|
||
streamed strings.Builder
|
||
textIdx int
|
||
parts []core.Part
|
||
pending map[string]string // callID → 工具名(start/end 配对)
|
||
failure *toolFailure // 最近一次失败;同名工具后续再起即视为已恢复
|
||
}
|
||
|
||
// newSquadEventLog 绑定一次运行的过程帧回调。
|
||
func newSquadEventLog(rc RunCtx) *squadEventLog {
|
||
return &squadEventLog{rc: rc, pending: map[string]string{}}
|
||
}
|
||
|
||
// onEvent 是小队过程帧回调(并发安全)。
|
||
func (l *squadEventLog) onEvent(ev squad.ProcessEvent) {
|
||
l.mu.Lock()
|
||
defer l.mu.Unlock()
|
||
switch ev.Type {
|
||
case "text_delta":
|
||
if ev.Text == "" {
|
||
return
|
||
}
|
||
l.streamed.WriteString(ev.Text)
|
||
_ = l.rc.Emit(core.EvMessageDelta, core.MessageDeltaPayload{Index: l.textIdx, Text: ev.Text})
|
||
case "reasoning_delta":
|
||
if ev.Text != "" {
|
||
_ = l.rc.Emit(core.EvThinkingDelta, core.ThinkingDeltaPayload{Text: ev.Text})
|
||
}
|
||
case "tool_execution_start":
|
||
id := toolEventID(ev)
|
||
if id != "" {
|
||
l.pending[id] = ev.Tool
|
||
}
|
||
l.noteRecovered(ev.Tool)
|
||
_ = l.rc.Emit(core.EvToolCall, core.ToolCallPayload{CallID: id, Name: ev.Tool, Arguments: ev.Detail})
|
||
l.parts = append(l.parts, core.Part{Type: core.PartToolCall, Process: true, ToolCall: &core.ToolCallPart{ID: id, Name: ev.Tool, Arguments: ev.Detail}})
|
||
case "tool_execution_end":
|
||
id := toolEventID(ev)
|
||
if tool, ok := l.pending[id]; ok && ev.Tool == "" {
|
||
ev.Tool = tool
|
||
}
|
||
delete(l.pending, id)
|
||
status, summary := toolEventOutcome(ev)
|
||
// 发射与部件同源:前端过程行与落库部件说的是同一件事。
|
||
_ = l.rc.Emit(core.EvToolResult, core.ToolResultPayload{CallID: id, Status: status, Summary: summary})
|
||
l.parts = append(l.parts, core.Part{Type: core.PartToolResult, Process: true, ToolResult: &core.ToolResultPart{CallID: id, Status: status, Summary: summary}})
|
||
if status == squad.ToolStatusOK {
|
||
l.noteRecovered(ev.Tool)
|
||
} else {
|
||
l.failure = &toolFailure{Tool: ev.Tool, CallID: id, Status: status, Summary: summary}
|
||
}
|
||
case "usage":
|
||
if sink, ok := l.rc.(interface{ AddUsage(core.Usage) }); ok {
|
||
sink.AddUsage(core.Usage{
|
||
PromptTokens: ev.PromptTokens, CompletionTokens: ev.CompletionTokens, TotalTokens: ev.PromptTokens + ev.CompletionTokens,
|
||
LastPromptTokens: ev.LastPromptTokens, LastCompletionTokens: ev.LastCompletionTokens,
|
||
CacheReadTokens: ev.CacheReadTokens, CacheWriteTokens: ev.CacheWriteTokens,
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
// noteRecovered 把同名工具的后续进展(再次发起或调用成功)记为已恢复:
|
||
// 失败部件仍留在过程部件里,只是不再让收尾文本替它宣布成功。
|
||
func (l *squadEventLog) noteRecovered(tool string) {
|
||
if l.failure != nil && l.failure.Tool == tool {
|
||
l.failure = nil
|
||
}
|
||
}
|
||
|
||
// reply 是本次运行聚合到的模型文本(不含任何宿主补写)。
|
||
func (l *squadEventLog) reply() string {
|
||
l.mu.Lock()
|
||
defer l.mu.Unlock()
|
||
return l.streamed.String()
|
||
}
|
||
|
||
// parts 返回过程部件的快照(复制,避免把内部切片交给最终部件)。
|
||
func (l *squadEventLog) toolParts() []core.Part {
|
||
l.mu.Lock()
|
||
defer l.mu.Unlock()
|
||
return append([]core.Part(nil), l.parts...)
|
||
}
|
||
|
||
// unresolvedFailure 返回终局时仍未恢复的工具失败,没有则 nil。
|
||
func (l *squadEventLog) unresolvedFailure() *toolFailure {
|
||
l.mu.Lock()
|
||
defer l.mu.Unlock()
|
||
if l.failure == nil {
|
||
return nil
|
||
}
|
||
f := *l.failure
|
||
return &f
|
||
}
|
||
|
||
// finalReply 决定终局文本。模型文本原样保留(不代写、不改写);只有宿主兜底的
|
||
// 「已完成。」在仍有未恢复的工具失败时会被换成如实说明,避免收尾文本与失败部件
|
||
// 自相矛盾。legacy 事件(无 CallID/Status)走不到这里,展示与旧行为一致。
|
||
func (l *squadEventLog) finalReply(modelText, summary, fallback string) string {
|
||
reply := strings.TrimSpace(modelText)
|
||
if reply == "" {
|
||
reply = strings.TrimSpace(summary)
|
||
}
|
||
if reply != "" {
|
||
return reply
|
||
}
|
||
if f := l.unresolvedFailure(); f != nil {
|
||
return failedToolLine(f)
|
||
}
|
||
return fallback
|
||
}
|
||
|
||
// failedToolLine 如实描述未恢复的工具失败,不声称成功,也不编造原因。
|
||
func failedToolLine(f *toolFailure) string {
|
||
name := strings.TrimSpace(f.Tool)
|
||
if name == "" {
|
||
name = "工具"
|
||
}
|
||
reason := strings.TrimSpace(f.Summary)
|
||
if reason == "" {
|
||
return name + " 未完成(" + f.Status + ")"
|
||
}
|
||
return name + " 未完成(" + f.Status + "):" + reason
|
||
}
|