From 90f2deda940945b081f0709c690e6680979d378e Mon Sep 17 00:00:00 2001 From: GuanyiLi-Craig Date: Thu, 2 Jul 2026 13:41:57 +0100 Subject: [PATCH 1/3] Move topic conditions to node publisher routes Topics are now pure FIFO queues: the routing predicate that lived on topic.condition moves to the publishing node as a publisher route, declared with Node.builder().publish_to(topic, when=predicate). PublishRoute/PublisherRouter live in node_base.py and publish_events routes via node.publisher_router.select(...), so a topic never drops an event. Node serialization carries the router (with `when` as a pickle-free callable reference) instead of the topic-name list. Co-Authored-By: Claude Fable 5 --- grafi/agents/react_agent.py | 30 +- grafi/nodes/node.py | 30 +- grafi/nodes/node_base.py | 130 +++++- grafi/topics/conditions.py | 18 +- grafi/topics/topic_base.py | 110 +---- grafi/topics/topic_factory.py | 3 - .../topic_impl/in_workflow_output_topic.py | 2 - grafi/workflows/impl/utils.py | 14 +- tests/assistants/test_assistant.py | 10 +- tests/assistants/test_assistant_mock_llm.py | 399 ++++++------------ .../test_publisher_routing_integration.py | 210 +++++++++ .../test_serialization_roundtrip.py | 89 ++-- tests/nodes/test_node.py | 29 +- tests/nodes/test_node_base.py | 57 ++- tests/nodes/test_publisher_router.py | 94 +++++ tests/topics/test_input_topic.py | 6 +- tests/topics/test_output_topic.py | 33 +- tests/topics/test_topic_base.py | 63 +-- .../test_topic_base_cache_integration.py | 17 +- tests/topics/test_topic_factory.py | 9 - tests/workflow/test_event_driven_workflow.py | 36 +- tests/workflow/test_fanout_quiescence.py | 10 +- tests/workflow/test_invocation_isolation.py | 4 +- tests/workflow/test_recovery.py | 4 +- tests/workflow/test_utils.py | 9 +- tests/workflow/test_workflow_run.py | 6 +- .../SimpleFunctionLLMAssistant_manifest.json | 43 +- .../SimpleFunctionCallAssistant_manifest.json | 70 +-- .../complex_function_manifest.json | 70 +-- .../multi_functions_call_assistant.py | 20 +- .../simple_claude_function_call_assistant.py | 18 +- ...simple_deepseek_function_call_assistant.py | 18 +- .../simple_function_call_assistant.py | 15 +- .../simple_gemini_function_call_assistant.py | 18 +- .../simple_ollama_function_call_assistant.py | 18 +- ...mple_openrouter_function_call_assistant.py | 18 +- .../hith_assistant/kyc_assistant.py | 14 +- .../hith_assistant/simple_hitl_assistant.py | 14 +- .../input_output_topics/mimo_llm_assistant.py | 55 ++- .../mimo_llm_assistant_example.py | 10 +- .../ReActAssistant_manifest.json | 113 +++-- .../react_assistant/react_assistant.py | 14 +- .../simple_stream_function_call_assistant.py | 14 +- 43 files changed, 1081 insertions(+), 883 deletions(-) create mode 100644 tests/assistants/test_publisher_routing_integration.py create mode 100644 tests/nodes/test_publisher_router.py diff --git a/grafi/agents/react_agent.py b/grafi/agents/react_agent.py index 4a1c634d..51f3898b 100644 --- a/grafi/agents/react_agent.py +++ b/grafi/agents/react_agent.py @@ -64,18 +64,12 @@ def builder(cls) -> "ReActAgentBuilder": return ReActAgentBuilder(cls) def _construct_workflow(self) -> "ReActAgent": - function_call_topic = Topic( - name="function_call_topic", - condition=has_tool_call, - ) + function_call_topic = Topic(name="function_call_topic") function_result_topic = Topic(name="function_result_topic") agent_input_topic = InputTopic(name="agent_input_topic") - agent_output_topic = OutputTopic( - name="agent_output_topic", - condition=has_text_response, - ) + agent_output_topic = OutputTopic(name="agent_output_topic") llm_node = ( Node.builder() @@ -96,21 +90,21 @@ def _construct_workflow(self) -> "ReActAgent": .system_message(self.system_prompt) .build() ) - .publish_to(function_call_topic) - .publish_to(agent_output_topic) + .publish_to(function_call_topic, when=has_tool_call) + .publish_to(agent_output_topic, when=has_text_response) .build() ) # Create a function call node - function_call_node = Node( - name="Node", - type="Node", - tool=self.function_call_tool, - subscribed_expressions=[ - SubscriptionBuilder().subscribed_to(function_call_topic).build() - ], - publish_to=[function_result_topic], + function_call_node = ( + Node.builder() + .name("Node") + .type("Node") + .tool(self.function_call_tool) + .subscribe(SubscriptionBuilder().subscribed_to(function_call_topic).build()) + .publish_to(function_result_topic) + .build() ) # Create a workflow and add the nodes diff --git a/grafi/nodes/node.py b/grafi/nodes/node.py index b77c9dea..5c52dcc4 100644 --- a/grafi/nodes/node.py +++ b/grafi/nodes/node.py @@ -12,6 +12,7 @@ from grafi.common.models.invoke_context import InvokeContext from grafi.nodes.node_base import NodeBase from grafi.nodes.node_base import NodeBaseBuilder +from grafi.nodes.node_base import PublisherRouter from grafi.tools.command import Command from grafi.tools.tool_factory import ToolFactory from grafi.topics.expressions.topic_expression import CombinedExpr @@ -73,17 +74,21 @@ def to_dict(self) -> dict[str, Any]: } @classmethod - async def from_dict( + async def _builder_from_dict( cls, node_dict: Dict[str, Any], topics: Dict[str, TopicBase] - ) -> "Node": - """Create a Node instance from a dictionary representation.""" + ) -> NodeBaseBuilder: + """Reconstruct this node's builder from a dictionary representation. + + Subclasses extend deserialization by calling this and then applying + their own fields to the returned builder. + """ from openinference.semconv.trace import OpenInferenceSpanKindValues # Deserialize the tool tool = await ToolFactory.from_dict(node_dict["tool"]) node_builder = ( - Node.builder() + cls.builder() .name(node_dict["name"]) .type(node_dict["type"]) .oi_span_type( @@ -103,11 +108,16 @@ async def from_dict( elif "op" in expr_dict: node_builder.subscribe(await CombinedExpr.from_dict(expr_dict, topics)) - for topic_name in node_dict["publish_to"]: - # Check if topic already exists in topics dict - if topic_name in topics: - node_builder.publish_to(topics[topic_name]) - else: - raise ValueError(f"Unknown topic: {topic_name}") + node_builder.publisher_router( + PublisherRouter.from_dict(node_dict["publisher_router"], topics) + ) + + return node_builder + @classmethod + async def from_dict( + cls, node_dict: Dict[str, Any], topics: Dict[str, TopicBase] + ) -> "Node": + """Create a Node instance from a dictionary representation.""" + node_builder = await cls._builder_from_dict(node_dict, topics) return node_builder.build() diff --git a/grafi/nodes/node_base.py b/grafi/nodes/node_base.py index 8666efee..21c438a9 100644 --- a/grafi/nodes/node_base.py +++ b/grafi/nodes/node_base.py @@ -1,5 +1,6 @@ from typing import Any from typing import AsyncGenerator +from typing import Callable from typing import Dict from typing import List from typing import Optional @@ -7,12 +8,15 @@ from typing import TypeVar from typing import Union +from loguru import logger from openinference.semconv.trace import OpenInferenceSpanKindValues from pydantic import BaseModel from pydantic import ConfigDict from pydantic import Field from pydantic import PrivateAttr +from grafi.common.callable_ref import deserialize_callable +from grafi.common.callable_ref import serialize_callable from grafi.common.events.topic_events.consume_from_topic_event import ( ConsumeFromTopicEvent, ) @@ -23,12 +27,98 @@ from grafi.common.models.invoke_context import InvokeContext from grafi.tools.command import Command from grafi.tools.tool import Tool +from grafi.topics.conditions import always_true from grafi.topics.expressions.topic_expression import SubExpr from grafi.topics.expressions.topic_expression import TopicExpr from grafi.topics.expressions.topic_expression import evaluate_subscription from grafi.topics.topic_base import TopicBase +class PublishRoute(BaseModel): + """One destination in the publisher router's table. + + ``when`` is the CONTENT predicate (what used to live on ``topic.condition``). + """ + + model_config = ConfigDict(arbitrary_types_allowed=True) + + topic: TopicBase # destination (definition-time instance) + when: Callable[[PublishToTopicEvent], bool] = always_true # content predicate + + def to_dict(self) -> Dict[str, Any]: + return { + "topic": self.topic.name, + "when": serialize_callable(self.when), + } + + @classmethod + def from_dict( + cls, data: Dict[str, Any], topics: Dict[str, TopicBase] + ) -> "PublishRoute": + raw = data.get("when") + when = ( + deserialize_callable(raw, context=f"route '{data['topic']}' when") + if raw + else always_true + ) + return cls( + topic=topics[data["topic"]], + when=when, + ) + + +class PublisherRouter(BaseModel): + """The node's output routing table; ``publish_to`` derives from its routes.""" + + model_config = ConfigDict(arbitrary_types_allowed=True) + + routes: List[PublishRoute] = Field(default_factory=list) + + def select(self, event: PublishToTopicEvent) -> List[str]: + """Destination topic names for an outgoing event, routed by content.""" + return [r.topic.name for r in self.routes if self._condition_ok(r.when, event)] + + @staticmethod + def _condition_ok( + when: Callable[[PublishToTopicEvent], bool], event: PublishToTopicEvent + ) -> bool: + """Evaluate a route's content predicate with the topic error policy. + + Mirrors the old ``TopicBase.publish_data``: an ``IndexError``/``KeyError`` + is the expected "not met" signal for predicates that index possibly-empty + data; any other exception is a probable bug, surfaced at WARNING but + still treated as not-met so one faulty predicate cannot halt the + workflow. + """ + try: + return bool(when(event)) + except (IndexError, KeyError) as e: + logger.debug(f"Route predicate not met ({type(e).__name__}: {e}).") + return False + except Exception as e: # noqa: BLE001 - defensive: a user predicate bug + logger.warning( + f"Route predicate raised an unexpected {type(e).__name__}: {e}. " + "Treating as not published; check the publisher route's `when`." + ) + return False + + @property + def publish_to(self) -> List[TopicBase]: + """Every destination topic (the node's single output declaration).""" + return [r.topic for r in self.routes] + + def to_dict(self) -> Dict[str, Any]: + return {"routes": [r.to_dict() for r in self.routes]} + + @classmethod + def from_dict( + cls, data: Dict[str, Any], topics: Dict[str, TopicBase] + ) -> "PublisherRouter": + return cls( + routes=[PublishRoute.from_dict(r, topics) for r in data.get("routes", [])] + ) + + class NodeBase(BaseModel): """Abstract base class for nodes in a graph-based agent system.""" @@ -40,11 +130,16 @@ class NodeBase(BaseModel): tool: Optional[Tool] = Field(default=None) oi_span_type: OpenInferenceSpanKindValues = OpenInferenceSpanKindValues.CHAIN subscribed_expressions: List[SubExpr] = Field(default_factory=list) - publish_to: List[TopicBase] = Field(default_factory=list) + publisher_router: PublisherRouter = Field(default_factory=PublisherRouter) _subscribed_topics: Dict[str, TopicBase] = PrivateAttr(default_factory=dict) _command: Optional[Command] = PrivateAttr(default=None) + @property + def publish_to(self) -> List[TopicBase]: + """Every destination topic (derived from the publisher router's routes).""" + return self.publisher_router.publish_to + @property def command(self) -> Optional[Command]: """Access the internal command (for backward compatibility). @@ -128,7 +223,7 @@ def to_dict(self) -> dict[str, Any]: "subscribed_expressions": [ expr.to_dict() for expr in self.subscribed_expressions ], - "publish_to": [topic.name for topic in self.publish_to], + "publisher_router": self.publisher_router.to_dict(), "command": self.command.to_dict() if self.command else None, } @@ -246,16 +341,37 @@ def subscribe(self, subscribe_to: Union[TopicBase, SubExpr]) -> Self: ) return self - def publish_to(self, topic: TopicBase) -> Self: - """Add a topic that this node will publish results to. + def publish_to( + self, + topic: TopicBase, + when: Optional[Callable[[PublishToTopicEvent], bool]] = None, + ) -> Self: + """Add a publisher route to a topic this node publishes to. Args: topic: The topic to publish output events to. + when: Optional content predicate (defaults to "always"); the route is + taken when it returns True for the outgoing event. + + Returns: + Self for method chaining. + """ + if "publisher_router" not in self.kwargs: + self.kwargs["publisher_router"] = PublisherRouter() + route_kwargs: Dict[str, Any] = {"topic": topic} + if when is not None: + route_kwargs["when"] = when + self.kwargs["publisher_router"].routes.append(PublishRoute(**route_kwargs)) + return self + + def publisher_router(self, publisher_router: PublisherRouter) -> Self: + """Set the whole publisher router (used when deserializing a node). + + Args: + publisher_router: The reconstructed output routing table. Returns: Self for method chaining. """ - if "publish_to" not in self.kwargs: - self.kwargs["publish_to"] = [] - self.kwargs["publish_to"].append(topic) + self.kwargs["publisher_router"] = publisher_router return self diff --git a/grafi/topics/conditions.py b/grafi/topics/conditions.py index df162fb0..2ac57a82 100644 --- a/grafi/topics/conditions.py +++ b/grafi/topics/conditions.py @@ -1,8 +1,9 @@ -"""Reusable topic-condition predicates. +"""Reusable routing predicates. -A topic ``condition`` decides whether an event is published to that topic. These -named, importable predicates cover the common routing checks so workflows can -reference them (``condition=has_tool_call``) instead of embedding inline lambdas. +A publisher route's ``when`` decides whether an outgoing event is delivered to +that route's topic. These named, importable predicates cover the common content +checks so workflows can reference them (``when=has_tool_call``) instead of +embedding inline lambdas. Named predicates are preferred over lambdas because they: @@ -18,6 +19,15 @@ from grafi.common.events.topic_events.publish_to_topic_event import PublishToTopicEvent +def always_true(_: PublishToTopicEvent) -> bool: + """Default routing predicate: take the route for every event. + + A named module-level function (rather than an inline ``lambda``) so it + serializes as a tiny import reference instead of inline code. + """ + return True + + def has_tool_call(event: PublishToTopicEvent) -> bool: """Publish only when the last message requests a tool/function call.""" return event.data[-1].tool_calls is not None diff --git a/grafi/topics/topic_base.py b/grafi/topics/topic_base.py index 3544640b..8eb214a5 100644 --- a/grafi/topics/topic_base.py +++ b/grafi/topics/topic_base.py @@ -1,70 +1,38 @@ from typing import Any -from typing import Callable -from typing import Dict from typing import List from typing import Optional from typing import Self from typing import TypeVar -from loguru import logger from pydantic import BaseModel from pydantic import ConfigDict from pydantic import Field -from grafi.common.callable_ref import deserialize_callable -from grafi.common.callable_ref import serialize_callable from grafi.common.events.topic_events.consume_from_topic_event import ( ConsumeFromTopicEvent, ) from grafi.common.events.topic_events.publish_to_topic_event import PublishToTopicEvent from grafi.common.events.topic_events.topic_event import TopicEvent from grafi.common.models.base_builder import BaseBuilder -from grafi.common.models.message import Messages from grafi.topics.queue_impl.in_mem_topic_event_queue import InMemTopicEventQueue from grafi.topics.topic_event_queue import TopicEventQueue from grafi.topics.topic_types import TopicType -def always_true(_: PublishToTopicEvent) -> bool: - """Default topic condition: publish every event. - - A named module-level function (rather than an inline ``lambda``) so it - serializes as a tiny import reference instead of inline code. - """ - return True - - -def deserialize_condition( - data: Dict[str, Any], -) -> Callable[[PublishToTopicEvent], bool]: - """Reconstruct a topic's ``condition`` from its serialized form. - - Centralizes the decoding shared by every topic ``from_dict``. A missing or - empty condition falls back to :func:`always_true`. Otherwise the value is - resolved by :func:`~grafi.common.callable_ref.deserialize_callable`, which - handles references and components (no pickle). +class TopicBase(BaseModel): """ - raw = data.get("condition") - if raw is None or raw == "": - return always_true - return deserialize_callable( - raw, context=f"topic '{data.get('name', '')}' condition" - ) + Represents a topic in a message queue system: a pure FIFO queue. + A topic only stores and delivers events; it carries no routing ``condition`` + (all routing lives on the node's publisher router). ``publish_data`` stamps + the event with this topic's name/type and enqueues it. -class TopicBase(BaseModel): - """ - Represents a topic in a message queue system. - Manages both publishing and consumption of message event IDs using a FIFO cache. - name: string (the topic's name) - - condition: function to determine if a message should be published - event_queue: FIFO cache for recently accessed events - - total_published: total number of events published to this topic """ name: str = Field(default="") type: TopicType = Field(default=TopicType.DEFAULT_TOPIC_TYPE) - condition: Callable[[PublishToTopicEvent], bool] = Field(default=always_true) event_queue: TopicEventQueue = Field(default_factory=InMemTopicEventQueue) model_config = ConfigDict(arbitrary_types_allowed=True) @@ -72,43 +40,19 @@ class TopicBase(BaseModel): async def publish_data( self, publish_event: PublishToTopicEvent ) -> Optional[PublishToTopicEvent]: + """Stamp the event with this topic's name/type and enqueue it. + + Returns the enqueued event. Routing has already happened on the node's + publisher router, so a topic never drops an event. """ - Publish data to the topic if it meets the condition. - - Returns the published event, or ``None`` when the topic's condition is - not met (callers must null-check). - """ - try: - condition_met = self.condition(publish_event) - except (IndexError, KeyError) as e: - # Expected "not met" signal for conditions that index into data that - # may legitimately be empty/absent. - logger.debug(f"[{self.name}] Condition not met ({type(e).__name__}: {e}).") - condition_met = False - except Exception as e: - # An unexpected error in a user condition is almost certainly a bug. - # Surface it at WARNING (a silent drop here looks identical to a normal - # routing decision) but still treat as not-met so one faulty condition - # cannot halt the whole workflow. - logger.warning( - f"[{self.name}] Condition raised an unexpected " - f"{type(e).__name__}: {e}. Treating as not published; " - "check the topic condition." - ) - condition_met = False - - if condition_met: - event = publish_event.model_copy( - update={ - "name": self.name, - "type": self.type, - }, - deep=True, - ) - return await self.add_event(event) - else: - logger.info(f"[{self.name}] Message NOT published (condition not met)") - return None + event = publish_event.model_copy( + update={ + "name": self.name, + "type": self.type, + }, + deep=True, + ) + return await self.add_event(event) async def can_consume(self, consumer_name: str) -> bool: """ @@ -173,7 +117,6 @@ def to_dict(self) -> dict[str, Any]: return { "name": self.name, "type": self.type.value, - "condition": serialize_callable(self.condition), } @classmethod @@ -186,16 +129,10 @@ async def from_dict(cls, data: dict[str, Any]) -> "TopicBase": Returns: TopicBase: A TopicBase instance created from the dictionary. - - Note: - The condition is reconstructed (without pickle) from its import - reference or CallableComponent config. See - :mod:`grafi.common.callable_ref`. """ return cls( name=data["name"], type=data["type"], - condition=deserialize_condition(data), ) @@ -228,16 +165,3 @@ def type(self, type_name: str) -> Self: """ self.kwargs["type"] = type_name return self - - def condition(self, condition: Callable[[Messages], bool]) -> Self: - """Set a condition function that determines when this topic is satisfied. - - Args: - condition: A callable that takes Messages and returns True - when the topic's output condition is met. - - Returns: - Self for method chaining. - """ - self.kwargs["condition"] = condition - return self diff --git a/grafi/topics/topic_factory.py b/grafi/topics/topic_factory.py index 14e757f3..b7206236 100644 --- a/grafi/topics/topic_factory.py +++ b/grafi/topics/topic_factory.py @@ -30,7 +30,6 @@ class TopicFactory: >>> topic_data = { ... "name": "my_topic", ... "type": "AgentInputTopic", - ... "condition": "..." ... } >>> topic = TopicFactory.from_dict(topic_data) >>> isinstance(topic, InputTopic) @@ -68,7 +67,6 @@ async def from_dict(cls, data: Dict[str, Any]) -> TopicBase: Must contain at least: - "type": The topic type (string or TopicType enum value) - "name": The topic name - - "condition": The serialized condition function Returns: TopicBase: An instance of the appropriate topic subclass. @@ -81,7 +79,6 @@ async def from_dict(cls, data: Dict[str, Any]) -> TopicBase: >>> data = { ... "name": "user_input", ... "type": "AgentInputTopic", - ... "condition": "" ... } >>> topic = await TopicFactory.from_dict(data) >>> isinstance(topic, InputTopic) diff --git a/grafi/topics/topic_impl/in_workflow_output_topic.py b/grafi/topics/topic_impl/in_workflow_output_topic.py index 0a836fea..7163de9b 100644 --- a/grafi/topics/topic_impl/in_workflow_output_topic.py +++ b/grafi/topics/topic_impl/in_workflow_output_topic.py @@ -4,7 +4,6 @@ from pydantic import Field -from grafi.topics.topic_base import deserialize_condition from grafi.topics.topic_impl.topic import Topic from grafi.topics.topic_impl.topic import TopicBuilder from grafi.topics.topic_types import TopicType @@ -73,7 +72,6 @@ async def from_dict(cls, data: dict[str, Any]) -> "InWorkflowOutputTopic": return cls( name=data["name"], type=data["type"], - condition=deserialize_condition(data), paired_in_workflow_input_topic_names=data.get( "paired_in_workflow_input_topic_names", [] ), diff --git a/grafi/workflows/impl/utils.py b/grafi/workflows/impl/utils.py index 7db2001f..1aea3b0a 100644 --- a/grafi/workflows/impl/utils.py +++ b/grafi/workflows/impl/utils.py @@ -109,21 +109,21 @@ async def publish_events( """ published_events: List[PublishToTopicEvent] = [] - for topic in node.publish_to: - run_topic = topics[topic.name] - delivery_count = len(consumers_of(topic.name)) + for topic_name in node.publisher_router.select(publish_event): + run_topic = topics[topic_name] + delivery_count = len(consumers_of(topic_name)) if delivery_count: await tracker.on_messages_published( - delivery_count, source=f"node:{node.name}->{topic.name}" + delivery_count, source=f"node:{node.name}->{topic_name}" ) event = await run_topic.publish_data(publish_event) if event: published_events.append(event) elif delivery_count: - # Condition not met: the event was never enqueued, so no consumer - # will ever commit these deliveries. Release them immediately. + # The topic dropped the event (still enqueued nothing), so no + # consumer will ever commit these deliveries. Release them now. await tracker.on_messages_committed( - delivery_count, source=f"discard:{topic.name}" + delivery_count, source=f"discard:{topic_name}" ) return published_events diff --git a/tests/assistants/test_assistant.py b/tests/assistants/test_assistant.py index c1887ef7..88cf1a2d 100644 --- a/tests/assistants/test_assistant.py +++ b/tests/assistants/test_assistant.py @@ -13,6 +13,8 @@ from grafi.common.events.topic_events.publish_to_topic_event import PublishToTopicEvent from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.message import Message +from grafi.nodes.node_base import PublisherRouter +from grafi.nodes.node_base import PublishRoute from grafi.topics.topic_types import TopicType from grafi.workflows.workflow import Workflow @@ -650,7 +652,9 @@ async def from_dict(cls, data): name="test_node", tool=mock_tool, subscribed_expressions=[TopicExpr(topic=input_topic)], - publish_to=[output_topic], + publisher_router=PublisherRouter( + routes=[PublishRoute(topic=output_topic)] + ), ) workflow = EventDrivenWorkflow(nodes={"test_node": node}) @@ -722,7 +726,9 @@ async def from_dict(cls, data): name="roundtrip_node", tool=mock_tool, subscribed_expressions=[TopicExpr(topic=input_topic)], - publish_to=[output_topic], + publisher_router=PublisherRouter( + routes=[PublishRoute(topic=output_topic)] + ), ) workflow = EventDrivenWorkflow(nodes={"roundtrip_node": node}) diff --git a/tests/assistants/test_assistant_mock_llm.py b/tests/assistants/test_assistant_mock_llm.py index 98a9c8a9..190bebf2 100644 --- a/tests/assistants/test_assistant_mock_llm.py +++ b/tests/assistants/test_assistant_mock_llm.py @@ -51,6 +51,16 @@ def _mock_passthrough(messages: Messages) -> Messages: return messages +def _is_final_response(event: PublishToTopicEvent) -> bool: + """Publish predicate: message has content and no tool calls.""" + return event.data[-1].content is not None and event.data[-1].tool_calls is None + + +def _has_tool_calls(event: PublishToTopicEvent) -> bool: + """Publish predicate: message contains tool calls.""" + return event.data[-1].tool_calls is not None + + @use_command(Command) class LLMMockTool(Tool): name: str = "LLMMockTool" @@ -180,17 +190,8 @@ def mock_llm(messages: List[Message]) -> List[Message]: # Create topics agent_input = InputTopic(name="agent_input") - agent_output = OutputTopic( - name="agent_output", - # Only output when there's content and no tool calls - condition=lambda event: ( - event.data[-1].content is not None and event.data[-1].tool_calls is None - ), - ) - function_call_topic = Topic( - name="function_call", - condition=lambda event: event.data[-1].tool_calls is not None, - ) + agent_output = OutputTopic(name="agent_output") + function_call_topic = Topic(name="function_call") # Create LLM node llm_node = ( @@ -198,8 +199,8 @@ def mock_llm(messages: List[Message]) -> List[Message]: .name("MockLLMNode") .subscribe(SubscriptionBuilder().subscribed_to(agent_input).build()) .tool(LLMMockTool(function=mock_llm)) - .publish_to(agent_output) - .publish_to(function_call_topic) + .publish_to(agent_output, when=_is_final_response) + .publish_to(function_call_topic, when=_has_tool_calls) .build() ) @@ -272,16 +273,8 @@ def search(self, query: str) -> str: # Create topics agent_input = InputTopic(name="agent_input") - agent_output = OutputTopic( - name="agent_output", - condition=lambda event: ( - event.data[-1].content is not None and event.data[-1].tool_calls is None - ), - ) - function_call_topic = Topic( - name="function_call", - condition=lambda event: event.data[-1].tool_calls is not None, - ) + agent_output = OutputTopic(name="agent_output") + function_call_topic = Topic(name="function_call") function_result_topic = Topic(name="function_result") # Create LLM node @@ -296,8 +289,8 @@ def search(self, query: str) -> str: .build() ) .tool(LLMMockTool(function=mock_llm)) - .publish_to(agent_output) - .publish_to(function_call_topic) + .publish_to(agent_output, when=_is_final_response) + .publish_to(function_call_topic, when=_has_tool_calls) .build() ) @@ -393,16 +386,8 @@ def get_orders(self, user_id: str) -> str: # Create topics agent_input = InputTopic(name="agent_input") - agent_output = OutputTopic( - name="agent_output", - condition=lambda event: ( - event.data[-1].content is not None and event.data[-1].tool_calls is None - ), - ) - function_call_topic = Topic( - name="function_call", - condition=lambda event: event.data[-1].tool_calls is not None, - ) + agent_output = OutputTopic(name="agent_output") + function_call_topic = Topic(name="function_call") function_result_topic = Topic(name="function_result") llm_node = ( @@ -416,8 +401,8 @@ def get_orders(self, user_id: str) -> str: .build() ) .tool(LLMMockTool(function=mock_llm)) - .publish_to(agent_output) - .publish_to(function_call_topic) + .publish_to(agent_output, when=_is_final_response) + .publish_to(function_call_topic, when=_has_tool_calls) .build() ) @@ -501,24 +486,16 @@ def mock_llm(messages: List[Message]) -> List[Message]: return [Message(role="assistant", content="I can answer this directly!")] agent_input = InputTopic(name="agent_input") - agent_output = OutputTopic( - name="agent_output", - condition=lambda event: ( - event.data[-1].content is not None and event.data[-1].tool_calls is None - ), - ) - hitl_call_topic = Topic( - name="hitl_call", - condition=lambda event: event.data[-1].tool_calls is not None, - ) + agent_output = OutputTopic(name="agent_output") + hitl_call_topic = Topic(name="hitl_call") llm_node = ( Node.builder() .name("MockLLMNode") .subscribe(SubscriptionBuilder().subscribed_to(agent_input).build()) .tool(LLMMockTool(function=mock_llm)) - .publish_to(agent_output) - .publish_to(hitl_call_topic) + .publish_to(agent_output, when=_is_final_response) + .publish_to(hitl_call_topic, when=_has_tool_calls) .build() ) @@ -601,16 +578,8 @@ def request_human_information(self, question: str) -> str: # Create topics following integration test pattern agent_input = InputTopic(name="agent_input") - agent_output = OutputTopic( - name="agent_output", - condition=lambda event: ( - event.data[-1].content is not None and event.data[-1].tool_calls is None - ), - ) - hitl_call_topic = Topic( - name="hitl_call", - condition=lambda event: event.data[-1].tool_calls is not None, - ) + agent_output = OutputTopic(name="agent_output") + hitl_call_topic = Topic(name="hitl_call") # HITL topics - the key components for true HITL pattern human_response_topic = InWorkflowInputTopic(name="human_response") @@ -631,8 +600,8 @@ def request_human_information(self, question: str) -> str: .build() ) .tool(LLMMockTool(function=mock_llm)) - .publish_to(agent_output) - .publish_to(hitl_call_topic) + .publish_to(agent_output, when=_is_final_response) + .publish_to(hitl_call_topic, when=_has_tool_calls) .build() ) @@ -761,16 +730,8 @@ def request_info(self, field: str) -> str: # Topics agent_input = InputTopic(name="agent_input") - agent_output = OutputTopic( - name="agent_output", - condition=lambda event: ( - event.data[-1].content is not None and event.data[-1].tool_calls is None - ), - ) - hitl_call_topic = Topic( - name="hitl_call", - condition=lambda event: event.data[-1].tool_calls is not None, - ) + agent_output = OutputTopic(name="agent_output") + hitl_call_topic = Topic(name="hitl_call") human_response_topic = InWorkflowInputTopic(name="human_response") human_request_topic = InWorkflowOutputTopic( name="human_request", @@ -788,8 +749,8 @@ def request_info(self, field: str) -> str: .build() ) .tool(LLMMockTool(function=mock_llm)) - .publish_to(agent_output) - .publish_to(hitl_call_topic) + .publish_to(agent_output, when=_is_final_response) + .publish_to(hitl_call_topic, when=_has_tool_calls) .build() ) @@ -930,16 +891,8 @@ def request_approval(self, action: str) -> str: ) agent_input = InputTopic(name="agent_input") - agent_output = OutputTopic( - name="agent_output", - condition=lambda event: ( - event.data[-1].content is not None and event.data[-1].tool_calls is None - ), - ) - hitl_call_topic = Topic( - name="hitl_call", - condition=lambda event: event.data[-1].tool_calls is not None, - ) + agent_output = OutputTopic(name="agent_output") + hitl_call_topic = Topic(name="hitl_call") human_response_topic = InWorkflowInputTopic(name="human_response") human_request_topic = InWorkflowOutputTopic( name="human_request", @@ -957,8 +910,8 @@ def request_approval(self, action: str) -> str: .build() ) .tool(LLMMockTool(function=mock_llm)) - .publish_to(agent_output) - .publish_to(hitl_call_topic) + .publish_to(agent_output, when=_is_final_response) + .publish_to(hitl_call_topic, when=_has_tool_calls) .build() ) @@ -1063,16 +1016,8 @@ def auto_approve(self, action: str) -> str: return "Action APPROVED automatically" agent_input = InputTopic(name="agent_input") - agent_output = OutputTopic( - name="agent_output", - condition=lambda event: ( - event.data[-1].content is not None and event.data[-1].tool_calls is None - ), - ) - function_call_topic = Topic( - name="function_call", - condition=lambda event: event.data[-1].tool_calls is not None, - ) + agent_output = OutputTopic(name="agent_output") + function_call_topic = Topic(name="function_call") function_result_topic = Topic(name="function_result") llm_node = ( @@ -1086,8 +1031,8 @@ def auto_approve(self, action: str) -> str: .build() ) .tool(LLMMockTool(function=mock_llm)) - .publish_to(agent_output) - .publish_to(function_call_topic) + .publish_to(agent_output, when=_is_final_response) + .publish_to(function_call_topic, when=_has_tool_calls) .build() ) @@ -1200,28 +1145,9 @@ def mock_response(messages: List[Message]) -> List[Message]: ] agent_input = InputTopic(name="agent_input") - agent_output = OutputTopic( - name="agent_output", - condition=lambda event: ( - event.data[-1].content is not None and event.data[-1].tool_calls is None - ), - ) - weather_topic = Topic( - name="weather_call", - condition=lambda event: ( - event.data[-1].tool_calls is not None - and any( - tc.function.name == "weather" for tc in event.data[-1].tool_calls - ) - ), - ) - math_topic = Topic( - name="math_call", - condition=lambda event: ( - event.data[-1].tool_calls is not None - and any(tc.function.name == "math" for tc in event.data[-1].tool_calls) - ), - ) + agent_output = OutputTopic(name="agent_output") + weather_topic = Topic(name="weather_call") + math_topic = Topic(name="math_call") function_result_topic = Topic(name="function_result") router_node = ( @@ -1229,9 +1155,26 @@ def mock_response(messages: List[Message]) -> List[Message]: .name("RouterNode") .subscribe(SubscriptionBuilder().subscribed_to(agent_input).build()) .tool(LLMMockTool(function=mock_router)) - .publish_to(agent_output) - .publish_to(weather_topic) - .publish_to(math_topic) + .publish_to(agent_output, when=_is_final_response) + .publish_to( + weather_topic, + when=lambda event: ( + event.data[-1].tool_calls is not None + and any( + tc.function.name == "weather" + for tc in event.data[-1].tool_calls + ) + ), + ) + .publish_to( + math_topic, + when=lambda event: ( + event.data[-1].tool_calls is not None + and any( + tc.function.name == "math" for tc in event.data[-1].tool_calls + ) + ), + ) .build() ) @@ -1260,7 +1203,7 @@ def mock_response(messages: List[Message]) -> List[Message]: SubscriptionBuilder().subscribed_to(function_result_topic).build() ) .tool(LLMMockTool(function=mock_response)) - .publish_to(agent_output) + .publish_to(agent_output, when=_is_final_response) .build() ) @@ -1330,16 +1273,8 @@ def news(self) -> str: return "News: Markets up 2%" agent_input = InputTopic(name="agent_input") - agent_output = OutputTopic( - name="agent_output", - condition=lambda event: ( - event.data[-1].content is not None and event.data[-1].tool_calls is None - ), - ) - function_call_topic = Topic( - name="function_call", - condition=lambda event: event.data[-1].tool_calls is not None, - ) + agent_output = OutputTopic(name="agent_output") + function_call_topic = Topic(name="function_call") function_result_topic = Topic(name="function_result") llm_node = ( @@ -1353,8 +1288,8 @@ def news(self) -> str: .build() ) .tool(LLMMockTool(function=mock_llm_parallel)) - .publish_to(agent_output) - .publish_to(function_call_topic) + .publish_to(agent_output, when=_is_final_response) + .publish_to(function_call_topic, when=_has_tool_calls) .build() ) @@ -1431,16 +1366,8 @@ def failing_func(self) -> str: return "Error: Service unavailable" agent_input = InputTopic(name="agent_input") - agent_output = OutputTopic( - name="agent_output", - condition=lambda event: ( - event.data[-1].content is not None and event.data[-1].tool_calls is None - ), - ) - function_call_topic = Topic( - name="function_call", - condition=lambda event: event.data[-1].tool_calls is not None, - ) + agent_output = OutputTopic(name="agent_output") + function_call_topic = Topic(name="function_call") function_result_topic = Topic(name="function_result") llm_node = ( @@ -1454,8 +1381,8 @@ def failing_func(self) -> str: .build() ) .tool(LLMMockTool(function=mock_llm)) - .publish_to(agent_output) - .publish_to(function_call_topic) + .publish_to(agent_output, when=_is_final_response) + .publish_to(function_call_topic, when=_has_tool_calls) .build() ) @@ -1529,16 +1456,8 @@ def context_func(self) -> str: return "Context function executed" agent_input = InputTopic(name="agent_input") - agent_output = OutputTopic( - name="agent_output", - condition=lambda event: ( - event.data[-1].content is not None and event.data[-1].tool_calls is None - ), - ) - function_call_topic = Topic( - name="function_call", - condition=lambda event: event.data[-1].tool_calls is not None, - ) + agent_output = OutputTopic(name="agent_output") + function_call_topic = Topic(name="function_call") function_result_topic = Topic(name="function_result") llm_node = ( @@ -1552,8 +1471,8 @@ def context_func(self) -> str: .build() ) .tool(LLMMockTool(function=mock_llm_with_context)) - .publish_to(agent_output) - .publish_to(function_call_topic) + .publish_to(agent_output, when=_is_final_response) + .publish_to(function_call_topic, when=_has_tool_calls) .build() ) @@ -1637,24 +1556,16 @@ def raise_error(self) -> str: raise ValueError("Intentional test error in function call") agent_input = InputTopic(name="agent_input") - agent_output = OutputTopic( - name="agent_output", - condition=lambda event: ( - event.data[-1].content is not None and event.data[-1].tool_calls is None - ), - ) - function_call_topic = Topic( - name="function_call", - condition=lambda event: event.data[-1].tool_calls is not None, - ) + agent_output = OutputTopic(name="agent_output") + function_call_topic = Topic(name="function_call") llm_node = ( Node.builder() .name("LLMNode") .subscribe(SubscriptionBuilder().subscribed_to(agent_input).build()) .tool(LLMMockTool(function=mock_llm)) - .publish_to(agent_output) - .publish_to(function_call_topic) + .publish_to(agent_output, when=_is_final_response) + .publish_to(function_call_topic, when=_has_tool_calls) .build() ) @@ -1668,7 +1579,7 @@ def raise_error(self) -> str: .function(raise_error) .build() ) - .publish_to(agent_output) + .publish_to(agent_output, when=_is_final_response) .build() ) @@ -1708,17 +1619,17 @@ def failing_llm(messages: List[Message]) -> List[Message]: raise RuntimeError("LLM processing failed") agent_input = InputTopic(name="agent_input") - agent_output = OutputTopic( - name="agent_output", - condition=lambda event: event.data[-1].content is not None, - ) + agent_output = OutputTopic(name="agent_output") llm_node = ( Node.builder() .name("FailingLLMNode") .subscribe(SubscriptionBuilder().subscribed_to(agent_input).build()) .tool(LLMMockTool(function=failing_llm)) - .publish_to(agent_output) + .publish_to( + agent_output, + when=lambda event: event.data[-1].content is not None, + ) .build() ) @@ -1755,17 +1666,17 @@ def empty_content_llm(messages: List[Message]) -> List[Message]: return [Message(role="assistant", content="")] agent_input = InputTopic(name="agent_input") - agent_output = OutputTopic( - name="agent_output", - condition=lambda event: event.data[-1].content is not None, - ) + agent_output = OutputTopic(name="agent_output") llm_node = ( Node.builder() .name("EmptyLLMNode") .subscribe(SubscriptionBuilder().subscribed_to(agent_input).build()) .tool(LLMMockTool(function=empty_content_llm)) - .publish_to(agent_output) + .publish_to( + agent_output, + when=lambda event: event.data[-1].content is not None, + ) .build() ) @@ -1802,17 +1713,17 @@ def single_message_llm(messages: List[Message]) -> Message: return Message(role="assistant", content="Single message response") agent_input = InputTopic(name="agent_input") - agent_output = OutputTopic( - name="agent_output", - condition=lambda event: event.data[-1].content is not None, - ) + agent_output = OutputTopic(name="agent_output") llm_node = ( Node.builder() .name("SingleMsgLLMNode") .subscribe(SubscriptionBuilder().subscribed_to(agent_input).build()) .tool(LLMMockTool(function=single_message_llm)) - .publish_to(agent_output) + .publish_to( + agent_output, + when=lambda event: event.data[-1].content is not None, + ) .build() ) @@ -1865,24 +1776,16 @@ def some_func(self) -> str: return "Should not reach here" agent_input = InputTopic(name="agent_input") - agent_output = OutputTopic( - name="agent_output", - condition=lambda event: ( - event.data[-1].content is not None and event.data[-1].tool_calls is None - ), - ) - function_call_topic = Topic( - name="function_call", - condition=lambda event: event.data[-1].tool_calls is not None, - ) + agent_output = OutputTopic(name="agent_output") + function_call_topic = Topic(name="function_call") llm_node = ( Node.builder() .name("LLMNode") .subscribe(SubscriptionBuilder().subscribed_to(agent_input).build()) .tool(LLMMockTool(function=mock_llm)) - .publish_to(agent_output) - .publish_to(function_call_topic) + .publish_to(agent_output, when=_is_final_response) + .publish_to(function_call_topic, when=_has_tool_calls) .build() ) @@ -1893,7 +1796,7 @@ def some_func(self) -> str: .tool( FunctionCallTool.builder().name("SomeFunc").function(some_func).build() ) - .publish_to(agent_output) + .publish_to(agent_output, when=_is_final_response) .build() ) @@ -1954,16 +1857,8 @@ def existing_func(self) -> str: return "This is an existing function" agent_input = InputTopic(name="agent_input") - agent_output = OutputTopic( - name="agent_output", - condition=lambda event: ( - event.data[-1].content is not None and event.data[-1].tool_calls is None - ), - ) - function_call_topic = Topic( - name="function_call", - condition=lambda event: event.data[-1].tool_calls is not None, - ) + agent_output = OutputTopic(name="agent_output") + function_call_topic = Topic(name="function_call") function_result_topic = Topic(name="function_result") llm_node = ( @@ -1977,8 +1872,8 @@ def existing_func(self) -> str: .build() ) .tool(LLMMockTool(function=mock_llm)) - .publish_to(agent_output) - .publish_to(function_call_topic) + .publish_to(agent_output, when=_is_final_response) + .publish_to(function_call_topic, when=_has_tool_calls) .build() ) @@ -2049,16 +1944,8 @@ def fail_func(self) -> str: raise Exception("Node failure - workflow should stop") agent_input = InputTopic(name="agent_input") - agent_output = OutputTopic( - name="agent_output", - condition=lambda event: ( - event.data[-1].content is not None and event.data[-1].tool_calls is None - ), - ) - function_call_topic = Topic( - name="function_call", - condition=lambda event: event.data[-1].tool_calls is not None, - ) + agent_output = OutputTopic(name="agent_output") + function_call_topic = Topic(name="function_call") function_result_topic = Topic(name="function_result") llm_node = ( @@ -2072,8 +1959,8 @@ def fail_func(self) -> str: .build() ) .tool(LLMMockTool(function=mock_llm)) - .publish_to(agent_output) - .publish_to(function_call_topic) + .publish_to(agent_output, when=_is_final_response) + .publish_to(function_call_topic, when=_has_tool_calls) .build() ) @@ -2170,16 +2057,8 @@ def func_b(self) -> str: return "Result B" agent_input = InputTopic(name="agent_input") - agent_output = OutputTopic( - name="agent_output", - condition=lambda event: ( - event.data[-1].content is not None and event.data[-1].tool_calls is None - ), - ) - function_call_topic = Topic( - name="function_call", - condition=lambda event: event.data[-1].tool_calls is not None, - ) + agent_output = OutputTopic(name="agent_output") + function_call_topic = Topic(name="function_call") function_result_topic = Topic(name="function_result") llm_node = ( @@ -2193,8 +2072,8 @@ def func_b(self) -> str: .build() ) .tool(LLMMockTool(function=mock_llm)) - .publish_to(agent_output) - .publish_to(function_call_topic) + .publish_to(agent_output, when=_is_final_response) + .publish_to(function_call_topic, when=_has_tool_calls) .build() ) @@ -2280,16 +2159,8 @@ def get_complex_data(self) -> str: ) agent_input = InputTopic(name="agent_input") - agent_output = OutputTopic( - name="agent_output", - condition=lambda event: ( - event.data[-1].content is not None and event.data[-1].tool_calls is None - ), - ) - function_call_topic = Topic( - name="function_call", - condition=lambda event: event.data[-1].tool_calls is not None, - ) + agent_output = OutputTopic(name="agent_output") + function_call_topic = Topic(name="function_call") function_result_topic = Topic(name="function_result") llm_node = ( @@ -2303,8 +2174,8 @@ def get_complex_data(self) -> str: .build() ) .tool(LLMMockTool(function=mock_llm)) - .publish_to(agent_output) - .publish_to(function_call_topic) + .publish_to(agent_output, when=_is_final_response) + .publish_to(function_call_topic, when=_has_tool_calls) .build() ) @@ -2378,16 +2249,8 @@ def process_text(self, text: str, query: str) -> str: return f"Processed: {text}" agent_input = InputTopic(name="agent_input") - agent_output = OutputTopic( - name="agent_output", - condition=lambda event: ( - event.data[-1].content is not None and event.data[-1].tool_calls is None - ), - ) - function_call_topic = Topic( - name="function_call", - condition=lambda event: event.data[-1].tool_calls is not None, - ) + agent_output = OutputTopic(name="agent_output") + function_call_topic = Topic(name="function_call") function_result_topic = Topic(name="function_result") llm_node = ( @@ -2401,8 +2264,8 @@ def process_text(self, text: str, query: str) -> str: .build() ) .tool(LLMMockTool(function=mock_llm)) - .publish_to(agent_output) - .publish_to(function_call_topic) + .publish_to(agent_output, when=_is_final_response) + .publish_to(function_call_topic, when=_has_tool_calls) .build() ) @@ -2498,16 +2361,8 @@ def search(self, query: str) -> str: # Create topics agent_input = InputTopic(name="agent_input") - agent_output = OutputTopic( - name="agent_output", - condition=lambda event: ( - event.data[-1].content is not None and event.data[-1].tool_calls is None - ), - ) - function_call_topic = Topic( - name="function_call", - condition=lambda event: event.data[-1].tool_calls is not None, - ) + agent_output = OutputTopic(name="agent_output") + function_call_topic = Topic(name="function_call") function_result_topic = Topic(name="function_result") # Create LLM node @@ -2522,8 +2377,8 @@ def search(self, query: str) -> str: .build() ) .tool(LLMMockTool(function=mock_llm)) - .publish_to(agent_output) - .publish_to(function_call_topic) + .publish_to(agent_output, when=_is_final_response) + .publish_to(function_call_topic, when=_has_tool_calls) .build() ) diff --git a/tests/assistants/test_publisher_routing_integration.py b/tests/assistants/test_publisher_routing_integration.py new file mode 100644 index 00000000..69e4044f --- /dev/null +++ b/tests/assistants/test_publisher_routing_integration.py @@ -0,0 +1,210 @@ +"""End-to-end integration tests for the node's publisher router. + +These run real ``EventDrivenWorkflow``s (via ``Assistant.invoke``) and cover +publisher-router behaviour: content routing to different consumers, fan-out to +several consumers, and a no-matching-route drop. +""" + +import uuid +from typing import Callable +from unittest.mock import patch + +import pytest +from openinference.semconv.trace import OpenInferenceSpanKindValues + +from grafi.assistants.assistant import Assistant +from grafi.common.events.topic_events.publish_to_topic_event import PublishToTopicEvent +from grafi.common.models.invoke_context import InvokeContext +from grafi.common.models.message import Message +from grafi.common.models.message import Messages +from grafi.common.models.message import MsgsAGen +from grafi.nodes.node import Node +from grafi.tools.command import Command +from grafi.tools.command import use_command +from grafi.tools.tool import Tool +from grafi.topics.expressions.subscription_builder import SubscriptionBuilder +from grafi.topics.topic_impl.input_topic import InputTopic +from grafi.topics.topic_impl.output_topic import OutputTopic +from grafi.topics.topic_impl.topic import Topic +from grafi.workflows.impl.event_driven_workflow import EventDrivenWorkflow + + +@use_command(Command) +class MockTool(Tool): + """A tool that yields ``fn(input_data)`` and counts invocations.""" + + name: str = "MockTool" + type: str = "MockTool" + oi_span_type: OpenInferenceSpanKindValues = OpenInferenceSpanKindValues.TOOL + fn: Callable[[Messages], Messages] + invoked: int = 0 + + async def invoke( + self, invoke_context: InvokeContext, input_data: Messages + ) -> MsgsAGen: + self.invoked += 1 + yield self.fn(input_data) + + def to_dict(self): + return {"name": self.name, "type": self.type} + + +def _ctx() -> InvokeContext: + return InvokeContext( + conversation_id=uuid.uuid4().hex, + invoke_id=uuid.uuid4().hex, + assistant_request_id=uuid.uuid4().hex, + ) + + +def _assistant(workflow: EventDrivenWorkflow, name: str = "A") -> Assistant: + with patch.object(Assistant, "_construct_workflow"): + return Assistant(name=name, workflow=workflow) + + +async def _run(assistant: Assistant, text: str): + outputs = [] + async for event in assistant.invoke( + PublishToTopicEvent( + invoke_context=_ctx(), data=[Message(role="user", content=text)] + ) + ): + outputs.append(event) + return outputs + + +def _contains(token: str) -> Callable: + return lambda event: token in (event.data[-1].content or "") + + +class TestPublisherRouting: + def _content_split_assistant(self) -> Assistant: + agent_input = InputTopic(name="agent_input") + agent_output = OutputTopic(name="agent_output") + to_x = Topic(name="to_x") + to_y = Topic(name="to_y") + + def route(input_data: Messages) -> Messages: + content = input_data[-1].content or "" + tag = "X" if "x" in content.lower() else "Y" + return [Message(role="assistant", content=f"payload-{tag}")] + + router = ( + Node.builder() + .name("Router") + .subscribe(SubscriptionBuilder().subscribed_to(agent_input).build()) + .tool(MockTool(fn=route)) + .publish_to(to_x, when=_contains("payload-X")) + .publish_to(to_y, when=_contains("payload-Y")) + .build() + ) + node_x = ( + Node.builder() + .name("HandlerX") + .subscribe(SubscriptionBuilder().subscribed_to(to_x).build()) + .tool( + MockTool( + fn=lambda _: [Message(role="assistant", content="handled by X")] + ) + ) + .publish_to(agent_output) + .build() + ) + node_y = ( + Node.builder() + .name("HandlerY") + .subscribe(SubscriptionBuilder().subscribed_to(to_y).build()) + .tool( + MockTool( + fn=lambda _: [Message(role="assistant", content="handled by Y")] + ) + ) + .publish_to(agent_output) + .build() + ) + workflow = ( + EventDrivenWorkflow.builder() + .name("split") + .node(router) + .node(node_x) + .node(node_y) + .build() + ) + return _assistant(workflow, name="Split") + + @pytest.mark.asyncio + async def test_content_routes_to_matching_consumer(self): + assistant = self._content_split_assistant() + out_x = await _run(assistant, "please do x") + assert [e.data[0].content for e in out_x] == ["handled by X"] + + assistant = self._content_split_assistant() + out_y = await _run(assistant, "please do y") + assert [e.data[0].content for e in out_y] == ["handled by Y"] + + @pytest.mark.asyncio + async def test_fan_out_to_multiple_consumers(self): + agent_input = InputTopic(name="agent_input") + agent_output = OutputTopic(name="agent_output") + to_p = Topic(name="to_p") + to_q = Topic(name="to_q") + + # default (always) routes -> the one event fans out to BOTH topics + source = ( + Node.builder() + .name("Source") + .subscribe(SubscriptionBuilder().subscribed_to(agent_input).build()) + .tool( + MockTool(fn=lambda _: [Message(role="assistant", content="broadcast")]) + ) + .publish_to(to_p) + .publish_to(to_q) + .build() + ) + node_p = ( + Node.builder() + .name("P") + .subscribe(SubscriptionBuilder().subscribed_to(to_p).build()) + .tool( + MockTool(fn=lambda _: [Message(role="assistant", content="P got it")]) + ) + .publish_to(agent_output) + .build() + ) + node_q = ( + Node.builder() + .name("Q") + .subscribe(SubscriptionBuilder().subscribed_to(to_q).build()) + .tool( + MockTool(fn=lambda _: [Message(role="assistant", content="Q got it")]) + ) + .publish_to(agent_output) + .build() + ) + workflow = ( + EventDrivenWorkflow.builder() + .name("fanout") + .node(source) + .node(node_p) + .node(node_q) + .build() + ) + out = await _run(_assistant(workflow, name="Fanout"), "go") + assert sorted(e.data[0].content for e in out) == ["P got it", "Q got it"] + + @pytest.mark.asyncio + async def test_no_matching_route_drops_event(self): + agent_input = InputTopic(name="agent_input") + agent_output = OutputTopic(name="agent_output") + node = ( + Node.builder() + .name("Picky") + .subscribe(SubscriptionBuilder().subscribed_to(agent_input).build()) + .tool(MockTool(fn=lambda _: [Message(role="assistant", content="nope")])) + # only routes a "yes" payload; "nope" matches nothing -> dropped + .publish_to(agent_output, when=_contains("yes")) + .build() + ) + workflow = EventDrivenWorkflow.builder().name("drop").node(node).build() + out = await _run(_assistant(workflow, name="Drop"), "anything") + assert out == [] # event matched no route -> no output, no hang diff --git a/tests/integration/test_serialization_roundtrip.py b/tests/integration/test_serialization_roundtrip.py index 28821c29..21de07e0 100644 --- a/tests/integration/test_serialization_roundtrip.py +++ b/tests/integration/test_serialization_roundtrip.py @@ -31,12 +31,10 @@ from grafi.tools.function_calls.function_call_tool import FunctionCallTool from grafi.tools.functions.function_tool import FunctionTool from grafi.tools.tool_factory import ToolFactory +from grafi.topics.conditions import always_true from grafi.topics.expressions.subscription_builder import SubscriptionBuilder -from grafi.topics.topic_base import always_true -from grafi.topics.topic_factory import TopicFactory from grafi.topics.topic_impl.input_topic import InputTopic from grafi.topics.topic_impl.output_topic import OutputTopic -from grafi.topics.topic_impl.topic import Topic from grafi.workflows.impl.event_driven_workflow import EventDrivenWorkflow # --------------------------------------------------------------------------- # @@ -51,7 +49,7 @@ def shout(messages: Messages) -> str: def has_content(event: PublishToTopicEvent) -> bool: - """A topic condition -> serializes as a reference.""" + """A publisher-route predicate -> serializes as a reference.""" return event.data[-1].content is not None @@ -65,7 +63,7 @@ def __call__(self, messages: Messages) -> str: class MinContentLength(CallableComponent): - """A topic condition as a component -> serializes as config.""" + """A publisher-route predicate as a component -> serializes as config.""" minimum: int @@ -178,62 +176,65 @@ async def test_manifest_contains_no_pickle(): # --------------------------------------------------------------------------- # -# Topic condition forms (reference / component / default) +# Publisher route predicate forms (reference / component / default) +# +# Routing lives on the node's publisher router now (topics are pure queues), so +# the route ``when`` predicate is what serializes -- via the same pickle-free +# reference/component path conditions used to. # --------------------------------------------------------------------------- # -async def _condition_roundtrip(topic: Topic) -> Topic: - data = json.loads(json.dumps(topic.to_dict())) - return await TopicFactory.from_dict(data) +async def _route_when_roundtrip(when: object) -> tuple[Node, Node]: + agent_input = InputTopic(name="agent_input") + agent_output = OutputTopic(name="agent_output") + builder = ( + Node.builder() + .name("FunctionNode") + .subscribe(SubscriptionBuilder().subscribed_to(agent_input).build()) + .tool(FunctionTool.builder().function(shout).build()) + ) + if when is None: + builder.publish_to(agent_output) + else: + builder.publish_to(agent_output, when=when) + node = builder.build() + + data = json.loads(json.dumps(node.to_dict())) + topics = {"agent_input": agent_input, "agent_output": agent_output} + restored = await Node.from_dict(data, topics) + return node, restored + + +def _first_route(node: Node) -> dict: + return node.to_dict()["publisher_router"]["routes"][0] @pytest.mark.asyncio -async def test_default_condition_is_a_reference(): - topic = Topic(name="t") - assert topic.to_dict()["condition"] == { +async def test_default_route_predicate_is_a_reference(): + node, restored = await _route_when_roundtrip(None) + assert _first_route(node)["when"] == { "ref": f"{always_true.__module__}:always_true" } - restored = await _condition_roundtrip(topic) - assert restored.condition(_event("x")) is True + assert restored.publisher_router.routes[0].when is always_true @pytest.mark.asyncio -async def test_module_function_condition_roundtrips(): - topic = Topic(name="t", condition=has_content) - assert topic.to_dict()["condition"] == {"ref": f"{__name__}:has_content"} - restored = await _condition_roundtrip(topic) - assert restored.condition is has_content - assert restored.condition(_event("x")) is True +async def test_module_function_route_predicate_roundtrips(): + node, restored = await _route_when_roundtrip(has_content) + assert _first_route(node)["when"] == {"ref": f"{__name__}:has_content"} + assert restored.publisher_router.routes[0].when is has_content @pytest.mark.asyncio -async def test_component_condition_roundtrips(): - topic = Topic(name="t", condition=MinContentLength(minimum=3)) - assert topic.to_dict()["condition"] == { +async def test_component_route_predicate_roundtrips(): + node, restored = await _route_when_roundtrip(MinContentLength(minimum=3)) + assert _first_route(node)["when"] == { "component": f"{__name__}:MinContentLength", "config": {"minimum": 3}, } - restored = await _condition_roundtrip(topic) - assert restored.condition(_event("yes")) is True - assert restored.condition(_event("no")) is False - - -# -- default condition (omitted / empty falls back to always_true) ---------- - - -@pytest.mark.asyncio -async def test_condition_omitted_defaults_to_always_true(): - topic = await TopicFactory.from_dict({"name": "t", "type": "Topic"}) - assert topic.condition is always_true - assert topic.condition(_event("anything")) is True - - -@pytest.mark.asyncio -async def test_condition_empty_string_defaults_to_always_true(): - topic = await TopicFactory.from_dict( - {"name": "t", "type": "Topic", "condition": ""} - ) - assert topic.condition is always_true + when = restored.publisher_router.routes[0].when + assert when(_event("yes")) is True + assert when(_event("no")) is False # --------------------------------------------------------------------------- # diff --git a/tests/nodes/test_node.py b/tests/nodes/test_node.py index e3c4699a..5a0643b0 100644 --- a/tests/nodes/test_node.py +++ b/tests/nodes/test_node.py @@ -13,6 +13,8 @@ from grafi.common.models.message import Messages from grafi.nodes.node import Node from grafi.nodes.node_base import NodeBaseBuilder +from grafi.nodes.node_base import PublisherRouter +from grafi.nodes.node_base import PublishRoute from grafi.tools.command import Command from grafi.tools.tool import Tool from grafi.topics.expressions.topic_expression import TopicExpr @@ -318,7 +320,7 @@ def test_to_dict_basic_node(self, basic_node: Node): assert "node_id" in result assert result["subscribed_expressions"] == [] - assert result["publish_to"] == [] + assert result["publisher_router"]["routes"] == [] assert result["command"] is None def test_to_dict_node_with_tool(self, node_with_tool: Node): @@ -327,7 +329,7 @@ def test_to_dict_node_with_tool(self, node_with_tool: Node): assert "node_id" in result assert result["subscribed_expressions"] == [] - assert result["publish_to"] == [] + assert result["publisher_router"]["routes"] == [] assert result["command"] is not None # Command should have to_dict method def test_to_dict_node_with_subscriptions(self, node_with_subscriptions: Node): @@ -336,17 +338,20 @@ def test_to_dict_node_with_subscriptions(self, node_with_subscriptions: Node): assert "node_id" in result assert len(result["subscribed_expressions"]) == 1 - assert result["publish_to"] == [] + assert result["publisher_router"]["routes"] == [] def test_to_dict_node_with_publish_to(self, mock_topic: MockTopic): """Test to_dict for a Node with publish_to topics.""" - node = Node(name="test_node", publish_to=[mock_topic]) + node = Node( + name="test_node", + publisher_router=PublisherRouter(routes=[PublishRoute(topic=mock_topic)]), + ) result = node.to_dict() assert "node_id" in result assert result["subscribed_expressions"] == [] - assert len(result["publish_to"]) == 1 - assert result["publish_to"][0] == "test_topic" + assert len(result["publisher_router"]["routes"]) == 1 + assert result["publisher_router"]["routes"][0]["topic"] == "test_topic" def test_to_dict_complete_node(self, mock_tool: MockTool, mock_topic: MockTopic): """Test to_dict for a Node with all features.""" @@ -356,15 +361,15 @@ def test_to_dict_complete_node(self, mock_tool: MockTool, mock_topic: MockTopic) name="complete_node", tool=mock_tool, subscribed_expressions=[topic_expr], - publish_to=[mock_topic], + publisher_router=PublisherRouter(routes=[PublishRoute(topic=mock_topic)]), ) result = node.to_dict() assert "node_id" in result assert len(result["subscribed_expressions"]) == 1 - assert len(result["publish_to"]) == 1 - assert result["publish_to"][0] == "test_topic" + assert len(result["publisher_router"]["routes"]) == 1 + assert result["publisher_router"]["routes"][0]["topic"] == "test_topic" assert result["command"] is not None # Test Property Access @@ -405,7 +410,7 @@ async def test_full_async_workflow_simulation( name="async_workflow_node", tool=mock_tool, subscribed_expressions=[topic_expr], - publish_to=[mock_topic], + publisher_router=PublisherRouter(routes=[PublishRoute(topic=mock_topic)]), ) sample_events = [ @@ -439,7 +444,7 @@ async def test_node_with_empty_subscribed_expressions_list(self): def test_node_with_empty_publish_to_list(self): """Test Node with empty publish_to list.""" - node = Node(name="test_node", publish_to=[]) + node = Node(name="test_node", publisher_router=PublisherRouter()) assert len(node.publish_to) == 0 def test_node_id_generation(self): @@ -487,7 +492,7 @@ def test_node_serialization_compatibility( name="serialization_test_node", tool=mock_tool, subscribed_expressions=[topic_expr], - publish_to=[mock_topic], + publisher_router=PublisherRouter(routes=[PublishRoute(topic=mock_topic)]), ) node_dict = node.to_dict() diff --git a/tests/nodes/test_node_base.py b/tests/nodes/test_node_base.py index 6d70eaaf..e8969ad7 100644 --- a/tests/nodes/test_node_base.py +++ b/tests/nodes/test_node_base.py @@ -13,6 +13,8 @@ from grafi.common.models.message import Messages from grafi.nodes.node_base import NodeBase from grafi.nodes.node_base import NodeBaseBuilder +from grafi.nodes.node_base import PublisherRouter +from grafi.nodes.node_base import PublishRoute from grafi.tools.command import Command from grafi.tools.tool import Tool from grafi.topics.expressions.topic_expression import SubExpr @@ -159,7 +161,7 @@ def test_node_base_creation_with_custom_values( tool=mock_tool, oi_span_type=OpenInferenceSpanKindValues.AGENT, subscribed_expressions=[topic_expr], - publish_to=[mock_topic], + publisher_router=PublisherRouter(routes=[PublishRoute(topic=mock_topic)]), ) assert node.node_id == custom_node_id @@ -285,7 +287,7 @@ def test_publish_to_field_validation(self, mock_topic: MockTopic): """Test that publish_to field accepts valid TopicBase objects.""" node = ConcreteNodeBase( name="test_node", - publish_to=[mock_topic], + publisher_router=PublisherRouter(routes=[PublishRoute(topic=mock_topic)]), ) assert len(node.publish_to) == 1 @@ -326,7 +328,12 @@ def test_invalid_subscribed_expressions_type_raises_error(self): def test_invalid_publish_to_type_raises_error(self): """Test that invalid publish_to type raises validation error.""" with pytest.raises(Exception): # Pydantic validation error - ConcreteNodeBase(name="test_node", publish_to=["invalid_topic"]) + ConcreteNodeBase( + name="test_node", + publisher_router=PublisherRouter( + routes=[PublishRoute(topic="invalid_topic")] + ), + ) def test_invalid_oi_span_type_raises_error(self): """Test that invalid oi_span_type raises validation error.""" @@ -376,7 +383,7 @@ def test_list_fields_are_mutable(self, mock_topic: MockTopic): # Add items topic_expr = TopicExpr(topic=mock_topic) node.subscribed_expressions.append(topic_expr) - node.publish_to.append(mock_topic) + node.publisher_router.routes.append(PublishRoute(topic=mock_topic)) # Verify changes assert len(node.subscribed_expressions) == 1 @@ -403,7 +410,7 @@ def test_model_dump(self, mock_tool: MockTool, mock_topic: MockTopic): name="test_node", tool=mock_tool, subscribed_expressions=[topic_expr], - publish_to=[mock_topic], + publisher_router=PublisherRouter(routes=[PublishRoute(topic=mock_topic)]), ) dumped = node.model_dump() @@ -413,7 +420,7 @@ def test_model_dump(self, mock_tool: MockTool, mock_topic: MockTopic): assert "node_id" in dumped assert "tool" in dumped assert "subscribed_expressions" in dumped - assert "publish_to" in dumped + assert "publisher_router" in dumped def test_model_equality(self): """Test that model equality works correctly.""" @@ -480,7 +487,9 @@ def test_complex_node_configuration(self, mock_tool: MockTool): tool=mock_tool, oi_span_type=OpenInferenceSpanKindValues.AGENT, subscribed_expressions=[expr1, expr2], - publish_to=[topic2, topic3], + publisher_router=PublisherRouter( + routes=[PublishRoute(topic=topic2), PublishRoute(topic=topic3)] + ), ) # Verify all configurations @@ -524,7 +533,9 @@ def test_large_lists_for_collection_fields(self, mock_topic: MockTopic): node = ConcreteNodeBase( name="test_node", subscribed_expressions=expressions, - publish_to=topics, + publisher_router=PublisherRouter( + routes=[PublishRoute(topic=topic) for topic in topics] + ), ) assert len(node.subscribed_expressions) == 50 @@ -633,9 +644,10 @@ def test_builder_publish_to_method( result = builder.publish_to(mock_topic) assert result is builder - assert "publish_to" in builder.kwargs - assert len(builder.kwargs["publish_to"]) == 1 - assert builder.kwargs["publish_to"][0] is mock_topic + assert "publisher_router" in builder.kwargs + routes = builder.kwargs["publisher_router"].routes + assert len(routes) == 1 + assert routes[0].topic is mock_topic def test_builder_publish_to_method_multiple_calls(self, builder: NodeBaseBuilder): """Test multiple calls to publish_to() method.""" @@ -644,9 +656,10 @@ def test_builder_publish_to_method_multiple_calls(self, builder: NodeBaseBuilder builder.publish_to(topic1).publish_to(topic2) - assert len(builder.kwargs["publish_to"]) == 2 - assert builder.kwargs["publish_to"][0] is topic1 - assert builder.kwargs["publish_to"][1] is topic2 + routes = builder.kwargs["publisher_router"].routes + assert len(routes) == 2 + assert routes[0].topic is topic1 + assert routes[1].topic is topic2 # Test Builder Chaining def test_builder_method_chaining(self, mock_tool: MockTool, mock_topic: MockTopic): @@ -668,7 +681,7 @@ def test_builder_method_chaining(self, mock_tool: MockTool, mock_topic: MockTopi assert builder.kwargs["oi_span_type"] == OpenInferenceSpanKindValues.AGENT assert builder.kwargs["tool"] is mock_tool assert len(builder.kwargs["subscribed_expressions"]) == 1 - assert len(builder.kwargs["publish_to"]) == 1 + assert len(builder.kwargs["publisher_router"].routes) == 1 # Test Builder Build Method def test_builder_build_basic(self, builder: NodeBaseBuilder): @@ -762,13 +775,13 @@ def test_builder_subscribe_creates_list_if_not_exists( def test_builder_publish_to_creates_list_if_not_exists( self, builder: NodeBaseBuilder, mock_topic: MockTopic ): - """Test that publish_to() creates publish_to list if it doesn't exist.""" - assert "publish_to" not in builder.kwargs + """Test that publish_to() creates the publisher router if absent.""" + assert "publisher_router" not in builder.kwargs builder.publish_to(mock_topic) - assert "publish_to" in builder.kwargs - assert isinstance(builder.kwargs["publish_to"], list) + assert "publisher_router" in builder.kwargs + assert isinstance(builder.kwargs["publisher_router"].routes, list) def test_builder_subscribe_appends_to_existing_list(self, builder: NodeBaseBuilder): """Test that subscribe() appends to existing subscribed_expressions list.""" @@ -786,17 +799,17 @@ def test_builder_subscribe_appends_to_existing_list(self, builder: NodeBaseBuild def test_builder_publish_to_appends_to_existing_list( self, builder: NodeBaseBuilder ): - """Test that publish_to() appends to existing publish_to list.""" + """Test that publish_to() appends to the existing publisher router.""" topic1 = MockTopic(name="topic1") topic2 = MockTopic(name="topic2") # First topic builder.publish_to(topic1) - assert len(builder.kwargs["publish_to"]) == 1 + assert len(builder.kwargs["publisher_router"].routes) == 1 # Second topic builder.publish_to(topic2) - assert len(builder.kwargs["publish_to"]) == 2 + assert len(builder.kwargs["publisher_router"].routes) == 2 # Test Builder Return Types def test_builder_methods_return_self( diff --git a/tests/nodes/test_publisher_router.py b/tests/nodes/test_publisher_router.py new file mode 100644 index 00000000..c1dea647 --- /dev/null +++ b/tests/nodes/test_publisher_router.py @@ -0,0 +1,94 @@ +"""Unit tests for the node's publisher router (isolated, no engine).""" + +from openai.types.chat.chat_completion_message_tool_call import ( + ChatCompletionMessageToolCall, +) +from openai.types.chat.chat_completion_message_tool_call import Function + +from grafi.common.events.topic_events.publish_to_topic_event import PublishToTopicEvent +from grafi.common.models.invoke_context import InvokeContext +from grafi.common.models.message import Message +from grafi.nodes.node_base import PublisherRouter +from grafi.nodes.node_base import PublishRoute +from grafi.topics.conditions import has_text_response +from grafi.topics.conditions import has_tool_call +from grafi.topics.topic_base import TopicBase + + +def _ctx() -> InvokeContext: + return InvokeContext(conversation_id="c", invoke_id="i", assistant_request_id="r") + + +def _tool_call(name: str = "do_thing", args: str = '{"x": 1}') -> Message: + return Message( + role="assistant", + tool_calls=[ + ChatCompletionMessageToolCall( + id="call_1", + type="function", + function=Function(name=name, arguments=args), + ) + ], + ) + + +def _publish(data, name: str = "") -> PublishToTopicEvent: + return PublishToTopicEvent( + name=name, + publisher_name="node", + publisher_type="Node", + invoke_context=_ctx(), + offset=0, + data=data, + ) + + +class TestPublisherRouter: + def _llm_router(self): + fc = TopicBase(name="function_call") + out = TopicBase(name="agent_output") + return PublisherRouter( + routes=[ + PublishRoute(topic=fc, when=has_tool_call), + PublishRoute(topic=out, when=has_text_response), + ] + ) + + def test_content_routes_tool_call(self): + router = self._llm_router() + assert router.select(_publish([_tool_call()])) == ["function_call"] + + def test_content_routes_text(self): + router = self._llm_router() + event = _publish([Message(role="assistant", content="the answer")]) + assert router.select(event) == ["agent_output"] + + def test_plain_node_selects_every_topic(self): + a, b = TopicBase(name="a"), TopicBase(name="b") + router = PublisherRouter(routes=[PublishRoute(topic=a), PublishRoute(topic=b)]) + # default when=always_true -> deliver to all + assert router.select(_publish([Message(role="assistant", content="x")])) == [ + "a", + "b", + ] + + def test_predicate_error_is_treated_as_not_met(self): + # has_tool_call indexes data[-1]; empty data raises IndexError -> omitted + fc = TopicBase(name="function_call") + router = PublisherRouter(routes=[PublishRoute(topic=fc, when=has_tool_call)]) + assert router.select(_publish([])) == [] + + def test_publish_to_property_lists_all_topics(self): + router = self._llm_router() + assert [t.name for t in router.publish_to] == ["function_call", "agent_output"] + + def test_serialization_round_trip(self): + router = self._llm_router() + topics = {t.name: t for t in router.publish_to} + restored = PublisherRouter.from_dict(router.to_dict(), topics) + assert [r.topic.name for r in restored.routes] == [ + "function_call", + "agent_output", + ] + assert restored.routes[0].when is has_tool_call + assert restored.routes[1].when is has_text_response diff --git a/tests/topics/test_input_topic.py b/tests/topics/test_input_topic.py index b91ccc7a..03076621 100644 --- a/tests/topics/test_input_topic.py +++ b/tests/topics/test_input_topic.py @@ -178,11 +178,10 @@ async def test_offset_updates_correctly(topic: Topic, invoke_context: InvokeCont @pytest.mark.asyncio async def test_from_dict(): - """Test deserialization from a dictionary with a reference condition.""" + """Test deserialization from a dictionary (pure-queue topic).""" data = { "name": "test_input_topic", "type": "AgentInputTopic", - "condition": {"ref": "grafi.topics.topic_base:always_true"}, } topic = await InputTopic.from_dict(data) @@ -190,8 +189,6 @@ async def test_from_dict(): assert isinstance(topic, InputTopic) assert topic.name == "test_input_topic" assert topic.type == TopicType.AGENT_INPUT_TOPIC_TYPE - assert topic.condition is not None - assert topic.condition("anything") is True @pytest.mark.asyncio @@ -210,4 +207,3 @@ async def test_from_dict_roundtrip(): assert isinstance(restored, InputTopic) assert restored.name == original.name assert restored.type == original.type - assert restored.condition is not None diff --git a/tests/topics/test_output_topic.py b/tests/topics/test_output_topic.py index dc8f6b27..7e801ece 100644 --- a/tests/topics/test_output_topic.py +++ b/tests/topics/test_output_topic.py @@ -1,5 +1,3 @@ -from unittest.mock import Mock - import pytest import pytest_asyncio @@ -111,17 +109,14 @@ def test_output_topic_with_custom_name(self): assert topic.name == "custom_topic" @pytest.mark.asyncio - async def test_publish_data_with_condition_true( + async def test_publish_data_enqueues( self, output_topic: OutputTopic, sample_invoke_context, sample_messages, sample_consumed_events, ): - """Test publishing data when condition is met.""" - # Mock condition to return True - output_topic.condition = Mock(return_value=True) - + """A pure-queue topic stamps and enqueues every event (no filtering).""" event = await output_topic.publish_data( PublishToTopicEvent( invoke_context=sample_invoke_context, @@ -139,27 +134,3 @@ async def test_publish_data_with_condition_true( assert event.data == sample_messages assert event.consumed_event_ids == ["test_id_1", "test_id_2"] assert event.offset == 0 - - @pytest.mark.asyncio - async def test_publish_data_with_condition_false( - self, - output_topic: OutputTopic, - sample_invoke_context, - sample_messages, - sample_consumed_events, - ): - """Test publishing data when condition is not met.""" - # Mock condition to return False - output_topic.condition = Mock(return_value=False) - - event = await output_topic.publish_data( - PublishToTopicEvent( - invoke_context=sample_invoke_context, - publisher_name="test_publisher", - publisher_type="test_type", - data=sample_messages, - consumed_event_ids=[event.event_id for event in sample_consumed_events], - ) - ) - - assert event is None diff --git a/tests/topics/test_topic_base.py b/tests/topics/test_topic_base.py index 3c8da1f5..d2103116 100644 --- a/tests/topics/test_topic_base.py +++ b/tests/topics/test_topic_base.py @@ -2,7 +2,6 @@ import pytest -from grafi.common.callable_ref import CallableSerializationError from grafi.common.events.topic_events.consume_from_topic_event import ( ConsumeFromTopicEvent, ) @@ -10,8 +9,6 @@ from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.message import Message from grafi.topics.topic_base import TopicBase -from grafi.topics.topic_base import always_true -from grafi.topics.topic_base import deserialize_condition class MockTopic(TopicBase): @@ -114,45 +111,27 @@ async def test_restore_topic_consume_event_does_not_skip_next( assert [event.offset for event in remaining] == [1, 2] -# Module-level condition used to exercise reference-based serialization. -def module_level_condition(event: PublishToTopicEvent) -> bool: - """A named, importable condition -> serializes as an import reference.""" - return True +def test_to_dict_has_no_condition(): + """Topics are pure queues -- the manifest carries only name and type.""" + topic = MockTopic(name="plain_topic") + assert topic.to_dict() == {"name": "plain_topic", "type": topic.type.value} -def test_default_condition_serializes_as_reference(): - """The default condition is a named function, not an inline lambda, so it - serializes as a tiny import reference rather than inline code.""" - topic = MockTopic(name="default_condition_topic") - data = topic.to_dict() - assert data["condition"] == {"ref": f"{always_true.__module__}:always_true"} - - -def test_named_condition_roundtrips_via_reference(): - topic = MockTopic(name="named_topic", condition=module_level_condition) - data = topic.to_dict() - assert data["condition"] == { - "ref": f"{module_level_condition.__module__}:module_level_condition" - } - assert deserialize_condition(data) is module_level_condition - - -def test_lambda_condition_cannot_be_serialized(): - # A lambda is neither an importable reference nor a component, so it cannot - # be serialized without pickle -- use a named predicate or a component. - topic = MockTopic(name="lambda_topic", condition=lambda _: False) - with pytest.raises(CallableSerializationError, match="without pickle"): - topic.to_dict() - - -def test_missing_condition_defaults_to_always_true(): - """A manifest without a condition restores to the always-publish default.""" - assert deserialize_condition({"name": "no_condition"}) is always_true - +@pytest.mark.asyncio +async def test_publish_data_always_enqueues( + topic: TopicBase, invoke_context: InvokeContext +): + """publish_data stamps name/type and enqueues -- routing lives on the node.""" + event = PublishToTopicEvent( + publisher_name="p", + publisher_type="t", + invoke_context=invoke_context, + consumed_event_ids=[], + data=[Message(role="assistant", content="hi")], + ) + published = await topic.publish_data(event) + assert published is not None + assert published.name == "test_topic" -def test_legacy_pickle_condition_dict_is_rejected(): - """Legacy {"base64": ...} pickle conditions are no longer supported.""" - with pytest.raises(CallableSerializationError, match="pickle payload"): - deserialize_condition( - {"name": "legacy", "condition": {"base64": "x", "code": "lambda _: True"}} - ) + consumed = await topic.consume("c") + assert len(consumed) == 1 diff --git a/tests/topics/test_topic_base_cache_integration.py b/tests/topics/test_topic_base_cache_integration.py index 256cccf8..8ada1a54 100644 --- a/tests/topics/test_topic_base_cache_integration.py +++ b/tests/topics/test_topic_base_cache_integration.py @@ -101,14 +101,10 @@ async def test_multiple_consumers( assert not await topic.can_consume("consumer2") @pytest.mark.asyncio - async def test_conditional_publishing(self, invoke_context): - # Create topic with condition - topic = Topic( - name="conditional_topic", - condition=lambda event: any(m.role == "user" for m in event.data), - ) + async def test_publish_data_enqueues_all(self, invoke_context): + # Topics are pure queues: every event is enqueued (routing is on the node) + topic = Topic(name="plain_topic") - # Message that meets condition user_messages = [Message(role="user", content="Hello")] event1 = await topic.publish_data( PublishToTopicEvent( @@ -121,7 +117,6 @@ async def test_conditional_publishing(self, invoke_context): ) assert event1 is not None - # Message that doesn't meet condition assistant_messages = [Message(role="assistant", content="Hi")] event2 = await topic.publish_data( PublishToTopicEvent( @@ -132,9 +127,9 @@ async def test_conditional_publishing(self, invoke_context): consumed_event_ids=[], ) ) - assert event2 is None + assert event2 is not None - # Only one event should be in cache + # Both events are enqueued -- no topic-level filtering. @pytest.mark.asyncio async def test_reset_functionality( @@ -391,7 +386,7 @@ def test_topic_serialization(self, topic): topic_dict = topic.to_dict() assert topic_dict["name"] == "test_topic" assert topic_dict["type"] == "Topic" - assert "condition" in topic_dict + assert "condition" not in topic_dict @pytest.mark.asyncio async def test_async_reset(self, topic: Topic, invoke_context, sample_messages): diff --git a/tests/topics/test_topic_factory.py b/tests/topics/test_topic_factory.py index 4fba3776..36282aaa 100644 --- a/tests/topics/test_topic_factory.py +++ b/tests/topics/test_topic_factory.py @@ -19,7 +19,6 @@ async def test_topic_factory_default_topic(): data = { "name": "test_topic", "type": "Topic", - "condition": {"ref": "grafi.topics.topic_base:always_true"}, } topic = await TopicFactory.from_dict(data) @@ -35,7 +34,6 @@ async def test_topic_factory_input_topic(): data = { "name": "input_topic", "type": "AgentInputTopic", - "condition": {"ref": "grafi.topics.topic_base:always_true"}, } topic = await TopicFactory.from_dict(data) @@ -51,7 +49,6 @@ async def test_topic_factory_output_topic(): data = { "name": "output_topic", "type": "AgentOutputTopic", - "condition": {"ref": "grafi.topics.topic_base:always_true"}, } topic = await TopicFactory.from_dict(data) @@ -67,7 +64,6 @@ async def test_topic_factory_in_workflow_input_topic(): data = { "name": "in_workflow_input", "type": "InWorkflowInputTopic", - "condition": {"ref": "grafi.topics.topic_base:always_true"}, } topic = await TopicFactory.from_dict(data) @@ -83,7 +79,6 @@ async def test_topic_factory_in_workflow_output_topic(): data = { "name": "in_workflow_output", "type": "InWorkflowOutputTopic", - "condition": {"ref": "grafi.topics.topic_base:always_true"}, "paired_in_workflow_input_topic_names": ["approval", "rejection"], } @@ -101,7 +96,6 @@ async def test_topic_factory_with_topic_type_enum(): data = { "name": "test_topic", "type": TopicType.DEFAULT_TOPIC_TYPE, - "condition": {"ref": "grafi.topics.topic_base:always_true"}, } topic = await TopicFactory.from_dict(data) @@ -116,7 +110,6 @@ async def test_topic_factory_unknown_type_string(): data = { "name": "test_topic", "type": "UnknownTopicType", - "condition": {"ref": "grafi.topics.topic_base:always_true"}, } with pytest.raises(ValueError, match="Unknown topic type string"): @@ -128,7 +121,6 @@ async def test_topic_factory_missing_type(): """Test factory raises KeyError when type is missing.""" data = { "name": "test_topic", - "condition": {"ref": "grafi.topics.topic_base:always_true"}, } with pytest.raises(KeyError, match="Missing required key 'type'"): @@ -141,7 +133,6 @@ async def test_topic_factory_invalid_type(): data = { "name": "test_topic", "type": 12345, # Invalid type - "condition": {"ref": "grafi.topics.topic_base:always_true"}, } with pytest.raises(ValueError, match="Invalid topic type"): diff --git a/tests/workflow/test_event_driven_workflow.py b/tests/workflow/test_event_driven_workflow.py index 89bb1fb7..d8442dfa 100644 --- a/tests/workflow/test_event_driven_workflow.py +++ b/tests/workflow/test_event_driven_workflow.py @@ -9,6 +9,8 @@ from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.message import Message from grafi.nodes.node import Node +from grafi.nodes.node_base import PublisherRouter +from grafi.nodes.node_base import PublishRoute from grafi.tools.tool import Tool from grafi.topics.expressions.topic_expression import TopicExpr from grafi.topics.topic_base import TopicType @@ -53,7 +55,7 @@ def test_builder_creates_event_driven_workflow(self): name="test_node", tool=mock_tool, subscribed_expressions=[TopicExpr(topic=input_topic)], - publish_to=[output_topic], + publisher_router=PublisherRouter(routes=[PublishRoute(topic=output_topic)]), ) workflow = EventDrivenWorkflow.builder().node(node).build() @@ -82,7 +84,7 @@ def test_initialization_with_nodes_and_topics(self): name="test_node", tool=mock_tool, subscribed_expressions=[TopicExpr(topic=input_topic)], - publish_to=[output_topic], + publisher_router=PublisherRouter(routes=[PublishRoute(topic=output_topic)]), ) workflow = EventDrivenWorkflow(nodes={"test_node": node}) @@ -103,7 +105,7 @@ def test_initialization_missing_input_topic_raises_error(self): name="test_node", tool=mock_tool, subscribed_expressions=[TopicExpr(topic=missing_topic)], - publish_to=[output_topic], + publisher_router=PublisherRouter(routes=[PublishRoute(topic=output_topic)]), ) with pytest.raises( @@ -120,7 +122,7 @@ def test_initialization_missing_output_topic_raises_error(self): name="test_node", tool=mock_tool, subscribed_expressions=[TopicExpr(topic=input_topic)], - publish_to=[], + publisher_router=PublisherRouter(), ) with pytest.raises( @@ -142,7 +144,7 @@ def workflow_with_topics(self): name="test_node", tool=mock_tool, subscribed_expressions=[TopicExpr(topic=input_topic)], - publish_to=[output_topic], + publisher_router=PublisherRouter(routes=[PublishRoute(topic=output_topic)]), ) return EventDrivenWorkflow(nodes={"test_node": node}) @@ -160,7 +162,7 @@ def workflow_with_nodes(self): name="test_node", tool=mock_tool, subscribed_expressions=[TopicExpr(topic=input_topic)], - publish_to=[output_topic], + publisher_router=PublisherRouter(routes=[PublishRoute(topic=output_topic)]), ) workflow = EventDrivenWorkflow(nodes={"test_node": node}) @@ -179,7 +181,7 @@ async def workflow_with_output_topics(self): name="test_node", tool=mock_tool, subscribed_expressions=[TopicExpr(topic=input_topic)], - publish_to=[output_topic], + publisher_router=PublisherRouter(routes=[PublishRoute(topic=output_topic)]), ) workflow = EventDrivenWorkflow(nodes={"test_node": node}) @@ -213,7 +215,7 @@ def simple_workflow(self): name="test_node", tool=mock_tool, subscribed_expressions=[TopicExpr(topic=input_topic)], - publish_to=[output_topic], + publisher_router=PublisherRouter(routes=[PublishRoute(topic=output_topic)]), ) return EventDrivenWorkflow(nodes={"test_node": node}) @@ -231,7 +233,7 @@ def async_workflow(self): name="test_node", tool=mock_tool, subscribed_expressions=[TopicExpr(topic=input_topic)], - publish_to=[output_topic], + publisher_router=PublisherRouter(routes=[PublishRoute(topic=output_topic)]), ) return EventDrivenWorkflow(nodes={"test_node": node}) @@ -289,7 +291,7 @@ def workflow_for_initial_test(self): name="test_node", tool=mock_tool, subscribed_expressions=[TopicExpr(topic=input_topic)], - publish_to=[output_topic], + publisher_router=PublisherRouter(routes=[PublishRoute(topic=output_topic)]), ) return EventDrivenWorkflow(nodes={"test_node": node}) @@ -312,7 +314,7 @@ def test_to_dict_includes_topics_and_topic_nodes(self): name="test_node", tool=mock_tool, subscribed_expressions=[TopicExpr(topic=input_topic)], - publish_to=[output_topic], + publisher_router=PublisherRouter(routes=[PublishRoute(topic=output_topic)]), ) workflow = EventDrivenWorkflow(nodes={"test_node": node}) @@ -337,7 +339,7 @@ def workflow_with_tracker(self): name="test_node", tool=mock_tool, subscribed_expressions=[TopicExpr(topic=input_topic)], - publish_to=[output_topic], + publisher_router=PublisherRouter(routes=[PublishRoute(topic=output_topic)]), ) return EventDrivenWorkflow(nodes={"test_node": node}) @@ -383,7 +385,7 @@ def stoppable_workflow(self): name="test_node", tool=mock_tool, subscribed_expressions=[TopicExpr(topic=input_topic)], - publish_to=[output_topic], + publisher_router=PublisherRouter(routes=[PublishRoute(topic=output_topic)]), ) return EventDrivenWorkflow(nodes={"test_node": node}) @@ -420,7 +422,9 @@ async def test_from_dict(self): name="test_node", tool=mock_tool, subscribed_expressions=[TopicExpr(topic=input_topic)], - publish_to=[output_topic], + publisher_router=PublisherRouter( + routes=[PublishRoute(topic=output_topic)] + ), ) # Create original workflow @@ -460,7 +464,9 @@ async def test_from_dict_roundtrip(self): name="roundtrip_node", tool=mock_tool, subscribed_expressions=[TopicExpr(topic=input_topic)], - publish_to=[output_topic], + publisher_router=PublisherRouter( + routes=[PublishRoute(topic=output_topic)] + ), ) # Create original workflow diff --git a/tests/workflow/test_fanout_quiescence.py b/tests/workflow/test_fanout_quiescence.py index 388087c1..2d7760fc 100644 --- a/tests/workflow/test_fanout_quiescence.py +++ b/tests/workflow/test_fanout_quiescence.py @@ -14,6 +14,8 @@ from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.message import Message from grafi.nodes.node import Node +from grafi.nodes.node_base import PublisherRouter +from grafi.nodes.node_base import PublishRoute from grafi.tools.tool import Tool from grafi.topics.expressions.topic_expression import TopicExpr from grafi.topics.topic_impl.input_topic import InputTopic @@ -63,14 +65,14 @@ def _build_fanout_workflow() -> EventDrivenWorkflow: type="Node", tool=LabelTool(label="a"), subscribed_expressions=[TopicExpr(topic=input_topic)], - publish_to=[output_topic], + publisher_router=PublisherRouter(routes=[PublishRoute(topic=output_topic)]), ) node_b = Node( name="node_b", type="Node", tool=LabelTool(label="b"), subscribed_expressions=[TopicExpr(topic=input_topic)], - publish_to=[output_topic], + publisher_router=PublisherRouter(routes=[PublishRoute(topic=output_topic)]), ) return EventDrivenWorkflow.builder().node(node_a).node(node_b).build() @@ -125,7 +127,7 @@ async def test_parallel_does_not_hang_on_unsatisfied_and_subscription(): type="Node", tool=LabelTool(label="ok"), subscribed_expressions=[TopicExpr(topic=input_topic)], - publish_to=[output_topic], + publisher_router=PublisherRouter(routes=[PublishRoute(topic=output_topic)]), ) # Subscribes to (agent_input AND gate_topic); gate_topic never fires. stuck = Node( @@ -139,7 +141,7 @@ async def test_parallel_does_not_hang_on_unsatisfied_and_subscription(): .subscribed_to(gate) .build() ], - publish_to=[output_topic], + publisher_router=PublisherRouter(routes=[PublishRoute(topic=output_topic)]), ) workflow = EventDrivenWorkflow.builder().node(firing).node(stuck).build() diff --git a/tests/workflow/test_invocation_isolation.py b/tests/workflow/test_invocation_isolation.py index d59a6f3b..411f6eb2 100644 --- a/tests/workflow/test_invocation_isolation.py +++ b/tests/workflow/test_invocation_isolation.py @@ -19,6 +19,8 @@ from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.message import Message from grafi.nodes.node import Node +from grafi.nodes.node_base import PublisherRouter +from grafi.nodes.node_base import PublishRoute from grafi.tools.tool import Tool from grafi.topics.expressions.topic_expression import TopicExpr from grafi.topics.topic_impl.input_topic import InputTopic @@ -55,7 +57,7 @@ def _build_workflow() -> EventDrivenWorkflow: type="Node", tool=MarkerTool(), subscribed_expressions=[TopicExpr(topic=input_topic)], - publish_to=[output_topic], + publisher_router=PublisherRouter(routes=[PublishRoute(topic=output_topic)]), ) return EventDrivenWorkflow.builder().node(node).build() diff --git a/tests/workflow/test_recovery.py b/tests/workflow/test_recovery.py index e4baa7a7..cd0246f4 100644 --- a/tests/workflow/test_recovery.py +++ b/tests/workflow/test_recovery.py @@ -13,6 +13,8 @@ from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.message import Message from grafi.nodes.node import Node +from grafi.nodes.node_base import PublisherRouter +from grafi.nodes.node_base import PublishRoute from grafi.tools.tool import Tool from grafi.topics.expressions.topic_expression import TopicExpr from grafi.topics.topic_impl.input_topic import InputTopic @@ -41,7 +43,7 @@ def _build_workflow() -> tuple[EventDrivenWorkflow, InputTopic, str]: type="Node", tool=EchoTool(), subscribed_expressions=[TopicExpr(topic=input_topic)], - publish_to=[output_topic], + publisher_router=PublisherRouter(routes=[PublishRoute(topic=output_topic)]), ) workflow = EventDrivenWorkflow.builder().node(node).build() return workflow, input_topic, node.name diff --git a/tests/workflow/test_utils.py b/tests/workflow/test_utils.py index 3208450f..5cf65a4c 100644 --- a/tests/workflow/test_utils.py +++ b/tests/workflow/test_utils.py @@ -213,7 +213,10 @@ async def test_publish_events(self): node = MagicMock(spec=Node) node.name = "test_node" node.type = "test_type" - node.publish_to = [mock_topic1, mock_topic2] + # The publisher router owns routing: it selects the destination topic + # names; publish_events just delivers + tracks. + node.publisher_router = MagicMock() + node.publisher_router.select.return_value = ["topic1", "topic2"] invoke_context = InvokeContext( conversation_id="test-conversation", @@ -271,10 +274,12 @@ def consumers_of(topic_name: str): call(2, source="node:test_node->topic1"), call(1, source="node:test_node->topic2"), ] - # topic2's condition filtered the event out, so its delivery is released. + # topic2 dropped the event (publish_data returned None), so its + # delivery is released. tracker.on_messages_committed.assert_awaited_once_with( 1, source="discard:topic2" ) + node.publisher_router.select.assert_called_once_with(publish_to_event) class TestGetNodeInput: diff --git a/tests/workflow/test_workflow_run.py b/tests/workflow/test_workflow_run.py index 07173246..3cc5593f 100644 --- a/tests/workflow/test_workflow_run.py +++ b/tests/workflow/test_workflow_run.py @@ -21,6 +21,8 @@ from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.message import Message from grafi.nodes.node import Node +from grafi.nodes.node_base import PublisherRouter +from grafi.nodes.node_base import PublishRoute from grafi.tools.tool import Tool from grafi.topics.expressions.subscription_builder import SubscriptionBuilder from grafi.topics.expressions.topic_expression import TopicExpr @@ -84,7 +86,7 @@ def _workflow(tool: Tool = None) -> EventDrivenWorkflow: type="Node", tool=tool or EchoTool(), subscribed_expressions=[TopicExpr(topic=input_topic)], - publish_to=[output_topic], + publisher_router=PublisherRouter(routes=[PublishRoute(topic=output_topic)]), ) return EventDrivenWorkflow.builder().node(node).build() @@ -105,7 +107,7 @@ def _and_workflow() -> EventDrivenWorkflow: .subscribed_to(gate) .build() ], - publish_to=[output_topic], + publisher_router=PublisherRouter(routes=[PublishRoute(topic=output_topic)]), ) return EventDrivenWorkflow.builder().node(node).build() diff --git a/tests_integration/function_assistant/SimpleFunctionLLMAssistant_manifest.json b/tests_integration/function_assistant/SimpleFunctionLLMAssistant_manifest.json index 7af36ccf..c8329e03 100644 --- a/tests_integration/function_assistant/SimpleFunctionLLMAssistant_manifest.json +++ b/tests_integration/function_assistant/SimpleFunctionLLMAssistant_manifest.json @@ -72,11 +72,19 @@ "topic": "agent_input_topic" } ], - "publish_to": [ - "function_call_topic" - ], "command": { "class": "LLMCommand" + }, + "publisher_router": { + "routes": [ + { + "topic": "function_call_topic", + "when": { + "ref": "grafi.topics.conditions:always_true" + }, + "from_stage": null + } + ] } }, "FunctionCallNode": { @@ -102,35 +110,34 @@ "topic": "function_call_topic" } ], - "publish_to": [ - "agent_output_topic" - ], "command": { "class": "Command" + }, + "publisher_router": { + "routes": [ + { + "topic": "agent_output_topic", + "when": { + "ref": "grafi.topics.conditions:always_true" + }, + "from_stage": null + } + ] } } }, "topics": { "agent_input_topic": { "name": "agent_input_topic", - "type": "AgentInputTopic", - "condition": { - "ref": "grafi.topics.topic_base:always_true" - } + "type": "AgentInputTopic" }, "function_call_topic": { "name": "function_call_topic", - "type": "Topic", - "condition": { - "ref": "grafi.topics.topic_base:always_true" - } + "type": "Topic" }, "agent_output_topic": { "name": "agent_output_topic", - "type": "AgentOutputTopic", - "condition": { - "ref": "grafi.topics.topic_base:always_true" - } + "type": "AgentOutputTopic" } } } diff --git a/tests_integration/function_call_assistant/SimpleFunctionCallAssistant_manifest.json b/tests_integration/function_call_assistant/SimpleFunctionCallAssistant_manifest.json index 3cce22d5..65d17e27 100644 --- a/tests_integration/function_call_assistant/SimpleFunctionCallAssistant_manifest.json +++ b/tests_integration/function_call_assistant/SimpleFunctionCallAssistant_manifest.json @@ -34,12 +34,26 @@ "topic": "agent_input_topic" } ], - "publish_to": [ - "function_call_topic", - "agent_output_topic" - ], "command": { "class": "LLMCommand" + }, + "publisher_router": { + "routes": [ + { + "topic": "function_call_topic", + "when": { + "ref": "grafi.topics.conditions:has_tool_call" + }, + "from_stage": null + }, + { + "topic": "agent_output_topic", + "when": { + "ref": "grafi.topics.conditions:has_text_response" + }, + "from_stage": null + } + ] } }, "FunctionCallNode": { @@ -102,11 +116,19 @@ "topic": "function_call_topic" } ], - "publish_to": [ - "function_result_topic" - ], "command": { "class": "FunctionCallCommand" + }, + "publisher_router": { + "routes": [ + { + "topic": "function_result_topic", + "when": { + "ref": "grafi.topics.conditions:always_true" + }, + "from_stage": null + } + ] } }, "OpenAIOutputNode": { @@ -134,42 +156,38 @@ "topic": "function_result_topic" } ], - "publish_to": [ - "agent_output_topic" - ], "command": { "class": "LLMCommand" + }, + "publisher_router": { + "routes": [ + { + "topic": "agent_output_topic", + "when": { + "ref": "grafi.topics.conditions:has_text_response" + }, + "from_stage": null + } + ] } } }, "topics": { "agent_input_topic": { "name": "agent_input_topic", - "type": "AgentInputTopic", - "condition": { - "ref": "grafi.topics.topic_base:always_true" - } + "type": "AgentInputTopic" }, "function_call_topic": { "name": "function_call_topic", - "type": "Topic", - "condition": { - "ref": "grafi.topics.conditions:has_tool_call" - } + "type": "Topic" }, "agent_output_topic": { "name": "agent_output_topic", - "type": "AgentOutputTopic", - "condition": { - "ref": "grafi.topics.conditions:has_text_response" - } + "type": "AgentOutputTopic" }, "function_result_topic": { "name": "function_result_topic", - "type": "Topic", - "condition": { - "ref": "grafi.topics.topic_base:always_true" - } + "type": "Topic" } } } diff --git a/tests_integration/function_call_assistant/complex_function_manifest.json b/tests_integration/function_call_assistant/complex_function_manifest.json index cf2284b0..c54be970 100644 --- a/tests_integration/function_call_assistant/complex_function_manifest.json +++ b/tests_integration/function_call_assistant/complex_function_manifest.json @@ -34,12 +34,26 @@ "topic": "agent_input_topic" } ], - "publish_to": [ - "function_call_topic", - "agent_output_topic" - ], "command": { "class": "LLMCommand" + }, + "publisher_router": { + "routes": [ + { + "topic": "function_call_topic", + "when": { + "ref": "grafi.topics.conditions:has_tool_call" + }, + "from_stage": null + }, + { + "topic": "agent_output_topic", + "when": { + "ref": "grafi.topics.conditions:has_text_response" + }, + "from_stage": null + } + ] } }, "FunctionCallNode": { @@ -97,11 +111,19 @@ "topic": "function_call_topic" } ], - "publish_to": [ - "function_result_topic" - ], "command": { "class": "FunctionCallCommand" + }, + "publisher_router": { + "routes": [ + { + "topic": "function_result_topic", + "when": { + "ref": "grafi.topics.conditions:always_true" + }, + "from_stage": null + } + ] } }, "OpenAIOutputNode": { @@ -129,42 +151,38 @@ "topic": "function_result_topic" } ], - "publish_to": [ - "agent_output_topic" - ], "command": { "class": "LLMCommand" + }, + "publisher_router": { + "routes": [ + { + "topic": "agent_output_topic", + "when": { + "ref": "grafi.topics.conditions:has_text_response" + }, + "from_stage": null + } + ] } } }, "topics": { "agent_input_topic": { "name": "agent_input_topic", - "type": "AgentInputTopic", - "condition": { - "ref": "grafi.topics.topic_base:always_true" - } + "type": "AgentInputTopic" }, "function_call_topic": { "name": "function_call_topic", - "type": "Topic", - "condition": { - "ref": "grafi.topics.conditions:has_tool_call" - } + "type": "Topic" }, "agent_output_topic": { "name": "agent_output_topic", - "type": "AgentOutputTopic", - "condition": { - "ref": "grafi.topics.conditions:has_text_response" - } + "type": "AgentOutputTopic" }, "function_result_topic": { "name": "function_result_topic", - "type": "Topic", - "condition": { - "ref": "grafi.topics.topic_base:always_true" - } + "type": "Topic" } } } diff --git a/tests_integration/function_call_assistant/multi_functions_call_assistant.py b/tests_integration/function_call_assistant/multi_functions_call_assistant.py index 5f134ea7..b71e845f 100644 --- a/tests_integration/function_call_assistant/multi_functions_call_assistant.py +++ b/tests_integration/function_call_assistant/multi_functions_call_assistant.py @@ -67,12 +67,7 @@ def _construct_workflow(self) -> "MultiFunctionsCallAssistant": agent_input_topic = InputTopic(name="agent_input_topic") agent_output_topic = OutputTopic(name="agent_output_topic") - function_call_topic = Topic( - name="function_call_topic", - condition=has_tool_call, - ) - - agent_output_topic.condition = has_text_response + function_call_topic = Topic(name="function_call_topic") # Create an input LLM node llm_input_node = ( @@ -88,17 +83,14 @@ def _construct_workflow(self) -> "MultiFunctionsCallAssistant": .system_message(self.function_call_llm_system_message) .build() ) - .publish_to(function_call_topic) - .publish_to(agent_output_topic) + .publish_to(function_call_topic, when=has_tool_call) + .publish_to(agent_output_topic, when=has_text_response) .build() ) workflow_dag_builder.node(llm_input_node) - function_result_topic = Topic( - name="function_result_topic", - condition=has_text_response, - ) + function_result_topic = Topic(name="function_result_topic") # Create function call node for function_tool in self.function_tools: @@ -111,7 +103,7 @@ def _construct_workflow(self) -> "MultiFunctionsCallAssistant": SubscriptionBuilder().subscribed_to(function_call_topic).build() ) .tool(function_tool) - .publish_to(function_result_topic) + .publish_to(function_result_topic, when=has_text_response) .build() ) workflow_dag_builder.node(function_call_node) @@ -131,7 +123,7 @@ def _construct_workflow(self) -> "MultiFunctionsCallAssistant": .system_message(self.summary_llm_system_message) .build() ) - .publish_to(agent_output_topic) + .publish_to(agent_output_topic, when=has_text_response) .build() ) diff --git a/tests_integration/function_call_assistant/simple_claude_function_call_assistant.py b/tests_integration/function_call_assistant/simple_claude_function_call_assistant.py index 6c488872..341c48d9 100644 --- a/tests_integration/function_call_assistant/simple_claude_function_call_assistant.py +++ b/tests_integration/function_call_assistant/simple_claude_function_call_assistant.py @@ -10,6 +10,7 @@ from grafi.nodes.node import Node from grafi.tools.function_calls.function_call_tool import FunctionCallTool from grafi.tools.llms.impl.claude_tool import ClaudeTool +from grafi.topics.conditions import has_text_response from grafi.topics.conditions import has_tool_call from grafi.topics.expressions.subscription_builder import SubscriptionBuilder from grafi.topics.topic_impl.input_topic import InputTopic @@ -53,10 +54,7 @@ def builder(cls) -> "SimpleClaudeFunctionCallAssistantBuilder": def _construct_workflow(self) -> "SimpleClaudeFunctionCallAssistant": agent_input_topic = InputTopic(name="agent_input_topic") agent_output_topic = OutputTopic(name="agent_output_topic") - function_call_topic = Topic( - name="function_call_topic", - condition=has_tool_call, - ) + function_call_topic = Topic(name="function_call_topic") # Create an input LLM node llm_input_node = ( @@ -72,19 +70,13 @@ def _construct_workflow(self) -> "SimpleClaudeFunctionCallAssistant": .system_message(self.function_call_llm_system_message) .build() ) - .publish_to(function_call_topic) - .publish_to(agent_output_topic) + .publish_to(function_call_topic, when=has_tool_call) + .publish_to(agent_output_topic, when=has_text_response) .build() ) function_result_topic = Topic(name="function_result_topic") - agent_output_topic.condition = ( - lambda event: event.data[-1].content is not None - and isinstance(event.data[-1].content, str) - and event.data[-1].content.strip() != "" - ) - # Create a function call node function_call_node = ( Node.builder() @@ -112,7 +104,7 @@ def _construct_workflow(self) -> "SimpleClaudeFunctionCallAssistant": .system_message(self.summary_llm_system_message) .build() ) - .publish_to(agent_output_topic) + .publish_to(agent_output_topic, when=has_text_response) .build() ) diff --git a/tests_integration/function_call_assistant/simple_deepseek_function_call_assistant.py b/tests_integration/function_call_assistant/simple_deepseek_function_call_assistant.py index 25aab6d2..eed61f99 100644 --- a/tests_integration/function_call_assistant/simple_deepseek_function_call_assistant.py +++ b/tests_integration/function_call_assistant/simple_deepseek_function_call_assistant.py @@ -10,6 +10,7 @@ from grafi.nodes.node import Node from grafi.tools.function_calls.function_call_tool import FunctionCallTool from grafi.tools.llms.impl.deepseek_tool import DeepseekTool +from grafi.topics.conditions import has_text_response from grafi.topics.conditions import has_tool_call from grafi.topics.expressions.subscription_builder import SubscriptionBuilder from grafi.topics.topic_impl.input_topic import InputTopic @@ -53,10 +54,7 @@ def builder(cls) -> "SimpleDeepseekFunctionCallAssistantBuilder": def _construct_workflow(self) -> "SimpleDeepseekFunctionCallAssistant": agent_input_topic = InputTopic(name="agent_input_topic") agent_output_topic = OutputTopic(name="agent_output_topic") - function_call_topic = Topic( - name="function_call_topic", - condition=has_tool_call, - ) + function_call_topic = Topic(name="function_call_topic") # Create an input LLM node llm_input_node = ( @@ -72,19 +70,13 @@ def _construct_workflow(self) -> "SimpleDeepseekFunctionCallAssistant": .system_message(self.function_call_llm_system_message) .build() ) - .publish_to(function_call_topic) - .publish_to(agent_output_topic) + .publish_to(function_call_topic, when=has_tool_call) + .publish_to(agent_output_topic, when=has_text_response) .build() ) function_result_topic = Topic(name="function_result_topic") - agent_output_topic.condition = ( - lambda event: event.data[-1].content is not None - and isinstance(event.data[-1].content, str) - and event.data[-1].content.strip() != "" - ) - # Create a function call node function_call_node = ( Node.builder() @@ -112,7 +104,7 @@ def _construct_workflow(self) -> "SimpleDeepseekFunctionCallAssistant": .system_message(self.summary_llm_system_message) .build() ) - .publish_to(agent_output_topic) + .publish_to(agent_output_topic, when=has_text_response) .build() ) diff --git a/tests_integration/function_call_assistant/simple_function_call_assistant.py b/tests_integration/function_call_assistant/simple_function_call_assistant.py index 95694f10..5ec81ae0 100644 --- a/tests_integration/function_call_assistant/simple_function_call_assistant.py +++ b/tests_integration/function_call_assistant/simple_function_call_assistant.py @@ -53,13 +53,8 @@ def builder(cls) -> "SimpleFunctionCallAssistantBuilder": def _construct_workflow(self) -> "SimpleFunctionCallAssistant": agent_input_topic = InputTopic(name="agent_input_topic") - agent_output_topic = OutputTopic( - name="agent_output_topic", condition=has_text_response - ) - function_call_topic = Topic( - name="function_call_topic", - condition=has_tool_call, - ) + agent_output_topic = OutputTopic(name="agent_output_topic") + function_call_topic = Topic(name="function_call_topic") llm_input_node = ( Node.builder() @@ -74,8 +69,8 @@ def _construct_workflow(self) -> "SimpleFunctionCallAssistant": .system_message(self.function_call_llm_system_message) .build() ) - .publish_to(function_call_topic) - .publish_to(agent_output_topic) + .publish_to(function_call_topic, when=has_tool_call) + .publish_to(agent_output_topic, when=has_text_response) .build() ) @@ -109,7 +104,7 @@ def _construct_workflow(self) -> "SimpleFunctionCallAssistant": .system_message(self.summary_llm_system_message) .build() ) - .publish_to(agent_output_topic) + .publish_to(agent_output_topic, when=has_text_response) .build() ) diff --git a/tests_integration/function_call_assistant/simple_gemini_function_call_assistant.py b/tests_integration/function_call_assistant/simple_gemini_function_call_assistant.py index 3edbb933..c1c6f069 100644 --- a/tests_integration/function_call_assistant/simple_gemini_function_call_assistant.py +++ b/tests_integration/function_call_assistant/simple_gemini_function_call_assistant.py @@ -10,6 +10,7 @@ from grafi.nodes.node import Node from grafi.tools.function_calls.function_call_tool import FunctionCallTool from grafi.tools.llms.impl.gemini_tool import GeminiTool +from grafi.topics.conditions import has_text_response from grafi.topics.conditions import has_tool_call from grafi.topics.expressions.subscription_builder import SubscriptionBuilder from grafi.topics.topic_impl.input_topic import InputTopic @@ -53,10 +54,7 @@ def builder(cls) -> "SimpleGeminiFunctionCallAssistantBuilder": def _construct_workflow(self) -> "SimpleGeminiFunctionCallAssistant": agent_input_topic = InputTopic(name="agent_input_topic") agent_output_topic = OutputTopic(name="agent_output_topic") - function_call_topic = Topic( - name="function_call_topic", - condition=has_tool_call, - ) + function_call_topic = Topic(name="function_call_topic") # Create an input LLM node llm_input_node = ( @@ -72,19 +70,13 @@ def _construct_workflow(self) -> "SimpleGeminiFunctionCallAssistant": .system_message(self.function_call_llm_system_message) .build() ) - .publish_to(function_call_topic) - .publish_to(agent_output_topic) + .publish_to(function_call_topic, when=has_tool_call) + .publish_to(agent_output_topic, when=has_text_response) .build() ) function_result_topic = Topic(name="function_result_topic") - agent_output_topic.condition = ( - lambda event: event.data[-1].content is not None - and isinstance(event.data[-1].content, str) - and event.data[-1].content.strip() != "" - ) - # Create a function call node function_call_node = ( Node.builder() @@ -112,7 +104,7 @@ def _construct_workflow(self) -> "SimpleGeminiFunctionCallAssistant": .system_message(self.summary_llm_system_message) .build() ) - .publish_to(agent_output_topic) + .publish_to(agent_output_topic, when=has_text_response) .build() ) diff --git a/tests_integration/function_call_assistant/simple_ollama_function_call_assistant.py b/tests_integration/function_call_assistant/simple_ollama_function_call_assistant.py index f80eb955..e040efec 100644 --- a/tests_integration/function_call_assistant/simple_ollama_function_call_assistant.py +++ b/tests_integration/function_call_assistant/simple_ollama_function_call_assistant.py @@ -9,6 +9,7 @@ from grafi.nodes.node import Node from grafi.tools.function_calls.function_call_tool import FunctionCallTool from grafi.tools.llms.impl.ollama_tool import OllamaTool +from grafi.topics.conditions import has_text_response from grafi.topics.conditions import has_tool_call from grafi.topics.expressions.subscription_builder import SubscriptionBuilder from grafi.topics.topic_impl.input_topic import InputTopic @@ -52,10 +53,7 @@ def builder(cls) -> "SimpleOllamaFunctionCallAssistantBuilder": def _construct_workflow(self) -> "SimpleOllamaFunctionCallAssistant": agent_input_topic = InputTopic(name="agent_input_topic") agent_output_topic = OutputTopic(name="agent_output_topic") - function_call_topic = Topic( - name="function_call_topic", - condition=has_tool_call, - ) + function_call_topic = Topic(name="function_call_topic") # Create an input LLM node llm_input_node = ( @@ -71,19 +69,13 @@ def _construct_workflow(self) -> "SimpleOllamaFunctionCallAssistant": .system_message(self.function_call_llm_system_message) .build() ) - .publish_to(function_call_topic) - .publish_to(agent_output_topic) + .publish_to(function_call_topic, when=has_tool_call) + .publish_to(agent_output_topic, when=has_text_response) .build() ) function_result_topic = Topic(name="function_result_topic") - agent_output_topic.condition = ( - lambda event: event.data[-1].content is not None - and isinstance(event.data[-1].content, str) - and event.data[-1].content.strip() != "" - ) - # Create a function call node function_call_node = ( Node.builder() @@ -111,7 +103,7 @@ def _construct_workflow(self) -> "SimpleOllamaFunctionCallAssistant": .system_message(self.summary_llm_system_message) .build() ) - .publish_to(agent_output_topic) + .publish_to(agent_output_topic, when=has_text_response) .build() ) diff --git a/tests_integration/function_call_assistant/simple_openrouter_function_call_assistant.py b/tests_integration/function_call_assistant/simple_openrouter_function_call_assistant.py index 3358a80d..8fba793f 100644 --- a/tests_integration/function_call_assistant/simple_openrouter_function_call_assistant.py +++ b/tests_integration/function_call_assistant/simple_openrouter_function_call_assistant.py @@ -10,6 +10,7 @@ from grafi.nodes.node import Node from grafi.tools.function_calls.function_call_tool import FunctionCallTool from grafi.tools.llms.impl.openrouter_tool import OpenRouterTool +from grafi.topics.conditions import has_text_response from grafi.topics.conditions import has_tool_call from grafi.topics.expressions.subscription_builder import SubscriptionBuilder from grafi.topics.topic_impl.input_topic import InputTopic @@ -53,10 +54,7 @@ def builder(cls) -> "SimpleOpenRouterFunctionCallAssistantBuilder": def _construct_workflow(self) -> "SimpleOpenRouterFunctionCallAssistant": agent_input_topic = InputTopic(name="agent_input_topic") agent_output_topic = OutputTopic(name="agent_output_topic") - function_call_topic = Topic( - name="function_call_topic", - condition=has_tool_call, - ) + function_call_topic = Topic(name="function_call_topic") # Create an input LLM node llm_input_node = ( @@ -72,19 +70,13 @@ def _construct_workflow(self) -> "SimpleOpenRouterFunctionCallAssistant": .system_message(self.function_call_llm_system_message) .build() ) - .publish_to(function_call_topic) - .publish_to(agent_output_topic) + .publish_to(function_call_topic, when=has_tool_call) + .publish_to(agent_output_topic, when=has_text_response) .build() ) function_result_topic = Topic(name="function_result_topic") - agent_output_topic.condition = ( - lambda event: event.data[-1].content is not None - and isinstance(event.data[-1].content, str) - and event.data[-1].content.strip() != "" - ) - # Create a function call node function_call_node = ( Node.builder() @@ -112,7 +104,7 @@ def _construct_workflow(self) -> "SimpleOpenRouterFunctionCallAssistant": .system_message(self.summary_llm_system_message) .build() ) - .publish_to(agent_output_topic) + .publish_to(agent_output_topic, when=has_text_response) .build() ) diff --git a/tests_integration/hith_assistant/kyc_assistant.py b/tests_integration/hith_assistant/kyc_assistant.py index cf38f39f..e07d5035 100644 --- a/tests_integration/hith_assistant/kyc_assistant.py +++ b/tests_integration/hith_assistant/kyc_assistant.py @@ -100,15 +100,9 @@ def _construct_workflow(self) -> "KycAssistant": # Create action node - hitl_call_topic = Topic( - name="hitl_call_topic", - condition=needs_human_review, - ) + hitl_call_topic = Topic(name="hitl_call_topic") - register_user_topic = Topic( - name="register_user_topic", - condition=is_register_client, - ) + register_user_topic = Topic(name="register_user_topic") action_node = ( Node.builder() @@ -123,8 +117,8 @@ def _construct_workflow(self) -> "KycAssistant": .system_message(self.action_llm_system_message) .build() ) - .publish_to(hitl_call_topic) - .publish_to(register_user_topic) + .publish_to(hitl_call_topic, when=needs_human_review) + .publish_to(register_user_topic, when=is_register_client) .build() ) diff --git a/tests_integration/hith_assistant/simple_hitl_assistant.py b/tests_integration/hith_assistant/simple_hitl_assistant.py index da4e7157..891e10dc 100644 --- a/tests_integration/hith_assistant/simple_hitl_assistant.py +++ b/tests_integration/hith_assistant/simple_hitl_assistant.py @@ -54,10 +54,7 @@ def builder(cls) -> "SimpleHITLAssistantBuilder": return SimpleHITLAssistantBuilder(cls) def _construct_workflow(self) -> "SimpleHITLAssistant": - hitl_call_topic = Topic( - name="hitl_call_topic", - condition=has_tool_call, - ) + hitl_call_topic = Topic(name="hitl_call_topic") agent_input_topic = InputTopic(name="agent_input_topic") agent_output_topic = OutputTopic(name="agent_output_topic") in_workflow_input_topic = InWorkflowInputTopic(name="human_response_topic") @@ -66,10 +63,7 @@ def _construct_workflow(self) -> "SimpleHITLAssistant": paired_in_workflow_input_topic_names=[in_workflow_input_topic.name], ) - register_user_topic = Topic( - name="register_user_topic", - condition=has_no_tool_call, - ) + register_user_topic = Topic(name="register_user_topic") llm_input_node = ( Node.builder() @@ -90,8 +84,8 @@ def _construct_workflow(self) -> "SimpleHITLAssistant": .system_message(self.hitl_llm_system_message) .build() ) - .publish_to(hitl_call_topic) - .publish_to(register_user_topic) + .publish_to(hitl_call_topic, when=has_tool_call) + .publish_to(register_user_topic, when=has_no_tool_call) .build() ) diff --git a/tests_integration/input_output_topics/mimo_llm_assistant.py b/tests_integration/input_output_topics/mimo_llm_assistant.py index f465919d..5d741c6b 100644 --- a/tests_integration/input_output_topics/mimo_llm_assistant.py +++ b/tests_integration/input_output_topics/mimo_llm_assistant.py @@ -9,7 +9,9 @@ from grafi.assistants.assistant_base import AssistantBaseBuilder from grafi.common.callable_component import CallableComponent from grafi.common.events.topic_events.publish_to_topic_event import PublishToTopicEvent +from grafi.common.models.message import Messages from grafi.nodes.node import Node +from grafi.tools.functions.function_tool import FunctionTool from grafi.tools.llms.impl.openai_tool import OpenAITool from grafi.topics.expressions.subscription_builder import SubscriptionBuilder from grafi.topics.topic_impl.input_topic import InputTopic @@ -19,12 +21,10 @@ class ContainsKeyword(CallableComponent): - """Route when the last message's content contains *keyword* (case-insensitive). + """Publisher-route predicate: True when the last message contains *keyword*. - A parameterized, serializable condition demonstrating the CallableComponent - pattern: the manifest stores - ``{"component": "...:ContainsKeyword", "config": {"keyword": "hello"}}`` -- - config data, no embedded code. + A parameterized, serializable predicate; the manifest stores + ``{"component": "...:ContainsKeyword", "config": {"keyword": "hello"}}``. """ keyword: str @@ -33,6 +33,11 @@ def __call__(self, event: PublishToTopicEvent) -> bool: return self.keyword in str(event.data[-1].content).lower() +def forward_input(messages: Messages) -> str: + """Identity router tool: re-emit the user's text so it can be content-routed.""" + return str(messages[-1].content or "") + + class MIMOLLMAssistant(Assistant): """ A Multiple Input Multiple Output (MIMO) LLM Assistant that processes multiple input topics @@ -76,25 +81,42 @@ def builder(cls) -> "MIMOLLMAssistantBuilder": return MIMOLLMAssistantBuilder(cls) def _construct_workflow(self) -> "MIMOLLMAssistant": - agent_input_greeting_topic = InputTopic( - name="agent_input_greeting_topic", - condition=ContainsKeyword(keyword="hello"), - ) - agent_input_question_topic = InputTopic( - name="agent_input_question_topic", - condition=ContainsKeyword(keyword="question"), - ) - agent_greeting_output_topic = OutputTopic(name="agent_greeting_output_topic") + # Content-routed MIMO: one input topic feeds a router node whose + # publisher routes (ContainsKeyword) fan the message out to the greeting + # and/or question paths. Topics are pure queues, so the content gating + # lives on the router's routes rather than on the input topics. + agent_input_topic = InputTopic(name="agent_input_topic") + agent_greeting_input_topic = Topic(name="agent_greeting_input_topic") + agent_question_input_topic = Topic(name="agent_question_input_topic") agent_greeting_output_topic = OutputTopic(name="agent_greeting_output_topic") agent_output_topic = OutputTopic(name="agent_output_topic") agent_greeting_merge_topic = Topic(name="agent_greeting_merge_topic") agent_question_merge_topic = Topic(name="agent_question_merge_topic") + router_node = ( + Node.builder() + .name("InputRouterNode") + .subscribe(agent_input_topic) + .tool( + FunctionTool.builder() + .name("InputRouter") + .function(forward_input) + .build() + ) + .publish_to( + agent_greeting_input_topic, when=ContainsKeyword(keyword="hello") + ) + .publish_to( + agent_question_input_topic, when=ContainsKeyword(keyword="question") + ) + .build() + ) + llm_greeting_node = ( Node.builder() .name("OpenAIGreetingNode") - .subscribe(agent_input_greeting_topic) + .subscribe(agent_greeting_input_topic) .tool( OpenAITool.builder() .name("OpenAIToolGreeting") @@ -111,7 +133,7 @@ def _construct_workflow(self) -> "MIMOLLMAssistant": llm_question_node = ( Node.builder() .name("OpenAIQuestionNode") - .subscribe(agent_input_question_topic) + .subscribe(agent_question_input_topic) .tool( OpenAITool.builder() .name("OpenAIToolQuestion") @@ -154,6 +176,7 @@ def _construct_workflow(self) -> "MIMOLLMAssistant": self.workflow = ( EventDrivenWorkflow.builder() .name("MIMOLLMAssistantWorkflow") + .node(router_node) .node(llm_greeting_node) .node(llm_question_node) .node(llm_merge_node) diff --git a/tests_integration/input_output_topics/mimo_llm_assistant_example.py b/tests_integration/input_output_topics/mimo_llm_assistant_example.py index fb77ade6..1848e766 100644 --- a/tests_integration/input_output_topics/mimo_llm_assistant_example.py +++ b/tests_integration/input_output_topics/mimo_llm_assistant_example.py @@ -62,9 +62,9 @@ async def test_mimo_llm_assistant() -> None: print(f"Greeting Output: {greeting_output}") assert len(greeting_output) == 2 - # Event store is idempotent on event_id; counts reflect deduped events - # (previously 20, which double-counted a duplicate publish). - assert len(await event_store.get_events()) == 19 + # Cumulative event count after this invoke (greeting path: router + greeting + # + merge nodes). The router node content-routes the single input. + assert len(await event_store.get_events()) == 25 # Test question input print("\nTesting question input...") @@ -85,7 +85,7 @@ async def test_mimo_llm_assistant() -> None: print(f"Question Output: {question_output}") assert len(question_output) == 2 - assert len(await event_store.get_events()) == 38 + assert len(await event_store.get_events()) == 50 # Test mixed input (both greeting and question) print("\nTesting mixed input...") @@ -107,7 +107,7 @@ async def test_mimo_llm_assistant() -> None: print(f"Mixed Output: {mixed_output}") assert len(mixed_output) == 3 - assert len(await event_store.get_events()) == 65 + assert len(await event_store.get_events()) == 83 with bind_services(runtime.services): diff --git a/tests_integration/react_assistant/ReActAssistant_manifest.json b/tests_integration/react_assistant/ReActAssistant_manifest.json index eadcded0..e6f167c0 100644 --- a/tests_integration/react_assistant/ReActAssistant_manifest.json +++ b/tests_integration/react_assistant/ReActAssistant_manifest.json @@ -40,11 +40,19 @@ } } ], - "publish_to": [ - "thought_result" - ], "command": { "class": "LLMCommand" + }, + "publisher_router": { + "routes": [ + { + "topic": "thought_result", + "when": { + "ref": "grafi.topics.conditions:always_true" + }, + "from_stage": null + } + ] } }, "ActionNode": { @@ -72,12 +80,26 @@ "topic": "thought_result" } ], - "publish_to": [ - "action_search_result", - "action_finish_result" - ], "command": { "class": "LLMCommand" + }, + "publisher_router": { + "routes": [ + { + "topic": "action_search_result", + "when": { + "ref": "grafi.topics.conditions:has_tool_call" + }, + "from_stage": null + }, + { + "topic": "action_finish_result", + "when": { + "ref": "grafi.topics.conditions:has_text_response" + }, + "from_stage": null + } + ] } }, "SearchNode": { @@ -128,11 +150,19 @@ "topic": "action_search_result" } ], - "publish_to": [ - "search_function_result" - ], "command": { "class": "FunctionCallCommand" + }, + "publisher_router": { + "routes": [ + { + "topic": "search_function_result", + "when": { + "ref": "grafi.topics.conditions:always_true" + }, + "from_stage": null + } + ] } }, "ObservationNode": { @@ -160,11 +190,19 @@ "topic": "search_function_result" } ], - "publish_to": [ - "observation_result" - ], "command": { "class": "LLMCommand" + }, + "publisher_router": { + "routes": [ + { + "topic": "observation_result", + "when": { + "ref": "grafi.topics.conditions:always_true" + }, + "from_stage": null + } + ] } }, "SummariesNode": { @@ -192,63 +230,50 @@ "topic": "action_finish_result" } ], - "publish_to": [ - "agent_output_topic" - ], "command": { "class": "LLMCommand" + }, + "publisher_router": { + "routes": [ + { + "topic": "agent_output_topic", + "when": { + "ref": "grafi.topics.conditions:always_true" + }, + "from_stage": null + } + ] } } }, "topics": { "agent_input_topic": { "name": "agent_input_topic", - "type": "AgentInputTopic", - "condition": { - "ref": "grafi.topics.topic_base:always_true" - } + "type": "AgentInputTopic" }, "observation_result": { "name": "observation_result", - "type": "Topic", - "condition": { - "ref": "grafi.topics.topic_base:always_true" - } + "type": "Topic" }, "thought_result": { "name": "thought_result", - "type": "Topic", - "condition": { - "ref": "grafi.topics.topic_base:always_true" - } + "type": "Topic" }, "action_search_result": { "name": "action_search_result", - "type": "Topic", - "condition": { - "ref": "grafi.topics.conditions:has_tool_call" - } + "type": "Topic" }, "action_finish_result": { "name": "action_finish_result", - "type": "Topic", - "condition": { - "ref": "grafi.topics.conditions:has_text_response" - } + "type": "Topic" }, "search_function_result": { "name": "search_function_result", - "type": "Topic", - "condition": { - "ref": "grafi.topics.topic_base:always_true" - } + "type": "Topic" }, "agent_output_topic": { "name": "agent_output_topic", - "type": "AgentOutputTopic", - "condition": { - "ref": "grafi.topics.topic_base:always_true" - } + "type": "AgentOutputTopic" } } } diff --git a/tests_integration/react_assistant/react_assistant.py b/tests_integration/react_assistant/react_assistant.py index 11efae3f..95c3d8b8 100644 --- a/tests_integration/react_assistant/react_assistant.py +++ b/tests_integration/react_assistant/react_assistant.py @@ -78,14 +78,8 @@ def _construct_workflow(self) -> "ReActAssistant": workflow_dag_builder.node(thought_node) - action_result_search_topic = Topic( - name="action_search_result", - condition=has_tool_call, - ) - action_result_finish_topic = Topic( - name="action_finish_result", - condition=has_text_response, - ) + action_result_search_topic = Topic(name="action_search_result") + action_result_finish_topic = Topic(name="action_finish_result") action_node = ( Node.builder() @@ -99,8 +93,8 @@ def _construct_workflow(self) -> "ReActAssistant": .system_message(self.action_llm_system_message) .build() ) - .publish_to(action_result_search_topic) - .publish_to(action_result_finish_topic) + .publish_to(action_result_search_topic, when=has_tool_call) + .publish_to(action_result_finish_topic, when=has_text_response) .build() ) diff --git a/tests_integration/simple_stream_assistant/simple_stream_function_call_assistant.py b/tests_integration/simple_stream_assistant/simple_stream_function_call_assistant.py index 115f3fe7..0eeb5de4 100644 --- a/tests_integration/simple_stream_assistant/simple_stream_function_call_assistant.py +++ b/tests_integration/simple_stream_assistant/simple_stream_function_call_assistant.py @@ -50,15 +50,9 @@ def _construct_workflow(self) -> "SimpleStreamFunctionCallAssistant": """ agent_input_topic = InputTopic(name="agent_input_topic") agent_output_topic = OutputTopic(name="agent_output_topic") - function_call_topic = Topic( - name="function_call_topic", - condition=has_tool_call, - ) + function_call_topic = Topic(name="function_call_topic") - summary_topic = Topic( - name="summary_topic", - condition=has_no_tool_call, - ) + summary_topic = Topic(name="summary_topic") llm_input_node = ( Node.builder() @@ -73,8 +67,8 @@ def _construct_workflow(self) -> "SimpleStreamFunctionCallAssistant": .system_message(self.function_call_llm_system_message) .build() ) - .publish_to(function_call_topic) - .publish_to(summary_topic) + .publish_to(function_call_topic, when=has_tool_call) + .publish_to(summary_topic, when=has_no_tool_call) .build() ) From 3d866baf1e825b87a5b89e724b383eda82f39c4c Mon Sep 17 00:00:00 2001 From: GuanyiLi-Craig Date: Thu, 2 Jul 2026 14:29:39 +0100 Subject: [PATCH 2/3] Move routing predicates to grafi/nodes/conditions.py The predicates in conditions.py (has_tool_call, has_text_response, always_true, ...) are route predicates now, used as the `when=` on a node's publisher route rather than a topic condition. Relocate the module from grafi/topics/ to grafi/nodes/ next to node_base's router, update all importers, and refresh the manifest callable refs to the new module path. Since this branch already breaks manifest compatibility (topic condition -> publisher route), folding the path change in here avoids a second serialization break later. Downgrade the human_tool_approval example to plain pause/resume: its approve/deny verdict routing was built on topic conditions on the human-reply topics, which is a consumer-router capability that arrives with the tool-hooks change. It now mirrors simple_hitl_assistant (content-routed publisher routes, one human-response channel). Co-Authored-By: Claude Fable 5 --- grafi/agents/react_agent.py | 4 +- grafi/{topics => nodes}/conditions.py | 0 grafi/nodes/node_base.py | 2 +- .../test_serialization_roundtrip.py | 2 +- tests/nodes/test_publisher_router.py | 4 +- .../SimpleFunctionLLMAssistant_manifest.json | 4 +- .../SimpleFunctionCallAssistant_manifest.json | 8 +- .../complex_function_manifest.json | 8 +- .../multi_functions_call_assistant.py | 4 +- .../simple_claude_function_call_assistant.py | 4 +- ...simple_deepseek_function_call_assistant.py | 4 +- .../simple_function_call_assistant.py | 4 +- .../simple_gemini_function_call_assistant.py | 4 +- .../simple_ollama_function_call_assistant.py | 4 +- ...mple_openrouter_function_call_assistant.py | 4 +- .../human_tool_approval_assistant.py | 112 ++++---- .../human_tool_approval_assistant_example.py | 241 ++---------------- .../hith_assistant/simple_hitl_assistant.py | 4 +- .../ReActAssistant_manifest.json | 12 +- .../react_assistant/react_assistant.py | 4 +- .../simple_stream_function_call_assistant.py | 4 +- 21 files changed, 117 insertions(+), 320 deletions(-) rename grafi/{topics => nodes}/conditions.py (100%) diff --git a/grafi/agents/react_agent.py b/grafi/agents/react_agent.py index 51f3898b..690c257e 100644 --- a/grafi/agents/react_agent.py +++ b/grafi/agents/react_agent.py @@ -14,13 +14,13 @@ from grafi.common.models.async_result import async_func_wrapper from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.message import Message +from grafi.nodes.conditions import has_text_response +from grafi.nodes.conditions import has_tool_call from grafi.nodes.node import Node from grafi.runtime import GrafiRuntime from grafi.tools.function_calls.function_call_tool import FunctionCallTool from grafi.tools.function_calls.impl.tavily_tool import TavilyTool from grafi.tools.llms.impl.openai_tool import OpenAITool -from grafi.topics.conditions import has_text_response -from grafi.topics.conditions import has_tool_call from grafi.topics.expressions.subscription_builder import SubscriptionBuilder from grafi.topics.topic_impl.input_topic import InputTopic from grafi.topics.topic_impl.output_topic import OutputTopic diff --git a/grafi/topics/conditions.py b/grafi/nodes/conditions.py similarity index 100% rename from grafi/topics/conditions.py rename to grafi/nodes/conditions.py diff --git a/grafi/nodes/node_base.py b/grafi/nodes/node_base.py index 21c438a9..2ea09ebd 100644 --- a/grafi/nodes/node_base.py +++ b/grafi/nodes/node_base.py @@ -25,9 +25,9 @@ from grafi.common.models.base_builder import BaseBuilder from grafi.common.models.default_id import default_id from grafi.common.models.invoke_context import InvokeContext +from grafi.nodes.conditions import always_true from grafi.tools.command import Command from grafi.tools.tool import Tool -from grafi.topics.conditions import always_true from grafi.topics.expressions.topic_expression import SubExpr from grafi.topics.expressions.topic_expression import TopicExpr from grafi.topics.expressions.topic_expression import evaluate_subscription diff --git a/tests/integration/test_serialization_roundtrip.py b/tests/integration/test_serialization_roundtrip.py index 21de07e0..0c3b8a57 100644 --- a/tests/integration/test_serialization_roundtrip.py +++ b/tests/integration/test_serialization_roundtrip.py @@ -27,11 +27,11 @@ from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.message import Message from grafi.common.models.message import Messages +from grafi.nodes.conditions import always_true from grafi.nodes.node import Node from grafi.tools.function_calls.function_call_tool import FunctionCallTool from grafi.tools.functions.function_tool import FunctionTool from grafi.tools.tool_factory import ToolFactory -from grafi.topics.conditions import always_true from grafi.topics.expressions.subscription_builder import SubscriptionBuilder from grafi.topics.topic_impl.input_topic import InputTopic from grafi.topics.topic_impl.output_topic import OutputTopic diff --git a/tests/nodes/test_publisher_router.py b/tests/nodes/test_publisher_router.py index c1dea647..6e890964 100644 --- a/tests/nodes/test_publisher_router.py +++ b/tests/nodes/test_publisher_router.py @@ -8,10 +8,10 @@ from grafi.common.events.topic_events.publish_to_topic_event import PublishToTopicEvent from grafi.common.models.invoke_context import InvokeContext from grafi.common.models.message import Message +from grafi.nodes.conditions import has_text_response +from grafi.nodes.conditions import has_tool_call from grafi.nodes.node_base import PublisherRouter from grafi.nodes.node_base import PublishRoute -from grafi.topics.conditions import has_text_response -from grafi.topics.conditions import has_tool_call from grafi.topics.topic_base import TopicBase diff --git a/tests_integration/function_assistant/SimpleFunctionLLMAssistant_manifest.json b/tests_integration/function_assistant/SimpleFunctionLLMAssistant_manifest.json index c8329e03..7402702c 100644 --- a/tests_integration/function_assistant/SimpleFunctionLLMAssistant_manifest.json +++ b/tests_integration/function_assistant/SimpleFunctionLLMAssistant_manifest.json @@ -80,7 +80,7 @@ { "topic": "function_call_topic", "when": { - "ref": "grafi.topics.conditions:always_true" + "ref": "grafi.nodes.conditions:always_true" }, "from_stage": null } @@ -118,7 +118,7 @@ { "topic": "agent_output_topic", "when": { - "ref": "grafi.topics.conditions:always_true" + "ref": "grafi.nodes.conditions:always_true" }, "from_stage": null } diff --git a/tests_integration/function_call_assistant/SimpleFunctionCallAssistant_manifest.json b/tests_integration/function_call_assistant/SimpleFunctionCallAssistant_manifest.json index 65d17e27..27c4ac66 100644 --- a/tests_integration/function_call_assistant/SimpleFunctionCallAssistant_manifest.json +++ b/tests_integration/function_call_assistant/SimpleFunctionCallAssistant_manifest.json @@ -42,14 +42,14 @@ { "topic": "function_call_topic", "when": { - "ref": "grafi.topics.conditions:has_tool_call" + "ref": "grafi.nodes.conditions:has_tool_call" }, "from_stage": null }, { "topic": "agent_output_topic", "when": { - "ref": "grafi.topics.conditions:has_text_response" + "ref": "grafi.nodes.conditions:has_text_response" }, "from_stage": null } @@ -124,7 +124,7 @@ { "topic": "function_result_topic", "when": { - "ref": "grafi.topics.conditions:always_true" + "ref": "grafi.nodes.conditions:always_true" }, "from_stage": null } @@ -164,7 +164,7 @@ { "topic": "agent_output_topic", "when": { - "ref": "grafi.topics.conditions:has_text_response" + "ref": "grafi.nodes.conditions:has_text_response" }, "from_stage": null } diff --git a/tests_integration/function_call_assistant/complex_function_manifest.json b/tests_integration/function_call_assistant/complex_function_manifest.json index c54be970..af49a67f 100644 --- a/tests_integration/function_call_assistant/complex_function_manifest.json +++ b/tests_integration/function_call_assistant/complex_function_manifest.json @@ -42,14 +42,14 @@ { "topic": "function_call_topic", "when": { - "ref": "grafi.topics.conditions:has_tool_call" + "ref": "grafi.nodes.conditions:has_tool_call" }, "from_stage": null }, { "topic": "agent_output_topic", "when": { - "ref": "grafi.topics.conditions:has_text_response" + "ref": "grafi.nodes.conditions:has_text_response" }, "from_stage": null } @@ -119,7 +119,7 @@ { "topic": "function_result_topic", "when": { - "ref": "grafi.topics.conditions:always_true" + "ref": "grafi.nodes.conditions:always_true" }, "from_stage": null } @@ -159,7 +159,7 @@ { "topic": "agent_output_topic", "when": { - "ref": "grafi.topics.conditions:has_text_response" + "ref": "grafi.nodes.conditions:has_text_response" }, "from_stage": null } diff --git a/tests_integration/function_call_assistant/multi_functions_call_assistant.py b/tests_integration/function_call_assistant/multi_functions_call_assistant.py index b71e845f..6d7bd8ca 100644 --- a/tests_integration/function_call_assistant/multi_functions_call_assistant.py +++ b/tests_integration/function_call_assistant/multi_functions_call_assistant.py @@ -9,11 +9,11 @@ from grafi.assistants.assistant import Assistant from grafi.assistants.assistant_base import AssistantBaseBuilder +from grafi.nodes.conditions import has_text_response +from grafi.nodes.conditions import has_tool_call from grafi.nodes.node import Node from grafi.tools.function_calls.function_call_tool import FunctionCallTool from grafi.tools.llms.impl.openai_tool import OpenAITool -from grafi.topics.conditions import has_text_response -from grafi.topics.conditions import has_tool_call from grafi.topics.expressions.subscription_builder import SubscriptionBuilder from grafi.topics.topic_impl.input_topic import InputTopic from grafi.topics.topic_impl.output_topic import OutputTopic diff --git a/tests_integration/function_call_assistant/simple_claude_function_call_assistant.py b/tests_integration/function_call_assistant/simple_claude_function_call_assistant.py index 341c48d9..4febcda0 100644 --- a/tests_integration/function_call_assistant/simple_claude_function_call_assistant.py +++ b/tests_integration/function_call_assistant/simple_claude_function_call_assistant.py @@ -7,11 +7,11 @@ from grafi.assistants.assistant import Assistant from grafi.assistants.assistant_base import AssistantBaseBuilder +from grafi.nodes.conditions import has_text_response +from grafi.nodes.conditions import has_tool_call from grafi.nodes.node import Node from grafi.tools.function_calls.function_call_tool import FunctionCallTool from grafi.tools.llms.impl.claude_tool import ClaudeTool -from grafi.topics.conditions import has_text_response -from grafi.topics.conditions import has_tool_call from grafi.topics.expressions.subscription_builder import SubscriptionBuilder from grafi.topics.topic_impl.input_topic import InputTopic from grafi.topics.topic_impl.output_topic import OutputTopic diff --git a/tests_integration/function_call_assistant/simple_deepseek_function_call_assistant.py b/tests_integration/function_call_assistant/simple_deepseek_function_call_assistant.py index eed61f99..e500699e 100644 --- a/tests_integration/function_call_assistant/simple_deepseek_function_call_assistant.py +++ b/tests_integration/function_call_assistant/simple_deepseek_function_call_assistant.py @@ -7,11 +7,11 @@ from grafi.assistants.assistant import Assistant from grafi.assistants.assistant_base import AssistantBaseBuilder +from grafi.nodes.conditions import has_text_response +from grafi.nodes.conditions import has_tool_call from grafi.nodes.node import Node from grafi.tools.function_calls.function_call_tool import FunctionCallTool from grafi.tools.llms.impl.deepseek_tool import DeepseekTool -from grafi.topics.conditions import has_text_response -from grafi.topics.conditions import has_tool_call from grafi.topics.expressions.subscription_builder import SubscriptionBuilder from grafi.topics.topic_impl.input_topic import InputTopic from grafi.topics.topic_impl.output_topic import OutputTopic diff --git a/tests_integration/function_call_assistant/simple_function_call_assistant.py b/tests_integration/function_call_assistant/simple_function_call_assistant.py index 5ec81ae0..f4bd68f4 100644 --- a/tests_integration/function_call_assistant/simple_function_call_assistant.py +++ b/tests_integration/function_call_assistant/simple_function_call_assistant.py @@ -7,11 +7,11 @@ from grafi.assistants.assistant import Assistant from grafi.assistants.assistant_base import AssistantBaseBuilder +from grafi.nodes.conditions import has_text_response +from grafi.nodes.conditions import has_tool_call from grafi.nodes.node import Node from grafi.tools.function_calls.function_call_tool import FunctionCallTool from grafi.tools.llms.impl.openai_tool import OpenAITool -from grafi.topics.conditions import has_text_response -from grafi.topics.conditions import has_tool_call from grafi.topics.expressions.subscription_builder import SubscriptionBuilder from grafi.topics.topic_impl.input_topic import InputTopic from grafi.topics.topic_impl.output_topic import OutputTopic diff --git a/tests_integration/function_call_assistant/simple_gemini_function_call_assistant.py b/tests_integration/function_call_assistant/simple_gemini_function_call_assistant.py index c1c6f069..a32ee242 100644 --- a/tests_integration/function_call_assistant/simple_gemini_function_call_assistant.py +++ b/tests_integration/function_call_assistant/simple_gemini_function_call_assistant.py @@ -7,11 +7,11 @@ from grafi.assistants.assistant import Assistant from grafi.assistants.assistant_base import AssistantBaseBuilder +from grafi.nodes.conditions import has_text_response +from grafi.nodes.conditions import has_tool_call from grafi.nodes.node import Node from grafi.tools.function_calls.function_call_tool import FunctionCallTool from grafi.tools.llms.impl.gemini_tool import GeminiTool -from grafi.topics.conditions import has_text_response -from grafi.topics.conditions import has_tool_call from grafi.topics.expressions.subscription_builder import SubscriptionBuilder from grafi.topics.topic_impl.input_topic import InputTopic from grafi.topics.topic_impl.output_topic import OutputTopic diff --git a/tests_integration/function_call_assistant/simple_ollama_function_call_assistant.py b/tests_integration/function_call_assistant/simple_ollama_function_call_assistant.py index e040efec..3b017345 100644 --- a/tests_integration/function_call_assistant/simple_ollama_function_call_assistant.py +++ b/tests_integration/function_call_assistant/simple_ollama_function_call_assistant.py @@ -6,11 +6,11 @@ from grafi.assistants.assistant import Assistant from grafi.assistants.assistant_base import AssistantBaseBuilder +from grafi.nodes.conditions import has_text_response +from grafi.nodes.conditions import has_tool_call from grafi.nodes.node import Node from grafi.tools.function_calls.function_call_tool import FunctionCallTool from grafi.tools.llms.impl.ollama_tool import OllamaTool -from grafi.topics.conditions import has_text_response -from grafi.topics.conditions import has_tool_call from grafi.topics.expressions.subscription_builder import SubscriptionBuilder from grafi.topics.topic_impl.input_topic import InputTopic from grafi.topics.topic_impl.output_topic import OutputTopic diff --git a/tests_integration/function_call_assistant/simple_openrouter_function_call_assistant.py b/tests_integration/function_call_assistant/simple_openrouter_function_call_assistant.py index 8fba793f..c1fee0fb 100644 --- a/tests_integration/function_call_assistant/simple_openrouter_function_call_assistant.py +++ b/tests_integration/function_call_assistant/simple_openrouter_function_call_assistant.py @@ -7,11 +7,11 @@ from grafi.assistants.assistant import Assistant from grafi.assistants.assistant_base import AssistantBaseBuilder +from grafi.nodes.conditions import has_text_response +from grafi.nodes.conditions import has_tool_call from grafi.nodes.node import Node from grafi.tools.function_calls.function_call_tool import FunctionCallTool from grafi.tools.llms.impl.openrouter_tool import OpenRouterTool -from grafi.topics.conditions import has_text_response -from grafi.topics.conditions import has_tool_call from grafi.topics.expressions.subscription_builder import SubscriptionBuilder from grafi.topics.topic_impl.input_topic import InputTopic from grafi.topics.topic_impl.output_topic import OutputTopic diff --git a/tests_integration/hith_assistant/human_tool_approval_assistant.py b/tests_integration/hith_assistant/human_tool_approval_assistant.py index 3f67e4a2..dcf5051a 100644 --- a/tests_integration/hith_assistant/human_tool_approval_assistant.py +++ b/tests_integration/hith_assistant/human_tool_approval_assistant.py @@ -7,12 +7,11 @@ from grafi.assistants.assistant import Assistant from grafi.assistants.assistant_base import AssistantBaseBuilder -from grafi.common.events.topic_events.publish_to_topic_event import PublishToTopicEvent +from grafi.nodes.conditions import has_no_tool_call +from grafi.nodes.conditions import has_tool_call from grafi.nodes.node import Node from grafi.tools.function_calls.function_call_tool import FunctionCallTool from grafi.tools.llms.impl.openai_tool import OpenAITool -from grafi.topics.conditions import has_no_tool_call -from grafi.topics.conditions import has_tool_call from grafi.topics.expressions.subscription_builder import SubscriptionBuilder from grafi.topics.topic_impl.in_workflow_input_topic import InWorkflowInputTopic from grafi.topics.topic_impl.in_workflow_output_topic import InWorkflowOutputTopic @@ -22,31 +21,29 @@ from grafi.workflows.impl.event_driven_workflow import EventDrivenWorkflow -def is_approval(event: PublishToTopicEvent) -> bool: - """Human approved the pending tool call.""" - return event.data[-1].content == "YES" - - -def is_disapproval(event: PublishToTopicEvent) -> bool: - """Human rejected the pending tool call (reply starts with 'NO').""" - content = event.data[-1].content - return isinstance(content, str) and content.startswith("NO") - - class HumanApprovalAssistant(Assistant): - """ - A simple assistant class that uses OpenAI's language model to process input, - make function calls, and generate responses. + """A human-in-the-loop assistant that surfaces a tool call for review. + + This is the plain pause/resume form: the LLM proposes a tool call, the + function node runs it and publishes the result to a paired + ``InWorkflowOutputTopic`` (surfacing it to a human), and the human's reply + resumes the workflow through the LLM, which produces the final answer. + Routing is content-based on the LLM node's publisher routes + (``publish_to(..., when=has_tool_call)``); there is a single human-response + channel and no approve/deny verdict branching. - This class sets up a workflow with three nodes: an input LLM node, a function call node, - and an output LLM node. It provides a method to run input through this workflow. + Verdict-gated approval (run the tool only if the human approves, deny to + forward a rejection) is a consumer-router capability introduced with the + tool-hooks change; this example is intentionally limited to the pause/resume + mechanics that the pure publisher-route routing supports. Attributes: name (str): The name of the assistant. - api_key (str): The API key for OpenAI. If not provided, it tries to use the OPENAI_API_KEY environment variable. + api_key (str): The API key for OpenAI. If not provided, it tries to use + the OPENAI_API_KEY environment variable. model (str): The name of the OpenAI model to use. - event_store (EventStore): An instance of EventStore to record events during the assistant's operation. - function (Callable): The function to be called by the assistant. + function_tool (FunctionCallTool): The tool whose call is surfaced for + human review. """ oi_span_type: OpenInferenceSpanKindValues = Field( @@ -58,6 +55,7 @@ class HumanApprovalAssistant(Assistant): model: str = Field(default="gpt-4o") function_tool: FunctionCallTool function_call_llm_system_message: Optional[str] = Field(default=None) + summary_llm_system_message: Optional[str] = Field(default=None) @classmethod def builder(cls) -> "HumanApprovalAssistantBuilder": @@ -66,29 +64,17 @@ def builder(cls) -> "HumanApprovalAssistantBuilder": def _construct_workflow(self) -> "HumanApprovalAssistant": agent_input_topic = InputTopic(name="agent_input_topic") - agent_output_topic = OutputTopic( - name="agent_output_topic", - condition=has_no_tool_call, - ) - in_workflow_input_approval_topic = InWorkflowInputTopic( - name="human_approval_topic", - condition=is_approval, - ) - in_workflow_input_disapproval_topic = InWorkflowInputTopic( - name="human_disapproval_topic", - condition=is_disapproval, - ) - in_workflow_output_topic = InWorkflowOutputTopic( + agent_output_topic = OutputTopic(name="agent_output_topic") + tool_call_topic = Topic(name="human_tool_call_topic") + register_topic = Topic(name="human_tool_call_response_topic") + human_response_topic = InWorkflowInputTopic(name="human_response_topic") + human_request_topic = InWorkflowOutputTopic( name="human_tool_call_request_topic", - paired_in_workflow_input_topic_names=[ - in_workflow_input_approval_topic.name, - in_workflow_input_disapproval_topic.name, - ], - condition=has_tool_call, + paired_in_workflow_input_topic_names=[human_response_topic.name], ) - function_output_topic = Topic(name="human_tool_call_response_topic") - + # The human's reply re-enters this node; a tool-call proposal is routed + # to the function node, a plain answer to the summary node. llm_input_node = ( Node.builder() .name("OpenAIInputNode") @@ -97,9 +83,7 @@ def _construct_workflow(self) -> "HumanApprovalAssistant": SubscriptionBuilder() .subscribed_to(agent_input_topic) .or_() - .subscribed_to(function_output_topic) - .or_() - .subscribed_to(in_workflow_input_disapproval_topic) + .subscribed_to(human_response_topic) .build() ) .tool( @@ -110,24 +94,39 @@ def _construct_workflow(self) -> "HumanApprovalAssistant": .system_message(self.function_call_llm_system_message) .build() ) - .publish_to(in_workflow_output_topic) - .publish_to(agent_output_topic) + .publish_to(tool_call_topic, when=has_tool_call) + .publish_to(register_topic, when=has_no_tool_call) .build() ) - # Create a function call node - + # The function node runs the proposed tool call and surfaces the result + # to a human for review via the paired in-workflow output topic. function_call_node = ( Node.builder() .name("FunctionCallNode") .type("FunctionCallNode") - .subscribe( - SubscriptionBuilder() - .subscribed_to(in_workflow_input_approval_topic) + .subscribe(SubscriptionBuilder().subscribed_to(tool_call_topic).build()) + .tool(self.function_tool) + .publish_to(human_request_topic) + .build() + ) + + # Once the input LLM produces a plain answer (no tool call), summarize it + # for the user. + llm_output_node = ( + Node.builder() + .name("OpenAIOutputNode") + .type("LLMNode") + .subscribe(SubscriptionBuilder().subscribed_to(register_topic).build()) + .tool( + OpenAITool.builder() + .name("UserOutputLLM") + .api_key(self.api_key) + .model(self.model) + .system_message(self.summary_llm_system_message) .build() ) - .tool(self.function_tool) - .publish_to(function_output_topic) + .publish_to(agent_output_topic) .build() ) @@ -137,6 +136,7 @@ def _construct_workflow(self) -> "HumanApprovalAssistant": .name("HumanApprovalWorkflow") .node(llm_input_node) .node(function_call_node) + .node(llm_output_node) .build() ) @@ -162,6 +162,10 @@ def function_call_llm_system_message( ) return self + def summary_llm_system_message(self, summary_llm_system_message: str) -> Self: + self.kwargs["summary_llm_system_message"] = summary_llm_system_message + return self + def function_tool(self, function_tool: FunctionCallTool) -> Self: self.kwargs["function_tool"] = function_tool return self diff --git a/tests_integration/hith_assistant/human_tool_approval_assistant_example.py b/tests_integration/hith_assistant/human_tool_approval_assistant_example.py index 50a0284c..9ae32c41 100644 --- a/tests_integration/hith_assistant/human_tool_approval_assistant_example.py +++ b/tests_integration/hith_assistant/human_tool_approval_assistant_example.py @@ -45,14 +45,16 @@ def get_invoke_context( ) -async def test_function_call_assistant() -> None: - conversation_id = "test_conversation_id_approve" - assistant_request_id = "test_assistant_request_id_approve" +async def test_human_tool_approval_pause_then_resume() -> None: + """Plain pause/resume: propose a tool call, surface it, resume on a reply. - invoke_context = get_invoke_context( - conversation_id, - assistant_request_id, - ) + (Verdict-gated approve/deny lands with the tool-hooks change; this example + only exercises the pause/resume mechanics.) + """ + conversation_id = "test_conversation_id_review" + assistant_request_id = "test_assistant_request_id_review" + + invoke_context = get_invoke_context(conversation_id, assistant_request_id) assistant = ( HumanApprovalAssistant.builder() @@ -62,7 +64,8 @@ async def test_function_call_assistant() -> None: .build() ) - # Test the run method + # 1) Propose the tool call; the function node runs it and surfaces the + # result to the human, pausing the workflow. input_data = [ Message(role="user", content="Hello, I want to delete database user_backup.") ] @@ -78,27 +81,18 @@ async def test_function_call_assistant() -> None: ) print(output) assert output is not None - assert output[0].data[0].tool_calls is not None - # Test the run method - input_data = output[0].data - input_data.append( - Message( - role="user", - content="YES", - ) - ) + # 2) The human reviews and replies; the reply resumes the workflow and the + # LLM produces the final answer. + resume_data = [Message(role="user", content="Looks good, thanks.")] - invoke_context = get_invoke_context( - conversation_id, - assistant_request_id, - ) + invoke_context = get_invoke_context(conversation_id, assistant_request_id) output = await async_func_wrapper( assistant.invoke( PublishToTopicEvent( invoke_context=invoke_context, - data=input_data, + data=resume_data, consumed_event_ids=[event.event_id for event in output], ), is_sequential=True, @@ -109,206 +103,5 @@ async def test_function_call_assistant() -> None: assert output[0].data[0].tool_calls is None -async def test_function_call_assistant_disapproval() -> None: - conversation_id = "test_conversation_id_disapprove" - assistant_request_id = "test_assistant_request_id_disapprove" - invoke_context = get_invoke_context( - conversation_id, - assistant_request_id, - ) - - assistant = ( - HumanApprovalAssistant.builder() - .name("HumanApprovalAssistant") - .api_key(api_key) - .function_tool(DeleteDatabase(name="DeleteDatabase")) - .build() - ) - - # Test the run method - input_data = [ - Message(role="user", content="Hello, I want to delete database user_backup.") - ] - - output = await async_func_wrapper( - assistant.invoke( - PublishToTopicEvent( - invoke_context=invoke_context, - data=input_data, - ), - is_sequential=True, - ) - ) - print(output) - assert output is not None - assert output[0].data[0].tool_calls is not None - - # Test the run method - input_data = [] - input_data.append( - Message( - role="user", - content="NO", - ) - ) - - invoke_context = get_invoke_context( - conversation_id, - assistant_request_id, - ) - - output = await async_func_wrapper( - assistant.invoke( - PublishToTopicEvent( - invoke_context=invoke_context, - data=input_data, - consumed_event_ids=[event.event_id for event in output], - ) - ) - ) - print(output) - assert output is not None - assert output[0].data[0].tool_calls is None - - -async def test_function_call_assistant_suggestion() -> None: - conversation_id = "test_conversation_id_suggest" - assistant_request_id = "test_assistant_request_id_suggest" - invoke_context = get_invoke_context( - conversation_id, - assistant_request_id, - ) - - assistant = ( - HumanApprovalAssistant.builder() - .name("HumanApprovalAssistant") - .api_key(api_key) - .function_tool(DeleteDatabase(name="DeleteDatabase")) - .build() - ) - - # Test the run method - input_data = [ - Message(role="user", content="Hello, I want to delete database user_backup.") - ] - - output = await async_func_wrapper( - assistant.invoke( - PublishToTopicEvent( - invoke_context=invoke_context, - data=input_data, - ) - ) - ) - print(output) - assert output is not None - assert output[0].data[0].tool_calls is not None - - # Test the run method - input_data = [] - input_data.append( - Message( - role="user", - content="NO. You should add 'staging.' prefix to all the database names.", - ) - ) - - invoke_context = get_invoke_context( - conversation_id, - assistant_request_id, - ) - - output = await async_func_wrapper( - assistant.invoke( - PublishToTopicEvent( - invoke_context=invoke_context, - data=input_data, - consumed_event_ids=[event.event_id for event in output], - ) - ) - ) - print(output) - assert output is not None - assert output[0].data[0].tool_calls is not None - arguments = output[0].data[0].tool_calls[0].function.arguments - import json - - parsed_args = json.loads(arguments) - assert parsed_args["db_name"] == "staging.user_backup" - - # Test the run method - input_data = [] - input_data.append( - Message( - role="user", - content="YES", - ) - ) - - invoke_context = get_invoke_context( - conversation_id, - assistant_request_id, - ) - - output = await async_func_wrapper( - assistant.invoke( - PublishToTopicEvent( - invoke_context=invoke_context, - data=input_data, - consumed_event_ids=[event.event_id for event in output], - ) - ) - ) - print(output) - assert output is not None - assert output[0].data[0].tool_calls is None - - -async def test_function_call_assistant_suggestion_mem() -> None: - conversation_id = "test_conversation_id_suggest" - assistant_request_id = "test_assistant_request_id_mem" - invoke_context = get_invoke_context( - conversation_id, - assistant_request_id, - ) - - assistant = ( - HumanApprovalAssistant.builder() - .name("HumanApprovalAssistant") - .api_key(api_key) - .function_tool(DeleteDatabase(name="DeleteDatabase")) - .function_call_llm_system_message( - "You must consider previous user's suggestions, think about how to incorporate them when calling the tools" - ) - .build() - ) - - # Test the run method - input_data = [ - Message(role="user", content="Hello, I want to delete database product_backup.") - ] - - output = await async_func_wrapper( - assistant.invoke( - PublishToTopicEvent( - invoke_context=invoke_context, - data=input_data, - ) - ) - ) - print(output) - assert output is not None - assert output[0].data[0].tool_calls is not None - arguments = output[0].data[0].tool_calls[0].function.arguments - import json - - parsed_args = json.loads(arguments) - assert parsed_args["db_name"] == "staging.product_backup" - - -with bind_services(runtime.services): - asyncio.run(test_function_call_assistant()) with bind_services(runtime.services): - asyncio.run(test_function_call_assistant_disapproval()) -# test_function_call_assistant_suggestion() -# test_function_call_assistant_suggestion_mem() + asyncio.run(test_human_tool_approval_pause_then_resume()) diff --git a/tests_integration/hith_assistant/simple_hitl_assistant.py b/tests_integration/hith_assistant/simple_hitl_assistant.py index 891e10dc..d20a225b 100644 --- a/tests_integration/hith_assistant/simple_hitl_assistant.py +++ b/tests_integration/hith_assistant/simple_hitl_assistant.py @@ -7,11 +7,11 @@ from grafi.assistants.assistant import Assistant from grafi.assistants.assistant_base import AssistantBaseBuilder +from grafi.nodes.conditions import has_no_tool_call +from grafi.nodes.conditions import has_tool_call from grafi.nodes.node import Node from grafi.tools.function_calls.function_call_tool import FunctionCallTool from grafi.tools.llms.impl.openai_tool import OpenAITool -from grafi.topics.conditions import has_no_tool_call -from grafi.topics.conditions import has_tool_call from grafi.topics.expressions.subscription_builder import SubscriptionBuilder from grafi.topics.topic_impl.in_workflow_input_topic import InWorkflowInputTopic from grafi.topics.topic_impl.in_workflow_output_topic import InWorkflowOutputTopic diff --git a/tests_integration/react_assistant/ReActAssistant_manifest.json b/tests_integration/react_assistant/ReActAssistant_manifest.json index e6f167c0..acf562fe 100644 --- a/tests_integration/react_assistant/ReActAssistant_manifest.json +++ b/tests_integration/react_assistant/ReActAssistant_manifest.json @@ -48,7 +48,7 @@ { "topic": "thought_result", "when": { - "ref": "grafi.topics.conditions:always_true" + "ref": "grafi.nodes.conditions:always_true" }, "from_stage": null } @@ -88,14 +88,14 @@ { "topic": "action_search_result", "when": { - "ref": "grafi.topics.conditions:has_tool_call" + "ref": "grafi.nodes.conditions:has_tool_call" }, "from_stage": null }, { "topic": "action_finish_result", "when": { - "ref": "grafi.topics.conditions:has_text_response" + "ref": "grafi.nodes.conditions:has_text_response" }, "from_stage": null } @@ -158,7 +158,7 @@ { "topic": "search_function_result", "when": { - "ref": "grafi.topics.conditions:always_true" + "ref": "grafi.nodes.conditions:always_true" }, "from_stage": null } @@ -198,7 +198,7 @@ { "topic": "observation_result", "when": { - "ref": "grafi.topics.conditions:always_true" + "ref": "grafi.nodes.conditions:always_true" }, "from_stage": null } @@ -238,7 +238,7 @@ { "topic": "agent_output_topic", "when": { - "ref": "grafi.topics.conditions:always_true" + "ref": "grafi.nodes.conditions:always_true" }, "from_stage": null } diff --git a/tests_integration/react_assistant/react_assistant.py b/tests_integration/react_assistant/react_assistant.py index 95c3d8b8..e2ddc8f5 100644 --- a/tests_integration/react_assistant/react_assistant.py +++ b/tests_integration/react_assistant/react_assistant.py @@ -13,11 +13,11 @@ from grafi.assistants.assistant import Assistant from grafi.assistants.assistant_base import AssistantBaseBuilder +from grafi.nodes.conditions import has_text_response +from grafi.nodes.conditions import has_tool_call from grafi.nodes.node import Node from grafi.tools.function_calls.function_call_tool import FunctionCallTool from grafi.tools.llms.impl.openai_tool import OpenAITool -from grafi.topics.conditions import has_text_response -from grafi.topics.conditions import has_tool_call from grafi.topics.expressions.subscription_builder import SubscriptionBuilder from grafi.topics.topic_impl.input_topic import InputTopic from grafi.topics.topic_impl.output_topic import OutputTopic diff --git a/tests_integration/simple_stream_assistant/simple_stream_function_call_assistant.py b/tests_integration/simple_stream_assistant/simple_stream_function_call_assistant.py index 0eeb5de4..af13a72b 100644 --- a/tests_integration/simple_stream_assistant/simple_stream_function_call_assistant.py +++ b/tests_integration/simple_stream_assistant/simple_stream_function_call_assistant.py @@ -7,11 +7,11 @@ from grafi.assistants.assistant import Assistant from grafi.assistants.assistant_base import AssistantBaseBuilder +from grafi.nodes.conditions import has_no_tool_call +from grafi.nodes.conditions import has_tool_call from grafi.nodes.node import Node from grafi.tools.function_calls.function_call_tool import FunctionCallTool from grafi.tools.llms.impl.openai_tool import OpenAITool -from grafi.topics.conditions import has_no_tool_call -from grafi.topics.conditions import has_tool_call from grafi.topics.expressions.subscription_builder import SubscriptionBuilder from grafi.topics.topic_impl.input_topic import InputTopic from grafi.topics.topic_impl.output_topic import OutputTopic From 92eb9f8ad223682356d1c1f4feae4652c631f663 Mon Sep 17 00:00:00 2001 From: GuanyiLi-Craig Date: Thu, 2 Jul 2026 20:47:34 +0100 Subject: [PATCH 3/3] Address PR review: tighten deserialization errors and publish contract Copilot review on #106: - PublishRoute.from_dict: raise a descriptive ValueError naming the missing topic and the available ones, instead of a bare KeyError. - Node._builder_from_dict: raise a clear ValueError when a manifest lacks 'publisher_router', pointing at the legacy 'publish_to' it replaced, instead of a bare KeyError. - TopicBase.publish_data: return a non-optional PublishToTopicEvent and raise TopicPublicationError if enqueue ever fails, matching the "topics never drop" contract; drop the now-dead condition-drop branch in publish_events (and its truthiness check). Update the utils test to assert both selected topics enqueue and nothing is discarded. Co-Authored-By: Claude Fable 5 --- grafi/nodes/node.py | 6 ++++++ grafi/nodes/node_base.py | 10 ++++++++-- grafi/topics/topic_base.py | 22 ++++++++++++++++++---- grafi/workflows/impl/utils.py | 11 +++-------- tests/nodes/test_node.py | 15 +++++++++++++++ tests/nodes/test_publisher_router.py | 10 ++++++++++ tests/workflow/test_utils.py | 23 +++++++++++++++-------- 7 files changed, 75 insertions(+), 22 deletions(-) diff --git a/grafi/nodes/node.py b/grafi/nodes/node.py index 5c52dcc4..75c3e107 100644 --- a/grafi/nodes/node.py +++ b/grafi/nodes/node.py @@ -108,6 +108,12 @@ async def _builder_from_dict( elif "op" in expr_dict: node_builder.subscribe(await CombinedExpr.from_dict(expr_dict, topics)) + if "publisher_router" not in node_dict: + raise ValueError( + "Node manifest is missing required key 'publisher_router'. It " + "replaced the legacy 'publish_to' topic-name list; regenerate " + "the manifest from the current assistant." + ) node_builder.publisher_router( PublisherRouter.from_dict(node_dict["publisher_router"], topics) ) diff --git a/grafi/nodes/node_base.py b/grafi/nodes/node_base.py index 2ea09ebd..f8ba8a39 100644 --- a/grafi/nodes/node_base.py +++ b/grafi/nodes/node_base.py @@ -55,14 +55,20 @@ def to_dict(self) -> Dict[str, Any]: def from_dict( cls, data: Dict[str, Any], topics: Dict[str, TopicBase] ) -> "PublishRoute": + topic_name = data["topic"] + if topic_name not in topics: + raise ValueError( + f"Publisher route references unknown topic {topic_name!r}. " + f"Available topics: {sorted(topics)}." + ) raw = data.get("when") when = ( - deserialize_callable(raw, context=f"route '{data['topic']}' when") + deserialize_callable(raw, context=f"route '{topic_name}' when") if raw else always_true ) return cls( - topic=topics[data["topic"]], + topic=topics[topic_name], when=when, ) diff --git a/grafi/topics/topic_base.py b/grafi/topics/topic_base.py index 8eb214a5..2fac9178 100644 --- a/grafi/topics/topic_base.py +++ b/grafi/topics/topic_base.py @@ -13,6 +13,7 @@ ) from grafi.common.events.topic_events.publish_to_topic_event import PublishToTopicEvent from grafi.common.events.topic_events.topic_event import TopicEvent +from grafi.common.exceptions.event_exceptions import TopicPublicationError from grafi.common.models.base_builder import BaseBuilder from grafi.topics.queue_impl.in_mem_topic_event_queue import InMemTopicEventQueue from grafi.topics.topic_event_queue import TopicEventQueue @@ -39,11 +40,11 @@ class TopicBase(BaseModel): async def publish_data( self, publish_event: PublishToTopicEvent - ) -> Optional[PublishToTopicEvent]: + ) -> PublishToTopicEvent: """Stamp the event with this topic's name/type and enqueue it. - Returns the enqueued event. Routing has already happened on the node's - publisher router, so a topic never drops an event. + Always returns the enqueued event: routing already happened on the + node's publisher router, so a topic never drops an event. """ event = publish_event.model_copy( update={ @@ -52,7 +53,20 @@ async def publish_data( }, deep=True, ) - return await self.add_event(event) + published = await self.add_event(event) + if not isinstance(published, PublishToTopicEvent): + # add_event only returns None for non-publish events; publish_data + # always enqueues a PublishToTopicEvent, so reaching here means a + # subclass or queue broke the pure-queue contract. + raise TopicPublicationError( + topic_name=self.name, + message=( + f"[{self.name}] publish_data failed to enqueue the event; " + "topics must always enqueue (they no longer drop by " + "condition)." + ), + ) + return published async def can_consume(self, consumer_name: str) -> bool: """ diff --git a/grafi/workflows/impl/utils.py b/grafi/workflows/impl/utils.py index 1aea3b0a..58d59f70 100644 --- a/grafi/workflows/impl/utils.py +++ b/grafi/workflows/impl/utils.py @@ -116,15 +116,10 @@ async def publish_events( await tracker.on_messages_published( delivery_count, source=f"node:{node.name}->{topic_name}" ) + # A topic is a pure queue and never drops (routing already happened on + # the publisher router), so every selected topic yields an event. event = await run_topic.publish_data(publish_event) - if event: - published_events.append(event) - elif delivery_count: - # The topic dropped the event (still enqueued nothing), so no - # consumer will ever commit these deliveries. Release them now. - await tracker.on_messages_committed( - delivery_count, source=f"discard:{topic_name}" - ) + published_events.append(event) return published_events diff --git a/tests/nodes/test_node.py b/tests/nodes/test_node.py index 5a0643b0..bf4d353f 100644 --- a/tests/nodes/test_node.py +++ b/tests/nodes/test_node.py @@ -551,3 +551,18 @@ async def test_from_dict_preserves_node_id(self): restored = await Node.from_dict(data, {}) # Recovered node keeps its identity instead of getting a fresh uuid. assert restored.node_id == original.node_id + + @pytest.mark.asyncio + async def test_from_dict_missing_publisher_router_raises_descriptive_error(self): + from grafi.tools.llms.impl.openai_tool import OpenAITool + + original = Node( + name="n", + type="Node", + tool=OpenAITool.builder().name("llm").model("gpt-4o-mini").build(), + ) + data = original.to_dict() + # Simulate a legacy manifest that predates the publisher router. + data.pop("publisher_router") + with pytest.raises(ValueError, match="publisher_router"): + await Node.from_dict(data, {}) diff --git a/tests/nodes/test_publisher_router.py b/tests/nodes/test_publisher_router.py index 6e890964..a72fd556 100644 --- a/tests/nodes/test_publisher_router.py +++ b/tests/nodes/test_publisher_router.py @@ -1,5 +1,6 @@ """Unit tests for the node's publisher router (isolated, no engine).""" +import pytest from openai.types.chat.chat_completion_message_tool_call import ( ChatCompletionMessageToolCall, ) @@ -92,3 +93,12 @@ def test_serialization_round_trip(self): ] assert restored.routes[0].when is has_tool_call assert restored.routes[1].when is has_text_response + + def test_from_dict_unknown_topic_raises_descriptive_error(self): + # A manifest referencing a topic that was not reconstructed should fail + # with a message naming the missing topic and the available ones. + data = {"routes": [{"topic": "ghost", "when": None, "from_stage": None}]} + with pytest.raises(ValueError, match="unknown topic 'ghost'"): + PublisherRouter.from_dict( + data, {"agent_output": TopicBase(name="agent_output")} + ) diff --git a/tests/workflow/test_utils.py b/tests/workflow/test_utils.py index 5cf65a4c..cb7ec619 100644 --- a/tests/workflow/test_utils.py +++ b/tests/workflow/test_utils.py @@ -236,7 +236,15 @@ async def test_publish_events(self): data=result, consumed_events=consumed_events, ) - mock_event2 = None # Test case where topic doesn't publish + mock_event2 = PublishToTopicEvent( + name="topic2", + publisher_name=node.name, + publisher_type=node.type, + invoke_context=invoke_context, + offset=0, + data=result, + consumed_events=consumed_events, + ) mock_topic1.name = "topic1" mock_topic2.name = "topic2" @@ -263,22 +271,21 @@ def consumers_of(topic_name: str): node, publish_to_event, tracker, consumers_of, topics ) - assert len(published_events) == 1 - assert published_events[0] == mock_event1 + # Both selected topics enqueue (topics are pure queues; they never drop). + assert len(published_events) == 2 + assert published_events == [mock_event1, mock_event2] # Verify topics were called correctly mock_topic1.publish_data.assert_called_once_with(publish_to_event) + mock_topic2.publish_data.assert_called_once_with(publish_to_event) # One delivery registered per consumer, per topic, before publishing. assert tracker.on_messages_published.await_args_list == [ call(2, source="node:test_node->topic1"), call(1, source="node:test_node->topic2"), ] - # topic2 dropped the event (publish_data returned None), so its - # delivery is released. - tracker.on_messages_committed.assert_awaited_once_with( - 1, source="discard:topic2" - ) + # Nothing is dropped, so no delivery is released. + tracker.on_messages_committed.assert_not_awaited() node.publisher_router.select.assert_called_once_with(publish_to_event)