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,454 @@
|
|
|
1
|
+
"""Shared redaction utilities for middleware components."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import ipaddress
|
|
7
|
+
import operator
|
|
8
|
+
import re
|
|
9
|
+
from collections.abc import Callable, Sequence
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Literal
|
|
12
|
+
from urllib.parse import urlparse
|
|
13
|
+
|
|
14
|
+
from typing_extensions import TypedDict
|
|
15
|
+
|
|
16
|
+
RedactionStrategy = Literal["block", "redact", "mask", "hash"]
|
|
17
|
+
"""Supported strategies for handling detected sensitive values."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PIIMatch(TypedDict):
|
|
21
|
+
"""Represents an individual match of sensitive data."""
|
|
22
|
+
|
|
23
|
+
type: str
|
|
24
|
+
value: str
|
|
25
|
+
start: int
|
|
26
|
+
end: int
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class PIIDetectionError(Exception):
|
|
30
|
+
"""Raised when configured to block on detected sensitive values."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, pii_type: str, matches: Sequence[PIIMatch]) -> None:
|
|
33
|
+
"""Initialize the exception with match context.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
pii_type: Name of the detected sensitive type.
|
|
37
|
+
matches: All matches that were detected for that type.
|
|
38
|
+
"""
|
|
39
|
+
self.pii_type = pii_type
|
|
40
|
+
self.matches = list(matches)
|
|
41
|
+
count = len(matches)
|
|
42
|
+
msg = f"Detected {count} instance(s) of {pii_type} in text content"
|
|
43
|
+
super().__init__(msg)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
Detector = Callable[[str], list[PIIMatch]]
|
|
47
|
+
"""Callable signature for detectors that locate sensitive values."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def detect_email(content: str) -> list[PIIMatch]:
|
|
51
|
+
"""Detect email addresses in content.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
content: The text content to scan for email addresses.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
A list of detected email matches.
|
|
58
|
+
"""
|
|
59
|
+
pattern = r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b"
|
|
60
|
+
return [
|
|
61
|
+
PIIMatch(
|
|
62
|
+
type="email",
|
|
63
|
+
value=match.group(),
|
|
64
|
+
start=match.start(),
|
|
65
|
+
end=match.end(),
|
|
66
|
+
)
|
|
67
|
+
for match in re.finditer(pattern, content)
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def detect_credit_card(content: str) -> list[PIIMatch]:
|
|
72
|
+
"""Detect credit card numbers in content using Luhn validation.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
content: The text content to scan for credit card numbers.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
A list of detected credit card matches.
|
|
79
|
+
"""
|
|
80
|
+
pattern = r"\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b"
|
|
81
|
+
matches = []
|
|
82
|
+
|
|
83
|
+
for match in re.finditer(pattern, content):
|
|
84
|
+
card_number = match.group()
|
|
85
|
+
if _passes_luhn(card_number):
|
|
86
|
+
matches.append(
|
|
87
|
+
PIIMatch(
|
|
88
|
+
type="credit_card",
|
|
89
|
+
value=card_number,
|
|
90
|
+
start=match.start(),
|
|
91
|
+
end=match.end(),
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
return matches
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def detect_ip(content: str) -> list[PIIMatch]:
|
|
99
|
+
"""Detect IPv4 or IPv6 addresses in content.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
content: The text content to scan for IP addresses.
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
A list of detected IP address matches.
|
|
106
|
+
"""
|
|
107
|
+
matches: list[PIIMatch] = []
|
|
108
|
+
ipv4_pattern = r"\b(?:[0-9]{1,3}\.){3}[0-9]{1,3}\b"
|
|
109
|
+
|
|
110
|
+
for match in re.finditer(ipv4_pattern, content):
|
|
111
|
+
ip_candidate = match.group()
|
|
112
|
+
try:
|
|
113
|
+
ipaddress.ip_address(ip_candidate)
|
|
114
|
+
except ValueError:
|
|
115
|
+
continue
|
|
116
|
+
matches.append(
|
|
117
|
+
PIIMatch(
|
|
118
|
+
type="ip",
|
|
119
|
+
value=ip_candidate,
|
|
120
|
+
start=match.start(),
|
|
121
|
+
end=match.end(),
|
|
122
|
+
)
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
return matches
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def detect_mac_address(content: str) -> list[PIIMatch]:
|
|
129
|
+
"""Detect MAC addresses in content.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
content: The text content to scan for MAC addresses.
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
A list of detected MAC address matches.
|
|
136
|
+
"""
|
|
137
|
+
pattern = r"\b([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}\b"
|
|
138
|
+
return [
|
|
139
|
+
PIIMatch(
|
|
140
|
+
type="mac_address",
|
|
141
|
+
value=match.group(),
|
|
142
|
+
start=match.start(),
|
|
143
|
+
end=match.end(),
|
|
144
|
+
)
|
|
145
|
+
for match in re.finditer(pattern, content)
|
|
146
|
+
]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def detect_url(content: str) -> list[PIIMatch]:
|
|
150
|
+
"""Detect URLs in content using regex and stdlib validation.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
content: The text content to scan for URLs.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
A list of detected URL matches.
|
|
157
|
+
"""
|
|
158
|
+
matches: list[PIIMatch] = []
|
|
159
|
+
|
|
160
|
+
# Pattern 1: URLs with scheme (http:// or https://)
|
|
161
|
+
scheme_pattern = r"https?://[^\s<>\"{}|\\^`\[\]]+"
|
|
162
|
+
|
|
163
|
+
for match in re.finditer(scheme_pattern, content):
|
|
164
|
+
url = match.group()
|
|
165
|
+
result = urlparse(url)
|
|
166
|
+
if result.scheme in {"http", "https"} and result.netloc:
|
|
167
|
+
matches.append(
|
|
168
|
+
PIIMatch(
|
|
169
|
+
type="url",
|
|
170
|
+
value=url,
|
|
171
|
+
start=match.start(),
|
|
172
|
+
end=match.end(),
|
|
173
|
+
)
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
# Pattern 2: URLs without scheme (www.example.com or example.com/path)
|
|
177
|
+
# More conservative to avoid false positives
|
|
178
|
+
bare_pattern = (
|
|
179
|
+
r"\b(?:www\.)?[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?"
|
|
180
|
+
r"(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?:/[^\s]*)?"
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
for match in re.finditer(bare_pattern, content):
|
|
184
|
+
start, end = match.start(), match.end()
|
|
185
|
+
# Skip if already matched with scheme
|
|
186
|
+
if any(m["start"] <= start < m["end"] or m["start"] < end <= m["end"] for m in matches):
|
|
187
|
+
continue
|
|
188
|
+
|
|
189
|
+
url = match.group()
|
|
190
|
+
# Only accept if it has a path or starts with www
|
|
191
|
+
# This reduces false positives like "example.com" in prose
|
|
192
|
+
if "/" in url or url.startswith("www."):
|
|
193
|
+
# Add scheme for validation (required for urlparse to work correctly)
|
|
194
|
+
test_url = f"http://{url}"
|
|
195
|
+
result = urlparse(test_url)
|
|
196
|
+
if result.netloc and "." in result.netloc:
|
|
197
|
+
matches.append(
|
|
198
|
+
PIIMatch(
|
|
199
|
+
type="url",
|
|
200
|
+
value=url,
|
|
201
|
+
start=start,
|
|
202
|
+
end=end,
|
|
203
|
+
)
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
return matches
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
BUILTIN_DETECTORS: dict[str, Detector] = {
|
|
210
|
+
"email": detect_email,
|
|
211
|
+
"credit_card": detect_credit_card,
|
|
212
|
+
"ip": detect_ip,
|
|
213
|
+
"mac_address": detect_mac_address,
|
|
214
|
+
"url": detect_url,
|
|
215
|
+
}
|
|
216
|
+
"""Registry of built-in detectors keyed by type name."""
|
|
217
|
+
|
|
218
|
+
_CARD_NUMBER_MIN_DIGITS = 13
|
|
219
|
+
_CARD_NUMBER_MAX_DIGITS = 19
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _passes_luhn(card_number: str) -> bool:
|
|
223
|
+
"""Validate credit card number using the Luhn checksum."""
|
|
224
|
+
digits = [int(d) for d in card_number if d.isdigit()]
|
|
225
|
+
if not _CARD_NUMBER_MIN_DIGITS <= len(digits) <= _CARD_NUMBER_MAX_DIGITS:
|
|
226
|
+
return False
|
|
227
|
+
|
|
228
|
+
checksum = 0
|
|
229
|
+
for index, digit in enumerate(reversed(digits)):
|
|
230
|
+
value = digit
|
|
231
|
+
if index % 2 == 1:
|
|
232
|
+
value *= 2
|
|
233
|
+
if value > 9: # noqa: PLR2004
|
|
234
|
+
value -= 9
|
|
235
|
+
checksum += value
|
|
236
|
+
return checksum % 10 == 0
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _apply_redact_strategy(content: str, matches: list[PIIMatch]) -> str:
|
|
240
|
+
result = content
|
|
241
|
+
for match in sorted(matches, key=operator.itemgetter("start"), reverse=True):
|
|
242
|
+
replacement = f"[REDACTED_{match['type'].upper()}]"
|
|
243
|
+
result = result[: match["start"]] + replacement + result[match["end"] :]
|
|
244
|
+
return result
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
_UNMASKED_CHAR_NUMBER = 4
|
|
248
|
+
_IPV4_PARTS_NUMBER = 4
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _apply_mask_strategy(content: str, matches: list[PIIMatch]) -> str:
|
|
252
|
+
result = content
|
|
253
|
+
for match in sorted(matches, key=operator.itemgetter("start"), reverse=True):
|
|
254
|
+
value = match["value"]
|
|
255
|
+
pii_type = match["type"]
|
|
256
|
+
if pii_type == "email":
|
|
257
|
+
parts = value.split("@")
|
|
258
|
+
if len(parts) == 2: # noqa: PLR2004
|
|
259
|
+
domain_parts = parts[1].split(".")
|
|
260
|
+
masked = (
|
|
261
|
+
f"{parts[0]}@****.{domain_parts[-1]}"
|
|
262
|
+
if len(domain_parts) > 1
|
|
263
|
+
else f"{parts[0]}@****"
|
|
264
|
+
)
|
|
265
|
+
else:
|
|
266
|
+
masked = "****"
|
|
267
|
+
elif pii_type == "credit_card":
|
|
268
|
+
digits_only = "".join(c for c in value if c.isdigit())
|
|
269
|
+
separator = "-" if "-" in value else " " if " " in value else ""
|
|
270
|
+
if separator:
|
|
271
|
+
masked = (
|
|
272
|
+
f"****{separator}****{separator}****{separator}"
|
|
273
|
+
f"{digits_only[-_UNMASKED_CHAR_NUMBER:]}"
|
|
274
|
+
)
|
|
275
|
+
else:
|
|
276
|
+
masked = f"************{digits_only[-_UNMASKED_CHAR_NUMBER:]}"
|
|
277
|
+
elif pii_type == "ip":
|
|
278
|
+
octets = value.split(".")
|
|
279
|
+
masked = f"*.*.*.{octets[-1]}" if len(octets) == _IPV4_PARTS_NUMBER else "****"
|
|
280
|
+
elif pii_type == "mac_address":
|
|
281
|
+
separator = ":" if ":" in value else "-"
|
|
282
|
+
masked = (
|
|
283
|
+
f"**{separator}**{separator}**{separator}**{separator}**{separator}{value[-2:]}"
|
|
284
|
+
)
|
|
285
|
+
elif pii_type == "url":
|
|
286
|
+
masked = "[MASKED_URL]"
|
|
287
|
+
else:
|
|
288
|
+
masked = (
|
|
289
|
+
f"****{value[-_UNMASKED_CHAR_NUMBER:]}"
|
|
290
|
+
if len(value) > _UNMASKED_CHAR_NUMBER
|
|
291
|
+
else "****"
|
|
292
|
+
)
|
|
293
|
+
result = result[: match["start"]] + masked + result[match["end"] :]
|
|
294
|
+
return result
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _apply_hash_strategy(content: str, matches: list[PIIMatch]) -> str:
|
|
298
|
+
result = content
|
|
299
|
+
for match in sorted(matches, key=operator.itemgetter("start"), reverse=True):
|
|
300
|
+
digest = hashlib.sha256(match["value"].encode()).hexdigest()[:8]
|
|
301
|
+
replacement = f"<{match['type']}_hash:{digest}>"
|
|
302
|
+
result = result[: match["start"]] + replacement + result[match["end"] :]
|
|
303
|
+
return result
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def apply_strategy(
|
|
307
|
+
content: str,
|
|
308
|
+
matches: list[PIIMatch],
|
|
309
|
+
strategy: RedactionStrategy,
|
|
310
|
+
) -> str:
|
|
311
|
+
"""Apply the configured strategy to matches within content.
|
|
312
|
+
|
|
313
|
+
Args:
|
|
314
|
+
content: The content to apply strategy to.
|
|
315
|
+
matches: List of detected PII matches.
|
|
316
|
+
strategy: The redaction strategy to apply.
|
|
317
|
+
|
|
318
|
+
Returns:
|
|
319
|
+
The content with the strategy applied.
|
|
320
|
+
|
|
321
|
+
Raises:
|
|
322
|
+
PIIDetectionError: If the strategy is `'block'` and matches are found.
|
|
323
|
+
ValueError: If the strategy is unknown.
|
|
324
|
+
"""
|
|
325
|
+
if not matches:
|
|
326
|
+
return content
|
|
327
|
+
if strategy == "redact":
|
|
328
|
+
return _apply_redact_strategy(content, matches)
|
|
329
|
+
if strategy == "mask":
|
|
330
|
+
return _apply_mask_strategy(content, matches)
|
|
331
|
+
if strategy == "hash":
|
|
332
|
+
return _apply_hash_strategy(content, matches)
|
|
333
|
+
if strategy == "block":
|
|
334
|
+
raise PIIDetectionError(matches[0]["type"], matches)
|
|
335
|
+
msg = f"Unknown redaction strategy: {strategy}" # type: ignore[unreachable]
|
|
336
|
+
raise ValueError(msg)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def resolve_detector(pii_type: str, detector: Detector | str | None) -> Detector:
|
|
340
|
+
"""Return a callable detector for the given configuration.
|
|
341
|
+
|
|
342
|
+
Args:
|
|
343
|
+
pii_type: The PII type name.
|
|
344
|
+
detector: Optional custom detector or regex pattern. If `None`, a built-in detector
|
|
345
|
+
for the given PII type will be used.
|
|
346
|
+
|
|
347
|
+
Returns:
|
|
348
|
+
The resolved detector.
|
|
349
|
+
|
|
350
|
+
Raises:
|
|
351
|
+
ValueError: If an unknown PII type is specified without a custom detector or regex.
|
|
352
|
+
"""
|
|
353
|
+
if detector is None:
|
|
354
|
+
if pii_type not in BUILTIN_DETECTORS:
|
|
355
|
+
msg = (
|
|
356
|
+
f"Unknown PII type: {pii_type}. "
|
|
357
|
+
f"Must be one of {list(BUILTIN_DETECTORS.keys())} or provide a custom detector."
|
|
358
|
+
)
|
|
359
|
+
raise ValueError(msg)
|
|
360
|
+
return BUILTIN_DETECTORS[pii_type]
|
|
361
|
+
if isinstance(detector, str):
|
|
362
|
+
pattern = re.compile(detector)
|
|
363
|
+
|
|
364
|
+
def regex_detector(content: str) -> list[PIIMatch]:
|
|
365
|
+
return [
|
|
366
|
+
PIIMatch(
|
|
367
|
+
type=pii_type,
|
|
368
|
+
value=match.group(),
|
|
369
|
+
start=match.start(),
|
|
370
|
+
end=match.end(),
|
|
371
|
+
)
|
|
372
|
+
for match in pattern.finditer(content)
|
|
373
|
+
]
|
|
374
|
+
|
|
375
|
+
return regex_detector
|
|
376
|
+
|
|
377
|
+
# Wrap the custom callable to normalize its output.
|
|
378
|
+
# Custom detectors may return dicts with "text" instead of "value"
|
|
379
|
+
# and may omit "type". Map them to proper PIIMatch objects so that
|
|
380
|
+
# downstream strategies (hash, mask) can access match["value"].
|
|
381
|
+
raw_detector = detector
|
|
382
|
+
|
|
383
|
+
def _normalizing_detector(content: str) -> list[PIIMatch]:
|
|
384
|
+
return [
|
|
385
|
+
PIIMatch(
|
|
386
|
+
type=m.get("type", pii_type),
|
|
387
|
+
value=m.get("value", m.get("text", "")),
|
|
388
|
+
start=m["start"],
|
|
389
|
+
end=m["end"],
|
|
390
|
+
)
|
|
391
|
+
for m in raw_detector(content)
|
|
392
|
+
]
|
|
393
|
+
|
|
394
|
+
return _normalizing_detector
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
@dataclass(frozen=True)
|
|
398
|
+
class RedactionRule:
|
|
399
|
+
"""Configuration for handling a single PII type."""
|
|
400
|
+
|
|
401
|
+
pii_type: str
|
|
402
|
+
strategy: RedactionStrategy = "redact"
|
|
403
|
+
detector: Detector | str | None = None
|
|
404
|
+
|
|
405
|
+
def resolve(self) -> ResolvedRedactionRule:
|
|
406
|
+
"""Resolve runtime detector and return an immutable rule.
|
|
407
|
+
|
|
408
|
+
Returns:
|
|
409
|
+
The resolved redaction rule.
|
|
410
|
+
"""
|
|
411
|
+
resolved_detector = resolve_detector(self.pii_type, self.detector)
|
|
412
|
+
return ResolvedRedactionRule(
|
|
413
|
+
pii_type=self.pii_type,
|
|
414
|
+
strategy=self.strategy,
|
|
415
|
+
detector=resolved_detector,
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
@dataclass(frozen=True)
|
|
420
|
+
class ResolvedRedactionRule:
|
|
421
|
+
"""Resolved redaction rule ready for execution."""
|
|
422
|
+
|
|
423
|
+
pii_type: str
|
|
424
|
+
strategy: RedactionStrategy
|
|
425
|
+
detector: Detector
|
|
426
|
+
|
|
427
|
+
def apply(self, content: str) -> tuple[str, list[PIIMatch]]:
|
|
428
|
+
"""Apply this rule to content, returning new content and matches.
|
|
429
|
+
|
|
430
|
+
Args:
|
|
431
|
+
content: The text content to scan and redact.
|
|
432
|
+
|
|
433
|
+
Returns:
|
|
434
|
+
A tuple of (updated content, list of detected matches).
|
|
435
|
+
"""
|
|
436
|
+
matches = self.detector(content)
|
|
437
|
+
if not matches:
|
|
438
|
+
return content, []
|
|
439
|
+
updated = apply_strategy(content, matches, self.strategy)
|
|
440
|
+
return updated, matches
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
__all__ = [
|
|
444
|
+
"PIIDetectionError",
|
|
445
|
+
"PIIMatch",
|
|
446
|
+
"RedactionRule",
|
|
447
|
+
"ResolvedRedactionRule",
|
|
448
|
+
"apply_strategy",
|
|
449
|
+
"detect_credit_card",
|
|
450
|
+
"detect_email",
|
|
451
|
+
"detect_ip",
|
|
452
|
+
"detect_mac_address",
|
|
453
|
+
"detect_url",
|
|
454
|
+
]
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Shared retry utilities for agent middleware.
|
|
2
|
+
|
|
3
|
+
This module contains common constants, utilities, and logic used by both
|
|
4
|
+
model and tool retry middleware implementations.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import random
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from typing import Literal
|
|
12
|
+
|
|
13
|
+
from coco_core.exceptions import ModelError
|
|
14
|
+
|
|
15
|
+
# Type aliases
|
|
16
|
+
RetryOn = tuple[type[Exception], ...] | Callable[[Exception], bool]
|
|
17
|
+
"""Type for specifying which exceptions to retry on.
|
|
18
|
+
|
|
19
|
+
Can be either:
|
|
20
|
+
- A tuple of exception types to retry on (based on `isinstance` checks)
|
|
21
|
+
- A callable that takes an exception and returns `True` if it should be retried
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
OnFailure = Literal["error", "continue"] | Callable[[Exception], str]
|
|
25
|
+
"""Type for specifying failure handling behavior.
|
|
26
|
+
|
|
27
|
+
Can be either:
|
|
28
|
+
- A literal action string (`'error'` or `'continue'`)
|
|
29
|
+
- `'error'`: Re-raise the exception, stopping agent execution.
|
|
30
|
+
- `'continue'`: Inject a message with the error details, allowing the agent to continue.
|
|
31
|
+
|
|
32
|
+
For tool retries, a `ToolMessage` with the error details will be injected.
|
|
33
|
+
|
|
34
|
+
For model retries, an `AIMessage` with the error details will be returned.
|
|
35
|
+
- A callable that takes an exception and returns a string for error message content
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def validate_retry_params(
|
|
40
|
+
max_retries: int,
|
|
41
|
+
initial_delay: float,
|
|
42
|
+
max_delay: float,
|
|
43
|
+
backoff_factor: float,
|
|
44
|
+
) -> None:
|
|
45
|
+
"""Validate retry parameters.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
max_retries: Maximum number of retry attempts.
|
|
49
|
+
initial_delay: Initial delay in seconds before first retry.
|
|
50
|
+
max_delay: Maximum delay in seconds between retries.
|
|
51
|
+
backoff_factor: Multiplier for exponential backoff.
|
|
52
|
+
|
|
53
|
+
Raises:
|
|
54
|
+
ValueError: If any parameter is invalid (negative values).
|
|
55
|
+
"""
|
|
56
|
+
if max_retries < 0:
|
|
57
|
+
msg = "max_retries must be >= 0"
|
|
58
|
+
raise ValueError(msg)
|
|
59
|
+
if initial_delay < 0:
|
|
60
|
+
msg = "initial_delay must be >= 0"
|
|
61
|
+
raise ValueError(msg)
|
|
62
|
+
if max_delay < 0:
|
|
63
|
+
msg = "max_delay must be >= 0"
|
|
64
|
+
raise ValueError(msg)
|
|
65
|
+
if backoff_factor < 0:
|
|
66
|
+
msg = "backoff_factor must be >= 0"
|
|
67
|
+
raise ValueError(msg)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def default_retry_on(exc: Exception) -> bool:
|
|
71
|
+
"""Return whether an exception should be retried by default."""
|
|
72
|
+
if isinstance(exc, ModelError):
|
|
73
|
+
return exc.is_retryable
|
|
74
|
+
return True
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def should_retry_exception(
|
|
78
|
+
exc: Exception,
|
|
79
|
+
retry_on: RetryOn,
|
|
80
|
+
) -> bool:
|
|
81
|
+
"""Check if an exception should trigger a retry.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
exc: The exception that occurred.
|
|
85
|
+
retry_on: Either a tuple of exception types to retry on, or a callable
|
|
86
|
+
that takes an exception and returns `True` if it should be retried.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
`True` if the exception should be retried, `False` otherwise.
|
|
90
|
+
"""
|
|
91
|
+
if callable(retry_on):
|
|
92
|
+
return retry_on(exc)
|
|
93
|
+
return isinstance(exc, retry_on)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def calculate_delay(
|
|
97
|
+
retry_number: int,
|
|
98
|
+
*,
|
|
99
|
+
backoff_factor: float,
|
|
100
|
+
initial_delay: float,
|
|
101
|
+
max_delay: float,
|
|
102
|
+
jitter: bool,
|
|
103
|
+
) -> float:
|
|
104
|
+
"""Calculate delay for a retry attempt with exponential backoff and optional jitter.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
retry_number: The retry attempt number (0-indexed).
|
|
108
|
+
backoff_factor: Multiplier for exponential backoff.
|
|
109
|
+
|
|
110
|
+
Set to `0.0` for constant delay.
|
|
111
|
+
initial_delay: Initial delay in seconds before first retry.
|
|
112
|
+
max_delay: Maximum delay in seconds between retries.
|
|
113
|
+
|
|
114
|
+
Caps exponential backoff growth.
|
|
115
|
+
jitter: Whether to add random jitter to delay to avoid thundering herd.
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
Delay in seconds before next retry.
|
|
119
|
+
"""
|
|
120
|
+
if backoff_factor == 0.0:
|
|
121
|
+
delay = initial_delay
|
|
122
|
+
else:
|
|
123
|
+
delay = initial_delay * (backoff_factor**retry_number)
|
|
124
|
+
|
|
125
|
+
# Cap at max_delay
|
|
126
|
+
delay = min(delay, max_delay)
|
|
127
|
+
|
|
128
|
+
if jitter and delay > 0:
|
|
129
|
+
jitter_amount = delay * 0.25 # ±25% jitter
|
|
130
|
+
delay += random.uniform(-jitter_amount, jitter_amount) # noqa: S311
|
|
131
|
+
# Ensure delay is not negative after jitter
|
|
132
|
+
delay = max(0, delay)
|
|
133
|
+
|
|
134
|
+
return delay
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Process-global default `TracePolicy` for agent middleware.
|
|
2
|
+
|
|
3
|
+
`TracePolicy` (from coco_graph) shapes what a middleware hook span records. This module
|
|
4
|
+
adds a process-wide default that individual middleware override via their own
|
|
5
|
+
`trace_policy` attribute. Set it once at startup with `configure_trace_policy`; the
|
|
6
|
+
default is read at span-creation time, so it applies to agents built before or after
|
|
7
|
+
the call.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
13
|
+
|
|
14
|
+
from coco_graph.types import TracePolicy
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
|
|
19
|
+
_DEFAULT_TRACE_POLICY: TracePolicy | None = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def configure_trace_policy(policy: TracePolicy | None) -> None:
|
|
23
|
+
"""Set the process-wide default `TracePolicy` for agent middleware hook spans.
|
|
24
|
+
|
|
25
|
+
Call once at startup. A middleware's own `trace_policy` overrides this wholesale
|
|
26
|
+
(no field-level merge). Pass `None` to clear the default.
|
|
27
|
+
"""
|
|
28
|
+
global _DEFAULT_TRACE_POLICY # noqa: PLW0603 - process-wide default, set once at startup
|
|
29
|
+
_DEFAULT_TRACE_POLICY = policy
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _resolve(mw_policy: TracePolicy | None) -> TracePolicy | None:
|
|
33
|
+
"""The effective policy: the middleware's own if set, else the global default."""
|
|
34
|
+
return mw_policy if mw_policy is not None else _DEFAULT_TRACE_POLICY
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _resolved_transform(
|
|
38
|
+
mw_policy: TracePolicy | None,
|
|
39
|
+
field: Literal["process_inputs", "process_outputs"],
|
|
40
|
+
) -> Callable[[Any], Any]:
|
|
41
|
+
"""A trace processor that resolves the effective policy at call time.
|
|
42
|
+
|
|
43
|
+
Reading the global at call time (not build time) means `configure_trace_policy` can
|
|
44
|
+
run after `create_agent` and still apply. Passes the value through unchanged when no
|
|
45
|
+
effective policy defines `field`.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def process(value: Any) -> Any:
|
|
49
|
+
effective = _resolve(mw_policy)
|
|
50
|
+
fn = getattr(effective, field) if effective is not None else None
|
|
51
|
+
return fn(value) if fn is not None else value
|
|
52
|
+
|
|
53
|
+
return process
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _node_trace_policy(mw_policy: TracePolicy | None) -> TracePolicy:
|
|
57
|
+
"""Build the resolver-backed `TracePolicy` for a middleware's node hook."""
|
|
58
|
+
return TracePolicy(
|
|
59
|
+
process_inputs=_resolved_transform(mw_policy, "process_inputs"),
|
|
60
|
+
process_outputs=_resolved_transform(mw_policy, "process_outputs"),
|
|
61
|
+
)
|