Skip to content

Commit 43a682b

Browse files
committed
feat(stepflow): execute the wrapped component for tool-mode tools
Tool-mode components built by component_tool returned a synthetic "Tool ... executed with inputs" payload instead of running the wrapped component; only a hard-coded calculator actually executed, so agent flows appeared to succeed while their tools never ran real component logic. Resolve the tool's component blob in ToolWrapperInputHandler.prepare (where the Stepflow context is available), reshape the raw component definition into the executor's enhanced blob shape, pre-compile it via CustomCodeExecutor, and wire an async tool_func that runs the real component through _execute_compiled_component on each invocation. The calculator stays as a self-contained fast-path.
1 parent 7ab1e8b commit 43a682b

5 files changed

Lines changed: 466 additions & 196 deletions

File tree

src/langflow-stepflow/src/langflow_stepflow/worker/handlers/tool_wrapper.py

Lines changed: 105 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Input handler for tool wrapper deserialization.
22
33
Converts serialized tool wrapper dicts into callable LangChain StructuredTool
4-
objects.
4+
objects whose invocation executes the wrapped Langflow component.
55
"""
66

77
from __future__ import annotations
@@ -57,84 +57,127 @@ def _eval_expr(node: ast.AST) -> float:
5757
raise TypeError(f"Unsupported operation or expression type: {type(node).__name__}")
5858

5959

60-
def _create_tool_from_wrapper(tool_wrapper: dict[str, Any]) -> Any:
61-
"""Create a LangChain StructuredTool from a tool wrapper."""
60+
def _build_blob_data(component_code: dict[str, Any], component_type: str) -> dict[str, Any]:
61+
"""Reshape a raw component definition into the executor's enhanced blob shape.
62+
63+
``component_tool`` stores the raw component node (``node["data"]["node"]``) as
64+
the tool blob: the Python source lives under ``template.code.value`` and the
65+
declared outputs under ``outputs``. ``CustomCodeExecutor._compile_component``
66+
instead expects ``code`` and the ``template`` (sans code) at the top level, so
67+
reshape here, mirroring ``NodeProcessor._prepare_udf_blob``. The tool runs the
68+
component's first declared output.
69+
"""
70+
template = component_code.get("template", {})
71+
code = template.get("code", {}).get("value", "")
72+
outputs = component_code.get("outputs", [])
73+
selected_output = outputs[0].get("name") if outputs else None
74+
prepared_template = {name: cfg for name, cfg in template.items() if name != "code"}
75+
return {
76+
"code": code,
77+
"template": prepared_template,
78+
"component_type": component_type,
79+
"outputs": outputs,
80+
"selected_output": selected_output,
81+
"base_classes": component_code.get("base_classes", []),
82+
"display_name": component_code.get("display_name", component_type),
83+
"description": component_code.get("description", ""),
84+
"documentation": component_code.get("documentation", ""),
85+
"metadata": component_code.get("metadata", {}),
86+
"field_order": component_code.get("field_order", []),
87+
"icon": component_code.get("icon", ""),
88+
}
89+
90+
91+
def _build_input_schema(tool_input_schema: dict[str, Any]) -> Any:
92+
"""Build the StructuredTool args schema from the tool's input schema."""
93+
from pydantic import BaseModel, create_model
94+
95+
properties = tool_input_schema.get("properties", {})
96+
field_definitions: dict[str, tuple[type, Any]] = {}
97+
for field_name, field_def in properties.items():
98+
field_definitions[field_name] = (str, field_def.get("default", ""))
99+
100+
if field_definitions:
101+
return create_model("ToolInputSchema", **field_definitions) # type: ignore[call-overload]
102+
103+
class EmptySchema(BaseModel):
104+
pass
105+
106+
return EmptySchema
107+
108+
109+
async def _create_tool_from_wrapper(tool_wrapper: dict[str, Any], context: Any = None) -> Any:
110+
"""Create a LangChain StructuredTool from a tool wrapper.
111+
112+
The returned tool executes the wrapped Langflow component on invocation. The
113+
component is compiled once here, where the Stepflow ``context`` is available to
114+
fetch its code blob, then executed without context on each call, matching the
115+
custom-code executor's pre-compile-then-execute design.
116+
"""
62117
try:
63118
from langchain_core.tools import StructuredTool
64-
from pydantic import BaseModel, create_model
65119

66120
tool_metadata = tool_wrapper.get("tool_metadata", {})
67-
tool_input_schema = tool_wrapper.get("tool_input_schema", {})
68121
static_inputs = tool_wrapper.get("static_inputs", {})
69122
component_type = tool_wrapper.get("component_type", "unknown")
70123
session_id = tool_wrapper.get("session_id", "default_session")
71124

125+
input_schema = _build_input_schema(tool_wrapper.get("tool_input_schema", {}))
126+
127+
# Calculator fast-path: the CalculatorComponent's expression evaluator is
128+
# pure and self-contained, so run it directly without compiling a component.
129+
if tool_metadata.get("name") == "evaluate_expression":
130+
131+
def calculator_func(**kwargs) -> dict[str, Any]:
132+
return {"result": _execute_calculator_tool(kwargs.get("expression", ""))}
133+
134+
return StructuredTool.from_function(
135+
func=calculator_func,
136+
name=tool_metadata.get("name", "unknown_tool"),
137+
description=tool_metadata.get("description", ""),
138+
args_schema=input_schema,
139+
)
140+
72141
component_code = tool_wrapper.get("component_code")
73142
code_blob_id = tool_wrapper.get("code_blob_id")
74-
75143
if component_code is None and code_blob_id is None:
76144
raise ValueError("Tool wrapper missing both component_code and code_blob_id")
77145

78-
properties = tool_input_schema.get("properties", {})
79-
80-
field_definitions: dict[str, tuple[type, Any]] = {}
81-
for field_name, field_def in properties.items():
82-
field_type: type = str
83-
default_value = field_def.get("default", "")
84-
field_definitions[field_name] = (field_type, default_value)
85-
86-
input_schema: type[BaseModel]
87-
if field_definitions:
88-
input_schema = create_model("ToolInputSchema", **field_definitions) # type: ignore[call-overload]
146+
# Resolve the raw component definition: component_tool stores it as a blob
147+
# and references it by id; an inline component_code dict is also accepted.
148+
if code_blob_id is not None:
149+
if context is None:
150+
raise ValueError("code_blob_id requires a Stepflow context to fetch the component blob")
151+
raw_component = await context.get_blob(code_blob_id)
89152
else:
153+
raw_component = component_code
154+
if not isinstance(raw_component, dict):
155+
raise TypeError(f"Component code must be a component definition dict, got {type(raw_component).__name__}")
90156

91-
class EmptySchema(BaseModel):
92-
pass
157+
# Imported lazily: custom_code_executor imports this handler, so a top-level
158+
# import would be circular.
159+
from ..custom_code_executor import CustomCodeExecutor
93160

94-
input_schema = EmptySchema
161+
executor = CustomCodeExecutor()
162+
blob_data = _build_blob_data(raw_component, component_type)
163+
compiled_component = await executor._compile_component(blob_data, code_blob_id)
95164

96-
def tool_func(**kwargs) -> dict[str, Any]:
97-
"""Execute the tool by running the component."""
165+
async def tool_func(**kwargs) -> Any:
166+
"""Execute the wrapped component with the tool's merged inputs."""
98167
try:
99-
if tool_metadata.get("name") == "evaluate_expression" and "expression" in kwargs:
100-
result = _execute_calculator_tool(kwargs["expression"])
101-
return {"result": result}
102-
103-
merged_inputs = {
104-
**static_inputs,
105-
**kwargs,
106-
"session_id": session_id,
107-
}
108-
109-
tool_name = tool_metadata.get("name", "unknown")
110-
result_data = {
111-
"result": (f"Tool {tool_name} executed with inputs: {merged_inputs}"),
112-
"component_type": component_type,
113-
"inputs": merged_inputs,
114-
"status": "tool_wrapper_execution",
115-
}
116-
117-
if code_blob_id:
118-
result_data["code_blob_id"] = code_blob_id
119-
elif component_code:
120-
result_data["has_component_code"] = True
121-
122-
return result_data
123-
124-
except Exception as e:
125-
return {
126-
"error": f"Tool execution failed: {str(e)}",
127-
"component_type": component_type,
128-
}
168+
runtime_inputs = {**static_inputs, **kwargs, "session_id": session_id}
169+
return await executor._execute_compiled_component(compiled_component, runtime_inputs)
170+
except Exception as e: # noqa: BLE001 - surface a tool-shaped error to the agent
171+
return {"error": f"Tool execution failed: {str(e)}", "component_type": component_type}
129172

130173
return StructuredTool.from_function(
131-
func=tool_func,
174+
coroutine=tool_func,
132175
name=tool_metadata.get("name", "unknown_tool"),
133176
description=tool_metadata.get("description", ""),
134177
args_schema=input_schema,
135178
)
136179

137-
except Exception as e:
180+
except Exception as e: # noqa: BLE001 - any wrapper-build failure degrades to a failed tool
138181

139182
class FailedToolWrapper:
140183
def __init__(self, tool_wrapper, error):
@@ -167,11 +210,14 @@ async def prepare(self, fields: dict[str, tuple[Any, dict[str, Any]]], context:
167210

168211
for key, (value, _template_field) in fields.items():
169212
if isinstance(value, dict) and value.get("__tool_wrapper__"):
170-
result[key] = _create_tool_from_wrapper(value)
213+
result[key] = await _create_tool_from_wrapper(value, context)
171214
elif isinstance(value, list):
172-
result[key] = [
173-
_create_tool_from_wrapper(item) if isinstance(item, dict) and item.get("__tool_wrapper__") else item
174-
for item in value
175-
]
215+
resolved: list[Any] = []
216+
for item in value:
217+
if isinstance(item, dict) and item.get("__tool_wrapper__"):
218+
resolved.append(await _create_tool_from_wrapper(item, context))
219+
else:
220+
resolved.append(item)
221+
result[key] = resolved
176222

177223
return result
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
"""Shared helpers for tool-wrapper tests.
2+
3+
A real (non-mock) in-memory Stepflow context and a real, executable component
4+
definition in the shape ``component_tool`` stores as the tool blob.
5+
"""
6+
7+
from typing import Any
8+
9+
10+
class InMemoryContext:
11+
"""Minimal in-memory StepflowContext stand-in (a real object, not a mock).
12+
13+
``put_blob`` stores and returns a stable id; ``get_blob`` reads it back.
14+
Enough for the handler to resolve a tool's component code the way the worker
15+
does at runtime.
16+
"""
17+
18+
def __init__(self) -> None:
19+
self._blobs: dict[str, Any] = {}
20+
self._counter = 0
21+
22+
async def put_blob(self, data: Any, *_args: Any, **_kwargs: Any) -> str:
23+
self._counter += 1
24+
blob_id = f"blob_{self._counter}"
25+
self._blobs[blob_id] = data
26+
return blob_id
27+
28+
async def get_blob(self, blob_id: str) -> Any:
29+
return self._blobs[blob_id]
30+
31+
32+
SIMPLE_COMPONENT_CODE = """
33+
from langflow.custom.custom_component.component import Component
34+
from langflow.io import MessageTextInput, Output
35+
from langflow.schema.message import Message
36+
37+
38+
class SimpleTestComponent(Component):
39+
display_name = "Simple Test"
40+
description = "A simple test component"
41+
42+
inputs = [
43+
MessageTextInput(name="text_input", display_name="Text Input", info="Text input for testing")
44+
]
45+
46+
outputs = [
47+
Output(display_name="Output", name="result", method="process_text")
48+
]
49+
50+
async def process_text(self) -> Message:
51+
input_text = self.text_input or "No input provided"
52+
return Message(text=f"Processed: {input_text}", sender="SimpleTestComponent")
53+
"""
54+
55+
56+
def simple_component_node_info() -> dict[str, Any]:
57+
"""Raw component definition as stored in the tool blob by ``component_tool``.
58+
59+
This is ``node["data"]["node"]``: the Python source under ``template.code.value``
60+
plus the declared outputs.
61+
"""
62+
return {
63+
"template": {
64+
"code": {"value": SIMPLE_COMPONENT_CODE},
65+
"text_input": {
66+
"type": "str",
67+
"value": "",
68+
"info": "Text input for testing",
69+
"required": False,
70+
"tool_mode": True,
71+
},
72+
},
73+
"outputs": [{"name": "result", "method": "process_text", "types": ["Message"]}],
74+
"display_name": "Simple Test",
75+
"description": "A simple test component",
76+
"base_classes": ["Message"],
77+
}
78+
79+
80+
def make_real_tool_wrapper(
81+
blob_id: str,
82+
*,
83+
name: str = "simple_test",
84+
description: str = "Runs the simple test component",
85+
static_inputs: dict[str, Any] | None = None,
86+
properties: dict[str, Any] | None = None,
87+
) -> dict[str, Any]:
88+
"""Build a tool wrapper backed by a real component blob.
89+
90+
``properties`` defaults to exposing ``text_input`` as the single tool param;
91+
pass ``{}`` to expose no tool params (so ``static_inputs`` drive the component).
92+
"""
93+
if properties is None:
94+
properties = {"text_input": {"type": "string", "default": ""}}
95+
return {
96+
"__tool_wrapper__": True,
97+
"code_blob_id": blob_id,
98+
"static_inputs": static_inputs or {},
99+
"component_type": "SimpleTestComponent",
100+
"tool_metadata": {"name": name, "description": description},
101+
"tool_input_schema": {"properties": properties},
102+
"session_id": "test_session",
103+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"""Integration test: tool wrapper producer -> consumer roundtrip.
2+
3+
``component_tool_executor`` (the producer) stores a tool-mode component as a blob
4+
and emits a tool wrapper; ``ToolWrapperInputHandler`` (the consumer) resolves that
5+
same blob and builds a tool that executes the real component. This proves the
6+
blob-shape contract between the two ends end-to-end, with a real component but no
7+
LLM (a full agent-driven tool call needs a live model and is out of scope here).
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import json
13+
14+
import pytest
15+
from tests.helpers.tool_components import InMemoryContext, simple_component_node_info
16+
17+
from langflow_stepflow.worker.component_tool import component_tool_executor
18+
from langflow_stepflow.worker.handlers.tool_wrapper import ToolWrapperInputHandler
19+
20+
pytestmark = pytest.mark.integration
21+
22+
23+
@pytest.mark.asyncio
24+
async def test_component_tool_to_execution_roundtrip():
25+
context = InMemoryContext()
26+
node_info = simple_component_node_info()
27+
28+
# Producer: component_tool stores the component blob and builds the wrapper.
29+
produced = await component_tool_executor(
30+
{
31+
"code": node_info,
32+
"inputs": {},
33+
"component_type": "SimpleTestComponent",
34+
"session_id": "session_roundtrip",
35+
},
36+
context,
37+
)
38+
wrapper = produced["result"]
39+
assert wrapper["__tool_wrapper__"] is True
40+
assert "code_blob_id" in wrapper
41+
# text_input is tool_mode=True, so the producer exposes it as a tool param.
42+
assert "text_input" in wrapper["tool_input_schema"]["properties"]
43+
44+
# Consumer: the handler resolves the same blob and builds an executing tool.
45+
handler = ToolWrapperInputHandler()
46+
prepared = await handler.prepare({"tools": (wrapper, {})}, context)
47+
tool = prepared["tools"]
48+
49+
output = await tool.ainvoke({"text_input": "roundtrip"})
50+
51+
assert "Processed: roundtrip" in json.dumps(output, default=str)

0 commit comments

Comments
 (0)