refactor(agent/misc-g6): dedupe qwen prep, image-part checks, pricing normalizers; tighten provider ABC/registry modules
This commit is contained in:
+15
-37
@@ -1,23 +1,11 @@
|
||||
"""
|
||||
Text-to-Speech Provider ABC
|
||||
============================
|
||||
"""Text-to-Speech Provider ABC.
|
||||
|
||||
Pluggable-backend interface for TTS synthesis. Providers register via
|
||||
``PluginContext.register_tts_provider()``; the one named by ``tts.provider``
|
||||
services ``text_to_speech`` **only when that name is neither a built-in nor a
|
||||
``tts.providers.<name>: type: command`` entry**. Resolution order:
|
||||
|
||||
1. Built-in providers (``BUILTIN_TTS_PROVIDERS`` in :mod:`tools.tts_tool`) —
|
||||
always win; :func:`agent.tts_registry.register_provider` rejects colliding
|
||||
names and the dispatcher re-checks at dispatch time.
|
||||
2. Command-type providers from ``config.yaml`` — win over a same-name plugin
|
||||
because config is more local than a plugin install.
|
||||
3. Plugin providers (this ABC) — for backends needing a Python SDK, streaming
|
||||
bytes, OAuth refresh, or voice-listing APIs the shell template can't express.
|
||||
|
||||
:meth:`TTSProvider.synthesize` writes audio to ``output_path`` and returns the
|
||||
path; it should raise on failure — the dispatcher converts exceptions into the
|
||||
standard ``{success: False, error: …}`` envelope.
|
||||
Providers register via ``PluginContext.register_tts_provider()`` and service
|
||||
``text_to_speech`` only when ``tts.provider`` names neither a built-in
|
||||
(``BUILTIN_TTS_PROVIDERS`` in :mod:`tools.tts_tool`; the registry rejects
|
||||
colliding names) nor a ``tts.providers.<name>: type: command`` entry (config is
|
||||
more local than a plugin install, so it wins). :meth:`TTSProvider.synthesize`
|
||||
should raise on failure — the dispatcher builds the ``{success: False}`` envelope.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -50,9 +38,7 @@ class TTSProvider(CatalogProviderBase):
|
||||
def default_voice(self) -> Optional[str]:
|
||||
"""Id of the first voice entry, or None if not applicable."""
|
||||
voices = self.list_voices()
|
||||
if voices:
|
||||
return voices[0].get("id")
|
||||
return None
|
||||
return voices[0].get("id") if voices else None
|
||||
|
||||
@abc.abstractmethod
|
||||
def synthesize(
|
||||
@@ -88,10 +74,8 @@ class TTSProvider(CatalogProviderBase):
|
||||
|
||||
def warm(self) -> None:
|
||||
"""Speech output was just turned on; pre-load so the first reply is hot.
|
||||
|
||||
Called from the TTS lease path (Desktop read-aloud / voice conversation)
|
||||
when this is the configured provider. Best-effort; default no-op.
|
||||
"""
|
||||
Called from the TTS lease path when this is the configured provider.
|
||||
Best-effort; default no-op."""
|
||||
|
||||
def release(self) -> None:
|
||||
"""Last speech-output lease released; free resident resources (counterpart
|
||||
@@ -100,11 +84,9 @@ class TTSProvider(CatalogProviderBase):
|
||||
@property
|
||||
def voice_compatible(self) -> bool:
|
||||
"""Whether output suits voice-bubble delivery (mirrors
|
||||
``tts.providers.<name>.voice_compatible``).
|
||||
|
||||
True → the gateway converts to Opus via ffmpeg if needed; False →
|
||||
delivered as a regular audio attachment. Default False (opt in).
|
||||
"""
|
||||
``tts.providers.<name>.voice_compatible``): True → the gateway converts
|
||||
to Opus via ffmpeg if needed; False → regular audio attachment. Default
|
||||
False (opt in)."""
|
||||
return False
|
||||
|
||||
|
||||
@@ -112,9 +94,5 @@ def resolve_output_format(value: Optional[str]) -> str:
|
||||
"""Clamp an output_format to :data:`VALID_OUTPUT_FORMATS`; invalid values
|
||||
coerce to :data:`DEFAULT_OUTPUT_FORMAT` so the tool surface forgives agent
|
||||
mistakes instead of rejecting them."""
|
||||
if not isinstance(value, str):
|
||||
return DEFAULT_OUTPUT_FORMAT
|
||||
v = value.strip().lower()
|
||||
if v in VALID_OUTPUT_FORMATS:
|
||||
return v
|
||||
return DEFAULT_OUTPUT_FORMAT
|
||||
v = value.strip().lower() if isinstance(value, str) else None
|
||||
return v if v in VALID_OUTPUT_FORMATS else DEFAULT_OUTPUT_FORMAT
|
||||
|
||||
+12
-31
@@ -1,21 +1,11 @@
|
||||
"""
|
||||
TTS Provider Registry
|
||||
=====================
|
||||
"""TTS Provider Registry.
|
||||
|
||||
Central map of registered TTS providers. Populated by plugins at import-time
|
||||
via :meth:`PluginContext.register_tts_provider`; consumed by
|
||||
:mod:`tools.tts_tool` to dispatch ``text_to_speech`` calls to the active
|
||||
plugin backend **when** the configured ``tts.provider`` name is neither a
|
||||
built-in nor a command-type provider.
|
||||
|
||||
Built-ins-always-win: a plugin name colliding with a built-in TTS provider is
|
||||
rejected at registration with a warning (re-checked at dispatch time in
|
||||
:func:`tools.tts_tool._dispatch_to_plugin_provider`).
|
||||
|
||||
Command-providers-win-over-plugins is enforced by the dispatcher, not here:
|
||||
it checks for a same-name ``tts.providers.<name>: type: command`` entry before
|
||||
consulting the registry (a name declared in the user's config.yaml is more
|
||||
specific to their setup than an installed plugin).
|
||||
Registered plugin TTS providers, populated at import-time via
|
||||
:meth:`PluginContext.register_tts_provider` and consulted by :mod:`tools.tts_tool`
|
||||
only when ``tts.provider`` is neither a built-in nor a command-type provider.
|
||||
Built-ins always win: a colliding plugin name is rejected here with a warning
|
||||
(re-checked at dispatch). Command-providers-win-over-plugins is enforced by the
|
||||
dispatcher (a name in the user's config.yaml is more specific than a plugin).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -29,21 +19,12 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Names reserved for native built-in TTS handlers. **Kept in sync with
|
||||
# ``BUILTIN_TTS_PROVIDERS`` in :mod:`tools.tts_tool`** (a regression test in
|
||||
# ``tests/agent/test_tts_registry.py::TestBuiltinSync`` fails on drift);
|
||||
# importing it directly would be a circular import.
|
||||
# ``BUILTIN_TTS_PROVIDERS`` in :mod:`tools.tts_tool`** (``TestBuiltinSync`` in
|
||||
# ``tests/agent/test_tts_registry.py`` fails on drift); importing it directly
|
||||
# would be a circular import.
|
||||
_BUILTIN_NAMES = frozenset({
|
||||
"edge",
|
||||
"elevenlabs",
|
||||
"openai",
|
||||
"minimax",
|
||||
"xai",
|
||||
"mistral",
|
||||
"gemini",
|
||||
"neutts",
|
||||
"kittentts",
|
||||
"piper",
|
||||
"deepinfra",
|
||||
"edge", "elevenlabs", "openai", "minimax", "xai", "mistral", "gemini", "neutts", "kittentts",
|
||||
"piper", "deepinfra",
|
||||
})
|
||||
|
||||
|
||||
|
||||
+56
-77
@@ -391,33 +391,34 @@ def resolve_billing_route(
|
||||
return BillingRoute(provider=provider_name or "unknown", model=bare if model else "", base_url=url, billing_mode="unknown")
|
||||
|
||||
|
||||
def _normalize_bedrock_model_name(model: str) -> str:
|
||||
"""Normalize a Bedrock model id to its bare foundation-model form.
|
||||
_BEDROCK_REGION_PREFIXES = ("global.", "us.", "eu.", "apac.", "ap.", "au.", "jp.", "ca.", "sa.", "me.", "af.")
|
||||
# Bedrock ids end in documented date/revision/profile components (``-20250514-v1:0``).
|
||||
_BEDROCK_TRAILERS = (r":\d+$", r"-v\d+$", r"-\d{8}$")
|
||||
|
||||
Cross-region inference profiles prefix the id with a region scope
|
||||
(``us.``/``global.``/``apac.``/``au.``/...); the pricing table is keyed on
|
||||
the bare ``anthropic.claude-*`` id, so the prefix is stripped. Also maps
|
||||
dotted versions (``4.7`` → ``4-7``) and strips only the documented
|
||||
trailing date/revision/profile components (``-20250514-v1:0``).
|
||||
"""
|
||||
name = model.lower().strip()
|
||||
for prefix in ("global.", "us.", "eu.", "apac.", "ap.", "au.", "jp.", "ca.", "sa.", "me.", "af."):
|
||||
if name.startswith(prefix):
|
||||
name = name[len(prefix):]
|
||||
break
|
||||
name = re.sub(r"(\d+)\.(\d+)", r"\1-\2", name)
|
||||
name = re.sub(r":\d+$", "", name)
|
||||
name = re.sub(r"-v\d+$", "", name)
|
||||
name = re.sub(r"-\d{8}$", "", name)
|
||||
|
||||
def _strip_prefix(name: str, prefixes: tuple[str, ...]) -> str:
|
||||
"""Drop the first matching prefix (at most one), else return ``name`` unchanged."""
|
||||
return next((name[len(p):] for p in prefixes if name.startswith(p)), name)
|
||||
|
||||
|
||||
def _normalize_bedrock_model_name(model: str) -> str:
|
||||
"""Bare foundation-model id: strip the cross-region inference-profile scope
|
||||
(``us.``/``global.``/...), map dotted versions (``4.7`` → ``4-7``), then
|
||||
strip the trailing date/revision/profile components."""
|
||||
name = re.sub(r"(\d+)\.(\d+)", r"\1-\2", _strip_prefix(model.lower().strip(), _BEDROCK_REGION_PREFIXES))
|
||||
for pattern in _BEDROCK_TRAILERS:
|
||||
name = re.sub(pattern, "", name)
|
||||
return name
|
||||
|
||||
|
||||
def _normalize_anthropic_model_name(model: str) -> str:
|
||||
"""Strip an ``anthropic/`` prefix and map dotted versions (4.7 → 4-7)."""
|
||||
name = model.lower().strip()
|
||||
if name.startswith("anthropic/"):
|
||||
name = name[len("anthropic/"):]
|
||||
return re.sub(r"(\d+)\.(\d+)", r"\1-\2", name)
|
||||
return re.sub(r"(\d+)\.(\d+)", r"\1-\2", _strip_prefix(model.lower().strip(), ("anthropic/",)))
|
||||
|
||||
|
||||
# Anthropic dot-notation (opus-4.7) and Bedrock region-prefixed ids need
|
||||
# normalizing before a second lookup.
|
||||
_MODEL_NORMALIZERS = {"anthropic": _normalize_anthropic_model_name, "bedrock": _normalize_bedrock_model_name}
|
||||
|
||||
|
||||
def _lookup_official_docs_pricing(route: BillingRoute) -> Optional[PricingEntry]:
|
||||
@@ -425,18 +426,9 @@ def _lookup_official_docs_pricing(route: BillingRoute) -> Optional[PricingEntry]
|
||||
entry = _OFFICIAL_DOCS_PRICING.get((route.provider, model))
|
||||
if entry:
|
||||
return entry
|
||||
# Anthropic dot-notation (opus-4.7) and Bedrock region-prefixed ids need
|
||||
# normalizing before a second lookup.
|
||||
normalize = {
|
||||
"anthropic": _normalize_anthropic_model_name, "bedrock": _normalize_bedrock_model_name
|
||||
}.get(route.provider)
|
||||
if normalize:
|
||||
normalized = normalize(model)
|
||||
if normalized != model:
|
||||
entry = _OFFICIAL_DOCS_PRICING.get((route.provider, normalized))
|
||||
if entry:
|
||||
return entry
|
||||
return None
|
||||
normalize = _MODEL_NORMALIZERS.get(route.provider)
|
||||
normalized = normalize(model) if normalize else model
|
||||
return _OFFICIAL_DOCS_PRICING.get((route.provider, normalized)) if normalized != model else None
|
||||
|
||||
|
||||
def _openrouter_pricing_entry(route: BillingRoute) -> Optional[PricingEntry]:
|
||||
@@ -453,30 +445,25 @@ def _pricing_entry_from_metadata(
|
||||
if model_id not in metadata:
|
||||
return None
|
||||
pricing = metadata[model_id].get("pricing") or {}
|
||||
prompt = _to_decimal(pricing.get("prompt"))
|
||||
completion = _to_decimal(pricing.get("completion"))
|
||||
request = _to_decimal(pricing.get("request"))
|
||||
cache_read = _to_decimal(
|
||||
pricing.get("cache_read") or pricing.get("cached_prompt") or pricing.get("input_cache_read")
|
||||
)
|
||||
cache_write = _to_decimal(
|
||||
pricing.get("cache_write")
|
||||
or pricing.get("cache_creation")
|
||||
or pricing.get("input_cache_write")
|
||||
)
|
||||
if prompt is None and completion is None and request is None:
|
||||
return None
|
||||
|
||||
def _per_million(value: Optional[Decimal]) -> Optional[Decimal]:
|
||||
def per_million(key: str, *aliases: str) -> Optional[Decimal]:
|
||||
raw = pricing.get(key)
|
||||
for alias in aliases: # alias chain is truthiness-based (``a or b or c``)
|
||||
raw = raw or pricing.get(alias)
|
||||
value = _to_decimal(raw)
|
||||
return None if value is None else value * _ONE_MILLION
|
||||
|
||||
prompt = per_million("prompt")
|
||||
completion = per_million("completion")
|
||||
request = _to_decimal(pricing.get("request"))
|
||||
if prompt is None and completion is None and request is None:
|
||||
return None
|
||||
return PricingEntry(
|
||||
input_cost_per_million=_per_million(prompt),
|
||||
output_cost_per_million=_per_million(completion),
|
||||
cache_read_cost_per_million=_per_million(cache_read),
|
||||
cache_write_cost_per_million=_per_million(cache_write), request_cost=request,
|
||||
source="provider_models_api", source_url=source_url, pricing_version=pricing_version,
|
||||
fetched_at=_UTC_NOW(),
|
||||
input_cost_per_million=prompt, output_cost_per_million=completion,
|
||||
cache_read_cost_per_million=per_million("cache_read", "cached_prompt", "input_cache_read"),
|
||||
cache_write_cost_per_million=per_million("cache_write", "cache_creation", "input_cache_write"),
|
||||
request_cost=request, source="provider_models_api", source_url=source_url,
|
||||
pricing_version=pricing_version, fetched_at=_UTC_NOW(),
|
||||
)
|
||||
|
||||
|
||||
@@ -609,32 +596,24 @@ def estimate_usage_cost(
|
||||
|
||||
# Whole-request context tier (e.g. Gemini Pro >200k prompts): above the
|
||||
# threshold the *_above rates apply to the entire request; None falls back.
|
||||
input_rate = entry.input_cost_per_million
|
||||
output_rate = entry.output_cost_per_million
|
||||
cache_read_rate = entry.cache_read_cost_per_million
|
||||
cache_write_rate = entry.cache_write_cost_per_million
|
||||
if entry.tier_threshold_tokens is not None and usage.prompt_tokens > entry.tier_threshold_tokens:
|
||||
if entry.input_cost_per_million_above is not None:
|
||||
input_rate = entry.input_cost_per_million_above
|
||||
if entry.output_cost_per_million_above is not None:
|
||||
output_rate = entry.output_cost_per_million_above
|
||||
if entry.cache_read_cost_per_million_above is not None:
|
||||
cache_read_rate = entry.cache_read_cost_per_million_above
|
||||
|
||||
if usage.input_tokens and input_rate is None:
|
||||
return _unknown_cost(entry.source)
|
||||
if usage.output_tokens and output_rate is None:
|
||||
return _unknown_cost(entry.source)
|
||||
if usage.cache_read_tokens and cache_read_rate is None:
|
||||
return _unknown_cost(entry.source, "cache-read pricing unavailable for route")
|
||||
if usage.cache_write_tokens and cache_write_rate is None:
|
||||
return _unknown_cost(entry.source, "cache-write pricing unavailable for route")
|
||||
above = entry.tier_threshold_tokens is not None and usage.prompt_tokens > entry.tier_threshold_tokens
|
||||
buckets = []
|
||||
for tokens, rate, rate_above, note in (
|
||||
(usage.input_tokens, entry.input_cost_per_million, entry.input_cost_per_million_above, ()),
|
||||
(usage.output_tokens, entry.output_cost_per_million, entry.output_cost_per_million_above, ()),
|
||||
(usage.cache_read_tokens, entry.cache_read_cost_per_million, entry.cache_read_cost_per_million_above,
|
||||
("cache-read pricing unavailable for route",)),
|
||||
(usage.cache_write_tokens, entry.cache_write_cost_per_million, None,
|
||||
("cache-write pricing unavailable for route",)),
|
||||
):
|
||||
if above and rate_above is not None:
|
||||
rate = rate_above
|
||||
if tokens and rate is None:
|
||||
return _unknown_cost(entry.source, *note)
|
||||
buckets.append((tokens, rate))
|
||||
|
||||
amount = _ZERO
|
||||
for tokens, rate in (
|
||||
(usage.input_tokens, input_rate), (usage.output_tokens, output_rate),
|
||||
(usage.cache_read_tokens, cache_read_rate), (usage.cache_write_tokens, cache_write_rate),
|
||||
):
|
||||
for tokens, rate in buckets:
|
||||
if rate is not None:
|
||||
amount += Decimal(tokens) * rate / _ONE_MILLION
|
||||
if entry.request_cost is not None and usage.request_count:
|
||||
|
||||
@@ -8,6 +8,7 @@ and never upgrades targeted checks into "repo green".
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import shlex
|
||||
import sqlite3
|
||||
import tempfile
|
||||
@@ -135,9 +136,8 @@ def _connect() -> sqlite3.Connection:
|
||||
def _transaction() -> Iterator[sqlite3.Connection]:
|
||||
"""Open a connection, commit/rollback on exit, and ALWAYS close it.
|
||||
|
||||
``sqlite3.Connection`` as a context manager only commits/rolls back; it does
|
||||
not close. Relying on it alone leaks a connection (and its WAL/SHM fds) per
|
||||
call until GC runs, which can exhaust ``RLIMIT_NOFILE`` in a long process.
|
||||
``sqlite3.Connection`` as a context manager only commits/rolls back; without
|
||||
the close, each call leaks a connection (and WAL/SHM fds) until GC runs.
|
||||
"""
|
||||
conn = _connect()
|
||||
try:
|
||||
@@ -249,28 +249,20 @@ def _exit_status_is_attributable(segments: list[_ShellSegment], match_index: int
|
||||
|
||||
def _canonical_tokens(canonical: str) -> list[str]:
|
||||
"""Tokenize a canonical command, stripping leading ``./`` from each token."""
|
||||
def clean(token: str) -> str:
|
||||
token = token.strip()
|
||||
while token.startswith("./"):
|
||||
token = token[2:]
|
||||
return token
|
||||
|
||||
try:
|
||||
return [clean(t) for t in shlex.split(canonical) if t]
|
||||
return [re.sub(r"^(?:\./)+", "", t.strip()) for t in shlex.split(canonical) if t]
|
||||
except ValueError:
|
||||
return []
|
||||
|
||||
|
||||
def _strip_command_prefix(tokens: list[str]) -> list[str]:
|
||||
"""Remove harmless command prefixes (env, VAR=x, command/time/noglob)."""
|
||||
remaining = list(tokens)
|
||||
if remaining and remaining[0] == "env":
|
||||
remaining = remaining[1:]
|
||||
while remaining and "=" in remaining[0] and not remaining[0].startswith("-"):
|
||||
remaining = remaining[1:]
|
||||
while remaining and remaining[0] in {"command", "time", "noglob"}:
|
||||
remaining = remaining[1:]
|
||||
return remaining
|
||||
i = 1 if tokens and tokens[0] == "env" else 0
|
||||
while i < len(tokens) and "=" in tokens[i] and not tokens[i].startswith("-"):
|
||||
i += 1
|
||||
while i < len(tokens) and tokens[i] in {"command", "time", "noglob"}:
|
||||
i += 1
|
||||
return list(tokens[i:])
|
||||
|
||||
|
||||
def _equivalent_needles(needle: list[str]) -> list[list[str]]:
|
||||
@@ -452,12 +444,12 @@ def classify_verification_command(
|
||||
verify_commands = list(facts.get("verifyCommands") or [])
|
||||
match = _find_canonical_match(command, verify_commands, int(exit_code))
|
||||
is_ad_hoc = False
|
||||
if match is None and not verify_commands:
|
||||
ad_hoc_args = _find_ad_hoc_match(command, facts.get("root"), int(exit_code))
|
||||
is_ad_hoc = ad_hoc_args is not None
|
||||
match = ("ad-hoc verification script", ad_hoc_args) if is_ad_hoc else None
|
||||
if match is None:
|
||||
return None
|
||||
ad_hoc_args = None if verify_commands else _find_ad_hoc_match(command, facts.get("root"), int(exit_code))
|
||||
if ad_hoc_args is None:
|
||||
return None
|
||||
is_ad_hoc = True
|
||||
match = ("ad-hoc verification script", ad_hoc_args)
|
||||
|
||||
canonical, trailing_args = match
|
||||
return VerificationEvidence(
|
||||
@@ -484,10 +476,9 @@ def record_verify_run(
|
||||
) -> Optional[dict[str, Any]]:
|
||||
"""Record a completed ``hermes verify`` run as verification evidence.
|
||||
|
||||
Explicit CLI-side write with nothing to classify: a pass marks the workspace
|
||||
``passed`` for the verify-on-stop guard like a canonical test command would;
|
||||
a failure keeps the guard asking for a fix. ``root`` is re-resolved through
|
||||
project facts so it matches what :func:`verification_status` derives later.
|
||||
A pass marks the workspace ``passed`` for the verify-on-stop guard like a
|
||||
canonical test command would. ``root`` is re-resolved through project facts
|
||||
so it matches what :func:`verification_status` derives later.
|
||||
"""
|
||||
resolved = str(Path(root).resolve())
|
||||
return _insert_evidence(VerificationEvidence(
|
||||
@@ -596,5 +587,4 @@ def verification_status(*, session_id: str | None, cwd: str | Path | None) -> di
|
||||
|
||||
evidence = dict(event)
|
||||
stale = bool(state["last_edit_at"]) and state["last_edit_at"] > evidence["created_at"]
|
||||
result["evidence"] = evidence
|
||||
return {"status": "stale" if stale else evidence["status"], **result}
|
||||
return {"status": "stale" if stale else evidence["status"], **result, "evidence": evidence}
|
||||
|
||||
+14
-20
@@ -42,9 +42,8 @@ def _is_non_code_path(raw: str) -> bool:
|
||||
|
||||
|
||||
def _session_is_messaging_surface() -> bool:
|
||||
"""Whether this turn is delivered over a human messaging channel
|
||||
(``gateway.session_context``). An unreachable gateway package means no
|
||||
messaging channel, so report a local surface (keeps verify-on-stop enabled)."""
|
||||
"""Whether this turn is delivered over a human messaging channel. An
|
||||
unreachable gateway package means no messaging channel (verify-on-stop stays on)."""
|
||||
try:
|
||||
from gateway.session_context import session_is_messaging_surface
|
||||
|
||||
@@ -56,12 +55,11 @@ def _session_is_messaging_surface() -> bool:
|
||||
def verify_on_stop_enabled(config: dict[str, Any] | None = None) -> bool:
|
||||
"""Return whether edit -> verify-before-finish behavior is enabled.
|
||||
|
||||
Precedence: explicit ``HERMES_VERIFY_ON_STOP`` env var, then explicit
|
||||
``agent.verify_on_stop`` config. Default OFF (opt-in). A bool forces the
|
||||
behavior; ``"auto"`` is the legacy surface-aware mode: ON for interactive
|
||||
coding surfaces (CLI, TUI, desktop) and programmatic callers, OFF for
|
||||
messaging surfaces where the verification narrative is chat noise.
|
||||
Missing/unrecognized values fall back to OFF.
|
||||
Precedence: ``HERMES_VERIFY_ON_STOP`` env var, then ``agent.verify_on_stop``
|
||||
config; default OFF (opt-in). A bool forces the behavior; ``"auto"`` is the
|
||||
legacy surface-aware mode: ON for interactive coding surfaces and
|
||||
programmatic callers, OFF for messaging surfaces where the verification
|
||||
narrative is chat noise. Missing/unrecognized values fall back to OFF.
|
||||
"""
|
||||
env = os.environ.get("HERMES_VERIFY_ON_STOP")
|
||||
if env is not None:
|
||||
@@ -77,13 +75,10 @@ def verify_on_stop_enabled(config: dict[str, Any] | None = None) -> bool:
|
||||
cfg_val = agent_cfg.get("verify_on_stop") if isinstance(agent_cfg, dict) else None
|
||||
if isinstance(cfg_val, bool):
|
||||
return cfg_val
|
||||
if isinstance(cfg_val, str):
|
||||
token = cfg_val.strip().lower()
|
||||
if token == "auto":
|
||||
return not _session_is_messaging_surface()
|
||||
if token in _TRUTHY_TOKENS | _FALSY_TOKENS:
|
||||
return token in _TRUTHY_TOKENS
|
||||
return False
|
||||
token = cfg_val.strip().lower() if isinstance(cfg_val, str) else ""
|
||||
if token == "auto":
|
||||
return not _session_is_messaging_surface()
|
||||
return token in _TRUTHY_TOKENS
|
||||
|
||||
|
||||
def _candidate_cwds(paths: Iterable[str]) -> list[Path]:
|
||||
@@ -139,13 +134,12 @@ def _workspace_has_runnable_recipe(root: Any) -> bool:
|
||||
if not root:
|
||||
return False
|
||||
try:
|
||||
root_path = Path(str(root))
|
||||
from agent.verify.environment import manifest_path
|
||||
|
||||
if manifest_path(root_path).is_file():
|
||||
return True
|
||||
from agent.verify.recipes import detect_recipe
|
||||
|
||||
root_path = Path(str(root))
|
||||
if manifest_path(root_path).is_file():
|
||||
return True
|
||||
recipe = detect_recipe(root_path)
|
||||
return bool(recipe is not None and recipe.start)
|
||||
except Exception:
|
||||
|
||||
+16
-30
@@ -1,17 +1,12 @@
|
||||
"""Static run-recipe detection for project verification.
|
||||
|
||||
Ported nearly 1:1 from superagent-ai/grok-cli ``src/verify/recipes.ts``, keeping
|
||||
its detection order and command choices: Node (lockfile-based package manager +
|
||||
framework), Python (Django / FastAPI / Flask / generic, uv/poetry/pipenv aware),
|
||||
Go, Rust, Java (Maven/Gradle), Makefile fallback, docker-compose.
|
||||
|
||||
Layer ownership vs :mod:`agent.coding_context`: ``detect_project_facts`` owns the
|
||||
cheap, byte-stable prompt-time facts (manifests, package managers, verify
|
||||
commands) — never push runtime detection into it. This module owns the deep
|
||||
runtime recipe (framework id, bootstrap/build/test, start command, port,
|
||||
readiness path) that lets ``hermes verify`` boot the app and prove it serves
|
||||
HTTP; the CLI merges project-facts verify commands the recipe missed into its
|
||||
test list (``hermes_cli.verify_cmd._merge_project_facts_commands``).
|
||||
Ported nearly 1:1 from superagent-ai/grok-cli ``src/verify/recipes.ts`` (same
|
||||
detection order and command choices). Layer ownership: ``detect_project_facts``
|
||||
in :mod:`agent.coding_context` owns the cheap, byte-stable prompt-time facts —
|
||||
never push runtime detection into it. This module owns the deep runtime recipe
|
||||
(framework, bootstrap/build/test, start command, port, readiness path) that lets
|
||||
``hermes verify`` boot the app; the CLI merges project-facts verify commands the
|
||||
recipe missed (``hermes_cli.verify_cmd._merge_project_facts_commands``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -24,11 +19,8 @@ from typing import Any
|
||||
|
||||
|
||||
def _as_strings(value: Any) -> list[str]:
|
||||
if isinstance(value, str) and value.strip():
|
||||
return [value.strip()]
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
return [v.strip() for v in value if isinstance(v, str) and v.strip()]
|
||||
values = [value] if isinstance(value, str) else value if isinstance(value, list) else []
|
||||
return [v.strip() for v in values if isinstance(v, str) and v.strip()]
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -66,7 +58,7 @@ class Recipe:
|
||||
name = raw.get("name") or raw.get("appLabel")
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
return None
|
||||
kind = raw.get("kind") or raw.get("appKind") or "unknown"
|
||||
kind = raw.get("kind") or raw.get("appKind")
|
||||
if not isinstance(kind, str) or not kind.strip():
|
||||
kind = "unknown"
|
||||
|
||||
@@ -99,12 +91,9 @@ def _read_text(root: Path, name: str) -> str | None:
|
||||
|
||||
|
||||
def _read_package_json(root: Path) -> dict[str, Any] | None:
|
||||
raw = _read_text(root, "package.json")
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
parsed = json.loads(_read_text(root, "package.json") or "")
|
||||
except ValueError: # includes JSONDecodeError; also the missing-file "" case
|
||||
return None
|
||||
return parsed if isinstance(parsed, dict) else None
|
||||
|
||||
@@ -159,8 +148,7 @@ def _detect_node_recipe(root: Path, pkg: dict[str, Any]) -> Recipe:
|
||||
raw_scripts = pkg.get("scripts")
|
||||
scripts: dict[str, str] = raw_scripts if isinstance(raw_scripts, dict) else {}
|
||||
deps: dict[str, Any] = {}
|
||||
for key in ("dependencies", "devDependencies"):
|
||||
section = pkg.get(key)
|
||||
for section in (pkg.get("dependencies"), pkg.get("devDependencies")):
|
||||
if isinstance(section, dict):
|
||||
deps.update(section)
|
||||
|
||||
@@ -288,14 +276,12 @@ def _detect_make_recipe(root: Path) -> Recipe | None:
|
||||
return None
|
||||
targets = [m.group(1) for m in map(_MAKE_TARGET_RE.match, makefile.splitlines()) if m]
|
||||
picked = {
|
||||
phase: next((f"make {n}" for n in names if n in targets), None)
|
||||
phase: [f"make {n}" for n in names if n in targets][:1]
|
||||
for phase, names in _MAKE_PHASE_TARGETS.items()
|
||||
}
|
||||
return Recipe(
|
||||
name="Makefile-driven project", kind="make", start=picked["start"],
|
||||
bootstrap=[picked["bootstrap"]] if picked["bootstrap"] else [],
|
||||
build=[picked["build"]] if picked["build"] else [],
|
||||
test=[picked["test"]] if picked["test"] else [],
|
||||
name="Makefile-driven project", kind="make", start=(picked["start"] or [None])[0],
|
||||
bootstrap=picked["bootstrap"], build=picked["build"], test=picked["test"],
|
||||
evidence=["Detected Makefile", f"Targets: {', '.join(targets) or '(none)'}"],
|
||||
)
|
||||
|
||||
|
||||
@@ -149,8 +149,8 @@ def _terminate_process_group(proc: subprocess.Popen) -> None:
|
||||
pgid = None
|
||||
|
||||
def stop(sig: int, fallback: Callable[[], None]) -> None:
|
||||
if pgid is not None and killpg is not None:
|
||||
killpg(pgid, sig) # windows-footgun: ok — POSIX-only branch (killpg checked above)
|
||||
if pgid is not None:
|
||||
killpg(pgid, sig) # windows-footgun: ok — POSIX-only branch (pgid only set when killpg exists)
|
||||
else:
|
||||
fallback()
|
||||
|
||||
@@ -186,8 +186,7 @@ def _run_start_phase(
|
||||
finally:
|
||||
_terminate_process_group(proc)
|
||||
try:
|
||||
if proc.stdout is not None:
|
||||
output = proc.stdout.read() or ""
|
||||
output = proc.stdout.read() or "" if proc.stdout is not None else ""
|
||||
except (OSError, ValueError):
|
||||
output = ""
|
||||
return ReadinessResult(url, ready, status, time.monotonic() - started, error, _tail(output))
|
||||
@@ -218,9 +217,6 @@ def run_verify(
|
||||
if not phase_result.ok and stop_on_failure:
|
||||
return result
|
||||
|
||||
failed = not all(p.ok for p in result.phases)
|
||||
if skip_start or "start" not in selected or failed or not recipe.start:
|
||||
return result
|
||||
|
||||
result.readiness = _run_start_phase(recipe, root, ready_timeout, port_override)
|
||||
if not skip_start and "start" in selected and recipe.start and all(p.ok for p in result.phases):
|
||||
result.readiness = _run_start_phase(recipe, root, ready_timeout, port_override)
|
||||
return result
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
"""Verification-loop helpers for the ``pre_verify`` round-end gate.
|
||||
|
||||
After code edits, the loop fires ``pre_verify`` (directives resolved by
|
||||
:func:`hermes_cli.plugins.get_pre_verify_continue_message`); a directive keeps the
|
||||
agent going one more turn. The shipped coding guidance rides on the evidence-based
|
||||
verification-stop nudge (``agent/verification_stop.py``) rather than a second
|
||||
After code edits the loop fires ``pre_verify`` (directives resolved by
|
||||
:func:`hermes_cli.plugins.get_pre_verify_continue_message`). The shipped coding
|
||||
guidance rides on the evidence-based verification-stop nudge rather than a second
|
||||
default stop gate, so default token cost stays tied to the "missing verification
|
||||
evidence" decision while ``pre_verify`` remains free for user/plugin policy.
|
||||
"""
|
||||
|
||||
+41
-102
@@ -1,38 +1,24 @@
|
||||
"""
|
||||
Video Generation Provider ABC
|
||||
=============================
|
||||
"""Video Generation Provider ABC.
|
||||
|
||||
Pluggable-backend interface for video generation. Providers register via
|
||||
``PluginContext.register_video_gen_provider()``; the one selected by
|
||||
``video_gen.provider`` services every ``video_generate`` call. Providers live in
|
||||
``<repo>/plugins/video_gen/<name>/`` (built-in) or
|
||||
``~/.hermes/plugins/video_gen/<name>/`` (user, opt-in). Mirrors
|
||||
``agent/image_gen_provider.py`` so the two surfaces stay learnable together.
|
||||
Providers register via ``PluginContext.register_video_gen_provider()`` and live
|
||||
in ``<repo>/plugins/video_gen/<name>/`` (built-in) or
|
||||
``~/.hermes/plugins/video_gen/<name>/``; mirrors ``agent/image_gen_provider.py``.
|
||||
One tool covers text-to-video and image-to-video: ``image_url`` present routes to
|
||||
the provider's image-to-video endpoint. Video edit/extend are deliberately NOT
|
||||
exposed — backends are too inconsistent for one unified tool.
|
||||
|
||||
One tool covers text-to-video and image-to-video: ``image_url`` present routes
|
||||
to the provider's image-to-video endpoint, absent routes to text-to-video. Users
|
||||
pick one model family; the provider picks the FAL/xAI endpoint. Video edit and
|
||||
extend are deliberately NOT exposed — backends are too inconsistent for one
|
||||
unified tool.
|
||||
|
||||
Response shape (built by :func:`success_response` / :func:`error_response`)::
|
||||
|
||||
success bool
|
||||
video str | None URL or absolute file path
|
||||
model str provider-specific model identifier
|
||||
prompt str echoed prompt
|
||||
modality str "text" | "image" (which mode was used)
|
||||
aspect_ratio str provider-native (e.g. "16:9") or ""
|
||||
duration int seconds (0 if not applicable)
|
||||
provider str provider name (for diagnostics)
|
||||
error str only when success=False
|
||||
error_type str only when success=False
|
||||
Response shape (:func:`success_response` / :func:`error_response`): ``success``,
|
||||
``video`` (URL or absolute path), ``model``, ``prompt``, ``modality``
|
||||
("text" | "image"), ``aspect_ratio``, ``duration`` (seconds, 0 if n/a),
|
||||
``provider``; plus ``error`` / ``error_type`` only when ``success`` is False.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import abc
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
@@ -70,16 +56,10 @@ class VideoGenProvider(CatalogProviderBase):
|
||||
and the picker. Default fails closed: text-only, no optional features.
|
||||
"""
|
||||
return {
|
||||
"modalities": ["text"],
|
||||
"aspect_ratios": list(COMMON_ASPECT_RATIOS),
|
||||
"resolutions": list(COMMON_RESOLUTIONS),
|
||||
"max_duration": 10,
|
||||
"min_duration": 1,
|
||||
"supports_audio": False,
|
||||
"supports_negative_prompt": False,
|
||||
"supports_seed": False,
|
||||
"supports_upscale": False,
|
||||
"max_reference_images": 0,
|
||||
"modalities": ["text"], "aspect_ratios": list(COMMON_ASPECT_RATIOS),
|
||||
"resolutions": list(COMMON_RESOLUTIONS), "max_duration": 10, "min_duration": 1,
|
||||
"supports_audio": False, "supports_negative_prompt": False, "supports_seed": False,
|
||||
"supports_upscale": False, "max_reference_images": 0,
|
||||
}
|
||||
|
||||
@abc.abstractmethod
|
||||
@@ -99,11 +79,6 @@ class VideoGenProvider(CatalogProviderBase):
|
||||
"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def save_b64_video(b64_data: str,*, prefix: str="video", extension: str="mp4") -> Path:
|
||||
"""Decode base64 video data into ``$HERMES_HOME/cache/videos/``; return the path."""
|
||||
return provider_media.save_b64("videos", b64_data, prefix=prefix, extension=extension)
|
||||
@@ -141,18 +116,11 @@ def success_response(
|
||||
) -> Dict[str, Any]:
|
||||
"""Uniform success dict; ``extra`` keys are added without overriding standard ones."""
|
||||
payload: Dict[str, Any] = {
|
||||
"success": True,
|
||||
"video": video,
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"modality": modality,
|
||||
"aspect_ratio": aspect_ratio,
|
||||
"duration": int(duration) if duration else 0,
|
||||
"provider": provider,
|
||||
"success": True, "video": video, "model": model, "prompt": prompt, "modality": modality,
|
||||
"aspect_ratio": aspect_ratio, "duration": int(duration) if duration else 0, "provider": provider,
|
||||
}
|
||||
if extra:
|
||||
for k, v in extra.items():
|
||||
payload.setdefault(k, v)
|
||||
for k, v in (extra or {}).items():
|
||||
payload.setdefault(k, v)
|
||||
return payload
|
||||
|
||||
|
||||
@@ -162,22 +130,11 @@ def error_response(
|
||||
) -> Dict[str, Any]:
|
||||
"""Build a uniform error response dict."""
|
||||
return {
|
||||
"success": False,
|
||||
"video": None,
|
||||
"error": error,
|
||||
"error_type": error_type,
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"aspect_ratio": aspect_ratio,
|
||||
"provider": provider,
|
||||
"success": False, "video": None, "error": error, "error_type": error_type, "model": model,
|
||||
"prompt": prompt, "aspect_ratio": aspect_ratio, "provider": provider,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reusable OpenAI-compatible backend
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class OpenAICompatibleVideoGenProvider(VideoGenProvider):
|
||||
"""Generic text/image-to-video over the OpenAI ``client.videos`` API.
|
||||
|
||||
@@ -207,8 +164,6 @@ class OpenAICompatibleVideoGenProvider(VideoGenProvider):
|
||||
_poll_deadline_s: float = 900.0
|
||||
|
||||
def _api_key(self) -> str:
|
||||
import os
|
||||
|
||||
return os.environ.get(self._env_key, "").strip()
|
||||
|
||||
def is_available(self) -> bool:
|
||||
@@ -217,8 +172,6 @@ class OpenAICompatibleVideoGenProvider(VideoGenProvider):
|
||||
def _create_and_poll(self, client: Any, call_kwargs: Dict[str, Any]) -> Any:
|
||||
"""Create the job and poll to a terminal status (any); raise
|
||||
:class:`TimeoutError` when ``_poll_deadline_s`` passes first."""
|
||||
import time
|
||||
|
||||
video = client.videos.create(**call_kwargs)
|
||||
terminal = {"completed", "succeeded", "failed", "error", "cancelled", "canceled"}
|
||||
deadline = time.monotonic() + self._poll_deadline_s
|
||||
@@ -234,10 +187,7 @@ class OpenAICompatibleVideoGenProvider(VideoGenProvider):
|
||||
return video
|
||||
|
||||
def _base_url(self) -> str:
|
||||
import os
|
||||
|
||||
override = os.environ.get(f"{self.name.upper()}_BASE_URL", "").strip()
|
||||
return override or self._default_base_url
|
||||
return os.environ.get(f"{self.name.upper()}_BASE_URL", "").strip() or self._default_base_url
|
||||
|
||||
def generate(
|
||||
self, prompt: str, *, model: Optional[str] = None, image_url: Optional[str] = None,
|
||||
@@ -247,13 +197,10 @@ class OpenAICompatibleVideoGenProvider(VideoGenProvider):
|
||||
seed: Optional[int] = None, **kwargs: Any,
|
||||
) -> Dict[str, Any]:
|
||||
if not prompt or not prompt.strip():
|
||||
return error_response(
|
||||
error="prompt is required", error_type="invalid_request", provider=self.name
|
||||
)
|
||||
return error_response(error="prompt is required", error_type="invalid_request", provider=self.name)
|
||||
if not self._api_key():
|
||||
return error_response(
|
||||
error=f"{self._env_key} is not set", error_type="missing_credentials",
|
||||
provider=self.name,
|
||||
error=f"{self._env_key} is not set", error_type="missing_credentials", provider=self.name
|
||||
)
|
||||
try:
|
||||
import openai
|
||||
@@ -270,12 +217,17 @@ class OpenAICompatibleVideoGenProvider(VideoGenProvider):
|
||||
error_type="no_model", provider=self.name,
|
||||
)
|
||||
|
||||
def fail(error: str, error_type: str) -> Dict[str, Any]:
|
||||
return error_response(
|
||||
error=error, error_type=error_type, provider=self.name, model=model_id, prompt=prompt,
|
||||
aspect_ratio=aspect_ratio,
|
||||
)
|
||||
|
||||
# Fields ``videos.create`` doesn't name natively ride in ``extra_body``.
|
||||
extra_body = {
|
||||
k: v
|
||||
for k, v in {
|
||||
"negative_prompt": negative_prompt,
|
||||
"aspect_ratio": aspect_ratio,
|
||||
"negative_prompt": negative_prompt, "aspect_ratio": aspect_ratio,
|
||||
"image_url": image_url, # presence ⇒ image-to-video
|
||||
"seed": seed,
|
||||
}.items()
|
||||
@@ -295,30 +247,22 @@ class OpenAICompatibleVideoGenProvider(VideoGenProvider):
|
||||
video = self._create_and_poll(client, call_kwargs)
|
||||
except Exception as exc: # noqa: BLE001 - surface any SDK/API/timeout failure uniformly
|
||||
logger.debug("%s video generation failed", self.name, exc_info=True)
|
||||
return error_response(
|
||||
error=f"{self.name} video generation failed: {exc}", error_type="api_error",
|
||||
provider=self.name, model=model_id, prompt=prompt, aspect_ratio=aspect_ratio,
|
||||
)
|
||||
return fail(f"{self.name} video generation failed: {exc}", "api_error")
|
||||
|
||||
# DeepInfra reports "succeeded", OpenAI/Sora "completed" — accept both.
|
||||
status = getattr(video, "status", None)
|
||||
if status not in ("completed", "succeeded"):
|
||||
# ``video.error`` is a pydantic object — str() keeps the dict JSON-serializable.
|
||||
job_error = getattr(video, "error", None)
|
||||
return error_response(
|
||||
error=str(job_error) if job_error else f"video job ended with status={status!r}",
|
||||
error_type="job_failed", provider=self.name, model=model_id, prompt=prompt,
|
||||
aspect_ratio=aspect_ratio,
|
||||
)
|
||||
return fail(str(job_error) if job_error else f"video job ended with status={status!r}", "job_failed")
|
||||
|
||||
# Output is a delivery URL in ``data`` (DeepInfra/FAL) or only reachable
|
||||
# via the SDK download endpoint (OpenAI/Sora). Save locally either way —
|
||||
# DeepInfra's delivery URLs are short-lived.
|
||||
url = None
|
||||
for item in getattr(video, "data", None) or []:
|
||||
candidate = item.get("url") if isinstance(item, dict) else getattr(item, "url", None)
|
||||
if candidate:
|
||||
url = candidate
|
||||
url = (item.get("url") if isinstance(item, dict) else getattr(item, "url", None)) or None
|
||||
if url:
|
||||
break
|
||||
|
||||
try:
|
||||
@@ -328,15 +272,10 @@ class OpenAICompatibleVideoGenProvider(VideoGenProvider):
|
||||
raw = client.videos.download_content(video.id).read()
|
||||
video_ref = str(save_bytes_video(raw, prefix=self.name))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
if url:
|
||||
logger.debug("%s: saving video locally failed (%s); returning URL", self.name, exc)
|
||||
video_ref = url
|
||||
else:
|
||||
return error_response(
|
||||
error=f"{self.name} video job succeeded but no output could be retrieved: {exc}",
|
||||
error_type="empty_response", provider=self.name, model=model_id,
|
||||
prompt=prompt, aspect_ratio=aspect_ratio,
|
||||
)
|
||||
if not url:
|
||||
return fail(f"{self.name} video job succeeded but no output could be retrieved: {exc}", "empty_response")
|
||||
logger.debug("%s: saving video locally failed (%s); returning URL", self.name, exc)
|
||||
video_ref = url
|
||||
|
||||
return success_response(
|
||||
video=video_ref, model=model_id, prompt=prompt,
|
||||
|
||||
+14
-31
@@ -1,25 +1,12 @@
|
||||
"""
|
||||
Video Generation Provider Registry
|
||||
==================================
|
||||
"""Video Generation Provider Registry.
|
||||
|
||||
Central map of registered providers. Populated by plugins at import-time via
|
||||
``PluginContext.register_video_gen_provider()``; consumed by the
|
||||
``video_generate`` tool to dispatch each call to the active backend.
|
||||
|
||||
Active selection
|
||||
----------------
|
||||
The active provider is chosen by ``video_gen.provider`` in ``config.yaml``.
|
||||
If unset, :func:`get_active_provider` applies fallback logic:
|
||||
|
||||
1. If exactly one *available* provider is registered, use it.
|
||||
2. Otherwise return ``None`` (the tool surfaces a helpful error pointing
|
||||
the user at ``hermes tools``).
|
||||
|
||||
Mirrors ``agent/image_gen_registry.py``: the unconfigured fallback is
|
||||
filtered by ``is_available()`` so a box with credentials for only one backend
|
||||
(e.g. DeepInfra, while ``fal``/``xai`` register unconditionally) auto-selects
|
||||
it instead of returning ``None``. Unlike image gen there is no legacy ``fal``
|
||||
preference, and a configured-but-unregistered name fails closed.
|
||||
Populated by plugins via ``PluginContext.register_video_gen_provider()``;
|
||||
consumed by the ``video_generate`` tool. The active provider is
|
||||
``video_gen.provider`` from ``config.yaml``; a configured-but-unregistered name
|
||||
fails closed. If unset, the single *available* registered provider is used
|
||||
(mirrors ``agent/image_gen_registry.py`` minus its legacy ``fal`` preference)
|
||||
so a box with credentials for only one backend auto-selects it; otherwise None
|
||||
and the tool points the user at ``hermes tools``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -46,18 +33,14 @@ def get_active_provider() -> Optional[VideoGenProvider]:
|
||||
|
||||
if configured:
|
||||
provider = snapshot.get(configured)
|
||||
if provider is not None:
|
||||
return provider
|
||||
logger.debug(
|
||||
"video_gen.provider='%s' configured but not registered; failing closed", configured
|
||||
)
|
||||
return None
|
||||
if provider is None:
|
||||
logger.debug(
|
||||
"video_gen.provider='%s' configured but not registered; failing closed", configured
|
||||
)
|
||||
return provider
|
||||
|
||||
available = [
|
||||
p for p in snapshot.values()
|
||||
if is_available_safe(p, logger, "video_gen provider %s.is_available() raised %s")
|
||||
]
|
||||
if len(available) == 1:
|
||||
return available[0]
|
||||
|
||||
return None
|
||||
return available[0] if len(available) == 1 else None
|
||||
|
||||
+54
-120
@@ -2,7 +2,6 @@
|
||||
|
||||
Vision capability probes, non-vision text fallbacks (cached ``vision_analyze`` descriptions), tool-result
|
||||
image stripping, and provider quirks (Anthropic dot preservation, Qwen portal message shaping).
|
||||
Extracted from ``run_agent.py``; every method resolves through ``AIAgent``'s MRO unchanged.
|
||||
"""
|
||||
import logging
|
||||
import asyncio
|
||||
@@ -22,18 +21,30 @@ from utils import base_url_host_matches, base_url_hostname
|
||||
# Same logger name as the origin module so log records / caplog filters are unchanged.
|
||||
logger = logging.getLogger("run_agent")
|
||||
|
||||
_IMAGE_PART_TYPES = {"image_url", "input_image"}
|
||||
_TEXT_PART_TYPES = {"text", "input_text"}
|
||||
_DATA_URL_SUFFIXES = {
|
||||
"image/png": ".png", "image/gif": ".gif", "image/webp": ".webp", "image/jpeg": ".jpg", "image/jpg": ".jpg"
|
||||
}
|
||||
|
||||
|
||||
def _is_image_part(part: Any) -> bool:
|
||||
return isinstance(part, dict) and part.get("type") in _IMAGE_PART_TYPES
|
||||
|
||||
|
||||
class VisionMessagePrepMixin:
|
||||
"""Vision probes + image-part fallbacks for outgoing messages (see module docstring)."""
|
||||
|
||||
@staticmethod
|
||||
def _content_has_image_parts(content: Any) -> bool:
|
||||
if not isinstance(content, list):
|
||||
return False
|
||||
for part in content:
|
||||
if isinstance(part, dict) and part.get("type") in {"image_url", "input_image"}:
|
||||
return True
|
||||
return False
|
||||
return isinstance(content, list) and any(_is_image_part(part) for part in content)
|
||||
|
||||
def _provider_model_key(self) -> tuple[str, str]:
|
||||
"""``(provider.lower(), model)`` as recorded in ``_no_list_tool_content_models``."""
|
||||
return (
|
||||
(getattr(self, "provider", "") or "").strip().lower(),
|
||||
(getattr(self, "model", "") or "").strip(),
|
||||
)
|
||||
|
||||
# 20 MB base64 ≈ 15 MB decoded — prevents OOM from an oversized data: URL in a shared gateway process.
|
||||
_MAX_DATA_URL_BASE64_BYTES = 20 * 1024 * 1024
|
||||
@@ -51,13 +62,7 @@ class VisionMessagePrepMixin:
|
||||
mime_part = header[len("data:"):].split(";", 1)[0].strip()
|
||||
if mime_part.startswith("image/"):
|
||||
mime = mime_part
|
||||
suffix = {
|
||||
"image/png": ".png",
|
||||
"image/gif": ".gif",
|
||||
"image/webp": ".webp",
|
||||
"image/jpeg": ".jpg",
|
||||
"image/jpg": ".jpg",
|
||||
}.get(mime, ".jpg")
|
||||
suffix = _DATA_URL_SUFFIXES.get(mime, ".jpg")
|
||||
tmp = tempfile.NamedTemporaryFile(prefix="anthropic_image_", suffix=suffix, delete=False)
|
||||
try:
|
||||
with tmp:
|
||||
@@ -79,9 +84,7 @@ class VisionMessagePrepMixin:
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
role_label = {
|
||||
"assistant": "assistant", "tool": "tool result"
|
||||
}.get(role, "user")
|
||||
role_label = {"assistant": "assistant", "tool": "tool result"}.get(role, "user")
|
||||
analysis_prompt = (
|
||||
"Describe everything visible in this image in thorough detail. "
|
||||
"Include any text, code, UI, data, objects, people, layout, colors, "
|
||||
@@ -111,14 +114,9 @@ class VisionMessagePrepMixin:
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
if not description:
|
||||
description = "Image analysis failed."
|
||||
|
||||
note = f"[The {role_label} attached an image. Here's what it contains:\n{description}]"
|
||||
note = f"[The {role_label} attached an image. Here's what it contains:\n{description or 'Image analysis failed.'}]"
|
||||
if vision_source and not str(image_url or "").startswith("data:"):
|
||||
note += (
|
||||
f"\n[If you need a closer look, use vision_analyze with image_url: {vision_source}]"
|
||||
)
|
||||
note += f"\n[If you need a closer look, use vision_analyze with image_url: {vision_source}]"
|
||||
|
||||
self._anthropic_image_fallback_cache[cache_key] = note
|
||||
return note
|
||||
@@ -170,22 +168,15 @@ class VisionMessagePrepMixin:
|
||||
if not isinstance(part, dict):
|
||||
continue
|
||||
|
||||
ptype = part.get("type")
|
||||
if ptype in {"text", "input_text"}:
|
||||
text = str(part.get("text", "") or "").strip()
|
||||
if text:
|
||||
text_parts.append(text)
|
||||
continue
|
||||
|
||||
if ptype in {"image_url", "input_image"}:
|
||||
if _is_image_part(part):
|
||||
image_data = part.get("image_url", {})
|
||||
image_url = image_data.get("url", "") if isinstance(image_data, dict) else str(image_data or "")
|
||||
if image_url:
|
||||
image_notes.append(self._describe_image_for_anthropic_fallback(image_url, role))
|
||||
else:
|
||||
image_notes.append("[An image was attached but no image source was available.]")
|
||||
image_notes.append(
|
||||
self._describe_image_for_anthropic_fallback(image_url, role) if image_url
|
||||
else "[An image was attached but no image source was available.]"
|
||||
)
|
||||
continue
|
||||
|
||||
# Text parts and unknown dict types both contribute their ``text``.
|
||||
text = str(part.get("text", "") or "").strip()
|
||||
if text:
|
||||
text_parts.append(text)
|
||||
@@ -194,11 +185,7 @@ class VisionMessagePrepMixin:
|
||||
suffix = "\n".join(text for text in text_parts if text).strip()
|
||||
if prefix and suffix:
|
||||
return f"{prefix}\n\n{suffix}"
|
||||
if prefix:
|
||||
return prefix
|
||||
if suffix:
|
||||
return suffix
|
||||
return "[A multimodal message was converted to text for Anthropic compatibility.]"
|
||||
return prefix or suffix or "[A multimodal message was converted to text for Anthropic compatibility.]"
|
||||
|
||||
def _get_transport(self, api_mode: str = None):
|
||||
"""Return the cached transport for the given (or current) api_mode (lazy; None if unregistered)."""
|
||||
@@ -264,10 +251,7 @@ class VisionMessagePrepMixin:
|
||||
tool_name, getattr(self, "provider", ""),
|
||||
)
|
||||
return _multimodal_text_summary(result)
|
||||
key = (
|
||||
(getattr(self, "provider", "") or "").strip().lower(),
|
||||
(getattr(self, "model", "") or "").strip(),
|
||||
)
|
||||
key = self._provider_model_key()
|
||||
no_list = getattr(self, "_no_list_tool_content_models", None)
|
||||
if no_list and key in no_list:
|
||||
logger.debug(
|
||||
@@ -293,9 +277,7 @@ class VisionMessagePrepMixin:
|
||||
logger.warning(
|
||||
"Tool %s returned image content for non-vision model %s/%s; "
|
||||
"falling back to text summary",
|
||||
tool_name,
|
||||
self.provider,
|
||||
self.model,
|
||||
tool_name, self.provider, self.model,
|
||||
)
|
||||
return summary
|
||||
|
||||
@@ -315,10 +297,7 @@ class VisionMessagePrepMixin:
|
||||
|
||||
if remember_model:
|
||||
# Record (provider, model) so we don't relearn this lesson.
|
||||
key = (
|
||||
(getattr(self, "provider", "") or "").strip().lower(),
|
||||
(getattr(self, "model", "") or "").strip(),
|
||||
)
|
||||
key = self._provider_model_key()
|
||||
if not hasattr(self, "_no_list_tool_content_models"):
|
||||
self._no_list_tool_content_models = set()
|
||||
if key[1]: # only record when we actually have a model id
|
||||
@@ -329,37 +308,26 @@ class VisionMessagePrepMixin:
|
||||
if not isinstance(msg, dict) or msg.get("role") != "tool":
|
||||
continue
|
||||
content = msg.get("content")
|
||||
if not isinstance(content, list):
|
||||
# List content without image parts is left alone; stripping wouldn't reduce ambiguity.
|
||||
if not self._content_has_image_parts(content):
|
||||
continue
|
||||
|
||||
# Salvage any text parts so the model still sees some signal.
|
||||
text_parts: List[str] = []
|
||||
had_image = False
|
||||
for part in content:
|
||||
if not isinstance(part, dict):
|
||||
if isinstance(part, str) and part.strip():
|
||||
text_parts.append(part.strip())
|
||||
continue
|
||||
ptype = part.get("type")
|
||||
if ptype == "image_url" or ptype == "input_image":
|
||||
had_image = True
|
||||
continue
|
||||
if ptype in {"text", "input_text"}:
|
||||
if isinstance(part, str):
|
||||
text = part.strip()
|
||||
elif isinstance(part, dict) and part.get("type") in _TEXT_PART_TYPES:
|
||||
text = str(part.get("text") or "").strip()
|
||||
if text:
|
||||
text_parts.append(text)
|
||||
else:
|
||||
continue
|
||||
if text:
|
||||
text_parts.append(text)
|
||||
|
||||
if not had_image:
|
||||
# List content without image parts — leave alone; stripping wouldn't reduce ambiguity.
|
||||
continue
|
||||
|
||||
if text_parts:
|
||||
msg["content"] = "\n\n".join(text_parts)
|
||||
else:
|
||||
msg["content"] = (
|
||||
"[image content removed — provider does not accept "
|
||||
"list-type tool message content]"
|
||||
)
|
||||
msg["content"] = "\n\n".join(text_parts) or (
|
||||
"[image content removed — provider does not accept "
|
||||
"list-type tool message content]"
|
||||
)
|
||||
changed = True
|
||||
|
||||
return changed
|
||||
@@ -371,10 +339,7 @@ class VisionMessagePrepMixin:
|
||||
dotted inference-profile IDs and rejects the hyphenated form with HTTP 400.
|
||||
"""
|
||||
if (getattr(self, "provider", "") or "").lower() in {
|
||||
"alibaba", "minimax", "minimax-cn",
|
||||
"opencode-go", "opencode-zen",
|
||||
"zai", "bedrock",
|
||||
"xiaomi", "vertex",
|
||||
"alibaba", "minimax", "minimax-cn", "opencode-go", "opencode-zen", "zai", "bedrock", "xiaomi", "vertex",
|
||||
}:
|
||||
return True
|
||||
base = (getattr(self, "base_url", "") or "").lower()
|
||||
@@ -399,43 +364,14 @@ class VisionMessagePrepMixin:
|
||||
return base_url_host_matches(self._base_url_lower, "portal.qwen.ai")
|
||||
|
||||
def _qwen_prepare_chat_messages(self, api_messages: list) -> list:
|
||||
"""Deep-copy ``api_messages`` and shape them for Qwen Portal (see the in-place variant)."""
|
||||
prepared = copy.deepcopy(api_messages)
|
||||
if not prepared:
|
||||
return prepared
|
||||
|
||||
for msg in prepared:
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
content = msg.get("content")
|
||||
if isinstance(content, str):
|
||||
msg["content"] = [{"type": "text", "text": content}]
|
||||
elif isinstance(content, list):
|
||||
# Normalize: convert bare strings to text dicts, keep dicts as-is.
|
||||
# deepcopy already created independent copies, no need for dict().
|
||||
normalized_parts = []
|
||||
for part in content:
|
||||
if isinstance(part, str):
|
||||
normalized_parts.append({"type": "text", "text": part})
|
||||
elif isinstance(part, dict):
|
||||
normalized_parts.append(part)
|
||||
if normalized_parts:
|
||||
msg["content"] = normalized_parts
|
||||
|
||||
# Inject cache_control on the last part of the system message.
|
||||
for msg in prepared:
|
||||
if isinstance(msg, dict) and msg.get("role") == "system":
|
||||
content = msg.get("content")
|
||||
if isinstance(content, list) and content and isinstance(content[-1], dict):
|
||||
content[-1]["cache_control"] = {"type": "ephemeral"}
|
||||
break
|
||||
|
||||
self._qwen_prepare_chat_messages_inplace(prepared)
|
||||
return prepared
|
||||
|
||||
def _qwen_prepare_chat_messages_inplace(self, messages: list) -> None:
|
||||
"""In-place variant — mutates an already-copied message list."""
|
||||
if not messages:
|
||||
return
|
||||
|
||||
"""Qwen Portal shaping, in place: every content becomes a list of parts (bare strings → text
|
||||
dicts, dicts kept), then ``cache_control`` is injected on the last part of the system message."""
|
||||
for msg in messages:
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
@@ -443,12 +379,10 @@ class VisionMessagePrepMixin:
|
||||
if isinstance(content, str):
|
||||
msg["content"] = [{"type": "text", "text": content}]
|
||||
elif isinstance(content, list):
|
||||
normalized_parts = []
|
||||
for part in content:
|
||||
if isinstance(part, str):
|
||||
normalized_parts.append({"type": "text", "text": part})
|
||||
elif isinstance(part, dict):
|
||||
normalized_parts.append(part)
|
||||
normalized_parts = [
|
||||
{"type": "text", "text": part} if isinstance(part, str) else part
|
||||
for part in content if isinstance(part, (str, dict))
|
||||
]
|
||||
if normalized_parts:
|
||||
msg["content"] = normalized_parts
|
||||
|
||||
|
||||
@@ -1,33 +1,22 @@
|
||||
"""
|
||||
Web Search Provider ABC
|
||||
=======================
|
||||
"""Web Search Provider ABC.
|
||||
|
||||
Pluggable-backend interface for web search and content extraction — the SINGLE
|
||||
plugin-facing surface every in-tree web provider (brave-free, ddgs, searxng,
|
||||
exa, parallel, tavily, keenable, firecrawl) implements. Providers register via
|
||||
``PluginContext.register_web_search_provider()``; the active one (selected by
|
||||
``web.search_backend`` / ``web.extract_backend`` / ``web.backend``) services
|
||||
every ``web_search`` / ``web_extract`` call.
|
||||
The single plugin-facing surface every web provider (brave-free, ddgs, searxng,
|
||||
exa, parallel, tavily, keenable, firecrawl) implements; registered via
|
||||
``PluginContext.register_web_search_provider()`` and selected by
|
||||
``web.search_backend`` / ``web.extract_backend`` / ``web.backend``.
|
||||
|
||||
Response shape (preserved from the legacy contract so the tool wrapper does not
|
||||
translate). Search::
|
||||
Response shapes (legacy contract, the tool wrapper does not translate)::
|
||||
|
||||
{"success": True, "data": {"web": [
|
||||
{"title": str, "url": str, "description": str, "position": int}, ...]}}
|
||||
|
||||
Extract::
|
||||
|
||||
{"success": True, "data": [
|
||||
{"url": str, "title": str, "content": str, "raw_content": str, "metadata": dict}, ...]}
|
||||
|
||||
On failure (either capability): ``{"success": False, "error": str}``.
|
||||
search: {"success": True, "data": {"web": [{"title", "url", "description", "position"}, ...]}}
|
||||
extract: {"success": True, "data": [{"url", "title", "content", "raw_content", "metadata"}, ...]}
|
||||
failure: {"success": False, "error": str}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import abc
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from agent.provider_base import ProviderBase
|
||||
|
||||
@@ -40,7 +29,6 @@ def get_provider_env(name: str) -> str:
|
||||
subprocess agent runs). Falls back to bare ``os.getenv`` when the config
|
||||
module is unavailable. Returns the stripped value, or ``""`` when unset.
|
||||
"""
|
||||
val: Optional[str] = None
|
||||
try:
|
||||
from hermes_cli.config import get_env_value
|
||||
|
||||
|
||||
@@ -1,33 +1,19 @@
|
||||
"""
|
||||
Web Search Provider Registry
|
||||
============================
|
||||
"""Web Search Provider Registry.
|
||||
|
||||
Central map of registered web providers. Populated by plugins at import-time
|
||||
via :meth:`PluginContext.register_web_search_provider`; consumed by the
|
||||
``web_search`` and ``web_extract`` tool wrappers in :mod:`tools.web_tools` to
|
||||
dispatch each call to the active backend.
|
||||
Populated by plugins via :meth:`PluginContext.register_web_search_provider`;
|
||||
consumed by the ``web_search`` / ``web_extract`` wrappers in :mod:`tools.web_tools`.
|
||||
|
||||
Active selection
|
||||
----------------
|
||||
The active provider is chosen by configuration with this precedence:
|
||||
Active selection, in precedence order (the ``supports_search`` /
|
||||
``supports_extract`` capability filter applies at every step, so a search-only
|
||||
provider configured as ``web.extract_backend`` falls through):
|
||||
|
||||
1. ``web.search_backend`` / ``web.extract_backend``
|
||||
(per-capability override).
|
||||
2. ``web.backend`` (shared fallback).
|
||||
3. If exactly one capability-eligible provider is registered AND available,
|
||||
use it.
|
||||
4. Legacy preference order — ``firecrawl`` → ``parallel`` → ``tavily`` →
|
||||
``exa`` → ``searxng`` → ``brave-free`` → ``ddgs`` — filtered by
|
||||
availability. Matches the historic ``tools.web_tools._get_backend()``
|
||||
candidate order so installs that never set a config key keep landing
|
||||
on the same provider they did before the plugin migration.
|
||||
5. Otherwise ``None`` — the tool surfaces a helpful error pointing at
|
||||
``hermes tools``.
|
||||
|
||||
The capability filter (``supports_search`` / ``supports_extract``) is
|
||||
applied at every step so a search-only provider (``brave-free``)
|
||||
configured as ``web.extract_backend`` correctly falls through to an
|
||||
extract-capable backend.
|
||||
1. ``web.search_backend`` / ``web.extract_backend``, then ``web.backend``.
|
||||
2. The single capability-eligible provider that is registered AND available.
|
||||
3. Legacy preference walk (``_LEGACY_PREFERENCE``) filtered by availability —
|
||||
the historic ``tools.web_tools._get_backend()`` order, so installs that never
|
||||
set a config key keep landing on the same provider.
|
||||
4. Keyless free-tier walk (``_KEYLESS_PREFERENCE``), last resort.
|
||||
5. ``None`` — the tool points the user at ``hermes tools``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -47,11 +33,6 @@ _registry: ProviderRegistry[WebSearchProvider] = ProviderRegistry(
|
||||
_registry.export(globals())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Active-provider resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _read_config_key(*path: str) -> Optional[str]:
|
||||
"""Resolve a dotted config key from ``config.yaml``. Returns None on miss."""
|
||||
try:
|
||||
@@ -75,38 +56,21 @@ def _configured_backend(capability: str) -> Optional[str]:
|
||||
return _read_config_key("web", f"{capability}_backend") or _read_config_key("web", "backend")
|
||||
|
||||
|
||||
# Legacy preference order — preserves behaviour for users who set no
|
||||
# ``web.backend`` / ``web.<capability>_backend`` config key at all. Matches
|
||||
# the historic candidate order in :func:`tools.web_tools._get_backend`
|
||||
# (paid providers first so existing paid setups don't get downgraded to
|
||||
# a free tier on upgrade). Filtered by ``is_available()`` at walk time so
|
||||
# we don't surface a provider the user has no credentials for.
|
||||
_LEGACY_PREFERENCE = (
|
||||
"firecrawl", "parallel", "tavily", "exa", "searxng", "brave-free", "ddgs"
|
||||
)
|
||||
# Paid providers first so existing paid setups don't get downgraded to a free
|
||||
# tier on upgrade; filtered by ``is_available()`` at walk time.
|
||||
_LEGACY_PREFERENCE = ("firecrawl", "parallel", "tavily", "exa", "searxng", "brave-free", "ddgs")
|
||||
|
||||
# Keyless free-tier walk — strictly LAST-resort, tried only after the
|
||||
# availability-filtered legacy walk finds nothing (i.e. the user has zero
|
||||
# web credentials and no importable ddgs). Ring vendors expose public
|
||||
# anonymous free tiers (see plugins/web/keyless_mcp.py). Unpinned keyless
|
||||
# traffic round-robins across the ring per request (the ring cursor lives
|
||||
# in keyless_mcp; an explicit `hermes tools` pick bypasses this walk
|
||||
# entirely, and rate-limited requests fail over to the next ring vendor).
|
||||
# Disable the tier with ``web.keyless_fallback: false``.
|
||||
_KEYLESS_PREFERENCE = (
|
||||
"exa", "parallel", "firecrawl", "keenable"
|
||||
)
|
||||
# Anonymous public free tiers (see plugins/web/keyless_mcp.py); strictly last
|
||||
# resort, i.e. zero web credentials and no importable ddgs. Unpinned keyless
|
||||
# traffic round-robins across the ring per request; an explicit `hermes tools`
|
||||
# pick bypasses this walk. Disable with ``web.keyless_fallback: false``.
|
||||
_KEYLESS_PREFERENCE = ("exa", "parallel", "firecrawl", "keenable")
|
||||
|
||||
|
||||
def _keyless_preference() -> tuple:
|
||||
"""Return the keyless walk order for resolution.
|
||||
|
||||
Delegates the entry-vendor choice to the ring cursor in
|
||||
:mod:`plugins.web.keyless_mcp` (round-robin per request, seeded by the
|
||||
per-process random session id) so resolution and dispatch agree on
|
||||
which vendor a fresh install starts at. The remaining vendors follow
|
||||
in ring order as fallbacks for registration gaps.
|
||||
"""
|
||||
"""Keyless walk order, starting at the ring cursor in
|
||||
:mod:`plugins.web.keyless_mcp` so resolution and dispatch agree on which
|
||||
vendor a fresh install starts at; the rest follow in ring order."""
|
||||
try:
|
||||
from plugins.web.keyless_mcp import _KEYLESS_RING, _ring_cursor
|
||||
|
||||
@@ -133,9 +97,7 @@ def _resolve(configured: Optional[str], *, capability: str) -> Optional[WebSearc
|
||||
def _capable(p: WebSearchProvider) -> bool:
|
||||
if capability == "search":
|
||||
return bool(p.supports_search())
|
||||
if capability == "extract":
|
||||
return bool(p.supports_extract())
|
||||
return False
|
||||
return bool(p.supports_extract()) if capability == "extract" else False
|
||||
|
||||
def _available(p: WebSearchProvider) -> bool:
|
||||
return is_available_safe(p, logger, "provider %s.is_available() raised %s")
|
||||
@@ -198,16 +160,13 @@ def _keyless_tier_enabled() -> bool:
|
||||
|
||||
def _disabled_web_plugin_for(configured: Optional[str] = None, *, capability: Optional[str] = None) -> Optional[str]:
|
||||
"""Plugin key of a *disabled* bundled web plugin that would have provided
|
||||
the configured backend (``web.<capability>_backend`` → ``web.backend``),
|
||||
or None.
|
||||
the configured backend (``web.<capability>_backend`` → ``web.backend``), or None.
|
||||
|
||||
Lets the dispatcher say "re-enable web-firecrawl" instead of a misleading
|
||||
"No web extract provider configured" when the backend IS configured but
|
||||
listed in ``plugins.disabled``. Resolving from config.yaml (rather than
|
||||
the resolved backend) matters because a disabled provider fails the
|
||||
availability gate and the dispatcher silently drops to the default.
|
||||
Bundled web plugins live under ``web/<vendor>`` with the provider name
|
||||
differing only by hyphen/underscore, so both sides are normalized.
|
||||
Lets the dispatcher say "re-enable web-firecrawl" instead of "No web extract
|
||||
provider configured". Resolved from config.yaml rather than the resolved
|
||||
backend because a disabled provider fails the availability gate and silently
|
||||
drops to the default. Bundled web plugins live under ``web/<vendor>`` with
|
||||
the provider name differing only by hyphen/underscore, so both are normalized.
|
||||
"""
|
||||
def _norm(s: str) -> str:
|
||||
return s.strip().lower().replace("-", "_")
|
||||
@@ -223,14 +182,10 @@ def _disabled_web_plugin_for(configured: Optional[str] = None, *, capability: Op
|
||||
|
||||
pm = get_plugin_manager()
|
||||
for key, loaded in pm._plugins.items():
|
||||
if not isinstance(key, str) or not key.startswith("web/"):
|
||||
continue
|
||||
if loaded.enabled:
|
||||
continue
|
||||
if loaded.error != "disabled via config":
|
||||
continue
|
||||
vendor = key.split("/", 1)[1]
|
||||
if _norm(vendor) == want:
|
||||
if (
|
||||
isinstance(key, str) and key.startswith("web/") and not loaded.enabled
|
||||
and loaded.error == "disabled via config" and _norm(key.split("/", 1)[1]) == want
|
||||
):
|
||||
return key
|
||||
except Exception as exc: # noqa: BLE001 — diagnostics are best-effort
|
||||
logger.debug("disabled-web-plugin lookup failed: %s", exc)
|
||||
|
||||
Reference in New Issue
Block a user