agent-api-server 2.1.9__tar.gz → 2.1.11__tar.gz

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.
Files changed (83) hide show
  1. {agent_api_server-2.1.9 → agent_api_server-2.1.11}/PKG-INFO +2 -2
  2. {agent_api_server-2.1.9 → agent_api_server-2.1.11}/agent_api_server/a2a_bridge/agent_card_builder.py +2 -2
  3. {agent_api_server-2.1.9 → agent_api_server-2.1.11}/agent_api_server/a2a_bridge/call_a2a_client.py +68 -5
  4. {agent_api_server-2.1.9 → agent_api_server-2.1.11}/agent_api_server/a2a_bridge/langgraph_executor.py +177 -58
  5. {agent_api_server-2.1.9 → agent_api_server-2.1.11}/agent_api_server/acp_bridge/go/agent.go +19 -16
  6. {agent_api_server-2.1.9 → agent_api_server-2.1.11}/agent_api_server/acp_bridge/go/main.go +40 -143
  7. {agent_api_server-2.1.9 → agent_api_server-2.1.11}/agent_api_server/api/v1/thread.py +21 -97
  8. agent_api_server-2.1.11/agent_api_server/orchestrator/__init__.py +85 -0
  9. agent_api_server-2.1.11/agent_api_server/orchestrator/agent_registry.py +128 -0
  10. agent_api_server-2.1.11/agent_api_server/orchestrator/modes/__init__.py +16 -0
  11. agent_api_server-2.1.11/agent_api_server/orchestrator/modes/chain.py +503 -0
  12. agent_api_server-2.1.11/agent_api_server/orchestrator/modes/discussion.py +1319 -0
  13. agent_api_server-2.1.11/agent_api_server/orchestrator/orchestrator.py +234 -0
  14. agent_api_server-2.1.11/agent_api_server/orchestrator/types.py +171 -0
  15. {agent_api_server-2.1.9 → agent_api_server-2.1.11}/pyproject.toml +1 -1
  16. {agent_api_server-2.1.9 → agent_api_server-2.1.11}/README.md +0 -0
  17. {agent_api_server-2.1.9 → agent_api_server-2.1.11}/agent_api_server/__init__.py +0 -0
  18. {agent_api_server-2.1.9 → agent_api_server-2.1.11}/agent_api_server/a2a_bridge/__init__.py +0 -0
  19. {agent_api_server-2.1.9 → agent_api_server-2.1.11}/agent_api_server/a2a_bridge/redis_task_store.py +0 -0
  20. {agent_api_server-2.1.9 → agent_api_server-2.1.11}/agent_api_server/a2a_bridge/server.py +0 -0
  21. {agent_api_server-2.1.9 → agent_api_server-2.1.11}/agent_api_server/acp_bridge/go/Makefile +0 -0
  22. {agent_api_server-2.1.9 → agent_api_server-2.1.11}/agent_api_server/acp_bridge/go/go.mod +0 -0
  23. {agent_api_server-2.1.9 → agent_api_server-2.1.11}/agent_api_server/acp_bridge/go/go.sum +0 -0
  24. {agent_api_server-2.1.9 → agent_api_server-2.1.11}/agent_api_server/agent_registration.py +0 -0
  25. {agent_api_server-2.1.9 → agent_api_server-2.1.11}/agent_api_server/api/__init__.py +0 -0
  26. {agent_api_server-2.1.9 → agent_api_server-2.1.11}/agent_api_server/api/v1/__init__.py +0 -0
  27. {agent_api_server-2.1.9 → agent_api_server-2.1.11}/agent_api_server/api/v1/api.py +0 -0
  28. {agent_api_server-2.1.9 → agent_api_server-2.1.11}/agent_api_server/api/v1/config.py +0 -0
  29. {agent_api_server-2.1.9 → agent_api_server-2.1.11}/agent_api_server/api/v1/graph.py +0 -0
  30. {agent_api_server-2.1.9 → agent_api_server-2.1.11}/agent_api_server/api/v1/schema.py +0 -0
  31. {agent_api_server-2.1.9 → agent_api_server-2.1.11}/agent_api_server/app.py +0 -0
  32. {agent_api_server-2.1.9 → agent_api_server-2.1.11}/agent_api_server/cache/__init__.py +0 -0
  33. {agent_api_server-2.1.9 → agent_api_server-2.1.11}/agent_api_server/cache/redis_cache.py +0 -0
  34. {agent_api_server-2.1.9 → agent_api_server-2.1.11}/agent_api_server/callback_handler.py +0 -0
  35. {agent_api_server-2.1.9 → agent_api_server-2.1.11}/agent_api_server/client/css/styles.css +0 -0
  36. {agent_api_server-2.1.9 → agent_api_server-2.1.11}/agent_api_server/client/favicon.ico +0 -0
  37. {agent_api_server-2.1.9 → agent_api_server-2.1.11}/agent_api_server/client/index.html +0 -0
  38. {agent_api_server-2.1.9 → agent_api_server-2.1.11}/agent_api_server/client/js/app.js +0 -0
  39. {agent_api_server-2.1.9 → agent_api_server-2.1.11}/agent_api_server/client/js/index.umd.js +0 -0
  40. {agent_api_server-2.1.9 → agent_api_server-2.1.11}/agent_api_server/config_center/config_center.py +0 -0
  41. {agent_api_server-2.1.9 → agent_api_server-2.1.11}/agent_api_server/configs/__init__.py +0 -0
  42. {agent_api_server-2.1.9 → agent_api_server-2.1.11}/agent_api_server/configs/config.py +0 -0
  43. {agent_api_server-2.1.9 → agent_api_server-2.1.11}/agent_api_server/dynamic_llm/__init__.py +0 -0
  44. {agent_api_server-2.1.9 → agent_api_server-2.1.11}/agent_api_server/dynamic_llm/dynamic_llm.py +0 -0
  45. {agent_api_server-2.1.9 → agent_api_server-2.1.11}/agent_api_server/listener/__init__.py +0 -0
  46. {agent_api_server-2.1.9 → agent_api_server-2.1.11}/agent_api_server/listener/base.py +0 -0
  47. {agent_api_server-2.1.9 → agent_api_server-2.1.11}/agent_api_server/listener/nats_listener.py +0 -0
  48. {agent_api_server-2.1.9 → agent_api_server-2.1.11}/agent_api_server/listener/redis_listener.py +0 -0
  49. {agent_api_server-2.1.9 → agent_api_server-2.1.11}/agent_api_server/log/__init__.py +0 -0
  50. {agent_api_server-2.1.9 → agent_api_server-2.1.11}/agent_api_server/log/formatters.py +0 -0
  51. {agent_api_server-2.1.9 → agent_api_server-2.1.11}/agent_api_server/log/logging.json +0 -0
  52. {agent_api_server-2.1.9 → agent_api_server-2.1.11}/agent_api_server/mcp_convert/__init__.py +0 -0
  53. {agent_api_server-2.1.9 → agent_api_server-2.1.11}/agent_api_server/mcp_convert/mcp_convert.py +0 -0
  54. {agent_api_server-2.1.9 → agent_api_server-2.1.11}/agent_api_server/mcp_server.py +0 -0
  55. {agent_api_server-2.1.9 → agent_api_server-2.1.11}/agent_api_server/memory/__init__.py +0 -0
  56. {agent_api_server-2.1.9 → agent_api_server-2.1.11}/agent_api_server/memory/postgres.py +0 -0
  57. {agent_api_server-2.1.9 → agent_api_server-2.1.11}/agent_api_server/register/__init__.py +0 -0
  58. {agent_api_server-2.1.9 → agent_api_server-2.1.11}/agent_api_server/register/register.py +0 -0
  59. {agent_api_server-2.1.9 → agent_api_server-2.1.11}/agent_api_server/service.py +0 -0
  60. {agent_api_server-2.1.9 → agent_api_server-2.1.11}/agent_api_server/service_hub/service_hub.py +0 -0
  61. {agent_api_server-2.1.9 → agent_api_server-2.1.11}/agent_api_server/service_hub/service_hub_test.py +0 -0
  62. {agent_api_server-2.1.9 → agent_api_server-2.1.11}/agent_api_server/services/__init__.py +0 -0
  63. {agent_api_server-2.1.9 → agent_api_server-2.1.11}/agent_api_server/services/config_builder.py +0 -0
  64. {agent_api_server-2.1.9 → agent_api_server-2.1.11}/agent_api_server/services/stream_service.py +0 -0
  65. {agent_api_server-2.1.9 → agent_api_server-2.1.11}/agent_api_server/shared/__init__.py +0 -0
  66. {agent_api_server-2.1.9 → agent_api_server-2.1.11}/agent_api_server/shared/ase.py +0 -0
  67. {agent_api_server-2.1.9 → agent_api_server-2.1.11}/agent_api_server/shared/base_model.py +0 -0
  68. {agent_api_server-2.1.9 → agent_api_server-2.1.11}/agent_api_server/shared/common.py +0 -0
  69. {agent_api_server-2.1.9 → agent_api_server-2.1.11}/agent_api_server/shared/decode_token.py +0 -0
  70. {agent_api_server-2.1.9 → agent_api_server-2.1.11}/agent_api_server/shared/detect_message.py +0 -0
  71. {agent_api_server-2.1.9 → agent_api_server-2.1.11}/agent_api_server/shared/exceptions.py +0 -0
  72. {agent_api_server-2.1.9 → agent_api_server-2.1.11}/agent_api_server/shared/get_model_info.py +0 -0
  73. {agent_api_server-2.1.9 → agent_api_server-2.1.11}/agent_api_server/shared/graph_loader.py +0 -0
  74. {agent_api_server-2.1.9 → agent_api_server-2.1.11}/agent_api_server/shared/message.py +0 -0
  75. {agent_api_server-2.1.9 → agent_api_server-2.1.11}/agent_api_server/shared/model_config.py +0 -0
  76. {agent_api_server-2.1.9 → agent_api_server-2.1.11}/agent_api_server/shared/util_func.py +0 -0
  77. {agent_api_server-2.1.9 → agent_api_server-2.1.11}/agent_api_server/sso_service/__init__.py +0 -0
  78. {agent_api_server-2.1.9 → agent_api_server-2.1.11}/agent_api_server/sso_service/sdk/__init__.py +0 -0
  79. {agent_api_server-2.1.9 → agent_api_server-2.1.11}/agent_api_server/sso_service/sdk/client.py +0 -0
  80. {agent_api_server-2.1.9 → agent_api_server-2.1.11}/agent_api_server/sso_service/sdk/credential.py +0 -0
  81. {agent_api_server-2.1.9 → agent_api_server-2.1.11}/agent_api_server/sso_service/sdk/encoding.py +0 -0
  82. {agent_api_server-2.1.9 → agent_api_server-2.1.11}/agent_api_server/sso_service/sso_service.py +0 -0
  83. {agent_api_server-2.1.9 → agent_api_server-2.1.11}/agent_api_server/startup.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.4
2
2
  Name: agent-api-server
3
- Version: 2.1.9
3
+ Version: 2.1.11
4
4
  Summary: A Langgraph agent API server that implements Langgraph agent's web capabilities and can interact with chatbot
5
5
  Author: zijie.zhang@advantech.com.cn
6
6
  Requires-Python: >=3.10,<3.13
@@ -222,7 +222,7 @@ class AgentCardBuilder:
222
222
  return await self._build_card_for_agent("AgentHub", config)
223
223
 
224
224
  capabilities = AgentCapabilities(
225
- streaming=False,
225
+ streaming=True,
226
226
  push_notifications=False,
227
227
  )
228
228
 
@@ -304,7 +304,7 @@ class AgentCardBuilder:
304
304
  self, agent_name: str, config: Dict[str, Any]
305
305
  ) -> AgentCard:
306
306
  capabilities = AgentCapabilities(
307
- streaming=False,
307
+ streaming=True,
308
308
  push_notifications=False,
309
309
  )
310
310
 
@@ -1,10 +1,15 @@
1
1
  import asyncio
2
2
  import logging
3
+ from collections.abc import Callable
3
4
  from dataclasses import dataclass, field
4
5
  from uuid import uuid4
6
+
7
+ import httpx
5
8
  from agent_api_server.shared.decode_token import decode_jwt
9
+ from a2a.client.client import ClientConfig
6
10
  from a2a.client.client_factory import create_client
7
11
  from a2a.types import (
12
+ AgentCard,
8
13
  Message,
9
14
  Part,
10
15
  Role,
@@ -94,15 +99,56 @@ class AgentHubClient:
94
99
  print(result.text)
95
100
  """
96
101
 
97
- def __init__(self, agent_url: str):
102
+ def __init__(
103
+ self,
104
+ agent_url: str = "",
105
+ timeout: float = 60.0,
106
+ agent_card: AgentCard | None = None,
107
+ ):
108
+ """Initialize the A2A client.
109
+
110
+ Args:
111
+ agent_url: Base URL or full AgentCard URL of the agent.
112
+ Used when agent_card is not provided.
113
+ timeout: HTTP timeout for requests.
114
+ agent_card: Pre-resolved AgentCard. When provided, the client
115
+ uses it directly without fetching from URL. This is the
116
+ preferred approach when the caller already has the card.
117
+ """
98
118
  self._agent_url = agent_url
119
+ self._timeout = timeout
120
+ self._agent_card = agent_card
99
121
  self._client = None
100
122
 
101
123
  async def _get_client(self):
102
124
  if self._client is None:
103
- logger.info("Resolving AgentCard from %s", self._agent_url)
104
- self._client = await create_client(self._agent_url)
105
- logger.info("Client created successfully for %s", self._agent_url)
125
+ httpx_client = httpx.AsyncClient(
126
+ timeout=httpx.Timeout(self._timeout, connect=10.0),
127
+ )
128
+ client_config = ClientConfig(httpx_client=httpx_client)
129
+
130
+ if self._agent_card is not None:
131
+ # Use the pre-resolved AgentCard directly
132
+ logger.info(
133
+ "Creating client from AgentCard for '%s'",
134
+ self._agent_card.name,
135
+ )
136
+ self._client = await create_client(
137
+ self._agent_card,
138
+ client_config=client_config,
139
+ )
140
+ else:
141
+ logger.info("Resolving AgentCard from %s", self._agent_url)
142
+ self._client = await create_client(
143
+ self._agent_url,
144
+ client_config=client_config,
145
+ )
146
+
147
+ logger.info(
148
+ "Client created successfully for %s (timeout=%ss)",
149
+ self._agent_url or (self._agent_card.name if self._agent_card else "unknown"),
150
+ self._timeout,
151
+ )
106
152
  return self._client
107
153
 
108
154
  async def call(
@@ -114,6 +160,7 @@ class AgentHubClient:
114
160
  files: list | None = None,
115
161
  task_id: str | None = None,
116
162
  context_id: str | None = None,
163
+ on_stream_chunk: Callable[[StreamResponse], None] | None = None,
117
164
  **extra_metadata,
118
165
  ) -> A2AResult:
119
166
  """Send a message to the remote agent and consume the full event stream.
@@ -125,6 +172,8 @@ class AgentHubClient:
125
172
  files: Optional file references.
126
173
  task_id: Existing task ID for multi-turn continuation.
127
174
  context_id: Existing context ID for multi-turn continuation.
175
+ on_stream_chunk: Optional callback invoked for each streaming event.
176
+ Enables real-time processing (e.g., logging) of stream chunks.
128
177
  **extra_metadata: Additional metadata (e.g., graph_name, app_id).
129
178
 
130
179
  Returns:
@@ -132,7 +181,11 @@ class AgentHubClient:
132
181
  """
133
182
  client = await self._get_client()
134
183
 
135
- graph_name = extra_metadata.get("graph_name", "unknown")
184
+ # Use agent card name if available, otherwise fall back to graph_name or URL
185
+ if self._agent_card:
186
+ graph_name = self._agent_card.name
187
+ else:
188
+ graph_name = extra_metadata.get("graph_name", self._agent_url or "unknown")
136
189
 
137
190
  # Extract user_id from JWT if available
138
191
  user_id = None
@@ -178,6 +231,13 @@ class AgentHubClient:
178
231
  async for response in client.send_message(request):
179
232
  self._process_stream_response(response, result)
180
233
 
234
+ # Invoke streaming callback if provided
235
+ if on_stream_chunk is not None:
236
+ try:
237
+ on_stream_chunk(response)
238
+ except Exception as e:
239
+ logger.warning("on_stream_chunk callback error: %s", e)
240
+
181
241
  # Stop consuming once we hit a terminal or interrupt state
182
242
  if result.task_state in _TERMINAL_STATES or result.task_state in _INTERRUPT_STATES:
183
243
  logger.info(
@@ -198,6 +258,7 @@ class AgentHubClient:
198
258
  follow_up_input: str,
199
259
  task_id: str,
200
260
  context_id: str,
261
+ on_stream_chunk: Callable[[StreamResponse], None] | None = None,
201
262
  **extra_metadata,
202
263
  ) -> A2AResult:
203
264
  """Send a follow-up message to an existing task in INPUT_REQUIRED state.
@@ -209,6 +270,7 @@ class AgentHubClient:
209
270
  follow_up_input: The user's response to the agent's prompt.
210
271
  task_id: The task ID from the previous result.
211
272
  context_id: The context ID from the previous result.
273
+ on_stream_chunk: Optional callback invoked for each streaming event.
212
274
  **extra_metadata: Additional metadata (e.g., graph_name).
213
275
 
214
276
  Returns:
@@ -218,6 +280,7 @@ class AgentHubClient:
218
280
  raw_question=follow_up_input,
219
281
  task_id=task_id,
220
282
  context_id=context_id,
283
+ on_stream_chunk=on_stream_chunk,
221
284
  **extra_metadata,
222
285
  )
223
286
 
@@ -1,7 +1,8 @@
1
1
  import asyncio
2
2
  import logging
3
- from typing import Any, Dict
4
- from langchain_core.messages import AIMessage
3
+ import uuid
4
+ from typing import Any, Dict, AsyncGenerator
5
+ from langchain_core.messages import AIMessage, AIMessageChunk
5
6
  from langgraph.graph.state import CompiledStateGraph
6
7
  from langgraph.types import Command
7
8
  from a2a.server.agent_execution import AgentExecutor, RequestContext
@@ -18,6 +19,71 @@ except ImportError:
18
19
  logger = logging.getLogger(__name__)
19
20
 
20
21
 
22
+ async def _extract_text_from_stream_event(
23
+ stream_event: Any,
24
+ ) -> AsyncGenerator[str, None]:
25
+ """Extract readable text content from a LangGraph stream event.
26
+
27
+ Unlike handle_stream_event (which wraps output in SSE format for REST),
28
+ this extracts plain text suitable for A2A artifact transport.
29
+
30
+ Handles both 2-tuple (mode, event) and 3-tuple (subgraph_id, mode, event)
31
+ formats produced by subgraphs=True.
32
+ """
33
+ # Unwrap subgraph tuple format: (subgraph_id, stream_mode, event)
34
+ if isinstance(stream_event, tuple) and len(stream_event) == 3:
35
+ _, stream_mode, event = stream_event
36
+ async for chunk in _extract_text_from_stream_event((stream_mode, event)):
37
+ yield chunk
38
+ return
39
+
40
+ if not (isinstance(stream_event, tuple) and len(stream_event) == 2):
41
+ return
42
+
43
+ stream_mode, event = stream_event
44
+
45
+ if stream_mode == "messages":
46
+ # Token-level streaming: (message_chunk, metadata)
47
+ if isinstance(event, tuple) and len(event) == 2:
48
+ message_chunk, metadata = event
49
+ if isinstance(message_chunk, AIMessageChunk):
50
+ content = message_chunk.content
51
+ # Handle list content (multimodal)
52
+ if isinstance(content, list):
53
+ text_parts = []
54
+ for item in content:
55
+ if isinstance(item, dict) and item.get("type") == "text":
56
+ text_parts.append(item.get("text", ""))
57
+ elif isinstance(item, str):
58
+ text_parts.append(item)
59
+ content = "".join(text_parts)
60
+ if content and isinstance(content, str) and content.strip():
61
+ yield content
62
+
63
+ elif stream_mode == "updates":
64
+ # Node-level updates: {node_name: state_updates}
65
+ if isinstance(event, dict):
66
+ for node_name, updates in event.items():
67
+ messages = updates.get("messages", [])
68
+ for msg in messages:
69
+ content = getattr(msg, "content", None)
70
+ if isinstance(content, str) and content.strip():
71
+ yield f"[{node_name}] {content}\n"
72
+ elif isinstance(content, list):
73
+ text_parts = []
74
+ for item in content:
75
+ if isinstance(item, dict) and item.get("type") == "text":
76
+ text_parts.append(item.get("text", ""))
77
+ text = "".join(text_parts)
78
+ if text.strip():
79
+ yield f"[{node_name}] {text}\n"
80
+
81
+ elif stream_mode == "custom":
82
+ # Custom events — yield as-is if string
83
+ if isinstance(event, str) and event.strip():
84
+ yield event
85
+
86
+
21
87
  class LangGraphAgentExecutor(AgentExecutor):
22
88
  """A2A AgentExecutor that bridges to LangGraph agents.
23
89
 
@@ -41,6 +107,11 @@ class LangGraphAgentExecutor(AgentExecutor):
41
107
  CANCELED state. The cancel() method actively cancels the running task
42
108
  and serves as a fallback for edge cases.
43
109
 
110
+ Streaming:
111
+ Uses graph.astream() with stream_mode=["updates", "messages", "custom"]
112
+ to emit incremental artifact chunks. Text content is extracted directly
113
+ from stream events (no SSE wrapping), suitable for A2A transport.
114
+
44
115
  Graph usage:
45
116
  from langgraph.types import interrupt
46
117
 
@@ -131,24 +202,13 @@ class LangGraphAgentExecutor(AgentExecutor):
131
202
  try:
132
203
  await graph_task
133
204
  except asyncio.CancelledError:
134
- logger.info("Task %s cancelled during execution", task_id)
135
- try:
136
- await updater.cancel()
137
- except RuntimeError:
138
- pass
139
- except Exception as e:
140
- logger.error("Error executing graph '%s': %s", graph_name, e, exc_info=True)
141
- await updater.failed(
142
- message=Message(
143
- role=Role.ROLE_AGENT,
144
- parts=[Part(text=f"Graph execution failed: {e}")],
145
- )
146
- )
205
+ await updater.cancel()
206
+ logger.info("Task %s -> CANCELED", task_id)
147
207
  finally:
148
208
  self._running_tasks.pop(task_id, None)
149
209
 
150
- @staticmethod
151
210
  async def _run_graph(
211
+ self,
152
212
  graph: CompiledStateGraph,
153
213
  user_input: str,
154
214
  metadata: Dict[str, Any],
@@ -156,12 +216,16 @@ class LangGraphAgentExecutor(AgentExecutor):
156
216
  updater: TaskUpdater,
157
217
  is_continuation: bool,
158
218
  ) -> None:
159
- """Execute the LangGraph and map its output to A2A terminal states.
219
+ """Execute the LangGraph with streaming and map output to A2A states.
220
+
221
+ Uses graph.astream() for real-time streaming of graph execution events
222
+ to the A2A client via incremental artifact updates.
160
223
 
161
224
  Uses LangGraph's native interrupt/resume mechanism for multi-turn:
162
- - If graph returns __interrupt__ -> INPUT_REQUIRED
163
- - If continuation, resume with Command(resume=user_input)
164
- - Otherwise -> COMPLETED with artifact
225
+ - Checks graph state before streaming for existing interrupts (resume)
226
+ - Streams events and emits each as an artifact chunk (append=True)
227
+ - After streaming, checks final state for interrupts -> INPUT_REQUIRED
228
+ - Otherwise -> COMPLETED
165
229
  """
166
230
  graph_name = config.get("configurable", {}).get("graph_name", "a2a_agent")
167
231
  callbacks_config = build_langfuse_callbacks(run_name=f"A2A_{graph_name}")
@@ -171,7 +235,14 @@ class LangGraphAgentExecutor(AgentExecutor):
171
235
  session_id = configurable.get("thread_id", "")
172
236
  user_id = configurable.get("user_id", "")
173
237
 
174
- if is_continuation:
238
+ # Check for existing interrupts (resume detection) before streaming
239
+ state_get = await graph.aget_state(config=a2a_config)
240
+ has_interrupts = any(
241
+ getattr(task, "interrupts", None)
242
+ for task in state_get.tasks
243
+ )
244
+
245
+ if has_interrupts or is_continuation:
175
246
  graph_input = Command(resume=user_input)
176
247
  else:
177
248
  graph_input = {}
@@ -182,34 +253,85 @@ class LangGraphAgentExecutor(AgentExecutor):
182
253
  graph_input["agent_name"] = graph_name
183
254
  graph_input["user_input"] = user_input
184
255
 
185
- if callbacks_config and propagate_attributes:
186
- with propagate_attributes(
256
+ # Generate a stable artifact ID for all streaming chunks
257
+ artifact_id = str(uuid.uuid4())
258
+ _trace_name = f"A2A_{graph_name}"
259
+
260
+ trace_ctx = (
261
+ propagate_attributes(
187
262
  session_id=session_id,
188
263
  user_id=user_id,
189
- trace_name=f"A2A_{graph_name}",
190
- ):
191
- result = await graph.ainvoke(graph_input, config=a2a_config)
264
+ trace_name=_trace_name,
265
+ )
266
+ if callbacks_config and propagate_attributes
267
+ else None
268
+ )
269
+
270
+ if trace_ctx:
271
+ with trace_ctx:
272
+ await self._stream_graph_and_emit_artifacts(
273
+ graph, graph_input, a2a_config, updater, artifact_id,
274
+ )
192
275
  else:
193
- result = await graph.ainvoke(graph_input, config=a2a_config)
194
-
195
- if isinstance(result, dict) and "__interrupt__" in result:
196
- interrupts = result["__interrupt__"]
197
- if interrupts:
198
- interrupt_value = interrupts[0].value
199
- prompt = str(interrupt_value) if interrupt_value else "Additional input required"
200
- await updater.requires_input(
201
- message=Message(
202
- role=Role.ROLE_AGENT,
203
- parts=[Part(text=prompt)],
204
- )
276
+ await self._stream_graph_and_emit_artifacts(
277
+ graph, graph_input, a2a_config, updater, artifact_id,
278
+ )
279
+
280
+ # After streaming, check final state for interrupts
281
+ final_state = await graph.aget_state(config=a2a_config)
282
+ if final_state.next:
283
+ # Graph is interrupted — extract interrupt value for the prompt
284
+ interrupt_values = []
285
+ for task in final_state.tasks:
286
+ for interrupt in getattr(task, "interrupts", []):
287
+ if hasattr(interrupt, "value") and interrupt.value is not None:
288
+ interrupt_values.append(str(interrupt.value))
289
+ prompt = interrupt_values[0] if interrupt_values else "Additional input required"
290
+ await updater.requires_input(
291
+ message=Message(
292
+ role=Role.ROLE_AGENT,
293
+ parts=[Part(text=prompt)],
205
294
  )
206
- logger.info("Task %s -> INPUT_REQUIRED: %s", updater.task_id, prompt)
207
- return
295
+ )
296
+ logger.info("Task %s -> INPUT_REQUIRED: %s", updater.task_id, prompt)
297
+ else:
298
+ await updater.complete()
299
+ logger.info("Task %s -> COMPLETED", updater.task_id)
208
300
 
209
- response_text = _extract_response_text(result)
210
- await updater.add_artifact(parts=[Part(text=response_text)])
211
- await updater.complete()
212
- logger.info("Task %s -> COMPLETED", updater.task_id)
301
+ async def _stream_graph_and_emit_artifacts(
302
+ self,
303
+ graph: CompiledStateGraph,
304
+ graph_input: Any,
305
+ config: Dict[str, Any],
306
+ updater: TaskUpdater,
307
+ artifact_id: str,
308
+ ) -> None:
309
+ """Stream graph execution and emit text content as artifact chunks.
310
+
311
+ Extracts text directly from stream events (no SSE wrapping).
312
+ Each chunk is sent as an A2A artifact update with append=True
313
+ for incremental delivery.
314
+ """
315
+ first_chunk = True
316
+ async for stream_event in graph.astream(
317
+ graph_input,
318
+ config=config,
319
+ stream_mode=["updates", "messages", "custom"],
320
+ subgraphs=True,
321
+ ):
322
+ async for text_chunk in _extract_text_from_stream_event(stream_event):
323
+ if first_chunk:
324
+ await updater.add_artifact(
325
+ parts=[Part(text=text_chunk)],
326
+ artifact_id=artifact_id,
327
+ )
328
+ first_chunk = False
329
+ else:
330
+ await updater.add_artifact(
331
+ parts=[Part(text=text_chunk)],
332
+ artifact_id=artifact_id,
333
+ append=True,
334
+ )
213
335
 
214
336
  def _build_config(
215
337
  self,
@@ -218,11 +340,20 @@ class LangGraphAgentExecutor(AgentExecutor):
218
340
  metadata: Dict[str, Any] = None,
219
341
  ) -> Dict[str, Any]:
220
342
  metadata = metadata or {}
221
- ts_tenant = metadata.get("tenent_id", "")
343
+ ts_tenant = metadata.get("tenant_id", "")
344
+
345
+ # Namespace thread_id by graph_name to isolate checkpoints
346
+ # across multiple agents sharing the same PostgreSQL checkpointer.
347
+ # Without this, agents on the same server with the same context_id
348
+ # would overwrite each other's conversation state.
349
+ namespaced_thread_id = (
350
+ f"{graph_name}:{context.context_id}"
351
+ if graph_name else context.context_id
352
+ )
222
353
 
223
354
  configurable = build_configurable(
224
355
  graph_name=graph_name,
225
- thread_id=context.context_id,
356
+ thread_id=namespaced_thread_id,
226
357
  ts_tenant=ts_tenant,
227
358
  ei_token=metadata.get("ei_token", ""),
228
359
  files=metadata.get("files"),
@@ -257,15 +388,3 @@ class LangGraphAgentExecutor(AgentExecutor):
257
388
  await updater.cancel()
258
389
  except RuntimeError:
259
390
  pass
260
-
261
-
262
- def _extract_response_text(result: Any) -> str:
263
- """Extract the response text from a LangGraph output."""
264
- if isinstance(result, dict) and "messages" in result and result["messages"]:
265
- last_message = result["messages"][-1]
266
- if isinstance(last_message, AIMessage):
267
- return last_message.content
268
- return str(last_message)
269
- if isinstance(result, str):
270
- return result
271
- return str(result) if result else "No response generated"
@@ -39,6 +39,12 @@ type SessionState struct {
39
39
  UpdatedAt string `json:"updated_at,omitempty"`
40
40
  }
41
41
 
42
+ // pendingToolCall holds buffered tool call info until the tool result is available.
43
+ type pendingToolCall struct {
44
+ name string
45
+ args interface{}
46
+ }
47
+
42
48
  // AgentHubACPAgent implements the ACP protocol over stdio, proxying to an HTTP backend.
43
49
  type AgentHubACPAgent struct {
44
50
  serviceURL string
@@ -54,7 +60,7 @@ type AgentHubACPAgent struct {
54
60
  mu sync.Mutex
55
61
  httpClient *http.Client
56
62
  sessions map[string]*SessionState
57
- pendingToolCalls map[string]map[string]string // sessionID -> tcID -> tcName
63
+ pendingToolCalls map[string]map[string]*pendingToolCall // sessionID -> tcID -> info
58
64
  streamedNodes map[string]map[string]bool // sessionID -> set of nodes
59
65
  }
60
66
 
@@ -77,7 +83,7 @@ func NewAgentHubACPAgent(
77
83
  extraInputs: extraInputs,
78
84
  streamMode: streamMode,
79
85
  sessions: make(map[string]*SessionState),
80
- pendingToolCalls: make(map[string]map[string]string),
86
+ pendingToolCalls: make(map[string]map[string]*pendingToolCall),
81
87
  streamedNodes: make(map[string]map[string]bool),
82
88
  httpClient: &http.Client{
83
89
  Timeout: 300 * time.Second,
@@ -348,24 +354,22 @@ func (a *AgentHubACPAgent) processSSEEvent(sessionID, raw string) {
348
354
  a.mu.Lock()
349
355
  pending := a.pendingToolCalls[sessionID]
350
356
  if pending == nil {
351
- pending = make(map[string]string)
357
+ pending = make(map[string]*pendingToolCall)
352
358
  a.pendingToolCalls[sessionID] = pending
353
359
  }
354
360
  a.mu.Unlock()
355
361
  for _, tc := range toolCalls {
356
- tcID, _ := tc["id"].(string)
357
- if tcID == "" {
358
- tcID = uuid.New().String()
359
- }
362
+ // Always use UUID for tcID to avoid invalid IDs from LangChain
363
+ tcID := uuid.New().String()
360
364
  tcName, _ := tc["name"].(string)
361
365
  if tcName == "" {
362
366
  tcName = "unknown"
363
367
  }
364
368
  tcArgs := tc["args"]
369
+ // Buffer tool call info; emit complete tool_call when tools_message arrives
365
370
  a.mu.Lock()
366
- pending[tcID] = tcName
371
+ pending[tcID] = &pendingToolCall{name: tcName, args: tcArgs}
367
372
  a.mu.Unlock()
368
- a.sendStartToolCall(sessionID, tcID, tcName, tcArgs, nil, "")
369
373
  }
370
374
  } else if text != "" {
371
375
  a.mu.Lock()
@@ -390,12 +394,13 @@ func (a *AgentHubACPAgent) processSSEEvent(sessionID, raw string) {
390
394
  a.mu.Unlock()
391
395
 
392
396
  if len(pending) > 0 {
393
- for tcID, tcName := range pending {
397
+ for tcID, info := range pending {
394
398
  output := text
395
399
  if output == "" {
396
- output = tcName + " completed"
400
+ output = info.name + " completed"
397
401
  }
398
- a.sendUpdateToolCall(sessionID, tcID, "completed", output)
402
+ // Emit single complete tool_call with rawInput + rawOutput
403
+ a.sendStartToolCall(sessionID, tcID, info.name, info.args, output, "completed")
399
404
  }
400
405
  a.mu.Lock()
401
406
  delete(a.pendingToolCalls, sessionID)
@@ -412,10 +417,8 @@ func (a *AgentHubACPAgent) processSSEEvent(sessionID, raw string) {
412
417
  }
413
418
  }
414
419
  for _, tc := range toolCalls {
415
- tcID, _ := tc["id"].(string)
416
- if tcID == "" {
417
- tcID = uuid.New().String()
418
- }
420
+ // Always use UUID for tcID to avoid invalid IDs from LangChain
421
+ tcID := uuid.New().String()
419
422
  tcName, _ := tc["name"].(string)
420
423
  if tcName == "" {
421
424
  tcName = "unknown"