You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
**Cycles SDK target:**`runcycles >= 0.4.1` (tested against `runcycles==0.4.1`, Python 3.10+)
7
7
**Server audit:** Cycles protocol conformance is owned by [`cycles-client-python/AUDIT.md`](https://github.com/runcycles/cycles-client-python/blob/main/AUDIT.md). This document audits this package's contract with the LangChain agent middleware API only.
@@ -41,6 +41,8 @@ Compared the following across LangChain documentation and this package's source:
41
41
|---|---|---|
42
42
|`wrap_tool_call(self, request, handler)`|`langchain_runcycles/tool_gate.py:80`| Sync. Reads `request.tool_call['name'/'args'/'id']` and `request.state` (best-effort). Returns `ToolMessage` on deny, else `handler(request)`. |
43
43
|`awrap_tool_call(self, request, handler)`|`langchain_runcycles/tool_gate.py:160`| Async. Awaits the SDK; awaits `handler(request)` if it returns a coroutine. |
44
+
|`wrap_model_call(self, request, handler)`|`langchain_runcycles/model_gate.py:108`| Sync (v0.1.5+). Reads `request.state` (best-effort). Returns `ModelResponse(result=[AIMessage(...)])` on deny — agent terminates naturally because the AIMessage has no `tool_calls`. |
45
+
|`awrap_model_call(self, request, handler)`|`langchain_runcycles/model_gate.py:188`| Async (v0.1.5+). Awaits the SDK; awaits `handler(request)` if it returns a coroutine. |
44
46
|`before_model(self, state, runtime)`|`langchain_runcycles/fanout.py:81`| Sync. Decorated with `@hook_config(can_jump_to=["end"])`. Returns `None` when allowed, halt-dict otherwise. |
45
47
|`abefore_model(self, state, runtime)`|`langchain_runcycles/fanout.py:113`| Async. Same contract. |
46
48
@@ -113,8 +115,9 @@ Locked down by `tests/test_tool_gate.py::test_idempotency_keys_are_deterministic
-`tests/test_examples.py` — import smoke for bundled examples
120
123
-`tests/integration/test_live_agent.py` — `create_agent` construction with our middleware against a `FakeMessagesListChatModel`, verifying the AgentMiddleware contract is satisfied at runtime
@@ -126,7 +129,7 @@ Locked down by `tests/test_tool_gate.py::test_idempotency_keys_are_deterministic
126
129
## Known limitations (v0.1.x)
127
130
128
131
-**Reserve mode commits at estimate**, not at actual usage. Tool-level cost instrumentation is left to the caller. A future revision may expose a `cost_fn` analogous to `stream_reservation`. Locked down by `tests/test_tool_gate.py::test_commit_called_with_configured_estimate`.
129
-
-**No model-call middleware yet.**`CyclesModelGate` (using `wrap_model_call`) is planned for v0.2; v0.1.x covers tool-call gating and fan-out caps only. For LLM-spend tracking today, use `runcycles.stream_reservation` directly inside an LLM-spend handler.
132
+
-**Model-call middleware ships at architecture parity, not production parity.**`CyclesModelGate` (v0.1.5+) implements `wrap_model_call` with the same three modes as `CyclesToolGate`, but commits at the configured `estimate` (no provider-specific token extraction yet) and uses a UUID per-call key (model requests don't carry a stable upstream id like `tool_call_id`). Provider extractors (OpenAI, Anthropic) and streaming integration are v0.2.0 scope. For precise actual-cost capture today, use the callback handler from `cycles-client-python` or wait for v0.2.0.
130
133
-**Single tenant per middleware instance** unless you supply a `SubjectExtractor` callable. Per-call subject resolution is fully supported via the callable form; only the static-Subject convenience is single-tenant.
131
134
-**Synthetic `tool_call_id` when missing.** A `ToolCallRequest` with no `id` field has its denial `ToolMessage` correlated via a fabricated `missing-<12-hex>` id, with a warning logged at `langchain_runcycles._internal`. Because the synthesis is fresh per call, the resulting idempotency key on this fallback path is *not* retry-stable. Conformant LangChain runtimes always supply `id`. Locked down by `tests/test_tool_gate.py::test_synthetic_tool_call_id_when_missing`.
132
135
-**Fan-out gate rejects per-tool action mappings.**`CyclesFanOutGate` gates *model turns*, not tool calls; a per-tool-name `Mapping` for `action` is meaningless there and is rejected at construction with `TypeError`. Locked down by `tests/test_fanout.py::test_fanout_rejects_mapping_action`.
Copy file name to clipboardExpand all lines: CHANGELOG.md
+38Lines changed: 38 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -5,6 +5,43 @@ All notable changes to this project will be documented in this file.
5
5
The format is based on [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/),
6
6
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
8
+
## [0.1.5] - 2026-05-10
9
+
10
+
Adds `CyclesModelGate` — pre-model-call authorization middleware — closing the third leg of the LangChain agent governance triad. Closes #10.
11
+
12
+
`CyclesToolGate` already gated tool calls; `CyclesFanOutGate` already capped model turns; this release adds the third middleware so model calls themselves are intercepted via LangChain's `wrap_model_call` hook. With v0.1.5, the package can truthfully say it puts pre-execution authority in front of model calls, tool calls, and runaway agent loops.
13
+
14
+
This is the **architecture milestone** — feature parity with `CyclesToolGate`. The **production milestone** (v0.2.0) will add provider-specific token-cost extractors, streaming integration, and a polished demo agent.
15
+
16
+
### Added
17
+
18
+
-**`CyclesModelGate`** — new `AgentMiddleware` subclass overriding `wrap_model_call` (sync) and `awrap_model_call` (async). On denial in `decide` mode, returns a `ModelResponse` whose `AIMessage` carries the denial reason (the agent terminates naturally because the AIMessage has no `tool_calls`).
19
+
-**All three modes** (`decide` / `reserve` / `decide+reserve`) at parity with `CyclesToolGate`.
-**`idempotency_namespace`** parity (static or callable, callable receives the `ModelRequest`).
22
+
-**Public API exports**: `CyclesModelGate` re-exported from `langchain_runcycles`.
23
+
24
+
### Changed
25
+
26
+
- README "What's in the box" gains a third bullet for `CyclesModelGate`.
27
+
- AUDIT.md hooks table gains `wrap_model_call` / `awrap_model_call` rows; "No model-call middleware yet" line removed from Known Limitations.
28
+
-`docs/runcycles.mdx` gets a third middleware section + a 3-class composition example.
29
+
30
+
### Known limitations (carried into v0.2.0)
31
+
32
+
-**Commits at the configured `estimate`**, not actual token cost. Provider-specific token extraction (OpenAI, Anthropic) is v0.2.0 scope. For precise per-call actual-cost capture today, use the callback handler from `cycles-client-python` instead, or until v0.2.0 ships.
33
+
-**No streaming integration**. For streaming LLM calls, use `runcycles.stream_reservation` directly. v0.2.0 may add streaming support inside `wrap_model_call`.
34
+
-**Per-call key uses UUID fallback**. Model-call requests don't carry an upstream-stable id like `tool_call_id`, so each call gets a fresh UUID for the per-call slot. Namespacing (`model-decide-{namespace}-{32-hex}`) still scopes by run/workflow/tenant. v0.2.0 may extract a turn-id or message-hash for full retry-stability.
35
+
36
+
### Test coverage
37
+
38
+
- 28 new tests (115 total, was 87). Coverage 99.07% (gate 95%).
39
+
- Sync + async parity for all paths; dedicated tests for the deny-path `ModelResponse` shape.
40
+
41
+
### Backward compatibility
42
+
43
+
Purely additive. v0.1.4 callers' code unchanged.
44
+
8
45
## [0.1.4] - 2026-05-10
9
46
10
47
External review of v0.1.3 caught two real defects (stale README dep line, type alias narrower than the documented per-call opt-out) plus a docs-example issue (undefined helper in a runnable-looking snippet). All fixed; no functional change.
@@ -85,6 +122,7 @@ Initial public release. First-class LangChain agent middleware integration for C
85
122
- Examples: `tenant_budget_agent.py` (tenant cap + risky-tool denial) and `multi_agent_fanout.py` (multi-agent / HITL flow).
Copy file name to clipboardExpand all lines: README.md
+23-3Lines changed: 23 additions & 3 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -6,23 +6,25 @@
6
6
7
7
# Cycles for LangChain — AI agent middleware for budget and action authority
8
8
9
-
**LangChain middleware for pre-tool-call authorization, fan-out caps, and per-tenant budget enforcement in `create_agent` workflows.** Provider-neutral: works with any LangChain 1.x agent regardless of model provider, as long as actions flow through LangChain middleware/tool execution.
9
+
**LangChain middleware for pre-execution budget authority over model calls, tool calls, and runaway agent loops in `create_agent` workflows.** Provider-neutral: works with any LangChain 1.x agent regardless of model provider, as long as actions flow through LangChain middleware/tool execution.
10
10
11
11
Built on LangChain's [`AgentMiddleware`](https://docs.langchain.com/oss/python/langchain/middleware/) API:
12
12
13
+
-**`wrap_model_call`** — pre-model-call authorization plus optional reserve/commit/release lifecycle around each LLM invocation (v0.1.5+)
13
14
-**`wrap_tool_call`** — tool-call authorization plus optional reserve/commit/release lifecycle around each tool execution
14
15
-**`before_model`** (with `@hook_config(can_jump_to=["end"])`) — fan-out caps and external policy halts before another model turn
15
16
16
-
Model-call reservation via `wrap_model_call` is on the roadmap but **not implemented in v0.1.x**. For token-level streaming budget tracking today, use `runcycles.stream_reservation` directly inside an LLM-spend handler.
17
+
Per-call actual-cost extraction (provider-specific token-usage parsing) and streaming integration are v0.2.0 scope. Until then, `CyclesModelGate` commits at the configured `estimate`; for precise per-call token capture today, use the `BaseCallbackHandler` recipe in [`cycles-client-python/examples/langchain_integration.py`](https://github.com/runcycles/cycles-client-python/blob/main/examples/langchain_integration.py).
17
18
18
19
Install via `pip install langchain-runcycles`.
19
20
20
21
## What's in the box
21
22
23
+
-**`CyclesModelGate`** (v0.1.5+) — runs before every model call. Authorizes via `client.decide()` and/or reserves budget. Returns a `ModelResponse` carrying the denial reason on deny so the agent terminates naturally.
22
24
-**`CyclesToolGate`** — runs before every tool call. Authorizes via `client.decide()` and/or reserves budget via `client.create_reservation()`. Returns a `ToolMessage` on denial so the model can recover gracefully.
23
25
-**`CyclesFanOutGate`** — runs before every model turn. Halts the agent (with `jump_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.
24
26
25
-
Both work with sync or async LangChain agents and the sync (`CyclesClient`) or async (`AsyncCyclesClient`) Cycles client.
27
+
All three work with sync or async LangChain agents and the sync (`CyclesClient`) or async (`AsyncCyclesClient`) Cycles client. Compose them in a single `middleware=[...]` list — typical order is `[CyclesFanOutGate, CyclesModelGate, CyclesToolGate]` so fan-out caps trigger before model spend before tool side effects.
26
28
27
29
## Installation
28
30
@@ -61,6 +63,24 @@ If `client.decide()` denies the call, `send_email` is never invoked — the mode
61
63
62
64
## Middleware
63
65
66
+
### `CyclesModelGate` (v0.1.5+)
67
+
68
+
Gates each model call. Same three modes as `CyclesToolGate`. On denial in `decide` mode, returns a `ModelResponse` whose `AIMessage` carries the denial reason — the agent terminates naturally because the AIMessage has no `tool_calls`.
estimate=Amount(unit=Unit.USD_MICROCENTS, amount=2_000_000), # $0.02 per call
79
+
)
80
+
```
81
+
82
+
> v0.1.5 commits at the configured `estimate`. Per-call actual-cost extraction (token usage from provider response metadata) and streaming integration land in v0.2.0. For precise per-call token cost capture today, use the `BaseCallbackHandler` recipe in [`cycles-client-python/examples/langchain_integration.py`](https://github.com/runcycles/cycles-client-python/blob/main/examples/langchain_integration.py).
`CyclesModelGate` overrides `wrap_model_call` to gate every LLM invocation. Same three modes as `CyclesToolGate`. On denial in `decide` mode, returns a `ModelResponse` whose `AIMessage` carries the denial reason: the agent terminates naturally because the `AIMessage` has no `tool_calls`.
128
+
129
+
| Mode | Behavior |
130
+
| :--- | :--- |
131
+
|`"decide"`| Calls `client.decide()`. Denies the model call on a non-allow decision. No reservation. |
132
+
|`"reserve"`| Creates a reservation, calls the model, commits on success (at the configured `estimate`), releases on exception. |
133
+
|`"decide+reserve"`| Authorizes via `decide()` first, then reserves and commits at the configured `estimate`. Most strict. |
134
+
135
+
```python CyclesModelGate icon="cpu"
136
+
from langchain_runcycles import CyclesModelGate
137
+
from runcycles import Action, Amount, Subject, Unit
estimate=Amount(unit=Unit.USD_MICROCENTS, amount=2_000_000), # $0.02 per call
145
+
denial_message="Cycles denied this model call: {reason}",
146
+
)
147
+
```
148
+
149
+
<Tip>
150
+
v0.1.5 commits at the configured `estimate` rather than extracting actual token usage from the provider response. Provider-specific token extractors (OpenAI, Anthropic) and streaming integration land in v0.2.0. For precise per-call token cost capture today, the `BaseCallbackHandler` recipe in `cycles-client-python` works alongside this middleware.
151
+
</Tip>
152
+
153
+
### Compose all three gates
154
+
155
+
The full LangChain agent governance triad (fan-out caps, model-spend reservation, and tool-call authorization) uses one `middleware=[...]` list:
156
+
157
+
```python All three gates icon="layer-group"
158
+
from langchain.agents import create_agent
159
+
from langchain_runcycles import CyclesFanOutGate, CyclesModelGate, CyclesToolGate
160
+
from runcycles import Action, Amount, Subject, Unit
0 commit comments