diff --git a/coffeeAGNTCY/coffee_agents/lungo/agents/supervisors/recruiter/dynamic_workflow_agent.py b/coffeeAGNTCY/coffee_agents/lungo/agents/supervisors/recruiter/dynamic_workflow_agent.py index ce3a174f..d11ef4f1 100644 --- a/coffeeAGNTCY/coffee_agents/lungo/agents/supervisors/recruiter/dynamic_workflow_agent.py +++ b/coffeeAGNTCY/coffee_agents/lungo/agents/supervisors/recruiter/dynamic_workflow_agent.py @@ -60,7 +60,14 @@ def _reachable_url(url: str) -> str: - """Rewrite an unroutable advertised host to a reachable one.""" + """Rewrite an unroutable advertised host to a reachable one. + + Discovered records advertise the agent's own bind address (e.g. + ``http://0.0.0.0:9999``), which the supervisor cannot dial. Replace such + hosts with DISCOVERED_AGENT_HOST while preserving scheme, port, and path. + Non-HTTP transports (slim://, nats://) and already-routable hosts are + returned unchanged. + """ if not url: return url parts = urlsplit(url) @@ -72,16 +79,6 @@ def _reachable_url(url: str) -> str: return urlunsplit((parts.scheme, netloc, parts.path, parts.query, parts.fragment)) -def _reachable_card(card: AgentCard) -> AgentCard: - """Return a card copy with HTTP interface URLs rewritten for delegation.""" - reachable = card.model_copy(deep=True) - reachable.url = _reachable_url(reachable.url) - if reachable.additional_interfaces: - for interface in reachable.additional_interfaces: - interface.url = _reachable_url(interface.url) - return reachable - - class DynamicWorkflowAgent(BaseAgent): """Executes tasks by sending messages to selected recruited agents via A2A HTTP. @@ -111,14 +108,14 @@ async def _send_a2a_message( message[:100], ) - # negotiate and create the client based on the card's preferred transport - client = await a2a_client_factory.create( - card, - interceptors=[_event_interceptor], - consumers=[_event_consumer], - ) - try: + # negotiate and create the client based on the card's preferred transport + client = await a2a_client_factory.create( + card, + interceptors=[_event_interceptor], + consumers=[_event_consumer], + ) + result_text = None async for response in client.send_message(a2a_message): logger.info( @@ -156,7 +153,7 @@ async def _send_a2a_message( str(e), exc_info=True, ) - return f"Error communicating with {agent_name}: {str(e)}" + raise @staticmethod def _protocol_from_record(record: AgentRecord) -> AgentProtocol: @@ -214,7 +211,7 @@ async def _run_async_impl( return try: - record = AgentRecord.from_record_data(selected_cid, record_data) + record = AgentRecord.from_record(selected_cid, record_data) except Exception: logger.warning( f"Failed to parse agent record for CID {selected_cid}.", @@ -259,7 +256,11 @@ async def _run_async_impl( ) return - agent_card = _reachable_card(record.card) + agent_card = record.to_agent_card() + agent_card.url = _reachable_url(agent_card.url) + if agent_card.additional_interfaces: + for interface in agent_card.additional_interfaces: + interface.url = _reachable_url(interface.url) logger.info( "[agent:dynamic_workflow] Sending to agent: %s at %s", @@ -267,12 +268,35 @@ async def _run_async_impl( agent_card.url, ) - # Send A2A message - response_text = await self._send_a2a_message( - card=agent_card, - message=task_message, - agent_name=record.name, - ) + try: + response_text = await self._send_a2a_message( + card=agent_card, + message=task_message, + agent_name=record.name, + ) + except Exception as e: + logger.error( + "[agent:dynamic_workflow] Delegation to %s failed: %s", + record.name, + str(e), + exc_info=True, + ) + yield Event( + author=self.name, + invocation_id=ctx.invocation_id, + content=types.Content( + role="model", + parts=[ + types.Part( + text=( + f"Failed to delegate to agent '{record.name}': {e}" + ) + ) + ], + ), + ) + return + combined = f"**{record.name}**:\n{response_text}" yield Event( diff --git a/coffeeAGNTCY/coffee_agents/lungo/agents/supervisors/recruiter/models.py b/coffeeAGNTCY/coffee_agents/lungo/agents/supervisors/recruiter/models.py index df28f965..8ee09f48 100644 --- a/coffeeAGNTCY/coffee_agents/lungo/agents/supervisors/recruiter/models.py +++ b/coffeeAGNTCY/coffee_agents/lungo/agents/supervisors/recruiter/models.py @@ -4,10 +4,11 @@ """Shared state keys and data models for the Recruiter Supervisor.""" from enum import Enum -from typing import Optional +from typing import Any, Optional -from a2a.types import AgentCard -from pydantic import BaseModel +from a2a.types import AgentCard, AgentCapabilities +from agntcy_app_sdk.directory.oasf_converter import oasf_to_agent_card +from pydantic import BaseModel, PrivateAttr class AgentProtocol(str, Enum): @@ -25,21 +26,45 @@ class AgentProtocol(str, Enum): STATE_KEY_TASK_MESSAGE = "task_message" # str: message to forward to selected agents STATE_KEY_SELECTED_AGENT = "selected_agent" # str: CID of the currently selected agent + # --------------------------------------------------------------------------- # Data models # --------------------------------------------------------------------------- +def _card_has_transport_endpoint(card: AgentCard) -> bool: + """True when the card advertises at least one non-empty transport URL.""" + if (card.url or "").strip(): + return True + if card.additional_interfaces: + for interface in card.additional_interfaces: + if (interface.url or "").strip(): + return True + return False + + +def _require_delegation_card(card: AgentCard) -> None: + """Reject cards that cannot be used for A2A delegation.""" + if not (card.name or "").strip(): + raise ValueError("agent record is missing a name") + if not _card_has_transport_endpoint(card): + raise ValueError("agent record has no transport endpoint") + + class AgentRecord(BaseModel): """A recruited agent: its CID plus the full A2A AgentCard. The card is preserved verbatim so the client factory can negotiate transport. + The original record dict is retained for OASF-aware conversion when the card + is nested under ``modules[].data.card_data``. """ cid: str card: AgentCard protocol: AgentProtocol = AgentProtocol.A2A + _raw: dict[str, Any] = PrivateAttr(default_factory=dict) + @classmethod def from_record_data(cls, cid: str, data: dict) -> "AgentRecord": """Build from a stored record dict (a full A2A AgentCard).""" @@ -49,7 +74,55 @@ def from_record_data(cls, cid: str, data: dict) -> "AgentRecord": if isinstance(raw_protocol, str) else AgentProtocol.A2A ) - return cls(cid=cid, card=AgentCard.model_validate(data), protocol=protocol) + instance = cls(cid=cid, card=AgentCard.model_validate(data), protocol=protocol) + instance._raw = data + return instance + + @classmethod + def from_record(cls, cid: str, record: dict[str, Any]) -> "AgentRecord": + """Parse a recruiter record dict, retaining the raw form for conversion.""" + card = oasf_to_agent_card(record) + if card is None: + preferred = record.get("preferredTransport") or record.get( + "preferred_transport" + ) + kwargs: dict[str, Any] = { + "name": record.get("name", ""), + "url": record.get("url", ""), + "description": record.get("description", ""), + "version": record.get("version", "1.0.0"), + "capabilities": AgentCapabilities(streaming=False), + "skills": [], + "defaultInputModes": ["text"], + "defaultOutputModes": ["text"], + "supportsAuthenticatedExtendedCard": False, + } + if preferred: + kwargs["preferred_transport"] = str(preferred) + card = AgentCard(**kwargs) + + raw_protocol = record.get("protocol") + protocol = ( + AgentProtocol(raw_protocol.lower()) + if isinstance(raw_protocol, str) + else AgentProtocol.A2A + ) + _require_delegation_card(card) + instance = cls(cid=cid, card=card, protocol=protocol) + instance._raw = record + return instance + + def to_agent_card(self) -> AgentCard: + """Convert to an A2A AgentCard for delegation. + + Recruited agents are stored as full OASF records with the card nested + under ``modules[].data.card_data``; prefer that so url and transport + survive. Fall back to the stored card for flat AgentCard records. + """ + card = oasf_to_agent_card(self._raw) + if card is not None: + return card + return self.card.model_copy(deep=True) @property def name(self) -> str: diff --git a/coffeeAGNTCY/coffee_agents/lungo/agents/supervisors/recruiter/shared.py b/coffeeAGNTCY/coffee_agents/lungo/agents/supervisors/recruiter/shared.py index 24382fb7..a6b9608a 100644 --- a/coffeeAGNTCY/coffee_agents/lungo/agents/supervisors/recruiter/shared.py +++ b/coffeeAGNTCY/coffee_agents/lungo/agents/supervisors/recruiter/shared.py @@ -26,7 +26,9 @@ def get_factory() -> AgntcyFactory: include_nats=True, ) -# Discovered agents are reached over JSONRPC; httpx defaults to a 5s read timeout. +# Discovered agents are reached over JSONRPC (their OASF records advertise it), +# and httpx defaults to a 5s read timeout — too short for LLM-backed agents. +# Only the JSONRPC transport consumes httpx_client; SLIM/NATS ignore it. config.httpx_client = httpx.AsyncClient( timeout=httpx.Timeout(A2A_CLIENT_TIMEOUT_SECONDS, connect=10.0) ) diff --git a/coffeeAGNTCY/coffee_agents/lungo/config/config.py b/coffeeAGNTCY/coffee_agents/lungo/config/config.py index ee8ba30d..b22dda5c 100644 --- a/coffeeAGNTCY/coffee_agents/lungo/config/config.py +++ b/coffeeAGNTCY/coffee_agents/lungo/config/config.py @@ -24,11 +24,14 @@ DEFAULT_MESSAGE_TRANSPORT = os.getenv("DEFAULT_MESSAGE_TRANSPORT", "SLIM") # Host used to reach discovered agents that advertise an unroutable bind -# address (e.g. http://0.0.0.0:9999). Defaults to localhost for host runs; -# set to host.docker.internal inside containers. +# address (e.g. http://0.0.0.0:9999). Defaults to localhost for host runs +# (the farm ports are published there); set to host.docker.internal inside +# containers so the supervisor reaches the host-published ports. DISCOVERED_AGENT_HOST = os.getenv("DISCOVERED_AGENT_HOST", "localhost") # Read timeout (seconds) for the recruiter's JSONRPC delegation client. +# Discovered agents are reached over JSONRPC, and httpx's 5s default is too +# short for LLM-backed agents that take several seconds to respond. A2A_CLIENT_TIMEOUT_SECONDS = float(os.getenv("A2A_CLIENT_TIMEOUT_SECONDS", "30")) if os.getenv("SLIM_SHARED_SECRET") is None: diff --git a/coffeeAGNTCY/coffee_agents/lungo/docker-compose.yaml b/coffeeAGNTCY/coffee_agents/lungo/docker-compose.yaml index f94151fa..a8ee1c5f 100644 --- a/coffeeAGNTCY/coffee_agents/lungo/docker-compose.yaml +++ b/coffeeAGNTCY/coffee_agents/lungo/docker-compose.yaml @@ -596,8 +596,11 @@ services: - CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS:-} - WORKFLOW_API_URL=http://agentic-workflows-api:9105 - WORKFLOW_API_KEY=${WORKFLOW_API_KEY:?WORKFLOW_API_KEY must be set in .env} + # Discovered farm records advertise http://0.0.0.0:; reach them via + # the host gateway and their host-published ports from inside the network. - DISCOVERED_AGENT_HOST=${DISCOVERED_AGENT_HOST:-host.docker.internal} extra_hosts: + # Make host.docker.internal resolvable on Linux (no-op on Docker Desktop). - "host.docker.internal:host-gateway" ports: - "8882:8882" diff --git a/coffeeAGNTCY/coffee_agents/lungo/frontend/src/components/Chat/chatStreamGraphHighlight.test.ts b/coffeeAGNTCY/coffee_agents/lungo/frontend/src/components/Chat/chatStreamGraphHighlight.test.ts index 6e39cb10..96d387e6 100644 --- a/coffeeAGNTCY/coffee_agents/lungo/frontend/src/components/Chat/chatStreamGraphHighlight.test.ts +++ b/coffeeAGNTCY/coffee_agents/lungo/frontend/src/components/Chat/chatStreamGraphHighlight.test.ts @@ -15,6 +15,7 @@ import { animationSequenceStepIds, deriveAnimationSequenceFromGraph, resolveStreamAuthorToNodeId, + selectAnimationSequence, } from "./chatStreamGraphHighlight" function liveNode( @@ -179,4 +180,56 @@ describe("deriveAnimationSequenceFromGraph", () => { ) expect(seq).toEqual([]) }) + + it("pulses fan-in / back-to-source edges without looping", () => { + const seq = deriveAnimationSequenceFromGraph( + [ + liveNode("agent://auction", "customNode", {}), + liveNode("transport://slim", "transportNode", {}), + liveNode("agent://brazil", "customNode", {}), + ], + [ + liveEdge("e-auction-transport", "agent://auction", "transport://slim"), + liveEdge("e-transport-brazil", "transport://slim", "agent://brazil"), + liveEdge("e-brazil-transport", "agent://brazil", "transport://slim"), + ], + ) + expect(seq.map((step) => step.ids)).toEqual([ + ["agent://auction"], + ["e-auction-transport"], + ["transport://slim"], + ["e-transport-brazil"], + ["agent://brazil"], + ["e-brazil-transport"], + ]) + }) +}) + +describe("selectAnimationSequence", () => { + const nodes: Node[] = [ + liveNode("agent://recruiter", "customNode", {}), + liveNode("agent://directory", "customNode", {}), + ] + const edges: Edge[] = [ + liveEdge("e-recruiter-directory", "agent://recruiter", "agent://directory"), + ] + const staticSequence = [{ ids: ["static-node"] }] + + it("derives from the live graph in agentic mode", () => { + expect( + selectAnimationSequence(true, nodes, edges, staticSequence).map( + (step) => step.ids, + ), + ).toEqual([ + ["agent://recruiter"], + ["e-recruiter-directory"], + ["agent://directory"], + ]) + }) + + it("uses the authored static sequence outside agentic mode", () => { + expect(selectAnimationSequence(false, nodes, edges, staticSequence)).toBe( + staticSequence, + ) + }) }) diff --git a/coffeeAGNTCY/coffee_agents/lungo/frontend/src/components/Chat/chatStreamGraphHighlight.ts b/coffeeAGNTCY/coffee_agents/lungo/frontend/src/components/Chat/chatStreamGraphHighlight.ts index 9cdb729d..4ae67e37 100644 --- a/coffeeAGNTCY/coffee_agents/lungo/frontend/src/components/Chat/chatStreamGraphHighlight.ts +++ b/coffeeAGNTCY/coffee_agents/lungo/frontend/src/components/Chat/chatStreamGraphHighlight.ts @@ -141,6 +141,10 @@ export function animationSequenceStepIds( * root node(s) (no inbound edges, at least one outbound), then the edges fanning * out, then the next layer of nodes, and so on. Isolated nodes (e.g. the group * container) are skipped. No authored sequence or static config required. + * + * Re-entrant edges (fan-in / back-to-source, e.g. a farm replying to the + * transport) still pulse so no edge that the static sequence animated goes + * missing; each node is only scheduled once so the traversal still terminates. */ export function deriveAnimationSequenceFromGraph( nodes: Node[], @@ -165,6 +169,7 @@ export function deriveAnimationSequenceFromGraph( const steps: { ids: string[] }[] = [{ ids: [...roots] }] const visited = new Set(roots) + const pulsedEdges = new Set() let frontier = roots while (frontier.length > 0) { @@ -172,9 +177,12 @@ export function deriveAnimationSequenceFromGraph( const nextNodes: string[] = [] for (const nodeId of frontier) { for (const edge of outgoing.get(nodeId) ?? []) { - if (visited.has(edge.target)) continue + if (pulsedEdges.has(edge.id)) continue + pulsedEdges.add(edge.id) edgeIds.push(edge.id) - if (!nextNodes.includes(edge.target)) nextNodes.push(edge.target) + if (!visited.has(edge.target) && !nextNodes.includes(edge.target)) { + nextNodes.push(edge.target) + } } } for (const id of nextNodes) visited.add(id) @@ -186,3 +194,19 @@ export function deriveAnimationSequenceFromGraph( return steps } + +/** + * Pick the animation sequence for the current mode: the live BFS-derived + * sequence when the backend owns the graph (agentic), else the authored static + * sequence. Keeps the button-pulse and live-config paths in lockstep. + */ +export function selectAnimationSequence( + agenticMode: boolean, + nodes: Node[], + edges: Edge[], + staticSequence: GraphConfig["animationSequence"], +): GraphConfig["animationSequence"] { + return agenticMode + ? deriveAnimationSequenceFromGraph(nodes, edges) + : staticSequence +} diff --git a/coffeeAGNTCY/coffee_agents/lungo/frontend/src/components/MainArea/useMainArea.ts b/coffeeAGNTCY/coffee_agents/lungo/frontend/src/components/MainArea/useMainArea.ts index 66e9804e..0bf76d8c 100644 --- a/coffeeAGNTCY/coffee_agents/lungo/frontend/src/components/MainArea/useMainArea.ts +++ b/coffeeAGNTCY/coffee_agents/lungo/frontend/src/components/MainArea/useMainArea.ts @@ -18,7 +18,7 @@ import { useNodeTransportInterfaces } from "./useNodeTransportInterfaces" import type { WorkflowSummary } from "@/utils/agenticWorkflowsApi" import type { GraphConfig } from "@/utils/graphConfigs" import { graphConfigFromNodes } from "@/utils/graphConfigFromNodes" -import { deriveAnimationSequenceFromGraph } from "@/components/Chat/chatStreamGraphHighlight" +import { selectAnimationSequence } from "@/components/Chat/chatStreamGraphHighlight" import { logger } from "@/utils/logger" export interface MainAreaProps { @@ -78,6 +78,14 @@ export function useMainArea({ const [nodes, setNodes, onNodesChange] = useNodesState(config.nodes) const [edges, setEdges, onEdgesChange] = useEdgesState(config.edges) const animationLock = useRef(false) + // Latest graph snapshot read at animation start so the agentic button-pulse + // targets live ids without re-triggering the effect on every node mutation. + const nodesRef = useRef(nodes) + const edgesRef = useRef(edges) + useEffect(() => { + nodesRef.current = nodes + edgesRef.current = edges + }, [nodes, edges]) const handleOpenOasfModal = useCallback((nodeData: CustomNodeData) => { setOasfModalData(nodeData) @@ -190,7 +198,6 @@ export function useMainArea({ useEffect(() => { const shouldAnimate = buttonClicked && !aiReplied if (!shouldAnimate) return - if (agenticMode) return if (pattern === "group_messaging") return const waitForAnimationAndRun = async () => { while (animationLock.current) await delay(100) @@ -200,7 +207,12 @@ export function useMainArea({ await delay(DELAY_DURATION) } const animateGraph = async (): Promise => { - const animationSequence = config.animationSequence + const animationSequence = selectAnimationSequence( + agenticMode, + nodesRef.current, + edgesRef.current, + config.animationSequence, + ) for (const step of animationSequence) { await animate(step.ids, HIGHLIGHT.ON) await animate(step.ids, HIGHLIGHT.OFF) @@ -227,9 +239,12 @@ export function useMainArea({ agenticMode && selectedWorkflowSummary ? `${selectedWorkflowSummary.name} — ${selectedWorkflowSummary.scenario}` : config.title - const animationSequence = agenticMode - ? deriveAnimationSequenceFromGraph(nodes, edges) - : config.animationSequence + const animationSequence = selectAnimationSequence( + agenticMode, + nodes, + edges, + config.animationSequence, + ) onLiveGraphConfig( graphConfigFromNodes(title, nodes, edges, animationSequence), ) diff --git a/coffeeAGNTCY/coffee_agents/lungo/tests/unit/recruiter/test_dynamic_workflow_agent.py b/coffeeAGNTCY/coffee_agents/lungo/tests/unit/recruiter/test_dynamic_workflow_agent.py index 5dfb78e1..236f7ec7 100644 --- a/coffeeAGNTCY/coffee_agents/lungo/tests/unit/recruiter/test_dynamic_workflow_agent.py +++ b/coffeeAGNTCY/coffee_agents/lungo/tests/unit/recruiter/test_dynamic_workflow_agent.py @@ -10,7 +10,6 @@ from agents.supervisors.recruiter import dynamic_workflow_agent as dwa from agents.supervisors.recruiter.dynamic_workflow_agent import ( DynamicWorkflowAgent, - _reachable_card, _reachable_url, ) from agents.supervisors.recruiter.models import ( @@ -88,28 +87,6 @@ def test_reachable_url(self, case, url, host, expected): assert _reachable_url(url) == expected, case -class TestReachableCard: - def test_reachable_card_does_not_mutate_original(self): - from a2a.types import AgentCard - - original = AgentCard( - name="Brazil Farm", - description="Brazil coffee farm agent", - url="http://0.0.0.0:9999", - version="1.0.0", - capabilities={}, - default_input_modes=["text"], - default_output_modes=["text"], - skills=[], - ) - with patch.object(dwa, "DISCOVERED_AGENT_HOST", "host.docker.internal"): - reachable = _reachable_card(original) - - assert original.url == "http://0.0.0.0:9999" - assert reachable.url == "http://host.docker.internal:9999" - assert reachable is not original - - class TestDynamicWorkflowAgentConstruction: def test_can_instantiate(self): agent = DynamicWorkflowAgent( @@ -177,3 +154,39 @@ async def test_invalid_record_yields_error(self): # Should get an error about failing to parse assert len(events) == 1 assert "Failed to parse" in events[0].content.parts[0].text + + @pytest.mark.asyncio + async def test_delegation_transport_error_yields_error(self): + """Transport/client setup failures should yield a delegation error event.""" + agent = DynamicWorkflowAgent(name="dw_test", description="test") + record = { + "name": "Farm Agent", + "url": "http://farm:9999", + "preferredTransport": "jsonrpc", + "description": "desc", + "version": "1.0.0", + "capabilities": {"streaming": False}, + "defaultInputModes": ["text"], + "defaultOutputModes": ["text"], + "skills": [], + } + state = { + STATE_KEY_SELECTED_AGENT: "farm_cid", + STATE_KEY_RECRUITED_AGENTS: {"farm_cid": record}, + STATE_KEY_TASK_MESSAGE: "Try this", + } + ctx = _make_ctx(state=state) + + mock_factory = MagicMock() + mock_factory.create = AsyncMock( + side_effect=ValueError("no compatible transports found.") + ) + + events = [] + with patch.object(dwa, "a2a_client_factory", mock_factory): + async for event in agent._run_async_impl(ctx): + events.append(event) + + assert len(events) == 1 + assert "Failed to delegate" in events[0].content.parts[0].text + assert "no compatible transports found" in events[0].content.parts[0].text diff --git a/coffeeAGNTCY/coffee_agents/lungo/tests/unit/recruiter/test_models.py b/coffeeAGNTCY/coffee_agents/lungo/tests/unit/recruiter/test_models.py index 78260fda..923d1725 100644 --- a/coffeeAGNTCY/coffee_agents/lungo/tests/unit/recruiter/test_models.py +++ b/coffeeAGNTCY/coffee_agents/lungo/tests/unit/recruiter/test_models.py @@ -88,6 +88,90 @@ def test_from_record_data_reads_protocol(self): assert record.protocol == AgentProtocol.A2A +# --------------------------------------------------------------------------- +# AgentRecord.from_record / OASF-aware to_agent_card +# --------------------------------------------------------------------------- + + +class TestAgentRecordFromRecord: + @pytest.mark.parametrize( + "case,record,expected_url,expected_transport", + [ + ( + "oasf record extracts card_data url and transport", + { + "name": "Accountant agent", + "modules": [ + { + "name": "integration/a2a", + "data": { + "card_data": { + "name": "Accountant agent", + "url": "http://localhost:3000", + "preferredTransport": "JSONRPC", + "version": "1.0.0", + "description": "An AI agent that confirms the payment.", + "defaultInputModes": ["text"], + "defaultOutputModes": ["text"], + "capabilities": {"streaming": True}, + "skills": [], + "supportsAuthenticatedExtendedCard": False, + } + }, + } + ], + }, + "http://localhost:3000", + "JSONRPC", + ), + ( + "flat record fallback uses top-level url", + { + "name": "Flat Agent", + "url": "http://farm:9999", + "preferredTransport": "slimrpc", + "description": "desc", + "version": "2.0.0", + }, + "http://farm:9999", + "slimrpc", + ), + ], + ids=lambda case: case, + ) + def test_to_agent_card_from_record(self, case, record, expected_url, expected_transport): + card = AgentRecord.from_record("cid-1", record).to_agent_card() + + assert isinstance(card, AgentCard) + assert card.url == expected_url + assert card.preferred_transport == expected_transport + + @pytest.mark.parametrize( + "case,record,expected_message", + [ + ( + "missing name", + {"description": "No name field"}, + "missing a name", + ), + ( + "empty name and no transport", + {"name": "", "url": ""}, + "missing a name", + ), + ( + "name but no transport endpoint", + {"name": "Orphan Agent", "description": "no url"}, + "no transport endpoint", + ), + ], + ids=lambda case: case, + ) + def test_from_record_rejects_unusable_card(self, case, record, expected_message): + with pytest.raises(ValueError, match=expected_message): + AgentRecord.from_record("cid-bad", record) + + # --------------------------------------------------------------------------- # RecruitmentResponse # ---------------------------------------------------------------------------