Files
EventHubAiRouter/router/hierarchical.py
T
aleksey a2d238d92e
CI / build-gateway (push) Failing after 16s
CI / sync-config (push) Failing after 0s
feat(agent): hierarchical executor with path resolve, runtime probe, quiet UI
Make Zed Agent closer to Cursor: deterministic DevOps path index, live Traefik
port probe before blind edits, stop-after-edit, and quieter Russian progress.
2026-08-13 11:41:27 +03:00

1138 lines
42 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)),
)