diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2f1fbb4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,150 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +concurrency: + group: ci-${{ github.event.pull_request.number || github.sha }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + backend: + name: Lint & Test (backend) + runs-on: ubuntu-latest + + # The suite runs against a real Postgres, not sqlite or mocks: this + # codebase stores JSONB and never mocks the database (see the docstring + # on tests/test_protocols.py). pgvector's image rather than plain + # postgres because core's own schema declares a vector column. + services: + postgres: + image: pgvector/pgvector:pg16 + env: + POSTGRES_USER: agentic + POSTGRES_PASSWORD: agentic + POSTGRES_DB: motoro + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U agentic" + --health-interval 5s + --health-timeout 5s + --health-retries 5 + + # Two databases on one server, matching the split in config.py: + # `ASAREE_DATABASE_URL` is core's schema, `ASAREE_PRODUCT_DATABASE_URL` + # is ASAREE's own tables. Bare `DATABASE_URL` is here for the sake of + # `python -m motoro.migrations`, which reads settings with no product + # prefix. Set as real env vars because CI has no .env to fall back on, + # and subprocess-spawned MCP servers inherit them. + env: + DATABASE_URL: postgresql+asyncpg://agentic:agentic@localhost:5432/motoro + ASAREE_DATABASE_URL: postgresql+asyncpg://agentic:agentic@localhost:5432/motoro + ASAREE_PRODUCT_DATABASE_URL: postgresql+asyncpg://agentic:agentic@localhost:5432/asaree + + steps: + - uses: actions/checkout@v6 + + # No setup-python step on purpose: `requires-python = ">=3.12"` plus uv's + # own managed interpreters already decide the version, and pinning one + # here would only let CI drift from what `uv sync` gives a developer + # locally. + - uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + + # Cheap, and it guards the exact class of bug this workflow was added + # after: a `tool.uv.sources` pin edited without re-locking, so the + # lockfile still resolves the old commit. + - name: Check lockfile is up to date + run: uv lock --check + + # The slow step by a wide margin -- motoro depends unconditionally on + # sentence-transformers, which pulls ~4.5 GB of torch + CUDA that + # nothing in ASAREE imports (EpistasisLab/motoro#6). The uv cache above + # absorbs it on warm runs; when that issue lands, this drops to well + # under a minute cold. + - name: Install dependencies + run: uv sync --frozen --group dev + + - name: Ruff check + run: uv run ruff check src/asaree tests + + # Deliberately absent: `ruff format --check`. This repo is knowingly not + # ruff-format-clean and reformatting it is not wanted, so the step could + # only ever be permanently-yellow noise. `ruff check` above is the part + # that finds real defects. + + # Non-blocking until the pre-existing strict-mode backlog is paid down + # (same reasoning as motoro's own CI). Flip to blocking once clean. + - name: Mypy (warn only) + continue-on-error: true + run: uv run mypy src/asaree + + # The service container only creates POSTGRES_DB; the second database + # has to be made by hand. Done with asyncpg rather than psql so the job + # depends only on the locked dependencies, not on whatever client + # tooling the runner image happens to ship. + - name: Create ASAREE's product database + run: | + uv run python - <<'PY' + import asyncio, asyncpg + + async def main() -> None: + conn = await asyncpg.connect("postgresql://agentic:agentic@localhost:5432/motoro") + try: + await conn.execute("CREATE DATABASE asaree") + finally: + await conn.close() + + asyncio.run(main()) + PY + + # Core's chain first, then ASAREE's -- the order `asaree.migrations` + # documents. `deploy` rather than `upgrade` so the pattern registry is + # projected into architectural_patterns too; the schema alone is not + # enough for tests that resolve a coordination strategy. + - name: Migrate core schema + run: uv run python -m motoro.migrations deploy --url "$DATABASE_URL" + + - name: Migrate ASAREE schema + run: uv run python -m asaree.migrations upgrade --url "$ASAREE_PRODUCT_DATABASE_URL" + + # The whole suite, on purpose: 769 tests in ~13s locally, so there is no + # slow tier worth splitting out. + - name: Pytest + run: uv run pytest tests/ -q --tb=short + + frontend: + name: Type Check & Lint (frontend) + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + run: npm ci + + # `tsc -b`, not `tsc --noEmit`: this is a project-references build + # (tsconfig.json -> tsconfig.app.json + tsconfig.node.json), and + # `--noEmit` against the solution file type-checks nothing at all. + - name: Type check + run: npx tsc -b + + # oxlint exits 0 on warnings, so this reports rather than gates. The + # standing baseline is 9 warnings; it is here to make that number + # visible and stop it climbing unnoticed. + - name: Lint + run: npx oxlint src diff --git a/.gitignore b/.gitignore index df61bd5..b79d76c 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,3 @@ backups/ # Generated by hatch-vcs at build time from the git tag (see pyproject.toml). src/asaree/_version.py sdk/src/asaree_client/_version.py -prompts/ diff --git a/compose.dev.yml b/compose.dev.yml new file mode 100644 index 0000000..c52b0b3 --- /dev/null +++ b/compose.dev.yml @@ -0,0 +1,54 @@ +# Run the stack against the working trees instead of the built image. +# +# For testing a change to ASAREE *and* Motoro together before Motoro is tagged: +# pyproject.toml pins motoro to a released git tag on purpose (never a floating +# branch), so the only way to exercise an untagged Motoro is to put its source +# where the installed package sits. That is exactly what this file does -- no +# image rebuild, no throwaway tag, and nothing about the pin changes. +# +# Explicitly opt in, so it can never be active without being asked for: +# +# docker compose -f compose.yml -f compose.dev.yml up -d --force-recreate \ +# asaree-migrate asaree-app asaree-worker +# +# and drop back to the released pin by leaving the -f off and recreating. +# +# The frontend needs nothing here: compose.yml already bind-mounts ./frontend +# for hot reload, so the UI is live off the working tree either way. + +services: + # Included because a source change can bring a new Alembic revision with it, + # and this one-shot runs from the image like everything else. + asaree-migrate: + volumes: + - ./src:/app/src + - ${MOTORO_SRC:-../Motoro/src/motoro}:/app/.venv/lib/python3.13/site-packages/motoro:ro + - ./workspace-core/src/asaree_workspace_core:/app/workspace-core/src/asaree_workspace_core:ro + + asaree-app: + volumes: + - ./data:/app/data + - ./src:/app/src + # Read-only: the container runs as root, so a writable mount would leave + # root-owned __pycache__ directories scattered through the host checkout. + # Python simply skips writing bytecode when it can't. + # + # This is a plain directory overlay rather than an editable install -- + # safe only while Motoro's own dependencies are unchanged, since uv never + # sees it. If Motoro gains a dependency, rebuild against a tag instead. + # The bundled MCP servers already spawn with `uv run --no-sync`, so + # nothing at runtime tries to reconcile the venv behind this mount. + - ${MOTORO_SRC:-../Motoro/src/motoro}:/app/.venv/lib/python3.13/site-packages/motoro:ro + # Same idea for the workspace package. Unlike `src`, it is COPYed into + # the image and installed editable *inside* it, so a new module there (a + # stage plan, say) is invisible until the image is rebuilt. Overlaying the + # package directory rather than the project keeps the editable install's + # own recorded path valid. + - ./workspace-core/src/asaree_workspace_core:/app/workspace-core/src/asaree_workspace_core:ro + + asaree-worker: + volumes: + - ./data:/app/data + - ./src:/app/src + - ${MOTORO_SRC:-../Motoro/src/motoro}:/app/.venv/lib/python3.13/site-packages/motoro:ro + - ./workspace-core/src/asaree_workspace_core:/app/workspace-core/src/asaree_workspace_core:ro diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index ef459ec..2cbf239 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -16,7 +16,7 @@ import type { DesignImpact, DesignRevision, DesignSpec, Experiment, ExperimentRe import type { LLMConnectionCheck, LLMProvider, LLMSetting, LLMSettingModelsResponse } from '@/types/llmSettings' import type { McpServer } from '@/types/mcpServers' import type { OkfBundle, OkfDocument } from '@/types/okf' -import type { CellRunBatch, Protocol, ProtocolGraph, ProtocolRevision, ProtocolRun } from '@/types/protocols' +import type { CellRunBatch, PromptPreview, Protocol, ProtocolGraph, ProtocolRevision, ProtocolRun } from '@/types/protocols' import type { Run, RunStep } from '@/types/runs' import type { Skill, SkillListResponse, SkillUrlPreview } from '@/types/skills' @@ -284,6 +284,12 @@ export const protocolsApi = { // 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(`/protocols/${id}/nodes/${nodeId}/run`, { method: 'POST' }), + // 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(`/protocols/${id}/nodes/${nodeId}/prompt-preview`, { method: 'POST', body: { graph } }), getRevision: (id: string, revisionId: string) => request(`/protocols/${id}/revisions/${revisionId}`), getRun: (id: string, runId: string) => request(`/protocols/${id}/runs/${runId}`), // Only raises cancel_requested_at -- a no-op (200, unchanged row) once the @@ -363,6 +369,10 @@ export const runsApi = { // No server-side experiment_id filter exists yet (runs.py only filters by // agent_id) -- callers filter client-side on run_metadata.experiment_id. list: () => request('/runs'), + // Owner-scoped, same as the list. Fetched per node run for `input` -- the + // exact assembled prompt that agent was given, which is the one piece of + // handoff evidence that isn't already on the polled node_runs blob. + get: (runId: string) => request(`/runs/${runId}`), getSteps: (runId: string) => request(`/runs/${runId}/steps`), } diff --git a/frontend/src/components/protocol/AddNodePanel.tsx b/frontend/src/components/protocol/AddNodePanel.tsx index 8d6cff5..1b7423c 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, 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, 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' @@ -123,6 +123,12 @@ const NODE_CATALOG = [ description: 'Upload a single Markdown concept an Agent reads and rewrites as it works', icon: FileText, }, + { + type: 'output_parser', + label: 'Output Parser', + description: "Defines the format an Agent's answer must take, and reads its named, typed fields back out", + icon: Braces, + }, { type: 'script', label: 'Script', diff --git a/frontend/src/components/protocol/AgentNodeInspector.tsx b/frontend/src/components/protocol/AgentNodeInspector.tsx index ab3e9b5..4c29846 100644 --- a/frontend/src/components/protocol/AgentNodeInspector.tsx +++ b/frontend/src/components/protocol/AgentNodeInspector.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react' +import { useRef } from 'react' import { Bot } from 'lucide-react' import { useQuery } from '@tanstack/react-query' import { Button } from '@/components/ui/button' @@ -11,24 +11,28 @@ import { Textarea } from '@/components/ui/textarea' import { defaultSystemPrompt } from './defaultSystemPrompt' import { EditableNodeTitle } from './EditableNodeTitle' import { FactorBindableField, MakeNodeFactorButton } from './FactorBindableField' +import { ReceivesSummary, SendsSummary } from './HandoffSummary' import { NodeInspectorDialog } from './NodeInspectorDialog' -import { NodeRunOutputPanel } from './NodeRunOutputPanel' -import { OutputContractEditor } from './OutputContractEditor' +import { NodeRunOutputPanel, ReceivedPromptPanel, UnresolvedReferencesNote } from './NodeRunOutputPanel' +import { PromptPreviewPanel } from './PromptPreviewPanel' +import { PromptReferenceField } from './PromptReferenceField' import { useProtocolCanvasActions } from './ProtocolCanvasContext' +import { RESIZE_HANDLE_CLASSNAME, useResizablePane } from './useResizablePane' import { experimentsApi } from '@/api/client' import { normalizeDesignMetrics } from '@/lib/metricCatalog' -import type { AgentNodeConfig, AgentNodeData, NodeRunState, ProtocolNode } from '@/types/protocols' +import { referenceLabel, seedPromptText } from '@/lib/promptReferences' +import type { HandoffPeers, PromptReferenceScope } from '@/lib/promptReferences' +import type { AgentNodeConfig, AgentNodeData, NodeRunState, PromptPreview, ProtocolNode } from '@/types/protocols' const ACCENT = nodeAccent('agent') -const DEFAULT_OUTPUT_PANE_WIDTH = 384 -const MIN_OUTPUT_PANE_WIDTH = 280 -const MAX_OUTPUT_PANE_WIDTH = 760 -const OUTPUT_PANE_WIDTH_STORAGE_KEY = 'asaree:agent-output-pane-width' -function outputPaneWidth(): number { - const raw = typeof window !== 'undefined' ? Number(window.localStorage.getItem(OUTPUT_PANE_WIDTH_STORAGE_KEY)) : NaN - return Number.isFinite(raw) ? Math.min(MAX_OUTPUT_PANE_WIDTH, Math.max(MIN_OUTPUT_PANE_WIDTH, raw)) : DEFAULT_OUTPUT_PANE_WIDTH -} +// The middle column is where the actual editing happens, so neither side pane +// may drag it below a width its labels and textareas still work at. Enforced +// at drag start (see useResizablePane) against the frame's measured width. +const MIN_PARAMETERS_WIDTH = 380 +// The two 16px handles plus the padding either side of each -- the layout cost +// of the gutters, which the panes themselves don't get to spend. +const GUTTER_WIDTH = 96 // A node's setup opens as a large centered floating window over the dimmed // canvas, not a sidebar or an edge-to-edge takeover. @@ -39,15 +43,32 @@ function outputPaneWidth(): number { // unaffected by which Parameters/Settings tab is active) is shared with the // other node inspectors via `NodeInspectorDialog` -- see that file for why. // -// Parameters/Settings (left, tabbed) splits what defines the agent's -// behavior/identity from what constrains its execution. Output is a -// right-hand side pane (always visible, not a third tab) instead -- run -// results are something you check -// *while* adjusting Parameters, not a destination you tab away to and lose -// your editing context to get to. See NodeRunOutputPanel. +// Three columns: Input, then Parameters/Settings, then Output -- laid out in +// the direction data actually travels, so the agent's configuration sits +// literally between what it is handed and what it produces. +// +// Input and Output are always-visible panes rather than tabs because both are +// things you check *while* adjusting Parameters, not destinations you tab away +// to and lose your editing context to get to. Both are drag-resizable and +// remember their width: how much room the evidence deserves against the form +// depends on whether you're building a prompt or reading a run, and that +// changes minute to minute. +// +// The split also decides where the handoff readout goes. Receives heads Input +// and Sends heads Output, each above the data it describes, and "the prompt +// this agent received" is input -- so on a run that already happened it appears +// on the left, not buried under the output it produced. +// +// Parameters/Settings (middle, tabbed) splits what defines the agent's +// behavior/identity from what constrains its execution. export function AgentNodeInspector({ node, experimentId, + markedLeadAgentId, + referenceScope, + handoffPeers, + wiredOutputParserLabel, + fetchPromptPreview, nodeRun, onChange, onDelete, @@ -55,27 +76,56 @@ export function AgentNodeInspector({ }: { node: (ProtocolNode & { data: AgentNodeData }) | null experimentId: string | null + // Which agent on the canvas already carries the lead marker, if any -- the + // inspector can't see its siblings, so ProtocolCanvas resolves it. + markedLeadAgentId: string | null + // What this node's prompt may reference, resolved from the graph for the same + // reason as markedLeadAgentId: the inspector only ever sees its own node. + referenceScope: PromptReferenceScope + // Who hands off to this node and who it hands off to. Same reasoning again -- + // it's the wiring around the node, which only the canvas can see. + handoffPeers: HandoffPeers + // The label of the Output Parser node wired into this agent, or null if + // none is. Same reasoning as markedLeadAgentId: it's wiring, which only the + // canvas can see. + wiredOutputParserLabel: string | null + // 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 nodeRun?: NodeRunState onChange: (nodeId: string, data: AgentNodeData) => void onDelete: (nodeId: string) => void onClose: () => void }) { - const { requestMakeFactor } = useProtocolCanvasActions() - const [outputWidth, setOutputWidth] = useState(outputPaneWidth) - const [resizingOutput, setResizingOutput] = useState(false) - const outputDragStart = useRef<{ x: number; width: number } | null>(null) + const { requestMakeFactor, requestConnectorAdd, convertLegacyOutputContract } = useProtocolCanvasActions() + // Measured at drag start so each pane's ceiling accounts for what the other + // one is currently taking; read through a ref because the two hooks below + // would otherwise have to reference each other's not-yet-declared width. + const columnsRef = useRef(null) + const widthsRef = useRef({ input: 0, output: 0 }) + const roomFor = (other: 'input' | 'output') => + (columnsRef.current?.clientWidth ?? Number.POSITIVE_INFINITY) - widthsRef.current[other] - MIN_PARAMETERS_WIDTH - GUTTER_WIDTH - useEffect(() => { - if (!resizingOutput) return - const previousCursor = document.body.style.cursor - const previousSelect = document.body.style.userSelect - document.body.style.cursor = 'col-resize' - document.body.style.userSelect = 'none' - return () => { - document.body.style.cursor = previousCursor - document.body.style.userSelect = previousSelect - } - }, [resizingOutput]) + const inputPane = useResizablePane({ + storageKey: 'asaree:agent-input-pane-width', + defaultWidth: 340, + minWidth: 260, + maxWidth: 700, + side: 'left', + resolveMaxWidth: () => roomFor('output'), + recomputeKey: node?.id ?? '', + }) + const outputPane = useResizablePane({ + storageKey: 'asaree:agent-output-pane-width', + defaultWidth: 384, + minWidth: 280, + maxWidth: 760, + side: 'right', + resolveMaxWidth: () => roomFor('input'), + recomputeKey: node?.id ?? '', + }) + widthsRef.current = { input: inputPane.width, output: outputPane.width } const experimentQuery = useQuery({ queryKey: ['experiments', experimentId], @@ -83,13 +133,11 @@ export function AgentNodeInspector({ enabled: !!experimentId, }) - if (!node) return null - const data = node.data - const config = data.config - const bindings = data.factor_bindings ?? {} + // Derived above the `!node` bail-out because the query below is a hook: it has + // to run on every render, including the no-selection one, where it's disabled. const metrics = normalizeDesignMetrics(experimentQuery.data?.design_spec?.metrics) const validMetricIds = new Set(metrics.map((metric) => metric.id!)) - const contextMetricIds = (data.contextMetricIds ?? []).filter((id) => validMetricIds.has(id)) + const contextMetricIds = (node?.data.contextMetricIds ?? []).filter((id) => validMetricIds.has(id)) const evaluationContextQuery = useQuery({ queryKey: ['experiments', experimentId, 'evaluation-context', contextMetricIds], queryFn: () => experimentsApi.evaluationContext(experimentId!, contextMetricIds), @@ -97,6 +145,38 @@ export function AgentNodeInspector({ }) const evaluationContext = evaluationContextQuery.data?.context ?? '' + if (!node) return null + const data = node.data + const config = data.config + const bindings = data.factor_bindings ?? {} + // The lead marker is meaningless under any other coordination strategy, so + // it isn't offered under one -- a checkbox that does nothing on the + // overwhelmingly common single-agent/sequential experiment is worse than an + // absent one. An already-marked agent still keeps its flag through a strategy + // change; nothing reads it, and switching back shouldn't silently lose it. + // Both strategies read the SAME `conversation_lead` field, deliberately: it + // marks "the agent this strategy hands the task to", which is the lead under + // one and the supervisor under the other, so the two never disagree about + // who that is and switching strategy doesn't need a re-mark. + const strategySlug = experimentQuery.data?.design_spec?.coordination_strategy?.slug + const leadRole = strategySlug === 'peer_collaboration' ? 'lead' : strategySlug === 'supervisor_architecture' ? 'supervisor' : null + // Exactly one agent can lead (two is a server-side validation error), so once + // one is marked the checkbox is offered on that agent alone -- unmark it there + // to move the role. Showing it everywhere would invite creating an invalid + // canvas, and silently reassigning on click would move a role the user might + // only have been inspecting. + const canMarkLead = leadRole !== null && (markedLeadAgentId === null || markedLeadAgentId === node.id) + // The pre-node way of declaring an output shape, still honoured by the + // executor when no parser node is wired (see _resolve_output_contract). + // Its presence swaps the section below into the convert-it banner: an agent + // is never allowed to have both at once. + const legacyContract = config.output_contract + // A wired parser is itself the requirement, whether or not the flag was ever + // set: the flag only ever existed to keep the connector drawn while the node + // it is waiting for does not exist yet. + const parserWired = wiredOutputParserLabel !== null + const formatRequired = parserWired || config.require_output_parser === true + function patchConfig(patch: Partial) { onChange(node!.id, { ...data, config: { ...config, ...patch } }) } @@ -118,24 +198,6 @@ export function AgentNodeInspector({ onChange(node!.id, { ...data, contextMetricIds: next.filter((id) => validMetricIds.has(id)) }) } - function startOutputResize(event: ReactPointerEvent) { - event.currentTarget.setPointerCapture(event.pointerId) - outputDragStart.current = { x: event.clientX, width: outputWidth } - setResizingOutput(true) - } - - function moveOutputResize(event: ReactPointerEvent) { - if (!outputDragStart.current) return - setOutputWidth(Math.round(Math.min(MAX_OUTPUT_PANE_WIDTH, Math.max(MIN_OUTPUT_PANE_WIDTH, outputDragStart.current.width + outputDragStart.current.x - event.clientX)))) - } - - function endOutputResize() { - if (!outputDragStart.current) return - outputDragStart.current = null - setResizingOutput(false) - window.localStorage.setItem(OUTPUT_PANE_WIDTH_STORAGE_KEY, String(outputWidth)) - } - return ( onDelete(node.id)} onClose={onClose} > -
-
+
+
+

Input

+ {/* First, because it is the context everything below is read + against: every incoming edge delivers, so "what am I even given?" + has to be answerable before the assembled prompt underneath means + anything. */} + + fetchPromptPreview(node.id)} + /> + {/* Below the design-time preview, because it supersedes it: a + placeholder proves nothing about a run that actually happened. */} + {nodeRun?.run_id && ( + // Boxed to match the preview above it -- in this pane the two are a + // matched pair, where elsewhere the panel is one item in a list. +
+ +
+ )} + referenceLabel(ref, referenceScope.names))} + /> +
+ +
+ +
Parameters @@ -162,13 +261,173 @@ export function AgentNodeInspector({ -
- -