diff --git a/apps/agent-forge/e2e/docShots.spec.ts b/apps/agent-forge/e2e/docShots.spec.ts new file mode 100644 index 00000000..f4d6b657 --- /dev/null +++ b/apps/agent-forge/e2e/docShots.spec.ts @@ -0,0 +1,239 @@ +import { test, expect, type Page, type Route } from '@playwright/test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { ObserveAgentRow, ObserveInsights, ObserveSessionRow } from '../src/observe/types'; +import { stubObserve } from './observeStub'; +import { SEED_ERROR, SEED_NEWEST_FAILURE, seedObserve } from './fixtures/seedObserve'; + +/** + * The screenshots in docs/agent-forge.md, taken from the built Studio against + * the seeded fake daemon (`observeStub.ts` + `fixtures/seedObserve.ts`). Opt-in: + * + * LOUSHO_DOC_SHOTS=1 npx playwright test -c apps/agent-forge/playwright.config.ts docShots + * + * writes `docs/images/studio-.png` at the repo root (1280x800, light). + * The small fixtures below fill the screens the seed leaves thin: a fleet with + * mixed health, a day of chart buckets for it, and a few threads. + */ +test.skip(!process.env.LOUSHO_DOC_SHOTS, 'set LOUSHO_DOC_SHOTS=1 to regenerate the docs screenshots'); +test.use({ viewport: { width: 1280, height: 800 }, colorScheme: 'light' }); + +const APP = '/?token=e2e-studio-token'; +const OUT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../docs/images'); + +const NOW = Date.now(); +const MINUTE = 60_000; +const HOUR = 60 * MINUTE; + +const FLEET = [ + { id: 'orders-bot', name: 'Orders bot', project: 'shop', status: 'healthy', age: 4_000, runs: 21, errorRate: 0.19, p95: 1_320 }, + { id: 'billing-bot', name: 'Billing bot', project: 'shop', status: 'healthy', age: 9_000, runs: 20, errorRate: 0.2, p95: 980 }, + { id: 'triage-bot', name: 'Triage bot', project: 'support', status: 'stale', age: 41_000, runs: 19, errorRate: 0.21, p95: 2_600 }, +] as const; + +const agents: ObserveAgentRow[] = FLEET.map((a) => ({ + agent_id: a.id, + project: a.project, + name: a.name, + version: '1.0.0', + first_seen: NOW - 48 * HOUR, + last_heartbeat: NOW - a.age, + status: a.status, + heartbeat_age_ms: a.age, + heartbeat_interval_ms: 15_000, + hostname: 'seed-host', + pid: 4242, + dropped: 0, + runs_today: a.runs, + error_rate_today: a.errorRate, +})); + +/** 18 hourly buckets per agent; a wave so the charts have shape. */ +function series(): ObserveInsights['series'] { + const rows: ObserveInsights['series'] = []; + FLEET.forEach((a, n) => { + for (let h = 17; h >= 0; h -= 1) { + const bucket = Math.floor((NOW - h * HOUR) / HOUR) * HOUR; + const runs = 2 + ((h + n * 3) % 4); + const errors = h % 5 === n ? 1 : 0; + const values: Record = { + runs, + errors, + cost_usd: runs * 0.0009, + input_tokens: runs * 720, + output_tokens: runs * 55, + p95_latency_ms: a.p95 * (0.7 + ((h + n) % 4) * 0.12), + error_rate: errors / runs, + }; + for (const [metric, value] of Object.entries(values)) + rows.push({ bucket_start: bucket, granularity: 'hour', agent_id: a.id, metric, value }); + } + }); + return rows; +} + +const insights = (): ObserveInsights => ({ + window: { since: NOW - 24 * HOUR, until: NOW }, + agents: FLEET.map((a) => ({ + agent_id: a.id, + project: a.project, + name: a.name, + runs: a.runs, + runs_per_day: a.runs, + errors: Math.round(a.runs * a.errorRate), + error_rate: a.errorRate, + cost_usd: a.runs * 0.0009, + input_tokens: a.runs * 720, + output_tokens: a.runs * 55, + p50_latency_ms: a.p95 / 2, + p95_latency_ms: a.p95, + })), + series: series(), +}); + +const sessions: ObserveSessionRow[] = [ + ['seed-session-0', 0, 'active', 'Where is order ord_54?', 9], + ['seed-session-1', 1, 'active', 'Why was I charged twice?', 6], + ['seed-session-2', 2, 'ended', 'My parcel arrived damaged', 4], + ['seed-session-3', 0, 'ended', 'Can I change the delivery address?', 3], + ['seed-session-4', 1, 'interrupted', 'Refund the last invoice', 2], +].map(([id, agent, status, input, traces], i) => { + const a = FLEET[agent as number]; + const started = NOW - (i + 1) * 2 * HOUR; + return { + session_id: id as string, + agent_id: a.id, + agent_name: a.name, + status: status as string, + started_at: started, + ended_at: started + (traces as number) * 4 * MINUTE, + traces: traces as number, + input_tokens: (traces as number) * 720, + output_tokens: (traces as number) * 55, + cost_usd: (traces as number) * 0.0009, + last_input_preview: input as string, + }; +}); + +const json = (route: Route, body: unknown) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) }); + +/** The doc-only fixtures; registered after seedObserve, so they answer first. */ +async function docFixtures(page: Page): Promise { + await page.route('**/observe/api/v1/**', (route) => { + if (route.request().method() !== 'GET') return route.fallback(); + const p = new URL(route.request().url()).pathname.replace(/^\/observe/, ''); + if (p === '/api/v1/agents') return json(route, { agents }); + if (p === '/api/v1/insights') return json(route, insights()); + if (p === '/api/v1/sessions') return json(route, { sessions, total: sessions.length, limit: 50, offset: 0 }); + return route.fallback(); + }); +} + +test.beforeAll(() => { + fs.mkdirSync(OUT, { recursive: true }); +}); + +test.beforeEach(async ({ page }) => { + await stubObserve(page); + await seedObserve(page); + await docFixtures(page); +}); + +async function open(page: Page, hash: string) { + await page.goto(`${APP}#${hash}`); + await expect(page.locator('.app')).toBeVisible(); +} + +async function shoot(page: Page, screen: string) { + // Let toasts, focus rings and chart transitions settle. + await page.mouse.move(0, 0); + await page.waitForTimeout(300); + await page.screenshot({ path: path.join(OUT, `studio-${screen}.png`) }); +} + +const rows = (page: Page) => page.getByRole('main').locator('table.data-table tbody tr'); + +test('agents: the builder with an agent', async ({ page }) => { + await open(page, '/agents'); + await page.getByRole('button', { name: '+ New agent' }).click(); + await page.locator('#new-agent-name').fill('support-bot'); + await page.locator('#new-agent-template').selectOption('support-bot'); + await page.getByRole('button', { name: 'Create', exact: true }).click(); + await expect(page.locator('[data-node-type="tool"]')).toBeVisible(); + await shoot(page, 'agents'); +}); + +test('traces: the list', async ({ page }) => { + await open(page, '/traces'); + await expect(rows(page).first()).toBeVisible(); + await shoot(page, 'traces'); +}); + +test('trace: a failed run with its error banner', async ({ page }) => { + await open(page, `/traces/${SEED_NEWEST_FAILURE.trace_id}`); + await expect(page.getByRole('main').getByRole('alert')).toContainText(SEED_ERROR); + await shoot(page, 'trace'); +}); + +test('threads: the session list', async ({ page }) => { + await open(page, '/threads'); + await expect(rows(page)).toHaveCount(sessions.length); + await shoot(page, 'threads'); +}); + +test('monitoring: fleet and charts', async ({ page }) => { + await open(page, '/monitoring'); + await expect(page.locator('.mon-chart').first()).toBeVisible(); + await expect(page.getByRole('region', { name: 'Fleet' }).locator('tbody tr')).toHaveCount(FLEET.length); + await shoot(page, 'monitoring'); +}); + +test('scores: an expanded eval', async ({ page }) => { + await open(page, '/evals/scores?open=e2e-eval'); + await expect(page.getByRole('region', { name: 'Eval e2e-eval' })).toBeVisible(); + await shoot(page, 'scores'); +}); + +test('datasets: one dataset with its examples', async ({ page }) => { + await open(page, '/evals/datasets/e2e-dataset'); + await expect(rows(page)).toHaveCount(2); + await shoot(page, 'datasets'); +}); + +test('evaluators: the rule list', async ({ page }) => { + await open(page, '/evals/evaluators'); + await expect(rows(page)).toHaveCount(2); + await expect(page.getByRole('switch', { name: 'answered enabled' })).toBeChecked(); + await shoot(page, 'evaluators'); +}); + +test('annotation queue: reviewing an item', async ({ page }) => { + await open(page, '/evals/queues/e2e-queue'); + await expect(page.locator('[data-review-trace="e2e-trace"]')).toBeVisible(); + await shoot(page, 'queue-review'); +}); + +test('prompts: a prompt with labels and a diff', async ({ page }) => { + await open(page, '/prompts/e2e-prompt?diff=1,2'); + await expect(page.getByRole('region', { name: 'Compare versions' })).toBeVisible(); + await shoot(page, 'prompts'); +}); + +test('playground: loaded from a trace generation', async ({ page }) => { + await open(page, '/playground?trace=e2e-trace&fromObs=e2e-obs'); + const typed = () => page.locator('main textarea').evaluateAll((els) => els.map((e) => (e as HTMLTextAreaElement).value).join('|')); + await expect.poll(typed).toContain('Where is my order?'); + await shoot(page, 'playground'); +}); + +test('command palette: open over Traces', async ({ page }) => { + await open(page, '/traces'); + await expect(rows(page).first()).toBeVisible(); + await page.keyboard.press('Control+K'); + const input = page.getByRole('combobox', { name: 'Command' }); + await expect(input).toBeFocused(); + await input.fill('go'); + await page.waitForTimeout(150); + await page.screenshot({ path: path.join(OUT, 'studio-command-palette.png') }); +}); diff --git a/docs/agent-forge.md b/docs/agent-forge.md index 0ea7e3cc..fcd58112 100644 --- a/docs/agent-forge.md +++ b/docs/agent-forge.md @@ -1,15 +1,22 @@ # Agent Forge -Agent Forge (`apps/agent-forge`) is this SDK's companion visual dashboard: -build an agent's graph on a canvas, run it, watch it execute in a live -debug console, chat with it, and author sandboxed pre/post hooks - all -reading and writing the exact same `AgentSpec` YAML that `lousho dev` and -`lousho build` use. It's launched with one command, `lousho studio`, and -ships as part of this SDK's npm package. - -This doc covers: installation, the `lousho studio` quickstart, a -first-agent walkthrough, hook authoring, time travel (replaying a run from -a past step), and how settings/secrets/deploy wiring works. +Agent Forge (`apps/agent-forge`, also called the Studio) is this SDK's +companion dashboard: one app with a left navigation to build and run agents, +try prompts, and read what your agents did from the observe daemon. Build an +agent's graph on a canvas, run it, chat with it and author sandboxed +pre/post hooks (all reading and writing the same `AgentSpec` YAML that +`lousho dev` and `lousho build` use), try prompts in a playground, and read +the [observe daemon](./observability.md#the-observe-daemon)'s traces, +threads, fleet monitoring, eval scores, datasets, annotation queues, prompt +registry and context hub. It's launched with one command, +`lousho studio`, and ships as part of this SDK's npm package. + +This doc covers: installation, the `lousho studio` quickstart, the +[navigation and screens](#navigation), a first-agent walkthrough, hook +authoring, time travel (replaying a run from a past step), the playground +endpoint, and how settings/secrets/deploy wiring works. + +![The Agents screen: nav, canvas, inspector and the bottom drawer](./images/studio-agents.png) ## Installation @@ -34,12 +41,19 @@ npx lousho studio ``` This starts one local server and prints its URL (default -`http://127.0.0.1:4750/?token=`). Open that exact URL in a browser - you'll see the canvas, -the left rail (your saved agents + a node/hook palette), the Inspector -(right), and a bottom drawer with Chat/Logs/Trace/Output/Settings tabs. -Below 860px wide the rail and the Inspector become slide-over panels you -open from the top bar, and Debug, Import, Export and Save move into its -**More** (`⋯`) menu. +`http://127.0.0.1:4750/?token=`). Open that exact URL in a browser. +It lands on the Agents screen (`#/agents`): the left navigation, the build +toolbar (Run, Stop, Debug, Import, Export, Save and the provider pill), the +agent rail (your saved agents plus a node/hook palette), the canvas, the +Inspector (right), and a bottom drawer with Chat, Logs, Trace, Output, +History and Settings tabs. The other screens are one click away in the +navigation; see [Navigation](#navigation). + +Below 860px wide the navigation becomes a drawer opened from the +**Navigation** (menu) button at the top; picking a screen, the scrim or Esc +closes it. On the Agents screen the agent rail and the Inspector become +slide-over panels you open from the top bar, and Debug, Import, Export and +Save move into its **More** (`⋯`) menu. Useful flags: @@ -86,7 +100,7 @@ ran `lousho studio` from (agent YAML files under `.lousho/agents/`, checkpoints and approvals alongside them) - the same `.lousho/` layout `lousho dev`/`lousho build` use. -The left rail lists exactly the agents in that `.lousho/agents/`: a YAML +The Agents screen's agent rail lists exactly the agents in that `.lousho/agents/`: a YAML file you add there shows up, and an agent you create in the studio is written there at once (autosave and **Save** both write the file), so it can be chatted with straight away. Another project, opened in its own @@ -114,10 +128,345 @@ longer parses is skipped with a warning in the server log. `lousho studio` auto-detects based on whether `apps/agent-forge/dist-server` exists. +## Navigation + +The left navigation lists every screen, grouped: + +| Group | Item | Screen | +| --- | --- | --- | +| Build | **Agents** | The builder: canvas, Inspector, bottom drawer. The build toolbar (Run, Stop, Debug, Import, Export, Save, provider pill) shows only here. | +| Build | **Playground** | Try messages against a model without editing an agent. | +| Observe | **Traces** | Every run the observe daemon holds, filtered and sorted; a row opens the trace. | +| Observe | **Threads** | Sessions (conversations): one row per session, one turn per run. | +| Observe | **Monitoring** | The fleet's health and charts over a time window. | +| Evaluate | **Evaluations** | Tabs for Scores, Datasets, Evaluators and Annotation queues. | +| Assets | **Prompts** | The prompt registry: versions, labels, diffs. | +| Assets | **Context Hub** | Shared key-values agents publish to the daemon. | +| | **Settings** | Provider keys, settings profiles and deploy. | + +Off the Agents screen the top bar names the screen and keeps the live run +status pill, nothing else. The last nav item is the collector status (see +[The collector](#the-collector-observe-daemon)). + +### Routes and deep links + +Every screen has a hash route, and a screen's filters live in the URL's +query string, so a reload, a bookmark or a pasted link opens the same screen +with the same filters. Defaults are never written (a bare `#/traces` is the +default 24 h list). Filter, sort and page edits replace the current history +entry, and opening a row pushes one, so the browser's Back returns from a +trace to the list instead of walking through filter edits. An empty or +unknown hash goes to `#/agents` (the `?token=` stays). + +| Route | Screen | Query keys | +| --- | --- | --- | +| `#/agents`, `#/agents/` | Agents, with that agent open | | +| `#/playground` | Playground | `trace` + `fromObs`, or `prompt` (see [Playground](#playground)) | +| `#/traces` | Traces list | `agent`, `status`, `model`, `project`, `q`, `prompt` (`` or `@`), `from` + `to` (ms epoch, half-open), `sort` (`duration`, `tokens`, `cost`) + `order` (`asc`, `desc`), `offset` | +| `#/traces/` | One trace | `obs` (the selected step) | +| `#/threads` | Threads list | `agent`, `status`, `from` + `to`, `offset` | +| `#/threads/` | One thread | | +| `#/monitoring` | Monitoring | `window` (`1h`, `24h`, `7d`, `30d`; default `24h`), `agent` | +| `#/evals/scores` | Scores | `eval` (comma list), `agent`, `source` (`eval`, `judge`, `human`, `online`), `range` (default `30d`), `open` (the expanded eval) | +| `#/evals/datasets`, `#/evals/datasets/` | Datasets, one dataset | `offset` | +| `#/evals/evaluators`, `#/evals/evaluators/` | Evaluators, one rule's edit view | `kind` (`code`, `judge`), `enabled` (`true`, `false`), `offset`; on a rule, `trace` (prefills Test on trace) | +| `#/evals/queues`, `#/evals/queues/` | Annotation queues, one queue's review | `offset` (the list) | +| `#/prompts` | Prompt registry | | +| `#/prompts/`, `#/prompts/@` | One prompt (latest version by default) | `diff=,` | +| `#/hub`, `#/hub/` | Context Hub | `agent` (the "Viewing as" agent), `scope` (`private`, `fleet`) | +| `#/settings` | Settings | | + +A time range picked from a preset other than the default is written as +absolute `from`/`to` bounds, so a reload shows exactly the same rows. The +full route list is `apps/agent-forge/src/screens/routeTable.ts`. + +### Keyboard + +| Keys | Does | +| --- | --- | +| `Ctrl+K` / `Cmd+K` | Open (or close) the command palette, anywhere, even while typing. | +| `?` | Show the keyboard shortcuts (not while typing in a field). | +| `j` / `k` | Next / previous row in a focused table. | +| `Enter` | Open the selected row. | +| `Esc` | Close the palette, the shortcut sheet, a header dialog or the narrow nav drawer. | + +The command palette lists **Go to** every nav screen, **Open agent** +for each saved agent, and **Toggle theme** (light/dark, remembered in the +browser); type to filter. Paste a trace id (8 or more hex digits and +dashes) and the first entry is **Open trace** for it. Arrow keys move, +Enter runs. + +![The command palette over the Traces screen](./images/studio-command-palette.png) + +In an annotation queue's review, Enter submits the scores and `S` skips +the item (see [Annotation queues](#annotation-queues)). + +### Status words + +Every status in the Studio is the same pill: + +- Runs: `ok`, `error`, `running`, `interrupted` (the agent's process died + mid-run; see [The observe daemon](./observability.md#the-observe-daemon)). +- Agents: `healthy`, `stale` (missed heartbeats), `down`, `stopped`. +- Threads: `active`, `ended` (idle, see + [Idle sessions](./observability.md#idle-sessions)), `interrupted`. +- Scores: `pass`, `fail`. +- Builder runs: `idle`, `running`, `done`, `stopped`, `error`, + `awaiting approval`. + +### The collector (observe daemon) + +Everything under Observe, Evaluate and Assets reads the observe daemon +(`lousho observe start`) through the Studio server's `/observe` proxy. The +proxy reads only `LOUSHO_OBSERVE_URL` (default `http://127.0.0.1:7777`); +`LOUSHO_OBSERVE` does not change where the screens read (it only picks where +builder runs report, see the [walkthrough](#first-agent-walkthrough)). The +proxy sends no bearer token: `LOUSHO_OBSERVE_TOKEN` is used only by run +reporting and the judge evaluators (the claim loop and the judge test +route). So point the screens at a loopback daemon +started without `--require-token`. Against a daemon that requires a token, +`/health` still answers (the collector item says running), but every +`/api/` read comes back `401` and the screens show that error instead of +data. The proxy only forwards reads plus the writes the screens make +(feedback scores, prompt label moves, hub entry edits, dataset and example +writes, annotation queue writes); every other write, ingest above all, is +refused. + +The last nav item says whether the daemon answers: **Collector: checking…** +until the first probe, **Collector: running at ``**, or **Collector: +not reachable**. The probe keeps polling. While the daemon is down, each +daemon screen shows the command to start it instead of an error, keeps its +URL, and swaps the data back in by itself once the daemon answers. The +Agents screen and Playground runs do not need the daemon. + +## Screens + +### Agents + +The builder: see the [First-agent walkthrough](#first-agent-walkthrough). +`#/agents/` opens that agent; leaving the screen (or switching agents) +with unsaved changes asks first. Runs started here report to the observe +daemon, and the Run status pill's **Open trace** link opens the run on the +Trace screen. + +### Playground + +`#/playground` (Build > Playground) tries messages against a model +without editing an agent: + +![The Playground, loaded from a trace's model call](./images/studio-playground.png) + +- **Messages**: system, user and assistant rows. Every `{name}` placeholder + in any row gets a **Variables** input, in the order the names first appear. +- **Model**: a provider (the ones with a key under Settings > Provider keys, + plus `ollama` and `mock`; `mock` when nothing is configured) and a model + name, sent as `provider/model`. +- **Tools**: pick a saved agent to borrow its tools, then switch each one on + or off. +- **Run** streams the reply as it arrives; **Stop** aborts it. When the run + finishes, **Open trace** goes to its trace. +- **Save as new prompt version** pushes the system message (and its + variables) to the prompt registry, under the loaded prompt's name or one + you type. It is disabled while there is no system message. + +Two links pre-fill it. **Open in Playground** on a model call (generation) +in a trace opens `#/playground?trace=&fromObs=`, which loads +that generation's messages and model. `#/playground?prompt=[@,` | +| `#/hub`, `#/hub/` | Context Hub | `agent` (the "Viewing as" agent), `scope` (`private`, `fleet`) | +| `#/settings` | Settings | | + +A time range picked from a preset other than the default is written as +absolute `from`/`to` bounds, so a reload shows exactly the same rows. The +full route list is `apps/agent-forge/src/screens/routeTable.ts`. + +### Keyboard + +| Keys | Does | +| --- | --- | +| `Ctrl+K` / `Cmd+K` | Open (or close) the command palette, anywhere, even while typing. | +| `?` | Show the keyboard shortcuts (not while typing in a field). | +| `j` / `k` | Next / previous row in a focused table. | +| `Enter` | Open the selected row. | +| `Esc` | Close the palette, the shortcut sheet, a header dialog or the narrow nav drawer. | + +The command palette lists **Go to** every nav screen, **Open agent** +for each saved agent, and **Toggle theme** (light/dark, remembered in the +browser); type to filter. Paste a trace id (8 or more hex digits and +dashes) and the first entry is **Open trace** for it. Arrow keys move, +Enter runs. + +In an annotation queue's review, Enter submits the scores and `S` skips +the item (see [Annotation queues](#annotation-queues)). + +### Status words + +Every status in the Studio is the same pill: + +- Runs: `ok`, `error`, `running`, `interrupted` (the agent's process died + mid-run; see [The observe daemon](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/observability.md#the-observe-daemon)). +- Agents: `healthy`, `stale` (missed heartbeats), `down`, `stopped`. +- Threads: `active`, `ended` (idle, see + [Idle sessions](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/observability.md#idle-sessions)), `interrupted`. +- Scores: `pass`, `fail`. +- Builder runs: `idle`, `running`, `done`, `stopped`, `error`, + `awaiting approval`. + +### The collector (observe daemon) + +Everything under Observe, Evaluate and Assets reads the observe daemon +(`lousho observe start`) through the Studio server's `/observe` proxy. The +proxy reads only `LOUSHO_OBSERVE_URL` (default `http://127.0.0.1:7777`); +`LOUSHO_OBSERVE` does not change where the screens read (it only picks where +builder runs report, see the [walkthrough](#first-agent-walkthrough)). The +proxy sends no bearer token: `LOUSHO_OBSERVE_TOKEN` is used only by run +reporting and the judge evaluators (the claim loop and the judge test +route). So point the screens at a loopback daemon +started without `--require-token`. Against a daemon that requires a token, +`/health` still answers (the collector item says running), but every +`/api/` read comes back `401` and the screens show that error instead of +data. The proxy only forwards reads plus the writes the screens make +(feedback scores, prompt label moves, hub entry edits, dataset and example +writes, annotation queue writes); every other write, ingest above all, is +refused. + +The last nav item says whether the daemon answers: **Collector: checking…** +until the first probe, **Collector: running at ``**, or **Collector: +not reachable**. The probe keeps polling. While the daemon is down, each +daemon screen shows the command to start it instead of an error, keeps its +URL, and swaps the data back in by itself once the daemon answers. The +Agents screen and Playground runs do not need the daemon. + +## Screens + +### Agents + +The builder: see the [First-agent walkthrough](#first-agent-walkthrough). +`#/agents/` opens that agent; leaving the screen (or switching agents) +with unsaved changes asks first. Runs started here report to the observe +daemon, and the Run status pill's **Open trace** link opens the run on the +Trace screen. + +### Playground + +`#/playground` (Build > Playground) tries messages against a model +without editing an agent: + +- **Messages**: system, user and assistant rows. Every `{name}` placeholder + in any row gets a **Variables** input, in the order the names first appear. +- **Model**: a provider (the ones with a key under Settings > Provider keys, + plus `ollama` and `mock`; `mock` when nothing is configured) and a model + name, sent as `provider/model`. +- **Tools**: pick a saved agent to borrow its tools, then switch each one on + or off. +- **Run** streams the reply as it arrives; **Stop** aborts it. When the run + finishes, **Open trace** goes to its trace. +- **Save as new prompt version** pushes the system message (and its + variables) to the prompt registry, under the loaded prompt's name or one + you type. It is disabled while there is no system message. + +Two links pre-fill it. **Open in Playground** on a model call (generation) +in a trace opens `#/playground?trace=&fromObs=`, which loads +that generation's messages and model. `#/playground?prompt=[@