Files
2026-08-09 20:12:05 +07:00

163 lines
6.1 KiB
Python

"""Auto-plan for the Code tab.
For each Code-tab message we (1) ask the model to split the request into a short
step checklist (shown in the preview's **Plan** view), then (2) give the agent an
``update_plan`` tool so it can tick steps off as it works.
The parse/normalize helpers are pure (no Qt, no provider) and unit-tested;
``decompose_request`` makes one provider call and is best-effort — it returns
``[]`` on any problem so it never blocks the chat.
"""
from __future__ import annotations
import json
import re
from typing import Any, Dict, List, Optional
from ..providers.base import Provider, ToolSpec
from .flows import STEP_DONE, STEP_ERROR, STEP_PENDING, STEP_RUNNING
_VALID_STATUS = {STEP_PENDING, STEP_RUNNING, STEP_DONE, STEP_ERROR}
MAX_PLAN_STEPS = 8
UPDATE_PLAN_SPEC = ToolSpec(
name="update_plan",
description=(
"Update the task checklist shown to the user. Pass the FULL list of steps "
"with each step's status (pending / running / done / error). Call it as you "
"work: mark the current step 'running', then 'done' when finished, or "
"'error' if it genuinely could not be completed (explain why in your reply "
"text — for an unattended Schedule Task, any step left 'error' or not "
"'done' by the time you finish means the task is NOT reported as done)."
),
parameters={
"type": "object",
"properties": {
"steps": {
"type": "array",
"items": {
"type": "object",
"properties": {
"title": {"type": "string"},
"status": {"type": "string",
"enum": [STEP_PENDING, STEP_RUNNING, STEP_DONE, STEP_ERROR]},
},
"required": ["title"],
},
},
},
"required": ["steps"],
},
)
def parse_plan_steps(text: str, max_steps: int = MAX_PLAN_STEPS) -> List[str]:
"""Extract step titles from an LLM response.
Accepts a JSON array (of strings or ``{title}`` objects); failing that, falls
back to numbered / bulleted lines. Returns a trimmed, de-duplicated list (empty
when nothing parses)."""
text = (text or "").strip()
if not text:
return []
titles: List[str] = []
# 1) JSON array anywhere in the text.
start, end = text.find("["), text.rfind("]")
if 0 <= start < end:
try:
arr = json.loads(text[start:end + 1])
if isinstance(arr, list):
for item in arr:
if isinstance(item, str):
titles.append(item)
elif isinstance(item, dict) and item.get("title"):
titles.append(str(item["title"]))
except json.JSONDecodeError:
pass
# 2) Fallback: numbered / bulleted lines.
if not titles:
for line in text.splitlines():
m = re.match(r"\s*(?:\d+[.)]|[-*•])\s+(.*\S)", line)
if m:
titles.append(m.group(1))
# Tidy: strip, drop empties, de-dupe (preserve order), cap at max_steps.
out: List[str] = []
seen = set()
for t in titles:
t = t.strip().strip('"').strip()
if t and t.lower() not in seen:
seen.add(t.lower())
out.append(t)
if len(out) >= max_steps:
break
return out
def plan_incomplete_reason(steps: List[Dict[str, str]]) -> str:
"""``""`` when there's no plan, or every step ended up 'done'; otherwise a
short human-readable note naming the step(s) that are NOT done (still
pending/running, or explicitly marked 'error') — used so a Schedule Task
isn't reported "done" when its own checklist says the work wasn't
actually finished."""
if not steps:
return ""
bad = [s for s in steps if s.get("status") != STEP_DONE]
if not bad:
return ""
errored = [s["title"] for s in bad if s.get("status") == STEP_ERROR]
unfinished = [s["title"] for s in bad if s.get("status") != STEP_ERROR]
parts = []
if errored:
parts.append(f"failed: {', '.join(errored)}")
if unfinished:
parts.append(f"left unfinished: {', '.join(unfinished)}")
return "The task's own plan reports steps not completed (" + "; ".join(parts) + ")."
def normalize_plan_steps(raw: Any) -> List[Dict[str, str]]:
"""Validate the agent's ``update_plan`` ``steps`` argument into
``[{title, status}]``. Non-dict items are dropped; an unknown/missing status is
clamped to ``pending``."""
out: List[Dict[str, str]] = []
if not isinstance(raw, list):
return out
for item in raw:
if not isinstance(item, dict):
continue
title = str(item.get("title", "")).strip()
if not title:
continue
status = str(item.get("status", STEP_PENDING)).strip().lower()
if status not in _VALID_STATUS:
status = STEP_PENDING
out.append({"title": title, "status": status})
return out
_DECOMPOSE_SYSTEM = (
"You are a planning assistant. Break the user's coding request into a SHORT "
"ordered checklist of concrete steps (2 to {n} steps; fewer is fine). "
"Reply with ONLY a JSON array of short imperative step titles — no prose, no "
"code fences. Example: [\"Read the config\", \"Add the new field\", \"Run tests\"]."
)
def decompose_request(provider: Provider, request: str,
cancel: Optional[Any] = None,
max_steps: int = MAX_PLAN_STEPS) -> List[str]:
"""Best-effort: ask the model to split ``request`` into step titles. Returns
``[]`` on any error/timeout so a failed plan never blocks the turn."""
request = (request or "").strip()
if not request:
return []
messages = [
{"role": "system", "content": _DECOMPOSE_SYSTEM.format(n=max_steps)},
{"role": "user", "content": request},
]
try:
assistant = provider.chat(messages, tools=None, on_text=None, cancel=cancel)
except Exception: # noqa: BLE001 - planning must never break the turn
return []
return parse_plan_steps(assistant.get("content", ""), max_steps)