diff --git a/apps/agent-forge/server/__tests__/app.test.ts b/apps/agent-forge/server/__tests__/app.test.ts index 1f890ba13..511a96210 100644 --- a/apps/agent-forge/server/__tests__/app.test.ts +++ b/apps/agent-forge/server/__tests__/app.test.ts @@ -162,4 +162,46 @@ describe('LOU-N HTTP API', () => { const res = await request(app).post('/agents/foo/approve').send({ approvalId: 'x' }); expect(res.status).toBe(400); }); + + it('GET /agents/:id/debug reports no breakpoints/not paused for an agent that never ran', async () => { + const res = await request(app).get('/agents/never-debugged/debug'); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ agentId: 'never-debugged', paused: false, breakpoints: [] }); + }); + + it('PUT /agents/:id/debug/breakpoints sets breakpoints, reflected by GET /agents/:id/debug', async () => { + const put = await request(app) + .put('/agents/debug-bp/debug/breakpoints') + .send({ breakpoints: ['llm:before', 'tool:current-date:after'] }); + expect(put.status).toBe(200); + expect(put.body.breakpoints.sort()).toEqual(['llm:before', 'tool:current-date:after']); + + const get = await request(app).get('/agents/debug-bp/debug'); + expect(get.body.breakpoints.sort()).toEqual(['llm:before', 'tool:current-date:after']); + }); + + it('PUT /agents/:id/debug/breakpoints rejects a non-array body', async () => { + const res = await request(app).put('/agents/debug-bp/debug/breakpoints').send({ breakpoints: 'nope' }); + expect(res.status).toBe(400); + }); + + it('a run paused at a breakpoint resumes via POST /agents/:id/debug/continue', async () => { + await request(app) + .put('/agents/debug-run/debug/breakpoints') + .send({ breakpoints: ['llm:before'] }); + + await request(app).post('/agents/debug-run/run').send({ input: 'hi', spec: SPEC }); + await new Promise((resolve) => setTimeout(resolve, 50)); + + const pausedState = await request(app).get('/agents/debug-run/debug'); + expect(pausedState.body.paused).toBe(true); + + // Clear breakpoints so a follow-up llm turn (if any) doesn't re-pause, + // then continue until the run reaches a terminal status. + await request(app).put('/agents/debug-run/debug/breakpoints').send({ breakpoints: [] }); + const cont = await request(app).post('/agents/debug-run/debug/continue'); + expect(cont.status).toBe(200); + + await waitForStatus(runManager, 'debug-run', (s) => s.status === 'stopped'); + }); }); diff --git a/apps/agent-forge/server/__tests__/debugController.test.ts b/apps/agent-forge/server/__tests__/debugController.test.ts new file mode 100644 index 000000000..a58e536e8 --- /dev/null +++ b/apps/agent-forge/server/__tests__/debugController.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect, vi } from 'vitest'; +import { DebugSession, llmBreakpointKey, toolBreakpointKey } from '../debugController'; + +describe('DebugSession', () => { + it('does not pause when no breakpoint matches and no step is armed', async () => { + const onChange = vi.fn(); + const session = new DebugSession([], onChange); + await session.hooks().onLLMRequest?.({ model: 'x', messages: [] }); + expect(onChange).not.toHaveBeenCalled(); + expect(session.snapshot().paused).toBe(false); + }); + + it('pauses at a matching llm:before breakpoint and resumes on continue()', async () => { + const states: boolean[] = []; + const session = new DebugSession([llmBreakpointKey('before')], (s) => states.push(s.paused)); + + const hookPromise = session.hooks().onLLMRequest?.({ model: 'x', messages: [{ role: 'user', content: 'hi' } as any] }); + + // Give the pause a tick to take effect before asserting/continuing. + await new Promise((r) => setTimeout(r, 10)); + expect(session.snapshot().paused).toBe(true); + expect(session.snapshot().atBreakpoint).toEqual({ phase: 'llm', boundary: 'before' }); + expect(session.snapshot().messages).toHaveLength(1); + + session.continue(); + await hookPromise; + expect(session.snapshot().paused).toBe(false); + expect(states).toEqual([true, false]); + }); + + it('pauses on tool::after breakpoints keyed by the tool call name', async () => { + const session = new DebugSession([toolBreakpointKey('current-date', 'after')], () => {}); + const toolCall = { id: '1', type: 'function' as const, function: { name: 'current-date', arguments: '{}' } }; + + const hookPromise = session.hooks().onToolResult?.(toolCall, undefined, 5); + await new Promise((r) => setTimeout(r, 10)); + expect(session.snapshot().paused).toBe(true); + session.continue(); + await hookPromise; + }); + + it('step() arms a one-shot pause at the next hook boundary regardless of breakpoints', async () => { + const session = new DebugSession([], () => {}); + session.step(); + const hookPromise = session.hooks().onLLMRequest?.({ model: 'x', messages: [] }); + await new Promise((r) => setTimeout(r, 10)); + expect(session.snapshot().paused).toBe(true); + session.continue(); + await hookPromise; + + // Step is one-shot: the next hook call does NOT pause again. + const secondPromise = session.hooks().onLLMResponse?.({} as any, 1); + await secondPromise; + expect(session.snapshot().paused).toBe(false); + }); + + it('setBreakpoints() updates the live set and notifies onChange', () => { + const onChange = vi.fn(); + const session = new DebugSession([], onChange); + session.setBreakpoints([llmBreakpointKey('after')]); + expect(session.snapshot().breakpoints).toEqual(['llm:after']); + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ breakpoints: ['llm:after'] })); + }); + + it('step() while already paused releases the current pause instead of stacking', async () => { + const session = new DebugSession([llmBreakpointKey('before')], () => {}); + const hookPromise = session.hooks().onLLMRequest?.({ model: 'x', messages: [] }); + await new Promise((r) => setTimeout(r, 10)); + expect(session.snapshot().paused).toBe(true); + session.step(); + await hookPromise; + expect(session.snapshot().paused).toBe(false); + }); + + it('auto-resumes (and flags autoResumed) if nobody continues/steps before pauseTimeoutMs elapses', async () => { + const states: Array<{ paused: boolean; autoResumed?: boolean }> = []; + // A tiny pauseTimeoutMs stands in for an abandoned pause (e.g. the only + // WS client disconnected) - this is the safety net that stops a run + // staying paused forever with no way to resume it. + const session = new DebugSession([llmBreakpointKey('before')], (s) => states.push({ paused: s.paused, autoResumed: s.autoResumed }), 15); + + const hookPromise = session.hooks().onLLMRequest?.({ model: 'x', messages: [] }); + await new Promise((r) => setTimeout(r, 10)); + expect(session.snapshot().paused).toBe(true); + + // Nobody calls continue()/step() - just wait past the timeout. + await hookPromise; + expect(session.snapshot().paused).toBe(false); + expect(states.at(-1)).toEqual({ paused: false, autoResumed: true }); + }); + + it('pauseTimeoutMs: 0 disables the safety net (pause never auto-resumes on its own)', async () => { + const session = new DebugSession([llmBreakpointKey('before')], () => {}, 0); + const hookPromise = session.hooks().onLLMRequest?.({ model: 'x', messages: [] }); + await new Promise((r) => setTimeout(r, 30)); + expect(session.snapshot().paused).toBe(true); + session.continue(); + await hookPromise; + expect(session.snapshot().paused).toBe(false); + }); +}); diff --git a/apps/agent-forge/server/__tests__/runRegistry.test.ts b/apps/agent-forge/server/__tests__/runRegistry.test.ts index 290ffa846..a6eb8d7b4 100644 --- a/apps/agent-forge/server/__tests__/runRegistry.test.ts +++ b/apps/agent-forge/server/__tests__/runRegistry.test.ts @@ -117,6 +117,65 @@ describe('RunManager', () => { expect(afterResume).toBeNull(); }); + it('emits structured log entries derived from the ExecutionEvent stream (O1)', async () => { + const logs: any[] = []; + runManager.on('log', (agentId: string, entry: any) => { + if (agentId === 'agent-logs') logs.push(entry); + }); + + await runManager.run('agent-logs', 'please use current-date', SPEC); + await waitForStatus(runManager, 'agent-logs', (s) => s.status === 'stopped'); + + expect(logs.length).toBeGreaterThan(0); + expect(logs.every((l) => typeof l.id === 'string' && typeof l.timestamp === 'string')).toBe(true); + expect(logs.some((l) => l.phase === 'trigger' && l.message.includes('started'))).toBe(true); + expect(logs.some((l) => l.phase === 'tool' && l.toolName === 'current-date')).toBe(true); + }); + + it('emits real span start/end events from the SDK TraceExporter hook (O2)', async () => { + const spans: any[] = []; + runManager.on('span', (agentId: string, span: any) => { + if (agentId === 'agent-spans') spans.push(span); + }); + + await runManager.run('agent-spans', 'please use current-date', SPEC); + await waitForStatus(runManager, 'agent-spans', (s) => s.status === 'stopped'); + + const runSpans = spans.filter((s) => s.name === 'agent.run'); + expect(runSpans).toHaveLength(2); // start + end + const [startSpan, endSpan] = runSpans; + expect(startSpan.endTime).toBeUndefined(); + expect(endSpan.endTime).toBeGreaterThanOrEqual(endSpan.startTime); + expect(spans.some((s) => s.name === 'llm.generate')).toBe(true); + expect(spans.some((s) => s.name === 'tool.call' && s.parentId === startSpan.id)).toBe(true); + }); + + it('pauses a run at a configured breakpoint and resumes on continueRun() (O3)', async () => { + const debugStates: any[] = []; + let pauseCount = 0; + // The fixture SPEC's prompt drives the mock provider through a tool + // call then a final response - two provider.generate() calls, so a + // persistent 'llm:before' breakpoint pauses twice. continueRun() every + // time the run reports paused, same as a real client would on seeing + // `{type:'debug', payload:{paused:true}}` over the WS stream. + runManager.on('debug', (agentId: string, state: any) => { + if (agentId !== 'agent-debug') return; + debugStates.push(state); + if (state.paused) { + pauseCount += 1; + runManager.continueRun('agent-debug'); + } + }); + + runManager.setBreakpoints('agent-debug', ['llm:before']); + await runManager.run('agent-debug', 'please use current-date', SPEC); + await waitForStatus(runManager, 'agent-debug', (s) => s.status === 'stopped'); + + expect(pauseCount).toBeGreaterThanOrEqual(1); + expect(runManager.debugState('agent-debug').paused).toBe(false); + expect(debugStates.some((s) => s.paused === true && s.atBreakpoint?.phase === 'llm')).toBe(true); + }); + it('surfaces a thrown provider error as status "error" without crashing the process', async () => { const errorSpec: AgentSpec = { name: 'erroring-agent', diff --git a/apps/agent-forge/server/app.ts b/apps/agent-forge/server/app.ts index c4e35c848..f4cecd4d8 100644 --- a/apps/agent-forge/server/app.ts +++ b/apps/agent-forge/server/app.ts @@ -167,6 +167,36 @@ export function createApp({ agentStore, runManager }: CreateAppOptions): Express }) ); + // O3: step-through debugger controls (Topbar's "Debug" button). See + // debugController.ts for exactly what "breakpoint"/"paused" mean given + // AgentExecutor's real control surface. + app.get('/agents/:id/debug', (req, res) => { + res.json(runManager.debugState(paramId(req))); + }); + + app.put( + '/agents/:id/debug/breakpoints', + (req, res) => { + const { breakpoints } = req.body as { breakpoints?: unknown }; + if (!Array.isArray(breakpoints) || !breakpoints.every((b) => typeof b === 'string')) { + res.status(400).json({ error: "Request body must include a 'breakpoints' string array" }); + return; + } + runManager.setBreakpoints(paramId(req), breakpoints); + res.json(runManager.debugState(paramId(req))); + } + ); + + app.post('/agents/:id/debug/continue', (req, res) => { + runManager.continueRun(paramId(req)); + res.json(runManager.debugState(paramId(req))); + }); + + app.post('/agents/:id/debug/step', (req, res) => { + runManager.stepRun(paramId(req)); + res.json(runManager.debugState(paramId(req))); + }); + // eslint-disable-next-line @typescript-eslint/no-unused-vars app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => { res.status(500).json({ error: err.message }); diff --git a/apps/agent-forge/server/debugController.ts b/apps/agent-forge/server/debugController.ts new file mode 100644 index 000000000..1c8f96833 --- /dev/null +++ b/apps/agent-forge/server/debugController.ts @@ -0,0 +1,211 @@ +/** + * LOU-O3 step-through debugger. + * + * `AgentExecutor.execute()` has no native breakpoint concept - there is no + * per-node hook, and the loop inside `runAgentLoop()` is private. What it + * DOES expose (see `ExecuteOptions` in src/execution/AgentExecutor.ts) is a + * set of lifecycle callbacks invoked synchronously around the two things a + * step in this app's fixed trigger->llm->tool(s)->approval->output pipeline + * actually corresponds to: `onLLMRequest`/`onLLMResponse` (before/after each + * provider.generate() call - the "llm" node) and `onToolCall`/`onToolResult` + * (before/after each tool execution - a "tool" node). All four are allowed + * to return a `Promise` that `execute()` awaits before continuing. + * + * That is the real, honest control surface this class uses: a "breakpoint" + * is a `{phase, boundary}` key matched against one of these four hook call + * sites, and "pausing" is `await`-ing a promise inside the hook that only + * resolves when the client calls continue()/step(). This is genuinely a + * pause of the live execution (the awaited generate()/tool call has not + * been made yet, or its result has not yet been folded back into the + * conversation) - NOT a synthetic/simulated pause layered on top of + * already-complete work. + * + * What this can'T do, honestly: there is no breakpoint *inside* a single + * provider.generate() call or *inside* a single tool's execute() body (the + * SDK doesn't instrument that deep), and a "trigger" or "output" node has no + * corresponding hook at all (the pipeline's trigger is just the initial + * `input`, and "output" is just the final `ExecutionResult` - neither is a + * discrete step the executor invokes a callback around). So breakpoints are + * only supported on `llm` and `tool` nodes, matching this app's graph model + * (graph/types.ts) - see the epic report for the full breakdown. + */ +import type { GenerateOptions, Message, ToolCall } from '@loushy/build-ai-agent'; + +export type DebugBoundary = 'before' | 'after'; + +/** `'llm:before'`, `'llm:after'`, `` `tool:${toolName}:before` ``, `` `tool:${toolName}:after` ``. */ +export type BreakpointKey = string; + +export function llmBreakpointKey(boundary: DebugBoundary): BreakpointKey { + return `llm:${boundary}`; +} + +export function toolBreakpointKey(toolName: string, boundary: DebugBoundary): BreakpointKey { + return `tool:${toolName}:${boundary}`; +} + +export interface DebugStateSnapshot { + paused: boolean; + atBreakpoint?: { phase: string; boundary: DebugBoundary }; + /** The live message array as of the last hook call - only meaningful while paused. */ + messages: Message[]; + /** Number of completed LLM generate() calls so far (a proxy for AgentExecutor's internal step counter). */ + stepCount: number; + breakpoints: BreakpointKey[]; + /** + * True on the one onChange() notification that reports a pause ending + * because `pauseTimeoutMs` elapsed with nobody calling continue()/step() + * (e.g. the only WS client disconnected while paused) - see the + * `pauseTimeoutMs` doc comment below. False for every other transition. + */ + autoResumed?: boolean; +} + +/** + * Default safety-net timeout: how long a run may sit paused at a breakpoint + * before this session auto-continues it on its own. A local dev tool has no + * server-side notion of "the client went away" beyond the WS socket closing + * (see wsServer.ts), and `POST /agents/:id/debug/continue` is still callable + * with no live socket at all - so an abandoned pause (browser tab closed, + * laptop slept, WS dropped mid-pause) would otherwise block that run's + * AgentExecutor.execute() call - and the checkpointed conversation/tool call + * it's holding open - forever, with nothing to time it out. 15 minutes is + * long enough not to interrupt a human actually stepping through a run, but + * bounds the worst case to "paused a while, then resumed on its own" instead + * of "stuck until the server process is restarted". + */ +export const DEFAULT_PAUSE_TIMEOUT_MS = 15 * 60 * 1000; + +export type DebugHooks = Pick< + import('@loushy/build-ai-agent').ExecuteOptions, + 'onLLMRequest' | 'onLLMResponse' | 'onToolCall' | 'onToolResult' +>; + +/** + * One debug session per live run. Breakpoints can be updated mid-run (the + * hooks re-read `this.breakpoints` on every call), so setting a breakpoint + * while a run is already in flight takes effect on its very next hook call. + */ +export class DebugSession { + private breakpoints: Set; + private paused = false; + private stepRequested = false; + private resumeResolve?: () => void; + private lastMessages: Message[] = []; + private stepCount = 0; + private current: { phase: string; boundary: DebugBoundary } | undefined; + private pauseTimer?: ReturnType; + private lastResumeWasTimeout = false; + + constructor( + initialBreakpoints: Iterable, + private readonly onChange: (state: DebugStateSnapshot) => void, + /** 0 (or any non-positive value) disables the safety net entirely. */ + private readonly pauseTimeoutMs: number = DEFAULT_PAUSE_TIMEOUT_MS + ) { + this.breakpoints = new Set(initialBreakpoints); + } + + setBreakpoints(keys: Iterable): void { + this.breakpoints = new Set(keys); + this.onChange(this.snapshot()); + } + + /** Resumes a paused run without changing the breakpoint set. No-op if not paused. */ + continue(): void { + this.release(); + } + + /** + * Resumes a paused run (or, if not currently paused, arms a one-shot + * pause at the very next hook boundary regardless of the breakpoint set) + * - real single-step execution bounded by the granularity above (one LLM + * call or one tool call at a time, not a single line of agent logic). + */ + step(): void { + if (this.paused) { + this.stepRequested = true; + this.release(); + } else { + this.stepRequested = true; + } + } + + snapshot(autoResumed = false): DebugStateSnapshot { + return { + paused: this.paused, + atBreakpoint: this.current, + messages: this.lastMessages, + stepCount: this.stepCount, + breakpoints: [...this.breakpoints], + autoResumed, + }; + } + + hooks(): DebugHooks { + return { + onLLMRequest: async (request: GenerateOptions) => { + this.lastMessages = request.messages; + await this.maybePause('llm', 'before'); + }, + onLLMResponse: async () => { + this.stepCount += 1; + await this.maybePause('llm', 'after'); + }, + onToolCall: async (toolCall: ToolCall) => { + await this.maybePause(toolCall.function.name, 'before'); + }, + onToolResult: async (toolCall: ToolCall) => { + await this.maybePause(toolCall.function.name, 'after'); + }, + }; + } + + private release(timedOut = false): void { + if (this.pauseTimer) { + clearTimeout(this.pauseTimer); + this.pauseTimer = undefined; + } + this.lastResumeWasTimeout = timedOut; + const resolve = this.resumeResolve; + this.resumeResolve = undefined; + resolve?.(); + } + + private async maybePause(phase: string, boundary: DebugBoundary): Promise { + const key = phase === 'llm' ? llmBreakpointKey(boundary) : toolBreakpointKey(phase, boundary); + if (!this.breakpoints.has(key) && !this.stepRequested) return; + this.stepRequested = false; + this.paused = true; + this.current = { phase, boundary }; + // `resumeResolve` must be assigned BEFORE `onChange()` fires - onChange + // synchronously notifies subscribers (see runRegistry.ts's + // makeDebugSession), and a subscriber is allowed to call continue()/ + // step() synchronously in response (e.g. a test, or a client that + // auto-continues past a particular breakpoint). If the Promise executor + // ran after onChange(), that synchronous continue() would silently + // no-op against an as-yet-unset resumeResolve and the pause would never + // release. + const pause = new Promise((resolve) => { + this.resumeResolve = resolve; + }); + // Safety net: if nobody (no WS client, nobody hitting the REST + // continue()/step() routes) ever resumes this pause - most plausibly + // because the only client watching this run's WS stream disconnected + // while it sat paused - auto-continue after `pauseTimeoutMs` rather than + // holding the underlying AgentExecutor.execute() call (and its + // checkpointed conversation) open indefinitely. See the doc comment on + // `DEFAULT_PAUSE_TIMEOUT_MS`. + if (this.pauseTimeoutMs > 0) { + this.pauseTimer = setTimeout(() => this.release(true), this.pauseTimeoutMs); + this.pauseTimer.unref?.(); + } + this.onChange(this.snapshot()); + await pause; + this.paused = false; + this.current = undefined; + const wasTimeout = this.lastResumeWasTimeout; + this.lastResumeWasTimeout = false; + this.onChange(this.snapshot(wasTimeout)); + } +} diff --git a/apps/agent-forge/server/runRegistry.ts b/apps/agent-forge/server/runRegistry.ts index c518536c4..9ba931126 100644 --- a/apps/agent-forge/server/runRegistry.ts +++ b/apps/agent-forge/server/runRegistry.ts @@ -9,18 +9,23 @@ * of the SDK's public execution API. */ import { EventEmitter } from 'node:events'; +import { randomUUID } from 'node:crypto'; import { AgentExecutor, ToolRegistry, type ExecutionEvent, type CheckpointStore, + type Span, + type TraceExporter, + type ExecutionResult, resumeAfterApproval, type AgentSpec, } from '@loushy/build-ai-agent'; import { buildAgentFromSpec } from './buildAgent'; import { withAbortSignal, RunAbortedError } from './abortableProvider'; import { FileApprovalStore } from './approvalStore'; -import type { AgentRunStatusPayload, RunStatus } from './types'; +import { DebugSession, type BreakpointKey } from './debugController'; +import type { AgentRunStatusPayload, RunStatus, LogEntry, LogLevel, LogPhase } from './types'; interface RunEntry { status: RunStatus; @@ -35,6 +40,8 @@ interface RunEntry { reason?: 'awaiting_approval'; pendingApproval?: AgentRunStatusPayload['pendingApproval']; resultText?: string; + /** O4: full ExecutionResult (messages/toolCalls/usage/steps/finishReason) for the Output tab's JSON tree. */ + result?: ExecutionResult; updatedAt: string; /** * The AgentSpec this agent id was last run() with. approve() needs a spec @@ -46,6 +53,8 @@ interface RunEntry { * the process. */ lastSpec?: AgentSpec; + /** O3: live step-through debug session for this agent's in-flight run, if any. */ + debugSession?: DebugSession; } export interface RunManagerOptions { @@ -67,8 +76,82 @@ export class AgentNotFoundError extends Error {} export class AlreadyRunningError extends Error {} export class NoActiveRunError extends Error {} +/** + * O1: translates one `ExecutionEvent` (the SDK's own execution-phase + * taxonomy - start/text-delta/text-complete/tool-call/tool-result/finish/ + * error, see AgentExecutor.ts) into zero or more structured `LogEntry` + * rows. This reuses that taxonomy rather than inventing a second, parallel + * logging vocabulary - `LogPhase` is a coarser regrouping of the same + * events (e.g. both 'start' and 'finish' map to phase 'trigger', since + * those are this pipeline's entry/exit points) plus 'sandbox'/'checkpoint'/ + * 'approval'/'debug' phases used by emitters elsewhere in this file for + * things ExecutionEvent has no dedicated type for. + */ +function toLogEntries(agentId: string, event: ExecutionEvent): LogEntry[] { + const timestamp = (event.timestamp instanceof Date ? event.timestamp : new Date()).toISOString(); + const base = { id: randomUUID(), agentId, timestamp }; + + switch (event.type) { + case 'start': + return [{ ...base, level: 'info', phase: 'trigger', message: `Run started for agent '${event.agentName ?? agentId}'` }]; + case 'text-complete': + return [{ ...base, level: 'info', phase: 'llm', message: event.text ? `LLM response: ${truncate(event.text)}` : 'LLM response received' }]; + case 'tool-call': + return [ + { + ...base, + level: 'tool', + phase: 'tool', + toolName: event.toolCall?.function?.name, + message: `Tool call: ${event.toolCall?.function?.name ?? 'unknown'}`, + detail: event.toolCall, + }, + ]; + case 'tool-result': { + const isError = !!event.toolResult?.error; + return [ + { + ...base, + level: isError ? 'error' : 'tool', + phase: 'tool', + toolName: event.toolResult?.toolName, + message: isError + ? `Tool '${event.toolResult?.toolName}' failed: ${event.toolResult?.error}` + : `Tool '${event.toolResult?.toolName}' result: ${truncate(JSON.stringify(event.toolResult?.result))}`, + detail: event.toolResult, + }, + ]; + } + case 'finish': + return [ + { + ...base, + level: 'info', + phase: event.finishReason === 'awaiting-approval' ? 'approval' : 'trigger', + message: `Run finished (${event.finishReason})`, + }, + ]; + case 'error': + return [{ ...base, level: 'error', phase: 'trigger', message: event.error?.message ?? 'Run failed' }]; + default: + return []; + } +} + +function truncate(text: string | undefined, max = 400): string { + if (!text) return ''; + return text.length > max ? `${text.slice(0, max)}...` : text; +} + export class RunManager extends EventEmitter { private readonly entries = new Map(); + /** + * O3: breakpoints persist per agent id across runs (set them once, they + * apply to every future run() until cleared/changed), independent of the + * in-flight `RunEntry.debugSession` - a fresh DebugSession is created + * per-run() but seeded from this map. + */ + private readonly breakpoints = new Map>(); constructor(private readonly opts: RunManagerOptions) { super(); @@ -88,6 +171,7 @@ export class RunManager extends EventEmitter { pendingApproval: entry.pendingApproval, error: entry.error, resultText: entry.resultText, + result: entry.result, updatedAt: entry.updatedAt, }; } @@ -114,6 +198,65 @@ export class RunManager extends EventEmitter { finishReason: event.finishReason, error: event.error ? { message: event.error.message } : undefined, }); + for (const log of toLogEntries(agentId, event)) { + this.emit('log', agentId, log); + } + } + + private emitSpan(agentId: string, span: Span): void { + this.emit('span', agentId, { + id: span.id, + name: span.name, + parentId: span.parentId, + startTime: span.startTime, + endTime: span.endTime, + attributes: span.attributes, + }); + } + + /** + * O2: builds a `TraceExporter` (src/execution/tracing.ts) that forwards + * 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. + */ + private makeTraceExporter(agentId: string): TraceExporter { + return { + onSpanStart: (span) => this.emitSpan(agentId, span), + onSpanEnd: (span) => this.emitSpan(agentId, span), + }; + } + + /** O3: (re)creates the debug session for a fresh run(), seeded from this agent's persisted breakpoints. */ + private makeDebugSession(agentId: string): DebugSession { + const initial = this.breakpoints.get(agentId) ?? new Set(); + const session = new DebugSession(initial, (state) => { + this.emit('debug', agentId, { agentId, ...state }); + if (state.paused) { + this.emit('log', agentId, { + id: randomUUID(), + agentId, + timestamp: new Date().toISOString(), + level: 'info' as LogLevel, + phase: 'debug' as LogPhase, + message: `Paused at breakpoint ${state.atBreakpoint?.phase} (${state.atBreakpoint?.boundary})`, + } satisfies LogEntry); + } else if (state.autoResumed) { + // O3 safety net: nobody called continue()/step() before + // DEFAULT_PAUSE_TIMEOUT_MS elapsed (most plausibly the only WS + // client watching this run disconnected while it was paused) - see + // debugController.ts's DEFAULT_PAUSE_TIMEOUT_MS doc comment. + this.emit('log', agentId, { + id: randomUUID(), + agentId, + timestamp: new Date().toISOString(), + level: 'warn' as LogLevel, + phase: 'debug' as LogPhase, + message: 'Auto-resumed after sitting paused at a breakpoint with no client response', + } satisfies LogEntry); + } + }); + return session; } /** @@ -163,6 +306,7 @@ export class RunManager extends EventEmitter { const sessionId = agentId; const controller = new AbortController(); + const debugSession = this.makeDebugSession(agentId); const entry = this.setEntry( agentId, @@ -175,6 +319,7 @@ export class RunManager extends EventEmitter { pendingApproval: undefined, resultText: undefined, lastSpec: resolvedSpec, + debugSession, }, existing ); @@ -190,6 +335,8 @@ export class RunManager extends EventEmitter { checkpointStore: this.opts.checkpointStore, approvalStore: this.opts.approvalStore, onEvent: (event) => this.emitEvent(agentId, event), + exporter: this.makeTraceExporter(agentId), + ...debugSession.hooks(), }) .then((result) => this.handleRunSettled(agentId, result)) .catch((error: unknown) => this.handleRunFailed(agentId, error)); @@ -198,10 +345,12 @@ export class RunManager extends EventEmitter { } /** Shared success-path handling for both run() and approve()'s follow-up execute() call. */ - private async handleRunSettled( - agentId: string, - result: { finishReason: string; approvalId?: string; text: string } - ): Promise { + private async handleRunSettled(agentId: string, result: ExecutionResult): Promise { + // O4: the full ExecutionResult (messages/toolCalls/usage/steps, not + // just the final text) is kept on the entry either way, so the + // client's Output tab can render it as a JSON tree whether the run + // finished or is paused for approval - "the final OR paused + // ExecutionResult" per the epic brief. if (result.finishReason === 'awaiting-approval' && result.approvalId) { const record = await this.opts.approvalStore.peek(agentId, result.approvalId); this.setEntry(agentId, { @@ -213,10 +362,11 @@ export class RunManager extends EventEmitter { args: record?.pending.args ?? {}, createdAt: record?.pending.createdAt ?? new Date().toISOString(), }, + result, }); return; } - this.setEntry(agentId, { status: 'stopped', resultText: result.text, controller: undefined }); + this.setEntry(agentId, { status: 'stopped', resultText: result.text, result, controller: undefined }); } /** Shared failure-path handling for both run() and approve()'s follow-up execute() call. */ @@ -265,11 +415,13 @@ export class RunManager extends EventEmitter { const { provider, toolRegistry } = buildAgentFromSpec(spec, agentId); const controller = new AbortController(); + const debugSession = this.makeDebugSession(agentId); this.setEntry(agentId, { status: 'running', reason: undefined, pendingApproval: undefined, controller, + debugSession, }); resumeAfterApproval( @@ -277,10 +429,44 @@ export class RunManager extends EventEmitter { this.opts.approvalStore, toolRegistry ?? new ToolRegistry(), withAbortSignal(provider, controller.signal), - { onEvent: (event) => this.emitEvent(agentId, event) }, + { + onEvent: (event) => this.emitEvent(agentId, event), + exporter: this.makeTraceExporter(agentId), + ...debugSession.hooks(), + }, this.opts.checkpointStore ) .then((result) => this.handleRunSettled(agentId, result)) .catch((error: unknown) => this.handleRunFailed(agentId, error)); } + + /** + * O3: replaces the breakpoint set for `agentId`, persisted across runs. + * If a run is currently in flight, its live DebugSession is updated too + * so the change takes effect on the very next hook call, not just future + * run()s. + */ + setBreakpoints(agentId: string, keys: BreakpointKey[]): void { + this.breakpoints.set(agentId, new Set(keys)); + this.entries.get(agentId)?.debugSession?.setBreakpoints(keys); + } + + /** Resumes a run paused at a breakpoint. No-op if not currently paused. */ + continueRun(agentId: string): void { + this.entries.get(agentId)?.debugSession?.continue(); + } + + /** Resumes a run paused at a breakpoint, or arms a pause at the very next LLM/tool boundary if not currently paused. */ + stepRun(agentId: string): void { + this.entries.get(agentId)?.debugSession?.step(); + } + + debugState(agentId: string): { agentId: string; paused: boolean; breakpoints: BreakpointKey[] } & Record { + const session = this.entries.get(agentId)?.debugSession; + const breakpoints = [...(this.breakpoints.get(agentId) ?? [])]; + if (!session) { + return { agentId, paused: false, breakpoints, messages: [], stepCount: 0 }; + } + return { agentId, ...session.snapshot() }; + } } diff --git a/apps/agent-forge/server/types.ts b/apps/agent-forge/server/types.ts index dc3a1ec8b..7ce5d594e 100644 --- a/apps/agent-forge/server/types.ts +++ b/apps/agent-forge/server/types.ts @@ -41,21 +41,90 @@ export interface AgentRunStatusPayload { error?: string; /** Final assistant text, once a run completes successfully. */ resultText?: string; + /** + * O4: the full `ExecutionResult` (src/execution/AgentExecutor.ts - + * `messages`/`toolCalls`/`usage`/`steps`/`finishReason`, not just the + * final text) once a run completes OR pauses for approval - the Output + * tab's JSON tree source. `unknown` here (rather than importing + * `ExecutionResult`) since this file is also mirrored client-side + * (src/runtime/runtimeClient.ts) where it's rendered generically. + */ + result?: unknown; updatedAt: string; } +/** + * O1: structured log line severity, shown in the Logs tab's filter bar. + * `tool` is its own level (not folded into `info`) so the filter bar can + * isolate tool activity the way the mockup's log rows do. + */ +export type LogLevel = 'info' | 'warn' | 'error' | 'tool'; + +/** + * O1: which part of the run a log line came from - reuses the same + * taxonomy the mockup's log rows are tagged with. Derived from + * `ExecutionEvent.type` (see runRegistry.ts's `toLogEntries()`), extended + * with 'sandbox'/'checkpoint'/'debug' for events the raw ExecutionEvent + * stream doesn't carry a dedicated type for. + */ +export type LogPhase = 'trigger' | 'llm' | 'tool' | 'sandbox' | 'checkpoint' | 'approval' | 'debug'; + +export interface LogEntry { + id: string; + agentId: string; + timestamp: string; + level: LogLevel; + phase: LogPhase; + /** Tool name, when `phase === 'tool'`. */ + toolName?: string; + message: string; + detail?: unknown; +} + +/** + * O2: one span lifecycle notification, forwarded verbatim (real + * `Date.now()` timestamps, not synthetic ones) from the SDK's + * `TraceExporter.onSpanStart`/`onSpanEnd` (src/execution/tracing.ts). + */ +export interface SpanEvent { + id: string; + name: string; + parentId?: string; + startTime: number; + endTime?: number; + attributes: Record; +} + +/** + * O3: live step-through debugger state for one agent's run (see + * debugController.ts for exactly what "paused"/"breakpoint" mean given + * AgentExecutor's real control surface). + */ +export interface DebugStatePayload { + agentId: string; + paused: boolean; + atBreakpoint?: { phase: string; boundary: 'before' | 'after' }; + messages: unknown[]; + stepCount: number; + breakpoints: string[]; +} + /** * A single WS message pushed to `WS /agents/:id/stream` subscribers. * `type: 'status'` carries the full AgentRunStatusPayload (sent on every * status transition, and once immediately on connect). `type: 'event'` * forwards a raw AgentExecutor ExecutionEvent (start/text-complete/tool-call/ * tool-result/finish/error) for lightweight visibility into an in-progress - * run - the full log/trace UI is LOU-O's job, this is best-effort forwarding - * only, not a durable log. + * run. `type: 'log'`/`'span'`/`'debug'` are LOU-O's structured log stream + * (O1), span waterfall (O2) and step-debugger state (O3), all derived from + * the same run rather than a second parallel event system. */ export type StreamMessage = | { type: 'status'; payload: AgentRunStatusPayload } - | { type: 'event'; payload: Record }; + | { type: 'event'; payload: Record } + | { type: 'log'; payload: LogEntry } + | { type: 'span'; payload: SpanEvent } + | { type: 'debug'; payload: DebugStatePayload }; /** * Every `:id`/agentId this server touches ends up interpolated into a diff --git a/apps/agent-forge/server/wsServer.ts b/apps/agent-forge/server/wsServer.ts index 1ab9c73cc..7a535732d 100644 --- a/apps/agent-forge/server/wsServer.ts +++ b/apps/agent-forge/server/wsServer.ts @@ -14,7 +14,7 @@ import type { Server as HttpServer, IncomingMessage } from 'node:http'; import type { Socket } from 'node:net'; import { WebSocketServer, WebSocket } from 'ws'; import type { RunManager } from './runRegistry'; -import { isValidAgentId, type StreamMessage } from './types'; +import { isValidAgentId, type StreamMessage, type LogEntry, type SpanEvent, type DebugStatePayload } from './types'; const STREAM_PATH_RE = /^\/agents\/([^/]+)\/stream$/; @@ -42,6 +42,26 @@ export function attachWebSocketServer(server: HttpServer, runManager: RunManager for (const ws of sockets) send(ws, { type: 'event', payload }); }); + // O1/O2/O3: same fan-out pattern as 'status'/'event' above, over the + // same WS connection - no second channel. + runManager.on('log', (agentId: string, payload: LogEntry) => { + const sockets = subscribers.get(agentId); + if (!sockets) return; + for (const ws of sockets) send(ws, { type: 'log', payload }); + }); + + runManager.on('span', (agentId: string, payload: SpanEvent) => { + const sockets = subscribers.get(agentId); + if (!sockets) return; + for (const ws of sockets) send(ws, { type: 'span', payload }); + }); + + runManager.on('debug', (agentId: string, payload: DebugStatePayload) => { + const sockets = subscribers.get(agentId); + if (!sockets) return; + for (const ws of sockets) send(ws, { type: 'debug', payload }); + }); + server.on('upgrade', (req: IncomingMessage, socket: Socket, head: Buffer) => { const url = new URL(req.url ?? '', 'http://localhost'); const match = STREAM_PATH_RE.exec(url.pathname); diff --git a/apps/agent-forge/src/canvas/AgentNode.tsx b/apps/agent-forge/src/canvas/AgentNode.tsx index 6a94fe6e8..5b2088ba4 100644 --- a/apps/agent-forge/src/canvas/AgentNode.tsx +++ b/apps/agent-forge/src/canvas/AgentNode.tsx @@ -45,10 +45,14 @@ function rowsFor(node: AgentGraphNode): { k: string; v: string }[] { export interface AgentNodeData extends Record { graphNode: AgentGraphNode; onRename: (nodeId: string, label: string) => void; + /** O2: true when this node's log/span is the one currently selected in the drawer. */ + highlighted?: boolean; + /** O3: true when this node has an active breakpoint set. */ + hasBreakpoint?: boolean; } function AgentNodeImpl({ data, selected }: NodeProps) { - const { graphNode, onRename } = data as unknown as AgentNodeData; + const { graphNode, onRename, highlighted, hasBreakpoint } = data as unknown as AgentNodeData; const meta = NODE_META[graphNode.type]; const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(graphNode.label); @@ -61,7 +65,10 @@ function AgentNodeImpl({ data, selected }: NodeProps) { } return ( -
+
{meta.hasIn && } {meta.hasOut && }
diff --git a/apps/agent-forge/src/components/BottomDrawer.tsx b/apps/agent-forge/src/components/BottomDrawer.tsx index 289500146..bf3706b60 100644 --- a/apps/agent-forge/src/components/BottomDrawer.tsx +++ b/apps/agent-forge/src/components/BottomDrawer.tsx @@ -1,5 +1,9 @@ import { useAppState } from '../state/AppState'; import type { DrawerTab } from '../state/AppState'; +import { LogsPanel } from './debug/LogsPanel'; +import { TracePanel } from './debug/TracePanel'; +import { OutputPanel } from './debug/OutputPanel'; +import { DebugBar } from './debug/DebugBar'; const TABS: { id: DrawerTab; label: string }[] = [ { id: 'chat', label: 'Chat' }, @@ -10,13 +14,16 @@ const TABS: { id: DrawerTab; label: string }[] = [ ]; /** - * Tab-switching chrome only - each tab's real content is a later epic: - * Chat -> LOU-P, Logs/Trace -> LOU-O, Settings -> LOU-R. "Output" shows the - * current in-memory `AgentSpec` as JSON, which is real (LOU-L2 data), not a - * placeholder. + * Tab-switching chrome (LOU-L), now with real content for LOU-O's + * Logs/Trace/Output tabs: `LogsPanel` (O1, a live filtered/virtualized log + * feed), `TracePanel` (O2, a real span waterfall) and `OutputPanel` (O4, a + * collapsible JSON tree of the final/paused ExecutionResult). `DebugBar` + * (O3) surfaces above Trace whenever debug mode is on or a run is paused + * at a breakpoint. Chat (LOU-P) and Settings (LOU-R) are still later + * epics' jobs. */ export function BottomDrawer() { - const { drawerTab, setDrawerTab, spec } = useAppState(); + const { drawerTab, setDrawerTab } = useAppState(); return (
@@ -32,11 +39,15 @@ export function BottomDrawer() { ))}
-
+ {drawerTab === 'trace' && } +
{drawerTab === 'chat' &&
Chat with a running agent is wired up in LOU-P.
} - {drawerTab === 'logs' &&
Live execution logs are wired up in LOU-O (debug console).
} - {drawerTab === 'trace' &&
Execution trace spans are wired up in LOU-O (debug console).
} - {drawerTab === 'output' &&
{JSON.stringify(spec, null, 2)}
} + {drawerTab === 'logs' && } + {drawerTab === 'trace' && } + {drawerTab === 'output' && } {drawerTab === 'settings' &&
Provider keys and deploy target settings are wired up in LOU-R.
}
diff --git a/apps/agent-forge/src/components/CanvasArea.tsx b/apps/agent-forge/src/components/CanvasArea.tsx index 250225b1e..521d64c80 100644 --- a/apps/agent-forge/src/components/CanvasArea.tsx +++ b/apps/agent-forge/src/components/CanvasArea.tsx @@ -31,17 +31,33 @@ import { isEdgeTypeAllowed } from '../graph/connectionRules'; import { PALETTE_DRAG_MIME } from '../canvas/dnd'; import type { AgentGraphNodeType, AgentGraphSpec } from '../graph/types'; +function breakpointKeyForNode(n: AgentGraphSpec['nodes'][number]): string | undefined { + if (n.type === 'llm') return 'llm:before'; + if (n.type === 'tool') return `tool:${n.data.toolName}:before`; + return undefined; +} + function toRfNodes( graph: AgentGraphSpec, selectedNodeId: string | undefined, - onRename: (id: string, label: string) => void + onRename: (id: string, label: string) => void, + highlightedNodeId: string | undefined, + breakpoints: string[] ): Node[] { return graph.nodes.map((n) => ({ id: n.id, type: n.type, position: n.position, selected: n.id === selectedNodeId, - data: { graphNode: n, onRename }, + data: { + graphNode: n, + onRename, + highlighted: n.id === highlightedNodeId, + hasBreakpoint: (() => { + const key = breakpointKeyForNode(n); + return !!key && breakpoints.includes(key); + })(), + }, })); } @@ -57,7 +73,7 @@ function toRfEdges(graph: AgentGraphSpec): Edge[] { * state, so there is nowhere for the two to drift apart. */ function CanvasInner() { - const { graph, setGraph, selectedNodeId, setSelectedNodeId } = useAppState(); + const { graph, setGraph, selectedNodeId, setSelectedNodeId, highlightedNodeId, debugState } = useAppState(); const { screenToFlowPosition, fitView } = useReactFlow(); const [connectError, setConnectError] = useState(undefined); const errorTimer = useRef>(); @@ -75,7 +91,12 @@ function CanvasInner() { [setGraph] ); - const nodes = useMemo(() => toRfNodes(graph, selectedNodeId, onRename), [graph, selectedNodeId, onRename]); + const breakpoints = debugState?.breakpoints ?? []; + const breakpointsKey = breakpoints.join(','); + const nodes = useMemo( + () => toRfNodes(graph, selectedNodeId, onRename, highlightedNodeId, breakpoints), + [graph, selectedNodeId, onRename, highlightedNodeId, breakpointsKey] + ); const edges = useMemo(() => toRfEdges(graph), [graph]); const onNodesChange = useCallback( diff --git a/apps/agent-forge/src/components/Inspector.tsx b/apps/agent-forge/src/components/Inspector.tsx index 61bed94dc..086f45934 100644 --- a/apps/agent-forge/src/components/Inspector.tsx +++ b/apps/agent-forge/src/components/Inspector.tsx @@ -3,6 +3,40 @@ import { updateNodeData, renameNode } from '../canvas/graphMutations'; const KNOWN_PROVIDERS = ['mock', 'openai', 'anthropic', 'ollama', 'openrouter']; +/** + * O3: breakpoint toggle for an llm/tool node's Inspector panel, shown only + * in debug mode. Breaks "before" that node runs - see + * server/debugController.ts's doc comment for why `before`/`after` on an + * llm/tool hook is the real granularity AgentExecutor's control surface + * supports (no per-line/per-node-inside-a-call breakpoints). + */ +function BreakpointField({ + breakpointKey, + breakpoints, + setBreakpoints, +}: { + breakpointKey: string; + breakpoints: string[]; + setBreakpoints: (breakpoints: string[]) => Promise; +}) { + const on = breakpoints.includes(breakpointKey); + return ( +
+ + + void setBreakpoints(on ? breakpoints.filter((b) => b !== breakpointKey) : [...breakpoints, breakpointKey]) + } + > + {on ? 'Break before this node ●' : 'Break before this node'} + +
+ ); +} + /** * Inspector (LOU-M): reflects and edits whichever canvas node is currently * selected, wired through `AppState.graph`/`selectedNodeId` - the same @@ -14,7 +48,7 @@ const KNOWN_PROVIDERS = ['mock', 'openai', 'anthropic', 'ollama', 'openrouter']; * wiring) needs `AgentSpec` itself to grow those fields first. */ export function Inspector() { - const { graph, setGraph, selectedNodeId } = useAppState(); + const { graph, setGraph, selectedNodeId, debugMode, debugState, setBreakpoints } = useAppState(); const selected = graph.nodes.find((n) => n.id === selectedNodeId); if (!selected) { @@ -134,6 +168,16 @@ export function Inspector() {
)} + {debugMode && (selected.type === 'llm' || selected.type === 'tool') && ( + + )} +
Hooks (pre/post) are wired up in LOU-Q.
diff --git a/apps/agent-forge/src/components/Topbar.tsx b/apps/agent-forge/src/components/Topbar.tsx index 8dda36914..83e8679e7 100644 --- a/apps/agent-forge/src/components/Topbar.tsx +++ b/apps/agent-forge/src/components/Topbar.tsx @@ -12,7 +12,20 @@ const STATUS_LABEL: Record = { }; export function Topbar() { - const { spec, setSpec, save, dirty, agentId, runStatus, runAgent, stopAgent, approveAgent } = useAppState(); + const { + spec, + setSpec, + save, + dirty, + agentId, + runStatus, + runAgent, + stopAgent, + approveAgent, + debugMode, + setDebugMode, + setDrawerTab, + } = useAppState(); const fileInputRef = useRef(null); const [actionError, setActionError] = useState(undefined); @@ -119,8 +132,20 @@ export function Topbar() {
local · {spec.provider.type} provider
- {/* Debug is a chrome-only placeholder still - the full log/trace UI is LOU-O's job. */} - + + {paused && debugState?.messages && debugState.messages.length > 0 && ( +
+ Live message array ({debugState.messages.length}) + +
+ )} +
+ ); +} diff --git a/apps/agent-forge/src/components/debug/JsonTree.tsx b/apps/agent-forge/src/components/debug/JsonTree.tsx new file mode 100644 index 000000000..2f40a3e48 --- /dev/null +++ b/apps/agent-forge/src/components/debug/JsonTree.tsx @@ -0,0 +1,90 @@ +import { useState, type ReactNode } from 'react'; + +/** + * O4: hand-rolled, collapsible JSON tree - no dependency needed for this. + * `defaultExpandDepth` keeps the tree from dumping a giant `ExecutionResult` + * fully expanded on first render. + */ +export function JsonTree({ value, name, depth = 0, defaultExpandDepth = 2 }: { value: unknown; name?: string; depth?: number; defaultExpandDepth?: number }) { + const [expanded, setExpanded] = useState(depth < defaultExpandDepth); + + if (value === null) return null; + if (value === undefined) return undefined; + + if (Array.isArray(value)) { + if (value.length === 0) return []; + return ( +
+ + {name !== undefined && {name}: } + Array({value.length}) + {expanded && ( +
+ {value.map((item, i) => ( + + ))} +
+ )} +
+ ); + } + + if (typeof value === 'object') { + const entries = Object.entries(value as Record); + if (entries.length === 0) return {'{}'}; + return ( +
+ + {name !== undefined && {name}: } + {'{' + entries.length + '}'} + {expanded && ( +
+ {entries.map(([k, v]) => ( + + ))} +
+ )} +
+ ); + } + + if (typeof value === 'string') { + return ( + + "{value}" + + ); + } + if (typeof value === 'number') { + return ( + + {value} + + ); + } + if (typeof value === 'boolean') { + return ( + + {String(value)} + + ); + } + return ( + + {String(value)} + + ); +} + +function Row({ name, children }: { name?: string; children: ReactNode }) { + return ( +
+ {name !== undefined && {name}: } + {children} +
+ ); +} diff --git a/apps/agent-forge/src/components/debug/LogsPanel.tsx b/apps/agent-forge/src/components/debug/LogsPanel.tsx new file mode 100644 index 000000000..187ad4728 --- /dev/null +++ b/apps/agent-forge/src/components/debug/LogsPanel.tsx @@ -0,0 +1,102 @@ +import { useMemo, useState } from 'react'; +import { useAppState } from '../../state/AppState'; +import { filterLogs } from '../../state/logReducer'; +import type { LogEntry, LogLevel, LogPhase } from '../../runtime/runtimeClient'; +import { VirtualList } from './VirtualList'; + +const LEVELS: LogLevel[] = ['info', 'warn', 'error', 'tool']; +const PHASES: LogPhase[] = ['trigger', 'llm', 'tool', 'sandbox', 'checkpoint', 'approval', 'debug']; +const ROW_HEIGHT = 22; + +function formatTime(iso: string): string { + const d = new Date(iso); + return Number.isNaN(d.getTime()) ? '' : d.toTimeString().slice(0, 8); +} + +/** + * O1: live log feed. Reads `AppState.logs` (a capped ring buffer fed by + * `{type:'log'}` WS messages - see state/AppState.tsx and + * server/runRegistry.ts's `toLogEntries()`), rendered through `VirtualList` + * so a long run's log volume doesn't bloat the DOM. + */ +export function LogsPanel() { + const { logs, highlightedNodeId, setHighlightedNodeId, graph } = useAppState(); + const [levels, setLevels] = useState>(new Set()); + const [phases, setPhases] = useState>(new Set()); + const [search, setSearch] = useState(''); + + const filtered = useMemo(() => filterLogs(logs, { levels, phases, search }), [logs, levels, phases, search]); + + function toggle(set: Set, value: T, setSet: (s: Set) => void) { + const next = new Set(set); + if (next.has(value)) next.delete(value); + else next.add(value); + setSet(next); + } + + function nodeIdForLog(entry: LogEntry): string | undefined { + if (entry.phase === 'llm') return graph.nodes.find((n) => n.type === 'llm')?.id; + if (entry.phase === 'tool' && entry.toolName) { + return graph.nodes.find((n) => n.type === 'tool' && n.data.toolName === entry.toolName)?.id; + } + return undefined; + } + + return ( +
+
+ {LEVELS.map((lvl) => ( + + ))} + {PHASES.map((phase) => ( + + ))} + setSearch(e.target.value)} + /> +
+ { + const nodeId = nodeIdForLog(entry); + return ( +
setHighlightedNodeId(nodeId)} + title={entry.detail ? JSON.stringify(entry.detail) : undefined} + > + {formatTime(entry.timestamp)} + {entry.level} + + [{entry.phase}] {entry.message} + +
+ ); + }} + /> +
+ ); +} diff --git a/apps/agent-forge/src/components/debug/OutputPanel.tsx b/apps/agent-forge/src/components/debug/OutputPanel.tsx new file mode 100644 index 000000000..ef56e1d18 --- /dev/null +++ b/apps/agent-forge/src/components/debug/OutputPanel.tsx @@ -0,0 +1,36 @@ +import { useAppState } from '../../state/AppState'; +import { JsonTree } from './JsonTree'; + +/** + * O4: the final (or paused-for-approval) `ExecutionResult` as a + * collapsible JSON tree - `runStatus.result` (see server/runRegistry.ts's + * `handleRunSettled()`, which keeps the full ExecutionResult on the entry + * for both the 'stopped' and 'paused' terminal states, not just the final + * text). Falls back to the current in-memory `AgentSpec` (LOU-L2 data) + * before any run has happened, same as the LOU-L placeholder did. + */ +export function OutputPanel() { + const { runStatus, spec } = useAppState(); + + if (runStatus?.result) { + return ( +
+
+ +
+ +
+ ); + } + + return ( +
+
+ +
+ +
+ ); +} diff --git a/apps/agent-forge/src/components/debug/TracePanel.tsx b/apps/agent-forge/src/components/debug/TracePanel.tsx new file mode 100644 index 000000000..87178fdc9 --- /dev/null +++ b/apps/agent-forge/src/components/debug/TracePanel.tsx @@ -0,0 +1,79 @@ +import { useMemo, useState } from 'react'; +import { useAppState } from '../../state/AppState'; +import { orderSpansForWaterfall } from '../../state/spanReducer'; +import type { SpanEvent } from '../../runtime/runtimeClient'; + +/** + * O2: real span waterfall, driven by `{type:'span'}` WS messages forwarded + * from the SDK's `TraceExporter.onSpanStart`/`onSpanEnd` hooks (see + * server/runRegistry.ts's `makeTraceExporter()`) - bar position/width are + * real `Date.now()` timestamps, not synthetic ones. Clicking a span + * highlights the corresponding canvas node (`llm`/`tool.call`'s attribute + * carries the tool name) and jumps the Logs tab's highlight to the same + * node, via the shared `AppState.highlightedNodeId`. + */ +export function TracePanel() { + const { spans, highlightedNodeId, setHighlightedNodeId, graph, setDrawerTab } = useAppState(); + const [selectedSpanId, setSelectedSpanId] = useState(undefined); + + const ordered = useMemo(() => orderSpansForWaterfall(spans), [spans]); + + const { minStart, maxEnd } = useMemo(() => { + if (ordered.length === 0) return { minStart: 0, maxEnd: 1 }; + const starts = ordered.map((s) => s.startTime); + const ends = ordered.map((s) => s.endTime ?? Date.now()); + return { minStart: Math.min(...starts), maxEnd: Math.max(...ends) }; + }, [ordered]); + + const totalMs = Math.max(1, maxEnd - minStart); + + function nodeIdForSpan(span: SpanEvent): string | undefined { + if (span.name === 'llm.generate') return graph.nodes.find((n) => n.type === 'llm')?.id; + if (span.name === 'tool.call') { + const toolName = span.attributes.toolName as string | undefined; + if (toolName) return graph.nodes.find((n) => n.type === 'tool' && n.data.toolName === toolName)?.id; + } + return undefined; + } + + function handleSelect(span: SpanEvent) { + setSelectedSpanId(span.id); + setHighlightedNodeId(nodeIdForSpan(span)); + } + + if (ordered.length === 0) { + return
No spans yet - run the agent to see its span waterfall here.
; + } + + return ( +
+ {ordered.map((span) => { + const startPct = ((span.startTime - minStart) / totalMs) * 100; + const durationMs = (span.endTime ?? Date.now()) - span.startTime; + const widthPct = Math.max(0.5, (durationMs / totalMs) * 100); + const nodeId = nodeIdForSpan(span); + const isHighlighted = nodeId && nodeId === highlightedNodeId; + return ( +
{ + handleSelect(span); + setDrawerTab('logs'); + }} + title={JSON.stringify(span.attributes)} + > + {span.name} + + + + {span.endTime === undefined ? 'running' : `${durationMs}ms`} +
+ ); + })} +
+ ); +} diff --git a/apps/agent-forge/src/components/debug/VirtualList.tsx b/apps/agent-forge/src/components/debug/VirtualList.tsx new file mode 100644 index 000000000..b2e043429 --- /dev/null +++ b/apps/agent-forge/src/components/debug/VirtualList.tsx @@ -0,0 +1,62 @@ +import { useRef, useState, type ReactNode, type UIEvent } from 'react'; + +export interface VirtualListProps { + items: T[]; + rowHeight: number; + renderRow: (item: T, index: number) => ReactNode; + overscan?: number; + emptyMessage?: string; + className?: string; +} + +/** + * O1's "lightweight windowing technique" for the live log feed - a real run + * can produce far more rows than fit comfortably in the DOM at once (up to + * `LOG_BUFFER_CAPACITY`, see state/logReducer.ts), so this only renders the + * rows currently scrolled into view (plus `overscan` rows of padding either + * side) rather than every row in `items`. No virtualization library: fixed + * `rowHeight` rows + a `scrollTop`-driven slice is enough for this app's + * uniform-height log/trace rows, and keeps this dependency-free. + */ +export function VirtualList({ + items, + rowHeight, + renderRow, + overscan = 8, + emptyMessage, + className, +}: VirtualListProps) { + const containerRef = useRef(null); + const [scrollTop, setScrollTop] = useState(0); + const [viewportHeight, setViewportHeight] = useState(300); + + function onScroll(e: UIEvent) { + setScrollTop(e.currentTarget.scrollTop); + } + + // Re-measure the viewport on mount / whenever the drawer resizes. + const measuredRef = (node: HTMLDivElement | null) => { + containerRef.current = node; + if (node) setViewportHeight(node.clientHeight || 300); + }; + + if (items.length === 0) { + return
{emptyMessage ?? 'Nothing yet.'}
; + } + + const totalHeight = items.length * rowHeight; + const firstVisible = Math.max(0, Math.floor(scrollTop / rowHeight) - overscan); + const visibleCount = Math.ceil(viewportHeight / rowHeight) + overscan * 2; + const lastVisible = Math.min(items.length, firstVisible + visibleCount); + const offsetY = firstVisible * rowHeight; + + return ( +
+
+
+ {items.slice(firstVisible, lastVisible).map((item, i) => renderRow(item, firstVisible + i))} +
+
+
+ ); +} diff --git a/apps/agent-forge/src/components/layout.css b/apps/agent-forge/src/components/layout.css index b9405e1bd..86698e366 100644 --- a/apps/agent-forge/src/components/layout.css +++ b/apps/agent-forge/src/components/layout.css @@ -593,6 +593,149 @@ color: var(--text-muted); } +/* ---------- O1: Logs tab ---------- */ +.logs-panel { + display: flex; + flex-direction: column; + height: 100%; +} +.logs-filter-bar { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 8px; + border-bottom: 1px solid var(--border); + flex: none; + font-family: var(--font-ui); +} +.logs-filter-bar input[type='search'] { + flex: 1; + min-width: 100px; + background: var(--surface-2); + border: 1px solid var(--border); + color: var(--text); + border-radius: 6px; + padding: 4px 8px; + font-size: 11px; +} +.logs-filter-toggle { + border: 1px solid var(--border); + background: var(--surface-2); + color: var(--text-muted); + border-radius: 12px; + padding: 2px 8px; + font-size: 10.5px; + font-weight: 600; + cursor: pointer; +} +.logs-filter-toggle.on { + border-color: var(--accent); + color: var(--accent); + background: var(--accent-soft); +} +.logs-empty { + padding: 16px; + color: var(--text-faint); + font-family: var(--font-ui); +} +.logs-viewport { + flex: 1; + overflow-y: auto; + position: relative; +} +.log-line { display: grid; grid-template-columns: 64px 60px 1fr; gap: 10px; padding: 3px 12px; border-radius: 5px; align-items: start; cursor: pointer; } +.log-line:hover { background: var(--surface-2); } +.log-line.highlighted { background: var(--accent-soft); } +.log-time { color: var(--text-faint); } +.log-level { font-weight: 700; } +.lvl-info { color: var(--info); } +.lvl-warn { color: var(--warning); } +.lvl-error { color: var(--danger); } +.lvl-tool { color: var(--accent); } +.log-msg { color: var(--text); white-space: pre-wrap; word-break: break-word; } +.log-msg b { color: var(--text-muted); font-weight: 600; } + +/* ---------- O2: Trace tab ---------- */ +.trace-panel { + display: flex; + flex-direction: column; + height: 100%; + overflow-y: auto; +} +.trace-empty { + padding: 16px; + color: var(--text-faint); + font-family: var(--font-ui); +} +.trace-row { display: flex; align-items: center; gap: 8px; padding: 5px 12px; cursor: pointer; border-radius: 5px; } +.trace-row:hover, .trace-row.selected { background: var(--surface-2); } +.trace-bar-track { position: relative; flex: 1; height: 16px; background: var(--surface-2); border-radius: 4px; overflow: hidden; } +.trace-bar { position: absolute; top: 0; bottom: 0; border-radius: 4px; background: var(--accent); } +.trace-bar.pending { background: repeating-linear-gradient(45deg, var(--accent), var(--accent) 4px, transparent 4px, transparent 8px); } +.trace-name { width: 150px; flex: none; color: var(--text); font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.trace-dur { width: 56px; flex: none; text-align: right; color: var(--text-muted); } + +/* ---------- O4: Output tab ---------- */ +.output-json { padding: 10px 14px; white-space: pre-wrap; word-break: break-word; font-family: var(--font-mono); font-size: 11.5px; } +.json-key { color: var(--info); } +.json-str { color: var(--success); } +.json-num { color: var(--warning); } +.json-bool { color: var(--accent); } +.json-null { color: var(--text-faint); } +.json-node { display: block; } +.json-toggle { + background: none; + border: none; + color: var(--text-faint); + cursor: pointer; + font-family: var(--font-mono); + padding: 0 3px 0 0; + font-size: 11px; +} +.json-children { + margin-left: 14px; + border-left: 1px dashed var(--border); + padding-left: 8px; +} + +/* ---------- O3: step-through debugger ---------- */ +.debug-bar { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + background: var(--warning-soft); + border-bottom: 1px solid var(--border); + font-size: 11px; + font-family: var(--font-ui); +} +.debug-bar .step-count { + color: var(--text-muted); + font-family: var(--font-mono); +} +.breakpoint-toggle { + display: inline-flex; + align-items: center; + gap: 5px; + border: 1px solid var(--border); + background: var(--surface-2); + border-radius: 20px; + padding: 4px 9px; + font-size: 11px; + cursor: pointer; +} +.breakpoint-toggle.on { + border-color: var(--danger); + color: var(--danger); + background: var(--danger-soft); +} +.rf-node.has-breakpoint { + box-shadow: 0 0 0 2px var(--danger); +} +.rf-node.highlighted { + box-shadow: 0 0 0 3px var(--accent), var(--shadow); +} + @media (max-width: 860px) { .shell { grid-template-columns: 0 1fr 0; diff --git a/apps/agent-forge/src/runtime/runtimeClient.ts b/apps/agent-forge/src/runtime/runtimeClient.ts index 1c3e8f843..ac665f617 100644 --- a/apps/agent-forge/src/runtime/runtimeClient.ts +++ b/apps/agent-forge/src/runtime/runtimeClient.ts @@ -25,12 +25,49 @@ export interface AgentRunStatusPayload { pendingApproval?: PendingApprovalInfo; error?: string; resultText?: string; + /** O4: full ExecutionResult (messages/toolCalls/usage/steps/finishReason) once a run completes or pauses. */ + result?: unknown; updatedAt: string; } +export type LogLevel = 'info' | 'warn' | 'error' | 'tool'; +export type LogPhase = 'trigger' | 'llm' | 'tool' | 'sandbox' | 'checkpoint' | 'approval' | 'debug'; + +export interface LogEntry { + id: string; + agentId: string; + timestamp: string; + level: LogLevel; + phase: LogPhase; + toolName?: string; + message: string; + detail?: unknown; +} + +export interface SpanEvent { + id: string; + name: string; + parentId?: string; + startTime: number; + endTime?: number; + attributes: Record; +} + +export interface DebugStatePayload { + agentId: string; + paused: boolean; + atBreakpoint?: { phase: string; boundary: 'before' | 'after' }; + messages: unknown[]; + stepCount: number; + breakpoints: string[]; +} + export type StreamMessage = | { type: 'status'; payload: AgentRunStatusPayload } - | { type: 'event'; payload: Record }; + | { type: 'event'; payload: Record } + | { type: 'log'; payload: LogEntry } + | { type: 'span'; payload: SpanEvent } + | { type: 'debug'; payload: DebugStatePayload }; /** Same-origin default: `loushy studio` prints the API server's own URL, but in dev the Vite server proxies to it (see vite.config.ts). */ const DEFAULT_BASE_URL = ''; @@ -102,6 +139,31 @@ export class RuntimeClient { }); } + /** O3: current breakpoints + pause state for `agentId` (see debugController.ts). */ + async debugState(agentId: string): Promise { + const res = await fetch(`${this.baseUrl}/agents/${encodeURIComponent(agentId)}/debug`); + return (await res.json()) as DebugStatePayload; + } + + async setBreakpoints(agentId: string, breakpoints: string[]): Promise { + const res = await fetch(`${this.baseUrl}/agents/${encodeURIComponent(agentId)}/debug/breakpoints`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ breakpoints }), + }); + return (await res.json()) as DebugStatePayload; + } + + async continueRun(agentId: string): Promise { + const res = await fetch(`${this.baseUrl}/agents/${encodeURIComponent(agentId)}/debug/continue`, { method: 'POST' }); + return (await res.json()) as DebugStatePayload; + } + + async stepRun(agentId: string): Promise { + const res = await fetch(`${this.baseUrl}/agents/${encodeURIComponent(agentId)}/debug/step`, { method: 'POST' }); + return (await res.json()) as DebugStatePayload; + } + /** * Subscribes to `WS /agents/:id/stream`. Returns an unsubscribe function. * Reconnects are NOT handled here - a dropped connection just stops diff --git a/apps/agent-forge/src/state/AppState.tsx b/apps/agent-forge/src/state/AppState.tsx index 1548d236f..09eeae02e 100644 --- a/apps/agent-forge/src/state/AppState.tsx +++ b/apps/agent-forge/src/state/AppState.tsx @@ -7,7 +7,15 @@ import { graphToSpec } from '../graph/graphToSpec'; import { specToGraph } from '../graph/specToGraph'; import type { AgentGraphSpec } from '../graph/types'; import { graphFromTemplate, type TemplateId } from '../canvas/templates'; -import { runtimeClient, type AgentRunStatusPayload } from '../runtime/runtimeClient'; +import { + runtimeClient, + type AgentRunStatusPayload, + type LogEntry, + type SpanEvent, + type DebugStatePayload, +} from '../runtime/runtimeClient'; +import { appendLog } from './logReducer'; +import { upsertSpan } from './spanReducer'; const AUTOSAVE_DEBOUNCE_MS = 800; @@ -76,6 +84,30 @@ interface AppState { * has never reported a status (equivalent to 'idle'). */ agentStatuses: Record; + + /** + * O1: this agent's live log feed (LOU-O), a capped ring buffer reset on + * every agent switch and on every fresh `runAgent()` call (so an old + * run's logs don't linger under a new one, matching the mockup's + * per-run log panel). + */ + logs: LogEntry[]; + /** O2: this agent's live span list (LOU-O), reset the same way as `logs`. */ + spans: SpanEvent[]; + /** O3: this agent's live step-debugger state (LOU-O), or undefined before the first `/debug` fetch/push. */ + debugState: DebugStatePayload | undefined; + /** Topbar's "Debug" toggle (O3) - when true, the canvas/inspector expose breakpoint controls. */ + debugMode: boolean; + setDebugMode: (on: boolean) => void; + /** Replaces this agent's breakpoint set (O3) - see debugController.ts for the `llm:before`/`tool::before` key shape. */ + setBreakpoints: (breakpoints: string[]) => Promise; + /** Resumes a run paused at a breakpoint. */ + continueDebug: () => Promise; + /** Resumes a run paused at a breakpoint, or arms a pause at the next LLM/tool boundary. */ + stepDebug: () => Promise; + /** Node (on the canvas) currently highlighted because its log/span is selected in the drawer (O2). */ + highlightedNodeId: string | undefined; + setHighlightedNodeId: (nodeId: string | undefined) => void; } const AppStateContext = createContext(undefined); @@ -196,6 +228,11 @@ export function AppStateProvider({ children }: { children: ReactNode }) { const [runStatus, setRunStatus] = useState(undefined); const [agentStatuses, setAgentStatuses] = useState>({}); + const [logs, setLogs] = useState([]); + const [spans, setSpans] = useState([]); + const [debugState, setDebugState] = useState(undefined); + const [debugMode, setDebugMode] = useState(false); + const [highlightedNodeId, setHighlightedNodeId] = useState(undefined); // Keep one WS subscription per known agent id (for the LeftRail's status // pills), added/removed as `agents` (the saved-agent list) changes - @@ -222,11 +259,19 @@ export function AppStateProvider({ children }: { children: ReactNode }) { // the server's stream is already scoped per agent id. useEffect(() => { setRunStatus(undefined); + setLogs([]); + setSpans([]); + setDebugState(undefined); + setHighlightedNodeId(undefined); const unsubscribe = runtimeClient.subscribe(agentId, (message) => { if (message.type === 'status') setRunStatus(message.payload); + else if (message.type === 'log') setLogs((prev) => appendLog(prev, message.payload)); + else if (message.type === 'span') setSpans((prev) => upsertSpan(prev, message.payload)); + else if (message.type === 'debug') setDebugState(message.payload); }); - // Also fetch the current status immediately in case the WS connection - // is slow to open - avoids a flash of "unknown" status on agent switch. + // Also fetch the current status/debug-state immediately in case the WS + // connection is slow to open - avoids a flash of "unknown" status (or a + // stale breakpoint list) on agent switch. runtimeClient .status(agentId) .then(setRunStatus) @@ -236,16 +281,41 @@ export function AppStateProvider({ children }: { children: ReactNode }) { // Run/Stop surface that as a real error when clicked instead of // failing silently here. }); + runtimeClient.debugState(agentId).then(setDebugState).catch(() => {}); return unsubscribe; }, [agentId]); const runAgent = useCallback( async (input: string) => { + // Reset the log/span feed on every Run click so stale rows from a + // previous run of this same agent don't linger alongside the new + // ones (including a checkpoint-resumed run - a fresh feed for it is + // preferable to conflating it with the aborted run's). + setLogs([]); + setSpans([]); await runtimeClient.run(agentId, input, spec); }, [agentId, spec] ); + const setBreakpoints = useCallback( + async (breakpoints: string[]) => { + const next = await runtimeClient.setBreakpoints(agentId, breakpoints); + setDebugState(next); + }, + [agentId] + ); + + const continueDebug = useCallback(async () => { + const next = await runtimeClient.continueRun(agentId); + setDebugState(next); + }, [agentId]); + + const stepDebug = useCallback(async () => { + const next = await runtimeClient.stepRun(agentId); + setDebugState(next); + }, [agentId]); + const stopAgent = useCallback(async () => { await runtimeClient.stop(agentId); }, [agentId]); @@ -284,6 +354,16 @@ export function AppStateProvider({ children }: { children: ReactNode }) { stopAgent, approveAgent, agentStatuses, + logs, + spans, + debugState, + debugMode, + setDebugMode, + setBreakpoints, + continueDebug, + stepDebug, + highlightedNodeId, + setHighlightedNodeId, }; return {children}; diff --git a/apps/agent-forge/src/state/__tests__/logReducer.test.ts b/apps/agent-forge/src/state/__tests__/logReducer.test.ts new file mode 100644 index 000000000..374a9c6b9 --- /dev/null +++ b/apps/agent-forge/src/state/__tests__/logReducer.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from 'vitest'; +import { appendLog, filterLogs } from '../logReducer'; +import type { LogEntry } from '../../runtime/runtimeClient'; + +function entry(overrides: Partial = {}): LogEntry { + return { + id: overrides.id ?? Math.random().toString(36), + agentId: 'agent-1', + timestamp: new Date().toISOString(), + level: 'info', + phase: 'trigger', + message: 'hello', + ...overrides, + }; +} + +describe('appendLog', () => { + it('appends within capacity', () => { + const buf = appendLog([entry({ id: '1' })], entry({ id: '2' }), 10); + expect(buf.map((e) => e.id)).toEqual(['1', '2']); + }); + + it('evicts the oldest entry once at capacity, keeping the buffer at exactly `capacity`', () => { + let buf: LogEntry[] = [entry({ id: 'a' }), entry({ id: 'b' }), entry({ id: 'c' })]; + buf = appendLog(buf, entry({ id: 'd' }), 3); + expect(buf.map((e) => e.id)).toEqual(['b', 'c', 'd']); + expect(buf).toHaveLength(3); + }); + + it('never mutates the input buffer', () => { + const original = [entry({ id: '1' })]; + const next = appendLog(original, entry({ id: '2' }), 10); + expect(original).toHaveLength(1); + expect(next).not.toBe(original); + }); +}); + +describe('filterLogs', () => { + const buf: LogEntry[] = [ + entry({ id: '1', level: 'info', phase: 'trigger', message: 'run started' }), + entry({ id: '2', level: 'tool', phase: 'tool', toolName: 'current-date', message: 'Tool call: current-date' }), + entry({ id: '3', level: 'error', phase: 'tool', toolName: 'current-date', message: 'Tool failed: boom' }), + ]; + + it('returns everything when no filter is set', () => { + expect(filterLogs(buf, {})).toHaveLength(3); + }); + + it('filters by level', () => { + const result = filterLogs(buf, { levels: new Set(['error']) }); + expect(result.map((e) => e.id)).toEqual(['3']); + }); + + it('filters by phase', () => { + const result = filterLogs(buf, { phases: new Set(['tool']) }); + expect(result.map((e) => e.id)).toEqual(['2', '3']); + }); + + it('filters by case-insensitive search text against message and toolName', () => { + expect(filterLogs(buf, { search: 'BOOM' }).map((e) => e.id)).toEqual(['3']); + expect(filterLogs(buf, { search: 'current-date' }).map((e) => e.id)).toEqual(['2', '3']); + }); + + it('combines level/phase/search filters with AND semantics', () => { + const result = filterLogs(buf, { levels: new Set(['tool']), phases: new Set(['tool']), search: 'call' }); + expect(result.map((e) => e.id)).toEqual(['2']); + }); +}); diff --git a/apps/agent-forge/src/state/__tests__/spanReducer.test.ts b/apps/agent-forge/src/state/__tests__/spanReducer.test.ts new file mode 100644 index 000000000..0b784698d --- /dev/null +++ b/apps/agent-forge/src/state/__tests__/spanReducer.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from 'vitest'; +import { upsertSpan, orderSpansForWaterfall } from '../spanReducer'; +import type { SpanEvent } from '../../runtime/runtimeClient'; + +function span(overrides: Partial): SpanEvent { + return { + id: 'span-1', + name: 'agent.run', + startTime: 0, + attributes: {}, + ...overrides, + }; +} + +describe('upsertSpan', () => { + it('appends a new span id', () => { + const spans = upsertSpan([], span({ id: 'a' })); + expect(spans).toHaveLength(1); + }); + + it('merges an onSpanEnd notification onto the same row an onSpanStart created, by id', () => { + const started = [span({ id: 'a', startTime: 100 })]; + const ended = upsertSpan(started, span({ id: 'a', startTime: 100, endTime: 150 })); + expect(ended).toHaveLength(1); + expect(ended[0].endTime).toBe(150); + }); + + it('never mutates the input array', () => { + const original = [span({ id: 'a' })]; + const next = upsertSpan(original, span({ id: 'a', endTime: 10 })); + expect(original[0].endTime).toBeUndefined(); + expect(next).not.toBe(original); + }); +}); + +describe('orderSpansForWaterfall', () => { + it('orders root spans first, each followed depth-first by its children', () => { + const spans: SpanEvent[] = [ + span({ id: 'tool', name: 'tool.call', parentId: 'root', startTime: 20 }), + span({ id: 'llm', name: 'llm.generate', parentId: 'root', startTime: 10 }), + span({ id: 'root', name: 'agent.run', startTime: 0 }), + ]; + const ordered = orderSpansForWaterfall(spans); + expect(ordered.map((s) => s.id)).toEqual(['root', 'llm', 'tool']); + }); + + it('sorts siblings by start time', () => { + const spans: SpanEvent[] = [ + span({ id: 'b', parentId: 'root', startTime: 20 }), + span({ id: 'a', parentId: 'root', startTime: 10 }), + span({ id: 'root', startTime: 0 }), + ]; + expect(orderSpansForWaterfall(spans).map((s) => s.id)).toEqual(['root', 'a', 'b']); + }); +}); diff --git a/apps/agent-forge/src/state/logReducer.ts b/apps/agent-forge/src/state/logReducer.ts new file mode 100644 index 000000000..336066a72 --- /dev/null +++ b/apps/agent-forge/src/state/logReducer.ts @@ -0,0 +1,44 @@ +import type { LogEntry } from '../runtime/runtimeClient'; + +/** + * O1: client-side ring buffer cap. A real run can produce far more than the + * mockup's static 8 log lines - this bounds memory/DOM growth instead of + * letting `logs` grow unbounded for the lifetime of a long-running agent. + */ +export const LOG_BUFFER_CAPACITY = 2000; + +/** + * Appends `entry` to `buffer`, evicting the oldest entries once + * `LOG_BUFFER_CAPACITY` is exceeded. Pure/reducer-shaped (no mutation of + * `buffer`) so it's trivial to unit test and to call from a `setState` + * updater. + */ +export function appendLog(buffer: LogEntry[], entry: LogEntry, capacity = LOG_BUFFER_CAPACITY): LogEntry[] { + if (buffer.length < capacity) { + return [...buffer, entry]; + } + // Drop from the front once at capacity, then push - keeps the buffer at + // exactly `capacity` rather than letting it grow one-then-trim-many. + return [...buffer.slice(buffer.length - capacity + 1), entry]; +} + +export interface LogFilter { + levels?: Set; + phases?: Set; + /** Case-insensitive substring match against `message` (and `toolName`, if present). */ + search?: string; +} + +/** Pure filter used by both the reducer's derived view and its own tests - no component-only logic. */ +export function filterLogs(buffer: LogEntry[], filter: LogFilter): LogEntry[] { + const search = filter.search?.trim().toLowerCase(); + return buffer.filter((entry) => { + if (filter.levels && filter.levels.size > 0 && !filter.levels.has(entry.level)) return false; + if (filter.phases && filter.phases.size > 0 && !filter.phases.has(entry.phase)) return false; + if (search) { + const haystack = `${entry.message} ${entry.toolName ?? ''}`.toLowerCase(); + if (!haystack.includes(search)) return false; + } + return true; + }); +} diff --git a/apps/agent-forge/src/state/spanReducer.ts b/apps/agent-forge/src/state/spanReducer.ts new file mode 100644 index 000000000..c118baf64 --- /dev/null +++ b/apps/agent-forge/src/state/spanReducer.ts @@ -0,0 +1,37 @@ +import type { SpanEvent } from '../runtime/runtimeClient'; + +/** + * O2: folds a stream of `{type:'span'}` WS messages (one per + * onSpanStart/onSpanEnd notification - see server/runRegistry.ts's + * `makeTraceExporter()`) into a flat list of spans, upserting by `id` so + * the 'end' notification (which carries `endTime`) merges onto the same + * row the 'start' notification created rather than appending a duplicate. + */ +export function upsertSpan(spans: SpanEvent[], span: SpanEvent): SpanEvent[] { + const idx = spans.findIndex((s) => s.id === span.id); + if (idx === -1) return [...spans, span]; + const next = spans.slice(); + next[idx] = { ...spans[idx], ...span }; + return next; +} + +/** Root spans first, each followed depth-first by its children, sorted by start time within a level - a stable waterfall row order. */ +export function orderSpansForWaterfall(spans: SpanEvent[]): SpanEvent[] { + const byParent = new Map(); + for (const span of spans) { + const list = byParent.get(span.parentId) ?? []; + list.push(span); + byParent.set(span.parentId, list); + } + for (const list of byParent.values()) list.sort((a, b) => a.startTime - b.startTime); + + const ordered: SpanEvent[] = []; + function visit(parentId: string | undefined) { + for (const span of byParent.get(parentId) ?? []) { + ordered.push(span); + visit(span.id); + } + } + visit(undefined); + return ordered; +}