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.

Files changed (36) hide show
  1. weakincentives/__init__.py +26 -2
  2. weakincentives/adapters/__init__.py +6 -5
  3. weakincentives/adapters/core.py +7 -17
  4. weakincentives/adapters/litellm.py +594 -0
  5. weakincentives/adapters/openai.py +286 -57
  6. weakincentives/events.py +103 -0
  7. weakincentives/examples/__init__.py +67 -0
  8. weakincentives/examples/code_review_prompt.py +118 -0
  9. weakincentives/examples/code_review_session.py +171 -0
  10. weakincentives/examples/code_review_tools.py +376 -0
  11. weakincentives/{prompts → prompt}/__init__.py +6 -8
  12. weakincentives/{prompts → prompt}/_types.py +1 -1
  13. weakincentives/{prompts/text.py → prompt/markdown.py} +19 -9
  14. weakincentives/{prompts → prompt}/prompt.py +216 -66
  15. weakincentives/{prompts → prompt}/response_format.py +9 -6
  16. weakincentives/{prompts → prompt}/section.py +25 -4
  17. weakincentives/{prompts/structured.py → prompt/structured_output.py} +16 -5
  18. weakincentives/{prompts → prompt}/tool.py +6 -6
  19. weakincentives/prompt/versioning.py +144 -0
  20. weakincentives/serde/__init__.py +0 -14
  21. weakincentives/serde/dataclass_serde.py +3 -17
  22. weakincentives/session/__init__.py +31 -0
  23. weakincentives/session/reducers.py +60 -0
  24. weakincentives/session/selectors.py +45 -0
  25. weakincentives/session/session.py +168 -0
  26. weakincentives/tools/__init__.py +69 -0
  27. weakincentives/tools/errors.py +22 -0
  28. weakincentives/tools/planning.py +538 -0
  29. weakincentives/tools/vfs.py +590 -0
  30. weakincentives-0.3.0.dist-info/METADATA +231 -0
  31. weakincentives-0.3.0.dist-info/RECORD +35 -0
  32. weakincentives-0.2.0.dist-info/METADATA +0 -173
  33. weakincentives-0.2.0.dist-info/RECORD +0 -20
  34. /weakincentives/{prompts → prompt}/errors.py +0 -0
  35. {weakincentives-0.2.0.dist-info → weakincentives-0.3.0.dist-info}/WHEEL +0 -0
  36. {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
+ ]