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,680 @@
1
+ """Human in the loop middleware."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import replace
7
+ from typing import TYPE_CHECKING, Annotated, Any, Literal, Protocol, cast
8
+
9
+ from coco_core.messages import AIMessage, ToolCall, ToolMessage
10
+ from coco_graph.config import get_config
11
+ from coco_graph.prebuilt.tool_node import ToolRuntime
12
+ from coco_graph.types import Command, interrupt
13
+ from typing_extensions import NotRequired, TypedDict
14
+
15
+ from coco.agents.middleware.types import (
16
+ AgentMiddleware,
17
+ AgentState,
18
+ ContextT,
19
+ PrivateStateAttr,
20
+ ResponseT,
21
+ StateT,
22
+ ToolCallRequest,
23
+ )
24
+
25
+ if TYPE_CHECKING:
26
+ from collections.abc import Awaitable, Callable
27
+
28
+ from coco_graph.runtime import Runtime
29
+
30
+
31
+ _EDITED_TOOL_CALLS_KEY = "hitl_edited_tool_calls"
32
+ """State key mapping tool call ID to the reviewer's replacement for it."""
33
+
34
+ _EDIT_NOTICE = (
35
+ "Note: a human reviewer replaced this tool call before it ran. The call recorded in "
36
+ "your message is the one you produced, not the one that executed. This was "
37
+ "intentional and authorized. Do not re-issue your original call."
38
+ )
39
+ """Default text prepended to the result of a tool call a reviewer edited."""
40
+
41
+
42
+ class Action(TypedDict):
43
+ """Represents an action with a name and args."""
44
+
45
+ name: str
46
+ """The type or name of action being requested (e.g., `'add_numbers'`)."""
47
+
48
+ args: dict[str, Any]
49
+ """Key-value pairs of args needed for the action (e.g., `{"a": 1, "b": 2}`)."""
50
+
51
+
52
+ class ActionRequest(TypedDict):
53
+ """Represents an action request with a name, args, and description."""
54
+
55
+ name: str
56
+ """The name of the action being requested."""
57
+
58
+ args: dict[str, Any]
59
+ """Key-value pairs of args needed for the action (e.g., `{"a": 1, "b": 2}`)."""
60
+
61
+ description: NotRequired[str]
62
+ """The description of the action to be reviewed."""
63
+
64
+
65
+ DecisionType = Literal["approve", "edit", "reject", "respond"]
66
+
67
+
68
+ class ReviewConfig(TypedDict):
69
+ """Policy for reviewing a HITL request."""
70
+
71
+ action_name: str
72
+ """Name of the action associated with this review configuration."""
73
+
74
+ allowed_decisions: list[DecisionType]
75
+ """The decisions that are allowed for this request."""
76
+
77
+ args_schema: NotRequired[dict[str, Any]]
78
+ """JSON schema for the args associated with the action, if edits are allowed."""
79
+
80
+
81
+ class HITLRequest(TypedDict):
82
+ """Request for human feedback on a sequence of actions requested by a model."""
83
+
84
+ action_requests: list[ActionRequest]
85
+ """A list of agent actions for human review."""
86
+
87
+ review_configs: list[ReviewConfig]
88
+ """Review configuration for all possible actions."""
89
+
90
+
91
+ class ApproveDecision(TypedDict):
92
+ """Response when a human approves the action."""
93
+
94
+ type: Literal["approve"]
95
+ """The type of response when a human approves the action."""
96
+
97
+
98
+ class EditDecision(TypedDict):
99
+ """Response when a human edits the action."""
100
+
101
+ type: Literal["edit"]
102
+ """The type of response when a human edits the action."""
103
+
104
+ edited_action: Action
105
+ """Edited action for the agent to perform.
106
+
107
+ Ex: for a tool call, a human reviewer can edit the tool name and args.
108
+ """
109
+
110
+
111
+ class RejectDecision(TypedDict):
112
+ """Response when a human rejects the action."""
113
+
114
+ type: Literal["reject"]
115
+ """The type of response when a human rejects the action."""
116
+
117
+ message: NotRequired[str]
118
+ """The human-provided reason for rejecting the action.
119
+
120
+ The reason is framed as a user rejection when sent to the model. If omitted,
121
+ the model is told that the tool was not executed and should not retry the same
122
+ tool call unless the user asks for it.
123
+ """
124
+
125
+
126
+ class RespondDecision(TypedDict):
127
+ """Response when a human answers on behalf of the tool, skipping execution.
128
+
129
+ Used for "ask user" style tools whose real implementation is the human's
130
+ response. The tool is not executed; instead, a synthetic `ToolMessage` with
131
+ `status="success"` and the provided `message` is returned to the model.
132
+ """
133
+
134
+ type: Literal["respond"]
135
+ """The type of response when a human responds on behalf of the tool."""
136
+
137
+ message: str
138
+ """Content of the synthetic `ToolMessage` returned to the model."""
139
+
140
+
141
+ Decision = ApproveDecision | EditDecision | RejectDecision | RespondDecision
142
+
143
+
144
+ class HITLResponse(TypedDict):
145
+ """Response payload for a HITLRequest."""
146
+
147
+ decisions: list[Decision]
148
+ """The decisions made by the human."""
149
+
150
+
151
+ class _DescriptionFactory(Protocol):
152
+ """Callable that generates a description for a tool call."""
153
+
154
+ def __call__(
155
+ self, tool_call: ToolCall, state: AgentState[Any], runtime: Runtime[ContextT]
156
+ ) -> str:
157
+ """Generate a description for a tool call."""
158
+ ...
159
+
160
+
161
+ class InterruptOnConfig(TypedDict):
162
+ """Configuration for an action requiring human in the loop.
163
+
164
+ This is the configuration format used in the `HumanInTheLoopMiddleware.__init__`
165
+ method.
166
+ """
167
+
168
+ allowed_decisions: list[DecisionType]
169
+ """The decisions that are allowed for this action."""
170
+
171
+ description: NotRequired[str | _DescriptionFactory]
172
+ """The description attached to the request for human input.
173
+
174
+ Can be either:
175
+
176
+ - A static string describing the approval request
177
+ - A callable that dynamically generates the description based on agent state,
178
+ runtime, and tool call information
179
+
180
+ Example:
181
+ ```python
182
+ # Static string description
183
+ config = InterruptOnConfig(
184
+ allowed_decisions=["approve", "reject"],
185
+ description="Please review this tool execution"
186
+ )
187
+
188
+ # Dynamic callable description
189
+ def format_tool_description(
190
+ tool_call: ToolCall,
191
+ state: AgentState,
192
+ runtime: Runtime[ContextT]
193
+ ) -> str:
194
+ import json
195
+ return (
196
+ f"Tool: {tool_call['name']}\\n"
197
+ f"Arguments:\\n{json.dumps(tool_call['args'], indent=2)}"
198
+ )
199
+
200
+ config = InterruptOnConfig(
201
+ allowed_decisions=["approve", "edit", "reject"],
202
+ description=format_tool_description
203
+ )
204
+ ```
205
+ """
206
+ args_schema: NotRequired[dict[str, Any]]
207
+ """JSON schema for the args associated with the action, if edits are allowed."""
208
+
209
+ when: NotRequired[Callable[[ToolCallRequest], bool]]
210
+ """Optional predicate controlling whether to interrupt for a given tool call.
211
+
212
+ Receives a `ToolCallRequest` and returns `True` to interrupt or `False` to
213
+ auto-approve. The predicate is called during `after_model` before the tool
214
+ call is added to the batched human-in-the-loop request.
215
+
216
+ The request is constructed with `tool=None` and a new `ToolRuntime`. The
217
+ `ToolRuntime` copies `context`, `store`, `stream_writer`, `execution_info`,
218
+ and `server_info` from the node-level `Runtime`, while `tool_call_id` is
219
+ populated from the current tool call. The `tools` argument is not supplied,
220
+ so it uses its default empty list.
221
+
222
+ Example:
223
+ ```python
224
+ # Only interrupt delete_file calls targeting /etc
225
+ config = InterruptOnConfig(
226
+ allowed_decisions=["approve", "reject"],
227
+ when=lambda req: req.tool_call["args"].get("path", "").startswith("/etc"),
228
+ )
229
+ ```
230
+ """
231
+
232
+
233
+ class _HumanInTheLoopState(AgentState[ResponseT]):
234
+ """State schema for `HumanInTheLoopMiddleware`."""
235
+
236
+ hitl_edited_tool_calls: NotRequired[Annotated[dict[str, Action], PrivateStateAttr]]
237
+ """Track tool call edits from `after_model`, so they can be used by `wrap_tool_call`."""
238
+
239
+
240
+ class HumanInTheLoopMiddleware(AgentMiddleware[StateT, ContextT, ResponseT]):
241
+ """Human in the loop middleware."""
242
+
243
+ state_schema = _HumanInTheLoopState # type: ignore[assignment]
244
+
245
+ def __init__(
246
+ self,
247
+ interrupt_on: dict[str, bool | InterruptOnConfig],
248
+ *,
249
+ description_prefix: str = "Tool execution requires approval",
250
+ edit_notice: str | None = _EDIT_NOTICE,
251
+ ) -> None:
252
+ """Initialize the human in the loop middleware.
253
+
254
+ Args:
255
+ interrupt_on: Mapping of tool name to allowed actions.
256
+
257
+ If a tool doesn't have an entry, it's auto-approved by default.
258
+
259
+ * `True` indicates all decisions are allowed: approve, edit, reject,
260
+ and respond.
261
+ * `False` indicates that the tool is auto-approved.
262
+ * `InterruptOnConfig` indicates the specific decisions allowed for this
263
+ tool.
264
+
265
+ The `InterruptOnConfig` can include a `description` field (`str` or
266
+ `Callable`) for custom formatting of the interrupt description.
267
+
268
+ A `when` predicate can also be provided to dynamically control
269
+ whether a tool call triggers an interrupt.
270
+ description_prefix: The prefix to use when constructing action requests.
271
+
272
+ This is used to provide context about the tool call and the action being
273
+ requested.
274
+
275
+ Not used if a tool has a `description` in its `InterruptOnConfig`.
276
+ edit_notice: Text prepended to the result of a tool call a reviewer replaced
277
+ via an `edit` decision. Pass `None` to add nothing.
278
+
279
+ Raises:
280
+ ValueError: If a tool's `InterruptOnConfig` does not have a non-empty
281
+ `allowed_decisions` list (e.g. a misspelled key or an empty list).
282
+ An interrupt config without decisions would otherwise be silently
283
+ dropped, disabling the approval gate for that tool.
284
+ """
285
+ super().__init__()
286
+ self.edit_notice = edit_notice
287
+ resolved_configs: dict[str, InterruptOnConfig] = {}
288
+ for tool_name, tool_config in interrupt_on.items():
289
+ if isinstance(tool_config, bool):
290
+ if tool_config is True:
291
+ resolved_configs[tool_name] = InterruptOnConfig(
292
+ allowed_decisions=["approve", "edit", "reject", "respond"]
293
+ )
294
+ elif tool_config.get("allowed_decisions"):
295
+ resolved_configs[tool_name] = tool_config
296
+ else:
297
+ msg = (
298
+ f"Invalid `interrupt_on` config for tool '{tool_name}': "
299
+ "`allowed_decisions` must be a non-empty list of decision types "
300
+ "(e.g. ['approve', 'reject']). Got config with keys "
301
+ f"{sorted(tool_config.keys())} and "
302
+ f"allowed_decisions={tool_config.get('allowed_decisions')!r}."
303
+ )
304
+ raise ValueError(msg)
305
+ self.interrupt_on = resolved_configs
306
+ self.description_prefix = description_prefix
307
+
308
+ def _create_action_and_config(
309
+ self,
310
+ tool_call: ToolCall,
311
+ config: InterruptOnConfig,
312
+ state: AgentState[Any],
313
+ runtime: Runtime[ContextT],
314
+ ) -> tuple[ActionRequest, ReviewConfig]:
315
+ """Create an ActionRequest and ReviewConfig for a tool call."""
316
+ tool_name = tool_call["name"]
317
+ tool_args = tool_call["args"]
318
+
319
+ # Generate description using the description field (str or callable)
320
+ description_value = config.get("description")
321
+ if callable(description_value):
322
+ description = description_value(tool_call, state, runtime)
323
+ elif description_value is not None:
324
+ description = description_value
325
+ else:
326
+ description = f"{self.description_prefix}\n\nTool: {tool_name}\nArgs: {tool_args}"
327
+
328
+ # Create ActionRequest with description
329
+ action_request = ActionRequest(
330
+ name=tool_name,
331
+ args=tool_args,
332
+ description=description,
333
+ )
334
+
335
+ # Create ReviewConfig
336
+ # eventually can get tool information and populate args_schema from there
337
+ review_config = ReviewConfig(
338
+ action_name=tool_name,
339
+ allowed_decisions=config["allowed_decisions"],
340
+ )
341
+
342
+ return action_request, review_config
343
+
344
+ @staticmethod
345
+ def _process_decision(
346
+ decision: Decision,
347
+ tool_call: ToolCall,
348
+ config: InterruptOnConfig,
349
+ ) -> tuple[ToolCall | None, ToolMessage | None]:
350
+ """Process a single decision and return the revised tool call and optional tool message."""
351
+ allowed_decisions = config["allowed_decisions"]
352
+
353
+ if decision["type"] == "approve" and "approve" in allowed_decisions:
354
+ return tool_call, None
355
+ if decision["type"] == "edit" and "edit" in allowed_decisions:
356
+ # Keep the model's own call in the message; `wrap_tool_call` substitutes the
357
+ # reviewer's at execution time.
358
+ return tool_call, None
359
+ if decision["type"] == "reject" and "reject" in allowed_decisions:
360
+ reason = decision.get("message")
361
+ content = (
362
+ f"User rejected the tool call for `{tool_call['name']}` with reason: {reason}"
363
+ if reason
364
+ else (
365
+ f"User rejected the tool call for `{tool_call['name']}` with id "
366
+ f"{tool_call['id']}. The tool was not executed. Do not retry this tool "
367
+ "call unless the user explicitly requests it."
368
+ )
369
+ )
370
+ tool_message = ToolMessage(
371
+ content=content,
372
+ name=tool_call["name"],
373
+ tool_call_id=tool_call["id"],
374
+ status="error",
375
+ )
376
+ return tool_call, tool_message
377
+ if decision["type"] == "respond" and "respond" in allowed_decisions:
378
+ # Skip tool execution; the human answers on behalf of the tool.
379
+ tool_message = ToolMessage(
380
+ content=decision["message"],
381
+ name=tool_call["name"],
382
+ tool_call_id=tool_call["id"],
383
+ status="success",
384
+ )
385
+ return tool_call, tool_message
386
+ msg = (
387
+ f"Unexpected human decision: {decision}. "
388
+ f"Decision type '{decision.get('type')}' "
389
+ f"is not allowed for tool '{tool_call['name']}'. "
390
+ f"Expected one of {allowed_decisions} based on the tool's configuration."
391
+ )
392
+ raise ValueError(msg)
393
+
394
+ def _should_interrupt(
395
+ self,
396
+ tool_call: ToolCall,
397
+ config: InterruptOnConfig,
398
+ state: AgentState[Any],
399
+ runtime: Runtime[ContextT],
400
+ ) -> bool:
401
+ """Return False if the `when` predicate rejects this tool call, True otherwise."""
402
+ when = config.get("when")
403
+ if when is None:
404
+ return True
405
+ try:
406
+ runnable_config = get_config()
407
+ except RuntimeError:
408
+ runnable_config = {}
409
+ tool_runtime = ToolRuntime(
410
+ state=state,
411
+ context=runtime.context,
412
+ config=runnable_config,
413
+ stream_writer=runtime.stream_writer,
414
+ tool_call_id=tool_call["id"],
415
+ store=runtime.store,
416
+ execution_info=runtime.execution_info,
417
+ server_info=runtime.server_info,
418
+ )
419
+ req = ToolCallRequest(
420
+ tool_call=tool_call,
421
+ tool=None,
422
+ state=state,
423
+ runtime=tool_runtime, # type: ignore[arg-type]
424
+ )
425
+ return when(req)
426
+
427
+ def after_model(
428
+ self, state: AgentState[Any], runtime: Runtime[ContextT]
429
+ ) -> dict[str, Any] | None:
430
+ """Trigger interrupt flows for relevant tool calls after an `AIMessage`.
431
+
432
+ Args:
433
+ state: The current agent state.
434
+ runtime: The runtime context.
435
+
436
+ Returns:
437
+ Updated message with the revised tool calls.
438
+
439
+ Raises:
440
+ ValueError: If the number of human decisions does not match the number of
441
+ interrupted tool calls.
442
+ """
443
+ messages = state["messages"]
444
+ if not messages:
445
+ return None
446
+
447
+ last_ai_msg = next((msg for msg in reversed(messages) if isinstance(msg, AIMessage)), None)
448
+ if not last_ai_msg or not last_ai_msg.tool_calls:
449
+ return None
450
+
451
+ # Create action requests and review configs for tools that need approval
452
+ action_requests: list[ActionRequest] = []
453
+ review_configs: list[ReviewConfig] = []
454
+ interrupt_indices: list[int] = []
455
+
456
+ for idx, tool_call in enumerate(last_ai_msg.tool_calls):
457
+ if (config := self.interrupt_on.get(tool_call["name"])) is not None:
458
+ if not self._should_interrupt(tool_call, config, state, runtime):
459
+ continue
460
+ action_request, review_config = self._create_action_and_config(
461
+ tool_call, config, state, runtime
462
+ )
463
+ action_requests.append(action_request)
464
+ review_configs.append(review_config)
465
+ interrupt_indices.append(idx)
466
+
467
+ # If no interrupts needed, return early, dropping any earlier turn's edits so
468
+ # they cannot be applied to this turn's tool calls.
469
+ if not action_requests:
470
+ if state.get(_EDITED_TOOL_CALLS_KEY):
471
+ return {_EDITED_TOOL_CALLS_KEY: {}}
472
+ return None
473
+
474
+ # Create single HITLRequest with all actions and configs
475
+ hitl_request = HITLRequest(
476
+ action_requests=action_requests,
477
+ review_configs=review_configs,
478
+ )
479
+
480
+ # Send interrupt and get response
481
+ decisions = interrupt(hitl_request)["decisions"]
482
+
483
+ # Validate that the number of decisions matches the number of interrupt tool calls
484
+ if (decisions_len := len(decisions)) != (interrupt_count := len(interrupt_indices)):
485
+ msg = (
486
+ f"Number of human decisions ({decisions_len}) does not match "
487
+ f"number of hanging tool calls ({interrupt_count})."
488
+ )
489
+ raise ValueError(msg)
490
+
491
+ # Process decisions and rebuild tool calls in original order
492
+ revised_tool_calls: list[ToolCall] = []
493
+ artificial_tool_messages: list[ToolMessage] = []
494
+ edited_tool_calls: dict[str, Action] = {}
495
+ decision_idx = 0
496
+
497
+ for idx, tool_call in enumerate(last_ai_msg.tool_calls):
498
+ if idx in interrupt_indices:
499
+ # This was an interrupt tool call - process the decision
500
+ config = self.interrupt_on[tool_call["name"]]
501
+ decision = decisions[decision_idx]
502
+ decision_idx += 1
503
+
504
+ revised_tool_call, tool_message = self._process_decision(
505
+ decision, tool_call, config
506
+ )
507
+ if revised_tool_call is not None:
508
+ revised_tool_calls.append(revised_tool_call)
509
+ if decision["type"] == "edit" and (edited_id := revised_tool_call.get("id")):
510
+ edited_tool_calls[edited_id] = decision["edited_action"]
511
+ if tool_message:
512
+ artificial_tool_messages.append(tool_message)
513
+ else:
514
+ # This was auto-approved - keep original
515
+ revised_tool_calls.append(tool_call)
516
+
517
+ # Update the AI message to only include approved tool calls
518
+ last_ai_msg.tool_calls = revised_tool_calls
519
+
520
+ # `wrap_tool_call` reads this back to substitute and annotate the call. Always
521
+ # written, so an earlier turn's edits cannot survive into this one.
522
+ return {
523
+ "messages": [last_ai_msg, *artificial_tool_messages],
524
+ _EDITED_TOOL_CALLS_KEY: edited_tool_calls,
525
+ }
526
+
527
+ async def aafter_model(
528
+ self, state: AgentState[Any], runtime: Runtime[ContextT]
529
+ ) -> dict[str, Any] | None:
530
+ """Async trigger interrupt flows for relevant tool calls after an `AIMessage`.
531
+
532
+ Args:
533
+ state: The current agent state.
534
+ runtime: The runtime context.
535
+
536
+ Returns:
537
+ Updated message with the revised tool calls.
538
+ """
539
+ return self.after_model(state, runtime)
540
+
541
+ def _reviewer_edit(self, request: ToolCallRequest) -> Action | None:
542
+ """The reviewer's replacement for this call, if an `edit` decision replaced it."""
543
+ tool_call_id = request.tool_call.get("id")
544
+ if not tool_call_id:
545
+ return None
546
+ edited = request.state.get(_EDITED_TOOL_CALLS_KEY) or {}
547
+ if tool_call_id in edited:
548
+ return cast("Action", edited[tool_call_id])
549
+ return None
550
+
551
+ def _apply_edit(self, request: ToolCallRequest, executed: Action) -> ToolCallRequest:
552
+ """Point the request at the reviewer's call, resolving a redirected tool.
553
+
554
+ Raises:
555
+ ValueError: If the reviewer named a tool the agent does not have.
556
+ """
557
+ tool_call: ToolCall = {
558
+ **request.tool_call,
559
+ "name": executed["name"],
560
+ "args": executed["args"],
561
+ }
562
+ if executed["name"] == request.tool_call["name"]:
563
+ return request.override(tool_call=tool_call)
564
+
565
+ # `tool_call["name"]` and `tool` must stay in agreement.
566
+ available = request.runtime.tools
567
+ tool = next((t for t in available if t.name == executed["name"]), None)
568
+ if tool is None:
569
+ names = ", ".join(sorted(t.name for t in available))
570
+ msg = (
571
+ f"Reviewer edited tool call {request.tool_call['id']!r} to "
572
+ f"{executed['name']!r}, which is not an available tool. "
573
+ f"Available tools: {names}."
574
+ )
575
+ raise ValueError(msg)
576
+ return request.override(tool_call=tool_call, tool=tool)
577
+
578
+ def _notice(self, executed: Action, *, has_content: bool) -> str:
579
+ """The notice text, stating the call that actually ran."""
580
+ notice = (
581
+ f"{self.edit_notice} Executed instead: {executed['name']} with arguments "
582
+ f"{json.dumps(executed['args'], default=str)}."
583
+ )
584
+ return f"{notice}\n\nTool response:" if has_content else notice
585
+
586
+ def _prepend_notice(self, message: ToolMessage, executed: Action) -> ToolMessage:
587
+ """Return `message` with the reviewer-edit notice prepended to its content."""
588
+ if not self.edit_notice:
589
+ return message
590
+ edit_notice = self._notice(executed, has_content=bool(message.content))
591
+
592
+ content: str | list[str | dict[Any, Any]]
593
+ if isinstance(message.content, str):
594
+ if edit_notice in message.content:
595
+ return message
596
+ separator = "\n" if message.content else ""
597
+ content = f"{edit_notice}{separator}{message.content}"
598
+ else:
599
+ if any(edit_notice in str(block) for block in message.content):
600
+ return message
601
+ # Match the surrounding block shape; providers may reject mixed lists.
602
+ notice: str | dict[Any, Any] = (
603
+ edit_notice
604
+ if message.content and all(isinstance(b, str) for b in message.content)
605
+ else {"type": "text", "text": edit_notice}
606
+ )
607
+ content = [notice, *message.content]
608
+
609
+ return message.model_copy(update={"content": content})
610
+
611
+ def _annotate_edited_result(
612
+ self,
613
+ result: ToolMessage | Command[Any],
614
+ request: ToolCallRequest,
615
+ executed: Action | None,
616
+ ) -> ToolMessage | Command[Any]:
617
+ """Tell the model a reviewer replaced the call, and with what."""
618
+ if not self.edit_notice or executed is None:
619
+ return result
620
+
621
+ if isinstance(result, ToolMessage):
622
+ return self._prepend_notice(result, executed)
623
+
624
+ # A `Command` carries the `ToolMessage` in its state update.
625
+ if not isinstance(result, Command) or not isinstance(result.update, dict):
626
+ return result
627
+ messages = result.update.get("messages")
628
+ if not isinstance(messages, list):
629
+ return result
630
+ tool_call_id = request.tool_call.get("id")
631
+ return replace(
632
+ result,
633
+ update={
634
+ **result.update,
635
+ "messages": [
636
+ self._prepend_notice(message, executed)
637
+ if isinstance(message, ToolMessage) and message.tool_call_id == tool_call_id
638
+ else message
639
+ for message in messages
640
+ ],
641
+ },
642
+ )
643
+
644
+ def wrap_tool_call(
645
+ self,
646
+ request: ToolCallRequest,
647
+ handler: Callable[[ToolCallRequest], ToolMessage | Command[Any]],
648
+ ) -> ToolMessage | Command[Any]:
649
+ """Prepend reviewer-edit guidance to the result of an edited tool call.
650
+
651
+ Args:
652
+ request: The tool call request being executed.
653
+ handler: Callable that executes the tool.
654
+
655
+ Returns:
656
+ The tool result, with a note prepended when a reviewer edited the call.
657
+ """
658
+ executed = self._reviewer_edit(request)
659
+ if executed is not None:
660
+ request = self._apply_edit(request, executed)
661
+ return self._annotate_edited_result(handler(request), request, executed)
662
+
663
+ async def awrap_tool_call(
664
+ self,
665
+ request: ToolCallRequest,
666
+ handler: Callable[[ToolCallRequest], Awaitable[ToolMessage | Command[Any]]],
667
+ ) -> ToolMessage | Command[Any]:
668
+ """Async variant of `wrap_tool_call`.
669
+
670
+ Args:
671
+ request: The tool call request being executed.
672
+ handler: Awaitable callable that executes the tool.
673
+
674
+ Returns:
675
+ The tool result, with a note prepended when a reviewer edited the call.
676
+ """
677
+ executed = self._reviewer_edit(request)
678
+ if executed is not None:
679
+ request = self._apply_edit(request, executed)
680
+ return self._annotate_edited_result(await handler(request), request, executed)