hexgate 0.2.6__tar.gz → 0.2.7__tar.gz
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.
- {hexgate-0.2.6 → hexgate-0.2.7}/PKG-INFO +2 -2
- {hexgate-0.2.6 → hexgate-0.2.7}/README.md +1 -1
- hexgate-0.2.7/hexgate/adapters/google/mcp.py +95 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/adapters/google/runner.py +3 -1
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/adapters/google/tools.py +27 -3
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/adapters/google/wrapper.py +13 -6
- hexgate-0.2.7/hexgate/adapters/langchain/mcp.py +47 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/adapters/langchain/tools.py +7 -25
- hexgate-0.2.7/hexgate/adapters/openai/mcp.py +97 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/adapters/openai/runner.py +23 -4
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/adapters/openai/tools.py +28 -3
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/adapters/openai/wrapper.py +12 -3
- hexgate-0.2.7/hexgate/adapters/pydantic_ai/mcp.py +60 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/adapters/pydantic_ai/tools.py +27 -3
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/adapters/pydantic_ai/wrapper.py +11 -5
- hexgate-0.2.7/hexgate/agents/approvals.py +53 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/cli/register/google.py +63 -10
- hexgate-0.2.7/hexgate/cli/serve.py +692 -0
- hexgate-0.2.7/hexgate/mcp/__init__.py +61 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/mcp/proxy.py +162 -52
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate.egg-info/PKG-INFO +2 -2
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate.egg-info/SOURCES.txt +5 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/pyproject.toml +1 -1
- hexgate-0.2.6/hexgate/cli/serve.py +0 -337
- hexgate-0.2.6/hexgate/mcp/__init__.py +0 -47
- {hexgate-0.2.6 → hexgate-0.2.7}/LICENSE +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/__init__.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/adapters/__init__.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/adapters/google/__init__.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/adapters/langchain/__init__.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/adapters/langchain/agent.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/adapters/langchain/wrapper.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/adapters/openai/__init__.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/adapters/pydantic_ai/__init__.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/adapters/pydantic_ai/agent.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/agents/__init__.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/agents/builtin/__init__.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/agents/builtin/researcher/agent.yaml +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/agents/builtin/researcher/policy.yaml +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/agents/builtin/researcher/system.md +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/agents/factory.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/agents/loader.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/agents/models.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/agents/prompts/agent_system.md +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/audit.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/bootstrap.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/cli/__init__.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/cli/_common.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/cli/chat.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/cli/policy/__init__.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/cli/policy/main.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/cli/register/__init__.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/cli/register/hexgate.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/cli/register/langchain.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/cli/register/main.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/cli/register/manifest.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/cli/register/models.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/cli/register/openai.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/cli/register/pydantic_ai.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/cli/register/register.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/cli/state.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/cloud/__init__.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/cloud/attenuate.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/cloud/biscuit.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/cloud/client.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/config/__init__.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/config/env.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/config/settings.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/mcp/client.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/mcp/config.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/runtime/__init__.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/runtime/command_policy.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/runtime/context.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/runtime/sandbox_runtime.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/runtime/srt.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/runtime/workspace.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/security/__init__.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/security/binding.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/security/bundle.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/security/constraints.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/security/decision.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/security/enforcer.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/security/errors.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/security/file_scope.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/security/models.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/security/policy.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/security/policy_set.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/security/rego.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/security/rego_wasm.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/security/signing.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/security/source.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/security/wasm_engine.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/streaming/__init__.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/streaming/events.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/streaming/normalize.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/tools/__init__.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/tools/bash.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/tools/decorators.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/tools/fetch.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/tools/files/__init__.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/tools/files/_common.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/tools/files/edit_file.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/tools/files/glob.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/tools/files/grep.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/tools/files/read_file.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/tools/files/write_file.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/tools/refund.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/tools/websearch.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/tracing/__init__.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/tracing/langfuse.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/utils/__init__.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate/utils/retry.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate.egg-info/dependency_links.txt +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate.egg-info/entry_points.txt +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate.egg-info/requires.txt +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/hexgate.egg-info/top_level.txt +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/setup.cfg +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/tests/test_bootstrap.py +0 -0
- {hexgate-0.2.6 → hexgate-0.2.7}/tests/test_demo.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hexgate
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.7
|
|
4
4
|
Summary: Hexgate — authorization infrastructure for AI agents (agent runtime + cloud client).
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Requires-Python: >=3.13
|
|
@@ -1329,7 +1329,7 @@ FastAPI over SQLite. Run with:
|
|
|
1329
1329
|
|
|
1330
1330
|
```bash
|
|
1331
1331
|
cd platform/api
|
|
1332
|
-
uv run uvicorn main:app --reload --port 8000
|
|
1332
|
+
uv run uvicorn hexgate_api.main:app --reload --port 8000
|
|
1333
1333
|
```
|
|
1334
1334
|
|
|
1335
1335
|
The default `support-bot` project is seeded on first boot with two agents — `default` (broad access, side-effects gated by `approval_required`) and `read_only` (everything mutating denied).
|
|
@@ -1288,7 +1288,7 @@ FastAPI over SQLite. Run with:
|
|
|
1288
1288
|
|
|
1289
1289
|
```bash
|
|
1290
1290
|
cd platform/api
|
|
1291
|
-
uv run uvicorn main:app --reload --port 8000
|
|
1291
|
+
uv run uvicorn hexgate_api.main:app --reload --port 8000
|
|
1292
1292
|
```
|
|
1293
1293
|
|
|
1294
1294
|
The default `support-bot` project is seeded on first boot with two agents — `default` (broad access, side-effects gated by `approval_required`) and `read_only` (everything mutating denied).
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Google ADK adapter for :class:`~hexgate.mcp.MCPToolset`.
|
|
2
|
+
|
|
3
|
+
Every :class:`~hexgate.mcp.MCPToolProxy` produced by the toolset becomes
|
|
4
|
+
a :class:`google.adk.tools.BaseTool` subclass whose ``_get_declaration``
|
|
5
|
+
returns a ``FunctionDeclaration`` carrying the MCP tool's raw
|
|
6
|
+
JSON Schema (via ``parametersJsonSchema``) and whose ``run_async``
|
|
7
|
+
forwards to the proxy's ``call``. Once wrapped, the resulting tools
|
|
8
|
+
are indistinguishable from ADK-native :class:`FunctionTool` instances
|
|
9
|
+
to the rest of the Google ADK path — attach them to an ``Agent``, then
|
|
10
|
+
wrap via :func:`~hexgate.adapters.google.wrap_google_agent` so the
|
|
11
|
+
existing per-tool policy gate covers MCP invocations too.
|
|
12
|
+
|
|
13
|
+
Usage::
|
|
14
|
+
|
|
15
|
+
from google.adk.agents import Agent
|
|
16
|
+
from hexgate.adapters.google import wrap_google_agent
|
|
17
|
+
from hexgate.adapters.google.mcp import wrap_mcp_toolset
|
|
18
|
+
from hexgate.mcp import MCPServerConfig, MCPToolset
|
|
19
|
+
|
|
20
|
+
slack = MCPServerConfig(name="slack", transport="stdio", command="slack-mcp")
|
|
21
|
+
async with MCPToolset(slack) as mcp:
|
|
22
|
+
agent = Agent(
|
|
23
|
+
name="bot",
|
|
24
|
+
tools=[*wrap_mcp_toolset(mcp), *native],
|
|
25
|
+
)
|
|
26
|
+
wrapped, binding = wrap_google_agent(agent, api_key=api_key)
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
from google.adk.tools import BaseTool
|
|
34
|
+
from google.adk.tools.tool_context import ToolContext
|
|
35
|
+
from google.genai import types as genai_types
|
|
36
|
+
|
|
37
|
+
from hexgate.mcp.proxy import MCPToolProxy, MCPToolset
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class _MCPProxyTool(BaseTool):
|
|
41
|
+
"""One :class:`BaseTool` that forwards to an :class:`MCPToolProxy`.
|
|
42
|
+
|
|
43
|
+
ADK's :class:`FunctionTool` derives its schema from a Python
|
|
44
|
+
callable's signature via reflection, which can't express the shapes
|
|
45
|
+
MCP servers advertise (partial ``required``, ``anyOf`` unions,
|
|
46
|
+
nested objects with dynamic keys). Subclassing :class:`BaseTool`
|
|
47
|
+
and returning a hand-built :class:`FunctionDeclaration` from
|
|
48
|
+
``_get_declaration`` bypasses the reflection path and hands the
|
|
49
|
+
server's raw JSON Schema straight to the model — via ADK's
|
|
50
|
+
``parametersJsonSchema`` alias which accepts JSON Schema dicts.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(self, proxy: MCPToolProxy) -> None:
|
|
54
|
+
super().__init__(name=proxy.qualified_name, description=proxy.description)
|
|
55
|
+
# Store the schema + call as private attrs — ADK's BaseTool has
|
|
56
|
+
# no field for them, so we ride on the object dict.
|
|
57
|
+
self._input_schema = proxy.input_schema
|
|
58
|
+
self._call = proxy.call
|
|
59
|
+
|
|
60
|
+
def _get_declaration(self) -> genai_types.FunctionDeclaration:
|
|
61
|
+
# Gemini's FunctionDeclaration validator rejects any
|
|
62
|
+
# parametersJsonSchema missing a top-level `type` — literal `{}`,
|
|
63
|
+
# or a partial like `{"properties": {...}}`, or a bare
|
|
64
|
+
# `{"anyOf": [...]}`. All three shapes cause the whole tool
|
|
65
|
+
# list to fail at declaration time, so the agent never gets to
|
|
66
|
+
# run. Fill in `type: "object"` (the only shape Gemini accepts
|
|
67
|
+
# for a function-args container) whenever it's absent, and
|
|
68
|
+
# ensure a `properties` map so the LLM sees an argument surface
|
|
69
|
+
# rather than an opaque object. LangChain and OpenAI Agents
|
|
70
|
+
# both tolerate the missing `type` unchanged.
|
|
71
|
+
schema = self._input_schema if isinstance(self._input_schema, dict) else {}
|
|
72
|
+
if "type" not in schema:
|
|
73
|
+
schema = {"type": "object", "properties": {}, **schema}
|
|
74
|
+
return genai_types.FunctionDeclaration(
|
|
75
|
+
name=self.name,
|
|
76
|
+
description=self.description,
|
|
77
|
+
parametersJsonSchema=schema,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
async def run_async(
|
|
81
|
+
self, *, args: dict[str, Any], tool_context: ToolContext
|
|
82
|
+
) -> Any:
|
|
83
|
+
return await self._call(**(args or {}))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def wrap_mcp_toolset(toolset: MCPToolset) -> list[BaseTool]:
|
|
87
|
+
"""Wrap every proxy in ``toolset`` as a Google ADK :class:`BaseTool`.
|
|
88
|
+
|
|
89
|
+
The returned tools share the toolset's connection lifecycle — they
|
|
90
|
+
stop working (returning a ``use_after_close`` envelope) once the
|
|
91
|
+
``async with MCPToolset(...)`` block exits. Combine with
|
|
92
|
+
:func:`~hexgate.adapters.google.wrap_google_agent` to gate every
|
|
93
|
+
invocation through :class:`~hexgate.security.PolicyEnforcer`.
|
|
94
|
+
"""
|
|
95
|
+
return [_MCPProxyTool(p) for p in toolset.proxies]
|
|
@@ -16,6 +16,7 @@ from langfuse import get_client, propagate_attributes
|
|
|
16
16
|
from openinference.instrumentation.google_adk import GoogleADKInstrumentor
|
|
17
17
|
|
|
18
18
|
from hexgate.adapters.google.wrapper import wrap_google_agent
|
|
19
|
+
from hexgate.agents.factory import ApprovalHandler
|
|
19
20
|
from hexgate.config.env import resolve_api_key
|
|
20
21
|
from hexgate.runtime import User
|
|
21
22
|
|
|
@@ -30,6 +31,7 @@ class HexgateRunner:
|
|
|
30
31
|
app_name: str,
|
|
31
32
|
session_service: BaseSessionService,
|
|
32
33
|
api_key: str | None = None,
|
|
34
|
+
approval_handler: ApprovalHandler | None = None,
|
|
33
35
|
**runner_kwargs: Any,
|
|
34
36
|
):
|
|
35
37
|
self.api_key = resolve_api_key(api_key)
|
|
@@ -41,7 +43,7 @@ class HexgateRunner:
|
|
|
41
43
|
# Runner is built once — refresh swaps the enforcer's policy
|
|
42
44
|
# without touching it.
|
|
43
45
|
self._wrapped_agent, self._binding = wrap_google_agent(
|
|
44
|
-
agent, api_key=self.api_key
|
|
46
|
+
agent, api_key=self.api_key, approval_handler=approval_handler
|
|
45
47
|
)
|
|
46
48
|
self._runner = Runner(
|
|
47
49
|
agent=self._wrapped_agent,
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
"""Google ADK adapter: wrap ``BaseTool`` so ``run_async`` consults a
|
|
2
2
|
:class:`PolicyEnforcer` first. Non-allow outcomes render as markered
|
|
3
3
|
strings the model sees as tool output.
|
|
4
|
+
|
|
5
|
+
When a caller supplies ``approval_handler``, a ``NEEDS_APPROVAL``
|
|
6
|
+
decision fires the callback and runs the original tool on truthy return;
|
|
7
|
+
falsy return (or a missing handler) keeps today's behavior of surfacing
|
|
8
|
+
the ``[approval_required]`` marker to the model.
|
|
4
9
|
"""
|
|
5
10
|
|
|
6
11
|
from __future__ import annotations
|
|
@@ -14,6 +19,9 @@ from google.adk.tools.base_tool import BaseTool
|
|
|
14
19
|
from google.adk.tools.function_tool import FunctionTool
|
|
15
20
|
from google.adk.tools.tool_context import ToolContext
|
|
16
21
|
|
|
22
|
+
from hexgate.agents.approvals import resolve_approval_async
|
|
23
|
+
from hexgate.agents.factory import ApprovalHandler
|
|
24
|
+
from hexgate.security.decision import DecisionOutcome
|
|
17
25
|
from hexgate.security.enforcer import PolicyEnforcer
|
|
18
26
|
|
|
19
27
|
|
|
@@ -32,7 +40,12 @@ def _normalize(tool: ToolEntry) -> BaseTool:
|
|
|
32
40
|
)
|
|
33
41
|
|
|
34
42
|
|
|
35
|
-
def wrap_tool(
|
|
43
|
+
def wrap_tool(
|
|
44
|
+
tool: ToolEntry,
|
|
45
|
+
enforcer: PolicyEnforcer,
|
|
46
|
+
*,
|
|
47
|
+
approval_handler: ApprovalHandler | None = None,
|
|
48
|
+
) -> BaseTool:
|
|
36
49
|
"""Return a copy of ``tool`` with ``run_async`` gated by ``enforcer``."""
|
|
37
50
|
base = _normalize(tool)
|
|
38
51
|
name = base.name
|
|
@@ -45,6 +58,12 @@ def wrap_tool(tool: ToolEntry, enforcer: PolicyEnforcer) -> BaseTool:
|
|
|
45
58
|
decision = enforcer.decide(name, args or {})
|
|
46
59
|
if decision.allowed:
|
|
47
60
|
return await original_run_async(args=args, tool_context=tool_context)
|
|
61
|
+
if (
|
|
62
|
+
decision.outcome is DecisionOutcome.NEEDS_APPROVAL
|
|
63
|
+
and approval_handler is not None
|
|
64
|
+
and await resolve_approval_async(approval_handler, decision)
|
|
65
|
+
):
|
|
66
|
+
return await original_run_async(args=args, tool_context=tool_context)
|
|
48
67
|
return decision.as_error_message()
|
|
49
68
|
|
|
50
69
|
wrapped = copy.copy(base)
|
|
@@ -52,6 +71,11 @@ def wrap_tool(tool: ToolEntry, enforcer: PolicyEnforcer) -> BaseTool:
|
|
|
52
71
|
return wrapped
|
|
53
72
|
|
|
54
73
|
|
|
55
|
-
def wrap_tools(
|
|
74
|
+
def wrap_tools(
|
|
75
|
+
tools: list[ToolEntry],
|
|
76
|
+
enforcer: PolicyEnforcer,
|
|
77
|
+
*,
|
|
78
|
+
approval_handler: ApprovalHandler | None = None,
|
|
79
|
+
) -> list[BaseTool]:
|
|
56
80
|
"""Return a fresh list of policy-gated copies."""
|
|
57
|
-
return [wrap_tool(t, enforcer) for t in tools]
|
|
81
|
+
return [wrap_tool(t, enforcer, approval_handler=approval_handler) for t in tools]
|
|
@@ -13,20 +13,27 @@ from __future__ import annotations
|
|
|
13
13
|
from google.adk.agents import BaseAgent
|
|
14
14
|
|
|
15
15
|
from hexgate.adapters.google.tools import wrap_tools
|
|
16
|
+
from hexgate.agents.factory import ApprovalHandler
|
|
16
17
|
from hexgate.security.binding import PolicyBinding, resolve_policy
|
|
17
18
|
from hexgate.security.enforcer import build_enforcer
|
|
18
19
|
|
|
19
20
|
|
|
20
21
|
def wrap_google_agent(
|
|
21
|
-
agent: BaseAgent,
|
|
22
|
+
agent: BaseAgent,
|
|
23
|
+
*,
|
|
24
|
+
api_key: str,
|
|
25
|
+
approval_handler: ApprovalHandler | None = None,
|
|
22
26
|
) -> tuple[BaseAgent, PolicyBinding]:
|
|
23
27
|
"""Return a policy-gated clone of ``agent`` plus its refresh binding.
|
|
24
28
|
|
|
25
29
|
Caller must open a :class:`User` scope around the run.
|
|
26
|
-
``NEEDS_APPROVAL`` outcomes
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
+
``NEEDS_APPROVAL`` outcomes fire ``approval_handler`` (async
|
|
31
|
+
``fn(decision) -> bool`` or ``bool`` shorthand); a truthy return
|
|
32
|
+
runs the tool, falsy or missing handler surfaces the
|
|
33
|
+
``[approval_required]``-prefixed string as tool result.
|
|
34
|
+
``[policy_denied]`` marks plain denials. Refresh the returned
|
|
35
|
+
binding at run boundaries (``HexgateRunner`` does). Fail-loud: an
|
|
36
|
+
unregistered agent (platform 404) raises — register it first with
|
|
30
37
|
``hexgate register``.
|
|
31
38
|
"""
|
|
32
39
|
agent_name = getattr(agent, "name", "default")
|
|
@@ -34,7 +41,7 @@ def wrap_google_agent(
|
|
|
34
41
|
|
|
35
42
|
resolved = resolve_policy(agent_name, api_key=api_key)
|
|
36
43
|
enforcer = build_enforcer(resolved.engine, agent_name=agent_name, api_key=api_key)
|
|
37
|
-
guarded_tools = wrap_tools(tools, enforcer)
|
|
44
|
+
guarded_tools = wrap_tools(tools, enforcer, approval_handler=approval_handler)
|
|
38
45
|
return (
|
|
39
46
|
agent.model_copy(update={"tools": guarded_tools}),
|
|
40
47
|
PolicyBinding(enforcer, resolved.source),
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""LangChain-facing wrapper for :class:`~hexgate.mcp.MCPToolset`.
|
|
2
|
+
|
|
3
|
+
Every :class:`~hexgate.mcp.MCPToolProxy` produced by the toolset becomes
|
|
4
|
+
a :class:`~langchain_core.tools.StructuredTool` that forwards to the
|
|
5
|
+
proxy's ``call``. Once wrapped, the resulting :class:`BaseTool` objects
|
|
6
|
+
are indistinguishable from native ``@agent_tool`` functions to the rest
|
|
7
|
+
of the LangChain path — hand them to :func:`create_agent`, call
|
|
8
|
+
:func:`enforce_policy`, and the existing :class:`GuardedTool` pass
|
|
9
|
+
gates every invocation through :class:`PolicyEnforcer`.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from langchain_core.tools import BaseTool, StructuredTool
|
|
15
|
+
|
|
16
|
+
from hexgate.mcp.proxy import MCPToolProxy, MCPToolset
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _wrap_one(proxy: MCPToolProxy) -> BaseTool:
|
|
20
|
+
"""Build a single :class:`StructuredTool` around ``proxy.call``.
|
|
21
|
+
|
|
22
|
+
LangChain's ``StructuredTool.from_function(args_schema=<dict>)``
|
|
23
|
+
accepts a raw JSON Schema directly — no Pydantic model generation
|
|
24
|
+
needed. ``proxy.call`` is used verbatim as the coroutine; its
|
|
25
|
+
``__name__`` is already set to the qualified name by
|
|
26
|
+
:func:`hexgate.mcp.proxy._build_proxy`.
|
|
27
|
+
"""
|
|
28
|
+
return StructuredTool.from_function(
|
|
29
|
+
coroutine=proxy.call,
|
|
30
|
+
name=proxy.qualified_name,
|
|
31
|
+
description=proxy.description,
|
|
32
|
+
args_schema=proxy.input_schema,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def wrap_mcp_toolset(toolset: MCPToolset) -> list[BaseTool]:
|
|
37
|
+
"""Wrap every proxy in ``toolset`` as a LangChain :class:`BaseTool`.
|
|
38
|
+
|
|
39
|
+
Called implicitly by :meth:`MCPToolset.tools` for back-compat, or
|
|
40
|
+
explicitly by adapter-aware callers that want the same
|
|
41
|
+
``wrap_mcp_toolset(mcp)`` shape they'd use with the OpenAI /
|
|
42
|
+
Pydantic AI / Google ADK adapters. The returned tools share the
|
|
43
|
+
toolset's connection lifecycle — they stop working (returning a
|
|
44
|
+
``use_after_close`` envelope) once the ``async with MCPToolset(...)``
|
|
45
|
+
block exits.
|
|
46
|
+
"""
|
|
47
|
+
return [_wrap_one(p) for p in toolset.proxies]
|
|
@@ -14,15 +14,20 @@ from __future__ import annotations
|
|
|
14
14
|
|
|
15
15
|
import functools
|
|
16
16
|
from collections.abc import Awaitable, Callable
|
|
17
|
-
from inspect import isawaitable
|
|
18
17
|
from typing import Any
|
|
19
18
|
|
|
20
19
|
from langchain_core.tools import BaseTool
|
|
21
20
|
from langchain_core.tools.structured import StructuredTool
|
|
22
21
|
from pydantic import ConfigDict
|
|
23
22
|
|
|
23
|
+
from hexgate.agents.approvals import (
|
|
24
|
+
resolve_approval_async as _resolve_approval_async,
|
|
25
|
+
)
|
|
26
|
+
from hexgate.agents.approvals import (
|
|
27
|
+
resolve_approval_sync as _resolve_approval_sync,
|
|
28
|
+
)
|
|
24
29
|
from hexgate.agents.factory import ApprovalHandler
|
|
25
|
-
from hexgate.security.decision import
|
|
30
|
+
from hexgate.security.decision import DecisionOutcome
|
|
26
31
|
from hexgate.security.enforcer import PolicyEnforcer
|
|
27
32
|
from hexgate.tools.decorators import TOOL_METADATA_ATTR
|
|
28
33
|
|
|
@@ -35,29 +40,6 @@ def _copy_tool_metadata(source: Any, target: Any) -> Any:
|
|
|
35
40
|
return target
|
|
36
41
|
|
|
37
42
|
|
|
38
|
-
def _resolve_approval_sync(handler: ApprovalHandler, decision: Decision) -> bool:
|
|
39
|
-
"""Resolve a NEEDS_APPROVAL decision in a sync caller (rejects coroutines)."""
|
|
40
|
-
if isinstance(handler, bool):
|
|
41
|
-
return handler
|
|
42
|
-
result = handler(decision)
|
|
43
|
-
if isawaitable(result):
|
|
44
|
-
raise RuntimeError(
|
|
45
|
-
"approval_handler returned a coroutine; sync tool invocation cannot "
|
|
46
|
-
"await it — use ainvoke/astream/astream_events"
|
|
47
|
-
)
|
|
48
|
-
return bool(result)
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
async def _resolve_approval_async(handler: ApprovalHandler, decision: Decision) -> bool:
|
|
52
|
-
"""Resolve a NEEDS_APPROVAL decision in an async caller."""
|
|
53
|
-
if isinstance(handler, bool):
|
|
54
|
-
return handler
|
|
55
|
-
result = handler(decision)
|
|
56
|
-
if isawaitable(result):
|
|
57
|
-
result = await result
|
|
58
|
-
return bool(result)
|
|
59
|
-
|
|
60
|
-
|
|
61
43
|
class GuardedTool(BaseTool):
|
|
62
44
|
"""LangChain tool wrapper that consults a :class:`PolicyEnforcer`.
|
|
63
45
|
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""OpenAI Agents adapter for :class:`~hexgate.mcp.MCPToolset`.
|
|
2
|
+
|
|
3
|
+
Every :class:`~hexgate.mcp.MCPToolProxy` produced by the toolset becomes
|
|
4
|
+
an :class:`agents.FunctionTool` whose ``on_invoke_tool`` forwards to the
|
|
5
|
+
proxy's ``call``. Once wrapped, the resulting :class:`FunctionTool`
|
|
6
|
+
objects are indistinguishable from native ``@function_tool``-decorated
|
|
7
|
+
callables to the rest of the OpenAI Agents path — hand them to an
|
|
8
|
+
``Agent`` alongside your other tools, then wrap the agent via
|
|
9
|
+
:func:`~hexgate.adapters.openai.wrap_openai_agent` so the existing
|
|
10
|
+
per-tool policy gate covers MCP invocations too.
|
|
11
|
+
|
|
12
|
+
Usage::
|
|
13
|
+
|
|
14
|
+
from agents import Agent
|
|
15
|
+
from hexgate.adapters.openai import wrap_openai_agent
|
|
16
|
+
from hexgate.adapters.openai.mcp import wrap_mcp_toolset
|
|
17
|
+
from hexgate.mcp import MCPServerConfig, MCPToolset
|
|
18
|
+
|
|
19
|
+
slack = MCPServerConfig(name="slack", transport="stdio", command="slack-mcp")
|
|
20
|
+
async with MCPToolset(slack) as mcp:
|
|
21
|
+
agent = Agent(
|
|
22
|
+
name="bot",
|
|
23
|
+
tools=[*wrap_mcp_toolset(mcp), *native_tools],
|
|
24
|
+
)
|
|
25
|
+
wrapped = wrap_openai_agent(agent, enforcer=enforcer)
|
|
26
|
+
await HexgateRunner(api_key).run(wrapped, "…", user=user)
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import json
|
|
32
|
+
from typing import Any
|
|
33
|
+
|
|
34
|
+
from agents import FunctionTool
|
|
35
|
+
from agents.tool import ToolContext
|
|
36
|
+
|
|
37
|
+
from hexgate.mcp.proxy import MCPToolProxy, MCPToolset
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _parse_args(raw: str) -> dict[str, Any]:
|
|
41
|
+
"""Best-effort JSON-to-dict parse of a tool-call payload.
|
|
42
|
+
|
|
43
|
+
An empty or unparseable payload becomes ``{}`` — the proxy's
|
|
44
|
+
JSON-Schema validator then decides whether that's acceptable.
|
|
45
|
+
Matches the same tolerance the native OpenAI wrap has.
|
|
46
|
+
"""
|
|
47
|
+
if not raw:
|
|
48
|
+
return {}
|
|
49
|
+
try:
|
|
50
|
+
parsed = json.loads(raw)
|
|
51
|
+
except (TypeError, ValueError):
|
|
52
|
+
return {}
|
|
53
|
+
return parsed if isinstance(parsed, dict) else {}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _wrap_one(proxy: MCPToolProxy) -> FunctionTool:
|
|
57
|
+
"""Build a single :class:`FunctionTool` around ``proxy.call``.
|
|
58
|
+
|
|
59
|
+
OpenAI Agents' ``FunctionTool`` accepts a raw JSON Schema in
|
|
60
|
+
``params_json_schema`` — no dynamic Pydantic model generation
|
|
61
|
+
needed. ``strict_json_schema=False`` is deliberate: MCP servers
|
|
62
|
+
routinely advertise schemas that don't meet OpenAI's strict-mode
|
|
63
|
+
requirements (partial ``required``, optional ``additionalProperties``,
|
|
64
|
+
``anyOf`` unions), and we'd rather forward the server's spec
|
|
65
|
+
verbatim than reject legitimate tools at wrap time. Our own
|
|
66
|
+
pre-call validator in ``proxy.call`` still catches malformed args
|
|
67
|
+
before the round trip.
|
|
68
|
+
|
|
69
|
+
``on_invoke_tool`` is the one bit that can't collapse to
|
|
70
|
+
``proxy.call`` directly — OpenAI hands us the raw JSON string, not
|
|
71
|
+
a parsed dict.
|
|
72
|
+
"""
|
|
73
|
+
call = proxy.call
|
|
74
|
+
|
|
75
|
+
async def on_invoke_tool(ctx: ToolContext[Any], raw: str) -> Any:
|
|
76
|
+
return await call(**_parse_args(raw))
|
|
77
|
+
|
|
78
|
+
return FunctionTool(
|
|
79
|
+
name=proxy.qualified_name,
|
|
80
|
+
description=proxy.description,
|
|
81
|
+
params_json_schema=proxy.input_schema,
|
|
82
|
+
on_invoke_tool=on_invoke_tool,
|
|
83
|
+
strict_json_schema=False,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def wrap_mcp_toolset(toolset: MCPToolset) -> list[FunctionTool]:
|
|
88
|
+
"""Wrap every proxy in ``toolset`` as an OpenAI Agents
|
|
89
|
+
:class:`FunctionTool`.
|
|
90
|
+
|
|
91
|
+
The returned tools share the toolset's connection lifecycle — they
|
|
92
|
+
stop working (returning a ``use_after_close`` envelope) once the
|
|
93
|
+
``async with MCPToolset(...)`` block exits. Combine with
|
|
94
|
+
:func:`~hexgate.adapters.openai.wrap_openai_agent` to gate every
|
|
95
|
+
invocation through :class:`~hexgate.security.PolicyEnforcer`.
|
|
96
|
+
"""
|
|
97
|
+
return [_wrap_one(p) for p in toolset.proxies]
|
|
@@ -25,6 +25,7 @@ from langfuse import get_client, propagate_attributes
|
|
|
25
25
|
from openinference.instrumentation.openai_agents import OpenAIAgentsInstrumentor
|
|
26
26
|
|
|
27
27
|
from hexgate.adapters.openai.wrapper import wrap_openai_agent
|
|
28
|
+
from hexgate.agents.factory import ApprovalHandler
|
|
28
29
|
from hexgate.config.env import resolve_api_key
|
|
29
30
|
from hexgate.runtime import User
|
|
30
31
|
from hexgate.security.binding import PolicyBinding, resolve_policy
|
|
@@ -34,7 +35,12 @@ from hexgate.security.enforcer import build_enforcer
|
|
|
34
35
|
class HexgateRunner:
|
|
35
36
|
"""Runner for OpenAI agents with Hexgate tool policy and observability."""
|
|
36
37
|
|
|
37
|
-
def __init__(
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
api_key: str | None = None,
|
|
41
|
+
*,
|
|
42
|
+
approval_handler: ApprovalHandler | None = None,
|
|
43
|
+
):
|
|
38
44
|
self.api_key = resolve_api_key(api_key)
|
|
39
45
|
if self.api_key is None:
|
|
40
46
|
raise ValueError(
|
|
@@ -42,6 +48,7 @@ class HexgateRunner:
|
|
|
42
48
|
)
|
|
43
49
|
# Cached per agent name — keeps the ETag memory alive across runs.
|
|
44
50
|
self._bindings: dict[str, PolicyBinding] = {}
|
|
51
|
+
self._approval_handler = approval_handler
|
|
45
52
|
|
|
46
53
|
def _binding_for(self, agent: Agent) -> PolicyBinding:
|
|
47
54
|
"""Get-or-resolve the cached policy binding for ``agent``'s name.
|
|
@@ -98,7 +105,11 @@ class HexgateRunner:
|
|
|
98
105
|
self._setup_observability()
|
|
99
106
|
binding = self._binding_for(agent)
|
|
100
107
|
await binding.refresh_async() # per-run policy pull; 304 when unchanged
|
|
101
|
-
wrapped_agent = wrap_openai_agent(
|
|
108
|
+
wrapped_agent = wrap_openai_agent(
|
|
109
|
+
agent,
|
|
110
|
+
enforcer=binding.enforcer,
|
|
111
|
+
approval_handler=self._approval_handler,
|
|
112
|
+
)
|
|
102
113
|
async with user:
|
|
103
114
|
with self._propagate(user, agent.name):
|
|
104
115
|
return await Runner.run(
|
|
@@ -117,7 +128,11 @@ class HexgateRunner:
|
|
|
117
128
|
self._setup_observability()
|
|
118
129
|
binding = self._binding_for(agent)
|
|
119
130
|
binding.refresh() # per-run policy pull; 304 when unchanged
|
|
120
|
-
wrapped_agent = wrap_openai_agent(
|
|
131
|
+
wrapped_agent = wrap_openai_agent(
|
|
132
|
+
agent,
|
|
133
|
+
enforcer=binding.enforcer,
|
|
134
|
+
approval_handler=self._approval_handler,
|
|
135
|
+
)
|
|
121
136
|
with user.sync_scope():
|
|
122
137
|
with self._propagate(user, agent.name):
|
|
123
138
|
return Runner.run_sync(
|
|
@@ -143,7 +158,11 @@ class HexgateRunner:
|
|
|
143
158
|
self._setup_observability()
|
|
144
159
|
binding = self._binding_for(agent)
|
|
145
160
|
binding.refresh() # must precede the wrap + setup
|
|
146
|
-
wrapped_agent = wrap_openai_agent(
|
|
161
|
+
wrapped_agent = wrap_openai_agent(
|
|
162
|
+
agent,
|
|
163
|
+
enforcer=binding.enforcer,
|
|
164
|
+
approval_handler=self._approval_handler,
|
|
165
|
+
)
|
|
147
166
|
|
|
148
167
|
with user.sync_scope():
|
|
149
168
|
with self._propagate(user, agent.name):
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
"""OpenAI Agents adapter: wrap ``FunctionTool`` so ``on_invoke_tool``
|
|
2
2
|
consults a :class:`PolicyEnforcer` first. Non-allow outcomes render as
|
|
3
3
|
markered strings the model sees as tool output.
|
|
4
|
+
|
|
5
|
+
When a caller supplies ``approval_handler``, a ``NEEDS_APPROVAL``
|
|
6
|
+
decision fires the callback and runs the original tool on truthy return;
|
|
7
|
+
falsy return (or a missing handler) keeps today's behavior of surfacing
|
|
8
|
+
the ``[approval_required]`` marker to the model.
|
|
4
9
|
"""
|
|
5
10
|
|
|
6
11
|
from __future__ import annotations
|
|
@@ -13,6 +18,9 @@ from typing import Any
|
|
|
13
18
|
from agents import FunctionTool
|
|
14
19
|
from agents.tool import ToolContext
|
|
15
20
|
|
|
21
|
+
from hexgate.agents.approvals import resolve_approval_async
|
|
22
|
+
from hexgate.agents.factory import ApprovalHandler
|
|
23
|
+
from hexgate.security.decision import DecisionOutcome
|
|
16
24
|
from hexgate.security.enforcer import PolicyEnforcer
|
|
17
25
|
|
|
18
26
|
|
|
@@ -27,7 +35,12 @@ def _parse_args(raw: str) -> dict[str, Any] | None:
|
|
|
27
35
|
return parsed if isinstance(parsed, dict) else None
|
|
28
36
|
|
|
29
37
|
|
|
30
|
-
def wrap_tool(
|
|
38
|
+
def wrap_tool(
|
|
39
|
+
tool: FunctionTool,
|
|
40
|
+
enforcer: PolicyEnforcer,
|
|
41
|
+
*,
|
|
42
|
+
approval_handler: ApprovalHandler | None = None,
|
|
43
|
+
) -> FunctionTool:
|
|
31
44
|
"""Return a copy of ``tool`` with ``on_invoke_tool`` gated by ``enforcer``."""
|
|
32
45
|
if not isinstance(tool, FunctionTool):
|
|
33
46
|
raise TypeError(
|
|
@@ -43,6 +56,12 @@ def wrap_tool(tool: FunctionTool, enforcer: PolicyEnforcer) -> FunctionTool:
|
|
|
43
56
|
decision = enforcer.decide(name, _parse_args(input) or {})
|
|
44
57
|
if decision.allowed:
|
|
45
58
|
return await original_invoke(ctx, input)
|
|
59
|
+
if (
|
|
60
|
+
decision.outcome is DecisionOutcome.NEEDS_APPROVAL
|
|
61
|
+
and approval_handler is not None
|
|
62
|
+
and await resolve_approval_async(approval_handler, decision)
|
|
63
|
+
):
|
|
64
|
+
return await original_invoke(ctx, input)
|
|
46
65
|
return decision.as_error_message()
|
|
47
66
|
|
|
48
67
|
wrapped = copy.copy(tool)
|
|
@@ -51,7 +70,13 @@ def wrap_tool(tool: FunctionTool, enforcer: PolicyEnforcer) -> FunctionTool:
|
|
|
51
70
|
|
|
52
71
|
|
|
53
72
|
def wrap_tools(
|
|
54
|
-
tools: list[FunctionTool],
|
|
73
|
+
tools: list[FunctionTool],
|
|
74
|
+
enforcer: PolicyEnforcer,
|
|
75
|
+
*,
|
|
76
|
+
approval_handler: ApprovalHandler | None = None,
|
|
55
77
|
) -> list[FunctionTool]:
|
|
56
78
|
"""Return a fresh list of policy-gated copies."""
|
|
57
|
-
return [wrap_tool(t, enforcer) for t in tools]
|
|
79
|
+
return [wrap_tool(t, enforcer, approval_handler=approval_handler) for t in tools]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
__all__ = ["_parse_args", "wrap_tool", "wrap_tools"]
|
|
@@ -15,14 +15,23 @@ import dataclasses
|
|
|
15
15
|
from agents import Agent
|
|
16
16
|
|
|
17
17
|
from hexgate.adapters.openai.tools import wrap_tools
|
|
18
|
+
from hexgate.agents.factory import ApprovalHandler
|
|
18
19
|
from hexgate.security.enforcer import PolicyEnforcer
|
|
19
20
|
|
|
20
21
|
|
|
21
|
-
def wrap_openai_agent(
|
|
22
|
+
def wrap_openai_agent(
|
|
23
|
+
agent: Agent,
|
|
24
|
+
*,
|
|
25
|
+
enforcer: PolicyEnforcer,
|
|
26
|
+
approval_handler: ApprovalHandler | None = None,
|
|
27
|
+
) -> Agent:
|
|
22
28
|
"""Return a clone of ``agent`` whose tools are gated by ``enforcer``.
|
|
23
29
|
|
|
24
30
|
Mechanics only — resolution/refresh live with the caller. Caller
|
|
25
|
-
must open a :class:`User` scope around the run.
|
|
31
|
+
must open a :class:`User` scope around the run. ``approval_handler``
|
|
32
|
+
(async ``fn(decision) -> bool`` or ``bool`` shorthand) fires when a
|
|
33
|
+
tool call carries a ``NEEDS_APPROVAL`` outcome; a truthy return runs
|
|
34
|
+
the tool, falsy surfaces the ``[approval_required]`` marker.
|
|
26
35
|
"""
|
|
27
|
-
guarded_tools = wrap_tools(agent.tools, enforcer)
|
|
36
|
+
guarded_tools = wrap_tools(agent.tools, enforcer, approval_handler=approval_handler)
|
|
28
37
|
return dataclasses.replace(agent, tools=guarded_tools)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Pydantic AI adapter for :class:`~hexgate.mcp.MCPToolset`.
|
|
2
|
+
|
|
3
|
+
Every :class:`~hexgate.mcp.MCPToolProxy` produced by the toolset becomes
|
|
4
|
+
a :class:`pydantic_ai.tools.Tool` via ``Tool.from_schema`` — Pydantic
|
|
5
|
+
AI's raw-JSON-Schema entry point. Once wrapped, the resulting Tools
|
|
6
|
+
are indistinguishable from ``@agent.tool``-decorated callables to the
|
|
7
|
+
rest of the Pydantic AI path — attach them to an ``Agent`` and wrap
|
|
8
|
+
via :func:`~hexgate.adapters.pydantic_ai.wrap_pydantic_agent` so the
|
|
9
|
+
existing per-tool policy gate covers MCP invocations too.
|
|
10
|
+
|
|
11
|
+
Usage::
|
|
12
|
+
|
|
13
|
+
from pydantic_ai import Agent
|
|
14
|
+
from hexgate.adapters.pydantic_ai import wrap_pydantic_agent
|
|
15
|
+
from hexgate.adapters.pydantic_ai.mcp import wrap_mcp_toolset
|
|
16
|
+
from hexgate.mcp import MCPServerConfig, MCPToolset
|
|
17
|
+
|
|
18
|
+
slack = MCPServerConfig(name="slack", transport="stdio", command="slack-mcp")
|
|
19
|
+
async with MCPToolset(slack) as mcp:
|
|
20
|
+
agent = Agent("openai:gpt-5.4", tools=[*wrap_mcp_toolset(mcp), *native])
|
|
21
|
+
proxy = wrap_pydantic_agent(agent=agent)
|
|
22
|
+
await proxy.run("…", user=user)
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from pydantic_ai.tools import Tool
|
|
28
|
+
|
|
29
|
+
from hexgate.mcp.proxy import MCPToolProxy, MCPToolset
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _wrap_one(proxy: MCPToolProxy) -> Tool:
|
|
33
|
+
"""Build a single :class:`Tool` around ``proxy.call``.
|
|
34
|
+
|
|
35
|
+
Pydantic AI's ``Tool.from_schema`` accepts a raw JSON Schema — no
|
|
36
|
+
dynamic Pydantic model generation is needed. ``proxy.call`` is
|
|
37
|
+
passed verbatim as the function; its ``__name__`` is already the
|
|
38
|
+
qualified name (set by :func:`hexgate.mcp.proxy._build_proxy`).
|
|
39
|
+
Pydantic AI's own validator (built from the same schema) is the
|
|
40
|
+
outer gate; our ``proxy.call`` runs its own JSON-Schema check as a
|
|
41
|
+
defence-in-depth layer before the server round-trip.
|
|
42
|
+
"""
|
|
43
|
+
return Tool.from_schema(
|
|
44
|
+
function=proxy.call,
|
|
45
|
+
name=proxy.qualified_name,
|
|
46
|
+
description=proxy.description,
|
|
47
|
+
json_schema=proxy.input_schema,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def wrap_mcp_toolset(toolset: MCPToolset) -> list[Tool]:
|
|
52
|
+
"""Wrap every proxy in ``toolset`` as a Pydantic AI :class:`Tool`.
|
|
53
|
+
|
|
54
|
+
The returned tools share the toolset's connection lifecycle — they
|
|
55
|
+
stop working (returning a ``use_after_close`` envelope) once the
|
|
56
|
+
``async with MCPToolset(...)`` block exits. Combine with
|
|
57
|
+
:func:`~hexgate.adapters.pydantic_ai.wrap_pydantic_agent` to gate
|
|
58
|
+
every invocation through :class:`~hexgate.security.PolicyEnforcer`.
|
|
59
|
+
"""
|
|
60
|
+
return [_wrap_one(p) for p in toolset.proxies]
|