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

319 lines
11 KiB
Python

"""Lane orchestration A/B/C with Redis session context."""
from __future__ import annotations
import hashlib
import json
import logging
import os
import re
from dataclasses import dataclass, field
from enum import Enum
from typing import Any
from classifier_llm import LlmClassifier
from rules_loader import load_model_matrix, load_orchestration, load_routing_rules
log = logging.getLogger("orchestrator")
LANE_PREFIX = {"A": "a", "B": "b", "C": "c"}
TIER_SUFFIX = {
"SIMPLE": "simple",
"MEDIUM_OPS": "medium-ops",
"MEDIUM_CODE": "medium-code",
"COMPLEX": "complex",
"REASONING": "reasoning",
"VISION_OCR": "vision-ocr",
"VISION_UI": "vision",
}
class Tier(str, Enum):
SIMPLE = "SIMPLE"
MEDIUM_OPS = "MEDIUM_OPS"
MEDIUM_CODE = "MEDIUM_CODE"
COMPLEX = "COMPLEX"
REASONING = "REASONING"
VISION_OCR = "VISION_OCR"
VISION_UI = "VISION_UI"
@dataclass
class SessionCtx:
escalation_level: int = 0
last_prompt_hash: str = ""
turn_count: int = 0
budget_pct: float = 0.0
@dataclass
class RouteDecision:
tier: Tier
lane: str
model: str
quality_mode: str
escalation_level: int
confidence: float
delegated_internal: bool = False
classifier_source: str = "heuristic"
class SessionStore:
def __init__(self, redis_url: str | None) -> None:
self._redis = None
if redis_url:
try:
import redis
self._redis = redis.from_url(redis_url, decode_responses=True)
self._redis.ping()
except Exception as exc: # noqa: BLE001
log.warning("Redis unavailable (%s), using in-memory sessions", exc)
self._memory: dict[str, SessionCtx] = {}
orch = load_orchestration()
self._prefix = orch.get("redis", {}).get("key_prefix", "ai-router:session:")
self._ttl = int(orch.get("redis", {}).get("ttl_sec", 1800))
def get(self, session_id: str) -> SessionCtx:
if not session_id:
return SessionCtx()
key = f"{self._prefix}{session_id}"
if self._redis:
raw = self._redis.get(key)
if raw:
data = json.loads(raw)
return SessionCtx(**data)
return SessionCtx()
return self._memory.get(session_id, SessionCtx())
def save(self, session_id: str, ctx: SessionCtx) -> None:
if not session_id:
return
key = f"{self._prefix}{session_id}"
payload = json.dumps(
{
"escalation_level": ctx.escalation_level,
"last_prompt_hash": ctx.last_prompt_hash,
"turn_count": ctx.turn_count,
"budget_pct": ctx.budget_pct,
}
)
if self._redis:
self._redis.setex(key, self._ttl, payload)
else:
self._memory[session_id] = ctx
class Classifier:
def __init__(self) -> None:
rules = load_routing_rules()
gw = rules.get("gateway", {})
self._simple_re = re.compile(
gw.get("simple_patterns", [r"^(hello)\\b"])[0],
re.IGNORECASE,
)
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", {})
self._simple_max = int(wt.get("simple_max_words", 12))
self._simple_q_max = int(wt.get("simple_question_max_words", 25))
self._complex_min = int(wt.get("complex_min_words", 400))
self._low_conf = float(rules.get("gateway", {}).get("confidence", {}).get("low_threshold", 0.6))
self._code_block = re.compile(r"```[\s\S]*?```|`[^`]+`")
@staticmethod
def _kw_re(keywords: list[str]) -> re.Pattern[str]:
if not keywords:
return re.compile(r"(?!x)x")
escaped = [re.escape(k) for k in keywords]
return re.compile("|".join(escaped), re.IGNORECASE)
def wants_escalation(self, text: str, prompt_hash: str, prev_hash: str) -> bool:
if self._escalation.search(text):
return True
return bool(prompt_hash and prompt_hash == prev_hash)
def classify(
self,
messages: list[dict[str, Any]],
*,
has_image: bool,
text: str,
) -> tuple[Tier, float]:
if has_image:
if self._ocr.search(text) or len(text.split()) < 30:
return Tier.VISION_OCR, 0.95
return Tier.VISION_UI, 0.9
text = text.strip()
if not text:
return Tier.SIMPLE, 0.9
words = len(text.split())
if words <= self._simple_max and self._simple_re.search(text):
return Tier.SIMPLE, 0.92
if self._reasoning.search(text):
return Tier.REASONING, 0.88
if self._complex.search(text):
return Tier.COMPLEX, 0.88
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
if words <= self._simple_q_max and "?" in text and not self._code_block.search(text):
return Tier.SIMPLE, 0.75
if words >= self._complex_min:
return Tier.COMPLEX, 0.7
return Tier.MEDIUM_OPS, 0.55
class Orchestrator:
def __init__(self, session_store: SessionStore) -> None:
self.sessions = session_store
self.classifier = Classifier()
self.llm_classifier = LlmClassifier()
self._orch = load_orchestration()
self._matrix = load_model_matrix()
self._models: dict[str, dict] = self._matrix.get("models", {})
self._no_esc = {
name for name, cfg in self._models.items() if cfg.get("no_escalation")
}
self.default_quality = os.environ.get(
"DEFAULT_QUALITY_MODE",
self._orch.get("default_quality_mode", "auto"),
)
def _lane_from_mode(self, quality_mode: str) -> str | None:
if quality_mode == "auto":
return None
return self._orch.get("quality_mode_map", {}).get(quality_mode)
def _start_lane(self, tier: Tier) -> str:
return self._orch.get("start_lanes", {}).get(tier.value, "A")
def _bump_lane(self, lane: str, levels: int) -> str:
order: list[str] = self._orch.get("lane_order", ["A", "B", "C"])
try:
idx = order.index(lane)
except ValueError:
return lane
return order[min(idx + levels, len(order) - 1)]
def _cap_lane(self, lane: str, budget_pct: float) -> str:
caps = self._orch.get("budget_caps", {})
hard = float(caps.get("hard_pct", 95))
warn = float(caps.get("warn_pct", 80))
if budget_pct >= hard:
return caps.get("hard_max_lane", "A")
if budget_pct >= warn:
max_lane = caps.get("warn_max_lane", "B")
order = self._orch.get("lane_order", ["A", "B", "C"])
if order.index(lane) > order.index(max_lane):
return max_lane
return lane
def _model_name(self, lane: str, tier: Tier) -> str:
prefix = LANE_PREFIX.get(lane, "a")
suffix = TIER_SUFFIX[tier]
return f"{prefix}-{suffix}"
async def resolve(
self,
messages: list[dict[str, Any]],
*,
quality_mode: str | None = None,
session_id: str = "",
text: str = "",
has_image: bool = False,
token_estimate: int = 0,
) -> RouteDecision:
mode = quality_mode or self.default_quality
tier, confidence = self.classifier.classify(messages, has_image=has_image, text=text)
classifier_source = "heuristic"
if self.llm_classifier.should_use_llm(tier.value, confidence, has_image):
llm_result = await self.llm_classifier.classify(text)
if llm_result:
tier = Tier(llm_result[0])
confidence = llm_result[1]
classifier_source = "gigachat"
elif self.llm_classifier.mode == "llm":
classifier_source = "heuristic_fallback"
ctx = self.sessions.get(session_id)
prompt_hash = hashlib.sha256(text.encode()).hexdigest()[:16]
if mode != "auto":
lane = self._lane_from_mode(mode) or "A"
escalation = 0
else:
lane = self._start_lane(tier)
escalation = ctx.escalation_level
if tier != Tier.VISION_OCR and self.classifier.wants_escalation(
text, prompt_hash, ctx.last_prompt_hash
):
escalation += 1
min_tokens = int(
self._orch.get("escalation", {}).get("context_tokens_min_lane_b", 32000)
)
if token_estimate > min_tokens:
order = self._orch.get("lane_order", ["A", "B", "C"])
if order.index(lane) < order.index("B"):
lane = "B"
lane = self._bump_lane(lane, escalation)
lane = self._cap_lane(lane, ctx.budget_pct)
model = self._model_name(lane, tier)
if model not in self._models:
log.warning("model %s missing from matrix, fallback smart-router-internal", model)
return RouteDecision(
tier=tier,
lane=lane,
model="smart-router-internal",
quality_mode=mode,
escalation_level=escalation,
confidence=confidence,
delegated_internal=True,
classifier_source=classifier_source,
)
delegated = (
classifier_source == "heuristic"
and confidence < self.classifier._low_conf
and tier in (Tier.MEDIUM_OPS, Tier.SIMPLE)
)
return RouteDecision(
tier=tier,
lane=lane,
model="smart-router-internal" if delegated else model,
quality_mode=mode,
escalation_level=escalation,
confidence=confidence,
delegated_internal=delegated,
classifier_source=classifier_source,
)
def after_request(
self,
session_id: str,
*,
prompt_hash: str,
success: bool,
escalate: bool,
) -> None:
if not session_id:
return
ctx = self.sessions.get(session_id)
ctx.last_prompt_hash = prompt_hash
ctx.turn_count += 1
if success and not escalate:
ctx.escalation_level = 0
elif escalate:
ctx.escalation_level = min(ctx.escalation_level + 1, 2)
self.sessions.save(session_id, ctx)