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.
- a2a_adapter/__init__.py +1 -1
- a2a_adapter/integrations/__init__.py +5 -1
- a2a_adapter/integrations/callable.py +204 -90
- a2a_adapter/integrations/crewai.py +489 -46
- a2a_adapter/integrations/langchain.py +248 -90
- a2a_adapter/integrations/langgraph.py +756 -0
- a2a_adapter/loader.py +71 -28
- {a2a_adapter-0.1.3.dist-info → a2a_adapter-0.1.4.dist-info}/METADATA +79 -26
- a2a_adapter-0.1.4.dist-info/RECORD +15 -0
- {a2a_adapter-0.1.3.dist-info → a2a_adapter-0.1.4.dist-info}/WHEEL +1 -1
- a2a_adapter-0.1.3.dist-info/RECORD +0 -14
- {a2a_adapter-0.1.3.dist-info → a2a_adapter-0.1.4.dist-info}/licenses/LICENSE +0 -0
- {a2a_adapter-0.1.3.dist-info → a2a_adapter-0.1.4.dist-info}/top_level.txt +0 -0
|
@@ -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
|
|
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
|
-
|
|
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
|
-
"""
|
|
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
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
124
|
-
|
|
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=
|
|
136
|
-
|
|
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
|
-
"""
|
|
584
|
+
"""CrewAI does not support streaming responses."""
|
|
141
585
|
return False
|
|
142
|
-
|