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/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))