Skip to content

Commit befea5f

Browse files
committed
feat(engine): implement unified variable resolver with rule-based transformation pipeline
BREAKING CHANGE: Replace legacy VariableResolver with new architecture that provides: - Expression classification for optimal Jinja2 routing - Rule-based transformation pipeline (security, syntax, namespace) - Enhanced context with BlockProxy and SecretProxy - SandboxedEnvironment for template rendering security - Clean separation of concerns between rules and evaluation Key components: - UnifiedVariableResolver: Main resolver orchestrating transformation pipeline - ExpressionClassifier: Routes expressions to appropriate evaluation methods - TransformRule system: Modular, priority-based transformation rules - BlockProxy: ADR-007 shortcuts and nested block access - SecretProxy: Lazy secret loading with audit logging - Security rules: Forbidden namespace blocking, secret tracking - Syntax rules: Bracket notation normalization, special character handling Updates: - RenderTemplateExecutor uses SandboxedEnvironment instead of Environment - BlockOrchestrator switches to UnifiedVariableResolver - Legacy variables.py preserved as variables.py.bak - Test workflows and snapshots reorganized for new resolver
1 parent ea2a86e commit befea5f

43 files changed

Lines changed: 2247 additions & 717 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

src/workflows_mcp/engine/executors_file.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@
1515
from typing import Any, ClassVar, Literal
1616

1717
import yaml
18-
from jinja2 import Environment, StrictUndefined
18+
from jinja2 import StrictUndefined
19+
from jinja2.sandbox import SandboxedEnvironment
1920
from pydantic import BaseModel, Field, computed_field, field_validator
2021

2122
from .block import BlockInput, BlockOutput
@@ -643,7 +644,7 @@ async def execute( # type: ignore[override]
643644
create_parents = resolve_interpolatable_boolean(inputs.create_parents, "create_parents")
644645

645646
# RenderTemplate template (exceptions bubble up)
646-
env = Environment(undefined=StrictUndefined, autoescape=False)
647+
env = SandboxedEnvironment(undefined=StrictUndefined, autoescape=False)
647648
template = env.from_string(inputs.template)
648649
rendered = template.render(**inputs.variables)
649650

src/workflows_mcp/engine/orchestrator.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -403,7 +403,7 @@ async def execute_for_each(
403403
Metadata.create_for_each_parent()
404404
- Handles continue_on_error: false (fail-fast) and true (resilient)
405405
"""
406-
from .variables import VariableResolver
406+
from .resolver import UnifiedVariableResolver
407407

408408
# Validate iteration keys (ADR-009: security & stability)
409409
validate_iteration_keys(iterations)
@@ -430,7 +430,7 @@ async def execute_iteration(
430430
iteration_context_dict["each"] = each_context
431431

432432
# Resolve iteration inputs (replace {{each.*}} variables)
433-
resolver = VariableResolver(
433+
resolver = UnifiedVariableResolver(
434434
iteration_context_dict, secret_provider=self.secret_provider
435435
)
436436
resolved_inputs = await resolver.resolve_async(inputs_template)
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
"""
2+
Unified variable resolver package.
3+
4+
This package implements a unified variable resolution system that combines
5+
workflow-specific domain rules with Jinja2's powerful expression evaluation.
6+
7+
The architecture uses a clean overlay pattern with rule-based transformations:
8+
1. Expression classification for optimal routing
9+
2. Rule-based transformation pipeline
10+
3. Context enhancement with proxies
11+
4. Jinja2 evaluation with appropriate method
12+
5. Post-processing and validation
13+
14+
Public API:
15+
- UnifiedVariableResolver: Main resolver class for all variable resolution
16+
- TransformRule: Base class for custom transformation rules
17+
- BlockProxy: Proxy for block attribute shortcuts
18+
- ExpressionClassifier: Expression type detection for routing
19+
"""
20+
21+
from .classifier import ExpressionClassifier, ExpressionType
22+
from .proxies import BlockProxy, ProxyBase, SecretProxy
23+
from .rules import RuleContext, RuleType, TransformRule
24+
from .security_rules import SecurityError
25+
from .unified_resolver import UnifiedVariableResolver
26+
27+
__all__ = [
28+
"UnifiedVariableResolver",
29+
"TransformRule",
30+
"RuleType",
31+
"RuleContext",
32+
"BlockProxy",
33+
"SecretProxy",
34+
"ProxyBase",
35+
"ExpressionClassifier",
36+
"ExpressionType",
37+
"SecurityError",
38+
]
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
"""
2+
Expression classification for optimal routing.
3+
4+
This module classifies expressions to determine the most efficient evaluation
5+
strategy. Different expression types are routed to different Jinja2 methods:
6+
- Pure variables: compile_expression (preserves types)
7+
- Templates: from_string (always returns string)
8+
- Complex expressions: compile_expression (full evaluation)
9+
10+
Expression Types:
11+
LITERAL: No template markers ({{...}})
12+
PURE_VARIABLE: Single variable reference
13+
FILTER_EXPRESSION: Variable with filter(s)
14+
BOOLEAN_EXPRESSION: Boolean logic (and, or, not)
15+
MATH_EXPRESSION: Mathematical operations
16+
TEMPLATE: Mixed text and variables
17+
COMPLEX_EXPRESSION: Function calls, parentheses, etc.
18+
"""
19+
20+
import re
21+
from enum import Enum
22+
23+
24+
class ExpressionType(Enum):
25+
"""Types of expressions for optimal routing."""
26+
27+
LITERAL = "literal" # No {{}} markers
28+
PURE_VARIABLE = "pure_variable" # {{blocks.foo}}
29+
FILTER_EXPRESSION = "filter" # {{value | default('x')}}
30+
BOOLEAN_EXPRESSION = "boolean" # {{a > 10 and b < 5}}
31+
MATH_EXPRESSION = "math" # {{(a * 2) + b}}
32+
TEMPLATE = "template" # Mixed text and {{}}
33+
COMPLEX_EXPRESSION = "complex" # Combination of above
34+
35+
36+
class ExpressionClassifier:
37+
"""
38+
Classify expressions to route to appropriate handlers.
39+
40+
Classification Process:
41+
1. Check for template markers ({{...}})
42+
2. If single expression, analyze inner content
43+
3. Route based on expression type
44+
45+
Example:
46+
classifier = ExpressionClassifier()
47+
expr_type = classifier.classify("{{blocks.foo.succeeded}}")
48+
# Returns: ExpressionType.PURE_VARIABLE
49+
"""
50+
51+
# Regex patterns for classification
52+
VARIABLE_PATTERN = re.compile(r"^[a-zA-Z_][\w\.\[\]\'\"]*$")
53+
FILTER_PATTERN = re.compile(r"\s*\|")
54+
BOOLEAN_OPS = {"and", "or", "not", "==", "!=", ">", "<", ">=", "<=", "is", "in"}
55+
MATH_OPS = {"+", "-", "*", "/", "//", "%", "**"}
56+
57+
def classify(self, expression: str) -> ExpressionType:
58+
"""
59+
Classify expression type for optimal handling.
60+
61+
Args:
62+
expression: Expression string to classify
63+
64+
Returns:
65+
ExpressionType enum value
66+
"""
67+
# No template markers
68+
if "{{" not in expression or "}}" not in expression:
69+
return ExpressionType.LITERAL
70+
71+
# Check if pure expression (single {{...}})
72+
stripped = expression.strip()
73+
if stripped.startswith("{{") and stripped.endswith("}}"):
74+
if stripped.count("{{") == 1 and stripped.count("}}") == 1:
75+
# Extract inner expression
76+
inner = stripped[2:-2].strip()
77+
return self._classify_inner(inner)
78+
79+
# Multiple {{}} or mixed with text
80+
return ExpressionType.TEMPLATE
81+
82+
def _classify_inner(self, inner: str) -> ExpressionType:
83+
"""
84+
Classify the inner content of {{...}}.
85+
86+
Args:
87+
inner: Inner expression content (without {{...}})
88+
89+
Returns:
90+
ExpressionType enum value
91+
"""
92+
# Has filter pipe
93+
if "|" in inner:
94+
return ExpressionType.FILTER_EXPRESSION
95+
96+
# Has boolean operators
97+
tokens = self._tokenize(inner)
98+
if any(op in tokens for op in self.BOOLEAN_OPS):
99+
return ExpressionType.BOOLEAN_EXPRESSION
100+
101+
# Has math operators (but not inside strings)
102+
if any(op in inner for op in self.MATH_OPS):
103+
# Check if operators are inside string literals
104+
if not self._is_inside_string(inner):
105+
return ExpressionType.MATH_EXPRESSION
106+
107+
# Has parentheses (function call or grouping)
108+
if "(" in inner and ")" in inner:
109+
return ExpressionType.COMPLEX_EXPRESSION
110+
111+
# Simple variable reference
112+
if self.VARIABLE_PATTERN.match(inner):
113+
return ExpressionType.PURE_VARIABLE
114+
115+
# Default to complex
116+
return ExpressionType.COMPLEX_EXPRESSION
117+
118+
def _tokenize(self, expr: str) -> list[str]:
119+
"""
120+
Simple tokenization for operator detection.
121+
122+
Args:
123+
expr: Expression to tokenize
124+
125+
Returns:
126+
List of tokens
127+
"""
128+
# Split on word boundaries while preserving operators
129+
tokens = re.findall(r"\w+|[<>=!]+|\S", expr)
130+
return tokens
131+
132+
def _is_inside_string(self, expr: str) -> bool:
133+
"""
134+
Check if operators appear inside string literals.
135+
136+
This is a simplified check. Real implementation would need
137+
proper string parsing with escape sequence handling.
138+
139+
Args:
140+
expr: Expression to check
141+
142+
Returns:
143+
True if expression appears to be inside strings
144+
"""
145+
# Count quotes to detect if we're inside strings
146+
# This is simplified - just checks if there are balanced quotes
147+
single_quotes = expr.count("'") - expr.count("\\'")
148+
double_quotes = expr.count('"') - expr.count('\\"')
149+
150+
# If we have balanced quotes, operators might be inside
151+
return (single_quotes % 2 == 0 and single_quotes > 0) or (
152+
double_quotes % 2 == 0 and double_quotes > 0
153+
)

0 commit comments

Comments
 (0)