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
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,20 @@ docker compose up -d # picks up the changed .env
— five agents in series building a classifier on a public dataset. It picks up
exactly where this step leaves off.

## Persona library

Add a Persona node and choose a built-in preset or a saved persona in its
inspector. Uploading a plain `.md` file saves a reusable library entry named
from the filename and copies its text into the node or factor level. No
frontmatter, description, or personality metadata is required. The original
local file is unchanged.

Use **Manage personas / Save current** in the node inspector or persona factor
editor to save typed instructions, edit library entries, or delete them.
Existing nodes and factor levels retain their copied text; choose an entry
again to use its latest revision. Published experiments keep their frozen
persona text even if the library entry is later edited or deleted.

## Measurements and Results

The Design panel offers built-in runtime and supervised-ML metrics with their
Expand Down
17 changes: 16 additions & 1 deletion frontend/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import type { OkfBundle, OkfDocument } from '@/types/okf'
import type { CellRunBatch, DatasetRowSchema, PromptPreview, Protocol, ProtocolGraph, ProtocolRevision, ProtocolRun, TestRun } from '@/types/protocols'
import type { Run, RunStep } from '@/types/runs'
import type { Skill, SkillListResponse, SkillUrlPreview } from '@/types/skills'
import type { Persona } from '@/types/personas'
import type { AttemptDetail, AttemptPage, CursorPage, ExecutionCell, ExecutionSlot, ExecutionSummary, ResultJob } from '@/types/executionResults'

const ACCESS_TOKEN_KEY = 'asaree_access_token'
Expand Down Expand Up @@ -189,7 +190,7 @@ export interface ResultsScope {
protocol_revision_id?: string
}

export interface RowSelection { row_index?: number | null; sub_agent_selections?: Record<string, string>; script_selections?: Record<string, string>; tool_selections?: Record<string, string>; skill_selections?: Record<string, string>; dataset_selections?: Record<string, string>; knowledge_selections?: Record<string, string> }
export interface RowSelection { row_index?: number | null; sub_agent_selections?: Record<string, string>; script_selections?: Record<string, string>; tool_selections?: Record<string, string>; skill_selections?: Record<string, string>; persona_selections?: Record<string, string>; dataset_selections?: Record<string, string>; knowledge_selections?: Record<string, string> }

function scopeQuery(scope?: ResultsScope): string {
const params = new URLSearchParams()
Expand Down Expand Up @@ -497,6 +498,20 @@ export const mcpServersApi = {
remove: (id: string) => request<void>(`/mcp-servers/${id}`, { method: 'DELETE' }),
}

export const personasApi = {
list: () => request<Persona[]>('/personas'),
create: (data: { prompt: string; name?: string; description?: string | null; traits?: Record<string, unknown> }) =>
request<Persona>('/personas', { method: 'POST', body: data }),
upload: (file: File) => {
const form = new FormData()
form.set('file', file)
return request<Persona>('/personas/upload', { method: 'POST', body: form })
},
update: (id: string, data: { expected_revision: number; name?: string; prompt?: string; description?: string | null; traits?: Record<string, unknown> }) =>
request<Persona>(`/personas/${id}`, { method: 'PATCH', body: data }),
remove: (id: string) => request<void>(`/personas/${id}`, { method: 'DELETE' }),
}

export const skillsApi = {
// The caller's own skills plus any global system skill -- GET /skills
// returns {items,total}, unwrapped here so callers get a plain array like
Expand Down
59 changes: 39 additions & 20 deletions frontend/src/components/protocol/AddNodePanel.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { PERSONA_BROWSE } from './personaCatalog'
import { useState } from 'react'
import { ArrowRight, Atom, BookMarked, Bot, Braces, BrainCircuit, Cloud, Code2, Cog, Database, FileText, HardDrive, Repeat2, Route, ScrollText, ShieldCheck, Server, Sparkles, User, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Card } from '@/components/ui/card'
import { nodeAccent } from '@/lib/nodeAccent'
import { cardAccent } from '@/lib/utils'
import { DATASET_BROWSE } from './datasetCatalog'
import { MCP_SERVER_BROWSE } from './mcpServerCatalog'
import { OKF_BUNDLE_BROWSE, OKF_DOCUMENT_BROWSE } from './okfCatalog'
Expand All @@ -17,6 +21,16 @@ const NODE_CATEGORIES = [

type NodeCategory = (typeof NODE_CATEGORIES)[number]['id']

// Library entries create nodes of these kinds; all model providers share a hue.
const CATALOG_ACCENT_KINDS: Record<string, string> = {
[PERSONA_BROWSE]: 'persona',
[DATASET_BROWSE]: 'dataset',
[MCP_SERVER_BROWSE]: 'mcp_tool',
[SKILL_BROWSE]: 'skill',
[OKF_BUNDLE_BROWSE]: 'okf_bundle',
[OKF_DOCUMENT_BROWSE]: 'okf_document',
}

// Every entry here earns its place: GET /api/mcp-servers already backs the
// tool picker, GET /datasets already backs the dataset picker,
// services.protocol_execution._run_gated_worker already implements the
Expand Down Expand Up @@ -108,10 +122,10 @@ const NODE_CATALOG = [
icon: BrainCircuit,
},
{
type: 'persona',
type: PERSONA_BROWSE,
category: 'knowledge',
label: 'Persona',
description: 'OCEAN personality traits for experimental manipulation of agent behavior',
label: 'Personas',
description: 'Browse persona instructions for an Agent',
icon: User,
},
// Not a node type either -- picking this opens the dataset browser
Expand Down Expand Up @@ -207,15 +221,15 @@ export function AddNodePanel({
})

return (
<div className="flex min-h-0 w-80 shrink-0 flex-col gap-3 overflow-hidden border-l p-4">
<div className="flex min-h-0 min-w-0 w-full shrink-0 flex-col gap-3 overflow-hidden border-l p-4">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold">{title}</p>
<Button variant="ghost" size="icon-sm" aria-label="Close" onClick={onClose}>
<X className="size-4" />
</Button>
</div>
<Input autoFocus placeholder="Search node types…" value={query} onChange={(e) => setQuery(e.target.value)} />
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
<div className="min-h-0 flex-1 overflow-y-auto p-1">
{filtered.length === 0 && <p className="py-4 text-center text-sm text-muted-foreground">No matching node types.</p>}
<div className="flex flex-col gap-4">
{groups.map((group) => (
Expand All @@ -225,23 +239,28 @@ export function AddNodePanel({
</h3>
<div className="flex flex-col gap-1.5">
{group.items.map((item) => (
<button
<Card
key={item.type}
type="button"
disabled={!allowedTypes && item.type === 'sub_agent'}
onClick={() => onAdd(item.type)}
className="flex cursor-pointer items-center gap-2.5 rounded-lg border bg-background px-3 py-2.5 text-left text-sm shadow-[0_0_16px_-6px_var(--primary)] ring-1 ring-primary/20 transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-55 disabled:hover:bg-background"
style={cardAccent(nodeAccent(CATALOG_ACCENT_KINDS[item.type] ?? (item.type.startsWith('model_') ? 'model' : item.type)))}
className="gap-0 p-0"
>
<item.icon className="size-4 shrink-0 text-primary" />
<div className="min-w-0">
<p className="font-medium">{item.label}</p>
<p className="truncate text-xs text-muted-foreground">
{!allowedTypes && item.type === 'sub_agent'
? "Add from an Agent's Sub-Agents connector"
: item.description}
</p>
</div>
</button>
<button
type="button"
disabled={!allowedTypes && item.type === 'sub_agent'}
onClick={() => onAdd(item.type)}
className="flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-3 py-2.5 text-left text-sm transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-55 disabled:hover:bg-background"
>
<item.icon className="size-4 shrink-0 text-[color:var(--card-accent,var(--primary))]" />
<div className="min-w-0">
<p className="font-medium">{item.label}</p>
<p className="truncate text-xs text-muted-foreground">
{!allowedTypes && item.type === 'sub_agent'
? "Add from an Agent's Sub-Agents connector"
: item.description}
</p>
</div>
</button>
</Card>
))}
</div>
</section>
Expand Down
14 changes: 9 additions & 5 deletions frontend/src/components/protocol/DatasetBrowserPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { Card } from '@/components/ui/card'
import { nodeAccent } from '@/lib/nodeAccent'
import { cardAccent } from '@/lib/utils'
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { ArrowLeft, Database, Plus, Trash2, X } from 'lucide-react'
Expand Down Expand Up @@ -64,7 +67,7 @@ export function DatasetBrowserPanel({
)

return (
<div className="flex min-h-0 w-80 shrink-0 flex-col gap-3 overflow-hidden border-l p-4">
<div className="flex min-h-0 min-w-0 w-full shrink-0 flex-col gap-3 overflow-hidden border-l p-4">
<div className="flex shrink-0 items-center justify-between">
<div className="flex min-w-0 items-center gap-1.5">
<Button variant="ghost" size="icon-sm" aria-label="Back" onClick={onBack}>
Expand Down Expand Up @@ -100,9 +103,10 @@ export function DatasetBrowserPanel({
{filtered.map((dataset) => (
// Same metrics as SkillBrowserPanel's rows (px-3 py-2.5, text-sm,
// three single-line rows) so every browser reads as one list style.
<div
<Card
key={dataset.id}
className="rounded-lg border bg-background px-3 py-2.5 text-sm shadow-[0_0_16px_-6px_var(--primary)] ring-1 ring-primary/20 transition-colors hover:bg-muted"
style={cardAccent(nodeAccent('dataset'))}
className="gap-0 px-3 py-2.5 transition-colors hover:bg-muted"
>
{confirmingDeleteId === dataset.id ? (
<div className="space-y-2">
Expand Down Expand Up @@ -134,7 +138,7 @@ export function DatasetBrowserPanel({
onClick={() => onPick(dataset)}
className="flex min-w-0 flex-1 cursor-pointer items-start gap-2.5 text-left"
>
<Database className="mt-0.5 size-4 shrink-0 text-primary" />
<Database className="mt-0.5 size-4 shrink-0 text-[color:var(--card-accent,var(--primary))]" />
<div className="min-w-0 flex-1">
<p className="truncate font-mono font-medium">{dataset.name}</p>
{/* One line, same as the other browsers' rows -- the
Expand Down Expand Up @@ -172,7 +176,7 @@ export function DatasetBrowserPanel({
</div>
</div>
)}
</div>
</Card>
))}
</div>
)}
Expand Down
6 changes: 4 additions & 2 deletions frontend/src/components/protocol/DesignTab.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { personaFactorIssues } from '@/lib/personaFactors'
import { subAgentFactorIssues } from '@/lib/subAgentFactors'
import { sharedGroupIssues } from '@/lib/sharedFactors'
import { knowledgeFactorIssues, isKnowledgeFactor } from '@/lib/knowledgeFactors'
Expand Down Expand Up @@ -527,6 +528,7 @@ export function DesignTab({
const toolLibrary = useQuery({ queryKey: ['mcp-servers'], queryFn: () => mcpServersApi.list(), enabled: factors.some((factor) => factor.level_type === 'tool_selection' || factor.level_type === 'tool_toggle') })
const scriptIssues = scriptFactorIssues(draftGraph, factors)
const toolIssues = toolFactorIssues(draftGraph, factors, toolLibrary.data ? new Set(toolLibrary.data.map((server) => server.id)) : undefined)
const personaIssues = personaFactorIssues(draftGraph, factors)
const skillIssues = skillFactorIssues(draftGraph, factors, skillLibrary.data ? new Set(skillLibrary.data.map((skill) => skill.id)) : undefined)
const impact = impactQuery.data

Expand Down Expand Up @@ -896,14 +898,14 @@ export function DesignTab({
Unbound factor{unboundFactors.length === 1 ? '' : 's'}: {unboundFactors.join(', ')}. Rebind on the canvas or remove from this design.
</p>
)}
{[...patternIssues, ...scriptIssues, ...toolIssues, ...subAgentIssues, ...skillIssues, ...datasetIssues, ...knowledgeIssues].map((issue) => <p key={issue} className="text-xs text-destructive">{issue}</p>)}
{[...patternIssues, ...scriptIssues, ...toolIssues, ...subAgentIssues, ...skillIssues, ...personaIssues, ...datasetIssues, ...knowledgeIssues].map((issue) => <p key={issue} className="text-xs text-destructive">{issue}</p>)}
<Button
size="sm"
disabled={
generateMutation.isPending ||
isAutosavingMetadata ||
!canGenerate ||
patternIssues.length > 0 || scriptIssues.length > 0 || toolIssues.length > 0 || (factors.some((factor) => factor.level_type === 'tool_selection' || factor.level_type === 'tool_toggle') && !toolLibrary.isSuccess) || unboundFactors.length > 0 || subAgentIssues.length > 0 || knowledgeIssues.length > 0 || (factors.some(isKnowledgeFactor) && !knowledgeLibrary.isSuccess) || skillIssues.length > 0 || datasetIssues.length > 0 || (factors.some((factor) => factor.level_type === 'dataset_selection' || factor.level_type === 'dataset_toggle') && !datasetLibrary.isSuccess) || (factors.some((factor) => (factor.level_type === 'skill_selection' || factor.level_type === 'skill_toggle')) && !skillLibrary.isSuccess)
patternIssues.length > 0 || scriptIssues.length > 0 || toolIssues.length > 0 || (factors.some((factor) => factor.level_type === 'tool_selection' || factor.level_type === 'tool_toggle') && !toolLibrary.isSuccess) || unboundFactors.length > 0 || subAgentIssues.length > 0 || knowledgeIssues.length > 0 || (factors.some(isKnowledgeFactor) && !knowledgeLibrary.isSuccess) || personaIssues.length > 0 || skillIssues.length > 0 || datasetIssues.length > 0 || (factors.some((factor) => factor.level_type === 'dataset_selection' || factor.level_type === 'dataset_toggle') && !datasetLibrary.isSuccess) || (factors.some((factor) => (factor.level_type === 'skill_selection' || factor.level_type === 'skill_toggle')) && !skillLibrary.isSuccess)
}
onClick={() => generateMutation.mutate()}
>
Expand Down
39 changes: 39 additions & 0 deletions frontend/src/components/protocol/FactorEditorDialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,49 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { expect, it, vi } from 'vitest'
import type { DesignFactor } from '@/types/experiments'
import { FactorEditorDialog } from './FactorEditorDialog'
import { personasApi } from '@/api/client'

const factorA: DesignFactor = { name: 'Model parameter', level_type: 'number', levels: [2048, 1024], level_labels: ['high', 'low'] }
const factorB: DesignFactor = { ...factorA, levels: [1024, 2048], level_labels: ['low', 'high'] }

it.each([null, '01-analyst'])('uploads to the intended persona level when both levels share persona_id %s', async (personaId) => {
vi.spyOn(personasApi, 'list').mockResolvedValue([])
vi.spyOn(personasApi, 'upload').mockResolvedValue({
id: 'saved-persona', name: 'methodical', prompt: 'You are methodical.', source_filename: 'methodical.md',
description: null, traits: {}, revision: 1, prompt_sha256: 'hash', created_at: '', updated_at: '',
})
const save = vi.fn().mockResolvedValue(undefined)
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
render(
<QueryClientProvider client={client}>
<FactorEditorDialog open onOpenChange={vi.fn()} onSave={save} factor={{
name: 'Persona', level_type: 'persona_text', levels: [
{ mode: 'text', persona_text: 'First treatment', persona_id: personaId },
{ mode: 'text', persona_text: 'Second treatment', persona_id: personaId },
],
}} />
</QueryClientProvider>,
)
const inputs = screen.getAllByLabelText<HTMLInputElement>('Upload persona (.md)')
const firstClick = vi.spyOn(inputs[0], 'click')
const secondClick = vi.spyOn(inputs[1], 'click')
fireEvent.click(screen.getAllByRole('button', { name: 'Choose .md file' })[1])
expect(secondClick).toHaveBeenCalledOnce()
expect(firstClick).not.toHaveBeenCalled()

const file = new File(['You are methodical.'], 'methodical.md', { type: 'text/markdown' })
fireEvent.change(inputs[1], { target: { files: [file] } })
await waitFor(() => expect(screen.getByRole('button', { name: 'methodical.md' })).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
await waitFor(() => expect(save).toHaveBeenCalledWith(expect.objectContaining({
levels: [
expect.objectContaining({ persona_text: 'First treatment' }),
expect.objectContaining({ persona_text: 'You are methodical.', persona_file: 'methodical.md', mode: 'file' }),
],
}), undefined))
expect(personasApi.upload).toHaveBeenCalledWith(file, expect.anything())
})

it.each(['tool_selection', 'dataset_selection', 'skill_selection', 'knowledge_selection', 'script_selection', 'sub_agent_selection'] as const)(
'refreshes untouched %s editors and preserves level-label pairs', async (level_type) => {
const save = vi.fn().mockResolvedValue(undefined)
Expand Down
Loading
Loading