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/human_loop.py
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Human-in-the-loop support (OPTIONAL).
|
|
3
|
+
|
|
4
|
+
Allows agents to ask users for clarification mid-task.
|
|
5
|
+
Uses A2A's "input-required" task state.
|
|
6
|
+
|
|
7
|
+
Example (simple - no human loop needed):
|
|
8
|
+
@agent.skill("greet")
|
|
9
|
+
async def greet(name: str) -> str:
|
|
10
|
+
return f"Hello, {name}!"
|
|
11
|
+
|
|
12
|
+
Example (with human-in-the-loop - opt-in):
|
|
13
|
+
@agent.skill("book_flight")
|
|
14
|
+
async def book_flight(destination: str, ctx: InteractionContext) -> str:
|
|
15
|
+
# Ask user for date
|
|
16
|
+
date = await ctx.ask("What date do you want to travel?")
|
|
17
|
+
|
|
18
|
+
# Ask for confirmation with options
|
|
19
|
+
confirm = await ctx.ask(
|
|
20
|
+
f"Book flight to {destination} on {date}?",
|
|
21
|
+
options=["Yes", "No", "Change date"]
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
if confirm == "Yes":
|
|
25
|
+
return f"Booked flight to {destination} on {date}!"
|
|
26
|
+
elif confirm == "Change date":
|
|
27
|
+
new_date = await ctx.ask("Enter new date:")
|
|
28
|
+
return f"Booked flight to {destination} on {new_date}!"
|
|
29
|
+
else:
|
|
30
|
+
return "Booking cancelled."
|
|
31
|
+
"""
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
from dataclasses import dataclass, field
|
|
35
|
+
from typing import Any, Callable, Dict, List, Optional, Union
|
|
36
|
+
from uuid import uuid4
|
|
37
|
+
import asyncio
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class InputRequest:
|
|
42
|
+
"""Request for user input."""
|
|
43
|
+
id: str
|
|
44
|
+
prompt: str
|
|
45
|
+
options: Optional[List[str]] = None
|
|
46
|
+
input_type: str = "text" # text, choice, confirm, file
|
|
47
|
+
required: bool = True
|
|
48
|
+
default: Optional[str] = None
|
|
49
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
50
|
+
|
|
51
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
52
|
+
return {
|
|
53
|
+
"id": self.id,
|
|
54
|
+
"prompt": self.prompt,
|
|
55
|
+
"options": self.options,
|
|
56
|
+
"type": self.input_type,
|
|
57
|
+
"required": self.required,
|
|
58
|
+
"default": self.default,
|
|
59
|
+
"metadata": self.metadata,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class InputResponse:
|
|
65
|
+
"""User's response to input request."""
|
|
66
|
+
request_id: str
|
|
67
|
+
value: Any
|
|
68
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class InteractionContext:
|
|
72
|
+
"""
|
|
73
|
+
Context for human-in-the-loop interactions.
|
|
74
|
+
|
|
75
|
+
Passed to skills that need to ask users questions.
|
|
76
|
+
|
|
77
|
+
Example:
|
|
78
|
+
@agent.skill("wizard")
|
|
79
|
+
async def wizard(ctx: InteractionContext) -> str:
|
|
80
|
+
name = await ctx.ask("What's your name?")
|
|
81
|
+
age = await ctx.ask("How old are you?", input_type="number")
|
|
82
|
+
confirm = await ctx.confirm(f"Create profile for {name}, age {age}?")
|
|
83
|
+
|
|
84
|
+
if confirm:
|
|
85
|
+
return f"Created profile for {name}!"
|
|
86
|
+
return "Cancelled."
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
def __init__(
|
|
90
|
+
self,
|
|
91
|
+
task_id: str,
|
|
92
|
+
event_queue=None,
|
|
93
|
+
response_handler: Optional[Callable] = None,
|
|
94
|
+
):
|
|
95
|
+
self.task_id = task_id
|
|
96
|
+
self._event_queue = event_queue
|
|
97
|
+
self._response_handler = response_handler
|
|
98
|
+
self._pending_requests: Dict[str, asyncio.Future] = {}
|
|
99
|
+
|
|
100
|
+
async def ask(
|
|
101
|
+
self,
|
|
102
|
+
prompt: str,
|
|
103
|
+
options: Optional[List[str]] = None,
|
|
104
|
+
input_type: str = "text",
|
|
105
|
+
required: bool = True,
|
|
106
|
+
default: Optional[str] = None,
|
|
107
|
+
timeout: Optional[float] = None,
|
|
108
|
+
) -> str:
|
|
109
|
+
"""
|
|
110
|
+
Ask the user a question and wait for response.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
prompt: Question to ask
|
|
114
|
+
options: List of options (for choice type)
|
|
115
|
+
input_type: "text", "choice", "number", "file"
|
|
116
|
+
required: Whether input is required
|
|
117
|
+
default: Default value if not required
|
|
118
|
+
timeout: Timeout in seconds
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
User's response
|
|
122
|
+
|
|
123
|
+
Example:
|
|
124
|
+
name = await ctx.ask("What's your name?")
|
|
125
|
+
choice = await ctx.ask("Pick one:", options=["A", "B", "C"])
|
|
126
|
+
"""
|
|
127
|
+
request = InputRequest(
|
|
128
|
+
id=uuid4().hex,
|
|
129
|
+
prompt=prompt,
|
|
130
|
+
options=options,
|
|
131
|
+
input_type="choice" if options else input_type,
|
|
132
|
+
required=required,
|
|
133
|
+
default=default,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
# Create future for response
|
|
137
|
+
future: asyncio.Future = asyncio.Future()
|
|
138
|
+
self._pending_requests[request.id] = future
|
|
139
|
+
|
|
140
|
+
# Send input-required event
|
|
141
|
+
await self._send_input_request(request)
|
|
142
|
+
|
|
143
|
+
# Wait for response
|
|
144
|
+
try:
|
|
145
|
+
if timeout:
|
|
146
|
+
response = await asyncio.wait_for(future, timeout=timeout)
|
|
147
|
+
else:
|
|
148
|
+
response = await future
|
|
149
|
+
return response.value
|
|
150
|
+
except asyncio.TimeoutError:
|
|
151
|
+
if default is not None:
|
|
152
|
+
return default
|
|
153
|
+
raise TimeoutError(f"No response received for: {prompt}")
|
|
154
|
+
finally:
|
|
155
|
+
self._pending_requests.pop(request.id, None)
|
|
156
|
+
|
|
157
|
+
async def confirm(
|
|
158
|
+
self,
|
|
159
|
+
prompt: str,
|
|
160
|
+
yes_label: str = "Yes",
|
|
161
|
+
no_label: str = "No",
|
|
162
|
+
) -> bool:
|
|
163
|
+
"""
|
|
164
|
+
Ask for yes/no confirmation.
|
|
165
|
+
|
|
166
|
+
Example:
|
|
167
|
+
if await ctx.confirm("Are you sure?"):
|
|
168
|
+
do_something()
|
|
169
|
+
"""
|
|
170
|
+
response = await self.ask(
|
|
171
|
+
prompt,
|
|
172
|
+
options=[yes_label, no_label],
|
|
173
|
+
input_type="choice",
|
|
174
|
+
)
|
|
175
|
+
return response == yes_label
|
|
176
|
+
|
|
177
|
+
async def choose(
|
|
178
|
+
self,
|
|
179
|
+
prompt: str,
|
|
180
|
+
options: List[str],
|
|
181
|
+
allow_multiple: bool = False,
|
|
182
|
+
) -> Union[str, List[str]]:
|
|
183
|
+
"""
|
|
184
|
+
Ask user to choose from options.
|
|
185
|
+
|
|
186
|
+
Example:
|
|
187
|
+
color = await ctx.choose("Pick a color:", ["Red", "Blue", "Green"])
|
|
188
|
+
"""
|
|
189
|
+
response = await self.ask(prompt, options=options, input_type="choice")
|
|
190
|
+
if allow_multiple and isinstance(response, str):
|
|
191
|
+
return [response]
|
|
192
|
+
return response
|
|
193
|
+
|
|
194
|
+
async def _send_input_request(self, request: InputRequest) -> None:
|
|
195
|
+
"""Send input-required event via SSE."""
|
|
196
|
+
if self._event_queue:
|
|
197
|
+
from a2a.utils import new_agent_text_message
|
|
198
|
+
import json
|
|
199
|
+
|
|
200
|
+
msg = json.dumps({
|
|
201
|
+
"_type": "input_required",
|
|
202
|
+
"task_id": self.task_id,
|
|
203
|
+
"request": request.to_dict(),
|
|
204
|
+
})
|
|
205
|
+
await self._event_queue.enqueue_event(new_agent_text_message(msg))
|
|
206
|
+
|
|
207
|
+
def handle_response(self, request_id: str, value: Any) -> bool:
|
|
208
|
+
"""
|
|
209
|
+
Handle incoming response from user.
|
|
210
|
+
|
|
211
|
+
Called by the executor when user responds.
|
|
212
|
+
"""
|
|
213
|
+
future = self._pending_requests.get(request_id)
|
|
214
|
+
if future and not future.done():
|
|
215
|
+
response = InputResponse(request_id=request_id, value=value)
|
|
216
|
+
future.set_result(response)
|
|
217
|
+
return True
|
|
218
|
+
return False
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class ConversationMemory:
|
|
222
|
+
"""
|
|
223
|
+
Simple conversation memory for multi-turn interactions.
|
|
224
|
+
|
|
225
|
+
Example:
|
|
226
|
+
@agent.skill("chat")
|
|
227
|
+
async def chat(message: str, memory: ConversationMemory) -> str:
|
|
228
|
+
# Get conversation history
|
|
229
|
+
history = memory.get_messages()
|
|
230
|
+
|
|
231
|
+
# Add user message
|
|
232
|
+
memory.add_user(message)
|
|
233
|
+
|
|
234
|
+
# Generate response (using history for context)
|
|
235
|
+
response = await generate_response(message, history)
|
|
236
|
+
|
|
237
|
+
# Add assistant response
|
|
238
|
+
memory.add_assistant(response)
|
|
239
|
+
|
|
240
|
+
return response
|
|
241
|
+
"""
|
|
242
|
+
|
|
243
|
+
def __init__(self, max_messages: int = 100):
|
|
244
|
+
self._messages: List[Dict[str, str]] = []
|
|
245
|
+
self._max_messages = max_messages
|
|
246
|
+
self._metadata: Dict[str, Any] = {}
|
|
247
|
+
|
|
248
|
+
def add_user(self, content: str) -> None:
|
|
249
|
+
"""Add user message."""
|
|
250
|
+
self._add_message("user", content)
|
|
251
|
+
|
|
252
|
+
def add_assistant(self, content: str) -> None:
|
|
253
|
+
"""Add assistant message."""
|
|
254
|
+
self._add_message("assistant", content)
|
|
255
|
+
|
|
256
|
+
def add_system(self, content: str) -> None:
|
|
257
|
+
"""Add system message."""
|
|
258
|
+
self._add_message("system", content)
|
|
259
|
+
|
|
260
|
+
def _add_message(self, role: str, content: str) -> None:
|
|
261
|
+
self._messages.append({"role": role, "content": content})
|
|
262
|
+
# Trim if over limit
|
|
263
|
+
if len(self._messages) > self._max_messages:
|
|
264
|
+
self._messages = self._messages[-self._max_messages:]
|
|
265
|
+
|
|
266
|
+
def get_messages(self) -> List[Dict[str, str]]:
|
|
267
|
+
"""Get all messages."""
|
|
268
|
+
return list(self._messages)
|
|
269
|
+
|
|
270
|
+
def get_last(self, n: int = 10) -> List[Dict[str, str]]:
|
|
271
|
+
"""Get last n messages."""
|
|
272
|
+
return self._messages[-n:]
|
|
273
|
+
|
|
274
|
+
def clear(self) -> None:
|
|
275
|
+
"""Clear all messages."""
|
|
276
|
+
self._messages.clear()
|
|
277
|
+
|
|
278
|
+
def set_metadata(self, key: str, value: Any) -> None:
|
|
279
|
+
"""Set metadata."""
|
|
280
|
+
self._metadata[key] = value
|
|
281
|
+
|
|
282
|
+
def get_metadata(self, key: str, default: Any = None) -> Any:
|
|
283
|
+
"""Get metadata."""
|
|
284
|
+
return self._metadata.get(key, default)
|
a2a_lite/middleware.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Middleware support for A2A Lite agents.
|
|
3
|
+
|
|
4
|
+
Middleware allows you to add cross-cutting concerns like logging,
|
|
5
|
+
authentication, rate limiting, etc.
|
|
6
|
+
|
|
7
|
+
Example:
|
|
8
|
+
@agent.middleware
|
|
9
|
+
async def log_requests(ctx, next):
|
|
10
|
+
print(f"Request: {ctx.skill}")
|
|
11
|
+
result = await next()
|
|
12
|
+
print(f"Result: {result}")
|
|
13
|
+
return result
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
19
|
+
import asyncio
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class MiddlewareContext:
|
|
24
|
+
"""
|
|
25
|
+
Context passed to middleware functions.
|
|
26
|
+
|
|
27
|
+
Attributes:
|
|
28
|
+
skill: The skill being called (if determined)
|
|
29
|
+
params: The parameters for the skill
|
|
30
|
+
message: The raw message text
|
|
31
|
+
metadata: Arbitrary metadata dict for middleware to share data
|
|
32
|
+
"""
|
|
33
|
+
skill: Optional[str] = None
|
|
34
|
+
params: Dict[str, Any] = field(default_factory=dict)
|
|
35
|
+
message: str = ""
|
|
36
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class MiddlewareChain:
|
|
40
|
+
"""
|
|
41
|
+
Manages the middleware execution chain.
|
|
42
|
+
|
|
43
|
+
Middleware functions are called in order, each receiving the context
|
|
44
|
+
and a `next` function to call the next middleware (or the final handler).
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(self):
|
|
48
|
+
self._middlewares: List[Callable] = []
|
|
49
|
+
|
|
50
|
+
def add(self, middleware: Callable) -> None:
|
|
51
|
+
"""Add a middleware function to the chain."""
|
|
52
|
+
self._middlewares.append(middleware)
|
|
53
|
+
|
|
54
|
+
async def execute(
|
|
55
|
+
self,
|
|
56
|
+
context: MiddlewareContext,
|
|
57
|
+
final_handler: Callable,
|
|
58
|
+
) -> Any:
|
|
59
|
+
"""
|
|
60
|
+
Execute the middleware chain.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
context: The middleware context
|
|
64
|
+
final_handler: The final handler to call after all middleware
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
The result from the handler (possibly modified by middleware)
|
|
68
|
+
"""
|
|
69
|
+
# Build the chain from the end
|
|
70
|
+
async def call_final():
|
|
71
|
+
return await final_handler(context)
|
|
72
|
+
|
|
73
|
+
# Wrap each middleware around the next
|
|
74
|
+
next_fn = call_final
|
|
75
|
+
for middleware in reversed(self._middlewares):
|
|
76
|
+
next_fn = self._wrap_middleware(middleware, context, next_fn)
|
|
77
|
+
|
|
78
|
+
return await next_fn()
|
|
79
|
+
|
|
80
|
+
def _wrap_middleware(
|
|
81
|
+
self,
|
|
82
|
+
middleware: Callable,
|
|
83
|
+
context: MiddlewareContext,
|
|
84
|
+
next_fn: Callable,
|
|
85
|
+
) -> Callable:
|
|
86
|
+
"""Wrap a middleware function."""
|
|
87
|
+
async def wrapped():
|
|
88
|
+
if asyncio.iscoroutinefunction(middleware):
|
|
89
|
+
return await middleware(context, next_fn)
|
|
90
|
+
else:
|
|
91
|
+
return middleware(context, next_fn)
|
|
92
|
+
return wrapped
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# Built-in middleware helpers
|
|
96
|
+
|
|
97
|
+
def logging_middleware(logger=None):
|
|
98
|
+
"""
|
|
99
|
+
Create a logging middleware.
|
|
100
|
+
|
|
101
|
+
Example:
|
|
102
|
+
agent.add_middleware(logging_middleware())
|
|
103
|
+
"""
|
|
104
|
+
import logging
|
|
105
|
+
log = logger or logging.getLogger("a2a_lite")
|
|
106
|
+
|
|
107
|
+
async def middleware(ctx: MiddlewareContext, next):
|
|
108
|
+
log.info(f"Calling skill: {ctx.skill} with params: {ctx.params}")
|
|
109
|
+
try:
|
|
110
|
+
result = await next()
|
|
111
|
+
log.info(f"Skill {ctx.skill} returned successfully")
|
|
112
|
+
return result
|
|
113
|
+
except Exception as e:
|
|
114
|
+
log.error(f"Skill {ctx.skill} failed: {e}")
|
|
115
|
+
raise
|
|
116
|
+
|
|
117
|
+
return middleware
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def timing_middleware():
|
|
121
|
+
"""
|
|
122
|
+
Create a timing middleware that adds execution time to metadata.
|
|
123
|
+
|
|
124
|
+
Example:
|
|
125
|
+
agent.add_middleware(timing_middleware())
|
|
126
|
+
"""
|
|
127
|
+
import time
|
|
128
|
+
|
|
129
|
+
async def middleware(ctx: MiddlewareContext, next):
|
|
130
|
+
start = time.perf_counter()
|
|
131
|
+
result = await next()
|
|
132
|
+
elapsed = time.perf_counter() - start
|
|
133
|
+
ctx.metadata["execution_time_ms"] = round(elapsed * 1000, 2)
|
|
134
|
+
return result
|
|
135
|
+
|
|
136
|
+
return middleware
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def retry_middleware(max_retries: int = 3, delay: float = 1.0):
|
|
140
|
+
"""
|
|
141
|
+
Create a retry middleware for failed skill calls.
|
|
142
|
+
|
|
143
|
+
Example:
|
|
144
|
+
agent.add_middleware(retry_middleware(max_retries=3))
|
|
145
|
+
"""
|
|
146
|
+
async def middleware(ctx: MiddlewareContext, next):
|
|
147
|
+
last_error = None
|
|
148
|
+
for attempt in range(max_retries):
|
|
149
|
+
try:
|
|
150
|
+
return await next()
|
|
151
|
+
except Exception as e:
|
|
152
|
+
last_error = e
|
|
153
|
+
if attempt < max_retries - 1:
|
|
154
|
+
await asyncio.sleep(delay * (attempt + 1))
|
|
155
|
+
raise last_error
|
|
156
|
+
|
|
157
|
+
return middleware
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def rate_limit_middleware(requests_per_minute: int = 60):
|
|
161
|
+
"""
|
|
162
|
+
Create a simple rate limiting middleware.
|
|
163
|
+
|
|
164
|
+
Example:
|
|
165
|
+
agent.add_middleware(rate_limit_middleware(requests_per_minute=100))
|
|
166
|
+
"""
|
|
167
|
+
import time
|
|
168
|
+
from collections import deque
|
|
169
|
+
|
|
170
|
+
request_times = deque()
|
|
171
|
+
|
|
172
|
+
async def middleware(ctx: MiddlewareContext, next):
|
|
173
|
+
now = time.time()
|
|
174
|
+
minute_ago = now - 60
|
|
175
|
+
|
|
176
|
+
# Remove old requests
|
|
177
|
+
while request_times and request_times[0] < minute_ago:
|
|
178
|
+
request_times.popleft()
|
|
179
|
+
|
|
180
|
+
if len(request_times) >= requests_per_minute:
|
|
181
|
+
raise RateLimitExceeded(
|
|
182
|
+
f"Rate limit exceeded: {requests_per_minute} requests per minute"
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
request_times.append(now)
|
|
186
|
+
return await next()
|
|
187
|
+
|
|
188
|
+
return middleware
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class RateLimitExceeded(Exception):
|
|
192
|
+
"""Raised when rate limit is exceeded."""
|
|
193
|
+
pass
|
a2a_lite/parts.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Multi-modal parts support (Text, File, Data).
|
|
3
|
+
|
|
4
|
+
OPTIONAL - Only use if you need files or structured data.
|
|
5
|
+
Simple text skills work without any of this.
|
|
6
|
+
|
|
7
|
+
Example (simple - no parts needed):
|
|
8
|
+
@agent.skill("greet")
|
|
9
|
+
async def greet(name: str) -> str:
|
|
10
|
+
return f"Hello, {name}!"
|
|
11
|
+
|
|
12
|
+
Example (with files - opt-in):
|
|
13
|
+
from a2a_lite import FilePart, DataPart
|
|
14
|
+
|
|
15
|
+
@agent.skill("summarize")
|
|
16
|
+
async def summarize(document: FilePart) -> str:
|
|
17
|
+
content = await document.read_text()
|
|
18
|
+
return summarize(content)
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import base64
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from typing import Any, Dict, Optional, Union
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class TextPart:
|
|
30
|
+
"""Simple text content."""
|
|
31
|
+
text: str
|
|
32
|
+
|
|
33
|
+
def to_a2a(self) -> Dict[str, Any]:
|
|
34
|
+
return {"type": "text", "text": self.text}
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def from_a2a(cls, data: Dict) -> "TextPart":
|
|
38
|
+
return cls(text=data.get("text", ""))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class FilePart:
|
|
43
|
+
"""
|
|
44
|
+
File content - can be bytes or a URI.
|
|
45
|
+
|
|
46
|
+
Example:
|
|
47
|
+
@agent.skill("process")
|
|
48
|
+
async def process(file: FilePart) -> str:
|
|
49
|
+
if file.is_uri:
|
|
50
|
+
# Download from URI
|
|
51
|
+
content = await fetch(file.uri)
|
|
52
|
+
else:
|
|
53
|
+
# Use bytes directly
|
|
54
|
+
content = file.data
|
|
55
|
+
|
|
56
|
+
return process_content(content)
|
|
57
|
+
"""
|
|
58
|
+
name: str
|
|
59
|
+
mime_type: str = "application/octet-stream"
|
|
60
|
+
data: Optional[bytes] = None
|
|
61
|
+
uri: Optional[str] = None
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def is_uri(self) -> bool:
|
|
65
|
+
return self.uri is not None
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def is_bytes(self) -> bool:
|
|
69
|
+
return self.data is not None
|
|
70
|
+
|
|
71
|
+
async def read_bytes(self) -> bytes:
|
|
72
|
+
"""Read file content as bytes."""
|
|
73
|
+
if self.data:
|
|
74
|
+
return self.data
|
|
75
|
+
if self.uri:
|
|
76
|
+
import httpx
|
|
77
|
+
async with httpx.AsyncClient() as client:
|
|
78
|
+
response = await client.get(self.uri)
|
|
79
|
+
response.raise_for_status()
|
|
80
|
+
return response.content
|
|
81
|
+
raise ValueError("FilePart has no data or URI")
|
|
82
|
+
|
|
83
|
+
async def read_text(self, encoding: str = "utf-8") -> str:
|
|
84
|
+
"""Read file content as text."""
|
|
85
|
+
data = await self.read_bytes()
|
|
86
|
+
return data.decode(encoding)
|
|
87
|
+
|
|
88
|
+
def to_a2a(self) -> Dict[str, Any]:
|
|
89
|
+
if self.uri:
|
|
90
|
+
return {
|
|
91
|
+
"type": "file",
|
|
92
|
+
"file": {
|
|
93
|
+
"name": self.name,
|
|
94
|
+
"mimeType": self.mime_type,
|
|
95
|
+
"uri": self.uri,
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
else:
|
|
99
|
+
return {
|
|
100
|
+
"type": "file",
|
|
101
|
+
"file": {
|
|
102
|
+
"name": self.name,
|
|
103
|
+
"mimeType": self.mime_type,
|
|
104
|
+
"bytes": base64.b64encode(self.data or b"").decode(),
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
@classmethod
|
|
109
|
+
def from_a2a(cls, data: Dict) -> "FilePart":
|
|
110
|
+
file_data = data.get("file", {})
|
|
111
|
+
bytes_data = file_data.get("bytes")
|
|
112
|
+
return cls(
|
|
113
|
+
name=file_data.get("name", "unknown"),
|
|
114
|
+
mime_type=file_data.get("mimeType", "application/octet-stream"),
|
|
115
|
+
data=base64.b64decode(bytes_data) if bytes_data else None,
|
|
116
|
+
uri=file_data.get("uri"),
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
@classmethod
|
|
120
|
+
def from_path(cls, path: Union[str, Path], mime_type: Optional[str] = None) -> "FilePart":
|
|
121
|
+
"""Create FilePart from a local file path."""
|
|
122
|
+
path = Path(path)
|
|
123
|
+
if mime_type is None:
|
|
124
|
+
import mimetypes
|
|
125
|
+
mime_type = mimetypes.guess_type(str(path))[0] or "application/octet-stream"
|
|
126
|
+
return cls(
|
|
127
|
+
name=path.name,
|
|
128
|
+
mime_type=mime_type,
|
|
129
|
+
data=path.read_bytes(),
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass
|
|
134
|
+
class DataPart:
|
|
135
|
+
"""
|
|
136
|
+
Structured JSON data.
|
|
137
|
+
|
|
138
|
+
Example:
|
|
139
|
+
@agent.skill("analyze")
|
|
140
|
+
async def analyze(data: DataPart) -> DataPart:
|
|
141
|
+
result = process(data.data)
|
|
142
|
+
return DataPart(data=result)
|
|
143
|
+
"""
|
|
144
|
+
data: Dict[str, Any]
|
|
145
|
+
mime_type: str = "application/json"
|
|
146
|
+
|
|
147
|
+
def to_a2a(self) -> Dict[str, Any]:
|
|
148
|
+
return {
|
|
149
|
+
"type": "data",
|
|
150
|
+
"data": self.data,
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
@classmethod
|
|
154
|
+
def from_a2a(cls, data: Dict) -> "DataPart":
|
|
155
|
+
return cls(data=data.get("data", {}))
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@dataclass
|
|
159
|
+
class Artifact:
|
|
160
|
+
"""
|
|
161
|
+
Rich output artifact.
|
|
162
|
+
|
|
163
|
+
Use when you need more than just text/JSON return.
|
|
164
|
+
|
|
165
|
+
Example:
|
|
166
|
+
@agent.skill("generate_report")
|
|
167
|
+
async def generate_report(query: str) -> Artifact:
|
|
168
|
+
return Artifact(
|
|
169
|
+
name="report.pdf",
|
|
170
|
+
parts=[
|
|
171
|
+
TextPart("Summary: ..."),
|
|
172
|
+
FilePart.from_path("report.pdf"),
|
|
173
|
+
],
|
|
174
|
+
)
|
|
175
|
+
"""
|
|
176
|
+
name: Optional[str] = None
|
|
177
|
+
description: Optional[str] = None
|
|
178
|
+
parts: list = field(default_factory=list)
|
|
179
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
180
|
+
|
|
181
|
+
def add_text(self, text: str) -> "Artifact":
|
|
182
|
+
"""Add text to artifact."""
|
|
183
|
+
self.parts.append(TextPart(text=text))
|
|
184
|
+
return self
|
|
185
|
+
|
|
186
|
+
def add_file(self, file: FilePart) -> "Artifact":
|
|
187
|
+
"""Add file to artifact."""
|
|
188
|
+
self.parts.append(file)
|
|
189
|
+
return self
|
|
190
|
+
|
|
191
|
+
def add_data(self, data: Dict[str, Any]) -> "Artifact":
|
|
192
|
+
"""Add structured data to artifact."""
|
|
193
|
+
self.parts.append(DataPart(data=data))
|
|
194
|
+
return self
|
|
195
|
+
|
|
196
|
+
def to_a2a(self) -> Dict[str, Any]:
|
|
197
|
+
return {
|
|
198
|
+
"name": self.name,
|
|
199
|
+
"description": self.description,
|
|
200
|
+
"parts": [p.to_a2a() for p in self.parts],
|
|
201
|
+
"metadata": self.metadata,
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# Helper to parse incoming parts
|
|
206
|
+
def parse_part(data: Dict) -> Union[TextPart, FilePart, DataPart]:
|
|
207
|
+
"""Parse an A2A part dict into the appropriate Part type."""
|
|
208
|
+
part_type = data.get("type") or data.get("kind")
|
|
209
|
+
|
|
210
|
+
if part_type == "text":
|
|
211
|
+
return TextPart.from_a2a(data)
|
|
212
|
+
elif part_type == "file":
|
|
213
|
+
return FilePart.from_a2a(data)
|
|
214
|
+
elif part_type == "data":
|
|
215
|
+
return DataPart.from_a2a(data)
|
|
216
|
+
else:
|
|
217
|
+
# Default to text
|
|
218
|
+
return TextPart(text=str(data))
|