feat(trace,loop,orchestration): memory/guardrail/orchestration 真 span + 参数摘要脱敏 + Guardrail 输出侧异步(裁决 11.4.2/11.4.3/13.4.1)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 11.4.2:三类节点首次真进图 —— memory.recall/memory.write(loop/memory.go 召回与写入点)、
  guardrail.input/output(流式主路径两侧 + agent.RunStep 静音路径)、orchestration.spawn
  (scheduler 每次派发/收束);新增 trace.ChildSpan 让不持有 Tracer 的叶子包也能开子 span
- 11.4.3(设计先行,规格先写进 design/19 §1.1):tool 节点参数摘要 —— 放行表 argValueKeys +
  trace.IsSecretKey 统一判据(密钥类键不给值)+ 单值 80 rune / ≤24 条 + 出图前再清洗;
  前端 NodeDetail 新增「参数摘要」区,未放行键显示「值未展示」
- 13.4.1:流式主路径输出侧护栏改后台 goroutine(脱离 context、30s 兜底、不阻塞回合),
  输入侧仍在模型调用前同步拦下;拦截闭环到 guardrail 节点 + 审计事件
- 拆文件:scheduler.go 加 span 后 546 行 → 编排 span 助手拆出 scheduler_spans.go(回到 492)
- 契约:无帧名/事件形状变化;graph_update 节点 props 新增**可选**键(tool args_keys/args_summary、
  guardrail action/allow/elapsed_ms、memory action/scope/hits/…、orchestration subagent/persona),
  文档同步由主会话统一落
This commit is contained in:
AskaEth committed 2026-10-04 12:31:20 +08:00
1 parent 0dfad675b8
commit b99268d019
14 files changed
+1396 -42

No files matched your search

+76 -2
View File
@@ -5,8 +5,10 @@ import (
"fmt"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/guardrail"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// Stop reasons carried by StepResult.StopReason, consumed by the loop (M2) to
@@ -53,6 +55,15 @@ type StepResult struct {
ModelStopReason string
}
// 护栏 span 名与本包记录事件时使用的类型:与循环侧(loop/spanlog.go)
// 同名,使面板无论走流式路径还是 agent 路径都看到同一类 guardrail 节点
// 与同一条日志事件(裁决 11.4.2,design/04 §9「逐层记到 guardrail」)。
const (
guardrailSpanInput = "guardrail.input"
guardrailSpanOutput = "guardrail.output"
guardrailEventDone = "guardrail.done"
)
// RunStep executes one step of the agent loop. It assembles a model.Request from
// the agent's instructions prepended as a leading system message plus the
// conversation messages, runs input guardrails, calls the model adapter, runs
@@ -64,11 +75,20 @@ type StepResult struct {
// yields Final=false with the decoded ToolCalls; a guardrail denial yields
// Final=true carrying the block reason. A nil adapter or a failed guardrail or
// model call returns a wrapped error.
//
// 两侧检查都包在自己的 guardrail span 里(裁决 11.4.2):本包不持有 Tracer,
// 因此用 trace.ChildSpan 在当前 span(循环的 step span)之下开一个子 span,
// 未开追踪时它是空操作。**本路径保持串行内联**:输入侧在模型调用之前拦下,
// 输出侧紧接模型返回裁决(裁决 13.4.1 的「输出侧异步」由流式主路径
// loop.streamStep 承担——agent.RunStep 只服务静音/非流式内部流程,不落在
// 主会话关键路径上,且它的裁决是 StepResult 的一部分)。
func (a *Agent) RunStep(ctx context.Context, sessionID string, messages []model.Message) (*StepResult, error) {
req := a.buildRequest(messages)
if a.guards != nil {
verdict, err := a.guards.CheckInput(ctx, &req)
gctx, gspan := trace.ChildSpan(ctx, guardrailSpanInput, trace.SpanKindGuardrail)
verdict, err := a.guards.CheckInput(gctx, &req)
finishGuardrailSpan(gspan, guardrailSpanInput, verdict, err)
if err != nil {
return nil, fmt.Errorf("agent: check input: %w", err)
}
@@ -96,7 +116,9 @@ func (a *Agent) RunStep(ctx context.Context, sessionID string, messages []model.
}
if a.guards != nil {
verdict, err := a.guards.CheckOutput(ctx, &resp)
gctx, gspan := trace.ChildSpan(ctx, guardrailSpanOutput, trace.SpanKindGuardrail)
verdict, err := a.guards.CheckOutput(gctx, &resp)
finishGuardrailSpan(gspan, guardrailSpanOutput, verdict, err)
if err != nil {
return nil, fmt.Errorf("agent: check output: %w", err)
}
@@ -111,6 +133,58 @@ func (a *Agent) RunStep(ctx context.Context, sessionID string, messages []model.
return &StepResult{Final: true, Text: resp.Content, StopReason: StopReasonStop, ModelStopReason: resp.StopReason}, nil
}
// finishGuardrailSpan 把一次护栏检查的结论落到其 span 上(裁决 11.4.2):
// props 只放检查名、裁决与耗时(全部在图的 safePropKeys 白名单内,出图时仍会
// 再清洗一遍),命中拦截或标记时额外记一条脱敏后的 Reason 事件。拦截的 span
// 终态为 error,面板上显红。span 为 nil(未开追踪)时是空操作。
func finishGuardrailSpan(span *trace.Span, action string, verdict *guardrail.Verdict, err error) {
if span == nil {
return
}
allow := err == nil && (verdict == nil || verdict.Allow)
reason := ""
if verdict != nil {
reason = verdict.Reason
}
if err != nil {
reason = err.Error()
}
_ = span.SetProp("action", action)
_ = span.SetProp("allow", allow)
_ = span.SetProp("elapsed_ms", trace.ElapsedMS(span))
if err != nil {
_ = span.SetProp("error", err.Error())
}
if !allow || reason != "" {
_ = span.Record(trace.Event{Type: guardrailEventDone, Props: map[string]any{
"action": action,
"allow": allow,
"reason": truncateRunes(reason, maxGuardrailReasonRunes),
}})
}
if allow {
trace.FinishChild(span, trace.SpanStatusOK, nil)
return
}
trace.FinishChild(span, trace.SpanStatusError, nil)
}
// maxGuardrailReasonRunes 限制护栏 Reason 进入 span 事件前的长度:Reason 是
// 面向用户的拦截说明,本就不含密钥(guardrail 包契约),这里只防它变成一大段。
const maxGuardrailReasonRunes = 200
// truncateRunes 把 s 截到最多 max 个 rune,超出时补省略号;max 非正时原样返回。
func truncateRunes(s string, max int) string {
if max <= 0 {
return s
}
r := []rune(s)
if len(r) <= max {
return s
}
return string(r[:max]) + "…"
}
// ExtractToolCalls decodes tool calls from a model.Response. It prefers the
// provider-native ToolCalls the adapters populate (Anthropic tool_use / OpenAI
// tool_calls), which carry the provider id needed to correlate the result and