tulip-frameworks 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,54 @@
1
+ # Copyright 2026 Tulip Labs
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Drop Tulip's control runtime into the agent framework you already use.
5
+
6
+ ``import tulip_frameworks`` exposes only the framework-agnostic core — it pulls in
7
+ **no** third-party framework package. Import a bridge explicitly to use it::
8
+
9
+ from tulip_frameworks.langchain import gate_langchain_tool # needs [langchain]
10
+ from tulip_frameworks.openai_agents import gate_openai_tool # needs [openai-agents]
11
+
12
+ Each bridge wraps the same primitive, :func:`tulip_frameworks.core.gate_callable`, so
13
+ the action your agent already takes runs only after it clears the admission gate.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from tulip_frameworks.actions import (
19
+ ActionSpec,
20
+ asset_from_args,
21
+ default_action,
22
+ resolve_action,
23
+ )
24
+ from tulip_frameworks.approval import ApprovalBridge, gateway_bridge
25
+ from tulip_frameworks.core import (
26
+ DENIED,
27
+ HELD,
28
+ Mode,
29
+ VerificationResult,
30
+ gate_callable,
31
+ held_result,
32
+ is_held,
33
+ )
34
+ from tulip_frameworks.policy_presets import action_gate_policy
35
+
36
+ __version__ = "0.1.0"
37
+
38
+ __all__ = [
39
+ "DENIED",
40
+ "HELD",
41
+ "ActionSpec",
42
+ "ApprovalBridge",
43
+ "Mode",
44
+ "VerificationResult",
45
+ "__version__",
46
+ "action_gate_policy",
47
+ "asset_from_args",
48
+ "default_action",
49
+ "gate_callable",
50
+ "gateway_bridge",
51
+ "held_result",
52
+ "is_held",
53
+ "resolve_action",
54
+ ]
@@ -0,0 +1,37 @@
1
+ # Copyright 2026 Tulip Labs
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Run an async gated callable from a sync framework hook (CrewAI's ``_run``)."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ from collections.abc import Awaitable
10
+ from typing import TypeVar
11
+
12
+ T = TypeVar("T")
13
+
14
+
15
+ def run_sync(awaitable: Awaitable[T]) -> T:
16
+ """Run ``awaitable`` to completion from sync code, loop-running or not.
17
+
18
+ CrewAI executes tools synchronously. If no event loop is running we use
19
+ :func:`asyncio.run`; if one is (rare for a sync tool body), we offload to a
20
+ worker thread so we don't try to nest loops.
21
+ """
22
+
23
+ async def _drive() -> T:
24
+ return await awaitable
25
+
26
+ try:
27
+ asyncio.get_running_loop()
28
+ except RuntimeError:
29
+ return asyncio.run(_drive())
30
+
31
+ import concurrent.futures
32
+
33
+ with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
34
+ return pool.submit(asyncio.run, _drive()).result()
35
+
36
+
37
+ __all__ = ["run_sync"]
@@ -0,0 +1,84 @@
1
+ # Copyright 2026 Tulip Labs
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Deriving a Tulip :class:`~tulip.security.Action` from a tool call.
5
+
6
+ The gate reasons over an :class:`Action` — its ``asset``, ``blast_radius``,
7
+ ``environment``, ``kind`` and ``tags``. A bridge can supply that three ways:
8
+
9
+ 1. a constant :class:`Action` (risk is the same regardless of arguments),
10
+ 2. a callable ``(name, kwargs) -> Action`` (the recommended form — risk usually
11
+ varies with the call's arguments), or
12
+ 3. nothing, in which case :func:`default_action` builds a conservative one.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from collections.abc import Callable, Mapping
18
+ from typing import Any
19
+
20
+ from tulip.control import Action
21
+
22
+ #: An :class:`Action`, or a callable that derives one from ``(name, kwargs)``.
23
+ ActionSpec = Action | Callable[[str, Mapping[str, Any]], Action]
24
+
25
+ #: Argument names commonly naming the asset an action touches, in priority order.
26
+ _ASSET_KEYS = (
27
+ "asset",
28
+ "host",
29
+ "hostname",
30
+ "target",
31
+ "resource",
32
+ "resource_id",
33
+ "account",
34
+ "user",
35
+ "username",
36
+ "email",
37
+ "order_id",
38
+ "id",
39
+ "ip",
40
+ "url",
41
+ )
42
+
43
+
44
+ def asset_from_args(kwargs: Mapping[str, Any]) -> str:
45
+ """Best-effort asset label from a tool call's arguments."""
46
+ for key in _ASSET_KEYS:
47
+ value = kwargs.get(key)
48
+ if value:
49
+ return str(value)
50
+ return ""
51
+
52
+
53
+ def default_action(
54
+ name: str,
55
+ kwargs: Mapping[str, Any],
56
+ *,
57
+ environment: str = "unknown",
58
+ kind: str = "",
59
+ ) -> Action:
60
+ """A conservative :class:`Action` for ``name`` when none was supplied.
61
+
62
+ Fail-safe by construction: ``environment="unknown"`` plus the stock
63
+ :class:`~tulip.security.ControlPolicy` (which requires a verification score)
64
+ lands an un-verified call on ``require_human`` rather than auto-allowing it.
65
+ """
66
+ return Action(
67
+ name=name,
68
+ asset=asset_from_args(kwargs),
69
+ blast_radius=1,
70
+ environment=environment,
71
+ kind=kind,
72
+ )
73
+
74
+
75
+ def resolve_action(spec: ActionSpec | None, name: str, kwargs: Mapping[str, Any]) -> Action:
76
+ """Resolve an :class:`ActionSpec` (or ``None``) into a concrete :class:`Action`."""
77
+ if spec is None:
78
+ return default_action(name, kwargs)
79
+ if isinstance(spec, Action):
80
+ return spec
81
+ return spec(name, kwargs)
82
+
83
+
84
+ __all__ = ["ActionSpec", "asset_from_args", "default_action", "resolve_action"]
@@ -0,0 +1,85 @@
1
+ # Copyright 2026 Tulip Labs
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Google ADK bridge — gate an Agent Development Kit tool behind Tulip's gate.
5
+
6
+ from google.adk.agents import Agent
7
+ from google.adk.tools import FunctionTool
8
+ from tulip_frameworks.adk import gate_adk_tool
9
+ from tulip_frameworks.policy_presets import action_gate_policy
10
+ from tulip.control import Action, AuditTrail
11
+
12
+ def disable_user(email: str) -> str:
13
+ "Disable an account."
14
+ return idp.disable(email)
15
+
16
+ trail = AuditTrail()
17
+ gated = gate_adk_tool(
18
+ FunctionTool(func=disable_user),
19
+ action=lambda n, a: Action(name=n, asset=a["email"],
20
+ environment="production", kind="identity"),
21
+ policy=action_gate_policy(), trail=trail)
22
+ # agent = Agent(model="gemini-2.0-flash", tools=[gated])
23
+
24
+ Accepts an ADK ``FunctionTool`` or a bare Python function. Needs the ``adk``
25
+ extra: ``pip install tulip-frameworks[adk]``.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from typing import Any
31
+
32
+ from tulip.control import AuditTrail, ControlPolicy
33
+ from tulip.security import Evidence
34
+
35
+ from tulip_frameworks.actions import ActionSpec
36
+ from tulip_frameworks.approval import ApprovalBridge
37
+ from tulip_frameworks.core import Mode, VerificationResult, gate_callable
38
+
39
+
40
+ def _require_adk() -> Any:
41
+ try:
42
+ from google.adk.tools import FunctionTool
43
+ except ImportError as exc: # pragma: no cover - exercised only without the extra
44
+ raise ImportError("Google ADK support needs: pip install tulip-frameworks[adk]") from exc
45
+ return FunctionTool
46
+
47
+
48
+ def gate_adk_tool(
49
+ tool: Any,
50
+ *,
51
+ action: ActionSpec,
52
+ policy: ControlPolicy,
53
+ trail: AuditTrail | None = None,
54
+ mode: Mode = "soft",
55
+ finding: Evidence | None = None,
56
+ verdict: VerificationResult | None = None,
57
+ principal: str = "agent",
58
+ approval: ApprovalBridge | None = None,
59
+ ) -> Any:
60
+ """Return an ADK ``FunctionTool`` whose action is gated by :func:`admit`.
61
+
62
+ ``tool`` may be an ADK ``FunctionTool`` or a plain function. The gated callable
63
+ keeps the original name, docstring, and signature (so ADK builds the right
64
+ function declaration), and runs the action only after it clears policy.
65
+ """
66
+ function_tool = _require_adk()
67
+ inner = getattr(tool, "func", tool) # FunctionTool.func, or a bare function
68
+ name = str(getattr(tool, "name", None) or getattr(inner, "__name__", "tool"))
69
+ gated = gate_callable(
70
+ inner,
71
+ name=name,
72
+ action=action,
73
+ policy=policy,
74
+ trail=trail,
75
+ mode=mode,
76
+ finding=finding,
77
+ verdict=verdict,
78
+ principal=principal,
79
+ approval=approval,
80
+ serialize=True, # a held result must be a string for the model
81
+ )
82
+ return function_tool(func=gated)
83
+
84
+
85
+ __all__ = ["gate_adk_tool"]
@@ -0,0 +1,56 @@
1
+ # Copyright 2026 Tulip Labs
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Out-of-band approval — a small Protocol the gateway's broker already satisfies.
5
+
6
+ When a tool is gated in ``mode="soft"`` and an action is held for a human, you can
7
+ hand :func:`tulip_frameworks.core.gate_callable` an :class:`ApprovalBridge`. The held
8
+ result then carries an ``approval_id`` the agent can poll, while a human approves or
9
+ denies on a side channel the agent has no access to.
10
+
11
+ This is a structural Protocol on purpose: it has **no import-time dependency** on
12
+ ``tulip-gateway`` (which is not yet on PyPI). ``tulip_gateway.policy.approval``'s
13
+ ``ApprovalBroker`` matches it in shape; :func:`gateway_bridge` adapts one when present.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from collections.abc import Mapping
19
+ from typing import Any, Protocol, runtime_checkable
20
+
21
+
22
+ @runtime_checkable
23
+ class ApprovalBridge(Protocol):
24
+ """Submit a held action for out-of-band approval and check its state."""
25
+
26
+ def submit(self, principal: str, tool: str, args: Mapping[str, Any]) -> str:
27
+ """Record a pending approval; return an id the agent can poll."""
28
+ ...
29
+
30
+ def state(self, approval_id: str) -> str | None:
31
+ """Current state for an id — e.g. ``"pending"`` / ``"approved"`` / ``"denied"``."""
32
+ ...
33
+
34
+
35
+ def gateway_bridge(broker: Any) -> ApprovalBridge:
36
+ """Adapt a ``tulip_gateway`` ``ApprovalBroker`` to the :class:`ApprovalBridge` shape.
37
+
38
+ The broker's ``submit`` returns an ``ApprovalRecord``; this thin adapter returns
39
+ its ``approval_id`` instead, and maps ``get`` to :meth:`ApprovalBridge.state`.
40
+ """
41
+
42
+ class _Bridge:
43
+ def submit(self, principal: str, tool: str, args: Mapping[str, Any]) -> str:
44
+ record = broker.submit(principal, tool, dict(args))
45
+ return str(getattr(record, "approval_id", record))
46
+
47
+ def state(self, approval_id: str) -> str | None:
48
+ record = broker.get(approval_id)
49
+ if record is None:
50
+ return None
51
+ return str(getattr(record, "state", record))
52
+
53
+ return _Bridge()
54
+
55
+
56
+ __all__ = ["ApprovalBridge", "gateway_bridge"]
@@ -0,0 +1,177 @@
1
+ # Copyright 2026 Tulip Labs
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """The framework-agnostic gate — the only place that calls :func:`tulip.security.admit`.
5
+
6
+ Every per-framework bridge (``langchain``, ``langgraph``, ``openai_agents``, …) is a
7
+ thin re-wrapper around :func:`gate_callable`. Give it the callable that performs a
8
+ side effect plus the :class:`~tulip.security.Action` that describes it, and it returns
9
+ an async callable that runs the side effect **only** if the action clears policy —
10
+ holding for a human or denying otherwise, and recording the decision on a
11
+ tamper-evident :class:`~tulip.security.AuditTrail` either way.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import inspect
17
+ from collections.abc import Awaitable, Callable, Mapping
18
+ from typing import Any, Literal
19
+
20
+ from tulip.control import (
21
+ Action,
22
+ AdmissionError,
23
+ AuditTrail,
24
+ ControlPolicy,
25
+ admit,
26
+ )
27
+ from tulip.security import (
28
+ Evidence,
29
+ as_json,
30
+ )
31
+ from tulip.security import (
32
+ VerificationResult as VerificationResult,
33
+ # re-exported for callers that pass a verification result,
34
+ )
35
+
36
+ from tulip_frameworks.actions import ActionSpec, resolve_action
37
+ from tulip_frameworks.approval import ApprovalBridge
38
+
39
+ #: How a held/denied action surfaces. ``"soft"`` returns an LLM-readable result the
40
+ #: agent loop can react to; ``"raise"`` re-raises :class:`~tulip.security.AdmissionError`.
41
+ Mode = Literal["soft", "raise"]
42
+
43
+ #: ``status`` value on a soft-mode result when an action is held for a human.
44
+ HELD = "held_for_approval"
45
+ #: ``status`` value on a soft-mode result when an action is denied outright.
46
+ DENIED = "denied"
47
+
48
+
49
+ def _ensure_async(fn: Callable[..., Any]) -> Callable[..., Awaitable[Any]]:
50
+ """Adapt a sync-or-async callable into an awaitable one (called with kwargs)."""
51
+ if inspect.iscoroutinefunction(fn):
52
+ return fn
53
+
54
+ async def _wrapped(**kwargs: Any) -> Any:
55
+ result = fn(**kwargs)
56
+ if inspect.isawaitable(result):
57
+ return await result
58
+ return result
59
+
60
+ return _wrapped
61
+
62
+
63
+ def held_result(
64
+ decision: Any,
65
+ *,
66
+ kwargs: Mapping[str, Any],
67
+ principal: str,
68
+ approval: ApprovalBridge | None,
69
+ ) -> dict[str, Any]:
70
+ """Build the structured result returned in soft mode when an action doesn't admit.
71
+
72
+ On ``require_human`` and an ``approval`` bridge is given, an approval is submitted
73
+ out of band and its id is embedded so the agent can poll for the human decision.
74
+ """
75
+ action: Action = decision.action
76
+ out: dict[str, Any] = {
77
+ "status": DENIED if decision.outcome == "deny" else HELD,
78
+ "outcome": decision.outcome,
79
+ "action": action.name,
80
+ "asset": action.asset,
81
+ "reason": decision.reason,
82
+ }
83
+ if decision.outcome != "deny" and approval is not None:
84
+ out["approval_id"] = approval.submit(principal, action.name, dict(kwargs))
85
+ out["next"] = "call approval_status(approval_id) once a human decides"
86
+ return out
87
+
88
+
89
+ def gate_callable(
90
+ fn: Callable[..., Any],
91
+ *,
92
+ name: str,
93
+ action: ActionSpec,
94
+ policy: ControlPolicy,
95
+ trail: AuditTrail | None = None,
96
+ mode: Mode = "soft",
97
+ finding: Evidence | None = None,
98
+ verdict: VerificationResult | None = None,
99
+ principal: str = "agent",
100
+ approval: ApprovalBridge | None = None,
101
+ serialize: bool = False,
102
+ ) -> Callable[..., Awaitable[Any]]:
103
+ """Wrap ``fn`` so it runs only after its :class:`Action` clears the admission gate.
104
+
105
+ Args:
106
+ fn: The callable that performs the side effect (sync or async). Invoked with
107
+ the gated callable's keyword arguments.
108
+ name: A stable tool name used in the audit record and held result.
109
+ action: An :class:`~tulip.security.Action`, or a callable
110
+ ``(name, kwargs) -> Action`` that derives one per call (the recommended
111
+ form — risk usually varies with the arguments).
112
+ policy: The governing :class:`~tulip.security.ControlPolicy`. For arbitrary
113
+ tools with no verification, prefer
114
+ :func:`tulip_frameworks.policy_presets.action_gate_policy`.
115
+ trail: An :class:`~tulip.security.AuditTrail` to record every decision on.
116
+ mode: ``"soft"`` (default) returns a held/denied result the LLM can read;
117
+ ``"raise"`` re-raises :class:`~tulip.security.AdmissionError`.
118
+ finding / verdict: Optional grounded evidence + verification, forwarded to the
119
+ gate so a fully grounded caller gets the complete trust chain.
120
+ principal: Identity recorded against an out-of-band approval.
121
+ approval: Optional :class:`~tulip_frameworks.approval.ApprovalBridge` to submit
122
+ a ``require_human`` hold to (e.g. the gateway's broker).
123
+ serialize: If ``True``, the soft-mode held/denied result is returned as a JSON
124
+ string (what a string-typed tool result needs). If ``False`` (default), it
125
+ is returned as a ``dict``.
126
+
127
+ Returns:
128
+ An async callable. On allow it returns whatever ``fn`` returns; in soft mode
129
+ on hold/deny it returns the held result; in raise mode it raises.
130
+ """
131
+ perform_async = _ensure_async(fn)
132
+
133
+ async def gated(**kwargs: Any) -> Any:
134
+ act = resolve_action(action, name, kwargs)
135
+
136
+ async def perform() -> Any:
137
+ return await perform_async(**kwargs)
138
+
139
+ try:
140
+ return await admit(
141
+ act, perform, policy=policy, finding=finding, verdict=verdict, trail=trail
142
+ )
143
+ except AdmissionError as exc:
144
+ if mode == "raise":
145
+ raise
146
+ result = held_result(
147
+ exc.decision, kwargs=kwargs, principal=principal, approval=approval
148
+ )
149
+ return as_json(result) if serialize else result
150
+
151
+ gated.__name__ = name
152
+ # Preserve the wrapped tool's signature + docstring so frameworks that build
153
+ # their arg schema by introspecting the callable (ADK, LlamaIndex, …) see the
154
+ # real parameters rather than the gated wrapper's ``**kwargs``.
155
+ try:
156
+ gated.__signature__ = inspect.signature(fn) # type: ignore[attr-defined]
157
+ except (TypeError, ValueError):
158
+ pass
159
+ if getattr(fn, "__doc__", None):
160
+ gated.__doc__ = fn.__doc__
161
+ return gated
162
+
163
+
164
+ def is_held(result: Any) -> bool:
165
+ """Whether a soft-mode result represents a held/denied action (dict form)."""
166
+ return isinstance(result, Mapping) and result.get("status") in {HELD, DENIED}
167
+
168
+
169
+ __all__ = [
170
+ "DENIED",
171
+ "HELD",
172
+ "Mode",
173
+ "VerificationResult",
174
+ "gate_callable",
175
+ "held_result",
176
+ "is_held",
177
+ ]
@@ -0,0 +1,88 @@
1
+ # Copyright 2026 Tulip Labs
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """CrewAI bridge — gate a CrewAI tool's action behind Tulip's admission gate.
5
+
6
+ from crewai.tools import BaseTool
7
+ from tulip_frameworks.crewai import gate_crewai_tool
8
+ from tulip_frameworks.policy_presets import action_gate_policy
9
+ from tulip.control import Action, AuditTrail
10
+
11
+ trail = AuditTrail()
12
+ safe_tool = gate_crewai_tool(
13
+ my_refund_tool,
14
+ action=lambda n, a: Action(name=n, asset=a["order_id"],
15
+ kind="payment", environment="production"),
16
+ policy=action_gate_policy(), trail=trail)
17
+ # crew = Crew(agents=[agent], tasks=[task]) # agent uses safe_tool
18
+
19
+ CrewAI runs tools synchronously, so the gated coroutine is driven via a sync bridge.
20
+ Needs the ``crewai`` extra: ``pip install tulip-frameworks[crewai]``.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from typing import Any
26
+
27
+ from tulip.control import AuditTrail, ControlPolicy
28
+ from tulip.security import Evidence
29
+
30
+ from tulip_frameworks._sync import run_sync
31
+ from tulip_frameworks.actions import ActionSpec
32
+ from tulip_frameworks.approval import ApprovalBridge
33
+ from tulip_frameworks.core import Mode, VerificationResult, gate_callable
34
+
35
+
36
+ def _require_crewai() -> Any:
37
+ try:
38
+ from crewai.tools import BaseTool
39
+ except ImportError as exc: # pragma: no cover - exercised only without the extra
40
+ raise ImportError("CrewAI support needs: pip install tulip-frameworks[crewai]") from exc
41
+ return BaseTool
42
+
43
+
44
+ def gate_crewai_tool(
45
+ tool: Any,
46
+ *,
47
+ action: ActionSpec,
48
+ policy: ControlPolicy,
49
+ trail: AuditTrail | None = None,
50
+ mode: Mode = "soft",
51
+ finding: Evidence | None = None,
52
+ verdict: VerificationResult | None = None,
53
+ principal: str = "agent",
54
+ approval: ApprovalBridge | None = None,
55
+ ) -> Any:
56
+ """Return a CrewAI ``BaseTool`` whose action is gated by :func:`admit`.
57
+
58
+ Keeps the original ``name``, ``description`` and ``args_schema``. The wrapped
59
+ ``_run`` drives the async gate synchronously (CrewAI's execution model).
60
+ """
61
+ base_tool = _require_crewai()
62
+ inner = getattr(tool, "_run", None) or tool.run
63
+ gated = gate_callable(
64
+ inner,
65
+ name=tool.name,
66
+ action=action,
67
+ policy=policy,
68
+ trail=trail,
69
+ mode=mode,
70
+ finding=finding,
71
+ verdict=verdict,
72
+ principal=principal,
73
+ approval=approval,
74
+ serialize=True, # a held result must be a string for the LLM
75
+ )
76
+
77
+ class _GatedTool(base_tool): # type: ignore[misc, valid-type]
78
+ name: str = tool.name
79
+ description: str = tool.description
80
+ args_schema: Any = getattr(tool, "args_schema", None)
81
+
82
+ def _run(self, **kwargs: Any) -> Any:
83
+ return run_sync(gated(**kwargs))
84
+
85
+ return _GatedTool()
86
+
87
+
88
+ __all__ = ["gate_crewai_tool"]
@@ -0,0 +1,94 @@
1
+ # Copyright 2026 Tulip Labs
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """LangChain bridge — gate a LangChain tool's action behind Tulip's admission gate.
5
+
6
+ from langchain_core.tools import tool
7
+ from tulip_frameworks.langchain import gate_langchain_tool
8
+ from tulip_frameworks.policy_presets import action_gate_policy
9
+ from tulip.control import Action, AuditTrail
10
+
11
+ @tool
12
+ async def refund(order_id: str, amount_usd: float) -> str:
13
+ "Issue a customer refund."
14
+ return payments.refund(order_id, amount_usd)
15
+
16
+ trail = AuditTrail()
17
+ safe_refund = gate_langchain_tool(
18
+ refund,
19
+ action=lambda name, a: Action(name=name, asset=a["order_id"],
20
+ blast_radius=1, kind="payment", environment="production"),
21
+ policy=action_gate_policy(), trail=trail)
22
+ # agent = create_react_agent(model, tools=[safe_refund])
23
+
24
+ Needs the ``langchain`` extra: ``pip install tulip-frameworks[langchain]``.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from typing import TYPE_CHECKING, Any
30
+
31
+ from tulip.control import AuditTrail, ControlPolicy
32
+ from tulip.security import Evidence
33
+
34
+ from tulip_frameworks.actions import ActionSpec
35
+ from tulip_frameworks.approval import ApprovalBridge
36
+ from tulip_frameworks.core import Mode, VerificationResult, gate_callable
37
+
38
+ if TYPE_CHECKING:
39
+ from langchain_core.tools import StructuredTool
40
+
41
+
42
+ def _require_langchain() -> Any:
43
+ try:
44
+ from langchain_core.tools import StructuredTool
45
+ except ImportError as exc: # pragma: no cover - exercised only without the extra
46
+ raise ImportError(
47
+ "LangChain support needs: pip install tulip-frameworks[langchain]"
48
+ ) from exc
49
+ return StructuredTool
50
+
51
+
52
+ def gate_langchain_tool(
53
+ tool: Any,
54
+ *,
55
+ action: ActionSpec,
56
+ policy: ControlPolicy,
57
+ trail: AuditTrail | None = None,
58
+ mode: Mode = "soft",
59
+ finding: Evidence | None = None,
60
+ verdict: VerificationResult | None = None,
61
+ principal: str = "agent",
62
+ approval: ApprovalBridge | None = None,
63
+ ) -> StructuredTool:
64
+ """Return a LangChain ``StructuredTool`` whose action is gated by :func:`admit`.
65
+
66
+ The returned tool keeps the original ``name``, ``description`` and ``args_schema``,
67
+ so it is a drop-in replacement in any agent/graph that consumed the original. It is
68
+ async (``coroutine``); use it where the runtime awaits tools (e.g. LangGraph).
69
+ """
70
+ structured_tool = _require_langchain()
71
+ # The underlying callable: prefer the async impl, else the sync one.
72
+ inner = getattr(tool, "coroutine", None) or tool.func
73
+ gated = gate_callable(
74
+ inner,
75
+ name=tool.name,
76
+ action=action,
77
+ policy=policy,
78
+ trail=trail,
79
+ mode=mode,
80
+ finding=finding,
81
+ verdict=verdict,
82
+ principal=principal,
83
+ approval=approval,
84
+ serialize=True, # a held result must be a string for the LLM's tool message
85
+ )
86
+ return structured_tool.from_function(
87
+ coroutine=gated,
88
+ name=tool.name,
89
+ description=tool.description,
90
+ args_schema=tool.args_schema,
91
+ )
92
+
93
+
94
+ __all__ = ["gate_langchain_tool"]