weakincentives 0.9.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.
Files changed (73) hide show
  1. weakincentives/__init__.py +67 -0
  2. weakincentives/adapters/__init__.py +37 -0
  3. weakincentives/adapters/_names.py +32 -0
  4. weakincentives/adapters/_provider_protocols.py +69 -0
  5. weakincentives/adapters/_tool_messages.py +80 -0
  6. weakincentives/adapters/core.py +102 -0
  7. weakincentives/adapters/litellm.py +254 -0
  8. weakincentives/adapters/openai.py +254 -0
  9. weakincentives/adapters/shared.py +1021 -0
  10. weakincentives/cli/__init__.py +23 -0
  11. weakincentives/cli/wink.py +58 -0
  12. weakincentives/dbc/__init__.py +412 -0
  13. weakincentives/deadlines.py +58 -0
  14. weakincentives/prompt/__init__.py +105 -0
  15. weakincentives/prompt/_generic_params_specializer.py +64 -0
  16. weakincentives/prompt/_normalization.py +48 -0
  17. weakincentives/prompt/_overrides_protocols.py +33 -0
  18. weakincentives/prompt/_types.py +34 -0
  19. weakincentives/prompt/chapter.py +146 -0
  20. weakincentives/prompt/composition.py +281 -0
  21. weakincentives/prompt/errors.py +57 -0
  22. weakincentives/prompt/markdown.py +108 -0
  23. weakincentives/prompt/overrides/__init__.py +59 -0
  24. weakincentives/prompt/overrides/_fs.py +164 -0
  25. weakincentives/prompt/overrides/inspection.py +141 -0
  26. weakincentives/prompt/overrides/local_store.py +275 -0
  27. weakincentives/prompt/overrides/validation.py +534 -0
  28. weakincentives/prompt/overrides/versioning.py +269 -0
  29. weakincentives/prompt/prompt.py +353 -0
  30. weakincentives/prompt/protocols.py +103 -0
  31. weakincentives/prompt/registry.py +375 -0
  32. weakincentives/prompt/rendering.py +288 -0
  33. weakincentives/prompt/response_format.py +60 -0
  34. weakincentives/prompt/section.py +166 -0
  35. weakincentives/prompt/structured_output.py +179 -0
  36. weakincentives/prompt/tool.py +397 -0
  37. weakincentives/prompt/tool_result.py +30 -0
  38. weakincentives/py.typed +0 -0
  39. weakincentives/runtime/__init__.py +82 -0
  40. weakincentives/runtime/events/__init__.py +126 -0
  41. weakincentives/runtime/events/_types.py +110 -0
  42. weakincentives/runtime/logging.py +284 -0
  43. weakincentives/runtime/session/__init__.py +46 -0
  44. weakincentives/runtime/session/_slice_types.py +24 -0
  45. weakincentives/runtime/session/_types.py +55 -0
  46. weakincentives/runtime/session/dataclasses.py +29 -0
  47. weakincentives/runtime/session/protocols.py +34 -0
  48. weakincentives/runtime/session/reducer_context.py +40 -0
  49. weakincentives/runtime/session/reducers.py +82 -0
  50. weakincentives/runtime/session/selectors.py +56 -0
  51. weakincentives/runtime/session/session.py +387 -0
  52. weakincentives/runtime/session/snapshots.py +310 -0
  53. weakincentives/serde/__init__.py +19 -0
  54. weakincentives/serde/_utils.py +240 -0
  55. weakincentives/serde/dataclass_serde.py +55 -0
  56. weakincentives/serde/dump.py +189 -0
  57. weakincentives/serde/parse.py +417 -0
  58. weakincentives/serde/schema.py +260 -0
  59. weakincentives/tools/__init__.py +154 -0
  60. weakincentives/tools/_context.py +38 -0
  61. weakincentives/tools/asteval.py +853 -0
  62. weakincentives/tools/errors.py +26 -0
  63. weakincentives/tools/planning.py +831 -0
  64. weakincentives/tools/podman.py +1655 -0
  65. weakincentives/tools/subagents.py +346 -0
  66. weakincentives/tools/vfs.py +1390 -0
  67. weakincentives/types/__init__.py +35 -0
  68. weakincentives/types/json.py +45 -0
  69. weakincentives-0.9.0.dist-info/METADATA +775 -0
  70. weakincentives-0.9.0.dist-info/RECORD +73 -0
  71. weakincentives-0.9.0.dist-info/WHEEL +4 -0
  72. weakincentives-0.9.0.dist-info/entry_points.txt +2 -0
  73. weakincentives-0.9.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,831 @@
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 enum import Enum
20
+ from typing import Final, Literal, cast, override
21
+
22
+ from ..prompt import SupportsDataclass, SupportsToolResult
23
+ from ..prompt.errors import PromptRenderError
24
+ from ..prompt.markdown import MarkdownSection
25
+ from ..prompt.tool import Tool, ToolContext, ToolResult
26
+ from ..runtime.session import (
27
+ ReducerContextProtocol,
28
+ ReducerEvent,
29
+ Session,
30
+ replace_latest,
31
+ select_latest,
32
+ )
33
+ from ._context import ensure_context_uses_session
34
+ from .errors import ToolValidationError
35
+
36
+ PlanStatus = Literal["active", "completed", "abandoned"]
37
+ StepStatus = Literal["pending", "in_progress", "blocked", "done"]
38
+
39
+
40
+ @dataclass(slots=True, frozen=True)
41
+ class PlanStep:
42
+ """Single actionable step tracked within a plan."""
43
+
44
+ step_id: str = field(
45
+ metadata={
46
+ "description": (
47
+ "Stable identifier generated by the planner. Use it when updating "
48
+ "or marking the step."
49
+ )
50
+ }
51
+ )
52
+ title: str = field(
53
+ metadata={
54
+ "description": (
55
+ "Concise summary of the work item (<=160 ASCII characters)."
56
+ )
57
+ }
58
+ )
59
+ details: str | None = field(
60
+ metadata={
61
+ "description": (
62
+ "Optional clarification for the step, limited to 512 ASCII characters."
63
+ )
64
+ }
65
+ )
66
+ status: StepStatus = field(
67
+ metadata={
68
+ "description": (
69
+ "Current progress state for the step: pending, in_progress, "
70
+ "blocked, or done."
71
+ )
72
+ }
73
+ )
74
+ notes: tuple[str, ...] = field(
75
+ default_factory=tuple,
76
+ metadata={
77
+ "description": (
78
+ "Chronological log of short reflections recorded via mark operations."
79
+ )
80
+ },
81
+ )
82
+
83
+
84
+ @dataclass(slots=True, frozen=True)
85
+ class Plan:
86
+ """Immutable snapshot of the active plan."""
87
+
88
+ objective: str = field(
89
+ metadata={
90
+ "description": (
91
+ "Single-sentence objective for the session (<=240 ASCII characters)."
92
+ )
93
+ }
94
+ )
95
+ status: PlanStatus = field(
96
+ metadata={
97
+ "description": "Lifecycle state of the plan: active, completed, or abandoned."
98
+ }
99
+ )
100
+ steps: tuple[PlanStep, ...] = field(
101
+ default_factory=tuple,
102
+ metadata={
103
+ "description": (
104
+ "Ordered collection of plan steps. Each entry is immutable to "
105
+ "simplify diffing and change tracking."
106
+ )
107
+ },
108
+ )
109
+
110
+
111
+ @dataclass(slots=True, frozen=True)
112
+ class NewPlanStep:
113
+ """User-supplied proposal for a new plan step."""
114
+
115
+ title: str = field(
116
+ metadata={
117
+ "description": ("Concise label for the new step (<=160 ASCII characters).")
118
+ }
119
+ )
120
+ details: str | None = field(
121
+ default=None,
122
+ metadata={
123
+ "description": (
124
+ "Optional context for the step, up to 512 ASCII characters."
125
+ )
126
+ },
127
+ )
128
+
129
+
130
+ @dataclass(slots=True, frozen=True)
131
+ class SetupPlan:
132
+ """Initialise or replace the session plan."""
133
+
134
+ objective: str = field(
135
+ metadata={
136
+ "description": (
137
+ "Objective the plan should accomplish (<=240 ASCII characters)."
138
+ )
139
+ }
140
+ )
141
+ initial_steps: tuple[NewPlanStep, ...] = field(
142
+ default_factory=tuple,
143
+ metadata={
144
+ "description": ("Optional ordered sequence of steps to seed the plan with.")
145
+ },
146
+ )
147
+
148
+
149
+ @dataclass(slots=True, frozen=True)
150
+ class AddStep:
151
+ """Append new steps to the current plan."""
152
+
153
+ steps: tuple[NewPlanStep, ...] = field(
154
+ metadata={
155
+ "description": (
156
+ "One or more proposed steps to queue. Each step is normalised "
157
+ "before being added."
158
+ )
159
+ }
160
+ )
161
+
162
+
163
+ @dataclass(slots=True, frozen=True)
164
+ class UpdateStep:
165
+ """Modify a step's title or details while keeping its identifier stable."""
166
+
167
+ step_id: str = field(
168
+ metadata={
169
+ "description": (
170
+ "Identifier of the target step, as reported in plan snapshots "
171
+ "and tool responses."
172
+ )
173
+ }
174
+ )
175
+ title: str | None = field(
176
+ default=None,
177
+ metadata={
178
+ "description": (
179
+ "Replacement title for the step. Omit or set to null to leave "
180
+ "the title unchanged."
181
+ )
182
+ },
183
+ )
184
+ details: str | None = field(
185
+ default=None,
186
+ metadata={
187
+ "description": (
188
+ "Replacement details for the step. Omit or set to null to keep "
189
+ "the existing details."
190
+ )
191
+ },
192
+ )
193
+
194
+
195
+ @dataclass(slots=True, frozen=True)
196
+ class MarkStep:
197
+ """Update the status of a step and optionally record a note."""
198
+
199
+ step_id: str = field(
200
+ metadata={
201
+ "description": (
202
+ "Identifier of the step to mark, matching the value from plan "
203
+ "snapshots."
204
+ )
205
+ }
206
+ )
207
+ status: StepStatus = field(
208
+ metadata={
209
+ "description": (
210
+ "New status for the step: pending, in_progress, blocked, or done."
211
+ )
212
+ }
213
+ )
214
+ note: str | None = field(
215
+ default=None,
216
+ metadata={
217
+ "description": (
218
+ "Optional brief reflection or context note (<=512 ASCII characters)."
219
+ )
220
+ },
221
+ )
222
+
223
+
224
+ @dataclass(slots=True, frozen=True)
225
+ class ClearPlan:
226
+ """Mark the current plan as abandoned while retaining its objective."""
227
+
228
+ pass
229
+
230
+
231
+ @dataclass(slots=True, frozen=True)
232
+ class ReadPlan:
233
+ """Request the most recent plan snapshot from the session store."""
234
+
235
+ pass
236
+
237
+
238
+ @dataclass(slots=True, frozen=True)
239
+ class _PlanningSectionParams:
240
+ """Placeholder params container for the planning tools section."""
241
+
242
+ pass
243
+
244
+
245
+ _ASCII: Final[str] = "ascii"
246
+ _MAX_OBJECTIVE_LENGTH: Final[int] = 240
247
+ _MAX_TITLE_LENGTH: Final[int] = 160
248
+ _MAX_DETAIL_LENGTH: Final[int] = 512
249
+ _STEP_ID_PREFIX: Final[str] = "S"
250
+
251
+
252
+ class PlanningStrategy(Enum):
253
+ """Predefined guidance templates for the planning section."""
254
+
255
+ REACT = "react"
256
+ PLAN_ACT_REFLECT = "plan_act_reflect"
257
+ GOAL_DECOMPOSE_ROUTE_SYNTHESISE = "goal_decompose_route_synthesise"
258
+
259
+
260
+ _PLANNING_SECTION_HEADER: Final[str] = (
261
+ "Use planning tools for multi-step or stateful work that requires an"
262
+ " execution plan.\n"
263
+ )
264
+ _PLANNING_SECTION_BODY: Final[str] = (
265
+ "- Start with `planning_setup_plan` to set an objective (<=240 ASCII"
266
+ " characters) and optional initial steps.\n"
267
+ "- Step identifiers use the `S###` format and stay stable even if"
268
+ " other steps are removed—reference them for every update.\n"
269
+ "- Keep steps concise (<=160 ASCII characters for titles, <=512 for"
270
+ " details).\n"
271
+ "- Extend plans with `planning_add_step` and refine steps with"
272
+ " `planning_update_step`.\n"
273
+ "- Track progress via `planning_mark_step` (pending, in_progress,\n"
274
+ " blocked, done).\n"
275
+ "- Inspect the latest plan using `planning_read_plan`.\n"
276
+ "- Use `planning_clear_plan` only when abandoning the objective.\n"
277
+ "Stay brief, ASCII-only, and skip planning for trivial single-step tasks."
278
+ )
279
+
280
+
281
+ def _template_for_strategy(strategy: PlanningStrategy) -> str:
282
+ guidance_map: dict[PlanningStrategy, str] = {
283
+ PlanningStrategy.REACT: "",
284
+ PlanningStrategy.PLAN_ACT_REFLECT: (
285
+ "Follow a plan-act-reflect rhythm: outline the entire plan before"
286
+ " executing any tools, then work through the steps.\n"
287
+ "After each tool call or completed step, append a brief reflection"
288
+ " as plan notes or status updates so progress stays visible.\n"
289
+ ),
290
+ PlanningStrategy.GOAL_DECOMPOSE_ROUTE_SYNTHESISE: (
291
+ "Begin by restating the goal in your own words to ensure the"
292
+ " objective is clear.\n"
293
+ "Break the goal into concrete sub-problems before routing tools to"
294
+ " each one, and record the routing in the plan steps.\n"
295
+ "When every tool has run, synthesise the results into a cohesive"
296
+ " answer and update the plan status as part of that synthesis.\n"
297
+ ),
298
+ }
299
+ guidance = guidance_map[strategy]
300
+ if not guidance:
301
+ return f"{_PLANNING_SECTION_HEADER}{_PLANNING_SECTION_BODY}"
302
+ return f"{_PLANNING_SECTION_HEADER}{guidance}{_PLANNING_SECTION_BODY}"
303
+
304
+
305
+ class PlanningToolsSection(MarkdownSection[_PlanningSectionParams]):
306
+ """Prompt section exposing the planning tool suite."""
307
+
308
+ def __init__(
309
+ self,
310
+ *,
311
+ session: Session,
312
+ strategy: PlanningStrategy = PlanningStrategy.REACT,
313
+ accepts_overrides: bool = False,
314
+ ) -> None:
315
+ self._strategy = strategy
316
+ self._session = session
317
+ self._initialize_session(session)
318
+
319
+ tools = _build_tools(section=self, accepts_overrides=accepts_overrides)
320
+ super().__init__(
321
+ title="Planning Tools",
322
+ key="planning.tools",
323
+ template=_template_for_strategy(strategy),
324
+ default_params=_PlanningSectionParams(),
325
+ tools=tools,
326
+ accepts_overrides=accepts_overrides,
327
+ )
328
+
329
+ @property
330
+ def session(self) -> Session:
331
+ return self._session
332
+
333
+ def _initialize_session(self, session: Session) -> None:
334
+ session.register_reducer(Plan, replace_latest)
335
+ session.register_reducer(SetupPlan, _setup_plan_reducer, slice_type=Plan)
336
+ session.register_reducer(AddStep, _add_step_reducer, slice_type=Plan)
337
+ session.register_reducer(UpdateStep, _update_step_reducer, slice_type=Plan)
338
+ session.register_reducer(MarkStep, _mark_step_reducer, slice_type=Plan)
339
+ session.register_reducer(ClearPlan, _clear_plan_reducer, slice_type=Plan)
340
+
341
+ @override
342
+ def render(self, params: SupportsDataclass | None, depth: int) -> str:
343
+ if not isinstance(params, _PlanningSectionParams):
344
+ raise PromptRenderError(
345
+ "Planning tools section requires parameters.",
346
+ dataclass_type=_PlanningSectionParams,
347
+ )
348
+ template = _template_for_strategy(self._strategy)
349
+ return self.render_with_template(template, params, depth)
350
+
351
+ @override
352
+ def original_body_template(self) -> str:
353
+ return _template_for_strategy(self._strategy)
354
+
355
+
356
+ def _build_tools(
357
+ *,
358
+ section: PlanningToolsSection,
359
+ accepts_overrides: bool,
360
+ ) -> tuple[Tool[SupportsDataclass, SupportsToolResult], ...]:
361
+ suite = _PlanningToolSuite(section=section)
362
+ return cast(
363
+ tuple[Tool[SupportsDataclass, SupportsToolResult], ...],
364
+ (
365
+ Tool[SetupPlan, SetupPlan](
366
+ name="planning_setup_plan",
367
+ description=(
368
+ "Create or replace the session plan with a short objective and "
369
+ "optional seed steps; identifiers restart at `S001` for the new"
370
+ " snapshot."
371
+ ),
372
+ handler=suite.setup_plan,
373
+ accepts_overrides=accepts_overrides,
374
+ ),
375
+ Tool[AddStep, AddStep](
376
+ name="planning_add_step",
377
+ description=(
378
+ "Append one or more queued steps to the active plan. Provide "
379
+ "step titles and optional details; new steps receive the next "
380
+ "`S###` identifier regardless of prior deletions."
381
+ ),
382
+ handler=suite.add_step,
383
+ accepts_overrides=accepts_overrides,
384
+ ),
385
+ Tool[UpdateStep, UpdateStep](
386
+ name="planning_update_step",
387
+ description=(
388
+ "Edit the title or details for an existing step using its "
389
+ "stable step identifier."
390
+ ),
391
+ handler=suite.update_step,
392
+ accepts_overrides=accepts_overrides,
393
+ ),
394
+ Tool[MarkStep, MarkStep](
395
+ name="planning_mark_step",
396
+ description=(
397
+ "Update a step's status and optionally append a short note to "
398
+ "its history."
399
+ ),
400
+ handler=suite.mark_step,
401
+ accepts_overrides=accepts_overrides,
402
+ ),
403
+ Tool[ClearPlan, ClearPlan](
404
+ name="planning_clear_plan",
405
+ description=(
406
+ "Mark the current plan as abandoned without deleting its objective."
407
+ ),
408
+ handler=suite.clear_plan,
409
+ accepts_overrides=accepts_overrides,
410
+ ),
411
+ Tool[ReadPlan, Plan](
412
+ name="planning_read_plan",
413
+ description=(
414
+ "Return the latest plan snapshot so the agent can inspect "
415
+ "steps, statuses, and notes."
416
+ ),
417
+ handler=suite.read_plan,
418
+ accepts_overrides=accepts_overrides,
419
+ ),
420
+ ),
421
+ )
422
+
423
+
424
+ class _PlanningToolSuite:
425
+ """Collection of tool handlers bound to a section instance."""
426
+
427
+ def __init__(self, *, section: PlanningToolsSection) -> None:
428
+ super().__init__()
429
+ self._section = section
430
+
431
+ def setup_plan(
432
+ self, params: SetupPlan, *, context: ToolContext
433
+ ) -> ToolResult[SetupPlan]:
434
+ ensure_context_uses_session(context=context, session=self._section.session)
435
+ del context
436
+ objective = _normalize_required_text(
437
+ params.objective,
438
+ field_name="objective",
439
+ max_length=_MAX_OBJECTIVE_LENGTH,
440
+ )
441
+ initial_steps = _normalize_new_steps(params.initial_steps)
442
+ normalized = SetupPlan(objective=objective, initial_steps=initial_steps)
443
+ step_count = len(initial_steps)
444
+ message = (
445
+ f"Plan initialised with {step_count} step{'s' if step_count != 1 else ''}."
446
+ )
447
+ return ToolResult(message=message, value=normalized)
448
+
449
+ def add_step(self, params: AddStep, *, context: ToolContext) -> ToolResult[AddStep]:
450
+ ensure_context_uses_session(context=context, session=self._section.session)
451
+ del context
452
+ session = self._section.session
453
+ plan = _require_plan(session)
454
+ _ensure_active(plan, "add steps to")
455
+ normalized_steps = _normalize_new_steps(params.steps)
456
+ if not normalized_steps:
457
+ message = "Provide at least one step to add."
458
+ raise ToolValidationError(message)
459
+ message = (
460
+ "Queued"
461
+ f" {len(normalized_steps)} step{'s' if len(normalized_steps) != 1 else ''}"
462
+ " for addition."
463
+ )
464
+ return ToolResult(message=message, value=AddStep(steps=normalized_steps))
465
+
466
+ def update_step(
467
+ self, params: UpdateStep, *, context: ToolContext
468
+ ) -> ToolResult[UpdateStep]:
469
+ ensure_context_uses_session(context=context, session=self._section.session)
470
+ del context
471
+ session = self._section.session
472
+ plan = _require_plan(session)
473
+ _ensure_active(plan, "update steps in")
474
+ step_id = params.step_id.strip()
475
+ if not step_id:
476
+ message = "Step ID must be provided."
477
+ raise ToolValidationError(message)
478
+ _ensure_ascii(step_id, "step_id")
479
+ updated_title = (
480
+ _normalize_required_text(
481
+ params.title,
482
+ field_name="title",
483
+ max_length=_MAX_TITLE_LENGTH,
484
+ )
485
+ if params.title is not None
486
+ else None
487
+ )
488
+ updated_details = (
489
+ _normalize_optional_text(
490
+ params.details,
491
+ field_name="details",
492
+ max_length=_MAX_DETAIL_LENGTH,
493
+ )
494
+ if params.details is not None
495
+ else None
496
+ )
497
+ if updated_title is None and updated_details is None:
498
+ message = "Provide a new title or details to update a step."
499
+ raise ToolValidationError(message)
500
+ _ensure_step_exists(plan, step_id)
501
+ normalized = UpdateStep(
502
+ step_id=step_id,
503
+ title=updated_title,
504
+ details=updated_details,
505
+ )
506
+ return ToolResult(message=f"Step {step_id} update queued.", value=normalized)
507
+
508
+ def mark_step(
509
+ self, params: MarkStep, *, context: ToolContext
510
+ ) -> ToolResult[MarkStep]:
511
+ ensure_context_uses_session(context=context, session=self._section.session)
512
+ del context
513
+ session = self._section.session
514
+ plan = _require_plan(session)
515
+ if plan.status == "abandoned":
516
+ message = "Cannot mark steps on an abandoned plan."
517
+ raise ToolValidationError(message)
518
+ step_id = params.step_id.strip()
519
+ if not step_id:
520
+ message = "Step ID must be provided."
521
+ raise ToolValidationError(message)
522
+ _ensure_ascii(step_id, "step_id")
523
+ _ensure_step_exists(plan, step_id)
524
+ note = _normalize_optional_text(
525
+ params.note,
526
+ field_name="note",
527
+ max_length=_MAX_DETAIL_LENGTH,
528
+ require_content=True,
529
+ )
530
+ normalized = MarkStep(step_id=step_id, status=params.status, note=note)
531
+ return ToolResult(
532
+ message=f"Step {step_id} marked as {params.status}.",
533
+ value=normalized,
534
+ )
535
+
536
+ def clear_plan(
537
+ self, params: ClearPlan, *, context: ToolContext
538
+ ) -> ToolResult[ClearPlan]:
539
+ ensure_context_uses_session(context=context, session=self._section.session)
540
+ del context
541
+ session = self._section.session
542
+ plan = _require_plan(session)
543
+ if plan.status == "abandoned":
544
+ message = "Plan already abandoned."
545
+ raise ToolValidationError(message)
546
+ return ToolResult(message="Plan marked as abandoned.", value=params)
547
+
548
+ def read_plan(self, params: ReadPlan, *, context: ToolContext) -> ToolResult[Plan]:
549
+ del params
550
+ ensure_context_uses_session(context=context, session=self._section.session)
551
+ del context
552
+ session = self._section.session
553
+ plan = select_latest(session, Plan)
554
+ if plan is None:
555
+ message = "No plan is currently initialised."
556
+ raise ToolValidationError(message)
557
+ step_count = len(plan.steps)
558
+ if step_count == 0:
559
+ message = "Retrieved the current plan (no steps recorded)."
560
+ else:
561
+ message = (
562
+ "Retrieved the current plan "
563
+ f"with {step_count} step{'s' if step_count != 1 else ''}."
564
+ )
565
+ return ToolResult(message=message, value=plan)
566
+
567
+
568
+ def _setup_plan_reducer(
569
+ slice_values: tuple[Plan, ...],
570
+ event: ReducerEvent,
571
+ *,
572
+ context: ReducerContextProtocol,
573
+ ) -> tuple[Plan, ...]:
574
+ del context
575
+ params = cast(SetupPlan, event.value)
576
+ steps = tuple(
577
+ PlanStep(
578
+ step_id=_format_step_id(index + 1),
579
+ title=step.title,
580
+ details=step.details,
581
+ status="pending",
582
+ notes=(),
583
+ )
584
+ for index, step in enumerate(params.initial_steps)
585
+ )
586
+ plan = Plan(objective=params.objective, status="active", steps=steps)
587
+ return (plan,)
588
+
589
+
590
+ def _add_step_reducer(
591
+ slice_values: tuple[Plan, ...],
592
+ event: ReducerEvent,
593
+ *,
594
+ context: ReducerContextProtocol,
595
+ ) -> tuple[Plan, ...]:
596
+ del context
597
+ previous = _latest_plan(slice_values)
598
+ if previous is None:
599
+ return slice_values
600
+ params = cast(AddStep, event.value)
601
+ existing = list(previous.steps)
602
+ next_index = _next_step_index(existing)
603
+ for step in params.steps:
604
+ next_index += 1
605
+ existing.append(
606
+ PlanStep(
607
+ step_id=_format_step_id(next_index),
608
+ title=step.title,
609
+ details=step.details,
610
+ status="pending",
611
+ notes=(),
612
+ )
613
+ )
614
+ updated = Plan(
615
+ objective=previous.objective,
616
+ status="active",
617
+ steps=tuple(existing),
618
+ )
619
+ return (updated,)
620
+
621
+
622
+ def _update_step_reducer(
623
+ slice_values: tuple[Plan, ...],
624
+ event: ReducerEvent,
625
+ *,
626
+ context: ReducerContextProtocol,
627
+ ) -> tuple[Plan, ...]:
628
+ del context
629
+ previous = _latest_plan(slice_values)
630
+ if previous is None:
631
+ return slice_values
632
+ params = cast(UpdateStep, event.value)
633
+ updated_steps: list[PlanStep] = []
634
+ for step in previous.steps:
635
+ if step.step_id != params.step_id:
636
+ updated_steps.append(step)
637
+ continue
638
+ new_title = params.title if params.title is not None else step.title
639
+ new_details = params.details if params.details is not None else step.details
640
+ updated_steps.append(
641
+ PlanStep(
642
+ step_id=step.step_id,
643
+ title=new_title,
644
+ details=new_details,
645
+ status=step.status,
646
+ notes=step.notes,
647
+ )
648
+ )
649
+ updated_plan = Plan(
650
+ objective=previous.objective,
651
+ status=previous.status,
652
+ steps=tuple(updated_steps),
653
+ )
654
+ return (updated_plan,)
655
+
656
+
657
+ def _mark_step_reducer(
658
+ slice_values: tuple[Plan, ...],
659
+ event: ReducerEvent,
660
+ *,
661
+ context: ReducerContextProtocol,
662
+ ) -> tuple[Plan, ...]:
663
+ del context
664
+ previous = _latest_plan(slice_values)
665
+ if previous is None:
666
+ return slice_values
667
+ params = cast(MarkStep, event.value)
668
+ updated_steps: list[PlanStep] = []
669
+ for step in previous.steps:
670
+ if step.step_id != params.step_id:
671
+ updated_steps.append(step)
672
+ continue
673
+ notes = step.notes
674
+ if params.note is not None:
675
+ notes = (*notes, params.note)
676
+ updated_steps.append(
677
+ PlanStep(
678
+ step_id=step.step_id,
679
+ title=step.title,
680
+ details=step.details,
681
+ status=params.status,
682
+ notes=notes,
683
+ )
684
+ )
685
+ plan_status: PlanStatus
686
+ if not updated_steps or all(step.status == "done" for step in updated_steps):
687
+ plan_status = "completed"
688
+ else:
689
+ plan_status = "active"
690
+ updated_plan = Plan(
691
+ objective=previous.objective,
692
+ status=plan_status,
693
+ steps=tuple(updated_steps),
694
+ )
695
+ return (updated_plan,)
696
+
697
+
698
+ def _clear_plan_reducer(
699
+ slice_values: tuple[Plan, ...],
700
+ event: ReducerEvent,
701
+ *,
702
+ context: ReducerContextProtocol,
703
+ ) -> tuple[Plan, ...]:
704
+ del context
705
+ previous = _latest_plan(slice_values)
706
+ if previous is None:
707
+ return slice_values
708
+ del event
709
+ abandoned = Plan(objective=previous.objective, status="abandoned", steps=())
710
+ return (abandoned,)
711
+
712
+
713
+ def _latest_plan(plans: tuple[Plan, ...]) -> Plan | None:
714
+ if not plans:
715
+ return None
716
+ return plans[-1]
717
+
718
+
719
+ def _next_step_index(steps: Iterable[PlanStep]) -> int:
720
+ max_index = 0
721
+ for step in steps:
722
+ suffix = step.step_id[len(_STEP_ID_PREFIX) :]
723
+ try:
724
+ max_index = max(max_index, int(suffix))
725
+ except ValueError:
726
+ continue
727
+ return max_index
728
+
729
+
730
+ def _format_step_id(index: int) -> str:
731
+ return f"{_STEP_ID_PREFIX}{index:03d}"
732
+
733
+
734
+ def _normalize_new_steps(steps: Sequence[NewPlanStep]) -> tuple[NewPlanStep, ...]:
735
+ normalized: list[NewPlanStep] = []
736
+ for step in steps:
737
+ title = _normalize_required_text(
738
+ step.title,
739
+ field_name="title",
740
+ max_length=_MAX_TITLE_LENGTH,
741
+ )
742
+ details = _normalize_optional_text(
743
+ step.details,
744
+ field_name="details",
745
+ max_length=_MAX_DETAIL_LENGTH,
746
+ )
747
+ normalized.append(NewPlanStep(title=title, details=details))
748
+ return tuple(normalized)
749
+
750
+
751
+ def _normalize_required_text(
752
+ value: str,
753
+ *,
754
+ field_name: str,
755
+ max_length: int,
756
+ ) -> str:
757
+ stripped = value.strip()
758
+ if not stripped:
759
+ message = f"{field_name.title()} must not be empty."
760
+ raise ToolValidationError(message)
761
+ if len(stripped) > max_length:
762
+ message = f"{field_name.title()} must be <= {max_length} characters."
763
+ raise ToolValidationError(message)
764
+ _ensure_ascii(stripped, field_name)
765
+ return stripped
766
+
767
+
768
+ def _normalize_optional_text(
769
+ value: str | None,
770
+ *,
771
+ field_name: str,
772
+ max_length: int,
773
+ require_content: bool = False,
774
+ ) -> str | None:
775
+ if value is None:
776
+ return None
777
+ stripped = value.strip()
778
+ if not stripped:
779
+ if require_content:
780
+ message = f"{field_name.title()} must not be empty when provided."
781
+ raise ToolValidationError(message)
782
+ return None
783
+ if len(stripped) > max_length:
784
+ message = f"{field_name.title()} must be <= {max_length} characters."
785
+ raise ToolValidationError(message)
786
+ _ensure_ascii(stripped, field_name)
787
+ return stripped
788
+
789
+
790
+ def _ensure_ascii(value: str, field_name: str) -> None:
791
+ try:
792
+ _ = value.encode(_ASCII)
793
+ except UnicodeEncodeError as error: # pragma: no cover - defensive
794
+ message = f"{field_name.title()} must be ASCII."
795
+ raise ToolValidationError(message) from error
796
+
797
+
798
+ def _require_plan(session: Session) -> Plan:
799
+ plan = select_latest(session, Plan)
800
+ if plan is None:
801
+ message = "No plan is currently initialised."
802
+ raise ToolValidationError(message)
803
+ return plan
804
+
805
+
806
+ def _ensure_active(plan: Plan, action: str) -> None:
807
+ if plan.status != "active":
808
+ message = f"Plan must be active to {action}. Current status: {plan.status}."
809
+ raise ToolValidationError(message)
810
+
811
+
812
+ def _ensure_step_exists(plan: Plan, step_id: str) -> None:
813
+ if not any(step.step_id == step_id for step in plan.steps):
814
+ message = f"Step {step_id} does not exist."
815
+ raise ToolValidationError(message)
816
+
817
+
818
+ __all__ = [
819
+ "AddStep",
820
+ "ClearPlan",
821
+ "MarkStep",
822
+ "NewPlanStep",
823
+ "Plan",
824
+ "PlanStatus",
825
+ "PlanStep",
826
+ "PlanningToolsSection",
827
+ "ReadPlan",
828
+ "SetupPlan",
829
+ "StepStatus",
830
+ "UpdateStep",
831
+ ]