Ochrona wywołań narzędzi agenta za pomocą Jev i LangGraph przed ich wykonaniem
Dodaj kontrolę bezpieczeństwa przed wykonaniem oraz przegląd zakończenia po wykonaniu do agenta LangGraph, wykorzystując decyzje typu Jev w celu zablokowania ryzykownych działań, takich jak duże zwroty pieniędzy.
Źródła: JEV AI: Adding Pre-Execution Safety and Post-Execution Verification to LangGraph Agentic Framework.
Agenty, które mogą wydawać pieniądze lub usuwać dane, potrzebują punktu kontrolnego przed uruchomieniem narzędzia oraz kolejnego po zakończeniu pracy. W tym przykładzie oba te punkty kontrolne są połączone z agentem e-commerce LangGraph, przy czym jako mechanizm sterujący użyto modelu decyzyjnego od TypeSafe AI o nazwie Jev.
Co zwraca Jev
Jev to model decyzyjny typu „System One”. Podczas gdy zwykłe modele językowe takie jak GPT-4 odpowiadają w formie otwartych tekstów, Jev przyjmuje dane w formie strukturyzowanej lub niestrukturyzowanej i zwraca sformułowane decyzje, wartości pewności oraz rozkłady prawdopodobieństwa. Oferuje trzy podstawowe funkcje.
- Choice wybiera jeden etykietę z ustalonej listy i zwraca ją wraz z wartością pewności oraz pełnym rozkładem prawdopodobieństwa. Nadaje się do podejmowania decyzji dotyczących dostępu do narzędzi (
allow,deny,confirm,review) oraz do klasyfikacji intencji. - Score umieszcza wartość na skali porządkowej, ponownie wraz z wartością pewności. Nadaje się do oceny ryzyka w finansach lub polityce (
Niskie,Umiarkowane,Wysokie,Krytyczne) oraz do oceny stopnia powagi. - Noul podaje prawdopodobieństwo w zakresie od
0.0do1.0, że dana warunek jest spełniony. Służy do sprawdzania wartości logicznych, przestrzegania zasad oraz określania pewności ukończenia zadania.
Ustawienia projektu
Agent działa w nowym wirtualnym środowisku Python z zainstalowanymi następującymi zależnościami.
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
Warstwa bezpieczeństwa: jev_utils.py
Ten plik pełni rolę middleware do walidacji, stanowiąc zaporę pomiędzy agentem a jego narzędziami. Zajmuje się dwoma odrębnymi problemami.
Przed wykonaniem: guard_tool_call
Autonomiczne agenty mogą uruchamiać delikatne operacje, takie jak płatności, usuwanie danych lub zwroty, bez żadnej nadzoru. Ta funkcja znajduje się przed każdym wywołaniem narzędzia. Nazwa narzędzia, jego argumenty oraz odpowiednie zasady bezpieczeństwa trafiają do uporządkowanego klasyfikatora, który wydaje decyzję (allow, confirm, review lub deny) wraz z oceną ryzyka.
Po wykonaniu: review_completion
LLM często nie potrafi stwierdzić, czy cel wieloetapowy został rzeczywiście osiągnięty, czy też praca jest nadal niewykonana. Ta funkcja porównuje ostatnią historię stanów z pierwotnym celem użytkownika i oznacza zadanie jako complete lub incomplete, podając przy tym wartość pewności.
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,
}
Orkiestrator: agent_graph.py
Ten plik tworzy cykliczny, stanowy workflow LangGraph i pełni trzy funkcje.
- Decydowanie o dalszych krokach odbywa się w
agent_node. Plik ten czyta historię wiadomości oraz aktualny cel, a następnie wykorzystuje API do wywoływania narzędzi od OpenAI w celu wyboru odpowiedzi lub wywołania narzędzia. - Weryfikacja wykonywanych działań odbywa się w
tools_node. Funkcja ta przerywa wywołania narzędzi proponowane przez model i sprawdza je za pomocąguard_tool_call. Działania o wysokim ryzyku, takie jak zwroty pieniędzy przekraczające 500 dolarów, są blokowane. Zatwierdzone narzędzia są uruchamiane, a ich wyniki zapisywane są wtool_outputs, dzięki czemu ostateczna odpowiedź zawiera ustrukturyzowany zapis sytuacji.
build_agent_graph. Funkcja ta ustala warunkowe rozgałęzianie się od węzła początkowego do agenta, następnie albo do narzędzi, albo do sprawdzenia ukończenia zadania, a na końcu do węzła końcowego. Pętla kontynuuje naprzemienną pracę nad rozumowaniem i używaniem narzędzi aż do zakończenia zadania.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()
main.py Ostatni plik udostępnia agenta przez HTTP za pomocą FastAPI. Jego składniki to:
- Endpoint REST
/chat, który przyjmuje historię wiadomości, bieżącą wiadomość oraz opcjonalny cel. - Przekształcenie otrzymanej historii w formacie JSON na obiekty LangChain
HumanMessageiAIMessageprzeznaczone do zastosowania w skompilowanym grafie. - Wezwanie do grafu z przygotowanym stanem: wiadomościami, celem oraz pustym списkiem
tool_outputs. - Typizowana odpowiedź w formacie Pydantic,
ChatResponse, zawierająca ostatecznąreplyagenta orazstate_snapshotz zawartymi w nimtool_outputsigoal.
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")
)
)
Rozpocznij serwer za pomocą uvicorn:
#Run uvicorn server
$uvicorn main:app --reload --port 8000 --host 0.0.0.0
Następnie wyślij prośbę o dużą zwrot pieniędzy:
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."
}'
Odpowiedź pokazuje, że ochroniarz wykonywa swoje obowiązki. Pracownik odmawia przeprowadzenia zwrotu w wysokości 750 dolarów i wspomina, że zwroty o dużej wartości mogą wymagać dodatkowej weryfikacji lub zatwierdzenia.
{
"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."
}
}
Dlaczego zwrot został zablokowany
Wewnątrz tools_node w pliku agent_graph.py istnieje wyraźna warunek, który określa, czy dane wezwanie zostanie zablokowane:
is_blocked = (
guard["decision"] == "deny"
or (name == "issue_refund" and args.get("amount_usd", 0) > 500)
)
Proszona kwota 750 dolarów przekracza limit 500 dolarów, dlatego is_blocked jest natychmiast ustawiany na true. Za tym wynikiem kryją się dwa czynniki.
- Konfiguracja polityki:
TOOL_POLICIESstanowi, żeissue_refundwymaga zatwierdzenia dla kwot przekraczających 500 dolarów. - Ocena zabezpieczeń: przed wykonaniem
issue_refundfunkcjaguard_tool_callw plikujev_utils.pyocenia poziom ryzyka. Duży transfer środków zazwyczaj otrzymuje ocenęHighlubCritical, albo jakąkolwiek inną niżallow, w wyniku czego proces jest oznaczany jako zablokowany.
Lekcja z projektowania
Jev pełni rolę warstwy bezpieczeństwa, a nie generatora. Model OpenAI (gpt-4.1) zajmuje się rozumowaniem, zwykłą rozmową oraz wyborem narzędzi do wykorzystania. Jev funkcjonuje jako niezależny regulator, który przerywa te żądania przed ich wykonaniem i sprawdza je pod kątem zasad, reguł bezpieczeństwa oraz progów finansowych.