yamlgraph 0.3.9__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.
- examples/__init__.py +1 -0
- examples/codegen/__init__.py +5 -0
- examples/codegen/models/__init__.py +13 -0
- examples/codegen/models/schemas.py +76 -0
- examples/codegen/tests/__init__.py +1 -0
- examples/codegen/tests/test_ai_helpers.py +235 -0
- examples/codegen/tests/test_ast_analysis.py +174 -0
- examples/codegen/tests/test_code_analysis.py +134 -0
- examples/codegen/tests/test_code_context.py +301 -0
- examples/codegen/tests/test_code_nav.py +89 -0
- examples/codegen/tests/test_dependency_tools.py +119 -0
- examples/codegen/tests/test_example_tools.py +185 -0
- examples/codegen/tests/test_git_tools.py +112 -0
- examples/codegen/tests/test_impl_agent_schemas.py +193 -0
- examples/codegen/tests/test_impl_agent_v4_graph.py +94 -0
- examples/codegen/tests/test_jedi_analysis.py +226 -0
- examples/codegen/tests/test_meta_tools.py +250 -0
- examples/codegen/tests/test_plan_discovery_prompt.py +98 -0
- examples/codegen/tests/test_syntax_tools.py +85 -0
- examples/codegen/tests/test_synthesize_prompt.py +94 -0
- examples/codegen/tests/test_template_tools.py +244 -0
- examples/codegen/tools/__init__.py +80 -0
- examples/codegen/tools/ai_helpers.py +420 -0
- examples/codegen/tools/ast_analysis.py +92 -0
- examples/codegen/tools/code_context.py +180 -0
- examples/codegen/tools/code_nav.py +52 -0
- examples/codegen/tools/dependency_tools.py +120 -0
- examples/codegen/tools/example_tools.py +188 -0
- examples/codegen/tools/git_tools.py +151 -0
- examples/codegen/tools/impl_executor.py +614 -0
- examples/codegen/tools/jedi_analysis.py +311 -0
- examples/codegen/tools/meta_tools.py +202 -0
- examples/codegen/tools/syntax_tools.py +26 -0
- examples/codegen/tools/template_tools.py +356 -0
- examples/fastapi_interview.py +167 -0
- examples/npc/api/__init__.py +1 -0
- examples/npc/api/app.py +100 -0
- examples/npc/api/routes/__init__.py +5 -0
- examples/npc/api/routes/encounter.py +182 -0
- examples/npc/api/session.py +330 -0
- examples/npc/demo.py +387 -0
- examples/npc/nodes/__init__.py +5 -0
- examples/npc/nodes/image_node.py +92 -0
- examples/npc/run_encounter.py +230 -0
- examples/shared/__init__.py +0 -0
- examples/shared/replicate_tool.py +238 -0
- examples/storyboard/__init__.py +1 -0
- examples/storyboard/generate_videos.py +335 -0
- examples/storyboard/nodes/__init__.py +12 -0
- examples/storyboard/nodes/animated_character_node.py +248 -0
- examples/storyboard/nodes/animated_image_node.py +138 -0
- examples/storyboard/nodes/character_node.py +162 -0
- examples/storyboard/nodes/image_node.py +118 -0
- examples/storyboard/nodes/replicate_tool.py +49 -0
- examples/storyboard/retry_images.py +118 -0
- scripts/demo_async_executor.py +212 -0
- scripts/demo_interview_e2e.py +200 -0
- scripts/demo_streaming.py +140 -0
- scripts/run_interview_demo.py +94 -0
- scripts/test_interrupt_fix.py +26 -0
- tests/__init__.py +1 -0
- tests/conftest.py +178 -0
- tests/integration/__init__.py +1 -0
- tests/integration/test_animated_storyboard.py +63 -0
- tests/integration/test_cli_commands.py +242 -0
- tests/integration/test_colocated_prompts.py +139 -0
- tests/integration/test_map_demo.py +50 -0
- tests/integration/test_memory_demo.py +283 -0
- tests/integration/test_npc_api/__init__.py +1 -0
- tests/integration/test_npc_api/test_routes.py +357 -0
- tests/integration/test_npc_api/test_session.py +216 -0
- tests/integration/test_pipeline_flow.py +105 -0
- tests/integration/test_providers.py +163 -0
- tests/integration/test_resume.py +75 -0
- tests/integration/test_subgraph_integration.py +295 -0
- tests/integration/test_subgraph_interrupt.py +106 -0
- tests/unit/__init__.py +1 -0
- tests/unit/test_agent_nodes.py +355 -0
- tests/unit/test_async_executor.py +346 -0
- tests/unit/test_checkpointer.py +212 -0
- tests/unit/test_checkpointer_factory.py +212 -0
- tests/unit/test_cli.py +121 -0
- tests/unit/test_cli_package.py +81 -0
- tests/unit/test_compile_graph_map.py +132 -0
- tests/unit/test_conditions_routing.py +253 -0
- tests/unit/test_config.py +93 -0
- tests/unit/test_conversation_memory.py +276 -0
- tests/unit/test_database.py +145 -0
- tests/unit/test_deprecation.py +104 -0
- tests/unit/test_executor.py +172 -0
- tests/unit/test_executor_async.py +179 -0
- tests/unit/test_export.py +149 -0
- tests/unit/test_expressions.py +178 -0
- tests/unit/test_feature_brainstorm.py +194 -0
- tests/unit/test_format_prompt.py +145 -0
- tests/unit/test_generic_report.py +200 -0
- tests/unit/test_graph_commands.py +327 -0
- tests/unit/test_graph_linter.py +627 -0
- tests/unit/test_graph_loader.py +357 -0
- tests/unit/test_graph_schema.py +193 -0
- tests/unit/test_inline_schema.py +151 -0
- tests/unit/test_interrupt_node.py +182 -0
- tests/unit/test_issues.py +164 -0
- tests/unit/test_jinja2_prompts.py +85 -0
- tests/unit/test_json_extract.py +134 -0
- tests/unit/test_langsmith.py +600 -0
- tests/unit/test_langsmith_tools.py +204 -0
- tests/unit/test_llm_factory.py +109 -0
- tests/unit/test_llm_factory_async.py +118 -0
- tests/unit/test_loops.py +403 -0
- tests/unit/test_map_node.py +144 -0
- tests/unit/test_no_backward_compat.py +56 -0
- tests/unit/test_node_factory.py +348 -0
- tests/unit/test_passthrough_node.py +126 -0
- tests/unit/test_prompts.py +324 -0
- tests/unit/test_python_nodes.py +198 -0
- tests/unit/test_reliability.py +298 -0
- tests/unit/test_result_export.py +234 -0
- tests/unit/test_router.py +296 -0
- tests/unit/test_sanitize.py +99 -0
- tests/unit/test_schema_loader.py +295 -0
- tests/unit/test_shell_tools.py +229 -0
- tests/unit/test_state_builder.py +331 -0
- tests/unit/test_state_builder_map.py +104 -0
- tests/unit/test_state_config.py +197 -0
- tests/unit/test_streaming.py +307 -0
- tests/unit/test_subgraph.py +596 -0
- tests/unit/test_template.py +190 -0
- tests/unit/test_tool_call_integration.py +164 -0
- tests/unit/test_tool_call_node.py +178 -0
- tests/unit/test_tool_nodes.py +129 -0
- tests/unit/test_websearch.py +234 -0
- yamlgraph/__init__.py +35 -0
- yamlgraph/builder.py +110 -0
- yamlgraph/cli/__init__.py +159 -0
- yamlgraph/cli/__main__.py +6 -0
- yamlgraph/cli/commands.py +231 -0
- yamlgraph/cli/deprecation.py +92 -0
- yamlgraph/cli/graph_commands.py +541 -0
- yamlgraph/cli/validators.py +37 -0
- yamlgraph/config.py +67 -0
- yamlgraph/constants.py +70 -0
- yamlgraph/error_handlers.py +227 -0
- yamlgraph/executor.py +290 -0
- yamlgraph/executor_async.py +288 -0
- yamlgraph/graph_loader.py +451 -0
- yamlgraph/map_compiler.py +150 -0
- yamlgraph/models/__init__.py +36 -0
- yamlgraph/models/graph_schema.py +181 -0
- yamlgraph/models/schemas.py +124 -0
- yamlgraph/models/state_builder.py +236 -0
- yamlgraph/node_factory.py +768 -0
- yamlgraph/routing.py +87 -0
- yamlgraph/schema_loader.py +240 -0
- yamlgraph/storage/__init__.py +20 -0
- yamlgraph/storage/checkpointer.py +72 -0
- yamlgraph/storage/checkpointer_factory.py +123 -0
- yamlgraph/storage/database.py +320 -0
- yamlgraph/storage/export.py +269 -0
- yamlgraph/tools/__init__.py +1 -0
- yamlgraph/tools/agent.py +320 -0
- yamlgraph/tools/graph_linter.py +388 -0
- yamlgraph/tools/langsmith_tools.py +125 -0
- yamlgraph/tools/nodes.py +126 -0
- yamlgraph/tools/python_tool.py +179 -0
- yamlgraph/tools/shell.py +205 -0
- yamlgraph/tools/websearch.py +242 -0
- yamlgraph/utils/__init__.py +48 -0
- yamlgraph/utils/conditions.py +157 -0
- yamlgraph/utils/expressions.py +245 -0
- yamlgraph/utils/json_extract.py +104 -0
- yamlgraph/utils/langsmith.py +416 -0
- yamlgraph/utils/llm_factory.py +118 -0
- yamlgraph/utils/llm_factory_async.py +105 -0
- yamlgraph/utils/logging.py +104 -0
- yamlgraph/utils/prompts.py +171 -0
- yamlgraph/utils/sanitize.py +98 -0
- yamlgraph/utils/template.py +102 -0
- yamlgraph/utils/validators.py +181 -0
- yamlgraph-0.3.9.dist-info/METADATA +1105 -0
- yamlgraph-0.3.9.dist-info/RECORD +185 -0
- yamlgraph-0.3.9.dist-info/WHEEL +5 -0
- yamlgraph-0.3.9.dist-info/entry_points.txt +2 -0
- yamlgraph-0.3.9.dist-info/licenses/LICENSE +33 -0
- yamlgraph-0.3.9.dist-info/top_level.txt +4 -0
|
@@ -0,0 +1,596 @@
|
|
|
1
|
+
"""Unit tests for subgraph node functionality.
|
|
2
|
+
|
|
3
|
+
TDD tests for the subgraph feature:
|
|
4
|
+
- create_subgraph_node function
|
|
5
|
+
- SubgraphNodeConfig schema
|
|
6
|
+
- Circular reference detection
|
|
7
|
+
- State mapping modes
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from unittest.mock import MagicMock
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TestSubgraphNodeConfig:
|
|
17
|
+
"""Tests for SubgraphNodeConfig schema validation."""
|
|
18
|
+
|
|
19
|
+
def test_valid_invoke_mode_config(self):
|
|
20
|
+
"""Valid config with invoke mode and mappings."""
|
|
21
|
+
from yamlgraph.models.graph_schema import SubgraphNodeConfig
|
|
22
|
+
|
|
23
|
+
config = SubgraphNodeConfig(
|
|
24
|
+
type="subgraph",
|
|
25
|
+
graph="subgraphs/child.yaml",
|
|
26
|
+
mode="invoke",
|
|
27
|
+
input_mapping={"query": "user_input"},
|
|
28
|
+
output_mapping={"result": "analysis"},
|
|
29
|
+
)
|
|
30
|
+
assert config.mode == "invoke"
|
|
31
|
+
assert config.graph == "subgraphs/child.yaml"
|
|
32
|
+
|
|
33
|
+
def test_valid_direct_mode_config(self):
|
|
34
|
+
"""Valid config with direct mode (no mappings)."""
|
|
35
|
+
from yamlgraph.models.graph_schema import SubgraphNodeConfig
|
|
36
|
+
|
|
37
|
+
config = SubgraphNodeConfig(
|
|
38
|
+
type="subgraph",
|
|
39
|
+
graph="subgraphs/child.yaml",
|
|
40
|
+
mode="direct",
|
|
41
|
+
)
|
|
42
|
+
assert config.mode == "direct"
|
|
43
|
+
assert config.input_mapping == {}
|
|
44
|
+
assert config.output_mapping == {}
|
|
45
|
+
|
|
46
|
+
def test_default_mode_is_invoke(self):
|
|
47
|
+
"""Mode defaults to 'invoke' when not specified."""
|
|
48
|
+
from yamlgraph.models.graph_schema import SubgraphNodeConfig
|
|
49
|
+
|
|
50
|
+
config = SubgraphNodeConfig(
|
|
51
|
+
type="subgraph",
|
|
52
|
+
graph="child.yaml",
|
|
53
|
+
)
|
|
54
|
+
assert config.mode == "invoke"
|
|
55
|
+
|
|
56
|
+
def test_rejects_non_yaml_graph_path(self):
|
|
57
|
+
"""Rejects graph paths that don't end in .yaml or .yml."""
|
|
58
|
+
from pydantic import ValidationError
|
|
59
|
+
|
|
60
|
+
from yamlgraph.models.graph_schema import SubgraphNodeConfig
|
|
61
|
+
|
|
62
|
+
with pytest.raises(ValidationError) as exc_info:
|
|
63
|
+
SubgraphNodeConfig(
|
|
64
|
+
type="subgraph",
|
|
65
|
+
graph="child.json",
|
|
66
|
+
)
|
|
67
|
+
assert "YAML file" in str(exc_info.value)
|
|
68
|
+
|
|
69
|
+
def test_rejects_mappings_with_direct_mode(self):
|
|
70
|
+
"""Direct mode does not allow input/output mappings."""
|
|
71
|
+
from pydantic import ValidationError
|
|
72
|
+
|
|
73
|
+
from yamlgraph.models.graph_schema import SubgraphNodeConfig
|
|
74
|
+
|
|
75
|
+
with pytest.raises(ValidationError) as exc_info:
|
|
76
|
+
SubgraphNodeConfig(
|
|
77
|
+
type="subgraph",
|
|
78
|
+
graph="child.yaml",
|
|
79
|
+
mode="direct",
|
|
80
|
+
input_mapping={"foo": "bar"},
|
|
81
|
+
)
|
|
82
|
+
assert "direct" in str(exc_info.value).lower()
|
|
83
|
+
|
|
84
|
+
def test_accepts_yml_extension(self):
|
|
85
|
+
"""Accepts .yml extension for graph path."""
|
|
86
|
+
from yamlgraph.models.graph_schema import SubgraphNodeConfig
|
|
87
|
+
|
|
88
|
+
config = SubgraphNodeConfig(
|
|
89
|
+
type="subgraph",
|
|
90
|
+
graph="child.yml",
|
|
91
|
+
)
|
|
92
|
+
assert config.graph == "child.yml"
|
|
93
|
+
|
|
94
|
+
def test_accepts_checkpointer_override(self):
|
|
95
|
+
"""Accepts optional checkpointer override."""
|
|
96
|
+
from yamlgraph.models.graph_schema import SubgraphNodeConfig
|
|
97
|
+
|
|
98
|
+
config = SubgraphNodeConfig(
|
|
99
|
+
type="subgraph",
|
|
100
|
+
graph="child.yaml",
|
|
101
|
+
checkpointer="memory",
|
|
102
|
+
)
|
|
103
|
+
assert config.checkpointer == "memory"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class TestCreateSubgraphNode:
|
|
107
|
+
"""Tests for create_subgraph_node function."""
|
|
108
|
+
|
|
109
|
+
@pytest.fixture
|
|
110
|
+
def child_graph_yaml(self, tmp_path: Path) -> Path:
|
|
111
|
+
"""Create a minimal child graph YAML file."""
|
|
112
|
+
child_yaml = tmp_path / "subgraphs" / "child.yaml"
|
|
113
|
+
child_yaml.parent.mkdir(parents=True, exist_ok=True)
|
|
114
|
+
child_yaml.write_text(
|
|
115
|
+
"""
|
|
116
|
+
version: "1.0"
|
|
117
|
+
name: child
|
|
118
|
+
state:
|
|
119
|
+
input_text: str
|
|
120
|
+
output_text: str
|
|
121
|
+
nodes:
|
|
122
|
+
process:
|
|
123
|
+
type: llm
|
|
124
|
+
prompt: test
|
|
125
|
+
state_key: output_text
|
|
126
|
+
edges:
|
|
127
|
+
- {from: START, to: process}
|
|
128
|
+
- {from: process, to: END}
|
|
129
|
+
"""
|
|
130
|
+
)
|
|
131
|
+
return child_yaml
|
|
132
|
+
|
|
133
|
+
@pytest.fixture
|
|
134
|
+
def parent_graph_path(self, tmp_path: Path) -> Path:
|
|
135
|
+
"""Return path to parent graph (for relative resolution)."""
|
|
136
|
+
return tmp_path / "parent.yaml"
|
|
137
|
+
|
|
138
|
+
def test_creates_callable_node_invoke_mode(
|
|
139
|
+
self, child_graph_yaml: Path, parent_graph_path: Path
|
|
140
|
+
):
|
|
141
|
+
"""Creates a callable node function in invoke mode."""
|
|
142
|
+
from yamlgraph.node_factory import create_subgraph_node
|
|
143
|
+
|
|
144
|
+
config = {
|
|
145
|
+
"type": "subgraph",
|
|
146
|
+
"mode": "invoke",
|
|
147
|
+
"graph": "subgraphs/child.yaml",
|
|
148
|
+
"input_mapping": {"parent_input": "input_text"},
|
|
149
|
+
"output_mapping": {"result": "output_text"},
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
node = create_subgraph_node(
|
|
153
|
+
"test_subgraph",
|
|
154
|
+
config,
|
|
155
|
+
parent_graph_path=parent_graph_path,
|
|
156
|
+
)
|
|
157
|
+
assert callable(node)
|
|
158
|
+
|
|
159
|
+
def test_raises_file_not_found_for_missing_graph(self, parent_graph_path: Path):
|
|
160
|
+
"""Raises FileNotFoundError when subgraph doesn't exist."""
|
|
161
|
+
from yamlgraph.node_factory import create_subgraph_node
|
|
162
|
+
|
|
163
|
+
config = {
|
|
164
|
+
"type": "subgraph",
|
|
165
|
+
"graph": "nonexistent.yaml",
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
with pytest.raises(FileNotFoundError) as exc_info:
|
|
169
|
+
create_subgraph_node(
|
|
170
|
+
"test_subgraph",
|
|
171
|
+
config,
|
|
172
|
+
parent_graph_path=parent_graph_path,
|
|
173
|
+
)
|
|
174
|
+
assert "nonexistent.yaml" in str(exc_info.value)
|
|
175
|
+
|
|
176
|
+
def test_resolves_path_relative_to_parent(
|
|
177
|
+
self, child_graph_yaml: Path, parent_graph_path: Path
|
|
178
|
+
):
|
|
179
|
+
"""Graph path is resolved relative to parent graph file."""
|
|
180
|
+
from yamlgraph.node_factory import create_subgraph_node
|
|
181
|
+
|
|
182
|
+
config = {
|
|
183
|
+
"type": "subgraph",
|
|
184
|
+
"graph": "subgraphs/child.yaml", # Relative path
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
# Should resolve to tmp_path/subgraphs/child.yaml
|
|
188
|
+
node = create_subgraph_node(
|
|
189
|
+
"test_subgraph",
|
|
190
|
+
config,
|
|
191
|
+
parent_graph_path=parent_graph_path,
|
|
192
|
+
)
|
|
193
|
+
assert callable(node)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class TestSubgraphStateMapping:
|
|
197
|
+
"""Tests for state mapping between parent and child."""
|
|
198
|
+
|
|
199
|
+
@pytest.fixture
|
|
200
|
+
def mock_compiled_graph(self):
|
|
201
|
+
"""Create a mock compiled graph."""
|
|
202
|
+
mock = MagicMock()
|
|
203
|
+
mock.invoke.return_value = {"output_text": "result from child"}
|
|
204
|
+
return mock
|
|
205
|
+
|
|
206
|
+
def test_maps_input_state_explicit(self, mock_compiled_graph):
|
|
207
|
+
"""Explicit input mapping transforms parent state to child input."""
|
|
208
|
+
from yamlgraph.node_factory import _map_input_state
|
|
209
|
+
|
|
210
|
+
parent_state = {"query": "hello", "context": "world", "other": "ignored"}
|
|
211
|
+
input_mapping = {"query": "user_input", "context": "conversation"}
|
|
212
|
+
|
|
213
|
+
child_input = _map_input_state(parent_state, input_mapping)
|
|
214
|
+
|
|
215
|
+
assert child_input == {"user_input": "hello", "conversation": "world"}
|
|
216
|
+
assert "other" not in child_input
|
|
217
|
+
|
|
218
|
+
def test_maps_input_state_auto(self, mock_compiled_graph):
|
|
219
|
+
"""Auto mapping copies all parent state fields."""
|
|
220
|
+
from yamlgraph.node_factory import _map_input_state
|
|
221
|
+
|
|
222
|
+
parent_state = {"query": "hello", "context": "world"}
|
|
223
|
+
|
|
224
|
+
child_input = _map_input_state(parent_state, "auto")
|
|
225
|
+
|
|
226
|
+
assert child_input == {"query": "hello", "context": "world"}
|
|
227
|
+
assert child_input is not parent_state # Should be a copy
|
|
228
|
+
|
|
229
|
+
def test_maps_input_state_full(self, mock_compiled_graph):
|
|
230
|
+
"""Star mapping passes entire state (same reference)."""
|
|
231
|
+
from yamlgraph.node_factory import _map_input_state
|
|
232
|
+
|
|
233
|
+
parent_state = {"query": "hello", "context": "world"}
|
|
234
|
+
|
|
235
|
+
child_input = _map_input_state(parent_state, "*")
|
|
236
|
+
|
|
237
|
+
assert child_input is parent_state
|
|
238
|
+
|
|
239
|
+
def test_maps_output_state_explicit(self):
|
|
240
|
+
"""Explicit output mapping transforms child output to parent updates."""
|
|
241
|
+
from yamlgraph.node_factory import _map_output_state
|
|
242
|
+
|
|
243
|
+
child_output = {"analysis": "done", "meta": {"count": 1}, "internal": "ignored"}
|
|
244
|
+
output_mapping = {"result": "analysis", "metadata": "meta"}
|
|
245
|
+
|
|
246
|
+
parent_updates = _map_output_state(child_output, output_mapping)
|
|
247
|
+
|
|
248
|
+
assert parent_updates == {"result": "done", "metadata": {"count": 1}}
|
|
249
|
+
assert "internal" not in parent_updates
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
class TestCircularReferenceDetection:
|
|
253
|
+
"""Tests for circular subgraph reference detection."""
|
|
254
|
+
|
|
255
|
+
def test_detects_direct_self_reference(self, tmp_path: Path):
|
|
256
|
+
"""Detects A → A cycle (graph references itself)."""
|
|
257
|
+
from yamlgraph.node_factory import create_subgraph_node
|
|
258
|
+
|
|
259
|
+
# Create a graph that references itself
|
|
260
|
+
self_ref = tmp_path / "self.yaml"
|
|
261
|
+
self_ref.write_text(
|
|
262
|
+
"""
|
|
263
|
+
version: "1.0"
|
|
264
|
+
name: self-referencing
|
|
265
|
+
state:
|
|
266
|
+
data: str
|
|
267
|
+
nodes:
|
|
268
|
+
recurse:
|
|
269
|
+
type: subgraph
|
|
270
|
+
graph: self.yaml
|
|
271
|
+
edges:
|
|
272
|
+
- {from: START, to: recurse}
|
|
273
|
+
- {from: recurse, to: END}
|
|
274
|
+
"""
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
config = {"type": "subgraph", "graph": "self.yaml"}
|
|
278
|
+
|
|
279
|
+
with pytest.raises(ValueError) as exc_info:
|
|
280
|
+
create_subgraph_node("test", config, parent_graph_path=self_ref)
|
|
281
|
+
|
|
282
|
+
assert "Circular" in str(exc_info.value)
|
|
283
|
+
|
|
284
|
+
def test_detects_indirect_cycle(self, tmp_path: Path):
|
|
285
|
+
"""Detects A → B → A cycle."""
|
|
286
|
+
from yamlgraph.node_factory import create_subgraph_node
|
|
287
|
+
|
|
288
|
+
# Create A that references B
|
|
289
|
+
graph_a = tmp_path / "a.yaml"
|
|
290
|
+
graph_a.write_text(
|
|
291
|
+
"""
|
|
292
|
+
version: "1.0"
|
|
293
|
+
name: graph-a
|
|
294
|
+
state:
|
|
295
|
+
data: str
|
|
296
|
+
nodes:
|
|
297
|
+
call_b:
|
|
298
|
+
type: subgraph
|
|
299
|
+
graph: b.yaml
|
|
300
|
+
edges:
|
|
301
|
+
- {from: START, to: call_b}
|
|
302
|
+
- {from: call_b, to: END}
|
|
303
|
+
"""
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
# Create B that references A
|
|
307
|
+
graph_b = tmp_path / "b.yaml"
|
|
308
|
+
graph_b.write_text(
|
|
309
|
+
"""
|
|
310
|
+
version: "1.0"
|
|
311
|
+
name: graph-b
|
|
312
|
+
state:
|
|
313
|
+
data: str
|
|
314
|
+
nodes:
|
|
315
|
+
call_a:
|
|
316
|
+
type: subgraph
|
|
317
|
+
graph: a.yaml
|
|
318
|
+
edges:
|
|
319
|
+
- {from: START, to: call_a}
|
|
320
|
+
- {from: call_a, to: END}
|
|
321
|
+
"""
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
config = {"type": "subgraph", "graph": "b.yaml"}
|
|
325
|
+
|
|
326
|
+
with pytest.raises(ValueError) as exc_info:
|
|
327
|
+
create_subgraph_node("test", config, parent_graph_path=graph_a)
|
|
328
|
+
|
|
329
|
+
error_msg = str(exc_info.value)
|
|
330
|
+
assert "Circular" in error_msg
|
|
331
|
+
assert "a.yaml" in error_msg
|
|
332
|
+
|
|
333
|
+
def test_allows_diamond_pattern(self, tmp_path: Path):
|
|
334
|
+
"""Allows diamond pattern: A→B, A→C, B→D, C→D (not circular)."""
|
|
335
|
+
from yamlgraph.node_factory import create_subgraph_node
|
|
336
|
+
|
|
337
|
+
# Create D (leaf)
|
|
338
|
+
graph_d = tmp_path / "d.yaml"
|
|
339
|
+
graph_d.write_text(
|
|
340
|
+
"""
|
|
341
|
+
version: "1.0"
|
|
342
|
+
name: graph-d
|
|
343
|
+
state:
|
|
344
|
+
data: str
|
|
345
|
+
nodes:
|
|
346
|
+
process:
|
|
347
|
+
type: llm
|
|
348
|
+
prompt: test
|
|
349
|
+
state_key: data
|
|
350
|
+
edges:
|
|
351
|
+
- {from: START, to: process}
|
|
352
|
+
- {from: process, to: END}
|
|
353
|
+
"""
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
# Create B that references D
|
|
357
|
+
graph_b = tmp_path / "b.yaml"
|
|
358
|
+
graph_b.write_text(
|
|
359
|
+
"""
|
|
360
|
+
version: "1.0"
|
|
361
|
+
name: graph-b
|
|
362
|
+
state:
|
|
363
|
+
data: str
|
|
364
|
+
nodes:
|
|
365
|
+
call_d:
|
|
366
|
+
type: subgraph
|
|
367
|
+
graph: d.yaml
|
|
368
|
+
edges:
|
|
369
|
+
- {from: START, to: call_d}
|
|
370
|
+
- {from: call_d, to: END}
|
|
371
|
+
"""
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
# Create C that also references D
|
|
375
|
+
graph_c = tmp_path / "c.yaml"
|
|
376
|
+
graph_c.write_text(
|
|
377
|
+
"""
|
|
378
|
+
version: "1.0"
|
|
379
|
+
name: graph-c
|
|
380
|
+
state:
|
|
381
|
+
data: str
|
|
382
|
+
nodes:
|
|
383
|
+
call_d:
|
|
384
|
+
type: subgraph
|
|
385
|
+
graph: d.yaml
|
|
386
|
+
edges:
|
|
387
|
+
- {from: START, to: call_d}
|
|
388
|
+
- {from: call_d, to: END}
|
|
389
|
+
"""
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
# This should NOT raise (diamond is valid)
|
|
393
|
+
config_b = {"type": "subgraph", "graph": "b.yaml"}
|
|
394
|
+
config_c = {"type": "subgraph", "graph": "c.yaml"}
|
|
395
|
+
|
|
396
|
+
# Both should succeed (D is referenced twice but no cycle)
|
|
397
|
+
parent_path = tmp_path / "a.yaml"
|
|
398
|
+
node_b = create_subgraph_node("call_b", config_b, parent_graph_path=parent_path)
|
|
399
|
+
node_c = create_subgraph_node("call_c", config_c, parent_graph_path=parent_path)
|
|
400
|
+
|
|
401
|
+
assert callable(node_b)
|
|
402
|
+
assert callable(node_c)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
class TestThreadIdPropagation:
|
|
406
|
+
"""Tests for thread ID propagation to child graphs."""
|
|
407
|
+
|
|
408
|
+
def test_propagates_thread_id_from_config(self):
|
|
409
|
+
"""Thread ID is propagated as parent_thread:node_name."""
|
|
410
|
+
from yamlgraph.node_factory import _build_child_config
|
|
411
|
+
|
|
412
|
+
parent_config = {"configurable": {"thread_id": "main-123"}}
|
|
413
|
+
node_name = "summarizer"
|
|
414
|
+
|
|
415
|
+
child_config = _build_child_config(parent_config, node_name)
|
|
416
|
+
|
|
417
|
+
assert child_config["configurable"]["thread_id"] == "main-123:summarizer"
|
|
418
|
+
|
|
419
|
+
def test_creates_thread_id_when_parent_has_none(self):
|
|
420
|
+
"""Creates thread ID from node name when parent has none."""
|
|
421
|
+
from yamlgraph.node_factory import _build_child_config
|
|
422
|
+
|
|
423
|
+
parent_config = {"configurable": {}}
|
|
424
|
+
node_name = "summarizer"
|
|
425
|
+
|
|
426
|
+
child_config = _build_child_config(parent_config, node_name)
|
|
427
|
+
|
|
428
|
+
assert child_config["configurable"]["thread_id"] == "summarizer"
|
|
429
|
+
|
|
430
|
+
def test_preserves_other_config_values(self):
|
|
431
|
+
"""Other config values are preserved in child config."""
|
|
432
|
+
from yamlgraph.node_factory import _build_child_config
|
|
433
|
+
|
|
434
|
+
parent_config = {
|
|
435
|
+
"configurable": {"thread_id": "main", "other": "value"},
|
|
436
|
+
"tags": ["test"],
|
|
437
|
+
}
|
|
438
|
+
node_name = "child"
|
|
439
|
+
|
|
440
|
+
child_config = _build_child_config(parent_config, node_name)
|
|
441
|
+
|
|
442
|
+
assert child_config["configurable"]["other"] == "value"
|
|
443
|
+
assert child_config["tags"] == ["test"]
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
class TestInterruptOutputMapping:
|
|
447
|
+
"""Tests for interrupt_output_mapping feature (FR-006)."""
|
|
448
|
+
|
|
449
|
+
def test_schema_accepts_interrupt_output_mapping(self):
|
|
450
|
+
"""SubgraphNodeConfig accepts interrupt_output_mapping."""
|
|
451
|
+
from yamlgraph.models.graph_schema import SubgraphNodeConfig
|
|
452
|
+
|
|
453
|
+
config = SubgraphNodeConfig(
|
|
454
|
+
type="subgraph",
|
|
455
|
+
graph="child.yaml",
|
|
456
|
+
mode="invoke",
|
|
457
|
+
output_mapping={"final_result": "result"},
|
|
458
|
+
interrupt_output_mapping={"current_phase": "phase"},
|
|
459
|
+
)
|
|
460
|
+
assert config.interrupt_output_mapping == {"current_phase": "phase"}
|
|
461
|
+
|
|
462
|
+
def test_schema_default_interrupt_output_mapping_empty(self):
|
|
463
|
+
"""interrupt_output_mapping defaults to empty dict."""
|
|
464
|
+
from yamlgraph.models.graph_schema import SubgraphNodeConfig
|
|
465
|
+
|
|
466
|
+
config = SubgraphNodeConfig(
|
|
467
|
+
type="subgraph",
|
|
468
|
+
graph="child.yaml",
|
|
469
|
+
)
|
|
470
|
+
assert config.interrupt_output_mapping == {}
|
|
471
|
+
|
|
472
|
+
def test_applies_interrupt_mapping_when_subgraph_interrupts(self, tmp_path):
|
|
473
|
+
"""interrupt_output_mapping is applied when subgraph returns __interrupt__."""
|
|
474
|
+
from unittest.mock import MagicMock, patch
|
|
475
|
+
|
|
476
|
+
from yamlgraph.node_factory import create_subgraph_node
|
|
477
|
+
|
|
478
|
+
# Create a minimal child graph file
|
|
479
|
+
child_yaml = tmp_path / "child.yaml"
|
|
480
|
+
child_yaml.write_text(
|
|
481
|
+
"""
|
|
482
|
+
version: "1.0"
|
|
483
|
+
name: child
|
|
484
|
+
state:
|
|
485
|
+
phase: str
|
|
486
|
+
result: str
|
|
487
|
+
nodes:
|
|
488
|
+
ask_question:
|
|
489
|
+
type: interrupt
|
|
490
|
+
state_key: question
|
|
491
|
+
edges:
|
|
492
|
+
- {from: START, to: ask_question}
|
|
493
|
+
- {from: ask_question, to: END}
|
|
494
|
+
"""
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
parent_path = tmp_path / "parent.yaml"
|
|
498
|
+
|
|
499
|
+
config = {
|
|
500
|
+
"type": "subgraph",
|
|
501
|
+
"graph": "child.yaml",
|
|
502
|
+
"mode": "invoke",
|
|
503
|
+
"output_mapping": {"final_result": "result"},
|
|
504
|
+
"interrupt_output_mapping": {"current_phase": "phase"},
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
# Mock the compiled subgraph to return an interrupted state
|
|
508
|
+
mock_compiled = MagicMock()
|
|
509
|
+
mock_compiled.invoke.return_value = {
|
|
510
|
+
"phase": "probing",
|
|
511
|
+
"result": None,
|
|
512
|
+
"__interrupt__": (MagicMock(value={"question": "What?"}),),
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
with patch(
|
|
516
|
+
"yamlgraph.graph_loader.compile_graph"
|
|
517
|
+
) as mock_compile_graph, patch(
|
|
518
|
+
"yamlgraph.graph_loader.load_graph_config"
|
|
519
|
+
) as mock_load:
|
|
520
|
+
mock_load.return_value = MagicMock()
|
|
521
|
+
mock_state_graph = MagicMock()
|
|
522
|
+
mock_state_graph.compile.return_value = mock_compiled
|
|
523
|
+
mock_compile_graph.return_value = mock_state_graph
|
|
524
|
+
|
|
525
|
+
node_fn = create_subgraph_node("demographics", config, parent_path)
|
|
526
|
+
result = node_fn({"user_input": "hello"}, {})
|
|
527
|
+
|
|
528
|
+
# Should use interrupt_output_mapping, not output_mapping
|
|
529
|
+
assert "current_phase" in result
|
|
530
|
+
assert result["current_phase"] == "probing"
|
|
531
|
+
# Should NOT have the completion mapping
|
|
532
|
+
assert "final_result" not in result
|
|
533
|
+
# Should forward the interrupt marker
|
|
534
|
+
assert "__interrupt__" in result
|
|
535
|
+
|
|
536
|
+
def test_applies_output_mapping_when_subgraph_completes(self, tmp_path):
|
|
537
|
+
"""output_mapping is applied when subgraph completes (no __interrupt__)."""
|
|
538
|
+
from unittest.mock import MagicMock, patch
|
|
539
|
+
|
|
540
|
+
from yamlgraph.node_factory import create_subgraph_node
|
|
541
|
+
|
|
542
|
+
child_yaml = tmp_path / "child.yaml"
|
|
543
|
+
child_yaml.write_text(
|
|
544
|
+
"""
|
|
545
|
+
version: "1.0"
|
|
546
|
+
name: child
|
|
547
|
+
state:
|
|
548
|
+
phase: str
|
|
549
|
+
result: str
|
|
550
|
+
nodes:
|
|
551
|
+
process:
|
|
552
|
+
type: llm
|
|
553
|
+
prompt: test
|
|
554
|
+
state_key: result
|
|
555
|
+
edges:
|
|
556
|
+
- {from: START, to: process}
|
|
557
|
+
- {from: process, to: END}
|
|
558
|
+
"""
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
parent_path = tmp_path / "parent.yaml"
|
|
562
|
+
|
|
563
|
+
config = {
|
|
564
|
+
"type": "subgraph",
|
|
565
|
+
"graph": "child.yaml",
|
|
566
|
+
"mode": "invoke",
|
|
567
|
+
"output_mapping": {"final_result": "result"},
|
|
568
|
+
"interrupt_output_mapping": {"current_phase": "phase"},
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
# Mock the compiled subgraph to return a completed state (no __interrupt__)
|
|
572
|
+
mock_compiled = MagicMock()
|
|
573
|
+
mock_compiled.invoke.return_value = {
|
|
574
|
+
"phase": "complete",
|
|
575
|
+
"result": "Analysis done",
|
|
576
|
+
# No __interrupt__ key
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
with patch(
|
|
580
|
+
"yamlgraph.graph_loader.compile_graph"
|
|
581
|
+
) as mock_compile_graph, patch(
|
|
582
|
+
"yamlgraph.graph_loader.load_graph_config"
|
|
583
|
+
) as mock_load:
|
|
584
|
+
mock_load.return_value = MagicMock()
|
|
585
|
+
mock_state_graph = MagicMock()
|
|
586
|
+
mock_state_graph.compile.return_value = mock_compiled
|
|
587
|
+
mock_compile_graph.return_value = mock_state_graph
|
|
588
|
+
|
|
589
|
+
node_fn = create_subgraph_node("demographics", config, parent_path)
|
|
590
|
+
result = node_fn({"user_input": "hello"}, {})
|
|
591
|
+
|
|
592
|
+
# Should use output_mapping, not interrupt_output_mapping
|
|
593
|
+
assert "final_result" in result
|
|
594
|
+
assert result["final_result"] == "Analysis done"
|
|
595
|
+
# Should NOT have the interrupt mapping
|
|
596
|
+
assert "current_phase" not in result
|