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,1978 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ import logging
5
+ import typing
6
+ import warnings
7
+ from collections import defaultdict
8
+ from collections.abc import Awaitable, Callable, Hashable, Sequence
9
+ from dataclasses import dataclass, is_dataclass
10
+ from datetime import timedelta
11
+ from functools import partial
12
+ from inspect import isclass, isfunction, ismethod, signature
13
+ from types import FunctionType
14
+ from types import NoneType as NoneType
15
+ from typing import (
16
+ Any,
17
+ Generic,
18
+ Literal,
19
+ TypeVar,
20
+ Union,
21
+ cast,
22
+ get_args,
23
+ get_origin,
24
+ get_type_hints,
25
+ overload,
26
+ )
27
+
28
+ from coco_core.runnables import Runnable, RunnableConfig
29
+ from coco_graph.cache.base import BaseCache
30
+ from coco_graph.checkpoint.base import Checkpoint
31
+ from coco_graph.store.base import BaseStore
32
+ from pydantic import BaseModel, TypeAdapter
33
+ from typing_extensions import NotRequired, Required, Self, Unpack, is_typeddict
34
+
35
+ from coco_graph._internal import _serde
36
+ from coco_graph._internal._constants import (
37
+ INTERRUPT,
38
+ NS_END,
39
+ NS_SEP,
40
+ TASKS,
41
+ )
42
+ from coco_graph._internal._fields import (
43
+ get_cached_annotated_keys,
44
+ get_field_default,
45
+ get_update_as_tuples,
46
+ )
47
+ from coco_graph._internal._pydantic import create_model
48
+ from coco_graph._internal._runnable import coerce_to_runnable
49
+ from coco_graph._internal._timeout import coerce_timeout_policy
50
+ from coco_graph._internal._typing import EMPTY_SEQ, MISSING, DeprecatedKwargs
51
+ from coco_graph.channels.base import BaseChannel
52
+ from coco_graph.channels.binop import BinaryOperatorAggregate
53
+ from coco_graph.channels.delta import DeltaChannel
54
+ from coco_graph.channels.ephemeral_value import EphemeralValue
55
+ from coco_graph.channels.last_value import LastValue, LastValueAfterFinish
56
+ from coco_graph.channels.named_barrier_value import (
57
+ NamedBarrierValue,
58
+ NamedBarrierValueAfterFinish,
59
+ )
60
+ from coco_graph.constants import END, START, TAG_HIDDEN
61
+ from coco_graph.errors import (
62
+ ErrorCode,
63
+ InvalidUpdateError,
64
+ ParentCommand,
65
+ create_error_message,
66
+ )
67
+ from coco_graph.graph._branch import BranchSpec
68
+ from coco_graph.graph._node import StateNode, StateNodeSpec
69
+ from coco_graph.managed.base import (
70
+ ManagedValueSpec,
71
+ is_managed_value,
72
+ )
73
+ from coco_graph.pregel import Pregel
74
+ from coco_graph.pregel._read import ChannelRead, PregelNode
75
+ from coco_graph.pregel._write import (
76
+ ChannelWrite,
77
+ ChannelWriteEntry,
78
+ ChannelWriteTupleEntry,
79
+ )
80
+ from coco_graph.types import (
81
+ All,
82
+ CachePolicy,
83
+ Checkpointer,
84
+ Command,
85
+ RetryPolicy,
86
+ Send,
87
+ TimeoutPolicy,
88
+ TracePolicy,
89
+ ensure_valid_checkpointer,
90
+ )
91
+ from coco_graph.typing import ContextT, InputT, NodeInputT, OutputT, StateT
92
+ from coco_graph.warnings import CocoGraphDeprecatedSinceV05, CocoGraphDeprecatedSinceV10
93
+
94
+ __all__ = ("StateGraph", "CompiledStateGraph")
95
+
96
+ logger = logging.getLogger(__name__)
97
+
98
+ _CHANNEL_BRANCH_TO = "branch:to:{}"
99
+ _DEFAULT_ERROR_HANDLER_NODE = "__default_error_handler__"
100
+
101
+
102
+ @dataclass(slots=True)
103
+ class _NodeDefaults:
104
+ """Default node policies applied to every node at compile time."""
105
+
106
+ retry_policy: RetryPolicy | Sequence[RetryPolicy] | None = None
107
+ cache_policy: CachePolicy | None = None
108
+ error_handler: StateNode[Any, Any] | None = None
109
+ timeout: TimeoutPolicy | None = None
110
+
111
+
112
+ def _warn_invalid_state_schema(schema: type[Any] | Any) -> None:
113
+ if isinstance(schema, type):
114
+ return
115
+ if typing.get_args(schema):
116
+ return
117
+ warnings.warn(
118
+ f"Invalid state_schema: {schema}. Expected a type or Annotated[type, reducer]. "
119
+ "Please provide a valid schema to ensure correct updates.\n"
120
+ " See: https://langchain-ai.github.io/langgraph/reference/graphs/#stategraph"
121
+ )
122
+
123
+
124
+ def _get_node_name(node: StateNode[Any, ContextT]) -> str:
125
+ try:
126
+ return getattr(node, "__name__", node.__class__.__name__)
127
+ except AttributeError:
128
+ raise TypeError(f"Unsupported node type: {type(node)}")
129
+
130
+
131
+ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
132
+ """A graph whose nodes communicate by reading and writing to a shared state.
133
+
134
+ The signature of each node is `State -> Partial<State>`.
135
+
136
+ Each state key can optionally be annotated with a reducer function that
137
+ will be used to aggregate the values of that key received from multiple nodes.
138
+ The signature of a reducer function is `(Value, Value) -> Value`.
139
+
140
+ !!! warning
141
+
142
+ `StateGraph` is a builder class and cannot be used directly for execution.
143
+ You must first call `.compile()` to create an executable graph that supports
144
+ methods like `invoke()`, `stream()`, `astream()`, and `ainvoke()`. See the
145
+ `CompiledStateGraph` documentation for more details.
146
+
147
+ Args:
148
+ state_schema: The schema class that defines the state.
149
+ context_schema: The schema class that defines the runtime context.
150
+
151
+ Use this to expose immutable context data to your nodes, like `user_id`, `db_conn`, etc.
152
+ input_schema: The schema class that defines the input to the graph.
153
+ output_schema: The schema class that defines the output from the graph.
154
+
155
+ !!! warning "`config_schema` Deprecated"
156
+ The `config_schema` parameter is deprecated in v0.6.0 and support will be removed in v2.0.0.
157
+ Please use `context_schema` instead to specify the schema for run-scoped context.
158
+
159
+ Example:
160
+ ```python
161
+ from coco_core.runnables import RunnableConfig
162
+ from typing_extensions import Annotated, TypedDict
163
+ from coco_graph.checkpoint.memory import InMemorySaver
164
+ from coco_graph.graph import StateGraph
165
+ from coco_graph.runtime import Runtime
166
+
167
+
168
+ def reducer(a: list, b: int | None) -> list:
169
+ if b is not None:
170
+ return a + [b]
171
+ return a
172
+
173
+
174
+ class State(TypedDict):
175
+ x: Annotated[list, reducer]
176
+
177
+
178
+ class Context(TypedDict):
179
+ r: float
180
+
181
+
182
+ graph = StateGraph(state_schema=State, context_schema=Context)
183
+
184
+
185
+ def node(state: State, runtime: Runtime[Context]) -> dict:
186
+ r = runtime.context.get("r", 1.0)
187
+ x = state["x"][-1]
188
+ next_value = x * r * (1 - x)
189
+ return {"x": next_value}
190
+
191
+
192
+ graph.add_node("A", node)
193
+ graph.set_entry_point("A")
194
+ graph.set_finish_point("A")
195
+ compiled = graph.compile()
196
+
197
+ step1 = compiled.invoke({"x": 0.5}, context={"r": 3.0})
198
+ # {'x': [0.5, 0.75]}
199
+ ```
200
+ """
201
+
202
+ edges: set[tuple[str, str]]
203
+ nodes: dict[str, StateNodeSpec[Any, ContextT]]
204
+ branches: defaultdict[str, dict[str, BranchSpec]]
205
+ channels: dict[str, BaseChannel]
206
+ managed: dict[str, ManagedValueSpec]
207
+ schemas: dict[type[Any], dict[str, BaseChannel | ManagedValueSpec]]
208
+ waiting_edges: set[tuple[tuple[str, ...], str]]
209
+
210
+ compiled: bool
211
+ state_schema: type[StateT]
212
+ context_schema: type[ContextT] | None
213
+ input_schema: type[InputT]
214
+ output_schema: type[OutputT]
215
+
216
+ def __init__(
217
+ self,
218
+ state_schema: type[StateT],
219
+ context_schema: type[ContextT] | None = None,
220
+ *,
221
+ input_schema: type[InputT] | None = None,
222
+ output_schema: type[OutputT] | None = None,
223
+ **kwargs: Unpack[DeprecatedKwargs],
224
+ ) -> None:
225
+ if (config_schema := kwargs.get("config_schema", MISSING)) is not MISSING:
226
+ warnings.warn(
227
+ "`config_schema` is deprecated and will be removed. Please use `context_schema` instead.",
228
+ category=CocoGraphDeprecatedSinceV10,
229
+ stacklevel=2,
230
+ )
231
+ if context_schema is None:
232
+ context_schema = cast(type[ContextT], config_schema)
233
+
234
+ if (input_ := kwargs.get("input", MISSING)) is not MISSING:
235
+ warnings.warn(
236
+ "`input` is deprecated and will be removed. Please use `input_schema` instead.",
237
+ category=CocoGraphDeprecatedSinceV05,
238
+ stacklevel=2,
239
+ )
240
+ if input_schema is None:
241
+ input_schema = cast(type[InputT], input_)
242
+
243
+ if (output := kwargs.get("output", MISSING)) is not MISSING:
244
+ warnings.warn(
245
+ "`output` is deprecated and will be removed. Please use `output_schema` instead.",
246
+ category=CocoGraphDeprecatedSinceV05,
247
+ stacklevel=2,
248
+ )
249
+ if output_schema is None:
250
+ output_schema = cast(type[OutputT], output)
251
+
252
+ self.nodes = {}
253
+ self.edges = set()
254
+ self.branches = defaultdict(dict)
255
+ self.schemas = {}
256
+ self.channels = {}
257
+ self.managed = {}
258
+ self.compiled = False
259
+ self.waiting_edges = set()
260
+
261
+ self.state_schema = state_schema
262
+ self.input_schema = cast(type[InputT], input_schema or state_schema)
263
+ self.output_schema = cast(type[OutputT], output_schema or state_schema)
264
+ self.context_schema = context_schema
265
+
266
+ self._node_defaults: _NodeDefaults = _NodeDefaults()
267
+
268
+ self._add_schema(self.state_schema)
269
+ self._add_schema(self.input_schema, allow_managed=False)
270
+ self._add_schema(self.output_schema, allow_managed=False)
271
+
272
+ def set_node_defaults(
273
+ self,
274
+ *,
275
+ retry_policy: RetryPolicy | Sequence[RetryPolicy] | None = None,
276
+ cache_policy: CachePolicy | None = None,
277
+ error_handler: StateNode[Any, ContextT] | None = None,
278
+ timeout: float | timedelta | TimeoutPolicy | None = None,
279
+ ) -> Self:
280
+ """Set default node policies that apply to every node in this graph.
281
+
282
+ Per-node values passed to `add_node` always take precedence over these
283
+ defaults. Defaults are applied at `compile()` time. Policies set here
284
+ are **not** inherited by subgraphs.
285
+
286
+ `retry_policy` and `timeout` defaults apply to **all** nodes,
287
+ including error-handler nodes. `cache_policy` and `error_handler`
288
+ defaults only apply to regular nodes -- caching error-handler results
289
+ is unsafe, and handlers must never catch themselves.
290
+
291
+ Args:
292
+ retry_policy: Default retry policy for nodes that don't specify
293
+ their own via `add_node(..., retry_policy=...)`. Also applies
294
+ to error-handler nodes.
295
+ cache_policy: Default cache policy for nodes that don't specify
296
+ their own via `add_node(..., cache_policy=...)`. Does **not**
297
+ apply to error-handler nodes.
298
+ error_handler: Default error handler invoked when any regular node
299
+ raises and does not have its own `error_handler` set via
300
+ `add_node`. The handler is **not** invoked when an
301
+ error-handler node itself raises -- handler failures fail the
302
+ run.
303
+ timeout: Default timeout policy for nodes that don't specify their
304
+ own via `add_node(..., timeout=...)`. Also applies to
305
+ error-handler nodes. Accepts a `TimeoutPolicy`, a number of
306
+ seconds (`float`), or a `timedelta`.
307
+
308
+ Returns:
309
+ Self: The builder instance, for chaining.
310
+
311
+ Example:
312
+ ```python
313
+ graph = (
314
+ StateGraph(State)
315
+ .set_node_defaults(
316
+ retry_policy=RetryPolicy(max_attempts=3),
317
+ error_handler=my_fallback_handler,
318
+ )
319
+ .add_node("a", node_a)
320
+ .add_node("b", node_b, retry_policy=custom_retry) # overrides default
321
+ .add_edge(START, "a")
322
+ .compile()
323
+ )
324
+ ```
325
+ """
326
+ defaults = self._node_defaults
327
+ if retry_policy is not None:
328
+ defaults.retry_policy = retry_policy
329
+ if cache_policy is not None:
330
+ defaults.cache_policy = cache_policy
331
+ if error_handler is not None:
332
+ defaults.error_handler = error_handler
333
+ if timeout is not None:
334
+ defaults.timeout = coerce_timeout_policy(timeout)
335
+ return self
336
+
337
+ @property
338
+ def _all_edges(self) -> set[tuple[str, str]]:
339
+ return self.edges | {
340
+ (start, end) for starts, end in self.waiting_edges for start in starts
341
+ }
342
+
343
+ def _add_schema(self, schema: type[Any], /, allow_managed: bool = True) -> None:
344
+ if schema not in self.schemas:
345
+ _warn_invalid_state_schema(schema)
346
+ channels, managed, type_hints = _get_channels(schema)
347
+ if managed and not allow_managed:
348
+ names = ", ".join(managed)
349
+ schema_name = getattr(schema, "__name__", "")
350
+ raise ValueError(
351
+ f"Invalid managed channels detected in {schema_name}: {names}."
352
+ " Managed channels are not permitted in Input/Output schema."
353
+ )
354
+ self.schemas[schema] = {**channels, **managed}
355
+ for key, channel in channels.items():
356
+ if key in self.channels:
357
+ if self.channels[key] != channel:
358
+ if isinstance(channel, LastValue):
359
+ pass
360
+ else:
361
+ raise ValueError(
362
+ f"Channel '{key}' already exists with a different type"
363
+ )
364
+ else:
365
+ self.channels[key] = channel
366
+ for key, managed in managed.items():
367
+ if key in self.managed:
368
+ if self.managed[key] != managed:
369
+ raise ValueError(
370
+ f"Managed value '{key}' already exists with a different type"
371
+ )
372
+ else:
373
+ self.managed[key] = managed
374
+
375
+ @overload
376
+ def add_node(
377
+ self,
378
+ node: StateNode[NodeInputT, ContextT],
379
+ *,
380
+ defer: bool = False,
381
+ metadata: dict[str, Any] | None = None,
382
+ input_schema: None = None,
383
+ retry_policy: RetryPolicy | Sequence[RetryPolicy] | None = None,
384
+ cache_policy: CachePolicy | None = None,
385
+ error_handler: StateNode[Any, ContextT] | None = None,
386
+ destinations: dict[str, str] | tuple[str, ...] | None = None,
387
+ timeout: float | timedelta | TimeoutPolicy | None = None,
388
+ trace_policy: TracePolicy | None = None,
389
+ **kwargs: Unpack[DeprecatedKwargs],
390
+ ) -> Self:
391
+ """Add a new node to the `StateGraph`, input schema is inferred as the state schema.
392
+
393
+ Will take the name of the function/runnable as the node name.
394
+
395
+ Args:
396
+ node: The function or runnable this node will run.
397
+ defer: Whether to defer the execution of the node until the run is about to end.
398
+ metadata: The metadata associated with the node.
399
+ input_schema: The input schema for the node. (Default: the graph's state schema)
400
+ retry_policy: The retry policy for the node.
401
+
402
+ If a sequence is provided, the first matching policy will be applied.
403
+ cache_policy: The cache policy for the node.
404
+ destinations: Destinations that indicate where a node can route to.
405
+
406
+ Useful for edgeless graphs with nodes that return `Command` objects.
407
+
408
+ If a `dict` is provided, the keys will be used as the target node names and the values will be used as the labels for the edges.
409
+
410
+ If a `tuple` is provided, the values will be used as the target node names.
411
+
412
+ !!! warning
413
+
414
+ This is only used for graph rendering and doesn't have any effect on the graph execution.
415
+
416
+ Example:
417
+ ```python
418
+ from typing_extensions import TypedDict
419
+
420
+ from coco_core.runnables import RunnableConfig
421
+ from coco_graph.graph import START, StateGraph
422
+
423
+
424
+ class State(TypedDict):
425
+ x: int
426
+
427
+
428
+ def my_node(state: State, config: RunnableConfig) -> State:
429
+ return {"x": state["x"] + 1}
430
+
431
+
432
+ builder = StateGraph(State)
433
+ builder.add_node(my_node) # node name will be 'my_node'
434
+ builder.add_edge(START, "my_node")
435
+ graph = builder.compile()
436
+ graph.invoke({"x": 1})
437
+ # {'x': 2}
438
+ ```
439
+
440
+ Returns:
441
+ Self: The instance of the `StateGraph`, allowing for method chaining.
442
+ """
443
+ ...
444
+
445
+ @overload
446
+ def add_node(
447
+ self,
448
+ node: StateNode[NodeInputT, ContextT],
449
+ *,
450
+ defer: bool = False,
451
+ metadata: dict[str, Any] | None = None,
452
+ input_schema: type[NodeInputT],
453
+ retry_policy: RetryPolicy | Sequence[RetryPolicy] | None = None,
454
+ cache_policy: CachePolicy | None = None,
455
+ error_handler: StateNode[Any, ContextT] | None = None,
456
+ destinations: dict[str, str] | tuple[str, ...] | None = None,
457
+ timeout: float | timedelta | TimeoutPolicy | None = None,
458
+ trace_policy: TracePolicy | None = None,
459
+ **kwargs: Unpack[DeprecatedKwargs],
460
+ ) -> Self:
461
+ """Add a new node to the `StateGraph` where input schema is specified.
462
+
463
+ Will take the name of the function/runnable as the node name.
464
+
465
+ Args:
466
+ node: The function or runnable this node will run.
467
+ defer: Whether to defer the execution of the node until the run is about to end.
468
+ metadata: The metadata associated with the node.
469
+ input_schema: The input schema for the node.
470
+ retry_policy: The retry policy for the node.
471
+
472
+ If a sequence is provided, the first matching policy will be applied.
473
+ cache_policy: The cache policy for the node.
474
+ destinations: Destinations that indicate where a node can route to.
475
+
476
+ Useful for edgeless graphs with nodes that return `Command` objects.
477
+
478
+ If a `dict` is provided, the keys will be used as the target node names and the values will be used as the labels for the edges.
479
+
480
+ If a `tuple` is provided, the values will be used as the target node names.
481
+
482
+ !!! warning
483
+
484
+ This is only used for graph rendering and doesn't have any effect on the graph execution.
485
+
486
+ Example:
487
+ ```python
488
+ from typing_extensions import TypedDict
489
+
490
+ from coco_core.runnables import RunnableConfig
491
+ from coco_graph.graph import START, StateGraph
492
+
493
+
494
+ class State(TypedDict):
495
+ x: int
496
+
497
+
498
+ class NodeInput(TypedDict):
499
+ x: int
500
+
501
+
502
+ def my_node(state: NodeInput, config: RunnableConfig) -> State:
503
+ return {"x": state["x"] + 1}
504
+
505
+
506
+ builder = StateGraph(State)
507
+ builder.add_node(my_node, input_schema=NodeInput) # node name will be 'my_node'
508
+ builder.add_edge(START, "my_node")
509
+ graph = builder.compile()
510
+ graph.invoke({"x": 1})
511
+ # {'x': 2}
512
+ ```
513
+
514
+ Returns:
515
+ Self: The instance of the `StateGraph`, allowing for method chaining.
516
+ """
517
+ ...
518
+
519
+ @overload
520
+ def add_node(
521
+ self,
522
+ node: str,
523
+ action: StateNode[NodeInputT, ContextT],
524
+ *,
525
+ defer: bool = False,
526
+ metadata: dict[str, Any] | None = None,
527
+ input_schema: None = None,
528
+ retry_policy: RetryPolicy | Sequence[RetryPolicy] | None = None,
529
+ cache_policy: CachePolicy | None = None,
530
+ error_handler: StateNode[Any, ContextT] | None = None,
531
+ destinations: dict[str, str] | tuple[str, ...] | None = None,
532
+ timeout: float | timedelta | TimeoutPolicy | None = None,
533
+ trace_policy: TracePolicy | None = None,
534
+ **kwargs: Unpack[DeprecatedKwargs],
535
+ ) -> Self:
536
+ """Add a new node to the `StateGraph`, input schema is inferred as the state schema.
537
+
538
+ Args:
539
+ node: The name of the node.
540
+ action: The function or runnable this node will run.
541
+ defer: Whether to defer the execution of the node until the run is about to end.
542
+ metadata: The metadata associated with the node.
543
+ input_schema: The input schema for the node. (Default: the graph's state schema)
544
+ retry_policy: The retry policy for the node.
545
+
546
+ If a sequence is provided, the first matching policy will be applied.
547
+ cache_policy: The cache policy for the node.
548
+ destinations: Destinations that indicate where a node can route to.
549
+
550
+ Useful for edgeless graphs with nodes that return `Command` objects.
551
+
552
+ If a `dict` is provided, the keys will be used as the target node names and the values will be used as the labels for the edges.
553
+
554
+ If a `tuple` is provided, the values will be used as the target node names.
555
+
556
+ !!! warning
557
+
558
+ This is only used for graph rendering and doesn't have any effect on the graph execution.
559
+
560
+ Example:
561
+ ```python
562
+ from typing_extensions import TypedDict
563
+
564
+ from coco_core.runnables import RunnableConfig
565
+ from coco_graph.graph import START, StateGraph
566
+
567
+
568
+ class State(TypedDict):
569
+ x: int
570
+
571
+
572
+ def my_node(state: State, config: RunnableConfig) -> State:
573
+ return {"x": state["x"] + 1}
574
+
575
+
576
+ builder = StateGraph(State)
577
+ builder.add_node("my_fair_node", my_node)
578
+ builder.add_edge(START, "my_fair_node")
579
+ graph = builder.compile()
580
+ graph.invoke({"x": 1})
581
+ # {'x': 2}
582
+ ```
583
+
584
+ Returns:
585
+ Self: The instance of the `StateGraph`, allowing for method chaining.
586
+ """
587
+ ...
588
+
589
+ @overload
590
+ def add_node(
591
+ self,
592
+ node: str | StateNode[NodeInputT, ContextT],
593
+ action: StateNode[NodeInputT, ContextT] | None = None,
594
+ *,
595
+ defer: bool = False,
596
+ metadata: dict[str, Any] | None = None,
597
+ input_schema: type[NodeInputT],
598
+ retry_policy: RetryPolicy | Sequence[RetryPolicy] | None = None,
599
+ cache_policy: CachePolicy | None = None,
600
+ error_handler: StateNode[Any, ContextT] | None = None,
601
+ destinations: dict[str, str] | tuple[str, ...] | None = None,
602
+ timeout: float | timedelta | TimeoutPolicy | None = None,
603
+ trace_policy: TracePolicy | None = None,
604
+ **kwargs: Unpack[DeprecatedKwargs],
605
+ ) -> Self:
606
+ """Add a new node to the `StateGraph`, input schema is specified.
607
+
608
+ Args:
609
+ node: The function or runnable this node will run.
610
+
611
+ If a string is provided, it will be used as the node name, and action will be used as the function or runnable.
612
+ action: The action associated with the node.
613
+
614
+ Will be used as the node function or runnable if `node` is a string (node name).
615
+ defer: Whether to defer the execution of the node until the run is about to end.
616
+ metadata: The metadata associated with the node.
617
+ input_schema: The input schema for the node.
618
+ retry_policy: The retry policy for the node.
619
+
620
+ If a sequence is provided, the first matching policy will be applied.
621
+ cache_policy: The cache policy for the node.
622
+ destinations: Destinations that indicate where a node can route to.
623
+
624
+ Useful for edgeless graphs with nodes that return `Command` objects.
625
+
626
+ If a `dict` is provided, the keys will be used as the target node names and the values will be used as the labels for the edges.
627
+
628
+ If a `tuple` is provided, the values will be used as the target node names.
629
+
630
+ !!! warning
631
+
632
+ This is only used for graph rendering and doesn't have any effect on the graph execution.
633
+
634
+ Example:
635
+ ```python
636
+ from typing_extensions import TypedDict
637
+
638
+ from coco_core.runnables import RunnableConfig
639
+ from coco_graph.graph import START, StateGraph
640
+
641
+
642
+ class State(TypedDict):
643
+ x: int
644
+
645
+
646
+ class NodeInput(TypedDict):
647
+ x: int
648
+
649
+
650
+ def my_node(state: NodeInput, config: RunnableConfig) -> State:
651
+ return {"x": state["x"] + 1}
652
+
653
+
654
+ builder = StateGraph(State)
655
+ builder.add_node("my_fair_node", my_node, input_schema=NodeInput)
656
+ builder.add_edge(START, "my_fair_node")
657
+ graph = builder.compile()
658
+ graph.invoke({"x": 1})
659
+ # {'x': 2}
660
+ ```
661
+
662
+ Returns:
663
+ Self: The instance of the `StateGraph`, allowing for method chaining.
664
+ """
665
+ ...
666
+
667
+ def add_node(
668
+ self,
669
+ node: str | StateNode[NodeInputT, ContextT],
670
+ action: StateNode[NodeInputT, ContextT] | None = None,
671
+ *,
672
+ defer: bool = False,
673
+ metadata: dict[str, Any] | None = None,
674
+ input_schema: type[NodeInputT] | None = None,
675
+ retry_policy: RetryPolicy | Sequence[RetryPolicy] | None = None,
676
+ cache_policy: CachePolicy | None = None,
677
+ error_handler: StateNode[Any, ContextT] | None = None,
678
+ destinations: dict[str, str] | tuple[str, ...] | None = None,
679
+ timeout: float | timedelta | TimeoutPolicy | None = None,
680
+ trace_policy: TracePolicy | None = None,
681
+ **kwargs: Unpack[DeprecatedKwargs],
682
+ ) -> Self:
683
+ """Add a new node to the `StateGraph`.
684
+
685
+ Args:
686
+ node: The function or runnable this node will run.
687
+
688
+ If a string is provided, it will be used as the node name, and action will be used as the function or runnable.
689
+ action: The action associated with the node.
690
+
691
+ Will be used as the node function or runnable if `node` is a string (node name).
692
+ defer: Whether to defer the execution of the node until the run is about to end.
693
+ metadata: The metadata associated with the node.
694
+ input_schema: The input schema for the node. (Default: the graph's state schema)
695
+ retry_policy: The retry policy for the node.
696
+
697
+ If a sequence is provided, the first matching policy will be applied.
698
+ cache_policy: The cache policy for the node.
699
+ error_handler: Optional node-level error handler callable for this node.
700
+ trace_policy: Optional policy controlling how this node's run is traced. Its
701
+ `process_inputs` callable transforms the node's input before it is
702
+ recorded (e.g. to omit or summarize large message history) without
703
+ changing the value passed to the node. Does not affect execution.
704
+ destinations: Destinations that indicate where a node can route to.
705
+
706
+ Useful for edgeless graphs with nodes that return `Command` objects.
707
+
708
+ If a `dict` is provided, the keys will be used as the target node names and the values will be used as the labels for the edges.
709
+
710
+ If a `tuple` is provided, the values will be used as the target node names.
711
+
712
+ !!! warning
713
+
714
+ This is only used for graph rendering and doesn't have any effect on the graph execution.
715
+ timeout: Timeout for each node attempt. A number or `timedelta` is
716
+ a hard wall-clock cap and is not refreshed. Use `TimeoutPolicy`
717
+ to configure both a wall-clock `run_timeout` and an
718
+ `idle_timeout` refreshed by progress signals. When exceeded, a
719
+ [`NodeTimeoutError`][coco_graph.errors.NodeTimeoutError] is raised
720
+ and the retry policy (if any) decides whether to retry. Timeouts
721
+ are supported only for async nodes; sync nodes cannot be safely
722
+ cancelled in-process.
723
+
724
+ Example:
725
+ ```python
726
+ from typing_extensions import TypedDict
727
+
728
+ from coco_core.runnables import RunnableConfig
729
+ from coco_graph.graph import START, StateGraph
730
+
731
+
732
+ class State(TypedDict):
733
+ x: int
734
+
735
+
736
+ def my_node(state: State, config: RunnableConfig) -> State:
737
+ return {"x": state["x"] + 1}
738
+
739
+
740
+ builder = StateGraph(State)
741
+ builder.add_node(my_node) # node name will be 'my_node'
742
+ builder.add_edge(START, "my_node")
743
+ graph = builder.compile()
744
+ graph.invoke({"x": 1})
745
+ # {'x': 2}
746
+ ```
747
+
748
+ Example: Customize the name:
749
+ ```python
750
+ builder = StateGraph(State)
751
+ builder.add_node("my_fair_node", my_node)
752
+ builder.add_edge(START, "my_fair_node")
753
+ graph = builder.compile()
754
+ graph.invoke({"x": 1})
755
+ # {'x': 2}
756
+ ```
757
+
758
+ Returns:
759
+ Self: The instance of the `StateGraph`, allowing for method chaining.
760
+ """
761
+ if (retry := kwargs.get("retry", MISSING)) is not MISSING:
762
+ warnings.warn(
763
+ "`retry` is deprecated and will be removed. Please use `retry_policy` instead.",
764
+ category=CocoGraphDeprecatedSinceV05,
765
+ )
766
+ if retry_policy is None:
767
+ retry_policy = retry # type: ignore[assignment]
768
+
769
+ if (input_ := kwargs.get("input", MISSING)) is not MISSING:
770
+ warnings.warn(
771
+ "`input` is deprecated and will be removed. Please use `input_schema` instead.",
772
+ category=CocoGraphDeprecatedSinceV05,
773
+ )
774
+ if input_schema is None:
775
+ input_schema = cast(type[NodeInputT] | None, input_)
776
+ timeout = coerce_timeout_policy(timeout)
777
+
778
+ if not isinstance(node, str):
779
+ action = node
780
+ if isinstance(action, Runnable):
781
+ node = action.get_name()
782
+ else:
783
+ node = getattr(action, "__name__", action.__class__.__name__)
784
+ if node is None:
785
+ raise ValueError(
786
+ "Node name must be provided if action is not a function"
787
+ )
788
+ if self.compiled:
789
+ logger.warning(
790
+ "Adding a node to a graph that has already been compiled. This will "
791
+ "not be reflected in the compiled graph."
792
+ )
793
+ if not isinstance(node, str):
794
+ action = node
795
+ node = cast(str, getattr(action, "name", getattr(action, "__name__", None)))
796
+ if node is None:
797
+ raise ValueError(
798
+ "Node name must be provided if action is not a function"
799
+ )
800
+ if action is None:
801
+ raise RuntimeError
802
+ if node in self.nodes:
803
+ raise ValueError(f"Node `{node}` already present.")
804
+ if node == END or node == START:
805
+ raise ValueError(f"Node `{node}` is reserved.")
806
+
807
+ for character in (NS_SEP, NS_END):
808
+ if character in node:
809
+ raise ValueError(
810
+ f"'{character}' is a reserved character and is not allowed in the node names."
811
+ )
812
+
813
+ inferred_input_schema = None
814
+
815
+ ends: tuple[str, ...] | dict[str, str] = EMPTY_SEQ
816
+ try:
817
+ if (
818
+ isfunction(action)
819
+ or ismethod(action)
820
+ or ismethod(getattr(action, "__call__", None))
821
+ ) and (
822
+ hints := get_type_hints(getattr(action, "__call__"))
823
+ or get_type_hints(action)
824
+ ):
825
+ if input_schema is None:
826
+ first_parameter_name = next(
827
+ iter(
828
+ inspect.signature(
829
+ cast(FunctionType, action)
830
+ ).parameters.keys()
831
+ )
832
+ )
833
+ if input_hint := hints.get(first_parameter_name):
834
+ if isinstance(input_hint, type) and get_type_hints(input_hint):
835
+ inferred_input_schema = input_hint
836
+ if rtn := hints.get("return"):
837
+ # Handle Union types
838
+ rtn_origin = get_origin(rtn)
839
+ if rtn_origin is Union:
840
+ rtn_args = get_args(rtn)
841
+ # Look for Command in the union
842
+ for arg in rtn_args:
843
+ arg_origin = get_origin(arg)
844
+ if arg_origin is Command:
845
+ rtn = arg
846
+ rtn_origin = arg_origin
847
+ break
848
+
849
+ # Check if it's a Command type
850
+ if (
851
+ rtn_origin is Command
852
+ and (rargs := get_args(rtn))
853
+ and get_origin(rargs[0]) is Literal
854
+ and (vals := get_args(rargs[0]))
855
+ ):
856
+ ends = vals
857
+ except (NameError, TypeError, StopIteration):
858
+ pass
859
+
860
+ if destinations is not None:
861
+ ends = destinations
862
+
863
+ resolved_input_schema: type[Any] = (
864
+ input_schema or inferred_input_schema or self.state_schema
865
+ )
866
+ handler_node_name: str | None = None
867
+ if error_handler is not None:
868
+ handler_node_name = f"__error_handler__{node}"
869
+ if handler_node_name in self.nodes:
870
+ raise ValueError(
871
+ f"Auto-generated error handler node `{handler_node_name}` already exists."
872
+ )
873
+ self.nodes[handler_node_name] = StateNodeSpec[Any, ContextT](
874
+ coerce_to_runnable(error_handler, name=handler_node_name, trace=False), # type: ignore[arg-type]
875
+ metadata=None,
876
+ input_schema=resolved_input_schema,
877
+ retry_policy=None,
878
+ cache_policy=None,
879
+ is_error_handler=True,
880
+ )
881
+
882
+ if input_schema is not None:
883
+ self.nodes[node] = StateNodeSpec[NodeInputT, ContextT](
884
+ coerce_to_runnable(action, name=node, trace=False), # type: ignore[arg-type]
885
+ metadata,
886
+ input_schema=input_schema,
887
+ retry_policy=retry_policy,
888
+ cache_policy=cache_policy,
889
+ error_handler_node=handler_node_name,
890
+ ends=ends,
891
+ defer=defer,
892
+ timeout=timeout,
893
+ trace_policy=trace_policy,
894
+ )
895
+ elif inferred_input_schema is not None:
896
+ self.nodes[node] = StateNodeSpec(
897
+ coerce_to_runnable(action, name=node, trace=False), # type: ignore[arg-type]
898
+ metadata,
899
+ input_schema=inferred_input_schema,
900
+ retry_policy=retry_policy,
901
+ cache_policy=cache_policy,
902
+ error_handler_node=handler_node_name,
903
+ ends=ends,
904
+ defer=defer,
905
+ timeout=timeout,
906
+ trace_policy=trace_policy,
907
+ )
908
+ else:
909
+ self.nodes[node] = StateNodeSpec[StateT, ContextT](
910
+ coerce_to_runnable(action, name=node, trace=False), # type: ignore[arg-type]
911
+ metadata,
912
+ input_schema=self.state_schema,
913
+ retry_policy=retry_policy,
914
+ cache_policy=cache_policy,
915
+ error_handler_node=handler_node_name,
916
+ ends=ends,
917
+ defer=defer,
918
+ timeout=timeout,
919
+ trace_policy=trace_policy,
920
+ )
921
+
922
+ input_schema = input_schema or inferred_input_schema
923
+ if input_schema is not None:
924
+ self._add_schema(input_schema)
925
+
926
+ return self
927
+
928
+ def add_edge(self, start_key: str | list[str], end_key: str) -> Self:
929
+ """Add a directed edge from the start node (or list of start nodes) to the end node.
930
+
931
+ When a single start node is provided, the graph will wait for that node to complete
932
+ before executing the end node. When multiple start nodes are provided,
933
+ the graph will wait for ALL of the start nodes to complete before executing the end node.
934
+
935
+ Args:
936
+ start_key: The key(s) of the start node(s) of the edge.
937
+ end_key: The key of the end node of the edge.
938
+
939
+ Raises:
940
+ ValueError: If the start key is `'END'` or if the start key or end key is not present in the graph.
941
+
942
+ Returns:
943
+ Self: The instance of the `StateGraph`, allowing for method chaining.
944
+ """
945
+ if self.compiled:
946
+ logger.warning(
947
+ "Adding an edge to a graph that has already been compiled. This will "
948
+ "not be reflected in the compiled graph."
949
+ )
950
+
951
+ if isinstance(start_key, str):
952
+ if start_key == END:
953
+ raise ValueError("END cannot be a start node")
954
+ if end_key == START:
955
+ raise ValueError("START cannot be an end node")
956
+
957
+ # run this validation only for non-StateGraph graphs
958
+ if not hasattr(self, "channels") and start_key in set(
959
+ start for start, _ in self.edges
960
+ ):
961
+ raise ValueError(
962
+ f"Already found path for node '{start_key}'.\n"
963
+ "For multiple edges, use StateGraph with an Annotated state key."
964
+ )
965
+
966
+ self.edges.add((start_key, end_key))
967
+ return self
968
+
969
+ for start in start_key:
970
+ if start == END:
971
+ raise ValueError("END cannot be a start node")
972
+ if start not in self.nodes:
973
+ raise ValueError(f"Need to add_node `{start}` first")
974
+ if end_key == START:
975
+ raise ValueError("START cannot be an end node")
976
+ if end_key != END and end_key not in self.nodes:
977
+ raise ValueError(f"Need to add_node `{end_key}` first")
978
+
979
+ self.waiting_edges.add((tuple(start_key), end_key))
980
+ return self
981
+
982
+ def add_conditional_edges(
983
+ self,
984
+ source: str,
985
+ path: Callable[..., Hashable | Sequence[Hashable]]
986
+ | Callable[..., Awaitable[Hashable | Sequence[Hashable]]]
987
+ | Runnable[Any, Hashable | Sequence[Hashable]],
988
+ path_map: dict[Hashable, str] | list[str] | None = None,
989
+ ) -> Self:
990
+ """Add a conditional edge from the starting node to any number of destination nodes.
991
+
992
+ Args:
993
+ source: The starting node. This conditional edge will run when
994
+ exiting this node.
995
+ path: The callable that determines the next node or nodes.
996
+
997
+ If not specifying `path_map` it should return one or more nodes.
998
+
999
+ If it returns `'END'`, the graph will stop execution.
1000
+ path_map: Optional mapping of paths to node names.
1001
+
1002
+ If omitted the paths returned by `path` should be node names.
1003
+
1004
+ Returns:
1005
+ Self: The instance of the graph, allowing for method chaining.
1006
+
1007
+ !!! warning
1008
+ Without type hints on the `path` function's return value (e.g., `-> Literal["foo", "__end__"]:`)
1009
+ or a path_map, the graph visualization assumes the edge could transition to any node in the graph.
1010
+
1011
+ """
1012
+ if self.compiled:
1013
+ logger.warning(
1014
+ "Adding an edge to a graph that has already been compiled. This will "
1015
+ "not be reflected in the compiled graph."
1016
+ )
1017
+
1018
+ # find a name for the condition
1019
+ path = coerce_to_runnable(path, name=None, trace=True)
1020
+ name = path.name or "condition"
1021
+ # validate the condition
1022
+ if name in self.branches[source]:
1023
+ raise ValueError(
1024
+ f"Branch with name `{path.name}` already exists for node `{source}`"
1025
+ )
1026
+ # save it
1027
+ self.branches[source][name] = BranchSpec.from_path(path, path_map, True)
1028
+ if schema := self.branches[source][name].input_schema:
1029
+ self._add_schema(schema)
1030
+ return self
1031
+
1032
+ def add_sequence(
1033
+ self,
1034
+ nodes: Sequence[
1035
+ StateNode[NodeInputT, ContextT]
1036
+ | tuple[str, StateNode[NodeInputT, ContextT]]
1037
+ ],
1038
+ ) -> Self:
1039
+ """Add a sequence of nodes that will be executed in the provided order.
1040
+
1041
+ Args:
1042
+ nodes: A sequence of `StateNode` (callables that accept a `state` arg) or `(name, StateNode)` tuples.
1043
+
1044
+ If no names are provided, the name will be inferred from the node object (e.g. a `Runnable` or a `Callable` name).
1045
+
1046
+ Each node will be executed in the order provided.
1047
+
1048
+ Raises:
1049
+ ValueError: If the sequence is empty.
1050
+ ValueError: If the sequence contains duplicate node names.
1051
+
1052
+ Returns:
1053
+ Self: The instance of the `StateGraph`, allowing for method chaining.
1054
+ """
1055
+ if len(nodes) < 1:
1056
+ raise ValueError("Sequence requires at least one node.")
1057
+
1058
+ previous_name: str | None = None
1059
+ for node in nodes:
1060
+ if isinstance(node, tuple) and len(node) == 2:
1061
+ name, node = node
1062
+ else:
1063
+ name = _get_node_name(node)
1064
+
1065
+ if name in self.nodes:
1066
+ raise ValueError(
1067
+ f"Node names must be unique: node with the name '{name}' already exists. "
1068
+ "If you need to use two different runnables/callables with the same name (for example, using `lambda`), please provide them as tuples (name, runnable/callable)."
1069
+ )
1070
+
1071
+ self.add_node(name, node)
1072
+ if previous_name is not None:
1073
+ self.add_edge(previous_name, name)
1074
+
1075
+ previous_name = name
1076
+
1077
+ return self
1078
+
1079
+ def set_entry_point(self, key: str) -> Self:
1080
+ """Specifies the first node to be called in the graph.
1081
+
1082
+ Equivalent to calling `add_edge(START, key)`.
1083
+
1084
+ Parameters:
1085
+ key (str): The key of the node to set as the entry point.
1086
+
1087
+ Returns:
1088
+ Self: The instance of the graph, allowing for method chaining.
1089
+ """
1090
+ return self.add_edge(START, key)
1091
+
1092
+ def set_conditional_entry_point(
1093
+ self,
1094
+ path: Callable[..., Hashable | Sequence[Hashable]]
1095
+ | Callable[..., Awaitable[Hashable | Sequence[Hashable]]]
1096
+ | Runnable[Any, Hashable | Sequence[Hashable]],
1097
+ path_map: dict[Hashable, str] | list[str] | None = None,
1098
+ ) -> Self:
1099
+ """Sets a conditional entry point in the graph.
1100
+
1101
+ Args:
1102
+ path: The callable that determines the next node or nodes.
1103
+
1104
+ If not specifying `path_map` it should return one or more nodes.
1105
+
1106
+ If it returns END, the graph will stop execution.
1107
+ path_map: Optional mapping of paths to node names.
1108
+
1109
+ If omitted the paths returned by `path` should be node names.
1110
+
1111
+ Returns:
1112
+ Self: The instance of the graph, allowing for method chaining.
1113
+ """
1114
+ return self.add_conditional_edges(START, path, path_map)
1115
+
1116
+ def set_finish_point(self, key: str) -> Self:
1117
+ """Marks a node as a finish point of the graph.
1118
+
1119
+ If the graph reaches this node, it will cease execution.
1120
+
1121
+ Parameters:
1122
+ key (str): The key of the node to set as the finish point.
1123
+
1124
+ Returns:
1125
+ Self: The instance of the graph, allowing for method chaining.
1126
+ """
1127
+ return self.add_edge(key, END)
1128
+
1129
+ def validate(self, interrupt: Sequence[str] | None = None) -> Self:
1130
+ # assemble sources
1131
+ all_sources = {src for src, _ in self._all_edges}
1132
+ for start, branches in self.branches.items():
1133
+ all_sources.add(start)
1134
+ for name, spec in self.nodes.items():
1135
+ if spec.ends:
1136
+ all_sources.add(name)
1137
+ # validate sources
1138
+ for source in all_sources:
1139
+ if source not in self.nodes and source != START:
1140
+ raise ValueError(f"Found edge starting at unknown node '{source}'")
1141
+
1142
+ if START not in all_sources:
1143
+ raise ValueError(
1144
+ "Graph must have an entrypoint: add at least one edge from START to another node"
1145
+ )
1146
+
1147
+ # assemble targets
1148
+ all_targets = {end for _, end in self._all_edges}
1149
+ for start, branches in self.branches.items():
1150
+ for cond, branch in branches.items():
1151
+ if branch.ends is not None:
1152
+ for end in branch.ends.values():
1153
+ if end not in self.nodes and end != END:
1154
+ raise ValueError(
1155
+ f"At '{start}' node, '{cond}' branch found unknown target '{end}'"
1156
+ )
1157
+ all_targets.add(end)
1158
+ else:
1159
+ all_targets.add(END)
1160
+ for node in self.nodes:
1161
+ if node != start:
1162
+ all_targets.add(node)
1163
+ for name, spec in self.nodes.items():
1164
+ if spec.ends:
1165
+ all_targets.update(spec.ends)
1166
+ for target in all_targets:
1167
+ if target not in self.nodes and target != END:
1168
+ raise ValueError(f"Found edge ending at unknown node `{target}`")
1169
+ # validate interrupts
1170
+ if interrupt:
1171
+ for node in interrupt:
1172
+ if node not in self.nodes:
1173
+ raise ValueError(f"Interrupt node `{node}` not found")
1174
+ self.compiled = True
1175
+ return self
1176
+
1177
+ def compile(
1178
+ self,
1179
+ checkpointer: Checkpointer = None,
1180
+ *,
1181
+ cache: BaseCache | None = None,
1182
+ store: BaseStore | None = None,
1183
+ interrupt_before: All | list[str] | None = None,
1184
+ interrupt_after: All | list[str] | None = None,
1185
+ debug: bool = False,
1186
+ name: str | None = None,
1187
+ transformers: Sequence[Callable[[tuple[str, ...]], Any]] | None = None,
1188
+ ) -> CompiledStateGraph[StateT, ContextT, InputT, OutputT]:
1189
+ """Compiles the `StateGraph` into a `CompiledStateGraph` object.
1190
+
1191
+ The compiled graph implements the `Runnable` interface and can be invoked,
1192
+ streamed, batched, and run asynchronously.
1193
+
1194
+ Args:
1195
+ checkpointer: A checkpoint saver object or flag.
1196
+
1197
+ If provided, this `Checkpointer` serves as a fully versioned "short-term memory" for the graph,
1198
+ allowing it to be paused, resumed, and replayed from any point.
1199
+
1200
+ If `None`, it may inherit the parent graph's checkpointer when used as a subgraph.
1201
+
1202
+ If `False`, it will not use or inherit any checkpointer.
1203
+
1204
+ **Important**: When a checkpointer is enabled, you should pass a `thread_id`
1205
+ in the config when invoking the graph:
1206
+
1207
+ ```python
1208
+ config = {"configurable": {"thread_id": "my-thread"}}
1209
+ graph.invoke(inputs, config)
1210
+ ```
1211
+
1212
+ The `thread_id` is the key used to store and retrieve checkpoints. Use a
1213
+ unique ID for independent runs, or reuse the same ID to accumulate state
1214
+ across invocations (e.g., for conversation memory).
1215
+
1216
+ interrupt_before: An optional list of node names to interrupt before.
1217
+ interrupt_after: An optional list of node names to interrupt after.
1218
+ debug: A flag indicating whether to enable debug mode.
1219
+ name: The name to use for the compiled graph.
1220
+ transformers: Optional sequence of `StreamTransformer` classes or
1221
+ configured factories. Classes and factories are instantiated
1222
+ per run whenever `stream_events(version="v3")` / `astream_events(version="v3")` is called and are
1223
+ propagated to subgraph scopes. Custom factories should follow
1224
+ the standard `StreamTransformer` constructor shape by
1225
+ accepting `scope` as their first argument. Appended after the
1226
+ built-in stream transformers.
1227
+
1228
+ Returns:
1229
+ CompiledStateGraph: The compiled `StateGraph`.
1230
+ """
1231
+ checkpointer = ensure_valid_checkpointer(checkpointer)
1232
+
1233
+ serde_allowlist: set[tuple[str, ...]] | None = None
1234
+ if _serde.STRICT_MSGPACK_ENABLED:
1235
+ schema_types: list[type[Any]] = [
1236
+ self.state_schema,
1237
+ self.input_schema,
1238
+ self.output_schema,
1239
+ ]
1240
+ if self.context_schema is not None:
1241
+ schema_types.append(self.context_schema)
1242
+ for node in self.nodes.values():
1243
+ schema_types.append(node.input_schema)
1244
+ for branches in self.branches.values():
1245
+ for branch in branches.values():
1246
+ if branch.input_schema is not None:
1247
+ schema_types.append(branch.input_schema)
1248
+ serde_allowlist = _serde.build_serde_allowlist(
1249
+ schemas=schema_types,
1250
+ channels=self.channels,
1251
+ )
1252
+ checkpointer = _serde.apply_checkpointer_allowlist(
1253
+ checkpointer, serde_allowlist
1254
+ )
1255
+
1256
+ # assign default values
1257
+ interrupt_before = interrupt_before or []
1258
+ interrupt_after = interrupt_after or []
1259
+
1260
+ # validate the graph
1261
+ self.validate(
1262
+ interrupt=(
1263
+ (interrupt_before if interrupt_before != "*" else []) + interrupt_after
1264
+ if interrupt_after != "*"
1265
+ else []
1266
+ )
1267
+ )
1268
+
1269
+ # prepare output channels
1270
+ output_channels = (
1271
+ "__root__"
1272
+ if len(self.schemas[self.output_schema]) == 1
1273
+ and "__root__" in self.schemas[self.output_schema]
1274
+ else [
1275
+ key
1276
+ for key, val in self.schemas[self.output_schema].items()
1277
+ if not is_managed_value(val)
1278
+ ]
1279
+ )
1280
+ stream_channels = (
1281
+ "__root__"
1282
+ if len(self.channels) == 1 and "__root__" in self.channels
1283
+ else [
1284
+ key for key, val in self.channels.items() if not is_managed_value(val)
1285
+ ]
1286
+ )
1287
+ # Apply builder defaults to node specs. Per-node values always win.
1288
+ # Error-handler routing and cache_policy are only assigned to regular
1289
+ # nodes. Retry and timeout defaults also apply to error-handler nodes.
1290
+ defaults = self._node_defaults
1291
+ default_handler_name: str | None = None
1292
+ if defaults.error_handler is not None:
1293
+ if _DEFAULT_ERROR_HANDLER_NODE in self.nodes:
1294
+ raise ValueError(
1295
+ f"Auto-generated default error handler node "
1296
+ f"`{_DEFAULT_ERROR_HANDLER_NODE}` already exists."
1297
+ )
1298
+ default_handler_name = _DEFAULT_ERROR_HANDLER_NODE
1299
+ self.nodes[default_handler_name] = StateNodeSpec[Any, ContextT](
1300
+ coerce_to_runnable(
1301
+ defaults.error_handler, # type: ignore[arg-type]
1302
+ name=default_handler_name,
1303
+ trace=False,
1304
+ ),
1305
+ metadata=None,
1306
+ input_schema=self.state_schema,
1307
+ retry_policy=None,
1308
+ cache_policy=None,
1309
+ is_error_handler=True,
1310
+ )
1311
+
1312
+ # Apply builder defaults to node specs. Per-node values always win.
1313
+ for spec in self.nodes.values():
1314
+ # error_handler: regular nodes only — handlers must never
1315
+ # catch themselves or other handlers.
1316
+ if (
1317
+ not spec.is_error_handler
1318
+ and default_handler_name is not None
1319
+ and spec.error_handler_node is None
1320
+ ):
1321
+ spec.error_handler_node = default_handler_name
1322
+ # retry: all nodes — handlers should be retried on transient
1323
+ # failures just like regular nodes.
1324
+ if defaults.retry_policy is not None and spec.retry_policy is None:
1325
+ spec.retry_policy = defaults.retry_policy
1326
+ # cache: regular nodes only — caching an error-handler result
1327
+ # is unsafe because the input (failed-node state) may differ
1328
+ # across failures even when the cache key matches.
1329
+ if (
1330
+ not spec.is_error_handler
1331
+ and defaults.cache_policy is not None
1332
+ and spec.cache_policy is None
1333
+ ):
1334
+ spec.cache_policy = defaults.cache_policy
1335
+ # timeout: all nodes — a stuck handler should be cancelled the
1336
+ # same way a stuck regular node would be.
1337
+ if defaults.timeout is not None and spec.timeout is None:
1338
+ spec.timeout = defaults.timeout
1339
+
1340
+ node_error_handler_map = {
1341
+ node_name: spec.error_handler_node
1342
+ for node_name, spec in self.nodes.items()
1343
+ if not spec.is_error_handler and spec.error_handler_node is not None
1344
+ }
1345
+
1346
+ compiled = CompiledStateGraph[StateT, ContextT, InputT, OutputT](
1347
+ builder=self,
1348
+ schema_to_mapper={},
1349
+ context_schema=self.context_schema,
1350
+ nodes={},
1351
+ channels={
1352
+ **self.channels,
1353
+ **self.managed,
1354
+ START: EphemeralValue(self.input_schema),
1355
+ },
1356
+ input_channels=START,
1357
+ stream_mode="updates",
1358
+ output_channels=output_channels,
1359
+ stream_channels=stream_channels,
1360
+ checkpointer=checkpointer,
1361
+ interrupt_before_nodes=interrupt_before,
1362
+ interrupt_after_nodes=interrupt_after,
1363
+ auto_validate=False,
1364
+ debug=debug,
1365
+ store=store,
1366
+ cache=cache,
1367
+ node_error_handler_map=node_error_handler_map,
1368
+ name=name or "CocoGraph",
1369
+ stream_transformers=transformers,
1370
+ )
1371
+ compiled._serde_allowlist = serde_allowlist
1372
+
1373
+ compiled.attach_node(START, None)
1374
+ for key, node in self.nodes.items():
1375
+ compiled.attach_node(key, node)
1376
+
1377
+ # Record output/state mappers for v2 stream coercion (pydantic/dataclass only)
1378
+ compiled._output_mapper = _pick_mapper(
1379
+ list(output_channels)
1380
+ if isinstance(output_channels, list)
1381
+ else [output_channels],
1382
+ self.output_schema,
1383
+ )
1384
+ compiled._state_mapper = _pick_mapper(
1385
+ list(stream_channels)
1386
+ if isinstance(stream_channels, list)
1387
+ else [stream_channels],
1388
+ self.state_schema,
1389
+ )
1390
+
1391
+ for start, end in self.edges:
1392
+ compiled.attach_edge(start, end)
1393
+
1394
+ for starts, end in self.waiting_edges:
1395
+ compiled.attach_edge(starts, end)
1396
+
1397
+ for start, branches in self.branches.items():
1398
+ for name, branch in branches.items():
1399
+ compiled.attach_branch(start, name, branch)
1400
+
1401
+ return compiled.validate()
1402
+
1403
+
1404
+ class CompiledStateGraph(
1405
+ Pregel[StateT, ContextT, InputT, OutputT],
1406
+ Generic[StateT, ContextT, InputT, OutputT],
1407
+ ):
1408
+ builder: StateGraph[StateT, ContextT, InputT, OutputT]
1409
+ schema_to_mapper: dict[type[Any], Callable[[Any], Any] | None]
1410
+ _output_mapper: Callable[[Any], Any] | None
1411
+ _state_mapper: Callable[[Any], Any] | None
1412
+
1413
+ def __init__(
1414
+ self,
1415
+ *,
1416
+ builder: StateGraph[StateT, ContextT, InputT, OutputT],
1417
+ schema_to_mapper: dict[type[Any], Callable[[Any], Any] | None],
1418
+ **kwargs: Any,
1419
+ ) -> None:
1420
+ super().__init__(**kwargs)
1421
+ self.builder = builder
1422
+ self.schema_to_mapper = schema_to_mapper
1423
+
1424
+ def get_input_jsonschema(
1425
+ self, config: RunnableConfig | None = None
1426
+ ) -> dict[str, Any]:
1427
+ return _get_json_schema(
1428
+ typ=self.builder.input_schema,
1429
+ schemas=self.builder.schemas,
1430
+ channels=self.builder.channels,
1431
+ name=self.get_name("Input"),
1432
+ )
1433
+
1434
+ def get_output_jsonschema(
1435
+ self, config: RunnableConfig | None = None
1436
+ ) -> dict[str, Any]:
1437
+ return _get_json_schema(
1438
+ typ=self.builder.output_schema,
1439
+ schemas=self.builder.schemas,
1440
+ channels=self.builder.channels,
1441
+ name=self.get_name("Output"),
1442
+ )
1443
+
1444
+ def attach_node(self, key: str, node: StateNodeSpec[Any, ContextT] | None) -> None:
1445
+ if key == START:
1446
+ output_keys = [
1447
+ k
1448
+ for k, v in self.builder.schemas[self.builder.input_schema].items()
1449
+ if not is_managed_value(v)
1450
+ ]
1451
+ else:
1452
+ output_keys = list(self.builder.channels) + [
1453
+ k for k, v in self.builder.managed.items()
1454
+ ]
1455
+
1456
+ def _get_updates(
1457
+ input: None | dict | Any,
1458
+ ) -> Sequence[tuple[str, Any]] | None:
1459
+ if input is None:
1460
+ return None
1461
+ elif isinstance(input, dict):
1462
+ return [(k, v) for k, v in input.items() if k in output_keys]
1463
+ elif isinstance(input, Command):
1464
+ if input.graph == Command.PARENT:
1465
+ return None
1466
+ return [
1467
+ (k, v) for k, v in input._update_as_tuples() if k in output_keys
1468
+ ]
1469
+ elif (
1470
+ isinstance(input, (list, tuple))
1471
+ and input
1472
+ and any(isinstance(i, Command) for i in input)
1473
+ ):
1474
+ updates: list[tuple[str, Any]] = []
1475
+ for i in input:
1476
+ if isinstance(i, Command):
1477
+ if i.graph == Command.PARENT:
1478
+ continue
1479
+ updates.extend(
1480
+ (k, v) for k, v in i._update_as_tuples() if k in output_keys
1481
+ )
1482
+ else:
1483
+ updates.extend(_get_updates(i) or ())
1484
+ return updates
1485
+ elif (t := type(input)) and get_cached_annotated_keys(t):
1486
+ return get_update_as_tuples(input, output_keys)
1487
+ else:
1488
+ msg = create_error_message(
1489
+ message=f"Expected dict, got {input}",
1490
+ error_code=ErrorCode.INVALID_GRAPH_NODE_RETURN_VALUE,
1491
+ )
1492
+ raise InvalidUpdateError(msg)
1493
+
1494
+ # state updaters
1495
+ write_entries: tuple[ChannelWriteEntry | ChannelWriteTupleEntry, ...] = (
1496
+ ChannelWriteTupleEntry(
1497
+ mapper=_get_root if output_keys == ["__root__"] else _get_updates
1498
+ ),
1499
+ ChannelWriteTupleEntry(
1500
+ mapper=_control_branch,
1501
+ static=_control_static(node.ends)
1502
+ if node is not None and node.ends is not None
1503
+ else None,
1504
+ ),
1505
+ )
1506
+
1507
+ # add node and output channel
1508
+ if key == START:
1509
+ self.nodes[key] = PregelNode(
1510
+ tags=[TAG_HIDDEN],
1511
+ triggers=[START],
1512
+ channels=START,
1513
+ writers=[ChannelWrite(write_entries)],
1514
+ )
1515
+ elif node is not None:
1516
+ input_schema = node.input_schema if node else self.builder.state_schema
1517
+ input_channels = list(self.builder.schemas[input_schema])
1518
+ is_single_input = len(input_channels) == 1 and "__root__" in input_channels
1519
+ if input_schema in self.schema_to_mapper:
1520
+ mapper = self.schema_to_mapper[input_schema]
1521
+ else:
1522
+ mapper = _pick_mapper(input_channels, input_schema)
1523
+ self.schema_to_mapper[input_schema] = mapper
1524
+
1525
+ branch_channel = _CHANNEL_BRANCH_TO.format(key)
1526
+ self.channels[branch_channel] = (
1527
+ LastValueAfterFinish(Any)
1528
+ if node.defer
1529
+ else EphemeralValue(Any, guard=False)
1530
+ )
1531
+ self.nodes[key] = PregelNode(
1532
+ triggers=[branch_channel],
1533
+ # read state keys and managed values
1534
+ channels=("__root__" if is_single_input else input_channels),
1535
+ # coerce state dict to schema class (eg. pydantic model)
1536
+ mapper=mapper,
1537
+ # publish to state keys
1538
+ writers=[ChannelWrite(write_entries)],
1539
+ metadata=node.metadata,
1540
+ retry_policy=node.retry_policy,
1541
+ cache_policy=node.cache_policy,
1542
+ is_error_handler=node.is_error_handler,
1543
+ error_handler_node=node.error_handler_node,
1544
+ bound=node.runnable, # type: ignore[arg-type]
1545
+ timeout=node.timeout,
1546
+ trace_policy=node.trace_policy,
1547
+ )
1548
+ else:
1549
+ raise RuntimeError
1550
+
1551
+ def attach_edge(self, starts: str | Sequence[str], end: str) -> None:
1552
+ if isinstance(starts, str):
1553
+ # subscribe to start channel
1554
+ if end != END:
1555
+ self.nodes[starts].writers.append(
1556
+ ChannelWrite(
1557
+ (ChannelWriteEntry(_CHANNEL_BRANCH_TO.format(end), None),)
1558
+ )
1559
+ )
1560
+ elif end != END:
1561
+ channel_name = f"join:{'+'.join(starts)}:{end}"
1562
+ # register channel
1563
+ if self.builder.nodes[end].defer:
1564
+ self.channels[channel_name] = NamedBarrierValueAfterFinish(
1565
+ str, set(starts)
1566
+ )
1567
+ else:
1568
+ self.channels[channel_name] = NamedBarrierValue(str, set(starts))
1569
+ # subscribe to channel
1570
+ self.nodes[end].triggers.append(channel_name)
1571
+ # publish to channel
1572
+ for start in starts:
1573
+ self.nodes[start].writers.append(
1574
+ ChannelWrite((ChannelWriteEntry(channel_name, start),))
1575
+ )
1576
+
1577
+ def attach_branch(
1578
+ self, start: str, name: str, branch: BranchSpec, *, with_reader: bool = True
1579
+ ) -> None:
1580
+ def get_writes(
1581
+ packets: Sequence[str | Send], static: bool = False
1582
+ ) -> Sequence[ChannelWriteEntry | Send]:
1583
+ writes = [
1584
+ (
1585
+ ChannelWriteEntry(
1586
+ p if p == END else _CHANNEL_BRANCH_TO.format(p), None
1587
+ )
1588
+ if not isinstance(p, Send)
1589
+ else p
1590
+ )
1591
+ for p in packets
1592
+ if (True if static else p != END)
1593
+ ]
1594
+ if not writes:
1595
+ return []
1596
+ return writes
1597
+
1598
+ if with_reader:
1599
+ # get schema
1600
+ schema = branch.input_schema or (
1601
+ self.builder.nodes[start].input_schema
1602
+ if start in self.builder.nodes
1603
+ else self.builder.state_schema
1604
+ )
1605
+ channels = list(self.builder.schemas[schema])
1606
+ # get mapper
1607
+ if schema in self.schema_to_mapper:
1608
+ mapper = self.schema_to_mapper[schema]
1609
+ else:
1610
+ mapper = _pick_mapper(channels, schema)
1611
+ self.schema_to_mapper[schema] = mapper
1612
+ # create reader
1613
+ reader: Callable[[RunnableConfig], Any] | None = partial(
1614
+ ChannelRead.do_read,
1615
+ select=channels[0] if channels == ["__root__"] else channels,
1616
+ fresh=True,
1617
+ # coerce state dict to schema class (eg. pydantic model)
1618
+ mapper=mapper,
1619
+ )
1620
+ else:
1621
+ reader = None
1622
+
1623
+ # attach branch publisher
1624
+ self.nodes[start].writers.append(branch.run(get_writes, reader))
1625
+
1626
+ def _migrate_checkpoint(self, checkpoint: Checkpoint) -> None:
1627
+ """Migrate a checkpoint to new channel layout."""
1628
+ super()._migrate_checkpoint(checkpoint)
1629
+
1630
+ values = checkpoint["channel_values"]
1631
+ versions = checkpoint["channel_versions"]
1632
+ seen = checkpoint["versions_seen"]
1633
+
1634
+ # empty checkpoints do not need migration
1635
+ if not versions:
1636
+ return
1637
+
1638
+ # current version
1639
+ if checkpoint["v"] >= 3:
1640
+ return
1641
+
1642
+ # Migrate from start:node to branch:to:node
1643
+ for k in list(versions):
1644
+ if k.startswith("start:"):
1645
+ # confirm node is present
1646
+ node = k.split(":")[1]
1647
+ if node not in self.nodes:
1648
+ continue
1649
+ # get next version
1650
+ new_k = f"branch:to:{node}"
1651
+ new_v = (
1652
+ max(versions[new_k], versions.pop(k))
1653
+ if new_k in versions
1654
+ else versions.pop(k)
1655
+ )
1656
+ # update seen
1657
+ for ss in (seen.get(node, {}), seen.get(INTERRUPT, {})):
1658
+ if k in ss:
1659
+ s = ss.pop(k)
1660
+ if new_k in ss:
1661
+ ss[new_k] = max(s, ss[new_k])
1662
+ else:
1663
+ ss[new_k] = s
1664
+ # update value
1665
+ if new_k not in values and k in values:
1666
+ values[new_k] = values.pop(k)
1667
+ # update version
1668
+ versions[new_k] = new_v
1669
+
1670
+ # Migrate from branch:source:condition:node to branch:to:node
1671
+ for k in list(versions):
1672
+ if k.startswith("branch:") and k.count(":") == 3:
1673
+ # confirm node is present
1674
+ node = k.split(":")[-1]
1675
+ if node not in self.nodes:
1676
+ continue
1677
+ # get next version
1678
+ new_k = f"branch:to:{node}"
1679
+ new_v = (
1680
+ max(versions[new_k], versions.pop(k))
1681
+ if new_k in versions
1682
+ else versions.pop(k)
1683
+ )
1684
+ # update seen
1685
+ for ss in (seen.get(node, {}), seen.get(INTERRUPT, {})):
1686
+ if k in ss:
1687
+ s = ss.pop(k)
1688
+ if new_k in ss:
1689
+ ss[new_k] = max(s, ss[new_k])
1690
+ else:
1691
+ ss[new_k] = s
1692
+ # update value
1693
+ if new_k not in values and k in values:
1694
+ values[new_k] = values.pop(k)
1695
+ # update version
1696
+ versions[new_k] = new_v
1697
+
1698
+ if not set(self.nodes).isdisjoint(versions):
1699
+ # Migrate from "node" to "branch:to:node"
1700
+ source_to_target = defaultdict(list)
1701
+ for start, end in self.builder.edges:
1702
+ if start != START and end != END:
1703
+ source_to_target[start].append(end)
1704
+ for k in list(versions):
1705
+ if k == START:
1706
+ continue
1707
+ if k in self.nodes:
1708
+ v = versions.pop(k)
1709
+ c = values.pop(k, MISSING)
1710
+ for end in source_to_target[k]:
1711
+ # get next version
1712
+ new_k = f"branch:to:{end}"
1713
+ new_v = max(versions[new_k], v) if new_k in versions else v
1714
+ # update seen
1715
+ for ss in (seen.get(end, {}), seen.get(INTERRUPT, {})):
1716
+ if k in ss:
1717
+ s = ss.pop(k)
1718
+ if new_k in ss:
1719
+ ss[new_k] = max(s, ss[new_k])
1720
+ else:
1721
+ ss[new_k] = s
1722
+ # update value
1723
+ if new_k not in values and c is not MISSING:
1724
+ values[new_k] = c
1725
+ # update version
1726
+ versions[new_k] = new_v
1727
+ # pop interrupt seen
1728
+ if INTERRUPT in seen:
1729
+ seen[INTERRUPT].pop(k, MISSING)
1730
+
1731
+
1732
+ def _pick_mapper(
1733
+ state_keys: Sequence[str], schema: type[Any]
1734
+ ) -> Callable[[Any], Any] | None:
1735
+ if state_keys == ["__root__"]:
1736
+ return None
1737
+ if isclass(schema) and (issubclass(schema, BaseModel) or is_dataclass(schema)):
1738
+ return partial(_coerce_state, schema)
1739
+ return None
1740
+
1741
+
1742
+ _S = TypeVar("_S")
1743
+
1744
+
1745
+ def _coerce_state(schema: type[_S], input: dict[str, Any]) -> _S:
1746
+ return schema(**input)
1747
+
1748
+
1749
+ def _control_branch(value: Any) -> Sequence[tuple[str, Any]]:
1750
+ if isinstance(value, Send):
1751
+ return ((TASKS, value),)
1752
+ commands: list[Command] = []
1753
+ if isinstance(value, Command):
1754
+ commands.append(value)
1755
+ elif isinstance(value, (list, tuple)):
1756
+ for cmd in value:
1757
+ if isinstance(cmd, Command):
1758
+ commands.append(cmd)
1759
+ rtn: list[tuple[str, Any]] = []
1760
+ for command in commands:
1761
+ if command.graph == Command.PARENT:
1762
+ raise ParentCommand(command)
1763
+
1764
+ goto_targets = (
1765
+ [command.goto] if isinstance(command.goto, (Send, str)) else command.goto
1766
+ )
1767
+
1768
+ for go in goto_targets:
1769
+ if isinstance(go, Send):
1770
+ rtn.append((TASKS, go))
1771
+ elif isinstance(go, str) and go != END:
1772
+ # END is a special case, it's not actually a node in a practical sense
1773
+ # but rather a special terminal node that we don't need to branch to
1774
+ rtn.append((_CHANNEL_BRANCH_TO.format(go), None))
1775
+ return rtn
1776
+
1777
+
1778
+ def _control_static(
1779
+ ends: tuple[str, ...] | dict[str, str],
1780
+ ) -> Sequence[tuple[str, Any, str | None]]:
1781
+ if isinstance(ends, dict):
1782
+ return [
1783
+ (k if k == END else _CHANNEL_BRANCH_TO.format(k), None, label)
1784
+ for k, label in ends.items()
1785
+ ]
1786
+ else:
1787
+ return [
1788
+ (e if e == END else _CHANNEL_BRANCH_TO.format(e), None, None) for e in ends
1789
+ ]
1790
+
1791
+
1792
+ def _get_root(input: Any) -> Sequence[tuple[str, Any]] | None:
1793
+ if isinstance(input, Command):
1794
+ if input.graph == Command.PARENT:
1795
+ return ()
1796
+ return input._update_as_tuples()
1797
+ elif (
1798
+ isinstance(input, (list, tuple))
1799
+ and input
1800
+ and any(isinstance(i, Command) for i in input)
1801
+ ):
1802
+ updates: list[tuple[str, Any]] = []
1803
+ for i in input:
1804
+ if isinstance(i, Command):
1805
+ if i.graph == Command.PARENT:
1806
+ continue
1807
+ updates.extend(i._update_as_tuples())
1808
+ else:
1809
+ updates.append(("__root__", i))
1810
+ return updates
1811
+ elif input is not None:
1812
+ return [("__root__", input)]
1813
+
1814
+
1815
+ def _get_channels(
1816
+ schema: type[dict],
1817
+ ) -> tuple[dict[str, BaseChannel], dict[str, ManagedValueSpec], dict[str, Any]]:
1818
+ if not hasattr(schema, "__annotations__"):
1819
+ return (
1820
+ {"__root__": _get_channel("__root__", schema, allow_managed=False)},
1821
+ {},
1822
+ {},
1823
+ )
1824
+
1825
+ type_hints = get_type_hints(schema, include_extras=True)
1826
+ all_keys = {
1827
+ name: _get_channel(name, typ)
1828
+ for name, typ in type_hints.items()
1829
+ if name != "__slots__"
1830
+ }
1831
+ return (
1832
+ {k: v for k, v in all_keys.items() if isinstance(v, BaseChannel)},
1833
+ {k: v for k, v in all_keys.items() if is_managed_value(v)},
1834
+ type_hints,
1835
+ )
1836
+
1837
+
1838
+ @overload
1839
+ def _get_channel(
1840
+ name: str, annotation: Any, *, allow_managed: Literal[False]
1841
+ ) -> BaseChannel: ...
1842
+
1843
+
1844
+ @overload
1845
+ def _get_channel(
1846
+ name: str, annotation: Any, *, allow_managed: Literal[True] = True
1847
+ ) -> BaseChannel | ManagedValueSpec: ...
1848
+
1849
+
1850
+ def _get_channel(
1851
+ name: str, annotation: Any, *, allow_managed: bool = True
1852
+ ) -> BaseChannel | ManagedValueSpec:
1853
+ # Strip out Required and NotRequired wrappers
1854
+ if hasattr(annotation, "__origin__") and annotation.__origin__ in (
1855
+ Required,
1856
+ NotRequired,
1857
+ ):
1858
+ annotation = annotation.__args__[0]
1859
+ if manager := _is_field_managed_value(name, annotation):
1860
+ if allow_managed:
1861
+ return manager
1862
+ else:
1863
+ raise ValueError(f"This {annotation} not allowed in this position")
1864
+ elif channel := _is_field_channel(annotation):
1865
+ channel.key = name
1866
+ return channel
1867
+ elif channel := _is_field_binop(annotation):
1868
+ channel.key = name
1869
+ return channel
1870
+
1871
+ fallback: LastValue = LastValue(annotation)
1872
+ fallback.key = name
1873
+ return fallback
1874
+
1875
+
1876
+ def _is_field_channel(typ: type[Any]) -> BaseChannel | None:
1877
+ if hasattr(typ, "__metadata__"):
1878
+ meta = typ.__metadata__
1879
+ # Search through all annotated medata to find channel annotations
1880
+ for item in meta:
1881
+ if isinstance(item, BaseChannel):
1882
+ if isinstance(item, DeltaChannel) and hasattr(typ, "__origin__"):
1883
+ origin = typ.__origin__
1884
+ # Unwrap parameterized Required[X]/NotRequired[X] to X
1885
+ # (e.g. Annotated[NotRequired[dict[...]], ...]).
1886
+ if hasattr(origin, "__origin__") and origin.__origin__ in (
1887
+ Required,
1888
+ NotRequired,
1889
+ ):
1890
+ origin = origin.__args__[0]
1891
+ item = item.__class__(
1892
+ item.reducer,
1893
+ origin,
1894
+ snapshot_frequency=item.snapshot_frequency,
1895
+ )
1896
+ return item
1897
+ elif isclass(item) and issubclass(item, BaseChannel):
1898
+ # ex, Annotated[int, EphemeralValue, SomeOtherAnnotation]
1899
+ # would return EphemeralValue(int)
1900
+ return item(typ.__origin__ if hasattr(typ, "__origin__") else typ)
1901
+ return None
1902
+
1903
+
1904
+ def _is_field_binop(typ: type[Any]) -> BinaryOperatorAggregate | None:
1905
+ if hasattr(typ, "__metadata__"):
1906
+ meta = typ.__metadata__
1907
+ if len(meta) >= 1 and callable(meta[-1]):
1908
+ sig = signature(meta[-1])
1909
+ params = list(sig.parameters.values())
1910
+ if (
1911
+ sum(
1912
+ p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD)
1913
+ for p in params
1914
+ )
1915
+ == 2
1916
+ ):
1917
+ return BinaryOperatorAggregate(typ, meta[-1])
1918
+ else:
1919
+ raise ValueError(
1920
+ f"Invalid reducer signature. Expected (a, b) -> c. Got {sig}"
1921
+ )
1922
+ return None
1923
+
1924
+
1925
+ def _is_field_managed_value(name: str, typ: type[Any]) -> ManagedValueSpec | None:
1926
+ if hasattr(typ, "__metadata__"):
1927
+ meta = typ.__metadata__
1928
+ if len(meta) >= 1:
1929
+ decoration = get_origin(meta[-1]) or meta[-1]
1930
+ if is_managed_value(decoration):
1931
+ return decoration
1932
+
1933
+ # Handle Required, NotRequired, etc wrapped types by extracting the inner type
1934
+ if (
1935
+ get_origin(typ) is not None
1936
+ and (args := get_args(typ))
1937
+ and (inner_type := args[0])
1938
+ ):
1939
+ return _is_field_managed_value(name, inner_type)
1940
+
1941
+ return None
1942
+
1943
+
1944
+ def _get_json_schema(
1945
+ typ: type,
1946
+ schemas: dict,
1947
+ channels: dict,
1948
+ name: str,
1949
+ ) -> dict[str, Any]:
1950
+ if isclass(typ) and issubclass(typ, BaseModel):
1951
+ return typ.model_json_schema()
1952
+ elif is_typeddict(typ):
1953
+ return TypeAdapter(typ).json_schema()
1954
+ else:
1955
+ keys = list(schemas[typ].keys())
1956
+ if len(keys) == 1 and keys[0] == "__root__":
1957
+ return create_model(
1958
+ name,
1959
+ root=(channels[keys[0]].UpdateType, None),
1960
+ ).model_json_schema()
1961
+ else:
1962
+ return create_model(
1963
+ name,
1964
+ field_definitions={
1965
+ k: (
1966
+ channels[k].UpdateType,
1967
+ (
1968
+ get_field_default(
1969
+ k,
1970
+ channels[k].UpdateType,
1971
+ typ,
1972
+ )
1973
+ ),
1974
+ )
1975
+ for k in schemas[typ]
1976
+ if k in channels and isinstance(channels[k], BaseChannel)
1977
+ },
1978
+ ).model_json_schema()