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,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
|
+
}
|
yamlgraph/tools/nodes.py
ADDED
|
@@ -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
|
yamlgraph/tools/shell.py
ADDED
|
@@ -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
|