Use this file to discover all available pages before exploring further.
Session 34 (Season 2, Episode 7 — March 2025) introduces a compelling pattern for LLM tool calling: instead of hard-coding tools in Python, store their configurations — name, description, and the Cypher query they execute — as Tool nodes inside Neo4j. At runtime, load_tools_from_ontology() queries the graph, constructs StructuredTool objects with Pydantic input schemas, and hands them to a LangChain agent. The agent then decides dynamically which tool to invoke for each question. This makes the tool inventory as flexible as the knowledge graph itself — adding a new capability is a graph write, not a code deployment.
Before introducing dynamic tool loading, the session establishes a static baseline in basic.py. Here, tools are defined with the @tool decorator and registered in a plain Python list. The get_artist_works tool illustrates the pattern:
from neo4j import GraphDatabasefrom langchain.agents import initialize_agentfrom langchain.chat_models import ChatOpenAIfrom langchain.tools import toolimport osNEO4J_URI = os.getenv("NEO4J_URI", "bolt://localhost:7687")NEO4J_USER = os.getenv("NEO4J_USER", "neo4j")NEO4J_PASSWORD = os.getenv("NEO4J_PASSWORD", "neoneoneo")driver = GraphDatabase.driver(NEO4J_URI, auth=(NEO4J_USER, NEO4J_PASSWORD), database="art")@tooldef get_artist_works(artist: str) -> str: """ Retrieve all artwork titles for a given artist_name. """ query = """ MATCH (a:Artist {name: $artist_name})<-[:created_by]-(aw:Artwork) RETURN aw.title as artwork_title """ with driver.session() as session: result = session.run(query, artist_name=artist) artworks = [record['artwork_title'] for record in result] if artworks: return "Artworks created: " + ", ".join(artworks) else: return f"No artworks found for artist with name: {artist}"@tooldef search_topic(topic: str) -> str: """ Search for a topic on the Neo4j vector index 'productsVectorIndex' and return matching nodes along with their neighbourhood. """ query = """ CALL db.index.fulltext.queryNodes('ft', $searchTopic) YIELD node, score MATCH (node)-[r]-(connectedNode:Chunk) WITH node, connectedNode LIMIT 2 RETURN node.text as content, collect(connectedNode.text) as neighbours """ with driver.session() as session: result = session.run(query, searchTopic=topic) results = [] for record in result: neighbours_str = ", ".join(record['neighbours']) if record['neighbours'] else "None" results.append(f"Node: {record['content']}, Neighbours: {neighbours_str}") return "\n".join(results) if results else f"No matching nodes found for topic: {topic}"llm = ChatOpenAI(temperature=0, model="gpt-3.5-turbo")tools = [get_artist_works, search_topic]agent = initialize_agent(tools, llm, agent="zero-shot-react-description", verbose=True)
This works, but every new capability requires editing and redeploying the Python file. The dynamic approach eliminates that constraint.
In dynamic.py, tool definitions live in Neo4j as Tool nodes. Each node has three properties: name, description, and cypher_query. The Python code is entirely generic — it never mentions a specific tool by name.
This function accepts a dictionary read from a Tool node and constructs a fully functional StructuredTool, including a dynamically generated Pydantic input model:
import jsonimport osfrom neo4j import GraphDatabasefrom pydantic import create_model, Fieldfrom langchain.tools import StructuredToolfrom langchain.agents import initialize_agentfrom langchain.chat_models import ChatOpenAINEO4J_URI = os.getenv("NEO4J_URI", "bolt://localhost:7687")NEO4J_USER = os.getenv("NEO4J_USER", "neo4j")NEO4J_PASSWORD = os.getenv("NEO4J_PASSWORD", "neoneoneo")driver = GraphDatabase.driver(NEO4J_URI, auth=(NEO4J_USER, NEO4J_PASSWORD), database="art")def create_tool_from_config(tool_config: dict, driver) -> StructuredTool: """ Given a tool config dictionary, create a StructuredTool that executes the specified Cypher query. """ name = tool_config["name"] description = tool_config.get("description", "") cypher_query = tool_config["cypher_query"] InputModel = create_model( name + "InformationInput", input=(str, Field(..., description="input parameter as string")) ) def tool_func(data) -> str: if isinstance(data, str): data = InputModel.model_validate({'input': str(data)}) elif isinstance(data, dict): data = InputModel.model_validate(data) params = data.model_dump() with driver.session() as session: result = session.run(cypher_query, **params) records = [] for record in result: record_dict = dict(record) record_str = ", ".join(f"{k}: {v}" for k, v in record_dict.items()) records.append(record_str) return "\n".join(records) if records else "No results found." return StructuredTool( name=name, description=description, args_schema=InputModel, func=tool_func )
pydantic.create_model() generates a Pydantic model class at runtime using the tool’s name as a unique class name. This is what allows LangChain to validate and describe the tool’s inputs without any static type annotations.
This function queries Neo4j for all Tool nodes and calls create_tool_from_config() for each, returning a list of StructuredTool objects ready for the agent:
def load_tools_from_ontology(driver) -> list: """ Reads tool configurations from the ontology. """ onto_query = ( "MATCH (t:Tool) " "RETURN t.name AS name, t.description AS description, t.cypher_query AS cypher_query" ) with driver.session() as session: result = session.run(onto_query) records = [dict(record) for record in result] tools = [create_tool_from_config(record, driver) for record in records] return tools
Define the parameterised Cypher query the new tool should execute. For example, a query that finds artworks by style.
2
Create a Tool node in Neo4j
CREATE (:Tool { name: "get_artworks_by_style", description: "Retrieve all artworks matching a given art style or movement.", cypher_query: "MATCH (s:Style {name: $input})<-[:HAS_STYLE]-(aw:Artwork) RETURN aw.title AS artwork_title"})
3
Restart (or reload) the agent
Because load_tools_from_ontology() queries Neo4j at startup, the new tool is automatically picked up the next time the agent is initialised — no Python changes required.
Tool logic is hard-coded in Python. Reliable and type-safe. Best for tools whose queries will never change. Requires code deployment to add or modify tools.
Dynamic (ontology nodes)
Tool configuration lives in Neo4j. Adding a new tool is a graph write. The agent picks it up on the next startup. Best for evolving toolsets and non-developer administrators.
Pydantic validation
Both approaches use Pydantic input models — the decorator creates them from type hints, create_model() builds them at runtime. LangChain sees the same interface either way.
Cypher as capability
Storing Cypher queries as node properties turns the knowledge graph into a capability registry: the graph holds both the domain knowledge and the tools to query it.
Session 35 takes the agentic pattern further with a LangGraph StateGraph that not only selects tools but also selects the ontology to use for KG construction — enabling fully adaptive, multi-ontology workflows.