Skip to main content

Tool calls and agents

An agent adds two steps to a turn. The model chooses a tool and its arguments, and the tool's response goes back to the model. jes checks both.

This page is for agents you build in Python. For Claude Code, Codex, and other coding agents, the hooks run these checks for you.

The full signatures are in the Guard reference.

check_tool_call​

call = guard.check_tool_call("search", {"query": "refund policy"}, prompt=incoming)
  • name is the tool the model asked for. It must be a non-empty, single-line string of at most 256 characters, with no leading or trailing whitespace. Anything else raises PolicyError.
  • arguments can be the raw argument string, or the argument object. jes serializes an object one canonical way (sorted keys, compact separators, UTF-8), so the checked string is the string an allowed call keeps.
  • prompt is required: the user turn. Context-aware judgments such as tool_safety see it; every judgment sees the tool name.
  • The arguments are never rewritten. Editing the JSON would break the call your application runs, so on this stage secrets and pii block instead of redacting, and placeholders are never restored into arguments. pii(tool_call_mode="flag") lets the call through with a finding instead. Judgments still never see the values they found: the judges' copy of the arguments has them removed. Set Guard(..., fail_fast=True) to skip the judgments once a transform blocks.

check_tool_result​

result = guard.check_tool_result(raw_response, name="search", prompt=incoming)

The tool's response is untrusted text, like a retrieved page. prompt is the user turn, so indirect_injection can judge the response against it. Transforms redact here as they do on untrusted, and pii uses its untrusted_mode.

allowed_tools​

from jes.policies import allowed_tools

allowed_tools(["search", "calculator"])

A transform on the tool_call stage. It blocks any call whose name isn't in the set, with the finding label tool_name. It never edits the arguments.

Leaving allowed_tools out doesn't allow every tool. It means jes doesn't check the name. Your application still decides whether this user may run this tool with these arguments. jes checks text; it doesn't do authorization.

tool_safety​

from jes.policies import tool_safety

tool_safety(threshold=0.5)

A judgment on the tool_call stage. It asks Jev whether the call, given the user's request, "is destructive, exfiltrates data, changes privileges, or goes beyond what the user asked." A user who asked to summarize a README doesn't need rm -rf ~/projects or a curl that posts ~/.aws/credentials.

allowed_tools checks which tool runs. tool_safety checks what the call does with it, so use both. The threshold is required; jes has no measured value for it yet.

What runs on the tool stages by default​

Policytool_calltool_result
allowed_toolsyes (blocks unknown names)—
injectionyes—
tool_safetyyes—
indirect_injection—yes
secretsyes (block, arguments unchanged)yes (redact)
piiyes (block, or flag with tool_call_mode="flag")yes (mask, from untrusted_mode)
canaryyes (blocks a leaked token)—
malicious_urls (recipe)—yes
invisible_text, regex, substrings—yes

The transforms (secrets, pii, canary, invisible_text, regex, substrings) and judge() take stages= to change where they run. The built-in judgments and allowed_tools have fixed stages.

Forward onward on every path​

The tool stages have their own refusals, so the model reads a short message in place of the tool's output. Results has the full onward rules.

call = guard.check_tool_call(name, args, prompt=incoming)
if not call.ok:
tool_message = call.onward # "Tool call blocked."
else:
raw = run_tool(name, args) # your code decides whether it may run
result = guard.check_tool_result(raw, name=name, prompt=incoming)
tool_message = result.onward # sanitized response, or "Tool result blocked."
if result.ok:
tool_history.append(result)

outgoing = guard.check_output(reply, prompt=incoming, history=tool_history)
show(outgoing.onward)
  • Give the model onward in place of the tool message, whether the check allowed or blocked.
  • Pass each allowed check_tool_result as history to check_output, and leave blocked ones out. The reply is then judged against the tool text the model actually saw.
  • In history, a Message(role="tool", text=...) or a tool_result result counts as a tool result, not as an assistant reply.

In your agent framework​

Part 2 of the jes course runs these checks in five complete agents. Each runs on hosted Jev in jev.py and fully on Ollama in local.py, against a clean request, a prompt injection, and a tool that returns a poisoned page: