a2a-adapter 0.1.2__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.2.dist-info → a2a_adapter-0.1.4.dist-info}/METADATA +96 -43
- a2a_adapter-0.1.4.dist-info/RECORD +15 -0
- {a2a_adapter-0.1.2.dist-info → a2a_adapter-0.1.4.dist-info}/WHEEL +1 -1
- a2a_adapter-0.1.2.dist-info/RECORD +0 -14
- {a2a_adapter-0.1.2.dist-info → a2a_adapter-0.1.4.dist-info}/licenses/LICENSE +0 -0
- {a2a_adapter-0.1.2.dist-info → a2a_adapter-0.1.4.dist-info}/top_level.txt +0 -0
a2a_adapter/__init__.py
CHANGED
|
@@ -5,6 +5,7 @@ This package contains concrete adapter implementations for various agent framewo
|
|
|
5
5
|
- n8n: HTTP webhook-based workflows
|
|
6
6
|
- CrewAI: Multi-agent collaboration framework
|
|
7
7
|
- LangChain: LLM application framework with LCEL support
|
|
8
|
+
- LangGraph: Stateful workflow orchestration framework
|
|
8
9
|
- Callable: Generic Python async function adapter
|
|
9
10
|
"""
|
|
10
11
|
|
|
@@ -12,6 +13,7 @@ __all__ = [
|
|
|
12
13
|
"N8nAgentAdapter",
|
|
13
14
|
"CrewAIAgentAdapter",
|
|
14
15
|
"LangChainAgentAdapter",
|
|
16
|
+
"LangGraphAgentAdapter",
|
|
15
17
|
"CallableAgentAdapter",
|
|
16
18
|
]
|
|
17
19
|
|
|
@@ -26,8 +28,10 @@ def __getattr__(name: str):
|
|
|
26
28
|
elif name == "LangChainAgentAdapter":
|
|
27
29
|
from .langchain import LangChainAgentAdapter
|
|
28
30
|
return LangChainAgentAdapter
|
|
31
|
+
elif name == "LangGraphAgentAdapter":
|
|
32
|
+
from .langgraph import LangGraphAgentAdapter
|
|
33
|
+
return LangGraphAgentAdapter
|
|
29
34
|
elif name == "CallableAgentAdapter":
|
|
30
35
|
from .callable import CallableAgentAdapter
|
|
31
36
|
return CallableAgentAdapter
|
|
32
37
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
33
|
-
|
|
@@ -6,18 +6,51 @@ agent, providing maximum flexibility for custom implementations.
|
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
import json
|
|
9
|
+
import logging
|
|
10
|
+
import uuid
|
|
9
11
|
from typing import Any, AsyncIterator, Callable, Dict
|
|
10
12
|
|
|
11
|
-
from a2a.types import
|
|
13
|
+
from a2a.types import (
|
|
14
|
+
Message,
|
|
15
|
+
MessageSendParams,
|
|
16
|
+
Task,
|
|
17
|
+
TextPart,
|
|
18
|
+
Role,
|
|
19
|
+
Part,
|
|
20
|
+
)
|
|
21
|
+
from ..adapter import BaseAgentAdapter
|
|
12
22
|
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
13
24
|
|
|
14
|
-
|
|
25
|
+
|
|
26
|
+
class CallableAgentAdapter(BaseAgentAdapter):
|
|
15
27
|
"""
|
|
16
28
|
Adapter for integrating custom async functions as A2A agents.
|
|
17
|
-
|
|
29
|
+
|
|
18
30
|
This adapter wraps any async callable (function, coroutine) and handles
|
|
19
31
|
the A2A protocol translation. The callable should accept a dictionary
|
|
20
32
|
input and return either a string or dictionary output.
|
|
33
|
+
|
|
34
|
+
For streaming support, the callable should be an async generator that
|
|
35
|
+
yields string chunks.
|
|
36
|
+
|
|
37
|
+
Example (non-streaming):
|
|
38
|
+
>>> async def my_agent(inputs: dict) -> str:
|
|
39
|
+
... message = inputs["message"]
|
|
40
|
+
... return f"Processed: {message}"
|
|
41
|
+
>>>
|
|
42
|
+
>>> adapter = CallableAgentAdapter(func=my_agent)
|
|
43
|
+
|
|
44
|
+
Example (streaming):
|
|
45
|
+
>>> async def my_streaming_agent(inputs: dict):
|
|
46
|
+
... message = inputs["message"]
|
|
47
|
+
... for word in message.split():
|
|
48
|
+
... yield word + " "
|
|
49
|
+
>>>
|
|
50
|
+
>>> adapter = CallableAgentAdapter(
|
|
51
|
+
... func=my_streaming_agent,
|
|
52
|
+
... supports_streaming=True
|
|
53
|
+
... )
|
|
21
54
|
"""
|
|
22
55
|
|
|
23
56
|
def __init__(
|
|
@@ -27,7 +60,7 @@ class CallableAgentAdapter:
|
|
|
27
60
|
):
|
|
28
61
|
"""
|
|
29
62
|
Initialize the callable adapter.
|
|
30
|
-
|
|
63
|
+
|
|
31
64
|
Args:
|
|
32
65
|
func: An async callable that processes the agent logic.
|
|
33
66
|
For non-streaming: Should accept Dict[str, Any] and return str or Dict.
|
|
@@ -37,136 +70,217 @@ class CallableAgentAdapter:
|
|
|
37
70
|
self.func = func
|
|
38
71
|
self._supports_streaming = supports_streaming
|
|
39
72
|
|
|
40
|
-
|
|
41
|
-
"""Handle a non-streaming A2A message request."""
|
|
42
|
-
framework_input = await self.to_framework(params)
|
|
43
|
-
framework_output = await self.call_framework(framework_input, params)
|
|
44
|
-
return await self.from_framework(framework_output, params)
|
|
45
|
-
|
|
46
|
-
async def handle_stream(
|
|
47
|
-
self, params: MessageSendParams
|
|
48
|
-
) -> AsyncIterator[Dict[str, Any]]:
|
|
49
|
-
"""
|
|
50
|
-
Handle a streaming A2A message request.
|
|
51
|
-
|
|
52
|
-
The wrapped function must be an async generator for streaming to work.
|
|
53
|
-
"""
|
|
54
|
-
if not self._supports_streaming:
|
|
55
|
-
raise NotImplementedError(
|
|
56
|
-
"CallableAgentAdapter: streaming not enabled for this function"
|
|
57
|
-
)
|
|
58
|
-
|
|
59
|
-
framework_input = await self.to_framework(params)
|
|
60
|
-
|
|
61
|
-
# Call the async generator function
|
|
62
|
-
async for chunk in self.func(framework_input):
|
|
63
|
-
# Convert chunk to string if needed
|
|
64
|
-
text = str(chunk) if not isinstance(chunk, str) else chunk
|
|
65
|
-
|
|
66
|
-
# Yield SSE-compatible event
|
|
67
|
-
if text:
|
|
68
|
-
yield {
|
|
69
|
-
"event": "message",
|
|
70
|
-
"data": json.dumps({
|
|
71
|
-
"type": "content",
|
|
72
|
-
"content": text,
|
|
73
|
-
}),
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
# Send completion event
|
|
77
|
-
yield {
|
|
78
|
-
"event": "done",
|
|
79
|
-
"data": json.dumps({"status": "completed"}),
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
def supports_streaming(self) -> bool:
|
|
83
|
-
"""Check if this adapter supports streaming."""
|
|
84
|
-
return self._supports_streaming
|
|
73
|
+
# ---------- Input mapping ----------
|
|
85
74
|
|
|
86
75
|
async def to_framework(self, params: MessageSendParams) -> Dict[str, Any]:
|
|
87
76
|
"""
|
|
88
77
|
Convert A2A message parameters to a dictionary for the callable.
|
|
89
|
-
|
|
78
|
+
|
|
79
|
+
Extracts the user's message and relevant metadata.
|
|
80
|
+
|
|
90
81
|
Args:
|
|
91
82
|
params: A2A message parameters
|
|
92
|
-
|
|
83
|
+
|
|
93
84
|
Returns:
|
|
94
85
|
Dictionary with input data for the callable
|
|
95
86
|
"""
|
|
96
|
-
# Extract text from the last user message
|
|
97
87
|
user_message = ""
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
88
|
+
|
|
89
|
+
# Extract message from A2A params (new format with message.parts)
|
|
90
|
+
if hasattr(params, "message") and params.message:
|
|
91
|
+
msg = params.message
|
|
92
|
+
if hasattr(msg, "parts") and msg.parts:
|
|
93
|
+
text_parts = []
|
|
94
|
+
for part in msg.parts:
|
|
95
|
+
# Handle Part(root=TextPart(...)) structure
|
|
96
|
+
if hasattr(part, "root") and hasattr(part.root, "text"):
|
|
97
|
+
text_parts.append(part.root.text)
|
|
98
|
+
# Handle direct TextPart
|
|
99
|
+
elif hasattr(part, "text"):
|
|
100
|
+
text_parts.append(part.text)
|
|
101
|
+
user_message = self._join_text_parts(text_parts)
|
|
102
|
+
|
|
103
|
+
# Legacy support for messages array (deprecated)
|
|
104
|
+
elif getattr(params, "messages", None):
|
|
105
|
+
last = params.messages[-1]
|
|
106
|
+
content = getattr(last, "content", "")
|
|
107
|
+
if isinstance(content, str):
|
|
108
|
+
user_message = content.strip()
|
|
109
|
+
elif isinstance(content, list):
|
|
110
|
+
text_parts = []
|
|
111
|
+
for item in content:
|
|
112
|
+
txt = getattr(item, "text", None)
|
|
113
|
+
if txt and isinstance(txt, str) and txt.strip():
|
|
114
|
+
text_parts.append(txt.strip())
|
|
115
|
+
user_message = self._join_text_parts(text_parts)
|
|
116
|
+
|
|
117
|
+
# Extract metadata
|
|
118
|
+
context_id = self._extract_context_id(params)
|
|
119
|
+
|
|
120
|
+
# Build input dictionary with useful fields
|
|
113
121
|
return {
|
|
114
122
|
"message": user_message,
|
|
115
|
-
"
|
|
116
|
-
"
|
|
117
|
-
"context": getattr(params, "context", None),
|
|
123
|
+
"context_id": context_id,
|
|
124
|
+
"params": params, # Full params for advanced use cases
|
|
118
125
|
}
|
|
119
126
|
|
|
127
|
+
@staticmethod
|
|
128
|
+
def _join_text_parts(parts: list[str]) -> str:
|
|
129
|
+
"""Join text parts into a single string."""
|
|
130
|
+
if not parts:
|
|
131
|
+
return ""
|
|
132
|
+
text = " ".join(p.strip() for p in parts if p)
|
|
133
|
+
return text.strip()
|
|
134
|
+
|
|
135
|
+
def _extract_context_id(self, params: MessageSendParams) -> str | None:
|
|
136
|
+
"""Extract context_id from MessageSendParams."""
|
|
137
|
+
if hasattr(params, "message") and params.message:
|
|
138
|
+
return getattr(params.message, "context_id", None)
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
# ---------- Framework call ----------
|
|
142
|
+
|
|
120
143
|
async def call_framework(
|
|
121
144
|
self, framework_input: Dict[str, Any], params: MessageSendParams
|
|
122
145
|
) -> Any:
|
|
123
146
|
"""
|
|
124
147
|
Execute the callable function with the provided input.
|
|
125
|
-
|
|
148
|
+
|
|
126
149
|
Args:
|
|
127
150
|
framework_input: Input dictionary for the function
|
|
128
151
|
params: Original A2A parameters (for context)
|
|
129
|
-
|
|
152
|
+
|
|
130
153
|
Returns:
|
|
131
154
|
Function execution output
|
|
132
|
-
|
|
155
|
+
|
|
133
156
|
Raises:
|
|
134
157
|
Exception: If function execution fails
|
|
135
158
|
"""
|
|
159
|
+
logger.debug("Invoking callable with input keys: %s", list(framework_input.keys()))
|
|
136
160
|
result = await self.func(framework_input)
|
|
161
|
+
logger.debug("Callable returned: %s", type(result).__name__)
|
|
137
162
|
return result
|
|
138
163
|
|
|
164
|
+
# ---------- Output mapping ----------
|
|
165
|
+
|
|
139
166
|
async def from_framework(
|
|
140
167
|
self, framework_output: Any, params: MessageSendParams
|
|
141
168
|
) -> Message | Task:
|
|
142
169
|
"""
|
|
143
170
|
Convert callable output to A2A Message.
|
|
144
|
-
|
|
171
|
+
|
|
145
172
|
Args:
|
|
146
173
|
framework_output: Output from the callable
|
|
147
174
|
params: Original A2A parameters
|
|
148
|
-
|
|
175
|
+
|
|
149
176
|
Returns:
|
|
150
177
|
A2A Message with the function's response
|
|
151
178
|
"""
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
# If output has a 'response' or 'output' key, use that
|
|
155
|
-
if "response" in framework_output:
|
|
156
|
-
response_text = str(framework_output["response"])
|
|
157
|
-
elif "output" in framework_output:
|
|
158
|
-
response_text = str(framework_output["output"])
|
|
159
|
-
else:
|
|
160
|
-
response_text = json.dumps(framework_output, indent=2)
|
|
161
|
-
else:
|
|
162
|
-
response_text = str(framework_output)
|
|
179
|
+
response_text = self._extract_output_text(framework_output)
|
|
180
|
+
context_id = self._extract_context_id(params)
|
|
163
181
|
|
|
164
182
|
return Message(
|
|
165
|
-
role=
|
|
166
|
-
|
|
183
|
+
role=Role.agent,
|
|
184
|
+
message_id=str(uuid.uuid4()),
|
|
185
|
+
context_id=context_id,
|
|
186
|
+
parts=[Part(root=TextPart(text=response_text))],
|
|
167
187
|
)
|
|
168
188
|
|
|
189
|
+
def _extract_output_text(self, framework_output: Any) -> str:
|
|
190
|
+
"""
|
|
191
|
+
Extract text content from callable output.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
framework_output: Output from the callable
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
Extracted text string
|
|
198
|
+
"""
|
|
199
|
+
# Dictionary output
|
|
200
|
+
if isinstance(framework_output, dict):
|
|
201
|
+
# Try common output keys
|
|
202
|
+
for key in ["response", "output", "result", "answer", "text", "message"]:
|
|
203
|
+
if key in framework_output:
|
|
204
|
+
return str(framework_output[key])
|
|
205
|
+
# Fallback: serialize as JSON
|
|
206
|
+
return json.dumps(framework_output, indent=2)
|
|
207
|
+
|
|
208
|
+
# String or other type - convert to string
|
|
209
|
+
return str(framework_output)
|
|
210
|
+
|
|
211
|
+
# ---------- Streaming support ----------
|
|
212
|
+
|
|
213
|
+
async def handle_stream(
|
|
214
|
+
self, params: MessageSendParams
|
|
215
|
+
) -> AsyncIterator[Dict[str, Any]]:
|
|
216
|
+
"""
|
|
217
|
+
Handle a streaming A2A message request.
|
|
218
|
+
|
|
219
|
+
The wrapped function must be an async generator for streaming to work.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
params: A2A message parameters
|
|
223
|
+
|
|
224
|
+
Yields:
|
|
225
|
+
Server-Sent Events compatible dictionaries with streaming chunks
|
|
226
|
+
|
|
227
|
+
Raises:
|
|
228
|
+
NotImplementedError: If streaming is not enabled for this adapter
|
|
229
|
+
"""
|
|
230
|
+
if not self._supports_streaming:
|
|
231
|
+
raise NotImplementedError(
|
|
232
|
+
"CallableAgentAdapter: streaming not enabled for this function. "
|
|
233
|
+
"Initialize with supports_streaming=True and provide an async generator."
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
framework_input = await self.to_framework(params)
|
|
237
|
+
context_id = self._extract_context_id(params)
|
|
238
|
+
message_id = str(uuid.uuid4())
|
|
239
|
+
|
|
240
|
+
logger.debug("Starting streaming call")
|
|
241
|
+
|
|
242
|
+
accumulated_text = ""
|
|
243
|
+
|
|
244
|
+
# Call the async generator function
|
|
245
|
+
async for chunk in self.func(framework_input):
|
|
246
|
+
# Convert chunk to string if needed
|
|
247
|
+
text = str(chunk) if not isinstance(chunk, str) else chunk
|
|
248
|
+
|
|
249
|
+
if text:
|
|
250
|
+
accumulated_text += text
|
|
251
|
+
# Yield SSE-compatible event
|
|
252
|
+
yield {
|
|
253
|
+
"event": "message",
|
|
254
|
+
"data": json.dumps({
|
|
255
|
+
"type": "content",
|
|
256
|
+
"content": text,
|
|
257
|
+
}),
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
# Send final message with complete response
|
|
261
|
+
final_message = Message(
|
|
262
|
+
role=Role.agent,
|
|
263
|
+
message_id=message_id,
|
|
264
|
+
context_id=context_id,
|
|
265
|
+
parts=[Part(root=TextPart(text=accumulated_text))],
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
# Send completion event
|
|
269
|
+
yield {
|
|
270
|
+
"event": "done",
|
|
271
|
+
"data": json.dumps({
|
|
272
|
+
"status": "completed",
|
|
273
|
+
"message": final_message.model_dump() if hasattr(final_message, "model_dump") else str(final_message),
|
|
274
|
+
}),
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
logger.debug("Streaming call completed")
|
|
278
|
+
|
|
169
279
|
def supports_streaming(self) -> bool:
|
|
170
|
-
"""
|
|
171
|
-
|
|
280
|
+
"""
|
|
281
|
+
Check if this adapter supports streaming.
|
|
172
282
|
|
|
283
|
+
Returns:
|
|
284
|
+
True if streaming is enabled, False otherwise
|
|
285
|
+
"""
|
|
286
|
+
return self._supports_streaming
|