Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,9 @@ jobs:
- name: Migrate ASAREE schema
run: uv run python -m asaree.migrations upgrade --url "$ASAREE_PRODUCT_DATABASE_URL"

- name: Seed row-test users
run: uv run python scripts/seed_row_test_users.py

# The whole suite, on purpose: 769 tests in ~13s locally, so there is no
# slow tier worth splitting out.
- name: Pytest
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,4 @@ sdk/src/asaree_client/_version.py
# Private local planning and agent guidance
docs/
issues/
tasks/
13 changes: 10 additions & 3 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ factor-combination cell and compares the recorded outcomes.
A factorial study comparing agent protocol conditions. Owns the design declaration, the protocol canvas, and all runs.
_Avoid_: Project, study, trial

**Experiment version**:
A published snapshot of an experiment's canvas, design settings, and measurement configuration. Runs and results belong to the version that produced them; subsequent edits form the experiment's draft.
_Avoid_: Canvas version, independent design/publication versions

**Factor**:
A declared experimental variable with discrete levels, bound to at least one canvas field before cells can run.
_Avoid_: Parameter, variable
Expand All @@ -21,7 +25,10 @@ One unique factor combination together with all its planned replicates.
_Avoid_: Condition, treatment (accepted in prose, never in code)

**Replicate**:
One run of one cell. Counts of pending/run/scored are replicate counts, never cell counts.
One execution unit planned under a cell. A row-mode parent Replicate owns one Row execution per original row; counts of pending/run/scored are replicate counts, never cell or row counts. Rows are neither factors nor extra stochastic replicates.

**Row execution**:
One complete protocol execution for a (cell, original dataset row, replicate) tuple.

**Primary metric**:
The optional declared metric Results uses to rank cells. An editable experiment may have none; when declared, it must be unique.
Expand Down Expand Up @@ -105,12 +112,12 @@ The draft editable graph. Production runs never read it directly.
_Avoid_: Workflow, pipeline

**Published revision**:
The immutable protocol snapshot a run executes; created by publishing the canvas.
The immutable published snapshot a run executes; for an experiment it includes the canvas and experiment settings together.
_Avoid_: Protocol version

**Gated pair**:
An agent node with its critic gate. `approved` and `revisions_used` are recorded on the worker's node-run.
_Avoid_: Reviewer, supervisor

**Attempt**:
One ProtocolRun for a replicate; later attempts supersede earlier ones but every attempt's own facts stay immutable.
One ProtocolRun under either a whole-dataset Replicate or a stable row slot; later attempts supersede earlier ones but every attempt's own facts stay immutable. A run may also retain a nullable dataset-row snapshot when no row slot exists, as for previews.
51 changes: 39 additions & 12 deletions frontend/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import type {
import type { LLMConnectionCheck, LLMProvider, LLMSetting, LLMSettingModelsResponse } from '@/types/llmSettings'
import type { McpOAuthAuthorization, McpServer } from '@/types/mcpServers'
import type { OkfBundle, OkfDocument } from '@/types/okf'
import type { CellRunBatch, PromptPreview, Protocol, ProtocolGraph, ProtocolRevision, ProtocolRun, TestRun } from '@/types/protocols'
import type { CellRunBatch, DatasetRowSchema, PromptPreview, Protocol, ProtocolGraph, ProtocolRevision, ProtocolRun, TestRun } from '@/types/protocols'
import type { Run, RunStep } from '@/types/runs'
import type { Skill, SkillListResponse, SkillUrlPreview } from '@/types/skills'

Expand Down Expand Up @@ -181,6 +181,28 @@ export const tokenApi = {
revoke: (id: string) => request<void>(`/auth/me/tokens/${id}`, { method: 'DELETE' }),
}

export interface ResultsScope {
protocol_id?: string
design_revision_id?: string
protocol_revision_id?: string
}

export interface RowSelection { row_index?: number | null }

function scopeQuery(scope?: ResultsScope): string {
const params = new URLSearchParams()
for (const [key, value] of Object.entries(scope ?? {})) if (value !== undefined) params.set(key, value)
return params.size ? `?${params}` : ''
}

function rowSelection(options?: RowSelection): RowSelection | undefined {
const index = options?.row_index
if (index !== undefined && index !== null && (!Number.isInteger(index) || index < 0)) {
throw new RangeError('Source row must be a nonnegative integer')
}
return options
}

export const experimentsApi = {
list: (opts?: { includeArchived?: boolean }) =>
request<Experiment[]>(opts?.includeArchived ? '/experiments?include_archived=true' : '/experiments'),
Expand Down Expand Up @@ -255,7 +277,8 @@ export const experimentsApi = {
// anywhere in the experiment (see services.csv_export.replicates_to_csv) --
// a Blob, not JSON, so callers hand it straight to URL.createObjectURL.
downloadReplicatesCsv: (id: string) => requestBlob(`/experiments/${id}/replicates.csv`),
downloadRunResultsCsv: (id: string) => requestBlob(`/experiments/${id}/run-results.csv`),
downloadRunResultsCsv: (id: string, scope?: ResultsScope) => requestBlob(`/experiments/${id}/run-results.csv${scopeQuery(scope)}`),
getRunResultsSchema: (id: string, scope?: ResultsScope) => request<Record<string, unknown>>(`/experiments/${id}/run-results.schema.json${scopeQuery(scope)}`),
// Materializes one cell per combination and its replicate-result children.
// declared factors, returning the current design's replicates. If the new design
// isn't the same set of cells as the current one, the current design
Expand All @@ -275,7 +298,7 @@ export const experimentsApi = {
getResults: (id: string) => request<ExperimentResults>(`/experiments/${id}/results`),
// A current-design scorecard and the per-cell/per-replicate evidence behind
// it. Unlike getResults(), no balanced-factorial assumptions are required.
getRunResults: (id: string) => request<ExperimentRunResults>(`/experiments/${id}/run-results`),
getRunResults: (id: string, scope?: ResultsScope) => request<ExperimentRunResults>(`/experiments/${id}/run-results${scopeQuery(scope)}`),
}

export const protocolsApi = {
Expand All @@ -286,27 +309,29 @@ export const protocolsApi = {
request<Protocol[]>(experimentId ? `/protocols?experiment_id=${experimentId}` : '/protocols'),
update: (id: string, data: { name?: string; description?: string | null; graph?: ProtocolGraph }) =>
request<Protocol>(`/protocols/${id}`, { method: 'PATCH', body: data }),
publish: (id: string) => request<Protocol>(`/protocols/${id}/publish`, { method: 'POST' }),
publish: (id: string, annotations?: { name?: string | null; note?: string | null }) => request<Protocol>(`/protocols/${id}/publish`, { method: 'POST', body: annotations }),
remove: (id: string) => request<void>(`/protocols/${id}`, { method: 'DELETE' }),
// 422 if the graph is empty or has a cycle -- returns immediately with
// status "pending"; poll getRun for progress. cellLabel runs that one
// already-generated cell for real (its own factor_values substituted in)
// instead of today's ad-hoc, un-substituted whole-graph run.
run: (id: string, cellLabel?: string | null) =>
request<ProtocolRun>(`/protocols/${id}/runs`, { method: 'POST', body: { replicate_label: cellLabel ?? null } }),
testRun: (id: string) => request<TestRun>(`/protocols/${id}/test-runs`, { method: 'POST' }),
run: (id: string, cellLabel?: string | null, options?: RowSelection) =>
request<ProtocolRun>(`/protocols/${id}/runs`, { method: 'POST', body: { replicate_label: cellLabel ?? null, ...rowSelection(options) } }),
testRun: (id: string, options?: RowSelection) => request<TestRun>(`/protocols/${id}/test-runs`, { method: 'POST', body: rowSelection(options) }),
getLatestTestRun: (id: string) => request<TestRun>(`/protocols/${id}/test-runs/latest`),
// The per-node Play icon -- 422 if the node has upstream input or isn't a
// runnable Agent (see validate_single_node_runnable). Same polling shape
// as a plain run (getRun), just with node_runs carrying only this one key.
runNode: (id: string, nodeId: string) => request<ProtocolRun>(`/protocols/${id}/nodes/${nodeId}/run`, { method: 'POST' }),
runNode: (id: string, nodeId: string, options?: RowSelection) => request<ProtocolRun>(`/protocols/${id}/nodes/${nodeId}/run`, { method: 'POST', body: rowSelection(options) }),
// Read-only despite the POST: the graph goes in the body because the canvas
// being previewed is the one on screen, including edits autosave hasn't
// flushed yet. Creates no run of any kind. 422 for a node that isn't an
// agent, since only an agent is ever given a prompt.
promptPreview: (id: string, nodeId: string, graph: ProtocolGraph) =>
request<PromptPreview>(`/protocols/${id}/nodes/${nodeId}/prompt-preview`, { method: 'POST', body: { graph } }),
promptPreview: (id: string, nodeId: string, graph: ProtocolGraph, options?: RowSelection) =>
request<PromptPreview>(`/protocols/${id}/nodes/${nodeId}/prompt-preview`, { method: 'POST', body: { graph, ...rowSelection(options) } }),
listRevisions: (id: string) => request<ProtocolRevision[]>(`/protocols/${id}/revisions`),
getRevision: (id: string, revisionId: string) => request<ProtocolRevision>(`/protocols/${id}/revisions/${revisionId}`),
updateRevision: (id: string, revisionId: string, annotations: { name?: string | null; note?: string | null }) => request<ProtocolRevision>(`/protocols/${id}/revisions/${revisionId}`, { method: 'PATCH', body: annotations }),
getRun: (id: string, runId: string) => request<ProtocolRun>(`/protocols/${id}/runs/${runId}`),
// Queued work is cancelled immediately. Active work raises
// cancel_requested_at, which its executor honors at a safe interruption
Expand All @@ -316,19 +341,21 @@ export const protocolsApi = {
// "Run all cells" -- 422 if there's no linked experiment or the graph
// doesn't have exactly one final node; fans out one ProtocolRun per pending
// replicate. The optional list explicitly re-runs selected completed rows.
runCells: (id: string, options?: { replicateLabels?: string[]; rerunReplicateLabels?: string[] }) =>
runCells: (id: string, options?: { replicateLabels?: string[]; rerunReplicateLabels?: string[]; retry_row_result_ids?: string[] }) =>
request<CellRunBatch>(`/protocols/${id}/cell-runs`, {
method: 'POST',
body: options
? {
replicate_labels: options.replicateLabels,
rerun_replicate_labels: options.rerunReplicateLabels ?? [],
rerun_replicate_labels: options.retry_row_result_ids ? undefined : options.rerunReplicateLabels ?? [],
retry_row_result_ids: options.retry_row_result_ids,
}
: undefined,
}),
}

export const datasetsApi = {
getRowSchema: (id: string) => request<DatasetRowSchema>(`/datasets/${id}/row-schema`),
// Owner-scoped, same convention as mcpServersApi.list -- backs the canvas's
// dataset browser (DatasetBrowserPanel) and the Dataset node inspector's
// read-out of whichever dataset the node is bound to.
Expand Down
56 changes: 56 additions & 0 deletions frontend/src/api/datasetRows.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { datasetsApi, experimentsApi, protocolsApi } from './client'

const fetchMock = vi.fn()
beforeEach(() => {
localStorage.clear()
vi.stubGlobal('fetch', fetchMock)
fetchMock.mockImplementation(async () => new Response(JSON.stringify({ dataset_row: null, row_result_id: null }), { status: 200 }))
})
afterEach(() => { vi.unstubAllGlobals(); vi.clearAllMocks() })
const lastBody = () => {
const body = fetchMock.mock.calls.at(-1)?.[1]?.body
return body === undefined ? undefined : JSON.parse(body)
}
describe('row HTTP contracts', () => {
it('retains omitted legacy bodies and explicit null', async () => {
await protocolsApi.testRun('p')
expect(lastBody()).toBeUndefined()
await protocolsApi.runNode('p', 'a')
expect(lastBody()).toBeUndefined()
await protocolsApi.testRun('p', { row_index: null })
expect(lastBody()).toEqual({ row_index: null })
})
it('sends zero-based selections on each execution interface', async () => {
await protocolsApi.testRun('p', { row_index: 2 })
expect(lastBody()).toEqual({ row_index: 2 })
await protocolsApi.runNode('p', 'a', { row_index: 2 })
expect(lastBody()).toEqual({ row_index: 2 })
await protocolsApi.run('p', null, { row_index: 2 })
expect(lastBody()).toEqual({ replicate_label: null, row_index: 2 })
const graph = { nodes: [], edges: [] }
await protocolsApi.promptPreview('p', 'a', graph, { row_index: 2 })
expect(lastBody()).toEqual({ graph, row_index: 2 })
expect(() => protocolsApi.testRun('p', { row_index: -1 })).toThrow(RangeError)
})
it('retries exactly one row without a replicate rerun payload', async () => {
await protocolsApi.runCells('p', { retry_row_result_ids: ['slot'] })
expect(lastBody()).toEqual({ retry_row_result_ids: ['slot'] })
})
it('uses identical encoded scope on projection and exports', async () => {
const scope = { protocol_id: 'p /', design_revision_id: 'd', protocol_revision_id: 'r' }
await experimentsApi.getRunResults('e', scope)
const query = fetchMock.mock.calls.at(-1)?.[0].split('?')[1]
await experimentsApi.downloadRunResultsCsv('e', scope)
expect(fetchMock.mock.calls.at(-1)?.[0].split('?')[1]).toBe(query)
await experimentsApi.getRunResultsSchema('e', scope)
expect(fetchMock.mock.calls.at(-1)?.[0].split('?')[1]).toBe(query)
})
it('fetches row metadata and preserves returned identities', async () => {
const schema = { dataset_id: 'id', raw_sha256: 'hash', columns: ['question'], row_count: 3 }
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify(schema)))
expect(await datasetsApi.getRowSchema('id')).toEqual(schema)
expect(fetchMock.mock.calls.at(-1)?.[0]).toContain('/datasets/id/row-schema')
expect(await protocolsApi.testRun('p')).toMatchObject({ dataset_row: null, row_result_id: null })
})
})
22 changes: 22 additions & 0 deletions frontend/src/api/versionAnnotations.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { afterEach, expect, it, vi } from 'vitest'
import { protocolsApi } from './client'

afterEach(() => vi.unstubAllGlobals())

it.each(['publish', 'updateRevision'] as const)('sends %s annotations as a JSON object accepted by the API', async operation => {
const annotations = { name: 'Baseline', note: 'Keep for comparison' }
const fetch = vi.fn(async (_url: string, options: RequestInit) => {
const body: unknown = JSON.parse(options.body as string)
// FastAPI's VersionAnnotationsRequest rejects a JSON string with 422.
if (typeof body !== 'object' || body === null) {
return new Response(JSON.stringify({ detail: [{ msg: 'Input should be a valid dictionary' }] }), { status: 422 })
}
return new Response(JSON.stringify({ id: 'p', ...body }), { status: 200 })
})
vi.stubGlobal('fetch', fetch)
const result = operation === 'publish'
? protocolsApi.publish('p', annotations)
: protocolsApi.updateRevision('p', 'v1', annotations)
await expect(result).resolves.toMatchObject({ id: 'p', ...annotations })
expect(JSON.parse(fetch.mock.calls[0][1].body as string)).toEqual(annotations)
})
10 changes: 7 additions & 3 deletions frontend/src/components/protocol/AgentNodeInspector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export function AgentNodeInspector({
handoffPeers,
wiredOutputParserLabel,
fetchPromptPreview,
previewGraph,
nodeRun,
onChange,
onDelete,
Expand All @@ -90,7 +91,8 @@ export function AgentNodeInspector({
// Assembles the real prompt server-side against the live canvas. A callback
// rather than an id pair because the graph it posts is the unsaved one on
// screen, which only ProtocolCanvas holds.
fetchPromptPreview: (nodeId: string) => Promise<PromptPreview>
previewGraph?: import('@/types/protocols').ProtocolGraph
fetchPromptPreview: (nodeId: string, rowIndex?: number) => Promise<PromptPreview>
nodeRun?: NodeRunState
onChange: (nodeId: string, data: AgentNodeData) => void
onDelete: (nodeId: string) => void
Expand Down Expand Up @@ -228,8 +230,10 @@ export function AgentNodeInspector({
// The panel holds the last text it assembled; on a node switch
// that text describes the previous node, so it starts over.
key={node.id}
signature={JSON.stringify(data)}
fetchPreview={() => fetchPromptPreview(node.id)}
signature={JSON.stringify({ data, graph: previewGraph })}
graph={previewGraph}
nodeId={node.id}
fetchPreview={rowIndex => fetchPromptPreview(node.id, rowIndex)}
/>
{/* Below the design-time preview, because it supersedes it: a
placeholder proves nothing about a run that actually happened. */}
Expand Down
Loading
Loading