Files
weichen fc0a0505b3 Honor tool-call identity and real status in squad runner events
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
2026-10-22 03:49:37 +00:00

189 lines
6.7 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}