Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 71 additions & 12 deletions python/packages/devui/agent_framework_devui/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

"""Utility functions for DevUI."""

import collections.abc as abc
import inspect
import json
import logging
Expand Down Expand Up @@ -395,19 +396,38 @@ def generate_input_schema(input_type: type) -> dict[str, Any]:
"""Generate JSON schema for workflow input type.

Supports multiple input types in priority order:
1. Built-in types (str, dict, int, etc.)
2. Pydantic models (via model_json_schema)
3. SerializationMixin classes (via __init__ introspection)
4. Dataclasses (via fields introspection)
5. Fallback to string
0. None type (type(None)) → {"type": "null"}
1. Union types (X | None) → extracts non-None type with default: None
2. Built-in types (str, dict, int, float, bool)
3. Generic types (list, List, Sequence)
4. Pydantic models (via model_json_schema)
5. SerializationMixin classes (via __init__ introspection)
6. Dataclasses (via fields introspection)
7. Fallback to string

Args:
input_type: Input type to generate schema for

Returns:
JSON schema dict
"""
# 1. Built-in types
# Handle None type (no input required)
if input_type is type(None):
return {"type": "null"}

# Check for Union types (e.g., str | None, list[str] | None) before other generic types
origin = get_origin(input_type)
if origin is Union or isinstance(input_type, UnionType):
args = get_args(input_type)
# Check if it's a Union with None (Optional type)
if type(None) in args:
non_none_types = [arg for arg in args if arg is not type(None)]
if non_none_types:
base_schema = generate_input_schema(non_none_types[0])
base_schema["default"] = None
return base_schema

# 2. Built-in types
if input_type is str:
return {"type": "string"}
if input_type is dict:
Expand All @@ -418,20 +438,31 @@ def generate_input_schema(input_type: type) -> dict[str, Any]:
return {"type": "number"}
if input_type is bool:
return {"type": "boolean"}
if input_type is list:
return {"type": "array"}

# 2. Pydantic models (legacy support)
# 3. Generic types (list, List, Sequence, etc.)
if origin is not None:
if origin is list or origin is abc.Sequence:
args = get_args(input_type)
if args:
items_schema = _type_to_schema(args[0], "item")
return {"type": "array", "items": items_schema}
return {"type": "array"}

# 4. Pydantic models (legacy support)
if hasattr(input_type, "model_json_schema"):
return input_type.model_json_schema() # type: ignore

# 3. SerializationMixin classes (Message, etc.)
# 5. SerializationMixin classes (Message, etc.)
if is_serialization_mixin(input_type):
return generate_schema_from_serialization_mixin(input_type)

# 4. Dataclasses
# 6. Dataclasses
if is_dataclass(input_type):
return generate_schema_from_dataclass(input_type)

# 5. Fallback to string
# 7. Fallback to string
type_name = getattr(input_type, "__name__", str(input_type))
return {"type": "string", "description": f"Input type: {type_name}"}

Expand All @@ -457,9 +488,18 @@ def parse_input_for_type(input_data: Any, target_type: type) -> Any:
Returns:
Parsed input matching target_type, or original input if parsing fails
"""
# Handle None type specially (when parameter is annotated as just `None`)
if target_type is type(None):
return None

# If already correct type, return as-is
if isinstance(input_data, target_type):
return input_data
# Note: We skip isinstance check if target_type could cause TypeError
try:
if isinstance(input_data, target_type):
return input_data
except TypeError:
# isinstance can raise TypeError for some special types
pass

# Handle string input
if isinstance(input_data, str):
Expand Down Expand Up @@ -584,6 +624,25 @@ def _parse_dict_input(input_dict: dict[str, Any], target_type: type) -> Any:
Returns:
Parsed input or original dict
"""
# Handle Union types (e.g., str | None, int | None) - extract non-None type
origin = get_origin(target_type)
if origin is Union or isinstance(target_type, UnionType):
args = get_args(target_type)
non_none_types = [arg for arg in args if arg is not type(None)]
if len(non_none_types) == 1:
base_type = non_none_types[0]

# Handle None value explicitly
if "input" in input_dict and input_dict["input"] is None:
return None

# Handle empty dict for optional types - treat as None
if not input_dict:
return None

# Recursively parse with the base type
return _parse_dict_input(input_dict, base_type)

Comment on lines +643 to +645
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Union type handling creates a potential infinite recursion risk if _parse_dict_input is called with the same Union type after filtering out None. While this doesn't happen with the current logic (since line 641 passes the base_type, not the original Union), the recursive call pattern is fragile and could break if the code is refactored.

For safer and clearer code, consider handling the base type's parsing inline rather than recursing. For example:

# After line 641, instead of recursion:
if base_type in (str, int, float, bool):
    # Handle primitive directly
    ...
elif is_dataclass(base_type):
    # Handle dataclass directly
    ...
Suggested change
# Recursively parse with the base type
return _parse_dict_input(input_dict, base_type)
# Inline parse with the base type to avoid fragile recursion
if base_type in (str, int, float, bool):
try:
# If it's already the right type, return as-is
if isinstance(input_dict, base_type):
return input_dict
# Try "input" field first (common for workflow inputs)
if "input" in input_dict:
return base_type(input_dict["input"]) # type: ignore
# If single-key dict, extract the value
if len(input_dict) == 1:
value = next(iter(input_dict.values()))
return base_type(value) # type: ignore
# Otherwise, return as-is
return input_dict
except (ValueError, TypeError) as e:
logger.debug(f"Failed to convert dict to {base_type}: {e}")
return input_dict
if base_type is dict:
return input_dict
if hasattr(base_type, "model_validate"):
try:
return base_type.model_validate(input_dict) # type: ignore
except Exception as e:
logger.debug(f"Failed to validate dict as Pydantic model: {e}")
if is_serialization_mixin(base_type):
try:
if hasattr(base_type, "from_dict"):
return base_type.from_dict(input_dict) # type: ignore
return base_type(**input_dict) # type: ignore
except Exception as e:
logger.debug(f"Failed to parse dict as SerializationMixin: {e}")
if is_dataclass(base_type):
try:
return base_type(**input_dict) # type: ignore
except Exception as e:
logger.debug(f"Failed to parse dict as dataclass: {e}")
# Fallback: return original dict
return input_dict

Copilot uses AI. Check for mistakes.
# Handle primitive types - extract from common field names
if target_type in (str, int, float, bool):
try:
Expand Down
105 changes: 50 additions & 55 deletions python/packages/devui/agent_framework_devui/ui/assets/index.js

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,17 @@ export function RunWorkflowButton({
isChatMessage: false,
};

// Null type means no input required
if (inputSchema.type === "null") {
return {
needsInput: false,
hasDefaults: false,
fieldCount: 0,
canRunDirectly: true,
isChatMessage: false,
};
}

if (inputSchema.type === "string") {
return {
needsInput: !inputSchema.default,
Expand All @@ -98,6 +109,36 @@ export function RunWorkflowButton({
};
}

if (inputSchema.type === "integer" || inputSchema.type === "number") {
return {
needsInput: inputSchema.default === undefined,
hasDefaults: inputSchema.default !== undefined,
fieldCount: 1,
canRunDirectly: inputSchema.default !== undefined,
isChatMessage: false,
};
}

if (inputSchema.type === "boolean") {
return {
needsInput: true,
hasDefaults: true,
fieldCount: 1,
canRunDirectly: false,
isChatMessage: false,
};
}

if (inputSchema.type === "array") {
return {
needsInput: true,
hasDefaults: !!inputSchema.default,
fieldCount: 1,
canRunDirectly: !!inputSchema.default,
isChatMessage: false,
};
}

if (inputSchema.type === "object" && inputSchema.properties) {
const properties = inputSchema.properties;
const fields = Object.entries(properties);
Expand Down Expand Up @@ -126,53 +167,50 @@ export function RunWorkflowButton({
};
}, [inputSchema]);

const buildDefaultData = (): Record<string, unknown> => {
const defaultData: Record<string, unknown> = {};

if (inputSchema?.type === "null") {
// No input needed
} else if (inputSchema?.type === "string" && inputSchema.default) {
defaultData.input = inputSchema.default;
} else if (inputSchema?.type === "boolean") {
defaultData.input = inputSchema.default ?? false;
} else if (
(inputSchema?.type === "integer" || inputSchema?.type === "number") &&
inputSchema.default !== undefined
) {
defaultData.input = inputSchema.default;
} else if (inputSchema?.type === "array" && inputSchema.default) {
defaultData.input = inputSchema.default;
} else if (inputSchema?.type === "object" && inputSchema.properties) {
Object.entries(inputSchema.properties).forEach(
([key, schema]: [string, JSONSchemaProperty]) => {
if (schema.default !== undefined) {
defaultData[key] = schema.default;
} else if (schema.enum && schema.enum.length > 0) {
defaultData[key] = schema.enum[0];
}
}
);
}

return defaultData;
};

const handleDirectRun = () => {
if (workflowState === "running" && onCancel) {
onCancel();
} else if (inputAnalysis.canRunDirectly) {
// Build default data
const defaultData: Record<string, unknown> = {};

if (inputSchema?.type === "string" && inputSchema.default) {
defaultData.input = inputSchema.default;
} else if (inputSchema?.type === "object" && inputSchema.properties) {
Object.entries(inputSchema.properties).forEach(
([key, schema]: [string, JSONSchemaProperty]) => {
if (schema.default !== undefined) {
defaultData[key] = schema.default;
} else if (schema.enum && schema.enum.length > 0) {
defaultData[key] = schema.enum[0];
}
}
);
}

onRun(defaultData);
onRun(buildDefaultData());
} else {
setShowModal(true);
}
};

const handleRunFromCheckpoint = (checkpointId: string) => {
if (inputAnalysis.canRunDirectly) {
// Build default data
const defaultData: Record<string, unknown> = {};

if (inputSchema?.type === "string" && inputSchema.default) {
defaultData.input = inputSchema.default;
} else if (inputSchema?.type === "object" && inputSchema.properties) {
Object.entries(inputSchema.properties).forEach(
([key, schema]: [string, JSONSchemaProperty]) => {
if (schema.default !== undefined) {
defaultData[key] = schema.default;
} else if (schema.enum && schema.enum.length > 0) {
defaultData[key] = schema.enum[0];
}
}
);
}

onRun(defaultData, checkpointId);
onRun(buildDefaultData(), checkpointId);
} else {
// TODO: Pass checkpoint ID to modal for custom inputs
setShowModal(true);
Expand Down Expand Up @@ -204,7 +242,7 @@ export function RunWorkflowButton({
<Loader2 className="w-4 h-4 animate-spin" />
) : workflowState === "error" ? (
<RotateCcw className="w-4 h-4" />
) : inputAnalysis.needsInput && !inputAnalysis.canRunDirectly ? (
) : !inputAnalysis.canRunDirectly ? (
<Settings className="w-4 h-4" />
) : (
<Play className="w-4 h-4" />
Expand Down Expand Up @@ -381,8 +419,18 @@ export function RunWorkflowButton({
<Badge variant="secondary">
{inputAnalysis.isChatMessage
? "Chat Message"
: inputSchema.type === "null"
? "No Input"
: inputSchema.type === "string"
? "Simple Text"
: inputSchema.type === "integer"
? "Integer"
: inputSchema.type === "number"
? "Number"
: inputSchema.type === "boolean"
? "Boolean"
: inputSchema.type === "array"
? "Array"
: "Structured Data"}
</Badge>
</div>
Expand All @@ -409,7 +457,21 @@ export function RunWorkflowButton({
) : (
<WorkflowInputForm
inputSchema={inputSchema}
inputTypeName="Input"
inputTypeName={
inputAnalysis.fieldCount === 0
? "No Input"
: inputSchema.type === "string"
? "String"
: inputSchema.type === "integer"
? "Integer"
: inputSchema.type === "number"
? "Number"
: inputSchema.type === "boolean"
? "Boolean"
: inputSchema.type === "array"
? "Array"
: "Object"
}
onSubmit={(values) => {
onRun(values as Record<string, unknown>);
setShowModal(false);
Expand Down
Loading
Loading