diff --git a/frontend/src/components/CreateCredentialDialog.test.tsx b/frontend/src/components/CreateCredentialDialog.test.tsx new file mode 100644 index 00000000..9a6e7437 --- /dev/null +++ b/frontend/src/components/CreateCredentialDialog.test.tsx @@ -0,0 +1,30 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { llmSettingsApi } from '@/api/client' +import { CreateCredentialDialog } from './CreateCredentialDialog' + +describe('CreateCredentialDialog', () => { + it('masks an Azure AI Foundry API key by default', async () => { + vi.spyOn(llmSettingsApi, 'list').mockResolvedValue([ + { + provider: 'azure_foundry', + api_base: 'https://example.services.ai.azure.com', + azure_project_endpoint: 'https://example.services.ai.azure.com/api/projects/example', + }, + ]) + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + + render( + + + , + ) + + const apiKey = await screen.findByPlaceholderText('**********') + + expect(apiKey).toHaveAttribute('type', 'password') + expect(apiKey).toHaveAttribute('placeholder', '**********') + expect(apiKey).toHaveValue('') + }) +}) diff --git a/frontend/src/components/CreateCredentialDialog.tsx b/frontend/src/components/CreateCredentialDialog.tsx index 9b52ac95..4c3576c9 100644 --- a/frontend/src/components/CreateCredentialDialog.tsx +++ b/frontend/src/components/CreateCredentialDialog.tsx @@ -191,6 +191,7 @@ export function CreateCredentialDialog({ { setApiKey(e.target.value) diff --git a/frontend/src/components/CreateExperimentFromFileDialog.tsx b/frontend/src/components/CreateExperimentFromFileDialog.tsx index 369d5811..a72f6693 100644 --- a/frontend/src/components/CreateExperimentFromFileDialog.tsx +++ b/frontend/src/components/CreateExperimentFromFileDialog.tsx @@ -1,11 +1,12 @@ -import { useEffect, useMemo, useRef, useState, type ChangeEvent } from 'react' +import { useEffect, useMemo, useState } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { FileJson, Upload } from 'lucide-react' +import { Upload } from 'lucide-react' import { ApiError, experimentsApi } from '@/api/client' 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 { FileDropInput } from '@/components/protocol/FileDropInput' import type { DesignSpec, MeasurementPlan } from '@/types/experiments' import type { ProtocolGraph } from '@/types/protocols' @@ -89,9 +90,8 @@ export function CreateExperimentFromFileDialog({ const [definition, setDefinition] = useState(null) const [name, setName] = useState('') const [nameEdited, setNameEdited] = useState(false) - const [fileName, setFileName] = useState(null) + const [file, setFile] = useState(null) const [fileError, setFileError] = useState(null) - const inputRef = useRef(null) const queryClient = useQueryClient() const experimentsQuery = useQuery({ @@ -150,9 +150,8 @@ export function CreateExperimentFromFileDialog({ setDefinition(null) setName('') setNameEdited(false) - setFileName(null) + setFile(null) setFileError(null) - if (inputRef.current) inputRef.current.value = '' } function handleOpenChange(next: boolean) { @@ -160,23 +159,21 @@ export function CreateExperimentFromFileDialog({ onOpenChange(next) } - async function handleFileSelected(event: ChangeEvent) { - const file = event.target.files?.[0] - event.target.value = '' // selecting the same file again should still parse it + async function handleFileSelected(file: File | null) { if (!file) return importMutation.reset() setFileError(null) try { const parsed = parseDefinition(await file.text()) setDefinition(parsed) - setFileName(file.name) + setFile(file) setNameEdited(false) // If the names request has not completed yet, the effect above fills in // the collision-safe name as soon as it does. setName(nextAvailableExperimentName(parsed.sourceName, existingNames)) } catch (error) { setDefinition(null) - setFileName(null) + setFile(null) setName('') setFileError(error instanceof Error ? error.message : 'Could not read this file as JSON.') } @@ -197,18 +194,13 @@ export function CreateExperimentFromFileDialog({
- void handleFileSelected(event)} + file={file} + onChange={(next) => void handleFileSelected(next)} + placeholder="Drop a JSON file or click to browse" /> - {fileName && ( -

- {fileName} -

- )}
{definition && (
diff --git a/frontend/src/components/protocol/AddNodePanel.tsx b/frontend/src/components/protocol/AddNodePanel.tsx index d7b72186..fbaa9f33 100644 --- a/frontend/src/components/protocol/AddNodePanel.tsx +++ b/frontend/src/components/protocol/AddNodePanel.tsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import { ArrowRight, Atom, BookMarked, Bot, Braces, BrainCircuit, Cloud, Code2, Database, FileText, HardDrive, Repeat2, Route, ScrollText, ShieldCheck, Server, Sparkles, X } from 'lucide-react' +import { ArrowRight, Atom, BookMarked, Bot, Braces, BrainCircuit, Cloud, Code2, Cog, Database, FileText, HardDrive, Repeat2, Route, ScrollText, ShieldCheck, Server, Sparkles, X } from 'lucide-react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { DATASET_BROWSE } from './datasetCatalog' @@ -56,6 +56,13 @@ const NODE_CATALOG = [ description: "Reviews an upstream Agent's output, requests revisions", icon: ShieldCheck, }, + { + type: 'tool_step', + category: 'tools', + label: 'Tool Step', + description: "Calls one MCP tool directly with the upstream node's payload -- deterministic, no model", + icon: Cog, + }, { type: 'model_anthropic', category: 'models', label: 'Anthropic', description: "An Agent or Critic Gate's model, temperature, and parameters", icon: Sparkles }, { type: 'model_openai', category: 'models', label: 'OpenAI', description: "An Agent or Critic Gate's model, temperature, and parameters", icon: Atom }, { diff --git a/frontend/src/components/protocol/ConnectMcpServerDialog.tsx b/frontend/src/components/protocol/ConnectMcpServerDialog.tsx index d6317007..8a947c09 100644 --- a/frontend/src/components/protocol/ConnectMcpServerDialog.tsx +++ b/frontend/src/components/protocol/ConnectMcpServerDialog.tsx @@ -199,11 +199,10 @@ export function ConnectMcpServerDialog({ value={name} onChange={(e) => setName(e.target.value)} /> - {/* Unique across the whole deployment, not just this account -- - worth saying, since the 409 that enforces it is otherwise a - surprise. */} + {/* User-created names are unique within this account. A system + server with the same name also reserves it for every account. */}

- How this connection is identified everywhere else. Must not already be taken. + How this connection is identified elsewhere. Must be unique within your account.

diff --git a/frontend/src/components/protocol/ConversationTranscript.tsx b/frontend/src/components/protocol/ConversationTranscript.tsx index 748bdb49..2182432f 100644 --- a/frontend/src/components/protocol/ConversationTranscript.tsx +++ b/frontend/src/components/protocol/ConversationTranscript.tsx @@ -1,5 +1,6 @@ import { useState } from 'react' import { ChevronDown, ChevronUp } from 'lucide-react' +import { cn } from '@/lib/utils' import type { Conversation, ConversationMessage } from '@/types/protocols' // The literal id the backend uses for the human on both ends of a @@ -51,7 +52,8 @@ function MessageBody({ parts }: { parts: ConversationMessage['parts'] }) { ) } -// The live transcript of a conversation-mode run, over the canvas. Deliberately +// The live transcript of a conversation-mode run, shown inside that run's +// results panel. Deliberately // a flat, ordered list rather than a threaded tree: `sequence` is assigned by // the runtime and one agent runs at a time, so the order things happened in IS // the structure -- nesting would only re-derive what the reading order already @@ -59,48 +61,47 @@ function MessageBody({ parts }: { parts: ConversationMessage['parts'] }) { export function ConversationTranscript({ conversation, agentNames, + className, }: { conversation: Conversation // Node id -> the label shown on that node's card, so the transcript names // agents the way the canvas does. Ids that aren't on the canvas (a node // deleted since the run) fall back to the raw id rather than disappearing. agentNames: Map + className?: string }) { const nameOf = (id: string) => (id === USER_PARTICIPANT ? 'You' : agentNames.get(id) ?? id) const stateLabel = CONVERSATION_STATE_LABEL[conversation.state] ?? conversation.state - // Collapsible because the canvas now opens on the protocol's most recent run - // rather than only on one launched in this tab, so this panel is present the - // whole time you're editing a Peer Collaboration graph -- not just while - // watching a run finish. Collapsed keeps the header, which is the part that - // says a conversation happened at all. + // Collapsible so a long transcript doesn't bury the rest of the results + // panel. Collapsed keeps the header, which is the part that says a + // conversation happened at all. const [collapsed, setCollapsed] = useState(false) return ( - // Positioned by the top-left overlay column in ProtocolCanvas, not by - // itself, so it can't collide with the lock badge it shares that corner - // with (nor with the MiniMap, whose corner it used to sit in). `min-h-0` - // lets it shrink inside that flex column rather than overflowing it, the - // scroll lives on the message list below so the header stays put while you - // read, and `pointer-events-auto` opts back in to the events that column - // waives. -
+ +
{conversation.messages.map((message) => { const badge = message.state ? REPLY_BADGE[message.state] : undefined diff --git a/frontend/src/components/protocol/CreateCustomMetricDialog.tsx b/frontend/src/components/protocol/CreateCustomMetricDialog.tsx deleted file mode 100644 index 52922b3b..00000000 --- a/frontend/src/components/protocol/CreateCustomMetricDialog.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import { useCallback, useRef, useState } from 'react' -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { experimentsApi } from '@/api/client' -import { agentOutputBindingForMetric } from '@/lib/agentOutputMetrics' -import { applyCustomMetricChange, type CustomMetricProducerConfig, type CustomMetricSourceContext } from '@/lib/customMetrics' -import { makeCustomMetric, normalizeDesignMetrics } from '@/lib/metricCatalog' -import { mcpToolBindingForMetric } from '@/lib/mcpToolMetrics' -import { pythonScriptBindingForMetric } from '@/lib/pythonScriptMetrics' -import type { ProtocolGraph } from '@/types/protocols' -import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' -import { CustomMetricFlow } from './CustomMetricDialogs' -import { NODE_INSPECTOR_CONTENT_CLASSNAME } from './NodeInspectorDialog' - -export function CreateCustomMetricDialog({ - experimentId, - protocolId, - graph, - sourceContext, - metricId, - open, - onOpenChange, -}: { - experimentId: string - protocolId: string - graph: ProtocolGraph - sourceContext: CustomMetricSourceContext - metricId?: string - open: boolean - onOpenChange: (open: boolean) => void -}) { - const queryClient = useQueryClient() - const experimentQuery = useQuery({ - queryKey: ['experiments', experimentId], - queryFn: () => experimentsApi.get(experimentId), - enabled: open, - }) - const experiment = experimentQuery.data - const existingMetrics = normalizeDesignMetrics(experiment?.design_spec?.metrics) - const existingMetric = existingMetrics.find((metric) => metric.id === metricId) - const existingBinding = agentOutputBindingForMetric(experiment?.measurement_plan ?? null, metricId) - ?? pythonScriptBindingForMetric(experiment?.measurement_plan ?? null, metricId) - ?? mcpToolBindingForMetric(experiment?.measurement_plan ?? null, metricId) - const [draft] = useState(() => makeCustomMetric({ - name: '', - description: '', - })) - const flushAutosaveRef = useRef<(() => void) | null>(null) - const createMutation = useMutation({ - mutationFn: async ({ metric, config }: { metric: typeof draft; config: CustomMetricProducerConfig }) => { - // Re-read immediately before the replacement PATCH. Design metadata and - // its measurement plan are nested documents, so this preserves edits - // made elsewhere while the metric dialog was open. - const fresh = await experimentsApi.get(experimentId) - const currentMetrics = normalizeDesignMetrics(fresh.design_spec?.metrics) - const metrics = currentMetrics.some((candidate) => candidate.id === metric.id) - ? currentMetrics.map((candidate) => candidate.id === metric.id ? metric : candidate) - : [...currentMetrics, metric] - const measurementPlan = applyCustomMetricChange(fresh.measurement_plan, metric, config, graph) - return experimentsApi.update(experimentId, { - measurement_plan: measurementPlan, - measurement_validation_protocol_id: protocolId, - design_spec: { ...fresh.design_spec, metrics }, - }) - }, - onSuccess: (updated) => { - queryClient.setQueryData(['experiments', experimentId], updated) - queryClient.invalidateQueries({ queryKey: ['experiments'] }) - queryClient.invalidateQueries({ queryKey: ['experiments', experimentId, 'design-impact'] }) - }, - }) - - const close = useCallback(() => { - flushAutosaveRef.current?.() - onOpenChange(false) - }, [onOpenChange]) - - return ( - next ? onOpenChange(true) : close()}> - - - {metricId ? 'Edit custom metric' : 'Create custom metric'} - {metricId ? 'Update this reported metric and its source.' : 'Capture an Agent output or tool result for display and export.'} - -
- {experimentQuery.isPending ? ( -

Loading experiment metrics…

- ) : experimentQuery.isError ? ( -

Could not load this experiment's metrics.

- ) : metricId && !existingMetric ? ( -

This custom metric no longer exists.

- ) : ( - { flushAutosaveRef.current = flush }} - onCancel={close} - onDirtyChange={() => undefined} - onSave={(metric, config) => createMutation.mutateAsync({ metric, config })} - /> - )} - {createMutation.isError && ( -

- {createMutation.error instanceof Error ? createMutation.error.message : 'Could not create the custom metric.'} -

- )} -
-
-
- ) -} diff --git a/frontend/src/components/protocol/CriticGateNodeInspector.tsx b/frontend/src/components/protocol/CriticGateNodeInspector.tsx index 7096e01c..00ab54c6 100644 --- a/frontend/src/components/protocol/CriticGateNodeInspector.tsx +++ b/frontend/src/components/protocol/CriticGateNodeInspector.tsx @@ -1,3 +1,4 @@ +import { useRef } from 'react' import { ShieldCheck } from 'lucide-react' import { nodeAccent } from '@/lib/nodeAccent' import { Input } from '@/components/ui/input' @@ -9,9 +10,12 @@ import { EditableNodeTitle } from './EditableNodeTitle' import { FactorBindableField } from './FactorBindableField' import { NodeInspectorDialog } from './NodeInspectorDialog' import { NodeRunOutputPanel } from './NodeRunOutputPanel' +import { RESIZE_HANDLE_CLASSNAME, useResizablePane } from './useResizablePane' import type { CriticGateNodeConfig, CriticGateNodeData, NodeRunState, ProtocolNode } from '@/types/protocols' const ACCENT = nodeAccent('critic_gate') +const MIN_CONFIG_WIDTH = 380 +const RESIZE_GUTTER_WIDTH = 16 // Same fixed-size NodeInspectorDialog shell as AgentNodeInspector/ // McpToolNodeInspector (see that file), but with a deliberately smaller @@ -36,6 +40,18 @@ export function CriticGateNodeInspector({ onDelete: (nodeId: string) => void onClose: () => void }) { + const columnsRef = useRef(null) + const outputPane = useResizablePane({ + storageKey: 'asaree:critic-gate-output-pane-width', + defaultWidth: 384, + minWidth: 280, + maxWidth: 760, + side: 'right', + resolveMaxWidth: () => + (columnsRef.current?.clientWidth ?? Number.POSITIVE_INFINITY) - MIN_CONFIG_WIDTH - RESIZE_GUTTER_WIDTH, + recomputeKey: node?.id ?? '', + }) + if (!node) return null const data = node.data const config = data.config @@ -71,8 +87,8 @@ export function CriticGateNodeInspector({ onDelete={() => onDelete(node.id)} onClose={onClose} > -
-
+
+
-
+
+ +

Output

- +
diff --git a/frontend/src/components/protocol/CustomMetricDialogs.test.tsx b/frontend/src/components/protocol/CustomMetricDialogs.test.tsx index cd6f7572..e4b0b406 100644 --- a/frontend/src/components/protocol/CustomMetricDialogs.test.tsx +++ b/frontend/src/components/protocol/CustomMetricDialogs.test.tsx @@ -14,10 +14,6 @@ function renderFlow(graph: ProtocolGraph, onSave = vi.fn(), sourceContext?: Cust id: 'metric-1', name: '', kind: 'custom', - valueType: 'number', - direction: 'neutral', - aggregation: 'mean', - primary: false, } render( @@ -36,6 +32,34 @@ function renderFlow(graph: ProtocolGraph, onSave = vi.fn(), sourceContext?: Cust } describe('CustomMetricFlow', () => { + it('sums a runtime measure over several chosen nodes', async () => { + const user = userEvent.setup() + const graph = { + nodes: [ + { id: 'agent-dc', type: 'agent', position: { x: 0, y: 0 }, data: { label: 'DC' } }, + { id: 'gate-dc', type: 'critic_gate', position: { x: 0, y: 0 }, data: { label: 'Critic (DC)' } }, + { id: 'gate-fs', type: 'critic_gate', position: { x: 0, y: 0 }, data: { label: 'Critic (FS)' } }, + ], + edges: [], + } as unknown as ProtocolGraph + const onSave = renderFlow(graph) + + await user.click(screen.getByRole('combobox', { name: 'Metric node' })) + await user.click(screen.getByRole('option', { name: 'Runs of chosen nodes, Node runtime' })) + expect(screen.getByRole('button', { name: 'Add custom metric' })).toBeDisabled() + await user.click(screen.getByRole('checkbox', { name: /^Critic \(FS\)/ })) + await user.click(screen.getByRole('checkbox', { name: /^Critic \(DC\)/ })) + await user.click(screen.getByRole('combobox', { name: 'Runtime measure' })) + await user.click(screen.getByRole('option', { name: /^Turns/ })) + + expect(screen.getByRole('textbox', { name: 'Metric name' })).toHaveValue('Turns · Critic (DC) + Critic (FS)') + await user.click(screen.getByRole('button', { name: 'Add custom metric' })) + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Turns · Critic (DC) + Critic (FS)' }), + { producer: 'node_runtime', nodeIds: ['gate-dc', 'gate-fs'], output: 'agent_loop_iterations' }, + ) + }) + it('captures an Agent final output as a custom metric source', async () => { const user = userEvent.setup() const graph = { @@ -103,7 +127,7 @@ describe('CustomMetricFlow', () => { } as unknown as ProtocolGraph const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) render( { } as unknown as ProtocolGraph const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) render( !sourceContext || sourceContext.producer === 'agent' && source.agentNodeId === sourceContext.nodeId) const pythonSources = pythonScriptSourceOptions(graph).filter((source) => !sourceContext || sourceContext.producer === 'python' && source.scriptNodeId === sourceContext.nodeId) const mcpSources = mcpToolSourceOptions(graph).filter((source) => !sourceContext || sourceContext.producer === 'mcp' && source.mcpNodeId === sourceContext.nodeId) + const toolStepSources = toolStepSourceOptions(graph).filter((source) => !sourceContext || sourceContext.producer === 'tool_step' && source.nodeId === sourceContext.nodeId) + // Every Agent/Critic Gate stays choosable even from a node-first context: + // a stage total often spans several nodes (all the gates, say). + const runtimeNodeSources = nodeRuntimeSourceOptions(graph) + const offerNodeRuntime = runtimeNodeSources.length > 0 && (!sourceContext || sourceContext.producer === 'node_runtime') const initialProducer: CustomMetricProducer | undefined = binding?.producer_id === AGENT_OUTPUT_PRODUCER_ID ? 'agent' : binding?.producer_id === 'asaree.python_script' ? 'python' : binding?.producer_id === 'asaree.mcp_tool' ? 'mcp' - : sourceContext?.producer + : binding?.producer_id === TOOL_STEP_PRODUCER_ID + ? 'tool_step' + : binding?.producer_id === NODE_RUNTIME_PRODUCER_ID + ? 'node_runtime' + : sourceContext?.producer const initialAgentSource = agentSources.find((source) => source.agentNodeId === binding?.config.agent_node_id) ?? (sourceContext?.producer === 'agent' && agentSources.length === 1 ? agentSources[0] : undefined) const initialPythonSource = pythonSources.find((source) => source.agentNodeId === binding?.config.agent_node_id && source.scriptNodeId === binding?.config.script_node_id) ?? (sourceContext?.producer === 'python' && pythonSources.length === 1 ? pythonSources[0] : undefined) const initialMcpSource = mcpSources.find((source) => source.agentNodeId === binding?.config.agent_node_id && source.mcpNodeId === binding?.config.mcp_node_id) ?? (sourceContext?.producer === 'mcp' && mcpSources.length === 1 ? mcpSources[0] : undefined) + const initialToolStepSource = toolStepSources.find((source) => source.nodeId === binding?.config.node_id) + ?? (sourceContext?.producer === 'tool_step' && toolStepSources.length === 1 ? toolStepSources[0] : undefined) + const initialProjection = bindingProjection(binding, metric.id) + const initialRuntimeNodeIds = binding?.producer_id === NODE_RUNTIME_PRODUCER_ID + ? bindingNodeIds(binding) + : sourceContext?.producer === 'node_runtime' ? [sourceContext.nodeId] : [] + const initialRuntimeOutput = binding?.producer_id === NODE_RUNTIME_PRODUCER_ID + ? Object.keys(binding.outputs)[0] ?? 'total_tokens' + : 'total_tokens' const editing = existingMetrics.some((item) => item.id === metric.id) + // Ticking several fields creates one metric per field in one pass -- only + // when creating with an explicit Add button, since autosave needs a single + // draft with a stable id. + const multiField = !editing && !autosave const [name, setName] = useState(editing ? metric.name : '') + const [nameTouched, setNameTouched] = useState(editing) + const [picks, setPicks] = useState(initialProjection ? [initialProjection] : []) + const [toolStepNodeId, setToolStepNodeId] = useState(initialToolStepSource?.nodeId ?? '') const [producer, setProducer] = useState(initialProducer) const [agentNodeId, setAgentNodeId] = useState(initialAgentSource?.agentNodeId ?? '') + const [runtimeNodeIds, setRuntimeNodeIds] = useState(initialRuntimeNodeIds) + const [runtimeOutput, setRuntimeOutput] = useState(initialRuntimeOutput) const serversQuery = useQuery({ queryKey: ['mcp-servers'], queryFn: mcpServersApi.list, staleTime: 60_000, enabled: mcpSources.length > 0 }) const servers = serversQuery.data ?? [] const [pythonSourceKey, setPythonSourceKey] = useState(initialPythonSource?.key ?? '') @@ -124,13 +174,18 @@ export function CustomMetricFlow({ metric, binding, graph, existingMetrics, sour const mcpSourceError = !selectedMcpSource ? 'Choose a direct Agent-to-MCP Tool connection.' : mcpSourceDisabledReason(selectedMcpSource) - const selectedMetricNode = selectedAgentSource + const selectedToolStepSource = toolStepSources.find((source) => source.nodeId === toolStepNodeId) + const toolStepSourceError = !selectedToolStepSource ? 'Choose a Tool Step.' : selectedToolStepSource.disabledReason + const runtimeNodeError = runtimeNodeIds.length === 0 ? 'Choose at least one node.' : undefined + const selectedMetricNode = producer === 'node_runtime' ? 'node_runtime' : selectedAgentSource ? `agent:${selectedAgentSource.agentNodeId}` : selectedPythonSource ? `python:${selectedPythonSource.key}` : selectedMcpSource ? `mcp:${selectedMcpSource.key}` - : '__none__' + : selectedToolStepSource + ? `tool_step:${selectedToolStepSource.nodeId}` + : '__none__' const sourceNodeLabel = (nodeId: string) => graph?.nodes.find((node) => node.id === nodeId)?.data.label || nodeId const sourceAgentLabel = (agentNodeId: string) => graph?.nodes.find((node) => node.id === agentNodeId)?.data.label || agentNodeId // Keep the connection path recognizable without making the node type look @@ -147,48 +202,93 @@ export function CustomMetricFlow({ metric, binding, graph, existingMetrics, sour label: source.label, type: 'Agent', }) - const selectedNodeDisplay = selectedAgentSource + const toolStepNodeDisplay = (source: (typeof toolStepSources)[number]): MetricNodeDisplay => ({ + label: source.label, + type: 'Tool Step', + }) + const nodeRuntimeDisplay: MetricNodeDisplay = { label: 'Runs of chosen nodes', type: 'Node runtime' } + const selectedNodeDisplay = producer === 'node_runtime' ? nodeRuntimeDisplay : selectedAgentSource ? agentNodeDisplay(selectedAgentSource) : selectedPythonSource ? pythonNodeDisplay(selectedPythonSource) : selectedMcpSource ? mcpNodeDisplay(selectedMcpSource) - : null + : selectedToolStepSource + ? toolStepNodeDisplay(selectedToolStepSource) + : null const sourceSelected = producer === 'agent' ? Boolean(selectedAgentSource) : producer === 'python' ? Boolean(selectedPythonSource) - : producer === 'mcp' ? Boolean(selectedMcpSource) : false + : producer === 'mcp' ? Boolean(selectedMcpSource) + : producer === 'tool_step' ? Boolean(selectedToolStepSource) + : producer === 'node_runtime' + // Agent and Tool Step outputs are JSON documents, so a metric can record + // one field of them; Script/MCP Tool metrics record the whole tool result. + const fieldNodeId = producer === 'agent' ? selectedAgentSource?.agentNodeId : producer === 'tool_step' ? selectedToolStepSource?.nodeId : undefined + const runsQuery = useQuery({ + queryKey: ['protocols', protocolId, 'runs'], + queryFn: () => protocolsApi.listRuns(protocolId!), + enabled: Boolean(protocolId && fieldNodeId), + staleTime: 30_000, + }) + const observedPayload = fieldNodeId ? latestNodePayload(runsQuery.data, fieldNodeId) : undefined + const fields = fieldNodeId + ? mergeFields(producer === 'agent' ? declaredAgentFields(graph, fieldNodeId) : [], observedFields(observedPayload)) + : [] + const fieldStatus = !fieldNodeId ? undefined + : runsQuery.isPending && protocolId ? 'Looking for fields in the latest run…' + : observedPayload ? 'Fields from the latest completed run' + (producer === 'agent' ? ' and the Output Parser.' : '.') + : fields.length ? 'Fields from the Output Parser. Run the protocol once to list nested fields.' + : 'No fields yet. Run the protocol once to list them, or type a field path.' + const otherMetricNames = existingMetrics.filter((item) => item.id !== metric.id).map((item) => item.name) + const pickNames = defaultFieldNames( + picks.map((projection) => ({ field: { segments: segmentsForPath(projection.path, fields) }, projection })), + otherMetricNames, + ) + function changePicks(next: FieldProjection[]) { + setPicks(next) + if (!nameTouched) { + setName(next.length === 1 + ? defaultFieldNames([{ field: { segments: segmentsForPath(next[0].path, fields) }, projection: next[0] }], otherMetricNames)[0] + : '') + } + } + // e.g. "Total tokens · DC", or "Turns · Critic (DC) + Critic (FTE)". + function runtimeName(nodeIds: string[], outputKey: string) { + const labels = nodeIds.map((id) => runtimeNodeSources.find((source) => source.nodeId === id)?.label ?? id) + return labels.length ? `${nodeRuntimeOutput(outputKey)?.label ?? outputKey} · ${labels.join(' + ')}` : '' + } + function changeRuntime(nodeIds: string[], outputKey: string) { + setRuntimeNodeIds(nodeIds) + setRuntimeOutput(outputKey) + if (!nameTouched) setName(runtimeName(nodeIds, outputKey)) + } + function toggleRuntimeNode(nodeId: string, checked: boolean) { + const next = checked + ? runtimeNodeSources.map((source) => source.nodeId).filter((id) => id === nodeId || runtimeNodeIds.includes(id)) + : runtimeNodeIds.filter((id) => id !== nodeId) + changeRuntime(next, runtimeOutput) + } function selectMetricNode(value: string | null) { - const agentSource = agentSources.find((source) => `agent:${source.agentNodeId}` === value) - if (agentSource) { - setProducer('agent') - setAgentNodeId(agentSource.agentNodeId) + setPicks([]) + if (!nameTouched) setName('') + if (value === 'node_runtime') { + setProducer('node_runtime') + setAgentNodeId('') setPythonSourceKey('') setMcpSourceKey('') + setToolStepNodeId('') setToolName('') return } + const agentSource = agentSources.find((source) => `agent:${source.agentNodeId}` === value) const pythonSource = pythonSources.find((source) => `python:${source.key}` === value) - if (pythonSource) { - setProducer('python') - setAgentNodeId('') - setPythonSourceKey(pythonSource.key) - setMcpSourceKey('') - setToolName('') - return - } const mcpSource = mcpSources.find((source) => `mcp:${source.key}` === value) - if (mcpSource) { - setProducer('mcp') - setAgentNodeId('') - setPythonSourceKey('') - setMcpSourceKey(mcpSource.key) - setToolName('') - return - } - setProducer(undefined) - setAgentNodeId('') - setPythonSourceKey('') - setMcpSourceKey('') + const toolStepSource = toolStepSources.find((source) => `tool_step:${source.nodeId}` === value) + setProducer(agentSource ? 'agent' : pythonSource ? 'python' : mcpSource ? 'mcp' : toolStepSource ? 'tool_step' : undefined) + setAgentNodeId(agentSource?.agentNodeId ?? '') + setPythonSourceKey(pythonSource?.key ?? '') + setMcpSourceKey(mcpSource?.key ?? '') + setToolStepNodeId(toolStepSource?.nodeId ?? '') setToolName('') } const selectedToolDisabled = Boolean(toolName && selectedMcpSource && !selectedMcpSource.toolNames.includes(toolName)) @@ -204,8 +304,12 @@ export function CustomMetricFlow({ metric, binding, graph, existingMetrics, sour pythonSourceKey: initialPythonSource?.key ?? '', mcpSourceKey: initialMcpSource?.key ?? '', toolName: typeof binding?.config.tool_name === 'string' ? binding.config.tool_name : '', + toolStepNodeId: initialToolStepSource?.nodeId ?? '', + runtimeNodeIds: initialRuntimeNodeIds, + runtimeOutput: initialRuntimeOutput, + picks: initialProjection ? [initialProjection] : [], }) - const currentSignature = JSON.stringify({ name, producer, agentNodeId, pythonSourceKey, mcpSourceKey, toolName }) + const currentSignature = JSON.stringify({ name, producer, agentNodeId, pythonSourceKey, mcpSourceKey, toolName, toolStepNodeId, runtimeNodeIds, runtimeOutput, picks }) const formRef = useRef(null) useEffect(() => onDirtyChange(currentSignature !== initialSignature), [currentSignature, initialSignature, onDirtyChange]) useEffect(() => { @@ -218,21 +322,40 @@ export function CustomMetricFlow({ metric, binding, graph, existingMetrics, sour if (availableTool) setToolName(availableTool) }, [producer, selectedMcpSource, toolName]) + const manyFields = picks.length > 1 const saveDisabled = Boolean( - detailError + (manyFields ? undefined : detailError) || !producer - || (producer === 'agent' ? agentSourceError : producer === 'python' ? pythonSourceError : mcpSourceError || mcpMappingError), + || (producer === 'agent' ? agentSourceError + : producer === 'tool_step' ? toolStepSourceError + : producer === 'node_runtime' ? runtimeNodeError + : producer === 'python' ? pythonSourceError : mcpSourceError || mcpMappingError), ) - // Imported measurement plans can carry typed reported metrics. Editing the - // source/name must not silently downgrade those declarations to opaque. - const nextMetric = { ...metric, name: name.trim(), description: metric.description } - const nextConfig: CustomMetricProducerConfig | null = producer === 'agent' && selectedAgentSource - ? { producer, agentNodeId: selectedAgentSource.agentNodeId } - : producer === 'mcp' && selectedMcpSource - ? { producer, source: selectedMcpSource, toolName } - : producer === 'python' - ? { producer, sourceKey: pythonSourceKey } - : null + const nextMetric: DesignMetric = { id: metric.id, name: name.trim(), kind: 'custom' } + const configFor = (projection: FieldProjection | undefined): CustomMetricProducerConfig | null => + producer === 'agent' && selectedAgentSource + ? { producer, agentNodeId: selectedAgentSource.agentNodeId, ...(projection ? { projection } : {}) } + : producer === 'tool_step' && selectedToolStepSource + ? { producer, nodeId: selectedToolStepSource.nodeId, ...(projection ? { projection } : {}) } + : producer === 'mcp' && selectedMcpSource + ? { producer, source: selectedMcpSource, toolName } + : producer === 'python' + ? { producer, sourceKey: pythonSourceKey } + : producer === 'node_runtime' + ? { producer, nodeIds: runtimeNodeIds, output: runtimeOutput } + : null + const nextConfig = configFor(picks[0]) + async function saveAll() { + if (!manyFields) { + if (nextConfig) await onSave(nextMetric, nextConfig) + return + } + for (const [index, projection] of picks.entries()) { + const config = configFor(projection) + const fieldMetric = index === 0 ? { ...nextMetric, name: pickNames[0] } : { ...makeCustomMetric({ name: pickNames[index] }) } + if (config) await onSave(fieldMetric, config) + } + } const autosaveDraft = autosave && !saveDisabled && nextConfig && currentSignature !== initialSignature ? { metric: nextMetric, config: nextConfig } : null @@ -250,7 +373,7 @@ export function CustomMetricFlow({ metric, binding, graph, existingMetrics, sour return

{editing ? `Edit ${metric.name}` : 'Create custom metric'}

-

Choose an Agent final output or Agent tool result to capture and export.

+

Choose an Agent output, Agent tool result, Tool Step result, or the runtime totals of chosen nodes to capture and export.

@@ -264,10 +387,13 @@ export function CustomMetricFlow({ metric, binding, graph, existingMetrics, sour {agentSources.map((source) => )} {pythonSources.map((source) => )} {mcpSources.map((source) => )} + {toolStepSources.map((source) => )} + {offerNodeRuntime && }
: <> -

{producer === 'agent' ? 'Agent output' : producer === 'python' ? 'Python Script' : 'MCP Tool'}

+

{producer === 'agent' ? 'Agent output' : producer === 'tool_step' ? 'Tool Step' : producer === 'node_runtime' ? 'Node runtime' : producer === 'python' ? 'Python Script' : 'MCP Tool'}

+ {producer === 'tool_step' &&

{selectedToolStepSource?.label}

{toolStepSourceError &&

{toolStepSourceError}

}
} {producer === 'agent' &&

{selectedAgentSource?.label}

{agentSourceError &&

{agentSourceError}

}
} {producer === 'python' &&
{pythonSources.length === 1 ?

{selectedPythonSource?.label}

: }{pythonSourceError && }
} {producer === 'mcp' &&
{mcpSources.length === 1 ?

{selectedMcpSource?.label}

: }{mcpSourceError && }
} @@ -275,9 +401,35 @@ export function CustomMetricFlow({ metric, binding, graph, existingMetrics, sour {producer === 'mcp' &&

{serversQuery.isPending ? 'Loading registered MCP Servers…' : serversQuery.isError ? 'Registered MCP Servers could not be loaded.' : 'Registered MCP Server availability loaded.'}

} + {producer === 'node_runtime' &&
+ Runtime total +
+ + {runtimeNodeSources.map((source) => )} + {runtimeNodeError &&

{runtimeNodeError}

} +
+
+ + +
+

ASAREE sums every run these nodes launch in a replicate, critic-requested revisions included. A Critic Gate's runs are its critic's reviews.

+
} + + {sourceSelected && fieldNodeId && } + {sourceSelected &&
Metric details -
setName(event.target.value)} />{detailError && }
+ {manyFields + ?

Creates {picks.length} metrics, one per field. Rename any of them afterwards.

    {picks.map((pick, index) =>
  • {pickNames[index]}
  • )}
+ :
{ setName(event.target.value); setNameTouched(true) }} />{detailError && }
}
} {sourceSelected && producer === 'mcp' &&
@@ -286,7 +438,8 @@ export function CustomMetricFlow({ metric, binding, graph, existingMetrics, sour

ASAREE records this tool's last call result. The Agent decides whether and how often to call it.

} - {sourceSelected && producer === 'agent' &&

ASAREE records this Agent's completed final output verbatim.

} + {sourceSelected && producer === 'agent' &&

{picks.length ? "ASAREE records the picked field from this Agent's typed output (its Output Parser payload, else JSON in its final answer)." : "ASAREE records this Agent's completed final output verbatim."}

} + {sourceSelected && producer === 'tool_step' &&

{picks.length ? "ASAREE records the picked field from this Tool Step's result." : "ASAREE records this Tool Step's whole result."}

} {autosave ? (

@@ -295,9 +448,7 @@ export function CustomMetricFlow({ metric, binding, graph, existingMetrics, sour ) : (

- +
)} diff --git a/frontend/src/components/protocol/DesignTab.tsx b/frontend/src/components/protocol/DesignTab.tsx index 93c7e9b9..e1a3d4d6 100644 --- a/frontend/src/components/protocol/DesignTab.tsx +++ b/frontend/src/components/protocol/DesignTab.tsx @@ -792,7 +792,7 @@ export function DesignTab({ created from Script or MCP Tool nodes. - { setMetrics(nextMetrics); setMeasurementPlan(nextPlan) }} onApplyMetrics={applyMetrics} disabled={isLocked} /> + { setMetrics(nextMetrics); setMeasurementPlan(nextPlan) }} onApplyMetrics={applyMetrics} disabled={isLocked} />
diff --git a/frontend/src/components/protocol/FileDropInput.tsx b/frontend/src/components/protocol/FileDropInput.tsx index e6a3ef59..c0465bb4 100644 --- a/frontend/src/components/protocol/FileDropInput.tsx +++ b/frontend/src/components/protocol/FileDropInput.tsx @@ -8,7 +8,8 @@ import { cn } from '@/lib/utils' // standard accessible custom-file-input pattern and gives this a natural // drop target: the label itself listens for the drag events, no extra // wrapper div needed. Shared by RegisterDatasetDialog (raw CSV, data -// dictionary) and SplitDatasetDialog (train/test CSVs for a manual split). +// dictionary), SplitDatasetDialog (train/test CSVs for a manual split) and +// CreateExperimentFromFileDialog (experiment definition JSON). export function FileDropInput({ id, accept, @@ -47,7 +48,17 @@ export function FileDropInput({ > {file ? file.name : placeholder} - onChange(e.target.files?.[0] ?? null)} /> + { + const picked = e.target.files?.[0] ?? null + e.target.value = '' // re-picking the same file should still fire onChange + onChange(picked) + }} + /> ) } diff --git a/frontend/src/components/protocol/HandoffSummary.tsx b/frontend/src/components/protocol/HandoffSummary.tsx index d9da1595..ecca64ed 100644 --- a/frontend/src/components/protocol/HandoffSummary.tsx +++ b/frontend/src/components/protocol/HandoffSummary.tsx @@ -1,5 +1,11 @@ import { ArrowLeft, ArrowRight } from 'lucide-react' -import { PREVIOUS_TOKEN, referencedSenderIds, usesPreviousToken, type HandoffPeers } from '@/lib/promptReferences' +import { + describeHandoff, + PREVIOUS_TOKEN, + referencedSenderIds, + usesPreviousToken, + type HandoffPeers, +} from '@/lib/promptReferences' // Who hands off to this agent, and who it hands off to -- split into two // components because the Agent inspector puts them at opposite ends of itself: @@ -37,6 +43,16 @@ export function ReceivesSummary({ peers, prompt }: { peers: HandoffPeers; prompt {peers.receives.map((target) => ( {target.name} + {describeHandoff(target.handoff) && ( + // Set on the edge itself (its hover toolbar), so the + // readout says where to change it. + + {describeHandoff(target.handoff)} + + )} {placed.has(target.id) && ( // Purely positional, and dim on purpose: it answers "why is // this one not at the bottom with the others", which is @@ -78,6 +94,16 @@ export function ReceivesSummary({ peers, prompt }: { peers: HandoffPeers; prompt ) } +function handoffTitle(handoff: NonNullable): string { + const what = + handoff.mode === 'fields' + ? 'only the extracted fields, without the prose' + : `only ${(handoff.fields ?? []) + .map((f) => (f.item_keys?.length ? `${f.name}[${f.item_keys.join(', ')}]` : f.name)) + .join(', ')}` + return `This connection passes ${what}. Change it from the connection's hover toolbar on the canvas.` +} + export function SendsSummary({ peers }: { peers: HandoffPeers }) { return (
diff --git a/frontend/src/components/protocol/MetricFieldPicker.tsx b/frontend/src/components/protocol/MetricFieldPicker.tsx new file mode 100644 index 00000000..66eb13d1 --- /dev/null +++ b/frontend/src/components/protocol/MetricFieldPicker.tsx @@ -0,0 +1,74 @@ +import { useState } from 'react' +import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { projectionLabel, type FieldProjection, type MetricField } from '@/lib/metricFields' + +function sameProjection(a: FieldProjection, b: FieldProjection) { + return a.path === b.path && (a.transform ?? null) === (b.transform ?? null) +} + +// Which part of a producer's output a custom metric records: the whole output +// (no picks), or one field per metric. `multi` lets one pass create a metric +// per ticked field; otherwise ticking a field replaces the current pick. +export function MetricFieldPicker({ fields, picks, multi, status, onChange }: { + fields: MetricField[] + picks: FieldProjection[] + multi: boolean + status?: string + onChange: (picks: FieldProjection[]) => void +}) { + const [filter, setFilter] = useState('') + const [customPath, setCustomPath] = useState('') + const [customCount, setCustomCount] = useState(false) + const options: { projection: FieldProjection; origin?: MetricField['origin'] }[] = [ + ...fields.flatMap((field) => [ + { projection: { path: field.path }, origin: field.origin }, + ...(field.countable ? [{ projection: { path: field.path, transform: 'length' as const }, origin: field.origin }] : []), + ]), + // A typed path (or a saved one no longer offered) stays visible and untickable. + ...picks.filter((pick) => !fields.some((field) => field.path === pick.path)).map((projection) => ({ projection })), + ] + const needle = filter.trim().toLocaleLowerCase() + const visible = needle ? options.filter((option) => option.projection.path.toLocaleLowerCase().includes(needle)) : options + function toggle(projection: FieldProjection, checked: boolean) { + if (!checked) onChange(picks.filter((pick) => !sameProjection(pick, projection))) + else onChange(multi ? [...picks, projection] : [projection]) + } + function addCustom() { + const path = customPath.trim() + if (!path) return + toggle(customCount ? { path, transform: 'length' } : { path }, true) + setCustomPath('') + setCustomCount(false) + } + + return
+ Field + + {options.length > 8 && setFilter(event.target.value)} />} + {visible.length > 0 &&
+ {visible.map(({ projection, origin }) => { + const label = projectionLabel(projection) + return + })} +
} + {status &&

{status}

} +
+
+ + setCustomPath(event.target.value)} onKeyDown={(event) => { if (event.key === 'Enter') { event.preventDefault(); addCustom() } }} /> +
+ + +
+
+} diff --git a/frontend/src/components/protocol/MetricsEditor.autosave.test.tsx b/frontend/src/components/protocol/MetricsEditor.autosave.test.tsx index 98f7e257..caef858d 100644 --- a/frontend/src/components/protocol/MetricsEditor.autosave.test.tsx +++ b/frontend/src/components/protocol/MetricsEditor.autosave.test.tsx @@ -31,9 +31,9 @@ describe('MetricsEditor autosave', () => { await user.click(screen.getByRole('button', { name: 'Add metrics' })) const dialog = await screen.findByRole('dialog', { name: 'Manage metrics' }) - const contextual = new Set(['tool_calls', 'tool_error_rate', 'critic_approvals', 'critic_rejections']) + const contextual = new Set(['tool_calls', 'tool_error_rate', 'critic_approvals', 'critic_rejections', 'critic_invocations', 'critic_rejections_partial', 'critic_rejections_full', 'revision_rounds']) for (const entry of METRIC_CATALOG.filter((candidate) => candidate.kind === 'runtime')) { - const checkbox = within(dialog).getByRole('checkbox', { name: new RegExp(`^${entry.name}`) }) + const checkbox = within(dialog).getByRole('checkbox', { name: new RegExp(`^${entry.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`) }) if (contextual.has(entry.key)) expect(checkbox).not.toBeChecked() else expect(checkbox).toBeChecked() } diff --git a/frontend/src/components/protocol/MetricsEditor.tsx b/frontend/src/components/protocol/MetricsEditor.tsx index 82e37fd4..78b4ad9c 100644 --- a/frontend/src/components/protocol/MetricsEditor.tsx +++ b/frontend/src/components/protocol/MetricsEditor.tsx @@ -10,7 +10,23 @@ import { AGENT_OUTPUT_PRODUCER_ID, agentOutputBindingForMetric, agentOutputSourc import { applyCustomMetricChange, type CustomMetricProducerConfig } from '@/lib/customMetrics' import { pythonScriptBindingForMetric, pythonScriptSourceOptions } from '@/lib/pythonScriptMetrics' import { mcpToolBindingForMetric, mcpToolSourceOptions } from '@/lib/mcpToolMetrics' -import { localMetricReadinessPreview, removeMetricFromMeasurementPlan, upsertRuntimeMetric } from '@/lib/measurementPlan' +import { bindingProjection, projectionLabel } from '@/lib/metricFields' +import { toolStepBindingForMetric, toolStepSourceOptions } from '@/lib/toolStepMetrics' +import { + NODE_RUNTIME_PRODUCER_ID, + bindingNodeIds, + nodeRuntimeBindingForMetric, + nodeRuntimeOutput, + nodeRuntimeSourceOptions, +} from '@/lib/nodeRuntimeMetrics' +import { + DERIVED_PRODUCER_IDS, + FEATURE_PIPELINE_PRODUCER_ID, + TOOL_STEP_PRODUCER_ID, + localMetricReadinessPreview, + removeMetricFromMeasurementPlan, + upsertRuntimeMetric, +} from '@/lib/measurementPlan' import { METRIC_CATALOG, makeCatalogMetric, makeCustomMetric, normalizeDesignMetrics, type MetricCatalogEntry } from '@/lib/metricCatalog' import type { ProtocolGraph } from '@/types/protocols' import type { DesignMetric, MeasurementPlan } from '@/types/experiments' @@ -18,6 +34,15 @@ import { CustomMetricFlow, MetricNodeLabel, type MetricNodeDisplay } from './Cus import { NodeInspectorDialog } from './NodeInspectorDialog' import { useDialogAutosave } from './useDialogAutosave' +const CRITIC_METRIC_KEYS = new Set([ + 'critic_approvals', + 'critic_rejections', + 'critic_invocations', + 'critic_rejections_partial', + 'critic_rejections_full', + 'revision_rounds', +]) + function synchronizeMeasurementPlanMetrics(plan: MeasurementPlan | null, metrics: DesignMetric[]) { if (!plan) return null const definitionsById = new Map(plan.metrics.map((definition) => [definition.id, definition])) @@ -25,13 +50,19 @@ function synchronizeMeasurementPlanMetrics(plan: MeasurementPlan | null, metrics const definition = metric.id ? definitionsById.get(metric.id) : undefined if (!definition) return [] definitionsById.delete(definition.id) - return [{ + const synchronized = { ...definition, name: metric.name, - direction: metric.direction, - primary: metric.primary, description: metric.description, unit: metric.unit, + } + // A custom metric's definition is owned by its producer's upsert -- reported + // ones are just {id, name}; a node runtime one also carries the value type + // and aggregation its output needs -- so keep it rather than rebuild it. + return [metric.kind === 'custom' ? { ...definition, name: metric.name } : { + ...synchronized, + direction: metric.direction, + primary: metric.primary, }] }) return { ...plan, metrics: [...orderedDefinitions, ...definitionsById.values()] } @@ -55,6 +86,11 @@ function customMetricProducerDisplay( if (binding.producer_id === 'asaree.python_script') { return { label: `${agent}:${nodeLabel(binding.config.script_node_id)}`, type: 'Script' } } + if (binding.producer_id === TOOL_STEP_PRODUCER_ID) return { label: nodeLabel(binding.config.node_id), type: 'Tool Step' } + if (binding.producer_id === FEATURE_PIPELINE_PRODUCER_ID) return { label: 'DC → FTE → FS', type: 'Feature pipeline' } + if (binding.producer_id === NODE_RUNTIME_PRODUCER_ID) { + return { label: bindingNodeIds(binding).map(nodeLabel).join(' + ') || 'No nodes', type: 'Node runtime' } + } if (binding.producer_id === 'asaree.mcp_tool') { const toolName = typeof binding.config.tool_name === 'string' ? binding.config.tool_name : null return { @@ -79,6 +115,7 @@ function MetricsDialog({ metrics, measurementPlan, graph, + protocolId, metricIssueById, }: { open: boolean @@ -94,6 +131,7 @@ function MetricsDialog({ metrics: DesignMetric[] measurementPlan: MeasurementPlan | null graph: ProtocolGraph | undefined + protocolId?: string metricIssueById: Map }) { const selectedKeySignature = selectedKeys.join('\u0000') @@ -137,12 +175,14 @@ function MetricsDialog({ const hasPythonMetricCandidate = pythonScriptSourceOptions(graph).some((source) => !source.disabledReason) const hasMcpMetricCandidate = mcpToolSourceOptions(graph).some((source) => !source.disabledReason) const hasAgentMetricCandidate = agentOutputSourceOptions(graph).some((source) => !source.disabledReason) + const hasToolStepMetricCandidate = toolStepSourceOptions(graph).some((source) => !source.disabledReason) + const hasNodeRuntimeMetricCandidate = nodeRuntimeSourceOptions(graph).some((source) => !source.disabledReason) const canvasMetricKeys = new Set(contextualMetricSuggestions(graph).map((suggestion) => suggestion.key)) const hasValidTool = canvasMetricKeys.has('tool_error_rate') const hasCriticGate = graph?.nodes.some((node) => node.type === 'critic_gate') ?? false const canvasCannotProduce = (key: string) => ((key === 'tool_calls' || key === 'tool_error_rate') && !hasValidTool) - || ((key === 'critic_approvals' || key === 'critic_rejections') && !hasCriticGate) + || (CRITIC_METRIC_KEYS.has(key) && !hasCriticGate) const unavailableBuiltInKeys = capabilitiesLoading || capabilitiesUnavailable ? [] : builtInEntries.flatMap((entry) => { @@ -150,7 +190,7 @@ function MetricsDialog({ return unavailable ? [entry.key] : [] }) const unavailableBuiltInKeySignature = unavailableBuiltInKeys.join('\u0000') - const canCreateCustomMetric = hasAgentMetricCandidate || hasPythonMetricCandidate || hasMcpMetricCandidate + const canCreateCustomMetric = hasAgentMetricCandidate || hasPythonMetricCandidate || hasMcpMetricCandidate || hasToolStepMetricCandidate || hasNodeRuntimeMetricCandidate const changedMetrics = metrics.map((metric) => customChanges.find((change) => change.metric.id === metric.id)?.metric ?? metric) .concat(customChanges.filter((change) => !metrics.some((metric) => metric.id === change.metric.id)).map((change) => change.metric)) const customMetricById = new Map(changedMetrics.filter((metric) => metric.kind === 'custom' && metric.id).map((metric) => [metric.id!, metric])) @@ -168,6 +208,8 @@ function MetricsDialog({ return agentOutputBindingForMetric(stagedPlan, metricId) ?? pythonScriptBindingForMetric(stagedPlan, metricId) ?? mcpToolBindingForMetric(stagedPlan, metricId) + ?? toolStepBindingForMetric(stagedPlan, metricId) + ?? nodeRuntimeBindingForMetric(stagedPlan, metricId) } function stagedReadinessFor(metric: DesignMetric) { const local = localMetricReadinessPreview(metric, stagedPlan, graph) @@ -316,7 +358,7 @@ function MetricsDialog({ ? 'Not supported by this runtime.' : (entry.key === 'tool_calls' || entry.key === 'tool_error_rate') && !hasValidTool ? 'Connect an enabled, configured tool to an active Agent to use this metric.' - : (entry.key === 'critic_approvals' || entry.key === 'critic_rejections') && !hasCriticGate + : CRITIC_METRIC_KEYS.has(entry.key) && !hasCriticGate ? 'Add a Critic Gate to the canvas to use this metric.' : undefined const unavailableReasonId = `metric-unavailable-${entry.key}` @@ -336,37 +378,44 @@ function MetricsDialog({

Custom metrics

Create metrics from eligible nodes on this canvas.

- {!customMetricDraft && } + {!customMetricDraft && }
{visibleCustomMetrics.length > 0 &&
{visibleCustomMetrics.map((metric) => { const binding = stagedBindingForMetric(metric.id) const readiness = stagedReadinessFor(metric) const selectedPosition = metric.id ? customMetricIds.indexOf(metric.id) : -1 const producerDisplay = customMetricProducerDisplay(binding, graph) + const projection = bindingProjection(binding, metric.id) + const runtimeOutput = binding?.producer_id === NODE_RUNTIME_PRODUCER_ID + ? nodeRuntimeOutput(Object.keys(binding.outputs)[0]) + : undefined return
{producerDisplay ? : Custom metric} {metric.name} + {projection && {projectionLabel(projection)}} + {runtimeOutput && {runtimeOutput.key}} {!readiness.ready && {readiness.detail}} - {binding && metric.valueType === 'opaque' && } + {binding && (binding.producer_id === TOOL_STEP_PRODUCER_ID || !DERIVED_PRODUCER_IDS.has(binding.producer_id)) && }
})}
}

{stagedOrderAnnouncement}

{visibleCustomMetrics.length === 0 && !customMetricDraft &&

No custom metrics yet.

} - {!canCreateCustomMetric && !customMetricDraft &&

Create custom metrics from an enabled Agent, Script, or configured MCP Tool.

} + {!canCreateCustomMetric && !customMetricDraft &&

Create custom metrics from an enabled Agent, Tool Step, Script, or configured MCP Tool.

} {customMetricDraft && { setCustomMetricDraft(undefined); setCustomMetricDirty(false) }} onDirtyChange={setCustomMetricDirty} @@ -401,11 +450,13 @@ export function MetricsEditor({ metrics, measurementPlan, graph, + protocolId, onChange, onApplyMetrics, disabled = false, }: { experimentId?: string + protocolId?: string metrics: DesignMetric[] measurementPlan: MeasurementPlan | null graph: ProtocolGraph | undefined @@ -502,7 +553,7 @@ export function MetricsEditor({ ...nextPlan, metrics: nextPlan.metrics.map((definition) => { const designMetric = byId.get(definition.id) - return designMetric ? { ...definition, primary: designMetric.primary } : definition + return designMetric?.kind === 'runtime' ? { ...definition, primary: designMetric.primary } : definition }), } return { metrics: nextMetrics, measurementPlan: synchronizedPlan } @@ -539,6 +590,7 @@ export function MetricsEditor({ metrics={normalized} measurementPlan={measurementPlan} graph={graph} + protocolId={protocolId} metricIssueById={serverIssueByMetricId} onApply={async (selectedKeys, customChanges, customMetricIds) => { let draft = builtInDraft(selectedKeys) diff --git a/frontend/src/components/protocol/ProtocolCanvas.test.tsx b/frontend/src/components/protocol/ProtocolCanvas.test.tsx index 784f4afa..6eb7ddce 100644 --- a/frontend/src/components/protocol/ProtocolCanvas.test.tsx +++ b/frontend/src/components/protocol/ProtocolCanvas.test.tsx @@ -15,6 +15,8 @@ import { defaultReasonActPatternNodeData, defaultScriptNodeData, type ProtocolGraph, + type ProtocolRun, + type TestRun, } from '@/types/protocols' import { ProtocolCanvas } from './ProtocolCanvas' @@ -44,6 +46,58 @@ function renderCanvas(graph: ProtocolGraph, experimentId: string | null = null) return { client, ...result } } +function protocolRun(overrides: Partial = {}): ProtocolRun { + return { + id: 'production-run', + protocol_id: 'protocol-1', + status: 'running', + node_runs: {}, + conversation: null, + error: null, + replicate_label: 'replicate-1', + replicate_result_id: 'replicate-result-1', + factor_values: {}, + design_revision_id: 'design-revision-1', + protocol_revision_id: 'protocol-revision-1', + target_node_id: null, + cancel_requested_at: null, + created_at: '2026-10-01T12:00:00Z', + updated_at: '2026-10-01T12:00:00Z', + observations: [], + artifacts: [], + ...overrides, + } +} + +function testRun(overrides: Partial = {}): TestRun { + return { + id: 'test-run', + protocol_id: 'protocol-1', + status: 'completed', + error: null, + protocol_revision_id: 'protocol-revision-1', + created_at: '2026-09-30T12:00:00Z', + updated_at: '2026-09-30T12:01:00Z', + observations: [], + artifacts: [], + conversation: null, + tested_published_revision: null, + freshness: { out_of_date: false, reasons: [] }, + resources: { + task: { duration_seconds: null, cost_usd: null }, + evaluation: { duration_seconds: null, cost_usd: null }, + total: { duration_seconds: null, cost_usd: null }, + }, + execution_summary: { + node_runs: {}, + started_at: null, + completed_at: '2026-09-30T12:01:00Z', + cancel_requested_at: null, + }, + ...overrides, + } +} + describe('ProtocolCanvas connector adds', () => { afterEach(() => vi.unstubAllGlobals()) @@ -166,6 +220,39 @@ describe('ProtocolCanvas connector adds', () => { expect(document.querySelector('[data-nodeid="model-1"][data-handleid="model"]')).toHaveClass('connectable') }) + it('draws Tool Step main-flow arrows under Sequential and Critic Gate strategies', async () => { + const graph: ProtocolGraph = { + nodes: [ + { id: 'agent-1', type: 'agent', position: { x: 100, y: 100 }, data: defaultAgentNodeData('Writer') }, + { + id: 'tool-step-1', + type: 'tool_step', + position: { x: 400, y: 100 }, + data: { label: 'Fetch', config: { tool_name: '', arguments: {}, hash_checks: {}, timeout_seconds: null } }, + }, + { id: 'agent-2', type: 'agent', position: { x: 700, y: 100 }, data: defaultAgentNodeData('Reviewer') }, + ], + edges: [ + { id: 'into-tool', source: 'agent-1', target: 'tool-step-1' }, + { id: 'out-of-tool', source: 'tool-step-1', target: 'agent-2' }, + ], + } + + const sequential = renderCanvas(graph) + await waitFor(() => expect(sequential.container.querySelector('.react-flow__arrowhead')).toBeInTheDocument()) + sequential.unmount() + + vi.spyOn(experimentsApi, 'get').mockResolvedValue({ + id: 'experiment-1', name: 'Experiment', description: null, hypothesis: null, design_type: 'factorial', task_brief: null, + design_spec: { factors: [], metrics: [], coordination_strategy: { slug: 'critic_gate', params: {} } }, + measurement_plan: null, dataset_ids: [], dataset_id: null, locked_at: null, + locked_protocol_revision_id: null, locked_design_spec: null, locked_measurement_plan: null, + created_at: '', updated_at: '', archived_at: null, + }) + const critic = renderCanvas(graph, 'experiment-1') + await waitFor(() => expect(critic.container.querySelector('.react-flow__arrowhead')).toBeInTheDocument()) + }) + it('adds a connector-only Sub-Agent from an Agent', async () => { const user = userEvent.setup() const { client } = renderCanvas({ @@ -306,4 +393,39 @@ describe('ProtocolCanvas connector adds', () => { expect(screen.queryByText('Bind to a field on the canvas')).not.toBeInTheDocument() }) + it('keeps the Test Run label when a production replicate is running', async () => { + const run = protocolRun() + vi.mocked(protocolsApi.listRuns).mockResolvedValue([run]) + vi.spyOn(protocolsApi, 'getRun').mockResolvedValue(run) + + renderCanvas({ nodes: [], edges: [] }) + + await screen.findByRole('button', { name: 'Stop' }) + expect(screen.getByRole('button', { name: 'Test Run' })).toBeDisabled() + expect(screen.queryByRole('button', { name: 'Test Run running…' })).not.toBeInTheDocument() + }) + + it('stops the active production replicate instead of a completed Test Run', async () => { + const user = userEvent.setup() + const run = protocolRun() + vi.mocked(protocolsApi.listRuns).mockResolvedValue([run]) + vi.spyOn(protocolsApi, 'getRun').mockResolvedValue(run) + vi.spyOn(protocolsApi, 'getLatestTestRun').mockResolvedValue(testRun()) + const cancelRun = vi.spyOn(protocolsApi, 'cancelRun').mockResolvedValue({ + ...run, + cancel_requested_at: '2026-10-01T12:00:30Z', + }) + vi.spyOn(experimentsApi, 'get').mockResolvedValue({ + id: 'experiment-1', name: 'Experiment', description: null, hypothesis: null, design_type: 'factorial', task_brief: null, + design_spec: { factors: [], metrics: [] }, measurement_plan: null, dataset_ids: [], dataset_id: null, + locked_at: null, locked_protocol_revision_id: null, locked_design_spec: null, locked_measurement_plan: null, + created_at: '', updated_at: '', archived_at: null, + }) + + renderCanvas({ nodes: [], edges: [] }, 'experiment-1') + + await user.click(await screen.findByRole('button', { name: 'Stop' })) + expect(cancelRun).toHaveBeenCalledWith('protocol-1', 'production-run') + }) + }) diff --git a/frontend/src/components/protocol/ProtocolCanvas.tsx b/frontend/src/components/protocol/ProtocolCanvas.tsx index 41db3cee..3e65e3bd 100644 --- a/frontend/src/components/protocol/ProtocolCanvas.tsx +++ b/frontend/src/components/protocol/ProtocolCanvas.tsx @@ -33,6 +33,7 @@ import { defaultAnthropicModelNodeData, defaultAzureFoundryModelNodeData, defaultCriticGateNodeData, + defaultToolStepNodeData, defaultDatasetNodeData, defaultLocalModelNodeData, defaultMcpToolNodeData, @@ -47,6 +48,7 @@ import { import type { AgentNodeData, CriticGateNodeData, + ToolStepNodeData, DatasetNodeData, ModelNodeData, McpToolNodeData, @@ -75,6 +77,7 @@ import { AgentNodeInspector } from './AgentNodeInspector' import { agentTracedLabel, factorBoundField, revealsHiddenMcpServers, toolFactorServerId, unboundBindableFields, type UnboundField } from './bindableFields' import { CanvasControls } from './CanvasControls' import { CriticGateNodeInspector } from './CriticGateNodeInspector' +import { ToolStepNodeInspector } from './ToolStepNodeInspector' import { DatasetNodeInspector } from './DatasetNodeInspector' import { DeleteNodeConfirmDialog } from './DeleteNodeConfirmDialog' import { DEFAULT_ZOOM } from './constants' @@ -110,7 +113,7 @@ import { } from './ProtocolCanvasContext' import { ReasonActPatternNodeInspector } from './ReasonActPatternNodeInspector' import { RunConfirmDialog } from './RunConfirmDialog' -import { ReopenTestRunResultsButton, TestRunResults } from './TestRunResults' +import { describeRun, ReopenTestRunResultsButton, TestRunResults } from './TestRunResults' import type { RunScope } from './runSummary' import { ScriptNodeInspector } from './ScriptNodeInspector' import { SingleAgentBaselinePatternNodeInspector } from './SingleAgentBaselinePatternNodeInspector' @@ -122,10 +125,10 @@ import { OkfDocumentNodeInspector } from './OkfDocumentNodeInspector' import { SkillBrowserPanel } from './SkillBrowserPanel' import { SKILL_BROWSE, nodeDataForSkill } from './skillCatalog' import { SkillNodeInspector } from './SkillNodeInspector' -import { ConversationTranscript } from './ConversationTranscript' import { InteractEdge } from './edges/InteractEdge' import { AgentNode } from './nodes/AgentNode' import { CriticGateNode } from './nodes/CriticGateNode' +import { ToolStepNode } from './nodes/ToolStepNode' import { DatasetNode } from './nodes/DatasetNode' import { ModelNode } from './nodes/ModelNode' import { McpClientToolNode } from './nodes/McpClientToolNode' @@ -167,6 +170,7 @@ const NODE_TYPES = { // that distinguishes it. Same data, same inspector. mcp_client_tool: McpClientToolNode, critic_gate: CriticGateNode, + tool_step: ToolStepNode, // All five LLM provider types render through the same component -- it // derives icon/accent/placeholder from data.config.provider, not from // which of these five keys it was registered under. @@ -217,6 +221,7 @@ function defaultDataFor(nodeType: string): ProtocolNode['data'] { // it. if (nodeType === 'mcp_tool') return defaultMcpToolNodeData() if (nodeType === 'critic_gate') return defaultCriticGateNodeData() + if (nodeType === 'tool_step') return defaultToolStepNodeData() if (nodeType === 'model_anthropic') return defaultAnthropicModelNodeData() if (nodeType === 'model_openai') return defaultOpenAiModelNodeData() if (nodeType === 'model_azure_foundry') return defaultAzureFoundryModelNodeData() @@ -280,6 +285,10 @@ function datasetIdsInGraph(nodes: Node[], factors: DesignFactor[] = EMPTY_FACTOR // callable capability of its own, so it shares Tool's slot rather than // getting a dedicated one. Dataset used to share it too, but now has its // own slot -- what an agent operates ON, not a capability it operates WITH. +// What MainEdgeAddStub and an edge's insert button can add: the main-flow +// steps, i.e. an Agent's turn or a Tool Step's fixed call. +const MAIN_FLOW_NODE_TYPES = ['agent', 'tool_step'] + const CONNECTOR_PANEL_INFO: Record = { model: { allowedTypes: MODEL_NODE_TYPES, title: 'Add Model' }, tool: { allowedTypes: [MCP_SERVER_BROWSE, 'script'], title: 'Add Tool' }, @@ -543,7 +552,7 @@ export const ProtocolCanvas = forwardRef(null) const [testResultsOpen, setTestResultsOpen] = useState(false) - const [playResultsOpen, setPlayResultsOpen] = useState(false) + const [runResultsOpen, setRunResultsOpen] = useState(false) const paneRef = useRef(null) const { screenToFlowPosition, fitView } = useReactFlow() @@ -635,7 +644,7 @@ export const ProtocolCanvas = forwardRef protocolsApi.testRun(protocolId), onSuccess: (run) => { setRunId(run.id) - setPlayResultsOpen(false) + setRunResultsOpen(false) setTestResultsOpen(true) queryClient.setQueryData(['protocols', protocolId, 'test-run'], run) queryClient.invalidateQueries({ queryKey: ['experiments', experimentId] }) @@ -652,7 +661,7 @@ export const ProtocolCanvas = forwardRef { setRunId(run.id) setTestResultsOpen(false) - setPlayResultsOpen(true) + setRunResultsOpen(true) }, }) @@ -680,7 +689,11 @@ export const ProtocolCanvas = forwardRef= activeProtocolRun.createdAt ? activeTestRun : activeProtocolRun + : activeTestRun ?? activeProtocolRun // Stop button -- only raises cancel_requested_at; run_protocol's own node // loop (polled between nodes, not mid-node) is what actually honors it. @@ -743,14 +771,15 @@ export const ProtocolCanvas = forwardRef protocolsApi.cancelRun(protocolId, testRunQuery.data?.id ?? runId!), + mutationFn: () => protocolsApi.cancelRun(protocolId, cancellableRun!.id), onSuccess: () => { runQuery.refetch() testRunQuery.refetch() }, }) - const cancelRequested = !!testRunQuery.data?.execution_summary.cancel_requested_at || !!runQuery.data?.cancel_requested_at - const showStandaloneConversation = !!runQuery.data?.conversation && runQuery.data.id !== testRunQuery.data?.id + const cancelRequested = !!cancellableRun?.cancelRequested + const runResultLabel = runQuery.data?.target_node_id ? 'Play Results' : 'Latest Run' + const runResultTitle = runQuery.data?.replicate_label ? `Cell Run · ${runQuery.data.replicate_label}` : runResultLabel // A connected execution-pattern node must never be deletable directly // (Backspace/Delete key, NodeHoverToolbar's trash icon -- both go through @@ -1013,6 +1042,9 @@ export const ProtocolCanvas = forwardRef nodeDisplayNames(nodes), [nodes]) + const runResultKind = runQuery.data?.target_node_id + ? `Play of ${nodeNames.get(runQuery.data.target_node_id) ?? runQuery.data.target_node_id}` + : runQuery.data?.replicate_label ? 'Cell run' : 'Graph run' const nodeTypes = useMemo(() => new Map(nodes.map((node) => [node.id, node.type ?? ''])), [nodes]) // The experiment's declared coordination strategy, which decides what the @@ -1025,6 +1057,7 @@ export const ProtocolCanvas = forwardRef(() => { - if (!isSequential) return edges - const agentIds = new Set(nodes.filter((node) => node.type === 'agent').map((node) => node.id)) + if (!isDirectedFlow) return edges return edges.map((edge) => { - const isAgentFlow = - !edge.sourceHandle && - !edge.targetHandle && - agentIds.has(edge.source) && - agentIds.has(edge.target) - if (!isAgentFlow) return edge + if (edge.sourceHandle || edge.targetHandle) return edge return { ...edge, - data: { ...edge.data, sequentialAgentFlow: true }, + data: { ...edge.data, directedFlow: true }, markerEnd: { type: MarkerType.ArrowClosed, width: 12, @@ -1059,7 +1086,7 @@ export const ProtocolCanvas = forwardRef() for (const metricId of Object.values(producer.outputs)) { @@ -1560,9 +1589,9 @@ export const ProtocolCanvas = forwardRef n.id === originId) @@ -1571,24 +1600,28 @@ export const ProtocolCanvas = forwardRef n.position), desired) const newId = newNodeId() - const newNode: Node = { id: newId, type: 'agent', position, data: defaultDataFor('agent') } + const newNode: Node = { id: newId, type: nodeType, position, data: dataOverride ?? defaultDataFor(nodeType) } const mainEdge: Edge = direction === 'outgoing' ? { id: newNodeId(), source: originId, target: newId } : { id: newNodeId(), source: newId, target: originId } - const { patternNode, patternEdge } = agentDefaultPattern(newId, position, nodes.map((n) => n.position)) - setNodes((nds) => nds.concat(newNode, patternNode)) - setEdges((eds) => eds.concat(mainEdge, patternEdge)) + const pattern = nodeType === 'agent' ? agentDefaultPattern(newId, position, nodes.map((n) => n.position)) : null + setNodes((nds) => nds.concat(newNode, ...(pattern ? [pattern.patternNode] : []))) + setEdges((eds) => eds.concat(mainEdge, ...(pattern ? [pattern.patternEdge] : []))) setPendingMainEdgeAdd(null) setAddPanelOpen(false) setSelectedNodeId(newId) return } if (pendingEdgeInsert) { - // Splits the original edge into origin->newAgent->target -- always an - // Agent (AddNodePanel restricted to ['agent'] below), positioned at + // Splits the original edge into origin->new->target -- an Agent or a + // Tool Step (AddNodePanel restricted to MAIN_FLOW_NODE_TYPES below), positioned at // the midpoint of the two nodes the removed edge used to connect. const { edgeId, source, target } = pendingEdgeInsert + // The first half keeps the split edge's handoff: same sender, so the + // narrowed fields still apply. The second half starts at Full output -- + // the new node has no Output Parser to narrow yet. + const handoff = edges.find((e) => e.id === edgeId)?.data?.handoff const sourceNode = nodes.find((n) => n.id === source) const targetNode = nodes.find((n) => n.id === target) const desired = @@ -1597,16 +1630,16 @@ export const ProtocolCanvas = forwardRef n.position), desired) const newId = newNodeId() - const newNode: Node = { id: newId, type: 'agent', position, data: defaultDataFor('agent') } - const { patternNode, patternEdge } = agentDefaultPattern(newId, position, nodes.map((n) => n.position)) - setNodes((nds) => nds.concat(newNode, patternNode)) + const newNode: Node = { id: newId, type: nodeType, position, data: dataOverride ?? defaultDataFor(nodeType) } + const pattern = nodeType === 'agent' ? agentDefaultPattern(newId, position, nodes.map((n) => n.position)) : null + setNodes((nds) => nds.concat(newNode, ...(pattern ? [pattern.patternNode] : []))) setEdges((eds) => eds .filter((e) => e.id !== edgeId) .concat( - { id: newNodeId(), source, target: newId }, + { id: newNodeId(), source, target: newId, ...(handoff ? { data: { handoff } } : {}) }, { id: newNodeId(), source: newId, target }, - patternEdge, + ...(pattern ? [pattern.patternEdge] : []), ), ) setPendingEdgeInsert(null) @@ -1867,6 +1900,17 @@ export const ProtocolCanvas = forwardRef n.id === selectedNodeId) ?? null + // What's wired into a selected Tool Step's Tool connector: its MCP Tool + // node (the callable tools and their server) and any Script node. + const toolStepSources = + selectedNode?.type === 'tool_step' + ? edges + .filter((edge) => edge.target === selectedNode.id && edge.targetHandle === 'tool') + .map((edge) => nodes.find((node) => node.id === edge.source)) + : [] + const toolStepMcpNodes = toolStepSources.filter( + (node): node is typeof node & { data: McpToolNodeData } => !!node && MCP_TOOL_NODE_TYPES.includes(node.type ?? ''), + ) // Computed once per selection change, not per FactorBindableField -- an // Model/Tool/Memory node's plain label alone doesn't say which agent it // belongs to (see bindableFields.ts's own comment), so every inspector @@ -1908,6 +1952,7 @@ export const ProtocolCanvas = forwardRef cancelMutation.mutate()} > @@ -2089,13 +2134,13 @@ export const ProtocolCanvas = forwardRef - {isRunning ? 'Test Run running…' : 'Test Run'} + {testRunIsRunning ? 'Test Run running…' : 'Test Run'} {testRunQuery.data && !testResultsOpen && ( - setTestResultsOpen(true)} refresh={() => { testRunQuery.refetch() }} /> + setTestResultsOpen(true)} refresh={() => { testRunQuery.refetch() }} /> )} - {playResult && !playResultsOpen && ( - setPlayResultsOpen(true)} refresh={() => { runQuery.refetch() }} /> + {runResult && !runResultsOpen && ( + setRunResultsOpen(true)} refresh={() => { runQuery.refetch() }} /> )} - {hasUnpublishedChanges ? ( + {needsPublishChoice ? ( <> - + {!neverPublished && ( + + )} ) : ( diff --git a/frontend/src/components/protocol/RunsTab.tsx b/frontend/src/components/protocol/RunsTab.tsx index 7a7130df..912bf051 100644 --- a/frontend/src/components/protocol/RunsTab.tsx +++ b/frontend/src/components/protocol/RunsTab.tsx @@ -398,20 +398,17 @@ export function RunAllCellsButton({ (pendingReplicateCount === 0 && previouslyRunCells.length === 0) || regenerationRequired || unboundFactors.length > 0 || - bindingDiscrepancies.length > 0 || - !protocol.published_revision_id + bindingDiscrepancies.length > 0 const blockedTitle = regenerationRequired ? 'Design changed — review and regenerate before running the experiment.' : unboundFactors.length > 0 ? `Rebind or remove: ${unboundFactors.join(', ')}.` : bindingDiscrepancies.length > 0 ? 'Canvas values and generated factor levels disagree. Resolve the warning in Runs before starting.' - : !protocol?.published_revision_id - ? 'Publish a valid canvas before running cells.' - : replicates.length === 0 - ? 'Generate design first — there are no cells to run yet.' - : pendingReplicateCount === 0 && previouslyRunCells.length === 0 - ? 'All selected replicates are already running.' + : replicates.length === 0 + ? 'Generate design first — there are no cells to run yet.' + : pendingReplicateCount === 0 && previouslyRunCells.length === 0 + ? 'All selected replicates are already running.' : undefined const runnableReplicateCount = pendingReplicateCount + selectedReruns.size const actionLabel = label ?? (hasCompletedRun ? 'Re-run all cells' : 'Run all cells') @@ -658,15 +655,13 @@ function RunReplicateButton({ runMutation.mutate() }, }) - const runBlocked = !protocol || regenerationRequired || unboundFactors.length > 0 || bindingDiscrepancies.length > 0 || !protocol.published_revision_id + const runBlocked = !protocol || regenerationRequired || unboundFactors.length > 0 || bindingDiscrepancies.length > 0 const blockedTitle = regenerationRequired ? 'Design changed — review and regenerate before running this replicate.' : unboundFactors.length > 0 ? `Rebind or remove: ${unboundFactors.join(', ')}.` : bindingDiscrepancies.length > 0 ? 'Canvas values and generated factor levels disagree. Resolve the warning in Runs before starting.' - : !protocol?.published_revision_id - ? 'Publish a valid canvas before running replicates.' : undefined const errorMessage = runMutation.error instanceof ApiError && typeof runMutation.error.detail === 'string' ? runMutation.error.detail diff --git a/frontend/src/components/protocol/TestRunResults.test.tsx b/frontend/src/components/protocol/TestRunResults.test.tsx index 2f6e9741..63299072 100644 --- a/frontend/src/components/protocol/TestRunResults.test.tsx +++ b/frontend/src/components/protocol/TestRunResults.test.tsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import { render, screen, within } from '@testing-library/react' +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { describe, expect, it, vi } from 'vitest' import type { TestRun } from '@/types/protocols' @@ -61,6 +61,21 @@ describe('TestRunResults', () => { expect(screen.getByText('Analyze this.')).toBeInTheDocument() }) + it('minimizes and restores the agent conversation flow', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByRole('button', { name: 'Minimize Agent conversation flow' })) + expect(screen.getByText('Analyze this.').closest('div')).toHaveClass('hidden') + await user.click(screen.getByRole('button', { name: 'Restore Agent conversation flow' })) + expect(screen.getByText('Analyze this.').closest('div')).not.toHaveClass('hidden') + }) + it('orders executable nodes by the canvas and omits configuration-only nodes', () => { render( { expect(screen.getByLabelText('Test Run Results')).toBeInTheDocument() expect(screen.getByText('Task execution in progress…')).toBeInTheDocument() }) + + it('minimizes, restores, and moves the results window', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByRole('button', { name: 'Minimize Test Run Results' })) + expect(screen.queryByText('Task execution in progress…')).not.toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Restore Test Run Results' })) + expect(screen.getByText('Task execution in progress…')).toBeInTheDocument() + + const panel = screen.getByLabelText('Test Run Results') + const handle = screen.getByLabelText('Move Test Run Results') + fireEvent.pointerDown(handle, { button: 0, pointerId: 1, clientX: 100, clientY: 100 }) + fireEvent.pointerMove(handle, { pointerId: 1, clientX: 140, clientY: 125 }) + fireEvent.pointerUp(handle, { pointerId: 1 }) + expect(panel).toHaveStyle({ transform: 'translate3d(40px, 25px, 0)' }) + }) + + it('keeps the drag header inside the canvas when a top-aligned minimized window is restored', async () => { + const user = userEvent.setup() + render() + const panel = screen.getByLabelText('Test Run Results') + const handle = screen.getByLabelText('Move Test Run Results') + const boundary = document.createElement('div') + Object.defineProperty(panel, 'offsetParent', { configurable: true, get: () => boundary }) + boundary.getBoundingClientRect = () => ({ + x: 0, y: 0, top: 0, left: 0, right: 1000, bottom: 800, width: 1000, height: 800, toJSON: () => ({}), + }) as DOMRect + panel.getBoundingClientRect = () => { + const minimized = screen.queryByRole('button', { name: 'Restore Test Run Results' }) !== null + const match = panel.style.transform.match(/translate3d\((-?\d+)px, (-?\d+)px/) + const offsetX = Number(match?.[1] ?? 0) + const offsetY = Number(match?.[2] ?? 0) + const height = minimized ? 80 : 600 + const top = (minimized ? 720 : 200) + offsetY + const left = 300 + offsetX + return { x: left, y: top, top, left, right: left + 700, bottom: top + height, width: 700, height, toJSON: () => ({}) } as DOMRect + } + + await user.click(screen.getByRole('button', { name: 'Minimize Test Run Results' })) + await waitFor(() => expect(panel).toHaveStyle({ transform: 'translate3d(0px, -520px, 0)' })) + fireEvent.pointerDown(handle, { button: 0, pointerId: 1, clientX: 300, clientY: 200 }) + fireEvent.pointerMove(handle, { pointerId: 1, clientX: 300, clientY: 0 }) + fireEvent.pointerUp(handle, { pointerId: 1 }) + expect(panel).toHaveStyle({ transform: 'translate3d(0px, -720px, 0)' }) + await user.click(screen.getByRole('button', { name: 'Restore Test Run Results' })) + + await waitFor(() => expect(panel).toHaveStyle({ transform: 'translate3d(0px, -200px, 0)' })) + }) + + it('minimizes in place instead of dropping to the bottom anchor', async () => { + const user = userEvent.setup() + render() + const panel = screen.getByLabelText('Test Run Results') + const boundary = document.createElement('div') + Object.defineProperty(panel, 'offsetParent', { configurable: true, get: () => boundary }) + boundary.getBoundingClientRect = () => ({ + x: 0, y: 0, top: 0, left: 0, right: 1000, bottom: 800, width: 1000, height: 800, toJSON: () => ({}), + }) as DOMRect + panel.getBoundingClientRect = () => { + const minimized = screen.queryByRole('button', { name: 'Restore Test Run Results' }) !== null + const match = panel.style.transform.match(/translate3d\((-?\d+)px, (-?\d+)px/) + const offsetX = Number(match?.[1] ?? 0) + const offsetY = Number(match?.[2] ?? 0) + const height = minimized ? 80 : 600 + const top = (minimized ? 720 : 200) + offsetY + const left = 300 + offsetX + return { x: left, y: top, top, left, right: left + 700, bottom: top + height, width: 700, height, toJSON: () => ({}) } as DOMRect + } + + await user.click(screen.getByRole('button', { name: 'Minimize Test Run Results' })) + + await waitFor(() => expect(panel).toHaveStyle({ transform: 'translate3d(0px, -520px, 0)' })) + }) }) diff --git a/frontend/src/components/protocol/TestRunResults.tsx b/frontend/src/components/protocol/TestRunResults.tsx index 923056e7..6be9242b 100644 --- a/frontend/src/components/protocol/TestRunResults.tsx +++ b/frontend/src/components/protocol/TestRunResults.tsx @@ -8,6 +8,7 @@ import { TERMINAL_RUN_STATUSES, nodeRunBadge } from '@/lib/protocolRun' import type { EvaluationArtifact, MetricObservation } from '@/types/experiments' import type { TestRun, TestRunResourceUsage } from '@/types/protocols' import { ConversationTranscript } from './ConversationTranscript' +import { useDraggableOverlay } from './useDraggableOverlay' function outcomeLabel(run: TestRun): string { if (run.status === 'finalizing') return 'Calculating metrics…' @@ -77,7 +78,7 @@ function ObservationCard({ observation, artifacts }: { observation: MetricObserv // `nodeNames` is every canvas node's friendly name by id (lib/nodeNames.ts), // not just the agents': Node progress lists a row per node in the graph, and a // bare uuid there matches nothing the user can point at on the canvas. -const EXECUTABLE_NODE_TYPES = new Set(['agent', 'sub_agent', 'critic_gate']) +const EXECUTABLE_NODE_TYPES = new Set(['agent', 'sub_agent', 'critic_gate', 'tool_step']) export function TestRunResults({ run, @@ -85,14 +86,20 @@ export function TestRunResults({ nodeNames = new Map(), nodeTypes = new Map(), title = 'Test Run Results', + kind = 'Test Run', }: { run: TestRun onClose: () => void nodeNames?: Map nodeTypes?: Map title?: string + // What kind of run this is (Test Run, node Play, cell run...), shown in the + // header so two open panels can't be mistaken for the same run. + kind?: string }) { const [nodeProgressCollapsed, setNodeProgressCollapsed] = useState(false) + const [collapsed, setCollapsed] = useState(false) + const drag = useDraggableOverlay({ recomputeKey: collapsed }) const running = !TERMINAL_RUN_STATUSES.has(run.status) const canvasOrder = new Map(Array.from(nodeTypes.keys(), (nodeId, index) => [nodeId, index])) const nodeRuns = Object.entries(run.execution_summary.node_runs) @@ -106,25 +113,52 @@ export function TestRunResults({ return leftOrder - rightOrder || (nodeNames.get(leftId) ?? leftId).localeCompare(nodeNames.get(rightId) ?? rightId) }) const nodeRunGroups = [ - { label: 'Agents', entries: nodeRuns.filter(([nodeId]) => nodeTypes.get(nodeId) !== 'critic_gate') }, + { + label: 'Agents', + entries: nodeRuns.filter(([nodeId]) => !['critic_gate', 'tool_step'].includes(nodeTypes.get(nodeId) ?? '')), + }, + { label: 'Tool steps', entries: nodeRuns.filter(([nodeId]) => nodeTypes.get(nodeId) === 'tool_step') }, { label: 'Critic gates', entries: nodeRuns.filter(([nodeId]) => nodeTypes.get(nodeId) === 'critic_gate') }, ].filter((group) => group.entries.length > 0) - const timestamp = new Date(run.created_at) return ( - - + + {title} {run.freshness.out_of_date && Out of date} {outcomeLabel(run)} - {run.tested_published_revision && <> · Published revision {run.tested_published_revision.number} · {Number.isNaN(timestamp.valueOf()) ? run.created_at : timestamp.toLocaleString()}} + · {kind} · {shortRunId(run.id)} + {run.tested_published_revision && <> · Published revision {run.tested_published_revision.number}} + {' · '}{formatRunTime(run.created_at)} - + +
+ + +
+
- + {!collapsed && {run.freshness.out_of_date && (

Out of date

{staleReason(run.freshness.reasons)}

@@ -176,11 +210,24 @@ export function TestRunResults({ {run.artifacts.length > 0 &&
Evaluation artifacts
{run.artifacts.map((artifact) =>

{artifact.artifact_key} · producer {artifact.producer.binding_id}

{JSON.stringify(artifact.payload, null, 2)}
)}
}

Resources

Unknown cost means the provider did not report pricing; it is never treated as zero.

- + } ) } -export function ReopenTestRunResultsButton({ onOpen, refresh, label = 'Test Run Results' }: { onOpen: () => void; refresh: () => void; label?: string }) { - return +const shortRunId = (id: string) => id.slice(0, 8) + +function formatRunTime(createdAt: string) { + const timestamp = new Date(createdAt) + return Number.isNaN(timestamp.valueOf()) ? createdAt : timestamp.toLocaleString() +} + +// One line naming a run -- kind, short id, start time -- shared by the reopen +// button's tooltip and the panel header so both identify the run identically. +export function describeRun(kind: string, run: { id: string; created_at: string }) { + return `${kind} · ${shortRunId(run.id)} · ${formatRunTime(run.created_at)}` +} + +export function ReopenTestRunResultsButton({ onOpen, refresh, label = 'Test Run Results', detail }: { onOpen: () => void; refresh: () => void; label?: string; detail?: string }) { + return } diff --git a/frontend/src/components/protocol/ToolStepNodeInspector.tsx b/frontend/src/components/protocol/ToolStepNodeInspector.tsx new file mode 100644 index 00000000..ebd00eca --- /dev/null +++ b/frontend/src/components/protocol/ToolStepNodeInspector.tsx @@ -0,0 +1,373 @@ +import { useRef, useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { Cog, Plus, X } from 'lucide-react' +import { mcpServersApi } from '@/api/client' +import { nodeAccent } from '@/lib/nodeAccent' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Textarea } from '@/components/ui/textarea' +import { EditableNodeTitle } from './EditableNodeTitle' +import { NodeInspectorDialog } from './NodeInspectorDialog' +import { NodeRunOutputPanel } from './NodeRunOutputPanel' +import { RESIZE_HANDLE_CLASSNAME, useResizablePane } from './useResizablePane' +import type { + NodeRunState, + ProtocolNode, + ToolStepArgumentSource, + ToolStepNodeConfig, + ToolStepNodeData, +} from '@/types/protocols' + +const ACCENT = nodeAccent('tool_step') +const MIN_CONFIG_WIDTH = 380 +const RESIZE_GUTTER_WIDTH = 16 + +type SourceKey = 'unset' | 'value' | 'payload_json' | 'payload_object' | 'script_code' | 'workspace_id' + +const SOURCE_LABELS: Record = { + unset: 'Not sent', + value: 'Fixed value', + payload_json: 'Upstream payload (JSON text)', + payload_object: 'Upstream payload (object)', + script_code: "Wired Script's code", + workspace_id: 'Workspace ID', +} + +interface SchemaProperty { + type?: string + description?: string +} + +function sourceKey(argument: ToolStepArgumentSource | undefined): SourceKey { + if (!argument) return 'unset' + if (argument.source === 'upstream_payload') return argument.format === 'object' ? 'payload_object' : 'payload_json' + return argument.source +} + +function argumentFor(key: SourceKey, schemaType: string | undefined): ToolStepArgumentSource | undefined { + switch (key) { + case 'unset': + return undefined + case 'value': + return { source: 'value', value: schemaType === 'string' ? '' : null } + case 'payload_json': + return { source: 'upstream_payload', format: 'json_string' } + case 'payload_object': + return { source: 'upstream_payload', format: 'object' } + default: + return { source: key } + } +} + +function valueText(value: unknown): string { + if (value === null || value === undefined) return '' + return typeof value === 'string' ? value : JSON.stringify(value) +} + +// A string-typed parameter keeps exactly what was typed; anything else is read +// as JSON when it parses (so `7` is a number, `true` a boolean), else as text. +function parseValue(text: string, schemaType: string | undefined): unknown { + if (schemaType === 'string') return text + if (text.trim() === '') return null + try { + return JSON.parse(text) + } catch { + return text + } +} + +function schemaProperties(inputSchema: unknown): Record | null { + if (!inputSchema || typeof inputSchema !== 'object') return null + const properties = (inputSchema as { properties?: unknown }).properties + return properties && typeof properties === 'object' ? (properties as Record) : null +} + +// Same NodeInspectorDialog shell as CriticGateNodeInspector. `toolOptions` is +// the wired MCP Tool node's own enabled tools: a Tool Step may only call one +// its MCP node allow-lists (services/tool_steps.py's validate_tool_step), so +// the picker offers exactly those rather than a free-text name. The argument +// rows come from the selected tool's own input schema, as its server +// published it -- nothing here knows any particular tool's parameters. +export function ToolStepNodeInspector({ + node, + toolOptions, + serverId, + hasScript, + nodeRun, + onChange, + onDelete, + onClose, +}: { + node: (ProtocolNode & { data: ToolStepNodeData }) | null + toolOptions: string[] + serverId: string | null + hasScript: boolean + nodeRun?: NodeRunState + onChange: (nodeId: string, data: ToolStepNodeData) => void + onDelete: (nodeId: string) => void + onClose: () => void +}) { + const columnsRef = useRef(null) + const outputPane = useResizablePane({ + storageKey: 'asaree:tool-step-output-pane-width', + defaultWidth: 384, + minWidth: 280, + maxWidth: 760, + side: 'right', + resolveMaxWidth: () => + (columnsRef.current?.clientWidth ?? Number.POSITIVE_INFINITY) - MIN_CONFIG_WIDTH - RESIZE_GUTTER_WIDTH, + recomputeKey: node?.id ?? '', + }) + const serversQuery = useQuery({ + queryKey: ['mcp-servers'], + queryFn: mcpServersApi.list, + staleTime: 60_000, + enabled: !!serverId, + }) + const [newArgument, setNewArgument] = useState('') + if (!node) return null + const data = node.data + const config = data.config + const mapped = config.arguments ?? {} + const hashChecks = config.hash_checks ?? {} + const options = config.tool_name && !toolOptions.includes(config.tool_name) ? [config.tool_name, ...toolOptions] : toolOptions + const tool = serversQuery.data + ?.find((server) => server.id === serverId) + ?.capabilities?.tools?.find((candidate) => candidate.name === config.tool_name) + const properties = schemaProperties(tool?.input_schema) + // Schema parameters first, in the server's order, then anything mapped that + // the schema doesn't list (or all of them when no schema is available). + const argumentNames = [ + ...Object.keys(properties ?? {}), + ...Object.keys(mapped).filter((name) => !properties || !(name in properties)), + ] + + function patchConfig(patch: Partial) { + onChange(node!.id, { ...data, config: { ...config, ...patch } }) + } + + function setArgument(name: string, argument: ToolStepArgumentSource | undefined) { + const next = { ...mapped } + if (argument) next[name] = argument + else delete next[name] + // A hash check on an argument that's no longer sent could never pass. + const checks = Object.fromEntries(Object.entries(hashChecks).filter(([, arg]) => arg in next)) + patchConfig({ arguments: next, hash_checks: checks }) + } + + function setHashCheck(oldField: string, field: string, argument: string) { + const entries = Object.entries(hashChecks).map(([f, a]) => (f === oldField ? [field, argument] : [f, a])) + patchConfig({ hash_checks: Object.fromEntries(entries) }) + } + + return ( + { + if (!open) onClose() + }} + accent={ACCENT} + title={ + <> + + onChange(node.id, { ...data, label })} /> + + } + onDelete={() => onDelete(node.id)} + onClose={onClose} + > +
+
+

+ Calls one MCP tool directly -- no model decides whether or how to call it. Choose where each argument's + value comes from; an argument that isn't mapped isn't sent. +

+ +
+ + +
+ + {config.tool_name && ( +
+ + {!properties && ( +

+ {serversQuery.isLoading + ? 'Loading the tool’s parameters…' + : 'This tool’s parameters aren’t available -- add arguments by name.'} +

+ )} + {argumentNames.map((name) => { + const property = properties?.[name] + const argument = mapped[name] + const key = sourceKey(argument) + const text = argument?.source === 'value' ? valueText(argument.value) : '' + return ( +
+
+ + {name} + {property?.type && : {property.type}} + + +
+ {key === 'value' && + (text.length > 60 || text.includes('\n') ? ( +