mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
ci: ruff UP037 fixes
This commit is contained in:
1 parent
9eab688b42
commit
2ff58849f4
388 files changed
+1300
-1300
No files matched your search
@@ -32,7 +32,7 @@ SNAPSHOT_STALE_AFTER_S = 15 * 60.0
|
||||
|
||||
_lock = threading.Lock()
|
||||
# slot key (home, provider, identity_id, base_url) → snapshot
|
||||
_snapshots: dict[tuple[str, str, str, str], "AccountUsageSnapshot"] = {}
|
||||
_snapshots: dict[tuple[str, str, str, str], AccountUsageSnapshot] = {}
|
||||
# slot key → monotonic time the stored snapshot's fetch STARTED (late-fetch guard)
|
||||
_started: dict[tuple[str, str, str, str], float] = {}
|
||||
# slot key → monotonic time of the last refresh try (success or failure)
|
||||
@@ -87,7 +87,7 @@ def _identity_id_for(provider: str, entry: Any) -> str:
|
||||
|
||||
|
||||
def remember_account_usage(
|
||||
provider: Optional[str], snapshot: Optional["AccountUsageSnapshot"],
|
||||
provider: Optional[str], snapshot: Optional[AccountUsageSnapshot],
|
||||
*, identity_id: Optional[str] = None, base_url: Optional[str] = None,
|
||||
started_monotonic: Optional[float] = None,
|
||||
) -> None:
|
||||
@@ -127,12 +127,12 @@ def remember_account_usage(
|
||||
|
||||
def cached_account_usage(
|
||||
provider: str, *, identity_id: Optional[str] = None, base_url: Optional[str] = None,
|
||||
) -> Optional["AccountUsageSnapshot"]:
|
||||
) -> Optional[AccountUsageSnapshot]:
|
||||
with _lock:
|
||||
return _snapshots.get(_key(provider, identity_id, base_url))
|
||||
|
||||
|
||||
def snapshot_is_stale(snapshot: Optional["AccountUsageSnapshot"]) -> bool:
|
||||
def snapshot_is_stale(snapshot: Optional[AccountUsageSnapshot]) -> bool:
|
||||
"""True when the snapshot is too old to render as a live gauge (the picker shows state
|
||||
``unknown`` instead of trusting its percentages)."""
|
||||
from agent.account_usage import AccountUsageSnapshot as _Snapshot
|
||||
|
||||
@@ -16,7 +16,7 @@ from agent.session_activity import ActivityProvenance
|
||||
logger = logging.getLogger("run_agent")
|
||||
|
||||
|
||||
def _activity_lock(obj) -> "threading.Lock":
|
||||
def _activity_lock(obj) -> threading.Lock:
|
||||
"""Lazy per-instance ``_turn_liveness_activity_lock`` (so ``__new__``/SimpleNamespace doubles work)."""
|
||||
_lock = getattr(obj, "_turn_liveness_activity_lock", None)
|
||||
if _lock is None:
|
||||
@@ -28,7 +28,7 @@ def _activity_lock(obj) -> "threading.Lock":
|
||||
class ActivityTrackingMixin:
|
||||
"""Liveness timestamps/labels and rate-limited session activity persistence."""
|
||||
|
||||
def _liveness_activity_lock(self) -> "threading.Lock":
|
||||
def _liveness_activity_lock(self) -> threading.Lock:
|
||||
"""Shared lock for the activity clock and its generation counter.
|
||||
|
||||
``_touch_activity`` stamps under it and the liveness watchdog samples/commits under it, so a stall
|
||||
|
||||
+1
-1
@@ -2388,7 +2388,7 @@ def init_agent(
|
||||
gateway_session_key: str | None = None, skip_context_files: bool = False,
|
||||
load_soul_identity: bool = False, skip_memory: bool = False,
|
||||
skip_background_review: bool = False, session_db=None, parent_session_id: str | None = None,
|
||||
iteration_budget: "IterationBudget" = None, run_budget_seconds: Optional[float] = None,
|
||||
iteration_budget: IterationBudget = None, run_budget_seconds: Optional[float] = None,
|
||||
fallback_model: dict[str, Any] | None = None, credential_pool=None, checkpoints_enabled: bool = False,
|
||||
checkpoint_max_snapshots: int = 20, checkpoint_max_total_size_mb: int = 500,
|
||||
checkpoint_max_file_size_mb: int = 10, pass_session_id: bool = False,
|
||||
|
||||
@@ -132,7 +132,7 @@ _SPENT_ROTATION_LOCK = threading.Lock()
|
||||
# Fingerprints of Claude Code refresh tokens the endpoint rejected terminally: the WARNING fires once per token
|
||||
# per process and later attempts skip the POST (a re-login rotates the token, so a new one is tried normally).
|
||||
_DEAD_REFRESH_TOKEN_FINGERPRINTS: set = set()
|
||||
_SPENT_ROTATION_FINGERPRINTS: "OrderedDict[str, None]" = OrderedDict()
|
||||
_SPENT_ROTATION_FINGERPRINTS: OrderedDict[str, None] = OrderedDict()
|
||||
_SPENT_ROTATION_MAX_TRACKED = 64
|
||||
_SPENT_ROTATION_SIDECAR_COMMENT = (
|
||||
"Non-secret one-way fingerprints of Anthropic OAuth credentials whose rotation was "
|
||||
@@ -238,7 +238,7 @@ def _claude_oauth_record(data: Any, source: str) -> Optional[dict[str, Any]]:
|
||||
_KEYCHAIN_ATTR = r'(?:0x(?P<hex>[0-9A-Fa-f]+)\b.*|"(?P<text>.*)")'
|
||||
|
||||
|
||||
def _decode_keychain_attr(match: Optional["re.Match[str]"]) -> str:
|
||||
def _decode_keychain_attr(match: Optional[re.Match[str]]) -> str:
|
||||
"""``security`` prints an attribute as ``"text"`` when it is plain printable ASCII and as
|
||||
``0x<HEX> "<octal-escaped echo>"`` otherwise; the quoted form is NOT escaped (an embedded
|
||||
``"`` appears raw), so the text group must run to the last quote on the line."""
|
||||
|
||||
@@ -40,7 +40,7 @@ def safe_schedule_threadsafe(
|
||||
return None
|
||||
|
||||
|
||||
def consume_detached_task_result(task: "asyncio.Future[Any]") -> None:
|
||||
def consume_detached_task_result(task: asyncio.Future[Any]) -> None:
|
||||
"""``add_done_callback`` for cancelled-and-detached tasks: observe the exception so the
|
||||
loop does not log "exception was never retrieved"; cancellation and terminal errors
|
||||
are swallowed because the task's owner already gave up on it."""
|
||||
|
||||
@@ -655,7 +655,7 @@ def remember_temperature_rejection(
|
||||
|
||||
def _fixed_temperature_for_model(
|
||||
model: Optional[str], base_url: Optional[str] = None, provider: Optional[str] = None,
|
||||
) -> "Optional[float] | object":
|
||||
) -> Optional[float] | object:
|
||||
"""``OMIT_TEMPERATURE`` (drop the key; Kimi/Moonshot, OpenAI reasoning families, routes that
|
||||
already rejected it), a fixed ``float``, or ``None``."""
|
||||
if _is_kimi_model(model):
|
||||
@@ -7016,7 +7016,7 @@ def _client_streams_internally(client: Any) -> bool:
|
||||
|
||||
|
||||
_MANAGED_LOCAL_STATE_TTL_S = 15.0
|
||||
_managed_local_cache: "tuple[float, str]" = (0.0, "")
|
||||
_managed_local_cache: tuple[float, str] = (0.0, "")
|
||||
|
||||
|
||||
def _managed_local_netloc() -> str:
|
||||
@@ -7123,7 +7123,7 @@ def _create_with_progress(
|
||||
return _create_with_progress_once(client, retry_kwargs, task, force_stream=force_stream)
|
||||
|
||||
|
||||
def _stream_request_plan(kwargs: dict[str, Any]) -> "tuple[dict[str, Any], str, float]":
|
||||
def _stream_request_plan(kwargs: dict[str, Any]) -> tuple[dict[str, Any], str, float]:
|
||||
"""(stream kwargs, model name, total ceiling) for a streamed re-aggregation."""
|
||||
stream_kwargs = dict(kwargs)
|
||||
stream_kwargs["stream"] = True
|
||||
@@ -7190,7 +7190,7 @@ def _create_with_progress_once(
|
||||
|
||||
def _aggregate_chat_stream(
|
||||
chunks: Any, *, model: str = "", total_ceiling: Optional[float] = None,
|
||||
no_progress: "Optional[tuple[float, Optional[float]]]" = None,
|
||||
no_progress: Optional[tuple[float, Optional[float]]] = None,
|
||||
) -> Any:
|
||||
"""Consume a chunk stream into a complete response; TimeoutError (phrased "timed out" so
|
||||
``_is_timeout_error`` matches) past *total_ceiling* or the *no_progress* windows (#100501)."""
|
||||
@@ -7576,7 +7576,7 @@ _FALLBACK_REASONS: tuple[tuple[Callable[[Exception], bool], str], ...] = (
|
||||
)
|
||||
|
||||
|
||||
def _rung(step: "_LadderStep", accept: Callable[[Exception], bool]):
|
||||
def _rung(step: _LadderStep, accept: Callable[[Exception], bool]):
|
||||
"""One ladder rung: perform ``step``; yields ``(response, None)`` on success,
|
||||
``(None, exc)`` when ``accept(exc)`` lets the next rung handle it, else re-raises."""
|
||||
try:
|
||||
|
||||
@@ -132,7 +132,7 @@ class ChatStreamWatchdog:
|
||||
f"({elapsed:.1f}s elapsed, timed out)")
|
||||
|
||||
|
||||
def chat_stream_windows(client: Any, kwargs: dict, task: Optional[str]) -> "tuple[float, Optional[float]]":
|
||||
def chat_stream_windows(client: Any, kwargs: dict, task: Optional[str]) -> tuple[float, Optional[float]]:
|
||||
"""(inter-chunk window, first-token window) for a streamed chat-completions attempt. Between
|
||||
chunks: the Codex guard's window (``auxiliary.<task>.no_progress_timeout``, 60s default). Before
|
||||
the first token: the main loop's cloud stale patience for the routed provider (its explicit
|
||||
@@ -156,7 +156,7 @@ def chat_stream_windows(client: Any, kwargs: dict, task: Optional[str]) -> "tupl
|
||||
return window, first
|
||||
|
||||
|
||||
def consume_chat_stream(chunks: Any, acc: Any, no_progress: "Optional[tuple[float, Optional[float]]]") -> None:
|
||||
def consume_chat_stream(chunks: Any, acc: Any, no_progress: Optional[tuple[float, Optional[float]]]) -> None:
|
||||
"""Feed *chunks* into the accumulator *acc*. Under a ``(window, first_token_window)`` watchdog a
|
||||
silent stream raises TimeoutError, except after the terminal chunk: with finish_reason (and any
|
||||
usage) in hand a stall is teardown, so the completed response and its billed usage are kept."""
|
||||
|
||||
@@ -89,7 +89,7 @@ class EntraIdentityConfig:
|
||||
return {"scope": self.scope, "exclude_interactive_browser": self.exclude_interactive_browser}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Optional[dict[str, Any]], *, default_scope: Optional[str] = None) -> "EntraIdentityConfig":
|
||||
def from_dict(cls, data: Optional[dict[str, Any]], *, default_scope: Optional[str] = None) -> EntraIdentityConfig:
|
||||
data = data or {}
|
||||
return cls(
|
||||
scope=str(data.get("scope") or "").strip() or default_scope or SCOPE_AI_AZURE_DEFAULT,
|
||||
@@ -318,7 +318,7 @@ def build_bearer_http_client(token_provider: Callable[[], str], **httpx_kwargs:
|
||||
raise ValueError("build_bearer_http_client requires a zero-arg callable token provider")
|
||||
import httpx
|
||||
|
||||
def _inject_bearer(request: "httpx.Request") -> None:
|
||||
def _inject_bearer(request: httpx.Request) -> None:
|
||||
try:
|
||||
token = materialize_bearer_for_http(token_provider)
|
||||
except ValueError as exc:
|
||||
|
||||
@@ -51,7 +51,7 @@ class BackendIdentity:
|
||||
def build(
|
||||
cls, provider: Optional[str] = None, model: Optional[str] = None,
|
||||
base_url: Optional[str] = None,
|
||||
) -> "BackendIdentity":
|
||||
) -> BackendIdentity:
|
||||
return cls(
|
||||
provider=_norm(provider), model=_norm(model),
|
||||
base_url=normalize_route_base_url(base_url),
|
||||
|
||||
@@ -486,7 +486,7 @@ def _provider_preferences_for_agent(agent) -> dict[str, Any]:
|
||||
return {key: value for key, value in merged.items() if value}
|
||||
|
||||
|
||||
def _prompt_cache_scope_for_agent(agent) -> "str | None":
|
||||
def _prompt_cache_scope_for_agent(agent) -> str | None:
|
||||
"""Rotation-stable logical cache scope for *agent*, or None (transports then
|
||||
fall back to the physical session_id, so a failure never blocks the build)."""
|
||||
try:
|
||||
@@ -618,7 +618,7 @@ def _check_stale_giveup(agent) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _stream_env_stale_base() -> "tuple[float, bool]":
|
||||
def _stream_env_stale_base() -> tuple[float, bool]:
|
||||
"""(HERMES_STREAM_STALE_TIMEOUT or the implicit 180s, explicit) — like
|
||||
``AIAgent._resolved_api_call_stale_timeout_base``; an explicit env value is the
|
||||
user's deadline, so it is never capped to the run budget."""
|
||||
@@ -700,7 +700,7 @@ def _cloud_stale_timeout_for(agent, api_kwargs: dict) -> float:
|
||||
return timeout if explicit_env else cap_to_run_budget(agent, timeout)
|
||||
|
||||
|
||||
def _bedrock_reasoning_stale_floor(model_id: object) -> "float | None":
|
||||
def _bedrock_reasoning_stale_floor(model_id: object) -> float | None:
|
||||
"""Map a Bedrock inference-profile id to its reasoning stale-timeout floor.
|
||||
|
||||
``us.anthropic.claude-opus-4-6-v1:0`` -> strip the region prefix, then try the
|
||||
@@ -831,7 +831,7 @@ def should_use_direct_api_call(agent) -> bool:
|
||||
_DIRECT_API_ACTIVITY_HEARTBEAT_SECONDS = 15.0
|
||||
|
||||
|
||||
def _managed_local_load_notice(agent, api_kwargs: dict) -> "Optional[str]":
|
||||
def _managed_local_load_notice(agent, api_kwargs: dict) -> Optional[str]:
|
||||
"""Live phase notice ("⏳ loading <model> into memory — N%" / "⚙ processing
|
||||
prompt — P%") while the managed local server works before the first token;
|
||||
None when neither applies. Otherwise a cold load reads as a generic stall."""
|
||||
@@ -1824,7 +1824,7 @@ _FALLBACK_REASON_LABELS = {
|
||||
}
|
||||
|
||||
|
||||
def _fallback_reason_text(reason: "FailoverReason | None") -> str:
|
||||
def _fallback_reason_text(reason: FailoverReason | None) -> str:
|
||||
"""Return a concise operator-facing explanation for a fallback switch."""
|
||||
label = _FALLBACK_REASON_LABELS.get(reason)
|
||||
return label or str(getattr(reason, "value", None) or reason or "provider failure").replace("_", " ")
|
||||
@@ -1930,7 +1930,7 @@ def _log_fallback_activated(agent, reason, old_model, old_provider, fb_model, fb
|
||||
)
|
||||
|
||||
|
||||
def _fallback_chain_exhausted(agent, reason: "FailoverReason | None") -> bool:
|
||||
def _fallback_chain_exhausted(agent, reason: FailoverReason | None) -> bool:
|
||||
"""Chain exhausted (always False). A non-empty chain walked on a non-rate-limit failure arms a
|
||||
short cooldown so next turn's restore_primary_runtime stays gated instead of replaying the whole
|
||||
context across every provider again."""
|
||||
@@ -2069,7 +2069,7 @@ def _buffer_fallback_notice(agent, notice: str) -> None:
|
||||
agent._pending_fallback_notice = [str(pending), notice] if pending else [notice]
|
||||
|
||||
|
||||
def try_activate_fallback(agent, reason: "FailoverReason | None" = None, reset_at=None) -> bool:
|
||||
def try_activate_fallback(agent, reason: FailoverReason | None = None, reset_at=None) -> bool:
|
||||
"""Switch to the next fallback model/provider in the chain; False when exhausted. Swaps client,
|
||||
model slug and provider in place so the retry loop continues on the new backend; client
|
||||
construction goes through resolve_provider_client (no duplicated provider→key mappings)."""
|
||||
|
||||
+14
-14
@@ -1055,7 +1055,7 @@ Spend up to ~{_LEAN_SESSION_LOG_BUDGET_TOKENS} tokens here — this section is t
|
||||
# (SHAs, ids, error strings) cannot be paraphrased away; also a session_search map.
|
||||
_LEAN_ANCHOR_HEADING = "## Anchor Index (mechanically extracted, exact)"
|
||||
_LEAN_ANCHOR_BUDGET_CHARS = 7_000
|
||||
_ANCHOR_PATTERNS: "list[tuple[str, re.Pattern[str], int]]" = [
|
||||
_ANCHOR_PATTERNS: list[tuple[str, re.Pattern[str], int]] = [
|
||||
("PRs/issues", re.compile(r"#\d{3,6}\b"), 120),
|
||||
("commits", re.compile(r"\b[0-9a-f]{9,40}\b"), 40),
|
||||
("branches", re.compile(r"\b(?:fix|feat|docs|refactor|chore|salvage|ent)/[A-Za-z0-9._/-]{3,60}"), 40),
|
||||
@@ -1352,17 +1352,17 @@ def _estimate_msg_budget_tokens(msg: dict, charge_stale_thinking: bool = True) -
|
||||
return tokens
|
||||
|
||||
|
||||
def _last_index_with_role(messages: "list[dict[str, Any]]", role: str) -> int:
|
||||
def _last_index_with_role(messages: list[dict[str, Any]], role: str) -> int:
|
||||
"""Index of the newest dict message with ``role``, or -1."""
|
||||
return max((i for i, m in enumerate(messages) if isinstance(m, dict) and m.get("role") == role), default=-1)
|
||||
|
||||
|
||||
def _last_assistant_index(messages: "list[dict[str, Any]]") -> int:
|
||||
def _last_assistant_index(messages: list[dict[str, Any]]) -> int:
|
||||
"""Newest assistant message index, or -1 (the one turn whose thinking may replay; see ``_NEWEST_TURN_ONLY_BUDGET_KEYS``)."""
|
||||
return _last_index_with_role(messages, "assistant")
|
||||
|
||||
|
||||
def _pending_tool_round(messages: "list[dict[str, Any]]") -> range:
|
||||
def _pending_tool_round(messages: list[dict[str, Any]]) -> range:
|
||||
"""Indices of the tool results the transcript ends with — a round the model has not answered yet; empty when
|
||||
the transcript ends in any other row. /steer rows after the round do not answer it: a steer is delivered
|
||||
after the newest tool result before the next API call, and two can land in one iteration (one when the tool
|
||||
@@ -2011,7 +2011,7 @@ def _json_dict(text: Any) -> dict:
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
|
||||
|
||||
def _summarize_refused_tool_result(tool_name: str, args: dict, content: str) -> "str | None":
|
||||
def _summarize_refused_tool_result(tool_name: str, args: dict, content: str) -> str | None:
|
||||
"""Summary for a call an approval or write guard refused (``BLOCKED: ...`` / ``status``
|
||||
``blocked``/``pending_approval``), else None. The per-tool summarizers describe the call as done
|
||||
("ran ...", "wrote to ..."), which would turn a user's denial into a record of the action and
|
||||
@@ -2050,7 +2050,7 @@ def _summarize_tool_result_unguarded(tool_name: str, tool_args: str, tool_conten
|
||||
return f"[{tool_name}]{first_arg} ({content_len:,} chars result)" + _result_failure_suffix(content)
|
||||
|
||||
|
||||
def _model_threshold_key_rank(key: str, model: str, provider: str) -> "tuple[int, int] | None":
|
||||
def _model_threshold_key_rank(key: str, model: str, provider: str) -> tuple[int, int] | None:
|
||||
"""Match rank for one ``model_thresholds`` key, or None when it does not apply.
|
||||
``"<provider>:<substr>"`` keys apply only on that provider; bare keys apply on every route.
|
||||
The same slug means different windows on different routes (Codex caps Astra at 272K; OpenRouter
|
||||
@@ -2934,7 +2934,7 @@ class ContextCompressor(SummaryDispatchMixin, PreLlmSkipMixin, MicroCompactionMi
|
||||
# Dedup key for the over-threshold "reclamation no-oped" warning
|
||||
# (#101889) so a tool loop riding above the threshold warns once per
|
||||
# distinct reason + rearm snapshot instead of every iteration.
|
||||
self._last_reclaim_block_warn: "tuple[str, int] | None" = None
|
||||
self._last_reclaim_block_warn: tuple[str, int] | None = None
|
||||
self.min_tail_user_messages = min_tail_user_messages
|
||||
self.summary_target_ratio = max(0.10, min(summary_target_ratio, 0.80))
|
||||
self.quiet_mode = quiet_mode
|
||||
@@ -3090,7 +3090,7 @@ class ContextCompressor(SummaryDispatchMixin, PreLlmSkipMixin, MicroCompactionMi
|
||||
"""True when compression should run now (anti-thrash included; see :meth:`should_compress_info` for the reason)."""
|
||||
return self.should_compress_info(prompt_tokens)[0]
|
||||
|
||||
def should_compress_info(self, prompt_tokens: int | None = None) -> "tuple[bool, str | None]":
|
||||
def should_compress_info(self, prompt_tokens: int | None = None) -> tuple[bool, str | None]:
|
||||
"""Return ``(should_compress, reason)``.
|
||||
``reason`` is None unless compression is needed but blocked: ``"cooldown:<seconds>"`` or
|
||||
``"ineffective"``. Callers should surface a warning when it is non-None."""
|
||||
@@ -3101,7 +3101,7 @@ class ContextCompressor(SummaryDispatchMixin, PreLlmSkipMixin, MicroCompactionMi
|
||||
return False, self._compression_block_reason() or "blocked"
|
||||
return True, None
|
||||
|
||||
def _compression_block_reason(self) -> "str | None":
|
||||
def _compression_block_reason(self) -> str | None:
|
||||
"""Block reason: ``"cooldown:<s>"``, ``"structural_backoff:<s>"``, ``"ineffective"``, or None."""
|
||||
for label, until in (
|
||||
("cooldown", self._summary_failure_cooldown_until), ("structural_backoff", self._structural_no_op_backoff_until),
|
||||
@@ -3421,7 +3421,7 @@ class ContextCompressor(SummaryDispatchMixin, PreLlmSkipMixin, MicroCompactionMi
|
||||
self._proactive_prune_rearm_tokens = 0
|
||||
self._last_reclaim_block_warn = None
|
||||
|
||||
def _billed_basis_over_threshold(self, current_tokens: "int | None") -> bool:
|
||||
def _billed_basis_over_threshold(self, current_tokens: int | None) -> bool:
|
||||
"""Whether a provider-billed reading says the session is over threshold.
|
||||
|
||||
``current_tokens`` is the provider's ``prompt_tokens`` (or the
|
||||
@@ -3440,8 +3440,8 @@ class ContextCompressor(SummaryDispatchMixin, PreLlmSkipMixin, MicroCompactionMi
|
||||
def _warn_reclamation_no_op(
|
||||
self,
|
||||
reason: str,
|
||||
current_tokens: "int | None",
|
||||
before: "int | None" = None,
|
||||
current_tokens: int | None,
|
||||
before: int | None = None,
|
||||
) -> None:
|
||||
"""Warn when an over-threshold session's reclamation path no-ops.
|
||||
|
||||
@@ -5298,7 +5298,7 @@ Write only the summary body. Do not include any preamble or prefix."""
|
||||
def _scan_window_handoffs(
|
||||
self, messages: list[dict[str, Any]], compress_start: int, compress_end: int,
|
||||
turns_to_summarize: list[dict[str, Any]],
|
||||
) -> "_HandoffScan":
|
||||
) -> _HandoffScan:
|
||||
"""Rehydrate ``_previous_summary`` / user-turn provenance from in-transcript handoffs.
|
||||
Handoff rows are removed from the summarizer window (merged handoffs unwrap to their prior-tail
|
||||
content) and ``tail_start`` advances past a handoff beyond the window. The pre-scan state is
|
||||
@@ -5736,7 +5736,7 @@ Write only the summary body. Do not include any preamble or prefix."""
|
||||
return self._finalize_compressed(compressed, canonical_messages, n_messages, spare_pending_images)
|
||||
|
||||
def _assemble_compressed(
|
||||
self, messages: list[dict[str, Any]], compress_start: int, compress_end: int, scan: "_HandoffScan", summary: str,
|
||||
self, messages: list[dict[str, Any]], compress_start: int, compress_end: int, scan: _HandoffScan, summary: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Head + summary + tail from the pruned copy: its tool-result demotions are what let an oversized
|
||||
head/tail compress at all (#61932); tool-call arguments are never rewritten by pruning."""
|
||||
|
||||
@@ -58,7 +58,7 @@ class SummaryDispatchMixin:
|
||||
call_kwargs[key] = value
|
||||
|
||||
def _summarize_window(
|
||||
self, messages: list[dict[str, Any]], turns_to_summarize: list[dict[str, Any]], scan: "_HandoffScan",
|
||||
self, messages: list[dict[str, Any]], turns_to_summarize: list[dict[str, Any]], scan: _HandoffScan,
|
||||
focus_topic: Optional[str], memory_context: str, bypass_cooldown: bool,
|
||||
) -> Optional[str]:
|
||||
"""Run the summary LLM; a cancellation rolls back the handoff scan's self-heal mutation first.
|
||||
|
||||
@@ -85,7 +85,7 @@ class ContextEngine(ABC):
|
||||
def should_compress(self, prompt_tokens: int | None = None) -> bool:
|
||||
"""Return True if compaction should fire this turn."""
|
||||
|
||||
def should_compress_info(self, prompt_tokens: int | None = None) -> "tuple[bool, str | None]":
|
||||
def should_compress_info(self, prompt_tokens: int | None = None) -> tuple[bool, str | None]:
|
||||
"""Return ``(should_compress, reason)``.
|
||||
|
||||
Engines with block reasons (summary-LLM cooldown, anti-thrashing guard) override
|
||||
@@ -223,7 +223,7 @@ class ContextEngine(ABC):
|
||||
"compression_count": self.compression_count,
|
||||
}
|
||||
|
||||
def clone_for_agent(self) -> "ContextEngine":
|
||||
def clone_for_agent(self) -> ContextEngine:
|
||||
"""Per-agent instance of a plugin-registered engine (the plugin system holds ONE shared
|
||||
instance; every AIAgent gets its own so a child's update_model() cannot mutate the parent's).
|
||||
Override when the engine holds uncopyable state (locks, DB connections): return a fresh
|
||||
|
||||
@@ -59,7 +59,7 @@ def _loaded_status(content: str, rendered_len: int, max_chars: int, user_authore
|
||||
|
||||
def list_context_file_sources(
|
||||
cwd: Optional[str] = None, context_length: Optional[int] = None, allow_install_tree_fallback: bool = False,
|
||||
home_override: "Path | None" = None, skip_soul: bool = False,
|
||||
home_override: Path | None = None, skip_soul: bool = False,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""One dict per context file Hermes considered, in the builder's priority order.
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ from hermes_cli.sizefmt import format_bytes
|
||||
# provider API (Issue #26193) ---------------------------------------------------------------------------
|
||||
BUILTIN_PREFIXES = frozenset({"diff", "staged", "file", "folder", "git", "url"})
|
||||
|
||||
_context_reference_providers: dict[str, "ContextReferenceProvider"] = {}
|
||||
_context_reference_providers: dict[str, ContextReferenceProvider] = {}
|
||||
|
||||
|
||||
class ContextCompletionItem:
|
||||
|
||||
@@ -1787,7 +1787,7 @@ class _CompressionActivityHeartbeat:
|
||||
self._stop = threading.Event()
|
||||
self._thread = threading.Thread(target=self._run, name="compression-activity-heartbeat", daemon=True)
|
||||
|
||||
def start(self) -> "_CompressionActivityHeartbeat":
|
||||
def start(self) -> _CompressionActivityHeartbeat:
|
||||
# A new compression episode always republishes agent.compression even
|
||||
# if a prior timeout/cooldown stamp is still on the agent.
|
||||
self._suppressed = False
|
||||
@@ -1905,7 +1905,7 @@ class _CompressionLockLeaseRefresher:
|
||||
self._stop = threading.Event()
|
||||
self._thread = threading.Thread(target=self._run, name="compression-lock-refresh", daemon=True)
|
||||
|
||||
def start(self) -> "_CompressionLockLeaseRefresher":
|
||||
def start(self) -> _CompressionLockLeaseRefresher:
|
||||
self._thread.start()
|
||||
return self
|
||||
|
||||
|
||||
@@ -251,7 +251,7 @@ class PooledCredential:
|
||||
raise AttributeError(f"'{type(self).__name__}' object has no attribute {name!r}")
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, provider: str, payload: dict[str, Any]) -> "PooledCredential":
|
||||
def from_dict(cls, provider: str, payload: dict[str, Any]) -> PooledCredential:
|
||||
field_names = {f.name for f in fields(cls) if f.name != "provider"}
|
||||
data = {k: payload.get(k) for k in field_names if k in payload}
|
||||
# Rehydrated last_status_at may be an ISO string from to_dict() — normalize to float epoch
|
||||
@@ -425,7 +425,7 @@ def _parse_absolute_timestamp(value: Any) -> Optional[float]:
|
||||
return None
|
||||
|
||||
|
||||
def _singleton_predates_entry(state: Any, entry: "PooledCredential") -> bool:
|
||||
def _singleton_predates_entry(state: Any, entry: PooledCredential) -> bool:
|
||||
"""True only when the auth.json singleton is PROVABLY older than *entry*.
|
||||
|
||||
Both sides stamp ``last_refresh`` on every successful rotation. When
|
||||
@@ -819,7 +819,7 @@ def _write_through_provider_state_to_global_root(
|
||||
logger.debug("%s pool refresh: write-through to global root failed: %s", provider_id, exc)
|
||||
|
||||
|
||||
def _singleton_target_for_entry(pool: "CredentialPool", entry: "PooledCredential") -> Optional[Path]:
|
||||
def _singleton_target_for_entry(pool: CredentialPool, entry: PooledCredential) -> Optional[Path]:
|
||||
"""Root ``.anthropic_oauth.json`` when *entry* is a borrowed hermes_pkce row, else None."""
|
||||
if entry.source != "hermes_pkce" or entry.id not in getattr(pool, "_borrowed_root_ids", ()):
|
||||
return None
|
||||
@@ -999,7 +999,7 @@ _RESYNC_SOURCE = {
|
||||
class _RefreshDone(Exception):
|
||||
"""Raised inside a provider refresher to short-circuit ``_refresh_entry_impl`` with ``result``."""
|
||||
|
||||
def __init__(self, result: Optional["PooledCredential"]):
|
||||
def __init__(self, result: Optional[PooledCredential]):
|
||||
super().__init__()
|
||||
self.result = result
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ if TYPE_CHECKING:
|
||||
MODEL_ENTITLEMENT_BENCH_SECONDS = 365 * 24 * 60 * 60
|
||||
|
||||
|
||||
def model_cooldown_until(entry: "PooledCredential", model: Optional[str]) -> Optional[float]:
|
||||
def model_cooldown_until(entry: PooledCredential, model: Optional[str]) -> Optional[float]:
|
||||
"""Active cooldown blocking *entry* for *model*, or ``None``.
|
||||
|
||||
Callers that do not know the model stay conservative: any active model
|
||||
@@ -75,7 +75,7 @@ class CredentialPoolModelCooldownMixin:
|
||||
)
|
||||
|
||||
def _cool_down_model(
|
||||
self, entry: "PooledCredential", model: str, error_context: Optional[dict[str, Any]],
|
||||
self, entry: PooledCredential, model: str, error_context: Optional[dict[str, Any]],
|
||||
failure_reason: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Record a cooldown for *model* on *entry* and every sibling sharing its key.
|
||||
|
||||
@@ -36,7 +36,7 @@ logger = logging.getLogger(__name__)
|
||||
_EXPIRY_SKEW_MS = 120_000
|
||||
|
||||
|
||||
def plugin_row_is_expiring(entry: "PooledCredential") -> bool:
|
||||
def plugin_row_is_expiring(entry: PooledCredential) -> bool:
|
||||
"""An expiry-stamped OAuth row (a plugin's, or Anthropic's) is due within the skew of ``expires_at_ms``.
|
||||
|
||||
The token endpoint's ``expires_in`` is the only clock: many providers issue opaque bearers (Google's
|
||||
@@ -45,7 +45,7 @@ def plugin_row_is_expiring(entry: "PooledCredential") -> bool:
|
||||
return entry.expires_at_ms is not None and int(entry.expires_at_ms) <= int(time.time() * 1000) + _EXPIRY_SKEW_MS
|
||||
|
||||
|
||||
def apply_plugin_refresh_result(entry: "PooledCredential", result: Any) -> "PooledCredential":
|
||||
def apply_plugin_refresh_result(entry: PooledCredential, result: Any) -> PooledCredential:
|
||||
"""Merge a ``refresh_credential`` return value into *entry*.
|
||||
|
||||
Field names go through ``dataclasses.replace``; everything else is merged into ``extra``
|
||||
@@ -78,8 +78,8 @@ def is_terminal_plugin_refresh_error(exc: BaseException) -> bool:
|
||||
|
||||
|
||||
def recover_failed_plugin_refresh(
|
||||
pool: "CredentialPool", entry: "PooledCredential", exc: Exception,
|
||||
) -> tuple[bool, Optional["PooledCredential"]]:
|
||||
pool: CredentialPool, entry: PooledCredential, exc: Exception,
|
||||
) -> tuple[bool, Optional[PooledCredential]]:
|
||||
"""Recovery for a plugin hook that raised: adopt a peer's rotation, or quarantine a dead grant.
|
||||
|
||||
Returns ``(handled, result)``; ``handled=False`` means the caller should bench the row as a
|
||||
|
||||
+3
-3
@@ -195,7 +195,7 @@ def resolve_timeout(key: str, *, default: Optional[float], env_var: Optional[str
|
||||
# timer dumps all thread stacks when the loop provably failed to process the expiry — the one piece of
|
||||
# information loop-blocked hangs otherwise never surface.
|
||||
# ---------------------------------------------------------------------------
|
||||
def _consume_abandoned(task: "asyncio.Future[Any]") -> None:
|
||||
def _consume_abandoned(task: asyncio.Future[Any]) -> None:
|
||||
"""Observe an abandoned task's outcome so it never logs 'never retrieved'."""
|
||||
try:
|
||||
if not task.cancelled():
|
||||
@@ -204,7 +204,7 @@ def _consume_abandoned(task: "asyncio.Future[Any]") -> None:
|
||||
pass
|
||||
|
||||
|
||||
def _abandon(task: "asyncio.Future[Any]") -> None:
|
||||
def _abandon(task: asyncio.Future[Any]) -> None:
|
||||
"""Cancel ``task`` and never await it; its outcome is consumed so it stays unobserved-safe."""
|
||||
task.cancel()
|
||||
task.add_done_callback(_consume_abandoned)
|
||||
@@ -253,7 +253,7 @@ async def run_bounded_async(
|
||||
|
||||
task = asyncio.ensure_future(awaitable)
|
||||
loop = asyncio.get_running_loop()
|
||||
deadline: "asyncio.Future[None]" = loop.create_future()
|
||||
deadline: asyncio.Future[None] = loop.create_future()
|
||||
loop_processed_expiry = threading.Event()
|
||||
|
||||
def _mark_expired() -> None:
|
||||
|
||||
@@ -138,7 +138,7 @@ def scrub_kanban_env(env: Mapping[str, str] | MutableMapping[str, str]) -> dict[
|
||||
return cleaned
|
||||
|
||||
|
||||
def kanban_path_is_fenced(path: "os.PathLike[str] | str") -> bool:
|
||||
def kanban_path_is_fenced(path: os.PathLike[str] | str) -> bool:
|
||||
"""Whether Kanban mutations at *path* (a board DB or board-metadata root) are denied for this
|
||||
process: always for an in-process delegate child (the parent's own board); for a spawned
|
||||
descendant only when *path* is the dispatcher-pinned ``HERMES_KANBAN_DB`` or lies under the
|
||||
|
||||
@@ -1301,7 +1301,7 @@ _CODEX_MASKED_REPLAY_MESSAGE = "request blocked."
|
||||
_CODEX_UNSUPPORTED_CONTENT_DETAIL = "unsupported content type"
|
||||
|
||||
|
||||
def _is_codex_masked_replay_rejection(c: "_Ctx") -> bool:
|
||||
def _is_codex_masked_replay_rejection(c: _Ctx) -> bool:
|
||||
"""HTTP 400 / status-less ``{code: invalid_prompt, message: "Request blocked."}`` from
|
||||
``openai-codex`` — as an SDK error body, a Responses ``error`` SSE frame, or the
|
||||
``response.failed`` text ``"invalid_prompt: Request blocked."`` — or the bare
|
||||
|
||||
@@ -21,7 +21,7 @@ def _provider_reset_delay(reset_at) -> float | None:
|
||||
return None
|
||||
|
||||
|
||||
def switch_deferred_by_reset(agent, reason: "FailoverReason | None", reset_at) -> bool:
|
||||
def switch_deferred_by_reset(agent, reason: FailoverReason | None, reset_at) -> bool:
|
||||
"""Opt-in ``fallback.min_switch_reset_seconds`` (default 0 = off, #117484): when the primary's
|
||||
rate limit reopens sooner than N seconds, switching model mid-task costs more than waiting, so
|
||||
the fallback walk is skipped and the retry loop's own backoff rides out the window. Only for
|
||||
@@ -43,7 +43,7 @@ def switch_deferred_by_reset(agent, reason: "FailoverReason | None", reset_at) -
|
||||
|
||||
|
||||
def _arm_rate_limit_cooldown(
|
||||
agent, reason: "FailoverReason | None", reset_at=None,
|
||||
agent, reason: FailoverReason | None, reset_at=None,
|
||||
) -> int | None:
|
||||
"""Arm the primary cooldown until the provider reset, or use exponential backoff.
|
||||
|
||||
|
||||
@@ -850,7 +850,7 @@ class GeminiNativeClient:
|
||||
return {"Content-Type": "application/json", "Accept": "application/json", **self._auth_headers(),
|
||||
"User-Agent": f"{_API_CLIENT} (gemini-native)", "X-Goog-Api-Client": _API_CLIENT, **self._default_headers}
|
||||
|
||||
def _http_error(self, response: httpx.Response, body_text: Optional[str] = None) -> "GeminiAPIError":
|
||||
def _http_error(self, response: httpx.Response, body_text: Optional[str] = None) -> GeminiAPIError:
|
||||
return gemini_http_error(response, body_text=body_text, api_key=self.api_key, base_url=self.base_url)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -18,7 +18,7 @@ from typing import Any, Iterator, Optional
|
||||
|
||||
from agent.interrupt_compat import request_hard_interrupt
|
||||
|
||||
_ACTIVE_SCOPE: ContextVar[Optional["InterruptScope"]] = ContextVar("hermes_interrupt_scope", default=None)
|
||||
_ACTIVE_SCOPE: ContextVar[Optional[InterruptScope]] = ContextVar("hermes_interrupt_scope", default=None)
|
||||
|
||||
|
||||
_TOOL_REASON_HOST_CANCELLED = "host cancelled the command"
|
||||
|
||||
@@ -189,7 +189,7 @@ class LSPService:
|
||||
self._loop.run(self._start_idle_reaper(), timeout=2.0)
|
||||
|
||||
@classmethod
|
||||
def create_from_config(cls) -> Optional["LSPService"]:
|
||||
def create_from_config(cls) -> Optional[LSPService]:
|
||||
"""Build a service from ``hermes_cli.config``; ``None`` if config can't load."""
|
||||
try:
|
||||
from hermes_cli.config import load_config_readonly
|
||||
|
||||
@@ -665,7 +665,7 @@ def matches_reasoning_echo_family(family: str, provider: Any, model: Any, base_u
|
||||
)
|
||||
|
||||
|
||||
def reasoning_echo_family(provider: Any, model: Any, base_url: Any) -> "str | None":
|
||||
def reasoning_echo_family(provider: Any, model: Any, base_url: Any) -> str | None:
|
||||
"""``"kimi"`` / ``"deepseek"`` / ``"mimo"`` (first match in table order) when the
|
||||
endpoint enforces reasoning_content echo-back, else ``None`` (strip side)."""
|
||||
families = (rule[0] for rule in _REASONING_ECHO_RULES)
|
||||
|
||||
@@ -371,7 +371,7 @@ class MicroCompactionMixin:
|
||||
except Exception as exc:
|
||||
logger.debug("failed to emit micro-compaction telemetry: %s", exc)
|
||||
|
||||
def _micro_start_watermark(self, held: list[dict[str, Any]]) -> "tuple[Optional[str], Optional[int]]":
|
||||
def _micro_start_watermark(self, held: list[dict[str, Any]]) -> tuple[Optional[str], Optional[int]]:
|
||||
"""``(skip_outcome, watermark)`` taken before a pass's slow step; *skip_outcome* is the telemetry
|
||||
label of a pass that must not run, or None.
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ class MonitoringEmitter:
|
||||
|
||||
def __init__(self, *, enabled: bool = True) -> None:
|
||||
self._enabled = enabled
|
||||
self._q: "queue.Queue[dict[str, Any]]" = queue.Queue(maxsize=_MAX_QUEUE)
|
||||
self._q: queue.Queue[dict[str, Any]] = queue.Queue(maxsize=_MAX_QUEUE)
|
||||
self._dropped = 0
|
||||
self._dispatched = 0
|
||||
self._stop = threading.Event()
|
||||
|
||||
@@ -46,7 +46,7 @@ QUEUE_MAX_SIZE = 256
|
||||
_registered: set[tuple[str, str, str]] = set()
|
||||
_registered_lock = threading.Lock()
|
||||
|
||||
_delivery_queue: "queue.Queue[Optional[dict[str, Any]]]" = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
_delivery_queue: queue.Queue[Optional[dict[str, Any]]] = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||
_worker_lock = threading.Lock()
|
||||
_worker: Optional[threading.Thread] = None
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ class ScheduledHandle:
|
||||
|
||||
__slots__ = ("_cancelled", "_context", "_fn", "_interval", "_runner", "_scheduler")
|
||||
|
||||
def __init__(self, scheduler: "PeriodicScheduler", fn: Callable[[], object], interval: float):
|
||||
def __init__(self, scheduler: PeriodicScheduler, fn: Callable[[], object], interval: float):
|
||||
self._scheduler = scheduler
|
||||
self._fn = fn
|
||||
self._interval = interval
|
||||
|
||||
@@ -80,7 +80,7 @@ STATE_ALIASES: dict[str, tuple[str, ...]] = {
|
||||
}
|
||||
|
||||
|
||||
def state_aliases_for(state: "PetState | str") -> tuple[str, ...]:
|
||||
def state_aliases_for(state: PetState | str) -> tuple[str, ...]:
|
||||
"""Return accepted row-name aliases for *state* (always non-empty)."""
|
||||
value = state.value if isinstance(state, PetState) else str(state)
|
||||
return STATE_ALIASES.get(value) or (value,)
|
||||
@@ -95,7 +95,7 @@ def state_rows_for_grid(row_count: int | None) -> list[str]:
|
||||
return CODEX_STATE_ROWS if rows >= len(CODEX_STATE_ROWS) else LEGACY_STATE_ROWS
|
||||
|
||||
|
||||
def state_row_index(state: "PetState | str", row_count: int | None = None) -> int:
|
||||
def state_row_index(state: PetState | str, row_count: int | None = None) -> int:
|
||||
"""Return the spritesheet row index for *state* (clamped, never raises)."""
|
||||
rows = state_rows_for_grid(row_count)
|
||||
return next((rows.index(name) for name in state_aliases_for(state) if name in rows), 0) # 0 = idle row
|
||||
@@ -78,7 +78,7 @@ class ManifestEntry:
|
||||
zip_url: str
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "ManifestEntry":
|
||||
def from_dict(cls, data: dict) -> ManifestEntry:
|
||||
return cls(
|
||||
slug=str(data.get("slug", "")).strip(),
|
||||
display_name=str(data.get("displayName", "") or data.get("slug", "")),
|
||||
|
||||
@@ -27,7 +27,7 @@ _STOP = object()
|
||||
class _ConsumerDispatcher:
|
||||
hook_name: str
|
||||
callback: Callable[..., Any]
|
||||
events: "queue.Queue[tuple[contextvars.Context, dict[str, Any]] | object]"
|
||||
events: queue.Queue[tuple[contextvars.Context, dict[str, Any]] | object]
|
||||
thread: threading.Thread | None = None
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ def _callback_name(callback: Callable[..., Any]) -> str:
|
||||
return getattr(callback, "__name__", repr(callback))
|
||||
|
||||
|
||||
def _put_drop_oldest(events: "queue.Queue[Any]", item: Any) -> bool:
|
||||
def _put_drop_oldest(events: queue.Queue[Any], item: Any) -> bool:
|
||||
"""put_nowait; on a full queue evict the oldest pending event and retry once."""
|
||||
try:
|
||||
events.put_nowait(item)
|
||||
|
||||
+18
-18
@@ -61,7 +61,7 @@ def _read_text_with_timeout(
|
||||
"""
|
||||
if timeout is None:
|
||||
timeout = _get_context_file_read_timeout()
|
||||
result: "queue.Queue[tuple[bool, object]]" = queue.Queue(maxsize=1)
|
||||
result: queue.Queue[tuple[bool, object]] = queue.Queue(maxsize=1)
|
||||
|
||||
def _reader() -> None:
|
||||
try:
|
||||
@@ -620,8 +620,8 @@ STEER_CHANNEL_NOTE = (
|
||||
)
|
||||
|
||||
|
||||
def hud_surface_note(valid_tool_names: "set[str] | None" = None,
|
||||
deferred_tool_names: "frozenset[str] | set[str]" = frozenset()) -> str:
|
||||
def hud_surface_note(valid_tool_names: set[str] | None = None,
|
||||
deferred_tool_names: frozenset[str] | set[str] = frozenset()) -> str:
|
||||
"""Per-turn note for a message typed into the desktop's floating HUD ("this"/"here" = the app behind it).
|
||||
|
||||
A per-turn fact, not a platform (one session alternates between app window and HUD), so it rides the
|
||||
@@ -1062,7 +1062,7 @@ def _local_host_hints() -> list[str]:
|
||||
return ["\n".join(host_lines), _WINDOWS_BASH_SHELL_HINT]
|
||||
|
||||
|
||||
def bot_screen_note(running: bool, display: "str | None", holder: str) -> str:
|
||||
def bot_screen_note(running: bool, display: str | None, holder: str) -> str:
|
||||
"""The one-line Bot Screen status the model sees — the prompt's ``_bot_screen_hint`` body,
|
||||
parameterised so the display watcher can stage the same sentence as a per-turn note when a
|
||||
screen starts or stops mid-session (#125830; the byte-stable prompt only converges at
|
||||
@@ -1184,7 +1184,7 @@ def _get_context_file_max_chars(context_length: Optional[int] = None) -> int:
|
||||
|
||||
# Truncation warnings for run_agent to surface. A ContextVar so concurrent gateway prompt builds cannot
|
||||
# drain each other's.
|
||||
_truncation_warnings: "contextvars.ContextVar[Optional[list]]" = contextvars.ContextVar(
|
||||
_truncation_warnings: contextvars.ContextVar[Optional[list]] = contextvars.ContextVar(
|
||||
"context_file_truncation_warnings", default=None
|
||||
)
|
||||
|
||||
@@ -1294,8 +1294,8 @@ def _parse_skill_file(skill_file: Path) -> tuple[bool, dict, str]:
|
||||
|
||||
|
||||
def _skill_should_show(
|
||||
conditions: dict, available_tools: "set[str] | None", available_toolsets: "set[str] | None",
|
||||
session_platform: "str | None" = None,
|
||||
conditions: dict, available_tools: set[str] | None, available_toolsets: set[str] | None,
|
||||
session_platform: str | None = None,
|
||||
) -> bool:
|
||||
"""False if the skill's conditional activation rules exclude it."""
|
||||
# Gateway-channel gate runs regardless of tool info; fails open when the platform is unknown.
|
||||
@@ -1315,16 +1315,16 @@ def _skill_should_show(
|
||||
|
||||
|
||||
def _plugin_skill_prompt_rows(
|
||||
disabled: "set[str]", available_tools: "set[str] | None", available_toolsets: "set[str] | None",
|
||||
session_platform: "str | None",
|
||||
) -> "list[tuple[str, str]]":
|
||||
disabled: set[str], available_tools: set[str] | None, available_toolsets: set[str] | None,
|
||||
session_platform: str | None,
|
||||
) -> list[tuple[str, str]]:
|
||||
"""``(qualified_name, description)`` for every skill registered by an ENABLED plugin
|
||||
(``ctx.register_skill``), filtered through the same offer-time gates as on-disk skills.
|
||||
Plugin skills live in the plugin-manager registry — never under the profile skills tree —
|
||||
so the disk scans above cannot see them; this is their one path into ``<available_skills>``.
|
||||
The qualified ``plugin:skill`` name is exactly what ``skill_view`` resolves, and disabling
|
||||
or unloading a plugin removes its registry entries, so enablement gating is inherent."""
|
||||
rows: "list[tuple[str, str]]" = []
|
||||
rows: list[tuple[str, str]] = []
|
||||
try:
|
||||
from hermes_cli.plugins import discover_plugins, get_plugin_manager
|
||||
discover_plugins() # idempotent; joins an in-flight discovery (same call skills_list makes)
|
||||
@@ -1359,8 +1359,8 @@ def _current_session_platform_hint() -> str:
|
||||
|
||||
|
||||
def build_skills_system_prompt(
|
||||
available_tools: "set[str] | None" = None, available_toolsets: "set[str] | None" = None,
|
||||
compact_categories: "frozenset[str] | None" = None, skills_dir_override: "Path | None" = None,
|
||||
available_tools: set[str] | None = None, available_toolsets: set[str] | None = None,
|
||||
compact_categories: frozenset[str] | None = None, skills_dir_override: Path | None = None,
|
||||
) -> str:
|
||||
"""Compact skill index for the system prompt.
|
||||
|
||||
@@ -1432,7 +1432,7 @@ def _label_visible_entries(visible_entries: list[dict], skills_by_category: dict
|
||||
|
||||
def _render_skills_index(
|
||||
skills_by_category: dict[str, list[tuple[str, str]]], category_descriptions: dict[str, str],
|
||||
compact_categories: "frozenset[str] | None", available_tools: "set[str] | None", unloadable: "list[str]" = (),
|
||||
compact_categories: frozenset[str] | None, available_tools: set[str] | None, unloadable: list[str] = (),
|
||||
) -> str:
|
||||
"""Render the ## Skills block; "" when there is nothing to list. *unloadable* names (different skills
|
||||
sharing a name AND relative path within one tier — one root or several) get a rename note instead of a row skill_view would refuse."""
|
||||
@@ -1501,8 +1501,8 @@ def _oneshot_prompt_variant() -> bool:
|
||||
|
||||
|
||||
def _build_skills_system_prompt_inner(
|
||||
skills_dir: "Path", extra_roots: "list[tuple[int, Path]]", available_tools: "set[str] | None",
|
||||
available_toolsets: "set[str] | None", compact_categories: "frozenset[str] | None",
|
||||
skills_dir: Path, extra_roots: list[tuple[int, Path]], available_tools: set[str] | None,
|
||||
available_toolsets: set[str] | None, compact_categories: frozenset[str] | None,
|
||||
) -> str:
|
||||
# The resolved platform is part of the key: per-platform disabled-skill lists need distinct cache entries.
|
||||
_platform_hint = _current_session_platform_hint()
|
||||
@@ -1644,7 +1644,7 @@ def _omitted_headings(content: str, start: int, end: int, limit: int = 15) -> li
|
||||
return headings[:limit] + (["..."] if len(headings) > limit else [])
|
||||
|
||||
|
||||
def load_soul_md(context_length: Optional[int] = None, home_override: "Path | None" = None) -> Optional[str]:
|
||||
def load_soul_md(context_length: Optional[int] = None, home_override: Path | None = None) -> Optional[str]:
|
||||
"""SOUL.md from HERMES_HOME (identity slot #1), or None.
|
||||
|
||||
Callers must pass ``skip_soul=True`` to ``build_context_files_prompt`` so it isn't injected twice.
|
||||
@@ -1850,7 +1850,7 @@ def _load_cursorrules(cwd_path: Path, context_length: Optional[int] = None) -> s
|
||||
|
||||
def build_context_files_prompt(
|
||||
cwd: Optional[str] = None, skip_soul: bool = False, context_length: Optional[int] = None,
|
||||
allow_install_tree_fallback: bool = False, home_override: "Path | None" = None,
|
||||
allow_install_tree_fallback: bool = False, home_override: Path | None = None,
|
||||
) -> str:
|
||||
"""Discover and load context files for the system prompt (each capped, see ``_get_context_file_max_chars``).
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ _MAX_ENTRIES = 32
|
||||
_MAX_CHARS = 4 * 1024 * 1024
|
||||
|
||||
_lock = threading.Lock()
|
||||
_prefixes: "OrderedDict[str, None]" = OrderedDict()
|
||||
_prefixes: OrderedDict[str, None] = OrderedDict()
|
||||
|
||||
|
||||
def register_stable_prefix(prefix: str) -> None:
|
||||
|
||||
@@ -215,7 +215,7 @@ def allowlisted_env() -> dict[str, str]:
|
||||
return {n: os.environ[n] for n in _PROXY_SUBPROCESS_ENV_ALLOWLIST if n in os.environ}
|
||||
|
||||
|
||||
def _run(argv: list[str], *, timeout: int, text: bool = False, **kwargs) -> "subprocess.CompletedProcess":
|
||||
def _run(argv: list[str], *, timeout: int, text: bool = False, **kwargs) -> subprocess.CompletedProcess:
|
||||
if text:
|
||||
kwargs.update(text=True, encoding="utf-8", errors="replace")
|
||||
return subprocess.run(argv, capture_output=True, timeout=timeout, stdin=subprocess.DEVNULL, **kwargs)
|
||||
@@ -719,7 +719,7 @@ def _interrupt_guard(handler):
|
||||
signal.signal(sig, old)
|
||||
|
||||
|
||||
def _spawn_daemon(bin_path: Path, cfg: Path, env: dict[str, str], log_path: Path) -> "subprocess.Popen":
|
||||
def _spawn_daemon(bin_path: Path, cfg: Path, env: dict[str, str], log_path: Path) -> subprocess.Popen:
|
||||
"""Popen with stdout/stderr appended to ``log_path`` (0o600 from the first byte, O_NOFOLLOW so a planted
|
||||
symlink e.g. to authorized_keys can't receive output, owner-checked). Our log fd closes after Popen — the child has its dup."""
|
||||
try:
|
||||
@@ -743,7 +743,7 @@ def _spawn_daemon(bin_path: Path, cfg: Path, env: dict[str, str], log_path: Path
|
||||
os.close(log_fd)
|
||||
|
||||
|
||||
def _await_listening(proc: "subprocess.Popen", host: str, port: int, *, on_exit) -> bool:
|
||||
def _await_listening(proc: subprocess.Popen, host: str, port: int, *, on_exit) -> bool:
|
||||
"""Poll until ``host:port`` accepts or the grace window lapses (do-while: >=1 check even at 0s); raises ``on_exit()`` if the child dies."""
|
||||
deadline = time.time() + _STARTUP_GRACE_SECONDS
|
||||
while True:
|
||||
@@ -784,7 +784,7 @@ def _write_pidfile_safely(pidfile: Path, pid: int) -> None:
|
||||
_write_private_file(pidfile.with_suffix(".nonce"), _proxy_nonce.encode("utf-8"))
|
||||
|
||||
|
||||
def _kill_and_wait(proc: "subprocess.Popen", *, grace_seconds: int = 2) -> None:
|
||||
def _kill_and_wait(proc: subprocess.Popen, *, grace_seconds: int = 2) -> None:
|
||||
try:
|
||||
proc.terminate()
|
||||
except OSError:
|
||||
|
||||
@@ -210,7 +210,7 @@ class ReasoningParamsMixin:
|
||||
_reapply_reasoning_echo_for_provider = _forward("agent.agent_runtime_helpers", "reapply_reasoning_echo_for_provider")
|
||||
|
||||
@staticmethod
|
||||
def _sanitize_tool_calls_for_strict_api(api_msg: dict, model: "str | None" = None) -> dict:
|
||||
def _sanitize_tool_calls_for_strict_api(api_msg: dict, model: str | None = None) -> dict:
|
||||
"""Strip Codex Responses fields from tool_calls for strict Chat Completions APIs (Mistral, Fireworks
|
||||
400/422 on unknown fields). ``extra_content`` (Gemini thought_signature) is kept only for Gemini-family
|
||||
models. Builds new dicts so the internal history keeps the Codex fields for a later fallback."""
|
||||
|
||||
+2
-2
@@ -363,7 +363,7 @@ def _is_word_end(s: str, j: int, *, allow_plural: bool = True) -> bool:
|
||||
return allow_plural and cur in "sS" and _is_word_end(s, j + 1, allow_plural=False)
|
||||
|
||||
|
||||
def _has_word_bounded_keyword(key: str, keyword_re: "re.Pattern[str]") -> bool:
|
||||
def _has_word_bounded_keyword(key: str, keyword_re: re.Pattern[str]) -> bool:
|
||||
"""True if ``keyword_re`` matches ``key`` at a word boundary (see _KEY_KEYWORD_RE)."""
|
||||
return any(_is_word_start(key, m.start()) and _is_word_end(key, m.end()) for m in keyword_re.finditer(key))
|
||||
|
||||
@@ -580,7 +580,7 @@ _CONTROL_CHARS_RE = re.compile(r"[\x00-\x1f\x7f\u200b-\u200f\u2028-\u202f\u2060\
|
||||
_TOKEN_BODY_CHARS = frozenset("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-.")
|
||||
|
||||
|
||||
def _compile_prefix_matcher(patterns: list) -> "re.Pattern[str]":
|
||||
def _compile_prefix_matcher(patterns: list) -> re.Pattern[str]:
|
||||
return re.compile(r"(?<![A-Za-z0-9_-])(" + "|".join(patterns) + r")(?![A-Za-z0-9_-])")
|
||||
|
||||
|
||||
|
||||
+2
-2
@@ -59,7 +59,7 @@ class _ManagedAttempt:
|
||||
def resolve(
|
||||
cls, session_id: str | None, request: dict[str, Any], metadata: dict[str, Any] | None,
|
||||
*, name: str, model_name: str,
|
||||
) -> "_ManagedAttempt | None":
|
||||
) -> _ManagedAttempt | None:
|
||||
"""Return the managed attempt for ``session_id`` (None: the inherited turn's), or None to run unmanaged."""
|
||||
if session_id is None:
|
||||
session_id = _current_session_id()
|
||||
@@ -429,7 +429,7 @@ class ManagedLlmStream(Iterator[Any]):
|
||||
self._release_runtime_lease()
|
||||
raise
|
||||
|
||||
def __iter__(self) -> "ManagedLlmStream":
|
||||
def __iter__(self) -> ManagedLlmStream:
|
||||
return self
|
||||
|
||||
def _prime_completed_response(self) -> None:
|
||||
|
||||
@@ -270,7 +270,7 @@ _reset_segments_config_for_tests = _SEGMENTS_CONFIG.reset
|
||||
class RelayOperationLease:
|
||||
"""Keep process-wide Relay plugins alive across a deferred operation."""
|
||||
|
||||
def __init__(self, runtime: "RelayRuntime") -> None:
|
||||
def __init__(self, runtime: RelayRuntime) -> None:
|
||||
self._lock, self._runtime = threading.Lock(), runtime
|
||||
|
||||
def run_in_session(self, session: RelaySession, callback: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
|
||||
|
||||
@@ -79,12 +79,12 @@ _RETRY_AFTER_SECONDS_RE = re.compile(r"retry\s+(?:after\s+)?(\d+(?:\.\d+)?)\s*(?
|
||||
_RESETS_IN_SECONDS_FIELD_RE = re.compile(r"resets_in_seconds\W{1,4}(\d+(?:\.\d+)?)", re.IGNORECASE)
|
||||
|
||||
|
||||
def _quota_reset_seconds(m: "re.Match[str]") -> float:
|
||||
def _quota_reset_seconds(m: re.Match[str]) -> float:
|
||||
value = float(m.group(1))
|
||||
return value / 1000.0 if m.group(2).lower() == "ms" else value
|
||||
|
||||
|
||||
def _resets_in_seconds(m: "re.Match[str]") -> Optional[float]:
|
||||
def _resets_in_seconds(m: re.Match[str]) -> Optional[float]:
|
||||
if not any(m.groups()): # "resets in" with no unit-bearing number: not this grammar
|
||||
return None
|
||||
return float(m.group(1) or 0) * 3600 + float(m.group(2) or 0) * 60 + float(m.group(3) or 0)
|
||||
|
||||
@@ -307,7 +307,7 @@ def _parse_env_value(raw_value: str) -> str:
|
||||
# symlink repointed mid-read can't file one file's contents under another's identity.
|
||||
# ``invalidate_env_file_cache()`` is the explicit knob; ``hermes_cli.config.invalidate_env_cache()``
|
||||
# calls it for Hermes's own .env writers.
|
||||
_ENV_FILE_CACHE: "OrderedDict[str, tuple[tuple, dict[str, str]]]" = OrderedDict()
|
||||
_ENV_FILE_CACHE: OrderedDict[str, tuple[tuple, dict[str, str]]] = OrderedDict()
|
||||
_ENV_FILE_CACHE_LOCK = threading.Lock()
|
||||
_ENV_FILE_CACHE_MAX = 64 # one entry per profile home in practice
|
||||
|
||||
|
||||
@@ -118,7 +118,7 @@ class FetchResult:
|
||||
def ok(self) -> bool:
|
||||
return self.error is None
|
||||
|
||||
def fail(self, error: str, kind: ErrorKind) -> "FetchResult":
|
||||
def fail(self, error: str, kind: ErrorKind) -> FetchResult:
|
||||
self.error, self.error_kind = error, kind
|
||||
return self
|
||||
|
||||
@@ -191,7 +191,7 @@ class SecretSource(ABC):
|
||||
"""Informational ``{key: {"description": str, "default": Any}}`` for setup UIs."""
|
||||
return {}
|
||||
|
||||
def remediation(self, kind: Optional["ErrorKind"], cfg: dict) -> str:
|
||||
def remediation(self, kind: Optional[ErrorKind], cfg: dict) -> str:
|
||||
"""One-line actionable next step for a failed fetch (pure); "" suppresses the hint."""
|
||||
if kind is None:
|
||||
return ""
|
||||
|
||||
@@ -77,7 +77,7 @@ class SubagentHandle:
|
||||
return dataclasses.asdict(self)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value: Mapping[str, Any]) -> "SubagentHandle":
|
||||
def from_dict(cls, value: Mapping[str, Any]) -> SubagentHandle:
|
||||
try:
|
||||
return cls(**dict(value))
|
||||
except (TypeError, ValueError) as exc:
|
||||
|
||||
@@ -19,11 +19,11 @@ __all__ = ["thread_scoped_silence"]
|
||||
|
||||
_install_lock = threading.Lock()
|
||||
# Proxy installed per attribute ("stdout"/"stderr"): never double-wrap.
|
||||
_installed: dict[str, "_ThreadRoutingStream"] = {}
|
||||
_installed: dict[str, _ThreadRoutingStream] = {}
|
||||
# One process-lifetime sink per stream: global redirects that displace and
|
||||
# restore a proxy must not leak a new /dev/null descriptor each time.
|
||||
_sinks: dict[str, TextIO] = {}
|
||||
_routing_states: dict[str, "_RoutingState"] = {}
|
||||
_routing_states: dict[str, _RoutingState] = {}
|
||||
|
||||
|
||||
class _RoutingState:
|
||||
@@ -87,7 +87,7 @@ class _ThreadRoutingStream:
|
||||
return getattr(self._target(), name)
|
||||
|
||||
|
||||
def _ensure_installed(attr: str, passthrough: TextIO) -> "_ThreadRoutingStream":
|
||||
def _ensure_installed(attr: str, passthrough: TextIO) -> _ThreadRoutingStream:
|
||||
"""Install (idempotently) a routing proxy as ``sys.<attr>`` and return it."""
|
||||
with _install_lock:
|
||||
proxy = _installed.get(attr)
|
||||
|
||||
@@ -25,7 +25,7 @@ logger = logging.getLogger(__name__)
|
||||
# In-flight stage-2 upgrade threads. They bill their aux usage to the session from a daemon thread,
|
||||
# so a process that reads the ledger right before exit (``-z --usage-file``) must be able to join
|
||||
# them (bounded) instead of racing the write (#112848).
|
||||
_UPGRADE_THREADS: "weakref.WeakSet[threading.Thread]" = weakref.WeakSet()
|
||||
_UPGRADE_THREADS: weakref.WeakSet[threading.Thread] = weakref.WeakSet()
|
||||
|
||||
|
||||
def wait_for_title_upgrades(timeout: float = 10.0) -> None:
|
||||
|
||||
@@ -106,7 +106,7 @@ class LoopCapConfig:
|
||||
max_subagents: int = _DEFAULT_MAX_SUBAGENTS_PER_TURN
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, data: Mapping[str, Any] | None) -> "LoopCapConfig":
|
||||
def from_mapping(cls, data: Mapping[str, Any] | None) -> LoopCapConfig:
|
||||
"""Build config from the ``tool_loop_guardrails.loop_caps`` section."""
|
||||
if not isinstance(data, Mapping):
|
||||
return cls()
|
||||
@@ -134,7 +134,7 @@ class ToolCallGuardrailConfig:
|
||||
@classmethod
|
||||
def from_mapping(
|
||||
cls, data: Mapping[str, Any] | None, *, platform: str | None = None,
|
||||
) -> "ToolCallGuardrailConfig":
|
||||
) -> ToolCallGuardrailConfig:
|
||||
"""Build config from `tool_loop_guardrails`; nested ``warn_after`` / ``hard_stop_after`` win over flat legacy keys."""
|
||||
if not isinstance(data, Mapping):
|
||||
data = {}
|
||||
@@ -170,7 +170,7 @@ class ToolCallSignature:
|
||||
args_hash: str
|
||||
|
||||
@classmethod
|
||||
def from_call(cls, tool_name: str, args: Mapping[str, Any] | None) -> "ToolCallSignature":
|
||||
def from_call(cls, tool_name: str, args: Mapping[str, Any] | None) -> ToolCallSignature:
|
||||
return cls(tool_name=tool_name, args_hash=_sha256(canonical_tool_args(args or {})))
|
||||
|
||||
def to_metadata(self) -> dict[str, str]:
|
||||
|
||||
@@ -43,7 +43,7 @@ class TranscriptionProvider(CatalogProviderBase):
|
||||
|
||||
def open_stream_session(
|
||||
self, *, language: Optional[str] = None, prompt: Optional[str] = None,
|
||||
) -> "TranscriptionStreamSession":
|
||||
) -> TranscriptionStreamSession:
|
||||
"""A single-use live session for one utterance (streaming-capable providers only)."""
|
||||
raise NotImplementedError(f"{self.name} does not support live streaming transcription")
|
||||
|
||||
|
||||
@@ -219,7 +219,7 @@ class CodexAppServerClient:
|
||||
for _rid, pending in pending_items:
|
||||
pending.put_nowait(synthetic)
|
||||
|
||||
def __enter__(self) -> "CodexAppServerClient":
|
||||
def __enter__(self) -> CodexAppServerClient:
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc: object) -> None:
|
||||
|
||||
@@ -80,7 +80,7 @@ class CanonicalUsage:
|
||||
def total_tokens(self) -> int:
|
||||
return self.prompt_tokens + self.output_tokens
|
||||
|
||||
def __add__(self, other: "CanonicalUsage") -> "CanonicalUsage":
|
||||
def __add__(self, other: CanonicalUsage) -> CanonicalUsage:
|
||||
"""Sum two usage buckets. ``raw_usage`` (single-response detail) is
|
||||
dropped; ``request_count`` adds so callers see how many API calls a
|
||||
combined figure covers."""
|
||||
|
||||
@@ -20,7 +20,7 @@ from agent.vault_store import VaultItemMeta
|
||||
class UnlockRequired(Exception):
|
||||
"""The backend is locked for this session; the surface must prompt for the master password."""
|
||||
|
||||
def __init__(self, backend: "LoginBackend"):
|
||||
def __init__(self, backend: LoginBackend):
|
||||
super().__init__(f"{backend.display_name} is locked")
|
||||
self.backend = backend
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ class LoginControl:
|
||||
max_length: Optional[int] = None
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, raw: dict[str, Any]) -> "LoginControl":
|
||||
def from_dict(cls, raw: dict[str, Any]) -> LoginControl:
|
||||
form_index = raw.get("formIndex", raw.get("form_index"))
|
||||
max_length = raw.get("maxLength", raw.get("max_length"))
|
||||
return cls(
|
||||
|
||||
@@ -47,7 +47,7 @@ class Recipe:
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, raw: Any) -> "Recipe | None":
|
||||
def from_dict(cls, raw: Any) -> Recipe | None:
|
||||
"""Tolerant loader mirroring grok's ``normalizeVerifyRecipe``; accepts
|
||||
both this module's field names and grok's camelCase aliases."""
|
||||
if not isinstance(raw, dict):
|
||||
|
||||
@@ -896,7 +896,7 @@ class HermesCLI(CLIInitMixin, CLITuiRuntimeMixin, CLIProcessNotificationsMixin,
|
||||
|
||||
# Seeded -q first message (see _should_seed_interactive); run() re-creates
|
||||
# _pending_input, so it is enqueued only after the fresh queue exists.
|
||||
_seeded_first_message: Optional["_SeededQueryMessage"] = None
|
||||
_seeded_first_message: Optional[_SeededQueryMessage] = None
|
||||
# Inspection surfaces (banner, /tools, status line) read this on partially built instances too.
|
||||
disabled_toolsets: Optional[list[str]] = None
|
||||
|
||||
|
||||
+1
-1
@@ -121,7 +121,7 @@ class _CronStorePaths:
|
||||
output_dir: Path
|
||||
|
||||
@classmethod
|
||||
def for_dir(cls, cron_dir: Path) -> "_CronStorePaths":
|
||||
def for_dir(cls, cron_dir: Path) -> _CronStorePaths:
|
||||
return cls(cron_dir, cron_dir / "jobs.json", cron_dir / "output")
|
||||
|
||||
|
||||
|
||||
+7
-7
@@ -659,7 +659,7 @@ class _CombinedCancelEvent:
|
||||
``lost_ownership`` + per-transport events). Workers only call is_set()/set(), so no pump thread.
|
||||
"""
|
||||
|
||||
def __init__(self, *events: Optional["_CancelEventLike"]) -> None:
|
||||
def __init__(self, *events: Optional[_CancelEventLike]) -> None:
|
||||
self._events = [event for event in events if event is not None]
|
||||
|
||||
def is_set(self) -> bool:
|
||||
@@ -670,7 +670,7 @@ class _CombinedCancelEvent:
|
||||
event.set()
|
||||
|
||||
|
||||
def get_running_job_ids() -> "frozenset[str]":
|
||||
def get_running_job_ids() -> frozenset[str]:
|
||||
"""Thread-safe snapshot of executing job IDs (dispatch until ``_process_job`` returns). Read by
|
||||
the gateway shutdown drain, otherwise blind to cron work (runs outside ``_running_agents``).
|
||||
|
||||
@@ -697,7 +697,7 @@ def get_running_job_details() -> list[dict]:
|
||||
]
|
||||
|
||||
|
||||
def get_wedged_job_ids() -> "frozenset[str]":
|
||||
def get_wedged_job_ids() -> frozenset[str]:
|
||||
"""In-flight job IDs older than their stale-inflight allowance (``max(2 * interval,
|
||||
cron.inflight_max_minutes)``) — the scheduler's own definition of a claim that can no longer be
|
||||
making progress. ``sweep_stale_inflight`` cannot release these while the worker thread is still
|
||||
@@ -707,7 +707,7 @@ def get_wedged_job_ids() -> "frozenset[str]":
|
||||
return frozenset(key[1] for key in _wedged_inflight_keys())
|
||||
|
||||
|
||||
def _wedged_inflight_keys() -> "frozenset[tuple]":
|
||||
def _wedged_inflight_keys() -> frozenset[tuple]:
|
||||
"""In-flight keys behind :func:`get_wedged_job_ids`, before the projection to bare job IDs."""
|
||||
now = time.time()
|
||||
with _running_lock:
|
||||
@@ -3243,7 +3243,7 @@ def _deliver_crash_failure(
|
||||
|
||||
|
||||
|
||||
def _install_fire_secret_scope() -> "tuple[contextvars.Token, Optional[contextvars.Token]]":
|
||||
def _install_fire_secret_scope() -> tuple[contextvars.Token, Optional[contextvars.Token]]:
|
||||
"""Install the firing profile's secret scope for the span ``_run_one_job_body`` runs, delivery
|
||||
included, and return the tokens ``_reset_fire_secret_scope`` needs.
|
||||
|
||||
@@ -3270,7 +3270,7 @@ def _install_fire_secret_scope() -> "tuple[contextvars.Token, Optional[contextva
|
||||
return scope_token, context_token
|
||||
|
||||
|
||||
def _reset_fire_secret_scope(tokens: "tuple[contextvars.Token, Optional[contextvars.Token]]") -> None:
|
||||
def _reset_fire_secret_scope(tokens: tuple[contextvars.Token, Optional[contextvars.Token]]) -> None:
|
||||
"""Undo ``_install_fire_secret_scope`` — the context first, so multiplex semantics never
|
||||
outlive the scope they depend on."""
|
||||
from agent.secret_scope import reset_multiplex_context, reset_secret_scope
|
||||
@@ -3291,7 +3291,7 @@ def _fire_secret_scope():
|
||||
_reset_fire_secret_scope(tokens)
|
||||
|
||||
|
||||
def _start_owned_run(job: dict, execution_id: str) -> "Optional[contextvars.Token]":
|
||||
def _start_owned_run(job: dict, execution_id: str) -> Optional[contextvars.Token]:
|
||||
"""Win the run's ``claimed`` → ``running`` CAS and bind its cron identity; ``None`` when the run
|
||||
lost ownership first. A restart-safe worker already won it by adopting the row."""
|
||||
if os.environ.get("_HERMES_CRON_EXTERNAL_WORKER") == execution_id:
|
||||
|
||||
@@ -15,7 +15,7 @@ import threading
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def _close_late_session_db_result(future: "concurrent.futures.Future") -> None:
|
||||
def _close_late_session_db_result(future: concurrent.futures.Future) -> None:
|
||||
"""Done-callback: close a SessionDB whose constructor finished after run_job's init timeout
|
||||
(worker abandoned via ``shutdown(wait=False)``), else its .db/WAL/SHM handles leak to EMFILE.
|
||||
|
||||
|
||||
@@ -291,7 +291,7 @@ def _misfire_grace_minutes() -> float:
|
||||
|
||||
|
||||
def fire_overdue_jobs(
|
||||
provider: "CronScheduler", *, adapters: Any = None, loop: Any = None, now: Any = None,
|
||||
provider: CronScheduler, *, adapters: Any = None, loop: Any = None, now: Any = None,
|
||||
) -> int:
|
||||
"""Misfire backstop (gateway housekeeping loop): fire jobs whose external HTTP fire never
|
||||
arrived, else ``next_run_at`` stays parked in the past forever. No-op for the built-in (its tick
|
||||
@@ -384,7 +384,7 @@ def fire_overdue_jobs(
|
||||
return fired
|
||||
|
||||
|
||||
def resolve_cron_scheduler() -> "CronScheduler":
|
||||
def resolve_cron_scheduler() -> CronScheduler:
|
||||
"""Resolve ``cron.provider``; missing/failing/unavailable providers fall back to the built-in
|
||||
with a warning — cron must never be left without a trigger."""
|
||||
name = ""
|
||||
@@ -414,8 +414,8 @@ def resolve_cron_scheduler() -> "CronScheduler":
|
||||
|
||||
|
||||
def scheduler_for_profile_mode(
|
||||
provider: "CronScheduler", *, multiplex_profiles: bool
|
||||
) -> "CronScheduler":
|
||||
provider: CronScheduler, *, multiplex_profiles: bool
|
||||
) -> CronScheduler:
|
||||
"""External providers own one unscoped remote registry and cannot reconcile several profile
|
||||
stores: fail closed to the built-in multiplex ticker until the API carries profile identity."""
|
||||
if not multiplex_profiles or isinstance(provider, InProcessCronScheduler):
|
||||
|
||||
@@ -67,12 +67,12 @@ class Run:
|
||||
return ap
|
||||
|
||||
@classmethod
|
||||
def from_args(cls, argv: Optional[list[str]] = None, description: str = "") -> "Run":
|
||||
def from_args(cls, argv: Optional[list[str]] = None, description: str = "") -> Run:
|
||||
a = cls.parser(description).parse_args(argv)
|
||||
return cls.open(a.db, root=a.root, out=a.out)
|
||||
|
||||
@classmethod
|
||||
def open(cls, db: str, *, root: Optional[str] = None, out: str = "postmortem_out") -> "Run":
|
||||
def open(cls, db: str, *, root: Optional[str] = None, out: str = "postmortem_out") -> Run:
|
||||
conn = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
|
||||
conn.row_factory = sqlite3.Row
|
||||
sessions = {r["id"]: dict(r) for r in conn.execute("SELECT * FROM sessions")}
|
||||
|
||||
@@ -448,7 +448,7 @@ class GatewayAuthorizationMixin:
|
||||
return bool(sender_allow.strip())
|
||||
return isinstance(sender_allow, (list, tuple, set)) and any(str(item).strip() for item in sender_allow)
|
||||
|
||||
def _pairing_store_for(self, source: "SessionSource"):
|
||||
def _pairing_store_for(self, source: SessionSource):
|
||||
"""Per-profile PairingStore for a source, else the global ``self.pairing_store``."""
|
||||
per_profile = getattr(self, "pairing_stores", None) or {}
|
||||
profile = getattr(source, "profile", None)
|
||||
|
||||
@@ -201,7 +201,7 @@ class BrowserControlBroker:
|
||||
else:
|
||||
self._artifact_stores[profile_id] = store
|
||||
|
||||
def _artifact_store_for_scope(self, scope: "ControllerScope") -> Any:
|
||||
def _artifact_store_for_scope(self, scope: ControllerScope) -> Any:
|
||||
store = self._artifact_stores.get(getattr(scope, "profile_id", None) or None)
|
||||
return store if store is not None else self._artifact_stores.get(None)
|
||||
|
||||
|
||||
+11
-11
@@ -43,7 +43,7 @@ def _coerce_bool(value: Any, default: bool = True) -> bool:
|
||||
return is_truthy_value(value, default=default)
|
||||
|
||||
|
||||
def _env_multiplex_profiles_override() -> "bool | None":
|
||||
def _env_multiplex_profiles_override() -> bool | None:
|
||||
"""GATEWAY_MULTIPLEX_PROFILES operator override: True/False for a recognized token.
|
||||
|
||||
``None`` when unset, blank, or unrecognized so the caller keeps the config.yaml
|
||||
@@ -68,7 +68,7 @@ def _env_multiplex_profiles_override() -> "bool | None":
|
||||
ON_ALL_ADAPTERS_DOWN_POLICIES = ("exit", "stay_alive")
|
||||
|
||||
|
||||
def _env_on_all_adapters_down_override() -> "str | None":
|
||||
def _env_on_all_adapters_down_override() -> str | None:
|
||||
"""GATEWAY_ON_ALL_ADAPTERS_DOWN operator override: 'exit'/'stay_alive' for a recognized token.
|
||||
|
||||
``None`` when unset, blank, or unrecognized so the caller keeps the config.yaml value
|
||||
@@ -267,7 +267,7 @@ class Platform(Enum):
|
||||
return cls._add_pseudo_member(value) if registered else None
|
||||
|
||||
@classmethod
|
||||
def _add_pseudo_member(cls, value: str) -> "Platform":
|
||||
def _add_pseudo_member(cls, value: str) -> Platform:
|
||||
pseudo = object.__new__(cls)
|
||||
pseudo._value_ = value
|
||||
pseudo._name_ = value.upper().replace("-", "_").replace(" ", "_")
|
||||
@@ -276,7 +276,7 @@ class Platform(Enum):
|
||||
return pseudo
|
||||
|
||||
@classmethod
|
||||
def _scan_bundled_plugin_platforms(cls) -> "tuple[set, dict]":
|
||||
def _scan_bundled_plugin_platforms(cls) -> tuple[set, dict]:
|
||||
"""Directory names of bundled platform plugins under ``plugins/platforms/``, plus a map of
|
||||
manifest ``name:`` keys that differ from their directory (alias -> directory name). Aliases
|
||||
never shadow a directory name, so the directory stays the canonical platform value."""
|
||||
@@ -359,7 +359,7 @@ class HomeChannel:
|
||||
return {"platform": self.platform.value, "chat_id": self.chat_id, "name": self.name, **optional}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "HomeChannel":
|
||||
def from_dict(cls, data: dict[str, Any]) -> HomeChannel:
|
||||
optional = {k: str(data[k]) if data.get(k) else None for k in ("thread_id", "user_id", "scope_id")}
|
||||
return cls(platform=Platform(data["platform"]), chat_id=str(data["chat_id"]), name=data.get("name", "Home"), **optional)
|
||||
|
||||
@@ -386,7 +386,7 @@ class ChannelOverride:
|
||||
return {k: v for k, v in asdict(self).items() if v is not None}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "ChannelOverride":
|
||||
def from_dict(cls, data: dict[str, Any]) -> ChannelOverride:
|
||||
return cls(**{f.name: data.get(f.name) for f in fields(cls)}) if data else cls()
|
||||
|
||||
|
||||
@@ -395,7 +395,7 @@ class ChannelOverride:
|
||||
# authenticate another way and must never be skipped for a missing token.
|
||||
# Platforms absent from this map authenticate some other way (session files, port-bound webhooks,
|
||||
# api_key-only) and must never be skipped for a missing token. See #64674.
|
||||
PLATFORM_TOKEN_ENV_NAMES: dict["Platform", str] = {
|
||||
PLATFORM_TOKEN_ENV_NAMES: dict[Platform, str] = {
|
||||
Platform.TELEGRAM: "TELEGRAM_BOT_TOKEN",
|
||||
Platform.DISCORD: "DISCORD_BOT_TOKEN",
|
||||
Platform.SLACK: "SLACK_BOT_TOKEN",
|
||||
@@ -442,7 +442,7 @@ class PlatformConfig:
|
||||
})
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "PlatformConfig":
|
||||
def from_dict(cls, data: dict[str, Any]) -> PlatformConfig:
|
||||
data = _coerce_dict(data)
|
||||
home = data.get("home_channel")
|
||||
# Adapters read their settings from ``extra`` (``config.extra.get("port")``), but users
|
||||
@@ -518,7 +518,7 @@ class StreamingConfig:
|
||||
return asdict(self)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "StreamingConfig":
|
||||
def from_dict(cls, data: dict[str, Any]) -> StreamingConfig:
|
||||
if not isinstance(data, dict) or not data:
|
||||
return cls()
|
||||
|
||||
@@ -721,7 +721,7 @@ class GatewayConfig:
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "GatewayConfig":
|
||||
def from_dict(cls, data: dict[str, Any]) -> GatewayConfig:
|
||||
data = _coerce_dict(data)
|
||||
nested_gateway = _coerce_dict(data.get("gateway"))
|
||||
|
||||
@@ -865,7 +865,7 @@ def load_gateway_config() -> GatewayConfig:
|
||||
return config
|
||||
|
||||
|
||||
def _validate_gateway_config(config: "GatewayConfig") -> None:
|
||||
def _validate_gateway_config(config: GatewayConfig) -> None:
|
||||
"""Validate and sanitize a loaded GatewayConfig in place (after all sources are merged)."""
|
||||
try:
|
||||
# Reject known-weak placeholder tokens. Ported from openclaw/openclaw#64586: users who copy
|
||||
|
||||
+1
-1
@@ -121,7 +121,7 @@ class DeliveryTarget:
|
||||
unknown_platform: Optional[str] = None
|
||||
|
||||
@classmethod
|
||||
def parse(cls, target: str, origin: Optional[SessionSource] = None) -> "DeliveryTarget":
|
||||
def parse(cls, target: str, origin: Optional[SessionSource] = None) -> DeliveryTarget:
|
||||
"""Parse "origin" | "local" | "<platform>" | "<platform>:<chat_id>[:<thread_id>]"."""
|
||||
target = target.strip()
|
||||
if target.lower() == "origin":
|
||||
|
||||
@@ -120,7 +120,7 @@ class HostRecord:
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, payload: Any) -> Optional["HostRecord"]:
|
||||
def from_json(cls, payload: Any) -> Optional[HostRecord]:
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
pid = payload.get("pid")
|
||||
|
||||
@@ -38,7 +38,7 @@ class RoomExecutionPolicy:
|
||||
policy_digest: str
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, value: Mapping[str, Any]) -> "RoomExecutionPolicy":
|
||||
def from_mapping(cls, value: Mapping[str, Any]) -> RoomExecutionPolicy:
|
||||
if not isinstance(value, Mapping) or set(value) != _POLICY_FIELDS:
|
||||
raise RoomExecutionPolicyError("execution policy fields are invalid")
|
||||
if value["version"] != POLICY_VERSION:
|
||||
|
||||
@@ -50,7 +50,7 @@ class StoredRoomLink:
|
||||
updated_at: float
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, value: Mapping[str, Any]) -> "StoredRoomLink":
|
||||
def from_mapping(cls, value: Mapping[str, Any]) -> StoredRoomLink:
|
||||
_link_fields(value)
|
||||
room_id = _short_string(value["room_id"], "room_id")
|
||||
member_id = _short_string(value["member_id"], "member_id")
|
||||
@@ -76,7 +76,7 @@ class StoredRoomLink:
|
||||
status=status, updated_at=updated_at)
|
||||
|
||||
@classmethod
|
||||
def from_record(cls, value: Mapping[str, Any]) -> "StoredRoomLink":
|
||||
def from_record(cls, value: Mapping[str, Any]) -> StoredRoomLink:
|
||||
try:
|
||||
catalog = json.loads(str(value["catalog_json"]))
|
||||
except Exception as exc:
|
||||
|
||||
@@ -206,7 +206,7 @@ class GatewayRoomCatalog:
|
||||
transport_security: TransportSecurity | None = None
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, value: Mapping[str, Any]) -> "GatewayRoomCatalog":
|
||||
def from_mapping(cls, value: Mapping[str, Any]) -> GatewayRoomCatalog:
|
||||
_exact_fields(value, required=_CATALOG_FIELDS, optional={"endpoint"}, label="capability catalog")
|
||||
installation_id = _identifier(value["installation_id"], field="installation_id")
|
||||
versions = tuple(_protocol_versions(_non_empty_list(value["protocol_versions"], field="protocol_versions")))
|
||||
@@ -379,7 +379,7 @@ class HostedMemberDispatch:
|
||||
return asdict(self)
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, value: Mapping[str, Any]) -> "HostedMemberDispatch":
|
||||
def from_mapping(cls, value: Mapping[str, Any]) -> HostedMemberDispatch:
|
||||
_exact_fields(value, required=set(_DISPATCH_FIELDS) | {"prompt", "prompt_digest"}, label="dispatch")
|
||||
if not isinstance(prompt := value["prompt"], str) or not prompt.strip():
|
||||
raise HostedRoomPeerError("prompt must be a non-empty string")
|
||||
|
||||
@@ -63,7 +63,7 @@ def _positive_int_setting(kanban_cfg: dict, key: str) -> Optional[int]:
|
||||
return value
|
||||
|
||||
|
||||
def _resolve_auto_decompose_settings(load_config: Callable[[], Any]) -> "tuple[bool, int]":
|
||||
def _resolve_auto_decompose_settings(load_config: Callable[[], Any]) -> tuple[bool, int]:
|
||||
"""Live (enabled, per_tick) auto-decompose settings, re-read every dispatcher tick.
|
||||
|
||||
Fails safe: a config read error returns ``(False, 3)`` rather than
|
||||
@@ -110,7 +110,7 @@ def _kanban_dispatch_allowed() -> bool:
|
||||
return not check_paused("kanban", logger)
|
||||
|
||||
|
||||
def _acquire_singleton_lock(lock_path) -> "tuple[Optional[object], str]":
|
||||
def _acquire_singleton_lock(lock_path) -> tuple[Optional[object], str]:
|
||||
"""Take the exclusive, non-blocking advisory lock for the sole dispatcher.
|
||||
|
||||
Only one gateway machine-wide may run the embedded dispatcher: concurrent
|
||||
|
||||
@@ -470,7 +470,7 @@ def _fmt_timed_out(ev, n) -> tuple:
|
||||
# archived / unblocked are claimed (so the cursor advances past them) but
|
||||
# intentionally silent (no formatter), and excluded from _WAKE_KINDS so they
|
||||
# never wake the creator.
|
||||
_EVENT_FORMATTERS: dict[str, Callable[[Any, "_KanbanNotification"], tuple]] = {
|
||||
_EVENT_FORMATTERS: dict[str, Callable[[Any, _KanbanNotification], tuple]] = {
|
||||
"completed": _fmt_completed,
|
||||
"blocked": lambda ev, n: (
|
||||
t("gateway.kanban.ping.blocked", head=n.head, reason=_clip(ev, "reason", "gateway.kanban.ping.reason_suffix", 160)),
|
||||
|
||||
@@ -34,7 +34,7 @@ def _positive_env(name: str, default, cast):
|
||||
return val if val > 0 else default
|
||||
|
||||
|
||||
def platform_httpx_limits() -> "httpx.Limits | None":
|
||||
def platform_httpx_limits() -> httpx.Limits | None:
|
||||
"""``httpx.Limits`` tuned for persistent platform-adapter clients; ``None`` without httpx."""
|
||||
if httpx is None:
|
||||
return None
|
||||
|
||||
@@ -558,7 +558,7 @@ def _content_has_visible_payload(content: Any) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _multimodal_validation_error(exc: ValueError, *, param: str) -> "web.Response":
|
||||
def _multimodal_validation_error(exc: ValueError, *, param: str) -> web.Response:
|
||||
"""Translate a ``_normalize_multimodal_content`` ValueError into a 400 response."""
|
||||
raw = str(exc)
|
||||
code, _, message = raw.partition(":")
|
||||
@@ -633,7 +633,7 @@ def _clear_turn_process_ownership(agent: Any) -> None:
|
||||
agent._gateway_turn_process_epoch = None
|
||||
|
||||
|
||||
def _session_chat_user_message(body: dict[str, Any], *, param: str = "message") -> tuple[Any, Optional["web.Response"]]:
|
||||
def _session_chat_user_message(body: dict[str, Any], *, param: str = "message") -> tuple[Any, Optional[web.Response]]:
|
||||
"""Parse and normalize session chat ``message`` / ``input`` like chat completions."""
|
||||
user_message = body.get("message") or body.get("input")
|
||||
if not _content_has_visible_payload(user_message):
|
||||
@@ -884,7 +884,7 @@ def _resolve_media_to_data_urls(text: str) -> str:
|
||||
return None
|
||||
return f""
|
||||
|
||||
def _repl(m: "re.Match[str]") -> str:
|
||||
def _repl(m: re.Match[str]) -> str:
|
||||
return _to_data_url(m.group("path")) or m.group(0)
|
||||
try:
|
||||
# A leaked terminal <|eos|> glued to the last tag is not a path terminator (#111046):
|
||||
@@ -912,12 +912,12 @@ def _openai_error(message: str, err_type: str = "invalid_request_error", param:
|
||||
def _error_response(
|
||||
message: str, status: int, *, err_type: str = "invalid_request_error",
|
||||
param: str | None = None, code: str | None = None, headers: Optional[dict[str, str]] = None,
|
||||
) -> "web.Response":
|
||||
) -> web.Response:
|
||||
"""``web.json_response(_openai_error(...), status=...)`` in one call."""
|
||||
return web.json_response(_openai_error(message, err_type, param, code), status=status, headers=headers)
|
||||
|
||||
|
||||
def _invalid_request(message: str) -> "web.Response":
|
||||
def _invalid_request(message: str) -> web.Response:
|
||||
"""400 with the bare ``{message, type}`` envelope the OpenAI-compatible validators use."""
|
||||
return web.json_response({"error": {"message": message, "type": "invalid_request_error"}}, status=400)
|
||||
|
||||
@@ -988,7 +988,7 @@ class _IdempotencyCache:
|
||||
def __init__(self, max_items: int = 1000, ttl_seconds: int = 300):
|
||||
from collections import OrderedDict
|
||||
self._store = OrderedDict()
|
||||
self._inflight: dict[tuple[str, str], "asyncio.Task[Any]"] = {}
|
||||
self._inflight: dict[tuple[str, str], asyncio.Task[Any]] = {}
|
||||
self._ttl = ttl_seconds
|
||||
self._max = max_items
|
||||
|
||||
@@ -1016,7 +1016,7 @@ class _IdempotencyCache:
|
||||
task = asyncio.create_task(_compute_and_store())
|
||||
self._inflight[inflight_key] = task
|
||||
|
||||
def _clear_inflight(done_task: "asyncio.Task[Any]") -> None:
|
||||
def _clear_inflight(done_task: asyncio.Task[Any]) -> None:
|
||||
if self._inflight.get(inflight_key) is done_task:
|
||||
self._inflight.pop(inflight_key, None)
|
||||
task.add_done_callback(_clear_inflight)
|
||||
@@ -1124,7 +1124,7 @@ class _SessionEventQueue:
|
||||
|
||||
def __init__(self, session_id: str, run_id: str):
|
||||
self.loop = asyncio.get_running_loop()
|
||||
self.queue: "asyncio.Queue[Optional[tuple[str, dict[str, Any]]]]" = asyncio.Queue()
|
||||
self.queue: asyncio.Queue[Optional[tuple[str, dict[str, Any]]]] = asyncio.Queue()
|
||||
self.session_id = session_id
|
||||
self.run_id = run_id
|
||||
self.seq = 0
|
||||
@@ -1153,7 +1153,7 @@ class _SessionEventQueue:
|
||||
def _room_grant_delegate(name: str):
|
||||
"""Adapter method forwarding to ``api_server_room_grants.<name>`` (looked up at call time so
|
||||
test patches on that module take effect) with this module's error/profile bindings."""
|
||||
async def _handler(self, request: "web.Request") -> "web.Response":
|
||||
async def _handler(self, request: web.Request) -> web.Response:
|
||||
return await getattr(_room_grants, name)(
|
||||
self, request, _openai_error=_openai_error, _api_request_profile=_api_request_profile)
|
||||
_handler.__name__ = name
|
||||
@@ -1163,7 +1163,7 @@ def _room_grant_delegate(name: str):
|
||||
def _run_route_delegate(name: str):
|
||||
"""Adapter method forwarding to ``api_server_runs.<name>`` (call-time lookup) with this
|
||||
module's namespace as ``_api_server``."""
|
||||
async def _handler(self, request: "web.Request") -> "web.StreamResponse":
|
||||
async def _handler(self, request: web.Request) -> web.StreamResponse:
|
||||
return await getattr(_api_runs, name)(self, request, _api_server=sys.modules[__name__])
|
||||
_handler.__name__ = name
|
||||
return _handler
|
||||
@@ -1214,9 +1214,9 @@ class APIServerAdapter(OpenAICompatRoutesMixin, BasePlatformAdapter):
|
||||
# clients that choke on named events (#12020). Default on.
|
||||
self._tool_progress_events: bool = _coerce_request_bool(
|
||||
extra.get("tool_progress_events"), default=True)
|
||||
self._app: Optional["web.Application"] = None
|
||||
self._runner: Optional["web.AppRunner"] = None
|
||||
self._site: Optional["web.TCPSite"] = None
|
||||
self._app: Optional[web.Application] = None
|
||||
self._runner: Optional[web.AppRunner] = None
|
||||
self._site: Optional[web.TCPSite] = None
|
||||
from hermes_constants import get_hermes_home
|
||||
self._response_store = ResponseStore() # this home's; a /p/<profile>/ route gets its own
|
||||
self._response_store_home = str(get_hermes_home())
|
||||
@@ -1310,7 +1310,7 @@ class APIServerAdapter(OpenAICompatRoutesMixin, BasePlatformAdapter):
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _draining_response(self) -> Optional["web.Response"]:
|
||||
def _draining_response(self) -> Optional[web.Response]:
|
||||
"""Return a retryable response while the gateway drains existing work."""
|
||||
if not self._gateway_is_draining():
|
||||
return None
|
||||
@@ -1478,7 +1478,7 @@ class APIServerAdapter(OpenAICompatRoutesMixin, BasePlatformAdapter):
|
||||
return {**_CORS_HEADERS, "Access-Control-Allow-Origin": origin, "Vary": "Origin",
|
||||
"Access-Control-Max-Age": "600"}
|
||||
|
||||
def _sse_headers(self, request: "web.Request", extra: Optional[dict[str, str]] = None) -> dict[str, str]:
|
||||
def _sse_headers(self, request: web.Request, extra: Optional[dict[str, str]] = None) -> dict[str, str]:
|
||||
"""Headers for an SSE StreamResponse prepared inside a handler: the CORS middleware only
|
||||
touches the response after the handler returns, by which point ``prepare()`` has already
|
||||
flushed the head, so CORS must be resolved up front (#72892, #6358)."""
|
||||
@@ -1500,7 +1500,7 @@ class APIServerAdapter(OpenAICompatRoutesMixin, BasePlatformAdapter):
|
||||
text = str(value).replace("\r", " ").replace("\n", " ").strip()
|
||||
return text[:max_len]
|
||||
|
||||
def _request_audit_context(self, request: "web.Request") -> dict[str, str]:
|
||||
def _request_audit_context(self, request: web.Request) -> dict[str, str]:
|
||||
"""Return non-secret source metadata for security/audit warnings."""
|
||||
peer_ip = ""
|
||||
with suppress(Exception):
|
||||
@@ -1516,12 +1516,12 @@ class APIServerAdapter(OpenAICompatRoutesMixin, BasePlatformAdapter):
|
||||
"path": self._clean_log_value(request.path_qs, max_len=500),
|
||||
"user_agent": self._clean_log_value(request.headers.get("User-Agent", ""), max_len=300)}
|
||||
|
||||
def _request_audit_log_suffix(self, request: "web.Request") -> str:
|
||||
def _request_audit_log_suffix(self, request: web.Request) -> str:
|
||||
ctx = self._request_audit_context(request)
|
||||
fields = [f"{key}={value!r}" for key, value in ctx.items() if value]
|
||||
return " ".join(fields) if fields else "source='unknown'"
|
||||
|
||||
def _cron_origin_from_request(self, request: "web.Request") -> dict[str, str]:
|
||||
def _cron_origin_from_request(self, request: web.Request) -> dict[str, str]:
|
||||
"""Persist safe API source metadata on cron jobs created over HTTP."""
|
||||
ctx = self._request_audit_context(request)
|
||||
origin = {"platform": "api_server", "chat_id": "api"}
|
||||
@@ -1550,13 +1550,13 @@ class APIServerAdapter(OpenAICompatRoutesMixin, BasePlatformAdapter):
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _auth_failed_response() -> "web.Response":
|
||||
def _auth_failed_response() -> web.Response:
|
||||
return web.json_response(
|
||||
{"error": {"message": "Invalid gateway API key (API_SERVER_KEY)", "type": "gateway_auth_error",
|
||||
"code": "gateway_auth_failed"}},
|
||||
status=401)
|
||||
|
||||
def _check_auth(self, request: "web.Request") -> Optional["web.Response"]:
|
||||
def _check_auth(self, request: web.Request) -> Optional[web.Response]:
|
||||
"""Validate the Bearer token; None when OK, else a 401. The no-key branch (connect()
|
||||
refuses to start without API_SERVER_KEY) exists for tests/manual wiring on the default
|
||||
listener only; named profiles fail closed rather than inherit the owner's key."""
|
||||
@@ -1586,7 +1586,7 @@ class APIServerAdapter(OpenAICompatRoutesMixin, BasePlatformAdapter):
|
||||
return normalized if re.fullmatch(r"[a-z0-9_]+", normalized) else ""
|
||||
|
||||
def _get_platform_callback_adapter(
|
||||
self, request: "web.Request", platform_name: str) -> Optional[Any]:
|
||||
self, request: web.Request, platform_name: str) -> Optional[Any]:
|
||||
injected = request.app.get("platform_event_adapters")
|
||||
adapter = injected.get(platform_name) if isinstance(injected, dict) else None
|
||||
if adapter is None:
|
||||
@@ -1605,7 +1605,7 @@ class APIServerAdapter(OpenAICompatRoutesMixin, BasePlatformAdapter):
|
||||
return None
|
||||
return runner._authorization_adapter(platform, _api_request_profile.get())
|
||||
|
||||
async def _handle_platform_event_callback(self, request: "web.Request") -> "web.Response":
|
||||
async def _handle_platform_event_callback(self, request: web.Request) -> web.Response:
|
||||
platform_name = self._normalize_callback_platform(request.match_info.get("platform", ""))
|
||||
if not platform_name:
|
||||
return _error_response("Invalid platform name", 400, code="invalid_platform")
|
||||
@@ -1644,7 +1644,7 @@ class APIServerAdapter(OpenAICompatRoutesMixin, BasePlatformAdapter):
|
||||
|
||||
# -- Multi-profile multiplexing (/p/<profile>/...) --------------------------------
|
||||
|
||||
def _resolve_request_profile(self, request: "web.Request"):
|
||||
def _resolve_request_profile(self, request: web.Request):
|
||||
"""Resolve + validate the /p/<profile>/ prefix: ``None`` (no prefix, or multiplexing
|
||||
off and the prefix names this gateway's own profile), the served profile name, or
|
||||
``_PROFILE_REJECTED`` (-> 404). Fail closed: a foreign prefix must never be ignored."""
|
||||
@@ -1682,7 +1682,7 @@ class APIServerAdapter(OpenAICompatRoutesMixin, BasePlatformAdapter):
|
||||
from hermes_cli.profiles import get_profile_dir
|
||||
return _profile_runtime_scope(get_profile_dir(profile))
|
||||
|
||||
async def _handle_profile_ingress(self, request: "web.Request") -> "web.StreamResponse":
|
||||
async def _handle_profile_ingress(self, request: web.Request) -> web.StreamResponse:
|
||||
"""``/p/<profile>/<tail>`` → the served profile's shared-listener adapter (already scoped by the
|
||||
prefix middleware); a profile with no adapter for the path is a 404, never the default's."""
|
||||
from gateway.platforms.shared_ingress import dispatch_profile_ingress
|
||||
@@ -1694,7 +1694,7 @@ class APIServerAdapter(OpenAICompatRoutesMixin, BasePlatformAdapter):
|
||||
"""Reject unknown /p/<profile>/ prefixes and scope the request home."""
|
||||
|
||||
@web.middleware
|
||||
async def profile_prefix_middleware(request: "web.Request", handler):
|
||||
async def profile_prefix_middleware(request: web.Request, handler):
|
||||
profile = self._resolve_request_profile(request)
|
||||
if profile is _PROFILE_REJECTED:
|
||||
return web.json_response({"error": "Unknown or unconfigured profile"}, status=404)
|
||||
@@ -1823,7 +1823,7 @@ class APIServerAdapter(OpenAICompatRoutesMixin, BasePlatformAdapter):
|
||||
"[%s] declared-conversation bind failed for %s", self.name, sid, exc_info=True)
|
||||
|
||||
def _parse_session_key_header(
|
||||
self, request: "web.Request") -> tuple[Optional[str], Optional["web.Response"]]:
|
||||
self, request: web.Request) -> tuple[Optional[str], Optional[web.Response]]:
|
||||
"""Validate ``X-Hermes-Session-Key`` (per-channel memory scope) -> ``(key_or_None, None)``
|
||||
or ``(None, error)``. Requires API-key auth so a client can't guess another scope."""
|
||||
raw = request.headers.get("X-Hermes-Session-Key", "").strip()
|
||||
@@ -1845,7 +1845,7 @@ class APIServerAdapter(OpenAICompatRoutesMixin, BasePlatformAdapter):
|
||||
|
||||
# -- Responses state ----------------------------------------------------------------
|
||||
|
||||
def _current_response_store(self) -> "ResponseStore":
|
||||
def _current_response_store(self) -> ResponseStore:
|
||||
"""Responses state of the routed profile's home. Conversation names are client-chosen, so one
|
||||
shared store let any profile's key read, chain onto and overwrite another's (#84253)."""
|
||||
from hermes_constants import get_hermes_home
|
||||
@@ -2049,7 +2049,7 @@ class APIServerAdapter(OpenAICompatRoutesMixin, BasePlatformAdapter):
|
||||
return (cls._clean_runtime_id(requested.get("model")),
|
||||
cls._clean_runtime_id(requested.get("provider"), max_len=80))
|
||||
|
||||
def _runtime_lock_error(self, runtime_request: dict[str, Any]) -> Optional["web.Response"]:
|
||||
def _runtime_lock_error(self, runtime_request: dict[str, Any]) -> Optional[web.Response]:
|
||||
if not runtime_request.get("require_model_lock"):
|
||||
return None
|
||||
model, provider = self._requested_ids(runtime_request.get("requested"))
|
||||
@@ -2422,12 +2422,12 @@ class APIServerAdapter(OpenAICompatRoutesMixin, BasePlatformAdapter):
|
||||
|
||||
# -- HTTP handlers ----------------------------------------------------------------
|
||||
|
||||
async def _handle_health(self, request: "web.Request") -> "web.Response":
|
||||
async def _handle_health(self, request: web.Request) -> web.Response:
|
||||
"""GET /health — simple health check."""
|
||||
return web.json_response({"status": "ok", "platform": "hermes-agent", "version": _hermes_version()})
|
||||
|
||||
@_require_auth
|
||||
async def _handle_health_detailed(self, request: "web.Request") -> "web.Response":
|
||||
async def _handle_health_detailed(self, request: web.Request) -> web.Response:
|
||||
"""GET /health/detailed — gateway state, platforms, PID for dashboard probing (Bearer auth)."""
|
||||
from gateway.status import (
|
||||
derive_gateway_busy, derive_gateway_drainable, normalize_updated_at, parse_active_agents,
|
||||
@@ -2472,7 +2472,7 @@ class APIServerAdapter(OpenAICompatRoutesMixin, BasePlatformAdapter):
|
||||
"updated_at": normalize_updated_at(runtime.get("updated_at")), "pid": os.getpid()})
|
||||
|
||||
@_require_auth
|
||||
async def _handle_models(self, request: "web.Request") -> "web.Response":
|
||||
async def _handle_models(self, request: web.Request) -> web.Response:
|
||||
"""GET /v1/models — hermes-agent plus configured model_routes aliases (alias + resolved
|
||||
model only, never credentials). Under /p/<profile>/ the primary id follows that profile."""
|
||||
now = int(time.time())
|
||||
@@ -2489,7 +2489,7 @@ class APIServerAdapter(OpenAICompatRoutesMixin, BasePlatformAdapter):
|
||||
return web.json_response({"object": "list", "data": models})
|
||||
|
||||
@_require_auth
|
||||
async def _handle_model_options(self, request: "web.Request") -> "web.Response":
|
||||
async def _handle_model_options(self, request: web.Request) -> web.Response:
|
||||
"""GET /api/model/options — the dashboard/TUI model-picker inventory, so external clients
|
||||
can sync to the configured provider catalog instead of scraping /v1/models."""
|
||||
refresh = _coerce_request_bool(request.query.get("refresh"), default=False)
|
||||
@@ -2509,7 +2509,7 @@ class APIServerAdapter(OpenAICompatRoutesMixin, BasePlatformAdapter):
|
||||
return _error_response("Failed to list model options.", 500, code="model_options_failed")
|
||||
|
||||
@_require_auth
|
||||
async def _handle_capabilities(self, request: "web.Request") -> "web.Response":
|
||||
async def _handle_capabilities(self, request: web.Request) -> web.Response:
|
||||
"""GET /v1/capabilities — the stable, machine-readable API surface for external UIs."""
|
||||
return web.json_response({
|
||||
"object": "hermes.api_server.capabilities", "platform": "hermes-agent",
|
||||
@@ -2551,7 +2551,7 @@ class APIServerAdapter(OpenAICompatRoutesMixin, BasePlatformAdapter):
|
||||
|
||||
# -- Browser-extension control (authenticated local/VPS API) ----------------------
|
||||
|
||||
async def _handle_browser_control_register(self, request: "web.Request") -> "web.Response":
|
||||
async def _handle_browser_control_register(self, request: web.Request) -> web.Response:
|
||||
"""POST /v1/browser-control/register — mint a short-lived single-use controller ticket.
|
||||
|
||||
Identity is NOT taken from the body: the principal is a server-derived digest of the
|
||||
@@ -2630,7 +2630,7 @@ class APIServerAdapter(OpenAICompatRoutesMixin, BasePlatformAdapter):
|
||||
"capabilities": sorted(scope.capabilities)}},
|
||||
status=201)
|
||||
|
||||
async def _handle_browser_control_ws(self, request: "web.Request") -> "web.WebSocketResponse":
|
||||
async def _handle_browser_control_ws(self, request: web.Request) -> web.WebSocketResponse:
|
||||
"""GET /v1/browser-control/ws — controller WebSocket (one-shot ticket).
|
||||
|
||||
The ticket rides in ``Sec-WebSocket-Protocol`` (never the query string: targets land in
|
||||
@@ -2686,7 +2686,7 @@ class APIServerAdapter(OpenAICompatRoutesMixin, BasePlatformAdapter):
|
||||
return ws
|
||||
|
||||
def _handle_browser_control_frame(
|
||||
self, scope: "ControllerScope", frame: dict, *, owner: Any = None) -> Optional[dict]:
|
||||
self, scope: ControllerScope, frame: dict, *, owner: Any = None) -> Optional[dict]:
|
||||
"""Apply one controller->broker frame with exact-scope checks."""
|
||||
method = frame.get("method")
|
||||
params = frame.get("params")
|
||||
@@ -2729,7 +2729,7 @@ class APIServerAdapter(OpenAICompatRoutesMixin, BasePlatformAdapter):
|
||||
digest = hashlib.sha256(f"{profile}\x00{key}".encode()).hexdigest()
|
||||
return f"principal:{profile}:{digest[:32]}"
|
||||
|
||||
def _browser_control_transport_family(self, request: "web.Request") -> str:
|
||||
def _browser_control_transport_family(self, request: web.Request) -> str:
|
||||
"""``local-api`` for a loopback peer, else ``remote-api``; the broker treats the family as
|
||||
part of exact identity, so a remote controller never satisfies a local-only dispatch."""
|
||||
host = None
|
||||
@@ -2830,7 +2830,7 @@ class APIServerAdapter(OpenAICompatRoutesMixin, BasePlatformAdapter):
|
||||
if limiter is not None:
|
||||
self._browser_control_artifact_limiter = limiter
|
||||
|
||||
def _artifact_route_prelude(self, request: "web.Request", action: str, *, check_enabled: bool = True) -> tuple:
|
||||
def _artifact_route_prelude(self, request: web.Request, action: str, *, check_enabled: bool = True) -> tuple:
|
||||
"""Shared upload/download gate (feature flag -> API key -> Bearer -> per-principal rate
|
||||
limit) -> ``((profile, principal), None)`` or ``(None, error_response)``."""
|
||||
if check_enabled and not self._browser_control_enabled():
|
||||
@@ -2851,7 +2851,7 @@ class APIServerAdapter(OpenAICompatRoutesMixin, BasePlatformAdapter):
|
||||
code="rate_limit_exceeded", headers={"Retry-After": "1"})
|
||||
return (profile, principal), None
|
||||
|
||||
async def _handle_artifact_upload(self, request: "web.Request") -> "web.Response":
|
||||
async def _handle_artifact_upload(self, request: web.Request) -> web.Response:
|
||||
"""POST /v1/artifacts/upload — one-shot bounded raw upload; ``Content-Type`` must be an
|
||||
allowed MIME type, ``X-Artifact-Filename`` is display-only. Returns a provenance receipt
|
||||
(never a filesystem path). Ladder: 404 disabled, 403 no API key, 401 bad Bearer, 429
|
||||
@@ -2895,7 +2895,7 @@ class APIServerAdapter(OpenAICompatRoutesMixin, BasePlatformAdapter):
|
||||
return web.json_response(
|
||||
receipt.to_dict(download_path=f"/v1/artifacts/download/{receipt.artifact_id}"), status=201)
|
||||
|
||||
async def _handle_artifact_download(self, request: "web.Request") -> "web.Response":
|
||||
async def _handle_artifact_download(self, request: web.Request) -> web.Response:
|
||||
"""GET /v1/artifacts/download/{artifact_id} — one-shot download (a second one 404s) with
|
||||
``X-Artifact-Sha256``. Ladder: 404 disabled/unknown, 403 no API key, 401 bad Bearer, 429
|
||||
rate limited, 410 expired, 400 invalid id/scope mismatch, 200."""
|
||||
@@ -2924,7 +2924,7 @@ class APIServerAdapter(OpenAICompatRoutesMixin, BasePlatformAdapter):
|
||||
"Content-Disposition": f'attachment; filename="{receipt.filename}"'})
|
||||
|
||||
@_require_auth
|
||||
async def _handle_skills(self, request: "web.Request") -> "web.Response":
|
||||
async def _handle_skills(self, request: web.Request) -> web.Response:
|
||||
"""GET /v1/skills — deterministic JSON listing of installed skills (name, description,
|
||||
category), the same set ``/skills list`` shows."""
|
||||
try:
|
||||
@@ -2940,7 +2940,7 @@ class APIServerAdapter(OpenAICompatRoutesMixin, BasePlatformAdapter):
|
||||
return web.json_response({"object": "list", "data": skills})
|
||||
|
||||
@_require_auth
|
||||
async def _handle_toolsets(self, request: "web.Request") -> "web.Response":
|
||||
async def _handle_toolsets(self, request: web.Request) -> web.Response:
|
||||
"""GET /v1/toolsets — each toolset the api_server agent exposes: enabled/configured state
|
||||
plus the concrete tool names it expands to."""
|
||||
try:
|
||||
@@ -2979,7 +2979,7 @@ class APIServerAdapter(OpenAICompatRoutesMixin, BasePlatformAdapter):
|
||||
return default if parsed < 0 else min(parsed, maximum)
|
||||
|
||||
@staticmethod
|
||||
def _session_db_unavailable() -> "web.Response":
|
||||
def _session_db_unavailable() -> web.Response:
|
||||
return _error_response("Session database unavailable", 503, code="session_db_unavailable")
|
||||
|
||||
@staticmethod
|
||||
@@ -3026,7 +3026,7 @@ class APIServerAdapter(OpenAICompatRoutesMixin, BasePlatformAdapter):
|
||||
"display_kind")
|
||||
return {key: message.get(key) for key in safe_keys if key in message}
|
||||
|
||||
async def _read_json_body(self, request: "web.Request") -> tuple[dict[str, Any], Optional["web.Response"]]:
|
||||
async def _read_json_body(self, request: web.Request) -> tuple[dict[str, Any], Optional[web.Response]]:
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
@@ -3035,7 +3035,7 @@ class APIServerAdapter(OpenAICompatRoutesMixin, BasePlatformAdapter):
|
||||
return {}, _error_response("Request body must be a JSON object", 400)
|
||||
return body, None
|
||||
|
||||
async def _get_existing_session_or_404(self, session_id: str) -> tuple[Optional[dict[str, Any]], Optional["web.Response"]]:
|
||||
async def _get_existing_session_or_404(self, session_id: str) -> tuple[Optional[dict[str, Any]], Optional[web.Response]]:
|
||||
db = await self._ensure_session_db_async()
|
||||
if db is None:
|
||||
return None, self._session_db_unavailable()
|
||||
@@ -3063,7 +3063,7 @@ class APIServerAdapter(OpenAICompatRoutesMixin, BasePlatformAdapter):
|
||||
notification_category=notification_category, _api_server=sys.modules[__name__])
|
||||
|
||||
@_require_auth
|
||||
async def _handle_list_sessions(self, request: "web.Request") -> "web.Response":
|
||||
async def _handle_list_sessions(self, request: web.Request) -> web.Response:
|
||||
"""GET /api/sessions — list persisted Hermes sessions."""
|
||||
db = await self._ensure_session_db_async()
|
||||
if db is None:
|
||||
@@ -3118,7 +3118,7 @@ class APIServerAdapter(OpenAICompatRoutesMixin, BasePlatformAdapter):
|
||||
"limit": limit, "offset": offset, "has_more": windowed >= limit})
|
||||
|
||||
@_require_auth
|
||||
async def _handle_create_session(self, request: "web.Request") -> "web.Response":
|
||||
async def _handle_create_session(self, request: web.Request) -> web.Response:
|
||||
"""POST /api/sessions -- create an empty Hermes session row. Existence check, insert and
|
||||
title handling run as ONE off-loop write so concurrent same-id creates can't both 201."""
|
||||
body, err = await self._read_json_body(request)
|
||||
@@ -3186,7 +3186,7 @@ class APIServerAdapter(OpenAICompatRoutesMixin, BasePlatformAdapter):
|
||||
return web.json_response({"object": "hermes.session", "session": self._session_response(session)}, status=201)
|
||||
|
||||
@_require_auth
|
||||
async def _handle_get_session(self, request: "web.Request") -> "web.Response":
|
||||
async def _handle_get_session(self, request: web.Request) -> web.Response:
|
||||
"""GET /api/sessions/{session_id}."""
|
||||
session, err = await self._get_existing_session_or_404(request.match_info["session_id"])
|
||||
if err:
|
||||
@@ -3194,7 +3194,7 @@ class APIServerAdapter(OpenAICompatRoutesMixin, BasePlatformAdapter):
|
||||
return web.json_response({"object": "hermes.session", "session": self._session_response(session)})
|
||||
|
||||
@_require_auth
|
||||
async def _handle_patch_session(self, request: "web.Request") -> "web.Response":
|
||||
async def _handle_patch_session(self, request: web.Request) -> web.Response:
|
||||
"""PATCH /api/sessions/{session_id} — update client-safe session metadata."""
|
||||
session_id = request.match_info["session_id"]
|
||||
session, err = await self._get_existing_session_or_404(session_id)
|
||||
@@ -3234,7 +3234,7 @@ class APIServerAdapter(OpenAICompatRoutesMixin, BasePlatformAdapter):
|
||||
return web.json_response({"object": "hermes.session", "session": self._session_response(session)})
|
||||
|
||||
@_require_auth
|
||||
async def _handle_delete_session(self, request: "web.Request") -> "web.Response":
|
||||
async def _handle_delete_session(self, request: web.Request) -> web.Response:
|
||||
"""DELETE /api/sessions/{session_id}."""
|
||||
session_id = request.match_info["session_id"]
|
||||
_session, err = await self._get_existing_session_or_404(session_id)
|
||||
@@ -3267,7 +3267,7 @@ class APIServerAdapter(OpenAICompatRoutesMixin, BasePlatformAdapter):
|
||||
return web.json_response({"object": "hermes.session.deleted", "id": session_id, "deleted": bool(deleted)})
|
||||
|
||||
@_require_auth
|
||||
async def _handle_session_messages(self, request: "web.Request") -> "web.Response":
|
||||
async def _handle_session_messages(self, request: web.Request) -> web.Response:
|
||||
"""GET /api/sessions/{session_id}/messages."""
|
||||
session_id = request.match_info["session_id"]
|
||||
_, err = await self._get_existing_session_or_404(session_id)
|
||||
@@ -3303,7 +3303,7 @@ class APIServerAdapter(OpenAICompatRoutesMixin, BasePlatformAdapter):
|
||||
"returned": len(messages)}})
|
||||
|
||||
@_require_auth
|
||||
async def _handle_fork_session(self, request: "web.Request") -> "web.Response":
|
||||
async def _handle_fork_session(self, request: web.Request) -> web.Response:
|
||||
"""POST /api/sessions/{session_id}/fork — branch via current SessionDB primitives."""
|
||||
source_id = request.match_info["session_id"]
|
||||
source, err = await self._get_existing_session_or_404(source_id)
|
||||
@@ -3344,7 +3344,7 @@ class APIServerAdapter(OpenAICompatRoutesMixin, BasePlatformAdapter):
|
||||
fork = await asyncio.to_thread(db.get_session, fork_id) or {"id": fork_id, "parent_session_id": source_id}
|
||||
return web.json_response({"object": "hermes.session", "session": self._session_response(fork)}, status=201)
|
||||
|
||||
async def _prepare_session_chat(self, request: "web.Request") -> tuple:
|
||||
async def _prepare_session_chat(self, request: web.Request) -> tuple:
|
||||
"""Shared prelude for /api/sessions/{id}/chat[/stream]: header/body validation, then
|
||||
runtime selection — a Browser model lock (body ``require_model_lock`` or a confirmed
|
||||
persisted lock) wins; else the session-persisted model routes via model_routes when an
|
||||
@@ -3481,7 +3481,7 @@ class APIServerAdapter(OpenAICompatRoutesMixin, BasePlatformAdapter):
|
||||
record = await asyncio.to_thread(_admit)
|
||||
return None if record is None else (home, record)
|
||||
|
||||
async def _answer_through_live_bot_chat(self, ctx: dict[str, Any]) -> Optional["web.Response"]:
|
||||
async def _answer_through_live_bot_chat(self, ctx: dict[str, Any]) -> Optional[web.Response]:
|
||||
"""Hand a turn aimed at a canonical Bot Chat that a Desktop holds live to that owner.
|
||||
|
||||
This is the ``hermes peer dm`` transport. Running the turn here would make this process a
|
||||
@@ -3528,7 +3528,7 @@ class APIServerAdapter(OpenAICompatRoutesMixin, BasePlatformAdapter):
|
||||
await keepalive()
|
||||
return record
|
||||
|
||||
async def _stream_through_live_bot_chat(self, request: "web.Request", ctx: dict[str, Any]) -> Optional["web.StreamResponse"]:
|
||||
async def _stream_through_live_bot_chat(self, request: web.Request, ctx: dict[str, Any]) -> Optional[web.StreamResponse]:
|
||||
"""``_answer_through_live_bot_chat`` for the SSE sibling route: the owner's settled receipt is
|
||||
the run's single ``assistant.completed`` event; a receipt still open at the budget is a
|
||||
``run.queued`` event (the 202 shape), a failed one an ``error`` event carrying the reason."""
|
||||
@@ -3567,7 +3567,7 @@ class APIServerAdapter(OpenAICompatRoutesMixin, BasePlatformAdapter):
|
||||
return response
|
||||
|
||||
@_admit_api_agent_request
|
||||
async def _handle_session_chat(self, request: "web.Request") -> "web.Response":
|
||||
async def _handle_session_chat(self, request: web.Request) -> web.Response:
|
||||
"""POST /api/sessions/{session_id}/chat — one synchronous agent turn (plus the delivery lanes'
|
||||
one bounded re-run of a transient failure; ``hermes peer dm`` is the client)."""
|
||||
from tools.bot_failure_reasons import RETRY_NONE, result_retry_action
|
||||
@@ -3612,7 +3612,7 @@ class APIServerAdapter(OpenAICompatRoutesMixin, BasePlatformAdapter):
|
||||
headers=headers)
|
||||
|
||||
@_admit_api_agent_request
|
||||
async def _handle_session_chat_stream(self, request: "web.Request") -> "web.StreamResponse":
|
||||
async def _handle_session_chat_stream(self, request: web.Request) -> web.StreamResponse:
|
||||
"""POST /api/sessions/{session_id}/chat/stream — SSE wrapper over _run_agent."""
|
||||
limited = self._concurrency_limited_response()
|
||||
if limited is not None:
|
||||
@@ -3740,7 +3740,7 @@ class APIServerAdapter(OpenAICompatRoutesMixin, BasePlatformAdapter):
|
||||
logger.debug("[api_server] session SSE stream error: %s", exc)
|
||||
return response
|
||||
|
||||
def _register_session_stream_approval(self, run_id: str, events: "_SessionEventQueue", message_id: str):
|
||||
def _register_session_stream_approval(self, run_id: str, events: _SessionEventQueue, message_id: str):
|
||||
"""Route a session-stream turn's dangerous-command approvals to its SSE queue and to
|
||||
``POST /v1/runs/{run_id}/approval`` (#58856). Keyed by the run id (never the shared
|
||||
session key) so concurrent turns on one session can't cross-resolve."""
|
||||
@@ -3753,7 +3753,7 @@ class APIServerAdapter(OpenAICompatRoutesMixin, BasePlatformAdapter):
|
||||
return _approval_notify
|
||||
|
||||
async def _drain_session_stream_task_on_disconnect(
|
||||
self, run_id: str, task: "asyncio.Task", *, interrupt_message: str, shield_wait: bool
|
||||
self, run_id: str, task: asyncio.Task, *, interrupt_message: str, shield_wait: bool
|
||||
) -> None:
|
||||
"""Preserve live run control refs until the executor-backed turn actually exits."""
|
||||
agent = self._active_run_agents.get(run_id)
|
||||
@@ -3770,7 +3770,7 @@ class APIServerAdapter(OpenAICompatRoutesMixin, BasePlatformAdapter):
|
||||
await (asyncio.shield(task) if shield_wait else task)
|
||||
|
||||
@_require_auth
|
||||
async def _handle_session_model_lock(self, request: "web.Request") -> "web.Response":
|
||||
async def _handle_session_model_lock(self, request: web.Request) -> web.Response:
|
||||
"""POST /api/sessions/{session_id}/model — backend-ack a Browser model lock."""
|
||||
session_id = request.match_info["session_id"]
|
||||
_, err = await self._get_existing_session_or_404(session_id)
|
||||
@@ -3809,7 +3809,7 @@ class APIServerAdapter(OpenAICompatRoutesMixin, BasePlatformAdapter):
|
||||
_MAX_PROMPT_LENGTH = 5000
|
||||
|
||||
def _cron_request_guard(
|
||||
self, request: "web.Request", *, need_job_id: bool = False, check_draining: bool = False,
|
||||
self, request: web.Request, *, need_job_id: bool = False, check_draining: bool = False,
|
||||
) -> tuple:
|
||||
"""Shared /api/jobs prelude: auth → (drain) → cron available → (job_id). Returns (job_id, err)."""
|
||||
auth_err = self._check_auth(request)
|
||||
@@ -3831,10 +3831,10 @@ class APIServerAdapter(OpenAICompatRoutesMixin, BasePlatformAdapter):
|
||||
return job_id, None
|
||||
|
||||
@staticmethod
|
||||
def _cron_error_response(exc: BaseException) -> "web.Response":
|
||||
def _cron_error_response(exc: BaseException) -> web.Response:
|
||||
return web.json_response({"error": _redact_api_error_text(exc)}, status=500)
|
||||
|
||||
def _validate_cron_prompt(self, prompt: str) -> Optional["web.Response"]:
|
||||
def _validate_cron_prompt(self, prompt: str) -> Optional[web.Response]:
|
||||
"""Length cap + injection scan shared by create/update/run."""
|
||||
if len(prompt) > self._MAX_PROMPT_LENGTH:
|
||||
return web.json_response({"error": f"Prompt must be ≤ {self._MAX_PROMPT_LENGTH} characters"}, status=400)
|
||||
@@ -3844,7 +3844,7 @@ class APIServerAdapter(OpenAICompatRoutesMixin, BasePlatformAdapter):
|
||||
return web.json_response({"error": scan_error}, status=400)
|
||||
return None
|
||||
|
||||
def _job_response(self, fn, job_id: str, *, notify: bool) -> "web.Response":
|
||||
def _job_response(self, fn, job_id: str, *, notify: bool) -> web.Response:
|
||||
"""Run ``fn(job_id)``: 404 when falsy, else ``{"job": ...}``; exceptions -> 500."""
|
||||
try:
|
||||
job = fn(job_id)
|
||||
@@ -3856,11 +3856,11 @@ class APIServerAdapter(OpenAICompatRoutesMixin, BasePlatformAdapter):
|
||||
except Exception as e:
|
||||
return self._cron_error_response(e)
|
||||
|
||||
async def _job_lookup_or_mutate(self, request: "web.Request", fn, *, notify: bool) -> "web.Response":
|
||||
async def _job_lookup_or_mutate(self, request: web.Request, fn, *, notify: bool) -> web.Response:
|
||||
job_id, err = self._cron_request_guard(request, need_job_id=True)
|
||||
return err if err else self._job_response(fn, job_id, notify=notify)
|
||||
|
||||
async def _handle_list_jobs(self, request: "web.Request") -> "web.Response":
|
||||
async def _handle_list_jobs(self, request: web.Request) -> web.Response:
|
||||
"""GET /api/jobs — list all cron jobs."""
|
||||
_, err = self._cron_request_guard(request)
|
||||
if err:
|
||||
@@ -3871,7 +3871,7 @@ class APIServerAdapter(OpenAICompatRoutesMixin, BasePlatformAdapter):
|
||||
except Exception as e:
|
||||
return self._cron_error_response(e)
|
||||
|
||||
async def _handle_create_job(self, request: "web.Request") -> "web.Response":
|
||||
async def _handle_create_job(self, request: web.Request) -> web.Response:
|
||||
"""POST /api/jobs — create a new cron job."""
|
||||
_, err = self._cron_request_guard(request)
|
||||
if err:
|
||||
@@ -3913,11 +3913,11 @@ class APIServerAdapter(OpenAICompatRoutesMixin, BasePlatformAdapter):
|
||||
except Exception as e:
|
||||
return self._cron_error_response(e)
|
||||
|
||||
async def _handle_get_job(self, request: "web.Request") -> "web.Response":
|
||||
async def _handle_get_job(self, request: web.Request) -> web.Response:
|
||||
"""GET /api/jobs/{job_id} — get a single cron job."""
|
||||
return await self._job_lookup_or_mutate(request, _cron_get, notify=False)
|
||||
|
||||
async def _handle_update_job(self, request: "web.Request") -> "web.Response":
|
||||
async def _handle_update_job(self, request: web.Request) -> web.Response:
|
||||
"""PATCH /api/jobs/{job_id} — update a cron job."""
|
||||
job_id, err = self._cron_request_guard(request, need_job_id=True)
|
||||
if err:
|
||||
@@ -3938,7 +3938,7 @@ class APIServerAdapter(OpenAICompatRoutesMixin, BasePlatformAdapter):
|
||||
return self._cron_error_response(e)
|
||||
return self._job_response(lambda jid: _cron_update(jid, sanitized), job_id, notify=True)
|
||||
|
||||
async def _handle_delete_job(self, request: "web.Request") -> "web.Response":
|
||||
async def _handle_delete_job(self, request: web.Request) -> web.Response:
|
||||
"""DELETE /api/jobs/{job_id} — delete a cron job."""
|
||||
job_id, err = self._cron_request_guard(request, need_job_id=True)
|
||||
if err:
|
||||
@@ -3951,15 +3951,15 @@ class APIServerAdapter(OpenAICompatRoutesMixin, BasePlatformAdapter):
|
||||
except Exception as e:
|
||||
return self._cron_error_response(e)
|
||||
|
||||
async def _handle_pause_job(self, request: "web.Request") -> "web.Response":
|
||||
async def _handle_pause_job(self, request: web.Request) -> web.Response:
|
||||
"""POST /api/jobs/{job_id}/pause — pause a cron job."""
|
||||
return await self._job_lookup_or_mutate(request, _cron_pause, notify=True)
|
||||
|
||||
async def _handle_resume_job(self, request: "web.Request") -> "web.Response":
|
||||
async def _handle_resume_job(self, request: web.Request) -> web.Response:
|
||||
"""POST /api/jobs/{job_id}/resume — resume a paused cron job."""
|
||||
return await self._job_lookup_or_mutate(request, _cron_resume, notify=True)
|
||||
|
||||
async def _handle_run_job(self, request: "web.Request") -> "web.Response":
|
||||
async def _handle_run_job(self, request: web.Request) -> web.Response:
|
||||
"""POST /api/jobs/{job_id}/run — trigger immediate execution."""
|
||||
job_id, err = self._cron_request_guard(request, need_job_id=True, check_draining=True)
|
||||
if err:
|
||||
@@ -3980,7 +3980,7 @@ class APIServerAdapter(OpenAICompatRoutesMixin, BasePlatformAdapter):
|
||||
return self._job_response(
|
||||
lambda jid: _cron_trigger(jid, extra_prompt=extra_prompt), job_id, notify=False)
|
||||
|
||||
async def _handle_cron_fire(self, request: "web.Request") -> "web.Response":
|
||||
async def _handle_cron_fire(self, request: web.Request) -> web.Response:
|
||||
"""POST /api/cron/fire — Chronos fire webhook (NAS -> agent), authenticated by a
|
||||
NAS-minted JWT via the pluggable verifier, NOT API_SERVER_KEY. 202 + background run so
|
||||
a long turn never trips NAS's timeout; the store CAS claim guards double-fire on retry."""
|
||||
@@ -4038,7 +4038,7 @@ class APIServerAdapter(OpenAICompatRoutesMixin, BasePlatformAdapter):
|
||||
if refusal is not None:
|
||||
return refusal
|
||||
|
||||
def _detach_fire(fire_fn, *fire_args) -> "web.Response":
|
||||
def _detach_fire(fire_fn, *fire_args) -> web.Response:
|
||||
# The done callback owns the reservation once the task is detached.
|
||||
task = asyncio.create_task(asyncio.to_thread(fire_fn, *fire_args, adapters=adapters, loop=loop))
|
||||
reservation["detached"] = True
|
||||
@@ -4077,7 +4077,7 @@ class APIServerAdapter(OpenAICompatRoutesMixin, BasePlatformAdapter):
|
||||
if hasattr(task, "add_done_callback"):
|
||||
task.add_done_callback(self._background_tasks.discard)
|
||||
|
||||
def _concurrency_limited_response(self) -> Optional["web.Response"]:
|
||||
def _concurrency_limited_response(self) -> Optional[web.Response]:
|
||||
"""429 when the concurrent-run cap is reached (0 disables), else None. Uses the same
|
||||
adapter-owned work count as shutdown draining (admitted requests included)."""
|
||||
limit = self._max_concurrent_runs
|
||||
@@ -4361,29 +4361,29 @@ class APIServerAdapter(OpenAICompatRoutesMixin, BasePlatformAdapter):
|
||||
def _set_run_status(self, run_id: str, status: str, **fields: Any) -> dict[str, Any]:
|
||||
return _api_runs._set_run_status(self, run_id, status, **fields)
|
||||
|
||||
def _make_run_event_callback(self, run_id: str, loop: "asyncio.AbstractEventLoop"):
|
||||
def _make_run_event_callback(self, run_id: str, loop: asyncio.AbstractEventLoop):
|
||||
return _api_runs._make_run_event_callback(self, run_id, loop, _api_server=sys.modules[__name__])
|
||||
|
||||
def _run_idempotency_scope(self, request: "web.Request") -> str:
|
||||
def _run_idempotency_scope(self, request: web.Request) -> str:
|
||||
return _api_runs._run_idempotency_scope(self, request, _api_server=sys.modules[__name__])
|
||||
|
||||
@staticmethod
|
||||
def _room_grant_token(request: "web.Request") -> str:
|
||||
def _room_grant_token(request: web.Request) -> str:
|
||||
return _room_grants._room_grant_token(request)
|
||||
|
||||
def _room_grant_secret(self) -> bytes:
|
||||
return _room_grants._room_grant_secret(self)
|
||||
|
||||
def _room_grant_claims(self, request: "web.Request", *, permission: str) -> dict[str, Any]:
|
||||
def _room_grant_claims(self, request: web.Request, *, permission: str) -> dict[str, Any]:
|
||||
return _room_grants._room_grant_claims(self, request, permission=permission)
|
||||
|
||||
def _check_run_auth(self, request: "web.Request", *, permission: str) -> "web.Response | None":
|
||||
def _check_run_auth(self, request: web.Request, *, permission: str) -> web.Response | None:
|
||||
return _api_runs._check_run_auth(self, request, permission=permission, _api_server=sys.modules[__name__])
|
||||
|
||||
async def _ensure_hosted_member_session(self, dispatch: Any) -> str:
|
||||
return await _room_dispatch._ensure_hosted_member_session(self, dispatch)
|
||||
|
||||
async def _normalize_room_dispatch(self, request: "web.Request", body: Any) -> tuple[Any, "web.Response | None"]:
|
||||
async def _normalize_room_dispatch(self, request: web.Request, body: Any) -> tuple[Any, web.Response | None]:
|
||||
return await _room_dispatch._normalize_room_dispatch(self, request, body, _api_server=sys.modules[__name__])
|
||||
|
||||
_handle_room_member_invitation = _room_grant_delegate("_handle_room_member_invitation")
|
||||
@@ -4391,14 +4391,14 @@ class APIServerAdapter(OpenAICompatRoutesMixin, BasePlatformAdapter):
|
||||
_handle_room_member_grant_refresh = _room_grant_delegate("_handle_room_member_grant_refresh")
|
||||
_handle_room_member_grant_revoke = _room_grant_delegate("_handle_room_member_grant_revoke")
|
||||
|
||||
def _durable_run_status(self, request: "web.Request", run_id: str) -> dict[str, Any] | None:
|
||||
def _durable_run_status(self, request: web.Request, run_id: str) -> dict[str, Any] | None:
|
||||
return _api_runs._durable_run_status(self, request, run_id)
|
||||
|
||||
@_admit_api_agent_request
|
||||
async def _handle_runs(self, request: "web.Request") -> "web.Response":
|
||||
async def _handle_runs(self, request: web.Request) -> web.Response:
|
||||
return await _api_runs._handle_runs(self, request, _api_server=sys.modules[__name__])
|
||||
|
||||
def _request_owns_run(self, request: "web.Request", run_id: str) -> bool:
|
||||
def _request_owns_run(self, request: web.Request, run_id: str) -> bool:
|
||||
return _api_runs._request_owns_run(self, request, run_id)
|
||||
|
||||
def _release_run_owner_if_forgotten(self, run_id: str) -> None:
|
||||
|
||||
@@ -27,7 +27,7 @@ def runner_is_draining(runner: Any) -> bool:
|
||||
return bool(getattr(runner, "_draining", False) or getattr(runner, "_external_drain_active", False))
|
||||
|
||||
|
||||
async def refuse_until_started(runner: Any, job_id: str, *, received_at: float) -> Optional["web.Response"]:
|
||||
async def refuse_until_started(runner: Any, job_id: str, *, received_at: float) -> Optional[web.Response]:
|
||||
"""None once ``runner`` has finished starting (``_running``: every platform connect attempted and its
|
||||
adapters published), or when there is no runner to ask (a self-hosted api_server, a test double).
|
||||
Otherwise the retryable 503, worded like the dashboard's own "gateway unreachable" so NAS classifies
|
||||
@@ -53,8 +53,8 @@ async def refuse_until_started(runner: Any, job_id: str, *, received_at: float)
|
||||
|
||||
|
||||
async def live_adapters_once_started(
|
||||
api_adapter: Any, request: "web.Request", job_id: str, *, received_at: float,
|
||||
) -> tuple[Optional["web.Response"], Any]:
|
||||
api_adapter: Any, request: web.Request, job_id: str, *, received_at: float,
|
||||
) -> tuple[Optional[web.Response], Any]:
|
||||
"""``(refusal, adapters)`` for a fire: the startup gate's retryable 503, else the gateway's LIVE adapters
|
||||
(parity with the built-in ticker: E2EE / relay-fronted platforms have no native credential, so without
|
||||
them delivery fails). ``None`` adapters when there is no runner (a self-hosted api_server)."""
|
||||
|
||||
@@ -32,7 +32,7 @@ class ApiServerMemorySessions:
|
||||
"""Session-keyed ``MemoryManager`` registry with exclusive check-out/check-in."""
|
||||
|
||||
def __init__(self, *, max_size: Optional[int] = None, idle_ttl_secs: Optional[float] = None) -> None:
|
||||
self._entries: "OrderedDict[tuple[str, str], tuple[Any, Optional[Path], float]]" = OrderedDict()
|
||||
self._entries: OrderedDict[tuple[str, str], tuple[Any, Optional[Path], float]] = OrderedDict()
|
||||
self._lock = threading.Lock()
|
||||
self._max_size = max_size
|
||||
self._idle_ttl_secs = idle_ttl_secs
|
||||
|
||||
@@ -633,7 +633,7 @@ class OpenAICompatRoutesMixin:
|
||||
agent_task.add_done_callback(_done)
|
||||
return agent_task, agent_ref
|
||||
|
||||
async def _handle_chat_completions(self, request: "web.Request") -> "web.Response":
|
||||
async def _handle_chat_completions(self, request: web.Request) -> web.Response:
|
||||
"""POST /v1/chat/completions — OpenAI Chat Completions format."""
|
||||
from gateway.platforms.api_server import (
|
||||
ThreadSafeAsyncQueue, _api_request_profile, _chat_usage_payload, _coerce_request_bool,
|
||||
@@ -841,7 +841,7 @@ class OpenAICompatRoutesMixin:
|
||||
return web.json_response(response_data, headers=response_headers)
|
||||
|
||||
async def _run_idempotent(
|
||||
self, request: "web.Request", body: dict[str, Any], compute, *,
|
||||
self, request: web.Request, body: dict[str, Any], compute, *,
|
||||
log_label: str, fingerprint_keys: list[str], route: str) -> tuple:
|
||||
"""Run ``compute()`` once per (principal scope, logical route, Idempotency-Key) + body fingerprint
|
||||
-> ``((result, usage), None)`` or ``(None, 500 response)``.
|
||||
@@ -870,8 +870,8 @@ class OpenAICompatRoutesMixin:
|
||||
return None, _error_response(message, 500, err_type="server_error")
|
||||
|
||||
async def _prepare_sse_response(
|
||||
self, request: "web.Request", session_id: Optional[str], gateway_session_key: Optional[str],
|
||||
) -> "web.StreamResponse":
|
||||
self, request: web.Request, session_id: Optional[str], gateway_session_key: Optional[str],
|
||||
) -> web.StreamResponse:
|
||||
"""Open a prepared SSE StreamResponse with CORS + session headers (the CORS middleware
|
||||
can't inject headers after ``prepare()`` flushes them, so they are resolved here)."""
|
||||
sse_headers = self._sse_headers(request)
|
||||
@@ -884,9 +884,9 @@ class OpenAICompatRoutesMixin:
|
||||
return response
|
||||
|
||||
async def _write_sse_chat_completion(
|
||||
self, request: "web.Request", completion_id: str, model: str,
|
||||
self, request: web.Request, completion_id: str, model: str,
|
||||
created: int, stream_q, agent_task, agent_ref=None, session_id: str | None = None,
|
||||
gateway_session_key: str | None = None) -> "web.StreamResponse":
|
||||
gateway_session_key: str | None = None) -> web.StreamResponse:
|
||||
"""Stream ``chat.completion.chunk`` frames from the agent's delta queue. On client
|
||||
disconnect the agent is interrupted (stops LLM calls), then its task wrapper cancelled."""
|
||||
from gateway.platforms.api_server import (
|
||||
@@ -975,10 +975,10 @@ class OpenAICompatRoutesMixin:
|
||||
return response
|
||||
|
||||
async def _write_sse_responses(
|
||||
self, request: "web.Request", response_id: str, model: str, created_at: int, stream_q,
|
||||
self, request: web.Request, response_id: str, model: str, created_at: int, stream_q,
|
||||
agent_task, agent_ref, conversation_history: list[dict[str, str]], user_message: str,
|
||||
instructions: Optional[str], conversation: Optional[str], store: bool, session_id: str,
|
||||
gateway_session_key: Optional[str] = None) -> "web.StreamResponse":
|
||||
gateway_session_key: Optional[str] = None) -> web.StreamResponse:
|
||||
"""Write the SSE stream for POST /v1/responses.
|
||||
|
||||
Events: ``response.created`` -> ``output_text.delta/done`` + ``output_item.added/done``
|
||||
@@ -1031,7 +1031,7 @@ class OpenAICompatRoutesMixin:
|
||||
logger.error("Agent crashed mid-stream for %s: %s", response_id, str(st.agent_error)[:300])
|
||||
return response
|
||||
|
||||
async def _handle_responses(self, request: "web.Request") -> "web.Response":
|
||||
async def _handle_responses(self, request: web.Request) -> web.Response:
|
||||
"""POST /v1/responses — OpenAI Responses API format."""
|
||||
from gateway.platforms.api_server import (
|
||||
ThreadSafeAsyncQueue, _auto_truncate_response_history, _coerce_request_bool,
|
||||
@@ -1213,7 +1213,7 @@ class OpenAICompatRoutesMixin:
|
||||
response_headers["X-Hermes-Session-Key"] = gateway_session_key
|
||||
return web.json_response(response_data, headers=response_headers)
|
||||
|
||||
async def _handle_get_response(self, request: "web.Request") -> "web.Response":
|
||||
async def _handle_get_response(self, request: web.Request) -> web.Response:
|
||||
"""GET /v1/responses/{response_id} — retrieve a stored response."""
|
||||
from gateway.platforms.api_server import _error_response
|
||||
auth_err = self._check_auth(request)
|
||||
@@ -1225,7 +1225,7 @@ class OpenAICompatRoutesMixin:
|
||||
return _error_response(f"Response not found: {response_id}", 404)
|
||||
return web.json_response(stored["response"])
|
||||
|
||||
async def _handle_delete_response(self, request: "web.Request") -> "web.Response":
|
||||
async def _handle_delete_response(self, request: web.Request) -> web.Response:
|
||||
"""DELETE /v1/responses/{response_id} — delete a stored response."""
|
||||
from gateway.platforms.api_server import _error_response
|
||||
auth_err = self._check_auth(request)
|
||||
|
||||
@@ -48,7 +48,7 @@ async def _ensure_hosted_member_session(self, dispatch: Any) -> str:
|
||||
return await asyncio.to_thread(db._execute_write, atomic)
|
||||
|
||||
|
||||
def _room_dispatch_error(exc: Exception, *, _openai_error) -> "web.Response":
|
||||
def _room_dispatch_error(exc: Exception, *, _openai_error) -> web.Response:
|
||||
message, code = str(exc), "invalid_room_dispatch"
|
||||
lowered = message.lower()
|
||||
if "execution policy" in lowered or "remote room execution requires" in lowered:
|
||||
@@ -61,7 +61,7 @@ def _room_dispatch_error(exc: Exception, *, _openai_error) -> "web.Response":
|
||||
|
||||
|
||||
async def _normalize_room_dispatch(
|
||||
self, request: "web.Request", body: Any, *, _api_server) -> tuple[Any, "web.Response | None"]:
|
||||
self, request: web.Request, body: Any, *, _api_server) -> tuple[Any, web.Response | None]:
|
||||
"""Validate and normalize a scoped RoomLink dispatch request."""
|
||||
_openai_error, room_token = _api_server._openai_error, self._room_grant_token(request)
|
||||
if not room_token:
|
||||
|
||||
@@ -14,7 +14,7 @@ class RoomGrantReauthorizationRequired(ValueError):
|
||||
"""A validly signed room grant was revoked or superseded."""
|
||||
|
||||
|
||||
def _json_error(_openai_error, message: str, *, status: int, **error_kwargs) -> "web.Response":
|
||||
def _json_error(_openai_error, message: str, *, status: int, **error_kwargs) -> web.Response:
|
||||
"""JSON error response built with the injected ``_openai_error`` envelope builder."""
|
||||
return web.json_response(_openai_error(message, **error_kwargs), status=status)
|
||||
|
||||
@@ -25,7 +25,7 @@ def _require_unchanged_execution_policy(claims: dict[str, Any], execution_policy
|
||||
raise RoomGrantReauthorizationRequired("room execution policy changed")
|
||||
|
||||
|
||||
def _room_grant_error_response(exc: Optional[Exception] = None, *, _openai_error) -> "web.Response":
|
||||
def _room_grant_error_response(exc: Optional[Exception] = None, *, _openai_error) -> web.Response:
|
||||
"""401 invalid grant, or 403 reauthorization-required for a revoked/superseded grant."""
|
||||
if isinstance(exc, RoomGrantReauthorizationRequired):
|
||||
message, code, status = "Room authorization needs to be renewed.", "room_reauthorization_required", 403
|
||||
@@ -78,7 +78,7 @@ def _http_routes(self) -> list[tuple[str, str, Any]]:
|
||||
("POST", "/v1/room-members/grants/revoke", self._handle_room_member_grant_revoke)]
|
||||
|
||||
|
||||
def _room_grant_token(request: "web.Request") -> str:
|
||||
def _room_grant_token(request: web.Request) -> str:
|
||||
scheme, separator, token = str(request.headers.get("Authorization") or "").partition(" ")
|
||||
return token.strip() if separator and scheme.lower() == "hermesroom" else ""
|
||||
|
||||
@@ -88,7 +88,7 @@ def _room_grant_secret(self) -> bytes:
|
||||
return gateway_room_grant_secret()
|
||||
|
||||
|
||||
def _decode_request_grant(self, request: "web.Request", *, permission: str) -> dict[str, Any]:
|
||||
def _decode_request_grant(self, request: web.Request, *, permission: str) -> dict[str, Any]:
|
||||
"""Signature/scope/horizon check only (no revocation lookup)."""
|
||||
from gateway.hosted_room_peer import decode_room_grant
|
||||
token = self._room_grant_token(request)
|
||||
@@ -97,7 +97,7 @@ def _decode_request_grant(self, request: "web.Request", *, permission: str) -> d
|
||||
return decode_room_grant(self._room_grant_secret(), token, permission=permission)
|
||||
|
||||
|
||||
def _room_grant_claims(self, request: "web.Request", *, permission: str) -> dict[str, Any]:
|
||||
def _room_grant_claims(self, request: web.Request, *, permission: str) -> dict[str, Any]:
|
||||
claims = _decode_request_grant(self, request, permission=permission)
|
||||
from gateway import hosted_rooms
|
||||
db_path = hosted_rooms.default_db_path()
|
||||
@@ -109,7 +109,7 @@ def _room_grant_claims(self, request: "web.Request", *, permission: str) -> dict
|
||||
|
||||
|
||||
async def _handle_room_member_invitation(
|
||||
self, request: "web.Request", *, _openai_error, _api_request_profile) -> "web.Response":
|
||||
self, request: web.Request, *, _openai_error, _api_request_profile) -> web.Response:
|
||||
"""Mint a short-lived room/profile grant for a trusted home gateway."""
|
||||
auth_err = self._check_auth(request)
|
||||
if auth_err:
|
||||
@@ -153,7 +153,7 @@ async def _handle_room_member_invitation(
|
||||
|
||||
|
||||
async def _handle_room_member_capabilities(
|
||||
self, request: "web.Request", *, _openai_error, _api_request_profile) -> "web.Response":
|
||||
self, request: web.Request, *, _openai_error, _api_request_profile) -> web.Response:
|
||||
"""Verify a scoped grant and return this target's live room catalog."""
|
||||
try:
|
||||
claims = self._room_grant_claims(request, permission="status")
|
||||
@@ -167,7 +167,7 @@ async def _handle_room_member_capabilities(
|
||||
|
||||
|
||||
async def _handle_room_member_grant_refresh(
|
||||
self, request: "web.Request", *, _openai_error, _api_request_profile) -> "web.Response":
|
||||
self, request: web.Request, *, _openai_error, _api_request_profile) -> web.Response:
|
||||
"""Refresh dispatch access without a Desktop or broad gateway key."""
|
||||
body, error = await self._read_json_body(request)
|
||||
if error:
|
||||
@@ -206,7 +206,7 @@ async def _handle_room_member_grant_refresh(
|
||||
|
||||
|
||||
async def _handle_room_member_grant_revoke(
|
||||
self, request: "web.Request", *, _openai_error, _api_request_profile) -> "web.Response":
|
||||
self, request: web.Request, *, _openai_error, _api_request_profile) -> web.Response:
|
||||
"""Revoke exactly the scoped grant authenticating this request."""
|
||||
body, error = await self._read_json_body(request)
|
||||
if error:
|
||||
|
||||
@@ -157,7 +157,7 @@ class _RunStream:
|
||||
self.subscribers.discard(queue)
|
||||
|
||||
|
||||
def _remember_room_retention(request: "web.Request", claims: dict[str, Any]) -> None:
|
||||
def _remember_room_retention(request: web.Request, claims: dict[str, Any]) -> None:
|
||||
value = float(claims.get("status_expires_at") or claims.get("expires_at") or 0)
|
||||
try:
|
||||
request[_ROOM_RETENTION_REQUEST_KEY] = value
|
||||
@@ -165,7 +165,7 @@ def _remember_room_retention(request: "web.Request", claims: dict[str, Any]) ->
|
||||
request._hermes_room_run_retention_until = value
|
||||
|
||||
|
||||
def _room_retention_until(request: "web.Request") -> float:
|
||||
def _room_retention_until(request: web.Request) -> float:
|
||||
try:
|
||||
value = request.get(_ROOM_RETENTION_REQUEST_KEY, 0)
|
||||
except AttributeError:
|
||||
@@ -200,11 +200,11 @@ def terminal_run_status(result: dict[str, Any]) -> tuple[str, dict[str, Any]]:
|
||||
return status, fields
|
||||
|
||||
|
||||
def _run_not_found(_openai_error, run_id: str) -> "web.Response":
|
||||
def _run_not_found(_openai_error, run_id: str) -> web.Response:
|
||||
return _json_error(_openai_error, f"Run not found: {run_id}", code="run_not_found", status=404)
|
||||
|
||||
|
||||
def _uses_room_run_auth(self, request: "web.Request") -> bool:
|
||||
def _uses_room_run_auth(self, request: web.Request) -> bool:
|
||||
return request.path.endswith("/v1/runs") and bool(self._room_grant_token(request))
|
||||
|
||||
|
||||
@@ -312,7 +312,7 @@ def _mark_shutdown_requested(self) -> int:
|
||||
return marked
|
||||
|
||||
|
||||
def _make_run_event_callback(self, run_id: str, loop: "asyncio.AbstractEventLoop", *, _api_server):
|
||||
def _make_run_event_callback(self, run_id: str, loop: asyncio.AbstractEventLoop, *, _api_server):
|
||||
"""Return a callback that pushes structured events to the run SSE queue."""
|
||||
redact_sensitive_text = _api_server.redact_sensitive_text
|
||||
|
||||
@@ -349,7 +349,7 @@ def _make_run_event_callback(self, run_id: str, loop: "asyncio.AbstractEventLoop
|
||||
return _callback
|
||||
|
||||
|
||||
def _room_permission_for(request: "web.Request") -> str:
|
||||
def _room_permission_for(request: web.Request) -> str:
|
||||
if request.path.endswith("/stop"):
|
||||
return "stop"
|
||||
if request.path.endswith("/approval"):
|
||||
@@ -357,7 +357,7 @@ def _room_permission_for(request: "web.Request") -> str:
|
||||
return "status" if request.method == "GET" else "dispatch"
|
||||
|
||||
|
||||
def _run_idempotency_scope(self, request: "web.Request", *, _api_server) -> str:
|
||||
def _run_idempotency_scope(self, request: web.Request, *, _api_server) -> str:
|
||||
"""Opaque auth/profile namespace; never persist bearer credentials."""
|
||||
if self._room_grant_token(request):
|
||||
claims = self._room_grant_claims(request, permission=_room_permission_for(request))
|
||||
@@ -371,7 +371,7 @@ def _run_idempotency_scope(self, request: "web.Request", *, _api_server) -> str:
|
||||
return hashlib.sha256("\0".join(map(str, parts)).encode()).hexdigest()
|
||||
|
||||
|
||||
def _check_run_auth(self, request: "web.Request", *, permission: str, _api_server) -> "web.Response | None":
|
||||
def _check_run_auth(self, request: web.Request, *, permission: str, _api_server) -> web.Response | None:
|
||||
if not self._room_grant_token(request):
|
||||
return self._check_auth(request)
|
||||
try:
|
||||
@@ -392,7 +392,7 @@ def _owner_alive(owner_pid: int, owner_started: int) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _durable_run_status(self, request: "web.Request", run_id: str) -> dict[str, Any] | None:
|
||||
def _durable_run_status(self, request: web.Request, run_id: str) -> dict[str, Any] | None:
|
||||
"""Hydrate a scoped run status and fail stale owners closed."""
|
||||
status = self._run_statuses.get(run_id)
|
||||
if status is not None:
|
||||
@@ -420,7 +420,7 @@ def _durable_run_status(self, request: "web.Request", run_id: str) -> dict[str,
|
||||
|
||||
def _resolve_conversation_history(
|
||||
self, body: dict, raw_input: Any, *, _openai_error
|
||||
) -> "tuple[list[dict[str, str]], Any, Any, web.Response | None]":
|
||||
) -> tuple[list[dict[str, str]], Any, Any, web.Response | None]:
|
||||
"""Return ``(history, instructions, stored_session_id, error)``; precedence:
|
||||
``conversation_history`` > ``previous_response_id`` chain > all-but-last ``input`` messages."""
|
||||
instructions = body.get("instructions")
|
||||
@@ -458,7 +458,7 @@ def _resolve_conversation_history(
|
||||
return conversation_history, instructions, stored_session_id, None
|
||||
|
||||
|
||||
def _accepted_response(run_id: str, status: str, gateway_session_key, *, replayed: bool) -> "web.Response":
|
||||
def _accepted_response(run_id: str, status: str, gateway_session_key, *, replayed: bool) -> web.Response:
|
||||
"""202 admission response; replays are flagged via ``Idempotency-Replayed``."""
|
||||
headers = {"Idempotency-Replayed": "true"} if replayed else {}
|
||||
if gateway_session_key:
|
||||
@@ -467,7 +467,7 @@ def _accepted_response(run_id: str, status: str, gateway_session_key, *, replaye
|
||||
{"run_id": run_id, "status": status, "replayed": replayed}, status=202, headers=headers)
|
||||
|
||||
|
||||
def _replay_or_conflict(self, request, outcome, record, gateway_session_key, _openai_error) -> "web.Response":
|
||||
def _replay_or_conflict(self, request, outcome, record, gateway_session_key, _openai_error) -> web.Response:
|
||||
"""409 for a fingerprint conflict, else a 202 replay of the already-admitted run."""
|
||||
if outcome == "conflict":
|
||||
return _json_error(
|
||||
@@ -617,7 +617,7 @@ async def run_internal_session_turn(self, *, session_id: str, text: str, profile
|
||||
_api_server._api_request_profile.reset(token)
|
||||
|
||||
|
||||
async def _handle_runs(self, request: "web.Request", *, _api_server) -> "web.Response":
|
||||
async def _handle_runs(self, request: web.Request, *, _api_server) -> web.Response:
|
||||
"""POST /v1/runs — start an agent run, return run_id immediately."""
|
||||
_openai_error = _api_server._openai_error
|
||||
# Long-term memory scope header (see chat_completions for details).
|
||||
@@ -1020,7 +1020,7 @@ def _release_run_owner_if_forgotten(self, run_id: str) -> None:
|
||||
self._run_owners.pop(run_id, None)
|
||||
|
||||
|
||||
def _request_owns_run(self, request: "web.Request", run_id: str) -> bool:
|
||||
def _request_owns_run(self, request: web.Request, run_id: str) -> bool:
|
||||
scope = self._run_idempotency_scope(request)
|
||||
owner = self._run_owners.get(run_id)
|
||||
if owner is not None:
|
||||
@@ -1054,14 +1054,14 @@ def _load_owned_run(self, request, *, _api_server, permission: Optional[str], ac
|
||||
return run_id, status, agent, task, None
|
||||
|
||||
|
||||
async def _handle_get_run(self, request: "web.Request", *, _api_server) -> "web.Response":
|
||||
async def _handle_get_run(self, request: web.Request, *, _api_server) -> web.Response:
|
||||
"""GET /v1/runs/{run_id} — return pollable run status for external UIs."""
|
||||
_, status, _, _, err = _load_owned_run(
|
||||
self, request, _api_server=_api_server, permission="status", active_fallback=True)
|
||||
return err or web.json_response(status)
|
||||
|
||||
|
||||
async def _handle_run_events(self, request: "web.Request", *, _api_server) -> "web.StreamResponse":
|
||||
async def _handle_run_events(self, request: web.Request, *, _api_server) -> web.StreamResponse:
|
||||
"""GET /v1/runs/{run_id}/events — stream structured agent lifecycle events."""
|
||||
auth_err = self._check_auth(request)
|
||||
if auth_err:
|
||||
@@ -1165,7 +1165,7 @@ def _mark_run_event(self, run_id: str, name: str, **fields: Any) -> None:
|
||||
_APPROVAL_CHOICE_ALIASES = {"approve": "once", "approved": "once", "allow": "once"}
|
||||
|
||||
|
||||
async def _handle_run_approval(self, request: "web.Request", *, _api_server) -> "web.Response":
|
||||
async def _handle_run_approval(self, request: web.Request, *, _api_server) -> web.Response:
|
||||
"""POST /v1/runs/{run_id}/approval — resolve a pending run approval."""
|
||||
_openai_error = _api_server._openai_error
|
||||
run_id, _, _, _, err = _load_owned_run(
|
||||
@@ -1216,7 +1216,7 @@ async def _handle_run_approval(self, request: "web.Request", *, _api_server) ->
|
||||
"resolved": resolved})
|
||||
|
||||
|
||||
async def _handle_steer_run(self, request: "web.Request", *, _api_server) -> "web.Response":
|
||||
async def _handle_steer_run(self, request: web.Request, *, _api_server) -> web.Response:
|
||||
"""POST /v1/runs/{run_id}/steer — inject guidance into a running agent."""
|
||||
_openai_error = _api_server._openai_error
|
||||
run_id, status, agent, _, err = _load_owned_run(
|
||||
@@ -1250,7 +1250,7 @@ async def _handle_steer_run(self, request: "web.Request", *, _api_server) -> "we
|
||||
return web.json_response({"object": "hermes.run.steer", "run_id": run_id, "accepted": True})
|
||||
|
||||
|
||||
async def _handle_stop_run(self, request: "web.Request", *, _api_server) -> "web.Response":
|
||||
async def _handle_stop_run(self, request: web.Request, *, _api_server) -> web.Response:
|
||||
"""POST /v1/runs/{run_id}/stop — interrupt a running agent."""
|
||||
_openai_error = _api_server._openai_error
|
||||
run_id, status, agent, task, err = _load_owned_run(
|
||||
|
||||
+36
-36
@@ -28,7 +28,7 @@ from agent.proxy_bypass import first_proxy_env_value, should_bypass_proxy as _sh
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _consume_detached_handler_exception(task: "asyncio.Task") -> None:
|
||||
def _consume_detached_handler_exception(task: asyncio.Task) -> None:
|
||||
"""Done-callback for a detached fatal-error handler task (carrier cancelled in
|
||||
``_notify_fatal_error``): retrieve its exception so asyncio never logs "never retrieved"."""
|
||||
if task.cancelled():
|
||||
@@ -53,7 +53,7 @@ _TELEGRAM_VOICE_EXTS = frozenset({'.ogg', '.opus'})
|
||||
|
||||
|
||||
def transcode_to_ogg_opus(path: str, *, bitrate: str = "32k", timeout: int = 60,
|
||||
output_path: "str | None" = None) -> "str | None":
|
||||
output_path: str | None = None) -> str | None:
|
||||
"""Best-effort ffmpeg transcode to Ogg/Opus (voip-tuned) for native voice bubbles: the written
|
||||
``.ogg`` path (a NEW temp file unless ``output_path`` is given; caller cleans up), or None when
|
||||
ffmpeg is missing/fails. ``output_path`` may equal ``path`` (in-place container repair) — the
|
||||
@@ -182,7 +182,7 @@ def _reply_anchor_for_event(event) -> str | None:
|
||||
_MEDIA_KIND_KEYS = frozenset({"audio", "video", "file", "image"})
|
||||
|
||||
|
||||
def _media_failure_text(kind: str, file_name: "str | None" = None) -> str:
|
||||
def _media_failure_text(kind: str, file_name: str | None = None) -> str:
|
||||
"""User-facing "couldn't deliver" notice; ``file_name`` is the only name ever shown."""
|
||||
kind_label = t(f"gateway.notify.media_kind.{kind}") if kind in _MEDIA_KIND_KEYS else kind
|
||||
if file_name:
|
||||
@@ -273,7 +273,7 @@ def is_network_accessible(host: str) -> bool:
|
||||
# goes out on a stale answer fails and is retried, which is the same outcome as any transient proxy error.
|
||||
# No lock: a race costs one extra fork and both answers are equally current.
|
||||
_MACOS_PROXY_TTL_SECONDS = 60.0
|
||||
_macos_proxy_cache: "tuple[float, str | None] | None" = None
|
||||
_macos_proxy_cache: tuple[float, str | None] | None = None
|
||||
|
||||
|
||||
def _detect_macos_system_proxy() -> str | None:
|
||||
@@ -1362,7 +1362,7 @@ MEDIA_EXTENSIONLESS_TAG_RE = re.compile(
|
||||
re.IGNORECASE)
|
||||
|
||||
|
||||
def _match_extensionless_path(scan_text: str, match: "re.Match") -> Optional[tuple[str, int]]:
|
||||
def _match_extensionless_path(scan_text: str, match: re.Match) -> Optional[tuple[str, int]]:
|
||||
"""Extensionless MEDIA tag match -> validated on-disk ``(safe_path, end_offset)`` or None: the
|
||||
captured path first, then extended across single spaces (max 8 tokens, never past a newline
|
||||
or the next ``MEDIA:``).
|
||||
@@ -1634,7 +1634,7 @@ class TextDebounceState:
|
||||
first_ts: float
|
||||
last_ts: float
|
||||
|
||||
def cancel_timer(self, *, unless: "asyncio.Task | None" = None) -> None:
|
||||
def cancel_timer(self, *, unless: asyncio.Task | None = None) -> None:
|
||||
"""Cancel the pending flush timer (if live and not ``unless``)."""
|
||||
if self.task is not None and self.task is not unless and not self.task.done():
|
||||
self.task.cancel()
|
||||
@@ -1662,7 +1662,7 @@ _PLAINTEXT_GATEWAY_RESTART_PATTERNS: tuple[re.Pattern[str], ...] = (
|
||||
re.compile(r"^(?:please\s+)?restart\s+hermes[.!?\s]*$", re.IGNORECASE))
|
||||
|
||||
|
||||
def coerce_plaintext_gateway_command(event: "MessageEvent") -> None:
|
||||
def coerce_plaintext_gateway_command(event: MessageEvent) -> None:
|
||||
"""Rewrite a tiny set of DM plaintext admin phrases (exact matches only) into slash commands so
|
||||
``restart gateway`` never reaches the LLM/tool path (a self-restart from inside the running
|
||||
agent leaves the gateway stuck in ``draining`` waiting on that agent)."""
|
||||
@@ -1976,7 +1976,7 @@ class BasePlatformAdapter(ABC):
|
||||
self._running, self._fatal_error_retryable = False, True
|
||||
self._fatal_error_code: Optional[str] = None
|
||||
self._fatal_error_message: Optional[str] = None
|
||||
self._fatal_error_handler: Optional[Callable[["BasePlatformAdapter"], Awaitable[None] | None]] = None
|
||||
self._fatal_error_handler: Optional[Callable[[BasePlatformAdapter], Awaitable[None] | None]] = None
|
||||
# Strong refs to shielded fatal-error handler tasks that outlive their carrier task
|
||||
# (asyncio keeps only weak refs); without them the loop can GC the detached handler
|
||||
# mid-flight — the "handler killed mid-flight" class (#81335).
|
||||
@@ -2135,7 +2135,7 @@ class BasePlatformAdapter(ABC):
|
||||
return t("gateway.progress.tool_preview", emoji=emoji, tool=tool, preview=self.format_tool_preview(prepared))
|
||||
|
||||
|
||||
def format_tool_preview(self, preview: "ToolPreview") -> str:
|
||||
def format_tool_preview(self, preview: ToolPreview) -> str:
|
||||
"""Platform-native formatting of a compact tool preview; rich-text adapters may use
|
||||
the preview's metadata (e.g. a URL shortened for display)."""
|
||||
return preview.text
|
||||
@@ -2155,7 +2155,7 @@ class BasePlatformAdapter(ABC):
|
||||
return chat_id in self._auto_tts_enabled_chats or (
|
||||
chat_id not in self._auto_tts_disabled_chats and bool(self._auto_tts_default))
|
||||
|
||||
def set_fatal_error_handler(self, handler: Callable[["BasePlatformAdapter"], Awaitable[None] | None]) -> None:
|
||||
def set_fatal_error_handler(self, handler: Callable[[BasePlatformAdapter], Awaitable[None] | None]) -> None:
|
||||
self._fatal_error_handler = handler
|
||||
|
||||
#: Published when an adapter is installed and running but its receive
|
||||
@@ -2451,7 +2451,7 @@ class BasePlatformAdapter(ABC):
|
||||
owner = getattr(self, "_owner_profile", None)
|
||||
return owner if isinstance(owner, str) and owner.strip() else None
|
||||
|
||||
def _canonicalize(self, source: Optional["SessionSource"]):
|
||||
def _canonicalize(self, source: Optional[SessionSource]):
|
||||
"""Pin the source's :class:`RoutingIdentity` before anything derives a key from it. Every
|
||||
ingress path (fresh event, batch merge, busy path, control command, callback) calls this
|
||||
FIRST. Returns the identity, or ``None`` when it cannot be resolved (a rejected route under
|
||||
@@ -2475,7 +2475,7 @@ class BasePlatformAdapter(ABC):
|
||||
logger.debug("[%s] identity resolution failed; using legacy key readers", self.name, exc_info=True)
|
||||
return None
|
||||
|
||||
def _drop_unresolved(self, event: "MessageEvent") -> bool:
|
||||
def _drop_unresolved(self, event: MessageEvent) -> bool:
|
||||
"""True when *event* must be dropped: its identity could not be resolved because the route
|
||||
targets an unserved profile. Same disposition as the runner's ingress gate — one WARNING,
|
||||
never a fall-through to ``agent:main``."""
|
||||
@@ -2554,11 +2554,11 @@ class BasePlatformAdapter(ABC):
|
||||
"text_batch_split_delay_seconds", self._TEXT_BATCH_DEFAULT_SPLIT_DELAY_S,
|
||||
min_value=self._text_batch_delay_seconds, max_value=self._TEXT_BATCH_MAX_SPLIT_DELAY_S)
|
||||
|
||||
def _event_session_key(self, event: "MessageEvent") -> str:
|
||||
def _event_session_key(self, event: MessageEvent) -> str:
|
||||
"""Adapter-level session key for ``event``, profile-namespaced like the agent run."""
|
||||
return self._source_session_key(event.source)
|
||||
|
||||
def _source_session_key(self, source: "SessionSource") -> str:
|
||||
def _source_session_key(self, source: SessionSource) -> str:
|
||||
self._canonicalize(source) # identity FIRST; no key derivation before it
|
||||
extra = self.config.extra
|
||||
return build_session_key(
|
||||
@@ -2566,11 +2566,11 @@ class BasePlatformAdapter(ABC):
|
||||
thread_sessions_per_user=extra.get("thread_sessions_per_user", False),
|
||||
profile=self._session_key_profile(source))
|
||||
|
||||
def _text_batch_key(self, event: "MessageEvent") -> str:
|
||||
def _text_batch_key(self, event: MessageEvent) -> str:
|
||||
"""Session-scoped key for text batching (subclasses may override)."""
|
||||
return self._event_session_key(event)
|
||||
|
||||
def _enqueue_text_event(self, event: "MessageEvent") -> None:
|
||||
def _enqueue_text_event(self, event: MessageEvent) -> None:
|
||||
"""Buffer a text event (merging into a pending one) and restart the flush timer."""
|
||||
if self._drop_unresolved(event):
|
||||
return
|
||||
@@ -2591,16 +2591,16 @@ class BasePlatformAdapter(ABC):
|
||||
prior_task.cancel()
|
||||
self._pending_text_batch_tasks[key] = asyncio.create_task(self._flush_text_batch(key))
|
||||
|
||||
def _text_batch_delay_for(self, pending: Optional["MessageEvent"]) -> float:
|
||||
def _text_batch_delay_for(self, pending: Optional[MessageEvent]) -> float:
|
||||
"""Quiet period before ``pending`` is dispatched; near-split chunks wait longer."""
|
||||
last_len = getattr(pending, "_last_chunk_len", 0) if pending is not None else 0
|
||||
return self._text_batch_split_delay_seconds if last_len >= self._SPLIT_THRESHOLD else self._text_batch_delay_seconds
|
||||
|
||||
def _pop_text_batch(self, key: str) -> Optional["MessageEvent"]:
|
||||
def _pop_text_batch(self, key: str) -> Optional[MessageEvent]:
|
||||
"""Remove and return the pending batch for ``key`` (adapters with side tables override)."""
|
||||
return self._pending_text_batches.pop(key, None)
|
||||
|
||||
async def _dispatch_text_batch(self, event: "MessageEvent") -> None:
|
||||
async def _dispatch_text_batch(self, event: MessageEvent) -> None:
|
||||
"""Hand a flushed batch to the pipeline (adapters with per-chat guards override)."""
|
||||
await self.handle_message(event)
|
||||
|
||||
@@ -2909,12 +2909,12 @@ class BasePlatformAdapter(ABC):
|
||||
allow_permanent=allow_permanent, allow_session=allow_session, smart_denied=smart_denied))
|
||||
return await self._send_exec_approval_prompt(prompt)
|
||||
|
||||
async def _send_exec_approval_prompt(self, prompt: "ExecApprovalPrompt") -> SendResult:
|
||||
async def _send_exec_approval_prompt(self, prompt: ExecApprovalPrompt) -> SendResult:
|
||||
"""Render ``prompt`` with the platform's native buttons; the default has none."""
|
||||
return SendResult(success=False, error="Not supported")
|
||||
|
||||
@staticmethod
|
||||
def _format_choice_page(options: list, page: int, per_page: int) -> "tuple[list, dict[str, Any]]":
|
||||
def _format_choice_page(options: list, page: int, per_page: int) -> tuple[list, dict[str, Any]]:
|
||||
"""Shared picker pagination: clamp ``page``, slice ``options`` -> ``(page_options, meta)``
|
||||
with ``page``/``total_pages``/``start``/``end``/``total``/``page_info`` (`` (N–M of T)``,
|
||||
empty for one page)."""
|
||||
@@ -3628,7 +3628,7 @@ class BasePlatformAdapter(ABC):
|
||||
logger.debug("[%s] Failed to resolve media delivery scope", self.name, exc_info=True)
|
||||
return contextlib.nullcontext()
|
||||
|
||||
def _final_delivery_adapter(self, source: Optional[SessionSource]) -> "BasePlatformAdapter":
|
||||
def _final_delivery_adapter(self, source: Optional[SessionSource]) -> BasePlatformAdapter:
|
||||
"""The runner's CURRENT adapter for a new final-response send: a reconnect can swap the
|
||||
registry adapter mid-task; an unsent final response belongs on the replacement transport,
|
||||
while message IDs, edits and deletes stay owned by the old one (nothing is migrated)."""
|
||||
@@ -3647,13 +3647,13 @@ class BasePlatformAdapter(ABC):
|
||||
@records_delivery
|
||||
async def _send_with_retry(
|
||||
self, chat_id: str, content: str, reply_to: Optional[str] = None, metadata: Any = None,
|
||||
max_retries: int = 2, base_delay: float = 2.0) -> "SendResult":
|
||||
max_retries: int = 2, base_delay: float = 2.0) -> SendResult:
|
||||
"""Send with exponential-backoff retry on transient network errors; permanent
|
||||
failures fall back to a plain-text send, exhausted retries notify the user."""
|
||||
async def _send(text: str) -> "SendResult":
|
||||
async def _send(text: str) -> SendResult:
|
||||
return await self.send(chat_id=chat_id, content=text, reply_to=reply_to, metadata=metadata)
|
||||
|
||||
async def _send_again(previous: "SendResult") -> "Optional[SendResult]":
|
||||
async def _send_again(previous: SendResult) -> Optional[SendResult]:
|
||||
"""Retry: the whole payload normally; only the undelivered remainder after a partial split
|
||||
delivery (``raw_response["partial_overflow"]``). ``None`` when the adapter cannot resume — the
|
||||
caller then keeps the partial failure rather than re-sending the already-visible head."""
|
||||
@@ -3767,13 +3767,13 @@ class BasePlatformAdapter(ABC):
|
||||
logger.error("[%s] Fallback send also failed: %s", self.name, fallback_result.error)
|
||||
return fallback_result
|
||||
|
||||
def _send_retry_is_final(self, result: "SendResult") -> bool:
|
||||
def _send_retry_is_final(self, result: SendResult) -> bool:
|
||||
"""True when a failed send must be returned as-is: neither a retry nor the plain-text
|
||||
fallback can fix it (a structured auth/target refusal). Default: never."""
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _is_partial_delivery(result: "SendResult") -> bool:
|
||||
def _is_partial_delivery(result: SendResult) -> bool:
|
||||
"""True when a split payload was PARTLY delivered (``raw_response["partial_overflow"]``, the
|
||||
contract Telegram's send/edit-overflow paths set and the stream consumer reads): the visible
|
||||
head must never be sent again."""
|
||||
@@ -3781,14 +3781,14 @@ class BasePlatformAdapter(ABC):
|
||||
return isinstance(raw, dict) and bool(raw.get("partial_overflow"))
|
||||
|
||||
async def _resume_partial_send(
|
||||
self, chat_id: str, result: "SendResult", *, reply_to: Optional[str], metadata: Any) -> "Optional[SendResult]":
|
||||
self, chat_id: str, result: SendResult, *, reply_to: Optional[str], metadata: Any) -> Optional[SendResult]:
|
||||
"""Deliver only the remainder of a partially delivered split payload. ``None`` (the default) means
|
||||
this adapter cannot resume; ``_send_with_retry`` then returns the partial failure instead of
|
||||
re-sending the whole payload. Override only where non-delivery of the remainder is CERTAIN."""
|
||||
return None
|
||||
|
||||
async def _send_plain_fallback(
|
||||
self, chat_id: str, content: str, *, reply_to: Optional[str], metadata: Any) -> "SendResult":
|
||||
self, chat_id: str, content: str, *, reply_to: Optional[str], metadata: Any) -> SendResult:
|
||||
"""Last-resort send after a non-transient failure; platforms whose markup is not the
|
||||
likely culprit override it (Photon drops rich links instead of adding the banner)."""
|
||||
return await self.send(
|
||||
@@ -4244,7 +4244,7 @@ class BasePlatformAdapter(ABC):
|
||||
|
||||
async def _record_delivery_obligation(
|
||||
self, event: MessageEvent, session_key: str, text_content: str,
|
||||
delivery_adapter: "BasePlatformAdapter", is_ephemeral_response: bool) -> Optional[str]:
|
||||
delivery_adapter: BasePlatformAdapter, is_ephemeral_response: bool) -> Optional[str]:
|
||||
"""Ledger the final response BEFORE the send so a crash before platform ACK redelivers on
|
||||
next boot; best-effort, skips slash-command and ephemeral replies. Returns the obligation id
|
||||
or None."""
|
||||
@@ -4278,7 +4278,7 @@ class BasePlatformAdapter(ABC):
|
||||
|
||||
async def _finalize_delivery_obligation(
|
||||
self, obligation_id: str, result: Any, event: MessageEvent,
|
||||
delivery_adapter: "BasePlatformAdapter") -> None:
|
||||
delivery_adapter: BasePlatformAdapter) -> None:
|
||||
"""Mark the ledger row delivered/failed (best-effort). On ``send_path_degraded`` with a
|
||||
replacement adapter live, trigger another redelivery sweep (the watcher's may have run
|
||||
before this failure landed; atomic claiming keeps it idempotent). On any other rejection arm
|
||||
@@ -4378,7 +4378,7 @@ class BasePlatformAdapter(ABC):
|
||||
async def send_final_ledgered(
|
||||
self, event: MessageEvent, session_key: str, text_content: str, metadata: dict[str, Any], *,
|
||||
reply_to: Optional[str], is_ephemeral_response: bool = False,
|
||||
) -> "tuple[SendResult, BasePlatformAdapter]":
|
||||
) -> tuple[SendResult, BasePlatformAdapter]:
|
||||
"""The delivery-ledger bracket every final text goes through, on the CURRENT transport
|
||||
(a reconnect may have replaced this adapter): record the obligation before the send,
|
||||
send with retry, finalize from the result — so a refused final (flood control, a dead
|
||||
@@ -4440,7 +4440,7 @@ class BasePlatformAdapter(ABC):
|
||||
"[%s] Failed to send error notification to user: %s", self.name, notify_err, exc_info=True)
|
||||
return _thread_metadata
|
||||
|
||||
async def _deliver_attachments(self, event: MessageEvent, extracted: "_ExtractedResponse",
|
||||
async def _deliver_attachments(self, event: MessageEvent, extracted: _ExtractedResponse,
|
||||
metadata: dict[str, Any], *, anything_sent: bool,
|
||||
record_delivery: Callable) -> None:
|
||||
"""Send extracted image URLs, MEDIA files and bare local files (human-paced),
|
||||
@@ -4474,7 +4474,7 @@ class BasePlatformAdapter(ABC):
|
||||
return asyncio.create_task(self._keep_typing(event.source.chat_id, **kwargs))
|
||||
|
||||
async def _extract_response_content(self, response: str, event: MessageEvent, session_key: str,
|
||||
*, is_ephemeral_response: bool) -> "_ExtractedResponse":
|
||||
*, is_ephemeral_response: bool) -> _ExtractedResponse:
|
||||
"""Split a handler response into deliverable text + attachments. Order matters: MEDIA tags →
|
||||
image URLs → residual directives → bare local paths (skipped for ephemeral notices so config
|
||||
paths stay text; unknown-extension MEDIA tags survive for the bare-path detector). History
|
||||
@@ -4859,7 +4859,7 @@ class BasePlatformAdapter(ABC):
|
||||
"""Get information about a chat/channel; dict with at least ``name``
|
||||
and ``type`` ("dm", "group", "channel")."""
|
||||
|
||||
def toolsets_for_source(self, source: "SessionSource") -> Optional[list[str]]:
|
||||
def toolsets_for_source(self, source: SessionSource) -> Optional[list[str]]:
|
||||
"""Per-source toolset override REPLACING ``platform_toolsets.<platform>``, or None
|
||||
(default); validated via ``_get_platform_tools`` (webhook adapter pins per-route)."""
|
||||
return None
|
||||
@@ -4871,7 +4871,7 @@ class BasePlatformAdapter(ABC):
|
||||
|
||||
@staticmethod
|
||||
def truncate_message(content: str, max_length: int = 4096,
|
||||
len_fn: Optional["Callable[[str], int]"] = None) -> list[str]:
|
||||
len_fn: Optional[Callable[[str], int]] = None) -> list[str]:
|
||||
"""Split a long message into chunks preserving code blocks: a split inside a fence closes it
|
||||
at the chunk end and reopens it (same language tag) in the next; multi-chunk output gets
|
||||
``(1/3)`` indicators. ``len_fn`` overrides ``len`` (``utf16_len`` for Telegram)."""
|
||||
|
||||
@@ -107,7 +107,7 @@ class MessageEvent:
|
||||
# Run-owned final presentation snapshot; never deserialized from ingress metadata.
|
||||
_notification_reply_muted: Optional[bool] = field(default=None, init=False, repr=False, compare=False)
|
||||
|
||||
def absorb_reply_expected(self, other: "MessageEvent") -> None:
|
||||
def absorb_reply_expected(self, other: MessageEvent) -> None:
|
||||
"""One turn now answers *other* too: an addressed message wins, then an unknown one."""
|
||||
if self.reply_expected is not True and other.reply_expected is not False:
|
||||
self.reply_expected = other.reply_expected
|
||||
|
||||
@@ -60,7 +60,7 @@ class MessageDeduplicator:
|
||||
def clear(self):
|
||||
self._seen.clear()
|
||||
|
||||
def absorb(self, other: "MessageDeduplicator") -> None:
|
||||
def absorb(self, other: MessageDeduplicator) -> None:
|
||||
"""Adopt *other*'s still-live IDs (at their original seen times) into this cache."""
|
||||
cutoff = time.time() - self._ttl
|
||||
self._seen.update({k: v for k, v in other._seen.items() if v > cutoff and k not in self._seen})
|
||||
@@ -135,7 +135,7 @@ _HTTP_TARGET_RE = re.compile(r"https?://", re.IGNORECASE)
|
||||
_NEWLINE_SQUEEZE_RE = re.compile(r"\n{3,}")
|
||||
|
||||
|
||||
def _keep_link_target(match: "re.Match[str]") -> str:
|
||||
def _keep_link_target(match: re.Match[str]) -> str:
|
||||
r"""``[label](https://url)`` -> ``label\nurl``.
|
||||
|
||||
The bare URL is the only thing a platform with its own data detection
|
||||
@@ -345,8 +345,8 @@ def convert_table_to_bullets(text: str) -> str:
|
||||
|
||||
|
||||
def compile_mention_patterns(raw, *, log_prefix: str, platform_label: str | None = None,
|
||||
display_label: str | None = None, defaults: 'list[str] | None' = None,
|
||||
logger_: 'logging.Logger | None' = None) -> 'list[re.Pattern]':
|
||||
display_label: str | None = None, defaults: list[str] | None = None,
|
||||
logger_: logging.Logger | None = None) -> list[re.Pattern]:
|
||||
"""Compile regex wake-word/mention patterns from config or env values.
|
||||
|
||||
* **Config-style** (dingtalk, telegram): pass ``platform_label``. ``raw`` must be a
|
||||
@@ -459,11 +459,11 @@ def split_at_paragraph_boundary(text, max_chars, len_fn=None):
|
||||
return text[:cut], text[cut:]
|
||||
|
||||
|
||||
def split_markdown_atoms(text: str) -> "list[str]":
|
||||
def split_markdown_atoms(text: str) -> list[str]:
|
||||
"""Split markdown into indivisible atoms: fenced code blocks, tables
|
||||
(consecutive ``|...|`` lines) and paragraphs. Blank lines belong to no atom."""
|
||||
atoms: "list[str]" = []
|
||||
current_lines: "list[str]" = []
|
||||
atoms: list[str] = []
|
||||
current_lines: list[str] = []
|
||||
in_fence = False
|
||||
|
||||
def _flush_current() -> None:
|
||||
@@ -505,10 +505,10 @@ def infer_block_separator(prev_chunk: str, next_chunk: str) -> str:
|
||||
return '\n\n'
|
||||
|
||||
|
||||
def merge_streaming_fences(chunks: "list[str]") -> "list[str]":
|
||||
def merge_streaming_fences(chunks: list[str]) -> list[str]:
|
||||
"""Rejoin chunks truncated mid-fence: while chunk *i* has an unclosed fence
|
||||
and a successor exists, merge the successor in via :func:`infer_block_separator`."""
|
||||
result: "list[str]" = []
|
||||
result: list[str] = []
|
||||
i = 0
|
||||
while i < len(chunks):
|
||||
current = chunks[i]
|
||||
@@ -520,12 +520,12 @@ def merge_streaming_fences(chunks: "list[str]") -> "list[str]":
|
||||
return result
|
||||
|
||||
|
||||
def balance_fences_across_chunks(chunks: "list[str]") -> "list[str]":
|
||||
def balance_fences_across_chunks(chunks: list[str]) -> list[str]:
|
||||
"""Close orphaned ``` fences at each chunk boundary and reopen (with the
|
||||
original language tag) on the next, so every chunk is fence-balanced alone."""
|
||||
if len(chunks) <= 1:
|
||||
return chunks
|
||||
out: "list[str]" = []
|
||||
out: list[str] = []
|
||||
carry_lang = None
|
||||
for chunk in chunks:
|
||||
body = f"```{carry_lang}\n{chunk}" if carry_lang is not None else chunk
|
||||
@@ -535,7 +535,7 @@ def balance_fences_across_chunks(chunks: "list[str]") -> "list[str]":
|
||||
return out
|
||||
|
||||
|
||||
def fence_state_after(text: str, in_code: bool = False, lang: str = "") -> "tuple[bool, str]":
|
||||
def fence_state_after(text: str, in_code: bool = False, lang: str = "") -> tuple[bool, str]:
|
||||
"""Walk ``text`` line by line toggling on ``` lines; return the final (in_code, lang)."""
|
||||
for line in text.split("\n"):
|
||||
stripped = line.strip()
|
||||
@@ -549,7 +549,7 @@ def greedy_pack_blocks(blocks, max_length, len_fn=None, sep="\n\n", overflow=Non
|
||||
"""Greedily pack *blocks* (joined with *sep*) into chunks of at most *max_length*; an
|
||||
oversized block goes through *overflow(block)* (-> list of chunks) if given, else as-is."""
|
||||
_len = len_fn or len
|
||||
packed: "list[str]" = []
|
||||
packed: list[str] = []
|
||||
current = ""
|
||||
for block in blocks:
|
||||
candidate = block if not current else f"{current}{sep}{block}"
|
||||
@@ -596,9 +596,9 @@ def _chunk_markdown_paragraphs(text, max_chars, len_fn=None):
|
||||
if _len(text) <= max_chars:
|
||||
return [text]
|
||||
# Phase 2: greedy merge; oversized fence/table atoms stay indivisible.
|
||||
chunks: "list[str]" = []
|
||||
indivisible_set: "set[int]" = set()
|
||||
current_parts: "list[str]" = []
|
||||
chunks: list[str] = []
|
||||
indivisible_set: set[int] = set()
|
||||
current_parts: list[str] = []
|
||||
current_len = 0
|
||||
for atom in split_markdown_atoms(text):
|
||||
atom_len = _len(atom)
|
||||
@@ -616,7 +616,7 @@ def _chunk_markdown_paragraphs(text, max_chars, len_fn=None):
|
||||
if current_parts:
|
||||
chunks.append('\n\n'.join(current_parts))
|
||||
# Phase 3: split still-oversized divisible chunks at paragraph boundaries.
|
||||
result: "list[str]" = []
|
||||
result: list[str] = []
|
||||
for idx, chunk in enumerate(chunks):
|
||||
if _len(chunk) <= max_chars or idx in indivisible_set or text_has_unclosed_fence(chunk):
|
||||
result.append(chunk)
|
||||
@@ -631,7 +631,7 @@ def _chunk_markdown_paragraphs(text, max_chars, len_fn=None):
|
||||
if remaining:
|
||||
result.append(remaining)
|
||||
# Phase 4: merge small chunks with neighbours.
|
||||
merged: "list[str]" = result[:1]
|
||||
merged: list[str] = result[:1]
|
||||
for chunk in result[1:]:
|
||||
combined = merged[-1] + '\n\n' + chunk
|
||||
if _len(combined) <= max_chars:
|
||||
@@ -647,7 +647,7 @@ def _chunk_newline_preferred(text, limit, len_fn):
|
||||
return [text]
|
||||
# Reserve headroom for fence markers a balancing pass may add.
|
||||
split_limit = max(limit - 16, limit // 2, 1) if "```" in text else limit
|
||||
chunks: "list[str]" = []
|
||||
chunks: list[str] = []
|
||||
remaining = text
|
||||
while len_fn(remaining) > split_limit:
|
||||
budget = _cp_budget(remaining, split_limit, len_fn)
|
||||
|
||||
@@ -167,14 +167,14 @@ class MSGraphWebhookAdapter(BasePlatformAdapter):
|
||||
async def get_chat_info(self, chat_id: str) -> dict[str, Any]:
|
||||
return {"name": chat_id, "type": "webhook"}
|
||||
|
||||
async def _handle_health(self, request: "web.Request") -> "web.Response":
|
||||
async def _handle_health(self, request: web.Request) -> web.Response:
|
||||
if not self._source_ip_allowed(request):
|
||||
return web.Response(status=403)
|
||||
return web.json_response({
|
||||
"status": "ok", "platform": self.platform.value, "webhook_path": self._webhook_path,
|
||||
"accepted": self._accepted_count, "duplicates": self._duplicate_count})
|
||||
|
||||
async def _handle_validation(self, request: "web.Request") -> "web.Response":
|
||||
async def _handle_validation(self, request: web.Request) -> web.Response:
|
||||
"""Graph subscription validation handshake: echo ``validationToken`` verbatim as text/plain. Bare GETs
|
||||
are rejected so the endpoint can't be enumerated."""
|
||||
if not self._source_ip_allowed(request):
|
||||
@@ -203,7 +203,7 @@ class MSGraphWebhookAdapter(BasePlatformAdapter):
|
||||
self._schedule_notification(notification, self._build_message_event(notification, receipt_key))
|
||||
return "accepted"
|
||||
|
||||
async def _handle_notification(self, request: "web.Request") -> "web.Response":
|
||||
async def _handle_notification(self, request: web.Request) -> web.Response:
|
||||
if not self._source_ip_allowed(request):
|
||||
return web.Response(status=403)
|
||||
# Graph never sends validationToken on POST, but tolerate clients replaying it in-band.
|
||||
@@ -224,7 +224,7 @@ class MSGraphWebhookAdapter(BasePlatformAdapter):
|
||||
return web.Response(status=403)
|
||||
return web.Response(status=400)
|
||||
|
||||
async def _read_notifications(self, request: "web.Request") -> tuple[int, list]:
|
||||
async def _read_notifications(self, request: web.Request) -> tuple[int, list]:
|
||||
"""Read and validate the POST body; returns (error_status, []) or (0, notifications)."""
|
||||
try:
|
||||
content_length = request.content_length
|
||||
@@ -245,7 +245,7 @@ class MSGraphWebhookAdapter(BasePlatformAdapter):
|
||||
notifications = body.get("value") if isinstance(body, dict) else None
|
||||
return (0, notifications) if isinstance(notifications, list) else (400, [])
|
||||
|
||||
def _source_ip_allowed(self, request: "web.Request") -> bool:
|
||||
def _source_ip_allowed(self, request: web.Request) -> bool:
|
||||
"""Loopback-only binds may omit ``allowed_source_cidrs`` (local proxies, dev tunnels);
|
||||
network-accessible binds fail closed without one."""
|
||||
if self._source_allowlist_required_but_missing():
|
||||
|
||||
@@ -1285,7 +1285,7 @@ class QQAdapter(OwnAccessPolicyMixin, BasePlatformAdapter):
|
||||
|
||||
# ── Outbound messaging — REST API ──
|
||||
|
||||
def _require_http_client(self) -> "httpx.AsyncClient":
|
||||
def _require_http_client(self) -> httpx.AsyncClient:
|
||||
if not self._http_client:
|
||||
raise RuntimeError("HTTP client not initialized — not connected?")
|
||||
return self._http_client
|
||||
|
||||
@@ -55,9 +55,9 @@ def shared_listener_base(runner: Any) -> Optional[str]:
|
||||
|
||||
|
||||
async def bind_listener(
|
||||
adapter: Any, app: "web.Application", host: Optional[str], port: int, ingress_path: str, *,
|
||||
adapter: Any, app: web.Application, host: Optional[str], port: int, ingress_path: str, *,
|
||||
reuse_address: Optional[bool] = None, access_log: Any = ...,
|
||||
) -> Optional["web.AppRunner"]:
|
||||
) -> Optional[web.AppRunner]:
|
||||
"""Start *app* on ``host:port`` and return its ``AppRunner`` — or, in shared-listener mode,
|
||||
publish *app* for ``/p/<profile>/`` forwarding and return None (nothing bound). ``ingress_path``
|
||||
is the adapter's primary callback path, used for the log line and runtime status."""
|
||||
@@ -77,7 +77,7 @@ async def bind_listener(
|
||||
return runner
|
||||
|
||||
|
||||
def publish_shared_ingress(adapter: Any, app: "web.Application", ingress_path: str) -> None:
|
||||
def publish_shared_ingress(adapter: Any, app: web.Application, ingress_path: str) -> None:
|
||||
"""Freeze *app* and expose it to the default listener; records the ``/p/<profile>/`` URL."""
|
||||
profile = shared_ingress_profile(adapter)
|
||||
app.freeze()
|
||||
@@ -104,7 +104,7 @@ def publish_shared_ingress(adapter: Any, app: "web.Application", ingress_path: s
|
||||
write("shared_ingress", ingress_url=adapter._shared_ingress_url)
|
||||
|
||||
|
||||
def shared_ingress_apps(runner: Any, profile: Optional[str]) -> list[tuple[Any, "web.Application"]]:
|
||||
def shared_ingress_apps(runner: Any, profile: Optional[str]) -> list[tuple[Any, web.Application]]:
|
||||
"""``(adapter, app)`` for every shared-listener adapter of a NAMED served profile. ``default`` and
|
||||
unknown profiles yield nothing: the default's port-binders own their own ports, and a profile
|
||||
without a live adapter must never fall back to another profile's."""
|
||||
@@ -118,8 +118,8 @@ def shared_ingress_apps(runner: Any, profile: Optional[str]) -> list[tuple[Any,
|
||||
|
||||
|
||||
async def dispatch_profile_ingress(
|
||||
runner: Any, profile: Optional[str], tail: str, request: "web.Request", *, scoped: bool = False,
|
||||
) -> "web.StreamResponse":
|
||||
runner: Any, profile: Optional[str], tail: str, request: web.Request, *, scoped: bool = False,
|
||||
) -> web.StreamResponse:
|
||||
"""Forward ``/p/<profile>/<tail>`` to the served profile's adapter app that routes ``/<tail>``,
|
||||
under that profile's runtime scope (``scoped=True`` when the caller already entered it). 404 when
|
||||
no adapter of *profile* serves the path."""
|
||||
|
||||
@@ -209,12 +209,12 @@ class SignalAdapter(BasePlatformAdapter):
|
||||
self._account_normalized = self.account.strip()
|
||||
# Recently sent timestamps filter echo-backs (Note to Self / linked-device sync-sents); LRU + TTL
|
||||
# so a pending echo in a chatty group isn't evicted by many outbounds.
|
||||
self._recent_sent_timestamps: "OrderedDict[int, float]" = OrderedDict()
|
||||
self._recent_sent_timestamps: OrderedDict[int, float] = OrderedDict()
|
||||
self._max_recent_timestamps = 512
|
||||
self._recent_sent_ttl_seconds = 300.0
|
||||
# Separate FIFO of outbound timestamps: Signal quote.id is the quoted message's timestamp, so
|
||||
# replies to this bot are recognised after the echo was consumed.
|
||||
self._sent_message_timestamps: "OrderedDict[str, None]" = OrderedDict()
|
||||
self._sent_message_timestamps: OrderedDict[str, None] = OrderedDict()
|
||||
self._max_sent_message_timestamps = 500
|
||||
# Best-effort number↔ACI/PNI UUID mapping so sends can upgrade a number to the UUID signal-cli prefers.
|
||||
self._recipient_uuid_by_number: dict[str, str] = {}
|
||||
@@ -950,7 +950,7 @@ class SignalAdapter(BasePlatformAdapter):
|
||||
ok = isinstance(raw, dict) and raw.get("sender") and raw.get("timestamp_ms")
|
||||
return (raw["sender"], raw["timestamp_ms"]) if ok else None
|
||||
|
||||
def _reactions_enabled(self, event: "MessageEvent" = None) -> bool:
|
||||
def _reactions_enabled(self, event: MessageEvent = None) -> bool:
|
||||
"""SIGNAL_REACTIONS env gate, then the DM allowlist: reactions fire before run.py's auth gate,
|
||||
so an unauthorized contact's 👀 would otherwise reveal a listening bot."""
|
||||
if str(_sig_secret("SIGNAL_REACTIONS", "true")).lower() in {"false", "0", "no"}:
|
||||
@@ -963,7 +963,7 @@ class SignalAdapter(BasePlatformAdapter):
|
||||
if self._reactions_enabled(event) and (target := self._extract_reaction_target(event)):
|
||||
await self.send_reaction(event.source.chat_id, "👀", *target)
|
||||
|
||||
async def on_processing_complete(self, event: MessageEvent, outcome: "ProcessingOutcome") -> None:
|
||||
async def on_processing_complete(self, event: MessageEvent, outcome: ProcessingOutcome) -> None:
|
||||
"""Swap 👀 for ✅/❌; on CANCELLED the 👀 stays to keep reflecting "in progress" (matches Telegram)."""
|
||||
if outcome == ProcessingOutcome.CANCELLED or not self._reactions_enabled(event):
|
||||
return
|
||||
|
||||
@@ -80,7 +80,7 @@ class _WebhookDeliveryIdentity:
|
||||
delivery_id: str
|
||||
|
||||
@classmethod
|
||||
def from_parts(cls, profile: Optional[str], route: str, delivery_id: str) -> "_WebhookDeliveryIdentity":
|
||||
def from_parts(cls, profile: Optional[str], route: str, delivery_id: str) -> _WebhookDeliveryIdentity:
|
||||
return cls(profile=profile or "default", route=route, delivery_id=delivery_id)
|
||||
|
||||
@property
|
||||
@@ -142,7 +142,7 @@ def _is_known_platform(name: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _json_error(message: str, status: int) -> "web.Response":
|
||||
def _json_error(message: str, status: int) -> web.Response:
|
||||
return web.json_response({"error": message}, status=status)
|
||||
|
||||
|
||||
@@ -385,7 +385,7 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
|
||||
# --- HTTP handlers ---
|
||||
|
||||
async def _handle_health(self, request: "web.Request") -> "web.Response":
|
||||
async def _handle_health(self, request: web.Request) -> web.Response:
|
||||
"""GET /health — simple health check."""
|
||||
return web.json_response({"status": "ok", "platform": "webhook"})
|
||||
|
||||
@@ -454,7 +454,7 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
except Exception as e:
|
||||
logger.error("[webhook] Failed to reload dynamic routes: %s", e)
|
||||
|
||||
async def _handle_profile_ingress(self, request: "web.Request") -> "web.StreamResponse":
|
||||
async def _handle_profile_ingress(self, request: web.Request) -> web.StreamResponse:
|
||||
profile = self._resolve_request_profile(request)
|
||||
if profile is _PROFILE_REJECTED or profile is None:
|
||||
return _json_error("Unknown or unconfigured profile", 404)
|
||||
@@ -462,7 +462,7 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
return await dispatch_profile_ingress(
|
||||
self.gateway_runner, profile, request.match_info.get("tail", ""), request)
|
||||
|
||||
def _resolve_request_profile(self, request: "web.Request"):
|
||||
def _resolve_request_profile(self, request: web.Request):
|
||||
"""Resolve + validate the /p/<profile>/ URL prefix: None (no prefix, or multiplexing off and the
|
||||
prefix names this gateway's own profile), the profile name (served under multiplexing), or
|
||||
``_PROFILE_REJECTED`` (unknown / not served → 404)."""
|
||||
@@ -502,8 +502,8 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
from hermes_cli.profiles import get_profile_dir
|
||||
return _profile_runtime_scope(get_profile_dir(profile))
|
||||
|
||||
async def _read_authenticated_body(self, request: "web.Request", route_name: str,
|
||||
route_config: dict) -> "tuple[Optional[bytes], Optional[web.Response]]":
|
||||
async def _read_authenticated_body(self, request: web.Request, route_name: str,
|
||||
route_config: dict) -> tuple[Optional[bytes], Optional[web.Response]]:
|
||||
"""Auth-before-body: size-cap, read, then HMAC-validate. Returns ``(body, None)`` or ``(None, response)``."""
|
||||
if (request.content_length or 0) > self._max_body_bytes:
|
||||
return None, _json_error("Payload too large", 413)
|
||||
@@ -541,7 +541,7 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
return _UNPARSEABLE
|
||||
|
||||
async def _handle_deliver_only(self, prompt: str, payload: Any, route_config: dict, route_name: str,
|
||||
event_type: str, delivery_id: str, profile: Optional[str] = None) -> "web.Response":
|
||||
event_type: str, delivery_id: str, profile: Optional[str] = None) -> web.Response:
|
||||
"""deliver_only: the rendered prompt IS the message — skip the agent, reuse the same
|
||||
auth/rate-limit/idempotency/template pipeline."""
|
||||
delivery = {"deliver": route_config.get("deliver", "log"), "payload": payload, "profile": profile,
|
||||
@@ -565,7 +565,7 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
return web.json_response(failed, status=502)
|
||||
|
||||
def _handle_cron_trigger(self, prompt: str, route_config: dict, route_name: str, event_type: str,
|
||||
delivery_id: str, profile: Optional[str] = None) -> "web.Response":
|
||||
delivery_id: str, profile: Optional[str] = None) -> web.Response:
|
||||
"""cron_job: fire an EXISTING cron job on this event instead of starting a webhook agent session.
|
||||
The rendered prompt is transient per-run context (same rail as ``cronjob(action='run', prompt=...)``);
|
||||
the job's own prompt, skills, model and delivery apply. Same auth/rate-limit/filter/script/idempotency
|
||||
@@ -596,7 +596,7 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
return web.json_response({"status": "accepted", "route": route_name, "cron_job": job_ref, "event": event_type,
|
||||
"delivery_id": delivery_id}, status=202)
|
||||
|
||||
def _resolve_route(self, request: "web.Request") -> "tuple[str, Optional[dict], Any, Optional[web.Response]]":
|
||||
def _resolve_route(self, request: web.Request) -> tuple[str, Optional[dict], Any, Optional[web.Response]]:
|
||||
"""Route + profile lookup for a POST; ``(route_name, route_config, profile, error_response)``."""
|
||||
self._reload_dynamic_routes() # hot-reload dynamic subscriptions (stat-gated, lock-free)
|
||||
route_name = request.match_info.get("route_name", "")
|
||||
@@ -635,7 +635,7 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
logger.warning("[webhook] Skill loading failed: %s", e)
|
||||
return prompt
|
||||
|
||||
async def _handle_webhook(self, request: "web.Request") -> "web.Response":
|
||||
async def _handle_webhook(self, request: web.Request) -> web.Response:
|
||||
"""POST /webhooks/{route_name} — receive and process a webhook event."""
|
||||
route_name, route_config, profile, error_response = self._resolve_route(request)
|
||||
if error_response is None:
|
||||
@@ -699,7 +699,7 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
delivery_id, now)
|
||||
|
||||
def _dispatch_agent_run(self, request, route_config: dict, route_name: str, profile, payload: Any, prompt: str,
|
||||
event_type: str, delivery_id: str, now: float) -> "web.Response":
|
||||
event_type: str, delivery_id: str, now: float) -> web.Response:
|
||||
"""Spawn the agent run for one POST and return 202 immediately."""
|
||||
logger.info("[webhook] %s event=%s route=%s prompt_len=%d delivery=%s", request.method, event_type, route_name,
|
||||
len(prompt), delivery_id)
|
||||
@@ -709,7 +709,7 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
"delivery_id": delivery_id}, status=202)
|
||||
|
||||
def _spawn_agent_run(self, payload: Any, prompt: str, delivery_id: str, now: float, *, route_config: dict,
|
||||
route_name: str, profile, event_type: str) -> "asyncio.Task":
|
||||
route_name: str, profile, event_type: str) -> asyncio.Task:
|
||||
"""Record delivery info and fire the agent run (shared by the immediate and coalesced paths)."""
|
||||
identity = _WebhookDeliveryIdentity.from_parts(profile, route_name, delivery_id)
|
||||
session_chat_id = identity.session_chat_id
|
||||
@@ -736,13 +736,13 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
task.add_done_callback(self._background_tasks.discard)
|
||||
return task
|
||||
|
||||
async def on_processing_complete(self, event: "MessageEvent", outcome: Any) -> None:
|
||||
async def on_processing_complete(self, event: MessageEvent, outcome: Any) -> None:
|
||||
"""Close the one-shot per-delivery session: ``prune_sessions`` only reaps rows with ``ended_at`` set, so
|
||||
unclosed webhook sessions leak unbounded. Fires at the true end of the run; ``end_session()`` is
|
||||
first-reason-wins."""
|
||||
await self._end_webhook_session(event, event.source.chat_id)
|
||||
|
||||
async def _end_webhook_session(self, event: "MessageEvent", session_chat_id: str) -> None:
|
||||
async def _end_webhook_session(self, event: MessageEvent, session_chat_id: str) -> None:
|
||||
"""Mark the per-delivery session ended via ``SessionDB.end_session`` (never a hand-written UPDATE),
|
||||
resolving session_id from the SAME source the run was keyed on."""
|
||||
runner = self.gateway_runner
|
||||
@@ -766,7 +766,7 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
|
||||
# --- Signature validation ---
|
||||
|
||||
def _validate_signature(self, request: "web.Request", body: bytes, secret: str) -> bool:
|
||||
def _validate_signature(self, request: web.Request, body: bytes, secret: str) -> bool:
|
||||
"""Validate webhook signature (GitHub, GitLab, Svix, Standard Webhooks, Linear, generic HMAC-SHA256)."""
|
||||
headers = request.headers
|
||||
|
||||
|
||||
+15
-15
@@ -66,7 +66,7 @@ _TABLE_RULE_RE = re.compile(r"^\s*\|?(?:\s*:?-{3,}:?\s*\|)+\s*:?-{3,}:?\s*\|?\s*
|
||||
_FENCE_RE = re.compile(r"^```([^\n`]*)\s*$")
|
||||
|
||||
|
||||
def _is_stale_session_ret(ret: "Optional[int]", errcode: "Optional[int]", errmsg: "Optional[str]") -> bool:
|
||||
def _is_stale_session_ret(ret: Optional[int], errcode: Optional[int], errmsg: Optional[str]) -> bool:
|
||||
"""Recognize stale-session variants of iLink's ``-2`` response, not real rate limits."""
|
||||
return (ret == RATE_LIMIT_ERRCODE or errcode == RATE_LIMIT_ERRCODE) and (errmsg or "").lower() in {
|
||||
"unknown error",
|
||||
@@ -88,7 +88,7 @@ def _session_not_ready_error(ret: Any, errcode: Any, errmsg: Any) -> RuntimeErro
|
||||
" — the user must send the bot a message first (or re-pair)")
|
||||
|
||||
|
||||
def _make_ssl_connector() -> Optional["aiohttp.TCPConnector"]:
|
||||
def _make_ssl_connector() -> Optional[aiohttp.TCPConnector]:
|
||||
"""TCPConnector with certifi's CA bundle (``ilinkai.weixin.qq.com`` fails some system stores, e.g. Homebrew
|
||||
OpenSSL); None without certifi so aiohttp's default (honors ``SSL_CERT_FILE`` under trust_env) applies.
|
||||
``keepalive_timeout=2`` + ``enable_cleanup_closed`` drain idle CLOSE_WAIT sockets behind proxies like Warp.
|
||||
@@ -108,7 +108,7 @@ def _make_ssl_connector() -> Optional["aiohttp.TCPConnector"]:
|
||||
return aiohttp.TCPConnector(ssl=ssl.create_default_context(cafile=certifi.where()), keepalive_timeout=2, enable_cleanup_closed=True)
|
||||
|
||||
|
||||
def _new_session(**kwargs: Any) -> "aiohttp.ClientSession":
|
||||
def _new_session(**kwargs: Any) -> aiohttp.ClientSession:
|
||||
return aiohttp.ClientSession(trust_env=gateway_trust_env(), connector=_make_ssl_connector(), **kwargs)
|
||||
|
||||
|
||||
@@ -264,7 +264,7 @@ def _guess_chat_type(message: dict[str, Any], account_id: str) -> tuple[str, str
|
||||
# HTTP helpers enforce timeouts via asyncio.wait_for(), not aiohttp ClientTimeout, which raises
|
||||
# "Timeout context manager should be used inside a task" under run_coroutine_threadsafe() from cron.
|
||||
async def _api_request(
|
||||
session: "aiohttp.ClientSession", method: str, *, base_url: str, endpoint: str, headers: dict[str, str], timeout_ms: int, body: Optional[str] = None,
|
||||
session: aiohttp.ClientSession, method: str, *, base_url: str, endpoint: str, headers: dict[str, str], timeout_ms: int, body: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
async def _do() -> dict[str, Any]:
|
||||
kwargs = {"data": body} if body is not None else {}
|
||||
@@ -277,18 +277,18 @@ async def _api_request(
|
||||
|
||||
|
||||
async def _api_post(
|
||||
session: "aiohttp.ClientSession", *, base_url: str, endpoint: str, payload: dict[str, Any], token: Optional[str], timeout_ms: int,
|
||||
session: aiohttp.ClientSession, *, base_url: str, endpoint: str, payload: dict[str, Any], token: Optional[str], timeout_ms: int,
|
||||
) -> dict[str, Any]:
|
||||
body = json.dumps({**payload, "base_info": {"channel_version": CHANNEL_VERSION}}, ensure_ascii=False, separators=(",", ":"))
|
||||
return await _api_request(session, "POST", base_url=base_url, endpoint=endpoint, headers=_headers(token, body), timeout_ms=timeout_ms, body=body)
|
||||
|
||||
|
||||
async def _api_get(session: "aiohttp.ClientSession", *, base_url: str, endpoint: str, timeout_ms: int) -> dict[str, Any]:
|
||||
async def _api_get(session: aiohttp.ClientSession, *, base_url: str, endpoint: str, timeout_ms: int) -> dict[str, Any]:
|
||||
headers = {"iLink-App-Id": ILINK_APP_ID, "iLink-App-ClientVersion": str(ILINK_APP_CLIENT_VERSION)}
|
||||
return await _api_request(session, "GET", base_url=base_url, endpoint=endpoint, headers=headers, timeout_ms=timeout_ms)
|
||||
|
||||
|
||||
async def _get_updates(session: "aiohttp.ClientSession", *, base_url: str, token: str, sync_buf: str, timeout_ms: int) -> dict[str, Any]:
|
||||
async def _get_updates(session: aiohttp.ClientSession, *, base_url: str, token: str, sync_buf: str, timeout_ms: int) -> dict[str, Any]:
|
||||
try:
|
||||
return await _api_post(session, base_url=base_url, endpoint=EP_GET_UPDATES, payload={"get_updates_buf": sync_buf}, token=token, timeout_ms=timeout_ms)
|
||||
except TimeoutError:
|
||||
@@ -296,7 +296,7 @@ async def _get_updates(session: "aiohttp.ClientSession", *, base_url: str, token
|
||||
|
||||
|
||||
async def _send_items(
|
||||
session: "aiohttp.ClientSession", *, base_url: str, token: str, to: str, item_list: list[dict[str, Any]], context_token: Optional[str], client_id: str,
|
||||
session: aiohttp.ClientSession, *, base_url: str, token: str, to: str, item_list: list[dict[str, Any]], context_token: Optional[str], client_id: str,
|
||||
) -> dict[str, Any]:
|
||||
message: dict[str, Any] = {
|
||||
"from_user_id": "", "to_user_id": to, "client_id": client_id, "message_type": MSG_TYPE_BOT, "message_state": MSG_STATE_FINISH,
|
||||
@@ -307,7 +307,7 @@ async def _send_items(
|
||||
|
||||
|
||||
async def _send_message(
|
||||
session: "aiohttp.ClientSession", *, base_url: str, token: str, to: str, text: str, context_token: Optional[str], client_id: str,
|
||||
session: aiohttp.ClientSession, *, base_url: str, token: str, to: str, text: str, context_token: Optional[str], client_id: str,
|
||||
) -> dict[str, Any]:
|
||||
if not text or not text.strip():
|
||||
raise ValueError("_send_message: text must not be empty")
|
||||
@@ -315,7 +315,7 @@ async def _send_message(
|
||||
return await _send_items(session, base_url=base_url, token=token, to=to, item_list=item_list, context_token=context_token, client_id=client_id)
|
||||
|
||||
|
||||
async def _get_config(session: "aiohttp.ClientSession", *, base_url: str, token: str, user_id: str, context_token: Optional[str]) -> dict[str, Any]:
|
||||
async def _get_config(session: aiohttp.ClientSession, *, base_url: str, token: str, user_id: str, context_token: Optional[str]) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {"ilink_user_id": user_id}
|
||||
if context_token:
|
||||
payload["context_token"] = context_token
|
||||
@@ -323,7 +323,7 @@ async def _get_config(session: "aiohttp.ClientSession", *, base_url: str, token:
|
||||
|
||||
|
||||
async def _get_upload_url(
|
||||
session: "aiohttp.ClientSession", *, base_url: str, token: str, to_user_id: str, media_type: int, filekey: str, rawsize: int,
|
||||
session: aiohttp.ClientSession, *, base_url: str, token: str, to_user_id: str, media_type: int, filekey: str, rawsize: int,
|
||||
rawfilemd5: str, filesize: int, aeskey_hex: str,
|
||||
) -> dict[str, Any]:
|
||||
payload = {
|
||||
@@ -332,7 +332,7 @@ async def _get_upload_url(
|
||||
return await _api_post(session, base_url=base_url, endpoint=EP_GET_UPLOAD_URL, payload=payload, token=token, timeout_ms=API_TIMEOUT_MS)
|
||||
|
||||
|
||||
async def _upload_ciphertext(session: "aiohttp.ClientSession", *, ciphertext: bytes, upload_url: str) -> str:
|
||||
async def _upload_ciphertext(session: aiohttp.ClientSession, *, ciphertext: bytes, upload_url: str) -> str:
|
||||
async def _do() -> str:
|
||||
async with session.post(upload_url, data=ciphertext, headers={"Content-Type": "application/octet-stream"}) as response:
|
||||
encrypted_param = response.headers.get("x-encrypted-param") if response.status == 200 else None
|
||||
@@ -344,7 +344,7 @@ async def _upload_ciphertext(session: "aiohttp.ClientSession", *, ciphertext: by
|
||||
return await asyncio.wait_for(_do(), timeout=120)
|
||||
|
||||
|
||||
async def _download_bytes(session: "aiohttp.ClientSession", *, url: str, timeout_seconds: float = 60.0) -> bytes:
|
||||
async def _download_bytes(session: aiohttp.ClientSession, *, url: str, timeout_seconds: float = 60.0) -> bytes:
|
||||
async def _do() -> bytes:
|
||||
async with session.get(url) as response:
|
||||
response.raise_for_status()
|
||||
@@ -369,7 +369,7 @@ def _assert_weixin_cdn_url(url: str) -> None:
|
||||
|
||||
|
||||
async def _download_and_decrypt_media(
|
||||
session: "aiohttp.ClientSession", *, cdn_base_url: str, encrypted_query_param: Optional[str], aes_key_b64: Optional[str],
|
||||
session: aiohttp.ClientSession, *, cdn_base_url: str, encrypted_query_param: Optional[str], aes_key_b64: Optional[str],
|
||||
full_url: Optional[str], timeout_seconds: float,
|
||||
) -> bytes:
|
||||
if encrypted_query_param:
|
||||
@@ -571,7 +571,7 @@ def _save_sync_buf(hermes_home: str, account_id: str, sync_buf: str) -> None:
|
||||
atomic_json_write(_account_dir(hermes_home) / f"{account_id}.sync.json", {"get_updates_buf": sync_buf})
|
||||
|
||||
|
||||
async def _fetch_qr(session: "aiohttp.ClientSession", bot_type: str) -> tuple[str, str]:
|
||||
async def _fetch_qr(session: aiohttp.ClientSession, bot_type: str) -> tuple[str, str]:
|
||||
qr_resp = await _api_get(session, base_url=ILINK_BASE_URL, endpoint=f"{EP_GET_BOT_QR}?bot_type={bot_type}", timeout_ms=QR_TIMEOUT_MS)
|
||||
return str(qr_resp.get("qrcode") or ""), str(qr_resp.get("qrcode_img_content") or "")
|
||||
|
||||
|
||||
@@ -205,18 +205,18 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
|
||||
self._group_allow_from: set[str] = self._normalize_allow_ids(self._coerce_allow_list(raw_groups))
|
||||
self._mention_patterns = self._compile_mention_patterns()
|
||||
# Webhook dedup state (in-memory, FIFO-evicted) and counters.
|
||||
self._seen_wamids: "OrderedDict[str, bool]" = OrderedDict()
|
||||
self._seen_wamids: OrderedDict[str, bool] = OrderedDict()
|
||||
self._duplicate_count = self._accepted_count = self._rejected_signature_count = 0
|
||||
self._warned_no_ffmpeg: bool = False
|
||||
# Latest inbound wamid per chat: Meta's typing/read-receipt API needs a
|
||||
# message_id to attach to, and the base send_typing contract has none.
|
||||
self._last_inbound_wamid_by_chat: "OrderedDict[str, str]" = OrderedDict()
|
||||
self._last_inbound_wamid_by_chat: OrderedDict[str, str] = OrderedDict()
|
||||
# Interactive-button state: short id (in the button payload) → session_key for
|
||||
# the gateway resolver. Popped on tap; FIFO-capped via bounded_put so ignored
|
||||
# prompts don't accumulate (an evicted tap degrades to text fallback).
|
||||
self._clarify_state: "OrderedDict[str, str]" = OrderedDict()
|
||||
self._exec_approval_state: "OrderedDict[str, str]" = OrderedDict()
|
||||
self._slash_confirm_state: "OrderedDict[str, str]" = OrderedDict()
|
||||
self._clarify_state: OrderedDict[str, str] = OrderedDict()
|
||||
self._exec_approval_state: OrderedDict[str, str] = OrderedDict()
|
||||
self._slash_confirm_state: OrderedDict[str, str] = OrderedDict()
|
||||
self._runner = self._http_client = None
|
||||
|
||||
# ------------------------------------------------------------------ helpers
|
||||
@@ -417,7 +417,7 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
|
||||
# ------------------------------------------------------------------ interactive messages
|
||||
async def _send_interactive(
|
||||
self, chat_id: str, interactive: dict[str, Any], metadata: Optional[dict[str, Any]],
|
||||
state: "OrderedDict[str, str]", state_id: str, session_key: str,
|
||||
state: OrderedDict[str, str], state_id: str, session_key: str,
|
||||
) -> SendResult:
|
||||
"""POST an ``interactive`` message (caller supplies ``type``/``body``/``action``) and, on
|
||||
success, remember ``state_id → session_key`` for the tap. Free-form interactives need no
|
||||
@@ -692,7 +692,7 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
|
||||
return str(out_path), mime or None
|
||||
|
||||
# ------------------------------------------------------------------ inbound
|
||||
async def _handle_health(self, request: "web.Request") -> "web.Response":
|
||||
async def _handle_health(self, request: web.Request) -> web.Response:
|
||||
return web.json_response({
|
||||
"status": "ok", "platform": self.platform.value, "phone_number_id": self._phone_number_id,
|
||||
"webhook_path": self._webhook_path, "verify_token_configured": bool(self._verify_token),
|
||||
@@ -701,7 +701,7 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
|
||||
"rejected_signature": self._rejected_signature_count,
|
||||
})
|
||||
|
||||
async def _handle_verify(self, request: "web.Request") -> "web.Response":
|
||||
async def _handle_verify(self, request: web.Request) -> web.Response:
|
||||
"""Meta subscription handshake: echo ``hub.challenge`` iff mode is
|
||||
``subscribe`` and ``hub.verify_token`` matches (constant-time)."""
|
||||
if not self._verify_token:
|
||||
@@ -717,7 +717,7 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
|
||||
return web.Response(status=400, text="missing challenge")
|
||||
return web.Response(text=q["hub.challenge"], content_type="text/plain")
|
||||
|
||||
async def _handle_webhook(self, request: "web.Request") -> "web.Response":
|
||||
async def _handle_webhook(self, request: web.Request) -> web.Response:
|
||||
"""Inbound webhook POST: raw bytes → HMAC verify → JSON → dispatch. Signature is over
|
||||
the raw body, so JSON parsing must come after verification. Always 200 once a valid
|
||||
request is ack'd — Meta retries non-200 for up to 7 days and would multiply agent work."""
|
||||
@@ -855,7 +855,7 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
|
||||
|
||||
@staticmethod
|
||||
def _pop_tap_state(
|
||||
state: "OrderedDict[str, str]", key: str, stale_log: str, choice: str = "", valid: tuple = (),
|
||||
state: OrderedDict[str, str], key: str, stale_log: str, choice: str = "", valid: tuple = (),
|
||||
) -> Optional[str]:
|
||||
"""Pop the session_key for a tapped prompt. None (info-logged) when nothing is live — likely
|
||||
a stale tap; an unrecognised ``choice`` keeps the prompt live and also yields None."""
|
||||
|
||||
@@ -337,24 +337,24 @@ class InboundPipeline:
|
||||
return name_or_mw.name, name_or_mw
|
||||
return name_or_mw, handler
|
||||
|
||||
def use(self, name_or_mw, handler=None, when=None) -> "InboundPipeline":
|
||||
def use(self, name_or_mw, handler=None, when=None) -> InboundPipeline:
|
||||
"""Append ``pipeline.use(SomeMiddleware())`` or ``pipeline.use("name", fn)``."""
|
||||
return self._insert_relative(None, 0, name_or_mw, handler, when)
|
||||
|
||||
def _insert_relative(self, target: Optional[str], offset: int, name_or_mw, handler, when) -> "InboundPipeline":
|
||||
def _insert_relative(self, target: Optional[str], offset: int, name_or_mw, handler, when) -> InboundPipeline:
|
||||
"""Insert at index(target)+offset; appends when *target* is None or not registered."""
|
||||
name, h = self._normalize(name_or_mw, handler)
|
||||
idx = next((i for i, (n, _, _) in enumerate(self._middlewares) if n == target), None)
|
||||
self._middlewares.insert(len(self._middlewares) if idx is None else idx + offset, (name, h, when))
|
||||
return self
|
||||
|
||||
def use_before(self, target: str, name_or_mw, handler=None, when=None) -> "InboundPipeline":
|
||||
def use_before(self, target: str, name_or_mw, handler=None, when=None) -> InboundPipeline:
|
||||
return self._insert_relative(target, 0, name_or_mw, handler, when)
|
||||
|
||||
def use_after(self, target: str, name_or_mw, handler=None, when=None) -> "InboundPipeline":
|
||||
def use_after(self, target: str, name_or_mw, handler=None, when=None) -> InboundPipeline:
|
||||
return self._insert_relative(target, 1, name_or_mw, handler, when)
|
||||
|
||||
def remove(self, name: str) -> "InboundPipeline":
|
||||
def remove(self, name: str) -> InboundPipeline:
|
||||
self._middlewares = [(n, h, w) for n, h, w in self._middlewares if n != name]
|
||||
return self
|
||||
|
||||
@@ -1721,7 +1721,7 @@ class DispatchMiddleware(InboundMiddleware):
|
||||
task.add_done_callback(adapter._inbound_tasks.discard)
|
||||
|
||||
@staticmethod
|
||||
async def _consume_group_queue(adapter: "YuanbaoAdapter", session_key: str) -> None:
|
||||
async def _consume_group_queue(adapter: YuanbaoAdapter, session_key: str) -> None:
|
||||
"""Drain the group queue one dispatch at a time, waiting for each to finish; exits after 2s idle."""
|
||||
queue = adapter._group_queues.get(session_key)
|
||||
if not queue:
|
||||
@@ -1766,7 +1766,7 @@ class ConnectionManager:
|
||||
_DEBOUNCE_WINDOW: float = 1.5 # seconds to wait for companion frames of a multi-part message
|
||||
_LOOPS = (("_heartbeat_task", "_heartbeat_loop", "heartbeat"), ("_recv_task", "_receive_loop", "recv"))
|
||||
|
||||
def __init__(self, adapter: "YuanbaoAdapter") -> None:
|
||||
def __init__(self, adapter: YuanbaoAdapter) -> None:
|
||||
self._adapter = adapter
|
||||
self._ws = None # websockets connection
|
||||
self._connect_id: Optional[str] = None
|
||||
@@ -2173,15 +2173,15 @@ class MediaSendHandler(ABC):
|
||||
return True
|
||||
|
||||
@abstractmethod
|
||||
async def acquire_file(self, adapter: "YuanbaoAdapter", **kwargs: Any) -> tuple[bytes, str, str]:
|
||||
async def acquire_file(self, adapter: YuanbaoAdapter, **kwargs: Any) -> tuple[bytes, str, str]:
|
||||
"""(file_bytes, filename, content_type); raise ValueError when unobtainable."""
|
||||
|
||||
@abstractmethod
|
||||
def build_msg_body(self, upload_result: dict, **kwargs: Any) -> list:
|
||||
"""Platform-specific MsgBody list from the COS upload result."""
|
||||
|
||||
async def handle(self, adapter: "YuanbaoAdapter", chat_id: str, reply_to: Optional[str] = None,
|
||||
caption: Optional[str] = None, **kwargs: Any) -> "SendResult":
|
||||
async def handle(self, adapter: YuanbaoAdapter, chat_id: str, reply_to: Optional[str] = None,
|
||||
caption: Optional[str] = None, **kwargs: Any) -> SendResult:
|
||||
if adapter._connection.ws is None:
|
||||
return SendResult(success=False, error="Not connected", retryable=True)
|
||||
adapter._outbound.slow_notifier.cancel(chat_id)
|
||||
@@ -2277,7 +2277,7 @@ class StickerHandler(MediaSendHandler):
|
||||
|
||||
class HeartbeatManager:
|
||||
"""Reply heartbeat lifecycle: RUNNING every 2s, auto-FINISH after 30s idle, explicit stop."""
|
||||
def __init__(self, adapter: "YuanbaoAdapter") -> None:
|
||||
def __init__(self, adapter: YuanbaoAdapter) -> None:
|
||||
self._adapter = adapter
|
||||
self._reply_heartbeat_tasks: dict[str, asyncio.Task] = {}
|
||||
self._reply_hb_last_active: dict[str, float] = {}
|
||||
@@ -2347,7 +2347,7 @@ class HeartbeatManager:
|
||||
|
||||
class SlowResponseNotifier:
|
||||
"""Per-chat timer that sends a courtesy 'please wait' after SLOW_RESPONSE_TIMEOUT_S without a reply."""
|
||||
def __init__(self, adapter: "YuanbaoAdapter", sender: "MessageSender") -> None:
|
||||
def __init__(self, adapter: YuanbaoAdapter, sender: MessageSender) -> None:
|
||||
self._adapter = adapter
|
||||
self._sender = sender
|
||||
self._tasks: dict[str, asyncio.Task] = {}
|
||||
@@ -2383,7 +2383,7 @@ class MessageSender:
|
||||
# @nickname bounded by whitespace / line edges
|
||||
_AT_USER_RE = re.compile(r'(?:(?<=\s)|(?<=^))@(\S+?)(?=\s|$)', re.MULTILINE)
|
||||
|
||||
def __init__(self, adapter: "YuanbaoAdapter") -> None:
|
||||
def __init__(self, adapter: YuanbaoAdapter) -> None:
|
||||
self._adapter = adapter
|
||||
self._chat_locks: collections.OrderedDict[str, asyncio.Lock] = collections.OrderedDict()
|
||||
self._media_handlers: dict[str, MediaSendHandler] = {
|
||||
@@ -2400,7 +2400,7 @@ class MessageSender:
|
||||
self._chat_locks[chat_id] = asyncio.Lock()
|
||||
return self._chat_locks[chat_id]
|
||||
|
||||
async def send_text(self, chat_id: str, content: str, reply_to: Optional[str] = None, group_code: str = "") -> "SendResult":
|
||||
async def send_text(self, chat_id: str, content: str, reply_to: Optional[str] = None, group_code: str = "") -> SendResult:
|
||||
"""Send text with auto-chunking and per-chat-id ordering guarantee."""
|
||||
adapter = self._adapter
|
||||
if adapter._connection.ws is None:
|
||||
@@ -2420,7 +2420,7 @@ class MessageSender:
|
||||
return SendResult(success=True)
|
||||
|
||||
async def send_media(self, chat_id: str, handler_name: str, reply_to: Optional[str] = None,
|
||||
caption: Optional[str] = None, **kwargs: Any) -> "SendResult":
|
||||
caption: Optional[str] = None, **kwargs: Any) -> SendResult:
|
||||
handler = self._media_handlers.get(handler_name)
|
||||
if handler is None:
|
||||
return SendResult(success=False, error=f"Unknown media handler: {handler_name!r}")
|
||||
@@ -2429,7 +2429,7 @@ class MessageSender:
|
||||
async def send_direct(self, chat_id: str, message: str, media_files: Optional[list[tuple[str, bool]]] = None) -> dict[str, Any]:
|
||||
"""send_message tool entry: text first, then each media file by extension, on the running adapter."""
|
||||
adapter = self._adapter
|
||||
last_result: Optional["SendResult"] = None
|
||||
last_result: Optional[SendResult] = None
|
||||
if message.strip():
|
||||
last_result = await adapter.send(chat_id, message)
|
||||
if not last_result.success:
|
||||
@@ -2443,19 +2443,19 @@ class MessageSender:
|
||||
return {"error": "No deliverable text or media remained after processing"}
|
||||
return {"success": True, "platform": "yuanbao", "chat_id": chat_id, "message_id": last_result.message_id}
|
||||
|
||||
async def dispatch_msg_body(self, chat_id: str, msg_body: list, reply_to: Optional[str] = None, group_code: str = "") -> "SendResult":
|
||||
async def dispatch_msg_body(self, chat_id: str, msg_body: list, reply_to: Optional[str] = None, group_code: str = "") -> SendResult:
|
||||
"""Lock + dispatch an arbitrary MsgBody to C2C or group."""
|
||||
async with self.get_chat_lock(chat_id):
|
||||
result = await self._send_msg_body(chat_id, msg_body, reply_to, group_code)
|
||||
return self._to_send_result(result)
|
||||
|
||||
@staticmethod
|
||||
def _to_send_result(raw: dict) -> "SendResult":
|
||||
def _to_send_result(raw: dict) -> SendResult:
|
||||
if raw.get("success"):
|
||||
return SendResult(success=True, message_id=raw.get("msg_key"))
|
||||
return SendResult(success=False, error=raw.get("error", "Unknown error"))
|
||||
|
||||
async def send_text_chunk(self, chat_id: str, text: str, reply_to: Optional[str] = None, retry: int = 3, group_code: str = "") -> "SendResult":
|
||||
async def send_text_chunk(self, chat_id: str, text: str, reply_to: Optional[str] = None, retry: int = 3, group_code: str = "") -> SendResult:
|
||||
"""Send a single text chunk with retry (exponential backoff: 1s, 2s, 4s)."""
|
||||
adapter = self._adapter
|
||||
last_error: str = "Unknown error"
|
||||
@@ -2532,7 +2532,7 @@ class MessageSender:
|
||||
), req_id)
|
||||
|
||||
@staticmethod
|
||||
async def _dispatch_encoded(adapter: "YuanbaoAdapter", encoded: bytes, req_id: str) -> dict:
|
||||
async def _dispatch_encoded(adapter: YuanbaoAdapter, encoded: bytes, req_id: str) -> dict:
|
||||
"""Send pre-encoded bytes via WS → ``{"success", "msg_key" | "error"}``."""
|
||||
try:
|
||||
response = await adapter._connection.send_biz_request(encoded, req_id=req_id)
|
||||
@@ -2579,7 +2579,7 @@ class MessageSender:
|
||||
class OutboundManager:
|
||||
"""Composes MessageSender, HeartbeatManager and SlowResponseNotifier (sender cancels the notifier
|
||||
before a send and emits the FINISH heartbeat after)."""
|
||||
def __init__(self, adapter: "YuanbaoAdapter") -> None:
|
||||
def __init__(self, adapter: YuanbaoAdapter) -> None:
|
||||
self._adapter = adapter
|
||||
self.sender: MessageSender = MessageSender(adapter)
|
||||
self.heartbeat: HeartbeatManager = HeartbeatManager(adapter)
|
||||
@@ -2598,16 +2598,16 @@ class YuanbaoAdapter(BasePlatformAdapter):
|
||||
splits_long_messages = True # send() auto-chunks via truncate_message(MAX_TEXT_CHUNK)
|
||||
MEDIA_MAX_SIZE_MB: int = 50
|
||||
DM_MAX_CHARS = 10000
|
||||
_active_instance: ClassVar[Optional["YuanbaoAdapter"]] = None
|
||||
_active_instance: ClassVar[Optional[YuanbaoAdapter]] = None
|
||||
# Per Hermes home: a multiplexed gateway runs one Yuanbao adapter per profile, and the tools /
|
||||
# send_message read "the" adapter from inside a profile-scoped turn, so last-wins would route
|
||||
# profile B's sends through profile A's bot. Registration and lookup both key on the ambient
|
||||
# override (connect/reconnect tasks inherit the profile's Context); the slot above serves the
|
||||
# unscoped path.
|
||||
_active_instances: ClassVar[dict[str, "YuanbaoAdapter"]] = {}
|
||||
_active_instances: ClassVar[dict[str, YuanbaoAdapter]] = {}
|
||||
|
||||
@classmethod
|
||||
def get_active(cls) -> Optional["YuanbaoAdapter"]:
|
||||
def get_active(cls) -> Optional[YuanbaoAdapter]:
|
||||
from hermes_constants import get_hermes_home_override, hermes_home_key
|
||||
|
||||
if get_hermes_home_override() is None:
|
||||
@@ -2615,7 +2615,7 @@ class YuanbaoAdapter(BasePlatformAdapter):
|
||||
return cls._active_instances.get(hermes_home_key())
|
||||
|
||||
@classmethod
|
||||
def set_active(cls, adapter: Optional["YuanbaoAdapter"]) -> None:
|
||||
def set_active(cls, adapter: Optional[YuanbaoAdapter]) -> None:
|
||||
from hermes_constants import get_hermes_home_override, hermes_home_key
|
||||
|
||||
if get_hermes_home_override() is None:
|
||||
|
||||
@@ -179,7 +179,7 @@ class RelayAdapter(BasePlatformAdapter):
|
||||
self._revocation_monitor: Optional[asyncio.Task[None]] = None
|
||||
# Lazily built client for the connector's /relay/media routes; None when
|
||||
# dial URL or creds are absent (media lanes degrade to text fallbacks).
|
||||
self._media_client: Optional["RelayMediaClient"] = None
|
||||
self._media_client: Optional[RelayMediaClient] = None
|
||||
# prompt_id -> pending-prompt state for the interactive `prompt` op; the
|
||||
# user's pick comes back as a prompt_response naming this id and resolves the
|
||||
# waiting primitive like native button callbacks. Expire lazily (_pop_prompt).
|
||||
@@ -193,7 +193,7 @@ class RelayAdapter(BasePlatformAdapter):
|
||||
self._prompt_owner_nonce: str = secrets.token_hex(3)
|
||||
# Prompt ids this process already resolved, newest last (repeat answers are
|
||||
# consumed silently instead of treated as stale).
|
||||
self._resolved_prompts: "OrderedDict[str, float]" = OrderedDict()
|
||||
self._resolved_prompts: OrderedDict[str, float] = OrderedDict()
|
||||
|
||||
# ── capability surface (from descriptor) ─────────────────────────────
|
||||
@property
|
||||
|
||||
@@ -67,7 +67,7 @@ class CapabilityDescriptor:
|
||||
return json.dumps(asdict(self), sort_keys=True, ensure_ascii=False)
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, data: str) -> "CapabilityDescriptor":
|
||||
def from_json(cls, data: str) -> CapabilityDescriptor:
|
||||
"""Deserialize a handshake JSON string; unknown keys ignored, missing keys default.
|
||||
|
||||
Trust-boundary normalization (malformed input never breaks the handshake):
|
||||
|
||||
@@ -42,7 +42,7 @@ class RelayTransport(Protocol):
|
||||
def set_inbound_handler(self, handler: InboundHandler) -> None:
|
||||
...
|
||||
|
||||
def set_passthrough_handler(self, handler: "PassthroughHandler") -> None:
|
||||
def set_passthrough_handler(self, handler: PassthroughHandler) -> None:
|
||||
"""Register the callback for each forwarded passthrough request (§5.1).
|
||||
|
||||
The connector answers the provider's edge ACK itself, then forwards the real
|
||||
|
||||
+1
-1
@@ -125,7 +125,7 @@ def read_launchd_exit_timeout_s(
|
||||
*,
|
||||
environ: Mapping[str, str] | None = None,
|
||||
uid: int | None = None,
|
||||
run: Callable[..., "subprocess.CompletedProcess[str]"] = subprocess.run,
|
||||
run: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run,
|
||||
platform: str = sys.platform,
|
||||
) -> float | None:
|
||||
"""Live ``ExitTimeOut`` (seconds) launchd enforces for this gateway's job.
|
||||
|
||||
+28
-28
@@ -542,7 +542,7 @@ def _is_transient_network_error(exc: BaseException) -> bool:
|
||||
|
||||
|
||||
def _gateway_loop_exception_handler(
|
||||
loop: "asyncio.AbstractEventLoop", context: dict[str, Any]) -> None:
|
||||
loop: asyncio.AbstractEventLoop, context: dict[str, Any]) -> None:
|
||||
"""Loop-level safety net for transient network errors (installed once by ``start_gateway``).
|
||||
|
||||
Logs WARNING with traceback; non-transient errors go to the default handler so real bugs surface.
|
||||
@@ -574,7 +574,7 @@ def _redact_gateway_user_facing_secrets(text: str) -> str:
|
||||
return redact_for_egress(text)
|
||||
|
||||
|
||||
def _redact_approval_command(cmd: "str | None") -> str:
|
||||
def _redact_approval_command(cmd: str | None) -> str:
|
||||
"""Redact credentials from a command before it goes into an approval prompt.
|
||||
|
||||
The gateway approval prompt is built from the raw command string, so a credential-shaped value would
|
||||
@@ -1587,7 +1587,7 @@ def _reload_runtime_env_preserving_config_authority() -> None:
|
||||
_bridge_max_turns_from_config(_hermes_home)
|
||||
|
||||
|
||||
def _bridge_max_turns_from_config(home: "Path") -> None:
|
||||
def _bridge_max_turns_from_config(home: Path) -> None:
|
||||
"""Re-bridge agent.max_turns (+ sessions.*) per turn; managed overlay applies or it reverts.
|
||||
Skipped inside a served secondary's scope: the env slots are the launch profile's and
|
||||
hermes_state reads the routed profile's ``sessions.*`` from its own config under scope."""
|
||||
@@ -1638,13 +1638,13 @@ class HygieneTurnHoldExceeded(Exception):
|
||||
must NOT take the idle-timeout path (AGENT_COMPRESSION_TIMEOUT, "no output", failure cooldown)."""
|
||||
|
||||
|
||||
def _multiplex_profile_homes(config: object) -> list[tuple[str, "Path"]]:
|
||||
def _multiplex_profile_homes(config: object) -> list[tuple[str, Path]]:
|
||||
"""Return the authoritative profile set for one multiplex gateway config."""
|
||||
from hermes_cli.profiles import profiles_to_serve
|
||||
return list(profiles_to_serve(multiplex=True))
|
||||
|
||||
|
||||
def _cron_tick_profile_homes(config: object) -> list[tuple[str, "Path"]]:
|
||||
def _cron_tick_profile_homes(config: object) -> list[tuple[str, Path]]:
|
||||
"""Profile homes the in-process ticker visits: the served set PLUS the process-active
|
||||
profile: ``profiles_to_serve`` lists default + every live named profile, but a ``--profile
|
||||
<name>`` gateway's own profile may sit outside ``profiles/`` (custom HERMES_HOME). One host
|
||||
@@ -1662,7 +1662,7 @@ def _cron_tick_profile_homes(config: object) -> list[tuple[str, "Path"]]:
|
||||
return homes
|
||||
|
||||
|
||||
def _cron_profile_gate(name: str, home: "Path") -> bool:
|
||||
def _cron_profile_gate(name: str, home: Path) -> bool:
|
||||
"""Tick ``home`` this cycle unless ANOTHER gateway process owns it.
|
||||
|
||||
Same stand-down the serve/Desktop ticker applies (``hermes_cli/web_server.py``): a host that
|
||||
@@ -1769,7 +1769,7 @@ def _terminal_scope_cwd(default: str = "") -> str:
|
||||
return _ts_env("TERMINAL_CWD", default)
|
||||
|
||||
|
||||
def _load_profile_secret_scope(profile_home: "Path") -> dict:
|
||||
def _load_profile_secret_scope(profile_home: Path) -> dict:
|
||||
"""Hydrate and load one profile's secrets under its home override."""
|
||||
from hermes_constants import set_hermes_home_override, reset_hermes_home_override
|
||||
# Caller already hydrated external sources off-loop (#99519).
|
||||
@@ -1786,7 +1786,7 @@ def _load_profile_secret_scope(profile_home: "Path") -> dict:
|
||||
|
||||
@_contextmanager
|
||||
def _profile_runtime_scope(
|
||||
profile_home: "Path", prepared_secret_scope: Optional[dict] = None, *,
|
||||
profile_home: Path, prepared_secret_scope: Optional[dict] = None, *,
|
||||
hydrate_secrets: bool = True):
|
||||
"""Scope config/skills/memory AND credentials to a profile for one turn (multiplexed path only).
|
||||
``set_hermes_home_override`` is a contextvar (reaches the agent worker via ``copy_context()``);
|
||||
@@ -1821,14 +1821,14 @@ def _profile_runtime_scope(
|
||||
|
||||
|
||||
@_asynccontextmanager
|
||||
async def _async_profile_runtime_scope(profile_home: "Path"):
|
||||
async def _async_profile_runtime_scope(profile_home: Path):
|
||||
"""Enter a profile scope without loading secret files on the event loop."""
|
||||
secrets = await asyncio.to_thread(_load_profile_secret_scope, Path(profile_home))
|
||||
with _profile_runtime_scope(Path(profile_home), secrets):
|
||||
yield
|
||||
|
||||
|
||||
def load_gateway_config_for_runner() -> "GatewayConfig":
|
||||
def load_gateway_config_for_runner() -> GatewayConfig:
|
||||
"""Load gateway config for the process-level GatewayRunner. An UNSET ``multiplex_profiles`` is
|
||||
settled first by ``resolve_multiplex_mode`` (the default is on; the boot guard keeps a fleet that
|
||||
still runs per-profile gateways standalone). Multiplexed: set multiplex-active, then reload
|
||||
@@ -1898,7 +1898,7 @@ async def _discover_gateway_mcp_tools(config: object) -> None:
|
||||
logger.warning("MCP tool discovery failed for profile '%s'", profile_name, exc_info=True)
|
||||
|
||||
|
||||
def _platform_has_bot_credential(platform: "Platform", platform_config: "PlatformConfig") -> bool:
|
||||
def _platform_has_bot_credential(platform: Platform, platform_config: PlatformConfig) -> bool:
|
||||
"""Return True when a token-authenticated platform has a usable bot credential; platforms not using
|
||||
``PlatformConfig.token`` (Signal session paths, port-binding HTTP adapters) always return True."""
|
||||
from gateway.config import PLATFORM_TOKEN_ENV_NAMES, Platform
|
||||
@@ -2846,7 +2846,7 @@ def _check_unavailable_skill(command_name: str) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _platform_config_key(platform: "Platform") -> str:
|
||||
def _platform_config_key(platform: Platform) -> str:
|
||||
"""Map a Platform enum to its config.yaml key (LOCAL→"cli", rest→enum value)."""
|
||||
return "cli" if platform == Platform.LOCAL else platform.value
|
||||
|
||||
@@ -2863,7 +2863,7 @@ def _gateway_config_home() -> Path:
|
||||
return Path(override) if override else _hermes_home
|
||||
|
||||
|
||||
def _load_gateway_config(config_path: "Path | None" = None) -> dict:
|
||||
def _load_gateway_config(config_path: Path | None = None) -> dict:
|
||||
"""The effective user config.yaml (managed overlay, ``${VAR}`` expansion, model-key canon; no
|
||||
DEFAULT_CONFIG merge) — ``{}`` on any error (fail-open). Defaults to the active gateway home
|
||||
(``_hermes_home`` monkeypatches apply); multiplexers pass a path.
|
||||
@@ -2953,7 +2953,7 @@ def _resolve_hermes_bin() -> Optional[list[str]]:
|
||||
_PROFILE_ID_KEY_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
|
||||
|
||||
|
||||
def _parse_session_key(session_key: str) -> "dict | None":
|
||||
def _parse_session_key(session_key: str) -> dict | None:
|
||||
"""Parse a session key (``agent:{ns}:{platform}:{chat_type}:{chat_id}[:{extra}...]``).
|
||||
|
||||
``{ns}`` is ``main`` for the default profile, ``main~`` for a profile literally named ``main``
|
||||
@@ -3020,7 +3020,7 @@ def _format_concise_process_notification(
|
||||
return text
|
||||
|
||||
|
||||
def _format_gateway_process_notification(evt: dict) -> "str | None":
|
||||
def _format_gateway_process_notification(evt: dict) -> str | None:
|
||||
"""Format a watch pattern event from completion_queue into a [IMPORTANT:] message."""
|
||||
evt_type = evt.get("type", "completion")
|
||||
_sid = evt.get("session_id", "unknown")
|
||||
@@ -3050,7 +3050,7 @@ def _format_gateway_process_notification(evt: dict) -> "str | None":
|
||||
return None
|
||||
|
||||
|
||||
def _drain_gateway_watch_events(completion_queue) -> "list[dict]":
|
||||
def _drain_gateway_watch_events(completion_queue) -> list[dict]:
|
||||
"""Drain gateway-owned watch events without spinning on requeued events.
|
||||
Foreign events requeued inside ``while not queue.empty()`` never terminate: detach, then requeue."""
|
||||
watch_events: list[dict] = []
|
||||
@@ -3205,7 +3205,7 @@ def _preserve_queued_followup_history_offset(
|
||||
return {**followup_result, "history_offset": current_offset}
|
||||
|
||||
|
||||
async def _dispose_unused_adapter(adapter: "BasePlatformAdapter | None") -> None:
|
||||
async def _dispose_unused_adapter(adapter: BasePlatformAdapter | None) -> None:
|
||||
"""Best-effort dispose for an adapter that never made it onto ``self.adapters`` (may be ``None``).
|
||||
Nothing else calls ``disconnect()`` on it, so ``__init__`` resources (e.g. SQLite fds) would leak
|
||||
until GC (not prompt for asyncio-bound objects) and exhaust the fd ulimit over a long retry loop.
|
||||
@@ -3403,7 +3403,7 @@ class GatewayRunner(
|
||||
_pending_approvals = legacy_dict_property("_pending_approvals")
|
||||
_update_prompt_pending = legacy_dict_property("_update_prompt_pending")
|
||||
|
||||
def _sessions_map(self) -> dict[str, "SessionState"]:
|
||||
def _sessions_map(self) -> dict[str, SessionState]:
|
||||
"""Per-session state map; lazily created so bare ``object.__new__`` test runners work."""
|
||||
sessions = self.__dict__.get("_sessions")
|
||||
if sessions is None:
|
||||
@@ -3411,7 +3411,7 @@ class GatewayRunner(
|
||||
self.__dict__["_sessions"] = sessions
|
||||
return sessions
|
||||
|
||||
def _session_state(self, session_key: str) -> "SessionState":
|
||||
def _session_state(self, session_key: str) -> SessionState:
|
||||
"""Get-or-create the :class:`SessionState` for ``session_key``."""
|
||||
sessions = self._sessions_map()
|
||||
state = sessions.get(session_key)
|
||||
@@ -3420,7 +3420,7 @@ class GatewayRunner(
|
||||
sessions[session_key] = state
|
||||
return state
|
||||
|
||||
def _peek_session_state(self, session_key: str) -> Optional["SessionState"]:
|
||||
def _peek_session_state(self, session_key: str) -> Optional[SessionState]:
|
||||
"""Return the SessionState for ``session_key`` without creating one."""
|
||||
sessions = self.__dict__.get("_sessions")
|
||||
return sessions.get(session_key) if sessions else None
|
||||
@@ -3437,13 +3437,13 @@ class GatewayRunner(
|
||||
# Loop-liveness / watchdog handles; class-level defaults so partially constructed test runners work.
|
||||
# Class-level defaults so partial construction in tests doesn't blow up on access; the real values are
|
||||
# set in __init__ / start() / stop(). See #66892, #69089.
|
||||
_loop_heartbeat_task: Optional["asyncio.Task"] = None
|
||||
_loop_heartbeat_task: Optional[asyncio.Task] = None
|
||||
_loop_floor_timer_handle: Optional[Any] = None
|
||||
_loop_liveness_watchdog: Optional[Any] = None
|
||||
_gateway_started_at: float = 0.0
|
||||
_shutdown_watchdog_done: Optional["threading.Event"] = None
|
||||
_shutdown_watchdog_done: Optional[threading.Event] = None
|
||||
_platform_lock_takeover_on_start: bool = False
|
||||
_reconnect_watcher_task: Optional["asyncio.Task"] = None
|
||||
_reconnect_watcher_task: Optional[asyncio.Task] = None
|
||||
|
||||
def __init__(self, config: Optional[GatewayConfig] = None):
|
||||
global _gateway_runner_ref
|
||||
@@ -3591,13 +3591,13 @@ class GatewayRunner(
|
||||
self._platform_lock_takeover_on_start = False
|
||||
# Capped LRU of live SessionSources for fallback routing (shutdown notices, synthetic events) when
|
||||
# the persisted origin is missing and _parse_session_key can't recover thread_id.
|
||||
self._session_sources: "OrderedDict[str, SessionSource]" = OrderedDict()
|
||||
self._session_sources: OrderedDict[str, SessionSource] = OrderedDict()
|
||||
self._session_sources_max = 512
|
||||
# Lifecycle-scoped completion dedup: closes queue/watcher races inside one gateway without claiming
|
||||
# exactly-once across a crash; durable replay state stays owned by tools.async_delegation.
|
||||
self._completion_delivery_lock = threading.Lock()
|
||||
self._completion_deliveries_inflight: set[tuple[str, str, object]] = set()
|
||||
self._completion_deliveries_delivered: "OrderedDict[tuple[str, str, object], None]" = OrderedDict()
|
||||
self._completion_deliveries_delivered: OrderedDict[tuple[str, str, object], None] = OrderedDict()
|
||||
self._completion_delivery_retention = 2048
|
||||
# Agent-triggered terminal completions from one conversation often land in the same scheduler
|
||||
# tick; hold them briefly so the agent gets one synthetic turn instead of one per process.
|
||||
@@ -3612,7 +3612,7 @@ class GatewayRunner(
|
||||
"""Agent cache, profile identity, Teams runtime, failed-platform tracking, slash-confirm counter."""
|
||||
# AIAgent per session preserves prompt caching (fresh agent per message ~10x cost on Anthropic).
|
||||
# Value: (AIAgent, config_signature); LRU cap in _enforce_agent_cache_cap, TTL in expiry watcher.
|
||||
self._agent_cache: "OrderedDict[str, tuple]" = OrderedDict()
|
||||
self._agent_cache: OrderedDict[str, tuple] = OrderedDict()
|
||||
self._agent_cache_lock = threading.Lock()
|
||||
# Launch-time identity of the profile that owns ``self.adapters``; ``_authorization_adapter``
|
||||
# compares against this rather than the per-turn ``_active_profile_name()``. A multiplex
|
||||
@@ -3685,7 +3685,7 @@ class GatewayRunner(
|
||||
from gateway.pairing import PairingStore
|
||||
from gateway.hooks import ProfileHookRegistries
|
||||
self.pairing_store = PairingStore()
|
||||
self.pairing_stores: dict[str, "PairingStore"] = {}
|
||||
self.pairing_stores: dict[str, PairingStore] = {}
|
||||
# One HookRegistry per served profile home, resolved from the active scope at emit time.
|
||||
self.hooks = ProfileHookRegistries()
|
||||
# Per-chat voice reply mode: "off" | "voice_only" | "all"
|
||||
@@ -4432,7 +4432,7 @@ class GatewayRunner(
|
||||
getattr(source, "thread_id", None), getattr(source, "parent_chat_id", None))
|
||||
return None
|
||||
|
||||
def _resolve_profile_home_for_source(self, source: SessionSource) -> "Path":
|
||||
def _resolve_profile_home_for_source(self, source: SessionSource) -> Path:
|
||||
"""Resolve which profile's HERMES_HOME serves this source: the pinned identity's runtime
|
||||
home, else ``source.profile``, then ``_profile_name_for_source`` (sources bypassing
|
||||
``build_source``), then the active profile."""
|
||||
|
||||
@@ -71,7 +71,7 @@ class GatewayAdapterLifecycleMixin:
|
||||
"""Adapter lifecycle: connect/teardown, fatal recovery, reconnect watcher, multiplex profiles."""
|
||||
|
||||
@staticmethod
|
||||
async def _wait_or_detach(task: "asyncio.Future", timeout: float) -> bool:
|
||||
async def _wait_or_detach(task: asyncio.Future, timeout: float) -> bool:
|
||||
"""Wait up to ``timeout`` for ``task``; on deadline (or our own cancellation) detach it. Not
|
||||
``asyncio.wait_for``: that WAITS for the cancelled child, so a connect()/close() swallowing
|
||||
``CancelledError`` blocks recovery forever. True if it finished in time."""
|
||||
@@ -427,7 +427,7 @@ class GatewayAdapterLifecycleMixin:
|
||||
"retry in background.", len(self._failed_platforms),
|
||||
)
|
||||
|
||||
def _retain_background_task(self, task: "asyncio.Task") -> "asyncio.Task":
|
||||
def _retain_background_task(self, task: asyncio.Task) -> asyncio.Task:
|
||||
"""Register ``task`` in ``_background_tasks`` (created lazily for bare test runners)."""
|
||||
tasks = getattr(self, "_background_tasks", None)
|
||||
if not isinstance(tasks, set):
|
||||
@@ -437,7 +437,7 @@ class GatewayAdapterLifecycleMixin:
|
||||
return task
|
||||
|
||||
@staticmethod
|
||||
def _track_task_in(tasks: set, task: "asyncio.Task") -> "asyncio.Task":
|
||||
def _track_task_in(tasks: set, task: asyncio.Task) -> asyncio.Task:
|
||||
"""Register ``task`` in an arbitrary lifecycle set with self-removal on completion."""
|
||||
tasks.add(task)
|
||||
task.add_done_callback(tasks.discard)
|
||||
@@ -541,7 +541,7 @@ class GatewayAdapterLifecycleMixin:
|
||||
except Exception:
|
||||
_process_takes_profile = False
|
||||
# In-flight dispatches by session id: a handoff is a FULL agent turn, so never process inline.
|
||||
inflight: dict[str, "asyncio.Task"] = {}
|
||||
inflight: dict[str, asyncio.Task] = {}
|
||||
|
||||
async def _dispatch(row, session_id, session_db, profile_name) -> None:
|
||||
"""Run one claimed handoff to a terminal state, off the poll path."""
|
||||
@@ -1061,7 +1061,7 @@ class GatewayAdapterLifecycleMixin:
|
||||
from hermes_constants import get_hermes_home
|
||||
publish_record(ROLE_GATEWAY, profiles=tuple(served), home=str(get_hermes_home()))
|
||||
|
||||
async def _load_secondary_profile_config(self, profile_name: str, profile_home: "Path"):
|
||||
async def _load_secondary_profile_config(self, profile_name: str, profile_home: Path):
|
||||
"""Hydrate + enter ``profile_home``'s scope once; return its gateway config. Raises
|
||||
``MultiplexConfigError`` (open dm/group policy). Port-binding platforms are NOT refused: the
|
||||
default profile owns the single shared listener and a secondary's port-binders are built in
|
||||
@@ -1247,7 +1247,7 @@ class GatewayAdapterLifecycleMixin:
|
||||
return lines
|
||||
|
||||
async def _start_one_profile_adapters(
|
||||
self, profile_name: str, profile_home: "Path", claimed: dict[tuple, str]
|
||||
self, profile_name: str, profile_home: Path, claimed: dict[tuple, str]
|
||||
) -> int:
|
||||
"""Create+connect one profile's adapters under its runtime scope."""
|
||||
from gateway.run import _platform_has_bot_credential, _profile_runtime_scope
|
||||
|
||||
+6
-6
@@ -102,7 +102,7 @@ class GatewayBusySessionMixin:
|
||||
state = self._peek_session_state(session_key)
|
||||
return state.conversation.queued_events if state else None
|
||||
|
||||
def _enqueue_fifo(self, session_key: str, queued_event: "MessageEvent", adapter: Any) -> None:
|
||||
def _enqueue_fifo(self, session_key: str, queued_event: MessageEvent, adapter: Any) -> None:
|
||||
"""Append a /queue event to the FIFO chain for a session."""
|
||||
pending_slot = getattr(adapter, "_pending_messages", None) if adapter is not None else None
|
||||
if pending_slot is None:
|
||||
@@ -114,8 +114,8 @@ class GatewayBusySessionMixin:
|
||||
queued_event._gateway_accepted = True
|
||||
|
||||
def _promote_queued_event(
|
||||
self, session_key: str, adapter: Any, pending_event: Optional["MessageEvent"]
|
||||
) -> Optional["MessageEvent"]:
|
||||
self, session_key: str, adapter: Any, pending_event: Optional[MessageEvent]
|
||||
) -> Optional[MessageEvent]:
|
||||
"""Promote the next overflow item after the slot drained.
|
||||
|
||||
``pending_event`` None → the overflow head becomes the pending event; otherwise the head is
|
||||
@@ -138,7 +138,7 @@ class GatewayBusySessionMixin:
|
||||
depth += 1
|
||||
return depth
|
||||
|
||||
def _rescue_orphaned_overflow(self, session_key: str, adapter: Any) -> Optional["MessageEvent"]:
|
||||
def _rescue_orphaned_overflow(self, session_key: str, adapter: Any) -> Optional[MessageEvent]:
|
||||
"""Pop the oldest orphaned FIFO overflow event for an idle session (None if nothing to rescue).
|
||||
|
||||
``queued_events`` drains only at the post-turn promotion site; a busy window ending without
|
||||
@@ -582,7 +582,7 @@ class GatewayBusySessionMixin:
|
||||
|
||||
async def _resolve_busy_steer_or_redirect(
|
||||
self, event: MessageEvent, session_key: str, effective_mode: str, running_agent: Any
|
||||
) -> "GatewayRunner._BusySteerOutcome":
|
||||
) -> GatewayRunner._BusySteerOutcome:
|
||||
"""Apply interrupt->queue demotions, then attempt steer (steer mode) or redirect (interrupt mode)."""
|
||||
from gateway.run import _AGENT_PENDING_SENTINEL
|
||||
# Steer injects mid-run via running_agent.steer(), falling back to queue (nothing lost) when
|
||||
@@ -1287,7 +1287,7 @@ class GatewayBusySessionMixin:
|
||||
|
||||
async def _maybe_confirm_destructive_slash(
|
||||
self, *, event: MessageEvent, command: str, title: str, detail: str, execute
|
||||
) -> str | "EphemeralReply" | None:
|
||||
) -> str | EphemeralReply | None:
|
||||
"""Gate a destructive session slash command (/new, /reset, /undo).
|
||||
|
||||
``execute()`` (async → str | EphemeralReply) runs immediately when
|
||||
|
||||
@@ -56,7 +56,7 @@ class GatewayGoalsMixin:
|
||||
except Exception as exc:
|
||||
logger.warning("%s: session DB warm-up failed: %s", label, exc)
|
||||
|
||||
async def _session_entry_for_manager(self, event: "MessageEvent", label: str):
|
||||
async def _session_entry_for_manager(self, event: MessageEvent, label: str):
|
||||
"""Session entry for a /goal or /heartbeat manager, or None when lookup fails. Warms the
|
||||
SessionDB cache first (a cold cache drops the first write while the reply claims it was
|
||||
set). Internal events never touch activity (idle/daily reset clock)."""
|
||||
@@ -70,7 +70,7 @@ class GatewayGoalsMixin:
|
||||
return None
|
||||
return session_entry if getattr(session_entry, "session_id", None) else None
|
||||
|
||||
async def _manager_for_event(self, event: "MessageEvent", kind: str, load):
|
||||
async def _manager_for_event(self, event: MessageEvent, kind: str, load):
|
||||
"""``(manager, session_entry)`` for *kind* ("goal"/"heartbeat"), or ``(None, None)``.
|
||||
``load()`` imports the manager class and returns a ``session_id -> manager`` factory."""
|
||||
try:
|
||||
@@ -83,7 +83,7 @@ class GatewayGoalsMixin:
|
||||
return None, None
|
||||
return factory(session_entry.session_id), session_entry
|
||||
|
||||
async def _get_goal_manager_for_event(self, event: "MessageEvent"):
|
||||
async def _get_goal_manager_for_event(self, event: MessageEvent):
|
||||
"""Return ``(GoalManager, session_entry)`` for this event, or ``(None, None)``."""
|
||||
def _load():
|
||||
from hermes_cli.goals import GoalManager
|
||||
@@ -91,7 +91,7 @@ class GatewayGoalsMixin:
|
||||
return lambda sid: GoalManager(session_id=sid, default_max_turns=max_turns)
|
||||
return await self._manager_for_event(event, "goal", _load)
|
||||
|
||||
async def _get_heartbeat_manager_for_event(self, event: "MessageEvent"):
|
||||
async def _get_heartbeat_manager_for_event(self, event: MessageEvent):
|
||||
"""Return ``(HeartbeatManager, session_entry)`` for this event, or ``(None, None)``."""
|
||||
def _load():
|
||||
from hermes_cli.heartbeat import HeartbeatManager
|
||||
|
||||
+29
-29
@@ -71,8 +71,8 @@ class GatewayInboundMixin(GatewayPluginInjectionMixin):
|
||||
"""Inbound message pipeline (_handle_message, text/media preparation, durable-turn markers, plugin injection) for GatewayRunner."""
|
||||
|
||||
async def _hm_pre_gateway_dispatch_hook(
|
||||
self, event: "MessageEvent", source: SessionSource
|
||||
) -> Optional["MessageEvent"]:
|
||||
self, event: MessageEvent, source: SessionSource
|
||||
) -> Optional[MessageEvent]:
|
||||
"""Run the ``pre_gateway_dispatch`` plugin hook; None = drop, else the (maybe rewritten) event.
|
||||
Results: ``{"action": "skip"}`` → drop; ``{"action": "rewrite", "text"}`` → replace ``event.text``;
|
||||
``allow``/None → normal dispatch. Runs BEFORE auth so plugins can handle unauthorized senders."""
|
||||
@@ -165,8 +165,8 @@ class GatewayInboundMixin(GatewayPluginInjectionMixin):
|
||||
await notifier.notify(self, source, hint)
|
||||
|
||||
async def _hm_admit_event(
|
||||
self, event: "MessageEvent"
|
||||
) -> Optional[tuple["MessageEvent", SessionSource, bool]]:
|
||||
self, event: MessageEvent
|
||||
) -> Optional[tuple[MessageEvent, SessionSource, bool]]:
|
||||
"""Ingress gates for ``_handle_message``; None when dropped, else ``(event, source, is_internal)``
|
||||
(the ``pre_gateway_dispatch`` hook may have rewritten ``event``)."""
|
||||
from gateway.run import _is_slack_ignored_channel
|
||||
@@ -260,7 +260,7 @@ class GatewayInboundMixin(GatewayPluginInjectionMixin):
|
||||
return None
|
||||
return event, source, False
|
||||
|
||||
def _hm_estop_turn_allowed(self, event: "MessageEvent", source: SessionSource) -> bool:
|
||||
def _hm_estop_turn_allowed(self, event: MessageEvent, source: SessionSource) -> bool:
|
||||
"""Whether a turn may bypass the global emergency stop: pause blocks NEW agent turns, never
|
||||
running work or control traffic — recognized slash commands (incl. /pause off, the in-band
|
||||
resume) and replies owned by in-flight work (pending update prompt, running session,
|
||||
@@ -288,7 +288,7 @@ class GatewayInboundMixin(GatewayPluginInjectionMixin):
|
||||
return False
|
||||
|
||||
def _hm_estop_gate(
|
||||
self, event: "MessageEvent", source: SessionSource, is_internal: bool
|
||||
self, event: MessageEvent, source: SessionSource, is_internal: bool
|
||||
) -> Optional[str]:
|
||||
"""Global emergency-stop (`hermes pause`) notice when this turn must be blocked, else None.
|
||||
Placed after auth so unauthorized senders can't probe pause state. A synthetic heartbeat that
|
||||
@@ -327,7 +327,7 @@ class GatewayInboundMixin(GatewayPluginInjectionMixin):
|
||||
return str(e)
|
||||
return None
|
||||
|
||||
def _hm_update_prompt_reply(self, event: "MessageEvent", _quick_key: str) -> Optional[str]:
|
||||
def _hm_update_prompt_reply(self, event: MessageEvent, _quick_key: str) -> Optional[str]:
|
||||
"""Consume a reply to a pending ``/update`` prompt (routed to the detached update process via
|
||||
``.update_response``); None when nothing was consumed. Recognized slash commands must bypass
|
||||
this or /new, /help etc. get silently consumed as update answers."""
|
||||
@@ -373,7 +373,7 @@ class GatewayInboundMixin(GatewayPluginInjectionMixin):
|
||||
return None
|
||||
|
||||
async def _hm_clarify_reply(
|
||||
self, event: "MessageEvent", source: SessionSource, _quick_key: str
|
||||
self, event: MessageEvent, source: SessionSource, _quick_key: str
|
||||
) -> Optional[str]:
|
||||
"""Intercept a reply to a pending clarify prompt; None when the message falls through.
|
||||
Free text answers open-ended/"Other" prompts; "2" answers a multi-choice one. Resolved/retained
|
||||
@@ -468,7 +468,7 @@ class GatewayInboundMixin(GatewayPluginInjectionMixin):
|
||||
choices.setdefault(word, choice)
|
||||
return choices
|
||||
|
||||
async def _hm_slash_confirm_reply(self, event: "MessageEvent", _quick_key: str) -> Optional[str]:
|
||||
async def _hm_slash_confirm_reply(self, event: MessageEvent, _quick_key: str) -> Optional[str]:
|
||||
"""Resolve a reply (/approve, /always, /cancel + aliases) to a pending slash-confirm prompt;
|
||||
None when it falls through — a stale pending confirm does NOT block other commands. A pending
|
||||
dangerous-command approval takes precedence: /approve there unblocks the waiting tool thread."""
|
||||
@@ -581,7 +581,7 @@ class GatewayInboundMixin(GatewayPluginInjectionMixin):
|
||||
self._drop_turn_slot(_quick_key, run_generation=_generation_at_interrupt)
|
||||
|
||||
def _hm_merge_pending_for_source(
|
||||
self, source: SessionSource, _quick_key: str, event: "MessageEvent", *, merge_text: bool = False
|
||||
self, source: SessionSource, _quick_key: str, event: MessageEvent, *, merge_text: bool = False
|
||||
) -> None:
|
||||
"""Merge *event* into the source adapter's pending slot (no-op without an adapter)."""
|
||||
from gateway.platforms.base import merge_pending_message_event
|
||||
@@ -590,7 +590,7 @@ class GatewayInboundMixin(GatewayPluginInjectionMixin):
|
||||
merge_pending_message_event(adapter._pending_messages, _quick_key, event, merge_text=merge_text)
|
||||
|
||||
async def _hm_busy_slash_or_photo(
|
||||
self, event: "MessageEvent", source: SessionSource, _quick_key: str
|
||||
self, event: MessageEvent, source: SessionSource, _quick_key: str
|
||||
) -> tuple[bool, Optional[str]]:
|
||||
"""Slash-command / photo-burst handling on the busy fast-path → ``(handled, result)``. Each
|
||||
command's mid-run behavior is declared on its CommandDef (busy_policy / busy_handler)."""
|
||||
@@ -622,7 +622,7 @@ class GatewayInboundMixin(GatewayPluginInjectionMixin):
|
||||
return False, None
|
||||
|
||||
def _hm_busy_telegram_grace_queue(
|
||||
self, event: "MessageEvent", source: SessionSource, _quick_key: str, effective_busy_input_mode: str
|
||||
self, event: MessageEvent, source: SessionSource, _quick_key: str, effective_busy_input_mode: str
|
||||
) -> bool:
|
||||
"""Queue a Telegram text follow-up that lands within the post-start grace window."""
|
||||
_grace = float(os.getenv("HERMES_TELEGRAM_FOLLOWUP_GRACE_SECONDS", "3.0"))
|
||||
@@ -646,10 +646,10 @@ class GatewayInboundMixin(GatewayPluginInjectionMixin):
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _hm_text_only(event: "MessageEvent") -> bool:
|
||||
def _hm_text_only(event: MessageEvent) -> bool:
|
||||
return event.message_type == MessageType.TEXT and not event.media_urls and not event.media_types
|
||||
|
||||
def _hm_busy_steer(self, event: "MessageEvent", running_agent: Any, _quick_key: str) -> None:
|
||||
def _hm_busy_steer(self, event: MessageEvent, running_agent: Any, _quick_key: str) -> None:
|
||||
"""Steer mode: inject text mid-run via ``agent.steer()``, else fall back to queue semantics."""
|
||||
steer_text = (event.text or "").strip()
|
||||
steered = False
|
||||
@@ -666,7 +666,7 @@ class GatewayInboundMixin(GatewayPluginInjectionMixin):
|
||||
self._queue_or_replace_pending_event(_quick_key, event)
|
||||
|
||||
async def _hm_busy_interrupt(
|
||||
self, event: "MessageEvent", source: SessionSource, running_agent: Any, _quick_key: str
|
||||
self, event: MessageEvent, source: SessionSource, running_agent: Any, _quick_key: str
|
||||
) -> None:
|
||||
"""Interrupt path: redirect text-only corrections when supported, else ``agent.interrupt()``."""
|
||||
from gateway.run import _build_media_placeholder
|
||||
@@ -691,7 +691,7 @@ class GatewayInboundMixin(GatewayPluginInjectionMixin):
|
||||
running_agent.interrupt(_interrupt_text)
|
||||
|
||||
async def _hm_handle_running_session_message(
|
||||
self, event: "MessageEvent", source: SessionSource, _quick_key: str
|
||||
self, event: MessageEvent, source: SessionSource, _quick_key: str
|
||||
) -> Optional[str]:
|
||||
"""Fast-path while this session's agent is running: interrupt by default (minimal latency);
|
||||
busy_input_mode queue/steer, subagent and compression protection demote to queue."""
|
||||
@@ -751,7 +751,7 @@ class GatewayInboundMixin(GatewayPluginInjectionMixin):
|
||||
return qc if isinstance(qc, dict) else {}
|
||||
|
||||
@staticmethod
|
||||
def _hm_expand_alias_quick_command(event: "MessageEvent", qcmd: dict) -> Optional[str]:
|
||||
def _hm_expand_alias_quick_command(event: MessageEvent, qcmd: dict) -> Optional[str]:
|
||||
"""Rewrite ``event.text`` to an alias quick command's target; returns the new command name."""
|
||||
target = (qcmd.get("target") or "").strip()
|
||||
if not target:
|
||||
@@ -762,7 +762,7 @@ class GatewayInboundMixin(GatewayPluginInjectionMixin):
|
||||
return target_command.split()[0] if target_command else target_command
|
||||
|
||||
async def _hm_command_hooks(
|
||||
self, event: "MessageEvent", source: SessionSource, _quick_key: str, command: str, canonical: str
|
||||
self, event: MessageEvent, source: SessionSource, _quick_key: str, command: str, canonical: str
|
||||
) -> tuple[bool, Optional[str], Optional[str]]:
|
||||
"""Fire ``pre_command`` (observer) and ``command:<canonical>`` (interceptor) hooks →
|
||||
``(handled, result, new_command)`` (``new_command`` set when a handler rewrote the command).
|
||||
@@ -809,7 +809,7 @@ class GatewayInboundMixin(GatewayPluginInjectionMixin):
|
||||
return False, None, None
|
||||
|
||||
async def _hm_resolve_command(
|
||||
self, event: "MessageEvent", source: SessionSource, _quick_key: str
|
||||
self, event: MessageEvent, source: SessionSource, _quick_key: str
|
||||
) -> tuple[bool, Optional[str], Optional[str], Optional[str]]:
|
||||
"""Resolve the slash command (aliases, access gate, hooks) → ``(handled, result, command,
|
||||
canonical)``; when ``handled`` the caller returns ``result`` as-is (may be None)."""
|
||||
@@ -1008,7 +1008,7 @@ class GatewayInboundMixin(GatewayPluginInjectionMixin):
|
||||
})
|
||||
|
||||
async def _hm_dispatch_canonical_command(
|
||||
self, event: "MessageEvent", source: SessionSource, _quick_key: str,
|
||||
self, event: MessageEvent, source: SessionSource, _quick_key: str,
|
||||
canonical: Optional[str],
|
||||
) -> tuple[bool, Optional[str]]:
|
||||
"""Dispatch built-in idle-path commands → ``(handled, result)``; prompt-rewriting commands
|
||||
@@ -1045,7 +1045,7 @@ class GatewayInboundMixin(GatewayPluginInjectionMixin):
|
||||
return t("gateway.quick_command.error", error=e)
|
||||
|
||||
async def _hm_dispatch_quick_and_plugin_commands(
|
||||
self, event: "MessageEvent", source: SessionSource, command: Optional[str]
|
||||
self, event: MessageEvent, source: SessionSource, command: Optional[str]
|
||||
) -> tuple[bool, Optional[str], Optional[str]]:
|
||||
"""Drain gate, user-defined quick commands (exec/alias) and plugin slash commands →
|
||||
``(handled, result, command)``; an alias quick command rewrites ``command``."""
|
||||
@@ -1105,7 +1105,7 @@ class GatewayInboundMixin(GatewayPluginInjectionMixin):
|
||||
return False, None, command
|
||||
|
||||
def _hm_bundle_slash_rewrite(
|
||||
self, event: "MessageEvent", source: SessionSource, _quick_key: str, command: str
|
||||
self, event: MessageEvent, source: SessionSource, _quick_key: str, command: str
|
||||
) -> bool:
|
||||
"""Rewrite ``/<bundle>`` to the bundle invocation message; True when handled.
|
||||
Skill bundles take precedence over individual skill commands (mirrors CLI dispatch)."""
|
||||
@@ -1156,7 +1156,7 @@ class GatewayInboundMixin(GatewayPluginInjectionMixin):
|
||||
return t("gateway.unknown_command", command=command)
|
||||
|
||||
def _hm_skill_slash_rewrite(
|
||||
self, event: "MessageEvent", source: SessionSource, _quick_key: str, command: Optional[str]
|
||||
self, event: MessageEvent, source: SessionSource, _quick_key: str, command: Optional[str]
|
||||
) -> Optional[str]:
|
||||
"""Rewrite ``/<bundle>`` / ``/<skill>`` invocations into the skill prompt on ``event.text``;
|
||||
returns a reply string when the command is disabled/unknown/failed, else None.
|
||||
@@ -1217,7 +1217,7 @@ class GatewayInboundMixin(GatewayPluginInjectionMixin):
|
||||
return None
|
||||
|
||||
async def _hm_pending_reply_intercepts(
|
||||
self, event: "MessageEvent", source: SessionSource, _quick_key: str
|
||||
self, event: MessageEvent, source: SessionSource, _quick_key: str
|
||||
) -> Optional[str]:
|
||||
"""Replies owned by in-flight work: pending /update prompt, clarify, slash-confirm.
|
||||
Only events that may control the gateway (``allow_gateway_control``) can answer them."""
|
||||
@@ -1231,7 +1231,7 @@ class GatewayInboundMixin(GatewayPluginInjectionMixin):
|
||||
return _reply
|
||||
|
||||
async def _hm_dispatch_idle_commands(
|
||||
self, event: "MessageEvent", source: SessionSource, _quick_key: str
|
||||
self, event: MessageEvent, source: SessionSource, _quick_key: str
|
||||
) -> tuple[bool, Optional[str]]:
|
||||
"""Idle path: resolve + dispatch slash commands; rewriting commands fall through to the agent."""
|
||||
_handled, _result, command, canonical = await self._hm_resolve_command(event, source, _quick_key)
|
||||
@@ -1250,8 +1250,8 @@ class GatewayInboundMixin(GatewayPluginInjectionMixin):
|
||||
return _handled, _result
|
||||
|
||||
def _hm_rescue_orphaned_fifo(
|
||||
self, event: "MessageEvent", source: SessionSource, is_internal: bool, _quick_key: str
|
||||
) -> tuple["MessageEvent", SessionSource, bool]:
|
||||
self, event: MessageEvent, source: SessionSource, is_internal: bool, _quick_key: str
|
||||
) -> tuple[MessageEvent, SessionSource, bool]:
|
||||
"""FIFO orphan rescue: a session that went idle with a populated overflow (post-turn drain
|
||||
never promoted, e.g. a compression-demoted follow-up) silently orphaned those events. The
|
||||
oldest orphan runs as THIS turn and the incoming event is parked behind the chain. Skipped
|
||||
@@ -1765,7 +1765,7 @@ class GatewayInboundMixin(GatewayPluginInjectionMixin):
|
||||
state.persistent.native_image_paths = []
|
||||
return paths
|
||||
|
||||
async def _mark_durable_active_turn(self, event: "MessageEvent", session_key: str) -> bool:
|
||||
async def _mark_durable_active_turn(self, event: MessageEvent, session_key: str) -> bool:
|
||||
"""Persist the exact resolved routing key for this running turn."""
|
||||
try:
|
||||
token = await self.async_session_store.mark_turn_active(session_key)
|
||||
@@ -1780,7 +1780,7 @@ class GatewayInboundMixin(GatewayPluginInjectionMixin):
|
||||
event._gateway_active_turn_token = token
|
||||
return True
|
||||
|
||||
async def _clear_durable_active_turn(self, event: "MessageEvent") -> bool:
|
||||
async def _clear_durable_active_turn(self, event: MessageEvent) -> bool:
|
||||
"""Best-effort CAS clear of the marker owned by *event* (3 attempts; never blocks agent/lease
|
||||
release — a stale marker is bounded by the agent timeout and clean-start discard)."""
|
||||
session_key = getattr(event, "_gateway_active_turn_session_key", None)
|
||||
|
||||
@@ -519,7 +519,7 @@ class GatewayNotificationsMixin:
|
||||
logger.debug("Skipping update notification watcher: no running event loop")
|
||||
|
||||
@classmethod
|
||||
def _update_paths(cls) -> "GatewayNotificationsMixin._UpdatePaths":
|
||||
def _update_paths(cls) -> GatewayNotificationsMixin._UpdatePaths:
|
||||
from gateway.run import _hermes_home
|
||||
return cls._UpdatePaths(
|
||||
pending=_hermes_home / ".update_pending.json",
|
||||
@@ -560,7 +560,7 @@ class GatewayNotificationsMixin:
|
||||
now = datetime.now(stamped.tzinfo) if stamped.tzinfo else datetime.now()
|
||||
return (now - stamped).total_seconds()
|
||||
|
||||
def _resolve_update_target(self, paths: "_UpdatePaths") -> Optional["_UpdateTarget"]:
|
||||
def _resolve_update_target(self, paths: _UpdatePaths) -> Optional[_UpdateTarget]:
|
||||
"""Resolve adapter/chat/session for update watcher messages from the pending marker."""
|
||||
for path in (paths.claimed, paths.pending):
|
||||
if not path.exists():
|
||||
@@ -592,7 +592,7 @@ class GatewayNotificationsMixin:
|
||||
reply_to_message_id=data.get("message_id"), adapter=adapter,
|
||||
)
|
||||
|
||||
async def _watch_update_completion_only(self, paths: "_UpdatePaths", deadline: float, poll_interval: float) -> None:
|
||||
async def _watch_update_completion_only(self, paths: _UpdatePaths, deadline: float, poll_interval: float) -> None:
|
||||
"""Fallback when no adapter/chat can be resolved: wait for the exit code, then notify."""
|
||||
logger.warning("Update watcher: cannot resolve adapter/chat_id, falling back to completion-only")
|
||||
# Poll until _send_update_notification delivers (it returns False while the platform reconnects).
|
||||
@@ -606,7 +606,7 @@ class GatewayNotificationsMixin:
|
||||
await self._send_update_notification()
|
||||
|
||||
@staticmethod
|
||||
def _update_exit_code(paths: "_UpdatePaths") -> int:
|
||||
def _update_exit_code(paths: _UpdatePaths) -> int:
|
||||
return int(paths.exit_code.read_text(encoding="utf-8-sig").strip() or "1")
|
||||
|
||||
@staticmethod
|
||||
@@ -620,7 +620,7 @@ class GatewayNotificationsMixin:
|
||||
return "", len(data)
|
||||
return data[offset:].decode("utf-8", errors="replace"), len(data)
|
||||
|
||||
async def _send_update_output(self, target: "_UpdateTarget", text: str) -> None:
|
||||
async def _send_update_output(self, target: _UpdateTarget, text: str) -> None:
|
||||
"""Send buffered update output as fenced chunks that fit message limits (Telegram: 4096)."""
|
||||
from tools.ansi_strip import strip_ansi
|
||||
clean = strip_ansi(text).strip()
|
||||
@@ -631,7 +631,7 @@ class GatewayNotificationsMixin:
|
||||
with _log_suppressed(logging.DEBUG, "Update stream send failed: %s"):
|
||||
await target.send(f"```\n{clean[i:i + max_chunk]}\n```")
|
||||
|
||||
async def _forward_update_prompt(self, target: "_UpdateTarget", prompt_text: str, default: str) -> None:
|
||||
async def _forward_update_prompt(self, target: _UpdateTarget, prompt_text: str, default: str) -> None:
|
||||
"""Forward an update prompt: platform-native buttons first (Discord, Telegram), else text."""
|
||||
sent_buttons = False
|
||||
adapter = target.adapter
|
||||
@@ -650,7 +650,7 @@ class GatewayNotificationsMixin:
|
||||
self._session_state(target.session_key).persistent.update_prompt_pending = True
|
||||
logger.info("Forwarded update prompt to %s: %s", target.session_key, prompt_text[:80])
|
||||
|
||||
def _clear_update_markers(self, paths: "_UpdatePaths", session_key: Optional[str]) -> None:
|
||||
def _clear_update_markers(self, paths: _UpdatePaths, session_key: Optional[str]) -> None:
|
||||
paths.unlink_all()
|
||||
state = self._peek_session_state(session_key)
|
||||
if state is not None:
|
||||
@@ -1480,7 +1480,7 @@ class GatewayNotificationsMixin:
|
||||
return False
|
||||
return True
|
||||
|
||||
async def _preflight_completion_delivery(self, evt: dict) -> "_CompletionClaim":
|
||||
async def _preflight_completion_delivery(self, evt: dict) -> _CompletionClaim:
|
||||
"""Claim the durable row (async delegations) and verify the target before adapter acceptance.
|
||||
|
||||
Adapter acceptance is not proof of delivery: the inner resolver can still fail closed inside
|
||||
|
||||
@@ -34,7 +34,7 @@ _PROFILE_SIGNATURE_FILES = ("config.yaml", ".env")
|
||||
_OWN_GATEWAY_PROBE_TIMEOUT_SECS = 5.0
|
||||
|
||||
|
||||
def profile_serve_signature(home: "Path") -> tuple:
|
||||
def profile_serve_signature(home: Path) -> tuple:
|
||||
"""Cheap change detector for a served profile's credentials/config: file signature per file."""
|
||||
sig = []
|
||||
for name in _PROFILE_SIGNATURE_FILES:
|
||||
@@ -49,7 +49,7 @@ def profile_serve_signature(home: "Path") -> tuple:
|
||||
class GatewayProfileReconcileMixin:
|
||||
"""Runtime reconciliation of the multiplexed served-profile set (hot add / unroute / credential-add)."""
|
||||
|
||||
_served_profile_homes: Optional[dict[str, "Path"]] = None
|
||||
_served_profile_homes: Optional[dict[str, Path]] = None
|
||||
_served_profile_signatures: Optional[dict[str, tuple]] = None
|
||||
_profile_reconcile_lock: Optional[asyncio.Lock] = None
|
||||
_profile_own_gateway_warned: Optional[set[str]] = None
|
||||
@@ -239,7 +239,7 @@ class GatewayProfileReconcileMixin:
|
||||
except Exception:
|
||||
logger.warning("MCP tool discovery failed for profile '%s'", profile_name, exc_info=True)
|
||||
|
||||
async def _unserve_profile(self, name: str, home: "Path") -> None:
|
||||
async def _unserve_profile(self, name: str, home: Path) -> None:
|
||||
"""Stop and unroute one profile: cancel its reconnects, tear down its adapters, drop its
|
||||
bookkeeping and release this process's handles into its home so the deleter's rmtree succeeds.
|
||||
The releasing process is not always the deleter (#130244): under multiplexing THIS gateway
|
||||
|
||||
Loaded 100 of 388 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user