Skip to content

Commit 652a1ce

Browse files
committed
feat(engine): add interpolatable field support for dynamic type resolution
Introduces a new interpolation pattern that allows block input fields to accept both their strict types (enums, booleans, integers) and string variables that get resolved at execution time. This enables dynamic workflow behavior while maintaining type safety through two-phase validation: 1. Load time: Accept Union[StrictType, str], validate interpolation syntax 2. Execution time: Resolve variables, validate against strict type Key additions: - New interpolation.py module (588 lines) with reusable validators and resolvers - Interpolatable enum, boolean, and numeric field support - Updated executors (Shell, CreateFile, HttpCall, LLMCall, State) with interpolation - LLMProvider enum type for better type safety - Comprehensive validation with clear error messages This change enables workflows to use variables in strictly-typed fields: provider: "{{inputs.llm_provider}}" # Resolves to LLMProvider enum timeout: "{{blocks.config.outputs.timeout}}" # Resolves to int verify_ssl: "{{inputs.ssl_enabled}}" # Resolves to bool
1 parent 810d266 commit 652a1ce

8 files changed

Lines changed: 1216 additions & 222 deletions

File tree

schema.json

Lines changed: 338 additions & 126 deletions
Large diffs are not rendered by default.

src/workflows_mcp/engine/executors_core.py

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from pathlib import Path
88
from typing import Any, ClassVar
99

10-
from pydantic import Field
10+
from pydantic import Field, field_validator
1111

1212
from .block import BlockInput, BlockOutput
1313
from .context_vars import block_custom_outputs
@@ -17,6 +17,10 @@
1717
ExecutorCapabilities,
1818
ExecutorSecurityLevel,
1919
)
20+
from .interpolation import (
21+
interpolatable_numeric_validator,
22+
resolve_interpolatable_numeric,
23+
)
2024

2125
# ============================================================================
2226
# Shell Executor
@@ -36,7 +40,9 @@ class ShellInput(BlockInput):
3640

3741
command: str = Field(description="Shell command to execute")
3842
working_dir: str = Field(default="", description="Working directory (empty = current dir)")
39-
timeout: int = Field(default=120, description="Timeout in seconds")
43+
timeout: int | str = Field(
44+
default=120, description="Timeout in seconds (or interpolation string)"
45+
)
4046
env: dict[str, str] = Field(default_factory=dict, description="Environment variables")
4147
capture_output: bool = Field(default=True, description="Capture stdout/stderr")
4248
shell: bool = Field(default=True, description="Execute via shell")
@@ -46,6 +52,11 @@ class ShellInput(BlockInput):
4652
exclude=True,
4753
)
4854

55+
# Validator for numeric field with interpolation support
56+
_validate_timeout = field_validator("timeout", mode="before")(
57+
interpolatable_numeric_validator(int, ge=1, le=3600)
58+
)
59+
4960

5061
class ShellOutput(BlockOutput):
5162
"""Output model for Shell executor.
@@ -361,6 +372,9 @@ async def execute( # type: ignore[override]
361372
TimeoutError: If command times out
362373
Exception: For other execution failures
363374
"""
375+
# Resolve interpolatable fields to their actual types
376+
timeout = resolve_interpolatable_numeric(inputs.timeout, int, "timeout", ge=1, le=3600)
377+
364378
# Prepare working directory
365379
cwd = Path(inputs.working_dir) if inputs.working_dir else Path.cwd()
366380
if not cwd.exists():
@@ -408,14 +422,12 @@ async def execute( # type: ignore[override]
408422
# Wait for completion with timeout
409423
try:
410424
stdout_bytes, stderr_bytes = await asyncio.wait_for(
411-
process.communicate(), timeout=inputs.timeout
425+
process.communicate(), timeout=timeout
412426
)
413427
except TimeoutError:
414428
process.kill()
415429
await process.wait()
416-
raise TimeoutError(
417-
f"Command timed out after {inputs.timeout} seconds: {inputs.command}"
418-
)
430+
raise TimeoutError(f"Command timed out after {timeout} seconds: {inputs.command}")
419431

420432
# Decode output
421433
stdout = stdout_bytes.decode("utf-8") if stdout_bytes else ""

src/workflows_mcp/engine/executors_file.py

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from typing import Any, ClassVar
1313

1414
from jinja2 import Environment, StrictUndefined
15-
from pydantic import Field
15+
from pydantic import Field, field_validator
1616

1717
from .block import BlockInput, BlockOutput
1818
from .block_utils import FileOperations, PathResolver
@@ -22,6 +22,10 @@
2222
ExecutorCapabilities,
2323
ExecutorSecurityLevel,
2424
)
25+
from .interpolation import (
26+
interpolatable_boolean_validator,
27+
resolve_interpolatable_boolean,
28+
)
2529

2630
# ============================================================================
2731
# CreateFile Executor
@@ -38,8 +42,21 @@ class CreateFileInput(BlockInput):
3842
default=None,
3943
description="File permissions (Unix only, e.g., 0o644, 644, or '644')",
4044
)
41-
overwrite: bool = Field(default=True, description="Whether to overwrite existing file")
42-
create_parents: bool = Field(default=True, description="Create parent directories if missing")
45+
overwrite: bool | str = Field(
46+
default=True, description="Whether to overwrite existing file (or interpolation string)"
47+
)
48+
create_parents: bool | str = Field(
49+
default=True,
50+
description="Create parent directories if missing (or interpolation string)",
51+
)
52+
53+
# Validators for boolean fields with interpolation support
54+
_validate_overwrite = field_validator("overwrite", mode="before")(
55+
interpolatable_boolean_validator()
56+
)
57+
_validate_create_parents = field_validator("create_parents", mode="before")(
58+
interpolatable_boolean_validator()
59+
)
4360

4461

4562
class CreateFileOutput(BlockOutput):
@@ -101,6 +118,10 @@ async def execute( # type: ignore[override]
101118
FileExistsError: File exists and overwrite=False
102119
Exception: Other I/O errors
103120
"""
121+
# Resolve interpolatable fields to their actual types
122+
overwrite = resolve_interpolatable_boolean(inputs.overwrite, "overwrite")
123+
create_parents = resolve_interpolatable_boolean(inputs.create_parents, "create_parents")
124+
104125
# Resolve path with security validation
105126
path_result = PathResolver.resolve_and_validate(inputs.path, allow_traversal=True)
106127
if not path_result.is_success:
@@ -112,7 +133,7 @@ async def execute( # type: ignore[override]
112133

113134
# Check overwrite protection
114135
file_existed = file_path.exists()
115-
if file_existed and not inputs.overwrite:
136+
if file_existed and not overwrite:
116137
raise FileExistsError(f"File exists and overwrite=False: {file_path}")
117138

118139
# Convert mode to integer if it's a string
@@ -136,7 +157,7 @@ async def execute( # type: ignore[override]
136157
content=inputs.content,
137158
encoding=inputs.encoding,
138159
mode=mode_int,
139-
create_parents=inputs.create_parents,
160+
create_parents=create_parents,
140161
)
141162

142163
if not write_result.is_success:
@@ -161,9 +182,17 @@ class ReadFileInput(BlockInput):
161182

162183
path: str = Field(description="File path to read (absolute or relative)")
163184
encoding: str = Field(default="utf-8", description="Text encoding")
164-
required: bool = Field(
185+
required: bool | str = Field(
165186
default=True,
166-
description="If False, missing file returns empty content instead of error",
187+
description=(
188+
"If False, missing file returns empty content instead of error "
189+
"(or interpolation string)"
190+
),
191+
)
192+
193+
# Validator for boolean field with interpolation support
194+
_validate_required = field_validator("required", mode="before")(
195+
interpolatable_boolean_validator()
167196
)
168197

169198

@@ -222,6 +251,9 @@ async def execute( # type: ignore[override]
222251
FileNotFoundError: File not found and required=True
223252
Exception: Other I/O errors
224253
"""
254+
# Resolve interpolatable fields to their actual types
255+
required = resolve_interpolatable_boolean(inputs.required, "required")
256+
225257
# Resolve path
226258
path_result = PathResolver.resolve_and_validate(inputs.path, allow_traversal=True)
227259
if not path_result.is_success:
@@ -233,7 +265,7 @@ async def execute( # type: ignore[override]
233265

234266
# Check if file exists
235267
if not file_path.exists():
236-
if inputs.required:
268+
if required:
237269
raise FileNotFoundError(f"File not found: {file_path}")
238270
else:
239271
# Graceful: return empty content

src/workflows_mcp/engine/executors_http.py

Lines changed: 37 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
from typing import TYPE_CHECKING, Any, ClassVar
2323

2424
import httpx
25-
from pydantic import Field, model_validator
25+
from pydantic import Field, field_validator, model_validator
2626

2727
if TYPE_CHECKING:
2828
from typing import Self
@@ -34,6 +34,12 @@
3434
ExecutorCapabilities,
3535
ExecutorSecurityLevel,
3636
)
37+
from .interpolation import (
38+
interpolatable_boolean_validator,
39+
interpolatable_numeric_validator,
40+
resolve_interpolatable_boolean,
41+
resolve_interpolatable_numeric,
42+
)
3743

3844
# ============================================================================
3945
# HttpCall Executor
@@ -71,14 +77,27 @@ class HttpCallInput(BlockInput):
7177
"Matches httpx parameter name."
7278
),
7379
)
74-
timeout: int = Field(
80+
timeout: int | str = Field(
7581
default=30,
76-
description="Request timeout in seconds",
77-
ge=1,
78-
le=1800,
82+
description="Request timeout in seconds (or interpolation string)",
83+
)
84+
follow_redirects: bool | str = Field(
85+
default=True, description="Whether to follow HTTP redirects (or interpolation string)"
86+
)
87+
verify_ssl: bool | str = Field(
88+
default=True, description="Whether to verify SSL certificates (or interpolation string)"
89+
)
90+
91+
# Validators for numeric and boolean fields with interpolation support
92+
_validate_timeout = field_validator("timeout", mode="before")(
93+
interpolatable_numeric_validator(int, ge=1, le=1800)
94+
)
95+
_validate_follow_redirects = field_validator("follow_redirects", mode="before")(
96+
interpolatable_boolean_validator()
97+
)
98+
_validate_verify_ssl = field_validator("verify_ssl", mode="before")(
99+
interpolatable_boolean_validator()
79100
)
80-
follow_redirects: bool = Field(default=True, description="Whether to follow HTTP redirects")
81-
verify_ssl: bool = Field(default=True, description="Whether to verify SSL certificates")
82101

83102
@model_validator(mode="after")
84103
def validate_body_exclusive(self) -> Self:
@@ -163,6 +182,13 @@ async def execute( # type: ignore[override]
163182
httpx.HTTPStatusError: HTTP error responses (can be caught)
164183
Exception: Other HTTP client errors
165184
"""
185+
# Resolve interpolatable fields to their actual types
186+
timeout = resolve_interpolatable_numeric(inputs.timeout, int, "timeout", ge=1, le=1800)
187+
follow_redirects = resolve_interpolatable_boolean(
188+
inputs.follow_redirects, "follow_redirects"
189+
)
190+
verify_ssl = resolve_interpolatable_boolean(inputs.verify_ssl, "verify_ssl")
191+
166192
# Substitute environment variables in URL and headers
167193
url = self._substitute_env_vars(inputs.url)
168194
headers = {key: self._substitute_env_vars(value) for key, value in inputs.headers.items()}
@@ -174,9 +200,9 @@ async def execute( # type: ignore[override]
174200

175201
# Create HTTP client with configuration
176202
async with httpx.AsyncClient(
177-
timeout=inputs.timeout,
178-
follow_redirects=inputs.follow_redirects,
179-
verify=inputs.verify_ssl,
203+
timeout=timeout,
204+
follow_redirects=follow_redirects,
205+
verify=verify_ssl,
180206
) as client:
181207
# Make HTTP request (parameters pass through directly to httpx)
182208
try:
@@ -188,9 +214,7 @@ async def execute( # type: ignore[override]
188214
content=inputs.content, # Direct passthrough - type-safe!
189215
)
190216
except httpx.TimeoutException as e:
191-
raise httpx.TimeoutException(
192-
f"Request timeout after {inputs.timeout}s: {url}"
193-
) from e
217+
raise httpx.TimeoutException(f"Request timeout after {timeout}s: {url}") from e
194218
except httpx.NetworkError as e:
195219
raise httpx.NetworkError(f"Network error for {url}: {e}") from e
196220

0 commit comments

Comments
 (0)