执行方案阶段 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/**`,「前端能渲染」待前端批);无真实模型端到端。
178 lines
5.6 KiB
Go
178 lines
5.6 KiB
Go
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,
|
||
}
|
||
}
|