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: 2 additions & 1 deletion .fallowrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@
"members": [
"save",
"load",
"delete"
"delete",
"history"
]
},
{
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] - 2026-09-28

### Added
- Agent Forge time travel API (LOU-D45): Agent Forge's `FileCheckpointStore` keeps the bounded per-session checkpoint history (`history()`, `historyLimit` option, `delete({ keepHistory })`) with the same semantics as `LocalStorageCheckpointStore`, checked by the SDK's history contract suite. The runtime control server gains `GET /runs/:id/history` (each step's status, save time, finish reason, tool calls, tokens and cost), `POST /runs/:id/fork` (`{ fromStep, patch?: { toolResult?, appendInput?, businessState? } }`, forks with `AgentExecutor.fork()` and starts the fork as a run of its own) and `GET /runs/compare?a=&b=` (`compareTrajectories()`). Fixed on the way: Agent Forge's chat transcript compiles against multimodal `Message.content` (LOU-V11) again, keeping each message's text. See docs/agent-forge.md#time-travel.
- Fork and replay (LOU-D44): `AgentExecutor.fork({ sessionId, fromStep, newSessionId?, checkpointStore, patch? })` takes the newest entry of `fromStep` in the session's checkpoint history, applies `patch` (`messages(messages)`, `businessState`, `toolResult: { toolCallId, result }` replaced in place so the transcript stays provider-valid, `appendInput` queued as a user message) and saves it as the `'in-progress'` checkpoint of `newSessionId` (default `<sessionId>.fork-<n>`), leaving the original session untouched; it returns `{ sessionId, step, checkpoint }`, and `execute({ sessionId, checkpointStore, input: [] })` continues the fork. A missing step throws the new `LOUSHY_CHECKPOINT_NOT_FOUND` code. `createAgent()` agents gain `agent.fork(sessionId, { fromStep, newSessionId?, patch? })` over `store.checkpoints` (resume with `agent.resume()`). `compareTrajectories(a, b)` compares two checkpoints or transcripts step by step: each run's steps (text, tool calls and results), the first diverging step and the `loushy eval --drift` diff. New types `ForkOptions`, `ForkPatch`, `ForkResult`, `TrajectoryComparison`, `TrajectoryStep` and `DriftEntry`. See docs/durable-execution.md#fork-and-replay.
- Multimodal message parts (LOU-V11): `Message.content` is now `string | ContentPart[]`, where a `ContentPart` is `{ type: 'text', text }`, `{ type: 'image', image, mimeType? }` (an `http(s)` URL, a `data:` URL or a `Uint8Array`) or `{ type: 'file', data, mimeType, filename? }` (new types `ContentPart`, `TextContentPart`, `ImageContentPart`, `FileContentPart`). The built-in providers map the parts of `user` messages to the `ai` v4 user-content parts: images go to the model with all four (the `ai` SDK downloads an image URL first for Anthropic and Ollama); file parts become a `[file <name> (<mimeType>) not sent]` text note with a one-time `console.warn`, since the pinned peers (`@ai-sdk/*` 0.0.x, `ollama-ai-provider`) cannot send files (a subclass sets `acceptsFileParts = true` to send them). `system`, `assistant` and `tool` messages are sent as their text. The new `textOf(message)` returns a message's text (the string, or its text parts joined), and is what `estimateTokens()` (each image or file part counts 1,000 tokens), compaction, `recordReplay()` fingerprints (cassette messages gain an optional `attachments` list: each image or file as its URL or a digest of its bytes), `mockModel()`, `MockLLMProvider`, OpenTelemetry content attributes and the React reducer (`ui.send` takes parts too) now read. `FileSessionStore` saves `Uint8Array`s as `{ "$bytes": "<base64>" }` and loads them back. Pass multimodal messages as `AgentExecutor.execute({ input: Message[] })`; `agent.send()` / `session.send()` taking parts is LOU-V12. See docs/providers.md#multimodal-input.
- Declarative permission policies (LOU-X2): `permissions?: PermissionRule[]` on `createAgent()`, `ExecuteOptions` and `resumeAfterApproval()` options. A rule is `{ tool: string | string[] | RegExp | '*', when?: (args, { toolName, toolCallId, sessionId }) => boolean | Promise<boolean>, action: 'allow' | 'deny' | 'ask', reason? }`; rules are checked in order for each tool call (after argument validation and `preToolCall` hooks, before `needsApproval`) and the first match decides: `allow` runs the call without approval even when `needsApproval` would ask, `deny` gives the model a tool error with the new `kind: 'denied'` (`ToolDeniedError`, plus `reason`) and streams `tool.error`, `ask` pauses for approval (or asks `approve`). No match keeps today's behaviour. New helpers `allow(tools)`, `deny(tools, reason?)`, `ask(tools)` and types `PermissionRule`, `PermissionAction`, `PermissionToolMatcher`, `PermissionContext`, `PermissionDecision`, `PermissionDecisionEntry`, `PermissionOptions`. Audit log: `onPermissionDecision(entry)` gets `{ toolName, toolCallId, decision: 'allow' | 'deny' | 'ask' | 'default', rule?: { index, reason? }, args?, at }` per call (`args` left out under `redactContent`), and streams get the same entry as the new typed `permission.decision` event (`PermissionDecisionEvent`; additive, the event schema version stays 1). Neither is produced unless `permissions` or `onPermissionDecision` is set. Sub-agents inherit the lead's rules (checked before their own) and its `onPermissionDecision`. See docs/approvals.md#permission-policies.
Expand Down
2 changes: 1 addition & 1 deletion apps/agent-forge/server/__tests__/chatReconcile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type { ChatMessage } from '../../shared/wireTypes';
const T1 = '2026-01-01T00:00:00.000Z';
const T2 = '2026-01-01T00:00:05.000Z';

function chatMsg(m: Message, id: string, timestamp = T1): ChatMessage {
function chatMsg(m: Message & { content: string }, id: string, timestamp = T1): ChatMessage {
return { ...m, id, timestamp };
}

Expand Down
21 changes: 21 additions & 0 deletions apps/agent-forge/server/__tests__/checkpointStore.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* LOU-D45: Forge's FileCheckpointStore keeps the same bounded history ring
* as the SDK's stores - the SDK's own history contract suite, run against it.
*/
import { afterAll } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { FileCheckpointStore } from '../checkpointStore';
import { describeCheckpointHistoryContract } from '../../../../src/execution/__fixtures__/checkpointHistoryContract';

const dirs: string[] = [];
afterAll(() => {
for (const dir of dirs) fs.rmSync(dir, { recursive: true, force: true });
});

describeCheckpointHistoryContract('FileCheckpointStore (Agent Forge)', (options) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'lou-d45-store-'));
dirs.push(dir);
return new FileCheckpointStore(dir, options);
});
152 changes: 152 additions & 0 deletions apps/agent-forge/server/__tests__/timeTravel.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
/**
* LOU-D45: the time-travel routes - `GET /runs/:id/history`,
* `POST /runs/:id/fork` and `GET /runs/compare` - against real runs of the
* mock provider, through the same RunManager lifecycle as every other run.
*/
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 type { AgentSpec } from '@loushy/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, RunComparisonPayload, RunHistoryPayload } from '../../shared/wireTypes';

const SPEC: AgentSpec = {
name: 'time-travel-agent',
prompt: 'You are a helpful agent.',
provider: { type: 'mock', model: 'mock-1' },
};
const GATED_SPEC: AgentSpec = { ...SPEC, tools: ['demo-approval'] };

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

describe('LOU-D45 time-travel routes', () => {
let baseDir: string;
let runManager: RunManager;
let app: ReturnType<typeof createApp>;

beforeEach(() => {
baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lou-d45-app-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(runId: string): Promise<void> {
await request(app).post(`/agents/${runId}/run`).send({ input: 'What is the weather?', spec: SPEC }).expect(202);
await settled(runManager, runId, 'stopped');
}

it('GET /runs/:id/history lists the steps with status, finish reason, tool calls and tokens', async () => {
await finishedRun('weather');
const res = await request(app).get('/runs/weather/history').expect(200);
const body = res.body as RunHistoryPayload;
expect(body.runId).toBe('weather');
expect(body.steps).toHaveLength(1);
expect(body.steps[0]).toMatchObject({ step: 1, status: 'finished', finishReason: 'stop', toolCalls: [] });
expect(body.steps[0].tokens).toBeGreaterThan(0);
expect(new Date(body.steps[0].savedAt).toISOString()).toBe(body.steps[0].savedAt);
});

it('GET /runs/:id/history is 404 for a run without checkpoints and 400 for an invalid id', async () => {
await request(app).get('/runs/never-ran/history').expect(404);
await request(app).get(`/runs/${encodeURIComponent('../x')}/history`).expect(400);
});

it('POST /runs/:id/fork with appendInput starts the fork through the registry; compare shows where it diverged', async () => {
await finishedRun('weather');
const fork = await request(app)
.post('/runs/weather/fork')
.send({ fromStep: 1, patch: { appendInput: 'And in Celsius?' } })
.expect(202);
expect(fork.body).toMatchObject({ runId: 'weather.fork-1', fromStep: 1 });
expect(['running', 'stopped']).toContain(fork.body.status.status);

const done = await settled(runManager, 'weather.fork-1', 'stopped');
expect(done.resultText).toBeTruthy();
expect((await request(app).get('/runs/weather.fork-1/history').expect(200)).body.steps.at(-1).step).toBe(2);

const compare = await request(app).get('/runs/compare').query({ a: 'weather', b: 'weather.fork-1' }).expect(200);
const comparison = compare.body as RunComparisonPayload;
expect(comparison.a).toHaveLength(1);
expect(comparison.b).toHaveLength(2);
expect(comparison.divergedAt).toBe(2);
expect(comparison.drift).toContainEqual({ field: 'steps', committed: '1', current: '2' });
});

it('POST /runs/:id/fork with toolResult answers a call paused for approval; history shows the call', async () => {
await request(app).post('/agents/gated/run').send({ input: 'please use demo-approval', spec: GATED_SPEC }).expect(202);
await settled(runManager, 'gated', 'paused');
const [step] = (await request(app).get('/runs/gated/history').expect(200)).body.steps as RunHistoryPayload['steps'];
expect(step).toMatchObject({ step: 1, status: 'awaiting-approval', finishReason: 'tool_calls' });
expect(step.toolCalls).toEqual([expect.objectContaining({ name: 'demo-approval' })]);
expect(step.toolCalls[0].result).toBeUndefined();

const toolCallId = step.toolCalls[0].id;
const fork = await request(app)
.post('/runs/gated/fork')
.send({ fromStep: 1, patch: { toolResult: { toolCallId, result: { done: false, note: 'edited' } } } })
.expect(202);
await settled(runManager, fork.body.runId, 'stopped');
expect(runManager.status('gated').status).toBe('paused'); // the source run is untouched

const comparison = (await request(app).get(`/runs/compare?a=gated&b=${fork.body.runId}`).expect(200)).body as RunComparisonPayload;
expect(comparison.divergedAt).toBe(1);
expect(comparison.b[0].tools[0].result).toBe(JSON.stringify({ done: false, note: 'edited' }));
});

it('POST /runs/:id/fork validates its body and maps fork errors', async () => {
await finishedRun('weather');
const fork = (body: object) => request(app).post('/runs/weather/fork').send(body);
await fork({}).expect(400);
await fork({ fromStep: -1 }).expect(400);
await fork({ fromStep: 1.5 }).expect(400);
await fork({ fromStep: 1, patch: [] }).expect(400);
await fork({ fromStep: 1, patch: { messages: [] } }).expect(400);
await fork({ fromStep: 1, patch: { appendInput: ' ' } }).expect(400);
await fork({ fromStep: 1, patch: { toolResult: 'x' } }).expect(400);
await fork({ fromStep: 1, patch: { toolResult: { toolCallId: 'c1' } } }).expect(400);
const unknownCall = await fork({ fromStep: 1, patch: { toolResult: { toolCallId: 'nope', result: 1 } } }).expect(400);
expect(unknownCall.body.error).toMatch(/no tool call 'nope'/);
const noStep = await fork({ fromStep: 9 }).expect(404);
expect(noStep.body.error).toMatch(/no checkpoint at step 9/);
await request(app).post('/runs/unknown/fork').send({ fromStep: 1 }).expect(404);
});

it('GET /runs/compare validates its query and is 404 when a run has no checkpoint', async () => {
await finishedRun('weather');
await request(app).get('/runs/compare?a=weather').expect(400);
await request(app).get(`/runs/compare?a=weather&b=${encodeURIComponent('../x')}`).expect(400);
await request(app).get('/runs/compare?a=weather&b=missing').expect(404);
const same = (await request(app).get('/runs/compare?a=weather&b=weather').expect(200)).body as RunComparisonPayload;
expect(same.divergedAt).toBeUndefined();
expect(same.drift).toEqual([]);
});
});
Loading
Loading