Files
AskaEth c907a34510 feat(gateway): 面 1 补齐 —— 消息读取面 toolCalls[](工具回看)
前提由 FV2 提供(图自 v0.77 起带每路 turnId、每路各落一行)。本片:
- 迁移 0023:flow_graphs 加「按路查询键」(写入时回填图的 TurnID),老行按缺席处理
- GET /api/v1/sessions/{id}/messages 加**可选** toolCalls[],形状严格照 Docs/api-client.md
  v0.76 ① 已登记的:{name, status(ok|error|unknown), durationMs, argsSummary, resultSummary}
- 🔴 图取不到 ⇒ **字段整体缺席**(绝不回空数组冒充「没调过工具」)
- 一条批量 SQL(按页内 turnId 集合),禁 N+1;单条 ≤20 个工具、resultSummary ≤500 字符(常量+依据)
- 只放图里已有的、已脱敏内容,不放宽脱敏通道
2026-10-06 09:03:55 +08:00

260 lines
12 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 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]) + "…"
}