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/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
+ }