Skip to content

Commit 0537afc

Browse files
committed
refactor: unify int/float types to num and fix composition bug
**Type System Unification:** - Replace separate INT and FLOAT types with unified NUM type - Update ValueType and InputType enums in schema.py - Implement smart num parsing: returns int for whole numbers, float for decimals - Migrate all 74 workflow files from type: int/float to type: num - Update type coercion logic in executors_core.py - Regenerate schema.json with new type definitions **Bug Fix:** - Fix composition-output-passing workflow bug - Replace bash arithmetic $((...)) with Python for proper float handling - Child calculator now correctly processes float operations - Verified: 10 * 5 = 50, then 50 + 5 = 55 (was 50 before) **Updates:** - Update README.md and CLAUDE.md documentation - Regenerate 68 test snapshots with correct type handling - Add 4 new workflow output type test cases - All 34/35 workflows passing (1 paused as expected) **BREAKING CHANGE:** - Workflow YAML files using `type: int` or `type: float` should migrate to `type: num`
1 parent 4d831fe commit 0537afc

112 files changed

Lines changed: 1591 additions & 2174 deletions

File tree

Some content is hidden

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

README.md

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -226,9 +226,33 @@ blocks:
226226
command: printf "Hello, {{inputs.name}}!"
227227
228228
outputs:
229-
greeting: "{{blocks.greet.outputs.stdout}}"
229+
greeting:
230+
value: "{{blocks.greet.outputs.stdout}}"
231+
type: str
232+
description: "The greeting message"
230233
```
231234

235+
### Workflow Outputs
236+
237+
Workflow outputs support automatic type coercion, allowing you to declare the expected type and get properly typed values:
238+
239+
```yaml
240+
outputs:
241+
output_name:
242+
value: "{{blocks.block_id.outputs.field}}" # Variable expression
243+
type: str # Type declaration (optional)
244+
description: "Human-readable description" # Documentation (optional)
245+
```
246+
247+
**Supported Types:**
248+
249+
- **str** - Text values (default if type not specified)
250+
- **num** - Numeric values (integer or float - JSON doesn't distinguish)
251+
- **bool** - Boolean values (true/false)
252+
- **list** - List/array values
253+
- **dict** - Dictionary/object values
254+
- **json** - Parse JSON string into dict/list/str/num/bool/None
255+
232256
### Key Features
233257

234258
**Variable Substitution:**

schema.json

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,7 @@
2525
"type": {
2626
"enum": [
2727
"str",
28-
"int",
29-
"float",
28+
"num",
3029
"bool",
3130
"list",
3231
"dict"
@@ -48,10 +47,42 @@
4847
},
4948
"outputs": {
5049
"type": "object",
51-
"description": "Workflow output expressions",
50+
"description": "Workflow output expressions with type coercion",
5251
"patternProperties": {
5352
".*": {
54-
"type": "string"
53+
"additionalProperties": false,
54+
"description": "Schema for workflow-level output with type coercion.\n\nDefines outputs that the workflow exposes to callers. Outputs are expressions\nthat reference block outputs, with optional type coercion.\n\nAttributes:\n value: Expression (e.g., \"{{block.outputs.field}}\" or \"{{block.exit_code}}\")\n type: Output type (str, int, float, bool, json, list, dict) - defaults to str\n description: Optional human-readable output description\n\nType Coercion:\n When type is specified, the resolved value is coerced to that type:\n - str: Convert any value to string\n - int: Parse string to int or validate existing int\n - float: Parse string to float or validate existing float\n - bool: Parse string (\"true\"/\"false\") or validate existing bool\n - json: Parse JSON string to dict/list or validate existing JSON-compatible value\n - list: Validate existing list\n - dict: Validate existing dict\n\nExamples:\n # Minimal (type defaults to str)\n outputs:\n message:\n value: \"{{blocks.foo.outputs.msg}}\"\n\n # With type coercion\n outputs:\n count:\n value: \"{{blocks.foo.outputs.count}}\"\n type: int\n description: \"Number of items\"\n\n # Logical expression (evaluates to bool)\n success:\n value: \"{{blocks.test.outputs.exit_code}} == 0\"\n type: bool\n description: \"Whether tests passed\"",
55+
"properties": {
56+
"value": {
57+
"description": "Expression referencing block outputs",
58+
"minLength": 1,
59+
"title": "Value",
60+
"type": "string"
61+
},
62+
"type": {
63+
"$ref": "#/$defs/ValueType",
64+
"default": "str",
65+
"description": "Output type with automatic coercion (defaults to str)"
66+
},
67+
"description": {
68+
"anyOf": [
69+
{
70+
"type": "string"
71+
},
72+
{
73+
"type": "null"
74+
}
75+
],
76+
"default": null,
77+
"description": "Human-readable description",
78+
"title": "Description"
79+
}
80+
},
81+
"required": [
82+
"value"
83+
],
84+
"title": "WorkflowOutputSchema",
85+
"type": "object"
5586
}
5687
}
5788
},
@@ -840,7 +871,20 @@
840871
}
841872
}
842873
},
843-
"definitions": {
874+
"$defs": {
875+
"ValueType": {
876+
"description": "Unified Python type system for workflow values.\n\nSingle source of truth for all type declarations across:\n- Workflow inputs\n- Block outputs (file-based)\n- Workflow outputs\n- Output parsing logic\n\nThese match Python's built-in types for consistency with:\n- isinstance() checks in conditions: isinstance({{inputs.name}}, str)\n- Type annotations in executor models: Field(default=\"\", description=\"...\")\n- Variable resolution return types\n\nType mappings:\n- str: Text values (Python str)\n- num: Numeric values (int or float)\n- bool: Boolean values (Python bool)\n- list: List/array values (Python list)\n- dict: Dictionary/object values (Python dict)\n- json: Special - parse as JSON (returns dict/list/str/int/float/bool/None)",
877+
"enum": [
878+
"str",
879+
"num",
880+
"bool",
881+
"list",
882+
"dict",
883+
"json"
884+
],
885+
"title": "ValueType",
886+
"type": "string"
887+
},
844888
"ShellInput": {
845889
"additionalProperties": false,
846890
"description": "Input model for Shell executor.\n\nArchitecture (ADR-006):\n- Execute returns ShellOutput directly\n- Operation outcome determined by exit_code\n- Exceptions indicate execution failure",

src/workflows_mcp/engine/executor_base.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323

2424
from .block import BlockInput, BlockOutput
2525
from .execution import Execution
26-
from .schema import InputType
26+
from .schema import InputType, WorkflowOutputSchema
2727

2828

2929
class ExecutorSecurityLevel(Enum):
@@ -254,11 +254,16 @@ def generate_workflow_schema(self) -> dict[str, Any]:
254254
with open("workflow-schema.json", "w") as f:
255255
json.dump(schema, f, indent=2)
256256
"""
257-
# Collect all executor input schemas
257+
# Collect all executor input schemas and shared type definitions
258258
definitions = {}
259259
block_types = []
260260
type_conditionals = []
261261

262+
# Extract WorkflowOutputSchema and merge its $defs to root level
263+
# This ensures ValueType enum is accessible at the root for proper $ref resolution
264+
output_schema = WorkflowOutputSchema.model_json_schema()
265+
definitions.update(output_schema.pop("$defs", {}))
266+
262267
for type_name, executor in self._executors.items():
263268
# Get the actual Pydantic schema from the executor
264269
input_schema = executor.get_input_schema()
@@ -377,16 +382,16 @@ def generate_workflow_schema(self) -> dict[str, Any]:
377382
},
378383
"outputs": {
379384
"type": "object",
380-
"description": "Workflow output expressions",
381-
"patternProperties": {".*": {"type": "string"}},
385+
"description": "Workflow output expressions with type coercion",
386+
"patternProperties": {".*": output_schema},
382387
},
383388
"blocks": {
384389
"type": "array",
385390
"description": "Workflow execution blocks",
386391
"items": base_block_schema,
387392
},
388393
},
389-
"definitions": definitions,
394+
"$defs": definitions,
390395
}
391396

392397
def discover_entry_points(self, group: str = "mcp_workflows.executors") -> int:

src/workflows_mcp/engine/executors_core.py

Lines changed: 100 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ def parse_output_value(content: str, output_type: str) -> Any:
165165
166166
Args:
167167
content: Raw file content
168-
output_type: One of ValueType values: str, int, float, bool, json
168+
output_type: One of ValueType values: str, num, bool, json
169169
170170
Returns:
171171
Parsed value with correct Python type
@@ -177,16 +177,15 @@ def parse_output_value(content: str, output_type: str) -> Any:
177177

178178
if output_type == "str":
179179
return str(content) # Explicit cast for consistency
180-
elif output_type == "int":
180+
elif output_type == "num":
181+
# Parse as number (int or float)
181182
try:
182-
return int(content)
183+
# Try float first (accepts both int and float strings)
184+
value = float(content)
185+
# Return int if it's a whole number, otherwise float
186+
return int(value) if value.is_integer() else value
183187
except ValueError:
184-
raise ValueError(f"Cannot parse as int: {content}")
185-
elif output_type == "float":
186-
try:
187-
return float(content)
188-
except ValueError:
189-
raise ValueError(f"Cannot parse as float: {content}")
188+
raise ValueError(f"Cannot parse as num: {content}")
190189
elif output_type == "bool":
191190
# Accept: true/false, 1/0, yes/no (case-insensitive)
192191
lower = content.lower()
@@ -208,6 +207,98 @@ def parse_output_value(content: str, output_type: str) -> Any:
208207
raise ValueError(f"Unknown output type: {output_type}")
209208

210209

210+
def coerce_value_type(value: Any, output_type: str) -> Any:
211+
"""
212+
Coerce a value to the declared type (flexible version of parse_output_value).
213+
214+
Handles both string values that need parsing and already-typed values.
215+
Used for workflow output type coercion where values may already be correctly typed
216+
from nested workflows or block outputs.
217+
218+
Args:
219+
value: Value to coerce (can be str, int, float, bool, dict, list, etc.)
220+
output_type: One of ValueType values: str, num, bool, json, list, dict
221+
222+
Returns:
223+
Value coerced to correct Python type
224+
225+
Raises:
226+
ValueError: If value cannot be coerced to declared type
227+
228+
Examples:
229+
# String values (parsed)
230+
coerce_value_type("42", "num") -> 42
231+
coerce_value_type("42.5", "num") -> 42.5
232+
coerce_value_type("true", "bool") -> True
233+
coerce_value_type('{"a":1}', "json") -> {"a": 1}
234+
235+
# Already-typed values (validated and passed through)
236+
coerce_value_type(42, "num") -> 42
237+
coerce_value_type(42.5, "num") -> 42.5
238+
coerce_value_type(True, "bool") -> True
239+
coerce_value_type({"a": 1}, "json") -> {"a": 1}
240+
241+
# Type conversion when needed
242+
coerce_value_type(42, "str") -> "42"
243+
coerce_value_type(42.5, "str") -> "42.5"
244+
"""
245+
# Handle string values using existing parser
246+
if isinstance(value, str):
247+
# Use existing parse_output_value for strings
248+
return parse_output_value(value, output_type)
249+
250+
# Handle already-typed values with validation and conversion
251+
if output_type == "str":
252+
# Convert any value to string
253+
return str(value)
254+
255+
elif output_type == "num":
256+
# Accept both int and float (exclude bool since it's subclass of int)
257+
if isinstance(value, (int, float)) and not isinstance(value, bool):
258+
return value
259+
# Try to convert
260+
try:
261+
result = float(value)
262+
# Return int if whole number, otherwise float
263+
return int(result) if result.is_integer() else result
264+
except (ValueError, TypeError):
265+
raise ValueError(f"Cannot coerce {type(value).__name__} to num: {value}")
266+
267+
elif output_type == "bool":
268+
# If already bool, return as-is
269+
if isinstance(value, bool):
270+
return value
271+
# Try to convert using standard Python truthiness
272+
# But be strict - only accept actual booleans or 1/0 integers
273+
if isinstance(value, int) and value in [0, 1]:
274+
return bool(value)
275+
raise ValueError(
276+
f"Cannot coerce {type(value).__name__} to bool: {value}. "
277+
f"Only bool values or integers 0/1 are accepted."
278+
)
279+
280+
elif output_type == "json":
281+
# Accept dict, list, or JSON-compatible primitives
282+
if isinstance(value, (dict, list, str, int, float, bool, type(None))):
283+
return value
284+
raise ValueError(f"Cannot coerce {type(value).__name__} to json: {value}")
285+
286+
elif output_type == "list":
287+
# If already list, return as-is
288+
if isinstance(value, list):
289+
return value
290+
raise ValueError(f"Cannot coerce {type(value).__name__} to list: {value}")
291+
292+
elif output_type == "dict":
293+
# If already dict, return as-is
294+
if isinstance(value, dict):
295+
return value
296+
raise ValueError(f"Cannot coerce {type(value).__name__} to dict: {value}")
297+
298+
else:
299+
raise ValueError(f"Unknown output type: {output_type}")
300+
301+
211302
class ShellExecutor(BlockExecutor):
212303
"""
213304
Shell command executor.

0 commit comments

Comments
 (0)