a2a-lite 0.2.0__tar.gz → 0.2.1__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.
- {a2a_lite-0.2.0 → a2a_lite-0.2.1}/PKG-INFO +1 -1
- {a2a_lite-0.2.0 → a2a_lite-0.2.1}/pyproject.toml +1 -1
- {a2a_lite-0.2.0 → a2a_lite-0.2.1}/src/a2a_lite/__init__.py +1 -1
- {a2a_lite-0.2.0 → a2a_lite-0.2.1}/src/a2a_lite/agent.py +5 -0
- {a2a_lite-0.2.0 → a2a_lite-0.2.1}/src/a2a_lite/auth.py +15 -4
- {a2a_lite-0.2.0 → a2a_lite-0.2.1}/src/a2a_lite/cli.py +1 -1
- {a2a_lite-0.2.0 → a2a_lite-0.2.1}/src/a2a_lite/executor.py +35 -7
- {a2a_lite-0.2.0 → a2a_lite-0.2.1}/src/a2a_lite/middleware.py +6 -1
- {a2a_lite-0.2.0 → a2a_lite-0.2.1}/src/a2a_lite/tasks.py +5 -5
- {a2a_lite-0.2.0 → a2a_lite-0.2.1}/src/a2a_lite/testing.py +1 -1
- {a2a_lite-0.2.0 → a2a_lite-0.2.1}/tests/test_auth.py +115 -0
- {a2a_lite-0.2.0 → a2a_lite-0.2.1}/.claude/settings.local.json +0 -0
- {a2a_lite-0.2.0 → a2a_lite-0.2.1}/.gitignore +0 -0
- {a2a_lite-0.2.0 → a2a_lite-0.2.1}/README.md +0 -0
- {a2a_lite-0.2.0 → a2a_lite-0.2.1}/examples/01_hello_world.py +0 -0
- {a2a_lite-0.2.0 → a2a_lite-0.2.1}/examples/02_calculator.py +0 -0
- {a2a_lite-0.2.0 → a2a_lite-0.2.1}/examples/03_async_agent.py +0 -0
- {a2a_lite-0.2.0 → a2a_lite-0.2.1}/examples/04_multi_agent/finance_agent.py +0 -0
- {a2a_lite-0.2.0 → a2a_lite-0.2.1}/examples/04_multi_agent/reporter_agent.py +0 -0
- {a2a_lite-0.2.0 → a2a_lite-0.2.1}/examples/04_multi_agent/run_demo.py +0 -0
- {a2a_lite-0.2.0 → a2a_lite-0.2.1}/examples/05_with_llm.py +0 -0
- {a2a_lite-0.2.0 → a2a_lite-0.2.1}/examples/06_pydantic_models.py +0 -0
- {a2a_lite-0.2.0 → a2a_lite-0.2.1}/examples/07_middleware.py +0 -0
- {a2a_lite-0.2.0 → a2a_lite-0.2.1}/examples/08_streaming.py +0 -0
- {a2a_lite-0.2.0 → a2a_lite-0.2.1}/examples/09_testing.py +0 -0
- {a2a_lite-0.2.0 → a2a_lite-0.2.1}/examples/10_webhooks.py +0 -0
- {a2a_lite-0.2.0 → a2a_lite-0.2.1}/examples/11_human_in_the_loop.py +0 -0
- {a2a_lite-0.2.0 → a2a_lite-0.2.1}/examples/12_file_handling.py +0 -0
- {a2a_lite-0.2.0 → a2a_lite-0.2.1}/examples/13_task_tracking.py +0 -0
- {a2a_lite-0.2.0 → a2a_lite-0.2.1}/examples/14_with_auth.py +0 -0
- {a2a_lite-0.2.0 → a2a_lite-0.2.1}/src/a2a_lite/decorators.py +0 -0
- {a2a_lite-0.2.0 → a2a_lite-0.2.1}/src/a2a_lite/discovery.py +0 -0
- {a2a_lite-0.2.0 → a2a_lite-0.2.1}/src/a2a_lite/human_loop.py +0 -0
- {a2a_lite-0.2.0 → a2a_lite-0.2.1}/src/a2a_lite/parts.py +0 -0
- {a2a_lite-0.2.0 → a2a_lite-0.2.1}/src/a2a_lite/streaming.py +0 -0
- {a2a_lite-0.2.0 → a2a_lite-0.2.1}/src/a2a_lite/utils.py +0 -0
- {a2a_lite-0.2.0 → a2a_lite-0.2.1}/src/a2a_lite/webhooks.py +0 -0
- {a2a_lite-0.2.0 → a2a_lite-0.2.1}/tests/__init__.py +0 -0
- {a2a_lite-0.2.0 → a2a_lite-0.2.1}/tests/test_agent.py +0 -0
- {a2a_lite-0.2.0 → a2a_lite-0.2.1}/tests/test_decorators.py +0 -0
- {a2a_lite-0.2.0 → a2a_lite-0.2.1}/tests/test_discovery.py +0 -0
- {a2a_lite-0.2.0 → a2a_lite-0.2.1}/tests/test_human_loop.py +0 -0
- {a2a_lite-0.2.0 → a2a_lite-0.2.1}/tests/test_integration.py +0 -0
- {a2a_lite-0.2.0 → a2a_lite-0.2.1}/tests/test_middleware.py +0 -0
- {a2a_lite-0.2.0 → a2a_lite-0.2.1}/tests/test_parts.py +0 -0
- {a2a_lite-0.2.0 → a2a_lite-0.2.1}/tests/test_pydantic.py +0 -0
- {a2a_lite-0.2.0 → a2a_lite-0.2.1}/tests/test_tasks.py +0 -0
- {a2a_lite-0.2.0 → a2a_lite-0.2.1}/tests/test_testing.py +0 -0
- {a2a_lite-0.2.0 → a2a_lite-0.2.1}/tests/test_utils.py +0 -0
|
@@ -332,6 +332,10 @@ class Agent:
|
|
|
332
332
|
task_store=self._task_store,
|
|
333
333
|
)
|
|
334
334
|
|
|
335
|
+
# The SDK's InMemoryTaskStore handles protocol-level task lifecycle
|
|
336
|
+
# (task creation, state transitions per the A2A spec). This is separate
|
|
337
|
+
# from self._task_store which provides application-level tracking
|
|
338
|
+
# (progress updates, custom status) exposed via TaskContext to skills.
|
|
335
339
|
request_handler = DefaultRequestHandler(
|
|
336
340
|
agent_executor=executor,
|
|
337
341
|
task_store=InMemoryTaskStore(),
|
|
@@ -485,6 +489,7 @@ class Agent:
|
|
|
485
489
|
task_store=self._task_store,
|
|
486
490
|
)
|
|
487
491
|
|
|
492
|
+
# SDK task store for protocol-level lifecycle (separate from app-level self._task_store)
|
|
488
493
|
request_handler = DefaultRequestHandler(
|
|
489
494
|
agent_executor=executor,
|
|
490
495
|
task_store=InMemoryTaskStore(),
|
|
@@ -60,6 +60,17 @@ class AuthRequest:
|
|
|
60
60
|
method: str = "POST"
|
|
61
61
|
path: str = "/"
|
|
62
62
|
|
|
63
|
+
def get_header(self, name: str) -> Optional[str]:
|
|
64
|
+
"""Get a header value (case-insensitive)."""
|
|
65
|
+
# Try exact match first, then case-insensitive
|
|
66
|
+
if name in self.headers:
|
|
67
|
+
return self.headers[name]
|
|
68
|
+
lower = name.lower()
|
|
69
|
+
for k, v in self.headers.items():
|
|
70
|
+
if k.lower() == lower:
|
|
71
|
+
return v
|
|
72
|
+
return None
|
|
73
|
+
|
|
63
74
|
|
|
64
75
|
@dataclass
|
|
65
76
|
class AuthResult:
|
|
@@ -128,8 +139,8 @@ class APIKeyAuth(AuthProvider):
|
|
|
128
139
|
return hashlib.sha256(key.encode()).hexdigest()
|
|
129
140
|
|
|
130
141
|
async def authenticate(self, request: AuthRequest) -> AuthResult:
|
|
131
|
-
# Check header
|
|
132
|
-
key = request.
|
|
142
|
+
# Check header (case-insensitive)
|
|
143
|
+
key = request.get_header(self.header)
|
|
133
144
|
|
|
134
145
|
# Check query param
|
|
135
146
|
if not key and self.query_param:
|
|
@@ -179,7 +190,7 @@ class BearerAuth(AuthProvider):
|
|
|
179
190
|
self.header = header
|
|
180
191
|
|
|
181
192
|
async def authenticate(self, request: AuthRequest) -> AuthResult:
|
|
182
|
-
auth_header = request.
|
|
193
|
+
auth_header = request.get_header(self.header) or ""
|
|
183
194
|
|
|
184
195
|
if not auth_header.startswith("Bearer "):
|
|
185
196
|
return AuthResult.failure("Bearer token required")
|
|
@@ -228,7 +239,7 @@ class OAuth2Auth(AuthProvider):
|
|
|
228
239
|
self._jwks_client = None
|
|
229
240
|
|
|
230
241
|
async def authenticate(self, request: AuthRequest) -> AuthResult:
|
|
231
|
-
auth_header = request.
|
|
242
|
+
auth_header = request.get_header("Authorization") or ""
|
|
232
243
|
|
|
233
244
|
if not auth_header.startswith("Bearer "):
|
|
234
245
|
return AuthResult.failure("Bearer token required")
|
|
@@ -59,6 +59,22 @@ class LiteAgentExecutor(AgentExecutor):
|
|
|
59
59
|
from a2a.utils import new_agent_text_message
|
|
60
60
|
|
|
61
61
|
try:
|
|
62
|
+
# Authenticate the request
|
|
63
|
+
if self.auth_provider:
|
|
64
|
+
from .auth import AuthRequest, NoAuth
|
|
65
|
+
if not isinstance(self.auth_provider, NoAuth):
|
|
66
|
+
headers = {}
|
|
67
|
+
if context.call_context and context.call_context.state:
|
|
68
|
+
headers = context.call_context.state.get('headers', {})
|
|
69
|
+
auth_request = AuthRequest(headers=headers)
|
|
70
|
+
auth_result = await self.auth_provider.authenticate(auth_request)
|
|
71
|
+
if not auth_result.authenticated:
|
|
72
|
+
error_msg = json.dumps({
|
|
73
|
+
"error": auth_result.error or "Authentication failed",
|
|
74
|
+
})
|
|
75
|
+
await event_queue.enqueue_event(new_agent_text_message(error_msg))
|
|
76
|
+
return
|
|
77
|
+
|
|
62
78
|
# Extract message and parts
|
|
63
79
|
message, parts = self._extract_message_and_parts(context)
|
|
64
80
|
|
|
@@ -120,7 +136,14 @@ class LiteAgentExecutor(AgentExecutor):
|
|
|
120
136
|
if skill_name is None:
|
|
121
137
|
if not self.skills:
|
|
122
138
|
return {"error": "No skills registered"}
|
|
123
|
-
|
|
139
|
+
# Only auto-select if there's exactly one skill
|
|
140
|
+
if len(self.skills) == 1:
|
|
141
|
+
skill_name = list(self.skills.keys())[0]
|
|
142
|
+
else:
|
|
143
|
+
return {
|
|
144
|
+
"error": "No skill specified. Use {\"skill\": \"name\", \"params\": {...}} format.",
|
|
145
|
+
"available_skills": list(self.skills.keys()),
|
|
146
|
+
}
|
|
124
147
|
|
|
125
148
|
if skill_name not in self.skills:
|
|
126
149
|
return {
|
|
@@ -167,11 +190,19 @@ class LiteAgentExecutor(AgentExecutor):
|
|
|
167
190
|
metadata: Dict[str, Any],
|
|
168
191
|
) -> Dict[str, Any]:
|
|
169
192
|
"""Convert parameters to Pydantic models and file parts if needed."""
|
|
193
|
+
import typing
|
|
170
194
|
handler = skill_def.handler
|
|
171
|
-
|
|
195
|
+
try:
|
|
196
|
+
hints = typing.get_type_hints(handler)
|
|
197
|
+
except Exception:
|
|
198
|
+
hints = getattr(handler, '__annotations__', {})
|
|
199
|
+
|
|
200
|
+
from .parts import FilePart, DataPart
|
|
172
201
|
|
|
173
202
|
converted = {}
|
|
174
203
|
for param_name, value in params.items():
|
|
204
|
+
if param_name == 'return':
|
|
205
|
+
continue
|
|
175
206
|
param_type = hints.get(param_name)
|
|
176
207
|
|
|
177
208
|
if param_type is None:
|
|
@@ -185,9 +216,7 @@ class LiteAgentExecutor(AgentExecutor):
|
|
|
185
216
|
continue
|
|
186
217
|
|
|
187
218
|
# Convert FilePart
|
|
188
|
-
|
|
189
|
-
if "FilePart" in type_name:
|
|
190
|
-
from .parts import FilePart
|
|
219
|
+
if _is_or_subclass(param_type, FilePart):
|
|
191
220
|
if isinstance(value, dict):
|
|
192
221
|
# Handle both A2A format and simple dict format
|
|
193
222
|
if "file" in value:
|
|
@@ -208,8 +237,7 @@ class LiteAgentExecutor(AgentExecutor):
|
|
|
208
237
|
continue
|
|
209
238
|
|
|
210
239
|
# Convert DataPart
|
|
211
|
-
if
|
|
212
|
-
from .parts import DataPart
|
|
240
|
+
if _is_or_subclass(param_type, DataPart):
|
|
213
241
|
if isinstance(value, dict):
|
|
214
242
|
# Handle both A2A format and simple dict format
|
|
215
243
|
if "type" in value and value.get("type") == "data":
|
|
@@ -159,7 +159,12 @@ def retry_middleware(max_retries: int = 3, delay: float = 1.0):
|
|
|
159
159
|
|
|
160
160
|
def rate_limit_middleware(requests_per_minute: int = 60):
|
|
161
161
|
"""
|
|
162
|
-
Create a simple rate limiting middleware.
|
|
162
|
+
Create a simple in-process rate limiting middleware.
|
|
163
|
+
|
|
164
|
+
Note: This rate limiter is per-process. Under multi-worker uvicorn
|
|
165
|
+
(e.g., ``--workers 4``), each worker tracks limits independently.
|
|
166
|
+
For shared rate limiting across workers, use an external store
|
|
167
|
+
(Redis, etc.) and a custom middleware.
|
|
163
168
|
|
|
164
169
|
Example:
|
|
165
170
|
agent.add_middleware(rate_limit_middleware(requests_per_minute=100))
|
|
@@ -30,7 +30,7 @@ from __future__ import annotations
|
|
|
30
30
|
import asyncio
|
|
31
31
|
import logging
|
|
32
32
|
from dataclasses import dataclass, field
|
|
33
|
-
from datetime import datetime
|
|
33
|
+
from datetime import datetime, timezone
|
|
34
34
|
from enum import Enum
|
|
35
35
|
from typing import Any, Callable, Dict, List, Optional
|
|
36
36
|
from uuid import uuid4
|
|
@@ -55,7 +55,7 @@ class TaskStatus:
|
|
|
55
55
|
state: TaskState
|
|
56
56
|
message: Optional[str] = None
|
|
57
57
|
progress: Optional[float] = None # 0.0 to 1.0
|
|
58
|
-
timestamp: datetime = field(default_factory=datetime.
|
|
58
|
+
timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
|
59
59
|
|
|
60
60
|
def to_dict(self) -> Dict[str, Any]:
|
|
61
61
|
return {
|
|
@@ -77,8 +77,8 @@ class Task:
|
|
|
77
77
|
error: Optional[str] = None
|
|
78
78
|
artifacts: List[Any] = field(default_factory=list)
|
|
79
79
|
history: List[TaskStatus] = field(default_factory=list)
|
|
80
|
-
created_at: datetime = field(default_factory=datetime.
|
|
81
|
-
updated_at: datetime = field(default_factory=datetime.
|
|
80
|
+
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
|
81
|
+
updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
|
82
82
|
|
|
83
83
|
def update_status(
|
|
84
84
|
self,
|
|
@@ -89,7 +89,7 @@ class Task:
|
|
|
89
89
|
"""Update task status."""
|
|
90
90
|
self.history.append(self.status)
|
|
91
91
|
self.status = TaskStatus(state=state, message=message, progress=progress)
|
|
92
|
-
self.updated_at = datetime.
|
|
92
|
+
self.updated_at = datetime.now(timezone.utc)
|
|
93
93
|
|
|
94
94
|
|
|
95
95
|
class TaskContext:
|
|
@@ -175,3 +175,118 @@ class TestCompositeAuth:
|
|
|
175
175
|
result = await auth.authenticate(request)
|
|
176
176
|
|
|
177
177
|
assert result.authenticated is False
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class TestAuthIntegration:
|
|
181
|
+
"""Test that auth is actually enforced in the HTTP request pipeline."""
|
|
182
|
+
|
|
183
|
+
def _make_agent_with_auth(self):
|
|
184
|
+
from a2a_lite import Agent
|
|
185
|
+
agent = Agent(
|
|
186
|
+
name="SecureAgent",
|
|
187
|
+
description="Auth integration test",
|
|
188
|
+
auth=APIKeyAuth(keys=["valid-key"]),
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
@agent.skill("secret")
|
|
192
|
+
async def secret(data: str) -> str:
|
|
193
|
+
return f"secret: {data}"
|
|
194
|
+
|
|
195
|
+
return agent
|
|
196
|
+
|
|
197
|
+
def test_unauthenticated_request_rejected(self):
|
|
198
|
+
"""Requests without a valid API key should be rejected."""
|
|
199
|
+
from starlette.testclient import TestClient
|
|
200
|
+
import json
|
|
201
|
+
from uuid import uuid4
|
|
202
|
+
|
|
203
|
+
agent = self._make_agent_with_auth()
|
|
204
|
+
app = agent.get_app()
|
|
205
|
+
client = TestClient(app)
|
|
206
|
+
|
|
207
|
+
request_body = {
|
|
208
|
+
"jsonrpc": "2.0",
|
|
209
|
+
"method": "message/send",
|
|
210
|
+
"id": uuid4().hex,
|
|
211
|
+
"params": {
|
|
212
|
+
"message": {
|
|
213
|
+
"role": "user",
|
|
214
|
+
"parts": [{"type": "text", "text": json.dumps({"skill": "secret", "params": {"data": "hello"}})}],
|
|
215
|
+
"messageId": uuid4().hex,
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
response = client.post("/", json=request_body)
|
|
221
|
+
data = response.json()
|
|
222
|
+
|
|
223
|
+
# The response should contain an auth error, not the skill result
|
|
224
|
+
result_text = data.get("result", {}).get("parts", [{}])[0].get("text", "")
|
|
225
|
+
assert "error" in result_text.lower() or "auth" in result_text.lower() or "key" in result_text.lower()
|
|
226
|
+
assert "secret: hello" not in result_text
|
|
227
|
+
|
|
228
|
+
def test_authenticated_request_succeeds(self):
|
|
229
|
+
"""Requests with a valid API key should succeed."""
|
|
230
|
+
from starlette.testclient import TestClient
|
|
231
|
+
import json
|
|
232
|
+
from uuid import uuid4
|
|
233
|
+
|
|
234
|
+
agent = self._make_agent_with_auth()
|
|
235
|
+
app = agent.get_app()
|
|
236
|
+
client = TestClient(app)
|
|
237
|
+
|
|
238
|
+
request_body = {
|
|
239
|
+
"jsonrpc": "2.0",
|
|
240
|
+
"method": "message/send",
|
|
241
|
+
"id": uuid4().hex,
|
|
242
|
+
"params": {
|
|
243
|
+
"message": {
|
|
244
|
+
"role": "user",
|
|
245
|
+
"parts": [{"type": "text", "text": json.dumps({"skill": "secret", "params": {"data": "hello"}})}],
|
|
246
|
+
"messageId": uuid4().hex,
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
response = client.post(
|
|
252
|
+
"/",
|
|
253
|
+
json=request_body,
|
|
254
|
+
headers={"X-API-Key": "valid-key"},
|
|
255
|
+
)
|
|
256
|
+
data = response.json()
|
|
257
|
+
|
|
258
|
+
result_text = data.get("result", {}).get("parts", [{}])[0].get("text", "")
|
|
259
|
+
assert "secret: hello" in result_text
|
|
260
|
+
|
|
261
|
+
def test_wrong_key_rejected(self):
|
|
262
|
+
"""Requests with an invalid API key should be rejected."""
|
|
263
|
+
from starlette.testclient import TestClient
|
|
264
|
+
import json
|
|
265
|
+
from uuid import uuid4
|
|
266
|
+
|
|
267
|
+
agent = self._make_agent_with_auth()
|
|
268
|
+
app = agent.get_app()
|
|
269
|
+
client = TestClient(app)
|
|
270
|
+
|
|
271
|
+
request_body = {
|
|
272
|
+
"jsonrpc": "2.0",
|
|
273
|
+
"method": "message/send",
|
|
274
|
+
"id": uuid4().hex,
|
|
275
|
+
"params": {
|
|
276
|
+
"message": {
|
|
277
|
+
"role": "user",
|
|
278
|
+
"parts": [{"type": "text", "text": json.dumps({"skill": "secret", "params": {"data": "hello"}})}],
|
|
279
|
+
"messageId": uuid4().hex,
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
response = client.post(
|
|
285
|
+
"/",
|
|
286
|
+
json=request_body,
|
|
287
|
+
headers={"X-API-Key": "wrong-key"},
|
|
288
|
+
)
|
|
289
|
+
data = response.json()
|
|
290
|
+
|
|
291
|
+
result_text = data.get("result", {}).get("parts", [{}])[0].get("text", "")
|
|
292
|
+
assert "secret: hello" not in result_text
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|