@huaqiu/dsh-tool-schematic-gen 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/trace.ts ADDED
@@ -0,0 +1,336 @@
1
+ /**
2
+ * `@huaqiu/dsh-tool-schematic-gen` — execution-trace model for the eda.cn
3
+ * design agents.
4
+ *
5
+ * Port of the `hq-eda-ai` schematic-gen trace contract
6
+ * (`packages/agents/schematic-gen/src/utils/trace.ts`) plus its frontend
7
+ * start/end pairing logic
8
+ * (`apps/web/src/components/sch_sub_gen/TraceTimeline.tsx#buildStatusEntries`).
9
+ *
10
+ * The eda.cn CopilotKit endpoint streams AG-UI `CUSTOM` events, each carrying
11
+ * one `TraceEvent` (LangGraph `custom` stream mode):
12
+ *
13
+ * data: {"type":"CUSTOM","name":"SCHEMATIC_GENERATOR_TRACE",
14
+ * "value":{"kind":"tool","phase":"start","scope":"schematicDesign",
15
+ * "name":"search_parts","ts":1770000000000}}
16
+ *
17
+ * `scope` is a `parent>child` breadcrumb and is the ONLY hierarchical signal
18
+ * the backend gives us — it is what the card turns into a call stack.
19
+ *
20
+ * @module @huaqiu/dsh-tool-schematic-gen
21
+ */
22
+
23
+ // ── Wire model ─────────────────────────────────────────────────────────────
24
+
25
+ export type TracePhase = 'start' | 'end'
26
+
27
+ /** A LangGraph graph node entering/leaving. */
28
+ export interface NodeTraceEvent {
29
+ kind: 'node'
30
+ phase: TracePhase
31
+ /** Graph node name. */
32
+ node: string
33
+ ts: number
34
+ /**
35
+ * Full scope path (`parent>this`). Older backend builds omit it; the
36
+ * frontend then degrades to the node's own name (see {@link tracePath}).
37
+ */
38
+ scope?: string
39
+ /** Optional human note emitted by the node. */
40
+ note?: string
41
+ }
42
+
43
+ /** A tool call entering/leaving. */
44
+ export interface ToolTraceEvent {
45
+ kind: 'tool'
46
+ phase: TracePhase
47
+ /** Scope path of the CALLER (`parent`), not including `name`. */
48
+ scope: string
49
+ name: string
50
+ ts: number
51
+ /**
52
+ * Instance path (`parent>leaf::task:<toolCallId>`), present only for spans
53
+ * synthesized from the AG-UI tool-call lifecycle. The hidden `::task:` suffix
54
+ * makes repeated calls of the SAME tool distinct so retries are not lost,
55
+ * while {@link traceName} keeps the display name clean.
56
+ */
57
+ path?: string
58
+ /** Absent means success; only `false` marks failure. */
59
+ ok?: boolean
60
+ }
61
+
62
+ export type TraceEvent = NodeTraceEvent | ToolTraceEvent
63
+
64
+ /**
65
+ * Custom-event names the eda.cn agents are known to use. Matching is
66
+ * deliberately tolerant: any name ending in `_TRACE` is accepted too, so a
67
+ * backend rename does not silently kill progress.
68
+ */
69
+ export const KNOWN_TRACE_EVENT_NAMES: readonly string[] = [
70
+ 'SCHEMATIC_GENERATOR_TRACE',
71
+ 'SCHEMAGEN_TRACE',
72
+ 'MODULAR_CIRCUIT_TRACE',
73
+ 'MODULE_GEN_TRACE',
74
+ ]
75
+
76
+ export function isTraceEventName(name: unknown): boolean {
77
+ if (typeof name !== 'string' || name.length === 0) return false
78
+ if (KNOWN_TRACE_EVENT_NAMES.includes(name)) return true
79
+ return /_TRACE$/i.test(name)
80
+ }
81
+
82
+ // ── Parsing ────────────────────────────────────────────────────────────────
83
+
84
+ function isRecord(v: unknown): v is Record<string, unknown> {
85
+ return !!v && typeof v === 'object' && !Array.isArray(v)
86
+ }
87
+
88
+ /**
89
+ * Validate one decoded `CUSTOM` payload as a {@link TraceEvent}.
90
+ * Returns `null` for anything else (interrupts, future event shapes).
91
+ */
92
+ export function parseTraceEvent(value: unknown): TraceEvent | null {
93
+ if (!isRecord(value)) return null
94
+ const kind = value['kind']
95
+ const phase = value['phase']
96
+ const ts = value['ts']
97
+ if (phase !== 'start' && phase !== 'end') return null
98
+ const timestamp = typeof ts === 'number' && Number.isFinite(ts) ? ts : Date.now()
99
+
100
+ if (kind === 'node') {
101
+ const node = value['node']
102
+ if (typeof node !== 'string' || node.length === 0) return null
103
+ const ev: NodeTraceEvent = { kind: 'node', phase, node, ts: timestamp }
104
+ const scope = value['scope']
105
+ if (typeof scope === 'string' && scope.length > 0) ev.scope = scope
106
+ const note = value['note']
107
+ if (typeof note === 'string' && note.length > 0) ev.note = note
108
+ return ev
109
+ }
110
+
111
+ if (kind === 'tool') {
112
+ const name = value['name']
113
+ if (typeof name !== 'string' || name.length === 0) return null
114
+ const scope = value['scope']
115
+ const ev: ToolTraceEvent = {
116
+ kind: 'tool',
117
+ phase,
118
+ // Tolerate a missing scope by degrading to the tool's own name.
119
+ scope: typeof scope === 'string' && scope.length > 0 ? scope : name,
120
+ name,
121
+ ts: timestamp,
122
+ }
123
+ const path = value['path']
124
+ if (typeof path === 'string' && path.length > 0) ev.path = path
125
+ if (value['ok'] === false) ev.ok = false
126
+ return ev
127
+ }
128
+
129
+ return null
130
+ }
131
+
132
+ /**
133
+ * Collect every {@link TraceEvent} inside one `CUSTOM` payload. The backend
134
+ * normally writes one event per chunk, but array payloads are accepted so a
135
+ * batched writer cannot drop the stream.
136
+ */
137
+ export function collectTraceEvents(value: unknown): TraceEvent[] {
138
+ if (Array.isArray(value)) {
139
+ const out: TraceEvent[] = []
140
+ for (const item of value) {
141
+ const ev = parseTraceEvent(item)
142
+ if (ev) out.push(ev)
143
+ }
144
+ return out
145
+ }
146
+ const ev = parseTraceEvent(value)
147
+ return ev ? [ev] : []
148
+ }
149
+
150
+ // ── Path derivation ────────────────────────────────────────────────────────
151
+
152
+ /**
153
+ * Canonical `a>b>c` path for one event — the breadcrumb the card nests by.
154
+ *
155
+ * Mirrors `TraceTimeline.buildStatusEntries`: a node's path is its own scope
156
+ * (falling back to its name when the backend omits scope), while a tool's path
157
+ * is its caller scope with the tool name appended.
158
+ */
159
+ export function tracePath(ev: TraceEvent): string {
160
+ if (ev.kind === 'node') {
161
+ return ev.scope && ev.scope.length > 0 ? ev.scope : ev.node
162
+ }
163
+ // The instance path wins when present: it is the only breadcrumb that
164
+ // distinguishes two calls of the same tool at the same scope.
165
+ if (ev.path && ev.path.length > 0) return ev.path
166
+ return ev.scope === ev.name ? ev.name : `${ev.scope}>${ev.name}`
167
+ }
168
+
169
+ /** Display name of one event (`node:foo` for nodes, bare name for tools). */
170
+ export function traceName(ev: TraceEvent): string {
171
+ return ev.kind === 'node' ? `node:${ev.node}` : ev.name
172
+ }
173
+
174
+ // ── Start/end pairing ──────────────────────────────────────────────────────
175
+
176
+ export type TraceStatus = 'running' | 'finished' | 'failed'
177
+
178
+ /** One paired span: a stack frame the card can render. */
179
+ export interface TraceFrame {
180
+ /** Stable synthetic id (`trace-<seq>`). */
181
+ id: string
182
+ /** Display key, e.g. `node:plan` or `search_parts`. */
183
+ name: string
184
+ /** Full `parent>child>leaf` breadcrumb used to nest the frame. */
185
+ path: string
186
+ status: TraceStatus
187
+ startedAt: number
188
+ finishedAt?: number
189
+ }
190
+
191
+ /**
192
+ * Pair `start`/`end` events into {@link TraceFrame}s, preserving nesting via
193
+ * `path`.
194
+ *
195
+ * Uses the same per-key open stack as `buildStatusEntries`, so nested calls
196
+ * with the same name (a tool re-entered inside its own subtree) close in LIFO
197
+ * order instead of cross-contaminating.
198
+ */
199
+ export function pairTraceEvents(events: readonly TraceEvent[]): TraceFrame[] {
200
+ const frames: TraceFrame[] = []
201
+ const open = new Map<string, number[]>()
202
+ let seq = 0
203
+
204
+ for (const ev of events) {
205
+ const name = traceName(ev)
206
+ const path = tracePath(ev)
207
+ // Key on the display name + path so two different subtrees using the same
208
+ // tool name never share an open slot.
209
+ const key = `${path}|${name}`
210
+
211
+ if (ev.phase === 'start') {
212
+ const stack = open.get(key)
213
+ const index = frames.length
214
+ if (stack) stack.push(index)
215
+ else open.set(key, [index])
216
+ frames.push({ id: `trace-${seq++}`, name, path, status: 'running', startedAt: ev.ts })
217
+ continue
218
+ }
219
+
220
+ const stack = open.get(key)
221
+ const index = stack?.pop()
222
+ if (index === undefined) continue // unpaired end — nothing to close
223
+ const failed = ev.kind === 'tool' && ev.ok === false
224
+ frames[index] = {
225
+ ...frames[index]!,
226
+ status: failed ? 'failed' : 'finished',
227
+ finishedAt: ev.ts,
228
+ }
229
+ }
230
+
231
+ return frames
232
+ }
233
+
234
+ // ── AG-UI tool-call lifecycle ──────────────────────────────────────────────
235
+
236
+ /**
237
+ * Marker appended to the leaf of a synthesized instance path. It is invisible
238
+ * in the UI (stripped from display names and from the tree key) but makes each
239
+ * invocation of a repeated tool a distinct path.
240
+ */
241
+ export const TASK_MARK_RE = /::task:[^>]*/g
242
+
243
+ /** Strip the hidden `::task:<id>` suffixes from one path segment/path. */
244
+ export function stripTaskIds(path: string): string {
245
+ return path.replace(TASK_MARK_RE, '')
246
+ }
247
+
248
+ /** True when a path was synthesized from the tool-call lifecycle. */
249
+ export function hasTaskId(path: string): boolean {
250
+ return path.includes('::task:')
251
+ }
252
+
253
+ interface ActiveToolCall {
254
+ semanticPath: string
255
+ instancePath: string
256
+ }
257
+
258
+ /**
259
+ * Convert the **standard AG-UI tool-call lifecycle** (`TOOL_CALL_START` /
260
+ * `TOOL_CALL_END`) into {@link TraceEvent}s.
261
+ *
262
+ * This exists because the two eda.cn agents report progress differently:
263
+ *
264
+ * - `schemagen` (schematic) emits LangGraph `custom`-stream `TraceEvent`s,
265
+ * republished as AG-UI `CUSTOM` events named `SCHEMATIC_GENERATOR_TRACE`.
266
+ * - `modular_circuit` (system design) does NOT emit those. Its stack is
267
+ * rebuilt by the agent server from LangGraph *tasks* and published as the
268
+ * ordinary AG-UI `TOOL_CALL_START` / `TOOL_CALL_END` pair — see
269
+ * `ModuleGenTraceProvider.onToolCallStartEvent` in hq-eda-ai.
270
+ *
271
+ * Without this adapter the system-design tool reported no progress at all.
272
+ *
273
+ * Port of `createToolInstancePath` + `toToolTraceEvent` in
274
+ * `apps/web/src/lib/modular_circuit/context/ModuleGenTraceProvider.tsx`.
275
+ */
276
+ export class ToolCallTracker {
277
+ private readonly active = new Map<string, ActiveToolCall>()
278
+
279
+ /** Forget every open call — invoke on `RUN_STARTED`. */
280
+ reset(): void {
281
+ this.active.clear()
282
+ }
283
+
284
+ /**
285
+ * Build an instance path, reusing the deepest currently-open call whose
286
+ * semantic path is a prefix of this one so nested tools nest properly.
287
+ */
288
+ private instancePath(semanticPath: string, toolCallId: string): string {
289
+ let parent: ActiveToolCall | undefined
290
+ for (const candidate of this.active.values()) {
291
+ if (!semanticPath.startsWith(`${candidate.semanticPath}>`)) continue
292
+ if (!parent || candidate.semanticPath.length > parent.semanticPath.length) {
293
+ parent = candidate
294
+ }
295
+ }
296
+ const relative = parent ? semanticPath.slice(parent.semanticPath.length + 1) : semanticPath
297
+ const segments = relative.split('>').map((s) => s.trim()).filter((s) => s.length > 0)
298
+ const leaf = segments.length - 1
299
+ if (leaf >= 0) segments[leaf] = `${segments[leaf]}::task:${toolCallId}`
300
+ const current = segments.join('>')
301
+ return parent ? `${parent.instancePath}>${current}` : current
302
+ }
303
+
304
+ /** Split a semantic path into its parent scope and leaf display name. */
305
+ private static split(semanticPath: string): { scope: string; name: string } {
306
+ const segments = semanticPath.split('>').map((s) => s.trim()).filter((s) => s.length > 0)
307
+ const name = segments[segments.length - 1] || 'unknown'
308
+ const scope = segments.length > 1 ? segments.slice(0, -1).join('>') : name
309
+ return { scope, name }
310
+ }
311
+
312
+ /** Open a tool call. Returns the `start` trace event. */
313
+ start(toolCallId: string, semanticPath: string, ts: number = Date.now()): TraceEvent {
314
+ const instancePath = this.instancePath(semanticPath, toolCallId)
315
+ if (toolCallId) this.active.set(toolCallId, { semanticPath, instancePath })
316
+ const { scope, name } = ToolCallTracker.split(semanticPath)
317
+ return { kind: 'tool', phase: 'start', scope, name, path: instancePath, ts }
318
+ }
319
+
320
+ /**
321
+ * Close a tool call. Returns the `end` trace event, or `null` when the id
322
+ * was never opened (a late/duplicate frame we must not invent a span for).
323
+ */
324
+ end(toolCallId: string, ts: number = Date.now()): TraceEvent | null {
325
+ const known = this.active.get(toolCallId)
326
+ if (!known) return null
327
+ this.active.delete(toolCallId)
328
+ const { scope, name } = ToolCallTracker.split(known.semanticPath)
329
+ return { kind: 'tool', phase: 'end', scope, name, path: known.instancePath, ts, ok: true }
330
+ }
331
+
332
+ /** Ids still open. Exposed for diagnostics/tests. */
333
+ get openIds(): string[] {
334
+ return [...this.active.keys()]
335
+ }
336
+ }