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
13 changes: 13 additions & 0 deletions apps/agent-forge/e2e/narrow.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,16 @@ test('at 1280px the three-column layout has no drawer toggles', async ({ page })
await expect(page.getByRole('button', { name: 'More actions' })).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Save' })).toBeVisible();
});

test('#/playground at 375px stacks into one column with nothing scrolling sideways', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 });
await page.goto('/?token=e2e-studio-token#/playground');
const main = page.getByRole('main');
await expect(main.getByRole('heading', { name: 'Playground' })).toBeVisible();
expect(await horizontalOverflow(page)).toBe(0);
expect(await main.evaluate((el) => el.scrollWidth - el.clientWidth)).toBe(0);
for (const name of ['Run', 'Add message', 'Save as new prompt version']) {
const box = await main.getByRole('button', { name, exact: true }).boundingBox();
expect(box && box.x >= 0 && box.x + box.width <= 375, `${name} is on-screen`).toBe(true);
}
});
25 changes: 2 additions & 23 deletions apps/agent-forge/server/playground.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ import {
InMemoryApprovalStore,
SDKError,
type AgentEvent,
type AgentEventUsage,
type AgentSpec,
type Message,
} from '@lousho/build-ai-agent';
Expand All @@ -38,6 +37,7 @@ import {
import { buildAgentFromSpec } from './buildAgent';
import { observeTarget } from './observeProxy';
import type { SecretsStore } from './secretsStore';
import type { PlaygroundEvent, PlaygroundMessage, PlaygroundRunRequest } from '../shared/wireTypes';

/** The agent name every playground run reports under. */
const PLAYGROUND_AGENT_NAME = 'playground';
Expand All @@ -47,28 +47,7 @@ const PROMPT_REF_ATTR = 'lousho.prompt.ref';
const ROLES = new Set(['system', 'user', 'assistant']);
const MAX_MESSAGES = 200;

interface PlaygroundMessage {
role: 'system' | 'user' | 'assistant';
content: string;
}

/** The validated `POST /playground/run` body. */
interface PlaygroundRunRequest {
messages: PlaygroundMessage[];
variables?: Record<string, string>;
/** `provider/model` (`openai/gpt-4o-mini`, `mock`, `ollama/llama3.2`). */
model: string;
/** Tool names the Studio already resolves (`current-date`, `day-name`, `http`, `web-fetch`, `demo-approval`). */
tools?: string[];
promptRef?: string;
}

/** One SSE `data:` payload of the stream. */
export type PlaygroundEvent =
| { type: 'delta'; text: string }
| { type: 'tool_call'; id: string; name: string; args: Record<string, unknown> }
| { type: 'done'; finishReason: string; text: string; traceId?: string; usage?: AgentEventUsage }
| { type: 'error'; message: string; code?: string };
export type { PlaygroundEvent };

const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value);
Expand Down
26 changes: 25 additions & 1 deletion apps/agent-forge/shared/wireTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
* defines the wire shape of the status/events the server reports back to
* the app over `GET /agents/:id/status` and `WS /agents/:id/stream`.
*/
import type { CheckpointStatus, TrajectoryComparison, TrajectoryStep } from '@lousho/build-ai-agent';
import type { AgentEventUsage, CheckpointStatus, TrajectoryComparison, TrajectoryStep } from '@lousho/build-ai-agent';

/**
* Status pill states the app's agent list / topbar render (as in the
Expand Down Expand Up @@ -292,3 +292,27 @@ export interface ForkRunResponse {

/** `GET /runs/compare?a=&b=` - `compareTrajectories(a, b)`. */
export type RunComparisonPayload = TrajectoryComparison;

/** One `POST /playground/run` message (server/playground.ts; the Playground screen's editor rows). */
export interface PlaygroundMessage {
role: 'system' | 'user' | 'assistant';
content: string;
}

/** The `POST /playground/run` body. */
export interface PlaygroundRunRequest {
messages: PlaygroundMessage[];
variables?: Record<string, string>;
/** `provider/model` (`openai/gpt-4o-mini`, `mock`, `ollama/llama3.2`). */
model: string;
/** Tool names the Studio already resolves (`current-date`, `day-name`, `http`, `web-fetch`, `demo-approval`). */
tools?: string[];
promptRef?: string;
}

/** One SSE `data:` payload of the `POST /playground/run` stream; every stream ends with one `done` or `error`. */
export type PlaygroundEvent =
| { type: 'delta'; text: string }
| { type: 'tool_call'; id: string; name: string; args: Record<string, unknown> }
| { type: 'done'; finishReason: string; text: string; traceId?: string; usage?: AgentEventUsage }
| { type: 'error'; message: string; code?: string };
47 changes: 47 additions & 0 deletions apps/agent-forge/src/observe/observeClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,53 @@ export class ObserveClient {
postScore(score: NewScore): Promise<{ score_id: string }> {
return this.request('/api/v1/scores', { method: 'POST', body: score });
}

/** `GET /api/v1/prompts/:name/:ref` (ref = `latest`, a version number or a label); `undefined` on the daemon's 404. */
async getPrompt(name: string, ref = 'latest'): Promise<ObservePrompt | undefined> {
try {
const body = await this.request<{ prompt: ObservePrompt }>(
`/api/v1/prompts/${encodeURIComponent(name)}/${encodeURIComponent(ref)}`
);
return body.prompt;
} catch (error) {
if (error instanceof ObserveApiError && error.status === 404) return undefined;
throw error;
}
}

/** `POST /api/v1/prompts` - pushes a new version (`unchanged` when the text matches the latest one). */
createPrompt(input: NewPromptVersion): Promise<PromptPushResult> {
return this.request('/api/v1/prompts', { method: 'POST', body: input });
}
}

/** One registry prompt version, as `GET /api/v1/prompts/:name/:ref` returns it. */
export interface ObservePrompt {
prompt_id: string;
name: string;
version: number;
labels: string[];
/** `null` when the version was pushed without a variable list. */
variables: string[] | null;
hash: string;
text: string;
created_at: number;
}

/** The body of {@link ObserveClient.createPrompt}. */
export interface NewPromptVersion {
name: string;
text: string;
labels?: string[];
variables?: string[];
}

/** The answer of {@link ObserveClient.createPrompt}: the stored version, and whether it was new. */
export interface PromptPushResult {
prompt: ObservePrompt;
created: boolean;
version: number;
unchanged: boolean;
}

export const observeClient = new ObserveClient();
72 changes: 72 additions & 0 deletions apps/agent-forge/src/screens/playground/MessageEditor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import type { PlaygroundMessage, PlaygroundRole } from './types';

const ROLES: PlaygroundRole[] = ['system', 'user', 'assistant'];
const MAX_ROWS = 200;

interface MessageEditorProps {
messages: PlaygroundMessage[];
onChange: (messages: PlaygroundMessage[]) => void;
}

/** The message rows: a role select and a text area each, plus remove and "Add message". */
export function MessageEditor({ messages, onChange }: MessageEditorProps) {
const update = (index: number, patch: Partial<PlaygroundMessage>) =>
onChange(messages.map((m, i) => (i === index ? { ...m, ...patch } : m)));
const remove = (index: number) => onChange(messages.filter((_, i) => i !== index));
const add = () => {
const last = messages.at(-1)?.role;
onChange([...messages, { role: last === 'user' ? 'assistant' : 'user', content: '' }]);
};

return (
<section className="pg-panel pg-messages" aria-labelledby="pg-messages-title">
<h3 id="pg-messages-title" className="pg-panel-title">
Messages
</h3>
<ol className="pg-message-list">
{messages.map((message, index) => (
<li key={index} className={`pg-message pg-message-${message.role}`}>
<div className="pg-message-head">
<label>
<span className="visually-hidden">Role of message {index + 1}</span>
<select
className="pg-role"
value={message.role}
onChange={(e) => update(index, { role: e.target.value as PlaygroundRole })}
>
{ROLES.map((role) => (
<option key={role} value={role}>
{role}
</option>
))}
</select>
</label>
<button
type="button"
className="btn btn-ghost pg-remove"
aria-label={`Remove message ${index + 1}`}
disabled={messages.length === 1}
onClick={() => remove(index)}
>
Remove
</button>
</div>
<label>
<span className="visually-hidden">Content of message {index + 1}</span>
<textarea
className="pg-content"
rows={message.role === 'system' ? 4 : 3}
placeholder={message.role === 'system' ? 'System instructions, e.g. You are {persona}.' : 'Message text'}
value={message.content}
onChange={(e) => update(index, { content: e.target.value })}
/>
</label>
</li>
))}
</ol>
<button type="button" className="btn" onClick={add} disabled={messages.length >= MAX_ROWS}>
Add message
</button>
</section>
);
}
78 changes: 78 additions & 0 deletions apps/agent-forge/src/screens/playground/ModelPicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { useEffect, useState } from 'react';

import type { ProviderKeyStatus } from '../../../shared/wireTypes';
import { runtimeClient } from '../../runtime/runtimeClient';
import { MOCK_PROVIDER, defaultProvider, providerOptions } from './request';

/** The picker's value; `provider: null` means "not chosen yet" (the default is used). */
export interface ModelChoice {
provider: string | null;
model: string;
}

/** The Studio's provider keys (`GET /settings/providers`); empty until they load, or when the server cannot say. */
export function useProviderKeys(): ProviderKeyStatus[] {
const [keys, setKeys] = useState<ProviderKeyStatus[]>([]);
useEffect(() => {
let live = true;
runtimeClient.listProviderKeys().then(
(list) => {
if (live) setKeys(list);
},
() => undefined
);
return () => {
live = false;
};
}, []);
return keys;
}

/** The provider to use: the chosen one, else the first with a key, else `mock`. */
export function effectiveProvider(choice: ModelChoice, keys: readonly ProviderKeyStatus[]): string {
return choice.provider ?? defaultProvider(keys);
}

interface ModelPickerProps {
choice: ModelChoice;
keys: ProviderKeyStatus[];
onChange: (choice: ModelChoice) => void;
}

function placeholderFor(provider: string): string {
if (provider === MOCK_PROVIDER) return 'mock needs no model';
return provider === '' ? 'provider/model' : 'model name';
}

/** Provider select (keyed providers, `ollama`, `mock`, or custom) plus a model-name input. */
export function ModelPicker({ choice, keys, onChange }: ModelPickerProps) {
const provider = effectiveProvider(choice, keys);
const options = providerOptions(keys);
if (provider !== '' && !options.includes(provider)) options.unshift(provider);
const isMock = provider === MOCK_PROVIDER;
return (
<div className="pg-model">
<label className="pg-field">
<span className="pg-field-label">Provider</span>
<select value={provider} onChange={(e) => onChange({ ...choice, provider: e.target.value })}>
{options.map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
<option value="">custom (provider/model)</option>
</select>
</label>
<label className="pg-field">
<span className="pg-field-label">Model</span>
<input
type="text"
value={isMock ? '' : choice.model}
disabled={isMock}
placeholder={placeholderFor(provider)}
onChange={(e) => onChange({ provider, model: e.target.value })}
/>
</label>
</div>
);
}
Loading
Loading