a2d238d92e
Make Zed Agent closer to Cursor: deterministic DevOps path index, live Traefik port probe before blind edits, stop-after-edit, and quieter Russian progress.
1138 lines
42 KiB
Python
1138 lines
42 KiB
Python
"""Hierarchical orchestration: strong Max plan → cheap workers → conditional DeepSeek verify → synthesize."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import json
|
||
import logging
|
||
import re
|
||
import time
|
||
from collections.abc import Awaitable, Callable
|
||
from dataclasses import dataclass, field
|
||
from typing import Any
|
||
|
||
from rules_loader import load_orchestration
|
||
|
||
log = logging.getLogger("hierarchical")
|
||
|
||
ProgressCallback = Callable[[str], Awaitable[None] | None]
|
||
|
||
PLAN_SYSTEM = """You are a task planner for a coding agent. Split the user request into at most {max_subtasks} subtasks.
|
||
Reply with ONLY valid compact JSON (no markdown, no commentary, no trailing commas):
|
||
{{"subtasks":[{{"id":"1","prompt":"...","worker_tier":"simple|medium_code|hard","depends_on":[],"paths":["C:\\\\abs\\\\or\\\\rel\\\\file"],"edit_goal":"what to change","constraints":["must keep X"]}}],"acceptance":["..."]}}
|
||
Rules:
|
||
- worker_tier simple = cheap text/ops (no file tools); medium_code = routine edits/scripts; hard = rare flagship only
|
||
- Prefer medium_code for code edits; use hard only for architecture, multi-repo, or ambiguous hard bugs
|
||
- Each prompt: one sentence, ASCII quotes only, max ~200 chars, self-contained
|
||
- Always fill paths[] with concrete file paths from context when known (absolute preferred for multi-root)
|
||
- edit_goal: short outcome; constraints: must-not-break items (empty array ok)
|
||
- Prefer 1 subtask when possible; never invent long copy-paste of the user request
|
||
- If context is truncated, plan from the visible goal only
|
||
- EventHub / *.calentiq.com / IFT: Traefik + Docker Swarm live in EventHubDevOps.
|
||
REAL files only (do NOT invent nginx /var/log or classic Traefik names):
|
||
- EventHubDevOps\\\\ift\\\\traefik\\\\dynamic_conf.yml (NOT traefik.yml / traefik.toml)
|
||
- EventHubDevOps\\\\ift\\\\docker-compose.core.yml (NO root docker-compose.yml)
|
||
Static Traefik config is NOT in this repo as traefik.yml — only dynamic_conf*.yml under ift|stage/traefik/.
|
||
"""
|
||
|
||
PLAN_REPAIR_USER = """Your previous reply was not valid JSON ({error}).
|
||
Reply again with ONLY one compact JSON object in the required schema.
|
||
Keep each prompt under 200 characters. Include paths/edit_goal when known. No markdown."""
|
||
|
||
VERIFY_SYSTEM = """You verify worker results against acceptance criteria.
|
||
Reply with ONLY valid JSON:
|
||
{{"ok":true|false,"retry_ids":[],"notes":"short"}}
|
||
retry_ids lists failed subtask ids (empty if ok).
|
||
"""
|
||
|
||
SYNTH_SYSTEM = """Merge worker outputs into one clear final answer for the user.
|
||
Respect acceptance criteria. Be concise and complete. Do not invent missing work."""
|
||
|
||
|
||
@dataclass
|
||
class Subtask:
|
||
id: str
|
||
prompt: str
|
||
worker_tier: str = "simple"
|
||
depends_on: list[str] = field(default_factory=list)
|
||
paths: list[str] = field(default_factory=list)
|
||
edit_goal: str = ""
|
||
constraints: list[str] = field(default_factory=list)
|
||
|
||
|
||
@dataclass
|
||
class HierarchicalResult:
|
||
content: str
|
||
meta: dict[str, Any]
|
||
ok: bool = True
|
||
|
||
|
||
def _cfg() -> dict[str, Any]:
|
||
return load_orchestration().get("hierarchical", {}) or {}
|
||
|
||
|
||
async def _emit(on_progress: ProgressCallback | None, message: str) -> None:
|
||
if not on_progress:
|
||
return
|
||
result = on_progress(message)
|
||
if asyncio.iscoroutine(result):
|
||
await result
|
||
elif hasattr(result, "__await__"):
|
||
await result # type: ignore[misc]
|
||
|
||
|
||
def _progress_verbose() -> bool:
|
||
return bool(_cfg().get("progress_verbose", False))
|
||
|
||
|
||
async def _emit_user(
|
||
on_progress: ProgressCallback | None, message: str, *, verbose_only: bool = False
|
||
) -> None:
|
||
"""User-facing progress. verbose_only lines skipped unless progress_verbose."""
|
||
if verbose_only and not _progress_verbose():
|
||
return
|
||
await _emit(on_progress, message)
|
||
|
||
|
||
def _assistant_text(msg: dict[str, Any]) -> str:
|
||
"""Qwen3 may put the answer in reasoning_content when thinking is on."""
|
||
content = msg.get("content")
|
||
if isinstance(content, str) and content.strip():
|
||
return content
|
||
for key in ("reasoning_content", "reasoning"):
|
||
val = msg.get(key)
|
||
if isinstance(val, str) and val.strip():
|
||
return val
|
||
if isinstance(content, str):
|
||
return content
|
||
return ""
|
||
|
||
|
||
def should_run_hierarchical(
|
||
*,
|
||
tier_value: str,
|
||
header: str | None,
|
||
quality_mode: str | None,
|
||
enabled: bool | None = None,
|
||
) -> bool:
|
||
cfg = _cfg()
|
||
if enabled is None:
|
||
enabled = bool(cfg.get("enabled", True))
|
||
if not enabled:
|
||
return False
|
||
mode = (header or "auto").strip().lower()
|
||
if mode in ("0", "false", "no"):
|
||
mode = "off"
|
||
if mode == "off":
|
||
return False
|
||
if mode == "force":
|
||
return True
|
||
# auto
|
||
if quality_mode == "economy":
|
||
return False
|
||
triggers = cfg.get("trigger_tiers") or ["COMPLEX", "REASONING"]
|
||
return tier_value in triggers
|
||
|
||
|
||
def _extract_json_blob(raw: str) -> str:
|
||
text = (raw or "").strip()
|
||
fence = re.search(r"```(?:json)?\s*([\s\S]*?)```", text)
|
||
if fence:
|
||
text = fence.group(1).strip()
|
||
start = text.find("{")
|
||
if start < 0:
|
||
raise ValueError("JSON object not found")
|
||
chunk = text[start:]
|
||
try:
|
||
obj, _end = json.JSONDecoder().raw_decode(chunk)
|
||
return json.dumps(obj, ensure_ascii=False)
|
||
except json.JSONDecodeError:
|
||
end = text.rfind("}")
|
||
if end <= start:
|
||
return chunk
|
||
return text[start : end + 1]
|
||
|
||
|
||
def loads_json_relaxed(raw: str) -> Any:
|
||
"""Parse LLM JSON; tolerate fences, trailing commas, mild truncation."""
|
||
blob = _extract_json_blob(raw)
|
||
candidates = [blob]
|
||
# trailing commas before } or ]
|
||
candidates.append(re.sub(r",\s*([}\]])", r"\1", blob))
|
||
# common truncation: close open string + structures
|
||
for suffix in (
|
||
'"}]}',
|
||
'"]}',
|
||
'"}]',
|
||
'"}',
|
||
"}",
|
||
'"}]}]}',
|
||
'"],"acceptance":[]}',
|
||
'"}],"acceptance":[]}',
|
||
):
|
||
candidates.append(blob.rstrip() + suffix)
|
||
candidates.append(re.sub(r",\s*([}\]])", r"\1", blob.rstrip() + suffix))
|
||
last_err: Exception | None = None
|
||
for cand in candidates:
|
||
try:
|
||
return json.loads(cand)
|
||
except Exception as exc: # noqa: BLE001
|
||
last_err = exc
|
||
continue
|
||
raise ValueError(str(last_err) if last_err else "JSON parse failed")
|
||
|
||
|
||
def parse_plan_json(raw: str, max_subtasks: int) -> tuple[list[Subtask], list[str]]:
|
||
data = loads_json_relaxed(raw)
|
||
if not isinstance(data, dict):
|
||
raise ValueError("plan JSON root must be object")
|
||
raw_tasks = data.get("subtasks") or []
|
||
acceptance = [str(x) for x in (data.get("acceptance") or [])]
|
||
tasks: list[Subtask] = []
|
||
for i, item in enumerate(raw_tasks[:max_subtasks]):
|
||
if not isinstance(item, dict):
|
||
continue
|
||
tid = str(item.get("id") or str(i + 1))
|
||
prompt = str(item.get("prompt") or "").strip()
|
||
if not prompt:
|
||
continue
|
||
tier = str(item.get("worker_tier") or "simple").strip().lower()
|
||
if tier not in ("simple", "medium_code", "hard"):
|
||
tier = "medium_code" if "code" in tier or "medium" in tier else "simple"
|
||
deps = item.get("depends_on") or []
|
||
if not isinstance(deps, list):
|
||
deps = []
|
||
paths_raw = item.get("paths") or item.get("path") or []
|
||
if isinstance(paths_raw, str):
|
||
paths_list = [paths_raw] if paths_raw.strip() else []
|
||
elif isinstance(paths_raw, list):
|
||
paths_list = [str(p).strip() for p in paths_raw if str(p).strip()]
|
||
else:
|
||
paths_list = []
|
||
edit_goal = str(item.get("edit_goal") or item.get("goal") or "").strip()
|
||
constraints_raw = item.get("constraints") or []
|
||
if isinstance(constraints_raw, str):
|
||
constraints_list = [constraints_raw] if constraints_raw.strip() else []
|
||
elif isinstance(constraints_raw, list):
|
||
constraints_list = [
|
||
str(c).strip() for c in constraints_raw if str(c).strip()
|
||
]
|
||
else:
|
||
constraints_list = []
|
||
tasks.append(
|
||
Subtask(
|
||
id=tid,
|
||
prompt=prompt,
|
||
worker_tier=tier,
|
||
depends_on=[str(d) for d in deps],
|
||
paths=paths_list,
|
||
edit_goal=edit_goal,
|
||
constraints=constraints_list,
|
||
)
|
||
)
|
||
if not tasks:
|
||
raise ValueError("empty subtasks")
|
||
return tasks, acceptance
|
||
|
||
|
||
def truncate_plan_input(text: str, max_chars: int) -> str:
|
||
"""Optional head+tail trim. max_chars<=0 means no truncation.
|
||
|
||
Planner (Llama 8B ~128k ctx) does not need a tight 6k char cap for typical
|
||
Zed prompts (~20k chars ≈ few k tokens). Keep only a soft safety ceiling
|
||
for pathological dumps so we do not burn latency/cost.
|
||
"""
|
||
text = text or ""
|
||
if max_chars <= 0 or len(text) <= max_chars:
|
||
return text
|
||
head = max(800, max_chars // 3)
|
||
tail = max_chars - head - 24
|
||
if tail < 1200:
|
||
tail = max_chars - 24
|
||
return "…[truncated head]…\n" + text[-tail:]
|
||
return text[:head] + "\n…[truncated]…\n" + text[-tail:]
|
||
|
||
|
||
def fallback_task_prompt(user_text: str, limit: int = 4000) -> str:
|
||
"""Single-task fallback: prefer a clean tail of the user request."""
|
||
text = (user_text or "").strip()
|
||
if len(text) <= limit:
|
||
return text
|
||
chunk = text[-limit:]
|
||
for sep in ("\n\n", "\n", ". ", "? ", "! "):
|
||
idx = chunk.find(sep)
|
||
if 0 <= idx < 240:
|
||
chunk = chunk[idx + len(sep) :]
|
||
break
|
||
return chunk.strip() or text[:limit]
|
||
|
||
|
||
PLAN_MARKER_RE = re.compile(
|
||
r"<!--hier-plan-v1:(\{[\s\S]*?\})-->",
|
||
re.MULTILINE,
|
||
)
|
||
|
||
|
||
def tasks_to_payload(
|
||
tasks: list[Subtask],
|
||
acceptance: list[str],
|
||
*,
|
||
user_goal: str = "",
|
||
) -> dict[str, Any]:
|
||
return {
|
||
"subtasks": [
|
||
{
|
||
"id": t.id,
|
||
"prompt": t.prompt,
|
||
"worker_tier": t.worker_tier,
|
||
"depends_on": t.depends_on,
|
||
"paths": t.paths,
|
||
"edit_goal": t.edit_goal,
|
||
"constraints": t.constraints,
|
||
}
|
||
for t in tasks
|
||
],
|
||
"acceptance": acceptance,
|
||
"user_goal": digest_text(user_goal, 4000),
|
||
}
|
||
|
||
|
||
def payload_to_tasks(
|
||
data: dict[str, Any], max_subtasks: int
|
||
) -> tuple[list[Subtask], list[str], str]:
|
||
raw = json.dumps(data, ensure_ascii=False)
|
||
tasks, acceptance = parse_plan_json(raw, max_subtasks)
|
||
goal = str(data.get("user_goal") or "")
|
||
return tasks, acceptance, goal
|
||
|
||
|
||
def format_plan_confirm(
|
||
tasks: list[Subtask],
|
||
acceptance: list[str],
|
||
*,
|
||
user_goal: str = "",
|
||
) -> str:
|
||
lines = [
|
||
"### План (ожидает утверждения)",
|
||
"",
|
||
"Проверь шаги. Ответь одним из вариантов:",
|
||
"- **утвердить** / `ok` / `да` — выполнить план",
|
||
"- **правка:** … — дополнить или изменить план (пересоберу)",
|
||
"- **отмена** — не выполнять",
|
||
"",
|
||
]
|
||
for t in tasks:
|
||
deps = f" (после {', '.join(t.depends_on)})" if t.depends_on else ""
|
||
extra = ""
|
||
if t.paths:
|
||
extra += f" — `{', '.join(t.paths[:4])}`"
|
||
if t.edit_goal:
|
||
extra += f" → {t.edit_goal}"
|
||
lines.append(
|
||
f"{t.id}. [{t.worker_tier}] {t.prompt}{deps}{extra}"
|
||
)
|
||
if acceptance:
|
||
lines.append("")
|
||
lines.append("Критерии: " + "; ".join(acceptance))
|
||
payload = json.dumps(
|
||
tasks_to_payload(tasks, acceptance, user_goal=user_goal),
|
||
ensure_ascii=False,
|
||
separators=(",", ":"),
|
||
)
|
||
lines.append("")
|
||
lines.append(f"<!--hier-plan-v1:{payload}-->")
|
||
return "\n".join(lines)
|
||
|
||
|
||
def find_pending_plan(
|
||
messages: list[dict[str, Any]] | None,
|
||
) -> dict[str, Any] | None:
|
||
"""Latest assistant message with embedded hier-plan marker."""
|
||
if not messages:
|
||
return None
|
||
for msg in reversed(messages):
|
||
if not isinstance(msg, dict):
|
||
continue
|
||
if str(msg.get("role") or "") != "assistant":
|
||
continue
|
||
content = msg.get("content")
|
||
if not isinstance(content, str):
|
||
continue
|
||
m = PLAN_MARKER_RE.search(content)
|
||
if not m:
|
||
continue
|
||
try:
|
||
data = json.loads(m.group(1))
|
||
except json.JSONDecodeError:
|
||
continue
|
||
if isinstance(data, dict) and data.get("subtasks"):
|
||
return data
|
||
return None
|
||
|
||
|
||
def classify_plan_reply(text: str) -> str:
|
||
"""Return approve | amend | cancel | unclear."""
|
||
t = (text or "").strip()
|
||
if not t:
|
||
return "unclear"
|
||
low = t.lower()
|
||
if re.search(r"(отмен|cancel|\bstop\b|\bстоп\b)", low) and len(t) < 100:
|
||
return "cancel"
|
||
if (
|
||
re.match(
|
||
r"^(ok|okay|lgtm|yes|y|да|ок|ага|go|approve|утверждаю|утвердить)\b",
|
||
low,
|
||
)
|
||
and len(t) < 80
|
||
):
|
||
return "approve"
|
||
if (
|
||
re.search(
|
||
r"утверд|выполняй|поехал[аи]?|можно\s+выполн|согласен|\blgtm\b|\bapprove\b",
|
||
low,
|
||
)
|
||
and len(t) < 160
|
||
and not re.search(r"добав|убери|измен|вместо|правк", low)
|
||
):
|
||
return "approve"
|
||
if re.search(
|
||
r"добав|убери|измен|вместо|ещё|еще|правк|amend|change|instead|"
|
||
r"переплан|дополн",
|
||
low,
|
||
):
|
||
return "amend"
|
||
if len(t) < 40 and re.search(r"\b(да|ок|ok|go|yes)\b", low):
|
||
return "approve"
|
||
# Default: treat non-trivial reply as plan amendment
|
||
if len(t) >= 12:
|
||
return "amend"
|
||
return "unclear"
|
||
|
||
|
||
def topological_waves(tasks: list[Subtask]) -> list[list[Subtask]]:
|
||
by_id = {t.id: t for t in tasks}
|
||
done: set[str] = set()
|
||
waves: list[list[Subtask]] = []
|
||
remaining = list(tasks)
|
||
while remaining:
|
||
wave = [
|
||
t
|
||
for t in remaining
|
||
if all(d in done or d not in by_id for d in t.depends_on)
|
||
]
|
||
if not wave:
|
||
# cycle / bad deps — run rest sequentially as one wave
|
||
waves.append(remaining)
|
||
break
|
||
waves.append(wave)
|
||
for t in wave:
|
||
done.add(t.id)
|
||
remaining = [t for t in remaining if t.id not in done]
|
||
return waves
|
||
|
||
|
||
def worker_model_for(tier: str, *, quality_mode: str | None = None) -> str:
|
||
"""Map subtask tier → LiteLLM model name.
|
||
|
||
quality=max uses worker_map_max (e.g. hard → Qwen3.8-Max / c-complex).
|
||
"""
|
||
cfg = _cfg()
|
||
mapping = cfg.get("worker_map") or {}
|
||
if (quality_mode or "").strip().lower() == "max":
|
||
max_map = cfg.get("worker_map_max") or {}
|
||
if tier in max_map:
|
||
return str(max_map[tier])
|
||
return str(mapping.get(tier) or mapping.get("medium_code") or "a-medium-code")
|
||
|
||
|
||
def digest_text(text: str, limit: int) -> str:
|
||
text = (text or "").strip()
|
||
if len(text) <= limit:
|
||
return text
|
||
return text[: limit - 20] + "\n…[truncated]"
|
||
|
||
|
||
def deterministic_checks(
|
||
results: dict[str, str],
|
||
acceptance: list[str],
|
||
*,
|
||
hard_used: bool,
|
||
) -> tuple[bool, list[str]]:
|
||
"""Return (ok, fail_ids)."""
|
||
fail: list[str] = []
|
||
for tid, content in results.items():
|
||
if not (content or "").strip():
|
||
fail.append(tid)
|
||
continue
|
||
low = content.lower()
|
||
if "error" in low[:80] and len(content) < 40:
|
||
fail.append(tid)
|
||
if hard_used:
|
||
# force Sonnet path for hard workers
|
||
return False, fail
|
||
if acceptance and not results:
|
||
return False, fail
|
||
return len(fail) == 0, fail
|
||
|
||
|
||
def should_verify(
|
||
*,
|
||
policy: str,
|
||
checks_ok: bool,
|
||
hard_used: bool,
|
||
quality_mode: str | None,
|
||
) -> bool:
|
||
if quality_mode == "max":
|
||
return True
|
||
pol = (policy or "on_fail_or_hard").lower()
|
||
if pol == "never":
|
||
return False
|
||
if pol == "always":
|
||
return True
|
||
# on_fail_or_hard
|
||
return (not checks_ok) or hard_used
|
||
|
||
|
||
class HierarchicalRunner:
|
||
def __init__(
|
||
self,
|
||
*,
|
||
litellm_url: str,
|
||
litellm_key: str,
|
||
client: Any = None,
|
||
) -> None:
|
||
self.litellm_url = litellm_url.rstrip("/")
|
||
self.litellm_key = litellm_key
|
||
self._client = client
|
||
|
||
async def _chat(
|
||
self,
|
||
model: str,
|
||
messages: list[dict[str, Any]],
|
||
*,
|
||
max_tokens: int,
|
||
temperature: float = 0,
|
||
on_progress: ProgressCallback | None = None,
|
||
stage: str = "call",
|
||
) -> str:
|
||
import httpx
|
||
|
||
from llm_cache import cache_key, run_cached
|
||
|
||
cfg = _cfg()
|
||
timeout = float(cfg.get("call_timeout_sec", 180))
|
||
ttl = int(cfg.get("llm_cache_ttl_sec", 3600))
|
||
key = cache_key(
|
||
model=model,
|
||
messages=messages,
|
||
max_tokens=max_tokens,
|
||
temperature=temperature,
|
||
)
|
||
|
||
async def _hit(source: str) -> None:
|
||
await _emit_user(
|
||
on_progress,
|
||
f"{stage}: повторный запрос → {source} (`{model}`)",
|
||
verbose_only=True,
|
||
)
|
||
|
||
async def _live() -> str:
|
||
await _emit_user(
|
||
on_progress,
|
||
f"{stage}: ждём LiteLLM `{model}` (до {int(timeout)}с)…",
|
||
verbose_only=True,
|
||
)
|
||
headers = {
|
||
"Authorization": f"Bearer {self.litellm_key}",
|
||
"Content-Type": "application/json",
|
||
}
|
||
payload = {
|
||
"model": model,
|
||
"messages": messages,
|
||
"max_tokens": max_tokens,
|
||
"temperature": temperature,
|
||
"stream": False,
|
||
"chat_template_kwargs": {"enable_thinking": False},
|
||
"enable_thinking": False,
|
||
}
|
||
owns = self._client is None
|
||
# connect short; read must outlive LiteLLM upstream timeout
|
||
client = self._client or httpx.AsyncClient(
|
||
timeout=httpx.Timeout(timeout, connect=15.0)
|
||
)
|
||
try:
|
||
resp = await client.post(
|
||
f"{self.litellm_url}/v1/chat/completions",
|
||
headers=headers,
|
||
json=payload,
|
||
)
|
||
if resp.status_code >= 400:
|
||
raise RuntimeError(
|
||
f"litellm {model} HTTP {resp.status_code}: {resp.text[:300]}"
|
||
)
|
||
data = resp.json()
|
||
choice = (data.get("choices") or [{}])[0]
|
||
msg = choice.get("message") or {}
|
||
return _assistant_text(msg)
|
||
finally:
|
||
if owns:
|
||
await client.aclose()
|
||
|
||
text, source = await run_cached(key, _live, ttl_sec=ttl, on_hit=_hit)
|
||
if source == "live":
|
||
await _emit_user(
|
||
on_progress,
|
||
f"{stage}: ответ `{model}` получен",
|
||
verbose_only=True,
|
||
)
|
||
return text
|
||
|
||
async def _chat_with_fallback(
|
||
self,
|
||
primary: str,
|
||
fallback: str | None,
|
||
messages: list[dict[str, Any]],
|
||
*,
|
||
max_tokens: int,
|
||
on_progress: ProgressCallback | None = None,
|
||
stage: str = "call",
|
||
) -> tuple[str, str]:
|
||
try:
|
||
return (
|
||
await self._chat(
|
||
primary,
|
||
messages,
|
||
max_tokens=max_tokens,
|
||
on_progress=on_progress,
|
||
stage=stage,
|
||
),
|
||
primary,
|
||
)
|
||
except Exception as exc: # noqa: BLE001
|
||
err = (str(exc) or type(exc).__name__)[:120]
|
||
if not fallback or fallback == primary:
|
||
raise
|
||
log.warning("primary %s failed (%s), trying %s", primary, err, fallback)
|
||
await _emit(
|
||
on_progress,
|
||
f"{stage}: `{primary}` → {err}, fallback `{fallback}`…",
|
||
)
|
||
return (
|
||
await self._chat(
|
||
fallback,
|
||
messages,
|
||
max_tokens=max_tokens,
|
||
on_progress=on_progress,
|
||
stage=stage,
|
||
),
|
||
fallback,
|
||
)
|
||
|
||
async def run(
|
||
self,
|
||
user_text: str,
|
||
*,
|
||
quality_mode: str | None = None,
|
||
session_id: str = "",
|
||
messages: list[dict[str, Any]] | None = None,
|
||
on_progress: ProgressCallback | None = None,
|
||
agent_mode: bool = False,
|
||
) -> HierarchicalResult:
|
||
cfg = _cfg()
|
||
t0 = time.perf_counter()
|
||
wall = float(cfg.get("wall_clock_sec", 180))
|
||
max_subtasks = int(cfg.get("max_subtasks", 5))
|
||
planner = str(cfg.get("planner_model", "claude-haiku-planner"))
|
||
planner_fb = cfg.get("planner_fallback")
|
||
verifier = str(cfg.get("verifier_model", "claude-sonnet-verifier"))
|
||
verifier_fb = cfg.get("verifier_fallback")
|
||
synth_model = str(cfg.get("synthesize_model", "a-medium-code"))
|
||
verify_policy = str(cfg.get("verify_policy", "on_fail_or_hard"))
|
||
verify_input = str(cfg.get("verify_input", "digests"))
|
||
digest_chars = int(cfg.get("digest_chars", 400))
|
||
plan_max = int(cfg.get("plan_max_tokens", 1024))
|
||
verify_max = int(cfg.get("verify_max_tokens", 1024))
|
||
synth_max = int(cfg.get("synthesize_max_tokens", 4096))
|
||
worker_max = int(cfg.get("worker_max_tokens", 2048))
|
||
max_retries = int(cfg.get("max_verify_retries", 1))
|
||
plan_confirm = bool(cfg.get("plan_confirm", True))
|
||
|
||
worker_calls: list[dict[str, Any]] = []
|
||
progress_log: list[str] = []
|
||
meta: dict[str, Any] = {
|
||
"mode": "hierarchical",
|
||
"session_id": session_id or None,
|
||
"planner_model": planner,
|
||
"verifier_model": None,
|
||
"synthesize_model": synth_model,
|
||
"verify_policy": verify_policy,
|
||
"worker_calls": worker_calls,
|
||
"verify_ok": None,
|
||
"verify_skipped": False,
|
||
"progress": progress_log,
|
||
"plan_confirm": plan_confirm,
|
||
"agent_mode": agent_mode,
|
||
}
|
||
|
||
async def note(msg: str, *, verbose: bool = False) -> None:
|
||
progress_log.append(msg)
|
||
# Auto-quiet noisy lines unless progress_verbose
|
||
auto_verbose = (
|
||
verbose
|
||
or msg.startswith(" •")
|
||
or msg.startswith("ctx")
|
||
or "ждём LiteLLM" in msg
|
||
or "payload:" in msg
|
||
or msg.startswith("worker #")
|
||
or msg.startswith("волна ")
|
||
or msg.startswith("verify:")
|
||
or msg.startswith("синтез")
|
||
or "параллельно" in msg
|
||
)
|
||
await _emit_user(on_progress, msg, verbose_only=auto_verbose)
|
||
|
||
if bool(cfg.get("show_context_fill", False)) and _progress_verbose():
|
||
try:
|
||
from agent_hier import format_context_fill, estimate_tokens_from_chars
|
||
|
||
raw = json.dumps(messages or [], ensure_ascii=False, default=str)
|
||
plan_budget = int(cfg.get("plan_input_chars") or 48000)
|
||
window = int(cfg.get("context_window_tokens") or 32768)
|
||
await note(
|
||
format_context_fill(
|
||
used_chars=len(raw),
|
||
budget_chars=plan_budget,
|
||
model_window_tokens=window,
|
||
label="ctx-in",
|
||
),
|
||
verbose=True,
|
||
)
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
|
||
tasks: list[Subtask] = []
|
||
acceptance: list[str] = []
|
||
skip_planning = False
|
||
pending = find_pending_plan(messages) if plan_confirm else None
|
||
if pending:
|
||
intent = classify_plan_reply(user_text)
|
||
meta["plan_reply_intent"] = intent
|
||
if intent == "cancel":
|
||
await note("план: отменён пользователем")
|
||
meta["awaiting_plan_confirm"] = False
|
||
meta["plan_cancelled"] = True
|
||
meta["elapsed_ms"] = int((time.perf_counter() - t0) * 1000)
|
||
return HierarchicalResult(
|
||
content="План отменён. Напиши новую задачу, когда будешь готов.",
|
||
meta=meta,
|
||
ok=True,
|
||
)
|
||
if intent == "approve":
|
||
try:
|
||
tasks, acceptance, _goal = payload_to_tasks(
|
||
pending, max_subtasks
|
||
)
|
||
skip_planning = True
|
||
meta["planner_model"] = "approved-plan"
|
||
await note(
|
||
f"план: утверждён пользователем ({len(tasks)} подзадач)"
|
||
)
|
||
except Exception as exc: # noqa: BLE001
|
||
log.warning("pending plan corrupt (%s); replan", exc)
|
||
pending = None
|
||
meta["plan_pending_error"] = str(exc)[:120]
|
||
elif intent in ("amend", "unclear"):
|
||
try:
|
||
_ot, _oa, old_goal = payload_to_tasks(pending, max_subtasks)
|
||
except Exception: # noqa: BLE001
|
||
old_goal = ""
|
||
goal = old_goal or user_text
|
||
plan_text = (
|
||
f"Original request:\n{truncate_plan_input(goal, 8000)}\n\n"
|
||
f"Previous plan JSON:\n"
|
||
f"{json.dumps(pending, ensure_ascii=False)[:3000]}\n\n"
|
||
f"User wants to change the plan:\n{user_text}\n\n"
|
||
"Produce an updated plan JSON only."
|
||
)
|
||
await note("план: правки от пользователя → пересборка…")
|
||
plan_messages = [
|
||
{
|
||
"role": "system",
|
||
"content": PLAN_SYSTEM.format(max_subtasks=max_subtasks),
|
||
},
|
||
{"role": "user", "content": plan_text},
|
||
]
|
||
plan_raw, used_planner = await self._chat_with_fallback(
|
||
planner,
|
||
str(planner_fb) if planner_fb else None,
|
||
plan_messages,
|
||
max_tokens=plan_max,
|
||
on_progress=on_progress,
|
||
stage="план",
|
||
)
|
||
meta["planner_model"] = used_planner
|
||
try:
|
||
tasks, acceptance = parse_plan_json(plan_raw, max_subtasks)
|
||
except Exception as exc: # noqa: BLE001
|
||
await note(f"план: после правок JSON битый ({exc})")
|
||
tasks = [
|
||
Subtask(
|
||
id="1",
|
||
prompt=fallback_task_prompt(goal),
|
||
worker_tier="simple",
|
||
)
|
||
]
|
||
acceptance = []
|
||
meta["subtask_count"] = len(tasks)
|
||
meta["acceptance"] = acceptance
|
||
for t in tasks:
|
||
deps = f", deps={t.depends_on}" if t.depends_on else ""
|
||
await note(
|
||
f" • #{t.id} [{t.worker_tier}→{worker_model_for(t.worker_tier)}] "
|
||
f"{digest_text(t.prompt, 80)}{deps}"
|
||
)
|
||
confirm = format_plan_confirm(
|
||
tasks, acceptance, user_goal=goal
|
||
)
|
||
meta["awaiting_plan_confirm"] = True
|
||
meta["elapsed_ms"] = int((time.perf_counter() - t0) * 1000)
|
||
await note("план: ждём утверждения обновлённого плана")
|
||
return HierarchicalResult(content=confirm, meta=meta, ok=True)
|
||
|
||
if not skip_planning:
|
||
await note(f"hierarchical: план через `{planner}`…")
|
||
max_plan_chars = int(cfg.get("plan_input_chars", 48000))
|
||
plan_text = truncate_plan_input(user_text, max_plan_chars)
|
||
if max_plan_chars > 0 and len(user_text) > max_plan_chars:
|
||
await note(
|
||
f"план: вход обрезан до {max_plan_chars} символов "
|
||
f"(было {len(user_text)}; head+tail)"
|
||
)
|
||
|
||
plan_messages = [
|
||
{
|
||
"role": "system",
|
||
"content": PLAN_SYSTEM.format(max_subtasks=max_subtasks),
|
||
},
|
||
{"role": "user", "content": plan_text},
|
||
]
|
||
try:
|
||
plan_raw, used_planner = await self._chat_with_fallback(
|
||
planner,
|
||
str(planner_fb) if planner_fb else None,
|
||
plan_messages,
|
||
max_tokens=plan_max,
|
||
on_progress=on_progress,
|
||
stage="план",
|
||
)
|
||
meta["planner_model"] = used_planner
|
||
try:
|
||
tasks, acceptance = parse_plan_json(plan_raw, max_subtasks)
|
||
except Exception as exc: # noqa: BLE001
|
||
log.warning("plan parse failed (%s); trying repair", exc)
|
||
meta["plan_parse_error"] = (str(exc) or type(exc).__name__)[:160]
|
||
await note(f"план: JSON битый → repair ({exc})")
|
||
repair_model = str(planner_fb or planner)
|
||
try:
|
||
plan_raw2 = await self._chat(
|
||
repair_model,
|
||
[
|
||
{
|
||
"role": "system",
|
||
"content": PLAN_SYSTEM.format(
|
||
max_subtasks=max_subtasks
|
||
),
|
||
},
|
||
{"role": "user", "content": plan_text},
|
||
{
|
||
"role": "assistant",
|
||
"content": digest_text(plan_raw, 1200),
|
||
},
|
||
{
|
||
"role": "user",
|
||
"content": PLAN_REPAIR_USER.format(
|
||
error=(str(exc) or type(exc).__name__)[:120]
|
||
),
|
||
},
|
||
],
|
||
max_tokens=plan_max,
|
||
temperature=0.0,
|
||
on_progress=on_progress,
|
||
stage="план-repair",
|
||
)
|
||
tasks, acceptance = parse_plan_json(
|
||
plan_raw2, max_subtasks
|
||
)
|
||
used_planner = repair_model
|
||
meta["planner_model"] = used_planner
|
||
meta["plan_repaired"] = True
|
||
await note(
|
||
f"план готов после repair ({used_planner}): "
|
||
f"{len(tasks)} подзадач"
|
||
)
|
||
except Exception as exc2: # noqa: BLE001
|
||
log.warning(
|
||
"plan repair failed (%s); single-task fallback", exc2
|
||
)
|
||
meta["plan_repair_error"] = (
|
||
str(exc2) or type(exc2).__name__
|
||
)[:160]
|
||
tasks = [
|
||
Subtask(
|
||
id="1",
|
||
prompt=fallback_task_prompt(user_text),
|
||
worker_tier="simple",
|
||
)
|
||
]
|
||
acceptance = []
|
||
await note(
|
||
f"план: JSON не разобран → 1 задача-fallback ({exc2})"
|
||
)
|
||
else:
|
||
await note(
|
||
f"план готов ({used_planner}): {len(tasks)} подзадач"
|
||
+ (
|
||
f", acceptance={len(acceptance)}"
|
||
if acceptance
|
||
else ""
|
||
)
|
||
)
|
||
except Exception as exc: # noqa: BLE001
|
||
err = (str(exc) or type(exc).__name__)[:160]
|
||
log.warning("plan LLM failed (%s); single-task fallback", err)
|
||
meta["plan_llm_error"] = err
|
||
meta["planner_model"] = None
|
||
tasks = [
|
||
Subtask(
|
||
id="1",
|
||
prompt=fallback_task_prompt(user_text),
|
||
worker_tier="simple",
|
||
)
|
||
]
|
||
acceptance = []
|
||
await note(
|
||
f"план: LLM недоступен ({err}) → 1 задача без LLM-плана"
|
||
)
|
||
|
||
meta["subtask_count"] = len(tasks)
|
||
meta["acceptance"] = acceptance
|
||
|
||
for t in tasks:
|
||
deps = f", deps={t.depends_on}" if t.depends_on else ""
|
||
await note(
|
||
f" • #{t.id} [{t.worker_tier}→{worker_model_for(t.worker_tier)}] "
|
||
f"{digest_text(t.prompt, 80)}{deps}"
|
||
)
|
||
|
||
if plan_confirm and not skip_planning:
|
||
confirm = format_plan_confirm(
|
||
tasks, acceptance, user_goal=user_text
|
||
)
|
||
meta["awaiting_plan_confirm"] = True
|
||
meta["elapsed_ms"] = int((time.perf_counter() - t0) * 1000)
|
||
await note("план: ждём утверждения (ответь ok / правка / отмена)")
|
||
return HierarchicalResult(content=confirm, meta=meta, ok=True)
|
||
|
||
meta["awaiting_plan_confirm"] = False
|
||
if agent_mode:
|
||
meta["agent_execute"] = True
|
||
meta["mode"] = "hierarchical_agent"
|
||
meta["plan_payload"] = tasks_to_payload(
|
||
tasks, acceptance, user_goal=user_text
|
||
)
|
||
meta["elapsed_ms"] = int((time.perf_counter() - t0) * 1000)
|
||
await note("agent: план готов → executor с tools (Zed)")
|
||
return HierarchicalResult(content="", meta=meta, ok=True)
|
||
|
||
results: dict[str, str] = {}
|
||
hard_used = any(t.worker_tier == "hard" for t in tasks)
|
||
|
||
async def run_one(task: Subtask) -> None:
|
||
model = worker_model_for(task.worker_tier, quality_mode=quality_mode)
|
||
await note(f"worker #{task.id}: запрос `{model}`…")
|
||
dep_ctx = ""
|
||
if task.depends_on:
|
||
bits = []
|
||
for d in task.depends_on:
|
||
if d in results:
|
||
bits.append(
|
||
f"[{d}]: {digest_text(results[d], digest_chars)}"
|
||
)
|
||
if bits:
|
||
dep_ctx = "Prior results:\n" + "\n".join(bits) + "\n\n"
|
||
w_messages = [
|
||
{
|
||
"role": "user",
|
||
"content": f"{dep_ctx}Task {task.id}:\n{task.prompt}",
|
||
}
|
||
]
|
||
t_start = time.perf_counter()
|
||
try:
|
||
content = await self._chat(
|
||
model,
|
||
w_messages,
|
||
max_tokens=worker_max,
|
||
temperature=0.2,
|
||
on_progress=on_progress,
|
||
stage=f"worker #{task.id}",
|
||
)
|
||
status = "ok"
|
||
except Exception as exc: # noqa: BLE001
|
||
content = ""
|
||
status = f"error:{exc}"
|
||
log.warning("worker %s failed: %s", task.id, exc)
|
||
results[task.id] = content
|
||
latency_ms = int((time.perf_counter() - t_start) * 1000)
|
||
worker_calls.append(
|
||
{
|
||
"id": task.id,
|
||
"worker_tier": task.worker_tier,
|
||
"model": model,
|
||
"status": status,
|
||
"latency_ms": latency_ms,
|
||
"chars": len(content or ""),
|
||
}
|
||
)
|
||
if status == "ok":
|
||
await note(
|
||
f"worker #{task.id}: ok `{model}` {latency_ms}ms, "
|
||
f"{len(content or '')} символов"
|
||
)
|
||
else:
|
||
await note(
|
||
f"worker #{task.id}: ошибка `{model}` — {status[:120]}"
|
||
)
|
||
|
||
waves = topological_waves(tasks)
|
||
for wi, wave in enumerate(waves, 1):
|
||
if time.perf_counter() - t0 > wall:
|
||
raise TimeoutError("hierarchical wall clock exceeded")
|
||
ids = ", ".join(f"#{t.id}" for t in wave)
|
||
await note(f"волна {wi}/{len(waves)}: параллельно {ids}")
|
||
await asyncio.gather(*(run_one(t) for t in wave))
|
||
|
||
checks_ok, fail_ids = deterministic_checks(
|
||
results, acceptance, hard_used=hard_used
|
||
)
|
||
meta["deterministic_ok"] = checks_ok
|
||
meta["fail_ids"] = fail_ids
|
||
|
||
verify_ran = should_verify(
|
||
policy=verify_policy,
|
||
checks_ok=checks_ok,
|
||
hard_used=hard_used,
|
||
quality_mode=quality_mode,
|
||
)
|
||
retry_ids: list[str] = list(fail_ids)
|
||
verify_notes = ""
|
||
|
||
if verify_ran:
|
||
await note(f"verify: `{verifier}` (policy={verify_policy})…")
|
||
for attempt in range(max_retries + 1):
|
||
if time.perf_counter() - t0 > wall:
|
||
break
|
||
if verify_input == "full":
|
||
body_parts = [
|
||
f"### {tid}\n{results.get(tid, '')}" for tid in results
|
||
]
|
||
else:
|
||
body_parts = [
|
||
f"### {tid}\n{digest_text(results.get(tid, ''), digest_chars)}"
|
||
for tid in results
|
||
]
|
||
verify_user = (
|
||
f"User request:\n{user_text}\n\n"
|
||
f"Acceptance:\n{json.dumps(acceptance, ensure_ascii=False)}\n\n"
|
||
f"Worker digests:\n" + "\n\n".join(body_parts)
|
||
)
|
||
v_raw, used_v = await self._chat_with_fallback(
|
||
verifier,
|
||
str(verifier_fb) if verifier_fb else None,
|
||
[
|
||
{"role": "system", "content": VERIFY_SYSTEM},
|
||
{"role": "user", "content": verify_user},
|
||
],
|
||
max_tokens=verify_max,
|
||
on_progress=on_progress,
|
||
stage="verify",
|
||
)
|
||
meta["verifier_model"] = used_v
|
||
try:
|
||
vdata = loads_json_relaxed(v_raw)
|
||
ok = bool(vdata.get("ok"))
|
||
retry_ids = [str(x) for x in (vdata.get("retry_ids") or [])]
|
||
verify_notes = str(vdata.get("notes") or "")
|
||
except Exception: # noqa: BLE001
|
||
ok = False
|
||
retry_ids = fail_ids or list(results.keys())[:1]
|
||
verify_notes = "verify parse failed"
|
||
meta["verify_ok"] = ok
|
||
await note(
|
||
f"verify ({used_v}): ok={ok}"
|
||
+ (f", retry={retry_ids}" if retry_ids and not ok else "")
|
||
+ (
|
||
f" — {digest_text(verify_notes, 100)}"
|
||
if verify_notes
|
||
else ""
|
||
)
|
||
)
|
||
if ok or not retry_ids or attempt >= max_retries:
|
||
break
|
||
await note(f"повтор workers: {retry_ids}")
|
||
to_retry = [t for t in tasks if t.id in set(retry_ids)]
|
||
await asyncio.gather(*(run_one(t) for t in to_retry))
|
||
else:
|
||
meta["verify_skipped"] = True
|
||
meta["verify_ok"] = checks_ok
|
||
await note(
|
||
f"verify: пропущен (checks_ok={checks_ok}, hard={hard_used})"
|
||
)
|
||
|
||
skip_synth = bool(cfg.get("skip_synthesize_if_single", True))
|
||
non_empty = [c for c in results.values() if (c or "").strip()]
|
||
max_plan_chars = int(cfg.get("plan_input_chars", 48000))
|
||
if (
|
||
skip_synth
|
||
and len(tasks) == 1
|
||
and len(non_empty) == 1
|
||
and not verify_notes
|
||
):
|
||
final = non_empty[0]
|
||
meta["synthesize_skipped"] = True
|
||
meta["synthesize_model"] = None
|
||
await note("синтез: пропущен (1 успешный worker)")
|
||
else:
|
||
await note(f"синтез через `{synth_model}`…")
|
||
synth_parts = []
|
||
for tid, content in results.items():
|
||
synth_parts.append(f"### Subtask {tid}\n{content}")
|
||
if verify_notes:
|
||
synth_parts.append(f"Verifier notes: {verify_notes}")
|
||
cap = max_plan_chars if max_plan_chars > 0 else 4000
|
||
orig = truncate_plan_input(user_text, min(4000, cap))
|
||
synth_user = (
|
||
f"Original request:\n{orig}\n\n"
|
||
f"Acceptance: {json.dumps(acceptance, ensure_ascii=False)}\n\n"
|
||
+ "\n\n".join(synth_parts)
|
||
)
|
||
final = await self._chat(
|
||
synth_model,
|
||
[
|
||
{"role": "system", "content": SYNTH_SYSTEM},
|
||
{"role": "user", "content": synth_user},
|
||
],
|
||
max_tokens=synth_max,
|
||
temperature=0.2,
|
||
on_progress=on_progress,
|
||
stage="синтез",
|
||
)
|
||
meta["synthesize_skipped"] = False
|
||
meta["elapsed_ms"] = int((time.perf_counter() - t0) * 1000)
|
||
meta["hard_used"] = hard_used
|
||
await note(f"готово за {meta['elapsed_ms']}ms")
|
||
return HierarchicalResult(
|
||
content=final,
|
||
meta=meta,
|
||
ok=bool(meta.get("verify_ok", True)),
|
||
)
|