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.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
Reference in New Issue
Block a user