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,854 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ import random
6
+ import sys
7
+ import threading
8
+ import time
9
+ import weakref
10
+ from collections.abc import Awaitable, Callable, Sequence
11
+ from contextlib import suppress
12
+ from dataclasses import dataclass, replace
13
+ from datetime import datetime, timedelta, timezone
14
+ from typing import Any, Literal, NamedTuple
15
+
16
+ from coco_core.callbacks import BaseCallbackHandler
17
+ from coco_core.runnables import RunnableConfig
18
+
19
+ from coco_graph._internal._config import (
20
+ merge_configs,
21
+ patch_configurable,
22
+ recast_checkpoint_ns,
23
+ )
24
+ from coco_graph._internal._constants import (
25
+ CONF,
26
+ CONFIG_KEY_CALL,
27
+ CONFIG_KEY_CHECKPOINT_ID,
28
+ CONFIG_KEY_CHECKPOINT_NS,
29
+ CONFIG_KEY_RESUMING,
30
+ CONFIG_KEY_RUNTIME,
31
+ CONFIG_KEY_SEND,
32
+ CONFIG_KEY_STREAM,
33
+ CONFIG_KEY_TASK_ID,
34
+ CONFIG_KEY_THREAD_ID,
35
+ CONFIG_KEY_TIMED_ATTEMPT_OBSERVER,
36
+ NS_SEP,
37
+ )
38
+ from coco_graph._internal._runnable import create_task_in_config_context
39
+ from coco_graph._internal._timeout import sync_timeout_unsupported
40
+ from coco_graph.errors import (
41
+ GraphBubbleUp,
42
+ NodeCancelledError,
43
+ NodeTimeoutError,
44
+ ParentCommand,
45
+ )
46
+ from coco_graph.pregel.protocol import StreamProtocol
47
+ from coco_graph.runtime import ExecutionInfo, Runtime
48
+ from coco_graph.types import Command, PregelExecutableTask, RetryPolicy, TimeoutPolicy
49
+
50
+ logger = logging.getLogger(__name__)
51
+ SUPPORTS_EXC_NOTES = sys.version_info >= (3, 11)
52
+ # `asyncio.Task.cancelling()` was added in Python 3.11. It reports the number of
53
+ # pending cancel requests on the task: ``0`` means no external code asked us to
54
+ # cancel — so a ``CancelledError`` observed here was raised by the task body
55
+ # itself (the user's node) rather than by pregel cancelling sibling tasks.
56
+ SUPPORTS_TASK_CANCELLING = sys.version_info >= (3, 11)
57
+
58
+
59
+ def _timeout_secs(value: float | timedelta) -> float:
60
+ return value.total_seconds() if isinstance(value, timedelta) else value
61
+
62
+
63
+ @dataclass(frozen=True, slots=True)
64
+ class _ResolvedTimeout:
65
+ run_timeout_secs: float | None
66
+ idle_timeout_secs: float | None
67
+ refresh_on: Literal["auto", "heartbeat"] | None
68
+
69
+
70
+ def _resolve_timeout(timeout: TimeoutPolicy) -> _ResolvedTimeout:
71
+ idle_timeout_secs = (
72
+ _timeout_secs(timeout.idle_timeout)
73
+ if timeout.idle_timeout is not None
74
+ else None
75
+ )
76
+ return _ResolvedTimeout(
77
+ run_timeout_secs=(
78
+ _timeout_secs(timeout.run_timeout)
79
+ if timeout.run_timeout is not None
80
+ else None
81
+ ),
82
+ idle_timeout_secs=idle_timeout_secs,
83
+ refresh_on=timeout.refresh_on if idle_timeout_secs is not None else None,
84
+ )
85
+
86
+
87
+ class _AttemptContext(NamedTuple):
88
+ """Immutable per-attempt metadata shared across start/progress/finish events.
89
+
90
+ Built once at attempt start and referenced (not copied) by every emitted
91
+ `_AttemptEvent`, so per-event allocation is just the small event wrapper.
92
+
93
+ Intentionally underscore-prefixed: this and `_AttemptEvent` are part of an
94
+ internal observer contract consumed by trustcoco-graph-server. Do not move to
95
+ `coco_graph.types` — server imports them by this path.
96
+ """
97
+
98
+ task_id: str
99
+ task_name: str
100
+ attempt: int
101
+ run_id: str | None
102
+ thread_id: str | None
103
+ checkpoint_ns: str | None
104
+ started_at: datetime
105
+ run_timeout_secs: float | None
106
+ idle_timeout_secs: float | None
107
+ refresh_on: Literal["auto", "heartbeat"] | None
108
+
109
+
110
+ @dataclass(frozen=True, slots=True)
111
+ class _AttemptEvent:
112
+ """One lifecycle event for a timed attempt.
113
+
114
+ Holds a reference to the shared `_AttemptContext` and the event-specific
115
+ fields. The observer must treat this and `context` as read-only — they
116
+ are reused across all events for the same attempt.
117
+ """
118
+
119
+ context: _AttemptContext
120
+ event: Literal["start", "progress", "finish"]
121
+ progress_at: datetime | None = None
122
+ finished_at: datetime | None = None
123
+ status: Literal["success", "error"] | None = None
124
+ error_type: str | None = None
125
+ error_message: str | None = None
126
+
127
+
128
+ class _TimedAttemptScope:
129
+ """Guarded-config window for timed attempts.
130
+
131
+ The wrapped config marks writes, stream events, runtime stream writer calls,
132
+ child task scheduling, and any Coco callback event emitted under the
133
+ node's run as observable progress when `refresh_on="auto"`.
134
+ `runtime.heartbeat()` exposes a manual progress signal for work that doesn't
135
+ otherwise emit any of these, and is the only progress signal when
136
+ `refresh_on="heartbeat"`.
137
+ Guarded writes are serialized with `close()` so cancelled background tasks
138
+ cannot persist writes past the timeout boundary. Stream/custom output is
139
+ best-effort: it is dropped after close is observed, but callbacks run outside
140
+ the lock because they may contain arbitrary user/runtime code.
141
+ """
142
+
143
+ __slots__ = (
144
+ "__weakref__",
145
+ "_active",
146
+ "_last_progress",
147
+ "_last_progress_emit",
148
+ "_lock",
149
+ "_on_progress",
150
+ "_progress_min_interval",
151
+ "_refresh_on",
152
+ )
153
+
154
+ def __init__(
155
+ self,
156
+ on_progress: Callable[[], None] | None = None,
157
+ progress_min_interval: float = 0.0,
158
+ refresh_on: Literal["auto", "heartbeat"] | None = None,
159
+ ) -> None:
160
+ self._active = True
161
+ self._last_progress = time.monotonic()
162
+ self._lock = threading.Lock()
163
+ self._on_progress = on_progress
164
+ self._progress_min_interval = progress_min_interval
165
+ self._refresh_on = refresh_on
166
+ # `-inf` so the first touch always passes the rate-limit gate.
167
+ self._last_progress_emit: float = float("-inf")
168
+
169
+ def wrap_config(self, config: RunnableConfig) -> RunnableConfig:
170
+ configurable = config.get(CONF, {})
171
+ patch: dict[str, Any] = {}
172
+ if (send := configurable.get(CONFIG_KEY_SEND)) is not None:
173
+ patch[CONFIG_KEY_SEND] = self._guard_send(send)
174
+ if (stream := configurable.get(CONFIG_KEY_STREAM)) is not None:
175
+ patch[CONFIG_KEY_STREAM] = self._guard_stream(stream)
176
+ if (call := configurable.get(CONFIG_KEY_CALL)) is not None:
177
+ patch[CONFIG_KEY_CALL] = self._guard_call(call)
178
+ if isinstance(runtime := configurable.get(CONFIG_KEY_RUNTIME), Runtime):
179
+ if self._refresh_on is not None:
180
+ patch[CONFIG_KEY_RUNTIME] = runtime.override(
181
+ stream_writer=self._guard_stream_writer(runtime.stream_writer),
182
+ heartbeat=self.touch,
183
+ )
184
+ else:
185
+ patch[CONFIG_KEY_RUNTIME] = runtime.override(
186
+ stream_writer=self._guard_stream_writer(runtime.stream_writer)
187
+ )
188
+ new_config = patch_configurable(config, patch) if patch else config
189
+ if self._refresh_on == "auto":
190
+ return merge_configs(
191
+ new_config, {"callbacks": [_IdleProgressCallbackHandler(self)]}
192
+ )
193
+ return new_config
194
+
195
+ def touch(self) -> None:
196
+ # Avoid locking this hot progress path. We accept a small race window in
197
+ # timestamp ordering because idle_timeout is expected to be coarse compared
198
+ # with scheduler/thread timing.
199
+ now = time.monotonic()
200
+ self._last_progress = now
201
+ if self._on_progress is None:
202
+ return
203
+ # Best-effort rate limit: a benign race may emit a duplicate progress
204
+ # event under heavy concurrency, which observers must already tolerate
205
+ # (callbacks fire from arbitrary threads).
206
+ if now - self._last_progress_emit < self._progress_min_interval:
207
+ return
208
+ self._last_progress_emit = now
209
+ self._on_progress()
210
+
211
+ def close(self) -> None:
212
+ with self._lock:
213
+ self._active = False
214
+
215
+ async def wait_for_idle_timeout(self, idle_timeout_s: float) -> None:
216
+ while True:
217
+ with self._lock:
218
+ if not self._active:
219
+ return
220
+ remaining = self._last_progress + idle_timeout_s - time.monotonic()
221
+ if remaining <= 0:
222
+ raise asyncio.TimeoutError
223
+ await asyncio.sleep(remaining)
224
+
225
+ def _guard_send(
226
+ self, send: Callable[[Sequence[tuple[str, Any]]], None]
227
+ ) -> Callable[[Sequence[tuple[str, Any]]], None]:
228
+ def guarded_send(writes: Sequence[tuple[str, Any]]) -> None:
229
+ with self._lock:
230
+ if self._active:
231
+ if writes and self._refresh_on == "auto":
232
+ self._last_progress = time.monotonic()
233
+ send(writes)
234
+
235
+ return guarded_send
236
+
237
+ def _guard_stream(self, stream: StreamProtocol) -> StreamProtocol:
238
+ # No lock: stream callbacks fire from the event loop only, so the
239
+ # active-check + write happen atomically between awaits.
240
+ def guarded_stream(chunk: tuple[tuple[str, ...], str, Any]) -> None:
241
+ if not self._active:
242
+ return
243
+ if self._refresh_on == "auto":
244
+ self._last_progress = time.monotonic()
245
+ stream(chunk)
246
+
247
+ return StreamProtocol(guarded_stream, stream.modes)
248
+
249
+ def _guard_call(self, call: Callable[..., Any]) -> Callable[..., Any]:
250
+ # No lock: child-task scheduling happens from the event loop only.
251
+ def guarded_call(*args: Any, **kwargs: Any) -> Any:
252
+ if not self._active:
253
+ raise asyncio.CancelledError
254
+ if self._refresh_on == "auto":
255
+ self._last_progress = time.monotonic()
256
+ return call(*args, **kwargs)
257
+
258
+ return guarded_call
259
+
260
+ def _guard_stream_writer(
261
+ self, stream_writer: Callable[[Any], None]
262
+ ) -> Callable[[Any], None]:
263
+ def guarded_stream_writer(chunk: Any) -> None:
264
+ with self._lock:
265
+ if not self._active:
266
+ return
267
+ if self._refresh_on == "auto":
268
+ self._last_progress = time.monotonic()
269
+ stream_writer(chunk)
270
+
271
+ return guarded_stream_writer
272
+
273
+
274
+ class _IdleProgressCallbackHandler(BaseCallbackHandler):
275
+ """Resets the idle timeout clock on any Coco callback event.
276
+
277
+ Inherits via `config["callbacks"]`, so it sees only events emitted by
278
+ runs descended from the node's attempt — sibling nodes do not bleed
279
+ through. Holds the scope by weakref so a child manager that outlives
280
+ the attempt cannot keep the scope alive.
281
+ """
282
+
283
+ # Run inline so progress is recorded in callback emission order;
284
+ # thread-pool dispatch would introduce extra reordering.
285
+ run_inline = True
286
+
287
+ def __init__(self, scope: _TimedAttemptScope) -> None:
288
+ self._scope_ref = weakref.ref(scope)
289
+
290
+ def _touch(self, *args: Any, **kwargs: Any) -> None:
291
+ if (scope := self._scope_ref()) is not None:
292
+ scope.touch()
293
+
294
+ on_llm_start = _touch
295
+ on_chat_model_start = _touch
296
+ on_llm_new_token = _touch
297
+ on_llm_end = _touch
298
+ on_llm_error = _touch
299
+ on_chain_start = _touch
300
+ on_chain_end = _touch
301
+ on_chain_error = _touch
302
+ on_tool_start = _touch
303
+ on_tool_end = _touch
304
+ on_tool_error = _touch
305
+ on_retriever_start = _touch
306
+ on_retriever_end = _touch
307
+ on_retriever_error = _touch
308
+ on_agent_action = _touch
309
+ on_agent_finish = _touch
310
+ on_text = _touch
311
+ on_retry = _touch
312
+ on_custom_event = _touch
313
+
314
+
315
+ def _is_user_raised_cancelled() -> bool:
316
+ """Return True if the in-flight ``CancelledError`` came from the task body.
317
+
318
+ Pregel cancels sibling tasks via ``task.cancel()`` when a peer fails, which
319
+ increments ``asyncio.Task.cancelling()`` on the target before the cancel
320
+ actually fires. A user node that calls ``raise asyncio.CancelledError()``
321
+ from inside its own body raises while ``cancelling() == 0``, which is the
322
+ signal we use to convert the exception into a regular
323
+ :class:`NodeCancelledError`.
324
+
325
+ Returns ``False`` when we can't tell (``cancelling()`` unavailable, or no
326
+ current task — neither should happen in practice from ``arun_with_retry``)
327
+ so framework-initiated cancellation continues to propagate unchanged.
328
+ """
329
+ if not SUPPORTS_TASK_CANCELLING:
330
+ return False
331
+ current = asyncio.current_task()
332
+ if current is None:
333
+ return False
334
+ return current.cancelling() == 0
335
+
336
+
337
+ def _drain_cancelled(task: asyncio.Task[Any]) -> None:
338
+ # Mark the abandoned task's exception as retrieved so asyncio doesn't log it.
339
+ with suppress(asyncio.CancelledError):
340
+ task.exception()
341
+
342
+
343
+ def _start_timed_attempt(
344
+ task: PregelExecutableTask, config: RunnableConfig, timeout: _ResolvedTimeout
345
+ ) -> _AttemptContext | None:
346
+ configurable = config.get(CONF, {})
347
+ callback = configurable.get(CONFIG_KEY_TIMED_ATTEMPT_OBSERVER)
348
+ if callback is None:
349
+ return None
350
+ runtime = configurable.get(CONFIG_KEY_RUNTIME)
351
+ execution_info = runtime.execution_info if isinstance(runtime, Runtime) else None
352
+ context = _AttemptContext(
353
+ task_id=task.id,
354
+ task_name=task.name,
355
+ attempt=execution_info.node_attempt if execution_info is not None else 1,
356
+ run_id=execution_info.run_id if execution_info is not None else None,
357
+ thread_id=execution_info.thread_id if execution_info is not None else None,
358
+ checkpoint_ns=(
359
+ execution_info.checkpoint_ns if execution_info is not None else None
360
+ ),
361
+ started_at=datetime.now(timezone.utc),
362
+ run_timeout_secs=timeout.run_timeout_secs,
363
+ idle_timeout_secs=timeout.idle_timeout_secs,
364
+ refresh_on=timeout.refresh_on,
365
+ )
366
+ _dispatch_observer(callback, _AttemptEvent(context=context, event="start"))
367
+ return context
368
+
369
+
370
+ def _finish_timed_attempt(
371
+ config: RunnableConfig,
372
+ context: _AttemptContext | None,
373
+ error: BaseException | None = None,
374
+ ) -> None:
375
+ if context is None:
376
+ return
377
+ callback = config.get(CONF, {}).get(CONFIG_KEY_TIMED_ATTEMPT_OBSERVER)
378
+ if callback is None:
379
+ return
380
+ _dispatch_observer(
381
+ callback,
382
+ _AttemptEvent(
383
+ context=context,
384
+ event="finish",
385
+ finished_at=datetime.now(timezone.utc),
386
+ status="error" if error is not None else "success",
387
+ error_type=type(error).__name__ if error is not None else None,
388
+ error_message=str(error) if error is not None else None,
389
+ ),
390
+ )
391
+
392
+
393
+ def _emit_progress(
394
+ callback: Callable[[_AttemptEvent], None],
395
+ context: _AttemptContext,
396
+ ) -> None:
397
+ _dispatch_observer(
398
+ callback,
399
+ _AttemptEvent(
400
+ context=context,
401
+ event="progress",
402
+ progress_at=datetime.now(timezone.utc),
403
+ ),
404
+ )
405
+
406
+
407
+ def _dispatch_observer(
408
+ callback: Callable[[_AttemptEvent], None],
409
+ event: _AttemptEvent,
410
+ ) -> None:
411
+ try:
412
+ callback(event)
413
+ except Exception:
414
+ logger.warning("Timed attempt observer failed", exc_info=True)
415
+
416
+
417
+ async def _run_timeout_watchdog(run_timeout_s: float) -> None:
418
+ await asyncio.sleep(run_timeout_s)
419
+ raise asyncio.TimeoutError
420
+
421
+
422
+ async def _arun_with_timeout(
423
+ task: PregelExecutableTask,
424
+ config: RunnableConfig,
425
+ timeout: _ResolvedTimeout,
426
+ attempt_ctx: _AttemptContext | None,
427
+ *,
428
+ stream: bool,
429
+ ) -> Any:
430
+ run_timeout_s = timeout.run_timeout_secs
431
+ idle_timeout_s = timeout.idle_timeout_secs
432
+ on_progress: Callable[[], None] | None = None
433
+ if attempt_ctx is not None:
434
+ callback = config.get(CONF, {}).get(CONFIG_KEY_TIMED_ATTEMPT_OBSERVER)
435
+ if callback is not None and idle_timeout_s is not None:
436
+ on_progress = lambda: _emit_progress(callback, attempt_ctx) # noqa: E731
437
+ scope = _TimedAttemptScope(
438
+ on_progress=on_progress,
439
+ # Cap progress emission at ~4 events per idle window so token-rate
440
+ # callbacks don't flood the observer.
441
+ progress_min_interval=idle_timeout_s / 4 if idle_timeout_s is not None else 0.0,
442
+ refresh_on=timeout.refresh_on,
443
+ )
444
+ scoped_config = scope.wrap_config(config)
445
+ start = time.monotonic()
446
+ if stream:
447
+ # Yielded chunks count as progress only under `refresh_on="auto"`.
448
+ # `refresh_on="heartbeat"` is the strict mode where only explicit
449
+ # `runtime.heartbeat()` calls reset the idle clock.
450
+ async def run() -> Any:
451
+ async for _ in task.proc.astream(task.input, scoped_config):
452
+ if timeout.refresh_on == "auto":
453
+ scope.touch()
454
+
455
+ else:
456
+
457
+ async def run() -> Any:
458
+ return await task.proc.ainvoke(task.input, scoped_config)
459
+
460
+ bg = create_task_in_config_context(run, scoped_config)
461
+ watchdogs: dict[asyncio.Task[None], Literal["idle", "run"]] = {}
462
+ if idle_timeout_s is not None:
463
+ watchdogs[asyncio.create_task(scope.wait_for_idle_timeout(idle_timeout_s))] = (
464
+ "idle"
465
+ )
466
+ if run_timeout_s is not None:
467
+ watchdogs[asyncio.create_task(_run_timeout_watchdog(run_timeout_s))] = "run"
468
+ try:
469
+ done, _ = await asyncio.wait(
470
+ {bg, *watchdogs}, return_when=asyncio.FIRST_COMPLETED
471
+ )
472
+ if bg in done:
473
+ # Task completed in time.
474
+ for watchdog in watchdogs:
475
+ watchdog.cancel()
476
+ # FIRST_COMPLETED can return both; a watchdog may have
477
+ # already raised TimeoutError before we cancelled it.
478
+ for watchdog in watchdogs:
479
+ with suppress(asyncio.CancelledError, asyncio.TimeoutError):
480
+ await watchdog
481
+ return await bg
482
+ # bg was not in `done`, so every member of `done` is one of our
483
+ # watchdogs. Only a watchdog's TimeoutError converts to
484
+ # NodeTimeoutError; any TimeoutError raised by the proc itself
485
+ # propagates unchanged.
486
+ for watchdog in done:
487
+ kind = watchdogs[watchdog]
488
+ try:
489
+ await watchdog
490
+ except asyncio.TimeoutError as exc:
491
+ elapsed = time.monotonic() - start
492
+ scope.close()
493
+ task.writes.clear()
494
+ bg.cancel()
495
+ bg.add_done_callback(_drain_cancelled)
496
+ raise NodeTimeoutError(
497
+ task.name,
498
+ elapsed,
499
+ kind=kind,
500
+ idle_timeout=idle_timeout_s,
501
+ run_timeout=run_timeout_s,
502
+ ) from exc
503
+ raise RuntimeError(
504
+ f"{kind} timeout watchdog completed without raising TimeoutError"
505
+ )
506
+ raise RuntimeError("timeout wait completed without task or watchdog")
507
+ except asyncio.CancelledError:
508
+ scope.close()
509
+ bg.cancel()
510
+ for watchdog in watchdogs:
511
+ watchdog.cancel()
512
+ bg.add_done_callback(_drain_cancelled)
513
+ raise
514
+ finally:
515
+ scope.close()
516
+ for watchdog in watchdogs:
517
+ watchdog.cancel()
518
+
519
+
520
+ def _ensure_execution_info(
521
+ runtime: Runtime, config: RunnableConfig, task: PregelExecutableTask
522
+ ) -> Runtime:
523
+ """Ensure runtime has execution_info, creating one from config if needed.
524
+
525
+ In the distributed runtime (CocoGraph Platform), tasks are prepared by the
526
+ server and deserialized in the executor, bypassing the OSS _algo.py code
527
+ that normally creates ExecutionInfo. This function fills in execution_info
528
+ from the task config when it's missing.
529
+ """
530
+ if runtime.execution_info is not None:
531
+ return runtime
532
+ configurable = config.get(CONF, {})
533
+ return runtime.override(
534
+ execution_info=ExecutionInfo(
535
+ checkpoint_id=configurable.get(CONFIG_KEY_CHECKPOINT_ID) or "",
536
+ checkpoint_ns=configurable.get(CONFIG_KEY_CHECKPOINT_NS) or "",
537
+ task_id=configurable.get(CONFIG_KEY_TASK_ID) or task.id,
538
+ thread_id=configurable.get(CONFIG_KEY_THREAD_ID),
539
+ run_id=str(rid) if (rid := config.get("run_id")) else None,
540
+ ),
541
+ )
542
+
543
+
544
+ def _checkpoint_ns_for_parent_command(ns: str) -> str:
545
+ """Return the checkpoint namespace for the parent graph.
546
+
547
+ The checkpoint namespace is a `|`-separated path. Each segment is usually
548
+ of the form `name:task_id` (e.g. `parent_first:<uuid>|node:<uuid>`), but the
549
+ runtime may also insert a purely-numeric segment (e.g. `|1`) to disambiguate
550
+ concurrent tasks (e.g. `parent_first:<uuid>|1|node:<uuid>`).
551
+
552
+ Numeric segments are not real path levels, so we drop them before computing
553
+ the parent namespace.
554
+ """
555
+
556
+ parts = ns.split(NS_SEP)
557
+
558
+ # Drop any trailing numeric selectors for the current frame (e.g. `...|node:<id>|1`).
559
+ while parts and parts[-1].isdigit():
560
+ parts.pop()
561
+
562
+ # Drop the current frame segment itself (e.g. the `node:<id>`).
563
+ if parts:
564
+ parts.pop()
565
+
566
+ # Drop any trailing numeric selectors for the parent frame (e.g. `...|1|node:<id>`).
567
+ while parts and parts[-1].isdigit():
568
+ parts.pop()
569
+
570
+ return NS_SEP.join(parts)
571
+
572
+
573
+ def run_with_retry(
574
+ task: PregelExecutableTask,
575
+ retry_policy: Sequence[RetryPolicy] | None,
576
+ configurable: dict[str, Any] | None = None,
577
+ ) -> None:
578
+ """Run a task with retries."""
579
+ retry_policy = task.retry_policy or retry_policy
580
+ if task.timeout is not None:
581
+ # `validate_timeout_supported` catches sync nodes at compile time;
582
+ # this is a runtime safety net for paths that may bypass that validation.
583
+ raise sync_timeout_unsupported(task.name)
584
+ attempts = 0
585
+ node_first_attempt_time = time.time()
586
+ config = task.config
587
+ if configurable is not None:
588
+ config = patch_configurable(config, configurable)
589
+ runtime = config.get(CONF, {}).get(CONFIG_KEY_RUNTIME)
590
+ if isinstance(runtime, Runtime):
591
+ runtime = _ensure_execution_info(runtime, config, task)
592
+ config = patch_configurable(
593
+ config,
594
+ {
595
+ CONFIG_KEY_RUNTIME: runtime.patch_execution_info(
596
+ node_first_attempt_time=node_first_attempt_time,
597
+ )
598
+ },
599
+ )
600
+ while True:
601
+ runtime = config.get(CONF, {}).get(CONFIG_KEY_RUNTIME)
602
+ if isinstance(runtime, Runtime):
603
+ config = patch_configurable(
604
+ config,
605
+ {
606
+ CONFIG_KEY_RUNTIME: runtime.patch_execution_info(
607
+ # node_attempt is execution count (1-indexed): 1 on first run,
608
+ # then 2, 3, ... on subsequent retries.
609
+ node_attempt=attempts + 1,
610
+ )
611
+ },
612
+ )
613
+ try:
614
+ # clear any writes from previous attempts
615
+ task.writes.clear()
616
+ # run the task
617
+ return task.proc.invoke(task.input, config)
618
+ except ParentCommand as exc:
619
+ ns: str = config[CONF][CONFIG_KEY_CHECKPOINT_NS]
620
+ cmd = exc.args[0]
621
+ # strip task_ids from namespace for comparison (ns format: "node1|node2:task_id")
622
+ if cmd.graph in (ns, recast_checkpoint_ns(ns), task.name):
623
+ # this command is for the current graph, handle it
624
+ for w in task.writers:
625
+ w.invoke(cmd, config)
626
+ break
627
+ elif cmd.graph == Command.PARENT:
628
+ # this command is for the parent graph, assign it to the parent.
629
+ exc.args = (replace(cmd, graph=_checkpoint_ns_for_parent_command(ns)),)
630
+ # bubble up
631
+ raise
632
+ except GraphBubbleUp:
633
+ # if interrupted, end
634
+ raise
635
+ except asyncio.CancelledError as exc:
636
+ # A sync node has no asyncio context, so any ``CancelledError`` that
637
+ # reaches here was raised by the node body itself. Surface it as a
638
+ # regular exception so the pregel runner panics the run instead of
639
+ # treating the task as a silent tear-down (LSD-1507).
640
+ raise NodeCancelledError(task.name) from exc
641
+ except Exception as exc:
642
+ if SUPPORTS_EXC_NOTES:
643
+ exc.add_note(f"During task with name '{task.name}' and id '{task.id}'")
644
+ if not retry_policy:
645
+ raise
646
+
647
+ # Check which retry policy applies to this exception
648
+ matching_policy = None
649
+ for policy in retry_policy:
650
+ if _should_retry_on(policy, exc):
651
+ matching_policy = policy
652
+ break
653
+
654
+ if not matching_policy:
655
+ raise
656
+
657
+ # attempts tracks failed tries only; it increments after a failure.
658
+ attempts += 1
659
+ # check if we should give up
660
+ if attempts >= matching_policy.max_attempts:
661
+ raise
662
+ # sleep before retrying
663
+ interval = matching_policy.initial_interval
664
+ # Apply backoff factor based on attempt count
665
+ interval = min(
666
+ matching_policy.max_interval,
667
+ interval * (matching_policy.backoff_factor ** (attempts - 1)),
668
+ )
669
+
670
+ # Apply jitter if configured
671
+ sleep_time = (
672
+ interval + random.uniform(0, 1) if matching_policy.jitter else interval
673
+ )
674
+ time.sleep(sleep_time)
675
+
676
+ # log the retry
677
+ logger.info(
678
+ f"Retrying task {task.name} after {sleep_time:.2f} seconds (attempt {attempts}) after {exc.__class__.__name__} {exc}",
679
+ exc_info=exc,
680
+ )
681
+ # signal subgraphs to resume (if available)
682
+ config = patch_configurable(config, {CONFIG_KEY_RESUMING: True})
683
+
684
+
685
+ async def arun_with_retry(
686
+ task: PregelExecutableTask,
687
+ retry_policy: Sequence[RetryPolicy] | None,
688
+ stream: bool = False,
689
+ match_cached_writes: Callable[[], Awaitable[Sequence[PregelExecutableTask]]]
690
+ | None = None,
691
+ configurable: dict[str, Any] | None = None,
692
+ ) -> None:
693
+ """Run a task asynchronously with retries."""
694
+ retry_policy = task.retry_policy or retry_policy
695
+ resolved_timeout = (
696
+ _resolve_timeout(task.timeout) if task.timeout is not None else None
697
+ )
698
+ attempts = 0
699
+ node_first_attempt_time = time.time()
700
+ config = task.config
701
+ if configurable is not None:
702
+ config = patch_configurable(config, configurable)
703
+ runtime = config.get(CONF, {}).get(CONFIG_KEY_RUNTIME)
704
+ if isinstance(runtime, Runtime):
705
+ runtime = _ensure_execution_info(runtime, config, task)
706
+ config = patch_configurable(
707
+ config,
708
+ {
709
+ CONFIG_KEY_RUNTIME: runtime.patch_execution_info(
710
+ node_first_attempt_time=node_first_attempt_time,
711
+ )
712
+ },
713
+ )
714
+ if match_cached_writes is not None and task.cache_key is not None:
715
+ for t in await match_cached_writes():
716
+ if t is task:
717
+ # if the task is already cached, return
718
+ return
719
+ while True:
720
+ runtime = config.get(CONF, {}).get(CONFIG_KEY_RUNTIME)
721
+ if isinstance(runtime, Runtime):
722
+ config = patch_configurable(
723
+ config,
724
+ {
725
+ CONFIG_KEY_RUNTIME: runtime.patch_execution_info(
726
+ # node_attempt is execution count (1-indexed): 1 on first run,
727
+ # then 2, 3, ... on subsequent retries.
728
+ node_attempt=attempts + 1,
729
+ )
730
+ },
731
+ )
732
+ attempt_ctx = (
733
+ _start_timed_attempt(task, config, resolved_timeout)
734
+ if resolved_timeout is not None
735
+ else None
736
+ )
737
+ try:
738
+ task.writes.clear()
739
+ if resolved_timeout is None:
740
+ if stream:
741
+ async for _ in task.proc.astream(task.input, config):
742
+ pass
743
+ break
744
+ return await task.proc.ainvoke(task.input, config)
745
+ result = await _arun_with_timeout(
746
+ task, config, resolved_timeout, attempt_ctx, stream=stream
747
+ )
748
+ _finish_timed_attempt(config, attempt_ctx)
749
+ if stream:
750
+ # if successful, end
751
+ break
752
+ return result
753
+ except ParentCommand as exc:
754
+ ns: str = config[CONF][CONFIG_KEY_CHECKPOINT_NS]
755
+ cmd = exc.args[0]
756
+ # strip task_ids from namespace for comparison (ns format: "node1|node2:task_id")
757
+ if cmd.graph in (ns, recast_checkpoint_ns(ns), task.name):
758
+ try:
759
+ # this command is for the current graph, handle it
760
+ for w in task.writers:
761
+ w.invoke(cmd, config)
762
+ except Exception as writer_exc:
763
+ _finish_timed_attempt(config, attempt_ctx, writer_exc)
764
+ raise
765
+ _finish_timed_attempt(config, attempt_ctx)
766
+ break
767
+ elif cmd.graph == Command.PARENT:
768
+ # this command is for the parent graph, assign it to the parent.
769
+ exc.args = (replace(cmd, graph=_checkpoint_ns_for_parent_command(ns)),)
770
+ _finish_timed_attempt(config, attempt_ctx)
771
+ # bubble up the exception to the parent graph
772
+ raise
773
+ except GraphBubbleUp:
774
+ # if interrupted, end
775
+ _finish_timed_attempt(config, attempt_ctx)
776
+ raise
777
+ except asyncio.CancelledError as exc:
778
+ # ``CancelledError`` reaches us in two very different shapes:
779
+ # 1. Pregel cancelled this task because a sibling failed
780
+ # (``asyncio.Task.cancelling() >= 1``). The framework already
781
+ # knows the run is failing and we must let cancellation
782
+ # propagate so the watchdog/cleanup code in the runner sees a
783
+ # cancelled future.
784
+ # 2. The node body itself raised ``asyncio.CancelledError`` (
785
+ # ``cancelling() == 0``). The runner would otherwise treat
786
+ # this as silent tear-down and the run would report
787
+ # ``success`` even though the node failed (LSD-1507). Convert
788
+ # it into :class:`NodeCancelledError` so it follows the same
789
+ # path as any other node failure.
790
+ if _is_user_raised_cancelled():
791
+ _finish_timed_attempt(config, attempt_ctx, exc)
792
+ raise NodeCancelledError(task.name) from exc
793
+ _finish_timed_attempt(config, attempt_ctx, exc)
794
+ raise
795
+ except Exception as exc:
796
+ _finish_timed_attempt(config, attempt_ctx, exc)
797
+ if SUPPORTS_EXC_NOTES:
798
+ exc.add_note(f"During task with name '{task.name}' and id '{task.id}'")
799
+ if not retry_policy:
800
+ raise
801
+
802
+ # Check which retry policy applies to this exception
803
+ matching_policy = None
804
+ for policy in retry_policy:
805
+ if _should_retry_on(policy, exc):
806
+ matching_policy = policy
807
+ break
808
+
809
+ if not matching_policy:
810
+ raise
811
+
812
+ # attempts tracks failed tries only; it increments after a failure.
813
+ # The next execution's node_attempt is derived as attempts + 1.
814
+ attempts += 1
815
+ # check if we should give up
816
+ if attempts >= matching_policy.max_attempts:
817
+ raise
818
+ # sleep before retrying
819
+ interval = matching_policy.initial_interval
820
+ # Apply backoff factor based on attempt count
821
+ interval = min(
822
+ matching_policy.max_interval,
823
+ interval * (matching_policy.backoff_factor ** (attempts - 1)),
824
+ )
825
+
826
+ # Apply jitter if configured
827
+ sleep_time = (
828
+ interval + random.uniform(0, 1) if matching_policy.jitter else interval
829
+ )
830
+ await asyncio.sleep(sleep_time)
831
+
832
+ # log the retry
833
+ logger.info(
834
+ f"Retrying task {task.name} after {sleep_time:.2f} seconds (attempt {attempts}) after {exc.__class__.__name__} {exc}",
835
+ exc_info=exc,
836
+ )
837
+ # signal subgraphs to resume (if available)
838
+ config = patch_configurable(config, {CONFIG_KEY_RESUMING: True})
839
+
840
+
841
+ def _should_retry_on(retry_policy: RetryPolicy, exc: Exception) -> bool:
842
+ """Check if the given exception should be retried based on the retry policy."""
843
+ if isinstance(retry_policy.retry_on, Sequence):
844
+ return isinstance(exc, tuple(retry_policy.retry_on))
845
+ elif isinstance(retry_policy.retry_on, type) and issubclass(
846
+ retry_policy.retry_on, Exception
847
+ ):
848
+ return isinstance(exc, retry_policy.retry_on)
849
+ elif callable(retry_policy.retry_on):
850
+ return retry_policy.retry_on(exc) # type: ignore[call-arg]
851
+ else:
852
+ raise TypeError(
853
+ "retry_on must be an Exception class, a list or tuple of Exception classes, or a callable"
854
+ )