"""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"", 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"") 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)), )