LangChain agent middleware for Cycles — pre-tool-call authorization, fan-out caps, and per-tenant budget enforcement for Python agents using create_agent.
Project description
LangChain Runcycles — AI agent middleware for budget and action authority
LangChain agent middleware for AI agent governance — enforce cost limits, tool permissions, and multi-tenant policies in create_agent workflows before LLM calls or tool actions execute. Works with LangGraph, LangSmith, OpenAI, Anthropic, MCP servers, and any LangChain 1.x agent runtime — built on the new AgentMiddleware API (wrap_tool_call, before_model, wrap_model_call).
AgentMiddleware subclasses for the Cycles Protocol: gate every tool call with wrap_tool_call, cap model fan-out with before_model + jump_to: "end", reserve and commit budget per call — with sync and async support, typed configuration, and optional remote policy decisions. Install via pip install langchain-runcycles.
What's in the box
CyclesToolGate— runs before every tool call. Authorizes viaclient.decide()and/or reserves budget viaclient.create_reservation(). Returns aToolMessageon denial so the model can recover gracefully.CyclesFanOutGate— runs before every model turn. Halts the agent (withjump_to: "end") when a turn cap is hit or when an external policy says to stop. Useful for runaway-loop protection and per-tenant burst caps.
Both work with sync or async LangChain agents and the sync (CyclesClient) or async (AsyncCyclesClient) Cycles client.
Installation
pip install langchain-runcycles
Requires Python 3.10+ and langchain >= 1.0.
Quick Start
from langchain.agents import create_agent
from langchain_core.tools import tool
from langchain_runcycles import CyclesToolGate
from runcycles import Action, CyclesClient, CyclesConfig, Subject
@tool
def send_email(to: str, body: str) -> str:
"""Send an email."""
return f"Sent to {to}"
client = CyclesClient(CyclesConfig(base_url="http://localhost:7878", api_key="..."))
gate = CyclesToolGate(
client,
subject=Subject(tenant="acme", agent="researcher"),
action={"send_email": Action(kind="tool.call", name="send_email")},
mode="decide",
)
agent = create_agent(model="claude-sonnet-4-6", tools=[send_email], middleware=[gate])
agent.invoke({"messages": [{"role": "user", "content": "Email alice."}]})
If client.decide() denies the call, send_email is never invoked — the model receives a ToolMessage with the denial reason and can choose another path.
Middleware
CyclesToolGate
Gates each tool call. Three modes:
| Mode | What it does |
|---|---|
"decide" |
Calls client.decide(). Denies the tool call on a non-allow decision. No reservation. |
"reserve" |
Creates a reservation, runs the tool, commits on success / releases on exception. |
"decide+reserve" |
Authorizes via decide(), then reserves+commits. Most strict. |
gate = CyclesToolGate(
client,
subject=Subject(tenant="acme", agent="researcher"),
action={
"search": Action(kind="tool.call", name="search"),
"send_email": Action(kind="tool.call", name="send_email"),
},
mode="decide+reserve",
)
CyclesFanOutGate
Halts the agent when a turn cap or external policy says stop. Optional client argument enables remote policy checks on each turn:
from langchain_runcycles import CyclesFanOutGate
fanout = CyclesFanOutGate(
max_turns=20,
client=client, # optional — for remote policy
subject=Subject(tenant="acme"),
action=Action(kind="model.turn", name="research"),
)
Pair with CyclesToolGate and HumanInTheLoopMiddleware for production-grade agent governance.
Configuration
Subject
Either a static Subject or a callable resolving from request/state:
from runcycles import Subject
# Static
subject = Subject(tenant="acme", agent="bot")
# Per-call extractor (CyclesToolGate: (request, state); CyclesFanOutGate: (state, state))
def per_tenant(request, state):
return Subject(tenant=state["config"]["tenant"], agent="bot")
Action
Static, mapping (per-tool name), or callable:
from runcycles import Action
# Static
action = Action(kind="tool.call", name="any")
# Per-tool mapping
action = {
"send_email": Action(kind="tool.call", name="send_email"),
"search": Action(kind="tool.call", name="search"),
}
# Callable
def derive(request):
return Action(kind="tool.call", name=request.tool_call["name"])
Denial messages
denial_message accepts a format string (placeholders: {reason}, {tool}, {decision}) or a callable receiving the CyclesResponse:
gate = CyclesToolGate(
client,
subject=...,
action=...,
denial_message="Cycles denied {tool}: {reason}",
)
Error handling
- Denied tool calls return a
ToolMessagewith the denial content; the underlying handler is never invoked. The agent's model sees the denial as if a tool returned an error and can recover. - Reservation failures in
"reserve"mode are returned asToolMessage(handler not invoked). - Tool exceptions in
"reserve"mode trigger an automaticrelease_reservation, then the exception propagates. - Async/sync mismatch raises
TypeError— pairCyclesClientwith.invoke()andAsyncCyclesClientwith.ainvoke().
Async support
Async middleware variants run automatically when the LangChain agent is invoked with .ainvoke(). Pass an AsyncCyclesClient:
from runcycles import AsyncCyclesClient
async_client = AsyncCyclesClient(CyclesConfig(...))
gate = CyclesToolGate(async_client, subject=..., action=..., mode="decide")
agent = create_agent(model="...", tools=[...], middleware=[gate])
await agent.ainvoke({"messages": [...]})
Examples
examples/tenant_budget_agent.py— single-tenant budget gate with risky-tool denial recovery.examples/multi_agent_fanout.py— multi-agent / HITL flow withCyclesToolGate+CyclesFanOutGate+HumanInTheLoopMiddleware.
Known limitations (v0.1)
- Reserve mode commits at the configured
estimate, not actual usage.mode="reserve"andmode="decide+reserve"reserve the estimate, run the tool, then commit the same amount on success. Per-tool actual-cost instrumentation (analogous toruncycles.stream_reservation'scost_fn) is on the roadmap. Until then, setestimateto the worst-case spend per call you're willing to debit, or usemode="decide"if you only want policy gating without budget movement. - No streaming-LLM cost integration yet.
wrap_model_callis not implemented in v0.1; for token-level streaming budget tracking, useruncycles.stream_reservationdirectly inside an LLM-spend handler. - Per-call subject only via the extractor form. Static
Subjectplays one tenant per middleware instance. For per-tenant/per-agent routing in a multi-tenant deployment, supply aSubjectExtractorcallable. - Synthetic
tool_call_idwhen missing. If aToolCallRequestarrives without anid, the middleware fabricatesmissing-<hex>for theToolMessageand logs a warning. Correct LangChain runtimes always supplyid; this is a defensive fallback.
Development
pip install -e ".[dev]"
pytest # all tests
pytest --cov=langchain_runcycles # with coverage (gate: ≥95%)
ruff check . && ruff format
mypy langchain_runcycles
Documentation
- LangChain integration page: https://docs.langchain.com/oss/python/integrations/middleware/runcycles (pending PR review)
- Cycles protocol & SDK: https://runcycles.io
- Architecture: see AUDIT.md
Requirements
- Python 3.10+
runcycles >= 0.4.1langchain >= 1.0, < 2.0langchain-core >= 0.3
License
Apache-2.0. See LICENSE.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file langchain_runcycles-0.1.1.tar.gz.
File metadata
- Download URL: langchain_runcycles-0.1.1.tar.gz
- Upload date:
- Size: 35.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
97b42b1a14bbb2b6b8d1c8d0138bba867a2a62451e6a93ae8b21d60ff4bffa14
|
|
| MD5 |
7bb626c7b836c5b25c3f2cdfe8424518
|
|
| BLAKE2b-256 |
a82a6d9bb00e679e6c13b557692169e60588a1413e36bf6d7522effc582c5b90
|
Provenance
The following attestation bundles were made for langchain_runcycles-0.1.1.tar.gz:
Publisher:
python-publish.yml on runcycles/langchain-runcycles
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
langchain_runcycles-0.1.1.tar.gz -
Subject digest:
97b42b1a14bbb2b6b8d1c8d0138bba867a2a62451e6a93ae8b21d60ff4bffa14 - Sigstore transparency entry: 1494602766
- Sigstore integration time:
-
Permalink:
runcycles/langchain-runcycles@682b79d3acc3346b00417bc0216a621eefe63f32 -
Branch / Tag:
refs/tags/v0.1.1 - Owner: https://github.com/runcycles
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
python-publish.yml@682b79d3acc3346b00417bc0216a621eefe63f32 -
Trigger Event:
push
-
Statement type:
File details
Details for the file langchain_runcycles-0.1.1-py3-none-any.whl.
File metadata
- Download URL: langchain_runcycles-0.1.1-py3-none-any.whl
- Upload date:
- Size: 17.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
3cfd61746a6e8fc24fd05eaf389db1292074fce2226570084b503853cca58985
|
|
| MD5 |
b79e98b7e4fd4ac99fb481630647fe48
|
|
| BLAKE2b-256 |
77a1e37a82a763be5e40265b1bf020330be129b26f334053b0e63e0dcbb3c488
|
Provenance
The following attestation bundles were made for langchain_runcycles-0.1.1-py3-none-any.whl:
Publisher:
python-publish.yml on runcycles/langchain-runcycles
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
langchain_runcycles-0.1.1-py3-none-any.whl -
Subject digest:
3cfd61746a6e8fc24fd05eaf389db1292074fce2226570084b503853cca58985 - Sigstore transparency entry: 1494602823
- Sigstore integration time:
-
Permalink:
runcycles/langchain-runcycles@682b79d3acc3346b00417bc0216a621eefe63f32 -
Branch / Tag:
refs/tags/v0.1.1 - Owner: https://github.com/runcycles
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
python-publish.yml@682b79d3acc3346b00417bc0216a621eefe63f32 -
Trigger Event:
push
-
Statement type: