diff --git a/skyvern/forge/sdk/workflow/models/block.py b/skyvern/forge/sdk/workflow/models/block.py index 80d5d29c..dcd0f006 100644 --- a/skyvern/forge/sdk/workflow/models/block.py +++ b/skyvern/forge/sdk/workflow/models/block.py @@ -107,6 +107,28 @@ else: jinja_sandbox_env = SandboxedEnvironment() +# Sentinel marker for native JSON type injection via | json filter. +_JSON_TYPE_MARKER = "__SKYVERN_RAW_JSON__" + + +def _json_type_filter(value: Any) -> str: + """Jinja filter that marks a value for native JSON type injection. + + Usage in templates: {{ some_bool | json }} + + The filter serializes the value to JSON and wraps it with sentinel markers. + When _render_templates_in_json() detects these markers, it unwraps and + parses the JSON to get the native typed value (bool, int, list, etc.). + + Args: + value: Any JSON-serializable value (bool, int, float, str, list, dict, None). + """ + return f"{_JSON_TYPE_MARKER}{json.dumps(value)}{_JSON_TYPE_MARKER}" + + +jinja_sandbox_env.filters["json"] = _json_type_filter + + # Mapping from TaskV2Status to the corresponding BlockStatus. Declared once at # import time so it is not recreated on each block execution. TASKV2_TO_BLOCK_STATUS: dict[TaskV2Status, BlockStatus] = { @@ -4032,11 +4054,30 @@ class HttpRequestBlock(Block): This is required because HTTP request bodies are often deeply nested dict/list structures, and templates may appear at any depth. + + Supports {{ expr | json }} filter for type-preserving JSON injection. """ if isinstance(value, str): - return self.format_block_parameter_template_from_workflow_run_context( + rendered = self.format_block_parameter_template_from_workflow_run_context( value, workflow_run_context, **template_kwargs ) + + if rendered.startswith(_JSON_TYPE_MARKER) and rendered.endswith(_JSON_TYPE_MARKER): + json_str = rendered[len(_JSON_TYPE_MARKER) : -len(_JSON_TYPE_MARKER)] + try: + return json.loads(json_str) + except json.JSONDecodeError: + raise FailedToFormatJinjaStyleParameter( + value, f"Raw JSON filter produced invalid JSON: {json_str}" + ) + elif _JSON_TYPE_MARKER in rendered: + raise FailedToFormatJinjaStyleParameter( + value, + "The '| json' filter can only be used for complete value replacement. " + "It cannot be combined with other text (e.g., 'prefix-{{ val | json }}'). " + "Remove the surrounding text or remove the '| json' filter.", + ) + return rendered if isinstance(value, list): return [_render_templates_in_json(item) for item in value] if isinstance(value, dict): diff --git a/uv.lock b/uv.lock index 94af82de..f6999e3a 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.11, <3.14" resolution-markers = [ "python_full_version >= '3.13'", @@ -5244,7 +5244,7 @@ requires-dist = [ { name = "types-toml", specifier = ">=0.10.8.7,<0.11" }, { name = "urllib3", specifier = ">=2.6.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.24.0.post1,<0.25" }, - { name = "websockets", specifier = "~=12.0" }, + { name = "websockets", specifier = ">=12.0,<15.1" }, { name = "zstandard", specifier = ">=0.25.0" }, ]