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.
- weakincentives/__init__.py +67 -0
- weakincentives/adapters/__init__.py +37 -0
- weakincentives/adapters/_names.py +32 -0
- weakincentives/adapters/_provider_protocols.py +69 -0
- weakincentives/adapters/_tool_messages.py +80 -0
- weakincentives/adapters/core.py +102 -0
- weakincentives/adapters/litellm.py +254 -0
- weakincentives/adapters/openai.py +254 -0
- weakincentives/adapters/shared.py +1021 -0
- weakincentives/cli/__init__.py +23 -0
- weakincentives/cli/wink.py +58 -0
- weakincentives/dbc/__init__.py +412 -0
- weakincentives/deadlines.py +58 -0
- weakincentives/prompt/__init__.py +105 -0
- weakincentives/prompt/_generic_params_specializer.py +64 -0
- weakincentives/prompt/_normalization.py +48 -0
- weakincentives/prompt/_overrides_protocols.py +33 -0
- weakincentives/prompt/_types.py +34 -0
- weakincentives/prompt/chapter.py +146 -0
- weakincentives/prompt/composition.py +281 -0
- weakincentives/prompt/errors.py +57 -0
- weakincentives/prompt/markdown.py +108 -0
- weakincentives/prompt/overrides/__init__.py +59 -0
- weakincentives/prompt/overrides/_fs.py +164 -0
- weakincentives/prompt/overrides/inspection.py +141 -0
- weakincentives/prompt/overrides/local_store.py +275 -0
- weakincentives/prompt/overrides/validation.py +534 -0
- weakincentives/prompt/overrides/versioning.py +269 -0
- weakincentives/prompt/prompt.py +353 -0
- weakincentives/prompt/protocols.py +103 -0
- weakincentives/prompt/registry.py +375 -0
- weakincentives/prompt/rendering.py +288 -0
- weakincentives/prompt/response_format.py +60 -0
- weakincentives/prompt/section.py +166 -0
- weakincentives/prompt/structured_output.py +179 -0
- weakincentives/prompt/tool.py +397 -0
- weakincentives/prompt/tool_result.py +30 -0
- weakincentives/py.typed +0 -0
- weakincentives/runtime/__init__.py +82 -0
- weakincentives/runtime/events/__init__.py +126 -0
- weakincentives/runtime/events/_types.py +110 -0
- weakincentives/runtime/logging.py +284 -0
- weakincentives/runtime/session/__init__.py +46 -0
- weakincentives/runtime/session/_slice_types.py +24 -0
- weakincentives/runtime/session/_types.py +55 -0
- weakincentives/runtime/session/dataclasses.py +29 -0
- weakincentives/runtime/session/protocols.py +34 -0
- weakincentives/runtime/session/reducer_context.py +40 -0
- weakincentives/runtime/session/reducers.py +82 -0
- weakincentives/runtime/session/selectors.py +56 -0
- weakincentives/runtime/session/session.py +387 -0
- weakincentives/runtime/session/snapshots.py +310 -0
- weakincentives/serde/__init__.py +19 -0
- weakincentives/serde/_utils.py +240 -0
- weakincentives/serde/dataclass_serde.py +55 -0
- weakincentives/serde/dump.py +189 -0
- weakincentives/serde/parse.py +417 -0
- weakincentives/serde/schema.py +260 -0
- weakincentives/tools/__init__.py +154 -0
- weakincentives/tools/_context.py +38 -0
- weakincentives/tools/asteval.py +853 -0
- weakincentives/tools/errors.py +26 -0
- weakincentives/tools/planning.py +831 -0
- weakincentives/tools/podman.py +1655 -0
- weakincentives/tools/subagents.py +346 -0
- weakincentives/tools/vfs.py +1390 -0
- weakincentives/types/__init__.py +35 -0
- weakincentives/types/json.py +45 -0
- weakincentives-0.9.0.dist-info/METADATA +775 -0
- weakincentives-0.9.0.dist-info/RECORD +73 -0
- weakincentives-0.9.0.dist-info/WHEEL +4 -0
- weakincentives-0.9.0.dist-info/entry_points.txt +2 -0
- 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
|
+
]
|