From cafb88885bf19df767161b801a456f77b41b8bbe Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Wed, 28 Oct 2026 23:55:46 +0300 Subject: [PATCH 1/3] feat(studio): builder canvas and inspector pass - Node cards show the name, an LLM node's provider/model chip and tool count, and the node's status in the current run (StatusPill, from the live span feed). - An empty canvas lists AGENT_TEMPLATES; choosing one fills the graph through setGraph (marks dirty, keeps the agent name, no switchAgent). - Inspector sections (Node, Debug, Hooks) collapse; state remembered under forge.inspector.. The run drawer resizes from its top edge and remembers its height (forge.drawer.height, clamped 120-560px). - Next to Run: the last run's status pill and an "Open trace" link to #/traces/ (More menu in the narrow layout). - Builder runs report to the observe daemon as agent through a reporter factory shared with the playground (server/observeReporting.ts); the run status carries the trace id. Co-Authored-By: Claude Opus 5.5 --- apps/agent-forge/e2e/studioUx.spec.ts | 7 + .../server/__tests__/builderReporting.test.ts | 120 ++++++++++++++++ .../server/__tests__/observeReporting.test.ts | 52 +++++++ apps/agent-forge/server/index.ts | 2 + apps/agent-forge/server/observeReporting.ts | 77 ++++++++++ apps/agent-forge/server/playground.ts | 63 +-------- apps/agent-forge/server/runRegistry.ts | 49 ++++++- apps/agent-forge/server/traceStore.ts | 9 -- apps/agent-forge/shared/wireTypes.ts | 2 + apps/agent-forge/src/canvas/AgentNode.tsx | 46 ++++-- apps/agent-forge/src/canvas/EmptyCanvas.tsx | 41 ++++++ .../src/canvas/__tests__/AgentNode.test.tsx | 53 +++++++ .../src/canvas/__tests__/EmptyCanvas.test.tsx | 53 +++++++ .../__snapshots__/AgentNode.test.tsx.snap | 3 + .../src/canvas/__tests__/nodeStatus.test.ts | 57 ++++++++ apps/agent-forge/src/canvas/nodeStatus.ts | 38 +++++ .../src/components/BottomDrawer.tsx | 8 +- .../agent-forge/src/components/CanvasArea.tsx | 33 +++-- apps/agent-forge/src/components/Inspector.tsx | 43 +++--- .../src/components/InspectorSection.tsx | 37 +++++ apps/agent-forge/src/components/RunStatus.tsx | 32 +++++ apps/agent-forge/src/components/Topbar.tsx | 17 ++- .../__tests__/InspectorSection.test.tsx | 47 +++++++ .../components/__tests__/RunStatus.test.tsx | 56 ++++++++ .../__tests__/drawerHeight.test.tsx | 76 ++++++++++ .../src/components/debug/TracePanel.tsx | 14 +- .../src/components/debug/nodeLookup.ts | 13 ++ apps/agent-forge/src/components/drawer.css | 16 +++ .../src/components/drawerHeight.tsx | 83 +++++++++++ apps/agent-forge/src/components/layout.css | 20 ++- .../agent-forge/src/components/storedValue.ts | 21 +++ .../agent-forge/src/screens/agents/agents.css | 132 ++++++++++++++++++ .../src/ui/__tests__/memoryStorage.ts | 40 ++++++ docs/agent-forge.md | 24 +++- 34 files changed, 1248 insertions(+), 136 deletions(-) create mode 100644 apps/agent-forge/server/__tests__/builderReporting.test.ts create mode 100644 apps/agent-forge/server/__tests__/observeReporting.test.ts create mode 100644 apps/agent-forge/server/observeReporting.ts create mode 100644 apps/agent-forge/src/canvas/EmptyCanvas.tsx create mode 100644 apps/agent-forge/src/canvas/__tests__/AgentNode.test.tsx create mode 100644 apps/agent-forge/src/canvas/__tests__/EmptyCanvas.test.tsx create mode 100644 apps/agent-forge/src/canvas/__tests__/__snapshots__/AgentNode.test.tsx.snap create mode 100644 apps/agent-forge/src/canvas/__tests__/nodeStatus.test.ts create mode 100644 apps/agent-forge/src/canvas/nodeStatus.ts create mode 100644 apps/agent-forge/src/components/InspectorSection.tsx create mode 100644 apps/agent-forge/src/components/RunStatus.tsx create mode 100644 apps/agent-forge/src/components/__tests__/InspectorSection.test.tsx create mode 100644 apps/agent-forge/src/components/__tests__/RunStatus.test.tsx create mode 100644 apps/agent-forge/src/components/__tests__/drawerHeight.test.tsx create mode 100644 apps/agent-forge/src/components/drawerHeight.tsx create mode 100644 apps/agent-forge/src/components/storedValue.ts create mode 100644 apps/agent-forge/src/ui/__tests__/memoryStorage.ts diff --git a/apps/agent-forge/e2e/studioUx.spec.ts b/apps/agent-forge/e2e/studioUx.spec.ts index d6b874c1d..ae98f74a1 100644 --- a/apps/agent-forge/e2e/studioUx.spec.ts +++ b/apps/agent-forge/e2e/studioUx.spec.ts @@ -32,6 +32,8 @@ test('a new agent is named what you typed; a finished run says done and labels i await page.getByRole('button', { name: 'Run', exact: true }).click(); await expect(page.locator('.topbar [role="status"]')).toContainText('done', { timeout: 15_000 }); await expect(page.locator('.topbar [role="status"]')).not.toContainText('stopped'); + // The finished run links to its trace (builder runs report to the observe daemon). + await expect(page.getByRole('link', { name: 'Open trace' })).toHaveAttribute('href', /^#\/traces\/[^/]+$/); const runBubble = page.locator('.chat-msg.run-input'); await expect(runBubble).toBeVisible(); @@ -88,6 +90,11 @@ test('the Output node is drawn once and the minimap shows the graph', async ({ p await page.goto(`/?token=${TOKEN}`); await createAgent(page, id); + // Node cards: the LLM node shows its provider/model chip and an idle status before any run. + const llm = page.locator('[data-node-type="llm"]'); + await expect(llm.locator('.node-model')).toHaveText('mock/mock-1'); + await expect(llm.locator('.status-pill')).toHaveText('idle'); + // React Flow's built-in `output` type used to add its own border/padding box around ours. await expect(page.locator('.react-flow__node-output')).toHaveCount(0); const output = page.locator('.react-flow__node:has([data-node-type="output"])'); diff --git a/apps/agent-forge/server/__tests__/builderReporting.test.ts b/apps/agent-forge/server/__tests__/builderReporting.test.ts new file mode 100644 index 000000000..e52351017 --- /dev/null +++ b/apps/agent-forge/server/__tests__/builderReporting.test.ts @@ -0,0 +1,120 @@ +/** + * Builder runs (`RunManager.run()`) report to the observe daemon the Studio + * reads, as agent ``, through the same reporter factory the + * playground uses (observeReporting.ts) - so the top bar's "Open trace" link + * (`#/traces/`) resolves. The run status carries that trace id. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import type { AgentSpec } from '@lousho/build-ai-agent'; +import { RunManager } from '../runRegistry'; +import { FileCheckpointStore } from '../checkpointStore'; +import { FileApprovalStore } from '../approvalStore'; + +type Ingested = { type: string; data: Record }; + +const SPEC: AgentSpec = { + name: 'builder-agent', + prompt: 'You are a helpful agent.', + provider: { type: 'mock', model: 'mock-1' }, +}; + +function waitForDone(runManager: RunManager, agentId: string): Promise<{ status: string; traceId?: string }> { + return new Promise((resolve) => { + const onStatus = (payload: { agentId: string; status: string; traceId?: string }) => { + if (payload.agentId !== agentId || payload.status !== 'done') return; + runManager.off('status', onStatus); + resolve(payload); + }; + runManager.on('status', onStatus); + }); +} + +describe('builder run reporting', () => { + let baseDir: string; + let ingested: Ingested[]; + const savedObserve = process.env.LOUSHO_OBSERVE; + + function makeManager(observe: ConstructorParameters[0]['observe']): RunManager { + return new RunManager({ + baseDir, + checkpointStore: new FileCheckpointStore(baseDir), + approvalStore: new FileApprovalStore(baseDir), + loadSpec: async () => undefined, + ...(observe ? { observe } : {}), + }); + } + + const fetchFn = () => + (async (_url: string, init?: RequestInit) => { + const body = JSON.parse(String(init?.body ?? '{}')) as { batch?: Ingested[] }; + ingested.push(...(body.batch ?? [])); + return new Response(JSON.stringify({ accepted: body.batch?.length ?? 0 }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }) as typeof fetch; + + beforeEach(() => { + delete process.env.LOUSHO_OBSERVE; + baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'builder-report-')); + ingested = []; + }); + + afterEach(() => { + if (savedObserve === undefined) delete process.env.LOUSHO_OBSERVE; + else process.env.LOUSHO_OBSERVE = savedObserve; + fs.rmSync(baseDir, { recursive: true, force: true }); + }); + + const rootEnd = () => + ingested.find((e) => e.type === 'observation' && e.data.parent_obs_id === undefined && e.data.end !== undefined); + + it("reports a builder run's spans as agent , and the status names the trace", async () => { + const runManager = makeManager({ url: 'http://127.0.0.1:7998', fetchFn: fetchFn(), registerExitHooks: false, flushIntervalMs: 10 }); + const done = waitForDone(runManager, 'support-desk'); + await runManager.run('support-desk', 'hello', SPEC); + const final = await done; + expect(typeof final.traceId).toBe('string'); + await vi.waitFor(() => expect(rootEnd()).toBeDefined()); + expect(ingested.find((e) => e.type === 'agent.heartbeat')?.data.name).toBe('support-desk'); + expect(rootEnd()!.data.trace_id).toBe(final.traceId); + expect(runManager.status('support-desk').traceId).toBe(final.traceId); + }); + + it('captures message content for a loopback daemon only', async () => { + const reportTo = async (url: string, agentId: string) => { + ingested = []; + const runManager = makeManager({ url, fetchFn: fetchFn(), registerExitHooks: false, flushIntervalMs: 10 }); + const done = waitForDone(runManager, agentId); + await runManager.run(agentId, 'hello', SPEC); + await done; + await vi.waitFor(() => expect(rootEnd()).toBeDefined()); + return JSON.stringify(ingested); + }; + expect(await reportTo('http://127.0.0.1:7998', 'local-agent')).toContain('gen_ai.input.messages'); + expect(await reportTo('http://observe.internal:7777', 'remote-agent')).not.toContain('gen_ai.input.messages'); + }); + + it('a daemon that is down never fails the run', async () => { + const down = (async () => { + throw new Error('ECONNREFUSED'); + }) as typeof fetch; + const runManager = makeManager({ url: 'http://127.0.0.1:7998', fetchFn: down, registerExitHooks: false, flushIntervalMs: 10 }); + const done = waitForDone(runManager, 'offline'); + await runManager.run('offline', 'hello', SPEC); + expect((await done).status).toBe('done'); + }); + + it('without the observe option the manager makes no reporter of its own', async () => { + const runManager = makeManager(undefined); + const done = waitForDone(runManager, 'quiet'); + await runManager.run('quiet', 'hello', SPEC); + const final = await done; + // The status still names the run's trace (the local trace file has it too). + expect(typeof final.traceId).toBe('string'); + expect(ingested).toEqual([]); + }); +}); diff --git a/apps/agent-forge/server/__tests__/observeReporting.test.ts b/apps/agent-forge/server/__tests__/observeReporting.test.ts new file mode 100644 index 000000000..3ff97a0df --- /dev/null +++ b/apps/agent-forge/server/__tests__/observeReporting.test.ts @@ -0,0 +1,52 @@ +/** + * The reporter factory shared by the playground and builder runs: an env + * reporter (`LOUSHO_OBSERVE`) wins so a run is not reported twice, and + * content is captured only for a loopback target. + */ +import { describe, it, expect, afterEach } from 'vitest'; +import { observeExporterFromEnv, type ObserveReporter } from '@lousho/build-ai-agent/traces'; +import { isLoopbackUrl, observeReporting } from '../observeReporting'; + +const noFetch = (async () => new Response('{}', { status: 200 })) as typeof fetch; +const overrides = (url: string) => ({ url, fetchFn: noFetch, registerExitHooks: false }); + +describe('observeReporting', () => { + const saved = process.env.LOUSHO_OBSERVE; + afterEach(() => { + if (saved === undefined) delete process.env.LOUSHO_OBSERVE; + else process.env.LOUSHO_OBSERVE = saved; + }); + + it('knows a loopback URL', () => { + expect(isLoopbackUrl('http://127.0.0.1:7777')).toBe(true); + expect(isLoopbackUrl('http://localhost:7777')).toBe(true); + expect(isLoopbackUrl('http://[::1]:7777')).toBe(true); + expect(isLoopbackUrl('http://observe.internal:7777')).toBe(false); + expect(isLoopbackUrl('not a url')).toBe(false); + }); + + it('captures content only for a loopback target', () => { + const local = observeReporting({ agentName: 'a', baseDir: process.cwd(), overrides: overrides('http://127.0.0.1:7998') }); + const remote = observeReporting({ agentName: 'a', baseDir: process.cwd(), overrides: overrides('http://observe.internal:7777') }); + expect(local.captureContent()).toBe(true); + expect(remote.captureContent()).toBe(false); + }); + + it('makes one reporter per agent name, on first use', async () => { + const reporting = observeReporting({ agentName: 'once', baseDir: process.cwd(), overrides: overrides('http://127.0.0.1:7998') }); + const first = reporting.exporter() as ObserveReporter; + expect(reporting.exporter()).toBe(first); + expect(first.agentId).toBeTruthy(); + await first.close(); + }); + + it('uses the LOUSHO_OBSERVE reporter when set, so nothing is reported twice', async () => { + // A closed loopback port: the env reporter drops-and-counts, nothing real is reached. + process.env.LOUSHO_OBSERVE = 'http://127.0.0.1:1'; + const reporting = observeReporting({ agentName: 'env-wins', baseDir: process.cwd() }); + const fromEnv = observeExporterFromEnv({ agentName: 'env-wins' }) as ObserveReporter; + expect(reporting.exporter()).toBe(fromEnv); + expect(reporting.captureContent()).toBe(true); + await fromEnv.close(); + }); +}); diff --git a/apps/agent-forge/server/index.ts b/apps/agent-forge/server/index.ts index ef2ce7613..d199617ee 100644 --- a/apps/agent-forge/server/index.ts +++ b/apps/agent-forge/server/index.ts @@ -95,6 +95,8 @@ export async function startStudioServer( saveSpec: (agentId, spec) => agentStore.save(agentId, spec), secretsStore, settingsStore, + // Builder runs report to the observe daemon, so "Open trace" resolves. + observe: true, }); const token = options.token ?? (process.env[STUDIO_TOKEN_ENV] || mintStudioToken()); diff --git a/apps/agent-forge/server/observeReporting.ts b/apps/agent-forge/server/observeReporting.ts new file mode 100644 index 000000000..facca740c --- /dev/null +++ b/apps/agent-forge/server/observeReporting.ts @@ -0,0 +1,77 @@ +/** + * The observe reporter a Studio run reports through - shared by the + * playground (agent `playground`, playground.ts) and builder runs (agent = + * the agent id, runRegistry.ts). + * + * Runs report to the daemon the Studio reads (`LOUSHO_OBSERVE_URL`, default + * `http://127.0.0.1:7777`, the same target as observeProxy.ts) through the + * SDK's own `observeExporter()`, so a daemon that is down only means the + * reporter drops-and-counts; a run never waits on it or fails because of it. + * When `LOUSHO_OBSERVE` is set for the studio process, its reporter is used + * instead, so the run is not reported twice. Content (`gen_ai.*` messages, + * tool arguments and results) is captured only for a loopback target. + */ +import { + observeExporter, + observeExporterFromEnv, + type ObserveExporterOptions, + type TraceExporter, +} from '@lousho/build-ai-agent/traces'; +import { observeTarget } from './observeProxy'; + +/** One agent name's reporter and whether its runs may capture content. */ +export interface ObserveReporting { + /** The reporter for the next run; made on first use (no heartbeat until a run). */ + exporter: () => TraceExporter; + /** True only when the reporter's target is a loopback daemon. */ + captureContent: () => boolean; +} + +export interface ObserveReportingOptions { + /** Every run reports under this agent name. */ + agentName: string; + /** Project dir the reporter's `.lousho/identity/` lives under. */ + baseDir: string; + /** + * Reporter overrides (tests: `url`, `fetchFn`, `registerExitHooks`, + * `flushIntervalMs`). Given, the `LOUSHO_OBSERVE` reporter is ignored. The + * agent name stays `agentName`. + */ + overrides?: Partial; +} + +/** True for a `127.0.0.1` / `localhost` / `[::1]` URL. */ +export function isLoopbackUrl(url: string): boolean { + try { + return ['127.0.0.1', 'localhost', '[::1]'].includes(new URL(url).hostname.toLowerCase()); + } catch { + return false; + } +} + +/** Content capture defaults on for a loopback collector, as `createAgent` does under `LOUSHO_OBSERVE`. */ +function envTargetIsLoopback(): boolean { + const value = process.env.LOUSHO_OBSERVE?.trim() ?? ''; + return /^(1|true|on|yes)$/i.test(value) || isLoopbackUrl(value); +} + +/** The observe reporting for runs under `options.agentName`. */ +export function observeReporting(options: ObserveReportingOptions): ObserveReporting { + let own: TraceExporter | undefined; + const { agentName, overrides } = options; + const url = () => overrides?.url ?? observeTarget(); + const fromEnv = () => (overrides ? undefined : observeExporterFromEnv({ agentName })); + return { + exporter: () => + fromEnv() ?? + (own ??= observeExporter({ + cwd: options.baseDir, + // Parity with the LOUSHO_OBSERVE reporter: a daemon bound beyond loopback demands the bearer. + ...(process.env.LOUSHO_OBSERVE_TOKEN ? { token: process.env.LOUSHO_OBSERVE_TOKEN } : {}), + ...overrides, + url: url(), + agentName, + })), + captureContent: () => (fromEnv() ? envTargetIsLoopback() : isLoopbackUrl(url())), + }; +} diff --git a/apps/agent-forge/server/playground.ts b/apps/agent-forge/server/playground.ts index bcaccac05..b8682ecf3 100644 --- a/apps/agent-forge/server/playground.ts +++ b/apps/agent-forge/server/playground.ts @@ -6,12 +6,9 @@ * secretsStore beats the env var; a real provider with no key fails with * `LOUSHO_PROVIDER_MISSING_API_KEY`) by compiling the request into a * throwaway `AgentSpec` named `playground`. Every run reports to the observe - * daemon the Studio reads (`LOUSHO_OBSERVE_URL`, default - * `http://127.0.0.1:7777`, the same target as observeProxy.ts) as agent - * `playground`, through the SDK's own `observeExporter()` - so a daemon that - * is down only means the reporter drops-and-counts; the stream never waits on - * it. When `LOUSHO_OBSERVE` is set for the studio process, its reporter is - * used instead, so the run is not reported twice. + * daemon the Studio reads as agent `playground` (observeReporting.ts: a + * daemon that is down only means the reporter drops-and-counts; the stream + * never waits on it, and `LOUSHO_OBSERVE`'s reporter wins when set). * * `promptRef` (`name`, `name@label`, `name@3`) is only recorded, as the root * span's `lousho.prompt.ref` attribute: the client already sends the resolved @@ -26,16 +23,9 @@ import { type AgentSpec, type Message, } from '@lousho/build-ai-agent'; -import { - fanOutExporters, - observeExporter, - observeExporterFromEnv, - type ObserveExporterOptions, - type Span, - type TraceExporter, -} from '@lousho/build-ai-agent/traces'; +import { fanOutExporters, type ObserveExporterOptions, type Span, type TraceExporter } from '@lousho/build-ai-agent/traces'; import { buildAgentFromSpec } from './buildAgent'; -import { observeTarget } from './observeProxy'; +import { observeReporting, type ObserveReporting } from './observeReporting'; import type { SecretsStore } from './secretsStore'; import type { PlaygroundEvent, PlaygroundMessage, PlaygroundRunRequest } from '../shared/wireTypes'; @@ -103,20 +93,6 @@ function providerOf(model: string): AgentSpec['provider'] { return slash === -1 ? { type: model.trim(), model: '' } : { type: model.slice(0, slash).trim(), model: model.slice(slash + 1).trim() }; } -const isLoopbackUrl = (url: string): boolean => { - try { - return ['127.0.0.1', 'localhost', '[::1]'].includes(new URL(url).hostname.toLowerCase()); - } catch { - return false; - } -}; - -/** Content capture defaults on for a loopback collector, as `createAgent` does under `LOUSHO_OBSERVE`. */ -function envTargetIsLoopback(): boolean { - const value = process.env.LOUSHO_OBSERVE?.trim() ?? ''; - return /^(1|true|on|yes)$/i.test(value) || isLoopbackUrl(value); -} - /** * Stamps `lousho.prompt.ref` on the run's root span and remembers its id (the * observe trace id). It mutates the span in place and runs first in the @@ -140,31 +116,6 @@ export interface PlaygroundOptions { observe?: Partial; } -interface PlaygroundReporting { - exporter: () => TraceExporter; - captureContent: () => boolean; -} - -/** The observe reporter, made on the first run (no heartbeat until the playground is used). */ -function playgroundReporting(options: PlaygroundOptions): PlaygroundReporting { - let own: TraceExporter | undefined; - const url = () => options.observe?.url ?? observeTarget(); - const fromEnv = () => (options.observe ? undefined : observeExporterFromEnv({ agentName: PLAYGROUND_AGENT_NAME })); - return { - exporter: () => - fromEnv() ?? - (own ??= observeExporter({ - cwd: options.baseDir, - // Parity with the LOUSHO_OBSERVE reporter: a daemon bound beyond loopback demands the bearer. - ...(process.env.LOUSHO_OBSERVE_TOKEN ? { token: process.env.LOUSHO_OBSERVE_TOKEN } : {}), - ...options.observe, - url: url(), - agentName: PLAYGROUND_AGENT_NAME, - })), - captureContent: () => (fromEnv() ? envTargetIsLoopback() : isLoopbackUrl(url())), - }; -} - /** The SSE response of one run; `send` stops after the first terminal (`done` / `error`) event. */ export interface SseStream { send(event: PlaygroundEvent): void; @@ -238,7 +189,7 @@ export async function pumpRunEvents(events: AsyncIterable, stream: S if (!stream.ended) stream.send({ type: 'error', message: failure ?? 'The run ended without a result' }); } -async function streamRun(res: Response, stream: SseStream, request: PlaygroundRunRequest, options: PlaygroundOptions, reporting: PlaygroundReporting): Promise { +async function streamRun(res: Response, stream: SseStream, request: PlaygroundRunRequest, options: PlaygroundOptions, reporting: ObserveReporting): Promise { const abort = new AbortController(); // The client went away: abort the run (the reporter still records it, as aborted). res.on('close', () => abort.abort()); @@ -283,7 +234,7 @@ function failStream(res: Response, stream: SseStream, error: unknown, next: (err /** Registers `POST /playground/run`. */ export function registerPlaygroundRoute(app: Express, options: PlaygroundOptions): void { - const reporting = playgroundReporting(options); + const reporting = observeReporting({ agentName: PLAYGROUND_AGENT_NAME, baseDir: options.baseDir, overrides: options.observe }); app.post('/playground/run', (req, res, next) => { const parsed = parsePlaygroundRequest(req.body); if (typeof parsed === 'string') { diff --git a/apps/agent-forge/server/runRegistry.ts b/apps/agent-forge/server/runRegistry.ts index ac54728b7..0ca3ccb70 100644 --- a/apps/agent-forge/server/runRegistry.ts +++ b/apps/agent-forge/server/runRegistry.ts @@ -10,8 +10,9 @@ */ import { EventEmitter } from 'node:events'; import { randomUUID } from 'node:crypto'; -import { fileTraceExporter } from '@lousho/build-ai-agent/traces'; -import { agentTraceDir, fanOutExporter } from './traceStore'; +import { fanOutExporters, fileTraceExporter, type ObserveExporterOptions } from '@lousho/build-ai-agent/traces'; +import { agentTraceDir } from './traceStore'; +import { observeReporting, type ObserveReporting } from './observeReporting'; import { AgentExecutor, ToolRegistry, resumeAfterApproval } from '@lousho/build-ai-agent/executor'; import { type AgentEvent, @@ -79,6 +80,8 @@ interface RunEntry { lastSpec?: AgentSpec; /** `/` the last run actually used, for the Topbar's env pill. */ provider?: string; + /** Root span id of the latest run (its trace id), for the top bar's "Open trace" link. */ + traceId?: string; /** Live step-through debug session for this agent's in-flight run, if any. */ debugSession?: DebugSession; /** @@ -120,6 +123,13 @@ export interface RunManagerOptions { secretsStore?: SecretsStore; /** Active settings profile source (hook timeout today; provider/deploy-adapter selection lives on the profile too, for the Settings UI). */ settingsStore?: SettingsStore; + /** + * Report every run to the observe daemon as agent `` (see + * observeReporting.ts): `true` in the studio, or reporter overrides + * (tests: `url`, `fetchFn`, ...). Omitted, the manager makes no reporter of + * its own (the SDK's `LOUSHO_OBSERVE` one still applies). + */ + observe?: true | Partial; } export class AgentNotFoundError extends Error {} @@ -190,6 +200,8 @@ export class RunManager extends EventEmitter { private readonly breakpoints = new Map>(); /** Chat transcript persistence (see chatStore.ts). */ private readonly chatStore: FileChatStore; + /** One observe reporter per agent id, made on its first run. */ + private readonly reporting = new Map(); constructor(private readonly opts: RunManagerOptions) { super(); @@ -212,6 +224,7 @@ export class RunManager extends EventEmitter { resultText: entry.resultText, result: entry.result, provider: entry.provider, + traceId: entry.traceId, updatedAt: entry.updatedAt, }; } @@ -332,14 +345,37 @@ export class RunManager extends EventEmitter { * every real span start/end notification for this run over the existing * WS channel (as `{type:'span', ...}` messages, see wsServer.ts) rather * than a second tracing pipeline. It also writes the run to the - * agent's trace folder (see traceStore.ts), so the Trace tab can reopen it. + * agent's trace folder (see traceStore.ts), so the Trace tab can reopen it, + * and reports it to the observe daemon when `observe` is set. The root + * span's id is the run's trace id, kept on the status. */ private makeTraceExporter(agentId: string): TraceExporter { const live: TraceExporter = { - onSpanStart: (span) => this.emitSpan(agentId, span), + onSpanStart: (span) => { + if (span.parentId === undefined) this.setEntry(agentId, { traceId: span.id }); + this.emitSpan(agentId, span); + }, onSpanEnd: (span) => this.emitSpan(agentId, span), }; - return fanOutExporter(live, fileTraceExporter({ dir: agentTraceDir(this.opts.baseDir, agentId) })); + const file = fileTraceExporter({ dir: agentTraceDir(this.opts.baseDir, agentId) }); + // The SDK's fan-out, so AgentExecutor sees an env reporter in it and adds none of its own. + return fanOutExporters([live, file, this.reportingFor(agentId)?.exporter()]) as TraceExporter; + } + + private reportingFor(agentId: string): ObserveReporting | undefined { + const { observe } = this.opts; + if (!observe) return undefined; + let reporting = this.reporting.get(agentId); + if (!reporting) { + reporting = observeReporting({ agentName: agentId, baseDir: this.opts.baseDir, ...(observe === true ? {} : { overrides: observe }) }); + this.reporting.set(agentId, reporting); + } + return reporting; + } + + /** Content goes on the run's spans only when its reporter's target is loopback; `undefined` keeps the SDK default. */ + private captureContentFor(agentId: string): boolean | undefined { + return this.reportingFor(agentId)?.captureContent(); } /** (re)creates the debug session for a fresh run(), seeded from this agent's persisted breakpoints. */ @@ -524,6 +560,7 @@ export class RunManager extends EventEmitter { reason: undefined, pendingApproval: undefined, resultText: undefined, + traceId: undefined, runInput: options.runInput, lastSpec: resolvedSpec, provider: `${provider.name}/${resolvedSpec.provider.model}`, @@ -559,6 +596,7 @@ export class RunManager extends EventEmitter { approvalStore: this.opts.approvalStore, onAgentEvent: (event) => this.emitEvent(agentId, event), exporter: this.makeTraceExporter(agentId), + captureContent: this.captureContentFor(agentId), skipSystemPromptInjection: options.skipSystemPromptInjection, // Hooks compiled from this agent's graph (spec.policy.hooks) // and the SandboxAdapter they (and any requiresSandbox tool) run @@ -756,6 +794,7 @@ export class RunManager extends EventEmitter { { onAgentEvent: (event) => this.emitEvent(agentId, event), exporter: this.makeTraceExporter(agentId), + captureContent: this.captureContentFor(agentId), // Hooks must fire on the deferred, post-approval tool // execution path too (see resume.ts in the core SDK) - not just // the initial run() - so the same compiled hooks/sandbox are diff --git a/apps/agent-forge/server/traceStore.ts b/apps/agent-forge/server/traceStore.ts index e17e97f40..b2b9425de 100644 --- a/apps/agent-forge/server/traceStore.ts +++ b/apps/agent-forge/server/traceStore.ts @@ -10,7 +10,6 @@ */ import * as fs from 'node:fs'; import * as path from 'node:path'; -import type { TraceExporter } from '@lousho/build-ai-agent'; import { listTraces, readTrace, type TraceSummary } from '@lousho/build-ai-agent/traces'; import type { SpanEvent, TraceSummaryPayload } from '../shared/wireTypes'; @@ -33,14 +32,6 @@ export function traceDirExists(baseDir: string, agentId: string): boolean { return fs.existsSync(agentTraceDir(baseDir, agentId)); } -/** A TraceExporter that calls every exporter in turn. */ -export function fanOutExporter(...exporters: TraceExporter[]): TraceExporter { - return { - onSpanStart: (span) => exporters.forEach((exporter) => exporter.onSpanStart?.(span)), - onSpanEnd: (span) => exporters.forEach((exporter) => exporter.onSpanEnd?.(span)), - }; -} - function toPayload(summary: TraceSummary): TraceSummaryPayload { const { file, ...rest } = summary; void file; // the server-side path stays on the server diff --git a/apps/agent-forge/shared/wireTypes.ts b/apps/agent-forge/shared/wireTypes.ts index a4a0d58a7..a73a2ed0f 100644 --- a/apps/agent-forge/shared/wireTypes.ts +++ b/apps/agent-forge/shared/wireTypes.ts @@ -59,6 +59,8 @@ export interface AgentRunStatusPayload { result?: unknown; /** `/` the latest run actually used (e.g. `openai/gpt-4o-mini`, `mock/mock-1`). */ provider?: string; + /** Trace id (root span id) of the latest run, once its first span started - the top bar's "Open trace" link. */ + traceId?: string; updatedAt: string; } diff --git a/apps/agent-forge/src/canvas/AgentNode.tsx b/apps/agent-forge/src/canvas/AgentNode.tsx index f88cfa091..ea53d439b 100644 --- a/apps/agent-forge/src/canvas/AgentNode.tsx +++ b/apps/agent-forge/src/canvas/AgentNode.tsx @@ -1,12 +1,15 @@ import { memo, useState, type KeyboardEvent } from 'react'; import { Handle, Position, type NodeProps } from '@xyflow/react'; import type { AgentGraphNode, AgentGraphNodeType } from '../graph/types'; +import { StatusPill } from '../ui/StatusPill'; /** * Custom ReactFlow node renderer for every `AgentGraphNodeType`, styled to * match `.design-ref/agent-forge-mockup.html`'s `.node` chrome: a colored - * type-swatch header, a body of key/value config rows read straight from - * the node's `AgentGraphSpec` data (nothing hardcoded per-node), and + * type-swatch header with the node's name, a meta line (an LLM node's + * `provider/model` chip and tool count, and the node's run status pill), a + * body of key/value config rows read straight from the node's + * `AgentGraphSpec` data (nothing hardcoded per-node), and * left/right ports drawn with ReactFlow's own `` so pipeline * connections attach to a real, draggable anchor rather than a picture of * one. @@ -42,10 +45,8 @@ function orUnset(value: string | undefined): string { const ROW_BUILDERS: { [T in AgentGraphNodeType]: RowBuilder } = { trigger: (node) => [{ k: 'type', v: orUnset(node.data.trigger.type) }], - llm: (node) => [ - { k: 'provider', v: orUnset(node.data.provider.type) }, - { k: 'model', v: orUnset(node.data.provider.model) }, - ], + // The provider/model is the card's model chip instead. + llm: () => [], tool: (node) => [{ k: 'tool', v: orUnset(node.data.toolName) }], approval: (node) => [{ k: 'requiresApproval', v: node.data.policy.requiresApproval ? 'yes' : 'no' }], output: () => [], @@ -64,6 +65,10 @@ export interface AgentNodeData extends Record { highlighted?: boolean; /** True when this node has an active breakpoint set. */ hasBreakpoint?: boolean; + /** Tools the agent can call, shown on its LLM node (omitted or 0: no count). */ + toolCount?: number; + /** This node's status in the current run (see nodeStatus.ts); omitted reads idle. */ + status?: string; } /** @@ -155,8 +160,30 @@ function NodeTitle({ label, rename }: { label: string; rename: RenameDraft }) { ); } +/** `provider/model` for an LLM node, else nothing. */ +function modelChipOf(graphNode: AgentGraphNode): string | undefined { + if (graphNode.type !== 'llm') return undefined; + return `${orUnset(graphNode.data.provider.type)}/${orUnset(graphNode.data.provider.model)}`; +} + +function NodeMeta({ graphNode, toolCount = 0, status }: Pick) { + const model = modelChipOf(graphNode); + return ( +
+ {model && ( + + {model} + + )} + {toolCount > 0 && {toolCount === 1 ? '1 tool' : `${toolCount} tools`}} + + +
+ ); +} + function AgentNodeImpl({ data, selected }: NodeProps) { - const { graphNode, onRename, highlighted, hasBreakpoint } = data as unknown as AgentNodeData; + const { graphNode, onRename, highlighted, hasBreakpoint, toolCount, status } = data as unknown as AgentNodeData; const meta = NODE_META[graphNode.type]; const rename = useRenameDraft(graphNode, onRename); const rows = rowsFor(graphNode); @@ -170,13 +197,14 @@ function AgentNodeImpl({ data, selected }: NodeProps) { {meta.hasIn && } {meta.hasOut && } -
0 ? { borderBottom: '1px solid var(--border)' } : undefined}> +
{meta.icon}
- {/* No empty body box under a node with nothing to show (output, router). */} + + {/* No empty body box under a node with nothing to show (llm, output, router). */} {rows.length > 0 && (
{rows.map((row) => ( diff --git a/apps/agent-forge/src/canvas/EmptyCanvas.tsx b/apps/agent-forge/src/canvas/EmptyCanvas.tsx new file mode 100644 index 000000000..e9ecc35a7 --- /dev/null +++ b/apps/agent-forge/src/canvas/EmptyCanvas.tsx @@ -0,0 +1,41 @@ +import { useAppState } from '../state/AppState'; +import { EmptyState } from '../ui/EmptyState'; +import { AGENT_TEMPLATES, graphFromTemplate, type TemplateId } from './templates'; + +/** + * Empty-canvas onboarding: while the open agent's graph has no nodes, the + * canvas lists the starter templates. Choosing one replaces the graph through + * `setGraph` (which marks the agent dirty, like any canvas edit) and keeps the + * agent's own name; the open agent and its route do not change. + */ +export function EmptyCanvas() { + const { graph, spec, setGraph, setSelectedNodeId } = useAppState(); + if (graph.nodes.length > 0) return null; + + function choose(id: TemplateId) { + const next = graphFromTemplate(id, spec.name || undefined); + setGraph(() => next); + setSelectedNodeId(next.nodes.find((n) => n.type === 'llm')?.id); + } + + return ( +
+ + {AGENT_TEMPLATES.map((template) => ( +
  • + +
  • + ))} + + } + /> +
    + ); +} diff --git a/apps/agent-forge/src/canvas/__tests__/AgentNode.test.tsx b/apps/agent-forge/src/canvas/__tests__/AgentNode.test.tsx new file mode 100644 index 000000000..14a9b337e --- /dev/null +++ b/apps/agent-forge/src/canvas/__tests__/AgentNode.test.tsx @@ -0,0 +1,53 @@ +import { describe, it, expect, vi } from 'vitest'; +import { ReactFlowProvider, type NodeProps } from '@xyflow/react'; +import { render } from '../../ui/__tests__/render'; +import { NODE_TYPES, flowNodeType, type AgentNodeData } from '../AgentNode'; +import { specToGraph } from '../../graph/specToGraph'; +import type { AgentGraphNode } from '../../graph/types'; + +const graph = specToGraph({ + name: 'support-bot', + prompt: 'p', + provider: { type: 'openai', model: 'gpt-4o-mini' }, + tools: ['http', 'search'], +}); +const nodeOf = (type: AgentGraphNode['type']) => graph.nodes.find((n) => n.type === type)!; + +function renderCard(graphNode: AgentGraphNode, extra: Partial = {}): HTMLElement { + const Card = NODE_TYPES[flowNodeType(graphNode.type)]; + const data: AgentNodeData = { graphNode, onRename: vi.fn(), ...extra }; + const props = { id: graphNode.id, type: flowNodeType(graphNode.type), data, selected: false } as unknown as NodeProps; + const container = render( + + + + ); + return container.querySelector('.rf-node') as HTMLElement; +} + +describe('AgentNode card', () => { + it('shows the name, a provider/model chip, the tool count and the run status', () => { + const card = renderCard(nodeOf('llm'), { toolCount: 2, status: 'running' }); + expect(card.querySelector('.rf-node-title')?.textContent).toBe('support-bot'); + expect(card.querySelector('.node-model')?.textContent).toBe('openai/gpt-4o-mini'); + expect(card.querySelector('.node-tools')?.textContent).toBe('2 tools'); + expect(card.querySelector('.status-pill')?.textContent).toBe('running'); + }); + + it('is idle with no tool count when nothing is known', () => { + const card = renderCard(nodeOf('llm')); + expect(card.querySelector('.node-tools')).toBeNull(); + expect(card.querySelector('.status-pill')?.textContent).toBe('idle'); + }); + + it('has no model chip on a non-LLM node', () => { + const card = renderCard(nodeOf('tool'), { status: 'done' }); + expect(card.querySelector('.node-model')).toBeNull(); + expect(card.querySelector('.node-row code')?.textContent).toBe('http'); + }); + + it('renders the LLM card markup', () => { + const card = renderCard(nodeOf('llm'), { toolCount: 1, status: 'paused' }); + expect(card.outerHTML).toMatchSnapshot(); + }); +}); diff --git a/apps/agent-forge/src/canvas/__tests__/EmptyCanvas.test.tsx b/apps/agent-forge/src/canvas/__tests__/EmptyCanvas.test.tsx new file mode 100644 index 000000000..1be21f5ed --- /dev/null +++ b/apps/agent-forge/src/canvas/__tests__/EmptyCanvas.test.tsx @@ -0,0 +1,53 @@ +import { act } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { AgentGraphSpec } from '../../graph/types'; + +const appState = vi.hoisted(() => ({ + graph: { version: 1, nodes: [], edges: [] } as AgentGraphSpec, + spec: { name: 'my-agent', prompt: '', provider: { type: 'mock', model: 'mock-1' } }, + setGraph: vi.fn(), + setSelectedNodeId: vi.fn(), + switchAgent: vi.fn(), +})); +vi.mock('../../state/AppState', () => ({ useAppState: () => appState })); + +import { render } from '../../ui/__tests__/render'; +import { EmptyCanvas } from '../EmptyCanvas'; +import { AGENT_TEMPLATES } from '../templates'; +import { specToGraph } from '../../graph/specToGraph'; + +beforeEach(() => { + appState.graph = { version: 1, nodes: [], edges: [] }; + appState.setGraph.mockReset(); + appState.setSelectedNodeId.mockReset(); + appState.switchAgent.mockReset(); +}); + +describe('EmptyCanvas', () => { + it('lists every template, by name and description, when the graph has no nodes', () => { + const container = render(); + for (const template of AGENT_TEMPLATES) { + const button = [...container.querySelectorAll('button')].find((b) => b.textContent?.includes(template.name)); + expect(button?.textContent).toContain(template.description); + } + }); + + it('renders nothing once the canvas has a node', () => { + appState.graph = specToGraph(appState.spec); + const container = render(); + expect(container.innerHTML).toBe(''); + }); + + it('fills the canvas from the chosen template through setGraph, keeping the agent name', () => { + const container = render(); + const supportBot = [...container.querySelectorAll('button')].find((b) => b.textContent?.includes('Support bot'))!; + act(() => supportBot.click()); + expect(appState.setGraph).toHaveBeenCalledTimes(1); + const next = appState.setGraph.mock.calls[0][0](appState.graph) as AgentGraphSpec; + expect(next.nodes.map((n) => n.type).sort()).toEqual(['llm', 'output', 'tool', 'trigger']); + const llm = next.nodes.find((n) => n.type === 'llm'); + expect(llm?.type === 'llm' && llm.data.name).toBe('my-agent'); + expect(appState.setSelectedNodeId).toHaveBeenCalledWith(llm?.id); + expect(appState.switchAgent).not.toHaveBeenCalled(); + }); +}); 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 new file mode 100644 index 000000000..140c48503 --- /dev/null +++ b/apps/agent-forge/src/canvas/__tests__/__snapshots__/AgentNode.test.tsx.snap @@ -0,0 +1,3 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`AgentNode card > renders the LLM card markup 1`] = `"
    🧠support-bot
    openai/gpt-4o-mini1 toolawaiting approval
    "`; diff --git a/apps/agent-forge/src/canvas/__tests__/nodeStatus.test.ts b/apps/agent-forge/src/canvas/__tests__/nodeStatus.test.ts new file mode 100644 index 000000000..12afe73cf --- /dev/null +++ b/apps/agent-forge/src/canvas/__tests__/nodeStatus.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from 'vitest'; +import { nodeRunStatuses } from '../nodeStatus'; +import { specToGraph } from '../../graph/specToGraph'; +import type { AgentRunStatusPayload, SpanEvent } from '../../../shared/wireTypes'; + +const graph = specToGraph({ + name: 'bot', + prompt: 'p', + provider: { type: 'mock', model: 'mock-1' }, + tools: ['http'], +}); +const llmId = graph.nodes.find((n) => n.type === 'llm')!.id; +const toolId = graph.nodes.find((n) => n.type === 'tool')!.id; + +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, +}); +const status = (s: AgentRunStatusPayload['status'], over: Partial = {}): AgentRunStatusPayload => + ({ agentId: 'bot', status: s, updatedAt: '', ...over }) as AgentRunStatusPayload; + +describe('nodeRunStatuses', () => { + it('is idle for every node before any run', () => { + const statuses = nodeRunStatuses(graph, [], undefined); + expect(graph.nodes.every((n) => statuses[n.id] === 'idle')).toBe(true); + }); + + it('marks the node of an open span running, and a finished one done', () => { + const statuses = nodeRunStatuses(graph, [chat({ endTime: 5 }), tool()], status('running')); + expect(statuses[llmId]).toBe('done'); + expect(statuses[toolId]).toBe('running'); + }); + + it('marks a failed span error', () => { + const statuses = nodeRunStatuses(graph, [chat({ endTime: 5, status: { code: 'error' } })], status('error')); + expect(statuses[llmId]).toBe('error'); + expect(statuses[toolId]).toBe('idle'); + }); + + 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); + expect(statuses[toolId]).toBe('paused'); + }); +}); diff --git a/apps/agent-forge/src/canvas/nodeStatus.ts b/apps/agent-forge/src/canvas/nodeStatus.ts new file mode 100644 index 000000000..a9844635f --- /dev/null +++ b/apps/agent-forge/src/canvas/nodeStatus.ts @@ -0,0 +1,38 @@ +import type { AgentGraphSpec } from '../graph/types'; +import type { AgentRunStatusPayload, SpanEvent } from '../../shared/wireTypes'; +import { nodeIdForSpan } from '../components/debug/nodeLookup'; + +/** 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) return runStatus?.status === 'paused' ? 'paused' : 'running'; + 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 }; + +/** + * Each node's status in the current run, from the live span feed: the node + * of an open span is running (paused while the run is), 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. + */ +export function nodeRunStatuses( + graph: AgentGraphSpec, + spans: SpanEvent[], + 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; + }; + for (const span of spans) raise(nodeIdForSpan(graph, span), spanStatus(span, runStatus)); + const pending = runStatus?.status === 'paused' ? runStatus.pendingApproval : undefined; + if (pending) { + const tool = graph.nodes.find((n) => n.type === 'tool' && n.data.toolName === pending.toolName); + raise(tool?.id, 'paused'); + } + return statuses; +} diff --git a/apps/agent-forge/src/components/BottomDrawer.tsx b/apps/agent-forge/src/components/BottomDrawer.tsx index a937df225..ebab2ef39 100644 --- a/apps/agent-forge/src/components/BottomDrawer.tsx +++ b/apps/agent-forge/src/components/BottomDrawer.tsx @@ -9,6 +9,7 @@ import { DebugBar } from './debug/DebugBar'; import { ChatPanel } from './ChatPanel'; import { SettingsPanel } from './SettingsPanel'; import { tabIds, tabListKeyDown } from './tabs'; +import { DrawerResizeHandle, useDrawerHeight } from './drawerHeight'; const TABS: { id: DrawerTab; label: string }[] = [ { id: 'chat', label: 'Chat' }, @@ -40,15 +41,18 @@ const FLUSH_BODY_STYLE: CSSProperties = { padding: 0, overflow: 'hidden' }; * collapsible JSON tree of the final/paused ExecutionResult). `DebugBar` * surfaces above Trace whenever debug mode is on or a run is paused * at a breakpoint. Chat and Settings are still later - * epics' jobs. + * epics' jobs. Its top edge resizes it; the height is remembered per viewer + * (see drawerHeight.tsx). */ export function BottomDrawer() { const { drawerTab, setDrawerTab } = useAppState(); const ids = tabIds('drawer', drawerTab); + const { height, setHeight } = useDrawerHeight(); // A real WAI-ARIA tablist (roles, aria-selected, arrow keys). return ( -
    +
    +
    void, - highlightedNodeId: string | undefined, - breakpoints: string[], - measured: MeasuredSizes = {} -): Node[] { +/** What each card shows beyond its own graph node: selection, highlight, breakpoints and run status. */ +interface NodeDecor { + selectedNodeId: string | undefined; + onRename: (id: string, label: string) => void; + highlightedNodeId: string | undefined; + breakpoints: string[]; + statuses: Record; +} + +function toRfNodes(graph: AgentGraphSpec, decor: NodeDecor, measured: MeasuredSizes = {}): Node[] { + const { selectedNodeId, onRename, highlightedNodeId, breakpoints, statuses } = decor; + // An agent's tools are all its tool nodes (graphToSpec.ts's flat `tools` list). + const toolCount = graph.nodes.filter((n) => n.type === 'tool').length; return graph.nodes.map((n) => ({ id: n.id, type: flowNodeType(n.type), @@ -87,6 +94,8 @@ function toRfNodes( onRename, highlighted: n.id === highlightedNodeId, hasBreakpoint: hasBreakpointOn(n, breakpoints), + toolCount: n.type === 'llm' ? toolCount : undefined, + status: statuses[n.id], }, })); } @@ -357,7 +366,7 @@ function CanvasToolbar({ * state, so there is nowhere for the two to drift apart. */ function CanvasInner() { - const { graph, setGraph, selectedNodeId, setSelectedNodeId, highlightedNodeId, debugState } = useAppState(); + const { graph, setGraph, selectedNodeId, setSelectedNodeId, highlightedNodeId, debugState, spans, runStatus } = useAppState(); const { connectError, flashError } = useConnectError(); const onRename = useCallback( @@ -370,9 +379,10 @@ function CanvasInner() { const breakpoints = debugState?.breakpoints ?? []; const breakpointsKey = breakpoints.join(','); const [measured, setMeasured] = useState({}); + const statuses = useMemo(() => nodeRunStatuses(graph, spans, runStatus), [graph, spans, runStatus]); const nodes = useMemo( - () => toRfNodes(graph, selectedNodeId, onRename, highlightedNodeId, breakpoints, measured), - [graph, selectedNodeId, onRename, highlightedNodeId, breakpointsKey, measured] + () => toRfNodes(graph, { selectedNodeId, onRename, highlightedNodeId, breakpoints, statuses }, measured), + [graph, selectedNodeId, onRename, highlightedNodeId, breakpointsKey, statuses, measured] ); const edges = useMemo(() => toRfEdges(graph), [graph]); @@ -390,6 +400,7 @@ function CanvasInner() { onFitView={handleFitView} onDuplicate={handleDuplicate} /> + {connectError && (
    {connectError} diff --git a/apps/agent-forge/src/components/Inspector.tsx b/apps/agent-forge/src/components/Inspector.tsx index 0753b5f5f..1c6e70102 100644 --- a/apps/agent-forge/src/components/Inspector.tsx +++ b/apps/agent-forge/src/components/Inspector.tsx @@ -13,6 +13,7 @@ import { updateEdgeCondition, } from '../canvas/graphMutations'; import { HOOK_TEMPLATES } from '../hooks/hookTemplates'; +import { InspectorSection } from './InspectorSection'; import type { AgentGraphEdge, AgentGraphNode, AgentGraphSpec, AgentNodeHookInstance } from '../graph/types'; type SetGraph = (updater: (graph: AgentGraphSpec) => AgentGraphSpec) => void; @@ -174,7 +175,6 @@ function HooksField({ node, setGraph }: { node: AgentGraphNode; setGraph: SetGra return (
    - h.phase === 'pre')} renderChip={renderChip} /> h.phase === 'post')} renderChip={renderChip} /> @@ -461,11 +461,13 @@ function BreakpointSection({ breakpointKey }: { breakpointKey: string }) { const { debugMode, debugState, setBreakpoints } = useAppState(); if (!debugMode) return null; return ( - + + + ); } @@ -482,22 +484,27 @@ function NodeInspector({ selected }: { selected: AgentGraphNode }) {
    {selected.label}
    -
    - - setGraph((g) => renameNode(g, selected.id, e.target.value))} - /> -
    - - + {/* Collapsible sections, each remembered per viewer (InspectorSection.tsx). */} + +
    + + setGraph((g) => renameNode(g, selected.id, e.target.value))} + /> +
    + + +
    {breakpointKey && ( <> - + + + )}
    diff --git a/apps/agent-forge/src/components/InspectorSection.tsx b/apps/agent-forge/src/components/InspectorSection.tsx new file mode 100644 index 000000000..9ce38a219 --- /dev/null +++ b/apps/agent-forge/src/components/InspectorSection.tsx @@ -0,0 +1,37 @@ +import { useState, type ReactNode } from 'react'; +import { readStored, writeStored } from './storedValue'; + +/** localStorage key prefix of each section's open/closed state (`forge.inspector.`). */ +const INSPECTOR_SECTION_KEY_PREFIX = 'forge.inspector.'; + +/** + * A collapsible Inspector section: a header button (`aria-expanded`) over a + * body that stays mounted while hidden. Sections start open, and each one's + * open/closed state is remembered per viewer under `forge.inspector.`. + */ +export function InspectorSection({ id, title, children }: { id: string; title: string; children: ReactNode }) { + const key = `${INSPECTOR_SECTION_KEY_PREFIX}${id}`; + const [open, setOpen] = useState(() => readStored(key) !== 'closed'); + const bodyId = `inspector-section-${id}`; + + function toggle() { + setOpen(!open); + writeStored(key, open ? 'closed' : 'open'); + } + + return ( +
    +

    + +

    + +
    + ); +} diff --git a/apps/agent-forge/src/components/RunStatus.tsx b/apps/agent-forge/src/components/RunStatus.tsx new file mode 100644 index 000000000..4b39ae331 --- /dev/null +++ b/apps/agent-forge/src/components/RunStatus.tsx @@ -0,0 +1,32 @@ +import { useAppState } from '../state/AppState'; +import { StatusPill } from '../ui/StatusPill'; +import type { AgentRunStatusPayload, SpanEvent } from '../../shared/wireTypes'; + +/** + * The last run's trace id: the one the server put on the status (its root + * span's id), else the live feed's root span while that status is in flight. + */ +export function lastRunTraceId(runStatus: AgentRunStatusPayload | undefined, spans: SpanEvent[]): string | undefined { + if (!runStatus) return undefined; + return runStatus.traceId ?? spans.find((span) => span.parentId === undefined)?.id; +} + +/** + * Next to Run: the last run's status (idle before any) and, once its trace id + * is known, an "Open trace" link to `#/traces/` - builder runs report + * to the observe daemon (server/observeReporting.ts), so the trace screen has it. + */ +export function RunStatus({ showTraceLink = true }: { showTraceLink?: boolean }) { + const { runStatus, spans } = useAppState(); + const traceId = showTraceLink ? lastRunTraceId(runStatus, spans) : undefined; + return ( + <> + + {traceId && ( + + Open trace + + )} + + ); +} diff --git a/apps/agent-forge/src/components/Topbar.tsx b/apps/agent-forge/src/components/Topbar.tsx index 8a252f592..81900bfa9 100644 --- a/apps/agent-forge/src/components/Topbar.tsx +++ b/apps/agent-forge/src/components/Topbar.tsx @@ -4,6 +4,7 @@ import { downloadSpec, importSpecFile } from '../persistence/importExport'; import { ApprovalCard } from './ApprovalCard'; import { errorMessage } from './errorMessage'; import { StatusPill } from '../ui/StatusPill'; +import { RunStatus, lastRunTraceId } from './RunStatus'; import { navigate } from '../router/router'; import { useRoute } from '../router/useRoute'; import { NAV_ITEMS, isActive } from '../shell/NavRail'; @@ -288,13 +289,13 @@ interface TopbarProps { } function NarrowTopbar({ openPanel, onTogglePanel }: Omit) { - const { spec, dirty, runStatus, debugMode, setDebugMode, setDrawerTab } = useAppState(); + const { spec, dirty, runStatus, spans, debugMode, setDebugMode, setDrawerTab } = useAppState(); const { actionError, handleRun, handleStop, handleSave, handleImport } = useRunActions(); const { fileInput, openImport, exportSpec } = useSpecFiles(handleImport); const toggle = onTogglePanel ?? (() => undefined); - const status = runStatus?.status ?? 'idle'; - const isRunning = status === 'running'; + const isRunning = runStatus?.status === 'running'; + const traceId = lastRunTraceId(runStatus, spans); const items: OverflowItem[] = [ { @@ -311,6 +312,8 @@ function NarrowTopbar({ openPanel, onTogglePanel }: Omit) // The old Build/Observe switch's menu entry, kept for muscle memory: Observe is now Traces. // Last: the menu focuses its first item on open (e2e expects Debug). { label: 'Observe surface', onSelect: () => navigate('/traces') }, + // The wide bar's "Open trace" link, which has no room next to Run at 375px. + ...(traceId ? [{ label: 'Open trace', onSelect: () => navigate(`/traces/${encodeURIComponent(traceId)}`) }] : []), ]; return ( @@ -325,7 +328,7 @@ function NarrowTopbar({ openPanel, onTogglePanel }: Omit) {spec.provider.type === 'mock' && MOCK}
    - + {isRunning ? (