diff --git a/frontend/web/src/admin/modules/Flow.tsx b/frontend/web/src/admin/modules/Flow.tsx index e2af9ff..74fac03 100644 --- a/frontend/web/src/admin/modules/Flow.tsx +++ b/frontend/web/src/admin/modules/Flow.tsx @@ -1,45 +1,30 @@ import { useState } from "react"; import { useAppStore } from "../../store/useAppStore"; -import type { AgentStatus, FlowGraph, FlowNode } from "../../lib/types"; +import type { AgentStatus, FlowGraph } from "../../lib/types"; import { CardGrid, Notice, PageHeader, StatCard } from "../components"; -import { Surface, Tag, type TagTone } from "../../components/primitives"; +import { Surface } from "../../components/primitives"; import { Icon } from "../../components/Icon"; import { FlowGraphView } from "./FlowGraph"; +import { NodeDetail } from "./flow/NodeDetail"; +import { LaneSummary } from "./flow/LaneSummary"; +import { groupFlowLanes } from "./flow/lanes"; +import { FLOW_TONE, STATUS_ORDER, statusOf } from "./flow/tone"; -const STATUS_LABEL: Record = { - idle: "空闲", - running: "运行中", - blocked: "阻塞", - done: "完成", - error: "错误", -}; -const STATUS_TONE: Record = { - idle: "neutral", - running: "accent", - blocked: "warn", - done: "ok", - error: "err", -}; -const STATUS_DOT: Record = { - idle: "var(--mu-surface-5)", - running: "var(--mu-accent)", - blocked: "var(--mu-warn)", - done: "var(--mu-ok)", - error: "var(--mu-err)", -}; - -/** 全链路流程页(design/19 §2):实时渲染 ws.graph_update 驱动的单轮主干图, - * 输入 → 思考(LLM) → 工具 → 输出,节点可点开看实时日志/参数/结果。 */ +/** 全链路流程页(design/19 §2):实时渲染 ws.graph_update 驱动的单轮图 —— + * 主干「输入 → 思考 → 工具 → 输出」+ 并行子代理泳道(子图容器), + * 节点点开看参数 / 结果 / 实时日志。 */ export function FlowModule(): React.ReactNode { const graph = useAppStore((s) => s.flowGraph); const [selectedId, setSelectedId] = useState(null); const selected = graph?.nodes.find((n) => n.id === selectedId) ?? null; + const grouping = graph ? groupFlowLanes(graph) : null; + const selectedLane = selected ? laneLabelFor(selectedId, grouping) : undefined; return (
{graph ? ( @@ -60,111 +45,87 @@ export function FlowModule(): React.ReactNode { tone="neutral" /> } - label="开始" - value={fmtStart(graph)} - sub="本轮开始时间" - tone="neutral" + icon={} + label="泳道" + value={grouping?.lanes.length ?? 0} + sub="并行子代理分支" + tone="accent" /> } + icon={} label="耗时" value={fmtDuration(graph)} - sub="本轮时长" + sub={`开始 ${fmtStart(graph)}`} tone="ok" /> - -
- {(["running", "done", "error", "idle"] as AgentStatus[]).map((s) => { - const n = countStatus(graph, s); - return ( - - - {STATUS_LABEL[s]} × {n} - - ); - })} -
- setSelectedId(n.id)} /> + + + setSelectedId(n.id)} + /> + {selected ? ( - setSelectedId(null)} /> + setSelectedId(null)} + /> ) : null} ) : ( - 暂无流程 —— 接入 /ws/chat 并发起一次会话后,将实时出现「输入 → 思考 → 工具 → 输出」连通图;若某环节无对应 span,则诚实占位。 + 暂无流程 —— 接入 /ws/chat 并发起一次会话后,将实时出现「输入 → 思考 → 工具 → 输出」连通图;并行子代理会各自成一条泳道。 )}
); } -/** NodeDetail 展示单个节点的属性/结果与累积的 span 事件(实时日志)。 */ -function NodeDetail({ node, onClose }: { node: FlowNode; onClose: () => void }): React.ReactNode { - const status = node.status ?? "idle"; - const hasProps = !!node.props && Object.keys(node.props).length > 0; - const hasLog = !!node.log && node.log.length > 0; - +/** StatusStrip shows the live status census of the round. */ +function StatusStrip({ graph }: { graph: FlowGraph }): React.ReactNode { return ( - -
- {node.label} - {node.kind ? {node.kind} : null} - {STATUS_LABEL[status]} - -
-
- {node.startedAt ? 开始 {new Date(node.startedAt).toLocaleTimeString()} : null} - {node.endedAt ? 结束 {new Date(node.endedAt).toLocaleTimeString()} : null} - {node.id} -
- - {hasProps ? ( -
-

参数 / 结果

-
-            {JSON.stringify(node.props, null, 2)}
-          
-
- ) : null} - - {hasLog ? ( -
-

实时日志(span 事件累积)

- {node.log!.map((ev, i) => ( -
- - {ev.time ? new Date(ev.time).toLocaleTimeString() : ""} - - {ev.type} - {ev.props ? ( - {JSON.stringify(ev.props)} - ) : null} -
- ))} -
- ) : null} - - {!hasProps && !hasLog ? 该节点暂无详细日志。 : null} -
+ + {FLOW_TONE[s].label} × {countStatus(graph, s)} + + ))} + ); } +/** laneLabelFor returns the label of the lane a node belongs to (its sub-agent + * root), or of the lane the node itself opens. */ +function laneLabelFor( + nodeId: string | null, + grouping: ReturnType | null, +): string | undefined { + if (!nodeId || !grouping) return undefined; + const band = grouping.bandOf.get(nodeId); + const own = grouping.lanes.find((l) => l.id === nodeId); + const laneId = band ?? own?.id; + if (!laneId) return undefined; + return grouping.lanes.find((l) => l.id === laneId)?.root.label; +} + /** countStatus returns how many nodes in graph are in the given status. */ function countStatus(graph: FlowGraph, status: AgentStatus): number { let n = 0; for (const node of graph.nodes) { - if ((node.status ?? "idle") === status) n += 1; + if (statusOf(node) === status) n += 1; } return n; } diff --git a/frontend/web/src/admin/modules/FlowGraph.tsx b/frontend/web/src/admin/modules/FlowGraph.tsx index f55d634..9711230 100644 --- a/frontend/web/src/admin/modules/FlowGraph.tsx +++ b/frontend/web/src/admin/modules/FlowGraph.tsx @@ -1,158 +1,35 @@ import { useId } from "react"; import type { ReactNode } from "react"; -import type { - AgentStatus, - FlowEdge, - FlowGraph, - FlowNode, -} from "../../lib/types"; +import type { FlowGraph, FlowNode } from "../../lib/types"; +import { + FLOW_TONE, + STATUS_ORDER, + statusOf, +} from "./flow/tone"; +import { layoutFlow, NODE_W, NODE_H, type PlacedBand, type PlacedEdge, type PlacedNode } from "./flow/layout"; /** Lightweight SVG directed-graph renderer for a FlowGraph (no graph deps). - * Left-to-right layered layout: a node's layer is its longest-path depth from - * a root; within a layer nodes are stacked top-down. Edges are cubic beziers - * with an arrowhead; node stroke/fill is coded by status. */ - -const NODE_W = 148; -const NODE_H = 52; -const GAP_X = 64; -const GAP_Y = 30; -const PAD = 20; - -interface FlowTone { - stroke: string; - fill: string; - label: string; -} - -const flowTone: Record = { - idle: { - stroke: "var(--mu-surface-5)", - fill: "color-mix(in srgb, var(--mu-surface-5) 18%, transparent)", - label: "空闲", - }, - running: { - stroke: "var(--mu-accent)", - fill: "color-mix(in srgb, var(--mu-accent) 18%, transparent)", - label: "运行中", - }, - blocked: { - stroke: "var(--mu-warn)", - fill: "color-mix(in srgb, var(--mu-warn) 18%, transparent)", - label: "阻塞", - }, - done: { - stroke: "var(--mu-ok)", - fill: "color-mix(in srgb, var(--mu-ok) 18%, transparent)", - label: "完成", - }, - error: { - stroke: "var(--mu-err)", - fill: "color-mix(in srgb, var(--mu-err) 18%, transparent)", - label: "错误", - }, -}; - -const STATUS_ORDER: ReadonlyArray = [ - "idle", - "running", - "blocked", - "done", - "error", -]; - -interface PlacedNode { - node: FlowNode; - x: number; - y: number; - tone: FlowTone; -} - -function layout(graph: FlowGraph): { - placed: PlacedNode[]; - edges: FlowEdge[]; - width: number; - height: number; -} { - if (graph.nodes.length === 0) { - return { placed: [], edges: [], width: PAD * 2, height: PAD * 2 }; - } - - const byId = new Map(); - graph.nodes.forEach((n) => byId.set(n.id, n)); - - const preds = new Map(); - graph.nodes.forEach((n) => preds.set(n.id, [])); - - const validEdges: FlowEdge[] = []; - graph.edges.forEach((e) => { - if (e.from === e.to) return; - if (!byId.has(e.from) || !byId.has(e.to)) return; - preds.get(e.to)!.push(e.from); - validEdges.push(e); - }); - - const memo = new Map(); - const visiting = new Set(); - const depth = (id: string): number => { - const cached = memo.get(id); - if (cached !== undefined) return cached; - if (visiting.has(id)) return 0; - visiting.add(id); - let d = 0; - for (const p of preds.get(id)!) d = Math.max(d, depth(p) + 1); - visiting.delete(id); - memo.set(id, d); - return d; - }; - - const byLayer = new Map(); - let maxRows = 0; - graph.nodes.forEach((n) => { - const l = depth(n.id); - const arr = byLayer.get(l) ?? []; - arr.push(n.id); - byLayer.set(l, arr); - maxRows = Math.max(maxRows, arr.length); - }); - - const layers = Array.from(byLayer.keys()).sort((a, b) => a - b); - const pos = new Map(); - layers.forEach((l, li) => { - const ids = byLayer.get(l)!; - ids.forEach((id, ri) => - pos.set(id, { x: PAD + li * (NODE_W + GAP_X), y: PAD + ri * (NODE_H + GAP_Y) }) - ); - }); - - const width = PAD * 2 + layers.length * NODE_W + (layers.length - 1) * GAP_X; - const height = PAD * 2 + maxRows * NODE_H + (maxRows - 1) * GAP_Y; - - const placed: PlacedNode[] = graph.nodes.map((n) => { - const p = pos.get(n.id)!; - return { - node: n, - x: p.x, - y: p.y, - tone: flowTone[n.status ?? "idle"] ?? flowTone.idle, - }; - }); - - return { placed, edges: validEdges, width, height }; -} - -function clampLabel(label: string): string { - return label.length > 20 ? `${label.slice(0, 19)}…` : label; -} + * Left-to-right layered layout: a node's column is its longest-path depth from + * a root, and the rows of one column stack top-down. Sub-agent branches are + * drawn in their own framed lanes below the trunk (design/19 §2), so parallel + * branches read as separate swimlanes instead of interleaving. Edges are cubic + * beziers with an arrowhead; node stroke/fill is coded by status and a running + * node pulses with the panel's existing `.mu-blink` (no new animation dep). */ export function FlowGraphView({ graph, onNodeClick, + selectedId, }: { graph: FlowGraph; onNodeClick?: (node: FlowNode) => void; + selectedId?: string | null; }): ReactNode { const markerId = useId().replace(/[^a-zA-Z0-9]/g, ""); - const { placed, edges, width, height } = layout(graph); + const laneMarkerId = `${markerId}L`; + const { nodes, edges, bands, lanes, width, height } = layoutFlow(graph); + const placedOf = new Map(nodes.map((p) => [p.node.id, p])); + const laneRoots = new Set(lanes.map((l) => l.id)); return (
@@ -170,82 +47,238 @@ export function FlowGraphView({ > - - {edges.map((e, i) => { - const s = placed.find((p) => p.node.id === e.from); - const t = placed.find((p) => p.node.id === e.to); - if (!s || !t) return null; - const sx = s.x + NODE_W; - const sy = s.y + NODE_H / 2; - const tx = t.x; - const ty = t.y + NODE_H / 2; - const dx = Math.max(24, Math.abs(tx - sx) / 2); - return ( - - ); - })} - {placed.map((p) => ( - onNodeClick(p.node) : undefined} - className={onNodeClick ? "cursor-pointer" : undefined} + - - {p.node.kind ? ( - - {p.node.kind} - - ) : null} - - {clampLabel(p.node.label)} - - + + + + + {bands.filter((b) => b.framed).map((band) => ( + + ))} + + {edges.map((e, i) => ( + + ))} + + {nodes.map((p) => ( + ))}
-
- {STATUS_ORDER.map((s) => ( - - - {flowTone[s].label} - - ))} -
+ 0} /> ); } + +/** EdgePath draws one cubic bezier between two placed nodes; edges that run into + * a lane are dashed and accent-colored so the trunk stays readable. */ +function EdgePath({ + edge, + from, + to, + markerId, + laneMarkerId, +}: { + edge: PlacedEdge; + from?: PlacedNode; + to?: PlacedNode; + markerId: string; + laneMarkerId: string; +}): ReactNode { + if (!from || !to) return null; + const sx = from.x + NODE_W; + const sy = from.y + NODE_H / 2; + const tx = to.x; + const ty = to.y + NODE_H / 2; + const dx = Math.max(24, Math.abs(tx - sx) / 2); + const inLane = edge.lane !== undefined; + return ( + + ); +} + +/** LaneFrame draws one sub-agent swimlane container: a dashed Miuix surface with + * a header carrying the sub-agent label and its live status. */ +function LaneFrame({ band }: { band: PlacedBand }): ReactNode { + const tone = FLOW_TONE[band.status]; + const running = band.status === "running"; + return ( + + + + 泳道 · {clampLabel(band.label, 24)} + + + + ); +} + +/** NodeGroup draws one node card: a status-colored rounded rect plus kind/label + * text and a status dot. A running node carries a blinking halo so 运行中 is + * visible at a glance without animating the text. */ +function NodeGroup({ + node, + x, + y, + selected, + lane, + onNodeClick, +}: { + node: FlowNode; + x: number; + y: number; + selected: boolean; + lane: boolean; + onNodeClick?: (node: FlowNode) => void; +}): ReactNode { + const status = statusOf(node); + const tone = FLOW_TONE[status]; + return ( + onNodeClick(node) : undefined} + className={onNodeClick ? "cursor-pointer" : undefined} + > + {`${node.label} · ${tone.label}`} + {status === "running" ? ( + + ) : null} + + + {lane ? ( + + 泳道根 + + ) : null} + {node.kind ? ( + + {node.kind} + + ) : null} + + {clampLabel(node.label, 20)} + + + ); +} + +/** Legend explains the status colors and the lane edge style. */ +function Legend({ hasLanes }: { hasLanes: boolean }): ReactNode { + return ( +
+ {STATUS_ORDER.map((s) => ( + + + {FLOW_TONE[s].label} + + ))} + {hasLanes ? ( + + + 泳道内步骤 + + ) : null} +
+ ); +} + +/** clampLabel shortens a node label to fit the fixed-width node card. */ +function clampLabel(label: string, max: number): string { + return label.length > max ? `${label.slice(0, max - 1)}…` : label; +} diff --git a/frontend/web/src/admin/modules/flow/LaneSummary.tsx b/frontend/web/src/admin/modules/flow/LaneSummary.tsx new file mode 100644 index 0000000..7ce7d67 --- /dev/null +++ b/frontend/web/src/admin/modules/flow/LaneSummary.tsx @@ -0,0 +1,75 @@ +import type { FlowLane } from "./lanes"; +import { FLOW_TONE } from "./tone"; +import { Tag } from "../../../components/primitives"; + +/** LaneSummary lists the parallel sub-agent branches of the current round + * (design/19 §2 活动状态:每个分支的 running/ok/err + 进度). Each row shows the + * branch's live status, its step progress and its start time; clicking a row + * selects the branch root node so the detail panel opens on it. Nothing is + * rendered when no sub-agent has reported — the trunk alone is not a lane. */ +export function LaneSummary({ + lanes, + onSelect, +}: { + lanes: FlowLane[]; + onSelect: (nodeId: string) => void; +}): React.ReactNode { + if (lanes.length === 0) return null; + return ( +
+

+ 并行子代理泳道({lanes.length}) +

+
+ {lanes.map((lane) => ( + + ))} +
+
+ ); +} + +/** LaneRow renders one branch: status dot/tag, label, progress bar and counts. */ +function LaneRow({ + lane, + onSelect, +}: { + lane: FlowLane; + onSelect: (nodeId: string) => void; +}): React.ReactNode { + const tone = FLOW_TONE[lane.status]; + const settled = lane.done + lane.failed; + const percent = lane.total > 0 ? Math.round((settled / lane.total) * 100) : 0; + + return ( + + ); +} diff --git a/frontend/web/src/admin/modules/flow/NodeDetail.tsx b/frontend/web/src/admin/modules/flow/NodeDetail.tsx new file mode 100644 index 0000000..5bcc42b --- /dev/null +++ b/frontend/web/src/admin/modules/flow/NodeDetail.tsx @@ -0,0 +1,116 @@ +import type { FlowNode } from "../../../lib/types"; +import { Surface, Tag } from "../../../components/primitives"; +import { Icon } from "../../../components/Icon"; +import { Notice } from "../../components"; +import { FLOW_TONE, statusOf } from "./tone"; + +/** NodeDetail shows one selected node: its status, its lane (when it belongs to + * a sub-agent branch), the whitelisted span props and the span's accumulated + * events (design/19 §2 节点实时日志). The panel is driven by the live graph, so + * a newer `graph_update` frame replaces the props/log arrays in place and the + * list keeps growing while the round runs — no local buffering, no invented + * entries. */ +export function NodeDetail({ + node, + laneLabel, + onClose, +}: { + node: FlowNode; + laneLabel?: string; + onClose: () => void; +}): React.ReactNode { + const status = statusOf(node); + const tone = FLOW_TONE[status]; + const props = node.props ?? {}; + const hasProps = Object.keys(props).length > 0; + const log = node.log ?? []; + + return ( + +
+ {node.label} + {node.kind ? {node.kind} : null} + {tone.label} + {laneLabel ? 泳道 · {laneLabel} : null} + +
+
+ {node.startedAt ? 开始 {fmtClock(node.startedAt)} : null} + {node.endedAt ? 结束 {fmtClock(node.endedAt)} : null} + {node.id} +
+ + {hasProps ? ( +
+

参数 / 结果

+
+            {JSON.stringify(props, null, 2)}
+          
+
+ ) : null} + + + + {!hasProps && log.length === 0 ? 该节点暂无详细日志。 : null} +
+ ); +} + +/** LogList renders the node's accumulated span events, newest last, and is + * explicit about why an empty list is empty instead of pretending to have + * content. */ +function LogList({ + node, + log, +}: { + node: FlowNode; + log: NonNullable; +}): React.ReactNode { + const running = statusOf(node) === "running"; + if (log.length === 0) { + return ( +
+ 实时日志:暂无事件 ——{" "} + {running ? "该 span 仍在运行,事件在 span 结束后随帧回填。" : "该 span 未记录事件。"} +
+ ); + } + return ( +
+

+ 实时日志(span 事件累积) + {log.length} + {running ? ● 运行中 : null} +

+
+ {log.map((ev, i) => ( +
+ {fmtClock(ev.time)} + {ev.type || "event"} + {ev.props ? ( + + {JSON.stringify(ev.props)} + + ) : null} +
+ ))} +
+
+ ); +} + +/** fmtClock renders an RFC3339 stamp as a locale time, or "—" when absent/invalid. */ +function fmtClock(value?: string): string { + if (!value) return "—"; + const t = Date.parse(value); + return Number.isNaN(t) ? "—" : new Date(t).toLocaleTimeString(); +} diff --git a/frontend/web/src/admin/modules/flow/lanes.test.ts b/frontend/web/src/admin/modules/flow/lanes.test.ts new file mode 100644 index 0000000..fdbba75 --- /dev/null +++ b/frontend/web/src/admin/modules/flow/lanes.test.ts @@ -0,0 +1,312 @@ +// Logic test for the P2 lane layer of the flow page (design/19 §2): the pure +// swimlane grouping (`groupFlowLanes`) and the lane-banded layout (`layoutFlow`) +// that the SVG renderer draws. The project has no frontend test runner and no +// jsdom, so this is a dependency-free plain-node check that drives the real +// functions and throws on the first failure. +// +// Run it with the esbuild that already ships with vite (no new dependency, no +// package.json change): +// +// cd frontend/web \ +// && ./node_modules/.bin/esbuild src/admin/modules/flow/lanes.test.ts \ +// --bundle --platform=node --format=esm \ +// --outfile=/tmp/cyrene-flow-lanes.test.mjs \ +// && node /tmp/cyrene-flow-lanes.test.mjs +// +// `tsc --noEmit` (part of `npm run build`) type-checks this file, which is why it +// imports nothing from node:*. + +import { groupFlowLanes } from "./lanes"; +import { GAP_X, NODE_W, PAD, layoutFlow } from "./layout"; +import type { FlowEdge, FlowGraph, FlowNode } from "../../../lib/types"; + +let checks = 0; + +function ok(cond: boolean, what: string): void { + checks += 1; + if (!cond) throw new Error(`FAILED: ${what}`); +} + +function same(got: T, want: T, what: string): void { + const g = JSON.stringify(got); + const w = JSON.stringify(want); + ok(g === w, `${what} — got ${g}, want ${w}`); +} + +function node(id: string, kind: string, status?: FlowNode["status"]): FlowNode { + return { id, label: id, kind, status }; +} + +function graph(nodes: FlowNode[], edges: FlowEdge[]): FlowGraph { + return { id: "web-1", nodes, edges, sessionId: "web" }; +} + +/** The P1 trunk: input → llm → tool → output, no sub-agent in flight. */ +function trunkOnly(): FlowGraph { + return graph( + [ + node("input", "input", "done"), + node("llm1", "llm", "done"), + node("tool1", "tool", "done"), + node("output", "output", "done"), + ], + [ + { from: "input", to: "llm1" }, + { from: "llm1", to: "tool1" }, + { from: "tool1", to: "output" }, + ], + ); +} + +/** Two parallel sub-agent branches hanging off the same trunk node, matching the + * edge shape the Go builder produces (graph_lane_test.go): each lane chains from + * its own root, and the trunk resumes from the spawn node, not from a branch. */ +function parallel(): FlowGraph { + return graph( + [ + node("input", "input", "done"), + node("t1", "tool", "done"), + node("subA", "subagent", "running"), + node("llmA", "llm", "running"), + node("toolA", "tool", "done"), + node("subB", "subagent", "done"), + node("llmB", "llm", "done"), + node("llm2", "llm", "done"), + node("output", "output", "done"), + ], + [ + { from: "input", to: "t1" }, + { from: "t1", to: "subA" }, + { from: "t1", to: "subB" }, + { from: "subA", to: "llmA" }, + { from: "llmA", to: "toolA" }, + { from: "subB", to: "llmB" }, + { from: "t1", to: "llm2" }, + { from: "llm2", to: "output" }, + ], + ); +} + +// --- trunk only: no lane, columns unchanged (no P1 regression) --------------- + +{ + const g = trunkOnly(); + const grouping = groupFlowLanes(g); + same(grouping.lanes.length, 0, "trunk graph has no lane"); + ok(grouping.bandOf.get("llm1") === undefined, "trunk node has no band"); + + const { nodes, bands } = layoutFlow(g); + const at = new Map(nodes.map((p) => [p.node.id, p])); + same(bands.length, 1, "trunk graph has exactly one band"); + same(bands[0].framed, false, "the trunk band is not framed"); + same(at.get("input")!.x, PAD, "input sits in the first column"); + ok( + at.get("output")!.x > at.get("tool1")!.x && + at.get("tool1")!.x > at.get("llm1")!.x && + at.get("llm1")!.x > at.get("input")!.x, + "trunk columns grow left to right", + ); + same( + [at.get("input")!.y, at.get("llm1")!.y, at.get("tool1")!.y, at.get("output")!.y], + [PAD, PAD, PAD, PAD], + "a single-row trunk keeps one row", + ); + // P1 pixel parity: with no lane in play the geometry is exactly the old + // longest-path column grid, so P2 cannot regress the trunk view. + same( + nodes.map((p) => [p.node.id, p.x, p.y]), + [ + ["input", PAD, PAD], + ["llm1", PAD + (NODE_W + GAP_X), PAD], + ["tool1", PAD + 2 * (NODE_W + GAP_X), PAD], + ["output", PAD + 3 * (NODE_W + GAP_X), PAD], + ], + "trunk-only layout matches the P1 column grid", + ); +} + +// --- parallel branches: two lanes, each holding its own step chain ----------- + +{ + const g = parallel(); + const grouping = groupFlowLanes(g); + same( + grouping.lanes.map((l) => l.id), + ["subA", "subB"], + "one lane per sub-agent node", + ); + same(grouping.lanes.map((l) => l.depth), [0, 0], "both lanes are top-level"); + same( + grouping.lanes.map((l) => l.members.map((m) => m.id)), + [ + ["llmA", "toolA"], + ["llmB"], + ], + "lane members follow the branch, in graph order", + ); + same( + [ + grouping.bandOf.get("llmA"), + grouping.bandOf.get("toolA"), + grouping.bandOf.get("llmB"), + grouping.bandOf.get("llm2"), + grouping.bandOf.get("subA"), + ], + ["subA", "subA", "subB", undefined, undefined], + "band membership: sub-agent roots stay on the trunk", + ); + + const laneA = grouping.lanes[0]; + same( + [laneA.status, laneA.total, laneA.running, laneA.done, laneA.failed], + ["running", 2, 1, 1, 0], + "lane status comes from the root when reported", + ); + same(laneA.root.status, "running", "lane A root keeps its own status"); + + // Aggregation only kicks in when the root reports nothing. + const rootless = groupFlowLanes({ + ...g, + nodes: g.nodes.map((n) => (n.id === "subA" ? { ...n, status: undefined } : n)), + }).lanes[0]; + same(rootless.status, "running", "rootless lane aggregates a running member"); + const failed = groupFlowLanes({ + ...g, + nodes: g.nodes.map((n) => + n.id === "subA" + ? { ...n, status: undefined } + : n.id === "toolA" + ? { ...n, status: "error" as const } + : n, + ), + }).lanes[0]; + same(failed.status, "error", "any failed member turns the lane red"); + + const { nodes, bands, edges } = layoutFlow(g); + const at = new Map(nodes.map((p) => [p.node.id, p])); + const framed = bands.filter((b) => b.framed); + same(framed.length, 2, "each lane gets its own framed band"); + + // Lane steps start one column right of their sub-agent root. + same( + at.get("llmA")!.x - at.get("subA")!.x, + NODE_W + GAP_X, + "lane A's first step starts one column right of its root", + ); + same( + at.get("toolA")!.x, + at.get("llmA")!.x + NODE_W + GAP_X, + "lane A chains toolA after llmA", + ); + same( + at.get("llmB")!.x - at.get("subB")!.x, + NODE_W + GAP_X, + "lane B's step starts one column right of its root", + ); + + // Lanes are stacked below the trunk and never share rows with each other. + const trunkBottom = PAD + 52; + ok(framed[0].y >= trunkBottom, "lane A sits below the trunk"); + ok(framed[1].y >= framed[0].y + framed[0].height, "lane B sits below lane A"); + const rowA = at.get("llmA")!.y; + const rowB = at.get("llmB")!.y; + ok(rowA !== rowB, "parallel lanes do not share a row"); + ok(at.get("llm2")!.y < framed[0].y, "the trunk continuation stays in the trunk band"); + + // No two cards overlap. + const seen = new Set(nodes.map((p) => `${p.x},${p.y}`)); + same(seen.size, nodes.length, "every node has its own slot"); + + // Edges into a lane are marked so the renderer can dash them. + const byKey = new Map(edges.map((e) => [`${e.edge.from}>${e.edge.to}`, e.lane])); + same(byKey.get("subA>llmA"), "subA", "lane entry edge is a lane edge"); + same(byKey.get("llmA>toolA"), "subA", "intra-lane edge is a lane edge"); + same(byKey.get("subB>llm2"), undefined, "trunk continuation is a trunk edge"); + same(byKey.get("t1>llm2"), undefined, "the trunk resumes from the spawn node"); +} + +// --- nested sub-agent: a lane inside a lane ---------------------------------- + +{ + const nested = graph( + [ + node("input", "input", "done"), + node("subA", "subagent", "running"), + node("llmA", "llm", "done"), + node("subN", "subagent", "running"), + node("llmN", "llm", "running"), + ], + [ + { from: "input", to: "subA" }, + { from: "subA", to: "llmA" }, + { from: "llmA", to: "subN" }, + { from: "subN", to: "llmN" }, + ], + ); + const grouping = groupFlowLanes(nested); + same( + grouping.lanes.map((l) => [l.id, l.depth, l.parentLaneId]), + [ + ["subA", 0, undefined], + ["subN", 1, "subA"], + ], + "a nested sub-agent is its own lane one level deeper", + ); + same(grouping.bandOf.get("subN"), "subA", "the nested root is drawn in its parent lane"); + same(grouping.bandOf.get("llmN"), "subN", "the nested root's steps belong to the nested lane"); + + const { bands } = layoutFlow(nested); + const framed = bands.filter((b) => b.framed); + same(framed.length, 2, "nested lanes each get a frame"); + ok(framed[1].y > framed[0].y, "the nested lane is drawn below its parent lane"); +} + +// --- degenerate input must not throw ----------------------------------------- + +{ + const empty: FlowGraph = { id: "g", nodes: [], edges: [] }; + const emptyLayout = layoutFlow(empty); + same( + [emptyLayout.nodes.length, emptyLayout.bands.length, emptyLayout.lanes.length], + [0, 0, 0], + "empty graph", + ); + same( + [emptyLayout.width, emptyLayout.height], + [PAD * 2, PAD * 2], + "empty graph keeps the padding box", + ); + + const messy = graph( + [node("a", "llm", "done"), node("b", "llm", "done")], + [ + { from: "a", to: "a" }, + { from: "a", to: "ghost" }, + { from: "a", to: "b" }, + { from: "b", to: "a" }, + ], + ); + const grouping = groupFlowLanes(messy); + same(grouping.edges.length, 2, "self-loops and dangling edges are dropped"); + const messyLayout = layoutFlow(messy); + same(messyLayout.nodes.length, 2, "layout terminates on a cyclic graph"); + + // A wide layer must not push two cards onto the same slot. + const wide = graph( + [node("r", "input", "done"), node("c1", "llm"), node("c2", "llm"), node("c3", "llm")], + [ + { from: "r", to: "c1" }, + { from: "r", to: "c2" }, + { from: "r", to: "c3" }, + ], + ); + const wideLayout = layoutFlow(wide); + const slots = new Set(wideLayout.nodes.map((p) => `${p.x},${p.y}`)); + same(slots.size, 4, "fan-out siblings get distinct rows"); + + // A malformed frame with a duplicated id must not draw the same card twice. + const dup = graph([node("a", "llm", "done"), node("a", "llm", "done")], []); + same(layoutFlow(dup).nodes.length, 1, "a duplicated node id is drawn once"); +} + +console.log(`flow/lanes.test.ts OK — ${checks} checks`); diff --git a/frontend/web/src/admin/modules/flow/lanes.ts b/frontend/web/src/admin/modules/flow/lanes.ts new file mode 100644 index 0000000..8678df4 --- /dev/null +++ b/frontend/web/src/admin/modules/flow/lanes.ts @@ -0,0 +1,166 @@ +import type { + AgentStatus, + FlowEdge, + FlowGraph, + FlowNode, +} from "../../../lib/types"; +import { statusOf } from "./tone"; + +/** Swimlane grouping for the live flow graph (design/19 §2): the main trunk + * (输入→思考→工具→输出) stays one band, and every sub-agent node opens its own + * band holding the step chain of that branch, so parallel branches never + * interleave visually. Membership is derived from the edges alone: a node + * belongs to the nearest sub-agent node upstream of it (the builder wires each + * lane's steps behind its sub-agent root, see internal/runtime/trace/graph.go). + * + * Everything here is a pure function over a FlowGraph — no React, no store — so + * it is unit-testable on a plain node run (flow/lanes.test.ts). */ + +/** The node kind that opens a lane. */ +export const SUBAGENT_KIND = "subagent"; + +export interface FlowLane { + /** The sub-agent root node's id. */ + id: string; + root: FlowNode; + /** The nodes drawn in this lane's band, in graph order. A nested sub-agent + * root is a member of its parent lane AND a lane of its own. */ + members: FlowNode[]; + /** The enclosing lane id when this sub-agent was spawned inside another. */ + parentLaneId?: string; + /** Nesting depth: 0 = spawned by the trunk. */ + depth: number; + /** The root's own outcome when reported, else aggregated from the members. */ + status: AgentStatus; + total: number; + running: number; + done: number; + failed: number; +} + +export interface FlowGrouping { + byId: Map; + /** The graph's nodes, graph order, first occurrence per id (duplicated ids can + * only come from a malformed frame; they must not draw twice). */ + nodes: FlowNode[]; + /** node id → the band it is drawn in (a lane id, or undefined = trunk). */ + bandOf: Map; + /** Every lane, ordered by nesting depth then graph order. */ + lanes: FlowLane[]; + /** Valid edges (self-loops and dangling endpoints dropped). */ + edges: FlowEdge[]; +} + +/** groupFlowLanes partitions a graph into the trunk band plus one band per + * sub-agent node. */ +export function groupFlowLanes(graph: FlowGraph): FlowGrouping { + const byId = new Map(); + const index = new Map(); + const nodes: FlowNode[] = []; + graph.nodes.forEach((n, i) => { + if (!byId.has(n.id)) { + byId.set(n.id, n); + index.set(n.id, i); + nodes.push(n); + } + }); + + const preds = new Map(); + byId.forEach((_, id) => preds.set(id, [])); + const edges: FlowEdge[] = []; + graph.edges.forEach((e) => { + if (e.from === e.to) return; + if (!byId.has(e.from) || !byId.has(e.to)) return; + if (preds.get(e.to)!.includes(e.from)) return; + preds.get(e.to)!.push(e.from); + edges.push(e); + }); + + const laneAncestor = (id: string): string | undefined => + nearestSubagent(id, preds, byId, index); + + const bandOf = new Map(); + byId.forEach((_, id) => bandOf.set(id, laneAncestor(id))); + + const roots = nodes.filter((n) => n.kind === SUBAGENT_KIND); + const depthMemo = new Map(); + const laneDepth = (id: string): number => { + const cached = depthMemo.get(id); + if (cached !== undefined) return cached; + const parent = bandOf.get(id); + const d = parent === undefined || parent === id ? 0 : laneDepth(parent) + 1; + depthMemo.set(id, d); + return d; + }; + + const lanes: FlowLane[] = roots + .map((root) => { + const members = nodes.filter((n) => n.id !== root.id && bandOf.get(n.id) === root.id); + let running = 0; + let done = 0; + let failed = 0; + members.forEach((m) => { + const s = statusOf(m); + if (s === "running") running += 1; + else if (s === "done") done += 1; + else if (s === "error") failed += 1; + }); + const parentLaneId = bandOf.get(root.id); + return { + id: root.id, + root, + members, + parentLaneId, + depth: laneDepth(root.id), + status: laneStatus(root, members.length, running, failed), + total: members.length, + running, + done, + failed, + }; + }) + .sort((a, b) => a.depth - b.depth || index.get(a.id)! - index.get(b.id)!); + + return { byId, nodes, bandOf, lanes, edges }; +} + +/** nearestSubagent walks upstream from id and returns the closest sub-agent node + * (BFS by hop count, ties broken by graph order so the result is stable). */ +function nearestSubagent( + id: string, + preds: Map, + byId: Map, + index: Map, +): string | undefined { + const seen = new Set([id]); + let frontier = preds.get(id) ?? []; + while (frontier.length > 0) { + const layer = frontier.filter((p) => !seen.has(p)); + if (layer.length === 0) return undefined; + layer.forEach((p) => seen.add(p)); + const hits = layer.filter((p) => byId.get(p)?.kind === SUBAGENT_KIND); + if (hits.length > 0) { + return hits.sort((a, b) => index.get(a)! - index.get(b)!)[0]; + } + const next: string[] = []; + layer.forEach((p) => (preds.get(p) ?? []).forEach((q) => next.push(q))); + frontier = next; + } + return undefined; +} + +/** laneStatus prefers the sub-agent's own reported status; when the root has no + * status yet it aggregates the members (any failure wins, then anything still + * running, then all-done) — never inventing an outcome. */ +function laneStatus( + root: FlowNode, + total: number, + running: number, + failed: number, +): AgentStatus { + if (root.status) return root.status; + if (failed > 0) return "error"; + if (running > 0) return "running"; + if (total > 0) return "done"; + return "idle"; +} diff --git a/frontend/web/src/admin/modules/flow/layout.ts b/frontend/web/src/admin/modules/flow/layout.ts new file mode 100644 index 0000000..48b7cdd --- /dev/null +++ b/frontend/web/src/admin/modules/flow/layout.ts @@ -0,0 +1,242 @@ +import type { AgentStatus, FlowEdge, FlowGraph, FlowNode } from "../../../lib/types"; +import { groupFlowLanes, type FlowGrouping, type FlowLane } from "./lanes"; + +/** Lane-aware left-to-right layout for the flow graph (design/19 §2). The trunk + * keeps the P1 "longest path depth" columns; every sub-agent lane gets its own + * horizontal band below the trunk, its steps starting one column right of the + * sub-agent root and chaining rightwards. Bands never share rows, so parallel + * branches cannot overlap — the canvas just grows (and scrolls) instead. */ + +export const NODE_W = 148; +export const NODE_H = 52; +export const GAP_X = 64; +export const GAP_Y = 30; +export const PAD = 20; +/** Vertical gap between two bands. */ +export const BAND_GAP = 26; +/** Padding between a lane frame and the nodes it holds. */ +export const FRAME_PAD = 10; +/** Height reserved inside a lane frame for its label row. */ +export const LANE_HEADER = 22; +/** Minimum columns a lane frame spans, so an idle lane is still readable. */ +const MIN_LANE_COLS = 1; + +export interface PlacedNode { + node: FlowNode; + x: number; + y: number; +} + +export interface PlacedBand { + /** The lane id, or undefined for the trunk band. */ + id?: string; + label: string; + status: AgentStatus; + /** Frame rect; for the trunk band this is the content area (not framed). */ + x: number; + y: number; + width: number; + height: number; + framed: boolean; +} + +export interface PlacedEdge { + edge: FlowEdge; + /** The lane the edge runs into/inside, when it is not a trunk edge. */ + lane?: string; +} + +export interface FlowLayout { + nodes: PlacedNode[]; + edges: PlacedEdge[]; + bands: PlacedBand[]; + lanes: FlowLane[]; + width: number; + height: number; +} + +interface BandPlan { + id?: string; + lane?: FlowLane; + members: FlowNode[]; + /** node id → column offset inside the band (longest path over the band's own edges). */ + depth: Map; + /** Absolute first column of the band. */ + originCol: number; + rows: number; + fromCol: number; + toCol: number; +} + +/** layoutFlow computes the lane-banded geometry of a graph. */ +export function layoutFlow(graph: FlowGraph): FlowLayout { + const grouping = groupFlowLanes(graph); + if (grouping.byId.size === 0) { + return { nodes: [], edges: [], bands: [], lanes: [], width: PAD * 2, height: PAD * 2 }; + } + + const index = orderIndex(grouping.nodes); + const plans = planBands(grouping); + const { nodes, bands, width, height } = placeBands(plans, index); + const placed = new Set(nodes.map((p) => p.node.id)); + const edges: PlacedEdge[] = grouping.edges + .filter((e) => placed.has(e.from) && placed.has(e.to)) + .map((edge) => ({ edge, lane: grouping.bandOf.get(edge.to) })); + + return { nodes, edges, bands, lanes: grouping.lanes, width, height }; +} + +/** orderIndex maps every node id to its graph-order position. */ +function orderIndex(nodes: FlowNode[]): Map { + const index = new Map(); + nodes.forEach((n, i) => index.set(n.id, i)); + return index; +} + +/** planBands builds one plan per band, trunk first: a lane's origin column is + * derived from its root's column, so the enclosing band must be planned before + * it — lane order (by nesting depth) guarantees exactly that. */ +function planBands(grouping: FlowGrouping): BandPlan[] { + const { bandOf, lanes, nodes } = grouping; + const trunk: BandPlan = { + members: nodes.filter((n) => bandOf.get(n.id) === undefined), + depth: new Map(), + originCol: 0, + rows: 1, + fromCol: 0, + toCol: 0, + }; + const plans: BandPlan[] = [ + trunk, + ...lanes.map((lane) => ({ + id: lane.id, + lane, + members: lane.members, + depth: new Map(), + originCol: 0, + rows: 1, + fromCol: 0, + toCol: 0, + })), + ]; + + /** colOf is the absolute column a node is drawn in. */ + const colOf = (id: string): number => { + const band = plans.find((p) => p.depth.has(id)); + return band ? band.originCol + band.depth.get(id)! : 0; + }; + + plans.forEach((plan) => { + if (plan.id !== undefined && plan.lane) { + plan.originCol = colOf(plan.lane.root.id) + 1; + } + computeBandColumns(plan, grouping.edges); + const cols = plan.members.map((n) => plan.originCol + plan.depth.get(n.id)!); + plan.fromCol = cols.length > 0 ? Math.min(...cols) : plan.originCol; + plan.toCol = cols.length > 0 ? Math.max(...cols) : plan.fromCol + MIN_LANE_COLS; + }); + return plans; +} + +/** placeBands turns the plans into pixel geometry: bands stack top-down (lanes + * framed, with a header row for their label) and every node gets its own slot. */ +function placeBands( + plans: BandPlan[], + index: Map, +): { nodes: PlacedNode[]; bands: PlacedBand[]; width: number; height: number } { + const pos = new Map(); + const bands: PlacedBand[] = []; + /** Top of the next band (its frame top, header included). */ + let limitY = PAD; + let contentBottom = PAD; + let maxCol = 0; + + plans.forEach((plan) => { + const framed = plan.id !== undefined; + const frameY = limitY; + const contentY = framed ? frameY + LANE_HEADER + FRAME_PAD : frameY; + const bandHeight = rowsHeight(plan.rows); + const frameHeight = framed ? bandHeight + LANE_HEADER + FRAME_PAD * 2 : bandHeight; + + const byCol = new Map(); + plan.members.forEach((n) => { + const col = plan.originCol + plan.depth.get(n.id)!; + const arr = byCol.get(col) ?? []; + arr.push(n); + byCol.set(col, arr); + maxCol = Math.max(maxCol, col); + }); + byCol.forEach((arr, col) => { + arr + .sort((a, b) => index.get(a.id)! - index.get(b.id)!) + .forEach((n, row) => { + pos.set(n.id, { + node: n, + x: PAD + col * (NODE_W + GAP_X), + y: contentY + row * (NODE_H + GAP_Y), + }); + }); + }); + + bands.push({ + id: plan.id, + label: plan.lane ? plan.lane.root.label : "主干", + status: plan.lane ? plan.lane.status : "idle", + x: PAD + plan.fromCol * (NODE_W + GAP_X) - (framed ? FRAME_PAD : 0), + y: frameY, + width: (plan.toCol - plan.fromCol + 1) * (NODE_W + GAP_X) - GAP_X + (framed ? FRAME_PAD * 2 : 0), + height: frameHeight, + framed, + }); + + contentBottom = frameY + frameHeight; + limitY = contentBottom + BAND_GAP; + }); + + return { + nodes: [...pos.values()].sort((a, b) => index.get(a.node.id)! - index.get(b.node.id)!), + bands, + width: PAD * 2 + (maxCol + 1) * NODE_W + maxCol * GAP_X, + height: Math.max(contentBottom + PAD, PAD * 2), + }; +} + +/** computeBandColumns fills a band's per-node depth (longest path inside the + * band) and row count. Edges leaving the band are ignored: a lane's entry edge + * comes from its root, which is drawn in the enclosing band. */ +function computeBandColumns(plan: BandPlan, edges: FlowEdge[]): void { + const memberIds = new Set(plan.members.map((n) => n.id)); + const preds = new Map(); + memberIds.forEach((id) => preds.set(id, [])); + edges.forEach((e) => { + if (memberIds.has(e.from) && memberIds.has(e.to)) preds.get(e.to)!.push(e.from); + }); + + const memo = new Map(); + const visiting = new Set(); + const depth = (id: string): number => { + const cached = memo.get(id); + if (cached !== undefined) return cached; + if (visiting.has(id)) return 0; + visiting.add(id); + let d = 0; + for (const p of preds.get(id) ?? []) d = Math.max(d, depth(p) + 1); + visiting.delete(id); + memo.set(id, d); + return d; + }; + + const perCol = new Map(); + plan.members.forEach((n) => { + const d = depth(n.id); + plan.depth.set(n.id, d); + const col = plan.originCol + d; + perCol.set(col, (perCol.get(col) ?? 0) + 1); + }); + plan.rows = perCol.size > 0 ? Math.max(...perCol.values()) : 1; +} + +/** rowsHeight is the pixel height of rows stacked node cards. */ +function rowsHeight(rows: number): number { + return rows * NODE_H + (rows - 1) * GAP_Y; +} diff --git a/frontend/web/src/admin/modules/flow/tone.ts b/frontend/web/src/admin/modules/flow/tone.ts new file mode 100644 index 0000000..5503fea --- /dev/null +++ b/frontend/web/src/admin/modules/flow/tone.ts @@ -0,0 +1,59 @@ +import type { AgentStatus, FlowNode } from "../../../lib/types"; +import type { TagTone } from "../../../components/primitives"; + +/** Miuix status presentation for flow nodes (design/19 §2: 节点闪烁=运行中、 + * ok=成功、err=失败). The stroke/fill colors are Miuix tokens so the graph + * follows the panel theme (light/dark) without hard-coded colors. */ +export interface FlowTone { + stroke: string; + fill: string; + label: string; + tag: TagTone; +} + +export const FLOW_TONE: Record = { + idle: { + stroke: "var(--mu-surface-5)", + fill: "color-mix(in srgb, var(--mu-surface-5) 18%, transparent)", + label: "空闲", + tag: "neutral", + }, + running: { + stroke: "var(--mu-accent)", + fill: "color-mix(in srgb, var(--mu-accent) 18%, transparent)", + label: "运行中", + tag: "accent", + }, + blocked: { + stroke: "var(--mu-warn)", + fill: "color-mix(in srgb, var(--mu-warn) 18%, transparent)", + label: "阻塞", + tag: "warn", + }, + done: { + stroke: "var(--mu-ok)", + fill: "color-mix(in srgb, var(--mu-ok) 18%, transparent)", + label: "完成", + tag: "ok", + }, + error: { + stroke: "var(--mu-err)", + fill: "color-mix(in srgb, var(--mu-err) 18%, transparent)", + label: "错误", + tag: "err", + }, +}; + +/** Legend / aggregate order, coldest to most alarming. */ +export const STATUS_ORDER: ReadonlyArray = [ + "idle", + "running", + "blocked", + "done", + "error", +]; + +/** statusOf narrows a node's optional status to a concrete AgentStatus. */ +export function statusOf(node: FlowNode): AgentStatus { + return node.status ?? "idle"; +} diff --git a/internal/runtime/trace/graph.go b/internal/runtime/trace/graph.go index b789236..73661af 100644 --- a/internal/runtime/trace/graph.go +++ b/internal/runtime/trace/graph.go @@ -30,6 +30,28 @@ const ( // collapsed to a safe, dashboard-friendly prefix. const maxSummaryRunes = 80 +// Node kinds the builder emits. They are named constants because the builder +// itself branches on the subagent kind when it resolves a node's lane +// (design/19 §2: parallel sub-agent branches render as separate swimlanes). +const ( + nodeKindLLM = "llm" + nodeKindTool = "tool" + nodeKindSubagent = "subagent" + nodeKindMemory = "memory" + nodeKindGuardrail = "guardrail" + nodeKindOrchestrate = "orchestration" +) + +// maxLaneWalk bounds how far the builder walks a span's parent chain when it +// resolves the lane (nearest enclosing subagent node) of a new node. Span +// parents form a tree, so the bound only guards against malformed input — it +// keeps the walk finite without a magic number in the loop (project rule §10). +const maxLaneWalk = 64 + +// trunkLane is the lane key of the main trunk: the lane of every span that has no +// sub-agent ancestor. It is a named constant so the map lookups read as intent. +const trunkLane = "" + // FlowNode is one node in a flow graph. Status/SpanKind mirror the frontier. type FlowNode struct { ID string `json:"id"` @@ -82,17 +104,27 @@ type RunGraph struct { order []string edges map[string]bool edgeOrder []FlowEdge - last string - started time.Time - ended time.Time + // 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), + session: session, + nodes: make(map[string]*FlowNode), + edges: make(map[string]bool), + parents: make(map[string]string), + laneLast: make(map[string]string), } } @@ -106,17 +138,21 @@ func (g *RunGraph) Start(round int, now time.Time) { g.order = nil g.edges = make(map[string]bool) g.edgeOrder = nil - g.last = "" + g.parents = make(map[string]string) + g.laneLast = make(map[string]string) } -// Upsert creates or merges a node and wires its incoming edge. The edge source -// is the node's ParentID when that parent is already a node, otherwise the most -// recently added node — so a round assembles as a left-to-right trunk even when -// the parent container (a state_transition span) is omitted from the graph. +// Upsert creates or merges a node and wires its incoming edge. The edge source is +// the node's ParentID when that parent is already a node; otherwise the span +// belongs to a lane (subagent branch) or the main trunk and is chained after the +// last node of that same lane — so a lane stays a coherent step chain even while +// several lanes are in flight, and the first node of a lane hangs off its +// subagent root (design/19 §2: 泳道内展示该子代理的步骤链). func (g *RunGraph) Upsert(spec NodeSpec) { if spec.ID == "" { return } + g.rememberParent(spec.ID, spec.ParentID) if existing := g.nodes[spec.ID]; existing != nil { g.merge(existing, spec) return @@ -136,16 +172,51 @@ func (g *RunGraph) Upsert(spec NodeSpec) { g.nodes[spec.ID] = n g.order = append(g.order, spec.ID) - from := g.last + lane := g.laneOf(spec.ParentID) + from := g.laneLast[lane] if spec.ParentID != "" { if _, ok := g.nodes[spec.ParentID]; ok { from = spec.ParentID } } + if from == "" { + from = lane + } if from != "" && from != spec.ID { g.addEdge(from, spec.ID) } - g.last = spec.ID + // A sub-agent root is drawn on the trunk it hangs off, but it does not become + // the trunk's continuation point: the main chain resumes where the branch left + // it (the spawn node). Without this, every node after a spawn would look like a + // step of the last branch (the frontend derives lanes from the edges). The + // first trunk node still advances the cursor, so a graph built without an input + // seed cannot orphan its later trunk nodes. + if lane != "" || n.Kind != nodeKindSubagent || g.laneLast[trunkLane] == "" { + g.laneLast[lane] = spec.ID + } +} + +// rememberParent records a span's parent link even when the span is not rendered +// as a node, so laneOf can walk through skipped containers. +func (g *RunGraph) rememberParent(id, parent string) { + if id == "" { + return + } + g.parents[id] = parent +} + +// laneOf returns the id of the nearest enclosing subagent node for a span's +// parent chain, or "" when the chain belongs to the main trunk. Only nodes +// already in the graph can be a lane root, so a lane resolves as soon as its +// first step is folded in. +func (g *RunGraph) laneOf(parentID string) string { + for id, hops := parentID, 0; id != "" && hops < maxLaneWalk; hops++ { + if n, ok := g.nodes[id]; ok && n.Kind == nodeKindSubagent { + return id + } + id = g.parents[id] + } + return "" } // merge updates an existing node in place (a running placeholder being finalized @@ -213,10 +284,12 @@ func (g *RunGraph) SeedOutput(text string) string { } // AddSpan folds a finished span record into the graph as a node with its final -// status. State-transition spans are the step container and are omitted. +// status. State-transition spans are the step container and are omitted — but +// their parent link is remembered so lane resolution can walk through them. func (g *RunGraph) AddSpan(rec SpanRecord) { kind := kindForSpan(rec.Kind) if kind == "" { + g.rememberParent(rec.ID, rec.ParentID) return } g.Upsert(NodeSpec{ @@ -234,10 +307,12 @@ func (g *RunGraph) AddSpan(rec SpanRecord) { // AddSpanStart registers a running placeholder for a span that just began, so a // node appears as "running" before it finishes. State-transition spans are -// omitted, matching AddSpan. It reports whether a node was created. +// omitted (their parent link is still remembered for lane resolution), matching +// AddSpan. It reports whether a node was created. func (g *RunGraph) AddSpanStart(id, name string, kind SpanKind, parent string, at time.Time) bool { nodeKind := kindForSpan(kind) if nodeKind == "" { + g.rememberParent(id, parent) return false } g.Upsert(NodeSpec{ @@ -272,17 +347,17 @@ func (g *RunGraph) Snapshot() *FlowGraph { func kindForSpan(k SpanKind) string { switch k { case SpanKindLLM: - return "llm" + return nodeKindLLM case SpanKindTool: - return "tool" + return nodeKindTool case SpanKindSubagent: - return "subagent" + return nodeKindSubagent case SpanKindMemory: - return "memory" + return nodeKindMemory case SpanKindGuardrail: - return "guardrail" + return nodeKindGuardrail case SpanKindOrchestration: - return "orchestration" + return nodeKindOrchestrate default: return "" } @@ -290,7 +365,7 @@ func kindForSpan(k SpanKind) string { // labelForSpan renders a node label from a finished span. func labelForSpan(rec SpanRecord) string { - if kind := kindForSpan(rec.Kind); kind == "tool" { + if kind := kindForSpan(rec.Kind); kind == nodeKindTool { return strings.TrimPrefix(rec.Name, "tool.") } if rec.Kind == SpanKindLLM { diff --git a/internal/runtime/trace/graph_lane_test.go b/internal/runtime/trace/graph_lane_test.go new file mode 100644 index 0000000..512e25c --- /dev/null +++ b/internal/runtime/trace/graph_lane_test.go @@ -0,0 +1,155 @@ +package trace + +import ( + "fmt" + "sort" + "strings" + "testing" + "time" +) + +// edgeSet renders a graph's edges as a sorted "from>to" set, so a test can assert +// the exact wiring (an extra cross-lane edge fails even though the wanted edges +// are present). +func edgeSet(g *FlowGraph) []string { + out := make([]string, 0, len(g.Edges)) + for _, e := range g.Edges { + out = append(out, e.From+">"+e.To) + } + sort.Strings(out) + return out +} + +// assertEdges asserts the graph's edges match want exactly. +func assertEdges(t *testing.T, g *FlowGraph, want ...string) { + t.Helper() + sort.Strings(want) + got := edgeSet(g) + if strings.Join(got, " ") != strings.Join(want, " ") { + t.Fatalf("edges = %v, want %v", got, want) + } +} + +// TestBuildFlowGraphParallelLanesStaySeparate folds two sub-agent lanes whose +// spans interleave (A's second step arrives after B started) and asserts the +// wiring stays per-lane: each lane chains from its own root through its own +// steps, with no edge crossing lanes (design/19 §2 泳道). +func TestBuildFlowGraphParallelLanesStaySeparate(t *testing.T) { + spans := []SpanRecord{ + spanRec("step1", "", "loop.step", SpanKindStateTransition, SpanStatusOK, nil), + spanRec("t1", "step1", "tool.spawn", SpanKindTool, SpanStatusOK, nil), + spanRec("subA", "t1", "subagent.a", SpanKindSubagent, SpanStatusOK, nil), + spanRec("stA", "subA", "loop.step", SpanKindStateTransition, SpanStatusOK, nil), + spanRec("llmA", "stA", "llm.call", SpanKindLLM, SpanStatusOK, nil), + // Lane B starts before lane A's second step finishes. + spanRec("subB", "t1", "subagent.b", SpanKindSubagent, SpanStatusOK, nil), + spanRec("stB", "subB", "loop.step", SpanKindStateTransition, SpanStatusOK, nil), + spanRec("llmB", "stB", "llm.call", SpanKindLLM, SpanStatusOK, nil), + spanRec("stA2", "subA", "loop.step", SpanKindStateTransition, SpanStatusOK, nil), + spanRec("toolA", "stA2", "tool.search", SpanKindTool, SpanStatusOK, nil), + // The main loop continues on its own step after the spawn tool call. + spanRec("st2", "", "loop.step", SpanKindStateTransition, SpanStatusOK, nil), + spanRec("llm2", "st2", "llm.call", SpanKindLLM, SpanStatusOK, nil), + } + + g := graphFor(spans, "q", "a") + + assertEdges(t, g, + "input>t1", + "t1>subA", + "t1>subB", + "subA>llmA", + "llmA>toolA", + "subB>llmB", + // The trunk resumes from the spawn node, not from a sub-agent's branch. + "t1>llm2", + "llm2>output", + ) +} + +// TestBuildFlowGraphNestedSubagentLane folds a sub-agent spawned inside another +// sub-agent and asserts the nested lane chains off its own root while staying +// chained inside the parent lane. +func TestBuildFlowGraphNestedSubagentLane(t *testing.T) { + spans := []SpanRecord{ + spanRec("step1", "", "loop.step", SpanKindStateTransition, SpanStatusOK, nil), + spanRec("t1", "step1", "tool.spawn", SpanKindTool, SpanStatusOK, nil), + spanRec("subA", "t1", "subagent.a", SpanKindSubagent, SpanStatusOK, nil), + spanRec("stA", "subA", "loop.step", SpanKindStateTransition, SpanStatusOK, nil), + spanRec("llmA", "stA", "llm.call", SpanKindLLM, SpanStatusOK, nil), + spanRec("stN", "subA", "loop.step", SpanKindStateTransition, SpanStatusOK, nil), + spanRec("subN", "stN", "subagent.nested", SpanKindSubagent, SpanStatusOK, nil), + spanRec("stN2", "subN", "loop.step", SpanKindStateTransition, SpanStatusOK, nil), + spanRec("llmN", "stN2", "llm.call", SpanKindLLM, SpanStatusOK, nil), + } + + g := graphFor(spans, "q", "a") + + assertEdges(t, g, + "input>t1", + "t1>subA", + "subA>llmA", + // The nested sub-agent is a step of lane A... + "llmA>subN", + // ...and its own steps hang off it. + "subN>llmN", + // The trunk resumes from the spawn node even though the round ended inside + // a nested branch. + "t1>output", + ) + if got := nodeByID(g, "subN").Kind; got != nodeKindSubagent { + t.Fatalf("nested node kind = %q, want %q", got, nodeKindSubagent) + } +} + +// TestBuildFlowGraphTrunkCursorUnchanged asserts the lane-aware cursor degrades to +// the plain left-to-right trunk when no sub-agent is in flight (no P1 regression). +func TestBuildFlowGraphTrunkCursorUnchanged(t *testing.T) { + spans := []SpanRecord{ + spanRec("step1", "", "loop.step", SpanKindStateTransition, SpanStatusOK, nil), + spanRec("llm1", "step1", "llm.call", SpanKindLLM, SpanStatusOK, nil), + spanRec("step2", "", "loop.step", SpanKindStateTransition, SpanStatusOK, nil), + spanRec("llm2", "step2", "llm.call", SpanKindLLM, SpanStatusOK, nil), + spanRec("step3", "", "loop.step", SpanKindStateTransition, SpanStatusOK, nil), + spanRec("tool2", "step3", "tool.echo", SpanKindTool, SpanStatusOK, nil), + } + + g := graphFor(spans, "q", "a") + + assertEdges(t, g, + "input>llm1", + "llm1>llm2", + "llm2>tool2", + "tool2>output", + ) +} + +// TestLaneOfWalkBound asserts a malformed (over-deep or cyclic) parent chain +// cannot spin laneOf forever: past the walk bound the node simply lands on the +// trunk, while a shallow chain still resolves to its sub-agent root. +func TestLaneOfWalkBound(t *testing.T) { + g := NewRunGraph("web") + g.Start(1, time.Now()) + g.AddSpan(spanRec("sub", "", "subagent.a", SpanKindSubagent, SpanStatusOK, nil)) + + g.rememberParent("near", "sub") + if got := g.laneOf("near"); got != "sub" { + t.Fatalf("laneOf near = %q, want sub", got) + } + + prev := "sub" + for i := 0; i <= maxLaneWalk+5; i++ { + id := fmt.Sprintf("st%d", i) + g.rememberParent(id, prev) + prev = id + } + if got := g.laneOf(prev); got != "" { + t.Fatalf("laneOf over the walk bound = %q, want \"\"", got) + } + + g.rememberParent("cycA", "cycB") + g.rememberParent("cycB", "cycA") + if got := g.laneOf("cycA"); got != "" { + t.Fatalf("laneOf cycle = %q, want \"\"", got) + } +}