agentmesh-p2p 0.1.0rc10__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.
- agentmesh_p2p-0.1.0rc10/.gitignore +78 -0
- agentmesh_p2p-0.1.0rc10/PKG-INFO +216 -0
- agentmesh_p2p-0.1.0rc10/README.md +191 -0
- agentmesh_p2p-0.1.0rc10/examples/a2a_agent.py +92 -0
- agentmesh_p2p-0.1.0rc10/examples/a2a_call.py +60 -0
- agentmesh_p2p-0.1.0rc10/examples/agent.py +50 -0
- agentmesh_p2p-0.1.0rc10/examples/call.py +76 -0
- agentmesh_p2p-0.1.0rc10/proto/circuit.proto +77 -0
- agentmesh_p2p-0.1.0rc10/pyproject.toml +46 -0
- agentmesh_p2p-0.1.0rc10/src/agent_mesh/__init__.py +225 -0
- agentmesh_p2p-0.1.0rc10/src/agent_mesh/_gen/__init__.py +1 -0
- agentmesh_p2p-0.1.0rc10/src/agent_mesh/_gen/datalog.json +82 -0
- agentmesh_p2p-0.1.0rc10/src/agent_mesh/_proto/__init__.py +1 -0
- agentmesh_p2p-0.1.0rc10/src/agent_mesh/_proto/agentmesh_pb2.py +222 -0
- agentmesh_p2p-0.1.0rc10/src/agent_mesh/_proto/agentmesh_pb2.pyi +1002 -0
- agentmesh_p2p-0.1.0rc10/src/agent_mesh/_proto/circuit_pb2.py +39 -0
- agentmesh_p2p-0.1.0rc10/src/agent_mesh/_proto/circuit_pb2.pyi +80 -0
- agentmesh_p2p-0.1.0rc10/src/agent_mesh/auth.py +186 -0
- agentmesh_p2p-0.1.0rc10/src/agent_mesh/authorizer.py +196 -0
- agentmesh_p2p-0.1.0rc10/src/agent_mesh/base58.py +40 -0
- agentmesh_p2p-0.1.0rc10/src/agent_mesh/biscuit.py +330 -0
- agentmesh_p2p-0.1.0rc10/src/agent_mesh/challenges.py +64 -0
- agentmesh_p2p-0.1.0rc10/src/agent_mesh/conformance.py +81 -0
- agentmesh_p2p-0.1.0rc10/src/agent_mesh/conformance_join.py +265 -0
- agentmesh_p2p-0.1.0rc10/src/agent_mesh/controlplane.py +434 -0
- agentmesh_p2p-0.1.0rc10/src/agent_mesh/credential.py +137 -0
- agentmesh_p2p-0.1.0rc10/src/agent_mesh/discovery.py +179 -0
- agentmesh_p2p-0.1.0rc10/src/agent_mesh/host.py +220 -0
- agentmesh_p2p-0.1.0rc10/src/agent_mesh/httpx_transport.py +98 -0
- agentmesh_p2p-0.1.0rc10/src/agent_mesh/identity.py +131 -0
- agentmesh_p2p-0.1.0rc10/src/agent_mesh/libp2p_http.py +617 -0
- agentmesh_p2p-0.1.0rc10/src/agent_mesh/mcp_client.py +198 -0
- agentmesh_p2p-0.1.0rc10/src/agent_mesh/mesh.py +402 -0
- agentmesh_p2p-0.1.0rc10/src/agent_mesh/relay.py +152 -0
- agentmesh_p2p-0.1.0rc10/src/agent_mesh/session.py +1381 -0
- agentmesh_p2p-0.1.0rc10/src/agent_mesh/sync.py +97 -0
- agentmesh_p2p-0.1.0rc10/src/agent_mesh/tar.py +352 -0
- agentmesh_p2p-0.1.0rc10/tests/__init__.py +0 -0
- agentmesh_p2p-0.1.0rc10/tests/test_authorizer.py +306 -0
- agentmesh_p2p-0.1.0rc10/tests/test_biscuit.py +79 -0
- agentmesh_p2p-0.1.0rc10/tests/test_controlplane.py +326 -0
- agentmesh_p2p-0.1.0rc10/tests/test_discovery.py +246 -0
- agentmesh_p2p-0.1.0rc10/tests/test_host.py +380 -0
- agentmesh_p2p-0.1.0rc10/tests/test_identity.py +86 -0
- agentmesh_p2p-0.1.0rc10/tests/test_libp2p_http.py +482 -0
- agentmesh_p2p-0.1.0rc10/tests/test_mcp.py +340 -0
- agentmesh_p2p-0.1.0rc10/tests/test_mesh.py +310 -0
- agentmesh_p2p-0.1.0rc10/tests/test_relay.py +70 -0
- agentmesh_p2p-0.1.0rc10/tests/test_session.py +1081 -0
- agentmesh_p2p-0.1.0rc10/tests/test_sync.py +296 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# If you prefer the allow list template instead of the deny list, see community template:
|
|
2
|
+
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
|
|
3
|
+
#
|
|
4
|
+
# Binaries for programs and plugins
|
|
5
|
+
*.exe
|
|
6
|
+
*.exe~
|
|
7
|
+
*.dll
|
|
8
|
+
*.so
|
|
9
|
+
*.dylib
|
|
10
|
+
*.pyc
|
|
11
|
+
|
|
12
|
+
# Test binary, built with `go test -c`
|
|
13
|
+
*.test
|
|
14
|
+
|
|
15
|
+
# Code coverage profiles and other test artifacts
|
|
16
|
+
*.out
|
|
17
|
+
coverage.*
|
|
18
|
+
*.coverprofile
|
|
19
|
+
profile.cov
|
|
20
|
+
|
|
21
|
+
# Dependency directories (remove the comment below to include it)
|
|
22
|
+
# vendor/
|
|
23
|
+
|
|
24
|
+
# Go workspace file
|
|
25
|
+
go.work
|
|
26
|
+
go.work.sum
|
|
27
|
+
|
|
28
|
+
# env file
|
|
29
|
+
.env
|
|
30
|
+
|
|
31
|
+
# Editor/IDE
|
|
32
|
+
# .idea/
|
|
33
|
+
# .vscode/
|
|
34
|
+
#
|
|
35
|
+
bin/
|
|
36
|
+
dist/
|
|
37
|
+
# State of `make testnet` (database, router key, tokens)
|
|
38
|
+
.agentmesh-one/
|
|
39
|
+
# Stray binaries from `go build ./cmd/<name>/` in the repo root
|
|
40
|
+
/agentmesh-node
|
|
41
|
+
/agentmesh-one
|
|
42
|
+
/agentmesh-box
|
|
43
|
+
/agentmesh-router
|
|
44
|
+
/agentmesh-console
|
|
45
|
+
|
|
46
|
+
# Playwright console UI tests
|
|
47
|
+
node_modules/
|
|
48
|
+
tests/ui/playwright-report/
|
|
49
|
+
tests/ui/test-results/
|
|
50
|
+
/agentmesh-control-plane
|
|
51
|
+
/agentmesh-bench
|
|
52
|
+
/mcp-client
|
|
53
|
+
/nano-init
|
|
54
|
+
tests/e2e/logs/
|
|
55
|
+
tests/integration/logs/
|
|
56
|
+
tests/integration/scratch/
|
|
57
|
+
__pycache__/
|
|
58
|
+
.pytest_cache/
|
|
59
|
+
.worktrees/
|
|
60
|
+
.venv/
|
|
61
|
+
*.egg-info/
|
|
62
|
+
keys.db
|
|
63
|
+
mobile/logcat.txt
|
|
64
|
+
|
|
65
|
+
# Guest images built by scripts/build-rootfs.sh; gigabytes, and reproducible
|
|
66
|
+
# from the script, so they belong in a bucket rather than in history.
|
|
67
|
+
/rootfs.ext4
|
|
68
|
+
/rootfs.tar
|
|
69
|
+
|
|
70
|
+
# Android upload keystore and Firebase config: secrets, wherever they land.
|
|
71
|
+
*.jks
|
|
72
|
+
*.keystore
|
|
73
|
+
key.properties
|
|
74
|
+
google-services.json
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# Recordings of development/examples/sandboxed-agent/record.sh
|
|
78
|
+
development/examples/sandboxed-agent/out/
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: agentmesh-p2p
|
|
3
|
+
Version: 0.1.0-rc.10
|
|
4
|
+
Summary: Native SDK for joining an Agent Mesh from inside a Python agent process
|
|
5
|
+
Project-URL: Homepage, https://github.com/google/agentmesh
|
|
6
|
+
Project-URL: Source, https://github.com/google/agentmesh/tree/main/sdk/python
|
|
7
|
+
License-Expression: Apache-2.0
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Requires-Dist: anyio>=4
|
|
10
|
+
Requires-Dist: biscuit-python>=0.4
|
|
11
|
+
Requires-Dist: cryptography>=42
|
|
12
|
+
Requires-Dist: h11>=0.14
|
|
13
|
+
Requires-Dist: httpx>=0.27
|
|
14
|
+
Requires-Dist: libp2p<0.9,>=0.8
|
|
15
|
+
Requires-Dist: mcp<3,>=2
|
|
16
|
+
Requires-Dist: multiaddr>=0.2
|
|
17
|
+
Requires-Dist: protobuf<8,>=4.25
|
|
18
|
+
Requires-Dist: trio>=0.26
|
|
19
|
+
Provides-Extra: examples
|
|
20
|
+
Requires-Dist: a2a-sdk[http-server]>=1.1; extra == 'examples'
|
|
21
|
+
Requires-Dist: uvicorn>=0.30; extra == 'examples'
|
|
22
|
+
Provides-Extra: test
|
|
23
|
+
Requires-Dist: pytest>=8; extra == 'test'
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# agentmesh-p2p (Python)
|
|
27
|
+
|
|
28
|
+
Native Python SDK for joining an Agent Mesh from inside the agent
|
|
29
|
+
process. It replaces the `agentmesh-node` sidecar for agents written in Python:
|
|
30
|
+
the agent enrolls with the control plane, joins the mesh through a router,
|
|
31
|
+
finds services and calls them, answers A2A requests for the agent itself,
|
|
32
|
+
and follows the control plane's keys, bans and policy while it runs. It
|
|
33
|
+
publishes no service; a tool or a model others should find by name runs
|
|
34
|
+
behind a `agentmesh-node`. Import it as
|
|
35
|
+
`agent_mesh`.
|
|
36
|
+
|
|
37
|
+
Guide: [google.github.io/agentmesh/docs/guides/native-sdks](https://google.github.io/agentmesh/docs/guides/native-sdks/),
|
|
38
|
+
from an empty machine to two programs on a mesh.
|
|
39
|
+
Source: [github.com/google/agentmesh/tree/main/sdk/python](https://github.com/google/agentmesh/tree/main/sdk/python).
|
|
40
|
+
|
|
41
|
+
## Install
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install agentmesh-p2p
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Python 3.11 or later. The SDK runs on trio (py-libp2p is trio-based); under
|
|
48
|
+
asyncio, use it through `anyio` with the trio backend. One dependency,
|
|
49
|
+
`fastecdsa`, builds from source against GMP (`libgmp-dev` on Debian).
|
|
50
|
+
|
|
51
|
+
## Use
|
|
52
|
+
|
|
53
|
+
Both programs below are in
|
|
54
|
+
[`examples/`](https://github.com/google/agentmesh/tree/main/sdk/python/examples)
|
|
55
|
+
and run against a real mesh in the repository's tests. They read the mesh
|
|
56
|
+
from `AGENTMESH_CONTROL_PLANE_URL` and the enrollment token from
|
|
57
|
+
`AGENTMESH_BOOTSTRAP_TOKEN_PATH`; the guide shows how to get both from `agentmesh-one`
|
|
58
|
+
or from the operator of an existing mesh.
|
|
59
|
+
|
|
60
|
+
Find a service and call it:
|
|
61
|
+
|
|
62
|
+
<!-- embed: sdk/python/examples/call.py -->
|
|
63
|
+
```python
|
|
64
|
+
"""Calls something on the mesh: a tool of an MCP service or a path of an
|
|
65
|
+
inference or A2A service someone published, found by name, or an agent that
|
|
66
|
+
published nothing, reached by its peer ID.
|
|
67
|
+
|
|
68
|
+
python call.py mcp://everything echo '{"message": "hi"}'
|
|
69
|
+
python call.py inference://ollama /v1/models
|
|
70
|
+
python call.py 12D3KooW... a2a://agent /card
|
|
71
|
+
|
|
72
|
+
AGENTMESH_CONTROL_PLANE_URL names the mesh. The first run enrolls with the file
|
|
73
|
+
AGENTMESH_BOOTSTRAP_TOKEN_PATH (a token the mesh operator gave you) or AGENTMESH_JWT_PATH
|
|
74
|
+
(a workload identity token your platform issues, such as a Kubernetes
|
|
75
|
+
projected service account token), and keeps the identity and credential in
|
|
76
|
+
AGENTMESH_STATE_DIR; later runs resume from there without it.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
import json
|
|
80
|
+
import os
|
|
81
|
+
import sys
|
|
82
|
+
|
|
83
|
+
import trio
|
|
84
|
+
from agent_mesh import AgentMesh
|
|
85
|
+
|
|
86
|
+
argv = sys.argv[1:]
|
|
87
|
+
# A first argument that is not a service target is the peer ID of an agent.
|
|
88
|
+
peer_id = None
|
|
89
|
+
if argv and "://" not in argv[0]:
|
|
90
|
+
peer_id, argv = argv[0], argv[1:]
|
|
91
|
+
service = argv[0] if len(argv) > 0 else ("a2a://agent" if peer_id else "mcp://everything")
|
|
92
|
+
tool_or_path = argv[1] if len(argv) > 1 else ("/card" if peer_id else "echo")
|
|
93
|
+
args = json.loads(argv[2]) if len(argv) > 2 else {"message": "hi"}
|
|
94
|
+
|
|
95
|
+
mesh = AgentMesh.enroll(
|
|
96
|
+
os.environ.get("AGENTMESH_CONTROL_PLANE_URL", "https://mesh.example.com"),
|
|
97
|
+
bootstrap_token_path=os.environ.get("AGENTMESH_BOOTSTRAP_TOKEN_PATH"),
|
|
98
|
+
jwt_path=os.environ.get("AGENTMESH_JWT_PATH"),
|
|
99
|
+
state_dir=os.environ.get("AGENTMESH_STATE_DIR", "~/.config/agentmesh/caller"),
|
|
100
|
+
# A plaintext http:// control plane is otherwise accepted only on loopback.
|
|
101
|
+
allow_insecure=os.environ.get("AGENTMESH_INSECURE_CONTROL_PLANE") == "true",
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
async def main() -> None:
|
|
106
|
+
async with mesh.join() as session:
|
|
107
|
+
print(f"on the mesh as {session.peer_id}")
|
|
108
|
+
|
|
109
|
+
if peer_id:
|
|
110
|
+
# An agent is not in the discovery table; the SDK finds the path
|
|
111
|
+
# to its peer ID through the routers.
|
|
112
|
+
await session.connect(peer_id)
|
|
113
|
+
provider = peer_id
|
|
114
|
+
else:
|
|
115
|
+
providers = await session.discover(service)
|
|
116
|
+
if not providers:
|
|
117
|
+
raise SystemExit(f"no member of the mesh serves {service}")
|
|
118
|
+
# A provider record can outlive its member; the first that answers is used.
|
|
119
|
+
for provider in providers:
|
|
120
|
+
try:
|
|
121
|
+
await session.connect(provider)
|
|
122
|
+
break
|
|
123
|
+
except (ConnectionError, PermissionError) as err:
|
|
124
|
+
print(f"{provider.peer_id}: {err}", file=sys.stderr)
|
|
125
|
+
else:
|
|
126
|
+
raise SystemExit(f"no provider of {service} is reachable")
|
|
127
|
+
print(f"{service} is served by {peer_id or provider.peer_id}")
|
|
128
|
+
|
|
129
|
+
if service.startswith("mcp://"):
|
|
130
|
+
tools = await session.list_tools(provider, service)
|
|
131
|
+
print("tools:", ", ".join(t.name for t in tools))
|
|
132
|
+
result = await session.call_tool(provider, service, tool_or_path, args)
|
|
133
|
+
print("\n".join(result.text))
|
|
134
|
+
else:
|
|
135
|
+
response = await session.request(provider, service, tool_or_path)
|
|
136
|
+
print(response.status, response.text)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
trio.run(main)
|
|
140
|
+
```
|
|
141
|
+
<!-- /embed -->
|
|
142
|
+
|
|
143
|
+
Be an agent others can call. Nothing is published; a caller reaches the
|
|
144
|
+
agent by its peer ID, the one it prints, through a router:
|
|
145
|
+
|
|
146
|
+
<!-- embed: sdk/python/examples/agent.py -->
|
|
147
|
+
```python
|
|
148
|
+
"""An agent on the mesh: joins, then answers A2A requests from other members
|
|
149
|
+
until stopped. It publishes nothing. There is no service name to look up; a
|
|
150
|
+
caller reaches the agent by its peer ID, through a router, as `a2a://agent`.
|
|
151
|
+
The mesh policy decides which members may call; the SDK turns the others away
|
|
152
|
+
before anything reaches this code.
|
|
153
|
+
|
|
154
|
+
python agent.py # answered by the handler below
|
|
155
|
+
python agent.py http://127.0.0.1:9999 # forwarded to an A2A server beside it
|
|
156
|
+
|
|
157
|
+
AGENTMESH_CONTROL_PLANE_URL names the mesh. The first run enrolls with the file
|
|
158
|
+
AGENTMESH_BOOTSTRAP_TOKEN_PATH (a token the mesh operator gave you) or AGENTMESH_JWT_PATH
|
|
159
|
+
(a workload identity token your platform issues, such as a Kubernetes
|
|
160
|
+
projected service account token), and keeps the identity and credential in
|
|
161
|
+
AGENTMESH_STATE_DIR; later runs resume from there, as the same peer, without it.
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
import json
|
|
165
|
+
import os
|
|
166
|
+
import sys
|
|
167
|
+
|
|
168
|
+
import trio
|
|
169
|
+
from agent_mesh import AgentMesh, HTTPRequest, HTTPResponse, VerifiedBiscuit
|
|
170
|
+
|
|
171
|
+
backend_url = sys.argv[1] if len(sys.argv) > 1 else None
|
|
172
|
+
|
|
173
|
+
mesh = AgentMesh.enroll(
|
|
174
|
+
os.environ.get("AGENTMESH_CONTROL_PLANE_URL", "https://mesh.example.com"),
|
|
175
|
+
bootstrap_token_path=os.environ.get("AGENTMESH_BOOTSTRAP_TOKEN_PATH"),
|
|
176
|
+
jwt_path=os.environ.get("AGENTMESH_JWT_PATH"),
|
|
177
|
+
state_dir=os.environ.get("AGENTMESH_STATE_DIR", "~/.config/agentmesh/agent"),
|
|
178
|
+
# A plaintext http:// control plane is otherwise accepted only on loopback.
|
|
179
|
+
allow_insecure=os.environ.get("AGENTMESH_INSECURE_CONTROL_PLANE") == "true",
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
async def card(request: HTTPRequest, caller: VerifiedBiscuit) -> HTTPResponse:
|
|
184
|
+
"""Answers every path with who was asked and who asked; a real agent runs
|
|
185
|
+
an A2A server here, or beside this process at backend_url."""
|
|
186
|
+
body = json.dumps({"name": "agent", "path": request.path, "caller": caller.peer_id})
|
|
187
|
+
return HTTPResponse(status=200, headers={"content-type": "application/json"}, body=body.encode())
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
async def main() -> None:
|
|
191
|
+
async with mesh.join() as session:
|
|
192
|
+
target = await session.accept_a2a(backend_url or card)
|
|
193
|
+
print(f"accepting {target} as {session.peer_id}", flush=True)
|
|
194
|
+
await trio.sleep_forever()
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
trio.run(main)
|
|
198
|
+
```
|
|
199
|
+
<!-- /embed -->
|
|
200
|
+
|
|
201
|
+
`enroll` reuses the identity and credential saved in `state_dir` when they
|
|
202
|
+
are still valid for that control plane, and needs exactly one of
|
|
203
|
+
`bootstrap_token_path`, `bootstrap_token`, `jwt_path` or `jwt` (a token string
|
|
204
|
+
or a `Callable[[], str]` invoked at enrollment and on every refresh)
|
|
205
|
+
otherwise. Read tokens from a file, a callback or the environment; do not put
|
|
206
|
+
them on a command line. `labels` are attested at enrollment;
|
|
207
|
+
`join(egress_require_labels=)` is the floor every peer the session calls must
|
|
208
|
+
attest, all of it, held for the session.
|
|
209
|
+
|
|
210
|
+
A plaintext `http://` control plane is accepted only on loopback. Pass
|
|
211
|
+
`allow_insecure=True` for a network you trust.
|
|
212
|
+
|
|
213
|
+
## License
|
|
214
|
+
|
|
215
|
+
Apache-2.0. Issues and contributions at
|
|
216
|
+
[github.com/google/agentmesh](https://github.com/google/agentmesh).
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# agentmesh-p2p (Python)
|
|
2
|
+
|
|
3
|
+
Native Python SDK for joining an Agent Mesh from inside the agent
|
|
4
|
+
process. It replaces the `agentmesh-node` sidecar for agents written in Python:
|
|
5
|
+
the agent enrolls with the control plane, joins the mesh through a router,
|
|
6
|
+
finds services and calls them, answers A2A requests for the agent itself,
|
|
7
|
+
and follows the control plane's keys, bans and policy while it runs. It
|
|
8
|
+
publishes no service; a tool or a model others should find by name runs
|
|
9
|
+
behind a `agentmesh-node`. Import it as
|
|
10
|
+
`agent_mesh`.
|
|
11
|
+
|
|
12
|
+
Guide: [google.github.io/agentmesh/docs/guides/native-sdks](https://google.github.io/agentmesh/docs/guides/native-sdks/),
|
|
13
|
+
from an empty machine to two programs on a mesh.
|
|
14
|
+
Source: [github.com/google/agentmesh/tree/main/sdk/python](https://github.com/google/agentmesh/tree/main/sdk/python).
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install agentmesh-p2p
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Python 3.11 or later. The SDK runs on trio (py-libp2p is trio-based); under
|
|
23
|
+
asyncio, use it through `anyio` with the trio backend. One dependency,
|
|
24
|
+
`fastecdsa`, builds from source against GMP (`libgmp-dev` on Debian).
|
|
25
|
+
|
|
26
|
+
## Use
|
|
27
|
+
|
|
28
|
+
Both programs below are in
|
|
29
|
+
[`examples/`](https://github.com/google/agentmesh/tree/main/sdk/python/examples)
|
|
30
|
+
and run against a real mesh in the repository's tests. They read the mesh
|
|
31
|
+
from `AGENTMESH_CONTROL_PLANE_URL` and the enrollment token from
|
|
32
|
+
`AGENTMESH_BOOTSTRAP_TOKEN_PATH`; the guide shows how to get both from `agentmesh-one`
|
|
33
|
+
or from the operator of an existing mesh.
|
|
34
|
+
|
|
35
|
+
Find a service and call it:
|
|
36
|
+
|
|
37
|
+
<!-- embed: sdk/python/examples/call.py -->
|
|
38
|
+
```python
|
|
39
|
+
"""Calls something on the mesh: a tool of an MCP service or a path of an
|
|
40
|
+
inference or A2A service someone published, found by name, or an agent that
|
|
41
|
+
published nothing, reached by its peer ID.
|
|
42
|
+
|
|
43
|
+
python call.py mcp://everything echo '{"message": "hi"}'
|
|
44
|
+
python call.py inference://ollama /v1/models
|
|
45
|
+
python call.py 12D3KooW... a2a://agent /card
|
|
46
|
+
|
|
47
|
+
AGENTMESH_CONTROL_PLANE_URL names the mesh. The first run enrolls with the file
|
|
48
|
+
AGENTMESH_BOOTSTRAP_TOKEN_PATH (a token the mesh operator gave you) or AGENTMESH_JWT_PATH
|
|
49
|
+
(a workload identity token your platform issues, such as a Kubernetes
|
|
50
|
+
projected service account token), and keeps the identity and credential in
|
|
51
|
+
AGENTMESH_STATE_DIR; later runs resume from there without it.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
import json
|
|
55
|
+
import os
|
|
56
|
+
import sys
|
|
57
|
+
|
|
58
|
+
import trio
|
|
59
|
+
from agent_mesh import AgentMesh
|
|
60
|
+
|
|
61
|
+
argv = sys.argv[1:]
|
|
62
|
+
# A first argument that is not a service target is the peer ID of an agent.
|
|
63
|
+
peer_id = None
|
|
64
|
+
if argv and "://" not in argv[0]:
|
|
65
|
+
peer_id, argv = argv[0], argv[1:]
|
|
66
|
+
service = argv[0] if len(argv) > 0 else ("a2a://agent" if peer_id else "mcp://everything")
|
|
67
|
+
tool_or_path = argv[1] if len(argv) > 1 else ("/card" if peer_id else "echo")
|
|
68
|
+
args = json.loads(argv[2]) if len(argv) > 2 else {"message": "hi"}
|
|
69
|
+
|
|
70
|
+
mesh = AgentMesh.enroll(
|
|
71
|
+
os.environ.get("AGENTMESH_CONTROL_PLANE_URL", "https://mesh.example.com"),
|
|
72
|
+
bootstrap_token_path=os.environ.get("AGENTMESH_BOOTSTRAP_TOKEN_PATH"),
|
|
73
|
+
jwt_path=os.environ.get("AGENTMESH_JWT_PATH"),
|
|
74
|
+
state_dir=os.environ.get("AGENTMESH_STATE_DIR", "~/.config/agentmesh/caller"),
|
|
75
|
+
# A plaintext http:// control plane is otherwise accepted only on loopback.
|
|
76
|
+
allow_insecure=os.environ.get("AGENTMESH_INSECURE_CONTROL_PLANE") == "true",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
async def main() -> None:
|
|
81
|
+
async with mesh.join() as session:
|
|
82
|
+
print(f"on the mesh as {session.peer_id}")
|
|
83
|
+
|
|
84
|
+
if peer_id:
|
|
85
|
+
# An agent is not in the discovery table; the SDK finds the path
|
|
86
|
+
# to its peer ID through the routers.
|
|
87
|
+
await session.connect(peer_id)
|
|
88
|
+
provider = peer_id
|
|
89
|
+
else:
|
|
90
|
+
providers = await session.discover(service)
|
|
91
|
+
if not providers:
|
|
92
|
+
raise SystemExit(f"no member of the mesh serves {service}")
|
|
93
|
+
# A provider record can outlive its member; the first that answers is used.
|
|
94
|
+
for provider in providers:
|
|
95
|
+
try:
|
|
96
|
+
await session.connect(provider)
|
|
97
|
+
break
|
|
98
|
+
except (ConnectionError, PermissionError) as err:
|
|
99
|
+
print(f"{provider.peer_id}: {err}", file=sys.stderr)
|
|
100
|
+
else:
|
|
101
|
+
raise SystemExit(f"no provider of {service} is reachable")
|
|
102
|
+
print(f"{service} is served by {peer_id or provider.peer_id}")
|
|
103
|
+
|
|
104
|
+
if service.startswith("mcp://"):
|
|
105
|
+
tools = await session.list_tools(provider, service)
|
|
106
|
+
print("tools:", ", ".join(t.name for t in tools))
|
|
107
|
+
result = await session.call_tool(provider, service, tool_or_path, args)
|
|
108
|
+
print("\n".join(result.text))
|
|
109
|
+
else:
|
|
110
|
+
response = await session.request(provider, service, tool_or_path)
|
|
111
|
+
print(response.status, response.text)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
trio.run(main)
|
|
115
|
+
```
|
|
116
|
+
<!-- /embed -->
|
|
117
|
+
|
|
118
|
+
Be an agent others can call. Nothing is published; a caller reaches the
|
|
119
|
+
agent by its peer ID, the one it prints, through a router:
|
|
120
|
+
|
|
121
|
+
<!-- embed: sdk/python/examples/agent.py -->
|
|
122
|
+
```python
|
|
123
|
+
"""An agent on the mesh: joins, then answers A2A requests from other members
|
|
124
|
+
until stopped. It publishes nothing. There is no service name to look up; a
|
|
125
|
+
caller reaches the agent by its peer ID, through a router, as `a2a://agent`.
|
|
126
|
+
The mesh policy decides which members may call; the SDK turns the others away
|
|
127
|
+
before anything reaches this code.
|
|
128
|
+
|
|
129
|
+
python agent.py # answered by the handler below
|
|
130
|
+
python agent.py http://127.0.0.1:9999 # forwarded to an A2A server beside it
|
|
131
|
+
|
|
132
|
+
AGENTMESH_CONTROL_PLANE_URL names the mesh. The first run enrolls with the file
|
|
133
|
+
AGENTMESH_BOOTSTRAP_TOKEN_PATH (a token the mesh operator gave you) or AGENTMESH_JWT_PATH
|
|
134
|
+
(a workload identity token your platform issues, such as a Kubernetes
|
|
135
|
+
projected service account token), and keeps the identity and credential in
|
|
136
|
+
AGENTMESH_STATE_DIR; later runs resume from there, as the same peer, without it.
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
import json
|
|
140
|
+
import os
|
|
141
|
+
import sys
|
|
142
|
+
|
|
143
|
+
import trio
|
|
144
|
+
from agent_mesh import AgentMesh, HTTPRequest, HTTPResponse, VerifiedBiscuit
|
|
145
|
+
|
|
146
|
+
backend_url = sys.argv[1] if len(sys.argv) > 1 else None
|
|
147
|
+
|
|
148
|
+
mesh = AgentMesh.enroll(
|
|
149
|
+
os.environ.get("AGENTMESH_CONTROL_PLANE_URL", "https://mesh.example.com"),
|
|
150
|
+
bootstrap_token_path=os.environ.get("AGENTMESH_BOOTSTRAP_TOKEN_PATH"),
|
|
151
|
+
jwt_path=os.environ.get("AGENTMESH_JWT_PATH"),
|
|
152
|
+
state_dir=os.environ.get("AGENTMESH_STATE_DIR", "~/.config/agentmesh/agent"),
|
|
153
|
+
# A plaintext http:// control plane is otherwise accepted only on loopback.
|
|
154
|
+
allow_insecure=os.environ.get("AGENTMESH_INSECURE_CONTROL_PLANE") == "true",
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
async def card(request: HTTPRequest, caller: VerifiedBiscuit) -> HTTPResponse:
|
|
159
|
+
"""Answers every path with who was asked and who asked; a real agent runs
|
|
160
|
+
an A2A server here, or beside this process at backend_url."""
|
|
161
|
+
body = json.dumps({"name": "agent", "path": request.path, "caller": caller.peer_id})
|
|
162
|
+
return HTTPResponse(status=200, headers={"content-type": "application/json"}, body=body.encode())
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
async def main() -> None:
|
|
166
|
+
async with mesh.join() as session:
|
|
167
|
+
target = await session.accept_a2a(backend_url or card)
|
|
168
|
+
print(f"accepting {target} as {session.peer_id}", flush=True)
|
|
169
|
+
await trio.sleep_forever()
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
trio.run(main)
|
|
173
|
+
```
|
|
174
|
+
<!-- /embed -->
|
|
175
|
+
|
|
176
|
+
`enroll` reuses the identity and credential saved in `state_dir` when they
|
|
177
|
+
are still valid for that control plane, and needs exactly one of
|
|
178
|
+
`bootstrap_token_path`, `bootstrap_token`, `jwt_path` or `jwt` (a token string
|
|
179
|
+
or a `Callable[[], str]` invoked at enrollment and on every refresh)
|
|
180
|
+
otherwise. Read tokens from a file, a callback or the environment; do not put
|
|
181
|
+
them on a command line. `labels` are attested at enrollment;
|
|
182
|
+
`join(egress_require_labels=)` is the floor every peer the session calls must
|
|
183
|
+
attest, all of it, held for the session.
|
|
184
|
+
|
|
185
|
+
A plaintext `http://` control plane is accepted only on loopback. Pass
|
|
186
|
+
`allow_insecure=True` for a network you trust.
|
|
187
|
+
|
|
188
|
+
## License
|
|
189
|
+
|
|
190
|
+
Apache-2.0. Issues and contributions at
|
|
191
|
+
[github.com/google/agentmesh](https://github.com/google/agentmesh).
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""An agent written with the A2A SDK, on the mesh. The A2A SDK's server runs
|
|
2
|
+
as it always does, a Starlette app under uvicorn on a loopback port; the mesh
|
|
3
|
+
SDK accepts requests for a2a://agent and forwards them to it. Other members
|
|
4
|
+
reach the agent by peer ID through a router; the mesh policy decides which
|
|
5
|
+
ones, and the SDK turns the rest away before a request reaches the app.
|
|
6
|
+
|
|
7
|
+
python a2a_agent.py
|
|
8
|
+
|
|
9
|
+
AGENTMESH_CONTROL_PLANE_URL names the mesh. The first run enrolls with the file
|
|
10
|
+
AGENTMESH_BOOTSTRAP_TOKEN_PATH (a token the mesh operator gave you) or AGENTMESH_JWT_PATH
|
|
11
|
+
(a workload identity token your platform issues, such as a Kubernetes
|
|
12
|
+
projected service account token), and keeps the identity and credential in
|
|
13
|
+
AGENTMESH_STATE_DIR; later runs resume from there, as the same peer, without it.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import socket
|
|
18
|
+
import threading
|
|
19
|
+
|
|
20
|
+
import trio
|
|
21
|
+
import uvicorn
|
|
22
|
+
from a2a.helpers import get_message_text, new_text_message
|
|
23
|
+
from a2a.server.agent_execution import AgentExecutor, RequestContext
|
|
24
|
+
from a2a.server.events import EventQueue
|
|
25
|
+
from a2a.server.request_handlers import DefaultRequestHandler
|
|
26
|
+
from a2a.server.routes import create_agent_card_routes, create_jsonrpc_routes
|
|
27
|
+
from a2a.server.tasks import InMemoryTaskStore
|
|
28
|
+
from a2a.types import AgentCapabilities, AgentCard, AgentInterface, AgentSkill, Role
|
|
29
|
+
from agent_mesh import AgentMesh, MeshSession
|
|
30
|
+
from starlette.applications import Starlette
|
|
31
|
+
|
|
32
|
+
mesh = AgentMesh.enroll(
|
|
33
|
+
os.environ.get("AGENTMESH_CONTROL_PLANE_URL", "https://mesh.example.com"),
|
|
34
|
+
bootstrap_token_path=os.environ.get("AGENTMESH_BOOTSTRAP_TOKEN_PATH"),
|
|
35
|
+
jwt_path=os.environ.get("AGENTMESH_JWT_PATH"),
|
|
36
|
+
state_dir=os.environ.get("AGENTMESH_STATE_DIR", "~/.config/agentmesh/a2a-agent"),
|
|
37
|
+
# A plaintext http:// control plane is otherwise accepted only on loopback.
|
|
38
|
+
allow_insecure=os.environ.get("AGENTMESH_INSECURE_CONTROL_PLANE") == "true",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class EchoExecutor(AgentExecutor):
|
|
43
|
+
"""One message in, one message out. X-Peer-Id is the caller the mesh
|
|
44
|
+
verified; the SDK sets it after authorizing the request."""
|
|
45
|
+
|
|
46
|
+
async def execute(self, context: RequestContext, event_queue: EventQueue) -> None:
|
|
47
|
+
caller = context.call_context.state["headers"].get("x-peer-id", "someone") if context.call_context else "someone"
|
|
48
|
+
said = get_message_text(context.message) if context.message else ""
|
|
49
|
+
await event_queue.enqueue_event(new_text_message(f"{caller} said: {said}", role=Role.ROLE_AGENT, context_id=context.context_id))
|
|
50
|
+
|
|
51
|
+
async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None:
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def a2a_app(agent_url: str) -> Starlette:
|
|
56
|
+
"""The A2A SDK's server, as its samples build it. The card names the agent
|
|
57
|
+
as the mesh reaches it: http://mesh/mesh/<peer-id>/a2a/agent."""
|
|
58
|
+
card = AgentCard(
|
|
59
|
+
name="Echo agent",
|
|
60
|
+
description="Answers every message with what it said and who sent it.",
|
|
61
|
+
version="1.0.0",
|
|
62
|
+
default_input_modes=["text/plain"],
|
|
63
|
+
default_output_modes=["text/plain"],
|
|
64
|
+
capabilities=AgentCapabilities(streaming=True),
|
|
65
|
+
supported_interfaces=[AgentInterface(protocol_binding="JSONRPC", url=agent_url, protocol_version="1.0")],
|
|
66
|
+
skills=[AgentSkill(id="echo", name="Echo", description="Repeats the message.", tags=["echo"], examples=["hello"])],
|
|
67
|
+
)
|
|
68
|
+
handler = DefaultRequestHandler(agent_executor=EchoExecutor(), task_store=InMemoryTaskStore(), agent_card=card)
|
|
69
|
+
return Starlette(routes=[*create_agent_card_routes(card), *create_jsonrpc_routes(handler, "/")])
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def serve_locally(app: Starlette) -> str:
|
|
73
|
+
"""Runs uvicorn on a free loopback port in a thread and returns its URL.
|
|
74
|
+
The A2A SDK runs on asyncio, the mesh SDK on trio; a thread keeps them apart."""
|
|
75
|
+
with socket.socket() as probe:
|
|
76
|
+
probe.bind(("127.0.0.1", 0))
|
|
77
|
+
port = probe.getsockname()[1]
|
|
78
|
+
server = uvicorn.Server(uvicorn.Config(app, host="127.0.0.1", port=port, log_level="warning"))
|
|
79
|
+
threading.Thread(target=server.run, daemon=True).start()
|
|
80
|
+
return f"http://127.0.0.1:{port}"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
async def main() -> None:
|
|
84
|
+
backend_url = serve_locally(a2a_app(MeshSession.mesh_url(mesh.identity.peer_id, "a2a://agent")))
|
|
85
|
+
async with mesh.join() as session:
|
|
86
|
+
target = await session.accept_a2a(backend_url)
|
|
87
|
+
print(f"accepting {target} as {session.peer_id}", flush=True)
|
|
88
|
+
print(f"agent card at {session.agent_url}/.well-known/agent-card.json", flush=True)
|
|
89
|
+
await trio.sleep_forever()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
trio.run(main)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Talks to an agent on the mesh with the A2A SDK's client. The mesh SDK is an
|
|
2
|
+
httpx transport; the A2A client fetches the agent card and sends messages as
|
|
3
|
+
it would to any A2A server, and every request travels to the peer through a
|
|
4
|
+
router with this member's credential.
|
|
5
|
+
|
|
6
|
+
python a2a_call.py 12D3KooW... "hello"
|
|
7
|
+
|
|
8
|
+
AGENTMESH_CONTROL_PLANE_URL names the mesh. The first run enrolls with the file
|
|
9
|
+
AGENTMESH_BOOTSTRAP_TOKEN_PATH (a token the mesh operator gave you) or AGENTMESH_JWT_PATH
|
|
10
|
+
(a workload identity token your platform issues, such as a Kubernetes
|
|
11
|
+
projected service account token), and keeps the identity and credential in
|
|
12
|
+
AGENTMESH_STATE_DIR; later runs resume from there without it.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
import trio
|
|
20
|
+
from a2a.client import A2ACardResolver, ClientConfig, create_client
|
|
21
|
+
from a2a.helpers import get_message_text, new_text_message
|
|
22
|
+
from a2a.types import Role, SendMessageRequest
|
|
23
|
+
from agent_mesh import AgentMesh, MeshSession, MeshTransport
|
|
24
|
+
|
|
25
|
+
if len(sys.argv) < 2:
|
|
26
|
+
raise SystemExit("usage: a2a_call.py <peer-id> [text]")
|
|
27
|
+
peer_id = sys.argv[1]
|
|
28
|
+
text = sys.argv[2] if len(sys.argv) > 2 else "hello"
|
|
29
|
+
|
|
30
|
+
mesh = AgentMesh.enroll(
|
|
31
|
+
os.environ.get("AGENTMESH_CONTROL_PLANE_URL", "https://mesh.example.com"),
|
|
32
|
+
bootstrap_token_path=os.environ.get("AGENTMESH_BOOTSTRAP_TOKEN_PATH"),
|
|
33
|
+
jwt_path=os.environ.get("AGENTMESH_JWT_PATH"),
|
|
34
|
+
state_dir=os.environ.get("AGENTMESH_STATE_DIR", "~/.config/agentmesh/a2a-caller"),
|
|
35
|
+
# A plaintext http:// control plane is otherwise accepted only on loopback.
|
|
36
|
+
allow_insecure=os.environ.get("AGENTMESH_INSECURE_CONTROL_PLANE") == "true",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def main() -> None:
|
|
41
|
+
async with mesh.join() as session:
|
|
42
|
+
print(f"on the mesh as {session.peer_id}")
|
|
43
|
+
|
|
44
|
+
# The agent's URL on the mesh, and an httpx client that carries requests to it.
|
|
45
|
+
agent_url = MeshSession.mesh_url(peer_id, "a2a://agent")
|
|
46
|
+
async with httpx.AsyncClient(transport=MeshTransport(session), timeout=60) as http:
|
|
47
|
+
card = await A2ACardResolver(http, agent_url).get_agent_card()
|
|
48
|
+
print(f"agent: {card.name}, {card.description}")
|
|
49
|
+
|
|
50
|
+
client = await create_client(card, ClientConfig(httpx_client=http, streaming=False))
|
|
51
|
+
request = SendMessageRequest(message=new_text_message(text, role=Role.ROLE_USER))
|
|
52
|
+
async for response in client.send_message(request):
|
|
53
|
+
if response.HasField("message"):
|
|
54
|
+
print(get_message_text(response.message))
|
|
55
|
+
elif response.HasField("task") and response.task.status.HasField("message"):
|
|
56
|
+
print(get_message_text(response.task.status.message))
|
|
57
|
+
await client.close()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
trio.run(main)
|