Skip to content

Commit 5ce86ae

Browse files
flyersworderclaude
andauthored
release: v0.20.0 (align SDK adapter session-limit enforcement) (#22)
* release: v0.20.0 (align SDK adapter session-limit enforcement) Brings `create_sdk_mcp_server` in line with `create_langchain_tools`: every wrapped tool now pre-checks `ContractSession.check_limits()` by default. Pre-0.20.0, only `run_query` self-checked limits — lookup tools (`describe_table`, `list_metrics`, etc.) bypassed entirely. The two adapters now behave identically. Practical effect: `max_duration_seconds` measures wall-clock from the first tool call (any tool), not just from the first `run_query`. For most users this is invisible — lookups complete in milliseconds. The narrow population that sees a behavior change: agents with tight `max_duration_seconds` AND lookup-heavy prompts that browse extensively before querying. The new behavior matches the YAML's documented intent ('the agent has N seconds total') and closes the runaway-loop gap where an agent stuck on lookups previously bypassed the duration cap. Public API unchanged. New optional `apply_middleware: bool = True` kwarg on `create_sdk_mcp_server` mirrors the LangChain adapter; pass `False` to opt out and restore pre-0.20.0 semantics. SQL validation is intentionally NOT auto-applied, same reasoning as the LangChain adapter: doing so would block `inspect_query`'s purpose of reporting violations as JSON. `run_query` self-validation at `factory.py:632-702` already covers the cost path. 6 new tests on the `_wrap_with_session_check` helper and the new kwarg surface. Full suite: 591 passed, 8 skipped, 0 regressions. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * review: address 2 sub-threshold items from automated review - Issue A (score 75): Wrapper-emitted BLOCKED envelopes now include the canonical `Remaining: {budget}` suffix matching run_query's self-emitted blocks (factory.py:627-628). Affected both SDK and LangChain adapters' wrappers (_wrap_with_session_check, _to_structured_tool, ContractMiddleware._check). Agents whose retry-planning logic depended on the suffix now see consistent output regardless of whether the limit fired in run_query or in the wrapper layer. - Issue D (score 72): Clarified the apply_middleware=False docstring in sdk.py. The earlier 'matching create_langchain_tools' phrasing understated a transport-inherent divergence — with apply_middleware=False the LangChain adapter still raises ToolException via its always-active prefix sniff, while the SDK adapter passes BLOCKED envelopes through as plain MCP text content (the SDK MCP transport has no status='error' field). The docstring now spells out that timing is aligned but error transport differs by design. 3 tests extended to assert the Remaining: suffix appears on session- limit-exceeded paths in both adapters. Full suite: 591 passed, 8 skipped, 0 regressions. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 88c6664 commit 5ce86ae

7 files changed

Lines changed: 275 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,27 @@
22

33
All notable changes to this project will be documented in this file.
44

5+
## [0.20.0] - 2026-05-10
6+
7+
### Changed
8+
9+
- **`create_sdk_mcp_server` now auto-applies session-limit enforcement to all 9 tools by default**, matching the v0.19.0 behavior of `create_langchain_tools`. Pre-v0.20.0, only `run_query` self-checked `ContractSession` limits — lookup tools (`describe_table`, `list_metrics`, etc.) bypassed. The two adapters now behave identically: a single contract YAML enforces the same way under SDK and LangChain.
10+
- **Practical effect**: `max_duration_seconds` now measures wall-clock from the *first tool call* (any tool), not just from the first `run_query`. For most contracts this is invisible — lookups complete in milliseconds. The narrow population that sees a behavior change: agents with tight `max_duration_seconds` AND lookup-heavy prompts that browse extensively before querying. The new behavior matches the YAML's documented intent ("the agent has N seconds total"), and closes the runaway-loop gap where an agent stuck on lookup tools previously bypassed the duration cap.
11+
- **Escape hatch**: pass `apply_middleware=False` to `create_sdk_mcp_server` to restore pre-0.20.0 behavior.
12+
13+
### Added
14+
15+
- New `_wrap_with_session_check(inner, session)` helper in `tools/sdk.py` — exported as a private symbol so tests can verify the enforcement wrapper directly without going through the SDK's `@tool` decorator. Mirrors the in-tool enforcement pattern in `tools/langchain.py:_to_structured_tool`.
16+
17+
### Fixed
18+
19+
- Wrapper-emitted BLOCKED envelopes now include the canonical `Remaining: {budget}` suffix that `run_query`'s self-emitted blocks have always carried (per `factory.py:627-628`). Pre-0.20.0 the LangChain wrapper (introduced in v0.19.0) and the new SDK wrapper both omitted this suffix, so agents whose retry-planning logic depended on the suffix would lose context once they hit the wrapper layer instead of `run_query`'s own block. Applies to both `_wrap_with_session_check` (SDK) and `_to_structured_tool` / `ContractMiddleware._check` (LangChain).
20+
21+
### Compatibility
22+
23+
- Public API unchanged — `create_sdk_mcp_server` gains one optional kwarg with a sensible default. Pre-built `ToolDef` lists, custom sessions, and all other call shapes continue to work.
24+
- 6 new tests in `tests/test_tools/test_sdk.py` cover the wrapper behavior and the new kwarg.
25+
526
## [0.19.0] - 2026-05-10
627

728
### Added

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "agentic-data-contracts"
3-
version = "0.19.0"
3+
version = "0.20.0"
44
description = "YAML-first, domain-driven data governance for AI agents"
55
readme = "README.md"
66
requires-python = ">=3.12"

src/agentic_data_contracts/tools/langchain.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232

3333
from __future__ import annotations
3434

35+
import json
3536
from collections.abc import Awaitable, Callable
3637
from typing import Any
3738

@@ -50,6 +51,13 @@
5051
_BLOCKED_PREFIX = "BLOCKED —"
5152

5253

54+
def _with_remaining(message: str, session: ContractSession) -> str:
55+
"""Append the canonical ``Remaining: {budget}`` suffix used by
56+
``run_query`` (factory.py:627-628) so wrapper-emitted blocks carry
57+
the same diagnostic footprint as run_query's own blocks."""
58+
return f"{message}\nRemaining: {json.dumps(session.remaining(), default=str)}"
59+
60+
5361
def _unwrap_mcp_text(envelope: dict[str, Any]) -> str:
5462
"""Pull the first text block out of an MCP-style content envelope.
5563
@@ -131,7 +139,10 @@ async def _coroutine(**kwargs: Any) -> tuple[str, dict[str, Any]]:
131139
session.check_limits()
132140
except LimitExceededError as e:
133141
raise ToolException(
134-
f"{_BLOCKED_PREFIX} Session limit exceeded: {e}"
142+
_with_remaining(
143+
f"{_BLOCKED_PREFIX} Session limit exceeded: {e}",
144+
session,
145+
)
135146
) from e
136147

137148
envelope = await inner(kwargs)
@@ -209,7 +220,10 @@ def _check(self, request: ToolCallRequest) -> ToolMessage | None:
209220
self._session.check_limits()
210221
except LimitExceededError as e:
211222
return ToolMessage(
212-
content=f"{_BLOCKED_PREFIX} Session limit exceeded: {e}",
223+
content=_with_remaining(
224+
f"{_BLOCKED_PREFIX} Session limit exceeded: {e}",
225+
self._session,
226+
),
213227
name=name,
214228
tool_call_id=tool_call_id,
215229
status="error",
@@ -226,9 +240,10 @@ def _check(self, request: ToolCallRequest) -> ToolMessage | None:
226240
if result.blocked:
227241
self._session.record_retry()
228242
return ToolMessage(
229-
content=(
243+
content=_with_remaining(
230244
f"{_BLOCKED_PREFIX} Violations:\n"
231-
+ "\n".join(f"- {r}" for r in result.reasons)
245+
+ "\n".join(f"- {r}" for r in result.reasons),
246+
self._session,
232247
),
233248
name=name,
234249
tool_call_id=tool_call_id,

src/agentic_data_contracts/tools/sdk.py

Lines changed: 94 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,77 @@
1-
"""Claude Agent SDK integration — wraps ToolDefs into an SDK MCP server."""
1+
"""Claude Agent SDK integration — wraps ToolDefs into an SDK MCP server.
2+
3+
By default (since v0.20.0) every wrapped tool pre-checks
4+
``ContractSession.check_limits()`` and short-circuits with a canonical
5+
``BLOCKED — Session limit exceeded`` envelope on overrun. This aligns the
6+
SDK adapter with ``create_langchain_tools`` so a single contract YAML
7+
behaves the same way under both adapters — in particular,
8+
``max_duration_seconds`` measures wall-clock from the first tool call,
9+
not just from the first ``run_query``.
10+
11+
SQL validation is intentionally **not** auto-applied. Doing so would
12+
block ``inspect_query`` from reporting violations as JSON; the canonical
13+
``run_query`` self-validation at ``factory.py:632-702`` already covers
14+
the cost path.
15+
16+
Pass ``apply_middleware=False`` to opt out (preserves the pre-0.20.0
17+
behavior where only ``run_query`` self-checked limits).
18+
"""
219

320
from __future__ import annotations
421

22+
import functools
23+
import json
24+
from collections.abc import Awaitable, Callable
525
from typing import Any
626

727
from agentic_data_contracts.adapters.base import DatabaseAdapter
828
from agentic_data_contracts.core.contract import DataContract
9-
from agentic_data_contracts.core.session import ContractSession
29+
from agentic_data_contracts.core.session import ContractSession, LimitExceededError
1030
from agentic_data_contracts.semantic.base import SemanticSource
1131
from agentic_data_contracts.tools.factory import ToolDef, create_tools
1232

33+
_BLOCKED_PREFIX = "BLOCKED —"
34+
35+
36+
def _with_remaining(message: str, session: ContractSession) -> str:
37+
"""Append the canonical ``Remaining: {budget}`` suffix used by
38+
``run_query`` (factory.py:627-628) so wrapper-emitted blocks carry
39+
the same diagnostic footprint as run_query's own blocks."""
40+
return f"{message}\nRemaining: {json.dumps(session.remaining(), default=str)}"
41+
42+
43+
def _wrap_with_session_check(
44+
inner: Callable[[dict[str, Any]], Awaitable[dict[str, Any]]],
45+
session: ContractSession,
46+
) -> Callable[[dict[str, Any]], Awaitable[dict[str, Any]]]:
47+
"""Wrap an MCP-style tool callable with a pre-call session-limit check.
48+
49+
Returns the canonical ``BLOCKED — Session limit exceeded`` envelope on
50+
overrun without invoking the inner function. SQL validation is
51+
intentionally NOT applied here — that would short-circuit
52+
``inspect_query`` whose purpose is to *report* violations as JSON.
53+
"""
54+
55+
@functools.wraps(inner)
56+
async def wrapped(args: dict[str, Any]) -> dict[str, Any]:
57+
try:
58+
session.check_limits()
59+
except LimitExceededError as e:
60+
return {
61+
"content": [
62+
{
63+
"type": "text",
64+
"text": _with_remaining(
65+
f"{_BLOCKED_PREFIX} Session limit exceeded: {e}",
66+
session,
67+
),
68+
}
69+
]
70+
}
71+
return await inner(args)
72+
73+
return wrapped
74+
1375

1476
def create_sdk_mcp_server(
1577
contract: DataContract,
@@ -18,6 +80,7 @@ def create_sdk_mcp_server(
1880
semantic_source: SemanticSource | None = None,
1981
session: ContractSession | None = None,
2082
tools: list[ToolDef] | None = None,
83+
apply_middleware: bool = True,
2184
server_name: str = "data-contracts",
2285
server_version: str = "1.0.0",
2386
) -> Any:
@@ -30,8 +93,25 @@ def create_sdk_mcp_server(
3093
contract: The data contract to enforce.
3194
adapter: Optional database adapter for query execution.
3295
semantic_source: Optional semantic source (auto-loaded if not given).
33-
session: Optional session for tracking enforcement state.
96+
session: Optional session for tracking enforcement state. One is
97+
created automatically if omitted.
3498
tools: Pre-built ToolDefs (if None, created via create_tools).
99+
apply_middleware: When ``True`` (default since v0.20.0), every
100+
wrapped tool pre-checks ``session.check_limits()`` and
101+
short-circuits on overrun. Aligned with ``create_langchain_tools``
102+
on enforcement *timing* (clock starts at first tool call), but
103+
error transport differs by design — see note below. Set
104+
``False`` to restore pre-0.20.0 behavior in which only
105+
``run_query`` self-checks limits (lookup tools bypass).
106+
107+
Note on cross-adapter parity: with ``apply_middleware=False``,
108+
this adapter passes a tool's BLOCKED envelope through to the
109+
agent as-is (the SDK MCP transport carries error context as
110+
text content; there is no ``status="error"`` field). The
111+
LangChain adapter additionally sniffs the ``BLOCKED —``
112+
prefix and converts it into a ``ToolException``. Both surface
113+
the same text to the agent; only the structured-error signal
114+
differs.
35115
server_name: Name for the MCP server.
36116
server_version: Version for the MCP server.
37117
@@ -51,6 +131,9 @@ def create_sdk_mcp_server(
51131
)
52132
raise ImportError(msg) from None
53133

134+
if session is None:
135+
session = ContractSession(contract)
136+
54137
if tools is None:
55138
tools = create_tools(
56139
contract,
@@ -61,7 +144,14 @@ def create_sdk_mcp_server(
61144

62145
sdk_tools = []
63146
for t in tools:
64-
decorated = sdk_tool(t.name, t.description, t.input_schema)(t.callable)
147+
callable_to_register = (
148+
_wrap_with_session_check(t.callable, session)
149+
if apply_middleware
150+
else t.callable
151+
)
152+
decorated = sdk_tool(t.name, t.description, t.input_schema)(
153+
callable_to_register
154+
)
65155
sdk_tools.append(decorated)
66156

67157
return _create_server(

tests/test_tools/test_langchain.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -244,7 +244,10 @@ async def test_session_limit_exceeded_raises_tool_exception(
244244
contract: DataContract, adapter: DuckDBAdapter, semantic: YamlSource
245245
) -> None:
246246
"""Even non-SQL tools must surface session-limit exhaustion. Fixture
247-
sets max_retries=3 (tests/fixtures/valid_contract.yml:45)."""
247+
sets max_retries=3 (tests/fixtures/valid_contract.yml:45). The
248+
raised ToolException must include the ``Remaining:`` budget summary
249+
so the agent sees the same diagnostic info ``run_query`` would have
250+
emitted directly."""
248251
session = ContractSession(contract)
249252
for _ in range(4): # exceed max_retries=3
250253
session.record_retry()
@@ -256,6 +259,7 @@ async def test_session_limit_exceeded_raises_tool_exception(
256259
await describe.ainvoke({"schema": "analytics", "table": "orders"})
257260
msg = str(exc.value).lower()
258261
assert "limit" in msg or "exceeded" in msg
262+
assert "remaining:" in msg
259263

260264

261265
# ─── apply_middleware=False escape hatch ──────────────────────────────────────
@@ -324,6 +328,7 @@ async def _handler(_req: ToolCallRequest) -> ToolMessage: # pragma: no cover
324328
assert isinstance(result, ToolMessage)
325329
assert result.status == "error"
326330
assert "BLOCKED" in str(result.content)
331+
assert "Remaining:" in str(result.content) # agent must see budget
327332
assert result.tool_call_id == "tc-1"
328333

329334

0 commit comments

Comments
 (0)