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.
Files changed (185) hide show
  1. examples/__init__.py +1 -0
  2. examples/codegen/__init__.py +5 -0
  3. examples/codegen/models/__init__.py +13 -0
  4. examples/codegen/models/schemas.py +76 -0
  5. examples/codegen/tests/__init__.py +1 -0
  6. examples/codegen/tests/test_ai_helpers.py +235 -0
  7. examples/codegen/tests/test_ast_analysis.py +174 -0
  8. examples/codegen/tests/test_code_analysis.py +134 -0
  9. examples/codegen/tests/test_code_context.py +301 -0
  10. examples/codegen/tests/test_code_nav.py +89 -0
  11. examples/codegen/tests/test_dependency_tools.py +119 -0
  12. examples/codegen/tests/test_example_tools.py +185 -0
  13. examples/codegen/tests/test_git_tools.py +112 -0
  14. examples/codegen/tests/test_impl_agent_schemas.py +193 -0
  15. examples/codegen/tests/test_impl_agent_v4_graph.py +94 -0
  16. examples/codegen/tests/test_jedi_analysis.py +226 -0
  17. examples/codegen/tests/test_meta_tools.py +250 -0
  18. examples/codegen/tests/test_plan_discovery_prompt.py +98 -0
  19. examples/codegen/tests/test_syntax_tools.py +85 -0
  20. examples/codegen/tests/test_synthesize_prompt.py +94 -0
  21. examples/codegen/tests/test_template_tools.py +244 -0
  22. examples/codegen/tools/__init__.py +80 -0
  23. examples/codegen/tools/ai_helpers.py +420 -0
  24. examples/codegen/tools/ast_analysis.py +92 -0
  25. examples/codegen/tools/code_context.py +180 -0
  26. examples/codegen/tools/code_nav.py +52 -0
  27. examples/codegen/tools/dependency_tools.py +120 -0
  28. examples/codegen/tools/example_tools.py +188 -0
  29. examples/codegen/tools/git_tools.py +151 -0
  30. examples/codegen/tools/impl_executor.py +614 -0
  31. examples/codegen/tools/jedi_analysis.py +311 -0
  32. examples/codegen/tools/meta_tools.py +202 -0
  33. examples/codegen/tools/syntax_tools.py +26 -0
  34. examples/codegen/tools/template_tools.py +356 -0
  35. examples/fastapi_interview.py +167 -0
  36. examples/npc/api/__init__.py +1 -0
  37. examples/npc/api/app.py +100 -0
  38. examples/npc/api/routes/__init__.py +5 -0
  39. examples/npc/api/routes/encounter.py +182 -0
  40. examples/npc/api/session.py +330 -0
  41. examples/npc/demo.py +387 -0
  42. examples/npc/nodes/__init__.py +5 -0
  43. examples/npc/nodes/image_node.py +92 -0
  44. examples/npc/run_encounter.py +230 -0
  45. examples/shared/__init__.py +0 -0
  46. examples/shared/replicate_tool.py +238 -0
  47. examples/storyboard/__init__.py +1 -0
  48. examples/storyboard/generate_videos.py +335 -0
  49. examples/storyboard/nodes/__init__.py +12 -0
  50. examples/storyboard/nodes/animated_character_node.py +248 -0
  51. examples/storyboard/nodes/animated_image_node.py +138 -0
  52. examples/storyboard/nodes/character_node.py +162 -0
  53. examples/storyboard/nodes/image_node.py +118 -0
  54. examples/storyboard/nodes/replicate_tool.py +49 -0
  55. examples/storyboard/retry_images.py +118 -0
  56. scripts/demo_async_executor.py +212 -0
  57. scripts/demo_interview_e2e.py +200 -0
  58. scripts/demo_streaming.py +140 -0
  59. scripts/run_interview_demo.py +94 -0
  60. scripts/test_interrupt_fix.py +26 -0
  61. tests/__init__.py +1 -0
  62. tests/conftest.py +178 -0
  63. tests/integration/__init__.py +1 -0
  64. tests/integration/test_animated_storyboard.py +63 -0
  65. tests/integration/test_cli_commands.py +242 -0
  66. tests/integration/test_colocated_prompts.py +139 -0
  67. tests/integration/test_map_demo.py +50 -0
  68. tests/integration/test_memory_demo.py +283 -0
  69. tests/integration/test_npc_api/__init__.py +1 -0
  70. tests/integration/test_npc_api/test_routes.py +357 -0
  71. tests/integration/test_npc_api/test_session.py +216 -0
  72. tests/integration/test_pipeline_flow.py +105 -0
  73. tests/integration/test_providers.py +163 -0
  74. tests/integration/test_resume.py +75 -0
  75. tests/integration/test_subgraph_integration.py +295 -0
  76. tests/integration/test_subgraph_interrupt.py +106 -0
  77. tests/unit/__init__.py +1 -0
  78. tests/unit/test_agent_nodes.py +355 -0
  79. tests/unit/test_async_executor.py +346 -0
  80. tests/unit/test_checkpointer.py +212 -0
  81. tests/unit/test_checkpointer_factory.py +212 -0
  82. tests/unit/test_cli.py +121 -0
  83. tests/unit/test_cli_package.py +81 -0
  84. tests/unit/test_compile_graph_map.py +132 -0
  85. tests/unit/test_conditions_routing.py +253 -0
  86. tests/unit/test_config.py +93 -0
  87. tests/unit/test_conversation_memory.py +276 -0
  88. tests/unit/test_database.py +145 -0
  89. tests/unit/test_deprecation.py +104 -0
  90. tests/unit/test_executor.py +172 -0
  91. tests/unit/test_executor_async.py +179 -0
  92. tests/unit/test_export.py +149 -0
  93. tests/unit/test_expressions.py +178 -0
  94. tests/unit/test_feature_brainstorm.py +194 -0
  95. tests/unit/test_format_prompt.py +145 -0
  96. tests/unit/test_generic_report.py +200 -0
  97. tests/unit/test_graph_commands.py +327 -0
  98. tests/unit/test_graph_linter.py +627 -0
  99. tests/unit/test_graph_loader.py +357 -0
  100. tests/unit/test_graph_schema.py +193 -0
  101. tests/unit/test_inline_schema.py +151 -0
  102. tests/unit/test_interrupt_node.py +182 -0
  103. tests/unit/test_issues.py +164 -0
  104. tests/unit/test_jinja2_prompts.py +85 -0
  105. tests/unit/test_json_extract.py +134 -0
  106. tests/unit/test_langsmith.py +600 -0
  107. tests/unit/test_langsmith_tools.py +204 -0
  108. tests/unit/test_llm_factory.py +109 -0
  109. tests/unit/test_llm_factory_async.py +118 -0
  110. tests/unit/test_loops.py +403 -0
  111. tests/unit/test_map_node.py +144 -0
  112. tests/unit/test_no_backward_compat.py +56 -0
  113. tests/unit/test_node_factory.py +348 -0
  114. tests/unit/test_passthrough_node.py +126 -0
  115. tests/unit/test_prompts.py +324 -0
  116. tests/unit/test_python_nodes.py +198 -0
  117. tests/unit/test_reliability.py +298 -0
  118. tests/unit/test_result_export.py +234 -0
  119. tests/unit/test_router.py +296 -0
  120. tests/unit/test_sanitize.py +99 -0
  121. tests/unit/test_schema_loader.py +295 -0
  122. tests/unit/test_shell_tools.py +229 -0
  123. tests/unit/test_state_builder.py +331 -0
  124. tests/unit/test_state_builder_map.py +104 -0
  125. tests/unit/test_state_config.py +197 -0
  126. tests/unit/test_streaming.py +307 -0
  127. tests/unit/test_subgraph.py +596 -0
  128. tests/unit/test_template.py +190 -0
  129. tests/unit/test_tool_call_integration.py +164 -0
  130. tests/unit/test_tool_call_node.py +178 -0
  131. tests/unit/test_tool_nodes.py +129 -0
  132. tests/unit/test_websearch.py +234 -0
  133. yamlgraph/__init__.py +35 -0
  134. yamlgraph/builder.py +110 -0
  135. yamlgraph/cli/__init__.py +159 -0
  136. yamlgraph/cli/__main__.py +6 -0
  137. yamlgraph/cli/commands.py +231 -0
  138. yamlgraph/cli/deprecation.py +92 -0
  139. yamlgraph/cli/graph_commands.py +541 -0
  140. yamlgraph/cli/validators.py +37 -0
  141. yamlgraph/config.py +67 -0
  142. yamlgraph/constants.py +70 -0
  143. yamlgraph/error_handlers.py +227 -0
  144. yamlgraph/executor.py +290 -0
  145. yamlgraph/executor_async.py +288 -0
  146. yamlgraph/graph_loader.py +451 -0
  147. yamlgraph/map_compiler.py +150 -0
  148. yamlgraph/models/__init__.py +36 -0
  149. yamlgraph/models/graph_schema.py +181 -0
  150. yamlgraph/models/schemas.py +124 -0
  151. yamlgraph/models/state_builder.py +236 -0
  152. yamlgraph/node_factory.py +768 -0
  153. yamlgraph/routing.py +87 -0
  154. yamlgraph/schema_loader.py +240 -0
  155. yamlgraph/storage/__init__.py +20 -0
  156. yamlgraph/storage/checkpointer.py +72 -0
  157. yamlgraph/storage/checkpointer_factory.py +123 -0
  158. yamlgraph/storage/database.py +320 -0
  159. yamlgraph/storage/export.py +269 -0
  160. yamlgraph/tools/__init__.py +1 -0
  161. yamlgraph/tools/agent.py +320 -0
  162. yamlgraph/tools/graph_linter.py +388 -0
  163. yamlgraph/tools/langsmith_tools.py +125 -0
  164. yamlgraph/tools/nodes.py +126 -0
  165. yamlgraph/tools/python_tool.py +179 -0
  166. yamlgraph/tools/shell.py +205 -0
  167. yamlgraph/tools/websearch.py +242 -0
  168. yamlgraph/utils/__init__.py +48 -0
  169. yamlgraph/utils/conditions.py +157 -0
  170. yamlgraph/utils/expressions.py +245 -0
  171. yamlgraph/utils/json_extract.py +104 -0
  172. yamlgraph/utils/langsmith.py +416 -0
  173. yamlgraph/utils/llm_factory.py +118 -0
  174. yamlgraph/utils/llm_factory_async.py +105 -0
  175. yamlgraph/utils/logging.py +104 -0
  176. yamlgraph/utils/prompts.py +171 -0
  177. yamlgraph/utils/sanitize.py +98 -0
  178. yamlgraph/utils/template.py +102 -0
  179. yamlgraph/utils/validators.py +181 -0
  180. yamlgraph-0.3.9.dist-info/METADATA +1105 -0
  181. yamlgraph-0.3.9.dist-info/RECORD +185 -0
  182. yamlgraph-0.3.9.dist-info/WHEEL +5 -0
  183. yamlgraph-0.3.9.dist-info/entry_points.txt +2 -0
  184. yamlgraph-0.3.9.dist-info/licenses/LICENSE +33 -0
  185. yamlgraph-0.3.9.dist-info/top_level.txt +4 -0
@@ -0,0 +1,125 @@
1
+ """LangSmith tools for agent use.
2
+
3
+ Provides tool wrappers for LangSmith functions, enabling agents
4
+ to inspect previous runs, check for errors, and implement
5
+ self-correcting behavior.
6
+
7
+ Example YAML configuration:
8
+ tools:
9
+ check_last_run:
10
+ type: python
11
+ module: yamlgraph.tools.langsmith_tools
12
+ function: get_run_details_tool
13
+ description: "Get status and errors from a pipeline run"
14
+
15
+ get_errors:
16
+ type: python
17
+ module: yamlgraph.tools.langsmith_tools
18
+ function: get_run_errors_tool
19
+ description: "Get detailed error info from a run"
20
+
21
+ failed_runs:
22
+ type: python
23
+ module: yamlgraph.tools.langsmith_tools
24
+ function: get_failed_runs_tool
25
+ description: "List recent failed runs"
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import logging
31
+ from typing import Any
32
+
33
+ from yamlgraph.utils.langsmith import (
34
+ get_failed_runs,
35
+ get_run_details,
36
+ get_run_errors,
37
+ )
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+
42
+ def get_run_details_tool(run_id: str | None = None) -> dict[str, Any]:
43
+ """Get detailed information about a LangSmith run.
44
+
45
+ This tool wrapper returns run details in a format suitable for
46
+ agent consumption.
47
+
48
+ Args:
49
+ run_id: Run ID to inspect. If not provided, uses the latest run.
50
+
51
+ Returns:
52
+ Dict with run details or error message:
53
+ - id: Run identifier
54
+ - name: Run name (usually the graph/pipeline name)
55
+ - status: "success", "error", or "pending"
56
+ - error: Error message if status is "error"
57
+ - start_time: When the run started (ISO format)
58
+ - end_time: When the run completed (ISO format)
59
+ - inputs: The input data for the run
60
+ - outputs: The output data from the run
61
+ - run_type: Type of run (chain, llm, tool, etc.)
62
+ """
63
+ result = get_run_details(run_id)
64
+ if result is None:
65
+ return {"error": "Could not retrieve run details", "success": False}
66
+
67
+ result["success"] = True
68
+ return result
69
+
70
+
71
+ def get_run_errors_tool(run_id: str | None = None) -> dict[str, Any]:
72
+ """Get all errors from a run and its child nodes.
73
+
74
+ This tool retrieves error information from both the parent run
75
+ and all child runs (individual nodes in the graph).
76
+
77
+ Args:
78
+ run_id: Run ID to inspect. If not provided, uses the latest run.
79
+
80
+ Returns:
81
+ Dict with error information:
82
+ - success: Whether the query succeeded
83
+ - error_count: Number of errors found
84
+ - errors: List of error dicts, each with:
85
+ - node: Name of the failed node
86
+ - error: Error message
87
+ - run_type: Type of the failed run
88
+ """
89
+ errors = get_run_errors(run_id)
90
+ return {
91
+ "success": True,
92
+ "error_count": len(errors),
93
+ "errors": errors,
94
+ "has_errors": len(errors) > 0,
95
+ }
96
+
97
+
98
+ def get_failed_runs_tool(
99
+ limit: int = 10,
100
+ project_name: str | None = None,
101
+ ) -> dict[str, Any]:
102
+ """Get recent failed runs from the LangSmith project.
103
+
104
+ This tool helps identify patterns in failures across multiple runs.
105
+
106
+ Args:
107
+ limit: Maximum number of failed runs to return (default: 10)
108
+ project_name: LangSmith project name. Uses default if not provided.
109
+
110
+ Returns:
111
+ Dict with failed run information:
112
+ - success: Whether the query succeeded
113
+ - failed_count: Number of failed runs found
114
+ - runs: List of failed run summaries, each with:
115
+ - id: Run identifier
116
+ - name: Run name
117
+ - error: Error message
118
+ - start_time: When the run started (ISO format)
119
+ """
120
+ runs = get_failed_runs(project_name=project_name, limit=limit)
121
+ return {
122
+ "success": True,
123
+ "failed_count": len(runs),
124
+ "runs": runs,
125
+ }
@@ -0,0 +1,126 @@
1
+ """Node factories for tool and agent nodes.
2
+
3
+ This module provides functions to create graph nodes that execute
4
+ shell tools, either deterministically (tool nodes) or via LLM
5
+ decision-making (agent nodes).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from collections.abc import Callable
12
+ from typing import Any
13
+
14
+ from yamlgraph.models.schemas import ErrorType, PipelineError
15
+ from yamlgraph.tools.shell import ShellToolConfig, execute_shell_tool
16
+ from yamlgraph.utils.expressions import resolve_template
17
+
18
+ # Type alias for state - dynamic TypedDict at runtime
19
+ GraphState = dict[str, Any]
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ def resolve_state_variable(template: str, state: dict[str, Any]) -> str:
25
+ """Resolve {state.path.to.value} to actual state value.
26
+
27
+ Note: Uses consolidated resolve_template from expressions module.
28
+
29
+ Args:
30
+ template: String with {state.key} or {state.nested.key} placeholders
31
+ state: Current graph state
32
+
33
+ Returns:
34
+ Resolved value (preserves type: lists, dicts, etc.)
35
+ """
36
+ value = resolve_template(template, state)
37
+ # resolve_template returns the template unchanged if not a state expression
38
+ if value is template:
39
+ return template
40
+ # Preserve the original type - don't convert to string
41
+ # This allows lists and dicts to be passed to Jinja2 templates correctly
42
+ return value
43
+
44
+
45
+ def resolve_variables(
46
+ variables_config: dict[str, str],
47
+ state: dict[str, Any],
48
+ ) -> dict[str, Any]:
49
+ """Resolve all variable templates against state.
50
+
51
+ Args:
52
+ variables_config: Dict of {var_name: template_string}
53
+ state: Current graph state
54
+
55
+ Returns:
56
+ Dict of {var_name: resolved_value}
57
+ """
58
+ resolved = {}
59
+ for key, template in variables_config.items():
60
+ resolved[key] = resolve_state_variable(template, state)
61
+ return resolved
62
+
63
+
64
+ def create_tool_node(
65
+ node_name: str,
66
+ node_config: dict[str, Any],
67
+ tools: dict[str, ShellToolConfig],
68
+ ) -> Callable[[GraphState], dict]:
69
+ """Create a node that executes a shell tool.
70
+
71
+ Args:
72
+ node_name: Name of the node in the graph
73
+ node_config: Node configuration from YAML
74
+ tools: Registry of available tools
75
+
76
+ Returns:
77
+ Node function that executes the tool
78
+
79
+ Raises:
80
+ KeyError: If tool name not in registry
81
+ """
82
+ tool_name = node_config["tool"]
83
+ tool_config = tools[tool_name] # Raise KeyError if not found
84
+ state_key = node_config.get("state_key", node_name)
85
+ on_error = node_config.get("on_error", "fail")
86
+ variables_template = node_config.get("variables", {})
87
+
88
+ def node_fn(state: GraphState) -> dict:
89
+ """Execute the shell tool and return state update."""
90
+ # Resolve variables from state
91
+ variables = resolve_variables(variables_template, state)
92
+
93
+ logger.info(f"🔧 Executing tool: {tool_name}")
94
+ result = execute_shell_tool(tool_config, variables)
95
+
96
+ if not result.success:
97
+ logger.warning(f"Tool {tool_name} failed: {result.error}")
98
+
99
+ if on_error == "skip":
100
+ # Return with error tracked but don't raise
101
+ errors = list(state.get("errors") or [])
102
+ errors.append(
103
+ PipelineError(
104
+ node=node_name,
105
+ type=ErrorType.UNKNOWN_ERROR,
106
+ message=result.error or "Tool execution failed",
107
+ )
108
+ )
109
+ return {
110
+ state_key: None,
111
+ "current_step": node_name,
112
+ "errors": errors,
113
+ }
114
+ else:
115
+ # on_error == "fail" - raise exception
116
+ raise RuntimeError(
117
+ f"Tool '{tool_name}' failed in node '{node_name}': {result.error}"
118
+ )
119
+
120
+ logger.info(f"✓ Tool {tool_name} completed")
121
+ return {
122
+ state_key: result.output,
123
+ "current_step": node_name,
124
+ }
125
+
126
+ return node_fn
@@ -0,0 +1,179 @@
1
+ """Python function loader for type: python nodes.
2
+
3
+ This module enables YAML graphs to call arbitrary Python functions
4
+ by specifying the module path and function name.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import importlib
10
+ import logging
11
+ import os
12
+ import sys
13
+ from collections.abc import Callable
14
+ from dataclasses import dataclass
15
+ from typing import Any
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ @dataclass
21
+ class PythonToolConfig:
22
+ """Configuration for a Python tool.
23
+
24
+ Attributes:
25
+ module: Full module path (e.g., "examples.storyboard.nodes.image_node")
26
+ function: Function name within the module
27
+ description: Human-readable description
28
+ """
29
+
30
+ module: str
31
+ function: str
32
+ description: str = ""
33
+
34
+
35
+ def load_python_function(config: PythonToolConfig) -> Callable:
36
+ """Load a Python function from module path.
37
+
38
+ Args:
39
+ config: Python tool configuration
40
+
41
+ Returns:
42
+ The loaded function
43
+
44
+ Raises:
45
+ ImportError: If module cannot be imported
46
+ AttributeError: If function not found in module
47
+ """
48
+ # Ensure current working directory is in path for project imports
49
+ cwd = os.getcwd()
50
+ if cwd not in sys.path:
51
+ sys.path.insert(0, cwd)
52
+
53
+ try:
54
+ module = importlib.import_module(config.module)
55
+ except ImportError as e:
56
+ logger.error(f"Failed to import module: {config.module}")
57
+ raise ImportError(f"Cannot import module '{config.module}': {e}") from e
58
+
59
+ try:
60
+ func = getattr(module, config.function)
61
+ except AttributeError as e:
62
+ logger.error(f"Function not found: {config.function} in {config.module}")
63
+ raise AttributeError(
64
+ f"Function '{config.function}' not found in module '{config.module}'"
65
+ ) from e
66
+
67
+ if not callable(func):
68
+ raise TypeError(f"'{config.function}' in '{config.module}' is not callable")
69
+
70
+ logger.debug(f"Loaded Python function: {config.module}.{config.function}")
71
+ return func
72
+
73
+
74
+ def parse_python_tools(tools_config: dict[str, Any]) -> dict[str, PythonToolConfig]:
75
+ """Parse Python tools from YAML tools section.
76
+
77
+ Only extracts tools with type: python.
78
+
79
+ Args:
80
+ tools_config: Dict from YAML tools: section
81
+
82
+ Returns:
83
+ Registry mapping tool names to PythonToolConfig objects
84
+ """
85
+ registry: dict[str, PythonToolConfig] = {}
86
+
87
+ for name, config in tools_config.items():
88
+ if config.get("type") != "python":
89
+ continue
90
+
91
+ if "module" not in config or "function" not in config:
92
+ logger.warning(
93
+ f"Python tool '{name}' missing 'module' or 'function', skipping"
94
+ )
95
+ continue
96
+
97
+ registry[name] = PythonToolConfig(
98
+ module=config["module"],
99
+ function=config["function"],
100
+ description=config.get("description", ""),
101
+ )
102
+
103
+ return registry
104
+
105
+
106
+ def create_python_node(
107
+ node_name: str,
108
+ node_config: dict[str, Any],
109
+ python_tools: dict[str, PythonToolConfig],
110
+ ) -> Callable[[dict[str, Any]], dict]:
111
+ """Create a node that executes a Python function.
112
+
113
+ The function receives the full state dict and should return
114
+ a partial state update dict.
115
+
116
+ Args:
117
+ node_name: Name of the node in the graph
118
+ node_config: Node configuration from YAML
119
+ python_tools: Registry of available Python tools
120
+
121
+ Returns:
122
+ Node function that executes the Python function
123
+ """
124
+ tool_name = node_config.get("tool") or node_config.get("function")
125
+ if not tool_name:
126
+ raise ValueError(f"Python node '{node_name}' must specify 'tool' or 'function'")
127
+
128
+ if tool_name not in python_tools:
129
+ raise KeyError(f"Python tool '{tool_name}' not found in tools registry")
130
+
131
+ tool_config = python_tools[tool_name]
132
+ state_key = node_config.get("state_key", node_name)
133
+ on_error = node_config.get("on_error", "fail")
134
+
135
+ # Load the function at node creation time
136
+ func = load_python_function(tool_config)
137
+
138
+ def node_fn(state: dict[str, Any]) -> dict:
139
+ """Execute the Python function and return state update."""
140
+ logger.info(f"🐍 Executing Python node: {node_name} -> {tool_name}")
141
+
142
+ try:
143
+ result = func(state)
144
+
145
+ # If function returns a dict, merge with node metadata
146
+ if isinstance(result, dict):
147
+ result["current_step"] = node_name
148
+ return result
149
+ else:
150
+ # Function returned a single value, store in state_key
151
+ return {
152
+ state_key: result,
153
+ "current_step": node_name,
154
+ }
155
+
156
+ except Exception as e:
157
+ logger.error(f"Python node {node_name} failed: {e}")
158
+
159
+ if on_error == "skip":
160
+ from yamlgraph.models import ErrorType, PipelineError
161
+
162
+ errors = list(state.get("errors") or [])
163
+ errors.append(
164
+ PipelineError(
165
+ node=node_name,
166
+ type=ErrorType.UNKNOWN_ERROR,
167
+ message=str(e),
168
+ )
169
+ )
170
+ return {
171
+ state_key: None,
172
+ "current_step": node_name,
173
+ "errors": errors,
174
+ }
175
+ else:
176
+ raise
177
+
178
+ node_fn.__name__ = f"{node_name}_python_node"
179
+ return node_fn
@@ -0,0 +1,205 @@
1
+ """Shell tool executor for running commands with variable substitution.
2
+
3
+ This module provides the core shell tool execution functionality,
4
+ allowing YAML-defined tools to run shell commands with parsed output.
5
+
6
+ Security Model:
7
+ Variables are sanitized with shlex.quote() to prevent shell injection.
8
+ The command template itself is trusted (from YAML config), but all
9
+ user-provided variable values are escaped before substitution.
10
+
11
+ Example:
12
+ command: "git log --author={author}"
13
+ variables: {"author": "$(rm -rf /)"}
14
+ → Executed: git log --author='$(rm -rf /)' (safely quoted)
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import logging
21
+ import os
22
+ import shlex
23
+ import subprocess
24
+ from dataclasses import dataclass, field
25
+ from typing import Any
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ @dataclass
31
+ class ShellToolConfig:
32
+ """Configuration for a shell tool.
33
+
34
+ Attributes:
35
+ command: Shell command with {variable} placeholders
36
+ description: Human-readable description for LLM tool selection
37
+ parse: Output parsing mode - 'text', 'json', or 'none'
38
+ timeout: Max seconds before command is killed
39
+ working_dir: Directory to run command in
40
+ env: Additional environment variables
41
+ success_codes: Exit codes considered successful
42
+ """
43
+
44
+ command: str
45
+ description: str = ""
46
+ parse: str = "text" # text | json | none
47
+ timeout: int = 30
48
+ working_dir: str = "."
49
+ env: dict[str, str] = field(default_factory=dict)
50
+ success_codes: list[int] = field(default_factory=lambda: [0])
51
+
52
+
53
+ @dataclass
54
+ class ToolResult:
55
+ """Result from executing a shell tool.
56
+
57
+ Attributes:
58
+ success: Whether the command succeeded
59
+ output: Parsed output (str, dict, or None based on parse mode)
60
+ error: Error message if failed
61
+ """
62
+
63
+ success: bool
64
+ output: Any = None
65
+ error: str | None = None
66
+
67
+
68
+ def sanitize_variables(variables: dict[str, Any]) -> dict[str, str]:
69
+ """Sanitize variable values for safe shell substitution.
70
+
71
+ Uses shlex.quote() to escape values, preventing shell injection.
72
+
73
+ Args:
74
+ variables: Raw variable values
75
+
76
+ Returns:
77
+ Sanitized variables safe for shell interpolation
78
+ """
79
+ sanitized = {}
80
+ for key, value in variables.items():
81
+ if value is None:
82
+ sanitized[key] = ""
83
+ elif isinstance(value, (list, dict)):
84
+ # Convert complex types to JSON, then quote
85
+ sanitized[key] = shlex.quote(json.dumps(value))
86
+ else:
87
+ sanitized[key] = shlex.quote(str(value))
88
+ return sanitized
89
+
90
+
91
+ def execute_shell_tool(
92
+ config: ShellToolConfig,
93
+ variables: dict[str, Any],
94
+ sanitize: bool = True,
95
+ ) -> ToolResult:
96
+ """Execute shell command with variable substitution.
97
+
98
+ Args:
99
+ config: Tool configuration with command template
100
+ variables: Values to substitute into command placeholders
101
+ sanitize: Whether to sanitize variables with shlex.quote (default True)
102
+
103
+ Returns:
104
+ ToolResult with success status and parsed output or error
105
+ """
106
+ # Sanitize variables to prevent shell injection
107
+ safe_vars = sanitize_variables(variables) if sanitize else variables
108
+
109
+ # Substitute variables into command
110
+ try:
111
+ command = config.command.format(**safe_vars)
112
+ except KeyError as e:
113
+ return ToolResult(
114
+ success=False,
115
+ error=f"Missing variable: {e}",
116
+ )
117
+
118
+ logger.debug(f"Executing: {command}")
119
+
120
+ # Build environment
121
+ env = os.environ.copy()
122
+ env.update(config.env)
123
+
124
+ try:
125
+ # Security: shell=True is required for command templates with pipes/redirects.
126
+ # All user-provided variables are sanitized via shlex.quote() in sanitize_variables()
127
+ # before substitution, preventing shell injection attacks. The command template
128
+ # itself comes from trusted YAML configuration, not user input.
129
+ result = subprocess.run(
130
+ command,
131
+ shell=True, # nosec B602
132
+ capture_output=True,
133
+ text=True,
134
+ timeout=config.timeout,
135
+ cwd=config.working_dir,
136
+ env=env,
137
+ )
138
+ except subprocess.TimeoutExpired:
139
+ return ToolResult(
140
+ success=False,
141
+ error=f"Command timed out after {config.timeout} seconds",
142
+ )
143
+ except Exception as e:
144
+ return ToolResult(
145
+ success=False,
146
+ error=f"Execution error: {e}",
147
+ )
148
+
149
+ # Check exit code
150
+ if result.returncode not in config.success_codes:
151
+ return ToolResult(
152
+ success=False,
153
+ output=result.stdout,
154
+ error=result.stderr or f"Exit code {result.returncode}",
155
+ )
156
+
157
+ # Parse output
158
+ output = result.stdout
159
+ if config.parse == "json":
160
+ try:
161
+ output = json.loads(output)
162
+ except json.JSONDecodeError as e:
163
+ return ToolResult(
164
+ success=False,
165
+ error=f"JSON parse error: {e}",
166
+ )
167
+ elif config.parse == "none":
168
+ output = None
169
+
170
+ return ToolResult(success=True, output=output)
171
+
172
+
173
+ def parse_tools(tools_config: dict[str, Any]) -> dict[str, ShellToolConfig]:
174
+ """Parse tools: section from YAML into ShellToolConfig registry.
175
+
176
+ Only parses shell tools (type: shell or no type specified with command).
177
+ Skips Python tools (type: python).
178
+
179
+ Args:
180
+ tools_config: Dict from YAML tools: section
181
+
182
+ Returns:
183
+ Registry mapping tool names to ShellToolConfig objects
184
+ """
185
+ registry: dict[str, ShellToolConfig] = {}
186
+
187
+ for name, config in tools_config.items():
188
+ # Skip Python tools
189
+ if config.get("type") == "python":
190
+ continue
191
+ # Skip tools without command (invalid shell tools)
192
+ if "command" not in config:
193
+ continue
194
+
195
+ registry[name] = ShellToolConfig(
196
+ command=config["command"],
197
+ description=config.get("description", ""),
198
+ parse=config.get("parse", "text"),
199
+ timeout=config.get("timeout", 30),
200
+ working_dir=config.get("working_dir", "."),
201
+ env=config.get("env", {}),
202
+ success_codes=config.get("success_codes", [0]),
203
+ )
204
+
205
+ return registry