a2a-adapter 0.1.3__py3-none-any.whl → 0.1.4__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.
@@ -3,140 +3,583 @@ CrewAI adapter for A2A Protocol.
3
3
 
4
4
  This adapter enables CrewAI crews to be exposed as A2A-compliant agents
5
5
  by translating A2A messages to crew inputs and crew outputs back to A2A.
6
+
7
+ Supports two modes:
8
+ - Synchronous (default): Blocks until crew completes, returns Message
9
+ - Async Task Mode: Returns Task immediately, processes in background, supports polling
6
10
  """
7
11
 
12
+ import asyncio
8
13
  import json
14
+ import logging
15
+ import uuid
16
+ from datetime import datetime, timezone
9
17
  from typing import Any, Dict
10
18
 
11
- from a2a.types import Message, MessageSendParams, Task, TextPart
19
+ from a2a.types import (
20
+ Message,
21
+ MessageSendParams,
22
+ Task,
23
+ TaskState,
24
+ TaskStatus,
25
+ TextPart,
26
+ Role,
27
+ Part,
28
+ )
29
+ from ..adapter import BaseAgentAdapter
30
+
31
+ # Lazy import for TaskStore to avoid hard dependency
32
+ try:
33
+ from a2a.server.tasks import TaskStore, InMemoryTaskStore
34
+ _HAS_TASK_STORE = True
35
+ except ImportError:
36
+ _HAS_TASK_STORE = False
37
+ TaskStore = None # type: ignore
38
+ InMemoryTaskStore = None # type: ignore
12
39
 
40
+ logger = logging.getLogger(__name__)
13
41
 
14
- class CrewAIAgentAdapter:
42
+
43
+ class CrewAIAgentAdapter(BaseAgentAdapter):
15
44
  """
16
45
  Adapter for integrating CrewAI crews as A2A agents.
17
-
46
+
18
47
  This adapter handles the translation between A2A protocol messages
19
48
  and CrewAI's crew execution model.
49
+
50
+ Supports two execution modes:
51
+
52
+ 1. **Synchronous Mode** (default):
53
+ - Blocks until the crew completes execution
54
+ - Returns a Message with the crew result
55
+ - Best for quick crews (< 30 seconds)
56
+
57
+ 2. **Async Task Mode** (async_mode=True):
58
+ - Returns a Task with state="working" immediately
59
+ - Processes the crew execution in the background
60
+ - Clients can poll get_task() for status updates
61
+ - Best for long-running crews
62
+
63
+ Example:
64
+ >>> from crewai import Crew, Agent, Task as CrewTask
65
+ >>>
66
+ >>> researcher = Agent(role="Researcher", ...)
67
+ >>> task = CrewTask(description="Research topic", agent=researcher)
68
+ >>> crew = Crew(agents=[researcher], tasks=[task])
69
+ >>>
70
+ >>> adapter = CrewAIAgentAdapter(crew=crew)
20
71
  """
21
72
 
22
73
  def __init__(
23
74
  self,
24
75
  crew: Any, # Type: crewai.Crew (avoiding hard dependency)
25
76
  inputs_key: str = "inputs",
77
+ async_mode: bool = False,
78
+ task_store: "TaskStore | None" = None,
79
+ async_timeout: int = 600, # 10 minutes default for crews
26
80
  ):
27
81
  """
28
82
  Initialize the CrewAI adapter.
29
-
83
+
30
84
  Args:
31
85
  crew: A CrewAI Crew instance to execute
32
86
  inputs_key: The key name for passing inputs to the crew (default: "inputs")
87
+ async_mode: If True, return Task immediately and process in background.
88
+ If False (default), block until crew completes.
89
+ task_store: Optional TaskStore for persisting task state. If not provided
90
+ and async_mode is True, uses InMemoryTaskStore.
91
+ async_timeout: Timeout for async task execution in seconds (default: 600).
33
92
  """
34
93
  self.crew = crew
35
94
  self.inputs_key = inputs_key
36
95
 
96
+ # Async task mode configuration
97
+ self.async_mode = async_mode
98
+ self.async_timeout = async_timeout
99
+ self._background_tasks: Dict[str, "asyncio.Task[None]"] = {}
100
+ self._cancelled_tasks: set[str] = set()
101
+
102
+ # Initialize task store for async mode
103
+ if async_mode:
104
+ if not _HAS_TASK_STORE:
105
+ raise ImportError(
106
+ "Async task mode requires the A2A SDK with task support. "
107
+ "Install with: pip install a2a-sdk"
108
+ )
109
+ self.task_store: "TaskStore" = task_store or InMemoryTaskStore()
110
+ else:
111
+ self.task_store = task_store # type: ignore
112
+
37
113
  async def handle(self, params: MessageSendParams) -> Message | Task:
38
- """Handle a non-streaming A2A message request."""
114
+ """
115
+ Handle a non-streaming A2A message request.
116
+
117
+ In sync mode (default): Blocks until crew completes, returns Message.
118
+ In async mode: Returns Task immediately, processes in background.
119
+ """
120
+ if self.async_mode:
121
+ return await self._handle_async(params)
122
+ else:
123
+ return await self._handle_sync(params)
124
+
125
+ async def _handle_sync(self, params: MessageSendParams) -> Message:
126
+ """Handle request synchronously - blocks until crew completes."""
39
127
  framework_input = await self.to_framework(params)
40
128
  framework_output = await self.call_framework(framework_input, params)
41
- return await self.from_framework(framework_output, params)
129
+ result = await self.from_framework(framework_output, params)
130
+
131
+ # In sync mode, always return Message
132
+ if isinstance(result, Task):
133
+ if result.status and result.status.message:
134
+ return result.status.message
135
+ return Message(
136
+ role=Role.agent,
137
+ message_id=str(uuid.uuid4()),
138
+ context_id=result.context_id,
139
+ parts=[Part(root=TextPart(text="Crew completed"))],
140
+ )
141
+ return result
142
+
143
+ async def _handle_async(self, params: MessageSendParams) -> Task:
144
+ """
145
+ Handle request asynchronously - returns Task immediately, processes in background.
146
+ """
147
+ # Generate IDs
148
+ task_id = str(uuid.uuid4())
149
+ context_id = self._extract_context_id(params) or str(uuid.uuid4())
150
+
151
+ # Extract the initial message for history
152
+ initial_message = None
153
+ if hasattr(params, "message") and params.message:
154
+ initial_message = params.message
155
+
156
+ # Create initial task with "working" state
157
+ now = datetime.now(timezone.utc).isoformat()
158
+ task = Task(
159
+ id=task_id,
160
+ context_id=context_id,
161
+ status=TaskStatus(
162
+ state=TaskState.working,
163
+ timestamp=now,
164
+ ),
165
+ history=[initial_message] if initial_message else None,
166
+ )
167
+
168
+ # Save initial task state
169
+ await self.task_store.save(task)
170
+ logger.debug("Created async task %s with state=working", task_id)
171
+
172
+ # Start background processing with timeout
173
+ bg_task = asyncio.create_task(
174
+ self._execute_crew_with_timeout(task_id, context_id, params)
175
+ )
176
+ self._background_tasks[task_id] = bg_task
177
+
178
+ # Clean up background task reference when done
179
+ def _on_task_done(t: "asyncio.Task[None]") -> None:
180
+ self._background_tasks.pop(task_id, None)
181
+ self._cancelled_tasks.discard(task_id)
182
+ if not t.cancelled():
183
+ exc = t.exception()
184
+ if exc:
185
+ logger.error(
186
+ "Unhandled exception in background task %s: %s",
187
+ task_id,
188
+ exc,
189
+ )
190
+
191
+ bg_task.add_done_callback(_on_task_done)
192
+
193
+ return task
194
+
195
+ async def _execute_crew_with_timeout(
196
+ self,
197
+ task_id: str,
198
+ context_id: str,
199
+ params: MessageSendParams,
200
+ ) -> None:
201
+ """Execute the crew with a timeout wrapper."""
202
+ try:
203
+ await asyncio.wait_for(
204
+ self._execute_crew_background(task_id, context_id, params),
205
+ timeout=self.async_timeout,
206
+ )
207
+ except asyncio.TimeoutError:
208
+ if task_id in self._cancelled_tasks:
209
+ logger.debug("Task %s was cancelled, not marking as failed", task_id)
210
+ return
211
+
212
+ logger.error("Task %s timed out after %s seconds", task_id, self.async_timeout)
213
+ now = datetime.now(timezone.utc).isoformat()
214
+ error_message = Message(
215
+ role=Role.agent,
216
+ message_id=str(uuid.uuid4()),
217
+ context_id=context_id,
218
+ parts=[Part(root=TextPart(text=f"Crew timed out after {self.async_timeout} seconds"))],
219
+ )
220
+
221
+ timeout_task = Task(
222
+ id=task_id,
223
+ context_id=context_id,
224
+ status=TaskStatus(
225
+ state=TaskState.failed,
226
+ message=error_message,
227
+ timestamp=now,
228
+ ),
229
+ )
230
+ await self.task_store.save(timeout_task)
231
+
232
+ async def _execute_crew_background(
233
+ self,
234
+ task_id: str,
235
+ context_id: str,
236
+ params: MessageSendParams,
237
+ ) -> None:
238
+ """Execute the CrewAI crew in the background and update task state."""
239
+ try:
240
+ logger.debug("Starting background execution for task %s", task_id)
241
+
242
+ # Execute the crew
243
+ framework_input = await self.to_framework(params)
244
+ framework_output = await self.call_framework(framework_input, params)
245
+
246
+ # Check if task was cancelled during execution
247
+ if task_id in self._cancelled_tasks:
248
+ logger.debug("Task %s was cancelled during execution", task_id)
249
+ return
250
+
251
+ # Convert to message
252
+ response_text = self._extract_output_text(framework_output)
253
+ response_message = Message(
254
+ role=Role.agent,
255
+ message_id=str(uuid.uuid4()),
256
+ context_id=context_id,
257
+ parts=[Part(root=TextPart(text=response_text))],
258
+ )
259
+
260
+ # Build history
261
+ history = []
262
+ if hasattr(params, "message") and params.message:
263
+ history.append(params.message)
264
+ history.append(response_message)
265
+
266
+ # Update task to completed state
267
+ now = datetime.now(timezone.utc).isoformat()
268
+ completed_task = Task(
269
+ id=task_id,
270
+ context_id=context_id,
271
+ status=TaskStatus(
272
+ state=TaskState.completed,
273
+ message=response_message,
274
+ timestamp=now,
275
+ ),
276
+ history=history,
277
+ )
278
+
279
+ await self.task_store.save(completed_task)
280
+ logger.debug("Task %s completed successfully", task_id)
281
+
282
+ except asyncio.CancelledError:
283
+ logger.debug("Task %s was cancelled", task_id)
284
+ raise
285
+
286
+ except Exception as e:
287
+ if task_id in self._cancelled_tasks:
288
+ logger.debug("Task %s was cancelled, not marking as failed", task_id)
289
+ return
290
+
291
+ logger.error("Task %s failed: %s", task_id, e)
292
+ now = datetime.now(timezone.utc).isoformat()
293
+ error_message = Message(
294
+ role=Role.agent,
295
+ message_id=str(uuid.uuid4()),
296
+ context_id=context_id,
297
+ parts=[Part(root=TextPart(text=f"Crew failed: {str(e)}"))],
298
+ )
299
+
300
+ failed_task = Task(
301
+ id=task_id,
302
+ context_id=context_id,
303
+ status=TaskStatus(
304
+ state=TaskState.failed,
305
+ message=error_message,
306
+ timestamp=now,
307
+ ),
308
+ )
309
+
310
+ await self.task_store.save(failed_task)
311
+
312
+ # ---------- Input mapping ----------
42
313
 
43
314
  async def to_framework(self, params: MessageSendParams) -> Dict[str, Any]:
44
315
  """
45
316
  Convert A2A message parameters to CrewAI crew inputs.
46
-
317
+
47
318
  Extracts the user's message and prepares it as input for the crew.
48
-
319
+
49
320
  Args:
50
321
  params: A2A message parameters
51
-
322
+
52
323
  Returns:
53
324
  Dictionary with crew input data
54
325
  """
55
- # Extract text from the last user message
56
326
  user_message = ""
57
- if params.messages:
58
- last_message = params.messages[-1]
59
- if hasattr(last_message, "content"):
60
- if isinstance(last_message.content, list):
61
- # Extract text from content blocks
62
- text_parts = [
63
- item.text
64
- for item in last_message.content
65
- if hasattr(item, "text")
66
- ]
67
- user_message = " ".join(text_parts)
68
- elif isinstance(last_message.content, str):
69
- user_message = last_message.content
327
+
328
+ # Extract message from A2A params (new format with message.parts)
329
+ if hasattr(params, "message") and params.message:
330
+ msg = params.message
331
+ if hasattr(msg, "parts") and msg.parts:
332
+ text_parts = []
333
+ for part in msg.parts:
334
+ # Handle Part(root=TextPart(...)) structure
335
+ if hasattr(part, "root") and hasattr(part.root, "text"):
336
+ text_parts.append(part.root.text)
337
+ # Handle direct TextPart
338
+ elif hasattr(part, "text"):
339
+ text_parts.append(part.text)
340
+ user_message = self._join_text_parts(text_parts)
341
+
342
+ # Legacy support for messages array (deprecated)
343
+ elif getattr(params, "messages", None):
344
+ last = params.messages[-1]
345
+ content = getattr(last, "content", "")
346
+ if isinstance(content, str):
347
+ user_message = content.strip()
348
+ elif isinstance(content, list):
349
+ text_parts = []
350
+ for item in content:
351
+ txt = getattr(item, "text", None)
352
+ if txt and isinstance(txt, str) and txt.strip():
353
+ text_parts.append(txt.strip())
354
+ user_message = self._join_text_parts(text_parts)
355
+
356
+ # Extract context_id from the message
357
+ context_id = self._extract_context_id(params)
70
358
 
71
359
  # Build crew inputs
72
360
  # CrewAI typically expects a dict with task-specific keys
73
361
  return {
74
362
  self.inputs_key: user_message,
75
363
  "message": user_message,
76
- "session_id": getattr(params, "session_id", None),
364
+ "context_id": context_id,
77
365
  }
78
366
 
367
+ @staticmethod
368
+ def _join_text_parts(parts: list[str]) -> str:
369
+ """Join text parts into a single string."""
370
+ if not parts:
371
+ return ""
372
+ text = " ".join(p.strip() for p in parts if p)
373
+ return text.strip()
374
+
375
+ def _extract_context_id(self, params: MessageSendParams) -> str | None:
376
+ """Extract context_id from MessageSendParams."""
377
+ if hasattr(params, "message") and params.message:
378
+ return getattr(params.message, "context_id", None)
379
+ return None
380
+
381
+ # ---------- Framework call ----------
382
+
79
383
  async def call_framework(
80
384
  self, framework_input: Dict[str, Any], params: MessageSendParams
81
385
  ) -> Any:
82
386
  """
83
387
  Execute the CrewAI crew with the provided inputs.
84
-
388
+
85
389
  Args:
86
390
  framework_input: Input dictionary for the crew
87
391
  params: Original A2A parameters (for context)
88
-
392
+
89
393
  Returns:
90
394
  CrewAI crew execution output
91
-
395
+
92
396
  Raises:
93
397
  Exception: If crew execution fails
94
398
  """
399
+ logger.debug("Executing CrewAI crew with inputs: %s", framework_input)
400
+
95
401
  # CrewAI supports async execution via kickoff_async
96
402
  try:
97
403
  result = await self.crew.kickoff_async(inputs=framework_input)
404
+ logger.debug("CrewAI crew returned: %s", type(result).__name__)
98
405
  return result
99
406
  except AttributeError:
100
407
  # Fallback for older CrewAI versions without async support
101
408
  # Note: This will block the event loop
102
- import asyncio
103
-
409
+ logger.warning("CrewAI kickoff_async not available, using sync fallback")
104
410
  loop = asyncio.get_event_loop()
105
411
  result = await loop.run_in_executor(
106
- None, self.crew.kickoff, framework_input
412
+ None, lambda: self.crew.kickoff(inputs=framework_input)
107
413
  )
108
414
  return result
109
415
 
416
+ # ---------- Output mapping ----------
417
+
110
418
  async def from_framework(
111
419
  self, framework_output: Any, params: MessageSendParams
112
420
  ) -> Message | Task:
113
421
  """
114
422
  Convert CrewAI crew output to A2A Message.
115
-
423
+
116
424
  Args:
117
425
  framework_output: Output from crew execution
118
426
  params: Original A2A parameters
119
-
427
+
120
428
  Returns:
121
429
  A2A Message with the crew's response
122
430
  """
123
- # CrewAI output can be various types (string, dict, CrewOutput object)
124
- if hasattr(framework_output, "raw"):
125
- # CrewOutput object
126
- response_text = str(framework_output.raw)
127
- elif isinstance(framework_output, dict):
128
- # Dictionary output - serialize as JSON
129
- response_text = json.dumps(framework_output, indent=2)
130
- else:
131
- # String or other type - convert to string
132
- response_text = str(framework_output)
431
+ response_text = self._extract_output_text(framework_output)
432
+ context_id = self._extract_context_id(params)
133
433
 
134
434
  return Message(
135
- role="assistant",
136
- content=[TextPart(type="text", text=response_text)],
435
+ role=Role.agent,
436
+ message_id=str(uuid.uuid4()),
437
+ context_id=context_id,
438
+ parts=[Part(root=TextPart(text=response_text))],
137
439
  )
138
440
 
441
+ def _extract_output_text(self, framework_output: Any) -> str:
442
+ """
443
+ Extract text content from CrewAI output.
444
+
445
+ Args:
446
+ framework_output: Output from the crew
447
+
448
+ Returns:
449
+ Extracted text string
450
+ """
451
+ # CrewOutput object with raw attribute
452
+ if hasattr(framework_output, "raw"):
453
+ return str(framework_output.raw)
454
+
455
+ # CrewOutput object with result attribute
456
+ if hasattr(framework_output, "result"):
457
+ return str(framework_output.result)
458
+
459
+ # Dictionary output
460
+ if isinstance(framework_output, dict):
461
+ for key in ["output", "result", "response", "answer", "text"]:
462
+ if key in framework_output:
463
+ return str(framework_output[key])
464
+ # Fallback: serialize as JSON
465
+ return json.dumps(framework_output, indent=2)
466
+
467
+ # String or other type - convert to string
468
+ return str(framework_output)
469
+
470
+ # ---------- Async Task Support ----------
471
+
472
+ def supports_async_tasks(self) -> bool:
473
+ """Check if this adapter supports async task execution."""
474
+ return self.async_mode
475
+
476
+ async def get_task(self, task_id: str) -> Task | None:
477
+ """
478
+ Get the current status of a task by ID.
479
+
480
+ Args:
481
+ task_id: The ID of the task to retrieve
482
+
483
+ Returns:
484
+ The Task object with current status, or None if not found
485
+
486
+ Raises:
487
+ RuntimeError: If async mode is not enabled
488
+ """
489
+ if not self.async_mode:
490
+ raise RuntimeError(
491
+ "get_task() is only available in async mode. "
492
+ "Initialize adapter with async_mode=True"
493
+ )
494
+
495
+ task = await self.task_store.get(task_id)
496
+ if task:
497
+ logger.debug("Retrieved task %s with state=%s", task_id, task.status.state)
498
+ else:
499
+ logger.debug("Task %s not found", task_id)
500
+ return task
501
+
502
+ async def cancel_task(self, task_id: str) -> Task | None:
503
+ """
504
+ Attempt to cancel a running task.
505
+
506
+ Note: CrewAI crews cannot be gracefully interrupted once started.
507
+ This will mark the task as cancelled but the crew may continue running.
508
+
509
+ Args:
510
+ task_id: The ID of the task to cancel
511
+
512
+ Returns:
513
+ The updated Task object with state="canceled", or None if not found
514
+ """
515
+ if not self.async_mode:
516
+ raise RuntimeError(
517
+ "cancel_task() is only available in async mode. "
518
+ "Initialize adapter with async_mode=True"
519
+ )
520
+
521
+ # Mark task as cancelled to prevent race conditions
522
+ self._cancelled_tasks.add(task_id)
523
+
524
+ # Cancel the background task if still running
525
+ bg_task = self._background_tasks.get(task_id)
526
+ if bg_task and not bg_task.done():
527
+ bg_task.cancel()
528
+ logger.debug("Cancelling background task for %s", task_id)
529
+ try:
530
+ await bg_task
531
+ except asyncio.CancelledError:
532
+ pass
533
+ except Exception:
534
+ pass
535
+
536
+ # Update task state to canceled
537
+ task = await self.task_store.get(task_id)
538
+ if task:
539
+ now = datetime.now(timezone.utc).isoformat()
540
+ canceled_task = Task(
541
+ id=task_id,
542
+ context_id=task.context_id,
543
+ status=TaskStatus(
544
+ state=TaskState.canceled,
545
+ timestamp=now,
546
+ ),
547
+ history=task.history,
548
+ )
549
+ await self.task_store.save(canceled_task)
550
+ logger.debug("Task %s marked as canceled", task_id)
551
+ return canceled_task
552
+
553
+ return None
554
+
555
+ # ---------- Lifecycle ----------
556
+
557
+ async def close(self) -> None:
558
+ """Cancel pending background tasks."""
559
+ for task_id in self._background_tasks:
560
+ self._cancelled_tasks.add(task_id)
561
+
562
+ tasks_to_cancel = []
563
+ for task_id, bg_task in list(self._background_tasks.items()):
564
+ if not bg_task.done():
565
+ bg_task.cancel()
566
+ tasks_to_cancel.append(bg_task)
567
+ logger.debug("Cancelling background task %s during close", task_id)
568
+
569
+ if tasks_to_cancel:
570
+ await asyncio.gather(*tasks_to_cancel, return_exceptions=True)
571
+
572
+ self._background_tasks.clear()
573
+ self._cancelled_tasks.clear()
574
+
575
+ async def __aenter__(self):
576
+ """Async context manager entry."""
577
+ return self
578
+
579
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
580
+ """Async context manager exit."""
581
+ await self.close()
582
+
139
583
  def supports_streaming(self) -> bool:
140
- """Check if this adapter supports streaming responses."""
584
+ """CrewAI does not support streaming responses."""
141
585
  return False
142
-