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,854 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import random
|
|
6
|
+
import sys
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
import weakref
|
|
10
|
+
from collections.abc import Awaitable, Callable, Sequence
|
|
11
|
+
from contextlib import suppress
|
|
12
|
+
from dataclasses import dataclass, replace
|
|
13
|
+
from datetime import datetime, timedelta, timezone
|
|
14
|
+
from typing import Any, Literal, NamedTuple
|
|
15
|
+
|
|
16
|
+
from coco_core.callbacks import BaseCallbackHandler
|
|
17
|
+
from coco_core.runnables import RunnableConfig
|
|
18
|
+
|
|
19
|
+
from coco_graph._internal._config import (
|
|
20
|
+
merge_configs,
|
|
21
|
+
patch_configurable,
|
|
22
|
+
recast_checkpoint_ns,
|
|
23
|
+
)
|
|
24
|
+
from coco_graph._internal._constants import (
|
|
25
|
+
CONF,
|
|
26
|
+
CONFIG_KEY_CALL,
|
|
27
|
+
CONFIG_KEY_CHECKPOINT_ID,
|
|
28
|
+
CONFIG_KEY_CHECKPOINT_NS,
|
|
29
|
+
CONFIG_KEY_RESUMING,
|
|
30
|
+
CONFIG_KEY_RUNTIME,
|
|
31
|
+
CONFIG_KEY_SEND,
|
|
32
|
+
CONFIG_KEY_STREAM,
|
|
33
|
+
CONFIG_KEY_TASK_ID,
|
|
34
|
+
CONFIG_KEY_THREAD_ID,
|
|
35
|
+
CONFIG_KEY_TIMED_ATTEMPT_OBSERVER,
|
|
36
|
+
NS_SEP,
|
|
37
|
+
)
|
|
38
|
+
from coco_graph._internal._runnable import create_task_in_config_context
|
|
39
|
+
from coco_graph._internal._timeout import sync_timeout_unsupported
|
|
40
|
+
from coco_graph.errors import (
|
|
41
|
+
GraphBubbleUp,
|
|
42
|
+
NodeCancelledError,
|
|
43
|
+
NodeTimeoutError,
|
|
44
|
+
ParentCommand,
|
|
45
|
+
)
|
|
46
|
+
from coco_graph.pregel.protocol import StreamProtocol
|
|
47
|
+
from coco_graph.runtime import ExecutionInfo, Runtime
|
|
48
|
+
from coco_graph.types import Command, PregelExecutableTask, RetryPolicy, TimeoutPolicy
|
|
49
|
+
|
|
50
|
+
logger = logging.getLogger(__name__)
|
|
51
|
+
SUPPORTS_EXC_NOTES = sys.version_info >= (3, 11)
|
|
52
|
+
# `asyncio.Task.cancelling()` was added in Python 3.11. It reports the number of
|
|
53
|
+
# pending cancel requests on the task: ``0`` means no external code asked us to
|
|
54
|
+
# cancel — so a ``CancelledError`` observed here was raised by the task body
|
|
55
|
+
# itself (the user's node) rather than by pregel cancelling sibling tasks.
|
|
56
|
+
SUPPORTS_TASK_CANCELLING = sys.version_info >= (3, 11)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _timeout_secs(value: float | timedelta) -> float:
|
|
60
|
+
return value.total_seconds() if isinstance(value, timedelta) else value
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True, slots=True)
|
|
64
|
+
class _ResolvedTimeout:
|
|
65
|
+
run_timeout_secs: float | None
|
|
66
|
+
idle_timeout_secs: float | None
|
|
67
|
+
refresh_on: Literal["auto", "heartbeat"] | None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _resolve_timeout(timeout: TimeoutPolicy) -> _ResolvedTimeout:
|
|
71
|
+
idle_timeout_secs = (
|
|
72
|
+
_timeout_secs(timeout.idle_timeout)
|
|
73
|
+
if timeout.idle_timeout is not None
|
|
74
|
+
else None
|
|
75
|
+
)
|
|
76
|
+
return _ResolvedTimeout(
|
|
77
|
+
run_timeout_secs=(
|
|
78
|
+
_timeout_secs(timeout.run_timeout)
|
|
79
|
+
if timeout.run_timeout is not None
|
|
80
|
+
else None
|
|
81
|
+
),
|
|
82
|
+
idle_timeout_secs=idle_timeout_secs,
|
|
83
|
+
refresh_on=timeout.refresh_on if idle_timeout_secs is not None else None,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class _AttemptContext(NamedTuple):
|
|
88
|
+
"""Immutable per-attempt metadata shared across start/progress/finish events.
|
|
89
|
+
|
|
90
|
+
Built once at attempt start and referenced (not copied) by every emitted
|
|
91
|
+
`_AttemptEvent`, so per-event allocation is just the small event wrapper.
|
|
92
|
+
|
|
93
|
+
Intentionally underscore-prefixed: this and `_AttemptEvent` are part of an
|
|
94
|
+
internal observer contract consumed by trustcoco-graph-server. Do not move to
|
|
95
|
+
`coco_graph.types` — server imports them by this path.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
task_id: str
|
|
99
|
+
task_name: str
|
|
100
|
+
attempt: int
|
|
101
|
+
run_id: str | None
|
|
102
|
+
thread_id: str | None
|
|
103
|
+
checkpoint_ns: str | None
|
|
104
|
+
started_at: datetime
|
|
105
|
+
run_timeout_secs: float | None
|
|
106
|
+
idle_timeout_secs: float | None
|
|
107
|
+
refresh_on: Literal["auto", "heartbeat"] | None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass(frozen=True, slots=True)
|
|
111
|
+
class _AttemptEvent:
|
|
112
|
+
"""One lifecycle event for a timed attempt.
|
|
113
|
+
|
|
114
|
+
Holds a reference to the shared `_AttemptContext` and the event-specific
|
|
115
|
+
fields. The observer must treat this and `context` as read-only — they
|
|
116
|
+
are reused across all events for the same attempt.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
context: _AttemptContext
|
|
120
|
+
event: Literal["start", "progress", "finish"]
|
|
121
|
+
progress_at: datetime | None = None
|
|
122
|
+
finished_at: datetime | None = None
|
|
123
|
+
status: Literal["success", "error"] | None = None
|
|
124
|
+
error_type: str | None = None
|
|
125
|
+
error_message: str | None = None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class _TimedAttemptScope:
|
|
129
|
+
"""Guarded-config window for timed attempts.
|
|
130
|
+
|
|
131
|
+
The wrapped config marks writes, stream events, runtime stream writer calls,
|
|
132
|
+
child task scheduling, and any Coco callback event emitted under the
|
|
133
|
+
node's run as observable progress when `refresh_on="auto"`.
|
|
134
|
+
`runtime.heartbeat()` exposes a manual progress signal for work that doesn't
|
|
135
|
+
otherwise emit any of these, and is the only progress signal when
|
|
136
|
+
`refresh_on="heartbeat"`.
|
|
137
|
+
Guarded writes are serialized with `close()` so cancelled background tasks
|
|
138
|
+
cannot persist writes past the timeout boundary. Stream/custom output is
|
|
139
|
+
best-effort: it is dropped after close is observed, but callbacks run outside
|
|
140
|
+
the lock because they may contain arbitrary user/runtime code.
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
__slots__ = (
|
|
144
|
+
"__weakref__",
|
|
145
|
+
"_active",
|
|
146
|
+
"_last_progress",
|
|
147
|
+
"_last_progress_emit",
|
|
148
|
+
"_lock",
|
|
149
|
+
"_on_progress",
|
|
150
|
+
"_progress_min_interval",
|
|
151
|
+
"_refresh_on",
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
def __init__(
|
|
155
|
+
self,
|
|
156
|
+
on_progress: Callable[[], None] | None = None,
|
|
157
|
+
progress_min_interval: float = 0.0,
|
|
158
|
+
refresh_on: Literal["auto", "heartbeat"] | None = None,
|
|
159
|
+
) -> None:
|
|
160
|
+
self._active = True
|
|
161
|
+
self._last_progress = time.monotonic()
|
|
162
|
+
self._lock = threading.Lock()
|
|
163
|
+
self._on_progress = on_progress
|
|
164
|
+
self._progress_min_interval = progress_min_interval
|
|
165
|
+
self._refresh_on = refresh_on
|
|
166
|
+
# `-inf` so the first touch always passes the rate-limit gate.
|
|
167
|
+
self._last_progress_emit: float = float("-inf")
|
|
168
|
+
|
|
169
|
+
def wrap_config(self, config: RunnableConfig) -> RunnableConfig:
|
|
170
|
+
configurable = config.get(CONF, {})
|
|
171
|
+
patch: dict[str, Any] = {}
|
|
172
|
+
if (send := configurable.get(CONFIG_KEY_SEND)) is not None:
|
|
173
|
+
patch[CONFIG_KEY_SEND] = self._guard_send(send)
|
|
174
|
+
if (stream := configurable.get(CONFIG_KEY_STREAM)) is not None:
|
|
175
|
+
patch[CONFIG_KEY_STREAM] = self._guard_stream(stream)
|
|
176
|
+
if (call := configurable.get(CONFIG_KEY_CALL)) is not None:
|
|
177
|
+
patch[CONFIG_KEY_CALL] = self._guard_call(call)
|
|
178
|
+
if isinstance(runtime := configurable.get(CONFIG_KEY_RUNTIME), Runtime):
|
|
179
|
+
if self._refresh_on is not None:
|
|
180
|
+
patch[CONFIG_KEY_RUNTIME] = runtime.override(
|
|
181
|
+
stream_writer=self._guard_stream_writer(runtime.stream_writer),
|
|
182
|
+
heartbeat=self.touch,
|
|
183
|
+
)
|
|
184
|
+
else:
|
|
185
|
+
patch[CONFIG_KEY_RUNTIME] = runtime.override(
|
|
186
|
+
stream_writer=self._guard_stream_writer(runtime.stream_writer)
|
|
187
|
+
)
|
|
188
|
+
new_config = patch_configurable(config, patch) if patch else config
|
|
189
|
+
if self._refresh_on == "auto":
|
|
190
|
+
return merge_configs(
|
|
191
|
+
new_config, {"callbacks": [_IdleProgressCallbackHandler(self)]}
|
|
192
|
+
)
|
|
193
|
+
return new_config
|
|
194
|
+
|
|
195
|
+
def touch(self) -> None:
|
|
196
|
+
# Avoid locking this hot progress path. We accept a small race window in
|
|
197
|
+
# timestamp ordering because idle_timeout is expected to be coarse compared
|
|
198
|
+
# with scheduler/thread timing.
|
|
199
|
+
now = time.monotonic()
|
|
200
|
+
self._last_progress = now
|
|
201
|
+
if self._on_progress is None:
|
|
202
|
+
return
|
|
203
|
+
# Best-effort rate limit: a benign race may emit a duplicate progress
|
|
204
|
+
# event under heavy concurrency, which observers must already tolerate
|
|
205
|
+
# (callbacks fire from arbitrary threads).
|
|
206
|
+
if now - self._last_progress_emit < self._progress_min_interval:
|
|
207
|
+
return
|
|
208
|
+
self._last_progress_emit = now
|
|
209
|
+
self._on_progress()
|
|
210
|
+
|
|
211
|
+
def close(self) -> None:
|
|
212
|
+
with self._lock:
|
|
213
|
+
self._active = False
|
|
214
|
+
|
|
215
|
+
async def wait_for_idle_timeout(self, idle_timeout_s: float) -> None:
|
|
216
|
+
while True:
|
|
217
|
+
with self._lock:
|
|
218
|
+
if not self._active:
|
|
219
|
+
return
|
|
220
|
+
remaining = self._last_progress + idle_timeout_s - time.monotonic()
|
|
221
|
+
if remaining <= 0:
|
|
222
|
+
raise asyncio.TimeoutError
|
|
223
|
+
await asyncio.sleep(remaining)
|
|
224
|
+
|
|
225
|
+
def _guard_send(
|
|
226
|
+
self, send: Callable[[Sequence[tuple[str, Any]]], None]
|
|
227
|
+
) -> Callable[[Sequence[tuple[str, Any]]], None]:
|
|
228
|
+
def guarded_send(writes: Sequence[tuple[str, Any]]) -> None:
|
|
229
|
+
with self._lock:
|
|
230
|
+
if self._active:
|
|
231
|
+
if writes and self._refresh_on == "auto":
|
|
232
|
+
self._last_progress = time.monotonic()
|
|
233
|
+
send(writes)
|
|
234
|
+
|
|
235
|
+
return guarded_send
|
|
236
|
+
|
|
237
|
+
def _guard_stream(self, stream: StreamProtocol) -> StreamProtocol:
|
|
238
|
+
# No lock: stream callbacks fire from the event loop only, so the
|
|
239
|
+
# active-check + write happen atomically between awaits.
|
|
240
|
+
def guarded_stream(chunk: tuple[tuple[str, ...], str, Any]) -> None:
|
|
241
|
+
if not self._active:
|
|
242
|
+
return
|
|
243
|
+
if self._refresh_on == "auto":
|
|
244
|
+
self._last_progress = time.monotonic()
|
|
245
|
+
stream(chunk)
|
|
246
|
+
|
|
247
|
+
return StreamProtocol(guarded_stream, stream.modes)
|
|
248
|
+
|
|
249
|
+
def _guard_call(self, call: Callable[..., Any]) -> Callable[..., Any]:
|
|
250
|
+
# No lock: child-task scheduling happens from the event loop only.
|
|
251
|
+
def guarded_call(*args: Any, **kwargs: Any) -> Any:
|
|
252
|
+
if not self._active:
|
|
253
|
+
raise asyncio.CancelledError
|
|
254
|
+
if self._refresh_on == "auto":
|
|
255
|
+
self._last_progress = time.monotonic()
|
|
256
|
+
return call(*args, **kwargs)
|
|
257
|
+
|
|
258
|
+
return guarded_call
|
|
259
|
+
|
|
260
|
+
def _guard_stream_writer(
|
|
261
|
+
self, stream_writer: Callable[[Any], None]
|
|
262
|
+
) -> Callable[[Any], None]:
|
|
263
|
+
def guarded_stream_writer(chunk: Any) -> None:
|
|
264
|
+
with self._lock:
|
|
265
|
+
if not self._active:
|
|
266
|
+
return
|
|
267
|
+
if self._refresh_on == "auto":
|
|
268
|
+
self._last_progress = time.monotonic()
|
|
269
|
+
stream_writer(chunk)
|
|
270
|
+
|
|
271
|
+
return guarded_stream_writer
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
class _IdleProgressCallbackHandler(BaseCallbackHandler):
|
|
275
|
+
"""Resets the idle timeout clock on any Coco callback event.
|
|
276
|
+
|
|
277
|
+
Inherits via `config["callbacks"]`, so it sees only events emitted by
|
|
278
|
+
runs descended from the node's attempt — sibling nodes do not bleed
|
|
279
|
+
through. Holds the scope by weakref so a child manager that outlives
|
|
280
|
+
the attempt cannot keep the scope alive.
|
|
281
|
+
"""
|
|
282
|
+
|
|
283
|
+
# Run inline so progress is recorded in callback emission order;
|
|
284
|
+
# thread-pool dispatch would introduce extra reordering.
|
|
285
|
+
run_inline = True
|
|
286
|
+
|
|
287
|
+
def __init__(self, scope: _TimedAttemptScope) -> None:
|
|
288
|
+
self._scope_ref = weakref.ref(scope)
|
|
289
|
+
|
|
290
|
+
def _touch(self, *args: Any, **kwargs: Any) -> None:
|
|
291
|
+
if (scope := self._scope_ref()) is not None:
|
|
292
|
+
scope.touch()
|
|
293
|
+
|
|
294
|
+
on_llm_start = _touch
|
|
295
|
+
on_chat_model_start = _touch
|
|
296
|
+
on_llm_new_token = _touch
|
|
297
|
+
on_llm_end = _touch
|
|
298
|
+
on_llm_error = _touch
|
|
299
|
+
on_chain_start = _touch
|
|
300
|
+
on_chain_end = _touch
|
|
301
|
+
on_chain_error = _touch
|
|
302
|
+
on_tool_start = _touch
|
|
303
|
+
on_tool_end = _touch
|
|
304
|
+
on_tool_error = _touch
|
|
305
|
+
on_retriever_start = _touch
|
|
306
|
+
on_retriever_end = _touch
|
|
307
|
+
on_retriever_error = _touch
|
|
308
|
+
on_agent_action = _touch
|
|
309
|
+
on_agent_finish = _touch
|
|
310
|
+
on_text = _touch
|
|
311
|
+
on_retry = _touch
|
|
312
|
+
on_custom_event = _touch
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _is_user_raised_cancelled() -> bool:
|
|
316
|
+
"""Return True if the in-flight ``CancelledError`` came from the task body.
|
|
317
|
+
|
|
318
|
+
Pregel cancels sibling tasks via ``task.cancel()`` when a peer fails, which
|
|
319
|
+
increments ``asyncio.Task.cancelling()`` on the target before the cancel
|
|
320
|
+
actually fires. A user node that calls ``raise asyncio.CancelledError()``
|
|
321
|
+
from inside its own body raises while ``cancelling() == 0``, which is the
|
|
322
|
+
signal we use to convert the exception into a regular
|
|
323
|
+
:class:`NodeCancelledError`.
|
|
324
|
+
|
|
325
|
+
Returns ``False`` when we can't tell (``cancelling()`` unavailable, or no
|
|
326
|
+
current task — neither should happen in practice from ``arun_with_retry``)
|
|
327
|
+
so framework-initiated cancellation continues to propagate unchanged.
|
|
328
|
+
"""
|
|
329
|
+
if not SUPPORTS_TASK_CANCELLING:
|
|
330
|
+
return False
|
|
331
|
+
current = asyncio.current_task()
|
|
332
|
+
if current is None:
|
|
333
|
+
return False
|
|
334
|
+
return current.cancelling() == 0
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _drain_cancelled(task: asyncio.Task[Any]) -> None:
|
|
338
|
+
# Mark the abandoned task's exception as retrieved so asyncio doesn't log it.
|
|
339
|
+
with suppress(asyncio.CancelledError):
|
|
340
|
+
task.exception()
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _start_timed_attempt(
|
|
344
|
+
task: PregelExecutableTask, config: RunnableConfig, timeout: _ResolvedTimeout
|
|
345
|
+
) -> _AttemptContext | None:
|
|
346
|
+
configurable = config.get(CONF, {})
|
|
347
|
+
callback = configurable.get(CONFIG_KEY_TIMED_ATTEMPT_OBSERVER)
|
|
348
|
+
if callback is None:
|
|
349
|
+
return None
|
|
350
|
+
runtime = configurable.get(CONFIG_KEY_RUNTIME)
|
|
351
|
+
execution_info = runtime.execution_info if isinstance(runtime, Runtime) else None
|
|
352
|
+
context = _AttemptContext(
|
|
353
|
+
task_id=task.id,
|
|
354
|
+
task_name=task.name,
|
|
355
|
+
attempt=execution_info.node_attempt if execution_info is not None else 1,
|
|
356
|
+
run_id=execution_info.run_id if execution_info is not None else None,
|
|
357
|
+
thread_id=execution_info.thread_id if execution_info is not None else None,
|
|
358
|
+
checkpoint_ns=(
|
|
359
|
+
execution_info.checkpoint_ns if execution_info is not None else None
|
|
360
|
+
),
|
|
361
|
+
started_at=datetime.now(timezone.utc),
|
|
362
|
+
run_timeout_secs=timeout.run_timeout_secs,
|
|
363
|
+
idle_timeout_secs=timeout.idle_timeout_secs,
|
|
364
|
+
refresh_on=timeout.refresh_on,
|
|
365
|
+
)
|
|
366
|
+
_dispatch_observer(callback, _AttemptEvent(context=context, event="start"))
|
|
367
|
+
return context
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _finish_timed_attempt(
|
|
371
|
+
config: RunnableConfig,
|
|
372
|
+
context: _AttemptContext | None,
|
|
373
|
+
error: BaseException | None = None,
|
|
374
|
+
) -> None:
|
|
375
|
+
if context is None:
|
|
376
|
+
return
|
|
377
|
+
callback = config.get(CONF, {}).get(CONFIG_KEY_TIMED_ATTEMPT_OBSERVER)
|
|
378
|
+
if callback is None:
|
|
379
|
+
return
|
|
380
|
+
_dispatch_observer(
|
|
381
|
+
callback,
|
|
382
|
+
_AttemptEvent(
|
|
383
|
+
context=context,
|
|
384
|
+
event="finish",
|
|
385
|
+
finished_at=datetime.now(timezone.utc),
|
|
386
|
+
status="error" if error is not None else "success",
|
|
387
|
+
error_type=type(error).__name__ if error is not None else None,
|
|
388
|
+
error_message=str(error) if error is not None else None,
|
|
389
|
+
),
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _emit_progress(
|
|
394
|
+
callback: Callable[[_AttemptEvent], None],
|
|
395
|
+
context: _AttemptContext,
|
|
396
|
+
) -> None:
|
|
397
|
+
_dispatch_observer(
|
|
398
|
+
callback,
|
|
399
|
+
_AttemptEvent(
|
|
400
|
+
context=context,
|
|
401
|
+
event="progress",
|
|
402
|
+
progress_at=datetime.now(timezone.utc),
|
|
403
|
+
),
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _dispatch_observer(
|
|
408
|
+
callback: Callable[[_AttemptEvent], None],
|
|
409
|
+
event: _AttemptEvent,
|
|
410
|
+
) -> None:
|
|
411
|
+
try:
|
|
412
|
+
callback(event)
|
|
413
|
+
except Exception:
|
|
414
|
+
logger.warning("Timed attempt observer failed", exc_info=True)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
async def _run_timeout_watchdog(run_timeout_s: float) -> None:
|
|
418
|
+
await asyncio.sleep(run_timeout_s)
|
|
419
|
+
raise asyncio.TimeoutError
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
async def _arun_with_timeout(
|
|
423
|
+
task: PregelExecutableTask,
|
|
424
|
+
config: RunnableConfig,
|
|
425
|
+
timeout: _ResolvedTimeout,
|
|
426
|
+
attempt_ctx: _AttemptContext | None,
|
|
427
|
+
*,
|
|
428
|
+
stream: bool,
|
|
429
|
+
) -> Any:
|
|
430
|
+
run_timeout_s = timeout.run_timeout_secs
|
|
431
|
+
idle_timeout_s = timeout.idle_timeout_secs
|
|
432
|
+
on_progress: Callable[[], None] | None = None
|
|
433
|
+
if attempt_ctx is not None:
|
|
434
|
+
callback = config.get(CONF, {}).get(CONFIG_KEY_TIMED_ATTEMPT_OBSERVER)
|
|
435
|
+
if callback is not None and idle_timeout_s is not None:
|
|
436
|
+
on_progress = lambda: _emit_progress(callback, attempt_ctx) # noqa: E731
|
|
437
|
+
scope = _TimedAttemptScope(
|
|
438
|
+
on_progress=on_progress,
|
|
439
|
+
# Cap progress emission at ~4 events per idle window so token-rate
|
|
440
|
+
# callbacks don't flood the observer.
|
|
441
|
+
progress_min_interval=idle_timeout_s / 4 if idle_timeout_s is not None else 0.0,
|
|
442
|
+
refresh_on=timeout.refresh_on,
|
|
443
|
+
)
|
|
444
|
+
scoped_config = scope.wrap_config(config)
|
|
445
|
+
start = time.monotonic()
|
|
446
|
+
if stream:
|
|
447
|
+
# Yielded chunks count as progress only under `refresh_on="auto"`.
|
|
448
|
+
# `refresh_on="heartbeat"` is the strict mode where only explicit
|
|
449
|
+
# `runtime.heartbeat()` calls reset the idle clock.
|
|
450
|
+
async def run() -> Any:
|
|
451
|
+
async for _ in task.proc.astream(task.input, scoped_config):
|
|
452
|
+
if timeout.refresh_on == "auto":
|
|
453
|
+
scope.touch()
|
|
454
|
+
|
|
455
|
+
else:
|
|
456
|
+
|
|
457
|
+
async def run() -> Any:
|
|
458
|
+
return await task.proc.ainvoke(task.input, scoped_config)
|
|
459
|
+
|
|
460
|
+
bg = create_task_in_config_context(run, scoped_config)
|
|
461
|
+
watchdogs: dict[asyncio.Task[None], Literal["idle", "run"]] = {}
|
|
462
|
+
if idle_timeout_s is not None:
|
|
463
|
+
watchdogs[asyncio.create_task(scope.wait_for_idle_timeout(idle_timeout_s))] = (
|
|
464
|
+
"idle"
|
|
465
|
+
)
|
|
466
|
+
if run_timeout_s is not None:
|
|
467
|
+
watchdogs[asyncio.create_task(_run_timeout_watchdog(run_timeout_s))] = "run"
|
|
468
|
+
try:
|
|
469
|
+
done, _ = await asyncio.wait(
|
|
470
|
+
{bg, *watchdogs}, return_when=asyncio.FIRST_COMPLETED
|
|
471
|
+
)
|
|
472
|
+
if bg in done:
|
|
473
|
+
# Task completed in time.
|
|
474
|
+
for watchdog in watchdogs:
|
|
475
|
+
watchdog.cancel()
|
|
476
|
+
# FIRST_COMPLETED can return both; a watchdog may have
|
|
477
|
+
# already raised TimeoutError before we cancelled it.
|
|
478
|
+
for watchdog in watchdogs:
|
|
479
|
+
with suppress(asyncio.CancelledError, asyncio.TimeoutError):
|
|
480
|
+
await watchdog
|
|
481
|
+
return await bg
|
|
482
|
+
# bg was not in `done`, so every member of `done` is one of our
|
|
483
|
+
# watchdogs. Only a watchdog's TimeoutError converts to
|
|
484
|
+
# NodeTimeoutError; any TimeoutError raised by the proc itself
|
|
485
|
+
# propagates unchanged.
|
|
486
|
+
for watchdog in done:
|
|
487
|
+
kind = watchdogs[watchdog]
|
|
488
|
+
try:
|
|
489
|
+
await watchdog
|
|
490
|
+
except asyncio.TimeoutError as exc:
|
|
491
|
+
elapsed = time.monotonic() - start
|
|
492
|
+
scope.close()
|
|
493
|
+
task.writes.clear()
|
|
494
|
+
bg.cancel()
|
|
495
|
+
bg.add_done_callback(_drain_cancelled)
|
|
496
|
+
raise NodeTimeoutError(
|
|
497
|
+
task.name,
|
|
498
|
+
elapsed,
|
|
499
|
+
kind=kind,
|
|
500
|
+
idle_timeout=idle_timeout_s,
|
|
501
|
+
run_timeout=run_timeout_s,
|
|
502
|
+
) from exc
|
|
503
|
+
raise RuntimeError(
|
|
504
|
+
f"{kind} timeout watchdog completed without raising TimeoutError"
|
|
505
|
+
)
|
|
506
|
+
raise RuntimeError("timeout wait completed without task or watchdog")
|
|
507
|
+
except asyncio.CancelledError:
|
|
508
|
+
scope.close()
|
|
509
|
+
bg.cancel()
|
|
510
|
+
for watchdog in watchdogs:
|
|
511
|
+
watchdog.cancel()
|
|
512
|
+
bg.add_done_callback(_drain_cancelled)
|
|
513
|
+
raise
|
|
514
|
+
finally:
|
|
515
|
+
scope.close()
|
|
516
|
+
for watchdog in watchdogs:
|
|
517
|
+
watchdog.cancel()
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _ensure_execution_info(
|
|
521
|
+
runtime: Runtime, config: RunnableConfig, task: PregelExecutableTask
|
|
522
|
+
) -> Runtime:
|
|
523
|
+
"""Ensure runtime has execution_info, creating one from config if needed.
|
|
524
|
+
|
|
525
|
+
In the distributed runtime (CocoGraph Platform), tasks are prepared by the
|
|
526
|
+
server and deserialized in the executor, bypassing the OSS _algo.py code
|
|
527
|
+
that normally creates ExecutionInfo. This function fills in execution_info
|
|
528
|
+
from the task config when it's missing.
|
|
529
|
+
"""
|
|
530
|
+
if runtime.execution_info is not None:
|
|
531
|
+
return runtime
|
|
532
|
+
configurable = config.get(CONF, {})
|
|
533
|
+
return runtime.override(
|
|
534
|
+
execution_info=ExecutionInfo(
|
|
535
|
+
checkpoint_id=configurable.get(CONFIG_KEY_CHECKPOINT_ID) or "",
|
|
536
|
+
checkpoint_ns=configurable.get(CONFIG_KEY_CHECKPOINT_NS) or "",
|
|
537
|
+
task_id=configurable.get(CONFIG_KEY_TASK_ID) or task.id,
|
|
538
|
+
thread_id=configurable.get(CONFIG_KEY_THREAD_ID),
|
|
539
|
+
run_id=str(rid) if (rid := config.get("run_id")) else None,
|
|
540
|
+
),
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _checkpoint_ns_for_parent_command(ns: str) -> str:
|
|
545
|
+
"""Return the checkpoint namespace for the parent graph.
|
|
546
|
+
|
|
547
|
+
The checkpoint namespace is a `|`-separated path. Each segment is usually
|
|
548
|
+
of the form `name:task_id` (e.g. `parent_first:<uuid>|node:<uuid>`), but the
|
|
549
|
+
runtime may also insert a purely-numeric segment (e.g. `|1`) to disambiguate
|
|
550
|
+
concurrent tasks (e.g. `parent_first:<uuid>|1|node:<uuid>`).
|
|
551
|
+
|
|
552
|
+
Numeric segments are not real path levels, so we drop them before computing
|
|
553
|
+
the parent namespace.
|
|
554
|
+
"""
|
|
555
|
+
|
|
556
|
+
parts = ns.split(NS_SEP)
|
|
557
|
+
|
|
558
|
+
# Drop any trailing numeric selectors for the current frame (e.g. `...|node:<id>|1`).
|
|
559
|
+
while parts and parts[-1].isdigit():
|
|
560
|
+
parts.pop()
|
|
561
|
+
|
|
562
|
+
# Drop the current frame segment itself (e.g. the `node:<id>`).
|
|
563
|
+
if parts:
|
|
564
|
+
parts.pop()
|
|
565
|
+
|
|
566
|
+
# Drop any trailing numeric selectors for the parent frame (e.g. `...|1|node:<id>`).
|
|
567
|
+
while parts and parts[-1].isdigit():
|
|
568
|
+
parts.pop()
|
|
569
|
+
|
|
570
|
+
return NS_SEP.join(parts)
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def run_with_retry(
|
|
574
|
+
task: PregelExecutableTask,
|
|
575
|
+
retry_policy: Sequence[RetryPolicy] | None,
|
|
576
|
+
configurable: dict[str, Any] | None = None,
|
|
577
|
+
) -> None:
|
|
578
|
+
"""Run a task with retries."""
|
|
579
|
+
retry_policy = task.retry_policy or retry_policy
|
|
580
|
+
if task.timeout is not None:
|
|
581
|
+
# `validate_timeout_supported` catches sync nodes at compile time;
|
|
582
|
+
# this is a runtime safety net for paths that may bypass that validation.
|
|
583
|
+
raise sync_timeout_unsupported(task.name)
|
|
584
|
+
attempts = 0
|
|
585
|
+
node_first_attempt_time = time.time()
|
|
586
|
+
config = task.config
|
|
587
|
+
if configurable is not None:
|
|
588
|
+
config = patch_configurable(config, configurable)
|
|
589
|
+
runtime = config.get(CONF, {}).get(CONFIG_KEY_RUNTIME)
|
|
590
|
+
if isinstance(runtime, Runtime):
|
|
591
|
+
runtime = _ensure_execution_info(runtime, config, task)
|
|
592
|
+
config = patch_configurable(
|
|
593
|
+
config,
|
|
594
|
+
{
|
|
595
|
+
CONFIG_KEY_RUNTIME: runtime.patch_execution_info(
|
|
596
|
+
node_first_attempt_time=node_first_attempt_time,
|
|
597
|
+
)
|
|
598
|
+
},
|
|
599
|
+
)
|
|
600
|
+
while True:
|
|
601
|
+
runtime = config.get(CONF, {}).get(CONFIG_KEY_RUNTIME)
|
|
602
|
+
if isinstance(runtime, Runtime):
|
|
603
|
+
config = patch_configurable(
|
|
604
|
+
config,
|
|
605
|
+
{
|
|
606
|
+
CONFIG_KEY_RUNTIME: runtime.patch_execution_info(
|
|
607
|
+
# node_attempt is execution count (1-indexed): 1 on first run,
|
|
608
|
+
# then 2, 3, ... on subsequent retries.
|
|
609
|
+
node_attempt=attempts + 1,
|
|
610
|
+
)
|
|
611
|
+
},
|
|
612
|
+
)
|
|
613
|
+
try:
|
|
614
|
+
# clear any writes from previous attempts
|
|
615
|
+
task.writes.clear()
|
|
616
|
+
# run the task
|
|
617
|
+
return task.proc.invoke(task.input, config)
|
|
618
|
+
except ParentCommand as exc:
|
|
619
|
+
ns: str = config[CONF][CONFIG_KEY_CHECKPOINT_NS]
|
|
620
|
+
cmd = exc.args[0]
|
|
621
|
+
# strip task_ids from namespace for comparison (ns format: "node1|node2:task_id")
|
|
622
|
+
if cmd.graph in (ns, recast_checkpoint_ns(ns), task.name):
|
|
623
|
+
# this command is for the current graph, handle it
|
|
624
|
+
for w in task.writers:
|
|
625
|
+
w.invoke(cmd, config)
|
|
626
|
+
break
|
|
627
|
+
elif cmd.graph == Command.PARENT:
|
|
628
|
+
# this command is for the parent graph, assign it to the parent.
|
|
629
|
+
exc.args = (replace(cmd, graph=_checkpoint_ns_for_parent_command(ns)),)
|
|
630
|
+
# bubble up
|
|
631
|
+
raise
|
|
632
|
+
except GraphBubbleUp:
|
|
633
|
+
# if interrupted, end
|
|
634
|
+
raise
|
|
635
|
+
except asyncio.CancelledError as exc:
|
|
636
|
+
# A sync node has no asyncio context, so any ``CancelledError`` that
|
|
637
|
+
# reaches here was raised by the node body itself. Surface it as a
|
|
638
|
+
# regular exception so the pregel runner panics the run instead of
|
|
639
|
+
# treating the task as a silent tear-down (LSD-1507).
|
|
640
|
+
raise NodeCancelledError(task.name) from exc
|
|
641
|
+
except Exception as exc:
|
|
642
|
+
if SUPPORTS_EXC_NOTES:
|
|
643
|
+
exc.add_note(f"During task with name '{task.name}' and id '{task.id}'")
|
|
644
|
+
if not retry_policy:
|
|
645
|
+
raise
|
|
646
|
+
|
|
647
|
+
# Check which retry policy applies to this exception
|
|
648
|
+
matching_policy = None
|
|
649
|
+
for policy in retry_policy:
|
|
650
|
+
if _should_retry_on(policy, exc):
|
|
651
|
+
matching_policy = policy
|
|
652
|
+
break
|
|
653
|
+
|
|
654
|
+
if not matching_policy:
|
|
655
|
+
raise
|
|
656
|
+
|
|
657
|
+
# attempts tracks failed tries only; it increments after a failure.
|
|
658
|
+
attempts += 1
|
|
659
|
+
# check if we should give up
|
|
660
|
+
if attempts >= matching_policy.max_attempts:
|
|
661
|
+
raise
|
|
662
|
+
# sleep before retrying
|
|
663
|
+
interval = matching_policy.initial_interval
|
|
664
|
+
# Apply backoff factor based on attempt count
|
|
665
|
+
interval = min(
|
|
666
|
+
matching_policy.max_interval,
|
|
667
|
+
interval * (matching_policy.backoff_factor ** (attempts - 1)),
|
|
668
|
+
)
|
|
669
|
+
|
|
670
|
+
# Apply jitter if configured
|
|
671
|
+
sleep_time = (
|
|
672
|
+
interval + random.uniform(0, 1) if matching_policy.jitter else interval
|
|
673
|
+
)
|
|
674
|
+
time.sleep(sleep_time)
|
|
675
|
+
|
|
676
|
+
# log the retry
|
|
677
|
+
logger.info(
|
|
678
|
+
f"Retrying task {task.name} after {sleep_time:.2f} seconds (attempt {attempts}) after {exc.__class__.__name__} {exc}",
|
|
679
|
+
exc_info=exc,
|
|
680
|
+
)
|
|
681
|
+
# signal subgraphs to resume (if available)
|
|
682
|
+
config = patch_configurable(config, {CONFIG_KEY_RESUMING: True})
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
async def arun_with_retry(
|
|
686
|
+
task: PregelExecutableTask,
|
|
687
|
+
retry_policy: Sequence[RetryPolicy] | None,
|
|
688
|
+
stream: bool = False,
|
|
689
|
+
match_cached_writes: Callable[[], Awaitable[Sequence[PregelExecutableTask]]]
|
|
690
|
+
| None = None,
|
|
691
|
+
configurable: dict[str, Any] | None = None,
|
|
692
|
+
) -> None:
|
|
693
|
+
"""Run a task asynchronously with retries."""
|
|
694
|
+
retry_policy = task.retry_policy or retry_policy
|
|
695
|
+
resolved_timeout = (
|
|
696
|
+
_resolve_timeout(task.timeout) if task.timeout is not None else None
|
|
697
|
+
)
|
|
698
|
+
attempts = 0
|
|
699
|
+
node_first_attempt_time = time.time()
|
|
700
|
+
config = task.config
|
|
701
|
+
if configurable is not None:
|
|
702
|
+
config = patch_configurable(config, configurable)
|
|
703
|
+
runtime = config.get(CONF, {}).get(CONFIG_KEY_RUNTIME)
|
|
704
|
+
if isinstance(runtime, Runtime):
|
|
705
|
+
runtime = _ensure_execution_info(runtime, config, task)
|
|
706
|
+
config = patch_configurable(
|
|
707
|
+
config,
|
|
708
|
+
{
|
|
709
|
+
CONFIG_KEY_RUNTIME: runtime.patch_execution_info(
|
|
710
|
+
node_first_attempt_time=node_first_attempt_time,
|
|
711
|
+
)
|
|
712
|
+
},
|
|
713
|
+
)
|
|
714
|
+
if match_cached_writes is not None and task.cache_key is not None:
|
|
715
|
+
for t in await match_cached_writes():
|
|
716
|
+
if t is task:
|
|
717
|
+
# if the task is already cached, return
|
|
718
|
+
return
|
|
719
|
+
while True:
|
|
720
|
+
runtime = config.get(CONF, {}).get(CONFIG_KEY_RUNTIME)
|
|
721
|
+
if isinstance(runtime, Runtime):
|
|
722
|
+
config = patch_configurable(
|
|
723
|
+
config,
|
|
724
|
+
{
|
|
725
|
+
CONFIG_KEY_RUNTIME: runtime.patch_execution_info(
|
|
726
|
+
# node_attempt is execution count (1-indexed): 1 on first run,
|
|
727
|
+
# then 2, 3, ... on subsequent retries.
|
|
728
|
+
node_attempt=attempts + 1,
|
|
729
|
+
)
|
|
730
|
+
},
|
|
731
|
+
)
|
|
732
|
+
attempt_ctx = (
|
|
733
|
+
_start_timed_attempt(task, config, resolved_timeout)
|
|
734
|
+
if resolved_timeout is not None
|
|
735
|
+
else None
|
|
736
|
+
)
|
|
737
|
+
try:
|
|
738
|
+
task.writes.clear()
|
|
739
|
+
if resolved_timeout is None:
|
|
740
|
+
if stream:
|
|
741
|
+
async for _ in task.proc.astream(task.input, config):
|
|
742
|
+
pass
|
|
743
|
+
break
|
|
744
|
+
return await task.proc.ainvoke(task.input, config)
|
|
745
|
+
result = await _arun_with_timeout(
|
|
746
|
+
task, config, resolved_timeout, attempt_ctx, stream=stream
|
|
747
|
+
)
|
|
748
|
+
_finish_timed_attempt(config, attempt_ctx)
|
|
749
|
+
if stream:
|
|
750
|
+
# if successful, end
|
|
751
|
+
break
|
|
752
|
+
return result
|
|
753
|
+
except ParentCommand as exc:
|
|
754
|
+
ns: str = config[CONF][CONFIG_KEY_CHECKPOINT_NS]
|
|
755
|
+
cmd = exc.args[0]
|
|
756
|
+
# strip task_ids from namespace for comparison (ns format: "node1|node2:task_id")
|
|
757
|
+
if cmd.graph in (ns, recast_checkpoint_ns(ns), task.name):
|
|
758
|
+
try:
|
|
759
|
+
# this command is for the current graph, handle it
|
|
760
|
+
for w in task.writers:
|
|
761
|
+
w.invoke(cmd, config)
|
|
762
|
+
except Exception as writer_exc:
|
|
763
|
+
_finish_timed_attempt(config, attempt_ctx, writer_exc)
|
|
764
|
+
raise
|
|
765
|
+
_finish_timed_attempt(config, attempt_ctx)
|
|
766
|
+
break
|
|
767
|
+
elif cmd.graph == Command.PARENT:
|
|
768
|
+
# this command is for the parent graph, assign it to the parent.
|
|
769
|
+
exc.args = (replace(cmd, graph=_checkpoint_ns_for_parent_command(ns)),)
|
|
770
|
+
_finish_timed_attempt(config, attempt_ctx)
|
|
771
|
+
# bubble up the exception to the parent graph
|
|
772
|
+
raise
|
|
773
|
+
except GraphBubbleUp:
|
|
774
|
+
# if interrupted, end
|
|
775
|
+
_finish_timed_attempt(config, attempt_ctx)
|
|
776
|
+
raise
|
|
777
|
+
except asyncio.CancelledError as exc:
|
|
778
|
+
# ``CancelledError`` reaches us in two very different shapes:
|
|
779
|
+
# 1. Pregel cancelled this task because a sibling failed
|
|
780
|
+
# (``asyncio.Task.cancelling() >= 1``). The framework already
|
|
781
|
+
# knows the run is failing and we must let cancellation
|
|
782
|
+
# propagate so the watchdog/cleanup code in the runner sees a
|
|
783
|
+
# cancelled future.
|
|
784
|
+
# 2. The node body itself raised ``asyncio.CancelledError`` (
|
|
785
|
+
# ``cancelling() == 0``). The runner would otherwise treat
|
|
786
|
+
# this as silent tear-down and the run would report
|
|
787
|
+
# ``success`` even though the node failed (LSD-1507). Convert
|
|
788
|
+
# it into :class:`NodeCancelledError` so it follows the same
|
|
789
|
+
# path as any other node failure.
|
|
790
|
+
if _is_user_raised_cancelled():
|
|
791
|
+
_finish_timed_attempt(config, attempt_ctx, exc)
|
|
792
|
+
raise NodeCancelledError(task.name) from exc
|
|
793
|
+
_finish_timed_attempt(config, attempt_ctx, exc)
|
|
794
|
+
raise
|
|
795
|
+
except Exception as exc:
|
|
796
|
+
_finish_timed_attempt(config, attempt_ctx, exc)
|
|
797
|
+
if SUPPORTS_EXC_NOTES:
|
|
798
|
+
exc.add_note(f"During task with name '{task.name}' and id '{task.id}'")
|
|
799
|
+
if not retry_policy:
|
|
800
|
+
raise
|
|
801
|
+
|
|
802
|
+
# Check which retry policy applies to this exception
|
|
803
|
+
matching_policy = None
|
|
804
|
+
for policy in retry_policy:
|
|
805
|
+
if _should_retry_on(policy, exc):
|
|
806
|
+
matching_policy = policy
|
|
807
|
+
break
|
|
808
|
+
|
|
809
|
+
if not matching_policy:
|
|
810
|
+
raise
|
|
811
|
+
|
|
812
|
+
# attempts tracks failed tries only; it increments after a failure.
|
|
813
|
+
# The next execution's node_attempt is derived as attempts + 1.
|
|
814
|
+
attempts += 1
|
|
815
|
+
# check if we should give up
|
|
816
|
+
if attempts >= matching_policy.max_attempts:
|
|
817
|
+
raise
|
|
818
|
+
# sleep before retrying
|
|
819
|
+
interval = matching_policy.initial_interval
|
|
820
|
+
# Apply backoff factor based on attempt count
|
|
821
|
+
interval = min(
|
|
822
|
+
matching_policy.max_interval,
|
|
823
|
+
interval * (matching_policy.backoff_factor ** (attempts - 1)),
|
|
824
|
+
)
|
|
825
|
+
|
|
826
|
+
# Apply jitter if configured
|
|
827
|
+
sleep_time = (
|
|
828
|
+
interval + random.uniform(0, 1) if matching_policy.jitter else interval
|
|
829
|
+
)
|
|
830
|
+
await asyncio.sleep(sleep_time)
|
|
831
|
+
|
|
832
|
+
# log the retry
|
|
833
|
+
logger.info(
|
|
834
|
+
f"Retrying task {task.name} after {sleep_time:.2f} seconds (attempt {attempts}) after {exc.__class__.__name__} {exc}",
|
|
835
|
+
exc_info=exc,
|
|
836
|
+
)
|
|
837
|
+
# signal subgraphs to resume (if available)
|
|
838
|
+
config = patch_configurable(config, {CONFIG_KEY_RESUMING: True})
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
def _should_retry_on(retry_policy: RetryPolicy, exc: Exception) -> bool:
|
|
842
|
+
"""Check if the given exception should be retried based on the retry policy."""
|
|
843
|
+
if isinstance(retry_policy.retry_on, Sequence):
|
|
844
|
+
return isinstance(exc, tuple(retry_policy.retry_on))
|
|
845
|
+
elif isinstance(retry_policy.retry_on, type) and issubclass(
|
|
846
|
+
retry_policy.retry_on, Exception
|
|
847
|
+
):
|
|
848
|
+
return isinstance(exc, retry_policy.retry_on)
|
|
849
|
+
elif callable(retry_policy.retry_on):
|
|
850
|
+
return retry_policy.retry_on(exc) # type: ignore[call-arg]
|
|
851
|
+
else:
|
|
852
|
+
raise TypeError(
|
|
853
|
+
"retry_on must be an Exception class, a list or tuple of Exception classes, or a callable"
|
|
854
|
+
)
|