Skip to content

Commit bf7d83a

Browse files
committed
fix(v0.1.5): reject Mapping action + integration test parity
Two release-readiness gaps caught by self-review on PR #11: 1. CyclesModelGate.__init__ now rejects per-tool Mapping action with a TypeError mirroring CyclesFanOutGate's behavior. Without this check, passing a Mapping (e.g. {'send_email': Action(...)}) to CyclesModelGate would fail at the first model call with the less-informative ValueError 'Action mapping requires a tool name' from resolve_action. Mappings are meaningless for ModelGate (model calls don't carry a tool name); rejecting at construction with a clearer error matches CyclesFanOutGate's pattern. 2. tests/integration/test_live_agent.py extended to: - Confirm CyclesModelGate satisfies the AgentMiddleware protocol - Confirm a real create_agent() call accepts CyclesModelGate as middleware - Confirm all three middleware compose in a single create_agent() call (the canonical v0.1.5+ shape with fan-out -> model -> tool ordering) Without (2), the package had unit-test coverage for ModelGate but no proof that LangChain's create_agent accepts the new middleware at runtime. The new tests catch any future LangChain release that breaks the wrap_model_call signature or hook registration. 118 tests pass (was 115, +3 new). Coverage 99.07%. ruff + mypy clean.
1 parent 33f062e commit bf7d83a

3 files changed

Lines changed: 86 additions & 3 deletions

File tree

langchain_runcycles/model_gate.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from __future__ import annotations
2323

2424
import logging
25+
from collections.abc import Mapping
2526
from typing import TYPE_CHECKING, Any, Literal
2627

2728
from langchain.agents.middleware import AgentMiddleware
@@ -86,6 +87,12 @@ def __init__(
8687
f"Invalid settlement_error_policy {settlement_error_policy!r}; "
8788
f"expected one of {_VALID_SETTLEMENT_POLICIES}."
8889
)
90+
if isinstance(action, Mapping):
91+
raise TypeError(
92+
"CyclesModelGate.action does not support per-tool Mapping: model "
93+
"calls don't carry a tool name. Pass a single Action or a "
94+
"Callable[[ModelRequest], Action]."
95+
)
8996
self._client = client
9097
self._subject = subject
9198
self._action = action

tests/integration/test_live_agent.py

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,15 @@
2222
from langchain_core.tools import tool
2323
from runcycles import Action, CyclesClient, CyclesConfig, CyclesResponse, Subject
2424

25-
from langchain_runcycles import CyclesFanOutGate, CyclesToolGate
25+
from langchain_runcycles import CyclesFanOutGate, CyclesModelGate, CyclesToolGate
2626

2727

2828
def test_classes_satisfy_AgentMiddleware_protocol() -> None:
29-
"""Both middleware classes must inherit from AgentMiddleware so create_agent
30-
accepts them in its `middleware=[...]` list."""
29+
"""All three middleware classes must inherit from AgentMiddleware so
30+
create_agent accepts them in its `middleware=[...]` list."""
3131
assert issubclass(CyclesToolGate, AgentMiddleware)
3232
assert issubclass(CyclesFanOutGate, AgentMiddleware)
33+
assert issubclass(CyclesModelGate, AgentMiddleware)
3334

3435

3536
def test_create_agent_accepts_tool_gate(sync_client: CyclesClient, subject: Any, action: Any) -> None:
@@ -87,6 +88,65 @@ def noop(x: str) -> str:
8788
assert agent is not None
8889

8990

91+
def test_create_agent_accepts_model_gate(sync_client: CyclesClient, subject: Any) -> None:
92+
"""A real `create_agent` call with CyclesModelGate as middleware succeeds.
93+
Verifies the wrap_model_call hook annotation is well-formed."""
94+
95+
@tool
96+
def noop(x: str) -> str:
97+
"""A no-op tool."""
98+
return x
99+
100+
fake_model = FakeMessagesListChatModel(responses=[AIMessage(content="ok")])
101+
model_gate = CyclesModelGate(
102+
sync_client,
103+
subject=subject,
104+
action=Action(kind="llm.completion", name="fake-model"),
105+
mode="decide",
106+
)
107+
108+
agent = create_agent(model=fake_model, tools=[noop], middleware=[model_gate])
109+
assert agent is not None
110+
111+
112+
def test_create_agent_accepts_full_triad(
113+
sync_client: CyclesClient, subject: Any, action: Any
114+
) -> None:
115+
"""All three middleware classes composed in a single create_agent call.
116+
117+
This is the v0.1.5+ canonical shape: fan-out -> model -> tool ordering as
118+
the recommended composition pattern. Verifies the three classes don't
119+
interfere with each other's hook registration.
120+
"""
121+
122+
@tool
123+
def noop(x: str) -> str:
124+
"""A no-op tool."""
125+
return x
126+
127+
fake_model = FakeMessagesListChatModel(responses=[AIMessage(content="ok")])
128+
fanout = CyclesFanOutGate(
129+
5,
130+
client=sync_client,
131+
subject=Subject(tenant="acme"),
132+
action=Action(kind="model.turn", name="research"),
133+
)
134+
model_gate = CyclesModelGate(
135+
sync_client,
136+
subject=subject,
137+
action=Action(kind="llm.completion", name="fake-model"),
138+
mode="decide",
139+
)
140+
tool_gate = CyclesToolGate(sync_client, subject=subject, action=action, mode="decide")
141+
142+
agent = create_agent(
143+
model=fake_model,
144+
tools=[noop],
145+
middleware=[fanout, model_gate, tool_gate],
146+
)
147+
assert agent is not None
148+
149+
90150
@pytest.fixture
91151
def subject() -> Subject: # noqa: F811 - integration tests don't import the package conftest
92152
return Subject(tenant="acme", agent="bot")

tests/test_model_gate.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,22 @@ def test_invalid_settlement_policy_raises(sync_client: CyclesClient, subject: An
3232
)
3333

3434

35+
def test_model_gate_rejects_mapping_action(sync_client: CyclesClient, subject: Any) -> None:
36+
"""A per-tool-name Mapping makes no sense for CyclesModelGate (which gates
37+
LLM calls, not tool calls). Reject at construction with a clear error rather
38+
than letting it fail mid-call with the less-informative 'tool name required'
39+
error from resolve_action."""
40+
from runcycles import Action
41+
42+
mapping = {"some_tool": Action(kind="tool.call", name="some_tool")}
43+
with pytest.raises(TypeError, match="does not support per-tool Mapping"):
44+
CyclesModelGate(
45+
sync_client,
46+
subject=subject,
47+
action=mapping, # type: ignore[arg-type]
48+
)
49+
50+
3551
# --- decide-mode paths ---------------------------------------------------------------
3652

3753

0 commit comments

Comments
 (0)