Guarding Agent Tool Calls with Jev and LangGraph Before They Execute
Add pre-execution safety checks and post-execution completion review to a LangGraph agent, using Jev typed decisions to block risky actions like large refunds.
This article is published in English.
Sources: JEV AI: Adding Pre-Execution Safety and Post-Execution Verification to LangGraph Agentic Framework.
Agents that can spend money or delete data need a checkpoint before a tool runs and another one once the work is supposedly done. This example wires both into a LangGraph e-commerce agent, using a decision model from TypeSafe AI called Jev as the governor.
What Jev returns
Jev is a "System One" decision model. Where a general LLM such as GPT-4 answers in open-ended prose, Jev takes structured or unstructured state in and hands back typed decisions, confidence scores and probability distributions. It offers three primitives.
- Choice picks one label from a fixed set and returns it with a confidence value and the full probability spread. It suits tool-guard decisions (
allow,deny,confirm,review) and intent classification. - Score places something on an ordinal scale, again with a confidence value. It fits risk assessment for finance or policy (
Low,Moderate,High,Critical) and severity ratings. - Noul gives a probability between
0.0and1.0that a condition is true. It works for boolean checks, policy compliance and confidence that a task is finished.
Project setup
The agent lives in a fresh Python virtual environment with these dependencies installed.
fastapi==0.115.6
uvicorn==0.32.1
pydantic==2.10.4
langgraph==0.2.61
langchain==0.3.13
langchain-openai==0.3.1
langchain-typesafe==0.1.0
httpx==0.27.2
python-dotenv==1.0.1
The safety layer: jev_utils.py
This file acts as validation middleware, a guardrail between the agent and its tools. It deals with two separate problems.
Before execution: guard_tool_call
Autonomous agents can trigger sensitive actions, such as payments, deletions or refunds, with nobody watching. This function stands at the gate ahead of every tool call. The tool name, its arguments and the relevant safety policies go into a structured classifier, which produces a decision (allow, confirm, review or deny) along with a risk score.
After execution: review_completion
LLMs often can't tell whether a multi-step goal has really been met or whether work is still outstanding. This function compares the recent state history with the user's original goal and labels the task complete or incomplete, with a confidence value attached.
import os
from typing import Any, Dict, List
from langchain_typesafe import Choice, Score, TypeSafeClassifier
if not os.getenv("TYPESAFE_API_KEY"):
raise RuntimeError("TYPESAFE_API_KEY not set")
classifier = TypeSafeClassifier()
def guard_tool_call(tool: str, args: Dict[str, Any], policy: List[str], side_effects: List[str]) -> Dict[str, Any]:
questions = {
"decision": Choice(
instructions="Choose the safest next action for this tool call.",
criteria={
"allow": "Execute immediately.",
"confirm": "Proceed after user confirmation.",
"review": "Require human review.",
"deny": "Do not execute.",
},
),
"risk": Score(
instructions="Score the financial and policy risk.",
criteria=["Low", "Moderate", "High", "Critical"],
),
}
res = classifier.invoke({
"state": {"tool": tool, "arguments": args, "policy": policy, "side_effects": side_effects},
"questions": questions,
})
return {
"decision": res.choices["decision"].choice,
"confidence": res.choices["decision"].confidence,
"risk_score": res.scores["risk"].score,
}
def review_completion(state_summary: str, goal: str) -> Dict[str, Any]:
questions = {
"complete": Choice(
instructions="Is the stated goal fully achieved?",
criteria={
"complete": "Goal fully achieved.",
"incomplete": "Goal not achieved.",
},
)
}
res = classifier.invoke({
"state": {"state_summary": state_summary, "goal": goal},
"questions": questions,
})
return {
"status": res.choices["complete"].choice,
"confidence": res.choices["complete"].confidence,
}
The orchestrator: agent_graph.py
This file builds the stateful, cyclic LangGraph workflow, and it carries three responsibilities.
- Deciding what to do next happens in
agent_node. It reads the message history and the current goal, then uses OpenAI's tool-calling API to choose a reply or a tool call. - Guarded execution happens in
tools_node. It intercepts the tool calls the model proposes and checks them throughguard_tool_call. High-risk actions, such as refunds over $500, are blocked. Approved tools run, and their outcomes are logged totool_outputsso the final response carries a structured snapshot. - Lifecycle and routing belong to
build_agent_graph. It sets up conditional branching from the start node to the agent, then either to the tools or to the completion check, and finally to the end. The loop keeps alternating between reasoning and tool use until the task is done.
from typing import Annotated, Any, Dict, List, Optional
from typing_extensions import TypedDict
from langgraph.graph import START, END, StateGraph
from langgraph.graph.message import add_messages
from langchain_core.messages import AIMessage, BaseMessage, SystemMessage, ToolMessage
from langchain_openai import ChatOpenAI
from jev_utils import guard_tool_call, review_completion
# --- Typed State ---
class AgentState(TypedDict):
messages: Annotated[List[BaseMessage], add_messages]
goal: Optional[str]
tool_outputs: List[Dict[str, Any]]
# --- Tools Definitions & Logic ---
TOOL_POLICIES = {
"checkout": {
"policy": ["Payment captured upon order creation."],
"side_effects": ["Charges customer"],
},
"issue_refund": {
"policy": ["Refunds above $500 require approval."],
"side_effects": ["Moves funds"],
},
}
def get_catalog_info(query: str) -> Dict[str, Any]:
return {"result": f"Catalog info for: {query}"}
def add_to_cart(user_id: str, product_id: str, qty: int) -> Dict[str, Any]:
return {"status": "ok", "user_id": user_id, "product_id": product_id, "qty": qty}
def checkout(user_id: str, cart_id: str, address: Dict[str, str], **_) -> Dict[str, Any]:
return {
"status": "ok",
"order_id": "ord_12345",
"user_id": cart_id,
"items": [{"product_id": "prod_777", "qty": 2}],
"ship_to": address,
}
def issue_refund(order_id: str, amount_usd: float, reason: str) -> Dict[str, Any]:
return {"status": "ok", "refund_id": "ref_999", "order_id": order_id, "amount_usd": amount_usd}
TOOLS = {
"get_catalog_info": get_catalog_info,
"add_to_cart": add_to_cart,
"checkout": checkout,
"issue_refund": issue_refund,
}
TOOL_SCHEMAS = [
{
"name": "get_catalog_info",
"description": "Get product/catalog information.",
"parameters": {
"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"],
},
},
{
"name": "add_to_cart",
"description": "Add product to cart.",
"parameters": {
"type": "object",
"properties": {"user_id": {"type": "string"}, "product_id": {"type": "string"}, "qty": {"type": "integer"}},
"required": ["user_id", "product_id", "qty"],
},
},
{
"name": "checkout",
"description": "Place an order.",
"parameters": {
"type": "object",
"properties": {
"user_id": {"type": "string"},
"cart_id": {"type": "string"},
"address": {
"type": "object",
"properties": {"line1": {"type": "string"}, "city": {"type": "string"}, "state": {"type": "string"}, "country": {"type": "string"}, "pin": {"type": "string"}},
"required": ["line1", "city", "state", "country", "pin"],
},
},
"required": ["user_id", "cart_id", "address"],
},
},
{
"name": "issue_refund",
"description": "Issue order refund.",
"parameters": {
"type": "object",
"properties": {"order_id": {"type": "string"}, "amount_usd": {"type": "number"}, "reason": {"type": "string"}},
"required": ["order_id", "amount_usd", "reason"],
},
},
]
llm = ChatOpenAI(model="gpt-4.1", temperature=0).bind_tools(TOOL_SCHEMAS)
# --- Graph Nodes ---
def agent_node(state: AgentState) -> Dict[str, Any]:
sys_msg = SystemMessage(
content=f"You are an e-commerce assistant. Target Goal: {state.get('goal') or 'N/A'}"
)
response = llm.invoke([sys_msg] + state["messages"])
return {"messages": [response]}
def tools_node(state: AgentState) -> Dict[str, Any]:
last_msg = state["messages"][-1]
tool_messages = []
tool_outputs = list(state.get("tool_outputs", []))
for tc in getattr(last_msg, "tool_calls", []):
name, args, call_id = tc["name"], tc["args"], tc["id"]
# Guard check
if name in TOOL_POLICIES:
config = TOOL_POLICIES[name]
guard = guard_tool_call(name, args, config["policy"], config["side_effects"])
is_blocked = (
guard["decision"] == "deny"
or (name == "issue_refund" and args.get("amount_usd", 0) > 500)
)
if is_blocked:
msg = f"Action `{name}` blocked by policy or high-risk evaluation."
tool_messages.append(ToolMessage(content=msg, tool_call_id=call_id))
tool_outputs.append({
"status": "blocked",
"tool": name,
"result": {"error": msg}
})
continue
# Execute safe tool
res = TOOLS[name](**args)
tool_messages.append(ToolMessage(content=f"Result: {res}", tool_call_id=call_id))
# Append structured tool execution record
tool_outputs.append({
"status": res.get("status", "ok"),
"tool": name,
"result": res
})
return {"messages": tool_messages, "tool_outputs": tool_outputs}
def completion_check_node(state: AgentState) -> Dict[str, Any]:
# Pass-through node; does not pollute message history
return {}
# --- Builder ---
def build_agent_graph():
builder = StateGraph(AgentState)
builder.add_node("agent", agent_node)
builder.add_node("tools", tools_node)
builder.add_node("completion_check", completion_check_node)
builder.add_edge(START, "agent")
builder.add_conditional_edges(
"agent",
lambda state: "tools" if getattr(state["messages"][-1], "tool_calls", None) else "completion_check",
)
builder.add_edge("tools", "agent")
builder.add_edge("completion_check", END)
return builder.compile()
The web layer: main.py
The last file exposes the agent over HTTP with FastAPI. Its pieces are:
- A
/chatREST endpoint that accepts the message history, the current message and an optional goal. - Conversion of the incoming JSON history into LangChain
HumanMessageandAIMessageobjects for the compiled graph. - A call into the graph with the prepared state: the messages, the goal and an empty
tool_outputslist. - A typed Pydantic response,
ChatResponse, holding the agent's finalreplyand astate_snapshotthat containstool_outputsandgoal.
from typing import Any, Dict, List, Optional
from dotenv import load_dotenv
from fastapi import FastAPI, HTTPException
from langchain_core.messages import AIMessage, HumanMessage
from pydantic import BaseModel
from agent_graph import build_agent_graph
load_dotenv(override=True)
app = FastAPI()
agent = build_agent_graph()
class ChatRequest(BaseModel):
user_id: str
message: str
goal: Optional[str] = None
history: Optional[List[Dict[str, str]]] = None
class StateSnapshot(BaseModel):
tool_outputs: List[Dict[str, Any]]
goal: Optional[str] = None
class ChatResponse(BaseModel):
reply: str
state_snapshot: StateSnapshot
@app.post("/chat", response_model=ChatResponse)
async def chat(req: ChatRequest):
messages = []
if req.history:
for m in req.history:
role, content = m.get("role"), m.get("content", "")
if role == "user":
messages.append(HumanMessage(content=content))
elif role == "assistant":
messages.append(AIMessage(content=content))
messages.append(HumanMessage(content=req.message))
try:
final_state = agent.invoke({
"messages": messages,
"goal": req.goal,
"tool_outputs": [],
})
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# Get the last AI message with non-empty content
reply_msg = next(
(m.content for m in reversed(final_state["messages"]) if isinstance(m, AIMessage) and m.content),
""
)
return ChatResponse(
reply=str(reply_msg),
state_snapshot=StateSnapshot(
tool_outputs=final_state.get("tool_outputs", []),
goal=final_state.get("goal")
)
)
Start the server with uvicorn:
#Run uvicorn server
$uvicorn main:app --reload --port 8000 --host 0.0.0.0
Then send a request that asks for a large refund:
curl -X 'POST' \
'http://127.0.0.1:8000/chat' \
-H 'accept: */*' \
-H 'Content-Type: application/json' \
-d '{
"user_id": "alice_123",
"message": "Please issue a $750 refund for order ord_999 because the item arrived broken.",
"goal": "Issue a $750 refund for order ord_999 to user alice_123."
}'
The reply shows the guard doing its job. The agent declines to process a $750 refund and mentions that high-value refunds may require extra verification or approval.
{
"reply": "I'm unable to process the $750 refund for order ord_999 due to a policy restriction or high-risk evaluation. For high-value refunds, additional verification or approval may be required.\n\nWould you like me to connect you with customer support or provide further instructions on how to proceed?",
"state_snapshot": {
"tool_outputs": [
{
"status": "blocked",
"tool": "issue_refund",
"result": {
"error": "Action `issue_refund` blocked by policy or high-risk evaluation."
}
}
],
"goal": "Issue a $750 refund for order ord_999 to user alice_123."
}
}
Why the refund was blocked
Inside tools_node in agent_graph.py, an explicit condition decides whether a call is blocked:
is_blocked = (
guard["decision"] == "deny"
or (name == "issue_refund" and args.get("amount_usd", 0) > 500)
)
The requested amount of $750 is above the $500 ceiling, so is_blocked is set to true at once. Two things line up behind that result.
- Policy configuration:
TOOL_POLICIESsays thatissue_refundneeds approval for amounts above $500. - Guard evaluation: before
issue_refundexecutes,guard_tool_callinjev_utils.pyrates the risk. A large transfer of funds tends to come back asHighorCritical, or as anything other thanallow, and the run is marked as blocked.
The design lesson
Jev acts as the safety layer and not as the generator. The OpenAI model (gpt-4.1) handles reasoning, ordinary conversation and the choice of which tools to call. Jev works as an independent governor that intercepts those requests before they run and tests them against policy, safety rules and money thresholds.