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
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 History tab (LOU-D45.2): the bottom drawer's new **History** tab lists the loaded agent's run steps (step, status, finish reason, tool calls, tokens, cost). "Edit and replay from here" appends a user message or edits one of the step's tool results, forks the run at that step through `POST /runs/:id/fork`, and shows the fork next to the original as a side-by-side trajectory with the diverging turn and the drift entries highlighted, refreshed when the fork finishes. See docs/agent-forge.md#the-history-tab.
- `loushy dev` for agent directories and TS agents, with hot reload (LOU-D31): `loushy dev <path>` takes a spec file (as before), an agent directory (`loadAgentDir()`) or a `.ts`/`.js` module whose default (or `agent`) export is a `SimpleAgent` or a `createAgent()` options object, detected by path type and extension (a bad path, extension or export fails with `LOUSHY_CONFIG_INVALID` / `LOUSHY_SPEC_UNSUPPORTED_FORMAT`). It watches the directory tree, or the module and its relative imports, and rebuilds the agent about 100 ms after a change with a `?t=<time>` cache-busted import, so edited tools reload; the new agent is built and `ready()` before it replaces the old one, which is then `close()`d. A failed rebuild keeps the last good agent and reports the error in the console, in a banner on the chat page and in the new `GET /dev/status` (`{ kind, path, reloads, error }`). `startDevServer(path, port?, host?, { overrides?, debounceMs? })` gains the options argument. Spec-file behaviour is unchanged; the chat is not streamed or per-tab (LOU-D32). See docs/cli.md#loushy-dev and docs/agent-directories.md#run-it-with-loushy-dev.
- Multimodal input through the public API (LOU-V12): `agent.send()` / `agent.stream()`, `session.send()` / `stream()`, `t.send()` in evals and `useLoushyAgent().send()` take the new exported `AgentInput` (`string | ContentPart[] | Message[]`): a string or parts become one user message, a `Message[]` is passed through (in a session it is appended to the transcript). Checkpoints, `agent.resume()` and queued input hold the parts like any message. The React user bubble shows the text with an `[image]` / `[file]` marker per non-text part. `SqliteStore` sessions and checkpoints save `Uint8Array` parts as `{ "$bytes": "<base64>" }`, as `FileSessionStore` does. Finishes LOU-V11 (its note that `agent.send()` / `session.send()` take no parts is out of date). See docs/providers.md#multimodal-input.
- Built-in `ask_question` tool (LOU-X9): `createAgent({ askQuestion: true })` (off by default) or `askQuestionTool()` gives the agent `ask_question({ question, options?, allowFreeText? })`. A call pauses the run through the approval mechanism, so it waits durably like any approval (sessions, `memoryStore()` / `SqliteStore`, a restart between question and answer). The pending record gains optional `kind: 'question'` and `question: { text, options?, allowFreeText? }` (new types `ApprovalKind`, `ApprovalQuestion`; `describeApproval()` and `ASK_QUESTION_TOOL_NAME` are exported), and so do the `approval.requested` event and the React hook's `pendingApproval`. The new `agent.approvals.answer({ id, answer })` (the same as `resolve({ id, approved: true, note: answer })`) continues the run with `{ answer, option? }` as the tool result (`option`: index of the matching option); `approved: false` gives the model a `kind: 'rejected'` error saying the user declined to answer. `useLoushyAgent()` gets `answer(text)`. The `approve` callback may now return a string (`ApproveToolCall` returns `boolean | string`), which approves with that note, so code can answer questions. A tool run after an approval sees the decision's note as `ctx.approval.note` (new optional `ToolExecutionContext.approval`). See docs/approvals.md "Asking the user a question".
Expand Down
5 changes: 4 additions & 1 deletion apps/agent-forge/src/components/BottomDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { DrawerTab } from '../state/AppState';
import { LogsPanel } from './debug/LogsPanel';
import { TracePanel } from './debug/TracePanel';
import { OutputPanel } from './debug/OutputPanel';
import { HistoryPanel } from './debug/HistoryPanel';
import { DebugBar } from './debug/DebugBar';
import { ChatPanel } from './ChatPanel';
import { SettingsPanel } from './SettingsPanel';
Expand All @@ -13,6 +14,7 @@ const TABS: { id: DrawerTab; label: string }[] = [
{ id: 'logs', label: 'Logs' },
{ id: 'trace', label: 'Trace' },
{ id: 'output', label: 'Output' },
{ id: 'history', label: 'History' },
{ id: 'settings', label: 'Settings' },
];

Expand All @@ -21,10 +23,11 @@ const TAB_PANELS: Record<DrawerTab, () => ReactElement> = {
logs: () => <LogsPanel />,
trace: () => <TracePanel />,
output: () => <OutputPanel />,
history: () => <HistoryPanel />,
settings: () => <SettingsPanel />,
};

// Every tab except Output manages its own padding/scrolling.
// Every tab except Output and History manages its own padding/scrolling.
const FLUSH_TABS: ReadonlySet<DrawerTab> = new Set<DrawerTab>(['logs', 'trace', 'chat', 'settings']);
const FLUSH_BODY_STYLE: CSSProperties = { padding: 0, overflow: 'hidden' };

Expand Down
205 changes: 205 additions & 0 deletions apps/agent-forge/src/components/debug/HistoryPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import { useEffect, useState } from 'react';
import type { TrajectoryStep } from '@loushy/build-ai-agent';
import { useAppState } from '../../state/AppState';
import { runtimeClient } from '../../runtime/runtimeClient';
import { errorMessage } from '../errorMessage';
import type { ForkRunRequest, RunComparisonPayload, RunHistoryStep } from '../../../shared/wireTypes';

type ForkPatch = NonNullable<ForkRunRequest['patch']>;

/** LOU-D45: one row per checkpointed step of the run, each with an "Edit and replay from here" action. */
export function HistoryList({ steps, onReplay }: { steps: RunHistoryStep[]; onReplay: (step: RunHistoryStep) => void }) {
return (
<table className="history-table">
<thead>
<tr>
{['Step', 'Status', 'Finish', 'Tool calls', 'Tokens', 'Cost', ''].map((h) => <th key={h}>{h}</th>)}
</tr>
</thead>
<tbody>
{steps.map((s) => (
<tr key={s.step}>
<td>{s.step}</td>
<td>{s.status}</td>
<td>{s.finishReason ?? '-'}</td>
<td>{s.toolCalls.map((call) => call.name).join(', ') || '-'}</td>
<td>{s.tokens ?? '-'}</td>
<td>{s.costUsd === undefined ? '-' : `$${s.costUsd.toFixed(4)}`}</td>
<td>
<button className="btn btn-ghost" onClick={() => onReplay(s)}>Edit and replay from here</button>
</td>
</tr>
))}
</tbody>
</table>
);
}

/** A tool result is sent as JSON when it parses, else as the plain string. */
function parseResult(text: string): unknown {
try {
return JSON.parse(text);
} catch {
return text;
}
}

function ReplayEditor({ step, onFork, onCancel }: { step: RunHistoryStep; onFork: (patch: ForkPatch) => void; onCancel: () => void }) {
const [target, setTarget] = useState('append');
const [text, setText] = useState('');
const choose = (value: string) => {
setTarget(value);
setText(step.toolCalls.find((call) => call.id === value)?.result ?? '');
};
const submit = () =>
onFork(target === 'append' ? { appendInput: text } : { toolResult: { toolCallId: target, result: parseResult(text) } });
return (
<div className="replay-editor">
<b>Replay from step {step.step}</b>
<select className="select" value={target} onChange={(e) => choose(e.target.value)}>
<option value="append">Append a user message</option>
{step.toolCalls.map((call) => (
<option key={call.id} value={call.id}>Edit the result of {call.name} ({call.id})</option>
))}
</select>
<textarea className="textarea" value={text} onChange={(e) => setText(e.target.value)} aria-label="Replay edit" />
<div className="replay-actions">
<button className="btn btn-primary" disabled={!text.trim()} onClick={submit}>Fork and replay</button>
<button className="btn btn-ghost" onClick={onCancel}>Cancel</button>
</div>
</div>
);
}

function StepCell({ step }: { step: TrajectoryStep | undefined }) {
if (!step) return <div className="traj-cell traj-missing">-</div>;
return (
<div className="traj-cell">
{step.text && <div>{step.text}</div>}
{step.tools.map((tool) => (
<div key={tool.id} className="traj-tool">
{tool.name}({tool.args}) {'->'} {tool.result ?? 'pending'}
</div>
))}
</div>
);
}

/** LOU-D45: two runs' model turns side by side, the first diverging turn and the drift entries highlighted. */
export function TrajectoryCompare({ comparison, a, b }: { comparison: RunComparisonPayload; a: string; b: string }) {
const turns = Math.max(comparison.a.length, comparison.b.length);
return (
<div className="traj-compare">
<div className="traj-row traj-head">
<span>Turn</span>
<b>{a}</b>
<b>{b}</b>
</div>
{Array.from({ length: turns }, (_, i) => (
<div key={i} className={`traj-row${comparison.divergedAt === i + 1 ? ' diverged' : ''}`}>
<span>{comparison.divergedAt === i + 1 ? `${i + 1} diverged` : i + 1}</span>
<StepCell step={comparison.a[i]} />
<StepCell step={comparison.b[i]} />
</div>
))}
<ul className="traj-drift">
{comparison.drift.length === 0 && <li>No drift</li>}
{comparison.drift.map((d) => (
<li key={`${d.field}:${d.committed}`}>
<b>{d.field}</b>: {d.committed} {'->'} {d.current}
</li>
))}
</ul>
</div>
);
}

/** The loaded agent's run steps (`GET /runs/:id/history`), refetched on every status change. */
function useRunHistory(agentId: string, statusAt: string | undefined): RunHistoryStep[] {
const [steps, setSteps] = useState<RunHistoryStep[]>([]);
useEffect(() => {
let live = true;
runtimeClient.runHistory(agentId).then(
(history) => live && setSteps(history.steps),
() => live && setSteps([])
);
return () => void (live = false);
}, [agentId, statusAt]);
return steps;
}

interface ForkView {
runId: string;
comparison?: RunComparisonPayload;
}

/**
* Forks the agent's run (`POST /runs/:id/fork`) and keeps the fork's
* comparison with it (`GET /runs/compare`) fresh as the fork's status streams in.
*/
function useForkReplay(agentId: string) {
const [fork, setFork] = useState<ForkView>();
const [error, setError] = useState<string>();
useEffect(() => setFork(undefined), [agentId]);

const forkId = fork?.runId;
useEffect(() => {
if (!forkId) return undefined;
const refresh = () =>
runtimeClient.compareRuns(agentId, forkId).then(
(comparison) => setFork((f) => (f?.runId === forkId ? { runId: forkId, comparison } : f)),
(e: unknown) => setError(errorMessage(e))
);
return runtimeClient.subscribe(forkId, (message) => {
if (message.type === 'status' && message.payload.status !== 'running') void refresh();
});
}, [agentId, forkId]);

const replay = async (fromStep: number, patch: ForkPatch): Promise<boolean> => {
setError(undefined);
try {
const { runId } = await runtimeClient.forkRun(agentId, { fromStep, patch });
setFork({ runId });
return true;
} catch (e) {
setError(errorMessage(e));
return false;
}
};
return { fork, error, replay };
}

function ForkResult({ fork, agentId }: { fork: ForkView | undefined; agentId: string }) {
if (!fork) return null;
if (!fork.comparison) return <div className="logs-empty">Replaying as {fork.runId}...</div>;
return <TrajectoryCompare comparison={fork.comparison} a={agentId} b={fork.runId} />;
}

/** LOU-D45 time travel: the loaded agent's run history, "Edit and replay from here", and the fork next to the original. */
export function HistoryPanel() {
const { agentId, runStatus } = useAppState();
const steps = useRunHistory(agentId, runStatus?.updatedAt);
const { fork, error, replay } = useForkReplay(agentId);
const [replaying, setReplaying] = useState<RunHistoryStep>();

const onFork = async (step: RunHistoryStep, patch: ForkPatch) => {
if (await replay(step.step, patch)) setReplaying(undefined);
};

if (steps.length === 0) return <div className="logs-empty">No checkpoint history yet - run the agent first.</div>;
return (
<div className="history-panel">
{error && <div className="run-error">{error}</div>}
<HistoryList steps={steps} onReplay={setReplaying} />
{replaying && (
<ReplayEditor
key={replaying.step}
step={replaying}
onFork={(patch) => void onFork(replaying, patch)}
onCancel={() => setReplaying(undefined)}
/>
)}
<ForkResult fork={fork} agentId={agentId} />
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import type { ReactElement } from 'react';
import { HistoryList, TrajectoryCompare } from '../HistoryPanel';
import type { RunComparisonPayload, RunHistoryStep } from '../../../../shared/wireTypes';

(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;

// What `GET /runs/:id/history` returns for a run that called a tool, then answered.
const STEPS: RunHistoryStep[] = [
{
step: 1,
status: 'in-progress',
savedAt: '2026-10-01T10:00:00.000Z',
finishReason: 'tool_calls',
toolCalls: [{ id: 'call_1', name: 'get_weather', args: '{"city":"Paris"}', result: '{"tempF":68}' }],
tokens: 42,
costUsd: 0.00021,
},
{ step: 2, status: 'finished', savedAt: '2026-10-01T10:00:01.000Z', finishReason: 'stop', toolCalls: [], tokens: 30 },
];

// What `GET /runs/compare` returns for that run and a fork whose tool result was edited.
const COMPARISON: RunComparisonPayload = {
a: [
{ text: '', tools: [{ id: 'call_1', name: 'get_weather', args: '{"city":"Paris"}', result: '{"tempF":68}' }] },
{ text: 'It is 68F in Paris.', tools: [] },
],
b: [
{ text: '', tools: [{ id: 'call_1', name: 'get_weather', args: '{"city":"Paris"}', result: '{"tempC":20}' }] },
{ text: 'It is 20C in Paris.', tools: [] },
{ text: 'Anything else?', tools: [] },
],
divergedAt: 1,
drift: [{ field: 'steps', committed: '2', current: '3' }],
};

let root: Root | undefined;
afterEach(() => {
act(() => root?.unmount());
root = undefined;
});

function render(element: ReactElement): HTMLElement {
const container = document.createElement('div');
root = createRoot(container);
act(() => root?.render(element));
return container;
}

describe('HistoryList (LOU-D45)', () => {
it('renders a row per step with status, finish reason, tool calls, tokens and cost', () => {
const container = render(<HistoryList steps={STEPS} onReplay={() => undefined} />);
const rows = [...container.querySelectorAll('tbody tr')].map((row) =>
[...row.querySelectorAll('td')].slice(0, 6).map((cell) => cell.textContent)
);
expect(rows).toEqual([
['1', 'in-progress', 'tool_calls', 'get_weather', '42', '$0.0002'],
['2', 'finished', 'stop', '-', '30', '-'],
]);
});

it('"Edit and replay from here" hands its step to onReplay', () => {
const onReplay = vi.fn();
const container = render(<HistoryList steps={STEPS} onReplay={onReplay} />);
const buttons = [...container.querySelectorAll('button')];
expect(buttons.map((b) => b.textContent)).toEqual(['Edit and replay from here', 'Edit and replay from here']);
act(() => buttons[1].click());
expect(onReplay).toHaveBeenCalledWith(STEPS[1]);
});
});

describe('TrajectoryCompare (LOU-D45)', () => {
it('shows both runs side by side, highlights the turn they diverged at and lists the drift', () => {
const container = render(<TrajectoryCompare comparison={COMPARISON} a="weather" b="weather.fork-1" />);
expect([...container.querySelectorAll('.traj-head b')].map((b) => b.textContent)).toEqual(['weather', 'weather.fork-1']);

const rows = [...container.querySelectorAll('.traj-row:not(.traj-head)')];
expect(rows).toHaveLength(3);
expect(rows[0].classList.contains('diverged')).toBe(true);
expect(rows[0].textContent).toContain('1 diverged');
expect(rows[0].textContent).toContain('{"tempF":68}');
expect(rows[0].textContent).toContain('{"tempC":20}');
expect(rows[1].classList.contains('diverged')).toBe(false);
expect(rows[2].querySelector('.traj-missing')?.textContent).toBe('-'); // the original has no third turn

expect(container.querySelector('.traj-drift')?.textContent).toBe('steps: 2 -> 3');
});

it('says so when the runs did not drift', () => {
const same: RunComparisonPayload = { a: COMPARISON.a, b: COMPARISON.a, drift: [] };
const container = render(<TrajectoryCompare comparison={same} a="a" b="b" />);
expect(container.querySelector('.diverged')).toBeNull();
expect(container.querySelector('.traj-drift')?.textContent).toBe('No drift');
});
});
16 changes: 16 additions & 0 deletions apps/agent-forge/src/components/layout.css
Original file line number Diff line number Diff line change
Expand Up @@ -811,6 +811,22 @@
.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); }

/* ---------- LOU-D45: History tab (time travel) ---------- */
.history-panel { display: flex; flex-direction: column; gap: 10px; }
.history-table { width: 100%; border-collapse: collapse; }
.history-table th { text-align: left; color: var(--text-faint); font-weight: 600; padding: 3px 8px; border-bottom: 1px solid var(--border); }
.history-table td { color: var(--text); padding: 3px 8px; border-bottom: 1px solid var(--border); }
.replay-editor { display: flex; flex-direction: column; gap: 6px; padding: 8px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface-2); }
.replay-actions { display: flex; gap: 6px; }
.traj-row { display: grid; grid-template-columns: 90px 1fr 1fr; gap: 10px; padding: 3px 8px; border-radius: 5px; }
.traj-row.traj-head { color: var(--text-faint); }
.traj-row.diverged { background: var(--warning-soft); }
.traj-row.diverged > span { color: var(--warning); font-weight: 700; }
.traj-cell { color: var(--text); white-space: pre-wrap; word-break: break-word; }
.traj-missing { color: var(--text-faint); }
.traj-tool { color: var(--accent); }
.traj-drift { margin: 6px 0 0; padding-left: 18px; color: var(--warning); }

/* ---------- 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); }
Expand Down
Loading
Loading