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,1978 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
import logging
|
|
5
|
+
import typing
|
|
6
|
+
import warnings
|
|
7
|
+
from collections import defaultdict
|
|
8
|
+
from collections.abc import Awaitable, Callable, Hashable, Sequence
|
|
9
|
+
from dataclasses import dataclass, is_dataclass
|
|
10
|
+
from datetime import timedelta
|
|
11
|
+
from functools import partial
|
|
12
|
+
from inspect import isclass, isfunction, ismethod, signature
|
|
13
|
+
from types import FunctionType
|
|
14
|
+
from types import NoneType as NoneType
|
|
15
|
+
from typing import (
|
|
16
|
+
Any,
|
|
17
|
+
Generic,
|
|
18
|
+
Literal,
|
|
19
|
+
TypeVar,
|
|
20
|
+
Union,
|
|
21
|
+
cast,
|
|
22
|
+
get_args,
|
|
23
|
+
get_origin,
|
|
24
|
+
get_type_hints,
|
|
25
|
+
overload,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
from coco_core.runnables import Runnable, RunnableConfig
|
|
29
|
+
from coco_graph.cache.base import BaseCache
|
|
30
|
+
from coco_graph.checkpoint.base import Checkpoint
|
|
31
|
+
from coco_graph.store.base import BaseStore
|
|
32
|
+
from pydantic import BaseModel, TypeAdapter
|
|
33
|
+
from typing_extensions import NotRequired, Required, Self, Unpack, is_typeddict
|
|
34
|
+
|
|
35
|
+
from coco_graph._internal import _serde
|
|
36
|
+
from coco_graph._internal._constants import (
|
|
37
|
+
INTERRUPT,
|
|
38
|
+
NS_END,
|
|
39
|
+
NS_SEP,
|
|
40
|
+
TASKS,
|
|
41
|
+
)
|
|
42
|
+
from coco_graph._internal._fields import (
|
|
43
|
+
get_cached_annotated_keys,
|
|
44
|
+
get_field_default,
|
|
45
|
+
get_update_as_tuples,
|
|
46
|
+
)
|
|
47
|
+
from coco_graph._internal._pydantic import create_model
|
|
48
|
+
from coco_graph._internal._runnable import coerce_to_runnable
|
|
49
|
+
from coco_graph._internal._timeout import coerce_timeout_policy
|
|
50
|
+
from coco_graph._internal._typing import EMPTY_SEQ, MISSING, DeprecatedKwargs
|
|
51
|
+
from coco_graph.channels.base import BaseChannel
|
|
52
|
+
from coco_graph.channels.binop import BinaryOperatorAggregate
|
|
53
|
+
from coco_graph.channels.delta import DeltaChannel
|
|
54
|
+
from coco_graph.channels.ephemeral_value import EphemeralValue
|
|
55
|
+
from coco_graph.channels.last_value import LastValue, LastValueAfterFinish
|
|
56
|
+
from coco_graph.channels.named_barrier_value import (
|
|
57
|
+
NamedBarrierValue,
|
|
58
|
+
NamedBarrierValueAfterFinish,
|
|
59
|
+
)
|
|
60
|
+
from coco_graph.constants import END, START, TAG_HIDDEN
|
|
61
|
+
from coco_graph.errors import (
|
|
62
|
+
ErrorCode,
|
|
63
|
+
InvalidUpdateError,
|
|
64
|
+
ParentCommand,
|
|
65
|
+
create_error_message,
|
|
66
|
+
)
|
|
67
|
+
from coco_graph.graph._branch import BranchSpec
|
|
68
|
+
from coco_graph.graph._node import StateNode, StateNodeSpec
|
|
69
|
+
from coco_graph.managed.base import (
|
|
70
|
+
ManagedValueSpec,
|
|
71
|
+
is_managed_value,
|
|
72
|
+
)
|
|
73
|
+
from coco_graph.pregel import Pregel
|
|
74
|
+
from coco_graph.pregel._read import ChannelRead, PregelNode
|
|
75
|
+
from coco_graph.pregel._write import (
|
|
76
|
+
ChannelWrite,
|
|
77
|
+
ChannelWriteEntry,
|
|
78
|
+
ChannelWriteTupleEntry,
|
|
79
|
+
)
|
|
80
|
+
from coco_graph.types import (
|
|
81
|
+
All,
|
|
82
|
+
CachePolicy,
|
|
83
|
+
Checkpointer,
|
|
84
|
+
Command,
|
|
85
|
+
RetryPolicy,
|
|
86
|
+
Send,
|
|
87
|
+
TimeoutPolicy,
|
|
88
|
+
TracePolicy,
|
|
89
|
+
ensure_valid_checkpointer,
|
|
90
|
+
)
|
|
91
|
+
from coco_graph.typing import ContextT, InputT, NodeInputT, OutputT, StateT
|
|
92
|
+
from coco_graph.warnings import CocoGraphDeprecatedSinceV05, CocoGraphDeprecatedSinceV10
|
|
93
|
+
|
|
94
|
+
__all__ = ("StateGraph", "CompiledStateGraph")
|
|
95
|
+
|
|
96
|
+
logger = logging.getLogger(__name__)
|
|
97
|
+
|
|
98
|
+
_CHANNEL_BRANCH_TO = "branch:to:{}"
|
|
99
|
+
_DEFAULT_ERROR_HANDLER_NODE = "__default_error_handler__"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass(slots=True)
|
|
103
|
+
class _NodeDefaults:
|
|
104
|
+
"""Default node policies applied to every node at compile time."""
|
|
105
|
+
|
|
106
|
+
retry_policy: RetryPolicy | Sequence[RetryPolicy] | None = None
|
|
107
|
+
cache_policy: CachePolicy | None = None
|
|
108
|
+
error_handler: StateNode[Any, Any] | None = None
|
|
109
|
+
timeout: TimeoutPolicy | None = None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _warn_invalid_state_schema(schema: type[Any] | Any) -> None:
|
|
113
|
+
if isinstance(schema, type):
|
|
114
|
+
return
|
|
115
|
+
if typing.get_args(schema):
|
|
116
|
+
return
|
|
117
|
+
warnings.warn(
|
|
118
|
+
f"Invalid state_schema: {schema}. Expected a type or Annotated[type, reducer]. "
|
|
119
|
+
"Please provide a valid schema to ensure correct updates.\n"
|
|
120
|
+
" See: https://langchain-ai.github.io/langgraph/reference/graphs/#stategraph"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _get_node_name(node: StateNode[Any, ContextT]) -> str:
|
|
125
|
+
try:
|
|
126
|
+
return getattr(node, "__name__", node.__class__.__name__)
|
|
127
|
+
except AttributeError:
|
|
128
|
+
raise TypeError(f"Unsupported node type: {type(node)}")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
|
|
132
|
+
"""A graph whose nodes communicate by reading and writing to a shared state.
|
|
133
|
+
|
|
134
|
+
The signature of each node is `State -> Partial<State>`.
|
|
135
|
+
|
|
136
|
+
Each state key can optionally be annotated with a reducer function that
|
|
137
|
+
will be used to aggregate the values of that key received from multiple nodes.
|
|
138
|
+
The signature of a reducer function is `(Value, Value) -> Value`.
|
|
139
|
+
|
|
140
|
+
!!! warning
|
|
141
|
+
|
|
142
|
+
`StateGraph` is a builder class and cannot be used directly for execution.
|
|
143
|
+
You must first call `.compile()` to create an executable graph that supports
|
|
144
|
+
methods like `invoke()`, `stream()`, `astream()`, and `ainvoke()`. See the
|
|
145
|
+
`CompiledStateGraph` documentation for more details.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
state_schema: The schema class that defines the state.
|
|
149
|
+
context_schema: The schema class that defines the runtime context.
|
|
150
|
+
|
|
151
|
+
Use this to expose immutable context data to your nodes, like `user_id`, `db_conn`, etc.
|
|
152
|
+
input_schema: The schema class that defines the input to the graph.
|
|
153
|
+
output_schema: The schema class that defines the output from the graph.
|
|
154
|
+
|
|
155
|
+
!!! warning "`config_schema` Deprecated"
|
|
156
|
+
The `config_schema` parameter is deprecated in v0.6.0 and support will be removed in v2.0.0.
|
|
157
|
+
Please use `context_schema` instead to specify the schema for run-scoped context.
|
|
158
|
+
|
|
159
|
+
Example:
|
|
160
|
+
```python
|
|
161
|
+
from coco_core.runnables import RunnableConfig
|
|
162
|
+
from typing_extensions import Annotated, TypedDict
|
|
163
|
+
from coco_graph.checkpoint.memory import InMemorySaver
|
|
164
|
+
from coco_graph.graph import StateGraph
|
|
165
|
+
from coco_graph.runtime import Runtime
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def reducer(a: list, b: int | None) -> list:
|
|
169
|
+
if b is not None:
|
|
170
|
+
return a + [b]
|
|
171
|
+
return a
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class State(TypedDict):
|
|
175
|
+
x: Annotated[list, reducer]
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class Context(TypedDict):
|
|
179
|
+
r: float
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
graph = StateGraph(state_schema=State, context_schema=Context)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def node(state: State, runtime: Runtime[Context]) -> dict:
|
|
186
|
+
r = runtime.context.get("r", 1.0)
|
|
187
|
+
x = state["x"][-1]
|
|
188
|
+
next_value = x * r * (1 - x)
|
|
189
|
+
return {"x": next_value}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
graph.add_node("A", node)
|
|
193
|
+
graph.set_entry_point("A")
|
|
194
|
+
graph.set_finish_point("A")
|
|
195
|
+
compiled = graph.compile()
|
|
196
|
+
|
|
197
|
+
step1 = compiled.invoke({"x": 0.5}, context={"r": 3.0})
|
|
198
|
+
# {'x': [0.5, 0.75]}
|
|
199
|
+
```
|
|
200
|
+
"""
|
|
201
|
+
|
|
202
|
+
edges: set[tuple[str, str]]
|
|
203
|
+
nodes: dict[str, StateNodeSpec[Any, ContextT]]
|
|
204
|
+
branches: defaultdict[str, dict[str, BranchSpec]]
|
|
205
|
+
channels: dict[str, BaseChannel]
|
|
206
|
+
managed: dict[str, ManagedValueSpec]
|
|
207
|
+
schemas: dict[type[Any], dict[str, BaseChannel | ManagedValueSpec]]
|
|
208
|
+
waiting_edges: set[tuple[tuple[str, ...], str]]
|
|
209
|
+
|
|
210
|
+
compiled: bool
|
|
211
|
+
state_schema: type[StateT]
|
|
212
|
+
context_schema: type[ContextT] | None
|
|
213
|
+
input_schema: type[InputT]
|
|
214
|
+
output_schema: type[OutputT]
|
|
215
|
+
|
|
216
|
+
def __init__(
|
|
217
|
+
self,
|
|
218
|
+
state_schema: type[StateT],
|
|
219
|
+
context_schema: type[ContextT] | None = None,
|
|
220
|
+
*,
|
|
221
|
+
input_schema: type[InputT] | None = None,
|
|
222
|
+
output_schema: type[OutputT] | None = None,
|
|
223
|
+
**kwargs: Unpack[DeprecatedKwargs],
|
|
224
|
+
) -> None:
|
|
225
|
+
if (config_schema := kwargs.get("config_schema", MISSING)) is not MISSING:
|
|
226
|
+
warnings.warn(
|
|
227
|
+
"`config_schema` is deprecated and will be removed. Please use `context_schema` instead.",
|
|
228
|
+
category=CocoGraphDeprecatedSinceV10,
|
|
229
|
+
stacklevel=2,
|
|
230
|
+
)
|
|
231
|
+
if context_schema is None:
|
|
232
|
+
context_schema = cast(type[ContextT], config_schema)
|
|
233
|
+
|
|
234
|
+
if (input_ := kwargs.get("input", MISSING)) is not MISSING:
|
|
235
|
+
warnings.warn(
|
|
236
|
+
"`input` is deprecated and will be removed. Please use `input_schema` instead.",
|
|
237
|
+
category=CocoGraphDeprecatedSinceV05,
|
|
238
|
+
stacklevel=2,
|
|
239
|
+
)
|
|
240
|
+
if input_schema is None:
|
|
241
|
+
input_schema = cast(type[InputT], input_)
|
|
242
|
+
|
|
243
|
+
if (output := kwargs.get("output", MISSING)) is not MISSING:
|
|
244
|
+
warnings.warn(
|
|
245
|
+
"`output` is deprecated and will be removed. Please use `output_schema` instead.",
|
|
246
|
+
category=CocoGraphDeprecatedSinceV05,
|
|
247
|
+
stacklevel=2,
|
|
248
|
+
)
|
|
249
|
+
if output_schema is None:
|
|
250
|
+
output_schema = cast(type[OutputT], output)
|
|
251
|
+
|
|
252
|
+
self.nodes = {}
|
|
253
|
+
self.edges = set()
|
|
254
|
+
self.branches = defaultdict(dict)
|
|
255
|
+
self.schemas = {}
|
|
256
|
+
self.channels = {}
|
|
257
|
+
self.managed = {}
|
|
258
|
+
self.compiled = False
|
|
259
|
+
self.waiting_edges = set()
|
|
260
|
+
|
|
261
|
+
self.state_schema = state_schema
|
|
262
|
+
self.input_schema = cast(type[InputT], input_schema or state_schema)
|
|
263
|
+
self.output_schema = cast(type[OutputT], output_schema or state_schema)
|
|
264
|
+
self.context_schema = context_schema
|
|
265
|
+
|
|
266
|
+
self._node_defaults: _NodeDefaults = _NodeDefaults()
|
|
267
|
+
|
|
268
|
+
self._add_schema(self.state_schema)
|
|
269
|
+
self._add_schema(self.input_schema, allow_managed=False)
|
|
270
|
+
self._add_schema(self.output_schema, allow_managed=False)
|
|
271
|
+
|
|
272
|
+
def set_node_defaults(
|
|
273
|
+
self,
|
|
274
|
+
*,
|
|
275
|
+
retry_policy: RetryPolicy | Sequence[RetryPolicy] | None = None,
|
|
276
|
+
cache_policy: CachePolicy | None = None,
|
|
277
|
+
error_handler: StateNode[Any, ContextT] | None = None,
|
|
278
|
+
timeout: float | timedelta | TimeoutPolicy | None = None,
|
|
279
|
+
) -> Self:
|
|
280
|
+
"""Set default node policies that apply to every node in this graph.
|
|
281
|
+
|
|
282
|
+
Per-node values passed to `add_node` always take precedence over these
|
|
283
|
+
defaults. Defaults are applied at `compile()` time. Policies set here
|
|
284
|
+
are **not** inherited by subgraphs.
|
|
285
|
+
|
|
286
|
+
`retry_policy` and `timeout` defaults apply to **all** nodes,
|
|
287
|
+
including error-handler nodes. `cache_policy` and `error_handler`
|
|
288
|
+
defaults only apply to regular nodes -- caching error-handler results
|
|
289
|
+
is unsafe, and handlers must never catch themselves.
|
|
290
|
+
|
|
291
|
+
Args:
|
|
292
|
+
retry_policy: Default retry policy for nodes that don't specify
|
|
293
|
+
their own via `add_node(..., retry_policy=...)`. Also applies
|
|
294
|
+
to error-handler nodes.
|
|
295
|
+
cache_policy: Default cache policy for nodes that don't specify
|
|
296
|
+
their own via `add_node(..., cache_policy=...)`. Does **not**
|
|
297
|
+
apply to error-handler nodes.
|
|
298
|
+
error_handler: Default error handler invoked when any regular node
|
|
299
|
+
raises and does not have its own `error_handler` set via
|
|
300
|
+
`add_node`. The handler is **not** invoked when an
|
|
301
|
+
error-handler node itself raises -- handler failures fail the
|
|
302
|
+
run.
|
|
303
|
+
timeout: Default timeout policy for nodes that don't specify their
|
|
304
|
+
own via `add_node(..., timeout=...)`. Also applies to
|
|
305
|
+
error-handler nodes. Accepts a `TimeoutPolicy`, a number of
|
|
306
|
+
seconds (`float`), or a `timedelta`.
|
|
307
|
+
|
|
308
|
+
Returns:
|
|
309
|
+
Self: The builder instance, for chaining.
|
|
310
|
+
|
|
311
|
+
Example:
|
|
312
|
+
```python
|
|
313
|
+
graph = (
|
|
314
|
+
StateGraph(State)
|
|
315
|
+
.set_node_defaults(
|
|
316
|
+
retry_policy=RetryPolicy(max_attempts=3),
|
|
317
|
+
error_handler=my_fallback_handler,
|
|
318
|
+
)
|
|
319
|
+
.add_node("a", node_a)
|
|
320
|
+
.add_node("b", node_b, retry_policy=custom_retry) # overrides default
|
|
321
|
+
.add_edge(START, "a")
|
|
322
|
+
.compile()
|
|
323
|
+
)
|
|
324
|
+
```
|
|
325
|
+
"""
|
|
326
|
+
defaults = self._node_defaults
|
|
327
|
+
if retry_policy is not None:
|
|
328
|
+
defaults.retry_policy = retry_policy
|
|
329
|
+
if cache_policy is not None:
|
|
330
|
+
defaults.cache_policy = cache_policy
|
|
331
|
+
if error_handler is not None:
|
|
332
|
+
defaults.error_handler = error_handler
|
|
333
|
+
if timeout is not None:
|
|
334
|
+
defaults.timeout = coerce_timeout_policy(timeout)
|
|
335
|
+
return self
|
|
336
|
+
|
|
337
|
+
@property
|
|
338
|
+
def _all_edges(self) -> set[tuple[str, str]]:
|
|
339
|
+
return self.edges | {
|
|
340
|
+
(start, end) for starts, end in self.waiting_edges for start in starts
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
def _add_schema(self, schema: type[Any], /, allow_managed: bool = True) -> None:
|
|
344
|
+
if schema not in self.schemas:
|
|
345
|
+
_warn_invalid_state_schema(schema)
|
|
346
|
+
channels, managed, type_hints = _get_channels(schema)
|
|
347
|
+
if managed and not allow_managed:
|
|
348
|
+
names = ", ".join(managed)
|
|
349
|
+
schema_name = getattr(schema, "__name__", "")
|
|
350
|
+
raise ValueError(
|
|
351
|
+
f"Invalid managed channels detected in {schema_name}: {names}."
|
|
352
|
+
" Managed channels are not permitted in Input/Output schema."
|
|
353
|
+
)
|
|
354
|
+
self.schemas[schema] = {**channels, **managed}
|
|
355
|
+
for key, channel in channels.items():
|
|
356
|
+
if key in self.channels:
|
|
357
|
+
if self.channels[key] != channel:
|
|
358
|
+
if isinstance(channel, LastValue):
|
|
359
|
+
pass
|
|
360
|
+
else:
|
|
361
|
+
raise ValueError(
|
|
362
|
+
f"Channel '{key}' already exists with a different type"
|
|
363
|
+
)
|
|
364
|
+
else:
|
|
365
|
+
self.channels[key] = channel
|
|
366
|
+
for key, managed in managed.items():
|
|
367
|
+
if key in self.managed:
|
|
368
|
+
if self.managed[key] != managed:
|
|
369
|
+
raise ValueError(
|
|
370
|
+
f"Managed value '{key}' already exists with a different type"
|
|
371
|
+
)
|
|
372
|
+
else:
|
|
373
|
+
self.managed[key] = managed
|
|
374
|
+
|
|
375
|
+
@overload
|
|
376
|
+
def add_node(
|
|
377
|
+
self,
|
|
378
|
+
node: StateNode[NodeInputT, ContextT],
|
|
379
|
+
*,
|
|
380
|
+
defer: bool = False,
|
|
381
|
+
metadata: dict[str, Any] | None = None,
|
|
382
|
+
input_schema: None = None,
|
|
383
|
+
retry_policy: RetryPolicy | Sequence[RetryPolicy] | None = None,
|
|
384
|
+
cache_policy: CachePolicy | None = None,
|
|
385
|
+
error_handler: StateNode[Any, ContextT] | None = None,
|
|
386
|
+
destinations: dict[str, str] | tuple[str, ...] | None = None,
|
|
387
|
+
timeout: float | timedelta | TimeoutPolicy | None = None,
|
|
388
|
+
trace_policy: TracePolicy | None = None,
|
|
389
|
+
**kwargs: Unpack[DeprecatedKwargs],
|
|
390
|
+
) -> Self:
|
|
391
|
+
"""Add a new node to the `StateGraph`, input schema is inferred as the state schema.
|
|
392
|
+
|
|
393
|
+
Will take the name of the function/runnable as the node name.
|
|
394
|
+
|
|
395
|
+
Args:
|
|
396
|
+
node: The function or runnable this node will run.
|
|
397
|
+
defer: Whether to defer the execution of the node until the run is about to end.
|
|
398
|
+
metadata: The metadata associated with the node.
|
|
399
|
+
input_schema: The input schema for the node. (Default: the graph's state schema)
|
|
400
|
+
retry_policy: The retry policy for the node.
|
|
401
|
+
|
|
402
|
+
If a sequence is provided, the first matching policy will be applied.
|
|
403
|
+
cache_policy: The cache policy for the node.
|
|
404
|
+
destinations: Destinations that indicate where a node can route to.
|
|
405
|
+
|
|
406
|
+
Useful for edgeless graphs with nodes that return `Command` objects.
|
|
407
|
+
|
|
408
|
+
If a `dict` is provided, the keys will be used as the target node names and the values will be used as the labels for the edges.
|
|
409
|
+
|
|
410
|
+
If a `tuple` is provided, the values will be used as the target node names.
|
|
411
|
+
|
|
412
|
+
!!! warning
|
|
413
|
+
|
|
414
|
+
This is only used for graph rendering and doesn't have any effect on the graph execution.
|
|
415
|
+
|
|
416
|
+
Example:
|
|
417
|
+
```python
|
|
418
|
+
from typing_extensions import TypedDict
|
|
419
|
+
|
|
420
|
+
from coco_core.runnables import RunnableConfig
|
|
421
|
+
from coco_graph.graph import START, StateGraph
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
class State(TypedDict):
|
|
425
|
+
x: int
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def my_node(state: State, config: RunnableConfig) -> State:
|
|
429
|
+
return {"x": state["x"] + 1}
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
builder = StateGraph(State)
|
|
433
|
+
builder.add_node(my_node) # node name will be 'my_node'
|
|
434
|
+
builder.add_edge(START, "my_node")
|
|
435
|
+
graph = builder.compile()
|
|
436
|
+
graph.invoke({"x": 1})
|
|
437
|
+
# {'x': 2}
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
Returns:
|
|
441
|
+
Self: The instance of the `StateGraph`, allowing for method chaining.
|
|
442
|
+
"""
|
|
443
|
+
...
|
|
444
|
+
|
|
445
|
+
@overload
|
|
446
|
+
def add_node(
|
|
447
|
+
self,
|
|
448
|
+
node: StateNode[NodeInputT, ContextT],
|
|
449
|
+
*,
|
|
450
|
+
defer: bool = False,
|
|
451
|
+
metadata: dict[str, Any] | None = None,
|
|
452
|
+
input_schema: type[NodeInputT],
|
|
453
|
+
retry_policy: RetryPolicy | Sequence[RetryPolicy] | None = None,
|
|
454
|
+
cache_policy: CachePolicy | None = None,
|
|
455
|
+
error_handler: StateNode[Any, ContextT] | None = None,
|
|
456
|
+
destinations: dict[str, str] | tuple[str, ...] | None = None,
|
|
457
|
+
timeout: float | timedelta | TimeoutPolicy | None = None,
|
|
458
|
+
trace_policy: TracePolicy | None = None,
|
|
459
|
+
**kwargs: Unpack[DeprecatedKwargs],
|
|
460
|
+
) -> Self:
|
|
461
|
+
"""Add a new node to the `StateGraph` where input schema is specified.
|
|
462
|
+
|
|
463
|
+
Will take the name of the function/runnable as the node name.
|
|
464
|
+
|
|
465
|
+
Args:
|
|
466
|
+
node: The function or runnable this node will run.
|
|
467
|
+
defer: Whether to defer the execution of the node until the run is about to end.
|
|
468
|
+
metadata: The metadata associated with the node.
|
|
469
|
+
input_schema: The input schema for the node.
|
|
470
|
+
retry_policy: The retry policy for the node.
|
|
471
|
+
|
|
472
|
+
If a sequence is provided, the first matching policy will be applied.
|
|
473
|
+
cache_policy: The cache policy for the node.
|
|
474
|
+
destinations: Destinations that indicate where a node can route to.
|
|
475
|
+
|
|
476
|
+
Useful for edgeless graphs with nodes that return `Command` objects.
|
|
477
|
+
|
|
478
|
+
If a `dict` is provided, the keys will be used as the target node names and the values will be used as the labels for the edges.
|
|
479
|
+
|
|
480
|
+
If a `tuple` is provided, the values will be used as the target node names.
|
|
481
|
+
|
|
482
|
+
!!! warning
|
|
483
|
+
|
|
484
|
+
This is only used for graph rendering and doesn't have any effect on the graph execution.
|
|
485
|
+
|
|
486
|
+
Example:
|
|
487
|
+
```python
|
|
488
|
+
from typing_extensions import TypedDict
|
|
489
|
+
|
|
490
|
+
from coco_core.runnables import RunnableConfig
|
|
491
|
+
from coco_graph.graph import START, StateGraph
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
class State(TypedDict):
|
|
495
|
+
x: int
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
class NodeInput(TypedDict):
|
|
499
|
+
x: int
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def my_node(state: NodeInput, config: RunnableConfig) -> State:
|
|
503
|
+
return {"x": state["x"] + 1}
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
builder = StateGraph(State)
|
|
507
|
+
builder.add_node(my_node, input_schema=NodeInput) # node name will be 'my_node'
|
|
508
|
+
builder.add_edge(START, "my_node")
|
|
509
|
+
graph = builder.compile()
|
|
510
|
+
graph.invoke({"x": 1})
|
|
511
|
+
# {'x': 2}
|
|
512
|
+
```
|
|
513
|
+
|
|
514
|
+
Returns:
|
|
515
|
+
Self: The instance of the `StateGraph`, allowing for method chaining.
|
|
516
|
+
"""
|
|
517
|
+
...
|
|
518
|
+
|
|
519
|
+
@overload
|
|
520
|
+
def add_node(
|
|
521
|
+
self,
|
|
522
|
+
node: str,
|
|
523
|
+
action: StateNode[NodeInputT, ContextT],
|
|
524
|
+
*,
|
|
525
|
+
defer: bool = False,
|
|
526
|
+
metadata: dict[str, Any] | None = None,
|
|
527
|
+
input_schema: None = None,
|
|
528
|
+
retry_policy: RetryPolicy | Sequence[RetryPolicy] | None = None,
|
|
529
|
+
cache_policy: CachePolicy | None = None,
|
|
530
|
+
error_handler: StateNode[Any, ContextT] | None = None,
|
|
531
|
+
destinations: dict[str, str] | tuple[str, ...] | None = None,
|
|
532
|
+
timeout: float | timedelta | TimeoutPolicy | None = None,
|
|
533
|
+
trace_policy: TracePolicy | None = None,
|
|
534
|
+
**kwargs: Unpack[DeprecatedKwargs],
|
|
535
|
+
) -> Self:
|
|
536
|
+
"""Add a new node to the `StateGraph`, input schema is inferred as the state schema.
|
|
537
|
+
|
|
538
|
+
Args:
|
|
539
|
+
node: The name of the node.
|
|
540
|
+
action: The function or runnable this node will run.
|
|
541
|
+
defer: Whether to defer the execution of the node until the run is about to end.
|
|
542
|
+
metadata: The metadata associated with the node.
|
|
543
|
+
input_schema: The input schema for the node. (Default: the graph's state schema)
|
|
544
|
+
retry_policy: The retry policy for the node.
|
|
545
|
+
|
|
546
|
+
If a sequence is provided, the first matching policy will be applied.
|
|
547
|
+
cache_policy: The cache policy for the node.
|
|
548
|
+
destinations: Destinations that indicate where a node can route to.
|
|
549
|
+
|
|
550
|
+
Useful for edgeless graphs with nodes that return `Command` objects.
|
|
551
|
+
|
|
552
|
+
If a `dict` is provided, the keys will be used as the target node names and the values will be used as the labels for the edges.
|
|
553
|
+
|
|
554
|
+
If a `tuple` is provided, the values will be used as the target node names.
|
|
555
|
+
|
|
556
|
+
!!! warning
|
|
557
|
+
|
|
558
|
+
This is only used for graph rendering and doesn't have any effect on the graph execution.
|
|
559
|
+
|
|
560
|
+
Example:
|
|
561
|
+
```python
|
|
562
|
+
from typing_extensions import TypedDict
|
|
563
|
+
|
|
564
|
+
from coco_core.runnables import RunnableConfig
|
|
565
|
+
from coco_graph.graph import START, StateGraph
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
class State(TypedDict):
|
|
569
|
+
x: int
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def my_node(state: State, config: RunnableConfig) -> State:
|
|
573
|
+
return {"x": state["x"] + 1}
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
builder = StateGraph(State)
|
|
577
|
+
builder.add_node("my_fair_node", my_node)
|
|
578
|
+
builder.add_edge(START, "my_fair_node")
|
|
579
|
+
graph = builder.compile()
|
|
580
|
+
graph.invoke({"x": 1})
|
|
581
|
+
# {'x': 2}
|
|
582
|
+
```
|
|
583
|
+
|
|
584
|
+
Returns:
|
|
585
|
+
Self: The instance of the `StateGraph`, allowing for method chaining.
|
|
586
|
+
"""
|
|
587
|
+
...
|
|
588
|
+
|
|
589
|
+
@overload
|
|
590
|
+
def add_node(
|
|
591
|
+
self,
|
|
592
|
+
node: str | StateNode[NodeInputT, ContextT],
|
|
593
|
+
action: StateNode[NodeInputT, ContextT] | None = None,
|
|
594
|
+
*,
|
|
595
|
+
defer: bool = False,
|
|
596
|
+
metadata: dict[str, Any] | None = None,
|
|
597
|
+
input_schema: type[NodeInputT],
|
|
598
|
+
retry_policy: RetryPolicy | Sequence[RetryPolicy] | None = None,
|
|
599
|
+
cache_policy: CachePolicy | None = None,
|
|
600
|
+
error_handler: StateNode[Any, ContextT] | None = None,
|
|
601
|
+
destinations: dict[str, str] | tuple[str, ...] | None = None,
|
|
602
|
+
timeout: float | timedelta | TimeoutPolicy | None = None,
|
|
603
|
+
trace_policy: TracePolicy | None = None,
|
|
604
|
+
**kwargs: Unpack[DeprecatedKwargs],
|
|
605
|
+
) -> Self:
|
|
606
|
+
"""Add a new node to the `StateGraph`, input schema is specified.
|
|
607
|
+
|
|
608
|
+
Args:
|
|
609
|
+
node: The function or runnable this node will run.
|
|
610
|
+
|
|
611
|
+
If a string is provided, it will be used as the node name, and action will be used as the function or runnable.
|
|
612
|
+
action: The action associated with the node.
|
|
613
|
+
|
|
614
|
+
Will be used as the node function or runnable if `node` is a string (node name).
|
|
615
|
+
defer: Whether to defer the execution of the node until the run is about to end.
|
|
616
|
+
metadata: The metadata associated with the node.
|
|
617
|
+
input_schema: The input schema for the node.
|
|
618
|
+
retry_policy: The retry policy for the node.
|
|
619
|
+
|
|
620
|
+
If a sequence is provided, the first matching policy will be applied.
|
|
621
|
+
cache_policy: The cache policy for the node.
|
|
622
|
+
destinations: Destinations that indicate where a node can route to.
|
|
623
|
+
|
|
624
|
+
Useful for edgeless graphs with nodes that return `Command` objects.
|
|
625
|
+
|
|
626
|
+
If a `dict` is provided, the keys will be used as the target node names and the values will be used as the labels for the edges.
|
|
627
|
+
|
|
628
|
+
If a `tuple` is provided, the values will be used as the target node names.
|
|
629
|
+
|
|
630
|
+
!!! warning
|
|
631
|
+
|
|
632
|
+
This is only used for graph rendering and doesn't have any effect on the graph execution.
|
|
633
|
+
|
|
634
|
+
Example:
|
|
635
|
+
```python
|
|
636
|
+
from typing_extensions import TypedDict
|
|
637
|
+
|
|
638
|
+
from coco_core.runnables import RunnableConfig
|
|
639
|
+
from coco_graph.graph import START, StateGraph
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
class State(TypedDict):
|
|
643
|
+
x: int
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
class NodeInput(TypedDict):
|
|
647
|
+
x: int
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def my_node(state: NodeInput, config: RunnableConfig) -> State:
|
|
651
|
+
return {"x": state["x"] + 1}
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
builder = StateGraph(State)
|
|
655
|
+
builder.add_node("my_fair_node", my_node, input_schema=NodeInput)
|
|
656
|
+
builder.add_edge(START, "my_fair_node")
|
|
657
|
+
graph = builder.compile()
|
|
658
|
+
graph.invoke({"x": 1})
|
|
659
|
+
# {'x': 2}
|
|
660
|
+
```
|
|
661
|
+
|
|
662
|
+
Returns:
|
|
663
|
+
Self: The instance of the `StateGraph`, allowing for method chaining.
|
|
664
|
+
"""
|
|
665
|
+
...
|
|
666
|
+
|
|
667
|
+
def add_node(
|
|
668
|
+
self,
|
|
669
|
+
node: str | StateNode[NodeInputT, ContextT],
|
|
670
|
+
action: StateNode[NodeInputT, ContextT] | None = None,
|
|
671
|
+
*,
|
|
672
|
+
defer: bool = False,
|
|
673
|
+
metadata: dict[str, Any] | None = None,
|
|
674
|
+
input_schema: type[NodeInputT] | None = None,
|
|
675
|
+
retry_policy: RetryPolicy | Sequence[RetryPolicy] | None = None,
|
|
676
|
+
cache_policy: CachePolicy | None = None,
|
|
677
|
+
error_handler: StateNode[Any, ContextT] | None = None,
|
|
678
|
+
destinations: dict[str, str] | tuple[str, ...] | None = None,
|
|
679
|
+
timeout: float | timedelta | TimeoutPolicy | None = None,
|
|
680
|
+
trace_policy: TracePolicy | None = None,
|
|
681
|
+
**kwargs: Unpack[DeprecatedKwargs],
|
|
682
|
+
) -> Self:
|
|
683
|
+
"""Add a new node to the `StateGraph`.
|
|
684
|
+
|
|
685
|
+
Args:
|
|
686
|
+
node: The function or runnable this node will run.
|
|
687
|
+
|
|
688
|
+
If a string is provided, it will be used as the node name, and action will be used as the function or runnable.
|
|
689
|
+
action: The action associated with the node.
|
|
690
|
+
|
|
691
|
+
Will be used as the node function or runnable if `node` is a string (node name).
|
|
692
|
+
defer: Whether to defer the execution of the node until the run is about to end.
|
|
693
|
+
metadata: The metadata associated with the node.
|
|
694
|
+
input_schema: The input schema for the node. (Default: the graph's state schema)
|
|
695
|
+
retry_policy: The retry policy for the node.
|
|
696
|
+
|
|
697
|
+
If a sequence is provided, the first matching policy will be applied.
|
|
698
|
+
cache_policy: The cache policy for the node.
|
|
699
|
+
error_handler: Optional node-level error handler callable for this node.
|
|
700
|
+
trace_policy: Optional policy controlling how this node's run is traced. Its
|
|
701
|
+
`process_inputs` callable transforms the node's input before it is
|
|
702
|
+
recorded (e.g. to omit or summarize large message history) without
|
|
703
|
+
changing the value passed to the node. Does not affect execution.
|
|
704
|
+
destinations: Destinations that indicate where a node can route to.
|
|
705
|
+
|
|
706
|
+
Useful for edgeless graphs with nodes that return `Command` objects.
|
|
707
|
+
|
|
708
|
+
If a `dict` is provided, the keys will be used as the target node names and the values will be used as the labels for the edges.
|
|
709
|
+
|
|
710
|
+
If a `tuple` is provided, the values will be used as the target node names.
|
|
711
|
+
|
|
712
|
+
!!! warning
|
|
713
|
+
|
|
714
|
+
This is only used for graph rendering and doesn't have any effect on the graph execution.
|
|
715
|
+
timeout: Timeout for each node attempt. A number or `timedelta` is
|
|
716
|
+
a hard wall-clock cap and is not refreshed. Use `TimeoutPolicy`
|
|
717
|
+
to configure both a wall-clock `run_timeout` and an
|
|
718
|
+
`idle_timeout` refreshed by progress signals. When exceeded, a
|
|
719
|
+
[`NodeTimeoutError`][coco_graph.errors.NodeTimeoutError] is raised
|
|
720
|
+
and the retry policy (if any) decides whether to retry. Timeouts
|
|
721
|
+
are supported only for async nodes; sync nodes cannot be safely
|
|
722
|
+
cancelled in-process.
|
|
723
|
+
|
|
724
|
+
Example:
|
|
725
|
+
```python
|
|
726
|
+
from typing_extensions import TypedDict
|
|
727
|
+
|
|
728
|
+
from coco_core.runnables import RunnableConfig
|
|
729
|
+
from coco_graph.graph import START, StateGraph
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
class State(TypedDict):
|
|
733
|
+
x: int
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def my_node(state: State, config: RunnableConfig) -> State:
|
|
737
|
+
return {"x": state["x"] + 1}
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
builder = StateGraph(State)
|
|
741
|
+
builder.add_node(my_node) # node name will be 'my_node'
|
|
742
|
+
builder.add_edge(START, "my_node")
|
|
743
|
+
graph = builder.compile()
|
|
744
|
+
graph.invoke({"x": 1})
|
|
745
|
+
# {'x': 2}
|
|
746
|
+
```
|
|
747
|
+
|
|
748
|
+
Example: Customize the name:
|
|
749
|
+
```python
|
|
750
|
+
builder = StateGraph(State)
|
|
751
|
+
builder.add_node("my_fair_node", my_node)
|
|
752
|
+
builder.add_edge(START, "my_fair_node")
|
|
753
|
+
graph = builder.compile()
|
|
754
|
+
graph.invoke({"x": 1})
|
|
755
|
+
# {'x': 2}
|
|
756
|
+
```
|
|
757
|
+
|
|
758
|
+
Returns:
|
|
759
|
+
Self: The instance of the `StateGraph`, allowing for method chaining.
|
|
760
|
+
"""
|
|
761
|
+
if (retry := kwargs.get("retry", MISSING)) is not MISSING:
|
|
762
|
+
warnings.warn(
|
|
763
|
+
"`retry` is deprecated and will be removed. Please use `retry_policy` instead.",
|
|
764
|
+
category=CocoGraphDeprecatedSinceV05,
|
|
765
|
+
)
|
|
766
|
+
if retry_policy is None:
|
|
767
|
+
retry_policy = retry # type: ignore[assignment]
|
|
768
|
+
|
|
769
|
+
if (input_ := kwargs.get("input", MISSING)) is not MISSING:
|
|
770
|
+
warnings.warn(
|
|
771
|
+
"`input` is deprecated and will be removed. Please use `input_schema` instead.",
|
|
772
|
+
category=CocoGraphDeprecatedSinceV05,
|
|
773
|
+
)
|
|
774
|
+
if input_schema is None:
|
|
775
|
+
input_schema = cast(type[NodeInputT] | None, input_)
|
|
776
|
+
timeout = coerce_timeout_policy(timeout)
|
|
777
|
+
|
|
778
|
+
if not isinstance(node, str):
|
|
779
|
+
action = node
|
|
780
|
+
if isinstance(action, Runnable):
|
|
781
|
+
node = action.get_name()
|
|
782
|
+
else:
|
|
783
|
+
node = getattr(action, "__name__", action.__class__.__name__)
|
|
784
|
+
if node is None:
|
|
785
|
+
raise ValueError(
|
|
786
|
+
"Node name must be provided if action is not a function"
|
|
787
|
+
)
|
|
788
|
+
if self.compiled:
|
|
789
|
+
logger.warning(
|
|
790
|
+
"Adding a node to a graph that has already been compiled. This will "
|
|
791
|
+
"not be reflected in the compiled graph."
|
|
792
|
+
)
|
|
793
|
+
if not isinstance(node, str):
|
|
794
|
+
action = node
|
|
795
|
+
node = cast(str, getattr(action, "name", getattr(action, "__name__", None)))
|
|
796
|
+
if node is None:
|
|
797
|
+
raise ValueError(
|
|
798
|
+
"Node name must be provided if action is not a function"
|
|
799
|
+
)
|
|
800
|
+
if action is None:
|
|
801
|
+
raise RuntimeError
|
|
802
|
+
if node in self.nodes:
|
|
803
|
+
raise ValueError(f"Node `{node}` already present.")
|
|
804
|
+
if node == END or node == START:
|
|
805
|
+
raise ValueError(f"Node `{node}` is reserved.")
|
|
806
|
+
|
|
807
|
+
for character in (NS_SEP, NS_END):
|
|
808
|
+
if character in node:
|
|
809
|
+
raise ValueError(
|
|
810
|
+
f"'{character}' is a reserved character and is not allowed in the node names."
|
|
811
|
+
)
|
|
812
|
+
|
|
813
|
+
inferred_input_schema = None
|
|
814
|
+
|
|
815
|
+
ends: tuple[str, ...] | dict[str, str] = EMPTY_SEQ
|
|
816
|
+
try:
|
|
817
|
+
if (
|
|
818
|
+
isfunction(action)
|
|
819
|
+
or ismethod(action)
|
|
820
|
+
or ismethod(getattr(action, "__call__", None))
|
|
821
|
+
) and (
|
|
822
|
+
hints := get_type_hints(getattr(action, "__call__"))
|
|
823
|
+
or get_type_hints(action)
|
|
824
|
+
):
|
|
825
|
+
if input_schema is None:
|
|
826
|
+
first_parameter_name = next(
|
|
827
|
+
iter(
|
|
828
|
+
inspect.signature(
|
|
829
|
+
cast(FunctionType, action)
|
|
830
|
+
).parameters.keys()
|
|
831
|
+
)
|
|
832
|
+
)
|
|
833
|
+
if input_hint := hints.get(first_parameter_name):
|
|
834
|
+
if isinstance(input_hint, type) and get_type_hints(input_hint):
|
|
835
|
+
inferred_input_schema = input_hint
|
|
836
|
+
if rtn := hints.get("return"):
|
|
837
|
+
# Handle Union types
|
|
838
|
+
rtn_origin = get_origin(rtn)
|
|
839
|
+
if rtn_origin is Union:
|
|
840
|
+
rtn_args = get_args(rtn)
|
|
841
|
+
# Look for Command in the union
|
|
842
|
+
for arg in rtn_args:
|
|
843
|
+
arg_origin = get_origin(arg)
|
|
844
|
+
if arg_origin is Command:
|
|
845
|
+
rtn = arg
|
|
846
|
+
rtn_origin = arg_origin
|
|
847
|
+
break
|
|
848
|
+
|
|
849
|
+
# Check if it's a Command type
|
|
850
|
+
if (
|
|
851
|
+
rtn_origin is Command
|
|
852
|
+
and (rargs := get_args(rtn))
|
|
853
|
+
and get_origin(rargs[0]) is Literal
|
|
854
|
+
and (vals := get_args(rargs[0]))
|
|
855
|
+
):
|
|
856
|
+
ends = vals
|
|
857
|
+
except (NameError, TypeError, StopIteration):
|
|
858
|
+
pass
|
|
859
|
+
|
|
860
|
+
if destinations is not None:
|
|
861
|
+
ends = destinations
|
|
862
|
+
|
|
863
|
+
resolved_input_schema: type[Any] = (
|
|
864
|
+
input_schema or inferred_input_schema or self.state_schema
|
|
865
|
+
)
|
|
866
|
+
handler_node_name: str | None = None
|
|
867
|
+
if error_handler is not None:
|
|
868
|
+
handler_node_name = f"__error_handler__{node}"
|
|
869
|
+
if handler_node_name in self.nodes:
|
|
870
|
+
raise ValueError(
|
|
871
|
+
f"Auto-generated error handler node `{handler_node_name}` already exists."
|
|
872
|
+
)
|
|
873
|
+
self.nodes[handler_node_name] = StateNodeSpec[Any, ContextT](
|
|
874
|
+
coerce_to_runnable(error_handler, name=handler_node_name, trace=False), # type: ignore[arg-type]
|
|
875
|
+
metadata=None,
|
|
876
|
+
input_schema=resolved_input_schema,
|
|
877
|
+
retry_policy=None,
|
|
878
|
+
cache_policy=None,
|
|
879
|
+
is_error_handler=True,
|
|
880
|
+
)
|
|
881
|
+
|
|
882
|
+
if input_schema is not None:
|
|
883
|
+
self.nodes[node] = StateNodeSpec[NodeInputT, ContextT](
|
|
884
|
+
coerce_to_runnable(action, name=node, trace=False), # type: ignore[arg-type]
|
|
885
|
+
metadata,
|
|
886
|
+
input_schema=input_schema,
|
|
887
|
+
retry_policy=retry_policy,
|
|
888
|
+
cache_policy=cache_policy,
|
|
889
|
+
error_handler_node=handler_node_name,
|
|
890
|
+
ends=ends,
|
|
891
|
+
defer=defer,
|
|
892
|
+
timeout=timeout,
|
|
893
|
+
trace_policy=trace_policy,
|
|
894
|
+
)
|
|
895
|
+
elif inferred_input_schema is not None:
|
|
896
|
+
self.nodes[node] = StateNodeSpec(
|
|
897
|
+
coerce_to_runnable(action, name=node, trace=False), # type: ignore[arg-type]
|
|
898
|
+
metadata,
|
|
899
|
+
input_schema=inferred_input_schema,
|
|
900
|
+
retry_policy=retry_policy,
|
|
901
|
+
cache_policy=cache_policy,
|
|
902
|
+
error_handler_node=handler_node_name,
|
|
903
|
+
ends=ends,
|
|
904
|
+
defer=defer,
|
|
905
|
+
timeout=timeout,
|
|
906
|
+
trace_policy=trace_policy,
|
|
907
|
+
)
|
|
908
|
+
else:
|
|
909
|
+
self.nodes[node] = StateNodeSpec[StateT, ContextT](
|
|
910
|
+
coerce_to_runnable(action, name=node, trace=False), # type: ignore[arg-type]
|
|
911
|
+
metadata,
|
|
912
|
+
input_schema=self.state_schema,
|
|
913
|
+
retry_policy=retry_policy,
|
|
914
|
+
cache_policy=cache_policy,
|
|
915
|
+
error_handler_node=handler_node_name,
|
|
916
|
+
ends=ends,
|
|
917
|
+
defer=defer,
|
|
918
|
+
timeout=timeout,
|
|
919
|
+
trace_policy=trace_policy,
|
|
920
|
+
)
|
|
921
|
+
|
|
922
|
+
input_schema = input_schema or inferred_input_schema
|
|
923
|
+
if input_schema is not None:
|
|
924
|
+
self._add_schema(input_schema)
|
|
925
|
+
|
|
926
|
+
return self
|
|
927
|
+
|
|
928
|
+
def add_edge(self, start_key: str | list[str], end_key: str) -> Self:
|
|
929
|
+
"""Add a directed edge from the start node (or list of start nodes) to the end node.
|
|
930
|
+
|
|
931
|
+
When a single start node is provided, the graph will wait for that node to complete
|
|
932
|
+
before executing the end node. When multiple start nodes are provided,
|
|
933
|
+
the graph will wait for ALL of the start nodes to complete before executing the end node.
|
|
934
|
+
|
|
935
|
+
Args:
|
|
936
|
+
start_key: The key(s) of the start node(s) of the edge.
|
|
937
|
+
end_key: The key of the end node of the edge.
|
|
938
|
+
|
|
939
|
+
Raises:
|
|
940
|
+
ValueError: If the start key is `'END'` or if the start key or end key is not present in the graph.
|
|
941
|
+
|
|
942
|
+
Returns:
|
|
943
|
+
Self: The instance of the `StateGraph`, allowing for method chaining.
|
|
944
|
+
"""
|
|
945
|
+
if self.compiled:
|
|
946
|
+
logger.warning(
|
|
947
|
+
"Adding an edge to a graph that has already been compiled. This will "
|
|
948
|
+
"not be reflected in the compiled graph."
|
|
949
|
+
)
|
|
950
|
+
|
|
951
|
+
if isinstance(start_key, str):
|
|
952
|
+
if start_key == END:
|
|
953
|
+
raise ValueError("END cannot be a start node")
|
|
954
|
+
if end_key == START:
|
|
955
|
+
raise ValueError("START cannot be an end node")
|
|
956
|
+
|
|
957
|
+
# run this validation only for non-StateGraph graphs
|
|
958
|
+
if not hasattr(self, "channels") and start_key in set(
|
|
959
|
+
start for start, _ in self.edges
|
|
960
|
+
):
|
|
961
|
+
raise ValueError(
|
|
962
|
+
f"Already found path for node '{start_key}'.\n"
|
|
963
|
+
"For multiple edges, use StateGraph with an Annotated state key."
|
|
964
|
+
)
|
|
965
|
+
|
|
966
|
+
self.edges.add((start_key, end_key))
|
|
967
|
+
return self
|
|
968
|
+
|
|
969
|
+
for start in start_key:
|
|
970
|
+
if start == END:
|
|
971
|
+
raise ValueError("END cannot be a start node")
|
|
972
|
+
if start not in self.nodes:
|
|
973
|
+
raise ValueError(f"Need to add_node `{start}` first")
|
|
974
|
+
if end_key == START:
|
|
975
|
+
raise ValueError("START cannot be an end node")
|
|
976
|
+
if end_key != END and end_key not in self.nodes:
|
|
977
|
+
raise ValueError(f"Need to add_node `{end_key}` first")
|
|
978
|
+
|
|
979
|
+
self.waiting_edges.add((tuple(start_key), end_key))
|
|
980
|
+
return self
|
|
981
|
+
|
|
982
|
+
def add_conditional_edges(
|
|
983
|
+
self,
|
|
984
|
+
source: str,
|
|
985
|
+
path: Callable[..., Hashable | Sequence[Hashable]]
|
|
986
|
+
| Callable[..., Awaitable[Hashable | Sequence[Hashable]]]
|
|
987
|
+
| Runnable[Any, Hashable | Sequence[Hashable]],
|
|
988
|
+
path_map: dict[Hashable, str] | list[str] | None = None,
|
|
989
|
+
) -> Self:
|
|
990
|
+
"""Add a conditional edge from the starting node to any number of destination nodes.
|
|
991
|
+
|
|
992
|
+
Args:
|
|
993
|
+
source: The starting node. This conditional edge will run when
|
|
994
|
+
exiting this node.
|
|
995
|
+
path: The callable that determines the next node or nodes.
|
|
996
|
+
|
|
997
|
+
If not specifying `path_map` it should return one or more nodes.
|
|
998
|
+
|
|
999
|
+
If it returns `'END'`, the graph will stop execution.
|
|
1000
|
+
path_map: Optional mapping of paths to node names.
|
|
1001
|
+
|
|
1002
|
+
If omitted the paths returned by `path` should be node names.
|
|
1003
|
+
|
|
1004
|
+
Returns:
|
|
1005
|
+
Self: The instance of the graph, allowing for method chaining.
|
|
1006
|
+
|
|
1007
|
+
!!! warning
|
|
1008
|
+
Without type hints on the `path` function's return value (e.g., `-> Literal["foo", "__end__"]:`)
|
|
1009
|
+
or a path_map, the graph visualization assumes the edge could transition to any node in the graph.
|
|
1010
|
+
|
|
1011
|
+
"""
|
|
1012
|
+
if self.compiled:
|
|
1013
|
+
logger.warning(
|
|
1014
|
+
"Adding an edge to a graph that has already been compiled. This will "
|
|
1015
|
+
"not be reflected in the compiled graph."
|
|
1016
|
+
)
|
|
1017
|
+
|
|
1018
|
+
# find a name for the condition
|
|
1019
|
+
path = coerce_to_runnable(path, name=None, trace=True)
|
|
1020
|
+
name = path.name or "condition"
|
|
1021
|
+
# validate the condition
|
|
1022
|
+
if name in self.branches[source]:
|
|
1023
|
+
raise ValueError(
|
|
1024
|
+
f"Branch with name `{path.name}` already exists for node `{source}`"
|
|
1025
|
+
)
|
|
1026
|
+
# save it
|
|
1027
|
+
self.branches[source][name] = BranchSpec.from_path(path, path_map, True)
|
|
1028
|
+
if schema := self.branches[source][name].input_schema:
|
|
1029
|
+
self._add_schema(schema)
|
|
1030
|
+
return self
|
|
1031
|
+
|
|
1032
|
+
def add_sequence(
|
|
1033
|
+
self,
|
|
1034
|
+
nodes: Sequence[
|
|
1035
|
+
StateNode[NodeInputT, ContextT]
|
|
1036
|
+
| tuple[str, StateNode[NodeInputT, ContextT]]
|
|
1037
|
+
],
|
|
1038
|
+
) -> Self:
|
|
1039
|
+
"""Add a sequence of nodes that will be executed in the provided order.
|
|
1040
|
+
|
|
1041
|
+
Args:
|
|
1042
|
+
nodes: A sequence of `StateNode` (callables that accept a `state` arg) or `(name, StateNode)` tuples.
|
|
1043
|
+
|
|
1044
|
+
If no names are provided, the name will be inferred from the node object (e.g. a `Runnable` or a `Callable` name).
|
|
1045
|
+
|
|
1046
|
+
Each node will be executed in the order provided.
|
|
1047
|
+
|
|
1048
|
+
Raises:
|
|
1049
|
+
ValueError: If the sequence is empty.
|
|
1050
|
+
ValueError: If the sequence contains duplicate node names.
|
|
1051
|
+
|
|
1052
|
+
Returns:
|
|
1053
|
+
Self: The instance of the `StateGraph`, allowing for method chaining.
|
|
1054
|
+
"""
|
|
1055
|
+
if len(nodes) < 1:
|
|
1056
|
+
raise ValueError("Sequence requires at least one node.")
|
|
1057
|
+
|
|
1058
|
+
previous_name: str | None = None
|
|
1059
|
+
for node in nodes:
|
|
1060
|
+
if isinstance(node, tuple) and len(node) == 2:
|
|
1061
|
+
name, node = node
|
|
1062
|
+
else:
|
|
1063
|
+
name = _get_node_name(node)
|
|
1064
|
+
|
|
1065
|
+
if name in self.nodes:
|
|
1066
|
+
raise ValueError(
|
|
1067
|
+
f"Node names must be unique: node with the name '{name}' already exists. "
|
|
1068
|
+
"If you need to use two different runnables/callables with the same name (for example, using `lambda`), please provide them as tuples (name, runnable/callable)."
|
|
1069
|
+
)
|
|
1070
|
+
|
|
1071
|
+
self.add_node(name, node)
|
|
1072
|
+
if previous_name is not None:
|
|
1073
|
+
self.add_edge(previous_name, name)
|
|
1074
|
+
|
|
1075
|
+
previous_name = name
|
|
1076
|
+
|
|
1077
|
+
return self
|
|
1078
|
+
|
|
1079
|
+
def set_entry_point(self, key: str) -> Self:
|
|
1080
|
+
"""Specifies the first node to be called in the graph.
|
|
1081
|
+
|
|
1082
|
+
Equivalent to calling `add_edge(START, key)`.
|
|
1083
|
+
|
|
1084
|
+
Parameters:
|
|
1085
|
+
key (str): The key of the node to set as the entry point.
|
|
1086
|
+
|
|
1087
|
+
Returns:
|
|
1088
|
+
Self: The instance of the graph, allowing for method chaining.
|
|
1089
|
+
"""
|
|
1090
|
+
return self.add_edge(START, key)
|
|
1091
|
+
|
|
1092
|
+
def set_conditional_entry_point(
|
|
1093
|
+
self,
|
|
1094
|
+
path: Callable[..., Hashable | Sequence[Hashable]]
|
|
1095
|
+
| Callable[..., Awaitable[Hashable | Sequence[Hashable]]]
|
|
1096
|
+
| Runnable[Any, Hashable | Sequence[Hashable]],
|
|
1097
|
+
path_map: dict[Hashable, str] | list[str] | None = None,
|
|
1098
|
+
) -> Self:
|
|
1099
|
+
"""Sets a conditional entry point in the graph.
|
|
1100
|
+
|
|
1101
|
+
Args:
|
|
1102
|
+
path: The callable that determines the next node or nodes.
|
|
1103
|
+
|
|
1104
|
+
If not specifying `path_map` it should return one or more nodes.
|
|
1105
|
+
|
|
1106
|
+
If it returns END, the graph will stop execution.
|
|
1107
|
+
path_map: Optional mapping of paths to node names.
|
|
1108
|
+
|
|
1109
|
+
If omitted the paths returned by `path` should be node names.
|
|
1110
|
+
|
|
1111
|
+
Returns:
|
|
1112
|
+
Self: The instance of the graph, allowing for method chaining.
|
|
1113
|
+
"""
|
|
1114
|
+
return self.add_conditional_edges(START, path, path_map)
|
|
1115
|
+
|
|
1116
|
+
def set_finish_point(self, key: str) -> Self:
|
|
1117
|
+
"""Marks a node as a finish point of the graph.
|
|
1118
|
+
|
|
1119
|
+
If the graph reaches this node, it will cease execution.
|
|
1120
|
+
|
|
1121
|
+
Parameters:
|
|
1122
|
+
key (str): The key of the node to set as the finish point.
|
|
1123
|
+
|
|
1124
|
+
Returns:
|
|
1125
|
+
Self: The instance of the graph, allowing for method chaining.
|
|
1126
|
+
"""
|
|
1127
|
+
return self.add_edge(key, END)
|
|
1128
|
+
|
|
1129
|
+
def validate(self, interrupt: Sequence[str] | None = None) -> Self:
|
|
1130
|
+
# assemble sources
|
|
1131
|
+
all_sources = {src for src, _ in self._all_edges}
|
|
1132
|
+
for start, branches in self.branches.items():
|
|
1133
|
+
all_sources.add(start)
|
|
1134
|
+
for name, spec in self.nodes.items():
|
|
1135
|
+
if spec.ends:
|
|
1136
|
+
all_sources.add(name)
|
|
1137
|
+
# validate sources
|
|
1138
|
+
for source in all_sources:
|
|
1139
|
+
if source not in self.nodes and source != START:
|
|
1140
|
+
raise ValueError(f"Found edge starting at unknown node '{source}'")
|
|
1141
|
+
|
|
1142
|
+
if START not in all_sources:
|
|
1143
|
+
raise ValueError(
|
|
1144
|
+
"Graph must have an entrypoint: add at least one edge from START to another node"
|
|
1145
|
+
)
|
|
1146
|
+
|
|
1147
|
+
# assemble targets
|
|
1148
|
+
all_targets = {end for _, end in self._all_edges}
|
|
1149
|
+
for start, branches in self.branches.items():
|
|
1150
|
+
for cond, branch in branches.items():
|
|
1151
|
+
if branch.ends is not None:
|
|
1152
|
+
for end in branch.ends.values():
|
|
1153
|
+
if end not in self.nodes and end != END:
|
|
1154
|
+
raise ValueError(
|
|
1155
|
+
f"At '{start}' node, '{cond}' branch found unknown target '{end}'"
|
|
1156
|
+
)
|
|
1157
|
+
all_targets.add(end)
|
|
1158
|
+
else:
|
|
1159
|
+
all_targets.add(END)
|
|
1160
|
+
for node in self.nodes:
|
|
1161
|
+
if node != start:
|
|
1162
|
+
all_targets.add(node)
|
|
1163
|
+
for name, spec in self.nodes.items():
|
|
1164
|
+
if spec.ends:
|
|
1165
|
+
all_targets.update(spec.ends)
|
|
1166
|
+
for target in all_targets:
|
|
1167
|
+
if target not in self.nodes and target != END:
|
|
1168
|
+
raise ValueError(f"Found edge ending at unknown node `{target}`")
|
|
1169
|
+
# validate interrupts
|
|
1170
|
+
if interrupt:
|
|
1171
|
+
for node in interrupt:
|
|
1172
|
+
if node not in self.nodes:
|
|
1173
|
+
raise ValueError(f"Interrupt node `{node}` not found")
|
|
1174
|
+
self.compiled = True
|
|
1175
|
+
return self
|
|
1176
|
+
|
|
1177
|
+
def compile(
|
|
1178
|
+
self,
|
|
1179
|
+
checkpointer: Checkpointer = None,
|
|
1180
|
+
*,
|
|
1181
|
+
cache: BaseCache | None = None,
|
|
1182
|
+
store: BaseStore | None = None,
|
|
1183
|
+
interrupt_before: All | list[str] | None = None,
|
|
1184
|
+
interrupt_after: All | list[str] | None = None,
|
|
1185
|
+
debug: bool = False,
|
|
1186
|
+
name: str | None = None,
|
|
1187
|
+
transformers: Sequence[Callable[[tuple[str, ...]], Any]] | None = None,
|
|
1188
|
+
) -> CompiledStateGraph[StateT, ContextT, InputT, OutputT]:
|
|
1189
|
+
"""Compiles the `StateGraph` into a `CompiledStateGraph` object.
|
|
1190
|
+
|
|
1191
|
+
The compiled graph implements the `Runnable` interface and can be invoked,
|
|
1192
|
+
streamed, batched, and run asynchronously.
|
|
1193
|
+
|
|
1194
|
+
Args:
|
|
1195
|
+
checkpointer: A checkpoint saver object or flag.
|
|
1196
|
+
|
|
1197
|
+
If provided, this `Checkpointer` serves as a fully versioned "short-term memory" for the graph,
|
|
1198
|
+
allowing it to be paused, resumed, and replayed from any point.
|
|
1199
|
+
|
|
1200
|
+
If `None`, it may inherit the parent graph's checkpointer when used as a subgraph.
|
|
1201
|
+
|
|
1202
|
+
If `False`, it will not use or inherit any checkpointer.
|
|
1203
|
+
|
|
1204
|
+
**Important**: When a checkpointer is enabled, you should pass a `thread_id`
|
|
1205
|
+
in the config when invoking the graph:
|
|
1206
|
+
|
|
1207
|
+
```python
|
|
1208
|
+
config = {"configurable": {"thread_id": "my-thread"}}
|
|
1209
|
+
graph.invoke(inputs, config)
|
|
1210
|
+
```
|
|
1211
|
+
|
|
1212
|
+
The `thread_id` is the key used to store and retrieve checkpoints. Use a
|
|
1213
|
+
unique ID for independent runs, or reuse the same ID to accumulate state
|
|
1214
|
+
across invocations (e.g., for conversation memory).
|
|
1215
|
+
|
|
1216
|
+
interrupt_before: An optional list of node names to interrupt before.
|
|
1217
|
+
interrupt_after: An optional list of node names to interrupt after.
|
|
1218
|
+
debug: A flag indicating whether to enable debug mode.
|
|
1219
|
+
name: The name to use for the compiled graph.
|
|
1220
|
+
transformers: Optional sequence of `StreamTransformer` classes or
|
|
1221
|
+
configured factories. Classes and factories are instantiated
|
|
1222
|
+
per run whenever `stream_events(version="v3")` / `astream_events(version="v3")` is called and are
|
|
1223
|
+
propagated to subgraph scopes. Custom factories should follow
|
|
1224
|
+
the standard `StreamTransformer` constructor shape by
|
|
1225
|
+
accepting `scope` as their first argument. Appended after the
|
|
1226
|
+
built-in stream transformers.
|
|
1227
|
+
|
|
1228
|
+
Returns:
|
|
1229
|
+
CompiledStateGraph: The compiled `StateGraph`.
|
|
1230
|
+
"""
|
|
1231
|
+
checkpointer = ensure_valid_checkpointer(checkpointer)
|
|
1232
|
+
|
|
1233
|
+
serde_allowlist: set[tuple[str, ...]] | None = None
|
|
1234
|
+
if _serde.STRICT_MSGPACK_ENABLED:
|
|
1235
|
+
schema_types: list[type[Any]] = [
|
|
1236
|
+
self.state_schema,
|
|
1237
|
+
self.input_schema,
|
|
1238
|
+
self.output_schema,
|
|
1239
|
+
]
|
|
1240
|
+
if self.context_schema is not None:
|
|
1241
|
+
schema_types.append(self.context_schema)
|
|
1242
|
+
for node in self.nodes.values():
|
|
1243
|
+
schema_types.append(node.input_schema)
|
|
1244
|
+
for branches in self.branches.values():
|
|
1245
|
+
for branch in branches.values():
|
|
1246
|
+
if branch.input_schema is not None:
|
|
1247
|
+
schema_types.append(branch.input_schema)
|
|
1248
|
+
serde_allowlist = _serde.build_serde_allowlist(
|
|
1249
|
+
schemas=schema_types,
|
|
1250
|
+
channels=self.channels,
|
|
1251
|
+
)
|
|
1252
|
+
checkpointer = _serde.apply_checkpointer_allowlist(
|
|
1253
|
+
checkpointer, serde_allowlist
|
|
1254
|
+
)
|
|
1255
|
+
|
|
1256
|
+
# assign default values
|
|
1257
|
+
interrupt_before = interrupt_before or []
|
|
1258
|
+
interrupt_after = interrupt_after or []
|
|
1259
|
+
|
|
1260
|
+
# validate the graph
|
|
1261
|
+
self.validate(
|
|
1262
|
+
interrupt=(
|
|
1263
|
+
(interrupt_before if interrupt_before != "*" else []) + interrupt_after
|
|
1264
|
+
if interrupt_after != "*"
|
|
1265
|
+
else []
|
|
1266
|
+
)
|
|
1267
|
+
)
|
|
1268
|
+
|
|
1269
|
+
# prepare output channels
|
|
1270
|
+
output_channels = (
|
|
1271
|
+
"__root__"
|
|
1272
|
+
if len(self.schemas[self.output_schema]) == 1
|
|
1273
|
+
and "__root__" in self.schemas[self.output_schema]
|
|
1274
|
+
else [
|
|
1275
|
+
key
|
|
1276
|
+
for key, val in self.schemas[self.output_schema].items()
|
|
1277
|
+
if not is_managed_value(val)
|
|
1278
|
+
]
|
|
1279
|
+
)
|
|
1280
|
+
stream_channels = (
|
|
1281
|
+
"__root__"
|
|
1282
|
+
if len(self.channels) == 1 and "__root__" in self.channels
|
|
1283
|
+
else [
|
|
1284
|
+
key for key, val in self.channels.items() if not is_managed_value(val)
|
|
1285
|
+
]
|
|
1286
|
+
)
|
|
1287
|
+
# Apply builder defaults to node specs. Per-node values always win.
|
|
1288
|
+
# Error-handler routing and cache_policy are only assigned to regular
|
|
1289
|
+
# nodes. Retry and timeout defaults also apply to error-handler nodes.
|
|
1290
|
+
defaults = self._node_defaults
|
|
1291
|
+
default_handler_name: str | None = None
|
|
1292
|
+
if defaults.error_handler is not None:
|
|
1293
|
+
if _DEFAULT_ERROR_HANDLER_NODE in self.nodes:
|
|
1294
|
+
raise ValueError(
|
|
1295
|
+
f"Auto-generated default error handler node "
|
|
1296
|
+
f"`{_DEFAULT_ERROR_HANDLER_NODE}` already exists."
|
|
1297
|
+
)
|
|
1298
|
+
default_handler_name = _DEFAULT_ERROR_HANDLER_NODE
|
|
1299
|
+
self.nodes[default_handler_name] = StateNodeSpec[Any, ContextT](
|
|
1300
|
+
coerce_to_runnable(
|
|
1301
|
+
defaults.error_handler, # type: ignore[arg-type]
|
|
1302
|
+
name=default_handler_name,
|
|
1303
|
+
trace=False,
|
|
1304
|
+
),
|
|
1305
|
+
metadata=None,
|
|
1306
|
+
input_schema=self.state_schema,
|
|
1307
|
+
retry_policy=None,
|
|
1308
|
+
cache_policy=None,
|
|
1309
|
+
is_error_handler=True,
|
|
1310
|
+
)
|
|
1311
|
+
|
|
1312
|
+
# Apply builder defaults to node specs. Per-node values always win.
|
|
1313
|
+
for spec in self.nodes.values():
|
|
1314
|
+
# error_handler: regular nodes only — handlers must never
|
|
1315
|
+
# catch themselves or other handlers.
|
|
1316
|
+
if (
|
|
1317
|
+
not spec.is_error_handler
|
|
1318
|
+
and default_handler_name is not None
|
|
1319
|
+
and spec.error_handler_node is None
|
|
1320
|
+
):
|
|
1321
|
+
spec.error_handler_node = default_handler_name
|
|
1322
|
+
# retry: all nodes — handlers should be retried on transient
|
|
1323
|
+
# failures just like regular nodes.
|
|
1324
|
+
if defaults.retry_policy is not None and spec.retry_policy is None:
|
|
1325
|
+
spec.retry_policy = defaults.retry_policy
|
|
1326
|
+
# cache: regular nodes only — caching an error-handler result
|
|
1327
|
+
# is unsafe because the input (failed-node state) may differ
|
|
1328
|
+
# across failures even when the cache key matches.
|
|
1329
|
+
if (
|
|
1330
|
+
not spec.is_error_handler
|
|
1331
|
+
and defaults.cache_policy is not None
|
|
1332
|
+
and spec.cache_policy is None
|
|
1333
|
+
):
|
|
1334
|
+
spec.cache_policy = defaults.cache_policy
|
|
1335
|
+
# timeout: all nodes — a stuck handler should be cancelled the
|
|
1336
|
+
# same way a stuck regular node would be.
|
|
1337
|
+
if defaults.timeout is not None and spec.timeout is None:
|
|
1338
|
+
spec.timeout = defaults.timeout
|
|
1339
|
+
|
|
1340
|
+
node_error_handler_map = {
|
|
1341
|
+
node_name: spec.error_handler_node
|
|
1342
|
+
for node_name, spec in self.nodes.items()
|
|
1343
|
+
if not spec.is_error_handler and spec.error_handler_node is not None
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
compiled = CompiledStateGraph[StateT, ContextT, InputT, OutputT](
|
|
1347
|
+
builder=self,
|
|
1348
|
+
schema_to_mapper={},
|
|
1349
|
+
context_schema=self.context_schema,
|
|
1350
|
+
nodes={},
|
|
1351
|
+
channels={
|
|
1352
|
+
**self.channels,
|
|
1353
|
+
**self.managed,
|
|
1354
|
+
START: EphemeralValue(self.input_schema),
|
|
1355
|
+
},
|
|
1356
|
+
input_channels=START,
|
|
1357
|
+
stream_mode="updates",
|
|
1358
|
+
output_channels=output_channels,
|
|
1359
|
+
stream_channels=stream_channels,
|
|
1360
|
+
checkpointer=checkpointer,
|
|
1361
|
+
interrupt_before_nodes=interrupt_before,
|
|
1362
|
+
interrupt_after_nodes=interrupt_after,
|
|
1363
|
+
auto_validate=False,
|
|
1364
|
+
debug=debug,
|
|
1365
|
+
store=store,
|
|
1366
|
+
cache=cache,
|
|
1367
|
+
node_error_handler_map=node_error_handler_map,
|
|
1368
|
+
name=name or "CocoGraph",
|
|
1369
|
+
stream_transformers=transformers,
|
|
1370
|
+
)
|
|
1371
|
+
compiled._serde_allowlist = serde_allowlist
|
|
1372
|
+
|
|
1373
|
+
compiled.attach_node(START, None)
|
|
1374
|
+
for key, node in self.nodes.items():
|
|
1375
|
+
compiled.attach_node(key, node)
|
|
1376
|
+
|
|
1377
|
+
# Record output/state mappers for v2 stream coercion (pydantic/dataclass only)
|
|
1378
|
+
compiled._output_mapper = _pick_mapper(
|
|
1379
|
+
list(output_channels)
|
|
1380
|
+
if isinstance(output_channels, list)
|
|
1381
|
+
else [output_channels],
|
|
1382
|
+
self.output_schema,
|
|
1383
|
+
)
|
|
1384
|
+
compiled._state_mapper = _pick_mapper(
|
|
1385
|
+
list(stream_channels)
|
|
1386
|
+
if isinstance(stream_channels, list)
|
|
1387
|
+
else [stream_channels],
|
|
1388
|
+
self.state_schema,
|
|
1389
|
+
)
|
|
1390
|
+
|
|
1391
|
+
for start, end in self.edges:
|
|
1392
|
+
compiled.attach_edge(start, end)
|
|
1393
|
+
|
|
1394
|
+
for starts, end in self.waiting_edges:
|
|
1395
|
+
compiled.attach_edge(starts, end)
|
|
1396
|
+
|
|
1397
|
+
for start, branches in self.branches.items():
|
|
1398
|
+
for name, branch in branches.items():
|
|
1399
|
+
compiled.attach_branch(start, name, branch)
|
|
1400
|
+
|
|
1401
|
+
return compiled.validate()
|
|
1402
|
+
|
|
1403
|
+
|
|
1404
|
+
class CompiledStateGraph(
|
|
1405
|
+
Pregel[StateT, ContextT, InputT, OutputT],
|
|
1406
|
+
Generic[StateT, ContextT, InputT, OutputT],
|
|
1407
|
+
):
|
|
1408
|
+
builder: StateGraph[StateT, ContextT, InputT, OutputT]
|
|
1409
|
+
schema_to_mapper: dict[type[Any], Callable[[Any], Any] | None]
|
|
1410
|
+
_output_mapper: Callable[[Any], Any] | None
|
|
1411
|
+
_state_mapper: Callable[[Any], Any] | None
|
|
1412
|
+
|
|
1413
|
+
def __init__(
|
|
1414
|
+
self,
|
|
1415
|
+
*,
|
|
1416
|
+
builder: StateGraph[StateT, ContextT, InputT, OutputT],
|
|
1417
|
+
schema_to_mapper: dict[type[Any], Callable[[Any], Any] | None],
|
|
1418
|
+
**kwargs: Any,
|
|
1419
|
+
) -> None:
|
|
1420
|
+
super().__init__(**kwargs)
|
|
1421
|
+
self.builder = builder
|
|
1422
|
+
self.schema_to_mapper = schema_to_mapper
|
|
1423
|
+
|
|
1424
|
+
def get_input_jsonschema(
|
|
1425
|
+
self, config: RunnableConfig | None = None
|
|
1426
|
+
) -> dict[str, Any]:
|
|
1427
|
+
return _get_json_schema(
|
|
1428
|
+
typ=self.builder.input_schema,
|
|
1429
|
+
schemas=self.builder.schemas,
|
|
1430
|
+
channels=self.builder.channels,
|
|
1431
|
+
name=self.get_name("Input"),
|
|
1432
|
+
)
|
|
1433
|
+
|
|
1434
|
+
def get_output_jsonschema(
|
|
1435
|
+
self, config: RunnableConfig | None = None
|
|
1436
|
+
) -> dict[str, Any]:
|
|
1437
|
+
return _get_json_schema(
|
|
1438
|
+
typ=self.builder.output_schema,
|
|
1439
|
+
schemas=self.builder.schemas,
|
|
1440
|
+
channels=self.builder.channels,
|
|
1441
|
+
name=self.get_name("Output"),
|
|
1442
|
+
)
|
|
1443
|
+
|
|
1444
|
+
def attach_node(self, key: str, node: StateNodeSpec[Any, ContextT] | None) -> None:
|
|
1445
|
+
if key == START:
|
|
1446
|
+
output_keys = [
|
|
1447
|
+
k
|
|
1448
|
+
for k, v in self.builder.schemas[self.builder.input_schema].items()
|
|
1449
|
+
if not is_managed_value(v)
|
|
1450
|
+
]
|
|
1451
|
+
else:
|
|
1452
|
+
output_keys = list(self.builder.channels) + [
|
|
1453
|
+
k for k, v in self.builder.managed.items()
|
|
1454
|
+
]
|
|
1455
|
+
|
|
1456
|
+
def _get_updates(
|
|
1457
|
+
input: None | dict | Any,
|
|
1458
|
+
) -> Sequence[tuple[str, Any]] | None:
|
|
1459
|
+
if input is None:
|
|
1460
|
+
return None
|
|
1461
|
+
elif isinstance(input, dict):
|
|
1462
|
+
return [(k, v) for k, v in input.items() if k in output_keys]
|
|
1463
|
+
elif isinstance(input, Command):
|
|
1464
|
+
if input.graph == Command.PARENT:
|
|
1465
|
+
return None
|
|
1466
|
+
return [
|
|
1467
|
+
(k, v) for k, v in input._update_as_tuples() if k in output_keys
|
|
1468
|
+
]
|
|
1469
|
+
elif (
|
|
1470
|
+
isinstance(input, (list, tuple))
|
|
1471
|
+
and input
|
|
1472
|
+
and any(isinstance(i, Command) for i in input)
|
|
1473
|
+
):
|
|
1474
|
+
updates: list[tuple[str, Any]] = []
|
|
1475
|
+
for i in input:
|
|
1476
|
+
if isinstance(i, Command):
|
|
1477
|
+
if i.graph == Command.PARENT:
|
|
1478
|
+
continue
|
|
1479
|
+
updates.extend(
|
|
1480
|
+
(k, v) for k, v in i._update_as_tuples() if k in output_keys
|
|
1481
|
+
)
|
|
1482
|
+
else:
|
|
1483
|
+
updates.extend(_get_updates(i) or ())
|
|
1484
|
+
return updates
|
|
1485
|
+
elif (t := type(input)) and get_cached_annotated_keys(t):
|
|
1486
|
+
return get_update_as_tuples(input, output_keys)
|
|
1487
|
+
else:
|
|
1488
|
+
msg = create_error_message(
|
|
1489
|
+
message=f"Expected dict, got {input}",
|
|
1490
|
+
error_code=ErrorCode.INVALID_GRAPH_NODE_RETURN_VALUE,
|
|
1491
|
+
)
|
|
1492
|
+
raise InvalidUpdateError(msg)
|
|
1493
|
+
|
|
1494
|
+
# state updaters
|
|
1495
|
+
write_entries: tuple[ChannelWriteEntry | ChannelWriteTupleEntry, ...] = (
|
|
1496
|
+
ChannelWriteTupleEntry(
|
|
1497
|
+
mapper=_get_root if output_keys == ["__root__"] else _get_updates
|
|
1498
|
+
),
|
|
1499
|
+
ChannelWriteTupleEntry(
|
|
1500
|
+
mapper=_control_branch,
|
|
1501
|
+
static=_control_static(node.ends)
|
|
1502
|
+
if node is not None and node.ends is not None
|
|
1503
|
+
else None,
|
|
1504
|
+
),
|
|
1505
|
+
)
|
|
1506
|
+
|
|
1507
|
+
# add node and output channel
|
|
1508
|
+
if key == START:
|
|
1509
|
+
self.nodes[key] = PregelNode(
|
|
1510
|
+
tags=[TAG_HIDDEN],
|
|
1511
|
+
triggers=[START],
|
|
1512
|
+
channels=START,
|
|
1513
|
+
writers=[ChannelWrite(write_entries)],
|
|
1514
|
+
)
|
|
1515
|
+
elif node is not None:
|
|
1516
|
+
input_schema = node.input_schema if node else self.builder.state_schema
|
|
1517
|
+
input_channels = list(self.builder.schemas[input_schema])
|
|
1518
|
+
is_single_input = len(input_channels) == 1 and "__root__" in input_channels
|
|
1519
|
+
if input_schema in self.schema_to_mapper:
|
|
1520
|
+
mapper = self.schema_to_mapper[input_schema]
|
|
1521
|
+
else:
|
|
1522
|
+
mapper = _pick_mapper(input_channels, input_schema)
|
|
1523
|
+
self.schema_to_mapper[input_schema] = mapper
|
|
1524
|
+
|
|
1525
|
+
branch_channel = _CHANNEL_BRANCH_TO.format(key)
|
|
1526
|
+
self.channels[branch_channel] = (
|
|
1527
|
+
LastValueAfterFinish(Any)
|
|
1528
|
+
if node.defer
|
|
1529
|
+
else EphemeralValue(Any, guard=False)
|
|
1530
|
+
)
|
|
1531
|
+
self.nodes[key] = PregelNode(
|
|
1532
|
+
triggers=[branch_channel],
|
|
1533
|
+
# read state keys and managed values
|
|
1534
|
+
channels=("__root__" if is_single_input else input_channels),
|
|
1535
|
+
# coerce state dict to schema class (eg. pydantic model)
|
|
1536
|
+
mapper=mapper,
|
|
1537
|
+
# publish to state keys
|
|
1538
|
+
writers=[ChannelWrite(write_entries)],
|
|
1539
|
+
metadata=node.metadata,
|
|
1540
|
+
retry_policy=node.retry_policy,
|
|
1541
|
+
cache_policy=node.cache_policy,
|
|
1542
|
+
is_error_handler=node.is_error_handler,
|
|
1543
|
+
error_handler_node=node.error_handler_node,
|
|
1544
|
+
bound=node.runnable, # type: ignore[arg-type]
|
|
1545
|
+
timeout=node.timeout,
|
|
1546
|
+
trace_policy=node.trace_policy,
|
|
1547
|
+
)
|
|
1548
|
+
else:
|
|
1549
|
+
raise RuntimeError
|
|
1550
|
+
|
|
1551
|
+
def attach_edge(self, starts: str | Sequence[str], end: str) -> None:
|
|
1552
|
+
if isinstance(starts, str):
|
|
1553
|
+
# subscribe to start channel
|
|
1554
|
+
if end != END:
|
|
1555
|
+
self.nodes[starts].writers.append(
|
|
1556
|
+
ChannelWrite(
|
|
1557
|
+
(ChannelWriteEntry(_CHANNEL_BRANCH_TO.format(end), None),)
|
|
1558
|
+
)
|
|
1559
|
+
)
|
|
1560
|
+
elif end != END:
|
|
1561
|
+
channel_name = f"join:{'+'.join(starts)}:{end}"
|
|
1562
|
+
# register channel
|
|
1563
|
+
if self.builder.nodes[end].defer:
|
|
1564
|
+
self.channels[channel_name] = NamedBarrierValueAfterFinish(
|
|
1565
|
+
str, set(starts)
|
|
1566
|
+
)
|
|
1567
|
+
else:
|
|
1568
|
+
self.channels[channel_name] = NamedBarrierValue(str, set(starts))
|
|
1569
|
+
# subscribe to channel
|
|
1570
|
+
self.nodes[end].triggers.append(channel_name)
|
|
1571
|
+
# publish to channel
|
|
1572
|
+
for start in starts:
|
|
1573
|
+
self.nodes[start].writers.append(
|
|
1574
|
+
ChannelWrite((ChannelWriteEntry(channel_name, start),))
|
|
1575
|
+
)
|
|
1576
|
+
|
|
1577
|
+
def attach_branch(
|
|
1578
|
+
self, start: str, name: str, branch: BranchSpec, *, with_reader: bool = True
|
|
1579
|
+
) -> None:
|
|
1580
|
+
def get_writes(
|
|
1581
|
+
packets: Sequence[str | Send], static: bool = False
|
|
1582
|
+
) -> Sequence[ChannelWriteEntry | Send]:
|
|
1583
|
+
writes = [
|
|
1584
|
+
(
|
|
1585
|
+
ChannelWriteEntry(
|
|
1586
|
+
p if p == END else _CHANNEL_BRANCH_TO.format(p), None
|
|
1587
|
+
)
|
|
1588
|
+
if not isinstance(p, Send)
|
|
1589
|
+
else p
|
|
1590
|
+
)
|
|
1591
|
+
for p in packets
|
|
1592
|
+
if (True if static else p != END)
|
|
1593
|
+
]
|
|
1594
|
+
if not writes:
|
|
1595
|
+
return []
|
|
1596
|
+
return writes
|
|
1597
|
+
|
|
1598
|
+
if with_reader:
|
|
1599
|
+
# get schema
|
|
1600
|
+
schema = branch.input_schema or (
|
|
1601
|
+
self.builder.nodes[start].input_schema
|
|
1602
|
+
if start in self.builder.nodes
|
|
1603
|
+
else self.builder.state_schema
|
|
1604
|
+
)
|
|
1605
|
+
channels = list(self.builder.schemas[schema])
|
|
1606
|
+
# get mapper
|
|
1607
|
+
if schema in self.schema_to_mapper:
|
|
1608
|
+
mapper = self.schema_to_mapper[schema]
|
|
1609
|
+
else:
|
|
1610
|
+
mapper = _pick_mapper(channels, schema)
|
|
1611
|
+
self.schema_to_mapper[schema] = mapper
|
|
1612
|
+
# create reader
|
|
1613
|
+
reader: Callable[[RunnableConfig], Any] | None = partial(
|
|
1614
|
+
ChannelRead.do_read,
|
|
1615
|
+
select=channels[0] if channels == ["__root__"] else channels,
|
|
1616
|
+
fresh=True,
|
|
1617
|
+
# coerce state dict to schema class (eg. pydantic model)
|
|
1618
|
+
mapper=mapper,
|
|
1619
|
+
)
|
|
1620
|
+
else:
|
|
1621
|
+
reader = None
|
|
1622
|
+
|
|
1623
|
+
# attach branch publisher
|
|
1624
|
+
self.nodes[start].writers.append(branch.run(get_writes, reader))
|
|
1625
|
+
|
|
1626
|
+
def _migrate_checkpoint(self, checkpoint: Checkpoint) -> None:
|
|
1627
|
+
"""Migrate a checkpoint to new channel layout."""
|
|
1628
|
+
super()._migrate_checkpoint(checkpoint)
|
|
1629
|
+
|
|
1630
|
+
values = checkpoint["channel_values"]
|
|
1631
|
+
versions = checkpoint["channel_versions"]
|
|
1632
|
+
seen = checkpoint["versions_seen"]
|
|
1633
|
+
|
|
1634
|
+
# empty checkpoints do not need migration
|
|
1635
|
+
if not versions:
|
|
1636
|
+
return
|
|
1637
|
+
|
|
1638
|
+
# current version
|
|
1639
|
+
if checkpoint["v"] >= 3:
|
|
1640
|
+
return
|
|
1641
|
+
|
|
1642
|
+
# Migrate from start:node to branch:to:node
|
|
1643
|
+
for k in list(versions):
|
|
1644
|
+
if k.startswith("start:"):
|
|
1645
|
+
# confirm node is present
|
|
1646
|
+
node = k.split(":")[1]
|
|
1647
|
+
if node not in self.nodes:
|
|
1648
|
+
continue
|
|
1649
|
+
# get next version
|
|
1650
|
+
new_k = f"branch:to:{node}"
|
|
1651
|
+
new_v = (
|
|
1652
|
+
max(versions[new_k], versions.pop(k))
|
|
1653
|
+
if new_k in versions
|
|
1654
|
+
else versions.pop(k)
|
|
1655
|
+
)
|
|
1656
|
+
# update seen
|
|
1657
|
+
for ss in (seen.get(node, {}), seen.get(INTERRUPT, {})):
|
|
1658
|
+
if k in ss:
|
|
1659
|
+
s = ss.pop(k)
|
|
1660
|
+
if new_k in ss:
|
|
1661
|
+
ss[new_k] = max(s, ss[new_k])
|
|
1662
|
+
else:
|
|
1663
|
+
ss[new_k] = s
|
|
1664
|
+
# update value
|
|
1665
|
+
if new_k not in values and k in values:
|
|
1666
|
+
values[new_k] = values.pop(k)
|
|
1667
|
+
# update version
|
|
1668
|
+
versions[new_k] = new_v
|
|
1669
|
+
|
|
1670
|
+
# Migrate from branch:source:condition:node to branch:to:node
|
|
1671
|
+
for k in list(versions):
|
|
1672
|
+
if k.startswith("branch:") and k.count(":") == 3:
|
|
1673
|
+
# confirm node is present
|
|
1674
|
+
node = k.split(":")[-1]
|
|
1675
|
+
if node not in self.nodes:
|
|
1676
|
+
continue
|
|
1677
|
+
# get next version
|
|
1678
|
+
new_k = f"branch:to:{node}"
|
|
1679
|
+
new_v = (
|
|
1680
|
+
max(versions[new_k], versions.pop(k))
|
|
1681
|
+
if new_k in versions
|
|
1682
|
+
else versions.pop(k)
|
|
1683
|
+
)
|
|
1684
|
+
# update seen
|
|
1685
|
+
for ss in (seen.get(node, {}), seen.get(INTERRUPT, {})):
|
|
1686
|
+
if k in ss:
|
|
1687
|
+
s = ss.pop(k)
|
|
1688
|
+
if new_k in ss:
|
|
1689
|
+
ss[new_k] = max(s, ss[new_k])
|
|
1690
|
+
else:
|
|
1691
|
+
ss[new_k] = s
|
|
1692
|
+
# update value
|
|
1693
|
+
if new_k not in values and k in values:
|
|
1694
|
+
values[new_k] = values.pop(k)
|
|
1695
|
+
# update version
|
|
1696
|
+
versions[new_k] = new_v
|
|
1697
|
+
|
|
1698
|
+
if not set(self.nodes).isdisjoint(versions):
|
|
1699
|
+
# Migrate from "node" to "branch:to:node"
|
|
1700
|
+
source_to_target = defaultdict(list)
|
|
1701
|
+
for start, end in self.builder.edges:
|
|
1702
|
+
if start != START and end != END:
|
|
1703
|
+
source_to_target[start].append(end)
|
|
1704
|
+
for k in list(versions):
|
|
1705
|
+
if k == START:
|
|
1706
|
+
continue
|
|
1707
|
+
if k in self.nodes:
|
|
1708
|
+
v = versions.pop(k)
|
|
1709
|
+
c = values.pop(k, MISSING)
|
|
1710
|
+
for end in source_to_target[k]:
|
|
1711
|
+
# get next version
|
|
1712
|
+
new_k = f"branch:to:{end}"
|
|
1713
|
+
new_v = max(versions[new_k], v) if new_k in versions else v
|
|
1714
|
+
# update seen
|
|
1715
|
+
for ss in (seen.get(end, {}), seen.get(INTERRUPT, {})):
|
|
1716
|
+
if k in ss:
|
|
1717
|
+
s = ss.pop(k)
|
|
1718
|
+
if new_k in ss:
|
|
1719
|
+
ss[new_k] = max(s, ss[new_k])
|
|
1720
|
+
else:
|
|
1721
|
+
ss[new_k] = s
|
|
1722
|
+
# update value
|
|
1723
|
+
if new_k not in values and c is not MISSING:
|
|
1724
|
+
values[new_k] = c
|
|
1725
|
+
# update version
|
|
1726
|
+
versions[new_k] = new_v
|
|
1727
|
+
# pop interrupt seen
|
|
1728
|
+
if INTERRUPT in seen:
|
|
1729
|
+
seen[INTERRUPT].pop(k, MISSING)
|
|
1730
|
+
|
|
1731
|
+
|
|
1732
|
+
def _pick_mapper(
|
|
1733
|
+
state_keys: Sequence[str], schema: type[Any]
|
|
1734
|
+
) -> Callable[[Any], Any] | None:
|
|
1735
|
+
if state_keys == ["__root__"]:
|
|
1736
|
+
return None
|
|
1737
|
+
if isclass(schema) and (issubclass(schema, BaseModel) or is_dataclass(schema)):
|
|
1738
|
+
return partial(_coerce_state, schema)
|
|
1739
|
+
return None
|
|
1740
|
+
|
|
1741
|
+
|
|
1742
|
+
_S = TypeVar("_S")
|
|
1743
|
+
|
|
1744
|
+
|
|
1745
|
+
def _coerce_state(schema: type[_S], input: dict[str, Any]) -> _S:
|
|
1746
|
+
return schema(**input)
|
|
1747
|
+
|
|
1748
|
+
|
|
1749
|
+
def _control_branch(value: Any) -> Sequence[tuple[str, Any]]:
|
|
1750
|
+
if isinstance(value, Send):
|
|
1751
|
+
return ((TASKS, value),)
|
|
1752
|
+
commands: list[Command] = []
|
|
1753
|
+
if isinstance(value, Command):
|
|
1754
|
+
commands.append(value)
|
|
1755
|
+
elif isinstance(value, (list, tuple)):
|
|
1756
|
+
for cmd in value:
|
|
1757
|
+
if isinstance(cmd, Command):
|
|
1758
|
+
commands.append(cmd)
|
|
1759
|
+
rtn: list[tuple[str, Any]] = []
|
|
1760
|
+
for command in commands:
|
|
1761
|
+
if command.graph == Command.PARENT:
|
|
1762
|
+
raise ParentCommand(command)
|
|
1763
|
+
|
|
1764
|
+
goto_targets = (
|
|
1765
|
+
[command.goto] if isinstance(command.goto, (Send, str)) else command.goto
|
|
1766
|
+
)
|
|
1767
|
+
|
|
1768
|
+
for go in goto_targets:
|
|
1769
|
+
if isinstance(go, Send):
|
|
1770
|
+
rtn.append((TASKS, go))
|
|
1771
|
+
elif isinstance(go, str) and go != END:
|
|
1772
|
+
# END is a special case, it's not actually a node in a practical sense
|
|
1773
|
+
# but rather a special terminal node that we don't need to branch to
|
|
1774
|
+
rtn.append((_CHANNEL_BRANCH_TO.format(go), None))
|
|
1775
|
+
return rtn
|
|
1776
|
+
|
|
1777
|
+
|
|
1778
|
+
def _control_static(
|
|
1779
|
+
ends: tuple[str, ...] | dict[str, str],
|
|
1780
|
+
) -> Sequence[tuple[str, Any, str | None]]:
|
|
1781
|
+
if isinstance(ends, dict):
|
|
1782
|
+
return [
|
|
1783
|
+
(k if k == END else _CHANNEL_BRANCH_TO.format(k), None, label)
|
|
1784
|
+
for k, label in ends.items()
|
|
1785
|
+
]
|
|
1786
|
+
else:
|
|
1787
|
+
return [
|
|
1788
|
+
(e if e == END else _CHANNEL_BRANCH_TO.format(e), None, None) for e in ends
|
|
1789
|
+
]
|
|
1790
|
+
|
|
1791
|
+
|
|
1792
|
+
def _get_root(input: Any) -> Sequence[tuple[str, Any]] | None:
|
|
1793
|
+
if isinstance(input, Command):
|
|
1794
|
+
if input.graph == Command.PARENT:
|
|
1795
|
+
return ()
|
|
1796
|
+
return input._update_as_tuples()
|
|
1797
|
+
elif (
|
|
1798
|
+
isinstance(input, (list, tuple))
|
|
1799
|
+
and input
|
|
1800
|
+
and any(isinstance(i, Command) for i in input)
|
|
1801
|
+
):
|
|
1802
|
+
updates: list[tuple[str, Any]] = []
|
|
1803
|
+
for i in input:
|
|
1804
|
+
if isinstance(i, Command):
|
|
1805
|
+
if i.graph == Command.PARENT:
|
|
1806
|
+
continue
|
|
1807
|
+
updates.extend(i._update_as_tuples())
|
|
1808
|
+
else:
|
|
1809
|
+
updates.append(("__root__", i))
|
|
1810
|
+
return updates
|
|
1811
|
+
elif input is not None:
|
|
1812
|
+
return [("__root__", input)]
|
|
1813
|
+
|
|
1814
|
+
|
|
1815
|
+
def _get_channels(
|
|
1816
|
+
schema: type[dict],
|
|
1817
|
+
) -> tuple[dict[str, BaseChannel], dict[str, ManagedValueSpec], dict[str, Any]]:
|
|
1818
|
+
if not hasattr(schema, "__annotations__"):
|
|
1819
|
+
return (
|
|
1820
|
+
{"__root__": _get_channel("__root__", schema, allow_managed=False)},
|
|
1821
|
+
{},
|
|
1822
|
+
{},
|
|
1823
|
+
)
|
|
1824
|
+
|
|
1825
|
+
type_hints = get_type_hints(schema, include_extras=True)
|
|
1826
|
+
all_keys = {
|
|
1827
|
+
name: _get_channel(name, typ)
|
|
1828
|
+
for name, typ in type_hints.items()
|
|
1829
|
+
if name != "__slots__"
|
|
1830
|
+
}
|
|
1831
|
+
return (
|
|
1832
|
+
{k: v for k, v in all_keys.items() if isinstance(v, BaseChannel)},
|
|
1833
|
+
{k: v for k, v in all_keys.items() if is_managed_value(v)},
|
|
1834
|
+
type_hints,
|
|
1835
|
+
)
|
|
1836
|
+
|
|
1837
|
+
|
|
1838
|
+
@overload
|
|
1839
|
+
def _get_channel(
|
|
1840
|
+
name: str, annotation: Any, *, allow_managed: Literal[False]
|
|
1841
|
+
) -> BaseChannel: ...
|
|
1842
|
+
|
|
1843
|
+
|
|
1844
|
+
@overload
|
|
1845
|
+
def _get_channel(
|
|
1846
|
+
name: str, annotation: Any, *, allow_managed: Literal[True] = True
|
|
1847
|
+
) -> BaseChannel | ManagedValueSpec: ...
|
|
1848
|
+
|
|
1849
|
+
|
|
1850
|
+
def _get_channel(
|
|
1851
|
+
name: str, annotation: Any, *, allow_managed: bool = True
|
|
1852
|
+
) -> BaseChannel | ManagedValueSpec:
|
|
1853
|
+
# Strip out Required and NotRequired wrappers
|
|
1854
|
+
if hasattr(annotation, "__origin__") and annotation.__origin__ in (
|
|
1855
|
+
Required,
|
|
1856
|
+
NotRequired,
|
|
1857
|
+
):
|
|
1858
|
+
annotation = annotation.__args__[0]
|
|
1859
|
+
if manager := _is_field_managed_value(name, annotation):
|
|
1860
|
+
if allow_managed:
|
|
1861
|
+
return manager
|
|
1862
|
+
else:
|
|
1863
|
+
raise ValueError(f"This {annotation} not allowed in this position")
|
|
1864
|
+
elif channel := _is_field_channel(annotation):
|
|
1865
|
+
channel.key = name
|
|
1866
|
+
return channel
|
|
1867
|
+
elif channel := _is_field_binop(annotation):
|
|
1868
|
+
channel.key = name
|
|
1869
|
+
return channel
|
|
1870
|
+
|
|
1871
|
+
fallback: LastValue = LastValue(annotation)
|
|
1872
|
+
fallback.key = name
|
|
1873
|
+
return fallback
|
|
1874
|
+
|
|
1875
|
+
|
|
1876
|
+
def _is_field_channel(typ: type[Any]) -> BaseChannel | None:
|
|
1877
|
+
if hasattr(typ, "__metadata__"):
|
|
1878
|
+
meta = typ.__metadata__
|
|
1879
|
+
# Search through all annotated medata to find channel annotations
|
|
1880
|
+
for item in meta:
|
|
1881
|
+
if isinstance(item, BaseChannel):
|
|
1882
|
+
if isinstance(item, DeltaChannel) and hasattr(typ, "__origin__"):
|
|
1883
|
+
origin = typ.__origin__
|
|
1884
|
+
# Unwrap parameterized Required[X]/NotRequired[X] to X
|
|
1885
|
+
# (e.g. Annotated[NotRequired[dict[...]], ...]).
|
|
1886
|
+
if hasattr(origin, "__origin__") and origin.__origin__ in (
|
|
1887
|
+
Required,
|
|
1888
|
+
NotRequired,
|
|
1889
|
+
):
|
|
1890
|
+
origin = origin.__args__[0]
|
|
1891
|
+
item = item.__class__(
|
|
1892
|
+
item.reducer,
|
|
1893
|
+
origin,
|
|
1894
|
+
snapshot_frequency=item.snapshot_frequency,
|
|
1895
|
+
)
|
|
1896
|
+
return item
|
|
1897
|
+
elif isclass(item) and issubclass(item, BaseChannel):
|
|
1898
|
+
# ex, Annotated[int, EphemeralValue, SomeOtherAnnotation]
|
|
1899
|
+
# would return EphemeralValue(int)
|
|
1900
|
+
return item(typ.__origin__ if hasattr(typ, "__origin__") else typ)
|
|
1901
|
+
return None
|
|
1902
|
+
|
|
1903
|
+
|
|
1904
|
+
def _is_field_binop(typ: type[Any]) -> BinaryOperatorAggregate | None:
|
|
1905
|
+
if hasattr(typ, "__metadata__"):
|
|
1906
|
+
meta = typ.__metadata__
|
|
1907
|
+
if len(meta) >= 1 and callable(meta[-1]):
|
|
1908
|
+
sig = signature(meta[-1])
|
|
1909
|
+
params = list(sig.parameters.values())
|
|
1910
|
+
if (
|
|
1911
|
+
sum(
|
|
1912
|
+
p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD)
|
|
1913
|
+
for p in params
|
|
1914
|
+
)
|
|
1915
|
+
== 2
|
|
1916
|
+
):
|
|
1917
|
+
return BinaryOperatorAggregate(typ, meta[-1])
|
|
1918
|
+
else:
|
|
1919
|
+
raise ValueError(
|
|
1920
|
+
f"Invalid reducer signature. Expected (a, b) -> c. Got {sig}"
|
|
1921
|
+
)
|
|
1922
|
+
return None
|
|
1923
|
+
|
|
1924
|
+
|
|
1925
|
+
def _is_field_managed_value(name: str, typ: type[Any]) -> ManagedValueSpec | None:
|
|
1926
|
+
if hasattr(typ, "__metadata__"):
|
|
1927
|
+
meta = typ.__metadata__
|
|
1928
|
+
if len(meta) >= 1:
|
|
1929
|
+
decoration = get_origin(meta[-1]) or meta[-1]
|
|
1930
|
+
if is_managed_value(decoration):
|
|
1931
|
+
return decoration
|
|
1932
|
+
|
|
1933
|
+
# Handle Required, NotRequired, etc wrapped types by extracting the inner type
|
|
1934
|
+
if (
|
|
1935
|
+
get_origin(typ) is not None
|
|
1936
|
+
and (args := get_args(typ))
|
|
1937
|
+
and (inner_type := args[0])
|
|
1938
|
+
):
|
|
1939
|
+
return _is_field_managed_value(name, inner_type)
|
|
1940
|
+
|
|
1941
|
+
return None
|
|
1942
|
+
|
|
1943
|
+
|
|
1944
|
+
def _get_json_schema(
|
|
1945
|
+
typ: type,
|
|
1946
|
+
schemas: dict,
|
|
1947
|
+
channels: dict,
|
|
1948
|
+
name: str,
|
|
1949
|
+
) -> dict[str, Any]:
|
|
1950
|
+
if isclass(typ) and issubclass(typ, BaseModel):
|
|
1951
|
+
return typ.model_json_schema()
|
|
1952
|
+
elif is_typeddict(typ):
|
|
1953
|
+
return TypeAdapter(typ).json_schema()
|
|
1954
|
+
else:
|
|
1955
|
+
keys = list(schemas[typ].keys())
|
|
1956
|
+
if len(keys) == 1 and keys[0] == "__root__":
|
|
1957
|
+
return create_model(
|
|
1958
|
+
name,
|
|
1959
|
+
root=(channels[keys[0]].UpdateType, None),
|
|
1960
|
+
).model_json_schema()
|
|
1961
|
+
else:
|
|
1962
|
+
return create_model(
|
|
1963
|
+
name,
|
|
1964
|
+
field_definitions={
|
|
1965
|
+
k: (
|
|
1966
|
+
channels[k].UpdateType,
|
|
1967
|
+
(
|
|
1968
|
+
get_field_default(
|
|
1969
|
+
k,
|
|
1970
|
+
channels[k].UpdateType,
|
|
1971
|
+
typ,
|
|
1972
|
+
)
|
|
1973
|
+
),
|
|
1974
|
+
)
|
|
1975
|
+
for k in schemas[typ]
|
|
1976
|
+
if k in channels and isinstance(channels[k], BaseChannel)
|
|
1977
|
+
},
|
|
1978
|
+
).model_json_schema()
|