package gateway // 本文件是消息读取面的**工具回看面**(契约 v0.76 ① 的 `toolCalls[]`;切片 CODE-TC)。 // // 它回答安卓侧那个问题:「回看这条回复时,它当时到底调了什么工具、结果如何」。答案是 // **已落盘的轮次链路图**(`flow_graphs`,迁移 0022/0023,保留期 168h)——**不从正文 // 反解、不从内存态猜、不按时间窗凑**。链路:消息行的 `chat_messages.turn_id`(路标识, // 迁移 0018)→ `flow_graphs.lane_turn_id`(迁移 0023,等值索引)→ 图里的 `tool` 节点 // → 本文件的 DTO。 // // 四条硬口径: // // - **缺席而非空数组**:`toolCalls` 出现 ⇔ 这一轮**确实调用过工具**。图取不到 // (超保留期 / 该轮没落盘 / 隔离渠道会话从不入库 / 该路标识查不到图)时字段 // **整体缺席**,绝不回 `[]` 去冒充「这一轮没调过工具」(契约明写)。 // - **只搬运、不加工**:`argsSummary` / `resultSummary` 逐字取自图里**出图时已脱敏** // 的 props(design/19 §3 的放行表 + 凭据形状清洗 + 截断)。这里**不做二次脱敏** // (那会掩盖「图侧漏了」这个真问题),也**绝不**放宽 —— 图上没有的内容,这里就没有。 // - **一次批量 SQL**:一页消息的路标识集合交给 `FlowGraphStore.GraphsByTurnID` 一次 // 查完,绝不按消息逐条查(禁 N+1)。 // - **回看面失败不拖垮正文**:查图失败只记一条带来源的 WARN 并让字段缺席 —— 消息本身 // 已经读到了,让一个**可视化存档**的故障把整页历史变成 500 是反的。这也不是 // 「静默」:每一次失败都有日志,且字段缺席本就是契约登记的语义。 import ( "context" "encoding/json" "strings" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace" ) // historyToolCall 是消息 DTO 里一条工具调用的线上结构(契约 v0.76 ① 登记的**五个键**)。 // // `name` 与 `status` **恒在**(一条工具调用至少有个名字与一个状态结论);后三个键 // `omitempty` —— 图上给不出那一项时它**缺席**,绝不用 `0` 或空串去编一个值 // (与 `turnId` / `clientMsgId` / `documents` 同一口径)。 type historyToolCall struct { // Name 是工具名(图节点的 `props.tool`,回落节点 label —— 两者同源:建图时 // label 就是去掉 `tool.` 前缀的 span 名)。 Name string `json:"name"` // Status ∈ ok | error | unknown(闭集三态)。unknown 覆盖图上一切非终态结论 // (running / idle / 空),因此客户端**永远**能按三态分支,不必猜。 Status string `json:"status"` // DurationMS 是这次调用在图上量到的耗时(毫秒)。缺席 = 图上给不出 // (缺起止时间戳,或不足 1 毫秒)—— 绝不估算。 DurationMS int64 `json:"durationMs,omitempty"` // ArgsSummary 是图上**已脱敏**的参数摘要,逐字搬运(对象则压成紧凑 JSON)。 // 缺席 = 图里没有这一项(例如这次调用没有任何放行表内的参数值)。 ArgsSummary string `json:"argsSummary,omitempty"` // ResultSummary 是图上**已脱敏**的结果摘要,逐字搬运。失败的调用在图上只有 // error 摘要(写侧二选一),这里如实回它 —— 否则 `status:"error"` 就只剩 // 一个「失败了」而没有任何原因。 ResultSummary string `json:"resultSummary,omitempty"` } // 工具调用的状态闭集(契约 v0.76 ①:`ok` | `error` | `unknown`)。它们是**线上 // 契约值**,与图节点的状态枚举(idle/running/done/error)刻意不同名:后者是面板的 // 渲染状态,前者是「这次调用成了没有」的结论,映射见 toolCallStatusOf。 const ( toolCallStatusOK = "ok" toolCallStatusError = "error" toolCallStatusUnknown = "unknown" ) // 图上的判据常量:工具节点的 kind,以及该节点的 props 键。 // // 都是**图的线上形状**(`trace.FlowNode` 的 JSON 字段),不是 API,因此 `trace` 包没有 // 导出它们。取值在 trace 侧由 `nodeKindTool` / `safePropKeys` 定义 // (`internal/runtime/trace/graph_build.go` / `graph_props.go`);两处必须同值, // 由本包的测试用**真建图器**(`trace.RunGraph` + `AddSpan`)钉住 —— 只按字面量断言 // 会与实现一起漂移。 const ( flowNodeKindTool = "tool" flowPropTool = "tool" flowPropArgsSummary = "args_summary" flowPropResultSummary = "result_summary" flowPropError = "error" ) const ( // maxToolCallsPerMessage 是单条消息最多回看的工具调用条数,超出按**图序** // (建图顺序 ≈ 时序)取前 N 条。 // // 依据:AGENTS §9 的「工具循环上限 8 轮」,每轮可并发调用若干个工具 ⇒ 20 覆盖 // 8 轮 × 2~3 个并发的现实上界。上限的存在是因为这段内容挂在**每一条**消息上: // 一页 50 条消息若各自背着整轮的工具历史,读接口的响应会随工具数无界增长。 // 丢弃的是**最旧之外的尾巴**而不是随机截断,客户端因此看到的永远是「最先发生的 // 那些调用」。 maxToolCallsPerMessage = 20 // maxToolCallSummaryRunes 是 `argsSummary` / `resultSummary` 各自的最大 rune 数 // (契约 v0.76 ① 登记的 `resultSummary ≤ 500`,两个摘要取同一上界)。 // // 依据:① 图上写入侧的结果摘要是 `RedactText(summarize(result))` ⇒ ≤ 80 rune // (`internal/runtime/trace/redact.go` 的 `maxSummaryRunes` 截断),因此对结果摘要 // 这只是**独立兜底**:写入侧一旦放宽、或出现绕过它直接写 graph_json 的写入者,读侧 // 仍保证单条消息的响应不被撑爆;② `args_summary` 是**键值对象**,最坏 24 个键 × // 80 rune 值(同目录 `loop/spanlog.go` 的 `maxArgSummaryKeys` / `maxArgValueRunes`) // ≈ 2.4k rune,这个上界**确实会生效**。 maxToolCallSummaryRunes = 500 ) // attachToolCalls 给一页消息补上各自的工具回看面(就地写进 DTO 切片)。 // // 它只认页内**非空**的 `turnId`:老行 / IM 镜像 / 落库那刻还没有路的消息没有标识, // 因此本就无从查图 —— 它们的 `toolCalls` 缺席,与「图取不到」得到同一个诚实结果。 func (s *Server) attachToolCalls(ctx context.Context, sessionID string, msgs []historyMessage) { ids := pageTurnIDs(msgs) if len(ids) == 0 || s.flowGraphs == nil { // 无标识可查 / 图源未接线:整体缺席(后者不是错误,是「没有这一面」)。 return } graphs, err := s.flowGraphs.GraphsByTurnID(ctx, sessionID, ids) if err != nil { // 只让回看面缺席,不让正文陪葬;失败必须可见(带来源的 WARN,只记结构化字段: // 会话 / 待查标识数 / 错误,绝不打印图内容或正文)。 s.log.Warn("history tool calls unavailable", "err", err, "session", sessionID, "turns", len(ids)) return } for i := range msgs { g := graphs[msgs[i].TurnID] if g == nil { continue // 该路没有已落盘的图:缺席,绝不回空数组 } if calls := toolCallsOf(g); len(calls) > 0 { msgs[i].ToolCalls = calls } } } // pageTurnIDs 收集页内非空且**互不相同**的路标识(保持首次出现顺序)。去重在这里做, // 是因为同一路的两条消息(提问 + 回答)必然带来重复:集合小一点、查询参数少一点, // 且「一页一次批量、集合就是这页的路」这件事在读侧一眼可见(存储侧另有自己的去重 // 兜底,两处都不依赖对方)。 func pageTurnIDs(msgs []historyMessage) []string { seen := make(map[string]bool, len(msgs)) out := make([]string, 0, len(msgs)) for _, m := range msgs { id := strings.TrimSpace(m.TurnID) if id == "" || seen[id] { continue } seen[id] = true out = append(out, id) } return out } // toolCallsOf 把一轮的图投影成工具回看面:按图序取 `kind == "tool"` 的节点,逐个映射为 // `historyToolCall`,最多 `maxToolCallsPerMessage` 条。没有任何工具节点时返回 nil // (调用方据此让字段缺席)。 func toolCallsOf(g *trace.FlowGraph) []historyToolCall { if g == nil { return nil } out := make([]historyToolCall, 0, 4) for i := range g.Nodes { if len(out) >= maxToolCallsPerMessage { break } n := g.Nodes[i] if n.Kind != flowNodeKindTool { continue } call := historyToolCall{ Name: toolCallNameOf(n), Status: toolCallStatusOf(n.Status), DurationMS: toolCallDurationMS(n), ResultSummary: toolCallResultSummary(n), } call.ArgsSummary = toolCallArgsSummary(n) out = append(out, call) } if len(out) == 0 { return nil } return out } // toolCallStatusOf 把图节点的状态映射进契约的三态闭集:done ⇒ ok、error ⇒ error、 // 其余(running / idle / 未知)⇒ unknown。未知一律落进 unknown 而不是 ok —— 三态里 // 唯一的安全默认是「不知道」,绝不把没结论的调用说成成功。 func toolCallStatusOf(nodeStatus string) string { switch nodeStatus { case trace.FlowStatusDone: return toolCallStatusOK case trace.FlowStatusError: return toolCallStatusError default: return toolCallStatusUnknown } } // toolCallNameOf 取工具名:优先节点 props 里的 `tool`(落盘的就是这个键),缺失时回落 // 节点 label(建图时 label = 去掉 `tool.` 前缀的 span 名,两者同源)。 func toolCallNameOf(n trace.FlowNode) string { if name, ok := n.Props[flowPropTool].(string); ok && strings.TrimSpace(name) != "" { return name } return n.Label } // toolCallDurationMS 从节点的起止时间戳量耗时。任一时间戳缺失(图里没有 = 量不出) // 或差值为非正时返回 0(线上随之省略该键)—— 绝不估算,也绝不回一个负数。 func toolCallDurationMS(n trace.FlowNode) int64 { if n.StartedAt.IsZero() || n.EndedAt.IsZero() { return 0 } d := n.EndedAt.Sub(n.StartedAt) if d <= 0 { return 0 } return d.Milliseconds() } // toolCallArgsSummary 把图上已脱敏的 `args_summary` 渲染成一行文本:对象压成紧凑 JSON // (键序由 encoding/json 排序 ⇒ 同一份参数每次都渲染成同一串),本就是字符串时原样用。 // 值不在这里再清洗 —— 图侧写入时已逐个过凭据形状清洗与截断(design/19 §3)。 func toolCallArgsSummary(n trace.FlowNode) string { v, ok := n.Props[flowPropArgsSummary] if !ok { return "" } if s, isStr := v.(string); isStr { return truncateToolSummary(s) } b, err := json.Marshal(v) if err != nil { // 图上写的是 JSON 可编码的值,走到这里只能是外部改动;**绝不**把无法渲染的 // 值悄悄换成 `{}` 或 `null` 那种看起来像真摘要的东西 —— 如实缺席。 return "" } return truncateToolSummary(string(b)) } // toolCallResultSummary 取图上已脱敏的结果摘要;没有它时回落 `error` 摘要(写侧对一次 // 调用**二选一**写这两个键,见 `loop/spanlog.go` 的 recordToolDone)—— 失败的调用因此 // 也带得出原因。两者都没有时返回空串(该键随 omitempty 缺席)。 func toolCallResultSummary(n trace.FlowNode) string { if s, ok := n.Props[flowPropResultSummary].(string); ok && s != "" { return truncateToolSummary(s) } if s, ok := n.Props[flowPropError].(string); ok && s != "" { return truncateToolSummary(s) } return "" } // truncateToolSummary 把摘要截到 maxToolCallSummaryRunes 个 rune,确实被截断时末位换成 // 省略号(与图上写入侧同一手法:面板/客户端一眼看得出「这里被截过」)。按 rune 截, // 绝不切开多字节字符。 func truncateToolSummary(s string) string { r := []rune(s) if len(r) <= maxToolCallSummaryRunes { return s } return string(r[:maxToolCallSummaryRunes-1]) + "…" }