diff --git a/api/index.api.md b/api/index.api.md index 237398221..ef2bb6309 100644 --- a/api/index.api.md +++ b/api/index.api.md @@ -1467,6 +1467,12 @@ export function compilePolicy(policy: AgentSpecPolicy | undefined): CompiledPoli // @public export function compileSpec(spec: AgentSpecV2, host: SpecHost): Promise; +// @public +export function compileSpecWithNodeIds(spec: AgentSpecV2, host: SpecHost, index?: { + spec?: AgentSpecV2; + toolNamesByRef?: (compiled: CompiledSpec) => readonly (readonly string[])[]; +}): Promise; + // @public (undocumented) interface ConditionNode { // (undocumented) diff --git a/api/spec.api.md b/api/spec.api.md index 2ee8f096c..9d82343bc 100644 --- a/api/spec.api.md +++ b/api/spec.api.md @@ -999,6 +999,11 @@ interface StepNode { // @public type StepPath = (string | number)[]; +// @public +export function subAgentNodeIndexes(subagents: Readonly> | { + list: unknown; +} | undefined): Record; + // @public interface SubagentOptions { awaitBackgroundOnFinish?: boolean; diff --git a/apps/agent-forge/server/__tests__/nodeFrames.test.ts b/apps/agent-forge/server/__tests__/nodeFrames.test.ts index 3e2ac539a..50f87f838 100644 --- a/apps/agent-forge/server/__tests__/nodeFrames.test.ts +++ b/apps/agent-forge/server/__tests__/nodeFrames.test.ts @@ -197,8 +197,7 @@ describe('RunManager node status frames', () => { ['agent', 'done'], ['tool:current_date', 'running'], ['tool:current_date', 'done'], - ['tool:send_email', 'waiting'], // the approval.wait marker - ['tool:send_email', 'done'], + ['tool:send_email', 'waiting'], // the approval.wait marker - and NO done after it (zero-length marker ends emit no node frame) ['tool:failing_tool', 'running'], ['tool:failing_tool', 'error'], ['agent', 'done'], @@ -249,16 +248,13 @@ describe('RunManager node status frames', () => { } }); - it('a real mock-provider run through the in-process host emits no node frames today', async () => { - // NOTE: buildAgentV2 compiles specs via `compileSpec` (the config half - // of specV2ToAgent), which does NOT wrap the run's exporter in S20's - // nodeIdExporter - that wrapping lives in specV2ToAgent only. So a - // Studio in-process run's spans carry no `lousho.node.id` yet and this - // run produces zero `node` frames; the frame logic itself is proven by - // the stamped-span tests above. Wiring Studio's compile through the - // exporter is for the ticket that owns buildAgentV2's S20 integration - - // when it lands, replace this assertion with the real agent/tool - // sequence. + it('a real mock-provider run through the in-process host emits node frames end to end', async () => { + // buildAgentV2 wraps the run's exporter in the SDK's nodeIdExporter + // (specV2ToAgent's S20 wiring), so the run's spans carry + // `lousho.node.id` and RunManager derives `node` frames from them - + // the canvas overlay's whole feed. 'please use current_date' makes + // the mock provider call the current_date tool, so both the agent + // node and the tool node light up. const runManager = makeRunManager(baseDir, newTestHost(baseDir)); const frames: NodeStatePayload[] = []; runManager.on('node', (agentId: string, payload: NodeStatePayload) => { @@ -268,6 +264,12 @@ describe('RunManager node status frames', () => { await runManager.run('mock-agent', 'please use current_date', SPEC); await waitForStatus(runManager, 'mock-agent', (s) => s.status === 'done'); - expect(frames).toEqual([]); + const sequence = frames.map((f) => [f.nodeId, f.state]); + expect(sequence).toContainEqual(['agent', 'running']); + expect(sequence).toContainEqual(['tool:current_date', 'running']); + expect(sequence).toContainEqual(['tool:current_date', 'done']); + expect(sequence[sequence.length - 1]).toEqual(['agent', 'done']); + // Only §3.3 node ids the spec declares - never span names or args. + expect(frames.every((f) => f.nodeId === 'agent' || f.nodeId === 'tool:current_date')).toBe(true); }); }); diff --git a/apps/agent-forge/server/__tests__/nodeStates.test.ts b/apps/agent-forge/server/__tests__/nodeStates.test.ts index a2758584f..ff2b4f820 100644 --- a/apps/agent-forge/server/__tests__/nodeStates.test.ts +++ b/apps/agent-forge/server/__tests__/nodeStates.test.ts @@ -52,6 +52,20 @@ describe('nodeStateFor', () => { expect(nodeStateFor(wait, 'start', NOW)?.state).toBe('waiting'); }); + it('an approval.wait span end yields no frame - the zero-length marker must not clobber waiting with done', () => { + const wait = span({ + name: `${APPROVAL_WAIT_SPAN_NAME} current_date`, + endTime: 1, + attributes: { + [SdkAttr.NODE_ID]: 'tool:current_date', + [SdkAttr.APPROVAL_ID]: 'appr-1', + }, + }); + expect(nodeStateFor(wait, 'end', NOW)).toBeUndefined(); + // A real span's end still frames normally. + expect(nodeStateFor(span({ endTime: 2 }), 'end', NOW)?.state).toBe('done'); + }); + it('a span end without an error status yields state "done"', () => { expect(nodeStateFor(span({ endTime: 2 }), 'end', NOW)?.state).toBe('done'); expect( diff --git a/apps/agent-forge/server/buildAgentV2.ts b/apps/agent-forge/server/buildAgentV2.ts index eebe7920d..110a09348 100644 --- a/apps/agent-forge/server/buildAgentV2.ts +++ b/apps/agent-forge/server/buildAgentV2.ts @@ -33,7 +33,7 @@ */ import * as path from 'node:path'; import { - compileSpec, + compileSpecWithNodeIds, createAgent, type AgentEvent, type AgentHook, @@ -129,6 +129,34 @@ function splitLocalTools(spec: AgentSpecV2): { spec: AgentSpecV2; localTools: Re return { spec: stripped, localTools }; } +/** + * `toolNamesByRef` re-aligned to the ORIGINAL spec's `tools` array: + * `compiled` only saw the non-local refs (local tools were stripped for + * the compiler), so each stripped ref contributes its Studio-local + * model-facing name — the key `mergeLocalTools` registers it under — + * letting `nodeIdIndexOf` stamp `lousho.node.id` on its spans (including + * the `approval.wait` marker) with the canvas's `tool:<…>` id. + */ +function toolNamesByRefForIndex( + spec: AgentSpecV2, + compilable: AgentSpecV2, + compiledNames: readonly (readonly string[])[] +): (readonly string[])[] { + const kept = compilable.tools ?? []; + const out: (readonly string[])[] = []; + let next = 0; + for (const ref of spec.tools ?? []) { + if (next < kept.length && kept[next] === ref) { + out.push(compiledNames[next] ?? []); + next += 1; + } else { + const local = localToolName(ref); + out.push(local === undefined ? [] : [local]); + } + } + return out; +} + /** `config.tools` as a mutable record (compileSpec only ever returns the record form; a function form is left alone). */ function mergeLocalTools(configTools: unknown, localTools: Record): unknown { const base = @@ -180,12 +208,23 @@ export async function buildRunnableAgent( seeded = true; } } - let config; + let compiled; try { - ({ config } = await compileSpec(compilable, host)); + // compileSpecWithNodeIds applies specV2ToAgent's S20 wiring: the run's + // exporter is wrapped in the node-id exporter so every span carries + // `lousho.node.id` and RunManager can emit `node` frames for the canvas + // overlay. The index is built from the ORIGINAL spec (local tools were + // stripped only for the compiler) with toolNamesByRef re-aligned onto + // it, so Studio-local tools' spans - including `approval.wait` markers - + // still stamp their canvas `tool:<…>` ids. + compiled = await compileSpecWithNodeIds(compilable, host, { + spec, + toolNamesByRef: (c) => toolNamesByRefForIndex(spec, compilable, c.toolNamesByRef), + }); } finally { if (seeded && envVar !== undefined) delete process.env[envVar]; } + const { config } = compiled; // The compiled provider is Studio's (stored keys over env); `model` keeps // the spec's string for reporting/fallbacks (a provider INSTANCE wins // over `model` in createAgent's resolveModelSource). diff --git a/apps/agent-forge/server/nodeStates.ts b/apps/agent-forge/server/nodeStates.ts index 1f4426fc9..d64a965c4 100644 --- a/apps/agent-forge/server/nodeStates.ts +++ b/apps/agent-forge/server/nodeStates.ts @@ -39,6 +39,12 @@ function isApprovalWait(span: Span): boolean { export function nodeStateFor(span: Span, phase: 'start' | 'end', now: Date): NodeStatePayload | undefined { const nodeId = span.attributes[NODE_ID_ATTRIBUTE]; if (typeof nodeId !== 'string' || nodeId === '') return undefined; + // The `approval.wait` marker is zero-length (markSpan fires start and + // end in the same tick): its `end` must not emit a `done` frame over + // the `waiting` one - the node stays paused until the gated tool's real + // span (or the run's own status) moves it on. The `span` frame itself + // is still forwarded; only the node-state derivation is suppressed. + if (phase === 'end' && isApprovalWait(span)) return undefined; const state: NodeRunState = phase === 'start' ? isApprovalWait(span) diff --git a/apps/agent-forge/src/__tests__/polishCss.test.ts b/apps/agent-forge/src/__tests__/polishCss.test.ts index 0b5d90c52..9d84a3e57 100644 --- a/apps/agent-forge/src/__tests__/polishCss.test.ts +++ b/apps/agent-forge/src/__tests__/polishCss.test.ts @@ -56,6 +56,21 @@ describe('sticky side panels', () => { }); }); +describe('canvas node status rings', () => { + const css = sheet('screens/agents/agents.css'); + it('paused rings in --warning-border, error in --danger-border, done rings nothing', () => { + expect(rulesFor(css, '.canvas-wrap .rf-node.paused::after')).toMatch(/border:\s*2px solid var\(--warning-border\)/); + expect(rulesFor(css, '.canvas-wrap .rf-node.error::after')).toMatch(/border:\s*2px solid var\(--danger-border\)/); + expect(rulesFor(css, '.canvas-wrap .rf-node.done::after')).toBe(''); + }); + it('turns the ring animation off under prefers-reduced-motion', () => { + const reduced = css.slice(css.indexOf('prefers-reduced-motion')); + expect(reduced).toContain('.canvas-wrap .rf-node.running::after'); + expect(reduced).toContain('.canvas-wrap .rf-node.paused::after'); + expect(reduced).toMatch(/animation:\s*none/); + }); +}); + describe('U01 tokens', () => { const theme = sheet('theme.css'); it.each(['success', 'warning', 'danger', 'info'])('--%s-border is the status colour at 40%%', (s) => { diff --git a/apps/agent-forge/src/canvas/NodeCard.tsx b/apps/agent-forge/src/canvas/NodeCard.tsx index 23e53ddfc..3b1218565 100644 --- a/apps/agent-forge/src/canvas/NodeCard.tsx +++ b/apps/agent-forge/src/canvas/NodeCard.tsx @@ -36,6 +36,9 @@ export function cardClassName( ['highlighted', data.highlighted], ['has-breakpoint', data.hasBreakpoint], ['running', data.status === 'running'], + ['paused', data.status === 'paused'], + ['error', data.status === 'error'], + ['done', data.status === 'done'], ]; return [base, ...flags.filter(([, on]) => on).map(([name]) => name)].join(' '); } diff --git a/apps/agent-forge/src/canvas/__tests__/AgentNode.test.tsx b/apps/agent-forge/src/canvas/__tests__/AgentNode.test.tsx index 62c1cd260..4af690bb5 100644 --- a/apps/agent-forge/src/canvas/__tests__/AgentNode.test.tsx +++ b/apps/agent-forge/src/canvas/__tests__/AgentNode.test.tsx @@ -132,6 +132,12 @@ describe('AgentNode card', () => { for (const state of ['running', 'selected', 'highlighted', 'has-breakpoint']) expect(card.classList).toContain(state); }); + it.each(['paused', 'error', 'done'] as const)('marks a %s node with its state class', (status) => { + const card = renderCard(nodeOf('tool'), { status }); + expect(card.classList).toContain(status); + expect(card.classList).not.toContain('running'); + }); + it('renders the generic card markup', () => { const card = renderCard(nodeOf('abort'), { status: 'paused' }); expect(card.outerHTML).toMatchSnapshot(); diff --git a/apps/agent-forge/src/canvas/__tests__/__snapshots__/AgentNode.test.tsx.snap b/apps/agent-forge/src/canvas/__tests__/__snapshots__/AgentNode.test.tsx.snap index e8d97236a..f02e5ded7 100644 --- a/apps/agent-forge/src/canvas/__tests__/__snapshots__/AgentNode.test.tsx.snap +++ b/apps/agent-forge/src/canvas/__tests__/__snapshots__/AgentNode.test.tsx.snap @@ -1,3 +1,3 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`AgentNode card > renders the generic card markup 1`] = `"
a1awaiting approval
"`; +exports[`AgentNode card > renders the generic card markup 1`] = `"
a1awaiting approval
"`; diff --git a/apps/agent-forge/src/canvas/__tests__/nodeStatus.test.ts b/apps/agent-forge/src/canvas/__tests__/nodeStatus.test.ts index 1c469e407..34a6cf669 100644 --- a/apps/agent-forge/src/canvas/__tests__/nodeStatus.test.ts +++ b/apps/agent-forge/src/canvas/__tests__/nodeStatus.test.ts @@ -1,74 +1,143 @@ import { describe, it, expect } from 'vitest'; import { nodeRunStatuses } from '../nodeStatus'; -import { specToGraph } from '../../graph/v2/specToGraph'; -import { graphToSpec } from '../../graph/v2/graphToSpec'; -import type { AgentRunStatusPayload, SpanEvent } from '../../../shared/wireTypes'; - -const graph = specToGraph({ - version: 2, - name: 'bot', - model: 'mock/mock-1', - instructions: 'p', - tools: [{ builtin: 'http' }], -}); -const nodeIdMap = graphToSpec(graph).nodeIdMap; -const toolId = 'tool:http'; - -const chat = (over: Partial = {}): SpanEvent => ({ - id: 'chat-1', - name: 'chat', - parentId: 'root', - startTime: 1, - attributes: { 'gen_ai.operation.name': 'chat' }, - ...over, -}); -const tool = (over: Partial = {}): SpanEvent => ({ - id: 'tool-1', - name: 'execute_tool http', - parentId: 'root', - startTime: 2, - attributes: { 'gen_ai.operation.name': 'execute_tool', 'gen_ai.tool.name': 'http' }, - ...over, -}); +import type { NodeStates } from '../../state/nodeStateReducer'; +import { applyNodeState } from '../../state/nodeStateReducer'; +import type { AgentRunStatusPayload, NodeStatePayload, NodeRunState } from '../../../shared/wireTypes'; + const status = (s: AgentRunStatusPayload['status'], over: Partial = {}): AgentRunStatusPayload => ({ agentId: 'bot', status: s, updatedAt: '', ...over }) as AgentRunStatusPayload; +const frame = (nodeId: string, state: NodeRunState, at = '2026-01-01T00:00:00.000Z'): NodeStatePayload => ({ + nodeId, + state, + spanId: `${nodeId}:${state}`, + at, +}); + +const states = (...frames: NodeStatePayload[]): NodeStates => + Object.fromEntries(frames.map((f) => [f.nodeId, f])); + +// A flat graph's ids plus a collapsed subagent group and one of its children. +const NODE_IDS = ['agent', 'tool:http', 'sub:research', 'sub:research/tool:web_fetch']; + describe('nodeRunStatuses', () => { it('is idle for every node before any run', () => { - const statuses = nodeRunStatuses(graph, [], undefined, nodeIdMap); - expect(graph.nodes.every((n) => statuses[n.id] === 'idle')).toBe(true); + const statuses = nodeRunStatuses(NODE_IDS, {}, undefined); + expect(NODE_IDS.every((id) => statuses[id] === 'idle')).toBe(true); }); - it('a span stamped with lousho.node.id lands on the graph node the id maps from', () => { - const stamped = chat({ attributes: { 'lousho.node.id': 'tool:http', 'gen_ai.operation.name': 'chat' } }); - const statuses = nodeRunStatuses(graph, [stamped], status('running'), nodeIdMap); - expect(statuses[toolId]).toBe('running'); - expect(statuses['agent']).toBe('idle'); + it('lights the run up frame by frame: agent running, tool running, tool done, agent done', () => { + const ids = ['agent', 'tool:web-fetch']; + let s = nodeRunStatuses(ids, states(frame('agent', 'running')), status('running')); + expect(s.agent).toBe('running'); + expect(s['tool:web-fetch']).toBe('idle'); + + s = nodeRunStatuses(ids, states(frame('agent', 'running'), frame('tool:web-fetch', 'running', '2026-01-01T00:00:01.000Z')), status('running')); + expect(s['tool:web-fetch']).toBe('running'); + + s = nodeRunStatuses( + ids, + states(frame('agent', 'running'), frame('tool:web-fetch', 'done', '2026-01-01T00:00:02.000Z')), + status('running') + ); + expect(s['tool:web-fetch']).toBe('done'); + expect(s.agent).toBe('running'); + + s = nodeRunStatuses( + ids, + states(frame('agent', 'done', '2026-01-01T00:00:03.000Z'), frame('tool:web-fetch', 'done', '2026-01-01T00:00:02.000Z')), + status('done') + ); + expect(s.agent).toBe('done'); + expect(s['tool:web-fetch']).toBe('done'); + }); + + it.each<[NodeRunState, AgentRunStatusPayload['status'] | undefined, string]>([ + ['running', 'running', 'running'], + ['running', 'paused', 'paused'], + ['running', 'stopped', 'idle'], + ['running', 'done', 'idle'], + ['running', undefined, 'idle'], + ['waiting', 'paused', 'paused'], + ['waiting', 'running', 'paused'], + ['waiting', 'stopped', 'idle'], + ['waiting', 'done', 'idle'], + ['waiting', undefined, 'idle'], + ['done', 'done', 'done'], + ['error', 'error', 'error'], + ])('frame %s with run %s maps to %s', (state, run, expected) => { + const runStatus = run === undefined ? undefined : status(run); + expect(nodeRunStatuses(['agent'], states(frame('agent', state)), runStatus).agent).toBe(expected); + }); + + it('a waiting frame shows the node paused (the warning ring status)', () => { + const s = nodeRunStatuses(['tool:http'], states(frame('tool:http', 'waiting')), status('paused')); + expect(s['tool:http']).toBe('paused'); + }); + + it('a node stays paused through the approval wait, then runs and finishes with the tool', () => { + // The approval.wait marker's end emits no node frame (nodeStates.ts), + // so the waiting frame is the node's latest until the gated tool's + // real span frames arrive after the resume. + const ids = ['tool:demo-approval']; + const waiting = frame('tool:demo-approval', 'waiting', '2026-01-01T00:00:01.000Z'); + let nodeStates: NodeStates = applyNodeState({}, waiting); + // No clobbering done frame arrives; a repeated status push keeps paused. + expect(nodeRunStatuses(ids, nodeStates, status('paused'))['tool:demo-approval']).toBe('paused'); + + nodeStates = applyNodeState( + nodeStates, + frame('tool:demo-approval', 'running', '2026-01-01T00:00:05.000Z') + ); + // spanId differs from the marker's, and `at` is newer - the wait is over. + expect(nodeRunStatuses(ids, nodeStates, status('running'))['tool:demo-approval']).toBe('running'); + + nodeStates = applyNodeState( + nodeStates, + frame('tool:demo-approval', 'done', '2026-01-01T00:00:06.000Z') + ); + expect(nodeRunStatuses(ids, nodeStates, status('running'))['tool:demo-approval']).toBe('done'); }); - it('marks the node of an open span running, and a finished one done', () => { - const statuses = nodeRunStatuses(graph, [chat({ endTime: 5 }), tool()], status('running'), nodeIdMap); - expect(statuses['agent']).toBe('done'); - expect(statuses[toolId]).toBe('running'); + it('raises the subagent group node from a child frame', () => { + const s = nodeRunStatuses(NODE_IDS, states(frame('sub:research/tool:web_fetch', 'running')), status('running')); + expect(s['sub:research/tool:web_fetch']).toBe('running'); + expect(s['sub:research']).toBe('running'); + expect(s.agent).toBe('idle'); }); - it('reads an open span as running only while the run is running', () => { - for (const s of ['stopped', 'done', 'error', 'idle'] as const) { - expect(nodeRunStatuses(graph, [tool()], status(s), nodeIdMap)[toolId]).toBe('idle'); - } - expect(nodeRunStatuses(graph, [tool()], undefined, nodeIdMap)[toolId]).toBe('idle'); - expect(nodeRunStatuses(graph, [tool()], status('paused'), nodeIdMap)[toolId]).toBe('paused'); + it('raises a nested group path only to its immediate owner', () => { + const s = nodeRunStatuses( + ['sub:a', 'sub:a/sub:b', 'sub:a/sub:b/tool:x'], + states(frame('sub:a/sub:b/tool:x', 'error')), + status('error') + ); + expect(s['sub:a/sub:b']).toBe('error'); + expect(s['sub:a']).toBe('idle'); }); - it('marks a failed span error', () => { - const statuses = nodeRunStatuses(graph, [chat({ endTime: 5, status: { code: 'error' } })], status('error'), nodeIdMap); - expect(statuses['agent']).toBe('error'); - expect(statuses[toolId]).toBe('idle'); + it('the highest rank wins when several child frames raise one group', () => { + // done (1) vs error (2) vs running (4) on children of the same group. + const group = nodeRunStatuses( + ['sub:g', 'sub:g/tool:a', 'sub:g/tool:b'], + states( + frame('sub:g/tool:a', 'done'), + frame('sub:g/tool:b', 'error', '2026-01-01T00:00:01.000Z') + ), + status('error') + ); + expect(group['sub:g']).toBe('error'); + const running = nodeRunStatuses( + ['sub:g', 'sub:g/tool:a', 'sub:g/tool:b'], + states(frame('sub:g/tool:a', 'done'), frame('sub:g/tool:b', 'running', '2026-01-01T00:00:01.000Z')), + status('running') + ); + expect(running['sub:g']).toBe('running'); }); - it('marks the tool awaiting approval paused', () => { - const paused = status('paused', { pendingApproval: { toolName: 'http', args: {} } as AgentRunStatusPayload['pendingApproval'] }); - const statuses = nodeRunStatuses(graph, [chat({ endTime: 5 })], paused, nodeIdMap); - expect(statuses[toolId]).toBe('paused'); + it('ignores frames for node ids the graph does not have', () => { + const s = nodeRunStatuses(['agent'], states(frame('ghost:node', 'error'), frame('agent', 'done')), status('done')); + expect(s.agent).toBe('done'); + expect(Object.keys(s)).toEqual(['agent']); }); }); diff --git a/apps/agent-forge/src/canvas/nodeStatus.ts b/apps/agent-forge/src/canvas/nodeStatus.ts index 083903b1c..1fd1e4c31 100644 --- a/apps/agent-forge/src/canvas/nodeStatus.ts +++ b/apps/agent-forge/src/canvas/nodeStatus.ts @@ -1,48 +1,68 @@ -import type { Graph } from '../graph/v2/types'; -import { toolNodeName } from '../graph/v2/nodeIds'; -import type { ToolNodeData } from '../graph/v2/types'; -import type { AgentRunStatusPayload, SpanEvent } from '../../shared/wireTypes'; -import { nodeIdForSpan } from '../components/debug/nodeLookup'; +import type { AgentRunStatusPayload, NodeRunState } from '../../shared/wireTypes'; +import type { NodeStates } from '../state/nodeStateReducer'; /** A node card's status, in the builder's StatusPill vocabulary. */ export type NodeRunStatus = 'idle' | 'running' | 'paused' | 'done' | 'error'; -function spanStatus(span: SpanEvent, runStatus: AgentRunStatusPayload | undefined): NodeRunStatus { - if (span.endTime === undefined) { - // An open span is live only while its run is: a stopped or finished run's never-ended span is not running. - const run = runStatus?.status; - return run === 'running' || run === 'paused' ? run : 'idle'; +/** An open span outranks a failed one, which outranks a finished one. */ +const RANK: Record = { idle: 0, done: 1, error: 2, paused: 3, running: 4 }; + +/** + * The card status one `node` frame's state maps to. `running` is live + * only while its run is: a stopped or finished run's never-ended span is + * a stale open frame and reads idle; `waiting` (the approval-wait + * marker) is the paused ring; `done`/`error` are terminal. + */ +function frameStatus(state: NodeRunState, runStatus: AgentRunStatusPayload | undefined): NodeRunStatus { + switch (state) { + case 'running': { + const run = runStatus?.status; + return run === 'running' ? 'running' : run === 'paused' ? 'paused' : 'idle'; + } + case 'waiting': { + // Like `running`, a wait is live only while its run is: a stopped + // or finished run's stale marker must not keep a pulsing ring. + const run = runStatus?.status; + return run === 'running' || run === 'paused' ? 'paused' : 'idle'; + } + case 'done': + return 'done'; + case 'error': + return 'error'; + default: + return 'idle'; } - return span.status?.code === 'error' ? 'error' : 'done'; } -/** An open span outranks a failed one, which outranks a finished one. */ -const RANK: Record = { idle: 0, done: 1, error: 2, paused: 3, running: 4 }; +/** The owner path of a §3.3 node id: `sub:a/sub:b/tool:x` → `sub:a/sub:b`; `''` for a root id. */ +function ownerId(nodeId: string): string { + const slash = nodeId.lastIndexOf('/'); + return slash === -1 ? '' : nodeId.slice(0, slash); +} /** - * Each node's status in the current run, from the live span feed: the node - * of an open span is running while the run is running (paused while it is - * paused, idle once it stopped or ended), a node whose spans - * all ended is done (error if one failed), the tool a run is paused on for - * approval is paused, and every other node is idle. `nodeIdMap` is the - * graphToSpec id rekey, so a span's `lousho.node.id` lands on the current - * node even after an in-session rename. + * Each canvas node's status in the current run, from the server's `node` + * frames (state/nodeStateReducer.ts): every known node id starts idle, + * each frame raises its node (and, for a sub-path id like + * `sub:/tool:`, its group node `sub:`) to the mapped + * status, the highest rank winning when several frames touch one node. + * Frames for node ids the graph does not have are ignored. */ export function nodeRunStatuses( - graph: Graph, - spans: SpanEvent[], - runStatus: AgentRunStatusPayload | undefined, - nodeIdMap: Record + nodeIds: readonly string[], + nodeStates: NodeStates, + runStatus: AgentRunStatusPayload | undefined ): Record { - const statuses: Record = Object.fromEntries(graph.nodes.map((n) => [n.id, 'idle'])); - const raise = (nodeId: string | undefined, status: NodeRunStatus) => { - if (nodeId && nodeId in statuses && RANK[status] > RANK[statuses[nodeId]]) statuses[nodeId] = status; + const known = new Set(nodeIds); + const statuses: Record = Object.fromEntries(nodeIds.map((id) => [id, 'idle'])); + const raise = (nodeId: string, status: NodeRunStatus) => { + if (known.has(nodeId) && RANK[status] > RANK[statuses[nodeId]]) statuses[nodeId] = status; }; - for (const span of spans) raise(nodeIdForSpan(graph, span, nodeIdMap), spanStatus(span, runStatus)); - const pending = runStatus?.status === 'paused' ? runStatus.pendingApproval : undefined; - if (pending) { - const tool = graph.nodes.find((n) => n.kind === 'tool' && toolNodeName((n.data as ToolNodeData).item) === pending.toolName); - raise(tool?.id, 'paused'); + for (const frame of Object.values(nodeStates)) { + const status = frameStatus(frame.state, runStatus); + raise(frame.nodeId, status); + const owner = ownerId(frame.nodeId); + if (owner !== '') raise(owner, status); } return statuses; } diff --git a/apps/agent-forge/src/components/CanvasArea.tsx b/apps/agent-forge/src/components/CanvasArea.tsx index eb3d52960..8ed3e987c 100644 --- a/apps/agent-forge/src/components/CanvasArea.tsx +++ b/apps/agent-forge/src/components/CanvasArea.tsx @@ -725,20 +725,22 @@ function useCanvasElements() { agentId, graph, edges: graphEdges, - nodeIdMap, setGraph, selectedNodeId, setSelectedNodeId, highlightedNodeId, debugState, - spans, + nodeStates, runStatus, } = useAppState(); const breakpoints = debugState?.breakpoints ?? []; const breakpointsKey = breakpoints.join(','); const [measured, setMeasured] = useState({}); - const statuses = useMemo(() => nodeRunStatuses(graph, spans, runStatus, nodeIdMap), [graph, spans, runStatus, nodeIdMap]); + const statuses = useMemo( + () => nodeRunStatuses(graph.nodes.map((n) => n.id), nodeStates, runStatus), + [graph, nodeStates, runStatus] + ); const nodes = useMemo( () => toRfNodes(graph, { selectedNodeId, highlightedNodeId, breakpoints, statuses }, measured), [graph, selectedNodeId, highlightedNodeId, breakpointsKey, statuses, measured] diff --git a/apps/agent-forge/src/screens/agents/agents.css b/apps/agent-forge/src/screens/agents/agents.css index 8547b8d71..cfdf3c85f 100644 --- a/apps/agent-forge/src/screens/agents/agents.css +++ b/apps/agent-forge/src/screens/agents/agents.css @@ -762,6 +762,35 @@ button.breakpoint-toggle { opacity: 0.3; } } +/* + * The `node`-frame states beyond running (canvas/nodeStatus.ts): paused is + * the approval wait - the same pulsing ring in the warning border tone; + * error is terminal, so its danger ring is drawn once, without the pulse; + * done carries no ring at all - the status pill alone marks it. + */ +.canvas-wrap .rf-node.paused { + border-color: var(--warning); +} +.canvas-wrap .rf-node.paused::after { + content: ''; + position: absolute; + inset: -1px; + border: 2px solid var(--warning-border); + border-radius: inherit; + pointer-events: none; + animation: rf-node-running 1.2s var(--ease) infinite alternate; +} +.canvas-wrap .rf-node.error { + border-color: var(--danger); +} +.canvas-wrap .rf-node.error::after { + content: ''; + position: absolute; + inset: -1px; + border: 2px solid var(--danger-border); + border-radius: inherit; + pointer-events: none; +} .rf-node-head { display: flex; align-items: center; @@ -975,6 +1004,7 @@ button.breakpoint-toggle { @media (prefers-reduced-motion: reduce) { .canvas-wrap .rf-node.running::after, + .canvas-wrap .rf-node.paused::after, .canvas-wrap .react-flow__edge.animated path { animation: none; } diff --git a/apps/agent-forge/src/state/AppState.tsx b/apps/agent-forge/src/state/AppState.tsx index 831961e62..f9d0172f0 100644 --- a/apps/agent-forge/src/state/AppState.tsx +++ b/apps/agent-forge/src/state/AppState.tsx @@ -18,6 +18,7 @@ import type { VersionListItem, } from '../../shared/wireTypes'; import type { ChatState } from './chatReducer'; +import type { NodeStates } from './nodeStateReducer'; import { useAgentDocument } from './useAgentDocument'; import { agentRouteId } from '../screens/agents/agentRoute'; import { useAgentStatuses, useAgentStream } from './useAgentStream'; @@ -97,6 +98,13 @@ interface AppState { * the WS subscription's initial push arrives). */ runStatus: AgentRunStatusPayload | undefined; + /** + * The latest `node` frame per canvas node id for the current run - + * the status overlay's feed (canvas/nodeStatus.ts). Reset on agent + * switch, on each `runAgent()` call and on a new run's first + * `running` status. + */ + nodeStates: NodeStates; /** POSTs the current graph's compiled spec + `input` to `/agents/:id/run`. */ runAgent: (input: string) => Promise; /** POSTs `/agents/:id/stop` - see runtimeClient.ts/runRegistry.ts for what this can and can't interrupt. */ diff --git a/apps/agent-forge/src/state/__tests__/nodeStateReducer.test.ts b/apps/agent-forge/src/state/__tests__/nodeStateReducer.test.ts new file mode 100644 index 000000000..1bdbbe692 --- /dev/null +++ b/apps/agent-forge/src/state/__tests__/nodeStateReducer.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from 'vitest'; +import { applyNodeState, isNewRun } from '../nodeStateReducer'; +import type { AgentRunStatusPayload, NodeStatePayload } from '../../../shared/wireTypes'; + +const frame = (over: Partial = {}): NodeStatePayload => ({ + nodeId: 'agent', + state: 'running', + spanId: 'span-1', + at: '2026-01-01T00:00:00.000Z', + ...over, +}); + +const status = (s: AgentRunStatusPayload['status']): AgentRunStatusPayload => + ({ agentId: 'bot', status: s, updatedAt: '' }) as AgentRunStatusPayload; + +describe('applyNodeState', () => { + it('stores the first frame for a node', () => { + const f = frame(); + expect(applyNodeState({}, f)).toEqual({ agent: f }); + }); + + it('keeps frames for different nodes side by side', () => { + const a = frame({ nodeId: 'agent' }); + const b = frame({ nodeId: 'tool:http', spanId: 'span-2' }); + const states = applyNodeState(applyNodeState({}, a), b); + expect(states.agent).toBe(a); + expect(states['tool:http']).toBe(b); + }); + + it('a later `at` frame for the same node wins', () => { + const start = frame({ state: 'running', at: '2026-01-01T00:00:01.000Z' }); + const end = frame({ state: 'done', at: '2026-01-01T00:00:02.000Z' }); + const states = applyNodeState(applyNodeState({}, start), end); + expect(states.agent.state).toBe('done'); + }); + + it('an older `at` frame for the same node is ignored and returns the same object', () => { + const end = frame({ state: 'done', at: '2026-01-01T00:00:02.000Z' }); + const late = frame({ nodeId: 'tool:http', state: 'running', spanId: 'span-9', at: '2026-01-01T00:00:01.000Z' }); + const states = applyNodeState(applyNodeState({}, end), late); + // `late` is a different node - it lands; an older frame for `agent` does not. + expect(states['tool:http']).toBe(late); + const stale = frame({ state: 'running', spanId: 'span-8', at: '2026-01-01T00:00:00.500Z' }); + expect(applyNodeState(states, stale)).toBe(states); + expect(states.agent.state).toBe('done'); + }); + + it('a frame for the same span id overrides the earlier one even on an older `at`', () => { + const start = frame({ state: 'running', at: '2026-01-01T00:00:02.000Z' }); + const end = frame({ state: 'done', at: '2026-01-01T00:00:01.000Z' }); + const states = applyNodeState(applyNodeState({}, start), end); + expect(states.agent).toBe(end); + }); +}); + +describe('isNewRun', () => { + it.each<[AgentRunStatusPayload | undefined, AgentRunStatusPayload, boolean]>([ + [undefined, status('running'), true], + [status('idle'), status('running'), true], + [status('done'), status('running'), true], + [status('running'), status('running'), false], + [status('paused'), status('running'), false], + [status('done'), status('paused'), false], + ])('prev %s -> next %s = %s', (prev, next, expected) => { + expect(isNewRun(prev, next)).toBe(expected); + }); +}); diff --git a/apps/agent-forge/src/state/nodeStateReducer.ts b/apps/agent-forge/src/state/nodeStateReducer.ts new file mode 100644 index 000000000..8f6bd3011 --- /dev/null +++ b/apps/agent-forge/src/state/nodeStateReducer.ts @@ -0,0 +1,35 @@ +import type { AgentRunStatusPayload, NodeStatePayload } from '../../shared/wireTypes'; + +/** + * The canvas node-status overlay's state: the latest `node` frame per + * builder node id (§3.3 ids - `agent`, `tool:`, `sub:/...`), + * keyed by node id. Frames arrive over `WS /agents/:id/stream` as + * `{type:'node'}` (server/nodeStates.ts) and are folded in by + * {@link applyNodeState}; `nodeRunStatuses` (canvas/nodeStatus.ts) maps + * them onto the card's `NodeRunStatus` vocabulary. + */ +export type NodeStates = Record; + +/** + * Folds one `node` frame into the per-node latest-frame map. The latest + * frame per node wins by `at`; a frame for the same span id always + * overrides the earlier one (a span's `running` start frame and its + * `done`/`error`/`waiting` end frame share the span id, and the later + * phase must win even on an `at` tie). Returns the same object when the + * frame is stale so React can bail out of the re-render. + */ +export function applyNodeState(states: NodeStates, frame: NodeStatePayload): NodeStates { + const prev = states[frame.nodeId]; + if (prev !== undefined && prev.spanId !== frame.spanId && frame.at < prev.at) return states; + return { ...states, [frame.nodeId]: frame }; +} + +/** + * True when `next` starts a new run relative to `prev`: the previous + * status was not live (`running`/`paused` - `undefined` counts as not + * live) and the next one is `running`. The stream uses this to drop the + * previous run's node marks at a run's first `running` status. + */ +export function isNewRun(prev: AgentRunStatusPayload | undefined, next: AgentRunStatusPayload): boolean { + return next.status === 'running' && prev?.status !== 'running' && prev?.status !== 'paused'; +} diff --git a/apps/agent-forge/src/state/useAgentControls.ts b/apps/agent-forge/src/state/useAgentControls.ts index ed9037c89..9ad5059cd 100644 --- a/apps/agent-forge/src/state/useAgentControls.ts +++ b/apps/agent-forge/src/state/useAgentControls.ts @@ -12,6 +12,7 @@ import type { SpanEvent, } from '../../shared/wireTypes'; import type { ChatState } from './chatReducer'; +import type { NodeStates } from './nodeStateReducer'; interface RunControlDeps { agentId: string; @@ -19,11 +20,12 @@ interface RunControlDeps { runStatus: AgentRunStatusPayload | undefined; setLogs: Dispatch>; setSpans: Dispatch>; + setNodeStates: Dispatch>; setDebugState: Dispatch>; } /** Run/stop/approve and the step-debugger actions for `agentId`. */ -export function useRunControls({ agentId, spec, runStatus, setLogs, setSpans, setDebugState }: RunControlDeps) { +export function useRunControls({ agentId, spec, runStatus, setLogs, setSpans, setNodeStates, setDebugState }: RunControlDeps) { const [debugMode, setDebugMode] = useState(false); const runAgent = useCallback( @@ -34,6 +36,7 @@ export function useRunControls({ agentId, spec, runStatus, setLogs, setSpans, se // preferable to conflating it with the aborted run's). setLogs([]); setSpans([]); + setNodeStates({}); await runtimeClient.run(agentId, input, spec); }, [agentId, spec] diff --git a/apps/agent-forge/src/state/useAgentStream.ts b/apps/agent-forge/src/state/useAgentStream.ts index 30a953675..282d87504 100644 --- a/apps/agent-forge/src/state/useAgentStream.ts +++ b/apps/agent-forge/src/state/useAgentStream.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { runtimeClient } from '../runtime/runtimeClient'; import type { AgentRunStatusPayload, @@ -11,6 +11,7 @@ import type { } from '../../shared/wireTypes'; import type { AgentListItem } from '../../shared/wireTypes'; import { appendLog } from './logReducer'; +import { applyNodeState, isNewRun, type NodeStates } from './nodeStateReducer'; import { upsertSpan } from './spanReducer'; import { applyChatState, emptyChatState } from './chatReducer'; @@ -65,6 +66,13 @@ export function useAgentStatuses(agents: AgentListItem[]): Record(undefined); + // The latest `node` frame per canvas node id - the status overlay's + // feed. Reset on agent switch and on each new run's first `running` + // status (see the `status` handler below), like `logs`/`spans`. + const [nodeStates, setNodeStates] = useState({}); + // The last status the stream reported, so the `status` handler can tell + // a fresh run's first `running` from a repeat of the live one. + const runStatusRef = useRef(undefined); const [logs, setLogs] = useState([]); const [spans, setSpans] = useState([]); const [debugState, setDebugState] = useState(undefined); @@ -82,6 +90,8 @@ export function useAgentStream(agentId: string) { useEffect(() => { setRunStatus(undefined); + runStatusRef.current = undefined; + setNodeStates({}); setLogs([]); setSpans([]); setDebugState(undefined); @@ -95,7 +105,14 @@ export function useAgentStream(agentId: string) { useEffect(() => { let retry: ReturnType | undefined; const handlers: StreamHandlers = { - status: setRunStatus, + status: (payload) => { + // A new run's first `running` status drops the previous run's + // node marks (done/error/running leftovers) before its frames land. + if (isNewRun(runStatusRef.current, payload)) setNodeStates({}); + runStatusRef.current = payload; + setRunStatus(payload); + }, + node: (payload) => setNodeStates((prev) => applyNodeState(prev, payload)), log: (entry) => setLogs((prev) => appendLog(prev, entry)), span: (span) => setSpans((prev) => upsertSpan(prev, span)), debug: setDebugState, @@ -122,7 +139,10 @@ export function useAgentStream(agentId: string) { // stale breakpoint list) on agent switch. runtimeClient .status(agentId) - .then(setRunStatus) + .then((status) => { + runStatusRef.current = status; + setRunStatus(status); + }) .catch(() => { // The runtime control server may not be running (e.g. the app was // opened without `lousho studio`) - status just stays unknown, and @@ -139,6 +159,8 @@ export function useAgentStream(agentId: string) { return { connected, runStatus, + nodeStates, + setNodeStates, logs, setLogs, spans, diff --git a/src/spec/index.ts b/src/spec/index.ts index e9f5f6b8d..3fa5a0b26 100644 --- a/src/spec/index.ts +++ b/src/spec/index.ts @@ -2,7 +2,7 @@ export * from './schema'; export * from './loadSpec'; export * from './specToAgent'; export * from './policy'; -export { specV2ToAgent, compileSpec } from './v2/specToAgent'; +export { specV2ToAgent, compileSpec, compileSpecWithNodeIds } from './v2/specToAgent'; export type { CompiledSpec, SpecAgentV2 } from './v2/specToAgent'; export type { SpecHost } from './v2/host'; diff --git a/src/spec/v2/index.test.ts b/src/spec/v2/index.test.ts index 612bdecf2..72f5d7ff3 100644 --- a/src/spec/v2/index.test.ts +++ b/src/spec/v2/index.test.ts @@ -55,6 +55,7 @@ describe('lousho/spec (S23)', () => { 'skillRefSchema', 'specNodeIds', 'specToCode', + 'subAgentNodeIndexes', 'toolRefSchema', 'triggerSpecSchema', 'upgradeSpec', diff --git a/src/spec/v2/index.ts b/src/spec/v2/index.ts index 3809455b1..069751ad5 100644 --- a/src/spec/v2/index.ts +++ b/src/spec/v2/index.ts @@ -56,7 +56,7 @@ export { specToCode, type SpecToCodeOptions } from '../generators/typescript'; // S20a–S20c: stable node ids and the span exporter that stamps them. export { specNodeIds, type SpecNodeIds } from './nodeIds'; -export { isNodeIdExporter, nodeIdExporter, nodeIdIndexOf, type NodeIdIndex } from './nodeIdExporter'; +export { isNodeIdExporter, nodeIdExporter, nodeIdIndexOf, subAgentNodeIndexes, type NodeIdIndex } from './nodeIdExporter'; // S21b/S21c: the `flow` spec key's schema and validation. export { flowProblems, flowSpecSchema, type FlowProblem } from './flow'; diff --git a/src/spec/v2/nodeIdExporter.ts b/src/spec/v2/nodeIdExporter.ts index c2cb34631..9177dd78e 100644 --- a/src/spec/v2/nodeIdExporter.ts +++ b/src/spec/v2/nodeIdExporter.ts @@ -66,6 +66,40 @@ export function nodeIdIndexOf( return index; } +/** The shape specV2ToAgent() stamps on a spec-built agent — structural, so this module never imports specToAgent (which imports this one). */ +interface SpecBuiltAgent { + spec: AgentSpecV2; + toolNamesByRef: readonly (readonly string[])[]; +} + +/** Whether `value` is an agent specV2ToAgent() built (carries `spec`/`toolNamesByRef`); remote sub-agents are not. */ +function isSpecBuiltAgent(value: unknown): value is SpecBuiltAgent { + return typeof value === 'object' && value !== null && 'spec' in value && 'toolNamesByRef' in value; +} + +/** + * `config.subagents` entries that are spec-built, as `name → their own + * {@link NodeIdIndex}` for the enclosing agent's index — what + * `nodeIdIndexOf(spec, toolNamesByRef, …)` takes as `subIndexes`. Agents + * compiled any other way (remote, or built by hand) contribute nothing: + * their spans still stamp `sub:` but resolve no member ids. + * + * The parameter is structural rather than `Subagents` so this browser-safe + * entry's public signature does not drag the whole agent runtime type graph + * into its API report: any `createAgent()` `subagents` record or + * `SubagentCatalog` (`{ list, resolve }`) is assignable to it. + */ +export function subAgentNodeIndexes( + subagents: Readonly> | { list: unknown } | undefined +): Record { + if (subagents === undefined || 'list' in subagents) return {}; + const indexes: Record = {}; + for (const [name, sub] of Object.entries(subagents)) { + if (isSpecBuiltAgent(sub)) indexes[name] = nodeIdIndexOf(sub.spec, sub.toolNamesByRef); + } + return indexes; +} + /** `transfer_to_` exactly as handoffAgents.ts builds it (illegal tool-name characters → '_', ≤64). */ function defaultHandoffToolName(name: string): string { return `transfer_to_${name.replace(/[^A-Za-z0-9_-]/g, '_')}`.slice(0, 64); diff --git a/src/spec/v2/specToAgent.ts b/src/spec/v2/specToAgent.ts index 4aba91ef8..4624cd044 100644 --- a/src/spec/v2/specToAgent.ts +++ b/src/spec/v2/specToAgent.ts @@ -12,13 +12,12 @@ import * as fs from 'node:fs'; import { createAgent, type CreateAgentConfig, type PromptRefString, type SimpleAgent } from '../../createAgent'; import { ConfigurationError } from '../../execution/errors'; import type { LLMProvider } from '../../providers/llm'; -import type { Subagents } from '../../subagents/types'; import { resolveSpecProvider } from '../provider'; import type { McpServerSpec } from '../schema'; import { familyInstructions } from './familyInstructions'; import { specFilePath } from './files'; import { outputFromSpec } from './jsonSchemaAdapter'; -import { isNodeIdExporter, nodeIdExporter, nodeIdIndexOf, type NodeIdIndex } from './nodeIdExporter'; +import { isNodeIdExporter, nodeIdExporter, nodeIdIndexOf, subAgentNodeIndexes, type NodeIdIndex } from './nodeIdExporter'; import { parseAgentSpecV2 } from './parse'; import { resolveMcpServers } from './resolve/mcpServers'; import { resolveToolRef, type ResolvedTools } from './resolve/tools'; @@ -173,6 +172,50 @@ export async function compileSpec(spec: AgentSpecV2, host: SpecHost): Promise readonly (readonly string[])[]; + } +): Promise { + let compileHost = host; + let nodeIds: NodeIdIndex | undefined; + if (host.exporter !== undefined && !isNodeIdExporter(host.exporter)) { + nodeIds = { toolNodeByName: {}, mcpServers: {}, handoffByToolName: {}, memorySlots: {}, guardrailByName: {}, subagents: {} }; + compileHost = { ...host, exporter: nodeIdExporter(host.exporter, nodeIds) }; + } + const compiled = await compileSpec(spec, compileHost); + if (nodeIds !== undefined) { + Object.assign( + nodeIds, + nodeIdIndexOf( + index?.spec ?? spec, + index?.toolNamesByRef !== undefined ? index.toolNamesByRef(compiled) : compiled.toolNamesByRef, + subAgentNodeIndexes(compiled.config.subagents) + ) + ); + } + return compiled; +} + /** The agent a v2 spec compiles to. */ export type SpecAgentV2 = SimpleAgent & { /** The validated spec the agent was built from. */ @@ -183,45 +226,15 @@ export type SpecAgentV2 = SimpleAgent & { readonly toolNamesByRef: readonly (readonly string[])[]; }; -/** Whether `value` is an agent this function built (carries `spec`/`toolNamesByRef`); remote sub-agents are not. */ -function isSpecAgent(value: unknown): value is SpecAgentV2 { - return typeof value === 'object' && value !== null && 'spec' in value && 'toolNamesByRef' in value; -} - -/** `config.subagents` entries that are spec-built, as `name → their own NodeIdIndex` for the root index. */ -function subIndexesOf(subagents: Subagents | undefined): Record { - if (subagents === undefined || 'list' in subagents) return {}; - const indexes: Record = {}; - for (const [name, sub] of Object.entries(subagents)) { - if (isSpecAgent(sub)) indexes[name] = nodeIdIndexOf(sub.spec, sub.toolNamesByRef); - } - return indexes; -} - /** * Validates (parseAgentSpecV2), compiles and builds the agent. The spec, * its resolved `mcpServers` and `toolNamesByRef` stay readable on the * result; `agent.close()` disconnects the MCP servers it connected. - * - * When `host.exporter` is set (and not already a node-id exporter), it is - * wrapped in `nodeIdExporter` so every span carries `lousho.node.id` - * (S20a): the index is created empty, the wrapper is compiled with (so - * inline sub-agents see an already-wrapped exporter and share the one - * scope map), then filled from the compiled spec before `createAgent()` - * returns — before any span can start. + * Spans carry `lousho.node.id` via {@link compileSpecWithNodeIds} (S20a). */ export async function specV2ToAgent(spec: AgentSpecV2, host: SpecHost): Promise { const parsed = parseAgentSpecV2(spec); - let compileHost = host; - let nodeIds: NodeIdIndex | undefined; - if (host.exporter !== undefined && !isNodeIdExporter(host.exporter)) { - nodeIds = { toolNodeByName: {}, mcpServers: {}, handoffByToolName: {}, memorySlots: {}, guardrailByName: {}, subagents: {} }; - compileHost = { ...host, exporter: nodeIdExporter(host.exporter, nodeIds) }; - } - const compiled = await compileSpec(parsed, compileHost); - if (nodeIds !== undefined) { - Object.assign(nodeIds, nodeIdIndexOf(parsed, compiled.toolNamesByRef, subIndexesOf(compiled.config.subagents))); - } + const compiled = await compileSpecWithNodeIds(parsed, host); const agent = createAgent(compiled.config); return Object.assign(agent, { spec: parsed,