coco-framework 0.1.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.
Files changed (400) hide show
  1. coco/__init__.py +3 -0
  2. coco/agents/__init__.py +9 -0
  3. coco/agents/_subagent_transformer.py +307 -0
  4. coco/agents/factory.py +2164 -0
  5. coco/agents/middleware/__init__.py +100 -0
  6. coco/agents/middleware/_execution.py +385 -0
  7. coco/agents/middleware/_redaction.py +454 -0
  8. coco/agents/middleware/_retry.py +134 -0
  9. coco/agents/middleware/_trace_policy.py +61 -0
  10. coco/agents/middleware/context_editing.py +306 -0
  11. coco/agents/middleware/file_search.py +433 -0
  12. coco/agents/middleware/human_in_the_loop.py +680 -0
  13. coco/agents/middleware/internal_call_transformer.py +108 -0
  14. coco/agents/middleware/model_call_limit.py +267 -0
  15. coco/agents/middleware/model_fallback.py +417 -0
  16. coco/agents/middleware/model_retry.py +327 -0
  17. coco/agents/middleware/pii.py +878 -0
  18. coco/agents/middleware/provider_tool_search.py +307 -0
  19. coco/agents/middleware/shell_tool.py +949 -0
  20. coco/agents/middleware/summarization.py +910 -0
  21. coco/agents/middleware/todo.py +357 -0
  22. coco/agents/middleware/tool_call_limit.py +495 -0
  23. coco/agents/middleware/tool_emulator.py +251 -0
  24. coco/agents/middleware/tool_error.py +209 -0
  25. coco/agents/middleware/tool_retry.py +421 -0
  26. coco/agents/middleware/tool_selection.py +515 -0
  27. coco/agents/middleware/types.py +2201 -0
  28. coco/agents/structured_output.py +472 -0
  29. coco/chat_models/__init__.py +7 -0
  30. coco/chat_models/base.py +1082 -0
  31. coco/embeddings/__init__.py +18 -0
  32. coco/embeddings/base.py +279 -0
  33. coco/mcp/__init__.py +36 -0
  34. coco/mcp/adapter.py +245 -0
  35. coco/mcp/elicitation.py +394 -0
  36. coco/mcp/tools.py +307 -0
  37. coco/messages/__init__.py +73 -0
  38. coco/py.typed +0 -0
  39. coco/rate_limiters/__init__.py +13 -0
  40. coco/tools/__init__.py +22 -0
  41. coco/tools/tool_node.py +20 -0
  42. coco_anthropic/__init__.py +15 -0
  43. coco_anthropic/_client_utils.py +81 -0
  44. coco_anthropic/_compat.py +228 -0
  45. coco_anthropic/_sdk_compat.py +124 -0
  46. coco_anthropic/_version.py +3 -0
  47. coco_anthropic/chat_models.py +3349 -0
  48. coco_anthropic/data/__init__.py +1 -0
  49. coco_anthropic/data/_profiles.py +491 -0
  50. coco_anthropic/data/profile_augmentations.toml +69 -0
  51. coco_anthropic/experimental.py +140 -0
  52. coco_anthropic/llms.py +428 -0
  53. coco_anthropic/middleware/__init__.py +25 -0
  54. coco_anthropic/middleware/anthropic_tools.py +1197 -0
  55. coco_anthropic/middleware/bash.py +84 -0
  56. coco_anthropic/middleware/file_search.py +360 -0
  57. coco_anthropic/middleware/prompt_caching.py +276 -0
  58. coco_anthropic/output_parsers.py +93 -0
  59. coco_anthropic/py.typed +0 -0
  60. coco_core/__init__.py +20 -0
  61. coco_core/_api/__init__.py +87 -0
  62. coco_core/_api/beta_decorator.py +266 -0
  63. coco_core/_api/deprecation.py +637 -0
  64. coco_core/_api/internal.py +23 -0
  65. coco_core/_api/path.py +50 -0
  66. coco_core/_import_utils.py +41 -0
  67. coco_core/_protocol.py +625 -0
  68. coco_core/_security/__init__.py +36 -0
  69. coco_core/_security/_exceptions.py +9 -0
  70. coco_core/_security/_policy.py +313 -0
  71. coco_core/_security/_ssrf_protection.py +155 -0
  72. coco_core/_security/_transport.py +254 -0
  73. coco_core/agents.py +258 -0
  74. coco_core/caches.py +272 -0
  75. coco_core/callbacks/__init__.py +132 -0
  76. coco_core/callbacks/base.py +1229 -0
  77. coco_core/callbacks/file.py +266 -0
  78. coco_core/callbacks/manager.py +2826 -0
  79. coco_core/callbacks/stdout.py +123 -0
  80. coco_core/callbacks/streaming_stdout.py +154 -0
  81. coco_core/callbacks/usage.py +124 -0
  82. coco_core/chat_history.py +267 -0
  83. coco_core/chat_loaders.py +26 -0
  84. coco_core/chat_sessions.py +19 -0
  85. coco_core/cross_encoders.py +18 -0
  86. coco_core/document_loaders/__init__.py +36 -0
  87. coco_core/document_loaders/base.py +155 -0
  88. coco_core/document_loaders/blob_loaders.py +38 -0
  89. coco_core/documents/__init__.py +55 -0
  90. coco_core/documents/base.py +347 -0
  91. coco_core/documents/compressor.py +74 -0
  92. coco_core/documents/transformers.py +79 -0
  93. coco_core/embeddings/__init__.py +31 -0
  94. coco_core/embeddings/embeddings.py +78 -0
  95. coco_core/embeddings/fake.py +129 -0
  96. coco_core/env.py +22 -0
  97. coco_core/example_selectors/__init__.py +47 -0
  98. coco_core/example_selectors/base.py +60 -0
  99. coco_core/example_selectors/length_based.py +131 -0
  100. coco_core/example_selectors/semantic_similarity.py +366 -0
  101. coco_core/exceptions.py +166 -0
  102. coco_core/globals.py +72 -0
  103. coco_core/indexing/__init__.py +53 -0
  104. coco_core/indexing/api.py +954 -0
  105. coco_core/indexing/base.py +661 -0
  106. coco_core/indexing/in_memory.py +104 -0
  107. coco_core/language_models/__init__.py +116 -0
  108. coco_core/language_models/_compat_bridge.py +844 -0
  109. coco_core/language_models/_utils.py +343 -0
  110. coco_core/language_models/base.py +500 -0
  111. coco_core/language_models/chat_model_stream.py +1525 -0
  112. coco_core/language_models/chat_models.py +2748 -0
  113. coco_core/language_models/fake.py +137 -0
  114. coco_core/language_models/fake_chat_models.py +396 -0
  115. coco_core/language_models/llms.py +1569 -0
  116. coco_core/language_models/model_profile.py +183 -0
  117. coco_core/load/__init__.py +44 -0
  118. coco_core/load/_validation.py +191 -0
  119. coco_core/load/dump.py +120 -0
  120. coco_core/load/load.py +828 -0
  121. coco_core/load/mapping.py +1097 -0
  122. coco_core/load/serializable.py +406 -0
  123. coco_core/messages/__init__.py +198 -0
  124. coco_core/messages/ai.py +854 -0
  125. coco_core/messages/base.py +518 -0
  126. coco_core/messages/block_translators/__init__.py +112 -0
  127. coco_core/messages/block_translators/anthropic.py +534 -0
  128. coco_core/messages/block_translators/bedrock.py +108 -0
  129. coco_core/messages/block_translators/bedrock_converse.py +329 -0
  130. coco_core/messages/block_translators/coco_v0.py +303 -0
  131. coco_core/messages/block_translators/google_genai.py +610 -0
  132. coco_core/messages/block_translators/google_vertexai.py +21 -0
  133. coco_core/messages/block_translators/groq.py +157 -0
  134. coco_core/messages/block_translators/openai.py +1089 -0
  135. coco_core/messages/chat.py +64 -0
  136. coco_core/messages/content.py +1488 -0
  137. coco_core/messages/function.py +62 -0
  138. coco_core/messages/human.py +70 -0
  139. coco_core/messages/modifier.py +33 -0
  140. coco_core/messages/system.py +70 -0
  141. coco_core/messages/tool.py +418 -0
  142. coco_core/messages/utils.py +2442 -0
  143. coco_core/output_parsers/__init__.py +101 -0
  144. coco_core/output_parsers/base.py +361 -0
  145. coco_core/output_parsers/format_instructions.py +16 -0
  146. coco_core/output_parsers/json.py +139 -0
  147. coco_core/output_parsers/list.py +249 -0
  148. coco_core/output_parsers/openai_functions.py +324 -0
  149. coco_core/output_parsers/openai_tools.py +383 -0
  150. coco_core/output_parsers/pydantic.py +141 -0
  151. coco_core/output_parsers/string.py +63 -0
  152. coco_core/output_parsers/transform.py +175 -0
  153. coco_core/output_parsers/xml.py +301 -0
  154. coco_core/outputs/__init__.py +66 -0
  155. coco_core/outputs/chat_generation.py +147 -0
  156. coco_core/outputs/chat_result.py +39 -0
  157. coco_core/outputs/generation.py +80 -0
  158. coco_core/outputs/llm_result.py +112 -0
  159. coco_core/outputs/run_info.py +22 -0
  160. coco_core/prompt_values.py +161 -0
  161. coco_core/prompts/__init__.py +101 -0
  162. coco_core/prompts/base.py +508 -0
  163. coco_core/prompts/chat.py +1495 -0
  164. coco_core/prompts/dict.py +177 -0
  165. coco_core/prompts/few_shot.py +483 -0
  166. coco_core/prompts/few_shot_with_templates.py +239 -0
  167. coco_core/prompts/image.py +177 -0
  168. coco_core/prompts/loading.py +296 -0
  169. coco_core/prompts/message.py +97 -0
  170. coco_core/prompts/prompt.py +312 -0
  171. coco_core/prompts/string.py +399 -0
  172. coco_core/prompts/structured.py +214 -0
  173. coco_core/py.typed +0 -0
  174. coco_core/rate_limiters.py +254 -0
  175. coco_core/retrievers.py +328 -0
  176. coco_core/runnables/__init__.py +136 -0
  177. coco_core/runnables/base.py +6725 -0
  178. coco_core/runnables/branch.py +457 -0
  179. coco_core/runnables/config.py +712 -0
  180. coco_core/runnables/configurable.py +710 -0
  181. coco_core/runnables/fallbacks.py +663 -0
  182. coco_core/runnables/graph.py +741 -0
  183. coco_core/runnables/graph_ascii.py +366 -0
  184. coco_core/runnables/graph_mermaid.py +503 -0
  185. coco_core/runnables/graph_png.py +215 -0
  186. coco_core/runnables/history.py +631 -0
  187. coco_core/runnables/passthrough.py +849 -0
  188. coco_core/runnables/retry.py +379 -0
  189. coco_core/runnables/router.py +239 -0
  190. coco_core/runnables/schema.py +188 -0
  191. coco_core/runnables/utils.py +798 -0
  192. coco_core/stores.py +291 -0
  193. coco_core/structured_query.py +203 -0
  194. coco_core/sys_info.py +154 -0
  195. coco_core/tools/__init__.py +95 -0
  196. coco_core/tools/base.py +1948 -0
  197. coco_core/tools/convert.py +497 -0
  198. coco_core/tools/render.py +67 -0
  199. coco_core/tools/retriever.py +94 -0
  200. coco_core/tools/simple.py +208 -0
  201. coco_core/tools/structured.py +322 -0
  202. coco_core/tracers/__init__.py +47 -0
  203. coco_core/tracers/_compat.py +95 -0
  204. coco_core/tracers/_context.py +337 -0
  205. coco_core/tracers/_streaming.py +53 -0
  206. coco_core/tracers/base.py +955 -0
  207. coco_core/tracers/coco.py +279 -0
  208. coco_core/tracers/context.py +205 -0
  209. coco_core/tracers/core.py +766 -0
  210. coco_core/tracers/event_stream.py +1101 -0
  211. coco_core/tracers/log_stream.py +765 -0
  212. coco_core/tracers/memory_stream.py +200 -0
  213. coco_core/tracers/root_listeners.py +130 -0
  214. coco_core/tracers/run_collector.py +40 -0
  215. coco_core/tracers/schemas.py +14 -0
  216. coco_core/tracers/stdout.py +205 -0
  217. coco_core/utils/__init__.py +111 -0
  218. coco_core/utils/_gateway.py +274 -0
  219. coco_core/utils/_merge.py +218 -0
  220. coco_core/utils/aiter.py +357 -0
  221. coco_core/utils/env.py +86 -0
  222. coco_core/utils/formatting.py +80 -0
  223. coco_core/utils/function_calling.py +869 -0
  224. coco_core/utils/html.py +132 -0
  225. coco_core/utils/image.py +15 -0
  226. coco_core/utils/input.py +82 -0
  227. coco_core/utils/interactive_env.py +12 -0
  228. coco_core/utils/iter.py +229 -0
  229. coco_core/utils/json.py +230 -0
  230. coco_core/utils/json_schema.py +273 -0
  231. coco_core/utils/mustache.py +706 -0
  232. coco_core/utils/pydantic.py +631 -0
  233. coco_core/utils/strings.py +69 -0
  234. coco_core/utils/usage.py +61 -0
  235. coco_core/utils/utils.py +528 -0
  236. coco_core/utils/uuid.py +57 -0
  237. coco_core/vectorstores/__init__.py +53 -0
  238. coco_core/vectorstores/base.py +1111 -0
  239. coco_core/vectorstores/in_memory.py +546 -0
  240. coco_core/vectorstores/utils.py +163 -0
  241. coco_core/version.py +3 -0
  242. coco_framework-0.1.0.dist-info/METADATA +184 -0
  243. coco_framework-0.1.0.dist-info/RECORD +400 -0
  244. coco_framework-0.1.0.dist-info/WHEEL +4 -0
  245. coco_framework-0.1.0.dist-info/licenses/LICENSE +24 -0
  246. coco_framework-0.1.0.dist-info/licenses/NOTICE +15 -0
  247. coco_graph/_internal/__init__.py +4 -0
  248. coco_graph/_internal/_cache.py +31 -0
  249. coco_graph/_internal/_config.py +474 -0
  250. coco_graph/_internal/_constants.py +140 -0
  251. coco_graph/_internal/_fields.py +213 -0
  252. coco_graph/_internal/_future.py +220 -0
  253. coco_graph/_internal/_pydantic.py +275 -0
  254. coco_graph/_internal/_queue.py +124 -0
  255. coco_graph/_internal/_replay.py +90 -0
  256. coco_graph/_internal/_retry.py +29 -0
  257. coco_graph/_internal/_runnable.py +969 -0
  258. coco_graph/_internal/_scratchpad.py +19 -0
  259. coco_graph/_internal/_serde.py +253 -0
  260. coco_graph/_internal/_timeout.py +25 -0
  261. coco_graph/_internal/_typing.py +54 -0
  262. coco_graph/cache/base/__init__.py +48 -0
  263. coco_graph/cache/base/py.typed +0 -0
  264. coco_graph/cache/memory/__init__.py +73 -0
  265. coco_graph/cache/redis/__init__.py +144 -0
  266. coco_graph/cache/sqlite/__init__.py +120 -0
  267. coco_graph/callbacks.py +394 -0
  268. coco_graph/channels/__init__.py +29 -0
  269. coco_graph/channels/any_value.py +72 -0
  270. coco_graph/channels/base.py +121 -0
  271. coco_graph/channels/binop.py +155 -0
  272. coco_graph/channels/delta.py +202 -0
  273. coco_graph/channels/ephemeral_value.py +79 -0
  274. coco_graph/channels/last_value.py +151 -0
  275. coco_graph/channels/named_barrier_value.py +167 -0
  276. coco_graph/channels/topic.py +94 -0
  277. coco_graph/channels/untracked_value.py +73 -0
  278. coco_graph/checkpoint/base/__init__.py +861 -0
  279. coco_graph/checkpoint/base/id.py +109 -0
  280. coco_graph/checkpoint/base/py.typed +0 -0
  281. coco_graph/checkpoint/memory/__init__.py +698 -0
  282. coco_graph/checkpoint/memory/py.typed +0 -0
  283. coco_graph/checkpoint/postgres/__init__.py +602 -0
  284. coco_graph/checkpoint/postgres/_ainternal.py +23 -0
  285. coco_graph/checkpoint/postgres/_internal.py +21 -0
  286. coco_graph/checkpoint/postgres/aio.py +691 -0
  287. coco_graph/checkpoint/postgres/base.py +666 -0
  288. coco_graph/checkpoint/postgres/py.typed +0 -0
  289. coco_graph/checkpoint/postgres/shallow.py +967 -0
  290. coco_graph/checkpoint/serde/__init__.py +0 -0
  291. coco_graph/checkpoint/serde/_msgpack.py +97 -0
  292. coco_graph/checkpoint/serde/base.py +64 -0
  293. coco_graph/checkpoint/serde/encrypted.py +80 -0
  294. coco_graph/checkpoint/serde/event_hooks.py +52 -0
  295. coco_graph/checkpoint/serde/jsonplus.py +883 -0
  296. coco_graph/checkpoint/serde/py.typed +0 -0
  297. coco_graph/checkpoint/serde/types.py +68 -0
  298. coco_graph/checkpoint/sqlite/__init__.py +645 -0
  299. coco_graph/checkpoint/sqlite/_delta.py +172 -0
  300. coco_graph/checkpoint/sqlite/aio.py +738 -0
  301. coco_graph/checkpoint/sqlite/py.typed +0 -0
  302. coco_graph/checkpoint/sqlite/utils.py +116 -0
  303. coco_graph/config.py +196 -0
  304. coco_graph/constants.py +64 -0
  305. coco_graph/errors.py +241 -0
  306. coco_graph/func/__init__.py +620 -0
  307. coco_graph/graph/__init__.py +12 -0
  308. coco_graph/graph/_branch.py +225 -0
  309. coco_graph/graph/_node.py +103 -0
  310. coco_graph/graph/message.py +437 -0
  311. coco_graph/graph/state.py +1978 -0
  312. coco_graph/graph/ui.py +227 -0
  313. coco_graph/managed/__init__.py +3 -0
  314. coco_graph/managed/base.py +31 -0
  315. coco_graph/managed/is_last_step.py +24 -0
  316. coco_graph/prebuilt/__init__.py +23 -0
  317. coco_graph/prebuilt/_tool_call_stream.py +117 -0
  318. coco_graph/prebuilt/_tool_call_transformer.py +165 -0
  319. coco_graph/prebuilt/chat_agent_executor.py +1015 -0
  320. coco_graph/prebuilt/interrupt.py +105 -0
  321. coco_graph/prebuilt/py.typed +0 -0
  322. coco_graph/prebuilt/tool_node.py +2030 -0
  323. coco_graph/prebuilt/tool_validator.py +221 -0
  324. coco_graph/pregel/__init__.py +3 -0
  325. coco_graph/pregel/_algo.py +1460 -0
  326. coco_graph/pregel/_call.py +298 -0
  327. coco_graph/pregel/_checkpoint.py +331 -0
  328. coco_graph/pregel/_config.py +0 -0
  329. coco_graph/pregel/_draw.py +294 -0
  330. coco_graph/pregel/_executor.py +223 -0
  331. coco_graph/pregel/_io.py +174 -0
  332. coco_graph/pregel/_log.py +3 -0
  333. coco_graph/pregel/_loop.py +1988 -0
  334. coco_graph/pregel/_messages.py +461 -0
  335. coco_graph/pregel/_read.py +312 -0
  336. coco_graph/pregel/_retry.py +854 -0
  337. coco_graph/pregel/_runner.py +941 -0
  338. coco_graph/pregel/_tools.py +268 -0
  339. coco_graph/pregel/_utils.py +221 -0
  340. coco_graph/pregel/_validate.py +120 -0
  341. coco_graph/pregel/_write.py +192 -0
  342. coco_graph/pregel/debug.py +302 -0
  343. coco_graph/pregel/main.py +4364 -0
  344. coco_graph/pregel/protocol.py +288 -0
  345. coco_graph/pregel/types.py +38 -0
  346. coco_graph/py.typed +0 -0
  347. coco_graph/runtime.py +350 -0
  348. coco_graph/store/base/__init__.py +1322 -0
  349. coco_graph/store/base/batch.py +371 -0
  350. coco_graph/store/base/embed.py +436 -0
  351. coco_graph/store/base/py.typed +0 -0
  352. coco_graph/store/memory/__init__.py +592 -0
  353. coco_graph/store/memory/py.typed +0 -0
  354. coco_graph/store/postgres/__init__.py +4 -0
  355. coco_graph/store/postgres/aio.py +592 -0
  356. coco_graph/store/postgres/base.py +1480 -0
  357. coco_graph/store/postgres/py.typed +0 -0
  358. coco_graph/store/sqlite/__init__.py +4 -0
  359. coco_graph/store/sqlite/aio.py +632 -0
  360. coco_graph/store/sqlite/base.py +1514 -0
  361. coco_graph/stream/__init__.py +45 -0
  362. coco_graph/stream/_convert.py +32 -0
  363. coco_graph/stream/_mux.py +523 -0
  364. coco_graph/stream/_types.py +330 -0
  365. coco_graph/stream/run_stream.py +735 -0
  366. coco_graph/stream/stream_channel.py +341 -0
  367. coco_graph/stream/transformers.py +1039 -0
  368. coco_graph/types.py +1079 -0
  369. coco_graph/typing.py +48 -0
  370. coco_graph/utils/__init__.py +1 -0
  371. coco_graph/utils/config.py +4 -0
  372. coco_graph/utils/runnable.py +3 -0
  373. coco_graph/version.py +13 -0
  374. coco_graph/warnings.py +69 -0
  375. coco_openai/__init__.py +20 -0
  376. coco_openai/_compat.py +45 -0
  377. coco_openai/_version.py +3 -0
  378. coco_openai/chat_models/__init__.py +6 -0
  379. coco_openai/chat_models/_client_utils.py +684 -0
  380. coco_openai/chat_models/_compat.py +525 -0
  381. coco_openai/chat_models/azure.py +1227 -0
  382. coco_openai/chat_models/base.py +5654 -0
  383. coco_openai/chat_models/codex.py +557 -0
  384. coco_openai/chatgpt_oauth.py +1063 -0
  385. coco_openai/data/__init__.py +1 -0
  386. coco_openai/data/_profiles.py +1655 -0
  387. coco_openai/data/profile_augmentations.toml +127 -0
  388. coco_openai/embeddings/__init__.py +6 -0
  389. coco_openai/embeddings/azure.py +240 -0
  390. coco_openai/embeddings/base.py +820 -0
  391. coco_openai/llms/__init__.py +6 -0
  392. coco_openai/llms/azure.py +249 -0
  393. coco_openai/llms/base.py +919 -0
  394. coco_openai/middleware/__init__.py +11 -0
  395. coco_openai/middleware/openai_moderation.py +484 -0
  396. coco_openai/output_parsers/__init__.py +9 -0
  397. coco_openai/output_parsers/tools.py +9 -0
  398. coco_openai/py.typed +0 -0
  399. coco_openai/tools/__init__.py +5 -0
  400. coco_openai/tools/custom_tool.py +111 -0
@@ -0,0 +1,4364 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import concurrent
5
+ import concurrent.futures
6
+ import contextlib
7
+ import queue
8
+ import warnings
9
+ import weakref
10
+ from collections import defaultdict, deque
11
+ from collections.abc import (
12
+ AsyncIterator,
13
+ Awaitable,
14
+ Callable,
15
+ Iterator,
16
+ Mapping,
17
+ Sequence,
18
+ )
19
+ from dataclasses import is_dataclass, replace
20
+ from datetime import timedelta
21
+ from functools import partial
22
+ from inspect import isclass
23
+ from typing import (
24
+ Any,
25
+ Generic,
26
+ Literal,
27
+ cast,
28
+ get_type_hints,
29
+ overload,
30
+ )
31
+ from uuid import UUID, uuid5
32
+
33
+ from coco_core._api import beta
34
+ from coco_core.globals import get_debug
35
+ from coco_core.runnables import (
36
+ RunnableSequence,
37
+ )
38
+ from coco_core.runnables.base import Input, Output
39
+ from coco_core.runnables.config import (
40
+ RunnableConfig,
41
+ get_async_callback_manager_for_config,
42
+ get_callback_manager_for_config,
43
+ )
44
+ from coco_core.runnables.graph import Graph
45
+ from coco_core.runnables.schema import StreamEvent
46
+ from coco_graph.cache.base import BaseCache
47
+ from coco_graph.checkpoint.base import (
48
+ BaseCheckpointSaver,
49
+ Checkpoint,
50
+ CheckpointTuple,
51
+ )
52
+ from coco_graph.store.base import BaseStore
53
+ from pydantic import BaseModel, TypeAdapter
54
+ from typing_extensions import Self, Unpack, deprecated, is_typeddict
55
+
56
+ from coco_graph._internal import _serde
57
+ from coco_graph._internal._config import (
58
+ ensure_config,
59
+ merge_configs,
60
+ patch_checkpoint_map,
61
+ patch_config,
62
+ patch_configurable,
63
+ recast_checkpoint_ns,
64
+ )
65
+ from coco_graph._internal._constants import (
66
+ CACHE_NS_WRITES,
67
+ CONF,
68
+ CONFIG_KEY_CACHE,
69
+ CONFIG_KEY_CHECKPOINT_ID,
70
+ CONFIG_KEY_CHECKPOINT_NS,
71
+ CONFIG_KEY_CHECKPOINTER,
72
+ CONFIG_KEY_DURABILITY,
73
+ CONFIG_KEY_NODE_FINISHED,
74
+ CONFIG_KEY_READ,
75
+ CONFIG_KEY_RUNNER_SUBMIT,
76
+ CONFIG_KEY_RUNTIME,
77
+ CONFIG_KEY_SEND,
78
+ CONFIG_KEY_STREAM,
79
+ CONFIG_KEY_STREAM_MESSAGES_V2,
80
+ CONFIG_KEY_TASK_ID,
81
+ CONFIG_KEY_THREAD_ID,
82
+ ERROR,
83
+ INPUT,
84
+ INTERRUPT,
85
+ NS_END,
86
+ NS_SEP,
87
+ NULL_TASK_ID,
88
+ PUSH,
89
+ TASKS,
90
+ )
91
+ from coco_graph._internal._pydantic import create_model
92
+ from coco_graph._internal._queue import ( # type: ignore[attr-defined]
93
+ AsyncQueue,
94
+ SyncQueue,
95
+ )
96
+ from coco_graph._internal._runnable import (
97
+ Runnable,
98
+ RunnableLike,
99
+ RunnableSeq,
100
+ coerce_to_runnable,
101
+ )
102
+ from coco_graph._internal._timeout import coerce_timeout_policy
103
+ from coco_graph._internal._typing import MISSING, DeprecatedKwargs
104
+ from coco_graph.callbacks import (
105
+ GraphInterruptEvent,
106
+ GraphResumeEvent,
107
+ get_async_graph_callback_manager_for_config,
108
+ get_sync_graph_callback_manager_for_config,
109
+ )
110
+ from coco_graph.channels.base import BaseChannel
111
+ from coco_graph.channels.topic import Topic
112
+ from coco_graph.config import get_config
113
+ from coco_graph.constants import END
114
+ from coco_graph.errors import (
115
+ ErrorCode,
116
+ GraphDrained,
117
+ GraphRecursionError,
118
+ InvalidUpdateError,
119
+ create_error_message,
120
+ )
121
+ from coco_graph.managed.base import ManagedValueSpec
122
+ from coco_graph.pregel._algo import (
123
+ PregelTaskWrites,
124
+ _scratchpad,
125
+ apply_writes,
126
+ local_read,
127
+ prepare_next_tasks,
128
+ )
129
+ from coco_graph.pregel._call import identifier
130
+ from coco_graph.pregel._checkpoint import (
131
+ achannels_from_checkpoint,
132
+ channels_from_checkpoint,
133
+ copy_checkpoint,
134
+ create_checkpoint,
135
+ create_checkpoint_plan_for_update_state_api,
136
+ empty_checkpoint,
137
+ get_updated_channels_from_tasks,
138
+ )
139
+ from coco_graph.pregel._draw import draw_graph
140
+ from coco_graph.pregel._io import map_input, read_channels
141
+ from coco_graph.pregel._loop import (
142
+ AsyncPregelLoop,
143
+ SyncPregelLoop,
144
+ )
145
+ from coco_graph.pregel._messages import (
146
+ StreamMessagesHandler,
147
+ StreamMessagesHandlerV2,
148
+ )
149
+ from coco_graph.pregel._read import DEFAULT_BOUND, PregelNode
150
+ from coco_graph.pregel._retry import RetryPolicy
151
+ from coco_graph.pregel._runner import PregelRunner
152
+ from coco_graph.pregel._tools import StreamToolCallHandler
153
+ from coco_graph.pregel._utils import (
154
+ get_new_channel_versions,
155
+ validate_timeout_supported,
156
+ )
157
+ from coco_graph.pregel._validate import validate_graph, validate_keys
158
+ from coco_graph.pregel._write import ChannelWrite, ChannelWriteEntry
159
+ from coco_graph.pregel.debug import get_bolded_text, get_colored_text, tasks_w_writes
160
+ from coco_graph.pregel.protocol import PregelProtocol, StreamChunk, StreamProtocol
161
+ from coco_graph.runtime import (
162
+ DEFAULT_RUNTIME,
163
+ BaseUser,
164
+ RunControl,
165
+ Runtime,
166
+ ServerInfo,
167
+ )
168
+ from coco_graph.stream._mux import StreamMux
169
+ from coco_graph.stream._types import StreamTransformer
170
+ from coco_graph.stream.run_stream import AsyncGraphRunStream, GraphRunStream
171
+ from coco_graph.stream.transformers import (
172
+ LifecycleTransformer,
173
+ MessagesTransformer,
174
+ SubgraphTransformer,
175
+ ValuesTransformer,
176
+ )
177
+ from coco_graph.types import (
178
+ All,
179
+ CachePolicy,
180
+ Checkpointer,
181
+ Command,
182
+ Durability,
183
+ GraphOutput,
184
+ Interrupt,
185
+ Send,
186
+ StateSnapshot,
187
+ StateUpdate,
188
+ StreamMode,
189
+ StreamPart,
190
+ TimeoutPolicy,
191
+ ensure_valid_checkpointer,
192
+ )
193
+ from coco_graph.typing import ContextT, InputT, OutputT, StateT
194
+ from coco_graph.warnings import CocoGraphDeprecatedSinceV10
195
+
196
+ try:
197
+ from coco_core.tracers._streaming import _StreamingCallbackHandler
198
+ except ImportError:
199
+ _StreamingCallbackHandler = None # type: ignore
200
+
201
+ __all__ = ("NodeBuilder", "Pregel")
202
+
203
+ _WriteValue = Callable[[Input], Output] | Any
204
+
205
+
206
+ class NodeBuilder:
207
+ __slots__ = (
208
+ "_channels",
209
+ "_triggers",
210
+ "_tags",
211
+ "_metadata",
212
+ "_writes",
213
+ "_bound",
214
+ "_retry_policy",
215
+ "_cache_policy",
216
+ "_timeout",
217
+ )
218
+
219
+ _channels: str | list[str]
220
+ _triggers: list[str]
221
+ _tags: list[str]
222
+ _metadata: dict[str, Any]
223
+ _writes: list[ChannelWriteEntry]
224
+ _bound: Runnable
225
+ _retry_policy: list[RetryPolicy]
226
+ _cache_policy: CachePolicy | None
227
+ _timeout: TimeoutPolicy | None
228
+
229
+ def __init__(
230
+ self,
231
+ ) -> None:
232
+ self._channels = []
233
+ self._triggers = []
234
+ self._tags = []
235
+ self._metadata = {}
236
+ self._writes = []
237
+ self._bound = DEFAULT_BOUND
238
+ self._retry_policy = []
239
+ self._cache_policy = None
240
+ self._timeout = None
241
+
242
+ def subscribe_only(
243
+ self,
244
+ channel: str,
245
+ ) -> Self:
246
+ """Subscribe to a single channel."""
247
+ if not self._channels:
248
+ self._channels = channel
249
+ else:
250
+ raise ValueError(
251
+ "Cannot subscribe to single channels when other channels are already subscribed to"
252
+ )
253
+
254
+ self._triggers.append(channel)
255
+
256
+ return self
257
+
258
+ def subscribe_to(
259
+ self,
260
+ *channels: str,
261
+ read: bool = True,
262
+ ) -> Self:
263
+ """Add channels to subscribe to.
264
+
265
+ Node will be invoked when any of these channels are updated, with a dict of the
266
+ channel values as input.
267
+
268
+ Args:
269
+ channels: Channel name(s) to subscribe to
270
+ read: If `True`, the channels will be included in the input to the node.
271
+ Otherwise, they will trigger the node without being sent in input.
272
+
273
+ Returns:
274
+ Self for chaining
275
+ """
276
+ if isinstance(self._channels, str):
277
+ raise ValueError(
278
+ "Cannot subscribe to channels when subscribed to a single channel"
279
+ )
280
+ if read:
281
+ if not self._channels:
282
+ self._channels = list(channels)
283
+ else:
284
+ self._channels.extend(channels)
285
+
286
+ if isinstance(channels, str):
287
+ self._triggers.append(channels)
288
+ else:
289
+ self._triggers.extend(channels)
290
+
291
+ return self
292
+
293
+ def read_from(
294
+ self,
295
+ *channels: str,
296
+ ) -> Self:
297
+ """Adds the specified channels to read from, without subscribing to them."""
298
+ assert isinstance(self._channels, list), (
299
+ "Cannot read additional channels when subscribed to single channels"
300
+ )
301
+ self._channels.extend(channels)
302
+ return self
303
+
304
+ def do(
305
+ self,
306
+ node: RunnableLike,
307
+ ) -> Self:
308
+ """Adds the specified node."""
309
+ if self._bound is not DEFAULT_BOUND:
310
+ self._bound = RunnableSeq(
311
+ self._bound, coerce_to_runnable(node, name=None, trace=True)
312
+ )
313
+ else:
314
+ self._bound = coerce_to_runnable(node, name=None, trace=True)
315
+ return self
316
+
317
+ def write_to(
318
+ self,
319
+ *channels: str | ChannelWriteEntry,
320
+ **kwargs: _WriteValue,
321
+ ) -> Self:
322
+ """Add channel writes.
323
+
324
+ Args:
325
+ *channels: Channel names to write to.
326
+ **kwargs: Channel name and value mappings.
327
+
328
+ Returns:
329
+ Self for chaining
330
+ """
331
+ self._writes.extend(
332
+ ChannelWriteEntry(c) if isinstance(c, str) else c for c in channels
333
+ )
334
+ self._writes.extend(
335
+ ChannelWriteEntry(k, mapper=v)
336
+ if callable(v)
337
+ else ChannelWriteEntry(k, value=v)
338
+ for k, v in kwargs.items()
339
+ )
340
+
341
+ return self
342
+
343
+ def meta(self, *tags: str, **metadata: Any) -> Self:
344
+ """Add tags or metadata to the node."""
345
+ self._tags.extend(tags)
346
+ self._metadata.update(metadata)
347
+ return self
348
+
349
+ def add_retry_policies(self, *policies: RetryPolicy) -> Self:
350
+ """Adds retry policies to the node."""
351
+ self._retry_policy.extend(policies)
352
+ return self
353
+
354
+ def add_cache_policy(self, policy: CachePolicy) -> Self:
355
+ """Adds cache policies to the node."""
356
+ self._cache_policy = policy
357
+ return self
358
+
359
+ def set_timeout(self, timeout: float | timedelta | TimeoutPolicy | None) -> Self:
360
+ """Set the per-attempt timeout policy for this node."""
361
+ self._timeout = coerce_timeout_policy(timeout)
362
+ return self
363
+
364
+ def build(self) -> PregelNode:
365
+ """Builds the node."""
366
+ return PregelNode(
367
+ channels=self._channels,
368
+ triggers=self._triggers,
369
+ tags=self._tags,
370
+ metadata=self._metadata,
371
+ writers=[ChannelWrite(self._writes)],
372
+ bound=self._bound,
373
+ retry_policy=self._retry_policy,
374
+ cache_policy=self._cache_policy,
375
+ timeout=self._timeout,
376
+ )
377
+
378
+
379
+ # Kwargs that ``stream_events(version="v3")`` / ``astream_events(version="v3")``
380
+ # manage internally and must not be overridden by callers. ``stream_mode`` is
381
+ # derived from the transformer mux; ``subgraphs`` is forced True so nested
382
+ # namespaces flow through scoped muxes. Forwarding either to the inner
383
+ # ``stream(...)`` would silently break v3's invariants, so we raise instead.
384
+ _V3_INVARIANT_KWARGS: tuple[str, ...] = ("stream_mode", "subgraphs")
385
+
386
+
387
+ def _reject_v3_invariant_kwargs(kwargs: dict[str, Any]) -> None:
388
+ collisions = [k for k in _V3_INVARIANT_KWARGS if k in kwargs]
389
+ if collisions:
390
+ raise TypeError(
391
+ "stream_events(version='v3') / astream_events(version='v3') do "
392
+ f"not accept {', '.join(collisions)}; v3 owns these "
393
+ "(stream_mode is built from the transformer mux, subgraphs is "
394
+ "forced True so nested namespaces flow through scoped muxes)."
395
+ )
396
+
397
+
398
+ def _collect_stream_modes(mux: Any) -> list[StreamMode]:
399
+ """Return the union of `required_stream_modes` across registered transformers.
400
+
401
+ Transformers declare the stream modes they need to function, and
402
+ `stream_events(version="v3")` asks the graph for exactly that union — no hardcoded
403
+ default set. If zero transformers declare a given mode, the graph
404
+ does not stream events for it.
405
+ """
406
+ modes: set[StreamMode] = set()
407
+ for transformer in mux._transformers:
408
+ modes.update(
409
+ cast(
410
+ "tuple[StreamMode, ...]",
411
+ getattr(transformer, "required_stream_modes", ()),
412
+ )
413
+ )
414
+ return list(modes)
415
+
416
+
417
+ def _normalize_stream_transformer_factories(
418
+ specs: Sequence[Callable[[tuple[str, ...]], Any]] | None,
419
+ ) -> list[Callable[[tuple[str, ...]], Any]]:
420
+ """Normalize stream transformer specs to scoped factories.
421
+
422
+ A stream transformer spec is a callable that accepts
423
+ `scope: tuple[str, ...]` and returns a fresh `StreamTransformer`.
424
+ Transformer classes work when their constructor follows the same
425
+ shape. Pre-built instances are rejected because they cannot be
426
+ cloned into subgraph scopes.
427
+ """
428
+ factories: list[Callable[[tuple[str, ...]], Any]] = []
429
+ for spec in specs or ():
430
+ if isinstance(spec, StreamTransformer):
431
+ raise TypeError(
432
+ "stream_events(version='v3') transformers must be scope-aware callables, "
433
+ f"got pre-built instance {type(spec).__name__}. Pass the "
434
+ "transformer class or a factory like "
435
+ "`lambda scope: MyTransformer(scope, ...)`."
436
+ )
437
+ if not callable(spec):
438
+ raise TypeError(
439
+ "stream_events(version='v3') transformers must be scope-aware callables, "
440
+ f"got {type(spec).__name__}."
441
+ )
442
+
443
+ def factory(scope: tuple[str, ...], _spec: Callable[..., Any] = spec) -> Any:
444
+ return _spec(scope)
445
+
446
+ factories.append(factory)
447
+ return factories
448
+
449
+
450
+ class Pregel(
451
+ PregelProtocol[StateT, ContextT, InputT, OutputT],
452
+ Generic[StateT, ContextT, InputT, OutputT],
453
+ ):
454
+ """Pregel manages the runtime behavior for CocoGraph applications.
455
+
456
+ ## Overview
457
+
458
+ Pregel combines [**actors**](https://en.wikipedia.org/wiki/Actor_model)
459
+ and **channels** into a single application.
460
+ **Actors** read data from channels and write data to channels.
461
+ Pregel organizes the execution of the application into multiple steps,
462
+ following the **Pregel Algorithm**/**Bulk Synchronous Parallel** model.
463
+
464
+ Each step consists of three phases:
465
+
466
+ - **Plan**: Determine which **actors** to execute in this step. For example,
467
+ in the first step, select the **actors** that subscribe to the special
468
+ **input** channels; in subsequent steps,
469
+ select the **actors** that subscribe to channels updated in the previous step.
470
+ - **Execution**: Execute all selected **actors** in parallel,
471
+ until all complete, or one fails, or a timeout is reached. During this
472
+ phase, channel updates are invisible to actors until the next step.
473
+ - **Update**: Update the channels with the values written by the **actors**
474
+ in this step.
475
+
476
+ Repeat until no **actors** are selected for execution, or a maximum number of
477
+ steps is reached.
478
+
479
+ ## Actors
480
+
481
+ An **actor** is a `PregelNode`.
482
+ It subscribes to channels, reads data from them, and writes data to them.
483
+ It can be thought of as an **actor** in the Pregel algorithm.
484
+ `PregelNodes` implement Coco's
485
+ Runnable interface.
486
+
487
+ ## Channels
488
+
489
+ Channels are used to communicate between actors (`PregelNodes`).
490
+ Each channel has a value type, an update type, and an update function – which
491
+ takes a sequence of updates and
492
+ modifies the stored value. Channels can be used to send data from one chain to
493
+ another, or to send data from a chain to itself in a future step. CocoGraph
494
+ provides a number of built-in channels:
495
+
496
+ ### Basic channels: LastValue and Topic
497
+
498
+ - `LastValue`: The default channel, stores the last value sent to the channel,
499
+ useful for input and output values, or for sending data from one step to the next
500
+ - `Topic`: A configurable PubSub Topic, useful for sending multiple values
501
+ between *actors*, or for accumulating output. Can be configured to deduplicate
502
+ values, and/or to accumulate values over the course of multiple steps.
503
+
504
+ ### Advanced channels: Context and BinaryOperatorAggregate
505
+
506
+ - `Context`: exposes the value of a context manager, managing its lifecycle.
507
+ Useful for accessing external resources that require setup and/or teardown. e.g.
508
+ `client = Context(httpx.Client)`
509
+ - `BinaryOperatorAggregate`: stores a persistent value, updated by applying
510
+ a binary operator to the current value and each update
511
+ sent to the channel, useful for computing aggregates over multiple steps. e.g.
512
+ `total = BinaryOperatorAggregate(int, operator.add)`
513
+
514
+ ## Examples
515
+
516
+ Most users will interact with Pregel via a
517
+ [StateGraph (Graph API)][coco_graph.graph.StateGraph] or via an
518
+ [entrypoint (Functional API)][coco_graph.func.entrypoint].
519
+
520
+ However, for **advanced** use cases, Pregel can be used directly. If you're
521
+ not sure whether you need to use Pregel directly, then the answer is probably no
522
+ - you should use the Graph API or Functional API instead. These are higher-level
523
+ interfaces that will compile down to Pregel under the hood.
524
+
525
+ Here are some examples to give you a sense of how it works:
526
+
527
+ Example: Single node application
528
+ ```python
529
+ from coco_graph.channels import EphemeralValue
530
+ from coco_graph.pregel import Pregel, NodeBuilder
531
+
532
+ node1 = (
533
+ NodeBuilder().subscribe_only("a")
534
+ .do(lambda x: x + x)
535
+ .write_to("b")
536
+ )
537
+
538
+ app = Pregel(
539
+ nodes={"node1": node1},
540
+ channels={
541
+ "a": EphemeralValue(str),
542
+ "b": EphemeralValue(str),
543
+ },
544
+ input_channels=["a"],
545
+ output_channels=["b"],
546
+ )
547
+
548
+ app.invoke({"a": "foo"})
549
+ ```
550
+
551
+ ```con
552
+ {'b': 'foofoo'}
553
+ ```
554
+
555
+ Example: Using multiple nodes and multiple output channels
556
+ ```python
557
+ from coco_graph.channels import LastValue, EphemeralValue
558
+ from coco_graph.pregel import Pregel, NodeBuilder
559
+
560
+ node1 = (
561
+ NodeBuilder().subscribe_only("a")
562
+ .do(lambda x: x + x)
563
+ .write_to("b")
564
+ )
565
+
566
+ node2 = (
567
+ NodeBuilder().subscribe_to("b")
568
+ .do(lambda x: x["b"] + x["b"])
569
+ .write_to("c")
570
+ )
571
+
572
+
573
+ app = Pregel(
574
+ nodes={"node1": node1, "node2": node2},
575
+ channels={
576
+ "a": EphemeralValue(str),
577
+ "b": LastValue(str),
578
+ "c": EphemeralValue(str),
579
+ },
580
+ input_channels=["a"],
581
+ output_channels=["b", "c"],
582
+ )
583
+
584
+ app.invoke({"a": "foo"})
585
+ ```
586
+
587
+ ```con
588
+ {'b': 'foofoo', 'c': 'foofoofoofoo'}
589
+ ```
590
+
591
+ Example: Using a Topic channel
592
+ ```python
593
+ from coco_graph.channels import LastValue, EphemeralValue, Topic
594
+ from coco_graph.pregel import Pregel, NodeBuilder
595
+
596
+ node1 = (
597
+ NodeBuilder().subscribe_only("a")
598
+ .do(lambda x: x + x)
599
+ .write_to("b", "c")
600
+ )
601
+
602
+ node2 = (
603
+ NodeBuilder().subscribe_only("b")
604
+ .do(lambda x: x + x)
605
+ .write_to("c")
606
+ )
607
+
608
+
609
+ app = Pregel(
610
+ nodes={"node1": node1, "node2": node2},
611
+ channels={
612
+ "a": EphemeralValue(str),
613
+ "b": EphemeralValue(str),
614
+ "c": Topic(str, accumulate=True),
615
+ },
616
+ input_channels=["a"],
617
+ output_channels=["c"],
618
+ )
619
+
620
+ app.invoke({"a": "foo"})
621
+ ```
622
+
623
+ ```pycon
624
+ {"c": ["foofoo", "foofoofoofoo"]}
625
+ ```
626
+
627
+ Example: Using a `BinaryOperatorAggregate` channel
628
+ ```python
629
+ from coco_graph.channels import EphemeralValue, BinaryOperatorAggregate
630
+ from coco_graph.pregel import Pregel, NodeBuilder
631
+
632
+
633
+ node1 = (
634
+ NodeBuilder().subscribe_only("a")
635
+ .do(lambda x: x + x)
636
+ .write_to("b", "c")
637
+ )
638
+
639
+ node2 = (
640
+ NodeBuilder().subscribe_only("b")
641
+ .do(lambda x: x + x)
642
+ .write_to("c")
643
+ )
644
+
645
+
646
+ def reducer(current, update):
647
+ if current:
648
+ return current + " | " + update
649
+ else:
650
+ return update
651
+
652
+
653
+ app = Pregel(
654
+ nodes={"node1": node1, "node2": node2},
655
+ channels={
656
+ "a": EphemeralValue(str),
657
+ "b": EphemeralValue(str),
658
+ "c": BinaryOperatorAggregate(str, operator=reducer),
659
+ },
660
+ input_channels=["a"],
661
+ output_channels=["c"],
662
+ )
663
+
664
+ app.invoke({"a": "foo"})
665
+ ```
666
+
667
+ ```con
668
+ {'c': 'foofoo | foofoofoofoo'}
669
+ ```
670
+
671
+ Example: Introducing a cycle
672
+ This example demonstrates how to introduce a cycle in the graph, by having
673
+ a chain write to a channel it subscribes to.
674
+
675
+ Execution will continue until a `None` value is written to the channel.
676
+
677
+ ```python
678
+ from coco_graph.channels import EphemeralValue
679
+ from coco_graph.pregel import Pregel, NodeBuilder, ChannelWriteEntry
680
+
681
+ example_node = (
682
+ NodeBuilder()
683
+ .subscribe_only("value")
684
+ .do(lambda x: x + x if len(x) < 10 else None)
685
+ .write_to(ChannelWriteEntry(channel="value", skip_none=True))
686
+ )
687
+
688
+ app = Pregel(
689
+ nodes={"example_node": example_node},
690
+ channels={
691
+ "value": EphemeralValue(str),
692
+ },
693
+ input_channels=["value"],
694
+ output_channels=["value"],
695
+ )
696
+
697
+ app.invoke({"value": "a"})
698
+ ```
699
+
700
+ ```con
701
+ {'value': 'aaaaaaaaaaaaaaaa'}
702
+ ```
703
+ """
704
+
705
+ nodes: dict[str, PregelNode]
706
+
707
+ channels: dict[str, BaseChannel | ManagedValueSpec]
708
+
709
+ stream_mode: StreamMode = "values"
710
+ """Mode to stream output, defaults to 'values'."""
711
+
712
+ stream_eager: bool = False
713
+ """Whether to force emitting stream events eagerly, automatically turned on
714
+ for stream_mode "messages" and "custom"."""
715
+
716
+ output_channels: str | Sequence[str]
717
+
718
+ stream_channels: str | Sequence[str] | None = None
719
+ """Channels to stream, defaults to all channels not in reserved channels"""
720
+
721
+ interrupt_after_nodes: All | Sequence[str]
722
+
723
+ interrupt_before_nodes: All | Sequence[str]
724
+
725
+ input_channels: str | Sequence[str]
726
+
727
+ step_timeout: float | None = None
728
+ """Maximum time to wait for a step to complete, in seconds."""
729
+
730
+ debug: bool
731
+ """Whether to print debug information during execution."""
732
+
733
+ checkpointer: Checkpointer = None
734
+ """`Checkpointer` used to save and load graph state."""
735
+
736
+ store: BaseStore | None = None
737
+ """Memory store to use for SharedValues."""
738
+
739
+ cache: BaseCache | None = None
740
+ """Cache to use for storing node results."""
741
+
742
+ retry_policy: Sequence[RetryPolicy] = ()
743
+ """Retry policies to use when running tasks. Empty set disables retries."""
744
+
745
+ cache_policy: CachePolicy | None = None
746
+ """Cache policy to use for all nodes. Can be overridden by individual nodes."""
747
+
748
+ context_schema: type[ContextT] | None = None
749
+ """Specifies the schema for the context object that will be passed to the workflow."""
750
+
751
+ config: RunnableConfig | None = None
752
+
753
+ name: str = "CocoGraph"
754
+
755
+ trigger_to_nodes: Mapping[str, Sequence[str]]
756
+ node_error_handler_map: Mapping[str, str]
757
+
758
+ def __init__(
759
+ self,
760
+ *,
761
+ nodes: dict[str, PregelNode | NodeBuilder],
762
+ channels: dict[str, BaseChannel | ManagedValueSpec] | None,
763
+ auto_validate: bool = True,
764
+ stream_mode: StreamMode = "values",
765
+ stream_eager: bool = False,
766
+ output_channels: str | Sequence[str],
767
+ stream_channels: str | Sequence[str] | None = None,
768
+ interrupt_after_nodes: All | Sequence[str] = (),
769
+ interrupt_before_nodes: All | Sequence[str] = (),
770
+ input_channels: str | Sequence[str],
771
+ step_timeout: float | None = None,
772
+ debug: bool | None = None,
773
+ checkpointer: Checkpointer = None,
774
+ store: BaseStore | None = None,
775
+ cache: BaseCache | None = None,
776
+ retry_policy: RetryPolicy | Sequence[RetryPolicy] = (),
777
+ cache_policy: CachePolicy | None = None,
778
+ context_schema: type[ContextT] | None = None,
779
+ config: RunnableConfig | None = None,
780
+ trigger_to_nodes: Mapping[str, Sequence[str]] | None = None,
781
+ node_error_handler_map: Mapping[str, str] | None = None,
782
+ name: str = "CocoGraph",
783
+ stream_transformers: Sequence[Callable[[tuple[str, ...]], Any]] | None = None,
784
+ **deprecated_kwargs: Unpack[DeprecatedKwargs],
785
+ ) -> None:
786
+ if (
787
+ config_type := deprecated_kwargs.get("config_type", MISSING)
788
+ ) is not MISSING:
789
+ warnings.warn(
790
+ "`config_type` is deprecated and will be removed. Please use `context_schema` instead.",
791
+ category=CocoGraphDeprecatedSinceV10,
792
+ stacklevel=2,
793
+ )
794
+
795
+ if context_schema is None:
796
+ context_schema = cast(type[ContextT], config_type)
797
+
798
+ checkpointer = ensure_valid_checkpointer(checkpointer)
799
+
800
+ self.nodes = {
801
+ k: v.build() if isinstance(v, NodeBuilder) else v for k, v in nodes.items()
802
+ }
803
+ self.channels = channels or {}
804
+ if TASKS in self.channels and not isinstance(self.channels[TASKS], Topic):
805
+ raise ValueError(
806
+ f"Channel '{TASKS}' is reserved and cannot be used in the graph."
807
+ )
808
+ else:
809
+ self.channels[TASKS] = Topic(Send, accumulate=False)
810
+ self.stream_mode = stream_mode
811
+ self.stream_eager = stream_eager
812
+ self.output_channels = output_channels
813
+ self.stream_channels = stream_channels
814
+ self.interrupt_after_nodes = interrupt_after_nodes
815
+ self.interrupt_before_nodes = interrupt_before_nodes
816
+ self.input_channels = input_channels
817
+ self.step_timeout = step_timeout
818
+ self.debug = debug if debug is not None else get_debug()
819
+ self.checkpointer = checkpointer
820
+ self.store = store
821
+ self.cache = cache
822
+ self.retry_policy = (
823
+ (retry_policy,) if isinstance(retry_policy, RetryPolicy) else retry_policy
824
+ )
825
+ self.cache_policy = cache_policy
826
+ self.context_schema = context_schema
827
+ self.config = config
828
+ self.trigger_to_nodes = trigger_to_nodes or {}
829
+ self.node_error_handler_map = node_error_handler_map or {}
830
+ self.name = name
831
+ self.stream_transformers: tuple[Callable[[tuple[str, ...]], Any], ...] = tuple(
832
+ stream_transformers or ()
833
+ )
834
+ self._serde_allowlist: set[tuple[str, ...]] | None = None
835
+ if auto_validate:
836
+ self.validate()
837
+
838
+ def _apply_checkpointer_allowlist(
839
+ self, checkpointer: BaseCheckpointSaver | None
840
+ ) -> BaseCheckpointSaver | None:
841
+ if not _serde.STRICT_MSGPACK_ENABLED:
842
+ return checkpointer
843
+ return _serde.apply_checkpointer_allowlist(checkpointer, self._serde_allowlist)
844
+
845
+ def get_graph(
846
+ self, config: RunnableConfig | None = None, *, xray: int | bool = False
847
+ ) -> Graph:
848
+ """Return a drawable representation of the computation graph."""
849
+ # gather subgraphs
850
+ if xray:
851
+ subgraphs = {
852
+ k: v.get_graph(
853
+ config,
854
+ xray=xray if isinstance(xray, bool) or xray <= 0 else xray - 1,
855
+ )
856
+ for k, v in self.get_subgraphs()
857
+ }
858
+ else:
859
+ subgraphs = {}
860
+
861
+ return draw_graph(
862
+ merge_configs(self.config, config),
863
+ nodes=self.nodes,
864
+ specs=self.channels,
865
+ input_channels=self.input_channels,
866
+ interrupt_after_nodes=self.interrupt_after_nodes,
867
+ interrupt_before_nodes=self.interrupt_before_nodes,
868
+ trigger_to_nodes=self.trigger_to_nodes,
869
+ checkpointer=self.checkpointer,
870
+ subgraphs=subgraphs,
871
+ )
872
+
873
+ async def aget_graph(
874
+ self, config: RunnableConfig | None = None, *, xray: int | bool = False
875
+ ) -> Graph:
876
+ """Return a drawable representation of the computation graph."""
877
+
878
+ # gather subgraphs
879
+ if xray:
880
+ subpregels: dict[str, PregelProtocol] = {
881
+ k: v async for k, v in self.aget_subgraphs()
882
+ }
883
+ subgraphs = {
884
+ k: v
885
+ for k, v in zip(
886
+ subpregels,
887
+ await asyncio.gather(
888
+ *(
889
+ p.aget_graph(
890
+ config,
891
+ xray=xray
892
+ if isinstance(xray, bool) or xray <= 0
893
+ else xray - 1,
894
+ )
895
+ for p in subpregels.values()
896
+ )
897
+ ),
898
+ )
899
+ }
900
+ else:
901
+ subgraphs = {}
902
+
903
+ return draw_graph(
904
+ merge_configs(self.config, config),
905
+ nodes=self.nodes,
906
+ specs=self.channels,
907
+ input_channels=self.input_channels,
908
+ interrupt_after_nodes=self.interrupt_after_nodes,
909
+ interrupt_before_nodes=self.interrupt_before_nodes,
910
+ trigger_to_nodes=self.trigger_to_nodes,
911
+ checkpointer=self.checkpointer,
912
+ subgraphs=subgraphs,
913
+ )
914
+
915
+ def _repr_mimebundle_(self, **kwargs: Any) -> dict[str, Any]:
916
+ """Mime bundle used by Jupyter to display the graph"""
917
+ return {
918
+ "text/plain": repr(self),
919
+ "image/png": self.get_graph().draw_mermaid_png(),
920
+ }
921
+
922
+ def copy(self, update: dict[str, Any] | None = None) -> Self:
923
+ attrs = {k: v for k, v in self.__dict__.items() if k != "__orig_class__"}
924
+ attrs.update(update or {})
925
+ return self.__class__(**attrs)
926
+
927
+ def with_config(self, config: RunnableConfig | None = None, **kwargs: Any) -> Self:
928
+ """Create a copy of the Pregel object with an updated config."""
929
+ return self.copy(
930
+ {"config": merge_configs(self.config, config, cast(RunnableConfig, kwargs))}
931
+ )
932
+
933
+ def validate(self) -> Self:
934
+ for name, node in self.nodes.items():
935
+ if node.timeout is not None:
936
+ validate_timeout_supported(node.node or node.bound, name=name)
937
+ validate_graph(
938
+ self.nodes,
939
+ {k: v for k, v in self.channels.items() if isinstance(v, BaseChannel)},
940
+ {k: v for k, v in self.channels.items() if not isinstance(v, BaseChannel)},
941
+ self.input_channels,
942
+ self.output_channels,
943
+ self.stream_channels,
944
+ self.interrupt_after_nodes,
945
+ self.interrupt_before_nodes,
946
+ )
947
+ self.trigger_to_nodes = _trigger_to_nodes(self.nodes)
948
+ return self
949
+
950
+ @deprecated(
951
+ "`config_schema` is deprecated. Use `get_context_jsonschema` for the relevant schema instead.",
952
+ category=None,
953
+ )
954
+ def config_schema(self, *, include: Sequence[str] | None = None) -> type[BaseModel]:
955
+ warnings.warn(
956
+ "`config_schema` is deprecated. Use `get_context_jsonschema` for the relevant schema instead.",
957
+ category=CocoGraphDeprecatedSinceV10,
958
+ stacklevel=2,
959
+ )
960
+
961
+ include = include or []
962
+ fields = {
963
+ **(
964
+ {"configurable": (self.context_schema, None)}
965
+ if self.context_schema
966
+ else {}
967
+ ),
968
+ **{
969
+ field_name: (field_type, None)
970
+ for field_name, field_type in get_type_hints(RunnableConfig).items()
971
+ if field_name in [i for i in include if i != "configurable"]
972
+ },
973
+ }
974
+ return create_model(self.get_name("Config"), field_definitions=fields)
975
+
976
+ @deprecated(
977
+ "`get_config_jsonschema` is deprecated. Use `get_context_jsonschema` instead.",
978
+ category=None,
979
+ )
980
+ def get_config_jsonschema(
981
+ self, *, include: Sequence[str] | None = None
982
+ ) -> dict[str, Any]:
983
+ warnings.warn(
984
+ "`get_config_jsonschema` is deprecated. Use `get_context_jsonschema` instead.",
985
+ category=CocoGraphDeprecatedSinceV10,
986
+ stacklevel=2,
987
+ )
988
+
989
+ with warnings.catch_warnings():
990
+ warnings.filterwarnings("ignore", category=CocoGraphDeprecatedSinceV10)
991
+ schema = self.config_schema(include=include)
992
+ return schema.model_json_schema()
993
+
994
+ def get_context_jsonschema(self) -> dict[str, Any] | None:
995
+ if (context_schema := self.context_schema) is None:
996
+ return None
997
+
998
+ if isclass(context_schema) and issubclass(context_schema, BaseModel):
999
+ return context_schema.model_json_schema()
1000
+ elif is_typeddict(context_schema) or is_dataclass(context_schema):
1001
+ return TypeAdapter(context_schema).json_schema()
1002
+ else:
1003
+ raise ValueError(
1004
+ f"Invalid context schema type: {context_schema}. Must be a BaseModel, TypedDict or dataclass."
1005
+ )
1006
+
1007
+ @property
1008
+ def InputType(self) -> Any:
1009
+ if isinstance(self.input_channels, str):
1010
+ channel = self.channels[self.input_channels]
1011
+ if isinstance(channel, BaseChannel):
1012
+ return channel.UpdateType
1013
+
1014
+ def get_input_schema(self, config: RunnableConfig | None = None) -> type[BaseModel]:
1015
+ config = merge_configs(self.config, config)
1016
+ if isinstance(self.input_channels, str):
1017
+ return super().get_input_schema(config)
1018
+ else:
1019
+ return create_model(
1020
+ self.get_name("Input"),
1021
+ field_definitions={
1022
+ k: (c.UpdateType, None)
1023
+ for k in self.input_channels or self.channels.keys()
1024
+ if (c := self.channels[k]) and isinstance(c, BaseChannel)
1025
+ },
1026
+ )
1027
+
1028
+ def get_input_jsonschema(
1029
+ self, config: RunnableConfig | None = None
1030
+ ) -> dict[str, Any]:
1031
+ schema = self.get_input_schema(config)
1032
+ return schema.model_json_schema()
1033
+
1034
+ @property
1035
+ def OutputType(self) -> Any:
1036
+ if isinstance(self.output_channels, str):
1037
+ channel = self.channels[self.output_channels]
1038
+ if isinstance(channel, BaseChannel):
1039
+ return channel.ValueType
1040
+
1041
+ def get_output_schema(
1042
+ self, config: RunnableConfig | None = None
1043
+ ) -> type[BaseModel]:
1044
+ config = merge_configs(self.config, config)
1045
+ if isinstance(self.output_channels, str):
1046
+ return super().get_output_schema(config)
1047
+ else:
1048
+ return create_model(
1049
+ self.get_name("Output"),
1050
+ field_definitions={
1051
+ k: (c.ValueType, None)
1052
+ for k in self.output_channels
1053
+ if (c := self.channels[k]) and isinstance(c, BaseChannel)
1054
+ },
1055
+ )
1056
+
1057
+ def get_output_jsonschema(
1058
+ self, config: RunnableConfig | None = None
1059
+ ) -> dict[str, Any]:
1060
+ schema = self.get_output_schema(config)
1061
+ return schema.model_json_schema()
1062
+
1063
+ @property
1064
+ def stream_channels_list(self) -> Sequence[str]:
1065
+ stream_channels = self.stream_channels_asis
1066
+ return (
1067
+ [stream_channels] if isinstance(stream_channels, str) else stream_channels
1068
+ )
1069
+
1070
+ @property
1071
+ def stream_channels_asis(self) -> str | Sequence[str]:
1072
+ return self.stream_channels or [
1073
+ k for k in self.channels if isinstance(self.channels[k], BaseChannel)
1074
+ ]
1075
+
1076
+ def get_subgraphs(
1077
+ self, *, namespace: str | None = None, recurse: bool = False
1078
+ ) -> Iterator[tuple[str, PregelProtocol]]:
1079
+ """Get the subgraphs of the graph.
1080
+
1081
+ Args:
1082
+ namespace: The namespace to filter the subgraphs by.
1083
+ recurse: Whether to recurse into the subgraphs.
1084
+ If `False`, only the immediate subgraphs will be returned.
1085
+
1086
+ Returns:
1087
+ An iterator of the `(namespace, subgraph)` pairs.
1088
+ """
1089
+ for name, node in self.nodes.items():
1090
+ # filter by prefix
1091
+ if namespace is not None:
1092
+ if not namespace.startswith(name):
1093
+ continue
1094
+
1095
+ # find the subgraph, if any
1096
+ graph = node.subgraphs[0] if node.subgraphs else None
1097
+
1098
+ # if found, yield recursively
1099
+ if graph:
1100
+ if name == namespace:
1101
+ yield name, graph
1102
+ return # we found it, stop searching
1103
+ if namespace is None:
1104
+ yield name, graph
1105
+ if recurse and isinstance(graph, Pregel):
1106
+ if namespace is not None:
1107
+ namespace = namespace[len(name) + 1 :]
1108
+ yield from (
1109
+ (f"{name}{NS_SEP}{n}", s)
1110
+ for n, s in graph.get_subgraphs(
1111
+ namespace=namespace, recurse=recurse
1112
+ )
1113
+ )
1114
+
1115
+ async def aget_subgraphs(
1116
+ self, *, namespace: str | None = None, recurse: bool = False
1117
+ ) -> AsyncIterator[tuple[str, PregelProtocol]]:
1118
+ """Get the subgraphs of the graph.
1119
+
1120
+ Args:
1121
+ namespace: The namespace to filter the subgraphs by.
1122
+ recurse: Whether to recurse into the subgraphs.
1123
+ If `False`, only the immediate subgraphs will be returned.
1124
+
1125
+ Returns:
1126
+ An iterator of the `(namespace, subgraph)` pairs.
1127
+ """
1128
+ for name, node in self.get_subgraphs(namespace=namespace, recurse=recurse):
1129
+ yield name, node
1130
+
1131
+ # Mappers for v2 stream coercion (pydantic/dataclass).
1132
+ # Set by CompiledStateGraph; None for base Pregel.
1133
+ _output_mapper: Callable[[Any], Any] | None = None
1134
+ _state_mapper: Callable[[Any], Any] | None = None
1135
+
1136
+ def _migrate_checkpoint(self, checkpoint: Checkpoint) -> None:
1137
+ """Migrate a saved checkpoint to new channel layout."""
1138
+ if checkpoint["v"] < 4 and checkpoint.get("pending_sends"):
1139
+ pending_sends: list[Send] = checkpoint.pop("pending_sends")
1140
+ checkpoint["channel_values"][TASKS] = pending_sends
1141
+ checkpoint["channel_versions"][TASKS] = max(
1142
+ checkpoint["channel_versions"].values()
1143
+ )
1144
+
1145
+ def _prepare_state_snapshot(
1146
+ self,
1147
+ config: RunnableConfig,
1148
+ saved: CheckpointTuple | None,
1149
+ recurse: BaseCheckpointSaver | None = None,
1150
+ apply_pending_writes: bool = False,
1151
+ ) -> StateSnapshot:
1152
+ if not saved:
1153
+ return StateSnapshot(
1154
+ values={},
1155
+ next=(),
1156
+ config=config,
1157
+ metadata=None,
1158
+ created_at=None,
1159
+ parent_config=None,
1160
+ tasks=(),
1161
+ interrupts=(),
1162
+ )
1163
+
1164
+ # migrate checkpoint if needed
1165
+ self._migrate_checkpoint(saved.checkpoint)
1166
+
1167
+ step = saved.metadata.get("step", -1) + 1
1168
+ stop = step + 2
1169
+ channels, managed = channels_from_checkpoint(
1170
+ self.channels,
1171
+ saved.checkpoint,
1172
+ saver=self.checkpointer
1173
+ if isinstance(self.checkpointer, BaseCheckpointSaver)
1174
+ else None,
1175
+ config=saved.config,
1176
+ )
1177
+ # tasks for this checkpoint
1178
+ next_tasks = prepare_next_tasks(
1179
+ saved.checkpoint,
1180
+ saved.pending_writes or [],
1181
+ self.nodes,
1182
+ channels,
1183
+ managed,
1184
+ saved.config,
1185
+ step,
1186
+ stop,
1187
+ for_execution=True,
1188
+ store=self.store,
1189
+ checkpointer=(
1190
+ self.checkpointer
1191
+ if isinstance(self.checkpointer, BaseCheckpointSaver)
1192
+ else None
1193
+ ),
1194
+ manager=None,
1195
+ )
1196
+ # get the subgraphs
1197
+ subgraphs = dict(self.get_subgraphs())
1198
+ parent_ns = saved.config[CONF].get(CONFIG_KEY_CHECKPOINT_NS, "")
1199
+ task_states: dict[str, RunnableConfig | StateSnapshot] = {}
1200
+ for task in next_tasks.values():
1201
+ if task.name not in subgraphs:
1202
+ continue
1203
+ # assemble checkpoint_ns for this task
1204
+ task_ns = f"{task.name}{NS_END}{task.id}"
1205
+ if parent_ns:
1206
+ task_ns = f"{parent_ns}{NS_SEP}{task_ns}"
1207
+ if not recurse:
1208
+ # set config as signal that subgraph checkpoints exist
1209
+ config = {
1210
+ CONF: {
1211
+ "thread_id": saved.config[CONF]["thread_id"],
1212
+ CONFIG_KEY_CHECKPOINT_NS: task_ns,
1213
+ }
1214
+ }
1215
+ task_states[task.id] = config
1216
+ else:
1217
+ # get the state of the subgraph
1218
+ config = {
1219
+ CONF: {
1220
+ CONFIG_KEY_CHECKPOINTER: recurse,
1221
+ "thread_id": saved.config[CONF]["thread_id"],
1222
+ CONFIG_KEY_CHECKPOINT_NS: task_ns,
1223
+ }
1224
+ }
1225
+ task_states[task.id] = subgraphs[task.name].get_state(
1226
+ config, subgraphs=True
1227
+ )
1228
+ # apply pending writes
1229
+ if null_writes := [
1230
+ w[1:] for w in saved.pending_writes or [] if w[0] == NULL_TASK_ID
1231
+ ]:
1232
+ apply_writes(
1233
+ saved.checkpoint,
1234
+ channels,
1235
+ [PregelTaskWrites((), INPUT, null_writes, [])],
1236
+ None,
1237
+ self.trigger_to_nodes,
1238
+ )
1239
+ if apply_pending_writes and saved.pending_writes:
1240
+ for tid, k, v in saved.pending_writes:
1241
+ if k in (ERROR, INTERRUPT):
1242
+ continue
1243
+ if tid not in next_tasks:
1244
+ continue
1245
+ next_tasks[tid].writes.append((k, v))
1246
+ if tasks := [t for t in next_tasks.values() if t.writes]:
1247
+ apply_writes(
1248
+ saved.checkpoint, channels, tasks, None, self.trigger_to_nodes
1249
+ )
1250
+ tasks_with_writes = tasks_w_writes(
1251
+ next_tasks.values(),
1252
+ saved.pending_writes,
1253
+ task_states,
1254
+ self.stream_channels_asis,
1255
+ )
1256
+ # assemble the state snapshot
1257
+ return StateSnapshot(
1258
+ read_channels(channels, self.stream_channels_asis),
1259
+ tuple(t.name for t in next_tasks.values() if not t.writes),
1260
+ patch_checkpoint_map(saved.config, saved.metadata),
1261
+ saved.metadata,
1262
+ saved.checkpoint["ts"],
1263
+ patch_checkpoint_map(saved.parent_config, saved.metadata),
1264
+ tasks_with_writes,
1265
+ tuple([i for task in tasks_with_writes for i in task.interrupts]),
1266
+ )
1267
+
1268
+ async def _aprepare_state_snapshot(
1269
+ self,
1270
+ config: RunnableConfig,
1271
+ saved: CheckpointTuple | None,
1272
+ recurse: BaseCheckpointSaver | None = None,
1273
+ apply_pending_writes: bool = False,
1274
+ ) -> StateSnapshot:
1275
+ if not saved:
1276
+ return StateSnapshot(
1277
+ values={},
1278
+ next=(),
1279
+ config=config,
1280
+ metadata=None,
1281
+ created_at=None,
1282
+ parent_config=None,
1283
+ tasks=(),
1284
+ interrupts=(),
1285
+ )
1286
+
1287
+ # migrate checkpoint if needed
1288
+ self._migrate_checkpoint(saved.checkpoint)
1289
+
1290
+ step = saved.metadata.get("step", -1) + 1
1291
+ stop = step + 2
1292
+ channels, managed = await achannels_from_checkpoint(
1293
+ self.channels,
1294
+ saved.checkpoint,
1295
+ saver=self.checkpointer
1296
+ if isinstance(self.checkpointer, BaseCheckpointSaver)
1297
+ else None,
1298
+ config=saved.config,
1299
+ )
1300
+ # tasks for this checkpoint
1301
+ next_tasks = prepare_next_tasks(
1302
+ saved.checkpoint,
1303
+ saved.pending_writes or [],
1304
+ self.nodes,
1305
+ channels,
1306
+ managed,
1307
+ saved.config,
1308
+ step,
1309
+ stop,
1310
+ for_execution=True,
1311
+ store=self.store,
1312
+ checkpointer=(
1313
+ self.checkpointer
1314
+ if isinstance(self.checkpointer, BaseCheckpointSaver)
1315
+ else None
1316
+ ),
1317
+ manager=None,
1318
+ )
1319
+ # get the subgraphs
1320
+ subgraphs = {n: g async for n, g in self.aget_subgraphs()}
1321
+ parent_ns = saved.config[CONF].get(CONFIG_KEY_CHECKPOINT_NS, "")
1322
+ task_states: dict[str, RunnableConfig | StateSnapshot] = {}
1323
+ for task in next_tasks.values():
1324
+ if task.name not in subgraphs:
1325
+ continue
1326
+ # assemble checkpoint_ns for this task
1327
+ task_ns = f"{task.name}{NS_END}{task.id}"
1328
+ if parent_ns:
1329
+ task_ns = f"{parent_ns}{NS_SEP}{task_ns}"
1330
+ if not recurse:
1331
+ # set config as signal that subgraph checkpoints exist
1332
+ config = {
1333
+ CONF: {
1334
+ "thread_id": saved.config[CONF]["thread_id"],
1335
+ CONFIG_KEY_CHECKPOINT_NS: task_ns,
1336
+ }
1337
+ }
1338
+ task_states[task.id] = config
1339
+ else:
1340
+ # get the state of the subgraph
1341
+ config = {
1342
+ CONF: {
1343
+ CONFIG_KEY_CHECKPOINTER: recurse,
1344
+ "thread_id": saved.config[CONF]["thread_id"],
1345
+ CONFIG_KEY_CHECKPOINT_NS: task_ns,
1346
+ }
1347
+ }
1348
+ task_states[task.id] = await subgraphs[task.name].aget_state(
1349
+ config, subgraphs=True
1350
+ )
1351
+ # apply pending writes
1352
+ if null_writes := [
1353
+ w[1:] for w in saved.pending_writes or [] if w[0] == NULL_TASK_ID
1354
+ ]:
1355
+ apply_writes(
1356
+ saved.checkpoint,
1357
+ channels,
1358
+ [PregelTaskWrites((), INPUT, null_writes, [])],
1359
+ None,
1360
+ self.trigger_to_nodes,
1361
+ )
1362
+ if apply_pending_writes and saved.pending_writes:
1363
+ for tid, k, v in saved.pending_writes:
1364
+ if k in (ERROR, INTERRUPT):
1365
+ continue
1366
+ if tid not in next_tasks:
1367
+ continue
1368
+ next_tasks[tid].writes.append((k, v))
1369
+ if tasks := [t for t in next_tasks.values() if t.writes]:
1370
+ apply_writes(
1371
+ saved.checkpoint, channels, tasks, None, self.trigger_to_nodes
1372
+ )
1373
+
1374
+ tasks_with_writes = tasks_w_writes(
1375
+ next_tasks.values(),
1376
+ saved.pending_writes,
1377
+ task_states,
1378
+ self.stream_channels_asis,
1379
+ )
1380
+ # assemble the state snapshot
1381
+ return StateSnapshot(
1382
+ read_channels(channels, self.stream_channels_asis),
1383
+ tuple(t.name for t in next_tasks.values() if not t.writes),
1384
+ patch_checkpoint_map(saved.config, saved.metadata),
1385
+ saved.metadata,
1386
+ saved.checkpoint["ts"],
1387
+ patch_checkpoint_map(saved.parent_config, saved.metadata),
1388
+ tasks_with_writes,
1389
+ tuple([i for task in tasks_with_writes for i in task.interrupts]),
1390
+ )
1391
+
1392
+ def get_state(
1393
+ self, config: RunnableConfig, *, subgraphs: bool = False
1394
+ ) -> StateSnapshot:
1395
+ """Get the current state of the graph."""
1396
+ checkpointer: BaseCheckpointSaver | None = ensure_config(config)[CONF].get(
1397
+ CONFIG_KEY_CHECKPOINTER, self.checkpointer
1398
+ )
1399
+ if isinstance(checkpointer, BaseCheckpointSaver):
1400
+ checkpointer = self._apply_checkpointer_allowlist(checkpointer)
1401
+ if not checkpointer:
1402
+ raise ValueError("No checkpointer set")
1403
+
1404
+ if (
1405
+ checkpoint_ns := config[CONF].get(CONFIG_KEY_CHECKPOINT_NS, "")
1406
+ ) and CONFIG_KEY_CHECKPOINTER not in config[CONF]:
1407
+ # remove task_ids from checkpoint_ns
1408
+ recast = recast_checkpoint_ns(checkpoint_ns)
1409
+ # find the subgraph with the matching name
1410
+ for _, pregel in self.get_subgraphs(namespace=recast, recurse=True):
1411
+ return pregel.get_state(
1412
+ patch_configurable(config, {CONFIG_KEY_CHECKPOINTER: checkpointer}),
1413
+ subgraphs=subgraphs,
1414
+ )
1415
+ else:
1416
+ raise ValueError(f"Subgraph {recast} not found")
1417
+
1418
+ config = merge_configs(self.config, config) if self.config else config
1419
+ if self.checkpointer is True:
1420
+ ns = cast(str, config[CONF][CONFIG_KEY_CHECKPOINT_NS])
1421
+ config = merge_configs(
1422
+ config, {CONF: {CONFIG_KEY_CHECKPOINT_NS: recast_checkpoint_ns(ns)}}
1423
+ )
1424
+ thread_id = config[CONF][CONFIG_KEY_THREAD_ID]
1425
+ if not isinstance(thread_id, str):
1426
+ config[CONF][CONFIG_KEY_THREAD_ID] = str(thread_id)
1427
+
1428
+ saved = checkpointer.get_tuple(config)
1429
+ return self._prepare_state_snapshot(
1430
+ config,
1431
+ saved,
1432
+ recurse=checkpointer if subgraphs else None,
1433
+ apply_pending_writes=CONFIG_KEY_CHECKPOINT_ID not in config[CONF],
1434
+ )
1435
+
1436
+ async def aget_state(
1437
+ self, config: RunnableConfig, *, subgraphs: bool = False
1438
+ ) -> StateSnapshot:
1439
+ """Get the current state of the graph."""
1440
+ checkpointer: BaseCheckpointSaver | None = ensure_config(config)[CONF].get(
1441
+ CONFIG_KEY_CHECKPOINTER, self.checkpointer
1442
+ )
1443
+ if isinstance(checkpointer, BaseCheckpointSaver):
1444
+ checkpointer = self._apply_checkpointer_allowlist(checkpointer)
1445
+ if not checkpointer:
1446
+ raise ValueError("No checkpointer set")
1447
+
1448
+ if (
1449
+ checkpoint_ns := config[CONF].get(CONFIG_KEY_CHECKPOINT_NS, "")
1450
+ ) and CONFIG_KEY_CHECKPOINTER not in config[CONF]:
1451
+ # remove task_ids from checkpoint_ns
1452
+ recast = recast_checkpoint_ns(checkpoint_ns)
1453
+ # find the subgraph with the matching name
1454
+ async for _, pregel in self.aget_subgraphs(namespace=recast, recurse=True):
1455
+ return await pregel.aget_state(
1456
+ patch_configurable(config, {CONFIG_KEY_CHECKPOINTER: checkpointer}),
1457
+ subgraphs=subgraphs,
1458
+ )
1459
+ else:
1460
+ raise ValueError(f"Subgraph {recast} not found")
1461
+
1462
+ config = merge_configs(self.config, config) if self.config else config
1463
+ if self.checkpointer is True:
1464
+ ns = cast(str, config[CONF][CONFIG_KEY_CHECKPOINT_NS])
1465
+ config = merge_configs(
1466
+ config, {CONF: {CONFIG_KEY_CHECKPOINT_NS: recast_checkpoint_ns(ns)}}
1467
+ )
1468
+ thread_id = config[CONF][CONFIG_KEY_THREAD_ID]
1469
+ if not isinstance(thread_id, str):
1470
+ config[CONF][CONFIG_KEY_THREAD_ID] = str(thread_id)
1471
+
1472
+ saved = await checkpointer.aget_tuple(config)
1473
+ return await self._aprepare_state_snapshot(
1474
+ config,
1475
+ saved,
1476
+ recurse=checkpointer if subgraphs else None,
1477
+ apply_pending_writes=CONFIG_KEY_CHECKPOINT_ID not in config[CONF],
1478
+ )
1479
+
1480
+ def get_state_history(
1481
+ self,
1482
+ config: RunnableConfig,
1483
+ *,
1484
+ filter: dict[str, Any] | None = None,
1485
+ before: RunnableConfig | None = None,
1486
+ limit: int | None = None,
1487
+ ) -> Iterator[StateSnapshot]:
1488
+ """Get the history of the state of the graph."""
1489
+ config = ensure_config(config)
1490
+ checkpointer: BaseCheckpointSaver | None = config[CONF].get(
1491
+ CONFIG_KEY_CHECKPOINTER, self.checkpointer
1492
+ )
1493
+ if isinstance(checkpointer, BaseCheckpointSaver):
1494
+ checkpointer = self._apply_checkpointer_allowlist(checkpointer)
1495
+ if not checkpointer:
1496
+ raise ValueError("No checkpointer set")
1497
+
1498
+ if (
1499
+ checkpoint_ns := config[CONF].get(CONFIG_KEY_CHECKPOINT_NS, "")
1500
+ ) and CONFIG_KEY_CHECKPOINTER not in config[CONF]:
1501
+ # remove task_ids from checkpoint_ns
1502
+ recast = recast_checkpoint_ns(checkpoint_ns)
1503
+ # find the subgraph with the matching name
1504
+ for _, pregel in self.get_subgraphs(namespace=recast, recurse=True):
1505
+ yield from pregel.get_state_history(
1506
+ patch_configurable(config, {CONFIG_KEY_CHECKPOINTER: checkpointer}),
1507
+ filter=filter,
1508
+ before=before,
1509
+ limit=limit,
1510
+ )
1511
+ return
1512
+ else:
1513
+ raise ValueError(f"Subgraph {recast} not found")
1514
+
1515
+ config = merge_configs(
1516
+ self.config,
1517
+ config,
1518
+ {
1519
+ CONF: {
1520
+ CONFIG_KEY_CHECKPOINT_NS: checkpoint_ns,
1521
+ CONFIG_KEY_THREAD_ID: str(config[CONF][CONFIG_KEY_THREAD_ID]),
1522
+ }
1523
+ },
1524
+ )
1525
+ # eagerly consume list() to avoid holding up the db cursor
1526
+ for checkpoint_tuple in list(
1527
+ checkpointer.list(config, before=before, limit=limit, filter=filter)
1528
+ ):
1529
+ yield self._prepare_state_snapshot(
1530
+ checkpoint_tuple.config, checkpoint_tuple
1531
+ )
1532
+
1533
+ async def aget_state_history(
1534
+ self,
1535
+ config: RunnableConfig,
1536
+ *,
1537
+ filter: dict[str, Any] | None = None,
1538
+ before: RunnableConfig | None = None,
1539
+ limit: int | None = None,
1540
+ ) -> AsyncIterator[StateSnapshot]:
1541
+ """Asynchronously get the history of the state of the graph."""
1542
+ config = ensure_config(config)
1543
+ checkpointer: BaseCheckpointSaver | None = ensure_config(config)[CONF].get(
1544
+ CONFIG_KEY_CHECKPOINTER, self.checkpointer
1545
+ )
1546
+ if isinstance(checkpointer, BaseCheckpointSaver):
1547
+ checkpointer = self._apply_checkpointer_allowlist(checkpointer)
1548
+ if not checkpointer:
1549
+ raise ValueError("No checkpointer set")
1550
+
1551
+ if (
1552
+ checkpoint_ns := config[CONF].get(CONFIG_KEY_CHECKPOINT_NS, "")
1553
+ ) and CONFIG_KEY_CHECKPOINTER not in config[CONF]:
1554
+ # remove task_ids from checkpoint_ns
1555
+ recast = recast_checkpoint_ns(checkpoint_ns)
1556
+ # find the subgraph with the matching name
1557
+ async for _, pregel in self.aget_subgraphs(namespace=recast, recurse=True):
1558
+ async for state in pregel.aget_state_history(
1559
+ patch_configurable(config, {CONFIG_KEY_CHECKPOINTER: checkpointer}),
1560
+ filter=filter,
1561
+ before=before,
1562
+ limit=limit,
1563
+ ):
1564
+ yield state
1565
+ return
1566
+ else:
1567
+ raise ValueError(f"Subgraph {recast} not found")
1568
+
1569
+ config = merge_configs(
1570
+ self.config,
1571
+ config,
1572
+ {
1573
+ CONF: {
1574
+ CONFIG_KEY_CHECKPOINT_NS: checkpoint_ns,
1575
+ CONFIG_KEY_THREAD_ID: str(config[CONF][CONFIG_KEY_THREAD_ID]),
1576
+ }
1577
+ },
1578
+ )
1579
+ # eagerly consume list() to avoid holding up the db cursor
1580
+ for checkpoint_tuple in [
1581
+ c
1582
+ async for c in checkpointer.alist(
1583
+ config, before=before, limit=limit, filter=filter
1584
+ )
1585
+ ]:
1586
+ yield await self._aprepare_state_snapshot(
1587
+ checkpoint_tuple.config, checkpoint_tuple
1588
+ )
1589
+
1590
+ def bulk_update_state(
1591
+ self,
1592
+ config: RunnableConfig,
1593
+ supersteps: Sequence[Sequence[StateUpdate]],
1594
+ ) -> RunnableConfig:
1595
+ """Apply updates to the graph state in bulk. Requires a checkpointer to be set.
1596
+
1597
+ Args:
1598
+ config: The config to apply the updates to.
1599
+ supersteps: A list of supersteps, each including a list of updates to apply sequentially to a graph state.
1600
+
1601
+ Each update is a tuple of the form `(values, as_node, task_id)` where `task_id` is optional.
1602
+
1603
+ Raises:
1604
+ ValueError: If no checkpointer is set or no updates are provided.
1605
+ InvalidUpdateError: If an invalid update is provided.
1606
+
1607
+ Returns:
1608
+ RunnableConfig: The updated config.
1609
+ """
1610
+
1611
+ checkpointer: BaseCheckpointSaver | None = ensure_config(config)[CONF].get(
1612
+ CONFIG_KEY_CHECKPOINTER, self.checkpointer
1613
+ )
1614
+ if isinstance(checkpointer, BaseCheckpointSaver):
1615
+ checkpointer = self._apply_checkpointer_allowlist(checkpointer)
1616
+ if not checkpointer:
1617
+ raise ValueError("No checkpointer set")
1618
+
1619
+ if len(supersteps) == 0:
1620
+ raise ValueError("No supersteps provided")
1621
+
1622
+ if any(len(u) == 0 for u in supersteps):
1623
+ raise ValueError("No updates provided")
1624
+
1625
+ # delegate to subgraph
1626
+ if (
1627
+ checkpoint_ns := config[CONF].get(CONFIG_KEY_CHECKPOINT_NS, "")
1628
+ ) and CONFIG_KEY_CHECKPOINTER not in config[CONF]:
1629
+ # remove task_ids from checkpoint_ns
1630
+ recast = recast_checkpoint_ns(checkpoint_ns)
1631
+ # find the subgraph with the matching name
1632
+ for _, pregel in self.get_subgraphs(namespace=recast, recurse=True):
1633
+ return pregel.bulk_update_state(
1634
+ patch_configurable(config, {CONFIG_KEY_CHECKPOINTER: checkpointer}),
1635
+ supersteps,
1636
+ )
1637
+ else:
1638
+ raise ValueError(f"Subgraph {recast} not found")
1639
+
1640
+ def perform_superstep(
1641
+ input_config: RunnableConfig, updates: Sequence[StateUpdate]
1642
+ ) -> RunnableConfig:
1643
+ # get last checkpoint
1644
+ config = ensure_config(self.config, input_config)
1645
+ saved = checkpointer.get_tuple(config)
1646
+ if saved is not None:
1647
+ self._migrate_checkpoint(saved.checkpoint)
1648
+ checkpoint = (
1649
+ copy_checkpoint(saved.checkpoint) if saved else empty_checkpoint()
1650
+ )
1651
+ checkpoint_previous_versions = (
1652
+ saved.checkpoint["channel_versions"].copy() if saved else {}
1653
+ )
1654
+ step = saved.metadata.get("step", -1) if saved else -1
1655
+ # merge configurable fields with previous checkpoint config
1656
+ checkpoint_config = patch_configurable(
1657
+ config,
1658
+ {
1659
+ CONFIG_KEY_CHECKPOINT_NS: config[CONF].get(
1660
+ CONFIG_KEY_CHECKPOINT_NS, ""
1661
+ )
1662
+ },
1663
+ )
1664
+ if saved:
1665
+ checkpoint_config = patch_configurable(config, saved.config[CONF])
1666
+ channels, managed = channels_from_checkpoint(
1667
+ self.channels,
1668
+ checkpoint,
1669
+ saver=self.checkpointer
1670
+ if saved is not None
1671
+ and isinstance(self.checkpointer, BaseCheckpointSaver)
1672
+ else None,
1673
+ config=saved.config if saved is not None else None,
1674
+ )
1675
+ values, as_node = updates[0][:2]
1676
+
1677
+ # no values as END, just clear all tasks
1678
+ if values is None and as_node == END:
1679
+ if len(updates) > 1:
1680
+ raise InvalidUpdateError(
1681
+ "Cannot apply multiple updates when clearing state"
1682
+ )
1683
+
1684
+ if saved is not None:
1685
+ # tasks for this checkpoint
1686
+ next_tasks = prepare_next_tasks(
1687
+ checkpoint,
1688
+ saved.pending_writes or [],
1689
+ self.nodes,
1690
+ channels,
1691
+ managed,
1692
+ saved.config,
1693
+ step + 1,
1694
+ step + 3,
1695
+ for_execution=True,
1696
+ store=self.store,
1697
+ checkpointer=checkpointer,
1698
+ manager=None,
1699
+ )
1700
+ # apply null writes
1701
+ if null_writes := [
1702
+ w[1:]
1703
+ for w in saved.pending_writes or []
1704
+ if w[0] == NULL_TASK_ID
1705
+ ]:
1706
+ apply_writes(
1707
+ checkpoint,
1708
+ channels,
1709
+ [PregelTaskWrites((), INPUT, null_writes, [])],
1710
+ checkpointer.get_next_version,
1711
+ self.trigger_to_nodes,
1712
+ )
1713
+ # apply writes from tasks that already ran
1714
+ for tid, k, v in saved.pending_writes or []:
1715
+ if k in (ERROR, INTERRUPT):
1716
+ continue
1717
+ if tid not in next_tasks:
1718
+ continue
1719
+ next_tasks[tid].writes.append((k, v))
1720
+ # clear all current tasks
1721
+ apply_writes(
1722
+ checkpoint,
1723
+ channels,
1724
+ next_tasks.values(),
1725
+ checkpointer.get_next_version,
1726
+ self.trigger_to_nodes,
1727
+ )
1728
+ # save checkpoint
1729
+ next_config = checkpointer.put(
1730
+ checkpoint_config,
1731
+ create_checkpoint(checkpoint, channels, step),
1732
+ {
1733
+ "source": "update",
1734
+ "step": step + 1,
1735
+ "parents": saved.metadata.get("parents", {}) if saved else {},
1736
+ },
1737
+ get_new_channel_versions(
1738
+ checkpoint_previous_versions,
1739
+ checkpoint["channel_versions"],
1740
+ ),
1741
+ )
1742
+ return patch_checkpoint_map(
1743
+ next_config, saved.metadata if saved else None
1744
+ )
1745
+
1746
+ # act as an input
1747
+ if as_node == INPUT:
1748
+ if len(updates) > 1:
1749
+ raise InvalidUpdateError(
1750
+ "Cannot apply multiple updates when updating as input"
1751
+ )
1752
+
1753
+ if input_writes := deque(map_input(self.input_channels, values)):
1754
+ apply_writes(
1755
+ checkpoint,
1756
+ channels,
1757
+ [PregelTaskWrites((), INPUT, input_writes, [])],
1758
+ checkpointer.get_next_version,
1759
+ self.trigger_to_nodes,
1760
+ )
1761
+
1762
+ # apply input write to channels
1763
+ next_step = (
1764
+ step + 1
1765
+ if saved and saved.metadata.get("step") is not None
1766
+ else -1
1767
+ )
1768
+ next_config = checkpointer.put(
1769
+ checkpoint_config,
1770
+ create_checkpoint(checkpoint, channels, next_step),
1771
+ {
1772
+ "source": "input",
1773
+ "step": next_step,
1774
+ "parents": saved.metadata.get("parents", {})
1775
+ if saved
1776
+ else {},
1777
+ },
1778
+ get_new_channel_versions(
1779
+ checkpoint_previous_versions,
1780
+ checkpoint["channel_versions"],
1781
+ ),
1782
+ )
1783
+
1784
+ # store the writes
1785
+ checkpointer.put_writes(
1786
+ next_config,
1787
+ input_writes,
1788
+ str(uuid5(UUID(checkpoint["id"]), INPUT)),
1789
+ )
1790
+
1791
+ return patch_checkpoint_map(
1792
+ next_config, saved.metadata if saved else None
1793
+ )
1794
+ else:
1795
+ raise InvalidUpdateError(
1796
+ f"Received no input writes for {self.input_channels}"
1797
+ )
1798
+
1799
+ # copy checkpoint
1800
+ if as_node == "__copy__":
1801
+ if len(updates) > 1:
1802
+ raise InvalidUpdateError(
1803
+ "Cannot copy checkpoint with multiple updates"
1804
+ )
1805
+
1806
+ if saved is None:
1807
+ raise InvalidUpdateError("Cannot copy a non-existent checkpoint")
1808
+
1809
+ next_checkpoint = create_checkpoint(checkpoint, None, step)
1810
+
1811
+ # copy checkpoint
1812
+ next_config = checkpointer.put(
1813
+ saved.parent_config
1814
+ or patch_configurable(
1815
+ saved.config, {CONFIG_KEY_CHECKPOINT_ID: None}
1816
+ ),
1817
+ next_checkpoint,
1818
+ {
1819
+ "source": "fork",
1820
+ "step": step + 1,
1821
+ "parents": saved.metadata.get("parents", {}),
1822
+ },
1823
+ {},
1824
+ )
1825
+
1826
+ # we want to both clone a checkpoint and update state in one go.
1827
+ # reuse the same task ID if possible.
1828
+ if isinstance(values, list) and len(values) > 0:
1829
+ # figure out the task IDs for the next update checkpoint
1830
+ next_tasks = prepare_next_tasks(
1831
+ next_checkpoint,
1832
+ saved.pending_writes or [],
1833
+ self.nodes,
1834
+ channels,
1835
+ managed,
1836
+ next_config,
1837
+ step + 2,
1838
+ step + 4,
1839
+ for_execution=True,
1840
+ store=self.store,
1841
+ checkpointer=checkpointer,
1842
+ manager=None,
1843
+ )
1844
+
1845
+ tasks_group_by = defaultdict(list)
1846
+ user_group_by: dict[str, list[StateUpdate]] = defaultdict(list)
1847
+
1848
+ for task in next_tasks.values():
1849
+ tasks_group_by[task.name].append(task.id)
1850
+
1851
+ for item in values:
1852
+ if not isinstance(item, Sequence):
1853
+ raise InvalidUpdateError(
1854
+ f"Invalid update item: {item} when copying checkpoint"
1855
+ )
1856
+
1857
+ values, as_node = item[:2]
1858
+
1859
+ user_group = user_group_by[as_node]
1860
+ tasks_group = tasks_group_by[as_node]
1861
+
1862
+ target_idx = len(user_group)
1863
+ task_id = (
1864
+ tasks_group[target_idx]
1865
+ if target_idx < len(tasks_group)
1866
+ else None
1867
+ )
1868
+
1869
+ user_group_by[as_node].append(
1870
+ StateUpdate(values=values, as_node=as_node, task_id=task_id)
1871
+ )
1872
+
1873
+ return perform_superstep(
1874
+ patch_checkpoint_map(next_config, saved.metadata),
1875
+ [item for lst in user_group_by.values() for item in lst],
1876
+ )
1877
+
1878
+ return patch_checkpoint_map(next_config, saved.metadata)
1879
+
1880
+ # task ids can be provided in the StateUpdate, but if not,
1881
+ # we use the task id generated by prepare_next_tasks
1882
+ node_to_task_ids: dict[str, deque[str]] = defaultdict(deque)
1883
+ if saved is not None and saved.pending_writes is not None:
1884
+ # we call prepare_next_tasks to discover the task IDs that
1885
+ # would have been generated, so we can reuse them and
1886
+ # properly populate task.result in state history
1887
+ next_tasks = prepare_next_tasks(
1888
+ checkpoint,
1889
+ saved.pending_writes,
1890
+ self.nodes,
1891
+ channels,
1892
+ managed,
1893
+ saved.config,
1894
+ step + 1,
1895
+ step + 3,
1896
+ for_execution=True,
1897
+ store=self.store,
1898
+ checkpointer=checkpointer,
1899
+ manager=None,
1900
+ )
1901
+ # collect task ids to reuse so we can properly attach task results
1902
+ for t in next_tasks.values():
1903
+ node_to_task_ids[t.name].append(t.id)
1904
+
1905
+ valid_updates: list[tuple[str, dict[str, Any] | None, str | None]] = []
1906
+ if len(updates) == 1:
1907
+ values, as_node, task_id = updates[0]
1908
+ # find last node that updated the state, if not provided
1909
+ if as_node is None and len(self.nodes) == 1:
1910
+ as_node = tuple(self.nodes)[0]
1911
+ elif as_node is None and not any(
1912
+ v
1913
+ for vv in checkpoint["versions_seen"].values()
1914
+ for v in vv.values()
1915
+ ):
1916
+ if (
1917
+ isinstance(self.input_channels, str)
1918
+ and self.input_channels in self.nodes
1919
+ ):
1920
+ as_node = self.input_channels
1921
+ elif as_node is None:
1922
+ last_seen_by_node = sorted(
1923
+ (v, n)
1924
+ for n, seen in checkpoint["versions_seen"].items()
1925
+ if n in self.nodes
1926
+ for v in seen.values()
1927
+ )
1928
+ # if two nodes updated the state at the same time, it's ambiguous
1929
+ if last_seen_by_node:
1930
+ if len(last_seen_by_node) == 1:
1931
+ as_node = last_seen_by_node[0][1]
1932
+ elif last_seen_by_node[-1][0] != last_seen_by_node[-2][0]:
1933
+ as_node = last_seen_by_node[-1][1]
1934
+ if as_node is None:
1935
+ raise InvalidUpdateError("Ambiguous update, specify as_node")
1936
+ if as_node not in self.nodes:
1937
+ raise InvalidUpdateError(f"Node {as_node} does not exist")
1938
+ valid_updates.append((as_node, values, task_id))
1939
+ else:
1940
+ for values, as_node, task_id in updates:
1941
+ if as_node is None:
1942
+ raise InvalidUpdateError(
1943
+ "as_node is required when applying multiple updates"
1944
+ )
1945
+ if as_node not in self.nodes:
1946
+ raise InvalidUpdateError(f"Node {as_node} does not exist")
1947
+
1948
+ valid_updates.append((as_node, values, task_id))
1949
+
1950
+ run_tasks: list[PregelTaskWrites] = []
1951
+ run_task_ids: list[str] = []
1952
+
1953
+ for as_node, values, provided_task_id in valid_updates:
1954
+ # create task to run all writers of the chosen node
1955
+ writers = self.nodes[as_node].flat_writers
1956
+ if not writers:
1957
+ raise InvalidUpdateError(f"Node {as_node} has no writers")
1958
+ writes: deque[tuple[str, Any]] = deque()
1959
+ task = PregelTaskWrites((), as_node, writes, [INTERRUPT])
1960
+ # get the task ids that were prepared for this node
1961
+ # if a task id was provided in the StateUpdate, we use it
1962
+ # otherwise, we use the next available task id
1963
+ prepared_task_ids = node_to_task_ids.get(as_node, deque())
1964
+ task_id = provided_task_id or (
1965
+ prepared_task_ids.popleft()
1966
+ if prepared_task_ids
1967
+ else str(uuid5(UUID(checkpoint["id"]), INTERRUPT))
1968
+ )
1969
+ run_tasks.append(task)
1970
+ run_task_ids.append(task_id)
1971
+ run = RunnableSequence(*writers) if len(writers) > 1 else writers[0]
1972
+ # execute task
1973
+ run.invoke(
1974
+ values,
1975
+ patch_config(
1976
+ config,
1977
+ run_name=self.name + "UpdateState",
1978
+ configurable={
1979
+ # deque.extend is thread-safe
1980
+ CONFIG_KEY_SEND: writes.extend,
1981
+ CONFIG_KEY_TASK_ID: task_id,
1982
+ CONFIG_KEY_READ: partial(
1983
+ local_read,
1984
+ _scratchpad(
1985
+ None,
1986
+ [],
1987
+ task_id,
1988
+ "",
1989
+ None,
1990
+ step,
1991
+ step + 2,
1992
+ ),
1993
+ channels,
1994
+ managed,
1995
+ task,
1996
+ ),
1997
+ },
1998
+ ),
1999
+ )
2000
+ updated_channels = get_updated_channels_from_tasks(run_tasks)
2001
+ if saved is not None:
2002
+ for task_id, task in zip(run_task_ids, run_tasks):
2003
+ channel_writes = [w for w in task.writes if w[0] != PUSH]
2004
+ if channel_writes:
2005
+ checkpointer.put_writes(
2006
+ checkpoint_config, channel_writes, task_id
2007
+ )
2008
+ apply_writes(
2009
+ checkpoint,
2010
+ channels,
2011
+ run_tasks,
2012
+ checkpointer.get_next_version,
2013
+ self.trigger_to_nodes,
2014
+ )
2015
+ channels_to_snapshot, checkpoint_metadata = (
2016
+ create_checkpoint_plan_for_update_state_api(
2017
+ channels,
2018
+ updated_channels,
2019
+ step=step + 1,
2020
+ parents=saved.metadata.get("parents", {}) if saved else {},
2021
+ saved_metadata=saved.metadata if saved else None,
2022
+ is_fresh_thread=saved is None,
2023
+ )
2024
+ )
2025
+ checkpoint = create_checkpoint(
2026
+ checkpoint,
2027
+ channels,
2028
+ step + 1,
2029
+ updated_channels=updated_channels if channels_to_snapshot else None,
2030
+ get_next_version=checkpointer.get_next_version
2031
+ if channels_to_snapshot
2032
+ else None,
2033
+ channels_to_snapshot=channels_to_snapshot,
2034
+ )
2035
+ next_config = checkpointer.put(
2036
+ checkpoint_config,
2037
+ checkpoint,
2038
+ checkpoint_metadata,
2039
+ get_new_channel_versions(
2040
+ checkpoint_previous_versions, checkpoint["channel_versions"]
2041
+ ),
2042
+ )
2043
+ for task_id, task in zip(run_task_ids, run_tasks):
2044
+ if push_writes := [w for w in task.writes if w[0] == PUSH]:
2045
+ checkpointer.put_writes(next_config, push_writes, task_id)
2046
+
2047
+ return patch_checkpoint_map(next_config, saved.metadata if saved else None)
2048
+
2049
+ current_config = patch_configurable(
2050
+ config, {CONFIG_KEY_THREAD_ID: str(config[CONF][CONFIG_KEY_THREAD_ID])}
2051
+ )
2052
+ for superstep in supersteps:
2053
+ current_config = perform_superstep(current_config, superstep)
2054
+ return current_config
2055
+
2056
+ async def abulk_update_state(
2057
+ self,
2058
+ config: RunnableConfig,
2059
+ supersteps: Sequence[Sequence[StateUpdate]],
2060
+ ) -> RunnableConfig:
2061
+ """Asynchronously apply updates to the graph state in bulk. Requires a checkpointer to be set.
2062
+
2063
+ Args:
2064
+ config: The config to apply the updates to.
2065
+ supersteps: A list of supersteps, each including a list of updates to apply sequentially to a graph state.
2066
+
2067
+ Each update is a tuple of the form `(values, as_node, task_id)` where `task_id` is optional.
2068
+
2069
+ Raises:
2070
+ ValueError: If no checkpointer is set or no updates are provided.
2071
+ InvalidUpdateError: If an invalid update is provided.
2072
+
2073
+ Returns:
2074
+ RunnableConfig: The updated config.
2075
+ """
2076
+
2077
+ checkpointer: BaseCheckpointSaver | None = ensure_config(config)[CONF].get(
2078
+ CONFIG_KEY_CHECKPOINTER, self.checkpointer
2079
+ )
2080
+ if isinstance(checkpointer, BaseCheckpointSaver):
2081
+ checkpointer = self._apply_checkpointer_allowlist(checkpointer)
2082
+ if not checkpointer:
2083
+ raise ValueError("No checkpointer set")
2084
+
2085
+ if len(supersteps) == 0:
2086
+ raise ValueError("No supersteps provided")
2087
+
2088
+ if any(len(u) == 0 for u in supersteps):
2089
+ raise ValueError("No updates provided")
2090
+
2091
+ # delegate to subgraph
2092
+ if (
2093
+ checkpoint_ns := config[CONF].get(CONFIG_KEY_CHECKPOINT_NS, "")
2094
+ ) and CONFIG_KEY_CHECKPOINTER not in config[CONF]:
2095
+ # remove task_ids from checkpoint_ns
2096
+ recast = recast_checkpoint_ns(checkpoint_ns)
2097
+ # find the subgraph with the matching name
2098
+ async for _, pregel in self.aget_subgraphs(namespace=recast, recurse=True):
2099
+ return await pregel.abulk_update_state(
2100
+ patch_configurable(config, {CONFIG_KEY_CHECKPOINTER: checkpointer}),
2101
+ supersteps,
2102
+ )
2103
+ else:
2104
+ raise ValueError(f"Subgraph {recast} not found")
2105
+
2106
+ async def aperform_superstep(
2107
+ input_config: RunnableConfig, updates: Sequence[StateUpdate]
2108
+ ) -> RunnableConfig:
2109
+ # get last checkpoint
2110
+ config = ensure_config(self.config, input_config)
2111
+ saved = await checkpointer.aget_tuple(config)
2112
+ if saved is not None:
2113
+ self._migrate_checkpoint(saved.checkpoint)
2114
+ checkpoint = (
2115
+ copy_checkpoint(saved.checkpoint) if saved else empty_checkpoint()
2116
+ )
2117
+ checkpoint_previous_versions = (
2118
+ saved.checkpoint["channel_versions"].copy() if saved else {}
2119
+ )
2120
+ step = saved.metadata.get("step", -1) if saved else -1
2121
+ # merge configurable fields with previous checkpoint config
2122
+ checkpoint_config = patch_configurable(
2123
+ config,
2124
+ {
2125
+ CONFIG_KEY_CHECKPOINT_NS: config[CONF].get(
2126
+ CONFIG_KEY_CHECKPOINT_NS, ""
2127
+ )
2128
+ },
2129
+ )
2130
+ if saved:
2131
+ checkpoint_config = patch_configurable(config, saved.config[CONF])
2132
+ channels, managed = await achannels_from_checkpoint(
2133
+ self.channels,
2134
+ checkpoint,
2135
+ saver=self.checkpointer
2136
+ if saved is not None
2137
+ and isinstance(self.checkpointer, BaseCheckpointSaver)
2138
+ else None,
2139
+ config=saved.config if saved is not None else None,
2140
+ )
2141
+ values, as_node = updates[0][:2]
2142
+ # no values, just clear all tasks
2143
+ if values is None and as_node == END:
2144
+ if len(updates) > 1:
2145
+ raise InvalidUpdateError(
2146
+ "Cannot apply multiple updates when clearing state"
2147
+ )
2148
+ if saved is not None:
2149
+ # tasks for this checkpoint
2150
+ next_tasks = prepare_next_tasks(
2151
+ checkpoint,
2152
+ saved.pending_writes or [],
2153
+ self.nodes,
2154
+ channels,
2155
+ managed,
2156
+ saved.config,
2157
+ step + 1,
2158
+ step + 3,
2159
+ for_execution=True,
2160
+ store=self.store,
2161
+ checkpointer=checkpointer,
2162
+ manager=None,
2163
+ )
2164
+ # apply null writes
2165
+ if null_writes := [
2166
+ w[1:]
2167
+ for w in saved.pending_writes or []
2168
+ if w[0] == NULL_TASK_ID
2169
+ ]:
2170
+ apply_writes(
2171
+ checkpoint,
2172
+ channels,
2173
+ [PregelTaskWrites((), INPUT, null_writes, [])],
2174
+ checkpointer.get_next_version,
2175
+ self.trigger_to_nodes,
2176
+ )
2177
+ # apply writes from tasks that already ran
2178
+ for tid, k, v in saved.pending_writes or []:
2179
+ if k in (ERROR, INTERRUPT):
2180
+ continue
2181
+ if tid not in next_tasks:
2182
+ continue
2183
+ next_tasks[tid].writes.append((k, v))
2184
+ # clear all current tasks
2185
+ apply_writes(
2186
+ checkpoint,
2187
+ channels,
2188
+ next_tasks.values(),
2189
+ checkpointer.get_next_version,
2190
+ self.trigger_to_nodes,
2191
+ )
2192
+ # save checkpoint
2193
+ next_config = await checkpointer.aput(
2194
+ checkpoint_config,
2195
+ create_checkpoint(checkpoint, channels, step),
2196
+ {
2197
+ "source": "update",
2198
+ "step": step + 1,
2199
+ "parents": saved.metadata.get("parents", {}) if saved else {},
2200
+ },
2201
+ get_new_channel_versions(
2202
+ checkpoint_previous_versions, checkpoint["channel_versions"]
2203
+ ),
2204
+ )
2205
+ return patch_checkpoint_map(
2206
+ next_config, saved.metadata if saved else None
2207
+ )
2208
+
2209
+ # act as an input
2210
+ if as_node == INPUT:
2211
+ if len(updates) > 1:
2212
+ raise InvalidUpdateError(
2213
+ "Cannot apply multiple updates when updating as input"
2214
+ )
2215
+
2216
+ if input_writes := deque(map_input(self.input_channels, values)):
2217
+ apply_writes(
2218
+ checkpoint,
2219
+ channels,
2220
+ [PregelTaskWrites((), INPUT, input_writes, [])],
2221
+ checkpointer.get_next_version,
2222
+ self.trigger_to_nodes,
2223
+ )
2224
+
2225
+ # apply input write to channels
2226
+ next_step = (
2227
+ step + 1
2228
+ if saved and saved.metadata.get("step") is not None
2229
+ else -1
2230
+ )
2231
+ next_config = await checkpointer.aput(
2232
+ checkpoint_config,
2233
+ create_checkpoint(checkpoint, channels, next_step),
2234
+ {
2235
+ "source": "input",
2236
+ "step": next_step,
2237
+ "parents": saved.metadata.get("parents", {})
2238
+ if saved
2239
+ else {},
2240
+ },
2241
+ get_new_channel_versions(
2242
+ checkpoint_previous_versions,
2243
+ checkpoint["channel_versions"],
2244
+ ),
2245
+ )
2246
+
2247
+ # store the writes
2248
+ await checkpointer.aput_writes(
2249
+ next_config,
2250
+ input_writes,
2251
+ str(uuid5(UUID(checkpoint["id"]), INPUT)),
2252
+ )
2253
+
2254
+ return patch_checkpoint_map(
2255
+ next_config, saved.metadata if saved else None
2256
+ )
2257
+ else:
2258
+ raise InvalidUpdateError(
2259
+ f"Received no input writes for {self.input_channels}"
2260
+ )
2261
+
2262
+ # no values, copy checkpoint
2263
+ if as_node == "__copy__":
2264
+ if len(updates) > 1:
2265
+ raise InvalidUpdateError(
2266
+ "Cannot copy checkpoint with multiple updates"
2267
+ )
2268
+
2269
+ if saved is None:
2270
+ raise InvalidUpdateError("Cannot copy a non-existent checkpoint")
2271
+
2272
+ next_checkpoint = create_checkpoint(checkpoint, None, step)
2273
+
2274
+ # copy checkpoint
2275
+ next_config = await checkpointer.aput(
2276
+ saved.parent_config
2277
+ or patch_configurable(
2278
+ saved.config, {CONFIG_KEY_CHECKPOINT_ID: None}
2279
+ ),
2280
+ next_checkpoint,
2281
+ {
2282
+ "source": "fork",
2283
+ "step": step + 1,
2284
+ "parents": saved.metadata.get("parents", {}),
2285
+ },
2286
+ {},
2287
+ )
2288
+
2289
+ # we want to both clone a checkpoint and update state in one go.
2290
+ # reuse the same task ID if possible.
2291
+ if isinstance(values, list) and len(values) > 0:
2292
+ # figure out the task IDs for the next update checkpoint
2293
+ next_tasks = prepare_next_tasks(
2294
+ next_checkpoint,
2295
+ saved.pending_writes or [],
2296
+ self.nodes,
2297
+ channels,
2298
+ managed,
2299
+ next_config,
2300
+ step + 2,
2301
+ step + 4,
2302
+ for_execution=True,
2303
+ store=self.store,
2304
+ checkpointer=checkpointer,
2305
+ manager=None,
2306
+ )
2307
+
2308
+ tasks_group_by = defaultdict(list)
2309
+ user_group_by: dict[str, list[StateUpdate]] = defaultdict(list)
2310
+
2311
+ for task in next_tasks.values():
2312
+ tasks_group_by[task.name].append(task.id)
2313
+
2314
+ for item in values:
2315
+ if not isinstance(item, Sequence):
2316
+ raise InvalidUpdateError(
2317
+ f"Invalid update item: {item} when copying checkpoint"
2318
+ )
2319
+
2320
+ values, as_node = item[:2]
2321
+ user_group = user_group_by[as_node]
2322
+ tasks_group = tasks_group_by[as_node]
2323
+
2324
+ target_idx = len(user_group)
2325
+ task_id = (
2326
+ tasks_group[target_idx]
2327
+ if target_idx < len(tasks_group)
2328
+ else None
2329
+ )
2330
+
2331
+ user_group_by[as_node].append(
2332
+ StateUpdate(values=values, as_node=as_node, task_id=task_id)
2333
+ )
2334
+
2335
+ return await aperform_superstep(
2336
+ patch_checkpoint_map(next_config, saved.metadata),
2337
+ [item for lst in user_group_by.values() for item in lst],
2338
+ )
2339
+
2340
+ return patch_checkpoint_map(
2341
+ next_config, saved.metadata if saved else None
2342
+ )
2343
+
2344
+ # task ids can be provided in the StateUpdate, but if not,
2345
+ # we use the task id generated by prepare_next_tasks
2346
+ node_to_task_ids: dict[str, deque[str]] = defaultdict(deque)
2347
+ if saved is not None and saved.pending_writes is not None:
2348
+ # we call prepare_next_tasks to discover the task IDs that
2349
+ # would have been generated, so we can reuse them and
2350
+ # properly populate task.result in state history
2351
+ next_tasks = prepare_next_tasks(
2352
+ checkpoint,
2353
+ saved.pending_writes,
2354
+ self.nodes,
2355
+ channels,
2356
+ managed,
2357
+ saved.config,
2358
+ step + 1,
2359
+ step + 3,
2360
+ for_execution=True,
2361
+ store=self.store,
2362
+ checkpointer=checkpointer,
2363
+ manager=None,
2364
+ )
2365
+ # collect task ids to reuse so we can properly attach task results
2366
+ for t in next_tasks.values():
2367
+ node_to_task_ids[t.name].append(t.id)
2368
+
2369
+ valid_updates: list[tuple[str, dict[str, Any] | None, str | None]] = []
2370
+ if len(updates) == 1:
2371
+ values, as_node, task_id = updates[0]
2372
+ # find last node that updated the state, if not provided
2373
+ if as_node is None and len(self.nodes) == 1:
2374
+ as_node = tuple(self.nodes)[0]
2375
+ elif as_node is None and not saved:
2376
+ if (
2377
+ isinstance(self.input_channels, str)
2378
+ and self.input_channels in self.nodes
2379
+ ):
2380
+ as_node = self.input_channels
2381
+ elif as_node is None:
2382
+ last_seen_by_node = sorted(
2383
+ (v, n)
2384
+ for n, seen in checkpoint["versions_seen"].items()
2385
+ if n in self.nodes
2386
+ for v in seen.values()
2387
+ )
2388
+ # if two nodes updated the state at the same time, it's ambiguous
2389
+ if last_seen_by_node:
2390
+ if len(last_seen_by_node) == 1:
2391
+ as_node = last_seen_by_node[0][1]
2392
+ elif last_seen_by_node[-1][0] != last_seen_by_node[-2][0]:
2393
+ as_node = last_seen_by_node[-1][1]
2394
+ if as_node is None:
2395
+ raise InvalidUpdateError("Ambiguous update, specify as_node")
2396
+ if as_node not in self.nodes:
2397
+ raise InvalidUpdateError(f"Node {as_node} does not exist")
2398
+ valid_updates.append((as_node, values, task_id))
2399
+ else:
2400
+ for values, as_node, task_id in updates:
2401
+ if as_node is None:
2402
+ raise InvalidUpdateError(
2403
+ "as_node is required when applying multiple updates"
2404
+ )
2405
+ if as_node not in self.nodes:
2406
+ raise InvalidUpdateError(f"Node {as_node} does not exist")
2407
+
2408
+ valid_updates.append((as_node, values, task_id))
2409
+
2410
+ run_tasks: list[PregelTaskWrites] = []
2411
+ run_task_ids: list[str] = []
2412
+
2413
+ for as_node, values, provided_task_id in valid_updates:
2414
+ # create task to run all writers of the chosen node
2415
+ writers = self.nodes[as_node].flat_writers
2416
+ if not writers:
2417
+ raise InvalidUpdateError(f"Node {as_node} has no writers")
2418
+ writes: deque[tuple[str, Any]] = deque()
2419
+ task = PregelTaskWrites((), as_node, writes, [INTERRUPT])
2420
+ # get the task ids that were prepared for this node
2421
+ # if a task id was provided in the StateUpdate, we use it
2422
+ # otherwise, we use the next available task id
2423
+ prepared_task_ids = node_to_task_ids.get(as_node, deque())
2424
+ task_id = provided_task_id or (
2425
+ prepared_task_ids.popleft()
2426
+ if prepared_task_ids
2427
+ else str(uuid5(UUID(checkpoint["id"]), INTERRUPT))
2428
+ )
2429
+ run_tasks.append(task)
2430
+ run_task_ids.append(task_id)
2431
+ run = RunnableSequence(*writers) if len(writers) > 1 else writers[0]
2432
+ # execute task
2433
+ await run.ainvoke(
2434
+ values,
2435
+ patch_config(
2436
+ config,
2437
+ run_name=self.name + "UpdateState",
2438
+ configurable={
2439
+ # deque.extend is thread-safe
2440
+ CONFIG_KEY_SEND: writes.extend,
2441
+ CONFIG_KEY_TASK_ID: task_id,
2442
+ CONFIG_KEY_READ: partial(
2443
+ local_read,
2444
+ _scratchpad(
2445
+ None,
2446
+ [],
2447
+ task_id,
2448
+ "",
2449
+ None,
2450
+ step,
2451
+ step + 2,
2452
+ ),
2453
+ channels,
2454
+ managed,
2455
+ task,
2456
+ ),
2457
+ },
2458
+ ),
2459
+ )
2460
+ updated_channels = get_updated_channels_from_tasks(run_tasks)
2461
+ if saved is not None:
2462
+ for task_id, task in zip(run_task_ids, run_tasks):
2463
+ channel_writes = [w for w in task.writes if w[0] != PUSH]
2464
+ if channel_writes:
2465
+ await checkpointer.aput_writes(
2466
+ checkpoint_config, channel_writes, task_id
2467
+ )
2468
+ apply_writes(
2469
+ checkpoint,
2470
+ channels,
2471
+ run_tasks,
2472
+ checkpointer.get_next_version,
2473
+ self.trigger_to_nodes,
2474
+ )
2475
+ channels_to_snapshot, checkpoint_metadata = (
2476
+ create_checkpoint_plan_for_update_state_api(
2477
+ channels,
2478
+ updated_channels,
2479
+ step=step + 1,
2480
+ parents=saved.metadata.get("parents", {}) if saved else {},
2481
+ saved_metadata=saved.metadata if saved else None,
2482
+ is_fresh_thread=saved is None,
2483
+ )
2484
+ )
2485
+ checkpoint = create_checkpoint(
2486
+ checkpoint,
2487
+ channels,
2488
+ step + 1,
2489
+ updated_channels=updated_channels if channels_to_snapshot else None,
2490
+ get_next_version=checkpointer.get_next_version
2491
+ if channels_to_snapshot
2492
+ else None,
2493
+ channels_to_snapshot=channels_to_snapshot,
2494
+ )
2495
+ next_config = await checkpointer.aput(
2496
+ checkpoint_config,
2497
+ checkpoint,
2498
+ checkpoint_metadata,
2499
+ get_new_channel_versions(
2500
+ checkpoint_previous_versions, checkpoint["channel_versions"]
2501
+ ),
2502
+ )
2503
+ for task_id, task in zip(run_task_ids, run_tasks):
2504
+ if push_writes := [w for w in task.writes if w[0] == PUSH]:
2505
+ await checkpointer.aput_writes(next_config, push_writes, task_id)
2506
+ return patch_checkpoint_map(next_config, saved.metadata if saved else None)
2507
+
2508
+ current_config = patch_configurable(
2509
+ config, {CONFIG_KEY_THREAD_ID: str(config[CONF][CONFIG_KEY_THREAD_ID])}
2510
+ )
2511
+ for superstep in supersteps:
2512
+ current_config = await aperform_superstep(current_config, superstep)
2513
+ return current_config
2514
+
2515
+ def update_state(
2516
+ self,
2517
+ config: RunnableConfig,
2518
+ values: dict[str, Any] | Any | None,
2519
+ as_node: str | None = None,
2520
+ task_id: str | None = None,
2521
+ ) -> RunnableConfig:
2522
+ """Update the state of the graph with the given values, as if they came from
2523
+ node `as_node`. If `as_node` is not provided, it will be set to the last node
2524
+ that updated the state, if not ambiguous.
2525
+ """
2526
+ return self.bulk_update_state(config, [[StateUpdate(values, as_node, task_id)]])
2527
+
2528
+ async def aupdate_state(
2529
+ self,
2530
+ config: RunnableConfig,
2531
+ values: dict[str, Any] | Any,
2532
+ as_node: str | None = None,
2533
+ task_id: str | None = None,
2534
+ ) -> RunnableConfig:
2535
+ """Asynchronously update the state of the graph with the given values, as if they came from
2536
+ node `as_node`. If `as_node` is not provided, it will be set to the last node
2537
+ that updated the state, if not ambiguous.
2538
+ """
2539
+ return await self.abulk_update_state(
2540
+ config, [[StateUpdate(values, as_node, task_id)]]
2541
+ )
2542
+
2543
+ def _defaults(
2544
+ self,
2545
+ config: RunnableConfig,
2546
+ *,
2547
+ stream_mode: StreamMode | Sequence[StreamMode],
2548
+ print_mode: StreamMode | Sequence[StreamMode],
2549
+ output_keys: str | Sequence[str] | None,
2550
+ interrupt_before: All | Sequence[str] | None,
2551
+ interrupt_after: All | Sequence[str] | None,
2552
+ durability: Durability | None = None,
2553
+ ) -> tuple[
2554
+ set[StreamMode],
2555
+ str | Sequence[str],
2556
+ All | Sequence[str],
2557
+ All | Sequence[str],
2558
+ BaseCheckpointSaver | None,
2559
+ BaseStore | None,
2560
+ BaseCache | None,
2561
+ Durability,
2562
+ ]:
2563
+ if config["recursion_limit"] < 1:
2564
+ raise ValueError("recursion_limit must be at least 1")
2565
+ if output_keys is None:
2566
+ output_keys = self.stream_channels_asis
2567
+ else:
2568
+ validate_keys(output_keys, self.channels)
2569
+ interrupt_before = interrupt_before or self.interrupt_before_nodes
2570
+ interrupt_after = interrupt_after or self.interrupt_after_nodes
2571
+ if isinstance(stream_mode, str):
2572
+ stream_modes = {stream_mode}
2573
+ else:
2574
+ stream_modes = set(stream_mode)
2575
+ if isinstance(print_mode, str):
2576
+ stream_modes.add(print_mode)
2577
+ else:
2578
+ stream_modes.update(print_mode)
2579
+ if self.checkpointer is False:
2580
+ checkpointer: BaseCheckpointSaver | None = None
2581
+ elif CONFIG_KEY_CHECKPOINTER in config.get(CONF, {}):
2582
+ checkpointer = config[CONF][CONFIG_KEY_CHECKPOINTER]
2583
+ elif self.checkpointer is True:
2584
+ raise RuntimeError("checkpointer=True cannot be used for root graphs.")
2585
+ else:
2586
+ checkpointer = self.checkpointer
2587
+ if isinstance(checkpointer, BaseCheckpointSaver):
2588
+ checkpointer = self._apply_checkpointer_allowlist(checkpointer)
2589
+ if checkpointer and not config.get(CONF):
2590
+ raise ValueError(
2591
+ "Checkpointer requires one or more of the following 'configurable' "
2592
+ "keys: thread_id, checkpoint_ns, checkpoint_id"
2593
+ )
2594
+ if CONFIG_KEY_RUNTIME in config.get(CONF, {}):
2595
+ store: BaseStore | None = config[CONF][CONFIG_KEY_RUNTIME].store
2596
+ else:
2597
+ store = self.store
2598
+ if CONFIG_KEY_CACHE in config.get(CONF, {}):
2599
+ cache: BaseCache | None = config[CONF][CONFIG_KEY_CACHE]
2600
+ else:
2601
+ cache = self.cache
2602
+ if durability is None:
2603
+ durability = config.get(CONF, {}).get(CONFIG_KEY_DURABILITY, "async")
2604
+ return (
2605
+ stream_modes,
2606
+ output_keys,
2607
+ interrupt_before,
2608
+ interrupt_after,
2609
+ checkpointer,
2610
+ store,
2611
+ cache,
2612
+ durability,
2613
+ )
2614
+
2615
+ @overload
2616
+ def stream(
2617
+ self,
2618
+ input: InputT | Command | None,
2619
+ config: RunnableConfig | None = None,
2620
+ *,
2621
+ context: ContextT | None = None,
2622
+ stream_mode: StreamMode | Sequence[StreamMode] | None = None,
2623
+ print_mode: StreamMode | Sequence[StreamMode] = (),
2624
+ output_keys: str | Sequence[str] | None = None,
2625
+ interrupt_before: All | Sequence[str] | None = None,
2626
+ interrupt_after: All | Sequence[str] | None = None,
2627
+ durability: Durability | None = None,
2628
+ control: RunControl | None = None,
2629
+ subgraphs: bool = False,
2630
+ debug: bool | None = None,
2631
+ version: Literal["v2"],
2632
+ **kwargs: Unpack[DeprecatedKwargs],
2633
+ ) -> Iterator[StreamPart[StateT, OutputT]]: ...
2634
+
2635
+ @overload
2636
+ def stream(
2637
+ self,
2638
+ input: InputT | Command | None,
2639
+ config: RunnableConfig | None = None,
2640
+ *,
2641
+ context: ContextT | None = None,
2642
+ stream_mode: StreamMode | Sequence[StreamMode] | None = None,
2643
+ print_mode: StreamMode | Sequence[StreamMode] = (),
2644
+ output_keys: str | Sequence[str] | None = None,
2645
+ interrupt_before: All | Sequence[str] | None = None,
2646
+ interrupt_after: All | Sequence[str] | None = None,
2647
+ durability: Durability | None = None,
2648
+ control: RunControl | None = None,
2649
+ subgraphs: bool = False,
2650
+ debug: bool | None = None,
2651
+ version: Literal["v1"] = ...,
2652
+ **kwargs: Unpack[DeprecatedKwargs],
2653
+ ) -> Iterator[dict[str, Any] | Any]: ...
2654
+
2655
+ def stream(
2656
+ self,
2657
+ input: InputT | Command | None,
2658
+ config: RunnableConfig | None = None,
2659
+ *,
2660
+ context: ContextT | None = None,
2661
+ stream_mode: StreamMode | Sequence[StreamMode] | None = None,
2662
+ print_mode: StreamMode | Sequence[StreamMode] = (),
2663
+ output_keys: str | Sequence[str] | None = None,
2664
+ interrupt_before: All | Sequence[str] | None = None,
2665
+ interrupt_after: All | Sequence[str] | None = None,
2666
+ durability: Durability | None = None,
2667
+ control: RunControl | None = None,
2668
+ subgraphs: bool = False,
2669
+ debug: bool | None = None,
2670
+ version: Literal["v1", "v2"] = "v1",
2671
+ **kwargs: Unpack[DeprecatedKwargs],
2672
+ ) -> Iterator[dict[str, Any] | Any]:
2673
+ """Stream graph steps for a single input.
2674
+
2675
+ Args:
2676
+ input: The input to the graph.
2677
+ config: The configuration to use for the run.
2678
+ context: The static context to use for the run.
2679
+ !!! version-added "Added in version 0.6.0"
2680
+ stream_mode: The mode to stream output, defaults to `self.stream_mode`.
2681
+
2682
+ Options are:
2683
+
2684
+ - `"values"`: Emit all values in the state after each step, including interrupts.
2685
+ When used with functional API, values are emitted once at the end of the workflow.
2686
+ - `"updates"`: Emit only the node or task names and updates returned by the nodes or tasks after each step.
2687
+ If multiple updates are made in the same step (e.g. multiple nodes are run) then those updates are emitted separately.
2688
+ - `"custom"`: Emit custom data from inside nodes or tasks using `StreamWriter`.
2689
+ - `"messages"`: Emit LLM messages token-by-token together with metadata for any LLM invocations inside nodes or tasks.
2690
+ - Will be emitted as 2-tuples `(LLM token, metadata)`.
2691
+ - `"checkpoints"`: Emit an event when a checkpoint is created, in the same format as returned by `get_state()`.
2692
+ - `"tasks"`: Emit events when tasks start and finish, including their results and errors.
2693
+ - `"debug"`: Emit debug events with as much information as possible for each step.
2694
+
2695
+ You can pass a list as the `stream_mode` parameter to stream multiple modes at once.
2696
+ The streamed outputs will be tuples of `(mode, data)`.
2697
+
2698
+ See [CocoGraph streaming guide](https://docs.langchain.com/oss/python/langgraph/streaming) for more details.
2699
+ print_mode: Accepts the same values as `stream_mode`, but only prints the output to the console, for debugging purposes.
2700
+
2701
+ Does not affect the output of the graph in any way.
2702
+ output_keys: The keys to stream, defaults to all non-context channels.
2703
+ interrupt_before: Nodes to interrupt before, defaults to all nodes in the graph.
2704
+ interrupt_after: Nodes to interrupt after, defaults to all nodes in the graph.
2705
+ durability: The durability mode for the graph execution, defaults to `"async"`.
2706
+
2707
+ Options are:
2708
+
2709
+ - `"sync"`: Changes are persisted synchronously before the next step starts.
2710
+ - `"async"`: Changes are persisted asynchronously while the next step executes.
2711
+ - `"exit"`: Changes are persisted only when the graph exits.
2712
+ control: Optional run control used to request cooperative drain.
2713
+ subgraphs: Whether to stream events from inside subgraphs, defaults to `False`.
2714
+
2715
+ If `True`, the events will be emitted as tuples `(namespace, data)`,
2716
+ or `(namespace, mode, data)` if `stream_mode` is a list,
2717
+ where `namespace` is a tuple with the path to the node where a subgraph is invoked,
2718
+ e.g. `("parent_node:<task_id>", "child_node:<task_id>")`.
2719
+
2720
+ See [CocoGraph streaming guide](https://docs.langchain.com/oss/python/langgraph/streaming) for more details.
2721
+
2722
+ Yields:
2723
+ The output of each step in the graph. The output shape depends on the `stream_mode`.
2724
+ """
2725
+ if (checkpoint_during := kwargs.get("checkpoint_during")) is not None:
2726
+ warnings.warn(
2727
+ "`checkpoint_during` is deprecated and will be removed. Please use `durability` instead.",
2728
+ category=CocoGraphDeprecatedSinceV10,
2729
+ stacklevel=2,
2730
+ )
2731
+ if durability is not None:
2732
+ raise ValueError(
2733
+ "Cannot use both `checkpoint_during` and `durability` parameters. Please use `durability` instead."
2734
+ )
2735
+ durability = "async" if checkpoint_during else "exit"
2736
+
2737
+ if stream_mode is None:
2738
+ # if being called as a node in another graph, default to values mode
2739
+ # but don't overwrite stream_mode arg if provided
2740
+ stream_mode = (
2741
+ "values"
2742
+ if config is not None and CONFIG_KEY_TASK_ID in config.get(CONF, {})
2743
+ else self.stream_mode
2744
+ )
2745
+ if debug or self.debug:
2746
+ print_mode = ["updates", "values"]
2747
+
2748
+ stream = SyncQueue()
2749
+
2750
+ config = ensure_config(self.config, config)
2751
+ run_manager = None
2752
+ try:
2753
+ # assign defaults
2754
+ (
2755
+ stream_modes,
2756
+ output_keys,
2757
+ interrupt_before_,
2758
+ interrupt_after_,
2759
+ checkpointer,
2760
+ store,
2761
+ cache,
2762
+ durability_,
2763
+ ) = self._defaults(
2764
+ config,
2765
+ stream_mode=stream_mode,
2766
+ print_mode=print_mode,
2767
+ output_keys=output_keys,
2768
+ interrupt_before=interrupt_before,
2769
+ interrupt_after=interrupt_after,
2770
+ durability=durability,
2771
+ )
2772
+ callback_manager = get_callback_manager_for_config(config)
2773
+ if "messages" in stream_modes and version != "v2":
2774
+ # Strip any inherited v2 messages handler so a v1 stream
2775
+ # does not get routed through the content-block event
2776
+ # protocol. Leave v1 handlers in place — an outer
2777
+ # stream(stream_mode="messages", subgraphs=True) relies
2778
+ # on its inheritable handler to observe events emitted
2779
+ # by inner stream(stream_mode="messages") calls.
2780
+ callback_manager.handlers = [
2781
+ h
2782
+ for h in callback_manager.handlers
2783
+ if not isinstance(h, StreamMessagesHandlerV2)
2784
+ ]
2785
+ callback_manager.inheritable_handlers = [
2786
+ h
2787
+ for h in callback_manager.inheritable_handlers
2788
+ if not isinstance(h, StreamMessagesHandlerV2)
2789
+ ]
2790
+ if "ls_integration" not in callback_manager.metadata:
2791
+ callback_manager.add_metadata({"ls_integration": "coco_graph"})
2792
+ run_manager = callback_manager.on_chain_start(
2793
+ None,
2794
+ input,
2795
+ name=config.get("run_name", self.get_name()),
2796
+ run_id=config.get("run_id"),
2797
+ )
2798
+ graph_callback_manager = get_sync_graph_callback_manager_for_config(
2799
+ config,
2800
+ run_id=run_manager.run_id,
2801
+ )
2802
+ if checkpointer is None and durability is not None:
2803
+ warnings.warn(
2804
+ "`durability` has no effect when no checkpointer is present.",
2805
+ )
2806
+ # set up subgraph checkpointing
2807
+ if self.checkpointer is True:
2808
+ ns = cast(str, config[CONF][CONFIG_KEY_CHECKPOINT_NS])
2809
+ config[CONF][CONFIG_KEY_CHECKPOINT_NS] = recast_checkpoint_ns(ns)
2810
+ # set up messages stream mode
2811
+ if "messages" in stream_modes:
2812
+ ns_ = cast(str | None, config[CONF].get(CONFIG_KEY_CHECKPOINT_NS))
2813
+ use_stream_messages_v2 = bool(
2814
+ version == "v2" and config[CONF].get(CONFIG_KEY_STREAM_MESSAGES_V2)
2815
+ )
2816
+ messages_handler_cls = (
2817
+ StreamMessagesHandlerV2
2818
+ if use_stream_messages_v2
2819
+ else StreamMessagesHandler
2820
+ )
2821
+ run_manager.inheritable_handlers.append(
2822
+ messages_handler_cls(
2823
+ stream.put,
2824
+ subgraphs,
2825
+ parent_ns=tuple(ns_.split(NS_SEP)) if ns_ else None,
2826
+ )
2827
+ )
2828
+
2829
+ # set up tools stream mode
2830
+ if "tools" in stream_modes:
2831
+ ns_tools = cast(str | None, config[CONF].get(CONFIG_KEY_CHECKPOINT_NS))
2832
+ run_manager.inheritable_handlers.append(
2833
+ StreamToolCallHandler(
2834
+ stream.put,
2835
+ subgraphs,
2836
+ parent_ns=tuple(ns_tools.split(NS_SEP)) if ns_tools else None,
2837
+ )
2838
+ )
2839
+
2840
+ # set up custom stream mode
2841
+ if "custom" in stream_modes:
2842
+
2843
+ def stream_writer(c: Any) -> None:
2844
+ stream.put(
2845
+ (
2846
+ tuple(
2847
+ get_config()[CONF][CONFIG_KEY_CHECKPOINT_NS].split(
2848
+ NS_SEP
2849
+ )[:-1]
2850
+ ),
2851
+ "custom",
2852
+ c,
2853
+ )
2854
+ )
2855
+ elif CONFIG_KEY_STREAM in config[CONF]:
2856
+ stream_writer = config[CONF][CONFIG_KEY_RUNTIME].stream_writer
2857
+ else:
2858
+
2859
+ def stream_writer(c: Any) -> None:
2860
+ pass
2861
+
2862
+ # set durability mode for subgraphs
2863
+ if durability is not None:
2864
+ config[CONF][CONFIG_KEY_DURABILITY] = durability_
2865
+
2866
+ # build server_info from metadata + parent runtime
2867
+ parent_runtime = _coerce_parent_runtime(
2868
+ config[CONF].get(CONFIG_KEY_RUNTIME, DEFAULT_RUNTIME)
2869
+ )
2870
+ server_info = _build_server_info(config, parent_runtime)
2871
+
2872
+ runtime = Runtime(
2873
+ context=_coerce_context(self.context_schema, context),
2874
+ store=store,
2875
+ stream_writer=stream_writer,
2876
+ previous=None,
2877
+ execution_info=None,
2878
+ server_info=server_info,
2879
+ control=control or parent_runtime.control or RunControl(),
2880
+ )
2881
+ runtime = parent_runtime.merge(runtime)
2882
+ config[CONF][CONFIG_KEY_RUNTIME] = runtime
2883
+
2884
+ # resolve mappers for v2 stream coercion
2885
+ _output_mapper = self._output_mapper if version == "v2" else None
2886
+ _state_mapper = self._state_mapper if version == "v2" else None
2887
+
2888
+ def emit_graph_lifecycle_events(loop: SyncPregelLoop) -> None:
2889
+ while (event := loop._pop_lifecycle_event()) is not None:
2890
+ if isinstance(event, GraphResumeEvent):
2891
+ graph_callback_manager.on_resume(
2892
+ replace(event, run_id=graph_callback_manager.run_id)
2893
+ )
2894
+ else:
2895
+ graph_callback_manager.on_interrupt(
2896
+ replace(event, run_id=graph_callback_manager.run_id)
2897
+ )
2898
+
2899
+ with SyncPregelLoop(
2900
+ input,
2901
+ stream=StreamProtocol(stream.put, stream_modes),
2902
+ config=config,
2903
+ store=store,
2904
+ cache=cache,
2905
+ checkpointer=checkpointer,
2906
+ nodes=self.nodes,
2907
+ specs=self.channels,
2908
+ output_keys=output_keys,
2909
+ input_keys=self.input_channels,
2910
+ stream_keys=self.stream_channels_asis,
2911
+ interrupt_before=interrupt_before_,
2912
+ interrupt_after=interrupt_after_,
2913
+ manager=run_manager,
2914
+ durability=durability_,
2915
+ trigger_to_nodes=self.trigger_to_nodes,
2916
+ migrate_checkpoint=self._migrate_checkpoint,
2917
+ retry_policy=self.retry_policy,
2918
+ cache_policy=self.cache_policy,
2919
+ has_graph_lifecycle_callbacks=bool(graph_callback_manager.handlers),
2920
+ ) as loop:
2921
+ emit_graph_lifecycle_events(loop)
2922
+ # create runner
2923
+ runner = PregelRunner(
2924
+ submit=config[CONF].get(
2925
+ CONFIG_KEY_RUNNER_SUBMIT, weakref.WeakMethod(loop.submit)
2926
+ ),
2927
+ put_writes=weakref.WeakMethod(loop.put_writes),
2928
+ node_finished=config[CONF].get(CONFIG_KEY_NODE_FINISHED),
2929
+ node_error_handler_map=self.node_error_handler_map,
2930
+ schedule_error_handler=loop.schedule_error_handler,
2931
+ )
2932
+ # enable subgraph streaming
2933
+ if subgraphs:
2934
+ loop.config[CONF][CONFIG_KEY_STREAM] = loop.stream
2935
+ # enable concurrent streaming
2936
+ get_waiter: Callable[[], concurrent.futures.Future[None]] | None = None
2937
+ if (
2938
+ self.stream_eager
2939
+ or subgraphs
2940
+ or "messages" in stream_modes
2941
+ or "custom" in stream_modes
2942
+ ):
2943
+ # we are careful to have a single waiter live at any one time
2944
+ # because on exit we increment semaphore count by exactly 1
2945
+ waiter: concurrent.futures.Future | None = None
2946
+ # because sync futures cannot be cancelled, we instead
2947
+ # release the stream semaphore on exit, which will cause
2948
+ # a pending waiter to return immediately
2949
+ loop.stack.callback(stream._count.release)
2950
+
2951
+ def get_waiter() -> concurrent.futures.Future[None]:
2952
+ nonlocal waiter
2953
+ if waiter is None or waiter.done():
2954
+ waiter = loop.submit(stream.wait)
2955
+ return waiter
2956
+ else:
2957
+ return waiter
2958
+
2959
+ # Similarly to Bulk Synchronous Parallel / Pregel model
2960
+ # computation proceeds in steps, while there are channel updates.
2961
+ # Channel updates from step N are only visible in step N+1
2962
+ # channels are guaranteed to be immutable for the duration of the step,
2963
+ # with channel updates applied only at the transition between steps.
2964
+ while loop.tick():
2965
+ for task in loop.match_cached_writes():
2966
+ loop.output_writes(task.id, task.writes, cached=True)
2967
+ for _ in runner.tick(
2968
+ [t for t in loop.tasks.values() if not t.writes],
2969
+ timeout=self.step_timeout,
2970
+ get_waiter=get_waiter,
2971
+ schedule_task=loop.accept_push,
2972
+ ):
2973
+ # emit output
2974
+ yield from _output(
2975
+ stream_mode,
2976
+ print_mode,
2977
+ subgraphs,
2978
+ stream.get,
2979
+ queue.Empty,
2980
+ version,
2981
+ _output_mapper,
2982
+ _state_mapper,
2983
+ )
2984
+ loop.after_tick()
2985
+ emit_graph_lifecycle_events(loop)
2986
+ # wait for checkpoint
2987
+ if durability_ == "sync":
2988
+ loop._put_checkpoint_fut.result()
2989
+ emit_graph_lifecycle_events(loop)
2990
+ # emit output
2991
+ yield from _output(
2992
+ stream_mode,
2993
+ print_mode,
2994
+ subgraphs,
2995
+ stream.get,
2996
+ queue.Empty,
2997
+ version,
2998
+ _output_mapper,
2999
+ _state_mapper,
3000
+ )
3001
+ # handle exit
3002
+ if loop.status == "out_of_steps":
3003
+ msg = create_error_message(
3004
+ message=(
3005
+ f"Recursion limit of {config['recursion_limit']} reached "
3006
+ "without hitting a stop condition. You can increase the "
3007
+ "limit by setting the `recursion_limit` config key."
3008
+ ),
3009
+ error_code=ErrorCode.GRAPH_RECURSION_LIMIT,
3010
+ )
3011
+ raise GraphRecursionError(msg)
3012
+ elif loop.status == "draining":
3013
+ if loop.control is None:
3014
+ raise RuntimeError("Draining status requires run control")
3015
+ raise GraphDrained(loop.control.drain_reason or "shutdown")
3016
+ # set final channel values as run output
3017
+ run_manager.on_chain_end(loop.output)
3018
+ except BaseException as e:
3019
+ if run_manager is not None:
3020
+ run_manager.on_chain_error(e)
3021
+ raise
3022
+
3023
+ @overload
3024
+ def astream(
3025
+ self,
3026
+ input: InputT | Command | None,
3027
+ config: RunnableConfig | None = None,
3028
+ *,
3029
+ context: ContextT | None = None,
3030
+ stream_mode: StreamMode | Sequence[StreamMode] | None = None,
3031
+ print_mode: StreamMode | Sequence[StreamMode] = (),
3032
+ output_keys: str | Sequence[str] | None = None,
3033
+ interrupt_before: All | Sequence[str] | None = None,
3034
+ interrupt_after: All | Sequence[str] | None = None,
3035
+ durability: Durability | None = None,
3036
+ control: RunControl | None = None,
3037
+ subgraphs: bool = False,
3038
+ debug: bool | None = None,
3039
+ version: Literal["v2"],
3040
+ **kwargs: Unpack[DeprecatedKwargs],
3041
+ ) -> AsyncIterator[StreamPart[StateT, OutputT]]: ...
3042
+
3043
+ @overload
3044
+ def astream(
3045
+ self,
3046
+ input: InputT | Command | None,
3047
+ config: RunnableConfig | None = None,
3048
+ *,
3049
+ context: ContextT | None = None,
3050
+ stream_mode: StreamMode | Sequence[StreamMode] | None = None,
3051
+ print_mode: StreamMode | Sequence[StreamMode] = (),
3052
+ output_keys: str | Sequence[str] | None = None,
3053
+ interrupt_before: All | Sequence[str] | None = None,
3054
+ interrupt_after: All | Sequence[str] | None = None,
3055
+ durability: Durability | None = None,
3056
+ control: RunControl | None = None,
3057
+ subgraphs: bool = False,
3058
+ debug: bool | None = None,
3059
+ version: Literal["v1"] = ...,
3060
+ **kwargs: Unpack[DeprecatedKwargs],
3061
+ ) -> AsyncIterator[dict[str, Any] | Any]: ...
3062
+
3063
+ async def astream(
3064
+ self,
3065
+ input: InputT | Command | None,
3066
+ config: RunnableConfig | None = None,
3067
+ *,
3068
+ context: ContextT | None = None,
3069
+ stream_mode: StreamMode | Sequence[StreamMode] | None = None,
3070
+ print_mode: StreamMode | Sequence[StreamMode] = (),
3071
+ output_keys: str | Sequence[str] | None = None,
3072
+ interrupt_before: All | Sequence[str] | None = None,
3073
+ interrupt_after: All | Sequence[str] | None = None,
3074
+ durability: Durability | None = None,
3075
+ control: RunControl | None = None,
3076
+ subgraphs: bool = False,
3077
+ debug: bool | None = None,
3078
+ version: Literal["v1", "v2"] = "v1",
3079
+ **kwargs: Unpack[DeprecatedKwargs],
3080
+ ) -> AsyncIterator[dict[str, Any] | Any]:
3081
+ """Asynchronously stream graph steps for a single input.
3082
+
3083
+ Args:
3084
+ input: The input to the graph.
3085
+ config: The configuration to use for the run.
3086
+ context: The static context to use for the run.
3087
+ !!! version-added "Added in version 0.6.0"
3088
+ stream_mode: The mode to stream output, defaults to `self.stream_mode`.
3089
+
3090
+ Options are:
3091
+
3092
+ - `"values"`: Emit all values in the state after each step, including interrupts.
3093
+ When used with functional API, values are emitted once at the end of the workflow.
3094
+ - `"updates"`: Emit only the node or task names and updates returned by the nodes or tasks after each step.
3095
+ If multiple updates are made in the same step (e.g. multiple nodes are run) then those updates are emitted separately.
3096
+ - `"custom"`: Emit custom data from inside nodes or tasks using `StreamWriter`.
3097
+ - `"messages"`: Emit LLM messages token-by-token together with metadata for any LLM invocations inside nodes or tasks.
3098
+ - Will be emitted as 2-tuples `(LLM token, metadata)`.
3099
+ - `"checkpoints"`: Emit an event when a checkpoint is created, in the same format as returned by `get_state()`.
3100
+ - `"tasks"`: Emit events when tasks start and finish, including their results and errors.
3101
+ - `"debug"`: Emit debug events with as much information as possible for each step.
3102
+
3103
+ You can pass a list as the `stream_mode` parameter to stream multiple modes at once.
3104
+ The streamed outputs will be tuples of `(mode, data)`.
3105
+
3106
+ See [CocoGraph streaming guide](https://docs.langchain.com/oss/python/langgraph/streaming) for more details.
3107
+ print_mode: Accepts the same values as `stream_mode`, but only prints the output to the console, for debugging purposes.
3108
+
3109
+ Does not affect the output of the graph in any way.
3110
+ output_keys: The keys to stream, defaults to all non-context channels.
3111
+ interrupt_before: Nodes to interrupt before, defaults to all nodes in the graph.
3112
+ interrupt_after: Nodes to interrupt after, defaults to all nodes in the graph.
3113
+ durability: The durability mode for the graph execution, defaults to `"async"`.
3114
+
3115
+ Options are:
3116
+
3117
+ - `"sync"`: Changes are persisted synchronously before the next step starts.
3118
+ - `"async"`: Changes are persisted asynchronously while the next step executes.
3119
+ - `"exit"`: Changes are persisted only when the graph exits.
3120
+ control: Optional run control used to request cooperative drain.
3121
+ subgraphs: Whether to stream events from inside subgraphs, defaults to `False`.
3122
+
3123
+ If `True`, the events will be emitted as tuples `(namespace, data)`,
3124
+ or `(namespace, mode, data)` if `stream_mode` is a list,
3125
+ where `namespace` is a tuple with the path to the node where a subgraph is invoked,
3126
+ e.g. `("parent_node:<task_id>", "child_node:<task_id>")`.
3127
+
3128
+ See [CocoGraph streaming guide](https://docs.langchain.com/oss/python/langgraph/streaming) for more details.
3129
+
3130
+ Yields:
3131
+ The output of each step in the graph. The output shape depends on the `stream_mode`.
3132
+ """
3133
+ if (checkpoint_during := kwargs.get("checkpoint_during")) is not None:
3134
+ warnings.warn(
3135
+ "`checkpoint_during` is deprecated and will be removed. Please use `durability` instead.",
3136
+ category=CocoGraphDeprecatedSinceV10,
3137
+ stacklevel=2,
3138
+ )
3139
+ if durability is not None:
3140
+ raise ValueError(
3141
+ "Cannot use both `checkpoint_during` and `durability` parameters. Please use `durability` instead."
3142
+ )
3143
+ durability = "async" if checkpoint_during else "exit"
3144
+
3145
+ if stream_mode is None:
3146
+ # if being called as a node in another graph, default to values mode
3147
+ # but don't overwrite stream_mode arg if provided
3148
+ stream_mode = (
3149
+ "values"
3150
+ if config is not None and CONFIG_KEY_TASK_ID in config.get(CONF, {})
3151
+ else self.stream_mode
3152
+ )
3153
+ if debug or self.debug:
3154
+ print_mode = ["updates", "values"]
3155
+
3156
+ stream = AsyncQueue()
3157
+ aioloop = asyncio.get_running_loop()
3158
+ stream_put = cast(
3159
+ Callable[[StreamChunk], None],
3160
+ partial(aioloop.call_soon_threadsafe, stream.put_nowait),
3161
+ )
3162
+
3163
+ config = ensure_config(self.config, config)
3164
+ run_manager = None
3165
+ try:
3166
+ # assign defaults
3167
+ (
3168
+ stream_modes,
3169
+ output_keys,
3170
+ interrupt_before_,
3171
+ interrupt_after_,
3172
+ checkpointer,
3173
+ store,
3174
+ cache,
3175
+ durability_,
3176
+ ) = self._defaults(
3177
+ config,
3178
+ stream_mode=stream_mode,
3179
+ print_mode=print_mode,
3180
+ output_keys=output_keys,
3181
+ interrupt_before=interrupt_before,
3182
+ interrupt_after=interrupt_after,
3183
+ durability=durability,
3184
+ )
3185
+ callback_manager = get_async_callback_manager_for_config(config)
3186
+ if "messages" in stream_modes and version != "v2":
3187
+ # Strip any inherited v2 messages handler so a v1 stream
3188
+ # does not get routed through the content-block event
3189
+ # protocol. Leave v1 handlers in place — an outer
3190
+ # astream(stream_mode="messages", subgraphs=True) relies
3191
+ # on its inheritable handler to observe events emitted
3192
+ # by inner astream(stream_mode="messages") calls.
3193
+ callback_manager.handlers = [
3194
+ h
3195
+ for h in callback_manager.handlers
3196
+ if not isinstance(h, StreamMessagesHandlerV2)
3197
+ ]
3198
+ callback_manager.inheritable_handlers = [
3199
+ h
3200
+ for h in callback_manager.inheritable_handlers
3201
+ if not isinstance(h, StreamMessagesHandlerV2)
3202
+ ]
3203
+ if "ls_integration" not in callback_manager.metadata:
3204
+ callback_manager.add_metadata({"ls_integration": "coco_graph"})
3205
+ run_manager = await callback_manager.on_chain_start(
3206
+ None,
3207
+ input,
3208
+ name=config.get("run_name", self.get_name()),
3209
+ run_id=config.get("run_id"),
3210
+ )
3211
+ graph_callback_manager = get_async_graph_callback_manager_for_config(
3212
+ config,
3213
+ run_id=run_manager.run_id,
3214
+ )
3215
+ # if running from astream_log() run each proc with streaming
3216
+ do_stream = (
3217
+ next(
3218
+ (
3219
+ True
3220
+ for h in run_manager.handlers
3221
+ if isinstance(h, _StreamingCallbackHandler)
3222
+ and not isinstance(h, StreamMessagesHandler)
3223
+ ),
3224
+ False,
3225
+ )
3226
+ if _StreamingCallbackHandler is not None
3227
+ else False
3228
+ )
3229
+ if checkpointer is None and durability is not None:
3230
+ warnings.warn(
3231
+ "`durability` has no effect when no checkpointer is present.",
3232
+ )
3233
+ # set up subgraph checkpointing
3234
+ if self.checkpointer is True:
3235
+ ns = cast(str, config[CONF][CONFIG_KEY_CHECKPOINT_NS])
3236
+ config[CONF][CONFIG_KEY_CHECKPOINT_NS] = recast_checkpoint_ns(ns)
3237
+ # set up messages stream mode
3238
+ if "messages" in stream_modes:
3239
+ # namespace can be None in a root level graph?
3240
+ ns_ = cast(str | None, config[CONF].get(CONFIG_KEY_CHECKPOINT_NS))
3241
+ use_stream_messages_v2 = bool(
3242
+ version == "v2" and config[CONF].get(CONFIG_KEY_STREAM_MESSAGES_V2)
3243
+ )
3244
+ messages_handler_cls = (
3245
+ StreamMessagesHandlerV2
3246
+ if use_stream_messages_v2
3247
+ else StreamMessagesHandler
3248
+ )
3249
+ run_manager.inheritable_handlers.append(
3250
+ messages_handler_cls(
3251
+ stream_put,
3252
+ subgraphs,
3253
+ parent_ns=tuple(ns_.split(NS_SEP)) if ns_ else None,
3254
+ )
3255
+ )
3256
+
3257
+ # set up tools stream mode
3258
+ if "tools" in stream_modes:
3259
+ ns_tools = cast(str | None, config[CONF].get(CONFIG_KEY_CHECKPOINT_NS))
3260
+ run_manager.inheritable_handlers.append(
3261
+ StreamToolCallHandler(
3262
+ stream_put,
3263
+ subgraphs,
3264
+ parent_ns=tuple(ns_tools.split(NS_SEP)) if ns_tools else None,
3265
+ )
3266
+ )
3267
+
3268
+ # set up custom stream mode
3269
+ def stream_writer(c: Any) -> None:
3270
+ aioloop.call_soon_threadsafe(
3271
+ stream.put_nowait,
3272
+ (
3273
+ tuple(
3274
+ get_config()[CONF][CONFIG_KEY_CHECKPOINT_NS].split(NS_SEP)[
3275
+ :-1
3276
+ ]
3277
+ ),
3278
+ "custom",
3279
+ c,
3280
+ ),
3281
+ )
3282
+
3283
+ if "custom" in stream_modes:
3284
+
3285
+ def stream_writer(c: Any) -> None:
3286
+ aioloop.call_soon_threadsafe(
3287
+ stream.put_nowait,
3288
+ (
3289
+ tuple(
3290
+ get_config()[CONF][CONFIG_KEY_CHECKPOINT_NS].split(
3291
+ NS_SEP
3292
+ )[:-1]
3293
+ ),
3294
+ "custom",
3295
+ c,
3296
+ ),
3297
+ )
3298
+ elif CONFIG_KEY_STREAM in config[CONF]:
3299
+ stream_writer = config[CONF][CONFIG_KEY_RUNTIME].stream_writer
3300
+ else:
3301
+
3302
+ def stream_writer(c: Any) -> None:
3303
+ pass
3304
+
3305
+ # set durability mode for subgraphs
3306
+ if durability is not None:
3307
+ config[CONF][CONFIG_KEY_DURABILITY] = durability_
3308
+
3309
+ # build server_info from metadata + parent runtime
3310
+ parent_runtime = _coerce_parent_runtime(
3311
+ config[CONF].get(CONFIG_KEY_RUNTIME, DEFAULT_RUNTIME)
3312
+ )
3313
+ server_info = _build_server_info(config, parent_runtime)
3314
+
3315
+ runtime = Runtime(
3316
+ context=_coerce_context(self.context_schema, context),
3317
+ store=store,
3318
+ stream_writer=stream_writer,
3319
+ previous=None,
3320
+ execution_info=None,
3321
+ server_info=server_info,
3322
+ control=control or parent_runtime.control or RunControl(),
3323
+ )
3324
+ runtime = parent_runtime.merge(runtime)
3325
+ config[CONF][CONFIG_KEY_RUNTIME] = runtime
3326
+
3327
+ # resolve mappers for v2 stream coercion
3328
+ _output_mapper = self._output_mapper if version == "v2" else None
3329
+ _state_mapper = self._state_mapper if version == "v2" else None
3330
+
3331
+ async def aemit_graph_lifecycle_events(loop: AsyncPregelLoop) -> None:
3332
+ while (event := loop._pop_lifecycle_event()) is not None:
3333
+ if isinstance(event, GraphResumeEvent):
3334
+ await graph_callback_manager.on_resume(
3335
+ GraphResumeEvent(
3336
+ run_id=graph_callback_manager.run_id,
3337
+ status=event.status,
3338
+ checkpoint_id=event.checkpoint_id,
3339
+ checkpoint_ns=event.checkpoint_ns,
3340
+ )
3341
+ )
3342
+ else:
3343
+ await graph_callback_manager.on_interrupt(
3344
+ GraphInterruptEvent(
3345
+ run_id=graph_callback_manager.run_id,
3346
+ status=event.status,
3347
+ checkpoint_id=event.checkpoint_id,
3348
+ checkpoint_ns=event.checkpoint_ns,
3349
+ interrupts=event.interrupts,
3350
+ )
3351
+ )
3352
+
3353
+ async with AsyncPregelLoop(
3354
+ input,
3355
+ stream=StreamProtocol(stream.put_nowait, stream_modes),
3356
+ config=config,
3357
+ store=store,
3358
+ cache=cache,
3359
+ checkpointer=checkpointer,
3360
+ nodes=self.nodes,
3361
+ specs=self.channels,
3362
+ output_keys=output_keys,
3363
+ input_keys=self.input_channels,
3364
+ stream_keys=self.stream_channels_asis,
3365
+ interrupt_before=interrupt_before_,
3366
+ interrupt_after=interrupt_after_,
3367
+ manager=run_manager,
3368
+ durability=durability_,
3369
+ trigger_to_nodes=self.trigger_to_nodes,
3370
+ migrate_checkpoint=self._migrate_checkpoint,
3371
+ retry_policy=self.retry_policy,
3372
+ cache_policy=self.cache_policy,
3373
+ has_graph_lifecycle_callbacks=bool(graph_callback_manager.handlers),
3374
+ ) as loop:
3375
+ await aemit_graph_lifecycle_events(loop)
3376
+ # create runner
3377
+ runner = PregelRunner(
3378
+ submit=config[CONF].get(
3379
+ CONFIG_KEY_RUNNER_SUBMIT, weakref.WeakMethod(loop.submit)
3380
+ ),
3381
+ put_writes=weakref.WeakMethod(loop.put_writes),
3382
+ use_astream=do_stream,
3383
+ node_finished=config[CONF].get(CONFIG_KEY_NODE_FINISHED),
3384
+ node_error_handler_map=self.node_error_handler_map,
3385
+ aschedule_error_handler=loop.aschedule_error_handler,
3386
+ )
3387
+ # enable subgraph streaming
3388
+ if subgraphs:
3389
+ loop.config[CONF][CONFIG_KEY_STREAM] = StreamProtocol(
3390
+ stream_put, stream_modes
3391
+ )
3392
+ # enable concurrent streaming
3393
+ get_waiter: Callable[[], asyncio.Task[None]] | None = None
3394
+ _cleanup_waiter: Callable[[], Awaitable[None]] | None = None
3395
+ if (
3396
+ self.stream_eager
3397
+ or subgraphs
3398
+ or "messages" in stream_modes
3399
+ or "custom" in stream_modes
3400
+ ):
3401
+ # Keep a single waiter task alive; ensure cleanup on exit.
3402
+ waiter: asyncio.Task[None] | None = None
3403
+
3404
+ def get_waiter() -> asyncio.Task[None]:
3405
+ nonlocal waiter
3406
+ if waiter is None or waiter.done():
3407
+ waiter = aioloop.create_task(stream.wait())
3408
+
3409
+ def _clear(t: asyncio.Task[None]) -> None:
3410
+ nonlocal waiter
3411
+ if waiter is t:
3412
+ waiter = None
3413
+
3414
+ waiter.add_done_callback(_clear)
3415
+ return waiter
3416
+
3417
+ async def _cleanup_waiter() -> None:
3418
+ """Wake pending waiter and/or cancel+await to avoid pending tasks."""
3419
+ nonlocal waiter
3420
+ # Try to wake via semaphore like SyncPregelLoop
3421
+ with contextlib.suppress(Exception):
3422
+ if hasattr(stream, "_count"):
3423
+ stream._count.release()
3424
+ t = waiter
3425
+ waiter = None
3426
+ if t is not None and not t.done():
3427
+ t.cancel()
3428
+ with contextlib.suppress(asyncio.CancelledError):
3429
+ await t
3430
+
3431
+ # Similarly to Bulk Synchronous Parallel / Pregel model
3432
+ # computation proceeds in steps, while there are channel updates
3433
+ # channel updates from step N are only visible in step N+1
3434
+ # channels are guaranteed to be immutable for the duration of the step,
3435
+ # with channel updates applied only at the transition between steps
3436
+ try:
3437
+ while loop.tick():
3438
+ for task in await loop.amatch_cached_writes():
3439
+ loop.output_writes(task.id, task.writes, cached=True)
3440
+ async for _ in runner.atick(
3441
+ [t for t in loop.tasks.values() if not t.writes],
3442
+ timeout=self.step_timeout,
3443
+ get_waiter=get_waiter,
3444
+ schedule_task=loop.aaccept_push,
3445
+ ):
3446
+ # emit output
3447
+ for o in _output(
3448
+ stream_mode,
3449
+ print_mode,
3450
+ subgraphs,
3451
+ stream.get_nowait,
3452
+ asyncio.QueueEmpty,
3453
+ version,
3454
+ _output_mapper,
3455
+ _state_mapper,
3456
+ ):
3457
+ yield o
3458
+ loop.after_tick()
3459
+ await aemit_graph_lifecycle_events(loop)
3460
+ # wait for checkpoint
3461
+ if durability_ == "sync":
3462
+ await cast(asyncio.Future, loop._put_checkpoint_fut)
3463
+ finally:
3464
+ # ensure waiter doesn't remain pending on cancel/shutdown
3465
+ if _cleanup_waiter is not None:
3466
+ await _cleanup_waiter()
3467
+
3468
+ await aemit_graph_lifecycle_events(loop)
3469
+
3470
+ # emit output
3471
+ for o in _output(
3472
+ stream_mode,
3473
+ print_mode,
3474
+ subgraphs,
3475
+ stream.get_nowait,
3476
+ asyncio.QueueEmpty,
3477
+ version,
3478
+ _output_mapper,
3479
+ _state_mapper,
3480
+ ):
3481
+ yield o
3482
+ # handle exit
3483
+ if loop.status == "out_of_steps":
3484
+ msg = create_error_message(
3485
+ message=(
3486
+ f"Recursion limit of {config['recursion_limit']} reached "
3487
+ "without hitting a stop condition. You can increase the "
3488
+ "limit by setting the `recursion_limit` config key."
3489
+ ),
3490
+ error_code=ErrorCode.GRAPH_RECURSION_LIMIT,
3491
+ )
3492
+ raise GraphRecursionError(msg)
3493
+ elif loop.status == "draining":
3494
+ if loop.control is None:
3495
+ raise RuntimeError("Draining status requires run control")
3496
+ raise GraphDrained(loop.control.drain_reason or "shutdown")
3497
+ # set final channel values as run output
3498
+ await run_manager.on_chain_end(loop.output)
3499
+ except BaseException as e:
3500
+ if run_manager is not None:
3501
+ await asyncio.shield(run_manager.on_chain_error(e))
3502
+ raise
3503
+
3504
+ @beta(message="The v3 streaming protocol on Pregel is experimental.")
3505
+ def _pregel_stream_v3(
3506
+ self,
3507
+ input: InputT | Command | None,
3508
+ config: RunnableConfig | None = None,
3509
+ *,
3510
+ interrupt_before: All | Sequence[str] | None = None,
3511
+ interrupt_after: All | Sequence[str] | None = None,
3512
+ control: RunControl | None = None,
3513
+ transformers: Sequence[Callable[[tuple[str, ...]], Any]] | None = None,
3514
+ **kwargs: Any,
3515
+ ) -> GraphRunStream:
3516
+ """Internal v3 sync streaming implementation. Public entry: stream_events(version='v3').
3517
+
3518
+ Extra keyword arguments are forwarded to the underlying ``stream(...)``
3519
+ call. The dispatcher in ``stream_events`` rejects ``stream_mode`` and
3520
+ ``subgraphs`` since v3 owns them (``stream_mode`` is derived from the
3521
+ transformer mux; ``subgraphs`` is always True so nested namespaces
3522
+ flow through scoped muxes).
3523
+
3524
+ !!! warning
3525
+
3526
+ The v3 streaming protocol is experimental and may change.
3527
+ """
3528
+ parent_ns = _resolve_parent_ns(self.config, config)
3529
+ compiled_factories = _normalize_stream_transformer_factories(
3530
+ self.stream_transformers
3531
+ )
3532
+ extra_factories = _normalize_stream_transformer_factories(transformers)
3533
+ mux = StreamMux(
3534
+ factories=[
3535
+ ValuesTransformer,
3536
+ MessagesTransformer,
3537
+ LifecycleTransformer,
3538
+ SubgraphTransformer,
3539
+ *compiled_factories,
3540
+ *extra_factories,
3541
+ ],
3542
+ scope=parent_ns,
3543
+ is_async=False,
3544
+ )
3545
+ graph_iter = iter(
3546
+ self.stream(
3547
+ input,
3548
+ patch_configurable(config, {CONFIG_KEY_STREAM_MESSAGES_V2: True}),
3549
+ stream_mode=_collect_stream_modes(mux),
3550
+ subgraphs=True,
3551
+ version="v2",
3552
+ interrupt_before=interrupt_before,
3553
+ interrupt_after=interrupt_after,
3554
+ control=control,
3555
+ **kwargs,
3556
+ )
3557
+ )
3558
+ return GraphRunStream(graph_iter, mux)
3559
+
3560
+ @beta(message="The v3 streaming protocol on Pregel is experimental.")
3561
+ async def _apregel_stream_v3(
3562
+ self,
3563
+ input: InputT | Command | None,
3564
+ config: RunnableConfig | None = None,
3565
+ *,
3566
+ interrupt_before: All | Sequence[str] | None = None,
3567
+ interrupt_after: All | Sequence[str] | None = None,
3568
+ control: RunControl | None = None,
3569
+ transformers: Sequence[Callable[[tuple[str, ...]], Any]] | None = None,
3570
+ **kwargs: Any,
3571
+ ) -> AsyncGraphRunStream:
3572
+ """Internal v3 async streaming implementation. Public entry: astream_events(version='v3').
3573
+
3574
+ Extra keyword arguments are forwarded to the underlying ``astream(...)``
3575
+ call. The dispatcher in ``astream_events`` rejects ``stream_mode`` and
3576
+ ``subgraphs`` since v3 owns them (``stream_mode`` is derived from the
3577
+ transformer mux; ``subgraphs`` is always True so nested namespaces
3578
+ flow through scoped muxes).
3579
+
3580
+ !!! warning
3581
+
3582
+ The v3 streaming protocol is experimental and may change.
3583
+ """
3584
+ parent_ns = _resolve_parent_ns(self.config, config)
3585
+ compiled_factories = _normalize_stream_transformer_factories(
3586
+ self.stream_transformers
3587
+ )
3588
+ extra_factories = _normalize_stream_transformer_factories(transformers)
3589
+ mux = StreamMux(
3590
+ factories=[
3591
+ ValuesTransformer,
3592
+ MessagesTransformer,
3593
+ LifecycleTransformer,
3594
+ SubgraphTransformer,
3595
+ *compiled_factories,
3596
+ *extra_factories,
3597
+ ],
3598
+ scope=parent_ns,
3599
+ is_async=True,
3600
+ )
3601
+ graph_aiter = self.astream(
3602
+ input,
3603
+ patch_configurable(config, {CONFIG_KEY_STREAM_MESSAGES_V2: True}),
3604
+ stream_mode=_collect_stream_modes(mux),
3605
+ subgraphs=True,
3606
+ version="v2",
3607
+ interrupt_before=interrupt_before,
3608
+ interrupt_after=interrupt_after,
3609
+ control=control,
3610
+ **kwargs,
3611
+ ).__aiter__()
3612
+ return AsyncGraphRunStream(graph_aiter, mux)
3613
+
3614
+ @overload
3615
+ def stream_events(
3616
+ self,
3617
+ input: InputT | Command | None,
3618
+ config: RunnableConfig | None = None,
3619
+ *,
3620
+ version: Literal["v1", "v2"] = "v2",
3621
+ **kwargs: Any,
3622
+ ) -> Iterator[StreamEvent]: ...
3623
+
3624
+ @overload
3625
+ def stream_events(
3626
+ self,
3627
+ input: InputT | Command | None,
3628
+ config: RunnableConfig | None = None,
3629
+ *,
3630
+ version: Literal["v3"],
3631
+ interrupt_before: All | Sequence[str] | None = None,
3632
+ interrupt_after: All | Sequence[str] | None = None,
3633
+ control: RunControl | None = None,
3634
+ transformers: Sequence[Callable[[tuple[str, ...]], Any]] | None = None,
3635
+ **kwargs: Any,
3636
+ ) -> GraphRunStream: ...
3637
+
3638
+ def stream_events(
3639
+ self,
3640
+ input: InputT | Command | None,
3641
+ config: RunnableConfig | None = None,
3642
+ *,
3643
+ version: Literal["v1", "v2", "v3"] = "v2",
3644
+ interrupt_before: All | Sequence[str] | None = None,
3645
+ interrupt_after: All | Sequence[str] | None = None,
3646
+ control: RunControl | None = None,
3647
+ transformers: Sequence[Callable[[tuple[str, ...]], Any]] | None = None,
3648
+ **kwargs: Any,
3649
+ ) -> Any:
3650
+ """Stream events from this graph.
3651
+
3652
+ For `version="v1"` / `"v2"`, yields `StreamEvent` dicts (see
3653
+ `Runnable.stream_events`). For `version="v3"`, returns a
3654
+ `GraphRunStream` whose typed projections the caller drives by
3655
+ iterating — no background thread.
3656
+
3657
+ !!! warning
3658
+
3659
+ The `version="v3"` API is experimental and may change.
3660
+
3661
+ Builds a `StreamMux` from the built-in transformers, this
3662
+ graph's compile-time `stream_transformers`, and any additional
3663
+ `transformers=` supplied at the call site. `run.output`,
3664
+ `run.interrupted`, and `run.interrupts` work regardless of
3665
+ which transformers are registered.
3666
+
3667
+ Note:
3668
+ Nesting v1 `stream(stream_mode="messages")` inside a node
3669
+ of a `stream_events(version="v3")` run is not fully
3670
+ supported. The outer v3 messages handler reroutes
3671
+ `BaseChatModel.invoke` through the v2 event protocol, so
3672
+ the inner v1 handler does not see `on_llm_new_token`
3673
+ chunks. The inner stream still yields a finalized message
3674
+ via `on_llm_end`. Use `stream_events(version="v3")` for the
3675
+ inner graph as well, or call `chat_model.stream(...)`
3676
+ explicitly, to get token-level streaming.
3677
+
3678
+ Args:
3679
+ input: Graph input.
3680
+ config: Optional runnable config.
3681
+ version: Streaming-event schema version. `"v3"` selects the
3682
+ content-block-centric streaming protocol.
3683
+ interrupt_before: Nodes to interrupt before, if any. Only
3684
+ used for `version="v3"`.
3685
+ interrupt_after: Nodes to interrupt after, if any. Only
3686
+ used for `version="v3"`.
3687
+ control: Optional run control used to request cooperative
3688
+ drain. Only used for `version="v3"`.
3689
+ transformers: Extra transformer classes or configured
3690
+ factories appended after compile-time
3691
+ `stream_transformers`. Factories are called as
3692
+ `factory(scope)` so they can propagate to subgraph
3693
+ scopes. Only used for `version="v3"`.
3694
+ **kwargs: For `version="v1"`/`"v2"`, forwarded to
3695
+ `Runnable.stream_events`. For `version="v3"`, forwarded
3696
+ to the underlying `stream(...)` call (e.g. `context`,
3697
+ `durability`, `output_keys`, `print_mode`, `debug`).
3698
+ `stream_mode` and `subgraphs` are not accepted under
3699
+ `version="v3"` and raise `TypeError` if supplied; v3
3700
+ owns them.
3701
+
3702
+ Returns:
3703
+ For `version="v3"`, a `GraphRunStream` the caller iterates
3704
+ to drive the run. Otherwise an `Iterator[StreamEvent]`.
3705
+ """
3706
+ if version == "v3":
3707
+ _reject_v3_invariant_kwargs(kwargs)
3708
+ return self._pregel_stream_v3(
3709
+ input,
3710
+ config,
3711
+ interrupt_before=interrupt_before,
3712
+ interrupt_after=interrupt_after,
3713
+ control=control,
3714
+ transformers=transformers,
3715
+ **kwargs,
3716
+ )
3717
+ return super().stream_events(input, config, version=version, **kwargs)
3718
+
3719
+ @overload
3720
+ def astream_events(
3721
+ self,
3722
+ input: InputT | Command | None,
3723
+ config: RunnableConfig | None = None,
3724
+ *,
3725
+ version: Literal["v1", "v2"] = "v2",
3726
+ **kwargs: Any,
3727
+ ) -> AsyncIterator[StreamEvent]: ...
3728
+
3729
+ @overload
3730
+ def astream_events(
3731
+ self,
3732
+ input: InputT | Command | None,
3733
+ config: RunnableConfig | None = None,
3734
+ *,
3735
+ version: Literal["v3"],
3736
+ interrupt_before: All | Sequence[str] | None = None,
3737
+ interrupt_after: All | Sequence[str] | None = None,
3738
+ control: RunControl | None = None,
3739
+ transformers: Sequence[Callable[[tuple[str, ...]], Any]] | None = None,
3740
+ **kwargs: Any,
3741
+ ) -> Awaitable[AsyncGraphRunStream]: ...
3742
+
3743
+ def astream_events(
3744
+ self,
3745
+ input: InputT | Command | None,
3746
+ config: RunnableConfig | None = None,
3747
+ *,
3748
+ version: Literal["v1", "v2", "v3"] = "v2",
3749
+ interrupt_before: All | Sequence[str] | None = None,
3750
+ interrupt_after: All | Sequence[str] | None = None,
3751
+ control: RunControl | None = None,
3752
+ transformers: Sequence[Callable[[tuple[str, ...]], Any]] | None = None,
3753
+ **kwargs: Any,
3754
+ ) -> AsyncIterator[StreamEvent] | Awaitable[Any]:
3755
+ """Async variant of `stream_events`.
3756
+
3757
+ For `version="v3"`, returns an `AsyncGraphRunStream` whose
3758
+ projections can be awaited concurrently; each subscribed cursor
3759
+ drives the pump when its buffer is empty. The same nesting
3760
+ limitation as the sync path applies — see `stream_events` for
3761
+ details.
3762
+
3763
+ !!! warning
3764
+
3765
+ The `version="v3"` API is experimental and may change.
3766
+
3767
+ See `stream_events` for full argument and return documentation.
3768
+ """
3769
+ if version == "v3":
3770
+ _reject_v3_invariant_kwargs(kwargs)
3771
+ return self._apregel_stream_v3(
3772
+ input,
3773
+ config,
3774
+ interrupt_before=interrupt_before,
3775
+ interrupt_after=interrupt_after,
3776
+ control=control,
3777
+ transformers=transformers,
3778
+ **kwargs,
3779
+ )
3780
+ return super().astream_events(input, config, version=version, **kwargs)
3781
+
3782
+ @overload
3783
+ def invoke(
3784
+ self,
3785
+ input: InputT | Command | None,
3786
+ config: RunnableConfig | None = None,
3787
+ *,
3788
+ context: ContextT | None = None,
3789
+ stream_mode: Literal["values"] = ...,
3790
+ print_mode: StreamMode | Sequence[StreamMode] = (),
3791
+ output_keys: str | Sequence[str] | None = None,
3792
+ interrupt_before: All | Sequence[str] | None = None,
3793
+ interrupt_after: All | Sequence[str] | None = None,
3794
+ durability: Durability | None = None,
3795
+ control: RunControl | None = None,
3796
+ version: Literal["v2"],
3797
+ **kwargs: Any,
3798
+ ) -> GraphOutput[OutputT]: ...
3799
+
3800
+ @overload
3801
+ def invoke(
3802
+ self,
3803
+ input: InputT | Command | None,
3804
+ config: RunnableConfig | None = None,
3805
+ *,
3806
+ context: ContextT | None = None,
3807
+ stream_mode: StreamMode,
3808
+ print_mode: StreamMode | Sequence[StreamMode] = (),
3809
+ output_keys: str | Sequence[str] | None = None,
3810
+ interrupt_before: All | Sequence[str] | None = None,
3811
+ interrupt_after: All | Sequence[str] | None = None,
3812
+ durability: Durability | None = None,
3813
+ control: RunControl | None = None,
3814
+ version: Literal["v2"],
3815
+ **kwargs: Any,
3816
+ ) -> list[StreamPart[StateT, OutputT]]: ...
3817
+
3818
+ @overload
3819
+ def invoke(
3820
+ self,
3821
+ input: InputT | Command | None,
3822
+ config: RunnableConfig | None = None,
3823
+ *,
3824
+ context: ContextT | None = None,
3825
+ stream_mode: StreamMode = "values",
3826
+ print_mode: StreamMode | Sequence[StreamMode] = (),
3827
+ output_keys: str | Sequence[str] | None = None,
3828
+ interrupt_before: All | Sequence[str] | None = None,
3829
+ interrupt_after: All | Sequence[str] | None = None,
3830
+ durability: Durability | None = None,
3831
+ control: RunControl | None = None,
3832
+ version: Literal["v1"] = ...,
3833
+ **kwargs: Any,
3834
+ ) -> dict[str, Any] | Any: ...
3835
+
3836
+ def invoke(
3837
+ self,
3838
+ input: InputT | Command | None,
3839
+ config: RunnableConfig | None = None,
3840
+ *,
3841
+ context: ContextT | None = None,
3842
+ stream_mode: StreamMode = "values",
3843
+ print_mode: StreamMode | Sequence[StreamMode] = (),
3844
+ output_keys: str | Sequence[str] | None = None,
3845
+ interrupt_before: All | Sequence[str] | None = None,
3846
+ interrupt_after: All | Sequence[str] | None = None,
3847
+ durability: Durability | None = None,
3848
+ control: RunControl | None = None,
3849
+ version: Literal["v1", "v2"] = "v1",
3850
+ **kwargs: Any,
3851
+ ) -> dict[str, Any] | Any:
3852
+ """Run the graph with a single input and config.
3853
+
3854
+ Args:
3855
+ input: The input data for the graph. It can be a dictionary or any other type.
3856
+ config: The configuration for the graph run.
3857
+ context: The static context to use for the run.
3858
+ !!! version-added "Added in version 0.6.0"
3859
+ stream_mode: The stream mode for the graph run.
3860
+ print_mode: Accepts the same values as `stream_mode`, but only prints the output to the console, for debugging purposes.
3861
+
3862
+ Does not affect the output of the graph in any way.
3863
+ output_keys: The output keys to retrieve from the graph run.
3864
+ interrupt_before: The nodes to interrupt the graph run before.
3865
+ interrupt_after: The nodes to interrupt the graph run after.
3866
+ durability: The durability mode for the graph execution, defaults to `"async"`.
3867
+
3868
+ Options are:
3869
+
3870
+ - `"sync"`: Changes are persisted synchronously before the next step starts.
3871
+ - `"async"`: Changes are persisted asynchronously while the next step executes.
3872
+ - `"exit"`: Changes are persisted only when the graph exits.
3873
+ control: Optional run control used to request cooperative drain.
3874
+ version: The streaming format version. `"v1"` (default) returns the
3875
+ traditional format, `"v2"` returns `StreamPart` typed dicts when
3876
+ `stream_mode` is not `"values"`.
3877
+ **kwargs: Additional keyword arguments to pass to the graph run.
3878
+
3879
+ Returns:
3880
+ The output of the graph run. If `stream_mode` is `"values"`, it returns the latest output.
3881
+ If `stream_mode` is not `"values"`, it returns a list of output chunks.
3882
+ """
3883
+ output_keys = output_keys if output_keys is not None else self.output_channels
3884
+
3885
+ latest: dict[str, Any] | Any = None
3886
+ chunks: list[dict[str, Any] | Any] = []
3887
+ interrupts: list[Interrupt] = []
3888
+
3889
+ if version == "v2":
3890
+ # v2: values stream parts carry interrupts directly
3891
+ for chunk in self.stream(
3892
+ input,
3893
+ config,
3894
+ context=context,
3895
+ stream_mode="values" if stream_mode == "values" else stream_mode,
3896
+ print_mode=print_mode,
3897
+ output_keys=output_keys,
3898
+ interrupt_before=interrupt_before,
3899
+ interrupt_after=interrupt_after,
3900
+ durability=durability,
3901
+ control=control,
3902
+ version=version,
3903
+ **kwargs,
3904
+ ):
3905
+ if stream_mode == "values":
3906
+ latest = chunk["data"]
3907
+ if chunk_ints := chunk.get("interrupts", ()):
3908
+ interrupts.extend(chunk_ints) # type: ignore[arg-type]
3909
+ else:
3910
+ chunks.append(chunk)
3911
+ else:
3912
+ # v1: collect interrupts from updates stream
3913
+ for chunk in self.stream(
3914
+ input,
3915
+ config,
3916
+ context=context,
3917
+ stream_mode=(
3918
+ ["updates", "values"] if stream_mode == "values" else stream_mode
3919
+ ),
3920
+ print_mode=print_mode,
3921
+ output_keys=output_keys,
3922
+ interrupt_before=interrupt_before,
3923
+ interrupt_after=interrupt_after,
3924
+ durability=durability,
3925
+ control=control,
3926
+ **kwargs,
3927
+ ):
3928
+ if stream_mode == "values":
3929
+ if len(chunk) == 2:
3930
+ mode, payload = cast(tuple[StreamMode, Any], chunk)
3931
+ else:
3932
+ _, mode, payload = cast(
3933
+ tuple[tuple[str, ...], StreamMode, Any], chunk
3934
+ )
3935
+ if (
3936
+ mode == "updates"
3937
+ and isinstance(payload, dict)
3938
+ and (ints := payload.get(INTERRUPT)) is not None
3939
+ ):
3940
+ interrupts.extend(ints)
3941
+ elif mode == "values":
3942
+ latest = payload
3943
+ else:
3944
+ chunks.append(chunk)
3945
+
3946
+ if stream_mode == "values":
3947
+ if version == "v2":
3948
+ return GraphOutput(value=latest, interrupts=tuple(interrupts))
3949
+ if interrupts:
3950
+ return (
3951
+ {**latest, INTERRUPT: interrupts}
3952
+ if isinstance(latest, dict)
3953
+ else {INTERRUPT: interrupts}
3954
+ )
3955
+ return latest
3956
+ else:
3957
+ return chunks
3958
+
3959
+ @overload
3960
+ async def ainvoke(
3961
+ self,
3962
+ input: InputT | Command | None,
3963
+ config: RunnableConfig | None = None,
3964
+ *,
3965
+ context: ContextT | None = None,
3966
+ stream_mode: Literal["values"] = ...,
3967
+ print_mode: StreamMode | Sequence[StreamMode] = (),
3968
+ output_keys: str | Sequence[str] | None = None,
3969
+ interrupt_before: All | Sequence[str] | None = None,
3970
+ interrupt_after: All | Sequence[str] | None = None,
3971
+ durability: Durability | None = None,
3972
+ control: RunControl | None = None,
3973
+ version: Literal["v2"],
3974
+ **kwargs: Any,
3975
+ ) -> GraphOutput[OutputT]: ...
3976
+
3977
+ @overload
3978
+ async def ainvoke(
3979
+ self,
3980
+ input: InputT | Command | None,
3981
+ config: RunnableConfig | None = None,
3982
+ *,
3983
+ context: ContextT | None = None,
3984
+ stream_mode: StreamMode,
3985
+ print_mode: StreamMode | Sequence[StreamMode] = (),
3986
+ output_keys: str | Sequence[str] | None = None,
3987
+ interrupt_before: All | Sequence[str] | None = None,
3988
+ interrupt_after: All | Sequence[str] | None = None,
3989
+ durability: Durability | None = None,
3990
+ control: RunControl | None = None,
3991
+ version: Literal["v2"],
3992
+ **kwargs: Any,
3993
+ ) -> list[StreamPart[StateT, OutputT]]: ...
3994
+
3995
+ @overload
3996
+ async def ainvoke(
3997
+ self,
3998
+ input: InputT | Command | None,
3999
+ config: RunnableConfig | None = None,
4000
+ *,
4001
+ context: ContextT | None = None,
4002
+ stream_mode: StreamMode = "values",
4003
+ print_mode: StreamMode | Sequence[StreamMode] = (),
4004
+ output_keys: str | Sequence[str] | None = None,
4005
+ interrupt_before: All | Sequence[str] | None = None,
4006
+ interrupt_after: All | Sequence[str] | None = None,
4007
+ durability: Durability | None = None,
4008
+ control: RunControl | None = None,
4009
+ version: Literal["v1"] = ...,
4010
+ **kwargs: Any,
4011
+ ) -> dict[str, Any] | Any: ...
4012
+
4013
+ async def ainvoke(
4014
+ self,
4015
+ input: InputT | Command | None,
4016
+ config: RunnableConfig | None = None,
4017
+ *,
4018
+ context: ContextT | None = None,
4019
+ stream_mode: StreamMode = "values",
4020
+ print_mode: StreamMode | Sequence[StreamMode] = (),
4021
+ output_keys: str | Sequence[str] | None = None,
4022
+ interrupt_before: All | Sequence[str] | None = None,
4023
+ interrupt_after: All | Sequence[str] | None = None,
4024
+ durability: Durability | None = None,
4025
+ control: RunControl | None = None,
4026
+ version: Literal["v1", "v2"] = "v1",
4027
+ **kwargs: Any,
4028
+ ) -> dict[str, Any] | Any:
4029
+ """Asynchronously run the graph with a single input and config.
4030
+
4031
+ Args:
4032
+ input: The input data for the graph. It can be a dictionary or any other type.
4033
+ config: The configuration for the graph run.
4034
+ context: The static context to use for the run.
4035
+ !!! version-added "Added in version 0.6.0"
4036
+ stream_mode: The stream mode for the graph run.
4037
+ print_mode: Accepts the same values as `stream_mode`, but only prints the output to the console, for debugging purposes.
4038
+
4039
+ Does not affect the output of the graph in any way.
4040
+ output_keys: The output keys to retrieve from the graph run.
4041
+ interrupt_before: The nodes to interrupt the graph run before.
4042
+ interrupt_after: The nodes to interrupt the graph run after.
4043
+ durability: The durability mode for the graph execution, defaults to `"async"`.
4044
+
4045
+ Options are:
4046
+
4047
+ - `"sync"`: Changes are persisted synchronously before the next step starts.
4048
+ - `"async"`: Changes are persisted asynchronously while the next step executes.
4049
+ - `"exit"`: Changes are persisted only when the graph exits.
4050
+ control: Optional run control used to request cooperative drain.
4051
+ version: The streaming format version. `"v1"` (default) returns the
4052
+ traditional format, `"v2"` returns `StreamPart` typed dicts when
4053
+ `stream_mode` is not `"values"`.
4054
+ **kwargs: Additional keyword arguments to pass to the graph run.
4055
+
4056
+ Returns:
4057
+ The output of the graph run. If `stream_mode` is `"values"`, it returns the latest output.
4058
+ If `stream_mode` is not `"values"`, it returns a list of output chunks.
4059
+ """
4060
+ output_keys = output_keys if output_keys is not None else self.output_channels
4061
+
4062
+ latest: dict[str, Any] | Any = None
4063
+ chunks: list[dict[str, Any] | Any] = []
4064
+ interrupts: list[Interrupt] = []
4065
+
4066
+ if version == "v2":
4067
+ # v2: values stream parts carry interrupts directly
4068
+ async for chunk in self.astream(
4069
+ input,
4070
+ config,
4071
+ context=context,
4072
+ stream_mode="values" if stream_mode == "values" else stream_mode,
4073
+ print_mode=print_mode,
4074
+ output_keys=output_keys,
4075
+ interrupt_before=interrupt_before,
4076
+ interrupt_after=interrupt_after,
4077
+ durability=durability,
4078
+ control=control,
4079
+ version=version,
4080
+ **kwargs,
4081
+ ):
4082
+ if stream_mode == "values":
4083
+ latest = chunk["data"]
4084
+ if chunk_ints := chunk.get("interrupts", ()):
4085
+ interrupts.extend(chunk_ints) # type: ignore[arg-type]
4086
+ else:
4087
+ chunks.append(chunk)
4088
+ else:
4089
+ # v1: collect interrupts from updates stream
4090
+ async for chunk in self.astream(
4091
+ input,
4092
+ config,
4093
+ context=context,
4094
+ stream_mode=(
4095
+ ["updates", "values"] if stream_mode == "values" else stream_mode
4096
+ ),
4097
+ print_mode=print_mode,
4098
+ output_keys=output_keys,
4099
+ interrupt_before=interrupt_before,
4100
+ interrupt_after=interrupt_after,
4101
+ durability=durability,
4102
+ control=control,
4103
+ **kwargs,
4104
+ ):
4105
+ if stream_mode == "values":
4106
+ if len(chunk) == 2:
4107
+ mode, payload = cast(tuple[StreamMode, Any], chunk)
4108
+ else:
4109
+ _, mode, payload = cast(
4110
+ tuple[tuple[str, ...], StreamMode, Any], chunk
4111
+ )
4112
+ if (
4113
+ mode == "updates"
4114
+ and isinstance(payload, dict)
4115
+ and (ints := payload.get(INTERRUPT)) is not None
4116
+ ):
4117
+ interrupts.extend(ints)
4118
+ elif mode == "values":
4119
+ latest = payload
4120
+ else:
4121
+ chunks.append(chunk)
4122
+
4123
+ if stream_mode == "values":
4124
+ if version == "v2":
4125
+ return GraphOutput(value=latest, interrupts=tuple(interrupts))
4126
+ if interrupts:
4127
+ return (
4128
+ {**latest, INTERRUPT: interrupts}
4129
+ if isinstance(latest, dict)
4130
+ else {INTERRUPT: interrupts}
4131
+ )
4132
+ return latest
4133
+ else:
4134
+ return chunks
4135
+
4136
+ def clear_cache(self, nodes: Sequence[str] | None = None) -> None:
4137
+ """Clear the cache for the given nodes."""
4138
+ if not self.cache:
4139
+ raise ValueError("No cache is set for this graph. Cannot clear cache.")
4140
+ nodes = nodes or self.nodes.keys()
4141
+ # collect namespaces to clear
4142
+ namespaces: list[tuple[str, ...]] = []
4143
+ for node in nodes:
4144
+ if node in self.nodes:
4145
+ namespaces.append(
4146
+ (
4147
+ CACHE_NS_WRITES,
4148
+ (identifier(self.nodes[node]) or "__dynamic__"),
4149
+ node,
4150
+ ),
4151
+ )
4152
+ # clear cache
4153
+ self.cache.clear(namespaces)
4154
+
4155
+ async def aclear_cache(self, nodes: Sequence[str] | None = None) -> None:
4156
+ """Asynchronously clear the cache for the given nodes."""
4157
+ if not self.cache:
4158
+ raise ValueError("No cache is set for this graph. Cannot clear cache.")
4159
+ nodes = nodes or self.nodes.keys()
4160
+ # collect namespaces to clear
4161
+ namespaces: list[tuple[str, ...]] = []
4162
+ for node in nodes:
4163
+ if node in self.nodes:
4164
+ namespaces.append(
4165
+ (
4166
+ CACHE_NS_WRITES,
4167
+ (identifier(self.nodes[node]) or "__dynamic__"),
4168
+ node,
4169
+ ),
4170
+ )
4171
+ # clear cache
4172
+ await self.cache.aclear(namespaces)
4173
+
4174
+
4175
+ def _trigger_to_nodes(nodes: dict[str, PregelNode]) -> Mapping[str, Sequence[str]]:
4176
+ """Index from a trigger to nodes that depend on it."""
4177
+ trigger_to_nodes: defaultdict[str, list[str]] = defaultdict(list)
4178
+ for name, node in nodes.items():
4179
+ for trigger in node.triggers:
4180
+ trigger_to_nodes[trigger].append(name)
4181
+ return dict(trigger_to_nodes)
4182
+
4183
+
4184
+ def _output(
4185
+ stream_mode: StreamMode | Sequence[StreamMode],
4186
+ print_mode: StreamMode | Sequence[StreamMode],
4187
+ stream_subgraphs: bool,
4188
+ getter: Callable[[], tuple[tuple[str, ...], str, Any]],
4189
+ empty_exc: type[Exception],
4190
+ version: Literal["v1", "v2"] = "v1",
4191
+ output_mapper: Callable[[Any], Any] | None = None,
4192
+ state_mapper: Callable[[Any], Any] | None = None,
4193
+ ) -> Iterator:
4194
+ while True:
4195
+ try:
4196
+ ns, mode, payload = getter()
4197
+ except empty_exc:
4198
+ break
4199
+ if mode in print_mode:
4200
+ if stream_subgraphs and ns:
4201
+ print(
4202
+ " ".join(
4203
+ (
4204
+ get_bolded_text(f"[{mode}]"),
4205
+ get_colored_text(f"[graph={ns}]", color="yellow"),
4206
+ repr(payload),
4207
+ )
4208
+ )
4209
+ )
4210
+ else:
4211
+ print(
4212
+ " ".join(
4213
+ (
4214
+ get_bolded_text(f"[{mode}]"),
4215
+ repr(payload),
4216
+ )
4217
+ )
4218
+ )
4219
+ if mode in stream_mode:
4220
+ if version == "v2":
4221
+ if mode == "values":
4222
+ # pop __interrupt__ into typed field, coerce data
4223
+ ints: tuple[Interrupt, ...] = ()
4224
+ if isinstance(payload, dict):
4225
+ ints = payload.pop(INTERRUPT, ())
4226
+ if output_mapper:
4227
+ payload = output_mapper(payload)
4228
+ yield {"type": mode, "ns": ns, "data": payload, "interrupts": ints}
4229
+ elif mode in ("checkpoints", "debug"):
4230
+ # coerce state values in checkpoint/debug payloads
4231
+ if state_mapper:
4232
+ _coerce_checkpoint_values(payload, state_mapper)
4233
+ yield {"type": mode, "ns": ns, "data": payload}
4234
+ else:
4235
+ yield {"type": mode, "ns": ns, "data": payload}
4236
+ elif stream_subgraphs and isinstance(stream_mode, list):
4237
+ yield (ns, mode, payload)
4238
+ elif isinstance(stream_mode, list):
4239
+ yield (mode, payload)
4240
+ elif stream_subgraphs:
4241
+ yield (ns, payload)
4242
+ else:
4243
+ yield payload
4244
+
4245
+
4246
+ def _coerce_checkpoint_values(payload: Any, mapper: Callable[[Any], Any]) -> None:
4247
+ """Coerce `values` dicts inside checkpoint or debug payloads in-place.
4248
+
4249
+ Skips the initial checkpoint (where next contains ``__start__``) because
4250
+ not all channels are populated yet and coercion would fail.
4251
+ """
4252
+ _START = "__start__"
4253
+ # debug wrapper: {"type": "checkpoint", "payload": {"values": dict, ...}}
4254
+ if (
4255
+ isinstance(payload, dict)
4256
+ and payload.get("type") == "checkpoint"
4257
+ and isinstance(payload.get("payload"), dict)
4258
+ and isinstance(payload["payload"].get("values"), dict)
4259
+ and _START not in payload["payload"].get("next", ())
4260
+ ):
4261
+ payload["payload"]["values"] = mapper(payload["payload"]["values"])
4262
+ # direct checkpoint payload: {"values": dict, ...}
4263
+ elif (
4264
+ isinstance(payload, dict)
4265
+ and isinstance(payload.get("values"), dict)
4266
+ and _START not in payload.get("next", ())
4267
+ ):
4268
+ payload["values"] = mapper(payload["values"])
4269
+
4270
+
4271
+ def _resolve_parent_ns(
4272
+ graph_config: RunnableConfig | None, call_config: RunnableConfig | None
4273
+ ) -> tuple[str, ...]:
4274
+ """Return the checkpoint namespace the caller is running under.
4275
+
4276
+ `stream_events(version="v3")` uses this to scope its native projections
4277
+ (`ValuesTransformer`, `MessagesTransformer`) to events emitted at
4278
+ the run's own level. A root call resolves to `()`; a call made
4279
+ from inside a node carries the outer graph's task namespace so the
4280
+ projection still matches its own root-level events.
4281
+ """
4282
+ merged = ensure_config(graph_config, call_config)
4283
+ ns = merged.get(CONF, {}).get(CONFIG_KEY_CHECKPOINT_NS)
4284
+ if not ns:
4285
+ return ()
4286
+ return tuple(ns.split(NS_SEP))
4287
+
4288
+
4289
+ def _coerce_parent_runtime(value: Any) -> Runtime[Any]:
4290
+ """Normalize the value stored under `CONFIG_KEY_RUNTIME` into a `Runtime`.
4291
+
4292
+ During a graph run this is always a `Runtime` the framework created and
4293
+ published for child tasks to inherit. Layers outside the run (for example a
4294
+ server's graph-factory path) may instead seed an object that only carries
4295
+ `context`/`store`. Adopt its `context` so context set at the graph level
4296
+ plumbs through (`merge` lets the run's own `context` take precedence when
4297
+ one is provided). `store` is resolved separately (passed to the graph
4298
+ directly), so it is not read off here. `merge` then combines this with the
4299
+ run's own runtime, including the framework's `control`.
4300
+ """
4301
+ if isinstance(value, Runtime):
4302
+ return value
4303
+ return Runtime(context=getattr(value, "context", None))
4304
+
4305
+
4306
+ def _build_server_info(
4307
+ config: RunnableConfig, parent_runtime: Runtime[Any]
4308
+ ) -> ServerInfo | None:
4309
+ """Build ServerInfo from config configurable.
4310
+
4311
+ The server puts assistant_id/graph_id in config configurable and the
4312
+ authenticated user dict in configurable["coco_graph_auth_user"].
4313
+ """
4314
+ configurable = config.get(CONF) or {}
4315
+ assistant_id = configurable.get("assistant_id")
4316
+ graph_id = configurable.get("graph_id")
4317
+
4318
+ # Read authenticated user from configurable (set by CocoGraph Server).
4319
+ # We prefer isinstance(BaseUser) but fall back to hasattr("identity")
4320
+ # because the server's ProxyUser provides `permissions` via __getattr__,
4321
+ # which Python's runtime_checkable Protocol check doesn't see.
4322
+ auth_user_data = configurable.get("coco_graph_auth_user")
4323
+ user: BaseUser | None = None
4324
+ if auth_user_data is not None:
4325
+ if isinstance(auth_user_data, BaseUser) or hasattr(auth_user_data, "identity"):
4326
+ user = cast(BaseUser, auth_user_data)
4327
+
4328
+ if assistant_id is not None or graph_id is not None or user is not None:
4329
+ return ServerInfo(
4330
+ assistant_id=str(assistant_id) if assistant_id else "",
4331
+ graph_id=str(graph_id) if graph_id else "",
4332
+ user=user,
4333
+ )
4334
+ return None
4335
+
4336
+
4337
+ def _coerce_context(
4338
+ context_schema: type[ContextT] | None, context: Any
4339
+ ) -> ContextT | None:
4340
+ """Coerce context input to the appropriate schema type.
4341
+
4342
+ If context is a dict and context_schema is a dataclass or pydantic model, we coerce.
4343
+ Else, we return the context as-is.
4344
+
4345
+ Args:
4346
+ context_schema: The schema type to coerce to (BaseModel, dataclass, or TypedDict)
4347
+ context: The context value to coerce
4348
+
4349
+ Returns:
4350
+ The coerced context value or None if context is None
4351
+ """
4352
+ if context is None:
4353
+ return None
4354
+
4355
+ if context_schema is None:
4356
+ return context
4357
+
4358
+ schema_is_class = issubclass(context_schema, BaseModel) or is_dataclass(
4359
+ context_schema
4360
+ )
4361
+ if isinstance(context, dict) and schema_is_class:
4362
+ return context_schema(**context) # type: ignore[misc]
4363
+
4364
+ return cast(ContextT, context)