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 字符(常量+依据)
- 只放图里已有的、已脱敏内容,不放宽脱敏通道
This commit is contained in:
1 parent
99a644d545
commit
c907a34510
10 files changed
+1429
-16
No files matched your search
@@ -0,0 +1,259 @@
|
||||
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]) + "…"
|
||||
}
|
||||
Reference in new issue
Block a user