From 904392a5d54d0706e084051646ac0506e3bf2c05 Mon Sep 17 00:00:00 2001 From: Jay Moran Date: Wed, 21 Oct 2026 11:20:11 -0700 Subject: [PATCH 1/7] fix: correct persona connector and file upload behavior --- .../protocol/FactorEditorDialog.test.tsx | 33 +++++++++++++++++++ .../protocol/FactorEditorDialog.tsx | 10 +++--- .../protocol/PersonaNodeInspector.tsx | 12 ++++--- .../protocol/connectionValidation.test.ts | 17 ++++++++++ frontend/src/lib/coordinationStrategy.ts | 1 + 5 files changed, 64 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/protocol/FactorEditorDialog.test.tsx b/frontend/src/components/protocol/FactorEditorDialog.test.tsx index dc9bd379..745d8f1f 100644 --- a/frontend/src/components/protocol/FactorEditorDialog.test.tsx +++ b/frontend/src/components/protocol/FactorEditorDialog.test.tsx @@ -7,6 +7,39 @@ import { FactorEditorDialog } from './FactorEditorDialog' 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) => { + const save = vi.fn().mockResolvedValue(undefined) + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + render( + + + , + ) + const inputs = screen.getAllByLabelText('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' }) + Object.defineProperty(file, 'text', { value: vi.fn().mockResolvedValue('You are methodical.') }) + 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)) +}) + 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) diff --git a/frontend/src/components/protocol/FactorEditorDialog.tsx b/frontend/src/components/protocol/FactorEditorDialog.tsx index dce7e092..013e876c 100644 --- a/frontend/src/components/protocol/FactorEditorDialog.tsx +++ b/frontend/src/components/protocol/FactorEditorDialog.tsx @@ -2,7 +2,7 @@ import type { ModelCapabilities } from '@/types/llmSettings' import { modelCapabilities } from '@/lib/modelCapabilities' import { SubAgentFactorEditor } from './SubAgentFactorEditor' import type { ProtocolGraph } from '@/types/protocols' -import { useEffect, useMemo, useState, type SetStateAction } from 'react' +import { useEffect, useId, useMemo, useState, type SetStateAction } from 'react' import { useQuery } from '@tanstack/react-query' import { Plus, Split, X } from 'lucide-react' import { Badge } from '@/components/ui/badge' @@ -422,6 +422,7 @@ function DatasetConfigLevelRow({ } function PersonaLevelRow({ value, onChange }: { value: StructuredLevel; onChange: (next: StructuredLevel) => void }) { + const uploadId = useId() const [selectedPersonas, setSelectedPersonas] = useState(() => { const ids = value.persona_ids as string[] | undefined if (ids && Array.isArray(ids) && ids.length > 0) return ids @@ -576,26 +577,27 @@ function PersonaLevelRow({ value, onChange }: { value: StructuredLevel; onChange )}
- +
+

Upload any .md file containing personality or behavior instructions to prepend to the agent's system prompt.

diff --git a/frontend/src/components/protocol/PersonaNodeInspector.tsx b/frontend/src/components/protocol/PersonaNodeInspector.tsx index d1c404b0..16dceacd 100644 --- a/frontend/src/components/protocol/PersonaNodeInspector.tsx +++ b/frontend/src/components/protocol/PersonaNodeInspector.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useId, useState } from 'react' import { nodeAccent } from '@/lib/nodeAccent' import { User, Upload, Library, Plus, X } from 'lucide-react' import { Label } from '@/components/ui/label' @@ -29,6 +29,7 @@ export function PersonaNodeInspector({ onDelete: (nodeId: string) => void onClose: () => void }) { + const uploadId = useId() // Track multiple selected personas for combination (can include empty strings for unfilled slots) const [selectedPersonas, setSelectedPersonas] = useState(() => { if (!node) return [] @@ -231,27 +232,28 @@ export function PersonaNodeInspector({ )}
- {/* File upload for skill.md */} + {/* Persona Markdown upload */}
- +
+

Upload any .md file containing personality or behavior instructions to prepend to the agent's system prompt.

{/* Persona text with factor binding - toolbar creation like AgentNodeInspector */} diff --git a/frontend/src/components/protocol/connectionValidation.test.ts b/frontend/src/components/protocol/connectionValidation.test.ts index d87f7187..335e5ede 100644 --- a/frontend/src/components/protocol/connectionValidation.test.ts +++ b/frontend/src/components/protocol/connectionValidation.test.ts @@ -1,10 +1,26 @@ import type { Connection, Edge, Node } from '@xyflow/react' import { describe, expect, it } from 'vitest' import { isProtocolConnectionValid } from './connectionValidation' +import { isMainEdge } from '@/lib/coordinationStrategy' const target: Node = { id: 'agent', type: 'agent', position: { x: 0, y: 0 }, data: {} } describe('isProtocolConnectionValid', () => { + it('treats a persona as configuration rather than an upstream pipeline input', () => { + expect(isMainEdge({ targetHandle: 'persona' })).toBe(false) + expect(isMainEdge({ targetHandle: null })).toBe(true) + }) + + it('allows a sequential handoff into an Agent that already has a persona', () => { + const upstream: Node = { ...target, id: 'upstream' } + const persona: Node = { ...target, id: 'persona', type: 'persona' } + const connection: Connection = { source: upstream.id, target: target.id, sourceHandle: null, targetHandle: null } + const personaEdge: Edge = { id: 'persona-edge', source: persona.id, target: target.id, targetHandle: 'persona' } + const nodes = [upstream, target, persona] + expect(isProtocolConnectionValid(connection, nodes, [personaEdge], true)).toBe(true) + expect(isProtocolConnectionValid(connection, nodes, [personaEdge, { ...connection, id: 'handoff' }], true)).toBe(false) + }) + it('allows the same Sub-Agent to connect to multiple parent Agents', () => { const child: Node = { id: 'child', type: 'sub_agent', position: { x: 0, y: 0 }, data: {} } const other = { ...target, id: 'other' } @@ -53,6 +69,7 @@ describe('isProtocolConnectionValid', () => { ['tool', 'mcp_tool'], ['dataset', 'dataset'], ['skill', 'skill'], + ['persona', 'persona'], ['knowledge', 'okf_bundle'], ])('continues to accept multiple %s connections', (slot, sourceType) => { const firstSource: Node = { id: 'first', type: sourceType, position: { x: 0, y: 0 }, data: {} } diff --git a/frontend/src/lib/coordinationStrategy.ts b/frontend/src/lib/coordinationStrategy.ts index b20f467f..1bc588bf 100644 --- a/frontend/src/lib/coordinationStrategy.ts +++ b/frontend/src/lib/coordinationStrategy.ts @@ -14,6 +14,7 @@ export const CONNECTOR_HANDLES = new Set([ 'memory', 'architectural_pattern', 'skill', + 'persona', 'dataset', 'resource', 'knowledge', From 10a8c4da3568c8fcf2f23fa2828b178edb5ad3d6 Mon Sep 17 00:00:00 2001 From: Jay Moran Date: Wed, 21 Oct 2026 11:40:28 -0700 Subject: [PATCH 2/7] feat: add reusable persona library with prompt snapshots --- README.md | 14 + frontend/src/api/client.ts | 15 + .../protocol/FactorEditorDialog.test.tsx | 8 +- .../protocol/FactorEditorDialog.tsx | 205 ++---------- .../protocol/PersonaLibraryDialog.tsx | 98 ++++++ .../protocol/PersonaLibraryPicker.test.tsx | 75 +++++ .../protocol/PersonaLibraryPicker.tsx | 52 +++ .../protocol/PersonaNodeInspector.tsx | 297 +++--------------- .../components/protocol/usePersonaLibrary.ts | 39 +++ frontend/src/types/personas.ts | 18 ++ frontend/src/types/protocols.ts | 5 +- src/asaree/api/personas.py | 113 +++++++ src/asaree/app.py | 2 + src/asaree/migrations/env.py | 1 + .../versions/8293a4b5c6d7_persona_library.py | 33 ++ src/asaree/models/persona.py | 30 ++ src/asaree/services/personas.py | 105 +++++++ tests/test_persona_uploads.py | 33 ++ tests/test_personas.py | 153 +++++++++ 19 files changed, 856 insertions(+), 440 deletions(-) create mode 100644 frontend/src/components/protocol/PersonaLibraryDialog.tsx create mode 100644 frontend/src/components/protocol/PersonaLibraryPicker.test.tsx create mode 100644 frontend/src/components/protocol/PersonaLibraryPicker.tsx create mode 100644 frontend/src/components/protocol/usePersonaLibrary.ts create mode 100644 frontend/src/types/personas.ts create mode 100644 src/asaree/api/personas.py create mode 100644 src/asaree/migrations/versions/8293a4b5c6d7_persona_library.py create mode 100644 src/asaree/models/persona.py create mode 100644 src/asaree/services/personas.py create mode 100644 tests/test_persona_uploads.py create mode 100644 tests/test_personas.py diff --git a/README.md b/README.md index d3c5eb1f..9e9100d2 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index a6fdb7d4..0739a3a8 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -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' @@ -497,6 +498,20 @@ export const mcpServersApi = { remove: (id: string) => request(`/mcp-servers/${id}`, { method: 'DELETE' }), } +export const personasApi = { + list: () => request('/personas'), + create: (data: { prompt: string; name?: string; description?: string | null; traits?: Record }) => + request('/personas', { method: 'POST', body: data }), + upload: (file: File) => { + const form = new FormData() + form.set('file', file) + return request('/personas/upload', { method: 'POST', body: form }) + }, + update: (id: string, data: { expected_revision: number; name?: string; prompt?: string; description?: string | null; traits?: Record }) => + request(`/personas/${id}`, { method: 'PATCH', body: data }), + remove: (id: string) => request(`/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 diff --git a/frontend/src/components/protocol/FactorEditorDialog.test.tsx b/frontend/src/components/protocol/FactorEditorDialog.test.tsx index 745d8f1f..13c93fc3 100644 --- a/frontend/src/components/protocol/FactorEditorDialog.test.tsx +++ b/frontend/src/components/protocol/FactorEditorDialog.test.tsx @@ -3,11 +3,17 @@ 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( @@ -28,7 +34,6 @@ it.each([null, '01-analyst'])('uploads to the intended persona level when both l expect(firstClick).not.toHaveBeenCalled() const file = new File(['You are methodical.'], 'methodical.md', { type: 'text/markdown' }) - Object.defineProperty(file, 'text', { value: vi.fn().mockResolvedValue('You are methodical.') }) fireEvent.change(inputs[1], { target: { files: [file] } }) await waitFor(() => expect(screen.getByRole('button', { name: 'methodical.md' })).toBeInTheDocument()) fireEvent.click(screen.getByRole('button', { name: 'Close' })) @@ -38,6 +43,7 @@ it.each([null, '01-analyst'])('uploads to the intended persona level when both l 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)( diff --git a/frontend/src/components/protocol/FactorEditorDialog.tsx b/frontend/src/components/protocol/FactorEditorDialog.tsx index 013e876c..81010f6a 100644 --- a/frontend/src/components/protocol/FactorEditorDialog.tsx +++ b/frontend/src/components/protocol/FactorEditorDialog.tsx @@ -3,7 +3,7 @@ import { modelCapabilities } from '@/lib/modelCapabilities' import { SubAgentFactorEditor } from './SubAgentFactorEditor' import type { ProtocolGraph } from '@/types/protocols' import { useEffect, useId, useMemo, useState, type SetStateAction } from 'react' -import { useQuery } from '@tanstack/react-query' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { Plus, Split, X } from 'lucide-react' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' @@ -39,9 +39,12 @@ import { PythonCodeEditor } from './PythonCodeEditor' import { useDialogAutosave } from './useDialogAutosave' import { useDialogDraft } from './useDialogDraft' import type { DesignFactor } from '@/types/experiments' -import { Library, Upload } from 'lucide-react' +import { Upload } from 'lucide-react' import { DatasetFactorEditor } from './DatasetFactorEditor' -import { PERSONA_LIBRARY } from './personaLibrary' +import { PersonaLibraryPicker } from './PersonaLibraryPicker' +import { configForSavedPersona } from './usePersonaLibrary' +import { personasApi } from '@/api/client' +import type { PersonaNodeConfig } from '@/types/protocols' import { KnowledgeFactorEditor } from './KnowledgeFactorEditor' import { ScriptFactorEditor } from './ScriptFactorEditor' import { ToolFactorEditor } from './ToolFactorEditor' @@ -423,190 +426,32 @@ function DatasetConfigLevelRow({ function PersonaLevelRow({ value, onChange }: { value: StructuredLevel; onChange: (next: StructuredLevel) => void }) { const uploadId = useId() - const [selectedPersonas, setSelectedPersonas] = useState(() => { - const ids = value.persona_ids as string[] | undefined - if (ids && Array.isArray(ids) && ids.length > 0) return ids - const id = value.persona_id as string | null - return id ? [id] : [''] + const client = useQueryClient() + const upload = useMutation({ + mutationFn: personasApi.upload, + onSuccess: () => client.invalidateQueries({ queryKey: ['personas'] }), }) - - // Sync state when value changes (e.g., when loading an existing factor for editing) - useEffect(() => { - const ids = value.persona_ids as string[] | undefined - if (ids && Array.isArray(ids) && ids.length > 0) { - setSelectedPersonas(ids) - } else { - const id = value.persona_id as string | null - setSelectedPersonas(id ? [id] : ['']) - } - }, [value.persona_ids, value.persona_id]) - - function patch(patch: StructuredLevel) { - onChange({ ...value, ...patch }) - } - - function updatePersona(index: number, newId: string | null) { - const newSelected = [...selectedPersonas] - newSelected[index] = newId || '' - const filtered = newSelected.filter(id => id !== '') - setSelectedPersonas(filtered.length > 0 ? filtered : ['']) - - const combinedText = filtered - .map(id => PERSONA_LIBRARY.find(p => p.id === id)?.prompt ?? '') - .filter(Boolean) - .join('\n\n') - - const allLabels = filtered - .map(id => PERSONA_LIBRARY.find(p => p.id === id)?.label) - .filter(Boolean) - .join(' + ') - - patch({ - persona_text: combinedText, - persona_id: filtered[0] || null, - persona_ids: filtered, - persona_label: allLabels || null, - mode: 'library', - persona_file: null - }) - } - - function addPersona() { - if (selectedPersonas.length < 5) { - setSelectedPersonas([...selectedPersonas, '']) - } - } - - function removePersona(index: number) { - const newSelected = selectedPersonas.filter((_, i) => i !== index) - setSelectedPersonas(newSelected.length > 0 ? newSelected : ['']) - - const combinedText = newSelected - .map(id => PERSONA_LIBRARY.find(p => p.id === id)?.prompt ?? '') - .filter(Boolean) - .join('\n\n') - - const allLabels = newSelected - .map(id => PERSONA_LIBRARY.find(p => p.id === id)?.label) - .filter(Boolean) - .join(' + ') - - patch({ - persona_text: combinedText, - persona_id: newSelected[0] || null, - persona_ids: newSelected, - persona_label: allLabels || null, - mode: newSelected.length > 0 ? 'library' : 'custom', - persona_file: null - }) - } - - async function handleFileUpload(e: React.ChangeEvent) { - const file = e.target.files?.[0] - if (!file) return - const text = await file.text() - patch({ - persona_text: text, - persona_file: file.name, - mode: 'file', - persona_id: null, - persona_ids: [], - persona_label: null - }) - setSelectedPersonas(['']) - } - return (
-
- -
- {selectedPersonas.map((personaId, index) => ( -
- - {selectedPersonas.length > 1 && ( - - )} -
- ))} -
- {selectedPersonas.length < 5 && ( - - )} -
+ onChange({ ...value, ...next })} />
-
- - -
-

Upload any .md file containing personality or behavior instructions to prepend to the agent's system prompt.

+ + { + const file = event.target.files?.[0] + if (file) upload.mutate(file, { onSuccess: persona => onChange({ ...value, ...configForSavedPersona(persona), mode: 'file' }) }) + event.target.value = '' + }} /> +

Upload plain Markdown; no frontmatter is required. The persona is saved to your library and its text is copied into this level.

+ {upload.error &&

{upload.error.message}

}
- -