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,2030 @@
1
+ """Tool execution node for CocoGraph workflows.
2
+
3
+ This module provides prebuilt functionality for executing tools in CocoGraph.
4
+
5
+ Tools are functions that models can call to interact with external systems,
6
+ APIs, databases, or perform computations.
7
+
8
+ The module implements design patterns for:
9
+
10
+ - Parallel execution of multiple tool calls for efficiency
11
+ - Robust error handling with customizable error messages
12
+ - State injection for tools that need access to graph state
13
+ - Store injection for tools that need persistent storage
14
+ - Command-based state updates for advanced control flow
15
+
16
+ Key Components:
17
+
18
+ - [`ToolNode`][coco_graph.prebuilt.ToolNode]: Main class for executing tools in CocoGraph workflows
19
+ - [`InjectedState`][coco_graph.prebuilt.InjectedState]: Annotation for injecting graph state into tools
20
+ - [`InjectedStore`][coco_graph.prebuilt.InjectedStore]: Annotation for injecting persistent store into tools
21
+ - [`ToolRuntime`][coco_graph.prebuilt.ToolRuntime]: Runtime information for tools, bundling together `state`, `context`,
22
+ `config`, `stream_writer`, `tool_call_id`, and `store`
23
+ - [`tools_condition`][coco_graph.prebuilt.tools_condition]: Utility function for conditional routing based on tool calls
24
+
25
+ Typical Usage:
26
+ ```python
27
+ from coco_core.tools import tool
28
+ from coco.tools import ToolNode
29
+
30
+
31
+ @tool
32
+ def my_tool(x: int) -> str:
33
+ return f"Result: {x}"
34
+
35
+
36
+ tool_node = ToolNode([my_tool])
37
+ ```
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import asyncio
43
+ import inspect
44
+ import json
45
+ from collections.abc import Awaitable, Callable
46
+ from copy import copy, deepcopy
47
+ from dataclasses import dataclass, field, replace
48
+ from types import UnionType
49
+ from typing import (
50
+ TYPE_CHECKING,
51
+ Annotated,
52
+ Any,
53
+ Generic,
54
+ Literal,
55
+ TypedDict,
56
+ Union,
57
+ cast,
58
+ get_args,
59
+ get_origin,
60
+ get_type_hints,
61
+ )
62
+
63
+ from coco_core.messages import (
64
+ AIMessage,
65
+ AnyMessage,
66
+ RemoveMessage,
67
+ ToolCall,
68
+ ToolMessage,
69
+ convert_to_messages,
70
+ )
71
+ from coco_core.runnables.config import (
72
+ RunnableConfig,
73
+ get_config_list,
74
+ get_executor_for_config,
75
+ )
76
+ from coco_core.tools import BaseTool, InjectedToolArg
77
+ from coco_core.tools import tool as create_tool
78
+ from coco_core.tools.base import (
79
+ TOOL_MESSAGE_BLOCK_TYPES,
80
+ ToolException,
81
+ _DirectlyInjectedToolArg,
82
+ _is_injected_arg_type,
83
+ get_all_basemodel_annotations,
84
+ )
85
+ from coco_graph._internal._constants import CONF, CONFIG_KEY_READ
86
+ from coco_graph._internal._runnable import RunnableCallable
87
+ from coco_graph.errors import GraphBubbleUp
88
+ from coco_graph.graph.message import REMOVE_ALL_MESSAGES
89
+ from coco_graph.pregel._tools import _tool_call_writer
90
+ from coco_graph.runtime import ExecutionInfo, ServerInfo
91
+ from coco_graph.store.base import BaseStore
92
+ from coco_graph.types import Command, Send, StreamWriter
93
+ from pydantic import BaseModel, ValidationError
94
+ from typing_extensions import TypeVar, Unpack
95
+
96
+ if TYPE_CHECKING:
97
+ from collections.abc import Sequence
98
+
99
+ from coco_graph.runtime import Runtime
100
+ from pydantic_core import ErrorDetails
101
+
102
+ # right now we use a dict as the default, can change this to AgentState, but depends
103
+ # on if this lives in Coco or CocoGraph... ideally would have some typed
104
+ # messages key
105
+ StateT = TypeVar("StateT", default=dict)
106
+ ContextT = TypeVar("ContextT", default=None)
107
+
108
+ INVALID_TOOL_NAME_ERROR_TEMPLATE = (
109
+ "Error: {requested_tool} is not a valid tool, try one of [{available_tools}]."
110
+ )
111
+ TOOL_CALL_ERROR_TEMPLATE = "Error: {error}\n Please fix your mistakes."
112
+ TOOL_EXECUTION_ERROR_TEMPLATE = (
113
+ "Error executing tool '{tool_name}' with kwargs {tool_kwargs} with error:\n"
114
+ " {error}\n"
115
+ " Please fix the error and try again."
116
+ )
117
+ TOOL_INVOCATION_ERROR_TEMPLATE = (
118
+ "Error invoking tool '{tool_name}' with kwargs {tool_kwargs} with error:\n"
119
+ " {error}\n"
120
+ " Please fix the error and try again."
121
+ )
122
+
123
+
124
+ class _ToolCallRequestOverrides(TypedDict, total=False):
125
+ """Possible overrides for ToolCallRequest.override() method."""
126
+
127
+ tool_call: ToolCall
128
+ tool: BaseTool
129
+ state: Any
130
+
131
+
132
+ @dataclass
133
+ class ToolCallRequest:
134
+ """Tool execution request passed to tool call interceptors.
135
+
136
+ Attributes:
137
+ tool_call: Tool call dict with name, args, and id from model output.
138
+ tool: BaseTool instance to be invoked, or None if tool is not
139
+ registered with the `ToolNode`. When tool is `None`, interceptors can
140
+ handle the request without validation. If the interceptor calls `execute()`,
141
+ validation will occur and raise an error for unregistered tools.
142
+ state: Agent state (`dict`, `list`, or `BaseModel`).
143
+ runtime: CocoGraph runtime context (optional, `None` if outside graph).
144
+ """
145
+
146
+ tool_call: ToolCall
147
+ tool: BaseTool | None
148
+ state: Any
149
+ runtime: ToolRuntime
150
+
151
+ def __setattr__(self, name: str, value: Any) -> None:
152
+ """Raise deprecation warning when setting attributes directly.
153
+
154
+ Direct attribute assignment is deprecated. Use the `override()` method instead.
155
+ """
156
+ import warnings
157
+
158
+ # Allow setting attributes during initialization
159
+ if not hasattr(self, "__dataclass_fields__") or not hasattr(self, name):
160
+ object.__setattr__(self, name, value)
161
+ else:
162
+ warnings.warn(
163
+ f"Setting attribute '{name}' on ToolCallRequest is deprecated. "
164
+ "Use the override() method instead to create a new instance with modified values.",
165
+ DeprecationWarning,
166
+ stacklevel=2,
167
+ )
168
+ object.__setattr__(self, name, value)
169
+
170
+ def override(
171
+ self, **overrides: Unpack[_ToolCallRequestOverrides]
172
+ ) -> ToolCallRequest:
173
+ """Replace the request with a new request with the given overrides.
174
+
175
+ Returns a new `ToolCallRequest` instance with the specified attributes replaced.
176
+ This follows an immutable pattern, leaving the original request unchanged.
177
+
178
+ Args:
179
+ **overrides: Keyword arguments for attributes to override.
180
+
181
+ Supported keys:
182
+
183
+ - tool_call: Tool call dict with `name`, `args`, and `id`
184
+ - state: Agent state (`dict`, `list`, or `BaseModel`)
185
+
186
+ Returns:
187
+ New ToolCallRequest instance with specified overrides applied.
188
+
189
+ Examples:
190
+ ```python
191
+ # Modify tool call arguments without mutating original
192
+ modified_call = {**request.tool_call, "args": {"value": 10}}
193
+ new_request = request.override(tool_call=modified_call)
194
+
195
+ # Override multiple attributes
196
+ new_request = request.override(tool_call=modified_call, state=new_state)
197
+ ```
198
+ """
199
+ return replace(self, **overrides)
200
+
201
+
202
+ ToolCallWrapper = Callable[
203
+ [ToolCallRequest, Callable[[ToolCallRequest], ToolMessage | Command]],
204
+ ToolMessage | Command,
205
+ ]
206
+ """Wrapper for tool call execution with multi-call support.
207
+
208
+ Wrapper receives:
209
+ request: ToolCallRequest with tool_call, tool, state, and runtime.
210
+ execute: Callable to execute the tool (CAN BE CALLED MULTIPLE TIMES).
211
+
212
+ Returns:
213
+ ToolMessage or Command (the final result).
214
+
215
+ The execute callable can be invoked multiple times for retry logic,
216
+ with potentially modified requests each time. Each call to execute
217
+ is independent and stateless.
218
+
219
+ !!! note
220
+ When implementing middleware for `create_agent`, use
221
+ `AgentMiddleware.wrap_tool_call` which provides properly typed
222
+ state parameter for better type safety.
223
+
224
+ Examples:
225
+ Passthrough (execute once):
226
+
227
+ def handler(request, execute):
228
+ return execute(request)
229
+
230
+ Modify request before execution:
231
+
232
+ ```python
233
+ def handler(request, execute):
234
+ modified_call = {**request.tool_call, "args": {**request.tool_call["args"], "value": request.tool_call["args"]["value"] * 2}}
235
+ modified_request = request.override(tool_call=modified_call)
236
+ return execute(modified_request)
237
+ ```
238
+
239
+ Retry on error (execute multiple times):
240
+
241
+ ```python
242
+ def handler(request, execute):
243
+ for attempt in range(3):
244
+ try:
245
+ result = execute(request)
246
+ if is_valid(result):
247
+ return result
248
+ except Exception:
249
+ if attempt == 2:
250
+ raise
251
+ return result
252
+ ```
253
+
254
+ Conditional retry based on response:
255
+
256
+ ```python
257
+ def handler(request, execute):
258
+ for attempt in range(3):
259
+ result = execute(request)
260
+ if isinstance(result, ToolMessage) and result.status != "error":
261
+ return result
262
+ if attempt < 2:
263
+ continue
264
+ return result
265
+ ```
266
+
267
+ Cache/short-circuit without calling execute:
268
+
269
+ ```python
270
+ def handler(request, execute):
271
+ if cached := get_cache(request):
272
+ return ToolMessage(content=cached, tool_call_id=request.tool_call["id"])
273
+ result = execute(request)
274
+ save_cache(request, result)
275
+ return result
276
+ ```
277
+ """
278
+
279
+ AsyncToolCallWrapper = Callable[
280
+ [ToolCallRequest, Callable[[ToolCallRequest], Awaitable[ToolMessage | Command]]],
281
+ Awaitable[ToolMessage | Command],
282
+ ]
283
+ """Async wrapper for tool call execution with multi-call support."""
284
+
285
+
286
+ class ToolCallWithContext(TypedDict):
287
+ """ToolCall with additional context for graph state.
288
+
289
+ This is an internal data structure meant to help the `ToolNode` accept
290
+ tool calls with additional context (e.g. state) when dispatched using the
291
+ Send API.
292
+
293
+ The Send API is used in create_agent to distribute tool calls in parallel
294
+ and support human-in-the-loop workflows where graph execution may be paused
295
+ for an indefinite time.
296
+ """
297
+
298
+ tool_call: ToolCall
299
+ __type: Literal["tool_call_with_context"]
300
+ """Type to parameterize the payload.
301
+
302
+ Using "__" as a prefix to be defensive against potential name collisions with
303
+ regular user state.
304
+ """
305
+ state: Any
306
+ """The state is provided as additional context."""
307
+
308
+
309
+ def msg_content_output(output: Any) -> str | list[dict]:
310
+ """Convert tool output to `ToolMessage` content format.
311
+
312
+ Handles `str`, `list[dict]` (content blocks), and arbitrary objects by attempting
313
+ JSON serialization with fallback to str().
314
+
315
+ Args:
316
+ output: Tool execution output of any type.
317
+
318
+ Returns:
319
+ String or list of content blocks suitable for `ToolMessage.content`.
320
+ """
321
+ if isinstance(output, str) or (
322
+ isinstance(output, list)
323
+ and all(
324
+ isinstance(x, dict) and x.get("type") in TOOL_MESSAGE_BLOCK_TYPES
325
+ for x in output
326
+ )
327
+ ):
328
+ return output
329
+ # Technically a list of strings is also valid message content, but it's
330
+ # not currently well tested that all chat models support this.
331
+ # And for backwards compatibility we want to make sure we don't break
332
+ # any existing ToolNode usage.
333
+ try:
334
+ return json.dumps(output, ensure_ascii=False)
335
+ except Exception:
336
+ return str(output)
337
+
338
+
339
+ class ToolInvocationError(ToolException):
340
+ """An error occurred while invoking a tool due to invalid arguments.
341
+
342
+ This exception is only raised when invoking a tool using the `ToolNode`!
343
+ """
344
+
345
+ def __init__(
346
+ self,
347
+ tool_name: str,
348
+ source: ValidationError,
349
+ tool_kwargs: dict[str, Any],
350
+ filtered_errors: list[ErrorDetails] | None = None,
351
+ ) -> None:
352
+ """Initialize the ToolInvocationError.
353
+
354
+ Args:
355
+ tool_name: The name of the tool that failed.
356
+ source: The exception that occurred.
357
+ tool_kwargs: The keyword arguments that were passed to the tool.
358
+ filtered_errors: Optional list of filtered validation errors excluding
359
+ injected arguments.
360
+ """
361
+ # Format error display based on filtered errors if provided
362
+ if filtered_errors is not None:
363
+ # Manually format the filtered errors without URLs or fancy formatting
364
+ error_str_parts = []
365
+ for error in filtered_errors:
366
+ loc_str = ".".join(str(loc) for loc in error.get("loc", ()))
367
+ msg = error.get("msg", "Unknown error")
368
+ error_str_parts.append(f"{loc_str}: {msg}")
369
+ error_display_str = "\n".join(error_str_parts)
370
+ else:
371
+ error_display_str = str(source)
372
+
373
+ self.message = TOOL_INVOCATION_ERROR_TEMPLATE.format(
374
+ tool_name=tool_name, tool_kwargs=tool_kwargs, error=error_display_str
375
+ )
376
+ self.tool_name = tool_name
377
+ self.tool_kwargs = tool_kwargs
378
+ self.source = source
379
+ self.filtered_errors = filtered_errors
380
+ super().__init__(self.message)
381
+
382
+
383
+ def _default_handle_tool_errors(e: Exception) -> str:
384
+ """Default error handler for tool errors.
385
+
386
+ If the tool is a tool invocation error, return its message.
387
+ Otherwise, raise the error.
388
+ """
389
+ if isinstance(e, ToolInvocationError):
390
+ return e.message
391
+ raise e
392
+
393
+
394
+ def _handle_tool_error(
395
+ e: Exception,
396
+ *,
397
+ flag: bool
398
+ | str
399
+ | Callable[..., str]
400
+ | type[Exception]
401
+ | tuple[type[Exception], ...],
402
+ ) -> str:
403
+ """Generate error message content based on exception handling configuration.
404
+
405
+ This function centralizes error message generation logic, supporting different
406
+ error handling strategies configured via the `ToolNode`'s `handle_tool_errors`
407
+ parameter.
408
+
409
+ Args:
410
+ e: The exception that occurred during tool execution.
411
+ flag: Configuration for how to handle the error. Can be:
412
+ - bool: If `True`, use default error template
413
+ - str: Use this string as the error message
414
+ - Callable: Call this function with the exception to get error message
415
+ - tuple: Not used in this context (handled by caller)
416
+
417
+ Returns:
418
+ A string containing the error message to include in the `ToolMessage`.
419
+
420
+ Raises:
421
+ ValueError: If flag is not one of the supported types.
422
+
423
+ !!! note
424
+ The tuple case is handled by the caller through exception type checking,
425
+ not by this function directly.
426
+ """
427
+ if isinstance(flag, (bool, tuple)) or (
428
+ isinstance(flag, type) and issubclass(flag, Exception)
429
+ ):
430
+ content = TOOL_CALL_ERROR_TEMPLATE.format(error=repr(e))
431
+ elif isinstance(flag, str):
432
+ content = flag
433
+ elif callable(flag):
434
+ content = flag(e) # type: ignore [assignment, call-arg]
435
+ else:
436
+ msg = (
437
+ f"Got unexpected type of `handle_tool_error`. Expected bool, str "
438
+ f"or callable. Received: {flag}"
439
+ )
440
+ raise ValueError(msg)
441
+ return content
442
+
443
+
444
+ def _infer_handled_types(handler: Callable[..., str]) -> tuple[type[Exception], ...]:
445
+ """Infer exception types handled by a custom error handler function.
446
+
447
+ This function analyzes the type annotations of a custom error handler to determine
448
+ which exception types it's designed to handle. This enables type-safe error handling
449
+ where only specific exceptions are caught and processed by the handler.
450
+
451
+ Args:
452
+ handler: A callable that takes an exception and returns an error message string.
453
+ The first parameter (after self/cls if present) should be type-annotated
454
+ with the exception type(s) to handle.
455
+
456
+ Returns:
457
+ A tuple of exception types that the handler can process. Returns (Exception,)
458
+ if no specific type information is available for backward compatibility.
459
+
460
+ Raises:
461
+ ValueError: If the handler's annotation contains non-Exception types or
462
+ if Union types contain non-Exception types.
463
+
464
+ !!! note
465
+ This function supports both single exception types and Union types for
466
+ handlers that need to handle multiple exception types differently.
467
+ """
468
+ sig = inspect.signature(handler)
469
+ params = list(sig.parameters.values())
470
+ if params:
471
+ # If it's a method, the first argument is typically 'self' or 'cls'
472
+ if params[0].name in ["self", "cls"] and len(params) == 2:
473
+ first_param = params[1]
474
+ else:
475
+ first_param = params[0]
476
+
477
+ type_hints = get_type_hints(handler)
478
+ if first_param.name in type_hints:
479
+ origin = get_origin(first_param.annotation)
480
+ if origin in [Union, UnionType]:
481
+ args = get_args(first_param.annotation)
482
+ if all(issubclass(arg, Exception) for arg in args):
483
+ return tuple(args)
484
+ msg = (
485
+ "All types in the error handler error annotation must be "
486
+ "Exception types. For example, "
487
+ "`def custom_handler(e: Union[ValueError, TypeError])`. "
488
+ f"Got '{first_param.annotation}' instead."
489
+ )
490
+ raise ValueError(msg)
491
+
492
+ exception_type = type_hints[first_param.name]
493
+ if Exception in exception_type.__mro__:
494
+ return (exception_type,)
495
+ msg = (
496
+ f"Arbitrary types are not supported in the error handler "
497
+ f"signature. Please annotate the error with either a "
498
+ f"specific Exception type or a union of Exception types. "
499
+ "For example, `def custom_handler(e: ValueError)` or "
500
+ "`def custom_handler(e: Union[ValueError, TypeError])`. "
501
+ f"Got '{exception_type}' instead."
502
+ )
503
+ raise ValueError(msg)
504
+
505
+ # If no type information is available, return (Exception,)
506
+ # for backwards compatibility.
507
+ return (Exception,)
508
+
509
+
510
+ def _filter_validation_errors(
511
+ validation_error: ValidationError,
512
+ injected_args: _InjectedArgs | None,
513
+ ) -> list[ErrorDetails]:
514
+ """Filter validation errors to only include LLM-controlled arguments.
515
+
516
+ When a tool invocation fails validation, only errors for arguments that the LLM
517
+ controls should be included in error messages. This ensures the LLM receives
518
+ focused, actionable feedback about parameters it can actually fix. System-injected
519
+ arguments (state, store, runtime) are filtered out since the LLM has no control
520
+ over them.
521
+
522
+ This function also removes injected argument values from the `input` field in error
523
+ details, ensuring that only LLM-provided arguments appear in error messages.
524
+
525
+ Args:
526
+ validation_error: The Pydantic ValidationError raised during tool invocation.
527
+ injected_args: The _InjectedArgs structure containing all injected arguments,
528
+ or None if there are no injected arguments.
529
+
530
+ Returns:
531
+ List of ErrorDetails containing only errors for LLM-controlled arguments,
532
+ with system-injected argument values removed from the input field.
533
+ """
534
+ # Collect all injected argument names
535
+ injected_arg_names: set[str] = set()
536
+ if injected_args:
537
+ if injected_args.state:
538
+ injected_arg_names.update(injected_args.state.keys())
539
+ if injected_args.store:
540
+ injected_arg_names.add(injected_args.store)
541
+ if injected_args.runtime:
542
+ injected_arg_names.add(injected_args.runtime)
543
+
544
+ filtered_errors: list[ErrorDetails] = []
545
+ for error in validation_error.errors():
546
+ # Check if error location contains any injected argument
547
+ # error['loc'] is a tuple like ('field_name',) or ('field_name', 'nested_field')
548
+ if error["loc"] and error["loc"][0] not in injected_arg_names:
549
+ # Create a copy of the error dict to avoid mutating the original
550
+ error_copy: dict[str, Any] = {**error}
551
+
552
+ # Remove injected arguments from input_value if it's a dict
553
+ if isinstance(error_copy.get("input"), dict):
554
+ input_dict = error_copy["input"]
555
+ input_copy = {
556
+ k: v for k, v in input_dict.items() if k not in injected_arg_names
557
+ }
558
+ error_copy["input"] = input_copy
559
+
560
+ # Cast is safe because ErrorDetails is a TypedDict compatible with this structure
561
+ filtered_errors.append(error_copy) # type: ignore[arg-type]
562
+
563
+ return filtered_errors
564
+
565
+
566
+ @dataclass
567
+ class _InjectedArgs:
568
+ """Internal structure for tracking injected arguments for a tool.
569
+
570
+ This data structure is built once during ToolNode initialization by analyzing
571
+ the tool's signature and args schema, then reused during execution for efficient
572
+ injection without repeated reflection.
573
+
574
+ The structure maps from tool parameter names to their injection sources, enabling
575
+ the ToolNode to know exactly which arguments need to be injected and where to
576
+ get their values from.
577
+
578
+ Attributes:
579
+ state: Mapping from tool parameter names to state field names for injection.
580
+ Keys are tool parameter names, values are either:
581
+ - str: Name of the state field to extract and inject
582
+ - None: Inject the entire state object
583
+ Empty dict if no state injection is needed.
584
+ store: Name of the tool parameter where the store should be injected,
585
+ or None if no store injection is needed.
586
+ runtime: Name of the tool parameter where the runtime should be injected,
587
+ or None if no runtime injection is needed.
588
+
589
+ Example:
590
+ For a tool with signature:
591
+ ```python
592
+ def my_tool(
593
+ x: int,
594
+ messages: Annotated[list, InjectedState("messages")],
595
+ full_state: Annotated[dict, InjectedState()],
596
+ store: Annotated[BaseStore, InjectedStore()],
597
+ runtime: ToolRuntime,
598
+ ) -> str:
599
+ ...
600
+ ```
601
+
602
+ The resulting `_InjectedArgs` would be:
603
+ ```python
604
+ _InjectedArgs(
605
+ state={
606
+ "messages": "messages", # Extract state["messages"]
607
+ "full_state": None, # Inject entire state
608
+ },
609
+ store="store", # Inject into "store" parameter
610
+ runtime="runtime", # Inject into "runtime" parameter
611
+ )
612
+ ```
613
+ """
614
+
615
+ state: dict[str, str | None]
616
+ store: str | None
617
+ runtime: str | None
618
+ all_injected_keys: set[str]
619
+ _optional_state_args: set[str]
620
+
621
+
622
+ class ToolNode(RunnableCallable):
623
+ """A node for executing tools in CocoGraph workflows.
624
+
625
+ Handles tool execution patterns including function calls, state injection,
626
+ persistent storage, and control flow. Manages parallel execution,
627
+ error handling.
628
+
629
+ Use `ToolNode` when building custom workflows that require fine-grained control over
630
+ tool execution—for example, custom routing logic, specialized error handling, or
631
+ non-standard agent architectures.
632
+
633
+ For standard ReAct-style agents, use [`create_agent`][coco.agents.create_agent]
634
+ instead. It uses `ToolNode` internally with sensible defaults for the agent loop,
635
+ conditional routing, and error handling.
636
+
637
+ Input Formats:
638
+ 1. **Graph state** with `messages` key that has a list of messages:
639
+ - Common representation for agentic workflows
640
+ - Supports custom messages key via `messages_key` parameter
641
+
642
+ 2. **Message List**: `[AIMessage(..., tool_calls=[...])]`
643
+ - List of messages with tool calls in the last AIMessage
644
+
645
+ 3. **Direct Tool Calls**: `[{"name": "tool", "args": {...}, "id": "1", "type": "tool_call"}]`
646
+ - Bypasses message parsing for direct tool execution
647
+ - For programmatic tool invocation and testing
648
+
649
+ Output Formats:
650
+ Output format depends on input type and tool behavior:
651
+
652
+ **For Regular tools**:
653
+
654
+ - Dict input → `{"messages": [ToolMessage(...)]}`
655
+ - List input → `[ToolMessage(...)]`
656
+
657
+ **For Command tools**:
658
+
659
+ - Returns `[Command(...)]` or mixed list with regular tool outputs
660
+ - `Command` can update state, trigger navigation, or send messages
661
+
662
+ Args:
663
+ tools: A sequence of tools that can be invoked by this node.
664
+
665
+ Supports:
666
+
667
+ - **BaseTool instances**: Tools with schemas and metadata
668
+ - **Plain functions**: Automatically converted to tools with inferred schemas
669
+
670
+ name: The name identifier for this node in the graph. Used for debugging
671
+ and visualization.
672
+ tags: Optional metadata tags to associate with the node for filtering
673
+ and organization.
674
+ handle_tool_errors: Configuration for error handling during tool execution.
675
+ Supports multiple strategies:
676
+
677
+ - `True`: Catch all errors and return a `ToolMessage` with the default
678
+ error template containing the exception details.
679
+ - `str`: Catch all errors and return a `ToolMessage` with this custom
680
+ error message string.
681
+ - `type[Exception]`: Only catch exceptions with the specified type and
682
+ return the default error message for it.
683
+ - `tuple[type[Exception], ...]`: Only catch exceptions with the specified
684
+ types and return default error messages for them.
685
+ - `Callable[..., str]`: Catch exceptions matching the callable's signature
686
+ and return the string result of calling it with the exception.
687
+ - `False`: Disable error handling entirely, allowing exceptions to
688
+ propagate.
689
+
690
+ Defaults to a callable that:
691
+
692
+ - Catches tool invocation errors (due to invalid arguments provided by the
693
+ model) and returns a descriptive error message
694
+ - Ignores tool execution errors (they will be re-raised)
695
+
696
+ messages_key: The key in the state dictionary that contains the message list.
697
+ This same key will be used for the output `ToolMessage` objects.
698
+
699
+ Allows custom state schemas with different message field names.
700
+
701
+ Examples:
702
+ Basic usage:
703
+
704
+ ```python
705
+ from coco.tools import ToolNode
706
+ from coco_core.tools import tool
707
+
708
+ @tool
709
+ def calculator(a: int, b: int) -> int:
710
+ \"\"\"Add two numbers.\"\"\"
711
+ return a + b
712
+
713
+ tool_node = ToolNode([calculator])
714
+ ```
715
+
716
+ State injection:
717
+
718
+ ```python
719
+ from typing_extensions import Annotated
720
+ from coco.tools import InjectedState
721
+
722
+ @tool
723
+ def context_tool(query: str, state: Annotated[dict, InjectedState]) -> str:
724
+ \"\"\"Some tool that uses state.\"\"\"
725
+ return f"Query: {query}, Messages: {len(state['messages'])}"
726
+
727
+ tool_node = ToolNode([context_tool])
728
+ ```
729
+
730
+ Error handling:
731
+
732
+ ```python
733
+ def handle_errors(e: ValueError) -> str:
734
+ return "Invalid input provided"
735
+
736
+
737
+ tool_node = ToolNode([my_tool], handle_tool_errors=handle_errors)
738
+ ```
739
+ """
740
+
741
+ name: str = "tools"
742
+
743
+ def __init__(
744
+ self,
745
+ tools: Sequence[BaseTool | Callable],
746
+ *,
747
+ name: str = "tools",
748
+ tags: list[str] | None = None,
749
+ handle_tool_errors: bool
750
+ | str
751
+ | Callable[..., str]
752
+ | type[Exception]
753
+ | tuple[type[Exception], ...] = _default_handle_tool_errors,
754
+ messages_key: str = "messages",
755
+ wrap_tool_call: ToolCallWrapper | None = None,
756
+ awrap_tool_call: AsyncToolCallWrapper | None = None,
757
+ ) -> None:
758
+ """Initialize `ToolNode` with tools and configuration.
759
+
760
+ Args:
761
+ tools: Sequence of tools to make available for execution.
762
+ name: Node name for graph identification.
763
+ tags: Optional metadata tags.
764
+ handle_tool_errors: Error handling configuration.
765
+ messages_key: State key containing messages.
766
+ wrap_tool_call: Sync wrapper function to intercept tool execution. Receives
767
+ ToolCallRequest and execute callable, returns ToolMessage or Command.
768
+ Enables retries, caching, request modification, and control flow.
769
+ awrap_tool_call: Async wrapper function to intercept tool execution.
770
+ If not provided, falls back to wrap_tool_call for async execution.
771
+ """
772
+ super().__init__(self._func, self._afunc, name=name, tags=tags, trace=False)
773
+ self._tools_by_name: dict[str, BaseTool] = {}
774
+ self._injected_args: dict[str, _InjectedArgs] = {}
775
+ self._handle_tool_errors = handle_tool_errors
776
+ self._messages_key = messages_key
777
+ self._wrap_tool_call = wrap_tool_call
778
+ self._awrap_tool_call = awrap_tool_call
779
+ for tool in tools:
780
+ if not isinstance(tool, BaseTool):
781
+ tool_ = create_tool(cast("type[BaseTool]", tool))
782
+ else:
783
+ tool_ = tool
784
+ self._tools_by_name[tool_.name] = tool_
785
+ # Build injected args mapping once during initialization in a single pass
786
+ self._injected_args[tool_.name] = _get_all_injected_args(tool_)
787
+
788
+ @property
789
+ def tools_by_name(self) -> dict[str, BaseTool]:
790
+ """Mapping from tool name to BaseTool instance."""
791
+ return self._tools_by_name
792
+
793
+ def _func(
794
+ self,
795
+ input: list[AnyMessage] | dict[str, Any] | BaseModel,
796
+ config: RunnableConfig,
797
+ runtime: Runtime,
798
+ ) -> Any:
799
+ tool_calls, input_type = self._parse_input(input)
800
+ config_list = get_config_list(config, len(tool_calls))
801
+
802
+ # Construct ToolRuntime instances at the top level for each tool call
803
+ tool_runtimes = []
804
+ for call, cfg in zip(tool_calls, config_list, strict=False):
805
+ state = self._extract_state(input, cfg)
806
+ tool_runtime = ToolRuntime(
807
+ state=state,
808
+ tool_call_id=call["id"],
809
+ config=cfg,
810
+ context=runtime.context,
811
+ store=runtime.store,
812
+ stream_writer=runtime.stream_writer,
813
+ tools=list(self.tools_by_name.values()),
814
+ execution_info=runtime.execution_info,
815
+ server_info=runtime.server_info,
816
+ )
817
+ tool_runtimes.append(tool_runtime)
818
+
819
+ # Pass original tool calls without injection
820
+ input_types = [input_type] * len(tool_calls)
821
+ with get_executor_for_config(config) as executor:
822
+ outputs = list(
823
+ executor.map(self._run_one, tool_calls, input_types, tool_runtimes)
824
+ )
825
+
826
+ return self._combine_tool_outputs(outputs, input_type)
827
+
828
+ async def _afunc(
829
+ self,
830
+ input: list[AnyMessage] | dict[str, Any] | BaseModel,
831
+ config: RunnableConfig,
832
+ runtime: Runtime,
833
+ ) -> Any:
834
+ tool_calls, input_type = self._parse_input(input)
835
+ config_list = get_config_list(config, len(tool_calls))
836
+
837
+ # Construct ToolRuntime instances at the top level for each tool call
838
+ tool_runtimes = []
839
+ for call, cfg in zip(tool_calls, config_list, strict=False):
840
+ state = self._extract_state(input, cfg)
841
+ tool_runtime = ToolRuntime(
842
+ state=state,
843
+ tool_call_id=call["id"],
844
+ config=cfg,
845
+ context=runtime.context,
846
+ store=runtime.store,
847
+ stream_writer=runtime.stream_writer,
848
+ tools=list(self.tools_by_name.values()),
849
+ execution_info=runtime.execution_info,
850
+ server_info=runtime.server_info,
851
+ )
852
+ tool_runtimes.append(tool_runtime)
853
+
854
+ # Pass original tool calls without injection
855
+ coros = []
856
+ for call, tool_runtime in zip(tool_calls, tool_runtimes, strict=False):
857
+ coros.append(self._arun_one(call, input_type, tool_runtime)) # type: ignore[arg-type]
858
+ outputs = await asyncio.gather(*coros)
859
+
860
+ return self._combine_tool_outputs(outputs, input_type)
861
+
862
+ def _combine_tool_outputs(
863
+ self,
864
+ outputs: list[ToolMessage | Command | list[ToolMessage | Command]],
865
+ input_type: Literal["list", "dict", "tool_calls"],
866
+ ) -> list[Command | list[ToolMessage] | dict[str, list[ToolMessage]]]:
867
+ # Flatten list entries from tools that returned multiple items
868
+ flat_outputs: list[ToolMessage | Command]
869
+ if any(isinstance(output, list) for output in outputs):
870
+ flat_outputs = []
871
+ for output in outputs:
872
+ if isinstance(output, list):
873
+ flat_outputs.extend(output)
874
+ else:
875
+ flat_outputs.append(output)
876
+ else:
877
+ flat_outputs = cast("list[ToolMessage | Command]", outputs)
878
+
879
+ # preserve existing behavior for non-command tool outputs for backwards
880
+ # compatibility
881
+ if not any(isinstance(output, Command) for output in flat_outputs):
882
+ # TypedDict, pydantic, dataclass, etc. should all be able to load from dict
883
+ return (
884
+ flat_outputs
885
+ if input_type == "list"
886
+ else {self._messages_key: flat_outputs}
887
+ )
888
+
889
+ # CocoGraph will automatically handle list of Command and non-command node
890
+ # updates
891
+ combined_outputs: list[
892
+ Command | list[ToolMessage] | dict[str, list[ToolMessage]]
893
+ ] = []
894
+
895
+ # combine all parent commands with goto into a single parent command
896
+ parent_command: Command | None = None
897
+ for output in flat_outputs:
898
+ if isinstance(output, Command):
899
+ if (
900
+ output.graph is Command.PARENT
901
+ and isinstance(output.goto, list)
902
+ and all(isinstance(send, Send) for send in output.goto)
903
+ ):
904
+ if parent_command:
905
+ parent_command = replace(
906
+ parent_command,
907
+ goto=cast("list[Send]", parent_command.goto) + output.goto,
908
+ )
909
+ else:
910
+ parent_command = Command(graph=Command.PARENT, goto=output.goto)
911
+ else:
912
+ combined_outputs.append(output)
913
+ else:
914
+ combined_outputs.append(
915
+ [output] if input_type == "list" else {self._messages_key: [output]}
916
+ )
917
+
918
+ if parent_command:
919
+ combined_outputs.append(parent_command)
920
+ return combined_outputs
921
+
922
+ def _execute_tool_sync(
923
+ self,
924
+ request: ToolCallRequest,
925
+ input_type: Literal["list", "dict", "tool_calls"],
926
+ config: RunnableConfig,
927
+ ) -> ToolMessage | Command | list[Command | ToolMessage]:
928
+ """Execute tool call with configured error handling.
929
+
930
+ Args:
931
+ request: Tool execution request.
932
+ input_type: Input format.
933
+ config: Runnable configuration.
934
+
935
+ Returns:
936
+ ToolMessage, Command, or list of Command/ToolMessage.
937
+
938
+ Raises:
939
+ Exception: If tool fails and handle_tool_errors is False.
940
+ """
941
+ call = request.tool_call
942
+ tool = request.tool
943
+
944
+ # Validate tool exists when we actually need to execute it
945
+ if tool is None:
946
+ if invalid_tool_message := self._validate_tool_call(call):
947
+ return invalid_tool_message
948
+ # This should never happen if validation works correctly
949
+ msg = f"Tool {call['name']} is not registered with ToolNode"
950
+ raise TypeError(msg)
951
+
952
+ # Inject state, store, and runtime right before invocation
953
+ injected_call = self._inject_tool_args(call, request.runtime, tool)
954
+ call_args = {**injected_call, "type": "tool_call"}
955
+
956
+ try:
957
+ try:
958
+ response = tool.invoke(call_args, config)
959
+ except ValidationError as exc:
960
+ # Filter out errors for injected arguments
961
+ injected = self._injected_args.get(call["name"])
962
+ filtered_errors = _filter_validation_errors(exc, injected)
963
+ # Use original call["args"] without injected values for error reporting
964
+ raise ToolInvocationError(
965
+ call["name"], exc, call["args"], filtered_errors
966
+ ) from exc
967
+
968
+ # Inside try so validation errors route through _handle_tool_errors
969
+ return self._normalize_tool_response(
970
+ response, request.tool_call, input_type
971
+ )
972
+
973
+ # GraphInterrupt is a special exception that will always be raised.
974
+ # It can be triggered in the following scenarios,
975
+ # Where GraphInterrupt(GraphBubbleUp) is raised from an `interrupt` invocation
976
+ # most commonly:
977
+ # (1) a GraphInterrupt is raised inside a tool
978
+ # (2) a GraphInterrupt is raised inside a graph node for a graph called as a tool
979
+ # (3) a GraphInterrupt is raised when a subgraph is interrupted inside a graph
980
+ # called as a tool
981
+ # (2 and 3 can happen in a "supervisor w/ tools" multi-agent architecture)
982
+ except GraphBubbleUp:
983
+ raise
984
+ except Exception as e:
985
+ # Determine which exception types are handled
986
+ handled_types: tuple[type[Exception], ...]
987
+ if isinstance(self._handle_tool_errors, type) and issubclass(
988
+ self._handle_tool_errors, Exception
989
+ ):
990
+ handled_types = (self._handle_tool_errors,)
991
+ elif isinstance(self._handle_tool_errors, tuple):
992
+ handled_types = self._handle_tool_errors
993
+ elif callable(self._handle_tool_errors) and not isinstance(
994
+ self._handle_tool_errors, type
995
+ ):
996
+ handled_types = _infer_handled_types(self._handle_tool_errors)
997
+ else:
998
+ # default behavior is catching all exceptions
999
+ handled_types = (Exception,)
1000
+
1001
+ # Check if this error should be handled
1002
+ if not self._handle_tool_errors or not isinstance(e, handled_types):
1003
+ raise
1004
+
1005
+ # Error is handled - create error ToolMessage
1006
+ content = _handle_tool_error(e, flag=self._handle_tool_errors)
1007
+ return ToolMessage(
1008
+ content=content,
1009
+ name=call["name"],
1010
+ tool_call_id=call["id"],
1011
+ status="error",
1012
+ )
1013
+
1014
+ def _run_one(
1015
+ self,
1016
+ call: ToolCall,
1017
+ input_type: Literal["list", "dict", "tool_calls"],
1018
+ tool_runtime: ToolRuntime,
1019
+ ) -> ToolMessage | Command | list[Command | ToolMessage]:
1020
+ """Execute single tool call with wrap_tool_call wrapper if configured.
1021
+
1022
+ Args:
1023
+ call: Tool call dict.
1024
+ input_type: Input format.
1025
+ tool_runtime: Tool runtime.
1026
+
1027
+ Returns:
1028
+ ToolMessage or Command.
1029
+ """
1030
+ # Validation is deferred to _execute_tool_sync to allow interceptors
1031
+ # to short-circuit requests for unregistered tools
1032
+ tool = self.tools_by_name.get(call["name"])
1033
+
1034
+ # Create the tool request with state and runtime
1035
+ tool_request = ToolCallRequest(
1036
+ tool_call=call,
1037
+ tool=tool,
1038
+ state=tool_runtime.state,
1039
+ runtime=tool_runtime,
1040
+ )
1041
+
1042
+ config = tool_runtime.config
1043
+
1044
+ if self._wrap_tool_call is None:
1045
+ # No wrapper - execute directly
1046
+ return self._execute_tool_sync(tool_request, input_type, config)
1047
+
1048
+ # Define execute callable that can be called multiple times
1049
+ def execute(req: ToolCallRequest) -> ToolMessage | Command:
1050
+ """Execute tool with given request. Can be called multiple times."""
1051
+ return self._execute_tool_sync(req, input_type, config)
1052
+
1053
+ # Call wrapper with request and execute callable
1054
+ try:
1055
+ return self._wrap_tool_call(tool_request, execute)
1056
+ except Exception as e:
1057
+ # Wrapper threw an exception
1058
+ if not self._handle_tool_errors:
1059
+ raise
1060
+ # Convert to error message
1061
+ content = _handle_tool_error(e, flag=self._handle_tool_errors)
1062
+ return ToolMessage(
1063
+ content=content,
1064
+ name=tool_request.tool_call["name"],
1065
+ tool_call_id=tool_request.tool_call["id"],
1066
+ status="error",
1067
+ )
1068
+
1069
+ async def _execute_tool_async(
1070
+ self,
1071
+ request: ToolCallRequest,
1072
+ input_type: Literal["list", "dict", "tool_calls"],
1073
+ config: RunnableConfig,
1074
+ ) -> ToolMessage | Command | list[Command | ToolMessage]:
1075
+ """Execute tool call asynchronously with configured error handling.
1076
+
1077
+ Args:
1078
+ request: Tool execution request.
1079
+ input_type: Input format.
1080
+ config: Runnable configuration.
1081
+
1082
+ Returns:
1083
+ ToolMessage, Command, or list of Command/ToolMessage.
1084
+
1085
+ Raises:
1086
+ Exception: If tool fails and handle_tool_errors is False.
1087
+ """
1088
+ call = request.tool_call
1089
+ tool = request.tool
1090
+
1091
+ # Validate tool exists when we actually need to execute it
1092
+ if tool is None:
1093
+ if invalid_tool_message := self._validate_tool_call(call):
1094
+ return invalid_tool_message
1095
+ # This should never happen if validation works correctly
1096
+ msg = f"Tool {call['name']} is not registered with ToolNode"
1097
+ raise TypeError(msg)
1098
+
1099
+ # Inject state, store, and runtime right before invocation
1100
+ injected_call = self._inject_tool_args(call, request.runtime, tool)
1101
+ call_args = {**injected_call, "type": "tool_call"}
1102
+
1103
+ try:
1104
+ try:
1105
+ response = await tool.ainvoke(call_args, config)
1106
+ except ValidationError as exc:
1107
+ # Filter out errors for injected arguments
1108
+ injected = self._injected_args.get(call["name"])
1109
+ filtered_errors = _filter_validation_errors(exc, injected)
1110
+ # Use original call["args"] without injected values for error reporting
1111
+ raise ToolInvocationError(
1112
+ call["name"], exc, call["args"], filtered_errors
1113
+ ) from exc
1114
+
1115
+ # Inside try so validation errors route through _handle_tool_errors
1116
+ return self._normalize_tool_response(
1117
+ response, request.tool_call, input_type
1118
+ )
1119
+
1120
+ # GraphInterrupt is a special exception that will always be raised.
1121
+ # It can be triggered in the following scenarios,
1122
+ # Where GraphInterrupt(GraphBubbleUp) is raised from an `interrupt` invocation
1123
+ # most commonly:
1124
+ # (1) a GraphInterrupt is raised inside a tool
1125
+ # (2) a GraphInterrupt is raised inside a graph node for a graph called as a tool
1126
+ # (3) a GraphInterrupt is raised when a subgraph is interrupted inside a graph
1127
+ # called as a tool
1128
+ # (2 and 3 can happen in a "supervisor w/ tools" multi-agent architecture)
1129
+ except GraphBubbleUp:
1130
+ raise
1131
+ except Exception as e:
1132
+ # Determine which exception types are handled
1133
+ handled_types: tuple[type[Exception], ...]
1134
+ if isinstance(self._handle_tool_errors, type) and issubclass(
1135
+ self._handle_tool_errors, Exception
1136
+ ):
1137
+ handled_types = (self._handle_tool_errors,)
1138
+ elif isinstance(self._handle_tool_errors, tuple):
1139
+ handled_types = self._handle_tool_errors
1140
+ elif callable(self._handle_tool_errors) and not isinstance(
1141
+ self._handle_tool_errors, type
1142
+ ):
1143
+ handled_types = _infer_handled_types(self._handle_tool_errors)
1144
+ else:
1145
+ # default behavior is catching all exceptions
1146
+ handled_types = (Exception,)
1147
+
1148
+ # Check if this error should be handled
1149
+ if not self._handle_tool_errors or not isinstance(e, handled_types):
1150
+ raise
1151
+
1152
+ # Error is handled - create error ToolMessage
1153
+ content = _handle_tool_error(e, flag=self._handle_tool_errors)
1154
+ return ToolMessage(
1155
+ content=content,
1156
+ name=call["name"],
1157
+ tool_call_id=call["id"],
1158
+ status="error",
1159
+ )
1160
+
1161
+ async def _arun_one(
1162
+ self,
1163
+ call: ToolCall,
1164
+ input_type: Literal["list", "dict", "tool_calls"],
1165
+ tool_runtime: ToolRuntime,
1166
+ ) -> ToolMessage | Command | list[Command | ToolMessage]:
1167
+ """Execute single tool call asynchronously with awrap_tool_call wrapper if configured.
1168
+
1169
+ Args:
1170
+ call: Tool call dict.
1171
+ input_type: Input format.
1172
+ tool_runtime: Tool runtime.
1173
+
1174
+ Returns:
1175
+ ToolMessage or Command.
1176
+ """
1177
+ # Validation is deferred to _execute_tool_async to allow interceptors
1178
+ # to short-circuit requests for unregistered tools
1179
+ tool = self.tools_by_name.get(call["name"])
1180
+
1181
+ # Create the tool request with state and runtime
1182
+ tool_request = ToolCallRequest(
1183
+ tool_call=call,
1184
+ tool=tool,
1185
+ state=tool_runtime.state,
1186
+ runtime=tool_runtime,
1187
+ )
1188
+
1189
+ config = tool_runtime.config
1190
+
1191
+ if self._awrap_tool_call is None and self._wrap_tool_call is None:
1192
+ # No wrapper - execute directly
1193
+ return await self._execute_tool_async(tool_request, input_type, config)
1194
+
1195
+ # Define async execute callable that can be called multiple times
1196
+ async def execute(req: ToolCallRequest) -> ToolMessage | Command:
1197
+ """Execute tool with given request. Can be called multiple times."""
1198
+ return await self._execute_tool_async(req, input_type, config)
1199
+
1200
+ def _sync_execute(req: ToolCallRequest) -> ToolMessage | Command:
1201
+ """Sync execute fallback for sync wrapper."""
1202
+ return self._execute_tool_sync(req, input_type, config)
1203
+
1204
+ # Call wrapper with request and execute callable
1205
+ try:
1206
+ if self._awrap_tool_call is not None:
1207
+ return await self._awrap_tool_call(tool_request, execute)
1208
+ # None check was performed above already
1209
+ self._wrap_tool_call = cast("ToolCallWrapper", self._wrap_tool_call)
1210
+ return self._wrap_tool_call(tool_request, _sync_execute)
1211
+ except Exception as e:
1212
+ # Wrapper threw an exception
1213
+ if not self._handle_tool_errors:
1214
+ raise
1215
+ # Convert to error message
1216
+ content = _handle_tool_error(e, flag=self._handle_tool_errors)
1217
+ return ToolMessage(
1218
+ content=content,
1219
+ name=tool_request.tool_call["name"],
1220
+ tool_call_id=tool_request.tool_call["id"],
1221
+ status="error",
1222
+ )
1223
+
1224
+ def _parse_input(
1225
+ self,
1226
+ input: list[AnyMessage] | dict[str, Any] | BaseModel,
1227
+ ) -> tuple[list[ToolCall], Literal["list", "dict", "tool_calls"]]:
1228
+ input_type: Literal["list", "dict", "tool_calls"]
1229
+ if isinstance(input, list):
1230
+ if isinstance(input[-1], dict) and input[-1].get("type") == "tool_call":
1231
+ input_type = "tool_calls"
1232
+ tool_calls = cast("list[ToolCall]", input)
1233
+ return tool_calls, input_type
1234
+ input_type = "list"
1235
+ messages = input
1236
+ elif (
1237
+ isinstance(input, dict) and input.get("__type") == "tool_call_with_context"
1238
+ ):
1239
+ # Handle ToolCallWithContext from Send API
1240
+ # Type checkers cannot narrow correctly since the signature
1241
+ # for input contains dict[str, Any]. We'd need to narrow dict[str, Any]
1242
+ # before we can apply correct typing.
1243
+ input_with_ctx = cast("ToolCallWithContext", input)
1244
+ input_type = "tool_calls"
1245
+ return [input_with_ctx["tool_call"]], input_type
1246
+ elif isinstance(input, dict) and (
1247
+ messages := input.get(self._messages_key, [])
1248
+ ):
1249
+ input_type = "dict"
1250
+ elif messages := getattr(input, self._messages_key, []):
1251
+ # Assume dataclass-like state that can coerce from dict
1252
+ input_type = "dict"
1253
+ else:
1254
+ msg = "No message found in input"
1255
+ raise ValueError(msg)
1256
+
1257
+ try:
1258
+ latest_ai_message = next(
1259
+ m for m in reversed(messages) if isinstance(m, AIMessage)
1260
+ )
1261
+ except StopIteration:
1262
+ msg = "No AIMessage found in input"
1263
+ raise ValueError(msg)
1264
+
1265
+ tool_calls = list(latest_ai_message.tool_calls)
1266
+ return tool_calls, input_type
1267
+
1268
+ def _validate_tool_call(self, call: ToolCall) -> ToolMessage | None:
1269
+ requested_tool = call["name"]
1270
+ if requested_tool not in self.tools_by_name:
1271
+ all_tool_names = list(self.tools_by_name.keys())
1272
+ content = INVALID_TOOL_NAME_ERROR_TEMPLATE.format(
1273
+ requested_tool=requested_tool,
1274
+ available_tools=", ".join(all_tool_names),
1275
+ )
1276
+ return ToolMessage(
1277
+ content, name=requested_tool, tool_call_id=call["id"], status="error"
1278
+ )
1279
+ return None
1280
+
1281
+ def _extract_state(
1282
+ self,
1283
+ input: list[AnyMessage] | dict[str, Any] | BaseModel,
1284
+ config: RunnableConfig,
1285
+ ) -> list[AnyMessage] | dict[str, Any] | BaseModel:
1286
+ """Extract state from input.
1287
+
1288
+ Three input shapes:
1289
+
1290
+ - `ToolCallWithContext` dict — legacy Send payload carrying an inlined
1291
+ state snapshot; return `input["state"]`.
1292
+ - list of `ToolCall` dicts — new Send payload with no inlined state;
1293
+ hydrate state from channels via `CONFIG_KEY_READ`.
1294
+ - regular graph state (dict/list/BaseModel) — return `input` as-is.
1295
+ """
1296
+ if isinstance(input, dict) and input.get("__type") == "tool_call_with_context":
1297
+ return input["state"]
1298
+ if (
1299
+ isinstance(input, list)
1300
+ and input
1301
+ and isinstance(input[-1], dict)
1302
+ and input[-1].get("type") == "tool_call"
1303
+ ):
1304
+ read = config.get(CONF, {}).get(CONFIG_KEY_READ)
1305
+ if read is None:
1306
+ return {}
1307
+ # Pregel installs CONFIG_KEY_READ as
1308
+ # `functools.partial(local_read, scratchpad, channels, managed, task)`.
1309
+ # Match the previous inlined-state contract by reading channels only;
1310
+ # managed values have their own injection path (`ToolRuntime.context`).
1311
+ channels = read.args[1]
1312
+ return cast("dict[str, Any]", read(list(channels), True))
1313
+ return input
1314
+
1315
+ def _inject_tool_args(
1316
+ self,
1317
+ tool_call: ToolCall,
1318
+ tool_runtime: ToolRuntime,
1319
+ tool: BaseTool | None = None,
1320
+ ) -> ToolCall:
1321
+ """Inject graph state, store, and runtime into tool call arguments.
1322
+
1323
+ This is an internal method that enables tools to access graph context that
1324
+ should not be controlled by the model. Tools can declare dependencies on graph
1325
+ state, persistent storage, or runtime context using InjectedState, InjectedStore,
1326
+ and ToolRuntime annotations. This method automatically identifies these
1327
+ dependencies and injects the appropriate values.
1328
+
1329
+ The injection process preserves the original tool call structure while adding
1330
+ the necessary context arguments. This allows tools to be both model-callable
1331
+ and context-aware without exposing internal state management to the model.
1332
+
1333
+ Args:
1334
+ tool_call: The tool call dictionary to augment with injected arguments.
1335
+ Must contain 'name', 'args', 'id', and 'type' fields.
1336
+ tool_runtime: The ToolRuntime instance containing all runtime context
1337
+ (state, config, store, context, stream_writer) to inject into tools.
1338
+ tool: Optional tool instance. When provided, allows injection for
1339
+ dynamically registered tools that are not in self.tools_by_name
1340
+ (e.g., tools added via middleware's wrap_tool_call).
1341
+
1342
+ Returns:
1343
+ A new ToolCall dictionary with the same structure as the input but with
1344
+ additional arguments injected based on the tool's annotation requirements.
1345
+
1346
+ Raises:
1347
+ ValueError: If a tool requires store injection but no store is provided,
1348
+ or if state injection requirements cannot be satisfied.
1349
+
1350
+ !!! note
1351
+ This method is called automatically during tool execution. It should not
1352
+ be called from outside the `ToolNode`.
1353
+ """
1354
+ injected = self._injected_args.get(tool_call["name"])
1355
+ if not injected and tool is not None:
1356
+ # For dynamically registered tools (e.g., added via middleware's
1357
+ # wrap_tool_call), compute injected args on-the-fly since they
1358
+ # were not present during ToolNode initialization.
1359
+ injected = _get_all_injected_args(tool)
1360
+ if not injected:
1361
+ return tool_call
1362
+
1363
+ tool_call_copy: ToolCall = copy(tool_call)
1364
+ injected_args: dict[str, Any] = {}
1365
+
1366
+ # Inject state
1367
+ if injected.state:
1368
+ state = tool_runtime.state
1369
+ # Handle list state by converting to dict
1370
+ if isinstance(state, list):
1371
+ required_fields = list(injected.state.values())
1372
+ if (
1373
+ len(required_fields) == 1
1374
+ and required_fields[0] == self._messages_key
1375
+ ) or required_fields[0] is None:
1376
+ state = {self._messages_key: state}
1377
+ else:
1378
+ err_msg = (
1379
+ f"Invalid input to ToolNode. Tool {tool_call['name']} requires "
1380
+ f"graph state dict as input."
1381
+ )
1382
+ if any(state_field for state_field in injected.state.values()):
1383
+ required_fields_str = ", ".join(f for f in required_fields if f)
1384
+ err_msg += (
1385
+ f" State should contain fields {required_fields_str}."
1386
+ )
1387
+ raise ValueError(err_msg)
1388
+
1389
+ # Extract state values
1390
+ if isinstance(state, dict):
1391
+ for tool_arg, state_field in injected.state.items():
1392
+ if not state_field:
1393
+ injected_args[tool_arg] = state
1394
+ elif state_field in state:
1395
+ injected_args[tool_arg] = state[state_field]
1396
+ elif tool_arg not in injected._optional_state_args:
1397
+ raise KeyError(state_field)
1398
+ else:
1399
+ for tool_arg, state_field in injected.state.items():
1400
+ if not state_field:
1401
+ injected_args[tool_arg] = state
1402
+ elif hasattr(state, state_field):
1403
+ injected_args[tool_arg] = getattr(state, state_field)
1404
+ elif tool_arg not in injected._optional_state_args:
1405
+ raise AttributeError(state_field)
1406
+
1407
+ # Inject store
1408
+ if injected.store:
1409
+ if tool_runtime.store is None:
1410
+ msg = (
1411
+ "Cannot inject store into tools with InjectedStore annotations - "
1412
+ "please compile your graph with a store."
1413
+ )
1414
+ raise ValueError(msg)
1415
+ injected_args[injected.store] = tool_runtime.store
1416
+
1417
+ # Inject runtime
1418
+ if injected.runtime:
1419
+ injected_args[injected.runtime] = tool_runtime
1420
+
1421
+ # Strip any caller-supplied values for injected args, then add
1422
+ # back only trusted values. This prevents an LLM from forging
1423
+ # hidden InjectedToolArg fields via ToolCall.args.
1424
+ stripped_args = {
1425
+ k: v
1426
+ for k, v in tool_call_copy["args"].items()
1427
+ if k not in injected.all_injected_keys
1428
+ }
1429
+ tool_call_copy["args"] = {**stripped_args, **injected_args}
1430
+ return tool_call_copy
1431
+
1432
+ def _normalize_tool_response(
1433
+ self,
1434
+ response: Any,
1435
+ tool_call: ToolCall,
1436
+ input_type: Literal["list", "dict", "tool_calls"],
1437
+ ) -> ToolMessage | Command | list[Command | ToolMessage]:
1438
+ """Validate and normalize a tool's raw return value."""
1439
+ if isinstance(response, Command):
1440
+ return self._validate_tool_command(response, tool_call, input_type)
1441
+ if isinstance(response, ToolMessage):
1442
+ response.content = cast("str | list", msg_content_output(response.content))
1443
+ return response
1444
+ if isinstance(response, list):
1445
+ if all(isinstance(r, (Command, ToolMessage)) for r in response):
1446
+ return self._validate_tool_command_list(response, tool_call, input_type)
1447
+ msg = (
1448
+ f"Tool {tool_call['name']} returned a list with invalid element "
1449
+ "types: expected all Command or ToolMessage"
1450
+ )
1451
+ raise TypeError(msg)
1452
+ msg = f"Tool {tool_call['name']} returned unexpected type: {type(response)}"
1453
+ raise TypeError(msg)
1454
+
1455
+ def _validate_tool_command_list(
1456
+ self,
1457
+ response: list[Command | ToolMessage],
1458
+ tool_call: ToolCall,
1459
+ input_type: Literal["list", "dict", "tool_calls"],
1460
+ ) -> list[Command | ToolMessage]:
1461
+ """Validate a list of Command/ToolMessage returned by a single tool call.
1462
+
1463
+ Requires exactly one terminating ToolMessage (matching the outer tool_call_id)
1464
+ across the list — either as a top-level element or nested in a
1465
+ Command.update["messages"].
1466
+ """
1467
+ expected_id = tool_call["id"]
1468
+
1469
+ terminator_count = 0
1470
+ for item in response:
1471
+ if isinstance(item, ToolMessage):
1472
+ if item.tool_call_id == expected_id:
1473
+ terminator_count += 1
1474
+ elif isinstance(item, Command) and isinstance(item.update, dict):
1475
+ for msg in item.update.get(self._messages_key, []):
1476
+ if isinstance(msg, ToolMessage) and msg.tool_call_id == expected_id:
1477
+ terminator_count += 1
1478
+
1479
+ if terminator_count != 1:
1480
+ msg = (
1481
+ f"Tool {tool_call['name']} returned a list with "
1482
+ f"{terminator_count} messages bound to tool_call_id "
1483
+ f"{expected_id!r}; expected exactly one terminating ToolMessage."
1484
+ )
1485
+ raise ValueError(msg)
1486
+
1487
+ # Per-Command normalization still runs, but the list-level count above
1488
+ # already guarantees exactly one terminator, so individual Commands may
1489
+ # lack one.
1490
+ validated: list[Command | ToolMessage] = []
1491
+ for item in response:
1492
+ if isinstance(item, Command):
1493
+ validated.append(
1494
+ self._validate_tool_command(
1495
+ item, tool_call, input_type, require_terminator=False
1496
+ )
1497
+ )
1498
+ else:
1499
+ item.content = cast("str | list", msg_content_output(item.content))
1500
+ validated.append(item)
1501
+ return validated
1502
+
1503
+ def _validate_tool_command(
1504
+ self,
1505
+ command: Command,
1506
+ call: ToolCall,
1507
+ input_type: Literal["list", "dict", "tool_calls"],
1508
+ *,
1509
+ require_terminator: bool = True,
1510
+ ) -> Command:
1511
+ if isinstance(command.update, dict):
1512
+ # input type is dict when ToolNode is invoked with a dict input
1513
+ # (e.g. {"messages": [AIMessage(..., tool_calls=[...])]})
1514
+ if input_type not in ("dict", "tool_calls"):
1515
+ msg = (
1516
+ "Tools can provide a dict in Command.update only when using dict "
1517
+ f"with '{self._messages_key}' key as ToolNode input, "
1518
+ f"got: {command.update} for tool '{call['name']}'"
1519
+ )
1520
+ raise ValueError(msg)
1521
+
1522
+ updated_command = deepcopy(command)
1523
+ state_update = cast("dict[str, Any]", updated_command.update) or {}
1524
+ messages_update = state_update.get(self._messages_key, [])
1525
+ elif isinstance(command.update, list):
1526
+ # Input type is list when ToolNode is invoked with a list input
1527
+ # (e.g. [AIMessage(..., tool_calls=[...])])
1528
+ if input_type != "list":
1529
+ msg = (
1530
+ "Tools can provide a list of messages in Command.update "
1531
+ "only when using list of messages as ToolNode input, "
1532
+ f"got: {command.update} for tool '{call['name']}'"
1533
+ )
1534
+ raise ValueError(msg)
1535
+
1536
+ updated_command = deepcopy(command)
1537
+ messages_update = updated_command.update
1538
+ else:
1539
+ return command
1540
+
1541
+ # convert to message objects if updates are in a dict format
1542
+ messages_update = convert_to_messages(messages_update)
1543
+
1544
+ # no validation needed if all messages are being removed
1545
+ if messages_update == [RemoveMessage(id=REMOVE_ALL_MESSAGES)]:
1546
+ return updated_command
1547
+
1548
+ has_matching_tool_message = False
1549
+ for message in messages_update:
1550
+ if not isinstance(message, ToolMessage):
1551
+ continue
1552
+
1553
+ if message.tool_call_id == call["id"]:
1554
+ message.name = call["name"]
1555
+ has_matching_tool_message = True
1556
+
1557
+ # validate that we always have a ToolMessage matching the tool call in
1558
+ # Command.update if command is sent to the CURRENT graph
1559
+ if (
1560
+ require_terminator
1561
+ and updated_command.graph is None
1562
+ and not has_matching_tool_message
1563
+ ):
1564
+ example_update = (
1565
+ '`Command(update={"messages": '
1566
+ '[ToolMessage("Success", tool_call_id=tool_call_id), ...]}, ...)`'
1567
+ if input_type == "dict"
1568
+ else "`Command(update="
1569
+ '[ToolMessage("Success", tool_call_id=tool_call_id), ...], ...)`'
1570
+ )
1571
+ msg = (
1572
+ "Expected to have a matching ToolMessage in Command.update "
1573
+ f"for tool '{call['name']}', got: {messages_update}. "
1574
+ "Every tool call (LLM requesting to call a tool) "
1575
+ "in the message history MUST have a corresponding ToolMessage. "
1576
+ f"You can fix it by modifying the tool to return {example_update}."
1577
+ )
1578
+ raise ValueError(msg)
1579
+ return updated_command
1580
+
1581
+
1582
+ def tools_condition(
1583
+ state: list[AnyMessage] | dict[str, Any] | BaseModel,
1584
+ messages_key: str = "messages",
1585
+ ) -> Literal["tools", "__end__"]:
1586
+ """Conditional routing function for tool-calling workflows.
1587
+
1588
+ This utility function implements the standard conditional logic for ReAct-style
1589
+ agents: if the last `AIMessage` contains tool calls, route to the tool execution
1590
+ node; otherwise, end the workflow. This pattern is fundamental to most tool-calling
1591
+ agent architectures.
1592
+
1593
+ The function handles multiple state formats commonly used in CocoGraph applications,
1594
+ making it flexible for different graph designs while maintaining consistent behavior.
1595
+
1596
+ Args:
1597
+ state: The current graph state to examine for tool calls. Supported formats:
1598
+ - Dictionary containing a messages key (for `StateGraph`)
1599
+ - `BaseModel` instance with a messages attribute
1600
+ messages_key: The key or attribute name containing the message list in the state.
1601
+ This allows customization for graphs using different state schemas.
1602
+
1603
+ Returns:
1604
+ Either `'tools'` if tool calls are present in the last `AIMessage`, or `'__end__'`
1605
+ to terminate the workflow. These are the standard routing destinations for
1606
+ tool-calling conditional edges.
1607
+
1608
+ Raises:
1609
+ ValueError: If no messages can be found in the provided state format.
1610
+
1611
+ Example:
1612
+ Basic usage in a ReAct agent:
1613
+
1614
+ ```python
1615
+ from coco_graph.graph import StateGraph
1616
+ from coco.tools import ToolNode
1617
+ from coco.tools.tool_node import tools_condition
1618
+ from typing_extensions import TypedDict
1619
+
1620
+
1621
+ class State(TypedDict):
1622
+ messages: list
1623
+
1624
+
1625
+ graph = StateGraph(State)
1626
+ graph.add_node("llm", call_model)
1627
+ graph.add_node("tools", ToolNode([my_tool]))
1628
+ graph.add_conditional_edges(
1629
+ "llm",
1630
+ tools_condition, # Routes to "tools" or "__end__"
1631
+ {"tools": "tools", "__end__": "__end__"},
1632
+ )
1633
+ ```
1634
+
1635
+ Custom messages key:
1636
+
1637
+ ```python
1638
+ def custom_condition(state):
1639
+ return tools_condition(state, messages_key="chat_history")
1640
+ ```
1641
+
1642
+ !!! note
1643
+ This function is designed to work seamlessly with `ToolNode` and standard
1644
+ CocoGraph patterns. It expects the last message to be an `AIMessage` when
1645
+ tool calls are present, which is the standard output format for tool-calling
1646
+ language models.
1647
+ """
1648
+ if isinstance(state, list):
1649
+ ai_message = state[-1]
1650
+ elif (isinstance(state, dict) and (messages := state.get(messages_key, []))) or (
1651
+ messages := getattr(state, messages_key, [])
1652
+ ):
1653
+ ai_message = messages[-1]
1654
+ else:
1655
+ msg = f"No messages found in input state to tool_edge: {state}"
1656
+ raise ValueError(msg)
1657
+ if hasattr(ai_message, "tool_calls") and len(ai_message.tool_calls) > 0:
1658
+ return "tools"
1659
+ return "__end__"
1660
+
1661
+
1662
+ @dataclass
1663
+ class ToolRuntime(_DirectlyInjectedToolArg, Generic[ContextT, StateT]):
1664
+ """Runtime context automatically injected into tools.
1665
+
1666
+ !!! note
1667
+
1668
+ This is distinct from `Runtime` (from `coco_graph.runtime`), which is injected
1669
+ into graph nodes and middleware. `ToolRuntime` includes additional tool-specific
1670
+ attributes like `config`, `state`, and `tool_call_id` that `Runtime` does not
1671
+ have.
1672
+
1673
+ When a tool function has a parameter named `runtime` with type hint
1674
+ `ToolRuntime`, the tool execution system will automatically inject an instance
1675
+ containing:
1676
+
1677
+ - `state`: The current graph state
1678
+ - `tool_call_id`: The ID of the current tool call
1679
+ - `config`: `RunnableConfig` for the current execution
1680
+ - `context`: Runtime context (shared with `Runtime`)
1681
+ - `store`: `BaseStore` instance for persistent storage (shared with `Runtime`)
1682
+ - `stream_writer`: `StreamWriter` for streaming output (shared with `Runtime`)
1683
+ - `tools`: List of all available `BaseTool` instances
1684
+
1685
+ No `Annotated` wrapper is needed - just use `runtime: ToolRuntime`
1686
+ as a parameter.
1687
+
1688
+ Example:
1689
+ ```python
1690
+ from coco_core.tools import tool
1691
+ from coco.tools import ToolRuntime
1692
+
1693
+ @tool
1694
+ def my_tool(x: int, runtime: ToolRuntime) -> str:
1695
+ \"\"\"Tool that accesses runtime context.\"\"\"
1696
+ # Access state
1697
+ messages = tool_runtime.state["messages"]
1698
+
1699
+ # Access tool_call_id
1700
+ print(f"Tool call ID: {tool_runtime.tool_call_id}")
1701
+
1702
+ # Access config
1703
+ print(f"Run ID: {tool_runtime.config.get('run_id')}")
1704
+
1705
+ # Access runtime context
1706
+ user_id = tool_runtime.context.get("user_id")
1707
+
1708
+ # Access store
1709
+ tool_runtime.store.put(("metrics",), "count", 1)
1710
+
1711
+ # Stream output
1712
+ tool_runtime.stream_writer.write("Processing...")
1713
+
1714
+ return f"Processed {x}"
1715
+ ```
1716
+
1717
+ !!! note
1718
+ This is a marker class used for type checking and detection.
1719
+ The actual runtime object will be constructed during tool execution.
1720
+ """
1721
+
1722
+ state: StateT
1723
+ context: ContextT
1724
+ config: RunnableConfig
1725
+ stream_writer: StreamWriter
1726
+ tool_call_id: str | None
1727
+ store: BaseStore | None
1728
+ tools: list[BaseTool] = field(default_factory=list)
1729
+ execution_info: ExecutionInfo | None = None
1730
+ server_info: ServerInfo | None = None
1731
+
1732
+ def emit_output_delta(self, delta: Any) -> None:
1733
+ """Stream a partial output chunk on the `tools` stream channel.
1734
+
1735
+ Reads the per-tool-call writer that `StreamToolCallHandler`
1736
+ installs on a ContextVar at `on_tool_start` and forwards `delta`
1737
+ through it. Silent no-op when the graph was not run with
1738
+ `"tools"` in `stream_mode` (no writer is set), so tool authors
1739
+ can leave `emit_output_delta` calls in place without gating
1740
+ them on stream mode.
1741
+
1742
+ Args:
1743
+ delta: Partial output chunk. Any JSON-serializable value;
1744
+ surfaced as-is on the `tools` channel's
1745
+ `tool-output-delta` payload under `"delta"`.
1746
+ """
1747
+ writer = _tool_call_writer.get()
1748
+ if writer is None:
1749
+ return
1750
+ writer(delta)
1751
+
1752
+
1753
+ class InjectedState(InjectedToolArg):
1754
+ """Annotation for injecting graph state into tool arguments.
1755
+
1756
+ This annotation enables tools to access graph state without exposing state
1757
+ management details to the language model. Tools annotated with `InjectedState`
1758
+ receive state data automatically during execution while remaining invisible
1759
+ to the model's tool-calling interface.
1760
+
1761
+ Args:
1762
+ field: Optional key to extract from the state dictionary. If `None`, the entire
1763
+ state is injected. If specified, only that field's value is injected.
1764
+ This allows tools to request specific state components rather than
1765
+ processing the full state structure.
1766
+
1767
+ Example:
1768
+ ```python
1769
+ from typing import List
1770
+ from typing_extensions import Annotated, TypedDict
1771
+
1772
+ from coco_core.messages import BaseMessage, AIMessage
1773
+ from coco.tools import InjectedState, ToolNode, tool
1774
+
1775
+
1776
+ class AgentState(TypedDict):
1777
+ messages: List[BaseMessage]
1778
+ foo: str
1779
+
1780
+
1781
+ @tool
1782
+ def state_tool(x: int, state: Annotated[dict, InjectedState]) -> str:
1783
+ '''Do something with state.'''
1784
+ if len(state["messages"]) > 2:
1785
+ return state["foo"] + str(x)
1786
+ else:
1787
+ return "not enough messages"
1788
+
1789
+
1790
+ @tool
1791
+ def foo_tool(x: int, foo: Annotated[str, InjectedState("foo")]) -> str:
1792
+ '''Do something else with state.'''
1793
+ return foo + str(x + 1)
1794
+
1795
+
1796
+ node = ToolNode([state_tool, foo_tool])
1797
+
1798
+ tool_call1 = {"name": "state_tool", "args": {"x": 1}, "id": "1", "type": "tool_call"}
1799
+ tool_call2 = {"name": "foo_tool", "args": {"x": 1}, "id": "2", "type": "tool_call"}
1800
+ state = {
1801
+ "messages": [AIMessage("", tool_calls=[tool_call1, tool_call2])],
1802
+ "foo": "bar",
1803
+ }
1804
+ node.invoke(state)
1805
+ ```
1806
+
1807
+ ```python
1808
+ [
1809
+ ToolMessage(content="not enough messages", name="state_tool", tool_call_id="1"),
1810
+ ToolMessage(content="bar2", name="foo_tool", tool_call_id="2"),
1811
+ ]
1812
+ ```
1813
+
1814
+ !!! note
1815
+ - `InjectedState` arguments are automatically excluded from tool schemas
1816
+ presented to language models
1817
+ - `ToolNode` handles the injection process during execution
1818
+ - Tools can mix regular arguments (controlled by the model) with injected
1819
+ arguments (controlled by the system)
1820
+ - State injection occurs after the model generates tool calls but before
1821
+ tool execution
1822
+ """
1823
+
1824
+ def __init__(self, field: str | None = None) -> None:
1825
+ """Initialize the `InjectedState` annotation."""
1826
+ self.field = field
1827
+
1828
+
1829
+ class InjectedStore(InjectedToolArg):
1830
+ """Annotation for injecting persistent store into tool arguments.
1831
+
1832
+ This annotation enables tools to access CocoGraph's persistent storage system
1833
+ without exposing storage details to the language model. Tools annotated with
1834
+ `InjectedStore` receive the store instance automatically during execution while
1835
+ remaining invisible to the model's tool-calling interface.
1836
+
1837
+ The store provides persistent, cross-session data storage that tools can use
1838
+ for maintaining context, user preferences, or any other data that needs to
1839
+ persist beyond individual workflow executions.
1840
+
1841
+ !!! warning
1842
+ `InjectedStore` annotation requires `trustcoco-core >= 0.3.8`
1843
+
1844
+ Example:
1845
+ ```python
1846
+ from typing_extensions import Annotated
1847
+ from coco_graph.store.memory import InMemoryStore
1848
+ from coco.tools import InjectedStore, ToolNode, tool
1849
+
1850
+ @tool
1851
+ def save_preference(
1852
+ key: str,
1853
+ value: str,
1854
+ store: Annotated[Any, InjectedStore()]
1855
+ ) -> str:
1856
+ \"\"\"Save user preference to persistent storage.\"\"\"
1857
+ store.put(("preferences",), key, value)
1858
+ return f"Saved {key} = {value}"
1859
+
1860
+ @tool
1861
+ def get_preference(
1862
+ key: str,
1863
+ store: Annotated[Any, InjectedStore()]
1864
+ ) -> str:
1865
+ \"\"\"Retrieve user preference from persistent storage.\"\"\"
1866
+ result = store.get(("preferences",), key)
1867
+ return result.value if result else "Not found"
1868
+ ```
1869
+
1870
+ Usage with `ToolNode` and graph compilation:
1871
+
1872
+ ```python
1873
+ from coco_graph.graph import StateGraph
1874
+ from coco_graph.store.memory import InMemoryStore
1875
+
1876
+ store = InMemoryStore()
1877
+ tool_node = ToolNode([save_preference, get_preference])
1878
+
1879
+ graph = StateGraph(State)
1880
+ graph.add_node("tools", tool_node)
1881
+ compiled_graph = graph.compile(store=store) # Store is injected automatically
1882
+ ```
1883
+
1884
+ Cross-session persistence:
1885
+
1886
+ ```python
1887
+ # First session
1888
+ result1 = graph.invoke({"messages": [HumanMessage("Save my favorite color as blue")]})
1889
+
1890
+ # Later session - data persists
1891
+ result2 = graph.invoke({"messages": [HumanMessage("What's my favorite color?")]})
1892
+ ```
1893
+
1894
+ !!! note
1895
+ - `InjectedStore` arguments are automatically excluded from tool schemas
1896
+ presented to language models
1897
+ - The store instance is automatically injected by `ToolNode` during execution
1898
+ - Tools can access namespaced storage using the store's get/put methods
1899
+ - Store injection requires the graph to be compiled with a store instance
1900
+ - Multiple tools can share the same store instance for data consistency
1901
+ """
1902
+
1903
+
1904
+ def _is_injection(
1905
+ type_arg: Any,
1906
+ injection_type: type[InjectedState | InjectedStore | ToolRuntime],
1907
+ ) -> bool:
1908
+ """Check if a type argument represents an injection annotation.
1909
+
1910
+ This utility function determines whether a type annotation indicates that
1911
+ an argument should be injected with state or store data. It handles both
1912
+ direct annotations and nested annotations within Union or Annotated types.
1913
+
1914
+ Args:
1915
+ type_arg: The type argument to check for injection annotations.
1916
+ injection_type: The injection type to look for (InjectedState or InjectedStore).
1917
+
1918
+ Returns:
1919
+ True if the type argument contains the specified injection annotation.
1920
+ """
1921
+ if isinstance(type_arg, injection_type) or (
1922
+ isinstance(type_arg, type) and issubclass(type_arg, injection_type)
1923
+ ):
1924
+ return True
1925
+ origin_ = get_origin(type_arg)
1926
+ if origin_ is Union or origin_ is Annotated:
1927
+ return any(_is_injection(ta, injection_type) for ta in get_args(type_arg))
1928
+
1929
+ if origin_ is not None and (
1930
+ origin_ is injection_type
1931
+ or (isinstance(origin_, type) and issubclass(origin_, injection_type))
1932
+ ):
1933
+ return True
1934
+ return False
1935
+
1936
+
1937
+ def _get_injection_from_type(
1938
+ type_: Any, injection_type: type[InjectedState | InjectedStore | ToolRuntime]
1939
+ ) -> Any | None:
1940
+ """Extract injection instance from a type annotation.
1941
+
1942
+ Args:
1943
+ type_: The type annotation to check.
1944
+ injection_type: The injection type to look for.
1945
+
1946
+ Returns:
1947
+ The injection instance if found, True if injection marker found without instance, None otherwise.
1948
+ """
1949
+ type_args = get_args(type_)
1950
+ matches = [arg for arg in type_args if _is_injection(arg, injection_type)]
1951
+
1952
+ if len(matches) > 1:
1953
+ msg = (
1954
+ f"A tool argument should not be annotated with {injection_type.__name__} "
1955
+ f"more than once. Found: {matches}"
1956
+ )
1957
+ raise ValueError(msg)
1958
+
1959
+ if len(matches) == 1:
1960
+ return matches[0]
1961
+ elif _is_injection(type_, injection_type):
1962
+ return True
1963
+
1964
+ return None
1965
+
1966
+
1967
+ def _get_all_injected_args(tool: BaseTool) -> _InjectedArgs:
1968
+ """Extract all injected arguments from tool in a single pass.
1969
+
1970
+ This function analyzes both the tool's input schema and function signature
1971
+ to identify all arguments that should be injected (state, store, runtime).
1972
+
1973
+ Args:
1974
+ tool: The tool to analyze for injection requirements.
1975
+
1976
+ Returns:
1977
+ _InjectedArgs structure containing all detected injections.
1978
+ """
1979
+ # Get annotations from both schema and function signature
1980
+ full_schema = tool.get_input_schema()
1981
+ schema_annotations = get_all_basemodel_annotations(full_schema)
1982
+
1983
+ func = getattr(tool, "func", None) or getattr(tool, "coroutine", None)
1984
+ func_annotations = get_type_hints(func, include_extras=True) if func else {}
1985
+
1986
+ # Combine both annotation sources, preferring schema annotations
1987
+ # In the future, we might want to add more restrictions here...
1988
+ all_annotations = {**func_annotations, **schema_annotations}
1989
+
1990
+ # Track injected args
1991
+ state_args: dict[str, str | None] = {}
1992
+ store_arg: str | None = None
1993
+ runtime_arg: str | None = None
1994
+ all_injected_keys: set[str] = set()
1995
+ _optional_state_args: set[str] = set()
1996
+
1997
+ for name, type_ in all_annotations.items():
1998
+ # Track all InjectedToolArg-annotated params (including custom subclasses)
1999
+ if _is_injected_arg_type(type_):
2000
+ all_injected_keys.add(name)
2001
+
2002
+ # Check for runtime (special case: parameter named "runtime")
2003
+ if name == "runtime":
2004
+ runtime_arg = name
2005
+
2006
+ # Check for InjectedState
2007
+ if state_inj := _get_injection_from_type(type_, InjectedState):
2008
+ if isinstance(state_inj, InjectedState) and state_inj.field:
2009
+ state_args[name] = state_inj.field
2010
+ field_info = full_schema.model_fields.get(name)
2011
+ if field_info and not field_info.is_required():
2012
+ _optional_state_args.add(name)
2013
+ else:
2014
+ state_args[name] = None
2015
+
2016
+ # Check for InjectedStore
2017
+ if _get_injection_from_type(type_, InjectedStore):
2018
+ store_arg = name
2019
+
2020
+ # Check for ToolRuntime
2021
+ if _get_injection_from_type(type_, ToolRuntime):
2022
+ runtime_arg = name
2023
+
2024
+ return _InjectedArgs(
2025
+ state=state_args,
2026
+ store=store_arg,
2027
+ runtime=runtime_arg,
2028
+ all_injected_keys=all_injected_keys,
2029
+ _optional_state_args=_optional_state_args,
2030
+ )