a2a-lite 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- a2a_lite/__init__.py +151 -0
- a2a_lite/agent.py +453 -0
- a2a_lite/auth.py +344 -0
- a2a_lite/cli.py +336 -0
- a2a_lite/decorators.py +32 -0
- a2a_lite/discovery.py +148 -0
- a2a_lite/executor.py +317 -0
- a2a_lite/human_loop.py +284 -0
- a2a_lite/middleware.py +193 -0
- a2a_lite/parts.py +218 -0
- a2a_lite/streaming.py +89 -0
- a2a_lite/tasks.py +221 -0
- a2a_lite/testing.py +268 -0
- a2a_lite/utils.py +117 -0
- a2a_lite/webhooks.py +232 -0
- a2a_lite-0.1.0.dist-info/METADATA +383 -0
- a2a_lite-0.1.0.dist-info/RECORD +19 -0
- a2a_lite-0.1.0.dist-info/WHEEL +4 -0
- a2a_lite-0.1.0.dist-info/entry_points.txt +2 -0
a2a_lite/auth.py
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Authentication support (OPTIONAL).
|
|
3
|
+
|
|
4
|
+
By default, A2A Lite agents have no authentication.
|
|
5
|
+
Add auth when you need it for enterprise deployments.
|
|
6
|
+
|
|
7
|
+
Example (simple - no auth):
|
|
8
|
+
agent = Agent(name="Bot", description="Open bot")
|
|
9
|
+
agent.run() # Anyone can access
|
|
10
|
+
|
|
11
|
+
Example (with API key - opt-in):
|
|
12
|
+
from a2a_lite.auth import APIKeyAuth
|
|
13
|
+
|
|
14
|
+
agent = Agent(
|
|
15
|
+
name="SecureBot",
|
|
16
|
+
auth=APIKeyAuth(keys=["secret-key-123"]),
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
Example (with OAuth2 - opt-in):
|
|
20
|
+
from a2a_lite.auth import OAuth2Auth
|
|
21
|
+
|
|
22
|
+
agent = Agent(
|
|
23
|
+
name="EnterpriseBot",
|
|
24
|
+
auth=OAuth2Auth(
|
|
25
|
+
issuer="https://auth.company.com",
|
|
26
|
+
audience="my-agent",
|
|
27
|
+
),
|
|
28
|
+
)
|
|
29
|
+
"""
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
from abc import ABC, abstractmethod
|
|
33
|
+
from dataclasses import dataclass, field
|
|
34
|
+
from typing import Any, Callable, Dict, List, Optional, Set
|
|
35
|
+
import hashlib
|
|
36
|
+
import hmac
|
|
37
|
+
import time
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class AuthProvider(ABC):
|
|
41
|
+
"""Base class for authentication providers."""
|
|
42
|
+
|
|
43
|
+
@abstractmethod
|
|
44
|
+
async def authenticate(self, request: "AuthRequest") -> "AuthResult":
|
|
45
|
+
"""Authenticate a request."""
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
@abstractmethod
|
|
49
|
+
def get_scheme(self) -> Dict[str, Any]:
|
|
50
|
+
"""Get A2A security scheme for agent card."""
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class AuthRequest:
|
|
56
|
+
"""Incoming authentication request."""
|
|
57
|
+
headers: Dict[str, str]
|
|
58
|
+
query_params: Dict[str, str] = field(default_factory=dict)
|
|
59
|
+
body: Optional[bytes] = None
|
|
60
|
+
method: str = "POST"
|
|
61
|
+
path: str = "/"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class AuthResult:
|
|
66
|
+
"""Authentication result."""
|
|
67
|
+
authenticated: bool
|
|
68
|
+
user_id: Optional[str] = None
|
|
69
|
+
scopes: Set[str] = field(default_factory=set)
|
|
70
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
71
|
+
error: Optional[str] = None
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def success(
|
|
75
|
+
cls,
|
|
76
|
+
user_id: str,
|
|
77
|
+
scopes: Optional[Set[str]] = None,
|
|
78
|
+
**metadata,
|
|
79
|
+
) -> "AuthResult":
|
|
80
|
+
return cls(
|
|
81
|
+
authenticated=True,
|
|
82
|
+
user_id=user_id,
|
|
83
|
+
scopes=scopes or set(),
|
|
84
|
+
metadata=metadata,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
@classmethod
|
|
88
|
+
def failure(cls, error: str) -> "AuthResult":
|
|
89
|
+
return cls(authenticated=False, error=error)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class NoAuth(AuthProvider):
|
|
93
|
+
"""No authentication (default)."""
|
|
94
|
+
|
|
95
|
+
async def authenticate(self, request: AuthRequest) -> AuthResult:
|
|
96
|
+
return AuthResult.success(user_id="anonymous")
|
|
97
|
+
|
|
98
|
+
def get_scheme(self) -> Dict[str, Any]:
|
|
99
|
+
return {}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class APIKeyAuth(AuthProvider):
|
|
103
|
+
"""
|
|
104
|
+
Simple API key authentication.
|
|
105
|
+
|
|
106
|
+
Example:
|
|
107
|
+
auth = APIKeyAuth(
|
|
108
|
+
keys=["key1", "key2"],
|
|
109
|
+
header="X-API-Key", # or use query param
|
|
110
|
+
)
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
def __init__(
|
|
114
|
+
self,
|
|
115
|
+
keys: List[str],
|
|
116
|
+
header: str = "X-API-Key",
|
|
117
|
+
query_param: Optional[str] = None,
|
|
118
|
+
):
|
|
119
|
+
self.keys = set(keys)
|
|
120
|
+
self.header = header
|
|
121
|
+
self.query_param = query_param
|
|
122
|
+
|
|
123
|
+
async def authenticate(self, request: AuthRequest) -> AuthResult:
|
|
124
|
+
# Check header
|
|
125
|
+
key = request.headers.get(self.header)
|
|
126
|
+
|
|
127
|
+
# Check query param
|
|
128
|
+
if not key and self.query_param:
|
|
129
|
+
key = request.query_params.get(self.query_param)
|
|
130
|
+
|
|
131
|
+
if not key:
|
|
132
|
+
return AuthResult.failure("API key required")
|
|
133
|
+
|
|
134
|
+
if key not in self.keys:
|
|
135
|
+
return AuthResult.failure("Invalid API key")
|
|
136
|
+
|
|
137
|
+
# Use hash of key as user ID
|
|
138
|
+
user_id = hashlib.sha256(key.encode()).hexdigest()[:16]
|
|
139
|
+
return AuthResult.success(user_id=user_id)
|
|
140
|
+
|
|
141
|
+
def get_scheme(self) -> Dict[str, Any]:
|
|
142
|
+
return {
|
|
143
|
+
"type": "apiKey",
|
|
144
|
+
"in": "header" if not self.query_param else "query",
|
|
145
|
+
"name": self.header if not self.query_param else self.query_param,
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class BearerAuth(AuthProvider):
|
|
150
|
+
"""
|
|
151
|
+
Bearer token authentication.
|
|
152
|
+
|
|
153
|
+
For custom token validation (JWT, etc).
|
|
154
|
+
|
|
155
|
+
Example:
|
|
156
|
+
def validate_token(token: str) -> Optional[str]:
|
|
157
|
+
# Your validation logic
|
|
158
|
+
if is_valid(token):
|
|
159
|
+
return get_user_id(token)
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
auth = BearerAuth(validator=validate_token)
|
|
163
|
+
"""
|
|
164
|
+
|
|
165
|
+
def __init__(
|
|
166
|
+
self,
|
|
167
|
+
validator: Callable[[str], Optional[str]],
|
|
168
|
+
header: str = "Authorization",
|
|
169
|
+
):
|
|
170
|
+
self.validator = validator
|
|
171
|
+
self.header = header
|
|
172
|
+
|
|
173
|
+
async def authenticate(self, request: AuthRequest) -> AuthResult:
|
|
174
|
+
auth_header = request.headers.get(self.header, "")
|
|
175
|
+
|
|
176
|
+
if not auth_header.startswith("Bearer "):
|
|
177
|
+
return AuthResult.failure("Bearer token required")
|
|
178
|
+
|
|
179
|
+
token = auth_header[7:] # Remove "Bearer "
|
|
180
|
+
|
|
181
|
+
user_id = self.validator(token)
|
|
182
|
+
if user_id is None:
|
|
183
|
+
return AuthResult.failure("Invalid token")
|
|
184
|
+
|
|
185
|
+
return AuthResult.success(user_id=user_id)
|
|
186
|
+
|
|
187
|
+
def get_scheme(self) -> Dict[str, Any]:
|
|
188
|
+
return {
|
|
189
|
+
"type": "http",
|
|
190
|
+
"scheme": "bearer",
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class OAuth2Auth(AuthProvider):
|
|
195
|
+
"""
|
|
196
|
+
OAuth2/OIDC authentication.
|
|
197
|
+
|
|
198
|
+
Validates JWT tokens from an OAuth2 provider.
|
|
199
|
+
|
|
200
|
+
Example:
|
|
201
|
+
auth = OAuth2Auth(
|
|
202
|
+
issuer="https://auth.company.com",
|
|
203
|
+
audience="my-agent",
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
Requires: pip install pyjwt[crypto]
|
|
207
|
+
"""
|
|
208
|
+
|
|
209
|
+
def __init__(
|
|
210
|
+
self,
|
|
211
|
+
issuer: str,
|
|
212
|
+
audience: str,
|
|
213
|
+
jwks_uri: Optional[str] = None,
|
|
214
|
+
algorithms: List[str] = None,
|
|
215
|
+
):
|
|
216
|
+
self.issuer = issuer
|
|
217
|
+
self.audience = audience
|
|
218
|
+
self.jwks_uri = jwks_uri or f"{issuer}/.well-known/jwks.json"
|
|
219
|
+
self.algorithms = algorithms or ["RS256"]
|
|
220
|
+
self._jwks_client = None
|
|
221
|
+
|
|
222
|
+
async def authenticate(self, request: AuthRequest) -> AuthResult:
|
|
223
|
+
auth_header = request.headers.get("Authorization", "")
|
|
224
|
+
|
|
225
|
+
if not auth_header.startswith("Bearer "):
|
|
226
|
+
return AuthResult.failure("Bearer token required")
|
|
227
|
+
|
|
228
|
+
token = auth_header[7:]
|
|
229
|
+
|
|
230
|
+
try:
|
|
231
|
+
import jwt
|
|
232
|
+
from jwt import PyJWKClient
|
|
233
|
+
|
|
234
|
+
# Get JWKS client (cached)
|
|
235
|
+
if self._jwks_client is None:
|
|
236
|
+
self._jwks_client = PyJWKClient(self.jwks_uri)
|
|
237
|
+
|
|
238
|
+
# Get signing key
|
|
239
|
+
signing_key = self._jwks_client.get_signing_key_from_jwt(token)
|
|
240
|
+
|
|
241
|
+
# Decode and validate
|
|
242
|
+
payload = jwt.decode(
|
|
243
|
+
token,
|
|
244
|
+
signing_key.key,
|
|
245
|
+
algorithms=self.algorithms,
|
|
246
|
+
audience=self.audience,
|
|
247
|
+
issuer=self.issuer,
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
user_id = payload.get("sub", payload.get("email", "unknown"))
|
|
251
|
+
scopes = set(payload.get("scope", "").split())
|
|
252
|
+
|
|
253
|
+
return AuthResult.success(
|
|
254
|
+
user_id=user_id,
|
|
255
|
+
scopes=scopes,
|
|
256
|
+
claims=payload,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
except ImportError:
|
|
260
|
+
return AuthResult.failure(
|
|
261
|
+
"OAuth2 requires pyjwt: pip install pyjwt[crypto]"
|
|
262
|
+
)
|
|
263
|
+
except Exception as e:
|
|
264
|
+
return AuthResult.failure(f"Token validation failed: {str(e)}")
|
|
265
|
+
|
|
266
|
+
def get_scheme(self) -> Dict[str, Any]:
|
|
267
|
+
return {
|
|
268
|
+
"type": "oauth2",
|
|
269
|
+
"flows": {
|
|
270
|
+
"authorizationCode": {
|
|
271
|
+
"authorizationUrl": f"{self.issuer}/authorize",
|
|
272
|
+
"tokenUrl": f"{self.issuer}/oauth/token",
|
|
273
|
+
"scopes": {},
|
|
274
|
+
}
|
|
275
|
+
},
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
class CompositeAuth(AuthProvider):
|
|
280
|
+
"""
|
|
281
|
+
Try multiple auth providers (first success wins).
|
|
282
|
+
|
|
283
|
+
Example:
|
|
284
|
+
auth = CompositeAuth([
|
|
285
|
+
APIKeyAuth(keys=["admin-key"]),
|
|
286
|
+
OAuth2Auth(issuer="..."),
|
|
287
|
+
])
|
|
288
|
+
"""
|
|
289
|
+
|
|
290
|
+
def __init__(self, providers: List[AuthProvider]):
|
|
291
|
+
self.providers = providers
|
|
292
|
+
|
|
293
|
+
async def authenticate(self, request: AuthRequest) -> AuthResult:
|
|
294
|
+
errors = []
|
|
295
|
+
|
|
296
|
+
for provider in self.providers:
|
|
297
|
+
result = await provider.authenticate(request)
|
|
298
|
+
if result.authenticated:
|
|
299
|
+
return result
|
|
300
|
+
if result.error:
|
|
301
|
+
errors.append(result.error)
|
|
302
|
+
|
|
303
|
+
return AuthResult.failure("; ".join(errors) or "Authentication failed")
|
|
304
|
+
|
|
305
|
+
def get_scheme(self) -> Dict[str, Any]:
|
|
306
|
+
# Return first provider's scheme
|
|
307
|
+
if self.providers:
|
|
308
|
+
return self.providers[0].get_scheme()
|
|
309
|
+
return {}
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
# Auth middleware helper
|
|
313
|
+
def require_auth(scopes: Optional[List[str]] = None):
|
|
314
|
+
"""
|
|
315
|
+
Decorator to require authentication for a skill.
|
|
316
|
+
|
|
317
|
+
Example:
|
|
318
|
+
@agent.skill("admin_action")
|
|
319
|
+
@require_auth(scopes=["admin"])
|
|
320
|
+
async def admin_action(data: str, auth: AuthResult) -> str:
|
|
321
|
+
return f"Admin {auth.user_id} did something"
|
|
322
|
+
"""
|
|
323
|
+
required_scopes = set(scopes or [])
|
|
324
|
+
|
|
325
|
+
def decorator(func: Callable) -> Callable:
|
|
326
|
+
async def wrapper(*args, auth: AuthResult = None, **kwargs):
|
|
327
|
+
if auth is None or not auth.authenticated:
|
|
328
|
+
return {"error": "Authentication required"}
|
|
329
|
+
|
|
330
|
+
if required_scopes and not required_scopes.issubset(auth.scopes):
|
|
331
|
+
return {
|
|
332
|
+
"error": "Insufficient permissions",
|
|
333
|
+
"required": list(required_scopes),
|
|
334
|
+
"provided": list(auth.scopes),
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return await func(*args, auth=auth, **kwargs)
|
|
338
|
+
|
|
339
|
+
wrapper.__wrapped__ = func
|
|
340
|
+
wrapper.__requires_auth__ = True
|
|
341
|
+
wrapper.__required_scopes__ = required_scopes
|
|
342
|
+
return wrapper
|
|
343
|
+
|
|
344
|
+
return decorator
|
a2a_lite/cli.py
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Command-line interface for A2A Lite.
|
|
3
|
+
"""
|
|
4
|
+
import asyncio
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional, List
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.panel import Panel
|
|
12
|
+
from rich.table import Table
|
|
13
|
+
|
|
14
|
+
app = typer.Typer(
|
|
15
|
+
name="a2a-lite",
|
|
16
|
+
help="A2A Lite - Simplified Agent-to-Agent Protocol SDK",
|
|
17
|
+
add_completion=False,
|
|
18
|
+
)
|
|
19
|
+
console = Console()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@app.command()
|
|
23
|
+
def init(
|
|
24
|
+
name: str = typer.Argument(..., help="Project name"),
|
|
25
|
+
path: Optional[Path] = typer.Option(None, help="Directory to create project in"),
|
|
26
|
+
):
|
|
27
|
+
"""
|
|
28
|
+
Initialize a new A2A Lite agent project.
|
|
29
|
+
|
|
30
|
+
Creates a new directory with a basic agent template.
|
|
31
|
+
"""
|
|
32
|
+
project_path = path or Path(name)
|
|
33
|
+
project_path.mkdir(exist_ok=True)
|
|
34
|
+
|
|
35
|
+
# Create agent.py
|
|
36
|
+
agent_template = '''"""
|
|
37
|
+
{name} - A2A Lite Agent
|
|
38
|
+
|
|
39
|
+
Run with: python agent.py
|
|
40
|
+
"""
|
|
41
|
+
from a2a_lite import Agent
|
|
42
|
+
|
|
43
|
+
agent = Agent(
|
|
44
|
+
name="{name}",
|
|
45
|
+
description="A simple A2A Lite agent",
|
|
46
|
+
version="1.0.0",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@agent.skill("hello", description="Say hello to someone")
|
|
51
|
+
async def hello(name: str = "World") -> str:
|
|
52
|
+
"""Greets the provided name."""
|
|
53
|
+
return f"Hello, {{name}}!"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@agent.skill("echo", description="Echo back the input")
|
|
57
|
+
async def echo(message: str) -> dict:
|
|
58
|
+
"""Echoes the input message."""
|
|
59
|
+
return {{"received": message, "echoed": True}}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
if __name__ == "__main__":
|
|
63
|
+
agent.run(port=8787)
|
|
64
|
+
'''
|
|
65
|
+
|
|
66
|
+
(project_path / "agent.py").write_text(
|
|
67
|
+
agent_template.format(name=name)
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# Create pyproject.toml
|
|
71
|
+
safe_name = name.lower().replace(" ", "-").replace("_", "-")
|
|
72
|
+
pyproject = f'''[project]
|
|
73
|
+
name = "{safe_name}"
|
|
74
|
+
version = "0.1.0"
|
|
75
|
+
description = "A2A Agent: {name}"
|
|
76
|
+
requires-python = ">=3.10"
|
|
77
|
+
dependencies = [
|
|
78
|
+
"a2a-lite>=0.1.0",
|
|
79
|
+
]
|
|
80
|
+
'''
|
|
81
|
+
(project_path / "pyproject.toml").write_text(pyproject)
|
|
82
|
+
|
|
83
|
+
# Create README
|
|
84
|
+
readme = f'''# {name}
|
|
85
|
+
|
|
86
|
+
An A2A Lite agent.
|
|
87
|
+
|
|
88
|
+
## Running
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
cd {project_path}
|
|
92
|
+
uv run agent.py
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Testing
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
a2a-lite test http://localhost:8787 hello -p name=World
|
|
99
|
+
```
|
|
100
|
+
'''
|
|
101
|
+
(project_path / "README.md").write_text(readme)
|
|
102
|
+
|
|
103
|
+
console.print(Panel(
|
|
104
|
+
f"[green]✅ Created project: {name}[/]\n\n"
|
|
105
|
+
f"[dim]Files created:[/]\n"
|
|
106
|
+
f" • {project_path}/agent.py\n"
|
|
107
|
+
f" • {project_path}/pyproject.toml\n"
|
|
108
|
+
f" • {project_path}/README.md\n\n"
|
|
109
|
+
f"[bold]Next steps:[/]\n"
|
|
110
|
+
f" cd {project_path}\n"
|
|
111
|
+
f" uv run agent.py",
|
|
112
|
+
title="🚀 A2A Lite Project Created",
|
|
113
|
+
border_style="green",
|
|
114
|
+
))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@app.command()
|
|
118
|
+
def inspect(
|
|
119
|
+
url: str = typer.Argument(..., help="Agent URL (e.g., http://localhost:8787)"),
|
|
120
|
+
):
|
|
121
|
+
"""
|
|
122
|
+
Inspect an A2A agent's capabilities.
|
|
123
|
+
|
|
124
|
+
Fetches and displays the agent card.
|
|
125
|
+
"""
|
|
126
|
+
import httpx
|
|
127
|
+
|
|
128
|
+
async def _inspect():
|
|
129
|
+
async with httpx.AsyncClient() as client:
|
|
130
|
+
# Fetch agent card
|
|
131
|
+
card_url = f"{url.rstrip('/')}/.well-known/agent.json"
|
|
132
|
+
response = await client.get(card_url, timeout=10.0)
|
|
133
|
+
response.raise_for_status()
|
|
134
|
+
card = response.json()
|
|
135
|
+
|
|
136
|
+
# Display
|
|
137
|
+
table = Table(title=f"📋 {card.get('name', 'Unknown')} v{card.get('version', '?')}")
|
|
138
|
+
table.add_column("Skill", style="cyan")
|
|
139
|
+
table.add_column("Description", style="dim")
|
|
140
|
+
table.add_column("Tags", style="green")
|
|
141
|
+
|
|
142
|
+
for skill in card.get('skills', []):
|
|
143
|
+
table.add_row(
|
|
144
|
+
skill.get('name', skill.get('id', '?')),
|
|
145
|
+
skill.get('description', '-'),
|
|
146
|
+
", ".join(skill.get('tags', [])) or "-",
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
console.print(f"\n[dim]URL: {card.get('url', url)}[/]")
|
|
150
|
+
console.print(f"[dim]Description: {card.get('description', '-')}[/]\n")
|
|
151
|
+
console.print(table)
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
asyncio.run(_inspect())
|
|
155
|
+
except httpx.HTTPError as e:
|
|
156
|
+
console.print(f"[red]Error: Could not connect to {url}[/]")
|
|
157
|
+
console.print(f"[dim]{e}[/]")
|
|
158
|
+
raise typer.Exit(1)
|
|
159
|
+
except Exception as e:
|
|
160
|
+
console.print(f"[red]Error: {e}[/]")
|
|
161
|
+
raise typer.Exit(1)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@app.command()
|
|
165
|
+
def test(
|
|
166
|
+
url: str = typer.Argument(..., help="Agent URL"),
|
|
167
|
+
skill: str = typer.Argument(..., help="Skill name to invoke"),
|
|
168
|
+
params: Optional[List[str]] = typer.Option(
|
|
169
|
+
None, "--param", "-p",
|
|
170
|
+
help="Parameters as key=value pairs"
|
|
171
|
+
),
|
|
172
|
+
):
|
|
173
|
+
"""
|
|
174
|
+
Test an agent skill.
|
|
175
|
+
|
|
176
|
+
Example: a2a-lite test http://localhost:8787 hello -p name=World
|
|
177
|
+
"""
|
|
178
|
+
import httpx
|
|
179
|
+
from uuid import uuid4
|
|
180
|
+
|
|
181
|
+
# Parse parameters
|
|
182
|
+
param_dict = {}
|
|
183
|
+
for p in (params or []):
|
|
184
|
+
if "=" in p:
|
|
185
|
+
key, value = p.split("=", 1)
|
|
186
|
+
# Try to parse as JSON for complex types
|
|
187
|
+
try:
|
|
188
|
+
param_dict[key] = json.loads(value)
|
|
189
|
+
except json.JSONDecodeError:
|
|
190
|
+
param_dict[key] = value
|
|
191
|
+
|
|
192
|
+
async def _test():
|
|
193
|
+
async with httpx.AsyncClient() as client:
|
|
194
|
+
# Build request
|
|
195
|
+
message = json.dumps({
|
|
196
|
+
"skill": skill,
|
|
197
|
+
"params": param_dict,
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
request_body = {
|
|
201
|
+
"jsonrpc": "2.0",
|
|
202
|
+
"method": "message/send",
|
|
203
|
+
"id": uuid4().hex,
|
|
204
|
+
"params": {
|
|
205
|
+
"message": {
|
|
206
|
+
"role": "user",
|
|
207
|
+
"parts": [{"type": "text", "text": message}],
|
|
208
|
+
"messageId": uuid4().hex,
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
response = await client.post(
|
|
214
|
+
url,
|
|
215
|
+
json=request_body,
|
|
216
|
+
timeout=30.0,
|
|
217
|
+
)
|
|
218
|
+
response.raise_for_status()
|
|
219
|
+
result = response.json()
|
|
220
|
+
|
|
221
|
+
# Extract and display result
|
|
222
|
+
console.print("\n[bold green]Response:[/]")
|
|
223
|
+
console.print_json(json.dumps(result, indent=2))
|
|
224
|
+
|
|
225
|
+
try:
|
|
226
|
+
asyncio.run(_test())
|
|
227
|
+
except httpx.HTTPError as e:
|
|
228
|
+
console.print(f"[red]Error: {e}[/]")
|
|
229
|
+
raise typer.Exit(1)
|
|
230
|
+
except Exception as e:
|
|
231
|
+
console.print(f"[red]Error: {e}[/]")
|
|
232
|
+
raise typer.Exit(1)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@app.command()
|
|
236
|
+
def discover(
|
|
237
|
+
timeout: float = typer.Option(5.0, help="Discovery timeout in seconds"),
|
|
238
|
+
):
|
|
239
|
+
"""
|
|
240
|
+
Discover A2A agents on the local network.
|
|
241
|
+
|
|
242
|
+
Uses mDNS to find agents advertising themselves.
|
|
243
|
+
"""
|
|
244
|
+
from .discovery import AgentDiscovery
|
|
245
|
+
|
|
246
|
+
async def _discover():
|
|
247
|
+
console.print("[dim]Scanning local network...[/]\n")
|
|
248
|
+
|
|
249
|
+
discovery = AgentDiscovery()
|
|
250
|
+
agents = await discovery.discover(timeout=timeout)
|
|
251
|
+
|
|
252
|
+
if not agents:
|
|
253
|
+
console.print("[yellow]No agents found.[/]")
|
|
254
|
+
console.print("[dim]Make sure agents are running with discovery enabled (enable_discovery=True).[/]")
|
|
255
|
+
return
|
|
256
|
+
|
|
257
|
+
table = Table(title=f"🔍 Found {len(agents)} Agent(s)")
|
|
258
|
+
table.add_column("Name", style="cyan")
|
|
259
|
+
table.add_column("URL", style="blue")
|
|
260
|
+
table.add_column("Properties", style="dim")
|
|
261
|
+
|
|
262
|
+
for agent in agents:
|
|
263
|
+
table.add_row(
|
|
264
|
+
agent.name,
|
|
265
|
+
agent.url,
|
|
266
|
+
json.dumps(agent.properties) if agent.properties else "-",
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
console.print(table)
|
|
270
|
+
|
|
271
|
+
asyncio.run(_discover())
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
@app.command()
|
|
275
|
+
def serve(
|
|
276
|
+
file: Path = typer.Argument(..., help="Python file containing the agent"),
|
|
277
|
+
port: int = typer.Option(8787, help="Port to run on"),
|
|
278
|
+
reload: bool = typer.Option(False, "--reload", "-r", help="Enable hot reload"),
|
|
279
|
+
discovery: bool = typer.Option(False, "--discovery", "-d", help="Enable mDNS discovery"),
|
|
280
|
+
):
|
|
281
|
+
"""
|
|
282
|
+
Run an agent from a Python file.
|
|
283
|
+
|
|
284
|
+
The file should define an 'agent' variable of type Agent.
|
|
285
|
+
"""
|
|
286
|
+
import importlib.util
|
|
287
|
+
import sys
|
|
288
|
+
|
|
289
|
+
# Load the module
|
|
290
|
+
file = file.resolve()
|
|
291
|
+
spec = importlib.util.spec_from_file_location("agent_module", file)
|
|
292
|
+
if spec is None or spec.loader is None:
|
|
293
|
+
console.print(f"[red]Error: Could not load {file}[/]")
|
|
294
|
+
raise typer.Exit(1)
|
|
295
|
+
|
|
296
|
+
module = importlib.util.module_from_spec(spec)
|
|
297
|
+
sys.modules["agent_module"] = module
|
|
298
|
+
|
|
299
|
+
# Change to the file's directory for relative imports
|
|
300
|
+
original_cwd = Path.cwd()
|
|
301
|
+
try:
|
|
302
|
+
import os
|
|
303
|
+
os.chdir(file.parent)
|
|
304
|
+
spec.loader.exec_module(module)
|
|
305
|
+
finally:
|
|
306
|
+
os.chdir(original_cwd)
|
|
307
|
+
|
|
308
|
+
# Find the agent
|
|
309
|
+
if not hasattr(module, 'agent'):
|
|
310
|
+
console.print("[red]Error: No 'agent' variable found in file[/]")
|
|
311
|
+
console.print("[dim]Make sure your file defines: agent = Agent(...)[/]")
|
|
312
|
+
raise typer.Exit(1)
|
|
313
|
+
|
|
314
|
+
agent = module.agent
|
|
315
|
+
agent.run(port=port, reload=reload, enable_discovery=discovery)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
@app.command()
|
|
319
|
+
def version():
|
|
320
|
+
"""Show A2A Lite version."""
|
|
321
|
+
from . import __version__
|
|
322
|
+
console.print(f"A2A Lite v{__version__}")
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
@app.callback()
|
|
326
|
+
def main():
|
|
327
|
+
"""
|
|
328
|
+
A2A Lite - Simplified A2A Protocol SDK
|
|
329
|
+
|
|
330
|
+
Build A2A agents with minimal boilerplate.
|
|
331
|
+
"""
|
|
332
|
+
pass
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
if __name__ == "__main__":
|
|
336
|
+
app()
|
a2a_lite/decorators.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Decorator definitions and skill metadata.
|
|
3
|
+
"""
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Any, Callable, Dict, List
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class SkillDefinition:
|
|
10
|
+
"""Metadata for a registered skill."""
|
|
11
|
+
name: str
|
|
12
|
+
description: str
|
|
13
|
+
handler: Callable
|
|
14
|
+
input_schema: Dict[str, Any]
|
|
15
|
+
output_schema: Dict[str, Any]
|
|
16
|
+
tags: List[str] = field(default_factory=list)
|
|
17
|
+
is_async: bool = False
|
|
18
|
+
is_streaming: bool = False
|
|
19
|
+
needs_task_context: bool = False
|
|
20
|
+
needs_interaction: bool = False
|
|
21
|
+
|
|
22
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
23
|
+
"""Convert to dictionary for serialization."""
|
|
24
|
+
return {
|
|
25
|
+
"name": self.name,
|
|
26
|
+
"description": self.description,
|
|
27
|
+
"tags": self.tags,
|
|
28
|
+
"input_schema": self.input_schema,
|
|
29
|
+
"output_schema": self.output_schema,
|
|
30
|
+
"is_streaming": self.is_streaming,
|
|
31
|
+
"needs_interaction": self.needs_interaction,
|
|
32
|
+
}
|