The decision layer¶
A calibrated decision layer for agents you already have.
Two failures in ordinary agent loops have the same root cause: the loop asks one generative model to do two unrelated jobs on every step — decide what to do next, and write the thing. The decision and the writing share one pass, so two things go unchecked.
The wrong tool runs. The model selects a tool in the same breath as it writes prose, and
nothing verifies that selection against anything. A constraint written in a prompt is a
suggestion; a constraint in the provider's tool_choice is a fact — but only if something
checks that the provider obeyed it.
The agent reports success on work that never happened. Stopping is decided in the same breath as the final answer, so the model is its own witness that it finished.
The first of those two jobs is a classification over a fixed set of options. Autoregressive generation is a strange and expensive way to pick one of eight strings — you pay for reasoning tokens, you re-send every tool description so the model can choose between them, and you wait seconds for an answer that was never prose.
jevqueue splits the loop. A decision model decides — Jev, TypeSafe's System One model, or
the offline rules baseline in LocalDecisionModel — and the
generative model is then constrained to that decision and checked against it. It installs into
agents you already run — LangChain, Hermes, Pydantic AI, OpenClaw, or any OpenAI-compatible loop
— without changing their tools, memory, sandboxes, or prompts.
┌──────────────────────────── one decision, ~70–300 ms ──┐
│ │
state ──► │ action : choice over YOUR tools + finish/clarify/delegate
│ goal_met : noul — is the substantive work already done?
│ risk : choice over low/medium/high (triage, not permission)
│ │
└───────────────────────────────┬────────────────────────┘
│
┌─────────────────────────────────────────┼─────────────────────────────────────┐
▼ ▼ ▼ ▼ ▼
static_tool tool_arguments final_response risk_stop delegated
pre-approved model writes ONLY model writes ONLY ask a human unconstrained
payload, zero the arguments the prose first generative step
generative cost (forced tool) (tools forbidden) (no model call) (as before)
What it does¶
Concretely, on every step of an agent loop, this package does six things.
1. It projects the transcript into a bounded, read-only state¶
The decision layer cannot read the whole transcript — it would be unbounded, and it would be
reading someone else's data. So state.py builds a fresh projection on every step and
throws it away:
{
"goal": "the latest user message",
"instructions": "system messages, concatenated once, in order",
"recent_history": ["newest turns that fit, oldest-first"],
"truncated": true
}
Two properties matter more than the shape. The canonical transcript is never touched — not truncated, not reordered, not rewritten; a decision layer that edits the transcript to make its own job cheaper has corrupted the record. And the bound is enforced on the serialized length, not the sum of field lengths, because JSON escaping can multiply a string several times over and a naive character count silently stops holding.
truncated is reported rather than hidden, because the second guard depends on it.
2. It asks three typed questions about that state¶
One request, three answers, each a closed type rather than prose:
| Question | Type | Answers |
|---|---|---|
action |
choice |
which tool to call, or finish / clarify / delegate — over exactly the tools available in this step |
goal_met |
noul |
is the substantive work already done? A probability, asked separately from action |
risk |
choice |
low / medium / high for the selected step — triage, never permission |
The separation of action and goal_met is the load-bearing part. They are asked in the same
request but they cannot see each other's answers, so a model that has just chosen finish
cannot be its own witness that finishing is justified.
3. It runs seven guards, in an order that is load-bearing¶
Governor._dispatch checks, in this order:
- Interrupted? A decision takes a few hundred milliseconds, which is long enough for someone to hit Ctrl-C. Checking after the decision and before the effect is the last honest moment to notice.
- Truncated view and a
finish? Delegate. The one judgment that must never be made from a partial transcript is "the work is already done" — the part that got dropped is exactly where unfinished work would be. A tool route from partial evidence is fine; a stop from partial evidence is how an agent confidently reports success on work it never did. - Confidently high risk? Stop and ask a human. This sits before the confidence gate on purpose: otherwise an action judged risky but with low confidence would fall through to the generative model — i.e. the response to "this might be dangerous and I am not sure" would be to hand it to the component with fewer brakes.
- Unconfident, or explicitly
delegate? Hand it over, unconstrained. This is the designed path, not the failure path. It is supposed to be common. clarify? Ask the user. No generative call — spending one to phrase a question is the cost this package exists to avoid.finish? Only ifgoal_metclears its own, higher threshold.- A tool? Either a pre-approved static payload (schema-validated, and only on a
lowrisk verdict), or the generative model writes the arguments under a hard constraint.
4. It constrains the provider, then verifies what came back¶
This is the part that makes it a decision layer rather than a suggestion:
response = host.generate(force_tool="read_file", directive=...)
called = host.called_tools(response)
if called != ["read_file"]:
raise JevError("The provider ignored the selected tool; execution blocked")
The host sets tool_choice={"type": "function", "function": {"name": ...}} with
parallel_tool_calls=False, and then the result is checked. Providers ignore tool_choice
— under load, on older models, through proxies and compatibility shims. Without the check, a
provider that disregarded the constraint would have its own choice executed, and the decision
layer would have been decorative in exactly the cases where it mattered.
The same check runs inverted for finish: a tool call after a no-tool decision also raises.
5. It distrusts the answer¶
A typed response is schema-valid, not coherent. Four properties are enforced, and each has a failure behind it:
| Check | What it catches |
|---|---|
Not a bool |
isinstance(True, int) is true in Python, so a stray boolean reads as certainty |
| Distribution covers exactly the offered options, sums to 1 | The question and the answer have drifted apart |
| The choice is its own argmax | An internally inconsistent answer where neither half can be believed |
confidence = min(stated, mass[winner]) |
Confidence summarising concentration more optimistically than the mass supports |
None of this makes a judgment correct. It makes an incoherent one loud, which is the only guarantee a client library can honestly offer.
6. It records one event per step¶
Nine paths, and each one is recorded with the probabilities, the resolved model id, the latency and the token usage behind it:
| Path | What happened |
|---|---|
static_tool |
A pre-approved payload ran, with no generative call at all |
tool_arguments |
The provider wrote the arguments for the one selected tool, under a hard constraint |
final_response |
The decision was that the work is done, so the model wrote prose and was forbidden from calling any tool |
clarification |
The user was asked for what was missing — no generative call |
risk_stop |
The step looked destructive, so a human was asked first — no model call |
delegated |
The decision was handed to the generative model unconstrained: the designed path, not a failure |
completion_held |
A finish was refused because completion was not certain enough |
truncated_hold |
A finish was refused because the view of the transcript was partial |
error |
The decision model failed, and on_error decided what happened next |
The benefits¶
Fourteen of them, and each is a property you can point at in the code or observe in a run. They are not all equally important — the first three are the reason the package exists, and the rest are why it is worth the dependency.
Control¶
- The wrong tool cannot run. The provider is constrained to the selected function and the result is checked against it. A provider that ignores the constraint raises before anything executes, so the failure is a retryable error rather than a wrong side effect.
- Completion gets an independent witness.
goal_metis a separate question fromaction, so the two cannot see each other's answers. When they disagree, the stricter completion judgment wins and the turn keeps working. This is the most common real failure of agent loops — confidently reporting success on work that never happened. - A partial view can never produce a "done." A
finishdecided on a truncated transcript is refused, because the dropped history is exactly where unfinished work would be. - Risk is triaged ahead of confidence. Uncertainty about risk never buys an escalation in capability: "this might be dangerous and I am not sure" cannot fall through to the generative model.
- The escape hatch is designed, not accidental.
delegatedis a first-class path, andon_error="delegate"degrades to the old behaviour when the decision service is unreachable. The fast path can afford to be strict precisely because falling off it is cheap and safe.
Assurance¶
- The decision is a value you can gate on, not a sentence you have to trust. It arrives as a typed choice over exactly the offered options, with a distribution and a number. You can threshold on it, compare it, and branch on it.
- Incoherent answers are loud instead of silent. Four checks, each with a failure behind it.
A
JevErrorhas exactly one meaning — no decision was executed — so it is always safe to retry and is never raised after an effect. - Every step leaves a number behind. One event with the path, the probabilities, the resolved model id and the latency. "Why did it do that" becomes a query instead of a transcript re-read, and a behaviour change is attributable to a model version after the fact.
- Your transcript and your record stay intact. The canonical transcript is never edited, and exception messages never carry response bodies, transcript content or credentials.
Cost and reach¶
- The routing decision generates zero tokens. The decision is a classification over a closed set, so it is not something a generative model should be paying reasoning tokens for.
- Some steps cost nothing at all.
static_toolsruns a pre-approved payload with no generative call — validated against the tool's live JSON schema, remote$refrefused, and only on alowrisk verdict.clarificationandrisk_stopalso skip the model. This is the single biggest win available, and it removes generative calls from the dangerous steps specifically. - Routing commands cannot be smuggled in through tool output. Fetched pages and tool results are declared to the model as evidence, never instructions, because a decision layer that reads routing commands out of a scraped page subverts the one component whose job is to be unsubvertible.
Fit¶
- It fits the framework you already run. The
Hostprotocol is seven methods, and any framework that can be described that way can be governed. The decision layer and the framework version move independently — the Hermes integration is a six-line seam rather than a fork. - The decision model is yours to choose.
Governortakes aDecisionModelprotocol, so the hosted judgment model, the offline rules baseline, and anything you plug in yourself all work the same way — and everything that happens after the answer is identical either way.
Install¶
# The package is not on PyPI, so every install names the repository.
pip install "jevqueue @ git+https://github.com/iamvazghen/jevqueue.git" # core: no dependencies
pip install "jevqueue[pydantic-ai] @ git+https://github.com/iamvazghen/jevqueue.git" # the model-wrapper adapter
pip install "jevqueue[langchain] @ git+https://github.com/iamvazghen/jevqueue.git" # the chat-model wrapper
pip install "jevqueue[static] @ git+https://github.com/iamvazghen/jevqueue.git" # validated zero-cost static tool payloads
You need a TypeSafe API key in TYPESAFE_API_KEY to use the hosted decision model. Get one at
typesafe.ai. The package itself does not need one — see below. The
test suite does not need it either — see Verification.
Try it in ten seconds, with no key¶
The hosted decision model needs a credential. The package does not have to.
from jevqueue import Governor, LocalDecisionModel
governor = Governor(LocalDecisionModel())
LocalDecisionModel answers the same three questions with offline rules, so the whole
machinery — the constraint, the verification, the gates — runs with no key and no network. It
is the hand-written-rules baseline rather than a judgment model, and
docs/local-decision-model.md is explicit about where it stops
being useful. With a key, the swap is one name:
from jevqueue import Governor, JevClient
governor = Governor(JevClient(key))
A runnable version of the whole thing — including a provider that ignores its constraint and
is refused before it executes — is in examples/local_first.py.
Or run it with no code at all:
pip install "jevqueue @ git+https://github.com/iamvazghen/jevqueue.git"
python -m jevqueue
Works with¶
| Framework | Integration | Install | Verified against |
|---|---|---|---|
| Any OpenAI-compatible loop | OpenAIChatHost |
core | logic tested; a live provider is yours to run |
| LangChain | GovernedChatModel, a chat-model wrapper |
jevqueue[langchain] |
tested against a real BaseChatModel; not run against a live provider |
| Hermes Agent | a six-line seam in agent/turn_api_call.py |
core | logic tested; not run against a live checkout |
| Pydantic AI | GovernedModel, a WrapperModel |
jevqueue[pydantic-ai] |
logic tested; not run against a live provider |
| OpenClaw | OpenClawRouter — fleet routing, not governed |
core | transport properties tested; not run against a live gateway |
| Your own | seven methods — see docs/adapters.md | core | — |
The last column is the honest one, and it is the row to read before you adopt: the decision parsing, guards, projection, config and transport are pinned by a hermetic suite, but the adapters have not been round-tripped against live frameworks in CI. That is exactly the part a contributor can close, and CONTRIBUTING.md says how.
LangChain — wrap the chat model, change nothing else¶
from langchain.agents import create_agent
from langchain.chat_models import init_chat_model
from jevqueue.adapters.langchain import GovernedChatModel
model = GovernedChatModel(init_chat_model("openai:gpt-4o"))
agent = create_agent(model, tools=[check_port, read_log])
Your tools, your chain, your create_agent call are untouched. LangChain has a real
tool_choice, and a named one is a provider-level constraint rather than a prompt
instruction — so the forced tool is not advice. To finish, the request is re-issued with no
tools bound at all, leaving prose as the only legal move.
Two honest limits: a provider that cannot constrain is governed less tightly rather than unsafely (the check catches it), and streaming is not governed — this wrapper implements the non-streaming path.
Pydantic AI — wrap the model, change nothing else¶
from pydantic_ai import Agent
from jevqueue.adapters.pydantic_ai import GovernedModel
agent = Agent(GovernedModel("openai:gpt-4o"), tools=[check_port, read_log])
print(agent.run_sync("Is port 8080 listening? If not, check uptime.").output)
Your tools, prompts, and run calls are untouched. Pydantic AI has no tool_choice, so the
constraint is applied by re-issuing the request with function_tools filtered to the chosen
tool and allow_text_output=False — one legal move, using only documented fields.
Hermes Agent — one seam, six lines¶
Hermes funnels every provider call through agent/turn_api_call.py, so the install is a seam
rather than a rewrite. Full copy-paste in docs/install-hermes.md;
the shape is:
# agent/turn_api_call.py
def _perform_api_call(next_api_kwargs):
governor = getattr(agent, "_jevqueue", None)
if governor is not None:
return governor.call(agent, next_api_kwargs, _generate_api_call)
return _generate_api_call(next_api_kwargs)
Enable per profile, so switching it off is one line and not an uninstall:
jev:
enabled: true
confidence_threshold: 0.8
on_error: delegate
Tools, sandboxes, session persistence, memory, skills, and compression stay upstream code.
OpenClaw — route the fleet¶
OpenClaw runs its own loop in Node, so there is no Python seam inside it. This adapter does not claim one. What it governs is the decision OpenClaw genuinely leaves open — which agent gets this, and should a human see it first:
from jevqueue import JevClient
from jevqueue.adapters.openclaw import AgentSpec, OpenClawRouter
router = OpenClawRouter(JevClient(key), [
AgentSpec("finance", "Invoices, bookkeeping, tax deadlines, portfolio questions."),
AgentSpec("personal", "Household bills, appointments, personal errands and admin."),
AgentSpec("security", "Breach alerts, credential hygiene, suspicious access."),
])
route = router.route(inbound_message)
print(router.dispatch(route, inbound_message) if route.routable else f"Hold: {route.reason}")
Four transport properties, each a deliberate refusal: commands are built as argv, never a
shell string; the message crosses in a file so nothing re-parses & or a quote;
--deliver is never passed, so a turn produces a reply and sends nothing; and the child
environment is an allowlist built from nothing.
Anything OpenAI-compatible¶
from jevqueue import Governor, JevClient
from jevqueue.adapters.openai_chat import OpenAIChatHost
governor = Governor(JevClient(key))
response = governor.step(OpenAIChatHost(request, lambda r: client.chat.completions.create(**r)))
Measured, not claimed¶
The package tells you to measure on your own traffic rather than trusting anyone's marketing — including this README's. So the benchmark is a harness you can run, not a number you have to believe, and a published run is committed:
uv run python bench/run_bench.py --model local
TYPESAFE_API_KEY=... uv run python bench/run_bench.py --model hosted --json results.json
A published run of the offline rules baseline over the labelled corpus in bench/:
decisions 70
routing accuracy 100.0% (70/70)
enforced rate 71.4% (50/70)
decision_ms p50/p95 0.395 / 0.496
input tokens/decision 215.1
path distribution tool_arguments 35, delegated 20, risk_stop 10, final_response 5
Two things in that are worth reading twice. The rules picked the right tool every time and
acted on only 71% of them — the gap is the honest part, because a correct decision that is
not enforced is worth much less than a correct decision that is. The two risk_stop cases
are the point of the whole exercise: delete_file and deploy_service were both triaged as
high risk and stopped before the provider was called at all.
The corpus is fourteen labelled scenarios, so read it as "this baseline, on these cases" and not as a claim about the world. The harness is the durable artifact; run it on your own transcripts and compare two decision models on identical inputs.
When not to use it¶
Stated here rather than left in docs/limits.md, because the failure mode of a decision layer
is to be adopted by someone who does not need one.
- You have one tool. There is nothing to choose between.
- Your agent already runs a cheap local model. The routing decision is nearly free already, and this adds a network hop to save less than it costs.
- You want the model to plan. This decides one step at a time. It does not decompose a goal and it does not sequence steps.
- You cannot tolerate a network dependency in the control flow. Use
LocalDecisionModel. - Your step count is very high and your decision is easy. Per-call cost is a line item then, not a rounding error.
What it does need: a provider that can be constrained to a named tool, and somewhere to record what happened. Both are ordinary.
What it costs, and what it saves¶
Honest accounting, because the interesting number is not the one in the benchmark table.
| Standard loop | With jevqueue |
|
|---|---|---|
| Routing decision | Full generative pass; tokens spent to emit an enum | One decision call, no tokens generated |
| Tool descriptions | Re-sent every step so the model can choose | Not sent for the decision; the criteria are scored instead |
| Deciding to stop | Same pass that chose to stop | Separate, independently gated judgment |
| Risk triage | Prompt instructions, if any | A typed answer with a calibrated probability |
| Provider ignoring tool choice | Executes whatever it called | Raises; nothing executes |
| Routine fixed calls | Always a generative call | static_tools: schema-validated, zero generative cost |
| Why did it do that | Read the transcript | One event: path, probabilities, resolved model |
One caveat on the second row, because that row is the one that gets quoted. Deferring tool schemas until they are needed is no longer a differentiator on its own: Anthropic's Tool Search Tool is generally available and on by default in Claude Code, and Spring AI ports the pattern to other providers. Saving the tool descriptions is table stakes now. What is left in the rows below it is not: the decision is verified rather than suggested, and completion is judged by a separate question that cannot see the answer it is judging. Neither is available from a schema filter.
Jev costs real money per call, and the governor adds one call per step. It pays for itself
when the generative model you are routing with is expensive and the routing decision is easy —
which is the common case, and is why delegated is a designed path rather than a failure.
It does not pay for itself if your agent is already running a cheap local model, and
docs/limits.md says so plainly.
Numbers from the vendor (~70–300 ms, $0.042/M input tokens) are not reproduced here as
findings. Measure on your own traffic: Governor.events records decision_ms, the resolved
model id, and token usage for exactly this purpose.
Configuration¶
from jevqueue import Governor, GovernorConfig, JevClient
governor = Governor(JevClient(key), GovernorConfig(
confidence_threshold=0.80, # below this, delegate instead of routing
completion_threshold=0.95, # below this, refuse to stop
state_chars=24_000, # bound on the per-step state projection
timeout=5.0,
on_error="delegate", # or "stop"
goal_hint="You operate a home lab and a small business.",
static_tools={"docker_ps": {}}, # zero-cost pre-approved payloads
))
The two thresholds are deliberately not one knob. The failure modes are not symmetric: a
needless delegation costs a few cents, while a turn that stops early hands the user a confident
answer about work that never happened. Unknown config keys are an error, not a warning — a
typo in confidence_threshold that is silently ignored leaves the gate at its default while you
believe it was tightened.
Verification¶
uv run pytest # hermetic: no network, no API key, no provider
uv run python scripts/verify_live.py # opt-in, needs TYPESAFE_API_KEY, costs a few cents
The suite is offline by design. A test suite that needs a paid credential does not run in CI, and one that does not run in CI is decoration. Live verification is a separate script you run deliberately.
The offline rules baseline is what makes that possible without giving anything up:
LocalDecisionModel is a real decision model as far as the governor is concerned, so the
constraint, the verification and every gate are exercised against it with no service behind
them.
Documentation¶
| docs/architecture.md | The two-tier split, the guard order, and why each guard sits where it does |
| docs/adapters.md | Writing a Host for a framework not listed here |
| docs/install-hermes.md | Copy-paste Hermes integration and how to keep merging upstream |
| docs/limits.md | What this cannot do, and when not to use it |
| docs/comparison.md | Against a standard loop, and against other routing approaches |
| docs/local-decision-model.md | The offline rules baseline: what it is, what it is not, and how to use it |
| docs/roadmap.md | Possible improvements, with the reasoning for each |
Three runnable examples: examples/local_first.py governs a step
with no key and no network; examples/openai_loop.py is a
complete governed agent loop in about fifty lines; and
examples/fleet_router.py routes across a real 18-agent roster
covering 83 utilities — adjacent remits included, which is where a router either earns its keep
or quietly guesses.
There is also a docs site: https://iamvazghen.github.io/jevqueue/.
Security¶
Credentials are read from the environment or the host framework's own secret scope, never from this package's configuration — a credential should have exactly one home. Exception messages carry status codes but never response bodies, transcript content, or keys. Tool output and fetched pages are declared to the model as evidence, never instructions, because a decision layer that reads routing commands out of a scraped page subverts the one component whose job is to be unsubvertible.
Report vulnerabilities per SECURITY.md. Please do not open a public issue for one.
Contributing¶
CONTRIBUTING.md covers the dev loop and the review bar. The short version: a new guard needs a test that fails without it, and docstrings explain why a check exists, not what the line below does.
If you are adding an adapter for a framework that is not listed above, there is a skill that
lays out the protocol, the four mandatory tests and the two reference implementations:
skills/jev-decision-layer-adapter.
License¶
MIT. See NOTICE for attribution.
jevqueue is an independent community project. It is not affiliated with, endorsed by, or
supported by TypeSafe AI or Nous Research. Jev and TypeSafe are names of a separately
hosted service; this repository distributes no model weights, API keys, or usage credits, and
use of the service is subject to TypeSafe's terms.