+
setSearch(e.target.value)} />
-
+
{filteredFields.length === 0 && (
{pickableFields?.length === 0
@@ -713,21 +720,27 @@ function StandardFactorEditorDialog({
: 'No matching fields.'}
)}
- {filteredFields.map((field) => (
+ {fieldGroups.map((group) =>
+ {group.label}{group.components.reduce((count, component) => count + component.fields.length, 0)} options
+ {group.components.map((component) =>
+ {component.category}{component.category === 'Agent fields' ? '' : ` · ${component.label}`}
+ {component.fields.map((field) => (
- ))}
+ ))}
+ )}
+ )}
) : (
diff --git a/frontend/src/components/protocol/ProtocolCanvas.tsx b/frontend/src/components/protocol/ProtocolCanvas.tsx
index 9f7186d2..703643a0 100644
--- a/frontend/src/components/protocol/ProtocolCanvas.tsx
+++ b/frontend/src/components/protocol/ProtocolCanvas.tsx
@@ -85,7 +85,7 @@ import type { OkfBundle, OkfDocument } from '@/types/okf'
import type { Skill } from '@/types/skills'
import { AddNodePanel } from './AddNodePanel'
import { AgentNodeInspector } from './AgentNodeInspector'
-import { agentTracedLabel, factorBoundField, revealsHiddenMcpServers, toolFactorServerId, unboundBindableFields, type UnboundField } from './bindableFields'
+import { agentTracedLabel, factorBoundField, factorCreationFields, revealsHiddenMcpServers, toolFactorServerId, type UnboundField } from './bindableFields'
import { CanvasControls } from './CanvasControls'
import { CriticGateNodeInspector } from './CriticGateNodeInspector'
import { ToolStepNodeInspector } from './ToolStepNodeInspector'
@@ -364,6 +364,7 @@ export interface ProtocolCanvasHandle {
// level from the sibling Design tab must update every field controlled by
// the factor, not leave the graph and design as two sources of truth.
setFactorBaseline: (factorName: string, baseline: unknown) => void
+ openConnectorFactor: (field: UnboundField) => void
}
export const ProtocolCanvas = forwardRef
{
+ if (!experimentId || experimentLocked || !field.connectorFactor) return
+ setSelectedNodeId(null)
+ setConnectorFactorChoice(field.connectorFactor)
+ if (field.connectorFactor.kind === 'skill') setSkillFactorNodeId(field.connectorFactor.nodeId)
+ else setDatasetFactorNodeId(field.connectorFactor.nodeId)
+ },
}),
- [bindFactorOnNode, removeFactorBindings, renameFactorBindings, setFactorBaseline],
+ [bindFactorOnNode, removeFactorBindings, renameFactorBindings, setFactorBaseline, experimentId, experimentLocked],
)
const [selectedNodeId, setSelectedNodeId] = useState(null)
useEffect(() => {
@@ -483,6 +491,7 @@ export const ProtocolCanvas = forwardRef(null)
const [skillFactorNodeId, setSkillFactorNodeId] = useState(null)
const [datasetFactorNodeId, setDatasetFactorNodeId] = useState(null)
+ const [connectorFactorChoice, setConnectorFactorChoice] = useState()
const [addPanelOpen, setAddPanelOpen] = useState(false)
// The second level of the add-node panel: AddNodePanel's "MCP Servers"
// entry swaps the browser in over it, and its Back button returns. Only
@@ -1345,11 +1354,13 @@ export const ProtocolCanvas = forwardRef {
if (!experimentId || experimentLocked) return
setSelectedNodeId(null)
+ setConnectorFactorChoice(undefined)
setSkillFactorNodeId(nodeId)
}, [experimentId, experimentLocked])
const requestDatasetFactor = useCallback((nodeId: string) => {
if (!experimentId || experimentLocked) return
setSelectedNodeId(null)
+ setConnectorFactorChoice(undefined)
setDatasetFactorNodeId(nodeId)
}, [experimentId, experimentLocked])
const metricsByNode = useMemo(() => {
@@ -1465,7 +1476,7 @@ export const ProtocolCanvas = forwardRef n.id === factorPickerNodeId) ?? null
const factorPickerFields: UnboundField[] = factorPickerNodeId
- ? unboundBindableFields(nodes, edges).filter((f) => f.nodeId === factorPickerNodeId)
+ ? factorCreationFields(nodes, edges).filter((f) => f.nodeId === factorPickerNodeId)
: []
const factorPickerExistingNames = factorPickerExperimentQuery.data?.design_spec?.factors?.map((f) => f.name) ?? []
@@ -2500,6 +2511,13 @@ export const ProtocolCanvas = forwardRef {
+ if (!field.connectorFactor) return
+ setFactorPickerNodeId(null)
+ setConnectorFactorChoice(field.connectorFactor)
+ if (field.connectorFactor.kind === 'skill') setSkillFactorNodeId(field.connectorFactor.nodeId)
+ else setDatasetFactorNodeId(field.connectorFactor.nodeId)
+ }}
existingNames={factorPickerExistingNames}
emptyPickerMessage={`${(factorPickerNode?.data as { label?: string })?.label || 'This node'} has no fields that can be turned into a factor.`}
revealHiddenServers={revealsHiddenMcpServers(nodes)}
@@ -2512,6 +2530,8 @@ export const ProtocolCanvas = forwardRef setSkillFactorNodeId(null)}
@@ -2550,6 +2570,8 @@ export const ProtocolCanvas = forwardRef setDatasetFactorNodeId(null)}
diff --git a/frontend/src/components/protocol/SkillFactorDialog.tsx b/frontend/src/components/protocol/SkillFactorDialog.tsx
index bf17f6ad..dcbc614c 100644
--- a/frontend/src/components/protocol/SkillFactorDialog.tsx
+++ b/frontend/src/components/protocol/SkillFactorDialog.tsx
@@ -10,21 +10,23 @@ import type { DesignFactor } from '@/types/experiments'
import type { ProtocolGraph } from '@/types/protocols'
import { computeFactorName } from './factorLevels'
-export function SkillFactorDialog({ skillNodeId, graph, factors, onClose, onSave, onRemove }: {
+export function SkillFactorDialog({ skillNodeId, graph, factors, initialMode, initialAgentId, onClose, onSave, onRemove }: {
skillNodeId: string
graph: ProtocolGraph
factors: DesignFactor[]
+ initialMode?: Mode
+ initialAgentId?: string
onClose: () => void
onSave: (factor: DesignFactor, ownerId: string, previousName?: string) => Promise
onRemove: (name: string) => Promise
}) {
const skill = graph.nodes.find((node) => node.id === skillNodeId)!
const agents = graph.nodes.filter((node) => ['agent', 'sub_agent'].includes(node.type) && graph.edges.some((edge) => edge.source === skillNodeId && edge.target === node.id && edge.targetHandle === 'skill'))
- const [agentId, setAgentId] = useState(agents.find((agent) => agent.data.factor_bindings?.[SKILL_FACTOR_PATH])?.id ?? agents[0]?.id ?? '')
+ const [agentId, setAgentId] = useState(initialAgentId ?? agents.find((agent) => agent.data.factor_bindings?.[SKILL_FACTOR_PATH])?.id ?? agents[0]?.id ?? '')
const existingFor = (ownerId: string, path: string) => factors.find((factor) => factor.name === graph.nodes.find((node) => node.id === ownerId)?.data.factor_bindings?.[path])
const initialFactor = existingFor(agentId, SKILL_FACTOR_PATH) ?? existingFor(skillNodeId, 'config.enabled')
const initial = initialFactor && initialFactor.level_type !== 'boolean' && initialFactor.level_type ? reconcileSkillFactor(initialFactor, graph, agentId) : initialFactor
- const [mode, setMode] = useState((initial?.level_type as Mode) ?? 'boolean')
+ const [mode, setMode] = useState(initialMode ?? (initial?.level_type as Mode) ?? 'boolean')
function seed(nextMode: Mode, nextAgentId: string): DesignFactor {
const ownerId = nextMode === 'boolean' ? skillNodeId : nextAgentId
const existing = existingFor(ownerId, nextMode === 'boolean' ? 'config.enabled' : SKILL_FACTOR_PATH)
@@ -43,7 +45,7 @@ export function SkillFactorDialog({ skillNodeId, graph, factors, onClose, onSave
return nextMode === 'boolean' ? factor : reconcileSkillFactor(factor, graph, ownerId)
}
const [draft, setDraft] = useState(() => {
- const factor = initial ?? seed(mode, agentId)
+ const factor = initialMode ? seed(mode, agentId) : initial ?? seed(mode, agentId)
return { ...factor, level_type: factor.level_type ?? 'boolean', level_labels: factor.level_labels ?? factor.levels.map((level) => level ? 'Enabled' : 'Disabled') }
})
const [saving, setSaving] = useState(false)
diff --git a/frontend/src/components/protocol/bindableFields.ts b/frontend/src/components/protocol/bindableFields.ts
index d465cf66..eebe8e75 100644
--- a/frontend/src/components/protocol/bindableFields.ts
+++ b/frontend/src/components/protocol/bindableFields.ts
@@ -251,16 +251,11 @@ export function bindableFieldsForNode(node: Node): BindableFieldSpec[] {
]
case 'dataset':
return [
- // No runtime effect beyond skipping the Dataset-context block in
- // _build_user_input.
+ // Individual availability controls Dataset context and implicit tools.
{ fieldPath: 'config.enabled', label: 'Enabled', levelType: 'boolean' },
// The whole node as a factor -- levels are entirely different
- // datasets, which is the ONLY supported way to run one experiment
- // across several of them. The Dataset connector is capped at one
- // node per agent (see AgentNode.tsx) precisely because a cell's
- // workspace is keyed by experiment_id/cell_label and therefore holds
- // exactly one dataset; varying it per CELL is the shape that fits,
- // and wiring several at once never was.
+ // datasets. Keep this field for editing legacy bindings; new creation
+ // uses factorCreationFields and the Dataset connector's levels.
//
// Unlike skill/okf_bundle above -- whose configs are also just
// per-account pointers -- this one earns a structured level type
@@ -271,14 +266,7 @@ export function bindableFieldsForNode(node: Node): BindableFieldSpec[] {
{ fieldPath: 'config', label: 'Dataset', levelType: 'dataset_config' },
]
case 'skill':
- // Only `enabled` -- the natural "which skill" factor is the WHOLE node
- // (levels = different skills), but unlike script/llm/tool_config, a
- // skill node's config is just an id pointing at a row in the skill
- // library, so a level would carry a per-account id rather than the
- // thing itself. Comparing two skills today means two nodes, one
- // enabled per cell; a real skill_config level type is deferred until
- // the level editor can pick from the library the way the inspector
- // does.
+ // Connector choices are listed by factorCreationFields.
return [{ fieldPath: 'config.enabled', label: 'Enabled', levelType: 'boolean' }]
case 'okf_bundle':
case 'okf_document':
@@ -301,6 +289,8 @@ export function bindableFieldsForNode(node: Node): BindableFieldSpec[] {
}
export interface UnboundField {
+ pickerGroup?: { id: string; label: string; category: string; componentId: string; componentLabel: string }
+ connectorFactor?: { kind: 'skill' | 'dataset'; nodeId: string; mode: LevelType; agentId?: string }
nodeId: string
nodeLabel: string
fieldPath: string
@@ -474,3 +464,81 @@ export function unboundBindableFields(nodes: Node[], edges: Edge[]): UnboundFiel
}
return result
}
+
+/** Creation entries route connector choices through their dedicated factor dialogs. */
+export function factorCreationFields(nodes: Node[], edges: Edge[]): UnboundField[] {
+ const fields = unboundBindableFields(nodes, edges).filter((field) => {
+ const kind = nodes.find((node) => node.id === field.nodeId)?.type
+ return kind !== 'skill' && kind !== 'dataset'
+ })
+ const connectorOwners = new Set()
+ for (const node of nodes) {
+ if (node.type !== 'skill' && node.type !== 'dataset') continue
+ const kind = node.type
+ const handles = kind === 'skill' ? ['skill'] : ['dataset', 'resource', 'tool']
+ const owners = nodes.filter((owner) => ['agent', 'sub_agent'].includes(owner.type ?? '') && edges.some((edge) => edge.source === node.id && edge.target === owner.id && handles.includes(edge.targetHandle ?? '')))
+ if (!(node.data.factor_bindings as Record | undefined)?.['config.enabled']) fields.push({
+ nodeId: node.id, nodeLabel: agentTracedLabel(node, edges, nodes), fieldPath: 'config.enabled',
+ fieldLabel: `This ${kind} on/off`, levelType: 'boolean', currentValue: undefined,
+ connectorFactor: { kind, nodeId: node.id, mode: 'boolean' },
+ })
+ for (const owner of owners) {
+ const key = `${owner.id}:${kind}`
+ if (connectorOwners.has(key)) continue
+ connectorOwners.add(key)
+ for (const mode of ['selection', 'toggle'] as const) {
+ const levelType = `${kind}_${mode}` as LevelType
+ fields.push({
+ nodeId: owner.id, nodeLabel: String(owner.data.label || 'Agent'), fieldPath: `${kind}_selection`,
+ fieldLabel: mode === 'selection' ? `${kind === 'skill' ? 'Skill' : 'Dataset'} levels` : `All ${kind}s on/off`,
+ levelType, currentValue: undefined,
+ connectorFactor: { kind, nodeId: node.id, mode: levelType, agentId: owner.id },
+ })
+ }
+ }
+ }
+ return fields.map((field) => {
+ const node = nodes.find((candidate) => candidate.id === field.nodeId)!
+ const kind = field.connectorFactor?.kind ?? node.type ?? 'node'
+ const agentKinds = ['agent', 'sub_agent']
+ const targets = nodes.filter((candidate) => agentKinds.includes(candidate.type ?? '') && edges.some((edge) => edge.source === node.id && edge.target === candidate.id))
+ const owner = agentKinds.includes(node.type ?? '') ? node : targets.length === 1 ? targets[0] : undefined
+ const category = field.connectorFactor?.kind === 'skill' ? 'Skills'
+ : field.connectorFactor?.kind === 'dataset' ? 'Datasets'
+ : agentKinds.includes(kind) ? 'Agent fields'
+ : kind.startsWith('llm_') ? 'Model'
+ : kind.startsWith('pattern_') ? 'Execution pattern'
+ : ({ skill: 'Skills', dataset: 'Datasets', mcp_tool: 'Tools', script: 'Tools', okf_bundle: 'Knowledge', okf_document: 'Knowledge', memory: 'Memory', output_parser: 'Output parser', critic_gate: 'Critic gate' } as Record)[kind] ?? 'Other components'
+ const connectorChoice = field.connectorFactor && field.connectorFactor.mode !== 'boolean'
+ return {
+ ...field,
+ pickerGroup: {
+ id: owner?.id ?? 'shared-unconnected',
+ label: owner ? String(owner.data.label || 'Agent') : 'Shared or unconnected components',
+ category,
+ componentId: connectorChoice ? `${field.nodeId}:${kind}` : node.id,
+ componentLabel: connectorChoice ? `${category} on this agent` : String(node.data.label || kind),
+ },
+ }
+ })
+}
+
+export function groupFactorCreationFields(fields: UnboundField[]) {
+ const agents = new Map }>()
+ for (const field of fields) {
+ const group = field.pickerGroup ?? { id: 'fields', label: 'Canvas fields', category: 'Fields', componentId: field.nodeId, componentLabel: field.nodeLabel }
+ if (!agents.has(group.id)) agents.set(group.id, { id: group.id, label: group.label, components: new Map() })
+ const agent = agents.get(group.id)!
+ if (!agent.components.has(group.componentId)) agent.components.set(group.componentId, { id: group.componentId, category: group.category, label: group.componentLabel, fields: [] })
+ agent.components.get(group.componentId)!.fields.push(field)
+ }
+ const categories = ['Agent fields', 'Skills', 'Datasets', 'Model', 'Execution pattern', 'Tools', 'Knowledge', 'Memory', 'Output parser', 'Critic gate', 'Other components']
+ return [...agents.values()]
+ .sort((a, b) => a.id === 'shared-unconnected' ? 1 : b.id === 'shared-unconnected' ? -1 : a.label.localeCompare(b.label))
+ .map((agent) => ({ ...agent, components: [...agent.components.values()]
+ .sort((a, b) => categories.indexOf(a.category) - categories.indexOf(b.category) || a.label.localeCompare(b.label))
+ .map((component) => ({ ...component, fields: component.fields.sort((a, b) => {
+ const modeOrder = (field: UnboundField) => field.connectorFactor?.mode === 'boolean' ? 0 : field.connectorFactor?.mode.endsWith('_selection') ? 1 : 2
+ return a.connectorFactor && b.connectorFactor ? modeOrder(a) - modeOrder(b) : a.fieldLabel.localeCompare(b.fieldLabel)
+ }) })) }))
+}
diff --git a/frontend/src/components/protocol/factorCreationFields.test.ts b/frontend/src/components/protocol/factorCreationFields.test.ts
new file mode 100644
index 00000000..8b9adf0d
--- /dev/null
+++ b/frontend/src/components/protocol/factorCreationFields.test.ts
@@ -0,0 +1,23 @@
+import { expect, it } from 'vitest'
+import type { Edge, Node } from '@xyflow/react'
+import { factorCreationFields, groupFactorCreationFields } from './bindableFields'
+
+it('groups by owning agent and component, putting shared nodes last and keeping connector modes together', () => {
+ const nodes: Node[] = [
+ { id: 'z', type: 'agent', position: { x: 0, y: 0 }, data: { label: 'Zebra', config: {} } },
+ { id: 'a', type: 'agent', position: { x: 0, y: 0 }, data: { label: 'Alpha', config: {} } },
+ { id: 'shared', type: 'skill', position: { x: 0, y: 0 }, data: { label: 'Shared skill', config: { skill_id: 's' } } },
+ { id: 'data', type: 'dataset', position: { x: 0, y: 0 }, data: { label: 'Cohort', config: { dataset_id: 'd' } } },
+ { id: 'loose', type: 'skill', position: { x: 0, y: 0 }, data: { label: 'Unconnected skill', config: { skill_id: 'u' } } },
+ ]
+ const edges: Edge[] = [
+ { id: 's-z', source: 'shared', target: 'z', targetHandle: 'skill' },
+ { id: 's-a', source: 'shared', target: 'a', targetHandle: 'skill' },
+ { id: 'd-a', source: 'data', target: 'a', targetHandle: 'dataset' },
+ ]
+ const groups = groupFactorCreationFields(factorCreationFields(nodes, edges))
+ expect(groups.map(group => group.label)).toEqual(['Alpha', 'Zebra', 'Shared or unconnected components'])
+ expect(groups[0].components.map(component => component.category)).toEqual(['Agent fields', 'Skills', 'Datasets', 'Datasets'])
+ expect(groups[0].components.find(component => component.category === 'Skills')?.fields.map(field => field.fieldLabel)).toEqual(['Skill levels', 'All skills on/off'])
+ expect(groups[2].components.map(component => component.label)).toEqual(['Shared skill', 'Unconnected skill'])
+})
From 09cc451e5229b82e88e4cbd952132155e496065d Mon Sep 17 00:00:00 2001
From: Jay Moran
Date: Wed, 7 Oct 2026 13:44:34 -0700
Subject: [PATCH 10/63] feat: add Knowledge node factor controls and execution
---
frontend/src/api/client.ts | 2 +-
.../src/components/protocol/DesignTab.tsx | 8 +-
.../protocol/FactorEditorDialog.tsx | 2 +
.../protocol/KnowledgeFactorDialog.test.tsx | 82 ++++++++++
.../protocol/KnowledgeFactorDialog.tsx | 119 ++++++++++++++
.../protocol/KnowledgeFactorEditor.tsx | 74 +++++++++
.../protocol/KnowledgeTestSelectors.tsx | 26 ++++
.../protocol/OkfBundleNodeInspector.tsx | 62 +++-----
.../protocol/OkfDocumentNodeInspector.tsx | 60 +++-----
.../components/protocol/ProtocolCanvas.tsx | 99 ++++++++++--
.../protocol/ProtocolCanvasContext.tsx | 1 +
.../src/components/protocol/bindableFields.ts | 22 ++-
.../protocol/factorCreationFields.test.ts | 13 ++
.../src/components/protocol/factorLevels.ts | 8 +-
.../components/protocol/nodes/AgentNode.tsx | 2 +-
.../protocol/nodes/ConnectorAddStub.tsx | 2 +-
.../protocol/nodes/OkfBundleNode.tsx | 10 +-
.../protocol/nodes/OkfDocumentNode.tsx | 10 +-
.../protocol/useKnowledgeLibrary.ts | 9 ++
.../protocol/useKnowledgeTestSelection.ts | 23 +++
frontend/src/lib/factorBindings.ts | 9 +-
frontend/src/lib/knowledgeFactors.test.ts | 38 +++++
frontend/src/lib/knowledgeFactors.ts | 94 ++++++++++++
frontend/src/types/experiments.ts | 2 +
frontend/src/types/protocols.ts | 2 +
src/asaree/api/protocols.py | 7 +
src/asaree/services/design_generation.py | 15 +-
src/asaree/services/factor_bindings.py | 7 +-
src/asaree/services/knowledge_factors.py | 145 ++++++++++++++++++
src/asaree/services/protocol_execution.py | 12 +-
src/asaree/services/protocol_revisions.py | 5 +-
tests/test_knowledge_factors.py | 116 ++++++++++++++
32 files changed, 961 insertions(+), 125 deletions(-)
create mode 100644 frontend/src/components/protocol/KnowledgeFactorDialog.test.tsx
create mode 100644 frontend/src/components/protocol/KnowledgeFactorDialog.tsx
create mode 100644 frontend/src/components/protocol/KnowledgeFactorEditor.tsx
create mode 100644 frontend/src/components/protocol/KnowledgeTestSelectors.tsx
create mode 100644 frontend/src/components/protocol/useKnowledgeLibrary.ts
create mode 100644 frontend/src/components/protocol/useKnowledgeTestSelection.ts
create mode 100644 frontend/src/lib/knowledgeFactors.test.ts
create mode 100644 frontend/src/lib/knowledgeFactors.ts
create mode 100644 src/asaree/services/knowledge_factors.py
create mode 100644 tests/test_knowledge_factors.py
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts
index 32c6ec04..97a88575 100644
--- a/frontend/src/api/client.ts
+++ b/frontend/src/api/client.ts
@@ -187,7 +187,7 @@ export interface ResultsScope {
protocol_revision_id?: string
}
-export interface RowSelection { row_index?: number | null; skill_selections?: Record; dataset_selections?: Record }
+export interface RowSelection { row_index?: number | null; skill_selections?: Record; dataset_selections?: Record; knowledge_selections?: Record }
function scopeQuery(scope?: ResultsScope): string {
const params = new URLSearchParams()
diff --git a/frontend/src/components/protocol/DesignTab.tsx b/frontend/src/components/protocol/DesignTab.tsx
index c8460ad8..2b3f3ace 100644
--- a/frontend/src/components/protocol/DesignTab.tsx
+++ b/frontend/src/components/protocol/DesignTab.tsx
@@ -1,3 +1,5 @@
+import { knowledgeFactorIssues, isKnowledgeFactor } from '@/lib/knowledgeFactors'
+import { useKnowledgeLibrary } from './useKnowledgeLibrary'
import { datasetsApi } from '@/api/client'
import { datasetFactorIssues } from '@/lib/datasetFactors'
import { useCallback, useEffect, useRef, useState, type RefObject } from 'react'
@@ -499,6 +501,8 @@ export function DesignTab({
? ({ nodes: graphQuery.data.nodes, edges: graphQuery.data.edges } as unknown as ProtocolGraph)
: undefined
const unboundFactors = unboundFactorNames(experiment.design_spec, draftGraph)
+ const knowledgeLibrary = useKnowledgeLibrary(factors.some(isKnowledgeFactor))
+ const knowledgeIssues = knowledgeFactorIssues(draftGraph, factors, knowledgeLibrary.data ? new Set(knowledgeLibrary.data.map((item) => item.id)) : undefined)
const skillLibrary = useQuery({ queryKey: ['skills'], queryFn: () => skillsApi.list(), enabled: factors.some((factor) => (factor.level_type === 'skill_selection' || factor.level_type === 'skill_toggle')) })
const datasetLibrary = useQuery({ queryKey: ['datasets'], queryFn: () => datasetsApi.list(), enabled: factors.some((factor) => factor.level_type === 'dataset_selection' || factor.level_type === 'dataset_toggle') })
const datasetIssues = datasetFactorIssues(draftGraph, factors, datasetLibrary.data ? new Set(datasetLibrary.data.map((dataset) => dataset.id)) : undefined)
@@ -871,14 +875,14 @@ export function DesignTab({
Unbound factor{unboundFactors.length === 1 ? '' : 's'}: {unboundFactors.join(', ')}. Rebind on the canvas or remove from this design.
)}
- {[...skillIssues, ...datasetIssues].map((issue) => {issue}
)}
+ {[...skillIssues, ...datasetIssues, ...knowledgeIssues].map((issue) => {issue}
)}
)
}
diff --git a/frontend/src/components/protocol/ModelFactorDialog.test.tsx b/frontend/src/components/protocol/ModelFactorDialog.test.tsx
new file mode 100644
index 00000000..5dd161a3
--- /dev/null
+++ b/frontend/src/components/protocol/ModelFactorDialog.test.tsx
@@ -0,0 +1,141 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { expect, it, vi } from 'vitest'
+import type { ModelNodeData, ProtocolNode } from '@/types/protocols'
+import { factorCreationFields } from './bindableFields'
+import { ModelFactorDialog } from './ModelFactorDialog'
+import { LlmConfigLevelRow } from './FactorEditorDialog'
+
+vi.mock('./useProviderModels', () => ({ useProviderModels: () => ({ models: [
+ { id: 'gpt-5', label: 'GPT 5', supports_temperature: true, supports_effort: false, effort_levels: [] },
+ { id: 'reasoning', label: 'Reasoning model', supports_temperature: false, supports_effort: true, effort_levels: ['low', 'high'] },
+], modelsQuery: { isLoading: false } }) }))
+
+const node: ProtocolNode & { data: ModelNodeData } = {
+ id: 'model', type: 'model_openai', position: { x: 0, y: 0 },
+ data: { label: 'OpenAI', config: { provider: 'openai', model: 'gpt-5', temperature: 0.7, max_tokens: 1000 } },
+}
+
+it('routes New Factor model choices to the dedicated dialog while retaining their node grouping', () => {
+ const fields = factorCreationFields([node], [])
+ expect(fields).toHaveLength(5)
+ expect(fields.find((field) => field.fieldPath === 'config')).toMatchObject({
+ fieldLabel: 'Model levels', connectorFactor: { kind: 'model', nodeId: 'model', mode: 'model_config' },
+ pickerGroup: { category: 'Model', componentId: 'model', componentLabel: 'OpenAI' },
+ })
+ expect(fields.every((field) => field.connectorFactor?.kind === 'model')).toBe(true)
+})
+
+it('blocks whole-model factors when individual fields are already bound, and allows editing that field', () => {
+ const bound = { ...node, data: { ...node.data, factor_bindings: { 'config.temperature': 'Heat' } } }
+ render(
)
+ fireEvent.click(screen.getByRole('button', { name: 'Model levels' }))
+ expect(screen.getByRole('status')).toHaveTextContent('cannot be combined')
+ expect(screen.getByRole('button', { name: 'Save factor' })).toBeDisabled()
+ fireEvent.click(screen.getByRole('button', { name: 'Temperature' }))
+ expect(screen.getByLabelText('Factor name')).toHaveValue('Heat')
+ expect(screen.getByRole('button', { name: 'Remove factor' })).toBeEnabled()
+})
+
+it('saves reordered numeric levels with their labels and previous factor name', async () => {
+ const save = vi.fn().mockResolvedValue(undefined)
+ const bound = { ...node, data: { ...node.data, factor_bindings: { 'config.temperature': 'Heat' } } }
+ render(
)
+ fireEvent.click(screen.getAllByRole('button', { name: 'Move level up' })[1])
+ fireEvent.change(screen.getByLabelText('Factor name'), { target: { value: 'Temperature' } })
+ fireEvent.click(screen.getByRole('button', { name: 'Save factor' }))
+ await waitFor(() => expect(save).toHaveBeenCalledWith({ name: 'Temperature', level_type: 'number', levels: [1, 0.7], level_labels: ['High', 'Low'] }, 'config.temperature', 'Heat'))
+})
+
+it('removes an existing model factor through its bound path', async () => {
+ const remove = vi.fn().mockResolvedValue(undefined)
+ const close = vi.fn()
+ const bound = { ...node, data: { ...node.data, factor_bindings: { config: 'Models' } } }
+ const { rerender } = render(
)
+ fireEvent.click(screen.getByRole('button', { name: 'Remove factor' }))
+ await waitFor(() => expect(remove).toHaveBeenCalledWith('Models', 'config'))
+ await waitFor(() => expect(screen.getByRole('button', { name: 'Cancel' })).toBeEnabled())
+ expect(close).not.toHaveBeenCalled()
+ rerender(
)
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: 'Remove factor' })).not.toBeInTheDocument()
+ fireEvent.click(screen.getByRole('button', { name: 'Temperature' }))
+ expect(screen.getByLabelText('Factor name')).toBeEnabled()
+})
+
+it('uses the shared model dropdown and disables models selected in other levels', async () => {
+ render(
)
+ expect(screen.getByRole('combobox', { name: 'Level 1 model' })).toHaveTextContent('GPT 5')
+ expect(screen.getByRole('combobox', { name: 'Level 2 model' })).toHaveTextContent('Select a model')
+ expect(screen.getByRole('button', { name: 'Temperature' })).toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: 'Effort' })).not.toBeInTheDocument()
+ fireEvent.click(screen.getByRole('combobox', { name: 'Level 2 model' }))
+ expect(await screen.findByRole('option', { name: 'GPT 5' })).toHaveAttribute('aria-disabled', 'true')
+ expect(screen.getByRole('option', { name: 'Reasoning model' })).not.toHaveAttribute('aria-disabled', 'true')
+})
+
+it('offers only Effort for a reasoning model and disables efforts selected in other levels', async () => {
+ const reasoning = { ...node, data: { ...node.data, config: { ...node.data.config, model: 'reasoning', effort: 'low' } } }
+ render(
)
+ expect(screen.queryByRole('button', { name: 'Temperature' })).not.toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Effort' })).toBeInTheDocument()
+ expect(screen.getByRole('combobox', { name: 'Level 1 effort' })).toHaveTextContent('low')
+ fireEvent.click(screen.getByRole('combobox', { name: 'Level 2 effort' }))
+ expect(await screen.findByRole('option', { name: 'low' })).toHaveAttribute('aria-disabled', 'true')
+ expect(screen.getByRole('option', { name: 'high' })).not.toHaveAttribute('aria-disabled', 'true')
+})
+
+it('updates whole-model level controls when its selected model changes', () => {
+ const { rerender } = render(
)
+ expect(screen.getByText('Temperature')).toBeInTheDocument()
+ expect(screen.queryByText('Effort')).not.toBeInTheDocument()
+ rerender(
)
+ expect(screen.queryByText('Temperature')).not.toBeInTheDocument()
+ expect(screen.getByText('Effort')).toBeInTheDocument()
+ rerender(
)
+ expect(screen.getByText('Temperature')).toBeInTheDocument()
+ expect(screen.queryByText('Effort')).not.toBeInTheDocument()
+})
+
+it('warns about duplicate model configurations regardless of property order and clears when changed', () => {
+ const bound = { ...node, data: { ...node.data, factor_bindings: { config: 'Models' } } }
+ render(
)
+ expect(screen.getByRole('alert')).toHaveTextContent('Duplicate levels: 1, 2')
+ expect(screen.getByRole('button', { name: 'Save factor' })).toBeDisabled()
+ const temperatures = screen.getAllByRole('spinbutton')
+ fireEvent.change(temperatures[2], { target: { value: '1' } })
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Save factor' })).toBeEnabled()
+})
+
+it.each([
+ ['config.temperature', '0.70', '1'],
+ ['config.max_tokens', '1000.0', '2000'],
+])('warns about duplicate numeric values for %s and clears after correction', (path, duplicate, distinct) => {
+ render(
)
+ fireEvent.change(screen.getByLabelText('Level 2 value'), { target: { value: duplicate } })
+ expect(screen.getByRole('alert')).toHaveTextContent('Duplicate levels: 1, 2')
+ expect(screen.getByRole('button', { name: 'Save factor' })).toBeDisabled()
+ fireEvent.change(screen.getByLabelText('Level 2 value'), { target: { value: distinct } })
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Save factor' })).toBeEnabled()
+})
+
+it('does not treat an empty numeric level as a duplicate of zero', () => {
+ render(
)
+ fireEvent.change(screen.getByLabelText('Level 1 value'), { target: { value: '0' } })
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument()
+ fireEvent.change(screen.getByLabelText('Level 2 value'), { target: { value: '0.0' } })
+ expect(screen.getByRole('alert')).toHaveTextContent('Duplicate levels: 1, 2')
+})
+
+it('focuses the level selected from a canvas preview', async () => {
+ const bound = { ...node, data: { ...node.data, factor_bindings: { config: 'Models' } } }
+ render(
)
+ await waitFor(() => expect(screen.getByLabelText('Level 2 label')).toHaveFocus())
+})
diff --git a/frontend/src/components/protocol/ModelFactorDialog.tsx b/frontend/src/components/protocol/ModelFactorDialog.tsx
new file mode 100644
index 00000000..1016208a
--- /dev/null
+++ b/frontend/src/components/protocol/ModelFactorDialog.tsx
@@ -0,0 +1,126 @@
+import { useEffect, useRef, useState } from 'react'
+import { ArrowDown, ArrowUp, Plus, Split, X } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
+import type { DesignFactor } from '@/types/experiments'
+import { factorValueKey } from '@/lib/experiment'
+import type { ProtocolNode } from '@/types/protocols'
+import { bindableFieldsForNode } from './bindableFields'
+import { LlmConfigLevelRow } from './FactorEditorDialog'
+import { ModelField } from './ModelField'
+import { useProviderModels } from './useProviderModels'
+import { computeFactorName, defaultFactorLevelLabels, seedLevels } from './factorLevels'
+
+export function ModelFactorDialog({ node, nodeLabel, factors, initialFieldPath, initialLevelIndex, onClose, onSave, onRemove }: {
+ node: ProtocolNode
+ nodeLabel: string
+ factors: DesignFactor[]
+ initialFieldPath?: string
+ initialLevelIndex?: number
+ onClose: () => void
+ onSave: (factor: DesignFactor, fieldPath: string, previousName?: string) => Promise
+ onRemove: (name: string, fieldPath: string) => Promise
+}) {
+ const config = { ...node.data.config } as Record
+ const { models, modelsQuery } = useProviderModels(config.provider as string | undefined)
+ const modelInfo = models.find((model) => model.id === config.model)
+ const showTemperature = modelInfo?.supports_temperature ?? true
+ const showEffort = modelInfo?.supports_effort ?? false
+ const effortLevels = modelInfo?.effort_levels.length ? modelInfo.effort_levels : ['low', 'medium', 'high', 'xhigh', 'max']
+ const fields = bindableFieldsForNode(node)
+ const available = (fieldPath: string) => fieldPath === 'config.temperature' ? showTemperature : fieldPath === 'config.effort' ? showEffort : true
+ const visibleFields = fields.filter((field) => available(field.fieldPath) || node.data.factor_bindings?.[field.fieldPath] || initialFieldPath === field.fieldPath)
+ .sort((a, b) => Number(b.fieldPath === 'config') - Number(a.fieldPath === 'config') || a.label.localeCompare(b.label))
+ const bindings = node.data.factor_bindings ?? {}
+ const [path, setPath] = useState(initialFieldPath ?? Object.keys(bindings)[0] ?? 'config')
+ const existingFor = (fieldPath: string) => factors.find((factor) => factor.name === bindings[fieldPath])
+ function seed(fieldPath: string): DesignFactor {
+ const existing = existingFor(fieldPath)
+ if (existing) return { ...existing, level_labels: existing.level_labels ?? defaultFactorLevelLabels(existing.name, existing.levels.length, existing.level_type) }
+ const field = fields.find((field) => field.fieldPath === fieldPath)!
+ const value = fieldPath === 'config' ? config : config[fieldPath.slice(7)]
+ const levels = field.levelType === 'model_config' ? [value, { ...config, model: '' }] : seedLevels(value)
+ const name = computeFactorName(nodeLabel, field.label, factors.map((factor) => factor.name))
+ return { name, level_type: field.levelType, levels, level_labels: defaultFactorLevelLabels(name, levels.length, field.levelType) }
+ }
+ const [draft, setDraft] = useState(() => seed(path))
+ const [saving, setSaving] = useState(false)
+ const [error, setError] = useState('')
+ const initialLevelRef = useRef(null)
+ useEffect(() => {
+ if (initialLevelIndex == null) return
+ initialLevelRef.current?.scrollIntoView?.({ block: 'nearest' })
+ }, [initialLevelIndex])
+ const current = existingFor(path)
+ const blocked = (fieldPath: string) => fieldPath === 'config'
+ ? Object.keys(bindings).some((key) => key.startsWith('config.'))
+ : !!bindings.config
+ const labels = draft.level_labels ?? []
+ const numeric = draft.level_type === 'number'
+ const structured = draft.level_type === 'model_config'
+ const levelKeys = draft.levels.map((level) => numeric && !String(level).trim() ? undefined : factorValueKey(numeric ? Number(level) : level))
+ const duplicateLevels = draft.levels.flatMap((level, index) => {
+ const configured = structured ? !!(level as Record).model : !!String(level).trim()
+ return configured && levelKeys.some((key, other) => other !== index && key === levelKeys[index]) ? [index + 1] : []
+ })
+ const valid = draft.name.trim() && draft.levels.length >= 2 && labels.length === draft.levels.length && labels.every((label) => label.trim()) && new Set(labels.map((label) => label.trim())).size === labels.length && draft.levels.every((level) => structured ? !!(level as Record).model : String(level).trim() && (!numeric || Number.isFinite(Number(level)))) && duplicateLevels.length === 0
+ function move(index: number, offset: number) {
+ const levels = [...draft.levels], nextLabels = [...labels]
+ ;[levels[index], levels[index + offset]] = [levels[index + offset], levels[index]]
+ ;[nextLabels[index], nextLabels[index + offset]] = [nextLabels[index + offset], nextLabels[index]]
+ setDraft({ ...draft, levels, level_labels: nextLabels })
+ }
+ async function commit(remove = false) {
+ setSaving(true)
+ setError('')
+ try {
+ if (remove && current) await onRemove(current.name, path)
+ else {
+ await onSave({ ...draft, name: draft.name.trim(), levels: numeric ? draft.levels.map(Number) : draft.levels, level_labels: labels.map((label) => label.trim()) }, path, current?.name)
+ onClose()
+ }
+ } catch (error) { setError(error instanceof Error ? error.message : 'Could not save the model factor.') }
+ finally { setSaving(false) }
+ }
+ return
+}
diff --git a/frontend/src/components/protocol/ModelField.tsx b/frontend/src/components/protocol/ModelField.tsx
index 715f81b2..d19fcb10 100644
--- a/frontend/src/components/protocol/ModelField.tsx
+++ b/frontend/src/components/protocol/ModelField.tsx
@@ -33,12 +33,14 @@ export function ModelField({
value,
models,
isLoading,
+ disabledModelIds = [],
onChange,
}: {
id?: string
value: string
models: LLMModelInfo[]
isLoading: boolean
+ disabledModelIds?: string[]
onChange: (model: string) => void
}) {
const known = models.find((m) => m.id === value)
@@ -84,7 +86,7 @@ export function ModelField({
diff --git a/frontend/src/components/protocol/SkillTestSelectors.tsx b/frontend/src/components/protocol/SkillTestSelectors.tsx
index ea2b239e..c7680894 100644
--- a/frontend/src/components/protocol/SkillTestSelectors.tsx
+++ b/frontend/src/components/protocol/SkillTestSelectors.tsx
@@ -1,19 +1,19 @@
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
-import { connectedSkills, skillId } from '@/lib/skillFactors'
+import { connectedSkills, skillId, SKILL_FACTOR_PATH } from '@/lib/skillFactors'
import type { useSkillTestSelection } from './useSkillTestSelection'
export function SkillTestSelectors({ selection }: { selection: ReturnType