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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased] - 2026-09-28

### Added
- Agent Forge keeps the traces of its runs (M5b, #227): every run is written with `fileTraceExporter()` to `.lousho/agents/<agent id>/traces` (the files `lousho traces` reads), and the Trace tab gets a list of the agent's past runs (time, duration, model calls, tokens, cost, status) with a "Live" entry while a run is active; choosing one opens its spans in the waterfall, with span kind and error status. New server routes `GET /agents/:id/traces?limit=N` and `GET /agents/:id/traces/:traceId`; the server reads only inside the agent's trace folder. The live `span` WebSocket messages now carry `kind` and `status` (optional fields of `SpanEvent`). See [Agent Forge](docs/agent-forge.md).

### Changed
- CI peer matrix (LOU-M8, #231): the `typecheck-ai7` and `typecheck-zod4` jobs are replaced by one `peers` job with five entries, each installed for real on top of the default install and run through `tsc`, `test:types`, both builds and `npx vitest run`: `ai4-zod4`, `ai6-zod3`, `ai6-zod4`, `ai7-zod3`, `ai7-zod4`. The two zod 4 entries on `ai` 6/7 also install `ollama-ai-provider-v2` (3.x / 4.x), and the new `src/providers/ollamaV2.contract.test.ts` runs `OllamaProvider` against the real package and a local fake Ollama server (`generate()`, `stream()`, a tool-call turn through `createAgent().send()` and `.stream()`). The `ai-v6` dev alias replaces the hand-made `ai` 6 stand-in in `aiMajorPeers.test.ts`.
- `lousho init --provider ollama` now scaffolds `ai@^7.0.0` with `ollama-ai-provider-v2@^4.0.0` and `zod@^4.0.0` (it was `ai@^4.3.19` with `ollama-ai-provider@^1.2.0` and zod 3). Existing projects are not touched; to stay on the old pairing keep `ai@^4.3.19` and `ollama-ai-provider@^1.2.0`.
Expand Down
4 changes: 4 additions & 0 deletions apps/agent-forge/server/__tests__/runRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,10 @@ describe('RunManager', () => {
expect(runSpans).toHaveLength(2); // start + end
const [startSpan, endSpan] = runSpans;
expect(startSpan.endTime).toBeUndefined();
// M5b: span kind is forwarded, and status when the SDK set one (only a failed span has it).
expect(startSpan.kind).toBe('internal');
expect(spans.every((s) => s.status === undefined)).toBe(true);
expect(spans.find((s) => s.attributes['gen_ai.operation.name'] === 'chat')?.kind).toBe('client');
expect(endSpan.endTime).toBeGreaterThanOrEqual(endSpan.startTime);
expect(spans.some((s) => s.attributes['gen_ai.operation.name'] === 'chat')).toBe(true);
expect(
Expand Down
149 changes: 149 additions & 0 deletions apps/agent-forge/server/__tests__/traces.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
/**
* M5b: persisted trace history - a run writes the SDK's trace files under the
* agent's folder and `GET /agents/:id/traces[/:traceId]` reads them back.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import request from 'supertest';
import { listTraces } from '@lousho/build-ai-agent/traces';
import type { AgentSpec } from '@lousho/build-ai-agent';
import { createApp } from '../app';
import { RunManager } from '../runRegistry';
import { FileCheckpointStore } from '../checkpointStore';
import { FileApprovalStore } from '../approvalStore';
import { createFsAgentStore } from '../../src/persistence/fsAgentStore';
import type { AgentRunStatusPayload, TraceDetailPayload, TraceSummaryPayload } from '../../shared/wireTypes';

const SPEC: AgentSpec = {
name: 'trace-agent',
prompt: 'You are a helpful agent.',
provider: { type: 'mock', model: 'mock-1' },
};

function stopped(runManager: RunManager, agentId: string): Promise<AgentRunStatusPayload> {
if (runManager.status(agentId).status === 'stopped') return Promise.resolve(runManager.status(agentId));
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`Timed out waiting for ${agentId}`)), 3000);
const onStatus = (payload: AgentRunStatusPayload) => {
if (payload.agentId !== agentId || payload.status !== 'stopped') return;
clearTimeout(timer);
runManager.off('status', onStatus);
resolve(payload);
};
runManager.on('status', onStatus);
});
}

describe('M5b trace history routes', () => {
let baseDir: string;
let runManager: RunManager;
let app: ReturnType<typeof createApp>;

beforeEach(() => {
baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lou-m5b-test-'));
const agentStore = createFsAgentStore(baseDir);
runManager = new RunManager({
baseDir,
checkpointStore: new FileCheckpointStore(baseDir),
approvalStore: new FileApprovalStore(baseDir),
loadSpec: (id) => agentStore.load(id),
saveSpec: (id, spec) => agentStore.save(id, spec),
});
app = createApp({ agentStore, runManager, baseDir });
});

afterEach(() => {
fs.rmSync(baseDir, { recursive: true, force: true });
});

async function finishedRun(agentId: string, input = 'What is the weather?'): Promise<void> {
await request(app).post(`/agents/${agentId}/run`).send({ input, spec: SPEC }).expect(202);
await stopped(runManager, agentId);
}

it('a run writes a trace file under the agent folder, readable with the SDK reader', async () => {
await finishedRun('weather');
const dir = path.join(baseDir, '.lousho', 'agents', 'weather', 'traces');
expect(fs.existsSync(dir)).toBe(true);
const summaries = await listTraces({ dir });
expect(summaries).toHaveLength(1);
expect(summaries[0].file.startsWith(dir)).toBe(true);
});

it('GET /agents/:id/traces lists the runs newest first, without file paths', async () => {
await finishedRun('weather');
await finishedRun('weather', 'And tomorrow?');
const res = await request(app).get('/agents/weather/traces').expect(200);
const traces = (res.body as { traces: TraceSummaryPayload[] }).traces;
expect(traces).toHaveLength(2);
expect(traces[0].startTime).toBeGreaterThanOrEqual(traces[1].startTime);
expect(traces[0].status).toBe('ok');
expect(traces[0].modelCalls).toBeGreaterThan(0);
expect(traces[0]).not.toHaveProperty('file');
expect(JSON.stringify(res.body)).not.toContain('lou-m5b-test-');

const limited = await request(app).get('/agents/weather/traces?limit=1').expect(200);
expect(limited.body.traces).toHaveLength(1);
});

it('GET /agents/:id/traces/:traceId returns the spans with kind and status', async () => {
await finishedRun('weather');
const [summary] = (await request(app).get('/agents/weather/traces').expect(200)).body.traces as TraceSummaryPayload[];
const res = await request(app).get(`/agents/weather/traces/${summary.traceId}`).expect(200);
const body = res.body as TraceDetailPayload;
expect(body.traceId).toBe(summary.traceId);
const root = body.spans.find((s) => s.id === summary.traceId);
expect(root).toMatchObject({ kind: 'internal' });
expect(body.spans.find((s) => s.attributes['gen_ai.operation.name'] === 'chat')).toMatchObject({ kind: 'client' });
});

it('a failed span keeps its error status in the list and the detail', async () => {
const dir = path.join(baseDir, '.lousho', 'agents', 'broken', 'traces', '2026-10-02');
fs.mkdirSync(dir, { recursive: true });
const root = { v: 1, traceId: 'r1', id: 'r1', name: 'invoke_agent broken', kind: 'internal', startTime: 1000, endTime: 1500, attributes: {}, status: { code: 'error', message: 'boom' } };
const tool = { v: 1, traceId: 'r1', id: 't1', parentId: 'r1', name: 'execute_tool x', kind: 'internal', startTime: 1100, endTime: 1200, attributes: {}, status: { code: 'error', message: 'boom' } };
const lines = [tool, root].map((line) => `${JSON.stringify(line)}
`).join('');
fs.writeFileSync(path.join(dir, 'r1.jsonl'), lines);
expect((await request(app).get('/agents/broken/traces').expect(200)).body.traces[0]).toMatchObject({ traceId: 'r1', status: 'error' });
const detail = (await request(app).get('/agents/broken/traces/r1').expect(200)).body as TraceDetailPayload;
expect(detail.spans.map((s) => s.status)).toEqual([{ code: 'error', message: 'boom' }, { code: 'error', message: 'boom' }]);
});

it('an agent without runs has an empty list; an unknown agent is 404', async () => {
await request(app).put('/agents/fresh').send(SPEC).expect(204);
expect((await request(app).get('/agents/fresh/traces').expect(200)).body).toEqual({ traces: [] });
await request(app).get('/agents/nobody/traces').expect(404);
await request(app).get('/agents/nobody/traces/abc').expect(404);
});

it('an unknown trace is 404', async () => {
await finishedRun('weather');
await request(app).get('/agents/weather/traces/00000000-0000-0000-0000-000000000000').expect(404);
});

it('a path-like trace or agent id is 400 and never reaches the filesystem', async () => {
await finishedRun('weather');
for (const bad of ['..%2F..%2Fagent.yaml', '..', 'a%2Fb', 'a%5Cb', '%2E%2E', 'x.jsonl']) {
const res = await request(app).get(`/agents/weather/traces/${bad}`);
expect(res.status, bad).not.toBe(200);
expect(res.status, bad).toBeLessThan(500);
}
await request(app).get('/agents/weather/traces/..%2F..%2Fagent.yaml').expect(400);
await request(app).get('/agents/weather/traces/a%5Cb').expect(400);
await request(app).get('/agents/..%2Fweather/traces').expect(400);
});

it('an ambiguous id prefix is 400', async () => {
await finishedRun('weather');
const dir = path.join(baseDir, '.lousho', 'agents', 'weather', 'traces');
const [day] = fs.readdirSync(dir);
for (const name of ['ab-1', 'ab-2']) {
const line = { v: 1, traceId: name, id: name, name: 'invoke_agent x', startTime: 1, endTime: 2, attributes: {} };
fs.writeFileSync(path.join(dir, day, `${name}.jsonl`), `${JSON.stringify(line)}\n`);
}
await request(app).get('/agents/weather/traces/ab').expect(400);
});
});
59 changes: 59 additions & 0 deletions apps/agent-forge/server/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
* POST /agents/:id/stop -> abort the in-flight run, if any
* GET /agents/:id/status -> AgentRunStatusPayload
* POST /agents/:id/approve -> body: { approvalId, approved, note? }
* GET /agents/:id/traces?limit=N -> { traces: TraceSummaryPayload[] } (M5b, newest first)
* GET /agents/:id/traces/:traceId -> TraceDetailPayload | 404 (M5b)
* GET /runs/:id/history -> RunHistoryPayload (LOU-D45 time travel)
* POST /runs/:id/fork -> body: ForkRunRequest; starts the fork, 202 ForkRunResponse
* GET /runs/compare?a=&b= -> RunComparisonPayload (compareTrajectories)
Expand Down Expand Up @@ -45,6 +47,14 @@ import { isValidAgentId } from './types';
import { SecretsStore, isSecretProvider } from './secretsStore';
import { SettingsStore } from './settingsStore';
import type { ForkRunRequest, ForkRunResponse, SettingsProfile } from '../shared/wireTypes';
import {
DEFAULT_TRACE_LIMIT,
MAX_TRACE_LIMIT,
isValidTraceId,
listAgentTraces,
readAgentTrace,
traceDirExists,
} from './traceStore';
import { DEPLOY_ADAPTERS, isDeployAdapter, runDeploy } from './deployRunner';

export interface CreateAppOptions {
Expand Down Expand Up @@ -216,6 +226,54 @@ function parseForkRequest(body: Partial<ForkRunRequest> | undefined): { fromStep
return { fromStep: fromStep as number, patch };
}

/** M5b: the persisted traces of an agent's runs (files under `.lousho/agents/<id>/traces`, the ones `lousho traces` reads). */
function registerTraceRoutes(app: Express, agentStore: AgentStore, baseDir: string): void {
const known = async (id: string) => traceDirExists(baseDir, id) || (await agentStore.load(id)) !== undefined;

app.get(
'/agents/:id/traces',
asyncRoute(async (req, res) => {
const raw = Number.parseInt(String(req.query.limit ?? ''), 10);
const limit = Number.isInteger(raw) && raw > 0 ? Math.min(raw, MAX_TRACE_LIMIT) : DEFAULT_TRACE_LIMIT;
if (!(await known(paramId(req)))) {
res.status(404).json({ error: `No saved agent '${paramId(req)}'` });
return;
}
res.json({ traces: await listAgentTraces(baseDir, paramId(req), limit) });
})
);

app.get(
'/agents/:id/traces/:traceId',
asyncRoute(async (req, res) => {
const traceId = Array.isArray(req.params.traceId) ? req.params.traceId[0] : req.params.traceId;
if (!isValidTraceId(traceId)) {
res.status(400).json({ error: 'Invalid trace id' });
return;
}
if (!(await known(paramId(req)))) {
res.status(404).json({ error: `No saved agent '${paramId(req)}'` });
return;
}
let spans;
try {
spans = await readAgentTrace(baseDir, paramId(req), traceId);
} catch (error) {
if (error instanceof SDKError) {
res.status(400).json({ error: error.message });
return;
}
throw error;
}
if (spans.length === 0) {
res.status(404).json({ error: `No trace '${traceId}' for agent '${paramId(req)}'` });
return;
}
res.json({ traceId, spans });
})
);
}

/** LOU-D45 time travel: a run's step history, forking it from a step, and comparing two runs. */
function registerTimeTravelRoutes(app: Express, runManager: RunManager): void {
app.get(
Expand Down Expand Up @@ -553,6 +611,7 @@ export function createApp({
registerChatRoutes(app, runManager, triggerRegistry);
registerDebugRoutes(app, runManager);
registerTimeTravelRoutes(app, runManager);
registerTraceRoutes(app, agentStore, baseDir);
registerProviderKeyRoutes(app, secrets);
registerProfileRoutes(app, settings);
registerDeployRoutes(app, agentStore, baseDir);
Expand Down
10 changes: 8 additions & 2 deletions apps/agent-forge/server/runRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
*/
import { EventEmitter } from 'node:events';
import { randomUUID } from 'node:crypto';
import { fileTraceExporter } from '@lousho/build-ai-agent/traces';
import { agentTraceDir, fanOutExporter } from './traceStore';
import {
AgentExecutor,
FlowExecutor,
Expand Down Expand Up @@ -313,20 +315,24 @@ export class RunManager extends EventEmitter {
startTime: span.startTime,
endTime: span.endTime,
attributes: span.attributes,
...(span.kind !== undefined && { kind: span.kind }),
...(span.status !== undefined && { status: span.status }),
});
}

/**
* 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.
* than a second tracing pipeline. M5b: it also writes the run to the
* agent's trace folder (see traceStore.ts), so the Trace tab can reopen it.
*/
private makeTraceExporter(agentId: string): TraceExporter {
return {
const live: TraceExporter = {
onSpanStart: (span) => this.emitSpan(agentId, span),
onSpanEnd: (span) => this.emitSpan(agentId, span),
};
return fanOutExporter(live, fileTraceExporter({ dir: agentTraceDir(this.opts.baseDir, agentId) }));
}

/** O3: (re)creates the debug session for a fresh run(), seeded from this agent's persisted breakpoints. */
Expand Down
59 changes: 59 additions & 0 deletions apps/agent-forge/server/traceStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/**
* M5b: where Agent Forge keeps run traces, and how it reads them back.
*
* Every run writes the SDK's trace files (`fileTraceExporter`, the same
* format `lousho traces` reads) to `<baseDir>/.lousho/agents/<agentId>/traces`.
* The server only ever reads inside that folder: an agent id is validated
* before it becomes a path segment, a trace id is matched against the file
* names found there (never joined into a path), and the absolute file path is
* not sent to the browser.
*/
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';

/** A trace id is a UUID in practice; this is the strictest shape that still allows prefixes. */
const TRACE_ID_RE = /^[A-Za-z0-9-]{1,128}$/;

export const DEFAULT_TRACE_LIMIT = 20;
export const MAX_TRACE_LIMIT = 200;

export function isValidTraceId(id: string): boolean {
return TRACE_ID_RE.test(id);
}

/** The agent's trace folder; `agentId` must already have passed `isValidAgentId`. */
export function agentTraceDir(baseDir: string, agentId: string): string {
return path.join(baseDir, '.lousho', 'agents', agentId, 'traces');
}

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
return rest;
}

/** The agent's recent traces, newest first. */
export async function listAgentTraces(baseDir: string, agentId: string, limit: number): Promise<TraceSummaryPayload[]> {
const summaries = await listTraces({ dir: agentTraceDir(baseDir, agentId), limit });
return summaries.map(toPayload);
}

/** One trace's spans; `[]` when the agent has no such trace. */
export async function readAgentTrace(baseDir: string, agentId: string, traceId: string): Promise<SpanEvent[]> {
return (await readTrace(traceId, { dir: agentTraceDir(baseDir, agentId) })) as SpanEvent[];
}
25 changes: 25 additions & 0 deletions apps/agent-forge/shared/wireTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,31 @@ export interface SpanEvent {
startTime: number;
endTime?: number;
attributes: Record<string, unknown>;
/** M5b: `internal` or `client` (a model call). */
kind?: 'internal' | 'client';
/** M5b: `error` when the span failed. */
status?: { code: 'ok' | 'error'; message?: string };
}

/** M5b: one persisted run in `GET /agents/:id/traces` (the SDK's `TraceSummary` without the server-side file path). */
export interface TraceSummaryPayload {
traceId: string;
name: string;
agent?: string;
startTime: number;
durationMs: number;
status: 'ok' | 'error';
modelCalls: number;
toolCalls: number;
inputTokens: number;
outputTokens: number;
costUsd?: number;
}

/** M5b: `GET /agents/:id/traces/:traceId`. */
export interface TraceDetailPayload {
traceId: string;
spans: SpanEvent[];
}

/**
Expand Down
Loading
Loading