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.
- coco/__init__.py +3 -0
- coco/agents/__init__.py +9 -0
- coco/agents/_subagent_transformer.py +307 -0
- coco/agents/factory.py +2164 -0
- coco/agents/middleware/__init__.py +100 -0
- coco/agents/middleware/_execution.py +385 -0
- coco/agents/middleware/_redaction.py +454 -0
- coco/agents/middleware/_retry.py +134 -0
- coco/agents/middleware/_trace_policy.py +61 -0
- coco/agents/middleware/context_editing.py +306 -0
- coco/agents/middleware/file_search.py +433 -0
- coco/agents/middleware/human_in_the_loop.py +680 -0
- coco/agents/middleware/internal_call_transformer.py +108 -0
- coco/agents/middleware/model_call_limit.py +267 -0
- coco/agents/middleware/model_fallback.py +417 -0
- coco/agents/middleware/model_retry.py +327 -0
- coco/agents/middleware/pii.py +878 -0
- coco/agents/middleware/provider_tool_search.py +307 -0
- coco/agents/middleware/shell_tool.py +949 -0
- coco/agents/middleware/summarization.py +910 -0
- coco/agents/middleware/todo.py +357 -0
- coco/agents/middleware/tool_call_limit.py +495 -0
- coco/agents/middleware/tool_emulator.py +251 -0
- coco/agents/middleware/tool_error.py +209 -0
- coco/agents/middleware/tool_retry.py +421 -0
- coco/agents/middleware/tool_selection.py +515 -0
- coco/agents/middleware/types.py +2201 -0
- coco/agents/structured_output.py +472 -0
- coco/chat_models/__init__.py +7 -0
- coco/chat_models/base.py +1082 -0
- coco/embeddings/__init__.py +18 -0
- coco/embeddings/base.py +279 -0
- coco/mcp/__init__.py +36 -0
- coco/mcp/adapter.py +245 -0
- coco/mcp/elicitation.py +394 -0
- coco/mcp/tools.py +307 -0
- coco/messages/__init__.py +73 -0
- coco/py.typed +0 -0
- coco/rate_limiters/__init__.py +13 -0
- coco/tools/__init__.py +22 -0
- coco/tools/tool_node.py +20 -0
- coco_anthropic/__init__.py +15 -0
- coco_anthropic/_client_utils.py +81 -0
- coco_anthropic/_compat.py +228 -0
- coco_anthropic/_sdk_compat.py +124 -0
- coco_anthropic/_version.py +3 -0
- coco_anthropic/chat_models.py +3349 -0
- coco_anthropic/data/__init__.py +1 -0
- coco_anthropic/data/_profiles.py +491 -0
- coco_anthropic/data/profile_augmentations.toml +69 -0
- coco_anthropic/experimental.py +140 -0
- coco_anthropic/llms.py +428 -0
- coco_anthropic/middleware/__init__.py +25 -0
- coco_anthropic/middleware/anthropic_tools.py +1197 -0
- coco_anthropic/middleware/bash.py +84 -0
- coco_anthropic/middleware/file_search.py +360 -0
- coco_anthropic/middleware/prompt_caching.py +276 -0
- coco_anthropic/output_parsers.py +93 -0
- coco_anthropic/py.typed +0 -0
- coco_core/__init__.py +20 -0
- coco_core/_api/__init__.py +87 -0
- coco_core/_api/beta_decorator.py +266 -0
- coco_core/_api/deprecation.py +637 -0
- coco_core/_api/internal.py +23 -0
- coco_core/_api/path.py +50 -0
- coco_core/_import_utils.py +41 -0
- coco_core/_protocol.py +625 -0
- coco_core/_security/__init__.py +36 -0
- coco_core/_security/_exceptions.py +9 -0
- coco_core/_security/_policy.py +313 -0
- coco_core/_security/_ssrf_protection.py +155 -0
- coco_core/_security/_transport.py +254 -0
- coco_core/agents.py +258 -0
- coco_core/caches.py +272 -0
- coco_core/callbacks/__init__.py +132 -0
- coco_core/callbacks/base.py +1229 -0
- coco_core/callbacks/file.py +266 -0
- coco_core/callbacks/manager.py +2826 -0
- coco_core/callbacks/stdout.py +123 -0
- coco_core/callbacks/streaming_stdout.py +154 -0
- coco_core/callbacks/usage.py +124 -0
- coco_core/chat_history.py +267 -0
- coco_core/chat_loaders.py +26 -0
- coco_core/chat_sessions.py +19 -0
- coco_core/cross_encoders.py +18 -0
- coco_core/document_loaders/__init__.py +36 -0
- coco_core/document_loaders/base.py +155 -0
- coco_core/document_loaders/blob_loaders.py +38 -0
- coco_core/documents/__init__.py +55 -0
- coco_core/documents/base.py +347 -0
- coco_core/documents/compressor.py +74 -0
- coco_core/documents/transformers.py +79 -0
- coco_core/embeddings/__init__.py +31 -0
- coco_core/embeddings/embeddings.py +78 -0
- coco_core/embeddings/fake.py +129 -0
- coco_core/env.py +22 -0
- coco_core/example_selectors/__init__.py +47 -0
- coco_core/example_selectors/base.py +60 -0
- coco_core/example_selectors/length_based.py +131 -0
- coco_core/example_selectors/semantic_similarity.py +366 -0
- coco_core/exceptions.py +166 -0
- coco_core/globals.py +72 -0
- coco_core/indexing/__init__.py +53 -0
- coco_core/indexing/api.py +954 -0
- coco_core/indexing/base.py +661 -0
- coco_core/indexing/in_memory.py +104 -0
- coco_core/language_models/__init__.py +116 -0
- coco_core/language_models/_compat_bridge.py +844 -0
- coco_core/language_models/_utils.py +343 -0
- coco_core/language_models/base.py +500 -0
- coco_core/language_models/chat_model_stream.py +1525 -0
- coco_core/language_models/chat_models.py +2748 -0
- coco_core/language_models/fake.py +137 -0
- coco_core/language_models/fake_chat_models.py +396 -0
- coco_core/language_models/llms.py +1569 -0
- coco_core/language_models/model_profile.py +183 -0
- coco_core/load/__init__.py +44 -0
- coco_core/load/_validation.py +191 -0
- coco_core/load/dump.py +120 -0
- coco_core/load/load.py +828 -0
- coco_core/load/mapping.py +1097 -0
- coco_core/load/serializable.py +406 -0
- coco_core/messages/__init__.py +198 -0
- coco_core/messages/ai.py +854 -0
- coco_core/messages/base.py +518 -0
- coco_core/messages/block_translators/__init__.py +112 -0
- coco_core/messages/block_translators/anthropic.py +534 -0
- coco_core/messages/block_translators/bedrock.py +108 -0
- coco_core/messages/block_translators/bedrock_converse.py +329 -0
- coco_core/messages/block_translators/coco_v0.py +303 -0
- coco_core/messages/block_translators/google_genai.py +610 -0
- coco_core/messages/block_translators/google_vertexai.py +21 -0
- coco_core/messages/block_translators/groq.py +157 -0
- coco_core/messages/block_translators/openai.py +1089 -0
- coco_core/messages/chat.py +64 -0
- coco_core/messages/content.py +1488 -0
- coco_core/messages/function.py +62 -0
- coco_core/messages/human.py +70 -0
- coco_core/messages/modifier.py +33 -0
- coco_core/messages/system.py +70 -0
- coco_core/messages/tool.py +418 -0
- coco_core/messages/utils.py +2442 -0
- coco_core/output_parsers/__init__.py +101 -0
- coco_core/output_parsers/base.py +361 -0
- coco_core/output_parsers/format_instructions.py +16 -0
- coco_core/output_parsers/json.py +139 -0
- coco_core/output_parsers/list.py +249 -0
- coco_core/output_parsers/openai_functions.py +324 -0
- coco_core/output_parsers/openai_tools.py +383 -0
- coco_core/output_parsers/pydantic.py +141 -0
- coco_core/output_parsers/string.py +63 -0
- coco_core/output_parsers/transform.py +175 -0
- coco_core/output_parsers/xml.py +301 -0
- coco_core/outputs/__init__.py +66 -0
- coco_core/outputs/chat_generation.py +147 -0
- coco_core/outputs/chat_result.py +39 -0
- coco_core/outputs/generation.py +80 -0
- coco_core/outputs/llm_result.py +112 -0
- coco_core/outputs/run_info.py +22 -0
- coco_core/prompt_values.py +161 -0
- coco_core/prompts/__init__.py +101 -0
- coco_core/prompts/base.py +508 -0
- coco_core/prompts/chat.py +1495 -0
- coco_core/prompts/dict.py +177 -0
- coco_core/prompts/few_shot.py +483 -0
- coco_core/prompts/few_shot_with_templates.py +239 -0
- coco_core/prompts/image.py +177 -0
- coco_core/prompts/loading.py +296 -0
- coco_core/prompts/message.py +97 -0
- coco_core/prompts/prompt.py +312 -0
- coco_core/prompts/string.py +399 -0
- coco_core/prompts/structured.py +214 -0
- coco_core/py.typed +0 -0
- coco_core/rate_limiters.py +254 -0
- coco_core/retrievers.py +328 -0
- coco_core/runnables/__init__.py +136 -0
- coco_core/runnables/base.py +6725 -0
- coco_core/runnables/branch.py +457 -0
- coco_core/runnables/config.py +712 -0
- coco_core/runnables/configurable.py +710 -0
- coco_core/runnables/fallbacks.py +663 -0
- coco_core/runnables/graph.py +741 -0
- coco_core/runnables/graph_ascii.py +366 -0
- coco_core/runnables/graph_mermaid.py +503 -0
- coco_core/runnables/graph_png.py +215 -0
- coco_core/runnables/history.py +631 -0
- coco_core/runnables/passthrough.py +849 -0
- coco_core/runnables/retry.py +379 -0
- coco_core/runnables/router.py +239 -0
- coco_core/runnables/schema.py +188 -0
- coco_core/runnables/utils.py +798 -0
- coco_core/stores.py +291 -0
- coco_core/structured_query.py +203 -0
- coco_core/sys_info.py +154 -0
- coco_core/tools/__init__.py +95 -0
- coco_core/tools/base.py +1948 -0
- coco_core/tools/convert.py +497 -0
- coco_core/tools/render.py +67 -0
- coco_core/tools/retriever.py +94 -0
- coco_core/tools/simple.py +208 -0
- coco_core/tools/structured.py +322 -0
- coco_core/tracers/__init__.py +47 -0
- coco_core/tracers/_compat.py +95 -0
- coco_core/tracers/_context.py +337 -0
- coco_core/tracers/_streaming.py +53 -0
- coco_core/tracers/base.py +955 -0
- coco_core/tracers/coco.py +279 -0
- coco_core/tracers/context.py +205 -0
- coco_core/tracers/core.py +766 -0
- coco_core/tracers/event_stream.py +1101 -0
- coco_core/tracers/log_stream.py +765 -0
- coco_core/tracers/memory_stream.py +200 -0
- coco_core/tracers/root_listeners.py +130 -0
- coco_core/tracers/run_collector.py +40 -0
- coco_core/tracers/schemas.py +14 -0
- coco_core/tracers/stdout.py +205 -0
- coco_core/utils/__init__.py +111 -0
- coco_core/utils/_gateway.py +274 -0
- coco_core/utils/_merge.py +218 -0
- coco_core/utils/aiter.py +357 -0
- coco_core/utils/env.py +86 -0
- coco_core/utils/formatting.py +80 -0
- coco_core/utils/function_calling.py +869 -0
- coco_core/utils/html.py +132 -0
- coco_core/utils/image.py +15 -0
- coco_core/utils/input.py +82 -0
- coco_core/utils/interactive_env.py +12 -0
- coco_core/utils/iter.py +229 -0
- coco_core/utils/json.py +230 -0
- coco_core/utils/json_schema.py +273 -0
- coco_core/utils/mustache.py +706 -0
- coco_core/utils/pydantic.py +631 -0
- coco_core/utils/strings.py +69 -0
- coco_core/utils/usage.py +61 -0
- coco_core/utils/utils.py +528 -0
- coco_core/utils/uuid.py +57 -0
- coco_core/vectorstores/__init__.py +53 -0
- coco_core/vectorstores/base.py +1111 -0
- coco_core/vectorstores/in_memory.py +546 -0
- coco_core/vectorstores/utils.py +163 -0
- coco_core/version.py +3 -0
- coco_framework-0.1.0.dist-info/METADATA +184 -0
- coco_framework-0.1.0.dist-info/RECORD +400 -0
- coco_framework-0.1.0.dist-info/WHEEL +4 -0
- coco_framework-0.1.0.dist-info/licenses/LICENSE +24 -0
- coco_framework-0.1.0.dist-info/licenses/NOTICE +15 -0
- coco_graph/_internal/__init__.py +4 -0
- coco_graph/_internal/_cache.py +31 -0
- coco_graph/_internal/_config.py +474 -0
- coco_graph/_internal/_constants.py +140 -0
- coco_graph/_internal/_fields.py +213 -0
- coco_graph/_internal/_future.py +220 -0
- coco_graph/_internal/_pydantic.py +275 -0
- coco_graph/_internal/_queue.py +124 -0
- coco_graph/_internal/_replay.py +90 -0
- coco_graph/_internal/_retry.py +29 -0
- coco_graph/_internal/_runnable.py +969 -0
- coco_graph/_internal/_scratchpad.py +19 -0
- coco_graph/_internal/_serde.py +253 -0
- coco_graph/_internal/_timeout.py +25 -0
- coco_graph/_internal/_typing.py +54 -0
- coco_graph/cache/base/__init__.py +48 -0
- coco_graph/cache/base/py.typed +0 -0
- coco_graph/cache/memory/__init__.py +73 -0
- coco_graph/cache/redis/__init__.py +144 -0
- coco_graph/cache/sqlite/__init__.py +120 -0
- coco_graph/callbacks.py +394 -0
- coco_graph/channels/__init__.py +29 -0
- coco_graph/channels/any_value.py +72 -0
- coco_graph/channels/base.py +121 -0
- coco_graph/channels/binop.py +155 -0
- coco_graph/channels/delta.py +202 -0
- coco_graph/channels/ephemeral_value.py +79 -0
- coco_graph/channels/last_value.py +151 -0
- coco_graph/channels/named_barrier_value.py +167 -0
- coco_graph/channels/topic.py +94 -0
- coco_graph/channels/untracked_value.py +73 -0
- coco_graph/checkpoint/base/__init__.py +861 -0
- coco_graph/checkpoint/base/id.py +109 -0
- coco_graph/checkpoint/base/py.typed +0 -0
- coco_graph/checkpoint/memory/__init__.py +698 -0
- coco_graph/checkpoint/memory/py.typed +0 -0
- coco_graph/checkpoint/postgres/__init__.py +602 -0
- coco_graph/checkpoint/postgres/_ainternal.py +23 -0
- coco_graph/checkpoint/postgres/_internal.py +21 -0
- coco_graph/checkpoint/postgres/aio.py +691 -0
- coco_graph/checkpoint/postgres/base.py +666 -0
- coco_graph/checkpoint/postgres/py.typed +0 -0
- coco_graph/checkpoint/postgres/shallow.py +967 -0
- coco_graph/checkpoint/serde/__init__.py +0 -0
- coco_graph/checkpoint/serde/_msgpack.py +97 -0
- coco_graph/checkpoint/serde/base.py +64 -0
- coco_graph/checkpoint/serde/encrypted.py +80 -0
- coco_graph/checkpoint/serde/event_hooks.py +52 -0
- coco_graph/checkpoint/serde/jsonplus.py +883 -0
- coco_graph/checkpoint/serde/py.typed +0 -0
- coco_graph/checkpoint/serde/types.py +68 -0
- coco_graph/checkpoint/sqlite/__init__.py +645 -0
- coco_graph/checkpoint/sqlite/_delta.py +172 -0
- coco_graph/checkpoint/sqlite/aio.py +738 -0
- coco_graph/checkpoint/sqlite/py.typed +0 -0
- coco_graph/checkpoint/sqlite/utils.py +116 -0
- coco_graph/config.py +196 -0
- coco_graph/constants.py +64 -0
- coco_graph/errors.py +241 -0
- coco_graph/func/__init__.py +620 -0
- coco_graph/graph/__init__.py +12 -0
- coco_graph/graph/_branch.py +225 -0
- coco_graph/graph/_node.py +103 -0
- coco_graph/graph/message.py +437 -0
- coco_graph/graph/state.py +1978 -0
- coco_graph/graph/ui.py +227 -0
- coco_graph/managed/__init__.py +3 -0
- coco_graph/managed/base.py +31 -0
- coco_graph/managed/is_last_step.py +24 -0
- coco_graph/prebuilt/__init__.py +23 -0
- coco_graph/prebuilt/_tool_call_stream.py +117 -0
- coco_graph/prebuilt/_tool_call_transformer.py +165 -0
- coco_graph/prebuilt/chat_agent_executor.py +1015 -0
- coco_graph/prebuilt/interrupt.py +105 -0
- coco_graph/prebuilt/py.typed +0 -0
- coco_graph/prebuilt/tool_node.py +2030 -0
- coco_graph/prebuilt/tool_validator.py +221 -0
- coco_graph/pregel/__init__.py +3 -0
- coco_graph/pregel/_algo.py +1460 -0
- coco_graph/pregel/_call.py +298 -0
- coco_graph/pregel/_checkpoint.py +331 -0
- coco_graph/pregel/_config.py +0 -0
- coco_graph/pregel/_draw.py +294 -0
- coco_graph/pregel/_executor.py +223 -0
- coco_graph/pregel/_io.py +174 -0
- coco_graph/pregel/_log.py +3 -0
- coco_graph/pregel/_loop.py +1988 -0
- coco_graph/pregel/_messages.py +461 -0
- coco_graph/pregel/_read.py +312 -0
- coco_graph/pregel/_retry.py +854 -0
- coco_graph/pregel/_runner.py +941 -0
- coco_graph/pregel/_tools.py +268 -0
- coco_graph/pregel/_utils.py +221 -0
- coco_graph/pregel/_validate.py +120 -0
- coco_graph/pregel/_write.py +192 -0
- coco_graph/pregel/debug.py +302 -0
- coco_graph/pregel/main.py +4364 -0
- coco_graph/pregel/protocol.py +288 -0
- coco_graph/pregel/types.py +38 -0
- coco_graph/py.typed +0 -0
- coco_graph/runtime.py +350 -0
- coco_graph/store/base/__init__.py +1322 -0
- coco_graph/store/base/batch.py +371 -0
- coco_graph/store/base/embed.py +436 -0
- coco_graph/store/base/py.typed +0 -0
- coco_graph/store/memory/__init__.py +592 -0
- coco_graph/store/memory/py.typed +0 -0
- coco_graph/store/postgres/__init__.py +4 -0
- coco_graph/store/postgres/aio.py +592 -0
- coco_graph/store/postgres/base.py +1480 -0
- coco_graph/store/postgres/py.typed +0 -0
- coco_graph/store/sqlite/__init__.py +4 -0
- coco_graph/store/sqlite/aio.py +632 -0
- coco_graph/store/sqlite/base.py +1514 -0
- coco_graph/stream/__init__.py +45 -0
- coco_graph/stream/_convert.py +32 -0
- coco_graph/stream/_mux.py +523 -0
- coco_graph/stream/_types.py +330 -0
- coco_graph/stream/run_stream.py +735 -0
- coco_graph/stream/stream_channel.py +341 -0
- coco_graph/stream/transformers.py +1039 -0
- coco_graph/types.py +1079 -0
- coco_graph/typing.py +48 -0
- coco_graph/utils/__init__.py +1 -0
- coco_graph/utils/config.py +4 -0
- coco_graph/utils/runnable.py +3 -0
- coco_graph/version.py +13 -0
- coco_graph/warnings.py +69 -0
- coco_openai/__init__.py +20 -0
- coco_openai/_compat.py +45 -0
- coco_openai/_version.py +3 -0
- coco_openai/chat_models/__init__.py +6 -0
- coco_openai/chat_models/_client_utils.py +684 -0
- coco_openai/chat_models/_compat.py +525 -0
- coco_openai/chat_models/azure.py +1227 -0
- coco_openai/chat_models/base.py +5654 -0
- coco_openai/chat_models/codex.py +557 -0
- coco_openai/chatgpt_oauth.py +1063 -0
- coco_openai/data/__init__.py +1 -0
- coco_openai/data/_profiles.py +1655 -0
- coco_openai/data/profile_augmentations.toml +127 -0
- coco_openai/embeddings/__init__.py +6 -0
- coco_openai/embeddings/azure.py +240 -0
- coco_openai/embeddings/base.py +820 -0
- coco_openai/llms/__init__.py +6 -0
- coco_openai/llms/azure.py +249 -0
- coco_openai/llms/base.py +919 -0
- coco_openai/middleware/__init__.py +11 -0
- coco_openai/middleware/openai_moderation.py +484 -0
- coco_openai/output_parsers/__init__.py +9 -0
- coco_openai/output_parsers/tools.py +9 -0
- coco_openai/py.typed +0 -0
- coco_openai/tools/__init__.py +5 -0
- 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
|
+
]
|