Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions api/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -1467,6 +1467,12 @@ export function compilePolicy(policy: AgentSpecPolicy | undefined): CompiledPoli
// @public
export function compileSpec(spec: AgentSpecV2, host: SpecHost): Promise<CompiledSpec>;

// @public
export function compileSpecWithNodeIds(spec: AgentSpecV2, host: SpecHost, index?: {
spec?: AgentSpecV2;
toolNamesByRef?: (compiled: CompiledSpec) => readonly (readonly string[])[];
}): Promise<CompiledSpec>;

// @public (undocumented)
interface ConditionNode {
// (undocumented)
Expand Down
5 changes: 5 additions & 0 deletions api/spec.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -999,6 +999,11 @@ interface StepNode {
// @public
type StepPath = (string | number)[];

// @public
export function subAgentNodeIndexes(subagents: Readonly<Record<string, unknown>> | {
list: unknown;
} | undefined): Record<string, NodeIdIndex>;

// @public
interface SubagentOptions {
awaitBackgroundOnFinish?: boolean;
Expand Down
28 changes: 15 additions & 13 deletions apps/agent-forge/server/__tests__/nodeFrames.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down Expand Up @@ -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) => {
Expand All @@ -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);
});
});
14 changes: 14 additions & 0 deletions apps/agent-forge/server/__tests__/nodeStates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
45 changes: 42 additions & 3 deletions apps/agent-forge/server/buildAgentV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
*/
import * as path from 'node:path';
import {
compileSpec,
compileSpecWithNodeIds,
createAgent,
type AgentEvent,
type AgentHook,
Expand Down Expand Up @@ -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<string, ToolDescriptor>): unknown {
const base =
Expand Down Expand Up @@ -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).
Expand Down
6 changes: 6 additions & 0 deletions apps/agent-forge/server/nodeStates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
15 changes: 15 additions & 0 deletions apps/agent-forge/src/__tests__/polishCss.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
3 changes: 3 additions & 0 deletions apps/agent-forge/src/canvas/NodeCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(' ');
}
Expand Down
6 changes: 6 additions & 0 deletions apps/agent-forge/src/canvas/__tests__/AgentNode.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`AgentNode card > renders the generic card markup 1`] = `"<div class="rf-node" data-node-kind="abort" data-node-id="abort:a1"><div data-handlepos="left" data-id="1-null-null-target" class="react-flow__handle react-flow__handle-left nodrag nopan rf-port rf-port-in target connectable connectablestart connectableend connectionindicator"></div><div data-handlepos="right" data-id="1-null-null-source" class="react-flow__handle react-flow__handle-right nodrag nopan rf-port rf-port-out source connectable connectablestart connectableend connectionindicator"></div><div class="rf-node-head"><span class="node-kind kind-neutral" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-box" aria-hidden="true"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"></path><path d="m3.3 7 8.7 5 8.7-5"></path><path d="M12 22V12"></path></svg></span><span class="rf-node-title" title="a1">a1</span><span class="status-pill pill-warning"><span class="dot" aria-hidden="true"></span><span class="status-pill-label">awaiting approval</span></span></div></div>"`;
exports[`AgentNode card > renders the generic card markup 1`] = `"<div class="rf-node paused" data-node-kind="abort" data-node-id="abort:a1"><div data-handlepos="left" data-id="1-null-null-target" class="react-flow__handle react-flow__handle-left nodrag nopan rf-port rf-port-in target connectable connectablestart connectableend connectionindicator"></div><div data-handlepos="right" data-id="1-null-null-source" class="react-flow__handle react-flow__handle-right nodrag nopan rf-port rf-port-out source connectable connectablestart connectableend connectionindicator"></div><div class="rf-node-head"><span class="node-kind kind-neutral" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-box" aria-hidden="true"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"></path><path d="m3.3 7 8.7 5 8.7-5"></path><path d="M12 22V12"></path></svg></span><span class="rf-node-title" title="a1">a1</span><span class="status-pill pill-warning"><span class="dot" aria-hidden="true"></span><span class="status-pill-label">awaiting approval</span></span></div></div>"`;
Loading
Loading