coco-framework 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (400) hide show
  1. coco/__init__.py +3 -0
  2. coco/agents/__init__.py +9 -0
  3. coco/agents/_subagent_transformer.py +307 -0
  4. coco/agents/factory.py +2164 -0
  5. coco/agents/middleware/__init__.py +100 -0
  6. coco/agents/middleware/_execution.py +385 -0
  7. coco/agents/middleware/_redaction.py +454 -0
  8. coco/agents/middleware/_retry.py +134 -0
  9. coco/agents/middleware/_trace_policy.py +61 -0
  10. coco/agents/middleware/context_editing.py +306 -0
  11. coco/agents/middleware/file_search.py +433 -0
  12. coco/agents/middleware/human_in_the_loop.py +680 -0
  13. coco/agents/middleware/internal_call_transformer.py +108 -0
  14. coco/agents/middleware/model_call_limit.py +267 -0
  15. coco/agents/middleware/model_fallback.py +417 -0
  16. coco/agents/middleware/model_retry.py +327 -0
  17. coco/agents/middleware/pii.py +878 -0
  18. coco/agents/middleware/provider_tool_search.py +307 -0
  19. coco/agents/middleware/shell_tool.py +949 -0
  20. coco/agents/middleware/summarization.py +910 -0
  21. coco/agents/middleware/todo.py +357 -0
  22. coco/agents/middleware/tool_call_limit.py +495 -0
  23. coco/agents/middleware/tool_emulator.py +251 -0
  24. coco/agents/middleware/tool_error.py +209 -0
  25. coco/agents/middleware/tool_retry.py +421 -0
  26. coco/agents/middleware/tool_selection.py +515 -0
  27. coco/agents/middleware/types.py +2201 -0
  28. coco/agents/structured_output.py +472 -0
  29. coco/chat_models/__init__.py +7 -0
  30. coco/chat_models/base.py +1082 -0
  31. coco/embeddings/__init__.py +18 -0
  32. coco/embeddings/base.py +279 -0
  33. coco/mcp/__init__.py +36 -0
  34. coco/mcp/adapter.py +245 -0
  35. coco/mcp/elicitation.py +394 -0
  36. coco/mcp/tools.py +307 -0
  37. coco/messages/__init__.py +73 -0
  38. coco/py.typed +0 -0
  39. coco/rate_limiters/__init__.py +13 -0
  40. coco/tools/__init__.py +22 -0
  41. coco/tools/tool_node.py +20 -0
  42. coco_anthropic/__init__.py +15 -0
  43. coco_anthropic/_client_utils.py +81 -0
  44. coco_anthropic/_compat.py +228 -0
  45. coco_anthropic/_sdk_compat.py +124 -0
  46. coco_anthropic/_version.py +3 -0
  47. coco_anthropic/chat_models.py +3349 -0
  48. coco_anthropic/data/__init__.py +1 -0
  49. coco_anthropic/data/_profiles.py +491 -0
  50. coco_anthropic/data/profile_augmentations.toml +69 -0
  51. coco_anthropic/experimental.py +140 -0
  52. coco_anthropic/llms.py +428 -0
  53. coco_anthropic/middleware/__init__.py +25 -0
  54. coco_anthropic/middleware/anthropic_tools.py +1197 -0
  55. coco_anthropic/middleware/bash.py +84 -0
  56. coco_anthropic/middleware/file_search.py +360 -0
  57. coco_anthropic/middleware/prompt_caching.py +276 -0
  58. coco_anthropic/output_parsers.py +93 -0
  59. coco_anthropic/py.typed +0 -0
  60. coco_core/__init__.py +20 -0
  61. coco_core/_api/__init__.py +87 -0
  62. coco_core/_api/beta_decorator.py +266 -0
  63. coco_core/_api/deprecation.py +637 -0
  64. coco_core/_api/internal.py +23 -0
  65. coco_core/_api/path.py +50 -0
  66. coco_core/_import_utils.py +41 -0
  67. coco_core/_protocol.py +625 -0
  68. coco_core/_security/__init__.py +36 -0
  69. coco_core/_security/_exceptions.py +9 -0
  70. coco_core/_security/_policy.py +313 -0
  71. coco_core/_security/_ssrf_protection.py +155 -0
  72. coco_core/_security/_transport.py +254 -0
  73. coco_core/agents.py +258 -0
  74. coco_core/caches.py +272 -0
  75. coco_core/callbacks/__init__.py +132 -0
  76. coco_core/callbacks/base.py +1229 -0
  77. coco_core/callbacks/file.py +266 -0
  78. coco_core/callbacks/manager.py +2826 -0
  79. coco_core/callbacks/stdout.py +123 -0
  80. coco_core/callbacks/streaming_stdout.py +154 -0
  81. coco_core/callbacks/usage.py +124 -0
  82. coco_core/chat_history.py +267 -0
  83. coco_core/chat_loaders.py +26 -0
  84. coco_core/chat_sessions.py +19 -0
  85. coco_core/cross_encoders.py +18 -0
  86. coco_core/document_loaders/__init__.py +36 -0
  87. coco_core/document_loaders/base.py +155 -0
  88. coco_core/document_loaders/blob_loaders.py +38 -0
  89. coco_core/documents/__init__.py +55 -0
  90. coco_core/documents/base.py +347 -0
  91. coco_core/documents/compressor.py +74 -0
  92. coco_core/documents/transformers.py +79 -0
  93. coco_core/embeddings/__init__.py +31 -0
  94. coco_core/embeddings/embeddings.py +78 -0
  95. coco_core/embeddings/fake.py +129 -0
  96. coco_core/env.py +22 -0
  97. coco_core/example_selectors/__init__.py +47 -0
  98. coco_core/example_selectors/base.py +60 -0
  99. coco_core/example_selectors/length_based.py +131 -0
  100. coco_core/example_selectors/semantic_similarity.py +366 -0
  101. coco_core/exceptions.py +166 -0
  102. coco_core/globals.py +72 -0
  103. coco_core/indexing/__init__.py +53 -0
  104. coco_core/indexing/api.py +954 -0
  105. coco_core/indexing/base.py +661 -0
  106. coco_core/indexing/in_memory.py +104 -0
  107. coco_core/language_models/__init__.py +116 -0
  108. coco_core/language_models/_compat_bridge.py +844 -0
  109. coco_core/language_models/_utils.py +343 -0
  110. coco_core/language_models/base.py +500 -0
  111. coco_core/language_models/chat_model_stream.py +1525 -0
  112. coco_core/language_models/chat_models.py +2748 -0
  113. coco_core/language_models/fake.py +137 -0
  114. coco_core/language_models/fake_chat_models.py +396 -0
  115. coco_core/language_models/llms.py +1569 -0
  116. coco_core/language_models/model_profile.py +183 -0
  117. coco_core/load/__init__.py +44 -0
  118. coco_core/load/_validation.py +191 -0
  119. coco_core/load/dump.py +120 -0
  120. coco_core/load/load.py +828 -0
  121. coco_core/load/mapping.py +1097 -0
  122. coco_core/load/serializable.py +406 -0
  123. coco_core/messages/__init__.py +198 -0
  124. coco_core/messages/ai.py +854 -0
  125. coco_core/messages/base.py +518 -0
  126. coco_core/messages/block_translators/__init__.py +112 -0
  127. coco_core/messages/block_translators/anthropic.py +534 -0
  128. coco_core/messages/block_translators/bedrock.py +108 -0
  129. coco_core/messages/block_translators/bedrock_converse.py +329 -0
  130. coco_core/messages/block_translators/coco_v0.py +303 -0
  131. coco_core/messages/block_translators/google_genai.py +610 -0
  132. coco_core/messages/block_translators/google_vertexai.py +21 -0
  133. coco_core/messages/block_translators/groq.py +157 -0
  134. coco_core/messages/block_translators/openai.py +1089 -0
  135. coco_core/messages/chat.py +64 -0
  136. coco_core/messages/content.py +1488 -0
  137. coco_core/messages/function.py +62 -0
  138. coco_core/messages/human.py +70 -0
  139. coco_core/messages/modifier.py +33 -0
  140. coco_core/messages/system.py +70 -0
  141. coco_core/messages/tool.py +418 -0
  142. coco_core/messages/utils.py +2442 -0
  143. coco_core/output_parsers/__init__.py +101 -0
  144. coco_core/output_parsers/base.py +361 -0
  145. coco_core/output_parsers/format_instructions.py +16 -0
  146. coco_core/output_parsers/json.py +139 -0
  147. coco_core/output_parsers/list.py +249 -0
  148. coco_core/output_parsers/openai_functions.py +324 -0
  149. coco_core/output_parsers/openai_tools.py +383 -0
  150. coco_core/output_parsers/pydantic.py +141 -0
  151. coco_core/output_parsers/string.py +63 -0
  152. coco_core/output_parsers/transform.py +175 -0
  153. coco_core/output_parsers/xml.py +301 -0
  154. coco_core/outputs/__init__.py +66 -0
  155. coco_core/outputs/chat_generation.py +147 -0
  156. coco_core/outputs/chat_result.py +39 -0
  157. coco_core/outputs/generation.py +80 -0
  158. coco_core/outputs/llm_result.py +112 -0
  159. coco_core/outputs/run_info.py +22 -0
  160. coco_core/prompt_values.py +161 -0
  161. coco_core/prompts/__init__.py +101 -0
  162. coco_core/prompts/base.py +508 -0
  163. coco_core/prompts/chat.py +1495 -0
  164. coco_core/prompts/dict.py +177 -0
  165. coco_core/prompts/few_shot.py +483 -0
  166. coco_core/prompts/few_shot_with_templates.py +239 -0
  167. coco_core/prompts/image.py +177 -0
  168. coco_core/prompts/loading.py +296 -0
  169. coco_core/prompts/message.py +97 -0
  170. coco_core/prompts/prompt.py +312 -0
  171. coco_core/prompts/string.py +399 -0
  172. coco_core/prompts/structured.py +214 -0
  173. coco_core/py.typed +0 -0
  174. coco_core/rate_limiters.py +254 -0
  175. coco_core/retrievers.py +328 -0
  176. coco_core/runnables/__init__.py +136 -0
  177. coco_core/runnables/base.py +6725 -0
  178. coco_core/runnables/branch.py +457 -0
  179. coco_core/runnables/config.py +712 -0
  180. coco_core/runnables/configurable.py +710 -0
  181. coco_core/runnables/fallbacks.py +663 -0
  182. coco_core/runnables/graph.py +741 -0
  183. coco_core/runnables/graph_ascii.py +366 -0
  184. coco_core/runnables/graph_mermaid.py +503 -0
  185. coco_core/runnables/graph_png.py +215 -0
  186. coco_core/runnables/history.py +631 -0
  187. coco_core/runnables/passthrough.py +849 -0
  188. coco_core/runnables/retry.py +379 -0
  189. coco_core/runnables/router.py +239 -0
  190. coco_core/runnables/schema.py +188 -0
  191. coco_core/runnables/utils.py +798 -0
  192. coco_core/stores.py +291 -0
  193. coco_core/structured_query.py +203 -0
  194. coco_core/sys_info.py +154 -0
  195. coco_core/tools/__init__.py +95 -0
  196. coco_core/tools/base.py +1948 -0
  197. coco_core/tools/convert.py +497 -0
  198. coco_core/tools/render.py +67 -0
  199. coco_core/tools/retriever.py +94 -0
  200. coco_core/tools/simple.py +208 -0
  201. coco_core/tools/structured.py +322 -0
  202. coco_core/tracers/__init__.py +47 -0
  203. coco_core/tracers/_compat.py +95 -0
  204. coco_core/tracers/_context.py +337 -0
  205. coco_core/tracers/_streaming.py +53 -0
  206. coco_core/tracers/base.py +955 -0
  207. coco_core/tracers/coco.py +279 -0
  208. coco_core/tracers/context.py +205 -0
  209. coco_core/tracers/core.py +766 -0
  210. coco_core/tracers/event_stream.py +1101 -0
  211. coco_core/tracers/log_stream.py +765 -0
  212. coco_core/tracers/memory_stream.py +200 -0
  213. coco_core/tracers/root_listeners.py +130 -0
  214. coco_core/tracers/run_collector.py +40 -0
  215. coco_core/tracers/schemas.py +14 -0
  216. coco_core/tracers/stdout.py +205 -0
  217. coco_core/utils/__init__.py +111 -0
  218. coco_core/utils/_gateway.py +274 -0
  219. coco_core/utils/_merge.py +218 -0
  220. coco_core/utils/aiter.py +357 -0
  221. coco_core/utils/env.py +86 -0
  222. coco_core/utils/formatting.py +80 -0
  223. coco_core/utils/function_calling.py +869 -0
  224. coco_core/utils/html.py +132 -0
  225. coco_core/utils/image.py +15 -0
  226. coco_core/utils/input.py +82 -0
  227. coco_core/utils/interactive_env.py +12 -0
  228. coco_core/utils/iter.py +229 -0
  229. coco_core/utils/json.py +230 -0
  230. coco_core/utils/json_schema.py +273 -0
  231. coco_core/utils/mustache.py +706 -0
  232. coco_core/utils/pydantic.py +631 -0
  233. coco_core/utils/strings.py +69 -0
  234. coco_core/utils/usage.py +61 -0
  235. coco_core/utils/utils.py +528 -0
  236. coco_core/utils/uuid.py +57 -0
  237. coco_core/vectorstores/__init__.py +53 -0
  238. coco_core/vectorstores/base.py +1111 -0
  239. coco_core/vectorstores/in_memory.py +546 -0
  240. coco_core/vectorstores/utils.py +163 -0
  241. coco_core/version.py +3 -0
  242. coco_framework-0.1.0.dist-info/METADATA +184 -0
  243. coco_framework-0.1.0.dist-info/RECORD +400 -0
  244. coco_framework-0.1.0.dist-info/WHEEL +4 -0
  245. coco_framework-0.1.0.dist-info/licenses/LICENSE +24 -0
  246. coco_framework-0.1.0.dist-info/licenses/NOTICE +15 -0
  247. coco_graph/_internal/__init__.py +4 -0
  248. coco_graph/_internal/_cache.py +31 -0
  249. coco_graph/_internal/_config.py +474 -0
  250. coco_graph/_internal/_constants.py +140 -0
  251. coco_graph/_internal/_fields.py +213 -0
  252. coco_graph/_internal/_future.py +220 -0
  253. coco_graph/_internal/_pydantic.py +275 -0
  254. coco_graph/_internal/_queue.py +124 -0
  255. coco_graph/_internal/_replay.py +90 -0
  256. coco_graph/_internal/_retry.py +29 -0
  257. coco_graph/_internal/_runnable.py +969 -0
  258. coco_graph/_internal/_scratchpad.py +19 -0
  259. coco_graph/_internal/_serde.py +253 -0
  260. coco_graph/_internal/_timeout.py +25 -0
  261. coco_graph/_internal/_typing.py +54 -0
  262. coco_graph/cache/base/__init__.py +48 -0
  263. coco_graph/cache/base/py.typed +0 -0
  264. coco_graph/cache/memory/__init__.py +73 -0
  265. coco_graph/cache/redis/__init__.py +144 -0
  266. coco_graph/cache/sqlite/__init__.py +120 -0
  267. coco_graph/callbacks.py +394 -0
  268. coco_graph/channels/__init__.py +29 -0
  269. coco_graph/channels/any_value.py +72 -0
  270. coco_graph/channels/base.py +121 -0
  271. coco_graph/channels/binop.py +155 -0
  272. coco_graph/channels/delta.py +202 -0
  273. coco_graph/channels/ephemeral_value.py +79 -0
  274. coco_graph/channels/last_value.py +151 -0
  275. coco_graph/channels/named_barrier_value.py +167 -0
  276. coco_graph/channels/topic.py +94 -0
  277. coco_graph/channels/untracked_value.py +73 -0
  278. coco_graph/checkpoint/base/__init__.py +861 -0
  279. coco_graph/checkpoint/base/id.py +109 -0
  280. coco_graph/checkpoint/base/py.typed +0 -0
  281. coco_graph/checkpoint/memory/__init__.py +698 -0
  282. coco_graph/checkpoint/memory/py.typed +0 -0
  283. coco_graph/checkpoint/postgres/__init__.py +602 -0
  284. coco_graph/checkpoint/postgres/_ainternal.py +23 -0
  285. coco_graph/checkpoint/postgres/_internal.py +21 -0
  286. coco_graph/checkpoint/postgres/aio.py +691 -0
  287. coco_graph/checkpoint/postgres/base.py +666 -0
  288. coco_graph/checkpoint/postgres/py.typed +0 -0
  289. coco_graph/checkpoint/postgres/shallow.py +967 -0
  290. coco_graph/checkpoint/serde/__init__.py +0 -0
  291. coco_graph/checkpoint/serde/_msgpack.py +97 -0
  292. coco_graph/checkpoint/serde/base.py +64 -0
  293. coco_graph/checkpoint/serde/encrypted.py +80 -0
  294. coco_graph/checkpoint/serde/event_hooks.py +52 -0
  295. coco_graph/checkpoint/serde/jsonplus.py +883 -0
  296. coco_graph/checkpoint/serde/py.typed +0 -0
  297. coco_graph/checkpoint/serde/types.py +68 -0
  298. coco_graph/checkpoint/sqlite/__init__.py +645 -0
  299. coco_graph/checkpoint/sqlite/_delta.py +172 -0
  300. coco_graph/checkpoint/sqlite/aio.py +738 -0
  301. coco_graph/checkpoint/sqlite/py.typed +0 -0
  302. coco_graph/checkpoint/sqlite/utils.py +116 -0
  303. coco_graph/config.py +196 -0
  304. coco_graph/constants.py +64 -0
  305. coco_graph/errors.py +241 -0
  306. coco_graph/func/__init__.py +620 -0
  307. coco_graph/graph/__init__.py +12 -0
  308. coco_graph/graph/_branch.py +225 -0
  309. coco_graph/graph/_node.py +103 -0
  310. coco_graph/graph/message.py +437 -0
  311. coco_graph/graph/state.py +1978 -0
  312. coco_graph/graph/ui.py +227 -0
  313. coco_graph/managed/__init__.py +3 -0
  314. coco_graph/managed/base.py +31 -0
  315. coco_graph/managed/is_last_step.py +24 -0
  316. coco_graph/prebuilt/__init__.py +23 -0
  317. coco_graph/prebuilt/_tool_call_stream.py +117 -0
  318. coco_graph/prebuilt/_tool_call_transformer.py +165 -0
  319. coco_graph/prebuilt/chat_agent_executor.py +1015 -0
  320. coco_graph/prebuilt/interrupt.py +105 -0
  321. coco_graph/prebuilt/py.typed +0 -0
  322. coco_graph/prebuilt/tool_node.py +2030 -0
  323. coco_graph/prebuilt/tool_validator.py +221 -0
  324. coco_graph/pregel/__init__.py +3 -0
  325. coco_graph/pregel/_algo.py +1460 -0
  326. coco_graph/pregel/_call.py +298 -0
  327. coco_graph/pregel/_checkpoint.py +331 -0
  328. coco_graph/pregel/_config.py +0 -0
  329. coco_graph/pregel/_draw.py +294 -0
  330. coco_graph/pregel/_executor.py +223 -0
  331. coco_graph/pregel/_io.py +174 -0
  332. coco_graph/pregel/_log.py +3 -0
  333. coco_graph/pregel/_loop.py +1988 -0
  334. coco_graph/pregel/_messages.py +461 -0
  335. coco_graph/pregel/_read.py +312 -0
  336. coco_graph/pregel/_retry.py +854 -0
  337. coco_graph/pregel/_runner.py +941 -0
  338. coco_graph/pregel/_tools.py +268 -0
  339. coco_graph/pregel/_utils.py +221 -0
  340. coco_graph/pregel/_validate.py +120 -0
  341. coco_graph/pregel/_write.py +192 -0
  342. coco_graph/pregel/debug.py +302 -0
  343. coco_graph/pregel/main.py +4364 -0
  344. coco_graph/pregel/protocol.py +288 -0
  345. coco_graph/pregel/types.py +38 -0
  346. coco_graph/py.typed +0 -0
  347. coco_graph/runtime.py +350 -0
  348. coco_graph/store/base/__init__.py +1322 -0
  349. coco_graph/store/base/batch.py +371 -0
  350. coco_graph/store/base/embed.py +436 -0
  351. coco_graph/store/base/py.typed +0 -0
  352. coco_graph/store/memory/__init__.py +592 -0
  353. coco_graph/store/memory/py.typed +0 -0
  354. coco_graph/store/postgres/__init__.py +4 -0
  355. coco_graph/store/postgres/aio.py +592 -0
  356. coco_graph/store/postgres/base.py +1480 -0
  357. coco_graph/store/postgres/py.typed +0 -0
  358. coco_graph/store/sqlite/__init__.py +4 -0
  359. coco_graph/store/sqlite/aio.py +632 -0
  360. coco_graph/store/sqlite/base.py +1514 -0
  361. coco_graph/stream/__init__.py +45 -0
  362. coco_graph/stream/_convert.py +32 -0
  363. coco_graph/stream/_mux.py +523 -0
  364. coco_graph/stream/_types.py +330 -0
  365. coco_graph/stream/run_stream.py +735 -0
  366. coco_graph/stream/stream_channel.py +341 -0
  367. coco_graph/stream/transformers.py +1039 -0
  368. coco_graph/types.py +1079 -0
  369. coco_graph/typing.py +48 -0
  370. coco_graph/utils/__init__.py +1 -0
  371. coco_graph/utils/config.py +4 -0
  372. coco_graph/utils/runnable.py +3 -0
  373. coco_graph/version.py +13 -0
  374. coco_graph/warnings.py +69 -0
  375. coco_openai/__init__.py +20 -0
  376. coco_openai/_compat.py +45 -0
  377. coco_openai/_version.py +3 -0
  378. coco_openai/chat_models/__init__.py +6 -0
  379. coco_openai/chat_models/_client_utils.py +684 -0
  380. coco_openai/chat_models/_compat.py +525 -0
  381. coco_openai/chat_models/azure.py +1227 -0
  382. coco_openai/chat_models/base.py +5654 -0
  383. coco_openai/chat_models/codex.py +557 -0
  384. coco_openai/chatgpt_oauth.py +1063 -0
  385. coco_openai/data/__init__.py +1 -0
  386. coco_openai/data/_profiles.py +1655 -0
  387. coco_openai/data/profile_augmentations.toml +127 -0
  388. coco_openai/embeddings/__init__.py +6 -0
  389. coco_openai/embeddings/azure.py +240 -0
  390. coco_openai/embeddings/base.py +820 -0
  391. coco_openai/llms/__init__.py +6 -0
  392. coco_openai/llms/azure.py +249 -0
  393. coco_openai/llms/base.py +919 -0
  394. coco_openai/middleware/__init__.py +11 -0
  395. coco_openai/middleware/openai_moderation.py +484 -0
  396. coco_openai/output_parsers/__init__.py +9 -0
  397. coco_openai/output_parsers/tools.py +9 -0
  398. coco_openai/py.typed +0 -0
  399. coco_openai/tools/__init__.py +5 -0
  400. coco_openai/tools/custom_tool.py +111 -0
@@ -0,0 +1,515 @@
1
+ """LLM-based tool selector middleware."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from collections.abc import Callable
7
+ from dataclasses import dataclass
8
+ from typing import TYPE_CHECKING, Annotated, Any, Literal, TypeGuard, Union
9
+
10
+ from coco_core.language_models.chat_models import BaseChatModel
11
+ from coco_core.messages import AIMessage, BaseMessage, HumanMessage
12
+ from pydantic import Field, TypeAdapter
13
+ from typing_extensions import TypedDict
14
+
15
+ from coco.agents.middleware.internal_call_transformer import (
16
+ InternalCallTransformer,
17
+ internal_call_metadata,
18
+ )
19
+ from coco.agents.middleware.types import (
20
+ AgentMiddleware,
21
+ AgentState,
22
+ ContextT,
23
+ ModelRequest,
24
+ ModelResponse,
25
+ ResponseT,
26
+ )
27
+ from coco.chat_models.base import init_chat_model
28
+
29
+ if TYPE_CHECKING:
30
+ from collections.abc import Awaitable
31
+
32
+ from coco_core.runnables import RunnableConfig
33
+
34
+ from coco.tools import BaseTool
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+ DEFAULT_SYSTEM_PROMPT = (
39
+ "Your goal is to select the most relevant tools for answering the user's query."
40
+ )
41
+
42
+ OnParsingFailure = Literal["error", "none", "all"] | list[str] | Callable[[Any], list[str]]
43
+ """Behavior when the selection model keeps returning a malformed response.
44
+
45
+ Can be either:
46
+ - `'error'`: Raise a `ValueError` (the default).
47
+ - `'none'`: Select no tools.
48
+ - `'all'`: Select every available tool.
49
+ - A `list[str]` of tool names to fall back to.
50
+ - A callable that takes the last (malformed) response and returns tool names to use.
51
+ """
52
+
53
+
54
+ @dataclass
55
+ class _SelectionRequest:
56
+ """Prepared inputs for tool selection."""
57
+
58
+ available_tools: list[BaseTool]
59
+ system_message: str
60
+ last_user_message: HumanMessage
61
+ model: BaseChatModel
62
+ valid_tool_names: list[str]
63
+
64
+
65
+ def _create_tool_selection_response(tools: list[BaseTool]) -> TypeAdapter[Any]:
66
+ """Create a structured output schema for tool selection.
67
+
68
+ Args:
69
+ tools: Available tools to include in the schema.
70
+
71
+ Returns:
72
+ `TypeAdapter` for a schema where each tool name is a `Literal` with its
73
+ description.
74
+
75
+ Raises:
76
+ AssertionError: If `tools` is empty.
77
+ """
78
+ if not tools:
79
+ msg = "Invalid usage: tools must be non-empty"
80
+ raise AssertionError(msg)
81
+
82
+ # Create a Union of Annotated Literal types for each tool name with description
83
+ # For instance: Union[Annotated[Literal["tool1"], Field(description="...")], ...]
84
+ literals = [
85
+ Annotated[Literal[tool.name], Field(description=tool.description)] for tool in tools
86
+ ]
87
+ selected_tool_type = Union[tuple(literals)] # type: ignore[valid-type] # noqa: UP007
88
+
89
+ description = "Tools to use. Place the most relevant tools first."
90
+
91
+ class ToolSelectionResponse(TypedDict):
92
+ """Use to select relevant tools."""
93
+
94
+ tools: Annotated[list[selected_tool_type], Field(description=description)] # type: ignore[valid-type]
95
+
96
+ return TypeAdapter(ToolSelectionResponse)
97
+
98
+
99
+ def _is_valid_selection_response(response: Any) -> TypeGuard[dict[str, Any]]:
100
+ """Check whether a structured-output response is a well-formed tool selection.
101
+
102
+ Args:
103
+ response: Raw response from the structured-output model call.
104
+
105
+ Returns:
106
+ `True` if `response` is a dict with a `tools` list, `False` otherwise.
107
+ """
108
+ return isinstance(response, dict) and isinstance(response.get("tools"), list)
109
+
110
+
111
+ def _render_tool_list(tools: list[BaseTool]) -> str:
112
+ """Format tools as markdown list.
113
+
114
+ Args:
115
+ tools: Tools to format.
116
+
117
+ Returns:
118
+ Markdown string with each tool on a new line.
119
+ """
120
+ return "\n".join(f"- {tool.name}: {tool.description}" for tool in tools)
121
+
122
+
123
+ class LLMToolSelectorMiddleware(AgentMiddleware[AgentState[ResponseT], ContextT, ResponseT]):
124
+ """Uses an LLM to select relevant tools before calling the main model.
125
+
126
+ When an agent has many tools available, this middleware filters them down
127
+ to only the most relevant ones for the user's query. This reduces token usage
128
+ and helps the main model focus on the right tools.
129
+
130
+ Examples:
131
+ !!! example "Limit to 3 tools"
132
+
133
+ ```python
134
+ from coco.agents.middleware import LLMToolSelectorMiddleware
135
+
136
+ middleware = LLMToolSelectorMiddleware(max_tools=3)
137
+
138
+ agent = create_agent(
139
+ model="openai:gpt-5.5",
140
+ tools=[tool1, tool2, tool3, tool4, tool5],
141
+ middleware=[middleware],
142
+ )
143
+ ```
144
+
145
+ !!! example "Use a smaller model for selection"
146
+
147
+ ```python
148
+ middleware = LLMToolSelectorMiddleware(model="openai:gpt-5.4-mini", max_tools=2)
149
+ ```
150
+ """
151
+
152
+ transformers = (InternalCallTransformer,)
153
+ """Keeps the tool-selection model call's tokens out of `run.messages`.
154
+
155
+ Registered only when this middleware is used — see
156
+ `InternalCallTransformer` for why the call needs tagging and filtering.
157
+ """
158
+
159
+ def __init__(
160
+ self,
161
+ *,
162
+ model: str | BaseChatModel | None = None,
163
+ system_prompt: str = DEFAULT_SYSTEM_PROMPT,
164
+ max_tools: int | None = None,
165
+ always_include: list[str] | None = None,
166
+ max_retries: int = 1,
167
+ on_parsing_failure: OnParsingFailure = "error",
168
+ ) -> None:
169
+ """Initialize the tool selector.
170
+
171
+ Args:
172
+ model: Model to use for selection.
173
+
174
+ If not provided, uses the agent's main model.
175
+
176
+ Can be a model identifier string or `BaseChatModel` instance.
177
+ system_prompt: Instructions for the selection model.
178
+ max_tools: Maximum number of tools to select.
179
+
180
+ If the model selects more, only the first `max_tools` will be used.
181
+
182
+ If not specified, there is no limit.
183
+ always_include: Tool names to always include regardless of selection.
184
+
185
+ These do not count against the `max_tools` limit.
186
+ max_retries: Maximum number of retry attempts after the initial call if
187
+ the selection model returns a malformed response (not a dict with a
188
+ `tools` list).
189
+
190
+ Must be `>= 0`.
191
+ on_parsing_failure: Behavior once `max_retries` is exhausted and the
192
+ response is still malformed.
193
+
194
+ Options:
195
+
196
+ - `'error'` (default): Raise a `ValueError`.
197
+ - `'none'`: Select no tools.
198
+ - `'all'`: Select every available tool.
199
+ - A `list[str]` of tool names to fall back to.
200
+ - A callable that takes the last (malformed) response and returns
201
+ the tool names to use.
202
+
203
+ Unlike a normal model selection, the fallback tools are not capped
204
+ by `max_tools` -- it's an already-deliberate choice, not raw model
205
+ output that needs bounding.
206
+
207
+ Raises:
208
+ ValueError: If `max_retries < 0`.
209
+ """
210
+ super().__init__()
211
+ if max_retries < 0:
212
+ msg = "max_retries must be >= 0"
213
+ raise ValueError(msg)
214
+ self.system_prompt = system_prompt
215
+ self.max_tools = max_tools
216
+ self.always_include = always_include or []
217
+ self.max_retries = max_retries
218
+ self.on_parsing_failure = on_parsing_failure
219
+
220
+ if isinstance(model, (BaseChatModel, type(None))):
221
+ self.model: BaseChatModel | None = model
222
+ else:
223
+ self.model = init_chat_model(model)
224
+
225
+ def _prepare_selection_request(
226
+ self, request: ModelRequest[ContextT]
227
+ ) -> _SelectionRequest | None:
228
+ """Prepare inputs for tool selection.
229
+
230
+ Args:
231
+ request: the model request.
232
+
233
+ Returns:
234
+ `SelectionRequest` with prepared inputs, or `None` if no selection is needed.
235
+
236
+ Raises:
237
+ ValueError: If tools in `always_include` are not found in the request.
238
+ AssertionError: If no user message is found in the request messages.
239
+ """
240
+ # If no tools available, return None
241
+ if not request.tools or len(request.tools) == 0:
242
+ return None
243
+
244
+ # Filter to only BaseTool instances (exclude provider-specific tool dicts)
245
+ base_tools = [tool for tool in request.tools if not isinstance(tool, dict)]
246
+
247
+ # Validate that always_include tools exist
248
+ if self.always_include:
249
+ available_tool_names = {tool.name for tool in base_tools}
250
+ missing_tools = [
251
+ name for name in self.always_include if name not in available_tool_names
252
+ ]
253
+ if missing_tools:
254
+ msg = (
255
+ f"Tools in always_include not found in request: {missing_tools}. "
256
+ f"Available tools: {sorted(available_tool_names)}"
257
+ )
258
+ raise ValueError(msg)
259
+
260
+ # Separate tools that are always included from those available for selection
261
+ available_tools = [tool for tool in base_tools if tool.name not in self.always_include]
262
+
263
+ # If no tools available for selection, return None
264
+ if not available_tools:
265
+ return None
266
+
267
+ system_message = self.system_prompt
268
+ # If there's a max_tools limit, append instructions to the system prompt
269
+ if self.max_tools is not None:
270
+ system_message += (
271
+ f"\nIMPORTANT: List the tool names in order of relevance, "
272
+ f"with the most relevant first. "
273
+ f"If you exceed the maximum number of tools, "
274
+ f"only the first {self.max_tools} will be used."
275
+ )
276
+
277
+ # Get the last user message from the conversation history
278
+ last_user_message: HumanMessage
279
+ for message in reversed(request.messages):
280
+ if isinstance(message, HumanMessage):
281
+ last_user_message = message
282
+ break
283
+ else:
284
+ msg = "No user message found in request messages"
285
+ raise AssertionError(msg)
286
+
287
+ model = self.model or request.model
288
+ valid_tool_names = [tool.name for tool in available_tools]
289
+
290
+ return _SelectionRequest(
291
+ available_tools=available_tools,
292
+ system_message=system_message,
293
+ last_user_message=last_user_message,
294
+ model=model,
295
+ valid_tool_names=valid_tool_names,
296
+ )
297
+
298
+ def _process_selection_response(
299
+ self,
300
+ response: dict[str, Any],
301
+ available_tools: list[BaseTool],
302
+ valid_tool_names: list[str],
303
+ request: ModelRequest[ContextT],
304
+ *,
305
+ apply_max_tools: bool = True,
306
+ ) -> ModelRequest[ContextT]:
307
+ """Process the selection response and return filtered `ModelRequest`.
308
+
309
+ Args:
310
+ response: Selection response, expected to have a `tools` list.
311
+ available_tools: Tools eligible for selection.
312
+ valid_tool_names: Names of `available_tools`.
313
+ request: Original model request to override.
314
+ apply_max_tools: Whether to cap the selection at `max_tools`.
315
+
316
+ Set to `False` for an already-deliberate fallback selection (e.g.
317
+ `on_parsing_failure="all"`), where truncating would contradict the
318
+ fallback's own semantics.
319
+ """
320
+ selected_tool_names: list[str] = []
321
+ invalid_tool_selections = []
322
+ max_tools = self.max_tools if apply_max_tools else None
323
+
324
+ for tool_name in response.get("tools", []):
325
+ if tool_name not in valid_tool_names:
326
+ invalid_tool_selections.append(tool_name)
327
+ continue
328
+
329
+ # Only add if not already selected and within max_tools limit
330
+ if tool_name not in selected_tool_names and (
331
+ max_tools is None or len(selected_tool_names) < max_tools
332
+ ):
333
+ selected_tool_names.append(tool_name)
334
+
335
+ if invalid_tool_selections:
336
+ msg = f"Model selected invalid tools: {invalid_tool_selections}"
337
+ raise ValueError(msg)
338
+
339
+ # Filter tools based on selection and append always-included tools
340
+ selected_tools: list[BaseTool] = [
341
+ tool for tool in available_tools if tool.name in selected_tool_names
342
+ ]
343
+ always_included_tools: list[BaseTool] = [
344
+ tool
345
+ for tool in request.tools
346
+ if not isinstance(tool, dict) and tool.name in self.always_include
347
+ ]
348
+ selected_tools.extend(always_included_tools)
349
+
350
+ # Also preserve any provider-specific tool dicts from the original request
351
+ provider_tools = [tool for tool in request.tools if isinstance(tool, dict)]
352
+
353
+ return request.override(tools=[*selected_tools, *provider_tools])
354
+
355
+ def _resolve_parsing_failure(self, response: Any, valid_tool_names: list[str]) -> list[str]:
356
+ """Determine which tool names to use once `max_retries` is exhausted.
357
+
358
+ Args:
359
+ response: The last (still malformed) response from the selection model.
360
+ valid_tool_names: Tool names available for selection.
361
+
362
+ Returns:
363
+ Tool names to select, per `on_parsing_failure`.
364
+
365
+ Raises:
366
+ ValueError: If `on_parsing_failure == "error"` (the default).
367
+ """
368
+ if self.on_parsing_failure == "error":
369
+ msg = (
370
+ "LLMToolSelectorMiddleware: selection model returned a malformed "
371
+ f"response after {self.max_retries} retries (expected a dict with a "
372
+ f"'tools' list): {response!r}"
373
+ )
374
+ raise ValueError(msg)
375
+ if self.on_parsing_failure == "none":
376
+ return []
377
+ if self.on_parsing_failure == "all":
378
+ return valid_tool_names
379
+ if callable(self.on_parsing_failure):
380
+ return self.on_parsing_failure(response)
381
+ return list(self.on_parsing_failure)
382
+
383
+ def wrap_model_call(
384
+ self,
385
+ request: ModelRequest[ContextT],
386
+ handler: Callable[[ModelRequest[ContextT]], ModelResponse[ResponseT]],
387
+ ) -> ModelResponse[ResponseT] | AIMessage:
388
+ """Filter tools based on LLM selection before invoking the model via handler.
389
+
390
+ Args:
391
+ request: Model request to execute (includes state and runtime).
392
+ handler: Async callback that executes the model request and returns `ModelResponse`.
393
+
394
+ Returns:
395
+ The model call result.
396
+
397
+ Raises:
398
+ ValueError: If `on_parsing_failure == "error"` (the default) and the
399
+ selection model returns a malformed response after `max_retries`
400
+ retries.
401
+ """
402
+ selection_request = self._prepare_selection_request(request)
403
+ if selection_request is None:
404
+ return handler(request)
405
+
406
+ # Create dynamic response model with Literal enum of available tool names
407
+ type_adapter = _create_tool_selection_response(selection_request.available_tools)
408
+ schema = type_adapter.json_schema()
409
+ structured_model = selection_request.model.with_structured_output(schema)
410
+
411
+ messages: list[BaseMessage | dict[str, Any]] = [
412
+ {"role": "system", "content": selection_request.system_message},
413
+ selection_request.last_user_message,
414
+ ]
415
+ config: RunnableConfig = {
416
+ "metadata": {"lc_source": "tool_selection", **internal_call_metadata()}
417
+ }
418
+
419
+ response = structured_model.invoke(messages, config=config)
420
+ attempts = 0
421
+ while not _is_valid_selection_response(response) and attempts < self.max_retries:
422
+ # Malformed structured output is usually transient, retry before giving
423
+ # up, rather than silently degrading to no/all tools selected.
424
+ response = structured_model.invoke(messages, config=config)
425
+ attempts += 1
426
+
427
+ if _is_valid_selection_response(response):
428
+ modified_request = self._process_selection_response(
429
+ response,
430
+ selection_request.available_tools,
431
+ selection_request.valid_tool_names,
432
+ request,
433
+ )
434
+ else:
435
+ fallback_tool_names = self._resolve_parsing_failure(
436
+ response, selection_request.valid_tool_names
437
+ )
438
+ # Bypass max_tools: the fallback is already a deliberate selection
439
+ # (e.g. `on_parsing_failure="all"`), not raw model output that needs
440
+ # to be capped.
441
+ modified_request = self._process_selection_response(
442
+ {"tools": fallback_tool_names},
443
+ selection_request.available_tools,
444
+ selection_request.valid_tool_names,
445
+ request,
446
+ apply_max_tools=False,
447
+ )
448
+ return handler(modified_request)
449
+
450
+ async def awrap_model_call(
451
+ self,
452
+ request: ModelRequest[ContextT],
453
+ handler: Callable[[ModelRequest[ContextT]], Awaitable[ModelResponse[ResponseT]]],
454
+ ) -> ModelResponse[ResponseT] | AIMessage:
455
+ """Filter tools based on LLM selection before invoking the model via handler.
456
+
457
+ Args:
458
+ request: Model request to execute (includes state and runtime).
459
+ handler: Async callback that executes the model request and returns `ModelResponse`.
460
+
461
+ Returns:
462
+ The model call result.
463
+
464
+ Raises:
465
+ ValueError: If `on_parsing_failure == "error"` (the default) and the
466
+ selection model returns a malformed response after `max_retries`
467
+ retries.
468
+ """
469
+ selection_request = self._prepare_selection_request(request)
470
+ if selection_request is None:
471
+ return await handler(request)
472
+
473
+ # Create dynamic response model with Literal enum of available tool names
474
+ type_adapter = _create_tool_selection_response(selection_request.available_tools)
475
+ schema = type_adapter.json_schema()
476
+ structured_model = selection_request.model.with_structured_output(schema)
477
+
478
+ messages: list[BaseMessage | dict[str, Any]] = [
479
+ {"role": "system", "content": selection_request.system_message},
480
+ selection_request.last_user_message,
481
+ ]
482
+ config: RunnableConfig = {
483
+ "metadata": {"lc_source": "tool_selection", **internal_call_metadata()}
484
+ }
485
+
486
+ response = await structured_model.ainvoke(messages, config=config)
487
+ attempts = 0
488
+ while not _is_valid_selection_response(response) and attempts < self.max_retries:
489
+ # Malformed structured output is usually transient, retry before giving
490
+ # up, rather than silently degrading to no/all tools selected.
491
+ response = await structured_model.ainvoke(messages, config=config)
492
+ attempts += 1
493
+
494
+ if _is_valid_selection_response(response):
495
+ modified_request = self._process_selection_response(
496
+ response,
497
+ selection_request.available_tools,
498
+ selection_request.valid_tool_names,
499
+ request,
500
+ )
501
+ else:
502
+ fallback_tool_names = self._resolve_parsing_failure(
503
+ response, selection_request.valid_tool_names
504
+ )
505
+ # Bypass max_tools: the fallback is already a deliberate selection
506
+ # (e.g. `on_parsing_failure="all"`), not raw model output that needs
507
+ # to be capped.
508
+ modified_request = self._process_selection_response(
509
+ {"tools": fallback_tool_names},
510
+ selection_request.available_tools,
511
+ selection_request.valid_tool_names,
512
+ request,
513
+ apply_max_tools=False,
514
+ )
515
+ return await handler(modified_request)