weakincentives 0.2.0__py3-none-any.whl → 0.3.0__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.
Potentially problematic release.
This version of weakincentives might be problematic. Click here for more details.
- weakincentives/__init__.py +26 -2
- weakincentives/adapters/__init__.py +6 -5
- weakincentives/adapters/core.py +7 -17
- weakincentives/adapters/litellm.py +594 -0
- weakincentives/adapters/openai.py +286 -57
- weakincentives/events.py +103 -0
- weakincentives/examples/__init__.py +67 -0
- weakincentives/examples/code_review_prompt.py +118 -0
- weakincentives/examples/code_review_session.py +171 -0
- weakincentives/examples/code_review_tools.py +376 -0
- weakincentives/{prompts → prompt}/__init__.py +6 -8
- weakincentives/{prompts → prompt}/_types.py +1 -1
- weakincentives/{prompts/text.py → prompt/markdown.py} +19 -9
- weakincentives/{prompts → prompt}/prompt.py +216 -66
- weakincentives/{prompts → prompt}/response_format.py +9 -6
- weakincentives/{prompts → prompt}/section.py +25 -4
- weakincentives/{prompts/structured.py → prompt/structured_output.py} +16 -5
- weakincentives/{prompts → prompt}/tool.py +6 -6
- weakincentives/prompt/versioning.py +144 -0
- weakincentives/serde/__init__.py +0 -14
- weakincentives/serde/dataclass_serde.py +3 -17
- weakincentives/session/__init__.py +31 -0
- weakincentives/session/reducers.py +60 -0
- weakincentives/session/selectors.py +45 -0
- weakincentives/session/session.py +168 -0
- weakincentives/tools/__init__.py +69 -0
- weakincentives/tools/errors.py +22 -0
- weakincentives/tools/planning.py +538 -0
- weakincentives/tools/vfs.py +590 -0
- weakincentives-0.3.0.dist-info/METADATA +231 -0
- weakincentives-0.3.0.dist-info/RECORD +35 -0
- weakincentives-0.2.0.dist-info/METADATA +0 -173
- weakincentives-0.2.0.dist-info/RECORD +0 -20
- /weakincentives/{prompts → prompt}/errors.py +0 -0
- {weakincentives-0.2.0.dist-info → weakincentives-0.3.0.dist-info}/WHEEL +0 -0
- {weakincentives-0.2.0.dist-info → weakincentives-0.3.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
2
|
+
# you may not use this file except in compliance with the License.
|
|
3
|
+
# You may obtain a copy of the License at
|
|
4
|
+
#
|
|
5
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
6
|
+
#
|
|
7
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
8
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
9
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
10
|
+
# See the License for the specific language governing permissions and
|
|
11
|
+
# limitations under the License.
|
|
12
|
+
|
|
13
|
+
"""Planning tool suite for session-scoped execution plans."""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from collections.abc import Iterable, Sequence
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from typing import Final, Literal, cast
|
|
20
|
+
|
|
21
|
+
from ..prompt import SupportsDataclass
|
|
22
|
+
from ..prompt.markdown import MarkdownSection
|
|
23
|
+
from ..prompt.tool import Tool, ToolResult
|
|
24
|
+
from ..session import Session, replace_latest, select_latest
|
|
25
|
+
from ..session.session import DataEvent
|
|
26
|
+
from .errors import ToolValidationError
|
|
27
|
+
|
|
28
|
+
PlanStatus = Literal["active", "completed", "abandoned"]
|
|
29
|
+
StepStatus = Literal["pending", "in_progress", "blocked", "done"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(slots=True, frozen=True)
|
|
33
|
+
class PlanStep:
|
|
34
|
+
step_id: str
|
|
35
|
+
title: str
|
|
36
|
+
details: str | None
|
|
37
|
+
status: StepStatus
|
|
38
|
+
notes: tuple[str, ...] = field(default_factory=tuple)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(slots=True, frozen=True)
|
|
42
|
+
class Plan:
|
|
43
|
+
objective: str
|
|
44
|
+
status: PlanStatus
|
|
45
|
+
steps: tuple[PlanStep, ...] = field(default_factory=tuple)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(slots=True, frozen=True)
|
|
49
|
+
class NewPlanStep:
|
|
50
|
+
title: str
|
|
51
|
+
details: str | None = None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(slots=True, frozen=True)
|
|
55
|
+
class SetupPlan:
|
|
56
|
+
objective: str
|
|
57
|
+
initial_steps: tuple[NewPlanStep, ...] = field(default_factory=tuple)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(slots=True, frozen=True)
|
|
61
|
+
class AddStep:
|
|
62
|
+
steps: tuple[NewPlanStep, ...]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(slots=True, frozen=True)
|
|
66
|
+
class UpdateStep:
|
|
67
|
+
step_id: str
|
|
68
|
+
title: str | None = None
|
|
69
|
+
details: str | None = None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(slots=True, frozen=True)
|
|
73
|
+
class MarkStep:
|
|
74
|
+
step_id: str
|
|
75
|
+
status: StepStatus
|
|
76
|
+
note: str | None = None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(slots=True, frozen=True)
|
|
80
|
+
class ClearPlan:
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(slots=True, frozen=True)
|
|
85
|
+
class ReadPlan:
|
|
86
|
+
pass
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(slots=True, frozen=True)
|
|
90
|
+
class _PlanningSectionParams:
|
|
91
|
+
"""Placeholder params container for the planning tools section."""
|
|
92
|
+
|
|
93
|
+
pass
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
_ASCII: Final[str] = "ascii"
|
|
97
|
+
_MAX_OBJECTIVE_LENGTH: Final[int] = 240
|
|
98
|
+
_MAX_TITLE_LENGTH: Final[int] = 160
|
|
99
|
+
_MAX_DETAIL_LENGTH: Final[int] = 512
|
|
100
|
+
_STEP_ID_PREFIX: Final[str] = "S"
|
|
101
|
+
|
|
102
|
+
_PLANNING_SECTION_TEMPLATE: Final[str] = (
|
|
103
|
+
"Use planning tools for multi-step or stateful work that requires an"
|
|
104
|
+
" execution plan.\n"
|
|
105
|
+
"- Start with `planning_setup_plan` to set an objective (<=240 ASCII"
|
|
106
|
+
" characters) and optional initial steps.\n"
|
|
107
|
+
"- Keep steps concise (<=160 ASCII characters for titles, <=512 for"
|
|
108
|
+
" details).\n"
|
|
109
|
+
"- Extend plans with `planning_add_step` and refine steps with"
|
|
110
|
+
" `planning_update_step`.\n"
|
|
111
|
+
"- Track progress via `planning_mark_step` (pending, in_progress,"
|
|
112
|
+
" blocked, done).\n"
|
|
113
|
+
"- Inspect the latest plan using `planning_read_plan`.\n"
|
|
114
|
+
"- Use `planning_clear_plan` only when abandoning the objective.\n"
|
|
115
|
+
"Stay brief, ASCII-only, and skip planning for trivial single-step tasks."
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class PlanningToolsSection(MarkdownSection[_PlanningSectionParams]):
|
|
120
|
+
"""Prompt section exposing the planning tool suite."""
|
|
121
|
+
|
|
122
|
+
def __init__(self, *, session: Session) -> None:
|
|
123
|
+
self._session = session
|
|
124
|
+
session.register_reducer(Plan, replace_latest)
|
|
125
|
+
session.register_reducer(SetupPlan, _setup_plan_reducer, slice_type=Plan)
|
|
126
|
+
session.register_reducer(AddStep, _add_step_reducer, slice_type=Plan)
|
|
127
|
+
session.register_reducer(UpdateStep, _update_step_reducer, slice_type=Plan)
|
|
128
|
+
session.register_reducer(MarkStep, _mark_step_reducer, slice_type=Plan)
|
|
129
|
+
session.register_reducer(ClearPlan, _clear_plan_reducer, slice_type=Plan)
|
|
130
|
+
|
|
131
|
+
tools = _build_tools(session)
|
|
132
|
+
super().__init__(
|
|
133
|
+
title="Planning Tools",
|
|
134
|
+
key="planning.tools",
|
|
135
|
+
template=_PLANNING_SECTION_TEMPLATE,
|
|
136
|
+
default_params=_PlanningSectionParams(),
|
|
137
|
+
tools=tools,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _build_tools(
|
|
142
|
+
session: Session,
|
|
143
|
+
) -> tuple[Tool[SupportsDataclass, SupportsDataclass], ...]:
|
|
144
|
+
suite = _PlanningToolSuite(session)
|
|
145
|
+
return (
|
|
146
|
+
Tool[SetupPlan, SetupPlan](
|
|
147
|
+
name="planning_setup_plan",
|
|
148
|
+
description="Create or replace the session plan.",
|
|
149
|
+
handler=suite.setup_plan,
|
|
150
|
+
),
|
|
151
|
+
Tool[AddStep, AddStep](
|
|
152
|
+
name="planning_add_step",
|
|
153
|
+
description="Append one or more steps to the active plan.",
|
|
154
|
+
handler=suite.add_step,
|
|
155
|
+
),
|
|
156
|
+
Tool[UpdateStep, UpdateStep](
|
|
157
|
+
name="planning_update_step",
|
|
158
|
+
description="Edit the title or details for an existing step.",
|
|
159
|
+
handler=suite.update_step,
|
|
160
|
+
),
|
|
161
|
+
Tool[MarkStep, MarkStep](
|
|
162
|
+
name="planning_mark_step",
|
|
163
|
+
description="Update step status and optionally record a note.",
|
|
164
|
+
handler=suite.mark_step,
|
|
165
|
+
),
|
|
166
|
+
Tool[ClearPlan, ClearPlan](
|
|
167
|
+
name="planning_clear_plan",
|
|
168
|
+
description="Mark the current plan as abandoned.",
|
|
169
|
+
handler=suite.clear_plan,
|
|
170
|
+
),
|
|
171
|
+
Tool[ReadPlan, Plan](
|
|
172
|
+
name="planning_read_plan",
|
|
173
|
+
description="Return the latest plan snapshot.",
|
|
174
|
+
handler=suite.read_plan,
|
|
175
|
+
),
|
|
176
|
+
) # type: ignore[return-value]
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class _PlanningToolSuite:
|
|
180
|
+
"""Collection of tool handlers bound to a session instance."""
|
|
181
|
+
|
|
182
|
+
def __init__(self, session: Session) -> None:
|
|
183
|
+
self._session = session
|
|
184
|
+
|
|
185
|
+
def setup_plan(self, params: SetupPlan) -> ToolResult[SetupPlan]:
|
|
186
|
+
objective = _normalize_required_text(
|
|
187
|
+
params.objective,
|
|
188
|
+
field_name="objective",
|
|
189
|
+
max_length=_MAX_OBJECTIVE_LENGTH,
|
|
190
|
+
)
|
|
191
|
+
initial_steps = _normalize_new_steps(params.initial_steps)
|
|
192
|
+
normalized = SetupPlan(objective=objective, initial_steps=initial_steps)
|
|
193
|
+
step_count = len(initial_steps)
|
|
194
|
+
message = (
|
|
195
|
+
f"Plan initialised with {step_count} step{'s' if step_count != 1 else ''}."
|
|
196
|
+
)
|
|
197
|
+
return ToolResult(message=message, value=normalized)
|
|
198
|
+
|
|
199
|
+
def add_step(self, params: AddStep) -> ToolResult[AddStep]:
|
|
200
|
+
plan = _require_plan(self._session)
|
|
201
|
+
_ensure_active(plan, "add steps to")
|
|
202
|
+
normalized_steps = _normalize_new_steps(params.steps)
|
|
203
|
+
if not normalized_steps:
|
|
204
|
+
raise ToolValidationError("Provide at least one step to add.")
|
|
205
|
+
message = (
|
|
206
|
+
"Queued"
|
|
207
|
+
f" {len(normalized_steps)} step{'s' if len(normalized_steps) != 1 else ''}"
|
|
208
|
+
" for addition."
|
|
209
|
+
)
|
|
210
|
+
return ToolResult(message=message, value=AddStep(steps=normalized_steps))
|
|
211
|
+
|
|
212
|
+
def update_step(self, params: UpdateStep) -> ToolResult[UpdateStep]:
|
|
213
|
+
plan = _require_plan(self._session)
|
|
214
|
+
_ensure_active(plan, "update steps in")
|
|
215
|
+
step_id = params.step_id.strip()
|
|
216
|
+
if not step_id:
|
|
217
|
+
raise ToolValidationError("Step ID must be provided.")
|
|
218
|
+
_ensure_ascii(step_id, "step_id")
|
|
219
|
+
updated_title = (
|
|
220
|
+
_normalize_required_text(
|
|
221
|
+
params.title,
|
|
222
|
+
field_name="title",
|
|
223
|
+
max_length=_MAX_TITLE_LENGTH,
|
|
224
|
+
)
|
|
225
|
+
if params.title is not None
|
|
226
|
+
else None
|
|
227
|
+
)
|
|
228
|
+
updated_details = (
|
|
229
|
+
_normalize_optional_text(
|
|
230
|
+
params.details,
|
|
231
|
+
field_name="details",
|
|
232
|
+
max_length=_MAX_DETAIL_LENGTH,
|
|
233
|
+
)
|
|
234
|
+
if params.details is not None
|
|
235
|
+
else None
|
|
236
|
+
)
|
|
237
|
+
if updated_title is None and updated_details is None:
|
|
238
|
+
raise ToolValidationError(
|
|
239
|
+
"Provide a new title or details to update a step."
|
|
240
|
+
)
|
|
241
|
+
_ensure_step_exists(plan, step_id)
|
|
242
|
+
normalized = UpdateStep(
|
|
243
|
+
step_id=step_id,
|
|
244
|
+
title=updated_title,
|
|
245
|
+
details=updated_details,
|
|
246
|
+
)
|
|
247
|
+
return ToolResult(message=f"Step {step_id} update queued.", value=normalized)
|
|
248
|
+
|
|
249
|
+
def mark_step(self, params: MarkStep) -> ToolResult[MarkStep]:
|
|
250
|
+
plan = _require_plan(self._session)
|
|
251
|
+
if plan.status == "abandoned":
|
|
252
|
+
raise ToolValidationError("Cannot mark steps on an abandoned plan.")
|
|
253
|
+
step_id = params.step_id.strip()
|
|
254
|
+
if not step_id:
|
|
255
|
+
raise ToolValidationError("Step ID must be provided.")
|
|
256
|
+
_ensure_ascii(step_id, "step_id")
|
|
257
|
+
_ensure_step_exists(plan, step_id)
|
|
258
|
+
note = _normalize_optional_text(
|
|
259
|
+
params.note,
|
|
260
|
+
field_name="note",
|
|
261
|
+
max_length=_MAX_DETAIL_LENGTH,
|
|
262
|
+
require_content=True,
|
|
263
|
+
)
|
|
264
|
+
normalized = MarkStep(step_id=step_id, status=params.status, note=note)
|
|
265
|
+
return ToolResult(
|
|
266
|
+
message=f"Step {step_id} marked as {params.status}.",
|
|
267
|
+
value=normalized,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
def clear_plan(self, params: ClearPlan) -> ToolResult[ClearPlan]:
|
|
271
|
+
plan = _require_plan(self._session)
|
|
272
|
+
if plan.status == "abandoned":
|
|
273
|
+
raise ToolValidationError("Plan already abandoned.")
|
|
274
|
+
return ToolResult(message="Plan marked as abandoned.", value=params)
|
|
275
|
+
|
|
276
|
+
def read_plan(self, params: ReadPlan) -> ToolResult[Plan]:
|
|
277
|
+
del params
|
|
278
|
+
plan = select_latest(self._session, Plan)
|
|
279
|
+
if plan is None:
|
|
280
|
+
raise ToolValidationError("No plan is currently initialised.")
|
|
281
|
+
step_summary = _summarize_steps(plan.steps)
|
|
282
|
+
message = (
|
|
283
|
+
f"Objective: {plan.objective}\nStatus: {plan.status}\nSteps: {step_summary}"
|
|
284
|
+
)
|
|
285
|
+
return ToolResult(message=message, value=plan)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _setup_plan_reducer(
|
|
289
|
+
slice_values: tuple[Plan, ...], event: DataEvent
|
|
290
|
+
) -> tuple[Plan, ...]:
|
|
291
|
+
params = cast(SetupPlan, event.value)
|
|
292
|
+
steps = tuple(
|
|
293
|
+
PlanStep(
|
|
294
|
+
step_id=_format_step_id(index + 1),
|
|
295
|
+
title=step.title,
|
|
296
|
+
details=step.details,
|
|
297
|
+
status="pending",
|
|
298
|
+
notes=(),
|
|
299
|
+
)
|
|
300
|
+
for index, step in enumerate(params.initial_steps)
|
|
301
|
+
)
|
|
302
|
+
plan = Plan(objective=params.objective, status="active", steps=steps)
|
|
303
|
+
return (plan,)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _add_step_reducer(
|
|
307
|
+
slice_values: tuple[Plan, ...], event: DataEvent
|
|
308
|
+
) -> tuple[Plan, ...]:
|
|
309
|
+
previous = _latest_plan(slice_values)
|
|
310
|
+
if previous is None:
|
|
311
|
+
return slice_values
|
|
312
|
+
params = cast(AddStep, event.value)
|
|
313
|
+
existing = list(previous.steps)
|
|
314
|
+
next_index = _next_step_index(existing)
|
|
315
|
+
for step in params.steps:
|
|
316
|
+
next_index += 1
|
|
317
|
+
existing.append(
|
|
318
|
+
PlanStep(
|
|
319
|
+
step_id=_format_step_id(next_index),
|
|
320
|
+
title=step.title,
|
|
321
|
+
details=step.details,
|
|
322
|
+
status="pending",
|
|
323
|
+
notes=(),
|
|
324
|
+
)
|
|
325
|
+
)
|
|
326
|
+
updated = Plan(
|
|
327
|
+
objective=previous.objective,
|
|
328
|
+
status="active",
|
|
329
|
+
steps=tuple(existing),
|
|
330
|
+
)
|
|
331
|
+
return (updated,)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _update_step_reducer(
|
|
335
|
+
slice_values: tuple[Plan, ...], event: DataEvent
|
|
336
|
+
) -> tuple[Plan, ...]:
|
|
337
|
+
previous = _latest_plan(slice_values)
|
|
338
|
+
if previous is None:
|
|
339
|
+
return slice_values
|
|
340
|
+
params = cast(UpdateStep, event.value)
|
|
341
|
+
updated_steps: list[PlanStep] = []
|
|
342
|
+
for step in previous.steps:
|
|
343
|
+
if step.step_id != params.step_id:
|
|
344
|
+
updated_steps.append(step)
|
|
345
|
+
continue
|
|
346
|
+
new_title = params.title if params.title is not None else step.title
|
|
347
|
+
new_details = params.details if params.details is not None else step.details
|
|
348
|
+
updated_steps.append(
|
|
349
|
+
PlanStep(
|
|
350
|
+
step_id=step.step_id,
|
|
351
|
+
title=new_title,
|
|
352
|
+
details=new_details,
|
|
353
|
+
status=step.status,
|
|
354
|
+
notes=step.notes,
|
|
355
|
+
)
|
|
356
|
+
)
|
|
357
|
+
updated_plan = Plan(
|
|
358
|
+
objective=previous.objective,
|
|
359
|
+
status=previous.status,
|
|
360
|
+
steps=tuple(updated_steps),
|
|
361
|
+
)
|
|
362
|
+
return (updated_plan,)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _mark_step_reducer(
|
|
366
|
+
slice_values: tuple[Plan, ...], event: DataEvent
|
|
367
|
+
) -> tuple[Plan, ...]:
|
|
368
|
+
previous = _latest_plan(slice_values)
|
|
369
|
+
if previous is None:
|
|
370
|
+
return slice_values
|
|
371
|
+
params = cast(MarkStep, event.value)
|
|
372
|
+
updated_steps: list[PlanStep] = []
|
|
373
|
+
for step in previous.steps:
|
|
374
|
+
if step.step_id != params.step_id:
|
|
375
|
+
updated_steps.append(step)
|
|
376
|
+
continue
|
|
377
|
+
notes = step.notes
|
|
378
|
+
if params.note is not None:
|
|
379
|
+
notes = (*notes, params.note)
|
|
380
|
+
updated_steps.append(
|
|
381
|
+
PlanStep(
|
|
382
|
+
step_id=step.step_id,
|
|
383
|
+
title=step.title,
|
|
384
|
+
details=step.details,
|
|
385
|
+
status=params.status,
|
|
386
|
+
notes=notes,
|
|
387
|
+
)
|
|
388
|
+
)
|
|
389
|
+
plan_status: PlanStatus
|
|
390
|
+
if not updated_steps or all(step.status == "done" for step in updated_steps):
|
|
391
|
+
plan_status = "completed"
|
|
392
|
+
else:
|
|
393
|
+
plan_status = "active"
|
|
394
|
+
updated_plan = Plan(
|
|
395
|
+
objective=previous.objective,
|
|
396
|
+
status=plan_status,
|
|
397
|
+
steps=tuple(updated_steps),
|
|
398
|
+
)
|
|
399
|
+
return (updated_plan,)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _clear_plan_reducer(
|
|
403
|
+
slice_values: tuple[Plan, ...], event: DataEvent
|
|
404
|
+
) -> tuple[Plan, ...]:
|
|
405
|
+
previous = _latest_plan(slice_values)
|
|
406
|
+
if previous is None:
|
|
407
|
+
return slice_values
|
|
408
|
+
del event
|
|
409
|
+
abandoned = Plan(objective=previous.objective, status="abandoned", steps=())
|
|
410
|
+
return (abandoned,)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _latest_plan(plans: tuple[Plan, ...]) -> Plan | None:
|
|
414
|
+
if not plans:
|
|
415
|
+
return None
|
|
416
|
+
return plans[-1]
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _next_step_index(steps: Iterable[PlanStep]) -> int:
|
|
420
|
+
max_index = 0
|
|
421
|
+
for step in steps:
|
|
422
|
+
suffix = step.step_id[len(_STEP_ID_PREFIX) :]
|
|
423
|
+
try:
|
|
424
|
+
max_index = max(max_index, int(suffix))
|
|
425
|
+
except ValueError:
|
|
426
|
+
continue
|
|
427
|
+
return max_index
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _format_step_id(index: int) -> str:
|
|
431
|
+
return f"{_STEP_ID_PREFIX}{index:03d}"
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _normalize_new_steps(steps: Sequence[NewPlanStep]) -> tuple[NewPlanStep, ...]:
|
|
435
|
+
normalized: list[NewPlanStep] = []
|
|
436
|
+
for step in steps:
|
|
437
|
+
title = _normalize_required_text(
|
|
438
|
+
step.title,
|
|
439
|
+
field_name="title",
|
|
440
|
+
max_length=_MAX_TITLE_LENGTH,
|
|
441
|
+
)
|
|
442
|
+
details = _normalize_optional_text(
|
|
443
|
+
step.details,
|
|
444
|
+
field_name="details",
|
|
445
|
+
max_length=_MAX_DETAIL_LENGTH,
|
|
446
|
+
)
|
|
447
|
+
normalized.append(NewPlanStep(title=title, details=details))
|
|
448
|
+
return tuple(normalized)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _normalize_required_text(
|
|
452
|
+
value: str,
|
|
453
|
+
*,
|
|
454
|
+
field_name: str,
|
|
455
|
+
max_length: int,
|
|
456
|
+
) -> str:
|
|
457
|
+
stripped = value.strip()
|
|
458
|
+
if not stripped:
|
|
459
|
+
raise ToolValidationError(f"{field_name.title()} must not be empty.")
|
|
460
|
+
if len(stripped) > max_length:
|
|
461
|
+
raise ToolValidationError(
|
|
462
|
+
f"{field_name.title()} must be <= {max_length} characters."
|
|
463
|
+
)
|
|
464
|
+
_ensure_ascii(stripped, field_name)
|
|
465
|
+
return stripped
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _normalize_optional_text(
|
|
469
|
+
value: str | None,
|
|
470
|
+
*,
|
|
471
|
+
field_name: str,
|
|
472
|
+
max_length: int,
|
|
473
|
+
require_content: bool = False,
|
|
474
|
+
) -> str | None:
|
|
475
|
+
if value is None:
|
|
476
|
+
return None
|
|
477
|
+
stripped = value.strip()
|
|
478
|
+
if not stripped:
|
|
479
|
+
if require_content:
|
|
480
|
+
raise ToolValidationError(
|
|
481
|
+
f"{field_name.title()} must not be empty when provided."
|
|
482
|
+
)
|
|
483
|
+
return None
|
|
484
|
+
if len(stripped) > max_length:
|
|
485
|
+
raise ToolValidationError(
|
|
486
|
+
f"{field_name.title()} must be <= {max_length} characters."
|
|
487
|
+
)
|
|
488
|
+
_ensure_ascii(stripped, field_name)
|
|
489
|
+
return stripped
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _ensure_ascii(value: str, field_name: str) -> None:
|
|
493
|
+
try:
|
|
494
|
+
value.encode(_ASCII)
|
|
495
|
+
except UnicodeEncodeError as error: # pragma: no cover - defensive
|
|
496
|
+
raise ToolValidationError(f"{field_name.title()} must be ASCII.") from error
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _require_plan(session: Session) -> Plan:
|
|
500
|
+
plan = select_latest(session, Plan)
|
|
501
|
+
if plan is None:
|
|
502
|
+
raise ToolValidationError("No plan is currently initialised.")
|
|
503
|
+
return plan
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _ensure_active(plan: Plan, action: str) -> None:
|
|
507
|
+
if plan.status != "active":
|
|
508
|
+
raise ToolValidationError(
|
|
509
|
+
f"Plan must be active to {action}. Current status: {plan.status}."
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _ensure_step_exists(plan: Plan, step_id: str) -> None:
|
|
514
|
+
if not any(step.step_id == step_id for step in plan.steps):
|
|
515
|
+
raise ToolValidationError(f"Step {step_id} does not exist.")
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _summarize_steps(steps: Sequence[PlanStep]) -> str:
|
|
519
|
+
if not steps:
|
|
520
|
+
return "no steps recorded"
|
|
521
|
+
summaries = [f"{step.step_id}:{step.status}" for step in steps]
|
|
522
|
+
return ", ".join(summaries)
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
__all__ = [
|
|
526
|
+
"Plan",
|
|
527
|
+
"PlanStatus",
|
|
528
|
+
"PlanStep",
|
|
529
|
+
"StepStatus",
|
|
530
|
+
"NewPlanStep",
|
|
531
|
+
"SetupPlan",
|
|
532
|
+
"AddStep",
|
|
533
|
+
"UpdateStep",
|
|
534
|
+
"MarkStep",
|
|
535
|
+
"ClearPlan",
|
|
536
|
+
"ReadPlan",
|
|
537
|
+
"PlanningToolsSection",
|
|
538
|
+
]
|