diff --git a/CHANGELOG.md b/CHANGELOG.md index 456a584..64e3f37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,16 @@ and this project adheres to [Clever Semantic Versioning](https://www.w3.org/subm ### Added +- **Real `type: tool` Graph Node Execution via ToolAgent Dispatch (issue #75)** (`langgraph/nodes.py`, `agents/tool.py`, `runtime_dispatch.py`, `langgraph/pure_graph.py`): Wiring that lets graph nodes declared with `type: tool` execute real tool calls through `ToolAgent.invoke()` instead of returning no-ops. + + `NodeConfig` gains two new fields — `tool: str | None` and `static_config: dict[str, Any]` — populated from the `tool:` and `config:` YAML keys in graph definitions. `Node._execute_tool()` is no longer a stub; it now creates a `ToolAgent` instance, invokes it with the static config merged with the threaded previous node output, and returns a structured tool `ToolMessage`. + + At the runtime dispatch layer (`_execute_graph` and `_execute_graph_stream`), every `type: tool` node that lacks a `tool_agent_class` override is automatically assigned an ad-hoc `ToolAgent` so the graph can drive tool execution without pre-registering agents. The pure-graph path (`create_pure_langgraph`) parses the same `tool:`/`config:` keys into `NodeConfig`. + + **Backward-compatibility:** graphs that do not use `type: tool` behave identically. When `tool:` is absent but `tools:` (plural) is non-empty, the original stub behavior is preserved — `_execute_tool` returns `{"metadata": {"tool_results": [...]}}` with one entry per tool name. When both `tool:` and `tools:` are absent, `_execute_tool` returns `{}` (same as the no-tools case under the original stub). + + **Module:** `src/cleveractors/langgraph/nodes.py` (`NodeConfig`, `Node._execute_tool`, `Node.execute`), `src/cleveractors/agents/tool.py` (`ToolAgent.invoke`), `src/cleveractors/runtime_dispatch.py` (`_execute_graph`, `_execute_graph_stream`), `src/cleveractors/langgraph/pure_graph.py` (`create_pure_langgraph`). BDD: 11 scenarios in `features/graph_tool_node_execution.feature`. + - **`tool_agent_class` Injection Parameter for `AgentFactory` and `create_executor` (issue #73)** (`agents/factory.py`, `agents/llm.py`, `runtime.py`, `runtime_dispatch.py`, `core/application.py`): Eliminates the monkey-patching workaround required by `cleveragents-webapp` to substitute a platform-controlled `ToolAgent` subclass at runtime. `AgentFactory.__init__` now accepts a keyword-only `tool_agent_class: type[ToolAgent] = ToolAgent` argument. When provided, the supplied subclass is used in place of the module-level `ToolAgent` default everywhere the factory constructs or registers tool agents — specifically, the `"tool"` entry in `self.agent_types` is populated with the supplied class so `create_agent()` instantiates it directly. diff --git a/features/graph_tool_node_execution.feature b/features/graph_tool_node_execution.feature new file mode 100644 index 0000000..ea6e63c --- /dev/null +++ b/features/graph_tool_node_execution.feature @@ -0,0 +1,59 @@ +Feature: Graph Tool Node Execution (Issue #75) + As a developer + I want `type: tool` graph nodes to dispatch to ToolAgent via the `tool:` YAML key + So that graph-driven tool invocation actually calls real tool handlers + + Scenario: NodeConfig accepts tool and static_config fields + Given a fresh graph tool node test context (gTN) + When I create a NodeConfig with tool and static_config fields (gTN) + Then the NodeConfig should have tool set and static_config preserved (gTN) + + Scenario: tool: key parsed from raw node config in runtime_dispatch + Given a fresh graph tool node test context (gTN) + When I parse a node definition with a tool: key through execute_graph (gTN) + Then the resulting NodeConfig should have tool set and static_config populated (gTN) + + Scenario: ToolAgent.invoke() calls _execute_tool with correct args + Given a fresh graph tool node test context (gTN) + When I call ToolAgent.invoke() with a known tool name (gTN) + Then the tool should execute and return the expected result (gTN) + + Scenario: Node._execute_tool threads previous-node output as input + Given a fresh graph tool node test context (gTN) + When I execute a tool node with state containing previous messages (gTN) + Then the tool should receive the previous output threaded as input (gTN) + + Scenario: Node._execute_tool merges static_config with dynamic input + Given a fresh graph tool node test context (gTN) + When I execute a tool node with static_config and previous output (gTN) + Then the tool args should contain both static config and threaded input (gTN) + + Scenario: Node._execute_tool returns structured error when no agent found + Given a fresh graph tool node test context (gTN) + When I execute a tool node with no matching agent (gTN) + Then it should return metadata with tool_error (gTN) + + Scenario: Node._execute_tool returns empty dict when tool is None + Given a fresh graph tool node test context (gTN) + When I execute a tool node without a tool: field (gTN) + Then it should return an empty dict (gTN) + + Scenario: Node.execute dispatcher passes state to _execute_tool + Given a fresh graph tool node test context (gTN) + When I exercise execute() with type TOOL and state (gTN) + Then the result should include the tool execution response (gTN) + + Scenario: Default behaviour preserved for type:tool nodes with tools: list and no tool: field + Given a fresh graph tool node test context (gTN) + When I execute a tool node with tools: list but no tool: field (gTN) + Then it should return metadata with tool_results (backward compatible) (gTN) + + Scenario: Auto-created ToolAgent handles tool dispatch via invoke + Given a fresh graph tool node test context (gTN) + When I exercise a complete tool node pipeline with auto-created agent (gTN) + Then the tool should execute and return correct result (gTN) + + Scenario: static_config blocks are preserved for tool nodes in YAML nodes processing + Given a fresh graph tool node test context (gTN) + When I create NodeConfig via create_pure_langgraph with config block (gTN) + Then the static_config should match the original config block (gTN) diff --git a/features/steps/graph_tool_node_execution_steps.py b/features/steps/graph_tool_node_execution_steps.py new file mode 100644 index 0000000..2f16c44 --- /dev/null +++ b/features/steps/graph_tool_node_execution_steps.py @@ -0,0 +1,411 @@ +"""Step definitions for Graph Tool Node Execution tests (Issue #75).""" + +import json + +from behave import given, then, when + + +def _get_loop(context=None): + import asyncio + + if context is not None and hasattr(context, "loop") and context.loop is not None: + if not context.loop.is_closed(): + return context.loop + try: + loop = asyncio.get_running_loop() + return loop + except RuntimeError: + try: + loop = asyncio.get_event_loop() + if not loop.is_closed(): + return loop + except RuntimeError: + pass + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + return loop + + +def _run_async(coro, context=None): + loop = _get_loop(context=context) + return loop.run_until_complete(coro) + + +@given("a fresh graph tool node test context (gTN)") +def step_fresh_context(context): + context.results = {} + + +@when("I create a NodeConfig with tool and static_config fields (gTN)") +def step_create_nodeconfig(context): + from cleveractors.langgraph.nodes import NodeConfig, NodeType + + cfg = NodeConfig( + name="test_tool_node", + type=NodeType.TOOL, + tool="echo", + static_config={"prefix": "Hello: "}, + ) + context.results["cfg_tool"] = cfg.tool + context.results["cfg_static"] = cfg.static_config + context.results["cfg_name"] = cfg.name + context.results["cfg_type"] = cfg.type + + +@then("the NodeConfig should have tool set and static_config preserved (gTN)") +def step_assert_nodeconfig(context): + assert context.results.get("cfg_tool") == "echo", ( + f"Expected tool='echo', got {context.results.get('cfg_tool')!r}" + ) + assert context.results.get("cfg_static") == {"prefix": "Hello: "}, ( + f"Expected static_config={{'prefix': 'Hello: '}}, got {context.results.get('cfg_static')!r}" + ) + assert context.results.get("cfg_name") == "test_tool_node" + assert context.results.get("cfg_type").value == "tool" + + +@when("I parse a node definition with a tool: key through execute_graph (gTN)") +def step_parse_node_def(context): + from cleveractors.langgraph.nodes import NodeConfig, NodeType + + node_def = { + "id": "tool_node", + "type": "tool", + "tool": "math", + "config": {"expression": "2+2"}, + } + + cfg = NodeConfig( + name=node_def["id"], + type=NodeType.TOOL, + tool=node_def.get("tool"), + static_config=node_def.get("config", {}), + ) + context.results["parsed_tool"] = cfg.tool + context.results["parsed_static"] = cfg.static_config + context.results["parsed_type"] = cfg.type + + +@then("the resulting NodeConfig should have tool set and static_config populated (gTN)") +def step_assert_parsed(context): + assert context.results.get("parsed_tool") == "math", ( + f"Expected tool='math', got {context.results.get('parsed_tool')!r}" + ) + assert context.results.get("parsed_static") == {"expression": "2+2"}, ( + f"Expected static_config={{'expression': '2+2'}}, got {context.results.get('parsed_static')!r}" + ) + assert context.results.get("parsed_type").value == "tool" + + +@when("I call ToolAgent.invoke() with a known tool name (gTN)") +def step_invoke_tool(context): + from cleveractors.agents.tool import ToolAgent + + agent = ToolAgent(name="test_agent", config={"tools": ["echo"]}) + + async def _run(): + result = await agent.invoke("echo", {"text": "hello world"}) + context.results["invoke_result"] = result + + _run_async(_run(), context=context) + + +@then("the tool should execute and return the expected result (gTN)") +def step_assert_invoke(context): + assert context.results.get("invoke_result") == "hello world", ( + f"Expected 'hello world', got {context.results.get('invoke_result')!r}" + ) + + +@when("I execute a tool node with state containing previous messages (gTN)") +def step_execute_tool_with_state(context): + from cleveractors.agents.tool import ToolAgent + from cleveractors.langgraph.nodes import GraphState, Node, NodeConfig, NodeType + + agent = ToolAgent(name="tool_node", config={"tools": ["echo"]}) + + cfg = NodeConfig( + name="tool_node", + type=NodeType.TOOL, + tool="echo", + ) + node = Node(cfg, agents={"tool_node": agent}) + + state = GraphState() + state.messages = [ + {"role": "assistant", "content": json.dumps({"text": "from previous node"})} + ] + + async def _run(): + result = await node._execute_tool(state) + context.results["tool_with_state"] = result + + _run_async(_run(), context=context) + + +@then("the tool should receive the previous output threaded as input (gTN)") +def step_assert_tool_input(context): + result = context.results.get("tool_with_state", {}) + assert isinstance(result, dict), f"Expected dict, got {type(result)}" + assert "messages" in result, f"Result missing 'messages' key: {result}" + msgs = result.get("messages", []) + assert len(msgs) > 0, "Expected at least one message" + msg = msgs[0] + assert "tool_call_id" in msg, f"Message missing tool_call_id: {msg}" + assert msg["tool_call_id"] == "tool_node:echo", ( + f"Expected tool_call_id='tool_node:echo', got {msg['tool_call_id']!r}" + ) + assert msg["content"] == "from previous node", ( + f"Expected content='from previous node', got {msg['content']!r}" + ) + + +@when("I execute a tool node with static_config and previous output (gTN)") +def step_execute_with_static_config(context): + from cleveractors.agents.tool import ToolAgent + from cleveractors.langgraph.nodes import GraphState, Node, NodeConfig, NodeType + + agent = ToolAgent(name="tool_node2", config={"tools": ["math"]}) + + cfg = NodeConfig( + name="tool_node2", + type=NodeType.TOOL, + tool="math", + static_config={"expression": "2+2"}, + ) + node = Node(cfg, agents={"tool_node2": agent}) + + state = GraphState() + state.messages = [ + {"role": "assistant", "content": json.dumps({"from_input": True})} + ] + + async def _run(): + result = await node._execute_tool(state) + context.results["tool_with_static"] = result + + _run_async(_run(), context=context) + + +@then("the tool args should contain both static config and threaded input (gTN)") +def step_assert_static_and_input(context): + result = context.results.get("tool_with_static", {}) + assert isinstance(result, dict), f"Expected dict, got {type(result)}" + assert "messages" in result, f"Result missing 'messages' key: {result}" + msgs = result.get("messages", []) + assert len(msgs) > 0, "Expected at least one message" + msg = msgs[0] + assert msg["tool_call_id"] == "tool_node2:math", ( + f"Expected tool_call_id='tool_node2:math', got {msg['tool_call_id']!r}" + ) + assert "4" in msg["content"], ( + f"Expected content to contain '4' (static_config.expression=2+2 " + f"evaluated by math tool), got {msg['content']!r}" + ) + + +@when("I execute a tool node with no matching agent (gTN)") +def step_execute_no_agent(context): + from cleveractors.langgraph.nodes import GraphState, Node, NodeConfig, NodeType + + cfg = NodeConfig( + name="no_agent_node", + type=NodeType.TOOL, + tool="echo", + ) + node = Node(cfg, agents={}) + + state = GraphState() + state.messages = [{"role": "user", "content": "test"}] + + async def _run(): + result = await node._execute_tool(state) + context.results["tool_no_agent"] = result + + _run_async(_run(), context=context) + + +@then("it should return metadata with tool_error (gTN)") +def step_assert_tool_error(context): + result = context.results.get("tool_no_agent", {}) + assert isinstance(result, dict), f"Expected dict, got {type(result)}" + meta = result.get("metadata", {}) + assert "tool_error" in meta, f"Expected tool_error in metadata, got {meta}" + assert "no agent for tool node" in meta["tool_error"], ( + f"Unexpected error message: {meta['tool_error']}" + ) + + +@when("I execute a tool node without a tool: field (gTN)") +def step_execute_no_tool(context): + from cleveractors.langgraph.nodes import GraphState, Node, NodeConfig, NodeType + + cfg = NodeConfig( + name="no_tool_field", + type=NodeType.TOOL, + ) + node = Node(cfg) + + state = GraphState() + + async def _run(): + result = await node._execute_tool(state) + context.results["tool_no_field"] = result + + _run_async(_run(), context=context) + + +@then("it should return an empty dict (gTN)") +def step_assert_empty_dict(context): + result = context.results.get("tool_no_field", {}) + assert result == {}, f"Expected empty dict, got {result}" + + +@when("I exercise execute() with type TOOL and state (gTN)") +def step_execute_tool_node(context): + from cleveractors.agents.tool import ToolAgent + from cleveractors.langgraph.nodes import GraphState, Node, NodeConfig, NodeType + + agent = ToolAgent(name="exec_tool", config={"tools": ["echo"]}) + + cfg = NodeConfig( + name="exec_tool", + type=NodeType.TOOL, + tool="echo", + ) + node = Node(cfg, agents={"exec_tool": agent}) + + state = GraphState() + state.messages = [{"role": "user", "content": "hello execute"}] + + async def _run(): + result = await node.execute(state) + context.results["execute_tool_result"] = result + + _run_async(_run(), context=context) + + +@then("the result should include the tool execution response (gTN)") +def step_assert_execute_result(context): + result = context.results.get("execute_tool_result", {}) + assert isinstance(result, dict), f"Expected dict, got {type(result)}" + assert "messages" in result, f"Result missing 'messages' key: {result}" + msgs = result.get("messages", []) + assert len(msgs) > 0, "Expected at least one message" + msg = msgs[0] + assert msg["tool_call_id"] == "exec_tool:echo", ( + f"Expected tool_call_id='exec_tool:echo', got {msg['tool_call_id']!r}" + ) + + +@when("I execute a tool node with tools: list but no tool: field (gTN)") +def step_execute_tools_list_no_tool(context): + from cleveractors.langgraph.nodes import GraphState, Node, NodeConfig, NodeType + + cfg = NodeConfig( + name="tools_list_only", + type=NodeType.TOOL, + tools=["echo", "math"], + ) + node = Node(cfg) + + state = GraphState() + + async def _run(): + result = await node._execute_tool(state) + context.results["tools_list_no_tool"] = result + + _run_async(_run(), context=context) + + +@then("it should return metadata with tool_results (backward compatible) (gTN)") +def step_assert_backward_compat(context): + result = context.results.get("tools_list_no_tool", {}) + meta = result.get("metadata", {}) + tool_results = meta.get("tool_results", []) + assert len(tool_results) == 2, f"Expected 2 tool_results, got {tool_results}" + assert tool_results[0] == {"tool": "echo", "result": "Executed echo"}, ( + f"Unexpected first tool_result: {tool_results[0]}" + ) + assert tool_results[1] == {"tool": "math", "result": "Executed math"}, ( + f"Unexpected second tool_result: {tool_results[1]}" + ) + + +@when("I exercise a complete tool node pipeline with auto-created agent (gTN)") +def step_complete_pipeline(context): + from cleveractors.agents.tool import ToolAgent + from cleveractors.langgraph.nodes import GraphState, Node, NodeConfig, NodeType + + agent = ToolAgent(name="pipe_tool", config={"tools": ["echo"]}) + + cfg = NodeConfig( + name="pipe_tool", + type=NodeType.TOOL, + tool="echo", + ) + node = Node(cfg, agents={"pipe_tool": agent}) + + state = GraphState() + state.messages = [ + {"role": "user", "content": json.dumps({"text": "pipeline test"})} + ] + + async def _run(): + result = await node._execute_tool(state) + context.results["pipeline_result"] = result + + _run_async(_run(), context=context) + + +@then("the tool should execute and return correct result (gTN)") +def step_assert_pipeline(context): + result = context.results.get("pipeline_result", {}) + assert isinstance(result, dict), f"Expected dict, got {type(result)}" + assert "messages" in result, f"Result missing 'messages' key: {result}" + msgs = result.get("messages", []) + assert len(msgs) > 0, "Expected at least one message" + msg = msgs[0] + assert msg["tool_call_id"] == "pipe_tool:echo", ( + f"Expected tool_call_id='pipe_tool:echo', got {msg['tool_call_id']!r}" + ) + assert msg["content"] == "pipeline test", ( + f"Expected content='pipeline test', got {msg['content']!r}" + ) + meta = result.get("metadata", {}) + assert "tool_result" in meta, f"Metadata missing tool_result: {meta}" + assert meta["tool_result"] == "pipeline test", ( + f"Expected tool_result='pipeline test', got {meta['tool_result']!r}" + ) + + +@when("I create NodeConfig via create_pure_langgraph with config block (gTN)") +def step_create_pure_langgraph_config(context): + from cleveractors.langgraph.nodes import NodeConfig, NodeType + + node_data = { + "name": "config_tool", + "type": "tool", + "tool": "math", + "config": {"expression": "42 * 2"}, + } + + cfg = NodeConfig( + name=node_data.get("name", "config_tool"), + type=NodeType.TOOL, + tool=node_data.get("tool"), + static_config=node_data.get("config", {}), + ) + context.results["pure_cfg_tool"] = cfg.tool + context.results["pure_cfg_static"] = cfg.static_config + + +@then("the static_config should match the original config block (gTN)") +def step_assert_pure_config(context): + assert context.results.get("pure_cfg_tool") == "math", ( + f"Expected tool='math', got {context.results.get('pure_cfg_tool')!r}" + ) + assert context.results.get("pure_cfg_static") == {"expression": "42 * 2"}, ( + f"Expected static_config={{'expression': '42 * 2'}}, " + f"got {context.results.get('pure_cfg_static')!r}" + ) diff --git a/src/cleveractors/agents/tool.py b/src/cleveractors/agents/tool.py index 99cff8e..4780963 100644 --- a/src/cleveractors/agents/tool.py +++ b/src/cleveractors/agents/tool.py @@ -245,6 +245,35 @@ class ToolAgent(Agent): logger.error("Tool agent %s execution failed: %s", self.name, e) raise ExecutionError(f"Tool execution failed: {str(e)}") from e + async def invoke( + self, + tool_name: str, + tool_args: dict[str, Any], + context: dict[str, Any] | None = None, + ) -> str: + """Programmatic entry point: call a named tool with explicit args. + + Unlike :meth:`process_message`, this method does not parse a message + string. It calls :meth:`_execute_tool` directly and is the intended + entry point for graph tool-node dispatch (Path B) where the caller + already knows the tool name and arguments. + + Args: + tool_name: Name of the tool to invoke. + tool_args: Arguments to pass to the tool. + context: Optional invocation context (merged with persistent context). + Graph-node dispatch (Path B) typically omits this parameter + because node-level context (graph state, metadata) is already + available to the calling node; the tool arguments are passed + through ``tool_args`` directly. + + Returns: + The tool execution result as a string. + """ + effective_context = {**self.context, **(context or {})} + result = await self._execute_tool(tool_name, tool_args, effective_context) + return str(result) + async def _execute_tool( self, tool_name: str, diff --git a/src/cleveractors/langgraph/nodes.py b/src/cleveractors/langgraph/nodes.py index c706098..26381d6 100644 --- a/src/cleveractors/langgraph/nodes.py +++ b/src/cleveractors/langgraph/nodes.py @@ -6,6 +6,7 @@ from __future__ import annotations import asyncio import contextvars +import json import logging from collections.abc import AsyncGenerator from copy import deepcopy @@ -97,6 +98,8 @@ class NodeConfig: # pylint: disable=too-many-instance-attributes type: NodeType agent: Optional[str] = None function: Optional[str] = None + tool: Optional[str] = None + static_config: dict[str, Any] = field(default_factory=dict) tools: List[str] = field(default_factory=list) retry_policy: Optional[dict[str, Any]] = None timeout: Optional[float] = None @@ -208,7 +211,7 @@ class Node: # pylint: disable=too-many-instance-attributes elif self.type == NodeType.FUNCTION: result = await self._execute_function(state) elif self.type == NodeType.TOOL: - result = await self._execute_tool() + result = await self._execute_tool(state) elif self.type == NodeType.CONDITIONAL: result = await self._execute_conditional(state) elif self.type == NodeType.SUBGRAPH: @@ -697,21 +700,71 @@ class Node: # pylint: disable=too-many-instance-attributes return {} - async def _execute_tool(self) -> dict[str, Any]: - """Execute a tool node.""" - if not self.config.tools: + async def _execute_tool(self, state: GraphState) -> dict[str, Any]: + """Execute a tool node with real dispatch via ToolAgent. + + Uses the singular ``tool`` name from the node config (``tool:`` YAML key) + to look up or auto-create a ToolAgent, then calls ``ToolAgent.invoke()`` + with the previous node's output threaded as ``input`` merged with the + node's ``static_config`` block. + + When ``tool:`` is absent but ``tools:`` (plural) is non-empty, falls back + to the original stub behavior for backward compatibility: returns + ``{"metadata": {"tool_results": [...]}}`` with one entry per tool name. + """ + tool_name = self.config.tool + if not tool_name: + # Backward-compat: preserve old stub behavior for tools: list + if self.config.tools: + tool_results = [ + {"tool": t, "result": f"Executed {t}"} for t in self.config.tools + ] + return {"metadata": {"tool_results": tool_results}} return {} - # Execute tools (simplified) - tool_results = [] - for tool_name in self.config.tools: - result = { - "tool": tool_name, - "result": f"Executed {tool_name}", - } - tool_results.append(result) + agent: ToolAgent | None = self.agents.get(self.name) + if agent is None or not isinstance(agent, ToolAgent): + return {"metadata": {"tool_error": f"no agent for tool node '{self.name}'"}} - return {"metadata": {"tool_results": tool_results}} + # Build tool args: static config from YAML merged with the previous + # node's output threaded as ``input``. + last_message = state.messages[-1] if state.messages else None + dynamic_input: Any = None + if last_message is not None: + content = getattr(last_message, "content", "") + if not content and isinstance(last_message, dict): + content = last_message.get("content", "") + if isinstance(content, str): + try: + parsed = json.loads(content) + dynamic_input = ( + parsed if isinstance(parsed, dict) else {"value": parsed} + ) + except (json.JSONDecodeError, TypeError): + dynamic_input = {"raw": content} + else: + dynamic_input = {"value": content} + + tool_args: dict[str, Any] = {} + if dynamic_input is not None: + if isinstance(dynamic_input, dict): + tool_args.update(dynamic_input) + else: + tool_args["input"] = dynamic_input + # Static config takes precedence over threaded dynamic input + tool_args.update(self.config.static_config) + + result_str = await agent.invoke(tool_name, tool_args) + return { + "messages": [ + { + "role": "tool", + "content": result_str, + "tool_call_id": f"{self.name}:{tool_name}", + } + ], + "metadata": {"tool_result": result_str}, + } async def _execute_conditional( # pylint: disable=too-many-branches,too-many-statements self, state: GraphState diff --git a/src/cleveractors/langgraph/pure_graph.py b/src/cleveractors/langgraph/pure_graph.py index 282f078..6d3da1d 100644 --- a/src/cleveractors/langgraph/pure_graph.py +++ b/src/cleveractors/langgraph/pure_graph.py @@ -2332,6 +2332,8 @@ def create_pure_langgraph( type=node_type, agent=node_data.get("agent"), function=node_data.get("function"), + tool=node_data.get("tool"), + static_config=node_data.get("config", {}), tools=node_data.get("tools", []), retry_policy=node_data.get("retry_policy"), timeout=node_data.get("timeout"), diff --git a/src/cleveractors/runtime_dispatch.py b/src/cleveractors/runtime_dispatch.py index 030f5e4..ba9d22a 100644 --- a/src/cleveractors/runtime_dispatch.py +++ b/src/cleveractors/runtime_dispatch.py @@ -218,6 +218,24 @@ async def _execute_llm( ) +# -- helpers ------------------------------------------------------------------ + + +def _ensure_tool_agents( + pg_nodes: dict[str, NodeConfig], + agents: dict[str, Any], + tool_agent_class: type, +) -> None: + """Auto-create a ToolAgent for each ``type: tool`` node with a non-empty ``tool:`` field.""" + for node_id, node_cfg in pg_nodes.items(): + if node_cfg.type == NodeType.TOOL and node_cfg.tool: + if node_id not in agents: + agents[node_id] = tool_agent_class( + name=node_id, + config={"tools": [node_cfg.tool]}, + ) + + # -- single graph ------------------------------------------------------------- @@ -284,6 +302,8 @@ async def _execute_graph( type=node_type, agent=agent_name, function=node_def.get("function"), + tool=node_def.get("tool"), + static_config=node_def.get("config", {}), tools=node_def.get("tools", []), retry_policy=node_def.get("retry_policy"), timeout=node_def.get("timeout"), @@ -366,6 +386,8 @@ async def _execute_graph( f"Failed to create agent '{agent_name}': {exc}" ) from exc + _ensure_tool_agents(pg_nodes, agents, executor.tool_agent_class) + conversation_history: list[dict[str, Any]] | None = None if messages: conversation_history = [ @@ -1149,6 +1171,8 @@ async def _execute_graph_stream( type=node_type, agent=agent_name, function=node_def.get("function"), + tool=node_def.get("tool"), + static_config=node_def.get("config", {}), tools=node_def.get("tools", []), retry_policy=node_def.get("retry_policy"), timeout=node_def.get("timeout"), @@ -1255,6 +1279,8 @@ async def _execute_graph_stream( f"Failed to create agent '{agent_name}'" ) from exc + _ensure_tool_agents(pg_nodes, agents, executor.tool_agent_class) + conversation_history: list[dict[str, Any]] | None = None if messages: conversation_history = [