Skip to content

Commit 759280f

Browse files
authored
Merge pull request #11 from runcycles/feat/v0.1.5-model-gate
feat(v0.1.5): CyclesModelGate via wrap_model_call (architecture parity)
2 parents 3855142 + bf7d83a commit 759280f

11 files changed

Lines changed: 995 additions & 10 deletions

File tree

AUDIT.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# langchain-runcycles — Middleware API Conformance Audit
22

33
**Date:** 2026-05-10
4-
**Package:** `langchain-runcycles` v0.1.4
4+
**Package:** `langchain-runcycles` v0.1.5
55
**LangChain target:** `langchain >= 1.0, < 2.0`, `langchain-core >= 1.0, < 2.0` (tested against `langchain==1.2.18`, `langchain-core==1.3.3`, `langgraph==1.1.10`)
66
**Cycles SDK target:** `runcycles >= 0.4.1` (tested against `runcycles==0.4.1`, Python 3.10+)
77
**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:
4141
|---|---|---|
4242
| `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)`. |
4343
| `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. |
4446
| `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. |
4547
| `abefore_model(self, state, runtime)` | `langchain_runcycles/fanout.py:113` | Async. Same contract. |
4648

@@ -113,8 +115,9 @@ Locked down by `tests/test_tool_gate.py::test_idempotency_keys_are_deterministic
113115

114116
## Test coverage
115117

116-
- 85 tests across:
118+
- 115 tests across:
117119
- `tests/test_tool_gate.py`, `tests/test_tool_gate_async.py` — sync + async tool-gate paths (including settlement_error_policy raise/log, idempotency-key determinism, and v0.1.3 namespace static/callable/no-namespace/cross-run-collision)
120+
- `tests/test_model_gate.py`, `tests/test_model_gate_async.py` — sync + async model-gate paths (v0.1.5+); decide allow/deny, reserve lifecycle, settlement raise/log, namespace
118121
- `tests/test_fanout.py`, `tests/test_fanout_async.py` — sync + async fan-out paths (including state-derived idempotency namespace)
119122
- `tests/test_examples.py` — import smoke for bundled examples
120123
- `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
126129
## Known limitations (v0.1.x)
127130

128131
- **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.
130133
- **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.
131134
- **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`.
132135
- **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`.

CHANGELOG.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,43 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

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`.
20+
- **`settlement_error_policy`** parity (default `"raise"`).
21+
- **`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+
845
## [0.1.4] - 2026-05-10
946

1047
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
85122
- Examples: `tenant_budget_agent.py` (tenant cap + risky-tool denial) and `multi_agent_fanout.py` (multi-agent / HITL flow).
86123
- `AUDIT.md` documenting LangChain middleware API conformance (hooks, ToolMessage shape, jump_to semantics, SDK methods consumed).
87124

125+
[0.1.5]: https://github.com/runcycles/langchain-runcycles/compare/v0.1.4...v0.1.5
88126
[0.1.4]: https://github.com/runcycles/langchain-runcycles/compare/v0.1.3...v0.1.4
89127
[0.1.3]: https://github.com/runcycles/langchain-runcycles/compare/v0.1.2...v0.1.3
90128
[0.1.2]: https://github.com/runcycles/langchain-runcycles/compare/v0.1.1...v0.1.2

README.md

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,23 +6,25 @@
66

77
# Cycles for LangChain — AI agent middleware for budget and action authority
88

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.
1010

1111
Built on LangChain's [`AgentMiddleware`](https://docs.langchain.com/oss/python/langchain/middleware/) API:
1212

13+
- **`wrap_model_call`** — pre-model-call authorization plus optional reserve/commit/release lifecycle around each LLM invocation (v0.1.5+)
1314
- **`wrap_tool_call`** — tool-call authorization plus optional reserve/commit/release lifecycle around each tool execution
1415
- **`before_model`** (with `@hook_config(can_jump_to=["end"])`) — fan-out caps and external policy halts before another model turn
1516

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).
1718

1819
Install via `pip install langchain-runcycles`.
1920

2021
## What's in the box
2122

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.
2224
- **`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.
2325
- **`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.
2426

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.
2628

2729
## Installation
2830

@@ -61,6 +63,24 @@ If `client.decide()` denies the call, `send_email` is never invoked — the mode
6163

6264
## Middleware
6365

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`.
69+
70+
```python
71+
from langchain_runcycles import CyclesModelGate
72+
73+
model_gate = CyclesModelGate(
74+
client,
75+
subject=Subject(tenant="acme", agent="researcher"),
76+
action=Action(kind="llm.completion", name="gpt-4o"),
77+
mode="reserve",
78+
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).
83+
6484
### `CyclesToolGate`
6585

6686
Gates each tool call. Three modes:

docs/runcycles.mdx

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,74 @@ agent.invoke({"messages": [{"role": "user", "content": "Email alice@example.com
122122

123123
---
124124

125+
## CyclesModelGate: pre-model-call authorization (v0.1.5+)
126+
127+
`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
138+
139+
model_gate = CyclesModelGate(
140+
client,
141+
subject=Subject(tenant="acme", agent="researcher"),
142+
action=Action(kind="llm.completion", name="gpt-4o"),
143+
mode="reserve",
144+
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
161+
162+
agent = create_agent(
163+
model="claude-sonnet-4-6",
164+
tools=[search, send_email],
165+
middleware=[
166+
CyclesFanOutGate(
167+
max_turns=20,
168+
client=client,
169+
subject=Subject(tenant="acme"),
170+
action=Action(kind="model.turn", name="research"),
171+
),
172+
CyclesModelGate(
173+
client,
174+
subject=Subject(tenant="acme", agent="researcher"),
175+
action=Action(kind="llm.completion", name="claude-sonnet-4-6"),
176+
mode="reserve",
177+
estimate=Amount(unit=Unit.USD_MICROCENTS, amount=2_000_000),
178+
),
179+
CyclesToolGate(
180+
client,
181+
subject=Subject(tenant="acme", agent="researcher"),
182+
action={"send_email": Action(kind="tool.call", name="send_email")},
183+
mode="decide+reserve",
184+
),
185+
],
186+
)
187+
```
188+
189+
This order (fan-out first, then model, then tool) means runaway loops halt before model spend, and model spend is reserved before tool side effects.
190+
191+
---
192+
125193
## CyclesToolGate: pre-tool-call authorization
126194

127195
`CyclesToolGate` overrides `wrap_tool_call` to gate every tool call. It supports three modes:

langchain_runcycles/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,15 @@
2424
TurnCounter,
2525
)
2626
from langchain_runcycles.fanout import CyclesFanOutGate
27+
from langchain_runcycles.model_gate import CyclesModelGate
2728
from langchain_runcycles.tool_gate import CyclesToolGate, Mode, SettlementErrorPolicy
2829

2930
__all__ = [
3031
"ActionConfig",
3132
"ActionExtractor",
3233
"ActionMap",
3334
"CyclesFanOutGate",
35+
"CyclesModelGate",
3436
"CyclesToolGate",
3537
"DenialFormatter",
3638
"IdempotencyNamespace",

0 commit comments

Comments
 (0)