This commit is contained in:
+162
@@ -0,0 +1,162 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user