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,219 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""Pure execution-state interpretation for instructions."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from enum import Enum
|
|
7
|
+
|
|
8
|
+
from ww.actions import actions
|
|
9
|
+
from ww.assignments import active_assignment, input_only
|
|
10
|
+
from ww.contracts import (
|
|
11
|
+
Control,
|
|
12
|
+
InstructionStatus,
|
|
13
|
+
ItemStatus,
|
|
14
|
+
NextRole,
|
|
15
|
+
OperatorReason,
|
|
16
|
+
PlanItemKind,
|
|
17
|
+
)
|
|
18
|
+
from ww.control import child_workflow, loop_control, replays_harmlessly
|
|
19
|
+
from ww.errors import StateError
|
|
20
|
+
from ww.execution_models import ExecutionState
|
|
21
|
+
from ww.handler_repairs import needs_repair
|
|
22
|
+
from ww.plan import WorkflowPlan
|
|
23
|
+
from ww.transitions import loop_limit_reached
|
|
24
|
+
from ww.validation import expect_literal
|
|
25
|
+
|
|
26
|
+
from .models import Instruction
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Audience(Enum):
|
|
30
|
+
"""Who reads an instruction and what they are expected to do with it."""
|
|
31
|
+
|
|
32
|
+
SINGLE_SESSION = "single"
|
|
33
|
+
"""One session plays both roles and does the work itself."""
|
|
34
|
+
MANAGER_DELEGATING = "manager_delegating"
|
|
35
|
+
"""The manager hands the next assignment to a worker."""
|
|
36
|
+
WORKER_RETURNING = "worker_returning"
|
|
37
|
+
"""A worker has finished its assignment and returns control."""
|
|
38
|
+
WORKER = "worker"
|
|
39
|
+
"""A worker performs the active assignment."""
|
|
40
|
+
MANAGER = "manager"
|
|
41
|
+
"""The manager runs the next command itself."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def audience(instruction: Instruction) -> Audience:
|
|
45
|
+
if instruction.workflow_runtime == "single":
|
|
46
|
+
return Audience.SINGLE_SESSION
|
|
47
|
+
if instruction.caller_role == "manager" and instruction.next_role == "worker":
|
|
48
|
+
return Audience.MANAGER_DELEGATING
|
|
49
|
+
if instruction.caller_role == "worker" and instruction.next_role in {
|
|
50
|
+
"manager",
|
|
51
|
+
"operator",
|
|
52
|
+
}:
|
|
53
|
+
return Audience.WORKER_RETURNING
|
|
54
|
+
return Audience.WORKER if instruction.next_role == "worker" else Audience.MANAGER
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def manager_continues_itself(instruction: Instruction) -> bool:
|
|
58
|
+
"""Whether the step a manager's completion hands back is the manager's own.
|
|
59
|
+
|
|
60
|
+
Then ``next`` has nothing to decide for the manager: no worker is selected,
|
|
61
|
+
nothing waits for the operator, no assessment outcome is to be chosen, and
|
|
62
|
+
no loop, child, repair, or plan change needs a deliberate command.
|
|
63
|
+
"""
|
|
64
|
+
return (
|
|
65
|
+
instruction.workflow_runtime == "auto"
|
|
66
|
+
and instruction.caller_role == "manager"
|
|
67
|
+
and instruction.next_role == "manager"
|
|
68
|
+
and instruction.role == "manager"
|
|
69
|
+
and instruction.control == "handoff_manager"
|
|
70
|
+
and instruction.status == "pending"
|
|
71
|
+
and instruction.item_status == "pending"
|
|
72
|
+
and instruction.operator_reason is None
|
|
73
|
+
and instruction.choosing_outcome_of is None
|
|
74
|
+
and instruction.plan_change is None
|
|
75
|
+
and instruction.fix_required is None
|
|
76
|
+
and instruction.handler_repair is None
|
|
77
|
+
and instruction.error is None
|
|
78
|
+
and not instruction.is_loop_control
|
|
79
|
+
and not instruction.is_child_workflow_control
|
|
80
|
+
and not instruction.manager_only
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _instruction_status(value: str) -> InstructionStatus:
|
|
85
|
+
status: InstructionStatus = expect_literal(
|
|
86
|
+
value, InstructionStatus, "bootstrap request status", error=StateError
|
|
87
|
+
)
|
|
88
|
+
return status
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _item_status(value: str) -> ItemStatus:
|
|
92
|
+
status: ItemStatus = expect_literal(
|
|
93
|
+
value, ItemStatus, "bootstrap request item status", error=StateError
|
|
94
|
+
)
|
|
95
|
+
return status
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _plan_item_kind(value: str) -> PlanItemKind:
|
|
99
|
+
if not actions.contains(value):
|
|
100
|
+
raise StateError(f"bootstrap request has invalid action kind: {value!r}")
|
|
101
|
+
return value
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _has_previous_artifacts(state: ExecutionState, plan: WorkflowPlan) -> bool:
|
|
105
|
+
return any(
|
|
106
|
+
record.artifact is not None
|
|
107
|
+
for index, (item, record) in enumerate(
|
|
108
|
+
zip(plan.items, state.item_executions, strict=True)
|
|
109
|
+
)
|
|
110
|
+
if index < state.cursor and record.status == "completed"
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _next_steps(state: ExecutionState, plan: WorkflowPlan) -> tuple[str, ...]:
|
|
115
|
+
current = plan.items[state.cursor]
|
|
116
|
+
if current.phase != "step":
|
|
117
|
+
return ()
|
|
118
|
+
return tuple(
|
|
119
|
+
dict.fromkeys(
|
|
120
|
+
item.step
|
|
121
|
+
for item in plan.items[state.cursor + 1 :]
|
|
122
|
+
if item.phase == "step"
|
|
123
|
+
and item.parent == current.parent
|
|
124
|
+
and item.step != current.step
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _index_for_id(plan: WorkflowPlan, item_id: str) -> int:
|
|
130
|
+
for index, item in enumerate(plan.items):
|
|
131
|
+
if item.id == item_id:
|
|
132
|
+
return index
|
|
133
|
+
raise StateError(f"plan item {item_id!r} is not in the task snapshot")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def operator_reason(state: ExecutionState, plan: WorkflowPlan) -> OperatorReason | None:
|
|
137
|
+
"""Why the task waits for the operator, or ``None`` when it does not."""
|
|
138
|
+
item = plan.items[state.cursor] if state.cursor < len(plan.items) else None
|
|
139
|
+
if state.status == "interrupted":
|
|
140
|
+
# ``next`` replays a harmless handler without asking anyone.
|
|
141
|
+
if item is not None and replays_harmlessly(item):
|
|
142
|
+
return None
|
|
143
|
+
return "handler_interrupted"
|
|
144
|
+
if state.status == "failed":
|
|
145
|
+
if state.failure_kind is not None:
|
|
146
|
+
return state.failure_kind
|
|
147
|
+
if item is not None and child_workflow(item) is not None:
|
|
148
|
+
return "child_failed"
|
|
149
|
+
if item is not None and item.owner == "agent":
|
|
150
|
+
return "work_failed"
|
|
151
|
+
return "handler_failed"
|
|
152
|
+
if (
|
|
153
|
+
state.status != "awaiting_input"
|
|
154
|
+
and item is not None
|
|
155
|
+
and loop_limit_reached(state, item)
|
|
156
|
+
):
|
|
157
|
+
return "loop_limit"
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _control(state: ExecutionState, plan: WorkflowPlan) -> tuple[Control, NextRole]:
|
|
162
|
+
if operator_reason(state, plan) is not None:
|
|
163
|
+
return "awaiting_operator", "operator"
|
|
164
|
+
if state.status in {"failed", "interrupted"}:
|
|
165
|
+
return "blocked", "manager"
|
|
166
|
+
if state.status in {"completed", "abandoned"}:
|
|
167
|
+
return "handoff_manager", "manager"
|
|
168
|
+
if needs_repair(state):
|
|
169
|
+
return (
|
|
170
|
+
("continue_worker", "worker")
|
|
171
|
+
if state.active_item_id
|
|
172
|
+
else ("handoff_manager", "manager")
|
|
173
|
+
)
|
|
174
|
+
if state.status == "awaiting_input":
|
|
175
|
+
assignment = active_assignment(
|
|
176
|
+
plan, state.assignment_item_id, runtime=state.workflow_runtime
|
|
177
|
+
)
|
|
178
|
+
if (
|
|
179
|
+
state.workflow_runtime == "auto"
|
|
180
|
+
and assignment is not None
|
|
181
|
+
and input_only(plan, assignment)
|
|
182
|
+
):
|
|
183
|
+
return "handoff_manager", "manager"
|
|
184
|
+
return "continue_worker", "worker"
|
|
185
|
+
if state.cursor < len(plan.items):
|
|
186
|
+
item = plan.items[state.cursor]
|
|
187
|
+
record = state.item_executions[state.cursor]
|
|
188
|
+
if (
|
|
189
|
+
item.owner == "ww"
|
|
190
|
+
and item.execution == "automatic"
|
|
191
|
+
and record.status == "in_progress"
|
|
192
|
+
):
|
|
193
|
+
return "blocked", "manager"
|
|
194
|
+
if state.cursor < len(plan.items) and (
|
|
195
|
+
child_workflow(plan.items[state.cursor]) is not None
|
|
196
|
+
or loop_control(plan.items[state.cursor]) is not None
|
|
197
|
+
):
|
|
198
|
+
return "blocked", "manager"
|
|
199
|
+
assignment = active_assignment(
|
|
200
|
+
plan, state.assignment_item_id, runtime=state.workflow_runtime
|
|
201
|
+
)
|
|
202
|
+
if assignment is not None and state.cursor < assignment.stop:
|
|
203
|
+
return "continue_worker", "worker"
|
|
204
|
+
if state.active_item_id and state.cursor < len(plan.items):
|
|
205
|
+
return "continue_worker", "worker"
|
|
206
|
+
return "handoff_manager", "manager"
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _result_saved(state: ExecutionState, plan: WorkflowPlan) -> bool:
|
|
210
|
+
assignment = active_assignment(
|
|
211
|
+
plan, state.assignment_item_id, runtime=state.workflow_runtime
|
|
212
|
+
)
|
|
213
|
+
start = assignment.start if assignment is not None else 0
|
|
214
|
+
stop = min(state.cursor + 1, len(plan.items))
|
|
215
|
+
return any(
|
|
216
|
+
plan.items[index].owner == "agent"
|
|
217
|
+
and state.item_executions[index].status == "completed"
|
|
218
|
+
for index in range(start, stop)
|
|
219
|
+
)
|
ww/instructions/text.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""Instruction wording and continuation command construction."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from ww.actions import InstructionContext, PlannedAction, actions
|
|
9
|
+
from ww.plan import PlanItem
|
|
10
|
+
from ww.variables import PROJECTS
|
|
11
|
+
|
|
12
|
+
from .commands import (
|
|
13
|
+
TASK_PLACEHOLDER,
|
|
14
|
+
add_child_command,
|
|
15
|
+
add_item_command,
|
|
16
|
+
item_command,
|
|
17
|
+
items_command,
|
|
18
|
+
update_item_command,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
NO_SUBAGENTS = (
|
|
22
|
+
"This step allows no subagents (`subagents: false`): whoever performs it "
|
|
23
|
+
"does all of its work alone and spawns no subagent for anything, not for "
|
|
24
|
+
"research, tests, or review."
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class ContainerArtifact:
|
|
30
|
+
"""The artifact ``artifact_from`` naming a group or an assessment found.
|
|
31
|
+
|
|
32
|
+
``step`` and ``artifact`` (an absolute path) name the latest step inside
|
|
33
|
+
the container that saved one in its current round, or the assessment
|
|
34
|
+
itself when its outcome saved none; both are ``None`` when none has.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
step: str | None = None
|
|
38
|
+
artifact: str | None = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _stage(item: PlanItem | None) -> str | None:
|
|
42
|
+
if item is None:
|
|
43
|
+
return None
|
|
44
|
+
if item.phase == "step":
|
|
45
|
+
return "Nested workflow step" if item.parent else "Workflow step"
|
|
46
|
+
if item.phase == "before_start_workflow":
|
|
47
|
+
return "Before Workflow Start hook"
|
|
48
|
+
if item.phase == "before_complete_workflow":
|
|
49
|
+
return "Before Workflow Completion hook"
|
|
50
|
+
return item.phase.replace("_", " ").title() + " hook"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def action_text(
|
|
54
|
+
item: PlanItem,
|
|
55
|
+
task_values: dict[str, str] | None = None,
|
|
56
|
+
task_id: str | None = None,
|
|
57
|
+
container_artifact: ContainerArtifact | None = None,
|
|
58
|
+
later_pass: bool = False,
|
|
59
|
+
) -> str:
|
|
60
|
+
"""Render the work text for an ordinary action, plus item or child guidance.
|
|
61
|
+
|
|
62
|
+
``later_pass``: the step collects for an ``items`` pass after the
|
|
63
|
+
workflow's first, which reuses the items already collected.
|
|
64
|
+
|
|
65
|
+
``container_artifact`` is what the step's ``artifact_from`` resolved to
|
|
66
|
+
when it names a group or an assessment; ``None`` for a single step.
|
|
67
|
+
"""
|
|
68
|
+
if not isinstance(item.operation, PlannedAction):
|
|
69
|
+
raise ValueError(f"core operation {item.kind!r} has no ordinary action text")
|
|
70
|
+
content = actions.get(item.operation.identifier).instruction(
|
|
71
|
+
item.operation.payload,
|
|
72
|
+
InstructionContext(item.description, item.name, task_values or {}),
|
|
73
|
+
)
|
|
74
|
+
task_reference = task_id or TASK_PLACEHOLDER
|
|
75
|
+
if item.item_operation == "collect" and later_pass:
|
|
76
|
+
result = (
|
|
77
|
+
content.text
|
|
78
|
+
+ "\n\nThis step starts a later pass over the items this run already "
|
|
79
|
+
"collected: do not split the source again or recreate items, and keep "
|
|
80
|
+
"what is recorded on them. Inspect them with "
|
|
81
|
+
f"`{items_command(task_reference)}`. Add an item with "
|
|
82
|
+
f"`{add_item_command(task_reference)}` only when this step asks you "
|
|
83
|
+
"to reconcile the items with their source. When this step completes, "
|
|
84
|
+
"the pass runs for the items recorded by then; an item added later "
|
|
85
|
+
"joins the next pass."
|
|
86
|
+
)
|
|
87
|
+
return _with_artifact_dependency(result, item, container_artifact)
|
|
88
|
+
if item.item_operation == "collect":
|
|
89
|
+
result = (
|
|
90
|
+
content.text
|
|
91
|
+
+ "\n\nSplit the source into complete, reportable items. Preserve every "
|
|
92
|
+
"source comment; use a stable source ID when one exists, otherwise choose "
|
|
93
|
+
f"a unique ID. Use `{add_item_command()}` for "
|
|
94
|
+
"each item. Combine related work under one canonical item only when that "
|
|
95
|
+
"avoids duplicate analysis or fixes; retain each related source item and "
|
|
96
|
+
"link it with `--refers-to <canonical-id>` so it can still be "
|
|
97
|
+
"reported."
|
|
98
|
+
)
|
|
99
|
+
if item.split_instruction:
|
|
100
|
+
result += f"\n\nHow to split: {item.split_instruction}"
|
|
101
|
+
return _with_artifact_dependency(result, item, container_artifact)
|
|
102
|
+
if item.child_operation == "collect":
|
|
103
|
+
projects = [
|
|
104
|
+
name for name in (task_values or {}).get(PROJECTS, "").split(",") if name
|
|
105
|
+
]
|
|
106
|
+
result = (
|
|
107
|
+
content.text
|
|
108
|
+
+ "\n\nRecord each child task with:\n\n```console\n"
|
|
109
|
+
+ add_child_command(
|
|
110
|
+
task_reference, project=bool(projects), identity=item.child_identity
|
|
111
|
+
)
|
|
112
|
+
+ "\n```\n\nRun this command once for every independent child task."
|
|
113
|
+
)
|
|
114
|
+
if item.split_instruction:
|
|
115
|
+
result += f"\n\nHow to split: {item.split_instruction}"
|
|
116
|
+
if item.child_identity:
|
|
117
|
+
result += (
|
|
118
|
+
"\n\nDo not pass `--id`: each child receives its ID from the first "
|
|
119
|
+
"step of its own workflow, which runs when the child starts."
|
|
120
|
+
)
|
|
121
|
+
if projects:
|
|
122
|
+
result += (
|
|
123
|
+
"\n\nConfigured projects: "
|
|
124
|
+
+ ", ".join(f"`{name}`" for name in projects)
|
|
125
|
+
+ ". Pass `--project` with the project a child works in; omit it "
|
|
126
|
+
"for a child that works in the root."
|
|
127
|
+
)
|
|
128
|
+
return _with_artifact_dependency(result, item, container_artifact)
|
|
129
|
+
text = content.text
|
|
130
|
+
if content.include_item_context and item.item_id:
|
|
131
|
+
text += (
|
|
132
|
+
f"\n\nActive item: `{item.item_id}`. Inspect it with "
|
|
133
|
+
f"`{item_command(task_reference, item.item_id)}`."
|
|
134
|
+
" Save progress with:\n\n```console\n"
|
|
135
|
+
+ update_item_command(task_reference, item.item_id, item.item_operation)
|
|
136
|
+
+ "\n```\n\nThis command confirms the update and returns the worker "
|
|
137
|
+
"completion command."
|
|
138
|
+
)
|
|
139
|
+
return _with_artifact_dependency(text, item, container_artifact)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _with_artifact_dependency(
|
|
143
|
+
text: str, item: PlanItem, container: ContainerArtifact | None
|
|
144
|
+
) -> str:
|
|
145
|
+
if item.artifact_dependency is None:
|
|
146
|
+
return text
|
|
147
|
+
if container is not None and container.artifact is None:
|
|
148
|
+
return (
|
|
149
|
+
text + f"\n\nNo artifact is available from `{item.artifact_dependency}`: "
|
|
150
|
+
"no step inside it that saves one completed in this run. Continue "
|
|
151
|
+
"without it."
|
|
152
|
+
)
|
|
153
|
+
if container is not None and container.step == item.artifact_dependency:
|
|
154
|
+
return (
|
|
155
|
+
text + f"\n\nUse the artifact produced by the `{container.step}` step as "
|
|
156
|
+
f"input to this work: `{container.artifact}`."
|
|
157
|
+
)
|
|
158
|
+
if container is not None:
|
|
159
|
+
return (
|
|
160
|
+
text + f"\n\nUse the artifact produced by the `{container.step}` step, "
|
|
161
|
+
f"the latest saved inside `{item.artifact_dependency}`, as input to "
|
|
162
|
+
f"this work: `{container.artifact}`."
|
|
163
|
+
)
|
|
164
|
+
return (
|
|
165
|
+
text
|
|
166
|
+
+ f"\n\nUse the artifact produced by the `{item.artifact_dependency}` step "
|
|
167
|
+
"as input to this work."
|
|
168
|
+
)
|
ww/interactions.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""The per-task record of conversations held with the operator.
|
|
3
|
+
|
|
4
|
+
An interactive step is a conversation the agent holds with the operator in
|
|
5
|
+
its own session; ww cannot hear it. When the conversation ends, the agent
|
|
6
|
+
records both sides at once with ``interact --transcript``, and ww appends
|
|
7
|
+
each entry to one file per task, ``interactions.md``, never rewriting it.
|
|
8
|
+
The ``interrupt`` hook appends what it recovers from the agent's own
|
|
9
|
+
transcript the same way, under a speaker marked ``(recovered)``. Every entry names the
|
|
10
|
+
run and step it belongs to, and the work item when the step is a per-item
|
|
11
|
+
stage, so the file reads as the task's whole history of operator involvement
|
|
12
|
+
and the conversation of one stage can be read back out of it.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
from collections.abc import Sequence
|
|
19
|
+
from dataclasses import dataclass, replace
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from ww.errors import StateError
|
|
23
|
+
from ww.storage import Storage
|
|
24
|
+
|
|
25
|
+
INTERACTIONS_FILE = "interactions.md"
|
|
26
|
+
_SEPARATOR = " · "
|
|
27
|
+
# A heading has time, run, step, and speaker; a per-item stage adds its item.
|
|
28
|
+
_FIELDS = 4
|
|
29
|
+
# Appended to the speaker of an entry the interrupt hook recovered.
|
|
30
|
+
RECOVERED = " (recovered)"
|
|
31
|
+
# A transcript line that starts an entry: a speaker marker in any case, plain
|
|
32
|
+
# or bold with the colon inside or outside the bold, then the entry's first
|
|
33
|
+
# line, e.g. "**Operator:** Use a queue." or "agent: I propose a queue.".
|
|
34
|
+
_MARKER = re.compile(
|
|
35
|
+
r"^\s*(?:\*\*)?(Agent|Operator)(?:\*\*)?:(?:\*\*)?\s*(.*)$", re.IGNORECASE
|
|
36
|
+
)
|
|
37
|
+
_TRANSCRIPT_FORMAT = (
|
|
38
|
+
"a transcript is lines starting with `Agent:` or `Operator:`, each "
|
|
39
|
+
"followed by that side's words; the lines until the next marker belong "
|
|
40
|
+
"to the same entry"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class InteractionEntry:
|
|
46
|
+
"""One recorded entry: who said what, in which run, step, and item."""
|
|
47
|
+
|
|
48
|
+
at: str
|
|
49
|
+
run_id: str | None
|
|
50
|
+
step: str
|
|
51
|
+
item_id: str | None
|
|
52
|
+
speaker: str
|
|
53
|
+
text: str
|
|
54
|
+
|
|
55
|
+
def to_dict(self) -> dict[str, object]:
|
|
56
|
+
return {
|
|
57
|
+
"at": self.at,
|
|
58
|
+
"run_id": self.run_id,
|
|
59
|
+
"step": self.step,
|
|
60
|
+
"item_id": self.item_id,
|
|
61
|
+
"speaker": self.speaker,
|
|
62
|
+
"text": self.text,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class InteractionLog:
|
|
67
|
+
def __init__(self, storage: Storage) -> None:
|
|
68
|
+
self.storage = storage
|
|
69
|
+
|
|
70
|
+
def path(self, task_id: str) -> Path:
|
|
71
|
+
return self.storage.runtime_path / "tasks" / task_id / INTERACTIONS_FILE
|
|
72
|
+
|
|
73
|
+
def append(
|
|
74
|
+
self,
|
|
75
|
+
task_id: str,
|
|
76
|
+
*,
|
|
77
|
+
run_id: str | None,
|
|
78
|
+
step: str,
|
|
79
|
+
speaker: str,
|
|
80
|
+
text: str,
|
|
81
|
+
at: str,
|
|
82
|
+
item_id: str | None = None,
|
|
83
|
+
) -> None:
|
|
84
|
+
"""Append one entry; the file is created with a title on first use."""
|
|
85
|
+
self.append_entries(
|
|
86
|
+
task_id,
|
|
87
|
+
((speaker, text),),
|
|
88
|
+
run_id=run_id,
|
|
89
|
+
step=step,
|
|
90
|
+
at=at,
|
|
91
|
+
item_id=item_id,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
def append_entries(
|
|
95
|
+
self,
|
|
96
|
+
task_id: str,
|
|
97
|
+
spoken: Sequence[tuple[str, str]],
|
|
98
|
+
*,
|
|
99
|
+
run_id: str | None,
|
|
100
|
+
step: str,
|
|
101
|
+
at: str,
|
|
102
|
+
item_id: str | None = None,
|
|
103
|
+
) -> None:
|
|
104
|
+
"""Append ``(speaker, text)`` entries in order, in one write."""
|
|
105
|
+
if not spoken:
|
|
106
|
+
return
|
|
107
|
+
path = self.path(task_id)
|
|
108
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
109
|
+
fields = [at, run_id or "-", step]
|
|
110
|
+
if item_id is not None:
|
|
111
|
+
fields.append(item_id)
|
|
112
|
+
chunks = []
|
|
113
|
+
for speaker, text in spoken:
|
|
114
|
+
chunks.append(f"## {_SEPARATOR.join([*fields, speaker])}\n\n")
|
|
115
|
+
if text.strip():
|
|
116
|
+
chunks.append(text.strip() + "\n\n")
|
|
117
|
+
with path.open("a", encoding="utf-8") as handle:
|
|
118
|
+
if handle.tell() == 0:
|
|
119
|
+
handle.write(f"# {task_id} — interactions with the operator\n\n")
|
|
120
|
+
handle.write("".join(chunks))
|
|
121
|
+
|
|
122
|
+
def read(self, task_id: str) -> str:
|
|
123
|
+
path = self.path(task_id)
|
|
124
|
+
return path.read_text(encoding="utf-8") if path.is_file() else ""
|
|
125
|
+
|
|
126
|
+
def entries(self, task_id: str) -> tuple[InteractionEntry, ...]:
|
|
127
|
+
"""Read the record back as entries, in the order they were appended."""
|
|
128
|
+
result: list[InteractionEntry] = []
|
|
129
|
+
heading: InteractionEntry | None = None
|
|
130
|
+
body: list[str] = []
|
|
131
|
+
for line in self.read(task_id).splitlines():
|
|
132
|
+
parsed = _parse_heading(line)
|
|
133
|
+
if parsed is None:
|
|
134
|
+
body.append(line)
|
|
135
|
+
continue
|
|
136
|
+
if heading is not None:
|
|
137
|
+
result.append(replace(heading, text="\n".join(body).strip()))
|
|
138
|
+
heading, body = parsed, []
|
|
139
|
+
if heading is not None:
|
|
140
|
+
result.append(replace(heading, text="\n".join(body).strip()))
|
|
141
|
+
return tuple(result)
|
|
142
|
+
|
|
143
|
+
def remove(self, task_id: str) -> None:
|
|
144
|
+
"""Forget a task's record, as part of resetting the task."""
|
|
145
|
+
self.path(task_id).unlink(missing_ok=True)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def parse_transcript(text: str) -> tuple[tuple[str, str], ...]:
|
|
149
|
+
"""A plain transcript as ordered ``(speaker, text)`` entries.
|
|
150
|
+
|
|
151
|
+
Speakers are ``operator`` and ``agent``; an entry with no words is
|
|
152
|
+
dropped. Text before the first marker, or no entry at all, is refused.
|
|
153
|
+
"""
|
|
154
|
+
entries: list[tuple[str, list[str]]] = []
|
|
155
|
+
for line in text.splitlines():
|
|
156
|
+
match = _MARKER.match(line)
|
|
157
|
+
if match is not None:
|
|
158
|
+
entries.append((match.group(1).lower(), [match.group(2)]))
|
|
159
|
+
elif entries:
|
|
160
|
+
entries[-1][1].append(line)
|
|
161
|
+
elif line.strip():
|
|
162
|
+
raise StateError(
|
|
163
|
+
f"the transcript starts with {line.strip()[:40]!r}; "
|
|
164
|
+
+ _TRANSCRIPT_FORMAT
|
|
165
|
+
)
|
|
166
|
+
result = tuple(
|
|
167
|
+
(speaker, "\n".join(lines).strip())
|
|
168
|
+
for speaker, lines in entries
|
|
169
|
+
if "\n".join(lines).strip()
|
|
170
|
+
)
|
|
171
|
+
if not result:
|
|
172
|
+
raise StateError("the transcript has no entries; " + _TRANSCRIPT_FORMAT)
|
|
173
|
+
return result
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _parse_heading(line: str) -> InteractionEntry | None:
|
|
177
|
+
"""An entry heading: time, run, step, an optional item, and the speaker."""
|
|
178
|
+
if not line.startswith("## "):
|
|
179
|
+
return None
|
|
180
|
+
fields = line[3:].split(_SEPARATOR)
|
|
181
|
+
if len(fields) not in (_FIELDS, _FIELDS + 1):
|
|
182
|
+
return None
|
|
183
|
+
at, run_id, step = fields[:3]
|
|
184
|
+
item_id = fields[3] if len(fields) > _FIELDS else None
|
|
185
|
+
return InteractionEntry(
|
|
186
|
+
at, None if run_id == "-" else run_id, step, item_id, fields[-1], ""
|
|
187
|
+
)
|
ww/interpolation.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""Interpolation analysis for normalized workflow definitions."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
|
|
9
|
+
from ww.errors import ConfigurationError
|
|
10
|
+
|
|
11
|
+
# One {{name}} template token, spaces inside the braces allowed:
|
|
12
|
+
# "{{ ww.task.id }}" and "{{metadata.owner}}" both match.
|
|
13
|
+
_TOKEN = re.compile(
|
|
14
|
+
r"\{\{\s*([A-Za-z_][A-Za-z0-9_-]*(?:\.[A-Za-z_][A-Za-z0-9_-]*)*)\s*\}\}"
|
|
15
|
+
)
|
|
16
|
+
# Double-braced text that is not a valid token, e.g. "{{ not a name }}".
|
|
17
|
+
_BRACES = re.compile(r"\{\{.*?\}\}")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def dependencies(value: str) -> tuple[str, ...]:
|
|
21
|
+
"""Return ordered interpolation names and reject malformed tokens."""
|
|
22
|
+
names = tuple(match.group(1) for match in _TOKEN.finditer(value))
|
|
23
|
+
normalized = _TOKEN.sub("", value)
|
|
24
|
+
if "{{" in normalized or "}}" in normalized or _BRACES.search(normalized):
|
|
25
|
+
raise ConfigurationError(f"invalid interpolation in {value!r}")
|
|
26
|
+
return names
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def interpolate(value: str, variables: Mapping[str, str]) -> str:
|
|
30
|
+
"""Bind values that are available, preserving declared unresolved tokens."""
|
|
31
|
+
dependencies(value)
|
|
32
|
+
|
|
33
|
+
def replace(match: re.Match[str]) -> str:
|
|
34
|
+
name = match.group(1)
|
|
35
|
+
return variables.get(name, match.group(0))
|
|
36
|
+
|
|
37
|
+
return _TOKEN.sub(replace, value)
|