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 CHANGED
@@ -26,7 +26,7 @@ Example:
26
26
  >>> asyncio.run(main())
27
27
  """
28
28
 
29
- __version__ = "0.1.3"
29
+ __version__ = "0.1.4"
30
30
 
31
31
  from .adapter import BaseAgentAdapter
32
32
  from .client import build_agent_app, serve_agent
@@ -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 Message, MessageSendParams, Task, TextPart
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
- class CallableAgentAdapter:
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
- async def handle(self, params: MessageSendParams) -> Message | Task:
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
- if params.messages:
99
- last_message = params.messages[-1]
100
- if hasattr(last_message, "content"):
101
- if isinstance(last_message.content, list):
102
- # Extract text from content blocks
103
- text_parts = [
104
- item.text
105
- for item in last_message.content
106
- if hasattr(item, "text")
107
- ]
108
- user_message = " ".join(text_parts)
109
- elif isinstance(last_message.content, str):
110
- user_message = last_message.content
111
-
112
- # Build input dictionary
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
- "messages": params.messages,
116
- "session_id": getattr(params, "session_id", None),
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
- # Convert output to string
153
- if isinstance(framework_output, dict):
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="assistant",
166
- content=[TextPart(type="text", text=response_text)],
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
- """Check if this adapter supports streaming responses."""
171
- return False
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