163 lines
6.1 KiB
Python
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)
|