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
coco/agents/factory.py ADDED
@@ -0,0 +1,2164 @@
1
+ """Agent factory for creating agents with middleware support."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import functools
6
+ import importlib
7
+ import itertools
8
+ import re
9
+ from dataclasses import dataclass, field, fields
10
+ from typing import (
11
+ TYPE_CHECKING,
12
+ Annotated,
13
+ Any,
14
+ Generic,
15
+ cast,
16
+ get_args,
17
+ get_origin,
18
+ get_type_hints,
19
+ )
20
+
21
+ from coco_core.language_models.chat_models import BaseChatModel
22
+ from coco_core.messages import (
23
+ AIMessage,
24
+ AnyMessage,
25
+ RemoveMessage,
26
+ SystemMessage,
27
+ ToolMessage,
28
+ )
29
+ from coco_core.tools import BaseTool
30
+ from coco_graph._internal._runnable import RunnableCallable
31
+ from coco_graph.constants import END, START
32
+ from coco_graph.graph.message import REMOVE_ALL_MESSAGES
33
+ from coco_graph.graph.state import StateGraph
34
+ from coco_graph.prebuilt import ToolCallTransformer
35
+ from coco_graph.prebuilt.tool_node import ToolNode
36
+ from coco_graph.types import Command, Send
37
+ from coco_core.tracers._context import traceable
38
+ from typing_extensions import NotRequired, Required, TypedDict, overload
39
+
40
+ from coco.agents._subagent_transformer import SubagentTransformer
41
+ from coco.agents.middleware._trace_policy import (
42
+ _node_trace_policy,
43
+ _resolved_transform,
44
+ )
45
+ from coco.agents.middleware.types import (
46
+ AgentMiddleware,
47
+ AgentState,
48
+ ContextT,
49
+ ExtendedModelResponse,
50
+ InputAgentState,
51
+ JumpTo,
52
+ ModelRequest,
53
+ ModelResponse,
54
+ OmitFromSchema,
55
+ OutputAgentState,
56
+ ResponseT,
57
+ StateT_co,
58
+ ToolCallRequest,
59
+ )
60
+ from coco.agents.structured_output import (
61
+ AutoStrategy,
62
+ MultipleStructuredOutputsError,
63
+ OutputToolBinding,
64
+ ProviderStrategy,
65
+ ProviderStrategyBinding,
66
+ ResponseFormat,
67
+ StructuredOutputError,
68
+ StructuredOutputValidationError,
69
+ ToolStrategy,
70
+ )
71
+ from coco.chat_models import init_chat_model
72
+
73
+
74
+ @dataclass
75
+ class _ComposedExtendedModelResponse(Generic[ResponseT]):
76
+ """Internal result from composed `wrap_model_call` middleware.
77
+
78
+ Unlike `ExtendedModelResponse` (user-facing, single command), this holds the
79
+ full list of commands accumulated across all middleware layers during
80
+ composition.
81
+ """
82
+
83
+ model_response: ModelResponse[ResponseT]
84
+ """The underlying model response."""
85
+
86
+ commands: list[Command[Any]] = field(default_factory=list)
87
+ """Commands accumulated from all middleware layers (inner-first, then outer)."""
88
+
89
+
90
+ if TYPE_CHECKING:
91
+ from collections.abc import Awaitable, Callable, Iterable, Sequence
92
+
93
+ from coco_core.messages import InvalidToolCall
94
+ from coco_core.runnables import Runnable, RunnableConfig
95
+ from coco_graph.cache.base import BaseCache
96
+ from coco_graph.graph.state import CompiledStateGraph
97
+ from coco_graph.runtime import Runtime
98
+ from coco_graph.store.base import BaseStore
99
+ from coco_graph.stream._mux import TransformerFactory
100
+ from coco_graph.types import Checkpointer
101
+
102
+ from coco.agents.middleware.types import ToolCallWrapper
103
+
104
+ _ModelCallHandler = Callable[
105
+ [ModelRequest[ContextT], Callable[[ModelRequest[ContextT]], ModelResponse]],
106
+ ModelResponse | AIMessage | ExtendedModelResponse,
107
+ ]
108
+
109
+ _ComposedModelCallHandler = Callable[
110
+ [ModelRequest[ContextT], Callable[[ModelRequest[ContextT]], ModelResponse]],
111
+ _ComposedExtendedModelResponse,
112
+ ]
113
+
114
+ _AsyncModelCallHandler = Callable[
115
+ [ModelRequest[ContextT], Callable[[ModelRequest[ContextT]], Awaitable[ModelResponse]]],
116
+ Awaitable[ModelResponse | AIMessage | ExtendedModelResponse],
117
+ ]
118
+
119
+ _ComposedAsyncModelCallHandler = Callable[
120
+ [ModelRequest[ContextT], Callable[[ModelRequest[ContextT]], Awaitable[ModelResponse]]],
121
+ Awaitable[_ComposedExtendedModelResponse],
122
+ ]
123
+
124
+
125
+ STRUCTURED_OUTPUT_ERROR_TEMPLATE = "Error: {error}\n Please fix your mistakes."
126
+
127
+ DYNAMIC_TOOL_ERROR_TEMPLATE = """
128
+ Middleware added tools that the agent doesn't know how to execute.
129
+
130
+ Unknown tools: {unknown_tool_names}
131
+ Registered tools: {available_tool_names}
132
+
133
+ This happens when middleware modifies `request.tools` in `wrap_model_call` to include
134
+ tools that weren't passed to `create_agent()`.
135
+
136
+ How to fix this:
137
+
138
+ Option 1: Register tools at agent creation (recommended for most cases)
139
+ Pass the tools to `create_agent(tools=[...])` or set them on `middleware.tools`.
140
+ This makes tools available for every agent invocation.
141
+
142
+ Option 2: Handle dynamic tools in middleware (for tools created at runtime)
143
+ Implement `wrap_tool_call` to execute tools that are added dynamically:
144
+
145
+ class MyMiddleware(AgentMiddleware):
146
+ def wrap_tool_call(self, request, handler):
147
+ if request.tool_call["name"] == "dynamic_tool":
148
+ # Execute the dynamic tool yourself or override with tool instance
149
+ return handler(request.override(tool=my_dynamic_tool))
150
+ return handler(request)
151
+ """.strip()
152
+
153
+
154
+ def _scrub_inputs(inputs: dict[str, Any]) -> dict[str, Any]:
155
+ """Remove `runtime` and `handler` from trace inputs before sending to LangSmith."""
156
+ filtered = inputs.copy()
157
+ filtered.pop("handler", None)
158
+ req = filtered.get("request")
159
+ if isinstance(req, (ModelRequest, ToolCallRequest)):
160
+ filtered["request"] = {
161
+ f.name: getattr(req, f.name) for f in fields(req) if f.name != "runtime"
162
+ }
163
+ return filtered
164
+
165
+
166
+ def _wrap_trace_kwargs(middleware: AgentMiddleware[Any, Any]) -> dict[str, Any]:
167
+ """`traceable` kwargs for a middleware's `wrap_*` hook spans.
168
+
169
+ The `_scrub_inputs` baseline (strip the unserializable `handler`/`runtime`) always
170
+ runs first; the effective `TracePolicy` (the middleware's own, else the process-wide
171
+ default) composes on top. The effective policy is resolved at call time, so a
172
+ `configure_trace_policy` call after `create_agent` still applies.
173
+ """
174
+ process_inputs = _resolved_transform(middleware.trace_policy, "process_inputs")
175
+ process_outputs = _resolved_transform(middleware.trace_policy, "process_outputs")
176
+ return {
177
+ "process_inputs": lambda inputs: process_inputs(_scrub_inputs(inputs)),
178
+ "process_outputs": process_outputs,
179
+ }
180
+
181
+
182
+ FALLBACK_MODELS_WITH_STRUCTURED_OUTPUT = [
183
+ # If model profile data are not available, model names matching these patterns
184
+ # are assumed to support provider-native structured output. These are regexes
185
+ # so matches stay bounded to model-name segments instead of arbitrary substrings.
186
+ r"(^|[/:.])gpt-4\.1($|[-/:])",
187
+ r"(^|[/:.])gpt-4o($|[-/:])",
188
+ r"(^|[/:.])gpt-5($|[-/:])",
189
+ r"(^|[/:.])gpt-5\.1($|[-/:])",
190
+ r"(^|[/:.])gpt-5\.2(-\d{4}-\d{2}-\d{2})?($|[/:])",
191
+ r"(^|[/:.])gpt-5\.2-(chat|codex)($|[-/:])",
192
+ r"(^|[/:.])gpt-5\.3($|[-/:])",
193
+ r"(^|[/:.])gpt-5\.4(-\d{4}-\d{2}-\d{2})?($|[/:])",
194
+ r"(^|[/:.])gpt-5\.4-(mini|nano)($|[-/:])",
195
+ r"(^|[/:.])gpt-5\.5($|[-/:])",
196
+ r"(^|[/:.])gpt-6-(astra|luna|sol)($|[-/:])",
197
+ r"(^|[/:.])claude-(fable|mythos)-5(?:-\d{8})?(?:-v\d(?::\d)?)?($|[/:])",
198
+ r"(^|[/:.])claude-haiku-4-5(?:-\d{8})?(?:-v\d(?::\d)?)?($|[/:])",
199
+ r"(^|[/:.])claude-opus-4-(5|6|7|8)(?:-\d{8})?(?:-v\d(?::\d)?)?($|[/:])",
200
+ r"(^|[/:.])claude-sonnet-4-(5|6)(?:-\d{8})?(?:-v\d(?::\d)?)?($|[/:])",
201
+ r"(^|[/:.])grok-4($|[-.:/])",
202
+ r"(^|[/:.])grok-build($|[-/:])",
203
+ ]
204
+
205
+
206
+ def _normalize_to_model_response(
207
+ result: ModelResponse | AIMessage | ExtendedModelResponse,
208
+ ) -> ModelResponse:
209
+ """Normalize middleware return value to ModelResponse.
210
+
211
+ At inner composition boundaries, `ExtendedModelResponse` is unwrapped to its
212
+ underlying `ModelResponse` so that inner middleware always sees `ModelResponse`
213
+ from the handler.
214
+ """
215
+ if isinstance(result, AIMessage):
216
+ return ModelResponse(result=[result], structured_response=None)
217
+ if isinstance(result, ExtendedModelResponse):
218
+ return result.model_response
219
+ return result
220
+
221
+
222
+ def _build_commands(
223
+ model_response: ModelResponse,
224
+ middleware_commands: list[Command[Any]] | None = None,
225
+ *,
226
+ has_structured_output: bool = False,
227
+ repaired_messages: list[AnyMessage] | None = None,
228
+ ) -> list[Command[Any]]:
229
+ """Build a list of Commands from a model response and middleware commands.
230
+
231
+ The first Command contains the model response state (messages and optional
232
+ structured_response). Middleware commands are appended as-is.
233
+
234
+ Args:
235
+ model_response: The model response containing messages and optional
236
+ structured output.
237
+ middleware_commands: Commands accumulated from middleware layers during
238
+ composition (inner-first ordering).
239
+ has_structured_output: Whether the agent was configured with a
240
+ `response_format`. When `True` and no structured response was
241
+ produced, `structured_response` is explicitly cleared to avoid a
242
+ stale value from a previous checkpointed turn.
243
+ repaired_messages: Complete message history after repairing invalid tool calls.
244
+
245
+ Returns:
246
+ List of `Command` objects ready to be returned from a model node.
247
+ """
248
+ messages = model_response.result
249
+ if repaired_messages is not None:
250
+ messages = [
251
+ RemoveMessage(id=REMOVE_ALL_MESSAGES),
252
+ *repaired_messages,
253
+ *model_response.result,
254
+ ]
255
+ state: dict[str, Any] = {"messages": messages}
256
+
257
+ if model_response.structured_response is not None:
258
+ state["structured_response"] = model_response.structured_response
259
+ elif has_structured_output:
260
+ state["structured_response"] = None
261
+
262
+ for cmd in middleware_commands or []:
263
+ if cmd.goto:
264
+ msg = (
265
+ "Command goto is not yet supported in wrap_model_call middleware. "
266
+ "Use the jump_to state field with before_model/after_model hooks instead."
267
+ )
268
+ raise NotImplementedError(msg)
269
+ if cmd.resume:
270
+ msg = "Command resume is not yet supported in wrap_model_call middleware."
271
+ raise NotImplementedError(msg)
272
+ if cmd.graph:
273
+ msg = "Command graph is not yet supported in wrap_model_call middleware."
274
+ raise NotImplementedError(msg)
275
+
276
+ commands: list[Command[Any]] = [Command(update=state)]
277
+ commands.extend(middleware_commands or [])
278
+ return commands
279
+
280
+
281
+ def _chain_model_call_handlers(
282
+ handlers: Sequence[_ModelCallHandler[ContextT]],
283
+ ) -> _ComposedModelCallHandler[ContextT] | None:
284
+ """Compose multiple `wrap_model_call` handlers into single middleware stack.
285
+
286
+ Composes handlers so first in list becomes outermost layer. Each handler receives a
287
+ handler callback to execute inner layers. Commands from each layer are accumulated
288
+ into a list (inner-first, then outer) without merging.
289
+
290
+ Args:
291
+ handlers: List of handlers.
292
+
293
+ First handler wraps all others.
294
+
295
+ Returns:
296
+ Composed handler returning `_ComposedExtendedModelResponse`,
297
+ or `None` if handlers empty.
298
+ """
299
+ if not handlers:
300
+ return None
301
+
302
+ def _to_composed_result(
303
+ result: ModelResponse | AIMessage | ExtendedModelResponse | _ComposedExtendedModelResponse,
304
+ extra_commands: list[Command[Any]] | None = None,
305
+ ) -> _ComposedExtendedModelResponse:
306
+ """Normalize any handler result to _ComposedExtendedModelResponse."""
307
+ commands: list[Command[Any]] = list(extra_commands or [])
308
+ if isinstance(result, _ComposedExtendedModelResponse):
309
+ commands.extend(result.commands)
310
+ model_response = result.model_response
311
+ elif isinstance(result, ExtendedModelResponse):
312
+ model_response = result.model_response
313
+ if result.command is not None:
314
+ commands.append(result.command)
315
+ else:
316
+ model_response = _normalize_to_model_response(result)
317
+
318
+ return _ComposedExtendedModelResponse(model_response=model_response, commands=commands)
319
+
320
+ if len(handlers) == 1:
321
+ single_handler = handlers[0]
322
+
323
+ def normalized_single(
324
+ request: ModelRequest[ContextT],
325
+ handler: Callable[[ModelRequest[ContextT]], ModelResponse],
326
+ ) -> _ComposedExtendedModelResponse:
327
+ return _to_composed_result(single_handler(request, handler))
328
+
329
+ return normalized_single
330
+
331
+ def compose_two(
332
+ outer: _ModelCallHandler[ContextT] | _ComposedModelCallHandler[ContextT],
333
+ inner: _ModelCallHandler[ContextT] | _ComposedModelCallHandler[ContextT],
334
+ ) -> _ComposedModelCallHandler[ContextT]:
335
+ """Compose two handlers where outer wraps inner."""
336
+
337
+ def composed(
338
+ request: ModelRequest[ContextT],
339
+ handler: Callable[[ModelRequest[ContextT]], ModelResponse],
340
+ ) -> _ComposedExtendedModelResponse:
341
+ # Closure variable to capture inner's commands before normalizing
342
+ accumulated_commands: list[Command[Any]] = []
343
+
344
+ def inner_handler(req: ModelRequest[ContextT]) -> ModelResponse:
345
+ # Clear on each call for retry safety
346
+ accumulated_commands.clear()
347
+ inner_result = inner(req, handler)
348
+ if isinstance(inner_result, _ComposedExtendedModelResponse):
349
+ accumulated_commands.extend(inner_result.commands)
350
+ return inner_result.model_response
351
+ if isinstance(inner_result, ExtendedModelResponse):
352
+ if inner_result.command is not None:
353
+ accumulated_commands.append(inner_result.command)
354
+ return inner_result.model_response
355
+ return _normalize_to_model_response(inner_result)
356
+
357
+ outer_result = outer(request, inner_handler)
358
+ return _to_composed_result(
359
+ outer_result,
360
+ extra_commands=accumulated_commands or None,
361
+ )
362
+
363
+ return composed
364
+
365
+ # Compose right-to-left: outer(inner(innermost(handler)))
366
+ composed_handler = compose_two(handlers[-2], handlers[-1])
367
+ for h in reversed(handlers[:-2]):
368
+ composed_handler = compose_two(h, composed_handler)
369
+
370
+ return composed_handler
371
+
372
+
373
+ def _chain_async_model_call_handlers(
374
+ handlers: Sequence[_AsyncModelCallHandler[ContextT]],
375
+ ) -> _ComposedAsyncModelCallHandler[ContextT] | None:
376
+ """Compose multiple async `wrap_model_call` handlers into single middleware stack.
377
+
378
+ Commands from each layer are accumulated into a list (inner-first, then outer)
379
+ without merging.
380
+
381
+ Args:
382
+ handlers: List of async handlers.
383
+
384
+ First handler wraps all others.
385
+
386
+ Returns:
387
+ Composed async handler returning `_ComposedExtendedModelResponse`,
388
+ or `None` if handlers empty.
389
+ """
390
+ if not handlers:
391
+ return None
392
+
393
+ def _to_composed_result(
394
+ result: ModelResponse | AIMessage | ExtendedModelResponse | _ComposedExtendedModelResponse,
395
+ extra_commands: list[Command[Any]] | None = None,
396
+ ) -> _ComposedExtendedModelResponse:
397
+ """Normalize any handler result to _ComposedExtendedModelResponse."""
398
+ commands: list[Command[Any]] = list(extra_commands or [])
399
+ if isinstance(result, _ComposedExtendedModelResponse):
400
+ commands.extend(result.commands)
401
+ model_response = result.model_response
402
+ elif isinstance(result, ExtendedModelResponse):
403
+ model_response = result.model_response
404
+ if result.command is not None:
405
+ commands.append(result.command)
406
+ else:
407
+ model_response = _normalize_to_model_response(result)
408
+
409
+ return _ComposedExtendedModelResponse(model_response=model_response, commands=commands)
410
+
411
+ if len(handlers) == 1:
412
+ single_handler = handlers[0]
413
+
414
+ async def normalized_single(
415
+ request: ModelRequest[ContextT],
416
+ handler: Callable[[ModelRequest[ContextT]], Awaitable[ModelResponse]],
417
+ ) -> _ComposedExtendedModelResponse:
418
+ return _to_composed_result(await single_handler(request, handler))
419
+
420
+ return normalized_single
421
+
422
+ def compose_two(
423
+ outer: _AsyncModelCallHandler[ContextT] | _ComposedAsyncModelCallHandler[ContextT],
424
+ inner: _AsyncModelCallHandler[ContextT] | _ComposedAsyncModelCallHandler[ContextT],
425
+ ) -> _ComposedAsyncModelCallHandler[ContextT]:
426
+ """Compose two async handlers where outer wraps inner."""
427
+
428
+ async def composed(
429
+ request: ModelRequest[ContextT],
430
+ handler: Callable[[ModelRequest[ContextT]], Awaitable[ModelResponse]],
431
+ ) -> _ComposedExtendedModelResponse:
432
+ # Closure variable to capture inner's commands before normalizing
433
+ accumulated_commands: list[Command[Any]] = []
434
+
435
+ async def inner_handler(req: ModelRequest[ContextT]) -> ModelResponse:
436
+ # Clear on each call for retry safety
437
+ accumulated_commands.clear()
438
+ inner_result = await inner(req, handler)
439
+ if isinstance(inner_result, _ComposedExtendedModelResponse):
440
+ accumulated_commands.extend(inner_result.commands)
441
+ return inner_result.model_response
442
+ if isinstance(inner_result, ExtendedModelResponse):
443
+ if inner_result.command is not None:
444
+ accumulated_commands.append(inner_result.command)
445
+ return inner_result.model_response
446
+ return _normalize_to_model_response(inner_result)
447
+
448
+ outer_result = await outer(request, inner_handler)
449
+ return _to_composed_result(
450
+ outer_result,
451
+ extra_commands=accumulated_commands or None,
452
+ )
453
+
454
+ return composed
455
+
456
+ # Compose right-to-left: outer(inner(innermost(handler)))
457
+ composed_handler = compose_two(handlers[-2], handlers[-1])
458
+ for h in reversed(handlers[:-2]):
459
+ composed_handler = compose_two(h, composed_handler)
460
+
461
+ return composed_handler
462
+
463
+
464
+ @functools.lru_cache(maxsize=100)
465
+ def _get_schema_type_hints(schema: type) -> dict[str, Any]:
466
+ """Return cached type hints for a schema."""
467
+ return get_type_hints(schema, include_extras=True)
468
+
469
+
470
+ def _resolve_schemas(schemas: list[type]) -> tuple[type, type, type]:
471
+ """Resolve state, input, and output schemas for the given schemas.
472
+
473
+ Schemas are merged in list order; later entries override earlier ones when the
474
+ same field is declared by multiple schemas. Duplicates are harmless — a type
475
+ that appears more than once is processed at its last position.
476
+ """
477
+ schema_hints: dict[type, dict[str, Any]] = {}
478
+ for schema in schemas:
479
+ # Reinsert duplicates so dict iteration reflects their final position.
480
+ schema_hints.pop(schema, None)
481
+ schema_hints[schema] = _get_schema_type_hints(schema)
482
+ return (
483
+ _resolve_schema(schema_hints, "StateSchema", None),
484
+ _resolve_schema(schema_hints, "InputSchema", "input"),
485
+ _resolve_schema(schema_hints, "OutputSchema", "output"),
486
+ )
487
+
488
+
489
+ def _resolve_schema(
490
+ schema_hints: dict[type, dict[str, Any]],
491
+ schema_name: str,
492
+ omit_flag: str | None = None,
493
+ ) -> type:
494
+ """Resolve schema by merging schemas and optionally respecting `OmitFromSchema` annotations.
495
+
496
+ Args:
497
+ schema_hints: Resolved schema annotations to merge
498
+ schema_name: Name for the generated `TypedDict`
499
+ omit_flag: If specified, omit fields with this flag set (`'input'` or
500
+ `'output'`)
501
+
502
+ Returns:
503
+ Merged schema as `TypedDict`
504
+ """
505
+ all_annotations = {}
506
+
507
+ for hints in schema_hints.values():
508
+ for field_name, field_type in hints.items():
509
+ should_omit = False
510
+
511
+ if omit_flag:
512
+ metadata = _extract_metadata(field_type)
513
+ for meta in metadata:
514
+ if isinstance(meta, OmitFromSchema) and getattr(meta, omit_flag) is True:
515
+ should_omit = True
516
+ break
517
+
518
+ if not should_omit:
519
+ all_annotations[field_name] = field_type
520
+
521
+ # `TypedDict` dynamically creates a class, but type checkers don't infer that
522
+ # the runtime result satisfies this function's `type` return contract.
523
+ return cast("type", TypedDict(schema_name, all_annotations)) # type: ignore[operator]
524
+
525
+
526
+ def _extract_metadata(type_: type) -> list[Any]:
527
+ """Extract metadata from a field type, handling `Required`/`NotRequired` and `Annotated` wrappers.""" # noqa: E501
528
+ # Handle Required[Annotated[...]] or NotRequired[Annotated[...]]
529
+ if get_origin(type_) in {Required, NotRequired}:
530
+ inner_type = get_args(type_)[0]
531
+ if get_origin(inner_type) is Annotated:
532
+ return list(get_args(inner_type)[1:])
533
+
534
+ # Handle direct Annotated[...]
535
+ elif get_origin(type_) is Annotated:
536
+ return list(get_args(type_)[1:])
537
+
538
+ return []
539
+
540
+
541
+ def _get_can_jump_to(middleware: AgentMiddleware[Any, Any], hook_name: str) -> list[JumpTo]:
542
+ """Get the `can_jump_to` list from either sync or async hook methods.
543
+
544
+ Args:
545
+ middleware: The middleware instance to inspect.
546
+ hook_name: The name of the hook (`'before_model'` or `'after_model'`).
547
+
548
+ Returns:
549
+ List of jump destinations, or empty list if not configured.
550
+ """
551
+ # Get the base class method for comparison
552
+ base_sync_method = getattr(AgentMiddleware, hook_name, None)
553
+ base_async_method = getattr(AgentMiddleware, f"a{hook_name}", None)
554
+
555
+ # Try sync method first - only if it's overridden from base class
556
+ sync_method = getattr(middleware.__class__, hook_name, None)
557
+ if (
558
+ sync_method
559
+ and sync_method is not base_sync_method
560
+ and hasattr(sync_method, "__can_jump_to__")
561
+ ):
562
+ # `hasattr` proves the metadata exists at runtime, but not its value type.
563
+ return cast("list[JumpTo]", sync_method.__can_jump_to__)
564
+
565
+ # Try async method - only if it's overridden from base class
566
+ async_method = getattr(middleware.__class__, f"a{hook_name}", None)
567
+ if (
568
+ async_method
569
+ and async_method is not base_async_method
570
+ and hasattr(async_method, "__can_jump_to__")
571
+ ):
572
+ # `hasattr` proves the metadata exists at runtime, but not its value type.
573
+ return cast("list[JumpTo]", async_method.__can_jump_to__)
574
+
575
+ return []
576
+
577
+
578
+ def _supports_provider_strategy(
579
+ model: str | BaseChatModel, tools: list[BaseTool | dict[str, Any]] | None = None
580
+ ) -> bool:
581
+ """Check if a model supports provider-specific structured output.
582
+
583
+ Args:
584
+ model: Model name string or `BaseChatModel` instance.
585
+ tools: Optional list of tools provided to the agent.
586
+
587
+ Needed because some models don't support structured output together with tool calling.
588
+
589
+ Returns:
590
+ `True` if the model supports provider-specific structured output, `False` otherwise.
591
+ """
592
+ model_name: str | None = None
593
+ if isinstance(model, str):
594
+ model_name = model
595
+ elif isinstance(model, BaseChatModel):
596
+ model_name = (
597
+ getattr(model, "model_name", None)
598
+ or getattr(model, "model", None)
599
+ or getattr(model, "model_id", "")
600
+ )
601
+ model_profile = model.profile
602
+ if (
603
+ model_profile is not None
604
+ and model_profile.get("structured_output")
605
+ # We make an exception for Gemini < 3-series models, which currently do not support
606
+ # simultaneous tool use with structured output; 3-series can.
607
+ and not (
608
+ tools
609
+ and isinstance(model_name, str)
610
+ and "gemini" in model_name.lower()
611
+ and "gemini-3" not in model_name.lower()
612
+ )
613
+ ):
614
+ return True
615
+
616
+ return (
617
+ any(
618
+ re.search(pattern, model_name.lower())
619
+ for pattern in FALLBACK_MODELS_WITH_STRUCTURED_OUTPUT
620
+ )
621
+ if model_name
622
+ else False
623
+ )
624
+
625
+
626
+ def _is_openai_compatible_model(model: BaseChatModel) -> bool:
627
+ """Check if a model inherits from `BaseChatOpenAI`.
628
+
629
+ Used to redundantly set `strict=True` on tools when `response_format` is
630
+ provided, as older versions of `trustcoco-openai` do not auto-set it.
631
+ Covers `ChatOpenAI`, `ChatDeepSeek`, `ChatXAI`, etc.
632
+
633
+ Args:
634
+ model: The chat model to check.
635
+
636
+ Returns:
637
+ `True` if the model inherits from `BaseChatOpenAI`, `False` otherwise.
638
+ """
639
+ try:
640
+ base_chat_openai = importlib.import_module("coco_openai.chat_models.base")
641
+ except ImportError:
642
+ return False
643
+ return isinstance(model, base_chat_openai.BaseChatOpenAI)
644
+
645
+
646
+ def _handle_structured_output_error(
647
+ exception: Exception,
648
+ response_format: ResponseFormat[Any],
649
+ ) -> tuple[bool, str]:
650
+ """Handle structured output error.
651
+
652
+ Returns `(should_retry, retry_tool_message)`.
653
+ """
654
+ if not isinstance(response_format, ToolStrategy):
655
+ return False, ""
656
+
657
+ handle_errors = response_format.handle_errors
658
+
659
+ if handle_errors is False:
660
+ return False, ""
661
+ if handle_errors is True:
662
+ return True, STRUCTURED_OUTPUT_ERROR_TEMPLATE.format(error=str(exception))
663
+ if isinstance(handle_errors, str):
664
+ return True, handle_errors
665
+ if isinstance(handle_errors, type):
666
+ if issubclass(handle_errors, Exception) and isinstance(exception, handle_errors):
667
+ return True, STRUCTURED_OUTPUT_ERROR_TEMPLATE.format(error=str(exception))
668
+ return False, ""
669
+ if isinstance(handle_errors, tuple):
670
+ if any(isinstance(exception, exc_type) for exc_type in handle_errors):
671
+ return True, STRUCTURED_OUTPUT_ERROR_TEMPLATE.format(error=str(exception))
672
+ return False, ""
673
+ return True, handle_errors(exception)
674
+
675
+
676
+ def _invalid_tool_call_message(tool_call: InvalidToolCall) -> ToolMessage | None:
677
+ tool_call_id = tool_call.get("id")
678
+ if tool_call_id is None:
679
+ return None
680
+ name = tool_call.get("name") or "unknown"
681
+ return ToolMessage(
682
+ content=(
683
+ f"Tool call {name} with id {tool_call_id} could not be executed - "
684
+ "arguments were malformed or truncated."
685
+ ),
686
+ name=name,
687
+ tool_call_id=tool_call_id,
688
+ status="error",
689
+ )
690
+
691
+
692
+ def _patch_invalid_tool_calls(messages: Sequence[AnyMessage]) -> list[AnyMessage]:
693
+ answered_ids = {
694
+ message.tool_call_id for message in messages if isinstance(message, ToolMessage)
695
+ }
696
+ patched_messages: list[AnyMessage] = []
697
+ for message in messages:
698
+ patched_messages.append(message)
699
+ if not isinstance(message, AIMessage):
700
+ continue
701
+ for tool_call in message.invalid_tool_calls:
702
+ if tool_call.get("id") in answered_ids:
703
+ continue
704
+ if tool_message := _invalid_tool_call_message(tool_call):
705
+ patched_messages.append(tool_message)
706
+ answered_ids.add(tool_message.tool_call_id)
707
+ return patched_messages
708
+
709
+
710
+ def _chain_tool_call_wrappers(
711
+ wrappers: Sequence[ToolCallWrapper],
712
+ ) -> ToolCallWrapper | None:
713
+ """Compose wrappers into middleware stack (first = outermost).
714
+
715
+ Args:
716
+ wrappers: Wrappers in middleware order.
717
+
718
+ Returns:
719
+ Composed wrapper, or `None` if empty.
720
+
721
+ Example:
722
+ ```python
723
+ wrapper = _chain_tool_call_wrappers([auth, cache, retry])
724
+ # Request flows: auth -> cache -> retry -> tool
725
+ # Response flows: tool -> retry -> cache -> auth
726
+ ```
727
+ """
728
+ if not wrappers:
729
+ return None
730
+
731
+ if len(wrappers) == 1:
732
+ return wrappers[0]
733
+
734
+ def compose_two(outer: ToolCallWrapper, inner: ToolCallWrapper) -> ToolCallWrapper:
735
+ """Compose two wrappers where outer wraps inner."""
736
+
737
+ def composed(
738
+ request: ToolCallRequest,
739
+ execute: Callable[[ToolCallRequest], ToolMessage | Command[Any]],
740
+ ) -> ToolMessage | Command[Any]:
741
+ # Create a callable that invokes inner with the original execute
742
+ def call_inner(req: ToolCallRequest) -> ToolMessage | Command[Any]:
743
+ return inner(req, execute)
744
+
745
+ # Outer can call call_inner multiple times
746
+ return outer(request, call_inner)
747
+
748
+ return composed
749
+
750
+ # Chain all wrappers: first -> second -> ... -> last
751
+ result = wrappers[-1]
752
+ for wrapper in reversed(wrappers[:-1]):
753
+ result = compose_two(wrapper, result)
754
+
755
+ return result
756
+
757
+
758
+ def _chain_async_tool_call_wrappers(
759
+ wrappers: Sequence[
760
+ Callable[
761
+ [ToolCallRequest, Callable[[ToolCallRequest], Awaitable[ToolMessage | Command[Any]]]],
762
+ Awaitable[ToolMessage | Command[Any]],
763
+ ]
764
+ ],
765
+ ) -> (
766
+ Callable[
767
+ [ToolCallRequest, Callable[[ToolCallRequest], Awaitable[ToolMessage | Command[Any]]]],
768
+ Awaitable[ToolMessage | Command[Any]],
769
+ ]
770
+ | None
771
+ ):
772
+ """Compose async wrappers into middleware stack (first = outermost).
773
+
774
+ Args:
775
+ wrappers: Async wrappers in middleware order.
776
+
777
+ Returns:
778
+ Composed async wrapper, or `None` if empty.
779
+ """
780
+ if not wrappers:
781
+ return None
782
+
783
+ if len(wrappers) == 1:
784
+ return wrappers[0]
785
+
786
+ def compose_two(
787
+ outer: Callable[
788
+ [ToolCallRequest, Callable[[ToolCallRequest], Awaitable[ToolMessage | Command[Any]]]],
789
+ Awaitable[ToolMessage | Command[Any]],
790
+ ],
791
+ inner: Callable[
792
+ [ToolCallRequest, Callable[[ToolCallRequest], Awaitable[ToolMessage | Command[Any]]]],
793
+ Awaitable[ToolMessage | Command[Any]],
794
+ ],
795
+ ) -> Callable[
796
+ [ToolCallRequest, Callable[[ToolCallRequest], Awaitable[ToolMessage | Command[Any]]]],
797
+ Awaitable[ToolMessage | Command[Any]],
798
+ ]:
799
+ """Compose two async wrappers where outer wraps inner."""
800
+
801
+ async def composed(
802
+ request: ToolCallRequest,
803
+ execute: Callable[[ToolCallRequest], Awaitable[ToolMessage | Command[Any]]],
804
+ ) -> ToolMessage | Command[Any]:
805
+ # Create an async callable that invokes inner with the original execute
806
+ async def call_inner(req: ToolCallRequest) -> ToolMessage | Command[Any]:
807
+ return await inner(req, execute)
808
+
809
+ # Outer can call call_inner multiple times
810
+ return await outer(request, call_inner)
811
+
812
+ return composed
813
+
814
+ # Chain all wrappers: first -> second -> ... -> last
815
+ result = wrappers[-1]
816
+ for wrapper in reversed(wrappers[:-1]):
817
+ result = compose_two(wrapper, result)
818
+
819
+ return result
820
+
821
+
822
+ # No `response_format`: there is no structured output, so `ResponseT` resolves to `Any`.
823
+ @overload
824
+ def create_agent(
825
+ model: str | BaseChatModel,
826
+ tools: Sequence[BaseTool | Callable[..., Any] | dict[str, Any]] | None = None,
827
+ *,
828
+ system_prompt: str | SystemMessage | None = None,
829
+ middleware: Sequence[AgentMiddleware[StateT_co, ContextT]] = (),
830
+ response_format: None = None,
831
+ state_schema: None = None,
832
+ context_schema: type[ContextT] | None = None,
833
+ checkpointer: Checkpointer | None = None,
834
+ store: BaseStore | None = None,
835
+ interrupt_before: list[str] | None = None,
836
+ interrupt_after: list[str] | None = None,
837
+ debug: bool = False,
838
+ name: str | None = None,
839
+ cache: BaseCache[Any] | None = None,
840
+ transformers: Sequence[TransformerFactory] | None = None,
841
+ ) -> CompiledStateGraph[AgentState[Any], ContextT, InputAgentState, OutputAgentState[Any]]: ...
842
+
843
+
844
+ # Raw-dict `response_format`: structured output is an untyped `dict[str, Any]`.
845
+ @overload
846
+ def create_agent(
847
+ model: str | BaseChatModel,
848
+ tools: Sequence[BaseTool | Callable[..., Any] | dict[str, Any]] | None = None,
849
+ *,
850
+ system_prompt: str | SystemMessage | None = None,
851
+ middleware: Sequence[AgentMiddleware[StateT_co, ContextT]] = (),
852
+ response_format: dict[str, Any],
853
+ state_schema: type[AgentState[dict[str, Any]]] | None = None,
854
+ context_schema: type[ContextT] | None = None,
855
+ checkpointer: Checkpointer | None = None,
856
+ store: BaseStore | None = None,
857
+ interrupt_before: list[str] | None = None,
858
+ interrupt_after: list[str] | None = None,
859
+ debug: bool = False,
860
+ name: str | None = None,
861
+ cache: BaseCache[Any] | None = None,
862
+ transformers: Sequence[TransformerFactory] | None = None,
863
+ ) -> CompiledStateGraph[
864
+ AgentState[dict[str, Any]], ContextT, InputAgentState, OutputAgentState[dict[str, Any]]
865
+ ]: ...
866
+
867
+
868
+ # Schema-typed `response_format`: `ResponseT` is inferred from the schema/type.
869
+ @overload
870
+ def create_agent(
871
+ model: str | BaseChatModel,
872
+ tools: Sequence[BaseTool | Callable[..., Any] | dict[str, Any]] | None = None,
873
+ *,
874
+ system_prompt: str | SystemMessage | None = None,
875
+ middleware: Sequence[AgentMiddleware[StateT_co, ContextT]] = (),
876
+ response_format: ResponseFormat[ResponseT] | type[ResponseT] | None = None,
877
+ state_schema: type[AgentState[ResponseT]] | None = None,
878
+ context_schema: type[ContextT] | None = None,
879
+ checkpointer: Checkpointer | None = None,
880
+ store: BaseStore | None = None,
881
+ interrupt_before: list[str] | None = None,
882
+ interrupt_after: list[str] | None = None,
883
+ debug: bool = False,
884
+ name: str | None = None,
885
+ cache: BaseCache[Any] | None = None,
886
+ transformers: Sequence[TransformerFactory] | None = None,
887
+ ) -> CompiledStateGraph[
888
+ AgentState[ResponseT], ContextT, InputAgentState, OutputAgentState[ResponseT]
889
+ ]: ...
890
+
891
+
892
+ def create_agent(
893
+ model: str | BaseChatModel,
894
+ tools: Sequence[BaseTool | Callable[..., Any] | dict[str, Any]] | None = None,
895
+ *,
896
+ system_prompt: str | SystemMessage | None = None,
897
+ middleware: Sequence[AgentMiddleware[StateT_co, ContextT]] = (),
898
+ response_format: ResponseFormat[ResponseT] | type[ResponseT] | dict[str, Any] | None = None,
899
+ state_schema: type[AgentState[ResponseT]] | None = None,
900
+ context_schema: type[ContextT] | None = None,
901
+ checkpointer: Checkpointer | None = None,
902
+ store: BaseStore | None = None,
903
+ interrupt_before: list[str] | None = None,
904
+ interrupt_after: list[str] | None = None,
905
+ debug: bool = False,
906
+ name: str | None = None,
907
+ cache: BaseCache[Any] | None = None,
908
+ transformers: Sequence[TransformerFactory] | None = None,
909
+ ) -> CompiledStateGraph[
910
+ AgentState[ResponseT], ContextT, InputAgentState, OutputAgentState[ResponseT]
911
+ ]:
912
+ """Creates an agent graph that calls tools in a loop until a stopping condition is met.
913
+
914
+ For more details on using `create_agent`,
915
+ visit the [Agents](https://docs.langchain.com/oss/python/langchain/agents) docs.
916
+
917
+ Args:
918
+ model: The language model for the agent.
919
+
920
+ Can be a string identifier (e.g., `"openai:gpt-5.5"`) or a direct chat model
921
+ instance (e.g., [`ChatOpenAI`][coco_openai.ChatOpenAI] or other another
922
+ [Coco chat model](https://docs.langchain.com/oss/python/integrations/chat)).
923
+
924
+ For a full list of supported model strings, see
925
+ [`init_chat_model`][coco.chat_models.init_chat_model(model_provider)].
926
+
927
+ !!! tip ""
928
+
929
+ See the [Models](https://docs.langchain.com/oss/python/langchain/models)
930
+ docs for more information.
931
+ tools: A list of tools, `dict`, or `Callable`.
932
+
933
+ If `None` or an empty list, the agent will consist of a model node without a
934
+ tool calling loop.
935
+
936
+
937
+ !!! tip ""
938
+
939
+ See the [Tools](https://docs.langchain.com/oss/python/langchain/tools)
940
+ docs for more information.
941
+ system_prompt: An optional system prompt for the LLM.
942
+
943
+ Can be a `str` (which will be converted to a `SystemMessage`) or a
944
+ `SystemMessage` instance directly. The system message is added to the
945
+ beginning of the message list when calling the model.
946
+ middleware: A sequence of middleware instances to apply to the agent.
947
+
948
+ Middleware can intercept and modify agent behavior at various stages.
949
+
950
+ !!! tip ""
951
+
952
+ See the [Middleware](https://docs.langchain.com/oss/python/langchain/middleware)
953
+ docs for more information.
954
+ response_format: An optional configuration for structured responses.
955
+
956
+ Can be a `ToolStrategy`, `ProviderStrategy`, or a Pydantic model class.
957
+
958
+ If provided, the agent will handle structured output during the
959
+ conversation flow.
960
+
961
+ Raw schemas will be wrapped in an appropriate strategy based on model
962
+ capabilities.
963
+
964
+ !!! tip ""
965
+
966
+ See the [Structured output](https://docs.langchain.com/oss/python/langchain/structured-output)
967
+ docs for more information.
968
+ state_schema: An optional `TypedDict` schema that extends `AgentState`.
969
+
970
+ When provided, this schema is used instead of `AgentState` as the base
971
+ schema for merging with middleware state schemas. This allows users to
972
+ add custom state fields without needing to create custom middleware.
973
+
974
+ Generally, it's recommended to use `state_schema` extensions via middleware
975
+ to keep relevant extensions scoped to corresponding hooks / tools.
976
+ context_schema: An optional schema for runtime context.
977
+ checkpointer: An optional checkpoint saver object.
978
+
979
+ Used for persisting the state of the graph (e.g., as chat memory) for a
980
+ single thread (e.g., a single conversation).
981
+ store: An optional store object.
982
+
983
+ Used for persisting data across multiple threads (e.g., multiple
984
+ conversations / users).
985
+ interrupt_before: An optional list of node names to interrupt before.
986
+
987
+ Useful if you want to add a user confirmation or other interrupt
988
+ before taking an action.
989
+ interrupt_after: An optional list of node names to interrupt after.
990
+
991
+ Useful if you want to return directly or run additional processing
992
+ on an output.
993
+ debug: Whether to enable verbose logging for graph execution.
994
+
995
+ When enabled, prints detailed information about each node execution, state
996
+ updates, and transitions during agent runtime. Useful for debugging
997
+ middleware behavior and understanding agent execution flow.
998
+ name: An optional name for the `CompiledStateGraph`.
999
+
1000
+ This name will be automatically used when adding the agent graph to
1001
+ another graph as a subgraph node - particularly useful for building
1002
+ multi-agent systems.
1003
+ cache: An optional `BaseCache` instance to enable caching of graph execution.
1004
+ transformers: Optional sequence of scope-aware `StreamTransformer`
1005
+ factories to register on the compiled graph in addition to
1006
+ the agent defaults. Each factory is invoked as `factory(scope)`
1007
+ so every invocation receives a fresh instance. The final order
1008
+ on the compiled graph is: `ToolCallTransformer`, then any
1009
+ factories declared by middleware via
1010
+ `AgentMiddleware.transformers`, then any factories supplied here.
1011
+
1012
+ Returns:
1013
+ A compiled `StateGraph` that can be used for chat interactions.
1014
+
1015
+ Raises:
1016
+ AssertionError: If duplicate middleware instances are provided.
1017
+
1018
+ The agent node calls the language model with the messages list (after applying
1019
+ the system prompt). If the resulting [`AIMessage`][coco.messages.AIMessage]
1020
+ contains `tool_calls`, the graph will then call the tools. The tools node executes
1021
+ the tools and adds the responses to the messages list as
1022
+ [`ToolMessage`][coco.messages.ToolMessage] objects. The agent node then calls
1023
+ the language model again. The process repeats until no more `tool_calls` are present
1024
+ in the response. The agent then returns the full list of messages.
1025
+
1026
+ Example:
1027
+ ```python
1028
+ from coco.agents import create_agent
1029
+
1030
+
1031
+ def check_weather(location: str) -> str:
1032
+ '''Return the weather forecast for the specified location.'''
1033
+ return f"It's always sunny in {location}"
1034
+
1035
+
1036
+ graph = create_agent(
1037
+ model="anthropic:claude-sonnet-4-5-20250929",
1038
+ tools=[check_weather],
1039
+ system_prompt="You are a helpful assistant",
1040
+ )
1041
+ inputs = {"messages": [{"role": "user", "content": "what is the weather in sf"}]}
1042
+ for chunk in graph.stream(inputs, stream_mode="updates"):
1043
+ print(chunk)
1044
+ ```
1045
+ """
1046
+ # init chat model
1047
+ if isinstance(model, str):
1048
+ model = init_chat_model(model)
1049
+
1050
+ # Convert system_prompt to SystemMessage if needed
1051
+ system_message: SystemMessage | None = None
1052
+ if system_prompt is not None:
1053
+ if isinstance(system_prompt, SystemMessage):
1054
+ system_message = system_prompt
1055
+ else:
1056
+ system_message = SystemMessage(content=system_prompt)
1057
+
1058
+ # Handle tools being None or empty
1059
+ if tools is None:
1060
+ tools = []
1061
+
1062
+ # Convert response format and setup structured output tools
1063
+ # Raw schemas are wrapped in AutoStrategy to preserve auto-detection intent.
1064
+ # AutoStrategy is converted to ToolStrategy upfront to calculate tools during agent creation,
1065
+ # but may be replaced with ProviderStrategy later based on model capabilities.
1066
+ initial_response_format: ToolStrategy[Any] | ProviderStrategy[Any] | AutoStrategy[Any] | None
1067
+ if response_format is None:
1068
+ initial_response_format = None
1069
+ elif isinstance(response_format, (ToolStrategy, ProviderStrategy, AutoStrategy)):
1070
+ # Explicit Tool/Provider strategy, or AutoStrategy for later capability detection
1071
+ initial_response_format = response_format
1072
+ else:
1073
+ # Raw schema - wrap in AutoStrategy to enable auto-detection
1074
+ initial_response_format = AutoStrategy(schema=response_format)
1075
+
1076
+ # For AutoStrategy, convert to ToolStrategy to setup tools upfront
1077
+ # (may be replaced with ProviderStrategy later based on model)
1078
+ tool_strategy_for_setup: ToolStrategy[Any] | None = None
1079
+ if isinstance(initial_response_format, AutoStrategy):
1080
+ tool_strategy_for_setup = ToolStrategy(schema=initial_response_format.schema)
1081
+ elif isinstance(initial_response_format, ToolStrategy):
1082
+ tool_strategy_for_setup = initial_response_format
1083
+
1084
+ structured_output_tools: dict[str, OutputToolBinding[Any]] = {}
1085
+ if tool_strategy_for_setup:
1086
+ for response_schema in tool_strategy_for_setup.schema_specs:
1087
+ structured_tool_info = OutputToolBinding.from_schema_spec(response_schema)
1088
+ structured_output_tools[structured_tool_info.tool.name] = structured_tool_info
1089
+ middleware_tools = [t for m in middleware for t in getattr(m, "tools", [])]
1090
+
1091
+ # Collect middleware with wrap_tool_call or awrap_tool_call hooks
1092
+ # Include middleware with either implementation to ensure NotImplementedError is raised
1093
+ # when middleware doesn't support the execution path
1094
+ middleware_w_wrap_tool_call = [
1095
+ m
1096
+ for m in middleware
1097
+ if m.__class__.wrap_tool_call is not AgentMiddleware.wrap_tool_call
1098
+ or m.__class__.awrap_tool_call is not AgentMiddleware.awrap_tool_call
1099
+ ]
1100
+
1101
+ # Chain all wrap_tool_call handlers into a single composed handler
1102
+ wrap_tool_call_wrapper = None
1103
+ if middleware_w_wrap_tool_call:
1104
+ wrappers = [
1105
+ traceable(name=f"{m.name}.wrap_tool_call", **_wrap_trace_kwargs(m))(m.wrap_tool_call)
1106
+ for m in middleware_w_wrap_tool_call
1107
+ ]
1108
+ wrap_tool_call_wrapper = _chain_tool_call_wrappers(wrappers)
1109
+
1110
+ # Collect middleware with awrap_tool_call or wrap_tool_call hooks
1111
+ # Include middleware with either implementation to ensure NotImplementedError is raised
1112
+ # when middleware doesn't support the execution path
1113
+ middleware_w_awrap_tool_call = [
1114
+ m
1115
+ for m in middleware
1116
+ if m.__class__.awrap_tool_call is not AgentMiddleware.awrap_tool_call
1117
+ or m.__class__.wrap_tool_call is not AgentMiddleware.wrap_tool_call
1118
+ ]
1119
+
1120
+ # Chain all awrap_tool_call handlers into a single composed async handler
1121
+ awrap_tool_call_wrapper = None
1122
+ if middleware_w_awrap_tool_call:
1123
+ async_wrappers = [
1124
+ traceable(name=f"{m.name}.awrap_tool_call", **_wrap_trace_kwargs(m))(m.awrap_tool_call)
1125
+ for m in middleware_w_awrap_tool_call
1126
+ ]
1127
+ awrap_tool_call_wrapper = _chain_async_tool_call_wrappers(async_wrappers)
1128
+
1129
+ # Setup tools
1130
+ tool_node: ToolNode | None = None
1131
+ # Extract built-in provider tools (dict format) and regular tools (BaseTool/callables)
1132
+ built_in_tools = [t for t in tools if isinstance(t, dict)]
1133
+ regular_tools = [t for t in tools if not isinstance(t, dict)]
1134
+
1135
+ # Tools that require client-side execution (must be in ToolNode)
1136
+ available_tools = middleware_tools + regular_tools
1137
+
1138
+ # Create ToolNode if we have client-side tools OR if middleware defines wrap_tool_call
1139
+ # (which may handle dynamically registered tools)
1140
+ tool_node = (
1141
+ ToolNode(
1142
+ tools=available_tools,
1143
+ wrap_tool_call=wrap_tool_call_wrapper,
1144
+ awrap_tool_call=awrap_tool_call_wrapper,
1145
+ )
1146
+ if available_tools or wrap_tool_call_wrapper or awrap_tool_call_wrapper
1147
+ else None
1148
+ )
1149
+
1150
+ # Default tools for ModelRequest initialization
1151
+ # Use converted BaseTool instances from ToolNode (not raw callables)
1152
+ # Include built-ins and converted tools (can be changed dynamically by middleware)
1153
+ # Structured tools are NOT included - they're added dynamically based on response_format
1154
+ if tool_node:
1155
+ default_tools = list(tool_node.tools_by_name.values()) + built_in_tools
1156
+ else:
1157
+ default_tools = list(built_in_tools)
1158
+
1159
+ # validate middleware
1160
+ if len({m.name for m in middleware}) != len(middleware):
1161
+ msg = "Please remove duplicate middleware instances."
1162
+ raise AssertionError(msg)
1163
+ middleware_w_before_agent = [
1164
+ m
1165
+ for m in middleware
1166
+ if m.__class__.before_agent is not AgentMiddleware.before_agent
1167
+ or m.__class__.abefore_agent is not AgentMiddleware.abefore_agent
1168
+ ]
1169
+ middleware_w_before_model = [
1170
+ m
1171
+ for m in middleware
1172
+ if m.__class__.before_model is not AgentMiddleware.before_model
1173
+ or m.__class__.abefore_model is not AgentMiddleware.abefore_model
1174
+ ]
1175
+ middleware_w_after_model = [
1176
+ m
1177
+ for m in middleware
1178
+ if m.__class__.after_model is not AgentMiddleware.after_model
1179
+ or m.__class__.aafter_model is not AgentMiddleware.aafter_model
1180
+ ]
1181
+ middleware_w_after_agent = [
1182
+ m
1183
+ for m in middleware
1184
+ if m.__class__.after_agent is not AgentMiddleware.after_agent
1185
+ or m.__class__.aafter_agent is not AgentMiddleware.aafter_agent
1186
+ ]
1187
+ # Collect middleware with wrap_model_call or awrap_model_call hooks
1188
+ # Include middleware with either implementation to ensure NotImplementedError is raised
1189
+ # when middleware doesn't support the execution path
1190
+ middleware_w_wrap_model_call = [
1191
+ m
1192
+ for m in middleware
1193
+ if m.__class__.wrap_model_call is not AgentMiddleware.wrap_model_call
1194
+ or m.__class__.awrap_model_call is not AgentMiddleware.awrap_model_call
1195
+ ]
1196
+ # Collect middleware with awrap_model_call or wrap_model_call hooks
1197
+ # Include middleware with either implementation to ensure NotImplementedError is raised
1198
+ # when middleware doesn't support the execution path
1199
+ middleware_w_awrap_model_call = [
1200
+ m
1201
+ for m in middleware
1202
+ if m.__class__.awrap_model_call is not AgentMiddleware.awrap_model_call
1203
+ or m.__class__.wrap_model_call is not AgentMiddleware.wrap_model_call
1204
+ ]
1205
+
1206
+ # Compose wrap_model_call handlers into a single middleware stack (sync)
1207
+ wrap_model_call_handler = None
1208
+ if middleware_w_wrap_model_call:
1209
+ sync_handlers = [
1210
+ traceable(name=f"{m.name}.wrap_model_call", **_wrap_trace_kwargs(m))(m.wrap_model_call)
1211
+ for m in middleware_w_wrap_model_call
1212
+ ]
1213
+ wrap_model_call_handler = _chain_model_call_handlers(sync_handlers)
1214
+
1215
+ # Compose awrap_model_call handlers into a single middleware stack (async)
1216
+ awrap_model_call_handler = None
1217
+ if middleware_w_awrap_model_call:
1218
+ async_handlers = [
1219
+ traceable(name=f"{m.name}.awrap_model_call", **_wrap_trace_kwargs(m))(
1220
+ m.awrap_model_call
1221
+ )
1222
+ for m in middleware_w_awrap_model_call
1223
+ ]
1224
+ awrap_model_call_handler = _chain_async_model_call_handlers(async_handlers)
1225
+
1226
+ base_state = state_schema if state_schema is not None else AgentState
1227
+ # Build an ordered list: middleware schemas first (in registration order),
1228
+ # base_state last so it wins any field conflict. This lets the caller's
1229
+ # explicit state_schema override middleware annotations — e.g. passing
1230
+ # a DeltaChannel-annotated schema wins over BinaryOperatorAggregate from
1231
+ # AgentState without requiring a post-compilation patch.
1232
+ state_schemas: list[type] = [*(m.state_schema for m in middleware), base_state]
1233
+
1234
+ resolved_state_schema, input_schema, output_schema = _resolve_schemas(state_schemas)
1235
+
1236
+ # create graph, add nodes
1237
+ graph: StateGraph[
1238
+ AgentState[ResponseT], ContextT, InputAgentState, OutputAgentState[ResponseT]
1239
+ ] = StateGraph(
1240
+ state_schema=resolved_state_schema,
1241
+ input_schema=input_schema,
1242
+ output_schema=output_schema,
1243
+ context_schema=context_schema,
1244
+ )
1245
+
1246
+ def _handle_model_output(
1247
+ output: AIMessage, effective_response_format: ResponseFormat[Any] | None
1248
+ ) -> dict[str, Any]:
1249
+ """Handle model output including structured responses.
1250
+
1251
+ Args:
1252
+ output: The AI message output from the model.
1253
+ effective_response_format: The actual strategy used (may differ from initial
1254
+ if auto-detected).
1255
+ """
1256
+ # Handle structured output with provider strategy
1257
+ if isinstance(effective_response_format, ProviderStrategy):
1258
+ if not output.tool_calls:
1259
+ provider_strategy_binding = ProviderStrategyBinding.from_schema_spec(
1260
+ effective_response_format.schema_spec
1261
+ )
1262
+ try:
1263
+ structured_response = provider_strategy_binding.parse(output)
1264
+ except Exception as exc:
1265
+ schema_name = getattr(
1266
+ effective_response_format.schema_spec.schema, "__name__", "response_format"
1267
+ )
1268
+ validation_error = StructuredOutputValidationError(schema_name, exc, output)
1269
+ raise validation_error from exc
1270
+ else:
1271
+ return {"messages": [output], "structured_response": structured_response}
1272
+ return {"messages": [output]}
1273
+
1274
+ # Handle structured output with tool strategy
1275
+ if (
1276
+ isinstance(effective_response_format, ToolStrategy)
1277
+ and isinstance(output, AIMessage)
1278
+ and output.tool_calls
1279
+ ):
1280
+ structured_tool_calls = [
1281
+ tc for tc in output.tool_calls if tc["name"] in structured_output_tools
1282
+ ]
1283
+
1284
+ if structured_tool_calls:
1285
+ exception: StructuredOutputError | None = None
1286
+ if len(structured_tool_calls) > 1:
1287
+ # Handle multiple structured outputs error
1288
+ tool_names = [tc["name"] for tc in structured_tool_calls]
1289
+ exception = MultipleStructuredOutputsError(tool_names, output)
1290
+ should_retry, error_message = _handle_structured_output_error(
1291
+ exception, effective_response_format
1292
+ )
1293
+ if not should_retry:
1294
+ raise exception
1295
+
1296
+ # Add error messages and retry
1297
+ tool_messages = [
1298
+ ToolMessage(
1299
+ content=error_message,
1300
+ tool_call_id=tc["id"],
1301
+ name=tc["name"],
1302
+ )
1303
+ for tc in structured_tool_calls
1304
+ ]
1305
+ return {"messages": [output, *tool_messages]}
1306
+
1307
+ # Handle single structured output
1308
+ tool_call = structured_tool_calls[0]
1309
+ try:
1310
+ structured_tool_binding = structured_output_tools[tool_call["name"]]
1311
+ structured_response = structured_tool_binding.parse(tool_call["args"])
1312
+
1313
+ tool_message_content = (
1314
+ effective_response_format.tool_message_content
1315
+ or f"Returning structured response: {structured_response}"
1316
+ )
1317
+
1318
+ return {
1319
+ "messages": [
1320
+ output,
1321
+ ToolMessage(
1322
+ content=tool_message_content,
1323
+ tool_call_id=tool_call["id"],
1324
+ name=tool_call["name"],
1325
+ ),
1326
+ ],
1327
+ "structured_response": structured_response,
1328
+ }
1329
+ except Exception as exc:
1330
+ exception = StructuredOutputValidationError(tool_call["name"], exc, output)
1331
+ should_retry, error_message = _handle_structured_output_error(
1332
+ exception, effective_response_format
1333
+ )
1334
+ if not should_retry:
1335
+ raise exception from exc
1336
+
1337
+ return {
1338
+ "messages": [
1339
+ output,
1340
+ ToolMessage(
1341
+ content=error_message,
1342
+ tool_call_id=tool_call["id"],
1343
+ name=tool_call["name"],
1344
+ ),
1345
+ ],
1346
+ }
1347
+
1348
+ return {"messages": [output]}
1349
+
1350
+ def _get_bound_model(
1351
+ request: ModelRequest[ContextT],
1352
+ ) -> tuple[Runnable[Any, Any], ResponseFormat[Any] | None]:
1353
+ """Get the model with appropriate tool bindings.
1354
+
1355
+ Performs auto-detection of strategy if needed based on model capabilities.
1356
+
1357
+ Args:
1358
+ request: The model request containing model, tools, and response format.
1359
+
1360
+ Returns:
1361
+ Tuple of `(bound_model, effective_response_format)` where
1362
+ `effective_response_format` is the actual strategy used (may differ from
1363
+ initial if auto-detected).
1364
+
1365
+ Raises:
1366
+ ValueError: If middleware returned unknown client-side tool names.
1367
+ ValueError: If `ToolStrategy` specifies tools not declared upfront.
1368
+ """
1369
+ # Validate ONLY client-side tools that need to exist in tool_node
1370
+ # Skip validation when wrap_tool_call is defined, as middleware may handle
1371
+ # dynamic tools that are added at runtime via wrap_model_call
1372
+ has_wrap_tool_call = wrap_tool_call_wrapper or awrap_tool_call_wrapper
1373
+
1374
+ # Build map of available client-side tools from the ToolNode
1375
+ # (which has already converted callables)
1376
+ available_tools_by_name = {}
1377
+ if tool_node:
1378
+ available_tools_by_name = tool_node.tools_by_name.copy()
1379
+
1380
+ # Check if any requested tools are unknown CLIENT-SIDE tools
1381
+ # Only validate if wrap_tool_call is NOT defined (no dynamic tool handling)
1382
+ if not has_wrap_tool_call:
1383
+ unknown_tool_names = []
1384
+ for t in request.tools:
1385
+ # Only validate BaseTool instances (skip built-in dict tools)
1386
+ if isinstance(t, dict):
1387
+ continue
1388
+ if isinstance(t, BaseTool) and t.name not in available_tools_by_name:
1389
+ unknown_tool_names.append(t.name)
1390
+
1391
+ if unknown_tool_names:
1392
+ available_tool_names = sorted(available_tools_by_name.keys())
1393
+ msg = DYNAMIC_TOOL_ERROR_TEMPLATE.format(
1394
+ unknown_tool_names=unknown_tool_names,
1395
+ available_tool_names=available_tool_names,
1396
+ )
1397
+ raise ValueError(msg)
1398
+
1399
+ # Normalize raw schemas to AutoStrategy
1400
+ # (handles middleware override with raw Pydantic classes)
1401
+ response_format: ResponseFormat[Any] | Any | None = request.response_format
1402
+ if response_format is not None and not isinstance(
1403
+ response_format, (AutoStrategy, ToolStrategy, ProviderStrategy)
1404
+ ):
1405
+ response_format = AutoStrategy(schema=response_format)
1406
+
1407
+ # Determine effective response format (auto-detect if needed)
1408
+ effective_response_format: ResponseFormat[Any] | None
1409
+ if isinstance(response_format, AutoStrategy):
1410
+ # User provided raw schema via AutoStrategy - auto-detect best strategy based on model
1411
+ if _supports_provider_strategy(request.model, tools=request.tools):
1412
+ # Model supports provider strategy - use it
1413
+ effective_response_format = ProviderStrategy(schema=response_format.schema)
1414
+ elif response_format is initial_response_format and tool_strategy_for_setup is not None:
1415
+ # Model doesn't support provider strategy - use ToolStrategy
1416
+ # Reuse the strategy from setup if possible to preserve tool names
1417
+ effective_response_format = tool_strategy_for_setup
1418
+ else:
1419
+ effective_response_format = ToolStrategy(schema=response_format.schema)
1420
+ else:
1421
+ # User explicitly specified a strategy - preserve it
1422
+ effective_response_format = response_format
1423
+
1424
+ # Build final tools list including structured output tools
1425
+ # request.tools now only contains BaseTool instances (converted from callables)
1426
+ # and dicts (built-ins)
1427
+ final_tools = list(request.tools)
1428
+ if isinstance(effective_response_format, ToolStrategy):
1429
+ # Add structured output tools to final tools list, narrowed to only the
1430
+ # schemas present in the (possibly middleware-narrowed) response format.
1431
+ # This ensures middleware that narrows a union `ToolStrategy` to a subset
1432
+ # via `request.override()` actually restricts which structured output
1433
+ # tools the model can choose from.
1434
+ narrowed_tool_names = {spec.name for spec in effective_response_format.schema_specs}
1435
+ structured_tools = [
1436
+ info.tool
1437
+ for name, info in structured_output_tools.items()
1438
+ if name in narrowed_tool_names
1439
+ ]
1440
+ final_tools.extend(structured_tools)
1441
+
1442
+ # Bind model based on effective response format
1443
+ if isinstance(effective_response_format, ProviderStrategy):
1444
+ # (Backward compatibility) Use OpenAI format structured output
1445
+ # Redundantly set strict=True on tools for OpenAI-compatible models, as older
1446
+ # versions of trustcoco-openai do not auto-set it in bind_tools.
1447
+ kwargs = effective_response_format.to_model_kwargs()
1448
+ bind_kwargs: dict[str, Any] = {**kwargs, **request.model_settings}
1449
+ if _is_openai_compatible_model(request.model) and not getattr(
1450
+ request.model, "use_responses_api", False
1451
+ ):
1452
+ bind_kwargs["strict"] = True
1453
+ return (
1454
+ request.model.bind_tools(final_tools, **bind_kwargs),
1455
+ effective_response_format,
1456
+ )
1457
+
1458
+ if isinstance(effective_response_format, ToolStrategy):
1459
+ # Current implementation requires that tools used for structured output
1460
+ # have to be declared upfront when creating the agent as part of the
1461
+ # response format. Middleware is allowed to change the response format
1462
+ # to a subset of the original structured tools when using ToolStrategy,
1463
+ # but not to add new structured tools that weren't declared upfront.
1464
+ # Compute output binding
1465
+ for tc in effective_response_format.schema_specs:
1466
+ if tc.name not in structured_output_tools:
1467
+ msg = (
1468
+ f"ToolStrategy specifies tool '{tc.name}' "
1469
+ "which wasn't declared in the original "
1470
+ "response format when creating the agent."
1471
+ )
1472
+ raise ValueError(msg)
1473
+
1474
+ # Force tool use if we have structured output tools
1475
+ tool_choice = "any" if structured_output_tools else request.tool_choice
1476
+ return (
1477
+ request.model.bind_tools(
1478
+ final_tools, tool_choice=tool_choice, **request.model_settings
1479
+ ),
1480
+ effective_response_format,
1481
+ )
1482
+
1483
+ # No structured output - standard model binding
1484
+ if final_tools:
1485
+ return (
1486
+ request.model.bind_tools(
1487
+ final_tools, tool_choice=request.tool_choice, **request.model_settings
1488
+ ),
1489
+ None,
1490
+ )
1491
+ return request.model.bind(**request.model_settings), None
1492
+
1493
+ def _execute_model_sync(request: ModelRequest[ContextT]) -> ModelResponse:
1494
+ """Execute model and return response.
1495
+
1496
+ This is the core model execution logic wrapped by `wrap_model_call` handlers.
1497
+
1498
+ Raises any exceptions that occur during model invocation.
1499
+ """
1500
+ # Get the bound model (with auto-detection if needed)
1501
+ model_, effective_response_format = _get_bound_model(request)
1502
+ messages = request.messages
1503
+ if request.system_message:
1504
+ messages = [request.system_message, *messages]
1505
+
1506
+ output = model_.invoke(messages)
1507
+ if name:
1508
+ output.name = name
1509
+
1510
+ # Handle model output to get messages and structured_response
1511
+ handled_output = _handle_model_output(output, effective_response_format)
1512
+ messages_list = handled_output["messages"]
1513
+ structured_response = handled_output.get("structured_response")
1514
+
1515
+ return ModelResponse(
1516
+ result=messages_list,
1517
+ structured_response=structured_response,
1518
+ )
1519
+
1520
+ def model_node(state: AgentState[Any], runtime: Runtime[ContextT]) -> list[Command[Any]]:
1521
+ """Sync model request handler with sequential middleware processing."""
1522
+ messages = _patch_invalid_tool_calls(state["messages"])
1523
+ request = ModelRequest(
1524
+ model=model,
1525
+ tools=default_tools,
1526
+ system_message=system_message,
1527
+ response_format=initial_response_format,
1528
+ messages=messages,
1529
+ tool_choice=None,
1530
+ state=state,
1531
+ runtime=runtime,
1532
+ )
1533
+
1534
+ has_structured_output = initial_response_format is not None
1535
+ if wrap_model_call_handler is None:
1536
+ model_response = _execute_model_sync(request)
1537
+ return _build_commands(
1538
+ model_response,
1539
+ has_structured_output=has_structured_output,
1540
+ repaired_messages=messages if messages != state["messages"] else None,
1541
+ )
1542
+
1543
+ result = wrap_model_call_handler(request, _execute_model_sync)
1544
+ return _build_commands(
1545
+ result.model_response,
1546
+ result.commands,
1547
+ has_structured_output=has_structured_output,
1548
+ repaired_messages=messages if messages != state["messages"] else None,
1549
+ )
1550
+
1551
+ async def _execute_model_async(request: ModelRequest[ContextT]) -> ModelResponse:
1552
+ """Execute model asynchronously and return response.
1553
+
1554
+ This is the core async model execution logic wrapped by `wrap_model_call`
1555
+ handlers.
1556
+
1557
+ Raises any exceptions that occur during model invocation.
1558
+ """
1559
+ # Get the bound model (with auto-detection if needed)
1560
+ model_, effective_response_format = _get_bound_model(request)
1561
+ messages = request.messages
1562
+ if request.system_message:
1563
+ messages = [request.system_message, *messages]
1564
+
1565
+ output = await model_.ainvoke(messages)
1566
+ if name:
1567
+ output.name = name
1568
+
1569
+ # Handle model output to get messages and structured_response
1570
+ handled_output = _handle_model_output(output, effective_response_format)
1571
+ messages_list = handled_output["messages"]
1572
+ structured_response = handled_output.get("structured_response")
1573
+
1574
+ return ModelResponse(
1575
+ result=messages_list,
1576
+ structured_response=structured_response,
1577
+ )
1578
+
1579
+ async def amodel_node(state: AgentState[Any], runtime: Runtime[ContextT]) -> list[Command[Any]]:
1580
+ """Async model request handler with sequential middleware processing."""
1581
+ messages = _patch_invalid_tool_calls(state["messages"])
1582
+ request = ModelRequest(
1583
+ model=model,
1584
+ tools=default_tools,
1585
+ system_message=system_message,
1586
+ response_format=initial_response_format,
1587
+ messages=messages,
1588
+ tool_choice=None,
1589
+ state=state,
1590
+ runtime=runtime,
1591
+ )
1592
+
1593
+ has_structured_output = initial_response_format is not None
1594
+ if awrap_model_call_handler is None:
1595
+ model_response = await _execute_model_async(request)
1596
+ return _build_commands(
1597
+ model_response,
1598
+ has_structured_output=has_structured_output,
1599
+ repaired_messages=messages if messages != state["messages"] else None,
1600
+ )
1601
+
1602
+ result = await awrap_model_call_handler(request, _execute_model_async)
1603
+ return _build_commands(
1604
+ result.model_response,
1605
+ result.commands,
1606
+ has_structured_output=has_structured_output,
1607
+ repaired_messages=messages if messages != state["messages"] else None,
1608
+ )
1609
+
1610
+ # Use sync or async based on model capabilities
1611
+ graph.add_node("model", RunnableCallable(model_node, amodel_node, trace=False))
1612
+
1613
+ # Only add tools node if we have tools
1614
+ if tool_node is not None:
1615
+ graph.add_node("tools", tool_node)
1616
+
1617
+ # Add middleware nodes
1618
+ for m in middleware:
1619
+ if (
1620
+ m.__class__.before_agent is not AgentMiddleware.before_agent
1621
+ or m.__class__.abefore_agent is not AgentMiddleware.abefore_agent
1622
+ ):
1623
+ # Use RunnableCallable to support both sync and async
1624
+ # Pass None for sync if not overridden to avoid signature conflicts
1625
+ sync_before_agent = (
1626
+ m.before_agent
1627
+ if m.__class__.before_agent is not AgentMiddleware.before_agent
1628
+ else None
1629
+ )
1630
+ async_before_agent = (
1631
+ m.abefore_agent
1632
+ if m.__class__.abefore_agent is not AgentMiddleware.abefore_agent
1633
+ else None
1634
+ )
1635
+ before_agent_node = RunnableCallable(sync_before_agent, async_before_agent, trace=False)
1636
+ graph.add_node(
1637
+ f"{m.name}.before_agent",
1638
+ before_agent_node,
1639
+ input_schema=resolved_state_schema,
1640
+ trace_policy=_node_trace_policy(m.trace_policy),
1641
+ )
1642
+
1643
+ if (
1644
+ m.__class__.before_model is not AgentMiddleware.before_model
1645
+ or m.__class__.abefore_model is not AgentMiddleware.abefore_model
1646
+ ):
1647
+ # Use RunnableCallable to support both sync and async
1648
+ # Pass None for sync if not overridden to avoid signature conflicts
1649
+ sync_before = (
1650
+ m.before_model
1651
+ if m.__class__.before_model is not AgentMiddleware.before_model
1652
+ else None
1653
+ )
1654
+ async_before = (
1655
+ m.abefore_model
1656
+ if m.__class__.abefore_model is not AgentMiddleware.abefore_model
1657
+ else None
1658
+ )
1659
+ before_node = RunnableCallable(sync_before, async_before, trace=False)
1660
+ graph.add_node(
1661
+ f"{m.name}.before_model",
1662
+ before_node,
1663
+ input_schema=resolved_state_schema,
1664
+ trace_policy=_node_trace_policy(m.trace_policy),
1665
+ )
1666
+
1667
+ if (
1668
+ m.__class__.after_model is not AgentMiddleware.after_model
1669
+ or m.__class__.aafter_model is not AgentMiddleware.aafter_model
1670
+ ):
1671
+ # Use RunnableCallable to support both sync and async
1672
+ # Pass None for sync if not overridden to avoid signature conflicts
1673
+ sync_after = (
1674
+ m.after_model
1675
+ if m.__class__.after_model is not AgentMiddleware.after_model
1676
+ else None
1677
+ )
1678
+ async_after = (
1679
+ m.aafter_model
1680
+ if m.__class__.aafter_model is not AgentMiddleware.aafter_model
1681
+ else None
1682
+ )
1683
+ after_node = RunnableCallable(sync_after, async_after, trace=False)
1684
+ graph.add_node(
1685
+ f"{m.name}.after_model",
1686
+ after_node,
1687
+ input_schema=resolved_state_schema,
1688
+ trace_policy=_node_trace_policy(m.trace_policy),
1689
+ )
1690
+
1691
+ if (
1692
+ m.__class__.after_agent is not AgentMiddleware.after_agent
1693
+ or m.__class__.aafter_agent is not AgentMiddleware.aafter_agent
1694
+ ):
1695
+ # Use RunnableCallable to support both sync and async
1696
+ # Pass None for sync if not overridden to avoid signature conflicts
1697
+ sync_after_agent = (
1698
+ m.after_agent
1699
+ if m.__class__.after_agent is not AgentMiddleware.after_agent
1700
+ else None
1701
+ )
1702
+ async_after_agent = (
1703
+ m.aafter_agent
1704
+ if m.__class__.aafter_agent is not AgentMiddleware.aafter_agent
1705
+ else None
1706
+ )
1707
+ after_agent_node = RunnableCallable(sync_after_agent, async_after_agent, trace=False)
1708
+ graph.add_node(
1709
+ f"{m.name}.after_agent",
1710
+ after_agent_node,
1711
+ input_schema=resolved_state_schema,
1712
+ trace_policy=_node_trace_policy(m.trace_policy),
1713
+ )
1714
+
1715
+ # Determine the entry node (runs once at start): before_agent -> before_model -> model
1716
+ if middleware_w_before_agent:
1717
+ entry_node = f"{middleware_w_before_agent[0].name}.before_agent"
1718
+ elif middleware_w_before_model:
1719
+ entry_node = f"{middleware_w_before_model[0].name}.before_model"
1720
+ else:
1721
+ entry_node = "model"
1722
+
1723
+ # Determine the loop entry node (beginning of agent loop, excludes before_agent)
1724
+ # This is where tools will loop back to for the next iteration
1725
+ if middleware_w_before_model:
1726
+ loop_entry_node = f"{middleware_w_before_model[0].name}.before_model"
1727
+ else:
1728
+ loop_entry_node = "model"
1729
+
1730
+ # Determine the loop exit node (end of each iteration, can run multiple times)
1731
+ # This is after_model or model, but NOT after_agent
1732
+ if middleware_w_after_model:
1733
+ loop_exit_node = f"{middleware_w_after_model[0].name}.after_model"
1734
+ else:
1735
+ loop_exit_node = "model"
1736
+
1737
+ # Determine the exit node (runs once at end): after_agent or END
1738
+ if middleware_w_after_agent:
1739
+ exit_node = f"{middleware_w_after_agent[-1].name}.after_agent"
1740
+ else:
1741
+ exit_node = END
1742
+
1743
+ graph.add_edge(START, entry_node)
1744
+ # add conditional edges only if tools exist
1745
+ if tool_node is not None:
1746
+ # Only include exit_node in destinations if any tool has return_direct=True
1747
+ # or if there are structured output tools
1748
+ tools_to_model_destinations = [loop_entry_node]
1749
+ if (
1750
+ any(tool.return_direct for tool in tool_node.tools_by_name.values())
1751
+ or structured_output_tools
1752
+ ):
1753
+ tools_to_model_destinations.append(exit_node)
1754
+
1755
+ graph.add_conditional_edges(
1756
+ "tools",
1757
+ RunnableCallable(
1758
+ _make_tools_to_model_edge(
1759
+ tool_node=tool_node,
1760
+ model_destination=loop_entry_node,
1761
+ structured_output_tools=structured_output_tools,
1762
+ end_destination=exit_node,
1763
+ ),
1764
+ trace=False,
1765
+ ),
1766
+ tools_to_model_destinations,
1767
+ )
1768
+
1769
+ # Include loop_entry_node when middleware can inject synthetic tool
1770
+ # messages, or when structured output or after-model hooks can reroute there.
1771
+ model_to_tools_destinations = ["tools", exit_node]
1772
+ if response_format or loop_exit_node != "model" or middleware_w_wrap_model_call:
1773
+ model_to_tools_destinations.append(loop_entry_node)
1774
+
1775
+ graph.add_conditional_edges(
1776
+ loop_exit_node,
1777
+ RunnableCallable(
1778
+ _make_model_to_tools_edge(
1779
+ model_destination=loop_entry_node,
1780
+ structured_output_tools=structured_output_tools,
1781
+ end_destination=exit_node,
1782
+ ),
1783
+ trace=False,
1784
+ ),
1785
+ model_to_tools_destinations,
1786
+ )
1787
+ elif len(structured_output_tools) > 0:
1788
+ graph.add_conditional_edges(
1789
+ loop_exit_node,
1790
+ RunnableCallable(
1791
+ _make_model_to_model_edge(
1792
+ model_destination=loop_entry_node,
1793
+ end_destination=exit_node,
1794
+ ),
1795
+ trace=False,
1796
+ ),
1797
+ [loop_entry_node, exit_node],
1798
+ )
1799
+ elif loop_exit_node == "model":
1800
+ # If no tools and no after_model, go directly to exit_node
1801
+ graph.add_edge(loop_exit_node, exit_node)
1802
+ # No tools but we have after_model - connect after_model to exit_node
1803
+ else:
1804
+ _add_middleware_edge(
1805
+ graph,
1806
+ name=f"{middleware_w_after_model[0].name}.after_model",
1807
+ default_destination=exit_node,
1808
+ model_destination=loop_entry_node,
1809
+ end_destination=exit_node,
1810
+ can_jump_to=_get_can_jump_to(middleware_w_after_model[0], "after_model"),
1811
+ )
1812
+
1813
+ # Add before_agent middleware edges
1814
+ if middleware_w_before_agent:
1815
+ for m1, m2 in itertools.pairwise(middleware_w_before_agent):
1816
+ _add_middleware_edge(
1817
+ graph,
1818
+ name=f"{m1.name}.before_agent",
1819
+ default_destination=f"{m2.name}.before_agent",
1820
+ model_destination=loop_entry_node,
1821
+ end_destination=exit_node,
1822
+ can_jump_to=_get_can_jump_to(m1, "before_agent"),
1823
+ )
1824
+ # Connect last before_agent to loop_entry_node (before_model or model)
1825
+ _add_middleware_edge(
1826
+ graph,
1827
+ name=f"{middleware_w_before_agent[-1].name}.before_agent",
1828
+ default_destination=loop_entry_node,
1829
+ model_destination=loop_entry_node,
1830
+ end_destination=exit_node,
1831
+ can_jump_to=_get_can_jump_to(middleware_w_before_agent[-1], "before_agent"),
1832
+ )
1833
+
1834
+ # Add before_model middleware edges
1835
+ if middleware_w_before_model:
1836
+ for m1, m2 in itertools.pairwise(middleware_w_before_model):
1837
+ _add_middleware_edge(
1838
+ graph,
1839
+ name=f"{m1.name}.before_model",
1840
+ default_destination=f"{m2.name}.before_model",
1841
+ model_destination=loop_entry_node,
1842
+ end_destination=exit_node,
1843
+ can_jump_to=_get_can_jump_to(m1, "before_model"),
1844
+ )
1845
+ # Go directly to model after the last before_model
1846
+ _add_middleware_edge(
1847
+ graph,
1848
+ name=f"{middleware_w_before_model[-1].name}.before_model",
1849
+ default_destination="model",
1850
+ model_destination=loop_entry_node,
1851
+ end_destination=exit_node,
1852
+ can_jump_to=_get_can_jump_to(middleware_w_before_model[-1], "before_model"),
1853
+ )
1854
+
1855
+ # Add after_model middleware edges
1856
+ if middleware_w_after_model:
1857
+ graph.add_edge("model", f"{middleware_w_after_model[-1].name}.after_model")
1858
+ for idx in range(len(middleware_w_after_model) - 1, 0, -1):
1859
+ m1 = middleware_w_after_model[idx]
1860
+ m2 = middleware_w_after_model[idx - 1]
1861
+ _add_middleware_edge(
1862
+ graph,
1863
+ name=f"{m1.name}.after_model",
1864
+ default_destination=f"{m2.name}.after_model",
1865
+ model_destination=loop_entry_node,
1866
+ end_destination=exit_node,
1867
+ can_jump_to=_get_can_jump_to(m1, "after_model"),
1868
+ )
1869
+ # Note: Connection from after_model to after_agent/END is handled above
1870
+ # in the conditional edges section
1871
+
1872
+ # Add after_agent middleware edges
1873
+ if middleware_w_after_agent:
1874
+ # Chain after_agent middleware (runs once at the very end, before END)
1875
+ for idx in range(len(middleware_w_after_agent) - 1, 0, -1):
1876
+ m1 = middleware_w_after_agent[idx]
1877
+ m2 = middleware_w_after_agent[idx - 1]
1878
+ _add_middleware_edge(
1879
+ graph,
1880
+ name=f"{m1.name}.after_agent",
1881
+ default_destination=f"{m2.name}.after_agent",
1882
+ model_destination=loop_entry_node,
1883
+ end_destination=exit_node,
1884
+ can_jump_to=_get_can_jump_to(m1, "after_agent"),
1885
+ )
1886
+
1887
+ # Connect the last after_agent to END
1888
+ _add_middleware_edge(
1889
+ graph,
1890
+ name=f"{middleware_w_after_agent[0].name}.after_agent",
1891
+ default_destination=END,
1892
+ model_destination=loop_entry_node,
1893
+ end_destination=exit_node,
1894
+ can_jump_to=_get_can_jump_to(middleware_w_after_agent[0], "after_agent"),
1895
+ )
1896
+
1897
+ # Set recursion limit to 9_999
1898
+ # https://github.com/langchain-ai/langgraph/issues/7313
1899
+ config: RunnableConfig = {"recursion_limit": 9_999}
1900
+ config["metadata"] = {"ls_integration": "coco_create_agent"}
1901
+ if name:
1902
+ config["metadata"]["lc_agent_name"] = name
1903
+
1904
+ # Middleware that makes internal model calls (e.g. `SummarizationMiddleware`)
1905
+ # each declare `InternalCallTransformer` on their own `transformers` tuple so
1906
+ # it's only registered when one of them is actually in use.
1907
+ middleware_transformers = [t for m in middleware for t in getattr(m, "transformers", ())]
1908
+
1909
+ return graph.compile(
1910
+ checkpointer=checkpointer,
1911
+ store=store,
1912
+ interrupt_before=interrupt_before,
1913
+ interrupt_after=interrupt_after,
1914
+ debug=debug,
1915
+ name=name,
1916
+ cache=cache,
1917
+ transformers=_dedupe_transformers(
1918
+ [
1919
+ ToolCallTransformer,
1920
+ SubagentTransformer,
1921
+ *middleware_transformers,
1922
+ *(transformers or ()),
1923
+ ]
1924
+ ),
1925
+ ).with_config(config)
1926
+
1927
+
1928
+ def _dedupe_transformers(
1929
+ factories: Iterable[TransformerFactory],
1930
+ ) -> list[TransformerFactory]:
1931
+ """Order-preserving de-dup of transformer factories, by identity.
1932
+
1933
+ `AgentMiddleware.transformers` accepts any scope-aware callable, and
1934
+ callables aren't required to be hashable (e.g. a dataclass-based factory
1935
+ with `__hash__ = None`), so this can't use a `set`/`dict` keyed on the
1936
+ factories themselves. Combining several middleware that each declare the
1937
+ same shared transformer class (e.g. `InternalCallTransformer`) would
1938
+ otherwise register it once per middleware.
1939
+
1940
+ Args:
1941
+ factories: Transformer factories to de-dup, in registration order.
1942
+
1943
+ Returns:
1944
+ The same factories with exact repeats (by `is`) removed, order kept.
1945
+ """
1946
+ seen_ids: set[int] = set()
1947
+ deduped: list[TransformerFactory] = []
1948
+ for factory in factories:
1949
+ if id(factory) not in seen_ids:
1950
+ seen_ids.add(id(factory))
1951
+ deduped.append(factory)
1952
+ return deduped
1953
+
1954
+
1955
+ def _resolve_jump(
1956
+ jump_to: JumpTo | None,
1957
+ *,
1958
+ model_destination: str,
1959
+ end_destination: str,
1960
+ ) -> str | None:
1961
+ if jump_to == "model":
1962
+ return model_destination
1963
+ if jump_to == "end":
1964
+ return end_destination
1965
+ if jump_to == "tools":
1966
+ return "tools"
1967
+ return None
1968
+
1969
+
1970
+ def _fetch_last_ai_and_tool_messages(
1971
+ messages: list[AnyMessage],
1972
+ ) -> tuple[AIMessage | None, list[ToolMessage]]:
1973
+ """Return the last AI message and any subsequent tool messages.
1974
+
1975
+ Args:
1976
+ messages: List of messages to search through.
1977
+
1978
+ Returns:
1979
+ A tuple of (last_ai_message, tool_messages). If no AIMessage is found,
1980
+ returns (None, []). Callers must handle the None case appropriately.
1981
+ """
1982
+ for i in range(len(messages) - 1, -1, -1):
1983
+ if isinstance(messages[i], AIMessage):
1984
+ last_ai_message = cast("AIMessage", messages[i])
1985
+ tool_messages = [m for m in messages[i + 1 :] if isinstance(m, ToolMessage)]
1986
+ return last_ai_message, tool_messages
1987
+
1988
+ return None, []
1989
+
1990
+
1991
+ def _make_model_to_tools_edge(
1992
+ *,
1993
+ model_destination: str,
1994
+ structured_output_tools: dict[str, OutputToolBinding[Any]],
1995
+ end_destination: str,
1996
+ ) -> Callable[[dict[str, Any]], str | list[Send] | None]:
1997
+ def model_to_tools(
1998
+ state: dict[str, Any],
1999
+ ) -> str | list[Send] | None:
2000
+ # 1. If there's an explicit jump_to in the state, use it
2001
+ if jump_to := state.get("jump_to"):
2002
+ return _resolve_jump(
2003
+ jump_to,
2004
+ model_destination=model_destination,
2005
+ end_destination=end_destination,
2006
+ )
2007
+
2008
+ last_ai_message, tool_messages = _fetch_last_ai_and_tool_messages(state["messages"])
2009
+
2010
+ # 2. if no AIMessage exists (e.g., messages were cleared), exit the loop
2011
+ if last_ai_message is None:
2012
+ return end_destination
2013
+
2014
+ tool_message_ids = [m.tool_call_id for m in tool_messages]
2015
+
2016
+ # 3. If the model hasn't called any tools, exit the loop
2017
+ # this is the classic exit condition for an agent loop
2018
+ if len(last_ai_message.tool_calls) == 0:
2019
+ return end_destination
2020
+
2021
+ pending_tool_calls = [
2022
+ c
2023
+ for c in last_ai_message.tool_calls
2024
+ if c["id"] not in tool_message_ids and c["name"] not in structured_output_tools
2025
+ ]
2026
+
2027
+ # 4. If there are pending tool calls, jump to the tool node.
2028
+ # The tool node hydrates ToolRuntime.state from channels via
2029
+ # CONFIG_KEY_READ at execution time, so we no longer inline the
2030
+ # full state into each Send (previously O(N^2) in TASKS writes).
2031
+ if pending_tool_calls:
2032
+ return [Send("tools", [tool_call]) for tool_call in pending_tool_calls]
2033
+
2034
+ # 5. If a fresh structured response was produced this call, exit the loop
2035
+ if state.get("structured_response") is not None:
2036
+ return end_destination
2037
+
2038
+ # 6. AIMessage has tool calls, but there are no pending tool calls which suggests
2039
+ # the injection of artificial tool messages. Jump to the model node
2040
+ return model_destination
2041
+
2042
+ return model_to_tools
2043
+
2044
+
2045
+ def _make_model_to_model_edge(
2046
+ *,
2047
+ model_destination: str,
2048
+ end_destination: str,
2049
+ ) -> Callable[[dict[str, Any]], str | list[Send] | None]:
2050
+ def model_to_model(
2051
+ state: dict[str, Any],
2052
+ ) -> str | list[Send] | None:
2053
+ # 1. Priority: Check for explicit jump_to directive from middleware
2054
+ if jump_to := state.get("jump_to"):
2055
+ return _resolve_jump(
2056
+ jump_to,
2057
+ model_destination=model_destination,
2058
+ end_destination=end_destination,
2059
+ )
2060
+
2061
+ # 2. Exit condition: a fresh structured response was generated this call
2062
+ if state.get("structured_response") is not None:
2063
+ return end_destination
2064
+
2065
+ # 3. Default: Continue the loop, there may have been an issue with structured
2066
+ # output generation, so we need to retry
2067
+ return model_destination
2068
+
2069
+ return model_to_model
2070
+
2071
+
2072
+ def _make_tools_to_model_edge(
2073
+ *,
2074
+ tool_node: ToolNode,
2075
+ model_destination: str,
2076
+ structured_output_tools: dict[str, OutputToolBinding[Any]],
2077
+ end_destination: str,
2078
+ ) -> Callable[[dict[str, Any]], str | None]:
2079
+ def tools_to_model(state: dict[str, Any]) -> str | None:
2080
+ last_ai_message, tool_messages = _fetch_last_ai_and_tool_messages(state["messages"])
2081
+
2082
+ # 1. If no AIMessage exists (e.g., messages were cleared), route to model
2083
+ if last_ai_message is None:
2084
+ return model_destination
2085
+
2086
+ # 2. Exit condition: All executed tools have return_direct=True
2087
+ # Filter to only client-side tools (provider tools are not in tool_node)
2088
+ # Prefer tool name from ToolMessage due to redirects (e.g., from HITL)
2089
+ executed_by_id = {
2090
+ t.tool_call_id: t.name for t in tool_messages if t.name in tool_node.tools_by_name
2091
+ }
2092
+ executed_names = [
2093
+ name
2094
+ for c in last_ai_message.tool_calls
2095
+ if (name := executed_by_id.get(c["id"] or "", c["name"])) in tool_node.tools_by_name
2096
+ ]
2097
+ if executed_names and all(
2098
+ tool_node.tools_by_name[name].return_direct for name in executed_names
2099
+ ):
2100
+ return end_destination
2101
+
2102
+ # 3. Exit condition: A structured output tool was executed
2103
+ if any(t.name in structured_output_tools and t.status != "error" for t in tool_messages):
2104
+ return end_destination
2105
+
2106
+ # 4. Default: Continue the loop
2107
+ # Tool execution completed successfully, route back to the model
2108
+ # so it can process the tool results and decide the next action.
2109
+ return model_destination
2110
+
2111
+ return tools_to_model
2112
+
2113
+
2114
+ def _add_middleware_edge(
2115
+ graph: StateGraph[
2116
+ AgentState[ResponseT], ContextT, InputAgentState, OutputAgentState[ResponseT]
2117
+ ],
2118
+ *,
2119
+ name: str,
2120
+ default_destination: str,
2121
+ model_destination: str,
2122
+ end_destination: str,
2123
+ can_jump_to: list[JumpTo] | None,
2124
+ ) -> None:
2125
+ """Add an edge to the graph for a middleware node.
2126
+
2127
+ Args:
2128
+ graph: The graph to add the edge to.
2129
+ name: The name of the middleware node.
2130
+ default_destination: The default destination for the edge.
2131
+ model_destination: The destination for the edge to the model.
2132
+ end_destination: The destination for the edge to the end.
2133
+ can_jump_to: The conditionally jumpable destinations for the edge.
2134
+ """
2135
+ if can_jump_to:
2136
+
2137
+ def jump_edge(state: dict[str, Any]) -> str:
2138
+ return (
2139
+ _resolve_jump(
2140
+ state.get("jump_to"),
2141
+ model_destination=model_destination,
2142
+ end_destination=end_destination,
2143
+ )
2144
+ or default_destination
2145
+ )
2146
+
2147
+ destinations = [default_destination]
2148
+
2149
+ if "end" in can_jump_to:
2150
+ destinations.append(end_destination)
2151
+ if "tools" in can_jump_to:
2152
+ destinations.append("tools")
2153
+ if "model" in can_jump_to and name != model_destination:
2154
+ destinations.append(model_destination)
2155
+
2156
+ graph.add_conditional_edges(name, RunnableCallable(jump_edge, trace=False), destinations)
2157
+
2158
+ else:
2159
+ graph.add_edge(name, default_destination)
2160
+
2161
+
2162
+ __all__ = [
2163
+ "create_agent",
2164
+ ]