Files
Cyrene_v2/internal/runtime/trace/graph.go
T
AskaEth 6e7ec56f9a
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(loop,gateway): 事件批 — tool_execution 三事件 + streamId + turn.start/end(裁决 01.4.1/01.4.2/01.4.3/01.4.5/03.4.4/1.12)
执行方案阶段 2 · P2-3(`DevDocs/audit/_plan.md`)。契约 `Docs/api-client.md` → **v0.50**。

- **01.4.1 `tool_execution` 拆三事件**:`model/stream.go` 删旧 `StreamKindToolExecution`,新增
  `tool_execution.start/update/end` + `ToolPhaseRunning/Done/Error`(与 design/19 status 同值);
  `StreamEvent` 增 `Tool`/`Phase`;`loop/tools.go` 的 `emitToolExecution` 重写为结构化字段、
  **Delta 恒空**(断言锁死),`ID` 复用 provider tool call id(可与 `tool_call.*` 关联)。
  ⚠️ **诚实边界**:start/end 有真实生产者;`update` 的 wire 映射已实现并单测锁定,但**工具分发器
  无中途进度上报源 ⇒ loop 暂不产出 update 帧**(已在 api-client §3.1 如实登记)。
- **03.4.4 phase 驱动图节点状态**:`invokeOne` 用**单一 phase 决策**同时决定 end 帧 phase、
  span 的 `phase` prop 与终态;`trace/graph.go` 新增 `FlowStatusForPhase`,`graph_build.go` 的
  `AddSpan` 对 tool 节点**优先消费 phase prop**(缺省回落 span 终态 ⇒ 两路不可能矛盾)。
- **01.4.2 `streamId`**:`StreamEvent` 增 `StreamID`;`loop/stream.go` 每次 ChatStream 生成
  `stream-<hex>`(crypto/rand,降级时间戳),该流所有帧共享、多步工具轮取新 id;`ws_bridge`
  把它并进**模型流帧** `data`(`tool_execution.*`/`turn.*` 不带)。语义区分:`turnId` = 哪轮回答,
  `streamId` = 这条流式消息。
- **01.4.3 `turn.start`/`turn.end`**:`loop/run.go` 入口发 start、defer 发 end,**覆盖全部出口**
  (最终答案/安全网终止/预算耗尽/步骤错误);帧**无 data 载荷**;静音的内部 run(backthink/子代理)
  不发。顺带把 91 行的 `Run` 拆成 `driveTurn` + `settleAndReturn`(清掉一处既有 >80 行违规)。
- **01.4.5**:仅 `Docs/api-client.md` §3.5 补 `graph_update` 三范围(`web`/`web:<uuid>`/`sess-backthink`),
  与 `wire_runtime.go` 的 allow 谓词一致,**零代码变更**。

验证:`go build ./...` 绿;本批四包(loop/model/trace/gateway)vet + 测试绿(含 `-count=1`);
linux/amd64 + darwin/arm64 交叉编译绿;本批 16 文件 ≤500 行、函数 ≤80 行。
**真 WS 帧断言 8 条**:帧序 `turn.start → message.delta → tool_execution.start →
tool_execution.end → message.delta → turn.end`(+`stream_end`)、turn 边界无载荷且整轮 turnId 一致、
两条模型流 `streamId` 互异、tool 帧 `{tool,phase,id}` 且无 `delta`/`streamId` 键、
`turn.end` 在步骤出错时也发出、内部静音 run 不发边界帧。
**变异验证**:把 `ev.StreamID = streamID` 改为赋空 → 两条断言 FAIL(`streamId` 为空/两流同空),
还原后复绿(直接删行会先被编译期 `declared and not used` 拦截)。

未验证:前端未消费这些帧(本轮未动 `frontend/**`,「前端能渲染」待前端批);无真实模型端到端。
2026-10-04 11:26:35 +08:00

178 lines
5.6 KiB
Go
Raw 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 trace
import (
"fmt"
"time"
)
// Flow graph wire types (DevDocs/design/19 §1). The JSON field names are
// camelCase so the frontend's FlowGraph type and parseGraph read them directly.
// Node status strings mirror the frontend AgentStatus keys so parseGraph and
// FlowGraphView pass them through unchanged.
// Node status labels kept as named constants so no bare string appears in the
// mapping code (project rule §10).
const (
// FlowStatusIdle is a node that has not started.
FlowStatusIdle = "idle"
// FlowStatusRunning is a node whose span is in flight.
FlowStatusRunning = "running"
// FlowStatusDone is a successfully completed node.
FlowStatusDone = "done"
// FlowStatusError is a node whose span finished with an error.
FlowStatusError = "error"
)
// FlowStatusForPhase 把工具执行的细粒度 phase(design/04 §1.1,
// loop 随 tool_execution.end 帧一同记在 tool span 上)映射为流程图
// 节点状态:phase 取值与本包状态枚举(design/19 §1 统一枚举)
// 同构(running/done/error)。未知 phase 返回空串,调用方回落到
// span 终态推导,因此缺 phase 的旧 span 不受影响。
func FlowStatusForPhase(phase string) string {
switch phase {
case FlowStatusRunning:
return FlowStatusRunning
case FlowStatusDone:
return FlowStatusDone
case FlowStatusError:
return FlowStatusError
default:
return ""
}
}
// FlowNode is one node in a flow graph. Status/SpanKind mirror the frontier.
type FlowNode struct {
ID string `json:"id"`
Label string `json:"label"`
Kind string `json:"kind,omitempty"`
Status string `json:"status,omitempty"`
StartedAt time.Time `json:"startedAt,omitempty"`
EndedAt time.Time `json:"endedAt,omitempty"`
Props map[string]any `json:"props,omitempty"`
Log []Event `json:"log,omitempty"`
}
// FlowEdge is a directed edge between two nodes.
type FlowEdge struct {
From string `json:"from"`
To string `json:"to"`
}
// FlowGraph is a live, single-round flow graph published as ws.graph_update.
type FlowGraph struct {
ID string `json:"id"`
Nodes []FlowNode `json:"nodes"`
Edges []FlowEdge `json:"edges"`
SessionID string `json:"sessionId,omitempty"`
StartedAt time.Time `json:"startedAt,omitempty"`
EndedAt time.Time `json:"endedAt,omitempty"`
}
// NodeSpec is the input to RunGraph.Upsert: the full description of a node plus
// the parent id used to wire its incoming edge.
type NodeSpec struct {
ID string
Label string
Kind string
Status string
StartedAt time.Time
EndedAt time.Time
Props map[string]any
Log []Event
ParentID string
}
// RunGraph accumulates the spans of one round into a FlowGraph. It is a pure
// structure: it knows nothing about the bus, sessions or the loop, so it is
// unit-testable in isolation. A caller serializes access (see loop.FlowPublisher).
type RunGraph struct {
session string
round int
nodes map[string]*FlowNode
order []string
edges map[string]bool
edgeOrder []FlowEdge
// parents remembers every span's parent link, including spans that are not
// rendered as nodes (a state_transition step container), so a node's lane can
// be resolved by walking through the skipped containers.
parents map[string]string
// laneLast is the per-lane cursor used to chain sequential steps: keyed by the
// enclosing subagent node id, and by "" for the main trunk. One cursor per lane
// (instead of one per graph) is what keeps two parallel sub-agent lanes from
// cross-linking when their spans interleave.
laneLast map[string]string
started time.Time
ended time.Time
}
// NewRunGraph allocates an empty graph for one session round.
func NewRunGraph(session string) *RunGraph {
return &RunGraph{
session: session,
nodes: make(map[string]*FlowNode),
edges: make(map[string]bool),
parents: make(map[string]string),
laneLast: make(map[string]string),
}
}
// Start marks the round began at now and resets the node/edge sets so a reused
// graph starts fresh for a new round.
func (g *RunGraph) Start(round int, now time.Time) {
g.round = round
g.started = now
g.ended = time.Time{}
g.nodes = make(map[string]*FlowNode)
g.order = nil
g.edges = make(map[string]bool)
g.edgeOrder = nil
g.parents = make(map[string]string)
g.laneLast = make(map[string]string)
}
// SeedInput creates the "input" node for the round. It returns the node id so a
// caller can reference it. The label is redacted (design/19 §3), so a message that
// happens to carry a credential cannot publish one.
func (g *RunGraph) SeedInput(text string) string {
g.Upsert(NodeSpec{
ID: "input",
Label: RedactText(text),
Kind: "input",
Status: FlowStatusDone,
StartedAt: g.started,
})
return "input"
}
// SeedOutput creates the "output" node for the round, wired to the last node so
// the trunk ends at the answer. The label is redacted like SeedInput's.
func (g *RunGraph) SeedOutput(text string) string {
g.ended = time.Now()
g.Upsert(NodeSpec{
ID: "output",
Label: RedactText(text),
Kind: "output",
Status: FlowStatusDone,
StartedAt: g.ended,
EndedAt: g.ended,
})
return "output"
}
// Snapshot returns a copy of the graph safe to encode and publish.
func (g *RunGraph) Snapshot() *FlowGraph {
nodes := make([]FlowNode, 0, len(g.order))
for _, id := range g.order {
nodes = append(nodes, *g.nodes[id])
}
return &FlowGraph{
ID: fmt.Sprintf("%s-%d", g.session, g.round),
Nodes: nodes,
Edges: append([]FlowEdge(nil), g.edgeOrder...),
SessionID: g.session,
StartedAt: g.started,
EndedAt: g.ended,
}
}