AI agent tools
Give an LLM agent list_rules, evaluate_rules and explain_rule so it checks before it acts - without letting it decide, name its own roles, or accept its own risks.
bindings/llm-tools.json
defines three strict function tools. Your program is the tool host: it receives a call, asks a
runtime or the rule server, and returns the answer.
| Tool | Arguments | Host answers with |
|---|---|---|
list_rules | ruleset, entity, operation | The rules of the server manifest for that entity and operation |
evaluate_rules | ruleset, entity, operation, data_json, original_json, view, resolutions | The evaluation result, unchanged |
explain_rule | ruleset, rule_id | One rule of the server manifest |
The actor is not an argument. The host takes it from its own authentication: a model that could name its own roles could accept its own risks.
Steps
Give the model the tools
The file is in the shape of the OpenAI Responses API and written for strict mode. Send its tools
array as it is. For Chat Completions, wrap each one as
{"type": "function", "function": {name, description, parameters, strict}}. As MCP tools, use
parameters as the inputSchema.
import json
tools = json.load(open("bindings/llm-tools.json"))["tools"] # drop the top-level $commentImplement the host
Against a runtime in process (here Python, run from the repository root with
packages/python/src on PYTHONPATH):
import json
from pathlib import Path
from rule_cascade import RuleSet
rulesets = {p.name[:-len(".bundle.json")]: RuleSet.from_bundle(json.loads(p.read_text(encoding="utf-8")))
for p in Path("conformance/bundles").glob("*.bundle.json")}
def list_rules(ruleset, entity, operation):
return [{"id": r["id"], "kind": r["kind"], "title": r.get("title"), "severity": r.get("severity")}
for r in rulesets[ruleset].manifest("server")["rules"]
if r["target"].get("entity", entity) == entity and (operation is None or operation in r["operations"])]
def evaluate_rules(actor, ruleset, entity, operation, data_json, original_json, view, resolutions):
return rulesets[ruleset].evaluate({
"entity": entity, "operation": operation,
"data": json.loads(data_json),
"original": json.loads(original_json) if original_json else None,
"view": {k: v for k, v in view.items() if v is not None},
"resolutions": [{k: v for k, v in r.items() if v is not None} for r in resolutions],
"actor": actor, # from the host's authentication, never from the model
})
def explain_rule(ruleset, rule_id):
return next((r for r in rulesets[ruleset].manifest("server")["rules"] if r["id"] == rule_id), None)Against the rule server instead: list_rules is GET /rulesets/{ruleset}/manifest?channel=server
filtered the same way, evaluate_rules is POST /evaluations, explain_rule is
GET /rulesets/{ruleset}/rules/{rule_id}, all with the server token.
Dispatch the calls, with the actor from your session
HANDLERS = {"list_rules": list_rules, "evaluate_rules": evaluate_rules, "explain_rule": explain_rule}
def handle_tool_call(name, arguments_json, session_actor):
arguments = json.loads(arguments_json)
if name == "evaluate_rules":
return json.dumps(evaluate_rules(session_actor, **arguments))
return json.dumps(HANDLERS[name](**arguments))A domestic transfer of 30,000 proposed by an agent acting for a teller comes back denied, with
two findings (summarised: decision, then code, resolution and acceptableBy of each):
deny ORG-TRF-002 resolution=accept-risk acceptableBy=['risk-officer']
PAY-TRF-003 resolution=acknowledgeKeep the agent honest
evaluate_ruleschecks; it does not perform. The API operation that follows evaluates again on the server, whatever the tool returned.- Return the result unchanged:
decision,blocking,resolutionandacceptableBytell the model what the user may do next. - A resolution comes from the user. Ask before sending
acknowledge; never let the model write the justification of anaccept-risk. - The server manifest contains server-only rules. If the model's output reaches people who must not
learn them, answer
list_rulesandexplain_rulefrommanifest("client")instead. - A ruleset with custom operators needs them registered in the host's runtime.
Optional: let the model draft rules
bindings/rule-draft.response-format.json
constrains a model to one syntactically valid validation rule. A draft is not a rule until it passes
the schema, the load checks, a golden test and review, like any other: see
drafting a rule.
Done when the agent calls evaluate_rules before every create, update or delete; the actor in
every evaluation comes from your session; a denied operation is never reported as done; and the
API's own server evaluation still runs after the tool call.
Batch processing
Evaluate thousands of records against one bundle, reproducibly - with a native library, or through one engine process from any language.
Multi-level inheritance and overrides
An enterprise or organisation baseline, rulesets per business unit, application and feature that extend it, and rules targeted down to a single field - with the parent deciding what a child may change.