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,4364 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import concurrent
|
|
5
|
+
import concurrent.futures
|
|
6
|
+
import contextlib
|
|
7
|
+
import queue
|
|
8
|
+
import warnings
|
|
9
|
+
import weakref
|
|
10
|
+
from collections import defaultdict, deque
|
|
11
|
+
from collections.abc import (
|
|
12
|
+
AsyncIterator,
|
|
13
|
+
Awaitable,
|
|
14
|
+
Callable,
|
|
15
|
+
Iterator,
|
|
16
|
+
Mapping,
|
|
17
|
+
Sequence,
|
|
18
|
+
)
|
|
19
|
+
from dataclasses import is_dataclass, replace
|
|
20
|
+
from datetime import timedelta
|
|
21
|
+
from functools import partial
|
|
22
|
+
from inspect import isclass
|
|
23
|
+
from typing import (
|
|
24
|
+
Any,
|
|
25
|
+
Generic,
|
|
26
|
+
Literal,
|
|
27
|
+
cast,
|
|
28
|
+
get_type_hints,
|
|
29
|
+
overload,
|
|
30
|
+
)
|
|
31
|
+
from uuid import UUID, uuid5
|
|
32
|
+
|
|
33
|
+
from coco_core._api import beta
|
|
34
|
+
from coco_core.globals import get_debug
|
|
35
|
+
from coco_core.runnables import (
|
|
36
|
+
RunnableSequence,
|
|
37
|
+
)
|
|
38
|
+
from coco_core.runnables.base import Input, Output
|
|
39
|
+
from coco_core.runnables.config import (
|
|
40
|
+
RunnableConfig,
|
|
41
|
+
get_async_callback_manager_for_config,
|
|
42
|
+
get_callback_manager_for_config,
|
|
43
|
+
)
|
|
44
|
+
from coco_core.runnables.graph import Graph
|
|
45
|
+
from coco_core.runnables.schema import StreamEvent
|
|
46
|
+
from coco_graph.cache.base import BaseCache
|
|
47
|
+
from coco_graph.checkpoint.base import (
|
|
48
|
+
BaseCheckpointSaver,
|
|
49
|
+
Checkpoint,
|
|
50
|
+
CheckpointTuple,
|
|
51
|
+
)
|
|
52
|
+
from coco_graph.store.base import BaseStore
|
|
53
|
+
from pydantic import BaseModel, TypeAdapter
|
|
54
|
+
from typing_extensions import Self, Unpack, deprecated, is_typeddict
|
|
55
|
+
|
|
56
|
+
from coco_graph._internal import _serde
|
|
57
|
+
from coco_graph._internal._config import (
|
|
58
|
+
ensure_config,
|
|
59
|
+
merge_configs,
|
|
60
|
+
patch_checkpoint_map,
|
|
61
|
+
patch_config,
|
|
62
|
+
patch_configurable,
|
|
63
|
+
recast_checkpoint_ns,
|
|
64
|
+
)
|
|
65
|
+
from coco_graph._internal._constants import (
|
|
66
|
+
CACHE_NS_WRITES,
|
|
67
|
+
CONF,
|
|
68
|
+
CONFIG_KEY_CACHE,
|
|
69
|
+
CONFIG_KEY_CHECKPOINT_ID,
|
|
70
|
+
CONFIG_KEY_CHECKPOINT_NS,
|
|
71
|
+
CONFIG_KEY_CHECKPOINTER,
|
|
72
|
+
CONFIG_KEY_DURABILITY,
|
|
73
|
+
CONFIG_KEY_NODE_FINISHED,
|
|
74
|
+
CONFIG_KEY_READ,
|
|
75
|
+
CONFIG_KEY_RUNNER_SUBMIT,
|
|
76
|
+
CONFIG_KEY_RUNTIME,
|
|
77
|
+
CONFIG_KEY_SEND,
|
|
78
|
+
CONFIG_KEY_STREAM,
|
|
79
|
+
CONFIG_KEY_STREAM_MESSAGES_V2,
|
|
80
|
+
CONFIG_KEY_TASK_ID,
|
|
81
|
+
CONFIG_KEY_THREAD_ID,
|
|
82
|
+
ERROR,
|
|
83
|
+
INPUT,
|
|
84
|
+
INTERRUPT,
|
|
85
|
+
NS_END,
|
|
86
|
+
NS_SEP,
|
|
87
|
+
NULL_TASK_ID,
|
|
88
|
+
PUSH,
|
|
89
|
+
TASKS,
|
|
90
|
+
)
|
|
91
|
+
from coco_graph._internal._pydantic import create_model
|
|
92
|
+
from coco_graph._internal._queue import ( # type: ignore[attr-defined]
|
|
93
|
+
AsyncQueue,
|
|
94
|
+
SyncQueue,
|
|
95
|
+
)
|
|
96
|
+
from coco_graph._internal._runnable import (
|
|
97
|
+
Runnable,
|
|
98
|
+
RunnableLike,
|
|
99
|
+
RunnableSeq,
|
|
100
|
+
coerce_to_runnable,
|
|
101
|
+
)
|
|
102
|
+
from coco_graph._internal._timeout import coerce_timeout_policy
|
|
103
|
+
from coco_graph._internal._typing import MISSING, DeprecatedKwargs
|
|
104
|
+
from coco_graph.callbacks import (
|
|
105
|
+
GraphInterruptEvent,
|
|
106
|
+
GraphResumeEvent,
|
|
107
|
+
get_async_graph_callback_manager_for_config,
|
|
108
|
+
get_sync_graph_callback_manager_for_config,
|
|
109
|
+
)
|
|
110
|
+
from coco_graph.channels.base import BaseChannel
|
|
111
|
+
from coco_graph.channels.topic import Topic
|
|
112
|
+
from coco_graph.config import get_config
|
|
113
|
+
from coco_graph.constants import END
|
|
114
|
+
from coco_graph.errors import (
|
|
115
|
+
ErrorCode,
|
|
116
|
+
GraphDrained,
|
|
117
|
+
GraphRecursionError,
|
|
118
|
+
InvalidUpdateError,
|
|
119
|
+
create_error_message,
|
|
120
|
+
)
|
|
121
|
+
from coco_graph.managed.base import ManagedValueSpec
|
|
122
|
+
from coco_graph.pregel._algo import (
|
|
123
|
+
PregelTaskWrites,
|
|
124
|
+
_scratchpad,
|
|
125
|
+
apply_writes,
|
|
126
|
+
local_read,
|
|
127
|
+
prepare_next_tasks,
|
|
128
|
+
)
|
|
129
|
+
from coco_graph.pregel._call import identifier
|
|
130
|
+
from coco_graph.pregel._checkpoint import (
|
|
131
|
+
achannels_from_checkpoint,
|
|
132
|
+
channels_from_checkpoint,
|
|
133
|
+
copy_checkpoint,
|
|
134
|
+
create_checkpoint,
|
|
135
|
+
create_checkpoint_plan_for_update_state_api,
|
|
136
|
+
empty_checkpoint,
|
|
137
|
+
get_updated_channels_from_tasks,
|
|
138
|
+
)
|
|
139
|
+
from coco_graph.pregel._draw import draw_graph
|
|
140
|
+
from coco_graph.pregel._io import map_input, read_channels
|
|
141
|
+
from coco_graph.pregel._loop import (
|
|
142
|
+
AsyncPregelLoop,
|
|
143
|
+
SyncPregelLoop,
|
|
144
|
+
)
|
|
145
|
+
from coco_graph.pregel._messages import (
|
|
146
|
+
StreamMessagesHandler,
|
|
147
|
+
StreamMessagesHandlerV2,
|
|
148
|
+
)
|
|
149
|
+
from coco_graph.pregel._read import DEFAULT_BOUND, PregelNode
|
|
150
|
+
from coco_graph.pregel._retry import RetryPolicy
|
|
151
|
+
from coco_graph.pregel._runner import PregelRunner
|
|
152
|
+
from coco_graph.pregel._tools import StreamToolCallHandler
|
|
153
|
+
from coco_graph.pregel._utils import (
|
|
154
|
+
get_new_channel_versions,
|
|
155
|
+
validate_timeout_supported,
|
|
156
|
+
)
|
|
157
|
+
from coco_graph.pregel._validate import validate_graph, validate_keys
|
|
158
|
+
from coco_graph.pregel._write import ChannelWrite, ChannelWriteEntry
|
|
159
|
+
from coco_graph.pregel.debug import get_bolded_text, get_colored_text, tasks_w_writes
|
|
160
|
+
from coco_graph.pregel.protocol import PregelProtocol, StreamChunk, StreamProtocol
|
|
161
|
+
from coco_graph.runtime import (
|
|
162
|
+
DEFAULT_RUNTIME,
|
|
163
|
+
BaseUser,
|
|
164
|
+
RunControl,
|
|
165
|
+
Runtime,
|
|
166
|
+
ServerInfo,
|
|
167
|
+
)
|
|
168
|
+
from coco_graph.stream._mux import StreamMux
|
|
169
|
+
from coco_graph.stream._types import StreamTransformer
|
|
170
|
+
from coco_graph.stream.run_stream import AsyncGraphRunStream, GraphRunStream
|
|
171
|
+
from coco_graph.stream.transformers import (
|
|
172
|
+
LifecycleTransformer,
|
|
173
|
+
MessagesTransformer,
|
|
174
|
+
SubgraphTransformer,
|
|
175
|
+
ValuesTransformer,
|
|
176
|
+
)
|
|
177
|
+
from coco_graph.types import (
|
|
178
|
+
All,
|
|
179
|
+
CachePolicy,
|
|
180
|
+
Checkpointer,
|
|
181
|
+
Command,
|
|
182
|
+
Durability,
|
|
183
|
+
GraphOutput,
|
|
184
|
+
Interrupt,
|
|
185
|
+
Send,
|
|
186
|
+
StateSnapshot,
|
|
187
|
+
StateUpdate,
|
|
188
|
+
StreamMode,
|
|
189
|
+
StreamPart,
|
|
190
|
+
TimeoutPolicy,
|
|
191
|
+
ensure_valid_checkpointer,
|
|
192
|
+
)
|
|
193
|
+
from coco_graph.typing import ContextT, InputT, OutputT, StateT
|
|
194
|
+
from coco_graph.warnings import CocoGraphDeprecatedSinceV10
|
|
195
|
+
|
|
196
|
+
try:
|
|
197
|
+
from coco_core.tracers._streaming import _StreamingCallbackHandler
|
|
198
|
+
except ImportError:
|
|
199
|
+
_StreamingCallbackHandler = None # type: ignore
|
|
200
|
+
|
|
201
|
+
__all__ = ("NodeBuilder", "Pregel")
|
|
202
|
+
|
|
203
|
+
_WriteValue = Callable[[Input], Output] | Any
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class NodeBuilder:
|
|
207
|
+
__slots__ = (
|
|
208
|
+
"_channels",
|
|
209
|
+
"_triggers",
|
|
210
|
+
"_tags",
|
|
211
|
+
"_metadata",
|
|
212
|
+
"_writes",
|
|
213
|
+
"_bound",
|
|
214
|
+
"_retry_policy",
|
|
215
|
+
"_cache_policy",
|
|
216
|
+
"_timeout",
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
_channels: str | list[str]
|
|
220
|
+
_triggers: list[str]
|
|
221
|
+
_tags: list[str]
|
|
222
|
+
_metadata: dict[str, Any]
|
|
223
|
+
_writes: list[ChannelWriteEntry]
|
|
224
|
+
_bound: Runnable
|
|
225
|
+
_retry_policy: list[RetryPolicy]
|
|
226
|
+
_cache_policy: CachePolicy | None
|
|
227
|
+
_timeout: TimeoutPolicy | None
|
|
228
|
+
|
|
229
|
+
def __init__(
|
|
230
|
+
self,
|
|
231
|
+
) -> None:
|
|
232
|
+
self._channels = []
|
|
233
|
+
self._triggers = []
|
|
234
|
+
self._tags = []
|
|
235
|
+
self._metadata = {}
|
|
236
|
+
self._writes = []
|
|
237
|
+
self._bound = DEFAULT_BOUND
|
|
238
|
+
self._retry_policy = []
|
|
239
|
+
self._cache_policy = None
|
|
240
|
+
self._timeout = None
|
|
241
|
+
|
|
242
|
+
def subscribe_only(
|
|
243
|
+
self,
|
|
244
|
+
channel: str,
|
|
245
|
+
) -> Self:
|
|
246
|
+
"""Subscribe to a single channel."""
|
|
247
|
+
if not self._channels:
|
|
248
|
+
self._channels = channel
|
|
249
|
+
else:
|
|
250
|
+
raise ValueError(
|
|
251
|
+
"Cannot subscribe to single channels when other channels are already subscribed to"
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
self._triggers.append(channel)
|
|
255
|
+
|
|
256
|
+
return self
|
|
257
|
+
|
|
258
|
+
def subscribe_to(
|
|
259
|
+
self,
|
|
260
|
+
*channels: str,
|
|
261
|
+
read: bool = True,
|
|
262
|
+
) -> Self:
|
|
263
|
+
"""Add channels to subscribe to.
|
|
264
|
+
|
|
265
|
+
Node will be invoked when any of these channels are updated, with a dict of the
|
|
266
|
+
channel values as input.
|
|
267
|
+
|
|
268
|
+
Args:
|
|
269
|
+
channels: Channel name(s) to subscribe to
|
|
270
|
+
read: If `True`, the channels will be included in the input to the node.
|
|
271
|
+
Otherwise, they will trigger the node without being sent in input.
|
|
272
|
+
|
|
273
|
+
Returns:
|
|
274
|
+
Self for chaining
|
|
275
|
+
"""
|
|
276
|
+
if isinstance(self._channels, str):
|
|
277
|
+
raise ValueError(
|
|
278
|
+
"Cannot subscribe to channels when subscribed to a single channel"
|
|
279
|
+
)
|
|
280
|
+
if read:
|
|
281
|
+
if not self._channels:
|
|
282
|
+
self._channels = list(channels)
|
|
283
|
+
else:
|
|
284
|
+
self._channels.extend(channels)
|
|
285
|
+
|
|
286
|
+
if isinstance(channels, str):
|
|
287
|
+
self._triggers.append(channels)
|
|
288
|
+
else:
|
|
289
|
+
self._triggers.extend(channels)
|
|
290
|
+
|
|
291
|
+
return self
|
|
292
|
+
|
|
293
|
+
def read_from(
|
|
294
|
+
self,
|
|
295
|
+
*channels: str,
|
|
296
|
+
) -> Self:
|
|
297
|
+
"""Adds the specified channels to read from, without subscribing to them."""
|
|
298
|
+
assert isinstance(self._channels, list), (
|
|
299
|
+
"Cannot read additional channels when subscribed to single channels"
|
|
300
|
+
)
|
|
301
|
+
self._channels.extend(channels)
|
|
302
|
+
return self
|
|
303
|
+
|
|
304
|
+
def do(
|
|
305
|
+
self,
|
|
306
|
+
node: RunnableLike,
|
|
307
|
+
) -> Self:
|
|
308
|
+
"""Adds the specified node."""
|
|
309
|
+
if self._bound is not DEFAULT_BOUND:
|
|
310
|
+
self._bound = RunnableSeq(
|
|
311
|
+
self._bound, coerce_to_runnable(node, name=None, trace=True)
|
|
312
|
+
)
|
|
313
|
+
else:
|
|
314
|
+
self._bound = coerce_to_runnable(node, name=None, trace=True)
|
|
315
|
+
return self
|
|
316
|
+
|
|
317
|
+
def write_to(
|
|
318
|
+
self,
|
|
319
|
+
*channels: str | ChannelWriteEntry,
|
|
320
|
+
**kwargs: _WriteValue,
|
|
321
|
+
) -> Self:
|
|
322
|
+
"""Add channel writes.
|
|
323
|
+
|
|
324
|
+
Args:
|
|
325
|
+
*channels: Channel names to write to.
|
|
326
|
+
**kwargs: Channel name and value mappings.
|
|
327
|
+
|
|
328
|
+
Returns:
|
|
329
|
+
Self for chaining
|
|
330
|
+
"""
|
|
331
|
+
self._writes.extend(
|
|
332
|
+
ChannelWriteEntry(c) if isinstance(c, str) else c for c in channels
|
|
333
|
+
)
|
|
334
|
+
self._writes.extend(
|
|
335
|
+
ChannelWriteEntry(k, mapper=v)
|
|
336
|
+
if callable(v)
|
|
337
|
+
else ChannelWriteEntry(k, value=v)
|
|
338
|
+
for k, v in kwargs.items()
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
return self
|
|
342
|
+
|
|
343
|
+
def meta(self, *tags: str, **metadata: Any) -> Self:
|
|
344
|
+
"""Add tags or metadata to the node."""
|
|
345
|
+
self._tags.extend(tags)
|
|
346
|
+
self._metadata.update(metadata)
|
|
347
|
+
return self
|
|
348
|
+
|
|
349
|
+
def add_retry_policies(self, *policies: RetryPolicy) -> Self:
|
|
350
|
+
"""Adds retry policies to the node."""
|
|
351
|
+
self._retry_policy.extend(policies)
|
|
352
|
+
return self
|
|
353
|
+
|
|
354
|
+
def add_cache_policy(self, policy: CachePolicy) -> Self:
|
|
355
|
+
"""Adds cache policies to the node."""
|
|
356
|
+
self._cache_policy = policy
|
|
357
|
+
return self
|
|
358
|
+
|
|
359
|
+
def set_timeout(self, timeout: float | timedelta | TimeoutPolicy | None) -> Self:
|
|
360
|
+
"""Set the per-attempt timeout policy for this node."""
|
|
361
|
+
self._timeout = coerce_timeout_policy(timeout)
|
|
362
|
+
return self
|
|
363
|
+
|
|
364
|
+
def build(self) -> PregelNode:
|
|
365
|
+
"""Builds the node."""
|
|
366
|
+
return PregelNode(
|
|
367
|
+
channels=self._channels,
|
|
368
|
+
triggers=self._triggers,
|
|
369
|
+
tags=self._tags,
|
|
370
|
+
metadata=self._metadata,
|
|
371
|
+
writers=[ChannelWrite(self._writes)],
|
|
372
|
+
bound=self._bound,
|
|
373
|
+
retry_policy=self._retry_policy,
|
|
374
|
+
cache_policy=self._cache_policy,
|
|
375
|
+
timeout=self._timeout,
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
# Kwargs that ``stream_events(version="v3")`` / ``astream_events(version="v3")``
|
|
380
|
+
# manage internally and must not be overridden by callers. ``stream_mode`` is
|
|
381
|
+
# derived from the transformer mux; ``subgraphs`` is forced True so nested
|
|
382
|
+
# namespaces flow through scoped muxes. Forwarding either to the inner
|
|
383
|
+
# ``stream(...)`` would silently break v3's invariants, so we raise instead.
|
|
384
|
+
_V3_INVARIANT_KWARGS: tuple[str, ...] = ("stream_mode", "subgraphs")
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _reject_v3_invariant_kwargs(kwargs: dict[str, Any]) -> None:
|
|
388
|
+
collisions = [k for k in _V3_INVARIANT_KWARGS if k in kwargs]
|
|
389
|
+
if collisions:
|
|
390
|
+
raise TypeError(
|
|
391
|
+
"stream_events(version='v3') / astream_events(version='v3') do "
|
|
392
|
+
f"not accept {', '.join(collisions)}; v3 owns these "
|
|
393
|
+
"(stream_mode is built from the transformer mux, subgraphs is "
|
|
394
|
+
"forced True so nested namespaces flow through scoped muxes)."
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _collect_stream_modes(mux: Any) -> list[StreamMode]:
|
|
399
|
+
"""Return the union of `required_stream_modes` across registered transformers.
|
|
400
|
+
|
|
401
|
+
Transformers declare the stream modes they need to function, and
|
|
402
|
+
`stream_events(version="v3")` asks the graph for exactly that union — no hardcoded
|
|
403
|
+
default set. If zero transformers declare a given mode, the graph
|
|
404
|
+
does not stream events for it.
|
|
405
|
+
"""
|
|
406
|
+
modes: set[StreamMode] = set()
|
|
407
|
+
for transformer in mux._transformers:
|
|
408
|
+
modes.update(
|
|
409
|
+
cast(
|
|
410
|
+
"tuple[StreamMode, ...]",
|
|
411
|
+
getattr(transformer, "required_stream_modes", ()),
|
|
412
|
+
)
|
|
413
|
+
)
|
|
414
|
+
return list(modes)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _normalize_stream_transformer_factories(
|
|
418
|
+
specs: Sequence[Callable[[tuple[str, ...]], Any]] | None,
|
|
419
|
+
) -> list[Callable[[tuple[str, ...]], Any]]:
|
|
420
|
+
"""Normalize stream transformer specs to scoped factories.
|
|
421
|
+
|
|
422
|
+
A stream transformer spec is a callable that accepts
|
|
423
|
+
`scope: tuple[str, ...]` and returns a fresh `StreamTransformer`.
|
|
424
|
+
Transformer classes work when their constructor follows the same
|
|
425
|
+
shape. Pre-built instances are rejected because they cannot be
|
|
426
|
+
cloned into subgraph scopes.
|
|
427
|
+
"""
|
|
428
|
+
factories: list[Callable[[tuple[str, ...]], Any]] = []
|
|
429
|
+
for spec in specs or ():
|
|
430
|
+
if isinstance(spec, StreamTransformer):
|
|
431
|
+
raise TypeError(
|
|
432
|
+
"stream_events(version='v3') transformers must be scope-aware callables, "
|
|
433
|
+
f"got pre-built instance {type(spec).__name__}. Pass the "
|
|
434
|
+
"transformer class or a factory like "
|
|
435
|
+
"`lambda scope: MyTransformer(scope, ...)`."
|
|
436
|
+
)
|
|
437
|
+
if not callable(spec):
|
|
438
|
+
raise TypeError(
|
|
439
|
+
"stream_events(version='v3') transformers must be scope-aware callables, "
|
|
440
|
+
f"got {type(spec).__name__}."
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
def factory(scope: tuple[str, ...], _spec: Callable[..., Any] = spec) -> Any:
|
|
444
|
+
return _spec(scope)
|
|
445
|
+
|
|
446
|
+
factories.append(factory)
|
|
447
|
+
return factories
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
class Pregel(
|
|
451
|
+
PregelProtocol[StateT, ContextT, InputT, OutputT],
|
|
452
|
+
Generic[StateT, ContextT, InputT, OutputT],
|
|
453
|
+
):
|
|
454
|
+
"""Pregel manages the runtime behavior for CocoGraph applications.
|
|
455
|
+
|
|
456
|
+
## Overview
|
|
457
|
+
|
|
458
|
+
Pregel combines [**actors**](https://en.wikipedia.org/wiki/Actor_model)
|
|
459
|
+
and **channels** into a single application.
|
|
460
|
+
**Actors** read data from channels and write data to channels.
|
|
461
|
+
Pregel organizes the execution of the application into multiple steps,
|
|
462
|
+
following the **Pregel Algorithm**/**Bulk Synchronous Parallel** model.
|
|
463
|
+
|
|
464
|
+
Each step consists of three phases:
|
|
465
|
+
|
|
466
|
+
- **Plan**: Determine which **actors** to execute in this step. For example,
|
|
467
|
+
in the first step, select the **actors** that subscribe to the special
|
|
468
|
+
**input** channels; in subsequent steps,
|
|
469
|
+
select the **actors** that subscribe to channels updated in the previous step.
|
|
470
|
+
- **Execution**: Execute all selected **actors** in parallel,
|
|
471
|
+
until all complete, or one fails, or a timeout is reached. During this
|
|
472
|
+
phase, channel updates are invisible to actors until the next step.
|
|
473
|
+
- **Update**: Update the channels with the values written by the **actors**
|
|
474
|
+
in this step.
|
|
475
|
+
|
|
476
|
+
Repeat until no **actors** are selected for execution, or a maximum number of
|
|
477
|
+
steps is reached.
|
|
478
|
+
|
|
479
|
+
## Actors
|
|
480
|
+
|
|
481
|
+
An **actor** is a `PregelNode`.
|
|
482
|
+
It subscribes to channels, reads data from them, and writes data to them.
|
|
483
|
+
It can be thought of as an **actor** in the Pregel algorithm.
|
|
484
|
+
`PregelNodes` implement Coco's
|
|
485
|
+
Runnable interface.
|
|
486
|
+
|
|
487
|
+
## Channels
|
|
488
|
+
|
|
489
|
+
Channels are used to communicate between actors (`PregelNodes`).
|
|
490
|
+
Each channel has a value type, an update type, and an update function – which
|
|
491
|
+
takes a sequence of updates and
|
|
492
|
+
modifies the stored value. Channels can be used to send data from one chain to
|
|
493
|
+
another, or to send data from a chain to itself in a future step. CocoGraph
|
|
494
|
+
provides a number of built-in channels:
|
|
495
|
+
|
|
496
|
+
### Basic channels: LastValue and Topic
|
|
497
|
+
|
|
498
|
+
- `LastValue`: The default channel, stores the last value sent to the channel,
|
|
499
|
+
useful for input and output values, or for sending data from one step to the next
|
|
500
|
+
- `Topic`: A configurable PubSub Topic, useful for sending multiple values
|
|
501
|
+
between *actors*, or for accumulating output. Can be configured to deduplicate
|
|
502
|
+
values, and/or to accumulate values over the course of multiple steps.
|
|
503
|
+
|
|
504
|
+
### Advanced channels: Context and BinaryOperatorAggregate
|
|
505
|
+
|
|
506
|
+
- `Context`: exposes the value of a context manager, managing its lifecycle.
|
|
507
|
+
Useful for accessing external resources that require setup and/or teardown. e.g.
|
|
508
|
+
`client = Context(httpx.Client)`
|
|
509
|
+
- `BinaryOperatorAggregate`: stores a persistent value, updated by applying
|
|
510
|
+
a binary operator to the current value and each update
|
|
511
|
+
sent to the channel, useful for computing aggregates over multiple steps. e.g.
|
|
512
|
+
`total = BinaryOperatorAggregate(int, operator.add)`
|
|
513
|
+
|
|
514
|
+
## Examples
|
|
515
|
+
|
|
516
|
+
Most users will interact with Pregel via a
|
|
517
|
+
[StateGraph (Graph API)][coco_graph.graph.StateGraph] or via an
|
|
518
|
+
[entrypoint (Functional API)][coco_graph.func.entrypoint].
|
|
519
|
+
|
|
520
|
+
However, for **advanced** use cases, Pregel can be used directly. If you're
|
|
521
|
+
not sure whether you need to use Pregel directly, then the answer is probably no
|
|
522
|
+
- you should use the Graph API or Functional API instead. These are higher-level
|
|
523
|
+
interfaces that will compile down to Pregel under the hood.
|
|
524
|
+
|
|
525
|
+
Here are some examples to give you a sense of how it works:
|
|
526
|
+
|
|
527
|
+
Example: Single node application
|
|
528
|
+
```python
|
|
529
|
+
from coco_graph.channels import EphemeralValue
|
|
530
|
+
from coco_graph.pregel import Pregel, NodeBuilder
|
|
531
|
+
|
|
532
|
+
node1 = (
|
|
533
|
+
NodeBuilder().subscribe_only("a")
|
|
534
|
+
.do(lambda x: x + x)
|
|
535
|
+
.write_to("b")
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
app = Pregel(
|
|
539
|
+
nodes={"node1": node1},
|
|
540
|
+
channels={
|
|
541
|
+
"a": EphemeralValue(str),
|
|
542
|
+
"b": EphemeralValue(str),
|
|
543
|
+
},
|
|
544
|
+
input_channels=["a"],
|
|
545
|
+
output_channels=["b"],
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
app.invoke({"a": "foo"})
|
|
549
|
+
```
|
|
550
|
+
|
|
551
|
+
```con
|
|
552
|
+
{'b': 'foofoo'}
|
|
553
|
+
```
|
|
554
|
+
|
|
555
|
+
Example: Using multiple nodes and multiple output channels
|
|
556
|
+
```python
|
|
557
|
+
from coco_graph.channels import LastValue, EphemeralValue
|
|
558
|
+
from coco_graph.pregel import Pregel, NodeBuilder
|
|
559
|
+
|
|
560
|
+
node1 = (
|
|
561
|
+
NodeBuilder().subscribe_only("a")
|
|
562
|
+
.do(lambda x: x + x)
|
|
563
|
+
.write_to("b")
|
|
564
|
+
)
|
|
565
|
+
|
|
566
|
+
node2 = (
|
|
567
|
+
NodeBuilder().subscribe_to("b")
|
|
568
|
+
.do(lambda x: x["b"] + x["b"])
|
|
569
|
+
.write_to("c")
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
app = Pregel(
|
|
574
|
+
nodes={"node1": node1, "node2": node2},
|
|
575
|
+
channels={
|
|
576
|
+
"a": EphemeralValue(str),
|
|
577
|
+
"b": LastValue(str),
|
|
578
|
+
"c": EphemeralValue(str),
|
|
579
|
+
},
|
|
580
|
+
input_channels=["a"],
|
|
581
|
+
output_channels=["b", "c"],
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
app.invoke({"a": "foo"})
|
|
585
|
+
```
|
|
586
|
+
|
|
587
|
+
```con
|
|
588
|
+
{'b': 'foofoo', 'c': 'foofoofoofoo'}
|
|
589
|
+
```
|
|
590
|
+
|
|
591
|
+
Example: Using a Topic channel
|
|
592
|
+
```python
|
|
593
|
+
from coco_graph.channels import LastValue, EphemeralValue, Topic
|
|
594
|
+
from coco_graph.pregel import Pregel, NodeBuilder
|
|
595
|
+
|
|
596
|
+
node1 = (
|
|
597
|
+
NodeBuilder().subscribe_only("a")
|
|
598
|
+
.do(lambda x: x + x)
|
|
599
|
+
.write_to("b", "c")
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
node2 = (
|
|
603
|
+
NodeBuilder().subscribe_only("b")
|
|
604
|
+
.do(lambda x: x + x)
|
|
605
|
+
.write_to("c")
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
app = Pregel(
|
|
610
|
+
nodes={"node1": node1, "node2": node2},
|
|
611
|
+
channels={
|
|
612
|
+
"a": EphemeralValue(str),
|
|
613
|
+
"b": EphemeralValue(str),
|
|
614
|
+
"c": Topic(str, accumulate=True),
|
|
615
|
+
},
|
|
616
|
+
input_channels=["a"],
|
|
617
|
+
output_channels=["c"],
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
app.invoke({"a": "foo"})
|
|
621
|
+
```
|
|
622
|
+
|
|
623
|
+
```pycon
|
|
624
|
+
{"c": ["foofoo", "foofoofoofoo"]}
|
|
625
|
+
```
|
|
626
|
+
|
|
627
|
+
Example: Using a `BinaryOperatorAggregate` channel
|
|
628
|
+
```python
|
|
629
|
+
from coco_graph.channels import EphemeralValue, BinaryOperatorAggregate
|
|
630
|
+
from coco_graph.pregel import Pregel, NodeBuilder
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
node1 = (
|
|
634
|
+
NodeBuilder().subscribe_only("a")
|
|
635
|
+
.do(lambda x: x + x)
|
|
636
|
+
.write_to("b", "c")
|
|
637
|
+
)
|
|
638
|
+
|
|
639
|
+
node2 = (
|
|
640
|
+
NodeBuilder().subscribe_only("b")
|
|
641
|
+
.do(lambda x: x + x)
|
|
642
|
+
.write_to("c")
|
|
643
|
+
)
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def reducer(current, update):
|
|
647
|
+
if current:
|
|
648
|
+
return current + " | " + update
|
|
649
|
+
else:
|
|
650
|
+
return update
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
app = Pregel(
|
|
654
|
+
nodes={"node1": node1, "node2": node2},
|
|
655
|
+
channels={
|
|
656
|
+
"a": EphemeralValue(str),
|
|
657
|
+
"b": EphemeralValue(str),
|
|
658
|
+
"c": BinaryOperatorAggregate(str, operator=reducer),
|
|
659
|
+
},
|
|
660
|
+
input_channels=["a"],
|
|
661
|
+
output_channels=["c"],
|
|
662
|
+
)
|
|
663
|
+
|
|
664
|
+
app.invoke({"a": "foo"})
|
|
665
|
+
```
|
|
666
|
+
|
|
667
|
+
```con
|
|
668
|
+
{'c': 'foofoo | foofoofoofoo'}
|
|
669
|
+
```
|
|
670
|
+
|
|
671
|
+
Example: Introducing a cycle
|
|
672
|
+
This example demonstrates how to introduce a cycle in the graph, by having
|
|
673
|
+
a chain write to a channel it subscribes to.
|
|
674
|
+
|
|
675
|
+
Execution will continue until a `None` value is written to the channel.
|
|
676
|
+
|
|
677
|
+
```python
|
|
678
|
+
from coco_graph.channels import EphemeralValue
|
|
679
|
+
from coco_graph.pregel import Pregel, NodeBuilder, ChannelWriteEntry
|
|
680
|
+
|
|
681
|
+
example_node = (
|
|
682
|
+
NodeBuilder()
|
|
683
|
+
.subscribe_only("value")
|
|
684
|
+
.do(lambda x: x + x if len(x) < 10 else None)
|
|
685
|
+
.write_to(ChannelWriteEntry(channel="value", skip_none=True))
|
|
686
|
+
)
|
|
687
|
+
|
|
688
|
+
app = Pregel(
|
|
689
|
+
nodes={"example_node": example_node},
|
|
690
|
+
channels={
|
|
691
|
+
"value": EphemeralValue(str),
|
|
692
|
+
},
|
|
693
|
+
input_channels=["value"],
|
|
694
|
+
output_channels=["value"],
|
|
695
|
+
)
|
|
696
|
+
|
|
697
|
+
app.invoke({"value": "a"})
|
|
698
|
+
```
|
|
699
|
+
|
|
700
|
+
```con
|
|
701
|
+
{'value': 'aaaaaaaaaaaaaaaa'}
|
|
702
|
+
```
|
|
703
|
+
"""
|
|
704
|
+
|
|
705
|
+
nodes: dict[str, PregelNode]
|
|
706
|
+
|
|
707
|
+
channels: dict[str, BaseChannel | ManagedValueSpec]
|
|
708
|
+
|
|
709
|
+
stream_mode: StreamMode = "values"
|
|
710
|
+
"""Mode to stream output, defaults to 'values'."""
|
|
711
|
+
|
|
712
|
+
stream_eager: bool = False
|
|
713
|
+
"""Whether to force emitting stream events eagerly, automatically turned on
|
|
714
|
+
for stream_mode "messages" and "custom"."""
|
|
715
|
+
|
|
716
|
+
output_channels: str | Sequence[str]
|
|
717
|
+
|
|
718
|
+
stream_channels: str | Sequence[str] | None = None
|
|
719
|
+
"""Channels to stream, defaults to all channels not in reserved channels"""
|
|
720
|
+
|
|
721
|
+
interrupt_after_nodes: All | Sequence[str]
|
|
722
|
+
|
|
723
|
+
interrupt_before_nodes: All | Sequence[str]
|
|
724
|
+
|
|
725
|
+
input_channels: str | Sequence[str]
|
|
726
|
+
|
|
727
|
+
step_timeout: float | None = None
|
|
728
|
+
"""Maximum time to wait for a step to complete, in seconds."""
|
|
729
|
+
|
|
730
|
+
debug: bool
|
|
731
|
+
"""Whether to print debug information during execution."""
|
|
732
|
+
|
|
733
|
+
checkpointer: Checkpointer = None
|
|
734
|
+
"""`Checkpointer` used to save and load graph state."""
|
|
735
|
+
|
|
736
|
+
store: BaseStore | None = None
|
|
737
|
+
"""Memory store to use for SharedValues."""
|
|
738
|
+
|
|
739
|
+
cache: BaseCache | None = None
|
|
740
|
+
"""Cache to use for storing node results."""
|
|
741
|
+
|
|
742
|
+
retry_policy: Sequence[RetryPolicy] = ()
|
|
743
|
+
"""Retry policies to use when running tasks. Empty set disables retries."""
|
|
744
|
+
|
|
745
|
+
cache_policy: CachePolicy | None = None
|
|
746
|
+
"""Cache policy to use for all nodes. Can be overridden by individual nodes."""
|
|
747
|
+
|
|
748
|
+
context_schema: type[ContextT] | None = None
|
|
749
|
+
"""Specifies the schema for the context object that will be passed to the workflow."""
|
|
750
|
+
|
|
751
|
+
config: RunnableConfig | None = None
|
|
752
|
+
|
|
753
|
+
name: str = "CocoGraph"
|
|
754
|
+
|
|
755
|
+
trigger_to_nodes: Mapping[str, Sequence[str]]
|
|
756
|
+
node_error_handler_map: Mapping[str, str]
|
|
757
|
+
|
|
758
|
+
def __init__(
|
|
759
|
+
self,
|
|
760
|
+
*,
|
|
761
|
+
nodes: dict[str, PregelNode | NodeBuilder],
|
|
762
|
+
channels: dict[str, BaseChannel | ManagedValueSpec] | None,
|
|
763
|
+
auto_validate: bool = True,
|
|
764
|
+
stream_mode: StreamMode = "values",
|
|
765
|
+
stream_eager: bool = False,
|
|
766
|
+
output_channels: str | Sequence[str],
|
|
767
|
+
stream_channels: str | Sequence[str] | None = None,
|
|
768
|
+
interrupt_after_nodes: All | Sequence[str] = (),
|
|
769
|
+
interrupt_before_nodes: All | Sequence[str] = (),
|
|
770
|
+
input_channels: str | Sequence[str],
|
|
771
|
+
step_timeout: float | None = None,
|
|
772
|
+
debug: bool | None = None,
|
|
773
|
+
checkpointer: Checkpointer = None,
|
|
774
|
+
store: BaseStore | None = None,
|
|
775
|
+
cache: BaseCache | None = None,
|
|
776
|
+
retry_policy: RetryPolicy | Sequence[RetryPolicy] = (),
|
|
777
|
+
cache_policy: CachePolicy | None = None,
|
|
778
|
+
context_schema: type[ContextT] | None = None,
|
|
779
|
+
config: RunnableConfig | None = None,
|
|
780
|
+
trigger_to_nodes: Mapping[str, Sequence[str]] | None = None,
|
|
781
|
+
node_error_handler_map: Mapping[str, str] | None = None,
|
|
782
|
+
name: str = "CocoGraph",
|
|
783
|
+
stream_transformers: Sequence[Callable[[tuple[str, ...]], Any]] | None = None,
|
|
784
|
+
**deprecated_kwargs: Unpack[DeprecatedKwargs],
|
|
785
|
+
) -> None:
|
|
786
|
+
if (
|
|
787
|
+
config_type := deprecated_kwargs.get("config_type", MISSING)
|
|
788
|
+
) is not MISSING:
|
|
789
|
+
warnings.warn(
|
|
790
|
+
"`config_type` is deprecated and will be removed. Please use `context_schema` instead.",
|
|
791
|
+
category=CocoGraphDeprecatedSinceV10,
|
|
792
|
+
stacklevel=2,
|
|
793
|
+
)
|
|
794
|
+
|
|
795
|
+
if context_schema is None:
|
|
796
|
+
context_schema = cast(type[ContextT], config_type)
|
|
797
|
+
|
|
798
|
+
checkpointer = ensure_valid_checkpointer(checkpointer)
|
|
799
|
+
|
|
800
|
+
self.nodes = {
|
|
801
|
+
k: v.build() if isinstance(v, NodeBuilder) else v for k, v in nodes.items()
|
|
802
|
+
}
|
|
803
|
+
self.channels = channels or {}
|
|
804
|
+
if TASKS in self.channels and not isinstance(self.channels[TASKS], Topic):
|
|
805
|
+
raise ValueError(
|
|
806
|
+
f"Channel '{TASKS}' is reserved and cannot be used in the graph."
|
|
807
|
+
)
|
|
808
|
+
else:
|
|
809
|
+
self.channels[TASKS] = Topic(Send, accumulate=False)
|
|
810
|
+
self.stream_mode = stream_mode
|
|
811
|
+
self.stream_eager = stream_eager
|
|
812
|
+
self.output_channels = output_channels
|
|
813
|
+
self.stream_channels = stream_channels
|
|
814
|
+
self.interrupt_after_nodes = interrupt_after_nodes
|
|
815
|
+
self.interrupt_before_nodes = interrupt_before_nodes
|
|
816
|
+
self.input_channels = input_channels
|
|
817
|
+
self.step_timeout = step_timeout
|
|
818
|
+
self.debug = debug if debug is not None else get_debug()
|
|
819
|
+
self.checkpointer = checkpointer
|
|
820
|
+
self.store = store
|
|
821
|
+
self.cache = cache
|
|
822
|
+
self.retry_policy = (
|
|
823
|
+
(retry_policy,) if isinstance(retry_policy, RetryPolicy) else retry_policy
|
|
824
|
+
)
|
|
825
|
+
self.cache_policy = cache_policy
|
|
826
|
+
self.context_schema = context_schema
|
|
827
|
+
self.config = config
|
|
828
|
+
self.trigger_to_nodes = trigger_to_nodes or {}
|
|
829
|
+
self.node_error_handler_map = node_error_handler_map or {}
|
|
830
|
+
self.name = name
|
|
831
|
+
self.stream_transformers: tuple[Callable[[tuple[str, ...]], Any], ...] = tuple(
|
|
832
|
+
stream_transformers or ()
|
|
833
|
+
)
|
|
834
|
+
self._serde_allowlist: set[tuple[str, ...]] | None = None
|
|
835
|
+
if auto_validate:
|
|
836
|
+
self.validate()
|
|
837
|
+
|
|
838
|
+
def _apply_checkpointer_allowlist(
|
|
839
|
+
self, checkpointer: BaseCheckpointSaver | None
|
|
840
|
+
) -> BaseCheckpointSaver | None:
|
|
841
|
+
if not _serde.STRICT_MSGPACK_ENABLED:
|
|
842
|
+
return checkpointer
|
|
843
|
+
return _serde.apply_checkpointer_allowlist(checkpointer, self._serde_allowlist)
|
|
844
|
+
|
|
845
|
+
def get_graph(
|
|
846
|
+
self, config: RunnableConfig | None = None, *, xray: int | bool = False
|
|
847
|
+
) -> Graph:
|
|
848
|
+
"""Return a drawable representation of the computation graph."""
|
|
849
|
+
# gather subgraphs
|
|
850
|
+
if xray:
|
|
851
|
+
subgraphs = {
|
|
852
|
+
k: v.get_graph(
|
|
853
|
+
config,
|
|
854
|
+
xray=xray if isinstance(xray, bool) or xray <= 0 else xray - 1,
|
|
855
|
+
)
|
|
856
|
+
for k, v in self.get_subgraphs()
|
|
857
|
+
}
|
|
858
|
+
else:
|
|
859
|
+
subgraphs = {}
|
|
860
|
+
|
|
861
|
+
return draw_graph(
|
|
862
|
+
merge_configs(self.config, config),
|
|
863
|
+
nodes=self.nodes,
|
|
864
|
+
specs=self.channels,
|
|
865
|
+
input_channels=self.input_channels,
|
|
866
|
+
interrupt_after_nodes=self.interrupt_after_nodes,
|
|
867
|
+
interrupt_before_nodes=self.interrupt_before_nodes,
|
|
868
|
+
trigger_to_nodes=self.trigger_to_nodes,
|
|
869
|
+
checkpointer=self.checkpointer,
|
|
870
|
+
subgraphs=subgraphs,
|
|
871
|
+
)
|
|
872
|
+
|
|
873
|
+
async def aget_graph(
|
|
874
|
+
self, config: RunnableConfig | None = None, *, xray: int | bool = False
|
|
875
|
+
) -> Graph:
|
|
876
|
+
"""Return a drawable representation of the computation graph."""
|
|
877
|
+
|
|
878
|
+
# gather subgraphs
|
|
879
|
+
if xray:
|
|
880
|
+
subpregels: dict[str, PregelProtocol] = {
|
|
881
|
+
k: v async for k, v in self.aget_subgraphs()
|
|
882
|
+
}
|
|
883
|
+
subgraphs = {
|
|
884
|
+
k: v
|
|
885
|
+
for k, v in zip(
|
|
886
|
+
subpregels,
|
|
887
|
+
await asyncio.gather(
|
|
888
|
+
*(
|
|
889
|
+
p.aget_graph(
|
|
890
|
+
config,
|
|
891
|
+
xray=xray
|
|
892
|
+
if isinstance(xray, bool) or xray <= 0
|
|
893
|
+
else xray - 1,
|
|
894
|
+
)
|
|
895
|
+
for p in subpregels.values()
|
|
896
|
+
)
|
|
897
|
+
),
|
|
898
|
+
)
|
|
899
|
+
}
|
|
900
|
+
else:
|
|
901
|
+
subgraphs = {}
|
|
902
|
+
|
|
903
|
+
return draw_graph(
|
|
904
|
+
merge_configs(self.config, config),
|
|
905
|
+
nodes=self.nodes,
|
|
906
|
+
specs=self.channels,
|
|
907
|
+
input_channels=self.input_channels,
|
|
908
|
+
interrupt_after_nodes=self.interrupt_after_nodes,
|
|
909
|
+
interrupt_before_nodes=self.interrupt_before_nodes,
|
|
910
|
+
trigger_to_nodes=self.trigger_to_nodes,
|
|
911
|
+
checkpointer=self.checkpointer,
|
|
912
|
+
subgraphs=subgraphs,
|
|
913
|
+
)
|
|
914
|
+
|
|
915
|
+
def _repr_mimebundle_(self, **kwargs: Any) -> dict[str, Any]:
|
|
916
|
+
"""Mime bundle used by Jupyter to display the graph"""
|
|
917
|
+
return {
|
|
918
|
+
"text/plain": repr(self),
|
|
919
|
+
"image/png": self.get_graph().draw_mermaid_png(),
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
def copy(self, update: dict[str, Any] | None = None) -> Self:
|
|
923
|
+
attrs = {k: v for k, v in self.__dict__.items() if k != "__orig_class__"}
|
|
924
|
+
attrs.update(update or {})
|
|
925
|
+
return self.__class__(**attrs)
|
|
926
|
+
|
|
927
|
+
def with_config(self, config: RunnableConfig | None = None, **kwargs: Any) -> Self:
|
|
928
|
+
"""Create a copy of the Pregel object with an updated config."""
|
|
929
|
+
return self.copy(
|
|
930
|
+
{"config": merge_configs(self.config, config, cast(RunnableConfig, kwargs))}
|
|
931
|
+
)
|
|
932
|
+
|
|
933
|
+
def validate(self) -> Self:
|
|
934
|
+
for name, node in self.nodes.items():
|
|
935
|
+
if node.timeout is not None:
|
|
936
|
+
validate_timeout_supported(node.node or node.bound, name=name)
|
|
937
|
+
validate_graph(
|
|
938
|
+
self.nodes,
|
|
939
|
+
{k: v for k, v in self.channels.items() if isinstance(v, BaseChannel)},
|
|
940
|
+
{k: v for k, v in self.channels.items() if not isinstance(v, BaseChannel)},
|
|
941
|
+
self.input_channels,
|
|
942
|
+
self.output_channels,
|
|
943
|
+
self.stream_channels,
|
|
944
|
+
self.interrupt_after_nodes,
|
|
945
|
+
self.interrupt_before_nodes,
|
|
946
|
+
)
|
|
947
|
+
self.trigger_to_nodes = _trigger_to_nodes(self.nodes)
|
|
948
|
+
return self
|
|
949
|
+
|
|
950
|
+
@deprecated(
|
|
951
|
+
"`config_schema` is deprecated. Use `get_context_jsonschema` for the relevant schema instead.",
|
|
952
|
+
category=None,
|
|
953
|
+
)
|
|
954
|
+
def config_schema(self, *, include: Sequence[str] | None = None) -> type[BaseModel]:
|
|
955
|
+
warnings.warn(
|
|
956
|
+
"`config_schema` is deprecated. Use `get_context_jsonschema` for the relevant schema instead.",
|
|
957
|
+
category=CocoGraphDeprecatedSinceV10,
|
|
958
|
+
stacklevel=2,
|
|
959
|
+
)
|
|
960
|
+
|
|
961
|
+
include = include or []
|
|
962
|
+
fields = {
|
|
963
|
+
**(
|
|
964
|
+
{"configurable": (self.context_schema, None)}
|
|
965
|
+
if self.context_schema
|
|
966
|
+
else {}
|
|
967
|
+
),
|
|
968
|
+
**{
|
|
969
|
+
field_name: (field_type, None)
|
|
970
|
+
for field_name, field_type in get_type_hints(RunnableConfig).items()
|
|
971
|
+
if field_name in [i for i in include if i != "configurable"]
|
|
972
|
+
},
|
|
973
|
+
}
|
|
974
|
+
return create_model(self.get_name("Config"), field_definitions=fields)
|
|
975
|
+
|
|
976
|
+
@deprecated(
|
|
977
|
+
"`get_config_jsonschema` is deprecated. Use `get_context_jsonschema` instead.",
|
|
978
|
+
category=None,
|
|
979
|
+
)
|
|
980
|
+
def get_config_jsonschema(
|
|
981
|
+
self, *, include: Sequence[str] | None = None
|
|
982
|
+
) -> dict[str, Any]:
|
|
983
|
+
warnings.warn(
|
|
984
|
+
"`get_config_jsonschema` is deprecated. Use `get_context_jsonschema` instead.",
|
|
985
|
+
category=CocoGraphDeprecatedSinceV10,
|
|
986
|
+
stacklevel=2,
|
|
987
|
+
)
|
|
988
|
+
|
|
989
|
+
with warnings.catch_warnings():
|
|
990
|
+
warnings.filterwarnings("ignore", category=CocoGraphDeprecatedSinceV10)
|
|
991
|
+
schema = self.config_schema(include=include)
|
|
992
|
+
return schema.model_json_schema()
|
|
993
|
+
|
|
994
|
+
def get_context_jsonschema(self) -> dict[str, Any] | None:
|
|
995
|
+
if (context_schema := self.context_schema) is None:
|
|
996
|
+
return None
|
|
997
|
+
|
|
998
|
+
if isclass(context_schema) and issubclass(context_schema, BaseModel):
|
|
999
|
+
return context_schema.model_json_schema()
|
|
1000
|
+
elif is_typeddict(context_schema) or is_dataclass(context_schema):
|
|
1001
|
+
return TypeAdapter(context_schema).json_schema()
|
|
1002
|
+
else:
|
|
1003
|
+
raise ValueError(
|
|
1004
|
+
f"Invalid context schema type: {context_schema}. Must be a BaseModel, TypedDict or dataclass."
|
|
1005
|
+
)
|
|
1006
|
+
|
|
1007
|
+
@property
|
|
1008
|
+
def InputType(self) -> Any:
|
|
1009
|
+
if isinstance(self.input_channels, str):
|
|
1010
|
+
channel = self.channels[self.input_channels]
|
|
1011
|
+
if isinstance(channel, BaseChannel):
|
|
1012
|
+
return channel.UpdateType
|
|
1013
|
+
|
|
1014
|
+
def get_input_schema(self, config: RunnableConfig | None = None) -> type[BaseModel]:
|
|
1015
|
+
config = merge_configs(self.config, config)
|
|
1016
|
+
if isinstance(self.input_channels, str):
|
|
1017
|
+
return super().get_input_schema(config)
|
|
1018
|
+
else:
|
|
1019
|
+
return create_model(
|
|
1020
|
+
self.get_name("Input"),
|
|
1021
|
+
field_definitions={
|
|
1022
|
+
k: (c.UpdateType, None)
|
|
1023
|
+
for k in self.input_channels or self.channels.keys()
|
|
1024
|
+
if (c := self.channels[k]) and isinstance(c, BaseChannel)
|
|
1025
|
+
},
|
|
1026
|
+
)
|
|
1027
|
+
|
|
1028
|
+
def get_input_jsonschema(
|
|
1029
|
+
self, config: RunnableConfig | None = None
|
|
1030
|
+
) -> dict[str, Any]:
|
|
1031
|
+
schema = self.get_input_schema(config)
|
|
1032
|
+
return schema.model_json_schema()
|
|
1033
|
+
|
|
1034
|
+
@property
|
|
1035
|
+
def OutputType(self) -> Any:
|
|
1036
|
+
if isinstance(self.output_channels, str):
|
|
1037
|
+
channel = self.channels[self.output_channels]
|
|
1038
|
+
if isinstance(channel, BaseChannel):
|
|
1039
|
+
return channel.ValueType
|
|
1040
|
+
|
|
1041
|
+
def get_output_schema(
|
|
1042
|
+
self, config: RunnableConfig | None = None
|
|
1043
|
+
) -> type[BaseModel]:
|
|
1044
|
+
config = merge_configs(self.config, config)
|
|
1045
|
+
if isinstance(self.output_channels, str):
|
|
1046
|
+
return super().get_output_schema(config)
|
|
1047
|
+
else:
|
|
1048
|
+
return create_model(
|
|
1049
|
+
self.get_name("Output"),
|
|
1050
|
+
field_definitions={
|
|
1051
|
+
k: (c.ValueType, None)
|
|
1052
|
+
for k in self.output_channels
|
|
1053
|
+
if (c := self.channels[k]) and isinstance(c, BaseChannel)
|
|
1054
|
+
},
|
|
1055
|
+
)
|
|
1056
|
+
|
|
1057
|
+
def get_output_jsonschema(
|
|
1058
|
+
self, config: RunnableConfig | None = None
|
|
1059
|
+
) -> dict[str, Any]:
|
|
1060
|
+
schema = self.get_output_schema(config)
|
|
1061
|
+
return schema.model_json_schema()
|
|
1062
|
+
|
|
1063
|
+
@property
|
|
1064
|
+
def stream_channels_list(self) -> Sequence[str]:
|
|
1065
|
+
stream_channels = self.stream_channels_asis
|
|
1066
|
+
return (
|
|
1067
|
+
[stream_channels] if isinstance(stream_channels, str) else stream_channels
|
|
1068
|
+
)
|
|
1069
|
+
|
|
1070
|
+
@property
|
|
1071
|
+
def stream_channels_asis(self) -> str | Sequence[str]:
|
|
1072
|
+
return self.stream_channels or [
|
|
1073
|
+
k for k in self.channels if isinstance(self.channels[k], BaseChannel)
|
|
1074
|
+
]
|
|
1075
|
+
|
|
1076
|
+
def get_subgraphs(
|
|
1077
|
+
self, *, namespace: str | None = None, recurse: bool = False
|
|
1078
|
+
) -> Iterator[tuple[str, PregelProtocol]]:
|
|
1079
|
+
"""Get the subgraphs of the graph.
|
|
1080
|
+
|
|
1081
|
+
Args:
|
|
1082
|
+
namespace: The namespace to filter the subgraphs by.
|
|
1083
|
+
recurse: Whether to recurse into the subgraphs.
|
|
1084
|
+
If `False`, only the immediate subgraphs will be returned.
|
|
1085
|
+
|
|
1086
|
+
Returns:
|
|
1087
|
+
An iterator of the `(namespace, subgraph)` pairs.
|
|
1088
|
+
"""
|
|
1089
|
+
for name, node in self.nodes.items():
|
|
1090
|
+
# filter by prefix
|
|
1091
|
+
if namespace is not None:
|
|
1092
|
+
if not namespace.startswith(name):
|
|
1093
|
+
continue
|
|
1094
|
+
|
|
1095
|
+
# find the subgraph, if any
|
|
1096
|
+
graph = node.subgraphs[0] if node.subgraphs else None
|
|
1097
|
+
|
|
1098
|
+
# if found, yield recursively
|
|
1099
|
+
if graph:
|
|
1100
|
+
if name == namespace:
|
|
1101
|
+
yield name, graph
|
|
1102
|
+
return # we found it, stop searching
|
|
1103
|
+
if namespace is None:
|
|
1104
|
+
yield name, graph
|
|
1105
|
+
if recurse and isinstance(graph, Pregel):
|
|
1106
|
+
if namespace is not None:
|
|
1107
|
+
namespace = namespace[len(name) + 1 :]
|
|
1108
|
+
yield from (
|
|
1109
|
+
(f"{name}{NS_SEP}{n}", s)
|
|
1110
|
+
for n, s in graph.get_subgraphs(
|
|
1111
|
+
namespace=namespace, recurse=recurse
|
|
1112
|
+
)
|
|
1113
|
+
)
|
|
1114
|
+
|
|
1115
|
+
async def aget_subgraphs(
|
|
1116
|
+
self, *, namespace: str | None = None, recurse: bool = False
|
|
1117
|
+
) -> AsyncIterator[tuple[str, PregelProtocol]]:
|
|
1118
|
+
"""Get the subgraphs of the graph.
|
|
1119
|
+
|
|
1120
|
+
Args:
|
|
1121
|
+
namespace: The namespace to filter the subgraphs by.
|
|
1122
|
+
recurse: Whether to recurse into the subgraphs.
|
|
1123
|
+
If `False`, only the immediate subgraphs will be returned.
|
|
1124
|
+
|
|
1125
|
+
Returns:
|
|
1126
|
+
An iterator of the `(namespace, subgraph)` pairs.
|
|
1127
|
+
"""
|
|
1128
|
+
for name, node in self.get_subgraphs(namespace=namespace, recurse=recurse):
|
|
1129
|
+
yield name, node
|
|
1130
|
+
|
|
1131
|
+
# Mappers for v2 stream coercion (pydantic/dataclass).
|
|
1132
|
+
# Set by CompiledStateGraph; None for base Pregel.
|
|
1133
|
+
_output_mapper: Callable[[Any], Any] | None = None
|
|
1134
|
+
_state_mapper: Callable[[Any], Any] | None = None
|
|
1135
|
+
|
|
1136
|
+
def _migrate_checkpoint(self, checkpoint: Checkpoint) -> None:
|
|
1137
|
+
"""Migrate a saved checkpoint to new channel layout."""
|
|
1138
|
+
if checkpoint["v"] < 4 and checkpoint.get("pending_sends"):
|
|
1139
|
+
pending_sends: list[Send] = checkpoint.pop("pending_sends")
|
|
1140
|
+
checkpoint["channel_values"][TASKS] = pending_sends
|
|
1141
|
+
checkpoint["channel_versions"][TASKS] = max(
|
|
1142
|
+
checkpoint["channel_versions"].values()
|
|
1143
|
+
)
|
|
1144
|
+
|
|
1145
|
+
def _prepare_state_snapshot(
|
|
1146
|
+
self,
|
|
1147
|
+
config: RunnableConfig,
|
|
1148
|
+
saved: CheckpointTuple | None,
|
|
1149
|
+
recurse: BaseCheckpointSaver | None = None,
|
|
1150
|
+
apply_pending_writes: bool = False,
|
|
1151
|
+
) -> StateSnapshot:
|
|
1152
|
+
if not saved:
|
|
1153
|
+
return StateSnapshot(
|
|
1154
|
+
values={},
|
|
1155
|
+
next=(),
|
|
1156
|
+
config=config,
|
|
1157
|
+
metadata=None,
|
|
1158
|
+
created_at=None,
|
|
1159
|
+
parent_config=None,
|
|
1160
|
+
tasks=(),
|
|
1161
|
+
interrupts=(),
|
|
1162
|
+
)
|
|
1163
|
+
|
|
1164
|
+
# migrate checkpoint if needed
|
|
1165
|
+
self._migrate_checkpoint(saved.checkpoint)
|
|
1166
|
+
|
|
1167
|
+
step = saved.metadata.get("step", -1) + 1
|
|
1168
|
+
stop = step + 2
|
|
1169
|
+
channels, managed = channels_from_checkpoint(
|
|
1170
|
+
self.channels,
|
|
1171
|
+
saved.checkpoint,
|
|
1172
|
+
saver=self.checkpointer
|
|
1173
|
+
if isinstance(self.checkpointer, BaseCheckpointSaver)
|
|
1174
|
+
else None,
|
|
1175
|
+
config=saved.config,
|
|
1176
|
+
)
|
|
1177
|
+
# tasks for this checkpoint
|
|
1178
|
+
next_tasks = prepare_next_tasks(
|
|
1179
|
+
saved.checkpoint,
|
|
1180
|
+
saved.pending_writes or [],
|
|
1181
|
+
self.nodes,
|
|
1182
|
+
channels,
|
|
1183
|
+
managed,
|
|
1184
|
+
saved.config,
|
|
1185
|
+
step,
|
|
1186
|
+
stop,
|
|
1187
|
+
for_execution=True,
|
|
1188
|
+
store=self.store,
|
|
1189
|
+
checkpointer=(
|
|
1190
|
+
self.checkpointer
|
|
1191
|
+
if isinstance(self.checkpointer, BaseCheckpointSaver)
|
|
1192
|
+
else None
|
|
1193
|
+
),
|
|
1194
|
+
manager=None,
|
|
1195
|
+
)
|
|
1196
|
+
# get the subgraphs
|
|
1197
|
+
subgraphs = dict(self.get_subgraphs())
|
|
1198
|
+
parent_ns = saved.config[CONF].get(CONFIG_KEY_CHECKPOINT_NS, "")
|
|
1199
|
+
task_states: dict[str, RunnableConfig | StateSnapshot] = {}
|
|
1200
|
+
for task in next_tasks.values():
|
|
1201
|
+
if task.name not in subgraphs:
|
|
1202
|
+
continue
|
|
1203
|
+
# assemble checkpoint_ns for this task
|
|
1204
|
+
task_ns = f"{task.name}{NS_END}{task.id}"
|
|
1205
|
+
if parent_ns:
|
|
1206
|
+
task_ns = f"{parent_ns}{NS_SEP}{task_ns}"
|
|
1207
|
+
if not recurse:
|
|
1208
|
+
# set config as signal that subgraph checkpoints exist
|
|
1209
|
+
config = {
|
|
1210
|
+
CONF: {
|
|
1211
|
+
"thread_id": saved.config[CONF]["thread_id"],
|
|
1212
|
+
CONFIG_KEY_CHECKPOINT_NS: task_ns,
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
task_states[task.id] = config
|
|
1216
|
+
else:
|
|
1217
|
+
# get the state of the subgraph
|
|
1218
|
+
config = {
|
|
1219
|
+
CONF: {
|
|
1220
|
+
CONFIG_KEY_CHECKPOINTER: recurse,
|
|
1221
|
+
"thread_id": saved.config[CONF]["thread_id"],
|
|
1222
|
+
CONFIG_KEY_CHECKPOINT_NS: task_ns,
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
task_states[task.id] = subgraphs[task.name].get_state(
|
|
1226
|
+
config, subgraphs=True
|
|
1227
|
+
)
|
|
1228
|
+
# apply pending writes
|
|
1229
|
+
if null_writes := [
|
|
1230
|
+
w[1:] for w in saved.pending_writes or [] if w[0] == NULL_TASK_ID
|
|
1231
|
+
]:
|
|
1232
|
+
apply_writes(
|
|
1233
|
+
saved.checkpoint,
|
|
1234
|
+
channels,
|
|
1235
|
+
[PregelTaskWrites((), INPUT, null_writes, [])],
|
|
1236
|
+
None,
|
|
1237
|
+
self.trigger_to_nodes,
|
|
1238
|
+
)
|
|
1239
|
+
if apply_pending_writes and saved.pending_writes:
|
|
1240
|
+
for tid, k, v in saved.pending_writes:
|
|
1241
|
+
if k in (ERROR, INTERRUPT):
|
|
1242
|
+
continue
|
|
1243
|
+
if tid not in next_tasks:
|
|
1244
|
+
continue
|
|
1245
|
+
next_tasks[tid].writes.append((k, v))
|
|
1246
|
+
if tasks := [t for t in next_tasks.values() if t.writes]:
|
|
1247
|
+
apply_writes(
|
|
1248
|
+
saved.checkpoint, channels, tasks, None, self.trigger_to_nodes
|
|
1249
|
+
)
|
|
1250
|
+
tasks_with_writes = tasks_w_writes(
|
|
1251
|
+
next_tasks.values(),
|
|
1252
|
+
saved.pending_writes,
|
|
1253
|
+
task_states,
|
|
1254
|
+
self.stream_channels_asis,
|
|
1255
|
+
)
|
|
1256
|
+
# assemble the state snapshot
|
|
1257
|
+
return StateSnapshot(
|
|
1258
|
+
read_channels(channels, self.stream_channels_asis),
|
|
1259
|
+
tuple(t.name for t in next_tasks.values() if not t.writes),
|
|
1260
|
+
patch_checkpoint_map(saved.config, saved.metadata),
|
|
1261
|
+
saved.metadata,
|
|
1262
|
+
saved.checkpoint["ts"],
|
|
1263
|
+
patch_checkpoint_map(saved.parent_config, saved.metadata),
|
|
1264
|
+
tasks_with_writes,
|
|
1265
|
+
tuple([i for task in tasks_with_writes for i in task.interrupts]),
|
|
1266
|
+
)
|
|
1267
|
+
|
|
1268
|
+
async def _aprepare_state_snapshot(
|
|
1269
|
+
self,
|
|
1270
|
+
config: RunnableConfig,
|
|
1271
|
+
saved: CheckpointTuple | None,
|
|
1272
|
+
recurse: BaseCheckpointSaver | None = None,
|
|
1273
|
+
apply_pending_writes: bool = False,
|
|
1274
|
+
) -> StateSnapshot:
|
|
1275
|
+
if not saved:
|
|
1276
|
+
return StateSnapshot(
|
|
1277
|
+
values={},
|
|
1278
|
+
next=(),
|
|
1279
|
+
config=config,
|
|
1280
|
+
metadata=None,
|
|
1281
|
+
created_at=None,
|
|
1282
|
+
parent_config=None,
|
|
1283
|
+
tasks=(),
|
|
1284
|
+
interrupts=(),
|
|
1285
|
+
)
|
|
1286
|
+
|
|
1287
|
+
# migrate checkpoint if needed
|
|
1288
|
+
self._migrate_checkpoint(saved.checkpoint)
|
|
1289
|
+
|
|
1290
|
+
step = saved.metadata.get("step", -1) + 1
|
|
1291
|
+
stop = step + 2
|
|
1292
|
+
channels, managed = await achannels_from_checkpoint(
|
|
1293
|
+
self.channels,
|
|
1294
|
+
saved.checkpoint,
|
|
1295
|
+
saver=self.checkpointer
|
|
1296
|
+
if isinstance(self.checkpointer, BaseCheckpointSaver)
|
|
1297
|
+
else None,
|
|
1298
|
+
config=saved.config,
|
|
1299
|
+
)
|
|
1300
|
+
# tasks for this checkpoint
|
|
1301
|
+
next_tasks = prepare_next_tasks(
|
|
1302
|
+
saved.checkpoint,
|
|
1303
|
+
saved.pending_writes or [],
|
|
1304
|
+
self.nodes,
|
|
1305
|
+
channels,
|
|
1306
|
+
managed,
|
|
1307
|
+
saved.config,
|
|
1308
|
+
step,
|
|
1309
|
+
stop,
|
|
1310
|
+
for_execution=True,
|
|
1311
|
+
store=self.store,
|
|
1312
|
+
checkpointer=(
|
|
1313
|
+
self.checkpointer
|
|
1314
|
+
if isinstance(self.checkpointer, BaseCheckpointSaver)
|
|
1315
|
+
else None
|
|
1316
|
+
),
|
|
1317
|
+
manager=None,
|
|
1318
|
+
)
|
|
1319
|
+
# get the subgraphs
|
|
1320
|
+
subgraphs = {n: g async for n, g in self.aget_subgraphs()}
|
|
1321
|
+
parent_ns = saved.config[CONF].get(CONFIG_KEY_CHECKPOINT_NS, "")
|
|
1322
|
+
task_states: dict[str, RunnableConfig | StateSnapshot] = {}
|
|
1323
|
+
for task in next_tasks.values():
|
|
1324
|
+
if task.name not in subgraphs:
|
|
1325
|
+
continue
|
|
1326
|
+
# assemble checkpoint_ns for this task
|
|
1327
|
+
task_ns = f"{task.name}{NS_END}{task.id}"
|
|
1328
|
+
if parent_ns:
|
|
1329
|
+
task_ns = f"{parent_ns}{NS_SEP}{task_ns}"
|
|
1330
|
+
if not recurse:
|
|
1331
|
+
# set config as signal that subgraph checkpoints exist
|
|
1332
|
+
config = {
|
|
1333
|
+
CONF: {
|
|
1334
|
+
"thread_id": saved.config[CONF]["thread_id"],
|
|
1335
|
+
CONFIG_KEY_CHECKPOINT_NS: task_ns,
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
task_states[task.id] = config
|
|
1339
|
+
else:
|
|
1340
|
+
# get the state of the subgraph
|
|
1341
|
+
config = {
|
|
1342
|
+
CONF: {
|
|
1343
|
+
CONFIG_KEY_CHECKPOINTER: recurse,
|
|
1344
|
+
"thread_id": saved.config[CONF]["thread_id"],
|
|
1345
|
+
CONFIG_KEY_CHECKPOINT_NS: task_ns,
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
task_states[task.id] = await subgraphs[task.name].aget_state(
|
|
1349
|
+
config, subgraphs=True
|
|
1350
|
+
)
|
|
1351
|
+
# apply pending writes
|
|
1352
|
+
if null_writes := [
|
|
1353
|
+
w[1:] for w in saved.pending_writes or [] if w[0] == NULL_TASK_ID
|
|
1354
|
+
]:
|
|
1355
|
+
apply_writes(
|
|
1356
|
+
saved.checkpoint,
|
|
1357
|
+
channels,
|
|
1358
|
+
[PregelTaskWrites((), INPUT, null_writes, [])],
|
|
1359
|
+
None,
|
|
1360
|
+
self.trigger_to_nodes,
|
|
1361
|
+
)
|
|
1362
|
+
if apply_pending_writes and saved.pending_writes:
|
|
1363
|
+
for tid, k, v in saved.pending_writes:
|
|
1364
|
+
if k in (ERROR, INTERRUPT):
|
|
1365
|
+
continue
|
|
1366
|
+
if tid not in next_tasks:
|
|
1367
|
+
continue
|
|
1368
|
+
next_tasks[tid].writes.append((k, v))
|
|
1369
|
+
if tasks := [t for t in next_tasks.values() if t.writes]:
|
|
1370
|
+
apply_writes(
|
|
1371
|
+
saved.checkpoint, channels, tasks, None, self.trigger_to_nodes
|
|
1372
|
+
)
|
|
1373
|
+
|
|
1374
|
+
tasks_with_writes = tasks_w_writes(
|
|
1375
|
+
next_tasks.values(),
|
|
1376
|
+
saved.pending_writes,
|
|
1377
|
+
task_states,
|
|
1378
|
+
self.stream_channels_asis,
|
|
1379
|
+
)
|
|
1380
|
+
# assemble the state snapshot
|
|
1381
|
+
return StateSnapshot(
|
|
1382
|
+
read_channels(channels, self.stream_channels_asis),
|
|
1383
|
+
tuple(t.name for t in next_tasks.values() if not t.writes),
|
|
1384
|
+
patch_checkpoint_map(saved.config, saved.metadata),
|
|
1385
|
+
saved.metadata,
|
|
1386
|
+
saved.checkpoint["ts"],
|
|
1387
|
+
patch_checkpoint_map(saved.parent_config, saved.metadata),
|
|
1388
|
+
tasks_with_writes,
|
|
1389
|
+
tuple([i for task in tasks_with_writes for i in task.interrupts]),
|
|
1390
|
+
)
|
|
1391
|
+
|
|
1392
|
+
def get_state(
|
|
1393
|
+
self, config: RunnableConfig, *, subgraphs: bool = False
|
|
1394
|
+
) -> StateSnapshot:
|
|
1395
|
+
"""Get the current state of the graph."""
|
|
1396
|
+
checkpointer: BaseCheckpointSaver | None = ensure_config(config)[CONF].get(
|
|
1397
|
+
CONFIG_KEY_CHECKPOINTER, self.checkpointer
|
|
1398
|
+
)
|
|
1399
|
+
if isinstance(checkpointer, BaseCheckpointSaver):
|
|
1400
|
+
checkpointer = self._apply_checkpointer_allowlist(checkpointer)
|
|
1401
|
+
if not checkpointer:
|
|
1402
|
+
raise ValueError("No checkpointer set")
|
|
1403
|
+
|
|
1404
|
+
if (
|
|
1405
|
+
checkpoint_ns := config[CONF].get(CONFIG_KEY_CHECKPOINT_NS, "")
|
|
1406
|
+
) and CONFIG_KEY_CHECKPOINTER not in config[CONF]:
|
|
1407
|
+
# remove task_ids from checkpoint_ns
|
|
1408
|
+
recast = recast_checkpoint_ns(checkpoint_ns)
|
|
1409
|
+
# find the subgraph with the matching name
|
|
1410
|
+
for _, pregel in self.get_subgraphs(namespace=recast, recurse=True):
|
|
1411
|
+
return pregel.get_state(
|
|
1412
|
+
patch_configurable(config, {CONFIG_KEY_CHECKPOINTER: checkpointer}),
|
|
1413
|
+
subgraphs=subgraphs,
|
|
1414
|
+
)
|
|
1415
|
+
else:
|
|
1416
|
+
raise ValueError(f"Subgraph {recast} not found")
|
|
1417
|
+
|
|
1418
|
+
config = merge_configs(self.config, config) if self.config else config
|
|
1419
|
+
if self.checkpointer is True:
|
|
1420
|
+
ns = cast(str, config[CONF][CONFIG_KEY_CHECKPOINT_NS])
|
|
1421
|
+
config = merge_configs(
|
|
1422
|
+
config, {CONF: {CONFIG_KEY_CHECKPOINT_NS: recast_checkpoint_ns(ns)}}
|
|
1423
|
+
)
|
|
1424
|
+
thread_id = config[CONF][CONFIG_KEY_THREAD_ID]
|
|
1425
|
+
if not isinstance(thread_id, str):
|
|
1426
|
+
config[CONF][CONFIG_KEY_THREAD_ID] = str(thread_id)
|
|
1427
|
+
|
|
1428
|
+
saved = checkpointer.get_tuple(config)
|
|
1429
|
+
return self._prepare_state_snapshot(
|
|
1430
|
+
config,
|
|
1431
|
+
saved,
|
|
1432
|
+
recurse=checkpointer if subgraphs else None,
|
|
1433
|
+
apply_pending_writes=CONFIG_KEY_CHECKPOINT_ID not in config[CONF],
|
|
1434
|
+
)
|
|
1435
|
+
|
|
1436
|
+
async def aget_state(
|
|
1437
|
+
self, config: RunnableConfig, *, subgraphs: bool = False
|
|
1438
|
+
) -> StateSnapshot:
|
|
1439
|
+
"""Get the current state of the graph."""
|
|
1440
|
+
checkpointer: BaseCheckpointSaver | None = ensure_config(config)[CONF].get(
|
|
1441
|
+
CONFIG_KEY_CHECKPOINTER, self.checkpointer
|
|
1442
|
+
)
|
|
1443
|
+
if isinstance(checkpointer, BaseCheckpointSaver):
|
|
1444
|
+
checkpointer = self._apply_checkpointer_allowlist(checkpointer)
|
|
1445
|
+
if not checkpointer:
|
|
1446
|
+
raise ValueError("No checkpointer set")
|
|
1447
|
+
|
|
1448
|
+
if (
|
|
1449
|
+
checkpoint_ns := config[CONF].get(CONFIG_KEY_CHECKPOINT_NS, "")
|
|
1450
|
+
) and CONFIG_KEY_CHECKPOINTER not in config[CONF]:
|
|
1451
|
+
# remove task_ids from checkpoint_ns
|
|
1452
|
+
recast = recast_checkpoint_ns(checkpoint_ns)
|
|
1453
|
+
# find the subgraph with the matching name
|
|
1454
|
+
async for _, pregel in self.aget_subgraphs(namespace=recast, recurse=True):
|
|
1455
|
+
return await pregel.aget_state(
|
|
1456
|
+
patch_configurable(config, {CONFIG_KEY_CHECKPOINTER: checkpointer}),
|
|
1457
|
+
subgraphs=subgraphs,
|
|
1458
|
+
)
|
|
1459
|
+
else:
|
|
1460
|
+
raise ValueError(f"Subgraph {recast} not found")
|
|
1461
|
+
|
|
1462
|
+
config = merge_configs(self.config, config) if self.config else config
|
|
1463
|
+
if self.checkpointer is True:
|
|
1464
|
+
ns = cast(str, config[CONF][CONFIG_KEY_CHECKPOINT_NS])
|
|
1465
|
+
config = merge_configs(
|
|
1466
|
+
config, {CONF: {CONFIG_KEY_CHECKPOINT_NS: recast_checkpoint_ns(ns)}}
|
|
1467
|
+
)
|
|
1468
|
+
thread_id = config[CONF][CONFIG_KEY_THREAD_ID]
|
|
1469
|
+
if not isinstance(thread_id, str):
|
|
1470
|
+
config[CONF][CONFIG_KEY_THREAD_ID] = str(thread_id)
|
|
1471
|
+
|
|
1472
|
+
saved = await checkpointer.aget_tuple(config)
|
|
1473
|
+
return await self._aprepare_state_snapshot(
|
|
1474
|
+
config,
|
|
1475
|
+
saved,
|
|
1476
|
+
recurse=checkpointer if subgraphs else None,
|
|
1477
|
+
apply_pending_writes=CONFIG_KEY_CHECKPOINT_ID not in config[CONF],
|
|
1478
|
+
)
|
|
1479
|
+
|
|
1480
|
+
def get_state_history(
|
|
1481
|
+
self,
|
|
1482
|
+
config: RunnableConfig,
|
|
1483
|
+
*,
|
|
1484
|
+
filter: dict[str, Any] | None = None,
|
|
1485
|
+
before: RunnableConfig | None = None,
|
|
1486
|
+
limit: int | None = None,
|
|
1487
|
+
) -> Iterator[StateSnapshot]:
|
|
1488
|
+
"""Get the history of the state of the graph."""
|
|
1489
|
+
config = ensure_config(config)
|
|
1490
|
+
checkpointer: BaseCheckpointSaver | None = config[CONF].get(
|
|
1491
|
+
CONFIG_KEY_CHECKPOINTER, self.checkpointer
|
|
1492
|
+
)
|
|
1493
|
+
if isinstance(checkpointer, BaseCheckpointSaver):
|
|
1494
|
+
checkpointer = self._apply_checkpointer_allowlist(checkpointer)
|
|
1495
|
+
if not checkpointer:
|
|
1496
|
+
raise ValueError("No checkpointer set")
|
|
1497
|
+
|
|
1498
|
+
if (
|
|
1499
|
+
checkpoint_ns := config[CONF].get(CONFIG_KEY_CHECKPOINT_NS, "")
|
|
1500
|
+
) and CONFIG_KEY_CHECKPOINTER not in config[CONF]:
|
|
1501
|
+
# remove task_ids from checkpoint_ns
|
|
1502
|
+
recast = recast_checkpoint_ns(checkpoint_ns)
|
|
1503
|
+
# find the subgraph with the matching name
|
|
1504
|
+
for _, pregel in self.get_subgraphs(namespace=recast, recurse=True):
|
|
1505
|
+
yield from pregel.get_state_history(
|
|
1506
|
+
patch_configurable(config, {CONFIG_KEY_CHECKPOINTER: checkpointer}),
|
|
1507
|
+
filter=filter,
|
|
1508
|
+
before=before,
|
|
1509
|
+
limit=limit,
|
|
1510
|
+
)
|
|
1511
|
+
return
|
|
1512
|
+
else:
|
|
1513
|
+
raise ValueError(f"Subgraph {recast} not found")
|
|
1514
|
+
|
|
1515
|
+
config = merge_configs(
|
|
1516
|
+
self.config,
|
|
1517
|
+
config,
|
|
1518
|
+
{
|
|
1519
|
+
CONF: {
|
|
1520
|
+
CONFIG_KEY_CHECKPOINT_NS: checkpoint_ns,
|
|
1521
|
+
CONFIG_KEY_THREAD_ID: str(config[CONF][CONFIG_KEY_THREAD_ID]),
|
|
1522
|
+
}
|
|
1523
|
+
},
|
|
1524
|
+
)
|
|
1525
|
+
# eagerly consume list() to avoid holding up the db cursor
|
|
1526
|
+
for checkpoint_tuple in list(
|
|
1527
|
+
checkpointer.list(config, before=before, limit=limit, filter=filter)
|
|
1528
|
+
):
|
|
1529
|
+
yield self._prepare_state_snapshot(
|
|
1530
|
+
checkpoint_tuple.config, checkpoint_tuple
|
|
1531
|
+
)
|
|
1532
|
+
|
|
1533
|
+
async def aget_state_history(
|
|
1534
|
+
self,
|
|
1535
|
+
config: RunnableConfig,
|
|
1536
|
+
*,
|
|
1537
|
+
filter: dict[str, Any] | None = None,
|
|
1538
|
+
before: RunnableConfig | None = None,
|
|
1539
|
+
limit: int | None = None,
|
|
1540
|
+
) -> AsyncIterator[StateSnapshot]:
|
|
1541
|
+
"""Asynchronously get the history of the state of the graph."""
|
|
1542
|
+
config = ensure_config(config)
|
|
1543
|
+
checkpointer: BaseCheckpointSaver | None = ensure_config(config)[CONF].get(
|
|
1544
|
+
CONFIG_KEY_CHECKPOINTER, self.checkpointer
|
|
1545
|
+
)
|
|
1546
|
+
if isinstance(checkpointer, BaseCheckpointSaver):
|
|
1547
|
+
checkpointer = self._apply_checkpointer_allowlist(checkpointer)
|
|
1548
|
+
if not checkpointer:
|
|
1549
|
+
raise ValueError("No checkpointer set")
|
|
1550
|
+
|
|
1551
|
+
if (
|
|
1552
|
+
checkpoint_ns := config[CONF].get(CONFIG_KEY_CHECKPOINT_NS, "")
|
|
1553
|
+
) and CONFIG_KEY_CHECKPOINTER not in config[CONF]:
|
|
1554
|
+
# remove task_ids from checkpoint_ns
|
|
1555
|
+
recast = recast_checkpoint_ns(checkpoint_ns)
|
|
1556
|
+
# find the subgraph with the matching name
|
|
1557
|
+
async for _, pregel in self.aget_subgraphs(namespace=recast, recurse=True):
|
|
1558
|
+
async for state in pregel.aget_state_history(
|
|
1559
|
+
patch_configurable(config, {CONFIG_KEY_CHECKPOINTER: checkpointer}),
|
|
1560
|
+
filter=filter,
|
|
1561
|
+
before=before,
|
|
1562
|
+
limit=limit,
|
|
1563
|
+
):
|
|
1564
|
+
yield state
|
|
1565
|
+
return
|
|
1566
|
+
else:
|
|
1567
|
+
raise ValueError(f"Subgraph {recast} not found")
|
|
1568
|
+
|
|
1569
|
+
config = merge_configs(
|
|
1570
|
+
self.config,
|
|
1571
|
+
config,
|
|
1572
|
+
{
|
|
1573
|
+
CONF: {
|
|
1574
|
+
CONFIG_KEY_CHECKPOINT_NS: checkpoint_ns,
|
|
1575
|
+
CONFIG_KEY_THREAD_ID: str(config[CONF][CONFIG_KEY_THREAD_ID]),
|
|
1576
|
+
}
|
|
1577
|
+
},
|
|
1578
|
+
)
|
|
1579
|
+
# eagerly consume list() to avoid holding up the db cursor
|
|
1580
|
+
for checkpoint_tuple in [
|
|
1581
|
+
c
|
|
1582
|
+
async for c in checkpointer.alist(
|
|
1583
|
+
config, before=before, limit=limit, filter=filter
|
|
1584
|
+
)
|
|
1585
|
+
]:
|
|
1586
|
+
yield await self._aprepare_state_snapshot(
|
|
1587
|
+
checkpoint_tuple.config, checkpoint_tuple
|
|
1588
|
+
)
|
|
1589
|
+
|
|
1590
|
+
def bulk_update_state(
|
|
1591
|
+
self,
|
|
1592
|
+
config: RunnableConfig,
|
|
1593
|
+
supersteps: Sequence[Sequence[StateUpdate]],
|
|
1594
|
+
) -> RunnableConfig:
|
|
1595
|
+
"""Apply updates to the graph state in bulk. Requires a checkpointer to be set.
|
|
1596
|
+
|
|
1597
|
+
Args:
|
|
1598
|
+
config: The config to apply the updates to.
|
|
1599
|
+
supersteps: A list of supersteps, each including a list of updates to apply sequentially to a graph state.
|
|
1600
|
+
|
|
1601
|
+
Each update is a tuple of the form `(values, as_node, task_id)` where `task_id` is optional.
|
|
1602
|
+
|
|
1603
|
+
Raises:
|
|
1604
|
+
ValueError: If no checkpointer is set or no updates are provided.
|
|
1605
|
+
InvalidUpdateError: If an invalid update is provided.
|
|
1606
|
+
|
|
1607
|
+
Returns:
|
|
1608
|
+
RunnableConfig: The updated config.
|
|
1609
|
+
"""
|
|
1610
|
+
|
|
1611
|
+
checkpointer: BaseCheckpointSaver | None = ensure_config(config)[CONF].get(
|
|
1612
|
+
CONFIG_KEY_CHECKPOINTER, self.checkpointer
|
|
1613
|
+
)
|
|
1614
|
+
if isinstance(checkpointer, BaseCheckpointSaver):
|
|
1615
|
+
checkpointer = self._apply_checkpointer_allowlist(checkpointer)
|
|
1616
|
+
if not checkpointer:
|
|
1617
|
+
raise ValueError("No checkpointer set")
|
|
1618
|
+
|
|
1619
|
+
if len(supersteps) == 0:
|
|
1620
|
+
raise ValueError("No supersteps provided")
|
|
1621
|
+
|
|
1622
|
+
if any(len(u) == 0 for u in supersteps):
|
|
1623
|
+
raise ValueError("No updates provided")
|
|
1624
|
+
|
|
1625
|
+
# delegate to subgraph
|
|
1626
|
+
if (
|
|
1627
|
+
checkpoint_ns := config[CONF].get(CONFIG_KEY_CHECKPOINT_NS, "")
|
|
1628
|
+
) and CONFIG_KEY_CHECKPOINTER not in config[CONF]:
|
|
1629
|
+
# remove task_ids from checkpoint_ns
|
|
1630
|
+
recast = recast_checkpoint_ns(checkpoint_ns)
|
|
1631
|
+
# find the subgraph with the matching name
|
|
1632
|
+
for _, pregel in self.get_subgraphs(namespace=recast, recurse=True):
|
|
1633
|
+
return pregel.bulk_update_state(
|
|
1634
|
+
patch_configurable(config, {CONFIG_KEY_CHECKPOINTER: checkpointer}),
|
|
1635
|
+
supersteps,
|
|
1636
|
+
)
|
|
1637
|
+
else:
|
|
1638
|
+
raise ValueError(f"Subgraph {recast} not found")
|
|
1639
|
+
|
|
1640
|
+
def perform_superstep(
|
|
1641
|
+
input_config: RunnableConfig, updates: Sequence[StateUpdate]
|
|
1642
|
+
) -> RunnableConfig:
|
|
1643
|
+
# get last checkpoint
|
|
1644
|
+
config = ensure_config(self.config, input_config)
|
|
1645
|
+
saved = checkpointer.get_tuple(config)
|
|
1646
|
+
if saved is not None:
|
|
1647
|
+
self._migrate_checkpoint(saved.checkpoint)
|
|
1648
|
+
checkpoint = (
|
|
1649
|
+
copy_checkpoint(saved.checkpoint) if saved else empty_checkpoint()
|
|
1650
|
+
)
|
|
1651
|
+
checkpoint_previous_versions = (
|
|
1652
|
+
saved.checkpoint["channel_versions"].copy() if saved else {}
|
|
1653
|
+
)
|
|
1654
|
+
step = saved.metadata.get("step", -1) if saved else -1
|
|
1655
|
+
# merge configurable fields with previous checkpoint config
|
|
1656
|
+
checkpoint_config = patch_configurable(
|
|
1657
|
+
config,
|
|
1658
|
+
{
|
|
1659
|
+
CONFIG_KEY_CHECKPOINT_NS: config[CONF].get(
|
|
1660
|
+
CONFIG_KEY_CHECKPOINT_NS, ""
|
|
1661
|
+
)
|
|
1662
|
+
},
|
|
1663
|
+
)
|
|
1664
|
+
if saved:
|
|
1665
|
+
checkpoint_config = patch_configurable(config, saved.config[CONF])
|
|
1666
|
+
channels, managed = channels_from_checkpoint(
|
|
1667
|
+
self.channels,
|
|
1668
|
+
checkpoint,
|
|
1669
|
+
saver=self.checkpointer
|
|
1670
|
+
if saved is not None
|
|
1671
|
+
and isinstance(self.checkpointer, BaseCheckpointSaver)
|
|
1672
|
+
else None,
|
|
1673
|
+
config=saved.config if saved is not None else None,
|
|
1674
|
+
)
|
|
1675
|
+
values, as_node = updates[0][:2]
|
|
1676
|
+
|
|
1677
|
+
# no values as END, just clear all tasks
|
|
1678
|
+
if values is None and as_node == END:
|
|
1679
|
+
if len(updates) > 1:
|
|
1680
|
+
raise InvalidUpdateError(
|
|
1681
|
+
"Cannot apply multiple updates when clearing state"
|
|
1682
|
+
)
|
|
1683
|
+
|
|
1684
|
+
if saved is not None:
|
|
1685
|
+
# tasks for this checkpoint
|
|
1686
|
+
next_tasks = prepare_next_tasks(
|
|
1687
|
+
checkpoint,
|
|
1688
|
+
saved.pending_writes or [],
|
|
1689
|
+
self.nodes,
|
|
1690
|
+
channels,
|
|
1691
|
+
managed,
|
|
1692
|
+
saved.config,
|
|
1693
|
+
step + 1,
|
|
1694
|
+
step + 3,
|
|
1695
|
+
for_execution=True,
|
|
1696
|
+
store=self.store,
|
|
1697
|
+
checkpointer=checkpointer,
|
|
1698
|
+
manager=None,
|
|
1699
|
+
)
|
|
1700
|
+
# apply null writes
|
|
1701
|
+
if null_writes := [
|
|
1702
|
+
w[1:]
|
|
1703
|
+
for w in saved.pending_writes or []
|
|
1704
|
+
if w[0] == NULL_TASK_ID
|
|
1705
|
+
]:
|
|
1706
|
+
apply_writes(
|
|
1707
|
+
checkpoint,
|
|
1708
|
+
channels,
|
|
1709
|
+
[PregelTaskWrites((), INPUT, null_writes, [])],
|
|
1710
|
+
checkpointer.get_next_version,
|
|
1711
|
+
self.trigger_to_nodes,
|
|
1712
|
+
)
|
|
1713
|
+
# apply writes from tasks that already ran
|
|
1714
|
+
for tid, k, v in saved.pending_writes or []:
|
|
1715
|
+
if k in (ERROR, INTERRUPT):
|
|
1716
|
+
continue
|
|
1717
|
+
if tid not in next_tasks:
|
|
1718
|
+
continue
|
|
1719
|
+
next_tasks[tid].writes.append((k, v))
|
|
1720
|
+
# clear all current tasks
|
|
1721
|
+
apply_writes(
|
|
1722
|
+
checkpoint,
|
|
1723
|
+
channels,
|
|
1724
|
+
next_tasks.values(),
|
|
1725
|
+
checkpointer.get_next_version,
|
|
1726
|
+
self.trigger_to_nodes,
|
|
1727
|
+
)
|
|
1728
|
+
# save checkpoint
|
|
1729
|
+
next_config = checkpointer.put(
|
|
1730
|
+
checkpoint_config,
|
|
1731
|
+
create_checkpoint(checkpoint, channels, step),
|
|
1732
|
+
{
|
|
1733
|
+
"source": "update",
|
|
1734
|
+
"step": step + 1,
|
|
1735
|
+
"parents": saved.metadata.get("parents", {}) if saved else {},
|
|
1736
|
+
},
|
|
1737
|
+
get_new_channel_versions(
|
|
1738
|
+
checkpoint_previous_versions,
|
|
1739
|
+
checkpoint["channel_versions"],
|
|
1740
|
+
),
|
|
1741
|
+
)
|
|
1742
|
+
return patch_checkpoint_map(
|
|
1743
|
+
next_config, saved.metadata if saved else None
|
|
1744
|
+
)
|
|
1745
|
+
|
|
1746
|
+
# act as an input
|
|
1747
|
+
if as_node == INPUT:
|
|
1748
|
+
if len(updates) > 1:
|
|
1749
|
+
raise InvalidUpdateError(
|
|
1750
|
+
"Cannot apply multiple updates when updating as input"
|
|
1751
|
+
)
|
|
1752
|
+
|
|
1753
|
+
if input_writes := deque(map_input(self.input_channels, values)):
|
|
1754
|
+
apply_writes(
|
|
1755
|
+
checkpoint,
|
|
1756
|
+
channels,
|
|
1757
|
+
[PregelTaskWrites((), INPUT, input_writes, [])],
|
|
1758
|
+
checkpointer.get_next_version,
|
|
1759
|
+
self.trigger_to_nodes,
|
|
1760
|
+
)
|
|
1761
|
+
|
|
1762
|
+
# apply input write to channels
|
|
1763
|
+
next_step = (
|
|
1764
|
+
step + 1
|
|
1765
|
+
if saved and saved.metadata.get("step") is not None
|
|
1766
|
+
else -1
|
|
1767
|
+
)
|
|
1768
|
+
next_config = checkpointer.put(
|
|
1769
|
+
checkpoint_config,
|
|
1770
|
+
create_checkpoint(checkpoint, channels, next_step),
|
|
1771
|
+
{
|
|
1772
|
+
"source": "input",
|
|
1773
|
+
"step": next_step,
|
|
1774
|
+
"parents": saved.metadata.get("parents", {})
|
|
1775
|
+
if saved
|
|
1776
|
+
else {},
|
|
1777
|
+
},
|
|
1778
|
+
get_new_channel_versions(
|
|
1779
|
+
checkpoint_previous_versions,
|
|
1780
|
+
checkpoint["channel_versions"],
|
|
1781
|
+
),
|
|
1782
|
+
)
|
|
1783
|
+
|
|
1784
|
+
# store the writes
|
|
1785
|
+
checkpointer.put_writes(
|
|
1786
|
+
next_config,
|
|
1787
|
+
input_writes,
|
|
1788
|
+
str(uuid5(UUID(checkpoint["id"]), INPUT)),
|
|
1789
|
+
)
|
|
1790
|
+
|
|
1791
|
+
return patch_checkpoint_map(
|
|
1792
|
+
next_config, saved.metadata if saved else None
|
|
1793
|
+
)
|
|
1794
|
+
else:
|
|
1795
|
+
raise InvalidUpdateError(
|
|
1796
|
+
f"Received no input writes for {self.input_channels}"
|
|
1797
|
+
)
|
|
1798
|
+
|
|
1799
|
+
# copy checkpoint
|
|
1800
|
+
if as_node == "__copy__":
|
|
1801
|
+
if len(updates) > 1:
|
|
1802
|
+
raise InvalidUpdateError(
|
|
1803
|
+
"Cannot copy checkpoint with multiple updates"
|
|
1804
|
+
)
|
|
1805
|
+
|
|
1806
|
+
if saved is None:
|
|
1807
|
+
raise InvalidUpdateError("Cannot copy a non-existent checkpoint")
|
|
1808
|
+
|
|
1809
|
+
next_checkpoint = create_checkpoint(checkpoint, None, step)
|
|
1810
|
+
|
|
1811
|
+
# copy checkpoint
|
|
1812
|
+
next_config = checkpointer.put(
|
|
1813
|
+
saved.parent_config
|
|
1814
|
+
or patch_configurable(
|
|
1815
|
+
saved.config, {CONFIG_KEY_CHECKPOINT_ID: None}
|
|
1816
|
+
),
|
|
1817
|
+
next_checkpoint,
|
|
1818
|
+
{
|
|
1819
|
+
"source": "fork",
|
|
1820
|
+
"step": step + 1,
|
|
1821
|
+
"parents": saved.metadata.get("parents", {}),
|
|
1822
|
+
},
|
|
1823
|
+
{},
|
|
1824
|
+
)
|
|
1825
|
+
|
|
1826
|
+
# we want to both clone a checkpoint and update state in one go.
|
|
1827
|
+
# reuse the same task ID if possible.
|
|
1828
|
+
if isinstance(values, list) and len(values) > 0:
|
|
1829
|
+
# figure out the task IDs for the next update checkpoint
|
|
1830
|
+
next_tasks = prepare_next_tasks(
|
|
1831
|
+
next_checkpoint,
|
|
1832
|
+
saved.pending_writes or [],
|
|
1833
|
+
self.nodes,
|
|
1834
|
+
channels,
|
|
1835
|
+
managed,
|
|
1836
|
+
next_config,
|
|
1837
|
+
step + 2,
|
|
1838
|
+
step + 4,
|
|
1839
|
+
for_execution=True,
|
|
1840
|
+
store=self.store,
|
|
1841
|
+
checkpointer=checkpointer,
|
|
1842
|
+
manager=None,
|
|
1843
|
+
)
|
|
1844
|
+
|
|
1845
|
+
tasks_group_by = defaultdict(list)
|
|
1846
|
+
user_group_by: dict[str, list[StateUpdate]] = defaultdict(list)
|
|
1847
|
+
|
|
1848
|
+
for task in next_tasks.values():
|
|
1849
|
+
tasks_group_by[task.name].append(task.id)
|
|
1850
|
+
|
|
1851
|
+
for item in values:
|
|
1852
|
+
if not isinstance(item, Sequence):
|
|
1853
|
+
raise InvalidUpdateError(
|
|
1854
|
+
f"Invalid update item: {item} when copying checkpoint"
|
|
1855
|
+
)
|
|
1856
|
+
|
|
1857
|
+
values, as_node = item[:2]
|
|
1858
|
+
|
|
1859
|
+
user_group = user_group_by[as_node]
|
|
1860
|
+
tasks_group = tasks_group_by[as_node]
|
|
1861
|
+
|
|
1862
|
+
target_idx = len(user_group)
|
|
1863
|
+
task_id = (
|
|
1864
|
+
tasks_group[target_idx]
|
|
1865
|
+
if target_idx < len(tasks_group)
|
|
1866
|
+
else None
|
|
1867
|
+
)
|
|
1868
|
+
|
|
1869
|
+
user_group_by[as_node].append(
|
|
1870
|
+
StateUpdate(values=values, as_node=as_node, task_id=task_id)
|
|
1871
|
+
)
|
|
1872
|
+
|
|
1873
|
+
return perform_superstep(
|
|
1874
|
+
patch_checkpoint_map(next_config, saved.metadata),
|
|
1875
|
+
[item for lst in user_group_by.values() for item in lst],
|
|
1876
|
+
)
|
|
1877
|
+
|
|
1878
|
+
return patch_checkpoint_map(next_config, saved.metadata)
|
|
1879
|
+
|
|
1880
|
+
# task ids can be provided in the StateUpdate, but if not,
|
|
1881
|
+
# we use the task id generated by prepare_next_tasks
|
|
1882
|
+
node_to_task_ids: dict[str, deque[str]] = defaultdict(deque)
|
|
1883
|
+
if saved is not None and saved.pending_writes is not None:
|
|
1884
|
+
# we call prepare_next_tasks to discover the task IDs that
|
|
1885
|
+
# would have been generated, so we can reuse them and
|
|
1886
|
+
# properly populate task.result in state history
|
|
1887
|
+
next_tasks = prepare_next_tasks(
|
|
1888
|
+
checkpoint,
|
|
1889
|
+
saved.pending_writes,
|
|
1890
|
+
self.nodes,
|
|
1891
|
+
channels,
|
|
1892
|
+
managed,
|
|
1893
|
+
saved.config,
|
|
1894
|
+
step + 1,
|
|
1895
|
+
step + 3,
|
|
1896
|
+
for_execution=True,
|
|
1897
|
+
store=self.store,
|
|
1898
|
+
checkpointer=checkpointer,
|
|
1899
|
+
manager=None,
|
|
1900
|
+
)
|
|
1901
|
+
# collect task ids to reuse so we can properly attach task results
|
|
1902
|
+
for t in next_tasks.values():
|
|
1903
|
+
node_to_task_ids[t.name].append(t.id)
|
|
1904
|
+
|
|
1905
|
+
valid_updates: list[tuple[str, dict[str, Any] | None, str | None]] = []
|
|
1906
|
+
if len(updates) == 1:
|
|
1907
|
+
values, as_node, task_id = updates[0]
|
|
1908
|
+
# find last node that updated the state, if not provided
|
|
1909
|
+
if as_node is None and len(self.nodes) == 1:
|
|
1910
|
+
as_node = tuple(self.nodes)[0]
|
|
1911
|
+
elif as_node is None and not any(
|
|
1912
|
+
v
|
|
1913
|
+
for vv in checkpoint["versions_seen"].values()
|
|
1914
|
+
for v in vv.values()
|
|
1915
|
+
):
|
|
1916
|
+
if (
|
|
1917
|
+
isinstance(self.input_channels, str)
|
|
1918
|
+
and self.input_channels in self.nodes
|
|
1919
|
+
):
|
|
1920
|
+
as_node = self.input_channels
|
|
1921
|
+
elif as_node is None:
|
|
1922
|
+
last_seen_by_node = sorted(
|
|
1923
|
+
(v, n)
|
|
1924
|
+
for n, seen in checkpoint["versions_seen"].items()
|
|
1925
|
+
if n in self.nodes
|
|
1926
|
+
for v in seen.values()
|
|
1927
|
+
)
|
|
1928
|
+
# if two nodes updated the state at the same time, it's ambiguous
|
|
1929
|
+
if last_seen_by_node:
|
|
1930
|
+
if len(last_seen_by_node) == 1:
|
|
1931
|
+
as_node = last_seen_by_node[0][1]
|
|
1932
|
+
elif last_seen_by_node[-1][0] != last_seen_by_node[-2][0]:
|
|
1933
|
+
as_node = last_seen_by_node[-1][1]
|
|
1934
|
+
if as_node is None:
|
|
1935
|
+
raise InvalidUpdateError("Ambiguous update, specify as_node")
|
|
1936
|
+
if as_node not in self.nodes:
|
|
1937
|
+
raise InvalidUpdateError(f"Node {as_node} does not exist")
|
|
1938
|
+
valid_updates.append((as_node, values, task_id))
|
|
1939
|
+
else:
|
|
1940
|
+
for values, as_node, task_id in updates:
|
|
1941
|
+
if as_node is None:
|
|
1942
|
+
raise InvalidUpdateError(
|
|
1943
|
+
"as_node is required when applying multiple updates"
|
|
1944
|
+
)
|
|
1945
|
+
if as_node not in self.nodes:
|
|
1946
|
+
raise InvalidUpdateError(f"Node {as_node} does not exist")
|
|
1947
|
+
|
|
1948
|
+
valid_updates.append((as_node, values, task_id))
|
|
1949
|
+
|
|
1950
|
+
run_tasks: list[PregelTaskWrites] = []
|
|
1951
|
+
run_task_ids: list[str] = []
|
|
1952
|
+
|
|
1953
|
+
for as_node, values, provided_task_id in valid_updates:
|
|
1954
|
+
# create task to run all writers of the chosen node
|
|
1955
|
+
writers = self.nodes[as_node].flat_writers
|
|
1956
|
+
if not writers:
|
|
1957
|
+
raise InvalidUpdateError(f"Node {as_node} has no writers")
|
|
1958
|
+
writes: deque[tuple[str, Any]] = deque()
|
|
1959
|
+
task = PregelTaskWrites((), as_node, writes, [INTERRUPT])
|
|
1960
|
+
# get the task ids that were prepared for this node
|
|
1961
|
+
# if a task id was provided in the StateUpdate, we use it
|
|
1962
|
+
# otherwise, we use the next available task id
|
|
1963
|
+
prepared_task_ids = node_to_task_ids.get(as_node, deque())
|
|
1964
|
+
task_id = provided_task_id or (
|
|
1965
|
+
prepared_task_ids.popleft()
|
|
1966
|
+
if prepared_task_ids
|
|
1967
|
+
else str(uuid5(UUID(checkpoint["id"]), INTERRUPT))
|
|
1968
|
+
)
|
|
1969
|
+
run_tasks.append(task)
|
|
1970
|
+
run_task_ids.append(task_id)
|
|
1971
|
+
run = RunnableSequence(*writers) if len(writers) > 1 else writers[0]
|
|
1972
|
+
# execute task
|
|
1973
|
+
run.invoke(
|
|
1974
|
+
values,
|
|
1975
|
+
patch_config(
|
|
1976
|
+
config,
|
|
1977
|
+
run_name=self.name + "UpdateState",
|
|
1978
|
+
configurable={
|
|
1979
|
+
# deque.extend is thread-safe
|
|
1980
|
+
CONFIG_KEY_SEND: writes.extend,
|
|
1981
|
+
CONFIG_KEY_TASK_ID: task_id,
|
|
1982
|
+
CONFIG_KEY_READ: partial(
|
|
1983
|
+
local_read,
|
|
1984
|
+
_scratchpad(
|
|
1985
|
+
None,
|
|
1986
|
+
[],
|
|
1987
|
+
task_id,
|
|
1988
|
+
"",
|
|
1989
|
+
None,
|
|
1990
|
+
step,
|
|
1991
|
+
step + 2,
|
|
1992
|
+
),
|
|
1993
|
+
channels,
|
|
1994
|
+
managed,
|
|
1995
|
+
task,
|
|
1996
|
+
),
|
|
1997
|
+
},
|
|
1998
|
+
),
|
|
1999
|
+
)
|
|
2000
|
+
updated_channels = get_updated_channels_from_tasks(run_tasks)
|
|
2001
|
+
if saved is not None:
|
|
2002
|
+
for task_id, task in zip(run_task_ids, run_tasks):
|
|
2003
|
+
channel_writes = [w for w in task.writes if w[0] != PUSH]
|
|
2004
|
+
if channel_writes:
|
|
2005
|
+
checkpointer.put_writes(
|
|
2006
|
+
checkpoint_config, channel_writes, task_id
|
|
2007
|
+
)
|
|
2008
|
+
apply_writes(
|
|
2009
|
+
checkpoint,
|
|
2010
|
+
channels,
|
|
2011
|
+
run_tasks,
|
|
2012
|
+
checkpointer.get_next_version,
|
|
2013
|
+
self.trigger_to_nodes,
|
|
2014
|
+
)
|
|
2015
|
+
channels_to_snapshot, checkpoint_metadata = (
|
|
2016
|
+
create_checkpoint_plan_for_update_state_api(
|
|
2017
|
+
channels,
|
|
2018
|
+
updated_channels,
|
|
2019
|
+
step=step + 1,
|
|
2020
|
+
parents=saved.metadata.get("parents", {}) if saved else {},
|
|
2021
|
+
saved_metadata=saved.metadata if saved else None,
|
|
2022
|
+
is_fresh_thread=saved is None,
|
|
2023
|
+
)
|
|
2024
|
+
)
|
|
2025
|
+
checkpoint = create_checkpoint(
|
|
2026
|
+
checkpoint,
|
|
2027
|
+
channels,
|
|
2028
|
+
step + 1,
|
|
2029
|
+
updated_channels=updated_channels if channels_to_snapshot else None,
|
|
2030
|
+
get_next_version=checkpointer.get_next_version
|
|
2031
|
+
if channels_to_snapshot
|
|
2032
|
+
else None,
|
|
2033
|
+
channels_to_snapshot=channels_to_snapshot,
|
|
2034
|
+
)
|
|
2035
|
+
next_config = checkpointer.put(
|
|
2036
|
+
checkpoint_config,
|
|
2037
|
+
checkpoint,
|
|
2038
|
+
checkpoint_metadata,
|
|
2039
|
+
get_new_channel_versions(
|
|
2040
|
+
checkpoint_previous_versions, checkpoint["channel_versions"]
|
|
2041
|
+
),
|
|
2042
|
+
)
|
|
2043
|
+
for task_id, task in zip(run_task_ids, run_tasks):
|
|
2044
|
+
if push_writes := [w for w in task.writes if w[0] == PUSH]:
|
|
2045
|
+
checkpointer.put_writes(next_config, push_writes, task_id)
|
|
2046
|
+
|
|
2047
|
+
return patch_checkpoint_map(next_config, saved.metadata if saved else None)
|
|
2048
|
+
|
|
2049
|
+
current_config = patch_configurable(
|
|
2050
|
+
config, {CONFIG_KEY_THREAD_ID: str(config[CONF][CONFIG_KEY_THREAD_ID])}
|
|
2051
|
+
)
|
|
2052
|
+
for superstep in supersteps:
|
|
2053
|
+
current_config = perform_superstep(current_config, superstep)
|
|
2054
|
+
return current_config
|
|
2055
|
+
|
|
2056
|
+
async def abulk_update_state(
|
|
2057
|
+
self,
|
|
2058
|
+
config: RunnableConfig,
|
|
2059
|
+
supersteps: Sequence[Sequence[StateUpdate]],
|
|
2060
|
+
) -> RunnableConfig:
|
|
2061
|
+
"""Asynchronously apply updates to the graph state in bulk. Requires a checkpointer to be set.
|
|
2062
|
+
|
|
2063
|
+
Args:
|
|
2064
|
+
config: The config to apply the updates to.
|
|
2065
|
+
supersteps: A list of supersteps, each including a list of updates to apply sequentially to a graph state.
|
|
2066
|
+
|
|
2067
|
+
Each update is a tuple of the form `(values, as_node, task_id)` where `task_id` is optional.
|
|
2068
|
+
|
|
2069
|
+
Raises:
|
|
2070
|
+
ValueError: If no checkpointer is set or no updates are provided.
|
|
2071
|
+
InvalidUpdateError: If an invalid update is provided.
|
|
2072
|
+
|
|
2073
|
+
Returns:
|
|
2074
|
+
RunnableConfig: The updated config.
|
|
2075
|
+
"""
|
|
2076
|
+
|
|
2077
|
+
checkpointer: BaseCheckpointSaver | None = ensure_config(config)[CONF].get(
|
|
2078
|
+
CONFIG_KEY_CHECKPOINTER, self.checkpointer
|
|
2079
|
+
)
|
|
2080
|
+
if isinstance(checkpointer, BaseCheckpointSaver):
|
|
2081
|
+
checkpointer = self._apply_checkpointer_allowlist(checkpointer)
|
|
2082
|
+
if not checkpointer:
|
|
2083
|
+
raise ValueError("No checkpointer set")
|
|
2084
|
+
|
|
2085
|
+
if len(supersteps) == 0:
|
|
2086
|
+
raise ValueError("No supersteps provided")
|
|
2087
|
+
|
|
2088
|
+
if any(len(u) == 0 for u in supersteps):
|
|
2089
|
+
raise ValueError("No updates provided")
|
|
2090
|
+
|
|
2091
|
+
# delegate to subgraph
|
|
2092
|
+
if (
|
|
2093
|
+
checkpoint_ns := config[CONF].get(CONFIG_KEY_CHECKPOINT_NS, "")
|
|
2094
|
+
) and CONFIG_KEY_CHECKPOINTER not in config[CONF]:
|
|
2095
|
+
# remove task_ids from checkpoint_ns
|
|
2096
|
+
recast = recast_checkpoint_ns(checkpoint_ns)
|
|
2097
|
+
# find the subgraph with the matching name
|
|
2098
|
+
async for _, pregel in self.aget_subgraphs(namespace=recast, recurse=True):
|
|
2099
|
+
return await pregel.abulk_update_state(
|
|
2100
|
+
patch_configurable(config, {CONFIG_KEY_CHECKPOINTER: checkpointer}),
|
|
2101
|
+
supersteps,
|
|
2102
|
+
)
|
|
2103
|
+
else:
|
|
2104
|
+
raise ValueError(f"Subgraph {recast} not found")
|
|
2105
|
+
|
|
2106
|
+
async def aperform_superstep(
|
|
2107
|
+
input_config: RunnableConfig, updates: Sequence[StateUpdate]
|
|
2108
|
+
) -> RunnableConfig:
|
|
2109
|
+
# get last checkpoint
|
|
2110
|
+
config = ensure_config(self.config, input_config)
|
|
2111
|
+
saved = await checkpointer.aget_tuple(config)
|
|
2112
|
+
if saved is not None:
|
|
2113
|
+
self._migrate_checkpoint(saved.checkpoint)
|
|
2114
|
+
checkpoint = (
|
|
2115
|
+
copy_checkpoint(saved.checkpoint) if saved else empty_checkpoint()
|
|
2116
|
+
)
|
|
2117
|
+
checkpoint_previous_versions = (
|
|
2118
|
+
saved.checkpoint["channel_versions"].copy() if saved else {}
|
|
2119
|
+
)
|
|
2120
|
+
step = saved.metadata.get("step", -1) if saved else -1
|
|
2121
|
+
# merge configurable fields with previous checkpoint config
|
|
2122
|
+
checkpoint_config = patch_configurable(
|
|
2123
|
+
config,
|
|
2124
|
+
{
|
|
2125
|
+
CONFIG_KEY_CHECKPOINT_NS: config[CONF].get(
|
|
2126
|
+
CONFIG_KEY_CHECKPOINT_NS, ""
|
|
2127
|
+
)
|
|
2128
|
+
},
|
|
2129
|
+
)
|
|
2130
|
+
if saved:
|
|
2131
|
+
checkpoint_config = patch_configurable(config, saved.config[CONF])
|
|
2132
|
+
channels, managed = await achannels_from_checkpoint(
|
|
2133
|
+
self.channels,
|
|
2134
|
+
checkpoint,
|
|
2135
|
+
saver=self.checkpointer
|
|
2136
|
+
if saved is not None
|
|
2137
|
+
and isinstance(self.checkpointer, BaseCheckpointSaver)
|
|
2138
|
+
else None,
|
|
2139
|
+
config=saved.config if saved is not None else None,
|
|
2140
|
+
)
|
|
2141
|
+
values, as_node = updates[0][:2]
|
|
2142
|
+
# no values, just clear all tasks
|
|
2143
|
+
if values is None and as_node == END:
|
|
2144
|
+
if len(updates) > 1:
|
|
2145
|
+
raise InvalidUpdateError(
|
|
2146
|
+
"Cannot apply multiple updates when clearing state"
|
|
2147
|
+
)
|
|
2148
|
+
if saved is not None:
|
|
2149
|
+
# tasks for this checkpoint
|
|
2150
|
+
next_tasks = prepare_next_tasks(
|
|
2151
|
+
checkpoint,
|
|
2152
|
+
saved.pending_writes or [],
|
|
2153
|
+
self.nodes,
|
|
2154
|
+
channels,
|
|
2155
|
+
managed,
|
|
2156
|
+
saved.config,
|
|
2157
|
+
step + 1,
|
|
2158
|
+
step + 3,
|
|
2159
|
+
for_execution=True,
|
|
2160
|
+
store=self.store,
|
|
2161
|
+
checkpointer=checkpointer,
|
|
2162
|
+
manager=None,
|
|
2163
|
+
)
|
|
2164
|
+
# apply null writes
|
|
2165
|
+
if null_writes := [
|
|
2166
|
+
w[1:]
|
|
2167
|
+
for w in saved.pending_writes or []
|
|
2168
|
+
if w[0] == NULL_TASK_ID
|
|
2169
|
+
]:
|
|
2170
|
+
apply_writes(
|
|
2171
|
+
checkpoint,
|
|
2172
|
+
channels,
|
|
2173
|
+
[PregelTaskWrites((), INPUT, null_writes, [])],
|
|
2174
|
+
checkpointer.get_next_version,
|
|
2175
|
+
self.trigger_to_nodes,
|
|
2176
|
+
)
|
|
2177
|
+
# apply writes from tasks that already ran
|
|
2178
|
+
for tid, k, v in saved.pending_writes or []:
|
|
2179
|
+
if k in (ERROR, INTERRUPT):
|
|
2180
|
+
continue
|
|
2181
|
+
if tid not in next_tasks:
|
|
2182
|
+
continue
|
|
2183
|
+
next_tasks[tid].writes.append((k, v))
|
|
2184
|
+
# clear all current tasks
|
|
2185
|
+
apply_writes(
|
|
2186
|
+
checkpoint,
|
|
2187
|
+
channels,
|
|
2188
|
+
next_tasks.values(),
|
|
2189
|
+
checkpointer.get_next_version,
|
|
2190
|
+
self.trigger_to_nodes,
|
|
2191
|
+
)
|
|
2192
|
+
# save checkpoint
|
|
2193
|
+
next_config = await checkpointer.aput(
|
|
2194
|
+
checkpoint_config,
|
|
2195
|
+
create_checkpoint(checkpoint, channels, step),
|
|
2196
|
+
{
|
|
2197
|
+
"source": "update",
|
|
2198
|
+
"step": step + 1,
|
|
2199
|
+
"parents": saved.metadata.get("parents", {}) if saved else {},
|
|
2200
|
+
},
|
|
2201
|
+
get_new_channel_versions(
|
|
2202
|
+
checkpoint_previous_versions, checkpoint["channel_versions"]
|
|
2203
|
+
),
|
|
2204
|
+
)
|
|
2205
|
+
return patch_checkpoint_map(
|
|
2206
|
+
next_config, saved.metadata if saved else None
|
|
2207
|
+
)
|
|
2208
|
+
|
|
2209
|
+
# act as an input
|
|
2210
|
+
if as_node == INPUT:
|
|
2211
|
+
if len(updates) > 1:
|
|
2212
|
+
raise InvalidUpdateError(
|
|
2213
|
+
"Cannot apply multiple updates when updating as input"
|
|
2214
|
+
)
|
|
2215
|
+
|
|
2216
|
+
if input_writes := deque(map_input(self.input_channels, values)):
|
|
2217
|
+
apply_writes(
|
|
2218
|
+
checkpoint,
|
|
2219
|
+
channels,
|
|
2220
|
+
[PregelTaskWrites((), INPUT, input_writes, [])],
|
|
2221
|
+
checkpointer.get_next_version,
|
|
2222
|
+
self.trigger_to_nodes,
|
|
2223
|
+
)
|
|
2224
|
+
|
|
2225
|
+
# apply input write to channels
|
|
2226
|
+
next_step = (
|
|
2227
|
+
step + 1
|
|
2228
|
+
if saved and saved.metadata.get("step") is not None
|
|
2229
|
+
else -1
|
|
2230
|
+
)
|
|
2231
|
+
next_config = await checkpointer.aput(
|
|
2232
|
+
checkpoint_config,
|
|
2233
|
+
create_checkpoint(checkpoint, channels, next_step),
|
|
2234
|
+
{
|
|
2235
|
+
"source": "input",
|
|
2236
|
+
"step": next_step,
|
|
2237
|
+
"parents": saved.metadata.get("parents", {})
|
|
2238
|
+
if saved
|
|
2239
|
+
else {},
|
|
2240
|
+
},
|
|
2241
|
+
get_new_channel_versions(
|
|
2242
|
+
checkpoint_previous_versions,
|
|
2243
|
+
checkpoint["channel_versions"],
|
|
2244
|
+
),
|
|
2245
|
+
)
|
|
2246
|
+
|
|
2247
|
+
# store the writes
|
|
2248
|
+
await checkpointer.aput_writes(
|
|
2249
|
+
next_config,
|
|
2250
|
+
input_writes,
|
|
2251
|
+
str(uuid5(UUID(checkpoint["id"]), INPUT)),
|
|
2252
|
+
)
|
|
2253
|
+
|
|
2254
|
+
return patch_checkpoint_map(
|
|
2255
|
+
next_config, saved.metadata if saved else None
|
|
2256
|
+
)
|
|
2257
|
+
else:
|
|
2258
|
+
raise InvalidUpdateError(
|
|
2259
|
+
f"Received no input writes for {self.input_channels}"
|
|
2260
|
+
)
|
|
2261
|
+
|
|
2262
|
+
# no values, copy checkpoint
|
|
2263
|
+
if as_node == "__copy__":
|
|
2264
|
+
if len(updates) > 1:
|
|
2265
|
+
raise InvalidUpdateError(
|
|
2266
|
+
"Cannot copy checkpoint with multiple updates"
|
|
2267
|
+
)
|
|
2268
|
+
|
|
2269
|
+
if saved is None:
|
|
2270
|
+
raise InvalidUpdateError("Cannot copy a non-existent checkpoint")
|
|
2271
|
+
|
|
2272
|
+
next_checkpoint = create_checkpoint(checkpoint, None, step)
|
|
2273
|
+
|
|
2274
|
+
# copy checkpoint
|
|
2275
|
+
next_config = await checkpointer.aput(
|
|
2276
|
+
saved.parent_config
|
|
2277
|
+
or patch_configurable(
|
|
2278
|
+
saved.config, {CONFIG_KEY_CHECKPOINT_ID: None}
|
|
2279
|
+
),
|
|
2280
|
+
next_checkpoint,
|
|
2281
|
+
{
|
|
2282
|
+
"source": "fork",
|
|
2283
|
+
"step": step + 1,
|
|
2284
|
+
"parents": saved.metadata.get("parents", {}),
|
|
2285
|
+
},
|
|
2286
|
+
{},
|
|
2287
|
+
)
|
|
2288
|
+
|
|
2289
|
+
# we want to both clone a checkpoint and update state in one go.
|
|
2290
|
+
# reuse the same task ID if possible.
|
|
2291
|
+
if isinstance(values, list) and len(values) > 0:
|
|
2292
|
+
# figure out the task IDs for the next update checkpoint
|
|
2293
|
+
next_tasks = prepare_next_tasks(
|
|
2294
|
+
next_checkpoint,
|
|
2295
|
+
saved.pending_writes or [],
|
|
2296
|
+
self.nodes,
|
|
2297
|
+
channels,
|
|
2298
|
+
managed,
|
|
2299
|
+
next_config,
|
|
2300
|
+
step + 2,
|
|
2301
|
+
step + 4,
|
|
2302
|
+
for_execution=True,
|
|
2303
|
+
store=self.store,
|
|
2304
|
+
checkpointer=checkpointer,
|
|
2305
|
+
manager=None,
|
|
2306
|
+
)
|
|
2307
|
+
|
|
2308
|
+
tasks_group_by = defaultdict(list)
|
|
2309
|
+
user_group_by: dict[str, list[StateUpdate]] = defaultdict(list)
|
|
2310
|
+
|
|
2311
|
+
for task in next_tasks.values():
|
|
2312
|
+
tasks_group_by[task.name].append(task.id)
|
|
2313
|
+
|
|
2314
|
+
for item in values:
|
|
2315
|
+
if not isinstance(item, Sequence):
|
|
2316
|
+
raise InvalidUpdateError(
|
|
2317
|
+
f"Invalid update item: {item} when copying checkpoint"
|
|
2318
|
+
)
|
|
2319
|
+
|
|
2320
|
+
values, as_node = item[:2]
|
|
2321
|
+
user_group = user_group_by[as_node]
|
|
2322
|
+
tasks_group = tasks_group_by[as_node]
|
|
2323
|
+
|
|
2324
|
+
target_idx = len(user_group)
|
|
2325
|
+
task_id = (
|
|
2326
|
+
tasks_group[target_idx]
|
|
2327
|
+
if target_idx < len(tasks_group)
|
|
2328
|
+
else None
|
|
2329
|
+
)
|
|
2330
|
+
|
|
2331
|
+
user_group_by[as_node].append(
|
|
2332
|
+
StateUpdate(values=values, as_node=as_node, task_id=task_id)
|
|
2333
|
+
)
|
|
2334
|
+
|
|
2335
|
+
return await aperform_superstep(
|
|
2336
|
+
patch_checkpoint_map(next_config, saved.metadata),
|
|
2337
|
+
[item for lst in user_group_by.values() for item in lst],
|
|
2338
|
+
)
|
|
2339
|
+
|
|
2340
|
+
return patch_checkpoint_map(
|
|
2341
|
+
next_config, saved.metadata if saved else None
|
|
2342
|
+
)
|
|
2343
|
+
|
|
2344
|
+
# task ids can be provided in the StateUpdate, but if not,
|
|
2345
|
+
# we use the task id generated by prepare_next_tasks
|
|
2346
|
+
node_to_task_ids: dict[str, deque[str]] = defaultdict(deque)
|
|
2347
|
+
if saved is not None and saved.pending_writes is not None:
|
|
2348
|
+
# we call prepare_next_tasks to discover the task IDs that
|
|
2349
|
+
# would have been generated, so we can reuse them and
|
|
2350
|
+
# properly populate task.result in state history
|
|
2351
|
+
next_tasks = prepare_next_tasks(
|
|
2352
|
+
checkpoint,
|
|
2353
|
+
saved.pending_writes,
|
|
2354
|
+
self.nodes,
|
|
2355
|
+
channels,
|
|
2356
|
+
managed,
|
|
2357
|
+
saved.config,
|
|
2358
|
+
step + 1,
|
|
2359
|
+
step + 3,
|
|
2360
|
+
for_execution=True,
|
|
2361
|
+
store=self.store,
|
|
2362
|
+
checkpointer=checkpointer,
|
|
2363
|
+
manager=None,
|
|
2364
|
+
)
|
|
2365
|
+
# collect task ids to reuse so we can properly attach task results
|
|
2366
|
+
for t in next_tasks.values():
|
|
2367
|
+
node_to_task_ids[t.name].append(t.id)
|
|
2368
|
+
|
|
2369
|
+
valid_updates: list[tuple[str, dict[str, Any] | None, str | None]] = []
|
|
2370
|
+
if len(updates) == 1:
|
|
2371
|
+
values, as_node, task_id = updates[0]
|
|
2372
|
+
# find last node that updated the state, if not provided
|
|
2373
|
+
if as_node is None and len(self.nodes) == 1:
|
|
2374
|
+
as_node = tuple(self.nodes)[0]
|
|
2375
|
+
elif as_node is None and not saved:
|
|
2376
|
+
if (
|
|
2377
|
+
isinstance(self.input_channels, str)
|
|
2378
|
+
and self.input_channels in self.nodes
|
|
2379
|
+
):
|
|
2380
|
+
as_node = self.input_channels
|
|
2381
|
+
elif as_node is None:
|
|
2382
|
+
last_seen_by_node = sorted(
|
|
2383
|
+
(v, n)
|
|
2384
|
+
for n, seen in checkpoint["versions_seen"].items()
|
|
2385
|
+
if n in self.nodes
|
|
2386
|
+
for v in seen.values()
|
|
2387
|
+
)
|
|
2388
|
+
# if two nodes updated the state at the same time, it's ambiguous
|
|
2389
|
+
if last_seen_by_node:
|
|
2390
|
+
if len(last_seen_by_node) == 1:
|
|
2391
|
+
as_node = last_seen_by_node[0][1]
|
|
2392
|
+
elif last_seen_by_node[-1][0] != last_seen_by_node[-2][0]:
|
|
2393
|
+
as_node = last_seen_by_node[-1][1]
|
|
2394
|
+
if as_node is None:
|
|
2395
|
+
raise InvalidUpdateError("Ambiguous update, specify as_node")
|
|
2396
|
+
if as_node not in self.nodes:
|
|
2397
|
+
raise InvalidUpdateError(f"Node {as_node} does not exist")
|
|
2398
|
+
valid_updates.append((as_node, values, task_id))
|
|
2399
|
+
else:
|
|
2400
|
+
for values, as_node, task_id in updates:
|
|
2401
|
+
if as_node is None:
|
|
2402
|
+
raise InvalidUpdateError(
|
|
2403
|
+
"as_node is required when applying multiple updates"
|
|
2404
|
+
)
|
|
2405
|
+
if as_node not in self.nodes:
|
|
2406
|
+
raise InvalidUpdateError(f"Node {as_node} does not exist")
|
|
2407
|
+
|
|
2408
|
+
valid_updates.append((as_node, values, task_id))
|
|
2409
|
+
|
|
2410
|
+
run_tasks: list[PregelTaskWrites] = []
|
|
2411
|
+
run_task_ids: list[str] = []
|
|
2412
|
+
|
|
2413
|
+
for as_node, values, provided_task_id in valid_updates:
|
|
2414
|
+
# create task to run all writers of the chosen node
|
|
2415
|
+
writers = self.nodes[as_node].flat_writers
|
|
2416
|
+
if not writers:
|
|
2417
|
+
raise InvalidUpdateError(f"Node {as_node} has no writers")
|
|
2418
|
+
writes: deque[tuple[str, Any]] = deque()
|
|
2419
|
+
task = PregelTaskWrites((), as_node, writes, [INTERRUPT])
|
|
2420
|
+
# get the task ids that were prepared for this node
|
|
2421
|
+
# if a task id was provided in the StateUpdate, we use it
|
|
2422
|
+
# otherwise, we use the next available task id
|
|
2423
|
+
prepared_task_ids = node_to_task_ids.get(as_node, deque())
|
|
2424
|
+
task_id = provided_task_id or (
|
|
2425
|
+
prepared_task_ids.popleft()
|
|
2426
|
+
if prepared_task_ids
|
|
2427
|
+
else str(uuid5(UUID(checkpoint["id"]), INTERRUPT))
|
|
2428
|
+
)
|
|
2429
|
+
run_tasks.append(task)
|
|
2430
|
+
run_task_ids.append(task_id)
|
|
2431
|
+
run = RunnableSequence(*writers) if len(writers) > 1 else writers[0]
|
|
2432
|
+
# execute task
|
|
2433
|
+
await run.ainvoke(
|
|
2434
|
+
values,
|
|
2435
|
+
patch_config(
|
|
2436
|
+
config,
|
|
2437
|
+
run_name=self.name + "UpdateState",
|
|
2438
|
+
configurable={
|
|
2439
|
+
# deque.extend is thread-safe
|
|
2440
|
+
CONFIG_KEY_SEND: writes.extend,
|
|
2441
|
+
CONFIG_KEY_TASK_ID: task_id,
|
|
2442
|
+
CONFIG_KEY_READ: partial(
|
|
2443
|
+
local_read,
|
|
2444
|
+
_scratchpad(
|
|
2445
|
+
None,
|
|
2446
|
+
[],
|
|
2447
|
+
task_id,
|
|
2448
|
+
"",
|
|
2449
|
+
None,
|
|
2450
|
+
step,
|
|
2451
|
+
step + 2,
|
|
2452
|
+
),
|
|
2453
|
+
channels,
|
|
2454
|
+
managed,
|
|
2455
|
+
task,
|
|
2456
|
+
),
|
|
2457
|
+
},
|
|
2458
|
+
),
|
|
2459
|
+
)
|
|
2460
|
+
updated_channels = get_updated_channels_from_tasks(run_tasks)
|
|
2461
|
+
if saved is not None:
|
|
2462
|
+
for task_id, task in zip(run_task_ids, run_tasks):
|
|
2463
|
+
channel_writes = [w for w in task.writes if w[0] != PUSH]
|
|
2464
|
+
if channel_writes:
|
|
2465
|
+
await checkpointer.aput_writes(
|
|
2466
|
+
checkpoint_config, channel_writes, task_id
|
|
2467
|
+
)
|
|
2468
|
+
apply_writes(
|
|
2469
|
+
checkpoint,
|
|
2470
|
+
channels,
|
|
2471
|
+
run_tasks,
|
|
2472
|
+
checkpointer.get_next_version,
|
|
2473
|
+
self.trigger_to_nodes,
|
|
2474
|
+
)
|
|
2475
|
+
channels_to_snapshot, checkpoint_metadata = (
|
|
2476
|
+
create_checkpoint_plan_for_update_state_api(
|
|
2477
|
+
channels,
|
|
2478
|
+
updated_channels,
|
|
2479
|
+
step=step + 1,
|
|
2480
|
+
parents=saved.metadata.get("parents", {}) if saved else {},
|
|
2481
|
+
saved_metadata=saved.metadata if saved else None,
|
|
2482
|
+
is_fresh_thread=saved is None,
|
|
2483
|
+
)
|
|
2484
|
+
)
|
|
2485
|
+
checkpoint = create_checkpoint(
|
|
2486
|
+
checkpoint,
|
|
2487
|
+
channels,
|
|
2488
|
+
step + 1,
|
|
2489
|
+
updated_channels=updated_channels if channels_to_snapshot else None,
|
|
2490
|
+
get_next_version=checkpointer.get_next_version
|
|
2491
|
+
if channels_to_snapshot
|
|
2492
|
+
else None,
|
|
2493
|
+
channels_to_snapshot=channels_to_snapshot,
|
|
2494
|
+
)
|
|
2495
|
+
next_config = await checkpointer.aput(
|
|
2496
|
+
checkpoint_config,
|
|
2497
|
+
checkpoint,
|
|
2498
|
+
checkpoint_metadata,
|
|
2499
|
+
get_new_channel_versions(
|
|
2500
|
+
checkpoint_previous_versions, checkpoint["channel_versions"]
|
|
2501
|
+
),
|
|
2502
|
+
)
|
|
2503
|
+
for task_id, task in zip(run_task_ids, run_tasks):
|
|
2504
|
+
if push_writes := [w for w in task.writes if w[0] == PUSH]:
|
|
2505
|
+
await checkpointer.aput_writes(next_config, push_writes, task_id)
|
|
2506
|
+
return patch_checkpoint_map(next_config, saved.metadata if saved else None)
|
|
2507
|
+
|
|
2508
|
+
current_config = patch_configurable(
|
|
2509
|
+
config, {CONFIG_KEY_THREAD_ID: str(config[CONF][CONFIG_KEY_THREAD_ID])}
|
|
2510
|
+
)
|
|
2511
|
+
for superstep in supersteps:
|
|
2512
|
+
current_config = await aperform_superstep(current_config, superstep)
|
|
2513
|
+
return current_config
|
|
2514
|
+
|
|
2515
|
+
def update_state(
|
|
2516
|
+
self,
|
|
2517
|
+
config: RunnableConfig,
|
|
2518
|
+
values: dict[str, Any] | Any | None,
|
|
2519
|
+
as_node: str | None = None,
|
|
2520
|
+
task_id: str | None = None,
|
|
2521
|
+
) -> RunnableConfig:
|
|
2522
|
+
"""Update the state of the graph with the given values, as if they came from
|
|
2523
|
+
node `as_node`. If `as_node` is not provided, it will be set to the last node
|
|
2524
|
+
that updated the state, if not ambiguous.
|
|
2525
|
+
"""
|
|
2526
|
+
return self.bulk_update_state(config, [[StateUpdate(values, as_node, task_id)]])
|
|
2527
|
+
|
|
2528
|
+
async def aupdate_state(
|
|
2529
|
+
self,
|
|
2530
|
+
config: RunnableConfig,
|
|
2531
|
+
values: dict[str, Any] | Any,
|
|
2532
|
+
as_node: str | None = None,
|
|
2533
|
+
task_id: str | None = None,
|
|
2534
|
+
) -> RunnableConfig:
|
|
2535
|
+
"""Asynchronously update the state of the graph with the given values, as if they came from
|
|
2536
|
+
node `as_node`. If `as_node` is not provided, it will be set to the last node
|
|
2537
|
+
that updated the state, if not ambiguous.
|
|
2538
|
+
"""
|
|
2539
|
+
return await self.abulk_update_state(
|
|
2540
|
+
config, [[StateUpdate(values, as_node, task_id)]]
|
|
2541
|
+
)
|
|
2542
|
+
|
|
2543
|
+
def _defaults(
|
|
2544
|
+
self,
|
|
2545
|
+
config: RunnableConfig,
|
|
2546
|
+
*,
|
|
2547
|
+
stream_mode: StreamMode | Sequence[StreamMode],
|
|
2548
|
+
print_mode: StreamMode | Sequence[StreamMode],
|
|
2549
|
+
output_keys: str | Sequence[str] | None,
|
|
2550
|
+
interrupt_before: All | Sequence[str] | None,
|
|
2551
|
+
interrupt_after: All | Sequence[str] | None,
|
|
2552
|
+
durability: Durability | None = None,
|
|
2553
|
+
) -> tuple[
|
|
2554
|
+
set[StreamMode],
|
|
2555
|
+
str | Sequence[str],
|
|
2556
|
+
All | Sequence[str],
|
|
2557
|
+
All | Sequence[str],
|
|
2558
|
+
BaseCheckpointSaver | None,
|
|
2559
|
+
BaseStore | None,
|
|
2560
|
+
BaseCache | None,
|
|
2561
|
+
Durability,
|
|
2562
|
+
]:
|
|
2563
|
+
if config["recursion_limit"] < 1:
|
|
2564
|
+
raise ValueError("recursion_limit must be at least 1")
|
|
2565
|
+
if output_keys is None:
|
|
2566
|
+
output_keys = self.stream_channels_asis
|
|
2567
|
+
else:
|
|
2568
|
+
validate_keys(output_keys, self.channels)
|
|
2569
|
+
interrupt_before = interrupt_before or self.interrupt_before_nodes
|
|
2570
|
+
interrupt_after = interrupt_after or self.interrupt_after_nodes
|
|
2571
|
+
if isinstance(stream_mode, str):
|
|
2572
|
+
stream_modes = {stream_mode}
|
|
2573
|
+
else:
|
|
2574
|
+
stream_modes = set(stream_mode)
|
|
2575
|
+
if isinstance(print_mode, str):
|
|
2576
|
+
stream_modes.add(print_mode)
|
|
2577
|
+
else:
|
|
2578
|
+
stream_modes.update(print_mode)
|
|
2579
|
+
if self.checkpointer is False:
|
|
2580
|
+
checkpointer: BaseCheckpointSaver | None = None
|
|
2581
|
+
elif CONFIG_KEY_CHECKPOINTER in config.get(CONF, {}):
|
|
2582
|
+
checkpointer = config[CONF][CONFIG_KEY_CHECKPOINTER]
|
|
2583
|
+
elif self.checkpointer is True:
|
|
2584
|
+
raise RuntimeError("checkpointer=True cannot be used for root graphs.")
|
|
2585
|
+
else:
|
|
2586
|
+
checkpointer = self.checkpointer
|
|
2587
|
+
if isinstance(checkpointer, BaseCheckpointSaver):
|
|
2588
|
+
checkpointer = self._apply_checkpointer_allowlist(checkpointer)
|
|
2589
|
+
if checkpointer and not config.get(CONF):
|
|
2590
|
+
raise ValueError(
|
|
2591
|
+
"Checkpointer requires one or more of the following 'configurable' "
|
|
2592
|
+
"keys: thread_id, checkpoint_ns, checkpoint_id"
|
|
2593
|
+
)
|
|
2594
|
+
if CONFIG_KEY_RUNTIME in config.get(CONF, {}):
|
|
2595
|
+
store: BaseStore | None = config[CONF][CONFIG_KEY_RUNTIME].store
|
|
2596
|
+
else:
|
|
2597
|
+
store = self.store
|
|
2598
|
+
if CONFIG_KEY_CACHE in config.get(CONF, {}):
|
|
2599
|
+
cache: BaseCache | None = config[CONF][CONFIG_KEY_CACHE]
|
|
2600
|
+
else:
|
|
2601
|
+
cache = self.cache
|
|
2602
|
+
if durability is None:
|
|
2603
|
+
durability = config.get(CONF, {}).get(CONFIG_KEY_DURABILITY, "async")
|
|
2604
|
+
return (
|
|
2605
|
+
stream_modes,
|
|
2606
|
+
output_keys,
|
|
2607
|
+
interrupt_before,
|
|
2608
|
+
interrupt_after,
|
|
2609
|
+
checkpointer,
|
|
2610
|
+
store,
|
|
2611
|
+
cache,
|
|
2612
|
+
durability,
|
|
2613
|
+
)
|
|
2614
|
+
|
|
2615
|
+
@overload
|
|
2616
|
+
def stream(
|
|
2617
|
+
self,
|
|
2618
|
+
input: InputT | Command | None,
|
|
2619
|
+
config: RunnableConfig | None = None,
|
|
2620
|
+
*,
|
|
2621
|
+
context: ContextT | None = None,
|
|
2622
|
+
stream_mode: StreamMode | Sequence[StreamMode] | None = None,
|
|
2623
|
+
print_mode: StreamMode | Sequence[StreamMode] = (),
|
|
2624
|
+
output_keys: str | Sequence[str] | None = None,
|
|
2625
|
+
interrupt_before: All | Sequence[str] | None = None,
|
|
2626
|
+
interrupt_after: All | Sequence[str] | None = None,
|
|
2627
|
+
durability: Durability | None = None,
|
|
2628
|
+
control: RunControl | None = None,
|
|
2629
|
+
subgraphs: bool = False,
|
|
2630
|
+
debug: bool | None = None,
|
|
2631
|
+
version: Literal["v2"],
|
|
2632
|
+
**kwargs: Unpack[DeprecatedKwargs],
|
|
2633
|
+
) -> Iterator[StreamPart[StateT, OutputT]]: ...
|
|
2634
|
+
|
|
2635
|
+
@overload
|
|
2636
|
+
def stream(
|
|
2637
|
+
self,
|
|
2638
|
+
input: InputT | Command | None,
|
|
2639
|
+
config: RunnableConfig | None = None,
|
|
2640
|
+
*,
|
|
2641
|
+
context: ContextT | None = None,
|
|
2642
|
+
stream_mode: StreamMode | Sequence[StreamMode] | None = None,
|
|
2643
|
+
print_mode: StreamMode | Sequence[StreamMode] = (),
|
|
2644
|
+
output_keys: str | Sequence[str] | None = None,
|
|
2645
|
+
interrupt_before: All | Sequence[str] | None = None,
|
|
2646
|
+
interrupt_after: All | Sequence[str] | None = None,
|
|
2647
|
+
durability: Durability | None = None,
|
|
2648
|
+
control: RunControl | None = None,
|
|
2649
|
+
subgraphs: bool = False,
|
|
2650
|
+
debug: bool | None = None,
|
|
2651
|
+
version: Literal["v1"] = ...,
|
|
2652
|
+
**kwargs: Unpack[DeprecatedKwargs],
|
|
2653
|
+
) -> Iterator[dict[str, Any] | Any]: ...
|
|
2654
|
+
|
|
2655
|
+
def stream(
|
|
2656
|
+
self,
|
|
2657
|
+
input: InputT | Command | None,
|
|
2658
|
+
config: RunnableConfig | None = None,
|
|
2659
|
+
*,
|
|
2660
|
+
context: ContextT | None = None,
|
|
2661
|
+
stream_mode: StreamMode | Sequence[StreamMode] | None = None,
|
|
2662
|
+
print_mode: StreamMode | Sequence[StreamMode] = (),
|
|
2663
|
+
output_keys: str | Sequence[str] | None = None,
|
|
2664
|
+
interrupt_before: All | Sequence[str] | None = None,
|
|
2665
|
+
interrupt_after: All | Sequence[str] | None = None,
|
|
2666
|
+
durability: Durability | None = None,
|
|
2667
|
+
control: RunControl | None = None,
|
|
2668
|
+
subgraphs: bool = False,
|
|
2669
|
+
debug: bool | None = None,
|
|
2670
|
+
version: Literal["v1", "v2"] = "v1",
|
|
2671
|
+
**kwargs: Unpack[DeprecatedKwargs],
|
|
2672
|
+
) -> Iterator[dict[str, Any] | Any]:
|
|
2673
|
+
"""Stream graph steps for a single input.
|
|
2674
|
+
|
|
2675
|
+
Args:
|
|
2676
|
+
input: The input to the graph.
|
|
2677
|
+
config: The configuration to use for the run.
|
|
2678
|
+
context: The static context to use for the run.
|
|
2679
|
+
!!! version-added "Added in version 0.6.0"
|
|
2680
|
+
stream_mode: The mode to stream output, defaults to `self.stream_mode`.
|
|
2681
|
+
|
|
2682
|
+
Options are:
|
|
2683
|
+
|
|
2684
|
+
- `"values"`: Emit all values in the state after each step, including interrupts.
|
|
2685
|
+
When used with functional API, values are emitted once at the end of the workflow.
|
|
2686
|
+
- `"updates"`: Emit only the node or task names and updates returned by the nodes or tasks after each step.
|
|
2687
|
+
If multiple updates are made in the same step (e.g. multiple nodes are run) then those updates are emitted separately.
|
|
2688
|
+
- `"custom"`: Emit custom data from inside nodes or tasks using `StreamWriter`.
|
|
2689
|
+
- `"messages"`: Emit LLM messages token-by-token together with metadata for any LLM invocations inside nodes or tasks.
|
|
2690
|
+
- Will be emitted as 2-tuples `(LLM token, metadata)`.
|
|
2691
|
+
- `"checkpoints"`: Emit an event when a checkpoint is created, in the same format as returned by `get_state()`.
|
|
2692
|
+
- `"tasks"`: Emit events when tasks start and finish, including their results and errors.
|
|
2693
|
+
- `"debug"`: Emit debug events with as much information as possible for each step.
|
|
2694
|
+
|
|
2695
|
+
You can pass a list as the `stream_mode` parameter to stream multiple modes at once.
|
|
2696
|
+
The streamed outputs will be tuples of `(mode, data)`.
|
|
2697
|
+
|
|
2698
|
+
See [CocoGraph streaming guide](https://docs.langchain.com/oss/python/langgraph/streaming) for more details.
|
|
2699
|
+
print_mode: Accepts the same values as `stream_mode`, but only prints the output to the console, for debugging purposes.
|
|
2700
|
+
|
|
2701
|
+
Does not affect the output of the graph in any way.
|
|
2702
|
+
output_keys: The keys to stream, defaults to all non-context channels.
|
|
2703
|
+
interrupt_before: Nodes to interrupt before, defaults to all nodes in the graph.
|
|
2704
|
+
interrupt_after: Nodes to interrupt after, defaults to all nodes in the graph.
|
|
2705
|
+
durability: The durability mode for the graph execution, defaults to `"async"`.
|
|
2706
|
+
|
|
2707
|
+
Options are:
|
|
2708
|
+
|
|
2709
|
+
- `"sync"`: Changes are persisted synchronously before the next step starts.
|
|
2710
|
+
- `"async"`: Changes are persisted asynchronously while the next step executes.
|
|
2711
|
+
- `"exit"`: Changes are persisted only when the graph exits.
|
|
2712
|
+
control: Optional run control used to request cooperative drain.
|
|
2713
|
+
subgraphs: Whether to stream events from inside subgraphs, defaults to `False`.
|
|
2714
|
+
|
|
2715
|
+
If `True`, the events will be emitted as tuples `(namespace, data)`,
|
|
2716
|
+
or `(namespace, mode, data)` if `stream_mode` is a list,
|
|
2717
|
+
where `namespace` is a tuple with the path to the node where a subgraph is invoked,
|
|
2718
|
+
e.g. `("parent_node:<task_id>", "child_node:<task_id>")`.
|
|
2719
|
+
|
|
2720
|
+
See [CocoGraph streaming guide](https://docs.langchain.com/oss/python/langgraph/streaming) for more details.
|
|
2721
|
+
|
|
2722
|
+
Yields:
|
|
2723
|
+
The output of each step in the graph. The output shape depends on the `stream_mode`.
|
|
2724
|
+
"""
|
|
2725
|
+
if (checkpoint_during := kwargs.get("checkpoint_during")) is not None:
|
|
2726
|
+
warnings.warn(
|
|
2727
|
+
"`checkpoint_during` is deprecated and will be removed. Please use `durability` instead.",
|
|
2728
|
+
category=CocoGraphDeprecatedSinceV10,
|
|
2729
|
+
stacklevel=2,
|
|
2730
|
+
)
|
|
2731
|
+
if durability is not None:
|
|
2732
|
+
raise ValueError(
|
|
2733
|
+
"Cannot use both `checkpoint_during` and `durability` parameters. Please use `durability` instead."
|
|
2734
|
+
)
|
|
2735
|
+
durability = "async" if checkpoint_during else "exit"
|
|
2736
|
+
|
|
2737
|
+
if stream_mode is None:
|
|
2738
|
+
# if being called as a node in another graph, default to values mode
|
|
2739
|
+
# but don't overwrite stream_mode arg if provided
|
|
2740
|
+
stream_mode = (
|
|
2741
|
+
"values"
|
|
2742
|
+
if config is not None and CONFIG_KEY_TASK_ID in config.get(CONF, {})
|
|
2743
|
+
else self.stream_mode
|
|
2744
|
+
)
|
|
2745
|
+
if debug or self.debug:
|
|
2746
|
+
print_mode = ["updates", "values"]
|
|
2747
|
+
|
|
2748
|
+
stream = SyncQueue()
|
|
2749
|
+
|
|
2750
|
+
config = ensure_config(self.config, config)
|
|
2751
|
+
run_manager = None
|
|
2752
|
+
try:
|
|
2753
|
+
# assign defaults
|
|
2754
|
+
(
|
|
2755
|
+
stream_modes,
|
|
2756
|
+
output_keys,
|
|
2757
|
+
interrupt_before_,
|
|
2758
|
+
interrupt_after_,
|
|
2759
|
+
checkpointer,
|
|
2760
|
+
store,
|
|
2761
|
+
cache,
|
|
2762
|
+
durability_,
|
|
2763
|
+
) = self._defaults(
|
|
2764
|
+
config,
|
|
2765
|
+
stream_mode=stream_mode,
|
|
2766
|
+
print_mode=print_mode,
|
|
2767
|
+
output_keys=output_keys,
|
|
2768
|
+
interrupt_before=interrupt_before,
|
|
2769
|
+
interrupt_after=interrupt_after,
|
|
2770
|
+
durability=durability,
|
|
2771
|
+
)
|
|
2772
|
+
callback_manager = get_callback_manager_for_config(config)
|
|
2773
|
+
if "messages" in stream_modes and version != "v2":
|
|
2774
|
+
# Strip any inherited v2 messages handler so a v1 stream
|
|
2775
|
+
# does not get routed through the content-block event
|
|
2776
|
+
# protocol. Leave v1 handlers in place — an outer
|
|
2777
|
+
# stream(stream_mode="messages", subgraphs=True) relies
|
|
2778
|
+
# on its inheritable handler to observe events emitted
|
|
2779
|
+
# by inner stream(stream_mode="messages") calls.
|
|
2780
|
+
callback_manager.handlers = [
|
|
2781
|
+
h
|
|
2782
|
+
for h in callback_manager.handlers
|
|
2783
|
+
if not isinstance(h, StreamMessagesHandlerV2)
|
|
2784
|
+
]
|
|
2785
|
+
callback_manager.inheritable_handlers = [
|
|
2786
|
+
h
|
|
2787
|
+
for h in callback_manager.inheritable_handlers
|
|
2788
|
+
if not isinstance(h, StreamMessagesHandlerV2)
|
|
2789
|
+
]
|
|
2790
|
+
if "ls_integration" not in callback_manager.metadata:
|
|
2791
|
+
callback_manager.add_metadata({"ls_integration": "coco_graph"})
|
|
2792
|
+
run_manager = callback_manager.on_chain_start(
|
|
2793
|
+
None,
|
|
2794
|
+
input,
|
|
2795
|
+
name=config.get("run_name", self.get_name()),
|
|
2796
|
+
run_id=config.get("run_id"),
|
|
2797
|
+
)
|
|
2798
|
+
graph_callback_manager = get_sync_graph_callback_manager_for_config(
|
|
2799
|
+
config,
|
|
2800
|
+
run_id=run_manager.run_id,
|
|
2801
|
+
)
|
|
2802
|
+
if checkpointer is None and durability is not None:
|
|
2803
|
+
warnings.warn(
|
|
2804
|
+
"`durability` has no effect when no checkpointer is present.",
|
|
2805
|
+
)
|
|
2806
|
+
# set up subgraph checkpointing
|
|
2807
|
+
if self.checkpointer is True:
|
|
2808
|
+
ns = cast(str, config[CONF][CONFIG_KEY_CHECKPOINT_NS])
|
|
2809
|
+
config[CONF][CONFIG_KEY_CHECKPOINT_NS] = recast_checkpoint_ns(ns)
|
|
2810
|
+
# set up messages stream mode
|
|
2811
|
+
if "messages" in stream_modes:
|
|
2812
|
+
ns_ = cast(str | None, config[CONF].get(CONFIG_KEY_CHECKPOINT_NS))
|
|
2813
|
+
use_stream_messages_v2 = bool(
|
|
2814
|
+
version == "v2" and config[CONF].get(CONFIG_KEY_STREAM_MESSAGES_V2)
|
|
2815
|
+
)
|
|
2816
|
+
messages_handler_cls = (
|
|
2817
|
+
StreamMessagesHandlerV2
|
|
2818
|
+
if use_stream_messages_v2
|
|
2819
|
+
else StreamMessagesHandler
|
|
2820
|
+
)
|
|
2821
|
+
run_manager.inheritable_handlers.append(
|
|
2822
|
+
messages_handler_cls(
|
|
2823
|
+
stream.put,
|
|
2824
|
+
subgraphs,
|
|
2825
|
+
parent_ns=tuple(ns_.split(NS_SEP)) if ns_ else None,
|
|
2826
|
+
)
|
|
2827
|
+
)
|
|
2828
|
+
|
|
2829
|
+
# set up tools stream mode
|
|
2830
|
+
if "tools" in stream_modes:
|
|
2831
|
+
ns_tools = cast(str | None, config[CONF].get(CONFIG_KEY_CHECKPOINT_NS))
|
|
2832
|
+
run_manager.inheritable_handlers.append(
|
|
2833
|
+
StreamToolCallHandler(
|
|
2834
|
+
stream.put,
|
|
2835
|
+
subgraphs,
|
|
2836
|
+
parent_ns=tuple(ns_tools.split(NS_SEP)) if ns_tools else None,
|
|
2837
|
+
)
|
|
2838
|
+
)
|
|
2839
|
+
|
|
2840
|
+
# set up custom stream mode
|
|
2841
|
+
if "custom" in stream_modes:
|
|
2842
|
+
|
|
2843
|
+
def stream_writer(c: Any) -> None:
|
|
2844
|
+
stream.put(
|
|
2845
|
+
(
|
|
2846
|
+
tuple(
|
|
2847
|
+
get_config()[CONF][CONFIG_KEY_CHECKPOINT_NS].split(
|
|
2848
|
+
NS_SEP
|
|
2849
|
+
)[:-1]
|
|
2850
|
+
),
|
|
2851
|
+
"custom",
|
|
2852
|
+
c,
|
|
2853
|
+
)
|
|
2854
|
+
)
|
|
2855
|
+
elif CONFIG_KEY_STREAM in config[CONF]:
|
|
2856
|
+
stream_writer = config[CONF][CONFIG_KEY_RUNTIME].stream_writer
|
|
2857
|
+
else:
|
|
2858
|
+
|
|
2859
|
+
def stream_writer(c: Any) -> None:
|
|
2860
|
+
pass
|
|
2861
|
+
|
|
2862
|
+
# set durability mode for subgraphs
|
|
2863
|
+
if durability is not None:
|
|
2864
|
+
config[CONF][CONFIG_KEY_DURABILITY] = durability_
|
|
2865
|
+
|
|
2866
|
+
# build server_info from metadata + parent runtime
|
|
2867
|
+
parent_runtime = _coerce_parent_runtime(
|
|
2868
|
+
config[CONF].get(CONFIG_KEY_RUNTIME, DEFAULT_RUNTIME)
|
|
2869
|
+
)
|
|
2870
|
+
server_info = _build_server_info(config, parent_runtime)
|
|
2871
|
+
|
|
2872
|
+
runtime = Runtime(
|
|
2873
|
+
context=_coerce_context(self.context_schema, context),
|
|
2874
|
+
store=store,
|
|
2875
|
+
stream_writer=stream_writer,
|
|
2876
|
+
previous=None,
|
|
2877
|
+
execution_info=None,
|
|
2878
|
+
server_info=server_info,
|
|
2879
|
+
control=control or parent_runtime.control or RunControl(),
|
|
2880
|
+
)
|
|
2881
|
+
runtime = parent_runtime.merge(runtime)
|
|
2882
|
+
config[CONF][CONFIG_KEY_RUNTIME] = runtime
|
|
2883
|
+
|
|
2884
|
+
# resolve mappers for v2 stream coercion
|
|
2885
|
+
_output_mapper = self._output_mapper if version == "v2" else None
|
|
2886
|
+
_state_mapper = self._state_mapper if version == "v2" else None
|
|
2887
|
+
|
|
2888
|
+
def emit_graph_lifecycle_events(loop: SyncPregelLoop) -> None:
|
|
2889
|
+
while (event := loop._pop_lifecycle_event()) is not None:
|
|
2890
|
+
if isinstance(event, GraphResumeEvent):
|
|
2891
|
+
graph_callback_manager.on_resume(
|
|
2892
|
+
replace(event, run_id=graph_callback_manager.run_id)
|
|
2893
|
+
)
|
|
2894
|
+
else:
|
|
2895
|
+
graph_callback_manager.on_interrupt(
|
|
2896
|
+
replace(event, run_id=graph_callback_manager.run_id)
|
|
2897
|
+
)
|
|
2898
|
+
|
|
2899
|
+
with SyncPregelLoop(
|
|
2900
|
+
input,
|
|
2901
|
+
stream=StreamProtocol(stream.put, stream_modes),
|
|
2902
|
+
config=config,
|
|
2903
|
+
store=store,
|
|
2904
|
+
cache=cache,
|
|
2905
|
+
checkpointer=checkpointer,
|
|
2906
|
+
nodes=self.nodes,
|
|
2907
|
+
specs=self.channels,
|
|
2908
|
+
output_keys=output_keys,
|
|
2909
|
+
input_keys=self.input_channels,
|
|
2910
|
+
stream_keys=self.stream_channels_asis,
|
|
2911
|
+
interrupt_before=interrupt_before_,
|
|
2912
|
+
interrupt_after=interrupt_after_,
|
|
2913
|
+
manager=run_manager,
|
|
2914
|
+
durability=durability_,
|
|
2915
|
+
trigger_to_nodes=self.trigger_to_nodes,
|
|
2916
|
+
migrate_checkpoint=self._migrate_checkpoint,
|
|
2917
|
+
retry_policy=self.retry_policy,
|
|
2918
|
+
cache_policy=self.cache_policy,
|
|
2919
|
+
has_graph_lifecycle_callbacks=bool(graph_callback_manager.handlers),
|
|
2920
|
+
) as loop:
|
|
2921
|
+
emit_graph_lifecycle_events(loop)
|
|
2922
|
+
# create runner
|
|
2923
|
+
runner = PregelRunner(
|
|
2924
|
+
submit=config[CONF].get(
|
|
2925
|
+
CONFIG_KEY_RUNNER_SUBMIT, weakref.WeakMethod(loop.submit)
|
|
2926
|
+
),
|
|
2927
|
+
put_writes=weakref.WeakMethod(loop.put_writes),
|
|
2928
|
+
node_finished=config[CONF].get(CONFIG_KEY_NODE_FINISHED),
|
|
2929
|
+
node_error_handler_map=self.node_error_handler_map,
|
|
2930
|
+
schedule_error_handler=loop.schedule_error_handler,
|
|
2931
|
+
)
|
|
2932
|
+
# enable subgraph streaming
|
|
2933
|
+
if subgraphs:
|
|
2934
|
+
loop.config[CONF][CONFIG_KEY_STREAM] = loop.stream
|
|
2935
|
+
# enable concurrent streaming
|
|
2936
|
+
get_waiter: Callable[[], concurrent.futures.Future[None]] | None = None
|
|
2937
|
+
if (
|
|
2938
|
+
self.stream_eager
|
|
2939
|
+
or subgraphs
|
|
2940
|
+
or "messages" in stream_modes
|
|
2941
|
+
or "custom" in stream_modes
|
|
2942
|
+
):
|
|
2943
|
+
# we are careful to have a single waiter live at any one time
|
|
2944
|
+
# because on exit we increment semaphore count by exactly 1
|
|
2945
|
+
waiter: concurrent.futures.Future | None = None
|
|
2946
|
+
# because sync futures cannot be cancelled, we instead
|
|
2947
|
+
# release the stream semaphore on exit, which will cause
|
|
2948
|
+
# a pending waiter to return immediately
|
|
2949
|
+
loop.stack.callback(stream._count.release)
|
|
2950
|
+
|
|
2951
|
+
def get_waiter() -> concurrent.futures.Future[None]:
|
|
2952
|
+
nonlocal waiter
|
|
2953
|
+
if waiter is None or waiter.done():
|
|
2954
|
+
waiter = loop.submit(stream.wait)
|
|
2955
|
+
return waiter
|
|
2956
|
+
else:
|
|
2957
|
+
return waiter
|
|
2958
|
+
|
|
2959
|
+
# Similarly to Bulk Synchronous Parallel / Pregel model
|
|
2960
|
+
# computation proceeds in steps, while there are channel updates.
|
|
2961
|
+
# Channel updates from step N are only visible in step N+1
|
|
2962
|
+
# channels are guaranteed to be immutable for the duration of the step,
|
|
2963
|
+
# with channel updates applied only at the transition between steps.
|
|
2964
|
+
while loop.tick():
|
|
2965
|
+
for task in loop.match_cached_writes():
|
|
2966
|
+
loop.output_writes(task.id, task.writes, cached=True)
|
|
2967
|
+
for _ in runner.tick(
|
|
2968
|
+
[t for t in loop.tasks.values() if not t.writes],
|
|
2969
|
+
timeout=self.step_timeout,
|
|
2970
|
+
get_waiter=get_waiter,
|
|
2971
|
+
schedule_task=loop.accept_push,
|
|
2972
|
+
):
|
|
2973
|
+
# emit output
|
|
2974
|
+
yield from _output(
|
|
2975
|
+
stream_mode,
|
|
2976
|
+
print_mode,
|
|
2977
|
+
subgraphs,
|
|
2978
|
+
stream.get,
|
|
2979
|
+
queue.Empty,
|
|
2980
|
+
version,
|
|
2981
|
+
_output_mapper,
|
|
2982
|
+
_state_mapper,
|
|
2983
|
+
)
|
|
2984
|
+
loop.after_tick()
|
|
2985
|
+
emit_graph_lifecycle_events(loop)
|
|
2986
|
+
# wait for checkpoint
|
|
2987
|
+
if durability_ == "sync":
|
|
2988
|
+
loop._put_checkpoint_fut.result()
|
|
2989
|
+
emit_graph_lifecycle_events(loop)
|
|
2990
|
+
# emit output
|
|
2991
|
+
yield from _output(
|
|
2992
|
+
stream_mode,
|
|
2993
|
+
print_mode,
|
|
2994
|
+
subgraphs,
|
|
2995
|
+
stream.get,
|
|
2996
|
+
queue.Empty,
|
|
2997
|
+
version,
|
|
2998
|
+
_output_mapper,
|
|
2999
|
+
_state_mapper,
|
|
3000
|
+
)
|
|
3001
|
+
# handle exit
|
|
3002
|
+
if loop.status == "out_of_steps":
|
|
3003
|
+
msg = create_error_message(
|
|
3004
|
+
message=(
|
|
3005
|
+
f"Recursion limit of {config['recursion_limit']} reached "
|
|
3006
|
+
"without hitting a stop condition. You can increase the "
|
|
3007
|
+
"limit by setting the `recursion_limit` config key."
|
|
3008
|
+
),
|
|
3009
|
+
error_code=ErrorCode.GRAPH_RECURSION_LIMIT,
|
|
3010
|
+
)
|
|
3011
|
+
raise GraphRecursionError(msg)
|
|
3012
|
+
elif loop.status == "draining":
|
|
3013
|
+
if loop.control is None:
|
|
3014
|
+
raise RuntimeError("Draining status requires run control")
|
|
3015
|
+
raise GraphDrained(loop.control.drain_reason or "shutdown")
|
|
3016
|
+
# set final channel values as run output
|
|
3017
|
+
run_manager.on_chain_end(loop.output)
|
|
3018
|
+
except BaseException as e:
|
|
3019
|
+
if run_manager is not None:
|
|
3020
|
+
run_manager.on_chain_error(e)
|
|
3021
|
+
raise
|
|
3022
|
+
|
|
3023
|
+
@overload
|
|
3024
|
+
def astream(
|
|
3025
|
+
self,
|
|
3026
|
+
input: InputT | Command | None,
|
|
3027
|
+
config: RunnableConfig | None = None,
|
|
3028
|
+
*,
|
|
3029
|
+
context: ContextT | None = None,
|
|
3030
|
+
stream_mode: StreamMode | Sequence[StreamMode] | None = None,
|
|
3031
|
+
print_mode: StreamMode | Sequence[StreamMode] = (),
|
|
3032
|
+
output_keys: str | Sequence[str] | None = None,
|
|
3033
|
+
interrupt_before: All | Sequence[str] | None = None,
|
|
3034
|
+
interrupt_after: All | Sequence[str] | None = None,
|
|
3035
|
+
durability: Durability | None = None,
|
|
3036
|
+
control: RunControl | None = None,
|
|
3037
|
+
subgraphs: bool = False,
|
|
3038
|
+
debug: bool | None = None,
|
|
3039
|
+
version: Literal["v2"],
|
|
3040
|
+
**kwargs: Unpack[DeprecatedKwargs],
|
|
3041
|
+
) -> AsyncIterator[StreamPart[StateT, OutputT]]: ...
|
|
3042
|
+
|
|
3043
|
+
@overload
|
|
3044
|
+
def astream(
|
|
3045
|
+
self,
|
|
3046
|
+
input: InputT | Command | None,
|
|
3047
|
+
config: RunnableConfig | None = None,
|
|
3048
|
+
*,
|
|
3049
|
+
context: ContextT | None = None,
|
|
3050
|
+
stream_mode: StreamMode | Sequence[StreamMode] | None = None,
|
|
3051
|
+
print_mode: StreamMode | Sequence[StreamMode] = (),
|
|
3052
|
+
output_keys: str | Sequence[str] | None = None,
|
|
3053
|
+
interrupt_before: All | Sequence[str] | None = None,
|
|
3054
|
+
interrupt_after: All | Sequence[str] | None = None,
|
|
3055
|
+
durability: Durability | None = None,
|
|
3056
|
+
control: RunControl | None = None,
|
|
3057
|
+
subgraphs: bool = False,
|
|
3058
|
+
debug: bool | None = None,
|
|
3059
|
+
version: Literal["v1"] = ...,
|
|
3060
|
+
**kwargs: Unpack[DeprecatedKwargs],
|
|
3061
|
+
) -> AsyncIterator[dict[str, Any] | Any]: ...
|
|
3062
|
+
|
|
3063
|
+
async def astream(
|
|
3064
|
+
self,
|
|
3065
|
+
input: InputT | Command | None,
|
|
3066
|
+
config: RunnableConfig | None = None,
|
|
3067
|
+
*,
|
|
3068
|
+
context: ContextT | None = None,
|
|
3069
|
+
stream_mode: StreamMode | Sequence[StreamMode] | None = None,
|
|
3070
|
+
print_mode: StreamMode | Sequence[StreamMode] = (),
|
|
3071
|
+
output_keys: str | Sequence[str] | None = None,
|
|
3072
|
+
interrupt_before: All | Sequence[str] | None = None,
|
|
3073
|
+
interrupt_after: All | Sequence[str] | None = None,
|
|
3074
|
+
durability: Durability | None = None,
|
|
3075
|
+
control: RunControl | None = None,
|
|
3076
|
+
subgraphs: bool = False,
|
|
3077
|
+
debug: bool | None = None,
|
|
3078
|
+
version: Literal["v1", "v2"] = "v1",
|
|
3079
|
+
**kwargs: Unpack[DeprecatedKwargs],
|
|
3080
|
+
) -> AsyncIterator[dict[str, Any] | Any]:
|
|
3081
|
+
"""Asynchronously stream graph steps for a single input.
|
|
3082
|
+
|
|
3083
|
+
Args:
|
|
3084
|
+
input: The input to the graph.
|
|
3085
|
+
config: The configuration to use for the run.
|
|
3086
|
+
context: The static context to use for the run.
|
|
3087
|
+
!!! version-added "Added in version 0.6.0"
|
|
3088
|
+
stream_mode: The mode to stream output, defaults to `self.stream_mode`.
|
|
3089
|
+
|
|
3090
|
+
Options are:
|
|
3091
|
+
|
|
3092
|
+
- `"values"`: Emit all values in the state after each step, including interrupts.
|
|
3093
|
+
When used with functional API, values are emitted once at the end of the workflow.
|
|
3094
|
+
- `"updates"`: Emit only the node or task names and updates returned by the nodes or tasks after each step.
|
|
3095
|
+
If multiple updates are made in the same step (e.g. multiple nodes are run) then those updates are emitted separately.
|
|
3096
|
+
- `"custom"`: Emit custom data from inside nodes or tasks using `StreamWriter`.
|
|
3097
|
+
- `"messages"`: Emit LLM messages token-by-token together with metadata for any LLM invocations inside nodes or tasks.
|
|
3098
|
+
- Will be emitted as 2-tuples `(LLM token, metadata)`.
|
|
3099
|
+
- `"checkpoints"`: Emit an event when a checkpoint is created, in the same format as returned by `get_state()`.
|
|
3100
|
+
- `"tasks"`: Emit events when tasks start and finish, including their results and errors.
|
|
3101
|
+
- `"debug"`: Emit debug events with as much information as possible for each step.
|
|
3102
|
+
|
|
3103
|
+
You can pass a list as the `stream_mode` parameter to stream multiple modes at once.
|
|
3104
|
+
The streamed outputs will be tuples of `(mode, data)`.
|
|
3105
|
+
|
|
3106
|
+
See [CocoGraph streaming guide](https://docs.langchain.com/oss/python/langgraph/streaming) for more details.
|
|
3107
|
+
print_mode: Accepts the same values as `stream_mode`, but only prints the output to the console, for debugging purposes.
|
|
3108
|
+
|
|
3109
|
+
Does not affect the output of the graph in any way.
|
|
3110
|
+
output_keys: The keys to stream, defaults to all non-context channels.
|
|
3111
|
+
interrupt_before: Nodes to interrupt before, defaults to all nodes in the graph.
|
|
3112
|
+
interrupt_after: Nodes to interrupt after, defaults to all nodes in the graph.
|
|
3113
|
+
durability: The durability mode for the graph execution, defaults to `"async"`.
|
|
3114
|
+
|
|
3115
|
+
Options are:
|
|
3116
|
+
|
|
3117
|
+
- `"sync"`: Changes are persisted synchronously before the next step starts.
|
|
3118
|
+
- `"async"`: Changes are persisted asynchronously while the next step executes.
|
|
3119
|
+
- `"exit"`: Changes are persisted only when the graph exits.
|
|
3120
|
+
control: Optional run control used to request cooperative drain.
|
|
3121
|
+
subgraphs: Whether to stream events from inside subgraphs, defaults to `False`.
|
|
3122
|
+
|
|
3123
|
+
If `True`, the events will be emitted as tuples `(namespace, data)`,
|
|
3124
|
+
or `(namespace, mode, data)` if `stream_mode` is a list,
|
|
3125
|
+
where `namespace` is a tuple with the path to the node where a subgraph is invoked,
|
|
3126
|
+
e.g. `("parent_node:<task_id>", "child_node:<task_id>")`.
|
|
3127
|
+
|
|
3128
|
+
See [CocoGraph streaming guide](https://docs.langchain.com/oss/python/langgraph/streaming) for more details.
|
|
3129
|
+
|
|
3130
|
+
Yields:
|
|
3131
|
+
The output of each step in the graph. The output shape depends on the `stream_mode`.
|
|
3132
|
+
"""
|
|
3133
|
+
if (checkpoint_during := kwargs.get("checkpoint_during")) is not None:
|
|
3134
|
+
warnings.warn(
|
|
3135
|
+
"`checkpoint_during` is deprecated and will be removed. Please use `durability` instead.",
|
|
3136
|
+
category=CocoGraphDeprecatedSinceV10,
|
|
3137
|
+
stacklevel=2,
|
|
3138
|
+
)
|
|
3139
|
+
if durability is not None:
|
|
3140
|
+
raise ValueError(
|
|
3141
|
+
"Cannot use both `checkpoint_during` and `durability` parameters. Please use `durability` instead."
|
|
3142
|
+
)
|
|
3143
|
+
durability = "async" if checkpoint_during else "exit"
|
|
3144
|
+
|
|
3145
|
+
if stream_mode is None:
|
|
3146
|
+
# if being called as a node in another graph, default to values mode
|
|
3147
|
+
# but don't overwrite stream_mode arg if provided
|
|
3148
|
+
stream_mode = (
|
|
3149
|
+
"values"
|
|
3150
|
+
if config is not None and CONFIG_KEY_TASK_ID in config.get(CONF, {})
|
|
3151
|
+
else self.stream_mode
|
|
3152
|
+
)
|
|
3153
|
+
if debug or self.debug:
|
|
3154
|
+
print_mode = ["updates", "values"]
|
|
3155
|
+
|
|
3156
|
+
stream = AsyncQueue()
|
|
3157
|
+
aioloop = asyncio.get_running_loop()
|
|
3158
|
+
stream_put = cast(
|
|
3159
|
+
Callable[[StreamChunk], None],
|
|
3160
|
+
partial(aioloop.call_soon_threadsafe, stream.put_nowait),
|
|
3161
|
+
)
|
|
3162
|
+
|
|
3163
|
+
config = ensure_config(self.config, config)
|
|
3164
|
+
run_manager = None
|
|
3165
|
+
try:
|
|
3166
|
+
# assign defaults
|
|
3167
|
+
(
|
|
3168
|
+
stream_modes,
|
|
3169
|
+
output_keys,
|
|
3170
|
+
interrupt_before_,
|
|
3171
|
+
interrupt_after_,
|
|
3172
|
+
checkpointer,
|
|
3173
|
+
store,
|
|
3174
|
+
cache,
|
|
3175
|
+
durability_,
|
|
3176
|
+
) = self._defaults(
|
|
3177
|
+
config,
|
|
3178
|
+
stream_mode=stream_mode,
|
|
3179
|
+
print_mode=print_mode,
|
|
3180
|
+
output_keys=output_keys,
|
|
3181
|
+
interrupt_before=interrupt_before,
|
|
3182
|
+
interrupt_after=interrupt_after,
|
|
3183
|
+
durability=durability,
|
|
3184
|
+
)
|
|
3185
|
+
callback_manager = get_async_callback_manager_for_config(config)
|
|
3186
|
+
if "messages" in stream_modes and version != "v2":
|
|
3187
|
+
# Strip any inherited v2 messages handler so a v1 stream
|
|
3188
|
+
# does not get routed through the content-block event
|
|
3189
|
+
# protocol. Leave v1 handlers in place — an outer
|
|
3190
|
+
# astream(stream_mode="messages", subgraphs=True) relies
|
|
3191
|
+
# on its inheritable handler to observe events emitted
|
|
3192
|
+
# by inner astream(stream_mode="messages") calls.
|
|
3193
|
+
callback_manager.handlers = [
|
|
3194
|
+
h
|
|
3195
|
+
for h in callback_manager.handlers
|
|
3196
|
+
if not isinstance(h, StreamMessagesHandlerV2)
|
|
3197
|
+
]
|
|
3198
|
+
callback_manager.inheritable_handlers = [
|
|
3199
|
+
h
|
|
3200
|
+
for h in callback_manager.inheritable_handlers
|
|
3201
|
+
if not isinstance(h, StreamMessagesHandlerV2)
|
|
3202
|
+
]
|
|
3203
|
+
if "ls_integration" not in callback_manager.metadata:
|
|
3204
|
+
callback_manager.add_metadata({"ls_integration": "coco_graph"})
|
|
3205
|
+
run_manager = await callback_manager.on_chain_start(
|
|
3206
|
+
None,
|
|
3207
|
+
input,
|
|
3208
|
+
name=config.get("run_name", self.get_name()),
|
|
3209
|
+
run_id=config.get("run_id"),
|
|
3210
|
+
)
|
|
3211
|
+
graph_callback_manager = get_async_graph_callback_manager_for_config(
|
|
3212
|
+
config,
|
|
3213
|
+
run_id=run_manager.run_id,
|
|
3214
|
+
)
|
|
3215
|
+
# if running from astream_log() run each proc with streaming
|
|
3216
|
+
do_stream = (
|
|
3217
|
+
next(
|
|
3218
|
+
(
|
|
3219
|
+
True
|
|
3220
|
+
for h in run_manager.handlers
|
|
3221
|
+
if isinstance(h, _StreamingCallbackHandler)
|
|
3222
|
+
and not isinstance(h, StreamMessagesHandler)
|
|
3223
|
+
),
|
|
3224
|
+
False,
|
|
3225
|
+
)
|
|
3226
|
+
if _StreamingCallbackHandler is not None
|
|
3227
|
+
else False
|
|
3228
|
+
)
|
|
3229
|
+
if checkpointer is None and durability is not None:
|
|
3230
|
+
warnings.warn(
|
|
3231
|
+
"`durability` has no effect when no checkpointer is present.",
|
|
3232
|
+
)
|
|
3233
|
+
# set up subgraph checkpointing
|
|
3234
|
+
if self.checkpointer is True:
|
|
3235
|
+
ns = cast(str, config[CONF][CONFIG_KEY_CHECKPOINT_NS])
|
|
3236
|
+
config[CONF][CONFIG_KEY_CHECKPOINT_NS] = recast_checkpoint_ns(ns)
|
|
3237
|
+
# set up messages stream mode
|
|
3238
|
+
if "messages" in stream_modes:
|
|
3239
|
+
# namespace can be None in a root level graph?
|
|
3240
|
+
ns_ = cast(str | None, config[CONF].get(CONFIG_KEY_CHECKPOINT_NS))
|
|
3241
|
+
use_stream_messages_v2 = bool(
|
|
3242
|
+
version == "v2" and config[CONF].get(CONFIG_KEY_STREAM_MESSAGES_V2)
|
|
3243
|
+
)
|
|
3244
|
+
messages_handler_cls = (
|
|
3245
|
+
StreamMessagesHandlerV2
|
|
3246
|
+
if use_stream_messages_v2
|
|
3247
|
+
else StreamMessagesHandler
|
|
3248
|
+
)
|
|
3249
|
+
run_manager.inheritable_handlers.append(
|
|
3250
|
+
messages_handler_cls(
|
|
3251
|
+
stream_put,
|
|
3252
|
+
subgraphs,
|
|
3253
|
+
parent_ns=tuple(ns_.split(NS_SEP)) if ns_ else None,
|
|
3254
|
+
)
|
|
3255
|
+
)
|
|
3256
|
+
|
|
3257
|
+
# set up tools stream mode
|
|
3258
|
+
if "tools" in stream_modes:
|
|
3259
|
+
ns_tools = cast(str | None, config[CONF].get(CONFIG_KEY_CHECKPOINT_NS))
|
|
3260
|
+
run_manager.inheritable_handlers.append(
|
|
3261
|
+
StreamToolCallHandler(
|
|
3262
|
+
stream_put,
|
|
3263
|
+
subgraphs,
|
|
3264
|
+
parent_ns=tuple(ns_tools.split(NS_SEP)) if ns_tools else None,
|
|
3265
|
+
)
|
|
3266
|
+
)
|
|
3267
|
+
|
|
3268
|
+
# set up custom stream mode
|
|
3269
|
+
def stream_writer(c: Any) -> None:
|
|
3270
|
+
aioloop.call_soon_threadsafe(
|
|
3271
|
+
stream.put_nowait,
|
|
3272
|
+
(
|
|
3273
|
+
tuple(
|
|
3274
|
+
get_config()[CONF][CONFIG_KEY_CHECKPOINT_NS].split(NS_SEP)[
|
|
3275
|
+
:-1
|
|
3276
|
+
]
|
|
3277
|
+
),
|
|
3278
|
+
"custom",
|
|
3279
|
+
c,
|
|
3280
|
+
),
|
|
3281
|
+
)
|
|
3282
|
+
|
|
3283
|
+
if "custom" in stream_modes:
|
|
3284
|
+
|
|
3285
|
+
def stream_writer(c: Any) -> None:
|
|
3286
|
+
aioloop.call_soon_threadsafe(
|
|
3287
|
+
stream.put_nowait,
|
|
3288
|
+
(
|
|
3289
|
+
tuple(
|
|
3290
|
+
get_config()[CONF][CONFIG_KEY_CHECKPOINT_NS].split(
|
|
3291
|
+
NS_SEP
|
|
3292
|
+
)[:-1]
|
|
3293
|
+
),
|
|
3294
|
+
"custom",
|
|
3295
|
+
c,
|
|
3296
|
+
),
|
|
3297
|
+
)
|
|
3298
|
+
elif CONFIG_KEY_STREAM in config[CONF]:
|
|
3299
|
+
stream_writer = config[CONF][CONFIG_KEY_RUNTIME].stream_writer
|
|
3300
|
+
else:
|
|
3301
|
+
|
|
3302
|
+
def stream_writer(c: Any) -> None:
|
|
3303
|
+
pass
|
|
3304
|
+
|
|
3305
|
+
# set durability mode for subgraphs
|
|
3306
|
+
if durability is not None:
|
|
3307
|
+
config[CONF][CONFIG_KEY_DURABILITY] = durability_
|
|
3308
|
+
|
|
3309
|
+
# build server_info from metadata + parent runtime
|
|
3310
|
+
parent_runtime = _coerce_parent_runtime(
|
|
3311
|
+
config[CONF].get(CONFIG_KEY_RUNTIME, DEFAULT_RUNTIME)
|
|
3312
|
+
)
|
|
3313
|
+
server_info = _build_server_info(config, parent_runtime)
|
|
3314
|
+
|
|
3315
|
+
runtime = Runtime(
|
|
3316
|
+
context=_coerce_context(self.context_schema, context),
|
|
3317
|
+
store=store,
|
|
3318
|
+
stream_writer=stream_writer,
|
|
3319
|
+
previous=None,
|
|
3320
|
+
execution_info=None,
|
|
3321
|
+
server_info=server_info,
|
|
3322
|
+
control=control or parent_runtime.control or RunControl(),
|
|
3323
|
+
)
|
|
3324
|
+
runtime = parent_runtime.merge(runtime)
|
|
3325
|
+
config[CONF][CONFIG_KEY_RUNTIME] = runtime
|
|
3326
|
+
|
|
3327
|
+
# resolve mappers for v2 stream coercion
|
|
3328
|
+
_output_mapper = self._output_mapper if version == "v2" else None
|
|
3329
|
+
_state_mapper = self._state_mapper if version == "v2" else None
|
|
3330
|
+
|
|
3331
|
+
async def aemit_graph_lifecycle_events(loop: AsyncPregelLoop) -> None:
|
|
3332
|
+
while (event := loop._pop_lifecycle_event()) is not None:
|
|
3333
|
+
if isinstance(event, GraphResumeEvent):
|
|
3334
|
+
await graph_callback_manager.on_resume(
|
|
3335
|
+
GraphResumeEvent(
|
|
3336
|
+
run_id=graph_callback_manager.run_id,
|
|
3337
|
+
status=event.status,
|
|
3338
|
+
checkpoint_id=event.checkpoint_id,
|
|
3339
|
+
checkpoint_ns=event.checkpoint_ns,
|
|
3340
|
+
)
|
|
3341
|
+
)
|
|
3342
|
+
else:
|
|
3343
|
+
await graph_callback_manager.on_interrupt(
|
|
3344
|
+
GraphInterruptEvent(
|
|
3345
|
+
run_id=graph_callback_manager.run_id,
|
|
3346
|
+
status=event.status,
|
|
3347
|
+
checkpoint_id=event.checkpoint_id,
|
|
3348
|
+
checkpoint_ns=event.checkpoint_ns,
|
|
3349
|
+
interrupts=event.interrupts,
|
|
3350
|
+
)
|
|
3351
|
+
)
|
|
3352
|
+
|
|
3353
|
+
async with AsyncPregelLoop(
|
|
3354
|
+
input,
|
|
3355
|
+
stream=StreamProtocol(stream.put_nowait, stream_modes),
|
|
3356
|
+
config=config,
|
|
3357
|
+
store=store,
|
|
3358
|
+
cache=cache,
|
|
3359
|
+
checkpointer=checkpointer,
|
|
3360
|
+
nodes=self.nodes,
|
|
3361
|
+
specs=self.channels,
|
|
3362
|
+
output_keys=output_keys,
|
|
3363
|
+
input_keys=self.input_channels,
|
|
3364
|
+
stream_keys=self.stream_channels_asis,
|
|
3365
|
+
interrupt_before=interrupt_before_,
|
|
3366
|
+
interrupt_after=interrupt_after_,
|
|
3367
|
+
manager=run_manager,
|
|
3368
|
+
durability=durability_,
|
|
3369
|
+
trigger_to_nodes=self.trigger_to_nodes,
|
|
3370
|
+
migrate_checkpoint=self._migrate_checkpoint,
|
|
3371
|
+
retry_policy=self.retry_policy,
|
|
3372
|
+
cache_policy=self.cache_policy,
|
|
3373
|
+
has_graph_lifecycle_callbacks=bool(graph_callback_manager.handlers),
|
|
3374
|
+
) as loop:
|
|
3375
|
+
await aemit_graph_lifecycle_events(loop)
|
|
3376
|
+
# create runner
|
|
3377
|
+
runner = PregelRunner(
|
|
3378
|
+
submit=config[CONF].get(
|
|
3379
|
+
CONFIG_KEY_RUNNER_SUBMIT, weakref.WeakMethod(loop.submit)
|
|
3380
|
+
),
|
|
3381
|
+
put_writes=weakref.WeakMethod(loop.put_writes),
|
|
3382
|
+
use_astream=do_stream,
|
|
3383
|
+
node_finished=config[CONF].get(CONFIG_KEY_NODE_FINISHED),
|
|
3384
|
+
node_error_handler_map=self.node_error_handler_map,
|
|
3385
|
+
aschedule_error_handler=loop.aschedule_error_handler,
|
|
3386
|
+
)
|
|
3387
|
+
# enable subgraph streaming
|
|
3388
|
+
if subgraphs:
|
|
3389
|
+
loop.config[CONF][CONFIG_KEY_STREAM] = StreamProtocol(
|
|
3390
|
+
stream_put, stream_modes
|
|
3391
|
+
)
|
|
3392
|
+
# enable concurrent streaming
|
|
3393
|
+
get_waiter: Callable[[], asyncio.Task[None]] | None = None
|
|
3394
|
+
_cleanup_waiter: Callable[[], Awaitable[None]] | None = None
|
|
3395
|
+
if (
|
|
3396
|
+
self.stream_eager
|
|
3397
|
+
or subgraphs
|
|
3398
|
+
or "messages" in stream_modes
|
|
3399
|
+
or "custom" in stream_modes
|
|
3400
|
+
):
|
|
3401
|
+
# Keep a single waiter task alive; ensure cleanup on exit.
|
|
3402
|
+
waiter: asyncio.Task[None] | None = None
|
|
3403
|
+
|
|
3404
|
+
def get_waiter() -> asyncio.Task[None]:
|
|
3405
|
+
nonlocal waiter
|
|
3406
|
+
if waiter is None or waiter.done():
|
|
3407
|
+
waiter = aioloop.create_task(stream.wait())
|
|
3408
|
+
|
|
3409
|
+
def _clear(t: asyncio.Task[None]) -> None:
|
|
3410
|
+
nonlocal waiter
|
|
3411
|
+
if waiter is t:
|
|
3412
|
+
waiter = None
|
|
3413
|
+
|
|
3414
|
+
waiter.add_done_callback(_clear)
|
|
3415
|
+
return waiter
|
|
3416
|
+
|
|
3417
|
+
async def _cleanup_waiter() -> None:
|
|
3418
|
+
"""Wake pending waiter and/or cancel+await to avoid pending tasks."""
|
|
3419
|
+
nonlocal waiter
|
|
3420
|
+
# Try to wake via semaphore like SyncPregelLoop
|
|
3421
|
+
with contextlib.suppress(Exception):
|
|
3422
|
+
if hasattr(stream, "_count"):
|
|
3423
|
+
stream._count.release()
|
|
3424
|
+
t = waiter
|
|
3425
|
+
waiter = None
|
|
3426
|
+
if t is not None and not t.done():
|
|
3427
|
+
t.cancel()
|
|
3428
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
3429
|
+
await t
|
|
3430
|
+
|
|
3431
|
+
# Similarly to Bulk Synchronous Parallel / Pregel model
|
|
3432
|
+
# computation proceeds in steps, while there are channel updates
|
|
3433
|
+
# channel updates from step N are only visible in step N+1
|
|
3434
|
+
# channels are guaranteed to be immutable for the duration of the step,
|
|
3435
|
+
# with channel updates applied only at the transition between steps
|
|
3436
|
+
try:
|
|
3437
|
+
while loop.tick():
|
|
3438
|
+
for task in await loop.amatch_cached_writes():
|
|
3439
|
+
loop.output_writes(task.id, task.writes, cached=True)
|
|
3440
|
+
async for _ in runner.atick(
|
|
3441
|
+
[t for t in loop.tasks.values() if not t.writes],
|
|
3442
|
+
timeout=self.step_timeout,
|
|
3443
|
+
get_waiter=get_waiter,
|
|
3444
|
+
schedule_task=loop.aaccept_push,
|
|
3445
|
+
):
|
|
3446
|
+
# emit output
|
|
3447
|
+
for o in _output(
|
|
3448
|
+
stream_mode,
|
|
3449
|
+
print_mode,
|
|
3450
|
+
subgraphs,
|
|
3451
|
+
stream.get_nowait,
|
|
3452
|
+
asyncio.QueueEmpty,
|
|
3453
|
+
version,
|
|
3454
|
+
_output_mapper,
|
|
3455
|
+
_state_mapper,
|
|
3456
|
+
):
|
|
3457
|
+
yield o
|
|
3458
|
+
loop.after_tick()
|
|
3459
|
+
await aemit_graph_lifecycle_events(loop)
|
|
3460
|
+
# wait for checkpoint
|
|
3461
|
+
if durability_ == "sync":
|
|
3462
|
+
await cast(asyncio.Future, loop._put_checkpoint_fut)
|
|
3463
|
+
finally:
|
|
3464
|
+
# ensure waiter doesn't remain pending on cancel/shutdown
|
|
3465
|
+
if _cleanup_waiter is not None:
|
|
3466
|
+
await _cleanup_waiter()
|
|
3467
|
+
|
|
3468
|
+
await aemit_graph_lifecycle_events(loop)
|
|
3469
|
+
|
|
3470
|
+
# emit output
|
|
3471
|
+
for o in _output(
|
|
3472
|
+
stream_mode,
|
|
3473
|
+
print_mode,
|
|
3474
|
+
subgraphs,
|
|
3475
|
+
stream.get_nowait,
|
|
3476
|
+
asyncio.QueueEmpty,
|
|
3477
|
+
version,
|
|
3478
|
+
_output_mapper,
|
|
3479
|
+
_state_mapper,
|
|
3480
|
+
):
|
|
3481
|
+
yield o
|
|
3482
|
+
# handle exit
|
|
3483
|
+
if loop.status == "out_of_steps":
|
|
3484
|
+
msg = create_error_message(
|
|
3485
|
+
message=(
|
|
3486
|
+
f"Recursion limit of {config['recursion_limit']} reached "
|
|
3487
|
+
"without hitting a stop condition. You can increase the "
|
|
3488
|
+
"limit by setting the `recursion_limit` config key."
|
|
3489
|
+
),
|
|
3490
|
+
error_code=ErrorCode.GRAPH_RECURSION_LIMIT,
|
|
3491
|
+
)
|
|
3492
|
+
raise GraphRecursionError(msg)
|
|
3493
|
+
elif loop.status == "draining":
|
|
3494
|
+
if loop.control is None:
|
|
3495
|
+
raise RuntimeError("Draining status requires run control")
|
|
3496
|
+
raise GraphDrained(loop.control.drain_reason or "shutdown")
|
|
3497
|
+
# set final channel values as run output
|
|
3498
|
+
await run_manager.on_chain_end(loop.output)
|
|
3499
|
+
except BaseException as e:
|
|
3500
|
+
if run_manager is not None:
|
|
3501
|
+
await asyncio.shield(run_manager.on_chain_error(e))
|
|
3502
|
+
raise
|
|
3503
|
+
|
|
3504
|
+
@beta(message="The v3 streaming protocol on Pregel is experimental.")
|
|
3505
|
+
def _pregel_stream_v3(
|
|
3506
|
+
self,
|
|
3507
|
+
input: InputT | Command | None,
|
|
3508
|
+
config: RunnableConfig | None = None,
|
|
3509
|
+
*,
|
|
3510
|
+
interrupt_before: All | Sequence[str] | None = None,
|
|
3511
|
+
interrupt_after: All | Sequence[str] | None = None,
|
|
3512
|
+
control: RunControl | None = None,
|
|
3513
|
+
transformers: Sequence[Callable[[tuple[str, ...]], Any]] | None = None,
|
|
3514
|
+
**kwargs: Any,
|
|
3515
|
+
) -> GraphRunStream:
|
|
3516
|
+
"""Internal v3 sync streaming implementation. Public entry: stream_events(version='v3').
|
|
3517
|
+
|
|
3518
|
+
Extra keyword arguments are forwarded to the underlying ``stream(...)``
|
|
3519
|
+
call. The dispatcher in ``stream_events`` rejects ``stream_mode`` and
|
|
3520
|
+
``subgraphs`` since v3 owns them (``stream_mode`` is derived from the
|
|
3521
|
+
transformer mux; ``subgraphs`` is always True so nested namespaces
|
|
3522
|
+
flow through scoped muxes).
|
|
3523
|
+
|
|
3524
|
+
!!! warning
|
|
3525
|
+
|
|
3526
|
+
The v3 streaming protocol is experimental and may change.
|
|
3527
|
+
"""
|
|
3528
|
+
parent_ns = _resolve_parent_ns(self.config, config)
|
|
3529
|
+
compiled_factories = _normalize_stream_transformer_factories(
|
|
3530
|
+
self.stream_transformers
|
|
3531
|
+
)
|
|
3532
|
+
extra_factories = _normalize_stream_transformer_factories(transformers)
|
|
3533
|
+
mux = StreamMux(
|
|
3534
|
+
factories=[
|
|
3535
|
+
ValuesTransformer,
|
|
3536
|
+
MessagesTransformer,
|
|
3537
|
+
LifecycleTransformer,
|
|
3538
|
+
SubgraphTransformer,
|
|
3539
|
+
*compiled_factories,
|
|
3540
|
+
*extra_factories,
|
|
3541
|
+
],
|
|
3542
|
+
scope=parent_ns,
|
|
3543
|
+
is_async=False,
|
|
3544
|
+
)
|
|
3545
|
+
graph_iter = iter(
|
|
3546
|
+
self.stream(
|
|
3547
|
+
input,
|
|
3548
|
+
patch_configurable(config, {CONFIG_KEY_STREAM_MESSAGES_V2: True}),
|
|
3549
|
+
stream_mode=_collect_stream_modes(mux),
|
|
3550
|
+
subgraphs=True,
|
|
3551
|
+
version="v2",
|
|
3552
|
+
interrupt_before=interrupt_before,
|
|
3553
|
+
interrupt_after=interrupt_after,
|
|
3554
|
+
control=control,
|
|
3555
|
+
**kwargs,
|
|
3556
|
+
)
|
|
3557
|
+
)
|
|
3558
|
+
return GraphRunStream(graph_iter, mux)
|
|
3559
|
+
|
|
3560
|
+
@beta(message="The v3 streaming protocol on Pregel is experimental.")
|
|
3561
|
+
async def _apregel_stream_v3(
|
|
3562
|
+
self,
|
|
3563
|
+
input: InputT | Command | None,
|
|
3564
|
+
config: RunnableConfig | None = None,
|
|
3565
|
+
*,
|
|
3566
|
+
interrupt_before: All | Sequence[str] | None = None,
|
|
3567
|
+
interrupt_after: All | Sequence[str] | None = None,
|
|
3568
|
+
control: RunControl | None = None,
|
|
3569
|
+
transformers: Sequence[Callable[[tuple[str, ...]], Any]] | None = None,
|
|
3570
|
+
**kwargs: Any,
|
|
3571
|
+
) -> AsyncGraphRunStream:
|
|
3572
|
+
"""Internal v3 async streaming implementation. Public entry: astream_events(version='v3').
|
|
3573
|
+
|
|
3574
|
+
Extra keyword arguments are forwarded to the underlying ``astream(...)``
|
|
3575
|
+
call. The dispatcher in ``astream_events`` rejects ``stream_mode`` and
|
|
3576
|
+
``subgraphs`` since v3 owns them (``stream_mode`` is derived from the
|
|
3577
|
+
transformer mux; ``subgraphs`` is always True so nested namespaces
|
|
3578
|
+
flow through scoped muxes).
|
|
3579
|
+
|
|
3580
|
+
!!! warning
|
|
3581
|
+
|
|
3582
|
+
The v3 streaming protocol is experimental and may change.
|
|
3583
|
+
"""
|
|
3584
|
+
parent_ns = _resolve_parent_ns(self.config, config)
|
|
3585
|
+
compiled_factories = _normalize_stream_transformer_factories(
|
|
3586
|
+
self.stream_transformers
|
|
3587
|
+
)
|
|
3588
|
+
extra_factories = _normalize_stream_transformer_factories(transformers)
|
|
3589
|
+
mux = StreamMux(
|
|
3590
|
+
factories=[
|
|
3591
|
+
ValuesTransformer,
|
|
3592
|
+
MessagesTransformer,
|
|
3593
|
+
LifecycleTransformer,
|
|
3594
|
+
SubgraphTransformer,
|
|
3595
|
+
*compiled_factories,
|
|
3596
|
+
*extra_factories,
|
|
3597
|
+
],
|
|
3598
|
+
scope=parent_ns,
|
|
3599
|
+
is_async=True,
|
|
3600
|
+
)
|
|
3601
|
+
graph_aiter = self.astream(
|
|
3602
|
+
input,
|
|
3603
|
+
patch_configurable(config, {CONFIG_KEY_STREAM_MESSAGES_V2: True}),
|
|
3604
|
+
stream_mode=_collect_stream_modes(mux),
|
|
3605
|
+
subgraphs=True,
|
|
3606
|
+
version="v2",
|
|
3607
|
+
interrupt_before=interrupt_before,
|
|
3608
|
+
interrupt_after=interrupt_after,
|
|
3609
|
+
control=control,
|
|
3610
|
+
**kwargs,
|
|
3611
|
+
).__aiter__()
|
|
3612
|
+
return AsyncGraphRunStream(graph_aiter, mux)
|
|
3613
|
+
|
|
3614
|
+
@overload
|
|
3615
|
+
def stream_events(
|
|
3616
|
+
self,
|
|
3617
|
+
input: InputT | Command | None,
|
|
3618
|
+
config: RunnableConfig | None = None,
|
|
3619
|
+
*,
|
|
3620
|
+
version: Literal["v1", "v2"] = "v2",
|
|
3621
|
+
**kwargs: Any,
|
|
3622
|
+
) -> Iterator[StreamEvent]: ...
|
|
3623
|
+
|
|
3624
|
+
@overload
|
|
3625
|
+
def stream_events(
|
|
3626
|
+
self,
|
|
3627
|
+
input: InputT | Command | None,
|
|
3628
|
+
config: RunnableConfig | None = None,
|
|
3629
|
+
*,
|
|
3630
|
+
version: Literal["v3"],
|
|
3631
|
+
interrupt_before: All | Sequence[str] | None = None,
|
|
3632
|
+
interrupt_after: All | Sequence[str] | None = None,
|
|
3633
|
+
control: RunControl | None = None,
|
|
3634
|
+
transformers: Sequence[Callable[[tuple[str, ...]], Any]] | None = None,
|
|
3635
|
+
**kwargs: Any,
|
|
3636
|
+
) -> GraphRunStream: ...
|
|
3637
|
+
|
|
3638
|
+
def stream_events(
|
|
3639
|
+
self,
|
|
3640
|
+
input: InputT | Command | None,
|
|
3641
|
+
config: RunnableConfig | None = None,
|
|
3642
|
+
*,
|
|
3643
|
+
version: Literal["v1", "v2", "v3"] = "v2",
|
|
3644
|
+
interrupt_before: All | Sequence[str] | None = None,
|
|
3645
|
+
interrupt_after: All | Sequence[str] | None = None,
|
|
3646
|
+
control: RunControl | None = None,
|
|
3647
|
+
transformers: Sequence[Callable[[tuple[str, ...]], Any]] | None = None,
|
|
3648
|
+
**kwargs: Any,
|
|
3649
|
+
) -> Any:
|
|
3650
|
+
"""Stream events from this graph.
|
|
3651
|
+
|
|
3652
|
+
For `version="v1"` / `"v2"`, yields `StreamEvent` dicts (see
|
|
3653
|
+
`Runnable.stream_events`). For `version="v3"`, returns a
|
|
3654
|
+
`GraphRunStream` whose typed projections the caller drives by
|
|
3655
|
+
iterating — no background thread.
|
|
3656
|
+
|
|
3657
|
+
!!! warning
|
|
3658
|
+
|
|
3659
|
+
The `version="v3"` API is experimental and may change.
|
|
3660
|
+
|
|
3661
|
+
Builds a `StreamMux` from the built-in transformers, this
|
|
3662
|
+
graph's compile-time `stream_transformers`, and any additional
|
|
3663
|
+
`transformers=` supplied at the call site. `run.output`,
|
|
3664
|
+
`run.interrupted`, and `run.interrupts` work regardless of
|
|
3665
|
+
which transformers are registered.
|
|
3666
|
+
|
|
3667
|
+
Note:
|
|
3668
|
+
Nesting v1 `stream(stream_mode="messages")` inside a node
|
|
3669
|
+
of a `stream_events(version="v3")` run is not fully
|
|
3670
|
+
supported. The outer v3 messages handler reroutes
|
|
3671
|
+
`BaseChatModel.invoke` through the v2 event protocol, so
|
|
3672
|
+
the inner v1 handler does not see `on_llm_new_token`
|
|
3673
|
+
chunks. The inner stream still yields a finalized message
|
|
3674
|
+
via `on_llm_end`. Use `stream_events(version="v3")` for the
|
|
3675
|
+
inner graph as well, or call `chat_model.stream(...)`
|
|
3676
|
+
explicitly, to get token-level streaming.
|
|
3677
|
+
|
|
3678
|
+
Args:
|
|
3679
|
+
input: Graph input.
|
|
3680
|
+
config: Optional runnable config.
|
|
3681
|
+
version: Streaming-event schema version. `"v3"` selects the
|
|
3682
|
+
content-block-centric streaming protocol.
|
|
3683
|
+
interrupt_before: Nodes to interrupt before, if any. Only
|
|
3684
|
+
used for `version="v3"`.
|
|
3685
|
+
interrupt_after: Nodes to interrupt after, if any. Only
|
|
3686
|
+
used for `version="v3"`.
|
|
3687
|
+
control: Optional run control used to request cooperative
|
|
3688
|
+
drain. Only used for `version="v3"`.
|
|
3689
|
+
transformers: Extra transformer classes or configured
|
|
3690
|
+
factories appended after compile-time
|
|
3691
|
+
`stream_transformers`. Factories are called as
|
|
3692
|
+
`factory(scope)` so they can propagate to subgraph
|
|
3693
|
+
scopes. Only used for `version="v3"`.
|
|
3694
|
+
**kwargs: For `version="v1"`/`"v2"`, forwarded to
|
|
3695
|
+
`Runnable.stream_events`. For `version="v3"`, forwarded
|
|
3696
|
+
to the underlying `stream(...)` call (e.g. `context`,
|
|
3697
|
+
`durability`, `output_keys`, `print_mode`, `debug`).
|
|
3698
|
+
`stream_mode` and `subgraphs` are not accepted under
|
|
3699
|
+
`version="v3"` and raise `TypeError` if supplied; v3
|
|
3700
|
+
owns them.
|
|
3701
|
+
|
|
3702
|
+
Returns:
|
|
3703
|
+
For `version="v3"`, a `GraphRunStream` the caller iterates
|
|
3704
|
+
to drive the run. Otherwise an `Iterator[StreamEvent]`.
|
|
3705
|
+
"""
|
|
3706
|
+
if version == "v3":
|
|
3707
|
+
_reject_v3_invariant_kwargs(kwargs)
|
|
3708
|
+
return self._pregel_stream_v3(
|
|
3709
|
+
input,
|
|
3710
|
+
config,
|
|
3711
|
+
interrupt_before=interrupt_before,
|
|
3712
|
+
interrupt_after=interrupt_after,
|
|
3713
|
+
control=control,
|
|
3714
|
+
transformers=transformers,
|
|
3715
|
+
**kwargs,
|
|
3716
|
+
)
|
|
3717
|
+
return super().stream_events(input, config, version=version, **kwargs)
|
|
3718
|
+
|
|
3719
|
+
@overload
|
|
3720
|
+
def astream_events(
|
|
3721
|
+
self,
|
|
3722
|
+
input: InputT | Command | None,
|
|
3723
|
+
config: RunnableConfig | None = None,
|
|
3724
|
+
*,
|
|
3725
|
+
version: Literal["v1", "v2"] = "v2",
|
|
3726
|
+
**kwargs: Any,
|
|
3727
|
+
) -> AsyncIterator[StreamEvent]: ...
|
|
3728
|
+
|
|
3729
|
+
@overload
|
|
3730
|
+
def astream_events(
|
|
3731
|
+
self,
|
|
3732
|
+
input: InputT | Command | None,
|
|
3733
|
+
config: RunnableConfig | None = None,
|
|
3734
|
+
*,
|
|
3735
|
+
version: Literal["v3"],
|
|
3736
|
+
interrupt_before: All | Sequence[str] | None = None,
|
|
3737
|
+
interrupt_after: All | Sequence[str] | None = None,
|
|
3738
|
+
control: RunControl | None = None,
|
|
3739
|
+
transformers: Sequence[Callable[[tuple[str, ...]], Any]] | None = None,
|
|
3740
|
+
**kwargs: Any,
|
|
3741
|
+
) -> Awaitable[AsyncGraphRunStream]: ...
|
|
3742
|
+
|
|
3743
|
+
def astream_events(
|
|
3744
|
+
self,
|
|
3745
|
+
input: InputT | Command | None,
|
|
3746
|
+
config: RunnableConfig | None = None,
|
|
3747
|
+
*,
|
|
3748
|
+
version: Literal["v1", "v2", "v3"] = "v2",
|
|
3749
|
+
interrupt_before: All | Sequence[str] | None = None,
|
|
3750
|
+
interrupt_after: All | Sequence[str] | None = None,
|
|
3751
|
+
control: RunControl | None = None,
|
|
3752
|
+
transformers: Sequence[Callable[[tuple[str, ...]], Any]] | None = None,
|
|
3753
|
+
**kwargs: Any,
|
|
3754
|
+
) -> AsyncIterator[StreamEvent] | Awaitable[Any]:
|
|
3755
|
+
"""Async variant of `stream_events`.
|
|
3756
|
+
|
|
3757
|
+
For `version="v3"`, returns an `AsyncGraphRunStream` whose
|
|
3758
|
+
projections can be awaited concurrently; each subscribed cursor
|
|
3759
|
+
drives the pump when its buffer is empty. The same nesting
|
|
3760
|
+
limitation as the sync path applies — see `stream_events` for
|
|
3761
|
+
details.
|
|
3762
|
+
|
|
3763
|
+
!!! warning
|
|
3764
|
+
|
|
3765
|
+
The `version="v3"` API is experimental and may change.
|
|
3766
|
+
|
|
3767
|
+
See `stream_events` for full argument and return documentation.
|
|
3768
|
+
"""
|
|
3769
|
+
if version == "v3":
|
|
3770
|
+
_reject_v3_invariant_kwargs(kwargs)
|
|
3771
|
+
return self._apregel_stream_v3(
|
|
3772
|
+
input,
|
|
3773
|
+
config,
|
|
3774
|
+
interrupt_before=interrupt_before,
|
|
3775
|
+
interrupt_after=interrupt_after,
|
|
3776
|
+
control=control,
|
|
3777
|
+
transformers=transformers,
|
|
3778
|
+
**kwargs,
|
|
3779
|
+
)
|
|
3780
|
+
return super().astream_events(input, config, version=version, **kwargs)
|
|
3781
|
+
|
|
3782
|
+
@overload
|
|
3783
|
+
def invoke(
|
|
3784
|
+
self,
|
|
3785
|
+
input: InputT | Command | None,
|
|
3786
|
+
config: RunnableConfig | None = None,
|
|
3787
|
+
*,
|
|
3788
|
+
context: ContextT | None = None,
|
|
3789
|
+
stream_mode: Literal["values"] = ...,
|
|
3790
|
+
print_mode: StreamMode | Sequence[StreamMode] = (),
|
|
3791
|
+
output_keys: str | Sequence[str] | None = None,
|
|
3792
|
+
interrupt_before: All | Sequence[str] | None = None,
|
|
3793
|
+
interrupt_after: All | Sequence[str] | None = None,
|
|
3794
|
+
durability: Durability | None = None,
|
|
3795
|
+
control: RunControl | None = None,
|
|
3796
|
+
version: Literal["v2"],
|
|
3797
|
+
**kwargs: Any,
|
|
3798
|
+
) -> GraphOutput[OutputT]: ...
|
|
3799
|
+
|
|
3800
|
+
@overload
|
|
3801
|
+
def invoke(
|
|
3802
|
+
self,
|
|
3803
|
+
input: InputT | Command | None,
|
|
3804
|
+
config: RunnableConfig | None = None,
|
|
3805
|
+
*,
|
|
3806
|
+
context: ContextT | None = None,
|
|
3807
|
+
stream_mode: StreamMode,
|
|
3808
|
+
print_mode: StreamMode | Sequence[StreamMode] = (),
|
|
3809
|
+
output_keys: str | Sequence[str] | None = None,
|
|
3810
|
+
interrupt_before: All | Sequence[str] | None = None,
|
|
3811
|
+
interrupt_after: All | Sequence[str] | None = None,
|
|
3812
|
+
durability: Durability | None = None,
|
|
3813
|
+
control: RunControl | None = None,
|
|
3814
|
+
version: Literal["v2"],
|
|
3815
|
+
**kwargs: Any,
|
|
3816
|
+
) -> list[StreamPart[StateT, OutputT]]: ...
|
|
3817
|
+
|
|
3818
|
+
@overload
|
|
3819
|
+
def invoke(
|
|
3820
|
+
self,
|
|
3821
|
+
input: InputT | Command | None,
|
|
3822
|
+
config: RunnableConfig | None = None,
|
|
3823
|
+
*,
|
|
3824
|
+
context: ContextT | None = None,
|
|
3825
|
+
stream_mode: StreamMode = "values",
|
|
3826
|
+
print_mode: StreamMode | Sequence[StreamMode] = (),
|
|
3827
|
+
output_keys: str | Sequence[str] | None = None,
|
|
3828
|
+
interrupt_before: All | Sequence[str] | None = None,
|
|
3829
|
+
interrupt_after: All | Sequence[str] | None = None,
|
|
3830
|
+
durability: Durability | None = None,
|
|
3831
|
+
control: RunControl | None = None,
|
|
3832
|
+
version: Literal["v1"] = ...,
|
|
3833
|
+
**kwargs: Any,
|
|
3834
|
+
) -> dict[str, Any] | Any: ...
|
|
3835
|
+
|
|
3836
|
+
def invoke(
|
|
3837
|
+
self,
|
|
3838
|
+
input: InputT | Command | None,
|
|
3839
|
+
config: RunnableConfig | None = None,
|
|
3840
|
+
*,
|
|
3841
|
+
context: ContextT | None = None,
|
|
3842
|
+
stream_mode: StreamMode = "values",
|
|
3843
|
+
print_mode: StreamMode | Sequence[StreamMode] = (),
|
|
3844
|
+
output_keys: str | Sequence[str] | None = None,
|
|
3845
|
+
interrupt_before: All | Sequence[str] | None = None,
|
|
3846
|
+
interrupt_after: All | Sequence[str] | None = None,
|
|
3847
|
+
durability: Durability | None = None,
|
|
3848
|
+
control: RunControl | None = None,
|
|
3849
|
+
version: Literal["v1", "v2"] = "v1",
|
|
3850
|
+
**kwargs: Any,
|
|
3851
|
+
) -> dict[str, Any] | Any:
|
|
3852
|
+
"""Run the graph with a single input and config.
|
|
3853
|
+
|
|
3854
|
+
Args:
|
|
3855
|
+
input: The input data for the graph. It can be a dictionary or any other type.
|
|
3856
|
+
config: The configuration for the graph run.
|
|
3857
|
+
context: The static context to use for the run.
|
|
3858
|
+
!!! version-added "Added in version 0.6.0"
|
|
3859
|
+
stream_mode: The stream mode for the graph run.
|
|
3860
|
+
print_mode: Accepts the same values as `stream_mode`, but only prints the output to the console, for debugging purposes.
|
|
3861
|
+
|
|
3862
|
+
Does not affect the output of the graph in any way.
|
|
3863
|
+
output_keys: The output keys to retrieve from the graph run.
|
|
3864
|
+
interrupt_before: The nodes to interrupt the graph run before.
|
|
3865
|
+
interrupt_after: The nodes to interrupt the graph run after.
|
|
3866
|
+
durability: The durability mode for the graph execution, defaults to `"async"`.
|
|
3867
|
+
|
|
3868
|
+
Options are:
|
|
3869
|
+
|
|
3870
|
+
- `"sync"`: Changes are persisted synchronously before the next step starts.
|
|
3871
|
+
- `"async"`: Changes are persisted asynchronously while the next step executes.
|
|
3872
|
+
- `"exit"`: Changes are persisted only when the graph exits.
|
|
3873
|
+
control: Optional run control used to request cooperative drain.
|
|
3874
|
+
version: The streaming format version. `"v1"` (default) returns the
|
|
3875
|
+
traditional format, `"v2"` returns `StreamPart` typed dicts when
|
|
3876
|
+
`stream_mode` is not `"values"`.
|
|
3877
|
+
**kwargs: Additional keyword arguments to pass to the graph run.
|
|
3878
|
+
|
|
3879
|
+
Returns:
|
|
3880
|
+
The output of the graph run. If `stream_mode` is `"values"`, it returns the latest output.
|
|
3881
|
+
If `stream_mode` is not `"values"`, it returns a list of output chunks.
|
|
3882
|
+
"""
|
|
3883
|
+
output_keys = output_keys if output_keys is not None else self.output_channels
|
|
3884
|
+
|
|
3885
|
+
latest: dict[str, Any] | Any = None
|
|
3886
|
+
chunks: list[dict[str, Any] | Any] = []
|
|
3887
|
+
interrupts: list[Interrupt] = []
|
|
3888
|
+
|
|
3889
|
+
if version == "v2":
|
|
3890
|
+
# v2: values stream parts carry interrupts directly
|
|
3891
|
+
for chunk in self.stream(
|
|
3892
|
+
input,
|
|
3893
|
+
config,
|
|
3894
|
+
context=context,
|
|
3895
|
+
stream_mode="values" if stream_mode == "values" else stream_mode,
|
|
3896
|
+
print_mode=print_mode,
|
|
3897
|
+
output_keys=output_keys,
|
|
3898
|
+
interrupt_before=interrupt_before,
|
|
3899
|
+
interrupt_after=interrupt_after,
|
|
3900
|
+
durability=durability,
|
|
3901
|
+
control=control,
|
|
3902
|
+
version=version,
|
|
3903
|
+
**kwargs,
|
|
3904
|
+
):
|
|
3905
|
+
if stream_mode == "values":
|
|
3906
|
+
latest = chunk["data"]
|
|
3907
|
+
if chunk_ints := chunk.get("interrupts", ()):
|
|
3908
|
+
interrupts.extend(chunk_ints) # type: ignore[arg-type]
|
|
3909
|
+
else:
|
|
3910
|
+
chunks.append(chunk)
|
|
3911
|
+
else:
|
|
3912
|
+
# v1: collect interrupts from updates stream
|
|
3913
|
+
for chunk in self.stream(
|
|
3914
|
+
input,
|
|
3915
|
+
config,
|
|
3916
|
+
context=context,
|
|
3917
|
+
stream_mode=(
|
|
3918
|
+
["updates", "values"] if stream_mode == "values" else stream_mode
|
|
3919
|
+
),
|
|
3920
|
+
print_mode=print_mode,
|
|
3921
|
+
output_keys=output_keys,
|
|
3922
|
+
interrupt_before=interrupt_before,
|
|
3923
|
+
interrupt_after=interrupt_after,
|
|
3924
|
+
durability=durability,
|
|
3925
|
+
control=control,
|
|
3926
|
+
**kwargs,
|
|
3927
|
+
):
|
|
3928
|
+
if stream_mode == "values":
|
|
3929
|
+
if len(chunk) == 2:
|
|
3930
|
+
mode, payload = cast(tuple[StreamMode, Any], chunk)
|
|
3931
|
+
else:
|
|
3932
|
+
_, mode, payload = cast(
|
|
3933
|
+
tuple[tuple[str, ...], StreamMode, Any], chunk
|
|
3934
|
+
)
|
|
3935
|
+
if (
|
|
3936
|
+
mode == "updates"
|
|
3937
|
+
and isinstance(payload, dict)
|
|
3938
|
+
and (ints := payload.get(INTERRUPT)) is not None
|
|
3939
|
+
):
|
|
3940
|
+
interrupts.extend(ints)
|
|
3941
|
+
elif mode == "values":
|
|
3942
|
+
latest = payload
|
|
3943
|
+
else:
|
|
3944
|
+
chunks.append(chunk)
|
|
3945
|
+
|
|
3946
|
+
if stream_mode == "values":
|
|
3947
|
+
if version == "v2":
|
|
3948
|
+
return GraphOutput(value=latest, interrupts=tuple(interrupts))
|
|
3949
|
+
if interrupts:
|
|
3950
|
+
return (
|
|
3951
|
+
{**latest, INTERRUPT: interrupts}
|
|
3952
|
+
if isinstance(latest, dict)
|
|
3953
|
+
else {INTERRUPT: interrupts}
|
|
3954
|
+
)
|
|
3955
|
+
return latest
|
|
3956
|
+
else:
|
|
3957
|
+
return chunks
|
|
3958
|
+
|
|
3959
|
+
@overload
|
|
3960
|
+
async def ainvoke(
|
|
3961
|
+
self,
|
|
3962
|
+
input: InputT | Command | None,
|
|
3963
|
+
config: RunnableConfig | None = None,
|
|
3964
|
+
*,
|
|
3965
|
+
context: ContextT | None = None,
|
|
3966
|
+
stream_mode: Literal["values"] = ...,
|
|
3967
|
+
print_mode: StreamMode | Sequence[StreamMode] = (),
|
|
3968
|
+
output_keys: str | Sequence[str] | None = None,
|
|
3969
|
+
interrupt_before: All | Sequence[str] | None = None,
|
|
3970
|
+
interrupt_after: All | Sequence[str] | None = None,
|
|
3971
|
+
durability: Durability | None = None,
|
|
3972
|
+
control: RunControl | None = None,
|
|
3973
|
+
version: Literal["v2"],
|
|
3974
|
+
**kwargs: Any,
|
|
3975
|
+
) -> GraphOutput[OutputT]: ...
|
|
3976
|
+
|
|
3977
|
+
@overload
|
|
3978
|
+
async def ainvoke(
|
|
3979
|
+
self,
|
|
3980
|
+
input: InputT | Command | None,
|
|
3981
|
+
config: RunnableConfig | None = None,
|
|
3982
|
+
*,
|
|
3983
|
+
context: ContextT | None = None,
|
|
3984
|
+
stream_mode: StreamMode,
|
|
3985
|
+
print_mode: StreamMode | Sequence[StreamMode] = (),
|
|
3986
|
+
output_keys: str | Sequence[str] | None = None,
|
|
3987
|
+
interrupt_before: All | Sequence[str] | None = None,
|
|
3988
|
+
interrupt_after: All | Sequence[str] | None = None,
|
|
3989
|
+
durability: Durability | None = None,
|
|
3990
|
+
control: RunControl | None = None,
|
|
3991
|
+
version: Literal["v2"],
|
|
3992
|
+
**kwargs: Any,
|
|
3993
|
+
) -> list[StreamPart[StateT, OutputT]]: ...
|
|
3994
|
+
|
|
3995
|
+
@overload
|
|
3996
|
+
async def ainvoke(
|
|
3997
|
+
self,
|
|
3998
|
+
input: InputT | Command | None,
|
|
3999
|
+
config: RunnableConfig | None = None,
|
|
4000
|
+
*,
|
|
4001
|
+
context: ContextT | None = None,
|
|
4002
|
+
stream_mode: StreamMode = "values",
|
|
4003
|
+
print_mode: StreamMode | Sequence[StreamMode] = (),
|
|
4004
|
+
output_keys: str | Sequence[str] | None = None,
|
|
4005
|
+
interrupt_before: All | Sequence[str] | None = None,
|
|
4006
|
+
interrupt_after: All | Sequence[str] | None = None,
|
|
4007
|
+
durability: Durability | None = None,
|
|
4008
|
+
control: RunControl | None = None,
|
|
4009
|
+
version: Literal["v1"] = ...,
|
|
4010
|
+
**kwargs: Any,
|
|
4011
|
+
) -> dict[str, Any] | Any: ...
|
|
4012
|
+
|
|
4013
|
+
async def ainvoke(
|
|
4014
|
+
self,
|
|
4015
|
+
input: InputT | Command | None,
|
|
4016
|
+
config: RunnableConfig | None = None,
|
|
4017
|
+
*,
|
|
4018
|
+
context: ContextT | None = None,
|
|
4019
|
+
stream_mode: StreamMode = "values",
|
|
4020
|
+
print_mode: StreamMode | Sequence[StreamMode] = (),
|
|
4021
|
+
output_keys: str | Sequence[str] | None = None,
|
|
4022
|
+
interrupt_before: All | Sequence[str] | None = None,
|
|
4023
|
+
interrupt_after: All | Sequence[str] | None = None,
|
|
4024
|
+
durability: Durability | None = None,
|
|
4025
|
+
control: RunControl | None = None,
|
|
4026
|
+
version: Literal["v1", "v2"] = "v1",
|
|
4027
|
+
**kwargs: Any,
|
|
4028
|
+
) -> dict[str, Any] | Any:
|
|
4029
|
+
"""Asynchronously run the graph with a single input and config.
|
|
4030
|
+
|
|
4031
|
+
Args:
|
|
4032
|
+
input: The input data for the graph. It can be a dictionary or any other type.
|
|
4033
|
+
config: The configuration for the graph run.
|
|
4034
|
+
context: The static context to use for the run.
|
|
4035
|
+
!!! version-added "Added in version 0.6.0"
|
|
4036
|
+
stream_mode: The stream mode for the graph run.
|
|
4037
|
+
print_mode: Accepts the same values as `stream_mode`, but only prints the output to the console, for debugging purposes.
|
|
4038
|
+
|
|
4039
|
+
Does not affect the output of the graph in any way.
|
|
4040
|
+
output_keys: The output keys to retrieve from the graph run.
|
|
4041
|
+
interrupt_before: The nodes to interrupt the graph run before.
|
|
4042
|
+
interrupt_after: The nodes to interrupt the graph run after.
|
|
4043
|
+
durability: The durability mode for the graph execution, defaults to `"async"`.
|
|
4044
|
+
|
|
4045
|
+
Options are:
|
|
4046
|
+
|
|
4047
|
+
- `"sync"`: Changes are persisted synchronously before the next step starts.
|
|
4048
|
+
- `"async"`: Changes are persisted asynchronously while the next step executes.
|
|
4049
|
+
- `"exit"`: Changes are persisted only when the graph exits.
|
|
4050
|
+
control: Optional run control used to request cooperative drain.
|
|
4051
|
+
version: The streaming format version. `"v1"` (default) returns the
|
|
4052
|
+
traditional format, `"v2"` returns `StreamPart` typed dicts when
|
|
4053
|
+
`stream_mode` is not `"values"`.
|
|
4054
|
+
**kwargs: Additional keyword arguments to pass to the graph run.
|
|
4055
|
+
|
|
4056
|
+
Returns:
|
|
4057
|
+
The output of the graph run. If `stream_mode` is `"values"`, it returns the latest output.
|
|
4058
|
+
If `stream_mode` is not `"values"`, it returns a list of output chunks.
|
|
4059
|
+
"""
|
|
4060
|
+
output_keys = output_keys if output_keys is not None else self.output_channels
|
|
4061
|
+
|
|
4062
|
+
latest: dict[str, Any] | Any = None
|
|
4063
|
+
chunks: list[dict[str, Any] | Any] = []
|
|
4064
|
+
interrupts: list[Interrupt] = []
|
|
4065
|
+
|
|
4066
|
+
if version == "v2":
|
|
4067
|
+
# v2: values stream parts carry interrupts directly
|
|
4068
|
+
async for chunk in self.astream(
|
|
4069
|
+
input,
|
|
4070
|
+
config,
|
|
4071
|
+
context=context,
|
|
4072
|
+
stream_mode="values" if stream_mode == "values" else stream_mode,
|
|
4073
|
+
print_mode=print_mode,
|
|
4074
|
+
output_keys=output_keys,
|
|
4075
|
+
interrupt_before=interrupt_before,
|
|
4076
|
+
interrupt_after=interrupt_after,
|
|
4077
|
+
durability=durability,
|
|
4078
|
+
control=control,
|
|
4079
|
+
version=version,
|
|
4080
|
+
**kwargs,
|
|
4081
|
+
):
|
|
4082
|
+
if stream_mode == "values":
|
|
4083
|
+
latest = chunk["data"]
|
|
4084
|
+
if chunk_ints := chunk.get("interrupts", ()):
|
|
4085
|
+
interrupts.extend(chunk_ints) # type: ignore[arg-type]
|
|
4086
|
+
else:
|
|
4087
|
+
chunks.append(chunk)
|
|
4088
|
+
else:
|
|
4089
|
+
# v1: collect interrupts from updates stream
|
|
4090
|
+
async for chunk in self.astream(
|
|
4091
|
+
input,
|
|
4092
|
+
config,
|
|
4093
|
+
context=context,
|
|
4094
|
+
stream_mode=(
|
|
4095
|
+
["updates", "values"] if stream_mode == "values" else stream_mode
|
|
4096
|
+
),
|
|
4097
|
+
print_mode=print_mode,
|
|
4098
|
+
output_keys=output_keys,
|
|
4099
|
+
interrupt_before=interrupt_before,
|
|
4100
|
+
interrupt_after=interrupt_after,
|
|
4101
|
+
durability=durability,
|
|
4102
|
+
control=control,
|
|
4103
|
+
**kwargs,
|
|
4104
|
+
):
|
|
4105
|
+
if stream_mode == "values":
|
|
4106
|
+
if len(chunk) == 2:
|
|
4107
|
+
mode, payload = cast(tuple[StreamMode, Any], chunk)
|
|
4108
|
+
else:
|
|
4109
|
+
_, mode, payload = cast(
|
|
4110
|
+
tuple[tuple[str, ...], StreamMode, Any], chunk
|
|
4111
|
+
)
|
|
4112
|
+
if (
|
|
4113
|
+
mode == "updates"
|
|
4114
|
+
and isinstance(payload, dict)
|
|
4115
|
+
and (ints := payload.get(INTERRUPT)) is not None
|
|
4116
|
+
):
|
|
4117
|
+
interrupts.extend(ints)
|
|
4118
|
+
elif mode == "values":
|
|
4119
|
+
latest = payload
|
|
4120
|
+
else:
|
|
4121
|
+
chunks.append(chunk)
|
|
4122
|
+
|
|
4123
|
+
if stream_mode == "values":
|
|
4124
|
+
if version == "v2":
|
|
4125
|
+
return GraphOutput(value=latest, interrupts=tuple(interrupts))
|
|
4126
|
+
if interrupts:
|
|
4127
|
+
return (
|
|
4128
|
+
{**latest, INTERRUPT: interrupts}
|
|
4129
|
+
if isinstance(latest, dict)
|
|
4130
|
+
else {INTERRUPT: interrupts}
|
|
4131
|
+
)
|
|
4132
|
+
return latest
|
|
4133
|
+
else:
|
|
4134
|
+
return chunks
|
|
4135
|
+
|
|
4136
|
+
def clear_cache(self, nodes: Sequence[str] | None = None) -> None:
|
|
4137
|
+
"""Clear the cache for the given nodes."""
|
|
4138
|
+
if not self.cache:
|
|
4139
|
+
raise ValueError("No cache is set for this graph. Cannot clear cache.")
|
|
4140
|
+
nodes = nodes or self.nodes.keys()
|
|
4141
|
+
# collect namespaces to clear
|
|
4142
|
+
namespaces: list[tuple[str, ...]] = []
|
|
4143
|
+
for node in nodes:
|
|
4144
|
+
if node in self.nodes:
|
|
4145
|
+
namespaces.append(
|
|
4146
|
+
(
|
|
4147
|
+
CACHE_NS_WRITES,
|
|
4148
|
+
(identifier(self.nodes[node]) or "__dynamic__"),
|
|
4149
|
+
node,
|
|
4150
|
+
),
|
|
4151
|
+
)
|
|
4152
|
+
# clear cache
|
|
4153
|
+
self.cache.clear(namespaces)
|
|
4154
|
+
|
|
4155
|
+
async def aclear_cache(self, nodes: Sequence[str] | None = None) -> None:
|
|
4156
|
+
"""Asynchronously clear the cache for the given nodes."""
|
|
4157
|
+
if not self.cache:
|
|
4158
|
+
raise ValueError("No cache is set for this graph. Cannot clear cache.")
|
|
4159
|
+
nodes = nodes or self.nodes.keys()
|
|
4160
|
+
# collect namespaces to clear
|
|
4161
|
+
namespaces: list[tuple[str, ...]] = []
|
|
4162
|
+
for node in nodes:
|
|
4163
|
+
if node in self.nodes:
|
|
4164
|
+
namespaces.append(
|
|
4165
|
+
(
|
|
4166
|
+
CACHE_NS_WRITES,
|
|
4167
|
+
(identifier(self.nodes[node]) or "__dynamic__"),
|
|
4168
|
+
node,
|
|
4169
|
+
),
|
|
4170
|
+
)
|
|
4171
|
+
# clear cache
|
|
4172
|
+
await self.cache.aclear(namespaces)
|
|
4173
|
+
|
|
4174
|
+
|
|
4175
|
+
def _trigger_to_nodes(nodes: dict[str, PregelNode]) -> Mapping[str, Sequence[str]]:
|
|
4176
|
+
"""Index from a trigger to nodes that depend on it."""
|
|
4177
|
+
trigger_to_nodes: defaultdict[str, list[str]] = defaultdict(list)
|
|
4178
|
+
for name, node in nodes.items():
|
|
4179
|
+
for trigger in node.triggers:
|
|
4180
|
+
trigger_to_nodes[trigger].append(name)
|
|
4181
|
+
return dict(trigger_to_nodes)
|
|
4182
|
+
|
|
4183
|
+
|
|
4184
|
+
def _output(
|
|
4185
|
+
stream_mode: StreamMode | Sequence[StreamMode],
|
|
4186
|
+
print_mode: StreamMode | Sequence[StreamMode],
|
|
4187
|
+
stream_subgraphs: bool,
|
|
4188
|
+
getter: Callable[[], tuple[tuple[str, ...], str, Any]],
|
|
4189
|
+
empty_exc: type[Exception],
|
|
4190
|
+
version: Literal["v1", "v2"] = "v1",
|
|
4191
|
+
output_mapper: Callable[[Any], Any] | None = None,
|
|
4192
|
+
state_mapper: Callable[[Any], Any] | None = None,
|
|
4193
|
+
) -> Iterator:
|
|
4194
|
+
while True:
|
|
4195
|
+
try:
|
|
4196
|
+
ns, mode, payload = getter()
|
|
4197
|
+
except empty_exc:
|
|
4198
|
+
break
|
|
4199
|
+
if mode in print_mode:
|
|
4200
|
+
if stream_subgraphs and ns:
|
|
4201
|
+
print(
|
|
4202
|
+
" ".join(
|
|
4203
|
+
(
|
|
4204
|
+
get_bolded_text(f"[{mode}]"),
|
|
4205
|
+
get_colored_text(f"[graph={ns}]", color="yellow"),
|
|
4206
|
+
repr(payload),
|
|
4207
|
+
)
|
|
4208
|
+
)
|
|
4209
|
+
)
|
|
4210
|
+
else:
|
|
4211
|
+
print(
|
|
4212
|
+
" ".join(
|
|
4213
|
+
(
|
|
4214
|
+
get_bolded_text(f"[{mode}]"),
|
|
4215
|
+
repr(payload),
|
|
4216
|
+
)
|
|
4217
|
+
)
|
|
4218
|
+
)
|
|
4219
|
+
if mode in stream_mode:
|
|
4220
|
+
if version == "v2":
|
|
4221
|
+
if mode == "values":
|
|
4222
|
+
# pop __interrupt__ into typed field, coerce data
|
|
4223
|
+
ints: tuple[Interrupt, ...] = ()
|
|
4224
|
+
if isinstance(payload, dict):
|
|
4225
|
+
ints = payload.pop(INTERRUPT, ())
|
|
4226
|
+
if output_mapper:
|
|
4227
|
+
payload = output_mapper(payload)
|
|
4228
|
+
yield {"type": mode, "ns": ns, "data": payload, "interrupts": ints}
|
|
4229
|
+
elif mode in ("checkpoints", "debug"):
|
|
4230
|
+
# coerce state values in checkpoint/debug payloads
|
|
4231
|
+
if state_mapper:
|
|
4232
|
+
_coerce_checkpoint_values(payload, state_mapper)
|
|
4233
|
+
yield {"type": mode, "ns": ns, "data": payload}
|
|
4234
|
+
else:
|
|
4235
|
+
yield {"type": mode, "ns": ns, "data": payload}
|
|
4236
|
+
elif stream_subgraphs and isinstance(stream_mode, list):
|
|
4237
|
+
yield (ns, mode, payload)
|
|
4238
|
+
elif isinstance(stream_mode, list):
|
|
4239
|
+
yield (mode, payload)
|
|
4240
|
+
elif stream_subgraphs:
|
|
4241
|
+
yield (ns, payload)
|
|
4242
|
+
else:
|
|
4243
|
+
yield payload
|
|
4244
|
+
|
|
4245
|
+
|
|
4246
|
+
def _coerce_checkpoint_values(payload: Any, mapper: Callable[[Any], Any]) -> None:
|
|
4247
|
+
"""Coerce `values` dicts inside checkpoint or debug payloads in-place.
|
|
4248
|
+
|
|
4249
|
+
Skips the initial checkpoint (where next contains ``__start__``) because
|
|
4250
|
+
not all channels are populated yet and coercion would fail.
|
|
4251
|
+
"""
|
|
4252
|
+
_START = "__start__"
|
|
4253
|
+
# debug wrapper: {"type": "checkpoint", "payload": {"values": dict, ...}}
|
|
4254
|
+
if (
|
|
4255
|
+
isinstance(payload, dict)
|
|
4256
|
+
and payload.get("type") == "checkpoint"
|
|
4257
|
+
and isinstance(payload.get("payload"), dict)
|
|
4258
|
+
and isinstance(payload["payload"].get("values"), dict)
|
|
4259
|
+
and _START not in payload["payload"].get("next", ())
|
|
4260
|
+
):
|
|
4261
|
+
payload["payload"]["values"] = mapper(payload["payload"]["values"])
|
|
4262
|
+
# direct checkpoint payload: {"values": dict, ...}
|
|
4263
|
+
elif (
|
|
4264
|
+
isinstance(payload, dict)
|
|
4265
|
+
and isinstance(payload.get("values"), dict)
|
|
4266
|
+
and _START not in payload.get("next", ())
|
|
4267
|
+
):
|
|
4268
|
+
payload["values"] = mapper(payload["values"])
|
|
4269
|
+
|
|
4270
|
+
|
|
4271
|
+
def _resolve_parent_ns(
|
|
4272
|
+
graph_config: RunnableConfig | None, call_config: RunnableConfig | None
|
|
4273
|
+
) -> tuple[str, ...]:
|
|
4274
|
+
"""Return the checkpoint namespace the caller is running under.
|
|
4275
|
+
|
|
4276
|
+
`stream_events(version="v3")` uses this to scope its native projections
|
|
4277
|
+
(`ValuesTransformer`, `MessagesTransformer`) to events emitted at
|
|
4278
|
+
the run's own level. A root call resolves to `()`; a call made
|
|
4279
|
+
from inside a node carries the outer graph's task namespace so the
|
|
4280
|
+
projection still matches its own root-level events.
|
|
4281
|
+
"""
|
|
4282
|
+
merged = ensure_config(graph_config, call_config)
|
|
4283
|
+
ns = merged.get(CONF, {}).get(CONFIG_KEY_CHECKPOINT_NS)
|
|
4284
|
+
if not ns:
|
|
4285
|
+
return ()
|
|
4286
|
+
return tuple(ns.split(NS_SEP))
|
|
4287
|
+
|
|
4288
|
+
|
|
4289
|
+
def _coerce_parent_runtime(value: Any) -> Runtime[Any]:
|
|
4290
|
+
"""Normalize the value stored under `CONFIG_KEY_RUNTIME` into a `Runtime`.
|
|
4291
|
+
|
|
4292
|
+
During a graph run this is always a `Runtime` the framework created and
|
|
4293
|
+
published for child tasks to inherit. Layers outside the run (for example a
|
|
4294
|
+
server's graph-factory path) may instead seed an object that only carries
|
|
4295
|
+
`context`/`store`. Adopt its `context` so context set at the graph level
|
|
4296
|
+
plumbs through (`merge` lets the run's own `context` take precedence when
|
|
4297
|
+
one is provided). `store` is resolved separately (passed to the graph
|
|
4298
|
+
directly), so it is not read off here. `merge` then combines this with the
|
|
4299
|
+
run's own runtime, including the framework's `control`.
|
|
4300
|
+
"""
|
|
4301
|
+
if isinstance(value, Runtime):
|
|
4302
|
+
return value
|
|
4303
|
+
return Runtime(context=getattr(value, "context", None))
|
|
4304
|
+
|
|
4305
|
+
|
|
4306
|
+
def _build_server_info(
|
|
4307
|
+
config: RunnableConfig, parent_runtime: Runtime[Any]
|
|
4308
|
+
) -> ServerInfo | None:
|
|
4309
|
+
"""Build ServerInfo from config configurable.
|
|
4310
|
+
|
|
4311
|
+
The server puts assistant_id/graph_id in config configurable and the
|
|
4312
|
+
authenticated user dict in configurable["coco_graph_auth_user"].
|
|
4313
|
+
"""
|
|
4314
|
+
configurable = config.get(CONF) or {}
|
|
4315
|
+
assistant_id = configurable.get("assistant_id")
|
|
4316
|
+
graph_id = configurable.get("graph_id")
|
|
4317
|
+
|
|
4318
|
+
# Read authenticated user from configurable (set by CocoGraph Server).
|
|
4319
|
+
# We prefer isinstance(BaseUser) but fall back to hasattr("identity")
|
|
4320
|
+
# because the server's ProxyUser provides `permissions` via __getattr__,
|
|
4321
|
+
# which Python's runtime_checkable Protocol check doesn't see.
|
|
4322
|
+
auth_user_data = configurable.get("coco_graph_auth_user")
|
|
4323
|
+
user: BaseUser | None = None
|
|
4324
|
+
if auth_user_data is not None:
|
|
4325
|
+
if isinstance(auth_user_data, BaseUser) or hasattr(auth_user_data, "identity"):
|
|
4326
|
+
user = cast(BaseUser, auth_user_data)
|
|
4327
|
+
|
|
4328
|
+
if assistant_id is not None or graph_id is not None or user is not None:
|
|
4329
|
+
return ServerInfo(
|
|
4330
|
+
assistant_id=str(assistant_id) if assistant_id else "",
|
|
4331
|
+
graph_id=str(graph_id) if graph_id else "",
|
|
4332
|
+
user=user,
|
|
4333
|
+
)
|
|
4334
|
+
return None
|
|
4335
|
+
|
|
4336
|
+
|
|
4337
|
+
def _coerce_context(
|
|
4338
|
+
context_schema: type[ContextT] | None, context: Any
|
|
4339
|
+
) -> ContextT | None:
|
|
4340
|
+
"""Coerce context input to the appropriate schema type.
|
|
4341
|
+
|
|
4342
|
+
If context is a dict and context_schema is a dataclass or pydantic model, we coerce.
|
|
4343
|
+
Else, we return the context as-is.
|
|
4344
|
+
|
|
4345
|
+
Args:
|
|
4346
|
+
context_schema: The schema type to coerce to (BaseModel, dataclass, or TypedDict)
|
|
4347
|
+
context: The context value to coerce
|
|
4348
|
+
|
|
4349
|
+
Returns:
|
|
4350
|
+
The coerced context value or None if context is None
|
|
4351
|
+
"""
|
|
4352
|
+
if context is None:
|
|
4353
|
+
return None
|
|
4354
|
+
|
|
4355
|
+
if context_schema is None:
|
|
4356
|
+
return context
|
|
4357
|
+
|
|
4358
|
+
schema_is_class = issubclass(context_schema, BaseModel) or is_dataclass(
|
|
4359
|
+
context_schema
|
|
4360
|
+
)
|
|
4361
|
+
if isinstance(context, dict) and schema_is_class:
|
|
4362
|
+
return context_schema(**context) # type: ignore[misc]
|
|
4363
|
+
|
|
4364
|
+
return cast(ContextT, context)
|