Skip to content

Commit 724567b

Browse files
SonAIengineclaude
andcommitted
feat: create_gateway_tools() — LangChain tool 2개(search_tools + call_tool)로 대규모 tool set 축약
LLM에 tool 전체를 바인딩하는 대신, search_tools(BM25+Graph 검색) → call_tool(실행) 2-hop 패턴으로 토큰 88% 절감 (47 tools 기준 ~3,900 → ~475 tokens/turn). - search_tools: 자연어 쿼리로 tool 검색, 파라미터 정보 포함 반환 - call_tool: arguments를 dict로 받아 LLM이 JSON string 생성 불필요 - qwen3.5:4b (4B 모델)로 e2e 5/5 (100%) 통과 - xgen-workflow 등 외부 LangChain agent에 gateway tool만 넘기면 즉시 적용 가능 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f3f5419 commit 724567b

6 files changed

Lines changed: 1155 additions & 0 deletions

File tree

graph_tool_call/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"DuplicatePair",
1010
"GraphAnalysisReport",
1111
"GraphToolkit",
12+
"create_gateway_tools",
1213
"MCPAnnotations",
1314
"MergeStrategy",
1415
"NodeType",
@@ -38,6 +39,7 @@
3839
"ToolCallPolicy": ("graph_tool_call.assist.policy", "ToolCallPolicy"),
3940
"RetrievalResult": ("graph_tool_call.retrieval.engine", "RetrievalResult"),
4041
"SearchMode": ("graph_tool_call.retrieval.engine", "SearchMode"),
42+
"create_gateway_tools": ("graph_tool_call.langchain.gateway", "create_gateway_tools"),
4143
"filter_tools": ("graph_tool_call.toolkit", "filter_tools"),
4244
"GraphToolkit": ("graph_tool_call.toolkit", "GraphToolkit"),
4345
}

graph_tool_call/langchain/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"GraphToolRetriever",
55
"GraphToolkit",
66
"create_agent",
7+
"create_gateway_tools",
78
"filter_tools",
89
"langchain_tools_to_schemas",
910
"tool_schema_to_openai_function",
@@ -13,6 +14,7 @@
1314
"GraphToolRetriever": ("graph_tool_call.langchain.retriever", "GraphToolRetriever"),
1415
"GraphToolkit": ("graph_tool_call.toolkit", "GraphToolkit"),
1516
"create_agent": ("graph_tool_call.langchain.agent", "create_agent"),
17+
"create_gateway_tools": ("graph_tool_call.langchain.gateway", "create_gateway_tools"),
1618
"filter_tools": ("graph_tool_call.toolkit", "filter_tools"),
1719
"langchain_tools_to_schemas": ("graph_tool_call.langchain.tools", "langchain_tools_to_schemas"),
1820
"tool_schema_to_openai_function": (
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
"""LangChain gateway tools — search & call pattern.
2+
3+
Converts a large tool list into 2 meta-tools that an LLM agent can use:
4+
5+
- ``search_tools``: BM25 + Graph search over tool names/descriptions
6+
- ``call_tool``: Execute a tool by name with arguments
7+
8+
Usage::
9+
10+
from graph_tool_call.langchain import create_gateway_tools
11+
12+
# Original tools (50~500+)
13+
all_tools = [tool1, tool2, ..., tool200]
14+
15+
# Convert to 2 gateway meta-tools
16+
gateway_tools = create_gateway_tools(all_tools, top_k=10)
17+
18+
# Use with any LangChain agent
19+
agent = create_react_agent(model=llm, tools=gateway_tools)
20+
"""
21+
22+
from __future__ import annotations
23+
24+
import json
25+
import logging
26+
from typing import Any
27+
28+
logger = logging.getLogger("graph-tool-call.langchain.gateway")
29+
30+
31+
def _extract_parameters_info(tool: Any) -> list[dict[str, Any]] | None:
32+
"""Extract parameter info from a LangChain tool for search results."""
33+
# LangChain BaseTool with args_schema (Pydantic model)
34+
if hasattr(tool, "args_schema") and tool.args_schema is not None:
35+
try:
36+
schema = tool.args_schema.model_json_schema()
37+
props = schema.get("properties", {})
38+
required = set(schema.get("required", []))
39+
params = []
40+
for name, info in props.items():
41+
param = {
42+
"name": name,
43+
"type": info.get("type", "string"),
44+
"required": name in required,
45+
}
46+
if "description" in info:
47+
param["description"] = info["description"]
48+
params.append(param)
49+
return params if params else None
50+
except Exception:
51+
pass
52+
53+
# LangChain tool with .args property (dict schema)
54+
if hasattr(tool, "args") and isinstance(tool.args, dict):
55+
try:
56+
params = []
57+
for name, info in tool.args.items():
58+
param = {"name": name, "type": info.get("type", "string")}
59+
if "description" in info:
60+
param["description"] = info["description"]
61+
params.append(param)
62+
return params if params else None
63+
except Exception:
64+
pass
65+
66+
return None
67+
68+
69+
def create_gateway_tools(
70+
tools: list[Any],
71+
*,
72+
top_k: int = 10,
73+
graph: Any | None = None,
74+
) -> list[Any]:
75+
"""Create 2 gateway meta-tools from a list of LangChain tools.
76+
77+
Parameters
78+
----------
79+
tools:
80+
Full list of tools (LangChain ``BaseTool``, callables, etc.).
81+
top_k:
82+
Default number of results for ``search_tools`` (default: 10).
83+
graph:
84+
Optional pre-built ``ToolGraph``. If *None*, one is built from *tools*.
85+
86+
Returns
87+
-------
88+
list
89+
Two LangChain tools: ``[search_tools, call_tool]``.
90+
"""
91+
from langchain_core.tools import tool as langchain_tool
92+
93+
from graph_tool_call.langchain.toolkit import GraphToolkit, _extract_name
94+
95+
# Build toolkit (reuses ToolGraph internally)
96+
toolkit = GraphToolkit(tools=tools, top_k=top_k, graph=graph)
97+
98+
# Build tool map for call_tool dispatch
99+
tool_map: dict[str, Any] = {}
100+
for t in tools:
101+
name = _extract_name(t)
102+
if name:
103+
tool_map[name] = t
104+
105+
total = len(tool_map)
106+
call_history: list[str] = []
107+
108+
@langchain_tool
109+
def search_tools(query: str, top_k: int | None = None) -> str:
110+
"""Search available tools by natural language query.
111+
112+
Use this FIRST to find which tools are available for the task.
113+
Returns tool names, descriptions, and required parameters.
114+
115+
Args:
116+
query: Natural language search query (e.g. "cancel order", "send email")
117+
top_k: Max number of results (optional)
118+
"""
119+
k = top_k if top_k is not None else toolkit._top_k
120+
results = toolkit.get_tools(query, top_k=k)
121+
122+
matched = []
123+
for t in results:
124+
name = _extract_name(t)
125+
desc = ""
126+
if hasattr(t, "description"):
127+
desc = t.description or ""
128+
elif isinstance(t, dict):
129+
desc = t.get("description", "")
130+
entry: dict[str, Any] = {
131+
"name": name,
132+
"description": desc[:200],
133+
}
134+
params = _extract_parameters_info(t)
135+
if params:
136+
entry["parameters"] = params
137+
matched.append(entry)
138+
139+
output = {
140+
"query": query,
141+
"matched": len(matched),
142+
"total_tools": total,
143+
"tools": matched,
144+
"hint": (
145+
"Use call_tool to execute a tool. "
146+
"Pass tool_name and arguments as a dict matching the parameters above."
147+
),
148+
}
149+
150+
logger.debug("search_tools(%r) → %d results", query, len(matched))
151+
return json.dumps(output, ensure_ascii=False, indent=2)
152+
153+
@langchain_tool
154+
def call_tool(tool_name: str, arguments: dict[str, Any] | None = None) -> str:
155+
"""Execute a tool by name with arguments.
156+
157+
Use after search_tools to call a specific tool.
158+
159+
Args:
160+
tool_name: Exact tool name from search_tools results
161+
arguments: Tool arguments as a dict (e.g. {"order_id": 123, "city": "Seoul"})
162+
"""
163+
target = tool_map.get(tool_name)
164+
if target is None:
165+
return json.dumps({
166+
"error": f"Tool '{tool_name}' not found.",
167+
"hint": "Use search_tools to find the correct tool name.",
168+
})
169+
170+
# Normalize arguments
171+
args: dict[str, Any] = {}
172+
if arguments is not None:
173+
if isinstance(arguments, dict):
174+
args = arguments
175+
elif isinstance(arguments, str):
176+
try:
177+
args = json.loads(arguments)
178+
except (json.JSONDecodeError, TypeError):
179+
args = {}
180+
181+
# Track call history for retrieval boost
182+
if tool_name not in call_history:
183+
call_history.append(tool_name)
184+
185+
# Execute
186+
try:
187+
if hasattr(target, "invoke"):
188+
result = target.invoke(args)
189+
elif callable(target):
190+
result = target(**args)
191+
else:
192+
return json.dumps({"error": f"Tool '{tool_name}' is not callable."})
193+
194+
if isinstance(result, str):
195+
return result
196+
return json.dumps(result, ensure_ascii=False, default=str)
197+
except Exception as e:
198+
logger.warning("call_tool(%s) failed: %s", tool_name, e)
199+
return json.dumps({
200+
"error": str(e),
201+
"tool_name": tool_name,
202+
})
203+
204+
return [search_tools, call_tool]

0 commit comments

Comments
 (0)