package loop import ( "fmt" "sort" "strings" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/guardrail" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace" ) // 记录在 span 上的日志事件类型,使 flow 图的节点 log[] 展示 // 每一步发生了什么(design/19 §1 节点实时日志)。仅当该步真正 // 完成时才追加事件,因此日志绝不宣告未运行的工作。 const ( // EventLLMDone 在模型调用返回后记录到 llm_call span 上。 EventLLMDone = "llm.done" // EventToolDone 在工具调用落定后记录到 tool_call span 上。 EventToolDone = "tool.done" // eventGuardrailDone 在输入/输出护栏检查落定后记录到 guardrail span 上。 eventGuardrailDone = "guardrail.done" // eventMemoryDone 在记忆召回/写入落定后记录到 memory span 上。 eventMemoryDone = "memory.done" ) // 旁路节点(design/19 §1,裁决 11.4.2)的 span 名:guardrail 记输入/输出 // 检查,memory 记召回/写入。名字即图上的节点标签。 const ( spanNameGuardrailInput = "guardrail.input" spanNameGuardrailOutput = "guardrail.output" spanNameMemoryRecall = "memory.recall" spanNameMemoryWrite = "memory.write" ) // maxLogSummaryRunes 在发布前限制日志取值的长度。图在 // 输出时还会再次脱敏与截断;这里只是让单个 span 保持小。 const maxLogSummaryRunes = 240 // 工具参数摘要的规格(裁决 11.4.3,权威定义见 DevDocs/design/19 §1): // 单值最多 maxArgValueRunes 个 rune,一次调用最多放行 maxArgSummaryKeys 个参数值。 const ( maxArgValueRunes = 80 maxArgSummaryKeys = 24 ) // argValueKeys 是「值可显示」的参数键放行表(裁决 11.4.3):只放行结构/元数据类 // 键,表外键一律只出键名。工具参数正是凭据、对话正文或含隐私的文件路径最容易 // 出现的地方,因此放行表取保守口径 —— 想放行一个新键,必须先改设计再改这里。 var argValueKeys = map[string]bool{ // 文件与路径(读写目标本身是运维信息,不是内容) "path": true, "file": true, "file_path": true, "filename": true, "dir": true, "directory": true, "input_path": true, "output_path": true, // 检索/匹配(查询词与模式,出图前仍经凭据形状清洗) "query": true, "q": true, "pattern": true, "name": true, "id": true, "url": true, // 分类与格式 "kind": true, "scope": true, "format": true, "encoding": true, "mode": true, "project": true, "project_id": true, "timezone": true, // 时间窗与分页(纯数值) "date": true, "from": true, "to": true, "limit": true, "offset": true, "page": true, "top_k": true, "max_results": true, "count": true, } // recordLLMDone 把一次已完成模型调用的脱敏摘要追加到其 // span:服务该 turn 的模型、两个 stop reason、请求了多少工具调用 // 以及该调用耗时。不记录任何提示词或补全文本, // 因此 llm 节点的日志绝不携带对话内容。ag 是该 turn 的 agent // (按角色池条目),因此多角色进程上报的是实际 // 服务该调用的模型,而不是默认角色的模型。 func (l *Loop) recordLLMDone(span *trace.Span, out *stepOutcome, ag *agent.Agent) { if span == nil || out == nil { return } props := map[string]any{ "stop_reason": out.stopReason, "tool_calls": len(out.toolCalls), "elapsed_ms": elapsedMS(span), } if m := modelName(out, ag); m != "" { props["model"] = m } if out.modelStopReason != "" { props["model_stop_reason"] = out.modelStopReason } _ = span.Record(trace.Event{Type: EventLLMDone, Props: props}) } // recordToolDone 把一次工具调用的脱敏摘要追加到其 span:工具 // 名、是否成功、参数名(绝不记录其值)、失败时的 // 错误种类,以及截断后的结果摘要。参数值被 // 刻意省略 —— 工具参数正是凭据、消息正文 // 或含机密的文件路径会出现的地方 —— 且这里记录的一切 // 在发布前都会由图再次清洗。 // // 参数摘要(裁决 11.4.3,规格见 design/19 §1):除全量参数名 // (args_keys)外,另生成 args_summary —— 只含放行表内、且非密钥类 // 键的值,单值截断到 maxArgValueRunes。摘要同时写进 span prop // (图的 safePropKeys 放行它,面板节点详情直接读),使「可视化思考 // 链路」看得到参数而看不到凭据。 func (l *Loop) recordToolDone(span *trace.Span, tc agent.ToolCall, out ToolOutcome) { if span == nil { return } keys := argKeys(tc.Args) props := map[string]any{ "tool": tc.Name, "ok": out.Err == nil, "args_keys": keys, "elapsed_ms": elapsedMS(span), } // 键名清单同时写进 span prop:面板节点详情要列出全部参数名, // 再对放行的键显示截断后的值、对其余键显示「值未展示」。 _ = span.SetProp("args_keys", keys) if summary := argSummary(tc.Args); len(summary) > 0 { props["args_summary"] = summary _ = span.SetProp("args_summary", summary) } if out.Err != nil { if out.ErrKind != "" { props["error_kind"] = out.ErrKind } props["error"] = trimSummary(out.Err.Error()) } else { props["result_summary"] = trimSummary(formatToolResult(out.Result)) } _ = span.Record(trace.Event{Type: EventToolDone, Props: props}) } // recordToolResult 把一次**成功**调用的结果落成两样东西,都不是正文: // // - result_summary:与 `tool_execution.end` 帧**逐字同源**的已脱敏、已截断 // 摘要(toolFrameResultSummary,同一条 trace.RedactText 路径)—— 回看面 // `toolCalls[].resultSummary` 读的就是它; // - result_digest:字节数 / rune 数 / 内容 sha256 前缀(trace.DigestOfValue)。 // // 🔴 原文绝不进 span:工具结果正文本来就留在会话库里,span 与 stderr 的 JSONL // 都不需要它,而它们正是最容易扩散的两个落盘面(一次 stderr tail 就能把整段 // 工具输出拷走)。排查「这次调用拿到了多大一份东西、两次是不是同一份」靠 // Digest 就够了。 func recordToolResult(span *trace.Span, result any) { if span == nil { return } if summary := toolFrameResultSummary(result); summary != "" { _ = span.SetProp("result_summary", summary) } _ = span.SetProp("result_digest", trace.DigestOfValue(result)) } // argSummary 生成工具参数的「值摘要」:只放行 argValueKeys 表内的键,且 // 密钥类键一律不给值(trace.IsSecretKey 与出图清洗同一判据);非标量值只给 // 占位(数组(N)/对象(N)),因为容器里可能藏着正文或凭据。键按字典序处理, // 使摘要只随参数变化,不随 map 迭代顺序抖动。 func argSummary(args map[string]any) map[string]any { if len(args) == 0 { return nil } out := make(map[string]any) for _, k := range argKeys(args) { if len(out) >= maxArgSummaryKeys { break } if !argValueVisible(k) { continue } if s := summarizeArgValue(args[k]); s != "" { out[k] = ellipsize(s, maxArgValueRunes) } } if len(out) == 0 { return nil } return out } // argValueVisible 报告某参数键的值是否允许出图:必须是放行表内的结构类键, // 且不得是密钥类键(放行表本身不含密钥名,这里是纵深防御)。 func argValueVisible(key string) bool { if trace.IsSecretKey(key) { return false } return argValueKeys[strings.ToLower(strings.TrimSpace(key))] } // summarizeArgValue 把参数值渲染成一行短文本:字符串原样,数字/布尔按字面量, // 数组与对象只给占位而不展开(不把容器内的正文或凭据带出来),其余类型返回空串。 func summarizeArgValue(v any) string { switch x := v.(type) { case nil: return "" case string: return x case bool, float64, float32, int, int64, int32, uint, uint64, uint32: return fmt.Sprintf("%v", x) case []any: return fmt.Sprintf("数组(%d)", len(x)) case []string: return fmt.Sprintf("数组(%d)", len(x)) case map[string]any: return fmt.Sprintf("对象(%d)", len(x)) default: return "" } } // finishGuardrailSpan 把一次护栏检查(输入侧同步门 / 输出侧异步检查)的结论 // 落到其 span 上(裁决 11.4.2,design/19 §1 旁路节点):props 只放检查名、 // 裁决与耗时(全部在 safePropKeys 白名单内,出图时仍会再清洗一遍),事件带 // 一条脱敏后的 Reason 供节点日志查阅。拦截时 span 终态记为 error,面板上显红。 // err 非 nil 表示检查本身失败(不是裁决),同样记为 error 并把原因脱敏后留下。 func (l *Loop) finishGuardrailSpan(span *trace.Span, action string, verdict *guardrail.Verdict, err error) { if span == nil { return } allow := err == nil && (verdict == nil || verdict.Allow) props := map[string]any{ "action": action, "allow": allow, "elapsed_ms": elapsedMS(span), } reason := "" if verdict != nil { reason = verdict.Reason } if err != nil { props["error"] = err.Error() reason = err.Error() } for k, v := range props { _ = span.SetProp(k, v) } // 一条事件同时覆盖「拦截原因」与「标记放行」(SensitiveMark 会带 Reason // 放行),使 mark 模式在面板上有消费者而不是无声通过。 if !allow || reason != "" { _ = span.Record(trace.Event{Type: eventGuardrailDone, Props: map[string]any{ "action": action, "allow": allow, "reason": trimSummary(reason), }}) } if allow { l.finishSpan(span, trace.SpanStatusOK) return } l.finishSpan(span, trace.SpanStatusError) } // finishMemoryRecallSpan 记录一次记忆召回的元数据:作用域、命中条数、注入块 // 字节数与耗时。绝不记录召回了什么内容(design/19 §1:memory 旁路节点只记 // 元数据,不含敏感内容)—— 记忆正文本身就可能是主人的隐私。 func (l *Loop) finishMemoryRecallSpan(span *trace.Span, scope string, hits, size int, err error) { if span == nil { return } props := map[string]any{ "action": "recall", "scope": scope, "bytes": size, "hits": hits, "elapsed_ms": elapsedMS(span), } l.finishMemorySpan(span, props, err) } // finishMemoryWriteSpan 记录一次记忆写入的元数据:写入与失败的条数、耗时。 // 绝不记录写入了什么内容(同 recall 的口径)。 func (l *Loop) finishMemoryWriteSpan(span *trace.Span, written, failed int, err error) { if span == nil { return } props := map[string]any{ "action": "write", "count": written, "failed": failed, "elapsed_ms": elapsedMS(span), } l.finishMemorySpan(span, props, err) } // finishMemorySpan 是两次记忆读写的共同收尾:写 props、记一条事件、按结果 // 关闭 span(失败记 error 终态)。props 由调用方给全,收尾只负责落盘与终态。 func (l *Loop) finishMemorySpan(span *trace.Span, props map[string]any, err error) { if span == nil { return } if err != nil { props["error"] = err.Error() } for k, v := range props { _ = span.SetProp(k, v) } _ = span.Record(trace.Event{Type: eventMemoryDone, Props: props}) if err != nil { l.finishSpan(span, trace.SpanStatusError) return } l.finishSpan(span, trace.SpanStatusOK) } // modelName 报告服务某步的模型:流式路径知道时用路由出的 // 请求模型,否则用该 turn 的 agent 配置的模型。两者 // 都不可用时为空。 func modelName(out *stepOutcome, ag *agent.Agent) string { if out.model != "" { return out.model } if ag == nil { return "" } return ag.Model() } // elapsedMS 报告 span 迄今已打开多久。span 的 Duration 仅由 // Finish 打上,因此在 Finish 之前记录的日志事件在此测量它。 // 实现委托给 trace.ElapsedMS,使 agent 侧的护栏 span 与本包用同一算法。 func elapsedMS(span *trace.Span) int64 { return trace.ElapsedMS(span) } // argKeys 按排序返回工具调用的参数名。只记录 // 参数名;值留在 trace 之外(见 recordToolDone)。 func argKeys(args map[string]any) []string { if len(args) == 0 { return nil } keys := make([]string, 0, len(args)) for k := range args { keys = append(keys, k) } sort.Strings(keys) return keys } // trimSummary 把日志取值缩短到 maxLogSummaryRunes 个 rune, // 且不切开多字节 rune(被截断时末位换成省略号)。 func trimSummary(s string) string { return ellipsize(s, maxLogSummaryRunes) } // ellipsize 把 s 截到最多 max 个 rune;确实被截断时末位换成省略号, // 使面板上的一眼能看出「这里被截过」。max 非正时原样返回。 func ellipsize(s string, max int) string { if max <= 0 || len([]rune(s)) <= max { return s } return truncateRunes(s, max-1) + "…" }