Skip to content

Commit 5561fe0

Browse files
Enhancements to AI Scriptless (#45)
* Support dual store and data type * Improve status, condition and error policy * Support condition statement and error policy * Variable, command contract and validations * Update hint notes * Contract and others improvements * Contract/spec and improvements --------- Co-authored-by: diego-ferrand <diego.ferrand@abstracta.com.uy>
1 parent 923efe9 commit 5561fe0

19 files changed

Lines changed: 3230 additions & 290 deletions

formatters/ai_scriptless.py

Lines changed: 224 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,19 @@
77
CommandDefinitionSummary,
88
ScriptFlowElement,
99
ScriptParameter,
10+
ScriptStepArgument,
11+
ScriptStepDetail,
12+
ScriptStepParameter,
1013
ScriptVariableSummary,
1114
SnapshotListResult,
1215
SnapshotSummary,
1316
TestStructure,
1417
)
18+
from tools.ai_scriptless.definitions import (
19+
parameter_label,
20+
restriction_allowed_values,
21+
restriction_range,
22+
)
1523
from tools.ai_scriptless.elements import normalize_if_statement_aliases
1624

1725
PRIMARY_AI_COMMAND_IDS = (
@@ -30,7 +38,7 @@ def command_selection_policy_info() -> List[str]:
3038
" • ai_user-action — user interactions (open browser/app, navigate to URL, tap, type, dismiss overlays); "
3139
"argument: action (natural language).",
3240
" • ai_validation — checkpoints and assertions; argument: validation (natural language).",
33-
" • ai_visual-comparison — visual/baseline comparison; argument: name.",
41+
" • ai_visual-comparison — visual/baseline comparison; argument: baselineId.",
3442
"Prefer ai_user-action for navigation (e.g. open browser and go to URL), not browser_goto / browser_open.",
3543
"Do not use browser_*, touch_tap, webpage.element_*, checkpoint_text, etc. unless the user explicitly "
3644
"requests a non-AI command or agreed that AI commands cannot meet a documented requirement.",
@@ -42,8 +50,11 @@ def command_selection_policy_info() -> List[str]:
4250
"Keep command arguments nested inside cmd_arguments: the 'action' parameter of ai_user-action "
4351
"collides with the tool's own action key if flattened into args.",
4452
"Values are constants by default; pass {\"data_source\": \"VARIABLE\", \"value\": \"<variable name>\"} "
45-
"to bind an argument to a script variable.",
53+
"to bind an argument to a script variable, or {\"data_source\": \"DATATABLE\", \"table_name\": \"<table>\", "
54+
"\"column\": \"<column>\"} to bind it to a DataTable column.",
4655
"modify_command merges: only the arguments sent are replaced, the others keep their current value.",
56+
"Values are validated against the declared type, range, allowed values and data sources; "
57+
"view_test_step reports all four for every argument of an existing step.",
4758
]
4859

4960
def format_ai_scriptless_tests_filter_values(tests: dict[str, Any], params: Optional[dict] = None) -> dict[str, Any]:
@@ -168,17 +179,17 @@ def _parameter_display_label(param: dict[str, Any]) -> str:
168179

169180

170181
def _format_argument_display_value(element: dict[str, Any], argument_name: str) -> Optional[str]:
171-
for argument in element.get("arguments", []):
172-
if argument.get("name") != argument_name:
173-
continue
174-
data = argument.get("data", {})
175-
value = data.get("value")
176-
if value is None:
177-
return None
178-
if data.get("secured"):
179-
return "<secured>"
180-
return str(value)
181-
return None
182+
# A multivalued parameter has several arguments under one name; the UI shows the last.
183+
matches = [a for a in element.get("arguments", []) if a.get("name") == argument_name]
184+
if not matches:
185+
return None
186+
data = matches[-1].get("data", {})
187+
value = data.get("value")
188+
if value is None:
189+
return None
190+
if data.get("secured"):
191+
return "<secured>"
192+
return str(value)
182193

183194

184195
def _command_step_display_name(
@@ -245,19 +256,24 @@ def _step_display_name(element: dict[str, Any], definitions_map: dict[str, dict[
245256

246257
if element_type == "Loop":
247258
iterator = element.get("iterator", {})
259+
variable = iterator.get("variable")
260+
if variable:
261+
return f"Loop ({variable})"
248262
count = iterator.get("count")
249263
if count is not None:
250-
return f"Loop ({count})"
264+
# The API serializes the count as a float; 2.0 reads as a broken count.
265+
return f"Loop ({_range_bound(count)})"
251266
return "Loop"
252267

253268
if element_type == "IfStatement":
254-
expression = element.get("expression") or element.get("label")
255-
if expression:
256-
return f"Condition ({expression})"
269+
label = element.get("label")
270+
if label:
271+
return f"Condition ({label})"
257272
return "Condition"
258273

259274
if element_type == "LogicalStep":
260-
label = element.get("label")
275+
# The UI stores the group title in `name`; `label` is only what older MCP writes used.
276+
label = element.get("name") or element.get("label")
261277
if label:
262278
return label
263279
return "Step"
@@ -357,6 +373,196 @@ def format_test_structure(payload: dict[str, Any], params: Optional[dict] = None
357373
)
358374

359375

376+
def _restriction_allowed_values(
377+
param: dict[str, Any],
378+
command_id: Optional[str] = None,
379+
) -> List[str]:
380+
return list(restriction_allowed_values(param, command_id))
381+
382+
383+
def _renamed_declared_label(
384+
command_id: Optional[str],
385+
name: Optional[str],
386+
declared: Optional[str],
387+
) -> Optional[str]:
388+
"""The declared label, reported only where the editor shows a different one."""
389+
shown = parameter_label(command_id, name, declared)
390+
return declared if shown != declared else None
391+
392+
393+
def _range_bound(value: Any) -> str:
394+
# The API serializes bounds as floats (0.0, 3600.0); render integral ones as integers.
395+
if isinstance(value, float) and value.is_integer():
396+
return str(int(value))
397+
return str(value)
398+
399+
400+
def _restriction_range(param: dict[str, Any]) -> Optional[str]:
401+
minimum, maximum = restriction_range(param)
402+
if minimum is None and maximum is None:
403+
return None
404+
return f"{_range_bound(minimum)}..{_range_bound(maximum)}"
405+
406+
407+
def _step_parameters_map(definition: Optional[dict[str, Any]]) -> dict[str, dict[str, Any]]:
408+
if not definition:
409+
return {}
410+
mandatory_names = {
411+
param.get("name") or param.get("parameterName")
412+
for param in _definition_data(definition).get("mandatoryParameters") or []
413+
if isinstance(param, dict)
414+
}
415+
parameters: dict[str, dict[str, Any]] = {}
416+
for param in _iter_definition_parameters(definition):
417+
name = param.get("name") or param.get("parameterName")
418+
parameters[name] = {**param, "_mandatory": name in mandatory_names}
419+
return parameters
420+
421+
422+
def _step_argument(
423+
argument: dict[str, Any],
424+
parameters: dict[str, dict[str, Any]],
425+
command_id: Optional[str] = None,
426+
) -> ScriptStepArgument:
427+
name = argument.get("name", "")
428+
data = argument.get("data") or {}
429+
value = data.get("value")
430+
if data.get("secured") and value:
431+
value = "<secured>"
432+
param = parameters.get(name)
433+
display = (param or {}).get("display") or {}
434+
return ScriptStepArgument(
435+
name=name,
436+
value=value,
437+
data_source=data.get("dataSource"),
438+
parameter_type=(param or {}).get("dataType"),
439+
mandatory=(param or {}).get("_mandatory") if param else None,
440+
declared=param is not None or not parameters,
441+
allowed_data_sources=list((param or {}).get("dataSources") or []),
442+
allowed_values=_restriction_allowed_values(param or {}, command_id),
443+
value_range=_restriction_range(param or {}),
444+
label=parameter_label(command_id, name, display.get("name")),
445+
declared_label=_renamed_declared_label(command_id, name, display.get("name")),
446+
table_name=data.get("tableName"),
447+
column=data.get("column"),
448+
)
449+
450+
451+
def _step_argument_is_set(argument: ScriptStepArgument) -> bool:
452+
"""A DataTable binding is a value even though it carries no value field."""
453+
if argument.data_source == "DATATABLE":
454+
return bool(argument.table_name or argument.column)
455+
return argument.value is not None and str(argument.value).strip() != ""
456+
457+
458+
def _unset_step_parameters(
459+
element: dict[str, Any],
460+
parameters: dict[str, dict[str, Any]],
461+
command_id: Optional[str] = None,
462+
) -> List[ScriptStepParameter]:
463+
set_names = {argument.get("name") for argument in element.get("arguments", [])}
464+
unset: List[ScriptStepParameter] = []
465+
for name, param in parameters.items():
466+
if name in set_names:
467+
continue
468+
display = param.get("display") or {}
469+
mandatory = bool(param.get("_mandatory"))
470+
unset.append(ScriptStepParameter(
471+
name=name,
472+
parameter_type=param.get("dataType"),
473+
mandatory=mandatory,
474+
default_value=param.get("defaultValue"),
475+
allowed_data_sources=list(param.get("dataSources") or []),
476+
allowed_values=_restriction_allowed_values(param, command_id),
477+
value_range=_restriction_range(param),
478+
label=parameter_label(command_id, name, display.get("name")),
479+
declared_label=_renamed_declared_label(command_id, name, display.get("name")),
480+
# Some commands declare ~60 optional parameters with long help texts; carrying them
481+
# all would dwarf the step itself. Names, types and accepted values are enough to
482+
# edit, and get_command_definitions has the full help when it is actually needed.
483+
help_text=(param.get("helpText") or display.get("helpText")) if mandatory else None,
484+
))
485+
unset.sort(key=lambda parameter: (not parameter.mandatory, parameter.name))
486+
return unset
487+
488+
489+
def _step_children_paths(element: dict[str, Any], step_path: str) -> List[str]:
490+
if element.get("@type") == "IfStatement":
491+
return [
492+
f"{step_path}.b{branch_index}"
493+
for branch_index, _branch in enumerate(element.get("branches", []))
494+
]
495+
return [
496+
f"{step_path}.{child_index}"
497+
for child_index, _child in enumerate(element.get("flowElements", []))
498+
]
499+
500+
501+
def _step_detail_notes(element: dict[str, Any], detail_arguments: List[ScriptStepArgument]) -> List[str]:
502+
notes: List[str] = []
503+
undeclared = [argument.name for argument in detail_arguments if not argument.declared]
504+
if undeclared:
505+
notes.append(
506+
f"Argument(s) not declared by the command: {', '.join(undeclared)}. "
507+
"Perfecto ignores them at execution time; they were most likely persisted by mistake."
508+
)
509+
if detail_arguments:
510+
notes.append(
511+
"To edit, call modify_command with cmd_arguments keyed by these argument names. "
512+
"Only the arguments you send change; the others keep their current value."
513+
)
514+
if element.get("active") is False:
515+
notes.append("This step is excluded from the run; re-include it with set_command_enabled.")
516+
empty_mandatory = [
517+
argument.name for argument in detail_arguments
518+
if argument.mandatory and not _step_argument_is_set(argument)
519+
]
520+
if empty_mandatory:
521+
notes.append(
522+
f"Mandatory argument(s) with no value: {', '.join(empty_mandatory)}. "
523+
"The step will not do anything until they are set with modify_command."
524+
)
525+
return notes
526+
527+
528+
def format_step_detail(
529+
element: dict[str, Any],
530+
item_key: str,
531+
step_path: str,
532+
command_definitions: Optional[list] = None,
533+
statement_step_path: Optional[str] = None,
534+
) -> ScriptStepDetail:
535+
"""Full configuration of one step, joined with what its command declares."""
536+
definitions_map = _definitions_map(command_definitions)
537+
command_id = _command_id(element.get("command"), element.get("subcommand"))
538+
parameters = _step_parameters_map(definitions_map.get(command_id) if command_id else None)
539+
arguments = [
540+
_step_argument(argument, parameters, command_id)
541+
for argument in element.get("arguments", [])
542+
]
543+
iterator = element.get("iterator") or {}
544+
return ScriptStepDetail(
545+
item_key=item_key,
546+
step_path=step_path,
547+
type=element.get("@type", ""),
548+
name=_step_display_name(element, definitions_map),
549+
command_id=command_id,
550+
command=element.get("command"),
551+
subcommand=element.get("subcommand"),
552+
active=element.get("active", True),
553+
error_policy=element.get("errorPolicy"),
554+
comment=element.get("comment"),
555+
arguments=arguments,
556+
unset_parameters=_unset_step_parameters(element, parameters, command_id),
557+
label=element.get("name") or element.get("label"),
558+
statement_step_path=statement_step_path,
559+
loop_count=iterator.get("count"),
560+
loop_variable=iterator.get("variable"),
561+
children=_step_children_paths(element, step_path),
562+
notes=_step_detail_notes(element, arguments),
563+
)
564+
565+
360566
def _flatten_command_catalog(node: dict[str, Any], category: Optional[str] = None) -> List[CommandCatalogEntry]:
361567
entries: List[CommandCatalogEntry] = []
362568
node_name = node.get("name")

0 commit comments

Comments
 (0)