feat(agent): hierarchical executor with path resolve, runtime probe, quiet UI
CI / build-gateway (push) Failing after 16s
CI / sync-config (push) Failing after 0s

Make Zed Agent closer to Cursor: deterministic DevOps path index, live Traefik
port probe before blind edits, stop-after-edit, and quieter Russian progress.
This commit is contained in:
2026-08-13 11:41:27 +03:00
parent 6fd2f0e689
commit a2d238d92e
71 changed files with 10617 additions and 449 deletions
+2546
View File
File diff suppressed because it is too large Load Diff
+587
View File
@@ -0,0 +1,587 @@
"""Streaming agent-hierarchical path — never block Zed on a silent await."""
from __future__ import annotations
import asyncio
import json
import logging
import time
from typing import Any
import httpx
from fastapi.responses import StreamingResponse
log = logging.getLogger("ai-router.agent_stream")
def sse_chunk(
*,
cid: str,
model: str,
delta: dict[str, Any],
finish_reason: str | None = None,
) -> bytes:
payload = {
"id": cid,
"object": "chat.completion.chunk",
"created": int(time.time()),
"model": model,
"choices": [
{
"index": 0,
"delta": delta,
"finish_reason": finish_reason,
}
],
}
return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n".encode("utf-8")
def format_progress_block(lines: list[str]) -> str:
from progress_ui import format_progress_block as _fmt
return _fmt(lines)
async def stream_agent_plan_then_act(
*,
runner: Any,
last_user: str,
quality_mode: str | None,
session_id: str,
messages: list[dict[str, Any]],
body: dict[str, Any],
meta: dict[str, Any],
decision: Any,
prompt_hash: str,
litellm_url: str,
litellm_key: str,
cfg: dict[str, Any],
find_pending_plan: Any,
plan_payload_from_meta: Any,
inject_plan_context: Any,
pick_agent_executor_model: Any,
prepare_agent_executor_forward: Any,
completion_to_sse_chunks: Any,
synthetic_first_tool_completion: Any,
meta_header: Any,
orchestrator: Any,
requests_metric: Any,
executor_fallback_completion: Any = None,
) -> StreamingResponse:
"""Immediately SSE-stream progress; plan in background; never silent-hang."""
cid = f"hier-agent-{int(time.time() * 1000)}"
queue: asyncio.Queue[tuple[str, Any]] = asyncio.Queue()
async def on_progress(msg: str) -> None:
await queue.put(("progress", msg))
async def work() -> None:
try:
hier = await runner.run(
last_user,
quality_mode=quality_mode,
session_id=session_id,
messages=messages,
on_progress=on_progress,
agent_mode=True,
)
await queue.put(("done", hier))
except Exception as exc: # noqa: BLE001
log.exception("agent plan failed: %s", exc)
await queue.put(("error", exc))
async def gen():
# First bytes ASAP — Zed must leave spinner for content
yield sse_chunk(
cid=cid,
model="hierarchical",
delta={
"role": "assistant",
"content": "**Agent**\n· Планирую задачу\n",
},
)
task = asyncio.create_task(work())
hier = None
failed: Exception | None = None
progress_lines: list[str] = ["Планирую задачу"]
while True:
try:
kind, payload = await asyncio.wait_for(queue.get(), timeout=12.0)
except asyncio.TimeoutError:
# SSE comment keepalive (proxies / Zed idle timeout)
yield b": keepalive\n\n"
if task.done() and queue.empty():
# drain outcome if any
if not task.cancelled():
try:
_ = task.result()
except Exception as exc: # noqa: BLE001
failed = exc
break
continue
if kind == "progress":
from progress_ui import humanize_line, stream_step
line = humanize_line(str(payload))
if not line:
continue
progress_lines.append(line)
yield sse_chunk(
cid=cid,
model="hierarchical",
delta={"content": stream_step(line)},
)
continue
if kind == "error":
failed = payload # type: ignore[assignment]
break
hier = payload
break
if not task.done():
await task
if failed is not None:
err = (str(failed) or type(failed).__name__)[:200]
meta["hierarchical_error"] = err
yield sse_chunk(
cid=cid,
model="hierarchical",
delta={
"content": (
f"\n---\nОшибка плана (роутер, не LiteLLM UI): `{err}`.\n"
"Повтори запрос или напиши проще. Спиннер без шагов = баг "
"роутера — мы должны стримить progress; если снова тишина, "
"проверь деплой.\n"
)
},
)
yield sse_chunk(
cid=cid, model="hierarchical", delta={}, finish_reason="stop"
)
yield b"data: [DONE]\n\n"
return
if hier is None:
yield sse_chunk(
cid=cid,
model="hierarchical",
delta={"content": "\n---\nПустой ответ плана.\n"},
)
yield sse_chunk(
cid=cid, model="hierarchical", delta={}, finish_reason="stop"
)
yield b"data: [DONE]\n\n"
return
hier_meta = {**meta, **hier.meta}
if hier.meta.get("agent_execute"):
plan = plan_payload_from_meta(hier.meta) or find_pending_plan(messages)
orchestrator.after_request(
session_id, prompt_hash=prompt_hash, success=True, escalate=False
)
from path_resolve import (
apply_deterministic_path_index,
path_resolve_needed,
pick_path_resolve_model,
)
if cfg.get("path_resolve_enabled", True):
plan, unresolved = apply_deterministic_path_index(plan)
if unresolved:
log.info("path_index miss (stream) → %s", unresolved)
if cfg.get("runtime_probe_enabled", True) and isinstance(plan, dict):
try:
from runtime_probe import attach_runtime_probe, runtime_facts_line
user_bits = []
for m in reversed(messages or []):
if isinstance(m, dict) and m.get("role") == "user":
c = m.get("content")
user_bits.append(c if isinstance(c, str) else str(c or ""))
break
plan = attach_runtime_probe(
plan, user_text=user_bits[0] if user_bits else "", cfg=cfg
)
line = runtime_facts_line(plan)
if line:
log.info("%s", line)
except Exception as exc: # noqa: BLE001
log.warning("runtime_probe skipped (stream): %s", exc)
path_mode = bool(cfg.get("path_resolve_enabled", True)) and path_resolve_needed(
plan
)
if path_mode:
executor = pick_path_resolve_model(cfg)
else:
executor = pick_agent_executor_model(cfg)
timeout = float(
cfg.get("path_resolve_timeout_sec")
if path_mode
else (
cfg.get("executor_timeout_sec")
or cfg.get("call_timeout_sec")
or 90
)
)
max_ctx = int(cfg.get("executor_input_chars", 12000))
use_stream = bool(cfg.get("executor_use_stream", False))
verbose = bool(cfg.get("progress_verbose", False))
from progress_ui import execution_banner
yield sse_chunk(
cid=cid,
model="hierarchical",
delta={
"content": execution_banner(
path_mode=path_mode, plan=plan, model=executor
)
},
)
if verbose:
yield sse_chunk(
cid=cid,
model="hierarchical",
delta={
"content": f"· модель `{executor}` · таймаут {int(timeout)}с\n"
},
)
_ = inject_plan_context
forward = prepare_agent_executor_forward(
body,
messages,
plan,
cfg,
executor=executor,
stream=use_stream,
minimal=True,
path_resolve=path_mode,
)
n_tools = len(forward.get("tools") or [])
approx = len(json.dumps(forward, ensure_ascii=False, default=str))
from agent_hier import context_fill_for_forward
ctx_line, ctx_meta = context_fill_for_forward(
forward, cfg, budget_chars=max_ctx
)
log.info(
"executor forward model=%s tools=%s bytes≈%s stream=%s %s",
executor,
n_tools,
approx,
use_stream,
ctx_line,
)
if verbose or cfg.get("show_context_fill", False):
payload_line = f"- tools={n_tools}\n"
if cfg.get("show_context_fill", False):
payload_line += f"- {ctx_line}\n"
yield sse_chunk(
cid=cid,
model="hierarchical",
delta={"content": payload_line},
)
_ = ctx_meta
try:
body_timeout = float(forward.get("timeout") or timeout)
client_timeout = body_timeout + 30.0
if use_stream:
async with httpx.AsyncClient(
timeout=httpx.Timeout(client_timeout, connect=15.0)
) as client:
async with client.stream(
"POST",
f"{litellm_url.rstrip('/')}/v1/chat/completions",
headers={
"Authorization": f"Bearer {litellm_key}",
"Content-Type": "application/json",
},
json=forward,
) as resp:
if resp.status_code >= 400:
err_body = (await resp.aread())[:300]
raise RuntimeError(
f"litellm HTTP {resp.status_code}: "
f"{err_body.decode('utf-8', 'replace')}"
)
buf = b""
aiter = resp.aiter_bytes().__aiter__()
deadline = time.perf_counter() + client_timeout
got_data = False
while time.perf_counter() < deadline:
try:
piece = await asyncio.wait_for(
aiter.__anext__(), timeout=12.0
)
except StopAsyncIteration:
break
except asyncio.TimeoutError:
yield b": keepalive\n\n"
if not got_data:
yield sse_chunk(
cid=cid,
model="hierarchical",
delta={
"content": "- executor: ждём LiteLLM…\n"
},
)
continue
got_data = True
buf += piece
while True:
nl = buf.find(b"\n")
if nl < 0:
break
line = buf[:nl].rstrip(b"\r")
buf = buf[nl + 1 :]
if not line:
yield b"\n"
continue
yield line + b"\n"
if buf.strip():
yield buf.rstrip(b"\r") + b"\n"
yield b"data: [DONE]\n\n"
else:
# Non-stream: keepalive while awaiting Novita (headers otherwise block)
async def _post() -> httpx.Response:
async with httpx.AsyncClient(
timeout=httpx.Timeout(client_timeout, connect=15.0)
) as client:
return await client.post(
f"{litellm_url.rstrip('/')}/v1/chat/completions",
headers={
"Authorization": f"Bearer {litellm_key}",
"Content-Type": "application/json",
},
json=forward,
)
task = asyncio.create_task(_post())
while not task.done():
try:
await asyncio.wait_for(asyncio.shield(task), timeout=8.0)
except asyncio.TimeoutError:
yield b": keepalive\n\n"
yield (
b": executor waiting LiteLLM non-stream\n\n"
)
resp = task.result()
if resp.status_code >= 400:
raise RuntimeError(
f"litellm HTTP {resp.status_code}: "
f"{resp.text[:300]}"
)
data = resp.json()
msg = ((data.get("choices") or [{}])[0].get("message") or {})
has_tools = bool(msg.get("tool_calls"))
if not has_tools:
from agent_hier import (
completion_preview,
force_kickstart_read,
pick_agent_escalate_model,
completion_has_tool_calls as _has_tc,
)
log.warning(
"executor no tool_calls after plan: %s",
completion_preview(data),
)
# 1) Escalate to DeepSeek with hard tool_choice
if cfg.get("executor_no_tools_escalate", True):
esc = pick_agent_escalate_model(cfg)
esc_fwd = dict(forward)
esc_fwd["model"] = esc
esc_fwd["tool_choice"] = "required"
path_hint = None
try:
from agent_hier import _first_path_hint
path_hint = _first_path_hint(
plan, forward.get("tools") or [], messages
)
except Exception: # noqa: BLE001
path_hint = None
nudge = (
"Call a tool now. Start with read_file"
+ (f" on `{path_hint}`." if path_hint else ".")
+ " No prose."
)
esc_fwd["messages"] = list(esc_fwd.get("messages") or []) + [
{"role": "user", "content": nudge}
]
yield (
f": no tool_calls → escalate `{esc}`\n\n".encode(
"utf-8"
)
)
async with httpx.AsyncClient(
timeout=httpx.Timeout(client_timeout, connect=15.0)
) as client2:
resp2 = await client2.post(
f"{litellm_url.rstrip('/')}/v1/chat/completions",
headers={
"Authorization": f"Bearer {litellm_key}",
"Content-Type": "application/json",
},
json=esc_fwd,
)
if resp2.status_code < 400:
data2 = resp2.json()
if isinstance(data2, dict) and _has_tc(data2):
data = data2
has_tools = True
log.info("escalate no_tools → tool_calls OK")
# 2) Kickstart read_file on plan path (keeps Zed loop alive)
if not has_tools and cfg.get(
"executor_synthetic_on_timeout", True
):
yield b": no tool_calls -> kickstart read_file\n\n"
if str(cfg.get("executor_synthetic_mode") or "").lower() in (
"never",
):
# still kickstart once on first turn — otherwise dead end
data = force_kickstart_read(
plan=plan,
tools=forward.get("tools") or [],
model=executor,
messages=messages,
)
elif executor_fallback_completion:
data = executor_fallback_completion(
plan=plan,
tools=forward.get("tools") or [],
model=executor,
messages=messages,
cfg=cfg,
reason="no_tool_calls",
)
else:
data = force_kickstart_read(
plan=plan,
tools=forward.get("tools") or [],
model=executor,
messages=messages,
)
has_tools = bool(
(
(data.get("choices") or [{}])[0].get("message") or {}
).get("tool_calls")
)
# Visible status for Zed (short), details stay in SSE comments
from progress_ui import plan_paths, tool_status
tc_names: list[str] = []
msg0 = ((data.get("choices") or [{}])[0].get("message") or {})
for tc in msg0.get("tool_calls") or []:
if isinstance(tc, dict):
tc_names.append(
str(((tc.get("function") or {}).get("name")) or "")
)
paths = plan_paths(plan)
yield sse_chunk(
cid=cid,
model="hierarchical",
delta={
"content": tool_status(
tc_names, path=paths[0] if paths else None
)
},
)
yield (
f": executor OK tool_calls={has_tools}\n\n".encode("utf-8")
)
for chunk in completion_to_sse_chunks(
data, cid=cid, model=str(data.get("model") or executor)
):
yield chunk
except Exception as exc: # noqa: BLE001
err = (str(exc) or type(exc).__name__)[:220]
log.exception("executor after plan failed: %s", err)
if cfg.get("executor_synthetic_on_timeout", True):
yield (
f": timeout → executor fallback ({err[:80]})\n\n".encode(
"utf-8"
)
)
if executor_fallback_completion:
data = executor_fallback_completion(
plan=plan,
tools=forward.get("tools") or [],
model=executor,
messages=messages,
cfg=cfg,
reason=f"exception:{err[:80]}",
)
else:
data = synthetic_first_tool_completion(
plan=plan,
tools=forward.get("tools") or [],
model=executor,
messages=messages,
max_synthetic=int(
cfg.get("executor_max_synthetic_continues", 1) or 1
),
max_edit_failures=int(
cfg.get("executor_max_edit_failures", 4) or 4
),
)
for chunk in completion_to_sse_chunks(
data, cid=cid, model=executor
):
yield chunk
else:
yield sse_chunk(
cid=cid,
model="hierarchical",
delta={
"content": (
f"\nExecutor не ответил: `{err}`. "
"Роутер жив — увеличь timeout / урежь контекст; "
"повтор после ok обычно быстрее (cache).\n"
)
},
)
yield sse_chunk(
cid=cid, model="hierarchical", delta={}, finish_reason="stop"
)
yield b"data: [DONE]\n\n"
return
# Confirm / cancel / amend — text plan for user
content = hier.content or ""
yield sse_chunk(
cid=cid,
model="hierarchical",
delta={"content": f"\n---\n\n{content}"},
)
try:
requests_metric.labels(
tier=decision.tier.value,
lane=decision.lane,
model="hierarchical_agent",
status="200",
).inc()
except Exception: # noqa: BLE001
pass
orchestrator.after_request(
session_id, prompt_hash=prompt_hash, success=True, escalate=False
)
yield sse_chunk(
cid=cid, model="hierarchical", delta={}, finish_reason="stop"
)
yield b"data: [DONE]\n\n"
return StreamingResponse(
gen(),
media_type="text/event-stream",
headers={
"X-Router-Meta": meta_header({**meta, "mode": "hierarchical_agent"}),
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
},
)
File diff suppressed because it is too large Load Diff
+134
View File
@@ -0,0 +1,134 @@
"""Redis cache + in-flight coalescing for identical LiteLLM chat calls."""
from __future__ import annotations
import asyncio
import hashlib
import json
import logging
import os
from collections.abc import Awaitable, Callable
from typing import Any
log = logging.getLogger("llm_cache")
_redis = None
_redis_tried = False
_inflight: dict[str, asyncio.Future[str]] = {}
_inflight_lock = asyncio.Lock()
def _get_redis():
global _redis, _redis_tried
if _redis_tried:
return _redis
_redis_tried = True
url = os.environ.get("REDIS_URL", "").strip()
if not url:
return None
try:
import redis
client = redis.from_url(url, decode_responses=True)
client.ping()
_redis = client
except Exception as exc: # noqa: BLE001
log.warning("llm cache redis unavailable: %s", exc)
_redis = None
return _redis
def cache_key(
*,
model: str,
messages: list[dict[str, Any]],
max_tokens: int,
temperature: float,
) -> str:
blob = json.dumps(
{
"model": model,
"messages": messages,
"max_tokens": max_tokens,
"temperature": temperature,
},
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
)
digest = hashlib.sha256(blob.encode("utf-8")).hexdigest()
return f"ai-router:llm-cache:{digest}"
def get_cached(key: str) -> str | None:
r = _get_redis()
if not r:
return None
try:
val = r.get(key)
return str(val) if val is not None else None
except Exception as exc: # noqa: BLE001
log.warning("llm cache get failed: %s", exc)
return None
def set_cached(key: str, value: str, ttl_sec: int) -> None:
r = _get_redis()
if not r or ttl_sec <= 0:
return
try:
r.setex(key, ttl_sec, value)
except Exception as exc: # noqa: BLE001
log.warning("llm cache set failed: %s", exc)
async def run_cached(
key: str,
factory: Callable[[], Awaitable[str]],
*,
ttl_sec: int,
on_hit: Callable[[str], Awaitable[None] | None] | None = None,
) -> tuple[str, str]:
"""
Return (text, source) where source is cache|coalesce|live.
Coalesce concurrent identical calls onto one upstream request.
"""
cached = get_cached(key)
if cached is not None:
if on_hit:
maybe = on_hit("cache")
if asyncio.iscoroutine(maybe):
await maybe
return cached, "cache"
async with _inflight_lock:
existing = _inflight.get(key)
if existing is not None:
fut: asyncio.Future[str] = existing
mine = False
else:
fut = asyncio.get_running_loop().create_future()
_inflight[key] = fut
mine = True
if not mine:
if on_hit:
maybe = on_hit("coalesce")
if asyncio.iscoroutine(maybe):
await maybe
return await fut, "coalesce"
try:
text = await factory()
set_cached(key, text, ttl_sec)
if not fut.done():
fut.set_result(text)
return text, "live"
except Exception as exc:
if not fut.done():
fut.set_exception(exc)
raise
finally:
async with _inflight_lock:
if _inflight.get(key) is fut:
_inflight.pop(key, None)
+2 -1
View File
@@ -115,6 +115,7 @@ class Classifier:
self._complex = self._kw_re(gw.get("complex_keywords", []))
self._reasoning = self._kw_re(gw.get("reasoning_keywords", []))
self._medium_ops = self._kw_re(gw.get("medium_ops_keywords", []))
self._medium_code = self._kw_re(gw.get("medium_code_keywords", []))
self._ocr = self._kw_re(gw.get("ocr_keywords", []))
self._escalation = self._kw_re(gw.get("escalation_keywords", []))
wt = gw.get("word_thresholds", {})
@@ -159,7 +160,7 @@ class Classifier:
return Tier.REASONING, 0.88
if self._complex.search(text):
return Tier.COMPLEX, 0.88
if self._code_block.search(text):
if self._code_block.search(text) or self._medium_code.search(text):
return Tier.MEDIUM_CODE, 0.85
if self._medium_ops.search(text):
return Tier.MEDIUM_OPS, 0.82
+430
View File
@@ -0,0 +1,430 @@
"""Path resolve: deterministic index first, cheap find_* tools if gaps remain."""
from __future__ import annotations
import json
import logging
import re
from typing import Any
log = logging.getLogger("ai-router")
# Absolute Windows root for EventHubDevOps (Zed multi-root).
_DEFAULT_DEVOPS_ROOT = r"C:\Users\alexc\IdeaProjects\eventHub\EventHubDevOps"
# keyword groups → relative paths under DevOps root (first match wins per group).
# Order matters: more specific rules first.
_INDEX_RULES: list[tuple[tuple[str, ...], tuple[str, ...]]] = [
(
("loadtest", "load-test", "load_test"),
(r"ift\traefik\dynamic_conf.loadtest.yml",),
),
(
(
"traefik",
"dynamic_conf",
"router",
"middleware",
"calentiq",
"observer",
"host(",
"ift.calentiq",
"stage.calentiq",
),
(r"ift\traefik\dynamic_conf.yml",),
),
(
("portainer",),
(r"ift\docker-compose.portainer.yml",),
),
(
("grafana", "dashboard"),
(
r"ift\observability\grafana\provisioning\dashboards\dashboard.yml",
r"ift\observability\grafana\provisioning\datasources\prometheus.yml",
),
),
(
("prometheus", "observability"),
(r"ift\observability\prometheus.yml",),
),
(
("admin", "compose.admin", "docker-compose.admin"),
(r"ift\docker-compose.admin.yml",),
),
(
("client", "compose.client", "docker-compose.client"),
(r"ift\docker-compose.client.yml",),
),
(
("compose", "swarm", "stack", "docker-compose", "service"),
(r"ift\docker-compose.core.yml",),
),
]
def _join_under(root: str, rel: str) -> str:
root = root.rstrip("\\/")
rel = rel.replace("/", "\\").lstrip("\\")
return root + "\\" + rel
def _norm(path: str) -> str:
return path.replace("/", "\\").rstrip("\\").lower()
def _blob(plan: dict[str, Any] | None) -> str:
return json.dumps(plan or {}, ensure_ascii=False).lower()
def devops_root_from_plan(plan: dict[str, Any] | None) -> str:
blob = json.dumps(plan or {}, ensure_ascii=False)
m = re.search(r"([A-Za-z]:\\[^\"'\n\r]*?EventHubDevOps)", blob)
if m:
return m.group(1)
return _DEFAULT_DEVOPS_ROOT
def looks_devops(plan: dict[str, Any] | None) -> bool:
b = _blob(plan)
return any(
k in b
for k in (
"traefik",
"calentiq",
"eventhubdevops",
"docker-compose",
"swarm",
"observer",
"ift.",
"devops",
)
)
def match_index_paths(plan: dict[str, Any] | None) -> list[str]:
"""Return absolute paths from keyword index (may be empty)."""
if not looks_devops(plan):
return []
root = devops_root_from_plan(plan)
text = _blob(plan)
found: list[str] = []
seen: set[str] = set()
for keys, rels in _INDEX_RULES:
if not any(k in text for k in keys):
continue
for rel in rels:
abs_p = _join_under(root, rel)
nk = _norm(abs_p)
if nk in seen:
continue
seen.add(nk)
found.append(abs_p)
# one rule group is usually enough for a focused plan
if found:
break
return found
def apply_deterministic_path_index(
plan: dict[str, Any] | None,
) -> tuple[dict[str, Any] | None, list[str]]:
"""Fill plan.paths from index. Returns (plan, unresolved_queries).
unresolved_queries empty ⇒ index covered the goal; else cheap find_* needed.
"""
if not isinstance(plan, dict):
return plan, []
out = dict(plan)
tasks = out.get("subtasks")
if not isinstance(tasks, list) or not tasks:
# still try goal-level match
indexed = match_index_paths(out)
if indexed:
out["subtasks"] = [
{
"id": "1",
"prompt": str(out.get("user_goal") or "edit")[:200],
"worker_tier": "medium_code",
"paths": indexed,
"edit_goal": "",
"constraints": [],
}
]
out["path_resolve"] = "index"
return out, []
if looks_devops(out):
q = _queries_from_text(_blob(out))
out["path_resolve_queries"] = q
return out, q
return out, []
indexed = match_index_paths(out)
unresolved: list[str] = []
fixed: list[Any] = []
for item in tasks:
if not isinstance(item, dict):
fixed.append(item)
continue
it = dict(item)
paths = [
p
for p in (it.get("paths") or [])
if isinstance(p, str) and p.strip()
]
# Prefer index hits when devops; replace empty/weak paths
if indexed:
it["paths"] = indexed
it["path_source"] = "index"
elif not paths:
q = _queries_from_text(
str(it.get("prompt") or "")
+ " "
+ str(it.get("edit_goal") or "")
+ " "
+ _blob(out)
)
unresolved.extend(q)
it["paths"] = []
it["path_source"] = "missing"
else:
it["path_source"] = "planner"
fixed.append(it)
out["subtasks"] = fixed
if indexed:
out["path_resolve"] = "index"
out["path_resolve_queries"] = []
log.info("path_index hit → %s", indexed)
return out, []
# Planner had paths but index missed — if devops, still verify via find
if looks_devops(out) and not indexed:
unresolved = unresolved or _queries_from_text(_blob(out))
out["path_resolve_queries"] = unresolved
out["path_resolve"] = "needed"
return out, unresolved
out["path_resolve_queries"] = unresolved
if unresolved:
out["path_resolve"] = "needed"
return out, unresolved
def _queries_from_text(text: str) -> list[str]:
low = text.lower()
qs: list[str] = []
if any(k in low for k in ("traefik", "router", "calentiq", "observer", "dynamic")):
qs.append("dynamic_conf.yml")
if any(k in low for k in ("compose", "swarm", "docker")):
qs.append("docker-compose.core.yml")
if "portainer" in low:
qs.append("docker-compose.portainer.yml")
if "grafana" in low:
qs.append("grafana")
if "prometheus" in low:
qs.append("prometheus.yml")
if not qs:
qs.append("dynamic_conf.yml")
# unique preserve order
out: list[str] = []
for q in qs:
if q not in out:
out.append(q)
return out[:3]
def enrich_plan_from_discovery_tools(
plan: dict[str, Any] | None,
messages: list[dict[str, Any]] | None,
) -> dict[str, Any] | None:
"""Pull absolute paths from find_path / list_directory tool results into plan."""
if not isinstance(plan, dict):
return plan
# Never overwrite a good deterministic index hit with noisy find parses
if str(plan.get("path_resolve") or "") == "index":
return plan
tasks = plan.get("subtasks") or []
if (
isinstance(tasks, list)
and tasks
and isinstance(tasks[0], dict)
and str(tasks[0].get("path_source") or "") == "index"
):
return plan
found = _paths_from_discovery_messages(messages)
if not found:
return plan
out = dict(plan)
tasks = list(out.get("subtasks") or [])
if not tasks:
out["subtasks"] = [
{
"id": "1",
"prompt": "edit",
"worker_tier": "medium_code",
"paths": found[:3],
"path_source": "find",
}
]
else:
fixed = []
for item in tasks:
if not isinstance(item, dict):
fixed.append(item)
continue
it = dict(item)
it["paths"] = found[:3]
it["path_source"] = "find"
fixed.append(it)
out["subtasks"] = fixed
out["path_resolve"] = "find"
out["path_resolve_queries"] = []
log.info("path_resolve find → %s", found[:3])
return out
def _is_workspace_path(path: str) -> bool:
"""Accept only real EventHub project paths — never /etc or null:/etc artifacts."""
key = _norm(path)
if "eventhubdevops" not in key and "eventhub" not in key:
return False
if "\\etc\\" in key or key.startswith("etc\\"):
return False
if "\\nginx\\" in key:
return False
# Reject 1-letter drive artifacts from YAML like null:/etc → l:\etc
if re.match(r"^[a-z]:\\etc\\", key):
return False
base = key.rsplit("\\", 1)[-1]
if base in ("traefik.yml", "traefik.yaml", "docker-compose.yml"):
if "\\ift\\" not in key and "\\stage\\" not in key:
return False
return True
def _paths_from_discovery_messages(
messages: list[dict[str, Any]] | None,
) -> list[str]:
"""Extract existing file paths from find/list tool results only."""
call_ids: set[str] = set()
for msg in messages or []:
if not isinstance(msg, dict) or msg.get("role") != "assistant":
continue
for tc in msg.get("tool_calls") or []:
if not isinstance(tc, dict):
continue
name = str((tc.get("function") or {}).get("name") or "").lower()
if not any(k in name for k in ("find", "list", "search", "grep", "glob")):
continue
# Do not treat read_file / edit_file as discovery
if any(k in name for k in ("read", "edit", "write", "create", "delete")):
continue
cid = str(tc.get("id") or "")
if cid:
call_ids.add(cid)
# No discovery tool calls → do not scrape read_file bodies (YAML has /etc/… paths)
if not call_ids:
return []
paths: list[str] = []
seen: set[str] = set()
for msg in messages or []:
if not isinstance(msg, dict) or msg.get("role") != "tool":
continue
if str(msg.get("tool_call_id") or "") not in call_ids:
continue
content = str(msg.get("content") or "")
low = content.lower()
if "not found" in low and len(content) < 200:
continue
for m in re.finditer(
r"([A-Za-z]:[\\/][^\s\"'<>\]\n\r]+\.(?:yml|yaml|toml|md|json|conf))",
content,
):
p = m.group(1).replace("/", "\\")
if not _is_workspace_path(p):
continue
nk = _norm(p)
if nk in seen:
continue
seen.add(nk)
paths.append(p)
# Also accept plain relative hits that Zed returns under EventHubDevOps
for m in re.finditer(
r"((?:EventHubDevOps[\\/](?:ift|stage)[\\/][^\s\"'<>\]\n\r]+\.(?:yml|yaml)))",
content,
re.I,
):
p = _join_under(
_DEFAULT_DEVOPS_ROOT,
m.group(1).split("EventHubDevOps", 1)[-1].lstrip("\\/"),
)
if not _is_workspace_path(p):
continue
nk = _norm(p)
if nk in seen:
continue
seen.add(nk)
paths.append(p)
return paths
def discovery_tools_only(tools: list[Any], *, max_tools: int = 3) -> list[Any]:
prefer_keys = ("find_path", "find", "list_directory", "list_dir", "grep", "search", "glob")
ranked: list[Any] = []
for key in prefer_keys:
for t in tools or []:
if not isinstance(t, dict):
continue
fn = t.get("function") if isinstance(t.get("function"), dict) else {}
name = str(fn.get("name") or t.get("name") or "").lower()
if key in name and t not in ranked:
ranked.append(t)
if len(ranked) >= max_tools:
break
return ranked[:max_tools]
def pick_path_resolve_model(cfg: dict[str, Any]) -> str:
"""Cheap model for find_* only — allow a-simple (unlike edit executor)."""
return str(cfg.get("path_resolve_model") or "a-simple")
def path_resolve_needed(plan: dict[str, Any] | None) -> bool:
if not isinstance(plan, dict):
return False
qs = plan.get("path_resolve_queries") or []
if qs:
return True
return str(plan.get("path_resolve") or "") == "needed"
def force_find_path_kickstart(
*,
model: str,
tools: list[Any],
queries: list[str],
) -> dict[str, Any]:
"""Synthetic find_path so Zed searches instead of inventing read_file."""
from agent_hier import _synthetic_tool_completion
tool_name = "find_path"
for t in tools or []:
if not isinstance(t, dict):
continue
fn = t.get("function") if isinstance(t.get("function"), dict) else {}
name = str(fn.get("name") or "")
low = name.lower()
if "find" in low or "glob" in low or ("list" in low and "dir" in low):
tool_name = name
break
q = (queries[0] if queries else "dynamic_conf.yml").strip()
args: dict[str, Any]
if "list" in tool_name.lower():
args = {
"path": _join_under(_DEFAULT_DEVOPS_ROOT, r"ift\traefik"),
}
else:
args = {"query": q, "path": _DEFAULT_DEVOPS_ROOT}
# Zed variants
args["glob"] = f"**/{q}" if "." in q else f"**/*{q}*"
return _synthetic_tool_completion(
model=model, tool_name=tool_name, arguments=args
)
+201
View File
@@ -0,0 +1,201 @@
"""Compact, readable progress lines for Zed agent chat."""
from __future__ import annotations
import re
from typing import Any
_MODEL_LABEL = {
"novita-planner": "Max",
"novita-verifier": "verify",
"a-medium-code": "Coder",
"a-simple": "Llama",
"b-complex": "DeepSeek",
"c-complex": "Max",
"approved-plan": "план",
}
_NOISE = (
"ждём LiteLLM",
"payload:",
"ctx ",
"ctx-",
"worker #",
"волна ",
"параллельно",
"bytes≈",
"upstream_stream",
"non-stream",
)
def model_label(model: str | None) -> str:
if not model:
return "модель"
m = str(model)
return _MODEL_LABEL.get(m, m.split("/")[-1][:18])
def short_path(path: str | None, *, max_parts: int = 3) -> str:
if not path:
return ""
p = str(path).replace("/", "\\").rstrip("\\")
parts = [x for x in p.split("\\") if x]
if len(parts) <= max_parts:
return "\\".join(parts)
return "\\".join(parts[-max_parts:])
def plan_paths(plan: dict[str, Any] | None) -> list[str]:
out: list[str] = []
for item in (plan or {}).get("subtasks") or []:
if not isinstance(item, dict):
continue
for p in item.get("paths") or []:
if isinstance(p, str) and p.strip() and p not in out:
out.append(p.strip())
return out
def is_noise(line: str) -> bool:
s = (line or "").strip()
if not s:
return True
low = s.lower()
return any(n.lower() in low for n in _NOISE)
def humanize_line(line: str) -> str | None:
"""Map internal progress → short Russian status. None = drop."""
s = (line or "").strip()
if not s or is_noise(s):
return None
low = s.lower()
if s in ("план…", "план...", "план"):
return "Планирую задачу"
if "план через" in low or low.startswith("hierarchical:"):
return "Планирую задачу"
if "утверждён" in low or "утвержден" in low:
m = re.search(r"(\d+)\s*подзадач", s)
n = m.group(1) if m else ""
return f"План утверждён{f' · {n} шаг(а)' if n else ''}"
if "отменён" in low or "отменен" in low:
return "План отменён"
if "правки от пользователя" in low or "пересборк" in low:
return "Пересобираю план по правкам"
if "ожидает утверждения" in low or "ждём утверждения" in low or "awaiting" in low:
return "План готов — подтверди («ok»)"
if "agent: план готов" in low or "executor с tools" in low:
return "Перехожу к выполнению"
if "готово за" in low:
return "Готово"
if "json битый" in low or "repair" in low:
return "Чиню формат плана"
if low.startswith("path_index") or "path_index hit" in low:
return None # shown via dedicated path line
if "path_resolve" in low or "поиск файл" in low:
return "Ищу файлы в проекте"
if "runtime_probe" in low or "runtime:" in low:
return "Проверяю runtime"
if "force edit" in low:
return "Правлю файл"
if "kickstart" in low:
return "Читаю файл"
if "escalate" in low or "mid-loop" in low:
return None
if "408" in s or "timeout" in low:
return "Таймаут модели — продолжаю иначе"
if "400" in s and "bad" in low:
return None
# strip technical prefixes
s = re.sub(r"^план:\s*", "", s, flags=re.I)
s = re.sub(r"^agent:\s*", "", s, flags=re.I)
if len(s) > 90:
s = s[:87] + ""
return s
def format_progress_block(lines: list[str]) -> str:
"""Final/static progress block — numbered, no junk."""
steps: list[str] = []
seen: set[str] = set()
for raw in lines or []:
h = humanize_line(str(raw))
if not h or h in seen:
continue
seen.add(h)
steps.append(h)
if not steps:
return ""
body = "\n".join(f"{i}. {t}" for i, t in enumerate(steps, 1))
return f"**Ход**\n{body}\n\n---\n\n"
def stream_header() -> str:
return "**Agent**\n"
def stream_step(text: str) -> str:
return f"· {text}\n"
def execution_banner(
*,
path_mode: bool,
plan: dict[str, Any] | None,
model: str | None,
) -> str:
"""Shown once after plan approve — what happens next."""
paths = plan_paths(plan)
lines = ["", "---", "", "**Выполнение**"]
facts = (plan or {}).get("runtime_facts") if isinstance(plan, dict) else None
step = 1
if isinstance(facts, dict) and (facts.get("host") or facts.get("hint")):
host = facts.get("host") or "?"
status = facts.get("http_status")
suggest = facts.get("suggested_backend_url")
bit = f"{host}"
if status is not None:
bit += f" → HTTP {status}"
if suggest:
bit += f" · fix `{suggest}`"
lines.append(f"{step}. Runtime: {bit}")
step += 1
if paths:
shown = ", ".join(f"`{short_path(p)}`" for p in paths[:2])
src = ""
tasks = (plan or {}).get("subtasks") or []
if tasks and isinstance(tasks[0], dict):
ps = str(tasks[0].get("path_source") or "")
if ps == "index":
src = " · индекс"
elif ps == "find":
src = " · поиск"
lines.append(f"{step}. Файл{src}: {shown}")
step += 1
if path_mode:
lines.append(f"{step}. Уточняю путь в репозитории…")
else:
lines.append(f"{step}. Читаю → правлю")
elif path_mode:
lines.append(f"{step}. Ищу нужные файлы…")
else:
lines.append(f"{step}. Запуск ({model_label(model)})")
lines.append("")
return "\n".join(lines) + "\n"
def tool_status(tool_names: list[str] | None, *, path: str | None = None) -> str:
names = [str(n).lower() for n in (tool_names or []) if n]
if any("edit" in n or "write" in n for n in names):
base = "Правлю"
elif any("read" in n for n in names):
base = "Читаю"
elif any("find" in n or "list" in n or "grep" in n for n in names):
base = "Ищу"
else:
base = "Инструмент"
if path:
return stream_step(f"{base} `{short_path(path)}`")
return stream_step(base)
+1317 -20
View File
File diff suppressed because it is too large Load Diff
+348
View File
@@ -0,0 +1,348 @@
"""DevOps runtime probe: gather live facts before editing Traefik/compose."""
from __future__ import annotations
import json
import logging
import re
import socket
import subprocess
import urllib.error
import urllib.request
from typing import Any
log = logging.getLogger("ai-router")
_HOST_RE = re.compile(
r"\b([a-z0-9-]+(?:\.(?:ift|stage))?\.calentiq\.com)\b",
re.I,
)
# hostname pattern → docker DNS name + candidate ports
_SERVICE_MAP: list[tuple[str, str, tuple[int, ...]]] = [
(r"^observer\.", "observer_web", (80, 4000, 8080, 3000)),
(r"^grafana\.", "grafana", (3000, 80)),
(r"^prometheus\.", "prometheus", (9090, 80)),
(r"^portainer\.", "portainer", (9000, 9443, 80)),
(r"^ai-router\.", "ai-router", (8000, 80)),
(r"^litellm\.", "litellm", (4000, 80)),
(r"^logs\.|^loglynx\.", "loglynx", (6123, 80)),
(r"^kuma\.", "uptime-kuma", (3001, 80)),
]
def extract_hosts(*texts: str) -> list[str]:
found: list[str] = []
seen: set[str] = set()
for text in texts:
if not text:
continue
for m in _HOST_RE.finditer(text):
h = m.group(1).lower()
if h not in seen:
seen.add(h)
found.append(h)
return found
def is_devops_request(plan: dict[str, Any] | None, *extra: str) -> bool:
blob = json.dumps(plan or {}, ensure_ascii=False).lower()
for t in extra:
blob += "\n" + str(t or "").lower()
keys = (
"calentiq",
"traefik",
"bad gateway",
"502",
"504",
"eventhubdevops",
"docker-compose",
"swarm",
"observer",
"ift.",
"stage.",
"devops",
"gateway",
)
if any(k in blob for k in keys):
return True
return bool(extract_hosts(blob))
def service_for_host(host: str) -> tuple[str, tuple[int, ...]]:
h = host.lower()
for pat, name, ports in _SERVICE_MAP:
if re.search(pat, h):
return name, ports
# fallback: first label as service guess
label = h.split(".")[0]
return label.replace("-", "_"), (80, 443, 8080, 3000, 4000)
def _tcp_open(host: str, port: int, timeout: float = 2.0) -> bool:
try:
with socket.create_connection((host, port), timeout=timeout):
return True
except OSError:
return False
def _http_status(url: str, timeout: float = 8.0) -> int | None:
try:
req = urllib.request.Request(url, method="GET")
with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310
return int(getattr(resp, "status", 200) or 200)
except urllib.error.HTTPError as exc:
return int(exc.code)
except Exception: # noqa: BLE001
return None
def probe_host(host: str, *, cfg: dict[str, Any] | None = None) -> dict[str, Any]:
"""Live probe from AiRouter container (same Docker network as services)."""
cfg = cfg or {}
timeout = float(cfg.get("runtime_probe_timeout_sec") or 8)
service, ports = service_for_host(host)
https = f"https://{host}/"
http = f"http://{host}/"
status = _http_status(https, timeout=timeout)
if status is None:
status = _http_status(http, timeout=timeout)
port_hits: dict[str, bool] = {}
open_ports: list[int] = []
for p in ports:
ok = _tcp_open(service, p, timeout=min(2.0, timeout))
port_hits[f"{service}:{p}"] = ok
if ok:
open_ports.append(p)
closed_preferred = not port_hits.get(f"{service}:80", True) and bool(open_ports)
hint = ""
suggested_url = None
if status in (502, 503, 504) and open_ports:
# Classic misconfig: Traefik → :80 while app on :4000
if 80 not in open_ports and open_ports:
suggested_url = f"http://{service}:{open_ports[0]}"
hint = (
f"HTTP {status}: Traefik likely targets a closed port. "
f"{service}:80 open={port_hits.get(f'{service}:80')}; "
f"open ports={open_ports}. Prefer {suggested_url} in dynamic_conf.yml."
)
else:
hint = (
f"HTTP {status} but {service} has open ports {open_ports or 'none'}. "
"Check container health / upstream path."
)
elif status in (502, 503, 504):
hint = (
f"HTTP {status}: no open TCP on guessed service `{service}` "
f"ports {list(ports)}. Service down or wrong DNS name."
)
elif status and status < 400:
hint = f"HTTP {status}: host responds; issue may be app-level."
else:
hint = f"HTTP status={status}; ports={port_hits}"
facts: dict[str, Any] = {
"host": host,
"http_status": status,
"service": service,
"ports": port_hits,
"open_ports": open_ports,
"suggested_backend_url": suggested_url,
"hint": hint,
"source": "gateway_tcp",
"closed_port_80_but_alt_open": closed_preferred,
}
log.info(
"runtime_probe host=%s status=%s service=%s open=%s suggest=%s",
host,
status,
service,
open_ports,
suggested_url,
)
return facts
def attach_runtime_probe(
plan: dict[str, Any] | None,
*,
user_text: str = "",
cfg: dict[str, Any] | None = None,
) -> dict[str, Any] | None:
"""If DevOps task — probe hosts and attach runtime_facts to plan."""
if not isinstance(plan, dict):
return plan
cfg = cfg or {}
if not cfg.get("runtime_probe_enabled", True):
return plan
if plan.get("runtime_facts"):
return plan
if not is_devops_request(plan, user_text):
return plan
out = dict(plan)
hosts = extract_hosts(
user_text,
json.dumps(plan, ensure_ascii=False),
str(plan.get("user_goal") or ""),
)
if not hosts:
# DevOps without host — still mark needed for terminal phase
out["runtime_probe"] = "needed"
out["runtime_facts"] = {
"hint": "DevOps task without clear host — curl/docker inspect before edit.",
"source": "none",
}
return out
facts_list = [probe_host(h, cfg=cfg) for h in hosts[:3]]
primary = facts_list[0]
out["runtime_facts"] = primary
out["runtime_facts_all"] = facts_list
out["runtime_probe"] = "gateway"
# Strengthen paths toward Traefik dynamic conf for ift hosts
if "ift." in primary.get("host", "") or primary.get("suggested_backend_url"):
root = r"C:\Users\alexc\IdeaProjects\eventHub\EventHubDevOps"
yml = root + r"\ift\traefik\dynamic_conf.yml"
tasks = list(out.get("subtasks") or [])
if tasks and isinstance(tasks[0], dict):
t0 = dict(tasks[0])
paths = list(t0.get("paths") or [])
if yml not in paths:
paths = [yml] + paths
t0["paths"] = paths[:4]
if primary.get("suggested_backend_url"):
t0["edit_goal"] = (
f"Set Traefik service backend to {primary['suggested_backend_url']} "
f"(HTTP {primary.get('http_status')}: wrong upstream port)."
)
tasks[0] = t0
out["subtasks"] = tasks
return out
def runtime_facts_line(plan: dict[str, Any] | None) -> str:
facts = (plan or {}).get("runtime_facts") if isinstance(plan, dict) else None
if not isinstance(facts, dict) or not facts:
return ""
parts = [
f"host={facts.get('host')}",
f"http={facts.get('http_status')}",
f"service={facts.get('service')}",
]
if facts.get("open_ports"):
parts.append(f"open={facts.get('open_ports')}")
if facts.get("suggested_backend_url"):
parts.append(f"fix→{facts.get('suggested_backend_url')}")
hint = str(facts.get("hint") or "")[:180]
return "RUNTIME: " + "; ".join(str(p) for p in parts) + (f" | {hint}" if hint else "")
def devops_blocks_blind_force_edit(plan: dict[str, Any] | None) -> bool:
"""Do not blind-edit DevOps YAML until runtime facts exist."""
if not isinstance(plan, dict):
return False
if not is_devops_request(plan):
return False
facts = plan.get("runtime_facts")
if not facts:
return True
# Facts present — allow targeted edit (including force with goal from facts)
return False
def suggested_traefik_edit(
plan: dict[str, Any] | None,
) -> dict[str, str] | None:
"""If probe found closed:80 + open:alt — return old/new URL snippets."""
if not isinstance(plan, dict):
return None
facts = plan.get("runtime_facts")
if not isinstance(facts, dict):
return None
url = facts.get("suggested_backend_url")
service = facts.get("service")
if not url or not service:
return None
# Common misconfig in our dynamic_conf
old = f'url: "http://{service}:80"'
new = f'url: "{url}"'
if old == new:
return None
return {
"path": r"C:\Users\alexc\IdeaProjects\eventHub\EventHubDevOps\ift\traefik\dynamic_conf.yml",
"old_text": old,
"new_text": new,
"reason": str(facts.get("hint") or "port mismatch"),
}
def force_runtime_fix_edit(
*,
plan: dict[str, Any] | None,
tools: list[Any],
model: str,
) -> dict[str, Any] | None:
"""Synthetic edit_file for clear Traefik port mismatch — skip blind marker edits."""
fix = suggested_traefik_edit(plan)
if not fix:
return None
# late import to avoid cycles
from agent_hier import _pick_edit_tool, _synthetic_tool_completion
edit_name = _pick_edit_tool(tools)
if not edit_name:
return None
log.info(
"runtime_probe → targeted edit %s => %s",
fix["old_text"],
fix["new_text"],
)
return _synthetic_tool_completion(
model=model,
tool_name=edit_name,
arguments={
"path": fix["path"],
"edits": [{"old_text": fix["old_text"], "new_text": fix["new_text"]}],
},
)
def ssh_probe_fallback(host: str, cfg: dict[str, Any]) -> dict[str, Any] | None:
"""Optional: ssh to host and docker inspect (if gateway has keys)."""
ssh_host = str(cfg.get("runtime_probe_ssh_host") or "").strip()
if not ssh_host:
return None
service, _ports = service_for_host(host)
script = (
f"echo HTTP=$(curl -sk -o /dev/null -w '%{{http_code}}' --max-time 5 https://{host}/ || echo err); "
f"CID=$(docker ps -q --filter name={service} | head -1); "
f"echo CID=$CID; "
f"if [ -n \"$CID\" ]; then docker inspect \"$CID\" --format 'PORTS={{{{json .NetworkSettings.Ports}}}}'; fi"
)
try:
proc = subprocess.run(
[
"ssh",
"-o",
"BatchMode=yes",
"-o",
"ConnectTimeout=5",
ssh_host,
"bash",
"-lc",
script,
],
capture_output=True,
text=True,
timeout=20,
check=False,
)
out = (proc.stdout or "") + "\n" + (proc.stderr or "")
return {"host": host, "ssh_raw": out[:1500], "source": "ssh", "service": service}
except Exception as exc: # noqa: BLE001
log.warning("runtime ssh probe failed: %s", exc)
return None