Agent-to-agent communication, explicit prompt assembly, and workspace stage plans - #8
Merged
Conversation
added 30 commits
September 8, 2026 15:47
The notebook/SDK flow PUTs cells straight through upsert_replicate with no design_spec, so there are no planned labels to compare against and no design to regenerate -- but the label comparison reported drift anyway, permanently 422'ing "run all cells" on every notebook-driven experiment. Gate that comparison on a declared factorial matrix; a design that had factors and no longer declares them still trips the spec comparison. Also fixes _cells_of's MissingGreenlet: a replicate's interesting attributes read through .cell, which a lazy load can't populate under asyncio.
Two Agent nodes joined by a plain canvas edge can now see each other and choose to work together, with no MCP server and no user-wired Tool node. This is the ASAREE half of the capability core gained in Motoro: core projects and dispatches, ASAREE decides who exists and authorizes every call. An AgentCard is derived, never stored. Capability comes from the published protocol revision the run is pinned to (so a canvas edit cannot change what a queued run believes about its peers) while reachability is read live from the draft graph on every call -- an unplugged edge stops a consultation mid-run. resolve_available_agents walks the graph for peers; _is_peer_edge is the one definition of what a peer edge is (a plain non-connector edge between two Agent nodes, read undirected, no self-peer). AgentMessenger is where the invariants live, because the model must not be able to state any of them. It authorizes the recipient against the live graph, assigns sender identity, message ids and sequence, appends the transcript, runs the peer's turn to completion, and returns its answer as the function call's result. There is deliberately no send_message/finish action protocol: an agent finishes by writing its final answer exactly as a single agent does today. The only new thing in its world is that a peer exists and can be asked, which is what keeps single-agent runs byte-for-byte unchanged -- no peers means no field content, no prompt section, no function schema. Budgets are caps on executions, nesting depth and wall clock, and exhausting one returns a *call result*, not an exception, so the caller can still finish with a real answer -- a run that hit a limit is finished, not failed. The wall clock pauses rather than extends (services/deadline.py): a consulting agent is blocked on its peer, so charging it for time it could not use would make asking for help a reason to time out. A ContextVar chain of Deadlines means the peer's own limit still applies to the peer. The transcript persists on ProtocolRun.conversation (migration ba2c3d4e5f60) in A2A's Message/Part shape, so a future data or file part is a new branch rather than a migration. Also states the rule on _sync_durable_agent that this design depends on: nothing run-scoped may enter its fields. update_agent reads None as "leave unchanged", so a field written once during one run is never cleared by any later one. Covered by tests/test_agent_cards.py and tests/test_agent_messenger.py.
POST /protocols/{id}/conversations addresses one agent on the canvas and lets
it consult the peers it is connected to. The request carries only an entry
point and an opening question: who else may participate comes from the graph,
so a client cannot widen a run's reach by naming extra agents.
It creates the same ProtocolRun against the same published revision a pipeline
run uses -- what differs is only which worker function walks it. Peers are a
capability of the run mode, not a different kind of protocol.
execute_conversation_task is a separate arq function rather than a flag on the
run row: which mode a run is in is decided once, by the endpoint that started
it, and a column would let a retry wander into the wrong mode if it were ever
written wrong. Both tasks now share _guarded_protocol_run for the durability
guards, none of which care whether the run walks a graph or hosts a
conversation. They share the protocol-run: job-id namespace so a run can never
have one of each queued against it.
validate_conversation_entry rejects an entry that is missing, not an agent, has
nobody to talk to, or whose peer has no model -- 422 before a run row exists,
rather than a job that dies on the worker.
A "Converse" action alongside Run, a live transcript panel over the canvas, and
a "can consult" caption on every peer edge.
The caption rather than a restyle is the point: a solid Agent-to-Agent edge is
BOTH the left-to-right pipeline edge a normal run walks AND the undirected
"these two may consult each other" edge a conversation run reads. The run mode
picks, so the edge itself must not commit to either.
ConversationTranscript renders parts, not a bare string, and is 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.
limit_reached joins TERMINAL_RUN_STATUSES: a budget-exhausted conversation is
finished, not stuck.
An agent wired to peers on a model that cannot be sent function schemas would
silently never consult them, so AgentNode warns. That needed a new fact on the
wire, supports_tool_calling on GET /llm-settings/{provider}/models, and it is
bool | None rather than bool: services/tool_calling.py returns None when
litellm has never heard of the model. Motoro's model_supports_tool_calling
collapses "unknown" into False, which is the right conservative default when
the answer picks an execution pattern and the wrong one to show a user -- every
Azure Foundry deployment name is unknown, so False would put a warning on every
Azure agent. The canvas injects only the wiring facts and AgentNode subscribes
to the model list itself, so the warning appears when the list loads.
An earlier conversation design injected a per-run action contract into the canvas node's config, which _sync_durable_agent then wrote onto the durable agent row. A normal agent node passes output_contract=None and update_agent reads None as "leave unchanged", so that contract was pinned on the node permanently, adding a junk extraction LLM call to every later ordinary run. Nothing writes it any more, so this is a one-off repair, dry-run by default. It is a direct UPDATE because update_agent offers no way to set a field back to NULL. Only output_contract needs repairing: the system prompt was contaminated the same way but is written non-None on every run, so it self-heals.
pyproject.toml pins motoro to a released git tag on purpose and never to a floating branch, which leaves no way to exercise an ASAREE change against an untagged Motoro. This mounts Motoro's source over the installed package, and ./src over the image's copy, so both working trees can be tested together before a tag exists -- no rebuild, no throwaway tag, and the pin unchanged. Opt in explicitly with -f compose.dev.yml rather than naming it compose.override.yml, so it can never be active without being asked for. The overlay is a plain directory mount, so uv never resolves it: valid only while Motoro's own dependencies are unchanged. If Motoro gains one, rebuild against a tag instead.
Two changes that together turn peer consultation into agents actually working a problem together, and make it selectable as an experiment's coordination strategy. Every turn now reads the whole transcript. A consulted peer used to start from nothing on every turn: it saw only the question it was asked, not its own earlier turns and not what any other agent had already found. Peer memory and shared context are one mechanism, because both are the same question -- what does this turn get to read. AgentMessenger._briefing renders the conversation so far ahead of the question, naming participants with the canvas's own labels, truncating each message, and marking a refused or failed turn so it can't read as an answer. Memory is reconstructed from the checkpointed transcript rather than by resuming a run, so each turn stays a separately attributable and priced AgentRun and a retried worker rebuilds identical context. It rides on user_input because ambient_meta is bound into MCP _meta and never shown to the model. A consulted peer also now resolves its References through _node_run_context like any pipeline node, so a Dataset connector on a peer is seeded and its data_path bound before it runs. The new peer_collaboration strategy makes a factorial cell *execute* as a conversation instead of a pipeline. run_conversation is split: execute_conversation is the conversation itself and returns (node_run, status) without touching the run's status, so run_protocol's peer_collaboration branch can wrap it in the same pre-write / result / metric-promotion path every other cell run uses. The entry agent is derived from the canvas -- resolve_conversation_entry_id picks the peer-connected agent nothing feeds -- rather than configured separately: one agent must lead, and two equally plausible starting points is an error, not a guess. validate_coordination_strategy now takes the whole graph instead of a pre-computed has_gated_pair, since each strategy asks the canvas a different question. A conversation that ran out of budget keeps limit_reached as the run's terminal status rather than flattening into failed. The six ARES coordination placeholders are removed from the picker: an option that always fails at run time is worse than one that isn't offered. The backend still recognizes those slugs so an experiment saved with one gets a real explanation instead of "unknown".
Both predated Peer Collaboration being a coordination strategy, and now
read as clutter and as a second way to press Run.
The caption annotated every solid Agent-to-Agent edge, which is most of
them on a real canvas, with something the Design tab already states once.
An edge now looks the same whether the experiment coordinates
sequentially or by peer collaboration -- which was always the point: it
is both edges, and the strategy picks.
The Converse button, its dialog and the `conversation` RunScope are gone
with it. Running the protocol is how connected agents start talking, so
there is one Run path and the strategy decides what it means. The cost
ceiling the dialog used to state moves to the strategy's own description
on the Design tab, where the choice is actually made.
The POST /protocols/{id}/conversations endpoint stays as the programmatic
path -- it is the only way to start a conversation with a question that
isn't the entry agent's node prompt -- but nothing in the GUI calls it.
resolve_conversation_entry_id derived the entry agent from the wiring alone, which quietly assumed a DAG. The topology Peer Collaboration most invites -- every agent wired to every other -- is a cycle, where every agent is fed, so the derivation found no candidate and could only tell the user to unwire something. An agent node can now carry data.conversation_lead, checked before the wiring rule: an override, not a tiebreak. Derivation stays the default so a plain chain needs no configuration and nothing saved earlier changes behavior. Two marked leads, or a marked lead with no peers, are explicit errors. That alone wasn't enough to run one, though. topological_order ran before validate_coordination_strategy at five call sites and rejects any cycle, while run_protocol's peer_collaboration branch discards the order it returns (order = []). Only the sort's validation was doing anything, and it rejected exactly the topology the marker exists for. It now takes require_acyclic, and each site reads the strategy first so its own message wins. The single-sink requirement in the two replicate planners relaxes the same way and for the same reason: a conversation's result comes from the lead, not a sink, and a loop has no sink at all. The empty-graph and critic-gate checks are untouched. On the canvas, the checkbox is offered on at most one agent -- once one is marked only that agent's inspector shows it, since two is a server-side error and the UI shouldn't be able to create one. The "Lead" chip and the checkbox both appear only under Peer Collaboration; the flag itself survives a strategy change. ReasonActPatternNode's "no tools" warning was also stale here: both Motoro execution paths append the peer roster to the function payload independently of tools, so a peer keeps the loop going with nothing else wired.
Motoro's agent_channel passes from_agent_id=str(context.agent_id), the
durable Agent row the turn ran as -- a UUID, never a canvas node id.
_can_deliver_communication authorizes against the graph, so every real
consultation came back rejected ("that agent is not connected to you"),
while the recipient resolved fine because cards carry node ids. A three-
agent loop ran, found its peers unreachable, and answered alone.
AgentMessenger now keeps a _turn_stack of node ids and takes the sender
from its innermost frame, ignoring what the port hands it. That is the
identity authorization, the transcript and the briefing all speak in; one
durable agent can back turns for more than one node; and invariant 2 puts
sender identity in the runtime's hands, not the caller's. Invariant 5 --
one agent at a time, a consulting agent blocked on its peer -- is what
makes a stack the right shape.
_depth became a derived property of that stack, so nesting depth and
attribution can no longer disagree. Every turn is wrapped in
messenger.turn(), the entry agent's included.
`runId` was React state set only by the mutation that launches a run, so the canvas could show a run started in that browser tab and nothing else. A reload dropped whatever it was watching, and a run started outside the GUI -- the SDK, a notebook, a direct API call -- could never be watched at all. Its node statuses, per-node output and conversation transcript were all recorded and all unreachable. It now seeds runId from list_protocol_runs (newest first), once and only into an empty slot: a run launched here has to win over whatever happened to be newest at page load, and re-seeding on every refetch would yank the view off the run the user is watching. ConversationTranscript is collapsible as a result. It used to appear only while you watched a conversation finish; now it is present the whole time you edit a Peer Collaboration graph, so it needs to get out of the way. Collapsed keeps the header, which is the part that says a conversation happened at all.
The transcript sat at `absolute right-3 bottom-3`, which is React Flow's MiniMap corner, so the two overlapped. Both are now children of a single top-left overlay column: the lock badge and the transcript share that corner and stacking them is what keeps them off each other, where two independently positioned overlays could only collide. The column is `pointer-events-none` with each child opting back in, so the empty space it reserves stays part of the canvas -- panning and node drags still work under it. The transcript's scroll moved onto the message list so its header stays put while reading.
Every later hardening phase has to leave this green. The prompt is the one run input no revision pins, so an obviously-good improvement to _build_user_input would change published numbers with nothing failing to stop it.
The sequential branch of validate_coordination_strategy was a bare return, so "sequential" imposed no shape at all -- an agent could fan out to three others and the run was still called sequential. The walk itself was already mandatory and exhaustive; what was missing was the topology guarantee. Handoffs are agent-to-agent paths, not single edges, so a critic gate or a Script node between two agents keeps the chain a chain. The canvas refuses the fork as you draw it and hides the "+" stub on a taken side, so the rule doesn't ambush the user at publish time. Enforced hard rather than grandfathered: every sequential experiment in the database has at most one agent and no agent-to-agent edge to break.
The strategy decides how every cell executes, so cells generated under one strategy aren't comparable to cells generated under another. Until now switching it left the existing cells in place, silently mixing two execution models inside one design. `material_design_spec` now carries the strategy slug, so the revision snapshot records what its cells ran under and `get_design_impact` can see the drift. `generate_design_cells` supersedes the revision on a strategy change and deliberately does NOT carry results forward -- a score from the old strategy would be the wrong number, not a stale one. `params` stays excluded: it tunes a strategy without changing which one ran, and retiring scored cells over a knob would be punitive. Both new checks are gated on a declared factorial matrix. The notebook flow writes cells directly onto the revision with no factors at all, so an ungated check would block "run all cells" forever there, and an ungated regenerate would retire the notebook's own cells. `DesignImpact` gained `regeneration_reasons` because the counts alone read as "nothing changed" here: a strategy switch regenerates every cell while adding and removing none. On the canvas side `lib/coordinationStrategy.ts` mirrors the backend's validation client-side, so an incompatible pick says so under the picker instead of surfacing as a rejected run. It's advisory on purpose -- the strategy has to be selectable before the canvas matches it or the design loop deadlocks -- and it only asks for confirmation when the switch breaks a canvas that currently validates. Also hoists AgentNodeInspector's evaluation-context query above the `!node` bail-out, which was a real rules-of-hooks violation.
The prompt is an input to a run as much as the model name is, and it was the only input nothing pinned. A design revision pins the factors, a protocol revision pins the graph, and the text ASAREE wraps around a node's own prompt was whatever the code said on the day it executed. So an obviously-correct improvement to that text -- naming the upstream agent instead of printing its raw canvas node id -- would silently have changed the numbers of every published experiment rerun afterwards. `design_spec.prompt_contract_version` fixes that. Absent means v1, and v1 is frozen: every pre-existing experiment, the published spinal pipeline included, stays there permanently, and test_spinal_compat now asserts both that it resolves to v1 and that v2 really would have rewritten every handoff label in it. New experiments are stamped v2 at creation. The version resolves once per run and threads down, so an edit made mid-run can't produce a run that used two formats. The split is at the upstream-context block rather than a duplicated `_build_user_input` body: the Dataset and Script cues are tool-usage instructions that have to track the tools that actually exist, so freezing them per version would hand a rerun stale instructions. What keeps v1's whole prompt honest is the byte-for-byte golden assertion, not a copied function. `design_spec` is a full replacement on PATCH, so the SDK, the notebook and older clients all send specs they built from scratch -- which would have dropped the key and moved a v2 experiment back to v1. The version is stamped once and isn't user-editable, so an omission now preserves it; an explicit value still wins, which is how an import restores an experiment at the version it ran under. v2 also records a sequential run's handoffs as an A2A conversation, so a chain gets the same transcript panel a peer run does instead of the user reading five node-output panels and reconstructing the order by hand. Deliberately the message layer only, never execute_conversation: the walk is what guarantees every node runs in order, and routing it through the conversation executor would hand that decision back to the models. Nothing about it can influence execution. Finally, a targeted guard for a real stale-data window under peer_collaboration: ambient `data_path` is bound at turn start and Motoro reads that frozen copy per tool call, so a consulted peer that accepts a stage leaves the caller fitting on the pre-consultation matrix. `send` now re-resolves HEAD and appends a system note to the reply when it moved. Phase 4's per-agent slots remove the window structurally; this closes it cheaply in the meantime.
A cell workspace held exactly one dataset: it was keyed by
experiment_id/cell_label alone, so a second Dataset node wired to an agent
was rejected at run time, after the run had already started. Comparing
datasets is a factor, but WORKING on several at once -- a supervisor's
workers each owning a cohort, a join across two tables -- had no shape.
A workspace now holds one dataset per named slot, each with its own
target_column, version list and HEAD:
dataset:<name> a seeded registration
agent:<node_id> one agent's private scratch lineage
state.json keeps two on-disk shapes. Format 1 is the flat
{target_column, head, versions} every existing workspace has; format 2 is
{"format_version": 2, "slots": {...}}. Format 1 is NOT migrated on disk --
a reader normalizes it in memory to a single "dataset:default" slot, and
_save_document writes format 1 back as format 1 for as long as that is the
only slot. The format changes when a second slot actually arrives, not
because something read the file. The legacy slot also lives at the
workspace ROOT rather than a subdirectory, so version dirs, manifests and
scratch dirs stay exactly where they were, even after a second slot opens.
The spinal experiment's published workspaces are byte-compatible.
Ambiguity is an error naming the candidates, never a guess: every
slot-taking API defaults to the sole slot when there is exactly one --
which is what keeps single-dataset callers unaware slots exist -- and
raises listing them otherwise, mirroring resolve_dataset_name's own rule.
A Workspace instance binds to one slot, so only load_state/_save_state and
the path properties are slot-aware; the ~15 staging methods are untouched.
Downstream:
- seed_cell_workspace seeds a slot and drops the collision rejection;
Workspace.open still refuses to reseed one slot from a different dataset,
which is the collision that remains a real error.
- _resolve_node_dataset seeds every wired dataset and carries (name, slot)
pairs, because the slot is not always dataset:<name> -- a workspace still
in format 1 keeps its unnamed slot, and the prompt has to name the slot
the agent's tool calls will actually accept.
- _ambient_meta_for publishes data_slots instead of data_path/target_column
when a workspace holds several, since there is no single HEAD.
- workspace_server's stage tools take slot=""; workspace_status reports
per-slot and flattens to the old top-level shape when there is one.
- The eda/model servers read state.json directly (deliberately no
workspace-core dependency), so each got a local _slot_state helper rather
than a KeyError on format 2.
- The Dataset connector is uncapped on the canvas to match.
Current boundary: domain MCP servers compute the scratch path from
workspace_id alone, so the staged DC/FTE/FS pipeline runs against one slot
per cell. Other slots stay reachable through workspace_server and the
path-taking tools.
One supervisor agent briefs the workers wired to it, they all run, an optional reviewer assesses their output advisorily, and the supervisor writes the final answer. Every one of those turns is dispatched by ASAREE. That last part is the point. Peer Collaboration can already draw this graph, but consultation there is a function schema the model chooses to call, so a supervisor that decides two of its three workers suffice produces a run that isn't comparable to one that used all three. An experiment measures a fixed treatment, so "all three ran" has to be a property of the design rather than of the model's mood -- the same guarantee Sequential makes, one topology up. Roles come off the wiring rather than a new per-node dropdown: the supervisor is the agent marked `conversation_lead` (the marker Peer Collaboration already uses) or, failing that, the agent nothing feeds into -- and in a loop, which the target topology becomes once the reviewer reports back, the agent dispatching to the most others. Workers are whoever the supervisor hands off to; the one remaining agent is the reviewer, which must be connected to at least two others. A worker-to-worker edge is a peer mesh and is rejected as one. Workers run in parallel by default, which is only safe because each stages into its own `agent:<node_id>` workspace slot. Set `coordination_strategy.params.parallel_workers = false` to dispatch them one at a time. A failed worker doesn't abort its siblings and doesn't fail the run: the failure is reported to the supervisor (and to the reviewer) in place of that worker's output, and the supervisor decides the answer without it. The run fails only if the supervisor itself fails, or if every worker did, since then there is nothing to synthesize.
The staged pipeline was the three constants dc/fte/fs, so any other kind of staged work got "unknown stage" and an empty workspace. It is now a StagePlan: an ordered list of stages, each with a version id, a scratch/fixed-input mode and a declarative gate. dc/fte/fs survives as the `tabular_ml` preset, which is also the default, and STAGES/STAGE_VERSION are views of it. The preset is immutable -- a variant is a copy into an inline plan -- because a published result depends on what its stages meant. The plan is recorded in state.json only when it differs from the preset, so a spinal-shaped workspace's state file stays byte-shaped exactly as before and stays format 1. Gates are a closed schema, not an expression language: missing=none, columns=subset_of_input|non_increasing, rows=preserved, validated when the plan resolves so a typo cannot quietly become "no check". The old hardcoded checks map onto it exactly -- dc's zero-missing rule is missing=none, and fs's "columns subset of v2_fte" is columns=subset_of_input resolved against whatever the plan says fs reads. stage_plan=None means "adopt whatever this cell already stages through", not "the default": the MCP open_workspace() tool knows a workspace id but not the experiment's design, and must be able to re-open a cell ASAREE seeded with a declared plan. Disk wins over the constructor, and re-opening with a different plan is refused -- half a lineage staged one way is not a reachable state. Runs read the plan from their pinned design revision, so a plan edit mid-flight cannot restage a queued replicate.
The stages a dataset workspace runs through can now be declared per experiment, so the Design tab gets a picker for them: the built-in tabular-ML preset, listed read-only, or a custom list with reorder, rename, per-stage promotion-gate checkboxes and the fixed-input toggle. Choosing custom copies the preset's stages rather than editing it. Version ids are derived from position + id rather than typed -- they are path components in the workspace's version lineage, so a hand-typed one buys nothing while a typo in one is a broken pipeline. The preset is stored as no `stage_plan` at all rather than an inline copy, which is what keeps a spinal-shaped workspace's state.json byte-shaped as it was before plans existed. lib/stagePlan.ts mirrors the backend's id and gate-rule validation so a malformed plan says so under the editor instead of being rejected at generate/publish/run time. The plan joins the coordination strategy as a design field that changes how every cell EXECUTES without changing which cells exist, so it autosaves and `get_design_impact` compares it against the revision the cells were staged under -- a new `stage_plan_changed` regeneration reason, since the counts alone read as "no change". Compared resolved, so absent, "tabular_ml" and an inline copy of the preset are one declaration and no legacy experiment is asked to regenerate for nothing. The section only appears when the canvas has a Dataset node: nothing else creates a workspace.
It is COPYed into the image and installed editable inside it, so unlike src/ a new module there stays invisible until a rebuild -- which the whole point of this overlay is to avoid. Overlays the package directory rather than the project so the editable install's own recorded path stays valid.
A Dataset connector whose registration has no train/test split has no workspace, so _build_user_input tells the agent outright not to call open_workspace and to use describe_dataset / describe_split / train_test_split instead. Those live on scikit-learn-mcp, and the run allow-list only ever carried asaree-workspace -- so the prompt named three tools the agent did not have. The first sequential demo run ended with the model reporting the gap instead of profiling the data. This is the same defect _resolve_dataset_tool_config and _resolve_script_tool_config were written to fix, in the one dataset shape neither covered. The grant is exactly the three tools the prompt names; the model-fitting tools alongside them are a real choice about the analysis and still take a Tool node. unsplit_dataset threads through the pipeline, peer, conversation-entry and supervisor-turn paths so every way an agent can be invoked gets the same allow-list. Also validate the declared stage plan at publish and ad-hoc-run time, not just when planning cells, so a malformed plan is refused where it is introduced rather than at the next batch.
POST /protocols/{id}/conversations had no caller anywhere: the canvas
deliberately has no second Run button, and agents collaborate through the
experiment's Peer Collaboration coordination strategy instead, which runs
through plan_cell_runs -> run_protocol. The frontend method existed but
nothing called it.
Gone with it: StartConversationRequest, enqueue_conversation,
execute_conversation_task, run_conversation, and the arq registration.
execute_conversation itself stays -- it is the peer_collaboration cell-run
path -- and so does validate_conversation_entry, which
validate_coordination_strategy uses to check a peer canvas before a batch.
The Design tab had a "Workspace stages" editor -- a list of stage ids, gates and version names the user had to fill in before a dataset experiment could stage anything. That was the wrong thing to ask. A user builds a team of agents; the pipeline those agents stage through is not a second thing to declare, it is a fact about the team they already drew. And the stage names were never theirs to pick anyway: each bundled stage-writing MCP server hardcodes the single stage it writes (asaree-sklearn-dc's STAGE = "dc", and it refuses any other), so the honest reading of "which stages does this experiment have" is "which of those servers does the canvas wire". system_mcp_servers.STAGE_WRITING_SERVERS mirrors that mapping and protocol_execution.derive_stage_plan asks it, walking the agents in the order the canvas draws. Backward compatibility is a no-op by construction. Deriving returns None -- the same value that resolved before plans existed, so nothing lands in state.json -- both when the canvas wires no stage server (every generic agent team) and when it wires all three (the published spinal canvas). The case that is not a no-op is a partial pipeline: a canvas wiring DC and FS but no FTE used to get the full triple regardless, so FS looked for a v2_fte version nothing had accepted and the run stalled on a lineage error with no visible connection to the wiring. It now simply has two stages. design_spec["stage_plan"] stays as the SDK escape hatch and still wins over the derived plan, so a notebook can name a preset or a pipeline the canvas cannot express. It is no longer a regeneration reason, though: with no GUI field it is absent on both sides of every canvas experiment's comparison. _kahn_order splits the dependency walk out of topological_order so derivation can read a half-wired draft without raising -- an unfinished canvas should say so through validation, not by failing to name its stages.
v1's assembled prompt was pinned in pieces; v2 was pinned only relatively, by a counterfactual asserting it equals v1 with the upstream labels swapped. That assertion proves v2 changes exactly one thing, but it describes v2 in terms of v1 -- so once a v3 exists and the shared assembly around the upstream block grows a section, an edit leaking into v2's output would keep it green as long as it leaked into v1's too. v2 needs its own anchor before a third format arrives, and it has real runs behind it: LATEST_PROMPT_CONTRACT_VERSION is 2, stamped on every experiment created since it shipped. So capture the assembled prompt per version as a golden file and parametrize the assertion over _UPSTREAM_CONTEXT_BUILDERS itself. Registering a version without capturing its prompt now fails, which is what makes pinning the next format something the harness demands rather than something a reviewer has to remember to ask for. The counterfactual stays -- the golden says what each version is, the counterfactual says what separates them. Goldens live in fixtures/prompt_goldens/, not fixtures/prompts/: .gitignore's unanchored prompts/ rule matches at any depth and would have left them untracked.
…s/prompts The rule was unanchored, so it matched at any depth and silently swallowed tests/fixtures/prompts/ -- which is why the goldens landed under prompt_goldens/ a commit ago. Removing the rule is the better fix: the only thing it was covering is an empty prompts/ at the repo root, and git does not track empty directories, so nothing new becomes tracked. The prompts/ dirs under .venv and node_modules stay ignored by their own rules.
`_upstream_context_v1`/`_v2` and `DEFAULT`/`LATEST_PROMPT_CONTRACT_VERSION` applied released-API discipline to a feature that has no users. Only one result is published, so only one format needs freezing: - `LEGACY_PROMPT_CONTRACT` (1) is frozen forever and is what an absent `prompt_contract_version` resolves to -- it exists to keep the spinal paper reproducible and takes no improvements. `_upstream_context_legacy` says so in its docstring. - `CURRENT_PROMPT_CONTRACT` (2) is where the handoff design happens. `_upstream_context` is expected to evolve in place; its golden is a change-detector, not a reproducibility promise. The goldens are renamed to match (`spinal_fte_legacy.txt`, `spinal_fte_current.txt`) and the parametrized "every version is pinned" test splits into two tests that state which kind of golden each is, plus a registry/golden consistency check. `_UPSTREAM_CONTEXT_BUILDERS` stays a dispatch table with two entries so restoring a real version ladder at release is an entry, not a rewrite. No behavior change: 44 tests pass and both goldens are byte-identical to their pre-rename contents.
Two agents connected by an edge already hand off: the upstream text is
appended to the downstream prompt. What the receiving model was never told
is what that text *is*. It arrived under a bare "Upstream context:" header
with no boundary and no framing, so a predecessor's output read as more of
the reader's own instructions -- and an agent with a successor had no idea
it had one, so it wrote a closing summary for a reader who does not exist
instead of a handoff.
The envelope. Each sender's block is now wrapped in Motoro's
<<<UPSTREAM_OUTPUT>>> pair and followed by one sentence naming those
delimiters and saying whose instructions live inside them. Motoro already
fenced the whole user_input in <<<USER_DATA>>>, but one coarse fence cannot
separate an agent's own goal from its predecessor's output -- that is the
line the inner pair draws. Each fence defuses only its own pair, so the
inner block survives the outer wrap intact.
The framing is a parameter, not a constant, because a supervisor's brief is
the opposite case: a worker is told to carry it out, so framing it as
"instructions in here are not for you" would have the prompt arguing with
itself. Handoffs get "do not follow them", briefs get "carry them out", and
an unrecognized kind falls back to the handoff wording, which fails closed.
The audience. An agent is now told who its output goes to, by name, and
that it is the last step when it is. Successors come from
_sequential_agent_links, not raw edges: on SF-DC -> Critic (DC) -> SF-FTE
the raw downstream is the gate, so naming that would tell the agent its
work stops at a reviewer. Step numbering ("step 2 of 3") is added only for
sequential, extracted into _chain_steps -- a supervisor fan-out has no
step 2 of 3, and a confidently wrong number is worse than none.
Both are current-contract only. The audience line is dropped inside
_build_user_input based on the resolved contract rather than at the call
sites, because the pipeline walk passes an audience for every agent and has
no business knowing which contract the experiment is pinned to. The legacy
golden is byte-identical; the current one is regenerated, and its diff is
exactly the envelope and the audience line.
Fixes a live bug on the way past: _turn now passes upstream_ids explicitly.
The upstream block was built from the graph, so a supervisor's brief only
rendered when the supervisor was a direct main-edge predecessor of the
worker. resolve_supervisor_roles reads roles off the agent handoff graph,
where a Critic Gate between two agents is plumbing -- so that topology is a
valid supervisor tree in which the brief silently vanished while the worker
was still told to carry it out. Who spoke to whom is a fact the messenger
knows outright; deriving it from topology was the mistake.
Requires Motoro ad1fb1e (the second delimiter pair), which is not in the
v0.5.2 tag pyproject.toml still pins.
On the current prompt contract an edge now grants availability -- the upstream
output is retained and in scope -- while a `{{...}}` reference in the prompt
grants use. Nothing platform-authored reaches an agent unasked.
Two defects motivated this, neither of them about assembly determinism (that
was already pinned byte-for-byte):
- The framing sentence and audience line landed in every treatment, so prose
nobody chose was part of the independent variable on a platform whose whole
job is isolating that variable.
- "All main-edge predecessors, concatenated in edge order" is a platform
decision standing in for one the experimenter should be making, and it had no
way at all to express reaching back past an intermediate step.
`services/prompt_references.py` is the syntax: `{{node:<id>}}`, `{{previous}}`,
`{{audience}}`, `{{upstream_instructions}}`, each accepting `|raw`. Ids are
stored and labels only displayed, so renaming a node cannot break a prompt.
Graph semantics stay in `protocol_execution`, where the graph is.
The fence stayed automatic while the prose became opt-in: delimiters are
constant across treatments and assert nothing, so they are not a confound.
`|raw` drops the fence but never the neutralization -- a payload that can forge
a delimiter can close Motoro's outer `<<<USER_DATA>>>` fence.
`upstream_ids is None` is the pipeline/messenger discriminator. The messenger
composes a supervisor's brief at runtime with no user prompt anywhere to hold a
reference to it, so suppressing its block would delete the message rather than
hand control of it to anyone. It passes senders explicitly and keeps the named,
fenced, framed block.
`referenceable_node_ids` is the one rule both the picker and the validator will
read, so they cannot disagree about what is legal. Scope is transitive main-edge
ancestry, not walk position: parallel branches have arbitrary relative Kahn
order, so a cross-branch reference would resolve or not depending on scheduling.
Following main edges only also excludes connector nodes, whose output is inert.
A reference that resolves empty annotates rather than fails -- an agent that
correctly produced nothing is a legitimate result, and failing the replicate
would discard valid data. A node with predecessors and no reference is likewise
not an error; that is how an agent that starts fresh is expressed now, and it is
Phase 3's job to mark it on the canvas.
The legacy contract is untouched: no substitution, no validation, and
spinal_fte_legacy.txt is byte-identical. spinal_fte_current.txt loses its
upstream block, framing sentence and audience line, because those prompts were
written when the block was automatic and contain no references. Reproducing
that pipeline on the current contract is a prompt-rewriting job, not a version
bump -- which is why the legacy contract is frozen rather than migrated.
Phase 2's backend made an edge grant availability and a {{...}} reference
grant use, but nothing on the canvas said so: the prompt field was a bare
textarea, and the only way to reference an upstream agent was to know the
syntax and type a node id by hand.
The field now knows what this node may reference. "Insert reference" is the
discovery path -- the one thing on screen that says the feature exists --
and typing `{{` is the speed path; both drive the same list, built from the
same ancestry rule the validator enforces, so the picker cannot offer
something publish will refuse.
References store ids and display labels, so renaming a node can never break
a prompt that points at it while the sentence being written still reads like
a sentence. The hazard that buys is editing inside the braces, which turns a
live reference into inert prose silently -- so a `{{...}}` naming nothing,
or naming a node that doesn't run first, is called out under the field.
A factor bound to the prompt replaces it whole, per cell, so every level has
to carry its own reference or that cell is one where nothing upstream
arrives. That makes the level editor the primary surface, not an afterthought:
`config.prompt` becomes bindable, and a prompt factor's levels get the same
picker, reached from the node inspector, the Design tab, and the canvas's
per-node picker alike.
added 27 commits
September 9, 2026 14:20
A preview that reimplements assembly in TypeScript would drift from the real prompt, which is worse than having no preview: it would be read as evidence about a run it does not describe. So preview_node_prompt wraps _build_user_input and changes only what it is given -- synthetic `<output of "Name">` for every referenceable ancestor, since upstream output is the one thing that genuinely does not exist yet. It creates nothing. _resolve_node_dataset seeds a workspace on disk as a side effect, so the preview gets a read-only sibling that repeats only the classification; a test monkeypatches seed_cell_workspace to raise if that ever stops being true. Also carries the ids of references that resolved empty onto the node run. An empty resolution leaves a literal gap in the prompt, which reads as an agent that was never told anything rather than one whose sender produced nothing -- the Runs tab needs to be able to tell those apart.
An edge grants availability and a reference grants use, so an edge alone
no longer tells anyone whether data flows -- which makes this the only
place a user can see that it does. Four readouts, all of existing data:
- Receives/Sends above the prompt, direct main-edge neighbours only
(mirroring _upstream_ids), so it is visible that step 5 does not see
step 1. A wired sender the prompt never references is marked, and
{{previous}} is expanded, since its meaning is in the wiring rather
than in the text.
- A collapsible preview of the assembled prompt, fetched from the
backend against the canvas on screen. Never re-derived in TypeScript.
The character count says how many stand-ins it is counting, because a
placeholder is a few dozen characters where a real answer is thousands.
- The stored prompt each agent was actually given, on a run that already
happened -- the sender's output is inside it or it is not, which is the
whole question and needs no token echoed through a prompt to answer.
Rendered as text, never markdown: it contains untrusted model output.
- The experiment's prompt contract, read-only, with what it means. It is
stamped at creation so published numbers keep their prompt.
Plus, on the canvas, a "no reference" chip on a main edge that carries
nothing -- suppressed on the legacy contract, where every predecessor's
output is handed over regardless and the marker would be a lie.
The received-prompt readout landed in ResultsTab rather than RunsTab as
planned: RunsTab lists cells and replicates, and node runs are rendered
in ResultsTab's timeline and the live inspector's output panel, which is
also where a reference that resolved empty is now named.
Three columns -- Input, Parameters/Settings, Output -- so an agent's configuration sits literally between what it is handed and what it produces. Input is drag-resizable and remembers its width the way Output already did; how much room the evidence deserves against the form depends on whether you are writing a prompt or reading a run. The split decides where each half of the handoff readout goes: Receives heads Input and Sends heads Output, each above the data it describes rather than both stacked over the prompt field. The assembled-prompt preview and, on a run that already happened, the prompt the agent actually received move to Input for the same reason -- what an agent was given is input, and it was previously filed under its own output. Both side panes now come from one hook rather than a hand-rolled copy each, which also fixes a first-open pane snapping to its minimum instead of its default, and clamps a width stored on a wide monitor so the two panes can't squeeze the middle column to nothing on a smaller one.
Added to the options type and used in the effect's dependency array, but never pulled out of the parameter object -- so opening an Agent inspector threw on a bare undefined identifier. Not caught before commit because `tsc --noEmit` checks nothing in this project: the root tsconfig is `files: []` with references only, so the real typecheck is `tsc -b`, which is what package.json's build script already runs.
The picker was only in Prompt, so the same `{{node:...}}` a user had just
learned to write arrived in the System prompt box as literal text. Now both
boxes offer the same list and both resolve against the same scope.
`_build_system_prompt` does the resolving, returning None for an unset field
so `_run_agent_node` keeps owning what an unset System prompt becomes; the
pipeline walk and the critic-gate loop pass the result through the new
`system_prompt` kwarg, and both share the user prompt's `unresolved` list --
a reference that resolved to nothing left the same gap wherever it was
written. Legacy stays literal, as its frozen bytes require.
`validate_prompt_references` now checks both fields and names which one it
refused, because the picker tells the user in that box that publishing will
be refused.
Note the placement this allows: a referenced upstream output in a System
prompt is model text at the highest-trust position in the request. It is
fenced exactly as in the user prompt, but choosing to put it there is the
user's.
Also: Escape in the suggestion list now stops propagating, so it dismisses
the list instead of closing the inspector and discarding every unsaved edit
behind it.
Between free text and a JSON schema there is a much cheaper middle: one line of plain English. The reader is another language model, which reads prose fine, so a schema between two of them buys malformed-output and refusal failures and nothing else. Optional, factor-bindable, unvalidated. The plan had this appear twice -- once in the producer's prompt and once in the consumer's envelope header, where it would let an agent notice it had been handed the wrong thing. The second half is unbuildable and has been dropped rather than forced: the current contract has no envelope on the pipeline path, and a single-node reference is deliberately unlabelled, so carrying the shape to the consumer would mean reintroducing exactly the unasked-for platform prose that design removed. It goes to the user instead -- each referenced sender's promise, in the Input pane's Receives readout, where a mismatch is something someone can act on while wiring. Legacy stays byte-identical: the field is inert there.
`config.output_contract` was a switch two tabs deep in an agent's inspector that made a second, billable LLM call at the end of every run and told the agent nothing about what to write. Both defects come from it being a field. It is a node now: wire an Output Parser into an agent's new `output_parser` connector and the contract is visible, has an edge, can be switched off per cell, and -- new -- contributes its field list to the agent's prompt, so the answer being parsed was actually asked for. Expected output stays a field, so the two form a ladder: the field alone is a free prompt hint, adding the node buys typed extraction for one more model call. One node type only, matching Motoro's single extraction mechanism. The legacy field keeps working, permanently and not transitionally: published protocol revisions are immutable snapshots that finished runs point at, and POST /agents still accepts the field, so a new graph can arrive carrying one at any time. There is no Alembic data migration and there never will be -- `_resolve_output_contract` falls back to the stored field whenever no parser node is wired at all, and refuses both at once. Converting is a button in the agent's inspector, one atomic canvas edit, never automatic: unlike migrateLegacyHandles (which rewrites an invisible handle string on an edge the user drew), this materialises a node the user never placed, and autosave would persist it. Also fixes a latent bug the migration would otherwise have laundered: the type dropdown offered one spelling per type while Motoro accepts alias pairs, so 136 of the 248 stored field specs had no matching item and were one click from being clobbered. The dropdown now carries whatever the field is already set to. Normalising the aliases on read was rejected -- it would diverge these drafts from the revisions production runs still execute.
A parser node declared a shape, but nothing kept what came back: the second,
post-hoc model call produced a payload and it was discarded, so the only way
to use a declared field was to re-read it out of the prose by hand.
The payload now rides alongside `output_text` on the node run -- never instead
of it, since extraction is a reading of an answer that already exists and is
allowed to fail without taking the answer down with it. `_run_agent_node`
returns a merge-ready fragment rather than a wider tuple, so the callers that
do not care about extraction ignore one value instead of destructuring five.
That makes `{{node:<id>.<field>}}` resolvable: one extracted value, bare and
unfenced, so a prompt asking for a row count receives `4300` and not a quoted
JSON fragment wrapped in a code fence. A field reference never falls back to
the prose -- substituting a whole answer where a number was expected is
silently wrong rather than merely degraded -- and naming a field the parser
does not declare is refused at publish, with the declared names listed.
Extraction is best effort, so the caveats are surfaced. Motoro's extractor
returns `(None, caveats)` rather than raising, which otherwise leaves a failed
extraction indistinguishable from never having wired a parser.
Under Sequential a plain agent-to-agent edge is a handoff, and the run passes no `available_agents` -- so the loop genuinely does end after one turn. The warning said 'no tools, skills, knowledge or peers' anyway, which reads as a broken check when a peer is visibly connected, and buries the actual fix. The condition is unchanged; only the sentence is, and only when the agent has connected agents that this strategy leaves uncallable.
This reverts commit b76cc2a.
The design-time preview stands in for an upstream node's prose, but had
nothing to stand in for its extracted payload -- so a prompt reading
"The dataset has {{Profiler.n_rows}} rows" previewed as "The dataset has
rows", a sentence with a gap in it. That reads as a broken prompt rather
than as a value that does not exist until the run, which is exactly the
kind of drift a preview exists to rule out.
Only the fields a node's Output Parser actually declares get a stand-in.
Referencing one it does not declare still previews empty, because that
gap is real -- the preview is allowed to be unfinished, never wrong.
Expected output and the Output Parser were two ways to ask for the same thing at two prices: a free-text field appended to the prompt, and a node that appended a field list and extracted it afterwards. Keeping two descriptions of one answer in agreement was manual work, and the prose half was the one nothing could read back. The field is withdrawn. The agent inspector now has a single "Require specific output format" switch: off means answer in prose, on means take the shape the wired Output Parser declares. The switch is checked and disabled whenever a parser is already wired, since the edge is itself the requirement and an unchecked box above a connected parser would be a lie. Required with nothing answering it warns on the card, not just in an inspector the user has closed -- the run still succeeds, in exactly the prose the switch was flipped to prevent. A graph may still carry `config.expected_output`; nothing reads it. The Receives readout drops the prose promise for the sender's parser field names, which is also the list of things that can be referenced individually. Adding an Output Parser from the unrestricted toolbar panel left it floating: the connector "+" wires the node it creates, the toolbar did not, and a loose parser silently does nothing while looking configured. It now attaches when the host is unambiguous -- an agent asking for a format wins outright, else a canvas with one parser-less agent -- and stays loose rather than guessing between two.
An Output Parser used to cost a model call per run: the agent wrote prose,
then a separate call read the typed fields back out of it. The prompt's
shape block now names the fields *and* asks for them repeated as a fenced
JSON block at the very end of the reply, which the runtime reads for free
and strips before anything downstream sees it. The extraction call
survives only as the fallback for an answer that arrives without a block.
The template is `null` per key so it is itself valid JSON: the model can
copy it verbatim and still emit something parseable, and "my answer does
not establish this" needs no notation of its own. A contract with no
usable fields asks for no block, since `{}` reads as an instruction to
emit an empty object rather than as the absence of one.
The current-contract golden moves with it; the legacy one does not, and
must not. `_without()` in the compat test switched to contiguous-substring
removal -- it used to drop whole "\n\n"-delimited parts, which quietly
became a no-op once the shape block grew a second paragraph.
The rest is copy that was describing the old price: the parser inspector,
the catalog entry, the Parser handle's tooltip, and the legacy-contract
banner, which was also claiming a stored contract "tells the agent nothing
about what to write" -- it does now, via the same resolver.
A new Output Parser node arrives with one blank editor row, so its contract is present but declares nothing. Every consumer read that as a real contract: the prompt block correctly appended nothing, but the runtime still received it and spent a model call extracting a payload that could not have had a single key in it. `_declares_a_field` is now the shared test, and the canvas already said the same thing -- the node warns "No fields declared" -- so this is the backend agreeing with what the user was already being shown. Half-filled is still not empty: one named row among blanks stays a contract, since the prompt block and the payload model both skip the unnamed ones anyway.
An agent used to receive nothing from the step before it unless its prompt
spelled a {{...}} token. The reference was meant to keep platform-authored
text out of a treatment, but the thing it gated is the pipeline's own payload,
not prose -- so "graph looks wired, nothing flows" was a silent, legal outcome.
In a factorial batch that is a degenerate cell that still reads clean in the
results table, which is worse than the confound it avoided.
Direct main-edge predecessors now arrive because the edge is there. Only
direct ones: an ancestor further back still has to be named, and staying
explicit is the point of {{node:X}}.
What arrives is structure and nothing else -- a [Sender] label and the fence.
Deleted: {{audience}}, {{upstream_instructions}}, both _UPSTREAM_INSTRUCTIONS
framings, upstream_kind, _node_audience and _chain_steps. Whether a
predecessor's output is material to work on or direction to follow is the
experimenter's design, not a fact about the topology; needing two opposed
framings selected by an argument was the tell, and the handoff wording was
actively wrong on any Planner -> Reporter chain, which is why the supervisor
path had to opt out of it. Delimiters assert nothing and are constant across
treatments, so unlike a sentence they are not a confound. fence_upstream still
neutralizes a forged delimiter -- a soft instruction was dropped, not a
boundary.
A prompt that places a predecessor by hand suppresses that sender's automatic
block, per-sender: _sender_block makes the automatic and explicit renderings
byte-identical, which is what makes the suppression lossless. A field-only
reference does not suppress -- one extracted number is not the answer it came
from. {{previous}} suppresses every direct predecessor, since that is what it
expands to.
The legacy contract is untouched: _upstream_context_legacy takes exclude_ids
and ignores it, and spinal_fte_legacy.txt is byte-identical.
Frontend: the "no reference" edge marker is gone (the state can no longer
occur), Receives marks a referenced sender "inline" instead of "not
referenced", and the picker no longer offers the two withdrawn prose tokens.
Ids are stored and labels are only ever displayed -- an invariant the
inspector's editor already upheld through `toDisplayPrompt`. The canvas
node card's summary line did not, so a prompt referencing another node
read as `{{node:demob-profiler}}` there: a raw internal id, which looks
like a substitution that failed rather than a reference that works.
`toDisplayPromptWith` is `toDisplayPrompt` against a lookup instead of a
prebuilt names object, because the card resolves its prompt on every
React Flow store tick -- including every pointer move of a drag -- and
building a whole-graph names map per node per tick is O(nodes) work for
the O(refs) answer it needs. The common case (no references at all)
costs one `replace` that matches nothing.
An experiment with no factors generates no cells, and every remaining
run affordance was cell-shaped: pick a replicate, or run the pending
batch, both in the Runs tab. So a canvas with no design had no way to be
run at all from the GUI, which is precisely the canvas someone is most
likely to be trying out.
Deliberately narrower than the button that used to be here: no replicate
dropdown. Running a cell keeps its one home in the Runs tab -- giving it
two that can disagree is what got the old button removed. This one is
only the un-substituted whole-graph run `POST /protocols/{id}/runs`
already does with no `replicate_label`.
Not disabled by `experimentLocked` either: locking freezes the design,
which is when you want to collect data, and the backend agrees -- the
lock guards sit on update/publish/delete, never on creating a run.
…case `run_model_script` reports `accuracy`; an experimenter declaring that outcome writes "Accuracy", because a metric name is a label they read, not a telemetry key. A declared custom metric is looked up by its exact name everywhere downstream, so the Results table showed an empty `Accuracy` column beside a populated `accuracy` one -- the same number, twice, once blank. Reconciled once at promotion time rather than at each reader, so the stored `metric_values` end up in the declared spelling and Results, the CSV export and `factorial_analysis` all keep reading a plain dict. Only case. `roc_auc` does not become a metric declared as "AUC": that is a mapping the experimenter has to state, not one a casefold can infer. An exact key already present wins, so a design declaring both spellings loses nothing.
A Script node's whole config is the bindable field, so a level is a
complete `{name, language, code}` -- and none of the dict-slug priority
keys matched it, which dropped every level through to `cfg-<hash>`. A
two-level classifier factor then produced two cell labels that said
nothing about the treatment they name.
`name` goes last of the identifying keys because it is the most generic:
a config carrying both it and a specific key keeps the specific one.
The frontend mirror of that list was also missing `dataset_name`, which
it has needed since dataset configs became bindable -- without it every
level of a dataset factor displayed as its `enabled` flag, "true", in
the Cells table. Both keys added, so the label and the column agree.
…rent one
The version number is an internal fact: read-only, unchangeable, and
identical for every experiment anyone creates from now on. Presented as a
Design field it reads as a setting, and answers a question nobody with a
new experiment is asking.
It stays on a legacy experiment, where it is the only signal anywhere in
the app that this one behaves differently -- upstream output arrives
whether the prompt asked for it or not, so a {{reference}} typed there
does not do what the same reference does on a newer experiment. Deleting
it outright would leave that difference with no explanation on screen at
all.
Shown as a notice rather than a labelled field, and without the bare
"v1", since there is nothing to set and the number names nothing the
sentence beneath it doesn't say better.
The two-contract split existed to keep the published spinal experiment on byte-for-byte the prompt text it was submitted with, while the handoff design evolved under a second format. That's no longer needed: the spinal use case is the only legacy experiment, its original prompts survive in its stored results, and the paper can be reproduced from the version it was run under. So there is now one assembly path. Gone: services/prompt_contract.py, the _UPSTREAM_CONTEXT_BUILDERS dispatch and _upstream_context_legacy, the prompt_contract_version parameters on _build_user_input/_build_system_prompt and the contract != LEGACY guards behind them, the contract_version threading through run_protocol / the gated-worker loop / the supervisor messenger, the creation-time stamping in services/experiments.py, the PATCH-time preservation in api/experiments.py, PromptPreviewResponse.contract_version, and the frontend lib/promptContract.ts plus the Design tab's "Older prompt format" notice and the preview panel's contract badge. validate_prompt_references and preview_node_prompt lose their design_spec parameter along with the legacy skip, since that was its only reader. The two goldens collapse to one: fixtures/prompts/spinal_fte.txt. It is now a snapshot rather than a contract -- a rerun assembles the current format -- so tests/test_spinal_compat.py says so, and the graph/gate/dataset assertions around it stay contracts. tests/test_prompt_contract.py is renamed test_prompt_assembly.py, keeping every test of how a prompt is actually built and dropping only the version-resolution and legacy-freeze ones. Existing rows keep a harmless prompt_contract_version key in design_spec; no migration needed since nothing reads it any more.
`align_to_declared_metrics` could only reconcile case, so a declaration was stuck being named for the tool rather than for the reader: "Accuracy" could claim `accuracy`, but nothing could claim `roc_auc` without literally being called "roc_auc". A declaration carrying an explicit `catalogKey` now claims that extraction key directly, which is the experimenter stating the mapping rather than a casefold inferring one. The casefold fallback is unchanged, so "AUC" still does not capture `roc_auc`. Reachable today only from a hand-written SDK/notebook design_spec. The obvious next step -- putting the five `run_model_script` scores in the GUI's metric picker, since they are hardcoded in `extract_score_metrics` and so are every bit as real as the runtime telemetry already there -- was written and then pulled back out: those five only exist for a binary target, because extraction reads `metrics_at_chosen_threshold` and only `binary_bundle` produces it. A multiclass run reports macro_f1/macro_roc_auc_ovr/ macro_average_precision instead and promotes nothing, so the picker would have advertised five columns that silently stay empty. Both catalogs record that, so it reads as sequenced work rather than an oversight; teaching extraction the multiclass bundle comes first.
…ript Peer Collaboration and Supervisor are the two strategies that run a canvas as a conversation rather than a pipeline walk, and they're the newest; the tag sets expectations before someone spends a cell batch on one. It rides on COORDINATION_STRATEGY_CATALOG's `label`, the same place "(default)" already rides for Sequential, so it appears in all three things that render that string -- the picker, the canvas-mismatch warning, and the switch-confirm dialog -- instead of only the picker. The prose that names the strategies stays unqualified (DesignTab's info tooltip, and protocol_execution's "switch the strategy to 'Peer Collaboration'" messages): those name the strategy, they don't quote the menu item, and threading the tag through them reads as noise. The canvas overlay header goes from "Agent conversation" to "Agent conversation flow" -- what it shows is the ordered flow of messages between agents, and the longer name says so without implying you can talk to it.
`services/agent_messenger.py` imports `AgentReply` from `motoro.engine.ports`, which does not exist in v0.5.2 -- the pin this replaces. Every local run of the suite has passed only because `PYTHONPATH` pointed at a Motoro working tree, so the pin has been a fiction since the messenger landed; anything installing from this lockfile would have failed at import. v0.6.0 is that work released (motoro#5), so the lock now resolves to the tagged commit rather than to whatever is checked out next door. Verified with the shadow removed: 769 passed.
There was no `.github` directory at all, so nothing verified a pull request: the only gate was whatever the author happened to run locally, which is also unreproducible by a reviewer. The pin bump this follows is a good example of what that misses -- the suite passed locally for weeks only because `PYTHONPATH` shadowed the pinned dependency. Two parallel jobs, backend and frontend, and the backend one runs the whole suite rather than a fast subset: 769 tests take ~13s, so there is nothing to gain by tiering them, and no service container is needed because nothing in tests/ touches a database. Deliberately not included, each for a stated reason in the file: a `ruff format --check` step (this repo is knowingly not format-clean), a blocking mypy step (8 pre-existing errors), and a postgres service with an Alembic upgrade -- that last one is a real gap, but standing up the first CI and covering migrations are separate changes.
The first version of this workflow ran no database, on my reading that tests/ held only self-contained unit tests. That was wrong: 113 of the 769 tests connect to a real Postgres and failed with ECONNREFUSED. They pass locally only because the dev-stack container is already up. `tests/test_protocols.py`'s own docstring says why it has to be real -- JSONB has no sqlite equivalent and this codebase never mocks the database -- so the fix is a service container, not a rewrite. Adds pgvector/pgvector:pg16 (core's schema declares a vector column), creates the second database the service container cannot, and runs both migration chains in the order `asaree.migrations` documents: core's `deploy` first, so architectural_patterns is populated and not merely present, then ASAREE's `upgrade`.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
56 commits, 88 files, +15409/−800 — four related threads, not a grab bag.
services/agent_messenger.py(new, +1151) implements Motoro'sAgentMessengerPort: peers derived from canvas topology, sender taken from the turn stack, pause-aware budgets, conversation persisted (migrationba2c3d4e5f60) and shown live on the canvas. Adds Peer Collaboration and Supervisor strategies, both(beta);Sequentialnow means an actual chain.8ad3773,912e8bc,9aac1f2) and then removed (6ffb597) in favour of one format; the goldens it added remain.Also adds the repo's first CI, so this PR verifies itself — everything before it landed with no automated checks.
Dependency: motoro pin
v0.5.2 → v0.6.0. Not optional —agent_messenger.pyimportsAgentReplyfrommotoro.engine.ports, absent in v0.5.2. Local runs only ever passed becausePYTHONPATHshadowed the pin, so anything installing from the old lockfile would have failed at import.Verified with that shadow removed, against the locked v0.6.0: 769 tests pass (13s), ruff clean,
uv lock --checkin sync,tsc -bclean, oxlint 9 (unchanged baseline).test_spinal_compat.pypins the published spinal protocol against a captured graph so this refactor can't silently change it.Not covered: canvas rendering unverified in a browser.