oc_chat
Loading...
Searching...
No Matches
planner.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/tools/planner.py
4
5"""
6Multi-step task planner for agentic goal execution.
7
8Purpose
9-------
10Process-singleton plan state that the model uses to break a complex
11goal into discrete steps and track progress across ReAct iterations.
12The model calls :func:`create_plan` to set up a plan, :func:`mark_step`
13as each step completes, :func:`get_status` for progress, and
14:func:`stop_plan` to halt execution.
15
16Responsibilities
17----------------
18- :class:`Step` / :class:`Plan` — dataclasses describing a single
19 step (description, status, result) and the active plan (goal,
20 steps, completion check).
21- :func:`create_plan` — start a new plan; replaces any existing one.
22- :func:`mark_step` — transition a step to ``done`` / ``failed`` with
23 an optional result string.
24- :func:`get_status` — return the current plan as a dict.
25- :func:`stop_plan` — clear active plan state and set the stop flag.
26
27Diagnostics
28-----------
29Domain: OC_CHAT.TOOLS
30Levels:
31 L2 — lifecycle events
32 L3 — semantic details
33 L4 — deep tracing
34
35Contracts
36---------
37- Module-level ``_active_plan`` and ``_stopped`` are process-singleton
38 state — only one plan is active at a time per REPL.
39- ``create_plan`` overwrites any existing plan without warning.
40- ``mark_step`` validates the step index; out-of-range indices return
41 an error dict rather than raising.
42- ``stop_plan`` is idempotent — calling it twice has no extra effect.
43
44Dependencies
45------------
46- oc_diagnostics (structured debug logging via _dbg)
47
48See Also
49--------
50oc_chat/docs/conventions/CONTRACTS.md §8 Plan / Step — owner of the planner
51dataclasses; §3 Tool registry contract; §4 Tool return-value conventions.
52"""
53from dataclasses import dataclass, field
54from typing import Literal
55
56from oc_diagnostics import _dbg
57
58_active_plan = None
59_stopped = False
60
61
62@dataclass
63class Step:
64 """A single step within a :class:`Plan`."""
65 description: str
66 status: Literal["pending", "running", "done", "failed"] = "pending"
67 result: str = ""
68
69
70@dataclass
71class Plan:
72 """A multi-step plan with a goal and ordered steps."""
73 goal: str
74 steps: list = field(default_factory=list)
75
76 @property
77 def is_done(self) -> bool:
78 """Return ``True`` when every step has completed (done or failed)."""
79 return bool(self.steps) and all(s.status in ("done", "failed") for s in self.steps)
80
81 @property
82 def next_pending_index(self) -> int | None:
83 """Return the index of the first pending step, or ``None``."""
84 for i, s in enumerate(self.steps):
85 if s.status == "pending":
86 return i
87 return None
88
89
90# ── Tool functions ─────────────────────────────────────────────────────────────
91
92#: Keys a model commonly nests a step description under when it (wrongly) sends
93#: ``steps`` as a list of objects instead of a list of strings (F28).
94_STEP_TEXT_KEYS = ("description", "step", "title", "text", "name", "task")
95
96
97def _coerce_steps(steps: object) -> "tuple[list[str] | None, str | None]":
98 """Coerce a model-supplied *steps* value into a ``list[str]``, or return an error.
99
100 F28: ``create_plan`` used to ``len(steps)`` / iterate ``steps`` blindly, so a
101 ``None`` raised ``TypeError`` (invisible crash) and a bare STRING was silently
102 char-split into one step per character. This tolerates the shapes the model
103 actually sends — a list of dicts (``{'description'|'step'|'title'|...: ...}``),
104 non-string scalars (stringified) — and hard-rejects only the crash/corruption
105 shapes (``None`` and a bare string). An empty list is allowed (an empty plan is
106 a valid, supported state).
107
108 Returns ``(steps_list, None)`` on success or ``(None, error_message)``.
109 """
110 if steps is None:
111 return None, "steps is required: pass a non-empty list of step description strings."
112 if isinstance(steps, str):
113 return None, (
114 "steps must be a LIST of step strings, not a single string "
115 "(a string would be split into one step per character). "
116 'Pass e.g. ["first step", "second step"].'
117 )
118 try:
119 items = list(steps)
120 except TypeError:
121 return None, f"steps must be a list of step strings; got {type(steps).__name__}."
122
123 out: list[str] = []
124 for item in items:
125 if isinstance(item, str):
126 text = item.strip()
127 elif isinstance(item, dict):
128 text = ""
129 for key in _STEP_TEXT_KEYS:
130 val = item.get(key)
131 if isinstance(val, str) and val.strip():
132 text = val.strip()
133 break
134 if not text:
135 text = str(item).strip()
136 else:
137 text = str(item).strip()
138 if text:
139 out.append(text)
140
141 # An empty list yields an empty plan — a valid, supported state (a plan is
142 # simply "not done"); only the crash/corruption shapes (None, a bare string)
143 # are hard-rejected above.
144 return out, None
145
146
147def create_plan(goal: str, steps: list) -> dict:
148 """Create a new plan with a list of step descriptions.
149
150 Validates and coerces the model-supplied arguments (F28) so a malformed call
151 returns a structured ``{"error": ...}`` the model can act on — it never raises
152 (an invisible crash) or silently char-splits a bare string.
153 """
154 func = "create_plan"
155 global _active_plan, _stopped
156
157 goal_text = goal.strip() if isinstance(goal, str) else str(goal or "").strip()
158 if not goal_text:
159 _dbg("OC_CHAT.TOOLS", func, "VALIDATION_ERROR: empty goal", 3)
160 return {"error": "goal is required: pass a short non-empty goal string."}
161
162 coerced, err = _coerce_steps(steps)
163 if err is not None:
164 _dbg("OC_CHAT.TOOLS", func, f"VALIDATION_ERROR: {err}", 3)
165 return {"error": err}
166
167 _dbg("OC_CHAT.TOOLS", func, f"goal={goal_text!r} steps={len(coerced)}", 2)
168 _active_plan = Plan(goal=goal_text, steps=[Step(description=s) for s in coerced])
169 _stopped = False
170 return {
171 "created": True,
172 "goal": goal_text,
173 "step_count": len(coerced),
174 "steps": [s.description for s in _active_plan.steps],
175 }
176
177
178def get_status() -> dict:
179 """Return the current plan status including all step statuses."""
180 if not _active_plan:
181 return {"status": "no active plan"}
182 return {
183 "goal": _active_plan.goal,
184 "done": _active_plan.is_done,
185 "stopped": _stopped,
186 "steps": [
187 {"index": i, "description": s.description, "status": s.status, "result": s.result}
188 for i, s in enumerate(_active_plan.steps)
189 ],
190 }
191
192
193def mark_step(step_index: int, status: str, result: str = "") -> dict:
194 """Update the status of a plan step.
195
196 *status* must be one of ``pending``, ``running``, ``done``, ``failed``.
197 """
198 if not _active_plan:
199 return {"error": "No active plan"}
200 if step_index < 0 or step_index >= len(_active_plan.steps):
201 return {"error": f"Invalid step index: {step_index}"}
202 if status not in ("pending", "running", "done", "failed"):
203 return {"error": f"Invalid status: {status}"}
204
205 _active_plan.steps[step_index].status = status
206 _active_plan.steps[step_index].result = result
207 return {"ok": True, "step": step_index, "status": status}
208
209
210def stop_plan() -> dict:
211 """Halt the active plan.
212
213 Call this when the goal is complete, impossible, or the user has
214 requested a stop.
215 """
216 global _stopped
217 _stopped = True
218 return {"stopped": True}
219
220
221TOOLS = {
222 "create_plan": create_plan,
223 "get_status": get_status,
224 "mark_step": mark_step,
225 "stop_plan": stop_plan,
226}
227
228SCHEMAS = [
229 {
230 "type": "function",
231 "function": {
232 "name": "create_plan",
233 "description": "Create a multi-step plan for a complex goal.",
234 "parameters": {
235 "type": "object",
236 "properties": {
237 "goal": {"type": "string", "description": "The overall goal"},
238 "steps": {
239 "type": "array",
240 "items": {"type": "string"},
241 "description": "Ordered list of step descriptions",
242 },
243 },
244 "required": ["goal", "steps"],
245 },
246 },
247 },
248 {
249 "type": "function",
250 "function": {
251 "name": "get_status",
252 "description": "Return the current plan status including all step statuses.",
253 "parameters": {"type": "object", "properties": {}, "required": []},
254 },
255 },
256 {
257 "type": "function",
258 "function": {
259 "name": "mark_step",
260 "description": "Update the status of a plan step.",
261 "parameters": {
262 "type": "object",
263 "properties": {
264 "step_index": {"type": "integer", "description": "Zero-based step index"},
265 "status": {"type": "string", "description": "One of: pending, running, done, failed"},
266 "result": {"type": "string", "description": "Optional result or error message"},
267 },
268 "required": ["step_index", "status"],
269 },
270 },
271 },
272 {
273 "type": "function",
274 "function": {
275 "name": "stop_plan",
276 "description": "Halt the active plan. Call this when the goal is complete, impossible, or the user has requested a stop.",
277 "parameters": {"type": "object", "properties": {}, "required": []},
278 },
279 },
280]
int|None next_pending_index(self)
Definition planner.py:82
dict mark_step(int step_index, str status, str result="")
Definition planner.py:193
dict create_plan(str goal, list steps)
Definition planner.py:147
"tuple[list[str] | None, str | None]" _coerce_steps(object steps)
Definition planner.py:97