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
ww/hooks/notices.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""The few lines agent hooks and task commands show about open work.
|
|
3
|
+
|
|
4
|
+
Whatever a session-start hook prints stays in the agent's context for the
|
|
5
|
+
rest of the session, so every text here is short and plain: no headings,
|
|
6
|
+
one line per task, and a cap on how many tasks are listed.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from ww.executable import ww_command
|
|
14
|
+
from ww.open_work import OpenTask, UnreadableTask
|
|
15
|
+
|
|
16
|
+
from .records import Interruption
|
|
17
|
+
|
|
18
|
+
SESSION_TASK_LIMIT = 5
|
|
19
|
+
STOP_TASK_LIMIT = 3
|
|
20
|
+
|
|
21
|
+
_OPERATOR_REASONS = {
|
|
22
|
+
"handler_failed": "an automatic handler failed",
|
|
23
|
+
"work_failed": "the work failed",
|
|
24
|
+
"child_failed": "a child task failed",
|
|
25
|
+
"handler_interrupted": "a command was interrupted",
|
|
26
|
+
"loop_limit": "a loop reached its limit",
|
|
27
|
+
"fix_limit": "a step's checks kept failing",
|
|
28
|
+
"check_disputed": "a worker disputed a check",
|
|
29
|
+
"value_unavailable": "a step's template value is not available yet",
|
|
30
|
+
"pass_incomplete": "an items pass left items without their recorded outcome",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def session_context(
|
|
35
|
+
open_tasks: tuple[OpenTask, ...],
|
|
36
|
+
interruptions: dict[str, Interruption],
|
|
37
|
+
root: Path,
|
|
38
|
+
*,
|
|
39
|
+
compacted: bool,
|
|
40
|
+
unreadable: tuple[UnreadableTask, ...] = (),
|
|
41
|
+
on_request: bool = False,
|
|
42
|
+
skipped: int = 0,
|
|
43
|
+
) -> str:
|
|
44
|
+
"""What a session learns about ww when it starts, resumes, or compacts.
|
|
45
|
+
|
|
46
|
+
``open_tasks`` are the recent unfinished tasks; ``skipped`` counts the
|
|
47
|
+
tasks the scan left unread as older, which ``discover`` still covers.
|
|
48
|
+
A step in progress without an interruption marker ended with no hook
|
|
49
|
+
run, so it gets a notice of its own, except after a compaction: the
|
|
50
|
+
session that holds it is the one carrying on.
|
|
51
|
+
"""
|
|
52
|
+
ww = ww_command()
|
|
53
|
+
lines = []
|
|
54
|
+
if compacted:
|
|
55
|
+
lines.append("Context was compacted; ww's task state is authoritative.")
|
|
56
|
+
lines.append(
|
|
57
|
+
"This project has ww available on request only: use it only when the "
|
|
58
|
+
"user explicitly asks for ww; otherwise work without it and do not "
|
|
59
|
+
f"ask. `{ww} discover` lists its workflows."
|
|
60
|
+
if on_request
|
|
61
|
+
else f"This project coordinates work through ww: `{ww} discover` lists "
|
|
62
|
+
"its workflows."
|
|
63
|
+
)
|
|
64
|
+
if open_tasks:
|
|
65
|
+
lines.append("Unfinished ww tasks, newest first:")
|
|
66
|
+
for task in open_tasks[:SESSION_TASK_LIMIT]:
|
|
67
|
+
lines.append(task_line(task, root, ww))
|
|
68
|
+
interruption = interruptions.get(task.task_id)
|
|
69
|
+
if interruption is not None:
|
|
70
|
+
lines.append(" " + interruption_notice(interruption, task.task_id))
|
|
71
|
+
elif task.agent_step_in_progress and not compacted:
|
|
72
|
+
lines.append(" " + abrupt_end_notice(task))
|
|
73
|
+
if len(open_tasks) > SESSION_TASK_LIMIT:
|
|
74
|
+
lines.append(
|
|
75
|
+
f"… and {len(open_tasks) - SESSION_TASK_LIMIT} more; "
|
|
76
|
+
f"`{ww} status <task-id>` shows one."
|
|
77
|
+
)
|
|
78
|
+
if open_tasks or skipped:
|
|
79
|
+
lines.append(f"`{ww} discover` lists every unfinished task.")
|
|
80
|
+
if unreadable:
|
|
81
|
+
lines.append(unreadable_notice(unreadable))
|
|
82
|
+
return "\n".join(lines) + "\n"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def unreadable_notice(unreadable: tuple[UnreadableTask, ...]) -> str:
|
|
86
|
+
"""One line naming the tasks ww cannot read, without their errors."""
|
|
87
|
+
names = ", ".join(task.task_id for task in unreadable)
|
|
88
|
+
return (
|
|
89
|
+
f"ww cannot read the state of {names}; other tasks and new work are "
|
|
90
|
+
f"unaffected. `{ww_command()} discover` shows why; ask the operator "
|
|
91
|
+
"before touching them."
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def task_line(task: OpenTask, root: Path, ww: str) -> str:
|
|
96
|
+
"""One unfinished task with its step, state, workspace and resume commands."""
|
|
97
|
+
step = task.label
|
|
98
|
+
if task.operator_reason is not None:
|
|
99
|
+
state = "awaiting the operator: " + _OPERATOR_REASONS.get(
|
|
100
|
+
task.operator_reason, task.operator_reason
|
|
101
|
+
)
|
|
102
|
+
else:
|
|
103
|
+
state = (task.item_status or task.run_status).replace("_", " ")
|
|
104
|
+
parts = [f"- {task.task_id} ({task.workflow}, {task.agent}) {step}: {state}"]
|
|
105
|
+
parts.append(f"in {display_workspace(task.workspace, root)}")
|
|
106
|
+
parts.append(f"resume: `{ww} instruction {task.task_id} --role manager`")
|
|
107
|
+
if task.agent_step_in_progress and task.run_id:
|
|
108
|
+
parts.append(
|
|
109
|
+
f"worker: `{ww} instruction {task.task_id} --run {task.run_id} "
|
|
110
|
+
"--role worker`"
|
|
111
|
+
)
|
|
112
|
+
return " · ".join(parts)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def stop_reminder(tasks: tuple[OpenTask, ...]) -> str:
|
|
116
|
+
"""Ask, once, that the open step be closed before the agent stops."""
|
|
117
|
+
ww = ww_command()
|
|
118
|
+
listed = tasks[:STOP_TASK_LIMIT]
|
|
119
|
+
names = "; ".join(f"{task.task_id} step `{task.label}`" for task in listed)
|
|
120
|
+
example = listed[0].task_id
|
|
121
|
+
return (
|
|
122
|
+
f"ww: {names} is still in progress. If the work is done, record it with "
|
|
123
|
+
f"`{ww} complete {example} --role worker ...` as its instruction shows; if "
|
|
124
|
+
f"it cannot finish, run `{ww} fail {example} --role worker "
|
|
125
|
+
f'--error "<reason>"`. '
|
|
126
|
+
"A manager waiting on a worker, or a deliberate pause, may simply stop "
|
|
127
|
+
"again: ww reminds only once."
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def interruption_notice(interruption: Interruption, task_id: str) -> str:
|
|
132
|
+
"""Tell whoever picks the task up that its last session stopped short."""
|
|
133
|
+
ww = ww_command()
|
|
134
|
+
who = interruption.agent + (
|
|
135
|
+
f", {interruption.reason}" if interruption.reason else ""
|
|
136
|
+
)
|
|
137
|
+
step = interruption.step or interruption.item_name or "its step"
|
|
138
|
+
if interruption.in_conversation:
|
|
139
|
+
lead = (
|
|
140
|
+
f"Interrupted: the previous session ({who}) stopped at {interruption.at} "
|
|
141
|
+
f"while `{step}` (attempt {interruption.attempt}) was talking with the "
|
|
142
|
+
"operator. "
|
|
143
|
+
)
|
|
144
|
+
if interruption.recovered_entries:
|
|
145
|
+
return lead + (
|
|
146
|
+
f"{interruption.recovered_entries} entries of the conversation "
|
|
147
|
+
"were recovered from the session transcript; read them on the "
|
|
148
|
+
"step's page and continue from the last unanswered point."
|
|
149
|
+
)
|
|
150
|
+
return lead + (
|
|
151
|
+
"The conversation was not recorded; ask the operator where you were."
|
|
152
|
+
)
|
|
153
|
+
return (
|
|
154
|
+
f"Interrupted: the previous session ({who}) stopped at {interruption.at} "
|
|
155
|
+
f"during `{step}` (attempt {interruption.attempt}). Before continuing, check "
|
|
156
|
+
"`git status` in the task workspace, review the diff and ww's recorded "
|
|
157
|
+
f"commits (`{ww} extension ww/git commits {task_id}`), and compare them "
|
|
158
|
+
"with the step's requirements; then complete, continue, or fail the step."
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def abrupt_end_notice(task: OpenTask) -> str:
|
|
163
|
+
"""Tell whoever picks the task up that its last session left no trace.
|
|
164
|
+
|
|
165
|
+
The ``interrupt`` hook never runs when a tab is closed, the agent crashes
|
|
166
|
+
or is killed, so the step is still in progress with no marker.
|
|
167
|
+
"""
|
|
168
|
+
lead = (
|
|
169
|
+
f"Left in progress at {task.updated_at} by {task.agent} with no recorded "
|
|
170
|
+
"end, probably a closed session: "
|
|
171
|
+
)
|
|
172
|
+
if task.in_conversation:
|
|
173
|
+
return (
|
|
174
|
+
lead + "the step's page shows the conversation so far; pick it up at "
|
|
175
|
+
"the last unanswered question."
|
|
176
|
+
)
|
|
177
|
+
return (
|
|
178
|
+
lead + f"check its page (`{ww_command()} instruction {task.task_id} "
|
|
179
|
+
"--role manager`) before continuing."
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def display_workspace(path: Path, root: Path) -> str:
|
|
184
|
+
"""A workspace relative to the root, or "the root" itself."""
|
|
185
|
+
try:
|
|
186
|
+
relative = path.relative_to(root.resolve())
|
|
187
|
+
except ValueError:
|
|
188
|
+
return str(path)
|
|
189
|
+
return str(relative) if relative.parts else "the root"
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def recent_interruptions_pointer(count: int, days: int) -> str | None:
|
|
193
|
+
"""One line for ``discover`` and ``lookup``, only when there is news.
|
|
194
|
+
|
|
195
|
+
Those entry commands are what an agent without hooks runs first, so the
|
|
196
|
+
line is the whole of their "first start" check; the details live in
|
|
197
|
+
``ww interrupted`` and on each task's own commands.
|
|
198
|
+
"""
|
|
199
|
+
if not count:
|
|
200
|
+
return None
|
|
201
|
+
tasks = "task was" if count == 1 else "tasks were"
|
|
202
|
+
window = "day" if days == 1 else f"{days} days"
|
|
203
|
+
return (
|
|
204
|
+
f"{count} {tasks} interrupted in the last {window}; "
|
|
205
|
+
f"run `{ww_command()} interrupted` before starting new work."
|
|
206
|
+
)
|
ww/hooks/records.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""The two small per-task records agent hooks keep beside the interaction log.
|
|
3
|
+
|
|
4
|
+
``interrupted.json`` marks a task whose agent session ended, or was
|
|
5
|
+
interrupted, while one of its agent-owned steps was in progress. It holds
|
|
6
|
+
one record, overwritten by the next interruption, and is cleared once that
|
|
7
|
+
step's attempt completes or fails, never merely because a notice showed it:
|
|
8
|
+
a compaction or a new session between showing and acting must not lose it.
|
|
9
|
+
|
|
10
|
+
``stop-reminders.json`` lists the step attempts ww already reminded an agent
|
|
11
|
+
about when it stopped, so the reminder is given once and the next stop is
|
|
12
|
+
always allowed.
|
|
13
|
+
|
|
14
|
+
Both belong to the task and are forgotten when the task is reset.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
from dataclasses import asdict, dataclass
|
|
21
|
+
from datetime import datetime, timedelta, timezone
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from ww.errors import StateError
|
|
25
|
+
from ww.storage import Storage
|
|
26
|
+
from ww.storage_adapters import TaskStorageAdapter
|
|
27
|
+
|
|
28
|
+
INTERRUPTED_FILE = "interrupted.json"
|
|
29
|
+
REMINDERS_FILE = "stop-reminders.json"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class Interruption:
|
|
34
|
+
"""What a session was doing when it stopped short."""
|
|
35
|
+
|
|
36
|
+
at: str
|
|
37
|
+
run_id: str | None
|
|
38
|
+
step: str | None
|
|
39
|
+
item_id: str
|
|
40
|
+
# ``step`` is how messages name the work, a hook as "<name> (a hook of
|
|
41
|
+
# <step>)"; ``item_name`` is the plan item's own name.
|
|
42
|
+
item_name: str | None
|
|
43
|
+
attempt: int
|
|
44
|
+
agent: str
|
|
45
|
+
# The agent's own word for why the session ended, when it gives one.
|
|
46
|
+
reason: str | None = None
|
|
47
|
+
# The step was talking with the operator when the session ended.
|
|
48
|
+
in_conversation: bool = False
|
|
49
|
+
# How many entries of that conversation the hook recovered from the
|
|
50
|
+
# session's transcript into the interaction record.
|
|
51
|
+
recovered_entries: int = 0
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def moment(self) -> datetime | None:
|
|
55
|
+
try:
|
|
56
|
+
return datetime.fromisoformat(self.at.replace("Z", "+00:00"))
|
|
57
|
+
except ValueError:
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
def to_dict(self) -> dict[str, object]:
|
|
61
|
+
return asdict(self)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class HookRecords:
|
|
65
|
+
"""Read and write the hook records of the tasks in one project root."""
|
|
66
|
+
|
|
67
|
+
def __init__(self, storage: Storage, tasks: TaskStorageAdapter) -> None:
|
|
68
|
+
self.storage = storage
|
|
69
|
+
self.tasks = tasks
|
|
70
|
+
|
|
71
|
+
def _directory(self, task_id: str) -> Path:
|
|
72
|
+
return self.storage.runtime_path / "tasks" / task_id
|
|
73
|
+
|
|
74
|
+
# Interruptions
|
|
75
|
+
|
|
76
|
+
def mark_interrupted(self, task_id: str, record: Interruption) -> None:
|
|
77
|
+
path = self._directory(task_id) / INTERRUPTED_FILE
|
|
78
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
79
|
+
self.storage.locks.atomic_write(
|
|
80
|
+
path, json.dumps(record.to_dict(), indent=2) + "\n"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
def interruption(self, task_id: str) -> Interruption | None:
|
|
84
|
+
"""The task's interruption, or ``None`` once its attempt has ended.
|
|
85
|
+
|
|
86
|
+
A record whose attempt completed or failed, or was superseded by a
|
|
87
|
+
later attempt, is removed here: this is the one place that decides
|
|
88
|
+
whether the notice still applies.
|
|
89
|
+
"""
|
|
90
|
+
path = self._directory(task_id) / INTERRUPTED_FILE
|
|
91
|
+
record = _read_interruption(path)
|
|
92
|
+
if record is None:
|
|
93
|
+
return None
|
|
94
|
+
if self._attempt_ended(task_id, record):
|
|
95
|
+
path.unlink(missing_ok=True)
|
|
96
|
+
return None
|
|
97
|
+
return record
|
|
98
|
+
|
|
99
|
+
def interruptions(self) -> tuple[tuple[str, Interruption], ...]:
|
|
100
|
+
"""Every task still marked as interrupted, newest first."""
|
|
101
|
+
root = self.storage.runtime_path / "tasks"
|
|
102
|
+
if not root.is_dir():
|
|
103
|
+
return ()
|
|
104
|
+
found = []
|
|
105
|
+
for path in root.rglob(INTERRUPTED_FILE):
|
|
106
|
+
task_id = path.parent.relative_to(root).as_posix()
|
|
107
|
+
try:
|
|
108
|
+
record = self.interruption(task_id)
|
|
109
|
+
except StateError:
|
|
110
|
+
# open_work() reports the unreadable task; skip it here.
|
|
111
|
+
continue
|
|
112
|
+
if record is not None:
|
|
113
|
+
found.append((task_id, record))
|
|
114
|
+
return tuple(sorted(found, key=lambda entry: entry[1].at, reverse=True))
|
|
115
|
+
|
|
116
|
+
def recent(self, days: int) -> tuple[tuple[str, Interruption], ...]:
|
|
117
|
+
"""The interruptions of the last ``days`` days, newest first."""
|
|
118
|
+
horizon = datetime.now(timezone.utc) - timedelta(days=days)
|
|
119
|
+
return tuple(
|
|
120
|
+
(task_id, record)
|
|
121
|
+
for task_id, record in self.interruptions()
|
|
122
|
+
if record.moment is not None and record.moment >= horizon
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
def _attempt_ended(self, task_id: str, record: Interruption) -> bool:
|
|
126
|
+
runs, _, _ = self.tasks.read_task_record(task_id)
|
|
127
|
+
run = next((run for run in runs if run.run_id == record.run_id), None)
|
|
128
|
+
if run is None:
|
|
129
|
+
return False
|
|
130
|
+
execution = next(
|
|
131
|
+
(
|
|
132
|
+
entry
|
|
133
|
+
for entry in run.state.item_executions
|
|
134
|
+
if entry.plan_item_id == record.item_id
|
|
135
|
+
),
|
|
136
|
+
None,
|
|
137
|
+
)
|
|
138
|
+
if execution is None:
|
|
139
|
+
return False
|
|
140
|
+
return execution.status in {"completed", "failed"} or (
|
|
141
|
+
execution.attempts > record.attempt
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
# Stop reminders
|
|
145
|
+
|
|
146
|
+
def claim_reminder(self, task_id: str, key: str) -> bool:
|
|
147
|
+
"""Record the reminder for ``key``; ``False`` when it was already given.
|
|
148
|
+
|
|
149
|
+
Agents may run the same hook twice at once (Claude Code runs matching
|
|
150
|
+
hooks in parallel), so checking and recording happen under one lock of
|
|
151
|
+
their own: exactly one call reminds. It is not the task lock, which a
|
|
152
|
+
long-running command may hold for minutes.
|
|
153
|
+
"""
|
|
154
|
+
path = self._directory(task_id) / REMINDERS_FILE
|
|
155
|
+
with self.storage.locks.lock(path, purpose="stop reminders"):
|
|
156
|
+
keys = self._reminders(task_id)
|
|
157
|
+
if key in keys:
|
|
158
|
+
return False
|
|
159
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
160
|
+
self.storage.locks.atomic_write(
|
|
161
|
+
path, json.dumps([*keys, key], indent=2) + "\n"
|
|
162
|
+
)
|
|
163
|
+
return True
|
|
164
|
+
|
|
165
|
+
def _reminders(self, task_id: str) -> list[str]:
|
|
166
|
+
path = self._directory(task_id) / REMINDERS_FILE
|
|
167
|
+
try:
|
|
168
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
169
|
+
except (OSError, json.JSONDecodeError):
|
|
170
|
+
return []
|
|
171
|
+
return (
|
|
172
|
+
[entry for entry in value if isinstance(entry, str)]
|
|
173
|
+
if isinstance(value, list)
|
|
174
|
+
else []
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
def remove(self, task_id: str) -> None:
|
|
178
|
+
"""Forget both records, as part of resetting the task."""
|
|
179
|
+
for name in (INTERRUPTED_FILE, REMINDERS_FILE):
|
|
180
|
+
(self._directory(task_id) / name).unlink(missing_ok=True)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def reminder_key(run_id: str | None, item_id: str, attempt: int) -> str:
|
|
184
|
+
"""One step attempt, which ww reminds an agent about at most once."""
|
|
185
|
+
return f"{run_id or '-'}:{item_id}:{attempt}"
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _read_interruption(path: Path) -> Interruption | None:
|
|
189
|
+
try:
|
|
190
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
191
|
+
except (OSError, json.JSONDecodeError):
|
|
192
|
+
return None
|
|
193
|
+
if not isinstance(value, dict):
|
|
194
|
+
return None
|
|
195
|
+
try:
|
|
196
|
+
return Interruption(
|
|
197
|
+
at=str(value["at"]),
|
|
198
|
+
run_id=value.get("run_id"),
|
|
199
|
+
step=value.get("step"),
|
|
200
|
+
item_id=str(value["item_id"]),
|
|
201
|
+
item_name=value.get("item_name"),
|
|
202
|
+
attempt=int(value.get("attempt", 0)),
|
|
203
|
+
agent=str(value.get("agent", "")),
|
|
204
|
+
reason=value.get("reason"),
|
|
205
|
+
in_conversation=bool(value.get("in_conversation", False)),
|
|
206
|
+
recovered_entries=int(value.get("recovered_entries", 0)),
|
|
207
|
+
)
|
|
208
|
+
except (KeyError, TypeError, ValueError):
|
|
209
|
+
return None
|
ww/hooks/runtime.py
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
"""What ww answers when an agent calls one of its hooks.
|
|
3
|
+
|
|
4
|
+
The hooks are gentle by design: they add a few lines of context or remind
|
|
5
|
+
once, and nothing is ever blocked.
|
|
6
|
+
|
|
7
|
+
- ``session-start`` prints a reminder that ww coordinates work here, and the
|
|
8
|
+
recently updated unfinished tasks with the commands that resume them,
|
|
9
|
+
unless ``agent_hooks.check_unfinished`` switches that scan off.
|
|
10
|
+
- ``stop`` asks the agent, once per step attempt, to record an agent-owned
|
|
11
|
+
step that is still in progress; the next stop is always allowed.
|
|
12
|
+
- ``interrupt`` records, without answering, that a session ended while such
|
|
13
|
+
a step was in progress, so the next session is told to check the work.
|
|
14
|
+
When the step was talking with the operator, it first recovers what the
|
|
15
|
+
two sides said from the session's transcript into the interaction record.
|
|
16
|
+
|
|
17
|
+
An adapter reads the agent's payload and renders the answer; everything else
|
|
18
|
+
is decided here, from persisted state alone.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
from dataclasses import dataclass, replace
|
|
25
|
+
from datetime import datetime, timedelta, timezone
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
from ww.config_files import WORKFLOWS_FILE
|
|
29
|
+
from ww.interactions import RECOVERED, InteractionLog
|
|
30
|
+
from ww.open_work import OpenTask, open_work, tasks_for_session
|
|
31
|
+
from ww.project_config import AgentHooks
|
|
32
|
+
from ww.storage import Storage
|
|
33
|
+
|
|
34
|
+
from .agents import HookAgent, HookEvent, HookPayload
|
|
35
|
+
from .notices import session_context, stop_reminder
|
|
36
|
+
from .records import HookRecords, Interruption, reminder_key
|
|
37
|
+
from .transcripts import recover_conversation
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class HookAnswer:
|
|
42
|
+
"""What the hook prints, and a word for the audit log about why."""
|
|
43
|
+
|
|
44
|
+
text: str
|
|
45
|
+
decision: str
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
ALLOW = HookAnswer("", "allowed")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def answer_hook(
|
|
52
|
+
storage: Storage,
|
|
53
|
+
agent: HookAgent,
|
|
54
|
+
event: HookEvent,
|
|
55
|
+
raw_payload: str,
|
|
56
|
+
*,
|
|
57
|
+
settings: AgentHooks,
|
|
58
|
+
on_request: bool = False,
|
|
59
|
+
) -> HookAnswer:
|
|
60
|
+
"""ww's answer to one hook call; the caller contains every error.
|
|
61
|
+
|
|
62
|
+
``on_request`` is the project's ``"enabled": "on_request"``: the session
|
|
63
|
+
is told that ww is used only when the user asks for it. Stop reminders
|
|
64
|
+
are unchanged, since they concern tasks already open. ``settings`` is
|
|
65
|
+
the project's ``agent_hooks``, which only ``session-start`` reads.
|
|
66
|
+
"""
|
|
67
|
+
payload = agent.parse(event, _payload(raw_payload))
|
|
68
|
+
records = HookRecords(storage, storage.task_persistence)
|
|
69
|
+
if event == "session-start":
|
|
70
|
+
return _session_start(
|
|
71
|
+
storage, agent, payload, records, settings, on_request=on_request
|
|
72
|
+
)
|
|
73
|
+
# An unreadable task has no step anyone can close, so stop and interrupt
|
|
74
|
+
# consider only the tasks that could be read.
|
|
75
|
+
tasks = open_work(storage.task_persistence, storage.root).tasks
|
|
76
|
+
if event == "stop" and not payload.interrupted:
|
|
77
|
+
return _stop(agent, payload, records, tasks)
|
|
78
|
+
return _interrupt(agent, payload, records, tasks)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _session_start(
|
|
82
|
+
storage: Storage,
|
|
83
|
+
agent: HookAgent,
|
|
84
|
+
payload: HookPayload,
|
|
85
|
+
records: HookRecords,
|
|
86
|
+
settings: AgentHooks,
|
|
87
|
+
*,
|
|
88
|
+
on_request: bool,
|
|
89
|
+
) -> HookAnswer:
|
|
90
|
+
if not payload.wants_context:
|
|
91
|
+
return HookAnswer("", "no context needed")
|
|
92
|
+
compacted = payload.source == "compact"
|
|
93
|
+
if not settings.check_unfinished:
|
|
94
|
+
text = session_context(
|
|
95
|
+
(), {}, storage.root, compacted=compacted, on_request=on_request
|
|
96
|
+
)
|
|
97
|
+
return HookAnswer(
|
|
98
|
+
agent.context_reply(text.rstrip("\n")) + "\n",
|
|
99
|
+
"context without the unfinished-task scan",
|
|
100
|
+
)
|
|
101
|
+
horizon = datetime.now(timezone.utc) - timedelta(days=settings.recent_days)
|
|
102
|
+
work = open_work(storage.task_persistence, storage.root, since=horizon)
|
|
103
|
+
interruptions = {
|
|
104
|
+
task.task_id: record
|
|
105
|
+
for task in work.tasks
|
|
106
|
+
if (record := records.interruption(task.task_id)) is not None
|
|
107
|
+
}
|
|
108
|
+
text = session_context(
|
|
109
|
+
work.tasks,
|
|
110
|
+
interruptions,
|
|
111
|
+
storage.root,
|
|
112
|
+
compacted=compacted,
|
|
113
|
+
unreadable=work.unreadable,
|
|
114
|
+
on_request=on_request,
|
|
115
|
+
skipped=work.skipped,
|
|
116
|
+
)
|
|
117
|
+
return HookAnswer(
|
|
118
|
+
agent.context_reply(text.rstrip("\n")) + "\n",
|
|
119
|
+
f"context with {len(work.tasks)} unfinished task(s)",
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _stop(
|
|
124
|
+
agent: HookAgent,
|
|
125
|
+
payload: HookPayload,
|
|
126
|
+
records: HookRecords,
|
|
127
|
+
tasks: tuple[OpenTask, ...],
|
|
128
|
+
) -> HookAnswer:
|
|
129
|
+
if payload.continued:
|
|
130
|
+
return replace(ALLOW, decision="allowed: the agent already continued once")
|
|
131
|
+
# A manager waiting on a worker is not the one to close the step: its own
|
|
132
|
+
# stop skips delegated steps, and the worker's stop reminds instead. A
|
|
133
|
+
# step in conversation with the operator stops to hear them.
|
|
134
|
+
if not payload.from_worker and any(
|
|
135
|
+
task.waiting_on_another(tasks)
|
|
136
|
+
for task in tasks_for_session(
|
|
137
|
+
tasks, records.storage.root, payload.directory, agent.name
|
|
138
|
+
)
|
|
139
|
+
):
|
|
140
|
+
return replace(
|
|
141
|
+
ALLOW, decision="allowed: the manager is waiting on a child or worker"
|
|
142
|
+
)
|
|
143
|
+
working = tasks_for_session(
|
|
144
|
+
tuple(
|
|
145
|
+
task
|
|
146
|
+
for task in tasks
|
|
147
|
+
if task.agent_step_in_progress
|
|
148
|
+
and not task.in_conversation
|
|
149
|
+
and (payload.from_worker or not task.delegated)
|
|
150
|
+
),
|
|
151
|
+
records.storage.root,
|
|
152
|
+
payload.directory,
|
|
153
|
+
agent.name,
|
|
154
|
+
)
|
|
155
|
+
pending = tuple(
|
|
156
|
+
task
|
|
157
|
+
for task in working
|
|
158
|
+
if task.item_id is not None and records.claim_reminder(task.task_id, _key(task))
|
|
159
|
+
)
|
|
160
|
+
if not pending:
|
|
161
|
+
return ALLOW
|
|
162
|
+
return HookAnswer(
|
|
163
|
+
agent.continue_reply(stop_reminder(pending)) + "\n",
|
|
164
|
+
"reminded: " + ", ".join(task.task_id for task in pending),
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _interrupt(
|
|
169
|
+
agent: HookAgent,
|
|
170
|
+
payload: HookPayload,
|
|
171
|
+
records: HookRecords,
|
|
172
|
+
tasks: tuple[OpenTask, ...],
|
|
173
|
+
) -> HookAnswer:
|
|
174
|
+
working = tasks_for_session(
|
|
175
|
+
tuple(task for task in tasks if task.agent_step_in_progress),
|
|
176
|
+
records.storage.root,
|
|
177
|
+
payload.directory,
|
|
178
|
+
agent.name,
|
|
179
|
+
)
|
|
180
|
+
if not working:
|
|
181
|
+
return HookAnswer("", "no step in progress to mark")
|
|
182
|
+
at = (
|
|
183
|
+
datetime.now(timezone.utc)
|
|
184
|
+
.replace(microsecond=0)
|
|
185
|
+
.isoformat()
|
|
186
|
+
.replace("+00:00", "Z")
|
|
187
|
+
)
|
|
188
|
+
for task in working:
|
|
189
|
+
assert task.item_id is not None
|
|
190
|
+
recovered = (
|
|
191
|
+
_recover(agent, payload, records.storage, task, at)
|
|
192
|
+
if task.in_conversation
|
|
193
|
+
else 0
|
|
194
|
+
)
|
|
195
|
+
records.mark_interrupted(
|
|
196
|
+
task.task_id,
|
|
197
|
+
Interruption(
|
|
198
|
+
at=at,
|
|
199
|
+
run_id=task.run_id,
|
|
200
|
+
step=task.label,
|
|
201
|
+
item_id=task.item_id,
|
|
202
|
+
item_name=task.item_name,
|
|
203
|
+
attempt=task.attempt,
|
|
204
|
+
agent=agent.name,
|
|
205
|
+
reason=payload.reason,
|
|
206
|
+
in_conversation=task.in_conversation,
|
|
207
|
+
recovered_entries=recovered,
|
|
208
|
+
),
|
|
209
|
+
)
|
|
210
|
+
return HookAnswer(
|
|
211
|
+
"", "marked interrupted: " + ", ".join(task.task_id for task in working)
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _recover(
|
|
216
|
+
agent: HookAgent,
|
|
217
|
+
payload: HookPayload,
|
|
218
|
+
storage: Storage,
|
|
219
|
+
task: OpenTask,
|
|
220
|
+
at: str,
|
|
221
|
+
) -> int:
|
|
222
|
+
"""Append the conversation the session's transcript holds; its size.
|
|
223
|
+
|
|
224
|
+
Only what was said since the step's attempt started is taken. The
|
|
225
|
+
task lock is not taken: the session that held the conversation is the
|
|
226
|
+
one ending, and the hook has seconds, not minutes. Any failure
|
|
227
|
+
recovers nothing, so the hook never fails over it.
|
|
228
|
+
"""
|
|
229
|
+
if payload.transcript_path is None:
|
|
230
|
+
return 0
|
|
231
|
+
try:
|
|
232
|
+
since = datetime.fromisoformat(
|
|
233
|
+
(task.started_at or task.updated_at).replace("Z", "+00:00")
|
|
234
|
+
)
|
|
235
|
+
entries = list(recover_conversation(agent.name, payload.transcript_path, since))
|
|
236
|
+
if entries and entries[-1][0] != "agent" and payload.last_agent_message:
|
|
237
|
+
entries.append(("agent", payload.last_agent_message.strip()))
|
|
238
|
+
InteractionLog(storage).append_entries(
|
|
239
|
+
task.task_id,
|
|
240
|
+
[(speaker + RECOVERED, text) for speaker, text in entries],
|
|
241
|
+
run_id=task.run_id,
|
|
242
|
+
step=task.item_name or task.label,
|
|
243
|
+
item_id=task.work_item_id,
|
|
244
|
+
at=at,
|
|
245
|
+
)
|
|
246
|
+
except Exception: # noqa: BLE001 - a hook must never fail over recovery
|
|
247
|
+
return 0
|
|
248
|
+
return len(entries)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _key(task: OpenTask) -> str:
|
|
252
|
+
assert task.item_id is not None
|
|
253
|
+
return reminder_key(task.run_id, task.item_id, task.attempt)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _payload(raw: str) -> dict[str, object]:
|
|
257
|
+
try:
|
|
258
|
+
value = json.loads(raw) if raw.strip() else {}
|
|
259
|
+
except json.JSONDecodeError:
|
|
260
|
+
return {}
|
|
261
|
+
return value if isinstance(value, dict) else {}
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def is_project_root(root: Path) -> bool:
|
|
265
|
+
"""Only a directory holding the repo workflow file is a ww root."""
|
|
266
|
+
return (root / WORKFLOWS_FILE).is_file()
|