langgraph-agent-common 2.0.0__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.
- langgraph_a2a_api/__init__.py +0 -0
- langgraph_a2a_api/adaptor/__init__.py +0 -0
- langgraph_a2a_api/adaptor/agent_executor.py +421 -0
- langgraph_a2a_api/adaptor/conversion.py +167 -0
- langgraph_a2a_api/adaptor/sanitize_messages_for_llm.py +34 -0
- langgraph_a2a_api/fastapi_application.py +152 -0
- langgraph_a2a_api/langgraph/__init__.py +0 -0
- langgraph_a2a_api/langgraph/checkpointer.py +102 -0
- langgraph_a2a_api/langgraph/graph.py +49 -0
- langgraph_a2a_api/langgraph/store.py +64 -0
- langgraph_a2a_api/langgraph/validator.py +42 -0
- langgraph_a2a_api/resources/openapi_schema.json +277 -0
- langgraph_a2a_api/resources/static/css/style.css +480 -0
- langgraph_a2a_api/resources/static/index.html +107 -0
- langgraph_a2a_api/resources/static/js/app.js +896 -0
- langgraph_a2a_api/server.py +149 -0
- langgraph_a2a_api/tasks/__init__.py +0 -0
- langgraph_a2a_api/tasks/push_notification_config_store.py +24 -0
- langgraph_a2a_api/tasks/task_store.py +66 -0
- langgraph_a2a_api/utils/__init__.py +0 -0
- langgraph_a2a_api/utils/lock.py +31 -0
- langgraph_a2a_api/utils/types.py +3 -0
- langgraph_agent_common-2.0.0.dist-info/METADATA +67 -0
- langgraph_agent_common-2.0.0.dist-info/RECORD +31 -0
- langgraph_agent_common-2.0.0.dist-info/WHEEL +5 -0
- langgraph_agent_common-2.0.0.dist-info/top_level.txt +2 -0
- langgraph_aux/__init__.py +0 -0
- langgraph_aux/callbacks/__init__.py +11 -0
- langgraph_aux/callbacks/talos_callback_handler.py +340 -0
- langgraph_aux/utils/__init__.py +0 -0
- langgraph_aux/utils/stream_output.py +13 -0
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from collections.abc import AsyncIterable
|
|
3
|
+
from typing import Any, Union
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
import asyncio
|
|
6
|
+
import uuid
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
|
|
9
|
+
from google.protobuf.json_format import MessageToDict
|
|
10
|
+
from google.protobuf.timestamp_pb2 import Timestamp
|
|
11
|
+
|
|
12
|
+
from langgraph.graph.state import CompiledStateGraph
|
|
13
|
+
from langchain_core.messages import AnyMessage, AIMessage, HumanMessage, RemoveMessage
|
|
14
|
+
from langchain_core.runnables import RunnableConfig
|
|
15
|
+
from langgraph.checkpoint.base import BaseCheckpointSaver
|
|
16
|
+
from langgraph.store.base import BaseStore
|
|
17
|
+
from langgraph.types import Command, StateSnapshot, Interrupt, Overwrite
|
|
18
|
+
from langgraph.typing import ContextT
|
|
19
|
+
from langgraph.errors import GraphRecursionError, GraphDrained
|
|
20
|
+
from langgraph.graph import END
|
|
21
|
+
from langgraph.runtime import RunControl
|
|
22
|
+
|
|
23
|
+
from a2a.server.agent_execution import AgentExecutor, RequestContext
|
|
24
|
+
from a2a.server.events import EventQueue
|
|
25
|
+
from a2a.server.tasks import TaskUpdater, TaskStore
|
|
26
|
+
from a2a.server.context import ServerCallContext
|
|
27
|
+
from a2a.types import Task, Message, Part, TaskState, TaskStatus, Role
|
|
28
|
+
from a2a.utils.errors import InternalError, InvalidParamsError
|
|
29
|
+
from a2a.helpers import new_text_message
|
|
30
|
+
|
|
31
|
+
from .conversion import a2a_message_to_langgraph, langgraph_message_to_a2a, any_content_to_langgraph, langgraph_part_to_a2a
|
|
32
|
+
from langgraph_a2a_api.utils.types import atomic_type
|
|
33
|
+
from langgraph_a2a_api.utils.lock import AsyncResourceLock
|
|
34
|
+
from .sanitize_messages_for_llm import sanitize_messages_for_llm
|
|
35
|
+
from langgraph_a2a_api.langgraph.validator import validate_HITLResponse, HITLResponseModel
|
|
36
|
+
|
|
37
|
+
logging.basicConfig(level=logging.WARN)
|
|
38
|
+
logger = logging.getLogger(__name__)
|
|
39
|
+
|
|
40
|
+
TASK_STATE_SUBMITTED = TaskState.Value("TASK_STATE_SUBMITTED")
|
|
41
|
+
TASK_STATE_WORKING = TaskState.Value("TASK_STATE_WORKING")
|
|
42
|
+
TASK_STATE_COMPLETED = TaskState.Value("TASK_STATE_COMPLETED")
|
|
43
|
+
TASK_STATE_FAILED = TaskState.Value("TASK_STATE_FAILED")
|
|
44
|
+
TASK_STATE_CANCELED = TaskState.Value("TASK_STATE_CANCELED")
|
|
45
|
+
TASK_STATE_INPUT_REQUIRED = TaskState.Value("TASK_STATE_INPUT_REQUIRED")
|
|
46
|
+
TASK_STATE_REJECTED = TaskState.Value("TASK_STATE_REJECTED")
|
|
47
|
+
TASK_STATE_UNSEPCIFIED = TaskState.Value("TASK_STATE_UNSPECIFIED")
|
|
48
|
+
|
|
49
|
+
negative_graph_state = {TASK_STATE_CANCELED, TASK_STATE_FAILED, TASK_STATE_REJECTED}
|
|
50
|
+
|
|
51
|
+
class GraphAgentExecutor(AgentExecutor):
|
|
52
|
+
def __init__(self, graph: CompiledStateGraph, task_store: TaskStore):
|
|
53
|
+
super().__init__()
|
|
54
|
+
self.graph = graph
|
|
55
|
+
self.graph_with_ck = None
|
|
56
|
+
self.task_store = task_store
|
|
57
|
+
self.run_controls: dict[str, RunControl] = {}
|
|
58
|
+
self.lock = AsyncResourceLock()
|
|
59
|
+
|
|
60
|
+
def bind_runtime_to_graph(self, checkpointer: BaseCheckpointSaver, store: BaseStore | None = None):
|
|
61
|
+
runtime = {"checkpointer": checkpointer}
|
|
62
|
+
if store is not None:
|
|
63
|
+
runtime["store"] = store
|
|
64
|
+
self.graph_with_ck = self.graph.copy(runtime)
|
|
65
|
+
|
|
66
|
+
async def stream_graph(self, message: Message, history: list[Message], context_id: str, task_id: str, metadata: dict[str, Any], updater: TaskUpdater):
|
|
67
|
+
context = MessageToDict(message.metadata) if message and message.metadata else {}
|
|
68
|
+
langgraph_message = a2a_message_to_langgraph(message)
|
|
69
|
+
langgraph_history = [a2a_message_to_langgraph(m) for m in history]
|
|
70
|
+
try:
|
|
71
|
+
recursion_limit = int(float(metadata.get("recursion_limit", 300)))
|
|
72
|
+
except (ValueError, TypeError):
|
|
73
|
+
recursion_limit = 300
|
|
74
|
+
stream_mode = metadata.get("stream_mode", "updates")
|
|
75
|
+
subgraphs = metadata.get("subgraphs", True)
|
|
76
|
+
|
|
77
|
+
config = {
|
|
78
|
+
"configurable": {
|
|
79
|
+
**metadata,
|
|
80
|
+
"thread_id": context_id,
|
|
81
|
+
"graph_id": self.graph.get_name(),
|
|
82
|
+
"assistant_id": self.graph.get_name(),
|
|
83
|
+
"task_id": task_id,
|
|
84
|
+
},
|
|
85
|
+
"recursion_limit": recursion_limit,
|
|
86
|
+
}
|
|
87
|
+
appended_artifacts = set()
|
|
88
|
+
|
|
89
|
+
current_state = await self.graph_with_ck.aget_state(config, subgraphs=subgraphs)
|
|
90
|
+
current_interrupts = await self._get_interrupts(current_state, subgraphs)
|
|
91
|
+
if current_interrupts:
|
|
92
|
+
current_interrupt_ids = [c.id for c in current_interrupts]
|
|
93
|
+
if isinstance(langgraph_message.content, list) and len(current_interrupts) == len(langgraph_message.content) and all([isinstance(part, dict) and part.get("type") == "non_standard" and validate_HITLResponse(part.get("value")) for part in langgraph_message.content]):
|
|
94
|
+
decisions = {part.get("value", {}).get("hitl_id"): part.get("value", {}).get("decision") for part in langgraph_message.content}
|
|
95
|
+
if set(current_interrupt_ids) == set(decisions.keys()):
|
|
96
|
+
resume_decisions = []
|
|
97
|
+
for hitl_id in current_interrupt_ids:
|
|
98
|
+
resume_decisions.append(decisions[hitl_id])
|
|
99
|
+
resume_content = {"decisions": resume_decisions}
|
|
100
|
+
else:
|
|
101
|
+
raise ValueError(f"Interrupt resume content: {langgraph_message.content}. Must contain overall original decision ids {current_interrupt_ids}.")
|
|
102
|
+
else:
|
|
103
|
+
raise ValueError(f"Invalid interrupt resume content: {langgraph_message.content}. Must be a list with the same length as interrupt ids: {current_interrupt_ids}. Each item in the list must be a data with HITLResponse schema: {HITLResponseModel.schema()}.")
|
|
104
|
+
inputs = Command(resume = resume_content, update = {"messages": sanitize_messages_for_llm(langgraph_history)})
|
|
105
|
+
for k, v in current_state.values.items():
|
|
106
|
+
if k in {"messages", "graph_status"} or not v or not (isinstance(v, list) and isinstance(v, set)):
|
|
107
|
+
continue
|
|
108
|
+
appended_artifacts.add(k)
|
|
109
|
+
else:
|
|
110
|
+
inputs = {"messages": sanitize_messages_for_llm(langgraph_history + [langgraph_message])}
|
|
111
|
+
|
|
112
|
+
run_control = RunControl()
|
|
113
|
+
self.run_controls[task_id] = run_control
|
|
114
|
+
try:
|
|
115
|
+
if stream_mode == "updates":
|
|
116
|
+
async for updates in self._stream_graph_updates(inputs, config, context, subgraphs, run_control):
|
|
117
|
+
await self._update_task(updates, context_id, task_id, updater, appended_artifacts)
|
|
118
|
+
elif stream_mode == "custom":
|
|
119
|
+
async for custom in self._stream_graph_custom(inputs, config, context, subgraphs, run_control):
|
|
120
|
+
await self._update_task(custom, context_id, task_id, updater, appended_artifacts)
|
|
121
|
+
else:
|
|
122
|
+
raise ValueError(f"Unsupported stream_mode {stream_mode}.")
|
|
123
|
+
finally:
|
|
124
|
+
self.run_controls.pop(task_id, None)
|
|
125
|
+
|
|
126
|
+
async def _get_interrupts(self, state: StateSnapshot, subgraphs: bool, seen_ids: set[str] = set()) -> list[Interrupt]:
|
|
127
|
+
interrupts = []
|
|
128
|
+
for interrupt in state.interrupts:
|
|
129
|
+
if interrupt.id not in seen_ids:
|
|
130
|
+
seen_ids.add(interrupt.id)
|
|
131
|
+
interrupts.append(interrupt)
|
|
132
|
+
for task in state.tasks:
|
|
133
|
+
if task.interrupts:
|
|
134
|
+
for interrupt in task.interrupts:
|
|
135
|
+
if interrupt.id not in seen_ids:
|
|
136
|
+
seen_ids.add(interrupt.id)
|
|
137
|
+
interrupts.append(interrupt)
|
|
138
|
+
nested_state = task.state
|
|
139
|
+
if isinstance(nested_state, StateSnapshot):
|
|
140
|
+
interrupts.extend(await self._get_interrupts(nested_state, subgraphs, seen_ids))
|
|
141
|
+
elif nested_state:
|
|
142
|
+
try:
|
|
143
|
+
fetched_state = await self.graph_with_ck.aget_state(nested_state, subgraphs=subgraphs)
|
|
144
|
+
except Exception:
|
|
145
|
+
continue
|
|
146
|
+
interrupts.extend(await self._get_interrupts(fetched_state, subgraphs, seen_ids))
|
|
147
|
+
return interrupts
|
|
148
|
+
|
|
149
|
+
def _normalize_messages(self, messages: Any) -> list[AnyMessage]:
|
|
150
|
+
if messages is None:
|
|
151
|
+
return []
|
|
152
|
+
if isinstance(messages, Overwrite):
|
|
153
|
+
messages = messages.value
|
|
154
|
+
if isinstance(messages, list):
|
|
155
|
+
return messages
|
|
156
|
+
raise ValueError(f"Unsupported type of messages: {type(messages).__name__}")
|
|
157
|
+
|
|
158
|
+
async def _update_task(self, state: dict[str, Any], context_id: str, task_id: str, updater: TaskUpdater, appended_artifacts: set):
|
|
159
|
+
if not state:
|
|
160
|
+
return
|
|
161
|
+
i = 0
|
|
162
|
+
for k, v in state.items():
|
|
163
|
+
append = False
|
|
164
|
+
i = i + 1
|
|
165
|
+
if k in {"messages", "graph_status"} or not v:
|
|
166
|
+
continue
|
|
167
|
+
if isinstance(v, atomic_type):
|
|
168
|
+
parts = [langgraph_part_to_a2a(str(v))]
|
|
169
|
+
elif isinstance(v, list) or isinstance(v, tuple):
|
|
170
|
+
parts = []
|
|
171
|
+
for part in v:
|
|
172
|
+
if isinstance(part, atomic_type):
|
|
173
|
+
parts.append(langgraph_part_to_a2a(str(part)))
|
|
174
|
+
elif isinstance(part, dict):
|
|
175
|
+
parts.append(langgraph_part_to_a2a({"type": "non_standard", "value": part}))
|
|
176
|
+
elif isinstance(part, BaseModel):
|
|
177
|
+
parts.append(langgraph_part_to_a2a({"type": "non_standard", "value": part.model_dump()}))
|
|
178
|
+
else:
|
|
179
|
+
raise ValueError(f"Unsupported type of artifact part {part}.")
|
|
180
|
+
if k in appended_artifacts:
|
|
181
|
+
append = True
|
|
182
|
+
else:
|
|
183
|
+
appended_artifacts.add(k)
|
|
184
|
+
elif isinstance(v, dict):
|
|
185
|
+
parts = [langgraph_part_to_a2a({"type": "non_standard", "value": v})]
|
|
186
|
+
elif isinstance(v, BaseModel):
|
|
187
|
+
parts = [langgraph_part_to_a2a({"type": "non_standard", "value": v.model_dump()})]
|
|
188
|
+
else:
|
|
189
|
+
raise ValueError(f"Unsupported type of artifact: {k}:{v}")
|
|
190
|
+
|
|
191
|
+
await updater.add_artifact(
|
|
192
|
+
parts = parts,
|
|
193
|
+
artifact_id = k,
|
|
194
|
+
name = k,
|
|
195
|
+
append = append,
|
|
196
|
+
last_chunk = False,
|
|
197
|
+
)
|
|
198
|
+
messages = [message for message in state.get("messages", []) if not isinstance(message, RemoveMessage)]
|
|
199
|
+
for message in messages[0:-1]:
|
|
200
|
+
a2a_message = langgraph_message_to_a2a(message, context_id, task_id)
|
|
201
|
+
await updater.update_status(
|
|
202
|
+
state = TASK_STATE_WORKING,
|
|
203
|
+
message = langgraph_message_to_a2a(message, context_id, task_id),
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
if state["graph_status"] == TASK_STATE_COMPLETED or state["graph_status"] in negative_graph_state or len(messages) > 0:
|
|
207
|
+
await updater.update_status(
|
|
208
|
+
state = state["graph_status"],
|
|
209
|
+
message = langgraph_message_to_a2a(messages[-1], context_id, task_id) if len(messages) > 0 else None,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
async def _stream_graph_updates(self, inputs: Union[dict[str, list[AnyMessage]], Command], config: RunnableConfig, context: ContextT, subgraphs: bool, control: RunControl) -> AsyncIterable[dict[str, Any]]:
|
|
213
|
+
graph_status = TASK_STATE_UNSEPCIFIED
|
|
214
|
+
break_out = False
|
|
215
|
+
pre_yield = None
|
|
216
|
+
try:
|
|
217
|
+
async for space, updates in self.graph_with_ck.astream(inputs, config = config, context = context, stream_mode = "updates", subgraphs = subgraphs, control = control):
|
|
218
|
+
if break_out:
|
|
219
|
+
continue
|
|
220
|
+
for step, update in updates.items():
|
|
221
|
+
if break_out:
|
|
222
|
+
continue
|
|
223
|
+
if not update:
|
|
224
|
+
continue
|
|
225
|
+
if pre_yield is not None:
|
|
226
|
+
yield pre_yield
|
|
227
|
+
pre_yield = None
|
|
228
|
+
if step == "__interrupt__":
|
|
229
|
+
graph_status = TASK_STATE_INPUT_REQUIRED
|
|
230
|
+
pre_yield = {
|
|
231
|
+
"messages": [AIMessage(content=any_content_to_langgraph([{"hitl_id": interrupt.id, "content": interrupt.value} for interrupt in update]))],
|
|
232
|
+
"graph_status": graph_status,
|
|
233
|
+
}
|
|
234
|
+
break_out = True
|
|
235
|
+
else:
|
|
236
|
+
messages = self._normalize_messages(update.get("messages"))
|
|
237
|
+
graph_status = TASK_STATE_WORKING
|
|
238
|
+
pre_yield = {
|
|
239
|
+
**update,
|
|
240
|
+
"messages": messages,
|
|
241
|
+
"graph_status": graph_status,
|
|
242
|
+
}
|
|
243
|
+
if not break_out:
|
|
244
|
+
graph_status = TASK_STATE_COMPLETED
|
|
245
|
+
yield {
|
|
246
|
+
**(pre_yield or {}),
|
|
247
|
+
"graph_status": graph_status,
|
|
248
|
+
}
|
|
249
|
+
else:
|
|
250
|
+
yield pre_yield
|
|
251
|
+
|
|
252
|
+
except GraphRecursionError as e:
|
|
253
|
+
if pre_yield is not None:
|
|
254
|
+
yield pre_yield
|
|
255
|
+
graph_status = TASK_STATE_FAILED
|
|
256
|
+
yield {
|
|
257
|
+
"messages": [AIMessage(content=str(e))],
|
|
258
|
+
"graph_status": graph_status,
|
|
259
|
+
}
|
|
260
|
+
except GraphDrained:
|
|
261
|
+
if pre_yield is not None:
|
|
262
|
+
yield pre_yield
|
|
263
|
+
yield {
|
|
264
|
+
"graph_status": TASK_STATE_CANCELED,
|
|
265
|
+
}
|
|
266
|
+
async def _stream_graph_custom(self, inputs: Union[dict[str, list[AnyMessage]], Command], config: RunnableConfig, context: ContextT, subgraphs: bool, control: RunControl) -> AsyncIterable[dict[str, Any]]:
|
|
267
|
+
graph_recursion_error = None
|
|
268
|
+
graph_drained = None
|
|
269
|
+
try:
|
|
270
|
+
async for space, custom in self.graph_with_ck.astream(inputs, config = config, context = context, stream_mode = "custom", subgraphs = subgraphs, control = control):
|
|
271
|
+
if not custom:
|
|
272
|
+
yield {}
|
|
273
|
+
continue
|
|
274
|
+
yield {
|
|
275
|
+
"messages": [AIMessage(content=any_content_to_langgraph(custom))],
|
|
276
|
+
"graph_status": TASK_STATE_WORKING,
|
|
277
|
+
}
|
|
278
|
+
except GraphRecursionError as e:
|
|
279
|
+
graph_recursion_error = str(e)
|
|
280
|
+
except GraphDrained as e:
|
|
281
|
+
graph_drained = str(e)
|
|
282
|
+
|
|
283
|
+
current_state_after = await self.graph_with_ck.aget_state(config, subgraphs=subgraphs)
|
|
284
|
+
current_interrupts_after = await self._get_interrupts(current_state_after, subgraphs)
|
|
285
|
+
if current_interrupts_after:
|
|
286
|
+
yield {
|
|
287
|
+
"messages": [AIMessage(content=any_content_to_langgraph([{"hitl_id": interrupt.id, "content": interrupt.value} for interrupt in current_interrupts_after]))],
|
|
288
|
+
"graph_status": TASK_STATE_INPUT_REQUIRED,
|
|
289
|
+
}
|
|
290
|
+
else:
|
|
291
|
+
messages_raw = self._normalize_messages(current_state_after.values.get("messages"))
|
|
292
|
+
if graph_recursion_error:
|
|
293
|
+
graph_status = TASK_STATE_FAILED
|
|
294
|
+
messages_raw = messages_raw + [AIMessage(content=graph_recursion_error)]
|
|
295
|
+
elif graph_drained:
|
|
296
|
+
graph_status = TASK_STATE_CANCELED
|
|
297
|
+
messages_raw = messages_raw + [AIMessage(content=graph_drained)]
|
|
298
|
+
else:
|
|
299
|
+
graph_status = TASK_STATE_COMPLETED
|
|
300
|
+
if len(messages_raw) > 0 and not isinstance(messages_raw[-1], HumanMessage) and messages_raw[-1].content:
|
|
301
|
+
messages = [messages_raw[-1]]
|
|
302
|
+
messages_history = messages_raw[0:-1]
|
|
303
|
+
else:
|
|
304
|
+
messages = []
|
|
305
|
+
messages_history = messages_raw
|
|
306
|
+
yield {
|
|
307
|
+
**current_state_after.values,
|
|
308
|
+
"graph_status": graph_status,
|
|
309
|
+
"messages": messages,
|
|
310
|
+
"messages_history": [langgraph_message_to_a2a(message, None, None) for message in messages_history],
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async def _update_task_store_status(self, task: Task, state: int, call_context: ServerCallContext) -> None:
|
|
314
|
+
ts = Timestamp()
|
|
315
|
+
ts.FromDatetime(datetime.now(timezone.utc))
|
|
316
|
+
task.status.state = state
|
|
317
|
+
task.status.timestamp.CopyFrom(ts)
|
|
318
|
+
await self.task_store.save(task, call_context)
|
|
319
|
+
|
|
320
|
+
async def execute(self, context: RequestContext, event_queue: EventQueue) -> None:
|
|
321
|
+
if not self.graph_with_ck:
|
|
322
|
+
raise InternalError(message=f"Can not found checkpointer of langgraph.")
|
|
323
|
+
if not context.message:
|
|
324
|
+
raise InvalidParamsError(message=f"Request message is required.")
|
|
325
|
+
|
|
326
|
+
task_id = context.current_task.id if context.current_task else context.message.task_id or str(uuid.uuid4())
|
|
327
|
+
context_id = context.current_task.context_id if context.current_task else context.message.context_id or str(uuid.uuid4())
|
|
328
|
+
updater = TaskUpdater(event_queue, task_id, context_id)
|
|
329
|
+
|
|
330
|
+
@self.lock.async_resource_lock(context_id)
|
|
331
|
+
async def start_task() -> tuple[bool, list[Task]]:
|
|
332
|
+
alive_tasks = await self.task_store.get_alive_tasks(context_id)
|
|
333
|
+
for alive_task in alive_tasks:
|
|
334
|
+
if alive_task.status.state == TASK_STATE_INPUT_REQUIRED:
|
|
335
|
+
if alive_task.id == task_id:
|
|
336
|
+
await updater.start_work()
|
|
337
|
+
history_tasks = await self.task_store.get_rejected_tasks(context_id)
|
|
338
|
+
return (True, history_tasks)
|
|
339
|
+
else:
|
|
340
|
+
return (False, [alive_task])
|
|
341
|
+
else:
|
|
342
|
+
return (False, [alive_task])
|
|
343
|
+
await updater.start_work()
|
|
344
|
+
history_tasks = await self.task_store.get_rejected_tasks(context_id)
|
|
345
|
+
return (True, history_tasks)
|
|
346
|
+
|
|
347
|
+
try:
|
|
348
|
+
exist_task = await self.task_store.get(task_id, context.call_context)
|
|
349
|
+
if not exist_task:
|
|
350
|
+
enqueue_task = Task(
|
|
351
|
+
id = task_id,
|
|
352
|
+
context_id = context_id,
|
|
353
|
+
status = TaskStatus(state = TASK_STATE_SUBMITTED, message = context.message),
|
|
354
|
+
)
|
|
355
|
+
await event_queue.enqueue_event(enqueue_task)
|
|
356
|
+
|
|
357
|
+
started = False
|
|
358
|
+
history_tasks = []
|
|
359
|
+
for _ in range(60):
|
|
360
|
+
started, history_tasks = await start_task()
|
|
361
|
+
if started:
|
|
362
|
+
break
|
|
363
|
+
await asyncio.sleep(10)
|
|
364
|
+
if not started:
|
|
365
|
+
await updater.update_status(
|
|
366
|
+
TASK_STATE_REJECTED,
|
|
367
|
+
new_text_message(f"Context {context_id} is working in task {history_tasks[0].id}, place this task to queue."),
|
|
368
|
+
)
|
|
369
|
+
return
|
|
370
|
+
|
|
371
|
+
history_tasks = sorted(history_tasks, key = lambda t: t.status.timestamp.ToDatetime())
|
|
372
|
+
history = []
|
|
373
|
+
history_task_ids = []
|
|
374
|
+
for history_task in history_tasks:
|
|
375
|
+
if history_task.history:
|
|
376
|
+
history.append(history_task.history[0])
|
|
377
|
+
history_task_ids.append(history_task.id)
|
|
378
|
+
|
|
379
|
+
await self.stream_graph(context.message, history, context_id, task_id, context.metadata or {}, updater)
|
|
380
|
+
for history_task_id in history_task_ids:
|
|
381
|
+
task = await self.task_store.get(history_task_id, context.call_context)
|
|
382
|
+
if task:
|
|
383
|
+
await self._update_task_store_status(task, TASK_STATE_COMPLETED, context.call_context)
|
|
384
|
+
except Exception as e:
|
|
385
|
+
if not isinstance(e, ValueError):
|
|
386
|
+
logger.error(f"An error occurred while streaming the response: {e}", exc_info=True)
|
|
387
|
+
raise InternalError(message=str(e))
|
|
388
|
+
except asyncio.CancelledError:
|
|
389
|
+
await updater.cancel()
|
|
390
|
+
raise
|
|
391
|
+
finally:
|
|
392
|
+
await asyncio.sleep(0)
|
|
393
|
+
|
|
394
|
+
async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None:
|
|
395
|
+
task_id = context.current_task.id if context.current_task else context.message.task_id or None
|
|
396
|
+
if not task_id:
|
|
397
|
+
raise InvalidParamsError(message="Must assign task_id for canceling task.")
|
|
398
|
+
context_id = context.current_task.context_id if context.current_task else context.message.context_id or None
|
|
399
|
+
if not context_id:
|
|
400
|
+
raise InvalidParamsError(message=f"Can not match context_id for canceling task {task_id}.")
|
|
401
|
+
exist_task = await self.task_store.get(task_id, context.call_context)
|
|
402
|
+
if exist_task and exist_task.status.state == TASK_STATE_INPUT_REQUIRED:
|
|
403
|
+
config = {
|
|
404
|
+
"configurable": {
|
|
405
|
+
"thread_id": context_id,
|
|
406
|
+
},
|
|
407
|
+
}
|
|
408
|
+
try:
|
|
409
|
+
await self.graph_with_ck.aupdate_state(config, None, as_node=END)
|
|
410
|
+
await self._update_task_store_status(exist_task, TASK_STATE_CANCELED, context.call_context)
|
|
411
|
+
except Exception as e:
|
|
412
|
+
raise InternalError(message=f"Cancel task {task_id} failed. {e}")
|
|
413
|
+
if task_id in self.run_controls:
|
|
414
|
+
self.run_controls[task_id].request_drain()
|
|
415
|
+
for _ in range(200):
|
|
416
|
+
if task_id not in self.run_controls:
|
|
417
|
+
break
|
|
418
|
+
await asyncio.sleep(2)
|
|
419
|
+
self.run_controls.pop(task_id, None)
|
|
420
|
+
|
|
421
|
+
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
from typing import Any, Union
|
|
2
|
+
import base64
|
|
3
|
+
import uuid
|
|
4
|
+
|
|
5
|
+
from google.protobuf.json_format import MessageToDict, ParseDict
|
|
6
|
+
from google.protobuf.struct_pb2 import Value
|
|
7
|
+
|
|
8
|
+
from a2a.types import Message, Part, Role
|
|
9
|
+
|
|
10
|
+
from langchain_core.messages import AnyMessage, HumanMessage, AIMessage, ToolMessage
|
|
11
|
+
from langchain_core.messages.content import KNOWN_BLOCK_TYPES
|
|
12
|
+
|
|
13
|
+
from langgraph_a2a_api.utils.types import atomic_type
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def a2a_part_to_langgraph(part: Part) -> Union[str, dict[str, Any]]:
|
|
17
|
+
part_type = part.WhichOneof("content")
|
|
18
|
+
if part_type == "text":
|
|
19
|
+
return {
|
|
20
|
+
"type": "text",
|
|
21
|
+
"text": part.text,
|
|
22
|
+
}
|
|
23
|
+
elif part_type == "data":
|
|
24
|
+
data = MessageToDict(part.data)
|
|
25
|
+
if isinstance(data, dict) and data.get("type") in KNOWN_BLOCK_TYPES:
|
|
26
|
+
return data
|
|
27
|
+
return {
|
|
28
|
+
"type": "non_standard",
|
|
29
|
+
"value": data,
|
|
30
|
+
}
|
|
31
|
+
elif part_type == "raw":
|
|
32
|
+
file_block = {
|
|
33
|
+
"type": "file",
|
|
34
|
+
"base64": base64.b64encode(part.raw).decode("ascii"),
|
|
35
|
+
}
|
|
36
|
+
if part.media_type:
|
|
37
|
+
file_block["mime_type"] = part.media_type
|
|
38
|
+
if part.filename:
|
|
39
|
+
file_block["extras"] = {"filename": part.filename}
|
|
40
|
+
return file_block
|
|
41
|
+
elif part_type == "url":
|
|
42
|
+
file_block = {
|
|
43
|
+
"type": "file",
|
|
44
|
+
"url": part.url,
|
|
45
|
+
}
|
|
46
|
+
if part.media_type:
|
|
47
|
+
file_block["mime_type"] = part.media_type
|
|
48
|
+
if part.filename:
|
|
49
|
+
file_block["extras"] = {"filename": part.filename}
|
|
50
|
+
return file_block
|
|
51
|
+
else:
|
|
52
|
+
raise ValueError(f"Unknown a2a part type {part}.")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def langgraph_part_to_a2a(part: Union[str, dict[str, Any]]) -> Part:
|
|
56
|
+
if isinstance(part, str):
|
|
57
|
+
return Part(text = part)
|
|
58
|
+
elif isinstance(part, dict) and part.get("type") in KNOWN_BLOCK_TYPES:
|
|
59
|
+
part_type = part.get("type")
|
|
60
|
+
if part_type == "text":
|
|
61
|
+
return Part(text = part.get("text", ""))
|
|
62
|
+
elif part_type == "reasoning":
|
|
63
|
+
return Part(text = part.get("reasoning", ""))
|
|
64
|
+
elif part_type in {"image", "audio", "video", "file"}:
|
|
65
|
+
proto = Part()
|
|
66
|
+
if "base64" in part:
|
|
67
|
+
proto.raw = base64.b64decode(part["base64"])
|
|
68
|
+
elif "url" in part:
|
|
69
|
+
proto.url = part["url"]
|
|
70
|
+
else:
|
|
71
|
+
raise ValueError(f"Unknown langgraph file part type {part}.")
|
|
72
|
+
if part.get("mime_type"):
|
|
73
|
+
proto.media_type = part["mime_type"]
|
|
74
|
+
extras = part.get("extras")
|
|
75
|
+
if isinstance(extras, dict) and extras.get("filename"):
|
|
76
|
+
proto.filename = extras["filename"]
|
|
77
|
+
return proto
|
|
78
|
+
elif part_type == "non_standard":
|
|
79
|
+
raw = {k: v.model_dump() if hasattr(v, "model_dump") else v for k, v in part["value"].items()}
|
|
80
|
+
value = ParseDict(raw, Value())
|
|
81
|
+
proto = Part()
|
|
82
|
+
proto.data.CopyFrom(value)
|
|
83
|
+
return proto
|
|
84
|
+
else:
|
|
85
|
+
raw = {k: v.model_dump() if hasattr(v, "model_dump") else v for k, v in part.items()}
|
|
86
|
+
value = ParseDict(raw, Value())
|
|
87
|
+
proto = Part()
|
|
88
|
+
proto.data.CopyFrom(value)
|
|
89
|
+
return proto
|
|
90
|
+
else:
|
|
91
|
+
raise ValueError(f"Unknown langgraph part {part}.")
|
|
92
|
+
|
|
93
|
+
def a2a_message_to_langgraph(message: Message) -> AnyMessage:
|
|
94
|
+
if message.parts and len(message.parts) == 1 and message.parts[0].WhichOneof("content") == "text":
|
|
95
|
+
content = message.parts[0].text
|
|
96
|
+
elif message.parts:
|
|
97
|
+
content = [a2a_part_to_langgraph(part) for part in message.parts]
|
|
98
|
+
else:
|
|
99
|
+
content = ""
|
|
100
|
+
if message.role == Role.Value("ROLE_USER"):
|
|
101
|
+
return HumanMessage(
|
|
102
|
+
id = message.message_id or str(uuid.uuid4()),
|
|
103
|
+
content = content,
|
|
104
|
+
)
|
|
105
|
+
else:
|
|
106
|
+
return AIMessage(
|
|
107
|
+
id = message.message_id or str(uuid.uuid4()),
|
|
108
|
+
content = content,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def langgraph_content_to_a2a(content) -> list[Part]:
|
|
113
|
+
if isinstance(content, str):
|
|
114
|
+
return [langgraph_part_to_a2a(content)]
|
|
115
|
+
return [langgraph_part_to_a2a(part) for part in content]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def langgraph_message_to_a2a(message: AnyMessage, context_id: str, task_id: str) -> Message:
|
|
119
|
+
if isinstance(message, HumanMessage):
|
|
120
|
+
role = Role.Value("ROLE_USER")
|
|
121
|
+
parts = langgraph_content_to_a2a(message.content)
|
|
122
|
+
elif isinstance(message, AIMessage):
|
|
123
|
+
role = Role.Value("ROLE_AGENT")
|
|
124
|
+
parts = langgraph_content_to_a2a(message.content)
|
|
125
|
+
if message.tool_calls:
|
|
126
|
+
parts.extend([langgraph_part_to_a2a({"type": "non_standard", "value": tc}) for tc in message.tool_calls])
|
|
127
|
+
if message.invalid_tool_calls:
|
|
128
|
+
parts.extend([langgraph_part_to_a2a({"type": "non_standard", "value": tc}) for tc in message.invalid_tool_calls])
|
|
129
|
+
elif isinstance(message, ToolMessage):
|
|
130
|
+
role = Role.Value("ROLE_AGENT")
|
|
131
|
+
tool_result = {"type": "tool_result", "tool_call_id": message.tool_call_id, "content": message.content}
|
|
132
|
+
parts = [langgraph_part_to_a2a({"type": "non_standard", "value": tool_result})]
|
|
133
|
+
else:
|
|
134
|
+
role = Role.Value("ROLE_AGENT")
|
|
135
|
+
parts = langgraph_content_to_a2a(message.content)
|
|
136
|
+
return Message(
|
|
137
|
+
role = role,
|
|
138
|
+
parts = parts,
|
|
139
|
+
message_id = message.id or str(uuid.uuid4()),
|
|
140
|
+
task_id = task_id or "",
|
|
141
|
+
context_id = context_id or "",
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def any_content_to_langgraph(custom: Any) -> Union[str, list[Union[str, dict[str, Any]]]]:
|
|
146
|
+
if isinstance(custom, atomic_type):
|
|
147
|
+
content = str(custom)
|
|
148
|
+
elif isinstance(custom, list):
|
|
149
|
+
content = []
|
|
150
|
+
for part in custom:
|
|
151
|
+
if isinstance(part, atomic_type):
|
|
152
|
+
content.append(str(part))
|
|
153
|
+
elif isinstance(part, dict):
|
|
154
|
+
content.append({"type": "non_standard", "value": part})
|
|
155
|
+
else:
|
|
156
|
+
raise ValueError(f"Unsupported type of langgraph content part {part}.")
|
|
157
|
+
if len(content) == 1 and isinstance(content[0], str):
|
|
158
|
+
content = content[0]
|
|
159
|
+
elif isinstance(custom, dict):
|
|
160
|
+
content = [{"type": "non_standard", "value": custom}]
|
|
161
|
+
else:
|
|
162
|
+
raise ValueError(f"Unsupported type of langgraph message content {custom}.")
|
|
163
|
+
try:
|
|
164
|
+
HumanMessage(content = content)
|
|
165
|
+
except Exception:
|
|
166
|
+
raise ValueError(f"Unsupported type of langgraph message content {custom}.")
|
|
167
|
+
return content
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from langchain_core.messages import AnyMessage
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
LLM_SAFE_CONTENT_TYPES = {"text", "image", "audio", "file", "text-plain", "video"}
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def sanitize_messages_for_llm(messages: list[AnyMessage]) -> list[AnyMessage]:
|
|
9
|
+
result = []
|
|
10
|
+
for msg in messages:
|
|
11
|
+
if isinstance(msg.content, str):
|
|
12
|
+
result.append(msg)
|
|
13
|
+
continue
|
|
14
|
+
if not isinstance(msg.content, list):
|
|
15
|
+
raise ValueError(f"Unexpected message content type: {type(msg.content).__name__}, value: {msg.content}")
|
|
16
|
+
sanitized = []
|
|
17
|
+
for part in msg.content:
|
|
18
|
+
if isinstance(part, str):
|
|
19
|
+
sanitized.append(part)
|
|
20
|
+
elif isinstance(part, dict) and part.get("type") in LLM_SAFE_CONTENT_TYPES:
|
|
21
|
+
sanitized.append(part)
|
|
22
|
+
elif isinstance(part, dict):
|
|
23
|
+
value = part.get("value", part)
|
|
24
|
+
sanitized.append({
|
|
25
|
+
"type": "text",
|
|
26
|
+
"text": json.dumps(value, ensure_ascii=False, default=str),
|
|
27
|
+
})
|
|
28
|
+
else:
|
|
29
|
+
raise ValueError(f"Unexpected content part type: {type(part).__name__}, value: {part}")
|
|
30
|
+
if not sanitized:
|
|
31
|
+
result.append(msg.__class__(id=msg.id, content=""))
|
|
32
|
+
else:
|
|
33
|
+
result.append(msg.__class__(id=msg.id, content=sanitized))
|
|
34
|
+
return result
|