flux7-mesh 0.1.0__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.
- flux7_mesh-0.1.0/.gitignore +43 -0
- flux7_mesh-0.1.0/PKG-INFO +111 -0
- flux7_mesh-0.1.0/README.md +98 -0
- flux7_mesh-0.1.0/examples/claude_api.py +53 -0
- flux7_mesh-0.1.0/pyproject.toml +22 -0
- flux7_mesh-0.1.0/src/agent_mesh/__init__.py +5 -0
- flux7_mesh-0.1.0/src/agent_mesh/client.py +191 -0
- flux7_mesh-0.1.0/src/agent_mesh/toolkit.py +167 -0
- flux7_mesh-0.1.0/tests/__init__.py +0 -0
- flux7_mesh-0.1.0/tests/test_client.py +170 -0
- flux7_mesh-0.1.0/tests/test_toolkit.py +134 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Build / OS
|
|
2
|
+
*.exe
|
|
3
|
+
*.dll
|
|
4
|
+
*.so
|
|
5
|
+
*.dylib
|
|
6
|
+
/agent-mesh
|
|
7
|
+
/mesh
|
|
8
|
+
.DS_Store
|
|
9
|
+
|
|
10
|
+
# IDE
|
|
11
|
+
.claude
|
|
12
|
+
CLAUDE.md
|
|
13
|
+
.idea/
|
|
14
|
+
.vscode/
|
|
15
|
+
*.swp
|
|
16
|
+
*.swo
|
|
17
|
+
|
|
18
|
+
# Go
|
|
19
|
+
vendor/
|
|
20
|
+
|
|
21
|
+
# Python
|
|
22
|
+
.venv/
|
|
23
|
+
__pycache__/
|
|
24
|
+
|
|
25
|
+
# Environment
|
|
26
|
+
.env
|
|
27
|
+
.env.*
|
|
28
|
+
|
|
29
|
+
# Debug / Test artifacts
|
|
30
|
+
*.debug
|
|
31
|
+
*.test
|
|
32
|
+
|
|
33
|
+
# Project
|
|
34
|
+
docs/internal/
|
|
35
|
+
docs/positioning.md
|
|
36
|
+
docs/agent-landscape.md
|
|
37
|
+
traces.jsonl
|
|
38
|
+
traces-*.jsonl
|
|
39
|
+
state.db
|
|
40
|
+
*.db-wal
|
|
41
|
+
*.db-shm
|
|
42
|
+
*.local.yaml
|
|
43
|
+
policies/*.local.yaml
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: flux7-mesh
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for agent-mesh — governance mesh for AI agent tool calls
|
|
5
|
+
Project-URL: Homepage, https://github.com/KTCrisis/agent-mesh
|
|
6
|
+
Project-URL: Repository, https://github.com/KTCrisis/agent-mesh
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Requires-Dist: requests>=2.28
|
|
10
|
+
Provides-Extra: anthropic
|
|
11
|
+
Requires-Dist: anthropic>=0.40; extra == 'anthropic'
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# agent-mesh Python SDK
|
|
15
|
+
|
|
16
|
+
Governance mesh for AI agent tool calls. Wraps any Python function with policy enforcement, human approval, and tracing via [agent-mesh](https://github.com/KTCrisis/agent-mesh).
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install agent-mesh
|
|
22
|
+
pip install agent-mesh[anthropic] # with Claude API support
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Quick start
|
|
26
|
+
|
|
27
|
+
### Direct client
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
from agent_mesh import AgentMesh
|
|
31
|
+
|
|
32
|
+
mesh = AgentMesh("http://localhost:9090", agent="my-agent")
|
|
33
|
+
|
|
34
|
+
# Check what's available
|
|
35
|
+
tools = mesh.tools()
|
|
36
|
+
health = mesh.health()
|
|
37
|
+
|
|
38
|
+
# Policy check only (POST /decide) — no execution
|
|
39
|
+
decision = mesh.decide("filesystem.write_file", {"path": "/tmp/x"})
|
|
40
|
+
print(decision.action) # allow | deny | human_approval
|
|
41
|
+
|
|
42
|
+
# Full proxy (POST /tool/{name}) — policy + execute + trace
|
|
43
|
+
decision = mesh.call_tool("filesystem.read_file", {"path": "/tmp/data.txt"})
|
|
44
|
+
print(decision.result)
|
|
45
|
+
|
|
46
|
+
# Manage grants
|
|
47
|
+
mesh.create_grant("filesystem.*", "30m")
|
|
48
|
+
mesh.revoke_grant("grant-id")
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### GovernedToolkit (Claude API)
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from agent_mesh import GovernedToolkit
|
|
55
|
+
|
|
56
|
+
toolkit = GovernedToolkit(agent="my-agent")
|
|
57
|
+
|
|
58
|
+
@toolkit.tool
|
|
59
|
+
def get_weather(city: str) -> str:
|
|
60
|
+
"""Get current weather for a city."""
|
|
61
|
+
return fetch_weather(city)
|
|
62
|
+
|
|
63
|
+
@toolkit.tool
|
|
64
|
+
def send_email(to: str, subject: str, body: str) -> str:
|
|
65
|
+
"""Send an email."""
|
|
66
|
+
return smtp_send(to, subject, body)
|
|
67
|
+
|
|
68
|
+
# Generate tools[] for Claude API
|
|
69
|
+
schemas = toolkit.schemas()
|
|
70
|
+
|
|
71
|
+
# Process tool_use blocks with governance
|
|
72
|
+
response = client.messages.create(model="claude-sonnet-4-6", tools=schemas, ...)
|
|
73
|
+
results = toolkit.process_response([b.model_dump() for b in response.content])
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
The toolkit:
|
|
77
|
+
1. **`schemas()`** — generates the `tools[]` array from Python function signatures
|
|
78
|
+
2. **`process_response()`** — intercepts `tool_use` blocks, checks policy via agent-mesh, executes locally if allowed
|
|
79
|
+
3. **`execute()`** — single tool call with governance
|
|
80
|
+
|
|
81
|
+
## Prerequisites
|
|
82
|
+
|
|
83
|
+
agent-mesh daemon running:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
agent-mesh serve --config config.yaml
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## API
|
|
90
|
+
|
|
91
|
+
### `AgentMesh(url, agent, timeout)`
|
|
92
|
+
|
|
93
|
+
| Method | Description |
|
|
94
|
+
|---|---|
|
|
95
|
+
| `decide(name, args)` | Evaluate policy without executing |
|
|
96
|
+
| `call_tool(name, args)` | Execute tool through governance (proxy) |
|
|
97
|
+
| `tools()` | List available tools |
|
|
98
|
+
| `approvals()` | List pending approvals |
|
|
99
|
+
| `approve(id)` / `deny(id)` | Resolve approval |
|
|
100
|
+
| `grants()` | List active grants |
|
|
101
|
+
| `create_grant(tools, duration)` | Create temporal grant |
|
|
102
|
+
| `health()` / `is_healthy()` | Check mesh status |
|
|
103
|
+
|
|
104
|
+
### `GovernedToolkit(agent, url)`
|
|
105
|
+
|
|
106
|
+
| Method | Description |
|
|
107
|
+
|---|---|
|
|
108
|
+
| `@toolkit.tool` | Decorator to register a function |
|
|
109
|
+
| `schemas()` | Generate Claude API tools array |
|
|
110
|
+
| `execute(name, input)` | Governed execution |
|
|
111
|
+
| `process_response(content)` | Process Claude response blocks |
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# agent-mesh Python SDK
|
|
2
|
+
|
|
3
|
+
Governance mesh for AI agent tool calls. Wraps any Python function with policy enforcement, human approval, and tracing via [agent-mesh](https://github.com/KTCrisis/agent-mesh).
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install agent-mesh
|
|
9
|
+
pip install agent-mesh[anthropic] # with Claude API support
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Quick start
|
|
13
|
+
|
|
14
|
+
### Direct client
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
from agent_mesh import AgentMesh
|
|
18
|
+
|
|
19
|
+
mesh = AgentMesh("http://localhost:9090", agent="my-agent")
|
|
20
|
+
|
|
21
|
+
# Check what's available
|
|
22
|
+
tools = mesh.tools()
|
|
23
|
+
health = mesh.health()
|
|
24
|
+
|
|
25
|
+
# Policy check only (POST /decide) — no execution
|
|
26
|
+
decision = mesh.decide("filesystem.write_file", {"path": "/tmp/x"})
|
|
27
|
+
print(decision.action) # allow | deny | human_approval
|
|
28
|
+
|
|
29
|
+
# Full proxy (POST /tool/{name}) — policy + execute + trace
|
|
30
|
+
decision = mesh.call_tool("filesystem.read_file", {"path": "/tmp/data.txt"})
|
|
31
|
+
print(decision.result)
|
|
32
|
+
|
|
33
|
+
# Manage grants
|
|
34
|
+
mesh.create_grant("filesystem.*", "30m")
|
|
35
|
+
mesh.revoke_grant("grant-id")
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### GovernedToolkit (Claude API)
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from agent_mesh import GovernedToolkit
|
|
42
|
+
|
|
43
|
+
toolkit = GovernedToolkit(agent="my-agent")
|
|
44
|
+
|
|
45
|
+
@toolkit.tool
|
|
46
|
+
def get_weather(city: str) -> str:
|
|
47
|
+
"""Get current weather for a city."""
|
|
48
|
+
return fetch_weather(city)
|
|
49
|
+
|
|
50
|
+
@toolkit.tool
|
|
51
|
+
def send_email(to: str, subject: str, body: str) -> str:
|
|
52
|
+
"""Send an email."""
|
|
53
|
+
return smtp_send(to, subject, body)
|
|
54
|
+
|
|
55
|
+
# Generate tools[] for Claude API
|
|
56
|
+
schemas = toolkit.schemas()
|
|
57
|
+
|
|
58
|
+
# Process tool_use blocks with governance
|
|
59
|
+
response = client.messages.create(model="claude-sonnet-4-6", tools=schemas, ...)
|
|
60
|
+
results = toolkit.process_response([b.model_dump() for b in response.content])
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The toolkit:
|
|
64
|
+
1. **`schemas()`** — generates the `tools[]` array from Python function signatures
|
|
65
|
+
2. **`process_response()`** — intercepts `tool_use` blocks, checks policy via agent-mesh, executes locally if allowed
|
|
66
|
+
3. **`execute()`** — single tool call with governance
|
|
67
|
+
|
|
68
|
+
## Prerequisites
|
|
69
|
+
|
|
70
|
+
agent-mesh daemon running:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
agent-mesh serve --config config.yaml
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## API
|
|
77
|
+
|
|
78
|
+
### `AgentMesh(url, agent, timeout)`
|
|
79
|
+
|
|
80
|
+
| Method | Description |
|
|
81
|
+
|---|---|
|
|
82
|
+
| `decide(name, args)` | Evaluate policy without executing |
|
|
83
|
+
| `call_tool(name, args)` | Execute tool through governance (proxy) |
|
|
84
|
+
| `tools()` | List available tools |
|
|
85
|
+
| `approvals()` | List pending approvals |
|
|
86
|
+
| `approve(id)` / `deny(id)` | Resolve approval |
|
|
87
|
+
| `grants()` | List active grants |
|
|
88
|
+
| `create_grant(tools, duration)` | Create temporal grant |
|
|
89
|
+
| `health()` / `is_healthy()` | Check mesh status |
|
|
90
|
+
|
|
91
|
+
### `GovernedToolkit(agent, url)`
|
|
92
|
+
|
|
93
|
+
| Method | Description |
|
|
94
|
+
|---|---|
|
|
95
|
+
| `@toolkit.tool` | Decorator to register a function |
|
|
96
|
+
| `schemas()` | Generate Claude API tools array |
|
|
97
|
+
| `execute(name, input)` | Governed execution |
|
|
98
|
+
| `process_response(content)` | Process Claude response blocks |
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Example: governed tool use with the Claude API.
|
|
2
|
+
|
|
3
|
+
Requires: pip install agent-mesh[anthropic]
|
|
4
|
+
Requires: agent-mesh daemon running on localhost:9090
|
|
5
|
+
"""
|
|
6
|
+
from agent_mesh import GovernedToolkit
|
|
7
|
+
|
|
8
|
+
toolkit = GovernedToolkit(agent="my-agent")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@toolkit.tool
|
|
12
|
+
def get_weather(city: str) -> str:
|
|
13
|
+
"""Get current weather for a city."""
|
|
14
|
+
return f"22C and sunny in {city}"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@toolkit.tool
|
|
18
|
+
def send_email(to: str, subject: str, body: str) -> str:
|
|
19
|
+
"""Send an email to a recipient."""
|
|
20
|
+
print(f" -> sending email to {to}")
|
|
21
|
+
return f"email sent to {to}"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def main():
|
|
25
|
+
import anthropic
|
|
26
|
+
|
|
27
|
+
client = anthropic.Anthropic()
|
|
28
|
+
messages = [{"role": "user", "content": "What's the weather in Paris? Then email the result to alice@example.com"}]
|
|
29
|
+
|
|
30
|
+
while True:
|
|
31
|
+
response = client.messages.create(
|
|
32
|
+
model="claude-sonnet-4-6",
|
|
33
|
+
max_tokens=1024,
|
|
34
|
+
tools=toolkit.schemas(),
|
|
35
|
+
messages=messages,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
if response.stop_reason == "end_turn":
|
|
39
|
+
for block in response.content:
|
|
40
|
+
if hasattr(block, "text"):
|
|
41
|
+
print(block.text)
|
|
42
|
+
break
|
|
43
|
+
|
|
44
|
+
if response.stop_reason == "tool_use":
|
|
45
|
+
tool_results = toolkit.process_response(
|
|
46
|
+
[b.model_dump() for b in response.content]
|
|
47
|
+
)
|
|
48
|
+
messages.append({"role": "assistant", "content": [b.model_dump() for b in response.content]})
|
|
49
|
+
messages.append({"role": "user", "content": tool_results})
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
if __name__ == "__main__":
|
|
53
|
+
main()
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "flux7-mesh"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python SDK for agent-mesh — governance mesh for AI agent tool calls"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
dependencies = ["requests>=2.28"]
|
|
13
|
+
|
|
14
|
+
[project.optional-dependencies]
|
|
15
|
+
anthropic = ["anthropic>=0.40"]
|
|
16
|
+
|
|
17
|
+
[project.urls]
|
|
18
|
+
Homepage = "https://github.com/KTCrisis/agent-mesh"
|
|
19
|
+
Repository = "https://github.com/KTCrisis/agent-mesh"
|
|
20
|
+
|
|
21
|
+
[tool.hatch.build.targets.wheel]
|
|
22
|
+
packages = ["src/agent_mesh"]
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""agent-mesh Python SDK — governance mesh for AI agent tool calls.
|
|
2
|
+
|
|
3
|
+
Usage::
|
|
4
|
+
|
|
5
|
+
from agent_mesh import AgentMesh
|
|
6
|
+
|
|
7
|
+
mesh = AgentMesh("http://localhost:9090", agent="my-agent")
|
|
8
|
+
decision = mesh.decide("filesystem.write_file", {"path": "/tmp/x"})
|
|
9
|
+
print(decision.action) # "allow" | "deny" | "human_approval"
|
|
10
|
+
|
|
11
|
+
tools = mesh.tools()
|
|
12
|
+
health = mesh.health()
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from enum import Enum
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
import requests
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Action(str, Enum):
|
|
24
|
+
ALLOW = "allow"
|
|
25
|
+
DENY = "deny"
|
|
26
|
+
HUMAN_APPROVAL = "human_approval"
|
|
27
|
+
ERROR = "error"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Decision:
|
|
32
|
+
action: Action
|
|
33
|
+
tool: str
|
|
34
|
+
result: str = ""
|
|
35
|
+
error: str = ""
|
|
36
|
+
approval_id: str = ""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class Tool:
|
|
41
|
+
name: str
|
|
42
|
+
description: str = ""
|
|
43
|
+
source: str = ""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class AgentMeshError(Exception):
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class AgentMesh:
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
url: str = "http://localhost:9090",
|
|
54
|
+
agent: str = "default",
|
|
55
|
+
timeout: int = 300,
|
|
56
|
+
) -> None:
|
|
57
|
+
self._url = url.rstrip("/")
|
|
58
|
+
self._agent = agent
|
|
59
|
+
self._timeout = timeout
|
|
60
|
+
self._session = requests.Session()
|
|
61
|
+
self._session.headers["Authorization"] = f"Bearer agent:{agent}"
|
|
62
|
+
self._session.headers["Content-Type"] = "application/json"
|
|
63
|
+
|
|
64
|
+
def decide(self, name: str, arguments: dict[str, Any] | None = None) -> Decision:
|
|
65
|
+
"""Evaluate policy without executing. Returns allow/deny/human_approval."""
|
|
66
|
+
resp = self._session.post(
|
|
67
|
+
f"{self._url}/decide",
|
|
68
|
+
json={"agent": self._agent, "tool": name, "arguments": arguments or {}},
|
|
69
|
+
timeout=self._timeout,
|
|
70
|
+
)
|
|
71
|
+
body = resp.json() if resp.content else {}
|
|
72
|
+
action = body.get("action", "error")
|
|
73
|
+
try:
|
|
74
|
+
act = Action(action)
|
|
75
|
+
except ValueError:
|
|
76
|
+
act = Action.ERROR
|
|
77
|
+
return Decision(
|
|
78
|
+
action=act,
|
|
79
|
+
tool=name,
|
|
80
|
+
error=body.get("reason", "") if act == Action.DENY else "",
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
def call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> Decision:
|
|
84
|
+
"""Execute a tool call through agent-mesh (policy + execute + trace)."""
|
|
85
|
+
resp = self._session.post(
|
|
86
|
+
f"{self._url}/tool/{name}",
|
|
87
|
+
json=arguments or {},
|
|
88
|
+
timeout=self._timeout,
|
|
89
|
+
)
|
|
90
|
+
if resp.status_code == 403:
|
|
91
|
+
return Decision(action=Action.DENY, tool=name, error="denied by policy")
|
|
92
|
+
if resp.status_code == 202:
|
|
93
|
+
body = resp.json() if resp.content else {}
|
|
94
|
+
return Decision(
|
|
95
|
+
action=Action.HUMAN_APPROVAL,
|
|
96
|
+
tool=name,
|
|
97
|
+
approval_id=body.get("approval_id", ""),
|
|
98
|
+
)
|
|
99
|
+
if resp.status_code >= 400:
|
|
100
|
+
return Decision(
|
|
101
|
+
action=Action.ERROR,
|
|
102
|
+
tool=name,
|
|
103
|
+
error=f"HTTP {resp.status_code}: {resp.text}",
|
|
104
|
+
)
|
|
105
|
+
return Decision(
|
|
106
|
+
action=Action.ALLOW,
|
|
107
|
+
tool=name,
|
|
108
|
+
result=resp.text,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
def tools(self) -> list[Tool]:
|
|
112
|
+
"""List all available tools."""
|
|
113
|
+
resp = self._session.get(f"{self._url}/tools", timeout=self._timeout)
|
|
114
|
+
resp.raise_for_status()
|
|
115
|
+
items = resp.json()
|
|
116
|
+
return [
|
|
117
|
+
Tool(
|
|
118
|
+
name=t.get("name", ""),
|
|
119
|
+
description=t.get("description", ""),
|
|
120
|
+
source=t.get("source", ""),
|
|
121
|
+
)
|
|
122
|
+
for t in items
|
|
123
|
+
]
|
|
124
|
+
|
|
125
|
+
def approvals(self) -> list[dict[str, Any]]:
|
|
126
|
+
"""List pending approvals."""
|
|
127
|
+
resp = self._session.get(f"{self._url}/approvals", timeout=self._timeout)
|
|
128
|
+
resp.raise_for_status()
|
|
129
|
+
return resp.json()
|
|
130
|
+
|
|
131
|
+
def approve(self, approval_id: str) -> bool:
|
|
132
|
+
"""Approve a pending request."""
|
|
133
|
+
resp = self._session.post(
|
|
134
|
+
f"{self._url}/approvals/{approval_id}/approve",
|
|
135
|
+
timeout=self._timeout,
|
|
136
|
+
)
|
|
137
|
+
return resp.status_code == 200
|
|
138
|
+
|
|
139
|
+
def deny(self, approval_id: str) -> bool:
|
|
140
|
+
"""Deny a pending request."""
|
|
141
|
+
resp = self._session.post(
|
|
142
|
+
f"{self._url}/approvals/{approval_id}/deny",
|
|
143
|
+
timeout=self._timeout,
|
|
144
|
+
)
|
|
145
|
+
return resp.status_code == 200
|
|
146
|
+
|
|
147
|
+
def grants(self) -> list[dict[str, Any]]:
|
|
148
|
+
"""List active grants."""
|
|
149
|
+
resp = self._session.get(f"{self._url}/grants", timeout=self._timeout)
|
|
150
|
+
resp.raise_for_status()
|
|
151
|
+
return resp.json()
|
|
152
|
+
|
|
153
|
+
def create_grant(self, tools: str, duration: str) -> dict[str, Any]:
|
|
154
|
+
"""Create a temporal grant."""
|
|
155
|
+
resp = self._session.post(
|
|
156
|
+
f"{self._url}/grants",
|
|
157
|
+
json={"agent": self._agent, "tools": tools, "duration": duration},
|
|
158
|
+
timeout=self._timeout,
|
|
159
|
+
)
|
|
160
|
+
resp.raise_for_status()
|
|
161
|
+
return resp.json()
|
|
162
|
+
|
|
163
|
+
def revoke_grant(self, grant_id: str) -> bool:
|
|
164
|
+
"""Revoke an active grant."""
|
|
165
|
+
resp = self._session.delete(
|
|
166
|
+
f"{self._url}/grants/{grant_id}",
|
|
167
|
+
timeout=self._timeout,
|
|
168
|
+
)
|
|
169
|
+
return resp.status_code == 200
|
|
170
|
+
|
|
171
|
+
def traces(self, limit: int = 100) -> list[dict[str, Any]]:
|
|
172
|
+
"""Get recent traces."""
|
|
173
|
+
resp = self._session.get(
|
|
174
|
+
f"{self._url}/traces",
|
|
175
|
+
params={"limit": limit},
|
|
176
|
+
timeout=self._timeout,
|
|
177
|
+
)
|
|
178
|
+
resp.raise_for_status()
|
|
179
|
+
return resp.json()
|
|
180
|
+
|
|
181
|
+
def health(self) -> dict[str, Any]:
|
|
182
|
+
"""Get mesh health status."""
|
|
183
|
+
try:
|
|
184
|
+
resp = self._session.get(f"{self._url}/health", timeout=5)
|
|
185
|
+
resp.raise_for_status()
|
|
186
|
+
return resp.json()
|
|
187
|
+
except requests.RequestException:
|
|
188
|
+
return {"status": "unreachable"}
|
|
189
|
+
|
|
190
|
+
def is_healthy(self) -> bool:
|
|
191
|
+
return self.health().get("status") == "ok"
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""GovernedToolkit — wrap Python functions as governed tools for the Claude API.
|
|
2
|
+
|
|
3
|
+
Usage::
|
|
4
|
+
|
|
5
|
+
from agent_mesh import GovernedToolkit
|
|
6
|
+
|
|
7
|
+
toolkit = GovernedToolkit(agent="my-agent")
|
|
8
|
+
|
|
9
|
+
@toolkit.tool
|
|
10
|
+
def get_weather(city: str) -> str:
|
|
11
|
+
\"\"\"Get current weather for a city.\"\"\"
|
|
12
|
+
return fetch_weather(city)
|
|
13
|
+
|
|
14
|
+
# Generate tools[] for Claude API
|
|
15
|
+
schemas = toolkit.schemas()
|
|
16
|
+
|
|
17
|
+
# Execute tool_use blocks with governance
|
|
18
|
+
result = toolkit.execute(tool_name, tool_input)
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import inspect
|
|
23
|
+
import json
|
|
24
|
+
from typing import Any, Callable, get_type_hints
|
|
25
|
+
|
|
26
|
+
from agent_mesh.client import Action, AgentMesh, AgentMeshError, Decision
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _python_type_to_json(t: Any) -> str:
|
|
30
|
+
mapping = {str: "string", int: "integer", float: "number", bool: "boolean"}
|
|
31
|
+
return mapping.get(t, "string")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _build_schema(func: Callable) -> dict[str, Any]:
|
|
35
|
+
"""Build a JSON Schema input_schema from a function's signature and type hints."""
|
|
36
|
+
hints = get_type_hints(func)
|
|
37
|
+
sig = inspect.signature(func)
|
|
38
|
+
properties: dict[str, Any] = {}
|
|
39
|
+
required: list[str] = []
|
|
40
|
+
|
|
41
|
+
for name, param in sig.parameters.items():
|
|
42
|
+
prop: dict[str, Any] = {"type": _python_type_to_json(hints.get(name, str))}
|
|
43
|
+
desc = ""
|
|
44
|
+
if func.__doc__:
|
|
45
|
+
for line in func.__doc__.splitlines():
|
|
46
|
+
stripped = line.strip()
|
|
47
|
+
if stripped.startswith(f":param {name}:") or stripped.startswith(f"{name}:"):
|
|
48
|
+
desc = stripped.split(":", 2)[-1].strip()
|
|
49
|
+
break
|
|
50
|
+
if desc:
|
|
51
|
+
prop["description"] = desc
|
|
52
|
+
properties[name] = prop
|
|
53
|
+
if param.default is inspect.Parameter.empty:
|
|
54
|
+
required.append(name)
|
|
55
|
+
|
|
56
|
+
schema: dict[str, Any] = {"type": "object", "properties": properties}
|
|
57
|
+
if required:
|
|
58
|
+
schema["required"] = required
|
|
59
|
+
return schema
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def tool(func: Callable) -> Callable:
|
|
63
|
+
"""Standalone decorator — marks a function as a governed tool."""
|
|
64
|
+
func._is_governed_tool = True # type: ignore[attr-defined]
|
|
65
|
+
return func
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class GovernedToolkit:
|
|
69
|
+
def __init__(
|
|
70
|
+
self,
|
|
71
|
+
agent: str = "default",
|
|
72
|
+
url: str = "http://localhost:9090",
|
|
73
|
+
mesh: AgentMesh | None = None,
|
|
74
|
+
) -> None:
|
|
75
|
+
self._mesh = mesh or AgentMesh(url=url, agent=agent)
|
|
76
|
+
self._tools: dict[str, Callable] = {}
|
|
77
|
+
|
|
78
|
+
def tool(self, func: Callable) -> Callable:
|
|
79
|
+
"""Register a function as a governed tool."""
|
|
80
|
+
self._tools[func.__name__] = func
|
|
81
|
+
func._is_governed_tool = True # type: ignore[attr-defined]
|
|
82
|
+
return func
|
|
83
|
+
|
|
84
|
+
def register(self, func: Callable, name: str | None = None) -> None:
|
|
85
|
+
"""Register a function programmatically."""
|
|
86
|
+
key = name or func.__name__
|
|
87
|
+
self._tools[key] = func
|
|
88
|
+
|
|
89
|
+
def schemas(self) -> list[dict[str, Any]]:
|
|
90
|
+
"""Generate the tools[] array for the Claude API messages endpoint."""
|
|
91
|
+
result = []
|
|
92
|
+
for name, func in self._tools.items():
|
|
93
|
+
result.append({
|
|
94
|
+
"name": name,
|
|
95
|
+
"description": (func.__doc__ or "").strip().split("\n")[0],
|
|
96
|
+
"input_schema": _build_schema(func),
|
|
97
|
+
})
|
|
98
|
+
return result
|
|
99
|
+
|
|
100
|
+
def execute(self, tool_name: str, tool_input: dict[str, Any]) -> Decision:
|
|
101
|
+
"""Execute a tool call with governance: decide via mesh, then run locally."""
|
|
102
|
+
if tool_name not in self._tools:
|
|
103
|
+
return Decision(
|
|
104
|
+
action=Action.ERROR,
|
|
105
|
+
tool=tool_name,
|
|
106
|
+
error=f"unknown tool: {tool_name}",
|
|
107
|
+
)
|
|
108
|
+
decision = self._mesh.decide(tool_name, tool_input)
|
|
109
|
+
if decision.action != Action.ALLOW:
|
|
110
|
+
return decision
|
|
111
|
+
try:
|
|
112
|
+
result = self._tools[tool_name](**tool_input)
|
|
113
|
+
decision.result = str(result) if result is not None else ""
|
|
114
|
+
except Exception as e:
|
|
115
|
+
decision.action = Action.ERROR
|
|
116
|
+
decision.error = str(e)
|
|
117
|
+
return decision
|
|
118
|
+
|
|
119
|
+
def execute_local(self, tool_name: str, tool_input: dict[str, Any]) -> Decision:
|
|
120
|
+
"""Execute locally without going through mesh (for tools not proxied)."""
|
|
121
|
+
if tool_name not in self._tools:
|
|
122
|
+
return Decision(
|
|
123
|
+
action=Action.ERROR,
|
|
124
|
+
tool=tool_name,
|
|
125
|
+
error=f"unknown tool: {tool_name}",
|
|
126
|
+
)
|
|
127
|
+
try:
|
|
128
|
+
result = self._tools[tool_name](**tool_input)
|
|
129
|
+
return Decision(
|
|
130
|
+
action=Action.ALLOW,
|
|
131
|
+
tool=tool_name,
|
|
132
|
+
result=str(result) if result is not None else "",
|
|
133
|
+
)
|
|
134
|
+
except Exception as e:
|
|
135
|
+
return Decision(action=Action.ERROR, tool=tool_name, error=str(e))
|
|
136
|
+
|
|
137
|
+
def process_response(self, content: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
138
|
+
"""Process tool_use blocks from a Claude API response.
|
|
139
|
+
|
|
140
|
+
Returns tool_result blocks ready to send back.
|
|
141
|
+
"""
|
|
142
|
+
results = []
|
|
143
|
+
for block in content:
|
|
144
|
+
if block.get("type") != "tool_use":
|
|
145
|
+
continue
|
|
146
|
+
decision = self.execute(block["name"], block.get("input", {}))
|
|
147
|
+
if decision.action in (Action.DENY, Action.ERROR):
|
|
148
|
+
results.append({
|
|
149
|
+
"type": "tool_result",
|
|
150
|
+
"tool_use_id": block["id"],
|
|
151
|
+
"is_error": True,
|
|
152
|
+
"content": decision.error or f"denied: {decision.tool}",
|
|
153
|
+
})
|
|
154
|
+
elif decision.action == Action.HUMAN_APPROVAL:
|
|
155
|
+
results.append({
|
|
156
|
+
"type": "tool_result",
|
|
157
|
+
"tool_use_id": block["id"],
|
|
158
|
+
"is_error": True,
|
|
159
|
+
"content": f"awaiting human approval (id: {decision.approval_id})",
|
|
160
|
+
})
|
|
161
|
+
else:
|
|
162
|
+
results.append({
|
|
163
|
+
"type": "tool_result",
|
|
164
|
+
"tool_use_id": block["id"],
|
|
165
|
+
"content": decision.result,
|
|
166
|
+
})
|
|
167
|
+
return results
|
|
File without changes
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Tests for the agent-mesh Python SDK client."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from unittest.mock import MagicMock, patch
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from agent_mesh import AgentMesh, Decision
|
|
9
|
+
from agent_mesh.client import Action, AgentMeshError, Tool
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.fixture
|
|
13
|
+
def mesh():
|
|
14
|
+
return AgentMesh("http://localhost:9090", agent="test-agent")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TestInit:
|
|
18
|
+
def test_url_trailing_slash(self):
|
|
19
|
+
m = AgentMesh("http://localhost:9090/")
|
|
20
|
+
assert m._url == "http://localhost:9090"
|
|
21
|
+
|
|
22
|
+
def test_auth_header(self):
|
|
23
|
+
m = AgentMesh(agent="bot")
|
|
24
|
+
assert m._session.headers["Authorization"] == "Bearer agent:bot"
|
|
25
|
+
|
|
26
|
+
def test_default_url(self):
|
|
27
|
+
m = AgentMesh()
|
|
28
|
+
assert m._url == "http://localhost:9090"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class TestDecide:
|
|
32
|
+
def test_allow(self, mesh):
|
|
33
|
+
mock_resp = MagicMock()
|
|
34
|
+
mock_resp.status_code = 200
|
|
35
|
+
mock_resp.content = b'{"action":"allow","rule":"allow-all","agent":"test-agent","tool":"fs.read"}'
|
|
36
|
+
mock_resp.json.return_value = {"action": "allow", "rule": "allow-all", "agent": "test-agent", "tool": "fs.read"}
|
|
37
|
+
with patch.object(mesh._session, "post", return_value=mock_resp) as mock_post:
|
|
38
|
+
d = mesh.decide("fs.read", {"path": "/tmp"})
|
|
39
|
+
assert d.action == Action.ALLOW
|
|
40
|
+
assert d.tool == "fs.read"
|
|
41
|
+
body = mock_post.call_args[1]["json"]
|
|
42
|
+
assert body["agent"] == "test-agent"
|
|
43
|
+
assert body["tool"] == "fs.read"
|
|
44
|
+
assert mock_post.call_args[0][0] == "http://localhost:9090/decide"
|
|
45
|
+
|
|
46
|
+
def test_deny(self, mesh):
|
|
47
|
+
mock_resp = MagicMock()
|
|
48
|
+
mock_resp.status_code = 403
|
|
49
|
+
mock_resp.content = b'{"action":"deny","reason":"blocked by policy"}'
|
|
50
|
+
mock_resp.json.return_value = {"action": "deny", "reason": "blocked by policy"}
|
|
51
|
+
with patch.object(mesh._session, "post", return_value=mock_resp):
|
|
52
|
+
d = mesh.decide("dangerous.tool", {})
|
|
53
|
+
assert d.action == Action.DENY
|
|
54
|
+
assert "blocked" in d.error
|
|
55
|
+
|
|
56
|
+
def test_human_approval(self, mesh):
|
|
57
|
+
mock_resp = MagicMock()
|
|
58
|
+
mock_resp.status_code = 200
|
|
59
|
+
mock_resp.content = b'{"action":"human_approval","rule":"needs-approval"}'
|
|
60
|
+
mock_resp.json.return_value = {"action": "human_approval", "rule": "needs-approval"}
|
|
61
|
+
with patch.object(mesh._session, "post", return_value=mock_resp):
|
|
62
|
+
d = mesh.decide("fs.write", {})
|
|
63
|
+
assert d.action == Action.HUMAN_APPROVAL
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class TestCallTool:
|
|
67
|
+
def test_allowed(self, mesh):
|
|
68
|
+
mock_resp = MagicMock()
|
|
69
|
+
mock_resp.status_code = 200
|
|
70
|
+
mock_resp.text = '{"result": "ok"}'
|
|
71
|
+
with patch.object(mesh._session, "post", return_value=mock_resp) as mock_post:
|
|
72
|
+
d = mesh.call_tool("mesh.catalog", {})
|
|
73
|
+
assert d.action == Action.ALLOW
|
|
74
|
+
assert d.tool == "mesh.catalog"
|
|
75
|
+
assert d.result == '{"result": "ok"}'
|
|
76
|
+
assert mock_post.call_args[0][0] == "http://localhost:9090/tool/mesh.catalog"
|
|
77
|
+
|
|
78
|
+
def test_denied(self, mesh):
|
|
79
|
+
mock_resp = MagicMock()
|
|
80
|
+
mock_resp.status_code = 403
|
|
81
|
+
with patch.object(mesh._session, "post", return_value=mock_resp):
|
|
82
|
+
d = mesh.call_tool("dangerous.tool", {})
|
|
83
|
+
assert d.action == Action.DENY
|
|
84
|
+
|
|
85
|
+
def test_human_approval(self, mesh):
|
|
86
|
+
mock_resp = MagicMock()
|
|
87
|
+
mock_resp.status_code = 202
|
|
88
|
+
mock_resp.content = b'{"approval_id": "abc123"}'
|
|
89
|
+
mock_resp.json.return_value = {"approval_id": "abc123"}
|
|
90
|
+
with patch.object(mesh._session, "post", return_value=mock_resp):
|
|
91
|
+
d = mesh.call_tool("fs.write", {"path": "/etc/passwd"})
|
|
92
|
+
assert d.action == Action.HUMAN_APPROVAL
|
|
93
|
+
assert d.approval_id == "abc123"
|
|
94
|
+
|
|
95
|
+
def test_server_error(self, mesh):
|
|
96
|
+
mock_resp = MagicMock()
|
|
97
|
+
mock_resp.status_code = 500
|
|
98
|
+
mock_resp.text = "internal error"
|
|
99
|
+
with patch.object(mesh._session, "post", return_value=mock_resp):
|
|
100
|
+
d = mesh.call_tool("broken", {})
|
|
101
|
+
assert d.action == Action.ERROR
|
|
102
|
+
assert "500" in d.error
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class TestTools:
|
|
106
|
+
def test_list_tools(self, mesh):
|
|
107
|
+
mock_resp = MagicMock()
|
|
108
|
+
mock_resp.json.return_value = [
|
|
109
|
+
{"name": "fs.read", "description": "Read a file", "source": "filesystem"},
|
|
110
|
+
{"name": "git.status", "description": "Git status", "source": "git"},
|
|
111
|
+
]
|
|
112
|
+
mock_resp.raise_for_status = MagicMock()
|
|
113
|
+
with patch.object(mesh._session, "get", return_value=mock_resp):
|
|
114
|
+
tools = mesh.tools()
|
|
115
|
+
assert len(tools) == 2
|
|
116
|
+
assert isinstance(tools[0], Tool)
|
|
117
|
+
assert tools[0].name == "fs.read"
|
|
118
|
+
assert tools[1].source == "git"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class TestApprovals:
|
|
122
|
+
def test_approve(self, mesh):
|
|
123
|
+
mock_resp = MagicMock()
|
|
124
|
+
mock_resp.status_code = 200
|
|
125
|
+
with patch.object(mesh._session, "post", return_value=mock_resp) as mock_post:
|
|
126
|
+
assert mesh.approve("abc123") is True
|
|
127
|
+
assert "/approvals/abc123/approve" in mock_post.call_args[0][0]
|
|
128
|
+
|
|
129
|
+
def test_deny(self, mesh):
|
|
130
|
+
mock_resp = MagicMock()
|
|
131
|
+
mock_resp.status_code = 200
|
|
132
|
+
with patch.object(mesh._session, "post", return_value=mock_resp):
|
|
133
|
+
assert mesh.deny("abc123") is True
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class TestGrants:
|
|
137
|
+
def test_create_grant(self, mesh):
|
|
138
|
+
mock_resp = MagicMock()
|
|
139
|
+
mock_resp.json.return_value = {"id": "g1", "tools": "fs.*", "duration": "30m"}
|
|
140
|
+
mock_resp.raise_for_status = MagicMock()
|
|
141
|
+
with patch.object(mesh._session, "post", return_value=mock_resp) as mock_post:
|
|
142
|
+
g = mesh.create_grant("fs.*", "30m")
|
|
143
|
+
assert g["id"] == "g1"
|
|
144
|
+
body = mock_post.call_args[1]["json"]
|
|
145
|
+
assert body["agent"] == "test-agent"
|
|
146
|
+
assert body["tools"] == "fs.*"
|
|
147
|
+
|
|
148
|
+
def test_revoke_grant(self, mesh):
|
|
149
|
+
mock_resp = MagicMock()
|
|
150
|
+
mock_resp.status_code = 200
|
|
151
|
+
with patch.object(mesh._session, "delete", return_value=mock_resp):
|
|
152
|
+
assert mesh.revoke_grant("g1") is True
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class TestHealth:
|
|
156
|
+
def test_healthy(self, mesh):
|
|
157
|
+
mock_resp = MagicMock()
|
|
158
|
+
mock_resp.json.return_value = {"status": "ok", "tools": 42}
|
|
159
|
+
mock_resp.raise_for_status = MagicMock()
|
|
160
|
+
with patch.object(mesh._session, "get", return_value=mock_resp):
|
|
161
|
+
assert mesh.is_healthy() is True
|
|
162
|
+
|
|
163
|
+
def test_unreachable(self, mesh):
|
|
164
|
+
with patch.object(mesh._session, "get", side_effect=requests.ConnectionError):
|
|
165
|
+
h = mesh.health()
|
|
166
|
+
assert h["status"] == "unreachable"
|
|
167
|
+
assert mesh.is_healthy() is False
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
import requests
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Tests for the GovernedToolkit."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from unittest.mock import MagicMock, patch
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from agent_mesh import GovernedToolkit
|
|
9
|
+
from agent_mesh.client import Action, AgentMesh, Decision
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.fixture
|
|
13
|
+
def toolkit():
|
|
14
|
+
tk = GovernedToolkit(agent="test", url="http://localhost:9090")
|
|
15
|
+
|
|
16
|
+
@tk.tool
|
|
17
|
+
def get_weather(city: str, units: str = "celsius") -> str:
|
|
18
|
+
"""Get current weather for a city."""
|
|
19
|
+
return f"sunny in {city}"
|
|
20
|
+
|
|
21
|
+
@tk.tool
|
|
22
|
+
def add_numbers(a: int, b: int) -> int:
|
|
23
|
+
"""Add two numbers."""
|
|
24
|
+
return a + b
|
|
25
|
+
|
|
26
|
+
return tk
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TestSchemas:
|
|
30
|
+
def test_generates_schemas(self, toolkit):
|
|
31
|
+
schemas = toolkit.schemas()
|
|
32
|
+
assert len(schemas) == 2
|
|
33
|
+
weather = next(s for s in schemas if s["name"] == "get_weather")
|
|
34
|
+
assert weather["description"] == "Get current weather for a city."
|
|
35
|
+
assert weather["input_schema"]["properties"]["city"]["type"] == "string"
|
|
36
|
+
assert weather["input_schema"]["properties"]["units"]["type"] == "string"
|
|
37
|
+
assert "city" in weather["input_schema"]["required"]
|
|
38
|
+
assert "units" not in weather["input_schema"]["required"]
|
|
39
|
+
|
|
40
|
+
def test_integer_types(self, toolkit):
|
|
41
|
+
schemas = toolkit.schemas()
|
|
42
|
+
add = next(s for s in schemas if s["name"] == "add_numbers")
|
|
43
|
+
assert add["input_schema"]["properties"]["a"]["type"] == "integer"
|
|
44
|
+
assert add["input_schema"]["required"] == ["a", "b"]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class TestExecute:
|
|
48
|
+
def test_allowed_runs_locally(self, toolkit):
|
|
49
|
+
mock_decision = Decision(action=Action.ALLOW, tool="get_weather", result="")
|
|
50
|
+
with patch.object(toolkit._mesh, "decide", return_value=mock_decision):
|
|
51
|
+
d = toolkit.execute("get_weather", {"city": "Paris"})
|
|
52
|
+
assert d.action == Action.ALLOW
|
|
53
|
+
assert d.result == "sunny in Paris"
|
|
54
|
+
|
|
55
|
+
def test_denied_does_not_run(self, toolkit):
|
|
56
|
+
mock_decision = Decision(action=Action.DENY, tool="get_weather", error="denied")
|
|
57
|
+
with patch.object(toolkit._mesh, "decide", return_value=mock_decision):
|
|
58
|
+
d = toolkit.execute("get_weather", {"city": "Paris"})
|
|
59
|
+
assert d.action == Action.DENY
|
|
60
|
+
assert d.result == ""
|
|
61
|
+
|
|
62
|
+
def test_unknown_tool(self, toolkit):
|
|
63
|
+
d = toolkit.execute("nonexistent", {})
|
|
64
|
+
assert d.action == Action.ERROR
|
|
65
|
+
assert "unknown tool" in d.error
|
|
66
|
+
|
|
67
|
+
def test_function_exception(self, toolkit):
|
|
68
|
+
def failing_tool():
|
|
69
|
+
raise ValueError("boom")
|
|
70
|
+
|
|
71
|
+
toolkit.register(failing_tool, "failing")
|
|
72
|
+
mock_decision = Decision(action=Action.ALLOW, tool="failing", result="")
|
|
73
|
+
with patch.object(toolkit._mesh, "decide", return_value=mock_decision):
|
|
74
|
+
d = toolkit.execute("failing", {})
|
|
75
|
+
assert d.action == Action.ERROR
|
|
76
|
+
assert "boom" in d.error
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class TestProcessResponse:
|
|
80
|
+
def test_processes_tool_use_blocks(self, toolkit):
|
|
81
|
+
mock_decision = Decision(action=Action.ALLOW, tool="get_weather", result="")
|
|
82
|
+
with patch.object(toolkit._mesh, "decide", return_value=mock_decision):
|
|
83
|
+
results = toolkit.process_response([
|
|
84
|
+
{"type": "text", "text": "Let me check the weather."},
|
|
85
|
+
{"type": "tool_use", "id": "tu_1", "name": "get_weather", "input": {"city": "Lyon"}},
|
|
86
|
+
])
|
|
87
|
+
assert len(results) == 1
|
|
88
|
+
assert results[0]["type"] == "tool_result"
|
|
89
|
+
assert results[0]["tool_use_id"] == "tu_1"
|
|
90
|
+
assert results[0]["content"] == "sunny in Lyon"
|
|
91
|
+
|
|
92
|
+
def test_denied_tool_returns_error(self, toolkit):
|
|
93
|
+
mock_decision = Decision(action=Action.DENY, tool="get_weather", error="denied by policy")
|
|
94
|
+
with patch.object(toolkit._mesh, "decide", return_value=mock_decision):
|
|
95
|
+
results = toolkit.process_response([
|
|
96
|
+
{"type": "tool_use", "id": "tu_2", "name": "get_weather", "input": {"city": "Lyon"}},
|
|
97
|
+
])
|
|
98
|
+
assert results[0]["is_error"] is True
|
|
99
|
+
assert "denied" in results[0]["content"]
|
|
100
|
+
|
|
101
|
+
def test_approval_pending(self, toolkit):
|
|
102
|
+
mock_decision = Decision(
|
|
103
|
+
action=Action.HUMAN_APPROVAL, tool="get_weather", approval_id="ap_123"
|
|
104
|
+
)
|
|
105
|
+
with patch.object(toolkit._mesh, "decide", return_value=mock_decision):
|
|
106
|
+
results = toolkit.process_response([
|
|
107
|
+
{"type": "tool_use", "id": "tu_3", "name": "get_weather", "input": {"city": "Lyon"}},
|
|
108
|
+
])
|
|
109
|
+
assert results[0]["is_error"] is True
|
|
110
|
+
assert "ap_123" in results[0]["content"]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class TestRegister:
|
|
114
|
+
def test_register_programmatic(self):
|
|
115
|
+
tk = GovernedToolkit(agent="test")
|
|
116
|
+
|
|
117
|
+
def my_func(x: str) -> str:
|
|
118
|
+
"""Do something."""
|
|
119
|
+
return x.upper()
|
|
120
|
+
|
|
121
|
+
tk.register(my_func, "custom_name")
|
|
122
|
+
schemas = tk.schemas()
|
|
123
|
+
assert schemas[0]["name"] == "custom_name"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class TestExecuteLocal:
|
|
127
|
+
def test_runs_without_mesh(self, toolkit):
|
|
128
|
+
d = toolkit.execute_local("add_numbers", {"a": 3, "b": 4})
|
|
129
|
+
assert d.action == Action.ALLOW
|
|
130
|
+
assert d.result == "7"
|
|
131
|
+
|
|
132
|
+
def test_unknown_tool(self, toolkit):
|
|
133
|
+
d = toolkit.execute_local("nope", {})
|
|
134
|
+
assert d.action == Action.ERROR
|