Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/jbarrasa/goingmeta/llms.txt

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.

Watch the Recording

Full live-stream replay on YouTube

Session Code

Python: dynamic.py and basic.py

The Static Baseline — basic.py

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 GraphDatabase
from langchain.agents import initialize_agent
from langchain.chat_models import ChatOpenAI
from langchain.tools import tool
import os

NEO4J_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")

@tool
def 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}"

@tool
def 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.

The Dynamic Approach — dynamic.py

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.

create_tool_from_config()

This function accepts a dictionary read from a Tool node and constructs a fully functional StructuredTool, including a dynamically generated Pydantic input model:
import json
import os
from neo4j import GraphDatabase
from pydantic import create_model, Field
from langchain.tools import StructuredTool
from langchain.agents import initialize_agent
from langchain.chat_models import ChatOpenAI

NEO4J_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.

load_tools_from_ontology()

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

Assembling the Agent

With load_tools_from_ontology() providing the tool list, the agent setup is entirely generic:
def main():
    tools = load_tools_from_ontology(driver)
    llm = ChatOpenAI(temperature=0, model="gpt-4o")
    agent = initialize_agent(tools, llm, agent="zero-shot-react-description", verbose=True)

    print("Welcome to the Neo4j Chatbot with Ontology defined Tools!")
    print("How can I help today?")
    while True:
        user_input = input("User: ")
        if user_input.lower() in ["exit", "quit"]:
            break
        try:
            response = agent.run(user_input)
            print("Chatbot:", response)
        except Exception as e:
            print("Error:", e)

if __name__ == "__main__":
    main()

Adding a New Tool

1

Write the Cypher query

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.

Static vs Dynamic Tool Loading

Static (@tool decorator)

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.

Build docs developers (and LLMs) love