Implement real type: tool graph node execution by wiring NodeConfig.tool to ToolAgent._execute_tool #75

Closed
opened 2026-07-07 09:12:00 +00:00 by hurui200320 · 0 comments
Member

Metadata

Commit Message: feat(langgraph): implement real type:tool graph node execution via ToolAgent dispatch
Branch: feature/m1-graph-tool-node-execution


Background

The library supports two distinct tool-calling code paths. Only one of them works.

Path A — LLM-driven tool calling (working): An agent node contains an LLMAgent with tools in its config.tools list. The LLM decides when to call a tool; LLMAgent._execute_tool_loop() dispatches each call to a ToolAgent, which looks up the handler in self.builtin_tools and calls it with (tool_args, context). This path was fully implemented in issues #59 and #67.

Path B — Graph tool nodes (stub): A type: tool node in the graph topology represents a deterministic, graph-driven tool invocation — the graph calls the tool, not the LLM. This path hits Node._execute_tool(), which is a non-functional stub:

# langgraph/nodes.py — current implementation
async def _execute_tool(self) -> dict[str, Any]:
    for tool_name in self.config.tools:
        result = {"tool": tool_name, "result": f"Executed {tool_name}"}
        ...
    return {"metadata": {"tool_results": tool_results}}

Two blockers prevent Path B from working.

Blocker 1 — _execute_tool() takes no state argument and performs no dispatch. Every other node handler (_execute_agent, _execute_conditional, etc.) receives GraphState. Without it, the method cannot read the previous node's output to pass as tool input. There is no registry lookup and no handler call — a fabricated string is returned unconditionally for every tool name, including non-existent ones. A type: tool graph node with tool: cleverthis/json-transform today silently returns "Executed cleverthis/json-transform" with no actual execution.

Blocker 2 — Two YAML parsing gaps. First, the spec uses a singular tool: key inside a type: tool graph node (e.g. tool: cleverthis/json-transform). runtime_dispatch._execute_graph() reads node_def.get("tools", []) (plural), so the tool name never reaches NodeConfig. NodeConfig itself only has tools: List[str]; there is no tool: str | None field for the single-tool case. Second, the static configuration block under a tool node (e.g. config: {expression: "$.x", language: jsonpath}) is not captured anywhere. runtime_dispatch stores node_def.get("metadata", {}) but not the config: sub-key, so there is currently no way for _execute_tool() to know the static arguments the YAML author declared.

These gaps mean Path B is entirely inert today. The spec's Minimal Actor Subset table marks type: tool graph nodes as a supported MVP feature, so this is a spec/implementation gap.


Proposed Solution

Path B should be wired to the same ToolAgent._execute_tool(tool_name, tool_args, context) kernel that Path A already uses. That method already handles builtin_tools dispatch (the hook PlatformAwareToolAgent in cleveragents-webapp uses to register platform handlers) and the Python-sandbox path for inline tools. No new handler registry is needed in PureLangGraph.

A — Add tool: str | None and static_config: dict to NodeConfig

@dataclass
class NodeConfig:
    ...
    tool: str | None = None                                        # singular tool name from `tool:` YAML key
    static_config: dict[str, Any] = field(default_factory=dict)   # from `config:` block
    tools: list[str] = field(default_factory=list)                 # existing; preserved

B — Fix runtime_dispatch._execute_graph() to parse tool: and config:

pg_nodes[node_id] = NodeConfig(
    name=node_def["id"],
    type=node_type,
    agent=agent_name,
    tool=node_def.get("tool"),                 # NEW: singular tool name
    static_config=node_def.get("config", {}),  # NEW: static arg block
    tools=node_def.get("tools", []),           # existing
    ...
)

The same fix must be applied to the second _execute_graph call site at line ~1139.

C — Auto-create a ToolAgent for each type: tool node in _execute_graph

After the existing agent-creation loop, add a pass for tool nodes:

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] = ToolAgent(
                name=node_id,
                config={"tools": [node_cfg.tool]},
            )

This reuses the existing ToolAgent class directly. Once issue #73 (tool_agent_class parameter) is resolved, ToolAgent(...) becomes tool_agent_class(...), which is the clean upgrade path — but #73 is not a blocking dependency: the existing PlatformAwareToolAgent monkey-patch in cleveragents-webapp already patches the ToolAgent reference in cleveractors.langgraph.nodes, so the auto-created instance inherits PlatformAwareToolAgent's builtin_tools registration.

D — Add ToolAgent.invoke() as a clean programmatic entry point

process_message() is designed for LLM-driven calling and parses a string message to extract tool name and args. For graph-driven calling we already know both — a direct programmatic entry point avoids the parsing overhead and avoids coupling graph-node dispatch to message-format conventions:

# agents/tool.py
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 process_message(), this method does not parse a message string.
    It calls _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.
    """
    effective_context = {**self.context, **(context or {})}
    result = await self._execute_tool(tool_name, tool_args, effective_context)
    return str(result)

E — Implement real _execute_tool(self, state: GraphState)

async def _execute_tool(self, state: GraphState) -> dict[str, Any]:
    tool_name = self.config.tool
    if not tool_name:
        return {}

    agent = self._agents.get(self.name)  # auto-created ToolAgent (step C)
    if agent is None:
        return {"metadata": {"tool_error": f"no agent for tool node '{self.name}'"}}

    # 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", "")
        try:
            parsed = json.loads(content) if isinstance(content, str) else content
            dynamic_input = parsed if isinstance(parsed, dict) else {"value": parsed}
        except (json.JSONDecodeError, TypeError):
            dynamic_input = {"raw": content}

    tool_args: dict[str, Any] = {**self.config.static_config}
    if dynamic_input is not None:
        tool_args["input"] = dynamic_input

    result_str = await agent.invoke(tool_name, tool_args)
    return {
        "messages": [ToolMessage(content=result_str, tool_call_id=tool_name)],
        "metadata": {"tool_result": result_str},
    }

Note on self._agents: Node.__init__ already receives agents: dict[str, Agent] — verify it is stored as an instance attribute before finalising. If it is not, add self._agents = agents to __init__.

F — Update Node.execute() dispatcher

# Before
elif self.config.type == NodeType.TOOL:
    result = await self._execute_tool()

# After
elif self.config.type == NodeType.TOOL:
    result = await self._execute_tool(state)

Relationship to Issue #73

Issue #73 (tool_agent_class parameter for AgentFactory and create_executor) is complementary but not blocking. After #73 lands, the ToolAgent(...) call in step C above becomes tool_agent_class(...), eliminating the need for the cleveragents-webapp monkey-patch entirely. This issue can ship before or after #73 without conflict; the two changes compose cleanly.


Acceptance Criteria

  • NodeConfig has tool: str | None and static_config: dict[str, Any] fields.
  • runtime_dispatch._execute_graph() (both call sites) reads node_def.get("tool") and node_def.get("config", {}) and populates the new NodeConfig fields.
  • A ToolAgent (or the active ToolAgent subclass) is auto-created for each type: tool node with a non-empty tool: field and stored in the agents dict under the node's ID.
  • ToolAgent.invoke(tool_name, tool_args, context) exists as a public method that calls _execute_tool() directly without message parsing.
  • Node._execute_tool(state) dispatches to agent.invoke() with the previous node's output threaded as input merged with the node's static_config.
  • Node._execute_tool(state) returns a structured {"metadata": {"tool_error": ...}} dict (no exception raised) when no agent is found or the tool name is empty.
  • Node.execute() passes state to _execute_tool(state).
  • Default behaviour (existing type: tool nodes with tools: [...] and no tool: field) is identical to the current behaviour — no regression.
  • All new fields and methods are fully type-annotated; Pyright strict mode produces no new errors.
  • BDD unit tests cover: tool: key parsed from raw node config; config: block stored as static_config; ToolAgent.invoke() calls _execute_tool() with correct args; Node._execute_tool(state) threads previous-node output as input; structured error returned when no agent found.
  • CHANGELOG.md updated with one entry for this commit.
  • cleveragents-webapp team notified (comment on this issue) once PR is merged with the release tag or commit SHA.

Subtasks

  • Confirm that Node.__init__ already stores the agents dict as an instance attribute (check before coding — avoids an unplanned change).
  • Add tool: str | None and static_config: dict[str, Any] to NodeConfig; update all construction sites in runtime_dispatch and pure_graph.
  • Add ToolAgent.invoke() public method.
  • Add the ToolAgent auto-creation pass in _execute_graph() (both call sites).
  • Implement Node._execute_tool(state) with dispatch, input threading, and error path.
  • Update Node.execute() dispatcher to pass state.
  • Write BDD unit tests for all acceptance criteria above.
  • Update CHANGELOG.md.
  • Notify cleveragents-webapp team via issue comment once PR is merged.

Definition of Done

  • type: tool graph nodes with a tool: field dispatch to the named tool handler via ToolAgent.invoke(), with the previous node's output threaded as input and the node's config: block merged in as static arguments.
  • The ToolAgent._execute_tool() kernel (and therefore PlatformAwareToolAgent's builtin_tools registration) is exercised by Path B, mirroring Path A.
  • All cleveractors-core quality gates pass (nox suite green, coverage ≥ threshold, Pyright strict clean).
  • PR merged to master.
  • cleveragents-webapp team notified with the new release tag or commit SHA.
## Metadata **Commit Message:** `feat(langgraph): implement real type:tool graph node execution via ToolAgent dispatch` **Branch:** `feature/m1-graph-tool-node-execution` --- ## Background The library supports two distinct tool-calling code paths. Only one of them works. **Path A — LLM-driven tool calling (working):** An `agent` node contains an `LLMAgent` with tools in its `config.tools` list. The LLM decides when to call a tool; `LLMAgent._execute_tool_loop()` dispatches each call to a `ToolAgent`, which looks up the handler in `self.builtin_tools` and calls it with `(tool_args, context)`. This path was fully implemented in issues #59 and #67. **Path B — Graph tool nodes (stub):** A `type: tool` node in the graph topology represents a deterministic, graph-driven tool invocation — the graph calls the tool, not the LLM. This path hits `Node._execute_tool()`, which is a non-functional stub: ```python # langgraph/nodes.py — current implementation async def _execute_tool(self) -> dict[str, Any]: for tool_name in self.config.tools: result = {"tool": tool_name, "result": f"Executed {tool_name}"} ... return {"metadata": {"tool_results": tool_results}} ``` Two blockers prevent Path B from working. **Blocker 1 — `_execute_tool()` takes no `state` argument and performs no dispatch.** Every other node handler (`_execute_agent`, `_execute_conditional`, etc.) receives `GraphState`. Without it, the method cannot read the previous node's output to pass as tool input. There is no registry lookup and no handler call — a fabricated string is returned unconditionally for every tool name, including non-existent ones. A `type: tool` graph node with `tool: cleverthis/json-transform` today silently returns `"Executed cleverthis/json-transform"` with no actual execution. **Blocker 2 — Two YAML parsing gaps.** First, the spec uses a singular `tool:` key inside a `type: tool` graph node (e.g. `tool: cleverthis/json-transform`). `runtime_dispatch._execute_graph()` reads `node_def.get("tools", [])` (plural), so the tool name never reaches `NodeConfig`. `NodeConfig` itself only has `tools: List[str]`; there is no `tool: str | None` field for the single-tool case. Second, the static configuration block under a tool node (e.g. `config: {expression: "$.x", language: jsonpath}`) is not captured anywhere. `runtime_dispatch` stores `node_def.get("metadata", {})` but not the `config:` sub-key, so there is currently no way for `_execute_tool()` to know the static arguments the YAML author declared. These gaps mean Path B is entirely inert today. The spec's Minimal Actor Subset table marks `type: tool` graph nodes as a supported MVP feature, so this is a spec/implementation gap. --- ## Proposed Solution Path B should be wired to the same `ToolAgent._execute_tool(tool_name, tool_args, context)` kernel that Path A already uses. That method already handles `builtin_tools` dispatch (the hook `PlatformAwareToolAgent` in `cleveragents-webapp` uses to register platform handlers) and the Python-sandbox path for inline tools. No new handler registry is needed in `PureLangGraph`. ### A — Add `tool: str | None` and `static_config: dict` to `NodeConfig` ```python @dataclass class NodeConfig: ... tool: str | None = None # singular tool name from `tool:` YAML key static_config: dict[str, Any] = field(default_factory=dict) # from `config:` block tools: list[str] = field(default_factory=list) # existing; preserved ``` ### B — Fix `runtime_dispatch._execute_graph()` to parse `tool:` and `config:` ```python pg_nodes[node_id] = NodeConfig( name=node_def["id"], type=node_type, agent=agent_name, tool=node_def.get("tool"), # NEW: singular tool name static_config=node_def.get("config", {}), # NEW: static arg block tools=node_def.get("tools", []), # existing ... ) ``` The same fix must be applied to the second `_execute_graph` call site at line ~1139. ### C — Auto-create a `ToolAgent` for each `type: tool` node in `_execute_graph` After the existing agent-creation loop, add a pass for tool nodes: ```python 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] = ToolAgent( name=node_id, config={"tools": [node_cfg.tool]}, ) ``` This reuses the existing `ToolAgent` class directly. Once issue #73 (`tool_agent_class` parameter) is resolved, `ToolAgent(...)` becomes `tool_agent_class(...)`, which is the clean upgrade path — but #73 is **not a blocking dependency**: the existing `PlatformAwareToolAgent` monkey-patch in `cleveragents-webapp` already patches the `ToolAgent` reference in `cleveractors.langgraph.nodes`, so the auto-created instance inherits `PlatformAwareToolAgent`'s `builtin_tools` registration. ### D — Add `ToolAgent.invoke()` as a clean programmatic entry point `process_message()` is designed for LLM-driven calling and parses a string message to extract tool name and args. For graph-driven calling we already know both — a direct programmatic entry point avoids the parsing overhead and avoids coupling graph-node dispatch to message-format conventions: ```python # agents/tool.py 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 process_message(), this method does not parse a message string. It calls _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. """ effective_context = {**self.context, **(context or {})} result = await self._execute_tool(tool_name, tool_args, effective_context) return str(result) ``` ### E — Implement real `_execute_tool(self, state: GraphState)` ```python async def _execute_tool(self, state: GraphState) -> dict[str, Any]: tool_name = self.config.tool if not tool_name: return {} agent = self._agents.get(self.name) # auto-created ToolAgent (step C) if agent is None: return {"metadata": {"tool_error": f"no agent for tool node '{self.name}'"}} # 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", "") try: parsed = json.loads(content) if isinstance(content, str) else content dynamic_input = parsed if isinstance(parsed, dict) else {"value": parsed} except (json.JSONDecodeError, TypeError): dynamic_input = {"raw": content} tool_args: dict[str, Any] = {**self.config.static_config} if dynamic_input is not None: tool_args["input"] = dynamic_input result_str = await agent.invoke(tool_name, tool_args) return { "messages": [ToolMessage(content=result_str, tool_call_id=tool_name)], "metadata": {"tool_result": result_str}, } ``` > **Note on `self._agents`:** `Node.__init__` already receives `agents: dict[str, Agent]` — verify it is stored as an instance attribute before finalising. If it is not, add `self._agents = agents` to `__init__`. ### F — Update `Node.execute()` dispatcher ```python # Before elif self.config.type == NodeType.TOOL: result = await self._execute_tool() # After elif self.config.type == NodeType.TOOL: result = await self._execute_tool(state) ``` --- ## Relationship to Issue #73 Issue #73 (`tool_agent_class` parameter for `AgentFactory` and `create_executor`) is **complementary but not blocking**. After #73 lands, the `ToolAgent(...)` call in step C above becomes `tool_agent_class(...)`, eliminating the need for the `cleveragents-webapp` monkey-patch entirely. This issue can ship before or after #73 without conflict; the two changes compose cleanly. --- ## Acceptance Criteria - [ ] `NodeConfig` has `tool: str | None` and `static_config: dict[str, Any]` fields. - [ ] `runtime_dispatch._execute_graph()` (both call sites) reads `node_def.get("tool")` and `node_def.get("config", {})` and populates the new `NodeConfig` fields. - [ ] A `ToolAgent` (or the active `ToolAgent` subclass) is auto-created for each `type: tool` node with a non-empty `tool:` field and stored in the `agents` dict under the node's ID. - [ ] `ToolAgent.invoke(tool_name, tool_args, context)` exists as a public method that calls `_execute_tool()` directly without message parsing. - [ ] `Node._execute_tool(state)` dispatches to `agent.invoke()` with the previous node's output threaded as `input` merged with the node's `static_config`. - [ ] `Node._execute_tool(state)` returns a structured `{"metadata": {"tool_error": ...}}` dict (no exception raised) when no agent is found or the tool name is empty. - [ ] `Node.execute()` passes `state` to `_execute_tool(state)`. - [ ] Default behaviour (existing `type: tool` nodes with `tools: [...]` and no `tool:` field) is identical to the current behaviour — no regression. - [ ] All new fields and methods are fully type-annotated; Pyright strict mode produces no new errors. - [ ] BDD unit tests cover: `tool:` key parsed from raw node config; `config:` block stored as `static_config`; `ToolAgent.invoke()` calls `_execute_tool()` with correct args; `Node._execute_tool(state)` threads previous-node output as `input`; structured error returned when no agent found. - [ ] `CHANGELOG.md` updated with one entry for this commit. - [ ] `cleveragents-webapp` team notified (comment on this issue) once PR is merged with the release tag or commit SHA. --- ## Subtasks - [ ] Confirm that `Node.__init__` already stores the `agents` dict as an instance attribute (check before coding — avoids an unplanned change). - [ ] Add `tool: str | None` and `static_config: dict[str, Any]` to `NodeConfig`; update all construction sites in `runtime_dispatch` and `pure_graph`. - [ ] Add `ToolAgent.invoke()` public method. - [ ] Add the `ToolAgent` auto-creation pass in `_execute_graph()` (both call sites). - [ ] Implement `Node._execute_tool(state)` with dispatch, input threading, and error path. - [ ] Update `Node.execute()` dispatcher to pass `state`. - [ ] Write BDD unit tests for all acceptance criteria above. - [ ] Update `CHANGELOG.md`. - [ ] Notify `cleveragents-webapp` team via issue comment once PR is merged. --- ## Definition of Done - `type: tool` graph nodes with a `tool:` field dispatch to the named tool handler via `ToolAgent.invoke()`, with the previous node's output threaded as `input` and the node's `config:` block merged in as static arguments. - The `ToolAgent._execute_tool()` kernel (and therefore `PlatformAwareToolAgent`'s `builtin_tools` registration) is exercised by Path B, mirroring Path A. - All `cleveractors-core` quality gates pass (`nox` suite green, coverage ≥ threshold, Pyright strict clean). - PR merged to `master`. - `cleveragents-webapp` team notified with the new release tag or commit SHA.
CoreRasurae added this to the v2.1.0 milestone 2026-07-08 21:25:55 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Reference
cleveragents/cleveractors-core#75
No description provided.