From d1b43613d6ffdc29ef9ddac3b7fe10380cc58d02 Mon Sep 17 00:00:00 2001
From: Jay Moran
Date: Tue, 8 Sep 2026 15:47:28 -0700
Subject: [PATCH 01/57] Stop requiring regeneration for cells with no declared
design
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.
---
src/asaree/services/design_generation.py | 13 ++++++-
tests/test_design_revisions.py | 44 ++++++++++++++++++++++++
2 files changed, 56 insertions(+), 1 deletion(-)
diff --git a/src/asaree/services/design_generation.py b/src/asaree/services/design_generation.py
index 663f36a..3c18553 100644
--- a/src/asaree/services/design_generation.py
+++ b/src/asaree/services/design_generation.py
@@ -206,9 +206,20 @@ async def get_design_impact(
current_cell_keys = {
_cell_key(replicate.factor_values, replicate.replicate_label) for replicate in current_replicates
}
+ # The label comparison only means something when a factorial matrix is
+ # actually declared. An experiment that never declared one but has cells is
+ # the supported notebook/SDK flow -- cells PUT straight through
+ # ``upsert_replicate`` onto the revision ``get_or_create_current`` opens for
+ # them (see services/design_revisions.py's module docstring). There is
+ # nothing planned to compare those labels against, so requiring a
+ # regeneration would permanently block "run all cells" on an experiment
+ # that has no design to regenerate. Genuine drift -- including a design
+ # whose final factor was removed without regenerating -- still trips the
+ # spec comparison on the left.
+ drifted_labels = bool(material["factors"]) and current_labels != planned_labels
return DesignImpact(
has_generated_design=True,
- regeneration_required=material_design_spec(current.design_spec) != material or current_labels != planned_labels,
+ regeneration_required=material_design_spec(current.design_spec) != material or drifted_labels,
current_cell_count=len(current_cell_keys),
proposed_cell_count=len(planned_cell_keys),
added_cell_count=len(planned_cell_keys - current_cell_keys),
diff --git a/tests/test_design_revisions.py b/tests/test_design_revisions.py
index a5e65fc..21f85d8 100644
--- a/tests/test_design_revisions.py
+++ b/tests/test_design_revisions.py
@@ -191,6 +191,43 @@ async def test_design_impact_previews_an_expansion_before_regeneration(experimen
assert (impact.added_replicate_count, impact.retained_replicate_count, impact.removed_replicate_count) == (4, 2, 0)
+async def test_cells_without_a_declared_design_never_require_regeneration(experiment_id: uuid.UUID) -> None:
+ """The notebook/SDK flow: cells PUT straight through upsert_replicate onto
+ the revision get_or_create_current opens for them, with no design_spec ever
+ declared (see services/design_revisions.py's module docstring).
+
+ Nothing is planned, so there are no labels to compare against and no design
+ to regenerate. Reporting regeneration_required here permanently 422'd
+ "run all cells" on every notebook-driven experiment."""
+ async with get_session() as db:
+ for label in ("cell-1", "cell-2"):
+ await upsert_replicate(db, experiment_id=experiment_id, replicate_label=label, fields={"factor_values": {}})
+
+ async with get_session() as db:
+ impact = await get_design_impact(db, experiment_id=experiment_id, design_spec=None)
+
+ assert impact.regeneration_required is False
+ assert (impact.current_replicate_count, impact.proposed_replicate_count) == (2, 0)
+
+
+async def test_removing_the_final_factor_without_regenerating_still_requires_regeneration(
+ experiment_id: uuid.UUID,
+) -> None:
+ """The case the empty-factors allowance must not swallow: a design that
+ *had* factors and no longer declares them has genuinely drifted from its
+ materialized cells, and the revision's own recorded spec is what proves it."""
+ async with get_session() as db:
+ await generate_design_cells(
+ db, experiment_id=experiment_id, factors=_TWO_BY_ONE, design_spec={"factors": _TWO_BY_ONE}
+ )
+
+ async with get_session() as db:
+ impact = await get_design_impact(db, experiment_id=experiment_id, design_spec={"factors": []})
+
+ assert impact.regeneration_required is True
+ assert (impact.current_replicate_count, impact.proposed_replicate_count) == (2, 0)
+
+
async def test_design_counts_cells_separately_from_replicates(experiment_id: uuid.UUID) -> None:
async with get_session() as db:
await generate_design_cells(
@@ -313,9 +350,16 @@ async def test_revision_numbers_are_never_reused(experiment_id: uuid.UUID) -> No
def _cells_of(experiment_id: uuid.UUID): # type: ignore[no-untyped-def]
from sqlalchemy import select
+ from sqlalchemy.orm import contains_eager
+ # contains_eager, not a bare join: every interesting attribute of a
+ # replicate (design_revision_id, experiment_id, factor_values) is a property
+ # that reads through .cell, and a lazy load can't fire from sync property
+ # access under asyncio -- it raises MissingGreenlet. The join is already
+ # there; this just tells the ORM to populate the relationship from it.
return (
select(FactorialReplicateResult)
.join(FactorialReplicateResult.cell)
+ .options(contains_eager(FactorialReplicateResult.cell))
.where(FactorialCell.experiment_id == experiment_id)
)
From 12799b1c991168c89564ce7549154644408577d6 Mon Sep 17 00:00:00 2001
From: Jay Moran
Date: Tue, 8 Sep 2026 15:49:28 -0700
Subject: [PATCH 02/57] Derive peer agent cards and mediate consultations
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.
---
...c3d4e5f60_add_protocol_run_conversation.py | 27 ++
src/asaree/models/protocol_run.py | 11 +
src/asaree/services/agent_cards.py | 132 ++++++
src/asaree/services/agent_messenger.py | 445 ++++++++++++++++++
src/asaree/services/deadline.py | 118 +++++
src/asaree/services/protocol_execution.py | 252 +++++++++-
src/asaree/services/protocol_runs.py | 27 +-
tests/test_agent_cards.py | 236 ++++++++++
tests/test_agent_messenger.py | 404 ++++++++++++++++
9 files changed, 1641 insertions(+), 11 deletions(-)
create mode 100644 src/asaree/migrations/versions/ba2c3d4e5f60_add_protocol_run_conversation.py
create mode 100644 src/asaree/services/agent_cards.py
create mode 100644 src/asaree/services/agent_messenger.py
create mode 100644 src/asaree/services/deadline.py
create mode 100644 tests/test_agent_cards.py
create mode 100644 tests/test_agent_messenger.py
diff --git a/src/asaree/migrations/versions/ba2c3d4e5f60_add_protocol_run_conversation.py b/src/asaree/migrations/versions/ba2c3d4e5f60_add_protocol_run_conversation.py
new file mode 100644
index 0000000..1a7f42a
--- /dev/null
+++ b/src/asaree/migrations/versions/ba2c3d4e5f60_add_protocol_run_conversation.py
@@ -0,0 +1,27 @@
+"""add persisted agent-to-agent conversation transcript
+
+Revision ID: ba2c3d4e5f60
+Revises: b8c9d0e1f2a3
+Create Date: 2026-09-08 00:00:00.000000
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+from sqlalchemy.dialects import postgresql
+
+revision: str = "ba2c3d4e5f60"
+down_revision: str | None = "b8c9d0e1f2a3"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ op.add_column("protocol_runs", sa.Column("conversation", postgresql.JSONB(), nullable=True), if_not_exists=True)
+
+
+def downgrade() -> None:
+ op.drop_column("protocol_runs", "conversation", if_exists=True)
diff --git a/src/asaree/models/protocol_run.py b/src/asaree/models/protocol_run.py
index 7f99ef4..56e6317 100644
--- a/src/asaree/models/protocol_run.py
+++ b/src/asaree/models/protocol_run.py
@@ -48,6 +48,17 @@ class ProtocolRun(Base, TimestampMixin):
# erasing the scores/evaluation state a user may inspect on an older run.
# Shape: {"metric_values": {...}, "metric_evaluation": {...}}.
attempt_result: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True)
+ # The agent-to-agent transcript, present only once a run's agents actually
+ # consult each other -- null for every single-agent and pipeline run.
+ # Shape: {"state": , "messages": [{"message_id", "sequence",
+ # "from_agent_id", "to_agent_id", "parts": [...], "created_at"}, ...]}
+ # Kept alongside node_runs and for the same reason: it is an append-only,
+ # run-scoped document that only the polling endpoint and the canvas read,
+ # and both read it whole. A messages table would add joins without
+ # supporting a query the product needs. `sequence` is stored rather than
+ # inferred from list position so an entry keeps its identity if the
+ # document is ever paged or filtered.
+ conversation: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True)
# Protocol-level failure (e.g. a cycle rejected at validation time, or an
# unhandled executor exception) -- distinct from any one node's own error
# already recorded inside node_runs.
diff --git a/src/asaree/services/agent_cards.py b/src/asaree/services/agent_cards.py
new file mode 100644
index 0000000..23dac18
--- /dev/null
+++ b/src/asaree/services/agent_cards.py
@@ -0,0 +1,132 @@
+"""The AgentCard: what one Agent node looks like to another.
+
+An AgentCard answers "who is this agent and what is it for", in a form another
+agent can read and act on. A2A defines it as the capability-discovery document;
+here it plays the same role, minus the network -- see
+``local_files/agent-communication/01-agent-card.md`` and ``06-a2a-alignment.md``.
+
+**It is derived, never stored.** There is no card table, no card column and no
+card cache. A card is a pure projection of an Agent node plus its connectors,
+computed at run start by the same pass that already computes ``model_config`` /
+``tool_config`` / ``skill_config`` / ``pattern_config`` from the graph. That is
+precisely what makes it dynamic: wiring a Skill node takes effect on the next run
+today with nothing to invalidate, and the card inherits that for free. If you
+find yourself adding a place to persist one, the staleness problem comes back.
+
+This module owns the card's *shape* only. The graph reading that feeds it lives
+in :mod:`asaree.services.protocol_execution`, next to every other ``_resolve_*``
+-- which is also what keeps the import one-directional.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from typing import Any
+
+
+@dataclass(frozen=True)
+class AgentSkill:
+ """One thing this agent can do. A2A's ``AgentCard.skills`` entry.
+
+ ``id`` is a slug of the name rather than the registered skill's UUID: it
+ only has to be unique within one card, and the reader is a language model
+ for which a UUID is noise. Nothing dereferences it -- a peer cannot load
+ another agent's skill.
+ """
+
+ id: str
+ name: str
+ description: str
+
+ def to_dict(self) -> dict[str, str]:
+ return {"id": self.id, "name": self.name, "description": self.description}
+
+
+@dataclass(frozen=True)
+class AgentCard:
+ """A2A-shaped capability descriptor for one Agent node.
+
+ ``agent_id`` is the **canvas node id** -- never the Motoro ``Agent.id``, and
+ never the label. The node id is what graph connectivity authorizes against,
+ what the transcript stores, and what stays stable across re-syncs of the
+ durable Motoro agent; the Motoro agent's UUID is an implementation detail of
+ one execution and must not leak into the card.
+
+ Deliberately absent: tools and MCP servers (a peer that knows which servers
+ another agent can reach will ask it to proxy a call -- out of scope, and a
+ permission hole), the system prompt (user-authored, long, and mostly
+ meaningless to another agent), and endpoint/auth/security schemes (A2A
+ carries those because its peers are remote and untrusted; in-process peers
+ in one graph owned by one user have no endpoint to publish).
+ """
+
+ agent_id: str
+ name: str
+ description: str
+ skills: list[AgentSkill] = field(default_factory=list)
+ metadata: dict[str, Any] = field(default_factory=dict)
+
+ def to_dict(self) -> dict[str, Any]:
+ """The wire shape Motoro's ``available_agents`` consumes.
+
+ Plain JSON-able dicts rather than the dataclass itself, because this
+ crosses into core -- which must not learn an ASAREE type -- and is
+ snapshotted into ``RunContext`` for resume.
+ """
+ return {
+ "agent_id": self.agent_id,
+ "name": self.name,
+ "description": self.description,
+ "skills": [s.to_dict() for s in self.skills],
+ "metadata": dict(self.metadata),
+ }
+
+
+def _skill_slug(name: str) -> str:
+ return "".join(c if c.isalnum() else "-" for c in name.strip().lower()).strip("-") or "skill"
+
+
+def build_agent_card(
+ *,
+ node_id: str,
+ label: str | None,
+ description: str,
+ goal: str,
+ skills: list[dict[str, Any]],
+ model: str | None,
+ metadata: dict[str, Any] | None = None,
+) -> AgentCard:
+ """Assemble a card from values the caller has already resolved.
+
+ Pure on purpose: every input here is something ``_run_agent_node`` reads out
+ of the graph anyway, so the card cannot disagree with the agent that
+ actually runs.
+
+ *description* falls back to *goal* and then to a generated default, because
+ the description is the single highest-leverage field for making a peer's
+ model choose well -- it becomes the consultation function's description in
+ ``motoro.engine.agent_channel``. An empty one is worse than a generic one.
+ """
+ name = (label or "").strip() or node_id
+ text = (description or "").strip() or (goal or "").strip() or f"An agent named {name}."
+ card_metadata: dict[str, Any] = dict(metadata or {})
+ if model:
+ card_metadata["model"] = model
+ return AgentCard(
+ agent_id=node_id,
+ name=name,
+ description=text,
+ skills=[
+ AgentSkill(
+ id=_skill_slug(str(s.get("name") or "")),
+ name=str(s.get("name") or ""),
+ description=str(s.get("description") or ""),
+ )
+ for s in skills
+ if s.get("name")
+ ],
+ metadata=card_metadata,
+ )
+
+
+__all__ = ["AgentCard", "AgentSkill", "build_agent_card"]
diff --git a/src/asaree/services/agent_messenger.py b/src/asaree/services/agent_messenger.py
new file mode 100644
index 0000000..8012086
--- /dev/null
+++ b/src/asaree/services/agent_messenger.py
@@ -0,0 +1,445 @@
+"""Delivery of one agent's question to a connected peer, and the run that hosts it.
+
+Motoro's :class:`~motoro.engine.ports.AgentMessengerPort` is deliberately one
+method wide: the engine projects the peers a caller declared into callable
+function schemas and hands any resulting call straight back here. Everything
+that decides *whether* the call happens lives in this module -- authorization,
+message identity and ordering, the transcript, the budget, and cancellation.
+The engine never learns what an ASAREE canvas is.
+
+**The reply is a call result, not an exception.** A peer that may not be
+reached, a spent budget and a cancelled conversation all come back as an
+:class:`~motoro.engine.ports.AgentReply` with a state the calling model can
+read, so it absorbs the outcome and still writes a real answer. Only genuine
+infrastructure failure raises.
+
+**Capability is snapshotted; reachability is live.** A peer's card describes it
+as the published revision configures it, so a canvas edit cannot hot-patch a
+run's agents mid-flight. Authorization is re-asked of the *draft* graph on every
+single consultation, so pulling the edge on the canvas stops the next
+consultation immediately. Two different questions, deliberately reading two
+different graphs.
+
+This module imports the executor, never the reverse. A pipeline run knows
+nothing about conversations, which is what keeps invariant 11 (single-agent runs
+are byte-for-byte unaffected) structural rather than a promise.
+"""
+
+from __future__ import annotations
+
+import logging
+import time
+import uuid
+from datetime import UTC, datetime, timedelta
+from typing import Any
+
+from motoro.engine.ports import AgentReply
+
+from asaree.models.database import get_session
+from asaree.services.deadline import deadlines_paused
+from asaree.services.experiments import get_experiment
+from asaree.services.protocol_execution import (
+ _AGENT_CANCELLED,
+ _ambient_meta_for,
+ _can_deliver_communication,
+ _compute_workspace_id,
+ _run_agent_node,
+ resolve_available_agents,
+)
+from asaree.services.protocol_revisions import get_revision
+from asaree.services.protocol_runs import get_protocol_run, set_status, update_conversation, update_node_run
+from asaree.services.protocols import get_protocol
+
+logger = logging.getLogger(__name__)
+
+#: Total peer turns allowed per protocol run, across the whole conversation
+#: tree. Every one of these is a full agent run with its own Reason/Plan/Act
+#: cycle and its own tokens, so this is a cost cap as much as a loop guard.
+_MAX_PEER_EXECUTIONS = 8
+
+#: The real backstop. Deliberately **not** extended by peer time the way an
+#: individual agent's own deadline is (see :mod:`asaree.services.deadline`):
+#: no agent is charged for delegating, but the total stays bounded.
+_MAX_CONVERSATION_DURATION = timedelta(minutes=5)
+
+#: How deep consultations may nest. Two is enough for "Planner asks Critic,
+#: Critic asks a clarifying question back" -- the shape this feature exists for
+#: -- without letting a chain of agents each delegate one level further.
+_MAX_CONSULT_DEPTH = 2
+
+#: The transcript's stand-in sender for the human who started the conversation.
+#: Not a node id, and deliberately not a valid one: nothing can address it, and
+#: authorization would refuse if anything tried.
+USER_PARTICIPANT = "user"
+
+
+def _text_of(parts: list[dict[str, Any]]) -> str:
+ return "\n".join(str(p.get("text", "")) for p in parts if p.get("kind") == "text").strip()
+
+
+class AgentMessenger:
+ """One per protocol run. Owns that run's whole agent-to-agent surface.
+
+ Holds the conversation document in memory as the authoritative copy and
+ checkpoints it to ``ProtocolRun.conversation`` around every peer execution,
+ which is what lets a worker retry see exactly how far it got.
+
+ Not concurrency-safe, by design: invariant 5 is that one agent executes at a
+ time and a consulting agent blocks on its peer's reply, so ``sequence`` and
+ the budget counters are only ever touched from a single logical call stack.
+ """
+
+ def __init__(
+ self,
+ *,
+ protocol_id: uuid.UUID,
+ protocol_run_id: uuid.UUID,
+ owner_id: uuid.UUID,
+ graph: dict[str, Any],
+ entry_agent_id: str,
+ workspace_id: str | None = None,
+ ) -> None:
+ #: The *revision* graph -- capability: who each peer is and how it runs.
+ #: Authorization reads the live draft graph instead, on every call.
+ self._graph = graph
+ self._protocol_id = protocol_id
+ self._protocol_run_id = protocol_run_id
+ self._owner_id = owner_id
+ self._workspace_id = workspace_id
+ self._entry_agent_id = entry_agent_id
+ self._started_at = time.monotonic()
+ self._executions = 0
+ self._depth = 0
+ self._sequence = 0
+ self._messages: list[dict[str, Any]] = []
+ self._state = "working"
+ #: Set when a cap is what stopped the conversation, so the run can land
+ #: on ``limit_reached`` rather than looking like a clean completion.
+ self.limit_reached = False
+
+ # -- transcript ----------------------------------------------------
+
+ @property
+ def conversation(self) -> dict[str, Any]:
+ """The transcript as stored: one JSON document, read whole."""
+ return {"state": self._state, "entry_agent_id": self._entry_agent_id, "messages": list(self._messages)}
+
+ def set_state(self, state: str) -> None:
+ self._state = state
+
+ def append(
+ self,
+ *,
+ from_agent_id: str,
+ to_agent_id: str,
+ parts: list[dict[str, Any]],
+ state: str | None = None,
+ ) -> dict[str, Any]:
+ """Record one message with a runtime-assigned id and ``sequence``.
+
+ Invariant 2: identity and ordering are assigned here, never taken from
+ model output. ``state`` is set on replies only -- a request has no
+ outcome of its own yet.
+ """
+ self._sequence += 1
+ message = {
+ "message_id": str(uuid.uuid4()),
+ "sequence": self._sequence,
+ "from_agent_id": from_agent_id,
+ "to_agent_id": to_agent_id,
+ "parts": parts,
+ "created_at": datetime.now(UTC).isoformat(),
+ **({"state": state} if state is not None else {}),
+ }
+ self._messages.append(message)
+ return message
+
+ async def checkpoint(self) -> None:
+ async with get_session() as db:
+ await update_conversation(db, self._protocol_run_id, self.conversation)
+
+ # -- delivery ------------------------------------------------------
+
+ async def send(
+ self,
+ *,
+ from_agent_id: str,
+ to_agent_id: str,
+ parts: list[dict[str, Any]],
+ context: Any = None,
+ ) -> AgentReply:
+ """Deliver one question to a peer and return its reply.
+
+ The request is recorded *before* any refusal check runs, so a rejected
+ consultation still appears in the transcript (invariant 10) -- a
+ silently dropped question is exactly the failure this makes debuggable.
+
+ ``context`` is the engine's ``RunContext``. Unused: everything this
+ needs is per-protocol-run state held on the instance, and reading run
+ state out of the engine's context would be a second source of truth.
+ """
+ self.append(from_agent_id=from_agent_id, to_agent_id=to_agent_id, parts=parts)
+
+ refusal = await self._refusal(from_agent_id, to_agent_id)
+ if refusal is not None:
+ logger.info("consultation refused (%s -> %s): %s", from_agent_id, to_agent_id, refusal)
+ return await self._reply(to_agent_id, from_agent_id, refusal, state="rejected")
+
+ await self.checkpoint()
+
+ self._executions += 1
+ self._depth += 1
+ try:
+ # The caller's own clock stops for exactly this span, failures
+ # included: it waited either way, and charging it for a peer's
+ # failure is the same unfairness as charging it for a peer's
+ # success. The conversation-level cap above keeps ticking.
+ with deadlines_paused():
+ output_text, error, run_id = await self._run_peer(to_agent_id, parts)
+ finally:
+ self._depth -= 1
+
+ if error == _AGENT_CANCELLED:
+ return await self._reply(
+ to_agent_id, from_agent_id, "The consultation was cancelled.", state="canceled", task_id=run_id
+ )
+ if error is not None:
+ return await self._reply(
+ to_agent_id,
+ from_agent_id,
+ f"The agent could not answer: {error}",
+ state="failed",
+ task_id=run_id,
+ error=error,
+ )
+ text = (output_text or "").strip()
+ return await self._reply(
+ to_agent_id,
+ from_agent_id,
+ text or "The agent finished without producing an answer.",
+ state="completed",
+ task_id=run_id,
+ )
+
+ async def _reply(
+ self,
+ from_agent_id: str,
+ to_agent_id: str,
+ text: str,
+ *,
+ state: str,
+ task_id: str | None = None,
+ error: str | None = None,
+ ) -> AgentReply:
+ parts = [{"kind": "text", "text": text}]
+ self.append(from_agent_id=from_agent_id, to_agent_id=to_agent_id, parts=parts, state=state)
+ await self.checkpoint()
+ return AgentReply(state=state, parts=parts, task_id=task_id, error=error)
+
+ async def _refusal(self, from_agent_id: str, to_agent_id: str) -> str | None:
+ """The reason this consultation may not proceed, or ``None``.
+
+ Every branch returns prose addressed to the calling *model*, because
+ that is who reads it: it has to be able to tell "you may not ask this
+ agent" from "you have asked enough" and choose differently, so a bare
+ "rejected" would be worse than useless.
+ """
+ if self._depth >= _MAX_CONSULT_DEPTH:
+ self.limit_reached = True
+ return (
+ f"Consultations are already nested {_MAX_CONSULT_DEPTH} deep, which is the limit. "
+ "Answer with what you have rather than delegating further."
+ )
+ if self._executions >= _MAX_PEER_EXECUTIONS:
+ self.limit_reached = True
+ return (
+ f"This conversation has used all {_MAX_PEER_EXECUTIONS} of its peer consultations. "
+ "Answer with what you already have."
+ )
+ if time.monotonic() - self._started_at >= _MAX_CONVERSATION_DURATION.total_seconds():
+ self.limit_reached = True
+ return (
+ f"This conversation has run for its full {int(_MAX_CONVERSATION_DURATION.total_seconds())} seconds. "
+ "Answer with what you already have."
+ )
+
+ async with get_session() as db:
+ run = await get_protocol_run(db, self._protocol_run_id)
+ if run is not None and run.cancel_requested_at is not None:
+ # Invariant 8: a Stop seen between consultations stops further
+ # ones. An already in-flight peer run is separately interrupted
+ # by _execute_run_cancellable's own poller.
+ return "This run was cancelled, so the consultation was not delivered."
+ protocol = await get_protocol(db, self._protocol_id)
+
+ # The live draft graph, not the revision this run's agents came from.
+ live_graph = protocol.graph if protocol is not None else self._graph
+ if not _can_deliver_communication(live_graph, from_agent_id, to_agent_id):
+ return (
+ "That agent is not connected to you on the canvas, so it cannot be consulted. "
+ "Answer using the agents listed for you, or with what you already have."
+ )
+ return None
+
+ async def _run_peer(
+ self, to_agent_id: str, parts: list[dict[str, Any]]
+ ) -> tuple[str | None, str | None, str | None]:
+ """Give the peer its own full turn.
+
+ A real nested agent run, not a prompt trick: its own Motoro ``AgentRun``,
+ so cost, steps and the Runs tab attribute it separately, and its own
+ ``available_agents`` so it may consult back within the depth cap.
+ """
+ node = next((n for n in self._graph.get("nodes") or [] if str(n.get("id")) == to_agent_id), None)
+ if node is None:
+ # Only reachable if the live graph and the revision disagree about
+ # whether a node exists, which authorization above cannot catch.
+ return None, "the agent no longer exists in this protocol revision", None
+
+ async with get_session() as db:
+ await update_node_run(db, self._protocol_run_id, to_agent_id, {"status": "running"})
+
+ output_text, error, run_id = await _run_agent_node(
+ node,
+ protocol_id=self._protocol_id,
+ protocol_run_id=self._protocol_run_id,
+ owner_id=self._owner_id,
+ user_input=_text_of(parts),
+ graph=self._graph,
+ workspace_id=self._workspace_id,
+ ambient_meta=_ambient_meta_for(self._graph, to_agent_id, self._workspace_id),
+ available_agents=await resolve_available_agents(self._graph, to_agent_id, owner_id=self._owner_id),
+ agent_messenger=self,
+ )
+ # The canvas shows a consulted peer as a node that ran, because it did.
+ # A peer consulted twice keeps only its latest turn here; the full
+ # sequence is the transcript's job, not node_runs'.
+ async with get_session() as db:
+ await update_node_run(
+ db,
+ self._protocol_run_id,
+ to_agent_id,
+ {
+ "status": "cancelled" if error == _AGENT_CANCELLED else ("failed" if error else "completed"),
+ "output_text": output_text,
+ "error": None if error == _AGENT_CANCELLED else error,
+ "run_id": str(run_id) if run_id else None,
+ },
+ )
+ return output_text, error, str(run_id) if run_id else None
+
+
+async def run_conversation(protocol_run_id: uuid.UUID, *, entry_agent_id: str, user_input: str) -> None:
+ """Execute a protocol run in conversation mode.
+
+ Deliberately *not* a turn scheduler. It starts exactly one agent -- the one
+ the user addressed -- and consultation is driven from inside that run by the
+ messenger, as a nested call. That is what makes the single-agent loop
+ unchanged: an agent finishes by writing its answer, not by choosing a
+ ``finish`` action, and the only new thing in its world is that a peer exists
+ and can be asked.
+
+ So what remains here is: seed the transcript, run the entry agent, map its
+ outcome to a terminal conversation state, checkpoint.
+ """
+ async with get_session() as db:
+ run = await get_protocol_run(db, protocol_run_id)
+ if run is None:
+ return
+ protocol = await get_protocol(db, run.protocol_id)
+ if protocol is None:
+ await set_status(db, protocol_run_id, status="failed", error="protocol no longer exists")
+ return
+ protocol_id, owner_id, graph = protocol.id, run.owner_id, protocol.graph
+ if run.protocol_revision_id is not None:
+ revision = await get_revision(db, run.protocol_revision_id)
+ if revision is None:
+ await set_status(
+ db, protocol_run_id, status="failed", error="published protocol revision no longer exists"
+ )
+ return
+ graph = revision.graph
+ experiment_id = protocol.experiment_id
+ experiment = await get_experiment(db, experiment_id) if experiment_id else None
+ evaluation_metrics = (experiment.design_spec or {}).get("metrics") if experiment is not None else None
+
+ node = next((n for n in graph.get("nodes") or [] if str(n.get("id")) == entry_agent_id), None)
+ if node is None or node.get("type") != "agent":
+ async with get_session() as db:
+ await set_status(db, protocol_run_id, status="failed", error="entry agent is not an Agent node")
+ return
+
+ workspace_id = _compute_workspace_id(experiment_id, None, protocol_run_id)
+ messenger = AgentMessenger(
+ protocol_id=protocol_id,
+ protocol_run_id=protocol_run_id,
+ owner_id=owner_id,
+ graph=graph,
+ entry_agent_id=entry_agent_id,
+ workspace_id=workspace_id,
+ )
+ messenger.append(
+ from_agent_id=USER_PARTICIPANT,
+ to_agent_id=entry_agent_id,
+ parts=[{"kind": "text", "text": user_input}],
+ )
+
+ async with get_session() as db:
+ await set_status(db, protocol_run_id, status="running")
+ await update_node_run(db, protocol_run_id, entry_agent_id, {"status": "running"})
+ await messenger.checkpoint()
+
+ output_text, error, run_id = await _run_agent_node(
+ node,
+ protocol_id=protocol_id,
+ protocol_run_id=protocol_run_id,
+ owner_id=owner_id,
+ user_input=user_input,
+ graph=graph,
+ workspace_id=workspace_id,
+ ambient_meta=_ambient_meta_for(graph, entry_agent_id, workspace_id),
+ evaluation_metrics=evaluation_metrics,
+ available_agents=await resolve_available_agents(graph, entry_agent_id, owner_id=owner_id),
+ agent_messenger=messenger,
+ )
+
+ cancelled = error == _AGENT_CANCELLED
+ if not cancelled and error is None:
+ messenger.append(
+ from_agent_id=entry_agent_id,
+ to_agent_id=USER_PARTICIPANT,
+ parts=[{"kind": "text", "text": output_text or ""}],
+ state="completed",
+ )
+ # A cap that was hit but absorbed still produced a real answer, so the
+ # conversation is `completed` -- `limit_reached` is reserved for a cap that
+ # actually stopped it. Invariant 7 in the run's own status.
+ if cancelled:
+ state, status = "canceled", "cancelled"
+ elif error is not None:
+ state, status = ("limit_reached", "limit_reached") if messenger.limit_reached else ("failed", "failed")
+ else:
+ state, status = "completed", "completed"
+ messenger.set_state(state)
+ await messenger.checkpoint()
+
+ async with get_session() as db:
+ await update_node_run(
+ db,
+ protocol_run_id,
+ entry_agent_id,
+ {
+ "status": "cancelled" if cancelled else ("failed" if error else "completed"),
+ "output_text": output_text,
+ "error": None if cancelled else error,
+ "run_id": str(run_id) if run_id else None,
+ },
+ )
+ await set_status(db, protocol_run_id, status=status, error=None if cancelled else error)
+
+
+__all__ = [
+ "USER_PARTICIPANT",
+ "AgentMessenger",
+ "run_conversation",
+]
diff --git a/src/asaree/services/deadline.py b/src/asaree/services/deadline.py
new file mode 100644
index 0000000..975ec4a
--- /dev/null
+++ b/src/asaree/services/deadline.py
@@ -0,0 +1,118 @@
+"""A wall-clock budget for one agent run that stops ticking while it waits.
+
+``asyncio.wait_for`` takes a fixed duration decided before the wait starts,
+which is the wrong shape once an agent can consult a peer: the peer's run
+executes *inside* the caller's timeout, so a peer that thinks for three minutes
+spends three minutes of a budget meant to bound the caller's own thinking. The
+caller would then time out through no fault of its own, and the more useful the
+delegation the more likely that becomes.
+
+**Each agent keeps its own wall-clock allowance.** A :class:`Deadline` is a
+mutable expiry that the run's supervisor re-reads while it waits, and
+:func:`deadlines_paused` freezes it for the span of a consultation.
+
+Pausing rather than crediting the time back afterwards is the whole point: a
+consultation longer than the caller's remaining budget would blow the deadline
+*while it was still in flight*, and no amount of repayment afterwards brings a
+run back. The clock has to stop at the moment the caller starts waiting.
+
+The active chain is a :class:`~contextvars.ContextVar` rather than an argument
+threaded through the executor, because the pause has to reach every caller above
+the peer, not just the immediate one. A depth-2 consultation blocks the depth-1
+caller *and* the entry agent, and those frames are separated by the whole Motoro
+engine -- code that must not learn what a peer is. An asyncio task copies the
+context at creation and the tuple holds the same ``Deadline`` objects by
+reference, so a nested run's pause is visible to every ancestor still waiting.
+
+This is deliberately *not* the backstop. The conversation-level wall clock in
+:mod:`asaree.services.agent_messenger` never pauses and does count peer time:
+total work stays bounded even though no individual agent is charged for
+delegating.
+"""
+
+from __future__ import annotations
+
+import time
+from collections.abc import Iterator
+from contextlib import contextmanager
+from contextvars import ContextVar
+
+#: What a paused deadline reports as remaining. Deliberately not the frozen
+#: remainder, which can be arbitrarily small -- the supervisor uses this value
+#: as its wait interval, and a near-zero one would spin.
+_PAUSED_POLL_SECONDS = 0.5
+
+
+class Deadline:
+ """A point in time a run must finish by, which can be paused.
+
+ Monotonic on purpose: a clock adjustment mid-run must not shorten or
+ lengthen an agent's allowance. Reentrant, because nested consultations pause
+ the same ancestor frames more than once and only the outermost resume may
+ restart the clock.
+ """
+
+ __slots__ = ("_expires_at", "_paused_at", "_pauses")
+
+ def __init__(self, seconds: float) -> None:
+ self._expires_at = time.monotonic() + seconds
+ self._paused_at: float | None = None
+ self._pauses = 0
+
+ def pause(self) -> None:
+ self._pauses += 1
+ if self._pauses == 1:
+ self._paused_at = time.monotonic()
+
+ def resume(self) -> None:
+ self._pauses -= 1
+ if self._pauses == 0 and self._paused_at is not None:
+ self._expires_at += time.monotonic() - self._paused_at
+ self._paused_at = None
+
+ def remaining(self) -> float:
+ if self._paused_at is not None:
+ return _PAUSED_POLL_SECONDS
+ return self._expires_at - time.monotonic()
+
+ def expired(self) -> bool:
+ return self._paused_at is None and self._expires_at <= time.monotonic()
+
+
+_ACTIVE: ContextVar[tuple[Deadline, ...]] = ContextVar("asaree_active_deadlines", default=())
+
+
+@contextmanager
+def active_deadline(deadline: Deadline) -> Iterator[Deadline]:
+ """Register *deadline* as the innermost frame of the active caller chain.
+
+ Enter this *before* creating the task that does the work, so the task's
+ copied context already contains the frame.
+ """
+ token = _ACTIVE.set((*_ACTIVE.get(), deadline))
+ try:
+ yield deadline
+ finally:
+ _ACTIVE.reset(token)
+
+
+@contextmanager
+def deadlines_paused() -> Iterator[None]:
+ """Stop the clock on every agent currently waiting, for this block's span.
+
+ Every frame, not just the innermost: a nested consultation blocks its own
+ caller and everyone above it for the same wall-clock span, so none of them
+ should be charged for it. The frames are captured on entry so an exception
+ inside cannot leave a deadline paused forever.
+ """
+ frames = _ACTIVE.get()
+ for deadline in frames:
+ deadline.pause()
+ try:
+ yield
+ finally:
+ for deadline in frames:
+ deadline.resume()
+
+
+__all__ = ["Deadline", "active_deadline", "deadlines_paused"]
diff --git a/src/asaree/services/protocol_execution.py b/src/asaree/services/protocol_execution.py
index 7b717ea..57199e1 100644
--- a/src/asaree/services/protocol_execution.py
+++ b/src/asaree/services/protocol_execution.py
@@ -36,6 +36,7 @@
from motoro.schemas.output import parse_envelope
from motoro.schemas.pattern import PatternConfig
from motoro.services.mcp_service import hydrate_registry
+from motoro.services.skill_service import resolve_skills
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
@@ -43,12 +44,14 @@
from asaree.config import get_settings
from asaree.models.database import get_session
from asaree.models.protocol_run import ProtocolRun
+from asaree.services.agent_cards import AgentCard, build_agent_card
from asaree.services.dataset_workspaces import (
WorkspaceSeedError,
fetch_owned_registration,
head_data_locator,
seed_cell_workspace,
)
+from asaree.services.deadline import Deadline, active_deadline
from asaree.services.design_generation import get_design_impact
from asaree.services.experiments import get_experiment
from asaree.services.factor_bindings import validate_factor_bindings
@@ -1164,6 +1167,122 @@ def _resolve_skill_config(graph: dict[str, Any], node_id: str) -> dict[str, Any]
return {"skill_ids": skill_ids} if skill_ids else {}
+def _is_peer_edge(edge: dict[str, Any], nodes: dict[str, dict[str, Any]]) -> bool:
+ """A main (untyped) edge joining two Agent nodes.
+
+ This is the *same* edge a normal pipeline run walks to pass one agent's
+ output to the next -- it is both things, and which one it means is decided
+ by the run mode, not by the edge. A conversation reads it as an undirected
+ "these two may consult each other"; ``run_protocol`` keeps reading it as a
+ directed data-flow. Nothing about the edge is rewritten to say so, which is
+ why a canvas built for a pipeline run needs no migration to support
+ conversation.
+
+ Connector-typed edges (``_CONNECTOR_HANDLES``) are configuration rather than
+ topology, and an Agent<->Critic Gate edge keeps its pipeline meaning, so
+ neither makes a peer.
+ """
+ if edge.get("targetHandle") in _CONNECTOR_HANDLES:
+ return False
+ source = nodes.get(str(edge.get("source")))
+ target = nodes.get(str(edge.get("target")))
+ return source is not None and target is not None and source.get("type") == target.get("type") == "agent"
+
+
+def _connected_agent_ids(graph: dict[str, Any], node_id: str) -> list[str]:
+ """Agent node ids reachable from *node_id* over a peer edge, either way.
+
+ Undirected on purpose: an edge's stored source/target records how the user
+ happened to draw it, not who is allowed to speak. Order is canvas wiring
+ order, and each peer appears once however many edges join the pair.
+ """
+ nodes = {str(n.get("id")): n for n in graph.get("nodes") or [] if n.get("id")}
+ peers: list[str] = []
+ for edge in graph.get("edges") or []:
+ if not _is_peer_edge(edge, nodes):
+ continue
+ source, target = str(edge.get("source")), str(edge.get("target"))
+ if source == node_id:
+ other = target
+ elif target == node_id:
+ other = source
+ else:
+ continue
+ if other != node_id and other not in peers:
+ peers.append(other)
+ return peers
+
+
+def _can_deliver_communication(graph: dict[str, Any], from_agent_id: str, to_agent_id: str) -> bool:
+ """Live authorization check, re-run for every consultation.
+
+ Capability is snapshotted, reachability is live: the cards an agent carries
+ come from the run's published revision, but whether it may still *reach* a
+ peer is answered against the draft ``Protocol.graph`` at call time. Pulling
+ the edge on the canvas stops the next consultation mid-run, which is the
+ behaviour a user unplugging a wire expects.
+ """
+ if from_agent_id == to_agent_id:
+ return False
+ return to_agent_id in _connected_agent_ids(graph, from_agent_id)
+
+
+async def resolve_agent_card(
+ graph: dict[str, Any],
+ node_id: str,
+ *,
+ owner_id: uuid.UUID,
+ metadata: dict[str, Any] | None = None,
+) -> AgentCard | None:
+ """Project one Agent node into the card its peers see.
+
+ ``None`` for a node that is missing or is not an Agent -- invariant 4: a
+ non-agent node is never addressable.
+
+ Reads the same graph the run executes from, so a card can never describe an
+ agent differently from how that agent is about to be configured.
+ """
+ nodes = {str(n.get("id")): n for n in graph.get("nodes") or [] if n.get("id")}
+ node = nodes.get(node_id)
+ if node is None or node.get("type") != "agent":
+ return None
+ config = (node.get("data") or {}).get("config") or {}
+ # The registered skill documents, not the ids: a peer reads names and
+ # descriptions to decide whether this agent is worth asking. Bodies are
+ # never projected -- progressive disclosure is the owning agent's business.
+ skills = await resolve_skills(_resolve_skill_config(graph, node_id), owner_id=owner_id)
+ return build_agent_card(
+ node_id=node_id,
+ label=(node.get("data") or {}).get("label"),
+ description=config.get("description") or "",
+ goal=config.get("goal") or "",
+ skills=[dict(s) for s in skills],
+ model=_resolve_llm_config(graph, node_id).get("model"),
+ metadata=metadata,
+ )
+
+
+async def resolve_available_agents(
+ graph: dict[str, Any],
+ node_id: str,
+ *,
+ owner_id: uuid.UUID,
+ metadata: dict[str, Any] | None = None,
+) -> list[dict[str, Any]]:
+ """Serialized cards for every peer connected to *node_id*, in wiring order.
+
+ ``[]`` when nothing is connected -- which is every existing single-agent and
+ pipeline run, so they carry no new field content, get no prompt section and
+ are bound no new function schemas (invariant 11).
+ """
+ cards: list[dict[str, Any]] = []
+ for peer_id in _connected_agent_ids(graph, node_id):
+ card = await resolve_agent_card(graph, peer_id, owner_id=owner_id, metadata=metadata)
+ if card is not None:
+ cards.append(card.to_dict())
+ return cards
+
+
def _resolve_pattern_config(graph: dict[str, Any], node_id: str) -> dict[str, Any]:
"""``{"execution_pattern": slug, "pattern_params": {slug: {...}}}`` from
the node's connected execution-pattern node, or ``{}`` if none is
@@ -1581,7 +1700,13 @@ async def _poll_cancel_flag(protocol_run_id: uuid.UUID, cancel_event: asyncio.Ev
async def _execute_run_cancellable(
- *, run_id: uuid.UUID, protocol_run_id: uuid.UUID, available_tools: list[dict[str, Any]], timeout: float
+ *,
+ run_id: uuid.UUID,
+ protocol_run_id: uuid.UUID,
+ available_tools: list[dict[str, Any]],
+ timeout: float,
+ available_agents: list[dict[str, Any]] | None = None,
+ agent_messenger: Any = None,
) -> None:
"""Wraps Motoro's execute_run with the poller above, scoped to
exactly this one run's lifetime -- shared by _run_agent_node and
@@ -1590,16 +1715,54 @@ async def _execute_run_cancellable(
detected, run_protocol's own between-nodes check means no later node
ever starts, so nothing else would benefit from a longer-lived poller,
and tearing this one down between nodes avoids running it during gaps
- where no agent is actually executing."""
+ where no agent is actually executing.
+
+ ``available_agents``/``agent_messenger`` travel together and are both empty
+ for a pipeline run: cards with no messenger would let an agent see peers it
+ could not reach, and a messenger with no cards would never be called.
+
+ ``timeout`` is enforced through an extendable :class:`Deadline` rather than
+ ``asyncio.wait_for``, so that time this agent spends *blocked on a peer* can
+ be handed back to it -- an agent is charged for its own thinking, never for
+ waiting. With no peers the two are indistinguishable: nothing extends the
+ deadline, and the run is cancelled at exactly ``timeout`` seconds as
+ before."""
cancel_event = asyncio.Event()
poller = asyncio.create_task(_poll_cancel_flag(protocol_run_id, cancel_event))
try:
- await asyncio.wait_for(
- execute_run(
- run_id=run_id, registry=get_registry(), available_tools=available_tools, cancel_event=cancel_event
- ),
- timeout=timeout,
- )
+ # Entered before create_task so the runner's copied context already
+ # holds this frame, which is how a nested peer run reaches back to
+ # extend it (see services.deadline).
+ with active_deadline(Deadline(timeout)) as deadline:
+ runner = asyncio.create_task(
+ execute_run(
+ run_id=run_id,
+ registry=get_registry(),
+ available_tools=available_tools,
+ cancel_event=cancel_event,
+ available_agents=available_agents or [],
+ agent_messenger=agent_messenger,
+ )
+ )
+ try:
+ while True:
+ if deadline.expired():
+ raise TimeoutError
+ # Re-read after every wait rather than waiting once for the
+ # full span: a consultation may have stopped the clock in
+ # the meantime, so what looked like the end of the budget
+ # no longer is. A paged deadline reports a poll interval
+ # instead of its frozen remainder, which is what makes this
+ # loop again rather than spin.
+ done, _ = await asyncio.wait({runner}, timeout=deadline.remaining())
+ if done:
+ await runner # re-raise whatever the run itself raised
+ return
+ finally:
+ if not runner.done():
+ runner.cancel()
+ with contextlib.suppress(asyncio.CancelledError):
+ await runner
finally:
poller.cancel()
with contextlib.suppress(asyncio.CancelledError):
@@ -1612,6 +1775,17 @@ async def _sync_durable_agent(*, name: str, owner_id: uuid.UUID, fields: dict[st
Multiple protocol runs can reach the same canvas node concurrently. The
durable Motoro Agent is keyed by owner/name, so a losing create retries as
an update of the row the concurrent request just inserted.
+
+ **Never put per-run state in ``fields``.** Every entry here must come from
+ the node's canvas config or its wired connectors -- i.e. from the protocol
+ itself, which is what this row is a projection of. Run-scoped configuration
+ belongs on the run: ``create_run`` already takes ``model_config_overrides``
+ and ``config_snapshot``. The rule needs stating because this write path
+ makes the mistake cheap to make and expensive to notice: ``update_agent``
+ reads ``None`` as "leave unchanged", so a field written once during one run
+ is never cleared by any later run and quietly contaminates every future one
+ (see ``scripts/repair_contaminated_agents.py``, which cleans up an earlier
+ design that injected a conversation contract this way).
"""
existing = await get_agent_by_name(name, owner_id=owner_id)
if existing is not None:
@@ -1636,6 +1810,8 @@ async def _run_agent_node(
workspace_id: str | None = None,
ambient_meta: dict[str, Any] | None = None,
evaluation_metrics: Any = None,
+ available_agents: list[dict[str, Any]] | None = None,
+ agent_messenger: Any = None,
) -> tuple[str | None, str | None, uuid.UUID | None]:
"""Create-or-sync the real agent and run it to completion. Returns
``(output_text, error, run_id)`` -- exactly one of output_text/error is
@@ -1643,7 +1819,16 @@ async def _run_agent_node(
populated once ``create_run`` succeeds (even on a later timeout/error),
since that's what the canvas's Output tab uses to fetch this node's own
step trace (``GET /runs/{run_id}/steps``); only ``None`` if agent
- creation/sync itself failed before a run could even be created."""
+ creation/sync itself failed before a run could even be created.
+
+ ``available_agents`` are the serialized peer cards this node may consult
+ (:func:`resolve_available_agents`) and ``agent_messenger`` is how a chosen
+ consultation is delivered. Both default to empty, which is what a pipeline
+ run passes: peers are a conversation-mode capability, so an ordinary run is
+ byte-for-byte what it was before. They are deliberately *not* folded into
+ ``_sync_durable_agent``'s fields -- the card is per-run and derived, and
+ writing it onto the durable agent row is exactly the contamination this
+ design avoids."""
config = node["data"]["config"]
# Deterministic, not config["name"]: Agent.name is unique per OWNER, not
# per protocol, so trusting the freeform (often identically-defaulted)
@@ -1740,7 +1925,12 @@ async def _run_agent_node(
timeout = agent.max_run_duration_seconds or get_settings().worker_job_timeout_seconds
try:
await _execute_run_cancellable(
- run_id=run.id, protocol_run_id=protocol_run_id, available_tools=gather_tools(agent), timeout=timeout
+ run_id=run.id,
+ protocol_run_id=protocol_run_id,
+ available_tools=gather_tools(agent),
+ timeout=timeout,
+ available_agents=available_agents,
+ agent_messenger=agent_messenger,
)
except TimeoutError:
return None, f"run exceeded its {timeout}s execution budget", run.id
@@ -2490,6 +2680,48 @@ def validate_single_node_runnable(graph: dict[str, Any], node_id: str) -> dict[s
return node
+def validate_conversation_entry(graph: dict[str, Any], node_id: str) -> dict[str, Any]:
+ """Validates an agent can host a conversation. Returns the node dict.
+
+ Scoped to the entry agent and its peers, deliberately *not* to the whole
+ graph: a conversation is a cluster of connected agents, and a half-configured
+ node in some unrelated corner of the same canvas has nothing to do with it.
+
+ Notably absent: any check on how many peer edges the *graph* has. An earlier
+ design required exactly one, which made a third agent on the canvas an error
+ rather than a third participant.
+ """
+ nodes: dict[str, dict[str, Any]] = {str(n["id"]): n for n in graph.get("nodes") or [] if n.get("id")}
+ node = nodes.get(node_id)
+ if node is None:
+ raise ProtocolValidationError(f"No such node: {node_id!r}")
+ if node.get("type") != "agent":
+ raise ProtocolValidationError("Only Agent nodes can start a conversation.")
+
+ peers = _connected_agent_ids(graph, node_id)
+ if not peers:
+ raise ProtocolValidationError(
+ f"{_node_display_name(node)!r} isn't connected to another agent, so it has nobody to talk to. "
+ "Draw an edge between two Agent nodes first."
+ )
+ # The entry agent and every peer it may consult: each needs its own model,
+ # or the consultation fails partway through a run the user already paid for.
+ for participant_id in [node_id, *peers]:
+ participant = nodes[participant_id]
+ llm_edges = _edges_with_handle(graph, participant_id, "ai", direction="incoming")
+ if len(llm_edges) != 1:
+ raise ProtocolValidationError(
+ f"Node {_node_display_name(participant)!r} must have exactly one AI connection "
+ f"(found {len(llm_edges)})."
+ )
+ llm_source = nodes.get(str(llm_edges[0]["source"]))
+ if llm_source is None or llm_source.get("type") not in _LLM_NODE_TYPES:
+ raise ProtocolValidationError(
+ f"Node {_node_display_name(participant)!r}'s AI connection must come from an AI node."
+ )
+ return node
+
+
async def _run_single_node(
protocol_run_id: uuid.UUID,
*,
diff --git a/src/asaree/services/protocol_runs.py b/src/asaree/services/protocol_runs.py
index 7f30a36..943cb76 100644
--- a/src/asaree/services/protocol_runs.py
+++ b/src/asaree/services/protocol_runs.py
@@ -23,7 +23,11 @@
from asaree.models.protocol_run import ProtocolRun
from asaree.services.factorial_cells import list_replicates
-_TERMINAL_STATUSES = frozenset({"completed", "failed", "cancelled"})
+# "limit_reached" is terminal too: a conversation that spent its consultation
+# budget and could not then produce an answer is finished, not broken, and
+# calling it "failed" would hide the one thing a user needs to know to fix it
+# (see services.agent_messenger's budget constants).
+_TERMINAL_STATUSES = frozenset({"completed", "failed", "cancelled", "limit_reached"})
async def create_protocol_run(
@@ -199,6 +203,27 @@ async def update_node_run(
return run
+async def update_conversation(
+ db: AsyncSession, protocol_run_id: uuid.UUID, conversation: dict[str, Any]
+) -> ProtocolRun | None:
+ """Checkpoint the whole transcript as one document.
+
+ Assigned whole rather than merged: the messenger holds the authoritative
+ in-memory copy for the life of the run and appends to it, so a partial
+ merge here could only ever reorder what it already knows. Called before a
+ peer is allowed to execute and again after it replies, which is what makes
+ a worker retry able to see exactly how far the conversation got.
+ """
+ run = await get_protocol_run(db, protocol_run_id)
+ if run is None:
+ return None
+ run.conversation = conversation
+ run.last_heartbeat_at = datetime.now(UTC)
+ await db.flush()
+ await db.refresh(run)
+ return run
+
+
async def update_attempt_result(
db: AsyncSession, protocol_run_id: uuid.UUID, *, fields: dict[str, Any]
) -> ProtocolRun | None:
diff --git a/tests/test_agent_cards.py b/tests/test_agent_cards.py
new file mode 100644
index 0000000..1095ff6
--- /dev/null
+++ b/tests/test_agent_cards.py
@@ -0,0 +1,236 @@
+"""Peer topology and AgentCard resolution.
+
+Two properties carry most of the weight here. *Capability is snapshotted,
+reachability is live*: a card describes an agent as the run's graph configures
+it, while ``_can_deliver_communication`` is re-asked of the draft graph on every
+consultation. And *a card is derived, never stored*: every assertion below reads
+the graph, because that is the only place the answer lives.
+"""
+
+from __future__ import annotations
+
+import uuid
+from typing import Any
+
+import pytest
+
+from asaree.services import protocol_execution as pe
+from asaree.services.agent_cards import AgentCard, build_agent_card
+
+OWNER = uuid.uuid4()
+
+
+def _agent(node_id: str, *, label: str = "", config: dict[str, Any] | None = None) -> dict[str, Any]:
+ return {"id": node_id, "type": "agent", "data": {"label": label, "config": config or {}}}
+
+
+def _edge(source: str, target: str, handle: str | None = None) -> dict[str, Any]:
+ edge: dict[str, Any] = {"id": f"{source}-{target}", "source": source, "target": target}
+ if handle is not None:
+ edge["targetHandle"] = handle
+ return edge
+
+
+def _pair_graph() -> dict[str, Any]:
+ """Planner <-> Critic, joined by one plain edge."""
+ return {
+ "nodes": [
+ _agent("planner", label="Planner", config={"description": "Breaks a goal into ordered steps."}),
+ _agent("critic", label="Critic", config={"description": "Names the weakest claim in a draft."}),
+ ],
+ "edges": [_edge("planner", "critic")],
+ }
+
+
+# ----------------------------------------------------------------------
+# Topology
+# ----------------------------------------------------------------------
+
+
+def test_a_plain_agent_edge_is_a_peer_edge_in_both_directions() -> None:
+ """The stored source/target records how the user drew the edge, not who
+ may speak."""
+ graph = _pair_graph()
+ assert pe._connected_agent_ids(graph, "planner") == ["critic"]
+ assert pe._connected_agent_ids(graph, "critic") == ["planner"]
+
+
+def test_connector_edges_are_configuration_not_topology() -> None:
+ graph = {
+ "nodes": [_agent("planner"), _agent("critic")],
+ # Nonsensical wiring on purpose: even if a connector-typed edge joined
+ # two Agent nodes, a connector declares capability, not who to talk to.
+ "edges": [_edge("planner", "critic", "tool")],
+ }
+ assert pe._connected_agent_ids(graph, "planner") == []
+
+
+def test_non_agent_nodes_are_never_peers() -> None:
+ """Invariant 4 — a Critic Gate edge keeps its pipeline meaning, and an LLM
+ node is not addressable however it is wired."""
+ graph = {
+ "nodes": [
+ _agent("planner"),
+ {"id": "gate", "type": "critic_gate", "data": {"config": {}}},
+ {"id": "llm", "type": "llm_anthropic", "data": {"config": {}}},
+ ],
+ "edges": [_edge("planner", "gate"), _edge("llm", "planner")],
+ }
+ assert pe._connected_agent_ids(graph, "planner") == []
+
+
+def test_peers_are_listed_once_in_wiring_order() -> None:
+ graph = {
+ "nodes": [_agent("a"), _agent("b"), _agent("c")],
+ "edges": [_edge("a", "c"), _edge("b", "a"), {"id": "dup", "source": "c", "target": "a"}],
+ }
+ assert pe._connected_agent_ids(graph, "a") == ["c", "b"]
+
+
+def test_an_agent_is_never_its_own_peer() -> None:
+ graph = {"nodes": [_agent("a")], "edges": [_edge("a", "a")]}
+ assert pe._connected_agent_ids(graph, "a") == []
+ assert pe._can_deliver_communication(graph, "a", "a") is False
+
+
+def test_delivery_is_authorized_by_the_graph_it_is_asked_of() -> None:
+ """Reachability is live: unplugging the edge on the canvas stops the next
+ consultation, even though the run's cards came from a revision."""
+ connected = _pair_graph()
+ assert pe._can_deliver_communication(connected, "planner", "critic") is True
+ assert pe._can_deliver_communication(connected, "critic", "planner") is True
+
+ unplugged = {"nodes": connected["nodes"], "edges": []}
+ assert pe._can_deliver_communication(unplugged, "planner", "critic") is False
+
+
+def test_an_unconnected_third_agent_is_not_reachable() -> None:
+ """Invariant 3 — the two clusters on a canvas stay separate."""
+ graph = _pair_graph()
+ graph["nodes"].append(_agent("loner", label="Loner"))
+ assert pe._connected_agent_ids(graph, "planner") == ["critic"]
+ assert pe._can_deliver_communication(graph, "planner", "loner") is False
+
+
+# ----------------------------------------------------------------------
+# The card
+# ----------------------------------------------------------------------
+
+
+def test_description_falls_back_to_goal_then_to_a_default() -> None:
+ """The description becomes the consultation function's description, which
+ is what a peer's model reads to decide. An empty one is worse than a
+ generic one."""
+ assert (
+ build_agent_card(
+ node_id="n1", label="Critic", description="", goal="Find the weakest claim.", skills=[], model=None
+ ).description
+ == "Find the weakest claim."
+ )
+ assert (
+ build_agent_card(node_id="n1", label="Critic", description="", goal="", skills=[], model=None).description
+ == "An agent named Critic."
+ )
+
+
+def test_an_unlabeled_agent_falls_back_to_its_node_id() -> None:
+ assert build_agent_card(node_id="n1", label=None, description="d", goal="", skills=[], model=None).name == "n1"
+
+
+def test_the_card_serializes_to_the_shape_core_consumes() -> None:
+ card = build_agent_card(
+ node_id="n1",
+ label="Critic",
+ description="Names the weakest claim.",
+ goal="",
+ skills=[{"name": "Fact Check", "description": "Verifies claims.", "body": "ignored", "files": {}}],
+ model="claude-sonnet-5",
+ metadata={"protocol_id": "p1"},
+ )
+ assert card.to_dict() == {
+ "agent_id": "n1",
+ "name": "Critic",
+ "description": "Names the weakest claim.",
+ "skills": [{"id": "fact-check", "name": "Fact Check", "description": "Verifies claims."}],
+ "metadata": {"protocol_id": "p1", "model": "claude-sonnet-5"},
+ }
+
+
+def test_the_card_never_carries_tools_or_the_system_prompt() -> None:
+ """Exposing another agent's servers invites asking it to proxy a tool call;
+ the system prompt is user-authored content meaningless to a peer."""
+ card = build_agent_card(node_id="n1", label="X", description="d", goal="", skills=[], model=None)
+ assert set(card.to_dict()) == {"agent_id", "name", "description", "skills", "metadata"}
+
+
+async def test_resolve_agent_card_reads_the_node_it_is_given() -> None:
+ card = await pe.resolve_agent_card(_pair_graph(), "critic", owner_id=OWNER)
+ assert isinstance(card, AgentCard)
+ assert (card.agent_id, card.name) == ("critic", "Critic")
+ assert card.description == "Names the weakest claim in a draft."
+
+
+async def test_the_card_carries_the_node_id_not_the_motoro_agent_id() -> None:
+ """The Motoro Agent uuid is an implementation detail of one execution; the
+ node id is what topology authorizes against and what the transcript
+ stores."""
+ card = await pe.resolve_agent_card(_pair_graph(), "planner", owner_id=OWNER)
+ assert card is not None
+ assert card.agent_id == "planner"
+
+
+async def test_the_model_family_comes_from_the_wired_ai_connector() -> None:
+ graph = _pair_graph()
+ graph["nodes"].append({"id": "llm", "type": "llm_anthropic", "data": {"config": {"model": "claude-sonnet-5"}}})
+ graph["edges"].append(_edge("llm", "critic", "ai"))
+
+ card = await pe.resolve_agent_card(graph, "critic", owner_id=OWNER)
+ assert card is not None
+ assert card.metadata["model"] == "claude-sonnet-5"
+
+
+async def test_a_non_agent_node_has_no_card() -> None:
+ graph = {"nodes": [{"id": "llm", "type": "llm_anthropic", "data": {"config": {}}}], "edges": []}
+ assert await pe.resolve_agent_card(graph, "llm", owner_id=OWNER) is None
+ assert await pe.resolve_agent_card(graph, "missing", owner_id=OWNER) is None
+
+
+async def test_wired_skills_reach_the_card_as_names_and_descriptions(monkeypatch: pytest.MonkeyPatch) -> None:
+ """Names and descriptions only — a skill's body is the owning agent's
+ business, and a peer cannot load it."""
+ skill_id = str(uuid.uuid4())
+ graph = _pair_graph()
+ graph["nodes"].append({"id": "skill1", "type": "skill", "data": {"config": {"skill_id": skill_id}}})
+ graph["edges"].append(_edge("skill1", "critic", "skill"))
+
+ seen: dict[str, Any] = {}
+
+ async def _fake_resolve_skills(skill_config: dict[str, Any] | None, **kwargs: Any) -> list[dict[str, Any]]:
+ seen.update(skill_config or {})
+ return [{"name": "Fact Check", "description": "Verifies claims.", "body": "SECRET", "files": {}}]
+
+ monkeypatch.setattr(pe, "resolve_skills", _fake_resolve_skills)
+
+ card = await pe.resolve_agent_card(graph, "critic", owner_id=OWNER)
+ assert card is not None
+ assert seen == {"skill_ids": [skill_id]}
+ assert [s.name for s in card.skills] == ["Fact Check"]
+ assert "SECRET" not in str(card.to_dict())
+
+
+async def test_available_agents_are_the_connected_peers_cards() -> None:
+ agents = await pe.resolve_available_agents(_pair_graph(), "planner", owner_id=OWNER)
+ assert [a["agent_id"] for a in agents] == ["critic"]
+ assert agents[0]["name"] == "Critic"
+
+
+async def test_an_agent_with_no_peers_gets_an_empty_list() -> None:
+ """Invariant 11 — a pipeline or single-agent run carries no new field
+ content, so nothing about it changes."""
+ graph = {"nodes": [_agent("solo", label="Solo")], "edges": []}
+ assert await pe.resolve_available_agents(graph, "solo", owner_id=OWNER) == []
+
+
+async def test_a_peer_never_sees_itself_in_its_own_roster() -> None:
+ agents = await pe.resolve_available_agents(_pair_graph(), "critic", owner_id=OWNER)
+ assert [a["agent_id"] for a in agents] == ["planner"]
diff --git a/tests/test_agent_messenger.py b/tests/test_agent_messenger.py
new file mode 100644
index 0000000..51933da
--- /dev/null
+++ b/tests/test_agent_messenger.py
@@ -0,0 +1,404 @@
+"""Consultation delivery: authorization, budgets, transcript, and the clock.
+
+The property under test throughout is that **a refusal is an answer, not an
+error**. Every cap and every authorization failure below asserts on the returned
+:class:`AgentReply`, because the whole point is that the calling model reads the
+outcome and still writes something useful. A test here that expected an
+exception would be asserting the opposite of the design.
+
+DB-free by construction: ``get_session`` and the five service calls the
+messenger makes through it are stubbed, so what runs is exactly the ordering,
+budget and authorization logic and nothing else.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import contextlib
+import uuid
+from collections.abc import AsyncIterator
+from typing import Any
+
+import pytest
+
+from asaree.services import agent_messenger as am
+from asaree.services import protocol_execution as pe
+from asaree.services.agent_messenger import AgentMessenger
+from asaree.services.deadline import Deadline, active_deadline, deadlines_paused
+
+PROTOCOL_ID = uuid.uuid4()
+RUN_ID = uuid.uuid4()
+OWNER = uuid.uuid4()
+
+
+def _agent(node_id: str, label: str) -> dict[str, Any]:
+ return {"id": node_id, "type": "agent", "data": {"label": label, "config": {"description": label}}}
+
+
+def _graph() -> dict[str, Any]:
+ """Planner <-> Critic, plus an unconnected Loner."""
+ return {
+ "nodes": [_agent("planner", "Planner"), _agent("critic", "Critic"), _agent("loner", "Loner")],
+ "edges": [{"id": "e1", "source": "planner", "target": "critic"}],
+ }
+
+
+class _Row:
+ def __init__(self, **kwargs: Any) -> None:
+ self.__dict__.update(kwargs)
+
+
+@pytest.fixture
+def stubs(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
+ """Cut every DB call, keeping the real authorization and budget logic."""
+ state: dict[str, Any] = {
+ "checkpoints": [],
+ "node_runs": [],
+ "cancel_requested_at": None,
+ "live_graph": _graph(),
+ "peer_runs": [],
+ # A tuple, or a callable taking the _run_agent_node kwargs and
+ # returning one -- the callable form is how a test asserts on state
+ # mid-consultation.
+ "peer_result": ("A peer answer.", None, uuid.uuid4()),
+ }
+
+ @contextlib.asynccontextmanager
+ async def _session() -> AsyncIterator[None]:
+ yield None
+
+ async def _update_conversation(_db: Any, _run_id: uuid.UUID, conversation: dict[str, Any]) -> None:
+ state["checkpoints"].append(conversation)
+
+ async def _get_protocol_run(_db: Any, _run_id: uuid.UUID) -> Any:
+ return _Row(cancel_requested_at=state["cancel_requested_at"])
+
+ async def _get_protocol(_db: Any, _protocol_id: uuid.UUID) -> Any:
+ return _Row(graph=state["live_graph"])
+
+ async def _update_node_run(_db: Any, _run_id: uuid.UUID, node_id: str, patch: dict[str, Any]) -> None:
+ state["node_runs"].append((node_id, patch))
+
+ async def _run_agent_node(node: dict[str, Any], **kwargs: Any) -> tuple[str | None, str | None, uuid.UUID | None]:
+ state["peer_runs"].append((node["id"], kwargs["user_input"], kwargs["agent_messenger"]))
+ result = state["peer_result"]
+ resolved: tuple[str | None, str | None, uuid.UUID | None] = result(kwargs) if callable(result) else result
+ return resolved
+
+ async def _resolve_available_agents(*_args: Any, **_kwargs: Any) -> list[dict[str, Any]]:
+ return []
+
+ monkeypatch.setattr(am, "get_session", _session)
+ monkeypatch.setattr(am, "update_conversation", _update_conversation)
+ monkeypatch.setattr(am, "get_protocol_run", _get_protocol_run)
+ monkeypatch.setattr(am, "get_protocol", _get_protocol)
+ monkeypatch.setattr(am, "update_node_run", _update_node_run)
+ monkeypatch.setattr(am, "_run_agent_node", _run_agent_node)
+ monkeypatch.setattr(am, "resolve_available_agents", _resolve_available_agents)
+ monkeypatch.setattr(am, "_ambient_meta_for", lambda *a, **k: {})
+ return state
+
+
+def _messenger(**kwargs: Any) -> AgentMessenger:
+ return AgentMessenger(
+ protocol_id=PROTOCOL_ID,
+ protocol_run_id=RUN_ID,
+ owner_id=OWNER,
+ graph=_graph(),
+ entry_agent_id="planner",
+ **kwargs,
+ )
+
+
+async def _ask(messenger: AgentMessenger, to: str = "critic", text: str = "What is weak here?") -> Any:
+ return await messenger.send(
+ from_agent_id="planner", to_agent_id=to, parts=[{"kind": "text", "text": text}], context=None
+ )
+
+
+# ----------------------------------------------------------------------
+# Delivery
+# ----------------------------------------------------------------------
+
+
+async def test_a_consultation_runs_the_peer_and_returns_its_words(stubs: dict[str, Any]) -> None:
+ reply = await _ask(_messenger())
+ assert reply.state == "completed"
+ assert reply.text == "A peer answer."
+ assert [r[0] for r in stubs["peer_runs"]] == ["critic"]
+ assert stubs["peer_runs"][0][1] == "What is weak here?"
+
+
+async def test_the_peer_is_handed_the_messenger_so_it_can_consult_back(stubs: dict[str, Any]) -> None:
+ """Recursion is the mechanism, bounded by depth -- not something bolted on
+ top of a flat exchange."""
+ await _ask(messenger := _messenger())
+ assert stubs["peer_runs"][0][2] is messenger
+
+
+async def test_identity_and_ordering_come_from_the_runtime(stubs: dict[str, Any]) -> None:
+ """Invariant 2 -- the model supplies content only."""
+ messenger = _messenger()
+ await _ask(messenger)
+ await _ask(messenger, text="And now?")
+ messages = messenger.conversation["messages"]
+ assert [m["sequence"] for m in messages] == [1, 2, 3, 4]
+ assert len({m["message_id"] for m in messages}) == 4
+ assert [(m["from_agent_id"], m["to_agent_id"]) for m in messages] == [
+ ("planner", "critic"),
+ ("critic", "planner"),
+ ("planner", "critic"),
+ ("critic", "planner"),
+ ]
+
+
+async def test_the_transcript_is_checkpointed_around_the_peer_turn(stubs: dict[str, Any]) -> None:
+ """A worker retry has to be able to see how far the conversation got."""
+
+ def _result(kwargs: Any) -> tuple[str, None, uuid.UUID]:
+ # Mid-flight: the question must already be durable, the reply must not.
+ assert len(stubs["checkpoints"][-1]["messages"]) == 1
+ return ("A peer answer.", None, uuid.uuid4())
+
+ stubs["peer_result"] = _result
+ await _ask(_messenger())
+ assert len(stubs["checkpoints"][-1]["messages"]) == 2
+
+
+async def test_a_consulted_peer_shows_on_the_canvas_as_a_node_that_ran(stubs: dict[str, Any]) -> None:
+ await _ask(_messenger())
+ assert [n for n, _ in stubs["node_runs"]] == ["critic", "critic"]
+ assert stubs["node_runs"][0][1]["status"] == "running"
+ assert stubs["node_runs"][1][1]["status"] == "completed"
+
+
+async def test_a_peer_that_fails_is_a_reply_not_an_exception(stubs: dict[str, Any]) -> None:
+ stubs["peer_result"] = (None, "boom", uuid.uuid4())
+ reply = await _ask(_messenger())
+ assert reply.state == "failed"
+ assert reply.error == "boom"
+ assert "boom" in reply.text
+
+
+async def test_a_cancelled_peer_is_canceled_not_failed(stubs: dict[str, Any]) -> None:
+ """A Stop is not an error, and reporting it as one would put a red node on
+ a canvas the user themselves stopped."""
+ stubs["peer_result"] = (None, pe._AGENT_CANCELLED, uuid.uuid4())
+ reply = await _ask(_messenger())
+ assert reply.state == "canceled"
+ assert reply.error is None
+
+
+async def test_a_silent_peer_still_says_something(stubs: dict[str, Any]) -> None:
+ """An empty function result reads to a model as a malfunction rather than
+ as an answer to work around."""
+ stubs["peer_result"] = (" ", None, uuid.uuid4())
+ reply = await _ask(_messenger())
+ assert reply.state == "completed"
+ assert reply.text
+
+
+# ----------------------------------------------------------------------
+# Authorization
+# ----------------------------------------------------------------------
+
+
+async def test_an_unconnected_agent_cannot_be_consulted(stubs: dict[str, Any]) -> None:
+ """Invariant 3 -- and it is a rejection, so the caller can still answer."""
+ reply = await _ask(_messenger(), to="loner")
+ assert reply.state == "rejected"
+ assert "not connected" in reply.text
+ assert stubs["peer_runs"] == []
+
+
+async def test_pulling_the_edge_mid_run_stops_the_next_consultation(stubs: dict[str, Any]) -> None:
+ """Reachability is live: the run's cards came from a revision, but delivery
+ is re-authorized against the draft graph every single time."""
+ messenger = _messenger()
+ assert (await _ask(messenger)).state == "completed"
+ stubs["live_graph"] = {"nodes": _graph()["nodes"], "edges": []}
+ assert (await _ask(messenger)).state == "rejected"
+ assert len(stubs["peer_runs"]) == 1
+
+
+async def test_a_refused_consultation_is_still_in_the_transcript(stubs: dict[str, Any]) -> None:
+ """Invariant 10 -- a silently dropped question is the failure this makes
+ debuggable."""
+ messenger = _messenger()
+ await _ask(messenger, to="loner")
+ messages = messenger.conversation["messages"]
+ assert [m["to_agent_id"] for m in messages] == ["loner", "planner"]
+ assert messages[1]["state"] == "rejected"
+
+
+async def test_a_stop_between_consultations_prevents_further_ones(stubs: dict[str, Any]) -> None:
+ """Invariant 8."""
+ from datetime import UTC, datetime
+
+ stubs["cancel_requested_at"] = datetime.now(UTC)
+ reply = await _ask(_messenger())
+ assert reply.state == "rejected"
+ assert "cancelled" in reply.text
+ assert stubs["peer_runs"] == []
+
+
+# ----------------------------------------------------------------------
+# Budgets
+# ----------------------------------------------------------------------
+
+
+async def test_the_execution_budget_is_spent_not_bypassed(
+ stubs: dict[str, Any], monkeypatch: pytest.MonkeyPatch
+) -> None:
+ monkeypatch.setattr(am, "_MAX_PEER_EXECUTIONS", 2)
+ messenger = _messenger()
+ assert (await _ask(messenger)).state == "completed"
+ assert (await _ask(messenger)).state == "completed"
+ exhausted = await _ask(messenger)
+ assert exhausted.state == "rejected"
+ assert "consultations" in exhausted.text
+ assert len(stubs["peer_runs"]) == 2
+ assert messenger.limit_reached is True
+
+
+async def test_depth_is_capped_and_the_cap_is_a_reply(stubs: dict[str, Any], monkeypatch: pytest.MonkeyPatch) -> None:
+ """The consulted peer consults back *from inside its own turn*; at the cap
+ it gets prose telling it to answer instead. Invariants 6 and 7 together."""
+ monkeypatch.setattr(am, "_MAX_CONSULT_DEPTH", 1)
+ nested_replies: list[Any] = []
+
+ async def _run_agent_node(node: dict[str, Any], **kwargs: Any) -> tuple[str, None, uuid.UUID]:
+ messenger: AgentMessenger = kwargs["agent_messenger"]
+ nested_replies.append(
+ await messenger.send(
+ from_agent_id="critic",
+ to_agent_id="planner",
+ parts=[{"kind": "text", "text": "Clarify?"}],
+ context=None,
+ )
+ )
+ return ("A peer answer.", None, uuid.uuid4())
+
+ monkeypatch.setattr(am, "_run_agent_node", _run_agent_node)
+ messenger = _messenger()
+ outer = await _ask(messenger)
+
+ assert outer.state == "completed"
+ assert [r.state for r in nested_replies] == ["rejected"]
+ assert "nested" in nested_replies[0].text
+ assert messenger.limit_reached is True
+
+
+async def test_depth_is_released_when_a_consultation_returns(
+ stubs: dict[str, Any], monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """A cap on how deep, not on how many in a row -- two sequential
+ consultations from the entry agent are both depth 1."""
+ monkeypatch.setattr(am, "_MAX_CONSULT_DEPTH", 1)
+ messenger = _messenger()
+ assert (await _ask(messenger)).state == "completed"
+ assert (await _ask(messenger)).state == "completed"
+
+
+async def test_the_conversation_wall_clock_is_the_backstop(
+ stubs: dict[str, Any], monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """Peer time is given back to individual agents but never to this cap --
+ otherwise nothing would bound the total."""
+ from datetime import timedelta
+
+ monkeypatch.setattr(am, "_MAX_CONVERSATION_DURATION", timedelta(seconds=0))
+ reply = await _ask(_messenger())
+ assert reply.state == "rejected"
+ assert "seconds" in reply.text
+ assert stubs["peer_runs"] == []
+
+
+# ----------------------------------------------------------------------
+# Timeout accounting
+# ----------------------------------------------------------------------
+
+
+async def test_a_paused_deadline_does_not_expire_while_it_waits() -> None:
+ deadline = Deadline(0.05)
+ deadline.pause()
+ await asyncio.sleep(0.15)
+ assert deadline.expired() is False
+ deadline.resume()
+ # The paused span was given back, so what was left before the pause is
+ # still left after it.
+ assert 0 < deadline.remaining() <= 0.05
+ assert deadline.expired() is False
+
+
+async def test_every_caller_in_the_chain_stops_ticking() -> None:
+ """A depth-2 consultation blocks its own caller and the entry agent for the
+ same span, so neither is charged for it."""
+ entry, middle = Deadline(0.05), Deadline(0.05)
+ with active_deadline(entry), active_deadline(middle), deadlines_paused():
+ await asyncio.sleep(0.15)
+ assert (entry.expired(), middle.expired()) == (False, False)
+
+
+async def test_a_finished_frame_is_no_longer_paused() -> None:
+ """Leaving ``active_deadline`` takes the frame out of the chain, so a later
+ consultation cannot freeze a run that already ended."""
+ with active_deadline(Deadline(10)):
+ with active_deadline(inner := Deadline(0.05)):
+ pass
+ with deadlines_paused():
+ await asyncio.sleep(0.15)
+ assert inner.expired() is True
+
+
+async def test_nested_pauses_only_restart_the_clock_once() -> None:
+ deadline = Deadline(0.05)
+ with active_deadline(deadline), deadlines_paused():
+ with deadlines_paused():
+ await asyncio.sleep(0.1)
+ assert deadline.expired() is False
+ await asyncio.sleep(0.1)
+ assert deadline.expired() is False
+
+
+async def test_peer_time_does_not_burn_the_callers_own_budget(monkeypatch: pytest.MonkeyPatch) -> None:
+ """The decision this feature turns on: an agent is charged for its own
+ thinking, never for waiting on a peer. A consultation longer than the
+ caller's whole allowance must not time the caller out."""
+
+ async def _never_cancels(*_args: Any, **_kwargs: Any) -> None:
+ await asyncio.Event().wait()
+
+ async def _execute_run(**_kwargs: Any) -> None:
+ # Stand in for the engine: think briefly, block on a peer for longer
+ # than the entire budget, then finish. Post-hoc repayment would not
+ # save this run -- the deadline would already have fired mid-wait --
+ # which is why the clock stops instead.
+ await asyncio.sleep(0.05)
+ with deadlines_paused():
+ await asyncio.sleep(0.3)
+ await asyncio.sleep(0.05)
+
+ monkeypatch.setattr(pe, "_poll_cancel_flag", _never_cancels)
+ monkeypatch.setattr(pe, "execute_run", _execute_run)
+ monkeypatch.setattr(pe, "get_registry", lambda: None)
+
+ await pe._execute_run_cancellable(run_id=uuid.uuid4(), protocol_run_id=RUN_ID, available_tools=[], timeout=0.25)
+
+
+async def test_an_agent_that_overruns_on_its_own_still_times_out(monkeypatch: pytest.MonkeyPatch) -> None:
+ """The other half: the deadline is still a real limit when nothing extends
+ it, which is every pipeline run."""
+
+ async def _never_cancels(*_args: Any, **_kwargs: Any) -> None:
+ await asyncio.Event().wait()
+
+ async def _execute_run(**_kwargs: Any) -> None:
+ await asyncio.sleep(5)
+
+ monkeypatch.setattr(pe, "_poll_cancel_flag", _never_cancels)
+ monkeypatch.setattr(pe, "execute_run", _execute_run)
+ monkeypatch.setattr(pe, "get_registry", lambda: None)
+
+ with pytest.raises(TimeoutError):
+ await pe._execute_run_cancellable(run_id=uuid.uuid4(), protocol_run_id=RUN_ID, available_tools=[], timeout=0.1)
From 17efb38a538d99905351061162c6ec488f7294db Mon Sep 17 00:00:00 2001
From: Jay Moran
Date: Tue, 8 Sep 2026 15:49:38 -0700
Subject: [PATCH 03/57] Add the conversation-mode run endpoint
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.
---
src/asaree/api/protocols.py | 42 +++++++++++++++++-
src/asaree/worker/enqueue.py | 17 ++++++++
src/asaree/worker/settings.py | 8 +++-
src/asaree/worker/tasks.py | 38 +++++++++++++++-
tests/test_protocol_execution.py | 74 ++++++++++++++++++++++++++++++--
5 files changed, 172 insertions(+), 7 deletions(-)
diff --git a/src/asaree/api/protocols.py b/src/asaree/api/protocols.py
index 8c9b014..c39ddf7 100644
--- a/src/asaree/api/protocols.py
+++ b/src/asaree/api/protocols.py
@@ -24,6 +24,7 @@
plan_cell_runs,
plan_single_replicate_run,
topological_order,
+ validate_conversation_entry,
validate_coordination_strategy,
validate_single_node_runnable,
)
@@ -47,7 +48,7 @@
list_protocols,
update_protocol,
)
-from asaree.worker.enqueue import enqueue_protocol_run
+from asaree.worker.enqueue import enqueue_conversation, enqueue_protocol_run
router = APIRouter(prefix="/protocols", tags=["protocols"])
@@ -95,6 +96,9 @@ class ProtocolRunResponse(BaseModel):
design_revision_id: uuid.UUID | None
protocol_revision_id: uuid.UUID | None
target_node_id: str | None
+ # Null for every pipeline run. Populated once a conversation-mode run's
+ # agents start talking -- see models/protocol_run.py for the shape.
+ conversation: dict[str, Any] | None = None
cancel_requested_at: datetime | None
created_at: datetime
updated_at: datetime
@@ -122,6 +126,18 @@ class CreateProtocolRunRequest(BaseModel):
replicate_label: str | None = None
+class StartConversationRequest(BaseModel):
+ """Address one agent on the canvas and let it consult its connected peers.
+
+ Only an entry point and an opening question: who else may participate comes
+ from the graph, not from this body, so a client cannot widen a run's reach
+ by naming extra agents.
+ """
+
+ entry_agent_id: str
+ user_input: str
+
+
class CellRunBatchRequest(BaseModel):
"""Previously completed replicates the user explicitly chose to run again.
@@ -317,6 +333,30 @@ async def create_protocol_run_endpoint(
return ProtocolRunResponse.model_validate(run)
+@router.post("/{protocol_id}/conversations", response_model=ProtocolRunResponse, status_code=201)
+async def start_conversation_endpoint(
+ protocol_id: uuid.UUID, user: CurrentUser, db: DbSession, body: StartConversationRequest
+) -> ProtocolRunResponse:
+ """Start a conversation-mode run: address one agent, and let it consult the
+ peers it is connected to.
+
+ The same ``ProtocolRun`` a pipeline run uses, against the same published
+ revision -- what differs is only which worker function walks it. Peers are a
+ capability of the run mode, not a different kind of protocol.
+ """
+ protocol = await _get_owned_protocol(db, protocol_id, user)
+ revision = await _require_published_revision(db, protocol)
+ try:
+ validate_conversation_entry(revision.graph, body.entry_agent_id)
+ except ProtocolValidationError as exc:
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
+ run = await create_protocol_run(
+ db, protocol_id=protocol_id, owner_id=user.id, protocol_revision_id=revision.id
+ )
+ await enqueue_conversation(run.id, entry_agent_id=body.entry_agent_id, user_input=body.user_input)
+ return ProtocolRunResponse.model_validate(run)
+
+
@router.post("/{protocol_id}/nodes/{node_id}/run", response_model=ProtocolRunResponse, status_code=201)
async def run_single_node_endpoint(
protocol_id: uuid.UUID, node_id: str, user: CurrentUser, db: DbSession
diff --git a/src/asaree/worker/enqueue.py b/src/asaree/worker/enqueue.py
index 953fed3..abec7c6 100644
--- a/src/asaree/worker/enqueue.py
+++ b/src/asaree/worker/enqueue.py
@@ -43,6 +43,23 @@ async def enqueue_protocol_run(protocol_run_id: uuid.UUID) -> None:
await pool.enqueue_job("execute_protocol_run_task", str(protocol_run_id), _job_id=f"protocol-run:{protocol_run_id}")
+async def enqueue_conversation(protocol_run_id: uuid.UUID, *, entry_agent_id: str, user_input: str) -> None:
+ """Same idempotent-enqueue pattern, for asaree.worker.tasks.execute_conversation_task.
+
+ Shares the ``protocol-run:`` job-id namespace with :func:`enqueue_protocol_run`
+ on purpose: both execute the same row, so a run must never be able to have
+ one of each queued against it.
+ """
+ pool = await _get_pool()
+ await pool.enqueue_job(
+ "execute_conversation_task",
+ str(protocol_run_id),
+ entry_agent_id,
+ user_input,
+ _job_id=f"protocol-run:{protocol_run_id}",
+ )
+
+
async def enqueue_metric_evaluation(protocol_run_id: uuid.UUID) -> None:
"""Queue an idempotent backfill/retry of configured post-run metrics."""
pool = await _get_pool()
diff --git a/src/asaree/worker/settings.py b/src/asaree/worker/settings.py
index ce21b1b..965d4ce 100644
--- a/src/asaree/worker/settings.py
+++ b/src/asaree/worker/settings.py
@@ -34,6 +34,7 @@
from asaree.worker.tasks import (
check_stale_runs,
evaluate_protocol_run_metrics_task,
+ execute_conversation_task,
execute_protocol_run_task,
execute_run_task,
)
@@ -62,7 +63,12 @@ async def on_shutdown(ctx: dict[str, Any]) -> None:
class WorkerSettings:
- functions = [execute_run_task, execute_protocol_run_task, evaluate_protocol_run_metrics_task]
+ functions = [
+ execute_run_task,
+ execute_protocol_run_task,
+ execute_conversation_task,
+ evaluate_protocol_run_metrics_task,
+ ]
cron_jobs = [cron(check_stale_runs, second={0, 30})]
redis_settings = RedisSettings.from_dsn(get_settings().redis_url)
on_startup = on_startup
diff --git a/src/asaree/worker/tasks.py b/src/asaree/worker/tasks.py
index 11965fb..a016b93 100644
--- a/src/asaree/worker/tasks.py
+++ b/src/asaree/worker/tasks.py
@@ -13,7 +13,7 @@
import asyncio
import logging
import uuid
-from collections.abc import Coroutine
+from collections.abc import Callable, Coroutine
from datetime import UTC, datetime, timedelta
from typing import Any
@@ -34,6 +34,7 @@
import asaree.models.user # noqa: F401 -- registers users for Protocol/ProtocolRun's owner_id FK
from asaree.config import get_settings
from asaree.models.database import get_session
+from asaree.services.agent_messenger import run_conversation
from asaree.services.protocol_execution import evaluate_protocol_run_metrics, run_protocol
from asaree.services.protocol_runs import fail_protocol_run, get_protocol_run, list_stale_protocol_runs
from asaree.services.run_tools import gather_tools
@@ -103,6 +104,39 @@ async def execute_protocol_run_task(ctx: dict[str, Any], protocol_run_id_str: st
timeout/exception rather than letting arq retry a partially-executed
graph (real agent runs, real tool calls -- not safe to assume idempotent).
"""
+ await _guarded_protocol_run(protocol_run_id_str, run_protocol)
+
+
+async def execute_conversation_task(
+ ctx: dict[str, Any], protocol_run_id_str: str, entry_agent_id: str, user_input: str
+) -> None:
+ """The conversation-mode counterpart of ``execute_protocol_run_task``.
+
+ 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 row column
+ would let a retry of a pipeline run wander into conversation mode (or the
+ reverse) if it were ever written wrong. The entry agent and the opening
+ question ride as job arguments because nothing else needs them persisted --
+ the transcript records them the moment the run starts.
+ """
+ await _guarded_protocol_run(
+ protocol_run_id_str,
+ lambda protocol_run_id: run_conversation(
+ protocol_run_id, entry_agent_id=entry_agent_id, user_input=user_input
+ ),
+ )
+
+
+async def _guarded_protocol_run(
+ protocol_run_id_str: str, work: Callable[[uuid.UUID], Coroutine[Any, Any, None]]
+) -> None:
+ """The durability guards both protocol-run tasks need, around either body.
+
+ Shared rather than duplicated because every branch below is about the run
+ *row* -- skip a non-actionable status, force-fail on timeout, record a
+ cancellation before re-raising -- and none of it knows or cares whether the
+ run walks a graph or hosts a conversation.
+ """
protocol_run_id = uuid.UUID(protocol_run_id_str)
async with get_session() as db:
run = await get_protocol_run(db, protocol_run_id)
@@ -118,7 +152,7 @@ async def execute_protocol_run_task(ctx: dict[str, Any], protocol_run_id_str: st
timeout = get_settings().worker_job_timeout_seconds
try:
- await asyncio.wait_for(run_protocol(protocol_run_id), timeout=timeout)
+ await asyncio.wait_for(work(protocol_run_id), timeout=timeout)
except TimeoutError:
async with get_session() as db:
await fail_protocol_run(db, protocol_run_id, error=f"protocol run exceeded its {timeout}s execution budget")
diff --git a/tests/test_protocol_execution.py b/tests/test_protocol_execution.py
index 2defc1e..8aa7a44 100644
--- a/tests/test_protocol_execution.py
+++ b/tests/test_protocol_execution.py
@@ -1554,7 +1554,7 @@ async def fake_run_agent_node(node, *, graph, workspace_id=None, **kwargs):
},
)
protocol_id = protocol.id
- await upsert_replicate(
+ replicate = await upsert_replicate(
db,
experiment_id=experiment_id,
replicate_label="only-cell",
@@ -1566,6 +1566,11 @@ async def fake_run_agent_node(node, *, graph, workspace_id=None, **kwargs):
owner_id=owner_id,
replicate_label="only-cell",
factor_values={"Temperature": 0.1},
+ # Claims the replicate slot, exactly as plan_cell_runs does. Without
+ # it run_protocol's write-back is correctly skipped:
+ # is_current_replicate_attempt reads run.replicate_result_id to decide
+ # whether this run still owns the slot's latest projection.
+ replicate_result_id=replicate.id,
)
run_id = run.id
@@ -1613,11 +1618,17 @@ async def _run_single_cell_protocol(owner_id: uuid.UUID) -> tuple[uuid.UUID, str
},
)
protocol_id = protocol.id
- await upsert_replicate(
+ replicate = await upsert_replicate(
db, experiment_id=experiment_id, replicate_label="only-cell", fields={"factor_values": {}}
)
run = await create_protocol_run(
- db, protocol_id=protocol_id, owner_id=owner_id, replicate_label="only-cell"
+ db,
+ protocol_id=protocol_id,
+ owner_id=owner_id,
+ replicate_label="only-cell",
+ # Claims the replicate slot the way plan_cell_runs does -- see
+ # is_current_replicate_attempt, which gates run_protocol's write-back.
+ replicate_result_id=replicate.id,
)
run_id = run.id
return experiment_id, "only-cell", protocol_id, run_id
@@ -3036,6 +3047,63 @@ def test_validate_single_node_runnable_accepts_a_valid_standalone_agent() -> Non
assert pe.validate_single_node_runnable(graph, "a") is agent
+# --- validate_conversation_entry ---------------------------------------------
+
+
+def _conversation_graph(*agent_ids: str) -> dict:
+ """Agents in a chain, each with its own LLM, joined by plain main edges."""
+ nodes: list[dict] = [_llm_node()]
+ edges: list[dict] = []
+ for agent_id in agent_ids:
+ agent, llm_edge = _agent_with_llm(agent_id)
+ nodes.append(agent)
+ edges.append(llm_edge)
+ for source, target in zip(agent_ids, agent_ids[1:], strict=False):
+ edges += _edges((source, target))
+ return {"nodes": nodes, "edges": edges}
+
+
+def test_validate_conversation_entry_rejects_a_missing_node() -> None:
+ with pytest.raises(ProtocolValidationError, match="No such node"):
+ pe.validate_conversation_entry(_conversation_graph("a", "b"), "nope")
+
+
+def test_validate_conversation_entry_rejects_a_non_agent_node() -> None:
+ graph = _conversation_graph("a", "b")
+ graph["nodes"].append(_node("g1", "critic_gate"))
+ with pytest.raises(ProtocolValidationError, match="Only Agent nodes"):
+ pe.validate_conversation_entry(graph, "g1")
+
+
+def test_validate_conversation_entry_rejects_an_agent_with_nobody_to_talk_to() -> None:
+ with pytest.raises(ProtocolValidationError, match="nobody to talk to"):
+ pe.validate_conversation_entry(_conversation_graph("a"), "a")
+
+
+def test_validate_conversation_entry_rejects_a_peer_with_no_model() -> None:
+ """A peer's own wiring is checked too: it will really run, and finding out
+ mid-conversation costs the user a run they already paid for."""
+ graph = _conversation_graph("a", "b")
+ graph["edges"] = [e for e in graph["edges"] if e.get("target") != "b" or e.get("targetHandle") != "ai"]
+ with pytest.raises(ProtocolValidationError, match="exactly one AI connection"):
+ pe.validate_conversation_entry(graph, "a")
+
+
+def test_validate_conversation_entry_accepts_a_third_agent_on_the_canvas() -> None:
+ """Three connected agents are three participants, not an error -- the
+ validator asks nothing about how many peer edges the graph has."""
+ graph = _conversation_graph("a", "b", "c")
+ assert pe.validate_conversation_entry(graph, "b")["id"] == "b"
+
+
+def test_validate_conversation_entry_ignores_an_unrelated_broken_node() -> None:
+ """Scoped to the entry agent and its peers: a half-wired node in another
+ corner of the same canvas has nothing to do with this conversation."""
+ graph = _conversation_graph("a", "b")
+ graph["nodes"].append(_node("stranded", "agent"))
+ assert pe.validate_conversation_entry(graph, "a")["id"] == "a"
+
+
async def test_run_single_node_ignores_an_unrelated_broken_sibling_node(
owner_id: uuid.UUID, monkeypatch: pytest.MonkeyPatch
) -> None:
From 656735f04622906a47f52f148fb3bbf1dd6d7119 Mon Sep 17 00:00:00 2001
From: Jay Moran
Date: Tue, 8 Sep 2026 15:49:48 -0700
Subject: [PATCH 04/57] Show conversations on the protocol canvas
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.
---
frontend/src/api/client.ts | 10 +
.../protocol/ConversationTranscript.tsx | 99 ++++++++++
.../components/protocol/ProtocolCanvas.tsx | 182 +++++++++++++++++-
.../components/protocol/RunConfirmDialog.tsx | 22 ++-
.../protocol/edges/InteractEdge.tsx | 38 +++-
.../components/protocol/nodes/AgentNode.tsx | 29 ++-
.../src/components/protocol/runSummary.ts | 19 +-
frontend/src/lib/protocolRun.ts | 9 +-
frontend/src/types/llmSettings.ts | 4 +
frontend/src/types/protocols.ts | 31 ++-
src/asaree/api/llm_settings.py | 11 ++
src/asaree/services/tool_calling.py | 43 +++++
12 files changed, 477 insertions(+), 20 deletions(-)
create mode 100644 frontend/src/components/protocol/ConversationTranscript.tsx
create mode 100644 src/asaree/services/tool_calling.py
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts
index ef459ec..5d619a0 100644
--- a/frontend/src/api/client.ts
+++ b/frontend/src/api/client.ts
@@ -284,6 +284,16 @@ 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' }),
+ // Conversation mode: address one agent and let it consult the peers it's
+ // wired to. Returns the same ProtocolRun a pipeline run does -- poll getRun
+ // the same way; what's new is `conversation` on the response, the transcript
+ // as it grows. 422 if the entry agent isn't an Agent node, has no peers, or
+ // any participant is missing its single LLM connection.
+ startConversation: (id: string, data: { entryAgentId: string; userInput: string }) =>
+ request(`/protocols/${id}/conversations`, {
+ method: 'POST',
+ body: { entry_agent_id: data.entryAgentId, user_input: data.userInput },
+ }),
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
diff --git a/frontend/src/components/protocol/ConversationTranscript.tsx b/frontend/src/components/protocol/ConversationTranscript.tsx
new file mode 100644
index 0000000..0e069a4
--- /dev/null
+++ b/frontend/src/components/protocol/ConversationTranscript.tsx
@@ -0,0 +1,99 @@
+import type { Conversation, ConversationMessage } from '@/types/protocols'
+
+// The literal id the backend uses for the human on both ends of a
+// conversation: the opening question comes from "user", and the entry agent's
+// final answer goes back to it (services/agent_messenger.py's
+// USER_PARTICIPANT).
+const USER_PARTICIPANT = 'user'
+
+// A2A TaskState, as the messenger records it on each reply. Only a reply
+// carries one -- a request has no outcome yet -- and only the ones that mean
+// something went sideways are worth colouring.
+const REPLY_BADGE: Record = {
+ completed: { label: 'answered', className: 'text-[color:var(--chart-3)]' },
+ failed: { label: 'failed', className: 'text-destructive' },
+ canceled: { label: 'cancelled', className: 'text-muted-foreground' },
+ rejected: { label: 'refused', className: 'text-[color:var(--chart-4)]' },
+}
+
+const CONVERSATION_STATE_LABEL: Record = {
+ working: 'Running',
+ completed: 'Completed',
+ failed: 'Failed',
+ canceled: 'Cancelled',
+ limit_reached: 'Limit reached',
+}
+
+// Parts, not a bare string: the backend already speaks A2A's part shape, so a
+// future data or file part is a new branch here rather than a rewrite of every
+// caller. Non-text parts are named rather than dropped -- a transcript that
+// silently omits half a reply is worse than one that says "1 data part".
+function MessageBody({ parts }: { parts: ConversationMessage['parts'] }) {
+ return (
+ <>
+ {parts.map((part, i) => {
+ if (part.kind === 'text') {
+ return (
+
+ {part.text}
+
+ )
+ }
+ return (
+
+ [{part.kind} part]
+
+ )
+ })}
+ >
+ )
+}
+
+// The live transcript of a conversation-mode run, over the canvas. Deliberately
+// a flat, ordered list rather than a threaded tree: `sequence` is assigned by
+// the runtime and one agent runs at a time, so the order things happened in IS
+// the structure -- nesting would only re-derive what the reading order already
+// shows.
+export function ConversationTranscript({
+ conversation,
+ agentNames,
+}: {
+ conversation: Conversation
+ // Node id -> the label shown on that node's card, so the transcript names
+ // agents the way the canvas does. Ids that aren't on the canvas (a node
+ // deleted since the run) fall back to the raw id rather than disappearing.
+ agentNames: Map
+}) {
+ const nameOf = (id: string) => (id === USER_PARTICIPANT ? 'You' : agentNames.get(id) ?? id)
+ const stateLabel = CONVERSATION_STATE_LABEL[conversation.state] ?? conversation.state
+
+ return (
+
+ )
+}
diff --git a/frontend/src/components/protocol/ProtocolCanvas.tsx b/frontend/src/components/protocol/ProtocolCanvas.tsx
index 4bcf20f..38c208f 100644
--- a/frontend/src/components/protocol/ProtocolCanvas.tsx
+++ b/frontend/src/components/protocol/ProtocolCanvas.tsx
@@ -16,8 +16,9 @@ import {
type Viewport,
} from '@xyflow/react'
import '@xyflow/react/dist/style.css'
-import { Lock, Plus, Square, X } from 'lucide-react'
+import { Lock, MessagesSquare, Plus, Square, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
+import { Textarea } from '@/components/ui/textarea'
import { ApiError, experimentsApi, protocolsApi } from '@/api/client'
import { newNodeId } from '@/lib/nodeId'
import { protocolForExperimentQueryKey, protocolGraphQueryKey, toPersistedGraph } from '@/lib/protocolGraph'
@@ -102,6 +103,7 @@ import { OkfDocumentNodeInspector } from './OkfDocumentNodeInspector'
import { SkillBrowserPanel } from './SkillBrowserPanel'
import { SKILL_BROWSE, nodeDataForSkill } from './skillCatalog'
import { SkillNodeInspector } from './SkillNodeInspector'
+import { ConversationTranscript } from './ConversationTranscript'
import { InteractEdge } from './edges/InteractEdge'
import { AgentNode } from './nodes/AgentNode'
import { CriticGateNode } from './nodes/CriticGateNode'
@@ -472,6 +474,12 @@ export const ProtocolCanvas = forwardRef(null)
+ // Conversation mode's own confirm state. Separate from pendingRunConfirm
+ // because the dialog needs two fields filled in (who to ask, and what) before
+ // the scope it confirms even exists.
+ const [conversationDialogOpen, setConversationDialogOpen] = useState(false)
+ const [conversationInput, setConversationInput] = useState('')
+ const [conversationEntryAgentId, setConversationEntryAgentId] = useState('')
const [runId, setRunId] = useState(null)
const paneRef = useRef(null)
const { screenToFlowPosition, fitView } = useReactFlow()
@@ -530,9 +538,16 @@ export const ProtocolCanvas = forwardRef setRunId(run.id),
})
+ // Conversation mode reuses the same runId/runQuery polling as every other
+ // run -- what's new is `conversation` on the polled row, which the transcript
+ // panel reads.
+ const conversationMutation = useMutation({
+ mutationFn: (data: { entryAgentId: string; userInput: string }) => protocolsApi.startConversation(protocolId, data),
+ onSuccess: (run) => {
+ setRunId(run.id)
+ setConversationDialogOpen(false)
+ setConversationInput('')
+ },
+ })
+
// First refetchInterval-based poll in this codebase -- no existing
// long-running-job UI to mirror. Function form so polling stops itself
// once the run reaches a terminal status, rather than polling forever.
@@ -572,7 +599,10 @@ export const ProtocolCanvas = forwardRef {
+ const nodeTypeById = new Map(nodes.map((n) => [n.id, n.type]))
+ const map = new Map()
+ const edgeIds = new Set()
+ const link = (a: string, b: string) => map.set(a, [...(map.get(a) ?? []), b])
+ for (const e of edges) {
+ if (CONNECTOR_HANDLES.has(e.targetHandle ?? '')) continue
+ if (nodeTypeById.get(e.source) !== 'agent' || nodeTypeById.get(e.target) !== 'agent') continue
+ if (e.source === e.target) continue
+ edgeIds.add(e.id)
+ link(e.source, e.target)
+ link(e.target, e.source)
+ }
+ return { peerIdsByAgent: map, peerEdgeIds: edgeIds }
+ }, [nodes, edges])
+
+ // Stamped onto the RENDERED copy only, never onto the persisted `edges`
+ // state -- an edge in a saved graph carries no data of its own, and this is
+ // derived from the graph anyway. InteractEdge reads it to caption the edge
+ // rather than re-deriving both endpoints' node types per edge.
+ const edgesWithPeerFlag = useMemo(
+ () => edges.map((e) => (peerEdgeIds.has(e.id) ? { ...e, data: { ...e.data, isPeerEdge: true } } : e)),
+ [edges, peerEdgeIds],
+ )
+
+ // The model each agent will actually run on, resolved through its AI
+ // connector. Injected into the node's data rather than read here, because
+ // whether that model can be sent function schemas needs the provider's model
+ // list -- a query, which the node card subscribes to itself (see AgentNode's
+ // peerNeedsToolCalling). Only the *wiring* half belongs in this file.
+ const llmConfigByAgent = useMemo(() => {
+ const nodeById = new Map(nodes.map((n) => [n.id, n]))
+ const map = new Map()
+ for (const e of edges) {
+ if (e.targetHandle !== 'ai') continue
+ const config = (nodeById.get(e.source)?.data as LlmNodeData | undefined)?.config
+ if (config) map.set(e.target, { provider: config.provider, model: config.model })
+ }
+ return map
+ }, [nodes, edges])
+
+ const agentNames = useMemo(
+ () => new Map(nodes.filter((n) => n.type === 'agent').map((n) => [n.id, (n.data as AgentNodeData).label || 'Agent'])),
+ [nodes],
+ )
+
+ // Every agent that could open a conversation -- it has at least one peer.
+ // The user picks which one they're addressing; the rest participate by being
+ // consulted.
+ const conversationCandidates = useMemo(
+ () => [...agentNames.keys()].filter((id) => (peerIdsByAgent.get(id)?.length ?? 0) > 0).map((id) => ({ id, name: agentNames.get(id)! })),
+ [agentNames, peerIdsByAgent],
+ )
+
+ useEffect(() => {
+ if (!conversationCandidates.some((a) => a.id === conversationEntryAgentId)) {
+ setConversationEntryAgentId(conversationCandidates[0]?.id ?? '')
+ }
+ }, [conversationCandidates, conversationEntryAgentId])
+
const nodesWithRunStatus = useMemo((): Node[] => {
return nodes.map((n) => {
const patternHostId = patternHostIds.get(n.id)
@@ -666,6 +761,8 @@ export const ProtocolCanvas = forwardRef 0,
+ llmConfig: n.type === 'agent' ? llmConfigByAgent.get(n.id) ?? null : null,
// Only meaningful once the pattern is actually wired to an agent --
// an orphaned pattern node has no loop to warn about.
hostHasNoTools: !!patternHostId && !agentIdsWithCallableTools.has(patternHostId),
@@ -680,6 +777,8 @@ export const ProtocolCanvas = forwardRef
)}
+ {/* Offered only once two agents are actually wired together --
+ with nobody to consult, a conversation is just a slower
+ single-agent run. */}
+ {conversationCandidates.length > 0 && (
+
+ )}
Canvas locked
)}
+ {runQuery.data?.conversation && (
+
+ )}
{addPanelOpen && serverBrowserOpen ? (
)}
- {pendingRunConfirm && (
+ {(pendingRunConfirm || conversationDialogOpen) && (
setPendingRunConfirm(null)}
+ onCancel={() => {
+ setPendingRunConfirm(null)
+ setConversationDialogOpen(false)
+ }}
onConfirm={confirmPendingRun}
hasUnpublishedChanges={hasUnpublishedChanges}
publishedRevision={publishedRevision}
@@ -1684,6 +1812,44 @@ export const ProtocolCanvas = forwardRef publishAndRunMutation.mutate()}
+ additionalContent={
+ conversationDialogOpen ? (
+
+
+
+
+ ) : undefined
+ }
+ confirmLabel={conversationDialogOpen ? 'Start conversation' : undefined}
+ confirmDisabled={conversationDialogOpen && (!conversationInput.trim() || !conversationEntryAgentId)}
+ isConfirming={conversationMutation.isPending}
+ confirmError={
+ conversationMutation.error instanceof ApiError && typeof conversationMutation.error.detail === 'string'
+ ? conversationMutation.error.detail
+ : conversationMutation.isError
+ ? 'Could not start the conversation.'
+ : null
+ }
/>
)}
{factorPickerNodeId && (
diff --git a/frontend/src/components/protocol/RunConfirmDialog.tsx b/frontend/src/components/protocol/RunConfirmDialog.tsx
index 384b78c..5bd13c4 100644
--- a/frontend/src/components/protocol/RunConfirmDialog.tsx
+++ b/frontend/src/components/protocol/RunConfirmDialog.tsx
@@ -20,6 +20,8 @@ function scopeTitle(scope: RunScope): string {
return scope.title ?? 'Run the experiment?'
case 'node':
return `Run "${scope.label}" alone?`
+ case 'conversation':
+ return `Start a conversation with "${scope.entryLabel}"?`
}
}
@@ -72,7 +74,13 @@ export function RunConfirmDialog({
// A node-scoped run only ever touches that node plus its own directly
// wired dependencies -- an issue on some unrelated node elsewhere on the
// canvas isn't relevant to THIS run, so don't show it here.
- const relevantNodeIds = scope.type === 'node' ? new Set([scope.nodeId, ...edges.filter((e) => e.target === scope.nodeId).map((e) => e.source)]) : null
+ // A conversation is scoped the same way, just to several nodes: only the
+ // participants and their own wiring can misbehave during one.
+ const scopedNodeIds =
+ scope.type === 'node' ? [scope.nodeId] : scope.type === 'conversation' ? scope.participantIds : null
+ const relevantNodeIds = scopedNodeIds
+ ? new Set([...scopedNodeIds, ...edges.filter((e) => scopedNodeIds.includes(e.target)).map((e) => e.source)])
+ : null
const issues = relevantNodeIds ? allIssues.filter((issue) => relevantNodeIds.has(issue.nodeId)) : allIssues
return (
@@ -108,6 +116,18 @@ export function RunConfirmDialog({
: ''}
)}
+ {/* Every consultation is a full agent run of its own -- its own
+ Reason/Plan/Act cycle, its own tokens -- so a conversation can
+ cost several times what the same agents cost in a pipeline run.
+ The caps are the messenger's (services/agent_messenger.py); state
+ the spend ceiling here, before the billable click, rather than
+ letting it be discovered from a transcript afterwards. */}
+ {scope.type === 'conversation' && (
+
+ The entry agent may consult its connected peers, up to 8 consultations nested 2 deep.
+ Each one is a full agent run, so this can cost several times a single-agent run.
+
+ )}
{summary.agentCount} agent{summary.agentCount === 1 ? '' : 's'}
{summary.criticGateCount > 0 ? `, ${summary.criticGateCount} critic gate${summary.criticGateCount === 1 ? '' : 's'}` : ''} will run
diff --git a/frontend/src/components/protocol/edges/InteractEdge.tsx b/frontend/src/components/protocol/edges/InteractEdge.tsx
index e1eefc1..7c47738 100644
--- a/frontend/src/components/protocol/edges/InteractEdge.tsx
+++ b/frontend/src/components/protocol/edges/InteractEdge.tsx
@@ -1,5 +1,5 @@
import { useState, type CSSProperties } from 'react'
-import { BaseEdge, EdgeToolbar, getBezierPath, useReactFlow, type EdgeProps } from '@xyflow/react'
+import { BaseEdge, EdgeLabelRenderer, EdgeToolbar, getBezierPath, useReactFlow, type EdgeProps } from '@xyflow/react'
// Trash2, not an X -- the same glyph NodeHoverToolbar's own Delete button
// uses, so "remove this thing" looks identical whether the thing is a node or
// an edge. An X here also collided with the two other X's on the canvas
@@ -19,11 +19,16 @@ import { useProtocolCanvasActions } from '../ProtocolCanvasContext'
// is a matter of moving the mouse, not squinting.
//
// Solid vs. dashed splits the graph the way it actually reads: solid is the
-// main left-to-right pipeline between agents and critic gates (the flow of
-// work), dashed is everything hanging off a typed connector handle -- LLM,
-// Memory, Tool, Dataset, Script, Architectural Pattern -- which supplies
-// config to a node rather than passing work along. `isMainEdge` (no
-// source/target handle) is exactly that distinction already.
+// main edge between agents and critic gates, dashed is everything hanging off a
+// typed connector handle -- LLM, Memory, Tool, Dataset, Script, Architectural
+// Pattern -- which supplies config to a node rather than joining two peers.
+// `isMainEdge` (no source/target handle) is exactly that distinction already.
+//
+// A solid edge is deliberately not one relationship: between two Agent nodes it
+// is BOTH the left-to-right pipeline edge a normal run walks AND the "these two
+// may consult each other" edge a conversation run reads (undirected). The run
+// mode picks which, so the edge itself must not commit to either -- that's why
+// a peer edge gets a caption below rather than a different stroke.
//
// Note the dashes are NOT the same statement as MemoryNode's dashed ring,
// which means "not yet functional"; here they only mean "connector, not
@@ -71,6 +76,7 @@ export function InteractEdge({
targetHandleId,
style,
markerEnd,
+ data,
}: EdgeProps) {
const [hovered, setHovered] = useState(false)
const { setEdges } = useReactFlow()
@@ -78,6 +84,12 @@ export function InteractEdge({
const [edgePath, labelX, labelY] = getBezierPath({ sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition })
const isMainEdge = !sourceHandleId && !targetHandleId
const isPatternEdge = targetHandleId === 'architectural_pattern'
+ // Stamped by ProtocolCanvas: this main edge joins two Agent nodes, so in a
+ // conversation run it also means "these two may consult each other". It
+ // stays a solid main edge because it is STILL the directed pipeline edge a
+ // normal run walks -- it's both things, and the run mode picks. Hence a
+ // caption rather than a restyle: nothing about the wiring changed.
+ const isPeerEdge = !!(data as { isPeerEdge?: boolean } | undefined)?.isPeerEdge
return (
<>
@@ -103,6 +115,20 @@ export function InteractEdge({
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
/>
+ {/* Same midpoint the toolbar uses, so it yields while hovered rather
+ than sitting under the buttons. --node-label, the same yellow the
+ connector captions use, for the same reason: it annotates the wiring
+ without claiming to be one of the node accent hues. */}
+ {isPeerEdge && !hovered && (
+
+
+ can consult
+
+
+ )}
{/* Nothing to put in it for a pattern edge -- no delete (see above) and
no insert -- so it's skipped entirely rather than rendered empty. */}
diff --git a/frontend/src/components/protocol/nodes/AgentNode.tsx b/frontend/src/components/protocol/nodes/AgentNode.tsx
index 9b416bb..a8020d4 100644
--- a/frontend/src/components/protocol/nodes/AgentNode.tsx
+++ b/frontend/src/components/protocol/nodes/AgentNode.tsx
@@ -8,6 +8,7 @@ import type { AgentNodeData, NodeRunStatus } from '@/types/protocols'
import { boundFactorCount, hasBoundFactor } from '../bindableFields'
import { connectorLefts } from '../layout'
import { useProtocolCanvasActions } from '../ProtocolCanvasContext'
+import { useProviderModels } from '../useProviderModels'
import { ConnectorAddStub } from './ConnectorAddStub'
import { ConnectorHandleLabel } from './ConnectorHandleLabel'
import { MainEdgeAddStub } from './MainEdgeAddStub'
@@ -33,9 +34,33 @@ export function AgentNode({
data,
selected,
}: NodeProps & {
- data: AgentNodeData & { runStatus?: NodeRunStatus; missingLlm?: boolean; canRunAlone?: boolean }
+ data: AgentNodeData & {
+ runStatus?: NodeRunStatus
+ missingLlm?: boolean
+ canRunAlone?: boolean
+ // Both injected by ProtocolCanvas: whether a plain Agent-to-Agent edge
+ // reaches this node, and the model its AI connector resolves to. The
+ // canvas supplies the wiring; the capability lookup below is this card's.
+ hasPeers?: boolean
+ llmConfig?: { provider?: string; model?: string } | null
+ }
}) {
const badge = nodeRunBadge(data.runStatus)
+ // Peers are offered to the model as function schemas -- that is the only
+ // channel a consultation can be *chosen* through -- so an agent on a model
+ // that can't accept them would know its peers exist and never be able to
+ // ask one. Not a misconfiguration (the run succeeds, the agent just works
+ // alone), so it's a card warning rather than a findNodeConfigIssues entry
+ // that interrupts a Run -- the same call the ReAct "this loop won't loop"
+ // warning makes. `supports_tool_calling` is null for a model litellm
+ // doesn't know, which is "can't tell", so only an explicit false warns.
+ const { models } = useProviderModels(data.hasPeers ? data.llmConfig?.provider : undefined)
+ const peerNeedsToolCalling =
+ !!data.hasPeers && models.find((m) => m.id === data.llmConfig?.model)?.supports_tool_calling === false
+ const warnings = [
+ ...(data.missingLlm ? ["No AI connected -- this agent can't run"] : []),
+ ...(peerNeedsToolCalling ? ["This model can't call tools, so this agent can't consult its connected peers"] : []),
+ ]
const { updateNodeData } = useReactFlow()
const { requestRunNode } = useProtocolCanvasActions()
const isActive = data.active ?? true
@@ -97,7 +122,7 @@ export function AgentNode({
0 ? warnings : null}
/>
{/* FOUR connectors live on the TOP edge -- Pattern, Skill, Dataset,
Knowledge, in that reading order -- all of them "what this agent IS
diff --git a/frontend/src/components/protocol/runSummary.ts b/frontend/src/components/protocol/runSummary.ts
index 9b704b5..46393b1 100644
--- a/frontend/src/components/protocol/runSummary.ts
+++ b/frontend/src/components/protocol/runSummary.ts
@@ -46,6 +46,11 @@ export type RunScope =
title?: string
}
| { type: 'node'; nodeId: string; label: string }
+ // Conversation mode: the entry agent plus every agent it can reach over a
+ // plain Agent-to-Agent edge. Unlike a graph run, the canvas is not the
+ // scope -- an agent with no peer edge never gets a turn -- so the
+ // participants are listed explicitly rather than inferred here.
+ | { type: 'conversation'; entryLabel: string; participantIds: string[] }
export interface RunSummary {
agentCount: number
@@ -70,7 +75,12 @@ export interface RunSummary {
// _resolve_dataset_configs do server-side -- kept as a client-side duplicate
// for the same reason nodeConfigIssues.ts already is.
export function summarizeRun(nodes: Node[], edges: Edge[], scope: RunScope): RunSummary {
- const relevantNodes = scope.type === 'node' ? nodesWiredTo(nodes, edges, scope.nodeId) : nodes
+ const relevantNodes =
+ scope.type === 'node'
+ ? nodesWiredTo(nodes, edges, scope.nodeId)
+ : scope.type === 'conversation'
+ ? dedupeById(scope.participantIds.flatMap((id) => nodesWiredTo(nodes, edges, id)))
+ : nodes
const datasets = uniq(
relevantNodes
@@ -149,6 +159,13 @@ function nodesWiredTo(nodes: Node[], edges: Edge[], targetId: string): Node[] {
return nodes.filter((n) => ids.has(n.id))
}
+// Participants share dependencies (one LLM node wired to both agents is the
+// common case), so the per-participant traversals above overlap.
+function dedupeById(candidates: Node[]): Node[] {
+ const byId = new Map(candidates.map((n) => [n.id, n]))
+ return [...byId.values()]
+}
+
function uniq(values: string[]): string[] {
return [...new Set(values)]
}
diff --git a/frontend/src/lib/protocolRun.ts b/frontend/src/lib/protocolRun.ts
index 257ec5f..7419aa7 100644
--- a/frontend/src/lib/protocolRun.ts
+++ b/frontend/src/lib/protocolRun.ts
@@ -2,7 +2,14 @@ import type { NodeRunStatus, ProtocolRun } from '@/types/protocols'
// Shared by ProtocolCanvas's own single-run polling and the "run all cells"
// batch polling on ProtocolCanvasPage -- one definition of "done" for both.
-export const TERMINAL_RUN_STATUSES = new Set(['completed', 'failed', 'cancelled'])
+export const TERMINAL_RUN_STATUSES = new Set([
+ 'completed',
+ 'failed',
+ 'cancelled',
+ // A conversation that ran out of budget is finished, not stuck: nothing
+ // further will be scheduled for it, so polling must stop here too.
+ 'limit_reached',
+])
// Same status-color language the app already uses for cell-scoring progress
// (replicatesStatusAccent in lib/experiment.ts): amber = in progress/queued, cyan
diff --git a/frontend/src/types/llmSettings.ts b/frontend/src/types/llmSettings.ts
index d847941..871a667 100644
--- a/frontend/src/types/llmSettings.ts
+++ b/frontend/src/types/llmSettings.ts
@@ -51,6 +51,10 @@ export interface LLMModelInfo {
supports_temperature: boolean
supports_effort: boolean
effort_levels: string[]
+ // litellm's function-calling flag rather than the capabilities registry --
+ // null means litellm doesn't know this model (every Azure Foundry deployment
+ // name), so treat it as "can't tell" and warn about nothing.
+ supports_tool_calling: boolean | null
}
// Matches src/asaree/api/llm_settings.py's LLMConnectionCheckResponse --
diff --git a/frontend/src/types/protocols.ts b/frontend/src/types/protocols.ts
index 4aa23af..94fedf7 100644
--- a/frontend/src/types/protocols.ts
+++ b/frontend/src/types/protocols.ts
@@ -65,11 +65,40 @@ export interface NodeRunState {
rejection_scope?: string | null
}
+// One turn of an agent-to-agent conversation. Identity and ordering are
+// assigned by the backend, never by a model -- see services/agent_messenger.py.
+// `from_agent_id` is the literal string "user" for the opening question and for
+// the entry agent's final answer back to the user; everything else is a canvas
+// node id. `state` is present on replies only (a request carries no outcome),
+// and uses A2A's TaskState vocabulary.
+export interface ConversationMessage {
+ message_id: string
+ sequence: number
+ from_agent_id: string
+ to_agent_id: string
+ parts: { kind: string; text?: string }[]
+ created_at: string
+ state?: 'working' | 'completed' | 'failed' | 'canceled' | 'rejected' | 'input-required'
+}
+
+export interface Conversation {
+ state: string
+ entry_agent_id: string
+ messages: ConversationMessage[]
+}
+
export interface ProtocolRun {
id: string
protocol_id: string
- status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'
+ // `limit_reached` is conversation-mode only: the agents were still talking
+ // when a budget (consultation count, depth, or the conversation wall clock)
+ // ran out. Distinct from `failed` because the work up to that point is
+ // sound -- the transcript is worth reading.
+ status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' | 'limit_reached'
node_runs: Record
+ // Null for every pipeline run; populated once a conversation-mode run's
+ // agents start talking.
+ conversation: Conversation | null
error: string | null
// Both null for a plain graph run. Set together only for a run created by
// "run all cells" (POST /protocols/{id}/cell-runs) -- factor_values is the
diff --git a/src/asaree/api/llm_settings.py b/src/asaree/api/llm_settings.py
index ab17c0a..2ee665d 100644
--- a/src/asaree/api/llm_settings.py
+++ b/src/asaree/api/llm_settings.py
@@ -15,6 +15,7 @@
from asaree.services.llm_connection_check import check_connection
from asaree.services.llm_model_cache import discover_models_cached, invalidate_models_cache
from asaree.services.rate_limit import check_rate_limit, record_attempt
+from asaree.services.tool_calling import model_tool_calling_support
from asaree.services.user_llm_settings import delete_setting, get_setting, list_settings, upsert_setting
router = APIRouter(prefix="/llm-settings", tags=["llm-settings"])
@@ -55,6 +56,15 @@ class LLMModelInfoResponse(BaseModel):
supports_temperature: bool
supports_effort: bool
effort_levels: list[str]
+ # Unlike the three above, this one doesn't come from Motoro's
+ # ModelCapabilities registry -- it's litellm's own function-calling flag,
+ # the same oracle ``model_supports_tool_calling`` uses to pick an agent's
+ # default execution pattern. Surfaced so the canvas can warn that an agent
+ # wired to peers is on a model that can never be sent function schemas, and
+ # so would silently never consult them. Null when litellm has never heard
+ # of the model (any Azure Foundry deployment name, for one) -- "can't tell"
+ # rather than "no", so an unknown model raises no warning.
+ supports_tool_calling: bool | None
class LLMSettingModelsResponse(BaseModel):
@@ -180,6 +190,7 @@ async def list_models_endpoint(provider: str, user: CurrentUser, db: DbSession)
supports_temperature=m.capabilities.supports_temperature,
supports_effort=m.capabilities.supports_effort,
effort_levels=m.capabilities.effort_levels,
+ supports_tool_calling=model_tool_calling_support(provider, m.id),
)
for m in models
],
diff --git a/src/asaree/services/tool_calling.py b/src/asaree/services/tool_calling.py
new file mode 100644
index 0000000..950e455
--- /dev/null
+++ b/src/asaree/services/tool_calling.py
@@ -0,0 +1,43 @@
+"""Whether a model can be sent function schemas -- as a *tri-state*.
+
+Motoro's ``model_supports_tool_calling`` answers the same question but collapses
+"litellm has never heard of this model" into ``False``, which is the right
+conservative default when the answer picks an execution pattern. It is the wrong
+answer to show a user: an Azure Foundry deployment name is never in litellm's
+map, so every Azure agent on the canvas would wear a warning saying it cannot
+call tools when in fact nobody knows.
+
+So this returns ``None`` for an unrecognised model and lets the caller say
+nothing. Same oracle, same model string, one more outcome.
+"""
+
+from __future__ import annotations
+
+import litellm
+from motoro.schemas.agent import ModelConfig
+from motoro.services.llm_service import model_supports_tool_calling
+
+
+def model_tool_calling_support(provider: str, model: str) -> bool | None:
+ """``True``/``False`` when litellm knows *model*, ``None`` when it doesn't."""
+ if not model:
+ return None
+ try:
+ config = ModelConfig(provider=provider, model=model)
+ except Exception: # noqa: BLE001 -- an unknown provider is a "can't tell", not a crash
+ return None
+ if not _litellm_knows(config):
+ return None
+ return model_supports_tool_calling(config)
+
+
+def _litellm_knows(config: ModelConfig) -> bool:
+ # Deliberately the same string model_supports_tool_calling builds -- an
+ # answer derived from a different one would be about a different model.
+ prefix = {"azure_foundry": "azure_ai", "local": "openai"}.get(config.provider.value)
+ model_str = f"{prefix}/{config.model}" if prefix else config.model
+ try:
+ litellm.get_model_info(model_str)
+ except Exception: # noqa: BLE001 -- litellm raises a plain Exception for an unmapped model
+ return False
+ return True
From 0c61963d0ed79cb863ee912f0019a432b4ea847c Mon Sep 17 00:00:00 2001
From: Jay Moran
Date: Tue, 8 Sep 2026 15:49:55 -0700
Subject: [PATCH 05/57] Clear the stuck AgentAction contract off protocol
agents
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.
---
scripts/repair_contaminated_agents.py | 82 +++++++++++++++++++++++++++
1 file changed, 82 insertions(+)
create mode 100644 scripts/repair_contaminated_agents.py
diff --git a/scripts/repair_contaminated_agents.py b/scripts/repair_contaminated_agents.py
new file mode 100644
index 0000000..61f0615
--- /dev/null
+++ b/scripts/repair_contaminated_agents.py
@@ -0,0 +1,82 @@
+"""Clear the stuck ``AgentAction`` output contract off protocol-owned agents.
+
+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
+Motoro agent row ``protocol-{protocol_id}-{node_id}``. ``update_agent`` reads
+``None`` as "leave unchanged" and a normal agent node passes
+``output_contract=None``, so that contract is never cleared: every later
+ordinary run of that node pays for a junk extraction LLM call forever.
+
+The current design writes no such thing (see the rule on ``_sync_durable_agent``
+in ``services/protocol_execution.py``), so this is a one-off repair of rows
+contaminated before the fix. It is a direct UPDATE because ``update_agent``
+offers no way to set a field back to NULL.
+
+Only ``output_contract`` is stuck. The system prompt was contaminated the same
+way but is passed non-``None`` on every run, so it self-heals the next time the
+node runs.
+
+Usage (inside the asaree-app container, so DATABASE_URL is the real one).
+``scripts/`` is baked into the image rather than bind-mounted, so a copy is
+needed until the image is rebuilt:
+
+ docker cp scripts/repair_contaminated_agents.py asaree-app:/app/scripts/
+ docker exec asaree-app python scripts/repair_contaminated_agents.py # dry run
+ docker exec asaree-app python scripts/repair_contaminated_agents.py --apply
+"""
+
+from __future__ import annotations
+
+import asyncio
+import sys
+
+import motoro.models.run # noqa: F401 -- registers AgentRun, which Agent's relationship() names
+from motoro.config import configure
+from motoro.models.agent import Agent
+from motoro.models.database import system_session
+from sqlalchemy import select, update
+
+from asaree.config import get_settings
+
+# The contract the old design injected. Matched by name rather than by whole
+# value so a row whose schema drifted is still repaired, and so a legitimate
+# user-authored contract on a protocol agent is left alone.
+CONTAMINANT = "AgentAction"
+
+
+async def main(*, apply: bool) -> int:
+ configure(get_settings())
+ async with system_session(reason="one-off repair: clear injected AgentAction contract") as db:
+ rows = (
+ await db.execute(
+ select(Agent.id, Agent.name, Agent.output_contract)
+ .where(Agent.name.like("protocol-%"))
+ .where(Agent.output_contract.is_not(None))
+ )
+ ).all()
+ contaminated = [
+ (agent_id, name)
+ for agent_id, name, contract in rows
+ if isinstance(contract, dict) and contract.get("name") == CONTAMINANT
+ ]
+ for _, name in contaminated:
+ print(f"contaminated: {name}")
+ if not contaminated:
+ print("nothing to repair")
+ return 0
+ if not apply:
+ print(f"\n{len(contaminated)} agent(s) would be cleared; re-run with --apply")
+ return 0
+ await db.execute(
+ update(Agent).where(Agent.id.in_([agent_id for agent_id, _ in contaminated])).values(output_contract=None)
+ )
+ print(f"\ncleared output_contract on {len(contaminated)} agent(s)")
+ return 0
+
+
+if __name__ == "__main__":
+ args = sys.argv[1:]
+ if args not in ([], ["--apply"]):
+ print(f"usage: {sys.argv[0]} [--apply]", file=sys.stderr)
+ raise SystemExit(2)
+ raise SystemExit(asyncio.run(main(apply=args == ["--apply"])))
From 2af50099526f60a4d7de52212bb1df27f90fd756 Mon Sep 17 00:00:00 2001
From: Jay Moran
Date: Tue, 8 Sep 2026 15:53:49 -0700
Subject: [PATCH 06/57] Add a compose overlay for running against the working
trees
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.
---
compose.dev.yml | 46 ++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 46 insertions(+)
create mode 100644 compose.dev.yml
diff --git a/compose.dev.yml b/compose.dev.yml
new file mode 100644
index 0000000..77dc55c
--- /dev/null
+++ b/compose.dev.yml
@@ -0,0 +1,46 @@
+# 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
+
+ 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
+
+ asaree-worker:
+ volumes:
+ - ./data:/app/data
+ - ./src:/app/src
+ - ${MOTORO_SRC:-../Motoro/src/motoro}:/app/.venv/lib/python3.13/site-packages/motoro:ro
From c56c09bb0165929d3c4ca456886076bb157bb36a Mon Sep 17 00:00:00 2001
From: Jay Moran
Date: Tue, 8 Sep 2026 16:26:52 -0700
Subject: [PATCH 07/57] Add Peer Collaboration as a coordination strategy
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".
---
.../src/components/protocol/DesignTab.tsx | 10 +-
frontend/src/types/experiments.ts | 65 +----
src/asaree/api/protocols.py | 15 +-
src/asaree/services/agent_messenger.py | 257 ++++++++++++++----
src/asaree/services/protocol_execution.py | 171 ++++++++++--
tests/test_agent_messenger.py | 84 +++++-
tests/test_protocol_execution.py | 135 +++++++--
7 files changed, 565 insertions(+), 172 deletions(-)
diff --git a/frontend/src/components/protocol/DesignTab.tsx b/frontend/src/components/protocol/DesignTab.tsx
index 06802f5..d80acd5 100644
--- a/frontend/src/components/protocol/DesignTab.tsx
+++ b/frontend/src/components/protocol/DesignTab.tsx
@@ -727,7 +727,8 @@ export function DesignTab({
Declares how the agents in this protocol work together as a multi-agent system -- separate from the
canvas graph itself, which only wires connections. The graph must actually match whatever you pick here
- (e.g. "Critic Gate" requires a real Critic Gate node wired in) or running the protocol is rejected.
+ ("Critic Gate" requires a real Critic Gate node wired in; "Peer Collaboration" requires at least two
+ connected Agent nodes, with one of them left unfed to lead) or running the protocol is rejected.
{selectedStrategy &&
- Not yet implemented -- coming with the ARES pattern migration. Saving this choice declares intent, but running
- this protocol will be rejected until it's backed.
-
- )}
diff --git a/frontend/src/types/experiments.ts b/frontend/src/types/experiments.ts
index b8a9278..3d2b721 100644
--- a/frontend/src/types/experiments.ts
+++ b/frontend/src/types/experiments.ts
@@ -58,22 +58,13 @@ export interface MetricScoringConfig {
}
}
-// "sequential" (default when design_spec.coordination_strategy is absent --
-// today's exact existing DAG-handoff behavior) and "critic_gate" (promotes
-// the existing gated-pair mechanism to an explicit declaration) are real.
-// The rest are named placeholders for ARES's own coordination-category
-// patterns, pending a later ARES -> Motoro migration -- selectable and
-// saveable, but services.protocol_execution rejects a run attempted with one
-// of these active. See COORDINATION_STRATEGY_CATALOG for display metadata.
-export type CoordinationStrategySlug =
- | 'sequential'
- | 'critic_gate'
- | 'supervisor_architecture'
- | 'swarm_architecture'
- | 'task_bidding'
- | 'supervision_tree_with_guarded_capabilities'
- | 'event_driven_reactivity'
- | 'multi_agent_planning'
+// Every slug here is implemented -- there is deliberately no "coming soon"
+// entry. Six ARES coordination-category placeholders (supervisor, swarm, task
+// bidding, supervision tree, event-driven, multi-agent planning) used to be
+// listed and were removed: an option that always fails at run time is worse
+// than an option that isn't offered. The backend still recognizes those slugs
+// so an experiment saved with one gets a real explanation, not "unknown".
+export type CoordinationStrategySlug = 'sequential' | 'critic_gate' | 'peer_collaboration'
export interface CoordinationStrategyConfig {
slug: CoordinationStrategySlug
@@ -84,58 +75,26 @@ export const COORDINATION_STRATEGY_CATALOG: {
slug: CoordinationStrategySlug
label: string
description: string
- implemented: boolean
}[] = [
{
slug: 'sequential',
label: 'Sequential (default)',
description: "Each agent's output becomes the next agent's input, following the canvas's own edges in order.",
- implemented: true,
},
{
slug: 'critic_gate',
label: 'Critic Gate',
description: 'A reviewer agent approves or requests revisions at a fixed point in the sequential pipeline.',
- implemented: true,
},
{
- slug: 'supervisor_architecture',
- label: 'Supervisor',
- description: 'One coordinator delegates sub-tasks to worker agents and aggregates their results.',
- implemented: false,
- },
- {
- slug: 'swarm_architecture',
- label: 'Swarm',
- description: 'Agents self-organize around a shared task board -- no fixed coordinator.',
- implemented: false,
- },
- {
- slug: 'task_bidding',
- label: 'Task Bidding',
- description: 'Agents competitively bid for tasks; the best-scoring bid is awarded the work.',
- implemented: false,
- },
- {
- slug: 'supervision_tree_with_guarded_capabilities',
- label: 'Supervision Tree',
- description: 'A hierarchical tree of agents with capability-scoped subtrees and structured failure recovery.',
- implemented: false,
- },
- {
- slug: 'event_driven_reactivity',
- label: 'Event-Driven',
- description: 'Agents react to published events on shared topics instead of a fixed plan.',
- implemented: false,
- },
- {
- slug: 'multi_agent_planning',
- label: 'Multi-Agent Planning',
- description: 'Multiple planner agents propose in parallel; a coordinator merges them into one plan for workers.',
- implemented: false,
+ slug: 'peer_collaboration',
+ label: 'Peer Collaboration',
+ description:
+ 'Connected agents work the task together as a conversation -- the lead agent can consult its peers, and each reply is shared with everyone, instead of handing off once.',
},
]
+
export interface DesignSpec {
factors?: DesignFactor[]
// Copies per factor-level combination (default 1 when absent).
diff --git a/src/asaree/api/protocols.py b/src/asaree/api/protocols.py
index c39ddf7..f918ffc 100644
--- a/src/asaree/api/protocols.py
+++ b/src/asaree/api/protocols.py
@@ -20,7 +20,6 @@
from asaree.services.factor_bindings import validate_factor_bindings
from asaree.services.protocol_execution import (
ProtocolValidationError,
- find_gated_pairs,
plan_cell_runs,
plan_single_replicate_run,
topological_order,
@@ -84,6 +83,7 @@ class ProtocolResponse(BaseModel):
created_at: datetime
updated_at: datetime
+
class ProtocolRunResponse(BaseModel):
id: uuid.UUID
protocol_id: uuid.UUID
@@ -117,6 +117,7 @@ class ProtocolRevisionResponse(BaseModel):
class Config:
from_attributes = True
+
class CreateProtocolRunRequest(BaseModel):
# Omitted/null -- today's ad-hoc, un-substituted whole-graph run. Set --
# runs that one already-generated replicate for real, its cell's factor_values
@@ -205,9 +206,7 @@ async def _validated_experiment_id(
@router.post("", response_model=ProtocolResponse, status_code=201)
-async def create_protocol_endpoint(
- body: CreateProtocolRequest, user: CurrentUser, db: DbSession
-) -> ProtocolResponse:
+async def create_protocol_endpoint(body: CreateProtocolRequest, user: CurrentUser, db: DbSession) -> ProtocolResponse:
if await get_protocol_by_name(db, body.name, owner_id=user.id) is not None:
raise HTTPException(status_code=409, detail="A protocol with this name already exists")
experiment_id = await _validated_experiment_id(body.experiment_id, db, user)
@@ -272,7 +271,7 @@ async def publish_protocol_endpoint(protocol_id: uuid.UUID, user: CurrentUser, d
topological_order(protocol.graph)
experiment = await get_experiment(db, protocol.experiment_id) if protocol.experiment_id else None
design_spec = experiment.design_spec if experiment is not None else None
- validate_coordination_strategy(design_spec, has_gated_pair=bool(find_gated_pairs(protocol.graph)))
+ validate_coordination_strategy(design_spec, graph=protocol.graph)
validate_factor_bindings(design_spec, protocol.graph)
except (ProtocolValidationError, ValueError) as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
@@ -323,7 +322,7 @@ async def create_protocol_run_endpoint(
topological_order(revision.graph)
experiment = await get_experiment(db, protocol.experiment_id) if protocol.experiment_id else None
design_spec = experiment.design_spec if experiment is not None else None
- validate_coordination_strategy(design_spec, has_gated_pair=bool(find_gated_pairs(revision.graph)))
+ validate_coordination_strategy(design_spec, graph=revision.graph)
run = await create_protocol_run(
db, protocol_id=protocol_id, owner_id=user.id, protocol_revision_id=revision.id
)
@@ -350,9 +349,7 @@ async def start_conversation_endpoint(
validate_conversation_entry(revision.graph, body.entry_agent_id)
except ProtocolValidationError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
- run = await create_protocol_run(
- db, protocol_id=protocol_id, owner_id=user.id, protocol_revision_id=revision.id
- )
+ run = await create_protocol_run(db, protocol_id=protocol_id, owner_id=user.id, protocol_revision_id=revision.id)
await enqueue_conversation(run.id, entry_agent_id=body.entry_agent_id, user_input=body.user_input)
return ProtocolRunResponse.model_validate(run)
diff --git a/src/asaree/services/agent_messenger.py b/src/asaree/services/agent_messenger.py
index 8012086..f69abe7 100644
--- a/src/asaree/services/agent_messenger.py
+++ b/src/asaree/services/agent_messenger.py
@@ -13,6 +13,13 @@
read, so it absorbs the outcome and still writes a real answer. Only genuine
infrastructure failure raises.
+**Every turn reads the whole conversation.** A consulted peer is given the
+transcript so far (:meth:`AgentMessenger._briefing`) ahead of the question, so it
+recalls its own earlier turns and can build on what other agents have already
+found. That is one mechanism serving both, because both are the same question --
+what does this turn get to read. The entry agent needs no briefing: it is a
+single continuous run, so its own scratchpad already holds every reply it got.
+
**Capability is snapshotted; reachability is live.** A peer's card describes it
as the published revision configures it, so a canvas edit cannot hot-patch a
run's agents mid-flight. Authorization is re-asked of the *draft* graph on every
@@ -40,9 +47,9 @@
from asaree.services.experiments import get_experiment
from asaree.services.protocol_execution import (
_AGENT_CANCELLED,
- _ambient_meta_for,
_can_deliver_communication,
_compute_workspace_id,
+ _node_run_context,
_run_agent_node,
resolve_available_agents,
)
@@ -72,6 +79,21 @@
#: authorization would refuse if anything tried.
USER_PARTICIPANT = "user"
+#: How much of one earlier message a briefing reproduces. A peer's own analysis
+#: can run to thousands of tokens, and eight of them would crowd out the
+#: question actually being asked. Truncation is marked so the reading model can
+#: tell a cut-off answer from a short one.
+_MAX_BRIEFING_CHARS_PER_MESSAGE = 1500
+
+#: How a non-``completed`` reply is described in a briefing. A turn that was
+#: refused or failed is part of what happened and stays in the transcript, but
+#: it must not read as an answer somebody gave.
+_BRIEFING_STATE_NOTE = {
+ "rejected": " (refused)",
+ "failed": " (could not answer)",
+ "canceled": " (cancelled)",
+}
+
def _text_of(parts: list[dict[str, Any]]) -> str:
return "\n".join(str(p.get("text", "")) for p in parts if p.get("kind") == "text").strip()
@@ -112,6 +134,14 @@ def __init__(
self._depth = 0
self._sequence = 0
self._messages: list[dict[str, Any]] = []
+ #: Node id -> the label the canvas shows, so a briefing names agents the
+ #: way the user does. Same source ``build_agent_card`` uses, and the same
+ #: node-id fallback, so a peer is called one thing everywhere.
+ self._display_names = {
+ str(n.get("id")): str((n.get("data") or {}).get("label") or "").strip() or str(n.get("id"))
+ for n in graph.get("nodes") or []
+ if n.get("type") == "agent"
+ }
self._state = "working"
#: Set when a cap is what stopped the conversation, so the run can land
#: on ``limit_reached`` rather than looking like a clean completion.
@@ -158,6 +188,65 @@ async def checkpoint(self) -> None:
async with get_session() as db:
await update_conversation(db, self._protocol_run_id, self.conversation)
+ # -- briefing ------------------------------------------------------
+
+ def _display_name(self, participant_id: str) -> str:
+ if participant_id == USER_PARTICIPANT:
+ return "The user"
+ return self._display_names.get(participant_id, participant_id)
+
+ def _briefing(self, *, from_agent_id: str, to_agent_id: str, exclude_message_id: str) -> str:
+ """What has already been said, rendered for the agent about to speak.
+
+ This is the whole of both "a peer remembers its own earlier turns" and
+ "agents see each other's work". One mechanism, because they are the same
+ question -- what does this turn get to read -- and splitting them would
+ mean two things to keep consistent.
+
+ Every participant sees the *entire* transcript, not a filtered view: a
+ conversation exists so that agents can build on each other, and deciding
+ for them which of their colleagues' findings are relevant would be the
+ orchestrator doing the reasoning. Everything here happened inside one
+ protocol run owned by one user, so there is nothing to partition.
+
+ Memory is *reconstructed* rather than resumed: a peer still gets a fresh
+ ``AgentRun`` per turn, and this is what carries its history across them.
+ That keeps each consultation separately attributable in the Runs tab and
+ priced on its own, which a resumed run would lose -- and it means a
+ retried worker rebuilds identical context from the checkpointed
+ transcript instead of needing a live run to still exist.
+
+ Returns ``""`` when there is nothing to report, so the very first
+ consultation of a conversation reads exactly as it did before.
+ """
+ entries: list[str] = []
+ for message in self._messages:
+ if message["message_id"] == exclude_message_id:
+ continue
+ body = _text_of(message["parts"])
+ if not body:
+ continue
+ if len(body) > _MAX_BRIEFING_CHARS_PER_MESSAGE:
+ body = body[:_MAX_BRIEFING_CHARS_PER_MESSAGE].rstrip() + " [...truncated]"
+ note = _BRIEFING_STATE_NOTE.get(str(message.get("state") or ""), "")
+ sender = self._display_name(message["from_agent_id"])
+ recipient = self._display_name(message["to_agent_id"])
+ entries.append(f"{sender} -> {recipient}{note}:\n{body}")
+ if not entries:
+ return ""
+ # Second person and the agent's own name together: the transcript refers
+ # to it in the third person, so it has to be able to find itself in what
+ # it is reading.
+ return (
+ f"You are {self._display_name(to_agent_id)}, taking part in a conversation between agents "
+ "working on the same problem. Everything said so far is below, including your own earlier "
+ "turns. Build on it rather than starting over, and don't repeat work that is already done.\n\n"
+ "--- conversation so far ---\n"
+ + "\n\n".join(entries)
+ + "\n--- end of conversation ---\n\n"
+ + f"{self._display_name(from_agent_id)} is now asking you:\n\n"
+ )
+
# -- delivery ------------------------------------------------------
async def send(
@@ -178,7 +267,7 @@ async def send(
needs is per-protocol-run state held on the instance, and reading run
state out of the engine's context would be a second source of truth.
"""
- self.append(from_agent_id=from_agent_id, to_agent_id=to_agent_id, parts=parts)
+ request = self.append(from_agent_id=from_agent_id, to_agent_id=to_agent_id, parts=parts)
refusal = await self._refusal(from_agent_id, to_agent_id)
if refusal is not None:
@@ -195,7 +284,14 @@ async def send(
# failure is the same unfairness as charging it for a peer's
# success. The conversation-level cap above keeps ticking.
with deadlines_paused():
- output_text, error, run_id = await self._run_peer(to_agent_id, parts)
+ # Built here, before the peer runs, so it is a snapshot of the
+ # conversation as it stood when the question was asked.
+ briefing = self._briefing(
+ from_agent_id=from_agent_id,
+ to_agent_id=to_agent_id,
+ exclude_message_id=request["message_id"],
+ )
+ output_text, error, run_id = await self._run_peer(to_agent_id, parts, briefing=briefing)
finally:
self._depth -= 1
@@ -282,13 +378,18 @@ async def _refusal(self, from_agent_id: str, to_agent_id: str) -> str | None:
return None
async def _run_peer(
- self, to_agent_id: str, parts: list[dict[str, Any]]
+ self, to_agent_id: str, parts: list[dict[str, Any]], *, briefing: str = ""
) -> tuple[str | None, str | None, str | None]:
"""Give the peer its own full turn.
A real nested agent run, not a prompt trick: its own Motoro ``AgentRun``,
so cost, steps and the Runs tab attribute it separately, and its own
``available_agents`` so it may consult back within the depth cap.
+
+ *briefing* (:meth:`_briefing`) prefixes the question with the
+ conversation so far. It rides on ``user_input`` because that is the one
+ channel the model actually reads -- ``ambient_meta`` is bound into MCP
+ tool calls and never shown to it.
"""
node = next((n for n in self._graph.get("nodes") or [] if str(n.get("id")) == to_agent_id), None)
if node is None:
@@ -299,15 +400,20 @@ async def _run_peer(
async with get_session() as db:
await update_node_run(db, self._protocol_run_id, to_agent_id, {"status": "running"})
+ # The same References resolution a pipeline node gets, not just the
+ # bare ambient meta: a consulted peer with a Dataset connector needs its
+ # workspace seeded and its `data_path` bound before it can run a script,
+ # exactly like any other node.
+ ambient_meta, _dataset = await _node_run_context(self._graph, to_agent_id, self._workspace_id, self._owner_id)
output_text, error, run_id = await _run_agent_node(
node,
protocol_id=self._protocol_id,
protocol_run_id=self._protocol_run_id,
owner_id=self._owner_id,
- user_input=_text_of(parts),
+ user_input=f"{briefing}{_text_of(parts)}",
graph=self._graph,
workspace_id=self._workspace_id,
- ambient_meta=_ambient_meta_for(self._graph, to_agent_id, self._workspace_id),
+ ambient_meta=ambient_meta,
available_agents=await resolve_available_agents(self._graph, to_agent_id, owner_id=self._owner_id),
agent_messenger=self,
)
@@ -329,8 +435,20 @@ async def _run_peer(
return output_text, error, str(run_id) if run_id else None
-async def run_conversation(protocol_run_id: uuid.UUID, *, entry_agent_id: str, user_input: str) -> None:
- """Execute a protocol run in conversation mode.
+async def execute_conversation(
+ protocol_run_id: uuid.UUID,
+ *,
+ protocol_id: uuid.UUID,
+ owner_id: uuid.UUID,
+ graph: dict[str, Any],
+ entry_agent_id: str,
+ user_input: str,
+ workspace_id: str | None,
+ ambient_meta: dict[str, Any] | None = None,
+ evaluation_metrics: Any = None,
+) -> tuple[dict[str, Any], str]:
+ """The conversation itself: seed the transcript, run the entry agent, map
+ its outcome to a terminal conversation state, checkpoint.
Deliberately *not* a turn scheduler. It starts exactly one agent -- the one
the user addressed -- and consultation is driven from inside that run by the
@@ -339,37 +457,19 @@ async def run_conversation(protocol_run_id: uuid.UUID, *, entry_agent_id: str, u
``finish`` action, and the only new thing in its world is that a peer exists
and can be asked.
- So what remains here is: seed the transcript, run the entry agent, map its
- outcome to a terminal conversation state, checkpoint.
+ Returns the entry agent's node-run dict and the terminal ``ProtocolRun``
+ status. It writes the node run but deliberately *not* the run's status,
+ because it has two callers with different bookkeeping: :func:`run_conversation`
+ (the user asked an agent a question directly) sets it and stops, while a
+ ``peer_collaboration`` factorial cell run wraps this in the same pre-write /
+ result / metric-promotion path every other cell run uses, and owns the
+ status so that path stays in one place.
+
+ *ambient_meta* is likewise the caller's when it has already resolved the
+ entry agent's References to build *user_input* (a cell run does, to get the
+ dataset and script cues into the prompt) -- resolving it twice would seed
+ the workspace twice.
"""
- async with get_session() as db:
- run = await get_protocol_run(db, protocol_run_id)
- if run is None:
- return
- protocol = await get_protocol(db, run.protocol_id)
- if protocol is None:
- await set_status(db, protocol_run_id, status="failed", error="protocol no longer exists")
- return
- protocol_id, owner_id, graph = protocol.id, run.owner_id, protocol.graph
- if run.protocol_revision_id is not None:
- revision = await get_revision(db, run.protocol_revision_id)
- if revision is None:
- await set_status(
- db, protocol_run_id, status="failed", error="published protocol revision no longer exists"
- )
- return
- graph = revision.graph
- experiment_id = protocol.experiment_id
- experiment = await get_experiment(db, experiment_id) if experiment_id else None
- evaluation_metrics = (experiment.design_spec or {}).get("metrics") if experiment is not None else None
-
- node = next((n for n in graph.get("nodes") or [] if str(n.get("id")) == entry_agent_id), None)
- if node is None or node.get("type") != "agent":
- async with get_session() as db:
- await set_status(db, protocol_run_id, status="failed", error="entry agent is not an Agent node")
- return
-
- workspace_id = _compute_workspace_id(experiment_id, None, protocol_run_id)
messenger = AgentMessenger(
protocol_id=protocol_id,
protocol_run_id=protocol_run_id,
@@ -384,11 +484,14 @@ async def run_conversation(protocol_run_id: uuid.UUID, *, entry_agent_id: str, u
parts=[{"kind": "text", "text": user_input}],
)
+ node = next(n for n in graph.get("nodes") or [] if str(n.get("id")) == entry_agent_id)
async with get_session() as db:
- await set_status(db, protocol_run_id, status="running")
await update_node_run(db, protocol_run_id, entry_agent_id, {"status": "running"})
await messenger.checkpoint()
+ if ambient_meta is None:
+ ambient_meta, _dataset = await _node_run_context(graph, entry_agent_id, workspace_id, owner_id)
+
output_text, error, run_id = await _run_agent_node(
node,
protocol_id=protocol_id,
@@ -397,7 +500,7 @@ async def run_conversation(protocol_run_id: uuid.UUID, *, entry_agent_id: str, u
user_input=user_input,
graph=graph,
workspace_id=workspace_id,
- ambient_meta=_ambient_meta_for(graph, entry_agent_id, workspace_id),
+ ambient_meta=ambient_meta,
evaluation_metrics=evaluation_metrics,
available_agents=await resolve_available_agents(graph, entry_agent_id, owner_id=owner_id),
agent_messenger=messenger,
@@ -423,23 +526,73 @@ async def run_conversation(protocol_run_id: uuid.UUID, *, entry_agent_id: str, u
messenger.set_state(state)
await messenger.checkpoint()
+ node_run = {
+ "status": "cancelled" if cancelled else ("failed" if error else "completed"),
+ "output_text": output_text,
+ "error": None if cancelled else error,
+ "run_id": str(run_id) if run_id else None,
+ }
async with get_session() as db:
- await update_node_run(
- db,
- protocol_run_id,
- entry_agent_id,
- {
- "status": "cancelled" if cancelled else ("failed" if error else "completed"),
- "output_text": output_text,
- "error": None if cancelled else error,
- "run_id": str(run_id) if run_id else None,
- },
- )
- await set_status(db, protocol_run_id, status=status, error=None if cancelled else error)
+ await update_node_run(db, protocol_run_id, entry_agent_id, node_run)
+ return node_run, status
+
+
+async def run_conversation(protocol_run_id: uuid.UUID, *, entry_agent_id: str, user_input: str) -> None:
+ """Execute a protocol run in conversation mode, as started from the canvas.
+
+ Loads the run's pinned graph, hands off to :func:`execute_conversation`, and
+ records the terminal status. There is no cell, replicate or score here --
+ this is the user talking to an agent cluster, not an experiment measuring
+ one; that path is ``run_protocol``'s ``peer_collaboration`` branch.
+ """
+ async with get_session() as db:
+ run = await get_protocol_run(db, protocol_run_id)
+ if run is None:
+ return
+ protocol = await get_protocol(db, run.protocol_id)
+ if protocol is None:
+ await set_status(db, protocol_run_id, status="failed", error="protocol no longer exists")
+ return
+ protocol_id, owner_id, graph = protocol.id, run.owner_id, protocol.graph
+ if run.protocol_revision_id is not None:
+ revision = await get_revision(db, run.protocol_revision_id)
+ if revision is None:
+ await set_status(
+ db, protocol_run_id, status="failed", error="published protocol revision no longer exists"
+ )
+ return
+ graph = revision.graph
+ experiment_id = protocol.experiment_id
+ experiment = await get_experiment(db, experiment_id) if experiment_id else None
+ evaluation_metrics = (experiment.design_spec or {}).get("metrics") if experiment is not None else None
+
+ node = next((n for n in graph.get("nodes") or [] if str(n.get("id")) == entry_agent_id), None)
+ if node is None or node.get("type") != "agent":
+ async with get_session() as db:
+ await set_status(db, protocol_run_id, status="failed", error="entry agent is not an Agent node")
+ return
+
+ async with get_session() as db:
+ await set_status(db, protocol_run_id, status="running")
+
+ node_run, status = await execute_conversation(
+ protocol_run_id,
+ protocol_id=protocol_id,
+ owner_id=owner_id,
+ graph=graph,
+ entry_agent_id=entry_agent_id,
+ user_input=user_input,
+ workspace_id=_compute_workspace_id(experiment_id, None, protocol_run_id),
+ evaluation_metrics=evaluation_metrics,
+ )
+
+ async with get_session() as db:
+ await set_status(db, protocol_run_id, status=status, error=node_run["error"])
__all__ = [
"USER_PARTICIPANT",
"AgentMessenger",
+ "execute_conversation",
"run_conversation",
]
diff --git a/src/asaree/services/protocol_execution.py b/src/asaree/services/protocol_execution.py
index 57199e1..b1dddd6 100644
--- a/src/asaree/services/protocol_execution.py
+++ b/src/asaree/services/protocol_execution.py
@@ -407,15 +407,19 @@ class ProtocolValidationError(Exception):
# mechanism (find_gated_pairs/_run_gated_worker, unchanged) from purely
# implicit-in-the-graph to an explicit, checked declaration: the graph must
# actually contain a gated pair, or the declared intent doesn't match
-# reality. The rest mirror ARES's own coordination-category patterns
-# (supervisor/swarm/task-bidding/supervision-tree/event-driven/multi-agent-
-# planning) -- named placeholders pending a later ARES -> Motoro
-# migration (the user's own call), matching this codebase's "declares
-# intent, no runtime effect yet" posture for Memory nodes -- except a
-# placeholder coordination strategy is REJECTED at run time rather than
-# silently inert, since (unlike a Memory node) choosing one is a claim about
-# how the whole graph runs, not a connector with no effect either way.
-_PLACEHOLDER_COORDINATION_STRATEGIES = frozenset(
+# reality. "peer_collaboration" is the third, and the only one that changes how
+# a cell run executes at all: the graph runs as a conversation (see
+# ``services.agent_messenger``) instead of as a one-pass DAG walk, so connected
+# agents can consult each other while working, and each declared cell/replicate
+# still records exactly one result the same way.
+#
+# Six further slugs mirroring ARES's own coordination categories (supervisor/
+# swarm/task-bidding/supervision-tree/event-driven/multi-agent-planning) used to
+# be offered as named placeholders. They were removed from the picker rather
+# than left selectable-but-rejected -- an option that always errors is worse
+# than an option that isn't there. The frozenset stays so an experiment whose
+# design_spec still names one gets that explanation instead of a bare "unknown".
+_RETIRED_COORDINATION_STRATEGIES = frozenset(
{
"supervisor_architecture",
"swarm_architecture",
@@ -427,24 +431,79 @@ class ProtocolValidationError(Exception):
)
-def validate_coordination_strategy(design_spec: dict[str, Any] | None, *, has_gated_pair: bool) -> None:
- slug = ((design_spec or {}).get("coordination_strategy") or {}).get("slug") or "sequential"
+def coordination_strategy_slug(design_spec: dict[str, Any] | None) -> str:
+ return str(((design_spec or {}).get("coordination_strategy") or {}).get("slug") or "sequential")
+
+
+def validate_coordination_strategy(design_spec: dict[str, Any] | None, *, graph: dict[str, Any]) -> None:
+ """Checks the experiment's declared strategy against the protocol it will
+ run. Takes the whole graph rather than a pre-computed fact about it because
+ each strategy asks a different question of the canvas."""
+ slug = coordination_strategy_slug(design_spec)
if slug == "sequential":
return
if slug == "critic_gate":
- if not has_gated_pair:
+ if not find_gated_pairs(graph):
raise ProtocolValidationError(
"This experiment's coordination strategy is 'Critic Gate' but this protocol has no Critic Gate "
"node wired in -- add one, or change the coordination strategy on the Design tab."
)
return
- if slug in _PLACEHOLDER_COORDINATION_STRATEGIES:
+ if slug == "peer_collaboration":
+ # Resolving the entry agent *is* the validation: it fails unless the
+ # canvas has connected agents and says unambiguously which one leads.
+ validate_conversation_entry(graph, resolve_conversation_entry_id(graph))
+ return
+ if slug in _RETIRED_COORDINATION_STRATEGIES:
raise ProtocolValidationError(
- f"Coordination strategy {slug!r} isn't implemented yet -- coming with the ARES pattern migration."
+ f"Coordination strategy {slug!r} was never implemented and is no longer offered -- "
+ "pick another one on the Design tab."
)
raise ProtocolValidationError(f"Unknown coordination strategy: {slug!r}")
+def resolve_conversation_entry_id(graph: dict[str, Any]) -> str:
+ """Which agent the user's question goes to when this graph runs as a
+ conversation.
+
+ Read off the canvas rather than configured separately. A peer edge is
+ undirected for *consultation*, but the user still drew it in a direction,
+ and that direction is the only statement of intent available -- so the entry
+ agent is the peer-connected agent that nothing upstream feeds, i.e. exactly
+ the node a pipeline run would have started at. One agent has to lead; if two
+ are equally plausible starting points there's no honest way to pick, and
+ guessing would silently drop half the canvas out of the run.
+ """
+ nodes = {str(n.get("id")): n for n in graph.get("nodes") or [] if n.get("id")}
+ peer_agents = [nid for nid in nodes if nodes[nid].get("type") == "agent" and _connected_agent_ids(graph, nid)]
+ if not peer_agents:
+ raise ProtocolValidationError(
+ "This experiment's coordination strategy is 'Peer Collaboration' but no two Agent nodes on this "
+ "protocol are connected, so nobody has anyone to talk to -- draw an edge between two agents, or "
+ "change the coordination strategy on the Design tab."
+ )
+ # Connector-typed edges are configuration, not upstream work, so an agent
+ # with only an LLM/Dataset/Tool wired into it is still a starting point.
+ fed = {
+ str(edge.get("target"))
+ for edge in graph.get("edges") or []
+ if edge.get("targetHandle") not in _CONNECTOR_HANDLES
+ }
+ entries = [nid for nid in peer_agents if nid not in fed]
+ if len(entries) == 1:
+ return entries[0]
+ if not entries:
+ raise ProtocolValidationError(
+ "Every connected agent in this protocol has something feeding into it, so there's no obvious agent "
+ "to start the conversation. Leave one agent's main input unwired to make it the one the task goes to."
+ )
+ names = ", ".join(sorted(_node_display_name(nodes[nid]) for nid in entries))
+ raise ProtocolValidationError(
+ f"This protocol has more than one agent that could start the conversation ({names}). Wire them so a "
+ "single agent leads and the others are its peers."
+ )
+
+
_NODE_TYPE_DISPLAY_NAMES: dict[str, str] = {
"agent": "Agent",
"critic_gate": "Critic Gate",
@@ -2134,10 +2193,7 @@ async def evaluate_protocol_run_metrics(protocol_run_id: uuid.UUID) -> bool:
protocol_run.protocol_revision_id is not None
and protocol_run.protocol_revision_id != current_published.id
)
- or (
- protocol_run.protocol_revision_id is None
- and protocol_run.created_at < current_published.published_at
- )
+ or (protocol_run.protocol_revision_id is None and protocol_run.created_at < current_published.published_at)
)
revision = (
await get_revision(db, protocol_run.protocol_revision_id) if protocol_run.protocol_revision_id else None
@@ -2158,9 +2214,7 @@ async def evaluate_protocol_run_metrics(protocol_run_id: uuid.UUID) -> bool:
status="skipped",
metric_ids=metric_ids,
error=(
- "Run uses an obsolete canvas revision."
- if obsolete
- else "Run has been superseded by a newer attempt."
+ "Run uses an obsolete canvas revision." if obsolete else "Run has been superseded by a newer attempt."
),
)
return False
@@ -2517,7 +2571,7 @@ async def plan_cell_runs(
)
experiment = await get_experiment(db, experiment_id)
design_spec = experiment.design_spec if experiment is not None else None
- validate_coordination_strategy(design_spec, has_gated_pair=bool(find_gated_pairs(graph)))
+ validate_coordination_strategy(design_spec, graph=graph)
try:
validate_factor_bindings(design_spec, graph)
except ValueError as exc:
@@ -2628,7 +2682,7 @@ async def plan_single_replicate_run(
)
experiment = await get_experiment(db, experiment_id)
design_spec = experiment.design_spec if experiment is not None else None
- validate_coordination_strategy(design_spec, has_gated_pair=bool(find_gated_pairs(graph)))
+ validate_coordination_strategy(design_spec, graph=graph)
try:
validate_factor_bindings(design_spec, graph)
except ValueError as exc:
@@ -2856,7 +2910,7 @@ async def run_protocol(protocol_run_id: uuid.UUID) -> None:
try:
order = topological_order(graph)
gated_by = find_gated_pairs(graph)
- validate_coordination_strategy(design_spec, has_gated_pair=bool(gated_by))
+ validate_coordination_strategy(design_spec, graph=graph)
except ProtocolValidationError as e:
async with get_session() as db:
await set_status(db, protocol_run_id, status="failed", error=str(e))
@@ -2884,6 +2938,61 @@ async def run_protocol(protocol_run_id: uuid.UUID) -> None:
node_runs: dict[str, Any] = {}
failed = False
cancelled = False
+ # The node whose output_text becomes this cell's result. For a pipeline
+ # that's the graph's single sink; a conversation has no sink to speak of,
+ # so it's the agent that was asked -- the one that writes the final answer.
+ result_node_id: str | None = None
+ failure_status, failure_error = "failed", "one or more nodes failed"
+
+ if coordination_strategy_slug(design_spec) == "peer_collaboration":
+ # Imported here, not at module scope: agent_messenger imports *this*
+ # module, and that direction is what keeps a pipeline run structurally
+ # unable to know conversations exist.
+ from asaree.services.agent_messenger import execute_conversation
+
+ entry_agent_id = resolve_conversation_entry_id(graph) # already validated above
+ entry_node = next(n for n in graph["nodes"] if str(n.get("id")) == entry_agent_id)
+ ambient_meta, entry_dataset = await _node_run_context(graph, entry_agent_id, workspace_id, owner_id)
+ node_run, conversation_status = await execute_conversation(
+ protocol_run_id,
+ protocol_id=protocol_id,
+ owner_id=owner_id,
+ graph=graph,
+ entry_agent_id=entry_agent_id,
+ # The entry agent's own prompt, with this cell's factor values
+ # already substituted in -- the task, not a chat message. There is
+ # no upstream to fold in: in a conversation everything the other
+ # agents contribute arrives as a reply, not as a prior node's output.
+ user_input=_build_user_input(
+ entry_node,
+ graph,
+ {},
+ experiment_id=experiment_id,
+ effective_cell_label=effective_cell_label,
+ script_bound="script_path" in ambient_meta,
+ seeded_dataset=entry_dataset.seeded_name,
+ unsplit_dataset=entry_dataset.unsplit_name,
+ ),
+ workspace_id=workspace_id,
+ ambient_meta=ambient_meta,
+ evaluation_metrics=(design_spec or {}).get("metrics"),
+ )
+ node_runs[entry_agent_id] = node_run
+ cancelled = conversation_status == "cancelled"
+ failed = conversation_status in ("failed", "limit_reached")
+ if failed:
+ failure_status = conversation_status
+ failure_error = node_run["error"] or failure_error
+ result_node_id = entry_agent_id
+ # One conversation replaces the whole DAG walk, so there are no pipeline
+ # nodes left to step through. Everything below the loop -- result
+ # write-back, metric promotion, terminal status -- is shared and runs
+ # either way.
+ order = []
+ else:
+ sinks = sink_node_ids(graph)
+ result_node_id = sinks[0] if len(sinks) == 1 else None
+
for node in order:
node_id = node["id"]
if node_id in node_runs:
@@ -3009,12 +3118,15 @@ async def run_protocol(protocol_run_id: uuid.UUID) -> None:
if cancelled:
await set_status(db, protocol_run_id, status="cancelled")
elif failed:
- await set_status(db, protocol_run_id, status="failed", error="one or more nodes failed")
+ # A conversation that ran out of budget gets its own terminal status
+ # rather than being flattened into "failed" -- it's the one failure
+ # mode the user fixes by raising a cap, not by fixing the protocol.
+ await set_status(db, protocol_run_id, status=failure_status, error=failure_error)
else:
await set_status(db, protocol_run_id, status="completed")
if replicate_label and experiment_id:
# Post-write, success only: fold the graph's single designated
- # output (the sink node's raw output_text) into this cell's
+ # output (``result_node_id``'s raw output_text) into this cell's
# artifacts. There's still no generic notion of "which
# output_contract field is the metric" for an arbitrary graph
# -- that's what the best-effort promote_cell_score_metrics
@@ -3024,10 +3136,9 @@ async def run_protocol(protocol_run_id: uuid.UUID) -> None:
# metric_values manually via PUT /experiments/{id}/replicates/
# {replicate_label}, the same manual step the notebook's own
# score_payload is today.
- sinks = sink_node_ids(graph)
if (
- len(sinks) == 1
- and node_runs.get(sinks[0], {}).get("status") == "completed"
+ result_node_id is not None
+ and node_runs.get(result_node_id, {}).get("status") == "completed"
and await is_current_replicate_attempt(db, protocol_run_id)
):
await upsert_replicate(
@@ -3036,7 +3147,7 @@ async def run_protocol(protocol_run_id: uuid.UUID) -> None:
replicate_label=replicate_label,
fields={
"artifacts": {
- "output_text": node_runs[sinks[0]].get("output_text"),
+ "output_text": node_runs[result_node_id].get("output_text"),
"protocol_run_id": str(protocol_run_id),
}
},
diff --git a/tests/test_agent_messenger.py b/tests/test_agent_messenger.py
index 51933da..9678444 100644
--- a/tests/test_agent_messenger.py
+++ b/tests/test_agent_messenger.py
@@ -17,6 +17,7 @@
import contextlib
import uuid
from collections.abc import AsyncIterator
+from types import SimpleNamespace
from typing import Any
import pytest
@@ -95,7 +96,11 @@ async def _resolve_available_agents(*_args: Any, **_kwargs: Any) -> list[dict[st
monkeypatch.setattr(am, "update_node_run", _update_node_run)
monkeypatch.setattr(am, "_run_agent_node", _run_agent_node)
monkeypatch.setattr(am, "resolve_available_agents", _resolve_available_agents)
- monkeypatch.setattr(am, "_ambient_meta_for", lambda *a, **k: {})
+
+ async def _node_run_context(*_args: Any, **_kwargs: Any) -> tuple[dict[str, Any], Any]:
+ return {}, SimpleNamespace(seeded_name="", unsplit_name="", data_path=None, target_column=None)
+
+ monkeypatch.setattr(am, "_node_run_context", _node_run_context)
return state
@@ -198,6 +203,83 @@ async def test_a_silent_peer_still_says_something(stubs: dict[str, Any]) -> None
assert reply.text
+# ----------------------------------------------------------------------
+# Briefing -- what a turn gets to read
+# ----------------------------------------------------------------------
+
+
+async def test_the_first_consultation_carries_only_the_question(stubs: dict[str, Any]) -> None:
+ """Nothing has been said yet, so there is nothing to brief -- and a peer
+ asked once reads exactly what it read before this existed."""
+ await _ask(_messenger())
+ assert stubs["peer_runs"][0][1] == "What is weak here?"
+
+
+async def test_a_peer_asked_twice_is_reminded_of_its_own_earlier_turn(stubs: dict[str, Any]) -> None:
+ """Peer memory. Each turn is still a fresh AgentRun, so without this the
+ second turn would have no idea it had already spoken."""
+ stubs["peer_result"] = ("The sample size is too small.", None, uuid.uuid4())
+ messenger = _messenger()
+ await _ask(messenger)
+ await _ask(messenger, text="How would you fix it?")
+
+ second = stubs["peer_runs"][1][1]
+ assert "The sample size is too small." in second
+ assert "What is weak here?" in second
+ # The live question stays last and is not also quoted as history.
+ assert second.count("How would you fix it?") == 1
+ assert second.endswith("How would you fix it?")
+
+
+async def test_an_agent_sees_what_other_agents_have_already_found(stubs: dict[str, Any]) -> None:
+ """Shared context: Loner's turn must carry Critic's finding, or the two
+ agents are answering in isolation rather than building on each other."""
+ stubs["live_graph"]["edges"].append({"id": "e2", "source": "planner", "target": "loner"})
+ stubs["peer_result"] = ("The outcome is bimodal.", None, uuid.uuid4())
+ messenger = _messenger()
+ await _ask(messenger)
+ await _ask(messenger, to="loner", text="Design a test.")
+
+ loner_input = stubs["peer_runs"][1][1]
+ assert "The outcome is bimodal." in loner_input
+ assert "Critic" in loner_input
+ # And it is told which participant it is, since the transcript names it in
+ # the third person.
+ assert "You are Loner" in loner_input
+
+
+async def test_a_briefing_names_participants_the_way_the_canvas_does(stubs: dict[str, Any]) -> None:
+ messenger = _messenger()
+ messenger.append(from_agent_id=am.USER_PARTICIPANT, to_agent_id="planner", parts=[{"kind": "text", "text": "Go."}])
+ await _ask(messenger)
+ briefed = stubs["peer_runs"][0][1]
+ assert "The user -> Planner" in briefed
+
+
+async def test_a_refused_turn_is_not_presented_as_an_answer(stubs: dict[str, Any]) -> None:
+ """It stays in the briefing -- it is part of what happened -- but a model
+ reading it must not mistake a refusal for a colleague's finding."""
+ messenger = _messenger()
+ await _ask(messenger, to="loner", text="Help?") # unconnected -> refused
+ await _ask(messenger)
+ briefed = stubs["peer_runs"][0][1]
+ assert "(refused)" in briefed
+
+
+async def test_a_long_earlier_turn_is_truncated_and_says_so(
+ stubs: dict[str, Any], monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """Eight full analyses would crowd out the question actually being asked."""
+ monkeypatch.setattr(am, "_MAX_BRIEFING_CHARS_PER_MESSAGE", 20)
+ stubs["peer_result"] = ("x" * 500, None, uuid.uuid4())
+ messenger = _messenger()
+ await _ask(messenger)
+ await _ask(messenger, text="And now?")
+ briefed = stubs["peer_runs"][1][1]
+ assert "[...truncated]" in briefed
+ assert "x" * 500 not in briefed
+
+
# ----------------------------------------------------------------------
# Authorization
# ----------------------------------------------------------------------
diff --git a/tests/test_protocol_execution.py b/tests/test_protocol_execution.py
index 8aa7a44..c1eaa19 100644
--- a/tests/test_protocol_execution.py
+++ b/tests/test_protocol_execution.py
@@ -1581,9 +1581,7 @@ async def fake_run_agent_node(node, *, graph, workspace_id=None, **kwargs):
assert received_workspace_ids[0] == f"{experiment_id}/only-cell"
async with get_session() as db:
- replicate = await get_replicate(
- db, experiment_id=experiment_id, replicate_label="only-cell"
- )
+ replicate = await get_replicate(db, experiment_id=experiment_id, replicate_label="only-cell")
assert replicate is not None
assert replicate.run_id == run_id
assert replicate.factor_values == {"Temperature": 0.1}
@@ -1690,9 +1688,7 @@ async def fake_promote(db, *, experiment_id, replicate_label, protocol_run_id):
run = await get_protocol_run(db, run_id)
assert run is not None
assert run.status == "completed"
- replicate = await get_replicate(
- db, experiment_id=experiment_id, replicate_label=cell_label
- )
+ replicate = await get_replicate(db, experiment_id=experiment_id, replicate_label=cell_label)
assert replicate is not None
assert replicate.artifacts is not None
assert replicate.artifacts["output_text"] == "worker output"
@@ -2933,38 +2929,84 @@ async def fake_run_agent_node(node, *, graph, **kwargs):
# --- Coordination strategy validation (pure) ---------------------------------
+def _peer_graph(*agent_ids: str) -> dict:
+ """Agents chained left-to-right by plain (untyped) edges, each with its own
+ LLM. That chain is both a pipeline and a peer cluster -- which one it means
+ is the coordination strategy's call, which is exactly what's under test."""
+ nodes: list[dict] = []
+ edges: list[dict] = []
+ for i, agent_id in enumerate(agent_ids):
+ llm_id = f"llm-{agent_id}"
+ agent, llm_edge = _agent_with_llm(agent_id, llm_id)
+ nodes += [_llm_node(llm_id), agent]
+ edges.append(llm_edge)
+ if i:
+ edges.append({"id": f"e{i}", "source": agent_ids[i - 1], "target": agent_id})
+ return {"nodes": nodes, "edges": edges}
+
+
+def _no_peers_graph() -> dict:
+ llm = _llm_node()
+ agent, llm_edge = _agent_with_llm("a")
+ return {"nodes": [llm, agent], "edges": [llm_edge]}
+
+
def test_coordination_strategy_absent_is_a_noop() -> None:
- validate_coordination_strategy(None, has_gated_pair=False)
- validate_coordination_strategy({}, has_gated_pair=False)
+ validate_coordination_strategy(None, graph=_no_peers_graph())
+ validate_coordination_strategy({}, graph=_no_peers_graph())
def test_coordination_strategy_sequential_is_a_noop() -> None:
- validate_coordination_strategy({"coordination_strategy": {"slug": "sequential"}}, has_gated_pair=False)
- validate_coordination_strategy({"coordination_strategy": {"slug": "sequential"}}, has_gated_pair=True)
+ validate_coordination_strategy({"coordination_strategy": {"slug": "sequential"}}, graph=_no_peers_graph())
+ validate_coordination_strategy({"coordination_strategy": {"slug": "sequential"}}, graph=_peer_graph("a", "b"))
def test_coordination_strategy_critic_gate_requires_a_gated_pair() -> None:
with pytest.raises(ProtocolValidationError, match="no Critic Gate node wired in"):
- validate_coordination_strategy({"coordination_strategy": {"slug": "critic_gate"}}, has_gated_pair=False)
+ validate_coordination_strategy({"coordination_strategy": {"slug": "critic_gate"}}, graph=_no_peers_graph())
+
+
+def test_coordination_strategy_peer_collaboration_needs_connected_agents() -> None:
+ with pytest.raises(ProtocolValidationError, match="no two Agent nodes"):
+ validate_coordination_strategy(
+ {"coordination_strategy": {"slug": "peer_collaboration"}}, graph=_no_peers_graph()
+ )
+
+
+def test_coordination_strategy_peer_collaboration_passes_with_a_peer_edge() -> None:
+ validate_coordination_strategy(
+ {"coordination_strategy": {"slug": "peer_collaboration"}}, graph=_peer_graph("a", "b")
+ )
-def test_coordination_strategy_critic_gate_passes_with_a_gated_pair() -> None:
- validate_coordination_strategy({"coordination_strategy": {"slug": "critic_gate"}}, has_gated_pair=True)
+def test_the_conversation_starts_at_the_agent_nothing_feeds() -> None:
+ assert pe.resolve_conversation_entry_id(_peer_graph("a", "b", "c")) == "a"
-def test_coordination_strategy_placeholder_slug_raises() -> None:
- with pytest.raises(ProtocolValidationError, match="isn't implemented yet"):
+def test_two_equally_plausible_starting_agents_is_an_error() -> None:
+ # a -> c <- b: both a and b are unfed, so there is no honest way to pick.
+ graph = _peer_graph("a", "c")
+ llm_b = _llm_node("llm-b")
+ agent_b, llm_edge_b = _agent_with_llm("b", "llm-b")
+ graph["nodes"] += [llm_b, agent_b]
+ graph["edges"] += [llm_edge_b, {"id": "e-bc", "source": "b", "target": "c"}]
+ with pytest.raises(ProtocolValidationError, match="more than one agent that could start"):
+ pe.resolve_conversation_entry_id(graph)
+
+
+def test_coordination_strategy_retired_slug_raises() -> None:
+ with pytest.raises(ProtocolValidationError, match="no longer offered"):
validate_coordination_strategy(
- {"coordination_strategy": {"slug": "supervisor_architecture"}}, has_gated_pair=False
+ {"coordination_strategy": {"slug": "supervisor_architecture"}}, graph=_no_peers_graph()
)
def test_coordination_strategy_unknown_slug_raises() -> None:
with pytest.raises(ProtocolValidationError, match="Unknown coordination strategy"):
- validate_coordination_strategy({"coordination_strategy": {"slug": "not-a-real-slug"}}, has_gated_pair=False)
+ validate_coordination_strategy({"coordination_strategy": {"slug": "not-a-real-slug"}}, graph=_no_peers_graph())
-async def test_run_protocol_rejects_placeholder_coordination_strategy(owner_id: uuid.UUID) -> None:
+async def test_run_protocol_rejects_retired_coordination_strategy(owner_id: uuid.UUID) -> None:
llm = _llm_node()
agent, agent_llm_edge = _agent_with_llm("a")
graph = {"nodes": [llm, agent], "edges": [agent_llm_edge]}
@@ -2995,7 +3037,62 @@ async def test_run_protocol_rejects_placeholder_coordination_strategy(owner_id:
assert fetched is not None
assert fetched.status == "failed"
assert fetched.error is not None
- assert "isn't implemented yet" in fetched.error
+ assert "no longer offered" in fetched.error
+ finally:
+ async with get_session() as db:
+ await delete_protocol(db, protocol_id)
+ await delete_experiment(db, experiment_id)
+
+
+async def test_peer_collaboration_runs_the_graph_as_one_conversation(
+ owner_id: uuid.UUID, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """The same a->b canvas a sequential run would walk node-by-node instead
+ starts one conversation at `a`, and `a`'s answer is the run's result. `b`
+ doesn't run here because nothing asked it to -- consultation is the lead
+ agent's choice, made inside its own run."""
+ import asaree.services.agent_messenger as am
+
+ ran: list[str] = []
+
+ async def fake_run_agent_node(node, **_kwargs):
+ ran.append(node["id"])
+ return f"{node['id']} answered", None, None
+
+ monkeypatch.setattr(am, "_run_agent_node", fake_run_agent_node)
+
+ graph = _peer_graph("a", "b")
+ async with get_session() as db:
+ experiment = await create_experiment(
+ db,
+ name=f"peer-collab-{uuid.uuid4().hex}",
+ owner_id=owner_id,
+ design_spec={"coordination_strategy": {"slug": "peer_collaboration"}},
+ )
+ experiment_id = experiment.id
+ protocol = await create_protocol(
+ db,
+ name=f"peer-collab-protocol-{uuid.uuid4().hex}",
+ owner_id=owner_id,
+ experiment_id=experiment_id,
+ graph=graph,
+ )
+ protocol_id = protocol.id
+ run_id = (await create_protocol_run(db, protocol_id=protocol_id, owner_id=owner_id)).id
+
+ try:
+ await pe.run_protocol(run_id)
+ async with get_session() as db:
+ fetched = await pe.get_protocol_run(db, run_id)
+ assert fetched is not None
+ assert fetched.status == "completed"
+ assert ran == ["a"]
+ assert fetched.node_runs["a"]["output_text"] == "a answered"
+ assert fetched.conversation["entry_agent_id"] == "a"
+ assert [(m["from_agent_id"], m["to_agent_id"]) for m in fetched.conversation["messages"]] == [
+ ("user", "a"),
+ ("a", "user"),
+ ]
finally:
async with get_session() as db:
await delete_protocol(db, protocol_id)
From 352d0f3a8336d9649878ed4710fc3307d092bb0a Mon Sep 17 00:00:00 2001
From: Jay Moran
Date: Tue, 8 Sep 2026 16:32:17 -0700
Subject: [PATCH 08/57] Drop the "can consult" caption and the Converse button
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.
---
frontend/src/api/client.ts | 15 +-
.../components/protocol/ProtocolCanvas.tsx | 143 +++---------------
.../components/protocol/RunConfirmDialog.tsx | 19 +--
.../protocol/edges/InteractEdge.tsx | 32 +---
.../src/components/protocol/runSummary.ts | 19 +--
frontend/src/types/experiments.ts | 6 +-
6 files changed, 43 insertions(+), 191 deletions(-)
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts
index 5d619a0..50a1f61 100644
--- a/frontend/src/api/client.ts
+++ b/frontend/src/api/client.ts
@@ -284,11 +284,16 @@ 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' }),
- // Conversation mode: address one agent and let it consult the peers it's
- // wired to. Returns the same ProtocolRun a pipeline run does -- poll getRun
- // the same way; what's new is `conversation` on the response, the transcript
- // as it grows. 422 if the entry agent isn't an Agent node, has no peers, or
- // any participant is missing its single LLM connection.
+ // Ad-hoc conversation: address one agent with a question of your own and let
+ // it consult the peers it's wired to. Returns the same ProtocolRun a pipeline
+ // run does -- poll getRun the same way; what's new is `conversation` on the
+ // response, the transcript as it grows. 422 if the entry agent isn't an Agent
+ // node, has no peers, or any participant is missing its single LLM connection.
+ //
+ // No GUI caller: the canvas deliberately has no second Run button, so agents
+ // collaborate by the experiment's Peer Collaboration coordination strategy
+ // instead. Kept as the programmatic path -- this is the only way to start a
+ // conversation with a question that isn't the entry agent's node prompt.
startConversation: (id: string, data: { entryAgentId: string; userInput: string }) =>
request(`/protocols/${id}/conversations`, {
method: 'POST',
diff --git a/frontend/src/components/protocol/ProtocolCanvas.tsx b/frontend/src/components/protocol/ProtocolCanvas.tsx
index 38c208f..106cd3a 100644
--- a/frontend/src/components/protocol/ProtocolCanvas.tsx
+++ b/frontend/src/components/protocol/ProtocolCanvas.tsx
@@ -16,9 +16,8 @@ import {
type Viewport,
} from '@xyflow/react'
import '@xyflow/react/dist/style.css'
-import { Lock, MessagesSquare, Plus, Square, X } from 'lucide-react'
+import { Lock, Plus, Square, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
-import { Textarea } from '@/components/ui/textarea'
import { ApiError, experimentsApi, protocolsApi } from '@/api/client'
import { newNodeId } from '@/lib/nodeId'
import { protocolForExperimentQueryKey, protocolGraphQueryKey, toPersistedGraph } from '@/lib/protocolGraph'
@@ -477,9 +476,6 @@ export const ProtocolCanvas = forwardRef(null)
const paneRef = useRef(null)
const { screenToFlowPosition, fitView } = useReactFlow()
@@ -543,12 +539,6 @@ export const ProtocolCanvas = forwardRef setRunId(run.id),
})
- // Conversation mode reuses the same runId/runQuery polling as every other
- // run -- what's new is `conversation` on the polled row, which the transcript
- // panel reads.
- const conversationMutation = useMutation({
- mutationFn: (data: { entryAgentId: string; userInput: string }) => protocolsApi.startConversation(protocolId, data),
- onSuccess: (run) => {
- setRunId(run.id)
- setConversationDialogOpen(false)
- setConversationInput('')
- },
- })
-
// First refetchInterval-based poll in this codebase -- no existing
// long-running-job UI to mirror. Function form so polling stops itself
// once the run reaches a terminal status, rather than polling forever.
@@ -601,7 +579,6 @@ export const ProtocolCanvas = forwardRef {
+ // Who each agent may consult under the Peer Collaboration coordination
+ // strategy, mirroring services/protocol_execution.py's _connected_agent_ids:
+ // a plain (non-connector) edge joining two Agent nodes, read undirected. The
+ // same edge is still a directed pipeline edge for a sequential run -- it is
+ // both, and the experiment's strategy decides which. Nothing on the canvas is
+ // drawn differently for it; this only feeds AgentNode's tool-calling warning,
+ // which is about the agent, not the edge.
+ const peerIdsByAgent = useMemo(() => {
const nodeTypeById = new Map(nodes.map((n) => [n.id, n.type]))
const map = new Map()
- const edgeIds = new Set()
const link = (a: string, b: string) => map.set(a, [...(map.get(a) ?? []), b])
for (const e of edges) {
if (CONNECTOR_HANDLES.has(e.targetHandle ?? '')) continue
if (nodeTypeById.get(e.source) !== 'agent' || nodeTypeById.get(e.target) !== 'agent') continue
if (e.source === e.target) continue
- edgeIds.add(e.id)
link(e.source, e.target)
link(e.target, e.source)
}
- return { peerIdsByAgent: map, peerEdgeIds: edgeIds }
+ return map
}, [nodes, edges])
- // Stamped onto the RENDERED copy only, never onto the persisted `edges`
- // state -- an edge in a saved graph carries no data of its own, and this is
- // derived from the graph anyway. InteractEdge reads it to caption the edge
- // rather than re-deriving both endpoints' node types per edge.
- const edgesWithPeerFlag = useMemo(
- () => edges.map((e) => (peerEdgeIds.has(e.id) ? { ...e, data: { ...e.data, isPeerEdge: true } } : e)),
- [edges, peerEdgeIds],
- )
-
// The model each agent will actually run on, resolved through its AI
// connector. Injected into the node's data rather than read here, because
// whether that model can be sent function schemas needs the provider's model
@@ -736,20 +704,6 @@ export const ProtocolCanvas = forwardRef [...agentNames.keys()].filter((id) => (peerIdsByAgent.get(id)?.length ?? 0) > 0).map((id) => ({ id, name: agentNames.get(id)! })),
- [agentNames, peerIdsByAgent],
- )
-
- useEffect(() => {
- if (!conversationCandidates.some((a) => a.id === conversationEntryAgentId)) {
- setConversationEntryAgentId(conversationCandidates[0]?.id ?? '')
- }
- }, [conversationCandidates, conversationEntryAgentId])
-
const nodesWithRunStatus = useMemo((): Node[] => {
return nodes.map((n) => {
const patternHostId = patternHostIds.get(n.id)
@@ -1450,7 +1404,7 @@ export const ProtocolCanvas = forwardRef
)}
- {/* Offered only once two agents are actually wired together --
- with nobody to consult, a conversation is just a slower
- single-agent run. */}
- {conversationCandidates.length > 0 && (
-
- )}
+ {/* There is deliberately no separate "start a conversation"
+ button here. Whether connected agents collaborate is the
+ experiment's coordination strategy (Design tab), not a second
+ way to press Run -- so an agent conversation is started by
+ running the protocol, like everything else. */}
)}
- {(pendingRunConfirm || conversationDialogOpen) && (
+ {pendingRunConfirm && (
{
- setPendingRunConfirm(null)
- setConversationDialogOpen(false)
- }}
+ onCancel={() => setPendingRunConfirm(null)}
onConfirm={confirmPendingRun}
hasUnpublishedChanges={hasUnpublishedChanges}
publishedRevision={publishedRevision}
@@ -1812,44 +1745,6 @@ export const ProtocolCanvas = forwardRef publishAndRunMutation.mutate()}
- additionalContent={
- conversationDialogOpen ? (
-
-
-
-
- ) : undefined
- }
- confirmLabel={conversationDialogOpen ? 'Start conversation' : undefined}
- confirmDisabled={conversationDialogOpen && (!conversationInput.trim() || !conversationEntryAgentId)}
- isConfirming={conversationMutation.isPending}
- confirmError={
- conversationMutation.error instanceof ApiError && typeof conversationMutation.error.detail === 'string'
- ? conversationMutation.error.detail
- : conversationMutation.isError
- ? 'Could not start the conversation.'
- : null
- }
/>
)}
{factorPickerNodeId && (
diff --git a/frontend/src/components/protocol/RunConfirmDialog.tsx b/frontend/src/components/protocol/RunConfirmDialog.tsx
index 5bd13c4..0c6534c 100644
--- a/frontend/src/components/protocol/RunConfirmDialog.tsx
+++ b/frontend/src/components/protocol/RunConfirmDialog.tsx
@@ -20,8 +20,6 @@ function scopeTitle(scope: RunScope): string {
return scope.title ?? 'Run the experiment?'
case 'node':
return `Run "${scope.label}" alone?`
- case 'conversation':
- return `Start a conversation with "${scope.entryLabel}"?`
}
}
@@ -74,10 +72,7 @@ export function RunConfirmDialog({
// A node-scoped run only ever touches that node plus its own directly
// wired dependencies -- an issue on some unrelated node elsewhere on the
// canvas isn't relevant to THIS run, so don't show it here.
- // A conversation is scoped the same way, just to several nodes: only the
- // participants and their own wiring can misbehave during one.
- const scopedNodeIds =
- scope.type === 'node' ? [scope.nodeId] : scope.type === 'conversation' ? scope.participantIds : null
+ const scopedNodeIds = scope.type === 'node' ? [scope.nodeId] : null
const relevantNodeIds = scopedNodeIds
? new Set([...scopedNodeIds, ...edges.filter((e) => scopedNodeIds.includes(e.target)).map((e) => e.source)])
: null
@@ -116,18 +111,6 @@ export function RunConfirmDialog({
: ''}
)}
- {/* Every consultation is a full agent run of its own -- its own
- Reason/Plan/Act cycle, its own tokens -- so a conversation can
- cost several times what the same agents cost in a pipeline run.
- The caps are the messenger's (services/agent_messenger.py); state
- the spend ceiling here, before the billable click, rather than
- letting it be discovered from a transcript afterwards. */}
- {scope.type === 'conversation' && (
-
- The entry agent may consult its connected peers, up to 8 consultations nested 2 deep.
- Each one is a full agent run, so this can cost several times a single-agent run.
-
- )}
{summary.agentCount} agent{summary.agentCount === 1 ? '' : 's'}
{summary.criticGateCount > 0 ? `, ${summary.criticGateCount} critic gate${summary.criticGateCount === 1 ? '' : 's'}` : ''} will run
diff --git a/frontend/src/components/protocol/edges/InteractEdge.tsx b/frontend/src/components/protocol/edges/InteractEdge.tsx
index 7c47738..d24abfa 100644
--- a/frontend/src/components/protocol/edges/InteractEdge.tsx
+++ b/frontend/src/components/protocol/edges/InteractEdge.tsx
@@ -1,5 +1,5 @@
import { useState, type CSSProperties } from 'react'
-import { BaseEdge, EdgeLabelRenderer, EdgeToolbar, getBezierPath, useReactFlow, type EdgeProps } from '@xyflow/react'
+import { BaseEdge, EdgeToolbar, getBezierPath, useReactFlow, type EdgeProps } from '@xyflow/react'
// Trash2, not an X -- the same glyph NodeHoverToolbar's own Delete button
// uses, so "remove this thing" looks identical whether the thing is a node or
// an edge. An X here also collided with the two other X's on the canvas
@@ -26,9 +26,12 @@ import { useProtocolCanvasActions } from '../ProtocolCanvasContext'
//
// A solid edge is deliberately not one relationship: between two Agent nodes it
// is BOTH the left-to-right pipeline edge a normal run walks AND the "these two
-// may consult each other" edge a conversation run reads (undirected). The run
-// mode picks which, so the edge itself must not commit to either -- that's why
-// a peer edge gets a caption below rather than a different stroke.
+// may consult each other" edge a Peer Collaboration run reads (undirected). The
+// experiment's coordination strategy picks which, so the edge must not commit to
+// either -- it looks the same in both, and nothing is annotated onto it. An
+// earlier pass captioned peer edges "can consult"; it read as clutter on a
+// canvas where most solid edges qualify, and the Design tab already says which
+// strategy is in force.
//
// Note the dashes are NOT the same statement as MemoryNode's dashed ring,
// which means "not yet functional"; here they only mean "connector, not
@@ -76,7 +79,6 @@ export function InteractEdge({
targetHandleId,
style,
markerEnd,
- data,
}: EdgeProps) {
const [hovered, setHovered] = useState(false)
const { setEdges } = useReactFlow()
@@ -84,12 +86,6 @@ export function InteractEdge({
const [edgePath, labelX, labelY] = getBezierPath({ sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition })
const isMainEdge = !sourceHandleId && !targetHandleId
const isPatternEdge = targetHandleId === 'architectural_pattern'
- // Stamped by ProtocolCanvas: this main edge joins two Agent nodes, so in a
- // conversation run it also means "these two may consult each other". It
- // stays a solid main edge because it is STILL the directed pipeline edge a
- // normal run walks -- it's both things, and the run mode picks. Hence a
- // caption rather than a restyle: nothing about the wiring changed.
- const isPeerEdge = !!(data as { isPeerEdge?: boolean } | undefined)?.isPeerEdge
return (
<>
@@ -115,20 +111,6 @@ export function InteractEdge({
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
/>
- {/* Same midpoint the toolbar uses, so it yields while hovered rather
- than sitting under the buttons. --node-label, the same yellow the
- connector captions use, for the same reason: it annotates the wiring
- without claiming to be one of the node accent hues. */}
- {isPeerEdge && !hovered && (
-
-
- can consult
-
-
- )}
{/* Nothing to put in it for a pattern edge -- no delete (see above) and
no insert -- so it's skipped entirely rather than rendered empty. */}
diff --git a/frontend/src/components/protocol/runSummary.ts b/frontend/src/components/protocol/runSummary.ts
index 46393b1..9b704b5 100644
--- a/frontend/src/components/protocol/runSummary.ts
+++ b/frontend/src/components/protocol/runSummary.ts
@@ -46,11 +46,6 @@ export type RunScope =
title?: string
}
| { type: 'node'; nodeId: string; label: string }
- // Conversation mode: the entry agent plus every agent it can reach over a
- // plain Agent-to-Agent edge. Unlike a graph run, the canvas is not the
- // scope -- an agent with no peer edge never gets a turn -- so the
- // participants are listed explicitly rather than inferred here.
- | { type: 'conversation'; entryLabel: string; participantIds: string[] }
export interface RunSummary {
agentCount: number
@@ -75,12 +70,7 @@ export interface RunSummary {
// _resolve_dataset_configs do server-side -- kept as a client-side duplicate
// for the same reason nodeConfigIssues.ts already is.
export function summarizeRun(nodes: Node[], edges: Edge[], scope: RunScope): RunSummary {
- const relevantNodes =
- scope.type === 'node'
- ? nodesWiredTo(nodes, edges, scope.nodeId)
- : scope.type === 'conversation'
- ? dedupeById(scope.participantIds.flatMap((id) => nodesWiredTo(nodes, edges, id)))
- : nodes
+ const relevantNodes = scope.type === 'node' ? nodesWiredTo(nodes, edges, scope.nodeId) : nodes
const datasets = uniq(
relevantNodes
@@ -159,13 +149,6 @@ function nodesWiredTo(nodes: Node[], edges: Edge[], targetId: string): Node[] {
return nodes.filter((n) => ids.has(n.id))
}
-// Participants share dependencies (one LLM node wired to both agents is the
-// common case), so the per-participant traversals above overlap.
-function dedupeById(candidates: Node[]): Node[] {
- const byId = new Map(candidates.map((n) => [n.id, n]))
- return [...byId.values()]
-}
-
function uniq(values: string[]): string[] {
return [...new Set(values)]
}
diff --git a/frontend/src/types/experiments.ts b/frontend/src/types/experiments.ts
index 3d2b721..701b332 100644
--- a/frontend/src/types/experiments.ts
+++ b/frontend/src/types/experiments.ts
@@ -89,8 +89,12 @@ export const COORDINATION_STRATEGY_CATALOG: {
{
slug: 'peer_collaboration',
label: 'Peer Collaboration',
+ // The cost ceiling belongs here rather than in RunConfirmDialog: this is
+ // where the choice is actually made, and every consultation is a full agent
+ // run of its own (Reason/Plan/Act cycle, own tokens). The caps are the
+ // messenger's -- services/agent_messenger.py.
description:
- 'Connected agents work the task together as a conversation -- the lead agent can consult its peers, and each reply is shared with everyone, instead of handing off once.',
+ 'Connected agents work the task together as a conversation -- the lead agent can consult its peers, and every reply is shared with everyone, instead of handing off once. Each consultation is a full agent run (up to 8, nested 2 deep), so a cell can cost several times a sequential one.',
},
]
From c5eb12906957294a43594e82c15dd903082883b3 Mon Sep 17 00:00:00 2001
From: Jay Moran
Date: Tue, 8 Sep 2026 17:22:27 -0700
Subject: [PATCH 09/57] Let a peer loop name its own conversation lead, and run
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.
---
.../protocol/AgentNodeInspector.tsx | 46 ++++++
.../components/protocol/ProtocolCanvas.tsx | 48 +++++-
.../components/protocol/nodes/AgentNode.tsx | 19 +++
.../protocol/nodes/ReasonActPatternNode.tsx | 7 +-
frontend/src/types/protocols.ts | 10 ++
src/asaree/api/protocols.py | 15 +-
src/asaree/services/protocol_execution.py | 137 ++++++++++++++----
tests/test_protocol_execution.py | 84 +++++++++++
8 files changed, 327 insertions(+), 39 deletions(-)
diff --git a/frontend/src/components/protocol/AgentNodeInspector.tsx b/frontend/src/components/protocol/AgentNodeInspector.tsx
index ab3e9b5..40e6c66 100644
--- a/frontend/src/components/protocol/AgentNodeInspector.tsx
+++ b/frontend/src/components/protocol/AgentNodeInspector.tsx
@@ -48,6 +48,7 @@ function outputPaneWidth(): number {
export function AgentNodeInspector({
node,
experimentId,
+ markedLeadAgentId,
nodeRun,
onChange,
onDelete,
@@ -55,6 +56,9 @@ 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
nodeRun?: NodeRunState
onChange: (nodeId: string, data: AgentNodeData) => void
onDelete: (nodeId: string) => void
@@ -87,6 +91,18 @@ export function AgentNodeInspector({
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.
+ const isPeerCollaboration = experimentQuery.data?.design_spec?.coordination_strategy?.slug === 'peer_collaboration'
+ // 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 = isPeerCollaboration && (markedLeadAgentId === null || markedLeadAgentId === node.id)
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))
@@ -162,6 +178,36 @@ export function AgentNodeInspector({
+ {/* First, above Prompt: which agent leads decides whose prompt
+ becomes the task and whose answer gets scored, so it frames
+ everything below it rather than being one more setting. It
+ only renders under Peer Collaboration, and only on the agent
+ that may still take the role (see canMarkLead), so it costs
+ the common single-agent case no vertical space at all. */}
+ {canMarkLead && (
+
+
+
+ )}
+
str:
return str(((design_spec or {}).get("coordination_strategy") or {}).get("slug") or "sequential")
+def is_conversation_strategy(design_spec: dict[str, Any] | None) -> bool:
+ """Whether this experiment's cells execute as a conversation rather than a
+ pipeline, which decides how much of the pipeline's structural validation
+ still applies to the canvas.
+
+ Two of those requirements -- that the graph is acyclic, and that it has
+ exactly one sink whose output is the deliverable -- describe walking a graph
+ in dependency order, not being a valid graph. ``run_protocol``'s
+ ``peer_collaboration`` branch discards the topological sort outright and
+ takes the result from the conversation lead, so neither requirement is a
+ fact about a valid conversation, and both reject the topology this strategy
+ exists for: agents wired to each other in a loop, which has no unfed node to
+ start from and no sink at all. Every other check (a non-empty graph,
+ critic-gate shape, factor bindings) is about the canvas itself and still
+ applies.
+ """
+ return coordination_strategy_slug(design_spec) == "peer_collaboration"
+
+
def validate_coordination_strategy(design_spec: dict[str, Any] | None, *, graph: dict[str, Any]) -> None:
"""Checks the experiment's declared strategy against the protocol it will
run. Takes the whole graph rather than a pre-computed fact about it because
@@ -462,17 +481,41 @@ def validate_coordination_strategy(design_spec: dict[str, Any] | None, *, graph:
raise ProtocolValidationError(f"Unknown coordination strategy: {slug!r}")
+#: Agent node ``data`` flag marking that agent as the one a conversation starts
+#: at. An explicit override of the wiring rule in
+#: :func:`resolve_conversation_entry_id`, deliberately not a second mechanism
+#: alongside it -- see that function for when each applies.
+_CONVERSATION_LEAD_FIELD = "conversation_lead"
+
+
+def _is_marked_lead(node: dict[str, Any]) -> bool:
+ data = node.get("data")
+ return isinstance(data, dict) and data.get(_CONVERSATION_LEAD_FIELD) is True
+
+
def resolve_conversation_entry_id(graph: dict[str, Any]) -> str:
"""Which agent the user's question goes to when this graph runs as a
conversation.
- Read off the canvas rather than configured separately. A peer edge is
- undirected for *consultation*, but the user still drew it in a direction,
- and that direction is the only statement of intent available -- so the entry
- agent is the peer-connected agent that nothing upstream feeds, i.e. exactly
- the node a pipeline run would have started at. One agent has to lead; if two
- are equally plausible starting points there's no honest way to pick, and
- guessing would silently drop half the canvas out of the run.
+ Two sources, in this order: an agent explicitly marked as the lead on the
+ canvas wins, and failing that it's derived from the wiring -- the
+ peer-connected agent that nothing feeds, i.e. exactly the node a pipeline
+ run would have started at. A peer edge is undirected for *consultation*, but
+ the user still drew it in a direction, and absent a marker that direction is
+ the only statement of intent available.
+
+ Derivation alone is not enough, because it quietly assumes a DAG. The
+ topology this strategy most invites -- every agent wired to every other --
+ is a cycle, and in a cycle *every* agent is fed, so the derivation finds no
+ candidate at all and could only tell the user to unwire something. The
+ marker is how you say "this one leads" without breaking the shape you meant
+ to draw. Derivation stays the default so a plain chain still needs no
+ configuration, and so nothing saved before the marker existed changes
+ behavior.
+
+ One agent has to lead either way: its answer is the run's result, so if two
+ are equally plausible there's no honest way to pick, and guessing would
+ silently drop half the canvas out of the run.
"""
nodes = {str(n.get("id")): n for n in graph.get("nodes") or [] if n.get("id")}
peer_agents = [nid for nid in nodes if nodes[nid].get("type") == "agent" and _connected_agent_ids(graph, nid)]
@@ -482,6 +525,25 @@ def resolve_conversation_entry_id(graph: dict[str, Any]) -> str:
"protocol are connected, so nobody has anyone to talk to -- draw an edge between two agents, or "
"change the coordination strategy on the Design tab."
)
+
+ marked = [nid for nid in nodes if nodes[nid].get("type") == "agent" and _is_marked_lead(nodes[nid])]
+ if len(marked) > 1:
+ names = ", ".join(sorted(_node_display_name(nodes[nid]) for nid in marked))
+ raise ProtocolValidationError(
+ f"More than one agent is marked as the conversation lead ({names}). Only one agent can lead a "
+ "conversation -- unmark the others."
+ )
+ if marked:
+ # Checked against the peer cluster rather than just the graph: honoring
+ # a marker on an unconnected agent would run a "conversation" with one
+ # participant, which is the one case where the marker must not win.
+ if marked[0] not in peer_agents:
+ raise ProtocolValidationError(
+ f"{_node_display_name(nodes[marked[0]])!r} is marked as the conversation lead but isn't connected "
+ "to another agent, so it has nobody to talk to. Connect it to a peer, or mark a different agent."
+ )
+ return marked[0]
+
# Connector-typed edges are configuration, not upstream work, so an agent
# with only an LLM/Dataset/Tool wired into it is still a starting point.
fed = {
@@ -495,12 +557,13 @@ def resolve_conversation_entry_id(graph: dict[str, Any]) -> str:
if not entries:
raise ProtocolValidationError(
"Every connected agent in this protocol has something feeding into it, so there's no obvious agent "
- "to start the conversation. Leave one agent's main input unwired to make it the one the task goes to."
+ "to start the conversation -- which is what happens whenever the agents are wired in a loop. Mark one "
+ "of them as the conversation lead in its node settings, or leave one agent's main input unwired."
)
names = ", ".join(sorted(_node_display_name(nodes[nid]) for nid in entries))
raise ProtocolValidationError(
- f"This protocol has more than one agent that could start the conversation ({names}). Wire them so a "
- "single agent leads and the others are its peers."
+ f"This protocol has more than one agent that could start the conversation ({names}). Mark one of them as "
+ "the conversation lead in its node settings, or wire them so a single agent leads."
)
@@ -571,14 +634,24 @@ def _adjacency(
return nodes, downstream, upstream
-def topological_order(graph: dict[str, Any]) -> list[dict[str, Any]]:
+def topological_order(graph: dict[str, Any], *, require_acyclic: bool = True) -> list[dict[str, Any]]:
"""Kahn's algorithm. Raises :class:`ProtocolValidationError` on an empty
graph, a cycle (any node Kahn's algorithm can't reach stays with a
nonzero in-degree, which is exactly the cycle signature), or a malformed
critic-gate topology: a ``critic_gate`` node must have exactly one
incoming edge, from an ``agent`` node, and that agent node's *only*
outgoing edge must be to this gate -- no fan-out around a gate, since
- anything wanting the reviewed output must consume it after the gate."""
+ anything wanting the reviewed output must consume it after the gate.
+
+ *require_acyclic* exists because the acyclic requirement is the only one of
+ those three that is a fact about *walking* a graph in dependency order
+ rather than a fact about a valid graph. A ``peer_collaboration`` run never
+ walks one -- see ``is_conversation_strategy`` -- so it passes ``False`` to
+ keep the empty-graph and critic-gate checks while dropping the check that
+ would reject the topology that strategy exists for. Nodes a cycle leaves
+ unreachable are appended in declaration order, since with the sort's
+ premise gone there is no order left to claim.
+ """
nodes, downstream, upstream = _adjacency(graph)
if not nodes:
raise ProtocolValidationError("This protocol has no nodes.")
@@ -595,7 +668,10 @@ def topological_order(graph: dict[str, Any]) -> list[dict[str, Any]]:
queue.append(nxt)
if len(ordered) != len(nodes):
- raise ProtocolValidationError("This protocol's graph has a cycle -- it can't be run in dependency order.")
+ if require_acyclic:
+ raise ProtocolValidationError("This protocol's graph has a cycle -- it can't be run in dependency order.")
+ reached = set(ordered)
+ ordered.extend(nid for nid in nodes if nid not in reached)
for nid, node in nodes.items():
if node.get("type") != "critic_gate":
@@ -2563,15 +2639,19 @@ async def plan_cell_runs(
``create_protocol_run_endpoint`` already uses for a plain run."""
if experiment_id is None:
raise ProtocolValidationError("This protocol has no linked experiment to run replicates for.")
- topological_order(graph) # raises ProtocolValidationError on a cycle/empty graph
- sinks = sink_node_ids(graph)
- if len(sinks) != 1:
- raise ProtocolValidationError(
- f"This protocol must have exactly one final node to run per replicate (found {len(sinks)})."
- )
+ # Strategy first, so its own message wins over a pipeline requirement that
+ # may not apply to this canvas at all -- see is_conversation_strategy.
experiment = await get_experiment(db, experiment_id)
design_spec = experiment.design_spec if experiment is not None else None
validate_coordination_strategy(design_spec, graph=graph)
+ conversation = is_conversation_strategy(design_spec)
+ topological_order(graph, require_acyclic=not conversation) # also raises on an empty graph
+ if not conversation:
+ sinks = sink_node_ids(graph)
+ if len(sinks) != 1:
+ raise ProtocolValidationError(
+ f"This protocol must have exactly one final node to run per replicate (found {len(sinks)})."
+ )
try:
validate_factor_bindings(design_spec, graph)
except ValueError as exc:
@@ -2674,15 +2754,18 @@ async def plan_single_replicate_run(
batch resume, so there's nothing to protect it from."""
if experiment_id is None:
raise ProtocolValidationError("This protocol has no linked experiment to run a replicate for.")
- topological_order(graph) # raises ProtocolValidationError on a cycle/empty graph
- sinks = sink_node_ids(graph)
- if len(sinks) != 1:
- raise ProtocolValidationError(
- f"This protocol must have exactly one final node to run per replicate (found {len(sinks)})."
- )
+ # Same order and same reason as plan_cell_runs above.
experiment = await get_experiment(db, experiment_id)
design_spec = experiment.design_spec if experiment is not None else None
validate_coordination_strategy(design_spec, graph=graph)
+ conversation = is_conversation_strategy(design_spec)
+ topological_order(graph, require_acyclic=not conversation) # also raises on an empty graph
+ if not conversation:
+ sinks = sink_node_ids(graph)
+ if len(sinks) != 1:
+ raise ProtocolValidationError(
+ f"This protocol must have exactly one final node to run per replicate (found {len(sinks)})."
+ )
try:
validate_factor_bindings(design_spec, graph)
except ValueError as exc:
@@ -2908,9 +2991,9 @@ async def run_protocol(protocol_run_id: uuid.UUID) -> None:
graph = apply_factor_bindings(graph, factor_values)
try:
- order = topological_order(graph)
- gated_by = find_gated_pairs(graph)
validate_coordination_strategy(design_spec, graph=graph)
+ order = topological_order(graph, require_acyclic=not is_conversation_strategy(design_spec))
+ gated_by = find_gated_pairs(graph)
except ProtocolValidationError as e:
async with get_session() as db:
await set_status(db, protocol_run_id, status="failed", error=str(e))
diff --git a/tests/test_protocol_execution.py b/tests/test_protocol_execution.py
index c1eaa19..5f3c6b8 100644
--- a/tests/test_protocol_execution.py
+++ b/tests/test_protocol_execution.py
@@ -2994,6 +2994,90 @@ def test_two_equally_plausible_starting_agents_is_an_error() -> None:
pe.resolve_conversation_entry_id(graph)
+def _mark_lead(graph: dict, *agent_ids: str) -> dict:
+ """Set the canvas's explicit conversation-lead flag on some agent nodes."""
+ for node in graph["nodes"]:
+ if node["id"] in agent_ids:
+ node["data"] = {**(node.get("data") or {}), "conversation_lead": True}
+ return graph
+
+
+def _cycle_peer_graph(*agent_ids: str) -> dict:
+ """The topology the lead marker exists for: the chain's last agent wired
+ back to its first, so every agent is fed and the wiring rule has no
+ candidate at all to pick."""
+ graph = _peer_graph(*agent_ids)
+ graph["edges"].append({"id": "e-cycle", "source": agent_ids[-1], "target": agent_ids[0]})
+ return graph
+
+
+def test_a_marked_lead_wins_over_the_wiring() -> None:
+ # a -> b -> c would derive "a"; the marker is an override, not a tiebreak.
+ assert pe.resolve_conversation_entry_id(_mark_lead(_peer_graph("a", "b", "c"), "c")) == "c"
+
+
+def test_a_cycle_has_no_derivable_lead() -> None:
+ with pytest.raises(ProtocolValidationError, match="wired in a loop"):
+ pe.resolve_conversation_entry_id(_cycle_peer_graph("a", "b", "c"))
+
+
+def test_a_marked_lead_resolves_a_cycle() -> None:
+ """The whole point of the marker: everyone wired to everyone is the shape
+ this strategy invites, and it must not have to be broken to run."""
+ assert pe.resolve_conversation_entry_id(_mark_lead(_cycle_peer_graph("a", "b", "c"), "b")) == "b"
+
+
+def test_two_marked_leads_is_an_error() -> None:
+ with pytest.raises(ProtocolValidationError, match="More than one agent is marked"):
+ pe.resolve_conversation_entry_id(_mark_lead(_peer_graph("a", "b", "c"), "a", "c"))
+
+
+def test_a_marked_lead_with_no_peers_is_an_error() -> None:
+ """Marking an agent that has nobody to talk to must not win -- that would
+ run a "conversation" with a single participant."""
+ graph = _peer_graph("a", "b")
+ llm_c = _llm_node("llm-c")
+ agent_c, llm_edge_c = _agent_with_llm("c", "llm-c")
+ graph["nodes"] += [llm_c, agent_c]
+ graph["edges"].append(llm_edge_c)
+ with pytest.raises(ProtocolValidationError, match="isn't connected to another agent"):
+ pe.resolve_conversation_entry_id(_mark_lead(graph, "c"))
+
+
+_PEER_SPEC = {"coordination_strategy": {"slug": "peer_collaboration"}}
+
+
+def test_is_conversation_strategy() -> None:
+ assert pe.is_conversation_strategy(_PEER_SPEC) is True
+ assert pe.is_conversation_strategy({"coordination_strategy": {"slug": "critic_gate"}}) is False
+ assert pe.is_conversation_strategy(None) is False
+
+
+def test_a_cycle_is_still_rejected_for_a_pipeline() -> None:
+ with pytest.raises(ProtocolValidationError, match="has a cycle"):
+ pe.topological_order(_cycle_peer_graph("a", "b", "c"))
+
+
+def test_a_conversation_may_contain_a_cycle() -> None:
+ """The lead marker resolves the *entry agent* in a loop; this is what makes
+ the same loop publishable and runnable. Every node still comes back -- the
+ order is meaningless, and run_protocol's conversation branch discards it."""
+ graph = _cycle_peer_graph("a", "b", "c")
+ ordered = pe.topological_order(graph, require_acyclic=False)
+ assert {n["id"] for n in ordered} == {n["id"] for n in graph["nodes"]}
+
+
+def test_an_empty_graph_is_rejected_even_for_a_conversation() -> None:
+ """require_acyclic drops one check, not all of them."""
+ with pytest.raises(ProtocolValidationError, match="no nodes"):
+ pe.topological_order({"nodes": [], "edges": []}, require_acyclic=False)
+
+
+def test_coordination_strategy_accepts_a_marked_lead_in_a_cycle() -> None:
+ """End to end over the guard both the publish endpoint and run_protocol call."""
+ pe.validate_coordination_strategy(_PEER_SPEC, graph=_mark_lead(_cycle_peer_graph("a", "b", "c"), "b"))
+
+
def test_coordination_strategy_retired_slug_raises() -> None:
with pytest.raises(ProtocolValidationError, match="no longer offered"):
validate_coordination_strategy(
From 1566b058f9d6f5aa27dfda9a2045ce65155e992b Mon Sep 17 00:00:00 2001
From: Jay Moran
Date: Tue, 8 Sep 2026 17:22:37 -0700
Subject: [PATCH 10/57] Take a consultation's sender from the turn stack, not
the engine
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.
---
src/asaree/services/agent_messenger.py | 129 +++++++++++++++++--------
tests/test_agent_messenger.py | 60 +++++++++++-
2 files changed, 146 insertions(+), 43 deletions(-)
diff --git a/src/asaree/services/agent_messenger.py b/src/asaree/services/agent_messenger.py
index f69abe7..a46e51e 100644
--- a/src/asaree/services/agent_messenger.py
+++ b/src/asaree/services/agent_messenger.py
@@ -37,6 +37,8 @@
import logging
import time
import uuid
+from collections.abc import Iterator
+from contextlib import contextmanager
from datetime import UTC, datetime, timedelta
from typing import Any
@@ -131,8 +133,16 @@ def __init__(
self._entry_agent_id = entry_agent_id
self._started_at = time.monotonic()
self._executions = 0
- self._depth = 0
self._sequence = 0
+ #: Canvas node ids of the agents whose turns are currently on the stack,
+ #: innermost last. This -- not anything the engine hands back -- is who
+ #: is speaking: invariant 2 says the runtime assigns sender identity, and
+ #: invariant 5 (one agent at a time, a consulting agent blocked on its
+ #: peer) is exactly what makes a stack the right shape. Motoro's own
+ #: ``from_agent_id`` is ``RunContext.agent_id``, the *durable* Agent row
+ #: for this turn, which is not a node id and so can't be authorized
+ #: against the graph at all -- see :meth:`send`.
+ self._turn_stack: list[str] = []
self._messages: list[dict[str, Any]] = []
#: Node id -> the label the canvas shows, so a briefing names agents the
#: way the user does. Same source ``build_agent_card`` uses, and the same
@@ -147,6 +157,32 @@ def __init__(
#: on ``limit_reached`` rather than looking like a clean completion.
self.limit_reached = False
+ # -- turns ---------------------------------------------------------
+
+ @contextmanager
+ def turn(self, node_id: str) -> Iterator[None]:
+ """Mark *node_id* as the agent now speaking, for the duration of its run.
+
+ Every agent turn in a conversation is wrapped in this -- the entry
+ agent's included -- so that a consultation raised from inside it is
+ attributed to the right canvas node without trusting anything the engine
+ or the model supplies.
+ """
+ self._turn_stack.append(node_id)
+ try:
+ yield
+ finally:
+ self._turn_stack.pop()
+
+ @property
+ def _depth(self) -> int:
+ """How deeply consultations are nested right now.
+
+ The entry agent's own turn is not a consultation, so it doesn't count --
+ depth 0 is "the agent the user addressed is asking its first peer".
+ """
+ return max(len(self._turn_stack) - 1, 0)
+
# -- transcript ----------------------------------------------------
@property
@@ -263,46 +299,56 @@ async def send(
consultation still appears in the transcript (invariant 10) -- a
silently dropped question is exactly the failure this makes debuggable.
- ``context`` is the engine's ``RunContext``. Unused: everything this
- needs is per-protocol-run state held on the instance, and reading run
- state out of the engine's context would be a second source of truth.
+ ``from_agent_id`` is the engine's view of the caller -- ``RunContext``'s
+ durable ``agent_id``, the reusable Agent row this turn ran as. That is
+ the wrong identity here and is deliberately **ignored**: authorization,
+ the transcript and the briefing all speak in canvas node ids, one
+ durable agent can back turns for more than one node, and taking the
+ sender from outside would put identity in the caller's hands (invariant
+ 2). The agent speaking is whichever turn is innermost on the stack --
+ see :meth:`turn`.
+
+ ``context`` is the engine's ``RunContext``. Unused for the same reason:
+ everything this needs is per-protocol-run state held on the instance,
+ and reading run state out of the engine's context would be a second
+ source of truth.
"""
- request = self.append(from_agent_id=from_agent_id, to_agent_id=to_agent_id, parts=parts)
+ sender_id = self._turn_stack[-1] if self._turn_stack else self._entry_agent_id
+ request = self.append(from_agent_id=sender_id, to_agent_id=to_agent_id, parts=parts)
- refusal = await self._refusal(from_agent_id, to_agent_id)
+ refusal = await self._refusal(sender_id, to_agent_id)
if refusal is not None:
- logger.info("consultation refused (%s -> %s): %s", from_agent_id, to_agent_id, refusal)
- return await self._reply(to_agent_id, from_agent_id, refusal, state="rejected")
+ logger.info("consultation refused (%s -> %s): %s", sender_id, to_agent_id, refusal)
+ return await self._reply(to_agent_id, sender_id, refusal, state="rejected")
await self.checkpoint()
self._executions += 1
- self._depth += 1
- try:
- # The caller's own clock stops for exactly this span, failures
- # included: it waited either way, and charging it for a peer's
- # failure is the same unfairness as charging it for a peer's
- # success. The conversation-level cap above keeps ticking.
- with deadlines_paused():
- # Built here, before the peer runs, so it is a snapshot of the
- # conversation as it stood when the question was asked.
- briefing = self._briefing(
- from_agent_id=from_agent_id,
- to_agent_id=to_agent_id,
- exclude_message_id=request["message_id"],
- )
+ # The caller's own clock stops for exactly this span, failures
+ # included: it waited either way, and charging it for a peer's
+ # failure is the same unfairness as charging it for a peer's
+ # success. The conversation-level cap above keeps ticking.
+ with deadlines_paused():
+ # Built here, before the peer runs, so it is a snapshot of the
+ # conversation as it stood when the question was asked.
+ briefing = self._briefing(
+ from_agent_id=sender_id,
+ to_agent_id=to_agent_id,
+ exclude_message_id=request["message_id"],
+ )
+ # `turn` is what makes the peer the sender of anything IT asks, and
+ # is also what advances the depth this consultation is nested at.
+ with self.turn(to_agent_id):
output_text, error, run_id = await self._run_peer(to_agent_id, parts, briefing=briefing)
- finally:
- self._depth -= 1
if error == _AGENT_CANCELLED:
return await self._reply(
- to_agent_id, from_agent_id, "The consultation was cancelled.", state="canceled", task_id=run_id
+ to_agent_id, sender_id, "The consultation was cancelled.", state="canceled", task_id=run_id
)
if error is not None:
return await self._reply(
to_agent_id,
- from_agent_id,
+ sender_id,
f"The agent could not answer: {error}",
state="failed",
task_id=run_id,
@@ -311,7 +357,7 @@ async def send(
text = (output_text or "").strip()
return await self._reply(
to_agent_id,
- from_agent_id,
+ sender_id,
text or "The agent finished without producing an answer.",
state="completed",
task_id=run_id,
@@ -492,19 +538,22 @@ async def execute_conversation(
if ambient_meta is None:
ambient_meta, _dataset = await _node_run_context(graph, entry_agent_id, workspace_id, owner_id)
- output_text, error, run_id = await _run_agent_node(
- node,
- protocol_id=protocol_id,
- protocol_run_id=protocol_run_id,
- owner_id=owner_id,
- user_input=user_input,
- graph=graph,
- workspace_id=workspace_id,
- ambient_meta=ambient_meta,
- evaluation_metrics=evaluation_metrics,
- available_agents=await resolve_available_agents(graph, entry_agent_id, owner_id=owner_id),
- agent_messenger=messenger,
- )
+ # The entry agent's turn is a turn like any other: without this, the peers
+ # it consults would be recorded and authorized against an empty stack.
+ with messenger.turn(entry_agent_id):
+ output_text, error, run_id = await _run_agent_node(
+ node,
+ protocol_id=protocol_id,
+ protocol_run_id=protocol_run_id,
+ owner_id=owner_id,
+ user_input=user_input,
+ graph=graph,
+ workspace_id=workspace_id,
+ ambient_meta=ambient_meta,
+ evaluation_metrics=evaluation_metrics,
+ available_agents=await resolve_available_agents(graph, entry_agent_id, owner_id=owner_id),
+ agent_messenger=messenger,
+ )
cancelled = error == _AGENT_CANCELLED
if not cancelled and error is None:
diff --git a/tests/test_agent_messenger.py b/tests/test_agent_messenger.py
index 9678444..c4e66b5 100644
--- a/tests/test_agent_messenger.py
+++ b/tests/test_agent_messenger.py
@@ -116,9 +116,14 @@ def _messenger(**kwargs: Any) -> AgentMessenger:
async def _ask(messenger: AgentMessenger, to: str = "critic", text: str = "What is weak here?") -> Any:
- return await messenger.send(
- from_agent_id="planner", to_agent_id=to, parts=[{"kind": "text", "text": text}], context=None
- )
+ # Inside the entry agent's own turn, the way execute_conversation runs it --
+ # the sender and the consultation depth both come from the turn stack, so a
+ # bare send() here would be asking from nobody's turn. `from_agent_id` is
+ # passed because the port requires it and is deliberately ignored (see send).
+ with messenger.turn("planner"):
+ return await messenger.send(
+ from_agent_id="planner", to_agent_id=to, parts=[{"kind": "text", "text": text}], context=None
+ )
# ----------------------------------------------------------------------
@@ -293,6 +298,55 @@ async def test_an_unconnected_agent_cannot_be_consulted(stubs: dict[str, Any]) -
assert stubs["peer_runs"] == []
+async def test_the_engines_idea_of_the_sender_is_ignored(stubs: dict[str, Any]) -> None:
+ """Regression: what Motoro passes as ``from_agent_id`` is
+ ``RunContext.agent_id`` -- the *durable* Agent row for the turn, never a
+ canvas node id -- so authorizing against it rejected every real
+ consultation. The sender is the innermost turn on the stack instead.
+ """
+ messenger = _messenger()
+ with messenger.turn("planner"):
+ reply = await messenger.send(
+ from_agent_id=str(uuid.uuid4()), # a durable agent id, as the engine sends it
+ to_agent_id="critic",
+ parts=[{"kind": "text", "text": "What is weak here?"}],
+ context=None,
+ )
+ assert reply.state == "completed"
+ assert [m["from_agent_id"] for m in messenger.conversation["messages"]] == ["planner", "critic"]
+
+
+async def test_a_peers_own_consultation_is_attributed_to_the_peer(stubs: dict[str, Any]) -> None:
+ """The stack, not the caller, is what makes a nested question come *from*
+ the peer -- which is also what its own authorization is checked against."""
+ nested: list[Any] = []
+
+ async def _run_agent_node(node: dict[str, Any], **kwargs: Any) -> tuple[str, None, uuid.UUID]:
+ messenger: AgentMessenger = kwargs["agent_messenger"]
+ if node["id"] == "critic":
+ nested.append(
+ await messenger.send(
+ from_agent_id=str(uuid.uuid4()),
+ to_agent_id="planner",
+ parts=[{"kind": "text", "text": "Clarify?"}],
+ context=None,
+ )
+ )
+ return ("A peer answer.", None, uuid.uuid4())
+
+ monkeypatch = pytest.MonkeyPatch()
+ monkeypatch.setattr(am, "_run_agent_node", _run_agent_node)
+ try:
+ messenger = _messenger()
+ assert (await _ask(messenger)).state == "completed"
+ finally:
+ monkeypatch.undo()
+ assert [r.state for r in nested] == ["completed"]
+ senders = [m["from_agent_id"] for m in messenger.conversation["messages"]]
+ # planner->critic, critic->planner (nested), planner's reply, critic's reply.
+ assert senders == ["planner", "critic", "planner", "critic"]
+
+
async def test_pulling_the_edge_mid_run_stops_the_next_consultation(stubs: dict[str, Any]) -> None:
"""Reachability is live: the run's cards came from a revision, but delivery
is re-authorized against the draft graph every single time."""
From 91ca13c30c4706d48f66779693763e3feef6b484 Mon Sep 17 00:00:00 2001
From: Jay Moran
Date: Tue, 8 Sep 2026 17:24:40 -0700
Subject: [PATCH 11/57] Open the canvas on the protocol's most recent run
`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.
---
.../protocol/ConversationTranscript.tsx | 29 ++++++++++++++++---
.../components/protocol/ProtocolCanvas.tsx | 25 ++++++++++++++++
2 files changed, 50 insertions(+), 4 deletions(-)
diff --git a/frontend/src/components/protocol/ConversationTranscript.tsx b/frontend/src/components/protocol/ConversationTranscript.tsx
index 0e069a4..37b73d6 100644
--- a/frontend/src/components/protocol/ConversationTranscript.tsx
+++ b/frontend/src/components/protocol/ConversationTranscript.tsx
@@ -1,3 +1,5 @@
+import { useState } from 'react'
+import { ChevronDown, ChevronUp } from 'lucide-react'
import type { Conversation, ConversationMessage } from '@/types/protocols'
// The literal id the backend uses for the human on both ends of a
@@ -66,14 +68,33 @@ export function ConversationTranscript({
}) {
const nameOf = (id: string) => (id === USER_PARTICIPANT ? 'You' : agentNames.get(id) ?? id)
const stateLabel = CONVERSATION_STATE_LABEL[conversation.state] ?? conversation.state
+ // Collapsible because the canvas now opens on the protocol's most recent run
+ // rather than only on one launched in this tab, so this panel is present the
+ // whole time you're editing a Peer Collaboration graph -- not just while
+ // watching a run finish. Collapsed keeps the header, which is the part that
+ // says a conversation happened at all.
+ const [collapsed, setCollapsed] = useState(false)
return (