Skip to navigation

Build Tools for an Agent

Wrap Zep retrieval in functional and domain-specific tools that an agent can use well

An agent retrieves context from Zep through tools. The tool set decides which retrievals the agent can make, and the tool descriptions decide when the agent makes them. This page shows the tools of the reference agent. Read Build an Agent with Zep first for the agent loop.

Choose tools for the task

Select tools from what the agent must do. The ontology tells you what the graph contains. The task tells you which retrievals the agent repeats.

  1. Start with the functional tools. They cover search, complete lists, graph traversal, and full details.
  2. Run the agent on your gold questions, and read the tool calls.
  3. Add a domain-specific tool when the agent often makes the same sequence of generic calls.
  4. Add a domain-specific tool when a business term maps to a fixed retrieval recipe.

The reference agent has four functional tools and two domain-specific tools:

ToolTypeZep callResult
search_contextFunctionalgraph.searchRanked sample
list_nodesFunctionalgraph.node.get_by_graph_idComplete, or truncated at the limit
get_neighborhoodFunctionalgraph.node.get_neighborsComplete, or truncated at the limit
get_detailsFunctionalgraph.node.get, graph.episode.getOne item
get_employeesDomain-specificNode list and neighbors, with pinned labels and edge typesComplete
search_productsDomain-specificNode list and neighbors, with pinned labels and edge typesComplete

In the reference dataset, one question asks which members of Reliability Engineering work on the Aster 410. With functional tools, the agent needs a team lookup, a product lookup, two neighbor calls, and an intersection. get_employees does the same retrieval in one call.

Pin and expose parameters

The application pins the parameters that control identity, security, cost, and result quality. The model controls only the arguments that the task changes.

ParameterOwnerReason
graph_id or user_idApplicationThe application decides which graph the user can read. The model never selects the graph
Security-sensitive filtersApplicationAuthorization does not come from the model
RerankerApplicationOne reranker gives results that you can compare across runs
Limits and result sizeApplicationLimits control token use and latency
QueryModelThe query depends on the plan step
Scope (edges, nodes, episodes)ModelThe plan step decides the result type
Entity type and edge type filtersModel, from an enumAn enum from the ontology prevents labels that do not exist
Metadata filters, such as report type or productModelThe plan step can restrict the search to one source
Handles of known nodesModelThe model selects the nodes for BFS or traversal

The pinned and exposed parameters for each tool in the reference agent:

ToolApplication pinsModel controls
search_contextgraph_id, reranker="cross_encoder", maximum limit of 20query, scope, node_labels, edge_types, report_type, product, near (handles), limit
list_nodesgraph_id, maximum limit of 49label, order_by, limit
get_neighborhoodMaximum limit of 30handle, edge_types, direction, limit
get_detailsMaximum episode length of 3,000 charactershandle
get_employeesgraph_id, the Employee, Team, and Product labels, the MEMBER_OF, WORKS_ON, and REPORTS_TO edge typesteam, product
search_productsgraph_id, the Product and RegulatoryFiling labels, the FILED_FOR edge typequery, category

When a request sets a limit, the list API returns at most 50 nodes in a page. The tool requests one extra node to find out if the list is complete.

Functional tools

The functional tools map to the Zep graph API. The examples use Pydantic AI. The same structure works in other agent frameworks. The examples are shorter than the reference agent code. Helper functions such as format_list format the results, as the result shape section describes.

search_context

search_context searches one scope with the cross_encoder reranker. The model can add entity type, edge type, and episode metadata filters. In the edge and node scopes, the model can add results from the neighborhood of known nodes. These breadth-first search (BFS) results are merged with the other search results. They do not restrict the search.

@toolset.tool
async def search_context(
ctx: RunContext[AgentDeps],
query: str,
scope: Literal["edges", "nodes", "episodes"] = "edges",
node_labels: list[EntityLabel] | None = None,
edge_types: list[EdgeTypeName] | None = None,
report_type: str | None = None,
product: str | None = None,
near: list[str] | None = None,
limit: int = 10,
) -> str:
"""Search the graph for the items most relevant to a query. Returns a ranked sample, not a complete set.
Use scope="edges" for facts and relationships, scope="nodes" for entities, and scope="episodes" for the source reports. Use report_type and product to restrict the search to reports with that metadata. Use near with one or more handles, and scope="edges" or scope="nodes", to add results from the graph neighborhood of known nodes. The results can also include matches from outside the neighborhood."""
meta_filters = [
EpisodeMetadataFilter(comparison_operator="=", property_name=name, property_value=value)
for name, value in (("report_type", report_type), ("product", product))
if value
]
results = await ctx.deps.zep.graph.search(
graph_id=ctx.deps.graph_id, # pinned
query=query,
scope=scope,
limit=min(limit, SEARCH_LIMIT_MAX), # pinned maximum
reranker="cross_encoder", # pinned
bfs_origin_node_uuids=[ctx.deps.registry.resolve(h) for h in near] if near else None,
search_filters=SearchFilters(
node_labels=node_labels,
edge_types=edge_types,
episode_metadata_filters=MetadataFilterGroup(type="and", filters=meta_filters)
if meta_filters else None,
),
)
return format_ranked_sample(ctx.deps, results)

list_nodes

list_nodes returns every node of one entity type, up to a limit. Use a list tool when the question needs a complete set. A search returns only the most relevant items.

@toolset.tool
async def list_nodes(
ctx: RunContext[AgentDeps],
label: EntityLabel,
order_by: Literal["degree"] | None = None,
limit: int = LIST_LIMIT_MAX,
) -> str:
"""List the nodes that have one entity type. Returns every match up to the limit, and says if the list is complete.
Use this when the question needs a complete set, such as all open quality issues or all products. Set order_by="degree" to list the most connected nodes first."""
page_size = min(limit, LIST_LIMIT_MAX)
nodes = await ctx.deps.zep.graph.node.get_by_graph_id(
ctx.deps.graph_id,
filters=SearchFilters(node_labels=[label]),
order_by=order_by,
limit=page_size + 1, # one extra node shows if the list is complete
)
return format_list(ctx.deps, nodes, page_size)

get_neighborhood

get_neighborhood follows the edges of a known node. The direction argument selects the edge direction: out for edges from the node, in for edges to the node, or both.

@toolset.tool
async def get_neighborhood(
ctx: RunContext[AgentDeps],
handle: str,
edge_types: list[EdgeTypeName] | None = None,
direction: Literal["out", "in", "both"] = "both",
limit: int = 30,
) -> str:
"""Get the edges and neighbor nodes of one node. Returns every match up to the limit, and says if the list is complete.
Use this to follow relationships from a node you already have, such as the components of a product, the supplier of a component, or the manager of an employee. Use edge_types to keep only some relationships. Use direction="out" for edges that start at the node, such as the manager of an employee (REPORTS_TO). Use direction="in" for edges that end at the node, such as the members of a team (MEMBER_OF)."""
page_size = min(limit, NEIGHBOR_LIMIT_MAX)
neighbors = await ctx.deps.zep.graph.node.get_neighbors(
ctx.deps.registry.resolve(handle, prefix="n"),
filters=SearchFilters(edge_types=edge_types) if edge_types else None,
direction=direction,
limit=page_size + 1,
)
return format_list(ctx.deps, neighbors, page_size)

get_details

get_details returns the full attributes of a node or the full text of an episode. Search results are short. The agent calls get_details when a result is relevant but does not contain enough text to answer from.

@toolset.tool
async def get_details(ctx: RunContext[AgentDeps], handle: str) -> str:
"""Get the full attributes of a node (n handle) or the full text and metadata of an episode (p handle).
Use this when a search result is relevant but too short to answer from, for example to read a full report."""
if handle.startswith("p"):
episode = await ctx.deps.zep.graph.episode.get(ctx.deps.registry.resolve(handle, prefix="p"))
return format_episode(ctx.deps, episode, max_chars=EPISODE_TEXT_MAX_CHARS)
node = await ctx.deps.zep.graph.node.get(ctx.deps.registry.resolve(handle, prefix="n"))
return format_node_details(ctx.deps, node)

Domain-specific tools

A domain-specific tool maps a business task to a fixed retrieval recipe. The application pins the labels, the edge types, and the call sequence. The model gives arguments in domain terms.

get_employees

get_employees finds the employees on a team, on a product, or on both. The tool returns the title, the team, and the manager of each employee.

@toolset.tool
async def get_employees(
ctx: RunContext[AgentDeps],
team: str | None = None,
product: str | None = None,
) -> str:
"""Find employees by team, by the product they work on, or both. Returns every match, with each person's title, team, and manager.
Use this for questions about who works on something or who is on a team. Names must match the graph, such as "Reliability Engineering" or "Aster 410"."""
if team is None and product is None:
return "pass team, product, or both."
groups = []
if team:
team_node = await node_by_name(ctx.deps, "Team", team)
groups.append(await members(ctx.deps, team_node, "MEMBER_OF")) # edges end at the team
if product:
product_node = await node_by_name(ctx.deps, "Product", product)
groups.append(await members(ctx.deps, product_node, "WORKS_ON")) # edges end at the product
employees = intersect(groups)
return await format_employees(ctx.deps, employees) # one REPORTS_TO lookup (direction="out") for each employee

search_products

search_products returns the products with their category, lifecycle status, and each regulatory filing. The tool description includes the domain rule for clearance, so the agent reads each filing status.

@toolset.tool
async def search_products(
ctx: RunContext[AgentDeps],
query: str | None = None,
category: str | None = None,
) -> str:
"""Get products with their category, lifecycle status, and every regulatory filing (region, status, and key dates). Returns every product that matches, and says if the product list is complete.
Use this for questions about which products are cleared, launched, or in a category. Read each filing status: only a cleared 510(k) or a valid CE certificate clears a product in a region."""
products = await ctx.deps.zep.graph.node.get_by_graph_id(
ctx.deps.graph_id, filters=SearchFilters(node_labels=["Product"]), limit=LIST_LIMIT_MAX + 1
)
truncated = len(products) > LIST_LIMIT_MAX
products = filter_products(products[:LIST_LIMIT_MAX], query=query, category=category)
return await format_products_with_filings(ctx.deps, products, truncated) # FILED_FOR neighbors for each product

The full code is in agent_with_zep/tools.py in the reference agent.

Write tool descriptions

The model selects a tool from its name, its description, and its parameter types. A tool description contains:

  • What the tool returns.
  • When to use the tool, with an example from the domain.
  • If the result is a ranked sample or a complete set.
  • The rules for each argument that the model can get wrong, such as a filter value or a handle prefix.

Compare two descriptions for the same tool:

DescriptionProblem
Search the knowledge graph.The model does not know the scopes, the filters, or that the result is incomplete
Search the graph for the items most relevant to a query. Returns a ranked sample, not a complete set. Use scope="edges" for facts and relationships, ...None. The model knows when to use a list tool instead

Use enum types from the ontology for entity type and edge type arguments. The model then cannot send a label that does not exist in the graph.

Result shape

Return compact text. Each line is one item, with only the fields that the agent needs.

complete:
- n4 QI-1041 [QualityIssue] issue_id=QI-1041; severity=high; status=open Aster 410 flow-rate under-delivery ...
- n9 QI-1068 [QualityIssue] issue_id=QI-1068; severity=high; status=open Lyric 350 alarm forwarding dropout ...

The reference agent applies these rules to each result:

  • Handles. Each node, edge, and episode gets a short handle (n1, e1, p1) in place of its UUID. The model passes handles to the tools that take a node or an episode. The application resolves each handle to its UUID.
  • Deduplication. The same UUID gets the same handle in every result of a run. An item that the agent already has is marked seen.
  • Completeness. The first line says ranked sample, complete, or truncated at N. A list tool requests one item more than the limit to find out if more items match.
  • Size. Each result is limited to 4,000 characters.
  • Budget. A tool that is called after the budget is spent returns a notice. A repeated call with the same arguments also returns a notice.
  • Errors. A tool returns invalid arguments and Zep API errors to the model as text. The model can then change the arguments or use a different tool. An error does not stop the run. A tool that rejects its arguments does not use the budget.

Use the tools with an agent framework

The reference agent registers the tools in one Pydantic AI FunctionToolset. The agent gets the toolset, the system prompt, and a function that hides the retrieval tools until the model submits a plan.

The framework integration pages show how to connect Zep to each framework. Use the same tool contracts in each framework. Pin the graph and the limits in the application, and expose only the task arguments to the model.