Your AI Agent Needs More Than Guardrails. It Needs a World With Rules.


Everyone complains that LLMs hallucinate.
But what if that is not a bug we will eventually patch away?
An LLM does not retrieve truth from a database. It generates the next plausible continuation. That is exactly why it can write code, propose new solutions, and imagine something that did not exist before.
Humans do this too. We call it imagination, a hypothesis, or an idea.
The problem starts when an agent issues a refund, reviews a contract, or changes data in your system. At that point, “plausible” is no longer enough.
You need reliability.
We are trying to solve this with better prompts, more context, RAG, and agentic loops. These techniques make agents more capable. They do not automatically make them more reliable.
A loop gives an agent more opportunities to correct an error. It also gives it more opportunities to drift away from the goal, burn tokens, or execute the same wrong idea three times.
More context helps the agent know more about the task. But context alone does not define a world.
If an agent is supposed to act inside your domain, your system must explicitly describe:
- Which entities exist?
- How are they related?
- Which states are valid?
- Which actions must never happen?
The agent does not need less imagination.
It needs a machine-readable understanding of the world in which that imagination is allowed to act.
This is where ontologies become interesting.
The idea came from Frank Coyle’s talk “Why Agentic Systems Need Ontologies”. His core argument addresses a real production pain: an LLM can propose a second refund for the same order, send money to the wrong role, or invent a state such as probably_shipped. A longer system prompt will not reliably prevent that.
Two worlds we need to connect#
Coyle describes two lines of AI development.
On one side, we have AI agents.
They observe their environment, select a next step, and propose an action. Underneath, the LLM remains probabilistic. It does not calculate the one logically correct action. It generates the action that appears most plausible in the current context.
That is precisely why it is so flexible.
The agent can react to new situations, choose between tools, and adapt a plan we did not fully preprogram.
On the other side, we have the ontology.
The term comes from philosophy. Ontology is the study of being: What exists? What kinds of things are there? And how do they relate to each other?
In computer science, that becomes a formal model of a domain.
For a payment system, that model might state:
Customer,SupportRepresentative,Order, andRefundare distinct entities.- A refund belongs to exactly one order.
- The recipient of a refund must be the customer who paid for that order.
- A fully refunded order cannot be refunded again.
The agent creates possibilities.
The ontology describes the world in which those possibilities are evaluated.
Connect both approaches and you get what is often called neurosymbolic AI:
LLM / Agent
→ proposes an action probabilistically
Ontology
→ provides the formal domain model and possible inferences
Runtime validator
→ checks the proposal against the model, policies, and current stateThe agent can still plan creatively. It just no longer decides on its own what may happen in the real world.
The ontology does not give it human understanding. It gives the system an explicit frame against which the proposal can be checked.

Why this matters inside agentic loops#
An agent does not simply answer one prompt.
It operates in a loop:
LLM proposes a tool call
→ tool runs
→ result returns as an observation
→ LLM chooses the next step
→ loop starts againThis loop is what turns an LLM into an agent.
The model can retrieve information, react to errors, and adapt its plan during execution. It does not have to be right on the first try.
But the loop amplifies more than the model’s capabilities. It also amplifies its mistakes.
If the agent makes a false assumption, it can build more tool calls on top of it. If a tool fails, it can retry the same action in slightly different forms. With every iteration, the context grows, the original task moves further into the background, and token consumption increases.
One bad proposal can quickly become:
- an endless repair attempt,
- context drift across several tool calls,
- the same forbidden action three times,
- or a surprisingly large API bill.
A max_steps = 6 limit stops the infinite loop.
It does not make the first five steps correct.
This is where the ontology becomes practical. We can express the rules of our domain as entities, relationships, and valid states in a graph, then check every tool call against that world.
The agent can continue to propose probabilistically:
{
"id": "call_01",
"tool": "refund_order",
"arguments": {
"order_id": "order_42",
"recipient_id": "support_17",
"amount_cents": 12900
}
}But the graph says:
customer_9 ── owns ──> order_42
order_42 ── paid_by ──> customer_9
refund ── must_go_to ──> payer(order_42)How does that rule reach the tool call?
In practice, I would extend the ontology with an operational policy layer. The tool itself becomes a node in the graph:
(:ToolAction {name: "refund_order"})
├──[:CHECK_BEFORE]──> (:Policy {key: "recipient_is_order_payer"})
├──[:CHECK_BEFORE]──> (:Policy {key: "order_has_refundable_balance"})
└──[:CHECK_AFTER]───> (:Policy {key: "refund_matches_approved_request"})
(:Policy {key: "recipient_is_order_payer"})
└──[:USES_RELATION]─> (:Relation {name: "PAID_BY"})
(:Order {id: "order_42"})
└──[:PAID_BY]───────> (:Customer {id: "customer_9"})This makes three things machine-readable:
- which policies belong to
refund_order, - when they must be evaluated,
- and which domain concepts and relationships they use.
Strictly speaking, this is no longer only an OWL ontology. It is an operational policy layer on the same knowledge graph.
That distinction matters: the graph describes and connects the rules. Deterministic code executes them.
What the check looks like in code#
First, the probabilistic LLM output must become a structurally valid tool call. That is Pydantic’s job:
from typing import Literal
from pydantic import BaseModel, Field
class RefundArguments(BaseModel):
order_id: str = Field(pattern=r"^order_[0-9]+$")
recipient_id: str = Field(pattern=r"^(customer|support)_[0-9]+$")
amount_cents: int = Field(gt=0)
class RefundToolCall(BaseModel):
id: str
tool: Literal["refund_order"]
arguments: RefundArgumentsThe backend can now parse the raw proposal:
call = RefundToolCall.model_validate(llm_output)Pydantic can answer:
- Is
amount_centsa positive integer? - Does
order_iduse the expected format? - Are all required arguments present?
- Did the LLM actually select
refund_order?
It cannot tell you whether customer_9 paid for this order or whether a refundable balance remains.
That is the next layer.
The runtime loads all CHECK_BEFORE policies attached to the tool node:
def load_policies(graph, tool_name: str, phase: str) -> list[str]:
relation = {
"before": "CHECK_BEFORE",
"after": "CHECK_AFTER",
}[phase]
rows = graph.query(
f"""
MATCH (:ToolAction {{name: $tool_name}})
-[:{relation}]->(policy:Policy)
RETURN policy.key AS key
ORDER BY policy.key
""",
tool_name=tool_name,
)
return [row["key"] for row in rows]Each policy has a deterministic implementation:
def recipient_is_order_payer(call, graph) -> bool:
return graph.exists(
"""
MATCH (:Order {id: $order_id})
-[:PAID_BY]->(:Customer {id: $recipient_id})
""",
order_id=call.arguments.order_id,
recipient_id=call.arguments.recipient_id,
)
def order_has_refundable_balance(call, graph) -> bool:
order = graph.get_order(call.arguments.order_id)
requested = call.arguments.amount_cents
remaining = order.paid_cents - order.refunded_cents
return requested > 0 and requested <= remaining
POLICY_EVALUATORS = {
"recipient_is_order_payer": recipient_is_order_payer,
"order_has_refundable_balance": order_has_refundable_balance,
}The runtime validator connects both sides:
def validate_before(call, graph) -> list[str]:
policy_keys = load_policies(
graph,
tool_name=call.tool,
phase="before",
)
return [
key
for key in policy_keys
if not POLICY_EVALUATORS[key](call, graph)
]For recipient_id = "support_17", the check effectively asks:
Does this path exist?
(order_42:Order)-[:PAID_BY]->(support_17:Customer)The answer is false.
The runtime does not call the payment tool. Instead, the agentic loop receives a structured observation:
{
"status": "rejected",
"failed_policy": "recipient_is_order_payer",
"message": "The refund recipient must be the customer who paid the order.",
"allowed_recipient_id": "customer_9"
}The LLM does not have to guess blindly on the next iteration. It knows which rule it violated and which path through the domain is valid.
What happens after the tool call?#
A tool result can also be wrong or incomplete.
A prepared refund result might look like this:
{
"refund_id": "refund_88",
"order_id": "order_42",
"recipient_id": "customer_9",
"amount_cents": 12900,
"status": "prepared"
}The CHECK_AFTER policy verifies that the result still matches the validated proposal:
def refund_matches_approved_request(call, result, graph) -> bool:
return all(
[
result.order_id == call.arguments.order_id,
result.recipient_id == call.arguments.recipient_id,
result.amount_cents == call.arguments.amount_cents,
result.status == "prepared",
]
)
AFTER_POLICY_EVALUATORS = {
"refund_matches_approved_request": (
refund_matches_approved_request
),
}
def validate_after(call, result, graph) -> list[str]:
policy_keys = load_policies(
graph,
tool_name=call.tool,
phase="after",
)
return [
key
for key in policy_keys
if not AFTER_POLICY_EVALUATORS[key](call, result, graph)
]The complete tool boundary now looks like this:
def rejected_observation(violations):
return {
"status": "rejected",
"failed_policies": violations,
}
async def execute_agent_tool(call, graph, tools):
before_violations = validate_before(call, graph)
if before_violations:
return rejected_observation(before_violations)
# No irreversible action yet:
prepared_result = await tools[call.tool].prepare(
call.arguments.model_dump()
)
after_violations = validate_after(call, prepared_result, graph)
if after_violations:
await tools[call.tool].discard(prepared_result)
return rejected_observation(after_violations)
# Only now may the external side effect happen:
receipt = await tools[call.tool].commit(
prepared_result,
idempotency_key=call.id,
)
return {
"status": "success",
"tool_result": receipt,
}This secures one tool call.
It still does not prevent an endless agentic loop. The orchestrator needs its own deterministic stop conditions:
from collections import Counter
from pydantic import ValidationError
MAX_STEPS = 6
MAX_SAME_POLICY_FAILURES = 2
async def run_agent(task, llm, graph, tools):
observations = []
policy_failures = Counter()
for step in range(MAX_STEPS):
raw_proposal = await llm.propose(
task=task,
observations=observations,
)
try:
call = RefundToolCall.model_validate(raw_proposal)
except ValidationError as error:
observations.append(
{
"status": "rejected",
"reason": "INVALID_TOOL_SCHEMA",
"details": error.errors(),
}
)
continue
observation = await execute_agent_tool(
call,
graph,
tools,
)
observations.append(observation)
if observation["status"] == "success":
return observation
for policy in observation.get("failed_policies", []):
policy_failures[policy] += 1
if (
policy_failures[policy]
>= MAX_SAME_POLICY_FAILURES
):
return {
"status": "human_review_required",
"reason": "REPEATED_POLICY_VIOLATION",
"failed_policy": policy,
"steps_used": step + 1,
}
return {
"status": "stopped",
"reason": "MAX_STEPS_EXCEEDED",
"steps_used": MAX_STEPS,
}The responsibilities are now explicit:
Pydantic
→ Is the tool call structurally valid?
BEFORE policies on the tool node
→ Is this action allowed in the current domain state?
AFTER policies on the tool node
→ Does the prepared result match the validated request?
Circuit breaker in the orchestrator
→ How often may the agent fail before the loop ends?If a policy fails once, the agent receives structured feedback and may correct its plan.
If the same policy fails repeatedly, the circuit breaker stops the loop. The global step limit provides a second stop against drift and token burn.
The ontology does not stop context drift or token consumption by itself. Its policy nodes provide precise failure signals that let the orchestrator decide: another attempt, a different path, or a hard stop.
Not every external tool offers a real prepare and commit. Depending on the system, you may need transactions, idempotency keys, a saga, or an approval step. A post-check alone cannot pull back a refund that has already happened.
What ontologies actually contribute#
An ontology gives your domain a shared, machine-readable model:
- Which entities exist?
- Which properties do they have?
- How are they related?
- Which conclusions follow from those relationships?
With RDFS, you can state that someone who teaches something is a Teacher, and that every Teacher is also a Person.
With OWL, you can model stronger statements:
ancestorOfis transitive.CustomerandSupportRepresentativeare disjoint classes.hasFatheris a functional property.
A reasoner can use those statements to infer new facts or detect contradictions in the model.
That is more than a graph database. The graph stores relationships. The ontology gives them meaning.
But this is where precision matters.
OWL is not automatically your validator#
It is tempting to treat an OWL functional property like a database constraint:
A functional property prevents duplicates and enforces consistency.
That is not how OWL works.
OWL does not use the usual unique-name assumption. If Peter and Peter_Griffin are both declared as one person’s father and hasFather is functional, a reasoner may infer that both names refer to the same individual. It does not automatically raise a validation error.
Missing data is not automatically false either. Under the open-world assumption, “not in the graph” initially means “unknown.”
That is useful for inference.
It is insufficient for a runtime rule such as “every payout must have exactly one verified recipient.”
If you need to validate RDF data against closed conditions, you can use SHACL. SHACL checks a data graph against defined shapes and produces a structured validation report.
The layers have different jobs:
Pydantic → Is the tool call syntactically well formed?
OWL → Which meaning and inferences follow from the model?
SHACL → Does the graph satisfy the expected data conditions?
Policy → May this actor perform this action in this state?Calling all of this an “ontology validator” sounds simpler. It also hides the architecture you need to understand in production.
The ontology does not know the agent’s goal#
An ontology does not automatically prevent context drift.
It knows that a Customer is not a SupportRepresentative. It does not know whether the agent is still working on the original task after six tool calls.
The system must represent that goal explicitly:
task = {
"goal": "prepare_refund_proposal",
"actor_id": "user_42",
"allowed_tools": ["get_order", "get_payment", "propose_refund"],
"max_steps": 6,
"max_cost_cents": 20,
"requires_approval": True,
}Now the loop can answer hard questions:
- Is this tool allowed for this task?
- Does the current state permit the next transition?
- Has the same failed action already been repeated?
- Is the step, time, or token budget exhausted?
- Does a human need to take over?
The ontology provides domain knowledge.
The orchestrator controls the loop.
The state machine controls valid transitions.
The circuit breaker pulls the plug.
Those are four different jobs.
A post-check cannot undo a side effect#
In a demo, you can write a value into a dictionary, notice that a post-condition failed, and set it back to None.
Production side effects look more like:
- a Stripe refund,
- an email that has already been sent,
- a flight that has been booked,
- a legal filing that has been submitted,
- or a write into someone else’s system.
You cannot reverse those with a Python assignment.
The agent should first propose a state transition:
{
"transition": "REFUND_REQUESTED",
"order_id": "order_123",
"recipient_id": "customer_9",
"amount_cents": 4900
}Then the system checks:
- schema and types,
- authentication and authorization,
- current order and payment data,
- domain invariants,
- idempotency key,
- approval policy.
Only then does the side effect happen.
Even then, the integration may need transactions, an outbox pattern, or a saga with compensating actions. “Rollback” is not a property of the agent. It is a property of your integration architecture.
The production architecture#
A robust loop looks more like this:
User goal
↓
[Task envelope]
Goal · actor · allowed tools · step budget · approval policy
↓
LLM proposes a tool call
↓
[1] Schema check
Pydantic / structured output
↓
[2] Policy and precondition check
Roles · permissions · current state
↓
[3] Semantic and data check
Ontology reasoner · SHACL · business invariants
↓
[4] Approval boundary
automatic · human in the loop · rejected
↓
[5] Side effect
idempotent · transactional · audited
↓
[6] Post-condition
Did the expected state actually occur?
↓
Observation returns to the agentWhen a check fails, the agent should not receive a vague “Action rejected.” It should receive structured feedback:
{
"code": "RECIPIENT_ROLE_MISMATCH",
"message": "Refund recipient must be the buyer of the order.",
"current_recipient_role": "SupportRepresentative",
"allowed_recipient_id": "customer_9",
"retryable": true
}The LLM can correct its proposal instead of guessing. After two or three identical failures, the orchestrator stops the loop and escalates.
The ontology does not stop the loop.
Your code stops it—with information from the ontology.
What this means for a Legal AI SaaS#
Legal AI is a good test for this pattern because “sounds plausible” is not enough.
Suppose a lawyer uploads a case file and asks:
Can we claim damages for late payment?
The naive architecture puts every PDF into a vector database, retrieves similar chunks, and asks the LLM to write a legal answer.
The problem is that a similar chunk is not yet a proven fact.
A letter saying “Payable by April 26” does not automatically prove that a payment reminder was delivered. An LLM can merge both statements into one very convincing conclusion.
I would separate the data into three layers:
Source documents
→ immutable PDFs, pages, and chunks
Extraction proposals
→ "could be a payment reminder," with source and confidence
Confirmed case graph
→ asserted · disputed · evidenced · confirmedThe extraction agent may propose new nodes and edges. It may not silently write them into the case graph as truth.
A useful tool set might be:
search_case_evidence(query)
get_claim_requirements(claim_type, jurisdiction, valid_at)
propose_case_fact(fact_type, source_chunk_id)
link_evidence_proposal(fact_id, source_chunk_id)
get_missing_requirements(claim_id)
draft_argument(claim_id, approved_evidence_ids)Notice the verb propose.
The agent can discover a passage. The graph records where it came from. A lawyer—or a sufficiently explicit review workflow—confirms its status.
The ontology can model that damages for late payment require:
Damages for late payment
requires → due date
requires → payment reminder OR exception
requires → responsibility
requires → damageThe system can reliably show:
- Due date: source available and confirmed
- Payment reminder: letter found, delivery not evidenced
- Damage: amount extracted, calculation not reviewed
It should not say:
The claim is 66% substantiated.
That is false precision. Legal requirements are not equally weighted progress-bar segments.
A better output is:
A potential payment reminder was found. The case file currently contains no confirmed evidence that it was delivered. Until that evidence exists, this requirement remains unresolved.
Less spectacular.
Far more useful.
Top-down or bottom-up?#
“Both” is not a strategy by itself.
Use top-down modeling for the stable core:
- roles and permissions,
- valid states and transitions,
- domain invariants,
- provenance,
- jurisdiction and temporal validity.
Use real files, queries, and failure cases to generate bottom-up candidates:
- unknown document types,
- new labels for known concepts,
- missing relationships,
- recurring extraction patterns.
Those candidates enter a review queue. A domain expert decides whether each one is a synonym, a new subclass, or simply noise.
The ontology can grow with reality without letting a probabilistic agent rewrite its own rulebook.
One detail from the talk is worth correcting: Wikipedia is not based on DBpedia. DBpedia extracts structured information from Wikipedia. It sounds minor, but semantic direction is exactly what matters in knowledge-graph systems.
The actual shift#
I would extend Coyle’s phrase “Pydantic at the door, ontology at the ledger”:
Pydantic validates the form. OWL derives meaning. SHACL validates the graph. Policy code permits the action. The transaction boundary controls what becomes real.
Then the LLM can keep doing what it is good at:
Forming hypotheses. Finding evidence. Proposing paths. Working with incomplete language.
But between its proposal and the real world sits code you can explain, test, and audit.
That is not less agentic.
That is the point where an agent becomes a system.
Sources: Frank Coyle, “Why Agentic Systems Need Ontologies”, AI Engineer World’s Fair 2026; W3C, OWL 2 Structural Specification and Shapes Constraint Language (SHACL); DBpedia Association.
AI engineering, weekly.
Join developers getting practical AI engineering in their inbox.