前提由 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 字符(常量+依据)
- 只放图里已有的、已脱敏内容,不放宽脱敏通道
260 lines
12 KiB
Go
260 lines
12 KiB
Go
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]) + "…"
|
||
}
|