ww-agentic-workflows 1.0.0.dev3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. ww/__init__.py +18 -0
  2. ww/_bundled_extensions/ww/git/extension.py +1728 -0
  3. ww/action_execution.py +887 -0
  4. ww/actions/__init__.py +94 -0
  5. ww/actions/command.py +444 -0
  6. ww/actions/contracts.py +699 -0
  7. ww/actions/extension.py +197 -0
  8. ww/actions/mcp.py +84 -0
  9. ww/actions/prompt.py +74 -0
  10. ww/actions/skill.py +62 -0
  11. ww/actions/slash_command.py +63 -0
  12. ww/agents.py +151 -0
  13. ww/amendments.py +54 -0
  14. ww/artifacts.py +93 -0
  15. ww/assessments.py +181 -0
  16. ww/assets/__init__.py +2 -0
  17. ww/assets/agent_instructions.md +49 -0
  18. ww/assets/docs/examples.md +879 -0
  19. ww/assets/docs/features.md +4639 -0
  20. ww/assets/docs/specification.md +1876 -0
  21. ww/assets/noww_skill.md +11 -0
  22. ww/assets/workflows/catchall.yaml +26 -0
  23. ww/assets/workflows/onboarding.yaml +586 -0
  24. ww/assets/workflows/scriptize.yaml +130 -0
  25. ww/assets/ww-automate_skill.md +23 -0
  26. ww/assets/ww-deduce-feedback_skill.md +38 -0
  27. ww/assets/ww-feedback-rules_skill.md +48 -0
  28. ww/assets/ww-learn-project_skill.md +22 -0
  29. ww/assets/ww-refresh_skill.md +26 -0
  30. ww/assets/ww-rule_skill.md +83 -0
  31. ww/assets/ww-rules-from-artifacts_skill.md +22 -0
  32. ww/assets/ww-scriptize_skill.md +33 -0
  33. ww/assets/ww-setup_skill.md +94 -0
  34. ww/assets/ww-solve_skill.md +23 -0
  35. ww/assets/ww-suggest_skill.md +32 -0
  36. ww/assets/ww-wizard_skill.md +105 -0
  37. ww/assets/ww_skill.md +59 -0
  38. ww/assignments.py +283 -0
  39. ww/bootstrap.py +405 -0
  40. ww/builtin_workflows.py +215 -0
  41. ww/changes.py +225 -0
  42. ww/child_coordination.py +482 -0
  43. ww/children.py +106 -0
  44. ww/claude_permissions.py +115 -0
  45. ww/cli/__init__.py +7 -0
  46. ww/cli/__main__.py +6 -0
  47. ww/cli/audit.py +129 -0
  48. ww/cli/catalogs.py +131 -0
  49. ww/cli/discover.py +607 -0
  50. ww/cli/initialization.py +898 -0
  51. ww/cli/lookup.py +287 -0
  52. ww/cli/main.py +1768 -0
  53. ww/cli/parser.py +1200 -0
  54. ww/cli/prompts.py +217 -0
  55. ww/cli/updates.py +117 -0
  56. ww/completion_artifacts.py +156 -0
  57. ww/completion_inputs.py +39 -0
  58. ww/config/__init__.py +582 -0
  59. ww/config/actions.py +591 -0
  60. ww/config/composition.py +571 -0
  61. ww/config/rules.py +511 -0
  62. ww/config/steps.py +1220 -0
  63. ww/config/values.py +223 -0
  64. ww/config_files.py +191 -0
  65. ww/config_writes.py +264 -0
  66. ww/contracts.py +155 -0
  67. ww/control.py +41 -0
  68. ww/defaults.py +130 -0
  69. ww/design_docs.py +32 -0
  70. ww/discovery.py +104 -0
  71. ww/documents.py +217 -0
  72. ww/errors.py +18 -0
  73. ww/executable.py +43 -0
  74. ww/execution_models/__init__.py +64 -0
  75. ww/execution_models/construction.py +148 -0
  76. ww/execution_models/decoding.py +38 -0
  77. ww/execution_models/plan_codec.py +565 -0
  78. ww/execution_models/records.py +1206 -0
  79. ww/execution_models/runs.py +266 -0
  80. ww/extensions/__init__.py +40 -0
  81. ww/extensions/api.py +559 -0
  82. ww/extensions/registry.py +864 -0
  83. ww/extensions/store.py +78 -0
  84. ww/feedback.py +342 -0
  85. ww/handler_repairs.py +57 -0
  86. ww/hooks/__init__.py +40 -0
  87. ww/hooks/agents.py +380 -0
  88. ww/hooks/install.py +168 -0
  89. ww/hooks/notices.py +206 -0
  90. ww/hooks/records.py +209 -0
  91. ww/hooks/runtime.py +266 -0
  92. ww/hooks/transcripts.py +183 -0
  93. ww/inspect.py +896 -0
  94. ww/instructions/__init__.py +17 -0
  95. ww/instructions/builder.py +1682 -0
  96. ww/instructions/commands.py +335 -0
  97. ww/instructions/handoff.py +149 -0
  98. ww/instructions/models.py +686 -0
  99. ww/instructions/policy.py +219 -0
  100. ww/instructions/text.py +168 -0
  101. ww/interactions.py +187 -0
  102. ww/interpolation.py +37 -0
  103. ww/item_passes.py +167 -0
  104. ww/items.py +99 -0
  105. ww/locking.py +207 -0
  106. ww/metadata_publication.py +230 -0
  107. ww/onboarding.py +229 -0
  108. ww/open_work.py +236 -0
  109. ww/operations.py +193 -0
  110. ww/operator_ui/__init__.py +16 -0
  111. ww/operator_ui/page.html +351 -0
  112. ww/operator_ui/server.py +215 -0
  113. ww/operator_ui/session.py +389 -0
  114. ww/operator_ui/sheet.py +104 -0
  115. ww/operator_ui/view.py +109 -0
  116. ww/output.py +339 -0
  117. ww/output_adapters/__init__.py +12 -0
  118. ww/output_adapters/base.py +25 -0
  119. ww/output_adapters/json_adapter.py +37 -0
  120. ww/output_adapters/markdown.py +2293 -0
  121. ww/output_adapters/rule_pages.py +337 -0
  122. ww/output_adapters/terminal.py +21 -0
  123. ww/package_updates.py +167 -0
  124. ww/plan/__init__.py +38 -0
  125. ww/plan/actions.py +207 -0
  126. ww/plan/compiler.py +1492 -0
  127. ww/plan/constructs.py +456 -0
  128. ww/plan/models.py +665 -0
  129. ww/project_config.py +752 -0
  130. ww/recovery.py +401 -0
  131. ww/replanning.py +367 -0
  132. ww/results.py +77 -0
  133. ww/rule_checks.py +230 -0
  134. ww/rule_conversion.py +331 -0
  135. ww/rule_disputes.py +148 -0
  136. ww/rule_store.py +456 -0
  137. ww/rule_verification.py +714 -0
  138. ww/rule_views.py +447 -0
  139. ww/rule_writes.py +920 -0
  140. ww/run_coordination.py +158 -0
  141. ww/runtimes.py +105 -0
  142. ww/service.py +4405 -0
  143. ww/setup_apply.py +428 -0
  144. ww/step_values.py +20 -0
  145. ww/storage.py +447 -0
  146. ww/storage_adapters/__init__.py +36 -0
  147. ww/storage_adapters/base.py +540 -0
  148. ww/storage_adapters/filesystem.py +370 -0
  149. ww/storage_adapters/memory.py +195 -0
  150. ww/storage_adapters/project_metadata.py +69 -0
  151. ww/storage_adapters/task_document.py +484 -0
  152. ww/task_ids.py +114 -0
  153. ww/task_references.py +124 -0
  154. ww/transitions.py +1619 -0
  155. ww/updates.py +399 -0
  156. ww/upgrade.py +95 -0
  157. ww/validation.py +168 -0
  158. ww/variables.py +275 -0
  159. ww/workflow_config.py +854 -0
  160. ww/workflow_update.py +239 -0
  161. ww/workflow_validation.py +1260 -0
  162. ww/workspace.py +50 -0
  163. ww_agentic_workflows-1.0.0.dev3.dist-info/METADATA +690 -0
  164. ww_agentic_workflows-1.0.0.dev3.dist-info/RECORD +167 -0
  165. ww_agentic_workflows-1.0.0.dev3.dist-info/WHEEL +4 -0
  166. ww_agentic_workflows-1.0.0.dev3.dist-info/entry_points.txt +2 -0
  167. ww_agentic_workflows-1.0.0.dev3.dist-info/licenses/LICENSE +674 -0
ww/config/steps.py ADDED
@@ -0,0 +1,1220 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ """Recursive step parsing and reusable handler catalog parsing."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from dataclasses import replace
7
+ from typing import Any, cast, get_args
8
+
9
+ from ww.actions import (
10
+ DefinedAction,
11
+ DefinitionOverrideContext,
12
+ Prompt,
13
+ actions,
14
+ )
15
+ from ww.contracts import ItemAssignment, ItemOperation, LoopAssignment, StepRole
16
+ from ww.errors import ConfigurationError
17
+ from ww.items import FIELD_NAME
18
+ from ww.operations import (
19
+ CHILD_LAUNCH_SETTINGS,
20
+ ChildLaunch,
21
+ ChildWorkflowRun,
22
+ WorkflowHandoff,
23
+ )
24
+ from ww.validation import is_positive_int
25
+ from ww.workflow_config import (
26
+ ChildFlow,
27
+ ChoiceDefinition,
28
+ HandlerDefinition,
29
+ HookDefinition,
30
+ ItemFlow,
31
+ StepDefinition,
32
+ StepRule,
33
+ step_tree,
34
+ )
35
+
36
+ from .actions import (
37
+ _bare_extension_reference,
38
+ _handler_keys,
39
+ _parse_handler,
40
+ _parse_hooks,
41
+ extension_reference,
42
+ )
43
+ from .rules import parse_step_rules
44
+ from .values import (
45
+ _NAME,
46
+ _description,
47
+ _mapping,
48
+ _named_entry,
49
+ _nonempty_string,
50
+ _only,
51
+ _optional_agent,
52
+ _optional_string,
53
+ _profile,
54
+ _role,
55
+ _subagents,
56
+ _unique,
57
+ )
58
+
59
+ STEP_ONLY_KEYS: set[str] = {
60
+ "hooks",
61
+ "steps",
62
+ "loop",
63
+ "max_rounds",
64
+ "assignment",
65
+ "break",
66
+ "continue",
67
+ "role",
68
+ "subagents",
69
+ "interactive",
70
+ "explicit",
71
+ "learnable",
72
+ "choices",
73
+ "profile",
74
+ "items",
75
+ "item_phase",
76
+ "start_child",
77
+ "artifact_from",
78
+ "artifact",
79
+ "children",
80
+ "handler",
81
+ "question",
82
+ "outcomes",
83
+ "positive",
84
+ "negative",
85
+ "mixed",
86
+ "rules",
87
+ }
88
+
89
+ CHILD_FLOW_KEYS = {"description", "workflow", "steps", "assignment"}
90
+ ITEM_FLOW_KEYS = {
91
+ "description",
92
+ "steps",
93
+ "assignment",
94
+ "persistent",
95
+ "analyze",
96
+ "resolve",
97
+ "report",
98
+ "variables",
99
+ "saves",
100
+ "interactive",
101
+ "choices",
102
+ "identity",
103
+ "unique",
104
+ "agent",
105
+ "model",
106
+ "reasoning",
107
+ "profile",
108
+ "role",
109
+ "subagents",
110
+ }
111
+ _ITEM_FLOW_SETTINGS = ("agent", "model", "reasoning", "profile", "role", "subagents")
112
+ BUILTIN_ITEM_STEP_NAME = "handle-item"
113
+ BUILTIN_ITEM_STEP_PROMPT = (
114
+ "Handle this item end to end: analyze it, resolve it, and report the outcome."
115
+ )
116
+ # Under ``items``, guidance for one phase of the built-in stage.
117
+ _ITEM_PHASE_GUIDANCE = (
118
+ ("analyze", "When analyzing it"),
119
+ ("resolve", "When resolving it"),
120
+ ("report", "When reporting the outcome"),
121
+ )
122
+ # ``item_phase`` on a per-item stage, and the item operation it marks.
123
+ ITEM_PHASES: dict[str, ItemOperation] = {
124
+ "analyze": "process_item",
125
+ "resolve": "resolve_item",
126
+ "report": "report_item",
127
+ }
128
+ # ``interactive`` takes true (a conversation) or ``page`` (the operator page).
129
+ INTERACTIVE_PAGE = "page"
130
+ LOOP_ASSIGNMENTS: tuple[LoopAssignment, ...] = ("per_round", "per_step")
131
+ CHILD_ASSIGNMENTS = ("per_step",)
132
+
133
+
134
+ def _parse_handlers(data: Any) -> tuple[HandlerDefinition, ...]:
135
+ if data is None:
136
+ return ()
137
+ if not isinstance(data, list):
138
+ raise ConfigurationError("handlers must be a list")
139
+ # A catalog entry may be a complete step tree, not just an executable
140
+ # action. Parse entries in order so a handler can reuse an earlier one in
141
+ # the same way that workflow steps do.
142
+ result: list[HandlerDefinition] = []
143
+ known: dict[str, HandlerDefinition] = {}
144
+ for index, item in enumerate(data):
145
+ path = f"handlers[{index}]"
146
+ raw_mapping = _mapping(item, path)
147
+ # Unlike an action-only handler, a reusable step may use its
148
+ # named-entry value for the container definition itself:
149
+ # ``- review: {loop: [...]}``.
150
+ if "name" not in raw_mapping and raw_mapping:
151
+ name, value = next(iter(raw_mapping.items()))
152
+ if isinstance(value, dict):
153
+ if not isinstance(name, str) or not name.strip():
154
+ raise ConfigurationError(
155
+ f"{path} shorthand name must be a non-empty string"
156
+ )
157
+ remaining = dict(raw_mapping)
158
+ del remaining[name]
159
+ overlap = set(remaining).intersection(value)
160
+ if overlap:
161
+ raise ConfigurationError(
162
+ f"{path} repeats key(s): {', '.join(sorted(overlap))}"
163
+ )
164
+ mapping = {"name": name, **value, **remaining}
165
+ else:
166
+ mapping = _named_entry(raw_mapping, path)
167
+ else:
168
+ mapping = _named_entry(raw_mapping, path)
169
+ handler = (
170
+ _parse_step(mapping, path, known)
171
+ if STEP_ONLY_KEYS & set(mapping)
172
+ else _parse_handler(mapping, path)
173
+ )
174
+ result.append(handler)
175
+ known[handler.name] = handler
176
+ return tuple(result)
177
+
178
+
179
+ # Keys that give a step content of its own; a step with none of them and a
180
+ # root handler of the same name refers to that handler.
181
+ _STEP_CONTENT_KEYS = frozenset(
182
+ {
183
+ "description",
184
+ "handler",
185
+ "kind",
186
+ "mcp",
187
+ "argv",
188
+ "shell",
189
+ "args",
190
+ "env",
191
+ "assert",
192
+ "idempotent",
193
+ "action",
194
+ "variables",
195
+ "saves",
196
+ "handlers",
197
+ "steps",
198
+ "loop",
199
+ "items",
200
+ "children",
201
+ "explicit",
202
+ "handoff_to",
203
+ "question",
204
+ "outcomes",
205
+ "positive",
206
+ "negative",
207
+ "mixed",
208
+ "item_phase",
209
+ "start_child",
210
+ "rules",
211
+ }
212
+ )
213
+
214
+
215
+ def _parse_choices(data: Any, path: str) -> tuple[ChoiceDefinition, ...]:
216
+ """Parse ``choices``: named entries whose key is the label shown as is."""
217
+ if data is None:
218
+ return ()
219
+ if not isinstance(data, list) or not data:
220
+ raise ConfigurationError(f"{path}.choices must be a non-empty list")
221
+ result = []
222
+ for index, item in enumerate(data):
223
+ item_path = f"{path}.choices[{index}]"
224
+ mapping = _named_entry(_mapping(item, item_path), item_path)
225
+ _only(mapping, {"name", "description"}, item_path)
226
+ label = mapping.get("name")
227
+ if not isinstance(label, str) or not label.strip():
228
+ raise ConfigurationError(f"{item_path} label must be a non-empty string")
229
+ result.append(
230
+ ChoiceDefinition(
231
+ label.strip(), _description(mapping.get("description"), item_path)
232
+ )
233
+ )
234
+ _unique((choice.label for choice in result), f"choice label in {path}")
235
+ return tuple(result)
236
+
237
+
238
+ def _is_bare_reference(mapping: dict[str, Any]) -> bool:
239
+ return isinstance(mapping.get("name"), str) and all(
240
+ mapping.get(key) is None for key in _STEP_CONTENT_KEYS
241
+ )
242
+
243
+
244
+ def _parse_step(
245
+ data: Any,
246
+ path: str,
247
+ handlers_by_name: dict[str, HandlerDefinition],
248
+ *,
249
+ item_stage: bool = False,
250
+ ) -> StepDefinition:
251
+ """Parse one step entry, its nested steps included."""
252
+ mapping = _step_mapping(data, path, handlers_by_name)
253
+ base = (
254
+ extension_reference(mapping, path)
255
+ if _bare_extension_reference(mapping)
256
+ else _parse_handler(
257
+ mapping, path, transition=True, allowed_extra=STEP_ONLY_KEYS
258
+ )
259
+ )
260
+ question, outcomes, base = _parse_assessment(mapping, path, base, handlers_by_name)
261
+ base, referenced = _resolve_handler(mapping, path, base, handlers_by_name)
262
+ if base.handlers and (base.action is not None or base.operation is not None):
263
+ raise ConfigurationError(f"{path} cannot combine handlers with an action")
264
+ children = _parse_nested_steps(mapping, "steps", path, handlers_by_name)
265
+ if "steps" not in mapping and referenced is not None:
266
+ children = referenced.child_steps
267
+ loop_steps, max_rounds, loop_assignment = _parse_loop(
268
+ mapping, path, handlers_by_name, referenced
269
+ )
270
+ loop_break, loop_continue = _parse_loop_controls(mapping, path, referenced)
271
+ _check_loop_wrapper(mapping, path, base, loop_steps)
272
+ item_operation = _parse_item_phase(mapping, path)
273
+ child_launch = _parse_child_launch(mapping, path)
274
+ child_flow = _parse_child_flow(mapping, path, handlers_by_name, referenced)
275
+ if "children" in mapping and base.operation is not None:
276
+ raise ConfigurationError(
277
+ f"{path} cannot combine children with handoff_to; "
278
+ "name the child workflow under children.workflow"
279
+ )
280
+ artifact, artifact_from = _parse_artifact(mapping, path, referenced)
281
+ role, subagents, profile = _parse_performer(mapping, path, referenced)
282
+ interactive, ui, choices = _parse_interactive(mapping, path, referenced, item_stage)
283
+ explicit = mapping.get(
284
+ "explicit", referenced.explicit if referenced is not None else None
285
+ )
286
+ if "explicit" in mapping and not isinstance(explicit, bool):
287
+ raise ConfigurationError(f"{path}.explicit must be true or false")
288
+ if interactive and role == "worker":
289
+ raise ConfigurationError(
290
+ f"{path} is interactive, so the manager holds the conversation; "
291
+ "role: worker contradicts it"
292
+ )
293
+ learnable = mapping.get("learnable", referenced.learnable if referenced else False)
294
+ if not isinstance(learnable, bool):
295
+ raise ConfigurationError(f"{path}.learnable must be true or false")
296
+ if learnable and not artifact:
297
+ raise ConfigurationError(f"{path}.learnable requires artifact: true")
298
+ hooks = _parse_step_hooks(mapping, path, referenced)
299
+ rules = _parse_rules(mapping, path, base.name, referenced)
300
+ items = (
301
+ _parse_items(mapping, path, handlers_by_name, profile, role, subagents)
302
+ if "items" in mapping
303
+ else referenced.items
304
+ if referenced is not None
305
+ else None
306
+ )
307
+ containers = sum(
308
+ bool(value)
309
+ for value in (children, loop_steps, items is not None, child_flow is not None)
310
+ )
311
+ if base.handlers and containers:
312
+ raise ConfigurationError(
313
+ f"{path} cannot combine handlers with steps, loop, items, or children"
314
+ )
315
+ if containers > 1:
316
+ raise ConfigurationError(
317
+ f"{path} cannot combine steps, loop, items, and children"
318
+ )
319
+ if items is not None and item_operation is not None:
320
+ raise ConfigurationError(f"{path} cannot combine items with item_phase")
321
+ if item_operation is None and referenced is not None:
322
+ item_operation = referenced.item_operation
323
+ return StepDefinition(
324
+ name=base.name,
325
+ description=base.description,
326
+ action=base.action,
327
+ operation=base.operation,
328
+ provide=base.provide,
329
+ save_metadata=base.save_metadata,
330
+ update_document=base.update_document,
331
+ update_item=base.update_item,
332
+ outputs=base.outputs,
333
+ agent=base.agent,
334
+ model=base.model,
335
+ reasoning=base.reasoning,
336
+ workdir=base.workdir,
337
+ extension_arguments=base.extension_arguments,
338
+ on_failure=base.on_failure,
339
+ on_failure_instruction=base.on_failure_instruction,
340
+ handlers=base.handlers,
341
+ role=role,
342
+ subagents=subagents,
343
+ interactive=interactive,
344
+ explicit=explicit,
345
+ learnable=learnable,
346
+ choices=choices,
347
+ ui=ui,
348
+ profile=profile["profile"],
349
+ profile_description=profile["profile_description"],
350
+ hooks=hooks,
351
+ rules=rules,
352
+ child_steps=children,
353
+ loop_steps=loop_steps,
354
+ max_rounds=max_rounds,
355
+ loop_assignment=loop_assignment,
356
+ loop_break=loop_break,
357
+ loop_continue=loop_continue,
358
+ items=items,
359
+ item_operation=item_operation,
360
+ child_launch=child_launch,
361
+ artifact=artifact,
362
+ children=child_flow,
363
+ artifact_dependency=artifact_from,
364
+ assessment_question=question,
365
+ assessment_outcomes=outcomes,
366
+ )
367
+
368
+
369
+ def _step_mapping(
370
+ data: Any, path: str, handlers_by_name: dict[str, HandlerDefinition]
371
+ ) -> dict[str, Any]:
372
+ """Normalize a step entry to one mapping of its declared keys."""
373
+ raw = _mapping(data, path)
374
+ # ``assess`` is deliberately a construct rather than a conventional step
375
+ # name: its compact spelling is a question and its long spelling owns
376
+ # outcome substeps.
377
+ mapping: dict[str, Any]
378
+ if "assess" in raw:
379
+ value = raw["assess"]
380
+ rest = {key: item for key, item in raw.items() if key != "assess"}
381
+ if isinstance(value, str):
382
+ if rest:
383
+ raise ConfigurationError(
384
+ f"{path}.assess compact form cannot have other keys"
385
+ )
386
+ mapping = {"name": "assess", "question": value}
387
+ elif isinstance(value, dict):
388
+ overlap = set(rest).intersection(value)
389
+ if overlap:
390
+ raise ConfigurationError(
391
+ f"{path}.assess repeats key(s): {', '.join(sorted(overlap))}"
392
+ )
393
+ mapping = {"name": "assess", **value, **rest}
394
+ else:
395
+ raise ConfigurationError(
396
+ f"{path}.assess must be a question string or mapping"
397
+ )
398
+ else:
399
+ mapping = _named_entry(raw, path)
400
+ if _is_bare_reference(mapping) and mapping["name"] in handlers_by_name:
401
+ # ``- fetch_requirements: ~`` with a root handler of that name means
402
+ # the handler, exactly as a bare hook entry does; settings such as
403
+ # ``profile`` or ``model`` on the step still override the copy.
404
+ mapping = {**mapping, "handler": mapping["name"]}
405
+ allowed = _handler_keys() | STEP_ONLY_KEYS | {"handoff_to"}
406
+ if mapping.get("name") == "assess":
407
+ unknown_branches = {
408
+ key
409
+ for key, value in mapping.items()
410
+ if key not in allowed and isinstance(value, dict)
411
+ }
412
+ if unknown_branches:
413
+ label = sorted(unknown_branches)[0]
414
+ raise ConfigurationError(
415
+ f"{path}.{label} is not a direct assessment branch; "
416
+ "use positive, negative, mixed, or outcomes"
417
+ )
418
+ _only(mapping, allowed, path)
419
+ return mapping
420
+
421
+
422
+ def _parse_assessment(
423
+ mapping: dict[str, Any],
424
+ path: str,
425
+ base: HandlerDefinition,
426
+ handlers_by_name: dict[str, HandlerDefinition],
427
+ ) -> tuple[str | None, tuple[StepDefinition, ...], HandlerDefinition]:
428
+ """Parse an assess step's question and outcomes, and its prompt handler."""
429
+ question = (
430
+ _nonempty_string(mapping, "question", path) if "question" in mapping else None
431
+ )
432
+ if question is not None and mapping["name"] != "assess":
433
+ raise ConfigurationError(f"{path}.question is only valid for an assess step")
434
+ direct = {
435
+ label: mapping[label]
436
+ for label in ("positive", "negative", "mixed")
437
+ if label in mapping
438
+ }
439
+ if direct and question is None:
440
+ raise ConfigurationError(
441
+ f"{path}.{next(iter(direct))} requires an assess question"
442
+ )
443
+ if direct and "outcomes" in mapping:
444
+ raise ConfigurationError(
445
+ f"{path}.outcomes cannot be combined with direct assessment branches"
446
+ )
447
+ if "outcomes" in mapping and question is None:
448
+ raise ConfigurationError(f"{path}.outcomes requires an assess question")
449
+ outcomes = (
450
+ _parse_assessment_branches(direct, path, handlers_by_name, "")
451
+ if direct
452
+ else _parse_assessment_outcomes(mapping, path, handlers_by_name)
453
+ )
454
+ if question is None:
455
+ return None, outcomes, base
456
+ if any(
457
+ (
458
+ base.action is not None,
459
+ base.operation is not None,
460
+ "handler" in mapping,
461
+ "steps" in mapping,
462
+ "loop" in mapping,
463
+ )
464
+ ):
465
+ raise ConfigurationError(
466
+ f"{path} assess cannot also declare an action or ordinary nested steps"
467
+ )
468
+ prompt = HandlerDefinition(
469
+ name="assess",
470
+ description=question,
471
+ action=DefinedAction("prompt", Prompt(question)),
472
+ workdir=base.workdir,
473
+ )
474
+ return question, outcomes, prompt
475
+
476
+
477
+ def _resolve_handler(
478
+ mapping: dict[str, Any],
479
+ path: str,
480
+ base: HandlerDefinition,
481
+ handlers_by_name: dict[str, HandlerDefinition],
482
+ ) -> tuple[HandlerDefinition, StepDefinition | None]:
483
+ """Apply ``handler``, and return the step it names when it names one."""
484
+ if "handler" not in mapping:
485
+ return base, None
486
+ handler_name = mapping["handler"]
487
+ if not isinstance(handler_name, str) or not _NAME.fullmatch(handler_name):
488
+ raise ConfigurationError(f"{path}.handler must be a handler name")
489
+ try:
490
+ referenced = handlers_by_name[handler_name]
491
+ except KeyError as error:
492
+ raise ConfigurationError(
493
+ f"{path}.handler references unknown handler {handler_name!r}"
494
+ ) from error
495
+ base = _step_handler_reference(mapping, base, referenced)
496
+ return base, referenced if isinstance(referenced, StepDefinition) else None
497
+
498
+
499
+ def _parse_loop(
500
+ mapping: dict[str, Any],
501
+ path: str,
502
+ handlers_by_name: dict[str, HandlerDefinition],
503
+ referenced: StepDefinition | None,
504
+ ) -> tuple[tuple[StepDefinition, ...], int | None, LoopAssignment | None]:
505
+ """Parse ``loop`` with its ``max_rounds`` and ``assignment``."""
506
+ loop_steps = _parse_nested_steps(
507
+ mapping, "loop", path, handlers_by_name, require_nonempty=True
508
+ )
509
+ max_rounds: int | None = mapping.get("max_rounds")
510
+ if max_rounds is not None and (not is_positive_int(max_rounds)):
511
+ raise ConfigurationError(f"{path}.max_rounds must be a positive integer")
512
+ if "max_rounds" in mapping and not loop_steps:
513
+ raise ConfigurationError(f"{path}.max_rounds requires a loop")
514
+ assignment: LoopAssignment | None = None
515
+ if "assignment" in mapping:
516
+ if not loop_steps:
517
+ raise ConfigurationError(
518
+ f"{path}.assignment on a step goes beside a loop; for items or "
519
+ "children, write it inside that mapping"
520
+ )
521
+ assignment = cast(
522
+ LoopAssignment,
523
+ _assignment(mapping["assignment"], f"{path}.assignment", LOOP_ASSIGNMENTS),
524
+ )
525
+ if referenced is not None:
526
+ if "loop" not in mapping:
527
+ loop_steps = referenced.loop_steps
528
+ if "max_rounds" not in mapping:
529
+ max_rounds = referenced.max_rounds
530
+ if "assignment" not in mapping:
531
+ assignment = referenced.loop_assignment
532
+ return loop_steps, max_rounds, assignment
533
+
534
+
535
+ def _parse_loop_controls(
536
+ mapping: dict[str, Any], path: str, referenced: StepDefinition | None
537
+ ) -> tuple[str | None, str | None]:
538
+ """Parse the loop's ``break`` and ``continue`` conditions."""
539
+ loop_break = mapping.get("break")
540
+ loop_continue = mapping.get("continue")
541
+ for control_name, control_value in (
542
+ ("break", loop_break),
543
+ ("continue", loop_continue),
544
+ ):
545
+ if control_value is not None and (
546
+ not isinstance(control_value, str) or not control_value.strip()
547
+ ):
548
+ raise ConfigurationError(
549
+ f"{path}.{control_name} must be a non-empty string"
550
+ )
551
+ if referenced is not None:
552
+ if "break" not in mapping:
553
+ loop_break = referenced.loop_break
554
+ if "continue" not in mapping:
555
+ loop_continue = referenced.loop_continue
556
+ return loop_break, loop_continue
557
+
558
+
559
+ def _check_loop_wrapper(
560
+ mapping: dict[str, Any],
561
+ path: str,
562
+ base: HandlerDefinition,
563
+ loop_steps: tuple[StepDefinition, ...],
564
+ ) -> None:
565
+ """Reject a loop step that also acts or collects."""
566
+ if loop_steps and any(
567
+ (
568
+ base.action is not None,
569
+ base.operation is not None,
570
+ bool(base.provide),
571
+ bool(base.save_metadata),
572
+ bool(base.update_document),
573
+ bool(base.update_item),
574
+ bool(base.outputs),
575
+ "items" in mapping,
576
+ "item_phase" in mapping,
577
+ "children" in mapping,
578
+ "artifact_from" in mapping,
579
+ )
580
+ ):
581
+ raise ConfigurationError(
582
+ f"{path} loop wrapper cannot also declare an action or collection"
583
+ )
584
+
585
+
586
+ def _parse_item_phase(mapping: dict[str, Any], path: str) -> ItemOperation | None:
587
+ """Parse ``item_phase`` into the item operation it marks."""
588
+ if "item_phase" not in mapping:
589
+ return None
590
+ phase = mapping["item_phase"]
591
+ if not isinstance(phase, str) or phase not in ITEM_PHASES:
592
+ raise ConfigurationError(
593
+ f"{path}.item_phase must be one of: " + ", ".join(ITEM_PHASES)
594
+ )
595
+ return ITEM_PHASES[phase]
596
+
597
+
598
+ def _parse_child_launch(mapping: dict[str, Any], path: str) -> ChildLaunch | None:
599
+ """Parse ``start_child``: the launch settings ww starts the child with.
600
+
601
+ Every setting is an optional template over the child's record; an omitted
602
+ or empty one inherits, as an omitted ``start-child`` option does.
603
+ """
604
+ if "start_child" not in mapping:
605
+ return None
606
+ launch_path = f"{path}.start_child"
607
+ value = mapping["start_child"]
608
+ if value is None:
609
+ value = {}
610
+ if not isinstance(value, dict):
611
+ raise ConfigurationError(f"{launch_path} must be a mapping of launch settings")
612
+ _only(value, set(CHILD_LAUNCH_SETTINGS), launch_path)
613
+ settings: dict[str, str] = {}
614
+ for name in CHILD_LAUNCH_SETTINGS:
615
+ if name in value:
616
+ setting = value[name]
617
+ if not isinstance(setting, str):
618
+ raise ConfigurationError(f"{launch_path}.{name} must be a string")
619
+ settings[name] = setting
620
+ return ChildLaunch(**settings)
621
+
622
+
623
+ def _parse_child_flow(
624
+ mapping: dict[str, Any],
625
+ path: str,
626
+ handlers_by_name: dict[str, HandlerDefinition],
627
+ referenced: StepDefinition | None,
628
+ ) -> ChildFlow | None:
629
+ """Parse ``children``, or take the referenced step's."""
630
+ if "children" in mapping:
631
+ return _parse_children(mapping, path, handlers_by_name)
632
+ return referenced.children if referenced is not None else None
633
+
634
+
635
+ def _parse_artifact(
636
+ mapping: dict[str, Any], path: str, referenced: StepDefinition | None
637
+ ) -> tuple[bool, str | None]:
638
+ """Parse ``artifact`` and ``artifact_from``."""
639
+ artifact = mapping.get("artifact", True)
640
+ if not isinstance(artifact, bool):
641
+ raise ConfigurationError(f"{path}.artifact must be true or false")
642
+ artifact_from = mapping.get("artifact_from")
643
+ if artifact_from is not None and (
644
+ not isinstance(artifact_from, str) or not _NAME.fullmatch(artifact_from)
645
+ ):
646
+ raise ConfigurationError(f"{path}.artifact_from must be a normalized step name")
647
+ if referenced is not None:
648
+ if "artifact" not in mapping:
649
+ artifact = referenced.artifact
650
+ if "artifact_from" not in mapping:
651
+ artifact_from = referenced.artifact_dependency
652
+ return artifact, artifact_from
653
+
654
+
655
+ def _parse_performer(
656
+ mapping: dict[str, Any], path: str, referenced: StepDefinition | None
657
+ ) -> tuple[StepRole | None, bool | None, dict[str, str | None]]:
658
+ """Parse who performs the step: ``role``, ``subagents`` and ``profile``."""
659
+ role = _role(mapping, path)
660
+ subagents = _subagents(mapping, path)
661
+ profile = _profile(mapping, path)
662
+ if referenced is not None:
663
+ if "role" not in mapping:
664
+ role = referenced.role
665
+ if "subagents" not in mapping:
666
+ subagents = referenced.subagents
667
+ if "profile" not in mapping:
668
+ profile = {
669
+ "profile": referenced.profile,
670
+ "profile_description": referenced.profile_description,
671
+ }
672
+ return role, subagents, profile
673
+
674
+
675
+ def _parse_interactive(
676
+ mapping: dict[str, Any],
677
+ path: str,
678
+ referenced: StepDefinition | None,
679
+ item_stage: bool,
680
+ ) -> tuple[bool, bool, tuple[ChoiceDefinition, ...]]:
681
+ """Parse ``interactive`` and its ``choices``: whether, page, and choices."""
682
+ raw = mapping.get("interactive", False)
683
+ if not isinstance(raw, bool) and raw != INTERACTIVE_PAGE:
684
+ raise ConfigurationError(f"{path}.interactive must be true, false, or page")
685
+ interactive = raw is not False
686
+ ui = raw == INTERACTIVE_PAGE
687
+ choices = _parse_choices(mapping.get("choices"), path)
688
+ if referenced is not None:
689
+ if "interactive" not in mapping:
690
+ interactive = referenced.interactive
691
+ ui = referenced.ui
692
+ if "choices" not in mapping:
693
+ choices = referenced.choices
694
+ if choices and not interactive:
695
+ raise ConfigurationError(
696
+ f"{path}.choices are offered to the operator, so they require "
697
+ "interactive: true"
698
+ )
699
+ if ui and not item_stage:
700
+ raise ConfigurationError(
701
+ f"{path}.interactive: page is offered on per-item stages only; "
702
+ "declare it under items"
703
+ )
704
+ return interactive, ui, choices
705
+
706
+
707
+ def _parse_step_hooks(
708
+ mapping: dict[str, Any], path: str, referenced: StepDefinition | None
709
+ ) -> tuple[HookDefinition, ...]:
710
+ """Parse the step's ``hooks``, or take the referenced step's."""
711
+ if "hooks" in mapping or referenced is None:
712
+ return _parse_hooks(mapping.get("hooks", {}), "step", f"{path}.hooks")
713
+ return referenced.hooks
714
+
715
+
716
+ def _parse_rules(
717
+ mapping: dict[str, Any], path: str, name: str, referenced: StepDefinition | None
718
+ ) -> tuple[StepRule, ...]:
719
+ """Parse the step's ``rules``, or take the referenced step's."""
720
+ if "rules" in mapping:
721
+ return parse_step_rules(mapping["rules"], name, path)
722
+ return referenced.rules if referenced is not None else ()
723
+
724
+
725
+ def _parse_children(
726
+ mapping: dict[str, Any],
727
+ path: str,
728
+ handlers_by_name: dict[str, HandlerDefinition],
729
+ ) -> ChildFlow:
730
+ """Parse ``children``: the child ``workflow``, or the parent's ``steps``."""
731
+ value = mapping["children"]
732
+ children_path = f"{path}.children"
733
+ if not isinstance(value, dict):
734
+ raise ConfigurationError(
735
+ f"{children_path} must be a mapping with the child workflow"
736
+ )
737
+ _only(value, CHILD_FLOW_KEYS, children_path)
738
+ description = _optional_string(value, "description", children_path)
739
+ if "assignment" in value:
740
+ if "steps" not in value:
741
+ raise ConfigurationError(
742
+ f"{children_path}.assignment splits children.steps into worker "
743
+ "assignments; without steps ww runs each child itself"
744
+ )
745
+ _assignment(
746
+ value["assignment"], f"{children_path}.assignment", CHILD_ASSIGNMENTS
747
+ )
748
+ if "steps" in value:
749
+ if "workflow" in value:
750
+ raise ConfigurationError(
751
+ f"{children_path} takes workflow or steps, not both: name the "
752
+ "workflow every child runs, or list the parent's stages per "
753
+ "child with one `workflow:` stage among them"
754
+ )
755
+ return _parse_child_stages(value, children_path, handlers_by_name, description)
756
+ workflow = value.get("workflow")
757
+ if not isinstance(workflow, str) or not _NAME.fullmatch(workflow):
758
+ raise ConfigurationError(f"{children_path}.workflow must be a workflow name")
759
+ return ChildFlow(workflow=workflow, description=description)
760
+
761
+
762
+ def _parse_child_stages(
763
+ value: dict[str, Any],
764
+ children_path: str,
765
+ handlers_by_name: dict[str, HandlerDefinition],
766
+ description: str | None,
767
+ ) -> ChildFlow:
768
+ """Parse ``children.steps``: the parent's stages, run once per child.
769
+
770
+ Exactly one top-level stage carries ``workflow:``; inside ``children`` it
771
+ runs the child task with that workflow and waits, so it becomes the
772
+ stage's ``ChildWorkflowRun``. A ``handoff_to`` transition cannot run
773
+ here.
774
+ """
775
+ stages_path = f"{children_path}.steps"
776
+ if not isinstance(value["steps"], list) or not value["steps"]:
777
+ raise ConfigurationError(f"{stages_path} must contain at least one step")
778
+ entries = [_child_run_entry(entry) for entry in value["steps"]]
779
+ stages = _parse_nested_steps(
780
+ {"steps": [entry for entry, _ in entries]},
781
+ "steps",
782
+ children_path,
783
+ handlers_by_name,
784
+ )
785
+ runs = [
786
+ stage
787
+ for stage, (_, runs_child) in zip(stages, entries, strict=True)
788
+ if runs_child
789
+ ]
790
+ if len(runs) != 1:
791
+ raise ConfigurationError(
792
+ f"{stages_path} needs exactly one stage with `workflow:`, the one "
793
+ f"that runs the child task; found {len(runs)}"
794
+ )
795
+ run = runs[0]
796
+ assert isinstance(run.operation, WorkflowHandoff)
797
+ target = run.operation.target
798
+ if not _NAME.fullmatch(target):
799
+ raise ConfigurationError(
800
+ f"{stages_path} stage {run.name!r}: workflow must be a workflow name"
801
+ )
802
+ if run.agent or run.model or run.reasoning:
803
+ raise ConfigurationError(
804
+ f"{stages_path} stage {run.name!r} runs the child task, which ww "
805
+ "does; agent, model, and reasoning do not apply to it"
806
+ )
807
+ child_run = replace(
808
+ run,
809
+ description=run.description
810
+ or f"Run the child task with the `{target}` workflow and wait for it.",
811
+ operation=ChildWorkflowRun(target, run.child_launch),
812
+ )
813
+ converted = tuple(child_run if stage is run else stage for stage in stages)
814
+ for stage in step_tree(converted):
815
+ if isinstance(stage.operation, WorkflowHandoff) or any(
816
+ isinstance(hook.handler.operation, WorkflowHandoff) for hook in stage.hooks
817
+ ):
818
+ raise ConfigurationError(
819
+ f"{stages_path}: {stage.name!r} carries handoff_to, a workflow "
820
+ "transition, which cannot run inside children.steps; the stage "
821
+ "with `workflow:` runs the child task"
822
+ )
823
+ if stage.items is not None or stage.children is not None:
824
+ raise ConfigurationError(
825
+ f"{stages_path} stage {stage.name!r} cannot use items or "
826
+ "children: per-child stages do not nest another collection"
827
+ )
828
+ return ChildFlow(workflow=target, description=description, steps=converted)
829
+
830
+
831
+ def _child_run_entry(entry: Any) -> tuple[Any, bool]:
832
+ """A top-level ``children.steps`` entry, and whether it runs the child.
833
+
834
+ The stage that runs the child names its ``workflow``; it is parsed as a
835
+ transition to that workflow and then turned into the child run. It has
836
+ nothing to say but its workflow, so it may be written as its name
837
+ mapped to that setting: ``- implement: {workflow: task}``.
838
+ """
839
+ if (
840
+ isinstance(entry, dict)
841
+ and len(entry) == 1
842
+ and "name" not in entry
843
+ and isinstance(next(iter(entry.values())), dict)
844
+ and {"workflow", "start_child"} & set(next(iter(entry.values())))
845
+ ):
846
+ name, settings = next(iter(entry.items()))
847
+ entry = {"name": name, **settings}
848
+ if not isinstance(entry, dict) or "workflow" not in entry:
849
+ return entry, False
850
+ if "handoff_to" in entry:
851
+ raise ConfigurationError(
852
+ "a children.steps stage takes workflow (run the child) or "
853
+ "handoff_to, not both"
854
+ )
855
+ converted = {
856
+ ("handoff_to" if key == "workflow" else key): value
857
+ for key, value in entry.items()
858
+ }
859
+ return converted, True
860
+
861
+
862
+ def _assignment(value: Any, path: str, allowed: tuple[str, ...]) -> str:
863
+ """Check one ``assignment`` value against the ones its construct takes."""
864
+ if not isinstance(value, str):
865
+ raise ConfigurationError(f"{path} must be one of: " + ", ".join(allowed))
866
+ if value == "per_child":
867
+ raise ConfigurationError(
868
+ f"{path}: per_child is reserved and not built yet; use per_step"
869
+ )
870
+ if value not in allowed:
871
+ raise ConfigurationError(f"{path} must be one of: " + ", ".join(allowed))
872
+ return value
873
+
874
+
875
+ def _parse_items(
876
+ mapping: dict[str, Any],
877
+ path: str,
878
+ handlers_by_name: dict[str, HandlerDefinition],
879
+ step_profile: dict[str, str | None],
880
+ step_role: StepRole | None,
881
+ step_subagents: bool | None,
882
+ ) -> ItemFlow:
883
+ """Parse ``items``: ``~``, splitting guidance text, or a full mapping."""
884
+ value = mapping["items"]
885
+ items_path = f"{path}.items"
886
+ if value is None:
887
+ value = {}
888
+ elif isinstance(value, str):
889
+ value = {"description": value}
890
+ elif not isinstance(value, dict):
891
+ raise ConfigurationError(
892
+ f"{items_path} must be null, splitting guidance text, or a mapping"
893
+ )
894
+ _only(value, ITEM_FLOW_KEYS, items_path)
895
+ description = (
896
+ _nonempty_string(value, "description", items_path)
897
+ if "description" in value
898
+ else None
899
+ )
900
+ persistent = value.get("persistent")
901
+ if "persistent" in value and not isinstance(persistent, bool):
902
+ raise ConfigurationError(f"{items_path}.persistent must be true or false")
903
+ identity = value.get("identity")
904
+ if identity is not None and (
905
+ not isinstance(identity, str) or not FIELD_NAME.fullmatch(identity)
906
+ ):
907
+ raise ConfigurationError(f"{items_path}.identity must be a field name")
908
+ unique_raw = value.get("unique", [])
909
+ if not isinstance(unique_raw, list) or not all(
910
+ isinstance(name, str) and FIELD_NAME.fullmatch(name) for name in unique_raw
911
+ ):
912
+ raise ConfigurationError(f"{items_path}.unique must be a list of field names")
913
+ unique = tuple(dict.fromkeys(unique_raw)) if "unique" in value else None
914
+ assignment = cast(
915
+ ItemAssignment,
916
+ _assignment(
917
+ value.get("assignment", "together"),
918
+ f"{items_path}.assignment",
919
+ get_args(ItemAssignment),
920
+ ),
921
+ )
922
+ phase_keys = [key for key, _ in _ITEM_PHASE_GUIDANCE]
923
+ guidance = [
924
+ (label, _nonempty_string(value, key, items_path))
925
+ for key, label in _ITEM_PHASE_GUIDANCE
926
+ if key in value
927
+ ]
928
+ if guidance and "steps" in value:
929
+ raise ConfigurationError(
930
+ f"{items_path} phase guidance ("
931
+ + ", ".join(key for key in phase_keys if key in value)
932
+ + ") describes the built-in handle-item stage; describe configured "
933
+ "steps directly"
934
+ )
935
+ folded = [
936
+ key for key in ("variables", "saves", "interactive", "choices") if key in value
937
+ ]
938
+ if folded and "steps" in value:
939
+ raise ConfigurationError(
940
+ f"{items_path}.{folded[0]} belongs to the built-in handle-item stage; "
941
+ f"declare {folded[0]} on configured steps directly"
942
+ )
943
+ collect_only = "steps" in value and value["steps"] in ([], None)
944
+ if collect_only:
945
+ configured = [
946
+ key for key in ("assignment", *_ITEM_FLOW_SETTINGS) if key in value
947
+ ]
948
+ if configured:
949
+ raise ConfigurationError(
950
+ f"{items_path} collects without per-item steps, so "
951
+ + ", ".join(configured)
952
+ + " has no effect"
953
+ )
954
+ return ItemFlow((), description, assignment, persistent, identity, unique)
955
+ defaults = _item_flow_defaults(value, path, step_profile, step_role, step_subagents)
956
+ if "steps" in value:
957
+ steps = _parse_nested_steps(
958
+ value,
959
+ "steps",
960
+ items_path,
961
+ handlers_by_name,
962
+ require_nonempty=True,
963
+ item_stage=True,
964
+ )
965
+ entries = value["steps"]
966
+ else:
967
+ builtin = {
968
+ "name": BUILTIN_ITEM_STEP_NAME,
969
+ "description": "\n\n".join(
970
+ (
971
+ BUILTIN_ITEM_STEP_PROMPT,
972
+ *(f"{label}: {text}" for label, text in guidance),
973
+ )
974
+ ),
975
+ **{key: value[key] for key in folded},
976
+ }
977
+ steps = (
978
+ replace(
979
+ _parse_step(
980
+ builtin,
981
+ f"{items_path}.steps[0]",
982
+ handlers_by_name,
983
+ item_stage=True,
984
+ ),
985
+ item_operation="handle_item",
986
+ ),
987
+ )
988
+ entries = [builtin]
989
+ pages = [step.name for step in steps if step.ui]
990
+ if len(pages) > 1:
991
+ raise ConfigurationError(
992
+ f"{items_path} may answer one stage on the operator page; found "
993
+ "interactive: page on " + ", ".join(pages)
994
+ )
995
+ return ItemFlow(
996
+ tuple(
997
+ _with_item_flow_defaults(step, _declared_keys(entry), defaults)
998
+ for step, entry in zip(steps, entries, strict=True)
999
+ ),
1000
+ description,
1001
+ assignment,
1002
+ persistent,
1003
+ identity,
1004
+ unique,
1005
+ )
1006
+
1007
+
1008
+ def _item_flow_defaults(
1009
+ items_mapping: dict[str, Any],
1010
+ path: str,
1011
+ step_profile: dict[str, str | None],
1012
+ step_role: StepRole | None,
1013
+ step_subagents: bool | None,
1014
+ ) -> dict[str, Any]:
1015
+ """Worker settings every per-item stage inherits unless it sets its own.
1016
+
1017
+ ``items`` settings win; profile and role otherwise come from the
1018
+ ``items`` step itself. Agent, model, and reasoning already cascade from
1019
+ that step through the compiler's execution hints.
1020
+ """
1021
+ items_path = f"{path}.items"
1022
+ role = _role(items_mapping, items_path)
1023
+ subagents = _subagents(items_mapping, items_path)
1024
+ return {
1025
+ "agent": _optional_agent(items_mapping, "agent", items_path),
1026
+ "model": _optional_string(items_mapping, "model", items_path),
1027
+ "reasoning": _optional_string(items_mapping, "reasoning", items_path),
1028
+ **(
1029
+ _profile(items_mapping, items_path)
1030
+ if "profile" in items_mapping
1031
+ else step_profile
1032
+ ),
1033
+ "role": step_role if role is None else role,
1034
+ "subagents": step_subagents if subagents is None else subagents,
1035
+ }
1036
+
1037
+
1038
+ def _with_item_flow_defaults(
1039
+ step: StepDefinition, declared: set[str], defaults: dict[str, Any]
1040
+ ) -> StepDefinition:
1041
+ """Apply item-flow settings a stage neither declares nor copies from a handler."""
1042
+ changes: dict[str, Any] = {}
1043
+ for key in ("agent", "model", "reasoning"):
1044
+ if key not in declared and getattr(step, key) is None and defaults[key]:
1045
+ changes[key] = defaults[key]
1046
+ if (
1047
+ "profile" not in declared
1048
+ and step.profile is None
1049
+ and step.profile_description is None
1050
+ ):
1051
+ changes["profile"] = defaults["profile"]
1052
+ changes["profile_description"] = defaults["profile_description"]
1053
+ if "role" not in declared and step.role is None and defaults["role"]:
1054
+ changes["role"] = defaults["role"]
1055
+ if (
1056
+ "subagents" not in declared
1057
+ and step.subagents is None
1058
+ and defaults["subagents"] is not None
1059
+ ):
1060
+ changes["subagents"] = defaults["subagents"]
1061
+ return replace(step, **changes) if changes else step
1062
+
1063
+
1064
+ def _declared_keys(entry: Any) -> set[str]:
1065
+ """Return the keys a raw step entry sets, after shorthand expansion."""
1066
+ if not isinstance(entry, dict):
1067
+ return set()
1068
+ value = entry.get("assess")
1069
+ if isinstance(value, dict):
1070
+ return set(value) | (set(entry) - {"assess"})
1071
+ return set(entry)
1072
+
1073
+
1074
+ def _parse_assessment_outcomes(
1075
+ mapping: dict[str, Any], path: str, handlers_by_name: dict[str, HandlerDefinition]
1076
+ ) -> tuple[StepDefinition, ...]:
1077
+ raw = mapping.get("outcomes", {})
1078
+ return _parse_assessment_branches(raw, path, handlers_by_name, ".outcomes")
1079
+
1080
+
1081
+ def _parse_assessment_branches(
1082
+ raw: Any,
1083
+ path: str,
1084
+ handlers_by_name: dict[str, HandlerDefinition],
1085
+ field_path: str,
1086
+ ) -> tuple[StepDefinition, ...]:
1087
+ """Normalize wrapped outcomes or sibling standard branches to steps."""
1088
+ if raw is None:
1089
+ raw = {}
1090
+ if not isinstance(raw, dict):
1091
+ raise ConfigurationError(f"{path}{field_path} must be a mapping")
1092
+ result = []
1093
+ for label, value in raw.items():
1094
+ if not isinstance(label, str) or not _NAME.fullmatch(label):
1095
+ raise ConfigurationError(
1096
+ f"{path}{field_path} keys must be normalized names"
1097
+ )
1098
+ if not isinstance(value, dict):
1099
+ raise ConfigurationError(
1100
+ f"{path}{field_path}.{label} must be a step mapping"
1101
+ )
1102
+ if "name" in value:
1103
+ raise ConfigurationError(f"{path}{field_path}.{label} must not set name")
1104
+ if "stop_workflow" in value:
1105
+ if value != {"stop_workflow": True}:
1106
+ raise ConfigurationError(
1107
+ f"{path}{field_path}.{label}.stop_workflow must be true and "
1108
+ "stand alone: the outcome ends the workflow and runs nothing"
1109
+ )
1110
+ result.append(StepDefinition(label, stop_workflow=True))
1111
+ continue
1112
+ result.append(
1113
+ _parse_step(
1114
+ {"name": label, **value},
1115
+ f"{path}{field_path}.{label}",
1116
+ handlers_by_name,
1117
+ )
1118
+ )
1119
+ return tuple(result)
1120
+
1121
+
1122
+ def _parse_nested_steps(
1123
+ mapping: dict[str, Any],
1124
+ key: str,
1125
+ path: str,
1126
+ handlers_by_name: dict[str, HandlerDefinition],
1127
+ *,
1128
+ require_nonempty: bool = False,
1129
+ item_stage: bool = False,
1130
+ ) -> tuple[StepDefinition, ...]:
1131
+ """Parse an optional nested step list without collapsing key presence."""
1132
+ data = mapping.get(key, [])
1133
+ if data is None:
1134
+ data = []
1135
+ if not isinstance(data, list):
1136
+ raise ConfigurationError(f"{path}.{key} must be a list")
1137
+ if require_nonempty and key in mapping and not data:
1138
+ raise ConfigurationError(f"{path}.{key} must contain at least one step")
1139
+ return tuple(
1140
+ _parse_step(
1141
+ item, f"{path}.{key}[{index}]", handlers_by_name, item_stage=item_stage
1142
+ )
1143
+ for index, item in enumerate(data)
1144
+ )
1145
+
1146
+
1147
+ def _step_handler_reference(
1148
+ mapping: dict[str, Any],
1149
+ local: HandlerDefinition,
1150
+ referenced: HandlerDefinition,
1151
+ ) -> HandlerDefinition:
1152
+ """Copy a catalog handler into a step, retaining explicit step overrides.
1153
+
1154
+ ``handler`` is deliberately notation-only: the result is an ordinary
1155
+ ``StepDefinition`` and therefore follows the same compilation path as an
1156
+ inline step. A step name always remains its own identity, while a supplied
1157
+ description or handler field overrides the copied value.
1158
+ """
1159
+ action_keys = {"kind", "mcp", "argv", "shell", "args", "env", "assert", "action"}
1160
+ replaces_action = bool(action_keys & set(mapping))
1161
+ action = local.action if replaces_action else referenced.action
1162
+ if (
1163
+ action is not None
1164
+ and referenced.action is not None
1165
+ # Aliases may intentionally share a payload contract (for example a
1166
+ # project command action with a different identifier). The action
1167
+ # capability, rather than its registry name, owns whether that payload
1168
+ # can inherit definition fields.
1169
+ and type(action.payload) is type(referenced.action.payload)
1170
+ ):
1171
+ payload = actions.get(action.identifier).override_definition(
1172
+ action.payload,
1173
+ referenced.action.payload,
1174
+ DefinitionOverrideContext(
1175
+ mapping,
1176
+ {
1177
+ key: value
1178
+ for key, value in mapping.get("action", {}).items()
1179
+ if key != "type"
1180
+ }
1181
+ if isinstance(mapping.get("action"), dict)
1182
+ else {},
1183
+ "description" in mapping,
1184
+ local.name,
1185
+ local.description,
1186
+ ),
1187
+ )
1188
+ action = DefinedAction(action.identifier, payload)
1189
+ return HandlerDefinition(
1190
+ name=local.name,
1191
+ description=(
1192
+ local.description if "description" in mapping else referenced.description
1193
+ ),
1194
+ action=action,
1195
+ handlers=local.handlers if "handlers" in mapping else referenced.handlers,
1196
+ operation=local.operation if replaces_action else referenced.operation,
1197
+ provide=local.provide if "variables" in mapping else referenced.provide,
1198
+ outputs=local.outputs if "variables" in mapping else referenced.outputs,
1199
+ save_metadata=(
1200
+ local.save_metadata if "saves" in mapping else referenced.save_metadata
1201
+ ),
1202
+ update_document=(
1203
+ local.update_document if "saves" in mapping else referenced.update_document
1204
+ ),
1205
+ update_item=(
1206
+ local.update_item if "saves" in mapping else referenced.update_item
1207
+ ),
1208
+ agent=local.agent if "agent" in mapping else referenced.agent,
1209
+ model=local.model if "model" in mapping else referenced.model,
1210
+ reasoning=(local.reasoning if "reasoning" in mapping else referenced.reasoning),
1211
+ workdir=local.workdir if "workdir" in mapping else referenced.workdir,
1212
+ on_failure=local.on_failure
1213
+ if "on_failure" in mapping
1214
+ else referenced.on_failure,
1215
+ on_failure_instruction=(
1216
+ local.on_failure_instruction
1217
+ if "on_failure_instruction" in mapping
1218
+ else referenced.on_failure_instruction
1219
+ ),
1220
+ )