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,878 @@
1
+ """PII detection and handling middleware for agents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from functools import partial
6
+ from typing import TYPE_CHECKING, Any, ClassVar, Literal
7
+
8
+ from coco_core.messages import AIMessage, BaseMessage, HumanMessage, ToolMessage
9
+ from coco_graph.stream import StreamTransformer
10
+ from typing_extensions import override
11
+
12
+ from coco.agents.middleware._redaction import (
13
+ PIIDetectionError,
14
+ PIIMatch,
15
+ RedactionRule,
16
+ ResolvedRedactionRule,
17
+ apply_strategy,
18
+ detect_credit_card,
19
+ detect_email,
20
+ detect_ip,
21
+ detect_mac_address,
22
+ detect_url,
23
+ )
24
+ from coco.agents.middleware.types import (
25
+ AgentMiddleware,
26
+ AgentState,
27
+ ContextT,
28
+ ResponseT,
29
+ hook_config,
30
+ )
31
+
32
+ if TYPE_CHECKING:
33
+ from collections.abc import Callable
34
+
35
+ from coco_graph.runtime import Runtime
36
+ from coco_graph.stream._types import ProtocolEvent
37
+
38
+
39
+ _DEFAULT_STREAM_LOOKBACK = 128
40
+ """Default trailing-buffer size for cross-delta PII detection.
41
+
42
+ The transformer always holds the last `lookback` characters in a per-content
43
+ block buffer so that PII patterns straddling delta boundaries are detected
44
+ before any text is released downstream. 128 comfortably covers the built-in
45
+ detectors (the credit-card regex tops out at 19 characters; URLs and emails
46
+ are typically well under 100) while bounding first-token latency.
47
+ """
48
+
49
+
50
+ class _PIIStreamTransformer(StreamTransformer):
51
+ """Mutates `content-block-delta` text on `messages` events in flight.
52
+
53
+ Runs before built-in stream transformers so the redacted text is what
54
+ every downstream consumer sees — both the main protocol event log and
55
+ the `run.messages` projection that `MessagesTransformer` snapshots into.
56
+
57
+ Holds a sliding buffer of the most recent text per (run_id, content
58
+ block index) so PII patterns that straddle delta boundaries are caught.
59
+ Anything older than `lookback` characters is redacted with the resolved
60
+ rule's strategy and emitted as the new delta text; the trailing tail
61
+ stays in the buffer until a later delta extends it past the cap or the
62
+ block's finish event flushes the snapshot.
63
+ """
64
+
65
+ before_builtins: ClassVar[bool] = True
66
+ required_stream_modes: ClassVar[tuple[str, ...]] = ("messages", "tools", "values")
67
+
68
+ def __init__(
69
+ self,
70
+ scope: tuple[str, ...] = (),
71
+ *,
72
+ rule: ResolvedRedactionRule,
73
+ lookback: int = _DEFAULT_STREAM_LOOKBACK,
74
+ ) -> None:
75
+ super().__init__(scope)
76
+ self._rule = rule
77
+ self._lookback = lookback
78
+ # Text/reasoning deltas keyed by `(run_id, content_block_index)`.
79
+ self._buffers: dict[tuple[str, int], str] = {}
80
+ # Tool-output-delta buffers keyed by `tool_call_id`. Held in a
81
+ # separate dict so `_drop_run` on the messages channel can't
82
+ # sweep active tool-output state.
83
+ self._tool_buffers: dict[str, str] = {}
84
+
85
+ def init(self) -> dict[str, Any]:
86
+ # No projection — this transformer mutates events in place rather
87
+ # than building a derived view.
88
+ return {}
89
+
90
+ def process(self, event: ProtocolEvent) -> bool:
91
+ method = event["method"]
92
+ if method == "messages":
93
+ return self._process_messages_event(event)
94
+ if method == "tools":
95
+ return self._process_tools_event(event)
96
+ if method == "values":
97
+ return self._process_values_event(event)
98
+ return True
99
+
100
+ def _process_values_event(self, event: ProtocolEvent) -> bool:
101
+ """Redact the state snapshot on the `values` channel.
102
+
103
+ State snapshots emitted between nodes carry the full state dict,
104
+ which typically includes the messages list. Walking the snapshot
105
+ with `_redact_value` returns a fresh structure where every
106
+ message has a redacted copy of its content — the original
107
+ objects in graph state remain intact for the state-level
108
+ enforcer (`apply_to_tool_results` via `before_model`) to act on
109
+ independently when the agent loops back.
110
+ """
111
+ data = event["params"].get("data")
112
+ if data is None:
113
+ return True
114
+ event["params"]["data"] = self._redact_value(data)
115
+ return True
116
+
117
+ def _process_messages_event(self, event: ProtocolEvent) -> bool:
118
+ params = event["params"]
119
+ data = params.get("data")
120
+ if not isinstance(data, tuple) or len(data) != 2: # noqa: PLR2004
121
+ return True
122
+ payload, metadata = data
123
+
124
+ # Legacy `(BaseMessage, metadata)` shape: the coco_graph→coco
125
+ # integration emits this when a model only implements `_generate`
126
+ # (or when its `_astream` falls back), producing a single event
127
+ # carrying the full message rather than streamed content-block
128
+ # deltas. Swap in a redacted copy so the consumer sees scrubbed
129
+ # text on the wire while the original stays intact in graph state
130
+ # for `after_model` to act on independently. Under `block`,
131
+ # `_redact_base_message` raises `PIIDetectionError` via
132
+ # `apply_strategy` before we get here.
133
+ if isinstance(payload, BaseMessage):
134
+ redacted = self._redact_base_message(payload)
135
+ if redacted is not payload:
136
+ params["data"] = (redacted, metadata)
137
+ return True
138
+
139
+ if not isinstance(payload, dict):
140
+ return True
141
+ kind = payload.get("event")
142
+ run_id = str(metadata.get("run_id") or "") if metadata else ""
143
+
144
+ if kind == "content-block-delta":
145
+ self._mutate_delta(payload, run_id)
146
+ elif kind == "content-block-finish":
147
+ self._finalize_block(payload, run_id)
148
+ elif kind in {"message-finish", "error"}:
149
+ self._drop_run(run_id)
150
+ return True
151
+
152
+ def _process_tools_event(self, event: ProtocolEvent) -> bool:
153
+ data = event["params"].get("data")
154
+ if not isinstance(data, dict):
155
+ return True
156
+ kind = data.get("event")
157
+ tool_call_id = data.get("tool_call_id")
158
+
159
+ if kind == "tool-started":
160
+ # Tool inputs may be a dict (multi-arg tools), a string
161
+ # (single-arg tools — `BaseTool._parse_input` passes the
162
+ # raw string through), or a list (array-input tools).
163
+ # `_redact_value` handles all three uniformly.
164
+ if "input" in data:
165
+ data["input"] = self._redact_value(data["input"])
166
+ elif kind == "tool-output-delta":
167
+ # Use the tool_call_id as buffer key when present; fall back
168
+ # to a None-keyed slot for the rare malformed/custom emitter
169
+ # case (the buffer becomes shared but at least redaction runs).
170
+ self._mutate_tool_output_delta(
171
+ data, tool_call_id if isinstance(tool_call_id, str) else ""
172
+ )
173
+ elif kind == "tool-finished":
174
+ if "output" in data:
175
+ data["output"] = self._redact_value(data["output"])
176
+ if isinstance(tool_call_id, str):
177
+ self._tool_buffers.pop(tool_call_id, None)
178
+ elif kind == "tool-error":
179
+ msg = data.get("message")
180
+ if isinstance(msg, str) and msg:
181
+ matches = self._rule.detector(msg)
182
+ if matches:
183
+ data["message"] = apply_strategy(msg, matches, self._rule.strategy)
184
+ if isinstance(tool_call_id, str):
185
+ self._tool_buffers.pop(tool_call_id, None)
186
+
187
+ return True
188
+
189
+ def _mutate_tool_output_delta(self, data: dict[str, Any], tool_call_id: str) -> None:
190
+ """Redact a `tool-output-delta` payload.
191
+
192
+ String deltas go through the same lookback machinery as
193
+ text-deltas, keyed by `tool_call_id` in the disjoint
194
+ `_tool_buffers` dict so `_drop_run` on the messages channel
195
+ can't sweep active tool-output state.
196
+
197
+ Structured deltas (dict/list) walk recursively without
198
+ buffering — they don't have a position-stable shape across
199
+ deltas to buffer against.
200
+ """
201
+ delta = data.get("delta")
202
+ if isinstance(delta, str):
203
+ held = self._tool_buffers.get(tool_call_id, "")
204
+ combined = held + delta
205
+
206
+ matches = self._rule.detector(combined)
207
+ if matches:
208
+ # `apply_strategy` raises `PIIDetectionError` under
209
+ # `strategy="block"`, failing the run immediately —
210
+ # cleaner than withholding deltas until `after_model`
211
+ # raises later.
212
+ combined = apply_strategy(combined, matches, self._rule.strategy)
213
+
214
+ emit_end = max(0, len(combined) - self._lookback)
215
+ self._tool_buffers[tool_call_id] = combined[emit_end:]
216
+ data["delta"] = combined[:emit_end]
217
+ elif isinstance(delta, (dict, list)):
218
+ data["delta"] = self._redact_value(delta)
219
+
220
+ def _redact_tool_call_list(self, calls: list[Any] | None) -> tuple[list[Any], bool]:
221
+ """Walk a list of tool-call (or invalid-tool-call) dicts.
222
+
223
+ Returns `(new_list, changed)`. Each element's `args` is run
224
+ through `_redact_value` regardless of its type — `tool_call.args`
225
+ is a dict, `invalid_tool_call.args` is a raw JSON string, and
226
+ `_redact_value` handles both shapes uniformly. If nothing
227
+ changed, returns the input list and `changed=False`.
228
+ """
229
+ if not calls:
230
+ return calls or [], False
231
+ new_calls: list[Any] = []
232
+ changed = False
233
+ for tc in calls:
234
+ if isinstance(tc, dict) and "args" in tc and tc["args"] is not None:
235
+ redacted = self._redact_value(tc["args"])
236
+ if redacted != tc["args"]:
237
+ new_tc = dict(tc)
238
+ new_tc["args"] = redacted
239
+ new_calls.append(new_tc)
240
+ changed = True
241
+ continue
242
+ new_calls.append(tc)
243
+ return new_calls, changed
244
+
245
+ def _redact_value(self, value: Any) -> Any:
246
+ """Recursively redact PII in string leaves of a nested structure.
247
+
248
+ Returns a new value where every `str` leaf that contains PII has
249
+ been replaced (or emptied under `block`). Non-string leaves and
250
+ the structure itself are preserved.
251
+
252
+ `BaseMessage` payloads (typically `ToolMessage` from
253
+ `tool-finished.output`, or any message reached via the `values`
254
+ channel) return a fresh copy with `.content` redacted plus
255
+ `AIMessage.tool_calls[*].args` / `invalid_tool_calls[*].args`
256
+ walked. The original object stays intact for state-level
257
+ enforcers (`after_model`, `before_model` with
258
+ `apply_to_tool_results`) to act on independently.
259
+
260
+ Scope mirrors the pre-streaming state-level surfaces:
261
+ `.content` (string or list-of-content-blocks) and `tool_calls`
262
+ args. Other message attributes (`additional_kwargs`,
263
+ `response_metadata`, `ToolMessage.artifact`) are intentionally
264
+ not walked here — they aren't scrubbed in graph state by the
265
+ existing hooks, so scrubbing them on the wire would create
266
+ a wire/state divergence.
267
+ """
268
+ if isinstance(value, str):
269
+ if not value:
270
+ return value
271
+ matches = self._rule.detector(value)
272
+ if not matches:
273
+ return value
274
+ # `apply_strategy` raises `PIIDetectionError` under `block`
275
+ # — the run fails immediately rather than buffering until a
276
+ # state-level hook can raise.
277
+ return apply_strategy(value, matches, self._rule.strategy)
278
+ if isinstance(value, BaseMessage):
279
+ return self._redact_base_message(value)
280
+ if isinstance(value, dict):
281
+ return {k: self._redact_value(v) for k, v in value.items()}
282
+ if isinstance(value, list):
283
+ return [self._redact_value(v) for v in value]
284
+ if isinstance(value, tuple):
285
+ return tuple(self._redact_value(v) for v in value)
286
+ return value
287
+
288
+ def _redact_base_message(self, value: BaseMessage) -> BaseMessage:
289
+ """Return a fresh copy of `value` with PII-carrying surfaces redacted."""
290
+ update: dict[str, Any] = {}
291
+
292
+ content = value.content
293
+ if isinstance(content, str) and content:
294
+ matches = self._rule.detector(content)
295
+ if matches:
296
+ update["content"] = apply_strategy(content, matches, self._rule.strategy)
297
+ elif isinstance(content, list) and content:
298
+ # Structured content-blocks shape:
299
+ # `[{"type": "text", "text": "..."}, {"type": "tool_call", ...}, ...]`.
300
+ redacted_content = self._redact_value(content)
301
+ if redacted_content != content:
302
+ update["content"] = redacted_content
303
+
304
+ # `AIMessage.tool_calls` and `.invalid_tool_calls` carry PII in
305
+ # `args` independently of `.content`. `tool_call.args` is a
306
+ # dict; `invalid_tool_call.args` is a raw JSON string —
307
+ # `_redact_value` handles both shapes via the recursion.
308
+ if isinstance(value, AIMessage):
309
+ new_tc_list, tc_changed = self._redact_tool_call_list(value.tool_calls)
310
+ if tc_changed:
311
+ update["tool_calls"] = new_tc_list
312
+ new_inv_list, inv_changed = self._redact_tool_call_list(value.invalid_tool_calls)
313
+ if inv_changed:
314
+ update["invalid_tool_calls"] = new_inv_list
315
+
316
+ if not update:
317
+ return value
318
+ return value.model_copy(update=update)
319
+
320
+ def _mutate_delta(self, payload: dict[str, Any], run_id: str) -> None:
321
+ delta = payload.get("delta")
322
+ if not isinstance(delta, dict):
323
+ return
324
+ delta_type = delta.get("type")
325
+ if delta_type == "text-delta":
326
+ self._mutate_string_field_delta(delta, payload, run_id, "text")
327
+ return
328
+ if delta_type == "reasoning-delta":
329
+ # Reasoning content (chain-of-thought from extended-thinking
330
+ # models) is a real PII surface — models echo back
331
+ # user-supplied data or synthesize it from context. Run the
332
+ # same lookback machinery as text-delta against the
333
+ # `reasoning` field. Block indices are unique within a
334
+ # message regardless of block type, so the buffer key
335
+ # `(run_id, index)` naturally disjoint from text-delta keys.
336
+ self._mutate_string_field_delta(delta, payload, run_id, "reasoning")
337
+ return
338
+ if delta_type == "block-delta":
339
+ fields = delta.get("fields")
340
+ if isinstance(fields, dict) and fields.get("type") in {
341
+ "tool_call_chunk",
342
+ "server_tool_call_chunk",
343
+ }:
344
+ self._mutate_tool_call_chunk_delta(fields)
345
+ # Other delta types (`data-delta`, vendor block types) pass
346
+ # through. The pre-streaming middleware scrubbed `.content` text
347
+ # on state messages only; binary payloads and provider-specific
348
+ # block shapes are out of scope for parity with that surface.
349
+
350
+ def _mutate_string_field_delta(
351
+ self,
352
+ delta: dict[str, Any],
353
+ payload: dict[str, Any],
354
+ run_id: str,
355
+ field: str,
356
+ ) -> None:
357
+ """Apply the lookback-buffer redaction to a string field on a delta.
358
+
359
+ Shared by `text-delta` (`field="text"`) and `reasoning-delta`
360
+ (`field="reasoning"`). Buffer is keyed by `(run_id, block_index)`;
361
+ block indices are unique within a message so different block
362
+ types share the same key space without collision.
363
+ """
364
+ text = delta.get(field)
365
+ if not isinstance(text, str) or not text:
366
+ return
367
+ index = payload.get("index")
368
+ if not isinstance(index, int):
369
+ return
370
+
371
+ key = (run_id, index)
372
+ held = self._buffers.get(key, "")
373
+ combined = held + text
374
+
375
+ # Run detection on the full accumulated buffer before splitting.
376
+ # Detecting only on the about-to-emit prefix would miss matches
377
+ # that straddle the lookback boundary — the detector's regex
378
+ # needs a complete, boundary-anchored hit, so a truncated prefix
379
+ # would fail to match and the partial PII would leak on the
380
+ # wire. Under `strategy="block"`, `apply_strategy` raises
381
+ # `PIIDetectionError` here, failing the run as soon as PII
382
+ # arrives rather than buffering until `after_model`.
383
+ matches = self._rule.detector(combined)
384
+ if matches:
385
+ combined = apply_strategy(combined, matches, self._rule.strategy)
386
+
387
+ emit_end = max(0, len(combined) - self._lookback)
388
+ self._buffers[key] = combined[emit_end:]
389
+ delta[field] = combined[:emit_end]
390
+
391
+ def _mutate_tool_call_chunk_delta(self, fields: dict[str, Any]) -> None:
392
+ """Redact cumulative tool-call args with lookback withholding.
393
+
394
+ Each `tool_call_chunk` `block-delta` event carries the full
395
+ accumulated args string (verified against `_compat_bridge.py`
396
+ — `delta_source = current` for these block types — and against
397
+ the consumer-side `_merge_block_delta_into_store`, which
398
+ replaces wholesale rather than appends).
399
+
400
+ Detection runs on the full cumulative args so any complete PII
401
+ anywhere in the string is redacted before emission. Lookback
402
+ withholding then trims the trailing the lookback window characters
403
+ from what reaches the consumer — those characters might be the
404
+ start of a partial PII match that completes in a future
405
+ cumulative delta. The trimmed tail surfaces at `content-block-
406
+ finish` where `_finalize_block` redacts the parsed args dict.
407
+
408
+ For args that fit within the lookback window (the typical case),
409
+ this withholds the entire args string during streaming — the
410
+ redacted args dict appears only at finalize. For args that
411
+ exceed the lookback window, the safe prefix streams incrementally
412
+ as the cumulative state grows. PII that appears more than
413
+ the lookback window characters from the cumulative tail in a
414
+ delta where it hasn't yet completed can still surface in the
415
+ emit prefix — same residual exposure as PII longer than
416
+ the lookback window on the text path. The `content-block-finish`
417
+ snapshot redaction is the backstop.
418
+ """
419
+ args = fields.get("args")
420
+ if not isinstance(args, str) or not args:
421
+ return
422
+
423
+ matches = self._rule.detector(args)
424
+ if matches:
425
+ # `apply_strategy` raises `PIIDetectionError` under
426
+ # `strategy="block"` — the run fails the moment a complete
427
+ # PII pattern surfaces in the cumulative args string.
428
+ args = apply_strategy(args, matches, self._rule.strategy)
429
+
430
+ emit_end = max(0, len(args) - self._lookback)
431
+ fields["args"] = args[:emit_end]
432
+
433
+ def _finalize_block(self, payload: dict[str, Any], run_id: str) -> None:
434
+ index = payload.get("index")
435
+ if not isinstance(index, int):
436
+ return
437
+ key = (run_id, index)
438
+ # The finalized block carries the model's original concatenation
439
+ # of deltas, not what we emitted on the wire. Re-run detection over
440
+ # its full text so the snapshot matches the redacted stream.
441
+ content = payload.get("content")
442
+ if isinstance(content, dict):
443
+ ctype = content.get("type")
444
+ if ctype == "text":
445
+ self._finalize_string_field(content, "text")
446
+ elif ctype == "reasoning":
447
+ self._finalize_string_field(content, "reasoning")
448
+ elif (
449
+ ctype in {"tool_call", "server_tool_call", "invalid_tool_call"}
450
+ and "args" in content
451
+ and content["args"] is not None
452
+ ):
453
+ # `tool_call` / `server_tool_call` args are dicts;
454
+ # `invalid_tool_call.args` is the raw unparsed JSON
455
+ # string. `_redact_value` handles both shapes.
456
+ content["args"] = self._redact_value(content["args"])
457
+ self._buffers.pop(key, None)
458
+
459
+ def _finalize_string_field(self, content: dict[str, Any], field: str) -> None:
460
+ """Re-redact a string content-block field on `content-block-finish`.
461
+
462
+ Used for `text` and `reasoning` content blocks. Under
463
+ `strategy="block"` `apply_strategy` raises `PIIDetectionError`,
464
+ failing the run immediately.
465
+ """
466
+ text = content.get(field)
467
+ if not isinstance(text, str) or not text:
468
+ return
469
+ matches = self._rule.detector(text)
470
+ if not matches:
471
+ return
472
+ content[field] = apply_strategy(text, matches, self._rule.strategy)
473
+
474
+ def _drop_run(self, run_id: str) -> None:
475
+ # Release any buffered tails for this run_id — content-block-finish
476
+ # should have already done so for normal completion, but message-finish
477
+ # / error paths need an explicit sweep so abandoned blocks don't
478
+ # accumulate in long-lived processes.
479
+ stale = [key for key in self._buffers if key[0] == run_id]
480
+ for key in stale:
481
+ del self._buffers[key]
482
+
483
+ def finalize(self) -> None:
484
+ self._buffers.clear()
485
+ self._tool_buffers.clear()
486
+
487
+ def fail(self, err: BaseException) -> None: # noqa: ARG002
488
+ self._buffers.clear()
489
+ self._tool_buffers.clear()
490
+
491
+
492
+ class PIIMiddleware(AgentMiddleware[AgentState[ResponseT], ContextT, ResponseT]):
493
+ """Detect and handle Personally Identifiable Information (PII) in conversations.
494
+
495
+ This middleware detects common PII types and applies configurable strategies
496
+ to handle them. It can detect emails, credit cards, IP addresses, MAC addresses, and
497
+ URLs in both user input and agent output.
498
+
499
+ Built-in PII types:
500
+
501
+ - `email`: Email addresses
502
+ - `credit_card`: Credit card numbers (validated with Luhn algorithm)
503
+ - `ip`: IP addresses (validated with stdlib)
504
+ - `mac_address`: MAC addresses
505
+ - `url`: URLs (both `http`/`https` and bare URLs)
506
+
507
+ Strategies:
508
+
509
+ - `block`: Raise an exception when PII is detected
510
+ - `redact`: Replace PII with `[REDACTED_TYPE]` placeholders
511
+ - `mask`: Partially mask PII (e.g., `****-****-****-1234` for credit card)
512
+ - `hash`: Replace PII with deterministic hash (e.g., `<email_hash:a1b2c3d4>`)
513
+
514
+ Strategy Selection Guide:
515
+
516
+ | Strategy | Preserves Identity? | Best For |
517
+ | -------- | ------------------- | --------------------------------------- |
518
+ | `block` | N/A | Avoid PII completely |
519
+ | `redact` | No | General compliance, log sanitization |
520
+ | `mask` | No | Human readability, customer service UIs |
521
+ | `hash` | Yes (pseudonymous) | Analytics, debugging |
522
+
523
+ Example:
524
+ ```python
525
+ from coco.agents.middleware import PIIMiddleware
526
+ from coco.agents import create_agent
527
+
528
+ # Redact all emails in user input
529
+ agent = create_agent(
530
+ "openai:gpt-5.5",
531
+ middleware=[
532
+ PIIMiddleware("email", strategy="redact"),
533
+ ],
534
+ )
535
+
536
+ # Use different strategies for different PII types
537
+ agent = create_agent(
538
+ "openai:gpt-5.5",
539
+ middleware=[
540
+ PIIMiddleware("credit_card", strategy="mask"),
541
+ PIIMiddleware("url", strategy="redact"),
542
+ PIIMiddleware("ip", strategy="hash"),
543
+ ],
544
+ )
545
+
546
+ # Custom PII type with regex
547
+ agent = create_agent(
548
+ "openai:gpt-5.5",
549
+ middleware=[
550
+ PIIMiddleware("api_key", detector=r"sk-[a-zA-Z0-9]{32}", strategy="block"),
551
+ ],
552
+ )
553
+ ```
554
+ """
555
+
556
+ def __init__(
557
+ self,
558
+ # From a typing point of view, the literals are covered by 'str'.
559
+ # Nonetheless, we escape PYI051 to keep hints and autocompletion for the caller.
560
+ pii_type: Literal["email", "credit_card", "ip", "mac_address", "url"] | str, # noqa: PYI051
561
+ *,
562
+ strategy: Literal["block", "redact", "mask", "hash"] = "redact",
563
+ detector: Callable[[str], list[PIIMatch]] | str | None = None,
564
+ apply_to_input: bool = True,
565
+ apply_to_output: bool = False,
566
+ apply_to_tool_results: bool = False,
567
+ ) -> None:
568
+ """Initialize the PII detection middleware.
569
+
570
+ Args:
571
+ pii_type: Type of PII to detect.
572
+
573
+ Can be a built-in type (`email`, `credit_card`, `ip`, `mac_address`,
574
+ `url`) or a custom type name.
575
+ strategy: How to handle detected PII.
576
+
577
+ Options:
578
+
579
+ * `block`: Raise `PIIDetectionError` when PII is detected
580
+ * `redact`: Replace with `[REDACTED_TYPE]` placeholders
581
+ * `mask`: Partially mask PII (show last few characters)
582
+ * `hash`: Replace with deterministic hash (format: `<type_hash:digest>`)
583
+
584
+ detector: Custom detector function or regex pattern.
585
+
586
+ * If `Callable`: Function that takes content string and returns
587
+ list of `PIIMatch` objects
588
+ * If `str`: Regex pattern to match PII
589
+ * If `None`: Uses built-in detector for the `pii_type`
590
+ apply_to_input: Whether to check user messages before model call.
591
+ apply_to_output: Whether to check AI messages after model call.
592
+
593
+ When `True`, a stream transformer is also installed so
594
+ that every wire surface of an agent run is redacted in
595
+ flight:
596
+
597
+ * Streamed AI text deltas (`content-block-delta` of type
598
+ `text-delta`)
599
+ * Streamed tool-call arguments (`content-block-delta`
600
+ with `tool_call_chunk` / `server_tool_call_chunk`
601
+ fields, plus the finalized `tool_call` content block
602
+ on `content-block-finish`)
603
+ * Tool execution events on the `tools` channel
604
+ (`tool-started.input`, `tool-output-delta`,
605
+ `tool-finished.output`, `tool-error.message`)
606
+ * State snapshots on the `values` channel — message
607
+ lists are walked and each message's `.content` is
608
+ redacted on a fresh copy (state itself stays intact
609
+ for `before_model` / `after_model` to act on
610
+ independently)
611
+
612
+ State-level redaction via `after_model` (and
613
+ `before_model` with `apply_to_tool_results`) remains the
614
+ canonical enforcer; the streaming transformer ensures
615
+ consumers reading `astream_events(version="v3")` or
616
+ `run.messages` / `run.tool_calls` / `run.values` never
617
+ see PII on the wire.
618
+ apply_to_tool_results: Whether to check tool result messages after tool execution.
619
+
620
+ Raises:
621
+ ValueError: If `pii_type` is not built-in and no detector is provided.
622
+ """
623
+ super().__init__()
624
+
625
+ self.apply_to_input = apply_to_input
626
+ self.apply_to_output = apply_to_output
627
+ self.apply_to_tool_results = apply_to_tool_results
628
+
629
+ self._resolved_rule: ResolvedRedactionRule = RedactionRule(
630
+ pii_type=pii_type,
631
+ strategy=strategy,
632
+ detector=detector,
633
+ ).resolve()
634
+ self.pii_type = self._resolved_rule.pii_type
635
+ self.strategy = self._resolved_rule.strategy
636
+ self.detector = self._resolved_rule.detector
637
+
638
+ # Stream transformer scrubs the streamed surface of the same
639
+ # messages that the state-level hooks scrub in graph state.
640
+ # Installed whenever any output-side scrubbing is enabled —
641
+ # `apply_to_output` covers AI messages (text, tool-call args,
642
+ # reasoning), `apply_to_tool_results` covers tool execution
643
+ # (the `tools` channel + ToolMessage content on `values` and
644
+ # `messages`). For `block` the transformer raises
645
+ # `PIIDetectionError` directly from its event handler the
646
+ # moment a complete PII pattern is detected, failing the run
647
+ # via coco_graph's `StreamMux.afail` path. The state-level
648
+ # `after_model` / `before_model` hooks remain a backstop for
649
+ # non-streaming consumers.
650
+ if self.apply_to_output or self.apply_to_tool_results:
651
+ self.transformers = (
652
+ partial(
653
+ _PIIStreamTransformer,
654
+ rule=self._resolved_rule,
655
+ ),
656
+ )
657
+
658
+ @property
659
+ def name(self) -> str:
660
+ """Name of the middleware."""
661
+ return f"{self.__class__.__name__}[{self.pii_type}]"
662
+
663
+ def _process_content(
664
+ self, content: str | list[str | dict[str, Any]]
665
+ ) -> tuple[str | list[str | dict[str, Any]], bool]:
666
+ """Apply the configured redaction rule, preserving the shape of `content`.
667
+
668
+ Only string leaves are redacted: a plain-string `content` directly, and
669
+ the `text` of each block in a block-list `content`. Non-text blocks pass
670
+ through untouched, so block-list content keeps its structure instead of
671
+ collapsing into the `repr` of the list.
672
+
673
+ Args:
674
+ content: The message content to redact.
675
+
676
+ Returns:
677
+ The redacted content and whether anything was rewritten.
678
+ """
679
+ if isinstance(content, str):
680
+ matches = self.detector(content)
681
+ if not matches:
682
+ return content, False
683
+ return apply_strategy(content, matches, self.strategy), True
684
+
685
+ new_blocks: list[str | dict[str, Any]] = []
686
+ changed = False
687
+ for block in content:
688
+ new_block: str | dict[str, Any] = block
689
+ text = block if isinstance(block, str) else block.get("text")
690
+ if isinstance(text, str) and (matches := self.detector(text)):
691
+ redacted = apply_strategy(text, matches, self.strategy)
692
+ new_block = redacted if isinstance(block, str) else {**block, "text": redacted}
693
+ changed = True
694
+ new_blocks.append(new_block)
695
+ return (new_blocks, True) if changed else (content, False)
696
+
697
+ @hook_config(can_jump_to=["end"])
698
+ @override
699
+ def before_model(
700
+ self,
701
+ state: AgentState[Any],
702
+ runtime: Runtime[ContextT],
703
+ ) -> dict[str, Any] | None:
704
+ """Check user messages and tool results for PII before model invocation.
705
+
706
+ Args:
707
+ state: The current agent state.
708
+ runtime: The coco_graph runtime.
709
+
710
+ Returns:
711
+ Updated state with PII handled according to strategy, or `None` if no PII
712
+ detected.
713
+
714
+ Raises:
715
+ PIIDetectionError: If PII is detected and strategy is `'block'`.
716
+ """
717
+ if not self.apply_to_input and not self.apply_to_tool_results:
718
+ return None
719
+
720
+ messages = state["messages"]
721
+ if not messages:
722
+ return None
723
+
724
+ new_messages = list(messages)
725
+ any_modified = False
726
+
727
+ # Check user input if enabled
728
+ if self.apply_to_input:
729
+ # Get last user message
730
+ last_user_msg = None
731
+ last_user_idx = None
732
+ for i in range(len(messages) - 1, -1, -1):
733
+ if isinstance(messages[i], HumanMessage):
734
+ last_user_msg = messages[i]
735
+ last_user_idx = i
736
+ break
737
+
738
+ if last_user_idx is not None and last_user_msg and last_user_msg.content:
739
+ new_content, changed = self._process_content(last_user_msg.content)
740
+
741
+ if changed:
742
+ new_messages[last_user_idx] = last_user_msg.model_copy(
743
+ update={"content": new_content}
744
+ )
745
+ any_modified = True
746
+
747
+ # Check tool results if enabled
748
+ if self.apply_to_tool_results:
749
+ # Find the last AIMessage, then process all `ToolMessage` objects after it
750
+ last_ai_idx = None
751
+ for i in range(len(messages) - 1, -1, -1):
752
+ if isinstance(messages[i], AIMessage):
753
+ last_ai_idx = i
754
+ break
755
+
756
+ if last_ai_idx is not None:
757
+ # Get all tool messages after the last AI message
758
+ for i in range(last_ai_idx + 1, len(messages)):
759
+ msg = messages[i]
760
+ if isinstance(msg, ToolMessage):
761
+ tool_msg = msg
762
+ if not tool_msg.content:
763
+ continue
764
+
765
+ new_content, changed = self._process_content(tool_msg.content)
766
+
767
+ if not changed:
768
+ continue
769
+
770
+ new_messages[i] = tool_msg.model_copy(update={"content": new_content})
771
+ any_modified = True
772
+
773
+ if any_modified:
774
+ return {"messages": new_messages}
775
+
776
+ return None
777
+
778
+ @hook_config(can_jump_to=["end"])
779
+ async def abefore_model(
780
+ self,
781
+ state: AgentState[Any],
782
+ runtime: Runtime[ContextT],
783
+ ) -> dict[str, Any] | None:
784
+ """Async check user messages and tool results for PII before model invocation.
785
+
786
+ Args:
787
+ state: The current agent state.
788
+ runtime: The coco_graph runtime.
789
+
790
+ Returns:
791
+ Updated state with PII handled according to strategy, or `None` if no PII
792
+ detected.
793
+
794
+ Raises:
795
+ PIIDetectionError: If PII is detected and strategy is `'block'`.
796
+ """
797
+ return self.before_model(state, runtime)
798
+
799
+ @override
800
+ def after_model(
801
+ self,
802
+ state: AgentState[Any],
803
+ runtime: Runtime[ContextT],
804
+ ) -> dict[str, Any] | None:
805
+ """Check AI messages for PII after model invocation.
806
+
807
+ Args:
808
+ state: The current agent state.
809
+ runtime: The coco_graph runtime.
810
+
811
+ Returns:
812
+ Updated state with PII handled according to strategy, or None if no PII
813
+ detected.
814
+
815
+ Raises:
816
+ PIIDetectionError: If PII is detected and strategy is `'block'`.
817
+ """
818
+ if not self.apply_to_output:
819
+ return None
820
+
821
+ messages = state["messages"]
822
+ if not messages:
823
+ return None
824
+
825
+ # Get last AI message
826
+ last_ai_msg = None
827
+ last_ai_idx = None
828
+ for i in range(len(messages) - 1, -1, -1):
829
+ msg = messages[i]
830
+ if isinstance(msg, AIMessage):
831
+ last_ai_msg = msg
832
+ last_ai_idx = i
833
+ break
834
+
835
+ if last_ai_idx is None or not last_ai_msg or not last_ai_msg.content:
836
+ return None
837
+
838
+ new_content, changed = self._process_content(last_ai_msg.content)
839
+
840
+ if not changed:
841
+ return None
842
+
843
+ new_messages = list(messages)
844
+ new_messages[last_ai_idx] = last_ai_msg.model_copy(update={"content": new_content})
845
+
846
+ return {"messages": new_messages}
847
+
848
+ async def aafter_model(
849
+ self,
850
+ state: AgentState[Any],
851
+ runtime: Runtime[ContextT],
852
+ ) -> dict[str, Any] | None:
853
+ """Async check AI messages for PII after model invocation.
854
+
855
+ Args:
856
+ state: The current agent state.
857
+ runtime: The coco_graph runtime.
858
+
859
+ Returns:
860
+ Updated state with PII handled according to strategy, or None if no PII
861
+ detected.
862
+
863
+ Raises:
864
+ PIIDetectionError: If PII is detected and strategy is `'block'`.
865
+ """
866
+ return self.after_model(state, runtime)
867
+
868
+
869
+ __all__ = [
870
+ "PIIDetectionError",
871
+ "PIIMatch",
872
+ "PIIMiddleware",
873
+ "detect_credit_card",
874
+ "detect_email",
875
+ "detect_ip",
876
+ "detect_mac_address",
877
+ "detect_url",
878
+ ]