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.
- ww/__init__.py +18 -0
- ww/_bundled_extensions/ww/git/extension.py +1728 -0
- ww/action_execution.py +887 -0
- ww/actions/__init__.py +94 -0
- ww/actions/command.py +444 -0
- ww/actions/contracts.py +699 -0
- ww/actions/extension.py +197 -0
- ww/actions/mcp.py +84 -0
- ww/actions/prompt.py +74 -0
- ww/actions/skill.py +62 -0
- ww/actions/slash_command.py +63 -0
- ww/agents.py +151 -0
- ww/amendments.py +54 -0
- ww/artifacts.py +93 -0
- ww/assessments.py +181 -0
- ww/assets/__init__.py +2 -0
- ww/assets/agent_instructions.md +49 -0
- ww/assets/docs/examples.md +879 -0
- ww/assets/docs/features.md +4639 -0
- ww/assets/docs/specification.md +1876 -0
- ww/assets/noww_skill.md +11 -0
- ww/assets/workflows/catchall.yaml +26 -0
- ww/assets/workflows/onboarding.yaml +586 -0
- ww/assets/workflows/scriptize.yaml +130 -0
- ww/assets/ww-automate_skill.md +23 -0
- ww/assets/ww-deduce-feedback_skill.md +38 -0
- ww/assets/ww-feedback-rules_skill.md +48 -0
- ww/assets/ww-learn-project_skill.md +22 -0
- ww/assets/ww-refresh_skill.md +26 -0
- ww/assets/ww-rule_skill.md +83 -0
- ww/assets/ww-rules-from-artifacts_skill.md +22 -0
- ww/assets/ww-scriptize_skill.md +33 -0
- ww/assets/ww-setup_skill.md +94 -0
- ww/assets/ww-solve_skill.md +23 -0
- ww/assets/ww-suggest_skill.md +32 -0
- ww/assets/ww-wizard_skill.md +105 -0
- ww/assets/ww_skill.md +59 -0
- ww/assignments.py +283 -0
- ww/bootstrap.py +405 -0
- ww/builtin_workflows.py +215 -0
- ww/changes.py +225 -0
- ww/child_coordination.py +482 -0
- ww/children.py +106 -0
- ww/claude_permissions.py +115 -0
- ww/cli/__init__.py +7 -0
- ww/cli/__main__.py +6 -0
- ww/cli/audit.py +129 -0
- ww/cli/catalogs.py +131 -0
- ww/cli/discover.py +607 -0
- ww/cli/initialization.py +898 -0
- ww/cli/lookup.py +287 -0
- ww/cli/main.py +1768 -0
- ww/cli/parser.py +1200 -0
- ww/cli/prompts.py +217 -0
- ww/cli/updates.py +117 -0
- ww/completion_artifacts.py +156 -0
- ww/completion_inputs.py +39 -0
- ww/config/__init__.py +582 -0
- ww/config/actions.py +591 -0
- ww/config/composition.py +571 -0
- ww/config/rules.py +511 -0
- ww/config/steps.py +1220 -0
- ww/config/values.py +223 -0
- ww/config_files.py +191 -0
- ww/config_writes.py +264 -0
- ww/contracts.py +155 -0
- ww/control.py +41 -0
- ww/defaults.py +130 -0
- ww/design_docs.py +32 -0
- ww/discovery.py +104 -0
- ww/documents.py +217 -0
- ww/errors.py +18 -0
- ww/executable.py +43 -0
- ww/execution_models/__init__.py +64 -0
- ww/execution_models/construction.py +148 -0
- ww/execution_models/decoding.py +38 -0
- ww/execution_models/plan_codec.py +565 -0
- ww/execution_models/records.py +1206 -0
- ww/execution_models/runs.py +266 -0
- ww/extensions/__init__.py +40 -0
- ww/extensions/api.py +559 -0
- ww/extensions/registry.py +864 -0
- ww/extensions/store.py +78 -0
- ww/feedback.py +342 -0
- ww/handler_repairs.py +57 -0
- ww/hooks/__init__.py +40 -0
- ww/hooks/agents.py +380 -0
- ww/hooks/install.py +168 -0
- ww/hooks/notices.py +206 -0
- ww/hooks/records.py +209 -0
- ww/hooks/runtime.py +266 -0
- ww/hooks/transcripts.py +183 -0
- ww/inspect.py +896 -0
- ww/instructions/__init__.py +17 -0
- ww/instructions/builder.py +1682 -0
- ww/instructions/commands.py +335 -0
- ww/instructions/handoff.py +149 -0
- ww/instructions/models.py +686 -0
- ww/instructions/policy.py +219 -0
- ww/instructions/text.py +168 -0
- ww/interactions.py +187 -0
- ww/interpolation.py +37 -0
- ww/item_passes.py +167 -0
- ww/items.py +99 -0
- ww/locking.py +207 -0
- ww/metadata_publication.py +230 -0
- ww/onboarding.py +229 -0
- ww/open_work.py +236 -0
- ww/operations.py +193 -0
- ww/operator_ui/__init__.py +16 -0
- ww/operator_ui/page.html +351 -0
- ww/operator_ui/server.py +215 -0
- ww/operator_ui/session.py +389 -0
- ww/operator_ui/sheet.py +104 -0
- ww/operator_ui/view.py +109 -0
- ww/output.py +339 -0
- ww/output_adapters/__init__.py +12 -0
- ww/output_adapters/base.py +25 -0
- ww/output_adapters/json_adapter.py +37 -0
- ww/output_adapters/markdown.py +2293 -0
- ww/output_adapters/rule_pages.py +337 -0
- ww/output_adapters/terminal.py +21 -0
- ww/package_updates.py +167 -0
- ww/plan/__init__.py +38 -0
- ww/plan/actions.py +207 -0
- ww/plan/compiler.py +1492 -0
- ww/plan/constructs.py +456 -0
- ww/plan/models.py +665 -0
- ww/project_config.py +752 -0
- ww/recovery.py +401 -0
- ww/replanning.py +367 -0
- ww/results.py +77 -0
- ww/rule_checks.py +230 -0
- ww/rule_conversion.py +331 -0
- ww/rule_disputes.py +148 -0
- ww/rule_store.py +456 -0
- ww/rule_verification.py +714 -0
- ww/rule_views.py +447 -0
- ww/rule_writes.py +920 -0
- ww/run_coordination.py +158 -0
- ww/runtimes.py +105 -0
- ww/service.py +4405 -0
- ww/setup_apply.py +428 -0
- ww/step_values.py +20 -0
- ww/storage.py +447 -0
- ww/storage_adapters/__init__.py +36 -0
- ww/storage_adapters/base.py +540 -0
- ww/storage_adapters/filesystem.py +370 -0
- ww/storage_adapters/memory.py +195 -0
- ww/storage_adapters/project_metadata.py +69 -0
- ww/storage_adapters/task_document.py +484 -0
- ww/task_ids.py +114 -0
- ww/task_references.py +124 -0
- ww/transitions.py +1619 -0
- ww/updates.py +399 -0
- ww/upgrade.py +95 -0
- ww/validation.py +168 -0
- ww/variables.py +275 -0
- ww/workflow_config.py +854 -0
- ww/workflow_update.py +239 -0
- ww/workflow_validation.py +1260 -0
- ww/workspace.py +50 -0
- ww_agentic_workflows-1.0.0.dev3.dist-info/METADATA +690 -0
- ww_agentic_workflows-1.0.0.dev3.dist-info/RECORD +167 -0
- ww_agentic_workflows-1.0.0.dev3.dist-info/WHEEL +4 -0
- ww_agentic_workflows-1.0.0.dev3.dist-info/entry_points.txt +2 -0
- ww_agentic_workflows-1.0.0.dev3.dist-info/licenses/LICENSE +674 -0
|
@@ -0,0 +1,1682 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""Storage-backed construction of caller-facing instructions."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from dataclasses import dataclass, replace
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from ww.agents import choice_mechanism
|
|
11
|
+
from ww.amendments import Amendment
|
|
12
|
+
from ww.assessments import assessment_outcomes, pending_assessment
|
|
13
|
+
from ww.assignments import (
|
|
14
|
+
Assignment,
|
|
15
|
+
ItemSpan,
|
|
16
|
+
LoopSpan,
|
|
17
|
+
active_assignment,
|
|
18
|
+
assignment_at,
|
|
19
|
+
completion_window,
|
|
20
|
+
input_only,
|
|
21
|
+
item_span,
|
|
22
|
+
loop_span,
|
|
23
|
+
selection_item,
|
|
24
|
+
)
|
|
25
|
+
from ww.config_files import WORKFLOWS_FILE
|
|
26
|
+
from ww.contracts import (
|
|
27
|
+
CallerRole,
|
|
28
|
+
Control,
|
|
29
|
+
InstructionStatus,
|
|
30
|
+
ItemStatus,
|
|
31
|
+
NextRole,
|
|
32
|
+
OperatorReason,
|
|
33
|
+
)
|
|
34
|
+
from ww.control import child_workflow, is_coordinator, loop_control
|
|
35
|
+
from ww.documents import DocumentStore
|
|
36
|
+
from ww.errors import StateError
|
|
37
|
+
from ww.executable import ww_command
|
|
38
|
+
from ww.execution_models import (
|
|
39
|
+
CheckResult,
|
|
40
|
+
ExecutionState,
|
|
41
|
+
PlanItemExecution,
|
|
42
|
+
PlanSnapshot,
|
|
43
|
+
)
|
|
44
|
+
from ww.handler_repairs import needs_repair
|
|
45
|
+
from ww.interactions import InteractionLog
|
|
46
|
+
from ww.item_passes import item_collection
|
|
47
|
+
from ww.operations import LoopBoundary
|
|
48
|
+
from ww.plan import PlanItem, PlannedMode, PlannedRule, WorkflowPlan
|
|
49
|
+
from ww.project_config import load_project_config
|
|
50
|
+
from ww.runtimes import requested_setting, runtime_instruction
|
|
51
|
+
from ww.step_values import StepValues, no_step_values
|
|
52
|
+
from ww.storage_adapters import TaskStorageAdapter
|
|
53
|
+
from ww.transitions import (
|
|
54
|
+
enclosing_loop_entry_index,
|
|
55
|
+
fix_limits,
|
|
56
|
+
loop_iteration_of,
|
|
57
|
+
loop_limit_reached,
|
|
58
|
+
)
|
|
59
|
+
from ww.variables import (
|
|
60
|
+
item_workspace_values,
|
|
61
|
+
)
|
|
62
|
+
from ww.workflow_config import INIT_STEP_NAME, ProvidedVariable
|
|
63
|
+
from ww.workspace import resolve_workspace
|
|
64
|
+
|
|
65
|
+
from .commands import (
|
|
66
|
+
complete_command,
|
|
67
|
+
force_command,
|
|
68
|
+
instruction_command,
|
|
69
|
+
interact_commands,
|
|
70
|
+
next_command,
|
|
71
|
+
recovery_commands,
|
|
72
|
+
requirements_command,
|
|
73
|
+
start_child_command,
|
|
74
|
+
update_child_command,
|
|
75
|
+
)
|
|
76
|
+
from .models import (
|
|
77
|
+
ConversationEntry,
|
|
78
|
+
DisputeView,
|
|
79
|
+
DocumentTask,
|
|
80
|
+
FixFailure,
|
|
81
|
+
FixRequired,
|
|
82
|
+
Instruction,
|
|
83
|
+
RecoveryCommand,
|
|
84
|
+
RuleLine,
|
|
85
|
+
StepHandover,
|
|
86
|
+
VerificationPage,
|
|
87
|
+
VerificationRuleLine,
|
|
88
|
+
)
|
|
89
|
+
from .policy import (
|
|
90
|
+
_control,
|
|
91
|
+
_has_previous_artifacts,
|
|
92
|
+
_index_for_id,
|
|
93
|
+
_instruction_status,
|
|
94
|
+
_item_status,
|
|
95
|
+
_next_steps,
|
|
96
|
+
_plan_item_kind,
|
|
97
|
+
_result_saved,
|
|
98
|
+
operator_reason,
|
|
99
|
+
)
|
|
100
|
+
from .text import NO_SUBAGENTS, ContainerArtifact, _stage, action_text
|
|
101
|
+
|
|
102
|
+
TaskValues = Callable[[ExecutionState, WorkflowPlan], dict[str, str]]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(frozen=True)
|
|
106
|
+
class _Selection:
|
|
107
|
+
"""The worker actually chosen: recorded on the item, else on the assignment."""
|
|
108
|
+
|
|
109
|
+
agent: str | None
|
|
110
|
+
model: str | None
|
|
111
|
+
reasoning: str | None
|
|
112
|
+
|
|
113
|
+
@classmethod
|
|
114
|
+
def effective(
|
|
115
|
+
cls, record: PlanItemExecution | None, state: ExecutionState
|
|
116
|
+
) -> _Selection:
|
|
117
|
+
return cls(
|
|
118
|
+
(record.selected_agent if record else None)
|
|
119
|
+
or state.assignment_selected_agent,
|
|
120
|
+
(record.selected_model if record else None)
|
|
121
|
+
or state.assignment_selected_model,
|
|
122
|
+
(record.selected_reasoning if record else None)
|
|
123
|
+
or state.assignment_selected_reasoning,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _with_retry_note(error: str | None, record: PlanItemExecution | None) -> str | None:
|
|
128
|
+
"""``error`` followed by the attempts ww itself retried before stopping."""
|
|
129
|
+
if error is None or record is None or not record.retry_errors:
|
|
130
|
+
return error
|
|
131
|
+
attempts = "\n".join(
|
|
132
|
+
f"- attempt {number}: {message.splitlines()[0] if message else 'failed'}"
|
|
133
|
+
for number, message in enumerate(record.retry_errors, start=1)
|
|
134
|
+
)
|
|
135
|
+
return (
|
|
136
|
+
f"{error}\n\nww retried this step {len(record.retry_errors)} time(s) "
|
|
137
|
+
"itself (`limits.auto_retries`) before stopping; the failed earlier "
|
|
138
|
+
f"attempts:\n{attempts}"
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@dataclass(frozen=True)
|
|
143
|
+
class _RequirementsPage:
|
|
144
|
+
"""What one page shows of the task's requirements and amendments."""
|
|
145
|
+
|
|
146
|
+
text: str | None = None
|
|
147
|
+
in_full: bool = True
|
|
148
|
+
command: str | None = None
|
|
149
|
+
amendments: tuple[Amendment, ...] = ()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class InstructionBuilder:
|
|
153
|
+
"""Build caller-facing instructions from authoritative run records."""
|
|
154
|
+
|
|
155
|
+
def __init__(
|
|
156
|
+
self,
|
|
157
|
+
tasks: TaskStorageAdapter,
|
|
158
|
+
task_values: TaskValues,
|
|
159
|
+
*,
|
|
160
|
+
root: Path,
|
|
161
|
+
documents: DocumentStore,
|
|
162
|
+
interactions: InteractionLog,
|
|
163
|
+
child_values: StepValues = no_step_values,
|
|
164
|
+
item_values: StepValues = no_step_values,
|
|
165
|
+
worker_requirements: Callable[[], str] = lambda: "full",
|
|
166
|
+
) -> None:
|
|
167
|
+
self.tasks = tasks
|
|
168
|
+
# ``pages.worker_requirements``, read per page so an edit applies at once.
|
|
169
|
+
self.worker_requirements = worker_requirements
|
|
170
|
+
self.item_values = item_values
|
|
171
|
+
self.child_values = child_values
|
|
172
|
+
self.documents = documents
|
|
173
|
+
self.interactions = interactions
|
|
174
|
+
# Persisted paths are project-relative; instructions print them
|
|
175
|
+
# absolute for the filesystem this process runs in.
|
|
176
|
+
self.root = root
|
|
177
|
+
self.task_values = task_values
|
|
178
|
+
|
|
179
|
+
def build(
|
|
180
|
+
self,
|
|
181
|
+
state: ExecutionState,
|
|
182
|
+
snapshot: PlanSnapshot,
|
|
183
|
+
caller_role: CallerRole | None = None,
|
|
184
|
+
) -> Instruction:
|
|
185
|
+
plan = snapshot.plan
|
|
186
|
+
item = plan.items[state.cursor] if state.cursor < len(plan.items) else None
|
|
187
|
+
record = (
|
|
188
|
+
state.item_executions[state.cursor]
|
|
189
|
+
if state.cursor < len(state.item_executions)
|
|
190
|
+
else None
|
|
191
|
+
)
|
|
192
|
+
control, next_role = _control(state, plan)
|
|
193
|
+
selection = _Selection.effective(record, state)
|
|
194
|
+
automatic_running = (
|
|
195
|
+
item is not None
|
|
196
|
+
and record is not None
|
|
197
|
+
and item.owner == "ww"
|
|
198
|
+
and record.status == "in_progress"
|
|
199
|
+
and not needs_repair(state)
|
|
200
|
+
)
|
|
201
|
+
assignment = _current_assignment(state, plan, item)
|
|
202
|
+
# A hook shares its assignment with its step, so the worker shape and
|
|
203
|
+
# the delegate heading come from the step that drives selection, not
|
|
204
|
+
# from whichever item the cursor happens to be on.
|
|
205
|
+
driver = (
|
|
206
|
+
item
|
|
207
|
+
if needs_repair(state)
|
|
208
|
+
else selection_item(plan, assignment)
|
|
209
|
+
if assignment is not None
|
|
210
|
+
else None
|
|
211
|
+
)
|
|
212
|
+
shape = driver or item
|
|
213
|
+
span = plan.items[assignment.start : assignment.stop] if assignment else ()
|
|
214
|
+
covered = tuple(
|
|
215
|
+
entry
|
|
216
|
+
for entry in span
|
|
217
|
+
if entry.owner == "agent"
|
|
218
|
+
or entry.requires_agent_input
|
|
219
|
+
or needs_repair(state)
|
|
220
|
+
)
|
|
221
|
+
built = self._build(state, snapshot)
|
|
222
|
+
choosing = pending_assessment(state, plan)
|
|
223
|
+
return replace(
|
|
224
|
+
built,
|
|
225
|
+
assessment_outcomes=(
|
|
226
|
+
choosing.outcomes
|
|
227
|
+
if choosing is not None
|
|
228
|
+
else assessment_outcomes(plan, state.cursor)
|
|
229
|
+
if item is not None and item.assessment_question is not None
|
|
230
|
+
else ()
|
|
231
|
+
),
|
|
232
|
+
choosing_outcome_of=(
|
|
233
|
+
plan.items[choosing.index].name if choosing is not None else None
|
|
234
|
+
),
|
|
235
|
+
continuation_command=(
|
|
236
|
+
next_command(state.task_id, outcome="<outcome>")
|
|
237
|
+
if choosing is not None
|
|
238
|
+
else built.continuation_command
|
|
239
|
+
),
|
|
240
|
+
run_id=state.run_id,
|
|
241
|
+
notices=(*built.notices, *self._configuration_notices(state)),
|
|
242
|
+
workflow_runtime=state.workflow_runtime,
|
|
243
|
+
agent=state.agent,
|
|
244
|
+
model=(record.model if record and record.model else None)
|
|
245
|
+
or (requested_setting(item.model) if item else None)
|
|
246
|
+
or state.model,
|
|
247
|
+
reasoning=(record.reasoning if record and record.reasoning else None)
|
|
248
|
+
or (requested_setting(item.reasoning) if item else None)
|
|
249
|
+
or state.reasoning,
|
|
250
|
+
requested_agent=shape.requested_agent if shape else None,
|
|
251
|
+
requested_model=shape.requested_model if shape else None,
|
|
252
|
+
requested_reasoning=shape.requested_reasoning if shape else None,
|
|
253
|
+
requested_profile=shape.profile if shape else None,
|
|
254
|
+
role=shape.role if shape else "worker",
|
|
255
|
+
subagents=shape.subagents if shape else True,
|
|
256
|
+
assignment_token=worker_token(state),
|
|
257
|
+
profile_instruction=(
|
|
258
|
+
built.profile_instruction
|
|
259
|
+
or (
|
|
260
|
+
profile_instruction(driver, self.root)
|
|
261
|
+
if driver is not None and built.item_status == "in_progress"
|
|
262
|
+
else None
|
|
263
|
+
)
|
|
264
|
+
),
|
|
265
|
+
selected_agent=selection.agent,
|
|
266
|
+
selected_model=selection.model,
|
|
267
|
+
selected_reasoning=selection.reasoning,
|
|
268
|
+
# Which work comes next depends on the outcome still to be chosen.
|
|
269
|
+
assignment_preview=(
|
|
270
|
+
None
|
|
271
|
+
if choosing is not None
|
|
272
|
+
else _assignment_preview(state, plan, item, next_role)
|
|
273
|
+
),
|
|
274
|
+
assignment_step=driver.name if driver else None,
|
|
275
|
+
assignment_items=tuple(entry.name for entry in covered),
|
|
276
|
+
assignment_explicit_steps=tuple(
|
|
277
|
+
dict.fromkeys(
|
|
278
|
+
entry.step
|
|
279
|
+
for entry in covered
|
|
280
|
+
if entry.explicit
|
|
281
|
+
and (entry.owner == "agent" or needs_repair(state))
|
|
282
|
+
)
|
|
283
|
+
),
|
|
284
|
+
assignment_continues=(
|
|
285
|
+
item is not None
|
|
286
|
+
and bool(covered)
|
|
287
|
+
and item.id != covered[0].id
|
|
288
|
+
and item.id in {entry.id for entry in covered}
|
|
289
|
+
),
|
|
290
|
+
caller_role=caller_role,
|
|
291
|
+
next_role=next_role,
|
|
292
|
+
control=control,
|
|
293
|
+
operator_reason=operator_reason(state, plan),
|
|
294
|
+
result_saved=(
|
|
295
|
+
# A pass gate stops before the next step: there is no result.
|
|
296
|
+
None
|
|
297
|
+
if state.failure_kind == "pass_incomplete"
|
|
298
|
+
# A rejected or held completion keeps only a draft of the result.
|
|
299
|
+
else False
|
|
300
|
+
if state.failure_kind is not None
|
|
301
|
+
else _result_saved(state, plan)
|
|
302
|
+
if state.status in {"failed", "interrupted"} or automatic_running
|
|
303
|
+
else None
|
|
304
|
+
),
|
|
305
|
+
workflow_runtime_instruction=_guidance(state, shape, selection, next_role),
|
|
306
|
+
is_child_workflow_control=item is not None
|
|
307
|
+
and child_workflow(item) is not None,
|
|
308
|
+
is_loop_control=item is not None and loop_control(item) is not None,
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
def _build(self, state: ExecutionState, snapshot: PlanSnapshot) -> Instruction:
|
|
312
|
+
"""Describe the current item; ``build`` layers the shared fields on top."""
|
|
313
|
+
plan = snapshot.plan
|
|
314
|
+
if state.status == "completed":
|
|
315
|
+
handoff = self.tasks.read_handoff(state.task_id)
|
|
316
|
+
return replace(
|
|
317
|
+
_base(state, None),
|
|
318
|
+
handoff=handoff.rstrip() if handoff else None,
|
|
319
|
+
recommended_workflow=plan.recommended_next_workflow,
|
|
320
|
+
parent_task_id=state.parent_task_id,
|
|
321
|
+
feedback_deduction_command=(
|
|
322
|
+
f"{ww_command()} feedback sources {state.task_id} "
|
|
323
|
+
f"--run {state.run_id} --json"
|
|
324
|
+
if load_project_config(self.root / "ww.json").feedback_learning
|
|
325
|
+
and any(
|
|
326
|
+
record.status == "completed"
|
|
327
|
+
and record.artifact
|
|
328
|
+
and any(
|
|
329
|
+
item.id == record.plan_item_id and item.learnable
|
|
330
|
+
for item in plan.items
|
|
331
|
+
)
|
|
332
|
+
for record in (*state.execution_history, *state.item_executions)
|
|
333
|
+
)
|
|
334
|
+
else None
|
|
335
|
+
),
|
|
336
|
+
)
|
|
337
|
+
if state.status == "awaiting_input":
|
|
338
|
+
return self._awaiting_input(state, plan)
|
|
339
|
+
if state.status in {"failed", "interrupted"}:
|
|
340
|
+
return self._stopped(state, plan)
|
|
341
|
+
if state.cursor >= len(plan.items):
|
|
342
|
+
return replace(
|
|
343
|
+
_base(state, None, status="completed"),
|
|
344
|
+
recommended_workflow=plan.recommended_next_workflow,
|
|
345
|
+
parent_task_id=state.parent_task_id,
|
|
346
|
+
)
|
|
347
|
+
item = plan.items[state.cursor]
|
|
348
|
+
record = state.item_executions[state.cursor]
|
|
349
|
+
if needs_repair(state):
|
|
350
|
+
return self._repair(state, plan, item, record)
|
|
351
|
+
if child_workflow(item) is not None:
|
|
352
|
+
return self._child_control(state, item, record)
|
|
353
|
+
loop = loop_control(item)
|
|
354
|
+
if loop is not None:
|
|
355
|
+
return _loop_control(state, item, loop)
|
|
356
|
+
if record.status == "in_progress":
|
|
357
|
+
if item.owner == "ww" and item.execution == "automatic":
|
|
358
|
+
return _automatic_running(state, item, record)
|
|
359
|
+
return self._active(state, plan, item, record)
|
|
360
|
+
return replace(
|
|
361
|
+
_base(state, item, item_status="pending"),
|
|
362
|
+
continuation_command=next_command(state.task_id),
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
def _repair(
|
|
366
|
+
self,
|
|
367
|
+
state: ExecutionState,
|
|
368
|
+
plan: WorkflowPlan,
|
|
369
|
+
item: PlanItem,
|
|
370
|
+
record: PlanItemExecution,
|
|
371
|
+
) -> Instruction:
|
|
372
|
+
active = state.active_item_id == item.id
|
|
373
|
+
workspace, values = item_workspace_values(
|
|
374
|
+
self.root,
|
|
375
|
+
item.workdir,
|
|
376
|
+
state.working_directory,
|
|
377
|
+
{**dict(state.workflow_values), **self.task_values(state, plan)},
|
|
378
|
+
)
|
|
379
|
+
commands = (
|
|
380
|
+
tuple(command for command in record.commands if command.status == "failed")
|
|
381
|
+
or record.commands
|
|
382
|
+
)
|
|
383
|
+
references = tuple(
|
|
384
|
+
dict.fromkeys(
|
|
385
|
+
reference
|
|
386
|
+
for command in commands
|
|
387
|
+
for reference in (command.stdout_ref, command.stderr_ref)
|
|
388
|
+
if reference
|
|
389
|
+
)
|
|
390
|
+
)
|
|
391
|
+
text = (
|
|
392
|
+
f"Repair attempt {record.repair_failures} of {item.max_handler_fixes}.\n\n"
|
|
393
|
+
f"Repair the cause of the failed automatic handler `{item.name}`.\n\n"
|
|
394
|
+
"Do not independently execute the handler command. Submit your repair "
|
|
395
|
+
"with ww complete; "
|
|
396
|
+
"ww retries the handler and advances only when it succeeds."
|
|
397
|
+
)
|
|
398
|
+
if item.on_failure_instruction:
|
|
399
|
+
text += "\n\n" + item.on_failure_instruction
|
|
400
|
+
text += "\n\n" + action_text(item, values, state.task_id, ContainerArtifact())
|
|
401
|
+
text += "\n\nFailure:\n\n" + (
|
|
402
|
+
_with_retry_note(
|
|
403
|
+
record.error or state.last_error or "Unknown command failure", record
|
|
404
|
+
)
|
|
405
|
+
or ""
|
|
406
|
+
)
|
|
407
|
+
if references:
|
|
408
|
+
text += "\n\nFull command output:\n" + "\n".join(
|
|
409
|
+
f"- `{self.root / reference}`" for reference in references
|
|
410
|
+
)
|
|
411
|
+
page = self._requirements_page(state, plan, item)
|
|
412
|
+
return replace(
|
|
413
|
+
_base(state, item, item_status="in_progress" if active else "pending"),
|
|
414
|
+
action_text=text,
|
|
415
|
+
task_requirements=page.text,
|
|
416
|
+
requirements_in_full=page.in_full,
|
|
417
|
+
requirements_command=page.command,
|
|
418
|
+
task_amendments=page.amendments,
|
|
419
|
+
working_directory=str(workspace or self.root),
|
|
420
|
+
profile_instruction=profile_instruction(item, self.root),
|
|
421
|
+
handler_repair={
|
|
422
|
+
"item_id": item.id,
|
|
423
|
+
"attempt": record.repair_failures,
|
|
424
|
+
"max_fixes": item.max_handler_fixes,
|
|
425
|
+
"instruction": item.on_failure_instruction,
|
|
426
|
+
"output_refs": list(references),
|
|
427
|
+
"artifacts": list(record.repair_artifacts),
|
|
428
|
+
},
|
|
429
|
+
continuation_command=complete_command(
|
|
430
|
+
state.task_id,
|
|
431
|
+
(),
|
|
432
|
+
True,
|
|
433
|
+
(),
|
|
434
|
+
role="worker",
|
|
435
|
+
assignment=worker_token(state),
|
|
436
|
+
)
|
|
437
|
+
if active
|
|
438
|
+
else next_command(state.task_id),
|
|
439
|
+
explicit=item.explicit,
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
def _awaiting_input(self, state: ExecutionState, plan: WorkflowPlan) -> Instruction:
|
|
443
|
+
request = state.pending_input_request
|
|
444
|
+
if request is None: # pragma: no cover - validated state invariant
|
|
445
|
+
raise ValueError("awaiting-input state requires an input request")
|
|
446
|
+
index = _index_for_id(plan, request.item_id)
|
|
447
|
+
item = plan.items[index]
|
|
448
|
+
requested = {value.name for value in request.values}
|
|
449
|
+
# A retried handler asks for its values again; what it failed with is
|
|
450
|
+
# kept on its record so the page can show it.
|
|
451
|
+
previous_values = tuple(
|
|
452
|
+
(name, value)
|
|
453
|
+
for name, value in state.item_executions[index].supplied_values
|
|
454
|
+
if name in requested
|
|
455
|
+
)
|
|
456
|
+
# The values describe the work done since this handler last ran in
|
|
457
|
+
# this run: an earlier loop round's execution is in the history.
|
|
458
|
+
last_run = max(
|
|
459
|
+
(
|
|
460
|
+
str(record.completed_at)
|
|
461
|
+
for record in state.execution_history
|
|
462
|
+
if record.plan_item_id == item.id and record.completed_at
|
|
463
|
+
),
|
|
464
|
+
default="",
|
|
465
|
+
)
|
|
466
|
+
input_context = tuple(
|
|
467
|
+
StepHandover(
|
|
468
|
+
found.step, str((self.root / found.artifact).resolve()), found.summary
|
|
469
|
+
)
|
|
470
|
+
for found in self._handovers(state, plan)
|
|
471
|
+
if found.completed_at > last_run
|
|
472
|
+
)
|
|
473
|
+
assignment = active_assignment(
|
|
474
|
+
plan, state.assignment_item_id, runtime=state.workflow_runtime
|
|
475
|
+
)
|
|
476
|
+
manager_input = (
|
|
477
|
+
state.workflow_runtime == "auto"
|
|
478
|
+
and assignment is not None
|
|
479
|
+
and input_only(plan, assignment)
|
|
480
|
+
)
|
|
481
|
+
return replace(
|
|
482
|
+
_base(state, item, item_status="awaiting_input"),
|
|
483
|
+
required_values=request.values,
|
|
484
|
+
previous_values=previous_values,
|
|
485
|
+
automatic_context=(item.name,),
|
|
486
|
+
continuation_command=complete_command(
|
|
487
|
+
state.task_id,
|
|
488
|
+
request.values,
|
|
489
|
+
# Values are the whole result of an input-only assignment.
|
|
490
|
+
artifact=not manager_input,
|
|
491
|
+
role="manager" if manager_input else "worker",
|
|
492
|
+
# A retried handler's values belong to the still open
|
|
493
|
+
# assignment, whose worker command carries its token.
|
|
494
|
+
assignment=None if manager_input else worker_token(state),
|
|
495
|
+
),
|
|
496
|
+
manager_input=manager_input,
|
|
497
|
+
input_context=input_context,
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
def _previous_step_result(
|
|
501
|
+
self, state: ExecutionState, plan: WorkflowPlan
|
|
502
|
+
) -> _StepResult | None:
|
|
503
|
+
"""The handover of the step completed most recently before this one.
|
|
504
|
+
|
|
505
|
+
The full result stays in the artifact; only its reference travels.
|
|
506
|
+
"""
|
|
507
|
+
handovers = self._handovers(state, plan)
|
|
508
|
+
return handovers[-1] if handovers else None
|
|
509
|
+
|
|
510
|
+
def _handovers(
|
|
511
|
+
self, state: ExecutionState, plan: WorkflowPlan
|
|
512
|
+
) -> tuple[_StepResult, ...]:
|
|
513
|
+
"""Every completed ordinary step's handover in this run, oldest first.
|
|
514
|
+
|
|
515
|
+
Only ordinary steps count: hooks, the built-in summary, and ``init``
|
|
516
|
+
(already shown as the requirements) are skipped. Loop history is
|
|
517
|
+
included, so every round of a loop is present in order.
|
|
518
|
+
"""
|
|
519
|
+
by_id = {entry.id: entry for entry in plan.items}
|
|
520
|
+
candidates = sorted(
|
|
521
|
+
(
|
|
522
|
+
(record.completed_at, record.position, entry, record)
|
|
523
|
+
for record in (*state.item_executions, *state.execution_history)
|
|
524
|
+
if record.status == "completed"
|
|
525
|
+
and record.artifact is not None
|
|
526
|
+
and record.completed_at is not None
|
|
527
|
+
and (entry := by_id.get(record.plan_item_id)) is not None
|
|
528
|
+
and entry.hands_over
|
|
529
|
+
),
|
|
530
|
+
key=lambda found: found[:2],
|
|
531
|
+
)
|
|
532
|
+
return tuple(
|
|
533
|
+
_StepResult(
|
|
534
|
+
entry.name,
|
|
535
|
+
str(record.artifact),
|
|
536
|
+
record.summary_for_next,
|
|
537
|
+
str(record.completed_at),
|
|
538
|
+
)
|
|
539
|
+
for _, _, entry, record in candidates
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
def _container_artifact(
|
|
543
|
+
self, state: ExecutionState, plan: WorkflowPlan, item: PlanItem
|
|
544
|
+
) -> ContainerArtifact | None:
|
|
545
|
+
"""What ``artifact_from`` supplies when it names a group or assessment.
|
|
546
|
+
|
|
547
|
+
A group emits no item of its own; an assessment named after its
|
|
548
|
+
outcomes has run one of them. Either supplies the artifact of the
|
|
549
|
+
latest step inside it that saved one in its current round: inside a
|
|
550
|
+
loop, only the records of the loop's current iteration count, while
|
|
551
|
+
the rounds of a loop inside the container all do. An assessment
|
|
552
|
+
whose chosen outcome saved nothing supplies its own artifact, when it
|
|
553
|
+
saved one; otherwise none is available. ``None`` means the
|
|
554
|
+
dependency is a single step, including an assessment named from
|
|
555
|
+
inside its own outcomes, and one whose outcomes cannot save an
|
|
556
|
+
artifact.
|
|
557
|
+
"""
|
|
558
|
+
path = item.artifact_dependency
|
|
559
|
+
if path is None:
|
|
560
|
+
return None
|
|
561
|
+
inside = {
|
|
562
|
+
entry.id: entry
|
|
563
|
+
for entry in plan.items
|
|
564
|
+
if entry.phase == "step" and entry.artifact and path in entry.ancestors
|
|
565
|
+
}
|
|
566
|
+
own = next(
|
|
567
|
+
(
|
|
568
|
+
entry
|
|
569
|
+
for entry in plan.items
|
|
570
|
+
if entry.phase == "step" and entry.step == path
|
|
571
|
+
),
|
|
572
|
+
None,
|
|
573
|
+
)
|
|
574
|
+
if not inside or (
|
|
575
|
+
own is not None and (not own.assessment_outcomes or path in item.ancestors)
|
|
576
|
+
):
|
|
577
|
+
return None
|
|
578
|
+
some = next(iter(inside.values()))
|
|
579
|
+
enclosing = some.ancestors[: some.ancestors.index(path)]
|
|
580
|
+
iterations = dict(state.loop_iterations)
|
|
581
|
+
loops = {
|
|
582
|
+
loop.loop_id: iterations.get(loop.loop_id, 1)
|
|
583
|
+
for entry in plan.items
|
|
584
|
+
if (loop := loop_control(entry)) is not None
|
|
585
|
+
and loop.boundary == "enter"
|
|
586
|
+
and loop.loop_id in enclosing
|
|
587
|
+
}
|
|
588
|
+
# The live records are the current round's; the history holds earlier
|
|
589
|
+
# rounds, of loops inside the container (this round's) or around it.
|
|
590
|
+
current = (
|
|
591
|
+
*state.item_executions,
|
|
592
|
+
*(
|
|
593
|
+
record
|
|
594
|
+
for record in state.execution_history
|
|
595
|
+
if all(
|
|
596
|
+
loop_iteration_of(record, loop_id) == iteration
|
|
597
|
+
for loop_id, iteration in loops.items()
|
|
598
|
+
)
|
|
599
|
+
),
|
|
600
|
+
)
|
|
601
|
+
latest = max(
|
|
602
|
+
(
|
|
603
|
+
record
|
|
604
|
+
for record in current
|
|
605
|
+
if record.status == "completed"
|
|
606
|
+
and record.artifact is not None
|
|
607
|
+
and record.completed_at is not None
|
|
608
|
+
and record.plan_item_id in inside
|
|
609
|
+
),
|
|
610
|
+
key=lambda record: (str(record.completed_at), record.position),
|
|
611
|
+
default=None,
|
|
612
|
+
)
|
|
613
|
+
if latest is not None and latest.artifact is not None:
|
|
614
|
+
return ContainerArtifact(
|
|
615
|
+
inside[latest.plan_item_id].step,
|
|
616
|
+
str((self.root / latest.artifact).resolve()),
|
|
617
|
+
)
|
|
618
|
+
if own is not None:
|
|
619
|
+
# The outcome saved nothing: the assessment's own artifact, as
|
|
620
|
+
# ``artifact_from`` naming it supplied before outcomes counted.
|
|
621
|
+
assessed = next(
|
|
622
|
+
(
|
|
623
|
+
record
|
|
624
|
+
for record in state.item_executions
|
|
625
|
+
if record.plan_item_id == own.id
|
|
626
|
+
and record.status == "completed"
|
|
627
|
+
and record.artifact is not None
|
|
628
|
+
),
|
|
629
|
+
None,
|
|
630
|
+
)
|
|
631
|
+
if assessed is not None and assessed.artifact is not None:
|
|
632
|
+
return ContainerArtifact(
|
|
633
|
+
own.step, str((self.root / assessed.artifact).resolve())
|
|
634
|
+
)
|
|
635
|
+
return ContainerArtifact()
|
|
636
|
+
|
|
637
|
+
def _conversation(
|
|
638
|
+
self, state: ExecutionState, item: PlanItem
|
|
639
|
+
) -> tuple[ConversationEntry, ...]:
|
|
640
|
+
"""The recorded entries of this step's conversation in this run."""
|
|
641
|
+
return tuple(
|
|
642
|
+
ConversationEntry(entry.at, entry.speaker, entry.text)
|
|
643
|
+
for entry in self.interactions.entries(state.task_id)
|
|
644
|
+
if (entry.run_id, entry.step, entry.item_id)
|
|
645
|
+
== (state.run_id, item.name, item.item_id)
|
|
646
|
+
)
|
|
647
|
+
|
|
648
|
+
def _current_child(self, state: ExecutionState, item: PlanItem) -> str:
|
|
649
|
+
"""Name a per-child stage's child, and how to refine it before it runs."""
|
|
650
|
+
if item.child_number is None:
|
|
651
|
+
return ""
|
|
652
|
+
children = self.tasks.read_children(state.task_id, state.run_id)
|
|
653
|
+
if item.child_number > len(children):
|
|
654
|
+
return ""
|
|
655
|
+
child = children[item.child_number - 1]
|
|
656
|
+
text = (
|
|
657
|
+
f"\n\nCurrent child: `{child.id}` ({child.status}), "
|
|
658
|
+
f"{item.child_number} of {len(children)}."
|
|
659
|
+
)
|
|
660
|
+
if child.status == "pending":
|
|
661
|
+
text += (
|
|
662
|
+
" Until it starts, change its text, project, or fields with "
|
|
663
|
+
f"`{update_child_command(state.task_id, child.id)}`."
|
|
664
|
+
)
|
|
665
|
+
return text
|
|
666
|
+
|
|
667
|
+
def _document_tasks(
|
|
668
|
+
self, plan: WorkflowPlan, item: PlanItem, state: ExecutionState
|
|
669
|
+
) -> tuple[DocumentTask, ...]:
|
|
670
|
+
"""The documents this item promised to update, with their current files."""
|
|
671
|
+
declared = {document.name: document for document in plan.documents}
|
|
672
|
+
workspace = resolve_workspace(self.root, state.working_directory)
|
|
673
|
+
tasks = []
|
|
674
|
+
for update in item.update_document:
|
|
675
|
+
document = declared.get(update.name)
|
|
676
|
+
if document is None:
|
|
677
|
+
continue
|
|
678
|
+
path = self.documents.path(document, state.task_id, workspace)
|
|
679
|
+
tasks.append(
|
|
680
|
+
DocumentTask(update.name, update.instruction, str(path), path.is_file())
|
|
681
|
+
)
|
|
682
|
+
return tuple(tasks)
|
|
683
|
+
|
|
684
|
+
def _configuration_notices(self, state: ExecutionState) -> tuple[str, ...]:
|
|
685
|
+
"""Say which ``ww.yaml`` is in force when the task's worktree has another.
|
|
686
|
+
|
|
687
|
+
Every command, child launches included, reads the configuration of the
|
|
688
|
+
primary checkout; a copy in the worktree that differs from it is not
|
|
689
|
+
in force, which is easy to miss after editing the wrong one.
|
|
690
|
+
"""
|
|
691
|
+
workspace = resolve_workspace(self.root, state.working_directory)
|
|
692
|
+
if workspace is None or workspace == self.root.resolve():
|
|
693
|
+
return ()
|
|
694
|
+
local = workspace / WORKFLOWS_FILE
|
|
695
|
+
primary = self.root / WORKFLOWS_FILE
|
|
696
|
+
try:
|
|
697
|
+
if not local.is_file() or local.read_bytes() == primary.read_bytes():
|
|
698
|
+
return ()
|
|
699
|
+
except OSError:
|
|
700
|
+
return ()
|
|
701
|
+
return (
|
|
702
|
+
f"This task's worktree has its own `{WORKFLOWS_FILE}` that differs from "
|
|
703
|
+
f"the primary checkout's; ww reads `{primary}` for every command, "
|
|
704
|
+
"child launches included, so the worktree's copy is not in force.",
|
|
705
|
+
)
|
|
706
|
+
|
|
707
|
+
def _requirements_page(
|
|
708
|
+
self,
|
|
709
|
+
state: ExecutionState,
|
|
710
|
+
plan: WorkflowPlan,
|
|
711
|
+
item: PlanItem,
|
|
712
|
+
*,
|
|
713
|
+
continues_assignment: bool = False,
|
|
714
|
+
) -> _RequirementsPage:
|
|
715
|
+
"""The requirements and amendments an item's page carries.
|
|
716
|
+
|
|
717
|
+
The full requirements print on the first page of each session, then
|
|
718
|
+
later pages point at the command that prints them again. A single
|
|
719
|
+
runtime is one session: its first page that asks an agent for work.
|
|
720
|
+
Under ``auto`` the manager is one session, so the same rule holds for
|
|
721
|
+
its own pages, and every delegated worker assignment is a fresh one:
|
|
722
|
+
its first page prints them (unless ``pages.worker_requirements`` is
|
|
723
|
+
``pointer``) and the later stages of that assignment point.
|
|
724
|
+
The amendments are short and print on every page.
|
|
725
|
+
"""
|
|
726
|
+
if state.workflow_runtime == "auto" and item.role == "worker":
|
|
727
|
+
return _RequirementsPage(
|
|
728
|
+
self.requirements(state, plan),
|
|
729
|
+
not continues_assignment and self.worker_requirements() == "full",
|
|
730
|
+
requirements_command(state.task_id),
|
|
731
|
+
self.tasks.read_amendments(state.task_id),
|
|
732
|
+
)
|
|
733
|
+
first = next(
|
|
734
|
+
(
|
|
735
|
+
entry
|
|
736
|
+
for entry in plan.items
|
|
737
|
+
if entry.owner == "agent" and entry.step != INIT_STEP_NAME
|
|
738
|
+
),
|
|
739
|
+
None,
|
|
740
|
+
)
|
|
741
|
+
return _RequirementsPage(
|
|
742
|
+
self.requirements(state, plan),
|
|
743
|
+
first is None or item.id == first.id,
|
|
744
|
+
requirements_command(state.task_id),
|
|
745
|
+
self.tasks.read_amendments(state.task_id),
|
|
746
|
+
)
|
|
747
|
+
|
|
748
|
+
def requirements(self, state: ExecutionState, plan: WorkflowPlan) -> str | None:
|
|
749
|
+
"""The requirements ``init`` saved, so every worker reads the user's ask."""
|
|
750
|
+
record = next(
|
|
751
|
+
(
|
|
752
|
+
state.item_executions[index]
|
|
753
|
+
for index, item in enumerate(plan.items)
|
|
754
|
+
if item.step == INIT_STEP_NAME
|
|
755
|
+
and item.phase == "step"
|
|
756
|
+
and item.owner == "agent"
|
|
757
|
+
),
|
|
758
|
+
None,
|
|
759
|
+
)
|
|
760
|
+
if record is None or record.artifact is None:
|
|
761
|
+
return None
|
|
762
|
+
try:
|
|
763
|
+
artifact = self.tasks.read_execution_artifact(record.artifact)
|
|
764
|
+
except StateError:
|
|
765
|
+
return None
|
|
766
|
+
return _result_body(artifact) or None
|
|
767
|
+
|
|
768
|
+
def _stopped(self, state: ExecutionState, plan: WorkflowPlan) -> Instruction:
|
|
769
|
+
"""A failed or interrupted run: report the item and how to recover."""
|
|
770
|
+
current = plan.items[state.cursor] if state.cursor < len(plan.items) else None
|
|
771
|
+
record = (
|
|
772
|
+
state.item_executions[state.cursor]
|
|
773
|
+
if current is not None and state.cursor < len(state.item_executions)
|
|
774
|
+
else None
|
|
775
|
+
)
|
|
776
|
+
interrupted = state.status == "interrupted"
|
|
777
|
+
return replace(
|
|
778
|
+
_base(
|
|
779
|
+
state, current, item_status="interrupted" if interrupted else "failed"
|
|
780
|
+
),
|
|
781
|
+
error=_with_retry_note(
|
|
782
|
+
(
|
|
783
|
+
f"Handler repair reached its fix limit "
|
|
784
|
+
f"({record.repair_failures} of {current.max_handler_fixes})."
|
|
785
|
+
f"\n\n{state.last_error}"
|
|
786
|
+
if record is not None
|
|
787
|
+
and current is not None
|
|
788
|
+
and needs_repair(state)
|
|
789
|
+
and state.failure_kind == "fix_limit"
|
|
790
|
+
else state.last_error
|
|
791
|
+
),
|
|
792
|
+
record,
|
|
793
|
+
),
|
|
794
|
+
child_tasks=(
|
|
795
|
+
self.tasks.read_children(state.task_id, state.run_id)
|
|
796
|
+
if current is not None and child_workflow(current) is not None
|
|
797
|
+
else ()
|
|
798
|
+
),
|
|
799
|
+
operation_id=record.operation_id if record else None,
|
|
800
|
+
recovery_commands=(
|
|
801
|
+
()
|
|
802
|
+
if current is None
|
|
803
|
+
# A pass gate is passed by recording the items, never skipped.
|
|
804
|
+
else (
|
|
805
|
+
RecoveryCommand("retry", next_command(state.task_id, retry=True)),
|
|
806
|
+
)
|
|
807
|
+
if state.failure_kind == "pass_incomplete"
|
|
808
|
+
else recovery_commands(state.task_id)
|
|
809
|
+
),
|
|
810
|
+
# At the fix limit the operator decides on what the checks said.
|
|
811
|
+
fix_required=(
|
|
812
|
+
fix_required(current, record)
|
|
813
|
+
if state.failure_kind == "fix_limit"
|
|
814
|
+
and current is not None
|
|
815
|
+
and record is not None
|
|
816
|
+
else None
|
|
817
|
+
),
|
|
818
|
+
dispute=(
|
|
819
|
+
dispute_view(current, record)
|
|
820
|
+
if state.failure_kind == "check_disputed"
|
|
821
|
+
and current is not None
|
|
822
|
+
and record is not None
|
|
823
|
+
else None
|
|
824
|
+
),
|
|
825
|
+
)
|
|
826
|
+
|
|
827
|
+
def _verification(
|
|
828
|
+
self, state: ExecutionState, plan: WorkflowPlan, item: PlanItem
|
|
829
|
+
) -> VerificationPage | None:
|
|
830
|
+
"""The rules a verification item is asked about and their evidence."""
|
|
831
|
+
target = item.verifies
|
|
832
|
+
if target is None:
|
|
833
|
+
return None
|
|
834
|
+
record = state.item_executions[state.cursor]
|
|
835
|
+
step_index = _index_for_id(plan, target.item_id)
|
|
836
|
+
step = plan.items[step_index]
|
|
837
|
+
step_record = state.item_executions[step_index]
|
|
838
|
+
held = step_record.held_completion
|
|
839
|
+
return VerificationPage(
|
|
840
|
+
step=step.name,
|
|
841
|
+
rules=tuple(
|
|
842
|
+
VerificationRuleLine(
|
|
843
|
+
rule.id,
|
|
844
|
+
rule.text,
|
|
845
|
+
rule.interpretation,
|
|
846
|
+
rule.check,
|
|
847
|
+
rule.missing,
|
|
848
|
+
)
|
|
849
|
+
for rule in record.verification
|
|
850
|
+
),
|
|
851
|
+
files=held.files if held else (),
|
|
852
|
+
all_files=held.all_files if held else False,
|
|
853
|
+
diff_command=(
|
|
854
|
+
f"git diff {step_record.change_mark} {held.mark}"
|
|
855
|
+
if held is not None and step_record.change_mark and held.mark
|
|
856
|
+
else None
|
|
857
|
+
),
|
|
858
|
+
draft_artifact=(
|
|
859
|
+
str((self.root / held.draft_ref).resolve())
|
|
860
|
+
if held is not None and held.draft_ref
|
|
861
|
+
else None
|
|
862
|
+
),
|
|
863
|
+
)
|
|
864
|
+
|
|
865
|
+
def _child_control(
|
|
866
|
+
self, state: ExecutionState, item: PlanItem, record: PlanItemExecution
|
|
867
|
+
) -> Instruction:
|
|
868
|
+
coordinator = child_workflow(item)
|
|
869
|
+
children = self.tasks.read_children(state.task_id, state.run_id)
|
|
870
|
+
if record.status == "pending":
|
|
871
|
+
return replace(
|
|
872
|
+
_base(state, item, item_status="pending"),
|
|
873
|
+
child_tasks=children,
|
|
874
|
+
continuation_command=next_command(state.task_id),
|
|
875
|
+
is_child_workflow_control=True,
|
|
876
|
+
)
|
|
877
|
+
# A per-child coordinator runs its own child only.
|
|
878
|
+
candidates = (
|
|
879
|
+
children[item.child_number - 1 : item.child_number]
|
|
880
|
+
if item.child_number is not None
|
|
881
|
+
else children
|
|
882
|
+
)
|
|
883
|
+
active = next(
|
|
884
|
+
(
|
|
885
|
+
child
|
|
886
|
+
for child in candidates
|
|
887
|
+
if child.status in {"in_progress", "starting"}
|
|
888
|
+
),
|
|
889
|
+
None,
|
|
890
|
+
)
|
|
891
|
+
pending = next(
|
|
892
|
+
(child for child in candidates if child.status == "pending"), None
|
|
893
|
+
)
|
|
894
|
+
if active is not None and active.status == "starting":
|
|
895
|
+
text = (
|
|
896
|
+
f"Child `{active.id}` is obtaining its task ID. Continue it "
|
|
897
|
+
f"with:\n\n```console\n"
|
|
898
|
+
f"{instruction_command(active.id, role='manager')}\n```"
|
|
899
|
+
)
|
|
900
|
+
elif active is not None:
|
|
901
|
+
text = f"Child `{active.id}` is in progress at `{active.task_id}`."
|
|
902
|
+
elif pending is not None and coordinator is not None and coordinator.launch:
|
|
903
|
+
text = (
|
|
904
|
+
f"ww starts pending child `{pending.id}` itself, with the "
|
|
905
|
+
"launch settings this stage declares from the child's record. "
|
|
906
|
+
f"Run `{next_command(state.task_id)}` and it "
|
|
907
|
+
"starts the child and shows its page. Until then, the child's "
|
|
908
|
+
"text or project can still change with "
|
|
909
|
+
f"`{update_child_command(state.task_id, pending.id)}`."
|
|
910
|
+
)
|
|
911
|
+
elif pending is not None:
|
|
912
|
+
text = (
|
|
913
|
+
f"Start pending child `{pending.id}` with:\n\n```console\n"
|
|
914
|
+
f"{start_child_command(state.task_id, pending.id)}\n```\n\n"
|
|
915
|
+
"Optionally add `--workflow <name>` to run the child under a "
|
|
916
|
+
"workflow other than the parent's configured child workflow, and "
|
|
917
|
+
"`--runtime`, `--model`, and `--reasoning` to "
|
|
918
|
+
"choose the child's session settings; omitted values inherit "
|
|
919
|
+
"from the parent, except a different model resets omitted "
|
|
920
|
+
"reasoning to `auto`. These settings are fixed once launch begins. "
|
|
921
|
+
"Until a child starts, its text or project can still change "
|
|
922
|
+
f"with `{update_child_command(state.task_id, pending.id)}`."
|
|
923
|
+
)
|
|
924
|
+
else:
|
|
925
|
+
text = "No child task is currently available to start."
|
|
926
|
+
return replace(
|
|
927
|
+
_base(state, item, item_status=record.status),
|
|
928
|
+
action_text=text,
|
|
929
|
+
child_tasks=children,
|
|
930
|
+
is_child_workflow_control=True,
|
|
931
|
+
)
|
|
932
|
+
|
|
933
|
+
def _active(
|
|
934
|
+
self,
|
|
935
|
+
state: ExecutionState,
|
|
936
|
+
plan: WorkflowPlan,
|
|
937
|
+
item: PlanItem,
|
|
938
|
+
record: PlanItemExecution,
|
|
939
|
+
) -> Instruction:
|
|
940
|
+
"""The agent item in progress: its work text and completion commands."""
|
|
941
|
+
assignment = active_assignment(
|
|
942
|
+
plan, state.assignment_item_id, runtime=state.workflow_runtime
|
|
943
|
+
)
|
|
944
|
+
required, context = completion_window(
|
|
945
|
+
plan, state.cursor, assignment.stop if assignment else None
|
|
946
|
+
)
|
|
947
|
+
selection = _Selection.effective(record, state)
|
|
948
|
+
span = _span(plan, assignment)
|
|
949
|
+
span_ids = tuple(stage.id for stage in span.stages) if span else ()
|
|
950
|
+
loop_round = _loop_round(state, plan, item)
|
|
951
|
+
role: CallerRole = "manager" if state.workflow_runtime == "auto" else "worker"
|
|
952
|
+
# Under ``auto`` the manager performs its own step and completes it
|
|
953
|
+
# as the manager; a worker's completion of it is refused.
|
|
954
|
+
completer: CallerRole = (
|
|
955
|
+
"manager"
|
|
956
|
+
if state.workflow_runtime == "auto" and item.role == "manager"
|
|
957
|
+
else "worker"
|
|
958
|
+
)
|
|
959
|
+
previous = (
|
|
960
|
+
self._previous_step_result(state, plan)
|
|
961
|
+
if item.step != INIT_STEP_NAME
|
|
962
|
+
else None
|
|
963
|
+
)
|
|
964
|
+
|
|
965
|
+
verification = self._verification(state, plan, item)
|
|
966
|
+
|
|
967
|
+
def completion(artifact: bool, loop_control: str | None = None) -> str:
|
|
968
|
+
return complete_command(
|
|
969
|
+
state.task_id,
|
|
970
|
+
required,
|
|
971
|
+
artifact,
|
|
972
|
+
item.save_metadata,
|
|
973
|
+
selected_agent=selection.agent,
|
|
974
|
+
selected_model=selection.model,
|
|
975
|
+
selected_reasoning=selection.reasoning,
|
|
976
|
+
loop_control=loop_control,
|
|
977
|
+
role=completer,
|
|
978
|
+
summary=item.hands_over,
|
|
979
|
+
rule_results=(
|
|
980
|
+
tuple(rule.id for rule in verification.rules)
|
|
981
|
+
if verification
|
|
982
|
+
else ()
|
|
983
|
+
),
|
|
984
|
+
assignment=worker_token(state),
|
|
985
|
+
)
|
|
986
|
+
|
|
987
|
+
loop_break_command = None
|
|
988
|
+
if item.loop_break is not None:
|
|
989
|
+
# A break ending per-child stages has no loop wrapper to save.
|
|
990
|
+
wrapper_artifact = (
|
|
991
|
+
False
|
|
992
|
+
if item.breaks_children
|
|
993
|
+
else plan.items[enclosing_loop_entry_index(plan, state.cursor)].artifact
|
|
994
|
+
)
|
|
995
|
+
loop_break_command = completion(item.artifact or wrapper_artifact, "break")
|
|
996
|
+
later_pass = _later_item_pass(plan, item)
|
|
997
|
+
# A collection step's identity and unique fields are the collection's.
|
|
998
|
+
collection = item_collection(plan) if item.item_operation == "collect" else None
|
|
999
|
+
workspace, values = item_workspace_values(
|
|
1000
|
+
self.root,
|
|
1001
|
+
item.workdir,
|
|
1002
|
+
state.working_directory,
|
|
1003
|
+
{**dict(state.workflow_values), **self.task_values(state, plan)},
|
|
1004
|
+
)
|
|
1005
|
+
requirements = (
|
|
1006
|
+
self._requirements_page(
|
|
1007
|
+
state,
|
|
1008
|
+
plan,
|
|
1009
|
+
item,
|
|
1010
|
+
continues_assignment=item.id in span_ids[1:],
|
|
1011
|
+
)
|
|
1012
|
+
if item.step != INIT_STEP_NAME
|
|
1013
|
+
else _RequirementsPage()
|
|
1014
|
+
)
|
|
1015
|
+
return replace(
|
|
1016
|
+
_base(state, item, item_status=record.status),
|
|
1017
|
+
action_text=action_text(
|
|
1018
|
+
item,
|
|
1019
|
+
{
|
|
1020
|
+
**values,
|
|
1021
|
+
**self.item_values(state, plan, item),
|
|
1022
|
+
**self.child_values(state, plan, item),
|
|
1023
|
+
},
|
|
1024
|
+
state.task_id,
|
|
1025
|
+
self._container_artifact(state, plan, item),
|
|
1026
|
+
later_pass=later_pass,
|
|
1027
|
+
)
|
|
1028
|
+
+ self._current_child(state, item),
|
|
1029
|
+
required_values=required,
|
|
1030
|
+
required_metadata=item.save_metadata,
|
|
1031
|
+
automatic_context=context,
|
|
1032
|
+
has_previous_artifacts=_has_previous_artifacts(state, plan),
|
|
1033
|
+
next_steps=_next_steps(state, plan),
|
|
1034
|
+
artifact=record.artifact,
|
|
1035
|
+
continuation_command=completion(item.artifact),
|
|
1036
|
+
loop_break_prompt=item.loop_break,
|
|
1037
|
+
loop_break_command=loop_break_command,
|
|
1038
|
+
breaks_children=item.breaks_children,
|
|
1039
|
+
loop_continue_prompt=item.loop_continue,
|
|
1040
|
+
loop_continue_command=(
|
|
1041
|
+
completion(item.artifact, "continue")
|
|
1042
|
+
if item.loop_continue is not None
|
|
1043
|
+
else None
|
|
1044
|
+
),
|
|
1045
|
+
loop_name=loop_round[0] if loop_round else None,
|
|
1046
|
+
loop_iteration=loop_round[1] if loop_round else None,
|
|
1047
|
+
max_rounds=loop_round[2] if loop_round else None,
|
|
1048
|
+
task_requirements=requirements.text,
|
|
1049
|
+
requirements_in_full=requirements.in_full,
|
|
1050
|
+
requirements_command=requirements.command,
|
|
1051
|
+
task_amendments=requirements.amendments,
|
|
1052
|
+
previous_step=previous.step if previous else None,
|
|
1053
|
+
previous_step_artifact=(
|
|
1054
|
+
str((self.root / previous.artifact).resolve()) if previous else None
|
|
1055
|
+
),
|
|
1056
|
+
previous_step_summary=previous.summary if previous else None,
|
|
1057
|
+
summary_required=item.hands_over,
|
|
1058
|
+
documents=self._document_tasks(plan, item, state),
|
|
1059
|
+
interactive=item.interactive,
|
|
1060
|
+
explicit=item.explicit and item.owner == "agent",
|
|
1061
|
+
interaction_entries=record.interaction_entries,
|
|
1062
|
+
interaction_ended=record.interaction_ended,
|
|
1063
|
+
interact_commands=(
|
|
1064
|
+
interact_commands(
|
|
1065
|
+
state.task_id,
|
|
1066
|
+
role,
|
|
1067
|
+
worker_token(state),
|
|
1068
|
+
choices=bool(item.choices),
|
|
1069
|
+
)
|
|
1070
|
+
if item.interactive
|
|
1071
|
+
else None
|
|
1072
|
+
),
|
|
1073
|
+
choices=item.choices,
|
|
1074
|
+
choice_mechanism=(
|
|
1075
|
+
choice_mechanism(state.agent).instruction if item.choices else None
|
|
1076
|
+
),
|
|
1077
|
+
chosen=record.chosen,
|
|
1078
|
+
operator_paused=state.operator_paused,
|
|
1079
|
+
conversation=(self._conversation(state, item) if item.interactive else ()),
|
|
1080
|
+
ui=item.ui,
|
|
1081
|
+
shared_items=item.shared_items and not later_pass,
|
|
1082
|
+
stored_items=(
|
|
1083
|
+
self.tasks.read_items(state.task_id, state.run_id)
|
|
1084
|
+
if item.shared_items and not later_pass
|
|
1085
|
+
else ()
|
|
1086
|
+
),
|
|
1087
|
+
required_item_fields=item.update_item,
|
|
1088
|
+
collects_items=item.item_operation == "collect",
|
|
1089
|
+
item_identity=collection.item_identity if collection else None,
|
|
1090
|
+
item_unique=collection.item_unique if collection else (),
|
|
1091
|
+
run_handovers=(
|
|
1092
|
+
tuple(
|
|
1093
|
+
StepHandover(
|
|
1094
|
+
found.step,
|
|
1095
|
+
str((self.root / found.artifact).resolve()),
|
|
1096
|
+
found.summary,
|
|
1097
|
+
)
|
|
1098
|
+
for found in self._handovers(state, plan)
|
|
1099
|
+
)
|
|
1100
|
+
if item.summary
|
|
1101
|
+
else ()
|
|
1102
|
+
),
|
|
1103
|
+
profile_instruction=profile_instruction(item, self.root),
|
|
1104
|
+
role=item.role,
|
|
1105
|
+
# A task still in the root needs no ``cd``; an item that chose
|
|
1106
|
+
# its own directory always names it.
|
|
1107
|
+
working_directory=str(workspace) if workspace is not None else None,
|
|
1108
|
+
assignment_scope=(
|
|
1109
|
+
span.to_dict() if span and item.id == span_ids[0] else None
|
|
1110
|
+
),
|
|
1111
|
+
continues_assignment=item.id in span_ids[1:],
|
|
1112
|
+
rules=rule_lines(item, record),
|
|
1113
|
+
modes=item.modes,
|
|
1114
|
+
fix_required=fix_required(item, record),
|
|
1115
|
+
checks_waived=record.checks_waived,
|
|
1116
|
+
verification=verification,
|
|
1117
|
+
)
|
|
1118
|
+
|
|
1119
|
+
|
|
1120
|
+
def rule_lines(item: PlanItem, record: PlanItemExecution) -> tuple[RuleLine, ...]:
|
|
1121
|
+
"""The rules a step's page lists: its rules, then its ``fix`` hooks.
|
|
1122
|
+
|
|
1123
|
+
A rule without a command that a converted derived check covers is listed
|
|
1124
|
+
as checked; a judged one carries the store's interpretation, and the
|
|
1125
|
+
missing configuration file when its converted check does not apply here.
|
|
1126
|
+
"""
|
|
1127
|
+
resolutions = {entry.id: entry for entry in record.rule_resolutions}
|
|
1128
|
+
|
|
1129
|
+
def line(rule: PlannedRule) -> RuleLine:
|
|
1130
|
+
resolution = resolutions.get(rule.id)
|
|
1131
|
+
converted = resolution is not None and resolution.status == "converted"
|
|
1132
|
+
missing = resolution.missing if resolution else None
|
|
1133
|
+
return RuleLine(
|
|
1134
|
+
rule.id,
|
|
1135
|
+
rule.summary,
|
|
1136
|
+
rule.paths,
|
|
1137
|
+
rule.has_command or converted,
|
|
1138
|
+
interpretation=resolution.interpretation if resolution else None,
|
|
1139
|
+
check=(
|
|
1140
|
+
resolution.check
|
|
1141
|
+
if resolution is not None and (converted or missing is not None)
|
|
1142
|
+
else None
|
|
1143
|
+
),
|
|
1144
|
+
missing=missing,
|
|
1145
|
+
)
|
|
1146
|
+
|
|
1147
|
+
return (
|
|
1148
|
+
*(line(rule) for rule in item.rules),
|
|
1149
|
+
*(
|
|
1150
|
+
RuleLine(check.id, check.summary, has_command=True, hook=True)
|
|
1151
|
+
for check in item.checks
|
|
1152
|
+
if check.source == "hook"
|
|
1153
|
+
),
|
|
1154
|
+
)
|
|
1155
|
+
|
|
1156
|
+
|
|
1157
|
+
def fix_required(item: PlanItem, record: PlanItemExecution) -> FixRequired | None:
|
|
1158
|
+
"""What the step's worker must fix after ww rejected its last completion.
|
|
1159
|
+
|
|
1160
|
+
A waiver lifts it for the checks it names: the operator decided the step
|
|
1161
|
+
completes without them. A derived check shows the texts of the rules it
|
|
1162
|
+
covers; a verifier's failing verdict shows its evidence.
|
|
1163
|
+
"""
|
|
1164
|
+
if not record.check_reports:
|
|
1165
|
+
return None
|
|
1166
|
+
report = record.check_reports[-1]
|
|
1167
|
+
waived = {key for key, _ in record.checks_waived}
|
|
1168
|
+
failed = tuple(result for result in report.failed if result.id not in waived)
|
|
1169
|
+
if not failed:
|
|
1170
|
+
return None
|
|
1171
|
+
limits = fix_limits(item, record)
|
|
1172
|
+
return FixRequired(
|
|
1173
|
+
attempt=record.fix_attempts,
|
|
1174
|
+
max_fixes=max(limits.values(), default=1),
|
|
1175
|
+
checks=len(report.results),
|
|
1176
|
+
failures=fix_failures(item, record, failed),
|
|
1177
|
+
draft_artifact=record.draft_artifact,
|
|
1178
|
+
)
|
|
1179
|
+
|
|
1180
|
+
|
|
1181
|
+
def fix_failures(
|
|
1182
|
+
item: PlanItem, record: PlanItemExecution, results: tuple[CheckResult, ...]
|
|
1183
|
+
) -> tuple[FixFailure, ...]:
|
|
1184
|
+
"""Failed check results as the fix page shows them, with their rules' texts."""
|
|
1185
|
+
texts = {rule.id: rule.text for rule in item.rules}
|
|
1186
|
+
texts.update(
|
|
1187
|
+
{
|
|
1188
|
+
check.id: check.on_failure_instruction
|
|
1189
|
+
for check in item.checks
|
|
1190
|
+
if check.on_failure_instruction is not None
|
|
1191
|
+
}
|
|
1192
|
+
)
|
|
1193
|
+
covers = {check.id: check.covers for check in record.resolved_checks}
|
|
1194
|
+
return tuple(
|
|
1195
|
+
FixFailure(
|
|
1196
|
+
result.id,
|
|
1197
|
+
result.source == "hook",
|
|
1198
|
+
(
|
|
1199
|
+
"\n".join(
|
|
1200
|
+
f"- `{rule_id}`: {texts.get(rule_id, '')}"
|
|
1201
|
+
for rule_id in covers[result.id]
|
|
1202
|
+
)
|
|
1203
|
+
if result.id in covers
|
|
1204
|
+
else texts.get(result.id)
|
|
1205
|
+
),
|
|
1206
|
+
result.command,
|
|
1207
|
+
result.output,
|
|
1208
|
+
judged=result.source == "judged",
|
|
1209
|
+
covers=covers.get(result.id, ()),
|
|
1210
|
+
)
|
|
1211
|
+
for result in results
|
|
1212
|
+
)
|
|
1213
|
+
|
|
1214
|
+
|
|
1215
|
+
def dispute_view(item: PlanItem, record: PlanItemExecution) -> DisputeView | None:
|
|
1216
|
+
"""The worker's open dispute, with the disputed check as it last failed."""
|
|
1217
|
+
dispute = record.dispute
|
|
1218
|
+
if dispute is None:
|
|
1219
|
+
return None
|
|
1220
|
+
result = next(
|
|
1221
|
+
(
|
|
1222
|
+
result
|
|
1223
|
+
for report in reversed(record.check_reports)
|
|
1224
|
+
for result in report.failed
|
|
1225
|
+
if result.id == dispute.check
|
|
1226
|
+
),
|
|
1227
|
+
None,
|
|
1228
|
+
)
|
|
1229
|
+
(failure,) = fix_failures(
|
|
1230
|
+
item,
|
|
1231
|
+
record,
|
|
1232
|
+
(
|
|
1233
|
+
result
|
|
1234
|
+
or CheckResult(
|
|
1235
|
+
dispute.check,
|
|
1236
|
+
"rule",
|
|
1237
|
+
"failed",
|
|
1238
|
+
command=dispute.command,
|
|
1239
|
+
output=dispute.output,
|
|
1240
|
+
),
|
|
1241
|
+
),
|
|
1242
|
+
)
|
|
1243
|
+
return DisputeView(
|
|
1244
|
+
failure=replace(failure, command=dispute.command, output=dispute.output),
|
|
1245
|
+
reason=dispute.reason,
|
|
1246
|
+
attempt=dispute.attempt,
|
|
1247
|
+
)
|
|
1248
|
+
|
|
1249
|
+
|
|
1250
|
+
def worker_token(state: ExecutionState) -> str | None:
|
|
1251
|
+
"""The token a worker command of the open assignment carries, if any.
|
|
1252
|
+
|
|
1253
|
+
Only the ``auto`` runtime delegates, so only there can a worker outlive
|
|
1254
|
+
its assignment; ``single`` runs every step in one session.
|
|
1255
|
+
"""
|
|
1256
|
+
return state.assignment_token if state.workflow_runtime == "auto" else None
|
|
1257
|
+
|
|
1258
|
+
|
|
1259
|
+
def _base(
|
|
1260
|
+
state: ExecutionState,
|
|
1261
|
+
item: PlanItem | None,
|
|
1262
|
+
*,
|
|
1263
|
+
status: InstructionStatus | None = None,
|
|
1264
|
+
item_status: ItemStatus | None = None,
|
|
1265
|
+
) -> Instruction:
|
|
1266
|
+
"""The identity fields every instruction carries for the current item."""
|
|
1267
|
+
return Instruction(
|
|
1268
|
+
task_id=state.task_id,
|
|
1269
|
+
workflow=state.workflow,
|
|
1270
|
+
status=status or state.status,
|
|
1271
|
+
item_id=item.id if item else None,
|
|
1272
|
+
item_name=item.name if item else None,
|
|
1273
|
+
stage=_stage(item),
|
|
1274
|
+
step=item.step if item else None,
|
|
1275
|
+
parent=item.parent if item else None,
|
|
1276
|
+
item_status=item_status,
|
|
1277
|
+
action_kind=item.kind if item else None,
|
|
1278
|
+
action_text=None,
|
|
1279
|
+
)
|
|
1280
|
+
|
|
1281
|
+
|
|
1282
|
+
def _loop_control(
|
|
1283
|
+
state: ExecutionState, item: PlanItem, loop: LoopBoundary
|
|
1284
|
+
) -> Instruction:
|
|
1285
|
+
iteration = dict(state.loop_iterations).get(loop.loop_id, 0)
|
|
1286
|
+
limit_reached = loop_limit_reached(state, item)
|
|
1287
|
+
continuation: str | None = next_command(state.task_id)
|
|
1288
|
+
recovery: tuple[RecoveryCommand, ...] = ()
|
|
1289
|
+
if loop.boundary == "enter":
|
|
1290
|
+
text = (
|
|
1291
|
+
"Dispatch the first loop step. The loop remains active until "
|
|
1292
|
+
"a child step explicitly breaks it."
|
|
1293
|
+
)
|
|
1294
|
+
elif limit_reached:
|
|
1295
|
+
text = (
|
|
1296
|
+
f"Warning: loop `{loop.loop_id}` reached its maximum of "
|
|
1297
|
+
f"{loop.max_times} iterations. ww will not start another "
|
|
1298
|
+
"iteration. Escalate this result to the user for manual resolution."
|
|
1299
|
+
)
|
|
1300
|
+
continuation = None
|
|
1301
|
+
recovery = (force_command(state.task_id),)
|
|
1302
|
+
else:
|
|
1303
|
+
text = (
|
|
1304
|
+
f"Iteration {iteration} is complete. Dispatch the first step "
|
|
1305
|
+
"of the next iteration. The loop remains active until a child "
|
|
1306
|
+
"step explicitly breaks it."
|
|
1307
|
+
)
|
|
1308
|
+
return replace(
|
|
1309
|
+
_base(state, item, item_status="pending"),
|
|
1310
|
+
stage="Loop boundary",
|
|
1311
|
+
action_text=text,
|
|
1312
|
+
continuation_command=continuation,
|
|
1313
|
+
loop_iteration=iteration,
|
|
1314
|
+
max_rounds=loop.max_times,
|
|
1315
|
+
loop_limit_reached=limit_reached,
|
|
1316
|
+
recovery_commands=recovery,
|
|
1317
|
+
is_loop_control=True,
|
|
1318
|
+
)
|
|
1319
|
+
|
|
1320
|
+
|
|
1321
|
+
def _automatic_running(
|
|
1322
|
+
state: ExecutionState, item: PlanItem, record: PlanItemExecution
|
|
1323
|
+
) -> Instruction:
|
|
1324
|
+
"""An automatic item still marked running: only the locked next can settle it."""
|
|
1325
|
+
return replace(
|
|
1326
|
+
_base(state, item, item_status="in_progress"),
|
|
1327
|
+
error=(
|
|
1328
|
+
"automatic handler may still be running; use next "
|
|
1329
|
+
"under the task lock to inspect and resolve it"
|
|
1330
|
+
),
|
|
1331
|
+
continuation_command=next_command(state.task_id),
|
|
1332
|
+
operation_id=record.operation_id,
|
|
1333
|
+
recovery_commands=recovery_commands(state.task_id),
|
|
1334
|
+
)
|
|
1335
|
+
|
|
1336
|
+
|
|
1337
|
+
@dataclass(frozen=True)
|
|
1338
|
+
class _StepResult:
|
|
1339
|
+
step: str
|
|
1340
|
+
artifact: str
|
|
1341
|
+
summary: str | None
|
|
1342
|
+
completed_at: str = ""
|
|
1343
|
+
|
|
1344
|
+
|
|
1345
|
+
def _result_body(artifact: str) -> str:
|
|
1346
|
+
"""The agent's result inside ww's step artifact envelope."""
|
|
1347
|
+
_, marker, body = artifact.partition("\n## Result\n\n")
|
|
1348
|
+
return (body if marker else artifact).strip()
|
|
1349
|
+
|
|
1350
|
+
|
|
1351
|
+
def _bootstrap_profile_instruction(
|
|
1352
|
+
request: dict[str, object], root: Path
|
|
1353
|
+
) -> str | None:
|
|
1354
|
+
path = request.get("profile_path")
|
|
1355
|
+
if path:
|
|
1356
|
+
return f"Use the profile defined in `{(root / str(path)).resolve()}`."
|
|
1357
|
+
text = request.get("profile_instruction")
|
|
1358
|
+
return str(text) if text else None
|
|
1359
|
+
|
|
1360
|
+
|
|
1361
|
+
def profile_instruction(item: PlanItem, root: Path) -> str | None:
|
|
1362
|
+
"""The profile text for an item: a file under ``root`` or configured text."""
|
|
1363
|
+
if item.profile_path is not None:
|
|
1364
|
+
return f"Use the profile defined in `{(root / item.profile_path).resolve()}`."
|
|
1365
|
+
return item.profile_instruction
|
|
1366
|
+
|
|
1367
|
+
|
|
1368
|
+
def _current_assignment(
|
|
1369
|
+
state: ExecutionState, plan: WorkflowPlan, item: PlanItem | None
|
|
1370
|
+
) -> Assignment | None:
|
|
1371
|
+
"""The auto-runtime assignment holding the cursor: active, or about to begin."""
|
|
1372
|
+
if state.workflow_runtime != "auto" or item is None or is_coordinator(item):
|
|
1373
|
+
return None
|
|
1374
|
+
if state.status in {"completed", "failed", "interrupted"}:
|
|
1375
|
+
return None
|
|
1376
|
+
if needs_repair(state):
|
|
1377
|
+
return Assignment(item.id, state.cursor, state.cursor + 1)
|
|
1378
|
+
active = active_assignment(
|
|
1379
|
+
plan, state.assignment_item_id, runtime=state.workflow_runtime
|
|
1380
|
+
)
|
|
1381
|
+
if active is not None and active.start <= state.cursor < active.stop:
|
|
1382
|
+
return active
|
|
1383
|
+
return assignment_at(plan, state.cursor, runtime=state.workflow_runtime)
|
|
1384
|
+
|
|
1385
|
+
|
|
1386
|
+
def _span(
|
|
1387
|
+
plan: WorkflowPlan, assignment: Assignment | None
|
|
1388
|
+
) -> ItemSpan | LoopSpan | None:
|
|
1389
|
+
"""The several stages or loop steps one worker performs in this assignment."""
|
|
1390
|
+
if assignment is None:
|
|
1391
|
+
return None
|
|
1392
|
+
return item_span(plan, assignment) or loop_span(plan, assignment)
|
|
1393
|
+
|
|
1394
|
+
|
|
1395
|
+
def _loop_round(
|
|
1396
|
+
state: ExecutionState, plan: WorkflowPlan, item: PlanItem
|
|
1397
|
+
) -> tuple[str, int, int] | None:
|
|
1398
|
+
"""The enclosing loop's name, current round, and limit for a body step."""
|
|
1399
|
+
if item.loop_id is None:
|
|
1400
|
+
return None
|
|
1401
|
+
entry = next(
|
|
1402
|
+
(
|
|
1403
|
+
candidate
|
|
1404
|
+
for candidate in plan.items
|
|
1405
|
+
if (loop := loop_control(candidate)) is not None
|
|
1406
|
+
and loop.loop_id == item.loop_id
|
|
1407
|
+
and loop.boundary == "enter"
|
|
1408
|
+
),
|
|
1409
|
+
None,
|
|
1410
|
+
)
|
|
1411
|
+
boundary = loop_control(entry) if entry is not None else None
|
|
1412
|
+
if entry is None or boundary is None: # pragma: no cover - compiler invariant
|
|
1413
|
+
raise ValueError(f"loop {item.loop_id!r} has no entry boundary")
|
|
1414
|
+
iteration = dict(state.loop_iterations).get(item.loop_id, 1)
|
|
1415
|
+
return entry.name, iteration, boundary.max_times
|
|
1416
|
+
|
|
1417
|
+
|
|
1418
|
+
def _assignment_preview(
|
|
1419
|
+
state: ExecutionState,
|
|
1420
|
+
plan: WorkflowPlan,
|
|
1421
|
+
item: PlanItem | None,
|
|
1422
|
+
next_role: NextRole,
|
|
1423
|
+
) -> dict[str, object] | None:
|
|
1424
|
+
"""What a delegating manager should know about the upcoming assignment."""
|
|
1425
|
+
if state.workflow_runtime != "auto" or next_role != "manager":
|
|
1426
|
+
return None
|
|
1427
|
+
if state.status == "awaiting_input":
|
|
1428
|
+
# The manager is already inside this assignment, supplying its values.
|
|
1429
|
+
return None
|
|
1430
|
+
if needs_repair(state) and item is not None:
|
|
1431
|
+
return {
|
|
1432
|
+
"first_item_id": item.id,
|
|
1433
|
+
"start": state.cursor,
|
|
1434
|
+
"stop": state.cursor + 1,
|
|
1435
|
+
"selection_item_id": item.id,
|
|
1436
|
+
"selection_item_name": item.name,
|
|
1437
|
+
"requested_agent": item.requested_agent,
|
|
1438
|
+
"requested_model": item.requested_model,
|
|
1439
|
+
"requested_reasoning": item.requested_reasoning,
|
|
1440
|
+
"requested_profile": item.profile,
|
|
1441
|
+
"explicit": item.explicit,
|
|
1442
|
+
"explicit_steps": (
|
|
1443
|
+
[item.step]
|
|
1444
|
+
if item.explicit and (item.owner == "agent" or needs_repair(state))
|
|
1445
|
+
else []
|
|
1446
|
+
),
|
|
1447
|
+
"repair": True,
|
|
1448
|
+
}
|
|
1449
|
+
assignment = assignment_at(plan, state.cursor, runtime=state.workflow_runtime)
|
|
1450
|
+
if assignment is not None and input_only(plan, assignment):
|
|
1451
|
+
return {
|
|
1452
|
+
"first_item_id": assignment.first_item_id,
|
|
1453
|
+
"start": assignment.start,
|
|
1454
|
+
"stop": assignment.stop,
|
|
1455
|
+
"message": (
|
|
1456
|
+
"The next assignment only supplies values to an automatic "
|
|
1457
|
+
"handler. The manager provides them itself; no worker is "
|
|
1458
|
+
"selected."
|
|
1459
|
+
),
|
|
1460
|
+
}
|
|
1461
|
+
if assignment is not None:
|
|
1462
|
+
driver = selection_item(plan, assignment)
|
|
1463
|
+
if driver is None:
|
|
1464
|
+
return None
|
|
1465
|
+
if driver.role == "manager":
|
|
1466
|
+
return {
|
|
1467
|
+
"first_item_id": assignment.first_item_id,
|
|
1468
|
+
"start": assignment.start,
|
|
1469
|
+
"stop": assignment.stop,
|
|
1470
|
+
"message": (
|
|
1471
|
+
f"`{driver.name}` is the manager's (`role: manager`), so no "
|
|
1472
|
+
"worker is "
|
|
1473
|
+
"selected: after the manager command, perform it yourself "
|
|
1474
|
+
"in this session."
|
|
1475
|
+
),
|
|
1476
|
+
}
|
|
1477
|
+
span = _span(plan, assignment)
|
|
1478
|
+
return {
|
|
1479
|
+
"first_item_id": assignment.first_item_id,
|
|
1480
|
+
"start": assignment.start,
|
|
1481
|
+
"stop": assignment.stop,
|
|
1482
|
+
"selection_item_id": driver.id,
|
|
1483
|
+
"selection_item_name": driver.name,
|
|
1484
|
+
"requested_agent": driver.requested_agent,
|
|
1485
|
+
"requested_model": driver.requested_model,
|
|
1486
|
+
"requested_reasoning": driver.requested_reasoning,
|
|
1487
|
+
"requested_profile": driver.profile,
|
|
1488
|
+
"explicit": driver.explicit,
|
|
1489
|
+
"explicit_steps": [
|
|
1490
|
+
entry.step
|
|
1491
|
+
for entry in plan.items[assignment.start : assignment.stop]
|
|
1492
|
+
if entry.explicit and entry.owner == "agent"
|
|
1493
|
+
],
|
|
1494
|
+
**(
|
|
1495
|
+
{"item_scope": span.to_dict()}
|
|
1496
|
+
if isinstance(span, ItemSpan)
|
|
1497
|
+
else {"loop_scope": span.to_dict()}
|
|
1498
|
+
if span is not None
|
|
1499
|
+
else {}
|
|
1500
|
+
),
|
|
1501
|
+
}
|
|
1502
|
+
if item is not None and is_coordinator(item):
|
|
1503
|
+
return {
|
|
1504
|
+
"coordinator_item_id": item.id,
|
|
1505
|
+
"coordinator_item_name": item.name,
|
|
1506
|
+
"message": (
|
|
1507
|
+
"Coordinator work must run before the next worker "
|
|
1508
|
+
"assignment can be determined."
|
|
1509
|
+
),
|
|
1510
|
+
}
|
|
1511
|
+
return None
|
|
1512
|
+
|
|
1513
|
+
|
|
1514
|
+
def _guidance(
|
|
1515
|
+
state: ExecutionState,
|
|
1516
|
+
item: PlanItem | None,
|
|
1517
|
+
selection: _Selection,
|
|
1518
|
+
next_role: NextRole,
|
|
1519
|
+
) -> tuple[str, ...]:
|
|
1520
|
+
"""Runtime guidance plus notes about worker selection."""
|
|
1521
|
+
guidance = runtime_instruction(state.workflow_runtime, next_role)
|
|
1522
|
+
if item is not None and not item.subagents:
|
|
1523
|
+
guidance = (*guidance, NO_SUBAGENTS)
|
|
1524
|
+
if state.workflow_runtime != "auto" or item is None:
|
|
1525
|
+
return guidance
|
|
1526
|
+
requested = (item.requested_agent, item.requested_model, item.requested_reasoning)
|
|
1527
|
+
selected = (selection.agent, selection.model, selection.reasoning)
|
|
1528
|
+
differences = tuple(
|
|
1529
|
+
(label, request, actual)
|
|
1530
|
+
for label, request, actual in zip(
|
|
1531
|
+
("agent", "model", "reasoning"), requested, selected, strict=True
|
|
1532
|
+
)
|
|
1533
|
+
if request not in {None, "auto"} and actual is not None and request != actual
|
|
1534
|
+
)
|
|
1535
|
+
if differences:
|
|
1536
|
+
detail = ", ".join(
|
|
1537
|
+
f"{label} requested {request!r}, selected {actual!r}"
|
|
1538
|
+
for label, request, actual in differences
|
|
1539
|
+
)
|
|
1540
|
+
guidance = (
|
|
1541
|
+
*guidance,
|
|
1542
|
+
"Execution selection differs from the request: " + detail + ".",
|
|
1543
|
+
)
|
|
1544
|
+
if item.phase != "step":
|
|
1545
|
+
guidance = (
|
|
1546
|
+
*guidance,
|
|
1547
|
+
"This hook requests different execution settings from "
|
|
1548
|
+
"the current assignment. You may perform it yourself "
|
|
1549
|
+
"or delegate only this hook to a suitable worker. "
|
|
1550
|
+
"You remain responsible for reporting the result to ww.",
|
|
1551
|
+
)
|
|
1552
|
+
if item.interactive:
|
|
1553
|
+
guidance = (
|
|
1554
|
+
*guidance,
|
|
1555
|
+
"This step is interactive: a conversation with the operator that "
|
|
1556
|
+
"only this session can hold, because a delegated worker cannot "
|
|
1557
|
+
"talk to them. Perform it yourself and do not delegate it. Its "
|
|
1558
|
+
"profile, agent, model, and reasoning settings are ignored.",
|
|
1559
|
+
)
|
|
1560
|
+
elif item.role == "manager":
|
|
1561
|
+
guidance = (
|
|
1562
|
+
*guidance,
|
|
1563
|
+
"This step is the manager's (`role: manager`): perform it yourself rather "
|
|
1564
|
+
"than handing it to a worker. Its profile, agent, model, and "
|
|
1565
|
+
"reasoning settings are ignored.",
|
|
1566
|
+
)
|
|
1567
|
+
return guidance
|
|
1568
|
+
|
|
1569
|
+
|
|
1570
|
+
def build_bootstrap_instruction(request: dict[str, object], root: Path) -> Instruction:
|
|
1571
|
+
"""Build the presentation view for a short-lived identity request."""
|
|
1572
|
+
request_id = str(request["request_id"])
|
|
1573
|
+
status = str(request["status"])
|
|
1574
|
+
in_progress = status == "in_progress"
|
|
1575
|
+
display_status = "in_progress" if status == "binding" else status
|
|
1576
|
+
if status == "completed":
|
|
1577
|
+
return Instruction(
|
|
1578
|
+
task_id=request_id,
|
|
1579
|
+
workflow=str(request["workflow"]),
|
|
1580
|
+
status="completed",
|
|
1581
|
+
item_id=None,
|
|
1582
|
+
item_name=None,
|
|
1583
|
+
stage=None,
|
|
1584
|
+
step=None,
|
|
1585
|
+
parent=None,
|
|
1586
|
+
item_status=None,
|
|
1587
|
+
action_kind=None,
|
|
1588
|
+
action_text=(
|
|
1589
|
+
"External task identity has been bound to "
|
|
1590
|
+
f"`{request['resolved_task_id']}`."
|
|
1591
|
+
),
|
|
1592
|
+
next_role="manager",
|
|
1593
|
+
control="handoff_manager",
|
|
1594
|
+
)
|
|
1595
|
+
# A failed bootstrap is agent work that failed: the operator decides.
|
|
1596
|
+
reason: OperatorReason | None = "work_failed" if status == "failed" else None
|
|
1597
|
+
next_role: NextRole = (
|
|
1598
|
+
"worker" if in_progress else "operator" if reason else "manager"
|
|
1599
|
+
)
|
|
1600
|
+
control: Control = (
|
|
1601
|
+
"continue_worker"
|
|
1602
|
+
if in_progress
|
|
1603
|
+
else "awaiting_operator"
|
|
1604
|
+
if reason
|
|
1605
|
+
else "handoff_manager"
|
|
1606
|
+
)
|
|
1607
|
+
return Instruction(
|
|
1608
|
+
task_id=request_id,
|
|
1609
|
+
workflow=str(request["workflow"]),
|
|
1610
|
+
status=_instruction_status(display_status),
|
|
1611
|
+
item_id=str(request["item_id"]),
|
|
1612
|
+
item_name=str(request["item_name"]),
|
|
1613
|
+
stage="Task identity bootstrap",
|
|
1614
|
+
step=str(request["step"]),
|
|
1615
|
+
parent=None,
|
|
1616
|
+
item_status=_item_status(
|
|
1617
|
+
"in_progress" if status in {"binding", "in_progress"} else status
|
|
1618
|
+
),
|
|
1619
|
+
action_kind=_plan_item_kind(str(request["action_kind"])),
|
|
1620
|
+
action_text=str(request["action_text"]) if in_progress else None,
|
|
1621
|
+
required_values=(ProvidedVariable("task_id", "The external task ID."),)
|
|
1622
|
+
if in_progress
|
|
1623
|
+
else (),
|
|
1624
|
+
continuation_command=(
|
|
1625
|
+
complete_command(request_id, (ProvidedVariable("task_id"),))
|
|
1626
|
+
if in_progress
|
|
1627
|
+
else next_command(request_id)
|
|
1628
|
+
),
|
|
1629
|
+
error=str(request["error"]) if "error" in request else None,
|
|
1630
|
+
profile_instruction=(
|
|
1631
|
+
_bootstrap_profile_instruction(request, root) if in_progress else None
|
|
1632
|
+
),
|
|
1633
|
+
workflow_runtime=str(request.get("workflow_runtime", "single")),
|
|
1634
|
+
workflow_runtime_instruction=runtime_instruction(
|
|
1635
|
+
str(request.get("workflow_runtime", "single")), next_role
|
|
1636
|
+
),
|
|
1637
|
+
model=str(request.get("model", "auto")),
|
|
1638
|
+
reasoning=str(request.get("reasoning", "auto")),
|
|
1639
|
+
requested_agent=str(request.get("requested_agent", request.get("agent", "")))
|
|
1640
|
+
or None,
|
|
1641
|
+
requested_model=str(request.get("requested_model", "auto")),
|
|
1642
|
+
requested_reasoning=str(request.get("requested_reasoning", "auto")),
|
|
1643
|
+
requested_profile=(
|
|
1644
|
+
str(request["requested_profile"])
|
|
1645
|
+
if request.get("requested_profile")
|
|
1646
|
+
else None
|
|
1647
|
+
),
|
|
1648
|
+
selected_agent=(
|
|
1649
|
+
str(request["selected_agent"]) if request.get("selected_agent") else None
|
|
1650
|
+
),
|
|
1651
|
+
selected_model=(
|
|
1652
|
+
str(request["selected_model"]) if request.get("selected_model") else None
|
|
1653
|
+
),
|
|
1654
|
+
selected_reasoning=(
|
|
1655
|
+
str(request["selected_reasoning"])
|
|
1656
|
+
if request.get("selected_reasoning")
|
|
1657
|
+
else None
|
|
1658
|
+
),
|
|
1659
|
+
next_role=next_role,
|
|
1660
|
+
control=control,
|
|
1661
|
+
operator_reason=reason,
|
|
1662
|
+
modes=_bootstrap_modes(request) if in_progress else (),
|
|
1663
|
+
)
|
|
1664
|
+
|
|
1665
|
+
|
|
1666
|
+
def _bootstrap_modes(request: dict[str, object]) -> tuple[PlannedMode, ...]:
|
|
1667
|
+
"""The bootstrap step's modes; a request recorded before them has none."""
|
|
1668
|
+
entries = request.get("step_modes", [])
|
|
1669
|
+
if not isinstance(entries, list):
|
|
1670
|
+
raise ValueError("bootstrap request step_modes must be a list")
|
|
1671
|
+
return tuple(
|
|
1672
|
+
PlannedMode.from_dict(entry, f"bootstrap request step_modes[{index}]")
|
|
1673
|
+
for index, entry in enumerate(entries)
|
|
1674
|
+
)
|
|
1675
|
+
|
|
1676
|
+
|
|
1677
|
+
def _later_item_pass(plan: WorkflowPlan, item: PlanItem) -> bool:
|
|
1678
|
+
"""Whether ``item`` collects for an ``items`` pass after the workflow's first."""
|
|
1679
|
+
if item.item_operation != "collect" or item.child_operation is not None:
|
|
1680
|
+
return False
|
|
1681
|
+
first = item_collection(plan)
|
|
1682
|
+
return first is not None and item.item_pass != first.item_pass
|