feat(langgraph): implement real type:tool graph node execution via ToolAgent dispatch #81

Merged
CoreRasurae merged 1 commits from feature/m1-graph-tool-node-execution into master 2026-07-09 14:31:56 +00:00
7 changed files with 603 additions and 13 deletions
+10
View File
@@ -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.
@@ -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)
@@ -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}"
)
+29
View File
@@ -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,
+66 -13
View File
@@ -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
+2
View File
@@ -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"),
+26
View File
@@ -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 = [