feat(routing): ordered backend candidates + real-probing doctor
- backends is now an ordered candidate list (first = preferred); channels report the backend actually serving via active_backend, surfaced in the doctor text report and --json - new agent_reach/probe.py really executes upstream commands and tells apart missing / broken (stale venv shebang after a system Python upgrade) / timeout, with a reinstall prescription for broken installs - all 13 channels migrated off which()-only checks: fixes bilibili false-positive "bili-cli 可用" on broken shims, misleading xiaohongshu "连接失败", rdt OSError crashing doctor, mcporter breakage masquerading as "未配置" - twitter: 15s probe + 1 retry (flaky 10s timeout), broken twitter-cli now falls back to bird instead of aborting the check - doctor survives per-channel exceptions; config supports per-channel backend override (<channel>_backend / <CHANNEL>_BACKEND env) - fix skill install/uninstall crash on symlinked skill dirs (the "[Errno None] None" warning from shutil.rmtree on a symlink) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -8,11 +8,22 @@ and provides:
|
||||
- check(config) → is the upstream tool installed and configured?
|
||||
|
||||
After installation, agents call upstream tools directly.
|
||||
|
||||
Backend routing semantics:
|
||||
- `backends` is an ORDERED candidate list: backends[0] is the preferred
|
||||
backend, the rest are fallbacks. "Switching backends" for a platform
|
||||
means reordering this list (or a user override) — not rewriting code.
|
||||
- check() must set `self.active_backend` to the backend that is actually
|
||||
serving the channel right now (None when nothing usable is found).
|
||||
shutil.which() alone is NOT proof of health — a stale venv shim passes
|
||||
which() but cannot execute (see agent_reach.probe). Channels should
|
||||
really execute a lightweight command before claiming a backend active.
|
||||
- Users can force a backend with config key `<channel>_backend`
|
||||
(or env var `<CHANNEL>_BACKEND`); ordered_backends() applies it.
|
||||
"""
|
||||
|
||||
import shutil
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import List, Tuple
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
|
||||
class Channel(ABC):
|
||||
@@ -20,17 +31,40 @@ class Channel(ABC):
|
||||
|
||||
name: str = "" # e.g. "youtube"
|
||||
description: str = "" # e.g. "YouTube 视频和字幕"
|
||||
backends: List[str] = [] # e.g. ["yt-dlp"] — what upstream tool is used
|
||||
backends: List[str] = [] # ordered candidates — backends[0] = preferred
|
||||
tier: int = 0 # 0=zero-config, 1=needs free key, 2=needs setup
|
||||
|
||||
#: Backend currently serving this channel; set by check(), None = unavailable.
|
||||
active_backend: Optional[str] = None
|
||||
|
||||
@abstractmethod
|
||||
def can_handle(self, url: str) -> bool:
|
||||
"""Check if this channel can handle this URL."""
|
||||
...
|
||||
|
||||
def ordered_backends(self, config=None) -> List[str]:
|
||||
"""Candidate backends in probe order, honoring the user override.
|
||||
|
||||
The config key `<channel>_backend` (env `<CHANNEL>_BACKEND`) moves the
|
||||
named backend to the front of the list; unknown values are ignored so
|
||||
a stale override can never hide working backends.
|
||||
"""
|
||||
candidates = list(self.backends)
|
||||
override = config.get(f"{self.name}_backend") if config else None
|
||||
if override:
|
||||
for i, b in enumerate(candidates):
|
||||
if b == override or b.startswith(override):
|
||||
candidates.insert(0, candidates.pop(i))
|
||||
break
|
||||
return candidates
|
||||
|
||||
def check(self, config=None) -> Tuple[str, str]:
|
||||
"""
|
||||
Check if this channel's upstream tool is available.
|
||||
Returns (status, message) where status is 'ok'/'warn'/'off'/'error'.
|
||||
|
||||
Subclasses with external backends must really probe them (see
|
||||
agent_reach.probe.probe_command) and set self.active_backend.
|
||||
"""
|
||||
self.active_backend = self.backends[0] if self.backends else "内置"
|
||||
return "ok", f"{'、'.join(self.backends) if self.backends else '内置'}"
|
||||
|
||||
Reference in New Issue
Block a user