From a166aa73bb372c57209e26d2a190375585bed67b Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:43:40 -0700 Subject: [PATCH 01/22] refactor(gateway/platforms): split _handle_runs, unify run teardown/terminal-event helpers, dedupe room-grant target checks and idempotency txn --- gateway/platforms/api_server_room_dispatch.py | 30 +- gateway/platforms/api_server_room_grants.py | 172 +++----- .../platforms/api_server_run_idempotency.py | 131 +++--- gateway/platforms/api_server_runs.py | 400 +++++++++--------- 4 files changed, 319 insertions(+), 414 deletions(-) diff --git a/gateway/platforms/api_server_room_dispatch.py b/gateway/platforms/api_server_room_dispatch.py index 957c4cedd3..a7623b2b7c 100644 --- a/gateway/platforms/api_server_room_dispatch.py +++ b/gateway/platforms/api_server_room_dispatch.py @@ -33,9 +33,7 @@ async def _ensure_hosted_member_session(self, dispatch: Any) -> str: session_id = f"room_{hashlib.sha256(seed.encode()).hexdigest()[:32]}" def atomic(conn): - row = conn.execute( - "SELECT id, title, source FROM sessions WHERE id=?", (session_id,) - ).fetchone() + row = conn.execute("SELECT id, title, source FROM sessions WHERE id=?", (session_id,)).fetchone() if row is not None: if row["title"] != title or row["source"] != "bot_room": raise RuntimeError("room session identity conflicts with existing data") @@ -60,7 +58,7 @@ async def _ensure_hosted_member_session(self, dispatch: Any) -> str: def _room_dispatch_error(exc: Exception, *, _openai_error) -> "web.Response": - message = str(exc) + message, code = str(exc), "invalid_room_dispatch" lowered = message.lower() if "execution policy" in lowered or "remote room execution requires" in lowered: message = "Room execution policy changed; reauthorization is required." @@ -68,26 +66,17 @@ def _room_dispatch_error(exc: Exception, *, _openai_error) -> "web.Response": elif "capability catalog changed" in lowered: message = "Room capability catalog changed; reauthorization is required." code = "room_capability_catalog_changed" - else: - code = "invalid_room_dispatch" return _json_error(_openai_error, message, code=code, status=403) async def _normalize_room_dispatch( - self, - request: "web.Request", - body: Any, - *, - _api_server, + self, request: "web.Request", body: Any, *, _api_server ) -> tuple[Any, "web.Response | None"]: """Validate and normalize a scoped RoomLink dispatch request.""" - _api_request_profile = _api_server._api_request_profile _openai_error = _api_server._openai_error - room_token = self._room_grant_token(request) if not room_token: return body, None - if not isinstance(body, dict) or set(body) - {"input", "hosted_room_dispatch"}: return body, _json_error( _openai_error, "Room dispatch accepts only input and hosted_room_dispatch.", @@ -95,22 +84,15 @@ async def _normalize_room_dispatch( ) try: from gateway import hosted_rooms - from gateway.hosted_room_peer import ( - GatewayRoomCatalog, - HostedMemberDispatch, - verify_room_grant, - ) + from gateway.hosted_room_peer import GatewayRoomCatalog, HostedMemberDispatch, verify_room_grant from gateway.hosted_room_execution_policy import RoomExecutionPolicy from gateway.platforms.api_server_room_grants import _local_room_catalog dispatch = HostedMemberDispatch.from_mapping(body.get("hosted_room_dispatch")) verify_room_grant(self._room_grant_secret(), room_token, dispatch, permission="dispatch") - active_profile = _api_request_profile.get() or "default" + active_profile = _api_server._api_request_profile.get() or "default" local_install = hosted_rooms.local_authority_gateway_id() - if ( - dispatch.target_profile != active_profile - or dispatch.target_install_id != local_install - ): + if dispatch.target_profile != active_profile or dispatch.target_install_id != local_install: raise ValueError("room dispatch target does not match this profile") _, catalog_map = _local_room_catalog(self, active_profile, local_install) catalog = GatewayRoomCatalog.from_mapping(catalog_map) diff --git a/gateway/platforms/api_server_room_grants.py b/gateway/platforms/api_server_room_grants.py index 43c0910263..8896430bc3 100644 --- a/gateway/platforms/api_server_room_grants.py +++ b/gateway/platforms/api_server_room_grants.py @@ -19,25 +19,25 @@ def _json_error(_openai_error, message: str, *, status: int, **error_kwargs) -> return web.json_response(_openai_error(message, **error_kwargs), status=status) -def _require_unchanged_execution_policy( - claims: dict[str, Any], - execution_policy: dict[str, Any], -) -> None: +def _require_unchanged_execution_policy(claims: dict[str, Any], execution_policy: dict[str, Any]) -> None: """Keep renewal from silently granting a changed execution policy.""" - if str(execution_policy.get("policy_digest") or "") != str( - claims.get("execution_policy_digest") or "" - ): + if str(execution_policy.get("policy_digest") or "") != str(claims.get("execution_policy_digest") or ""): raise RoomGrantReauthorizationRequired("room execution policy changed") -def _room_grant_error_response(exc: Exception, *, _openai_error) -> "web.Response": - reauth = isinstance(exc, RoomGrantReauthorizationRequired) +def _invalid_room_grant(_openai_error) -> "web.Response": return _json_error( - _openai_error, - "Room authorization needs to be renewed." if reauth else "Room authorization is invalid or expired.", - err_type="gateway_auth_error", - code="room_reauthorization_required" if reauth else "invalid_room_grant", - status=403 if reauth else 401, + _openai_error, "Room authorization is invalid or expired.", + err_type="gateway_auth_error", code="invalid_room_grant", status=401, + ) + + +def _room_grant_error_response(exc: Exception, *, _openai_error) -> "web.Response": + if not isinstance(exc, RoomGrantReauthorizationRequired): + return _invalid_room_grant(_openai_error) + return _json_error( + _openai_error, "Room authorization needs to be renewed.", + err_type="gateway_auth_error", code="room_reauthorization_required", status=403, ) @@ -45,9 +45,17 @@ def _hard_expiry(claims: dict[str, Any]) -> float: return float(claims.get("status_expires_at", claims["expires_at"])) -def _require_local_target(claims: dict[str, Any], profile: str, installation_id: str) -> None: - if claims["target_profile"] != profile or claims["target_install_id"] != installation_id: +def _local_target(claims: dict[str, Any] | None, _api_request_profile) -> tuple[str, str]: + """Return ``(profile, installation_id)`` for this gateway; *claims* must target it.""" + from gateway import hosted_rooms + + profile = _api_request_profile.get() or "default" + installation_id = hosted_rooms.local_authority_gateway_id() + if claims is not None and ( + claims["target_profile"] != profile or claims["target_install_id"] != installation_id + ): raise ValueError("room grant target does not match this profile") + return profile, installation_id def _local_room_catalog(self, profile: str, installation_id: str) -> tuple[dict, dict]: @@ -58,13 +66,8 @@ def _local_room_catalog(self, profile: str, installation_id: str) -> tuple[dict, with self._profile_scope(profile): execution_policy = execution_policy_mapping(target_profile=profile) catalog = catalog_mapping( - installation_id=installation_id, - protocol_versions=(PROTOCOL_VERSION,), - link_modes=("direct",), - persistent_process=True, - text=True, - attachments=False, - target_profile=profile, + installation_id=installation_id, protocol_versions=(PROTOCOL_VERSION,), link_modes=("direct",), + persistent_process=True, text=True, attachments=False, target_profile=profile, execution_policy=execution_policy, ) return execution_policy, catalog @@ -93,13 +96,18 @@ def _room_grant_secret(self) -> bytes: return gateway_room_grant_secret() -def _room_grant_claims(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) if not token: raise ValueError("room grant is missing") - claims = decode_room_grant(self._room_grant_secret(), token, permission=permission) + return decode_room_grant(self._room_grant_secret(), token, permission=permission) + + +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() @@ -111,11 +119,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, + 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) @@ -135,16 +139,13 @@ async def _handle_room_member_invitation( from gateway import hosted_rooms from gateway.hosted_room_peer import decode_room_grant, issue_room_grant - profile = _api_request_profile.get() or "default" - target_install_id = hosted_rooms.local_authority_gateway_id() + profile, target_install_id = _local_target(None, _api_request_profile) ttl = float(body.get("ttl_seconds", 3600)) if not 60 <= ttl <= 24 * 60 * 60: raise ValueError("ttl_seconds must be between 60 and 86400") status_ttl = float(body.get("status_ttl_seconds", ttl)) if not ttl <= status_ttl <= 30 * 24 * 60 * 60: - raise ValueError( - "status_ttl_seconds must be at least ttl_seconds and no more than 2592000" - ) + raise ValueError("status_ttl_seconds must be at least ttl_seconds and no more than 2592000") execution_policy, catalog = _local_room_catalog(self, profile, target_install_id) token = issue_room_grant( self._room_grant_secret(), @@ -167,57 +168,40 @@ async def _handle_room_member_invitation( ) except Exception as exc: return _json_error(_openai_error, str(exc), code="invalid_room_invitation", status=400) - return web.json_response( - { - "object": "hermes.room_member.invitation", - "grant": token, - "target_profile": profile, - "catalog": catalog, - "expires_at": float(claims["expires_at"]), - "status_expires_at": float(claims["status_expires_at"]), - }, - status=201, - ) + return web.json_response({ + "object": "hermes.room_member.invitation", + "grant": token, + "target_profile": profile, + "catalog": catalog, + "expires_at": float(claims["expires_at"]), + "status_expires_at": float(claims["status_expires_at"]), + }, status=201) async def _handle_room_member_capabilities( - self, - request: "web.Request", - *, - _openai_error, - _api_request_profile, + self, request: "web.Request", *, _openai_error, _api_request_profile ) -> "web.Response": """Verify a scoped grant and return this target's live room catalog.""" try: - from gateway import hosted_rooms - claims = self._room_grant_claims(request, permission="status") - profile = _api_request_profile.get() or "default" - installation_id = hosted_rooms.local_authority_gateway_id() - _require_local_target(claims, profile, installation_id) + profile, installation_id = _local_target(claims, _api_request_profile) _, catalog = _local_room_catalog(self, profile, installation_id) except Exception as exc: return _room_grant_error_response(exc, _openai_error=_openai_error) - return web.json_response( - { - "object": "hermes.room_member.capabilities", - "room_id": claims["room_id"], - "home_install_id": claims["home_install_id"], - "authority_gateway_id": claims["authority_gateway_id"], - "authority_epoch": claims["authority_epoch"], - "member_id": claims["member_id"], - "target_profile": profile, - "catalog": catalog, - } - ) + return web.json_response({ + "object": "hermes.room_member.capabilities", + "room_id": claims["room_id"], + "home_install_id": claims["home_install_id"], + "authority_gateway_id": claims["authority_gateway_id"], + "authority_epoch": claims["authority_epoch"], + "member_id": claims["member_id"], + "target_profile": profile, + "catalog": catalog, + }) async def _handle_room_member_grant_refresh( - self, - request: "web.Request", - *, - _openai_error, - _api_request_profile, + 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) @@ -229,16 +213,13 @@ async def _handle_room_member_grant_refresh( code="invalid_room_grant_refresh", status=400, ) try: - from gateway import hosted_rooms from gateway.hosted_room_peer import MAX_DISPATCH_GRANT_TTL_SECONDS, issue_room_grant from gateway.hosted_room_execution_policy import execution_policy_mapping # A status-only bearer may observe a run but must never mint new # dispatch authority: renewal requires a still-live dispatch permission. claims = self._room_grant_claims(request, permission="dispatch") - profile = _api_request_profile.get() or "default" - installation_id = hosted_rooms.local_authority_gateway_id() - _require_local_target(claims, profile, installation_id) + profile, installation_id = _local_target(claims, _api_request_profile) now = time.time() hard_expiry = _hard_expiry(claims) remaining = hard_expiry - now @@ -267,23 +248,17 @@ async def _handle_room_member_grant_refresh( ) except Exception as exc: return _room_grant_error_response(exc, _openai_error=_openai_error) - return web.json_response( - { - "object": "hermes.room_member.grant", - "grant": token, - "expires_at": now + dispatch_ttl, - "status_expires_at": hard_expiry, - "execution_policy": execution_policy, - } - ) + return web.json_response({ + "object": "hermes.room_member.grant", + "grant": token, + "expires_at": now + dispatch_ttl, + "status_expires_at": hard_expiry, + "execution_policy": execution_policy, + }) async def _handle_room_member_grant_revoke( - self, - request: "web.Request", - *, - _openai_error, - _api_request_profile, + 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) @@ -296,24 +271,15 @@ async def _handle_room_member_grant_revoke( ) try: from gateway import hosted_rooms - from gateway.hosted_room_peer import decode_room_grant - token = self._room_grant_token(request) - if not token: - raise ValueError("room grant is missing") # Revoke is idempotent: a response-lost retry may authenticate with the # grant just denylisted, so verify signature/scope/horizon directly # (not via _room_grant_claims) and upsert the same grant id. - claims = decode_room_grant(self._room_grant_secret(), token, permission="status") - profile = _api_request_profile.get() or "default" - installation_id = hosted_rooms.local_authority_gateway_id() - _require_local_target(claims, profile, installation_id) + claims = _decode_request_grant(self, request, permission="status") + _local_target(claims, _api_request_profile) hosted_rooms.revoke_room_grant_scope( hosted_rooms.default_db_path(), claims=claims, expires_at=_hard_expiry(claims) ) except Exception: - return _json_error( - _openai_error, "Room authorization is invalid or expired.", - err_type="gateway_auth_error", code="invalid_room_grant", status=401, - ) + return _invalid_room_grant(_openai_error) return web.json_response({"object": "hermes.room_member.grant.revocation", "revoked": True}) diff --git a/gateway/platforms/api_server_run_idempotency.py b/gateway/platforms/api_server_run_idempotency.py index d39bfb0128..b1e204fa22 100644 --- a/gateway/platforms/api_server_run_idempotency.py +++ b/gateway/platforms/api_server_run_idempotency.py @@ -6,6 +6,7 @@ import logging import sqlite3 import threading import time +from contextlib import contextmanager from pathlib import Path from typing import Any, Dict @@ -50,6 +51,11 @@ def _record(run_id, status_json, owner_pid, owner_started, updated_at) -> dict[s } +def _outcome(row, fingerprint): + """Classify a stored ``(scope, key)`` row against the caller's fingerprint.""" + return ("reused" if hmac.compare_digest(row[0], fingerprint) else "conflict"), _record(*row[1:]) + + class RunIdempotencyStore: """Durable, tenant-scoped reservations for ``POST /v1/runs``. @@ -127,78 +133,58 @@ class RunIdempotencyStore: except OSError: logger.debug("Failed to restrict run idempotency store permissions", exc_info=True) - def reserve( - self, - scope: str, - key: str, - fingerprint: str, - run_id: str, - status: Dict[str, Any], - *, - owner_pid: int = 0, - owner_started: int = 0, - retention_until: float = 0, - ): + @contextmanager + def _immediate_txn(self): + """Hold the lock inside ``BEGIN IMMEDIATE``; the body commits, errors roll back.""" + with self._lock: + self._conn.execute("BEGIN IMMEDIATE") + try: + yield + except Exception: + self._conn.rollback() + raise + + def reserve(self, scope: str, key: str, fingerprint: str, run_id: str, status: Dict[str, Any], *, + owner_pid: int = 0, owner_started: int = 0, retention_until: float = 0): """Atomically reserve a key; return ``(outcome, stored_record)``.""" now = time.time() retention_until = max(0.0, float(retention_until or 0)) encoded = _encode_status(status) - with self._lock: - self._conn.execute("BEGIN IMMEDIATE") - try: - self._prune_stale_terminal_locked(now) - row = self._conn.execute(_SELECT_BY_KEY, (scope, key)).fetchone() - if row is not None: - if retention_until: - self._conn.execute( - _EXTEND_RETENTION_BY_KEY, (retention_until, scope, key, fingerprint) - ) - self._conn.commit() - outcome = "reused" if hmac.compare_digest(row[0], fingerprint) else "conflict" - return outcome, _record(*row[1:]) - self._conn.execute( - "INSERT INTO run_idempotency(" - "scope,idempotency_key,fingerprint,run_id,status_json," - "owner_pid,owner_started,retention_until,created_at,updated_at" - ") VALUES(?,?,?,?,?,?,?,?,?,?)", - ( - scope, key, fingerprint, run_id, encoded, - int(owner_pid or 0), int(owner_started or 0), retention_until, now, now, - ), - ) + with self._immediate_txn(): + self._prune_stale_terminal_locked(now) + row = self._conn.execute(_SELECT_BY_KEY, (scope, key)).fetchone() + if row is not None: + if retention_until: + self._conn.execute(_EXTEND_RETENTION_BY_KEY, (retention_until, scope, key, fingerprint)) self._conn.commit() - return "created", { - "run_id": run_id, - "status": status, - "owner_pid": int(owner_pid or 0), - "owner_started": int(owner_started or 0), - "updated_at": now, - } - except Exception: - self._conn.rollback() - raise + return _outcome(row, fingerprint) + self._conn.execute( + "INSERT INTO run_idempotency(" + "scope,idempotency_key,fingerprint,run_id,status_json," + "owner_pid,owner_started,retention_until,created_at,updated_at" + ") VALUES(?,?,?,?,?,?,?,?,?,?)", + ( + scope, key, fingerprint, run_id, encoded, + int(owner_pid or 0), int(owner_started or 0), retention_until, now, now, + ), + ) + self._conn.commit() + return "created", { + "run_id": run_id, "status": status, "owner_pid": int(owner_pid or 0), + "owner_started": int(owner_started or 0), "updated_at": now, + } def lookup(self, scope: str, key: str, fingerprint: str, *, retention_until: float = 0): """Return ``missing``, ``reused`` or ``conflict`` without reserving.""" now = time.time() retention_until = max(0.0, float(retention_until or 0)) - with self._lock: - self._conn.execute("BEGIN IMMEDIATE") - try: - if retention_until: - self._conn.execute( - _EXTEND_RETENTION_BY_KEY, (retention_until, scope, key, fingerprint) - ) - self._prune_stale_terminal_locked(now) - row = self._conn.execute(_SELECT_BY_KEY, (scope, key)).fetchone() - self._conn.commit() - except Exception: - self._conn.rollback() - raise - if row is None: - return "missing", None - outcome = "reused" if hmac.compare_digest(row[0], fingerprint) else "conflict" - return outcome, _record(*row[1:]) + with self._immediate_txn(): + if retention_until: + self._conn.execute(_EXTEND_RETENTION_BY_KEY, (retention_until, scope, key, fingerprint)) + self._prune_stale_terminal_locked(now) + row = self._conn.execute(_SELECT_BY_KEY, (scope, key)).fetchone() + self._conn.commit() + return ("missing", None) if row is None else _outcome(row, fingerprint) def _prune_stale_terminal_locked(self, now: float) -> None: """Prune replay records only after their stored run is terminal. @@ -241,10 +227,8 @@ class RunIdempotencyStore: if row is None: return None return { - "status": json.loads(row[0]), - "owner_pid": int(row[1] or 0), - "owner_started": int(row[2] or 0), - "updated_at": float(row[3] or 0), + "status": json.loads(row[0]), "owner_pid": int(row[1] or 0), + "owner_started": int(row[2] or 0), "updated_at": float(row[3] or 0), } def extend_retention(self, scope: str, run_id: str, until: float) -> bool: @@ -253,21 +237,16 @@ class RunIdempotencyStore: if not checked_until: return False with self._lock: - changed = self._conn.execute( - _EXTEND_RETENTION_BY_RUN, (checked_until, scope, run_id) - ).rowcount + changed = self._conn.execute(_EXTEND_RETENTION_BY_RUN, (checked_until, scope, run_id)).rowcount self._conn.commit() return changed == 1 def owns_run(self, scope: str, run_id: str) -> bool: with self._lock: - return ( - self._conn.execute( - "SELECT 1 FROM run_idempotency WHERE scope=? AND run_id=?", - (scope, run_id), - ).fetchone() - is not None - ) + row = self._conn.execute( + "SELECT 1 FROM run_idempotency WHERE scope=? AND run_id=?", (scope, run_id) + ).fetchone() + return row is not None def update_status(self, run_id: str, status: Dict[str, Any]) -> None: with self._lock: diff --git a/gateway/platforms/api_server_runs.py b/gateway/platforms/api_server_runs.py index c2f7e3dad7..9f9529f68e 100644 --- a/gateway/platforms/api_server_runs.py +++ b/gateway/platforms/api_server_runs.py @@ -164,9 +164,7 @@ def _make_run_event_callback(self, run_id: str, loop: "asyncio.AbstractEventLoop def _push(event: Dict[str, Any]) -> None: self._set_run_status( - run_id, - self._run_statuses.get(run_id, {}).get("status", "running"), - last_event=event.get("event"), + run_id, self._run_statuses.get(run_id, {}).get("status", "running"), last_event=event.get("event") ) q = self._run_streams.get(run_id) if q is None: @@ -180,8 +178,7 @@ def _make_run_event_callback(self, run_id: str, loop: "asyncio.AbstractEventLoop elif event_type == "tool.completed": _push(_run_event( run_id, event_type, tool=tool_name, - duration=round(kwargs.get("duration", 0), 3), - error=kwargs.get("is_error", False), + duration=round(kwargs.get("duration", 0), 3), error=kwargs.get("is_error", False), )) elif event_type == "reasoning.available": _push(_run_event(run_id, event_type, text=preview or "")) @@ -241,6 +238,21 @@ def _check_run_auth(self, request: "web.Request", *, permission: str, _api_serve return None +def _owner_alive(owner_pid: int, owner_started: int) -> bool: + """True when the recorded owner pid still exists and is the same process incarnation.""" + if owner_pid <= 0: + return False + try: + from gateway.status import _pid_exists, get_process_start_time + + alive = bool(_pid_exists(owner_pid)) + if alive and owner_started: + alive = int(get_process_start_time(owner_pid) or 0) == owner_started + return alive + except Exception: + return False + + 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) @@ -258,20 +270,9 @@ def _durable_run_status(self, request: "web.Request", run_id: str) -> Dict[str, return None status = dict(record["status"]) - owner_pid = int(record.get("owner_pid") or 0) - owner_started = int(record.get("owner_started") or 0) - owner_alive = False - if owner_pid > 0: - try: - from gateway.status import _pid_exists, get_process_start_time - - owner_alive = bool(_pid_exists(owner_pid)) - if owner_alive and owner_started: - owner_alive = int(get_process_start_time(owner_pid) or 0) == owner_started - except Exception: - owner_alive = False - - if status.get("status") not in TERMINAL_STATUSES and not owner_alive: + if status.get("status") not in TERMINAL_STATUSES and not _owner_alive( + int(record.get("owner_pid") or 0), int(record.get("owner_started") or 0) + ): status.update({ "status": "interrupted", "error": "The gateway restarted before this run settled.", @@ -301,8 +302,7 @@ def _resolve_conversation_history( if raw_history: if not isinstance(raw_history, list): return [], instructions, None, _json_error( - _openai_error, "'conversation_history' must be an array of message objects", - status=400, + _openai_error, "'conversation_history' must be an array of message objects", status=400 ) for i, entry in enumerate(raw_history): if not isinstance(entry, dict) or "role" not in entry or "content" not in entry: @@ -345,8 +345,7 @@ def _replay_response(self, request: "web.Request", record: dict, gateway_session headers["X-Hermes-Session-Key"] = gateway_session_key return web.json_response( {"run_id": original_id, "status": status.get("status", "queued"), "replayed": True}, - status=202, - headers=headers, + status=202, headers=headers, ) @@ -380,6 +379,50 @@ class _RunLaunch: event_cb: Callable +def _idempotency_key_from(request: "web.Request", _openai_error) -> "tuple[str, web.Response | None]": + """Return ``(key, error)``; an absent header yields ``("", None)``.""" + key = request.headers.get("Idempotency-Key", "").strip() + if key and (len(key) > 255 or any(ord(ch) < 33 or ord(ch) > 126 for ch in key)): + return "", _json_error( + _openai_error, "Idempotency-Key must be 1-255 visible ASCII characters", + code="invalid_idempotency_key", status=400, + ) + return key, None + + +def _optional_dict(body: Any, key: str) -> Optional[dict]: + value = body.get(key) if isinstance(body, dict) else None + return value if isinstance(value, dict) else None + + +def _last_user_message(raw_input: Any) -> str: + if isinstance(raw_input, str): + return raw_input + return raw_input[-1].get("content", "") if isinstance(raw_input, list) else "" + + +def _forget_run(self, run_id: str, *tables) -> None: + """Drop *run_id* from the given run-keyed dicts/sets, then release its owner stamp.""" + for table in tables: + if isinstance(table, set): + table.discard(run_id) + else: + table.pop(run_id, None) + self._release_run_owner_if_forgotten(run_id) + + +def _retire_live_run(self, run_id: str) -> None: + """Retire agent/task/approval control state once the executor-backed task is done.""" + _forget_run( + self, run_id, self._active_run_agents, self._active_run_tasks, + self._run_approval_sessions, self._stopping_run_ids, + ) + + +def _drop_run_transport(self, run_id: str) -> None: + _forget_run(self, run_id, self._run_streams, self._run_streams_created) + + 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 @@ -397,36 +440,24 @@ async def _handle_runs(self, request: "web.Request", *, _api_server) -> "web.Res body, room_error = await self._normalize_room_dispatch(request, body) if room_error is not None: return room_error - room_dispatch = body.get("hosted_room_dispatch") if isinstance(body, dict) else None - room_dispatch = room_dispatch if isinstance(room_dispatch, dict) else None - room_execution_policy = body.get("_room_execution_policy") if isinstance(body, dict) else None - room_execution_policy = room_execution_policy if isinstance(room_execution_policy, dict) else None + room_dispatch = _optional_dict(body, "hosted_room_dispatch") + room_execution_policy = _optional_dict(body, "_room_execution_policy") - idempotency_key = request.headers.get("Idempotency-Key", "").strip() - if idempotency_key and ( - len(idempotency_key) > 255 or any(ord(ch) < 33 or ord(ch) > 126 for ch in idempotency_key) - ): - return _json_error( - _openai_error, "Idempotency-Key must be 1-255 visible ASCII characters", - code="invalid_idempotency_key", status=400, - ) + idempotency_key, key_err = _idempotency_key_from(request, _openai_error) + if key_err is not None: + return key_err idempotency_scope = idempotency_fingerprint = "" if idempotency_key: idempotency_scope = self._run_idempotency_scope(request) - idempotency_fingerprint = hashlib.sha256( - json.dumps( - {"body": body, "gateway_session_key": gateway_session_key or ""}, - sort_keys=True, separators=(",", ":"), ensure_ascii=False, - ).encode() - ).hexdigest() + idempotency_fingerprint = hashlib.sha256(json.dumps( + {"body": body, "gateway_session_key": gateway_session_key or ""}, + sort_keys=True, separators=(",", ":"), ensure_ascii=False, + ).encode()).hexdigest() raw_input = body.get("input") if not raw_input: return _json_error(_openai_error, "Missing 'input' field", status=400) - if isinstance(raw_input, str): - user_message = raw_input - else: - user_message = raw_input[-1].get("content", "") if isinstance(raw_input, list) else "" + user_message = _last_user_message(raw_input) if not user_message: return _json_error(_openai_error, "No user message found in input", status=400) @@ -441,11 +472,9 @@ async def _handle_runs(self, request: "web.Request", *, _api_server) -> "web.Res route = self._resolve_route(body.get("model")) agent_overrides = _api_server._request_agent_overrides(body, virtual_model=self._model_name) selection_error = self._request_route_conflict_error( - session_id=session_id, - gateway_session_key=gateway_session_key, + session_id=session_id, gateway_session_key=gateway_session_key, requested_model=agent_overrides.get("requested_model"), - requested_provider=agent_overrides.get("requested_provider"), - route=route, + requested_provider=agent_overrides.get("requested_provider"), route=route, ) if selection_error: return _json_error(_openai_error, selection_error, status=400) @@ -490,7 +519,6 @@ async def _handle_runs(self, request: "web.Request", *, _api_server) -> "web.Res self._run_streams[run_id] = q self._run_streams_created[run_id] = created_at self._run_approval_sessions[run_id] = approval_session_key - event_cb = self._make_run_event_callback(run_id, loop) def _put_event_if_active(event: Optional[Dict]) -> None: @@ -502,19 +530,15 @@ async def _handle_runs(self, request: "web.Request", *, _api_server) -> "web.Res if delta is None or run_id not in self._run_streams: return with suppress(Exception): - loop.call_soon_threadsafe( - _put_event_if_active, _run_event(run_id, "message.delta", delta=delta) - ) + loop.call_soon_threadsafe(_put_event_if_active, _run_event(run_id, "message.delta", delta=delta)) initial_status = self._set_run_status( - run_id, "queued", created_at=created_at, session_id=session_id, - model=body.get("model", self._model_name), + run_id, "queued", created_at=created_at, session_id=session_id, model=body.get("model", self._model_name) ) if idempotency_key: outcome, record = self._run_idempotency_store.reserve( idempotency_scope, idempotency_key, idempotency_fingerprint, run_id, initial_status, - owner_pid=self._run_owner_pid, - owner_started=self._run_owner_started, + owner_pid=self._run_owner_pid, owner_started=self._run_owner_started, retention_until=_room_retention_until(request), ) if outcome != "created": @@ -529,33 +553,29 @@ async def _handle_runs(self, request: "web.Request", *, _api_server) -> "web.Res self._run_idempotency_ids.add(run_id) launch = _RunLaunch( - run_id=run_id, - queue=q, - loop=loop, - put_event=_put_event_if_active, - session_id=session_id, - gateway_session_key=gateway_session_key, - declared_selected=_declared_selected, - approval_session_key=approval_session_key, - user_message=user_message, - conversation_history=conversation_history, - ephemeral_system_prompt=instructions, - agent_overrides=agent_overrides, - route=route, - room_dispatch=room_dispatch, + run_id=run_id, queue=q, loop=loop, put_event=_put_event_if_active, session_id=session_id, + gateway_session_key=gateway_session_key, declared_selected=_declared_selected, + approval_session_key=approval_session_key, user_message=user_message, + conversation_history=conversation_history, ephemeral_system_prompt=instructions, + agent_overrides=agent_overrides, route=route, room_dispatch=room_dispatch, room_execution_policy=room_execution_policy, request_profile=_api_server._api_request_profile.get(), browser_control_principal=_api_server._api_request_browser_control_principal.get(), - browser_control_transport_family=( - _api_server._api_request_browser_control_transport_family.get() - ), - text_cb=_text_cb, - event_cb=event_cb, + browser_control_transport_family=_api_server._api_request_browser_control_transport_family.get(), + text_cb=_text_cb, event_cb=event_cb, + ) + _start_run_task(self, launch, _api_server=_api_server) + response_headers = {"X-Hermes-Session-Key": gateway_session_key} if gateway_session_key else {} + return web.json_response( + {"run_id": run_id, "status": "started", "replayed": False}, status=202, headers=response_headers ) + +def _start_run_task(self, launch: _RunLaunch, *, _api_server) -> None: + """Admit the run and schedule its background task, tracked for shutdown drain.""" self._activate_admitted_request() task = asyncio.create_task(_execute_run(self, launch, _api_server=_api_server)) - self._active_run_tasks[run_id] = task + self._active_run_tasks[launch.run_id] = task try: self._background_tasks.add(task) except TypeError: @@ -563,13 +583,6 @@ async def _handle_runs(self, request: "web.Request", *, _api_server) -> "web.Res if hasattr(task, "add_done_callback"): task.add_done_callback(self._background_tasks.discard) - response_headers = {"X-Hermes-Session-Key": gateway_session_key} if gateway_session_key else {} - return web.json_response( - {"run_id": run_id, "status": "started", "replayed": False}, - status=202, - headers=response_headers, - ) - def _run_agent_sync(self, run: _RunLaunch, agent, approval_notify, *, _api_server): """Executor-thread body of one run; returns ``(result, usage)``.""" @@ -596,17 +609,12 @@ def _run_agent_sync(self, run: _RunLaunch, agent, approval_notify, *, _api_serve # tools.async_delegation sees an empty HERMES_SESSION_CHAT_ID and # background delegations stay forced-sync (no wake target). session_tokens = self._bind_api_server_session( - chat_id=session_id or "", - session_key=run.approval_session_key, - session_id=session_id or "", + chat_id=session_id or "", session_key=run.approval_session_key, session_id=session_id or "", browser_control_principal=run.browser_control_principal, browser_control_transport_family=run.browser_control_transport_family, ) if run.room_dispatch is not None: - from gateway.hosted_room_execution_policy import ( - RoomExecutionPolicy, - bind_room_execution_policy, - ) + from gateway.hosted_room_execution_policy import RoomExecutionPolicy, bind_room_execution_policy policy = RoomExecutionPolicy.from_mapping(run.room_execution_policy or {}) room_policy_token = bind_room_execution_policy(policy) @@ -616,8 +624,7 @@ def _run_agent_sync(self, run: _RunLaunch, agent, approval_notify, *, _api_serve # background processes this run created. _api_server._publish_turn_process_ownership(agent, effective_task_id) r = agent.run_conversation( - user_message=run.user_message, - conversation_history=run.conversation_history, + user_message=run.user_message, conversation_history=run.conversation_history, task_id=effective_task_id, ) finally: @@ -652,121 +659,113 @@ def _run_agent_sync(self, run: _RunLaunch, agent, approval_notify, *, _api_serve return r, u +def _make_approval_notify(self, run: _RunLaunch, *, _api_server) -> Callable[[Dict[str, Any]], None]: + """Approval-request bridge: redact, stamp the event envelope, park the run status, enqueue.""" + run_id, q, loop = run.run_id, run.queue, run.loop + + def _approval_notify(approval_data: Dict[str, Any]) -> None: + event = dict(approval_data or {}) + # Redact credentials before the command enters the SSE/API stream; + # API/desktop clients must never receive the raw flagged command. + if "command" in event: + from gateway.run import _redact_approval_command + + event["command"] = _redact_approval_command(event.get("command")) + event.update({ + "event": "approval.request", + "run_id": run_id, + "timestamp": time.time(), + "choices": _api_server._approval_event_choices( + smart_denied=bool(event.get("smart_denied")), + allow_session=event.get("allow_session") is not False, + allow_permanent=event.get("allow_permanent") is not False, + ), + }) + self._set_run_status(run_id, "waiting_for_approval", last_event="approval.request", approval=event) + with suppress(Exception): + loop.call_soon_threadsafe(q.put_nowait, event) + + return _approval_notify + + async def _execute_run(self, run: _RunLaunch, *, _api_server) -> None: """Background task for one admitted run: drives the agent, then publishes the terminal event/status and releases live state.""" _redact_api_error_text = _api_server._redact_api_error_text - run_id, q, loop = run.run_id, run.queue, run.loop + run_id = run.run_id - def _emit(name: str, **fields: Any) -> None: - run.put_event(_run_event(run_id, name, **fields)) + def _finish(status: str, extra: Optional[dict] = None, **fields: Any) -> None: + """Publish a terminal status, then the matching ``run.`` event (best effort). - def _fail(error_msg: str) -> None: # status first, then best-effort event - self._set_run_status(run_id, "failed", error=error_msg, last_event="run.failed") + Field order is part of the pollable status shape: *fields*, ``last_event``, then *extra*. + """ + extra = extra or {} + self._set_run_status(run_id, status, **fields, last_event=f"run.{status}", **extra) with suppress(Exception): - _emit("run.failed", error=error_msg) + run.put_event(_run_event(run_id, f"run.{status}", **fields, **extra)) try: self._set_run_status(run_id, "running") if run_id in self._stopping_run_ids: - _emit("run.cancelled") - self._set_run_status(run_id, "cancelled", last_event="run.cancelled") + _finish("cancelled") return with self._profile_scope(run.request_profile): agent = self._create_agent( - ephemeral_system_prompt=run.ephemeral_system_prompt, - session_id=run.session_id, - stream_delta_callback=run.text_cb, - tool_progress_callback=run.event_cb, + ephemeral_system_prompt=run.ephemeral_system_prompt, session_id=run.session_id, + stream_delta_callback=run.text_cb, tool_progress_callback=run.event_cb, gateway_session_key=run.gateway_session_key, requested_model=run.agent_overrides.get("requested_model"), requested_provider=run.agent_overrides.get("requested_provider"), - model_options=run.agent_overrides.get("model_options"), - route=run.route, - room_dispatch=run.room_dispatch, - room_execution_policy=run.room_execution_policy, + model_options=run.agent_overrides.get("model_options"), route=run.route, + room_dispatch=run.room_dispatch, room_execution_policy=run.room_execution_policy, ) self._active_run_agents[run_id] = agent - - def _approval_notify(approval_data: Dict[str, Any]) -> None: - event = dict(approval_data or {}) - # Redact credentials before the command enters the SSE/API stream; - # API/desktop clients must never receive the raw flagged command. - if "command" in event: - from gateway.run import _redact_approval_command - - event["command"] = _redact_approval_command(event.get("command")) - event.update({ - "event": "approval.request", - "run_id": run_id, - "timestamp": time.time(), - "choices": _api_server._approval_event_choices( - smart_denied=bool(event.get("smart_denied")), - allow_session=event.get("allow_session") is not False, - allow_permanent=event.get("allow_permanent") is not False, - ), - }) - self._set_run_status( - run_id, "waiting_for_approval", last_event="approval.request", approval=event - ) - with suppress(Exception): - loop.call_soon_threadsafe(q.put_nowait, event) - + approval_notify = _make_approval_notify(self, run, _api_server=_api_server) result, usage = await asyncio.get_running_loop().run_in_executor( - None, lambda: _run_agent_sync(self, run, agent, _approval_notify, _api_server=_api_server) + None, lambda: _run_agent_sync(self, run, agent, approval_notify, _api_server=_api_server) ) - if ( - run_id in self._stopping_run_ids - and isinstance(result, dict) - and result.get("interrupted") is True - ): - _emit("run.cancelled") - self._set_run_status(run_id, "cancelled", last_event="run.cancelled") - elif isinstance(result, dict) and result.get("failed"): + if not isinstance(result, dict): + result = {} + if run_id in self._stopping_run_ids and result.get("interrupted") is True: + _finish("cancelled") + elif result.get("failed"): # Non-retryable client errors (401/400) return failed=True instead # of raising, so the except branches below never fire for them. - error_msg = _redact_api_error_text(result.get("error") or "agent run failed") - _emit("run.failed", error=error_msg) - self._set_run_status(run_id, "failed", error=error_msg, last_event="run.failed") + _finish("failed", error=_redact_api_error_text(result.get("error") or "agent run failed")) else: - final_response = result.get("final_response", "") if isinstance(result, dict) else "" # Undelivered steer text (accepted after the final response) rides on # the terminal event/status so the client can replay it as the next turn. - pending_steer = result.get("pending_steer") if isinstance(result, dict) else None + pending_steer = result.get("pending_steer") extra = {"pending_steer": pending_steer} if pending_steer else {} - _emit("run.completed", output=final_response, usage=usage, **extra) - self._set_run_status( - run_id, "completed", output=final_response, usage=usage, - last_event="run.completed", **extra, - ) + _finish("completed", extra, output=result.get("final_response", ""), usage=usage) except asyncio.CancelledError: - self._set_run_status(run_id, "cancelled", last_event="run.cancelled") - with suppress(Exception): - _emit("run.cancelled") + _finish("cancelled") raise except _api_server._ProviderAuthResolutionError as exc: # /v1/runs bypasses _run_agent(), so it needs its own branch to surface # the same controlled provider-auth message the other endpoints give. logger.warning("Provider authentication failed for run=%s: %s", run_id, exc) - _fail(f"⚠️ Provider authentication failed: {exc}") + _finish("failed", error=f"⚠️ Provider authentication failed: {exc}") except Exception as exc: logger.exception("[api_server] run %s failed", run_id) - _fail(_redact_api_error_text(exc)) + _finish("failed", error=_redact_api_error_text(exc)) finally: # If the asyncio wrapper is cancelled (e.g. via /stop) the executor # thread may still block on an approval Event; unregistering here # releases it. Harmlessly idempotent on normal completion. - with suppress(Exception): - from tools.approval import unregister_gateway_notify - - unregister_gateway_notify(run.approval_session_key) + _unregister_approval_notify(run.approval_session_key) with suppress(Exception): run.put_event(None) # sentinel: close the SSE stream - self._active_run_agents.pop(run_id, None) - self._active_run_tasks.pop(run_id, None) - self._run_approval_sessions.pop(run_id, None) - self._stopping_run_ids.discard(run_id) - self._release_run_owner_if_forgotten(run_id) + _retire_live_run(self, run_id) + + +def _unregister_approval_notify(approval_session_key: Optional[str]) -> None: + """Best-effort release of a run's approval waiter (no-op without a key).""" + with suppress(Exception): + from tools.approval import unregister_gateway_notify + + if approval_session_key: + unregister_gateway_notify(approval_session_key) def _release_run_owner_if_forgotten(self, run_id: str) -> None: @@ -854,11 +853,7 @@ async def _handle_run_events(self, request: "web.Request", *, _api_server) -> "w response = web.StreamResponse( status=200, - headers={ - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - "X-Accel-Buffering": "no", - }, + headers={"Content-Type": "text/event-stream", "Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, ) await response.prepare(request) @@ -877,9 +872,7 @@ async def _handle_run_events(self, request: "web.Request", *, _api_server) -> "w logger.debug("[api_server] SSE stream error for run %s: %s", run_id, exc) finally: self._run_stream_subscribers.discard(run_id) - self._run_streams.pop(run_id, None) - self._run_streams_created.pop(run_id, None) - self._release_run_owner_if_forgotten(run_id) + _drop_run_transport(self, run_id) return response @@ -891,6 +884,15 @@ def _emit_to_stream(self, run_id: str, event: Dict[str, Any]) -> None: q.put_nowait(event) +def _mark_run_event(self, run_id: str, name: str, **fields: Any) -> None: + """Record a control-plane event on the run status and its SSE stream.""" + self._set_run_status(run_id, "running", last_event=name) + _emit_to_stream(self, run_id, _run_event(run_id, name, **fields)) + + +_APPROVAL_CHOICE_ALIASES = {"approve": "once", "approved": "once", "allow": "once"} + + async def _handle_run_approval(self, request: "web.Request", *, _api_server) -> "web.Response": """POST /v1/runs/{run_id}/approval — resolve a pending run approval.""" _coerce_request_bool = _api_server._coerce_request_bool @@ -899,9 +901,7 @@ async def _handle_run_approval(self, request: "web.Request", *, _api_server) -> auth_err = self._check_run_auth(request, permission="approve") if auth_err: return auth_err - run_id, _, _, _, err = _load_owned_run( - self, request, _openai_error=_openai_error, active_fallback=False - ) + run_id, _, _, _, err = _load_owned_run(self, request, _openai_error=_openai_error, active_fallback=False) if err is not None: return err @@ -911,15 +911,13 @@ async def _handle_run_approval(self, request: "web.Request", *, _api_server) -> return _json_error(_openai_error, "Invalid JSON", status=400) raw_choice = str(body.get("choice", "")).strip().lower() - aliases = {"approve": "once", "approved": "once", "allow": "once"} - choice = aliases.get(raw_choice, raw_choice) + choice = _APPROVAL_CHOICE_ALIASES.get(raw_choice, raw_choice) room_scoped = bool(self._room_grant_token(request)) raw_request_id = body.get("request_id") request_id = raw_request_id.strip() if isinstance(raw_request_id, str) else "" if raw_request_id is not None and (not request_id or len(request_id) > 256): return _json_error( - _openai_error, "Approval request_id is invalid.", - code="invalid_approval_request", status=400, + _openai_error, "Approval request_id is invalid.", code="invalid_approval_request", status=400 ) allowed = {"once", "deny"} if room_scoped else {"once", "session", "always", "deny"} if choice not in allowed: @@ -961,16 +959,11 @@ async def _handle_run_approval(self, request: "web.Request", *, _api_server) -> if resolved <= 0: return _json_error( - _openai_error, f"Run has no pending approval: {run_id}", - code="approval_not_pending", status=409, + _openai_error, f"Run has no pending approval: {run_id}", code="approval_not_pending", status=409 ) - self._set_run_status(run_id, "running", last_event="approval.responded") request_id_field = {"request_id": request_id} if request_id else {} - _emit_to_stream( - self, run_id, - _run_event(run_id, "approval.responded", choice=choice, **request_id_field, resolved=resolved), - ) + _mark_run_event(self, run_id, "approval.responded", choice=choice, **request_id_field, resolved=resolved) return web.json_response({ "object": "hermes.run.approval_response", "run_id": run_id, @@ -983,7 +976,6 @@ async def _handle_run_approval(self, request: "web.Request", *, _api_server) -> 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 - _redact_api_error_text = _api_server._redact_api_error_text auth_err = self._check_auth(request) if auth_err: @@ -1018,16 +1010,14 @@ async def _handle_steer_run(self, request: "web.Request", *, _api_server) -> "we except Exception as exc: logger.exception("[api_server] steer failed for run %s", run_id) return _json_error( - _openai_error, _redact_api_error_text(exc), code="steer_failed", status=500 + _openai_error, _api_server._redact_api_error_text(exc), code="steer_failed", status=500 ) if not accepted: return _json_error( - _openai_error, f"Run did not accept steer text: {run_id}", - code="steer_not_accepted", status=409, + _openai_error, f"Run did not accept steer text: {run_id}", code="steer_not_accepted", status=409 ) - self._set_run_status(run_id, "running", last_event="run.steered") - _emit_to_stream(self, run_id, _run_event(run_id, "run.steered", accepted=True)) + _mark_run_event(self, run_id, "run.steered", accepted=True) return web.json_response({"object": "hermes.run.steer", "run_id": run_id, "accepted": True}) @@ -1087,22 +1077,12 @@ def _sweep_orphaned_runs_once(self, now: Optional[float] = None) -> None: task = self._active_run_tasks.get(run_id) task_done = task is None or task.done() if task_done: - with suppress(Exception): - from tools.approval import unregister_gateway_notify - - approval_session_key = self._run_approval_sessions.get(run_id) - if approval_session_key: - unregister_gateway_notify(approval_session_key) + _unregister_approval_notify(self._run_approval_sessions.get(run_id)) # The transport TTL always bounds buffering. Live control state is # independent and survives until the executor-backed task returns. - self._run_streams.pop(run_id, None) - self._run_streams_created.pop(run_id, None) + _drop_run_transport(self, run_id) if task_done: - self._active_run_agents.pop(run_id, None) - self._active_run_tasks.pop(run_id, None) - self._run_approval_sessions.pop(run_id, None) - self._stopping_run_ids.discard(run_id) - self._release_run_owner_if_forgotten(run_id) + _retire_live_run(self, run_id) stale_statuses = [ run_id @@ -1111,6 +1091,4 @@ def _sweep_orphaned_runs_once(self, now: Optional[float] = None) -> None: and now - float(status.get("updated_at", 0) or 0) > self._RUN_STATUS_TTL ] for run_id in stale_statuses: - self._run_statuses.pop(run_id, None) - self._run_idempotency_ids.discard(run_id) - self._release_run_owner_if_forgotten(run_id) + _forget_run(self, run_id, self._run_statuses, self._run_idempotency_ids) From 7b6ec94ec153c8287ad69d8ccba2c1152dee4f77 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:47:51 -0700 Subject: [PATCH 02/22] =?UTF-8?q?refactor(gateway/platforms):=20compact=20?= =?UTF-8?q?qqbot=20adapter=20=E2=80=94=20fold=20reconnect/ready/ws-close?= =?UTF-8?q?=20helpers,=20dedupe=20token/gateway=20fetch=20+=20http-client?= =?UTF-8?q?=20guard,=20collapse=20dm/group=20ACL=20ladders,=20magic-byte?= =?UTF-8?q?=20table,=20tighter=20layouts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gateway/platforms/qqbot/__init__.py | 36 +- gateway/platforms/qqbot/adapter.py | 1048 ++++++--------------- gateway/platforms/qqbot/chunked_upload.py | 176 +--- gateway/platforms/qqbot/constants.py | 11 +- gateway/platforms/qqbot/crypto.py | 8 +- gateway/platforms/qqbot/keyboards.py | 54 +- gateway/platforms/qqbot/onboard.py | 28 +- gateway/platforms/qqbot/utils.py | 13 +- 8 files changed, 380 insertions(+), 994 deletions(-) diff --git a/gateway/platforms/qqbot/__init__.py b/gateway/platforms/qqbot/__init__.py index 395ba630f1..9d93995dac 100644 --- a/gateway/platforms/qqbot/__init__.py +++ b/gateway/platforms/qqbot/__init__.py @@ -1,37 +1,15 @@ -"""QQBot platform package. +"""QQBot platform package. Re-exports adapter symbols so existing import paths +(``from gateway.platforms.qqbot import QQAdapter, check_qq_requirements``) keep working. +Sub-modules: constants, utils, crypto (AES-256-GCM), onboard (QR), chunked_upload, keyboards.""" -Re-exports the adapter symbols from ``adapter.py`` (the original ``qqbot.py``) -so all existing import paths remain unchanged, e.g. -``from gateway.platforms.qqbot import QQAdapter, check_qq_requirements``. - -Sub-modules: ``constants``, ``utils`` (User-Agent, config helpers), ``crypto`` -(AES-256-GCM), ``onboard`` (QR scan-to-configure), ``chunked_upload``, ``keyboards``. -""" - -from .adapter import ( # noqa: F401 - QQAdapter, - QQCloseError, - check_qq_requirements, - _coerce_list, - _ssrf_redirect_guard, -) +from .adapter import QQAdapter, QQCloseError, check_qq_requirements, _coerce_list, _ssrf_redirect_guard # noqa: F401 from .onboard import BindStatus, build_connect_url, qr_register # noqa: F401 from .crypto import decrypt_secret, generate_bind_key # noqa: F401 from .utils import build_user_agent, get_api_headers, coerce_list # noqa: F401 -from .chunked_upload import ( # noqa: F401 - ChunkedUploader, - UploadDailyLimitExceededError, - UploadFileTooLargeError, -) +from .chunked_upload import ChunkedUploader, UploadDailyLimitExceededError, UploadFileTooLargeError # noqa: F401 from .keyboards import ( # noqa: F401 - ApprovalRequest, - InlineKeyboard, - InteractionEvent, - build_approval_keyboard, - build_approval_text, - build_update_prompt_keyboard, - parse_approval_button_data, - parse_interaction_event, + ApprovalRequest, InlineKeyboard, InteractionEvent, build_approval_keyboard, build_approval_text, + build_update_prompt_keyboard, parse_approval_button_data, parse_interaction_event, parse_update_prompt_button_data, ) diff --git a/gateway/platforms/qqbot/adapter.py b/gateway/platforms/qqbot/adapter.py index a78a97e96e..072bb8a13a 100644 --- a/gateway/platforms/qqbot/adapter.py +++ b/gateway/platforms/qqbot/adapter.py @@ -1,32 +1,11 @@ -""" -QQ Bot platform adapter using the Official QQ Bot API (v2). +"""QQ Bot platform adapter (Official QQ Bot API v2): WebSocket gateway for inbound +events, REST (``api.sgroup.qq.com``) for outbound messages and media uploads. -Connects to the QQ Bot WebSocket Gateway for inbound events and uses the -REST API (``api.sgroup.qq.com``) for outbound messages and media uploads. - -Configuration in config.yaml: - platforms: - qq: - enabled: true - extra: - app_id: "your-app-id" # or QQ_APP_ID env var - client_secret: "your-secret" # or QQ_CLIENT_SECRET env var - markdown_support: true # enable QQ markdown (msg_type 2) - dm_policy: "pairing" # open | allowlist | disabled | pairing - allow_from: ["openid_1"] - group_policy: "pairing" # open | allowlist | disabled | pairing - group_allow_from: ["group_openid_1"] - stt: # Voice-to-text config (optional) - provider: "zai" # zai (GLM-ASR), openai (Whisper), etc. - baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4" - apiKey: "your-stt-api-key" # or set QQ_STT_API_KEY env var - model: "glm-asr" # glm-asr, whisper-1, etc. - - Voice transcription priority: - 1. QQ's built-in ``asr_refer_text`` (Tencent ASR — free, always tried first) - 2. Configured STT provider via ``stt`` config or ``QQ_STT_*`` env vars - -Reference: https://bot.q.qq.com/wiki/develop/api-v2/ +config.yaml ``platforms.qq.extra``: app_id / client_secret (or QQ_APP_ID / +QQ_CLIENT_SECRET), markdown_support, dm_policy + allow_from, group_policy + +group_allow_from (open | allowlist | disabled | pairing), and optional ``stt`` +{provider, baseUrl, apiKey, model} (or QQ_STT_* env vars). Voice transcription +tries QQ's free ``asr_refer_text`` first, then the configured STT provider. """ from __future__ import annotations @@ -45,7 +24,6 @@ from urllib.parse import urlparse try: import aiohttp - AIOHTTP_AVAILABLE = True except ImportError: AIOHTTP_AVAILABLE = False @@ -53,7 +31,6 @@ except ImportError: try: import httpx - HTTPX_AVAILABLE = True except ImportError: HTTPX_AVAILABLE = False @@ -61,14 +38,8 @@ except ImportError: from gateway.config import Platform, PlatformConfig from gateway.platforms.base import ( - gateway_trust_env, - BasePlatformAdapter, - MessageEvent, - MessageType, - SendResult, - _ssrf_redirect_guard, - cache_document_from_bytes, - cache_image_from_bytes, + gateway_trust_env, BasePlatformAdapter, MessageEvent, MessageType, SendResult, + _ssrf_redirect_guard, cache_document_from_bytes, cache_image_from_bytes, ) from gateway.platforms.helpers import strip_markdown from gateway.platforms.media_cache import ext_for_mime @@ -86,43 +57,19 @@ class QQCloseError(Exception): from gateway.platforms.qqbot.constants import ( - API_BASE, - TOKEN_URL, - GATEWAY_URL_PATH, - DEFAULT_API_TIMEOUT, - FILE_UPLOAD_TIMEOUT, - CONNECT_TIMEOUT_SECONDS, - RECONNECT_BACKOFF, - MAX_RECONNECT_ATTEMPTS, - RATE_LIMIT_DELAY, - QUICK_DISCONNECT_THRESHOLD, - MAX_QUICK_DISCONNECT_COUNT, - MAX_MESSAGE_LENGTH, - DEDUP_WINDOW_SECONDS, - DEDUP_MAX_SIZE, - MSG_TYPE_TEXT, - MSG_TYPE_MARKDOWN, - MSG_TYPE_MEDIA, - MSG_TYPE_INPUT_NOTIFY, - MEDIA_TYPE_IMAGE, - MEDIA_TYPE_VIDEO, - MEDIA_TYPE_VOICE, - MEDIA_TYPE_FILE, + API_BASE, TOKEN_URL, GATEWAY_URL_PATH, DEFAULT_API_TIMEOUT, FILE_UPLOAD_TIMEOUT, + CONNECT_TIMEOUT_SECONDS, RECONNECT_BACKOFF, MAX_RECONNECT_ATTEMPTS, RATE_LIMIT_DELAY, + QUICK_DISCONNECT_THRESHOLD, MAX_QUICK_DISCONNECT_COUNT, MAX_MESSAGE_LENGTH, + DEDUP_WINDOW_SECONDS, DEDUP_MAX_SIZE, MSG_TYPE_TEXT, MSG_TYPE_MARKDOWN, MSG_TYPE_MEDIA, + MSG_TYPE_INPUT_NOTIFY, MEDIA_TYPE_IMAGE, MEDIA_TYPE_VIDEO, MEDIA_TYPE_VOICE, MEDIA_TYPE_FILE, ) from gateway.platforms.qqbot.utils import coerce_list as _coerce_list, build_user_agent from gateway.platforms.qqbot.chunked_upload import ( - ChunkedUploader, - UploadDailyLimitExceededError, - UploadFileTooLargeError, + ChunkedUploader, UploadDailyLimitExceededError, UploadFileTooLargeError, ) from gateway.platforms.qqbot.keyboards import ( - ApprovalRequest, - InlineKeyboard, - InteractionEvent, - build_approval_keyboard, - build_update_prompt_keyboard, - parse_approval_button_data, - parse_interaction_event, + ApprovalRequest, InlineKeyboard, InteractionEvent, build_approval_keyboard, + build_update_prompt_keyboard, parse_approval_button_data, parse_interaction_event, parse_update_prompt_button_data, ) from gateway.platforms._shared import get_scoped_secret as _resolve_qq_secret @@ -153,15 +100,9 @@ class QQAdapter(BasePlatformAdapter): # WS close codes that are unrecoverable → stop reconnecting. _FATAL_CLOSE_CODES = { - 4001: "invalid opcode", - 4002: "invalid payload", - 4010: "invalid shard", - 4011: "sharding required", - 4012: "invalid API version", - 4013: "invalid intent", - 4014: "intent not authorized", - 4914: "offline/sandbox-only", - 4915: "banned", + 4001: "invalid opcode", 4002: "invalid payload", 4010: "invalid shard", + 4011: "sharding required", 4012: "invalid API version", 4013: "invalid intent", + 4014: "intent not authorized", 4914: "offline/sandbox-only", 4915: "banned", } # WS close codes that invalidate the session → clear it and re-identify on # the next Hello. 4009 (connection timeout) is deliberately absent: it is @@ -209,9 +150,7 @@ class QQAdapter(BasePlatformAdapter): self._dm_policy = str(extra.get("dm_policy", "pairing")).strip().lower() self._allow_from = _coerce_list(extra.get("allow_from") or extra.get("allowFrom")) self._group_policy = str(extra.get("group_policy", "pairing")).strip().lower() - self._group_allow_from = _coerce_list( - extra.get("group_allow_from") or extra.get("groupAllowFrom") - ) + self._group_allow_from = _coerce_list(extra.get("group_allow_from") or extra.get("groupAllowFrom")) # Connection state self._session: Optional[aiohttp.ClientSession] = None @@ -224,14 +163,10 @@ class QQAdapter(BasePlatformAdapter): self._last_seq: Optional[int] = None self._chat_type_map: Dict[str, str] = {} # chat_id → "c2c"|"group"|"guild"|"dm" - # Request/response correlation - self._pending_responses: Dict[str, asyncio.Future] = {} + self._pending_responses: Dict[str, asyncio.Future] = {} # request/response correlation self._seen_messages: Dict[str, float] = {} - - # Last inbound message ID per chat — used by send_typing - self._last_msg_id: Dict[str, str] = {} - # Typing debounce: chat_id → last send_typing timestamp - self._typing_sent_at: Dict[str, float] = {} + self._last_msg_id: Dict[str, str] = {} # last inbound message ID per chat (send_typing) + self._typing_sent_at: Dict[str, float] = {} # typing debounce: chat_id → last send_typing ts # Token cache self._access_token: Optional[str] = None @@ -245,9 +180,7 @@ class QQAdapter(BasePlatformAdapter): Callable[[InteractionEvent], Awaitable[None]] ] = self._default_interaction_dispatch - # ------------------------------------------------------------------ - # Properties - # ------------------------------------------------------------------ + # ── Properties ── @property def name(self) -> str: @@ -258,20 +191,16 @@ class QQAdapter(BasePlatformAdapter): """QQBot gates DM/group access at intake via dm_policy/group_policy.""" return True - # ------------------------------------------------------------------ - # Connection lifecycle - # ------------------------------------------------------------------ + # ── Connection lifecycle ── async def connect(self, *, is_reconnect: bool = False) -> bool: - """ - Authenticate, obtain gateway URL, and open the WebSocket. - - ``is_reconnect`` is accepted for interface conformance only (QQBot has no - server-side update queue). - """ + """Authenticate, obtain gateway URL, and open the WebSocket. ``is_reconnect`` + is accepted for interface conformance only (QQBot has no server-side update queue).""" for ok, code, message, hint in ( - (AIOHTTP_AVAILABLE, "qq_missing_dependency", "QQ startup failed: aiohttp not installed", ". Run: pip install aiohttp"), - (HTTPX_AVAILABLE, "qq_missing_dependency", "QQ startup failed: httpx not installed", ". Run: pip install httpx"), + (AIOHTTP_AVAILABLE, "qq_missing_dependency", "QQ startup failed: aiohttp not installed", + ". Run: pip install aiohttp"), + (HTTPX_AVAILABLE, "qq_missing_dependency", "QQ startup failed: httpx not installed", + ". Run: pip install httpx"), (self._app_id and self._client_secret, "qq_missing_credentials", "QQ startup failed: QQ_APP_ID and QQ_CLIENT_SECRET are required", ""), ): @@ -352,35 +281,34 @@ class QQAdapter(BasePlatformAdapter): self._http_client = None self._fail_pending("Disconnected") - # ------------------------------------------------------------------ - # Token management - # ------------------------------------------------------------------ + # ── Token management ── + + async def _fetch_json(self, what: str, request: Callable[[], Awaitable[Any]]) -> Dict[str, Any]: + """Run an httpx request and return its JSON; any failure → RuntimeError.""" + try: + resp = await request() + resp.raise_for_status() + return resp.json() + except Exception as exc: + raise RuntimeError(f"Failed to get QQ Bot {what}: {exc}") from exc + + def _token_fresh(self) -> bool: + return bool(self._access_token) and time.time() < self._token_expires_at - 60 async def _ensure_token(self) -> str: """Return a valid access token, refreshing if needed (with singleflight).""" - if self._access_token and time.time() < self._token_expires_at - 60: + if self._token_fresh(): return self._access_token - async with self._token_lock: - # Double-check after acquiring lock - if self._access_token and time.time() < self._token_expires_at - 60: + if self._token_fresh(): # double-check after acquiring lock return self._access_token - - try: - resp = await self._http_client.post( - TOKEN_URL, - json={"appId": self._app_id, "clientSecret": self._client_secret}, - timeout=DEFAULT_API_TIMEOUT, - ) - resp.raise_for_status() - data = resp.json() - except Exception as exc: - raise RuntimeError(f"Failed to get QQ Bot access token: {exc}") from exc - + data = await self._fetch_json("access token", lambda: self._http_client.post( + TOKEN_URL, json={"appId": self._app_id, "clientSecret": self._client_secret}, + timeout=DEFAULT_API_TIMEOUT, + )) token = data.get("access_token") if not token: raise RuntimeError(f"QQ Bot token response missing access_token: {data}") - expires_in = int(data.get("expires_in", 7200)) self._access_token = token self._token_expires_at = time.time() + expires_in @@ -390,81 +318,64 @@ class QQAdapter(BasePlatformAdapter): async def _get_gateway_url(self) -> str: """Fetch the WebSocket gateway URL from the REST API.""" token = await self._ensure_token() - try: - resp = await self._http_client.get( - f"{API_BASE}{GATEWAY_URL_PATH}", - headers={"Authorization": f"QQBot {token}", "User-Agent": build_user_agent()}, - timeout=DEFAULT_API_TIMEOUT, - ) - resp.raise_for_status() - data = resp.json() - except Exception as exc: - raise RuntimeError(f"Failed to get QQ Bot gateway URL: {exc}") from exc - + data = await self._fetch_json("gateway URL", lambda: self._http_client.get( + f"{API_BASE}{GATEWAY_URL_PATH}", + headers={"Authorization": f"QQBot {token}", "User-Agent": build_user_agent()}, + timeout=DEFAULT_API_TIMEOUT, + )) url = data.get("url") if not url: raise RuntimeError(f"QQ Bot gateway response missing url: {data}") return url - # ------------------------------------------------------------------ - # WebSocket lifecycle - # ------------------------------------------------------------------ + # ── WebSocket lifecycle ── async def _open_ws(self, gateway_url: str) -> None: """Open a WebSocket connection to the QQ Bot gateway.""" await self._close_ws() # Honor proxy env vars for the WebSocket (WSL setups need this). self._session = aiohttp.ClientSession(trust_env=gateway_trust_env()) - ws_proxy = ( - os.getenv("WSS_PROXY") - or os.getenv("wss_proxy") - or os.getenv("HTTPS_PROXY") - or os.getenv("https_proxy") - or os.getenv("ALL_PROXY") - or os.getenv("all_proxy") - ) + proxy_vars = ("WSS_PROXY", "wss_proxy", "HTTPS_PROXY", "https_proxy", "ALL_PROXY", "all_proxy") + ws_proxy = next((v for v in map(os.getenv, proxy_vars) if v), None) self._ws = await self._session.ws_connect( - gateway_url, - headers={"User-Agent": build_user_agent()}, - timeout=CONNECT_TIMEOUT_SECONDS, - proxy=ws_proxy, + gateway_url, headers={"User-Agent": build_user_agent()}, timeout=CONNECT_TIMEOUT_SECONDS, proxy=ws_proxy, ) logger.info("[%s] WebSocket connected to %s", self._log_tag, gateway_url) async def _listen_loop(self) -> None: - """Read WebSocket events and reconnect on errors. - - Close codes: 4004 → refresh token; 4006/4007/49xx → clear session and - re-identify; 4008 → rate limited, back off; _FATAL_CLOSE_CODES → stop. - """ + """Read WebSocket events and reconnect on errors. Close codes: 4004 → refresh + token; 4006/4007/49xx → clear session and re-identify; 4008 → rate limited, + back off; _FATAL_CLOSE_CODES → stop.""" backoff_idx = 0 connect_time = 0.0 quick_disconnect_count = 0 + async def reconnect() -> None: + nonlocal backoff_idx, quick_disconnect_count + if await self._reconnect(backoff_idx): + backoff_idx = quick_disconnect_count = 0 + else: + backoff_idx += 1 + while self._running: try: connect_time = time.monotonic() await self._read_events() - backoff_idx = 0 - quick_disconnect_count = 0 + backoff_idx = quick_disconnect_count = 0 except asyncio.CancelledError: return except QQCloseError as exc: if not self._running: return - code = exc.code - logger.warning( - "[%s] WebSocket closed: code=%s reason=%s", self._log_tag, code, exc.reason - ) + logger.warning("[%s] WebSocket closed: code=%s reason=%s", self._log_tag, code, exc.reason) # Quick disconnect detection (permission issues, misconfiguration) duration = time.monotonic() - connect_time if duration < QUICK_DISCONNECT_THRESHOLD and connect_time > 0: quick_disconnect_count += 1 logger.info( - "[%s] Quick disconnect (%.1fs), count: %d", - self._log_tag, duration, quick_disconnect_count, + "[%s] Quick disconnect (%.1fs), count: %d", self._log_tag, duration, quick_disconnect_count ) if quick_disconnect_count >= MAX_QUICK_DISCONNECT_COUNT: logger.error( @@ -473,9 +384,7 @@ class QQAdapter(BasePlatformAdapter): self._log_tag, ) self._set_fatal_error( - "qq_quick_disconnect", - "Too many quick disconnects — check bot permissions", - retryable=True, + "qq_quick_disconnect", "Too many quick disconnects — check bot permissions", retryable=True ) return else: @@ -491,44 +400,29 @@ class QQAdapter(BasePlatformAdapter): return if code == 4008: - logger.info( - "[%s] Rate limited (4008), waiting %ds", self._log_tag, RATE_LIMIT_DELAY - ) + logger.info("[%s] Rate limited (4008), waiting %ds", self._log_tag, RATE_LIMIT_DELAY) if backoff_idx >= MAX_RECONNECT_ATTEMPTS: self._mark_disconnected() return await asyncio.sleep(RATE_LIMIT_DELAY) - if await self._reconnect(backoff_idx): - backoff_idx = 0 - quick_disconnect_count = 0 - else: - backoff_idx += 1 + await reconnect() continue if code == 4004: - logger.info( - "[%s] Invalid token (4004), will refresh and reconnect", self._log_tag - ) + logger.info("[%s] Invalid token (4004), will refresh and reconnect", self._log_tag) self._access_token = None self._token_expires_at = 0.0 if code in self._SESSION_INVALID_CLOSE_CODES: - logger.info( - "[%s] Session error (%d), clearing session for re-identify", - self._log_tag, code, - ) + logger.info("[%s] Session error (%d), clearing session for re-identify", self._log_tag, code) self._session_id = None self._last_seq = None - if await self._reconnect(backoff_idx): - backoff_idx = 0 - quick_disconnect_count = 0 - else: - backoff_idx += 1 - if backoff_idx >= MAX_RECONNECT_ATTEMPTS: - logger.error("[%s] Max reconnect attempts reached (QQCloseError)", self._log_tag) - self._mark_disconnected() - return + await reconnect() + if backoff_idx >= MAX_RECONNECT_ATTEMPTS: + logger.error("[%s] Max reconnect attempts reached (QQCloseError)", self._log_tag) + self._mark_disconnected() + return except Exception as exc: if not self._running: @@ -541,12 +435,7 @@ class QQAdapter(BasePlatformAdapter): logger.error("[%s] Max reconnect attempts reached", self._log_tag) self._mark_disconnected() return - - if await self._reconnect(backoff_idx): - backoff_idx = 0 - quick_disconnect_count = 0 - else: - backoff_idx += 1 + await reconnect() async def _reconnect(self, backoff_idx: int) -> bool: """Attempt to reconnect the WebSocket. Returns True on success.""" @@ -614,29 +503,21 @@ class QQAdapter(BasePlatformAdapter): return True async def _send_identify(self) -> None: - """Send op 2 Identify (reply to Hello); server answers with READY. - - Intents: C2C_GROUP_AT_MESSAGES | PUBLIC_GUILD_MESSAGES | DIRECT_MESSAGE | INTERACTION. - """ + """Send op 2 Identify (reply to Hello); server answers with READY. Intents: + C2C_GROUP_AT_MESSAGES | PUBLIC_GUILD_MESSAGES | DIRECT_MESSAGE | INTERACTION.""" token = await self._ensure_token() - payload = { - "op": 2, - "d": { - "token": f"QQBot {token}", - "intents": (1 << 25) | (1 << 30) | (1 << 12) | (1 << 26), - "shard": [0, 1], - "properties": {"$os": "macOS", "$browser": "hermes-agent", "$device": "hermes-agent"}, - }, - } + payload = {"op": 2, "d": { + "token": f"QQBot {token}", + "intents": (1 << 25) | (1 << 30) | (1 << 12) | (1 << 26), + "shard": [0, 1], + "properties": {"$os": "macOS", "$browser": "hermes-agent", "$device": "hermes-agent"}, + }} await self._send_ws_auth("Identify", payload, "Identify sent") async def _send_resume(self) -> None: """Send op 6 Resume after a reconnect; on failure clear session → Identify next Hello.""" token = await self._ensure_token() - payload = { - "op": 6, - "d": {"token": f"QQBot {token}", "session_id": self._session_id, "seq": self._last_seq}, - } + payload = {"op": 6, "d": {"token": f"QQBot {token}", "session_id": self._session_id, "seq": self._last_seq}} if not await self._send_ws_auth( "Resume", payload, "Resume sent (session_id=%s, seq=%s)", self._session_id, self._last_seq ): @@ -652,32 +533,31 @@ class QQAdapter(BasePlatformAdapter): except RuntimeError: return None + def _close_ws_soon(self) -> None: + """Close the WS so _read_events raises and _listen_loop reconnects (with Resume).""" + if self._ws and not self._ws.closed: + self._create_task(self._ws.close()) + def _dispatch_payload(self, payload: Dict[str, Any]) -> None: """Route inbound WebSocket payloads (dispatch synchronously, spawn async handlers).""" - op = payload.get("op") - t = payload.get("t") - s = payload.get("s") - d = payload.get("d") + op, t, s, d = payload.get("op"), payload.get("t"), payload.get("s"), payload.get("d") if isinstance(s, int) and (self._last_seq is None or s > self._last_seq): self._last_seq = s if op == 10: # Hello — reply with Resume (have session) or Identify - d_data = d if isinstance(d, dict) else {} - interval_ms = d_data.get("heartbeat_interval", 30000) + interval_ms = (d if isinstance(d, dict) else {}).get("heartbeat_interval", 30000) self._heartbeat_interval = interval_ms / 1000.0 * 0.8 # 80% of server interval logger.debug( "[%s] Hello received, heartbeat_interval=%dms (sending every %.1fs)", self._log_tag, interval_ms, self._heartbeat_interval, ) - if self._session_id and self._last_seq is not None: - self._create_task(self._send_resume()) - else: - self._create_task(self._send_identify()) - return - - if op == 0 and t: # Dispatch + resume = self._session_id and self._last_seq is not None + self._create_task(self._send_resume() if resume else self._send_identify()) + elif op == 0 and t: # Dispatch if t == "READY": - self._handle_ready(d) + if isinstance(d, dict): # store session_id for resume + self._session_id = d.get("session_id") + logger.info("[%s] Ready, session_id=%s", self._log_tag, self._session_id) elif t == "RESUMED": logger.info("[%s] Session resumed", self._log_tag) elif t in self._INBOUND_HANDLERS: @@ -686,41 +566,23 @@ class QQAdapter(BasePlatformAdapter): self._create_task(self._on_interaction(d)) else: logger.debug("[%s] Unhandled dispatch: %s", self._log_tag, t) - return - - if op == 11: # Heartbeat ACK - return - - if op == 7: # Server Reconnect — close so _read_events raises and we reconnect w/ Resume + elif op == 11: # Heartbeat ACK + pass + elif op == 7: # Server Reconnect logger.info("[%s] Server requested reconnect (op 7)", self._log_tag) - if self._ws and not self._ws.closed: - self._create_task(self._ws.close()) - return - - if op == 9: # Invalid Session — d=True resumable, d=False re-identify from scratch + self._close_ws_soon() + elif op == 9: # Invalid Session — d=True resumable, d=False re-identify from scratch if d is not None and bool(d): logger.info("[%s] Invalid session (op 9, resumable)", self._log_tag) else: - logger.info( - "[%s] Invalid session (op 9, not resumable), clearing session", self._log_tag - ) + logger.info("[%s] Invalid session (op 9, not resumable), clearing session", self._log_tag) self._session_id = None self._last_seq = None - if self._ws and not self._ws.closed: - self._create_task(self._ws.close()) - return + self._close_ws_soon() + else: + logger.debug("[%s] Unknown op: %s", self._log_tag, op) - logger.debug("[%s] Unknown op: %s", self._log_tag, op) - - def _handle_ready(self, d: Any) -> None: - """Handle the READY event — store session_id for resume.""" - if isinstance(d, dict): - self._session_id = d.get("session_id") - logger.info("[%s] Ready, session_id=%s", self._log_tag, self._session_id) - - # ------------------------------------------------------------------ - # JSON helpers - # ------------------------------------------------------------------ + # ── JSON helpers ── @staticmethod def _parse_json(raw: Any) -> Optional[Dict[str, Any]]: @@ -738,9 +600,7 @@ class QQAdapter(BasePlatformAdapter): rand = int(uuid.uuid4().hex[:4], 16) return (time_part ^ rand) % 65536 - # ------------------------------------------------------------------ - # Inbound message handling - # ------------------------------------------------------------------ + # ── Inbound message handling ── async def handle_message(self, event: MessageEvent) -> None: """Cache the last message ID per chat, then delegate to base.""" @@ -765,14 +625,9 @@ class QQAdapter(BasePlatformAdapter): if handler: await getattr(self, handler)(d, msg_id, content, author, timestamp) - # ------------------------------------------------------------------ - # Inline-keyboard interactions (INTERACTION_CREATE) - # ------------------------------------------------------------------ + # ── Inline-keyboard interactions (INTERACTION_CREATE) ── - def set_interaction_callback( - self, - callback: Optional[Callable[[InteractionEvent], Awaitable[None]]], - ) -> None: + def set_interaction_callback(self, callback: Optional[Callable[[InteractionEvent], Awaitable[None]]]) -> None: """Register (or clear) the callback invoked per ACKed INTERACTION_CREATE.""" self._interaction_callback = callback @@ -802,8 +657,7 @@ class QQAdapter(BasePlatformAdapter): callback = self._interaction_callback if callback is None: logger.debug( - "[%s] No interaction callback registered; dropping button click %r", - self._log_tag, event.button_data, + "[%s] No interaction callback registered; dropping button click %r", self._log_tag, event.button_data ) return try: @@ -813,13 +667,9 @@ class QQAdapter(BasePlatformAdapter): async def _acknowledge_interaction(self, interaction_id: str, code: int = 0) -> None: """ACK a button interaction via ``PUT /interactions/{id}`` (code 0 = success).""" - if not self._http_client: - raise RuntimeError("HTTP client not initialized — not connected?") - resp = await self._http_client.put( + resp = await self._require_http_client().put( f"{API_BASE}/interactions/{interaction_id}", - headers=await self._auth_headers(), - json={"code": code}, - timeout=DEFAULT_API_TIMEOUT, + headers=await self._auth_headers(), json={"code": code}, timeout=DEFAULT_API_TIMEOUT, ) if resp.status_code >= 400: raise RuntimeError(f"Interaction ACK failed [{resp.status_code}]: {resp.text[:200]}") @@ -839,11 +689,7 @@ class QQAdapter(BasePlatformAdapter): parsed["user_id"] = parts[5] return parsed - def _is_authorized_interaction_for_session( - self, - event: InteractionEvent, - session_key: str, - ) -> bool: + def _is_authorized_interaction_for_session(self, event: InteractionEvent, session_key: str) -> bool: """Authorize approval/update interactions against session + operator.""" parsed = self._parse_gateway_session_key(session_key) operator = str(event.operator_openid or "").strip() @@ -854,20 +700,15 @@ class QQAdapter(BasePlatformAdapter): chat_id = parsed.get("chat_id", "") if chat_type == "c2c": return bool(chat_id) and operator == chat_id - if chat_type in {"group", "guild"}: event_chat = str(event.group_openid or event.guild_id or "").strip() if not event_chat or event_chat != chat_id: return False session_user = str(parsed.get("user_id", "")).strip() return bool(session_user) and operator == session_user - return False - async def _default_interaction_dispatch( - self, - event: InteractionEvent, - ) -> None: + async def _default_interaction_dispatch(self, event: InteractionEvent) -> None: """Default interaction callback: ``approve::`` → tools.approval.resolve_gateway_approval; ``update_prompt:`` → ``~/.hermes/.update_response``; anything else is ignored at DEBUG.""" @@ -880,15 +721,11 @@ class QQAdapter(BasePlatformAdapter): session_key, decision = approval choice = self._APPROVAL_BUTTON_TO_CHOICE.get(decision) if choice is None: - logger.warning( - "[%s] Unknown approval decision %r (session=%s)", - self._log_tag, decision, session_key, - ) + logger.warning("[%s] Unknown approval decision %r (session=%s)", self._log_tag, decision, session_key) return if not self._is_authorized_interaction_for_session(event, session_key): logger.warning( - "[%s] Rejected unauthorized approval click for session %s " - "(operator=%s)", + "[%s] Rejected unauthorized approval click for session %s (operator=%s)", self._log_tag, session_key, event.operator_openid, ) return @@ -900,28 +737,21 @@ class QQAdapter(BasePlatformAdapter): self._log_tag, count, session_key, choice, event.operator_openid, ) except Exception as exc: - logger.error( - "[%s] resolve_gateway_approval failed for session %s: %s", - self._log_tag, session_key, exc, - ) + logger.error("[%s] resolve_gateway_approval failed for session %s: %s", self._log_tag, session_key, exc) return update_answer = parse_update_prompt_button_data(button_data) if update_answer is not None: - update_session_key = f"agent:main:qqbot:{event.scene}:{event.group_openid or event.guild_id or event.user_openid}" - if not self._is_authorized_interaction_for_session(event, update_session_key): + chat = event.group_openid or event.guild_id or event.user_openid + if not self._is_authorized_interaction_for_session(event, f"agent:main:qqbot:{event.scene}:{chat}"): logger.warning( - "[%s] Rejected unauthorized update prompt click (operator=%s)", - self._log_tag, event.operator_openid, + "[%s] Rejected unauthorized update prompt click (operator=%s)", self._log_tag, event.operator_openid ) return self._write_update_response(update_answer, event.operator_openid) return - logger.debug( - "[%s] Unrecognised button_data %r from interaction %s", - self._log_tag, button_data, event.id, - ) + logger.debug("[%s] Unrecognised button_data %r from interaction %s", self._log_tag, button_data, event.id) @staticmethod def _write_update_response(answer: str, operator: str = "") -> None: @@ -937,144 +767,83 @@ class QQAdapter(BasePlatformAdapter): except Exception as exc: logger.error("Failed to write update response: %s", exc) - async def _handle_c2c_message( - self, - d: Dict[str, Any], - msg_id: str, - content: str, - author: Dict[str, Any], - timestamp: str, - ) -> None: + async def _handle_c2c_message(self, d, msg_id, content, author, timestamp) -> None: """Handle a C2C (private) message event.""" user_openid = str(author.get("user_openid", "")) - if not user_openid: - return - if not self._is_dm_intake_allowed(user_openid): + if not user_openid or not self._is_dm_intake_allowed(user_openid): return attachments_raw = d.get("attachments") logger.info( "[%s] C2C message: id=%s content=%r attachments=%s", - self._log_tag, - msg_id, - content[:50] if content else "", - ( - f"{len(attachments_raw) if isinstance(attachments_raw, list) else 0} items" - if attachments_raw - else "None" - ), + self._log_tag, msg_id, content[:50] if content else "", + f"{len(attachments_raw) if isinstance(attachments_raw, list) else 0} items" if attachments_raw else "None", ) if attachments_raw and isinstance(attachments_raw, list): for _i, _att in enumerate(attachments_raw): if isinstance(_att, dict): logger.info( "[%s] attachment[%d]: content_type=%s url=%s filename=%s", - self._log_tag, - _i, - _att.get("content_type", ""), - str(_att.get("url", ""))[:80], - _att.get("filename", ""), + self._log_tag, _i, _att.get("content_type", ""), + str(_att.get("url", ""))[:80], _att.get("filename", ""), ) - text, image_urls, image_media_types, n_voice = await self._absorb_attachments( - content, attachments_raw - ) - logger.info( - "[%s] After processing: images=%d, voice=%d", self._log_tag, len(image_urls), n_voice - ) + text, image_urls, image_media_types, n_voice = await self._absorb_attachments(content, attachments_raw) + logger.info("[%s] After processing: images=%d, voice=%d", self._log_tag, len(image_urls), n_voice) await self._emit_inbound( d, msg_id, timestamp, text, image_urls, image_media_types, chat_id=user_openid, qq_chat_type="c2c", user_id=user_openid, chat_type="dm", ) - async def _handle_group_message( - self, - d: Dict[str, Any], - msg_id: str, - content: str, - author: Dict[str, Any], - timestamp: str, - ) -> None: + async def _handle_group_message(self, d, msg_id, content, author, timestamp) -> None: """Handle a group @-message event.""" group_openid = str(d.get("group_openid", "")) - if not group_openid: + member = str(author.get("member_openid", "")) + if not group_openid or not self._is_group_allowed(group_openid, member): return - if not self._is_group_allowed(group_openid, str(author.get("member_openid", ""))): - return - text, image_urls, image_media_types, _ = await self._absorb_attachments( self._strip_at_mention(content), d.get("attachments") ) await self._emit_inbound( d, msg_id, timestamp, text, image_urls, image_media_types, - chat_id=group_openid, qq_chat_type="group", - user_id=str(author.get("member_openid", "")), chat_type="group", + chat_id=group_openid, qq_chat_type="group", user_id=member, chat_type="group", ) - async def _handle_guild_message( - self, - d: Dict[str, Any], - msg_id: str, - content: str, - author: Dict[str, Any], - timestamp: str, - ) -> None: + async def _handle_guild_message(self, d, msg_id, content, author, timestamp) -> None: """Handle a guild/channel message event.""" channel_id = str(d.get("channel_id", "")) if not channel_id: return - # group_policy ACL — guild channels are group-like; without it any guild # member could bypass the allowlist. guild_id = str(d.get("guild_id", "")) author_id = str(author.get("id", "")) if not self._is_group_allowed(guild_id or channel_id, author_id): - logger.debug( - "[%s] Guild message blocked by ACL: channel=%s user=%s", - self._log_tag, channel_id, author_id, - ) + logger.debug("[%s] Guild message blocked by ACL: channel=%s user=%s", self._log_tag, channel_id, author_id) return member = d.get("member") if isinstance(d.get("member"), dict) else {} nick = str(member.get("nick", "")) or str(author.get("username", "")) - - text, image_urls, image_media_types, _ = await self._absorb_attachments( - content, d.get("attachments") - ) + text, image_urls, image_media_types, _ = await self._absorb_attachments(content, d.get("attachments")) await self._emit_inbound( d, msg_id, timestamp, text, image_urls, image_media_types, - chat_id=channel_id, qq_chat_type="guild", - user_id=str(author.get("id", "")), user_name=nick or None, chat_type="group", + chat_id=channel_id, qq_chat_type="guild", user_id=author_id, user_name=nick or None, chat_type="group", ) - async def _handle_dm_message( - self, - d: Dict[str, Any], - msg_id: str, - content: str, - author: Dict[str, Any], - timestamp: str, - ) -> None: + async def _handle_dm_message(self, d, msg_id, content, author, timestamp) -> None: """Handle a guild DM message event.""" guild_id = str(d.get("guild_id", "")) if not guild_id: return - # dm_policy ACL — without it any guild member could bypass the allowlist via DM. author_id = str(author.get("id", "")) if not self._is_dm_intake_allowed(author_id): - logger.debug( - "[%s] Guild DM blocked by ACL: guild=%s user=%s", - self._log_tag, guild_id, author_id, - ) + logger.debug("[%s] Guild DM blocked by ACL: guild=%s user=%s", self._log_tag, guild_id, author_id) return - - text, image_urls, image_media_types, _ = await self._absorb_attachments( - content, d.get("attachments") - ) + text, image_urls, image_media_types, _ = await self._absorb_attachments(content, d.get("attachments")) await self._emit_inbound( d, msg_id, timestamp, text, image_urls, image_media_types, - chat_id=guild_id, qq_chat_type="dm", user_id=str(author.get("id", "")), chat_type="dm", + chat_id=guild_id, qq_chat_type="dm", user_id=author_id, chat_type="dm", ) _INBOUND_HANDLERS = { @@ -1085,18 +854,14 @@ class QQAdapter(BasePlatformAdapter): "DIRECT_MESSAGE_CREATE": "_handle_dm_message", } - # ------------------------------------------------------------------ - # Shared inbound pipeline (all four message kinds) - # ------------------------------------------------------------------ + # ── Shared inbound pipeline (all four message kinds) ── @staticmethod def _append_block(text: str, block: str) -> str: """Append *block* to *text* after a blank line (or return block alone if text is blank).""" return (text + "\n\n" + block).strip() if text.strip() else block - async def _absorb_attachments( - self, text: str, attachments: Any, - ) -> Tuple[str, List[str], List[str], int]: + async def _absorb_attachments(self, text: str, attachments: Any) -> Tuple[str, List[str], List[str], int]: """Run attachments through _process_attachments and fold transcripts/file info into *text*. Returns (text, image_urls, image_media_types, n_voice).""" att = await self._process_attachments(attachments) @@ -1108,17 +873,8 @@ class QQAdapter(BasePlatformAdapter): return text, att["image_urls"], att["image_media_types"], len(voice_transcripts) async def _emit_inbound( - self, - d: Dict[str, Any], - msg_id: str, - timestamp: str, - text: str, - image_urls: List[str], - image_media_types: List[str], - *, - chat_id: str, - qq_chat_type: str, - **source_kwargs: Any, + self, d: Dict[str, Any], msg_id: str, timestamp: str, text: str, image_urls: List[str], + image_media_types: List[str], *, chat_id: str, qq_chat_type: str, **source_kwargs: Any, ) -> None: """Merge quoted context, drop empty events, remember the QQ chat kind and dispatch.""" quoted = await self._process_quoted_context(d) @@ -1126,7 +882,6 @@ class QQAdapter(BasePlatformAdapter): if quoted["image_urls"]: image_urls = image_urls + quoted["image_urls"] image_media_types = image_media_types + quoted["image_media_types"] - if not text.strip() and not image_urls: return @@ -1143,24 +898,14 @@ class QQAdapter(BasePlatformAdapter): ) await self.handle_message(event) - # ------------------------------------------------------------------ - # Quoted-message handling - # ------------------------------------------------------------------ + # ── Quoted-message handling ── - async def _process_quoted_context( - self, - d: Dict[str, Any], - ) -> Dict[str, Any]: - """Process the quoted message a user is replying to. - - A quote-reply has ``message_type == 103`` with the referenced message's - content + attachments in ``msg_elements`` (normally just [0]). Quoted - attachments run through the same _process_attachments pipeline, so - quoted voice gets STT and quoted images are cached identically. - - Returns ``{"quote_block", "image_urls", "image_media_types"}``; - quote_block is "" when nothing is quoted. - """ + async def _process_quoted_context(self, d: Dict[str, Any]) -> Dict[str, Any]: + """Process the quoted message a user is replying to (``message_type == 103``; + referenced content + attachments live in ``msg_elements``). Quoted attachments + go through _process_attachments so quoted voice gets STT and quoted images are + cached identically. Returns ``{"quote_block", "image_urls", "image_media_types"}``; + quote_block is "" when nothing is quoted.""" empty = {"quote_block": "", "image_urls": [], "image_media_types": []} try: if int(d.get("message_type", 0) or 0) != 103: @@ -1182,9 +927,7 @@ class QQAdapter(BasePlatformAdapter): quoted_text_parts.append(etext) eatts = elem.get("attachments") if isinstance(eatts, list): - for a in eatts: - if isinstance(a, dict): - all_attachments.append(a) + all_attachments.extend(a for a in eatts if isinstance(a, dict)) att_result = await self._process_attachments(all_attachments) quoted_images = att_result.get("image_urls") or [] @@ -1199,11 +942,8 @@ class QQAdapter(BasePlatformAdapter): if not lines and not quoted_images: return empty # Images-only quote still gets a marker so the LLM knows context was referenced. - quote_block = ( - "[Quoted message]:\n" + "\n".join(lines) if lines else "[Quoted message]: (image)" - ) return { - "quote_block": quote_block, + "quote_block": "[Quoted message]:\n" + "\n".join(lines) if lines else "[Quoted message]: (image)", "image_urls": quoted_images, "image_media_types": att_result.get("image_media_types") or [], } @@ -1213,13 +953,9 @@ class QQAdapter(BasePlatformAdapter): """Prepend ``quote_block`` to *text*, separated by a blank line.""" if not quote_block: return text - if text.strip(): - return f"{quote_block}\n\n{text}".strip() - return quote_block + return f"{quote_block}\n\n{text}".strip() if text.strip() else quote_block - # ------------------------------------------------------------------ - # Attachment processing - # ------------------------------------------------------------------ + # ── Attachment processing ── @staticmethod def _detect_message_type(media_urls: list, media_types: list): @@ -1238,16 +974,10 @@ class QQAdapter(BasePlatformAdapter): logger.debug("Unknown media content_type '%s', defaulting to TEXT", first_type) return MessageType.TEXT - async def _process_attachments( - self, - attachments: Any, - ) -> Dict[str, Any]: - """Process inbound attachments uniformly (images, voice, other files). - - Returns ``{"image_urls", "image_media_types", "voice_transcripts", "attachment_info"}`` - (cached image paths + MIME types, "[Voice] ..." transcripts, and a text - description of non-image/non-voice files). - """ + async def _process_attachments(self, attachments: Any) -> Dict[str, Any]: + """Process inbound attachments uniformly. Returns ``{"image_urls", + "image_media_types", "voice_transcripts", "attachment_info"}`` (cached image + paths + MIME types, "[Voice] ..." transcripts, text description of other files).""" image_urls: List[str] = [] image_media_types: List[str] = [] voice_transcripts: List[str] = [] @@ -1256,7 +986,6 @@ class QQAdapter(BasePlatformAdapter): for att in attachments if isinstance(attachments, list) else (): if not isinstance(att, dict): continue - ct = str(att.get("content_type", "")).strip().lower() url = str(att.get("url", "")).strip() filename = str(att.get("filename", "")) @@ -1264,7 +993,6 @@ class QQAdapter(BasePlatformAdapter): continue if url.startswith("//"): url = f"https:{url}" - logger.debug( "[%s] Processing attachment: content_type=%s, url=%s, filename=%s", self._log_tag, ct, url[:80], filename, @@ -1274,9 +1002,7 @@ class QQAdapter(BasePlatformAdapter): asr_refer = att.get("asr_refer_text") voice_wav_url = att.get("voice_wav_url") transcript = await self._stt_voice_attachment( - url, - ct, - filename, + url, ct, filename, asr_refer_text=(asr_refer.strip() if isinstance(asr_refer, str) else "") or None, voice_wav_url=(voice_wav_url.strip() if isinstance(voice_wav_url, str) else "") or None, ) @@ -1293,9 +1019,7 @@ class QQAdapter(BasePlatformAdapter): image_urls.append(cached_path) image_media_types.append(ct or "image/jpeg") elif cached_path: - logger.warning( - "[%s] Cached image path does not exist: %s", self._log_tag, cached_path - ) + logger.warning("[%s] Cached image path does not exist: %s", self._log_tag, cached_path) except Exception as exc: logger.debug("[%s] Failed to cache image: %s", self._log_tag, exc) else: @@ -1314,9 +1038,7 @@ class QQAdapter(BasePlatformAdapter): "attachment_info": "\n".join(other_attachments), } - async def _download_and_cache( - self, url: str, content_type: str, original_name: str = "", - ) -> Optional[str]: + async def _download_and_cache(self, url: str, content_type: str, original_name: str = "") -> Optional[str]: """Download a URL and cache it locally (``original_name`` falls back to the URL basename).""" from tools.url_safety import is_safe_url @@ -1324,7 +1046,6 @@ class QQAdapter(BasePlatformAdapter): raise ValueError(f"Blocked unsafe URL: {url[:80]}") if not self._http_client: return None - try: resp = await self._http_client.get(url, timeout=30.0, headers=self._qq_media_headers()) resp.raise_for_status() @@ -1334,11 +1055,8 @@ class QQAdapter(BasePlatformAdapter): return None if content_type.startswith("image/"): - # Historical qqbot mapping: trust mimetypes' guess (never the shared - # table) and fall back to .jpg. - ext = ext_for_mime( - content_type, use_defaults=False, use_mimetypes=True, fallback=".jpg" - ) or ".jpg" + # Historical qqbot mapping: trust mimetypes' guess (never the shared table), fall back to .jpg. + ext = ext_for_mime(content_type, use_defaults=False, use_mimetypes=True, fallback=".jpg") or ".jpg" return cache_image_from_bytes(data, ext) if content_type == "voice" or content_type.startswith("audio/"): # QQ voice is usually .amr/.silk — convert to .wav for STT engines. @@ -1359,18 +1077,11 @@ class QQAdapter(BasePlatformAdapter): def _qq_media_headers(self) -> Dict[str, str]: """Authorization header for QQ multimedia CDN downloads (required, else non-200).""" - if self._access_token: - return {"Authorization": f"QQBot {self._access_token}"} - return {} + return {"Authorization": f"QQBot {self._access_token}"} if self._access_token else {} async def _stt_voice_attachment( - self, - url: str, - content_type: str, - filename: str, - *, - asr_refer_text: Optional[str] = None, - voice_wav_url: Optional[str] = None, + self, url: str, content_type: str, filename: str, *, + asr_refer_text: Optional[str] = None, voice_wav_url: Optional[str] = None, ) -> Optional[str]: """Transcribe a voice attachment. Priority: QQ's free ``asr_refer_text`` → STT on ``voice_wav_url`` (pre-converted WAV, no SILK decode) → STT on the @@ -1397,7 +1108,6 @@ class QQAdapter(BasePlatformAdapter): if not self._http_client: logger.warning("[%s] STT: no HTTP client", self._log_tag) return None - download_headers = self._qq_media_headers() # QQ CDN requires Authorization logger.debug( "[%s] STT: downloading voice from %s (pre_wav=%s, headers=%s)", @@ -1414,17 +1124,13 @@ class QQAdapter(BasePlatformAdapter): ) if len(audio_data) < 10: logger.warning( - "[%s] STT: downloaded data too small (%d bytes), skipping", - self._log_tag, len(audio_data), + "[%s] STT: downloaded data too small (%d bytes), skipping", self._log_tag, len(audio_data) ) return None if is_pre_wav: wav_path = self._write_temp(audio_data, ".wav") - logger.debug( - "[%s] STT: using pre-converted WAV directly (%d bytes)", - self._log_tag, len(audio_data), - ) + logger.debug("[%s] STT: using pre-converted WAV directly (%d bytes)", self._log_tag, len(audio_data)) else: logger.debug("[%s] STT: converting to wav, filename=%r", self._log_tag, filename) wav_path = await self._convert_audio_to_wav_file(audio_data, filename) @@ -1437,17 +1143,13 @@ class QQAdapter(BasePlatformAdapter): transcript = await self._call_stt(wav_path) finally: self._unlink_quiet(wav_path) - if transcript: logger.debug("[%s] STT success: %r", self._log_tag, transcript[:100]) else: logger.warning("[%s] STT: ASR returned empty transcript", self._log_tag) return transcript except (httpx.HTTPStatusError, httpx.TransportError, IOError) as exc: - logger.warning( - "[%s] STT failed for voice attachment: %s: %s", - self._log_tag, type(exc).__name__, exc, - ) + logger.warning("[%s] STT failed for voice attachment: %s: %s", self._log_tag, type(exc).__name__, exc) return None @staticmethod @@ -1471,9 +1173,14 @@ class QQAdapter(BasePlatformAdapter): """True when *wav_path* exists and holds more than a bare 44-byte header.""" return Path(wav_path).exists() and Path(wav_path).stat().st_size > 44 - async def _convert_audio_to_wav_file( - self, audio_data: bytes, filename: str - ) -> Optional[str]: + def _log_converted(self, tool: str, src_path: str, wav_path: str) -> str: + logger.debug( + "[%s] %s converted %s to wav (%d bytes)", + self._log_tag, tool, Path(src_path).name, Path(wav_path).stat().st_size, + ) + return wav_path + + async def _convert_audio_to_wav_file(self, audio_data: bytes, filename: str) -> Optional[str]: """Convert audio bytes to a temp .wav: pilk (SILK, which ffmpeg can't decode) → ffmpeg → raw-PCM last resort. Returns the wav path or None.""" ext = Path(filename).suffix.lower() or self._guess_ext_from_data(audio_data) @@ -1483,7 +1190,6 @@ class QQAdapter(BasePlatformAdapter): ) src_path = self._write_temp(audio_data, ext) wav_path = src_path.rsplit(".", 1)[0] + ".wav" - result = ( await self._convert_silk_to_wav(src_path, wav_path) or await self._convert_ffmpeg_to_wav(src_path, wav_path) @@ -1492,20 +1198,16 @@ class QQAdapter(BasePlatformAdapter): self._unlink_quiet(src_path) return result - @staticmethod - def _guess_ext_from_data(data: bytes) -> str: + _MAGIC_EXTS = ( + (b"#!SILK", ".silk"), (b"\x02!", ".silk"), (b"RIFF", ".wav"), (b"fLaC", ".flac"), + (b"\xff\xfb", ".mp3"), (b"\xff\xf3", ".mp3"), (b"\xff\xf2", ".mp3"), + (b"\x30\x26\xb2\x75", ".ogg"), (b"\x4f\x67\x67\x53", ".ogg"), + ) + + @classmethod + def _guess_ext_from_data(cls, data: bytes) -> str: """Guess file extension from magic bytes (unknown → .amr, QQ's most common).""" - if data[:6] == b"#!SILK" or data[:2] == b"\x02!": - return ".silk" - if data[:4] == b"RIFF": - return ".wav" - if data[:4] == b"fLaC": - return ".flac" - if data[:2] in {b"\xff\xfb", b"\xff\xf3", b"\xff\xf2"}: - return ".mp3" - if data[:4] == b"\x30\x26\xb2\x75" or data[:4] == b"\x4f\x67\x67\x53": - return ".ogg" - return ".amr" + return next((ext for magic, ext in cls._MAGIC_EXTS if data.startswith(magic)), ".amr") @staticmethod def _looks_like_silk(data: bytes) -> bool: @@ -1517,20 +1219,13 @@ class QQAdapter(BasePlatformAdapter): try: import pilk except ImportError: - logger.warning( - "[%s] pilk not installed — cannot decode SILK audio. Run: pip install pilk", - self._log_tag, - ) + logger.warning("[%s] pilk not installed — cannot decode SILK audio. Run: pip install pilk", self._log_tag) return None try: pilk.silk_to_wav(src_path, wav_path, rate=16000) if self._wav_ok(wav_path): - logger.debug( - "[%s] pilk converted %s to wav (%d bytes)", - self._log_tag, Path(src_path).name, Path(wav_path).stat().st_size, - ) - return wav_path + return self._log_converted("pilk", src_path, wav_path) except Exception as exc: logger.debug("[%s] pilk direct conversion failed: %s", self._log_tag, exc) @@ -1573,8 +1268,7 @@ class QQAdapter(BasePlatformAdapter): try: proc = await asyncio.create_subprocess_exec( "ffmpeg", "-y", "-i", src_path, "-ar", "16000", "-ac", "1", wav_path, - stdout=asyncio.subprocess.DEVNULL, - stderr=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.PIPE, ) await asyncio.wait_for(proc.wait(), timeout=30) if proc.returncode != 0: @@ -1589,15 +1283,9 @@ class QQAdapter(BasePlatformAdapter): return None if not self._wav_ok(wav_path): - logger.warning( - "[%s] ffmpeg produced no/small output for %s", self._log_tag, Path(src_path).name - ) + logger.warning("[%s] ffmpeg produced no/small output for %s", self._log_tag, Path(src_path).name) return None - logger.debug( - "[%s] ffmpeg converted %s to wav (%d bytes)", - self._log_tag, Path(src_path).name, Path(wav_path).stat().st_size, - ) - return wav_path + return self._log_converted("ffmpeg", src_path, wav_path) def _resolve_stt_config(self) -> Optional[Dict[str, str]]: """Resolve STT backend: ``extra["stt"]`` config first, then ``QQ_STT_*`` env @@ -1613,11 +1301,8 @@ class QQAdapter(BasePlatformAdapter): provider = stt_cfg.get("provider", "zai") base_url = _STT_PROVIDER_BASE_URLS.get(provider, "") if base_url: - return { - "base_url": base_url, - "api_key": api_key, - "model": model or ("glm-asr" if provider in {"zai", "glm"} else "whisper-1"), - } + default_model = "glm-asr" if provider in {"zai", "glm"} else "whisper-1" + return {"base_url": base_url, "api_key": api_key, "model": model or default_model} qq_stt_key = _resolve_qq_secret("QQ_STT_API_KEY", "") if qq_stt_key: @@ -1654,18 +1339,12 @@ class QQAdapter(BasePlatformAdapter): content = choices[0].get("message", {}).get("content", "") if content.strip(): return content.strip() - text = result.get("text", "") - return text.strip() or None + return result.get("text", "").strip() or None except (httpx.HTTPStatusError, IOError) as exc: - logger.warning( - "[%s] STT API call failed (model=%s, base=%s): %s", - self._log_tag, model, base_url[:50], exc, - ) + logger.warning("[%s] STT API call failed (model=%s, base=%s): %s", self._log_tag, model, base_url[:50], exc) return None - async def _convert_audio_to_wav( - self, audio_data: bytes, source_url: str - ) -> Optional[str]: + async def _convert_audio_to_wav(self, audio_data: bytes, source_url: str) -> Optional[str]: """Convert audio bytes to .wav (pilk for SILK, else ffmpeg) and cache the result; on conversion failure the original bytes are cached as ``qq_voice``.""" ext = Path(urlparse(source_url).path).suffix.lower() @@ -1680,10 +1359,7 @@ class QQAdapter(BasePlatformAdapter): else: result = await self._convert_ffmpeg_to_wav(src_path, wav_path) if not result: - logger.warning( - "[%s] audio conversion failed for %s (format=%s)", - self._log_tag, source_url[:60], ext, - ) + logger.warning("[%s] audio conversion failed for %s (format=%s)", self._log_tag, source_url[:60], ext) return cache_document_from_bytes(audio_data, f"qq_voice{ext}") except Exception: return cache_document_from_bytes(audio_data, f"qq_voice{ext}") @@ -1698,30 +1374,24 @@ class QQAdapter(BasePlatformAdapter): logger.debug("[%s] Failed to read converted wav: %s", self._log_tag, exc) return None - # ------------------------------------------------------------------ - # Outbound messaging — REST API - # ------------------------------------------------------------------ + # ── Outbound messaging — REST API ── - async def _api_request( - self, - method: str, - path: str, - body: Optional[Dict[str, Any]] = None, - timeout: float = DEFAULT_API_TIMEOUT, - ) -> Dict[str, Any]: - """Make an authenticated REST API request to QQ Bot API.""" + def _require_http_client(self) -> "httpx.AsyncClient": if not self._http_client: raise RuntimeError("HTTP client not initialized — not connected?") + return self._http_client + + async def _api_request( + self, method: str, path: str, body: Optional[Dict[str, Any]] = None, timeout: float = DEFAULT_API_TIMEOUT, + ) -> Dict[str, Any]: + """Make an authenticated REST API request to QQ Bot API.""" + client = self._require_http_client() headers = await self._auth_headers() try: - resp = await self._http_client.request( - method, f"{API_BASE}{path}", headers=headers, json=body, timeout=timeout - ) + resp = await client.request(method, f"{API_BASE}{path}", headers=headers, json=body, timeout=timeout) data = resp.json() if resp.status_code >= 400: - raise RuntimeError( - f"QQ Bot API error [{resp.status_code}] {path}: {data.get('message', data)}" - ) + raise RuntimeError(f"QQ Bot API error [{resp.status_code}] {path}: {data.get('message', data)}") return data except httpx.TimeoutException as exc: raise RuntimeError(f"QQ Bot API timeout [{path}]: {exc}") from exc @@ -1736,18 +1406,11 @@ class QQAdapter(BasePlatformAdapter): } async def _upload_media( - self, - target_type: str, - target_id: str, - file_type: int, - url: Optional[str] = None, - file_data: Optional[str] = None, - srv_send_msg: bool = False, - file_name: Optional[str] = None, + self, target_type: str, target_id: str, file_type: int, url: Optional[str] = None, + file_data: Optional[str] = None, srv_send_msg: bool = False, file_name: Optional[str] = None, ) -> Dict[str, Any]: """Upload media and return file_info.""" - kind = "users" if target_type == "c2c" else "groups" - path = f"/v2/{kind}/{target_id}/files" + path = self._rest_path(target_type, target_id, "files") body: Dict[str, Any] = {"file_type": file_type, "srv_send_msg": srv_send_msg} if url: body["url"] = url @@ -1761,23 +1424,19 @@ class QQAdapter(BasePlatformAdapter): return await self._api_request("POST", path, body, timeout=FILE_UPLOAD_TIMEOUT) except RuntimeError as exc: err_msg = str(exc) - if any(kw in err_msg for kw in ("400", "401", "Invalid", "timeout", "Timeout")): - raise - if attempt < 2: - await asyncio.sleep(1.5 * (attempt + 1)) - else: + if attempt == 2 or any(kw in err_msg for kw in ("400", "401", "Invalid", "timeout", "Timeout")): raise + await asyncio.sleep(1.5 * (attempt + 1)) - # Maximum time (seconds) to wait for reconnection before giving up on send. - _RECONNECT_WAIT_SECONDS = 15.0 - # How often (seconds) to poll is_connected while waiting. - _RECONNECT_POLL_INTERVAL = 0.5 + _RECONNECT_WAIT_SECONDS = 15.0 # max wait for reconnection before giving up on send + _RECONNECT_POLL_INTERVAL = 0.5 # is_connected poll interval while waiting async def _wait_for_reconnection(self) -> bool: """Poll is_connected for up to _RECONNECT_WAIT_SECONDS — covers the race where send() lands between a disconnect and _listen_loop's reconnect.""" - logger.info("[%s] Not connected — waiting for reconnection (up to %.0fs)", - self._log_tag, self._RECONNECT_WAIT_SECONDS) + logger.info( + "[%s] Not connected — waiting for reconnection (up to %.0fs)", self._log_tag, self._RECONNECT_WAIT_SECONDS + ) waited = 0.0 while waited < self._RECONNECT_WAIT_SECONDS: await asyncio.sleep(self._RECONNECT_POLL_INTERVAL) @@ -1797,11 +1456,7 @@ class QQAdapter(BasePlatformAdapter): return self.is_connected or await self._wait_for_reconnection() async def send( - self, - chat_id: str, - content: str, - reply_to: Optional[str] = None, - metadata: Optional[Dict[str, Any]] = None, + self, chat_id: str, content: str, reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: """Send text/markdown: format, split via truncate_message(), retry transient failures.""" del metadata @@ -1810,78 +1465,64 @@ class QQAdapter(BasePlatformAdapter): if not content or not content.strip(): return SendResult(success=True) - formatted = self.format_message(content) - chunks = self.truncate_message(formatted, self.MAX_MESSAGE_LENGTH) - + chunks = self.truncate_message(self.format_message(content), self.MAX_MESSAGE_LENGTH) last_result = SendResult(success=False, error="No chunks") for chunk in chunks: last_result = await self._send_chunk(chat_id, chunk, reply_to) if not last_result.success: return last_result - # Only reply_to the first chunk - reply_to = None + reply_to = None # only reply_to the first chunk return last_result - async def _send_chunk( - self, - chat_id: str, - content: str, - reply_to: Optional[str] = None, - ) -> SendResult: + _PERMANENT_SEND_ERRORS = ("invalid", "forbidden", "not found") + + async def _send_chunk(self, chat_id: str, content: str, reply_to: Optional[str] = None) -> SendResult: """Send a single chunk with retry + exponential backoff.""" last_exc: Optional[Exception] = None chat_type = self._guess_chat_type(chat_id) - for attempt in range(3): try: if chat_type == "c2c": return await self._send_c2c_text(chat_id, content, reply_to) - elif chat_type == "group": + if chat_type == "group": return await self._send_group_text(chat_id, content, reply_to) - elif chat_type == "guild": + if chat_type == "guild": return await self._send_guild_text(chat_id, content, reply_to) - else: - return SendResult(success=False, error=f"Unknown chat type for {chat_id}") + return SendResult(success=False, error=f"Unknown chat type for {chat_id}") except Exception as exc: last_exc = exc err = str(exc).lower() - if any(k in err for k in ("invalid", "forbidden", "not found", "bad request")): + if any(k in err for k in self._PERMANENT_SEND_ERRORS + ("bad request",)): break # permanent — don't retry if attempt < 2: delay = 1.0 * (2 ** attempt) - logger.warning( - "[%s] send retry %d/3 after %.1fs: %s", - self._log_tag, attempt + 1, delay, exc, - ) + logger.warning("[%s] send retry %d/3 after %.1fs: %s", self._log_tag, attempt + 1, delay, exc) await asyncio.sleep(delay) error_msg = (str(last_exc) or type(last_exc).__name__) if last_exc else "Unknown error" logger.error("[%s] Send failed: %s", self._log_tag, error_msg) - retryable = not any( - k in error_msg.lower() for k in ("invalid", "forbidden", "not found") - ) + retryable = not any(k in error_msg.lower() for k in self._PERMANENT_SEND_ERRORS) return SendResult(success=False, error=error_msg, retryable=retryable) @staticmethod - def _messages_path(chat_type: str, target_id: str) -> str: - """REST path for outbound messages to a c2c user or a group.""" + def _rest_path(chat_type: str, target_id: str, endpoint: str) -> str: + """``/v2/{users|groups}/{id}/{endpoint}`` for a c2c user or a group.""" kind = "users" if chat_type == "c2c" else "groups" - return f"/v2/{kind}/{target_id}/messages" + return f"/v2/{kind}/{target_id}/{endpoint}" + + @classmethod + def _messages_path(cls, chat_type: str, target_id: str) -> str: + """REST path for outbound messages to a c2c user or a group.""" + return cls._rest_path(chat_type, target_id, "messages") async def _post_message(self, path: str, body: Dict[str, Any]) -> SendResult: """POST a message body and wrap the response as a successful SendResult.""" data = await self._api_request("POST", path, body) - return SendResult( - success=True, message_id=str(data.get("id", uuid.uuid4().hex[:12])), raw_response=data - ) + return SendResult(success=True, message_id=str(data.get("id", uuid.uuid4().hex[:12])), raw_response=data) async def _send_text_to( - self, - chat_type: str, - target_id: str, - content: str, - reply_to: Optional[str] = None, - keyboard: Optional[InlineKeyboard] = None, + self, chat_type: str, target_id: str, content: str, reply_to: Optional[str] = None, + keyboard: Optional[InlineKeyboard] = None, ) -> SendResult: """Send text (optionally with an inline keyboard) to a c2c user or group.""" self._next_msg_seq(reply_to or target_id) @@ -1898,25 +1539,17 @@ class QQAdapter(BasePlatformAdapter): async def _send_group_text(self, group_openid, content, reply_to=None, keyboard=None) -> SendResult: return await self._send_text_to("group", group_openid, content, reply_to, keyboard) - async def _send_guild_text( - self, channel_id: str, content: str, reply_to: Optional[str] = None - ) -> SendResult: + async def _send_guild_text(self, channel_id: str, content: str, reply_to: Optional[str] = None) -> SendResult: """Send text to a guild channel via REST API.""" body: Dict[str, Any] = {"content": content[: self.MAX_MESSAGE_LENGTH]} if reply_to: body["msg_id"] = reply_to return await self._post_message(f"/channels/{channel_id}/messages", body) - # ------------------------------------------------------------------ - # Inline-keyboard outbound helpers (approval / update-prompt flows) - # ------------------------------------------------------------------ + # ── Inline-keyboard outbound helpers (approval / update-prompt flows) ── async def send_with_keyboard( - self, - chat_id: str, - content: str, - keyboard: InlineKeyboard, - reply_to: Optional[str] = None, + self, chat_id: str, content: str, keyboard: InlineKeyboard, reply_to: Optional[str] = None, ) -> SendResult: """Send ONE text message with an inline keyboard (no chunking — splitting would orphan the buttons; keep bodies short). Guild chats are unsupported.""" @@ -1930,44 +1563,29 @@ class QQAdapter(BasePlatformAdapter): if chat_type == "group": return await self._send_group_text(chat_id, truncated, reply_to, keyboard=keyboard) return SendResult( - success=False, - error=f"Inline keyboards not supported for chat_type {chat_type!r}", - retryable=False, + success=False, error=f"Inline keyboards not supported for chat_type {chat_type!r}", retryable=False ) except Exception as exc: logger.error("[%s] send_with_keyboard failed: %s", self._log_tag, exc) return SendResult(success=False, error=str(exc) or type(exc).__name__) async def send_approval_request( - self, - chat_id: str, - req: ApprovalRequest, - reply_to: Optional[str] = None, + self, chat_id: str, req: ApprovalRequest, reply_to: Optional[str] = None ) -> SendResult: """Send a 3-button approval request (allow-once / allow-always / deny); clicks come back as INTERACTION_CREATE decoded by parse_approval_button_data.""" from gateway.platforms.qqbot.keyboards import build_approval_text - return await self.send_with_keyboard( - chat_id, - build_approval_text(req), - build_approval_keyboard( - req.session_key, allow_permanent=getattr(req, "allow_permanent", True) - ), - reply_to=reply_to, - ) + keyboard = build_approval_keyboard(req.session_key, allow_permanent=getattr(req, "allow_permanent", True)) + return await self.send_with_keyboard(chat_id, build_approval_text(req), keyboard, reply_to=reply_to) # Cross-adapter gateway contract: gateway/run.py detects send_exec_approval / # send_update_prompt on the adapter class for button-based approval/update UX. + _APPROVAL_TIMEOUT_SECONDS = 300 # matches gateway's default gateway_timeout + async def send_exec_approval( - self, - chat_id: str, - command: str, - session_key: str, - description: str = "dangerous command", - metadata: Optional[Dict[str, Any]] = None, - allow_permanent: bool = True, - allow_session: bool = True, + self, chat_id: str, command: str, session_key: str, description: str = "dangerous command", + metadata: Optional[Dict[str, Any]] = None, allow_permanent: bool = True, allow_session: bool = True, smart_denied: bool = False, ) -> SendResult: """Button-based exec-approval prompt (called by gateway/run.py while the @@ -1976,7 +1594,6 @@ class QQAdapter(BasePlatformAdapter): del allow_session # QQ's 3-button keyboard has no session tier. if smart_denied: description += " Owner override applies to this one operation only." - req = ApprovalRequest( session_key=session_key, title="Execute this command?", @@ -1988,29 +1605,20 @@ class QQAdapter(BasePlatformAdapter): # QQ requires a msg_id for passive replies; the last inbound id is the natural one. return await self.send_approval_request(chat_id, req, reply_to=self._last_msg_id.get(chat_id)) - _APPROVAL_TIMEOUT_SECONDS = 300 # matches gateway's default gateway_timeout - async def send_update_prompt( - self, - chat_id: str, - prompt: str, - default: str = "", - session_key: str = "", - metadata: Optional[Dict[str, Any]] = None, + self, chat_id: str, prompt: str, default: str = "", session_key: str = "", + metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: """Yes/No update-confirmation prompt; button clicks (``update_prompt:y|n``) are written to ``~/.hermes/.update_response`` by the interaction callback.""" del session_key, metadata # present for contract parity only. - default_hint = f" (default: {default})" if default else "" content = f"⚕ **Update Needs Your Input**\n\n{prompt}{default_hint}" return await self.send_with_keyboard( chat_id, content, build_update_prompt_keyboard(), reply_to=self._last_msg_id.get(chat_id) ) - def _build_text_body( - self, content: str, reply_to: Optional[str] = None - ) -> Dict[str, Any]: + def _build_text_body(self, content: str, reply_to: Optional[str] = None) -> Dict[str, Any]: """Build the message body for C2C/group text sending.""" msg_seq = self._next_msg_seq(reply_to or "default") text = content[: self.MAX_MESSAGE_LENGTH] @@ -2021,26 +1629,18 @@ class QQAdapter(BasePlatformAdapter): body["message_reference"] = {"message_id": reply_to} return body - # ------------------------------------------------------------------ - # Native media sending - # ------------------------------------------------------------------ + # ── Native media sending ── async def send_image( - self, - chat_id: str, - image_url: str, - caption: Optional[str] = None, - reply_to: Optional[str] = None, - metadata: Optional[Dict[str, Any]] = None, + self, chat_id: str, image_url: str, caption: Optional[str] = None, reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: """Send an image natively via QQ Bot API upload; URL sources fall back to text.""" del metadata result = await self._send_media(chat_id, image_url, MEDIA_TYPE_IMAGE, "image", caption, reply_to) if result.success or not self._is_url(image_url): return result - logger.warning( - "[%s] Image send failed, falling back to text: %s", self._log_tag, result.error - ) + logger.warning("[%s] Image send failed, falling back to text: %s", self._log_tag, result.error) fallback = f"{caption}\n{image_url}" if caption else image_url return await self.send(chat_id=chat_id, content=fallback, reply_to=reply_to) @@ -2057,7 +1657,7 @@ class QQAdapter(BasePlatformAdapter): return await self._send_media(chat_id, video_path, MEDIA_TYPE_VIDEO, "video", caption, reply_to) async def send_document( - self, chat_id, file_path, caption=None, file_name=None, reply_to=None, **kwargs + self, chat_id, file_path, caption=None, file_name=None, reply_to=None, **kwargs ) -> SendResult: """Send a file/document natively.""" return await self._send_media( @@ -2065,21 +1665,12 @@ class QQAdapter(BasePlatformAdapter): ) async def _send_media( - self, - chat_id: str, - media_source: str, - file_type: int, - kind: str, - caption: Optional[str] = None, - reply_to: Optional[str] = None, - file_name: Optional[str] = None, + self, chat_id: str, media_source: str, file_type: int, kind: str, caption: Optional[str] = None, + reply_to: Optional[str] = None, file_name: Optional[str] = None, ) -> SendResult: - """Upload media and send as a native message. - - HTTP(S) URLs → single ``POST .../files`` with ``url=`` (QQ fetches it). - Local files → chunked upload (prepare / PUT parts / complete), up to the - platform's ~100 MB per-file limit. - """ + """Upload media and send as a native message. HTTP(S) URLs → single ``POST + .../files`` with ``url=`` (QQ fetches it). Local files → chunked upload + (prepare / PUT parts / complete), up to the platform's ~100 MB per-file limit.""" if not await self._ensure_connected(): return self._NOT_CONNECTED chat_type = self._guess_chat_type(chat_id) @@ -2090,11 +1681,7 @@ class QQAdapter(BasePlatformAdapter): if self._is_url(media_source): resolved_name = file_name or Path(urlparse(media_source).path).name or "media" upload = await self._upload_media( - chat_type, - chat_id, - file_type, - url=media_source, - srv_send_msg=False, + chat_type, chat_id, file_type, url=media_source, srv_send_msg=False, file_name=resolved_name if file_type == MEDIA_TYPE_FILE else None, ) else: @@ -2119,16 +1706,11 @@ class QQAdapter(BasePlatformAdapter): except UploadDailyLimitExceededError as exc: # Non-retryable quota hit; give the model actionable text. logger.warning( - "[%s] Daily upload limit exceeded for %s (%s)", - self._log_tag, exc.file_name, exc.file_size_human, + "[%s] Daily upload limit exceeded for %s (%s)", self._log_tag, exc.file_name, exc.file_size_human ) return SendResult( - success=False, - error=( - f"QQ daily upload limit exceeded for {exc.file_name!r} " - f"({exc.file_size_human}). Retry tomorrow." - ), - retryable=False, + success=False, retryable=False, + error=f"QQ daily upload limit exceeded for {exc.file_name!r} ({exc.file_size_human}). Retry tomorrow.", ) except UploadFileTooLargeError as exc: logger.warning( @@ -2136,59 +1718,41 @@ class QQAdapter(BasePlatformAdapter): self._log_tag, exc.file_name, exc.file_size_human, exc.limit_human, ) return SendResult( - success=False, + success=False, retryable=False, error=( f"{exc.file_name!r} ({exc.file_size_human}) exceeds the " f"QQ per-file upload limit ({exc.limit_human})." ), - retryable=False, ) except Exception as exc: logger.error("[%s] Media send failed: %s", self._log_tag, exc) return SendResult(success=False, error=str(exc) or type(exc).__name__) async def _upload_local_file( - self, - chat_type: str, - chat_id: str, - media_source: str, - file_type: int, - file_name: Optional[str], + self, chat_type: str, chat_id: str, media_source: str, file_type: int, file_name: Optional[str], ) -> Tuple[str, Dict[str, Any]]: """Chunked-upload a local file; returns ``(resolved_name, complete_response)`` - whose ``file_info`` goes into the RichMedia body. - - Raises UploadDailyLimitExceededError / UploadFileTooLargeError from the - uploader, ValueError for placeholder paths like ````, FileNotFoundError. - """ - if not self._http_client: - raise RuntimeError("HTTP client not initialized — not connected?") - + whose ``file_info`` goes into the RichMedia body. Raises UploadDailyLimitExceededError / + UploadFileTooLargeError from the uploader, ValueError for placeholder paths like + ````, FileNotFoundError.""" + client = self._require_http_client() local_path = Path(media_source).expanduser() if not local_path.is_absolute(): local_path = (Path.cwd() / local_path).resolve() - if not local_path.exists() or not local_path.is_file(): if media_source.startswith("<") or len(media_source) < 3: raise ValueError(f"Invalid media source (looks like a placeholder): {media_source!r}") raise FileNotFoundError(f"Media file not found: {local_path}") resolved_name = file_name or local_path.name - uploader = ChunkedUploader( - api_request=self._api_request, http_put=self._http_client.put, log_tag=self._log_tag - ) + uploader = ChunkedUploader(api_request=self._api_request, http_put=client.put, log_tag=self._log_tag) complete = await uploader.upload( - chat_type=chat_type, - target_id=chat_id, - file_path=str(local_path), - file_type=file_type, + chat_type=chat_type, target_id=chat_id, file_path=str(local_path), file_type=file_type, file_name=resolved_name, ) return resolved_name, complete - # ------------------------------------------------------------------ - # Typing indicator - # ------------------------------------------------------------------ + # ── Typing indicator ── async def send_typing(self, chat_id: str, metadata=None) -> None: """C2C-only input notify, debounced to ~50s (API shows a 60s indicator); @@ -2201,7 +1765,6 @@ class QQAdapter(BasePlatformAdapter): now = time.time() if now - self._typing_sent_at.get(chat_id, 0.0) < self._TYPING_DEBOUNCE_SECONDS: return - try: body = { "msg_type": MSG_TYPE_INPUT_NOTIFY, @@ -2214,29 +1777,16 @@ class QQAdapter(BasePlatformAdapter): except Exception as exc: logger.debug("[%s] send_typing failed: %s", self._log_tag, exc) - # ------------------------------------------------------------------ - # Format - # ------------------------------------------------------------------ + # ── Format / chat info / helpers ── def format_message(self, content: str) -> str: """Pass markdown through when supported, else strip it (as BlueBubbles/SMS do).""" return content if self._markdown_support else strip_markdown(content) - # ------------------------------------------------------------------ - # Chat info - # ------------------------------------------------------------------ - async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: """Return chat info based on chat type heuristics.""" chat_type = self._guess_chat_type(chat_id) - return { - "name": chat_id, - "type": "group" if chat_type in {"group", "guild"} else "dm", - } - - # ------------------------------------------------------------------ - # Helpers - # ------------------------------------------------------------------ + return {"name": chat_id, "type": "group" if chat_type in {"group", "guild"} else "dm"} @staticmethod def _is_url(source: str) -> bool: @@ -2257,8 +1807,6 @@ class QQAdapter(BasePlatformAdapter): return _resolve_qq_secret("QQ_ALLOW_ALL_USERS", "").lower() in {"true", "1", "yes"} def _is_dm_allowed(self, user_id: str) -> bool: - if self._dm_policy == "disabled": - return False if self._dm_policy == "allowlist": return self._entry_matches(self._allow_from, user_id) if self._dm_policy == "open": @@ -2269,23 +1817,13 @@ class QQAdapter(BasePlatformAdapter): principal = str(user_id or "").strip() if not principal: return False - if self._dm_policy == "disabled": - return False - if self._dm_policy == "allowlist": - return self._entry_matches(self._allow_from, principal) if self._dm_policy == "pairing": return True - if self._dm_policy == "open": - return self._open_dm_opted_in() - return False + return self._is_dm_allowed(principal) def _is_group_allowed(self, group_id: str, user_id: str) -> bool: - if self._group_policy == "disabled": - return False if self._group_policy == "allowlist": return self._entry_matches(self._group_allow_from, group_id) - if self._group_policy == "pairing": - return False return self._group_policy == "open" @staticmethod diff --git a/gateway/platforms/qqbot/chunked_upload.py b/gateway/platforms/qqbot/chunked_upload.py index 32d62b517e..5a933c16af 100644 --- a/gateway/platforms/qqbot/chunked_upload.py +++ b/gateway/platforms/qqbot/chunked_upload.py @@ -1,21 +1,10 @@ -"""QQ Bot chunked upload flow. - -The QQ v2 API caps inline base64 uploads (``file_data`` / ``url``) at ~10 MB. -Files between 10 MB and ~100 MB use the three-step chunked flow:: - - 1. POST /v2/{users|groups}/{id}/upload_prepare - → upload_id, block_size, and pre-signed COS part URLs. - 2. Per part: PUT bytes to the COS URL, then POST .../upload_part_finish. - 3. POST /v2/{users|groups}/{id}/files with {"upload_id": ...} - → ``file_info`` token used in a RichMedia message. - -Error codes (QQ Bot v2 spec): ``40093001`` — ``upload_part_finish`` retryable -until the server's ``retry_timeout`` (or a local cap) elapses; ``40093002`` — -daily upload quota exceeded, surfaced as :class:`UploadDailyLimitExceededError`. -Other API/I/O failures raise ``RuntimeError``. - -Ported from WideLee's qqbot-agent-sdk v1.2.2 (``media_loader.py::ChunkedUploader``). -Authorship preserved via Co-authored-by. +"""QQ Bot chunked upload flow (files 10 MB..~100 MB; inline uploads cap at ~10 MB): +1. POST .../upload_prepare → upload_id, block_size, pre-signed COS part URLs; +2. per part: PUT bytes to COS, then POST .../upload_part_finish; +3. POST .../files with {"upload_id"} → ``file_info`` for a RichMedia message. +biz_code 40093001 = part_finish retryable until ``retry_timeout``; 40093002 = daily +quota (UploadDailyLimitExceededError); other API/I/O failures raise RuntimeError. +Ported from WideLee's qqbot-agent-sdk v1.2.2 (authorship via Co-authored-by). """ from __future__ import annotations @@ -34,60 +23,55 @@ logger = logging.getLogger(__name__) _BIZ_CODE_DAILY_LIMIT = 40093002 # upload_prepare: daily cumulative limit _BIZ_CODE_PART_RETRYABLE = 40093001 # upload_part_finish: transient - _DEFAULT_CONCURRENT_PARTS = 1 _MAX_CONCURRENT_PARTS = 10 - _PART_UPLOAD_TIMEOUT = 300.0 # 5 minutes per COS PUT _PART_UPLOAD_MAX_RETRIES = 2 _PART_FINISH_RETRY_INTERVAL = 1.0 _PART_FINISH_DEFAULT_TIMEOUT = 120.0 _PART_FINISH_MAX_TIMEOUT = 600.0 - _COMPLETE_UPLOAD_MAX_RETRIES = 2 _COMPLETE_UPLOAD_BASE_DELAY = 2.0 - -# First 10,002,432 bytes used for the ``md5_10m`` hash (per QQ API spec). -_MD5_10M_SIZE = 10_002_432 +_MD5_10M_SIZE = 10_002_432 # first N bytes used for the ``md5_10m`` hash (per QQ API spec) -# ── Exceptions ─────────────────────────────────────────────────────── +# ── Exceptions ── -class UploadDailyLimitExceededError(Exception): +class _UploadError(Exception): + def __init__(self, file_name: str, file_size: int, message: str) -> None: + self.file_name = file_name + self.file_size = file_size + super().__init__(message) + + @property + def file_size_human(self) -> str: + return format_size(self.file_size) + + +class UploadDailyLimitExceededError(_UploadError): """Raised when ``upload_prepare`` returns biz_code 40093002 (daily quota hit).""" def __init__(self, file_name: str, file_size: int, message: str = "") -> None: - self.file_name = file_name - self.file_size = file_size - super().__init__(message or f"Daily upload limit exceeded for {file_name!r}") - - @property - def file_size_human(self) -> str: - return format_size(self.file_size) + super().__init__(file_name, file_size, message or f"Daily upload limit exceeded for {file_name!r}") -class UploadFileTooLargeError(Exception): +class UploadFileTooLargeError(_UploadError): """Raised when a file exceeds the platform per-file size limit.""" def __init__(self, file_name: str, file_size: int, limit_bytes: int = 0, message: str = "") -> None: - self.file_name = file_name - self.file_size = file_size self.limit_bytes = limit_bytes limit_str = f" ({format_size(limit_bytes)})" if limit_bytes else "" super().__init__( - message or f"File {file_name!r} ({format_size(file_size)}) exceeds platform limit{limit_str}" + file_name, file_size, + message or f"File {file_name!r} ({format_size(file_size)}) exceeds platform limit{limit_str}", ) - @property - def file_size_human(self) -> str: - return format_size(self.file_size) - @property def limit_human(self) -> str: return format_size(self.limit_bytes) if self.limit_bytes else "unknown" -# ── Prepare-response shape ─────────────────────────────────────────── +# ── Prepare-response shape ── @dataclass class _PreparePart: @@ -138,47 +122,26 @@ def _api_path(chat_type: str, target_id: str, endpoint: str) -> str: return f"{base}/{target_id}/{endpoint}" -# ── Chunked upload driver ──────────────────────────────────────────── +# ── Chunked upload driver ── class ChunkedUploader: - """Run the prepare → PUT parts → complete sequence. - - :param api_request: Bound ``_api_request(method, path, body=..., timeout=...)`` - coroutine from the adapter (passed in rather than imported to avoid - circular imports). Must raise ``RuntimeError`` with the biz_code in - the message on API errors. - :param http_put: Coroutine ``(url, data, headers) -> httpx-like response`` - for COS part uploads. - :param log_tag: Log prefix. - """ + """Run the prepare → PUT parts → complete sequence. ``api_request`` is the adapter's + bound ``_api_request(method, path, body=..., timeout=...)`` (injected to avoid a + circular import; must raise RuntimeError with the biz_code in the message); + ``http_put`` is ``(url, data, headers) -> httpx-like response`` for COS PUTs.""" def __init__( - self, - api_request: Callable[..., Awaitable[Dict[str, Any]]], - http_put: Callable[..., Awaitable[Any]], + self, api_request: Callable[..., Awaitable[Dict[str, Any]]], http_put: Callable[..., Awaitable[Any]], log_tag: str = "QQBot", ) -> None: self._api_request = api_request self._http_put = http_put self._log_tag = log_tag - async def upload( - self, - chat_type: str, - target_id: str, - file_path: str, - file_type: int, - file_name: str, - ) -> Dict[str, Any]: - """Run the full chunked upload and return the ``complete_upload`` response. - - :param chat_type: ``'c2c'`` or ``'group'``. - :param file_type: ``MEDIA_TYPE_*`` constant. - :returns: Raw ``complete_upload`` response dict (contains ``file_info``). - :raises UploadDailyLimitExceededError: On biz_code 40093002. - :raises UploadFileTooLargeError: When the file exceeds the platform limit. - :raises RuntimeError: On other API or I/O failures. - """ + async def upload(self, chat_type: str, target_id: str, file_path: str, file_type: int, file_name: str) -> Dict[str, Any]: + """Run the full chunked upload (``chat_type`` 'c2c'|'group', ``file_type`` MEDIA_TYPE_*) + and return the raw ``complete_upload`` response (contains ``file_info``). + Raises UploadDailyLimitExceededError (40093002), UploadFileTooLargeError, RuntimeError.""" if chat_type not in {"c2c", "group"}: raise ValueError(f"ChunkedUploader: unsupported chat_type {chat_type!r}") @@ -189,9 +152,7 @@ class ChunkedUploader: ) # Hashing is blocking I/O → executor. - hashes = await asyncio.get_running_loop().run_in_executor( - None, _compute_file_hashes, file_path, file_size - ) + hashes = await asyncio.get_running_loop().run_in_executor(None, _compute_file_hashes, file_path, file_size) prepare = await self._prepare(chat_type, target_id, file_type, file_name, file_size, hashes) max_concurrent = min(prepare.concurrency, _MAX_CONCURRENT_PARTS) retry_timeout = min( @@ -220,26 +181,15 @@ class ChunkedUploader: return await self._complete(chat_type, target_id, prepare.upload_id) async def _prepare( - self, - chat_type: str, - target_id: str, - file_type: int, - file_name: str, - file_size: int, - hashes: Dict[str, str], + self, chat_type: str, target_id: str, file_type: int, file_name: str, file_size: int, hashes: Dict[str, str], ) -> _PrepareResult: body = { - "file_type": file_type, - "file_name": file_name, - "file_size": file_size, - "md5": hashes["md5"], - "sha1": hashes["sha1"], - "md5_10m": hashes["md5_10m"], + "file_type": file_type, "file_name": file_name, "file_size": file_size, + "md5": hashes["md5"], "sha1": hashes["sha1"], "md5_10m": hashes["md5_10m"], } try: raw = await self._api_request( - "POST", _api_path(chat_type, target_id, "upload_prepare"), - body=body, timeout=FILE_UPLOAD_TIMEOUT, + "POST", _api_path(chat_type, target_id, "upload_prepare"), body=body, timeout=FILE_UPLOAD_TIMEOUT, ) except RuntimeError as exc: err_msg = str(exc) @@ -249,17 +199,8 @@ class ChunkedUploader: return _parse_prepare_response(raw) async def _upload_one_part( - self, - chat_type: str, - target_id: str, - file_path: str, - file_size: int, - upload_id: str, - rsp_block_size: int, - part: _PreparePart, - retry_timeout: float, - total_parts: int, - completed: List[int], + self, chat_type: str, target_id: str, file_path: str, file_size: int, upload_id: str, rsp_block_size: int, + part: _PreparePart, retry_timeout: float, total_parts: int, completed: List[int], ) -> None: """PUT one part to COS, then call ``upload_part_finish``.""" part_index = part.index @@ -268,9 +209,7 @@ class ChunkedUploader: offset = (part_index - 1) * rsp_block_size length = min(actual_block_size, file_size - offset) - data = await asyncio.get_running_loop().run_in_executor( - None, _read_file_chunk, file_path, offset, length - ) + data = await asyncio.get_running_loop().run_in_executor(None, _read_file_chunk, file_path, offset, length) md5_hex = hashlib.md5(data).hexdigest() logger.debug( "[%s] Part %d/%d: uploading %s (offset=%d md5=%s)", @@ -289,12 +228,7 @@ class ChunkedUploader: ) async def _with_retries( - self, - attempt_fn: Callable[[], Awaitable[Any]], - *, - max_retries: int, - base_delay: float, - label: str, + self, attempt_fn: Callable[[], Awaitable[Any]], *, max_retries: int, base_delay: float, label: str, failure_label: str, ) -> Any: """Run *attempt_fn* up to ``max_retries + 1`` times with exponential backoff.""" @@ -325,11 +259,10 @@ class ChunkedUploader: if 200 <= status < 300: logger.debug("[%s] PUT part %d/%d: %d OK", self._log_tag, part_index, total_parts, status) return - body_preview = "" try: body_preview = getattr(resp, "text", "")[:200] except Exception: # pragma: no cover — defensive - pass + body_preview = "" raise RuntimeError(f"COS PUT returned {status}: {body_preview}") await self._with_retries( @@ -339,13 +272,7 @@ class ChunkedUploader: ) async def _part_finish_with_retry( - self, - chat_type: str, - target_id: str, - upload_id: str, - part_index: int, - block_size: int, - md5: str, + self, chat_type: str, target_id: str, upload_id: str, part_index: int, block_size: int, md5: str, retry_timeout: float, ) -> None: """Call ``upload_part_finish``, retrying on biz_code 40093001 until *retry_timeout*.""" @@ -375,11 +302,8 @@ class ChunkedUploader: await asyncio.sleep(_PART_FINISH_RETRY_INTERVAL) async def _complete(self, chat_type: str, target_id: str, upload_id: str) -> Dict[str, Any]: - """Call ``complete_upload`` with retry. - - Reuses the ``/files`` endpoint (same as the simple URL-based upload) but - signals the chunked-completion path by sending only ``upload_id``. - """ + """Call ``complete_upload`` with retry — the ``/files`` endpoint (same as the simple + URL upload) selects the chunked-completion path when only ``upload_id`` is sent.""" path = _api_path(chat_type, target_id, "files") body = {"upload_id": upload_id} return await self._with_retries( @@ -389,7 +313,7 @@ class ChunkedUploader: ) -# ── Helpers (module-level for testability) ─────────────────────────── +# ── Helpers (module-level for testability) ── def format_size(size_bytes: int) -> str: """Return a human-readable file size string (e.g. ``'12.3 MB'``).""" diff --git a/gateway/platforms/qqbot/constants.py b/gateway/platforms/qqbot/constants.py index 9ceb09c8e9..e4fec356d7 100644 --- a/gateway/platforms/qqbot/constants.py +++ b/gateway/platforms/qqbot/constants.py @@ -7,45 +7,36 @@ import os # Bump on functional changes to the adapter package. QQBOT_VERSION = "1.1.0" -# ── API endpoints ── # Portal domain is overridable (QQ_PORTAL_HOST) for corporate proxies / test environments. PORTAL_HOST = os.getenv("QQ_PORTAL_HOST", "q.qq.com") API_BASE = "https://api.sgroup.qq.com" TOKEN_URL = "https://bots.qq.com/app/getAppAccessToken" GATEWAY_URL_PATH = "/gateway" - -# QR-code onboard endpoints (on the portal host) ONBOARD_CREATE_PATH = "/lite/create_bind_task" ONBOARD_POLL_PATH = "/lite/poll_bind_result" QR_URL_TEMPLATE = "https://q.qq.com/qqbot/openclaw/connect.html?task_id={task_id}&_wv=2&source=hermes" -# ── Timeouts & retry ── DEFAULT_API_TIMEOUT = 30.0 FILE_UPLOAD_TIMEOUT = 120.0 CONNECT_TIMEOUT_SECONDS = 20.0 - RECONNECT_BACKOFF = [2, 5, 10, 30, 60] MAX_RECONNECT_ATTEMPTS = 100 RATE_LIMIT_DELAY = 60 # seconds QUICK_DISCONNECT_THRESHOLD = 5.0 # seconds MAX_QUICK_DISCONNECT_COUNT = 3 - ONBOARD_POLL_INTERVAL = 2.0 # seconds between poll_bind_result calls ONBOARD_API_TIMEOUT = 10.0 -# ── Message limits ── MAX_MESSAGE_LENGTH = 4000 DEDUP_WINDOW_SECONDS = 300 DEDUP_MAX_SIZE = 1000 -# ── QQ Bot message types ── +# QQ Bot message types / file media types MSG_TYPE_TEXT = 0 MSG_TYPE_MARKDOWN = 2 MSG_TYPE_MEDIA = 7 MSG_TYPE_INPUT_NOTIFY = 6 - -# ── QQ Bot file media types ── MEDIA_TYPE_IMAGE = 1 MEDIA_TYPE_VIDEO = 2 MEDIA_TYPE_VOICE = 3 diff --git a/gateway/platforms/qqbot/crypto.py b/gateway/platforms/qqbot/crypto.py index 8eefc8ce1b..af3b5ff854 100644 --- a/gateway/platforms/qqbot/crypto.py +++ b/gateway/platforms/qqbot/crypto.py @@ -7,12 +7,8 @@ import os def generate_bind_key() -> str: - """Generate a random 256-bit AES key as base64. - - Passed to ``create_bind_task`` so the server encrypts the bot's - *client_secret*; only this CLI holds the key, so the secret never travels - in plaintext. - """ + """Random 256-bit AES key (base64) passed to ``create_bind_task`` so the server + encrypts the bot's *client_secret*; only this CLI holds the key.""" return base64.b64encode(os.urandom(32)).decode() diff --git a/gateway/platforms/qqbot/keyboards.py b/gateway/platforms/qqbot/keyboards.py index 4e8f65f28c..5ba995f377 100644 --- a/gateway/platforms/qqbot/keyboards.py +++ b/gateway/platforms/qqbot/keyboards.py @@ -1,17 +1,10 @@ """QQ Bot inline keyboards + approval / update-prompt helpers. -QQ Bot v2 attaches inline keyboards to outbound messages. A button click -dispatches an ``INTERACTION_CREATE`` gateway event carrying the button's -``data`` payload; the bot must ACK promptly via ``PUT /interactions/{id}`` or -the user sees an error indicator on the button. - -``button_data`` formats:: - - approve:: # decision = allow-once|allow-always|deny - update_prompt: # answer = y|n - -Ported from WideLee's qqbot-agent-sdk v1.2.2 (``approval.py`` + ``dto.py`` -keyboard types). Authorship preserved via Co-authored-by. +A button click dispatches an ``INTERACTION_CREATE`` event carrying the button's +``data``; the bot must ACK promptly via ``PUT /interactions/{id}`` or the user +sees an error indicator. ``button_data`` formats: ``approve::`` +(decision = allow-once|allow-always|deny) and ``update_prompt:`` (y|n). +Ported from WideLee's qqbot-agent-sdk v1.2.2 (authorship via Co-authored-by). """ from __future__ import annotations @@ -29,7 +22,7 @@ _APPROVAL_DATA_RE = re.compile(r"^approve:(.+):(allow-once|allow-always|deny)$") _UPDATE_PROMPT_RE = re.compile(r"^update_prompt:(y|n)$") -# ── Keyboard dataclasses ───────────────────────────────────────────── +# ── Keyboard dataclasses ── def _to_dict(value: Any) -> Any: """Serialize a dataclass tree in field-declaration order (the wire shape).""" @@ -53,9 +46,8 @@ class KeyboardButtonPermission(_Serializable): @dataclass class KeyboardButtonAction(_Serializable): - """Click behaviour: ``type`` 1 = Callback (INTERACTION_CREATE with ``data``), 2 = Link. - ``click_limit=1`` = single-use. - """ + """Click behaviour: ``type`` 1 = Callback (INTERACTION_CREATE with ``data``), 2 = Link; + ``click_limit=1`` = single-use.""" type: int data: str permission: KeyboardButtonPermission = field(default_factory=KeyboardButtonPermission) @@ -95,7 +87,7 @@ class InlineKeyboard(_Serializable): content: KeyboardContent = field(default_factory=KeyboardContent) -# ── INTERACTION_CREATE parsing ─────────────────────────────────────── +# ── INTERACTION_CREATE parsing ── def parse_approval_button_data(button_data: str) -> Optional[tuple[str, str]]: """Parse approval ``button_data`` into ``(session_key, decision)`` or ``None``.""" @@ -109,11 +101,9 @@ def parse_update_prompt_button_data(button_data: str) -> Optional[str]: return m.group(1) if m else None -# ── Keyboard builders ──────────────────────────────────────────────── +# ── Keyboard builders ── -def _make_callback_button( - btn_id: str, label: str, visited_label: str, data: str, style: int, group_id: str, -) -> KeyboardButton: +def _make_callback_button(btn_id: str, label: str, visited_label: str, data: str, style: int, group_id: str) -> KeyboardButton: return KeyboardButton( id=btn_id, render_data=KeyboardButtonRenderData(label=label, visited_label=visited_label, style=style), @@ -128,10 +118,8 @@ def _single_row_keyboard(buttons: List[KeyboardButton]) -> InlineKeyboard: def build_approval_keyboard(session_key: str, *, allow_permanent: bool = True) -> InlineKeyboard: """Build ``[✅ 允许一次] [⭐ 始终允许] [❌ 拒绝]`` (one group, so a click greys the rest). - - The ⭐ button is hidden when persistent scope is unavailable. *session_key* - is embedded in ``button_data`` so the decision routes to the right approval. - """ + ⭐ is hidden when persistent scope is unavailable; *session_key* is embedded in + ``button_data`` so the decision routes to the right approval.""" prefix = f"{APPROVAL_BUTTON_PREFIX}{session_key}" buttons = [_make_callback_button("allow", "✅ 允许一次", "已允许", f"{prefix}:allow-once", 1, "approval")] if allow_permanent: @@ -148,15 +136,12 @@ def build_update_prompt_keyboard() -> InlineKeyboard: ]) -# ── ApprovalRequest + text builder ─────────────────────────────────── +# ── ApprovalRequest + text builder ── @dataclass class ApprovalRequest: - """Approval-request display data. - - ``command_preview`` / ``cwd`` are set for exec approvals, ``tool_name`` for - plugin approvals; ``severity`` is ``'critical' | 'info' | ''``. - """ + """Approval-request display data. ``command_preview`` / ``cwd`` are set for exec + approvals, ``tool_name`` for plugin approvals; ``severity`` is ``'critical' | 'info' | ''``.""" session_key: str title: str description: str = "" @@ -193,14 +178,11 @@ def build_approval_text(req: ApprovalRequest) -> str: return "\n".join(lines) -# ── INTERACTION_CREATE event shape ─────────────────────────────────── +# ── INTERACTION_CREATE event shape ── @dataclass class InteractionEvent: - """Parsed ``INTERACTION_CREATE`` payload. - - See https://bot.q.qq.com/wiki/develop/api-v2/dev-prepare/interface-framework/event-emit.html - """ + """Parsed ``INTERACTION_CREATE`` payload (api-v2 event-emit docs).""" id: str = "" # required for the ``PUT /interactions/{id}`` ACK type: int = 0 # event type code (11 = message button) chat_type: int = 0 # 0 = guild, 1 = group, 2 = c2c diff --git a/gateway/platforms/qqbot/onboard.py b/gateway/platforms/qqbot/onboard.py index 7fc1041079..7a1754892a 100644 --- a/gateway/platforms/qqbot/onboard.py +++ b/gateway/platforms/qqbot/onboard.py @@ -1,11 +1,6 @@ -"""QQBot scan-to-configure (QR code onboard) module. - -Mirrors the Feishu onboarding pattern: synchronous HTTP + one public entry-point -``qr_register()`` (create task → display QR → poll → decrypt credentials) against -the ``q.qq.com`` ``create_bind_task`` / ``poll_bind_result`` APIs. - -Reference: https://bot.q.qq.com/wiki/develop/api-v2/ -""" +"""QQBot scan-to-configure (QR code onboard). Mirrors the Feishu pattern: synchronous +HTTP + one entry-point ``qr_register()`` (create task → display QR → poll → decrypt +credentials) against ``q.qq.com`` ``create_bind_task`` / ``poll_bind_result``.""" from __future__ import annotations @@ -16,12 +11,7 @@ from typing import Optional, Tuple from urllib.parse import quote from .constants import ( - ONBOARD_API_TIMEOUT, - ONBOARD_CREATE_PATH, - ONBOARD_POLL_INTERVAL, - ONBOARD_POLL_PATH, - PORTAL_HOST, - QR_URL_TEMPLATE, + ONBOARD_API_TIMEOUT, ONBOARD_CREATE_PATH, ONBOARD_POLL_INTERVAL, ONBOARD_POLL_PATH, PORTAL_HOST, QR_URL_TEMPLATE, ) from .crypto import decrypt_secret, generate_bind_key from .utils import get_api_headers @@ -31,7 +21,6 @@ logger = logging.getLogger(__name__) class BindStatus(IntEnum): """Status codes returned by ``_poll_bind_result``.""" - NONE = 0 PENDING = 1 COMPLETED = 2 @@ -103,13 +92,8 @@ _MAX_REFRESHES = 3 def qr_register(timeout_seconds: int = 600) -> Optional[dict]: - """Run the QQBot scan-to-configure QR registration flow. - - Unexpected errors propagate to the caller. - - :returns: ``{"app_id", "client_secret", "user_openid"}`` on success, or - ``None`` on failure / expiry / cancellation. - """ + """Run the QR registration flow; returns ``{"app_id", "client_secret", "user_openid"}`` + or None on failure / expiry / cancellation. Unexpected errors propagate.""" deadline = time.monotonic() + timeout_seconds for refresh_count in range(_MAX_REFRESHES + 1): diff --git a/gateway/platforms/qqbot/utils.py b/gateway/platforms/qqbot/utils.py index 156fbc68cc..a548d3f966 100644 --- a/gateway/platforms/qqbot/utils.py +++ b/gateway/platforms/qqbot/utils.py @@ -28,16 +28,9 @@ def build_user_agent() -> str: def get_api_headers() -> Dict[str, str]: - """Standard QQBot API headers. - - ``q.qq.com`` requires ``Accept: application/json`` — without it the server - returns a JavaScript anti-bot challenge page. - """ - return { - "Content-Type": "application/json", - "Accept": "application/json", - "User-Agent": build_user_agent(), - } + """Standard QQBot API headers. ``q.qq.com`` requires ``Accept: application/json`` + — without it the server returns a JavaScript anti-bot challenge page.""" + return {"Content-Type": "application/json", "Accept": "application/json", "User-Agent": build_user_agent()} def coerce_list(value: Any) -> List[str]: From c68b56c97f5ad864dd55587b1c15115445dae1f2 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:49:43 -0700 Subject: [PATCH 03/22] refactor(gateway/platforms): qqbot AST-neutral layout packing (closers, hanging arg lists) --- gateway/platforms/qqbot/adapter.py | 189 +++++++--------------- gateway/platforms/qqbot/chunked_upload.py | 68 +++----- gateway/platforms/qqbot/keyboards.py | 20 +-- gateway/platforms/qqbot/onboard.py | 8 +- 4 files changed, 93 insertions(+), 192 deletions(-) diff --git a/gateway/platforms/qqbot/adapter.py b/gateway/platforms/qqbot/adapter.py index 072bb8a13a..efc3b957c1 100644 --- a/gateway/platforms/qqbot/adapter.py +++ b/gateway/platforms/qqbot/adapter.py @@ -39,8 +39,7 @@ except ImportError: from gateway.config import Platform, PlatformConfig from gateway.platforms.base import ( gateway_trust_env, BasePlatformAdapter, MessageEvent, MessageType, SendResult, - _ssrf_redirect_guard, cache_document_from_bytes, cache_image_from_bytes, -) + _ssrf_redirect_guard, cache_document_from_bytes, cache_image_from_bytes) from gateway.platforms.helpers import strip_markdown from gateway.platforms.media_cache import ext_for_mime @@ -61,17 +60,14 @@ from gateway.platforms.qqbot.constants import ( CONNECT_TIMEOUT_SECONDS, RECONNECT_BACKOFF, MAX_RECONNECT_ATTEMPTS, RATE_LIMIT_DELAY, QUICK_DISCONNECT_THRESHOLD, MAX_QUICK_DISCONNECT_COUNT, MAX_MESSAGE_LENGTH, DEDUP_WINDOW_SECONDS, DEDUP_MAX_SIZE, MSG_TYPE_TEXT, MSG_TYPE_MARKDOWN, MSG_TYPE_MEDIA, - MSG_TYPE_INPUT_NOTIFY, MEDIA_TYPE_IMAGE, MEDIA_TYPE_VIDEO, MEDIA_TYPE_VOICE, MEDIA_TYPE_FILE, -) + MSG_TYPE_INPUT_NOTIFY, MEDIA_TYPE_IMAGE, MEDIA_TYPE_VIDEO, MEDIA_TYPE_VOICE, MEDIA_TYPE_FILE) from gateway.platforms.qqbot.utils import coerce_list as _coerce_list, build_user_agent from gateway.platforms.qqbot.chunked_upload import ( - ChunkedUploader, UploadDailyLimitExceededError, UploadFileTooLargeError, -) + ChunkedUploader, UploadDailyLimitExceededError, UploadFileTooLargeError) from gateway.platforms.qqbot.keyboards import ( ApprovalRequest, InlineKeyboard, InteractionEvent, build_approval_keyboard, build_update_prompt_keyboard, parse_approval_button_data, parse_interaction_event, - parse_update_prompt_button_data, -) + parse_update_prompt_button_data) from gateway.platforms._shared import get_scoped_secret as _resolve_qq_secret @@ -84,8 +80,7 @@ _VOICE_EXTENSIONS = (".silk", ".amr", ".mp3", ".wav", ".ogg", ".m4a", ".aac", ". _STT_PROVIDER_BASE_URLS = { "zai": "https://open.bigmodel.cn/api/coding/paas/v4", "openai": "https://api.openai.com/v1", - "glm": "https://open.bigmodel.cn/api/coding/paas/v4", -} + "glm": "https://open.bigmodel.cn/api/coding/paas/v4"} _AUDIO_URL_EXTENSIONS = {".silk", ".amr", ".mp3", ".wav", ".ogg", ".m4a", ".aac", ".flac"} @@ -102,8 +97,7 @@ class QQAdapter(BasePlatformAdapter): _FATAL_CLOSE_CODES = { 4001: "invalid opcode", 4002: "invalid payload", 4010: "invalid shard", 4011: "sharding required", 4012: "invalid API version", 4013: "invalid intent", - 4014: "intent not authorized", 4914: "offline/sandbox-only", 4915: "banned", - } + 4014: "intent not authorized", 4914: "offline/sandbox-only", 4915: "banned"} # WS close codes that invalidate the session → clear it and re-identify on # the next Hello. 4009 (connection timeout) is deliberately absent: it is # resumable per the QQ protocol and must keep session state. @@ -128,8 +122,7 @@ class QQAdapter(BasePlatformAdapter): if self.has_fatal_error: return self._write_runtime_status_safe( - "disconnected", platform_state="disconnected", error_code=None, error_message=None - ) + "disconnected", platform_state="disconnected", error_code=None, error_message=None) @property def is_connected(self) -> bool: @@ -142,8 +135,7 @@ class QQAdapter(BasePlatformAdapter): extra = config.extra or {} self._app_id = str(extra.get("app_id") or _resolve_qq_secret("QQ_APP_ID", "")).strip() self._client_secret = str( - extra.get("client_secret") or _resolve_qq_secret("QQ_CLIENT_SECRET", "") - ).strip() + extra.get("client_secret") or _resolve_qq_secret("QQ_CLIENT_SECRET", "")).strip() self._markdown_support = bool(extra.get("markdown_support", True)) # Auth/ACL policies @@ -202,8 +194,7 @@ class QQAdapter(BasePlatformAdapter): (HTTPX_AVAILABLE, "qq_missing_dependency", "QQ startup failed: httpx not installed", ". Run: pip install httpx"), (self._app_id and self._client_secret, "qq_missing_credentials", - "QQ startup failed: QQ_APP_ID and QQ_CLIENT_SECRET are required", ""), - ): + "QQ startup failed: QQ_APP_ID and QQ_CLIENT_SECRET are required", "")): if not ok: self._set_fatal_error(code, message, retryable=True) logger.warning("[%s] %s%s", self._log_tag, message, hint) @@ -218,10 +209,8 @@ class QQAdapter(BasePlatformAdapter): from gateway.platforms._http_client_limits import platform_httpx_limits from tools.url_safety import create_ssrf_safe_async_client self._http_client = create_ssrf_safe_async_client( - timeout=30.0, - follow_redirects=True, - event_hooks={"response": [_ssrf_redirect_guard]}, - limits=platform_httpx_limits(), + timeout=30.0, follow_redirects=True, + event_hooks={"response": [_ssrf_redirect_guard]}, limits=platform_httpx_limits(), ) await self._ensure_token() @@ -304,8 +293,7 @@ class QQAdapter(BasePlatformAdapter): return self._access_token data = await self._fetch_json("access token", lambda: self._http_client.post( TOKEN_URL, json={"appId": self._app_id, "clientSecret": self._client_secret}, - timeout=DEFAULT_API_TIMEOUT, - )) + timeout=DEFAULT_API_TIMEOUT)) token = data.get("access_token") if not token: raise RuntimeError(f"QQ Bot token response missing access_token: {data}") @@ -321,8 +309,7 @@ class QQAdapter(BasePlatformAdapter): data = await self._fetch_json("gateway URL", lambda: self._http_client.get( f"{API_BASE}{GATEWAY_URL_PATH}", headers={"Authorization": f"QQBot {token}", "User-Agent": build_user_agent()}, - timeout=DEFAULT_API_TIMEOUT, - )) + timeout=DEFAULT_API_TIMEOUT)) url = data.get("url") if not url: raise RuntimeError(f"QQ Bot gateway response missing url: {data}") @@ -381,8 +368,7 @@ class QQAdapter(BasePlatformAdapter): logger.error( "[%s] Too many quick disconnects. " "Check: 1) AppID/Secret correct 2) Bot permissions on QQ Open Platform", - self._log_tag, - ) + self._log_tag) self._set_fatal_error( "qq_quick_disconnect", "Too many quick disconnects — check bot permissions", retryable=True ) @@ -510,8 +496,7 @@ class QQAdapter(BasePlatformAdapter): "token": f"QQBot {token}", "intents": (1 << 25) | (1 << 30) | (1 << 12) | (1 << 26), "shard": [0, 1], - "properties": {"$os": "macOS", "$browser": "hermes-agent", "$device": "hermes-agent"}, - }} + "properties": {"$os": "macOS", "$browser": "hermes-agent", "$device": "hermes-agent"}}} await self._send_ws_auth("Identify", payload, "Identify sent") async def _send_resume(self) -> None: @@ -549,8 +534,7 @@ class QQAdapter(BasePlatformAdapter): self._heartbeat_interval = interval_ms / 1000.0 * 0.8 # 80% of server interval logger.debug( "[%s] Hello received, heartbeat_interval=%dms (sending every %.1fs)", - self._log_tag, interval_ms, self._heartbeat_interval, - ) + self._log_tag, interval_ms, self._heartbeat_interval) resume = self._session_id and self._last_seq is not None self._create_task(self._send_resume() if resume else self._send_identify()) elif op == 0 and t: # Dispatch @@ -652,8 +636,7 @@ class QQAdapter(BasePlatformAdapter): logger.info( "[%s] Interaction: scene=%s button_data=%r operator=%s", - self._log_tag, event.scene, event.button_data, event.operator_openid, - ) + self._log_tag, event.scene, event.button_data, event.operator_openid) callback = self._interaction_callback if callback is None: logger.debug( @@ -669,8 +652,7 @@ class QQAdapter(BasePlatformAdapter): """ACK a button interaction via ``PUT /interactions/{id}`` (code 0 = success).""" resp = await self._require_http_client().put( f"{API_BASE}/interactions/{interaction_id}", - headers=await self._auth_headers(), json={"code": code}, timeout=DEFAULT_API_TIMEOUT, - ) + headers=await self._auth_headers(), json={"code": code}, timeout=DEFAULT_API_TIMEOUT) if resp.status_code >= 400: raise RuntimeError(f"Interaction ACK failed [{resp.status_code}]: {resp.text[:200]}") @@ -726,16 +708,14 @@ class QQAdapter(BasePlatformAdapter): if not self._is_authorized_interaction_for_session(event, session_key): logger.warning( "[%s] Rejected unauthorized approval click for session %s (operator=%s)", - self._log_tag, session_key, event.operator_openid, - ) + self._log_tag, session_key, event.operator_openid) return try: from tools.approval import resolve_gateway_approval # lazy: keep adapter light count = resolve_gateway_approval(session_key, choice) logger.info( "[%s] Button resolved %d approval(s) for session %s (choice=%s, operator=%s)", - self._log_tag, count, session_key, choice, event.operator_openid, - ) + self._log_tag, count, session_key, choice, event.operator_openid) except Exception as exc: logger.error("[%s] resolve_gateway_approval failed for session %s: %s", self._log_tag, session_key, exc) return @@ -785,15 +765,13 @@ class QQAdapter(BasePlatformAdapter): logger.info( "[%s] attachment[%d]: content_type=%s url=%s filename=%s", self._log_tag, _i, _att.get("content_type", ""), - str(_att.get("url", ""))[:80], _att.get("filename", ""), - ) + str(_att.get("url", ""))[:80], _att.get("filename", "")) text, image_urls, image_media_types, n_voice = await self._absorb_attachments(content, attachments_raw) logger.info("[%s] After processing: images=%d, voice=%d", self._log_tag, len(image_urls), n_voice) await self._emit_inbound( d, msg_id, timestamp, text, image_urls, image_media_types, - chat_id=user_openid, qq_chat_type="c2c", user_id=user_openid, chat_type="dm", - ) + chat_id=user_openid, qq_chat_type="c2c", user_id=user_openid, chat_type="dm") async def _handle_group_message(self, d, msg_id, content, author, timestamp) -> None: """Handle a group @-message event.""" @@ -802,12 +780,10 @@ class QQAdapter(BasePlatformAdapter): if not group_openid or not self._is_group_allowed(group_openid, member): return text, image_urls, image_media_types, _ = await self._absorb_attachments( - self._strip_at_mention(content), d.get("attachments") - ) + self._strip_at_mention(content), d.get("attachments")) await self._emit_inbound( d, msg_id, timestamp, text, image_urls, image_media_types, - chat_id=group_openid, qq_chat_type="group", user_id=member, chat_type="group", - ) + chat_id=group_openid, qq_chat_type="group", user_id=member, chat_type="group") async def _handle_guild_message(self, d, msg_id, content, author, timestamp) -> None: """Handle a guild/channel message event.""" @@ -843,16 +819,14 @@ class QQAdapter(BasePlatformAdapter): text, image_urls, image_media_types, _ = await self._absorb_attachments(content, d.get("attachments")) await self._emit_inbound( d, msg_id, timestamp, text, image_urls, image_media_types, - chat_id=guild_id, qq_chat_type="dm", user_id=author_id, chat_type="dm", - ) + chat_id=guild_id, qq_chat_type="dm", user_id=author_id, chat_type="dm") _INBOUND_HANDLERS = { "C2C_MESSAGE_CREATE": "_handle_c2c_message", "GROUP_AT_MESSAGE_CREATE": "_handle_group_message", "GUILD_MESSAGE_CREATE": "_handle_guild_message", "GUILD_AT_MESSAGE_CREATE": "_handle_guild_message", - "DIRECT_MESSAGE_CREATE": "_handle_dm_message", - } + "DIRECT_MESSAGE_CREATE": "_handle_dm_message"} # ── Shared inbound pipeline (all four message kinds) ── @@ -887,13 +861,9 @@ class QQAdapter(BasePlatformAdapter): self._chat_type_map[chat_id] = qq_chat_type event = MessageEvent( - source=self.build_source(chat_id=chat_id, **source_kwargs), - text=text, - message_type=self._detect_message_type(image_urls, image_media_types), - raw_message=d, - message_id=msg_id, - media_urls=image_urls, - media_types=image_media_types, + source=self.build_source(chat_id=chat_id,** source_kwargs), text=text, + message_type=self._detect_message_type(image_urls, image_media_types), raw_message=d, + message_id=msg_id, media_urls=image_urls, media_types=image_media_types, timestamp=self._parse_qq_timestamp(timestamp), ) await self.handle_message(event) @@ -945,8 +915,7 @@ class QQAdapter(BasePlatformAdapter): return { "quote_block": "[Quoted message]:\n" + "\n".join(lines) if lines else "[Quoted message]: (image)", "image_urls": quoted_images, - "image_media_types": att_result.get("image_media_types") or [], - } + "image_media_types": att_result.get("image_media_types") or []} @staticmethod def _merge_quote_into(text: str, quote_block: str) -> str: @@ -995,8 +964,7 @@ class QQAdapter(BasePlatformAdapter): url = f"https:{url}" logger.debug( "[%s] Processing attachment: content_type=%s, url=%s, filename=%s", - self._log_tag, ct, url[:80], filename, - ) + self._log_tag, ct, url[:80], filename) if self._is_voice_content_type(ct, filename): asr_refer = att.get("asr_refer_text") @@ -1035,8 +1003,7 @@ class QQAdapter(BasePlatformAdapter): "image_urls": image_urls, "image_media_types": image_media_types, "voice_transcripts": voice_transcripts, - "attachment_info": "\n".join(other_attachments), - } + "attachment_info": "\n".join(other_attachments)} async def _download_and_cache(self, url: str, content_type: str, original_name: str = "") -> Optional[str]: """Download a URL and cache it locally (``original_name`` falls back to the URL basename).""" @@ -1081,8 +1048,7 @@ class QQAdapter(BasePlatformAdapter): async def _stt_voice_attachment( self, url: str, content_type: str, filename: str, *, - asr_refer_text: Optional[str] = None, voice_wav_url: Optional[str] = None, - ) -> Optional[str]: + asr_refer_text: Optional[str] = None, voice_wav_url: Optional[str] = None) -> Optional[str]: """Transcribe a voice attachment. Priority: QQ's free ``asr_refer_text`` → STT on ``voice_wav_url`` (pre-converted WAV, no SILK decode) → STT on the original URL (SILK→WAV). Returns the transcript or None.""" @@ -1111,17 +1077,14 @@ class QQAdapter(BasePlatformAdapter): download_headers = self._qq_media_headers() # QQ CDN requires Authorization logger.debug( "[%s] STT: downloading voice from %s (pre_wav=%s, headers=%s)", - self._log_tag, download_url[:80], is_pre_wav, bool(download_headers), - ) + self._log_tag, download_url[:80], is_pre_wav, bool(download_headers)) resp = await self._http_client.get( - download_url, timeout=30.0, headers=download_headers, follow_redirects=True - ) + download_url, timeout=30.0, headers=download_headers, follow_redirects=True) resp.raise_for_status() audio_data = resp.content logger.debug( "[%s] STT: downloaded %d bytes, content_type=%s", - self._log_tag, len(audio_data), resp.headers.get("content-type", "unknown"), - ) + self._log_tag, len(audio_data), resp.headers.get("content-type", "unknown")) if len(audio_data) < 10: logger.warning( "[%s] STT: downloaded data too small (%d bytes), skipping", self._log_tag, len(audio_data) @@ -1176,8 +1139,7 @@ class QQAdapter(BasePlatformAdapter): def _log_converted(self, tool: str, src_path: str, wav_path: str) -> str: logger.debug( "[%s] %s converted %s to wav (%d bytes)", - self._log_tag, tool, Path(src_path).name, Path(wav_path).stat().st_size, - ) + self._log_tag, tool, Path(src_path).name, Path(wav_path).stat().st_size) return wav_path async def _convert_audio_to_wav_file(self, audio_data: bytes, filename: str) -> Optional[str]: @@ -1186,23 +1148,20 @@ class QQAdapter(BasePlatformAdapter): ext = Path(filename).suffix.lower() or self._guess_ext_from_data(audio_data) logger.info( "[%s] STT: audio_data size=%d, ext=%r, first_20_bytes=%r", - self._log_tag, len(audio_data), ext, audio_data[:20], - ) + self._log_tag, len(audio_data), ext, audio_data[:20]) src_path = self._write_temp(audio_data, ext) wav_path = src_path.rsplit(".", 1)[0] + ".wav" result = ( await self._convert_silk_to_wav(src_path, wav_path) or await self._convert_ffmpeg_to_wav(src_path, wav_path) - or await self._convert_raw_to_wav(audio_data, wav_path) - ) + or await self._convert_raw_to_wav(audio_data, wav_path)) self._unlink_quiet(src_path) return result _MAGIC_EXTS = ( (b"#!SILK", ".silk"), (b"\x02!", ".silk"), (b"RIFF", ".wav"), (b"fLaC", ".flac"), (b"\xff\xfb", ".mp3"), (b"\xff\xf3", ".mp3"), (b"\xff\xf2", ".mp3"), - (b"\x30\x26\xb2\x75", ".ogg"), (b"\x4f\x67\x67\x53", ".ogg"), - ) + (b"\x30\x26\xb2\x75", ".ogg"), (b"\x4f\x67\x67\x53", ".ogg")) @classmethod def _guess_ext_from_data(cls, data: bytes) -> str: @@ -1238,8 +1197,7 @@ class QQAdapter(BasePlatformAdapter): if self._wav_ok(wav_path): logger.debug( "[%s] pilk converted %s (as .silk) to wav (%d bytes)", - self._log_tag, Path(src_path).name, Path(wav_path).stat().st_size, - ) + self._log_tag, Path(src_path).name, Path(wav_path).stat().st_size) return wav_path except Exception as exc: logger.debug("[%s] pilk .silk conversion failed: %s", self._log_tag, exc) @@ -1268,15 +1226,13 @@ class QQAdapter(BasePlatformAdapter): try: proc = await asyncio.create_subprocess_exec( "ffmpeg", "-y", "-i", src_path, "-ar", "16000", "-ac", "1", wav_path, - stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.PIPE, - ) + stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.PIPE) await asyncio.wait_for(proc.wait(), timeout=30) if proc.returncode != 0: stderr = await proc.stderr.read() if proc.stderr else b"" logger.warning( "[%s] ffmpeg failed for %s: %s", - self._log_tag, Path(src_path).name, stderr[:200].decode(errors="replace"), - ) + self._log_tag, Path(src_path).name, stderr[:200].decode(errors="replace")) return None except (asyncio.TimeoutError, FileNotFoundError) as exc: logger.warning("[%s] ffmpeg conversion error: %s", self._log_tag, exc) @@ -1310,8 +1266,7 @@ class QQAdapter(BasePlatformAdapter): return { "base_url": base_url.rstrip("/"), "api_key": qq_stt_key, - "model": _resolve_qq_secret("QQ_STT_MODEL", "glm-asr"), - } + "model": _resolve_qq_secret("QQ_STT_MODEL", "glm-asr")} return None async def _call_stt(self, wav_path: str) -> Optional[str]: @@ -1329,8 +1284,7 @@ class QQAdapter(BasePlatformAdapter): headers={"Authorization": f"Bearer {api_key}"}, files={"file": (Path(wav_path).name, f, "audio/wav")}, data={"model": model}, - timeout=30.0, - ) + timeout=30.0) resp.raise_for_status() result = resp.json() # Zhipu/GLM: {"choices": [{"message": {"content": ...}}]}; OpenAI/Whisper: {"text": ...} @@ -1402,8 +1356,7 @@ class QQAdapter(BasePlatformAdapter): return { "Authorization": f"QQBot {token}", "Content-Type": "application/json", - "User-Agent": build_user_agent(), - } + "User-Agent": build_user_agent()} async def _upload_media( self, target_type: str, target_id: str, file_type: int, url: Optional[str] = None, @@ -1522,8 +1475,7 @@ class QQAdapter(BasePlatformAdapter): async def _send_text_to( self, chat_type: str, target_id: str, content: str, reply_to: Optional[str] = None, - keyboard: Optional[InlineKeyboard] = None, - ) -> SendResult: + keyboard: Optional[InlineKeyboard] = None) -> SendResult: """Send text (optionally with an inline keyboard) to a c2c user or group.""" self._next_msg_seq(reply_to or target_id) body = self._build_text_body(content, reply_to) @@ -1570,8 +1522,7 @@ class QQAdapter(BasePlatformAdapter): return SendResult(success=False, error=str(exc) or type(exc).__name__) async def send_approval_request( - self, chat_id: str, req: ApprovalRequest, reply_to: Optional[str] = None - ) -> SendResult: + self, chat_id: str, req: ApprovalRequest, reply_to: Optional[str] = None) -> SendResult: """Send a 3-button approval request (allow-once / allow-always / deny); clicks come back as INTERACTION_CREATE decoded by parse_approval_button_data.""" from gateway.platforms.qqbot.keyboards import build_approval_text @@ -1586,8 +1537,7 @@ class QQAdapter(BasePlatformAdapter): async def send_exec_approval( self, chat_id: str, command: str, session_key: str, description: str = "dangerous command", metadata: Optional[Dict[str, Any]] = None, allow_permanent: bool = True, allow_session: bool = True, - smart_denied: bool = False, - ) -> SendResult: + smart_denied: bool = False) -> SendResult: """Button-based exec-approval prompt (called by gateway/run.py while the agent blocks on approval); clicks resolve via _default_interaction_dispatch.""" del metadata # QQ has no thread_id / DM targeting overrides. @@ -1595,11 +1545,8 @@ class QQAdapter(BasePlatformAdapter): if smart_denied: description += " Owner override applies to this one operation only." req = ApprovalRequest( - session_key=session_key, - title="Execute this command?", - description=description, - command_preview=command, - timeout_sec=self._APPROVAL_TIMEOUT_SECONDS, + session_key=session_key, title="Execute this command?", description=description, + command_preview=command, timeout_sec=self._APPROVAL_TIMEOUT_SECONDS, allow_permanent=allow_permanent and not smart_denied, ) # QQ requires a msg_id for passive replies; the last inbound id is the natural one. @@ -1607,8 +1554,7 @@ class QQAdapter(BasePlatformAdapter): async def send_update_prompt( self, chat_id: str, prompt: str, default: str = "", session_key: str = "", - metadata: Optional[Dict[str, Any]] = None, - ) -> SendResult: + metadata: Optional[Dict[str, Any]] = None) -> SendResult: """Yes/No update-confirmation prompt; button clicks (``update_prompt:y|n``) are written to ``~/.hermes/.update_response`` by the interaction callback.""" del session_key, metadata # present for contract parity only. @@ -1633,8 +1579,7 @@ class QQAdapter(BasePlatformAdapter): async def send_image( self, chat_id: str, image_url: str, caption: Optional[str] = None, reply_to: Optional[str] = None, - metadata: Optional[Dict[str, Any]] = None, - ) -> SendResult: + metadata: Optional[Dict[str, Any]] = None) -> SendResult: """Send an image natively via QQ Bot API upload; URL sources fall back to text.""" del metadata result = await self._send_media(chat_id, image_url, MEDIA_TYPE_IMAGE, "image", caption, reply_to) @@ -1661,13 +1606,11 @@ class QQAdapter(BasePlatformAdapter): ) -> SendResult: """Send a file/document natively.""" return await self._send_media( - chat_id, file_path, MEDIA_TYPE_FILE, "file", caption, reply_to, file_name=file_name - ) + chat_id, file_path, MEDIA_TYPE_FILE, "file", caption, reply_to, file_name=file_name) async def _send_media( self, chat_id: str, media_source: str, file_type: int, kind: str, caption: Optional[str] = None, - reply_to: Optional[str] = None, file_name: Optional[str] = None, - ) -> SendResult: + reply_to: Optional[str] = None, file_name: Optional[str] = None) -> SendResult: """Upload media and send as a native message. HTTP(S) URLs → single ``POST .../files`` with ``url=`` (QQ fetches it). Local files → chunked upload (prepare / PUT parts / complete), up to the platform's ~100 MB per-file limit.""" @@ -1682,12 +1625,10 @@ class QQAdapter(BasePlatformAdapter): resolved_name = file_name or Path(urlparse(media_source).path).name or "media" upload = await self._upload_media( chat_type, chat_id, file_type, url=media_source, srv_send_msg=False, - file_name=resolved_name if file_type == MEDIA_TYPE_FILE else None, - ) + file_name=resolved_name if file_type == MEDIA_TYPE_FILE else None) else: resolved_name, upload = await self._upload_local_file( - chat_type, chat_id, media_source, file_type, file_name - ) + chat_type, chat_id, media_source, file_type, file_name) file_info = upload.get("file_info") or (upload.get("data", {}) or {}).get("file_info") if not file_info: @@ -1696,8 +1637,7 @@ class QQAdapter(BasePlatformAdapter): body: Dict[str, Any] = { "msg_type": MSG_TYPE_MEDIA, "media": {"file_info": file_info}, - "msg_seq": self._next_msg_seq(chat_id), - } + "msg_seq": self._next_msg_seq(chat_id)} if caption: body["content"] = caption[: self.MAX_MESSAGE_LENGTH] if reply_to: @@ -1715,15 +1655,12 @@ class QQAdapter(BasePlatformAdapter): except UploadFileTooLargeError as exc: logger.warning( "[%s] File too large: %s (%s, platform limit %s)", - self._log_tag, exc.file_name, exc.file_size_human, exc.limit_human, - ) + self._log_tag, exc.file_name, exc.file_size_human, exc.limit_human) return SendResult( success=False, retryable=False, error=( f"{exc.file_name!r} ({exc.file_size_human}) exceeds the " - f"QQ per-file upload limit ({exc.limit_human})." - ), - ) + f"QQ per-file upload limit ({exc.limit_human}).")) except Exception as exc: logger.error("[%s] Media send failed: %s", self._log_tag, exc) return SendResult(success=False, error=str(exc) or type(exc).__name__) @@ -1748,8 +1685,7 @@ class QQAdapter(BasePlatformAdapter): uploader = ChunkedUploader(api_request=self._api_request, http_put=client.put, log_tag=self._log_tag) complete = await uploader.upload( chat_type=chat_type, target_id=chat_id, file_path=str(local_path), file_type=file_type, - file_name=resolved_name, - ) + file_name=resolved_name) return resolved_name, complete # ── Typing indicator ── @@ -1770,8 +1706,7 @@ class QQAdapter(BasePlatformAdapter): "msg_type": MSG_TYPE_INPUT_NOTIFY, "msg_id": msg_id, "input_notify": {"input_type": 1, "input_second": self._TYPING_INPUT_SECONDS}, - "msg_seq": self._next_msg_seq(chat_id), - } + "msg_seq": self._next_msg_seq(chat_id)} await self._api_request("POST", f"/v2/users/{chat_id}/messages", body) self._typing_sent_at[chat_id] = now except Exception as exc: diff --git a/gateway/platforms/qqbot/chunked_upload.py b/gateway/platforms/qqbot/chunked_upload.py index 5a933c16af..8e11fbbb40 100644 --- a/gateway/platforms/qqbot/chunked_upload.py +++ b/gateway/platforms/qqbot/chunked_upload.py @@ -20,7 +20,6 @@ from gateway.platforms.qqbot.constants import FILE_UPLOAD_TIMEOUT logger = logging.getLogger(__name__) - _BIZ_CODE_DAILY_LIMIT = 40093002 # upload_prepare: daily cumulative limit _BIZ_CODE_PART_RETRYABLE = 40093001 # upload_part_finish: transient _DEFAULT_CONCURRENT_PARTS = 1 @@ -34,7 +33,6 @@ _COMPLETE_UPLOAD_MAX_RETRIES = 2 _COMPLETE_UPLOAD_BASE_DELAY = 2.0 _MD5_10M_SIZE = 10_002_432 # first N bytes used for the ``md5_10m`` hash (per QQ API spec) - # ── Exceptions ── class _UploadError(Exception): @@ -103,18 +101,15 @@ def _parse_prepare_response(raw: Dict[str, Any]) -> _PrepareResult: _PreparePart( index=int(p.get("part_index") or p.get("index") or 0), presigned_url=str(p.get("presigned_url") or p.get("url") or ""), - block_size=int(p.get("block_size", 0)), - ) + block_size=int(p.get("block_size", 0))) for p in raw_parts - if isinstance(p, dict) - ] + if isinstance(p, dict)] return _PrepareResult( upload_id=upload_id, block_size=block_size, parts=parts, concurrency=int(src.get("concurrency", _DEFAULT_CONCURRENT_PARTS)) or _DEFAULT_CONCURRENT_PARTS, - retry_timeout=float(src.get("retry_timeout", 0.0) or 0.0), - ) + retry_timeout=float(src.get("retry_timeout", 0.0) or 0.0)) def _api_path(chat_type: str, target_id: str, endpoint: str) -> str: @@ -132,8 +127,7 @@ class ChunkedUploader: def __init__( self, api_request: Callable[..., Awaitable[Dict[str, Any]]], http_put: Callable[..., Awaitable[Any]], - log_tag: str = "QQBot", - ) -> None: + log_tag: str = "QQBot") -> None: self._api_request = api_request self._http_put = http_put self._log_tag = log_tag @@ -148,8 +142,7 @@ class ChunkedUploader: file_size = Path(file_path).stat().st_size logger.info( "[%s] Chunked upload start: file=%s size=%s type=%d", - self._log_tag, file_name, format_size(file_size), file_type, - ) + self._log_tag, file_name, format_size(file_size), file_type) # Hashing is blocking I/O → executor. hashes = await asyncio.get_running_loop().run_in_executor(None, _compute_file_hashes, file_path, file_size) @@ -157,13 +150,11 @@ class ChunkedUploader: max_concurrent = min(prepare.concurrency, _MAX_CONCURRENT_PARTS) retry_timeout = min( prepare.retry_timeout if prepare.retry_timeout > 0 else _PART_FINISH_DEFAULT_TIMEOUT, - _PART_FINISH_MAX_TIMEOUT, - ) + _PART_FINISH_MAX_TIMEOUT) logger.info( "[%s] Prepared: upload_id=%s block_size=%s parts=%d concurrency=%d", self._log_tag, prepare.upload_id, format_size(prepare.block_size), - len(prepare.parts), max_concurrent, - ) + len(prepare.parts), max_concurrent) total_parts = len(prepare.parts) completed = [0] # shared counter for progress logging @@ -173,8 +164,7 @@ class ChunkedUploader: async with sem: await self._upload_one_part( chat_type, target_id, file_path, file_size, prepare.upload_id, - prepare.block_size, part, retry_timeout, total_parts, completed, - ) + prepare.block_size, part, retry_timeout, total_parts, completed) await asyncio.gather(*(_run(p) for p in prepare.parts)) logger.info("[%s] All %d parts uploaded, completing…", self._log_tag, total_parts) @@ -185,8 +175,7 @@ class ChunkedUploader: ) -> _PrepareResult: body = { "file_type": file_type, "file_name": file_name, "file_size": file_size, - "md5": hashes["md5"], "sha1": hashes["sha1"], "md5_10m": hashes["md5_10m"], - } + "md5": hashes["md5"], "sha1": hashes["sha1"], "md5_10m": hashes["md5_10m"]} try: raw = await self._api_request( "POST", _api_path(chat_type, target_id, "upload_prepare"), body=body, timeout=FILE_UPLOAD_TIMEOUT, @@ -200,8 +189,7 @@ class ChunkedUploader: async def _upload_one_part( self, chat_type: str, target_id: str, file_path: str, file_size: int, upload_id: str, rsp_block_size: int, - part: _PreparePart, retry_timeout: float, total_parts: int, completed: List[int], - ) -> None: + part: _PreparePart, retry_timeout: float, total_parts: int, completed: List[int]) -> None: """PUT one part to COS, then call ``upload_part_finish``.""" part_index = part.index # Per-part block_size wins; fall back to the response-level value. @@ -213,24 +201,20 @@ class ChunkedUploader: md5_hex = hashlib.md5(data).hexdigest() logger.debug( "[%s] Part %d/%d: uploading %s (offset=%d md5=%s)", - self._log_tag, part_index, total_parts, format_size(length), offset, md5_hex, - ) + self._log_tag, part_index, total_parts, format_size(length), offset, md5_hex) await self._put_to_presigned_url(part.presigned_url, data, part_index, total_parts) await self._part_finish_with_retry( - chat_type, target_id, upload_id, part_index, length, md5_hex, retry_timeout, - ) + chat_type, target_id, upload_id, part_index, length, md5_hex, retry_timeout) completed[0] += 1 logger.debug( "[%s] Part %d/%d done (%d/%d total)", - self._log_tag, part_index, total_parts, completed[0], total_parts, - ) + self._log_tag, part_index, total_parts, completed[0], total_parts) async def _with_retries( self, attempt_fn: Callable[[], Awaitable[Any]], *, max_retries: int, base_delay: float, label: str, - failure_label: str, - ) -> Any: + failure_label: str) -> Any: """Run *attempt_fn* up to ``max_retries + 1`` times with exponential backoff.""" last_exc: Exception | None = None for attempt in range(max_retries + 1): @@ -242,8 +226,7 @@ class ChunkedUploader: delay = base_delay * (2 ** attempt) logger.warning( "[%s] %s attempt %d failed, retry in %.1fs: %s", - self._log_tag, label, attempt + 1, delay, exc, - ) + self._log_tag, label, attempt + 1, delay, exc) await asyncio.sleep(delay) raise RuntimeError(f"{failure_label} failed after {max_retries + 1} attempts: {last_exc}") @@ -253,8 +236,7 @@ class ChunkedUploader: async def _attempt() -> None: resp = await asyncio.wait_for( self._http_put(url, data=data, headers={"Content-Length": str(len(data))}), - timeout=_PART_UPLOAD_TIMEOUT, - ) + timeout=_PART_UPLOAD_TIMEOUT) status = getattr(resp, "status_code", 0) if 200 <= status < 300: logger.debug("[%s] PUT part %d/%d: %d OK", self._log_tag, part_index, total_parts, status) @@ -268,13 +250,11 @@ class ChunkedUploader: await self._with_retries( _attempt, max_retries=_PART_UPLOAD_MAX_RETRIES, base_delay=1.0, label=f"PUT part {part_index}/{total_parts}", - failure_label=f"Part {part_index}/{total_parts} upload", - ) + failure_label=f"Part {part_index}/{total_parts} upload") async def _part_finish_with_retry( self, chat_type: str, target_id: str, upload_id: str, part_index: int, block_size: int, md5: str, - retry_timeout: float, - ) -> None: + retry_timeout: float) -> None: """Call ``upload_part_finish``, retrying on biz_code 40093001 until *retry_timeout*.""" path = _api_path(chat_type, target_id, "upload_part_finish") body = {"upload_id": upload_id, "part_index": part_index, "block_size": block_size, "md5": md5} @@ -297,8 +277,7 @@ class ChunkedUploader: attempt += 1 logger.debug( "[%s] part_finish retryable error, attempt %d, elapsed=%.1fs: %s", - self._log_tag, attempt, elapsed, exc, - ) + self._log_tag, attempt, elapsed, exc) await asyncio.sleep(_PART_FINISH_RETRY_INTERVAL) async def _complete(self, chat_type: str, target_id: str, upload_id: str) -> Dict[str, Any]: @@ -309,8 +288,7 @@ class ChunkedUploader: return await self._with_retries( lambda: self._api_request("POST", path, body=body, timeout=FILE_UPLOAD_TIMEOUT), max_retries=_COMPLETE_UPLOAD_MAX_RETRIES, base_delay=_COMPLETE_UPLOAD_BASE_DELAY, - label="complete_upload", failure_label="complete_upload", - ) + label="complete_upload", failure_label="complete_upload") # ── Helpers (module-level for testability) ── @@ -333,8 +311,7 @@ def _read_file_chunk(file_path: str, offset: int, length: int) -> bytes: if len(data) != length: raise IOError( f"Short read from {file_path}: expected {length} bytes at " - f"offset {offset}, got {len(data)} (file may be truncated)" - ) + f"offset {offset}, got {len(data)} (file may be truncated)") return data @@ -359,5 +336,4 @@ def _compute_file_hashes(file_path: str, file_size: int) -> Dict[str, str]: "md5": full_md5, "sha1": sha1.hexdigest(), # For small files the "10m" hash is just the full md5. - "md5_10m": md5_10m.hexdigest() if need_10m else full_md5, - } + "md5_10m": md5_10m.hexdigest() if need_10m else full_md5} diff --git a/gateway/platforms/qqbot/keyboards.py b/gateway/platforms/qqbot/keyboards.py index 5ba995f377..d07d119bb2 100644 --- a/gateway/platforms/qqbot/keyboards.py +++ b/gateway/platforms/qqbot/keyboards.py @@ -21,7 +21,6 @@ UPDATE_PROMPT_PREFIX = "update_prompt:" _APPROVAL_DATA_RE = re.compile(r"^approve:(.+):(allow-once|allow-always|deny)$") _UPDATE_PROMPT_RE = re.compile(r"^update_prompt:(y|n)$") - # ── Keyboard dataclasses ── def _to_dict(value: Any) -> Any: @@ -108,8 +107,7 @@ def _make_callback_button(btn_id: str, label: str, visited_label: str, data: str id=btn_id, render_data=KeyboardButtonRenderData(label=label, visited_label=visited_label, style=style), action=KeyboardButtonAction(type=1, data=data), - group_id=group_id, - ) + group_id=group_id) def _single_row_keyboard(buttons: List[KeyboardButton]) -> InlineKeyboard: @@ -132,8 +130,7 @@ def build_update_prompt_keyboard() -> InlineKeyboard: """Build a Yes/No keyboard for update confirmation prompts.""" return _single_row_keyboard([ _make_callback_button("yes", "✓ 确认", "已确认", f"{UPDATE_PROMPT_PREFIX}y", 1, "update_prompt"), - _make_callback_button("no", "✗ 取消", "已取消", f"{UPDATE_PROMPT_PREFIX}n", 0, "update_prompt"), - ]) + _make_callback_button("no", "✗ 取消", "已取消", f"{UPDATE_PROMPT_PREFIX}n", 0, "update_prompt")]) # ── ApprovalRequest + text builder ── @@ -211,16 +208,11 @@ def parse_interaction_event(raw: Dict[str, Any]) -> InteractionEvent: resolved = data_raw.get("resolved") or {} scene_code = int(raw.get("chat_type", 0) or 0) return InteractionEvent( - id=str(raw.get("id", "")), - type=int(data_raw.get("type", 0) or 0), - chat_type=scene_code, - scene=_SCENE_NAMES.get(scene_code, ""), - group_openid=str(raw.get("group_openid", "")), + id=str(raw.get("id", "")), type=int(data_raw.get("type", 0) or 0), chat_type=scene_code, + scene=_SCENE_NAMES.get(scene_code, ""), group_openid=str(raw.get("group_openid", "")), group_member_openid=str(raw.get("group_member_openid", "")), - user_openid=str(raw.get("user_openid", "")), - channel_id=str(raw.get("channel_id", "")), - guild_id=str(raw.get("guild_id", "")), - button_data=str(resolved.get("button_data", "")), + user_openid=str(raw.get("user_openid", "")), channel_id=str(raw.get("channel_id", "")), + guild_id=str(raw.get("guild_id", "")), button_data=str(resolved.get("button_data", "")), button_id=str(resolved.get("button_id", "")), resolver_user_id=str(resolved.get("user_id", "")), ) diff --git a/gateway/platforms/qqbot/onboard.py b/gateway/platforms/qqbot/onboard.py index 7a1754892a..856be442da 100644 --- a/gateway/platforms/qqbot/onboard.py +++ b/gateway/platforms/qqbot/onboard.py @@ -75,11 +75,9 @@ def _poll_bind_result(task_id: str, timeout: float = ONBOARD_API_TIMEOUT) -> Tup """Poll *task_id*; returns ``(status, bot_appid, bot_encrypt_secret, user_openid)``.""" data = _portal_post(ONBOARD_POLL_PATH, {"task_id": task_id}, timeout, "poll_bind_result failed") d = data.get("data", {}) - return ( - BindStatus(d.get("status", 0)), - str(d.get("bot_appid", "")), - d.get("bot_encrypt_secret", ""), - d.get("user_openid", ""), + return( + BindStatus(d.get("status", 0)), str(d.get("bot_appid", "")), + d.get("bot_encrypt_secret", ""), d.get("user_openid", ""), ) From de427ff550f0b1a75787e0d19b8794dd76642104 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:55:18 -0700 Subject: [PATCH 04/22] =?UTF-8?q?refactor(gateway/platforms):=20qqbot=20in?= =?UTF-8?q?bound=20tail=20=E2=86=92=20one=20=5Fingest=20(absorb+emit),=20p?= =?UTF-8?q?ilk=20attempts=20=E2=86=92=20loop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gateway/platforms/qqbot/adapter.py | 95 ++++++++++++++---------------- 1 file changed, 43 insertions(+), 52 deletions(-) diff --git a/gateway/platforms/qqbot/adapter.py b/gateway/platforms/qqbot/adapter.py index efc3b957c1..71cde71f58 100644 --- a/gateway/platforms/qqbot/adapter.py +++ b/gateway/platforms/qqbot/adapter.py @@ -767,10 +767,8 @@ class QQAdapter(BasePlatformAdapter): self._log_tag, _i, _att.get("content_type", ""), str(_att.get("url", ""))[:80], _att.get("filename", "")) - text, image_urls, image_media_types, n_voice = await self._absorb_attachments(content, attachments_raw) - logger.info("[%s] After processing: images=%d, voice=%d", self._log_tag, len(image_urls), n_voice) - await self._emit_inbound( - d, msg_id, timestamp, text, image_urls, image_media_types, + await self._ingest( + d, msg_id, content, attachments_raw, timestamp, verbose=True, chat_id=user_openid, qq_chat_type="c2c", user_id=user_openid, chat_type="dm") async def _handle_group_message(self, d, msg_id, content, author, timestamp) -> None: @@ -779,10 +777,8 @@ class QQAdapter(BasePlatformAdapter): member = str(author.get("member_openid", "")) if not group_openid or not self._is_group_allowed(group_openid, member): return - text, image_urls, image_media_types, _ = await self._absorb_attachments( - self._strip_at_mention(content), d.get("attachments")) - await self._emit_inbound( - d, msg_id, timestamp, text, image_urls, image_media_types, + await self._ingest( + d, msg_id, self._strip_at_mention(content), d.get("attachments"), timestamp, chat_id=group_openid, qq_chat_type="group", user_id=member, chat_type="group") async def _handle_guild_message(self, d, msg_id, content, author, timestamp) -> None: @@ -800,11 +796,9 @@ class QQAdapter(BasePlatformAdapter): member = d.get("member") if isinstance(d.get("member"), dict) else {} nick = str(member.get("nick", "")) or str(author.get("username", "")) - text, image_urls, image_media_types, _ = await self._absorb_attachments(content, d.get("attachments")) - await self._emit_inbound( - d, msg_id, timestamp, text, image_urls, image_media_types, - chat_id=channel_id, qq_chat_type="guild", user_id=author_id, user_name=nick or None, chat_type="group", - ) + await self._ingest( + d, msg_id, content, d.get("attachments"), timestamp, + chat_id=channel_id, qq_chat_type="guild", user_id=author_id, user_name=nick or None, chat_type="group") async def _handle_dm_message(self, d, msg_id, content, author, timestamp) -> None: """Handle a guild DM message event.""" @@ -816,9 +810,8 @@ class QQAdapter(BasePlatformAdapter): if not self._is_dm_intake_allowed(author_id): logger.debug("[%s] Guild DM blocked by ACL: guild=%s user=%s", self._log_tag, guild_id, author_id) return - text, image_urls, image_media_types, _ = await self._absorb_attachments(content, d.get("attachments")) - await self._emit_inbound( - d, msg_id, timestamp, text, image_urls, image_media_types, + await self._ingest( + d, msg_id, content, d.get("attachments"), timestamp, chat_id=guild_id, qq_chat_type="dm", user_id=author_id, chat_type="dm") _INBOUND_HANDLERS = { @@ -835,22 +828,23 @@ class QQAdapter(BasePlatformAdapter): """Append *block* to *text* after a blank line (or return block alone if text is blank).""" return (text + "\n\n" + block).strip() if text.strip() else block - async def _absorb_attachments(self, text: str, attachments: Any) -> Tuple[str, List[str], List[str], int]: - """Run attachments through _process_attachments and fold transcripts/file - info into *text*. Returns (text, image_urls, image_media_types, n_voice).""" + async def _ingest( + self, d: Dict[str, Any], msg_id: str, content: str, attachments: Any, timestamp: str, *, + chat_id: str, qq_chat_type: str, verbose: bool = False, **source_kwargs: Any, + ) -> None: + """Shared inbound tail: fold attachment transcripts/file info and quoted context + into the text, drop empty events, remember the QQ chat kind and dispatch.""" att = await self._process_attachments(attachments) + text = content voice_transcripts = att["voice_transcripts"] if voice_transcripts: text = self._append_block(text, "\n".join(voice_transcripts)) if att["attachment_info"]: text = self._append_block(text, att["attachment_info"]) - return text, att["image_urls"], att["image_media_types"], len(voice_transcripts) + image_urls, image_media_types = att["image_urls"], att["image_media_types"] + if verbose: + logger.info("[%s] After processing: images=%d, voice=%d", self._log_tag, len(image_urls), len(voice_transcripts)) - async def _emit_inbound( - self, d: Dict[str, Any], msg_id: str, timestamp: str, text: str, image_urls: List[str], - image_media_types: List[str], *, chat_id: str, qq_chat_type: str, **source_kwargs: Any, - ) -> None: - """Merge quoted context, drop empty events, remember the QQ chat kind and dispatch.""" quoted = await self._process_quoted_context(d) text = self._merge_quote_into(text, quoted["quote_block"]) if quoted["image_urls"]: @@ -861,9 +855,13 @@ class QQAdapter(BasePlatformAdapter): self._chat_type_map[chat_id] = qq_chat_type event = MessageEvent( - source=self.build_source(chat_id=chat_id,** source_kwargs), text=text, - message_type=self._detect_message_type(image_urls, image_media_types), raw_message=d, - message_id=msg_id, media_urls=image_urls, media_types=image_media_types, + source=self.build_source(chat_id=chat_id, **source_kwargs), + text=text, + message_type=self._detect_message_type(image_urls, image_media_types), + raw_message=d, + message_id=msg_id, + media_urls=image_urls, + media_types=image_media_types, timestamp=self._parse_qq_timestamp(timestamp), ) await self.handle_message(event) @@ -1136,12 +1134,6 @@ class QQAdapter(BasePlatformAdapter): """True when *wav_path* exists and holds more than a bare 44-byte header.""" return Path(wav_path).exists() and Path(wav_path).stat().st_size > 44 - def _log_converted(self, tool: str, src_path: str, wav_path: str) -> str: - logger.debug( - "[%s] %s converted %s to wav (%d bytes)", - self._log_tag, tool, Path(src_path).name, Path(wav_path).stat().st_size) - return wav_path - async def _convert_audio_to_wav_file(self, audio_data: bytes, filename: str) -> Optional[str]: """Convert audio bytes to a temp .wav: pilk (SILK, which ffmpeg can't decode) → ffmpeg → raw-PCM last resort. Returns the wav path or None.""" @@ -1181,26 +1173,22 @@ class QQAdapter(BasePlatformAdapter): logger.warning("[%s] pilk not installed — cannot decode SILK audio. Run: pip install pilk", self._log_tag) return None - try: - pilk.silk_to_wav(src_path, wav_path, rate=16000) - if self._wav_ok(wav_path): - return self._log_converted("pilk", src_path, wav_path) - except Exception as exc: - logger.debug("[%s] pilk direct conversion failed: %s", self._log_tag, exc) - silk_path = src_path.rsplit(".", 1)[0] + ".silk" try: - import shutil + for path, label, how in ((src_path, "", "direct"), (silk_path, " (as .silk)", ".silk")): + try: + if path == silk_path: + import shutil - shutil.copy2(src_path, silk_path) - pilk.silk_to_wav(silk_path, wav_path, rate=16000) - if self._wav_ok(wav_path): - logger.debug( - "[%s] pilk converted %s (as .silk) to wav (%d bytes)", - self._log_tag, Path(src_path).name, Path(wav_path).stat().st_size) - return wav_path - except Exception as exc: - logger.debug("[%s] pilk .silk conversion failed: %s", self._log_tag, exc) + shutil.copy2(src_path, silk_path) + pilk.silk_to_wav(path, wav_path, rate=16000) + if self._wav_ok(wav_path): + logger.debug( + "[%s] pilk converted %s%s to wav (%d bytes)", + self._log_tag, Path(src_path).name, label, Path(wav_path).stat().st_size) + return wav_path + except Exception as exc: + logger.debug("[%s] pilk %s conversion failed: %s", self._log_tag, how, exc) finally: self._unlink_quiet(silk_path) return None @@ -1241,7 +1229,10 @@ class QQAdapter(BasePlatformAdapter): if not self._wav_ok(wav_path): logger.warning("[%s] ffmpeg produced no/small output for %s", self._log_tag, Path(src_path).name) return None - return self._log_converted("ffmpeg", src_path, wav_path) + logger.debug( + "[%s] ffmpeg converted %s to wav (%d bytes)", + self._log_tag, Path(src_path).name, Path(wav_path).stat().st_size) + return wav_path def _resolve_stt_config(self) -> Optional[Dict[str, str]]: """Resolve STT backend: ``extra["stt"]`` config first, then ``QQ_STT_*`` env From 9142545df8951f8f3ef65e4782306b8fa8643ffc Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:02:23 -0700 Subject: [PATCH 05/22] refactor(gateway/platforms): qqbot attachment loop single download path, text-sender table, _opt_str --- gateway/platforms/qqbot/adapter.py | 99 +++++++++++++++--------------- 1 file changed, 48 insertions(+), 51 deletions(-) diff --git a/gateway/platforms/qqbot/adapter.py b/gateway/platforms/qqbot/adapter.py index 71cde71f58..6a9b408e8d 100644 --- a/gateway/platforms/qqbot/adapter.py +++ b/gateway/platforms/qqbot/adapter.py @@ -210,8 +210,7 @@ class QQAdapter(BasePlatformAdapter): from tools.url_safety import create_ssrf_safe_async_client self._http_client = create_ssrf_safe_async_client( timeout=30.0, follow_redirects=True, - event_hooks={"response": [_ssrf_redirect_guard]}, limits=platform_httpx_limits(), - ) + event_hooks={"response": [_ssrf_redirect_guard]}, limits=platform_httpx_limits()) await self._ensure_token() gateway_url = await self._get_gateway_url() @@ -830,8 +829,7 @@ class QQAdapter(BasePlatformAdapter): async def _ingest( self, d: Dict[str, Any], msg_id: str, content: str, attachments: Any, timestamp: str, *, - chat_id: str, qq_chat_type: str, verbose: bool = False, **source_kwargs: Any, - ) -> None: + chat_id: str, qq_chat_type: str, verbose: bool = False, **source_kwargs: Any) -> None: """Shared inbound tail: fold attachment transcripts/file info and quoted context into the text, drop empty events, remember the QQ chat kind and dispatch.""" att = await self._process_attachments(attachments) @@ -855,13 +853,9 @@ class QQAdapter(BasePlatformAdapter): self._chat_type_map[chat_id] = qq_chat_type event = MessageEvent( - source=self.build_source(chat_id=chat_id, **source_kwargs), - text=text, - message_type=self._detect_message_type(image_urls, image_media_types), - raw_message=d, - message_id=msg_id, - media_urls=image_urls, - media_types=image_media_types, + source=self.build_source(chat_id=chat_id,** source_kwargs), text=text, + message_type=self._detect_message_type(image_urls, image_media_types), raw_message=d, + message_id=msg_id, media_urls=image_urls, media_types=image_media_types, timestamp=self._parse_qq_timestamp(timestamp), ) await self.handle_message(event) @@ -965,37 +959,33 @@ class QQAdapter(BasePlatformAdapter): self._log_tag, ct, url[:80], filename) if self._is_voice_content_type(ct, filename): - asr_refer = att.get("asr_refer_text") - voice_wav_url = att.get("voice_wav_url") + asr_refer, wav_url = (self._opt_str(att.get(k)) for k in ("asr_refer_text", "voice_wav_url")) transcript = await self._stt_voice_attachment( - url, ct, filename, - asr_refer_text=(asr_refer.strip() if isinstance(asr_refer, str) else "") or None, - voice_wav_url=(voice_wav_url.strip() if isinstance(voice_wav_url, str) else "") or None, - ) + url, ct, filename, asr_refer_text=asr_refer, voice_wav_url=wav_url) if transcript: voice_transcripts.append(f"[Voice] {transcript}") logger.debug("[%s] Voice transcript: %s", self._log_tag, transcript) else: logger.warning("[%s] Voice STT failed for %s", self._log_tag, url[:60]) voice_transcripts.append("[Voice] [语音识别失败]") - elif ct.startswith("image/"): - try: - cached_path = await self._download_and_cache(url, ct, filename) - if cached_path and os.path.isfile(cached_path): - image_urls.append(cached_path) - image_media_types.append(ct or "image/jpeg") - elif cached_path: - logger.warning("[%s] Cached image path does not exist: %s", self._log_tag, cached_path) - except Exception as exc: - logger.debug("[%s] Failed to cache image: %s", self._log_tag, exc) + continue + + is_image = ct.startswith("image/") + try: + cached_path = await self._download_and_cache(url, ct, filename) + except Exception as exc: + logger.debug("[%s] Failed to cache %s: %s", self._log_tag, "image" if is_image else "attachment", exc) + continue + if not cached_path: + continue + if not is_image: + label = "video" if ct.startswith("video/") else "file" + other_attachments.append(f"[{label}: {filename or ct} ({cached_path})]") + elif os.path.isfile(cached_path): + image_urls.append(cached_path) + image_media_types.append(ct or "image/jpeg") else: - try: - cached_path = await self._download_and_cache(url, ct, filename) - if cached_path: - label = "video" if ct.startswith("video/") else "file" - other_attachments.append(f"[{label}: {filename or ct} ({cached_path})]") - except Exception as exc: - logger.debug("[%s] Failed to cache attachment: %s", self._log_tag, exc) + logger.warning("[%s] Cached image path does not exist: %s", self._log_tag, cached_path) return { "image_urls": image_urls, @@ -1003,6 +993,11 @@ class QQAdapter(BasePlatformAdapter): "voice_transcripts": voice_transcripts, "attachment_info": "\n".join(other_attachments)} + @staticmethod + def _opt_str(value: Any) -> Optional[str]: + """Stripped non-empty string or None.""" + return (value.strip() if isinstance(value, str) else "") or None + async def _download_and_cache(self, url: str, content_type: str, original_name: str = "") -> Optional[str]: """Download a URL and cache it locally (``original_name`` falls back to the URL basename).""" from tools.url_safety import is_safe_url @@ -1423,16 +1418,12 @@ class QQAdapter(BasePlatformAdapter): async def _send_chunk(self, chat_id: str, content: str, reply_to: Optional[str] = None) -> SendResult: """Send a single chunk with retry + exponential backoff.""" last_exc: Optional[Exception] = None - chat_type = self._guess_chat_type(chat_id) + sender = self._text_sender(self._guess_chat_type(chat_id)) + if sender is None: + return SendResult(success=False, error=f"Unknown chat type for {chat_id}") for attempt in range(3): try: - if chat_type == "c2c": - return await self._send_c2c_text(chat_id, content, reply_to) - if chat_type == "group": - return await self._send_group_text(chat_id, content, reply_to) - if chat_type == "guild": - return await self._send_guild_text(chat_id, content, reply_to) - return SendResult(success=False, error=f"Unknown chat type for {chat_id}") + return await sender(chat_id, content, reply_to) except Exception as exc: last_exc = exc err = str(exc).lower() @@ -1482,6 +1473,15 @@ class QQAdapter(BasePlatformAdapter): async def _send_group_text(self, group_openid, content, reply_to=None, keyboard=None) -> SendResult: return await self._send_text_to("group", group_openid, content, reply_to, keyboard) + _TEXT_SENDERS = {"c2c": "_send_c2c_text", "group": "_send_group_text", "guild": "_send_guild_text"} + + def _text_sender(self, chat_type: str, *, keyboard_ok: bool = False): + """Bound text sender for *chat_type* (None if unsupported); guild lacks keyboards.""" + if keyboard_ok and chat_type == "guild": + return None + name = self._TEXT_SENDERS.get(chat_type) + return getattr(self, name) if name else None + async def _send_guild_text(self, channel_id: str, content: str, reply_to: Optional[str] = None) -> SendResult: """Send text to a guild channel via REST API.""" body: Dict[str, Any] = {"content": content[: self.MAX_MESSAGE_LENGTH]} @@ -1499,15 +1499,13 @@ class QQAdapter(BasePlatformAdapter): if not await self._ensure_connected(): return self._NOT_CONNECTED chat_type = self._guess_chat_type(chat_id) + sender = self._text_sender(chat_type, keyboard_ok=True) + if sender is None: + return SendResult( + success=False, error=f"Inline keyboards not supported for chat_type {chat_type!r}", retryable=False) truncated = self.format_message(content)[: self.MAX_MESSAGE_LENGTH] try: - if chat_type == "c2c": - return await self._send_c2c_text(chat_id, truncated, reply_to, keyboard=keyboard) - if chat_type == "group": - return await self._send_group_text(chat_id, truncated, reply_to, keyboard=keyboard) - return SendResult( - success=False, error=f"Inline keyboards not supported for chat_type {chat_type!r}", retryable=False - ) + return await sender(chat_id, truncated, reply_to, keyboard=keyboard) except Exception as exc: logger.error("[%s] send_with_keyboard failed: %s", self._log_tag, exc) return SendResult(success=False, error=str(exc) or type(exc).__name__) @@ -1538,8 +1536,7 @@ class QQAdapter(BasePlatformAdapter): req = ApprovalRequest( session_key=session_key, title="Execute this command?", description=description, command_preview=command, timeout_sec=self._APPROVAL_TIMEOUT_SECONDS, - allow_permanent=allow_permanent and not smart_denied, - ) + allow_permanent=allow_permanent and not smart_denied) # QQ requires a msg_id for passive replies; the last inbound id is the natural one. return await self.send_approval_request(chat_id, req, reply_to=self._last_msg_id.get(chat_id)) From 4acbbd1f2b759a3e7ab2e0673470d2841af62d73 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:03:43 -0700 Subject: [PATCH 06/22] =?UTF-8?q?refactor(gateway/platforms):=20api=5Fserv?= =?UTF-8?q?er=5Fruns=20=E2=80=94=20event=20dispatch=20table,=20approval=20?= =?UTF-8?q?check=20table,=20auth=20folded=20into=20=5Fload=5Fowned=5Frun,?= =?UTF-8?q?=20contextvar=20reset=20list,=20transport=20closures=20moved=20?= =?UTF-8?q?onto=20=5FRunLaunch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gateway/platforms/api_server_runs.py | 243 +++++++++++---------------- 1 file changed, 96 insertions(+), 147 deletions(-) diff --git a/gateway/platforms/api_server_runs.py b/gateway/platforms/api_server_runs.py index 9f9529f68e..be0a58530a 100644 --- a/gateway/platforms/api_server_runs.py +++ b/gateway/platforms/api_server_runs.py @@ -37,6 +37,13 @@ _SUBAGENT_EVENT_KEYS = ( "output_tail", ) _SUBAGENT_TEXT_KEYS = ("goal", "summary", "output_tail") +# Tool-progress event -> SSE payload fields (tool_name, preview, kwargs); key order is wire format. +_FIXED_EVENT_FIELDS = { + "tool.started": lambda tool, preview, kw: {"tool": tool, "preview": preview}, + "tool.completed": lambda tool, preview, kw: { + "tool": tool, "duration": round(kw.get("duration", 0), 3), "error": kw.get("is_error", False)}, + "reasoning.available": lambda tool, preview, kw: {"text": preview or ""}, +} def _remember_room_retention(request: "web.Request", claims: dict[str, Any]) -> None: @@ -78,8 +85,6 @@ def _uses_room_run_auth(self, request: "web.Request") -> bool: def _initialize_run_state(self, *, store_factory) -> None: """Initialize adapter-owned durable and live ``/v1/runs`` state.""" self._run_idempotency_store = store_factory() - self._run_idempotency_ids: set[str] = set() - self._run_owners: Dict[str, str] = {} self._run_owner_pid = os.getpid() try: from gateway.status import get_process_start_time @@ -87,21 +92,19 @@ def _initialize_run_state(self, *, store_factory) -> None: self._run_owner_started = int(get_process_start_time(self._run_owner_pid) or 0) except Exception: self._run_owner_started = 0 - # run_id -> asyncio.Queue of SSE event dicts, plus creation time for the TTL sweep - self._run_streams: Dict[str, "asyncio.Queue[Optional[Dict]]"] = {} - self._run_streams_created: Dict[str, float] = {} - # Runs with a connected SSE consumer; their queue is actively draining. + # All keyed by run_id. _run_streams: SSE event queues (+ creation time for the TTL + # sweep); _run_stream_subscribers: runs with a connected, draining consumer; + # _active_run_agents/_active_run_tasks: live refs for cooperative stop (the executor + # thread may outlive the HTTP request, hence the separate _stopping_run_ids set); + # _run_statuses: pollable status for dashboards; _run_approval_sessions: approval + # session key (the approval core resolves by session key, API clients by run_id). + self._run_idempotency_ids: set[str] = set() self._run_stream_subscribers: set[str] = set() - # Live agent/task refs for stop support. Stop is cooperative: the executor - # thread may outlive the HTTP request, hence the separate stopping set. - self._active_run_agents: Dict[str, Any] = {} - self._active_run_tasks: Dict[str, "asyncio.Task"] = {} self._stopping_run_ids: set[str] = set() - # Pollable run status for dashboards and external control-plane UIs. - self._run_statuses: Dict[str, Dict[str, Any]] = {} - # run_id -> approval session key: the approval core resolves requests by - # session key, while API clients address them by run_id. - self._run_approval_sessions: Dict[str, str] = {} + ( + self._run_owners, self._run_streams, self._run_streams_created, self._active_run_agents, + self._active_run_tasks, self._run_statuses, self._run_approval_sessions, + ) = ({} for _ in range(7)) def _http_routes(self) -> list[tuple[str, str, Any]]: @@ -173,15 +176,12 @@ def _make_run_event_callback(self, run_id: str, loop: "asyncio.AbstractEventLoop loop.call_soon_threadsafe(q.put_nowait, event) def _callback(event_type: str, tool_name: str = None, preview: str = None, args=None, **kwargs): - if event_type == "tool.started": - _push(_run_event(run_id, event_type, tool=tool_name, preview=preview)) - elif event_type == "tool.completed": - _push(_run_event( - run_id, event_type, tool=tool_name, - duration=round(kwargs.get("duration", 0), 3), error=kwargs.get("is_error", False), - )) - elif event_type == "reasoning.available": - _push(_run_event(run_id, event_type, text=preview or "")) + # _thinking, subagent.tool, and subagent_progress are deliberately not + # forwarded (high-volume UI noise); lifecycle boundaries must land so + # clients can observe delegate_task timeouts and failures. + fields = _FIXED_EVENT_FIELDS.get(event_type) + if fields is not None: + _push(_run_event(run_id, event_type, **fields(tool_name, preview, kwargs))) elif event_type in {"subagent.start", "subagent.complete"}: event = _run_event(run_id, event_type) if preview is not None: @@ -196,9 +196,6 @@ def _make_run_event_callback(self, run_id: str, loop: "asyncio.AbstractEventLoop value = redact_sensitive_text(value, force=True) event[key] = value _push(event) - # _thinking, subagent.tool, and subagent_progress are deliberately not - # forwarded (high-volume UI noise); lifecycle boundaries must land so - # clients can observe delegate_task timeouts and failures. return _callback @@ -357,10 +354,9 @@ class _RunLaunch: scope), so contextvar values are captured here and re-entered later. """ + owner: Any run_id: str queue: "asyncio.Queue[Optional[Dict]]" - loop: "asyncio.AbstractEventLoop" - put_event: Callable[[Optional[Dict]], None] session_id: str gateway_session_key: Optional[str] declared_selected: bool @@ -375,8 +371,11 @@ class _RunLaunch: request_profile: Any browser_control_principal: Any browser_control_transport_family: Any - text_cb: Callable - event_cb: Callable + + def put_event(self, event: Optional[Dict]) -> None: + """Enqueue only while this run still owns live transport state.""" + if self.owner._run_streams.get(self.run_id) is self.queue: + self.queue.put_nowait(event) def _idempotency_key_from(request: "web.Request", _openai_error) -> "tuple[str, web.Response | None]": @@ -395,12 +394,6 @@ def _optional_dict(body: Any, key: str) -> Optional[dict]: return value if isinstance(value, dict) else None -def _last_user_message(raw_input: Any) -> str: - if isinstance(raw_input, str): - return raw_input - return raw_input[-1].get("content", "") if isinstance(raw_input, list) else "" - - def _forget_run(self, run_id: str, *tables) -> None: """Drop *run_id* from the given run-keyed dicts/sets, then release its owner stamp.""" for table in tables: @@ -457,7 +450,10 @@ async def _handle_runs(self, request: "web.Request", *, _api_server) -> "web.Res raw_input = body.get("input") if not raw_input: return _json_error(_openai_error, "Missing 'input' field", status=400) - user_message = _last_user_message(raw_input) + if isinstance(raw_input, str): + user_message = raw_input + else: + user_message = raw_input[-1].get("content", "") if isinstance(raw_input, list) else "" if not user_message: return _json_error(_openai_error, "No user message found in input", status=400) @@ -513,25 +509,11 @@ async def _handle_runs(self, request: "web.Request", *, _api_server) -> "web.Res # run: session ids / memory keys are conversation scopes, not authorization # namespaces, and resolving one run's approval must not unblock another's. approval_session_key = run_id - loop = asyncio.get_running_loop() q: "asyncio.Queue[Optional[Dict]]" = asyncio.Queue() created_at = time.time() self._run_streams[run_id] = q self._run_streams_created[run_id] = created_at self._run_approval_sessions[run_id] = approval_session_key - event_cb = self._make_run_event_callback(run_id, loop) - - def _put_event_if_active(event: Optional[Dict]) -> None: - """Enqueue only while this run still owns live transport state.""" - if self._run_streams.get(run_id) is q: - q.put_nowait(event) - - def _text_cb(delta: Optional[str]) -> None: - if delta is None or run_id not in self._run_streams: - return - with suppress(Exception): - loop.call_soon_threadsafe(_put_event_if_active, _run_event(run_id, "message.delta", delta=delta)) - initial_status = self._set_run_status( run_id, "queued", created_at=created_at, session_id=session_id, model=body.get("model", self._model_name) ) @@ -542,18 +524,17 @@ async def _handle_runs(self, request: "web.Request", *, _api_server) -> "web.Res retention_until=_room_retention_until(request), ) if outcome != "created": - for table in ( - self._run_streams, self._run_streams_created, self._run_approval_sessions, + _forget_run( + self, run_id, self._run_streams, self._run_streams_created, self._run_approval_sessions, self._run_statuses, self._run_owners, - ): - table.pop(run_id, None) + ) if outcome == "conflict": return _idempotency_conflict(_openai_error) return _replay_response(self, request, record, gateway_session_key) self._run_idempotency_ids.add(run_id) launch = _RunLaunch( - run_id=run_id, queue=q, loop=loop, put_event=_put_event_if_active, session_id=session_id, + self, run_id=run_id, queue=q, session_id=session_id, gateway_session_key=gateway_session_key, declared_selected=_declared_selected, approval_session_key=approval_session_key, user_message=user_message, conversation_history=conversation_history, ephemeral_system_prompt=instructions, @@ -562,7 +543,6 @@ async def _handle_runs(self, request: "web.Request", *, _api_server) -> "web.Res request_profile=_api_server._api_request_profile.get(), browser_control_principal=_api_server._api_request_browser_control_principal.get(), browser_control_transport_family=_api_server._api_request_browser_control_transport_family.get(), - text_cb=_text_cb, event_cb=event_cb, ) _start_run_task(self, launch, _api_server=_api_server) response_headers = {"X-Hermes-Session-Key": gateway_session_key} if gateway_session_key else {} @@ -587,6 +567,9 @@ def _start_run_task(self, launch: _RunLaunch, *, _api_server) -> None: def _run_agent_sync(self, run: _RunLaunch, agent, approval_notify, *, _api_server): """Executor-thread body of one run; returns ``(result, usage)``.""" from gateway.session_context import clear_session_vars + from gateway.hosted_room_execution_policy import ( + RoomExecutionPolicy, bind_room_execution_policy, reset_room_execution_policy, + ) from tools.approval import ( register_gateway_notify, reset_current_session_key, @@ -596,14 +579,13 @@ def _run_agent_sync(self, run: _RunLaunch, agent, approval_notify, *, _api_serve session_id = run.session_id effective_task_id = session_id or run.run_id - approval_token = None - session_tokens = [] - room_policy_token = None + # (token, reset) pairs unwound in the finally block; bound only once each step succeeds. + resets: list[tuple[Any, Callable]] = [] with self._profile_scope(run.request_profile): try: # Bind approval/session identity via contextvars so concurrent # runs do not share process environment state. - approval_token = set_current_session_key(run.approval_session_key) + resets.append((set_current_session_key(run.approval_session_key), reset_current_session_key)) # chat_id carries the raw session id exactly like the other # agent-entry routes bind it via _run_agent(); without it # tools.async_delegation sees an empty HERMES_SESSION_CHAT_ID and @@ -613,11 +595,11 @@ def _run_agent_sync(self, run: _RunLaunch, agent, approval_notify, *, _api_serve browser_control_principal=run.browser_control_principal, browser_control_transport_family=run.browser_control_transport_family, ) + if session_tokens: + resets.append((session_tokens, clear_session_vars)) if run.room_dispatch is not None: - from gateway.hosted_room_execution_policy import RoomExecutionPolicy, bind_room_execution_policy - policy = RoomExecutionPolicy.from_mapping(run.room_execution_policy or {}) - room_policy_token = bind_room_execution_policy(policy) + resets.append((bind_room_execution_policy(policy), reset_room_execution_policy)) register_gateway_notify(run.approval_session_key, approval_notify) # /v1/runs owns its agent lifecycle (no TurnRunner/_run_agent): # record turn process ownership so stop/cancel reaps only the @@ -640,28 +622,19 @@ def _run_agent_sync(self, run: _RunLaunch, agent, approval_notify, *, _api_serve try: unregister_gateway_notify(run.approval_session_key) finally: - if approval_token is not None: + for token, reset in resets: with suppress(Exception): - reset_current_session_key(approval_token) - if session_tokens: - with suppress(Exception): - clear_session_vars(session_tokens) - if room_policy_token is not None: - with suppress(Exception): - from gateway.hosted_room_execution_policy import reset_room_execution_policy - - reset_room_execution_policy(room_policy_token) - u = { + reset(token) + return r, { "input_tokens": getattr(agent, "session_prompt_tokens", 0) or 0, "output_tokens": getattr(agent, "session_completion_tokens", 0) or 0, "total_tokens": getattr(agent, "session_total_tokens", 0) or 0, } - return r, u def _make_approval_notify(self, run: _RunLaunch, *, _api_server) -> Callable[[Dict[str, Any]], None]: """Approval-request bridge: redact, stamp the event envelope, park the run status, enqueue.""" - run_id, q, loop = run.run_id, run.queue, run.loop + run_id, q, loop = run.run_id, run.queue, asyncio.get_running_loop() def _approval_notify(approval_data: Dict[str, Any]) -> None: event = dict(approval_data or {}) @@ -692,7 +665,13 @@ async def _execute_run(self, run: _RunLaunch, *, _api_server) -> None: """Background task for one admitted run: drives the agent, then publishes the terminal event/status and releases live state.""" _redact_api_error_text = _api_server._redact_api_error_text - run_id = run.run_id + run_id, loop = run.run_id, asyncio.get_running_loop() + + def _text_cb(delta: Optional[str]) -> None: + if delta is None or run_id not in self._run_streams: + return + with suppress(Exception): + loop.call_soon_threadsafe(run.put_event, _run_event(run_id, "message.delta", delta=delta)) def _finish(status: str, extra: Optional[dict] = None, **fields: Any) -> None: """Publish a terminal status, then the matching ``run.`` event (best effort). @@ -712,7 +691,7 @@ async def _execute_run(self, run: _RunLaunch, *, _api_server) -> None: with self._profile_scope(run.request_profile): agent = self._create_agent( ephemeral_system_prompt=run.ephemeral_system_prompt, session_id=run.session_id, - stream_delta_callback=run.text_cb, tool_progress_callback=run.event_cb, + stream_delta_callback=_text_cb, tool_progress_callback=self._make_run_event_callback(run_id, loop), gateway_session_key=run.gateway_session_key, requested_model=run.agent_overrides.get("requested_model"), requested_provider=run.agent_overrides.get("requested_provider"), @@ -721,7 +700,7 @@ async def _execute_run(self, run: _RunLaunch, *, _api_server) -> None: ) self._active_run_agents[run_id] = agent approval_notify = _make_approval_notify(self, run, _api_server=_api_server) - result, usage = await asyncio.get_running_loop().run_in_executor( + result, usage = await loop.run_in_executor( None, lambda: _run_agent_sync(self, run, agent, approval_notify, _api_server=_api_server) ) if not isinstance(result, dict): @@ -798,12 +777,17 @@ def _request_owns_run(self, request: "web.Request", run_id: str) -> bool: return self._run_idempotency_store.owns_run(scope, run_id) -def _load_owned_run(self, request: "web.Request", *, _openai_error, active_fallback: bool): - """Resolve ``(run_id, status, agent, task, error)`` for a control endpoint. +def _load_owned_run(self, request: "web.Request", *, _api_server, permission: Optional[str], active_fallback: bool): + """Authenticate and resolve ``(run_id, status, agent, task, error)`` for a control endpoint. - With *active_fallback*, an in-process run registered before pollable - status existed is reported as ``running`` instead of 404. + *permission* selects room-grant auth (``None`` = plain API-key auth). With + *active_fallback*, an in-process run registered before pollable status + existed is reported as ``running`` instead of 404. """ + auth_err = self._check_run_auth(request, permission=permission) if permission else self._check_auth(request) + if auth_err: + return None, None, None, None, auth_err + _openai_error = _api_server._openai_error run_id = request.match_info["run_id"] if not self._request_owns_run(request, run_id): return run_id, None, None, None, _run_not_found(_openai_error, run_id) @@ -819,44 +803,34 @@ def _load_owned_run(self, request: "web.Request", *, _openai_error, active_fallb 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.""" - auth_err = self._check_run_auth(request, permission="status") - if auth_err: - return auth_err _, status, _, _, err = _load_owned_run( - self, request, _openai_error=_api_server._openai_error, active_fallback=True + 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": """GET /v1/runs/{run_id}/events — stream structured agent lifecycle events.""" - _openai_error = _api_server._openai_error - auth_err = self._check_auth(request) if auth_err: return auth_err - run_id = request.match_info["run_id"] if not self._request_owns_run(request, run_id): - return _run_not_found(_openai_error, run_id) - + return _run_not_found(_api_server._openai_error, run_id) # Allow subscribing slightly before the run is registered (race window). for _ in range(20): if run_id in self._run_streams: break await asyncio.sleep(0.05) else: - return _run_not_found(_openai_error, run_id) - + return _run_not_found(_api_server._openai_error, run_id) q = self._run_streams[run_id] self._run_stream_subscribers.add(run_id) - response = web.StreamResponse( status=200, headers={"Content-Type": "text/event-stream", "Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, ) await response.prepare(request) - try: while True: try: @@ -897,11 +871,9 @@ async def _handle_run_approval(self, request: "web.Request", *, _api_server) -> """POST /v1/runs/{run_id}/approval — resolve a pending run approval.""" _coerce_request_bool = _api_server._coerce_request_bool _openai_error = _api_server._openai_error - - auth_err = self._check_run_auth(request, permission="approve") - if auth_err: - return auth_err - run_id, _, _, _, err = _load_owned_run(self, request, _openai_error=_openai_error, active_fallback=False) + run_id, _, _, _, err = _load_owned_run( + self, request, _api_server=_api_server, permission="approve", active_fallback=False + ) if err is not None: return err @@ -915,38 +887,28 @@ async def _handle_run_approval(self, request: "web.Request", *, _api_server) -> room_scoped = bool(self._room_grant_token(request)) raw_request_id = body.get("request_id") request_id = raw_request_id.strip() if isinstance(raw_request_id, str) else "" - if raw_request_id is not None and (not request_id or len(request_id) > 256): - return _json_error( - _openai_error, "Approval request_id is invalid.", code="invalid_approval_request", status=400 - ) + # Room grants may resolve exactly one request and never widen to session/always. allowed = {"once", "deny"} if room_scoped else {"once", "session", "always", "deny"} - if choice not in allowed: - return _json_error( - _openai_error, "Invalid approval choice; expected one of: " + ", ".join(sorted(allowed)), - code="invalid_approval_choice", status=400, - ) - resolve_all = ( _coerce_request_bool(body.get("all"), default=False) or _coerce_request_bool(body.get("resolve_all"), default=False) ) - if room_scoped and resolve_all: - return _json_error( - _openai_error, "Room approvals can resolve only one exact request", - code="invalid_approval_scope", status=400, - ) - if room_scoped and not request_id: - return _json_error( - _openai_error, "Room approvals require the exact request_id.", - code="approval_request_required", status=400, - ) - approval_session_key = self._run_approval_sessions.get(run_id) - if not approval_session_key: - return _json_error( - _openai_error, f"Run has no active approval session: {run_id}", - code="approval_not_active", status=409, - ) + for failed, message, code, status in ( + (raw_request_id is not None and (not request_id or len(request_id) > 256), + "Approval request_id is invalid.", "invalid_approval_request", 400), + (choice not in allowed, + "Invalid approval choice; expected one of: " + ", ".join(sorted(allowed)), + "invalid_approval_choice", 400), + (room_scoped and resolve_all, + "Room approvals can resolve only one exact request", "invalid_approval_scope", 400), + (room_scoped and not request_id, + "Room approvals require the exact request_id.", "approval_request_required", 400), + (not approval_session_key, + f"Run has no active approval session: {run_id}", "approval_not_active", 409), + ): + if failed: + return _json_error(_openai_error, message, code=code, status=status) try: from tools.approval import resolve_gateway_approval @@ -976,12 +938,8 @@ async def _handle_run_approval(self, request: "web.Request", *, _api_server) -> 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 - - auth_err = self._check_auth(request) - if auth_err: - return auth_err run_id, status, agent, _, err = _load_owned_run( - self, request, _openai_error=_openai_error, active_fallback=False + self, request, _api_server=_api_server, permission=None, active_fallback=False ) if err is not None: return err @@ -1009,14 +967,11 @@ async def _handle_steer_run(self, request: "web.Request", *, _api_server) -> "we accepted = bool(agent.steer(steer_text)) except Exception as exc: logger.exception("[api_server] steer failed for run %s", run_id) - return _json_error( - _openai_error, _api_server._redact_api_error_text(exc), code="steer_failed", status=500 - ) + return _json_error(_openai_error, _api_server._redact_api_error_text(exc), code="steer_failed", status=500) if not accepted: return _json_error( _openai_error, f"Run did not accept steer text: {run_id}", code="steer_not_accepted", status=409 ) - _mark_run_event(self, run_id, "run.steered", accepted=True) return web.json_response({"object": "hermes.run.steer", "run_id": run_id, "accepted": True}) @@ -1024,12 +979,8 @@ async def _handle_steer_run(self, request: "web.Request", *, _api_server) -> "we 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 - - auth_err = self._check_run_auth(request, permission="stop") - if auth_err: - return auth_err run_id, status, agent, task, err = _load_owned_run( - self, request, _openai_error=_openai_error, active_fallback=True + self, request, _api_server=_api_server, permission="stop", active_fallback=True ) if err is not None: return err @@ -1075,13 +1026,11 @@ def _sweep_orphaned_runs_once(self, now: Optional[float] = None) -> None: for run_id in stale: logger.debug("[api_server] sweeping expired run transport %s", run_id) task = self._active_run_tasks.get(run_id) - task_done = task is None or task.done() - if task_done: - _unregister_approval_notify(self._run_approval_sessions.get(run_id)) # The transport TTL always bounds buffering. Live control state is # independent and survives until the executor-backed task returns. _drop_run_transport(self, run_id) - if task_done: + if task is None or task.done(): + _unregister_approval_notify(self._run_approval_sessions.get(run_id)) _retire_live_run(self, run_id) stale_statuses = [ From 1a1e95bb0b264aa24ba8eaa1317592f6ed73e8a0 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:04:11 -0700 Subject: [PATCH 07/22] refactor(gateway/platforms): qqbot STT url selection + silk sniff via magic table --- gateway/platforms/qqbot/adapter.py | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/gateway/platforms/qqbot/adapter.py b/gateway/platforms/qqbot/adapter.py index 6a9b408e8d..3bae37a948 100644 --- a/gateway/platforms/qqbot/adapter.py +++ b/gateway/platforms/qqbot/adapter.py @@ -1049,13 +1049,10 @@ class QQAdapter(BasePlatformAdapter): logger.debug("[%s] STT: using QQ asr_refer_text: %r", self._log_tag, asr_refer_text[:100]) return asr_refer_text + is_pre_wav = bool(voice_wav_url) download_url = url - is_pre_wav = False - if voice_wav_url: - if voice_wav_url.startswith("//"): - voice_wav_url = f"https:{voice_wav_url}" - download_url = voice_wav_url - is_pre_wav = True + if is_pre_wav: + download_url = f"https:{voice_wav_url}" if voice_wav_url.startswith("//") else voice_wav_url logger.debug("[%s] STT: using voice_wav_url (pre-converted WAV)", self._log_tag) from tools.url_safety import is_safe_url @@ -1079,9 +1076,7 @@ class QQAdapter(BasePlatformAdapter): "[%s] STT: downloaded %d bytes, content_type=%s", self._log_tag, len(audio_data), resp.headers.get("content-type", "unknown")) if len(audio_data) < 10: - logger.warning( - "[%s] STT: downloaded data too small (%d bytes), skipping", self._log_tag, len(audio_data) - ) + logger.warning("[%s] STT: downloaded data too small (%d bytes), skipping", self._log_tag, len(audio_data)) return None if is_pre_wav: @@ -1155,10 +1150,9 @@ class QQAdapter(BasePlatformAdapter): """Guess file extension from magic bytes (unknown → .amr, QQ's most common).""" return next((ext for magic, ext in cls._MAGIC_EXTS if data.startswith(magic)), ".amr") - @staticmethod - def _looks_like_silk(data: bytes) -> bool: - """Check if bytes look like a SILK audio file.""" - return data[:6] == b"#!SILK" or data[:2] == b"\x02!" + @classmethod + def _looks_like_silk(cls, data: bytes) -> bool: + return cls._guess_ext_from_data(data) == ".silk" async def _convert_silk_to_wav(self, src_path: str, wav_path: str) -> Optional[str]: """Convert to WAV with pilk: as-is first, then copied to .silk (pilk checks the extension).""" From e282b09973a1dc6b7a03b4b246ea1016b5a125b6 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:08:22 -0700 Subject: [PATCH 08/22] refactor(gateway/platforms): qqbot temp-pair helper, connect() dependency table, media error paths compacted --- gateway/platforms/qqbot/adapter.py | 54 ++++++++++++------------------ 1 file changed, 21 insertions(+), 33 deletions(-) diff --git a/gateway/platforms/qqbot/adapter.py b/gateway/platforms/qqbot/adapter.py index 3bae37a948..5e114a2600 100644 --- a/gateway/platforms/qqbot/adapter.py +++ b/gateway/platforms/qqbot/adapter.py @@ -188,14 +188,13 @@ class QQAdapter(BasePlatformAdapter): async def connect(self, *, is_reconnect: bool = False) -> bool: """Authenticate, obtain gateway URL, and open the WebSocket. ``is_reconnect`` is accepted for interface conformance only (QQBot has no server-side update queue).""" - for ok, code, message, hint in ( - (AIOHTTP_AVAILABLE, "qq_missing_dependency", "QQ startup failed: aiohttp not installed", - ". Run: pip install aiohttp"), - (HTTPX_AVAILABLE, "qq_missing_dependency", "QQ startup failed: httpx not installed", - ". Run: pip install httpx"), + for ok, code, what, hint in ( + (AIOHTTP_AVAILABLE, "qq_missing_dependency", "aiohttp not installed", ". Run: pip install aiohttp"), + (HTTPX_AVAILABLE, "qq_missing_dependency", "httpx not installed", ". Run: pip install httpx"), (self._app_id and self._client_secret, "qq_missing_credentials", - "QQ startup failed: QQ_APP_ID and QQ_CLIENT_SECRET are required", "")): + "QQ_APP_ID and QQ_CLIENT_SECRET are required", "")): if not ok: + message = f"QQ startup failed: {what}" self._set_fatal_error(code, message, retryable=True) logger.warning("[%s] %s%s", self._log_tag, message, hint) return False @@ -1124,6 +1123,12 @@ class QQAdapter(BasePlatformAdapter): """True when *wav_path* exists and holds more than a bare 44-byte header.""" return Path(wav_path).exists() and Path(wav_path).stat().st_size > 44 + @classmethod + def _temp_pair(cls, audio_data: bytes, ext: str) -> Tuple[str, str]: + """Write *audio_data* to a temp ``{ext}`` and return ``(src_path, sibling .wav path)``.""" + src_path = cls._write_temp(audio_data, ext) + return src_path, src_path.rsplit(".", 1)[0] + ".wav" + async def _convert_audio_to_wav_file(self, audio_data: bytes, filename: str) -> Optional[str]: """Convert audio bytes to a temp .wav: pilk (SILK, which ffmpeg can't decode) → ffmpeg → raw-PCM last resort. Returns the wav path or None.""" @@ -1131,8 +1136,7 @@ class QQAdapter(BasePlatformAdapter): logger.info( "[%s] STT: audio_data size=%d, ext=%r, first_20_bytes=%r", self._log_tag, len(audio_data), ext, audio_data[:20]) - src_path = self._write_temp(audio_data, ext) - wav_path = src_path.rsplit(".", 1)[0] + ".wav" + src_path, wav_path = self._temp_pair(audio_data, ext) result = ( await self._convert_silk_to_wav(src_path, wav_path) or await self._convert_ffmpeg_to_wav(src_path, wav_path) @@ -1284,15 +1288,11 @@ class QQAdapter(BasePlatformAdapter): ext = Path(urlparse(source_url).path).suffix.lower() if ext not in _AUDIO_URL_EXTENSIONS: ext = self._guess_ext_from_data(audio_data) - - src_path = self._write_temp(audio_data, ext) - wav_path = src_path.rsplit(".", 1)[0] + ".wav" + src_path, wav_path = self._temp_pair(audio_data, ext) + is_silk = ext == ".silk" or self._looks_like_silk(audio_data) + convert = self._convert_silk_to_wav if is_silk else self._convert_ffmpeg_to_wav try: - if ext == ".silk" or self._looks_like_silk(audio_data): - result = await self._convert_silk_to_wav(src_path, wav_path) - else: - result = await self._convert_ffmpeg_to_wav(src_path, wav_path) - if not result: + if not await convert(src_path, wav_path): logger.warning("[%s] audio conversion failed for %s (format=%s)", self._log_tag, source_url[:60], ext) return cache_document_from_bytes(audio_data, f"qq_voice{ext}") except Exception: @@ -1572,15 +1572,12 @@ class QQAdapter(BasePlatformAdapter): return await self.send(chat_id=chat_id, content=fallback, reply_to=reply_to) async def send_image_file(self, chat_id, image_path, caption=None, reply_to=None, **kwargs) -> SendResult: - """Send a local image file natively.""" return await self._send_media(chat_id, image_path, MEDIA_TYPE_IMAGE, "image", caption, reply_to) async def send_voice(self, chat_id, audio_path, caption=None, reply_to=None, **kwargs) -> SendResult: - """Send a voice message natively.""" return await self._send_media(chat_id, audio_path, MEDIA_TYPE_VOICE, "voice", caption, reply_to) async def send_video(self, chat_id, video_path, caption=None, reply_to=None, **kwargs) -> SendResult: - """Send a video natively.""" return await self._send_media(chat_id, video_path, MEDIA_TYPE_VIDEO, "video", caption, reply_to) async def send_document( @@ -1609,17 +1606,13 @@ class QQAdapter(BasePlatformAdapter): chat_type, chat_id, file_type, url=media_source, srv_send_msg=False, file_name=resolved_name if file_type == MEDIA_TYPE_FILE else None) else: - resolved_name, upload = await self._upload_local_file( - chat_type, chat_id, media_source, file_type, file_name) + _, upload = await self._upload_local_file(chat_type, chat_id, media_source, file_type, file_name) file_info = upload.get("file_info") or (upload.get("data", {}) or {}).get("file_info") if not file_info: return SendResult(success=False, error=f"Upload returned no file_info: {upload}") - body: Dict[str, Any] = { - "msg_type": MSG_TYPE_MEDIA, - "media": {"file_info": file_info}, - "msg_seq": self._next_msg_seq(chat_id)} + "msg_type": MSG_TYPE_MEDIA, "media": {"file_info": file_info}, "msg_seq": self._next_msg_seq(chat_id)} if caption: body["content"] = caption[: self.MAX_MESSAGE_LENGTH] if reply_to: @@ -1627,22 +1620,17 @@ class QQAdapter(BasePlatformAdapter): return await self._post_message(self._messages_path(chat_type, chat_id), body) except UploadDailyLimitExceededError as exc: # Non-retryable quota hit; give the model actionable text. - logger.warning( - "[%s] Daily upload limit exceeded for %s (%s)", self._log_tag, exc.file_name, exc.file_size_human - ) + logger.warning("[%s] Daily upload limit exceeded for %s (%s)", self._log_tag, exc.file_name, exc.file_size_human) return SendResult( success=False, retryable=False, - error=f"QQ daily upload limit exceeded for {exc.file_name!r} ({exc.file_size_human}). Retry tomorrow.", - ) + error=f"QQ daily upload limit exceeded for {exc.file_name!r} ({exc.file_size_human}). Retry tomorrow.") except UploadFileTooLargeError as exc: logger.warning( "[%s] File too large: %s (%s, platform limit %s)", self._log_tag, exc.file_name, exc.file_size_human, exc.limit_human) return SendResult( success=False, retryable=False, - error=( - f"{exc.file_name!r} ({exc.file_size_human}) exceeds the " - f"QQ per-file upload limit ({exc.limit_human}).")) + error=f"{exc.file_name!r} ({exc.file_size_human}) exceeds the QQ per-file upload limit ({exc.limit_human}).") except Exception as exc: logger.error("[%s] Media send failed: %s", self._log_tag, exc) return SendResult(success=False, error=str(exc) or type(exc).__name__) From a12a72314dfd91d4e0fbd2efc6c617a06aaea078 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:12:53 -0700 Subject: [PATCH 09/22] =?UTF-8?q?refactor(gateway/platforms):=20runs=20?= =?UTF-8?q?=E2=80=94=20unify=20202=20admission/replay=20responses,=20scope?= =?UTF-8?q?=20hashing,=20AST-neutral=20layout=20packing=20across=20the=20f?= =?UTF-8?q?our=20/v1/runs=20+=20room=20modules?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gateway/platforms/api_server_room_dispatch.py | 19 +- gateway/platforms/api_server_room_grants.py | 83 +++----- .../platforms/api_server_run_idempotency.py | 42 ++-- gateway/platforms/api_server_runs.py | 200 +++++++----------- 4 files changed, 117 insertions(+), 227 deletions(-) diff --git a/gateway/platforms/api_server_room_dispatch.py b/gateway/platforms/api_server_room_dispatch.py index a7623b2b7c..89d7eea05b 100644 --- a/gateway/platforms/api_server_room_dispatch.py +++ b/gateway/platforms/api_server_room_dispatch.py @@ -28,8 +28,7 @@ async def _ensure_hosted_member_session(self, dispatch: Any) -> str: title = f"Group: {dispatch.room_id}" seed = ( f"{dispatch.home_install_id}\0{dispatch.room_id}\0" - f"{dispatch.member_id}\0{dispatch.target_profile}" - ) + f"{dispatch.member_id}\0{dispatch.target_profile}") session_id = f"room_{hashlib.sha256(seed.encode()).hexdigest()[:32]}" def atomic(conn): @@ -40,18 +39,14 @@ async def _ensure_hosted_member_session(self, dispatch: Any) -> str: return session_id clean_title = db.sanitize_title(title) conflict = conn.execute( - "SELECT id FROM sessions WHERE title=? AND id!=?", (clean_title, session_id) - ).fetchone() + "SELECT id FROM sessions WHERE title=? AND id!=?", (clean_title, session_id)).fetchone() if conflict: raise RuntimeError( "Another group already uses this room title on the target gateway. " - "Rename or migrate that group before retrying." - ) + "Rename or migrate that group before retrying.") conn.execute( "INSERT INTO sessions(id, source, title, hidden, started_at) " - "VALUES(?, 'bot_room', ?, 1, ?)", - (session_id, clean_title, time.time()), - ) + "VALUES(?, 'bot_room', ?, 1, ?)", (session_id, clean_title, time.time())) return session_id return await asyncio.to_thread(db._execute_write, atomic) @@ -70,8 +65,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 = _api_server._openai_error room_token = self._room_grant_token(request) @@ -80,8 +74,7 @@ async def _normalize_room_dispatch( if not isinstance(body, dict) or set(body) - {"input", "hosted_room_dispatch"}: return body, _json_error( _openai_error, "Room dispatch accepts only input and hosted_room_dispatch.", - code="invalid_room_dispatch", status=400, - ) + code="invalid_room_dispatch", status=400) try: from gateway import hosted_rooms from gateway.hosted_room_peer import GatewayRoomCatalog, HostedMemberDispatch, verify_room_grant diff --git a/gateway/platforms/api_server_room_grants.py b/gateway/platforms/api_server_room_grants.py index 8896430bc3..304961054e 100644 --- a/gateway/platforms/api_server_room_grants.py +++ b/gateway/platforms/api_server_room_grants.py @@ -28,8 +28,7 @@ def _require_unchanged_execution_policy(claims: dict[str, Any], execution_policy def _invalid_room_grant(_openai_error) -> "web.Response": return _json_error( _openai_error, "Room authorization is invalid or expired.", - err_type="gateway_auth_error", code="invalid_room_grant", status=401, - ) + err_type="gateway_auth_error", code="invalid_room_grant", status=401) def _room_grant_error_response(exc: Exception, *, _openai_error) -> "web.Response": @@ -37,8 +36,7 @@ def _room_grant_error_response(exc: Exception, *, _openai_error) -> "web.Respons return _invalid_room_grant(_openai_error) return _json_error( _openai_error, "Room authorization needs to be renewed.", - err_type="gateway_auth_error", code="room_reauthorization_required", status=403, - ) + err_type="gateway_auth_error", code="room_reauthorization_required", status=403) def _hard_expiry(claims: dict[str, Any]) -> float: @@ -52,8 +50,7 @@ def _local_target(claims: dict[str, Any] | None, _api_request_profile) -> tuple[ profile = _api_request_profile.get() or "default" installation_id = hosted_rooms.local_authority_gateway_id() if claims is not None and ( - claims["target_profile"] != profile or claims["target_install_id"] != installation_id - ): + claims["target_profile"] != profile or claims["target_install_id"] != installation_id): raise ValueError("room grant target does not match this profile") return profile, installation_id @@ -68,8 +65,7 @@ def _local_room_catalog(self, profile: str, installation_id: str) -> tuple[dict, catalog = catalog_mapping( installation_id=installation_id, protocol_versions=(PROTOCOL_VERSION,), link_modes=("direct",), persistent_process=True, text=True, attachments=False, target_profile=profile, - execution_policy=execution_policy, - ) + execution_policy=execution_policy) return execution_policy, catalog @@ -78,8 +74,7 @@ def _http_routes(self) -> list[tuple[str, str, Any]]: ("POST", "/v1/room-members/invitations", self._handle_room_member_invitation), ("GET", "/v1/room-members/capabilities", self._handle_room_member_capabilities), ("POST", "/v1/room-members/grants/refresh", self._handle_room_member_grant_refresh), - ("POST", "/v1/room-members/grants/revoke", self._handle_room_member_grant_revoke), - ] + ("POST", "/v1/room-members/grants/revoke", self._handle_room_member_grant_revoke)] def _room_grant_token(request: "web.Request") -> str: @@ -119,8 +114,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: @@ -133,8 +127,7 @@ async def _handle_room_member_invitation( if set(body) - allowed or not required <= set(body): return _json_error( _openai_error, "Invitation is missing required room authority fields.", - code="invalid_room_invitation", status=400, - ) + code="invalid_room_invitation", status=400) try: from gateway import hosted_rooms from gateway.hosted_room_peer import decode_room_grant, issue_room_grant @@ -150,22 +143,15 @@ async def _handle_room_member_invitation( token = issue_room_grant( self._room_grant_secret(), grant_id=str(body.get("grant_id") or f"grant-{uuid.uuid4().hex}"), - room_id=str(body["room_id"]), - home_install_id=str(body["home_install_id"]), + room_id=str(body["room_id"]), home_install_id=str(body["home_install_id"]), authority_gateway_id=str(body["authority_gateway_id"]), - authority_epoch=int(body["authority_epoch"]), - member_id=str(body["member_id"]), - target_install_id=target_install_id, - target_profile=profile, - execution_policy_digest=execution_policy["policy_digest"], - issued_at=time.time(), - ttl_seconds=ttl, - status_ttl_seconds=status_ttl, - ) + authority_epoch=int(body["authority_epoch"]), member_id=str(body["member_id"]), + target_install_id=target_install_id, target_profile=profile, + execution_policy_digest=execution_policy["policy_digest"], issued_at=time.time(), + ttl_seconds=ttl, status_ttl_seconds=status_ttl) claims = decode_room_grant(self._room_grant_secret(), token, permission="status") hosted_rooms.reserve_peer_room( - hosted_rooms.default_db_path(), claims=claims, expires_at=_hard_expiry(claims) - ) + hosted_rooms.default_db_path(), claims=claims, expires_at=_hard_expiry(claims)) except Exception as exc: return _json_error(_openai_error, str(exc), code="invalid_room_invitation", status=400) return web.json_response({ @@ -179,8 +165,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") @@ -196,13 +181,11 @@ async def _handle_room_member_capabilities( "authority_epoch": claims["authority_epoch"], "member_id": claims["member_id"], "target_profile": profile, - "catalog": catalog, - }) + "catalog": catalog}) 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: @@ -210,8 +193,7 @@ async def _handle_room_member_grant_refresh( if set(body) - {"ttl_seconds"}: return _json_error( _openai_error, "Grant refresh accepts only ttl_seconds.", - code="invalid_room_grant_refresh", status=400, - ) + code="invalid_room_grant_refresh", status=400) try: from gateway.hosted_room_peer import MAX_DISPATCH_GRANT_TTL_SECONDS, issue_room_grant from gateway.hosted_room_execution_policy import execution_policy_mapping @@ -231,21 +213,14 @@ async def _handle_room_member_grant_refresh( execution_policy = execution_policy_mapping(target_profile=profile) _require_unchanged_execution_policy(claims, execution_policy) token = issue_room_grant( - self._room_grant_secret(), - grant_id=f"grant-refresh-{uuid.uuid4().hex}", - room_id=claims["room_id"], - home_install_id=claims["home_install_id"], + self._room_grant_secret(), grant_id=f"grant-refresh-{uuid.uuid4().hex}", + room_id=claims["room_id"], home_install_id=claims["home_install_id"], authority_gateway_id=claims["authority_gateway_id"], - authority_epoch=int(claims["authority_epoch"]), - member_id=claims["member_id"], - target_install_id=installation_id, - target_profile=profile, + authority_epoch=int(claims["authority_epoch"]), member_id=claims["member_id"], + target_install_id=installation_id, target_profile=profile, execution_policy_digest=execution_policy["policy_digest"], - permissions=claims["permissions"], - issued_at=now, - ttl_seconds=dispatch_ttl, - status_expires_at=hard_expiry, - ) + permissions=claims["permissions"], issued_at=now, ttl_seconds=dispatch_ttl, + status_expires_at=hard_expiry) except Exception as exc: return _room_grant_error_response(exc, _openai_error=_openai_error) return web.json_response({ @@ -253,13 +228,11 @@ async def _handle_room_member_grant_refresh( "grant": token, "expires_at": now + dispatch_ttl, "status_expires_at": hard_expiry, - "execution_policy": execution_policy, - }) + "execution_policy": execution_policy}) 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: @@ -267,8 +240,7 @@ async def _handle_room_member_grant_revoke( if body: return _json_error( _openai_error, "Grant revoke accepts no fields.", - code="invalid_room_grant_revoke", status=400, - ) + code="invalid_room_grant_revoke", status=400) try: from gateway import hosted_rooms @@ -278,8 +250,7 @@ async def _handle_room_member_grant_revoke( claims = _decode_request_grant(self, request, permission="status") _local_target(claims, _api_request_profile) hosted_rooms.revoke_room_grant_scope( - hosted_rooms.default_db_path(), claims=claims, expires_at=_hard_expiry(claims) - ) + hosted_rooms.default_db_path(), claims=claims, expires_at=_hard_expiry(claims)) except Exception: return _invalid_room_grant(_openai_error) return web.json_response({"object": "hermes.room_member.grant.revocation", "revoked": True}) diff --git a/gateway/platforms/api_server_run_idempotency.py b/gateway/platforms/api_server_run_idempotency.py index b1e204fa22..fe437fdf0e 100644 --- a/gateway/platforms/api_server_run_idempotency.py +++ b/gateway/platforms/api_server_run_idempotency.py @@ -18,23 +18,19 @@ TERMINAL_STATUSES = frozenset({"completed", "failed", "cancelled", "interrupted" _SELECT_BY_KEY = ( "SELECT fingerprint, run_id, status_json, owner_pid, owner_started, updated_at " - "FROM run_idempotency WHERE scope=? AND idempotency_key=?" -) + "FROM run_idempotency WHERE scope=? AND idempotency_key=?") _EXTEND_RETENTION_BY_KEY = ( "UPDATE run_idempotency SET retention_until=MAX(retention_until, ?) " - "WHERE scope=? AND idempotency_key=? AND fingerprint=?" -) + "WHERE scope=? AND idempotency_key=? AND fingerprint=?") _EXTEND_RETENTION_BY_RUN = ( "UPDATE run_idempotency SET retention_until=MAX(retention_until, ?) " - "WHERE scope=? AND run_id=?" -) + "WHERE scope=? AND run_id=?") # Columns added after the first schema shipped; applied when missing. _MIGRATIONS = { "owner_pid": "INTEGER NOT NULL DEFAULT 0", "owner_started": "INTEGER NOT NULL DEFAULT 0", "retention_until": "REAL NOT NULL DEFAULT 0", - "acknowledged_at": "REAL", -} + "acknowledged_at": "REAL"} def _encode_status(status: Dict[str, Any]) -> str: @@ -47,8 +43,7 @@ def _record(run_id, status_json, owner_pid, owner_started, updated_at) -> dict[s "status": json.loads(status_json), "owner_pid": int(owner_pid or 0), "owner_started": int(owner_started or 0), - "updated_at": float(updated_at or 0), - } + "updated_at": float(updated_at or 0)} def _outcome(row, fingerprint): @@ -87,9 +82,7 @@ class RunIdempotencyStore: except Exception as exc: logger.warning( "Run idempotency storage is unavailable; falling back to " - "process memory, so replay will not survive a restart: %s", - exc, - ) + "process memory, so replay will not survive a restart: %s", exc) self._conn = sqlite3.connect(":memory:", check_same_thread=False) self._db_path = None from hermes_state import apply_wal_with_fallback @@ -116,8 +109,7 @@ class RunIdempotencyStore: if column not in columns: self._conn.execute(f"ALTER TABLE run_idempotency ADD COLUMN {column} {ddl}") self._conn.execute( - "CREATE UNIQUE INDEX IF NOT EXISTS run_idempotency_run_id ON run_idempotency(run_id)" - ) + "CREATE UNIQUE INDEX IF NOT EXISTS run_idempotency_run_id ON run_idempotency(run_id)") self._conn.commit() self._lock = threading.Lock() self._tighten_permissions() @@ -165,14 +157,11 @@ class RunIdempotencyStore: ") VALUES(?,?,?,?,?,?,?,?,?,?)", ( scope, key, fingerprint, run_id, encoded, - int(owner_pid or 0), int(owner_started or 0), retention_until, now, now, - ), - ) + int(owner_pid or 0), int(owner_started or 0), retention_until, now, now)) self._conn.commit() return "created", { "run_id": run_id, "status": status, "owner_pid": int(owner_pid or 0), - "owner_started": int(owner_started or 0), "updated_at": now, - } + "owner_started": int(owner_started or 0), "updated_at": now} def lookup(self, scope: str, key: str, fingerprint: str, *, retention_until: float = 0): """Return ``missing``, ``reused`` or ``conflict`` without reserving.""" @@ -209,8 +198,7 @@ class RunIdempotencyStore: if terminal: self._conn.execute( "DELETE FROM run_idempotency WHERE scope=? AND idempotency_key=?", - (stale_scope, stale_key), - ) + (stale_scope, stale_key)) def status_for_run(self, scope: str, run_id: str, *, retention_until: float = 0) -> dict[str, Any] | None: """Load one durable run status inside its authenticated scope.""" @@ -221,15 +209,12 @@ class RunIdempotencyStore: self._conn.commit() row = self._conn.execute( "SELECT status_json, owner_pid, owner_started, updated_at " - "FROM run_idempotency WHERE scope=? AND run_id=?", - (scope, run_id), - ).fetchone() + "FROM run_idempotency WHERE scope=? AND run_id=?", (scope, run_id)).fetchone() if row is None: return None return { "status": json.loads(row[0]), "owner_pid": int(row[1] or 0), - "owner_started": int(row[2] or 0), "updated_at": float(row[3] or 0), - } + "owner_started": int(row[2] or 0), "updated_at": float(row[3] or 0)} def extend_retention(self, scope: str, run_id: str, until: float) -> bool: """Persist the latest verified recovery horizon for an active grant.""" @@ -252,8 +237,7 @@ class RunIdempotencyStore: with self._lock: self._conn.execute( "UPDATE run_idempotency SET status_json=?, updated_at=? WHERE run_id=?", - (_encode_status(status), time.time(), run_id), - ) + (_encode_status(status), time.time(), run_id)) self._conn.commit() def close(self) -> None: diff --git a/gateway/platforms/api_server_runs.py b/gateway/platforms/api_server_runs.py index be0a58530a..0a1348fe9d 100644 --- a/gateway/platforms/api_server_runs.py +++ b/gateway/platforms/api_server_runs.py @@ -26,24 +26,21 @@ logger = logging.getLogger("gateway.platforms.api_server") _ROOM_RETENTION_REQUEST_KEY = ( RequestKey("hermes.room_run_retention_until", float) if RequestKey is not None - else "hermes.room_run_retention_until" -) + else "hermes.room_run_retention_until") # Forwarded subagent lifecycle fields; free-text ones are secret-redacted. _SUBAGENT_EVENT_KEYS = ( "goal", "task_count", "task_index", "subagent_id", "child_session_id", "delegation_id", "parent_id", "depth", "model", "tool_count", "status", "summary", "duration_seconds", "input_tokens", "output_tokens", "reasoning_tokens", "api_calls", "cost_usd", "files_read", "files_written", - "output_tail", -) + "output_tail") _SUBAGENT_TEXT_KEYS = ("goal", "summary", "output_tail") # Tool-progress event -> SSE payload fields (tool_name, preview, kwargs); key order is wire format. _FIXED_EVENT_FIELDS = { "tool.started": lambda tool, preview, kw: {"tool": tool, "preview": preview}, "tool.completed": lambda tool, preview, kw: { "tool": tool, "duration": round(kw.get("duration", 0), 3), "error": kw.get("is_error", False)}, - "reasoning.available": lambda tool, preview, kw: {"text": preview or ""}, -} + "reasoning.available": lambda tool, preview, kw: {"text": preview or ""}} def _remember_room_retention(request: "web.Request", claims: dict[str, Any]) -> None: @@ -71,13 +68,6 @@ 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 _idempotency_conflict(_openai_error) -> "web.Response": - return _json_error( - _openai_error, "Idempotency-Key was already used with a different request payload", - code="idempotency_key_conflict", status=409, - ) - - def _uses_room_run_auth(self, request: "web.Request") -> bool: return request.path.endswith("/v1/runs") and bool(self._room_grant_token(request)) @@ -109,21 +99,18 @@ def _initialize_run_state(self, *, store_factory) -> None: def _http_routes(self) -> list[tuple[str, str, Any]]: return [ - ("POST", "/v1/runs", self._handle_runs), - ("GET", "/v1/runs/{run_id}", self._handle_get_run), + ("POST", "/v1/runs", self._handle_runs), ("GET", "/v1/runs/{run_id}", self._handle_get_run), ("GET", "/v1/runs/{run_id}/events", self._handle_run_events), ("POST", "/v1/runs/{run_id}/approval", self._handle_run_approval), ("POST", "/v1/runs/{run_id}/steer", self._handle_steer_run), - ("POST", "/v1/runs/{run_id}/stop", self._handle_stop_run), - ] + ("POST", "/v1/runs/{run_id}/stop", self._handle_stop_run)] def _idempotency_capabilities(self, *, store_type) -> dict[str, Any]: return { "supported": True, "durable": self._run_idempotency_store.durable, - "retention_seconds": store_type.RETENTION_SECONDS, - } + "retention_seconds": store_type.RETENTION_SECONDS} def _close_run_state(self) -> None: @@ -151,8 +138,7 @@ def _set_run_status(self, run_id: str, status: str, **fields: Any) -> Dict[str, should_persist = ( status != previous_status or status in TERMINAL_STATUSES - or bool(field_names & {"output", "error", "usage", "pending_steer", "session_id"}) - ) + or bool(field_names & {"output", "error", "usage", "pending_steer", "session_id"})) if run_id in self._run_idempotency_ids and should_persist: try: self._run_idempotency_store.update_status(run_id, current) @@ -213,16 +199,13 @@ def _run_idempotency_scope(self, request: "web.Request", *, _api_server) -> str: if self._room_grant_token(request): claims = self._room_grant_claims(request, permission=_room_permission_for(request)) _remember_room_retention(request, claims) - identity = ( - f"{claims['room_id']}\0{claims['home_install_id']}\0" - f"{claims['authority_gateway_id']}\0{claims['authority_epoch']}\0" - f"{claims['member_id']}\0{claims['target_install_id']}\0" - f"{claims['target_profile']}" - ) - return hashlib.sha256(identity.encode()).hexdigest() - profile = _api_server._api_request_profile.get() or "default" - identity = self._expected_api_key() or "unauthenticated-test-listener" - return hashlib.sha256(f"{profile}\0{identity}".encode()).hexdigest() + parts = (claims[k] for k in ( + "room_id", "home_install_id", "authority_gateway_id", "authority_epoch", + "member_id", "target_install_id", "target_profile")) + else: + parts = (_api_server._api_request_profile.get() or "default", + self._expected_api_key() or "unauthenticated-test-listener") + 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": @@ -261,21 +244,16 @@ def _durable_run_status(self, request: "web.Request", run_id: str) -> Dict[str, scope = self._run_idempotency_scope(request) record = self._run_idempotency_store.status_for_run( - scope, run_id, retention_until=_room_retention_until(request) - ) + scope, run_id, retention_until=_room_retention_until(request)) if record is None: return None status = dict(record["status"]) if status.get("status") not in TERMINAL_STATUSES and not _owner_alive( - int(record.get("owner_pid") or 0), int(record.get("owner_started") or 0) - ): - status.update({ - "status": "interrupted", - "error": "The gateway restarted before this run settled.", - "last_event": "run.interrupted", - "updated_at": time.time(), - }) + int(record.get("owner_pid") or 0), int(record.get("owner_started") or 0)): + status.update( + status="interrupted", error="The gateway restarted before this run settled.", + last_event="run.interrupted", updated_at=time.time()) self._run_idempotency_store.update_status(run_id, status) self._run_statuses[run_id] = status @@ -305,8 +283,7 @@ def _resolve_conversation_history( if not isinstance(entry, dict) or "role" not in entry or "content" not in entry: return [], instructions, None, _json_error( _openai_error, f"conversation_history[{i}] must have 'role' and 'content' fields", - status=400, - ) + status=400) conversation_history.append({"role": str(entry["role"]), "content": str(entry["content"])}) if previous_response_id: logger.debug("Both conversation_history and previous_response_id provided; using conversation_history") @@ -327,23 +304,29 @@ def _resolve_conversation_history( if isinstance(content, list): # flatten multi-part content blocks to text content = " ".join( part.get("text", "") for part in content - if isinstance(part, dict) and part.get("type") == "text" - ) + if isinstance(part, dict) and part.get("type") == "text") conversation_history.append({"role": msg["role"], "content": str(content)}) return conversation_history, instructions, stored_session_id, None -def _replay_response(self, request: "web.Request", record: dict, gateway_session_key) -> "web.Response": - """202 replay of an already-admitted idempotent run.""" - original_id = str(record["run_id"]) - status = self._durable_run_status(request, original_id) or record["status"] - headers = {"Idempotency-Replayed": "true"} +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: headers["X-Hermes-Session-Key"] = gateway_session_key return web.json_response( - {"run_id": original_id, "status": status.get("status", "queued"), "replayed": True}, - status=202, headers=headers, - ) + {"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": + """409 for a fingerprint conflict, else a 202 replay of the already-admitted run.""" + if outcome == "conflict": + return _json_error( + _openai_error, "Idempotency-Key was already used with a different request payload", + code="idempotency_key_conflict", status=409) + original_id = str(record["run_id"]) + status = self._durable_run_status(request, original_id) or record["status"] + return _accepted_response(original_id, status.get("status", "queued"), gateway_session_key, replayed=True) @dataclass(slots=True) @@ -384,8 +367,7 @@ def _idempotency_key_from(request: "web.Request", _openai_error) -> "tuple[str, if key and (len(key) > 255 or any(ord(ch) < 33 or ord(ch) > 126 for ch in key)): return "", _json_error( _openai_error, "Idempotency-Key must be 1-255 visible ASCII characters", - code="invalid_idempotency_key", status=400, - ) + code="invalid_idempotency_key", status=400) return key, None @@ -408,8 +390,7 @@ def _retire_live_run(self, run_id: str) -> None: """Retire agent/task/approval control state once the executor-backed task is done.""" _forget_run( self, run_id, self._active_run_agents, self._active_run_tasks, - self._run_approval_sessions, self._stopping_run_ids, - ) + self._run_approval_sessions, self._stopping_run_ids) def _drop_run_transport(self, run_id: str) -> None: @@ -458,8 +439,7 @@ async def _handle_runs(self, request: "web.Request", *, _api_server) -> "web.Res return _json_error(_openai_error, "No user message found in input", status=400) conversation_history, instructions, stored_session_id, history_err = ( - _resolve_conversation_history(self, body, raw_input, _openai_error=_openai_error) - ) + _resolve_conversation_history(self, body, raw_input, _openai_error=_openai_error)) if history_err is not None: return history_err previous_response_id = body.get("previous_response_id") @@ -470,8 +450,7 @@ async def _handle_runs(self, request: "web.Request", *, _api_server) -> "web.Res selection_error = self._request_route_conflict_error( session_id=session_id, gateway_session_key=gateway_session_key, requested_model=agent_overrides.get("requested_model"), - requested_provider=agent_overrides.get("requested_provider"), route=route, - ) + requested_provider=agent_overrides.get("requested_provider"), route=route) if selection_error: return _json_error(_openai_error, selection_error, status=400) @@ -481,12 +460,9 @@ async def _handle_runs(self, request: "web.Request", *, _api_server) -> "web.Res if idempotency_key: outcome, record = self._run_idempotency_store.lookup( idempotency_scope, idempotency_key, idempotency_fingerprint, - retention_until=_room_retention_until(request), - ) - if outcome == "conflict": - return _idempotency_conflict(_openai_error) - if outcome == "reused" and record is not None: - return _replay_response(self, request, record, gateway_session_key) + retention_until=_room_retention_until(request)) + if outcome == "conflict" or (outcome == "reused" and record is not None): + return _replay_or_conflict(self, request, outcome, record, gateway_session_key, _openai_error) # Enforce concurrency only for a genuinely new run. limited = self._concurrency_limited_response() @@ -521,16 +497,12 @@ async def _handle_runs(self, request: "web.Request", *, _api_server) -> "web.Res outcome, record = self._run_idempotency_store.reserve( idempotency_scope, idempotency_key, idempotency_fingerprint, run_id, initial_status, owner_pid=self._run_owner_pid, owner_started=self._run_owner_started, - retention_until=_room_retention_until(request), - ) + retention_until=_room_retention_until(request)) if outcome != "created": _forget_run( self, run_id, self._run_streams, self._run_streams_created, self._run_approval_sessions, - self._run_statuses, self._run_owners, - ) - if outcome == "conflict": - return _idempotency_conflict(_openai_error) - return _replay_response(self, request, record, gateway_session_key) + self._run_statuses, self._run_owners) + return _replay_or_conflict(self, request, outcome, record, gateway_session_key, _openai_error) self._run_idempotency_ids.add(run_id) launch = _RunLaunch( @@ -545,10 +517,7 @@ async def _handle_runs(self, request: "web.Request", *, _api_server) -> "web.Res browser_control_transport_family=_api_server._api_request_browser_control_transport_family.get(), ) _start_run_task(self, launch, _api_server=_api_server) - response_headers = {"X-Hermes-Session-Key": gateway_session_key} if gateway_session_key else {} - return web.json_response( - {"run_id": run_id, "status": "started", "replayed": False}, status=202, headers=response_headers - ) + return _accepted_response(run_id, "started", gateway_session_key, replayed=False) def _start_run_task(self, launch: _RunLaunch, *, _api_server) -> None: @@ -568,14 +537,10 @@ def _run_agent_sync(self, run: _RunLaunch, agent, approval_notify, *, _api_serve """Executor-thread body of one run; returns ``(result, usage)``.""" from gateway.session_context import clear_session_vars from gateway.hosted_room_execution_policy import ( - RoomExecutionPolicy, bind_room_execution_policy, reset_room_execution_policy, - ) + RoomExecutionPolicy, bind_room_execution_policy, reset_room_execution_policy) from tools.approval import ( - register_gateway_notify, - reset_current_session_key, - set_current_session_key, - unregister_gateway_notify, - ) + register_gateway_notify, reset_current_session_key, set_current_session_key, + unregister_gateway_notify) session_id = run.session_id effective_task_id = session_id or run.run_id @@ -593,8 +558,7 @@ def _run_agent_sync(self, run: _RunLaunch, agent, approval_notify, *, _api_serve session_tokens = self._bind_api_server_session( chat_id=session_id or "", session_key=run.approval_session_key, session_id=session_id or "", browser_control_principal=run.browser_control_principal, - browser_control_transport_family=run.browser_control_transport_family, - ) + browser_control_transport_family=run.browser_control_transport_family) if session_tokens: resets.append((session_tokens, clear_session_vars)) if run.room_dispatch is not None: @@ -607,8 +571,7 @@ def _run_agent_sync(self, run: _RunLaunch, agent, approval_notify, *, _api_serve _api_server._publish_turn_process_ownership(agent, effective_task_id) r = agent.run_conversation( user_message=run.user_message, conversation_history=run.conversation_history, - task_id=effective_task_id, - ) + task_id=effective_task_id) finally: # Clear ownership immediately so a later stop/cancel can't reap # background work this run deliberately left running. @@ -617,8 +580,7 @@ def _run_agent_sync(self, run: _RunLaunch, agent, approval_notify, *, _api_serve # bind_declared_conversation), with the same precedence gate. if run.declared_selected: self._bind_declared_conversation( - getattr(agent, "session_id", None) or session_id, run.gateway_session_key - ) + getattr(agent, "session_id", None) or session_id, run.gateway_session_key) try: unregister_gateway_notify(run.approval_session_key) finally: @@ -628,8 +590,7 @@ def _run_agent_sync(self, run: _RunLaunch, agent, approval_notify, *, _api_serve return r, { "input_tokens": getattr(agent, "session_prompt_tokens", 0) or 0, "output_tokens": getattr(agent, "session_completion_tokens", 0) or 0, - "total_tokens": getattr(agent, "session_total_tokens", 0) or 0, - } + "total_tokens": getattr(agent, "session_total_tokens", 0) or 0} def _make_approval_notify(self, run: _RunLaunch, *, _api_server) -> Callable[[Dict[str, Any]], None]: @@ -651,9 +612,7 @@ def _make_approval_notify(self, run: _RunLaunch, *, _api_server) -> Callable[[Di "choices": _api_server._approval_event_choices( smart_denied=bool(event.get("smart_denied")), allow_session=event.get("allow_session") is not False, - allow_permanent=event.get("allow_permanent") is not False, - ), - }) + allow_permanent=event.get("allow_permanent") is not False)}) self._set_run_status(run_id, "waiting_for_approval", last_event="approval.request", approval=event) with suppress(Exception): loop.call_soon_threadsafe(q.put_nowait, event) @@ -696,8 +655,7 @@ async def _execute_run(self, run: _RunLaunch, *, _api_server) -> None: requested_model=run.agent_overrides.get("requested_model"), requested_provider=run.agent_overrides.get("requested_provider"), model_options=run.agent_overrides.get("model_options"), route=run.route, - room_dispatch=run.room_dispatch, room_execution_policy=run.room_execution_policy, - ) + room_dispatch=run.room_dispatch, room_execution_policy=run.room_execution_policy) self._active_run_agents[run_id] = agent approval_notify = _make_approval_notify(self, run, _api_server=_api_server) result, usage = await loop.run_in_executor( @@ -758,9 +716,7 @@ def _release_run_owner_if_forgotten(self, run_id: str) -> None: run_id in table for table in ( self._run_statuses, self._active_run_agents, self._active_run_tasks, - self._run_streams, self._run_approval_sessions, - ) - ): + self._run_streams, self._run_approval_sessions)): return self._run_owners.pop(run_id, None) @@ -804,8 +760,7 @@ def _load_owned_run(self, request: "web.Request", *, _api_server, permission: Op 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 - ) + self, request, _api_server=_api_server, permission="status", active_fallback=True) return err or web.json_response(status) @@ -872,8 +827,7 @@ async def _handle_run_approval(self, request: "web.Request", *, _api_server) -> _coerce_request_bool = _api_server._coerce_request_bool _openai_error = _api_server._openai_error run_id, _, _, _, err = _load_owned_run( - self, request, _api_server=_api_server, permission="approve", active_fallback=False - ) + self, request, _api_server=_api_server, permission="approve", active_fallback=False) if err is not None: return err @@ -891,8 +845,7 @@ async def _handle_run_approval(self, request: "web.Request", *, _api_server) -> allowed = {"once", "deny"} if room_scoped else {"once", "session", "always", "deny"} resolve_all = ( _coerce_request_bool(body.get("all"), default=False) - or _coerce_request_bool(body.get("resolve_all"), default=False) - ) + or _coerce_request_bool(body.get("resolve_all"), default=False)) approval_session_key = self._run_approval_sessions.get(run_id) for failed, message, code, status in ( (raw_request_id is not None and (not request_id or len(request_id) > 256), @@ -905,16 +858,14 @@ async def _handle_run_approval(self, request: "web.Request", *, _api_server) -> (room_scoped and not request_id, "Room approvals require the exact request_id.", "approval_request_required", 400), (not approval_session_key, - f"Run has no active approval session: {run_id}", "approval_not_active", 409), - ): + f"Run has no active approval session: {run_id}", "approval_not_active", 409)): if failed: return _json_error(_openai_error, message, code=code, status=status) try: from tools.approval import resolve_gateway_approval resolved = resolve_gateway_approval( - approval_session_key, choice, resolve_all=resolve_all, request_id=request_id or None - ) + approval_session_key, choice, resolve_all=resolve_all, request_id=request_id or None) except Exception as exc: logger.exception("[api_server] approval resolution failed for run %s", run_id) return _json_error(_openai_error, str(exc), status=500) @@ -929,18 +880,15 @@ async def _handle_run_approval(self, request: "web.Request", *, _api_server) -> return web.json_response({ "object": "hermes.run.approval_response", "run_id": run_id, - "choice": choice, - **request_id_field, - "resolved": resolved, - }) + "choice": choice, **request_id_field, + "resolved": resolved}) 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( - self, request, _api_server=_api_server, permission=None, active_fallback=False - ) + self, request, _api_server=_api_server, permission=None, active_fallback=False) if err is not None: return err # Only genuinely running runs are steerable. /stop retains agent/task refs @@ -949,8 +897,7 @@ async def _handle_steer_run(self, request: "web.Request", *, _api_server) -> "we if status.get("status") != "running" or not hasattr(agent, "steer"): return _json_error( _openai_error, f"Run is not currently accepting steer input: {run_id}", - code="run_not_accepting_steer", status=409, - ) + code="run_not_accepting_steer", status=409) body, err = await self._read_json_body(request) if err: @@ -960,8 +907,7 @@ async def _handle_steer_run(self, request: "web.Request", *, _api_server) -> "we if not steer_text: return _json_error( _openai_error, "Missing non-empty steer text; expected 'input', 'message', or 'text'.", - code="invalid_steer_input", status=400, - ) + code="invalid_steer_input", status=400) try: accepted = bool(agent.steer(steer_text)) @@ -980,8 +926,7 @@ async def _handle_stop_run(self, request: "web.Request", *, _api_server) -> "web """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( - self, request, _api_server=_api_server, permission="stop", active_fallback=True - ) + self, request, _api_server=_api_server, permission="stop", active_fallback=True) if err is not None: return err if status.get("status") in TERMINAL_STATUSES: @@ -990,8 +935,7 @@ async def _handle_stop_run(self, request: "web.Request", *, _api_server) -> "web if agent is None and task is None: return _json_error( _openai_error, f"Run is not active in this gateway process: {run_id}", - code="run_not_active", status=409, - ) + code="run_not_active", status=409) self._set_run_status(run_id, "stopping", last_event="run.stopping") self._stopping_run_ids.add(run_id) @@ -1021,8 +965,7 @@ def _sweep_orphaned_runs_once(self, now: Optional[float] = None) -> None: stale = [ run_id for run_id, created_at in list(self._run_streams_created.items()) - if now - created_at > self._RUN_STREAM_TTL and run_id not in self._run_stream_subscribers - ] + if now - created_at > self._RUN_STREAM_TTL and run_id not in self._run_stream_subscribers] for run_id in stale: logger.debug("[api_server] sweeping expired run transport %s", run_id) task = self._active_run_tasks.get(run_id) @@ -1037,7 +980,6 @@ def _sweep_orphaned_runs_once(self, now: Optional[float] = None) -> None: run_id for run_id, status in list(self._run_statuses.items()) if status.get("status") in {"completed", "failed", "cancelled"} - and now - float(status.get("updated_at", 0) or 0) > self._RUN_STATUS_TTL - ] + and now - float(status.get("updated_at", 0) or 0) > self._RUN_STATUS_TTL] for run_id in stale_statuses: _forget_run(self, run_id, self._run_statuses, self._run_idempotency_ids) From 0da4cbc25bc9ab70efa2257fd126431caf535415 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:25:16 -0700 Subject: [PATCH 10/22] =?UTF-8?q?refactor(gateway/platforms):=20runs/room?= =?UTF-8?q?=20modules=20=E2=80=94=20=5FRunLaunch=20carries=20agent=20kwarg?= =?UTF-8?q?s,=20room-identity=20field=20table,=20=5Frecord=20reuse,=20comm?= =?UTF-8?q?ent/docstring=20compaction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gateway/platforms/api_server_room_dispatch.py | 10 +- gateway/platforms/api_server_room_grants.py | 40 ++-- .../platforms/api_server_run_idempotency.py | 33 +--- gateway/platforms/api_server_runs.py | 185 +++++++----------- 4 files changed, 97 insertions(+), 171 deletions(-) diff --git a/gateway/platforms/api_server_room_dispatch.py b/gateway/platforms/api_server_room_dispatch.py index 89d7eea05b..b7270346ff 100644 --- a/gateway/platforms/api_server_room_dispatch.py +++ b/gateway/platforms/api_server_room_dispatch.py @@ -15,13 +15,9 @@ from gateway.platforms.api_server_room_grants import _json_error async def _ensure_hosted_member_session(self, dispatch: Any) -> str: - """Create or verify the target's canonical hidden group session. - - Reusing the ``Group: `` namespace is intentional: a room that - moves from Desktop-assisted to hosted execution keeps one transcript. - A conflicting title with a different session id fails closed instead - of merging unrelated conversations. - """ + """Create or verify the target's canonical hidden group session. The ``Group: `` + namespace is reused on purpose (Desktop-assisted -> hosted keeps one transcript); a + conflicting title under another session id fails closed rather than merging.""" db = await self._ensure_session_db_async() if db is None: raise RuntimeError("session database unavailable") diff --git a/gateway/platforms/api_server_room_grants.py b/gateway/platforms/api_server_room_grants.py index 304961054e..7b12c45132 100644 --- a/gateway/platforms/api_server_room_grants.py +++ b/gateway/platforms/api_server_room_grants.py @@ -15,7 +15,7 @@ class RoomGrantReauthorizationRequired(ValueError): def _json_error(_openai_error, message: str, *, status: int, **error_kwargs) -> "web.Response": - """``web.json_response(_openai_error(message, **kw), status=...)`` with the injected envelope builder.""" + """JSON error response built with the injected ``_openai_error`` envelope builder.""" return web.json_response(_openai_error(message, **error_kwargs), status=status) @@ -43,6 +43,15 @@ def _hard_expiry(claims: dict[str, Any]) -> float: return float(claims.get("status_expires_at", claims["expires_at"])) +_ROOM_IDENTITY_FIELDS = ("room_id", "home_install_id", "authority_gateway_id", "authority_epoch", "member_id") + + +def _room_identity(source: dict[str, Any], *, coerce: bool = False) -> dict[str, Any]: + """Room-authority kwargs for ``issue_room_grant``; *coerce* applies ``str``/``int`` to raw body values.""" + text = str if coerce else (lambda v: v) + return {k: int(source[k]) if k == "authority_epoch" else text(source[k]) for k in _ROOM_IDENTITY_FIELDS} + + def _local_target(claims: dict[str, Any] | None, _api_request_profile) -> tuple[str, str]: """Return ``(profile, installation_id)`` for this gateway; *claims* must target it.""" from gateway import hosted_rooms @@ -122,7 +131,7 @@ async def _handle_room_member_invitation( body, error = await self._read_json_body(request) if error: return error - required = {"room_id", "home_install_id", "authority_gateway_id", "authority_epoch", "member_id"} + required = set(_ROOM_IDENTITY_FIELDS) allowed = required | {"grant_id", "ttl_seconds", "status_ttl_seconds"} if set(body) - allowed or not required <= set(body): return _json_error( @@ -143,9 +152,7 @@ async def _handle_room_member_invitation( token = issue_room_grant( self._room_grant_secret(), grant_id=str(body.get("grant_id") or f"grant-{uuid.uuid4().hex}"), - room_id=str(body["room_id"]), home_install_id=str(body["home_install_id"]), - authority_gateway_id=str(body["authority_gateway_id"]), - authority_epoch=int(body["authority_epoch"]), member_id=str(body["member_id"]), + **_room_identity(body, coerce=True), target_install_id=target_install_id, target_profile=profile, execution_policy_digest=execution_policy["policy_digest"], issued_at=time.time(), ttl_seconds=ttl, status_ttl_seconds=status_ttl) @@ -174,14 +181,8 @@ async def _handle_room_member_capabilities( except Exception as exc: return _room_grant_error_response(exc, _openai_error=_openai_error) return web.json_response({ - "object": "hermes.room_member.capabilities", - "room_id": claims["room_id"], - "home_install_id": claims["home_install_id"], - "authority_gateway_id": claims["authority_gateway_id"], - "authority_epoch": claims["authority_epoch"], - "member_id": claims["member_id"], - "target_profile": profile, - "catalog": catalog}) + "object": "hermes.room_member.capabilities", **{k: claims[k] for k in _ROOM_IDENTITY_FIELDS}, + "target_profile": profile, "catalog": catalog}) async def _handle_room_member_grant_refresh( @@ -198,8 +199,7 @@ async def _handle_room_member_grant_refresh( from gateway.hosted_room_peer import MAX_DISPATCH_GRANT_TTL_SECONDS, issue_room_grant from gateway.hosted_room_execution_policy import execution_policy_mapping - # A status-only bearer may observe a run but must never mint new - # dispatch authority: renewal requires a still-live dispatch permission. + # A status-only bearer must never mint dispatch authority: renewal needs live "dispatch". claims = self._room_grant_claims(request, permission="dispatch") profile, installation_id = _local_target(claims, _api_request_profile) now = time.time() @@ -214,10 +214,7 @@ async def _handle_room_member_grant_refresh( _require_unchanged_execution_policy(claims, execution_policy) token = issue_room_grant( self._room_grant_secret(), grant_id=f"grant-refresh-{uuid.uuid4().hex}", - room_id=claims["room_id"], home_install_id=claims["home_install_id"], - authority_gateway_id=claims["authority_gateway_id"], - authority_epoch=int(claims["authority_epoch"]), member_id=claims["member_id"], - target_install_id=installation_id, target_profile=profile, + **_room_identity(claims), target_install_id=installation_id, target_profile=profile, execution_policy_digest=execution_policy["policy_digest"], permissions=claims["permissions"], issued_at=now, ttl_seconds=dispatch_ttl, status_expires_at=hard_expiry) @@ -244,9 +241,8 @@ async def _handle_room_member_grant_revoke( try: from gateway import hosted_rooms - # Revoke is idempotent: a response-lost retry may authenticate with the - # grant just denylisted, so verify signature/scope/horizon directly - # (not via _room_grant_claims) and upsert the same grant id. + # Idempotent: a response-lost retry authenticates with the grant just denylisted, so + # verify signature/scope/horizon directly (not _room_grant_claims) and upsert the id. claims = _decode_request_grant(self, request, permission="status") _local_target(claims, _api_request_profile) hosted_rooms.revoke_room_grant_scope( diff --git a/gateway/platforms/api_server_run_idempotency.py b/gateway/platforms/api_server_run_idempotency.py index fe437fdf0e..f41a1e439f 100644 --- a/gateway/platforms/api_server_run_idempotency.py +++ b/gateway/platforms/api_server_run_idempotency.py @@ -39,11 +39,8 @@ def _encode_status(status: Dict[str, Any]) -> str: def _record(run_id, status_json, owner_pid, owner_started, updated_at) -> dict[str, Any]: return { - "run_id": run_id, - "status": json.loads(status_json), - "owner_pid": int(owner_pid or 0), - "owner_started": int(owner_started or 0), - "updated_at": float(updated_at or 0)} + "run_id": run_id, "status": json.loads(status_json), "owner_pid": int(owner_pid or 0), + "owner_started": int(owner_started or 0), "updated_at": float(updated_at or 0)} def _outcome(row, fingerprint): @@ -52,13 +49,9 @@ def _outcome(row, fingerprint): class RunIdempotencyStore: - """Durable, tenant-scoped reservations for ``POST /v1/runs``. - - A unique ``(scope, key)`` row is inserted inside ``BEGIN IMMEDIATE`` so - separate gateway workers/processes cannot both admit the same request. - Only request fingerprints and public run status are stored; request bodies - and credentials are deliberately excluded. - """ + """Durable, tenant-scoped reservations for ``POST /v1/runs``: a unique ``(scope, key)`` row + inserted inside ``BEGIN IMMEDIATE`` so separate workers cannot both admit one request. Only + fingerprints and public run status are stored — never request bodies or credentials.""" RETENTION_SECONDS = 24 * 60 * 60 ACKNOWLEDGED_RETENTION_SECONDS = 24 * 60 * 60 @@ -159,9 +152,7 @@ class RunIdempotencyStore: scope, key, fingerprint, run_id, encoded, int(owner_pid or 0), int(owner_started or 0), retention_until, now, now)) self._conn.commit() - return "created", { - "run_id": run_id, "status": status, "owner_pid": int(owner_pid or 0), - "owner_started": int(owner_started or 0), "updated_at": now} + return "created", _record(run_id, encoded, owner_pid, owner_started, now) | {"status": status} def lookup(self, scope: str, key: str, fingerprint: str, *, retention_until: float = 0): """Return ``missing``, ``reused`` or ``conflict`` without reserving.""" @@ -176,12 +167,8 @@ class RunIdempotencyStore: return ("missing", None) if row is None else _outcome(row, fingerprint) def _prune_stale_terminal_locked(self, now: float) -> None: - """Prune replay records only after their stored run is terminal. - - The caller owns ``self._lock`` and an active transaction. Age alone - can never release an in-flight idempotency reservation: a long or - disconnected room turn may legitimately outlive the retention window. - """ + """Prune aged replay records only once their stored run is terminal (caller holds the + lock + transaction): a long or disconnected room turn may outlive the retention window.""" stale = self._conn.execute( """SELECT scope, idempotency_key, status_json FROM run_idempotency @@ -212,9 +199,7 @@ class RunIdempotencyStore: "FROM run_idempotency WHERE scope=? AND run_id=?", (scope, run_id)).fetchone() if row is None: return None - return { - "status": json.loads(row[0]), "owner_pid": int(row[1] or 0), - "owner_started": int(row[2] or 0), "updated_at": float(row[3] or 0)} + return {k: v for k, v in _record(None, *row).items() if k != "run_id"} def extend_retention(self, scope: str, run_id: str, until: float) -> bool: """Persist the latest verified recovery horizon for an active grant.""" diff --git a/gateway/platforms/api_server_runs.py b/gateway/platforms/api_server_runs.py index 0a1348fe9d..f79514256f 100644 --- a/gateway/platforms/api_server_runs.py +++ b/gateway/platforms/api_server_runs.py @@ -82,12 +82,10 @@ def _initialize_run_state(self, *, store_factory) -> None: self._run_owner_started = int(get_process_start_time(self._run_owner_pid) or 0) except Exception: self._run_owner_started = 0 - # All keyed by run_id. _run_streams: SSE event queues (+ creation time for the TTL - # sweep); _run_stream_subscribers: runs with a connected, draining consumer; - # _active_run_agents/_active_run_tasks: live refs for cooperative stop (the executor - # thread may outlive the HTTP request, hence the separate _stopping_run_ids set); - # _run_statuses: pollable status for dashboards; _run_approval_sessions: approval - # session key (the approval core resolves by session key, API clients by run_id). + # All keyed by run_id: SSE queues (+creation time for the TTL sweep), connected + # subscribers, live agent/task refs for cooperative stop (the executor thread may + # outlive the request, hence the separate stopping set), pollable statuses, and + # approval session keys (approval core resolves by session key, clients by run_id). self._run_idempotency_ids: set[str] = set() self._run_stream_subscribers: set[str] = set() self._stopping_run_ids: set[str] = set() @@ -162,9 +160,8 @@ def _make_run_event_callback(self, run_id: str, loop: "asyncio.AbstractEventLoop loop.call_soon_threadsafe(q.put_nowait, event) def _callback(event_type: str, tool_name: str = None, preview: str = None, args=None, **kwargs): - # _thinking, subagent.tool, and subagent_progress are deliberately not - # forwarded (high-volume UI noise); lifecycle boundaries must land so - # clients can observe delegate_task timeouts and failures. + # _thinking / subagent.tool / subagent_progress are deliberately dropped (UI noise); + # lifecycle boundaries must land so clients can observe delegate_task failures. fields = _FIXED_EVENT_FIELDS.get(event_type) if fields is not None: _push(_run_event(run_id, event_type, **fields(tool_name, preview, kwargs))) @@ -176,8 +173,7 @@ def _make_run_event_callback(self, run_id: str, loop: "asyncio.AbstractEventLoop value = kwargs.get(key) if value is None: continue - # Free text can carry child terminal/tool output: force the same - # secret redaction the API applies to error text on this public stream. + # Free text may carry child tool output: force secret redaction on this public stream. if key in _SUBAGENT_TEXT_KEYS and isinstance(value, str): value = redact_sensitive_text(value, force=True) event[key] = value @@ -265,11 +261,8 @@ 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]": - """Return ``(history, instructions, stored_session_id, error)``. - - Precedence: explicit ``conversation_history`` > ``previous_response_id`` - chain > all-but-last messages of a multi-message ``input`` array. - """ + """Return ``(history, instructions, stored_session_id, error)``; precedence: + ``conversation_history`` > ``previous_response_id`` chain > all-but-last ``input`` messages.""" instructions = body.get("instructions") previous_response_id = body.get("previous_response_id") conversation_history: List[Dict[str, str]] = [] @@ -331,11 +324,8 @@ def _replay_or_conflict(self, request, outcome, record, gateway_session_key, _op @dataclass(slots=True) class _RunLaunch: - """Everything an admitted run needs once the HTTP request has returned. - - The background task outlives the request (and thus the middleware profile - scope), so contextvar values are captured here and re-entered later. - """ + """State for an admitted run's background task; contextvars are captured here + because the task outlives the request (and its middleware profile scope).""" owner: Any run_id: str @@ -343,18 +333,19 @@ class _RunLaunch: session_id: str gateway_session_key: Optional[str] declared_selected: bool - approval_session_key: str user_message: str conversation_history: List[Dict[str, str]] - ephemeral_system_prompt: Any - agent_overrides: dict - route: Any - room_dispatch: Optional[dict] - room_execution_policy: Optional[dict] + agent_kwargs: dict # ``_create_agent`` keyword arguments (prompt, model overrides, route, room policy) request_profile: Any browser_control_principal: Any browser_control_transport_family: Any + @property + def approval_session_key(self) -> str: + # Approval queues are isolated per run: session ids are conversation scopes, not + # authorization namespaces, so resolving one run's approval must not unblock another's. + return self.run_id + def put_event(self, event: Optional[Dict]) -> None: """Enqueue only while this run still owns live transport state.""" if self.owner._run_streams.get(self.run_id) is self.queue: @@ -454,9 +445,8 @@ async def _handle_runs(self, request: "web.Request", *, _api_server) -> "web.Res if selection_error: return _json_error(_openai_error, selection_error, status=400) - # A lost-acceptance replay must resolve even while the original run - # consumes the final concurrency slot. This read does not reserve a - # missing key; the atomic reserve below closes the concurrent-miss race. + # A lost-acceptance replay must resolve even while the original run holds the last + # concurrency slot; this read reserves nothing (the atomic reserve below closes the race). if idempotency_key: outcome, record = self._run_idempotency_store.lookup( idempotency_scope, idempotency_key, idempotency_fingerprint, @@ -474,22 +464,16 @@ async def _handle_runs(self, request: "web.Request", *, _api_server) -> "web.Res run_id = f"run_{uuid.uuid4().hex}" self._run_owners[run_id] = self._run_idempotency_scope(request) - # Same precedence as /v1/responses: explicit body session_id wins, then the - # response chain, then the conversation declared via X-Hermes-Session-Key. - # Falling straight through to run_id would make the run id the conversation - # identity and re-key every affinity surface once per run. An explicit or - # chained session owns its routing key and must not be rebound to the header. + # Same precedence as /v1/responses: body session_id > response chain > X-Hermes-Session-Key + # conversation > run_id (which would otherwise re-key every affinity surface per run). + # An explicit or chained session owns its routing key and is never rebound to the header. _declared_selected = not session_id and bool(gateway_session_key) session_id = session_id or self._declared_conversation_session(gateway_session_key) or run_id - # Approval queues gate host-side tool execution and must be isolated per - # run: session ids / memory keys are conversation scopes, not authorization - # namespaces, and resolving one run's approval must not unblock another's. - approval_session_key = run_id q: "asyncio.Queue[Optional[Dict]]" = asyncio.Queue() created_at = time.time() self._run_streams[run_id] = q self._run_streams_created[run_id] = created_at - self._run_approval_sessions[run_id] = approval_session_key + self._run_approval_sessions[run_id] = run_id # approval session key (see _RunLaunch) initial_status = self._set_run_status( run_id, "queued", created_at=created_at, session_id=session_id, model=body.get("model", self._model_name) ) @@ -506,31 +490,28 @@ async def _handle_runs(self, request: "web.Request", *, _api_server) -> "web.Res self._run_idempotency_ids.add(run_id) launch = _RunLaunch( - self, run_id=run_id, queue=q, session_id=session_id, - gateway_session_key=gateway_session_key, declared_selected=_declared_selected, - approval_session_key=approval_session_key, user_message=user_message, - conversation_history=conversation_history, ephemeral_system_prompt=instructions, - agent_overrides=agent_overrides, route=route, room_dispatch=room_dispatch, - room_execution_policy=room_execution_policy, + self, run_id, q, session_id, gateway_session_key, _declared_selected, user_message, + conversation_history, + agent_kwargs=dict( + ephemeral_system_prompt=instructions, session_id=session_id, + gateway_session_key=gateway_session_key, + requested_model=agent_overrides.get("requested_model"), + requested_provider=agent_overrides.get("requested_provider"), + model_options=agent_overrides.get("model_options"), route=route, + room_dispatch=room_dispatch, room_execution_policy=room_execution_policy), request_profile=_api_server._api_request_profile.get(), browser_control_principal=_api_server._api_request_browser_control_principal.get(), - browser_control_transport_family=_api_server._api_request_browser_control_transport_family.get(), - ) - _start_run_task(self, launch, _api_server=_api_server) - return _accepted_response(run_id, "started", gateway_session_key, replayed=False) - - -def _start_run_task(self, launch: _RunLaunch, *, _api_server) -> None: - """Admit the run and schedule its background task, tracked for shutdown drain.""" + browser_control_transport_family=_api_server._api_request_browser_control_transport_family.get()) self._activate_admitted_request() task = asyncio.create_task(_execute_run(self, launch, _api_server=_api_server)) - self._active_run_tasks[launch.run_id] = task + self._active_run_tasks[run_id] = task try: - self._background_tasks.add(task) + self._background_tasks.add(task) # tracked for shutdown drain except TypeError: pass if hasattr(task, "add_done_callback"): task.add_done_callback(self._background_tasks.discard) + return _accepted_response(run_id, "started", gateway_session_key, replayed=False) def _run_agent_sync(self, run: _RunLaunch, agent, approval_notify, *, _api_server): @@ -548,36 +529,30 @@ def _run_agent_sync(self, run: _RunLaunch, agent, approval_notify, *, _api_serve resets: list[tuple[Any, Callable]] = [] with self._profile_scope(run.request_profile): try: - # Bind approval/session identity via contextvars so concurrent - # runs do not share process environment state. + # Contextvars, not process env: concurrent runs must not share identity. resets.append((set_current_session_key(run.approval_session_key), reset_current_session_key)) - # chat_id carries the raw session id exactly like the other - # agent-entry routes bind it via _run_agent(); without it - # tools.async_delegation sees an empty HERMES_SESSION_CHAT_ID and - # background delegations stay forced-sync (no wake target). + # chat_id carries the raw session id like _run_agent() does; without it + # tools.async_delegation sees no HERMES_SESSION_CHAT_ID and forces delegations sync. session_tokens = self._bind_api_server_session( chat_id=session_id or "", session_key=run.approval_session_key, session_id=session_id or "", browser_control_principal=run.browser_control_principal, browser_control_transport_family=run.browser_control_transport_family) if session_tokens: resets.append((session_tokens, clear_session_vars)) - if run.room_dispatch is not None: - policy = RoomExecutionPolicy.from_mapping(run.room_execution_policy or {}) + if run.agent_kwargs["room_dispatch"] is not None: + policy = RoomExecutionPolicy.from_mapping(run.agent_kwargs["room_execution_policy"] or {}) resets.append((bind_room_execution_policy(policy), reset_room_execution_policy)) register_gateway_notify(run.approval_session_key, approval_notify) - # /v1/runs owns its agent lifecycle (no TurnRunner/_run_agent): - # record turn process ownership so stop/cancel reaps only the - # background processes this run created. + # /v1/runs owns its agent lifecycle (no TurnRunner): record process ownership + # so stop/cancel reaps only the background processes this run created. _api_server._publish_turn_process_ownership(agent, effective_task_id) r = agent.run_conversation( user_message=run.user_message, conversation_history=run.conversation_history, task_id=effective_task_id) finally: - # Clear ownership immediately so a later stop/cancel can't reap - # background work this run deliberately left running. + # Clear ownership now so a later stop can't reap work this run left running. _api_server._clear_turn_process_ownership(agent) - # Record the declared conversation ourselves (not via _run_agent's - # bind_declared_conversation), with the same precedence gate. + # Declared-conversation binding, same precedence gate as _run_agent. if run.declared_selected: self._bind_declared_conversation( getattr(agent, "session_id", None) or session_id, run.gateway_session_key) @@ -599,8 +574,7 @@ def _make_approval_notify(self, run: _RunLaunch, *, _api_server) -> Callable[[Di def _approval_notify(approval_data: Dict[str, Any]) -> None: event = dict(approval_data or {}) - # Redact credentials before the command enters the SSE/API stream; - # API/desktop clients must never receive the raw flagged command. + # Clients must never receive the raw flagged command: redact before it hits the stream. if "command" in event: from gateway.run import _redact_approval_command @@ -621,8 +595,7 @@ def _make_approval_notify(self, run: _RunLaunch, *, _api_server) -> Callable[[Di async def _execute_run(self, run: _RunLaunch, *, _api_server) -> None: - """Background task for one admitted run: drives the agent, then publishes - the terminal event/status and releases live state.""" + """Drive one admitted run, publish its terminal event/status, release live state.""" _redact_api_error_text = _api_server._redact_api_error_text run_id, loop = run.run_id, asyncio.get_running_loop() @@ -633,10 +606,7 @@ async def _execute_run(self, run: _RunLaunch, *, _api_server) -> None: loop.call_soon_threadsafe(run.put_event, _run_event(run_id, "message.delta", delta=delta)) def _finish(status: str, extra: Optional[dict] = None, **fields: Any) -> None: - """Publish a terminal status, then the matching ``run.`` event (best effort). - - Field order is part of the pollable status shape: *fields*, ``last_event``, then *extra*. - """ + """Terminal status, then best-effort ``run.`` event; key order is wire shape.""" extra = extra or {} self._set_run_status(run_id, status, **fields, last_event=f"run.{status}", **extra) with suppress(Exception): @@ -649,13 +619,8 @@ async def _execute_run(self, run: _RunLaunch, *, _api_server) -> None: return with self._profile_scope(run.request_profile): agent = self._create_agent( - ephemeral_system_prompt=run.ephemeral_system_prompt, session_id=run.session_id, stream_delta_callback=_text_cb, tool_progress_callback=self._make_run_event_callback(run_id, loop), - gateway_session_key=run.gateway_session_key, - requested_model=run.agent_overrides.get("requested_model"), - requested_provider=run.agent_overrides.get("requested_provider"), - model_options=run.agent_overrides.get("model_options"), route=run.route, - room_dispatch=run.room_dispatch, room_execution_policy=run.room_execution_policy) + **run.agent_kwargs) self._active_run_agents[run_id] = agent approval_notify = _make_approval_notify(self, run, _api_server=_api_server) result, usage = await loop.run_in_executor( @@ -666,12 +631,10 @@ async def _execute_run(self, run: _RunLaunch, *, _api_server) -> None: if run_id in self._stopping_run_ids and result.get("interrupted") is True: _finish("cancelled") elif result.get("failed"): - # Non-retryable client errors (401/400) return failed=True instead - # of raising, so the except branches below never fire for them. + # Non-retryable client errors (401/400) return failed=True rather than raising. _finish("failed", error=_redact_api_error_text(result.get("error") or "agent run failed")) else: - # Undelivered steer text (accepted after the final response) rides on - # the terminal event/status so the client can replay it as the next turn. + # Undelivered steer text rides on the terminal event/status for client replay. pending_steer = result.get("pending_steer") extra = {"pending_steer": pending_steer} if pending_steer else {} _finish("completed", extra, output=result.get("final_response", ""), usage=usage) @@ -679,17 +642,15 @@ async def _execute_run(self, run: _RunLaunch, *, _api_server) -> None: _finish("cancelled") raise except _api_server._ProviderAuthResolutionError as exc: - # /v1/runs bypasses _run_agent(), so it needs its own branch to surface - # the same controlled provider-auth message the other endpoints give. + # Same controlled provider-auth message the _run_agent() endpoints give. logger.warning("Provider authentication failed for run=%s: %s", run_id, exc) _finish("failed", error=f"⚠️ Provider authentication failed: {exc}") except Exception as exc: logger.exception("[api_server] run %s failed", run_id) _finish("failed", error=_redact_api_error_text(exc)) finally: - # If the asyncio wrapper is cancelled (e.g. via /stop) the executor - # thread may still block on an approval Event; unregistering here - # releases it. Harmlessly idempotent on normal completion. + # On cancellation (/stop) the executor thread may still block on an approval + # Event; unregistering releases it. Idempotent on normal completion. _unregister_approval_notify(run.approval_session_key) with suppress(Exception): run.put_event(None) # sentinel: close the SSE stream @@ -706,12 +667,8 @@ def _unregister_approval_notify(approval_session_key: Optional[str]) -> None: def _release_run_owner_if_forgotten(self, run_id: str) -> None: - """Drop the owner stamp only once nothing keyed by *run_id* survives. - - Ownership must outlive every surface it protects (statuses, live - agent/task refs, SSE transport, approval sessions), which are retired on - different clocks; ``_request_owns_run`` treats ownerless state as fail-closed. - """ + """Drop the owner stamp only once nothing keyed by *run_id* survives: ownership must + outlive every surface it protects (retired on different clocks); ownerless = fail-closed.""" if any( run_id in table for table in ( @@ -726,20 +683,15 @@ def _request_owns_run(self, request: "web.Request", run_id: str) -> bool: owner = self._run_owners.get(run_id) if owner is not None: return owner == scope - # No in-memory owner: only a durable record under the caller's own scope - # admits it. Ownerless run state is an unanswered authorization question; - # under multiplex_profiles every served profile holds a valid key, so - # admitting it would make the boundary allow-all. + # No in-memory owner: only a durable record under the caller's scope admits it. + # Under multiplex_profiles every profile holds a valid key, so ownerless = allow-all. return self._run_idempotency_store.owns_run(scope, run_id) def _load_owned_run(self, request: "web.Request", *, _api_server, permission: Optional[str], active_fallback: bool): - """Authenticate and resolve ``(run_id, status, agent, task, error)`` for a control endpoint. - - *permission* selects room-grant auth (``None`` = plain API-key auth). With - *active_fallback*, an in-process run registered before pollable status - existed is reported as ``running`` instead of 404. - """ + """Authenticate (*permission* -> room-grant aware; ``None`` -> API key only) and resolve + ``(run_id, status, agent, task, error)``; *active_fallback* reports a live in-process run + without pollable status as ``running`` instead of 404.""" auth_err = self._check_run_auth(request, permission=permission) if permission else self._check_auth(request) if auth_err: return None, None, None, None, auth_err @@ -891,9 +843,8 @@ async def _handle_steer_run(self, request: "web.Request", *, _api_server) -> "we self, request, _api_server=_api_server, permission=None, active_fallback=False) if err is not None: return err - # Only genuinely running runs are steerable. /stop retains agent/task refs - # during cooperative shutdown, so the status gate (not the mere presence - # of an agent ref) is what rejects stop-then-steer. + # /stop keeps agent refs during cooperative shutdown, so the status gate (not the + # agent ref) is what rejects stop-then-steer. if status.get("status") != "running" or not hasattr(agent, "steer"): return _json_error( _openai_error, f"Run is not currently accepting steer input: {run_id}", @@ -943,9 +894,8 @@ async def _handle_stop_run(self, request: "web.Request", *, _api_server) -> "web if agent is not None: with suppress(Exception): _api_server.request_hard_interrupt(agent, "Stop requested via API") - # The stopped run is abandoned — reap only the background processes it - # created. Epoch-gated inside, so a concurrent run sharing the same - # session_id keeps its own processes; no-op if the run already finished. + # Reap only this run's background processes (epoch-gated inside, so a concurrent + # run on the same session_id keeps its own); no-op if the run already finished. _api_server._reap_disconnected_agent_processes(agent, source="api_server_run_stop") return web.json_response({"run_id": run_id, "status": "stopping"}) @@ -969,8 +919,7 @@ def _sweep_orphaned_runs_once(self, now: Optional[float] = None) -> None: for run_id in stale: logger.debug("[api_server] sweeping expired run transport %s", run_id) task = self._active_run_tasks.get(run_id) - # The transport TTL always bounds buffering. Live control state is - # independent and survives until the executor-backed task returns. + # Transport TTL bounds buffering; live control state survives until the task returns. _drop_run_transport(self, run_id) if task is None or task.done(): _unregister_approval_notify(self._run_approval_sessions.get(run_id)) From eee5f2a8a270e346d6b663203ec6da8848660a65 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:29:07 -0700 Subject: [PATCH 11/22] refactor(gateway/platforms): qqbot quoted-context + _on_message compaction --- gateway/platforms/qqbot/adapter.py | 38 +++++++++--------------------- 1 file changed, 11 insertions(+), 27 deletions(-) diff --git a/gateway/platforms/qqbot/adapter.py b/gateway/platforms/qqbot/adapter.py index 5e114a2600..69f0c20d3b 100644 --- a/gateway/platforms/qqbot/adapter.py +++ b/gateway/platforms/qqbot/adapter.py @@ -598,14 +598,11 @@ class QQAdapter(BasePlatformAdapter): if not msg_id or self._is_duplicate(msg_id): logger.debug("[%s] Duplicate or missing message id: %s", self._log_tag, msg_id) return - - timestamp = str(d.get("timestamp", "")) - content = str(d.get("content", "")).strip() - author = d.get("author") if isinstance(d.get("author"), dict) else {} - handler = self._INBOUND_HANDLERS.get(event_type) if handler: - await getattr(self, handler)(d, msg_id, content, author, timestamp) + author = d.get("author") if isinstance(d.get("author"), dict) else {} + await getattr(self, handler)( + d, msg_id, str(d.get("content", "")).strip(), author, str(d.get("timestamp", ""))) # ── Inline-keyboard interactions (INTERACTION_CREATE) ── @@ -869,37 +866,24 @@ class QQAdapter(BasePlatformAdapter): quote_block is "" when nothing is quoted.""" empty = {"quote_block": "", "image_urls": [], "image_media_types": []} try: - if int(d.get("message_type", 0) or 0) != 103: - return empty + is_quote = int(d.get("message_type", 0) or 0) == 103 except (TypeError, ValueError): - return empty - + is_quote = False elements = d.get("msg_elements") - if not isinstance(elements, list) or not elements: + if not is_quote or not isinstance(elements, list) or not elements: return empty - quoted_text_parts: List[str] = [] - all_attachments: List[Dict[str, Any]] = [] - for elem in elements: - if not isinstance(elem, dict): - continue - etext = str(elem.get("content", "")).strip() - if etext: - quoted_text_parts.append(etext) - eatts = elem.get("attachments") - if isinstance(eatts, list): - all_attachments.extend(a for a in eatts if isinstance(a, dict)) - + elements = [e for e in elements if isinstance(e, dict)] + quoted_text_parts = [t for t in (str(e.get("content", "")).strip() for e in elements) if t] + all_attachments = [ + a for e in elements if isinstance(e.get("attachments"), list) for a in e["attachments"] if isinstance(a, dict)] att_result = await self._process_attachments(all_attachments) quoted_images = att_result.get("image_urls") or [] - lines: List[str] = [] - if quoted_text_parts: - lines.append(" ".join(quoted_text_parts)) + lines: List[str] = [" ".join(quoted_text_parts)] if quoted_text_parts else [] lines.extend(att_result.get("voice_transcripts") or []) if att_result.get("attachment_info"): lines.append(att_result["attachment_info"]) - if not lines and not quoted_images: return empty # Images-only quote still gets a marker so the LLM knows context was referenced. From e42ced1314b2af087f5372748dc0354998c0d975 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:33:07 -0700 Subject: [PATCH 12/22] =?UTF-8?q?refactor(gateway/platforms):=20runs=20?= =?UTF-8?q?=E2=80=94=20usage/owner-alive/idempotency-key=20collapse,=20inl?= =?UTF-8?q?ine=20approval=20envelope=20via=20=5Frun=5Fevent,=20body=20blan?= =?UTF-8?q?k-line=20squeeze?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gateway/platforms/api_server_room_dispatch.py | 1 - gateway/platforms/api_server_room_grants.py | 8 -- .../platforms/api_server_run_idempotency.py | 2 - gateway/platforms/api_server_runs.py | 134 +++++------------- 4 files changed, 33 insertions(+), 112 deletions(-) diff --git a/gateway/platforms/api_server_room_dispatch.py b/gateway/platforms/api_server_room_dispatch.py index b7270346ff..38f56a8da8 100644 --- a/gateway/platforms/api_server_room_dispatch.py +++ b/gateway/platforms/api_server_room_dispatch.py @@ -76,7 +76,6 @@ async def _normalize_room_dispatch( from gateway.hosted_room_peer import GatewayRoomCatalog, HostedMemberDispatch, verify_room_grant from gateway.hosted_room_execution_policy import RoomExecutionPolicy from gateway.platforms.api_server_room_grants import _local_room_catalog - dispatch = HostedMemberDispatch.from_mapping(body.get("hosted_room_dispatch")) verify_room_grant(self._room_grant_secret(), room_token, dispatch, permission="dispatch") active_profile = _api_server._api_request_profile.get() or "default" diff --git a/gateway/platforms/api_server_room_grants.py b/gateway/platforms/api_server_room_grants.py index 7b12c45132..7cf55ec459 100644 --- a/gateway/platforms/api_server_room_grants.py +++ b/gateway/platforms/api_server_room_grants.py @@ -55,7 +55,6 @@ def _room_identity(source: dict[str, Any], *, coerce: bool = False) -> dict[str, def _local_target(claims: dict[str, Any] | None, _api_request_profile) -> tuple[str, str]: """Return ``(profile, installation_id)`` for this gateway; *claims* must target it.""" from gateway import hosted_rooms - profile = _api_request_profile.get() or "default" installation_id = hosted_rooms.local_authority_gateway_id() if claims is not None and ( @@ -68,7 +67,6 @@ def _local_room_catalog(self, profile: str, installation_id: str) -> tuple[dict, """Return ``(execution_policy, catalog)`` for this gateway's *profile*.""" from gateway.hosted_room_peer import PROTOCOL_VERSION, catalog_mapping from gateway.hosted_room_execution_policy import execution_policy_mapping - with self._profile_scope(profile): execution_policy = execution_policy_mapping(target_profile=profile) catalog = catalog_mapping( @@ -96,14 +94,12 @@ def _room_grant_token(request: "web.Request") -> str: def _room_grant_secret(self) -> bytes: from gateway.hosted_room_peer import gateway_room_grant_secret - return gateway_room_grant_secret() 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) if not token: raise ValueError("room grant is missing") @@ -113,7 +109,6 @@ def _decode_request_grant(self, request: "web.Request", *, permission: str) -> d 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() if hosted_rooms.room_grant_is_revoked(db_path, claims=claims): raise RoomGrantReauthorizationRequired("room grant is revoked") @@ -140,7 +135,6 @@ async def _handle_room_member_invitation( try: from gateway import hosted_rooms from gateway.hosted_room_peer import decode_room_grant, issue_room_grant - profile, target_install_id = _local_target(None, _api_request_profile) ttl = float(body.get("ttl_seconds", 3600)) if not 60 <= ttl <= 24 * 60 * 60: @@ -198,7 +192,6 @@ async def _handle_room_member_grant_refresh( try: from gateway.hosted_room_peer import MAX_DISPATCH_GRANT_TTL_SECONDS, issue_room_grant from gateway.hosted_room_execution_policy import execution_policy_mapping - # A status-only bearer must never mint dispatch authority: renewal needs live "dispatch". claims = self._room_grant_claims(request, permission="dispatch") profile, installation_id = _local_target(claims, _api_request_profile) @@ -240,7 +233,6 @@ async def _handle_room_member_grant_revoke( code="invalid_room_grant_revoke", status=400) try: from gateway import hosted_rooms - # Idempotent: a response-lost retry authenticates with the grant just denylisted, so # verify signature/scope/horizon directly (not _room_grant_claims) and upsert the id. claims = _decode_request_grant(self, request, permission="status") diff --git a/gateway/platforms/api_server_run_idempotency.py b/gateway/platforms/api_server_run_idempotency.py index f41a1e439f..92271c24f4 100644 --- a/gateway/platforms/api_server_run_idempotency.py +++ b/gateway/platforms/api_server_run_idempotency.py @@ -65,7 +65,6 @@ class RunIdempotencyStore: if db_path is None: try: from hermes_cli.config import get_hermes_home - db_path = str(get_hermes_home() / "runs_idempotency.db") except Exception: db_path = ":memory:" @@ -79,7 +78,6 @@ class RunIdempotencyStore: self._conn = sqlite3.connect(":memory:", check_same_thread=False) self._db_path = None from hermes_state import apply_wal_with_fallback - apply_wal_with_fallback(self._conn, db_label="runs_idempotency.db") self._conn.execute( """CREATE TABLE IF NOT EXISTS run_idempotency ( diff --git a/gateway/platforms/api_server_runs.py b/gateway/platforms/api_server_runs.py index f79514256f..d5050adba2 100644 --- a/gateway/platforms/api_server_runs.py +++ b/gateway/platforms/api_server_runs.py @@ -35,6 +35,10 @@ _SUBAGENT_EVENT_KEYS = ( "reasoning_tokens", "api_calls", "cost_usd", "files_read", "files_written", "output_tail") _SUBAGENT_TEXT_KEYS = ("goal", "summary", "output_tail") +# Terminal usage payload: (wire key, agent attribute), in wire order. +_USAGE_FIELDS = ( + ("input_tokens", "session_prompt_tokens"), ("output_tokens", "session_completion_tokens"), + ("total_tokens", "session_total_tokens")) # Tool-progress event -> SSE payload fields (tool_name, preview, kwargs); key order is wire format. _FIXED_EVENT_FIELDS = { "tool.started": lambda tool, preview, kw: {"tool": tool, "preview": preview}, @@ -78,7 +82,6 @@ def _initialize_run_state(self, *, store_factory) -> None: self._run_owner_pid = os.getpid() try: from gateway.status import get_process_start_time - self._run_owner_started = int(get_process_start_time(self._run_owner_pid) or 0) except Exception: self._run_owner_started = 0 @@ -216,15 +219,10 @@ def _check_run_auth(self, request: "web.Request", *, permission: str, _api_serve def _owner_alive(owner_pid: int, owner_started: int) -> bool: """True when the recorded owner pid still exists and is the same process incarnation.""" - if owner_pid <= 0: - return False try: from gateway.status import _pid_exists, get_process_start_time - - alive = bool(_pid_exists(owner_pid)) - if alive and owner_started: - alive = int(get_process_start_time(owner_pid) or 0) == owner_started - return alive + return owner_pid > 0 and bool(_pid_exists(owner_pid)) and ( + not owner_started or int(get_process_start_time(owner_pid) or 0) == owner_started) except Exception: return False @@ -237,13 +235,11 @@ def _durable_run_status(self, request: "web.Request", run_id: str) -> Dict[str, scope = self._run_idempotency_scope(request) self._run_idempotency_store.extend_retention(scope, run_id, _room_retention_until(request)) return status - scope = self._run_idempotency_scope(request) record = self._run_idempotency_store.status_for_run( scope, run_id, retention_until=_room_retention_until(request)) if record is None: return None - status = dict(record["status"]) if status.get("status") not in TERMINAL_STATUSES and not _owner_alive( int(record.get("owner_pid") or 0), int(record.get("owner_started") or 0)): @@ -251,7 +247,6 @@ def _durable_run_status(self, request: "web.Request", run_id: str) -> Dict[str, status="interrupted", error="The gateway restarted before this run settled.", last_event="run.interrupted", updated_at=time.time()) self._run_idempotency_store.update_status(run_id, status) - self._run_statuses[run_id] = status self._run_idempotency_ids.add(run_id) self._run_owners[run_id] = scope @@ -280,7 +275,6 @@ def _resolve_conversation_history( conversation_history.append({"role": str(entry["role"]), "content": str(entry["content"])}) if previous_response_id: logger.debug("Both conversation_history and previous_response_id provided; using conversation_history") - stored_session_id = None if not conversation_history and previous_response_id: stored = self._response_store.get(previous_response_id) @@ -289,7 +283,6 @@ def _resolve_conversation_history( stored_session_id = stored.get("session_id") if instructions is None: instructions = stored.get("instructions") - if not conversation_history and isinstance(raw_input, list) and len(raw_input) > 1: for msg in raw_input[:-1]: if isinstance(msg, dict) and msg.get("role") and msg.get("content"): @@ -352,16 +345,6 @@ class _RunLaunch: self.queue.put_nowait(event) -def _idempotency_key_from(request: "web.Request", _openai_error) -> "tuple[str, web.Response | None]": - """Return ``(key, error)``; an absent header yields ``("", None)``.""" - key = request.headers.get("Idempotency-Key", "").strip() - if key and (len(key) > 255 or any(ord(ch) < 33 or ord(ch) > 126 for ch in key)): - return "", _json_error( - _openai_error, "Idempotency-Key must be 1-255 visible ASCII characters", - code="invalid_idempotency_key", status=400) - return key, None - - def _optional_dict(body: Any, key: str) -> Optional[dict]: value = body.get(key) if isinstance(body, dict) else None return value if isinstance(value, dict) else None @@ -391,26 +374,24 @@ def _drop_run_transport(self, run_id: str) -> None: 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). gateway_session_key, key_err = self._parse_session_key_header(request) if key_err is not None: return key_err - try: body = await request.json() except Exception: return _json_error(_openai_error, "Invalid JSON", status=400) - body, room_error = await self._normalize_room_dispatch(request, body) if room_error is not None: return room_error room_dispatch = _optional_dict(body, "hosted_room_dispatch") room_execution_policy = _optional_dict(body, "_room_execution_policy") - - idempotency_key, key_err = _idempotency_key_from(request, _openai_error) - if key_err is not None: - return key_err + idempotency_key = request.headers.get("Idempotency-Key", "").strip() + if len(idempotency_key) > 255 or any(ord(ch) < 33 or ord(ch) > 126 for ch in idempotency_key): + return _json_error( + _openai_error, "Idempotency-Key must be 1-255 visible ASCII characters", + code="invalid_idempotency_key", status=400) idempotency_scope = idempotency_fingerprint = "" if idempotency_key: idempotency_scope = self._run_idempotency_scope(request) @@ -418,7 +399,6 @@ async def _handle_runs(self, request: "web.Request", *, _api_server) -> "web.Res {"body": body, "gateway_session_key": gateway_session_key or ""}, sort_keys=True, separators=(",", ":"), ensure_ascii=False, ).encode()).hexdigest() - raw_input = body.get("input") if not raw_input: return _json_error(_openai_error, "Missing 'input' field", status=400) @@ -428,13 +408,11 @@ async def _handle_runs(self, request: "web.Request", *, _api_server) -> "web.Res user_message = raw_input[-1].get("content", "") if isinstance(raw_input, list) else "" if not user_message: return _json_error(_openai_error, "No user message found in input", status=400) - conversation_history, instructions, stored_session_id, history_err = ( _resolve_conversation_history(self, body, raw_input, _openai_error=_openai_error)) if history_err is not None: return history_err previous_response_id = body.get("previous_response_id") - session_id = body.get("session_id") or stored_session_id route = self._resolve_route(body.get("model")) agent_overrides = _api_server._request_agent_overrides(body, virtual_model=self._model_name) @@ -444,7 +422,6 @@ async def _handle_runs(self, request: "web.Request", *, _api_server) -> "web.Res requested_provider=agent_overrides.get("requested_provider"), route=route) if selection_error: return _json_error(_openai_error, selection_error, status=400) - # A lost-acceptance replay must resolve even while the original run holds the last # concurrency slot; this read reserves nothing (the atomic reserve below closes the race). if idempotency_key: @@ -453,15 +430,12 @@ async def _handle_runs(self, request: "web.Request", *, _api_server) -> "web.Res retention_until=_room_retention_until(request)) if outcome == "conflict" or (outcome == "reused" and record is not None): return _replay_or_conflict(self, request, outcome, record, gateway_session_key, _openai_error) - # Enforce concurrency only for a genuinely new run. limited = self._concurrency_limited_response() if limited is not None: return limited - if not conversation_history and session_id and not previous_response_id: conversation_history = await self._conversation_history_for_session(str(session_id)) - run_id = f"run_{uuid.uuid4().hex}" self._run_owners[run_id] = self._run_idempotency_scope(request) # Same precedence as /v1/responses: body session_id > response chain > X-Hermes-Session-Key @@ -475,8 +449,7 @@ async def _handle_runs(self, request: "web.Request", *, _api_server) -> "web.Res self._run_streams_created[run_id] = created_at self._run_approval_sessions[run_id] = run_id # approval session key (see _RunLaunch) initial_status = self._set_run_status( - run_id, "queued", created_at=created_at, session_id=session_id, model=body.get("model", self._model_name) - ) + run_id, "queued", created_at=created_at, session_id=session_id, model=body.get("model", self._model_name)) if idempotency_key: outcome, record = self._run_idempotency_store.reserve( idempotency_scope, idempotency_key, idempotency_fingerprint, run_id, initial_status, @@ -488,7 +461,6 @@ async def _handle_runs(self, request: "web.Request", *, _api_server) -> "web.Res self._run_statuses, self._run_owners) return _replay_or_conflict(self, request, outcome, record, gateway_session_key, _openai_error) self._run_idempotency_ids.add(run_id) - launch = _RunLaunch( self, run_id, q, session_id, gateway_session_key, _declared_selected, user_message, conversation_history, @@ -522,7 +494,6 @@ def _run_agent_sync(self, run: _RunLaunch, agent, approval_notify, *, _api_serve from tools.approval import ( register_gateway_notify, reset_current_session_key, set_current_session_key, unregister_gateway_notify) - session_id = run.session_id effective_task_id = session_id or run.run_id # (token, reset) pairs unwound in the finally block; bound only once each step succeeds. @@ -562,10 +533,7 @@ def _run_agent_sync(self, run: _RunLaunch, agent, approval_notify, *, _api_serve for token, reset in resets: with suppress(Exception): reset(token) - return r, { - "input_tokens": getattr(agent, "session_prompt_tokens", 0) or 0, - "output_tokens": getattr(agent, "session_completion_tokens", 0) or 0, - "total_tokens": getattr(agent, "session_total_tokens", 0) or 0} + return r, {key: getattr(agent, attr, 0) or 0 for key, attr in _USAGE_FIELDS} def _make_approval_notify(self, run: _RunLaunch, *, _api_server) -> Callable[[Dict[str, Any]], None]: @@ -577,16 +545,11 @@ def _make_approval_notify(self, run: _RunLaunch, *, _api_server) -> Callable[[Di # Clients must never receive the raw flagged command: redact before it hits the stream. if "command" in event: from gateway.run import _redact_approval_command - event["command"] = _redact_approval_command(event.get("command")) - event.update({ - "event": "approval.request", - "run_id": run_id, - "timestamp": time.time(), - "choices": _api_server._approval_event_choices( - smart_denied=bool(event.get("smart_denied")), - allow_session=event.get("allow_session") is not False, - allow_permanent=event.get("allow_permanent") is not False)}) + event.update(_run_event(run_id, "approval.request", choices=_api_server._approval_event_choices( + smart_denied=bool(event.get("smart_denied")), + allow_session=event.get("allow_session") is not False, + allow_permanent=event.get("allow_permanent") is not False))) self._set_run_status(run_id, "waiting_for_approval", last_event="approval.request", approval=event) with suppress(Exception): loop.call_soon_threadsafe(q.put_nowait, event) @@ -624,8 +587,7 @@ async def _execute_run(self, run: _RunLaunch, *, _api_server) -> None: self._active_run_agents[run_id] = agent approval_notify = _make_approval_notify(self, run, _api_server=_api_server) result, usage = await loop.run_in_executor( - None, lambda: _run_agent_sync(self, run, agent, approval_notify, _api_server=_api_server) - ) + None, lambda: _run_agent_sync(self, run, agent, approval_notify, _api_server=_api_server)) if not isinstance(result, dict): result = {} if run_id in self._stopping_run_ids and result.get("interrupted") is True: @@ -661,7 +623,6 @@ def _unregister_approval_notify(approval_session_key: Optional[str]) -> None: """Best-effort release of a run's approval waiter (no-op without a key).""" with suppress(Exception): from tools.approval import unregister_gateway_notify - if approval_session_key: unregister_gateway_notify(approval_session_key) @@ -669,13 +630,10 @@ def _unregister_approval_notify(approval_session_key: Optional[str]) -> None: def _release_run_owner_if_forgotten(self, run_id: str) -> None: """Drop the owner stamp only once nothing keyed by *run_id* survives: ownership must outlive every surface it protects (retired on different clocks); ownerless = fail-closed.""" - if any( - run_id in table - for table in ( - self._run_statuses, self._active_run_agents, self._active_run_tasks, - self._run_streams, self._run_approval_sessions)): - return - self._run_owners.pop(run_id, None) + live = (self._run_statuses, self._active_run_agents, self._active_run_tasks, self._run_streams, + self._run_approval_sessions) + if not any(run_id in table for table in live): + self._run_owners.pop(run_id, None) def _request_owns_run(self, request: "web.Request", run_id: str) -> bool: @@ -733,10 +691,8 @@ async def _handle_run_events(self, request: "web.Request", *, _api_server) -> "w return _run_not_found(_api_server._openai_error, run_id) q = self._run_streams[run_id] self._run_stream_subscribers.add(run_id) - response = web.StreamResponse( - status=200, - headers={"Content-Type": "text/event-stream", "Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, - ) + response = web.StreamResponse(status=200, headers={ + "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "X-Accel-Buffering": "no"}) await response.prepare(request) try: while True: @@ -754,7 +710,6 @@ async def _handle_run_events(self, request: "web.Request", *, _api_server) -> "w finally: self._run_stream_subscribers.discard(run_id) _drop_run_transport(self, run_id) - return response @@ -776,18 +731,15 @@ _APPROVAL_CHOICE_ALIASES = {"approve": "once", "approved": "once", "allow": "onc async def _handle_run_approval(self, request: "web.Request", *, _api_server) -> "web.Response": """POST /v1/runs/{run_id}/approval — resolve a pending run approval.""" - _coerce_request_bool = _api_server._coerce_request_bool _openai_error = _api_server._openai_error run_id, _, _, _, err = _load_owned_run( self, request, _api_server=_api_server, permission="approve", active_fallback=False) if err is not None: return err - try: body = await request.json() except Exception: return _json_error(_openai_error, "Invalid JSON", status=400) - raw_choice = str(body.get("choice", "")).strip().lower() choice = _APPROVAL_CHOICE_ALIASES.get(raw_choice, raw_choice) room_scoped = bool(self._room_grant_token(request)) @@ -795,9 +747,7 @@ async def _handle_run_approval(self, request: "web.Request", *, _api_server) -> request_id = raw_request_id.strip() if isinstance(raw_request_id, str) else "" # Room grants may resolve exactly one request and never widen to session/always. allowed = {"once", "deny"} if room_scoped else {"once", "session", "always", "deny"} - resolve_all = ( - _coerce_request_bool(body.get("all"), default=False) - or _coerce_request_bool(body.get("resolve_all"), default=False)) + resolve_all = any(_api_server._coerce_request_bool(body.get(k), default=False) for k in ("all", "resolve_all")) approval_session_key = self._run_approval_sessions.get(run_id) for failed, message, code, status in ( (raw_request_id is not None and (not request_id or len(request_id) > 256), @@ -815,24 +765,18 @@ async def _handle_run_approval(self, request: "web.Request", *, _api_server) -> return _json_error(_openai_error, message, code=code, status=status) try: from tools.approval import resolve_gateway_approval - resolved = resolve_gateway_approval( approval_session_key, choice, resolve_all=resolve_all, request_id=request_id or None) except Exception as exc: logger.exception("[api_server] approval resolution failed for run %s", run_id) return _json_error(_openai_error, str(exc), status=500) - if resolved <= 0: return _json_error( - _openai_error, f"Run has no pending approval: {run_id}", code="approval_not_pending", status=409 - ) - + _openai_error, f"Run has no pending approval: {run_id}", code="approval_not_pending", status=409) request_id_field = {"request_id": request_id} if request_id else {} _mark_run_event(self, run_id, "approval.responded", choice=choice, **request_id_field, resolved=resolved) return web.json_response({ - "object": "hermes.run.approval_response", - "run_id": run_id, - "choice": choice, **request_id_field, + "object": "hermes.run.approval_response", "run_id": run_id, "choice": choice, **request_id_field, "resolved": resolved}) @@ -849,7 +793,6 @@ async def _handle_steer_run(self, request: "web.Request", *, _api_server) -> "we return _json_error( _openai_error, f"Run is not currently accepting steer input: {run_id}", code="run_not_accepting_steer", status=409) - body, err = await self._read_json_body(request) if err: return err @@ -859,7 +802,6 @@ async def _handle_steer_run(self, request: "web.Request", *, _api_server) -> "we return _json_error( _openai_error, "Missing non-empty steer text; expected 'input', 'message', or 'text'.", code="invalid_steer_input", status=400) - try: accepted = bool(agent.steer(steer_text)) except Exception as exc: @@ -867,8 +809,7 @@ async def _handle_steer_run(self, request: "web.Request", *, _api_server) -> "we return _json_error(_openai_error, _api_server._redact_api_error_text(exc), code="steer_failed", status=500) if not accepted: return _json_error( - _openai_error, f"Run did not accept steer text: {run_id}", code="steer_not_accepted", status=409 - ) + _openai_error, f"Run did not accept steer text: {run_id}", code="steer_not_accepted", status=409) _mark_run_event(self, run_id, "run.steered", accepted=True) return web.json_response({"object": "hermes.run.steer", "run_id": run_id, "accepted": True}) @@ -882,22 +823,18 @@ async def _handle_stop_run(self, request: "web.Request", *, _api_server) -> "web return err if status.get("status") in TERMINAL_STATUSES: return web.json_response(status) - if agent is None and task is None: return _json_error( _openai_error, f"Run is not active in this gateway process: {run_id}", code="run_not_active", status=409) - self._set_run_status(run_id, "stopping", last_event="run.stopping") self._stopping_run_ids.add(run_id) - if agent is not None: with suppress(Exception): _api_server.request_hard_interrupt(agent, "Stop requested via API") # Reap only this run's background processes (epoch-gated inside, so a concurrent # run on the same session_id keeps its own); no-op if the run already finished. _api_server._reap_disconnected_agent_processes(agent, source="api_server_run_stop") - return web.json_response({"run_id": run_id, "status": "stopping"}) @@ -913,8 +850,7 @@ def _sweep_orphaned_runs_once(self, now: Optional[float] = None) -> None: if now is None: now = time.time() stale = [ - run_id - for run_id, created_at in list(self._run_streams_created.items()) + run_id for run_id, created_at in list(self._run_streams_created.items()) if now - created_at > self._RUN_STREAM_TTL and run_id not in self._run_stream_subscribers] for run_id in stale: logger.debug("[api_server] sweeping expired run transport %s", run_id) @@ -924,11 +860,7 @@ def _sweep_orphaned_runs_once(self, now: Optional[float] = None) -> None: if task is None or task.done(): _unregister_approval_notify(self._run_approval_sessions.get(run_id)) _retire_live_run(self, run_id) - - stale_statuses = [ - run_id - for run_id, status in list(self._run_statuses.items()) - if status.get("status") in {"completed", "failed", "cancelled"} - and now - float(status.get("updated_at", 0) or 0) > self._RUN_STATUS_TTL] - for run_id in stale_statuses: - _forget_run(self, run_id, self._run_statuses, self._run_idempotency_ids) + for run_id, status in list(self._run_statuses.items()): + if (status.get("status") in {"completed", "failed", "cancelled"} + and now - float(status.get("updated_at", 0) or 0) > self._RUN_STATUS_TTL): + _forget_run(self, run_id, self._run_statuses, self._run_idempotency_ids) From c1790f2a7b3b7b6abda294c078667a8503a1e532 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:33:09 -0700 Subject: [PATCH 13/22] =?UTF-8?q?refactor(gateway/platforms):=20qqbot=20Ch?= =?UTF-8?q?unkedUploader=20=E2=80=94=20per-upload=20=5FJob=20replaces=2010?= =?UTF-8?q?-arg=20part=20plumbing,=20single=20=5Fpost=20helper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gateway/platforms/qqbot/chunked_upload.py | 103 +++++++++++----------- 1 file changed, 52 insertions(+), 51 deletions(-) diff --git a/gateway/platforms/qqbot/chunked_upload.py b/gateway/platforms/qqbot/chunked_upload.py index 8e11fbbb40..8b9c5c448f 100644 --- a/gateway/platforms/qqbot/chunked_upload.py +++ b/gateway/platforms/qqbot/chunked_upload.py @@ -119,6 +119,23 @@ def _api_path(chat_type: str, target_id: str, endpoint: str) -> str: # ── Chunked upload driver ── +@dataclass +class _Job: + """Per-upload state shared by the part workers.""" + chat_type: str + target_id: str + file_path: str + file_size: int + upload_id: str = "" + block_size: int = 0 + total_parts: int = 0 + retry_timeout: float = 0.0 + completed: int = 0 + + def path(self, endpoint: str) -> str: + return _api_path(self.chat_type, self.target_id, endpoint) + + class ChunkedUploader: """Run the prepare → PUT parts → complete sequence. ``api_request`` is the adapter's bound ``_api_request(method, path, body=..., timeout=...)`` (injected to avoid a @@ -132,6 +149,9 @@ class ChunkedUploader: self._http_put = http_put self._log_tag = log_tag + async def _post(self, job: _Job, endpoint: str, body: Dict[str, Any]) -> Dict[str, Any]: + return await self._api_request("POST", job.path(endpoint), body=body, timeout=FILE_UPLOAD_TIMEOUT) + async def upload(self, chat_type: str, target_id: str, file_path: str, file_type: int, file_name: str) -> Dict[str, Any]: """Run the full chunked upload (``chat_type`` 'c2c'|'group', ``file_type`` MEDIA_TYPE_*) and return the raw ``complete_upload`` response (contains ``file_info``). @@ -139,78 +159,66 @@ class ChunkedUploader: if chat_type not in {"c2c", "group"}: raise ValueError(f"ChunkedUploader: unsupported chat_type {chat_type!r}") - file_size = Path(file_path).stat().st_size + job = _Job(chat_type, target_id, file_path, Path(file_path).stat().st_size) logger.info( "[%s] Chunked upload start: file=%s size=%s type=%d", - self._log_tag, file_name, format_size(file_size), file_type) + self._log_tag, file_name, format_size(job.file_size), file_type) # Hashing is blocking I/O → executor. - hashes = await asyncio.get_running_loop().run_in_executor(None, _compute_file_hashes, file_path, file_size) - prepare = await self._prepare(chat_type, target_id, file_type, file_name, file_size, hashes) + hashes = await asyncio.get_running_loop().run_in_executor(None, _compute_file_hashes, file_path, job.file_size) + prepare = await self._prepare(job, file_type, file_name, hashes) max_concurrent = min(prepare.concurrency, _MAX_CONCURRENT_PARTS) - retry_timeout = min( + job.upload_id, job.block_size, job.total_parts = prepare.upload_id, prepare.block_size, len(prepare.parts) + job.retry_timeout = min( prepare.retry_timeout if prepare.retry_timeout > 0 else _PART_FINISH_DEFAULT_TIMEOUT, _PART_FINISH_MAX_TIMEOUT) logger.info( "[%s] Prepared: upload_id=%s block_size=%s parts=%d concurrency=%d", - self._log_tag, prepare.upload_id, format_size(prepare.block_size), - len(prepare.parts), max_concurrent) + self._log_tag, job.upload_id, format_size(job.block_size), job.total_parts, max_concurrent) - total_parts = len(prepare.parts) - completed = [0] # shared counter for progress logging sem = asyncio.Semaphore(max(max_concurrent, 1)) async def _run(part: _PreparePart) -> None: async with sem: - await self._upload_one_part( - chat_type, target_id, file_path, file_size, prepare.upload_id, - prepare.block_size, part, retry_timeout, total_parts, completed) + await self._upload_one_part(job, part) await asyncio.gather(*(_run(p) for p in prepare.parts)) - logger.info("[%s] All %d parts uploaded, completing…", self._log_tag, total_parts) - return await self._complete(chat_type, target_id, prepare.upload_id) + logger.info("[%s] All %d parts uploaded, completing…", self._log_tag, job.total_parts) + return await self._complete(job) - async def _prepare( - self, chat_type: str, target_id: str, file_type: int, file_name: str, file_size: int, hashes: Dict[str, str], - ) -> _PrepareResult: + async def _prepare(self, job: _Job, file_type: int, file_name: str, hashes: Dict[str, str]) -> _PrepareResult: body = { - "file_type": file_type, "file_name": file_name, "file_size": file_size, + "file_type": file_type, "file_name": file_name, "file_size": job.file_size, "md5": hashes["md5"], "sha1": hashes["sha1"], "md5_10m": hashes["md5_10m"]} try: - raw = await self._api_request( - "POST", _api_path(chat_type, target_id, "upload_prepare"), body=body, timeout=FILE_UPLOAD_TIMEOUT, - ) + raw = await self._post(job, "upload_prepare", body) except RuntimeError as exc: err_msg = str(exc) if f"{_BIZ_CODE_DAILY_LIMIT}" in err_msg: - raise UploadDailyLimitExceededError(file_name, file_size, err_msg) from exc + raise UploadDailyLimitExceededError(file_name, job.file_size, err_msg) from exc raise return _parse_prepare_response(raw) - async def _upload_one_part( - self, chat_type: str, target_id: str, file_path: str, file_size: int, upload_id: str, rsp_block_size: int, - part: _PreparePart, retry_timeout: float, total_parts: int, completed: List[int]) -> None: + async def _upload_one_part(self, job: _Job, part: _PreparePart) -> None: """PUT one part to COS, then call ``upload_part_finish``.""" - part_index = part.index + part_index, total_parts = part.index, job.total_parts # Per-part block_size wins; fall back to the response-level value. - actual_block_size = part.block_size if part.block_size > 0 else rsp_block_size - offset = (part_index - 1) * rsp_block_size - length = min(actual_block_size, file_size - offset) + actual_block_size = part.block_size if part.block_size > 0 else job.block_size + offset = (part_index - 1) * job.block_size + length = min(actual_block_size, job.file_size - offset) - data = await asyncio.get_running_loop().run_in_executor(None, _read_file_chunk, file_path, offset, length) + data = await asyncio.get_running_loop().run_in_executor(None, _read_file_chunk, job.file_path, offset, length) md5_hex = hashlib.md5(data).hexdigest() logger.debug( "[%s] Part %d/%d: uploading %s (offset=%d md5=%s)", self._log_tag, part_index, total_parts, format_size(length), offset, md5_hex) await self._put_to_presigned_url(part.presigned_url, data, part_index, total_parts) - await self._part_finish_with_retry( - chat_type, target_id, upload_id, part_index, length, md5_hex, retry_timeout) + await self._part_finish_with_retry(job, part_index, length, md5_hex) - completed[0] += 1 + job.completed += 1 logger.debug( - "[%s] Part %d/%d done (%d/%d total)", - self._log_tag, part_index, total_parts, completed[0], total_parts) + "[%s] Part %d/%d done (%d/%d total)", self._log_tag, part_index, total_parts, job.completed, total_parts) async def _with_retries( self, attempt_fn: Callable[[], Awaitable[Any]], *, max_retries: int, base_delay: float, label: str, @@ -249,44 +257,37 @@ class ChunkedUploader: await self._with_retries( _attempt, max_retries=_PART_UPLOAD_MAX_RETRIES, base_delay=1.0, - label=f"PUT part {part_index}/{total_parts}", - failure_label=f"Part {part_index}/{total_parts} upload") + label=f"PUT part {part_index}/{total_parts}", failure_label=f"Part {part_index}/{total_parts} upload") - async def _part_finish_with_retry( - self, chat_type: str, target_id: str, upload_id: str, part_index: int, block_size: int, md5: str, - retry_timeout: float) -> None: - """Call ``upload_part_finish``, retrying on biz_code 40093001 until *retry_timeout*.""" - path = _api_path(chat_type, target_id, "upload_part_finish") - body = {"upload_id": upload_id, "part_index": part_index, "block_size": block_size, "md5": md5} + async def _part_finish_with_retry(self, job: _Job, part_index: int, block_size: int, md5: str) -> None: + """Call ``upload_part_finish``, retrying on biz_code 40093001 until ``job.retry_timeout``.""" + body = {"upload_id": job.upload_id, "part_index": part_index, "block_size": block_size, "md5": md5} loop = asyncio.get_running_loop() start = loop.time() attempt = 0 while True: try: - await self._api_request("POST", path, body=body, timeout=FILE_UPLOAD_TIMEOUT) + await self._post(job, "upload_part_finish", body) return except RuntimeError as exc: if f"{_BIZ_CODE_PART_RETRYABLE}" not in str(exc): raise elapsed = loop.time() - start - if elapsed >= retry_timeout: + if elapsed >= job.retry_timeout: raise RuntimeError( f"upload_part_finish persistent retry timed out " - f"after {retry_timeout:.0f}s ({attempt} retries): {exc}" - ) from exc + f"after {job.retry_timeout:.0f}s ({attempt} retries): {exc}") from exc attempt += 1 logger.debug( "[%s] part_finish retryable error, attempt %d, elapsed=%.1fs: %s", self._log_tag, attempt, elapsed, exc) await asyncio.sleep(_PART_FINISH_RETRY_INTERVAL) - async def _complete(self, chat_type: str, target_id: str, upload_id: str) -> Dict[str, Any]: + async def _complete(self, job: _Job) -> Dict[str, Any]: """Call ``complete_upload`` with retry — the ``/files`` endpoint (same as the simple URL upload) selects the chunked-completion path when only ``upload_id`` is sent.""" - path = _api_path(chat_type, target_id, "files") - body = {"upload_id": upload_id} return await self._with_retries( - lambda: self._api_request("POST", path, body=body, timeout=FILE_UPLOAD_TIMEOUT), + lambda: self._post(job, "files", {"upload_id": job.upload_id}), max_retries=_COMPLETE_UPLOAD_MAX_RETRIES, base_delay=_COMPLETE_UPLOAD_BASE_DELAY, label="complete_upload", failure_label="complete_upload") From 978ec25e0e7956d9c4ab0aa21d9cb1e2e127ed4d Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:37:55 -0700 Subject: [PATCH 14/22] refactor(gateway/platforms): qqbot drop name-restating docstrings/comments, STT result + typing guard compaction --- gateway/platforms/qqbot/adapter.py | 61 ++++++------------------------ 1 file changed, 12 insertions(+), 49 deletions(-) diff --git a/gateway/platforms/qqbot/adapter.py b/gateway/platforms/qqbot/adapter.py index 69f0c20d3b..6184078904 100644 --- a/gateway/platforms/qqbot/adapter.py +++ b/gateway/platforms/qqbot/adapter.py @@ -72,7 +72,6 @@ from gateway.platforms._shared import get_scoped_secret as _resolve_qq_secret def check_qq_requirements() -> bool: - """Check if QQ runtime dependencies are available.""" return AIOHTTP_AVAILABLE and HTTPX_AVAILABLE @@ -110,7 +109,6 @@ class QQAdapter(BasePlatformAdapter): return f"QQBot:{app_id}" if app_id else "QQBot" def _fail_pending(self, reason: str) -> None: - """Fail all pending response futures.""" for fut in self._pending_responses.values(): if not fut.done(): fut.set_exception(RuntimeError(reason)) @@ -138,13 +136,11 @@ class QQAdapter(BasePlatformAdapter): extra.get("client_secret") or _resolve_qq_secret("QQ_CLIENT_SECRET", "")).strip() self._markdown_support = bool(extra.get("markdown_support", True)) - # Auth/ACL policies self._dm_policy = str(extra.get("dm_policy", "pairing")).strip().lower() self._allow_from = _coerce_list(extra.get("allow_from") or extra.get("allowFrom")) self._group_policy = str(extra.get("group_policy", "pairing")).strip().lower() self._group_allow_from = _coerce_list(extra.get("group_allow_from") or extra.get("groupAllowFrom")) - # Connection state self._session: Optional[aiohttp.ClientSession] = None self._ws: Optional[aiohttp.ClientWebSocketResponse] = None self._http_client: Optional[httpx.AsyncClient] = None @@ -160,7 +156,6 @@ class QQAdapter(BasePlatformAdapter): self._last_msg_id: Dict[str, str] = {} # last inbound message ID per chat (send_typing) self._typing_sent_at: Dict[str, float] = {} # typing debounce: chat_id → last send_typing ts - # Token cache self._access_token: Optional[str] = None self._token_expires_at: float = 0.0 self._token_lock = asyncio.Lock() @@ -199,7 +194,6 @@ class QQAdapter(BasePlatformAdapter): logger.warning("[%s] %s%s", self._log_tag, message, hint) return False - # Prevent duplicate connections with the same credentials if not self._acquire_platform_lock("qqbot-appid", self._app_id, "QQBot app ID"): return False @@ -219,7 +213,6 @@ class QQAdapter(BasePlatformAdapter): self._heartbeat_task = asyncio.create_task(self._heartbeat_loop()) self._mark_connected() logger.info("[%s] Connected", self._log_tag) - # Plugin-registered native handlers (ctx.register_platform_handler). self._wire_plugin_handlers(None) return True except Exception as exc: @@ -231,7 +224,6 @@ class QQAdapter(BasePlatformAdapter): return False async def disconnect(self) -> None: - """Close all connections and stop listeners.""" self._running = False self._mark_disconnected() self._listen_task = await self._cancel_task(self._listen_task) @@ -302,7 +294,6 @@ class QQAdapter(BasePlatformAdapter): return self._access_token async def _get_gateway_url(self) -> str: - """Fetch the WebSocket gateway URL from the REST API.""" token = await self._ensure_token() data = await self._fetch_json("gateway URL", lambda: self._http_client.get( f"{API_BASE}{GATEWAY_URL_PATH}", @@ -316,7 +307,6 @@ class QQAdapter(BasePlatformAdapter): # ── WebSocket lifecycle ── async def _open_ws(self, gateway_url: str) -> None: - """Open a WebSocket connection to the QQ Bot gateway.""" await self._close_ws() # Honor proxy env vars for the WebSocket (WSL setups need this). self._session = aiohttp.ClientSession(trust_env=gateway_trust_env()) @@ -422,7 +412,6 @@ class QQAdapter(BasePlatformAdapter): await reconnect() async def _reconnect(self, backoff_idx: int) -> bool: - """Attempt to reconnect the WebSocket. Returns True on success.""" delay = RECONNECT_BACKOFF[min(backoff_idx, len(RECONNECT_BACKOFF) - 1)] logger.info("[%s] Reconnecting in %ds (attempt %d)...", self._log_tag, delay, backoff_idx + 1) await asyncio.sleep(delay) @@ -440,7 +429,6 @@ class QQAdapter(BasePlatformAdapter): return False async def _read_events(self) -> None: - """Read WebSocket frames until connection closes.""" if not self._ws: raise RuntimeError("WebSocket not connected") if self._ws.closed: @@ -591,7 +579,6 @@ class QQAdapter(BasePlatformAdapter): await super().handle_message(event) async def _on_message(self, event_type: str, d: Any) -> None: - """Process an inbound QQ Bot message event.""" if not isinstance(d, dict): return msg_id = str(d.get("id", "")) @@ -743,7 +730,6 @@ class QQAdapter(BasePlatformAdapter): logger.error("Failed to write update response: %s", exc) async def _handle_c2c_message(self, d, msg_id, content, author, timestamp) -> None: - """Handle a C2C (private) message event.""" user_openid = str(author.get("user_openid", "")) if not user_openid or not self._is_dm_intake_allowed(user_openid): return @@ -767,7 +753,6 @@ class QQAdapter(BasePlatformAdapter): chat_id=user_openid, qq_chat_type="c2c", user_id=user_openid, chat_type="dm") async def _handle_group_message(self, d, msg_id, content, author, timestamp) -> None: - """Handle a group @-message event.""" group_openid = str(d.get("group_openid", "")) member = str(author.get("member_openid", "")) if not group_openid or not self._is_group_allowed(group_openid, member): @@ -777,7 +762,6 @@ class QQAdapter(BasePlatformAdapter): chat_id=group_openid, qq_chat_type="group", user_id=member, chat_type="group") async def _handle_guild_message(self, d, msg_id, content, author, timestamp) -> None: - """Handle a guild/channel message event.""" channel_id = str(d.get("channel_id", "")) if not channel_id: return @@ -796,7 +780,6 @@ class QQAdapter(BasePlatformAdapter): chat_id=channel_id, qq_chat_type="guild", user_id=author_id, user_name=nick or None, chat_type="group") async def _handle_dm_message(self, d, msg_id, content, author, timestamp) -> None: - """Handle a guild DM message event.""" guild_id = str(d.get("guild_id", "")) if not guild_id: return @@ -903,7 +886,6 @@ class QQAdapter(BasePlatformAdapter): @staticmethod def _detect_message_type(media_urls: list, media_types: list): - """Determine MessageType from attachment content types.""" if not media_urls: return MessageType.TEXT if not media_types: @@ -978,7 +960,6 @@ class QQAdapter(BasePlatformAdapter): @staticmethod def _opt_str(value: Any) -> Optional[str]: - """Stripped non-empty string or None.""" return (value.strip() if isinstance(value, str) else "") or None async def _download_and_cache(self, url: str, content_type: str, original_name: str = "") -> Optional[str]: @@ -1009,7 +990,6 @@ class QQAdapter(BasePlatformAdapter): @staticmethod def _is_voice_content_type(content_type: str, filename: str) -> bool: - """Check if an attachment is a voice/audio message.""" ct = content_type.strip().lower() if ct == "voice" or ct.startswith("audio/"): return True @@ -1187,7 +1167,6 @@ class QQAdapter(BasePlatformAdapter): return None async def _convert_ffmpeg_to_wav(self, src_path: str, wav_path: str) -> Optional[str]: - """Convert audio file to WAV using ffmpeg.""" try: proc = await asyncio.create_subprocess_exec( "ffmpeg", "-y", "-i", src_path, "-ar", "16000", "-ac", "1", wav_path, @@ -1231,10 +1210,8 @@ class QQAdapter(BasePlatformAdapter): qq_stt_key = _resolve_qq_secret("QQ_STT_API_KEY", "") if qq_stt_key: base_url = _resolve_qq_secret("QQ_STT_BASE_URL", _STT_PROVIDER_BASE_URLS["zai"]) - return { - "base_url": base_url.rstrip("/"), - "api_key": qq_stt_key, - "model": _resolve_qq_secret("QQ_STT_MODEL", "glm-asr")} + model = _resolve_qq_secret("QQ_STT_MODEL", "glm-asr") + return {"base_url": base_url.rstrip("/"), "api_key": qq_stt_key, "model": model} return None async def _call_stt(self, wav_path: str) -> Optional[str]: @@ -1257,11 +1234,8 @@ class QQAdapter(BasePlatformAdapter): result = resp.json() # Zhipu/GLM: {"choices": [{"message": {"content": ...}}]}; OpenAI/Whisper: {"text": ...} choices = result.get("choices", []) - if choices: - content = choices[0].get("message", {}).get("content", "") - if content.strip(): - return content.strip() - return result.get("text", "").strip() or None + content = choices[0].get("message", {}).get("content", "") if choices else "" + return content.strip() or result.get("text", "").strip() or None except (httpx.HTTPStatusError, IOError) as exc: logger.warning("[%s] STT API call failed (model=%s, base=%s): %s", self._log_tag, model, base_url[:50], exc) return None @@ -1302,7 +1276,6 @@ class QQAdapter(BasePlatformAdapter): async def _api_request( self, method: str, path: str, body: Optional[Dict[str, Any]] = None, timeout: float = DEFAULT_API_TIMEOUT, ) -> Dict[str, Any]: - """Make an authenticated REST API request to QQ Bot API.""" client = self._require_http_client() headers = await self._auth_headers() try: @@ -1326,7 +1299,6 @@ class QQAdapter(BasePlatformAdapter): self, target_type: str, target_id: str, file_type: int, url: Optional[str] = None, file_data: Optional[str] = None, srv_send_msg: bool = False, file_name: Optional[str] = None, ) -> Dict[str, Any]: - """Upload media and return file_info.""" path = self._rest_path(target_type, target_id, "files") body: Dict[str, Any] = {"file_type": file_type, "srv_send_msg": srv_send_msg} if url: @@ -1394,7 +1366,6 @@ class QQAdapter(BasePlatformAdapter): _PERMANENT_SEND_ERRORS = ("invalid", "forbidden", "not found") async def _send_chunk(self, chat_id: str, content: str, reply_to: Optional[str] = None) -> SendResult: - """Send a single chunk with retry + exponential backoff.""" last_exc: Optional[Exception] = None sender = self._text_sender(self._guess_chat_type(chat_id)) if sender is None: @@ -1425,7 +1396,6 @@ class QQAdapter(BasePlatformAdapter): @classmethod def _messages_path(cls, chat_type: str, target_id: str) -> str: - """REST path for outbound messages to a c2c user or a group.""" return cls._rest_path(chat_type, target_id, "messages") async def _post_message(self, path: str, body: Dict[str, Any]) -> SendResult: @@ -1461,7 +1431,6 @@ class QQAdapter(BasePlatformAdapter): return getattr(self, name) if name else None async def _send_guild_text(self, channel_id: str, content: str, reply_to: Optional[str] = None) -> SendResult: - """Send text to a guild channel via REST API.""" body: Dict[str, Any] = {"content": content[: self.MAX_MESSAGE_LENGTH]} if reply_to: body["msg_id"] = reply_to @@ -1531,7 +1500,6 @@ class QQAdapter(BasePlatformAdapter): ) def _build_text_body(self, content: str, reply_to: Optional[str] = None) -> Dict[str, Any]: - """Build the message body for C2C/group text sending.""" msg_seq = self._next_msg_seq(reply_to or "default") text = content[: self.MAX_MESSAGE_LENGTH] if self._markdown_support: @@ -1567,7 +1535,6 @@ class QQAdapter(BasePlatformAdapter): async def send_document( self, chat_id, file_path, caption=None, file_name=None, reply_to=None, **kwargs ) -> SendResult: - """Send a file/document natively.""" return await self._send_media( chat_id, file_path, MEDIA_TYPE_FILE, "file", caption, reply_to, file_name=file_name) @@ -1637,23 +1604,21 @@ class QQAdapter(BasePlatformAdapter): resolved_name = file_name or local_path.name uploader = ChunkedUploader(api_request=self._api_request, http_put=client.put, log_tag=self._log_tag) - complete = await uploader.upload( + return resolved_name, await uploader.upload( chat_type=chat_type, target_id=chat_id, file_path=str(local_path), file_type=file_type, file_name=resolved_name) - return resolved_name, complete # ── Typing indicator ── async def send_typing(self, chat_id: str, metadata=None) -> None: """C2C-only input notify, debounced to ~50s (API shows a 60s indicator); needs the last inbound msg_id from ``_last_msg_id``.""" - if not self.is_connected or self._guess_chat_type(chat_id) != "c2c": - return msg_id = self._last_msg_id.get(chat_id) - if not msg_id: - return now = time.time() - if now - self._typing_sent_at.get(chat_id, 0.0) < self._TYPING_DEBOUNCE_SECONDS: + if ( + not self.is_connected or self._guess_chat_type(chat_id) != "c2c" or not msg_id + or now - self._typing_sent_at.get(chat_id, 0.0) < self._TYPING_DEBOUNCE_SECONDS + ): return try: body = { @@ -1673,7 +1638,6 @@ class QQAdapter(BasePlatformAdapter): return content if self._markdown_support else strip_markdown(content) async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: - """Return chat info based on chat type heuristics.""" chat_type = self._guess_chat_type(chat_id) return {"name": chat_id, "type": "group" if chat_type in {"group", "guild"} else "dm"} @@ -1687,13 +1651,12 @@ class QQAdapter(BasePlatformAdapter): @staticmethod def _strip_at_mention(content: str) -> str: - """Strip the @bot mention prefix from group message content.""" return re.sub(r"^@\S+\s*", "", content.strip()) def _open_dm_opted_in(self) -> bool: - if os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in {"true", "1", "yes"}: - return True - return _resolve_qq_secret("QQ_ALLOW_ALL_USERS", "").lower() in {"true", "1", "yes"} + truthy = {"true", "1", "yes"} + return (os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in truthy + or _resolve_qq_secret("QQ_ALLOW_ALL_USERS", "").lower() in truthy) def _is_dm_allowed(self, user_id: str) -> bool: if self._dm_policy == "allowlist": From 9fd798cc688b76d1220764a0f3d34dfaa6258f13 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:39:55 -0700 Subject: [PATCH 15/22] refactor(gateway/platforms): qqbot contextlib.suppress for swallow-only try/except, __init__ layout --- gateway/platforms/qqbot/adapter.py | 39 +++++++++--------------------- 1 file changed, 12 insertions(+), 27 deletions(-) diff --git a/gateway/platforms/qqbot/adapter.py b/gateway/platforms/qqbot/adapter.py index 6184078904..6a4ea923c8 100644 --- a/gateway/platforms/qqbot/adapter.py +++ b/gateway/platforms/qqbot/adapter.py @@ -11,6 +11,7 @@ tries QQ's free ``asr_refer_text`` first, then the configured STT provider. from __future__ import annotations import asyncio +import contextlib import json import logging import os @@ -132,10 +133,8 @@ class QQAdapter(BasePlatformAdapter): extra = config.extra or {} self._app_id = str(extra.get("app_id") or _resolve_qq_secret("QQ_APP_ID", "")).strip() - self._client_secret = str( - extra.get("client_secret") or _resolve_qq_secret("QQ_CLIENT_SECRET", "")).strip() + self._client_secret = str(extra.get("client_secret") or _resolve_qq_secret("QQ_CLIENT_SECRET", "")).strip() self._markdown_support = bool(extra.get("markdown_support", True)) - self._dm_policy = str(extra.get("dm_policy", "pairing")).strip().lower() self._allow_from = _coerce_list(extra.get("allow_from") or extra.get("allowFrom")) self._group_policy = str(extra.get("group_policy", "pairing")).strip().lower() @@ -150,12 +149,10 @@ class QQAdapter(BasePlatformAdapter): self._session_id: Optional[str] = None self._last_seq: Optional[int] = None self._chat_type_map: Dict[str, str] = {} # chat_id → "c2c"|"group"|"guild"|"dm" - self._pending_responses: Dict[str, asyncio.Future] = {} # request/response correlation self._seen_messages: Dict[str, float] = {} self._last_msg_id: Dict[str, str] = {} # last inbound message ID per chat (send_typing) self._typing_sent_at: Dict[str, float] = {} # typing debounce: chat_id → last send_typing ts - self._access_token: Optional[str] = None self._token_expires_at: float = 0.0 self._token_lock = asyncio.Lock() @@ -163,9 +160,8 @@ class QQAdapter(BasePlatformAdapter): # Inline-keyboard interaction routing: invoked for every INTERACTION_CREATE # after the adapter ACKed it. Defaults to the approval/update-prompt # dispatcher; override via set_interaction_callback() (None drops clicks). - self._interaction_callback: Optional[ - Callable[[InteractionEvent], Awaitable[None]] - ] = self._default_interaction_dispatch + self._interaction_callback: Optional[Callable[[InteractionEvent], Awaitable[None]]] = ( + self._default_interaction_dispatch) # ── Properties ── @@ -237,10 +233,8 @@ class QQAdapter(BasePlatformAdapter): """Cancel and await *task* (if any); always returns None for reassignment.""" if task: task.cancel() - try: + with contextlib.suppress(asyncio.CancelledError): await task - except asyncio.CancelledError: - pass return None async def _close_ws(self) -> None: @@ -449,7 +443,7 @@ class QQAdapter(BasePlatformAdapter): async def _heartbeat_loop(self) -> None: """Send op 1 heartbeats with the latest seq at 80% of the Hello interval.""" - try: + with contextlib.suppress(asyncio.CancelledError): while self._running: await asyncio.sleep(self._heartbeat_interval) if not self._ws or self._ws.closed: @@ -458,8 +452,6 @@ class QQAdapter(BasePlatformAdapter): await self._ws.send_json({"op": 1, "d": self._last_seq}) except Exception as exc: logger.debug("[%s] Heartbeat failed: %s", self._log_tag, exc) - except asyncio.CancelledError: - pass async def _send_ws_auth(self, name: str, payload: Dict[str, Any], sent_msg: str, *log_args) -> bool: """Send an Identify/Resume payload; returns False if the send raised.""" @@ -1077,10 +1069,8 @@ class QQAdapter(BasePlatformAdapter): @staticmethod def _unlink_quiet(path: str) -> None: - try: + with contextlib.suppress(OSError): os.unlink(path) - except OSError: - pass @staticmethod def _wav_ok(wav_path: str) -> bool: @@ -1685,16 +1675,11 @@ class QQAdapter(BasePlatformAdapter): def _parse_qq_timestamp(self, raw: str) -> datetime: """Parse a QQ timestamp — ISO 8601 string (current) or integer ms (legacy).""" - if not raw: - return datetime.now(tz=timezone.utc) - try: - return datetime.fromisoformat(raw) - except (ValueError, TypeError): - pass - try: - return datetime.fromtimestamp(int(raw) / 1000, tz=timezone.utc) - except (ValueError, TypeError): - pass + if raw: + with contextlib.suppress(ValueError, TypeError): + return datetime.fromisoformat(raw) + with contextlib.suppress(ValueError, TypeError): + return datetime.fromtimestamp(int(raw) / 1000, tz=timezone.utc) return datetime.now(tz=timezone.utc) def _is_duplicate(self, msg_id: str) -> bool: From 4b04c2a3f9e11a52f94e16a8c14f2ec0fa18841b Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:42:02 -0700 Subject: [PATCH 16/22] =?UTF-8?q?refactor(gateway/platforms):=20qqbot=20ke?= =?UTF-8?q?yboards=20=E2=80=94=20one=20row=20builder=20from=20button=20tup?= =?UTF-8?q?les?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gateway/platforms/qqbot/keyboards.py | 33 ++++++++++++++-------------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/gateway/platforms/qqbot/keyboards.py b/gateway/platforms/qqbot/keyboards.py index d07d119bb2..7bf02d9486 100644 --- a/gateway/platforms/qqbot/keyboards.py +++ b/gateway/platforms/qqbot/keyboards.py @@ -102,16 +102,14 @@ def parse_update_prompt_button_data(button_data: str) -> Optional[str]: # ── Keyboard builders ── -def _make_callback_button(btn_id: str, label: str, visited_label: str, data: str, style: int, group_id: str) -> KeyboardButton: - return KeyboardButton( - id=btn_id, - render_data=KeyboardButtonRenderData(label=label, visited_label=visited_label, style=style), - action=KeyboardButtonAction(type=1, data=data), - group_id=group_id) - - -def _single_row_keyboard(buttons: List[KeyboardButton]) -> InlineKeyboard: - return InlineKeyboard(content=KeyboardContent(rows=[KeyboardRow(buttons=buttons)])) +def _single_row_keyboard(group_id: str, *buttons: tuple) -> InlineKeyboard: + """One row of callback buttons from ``(id, label, visited_label, data, style)`` tuples.""" + row = KeyboardRow(buttons=[ + KeyboardButton( + id=btn_id, group_id=group_id, action=KeyboardButtonAction(type=1, data=data), + render_data=KeyboardButtonRenderData(label=label, visited_label=visited, style=style)) + for btn_id, label, visited, data, style in buttons]) + return InlineKeyboard(content=KeyboardContent(rows=[row])) def build_approval_keyboard(session_key: str, *, allow_permanent: bool = True) -> InlineKeyboard: @@ -119,18 +117,19 @@ def build_approval_keyboard(session_key: str, *, allow_permanent: bool = True) - ⭐ is hidden when persistent scope is unavailable; *session_key* is embedded in ``button_data`` so the decision routes to the right approval.""" prefix = f"{APPROVAL_BUTTON_PREFIX}{session_key}" - buttons = [_make_callback_button("allow", "✅ 允许一次", "已允许", f"{prefix}:allow-once", 1, "approval")] + buttons = [("allow", "✅ 允许一次", "已允许", f"{prefix}:allow-once", 1)] if allow_permanent: - buttons.append(_make_callback_button("always", "⭐ 始终允许", "已始终允许", f"{prefix}:allow-always", 1, "approval")) - buttons.append(_make_callback_button("deny", "❌ 拒绝", "已拒绝", f"{prefix}:deny", 0, "approval")) - return _single_row_keyboard(buttons) + buttons.append(("always", "⭐ 始终允许", "已始终允许", f"{prefix}:allow-always", 1)) + buttons.append(("deny", "❌ 拒绝", "已拒绝", f"{prefix}:deny", 0)) + return _single_row_keyboard("approval", *buttons) def build_update_prompt_keyboard() -> InlineKeyboard: """Build a Yes/No keyboard for update confirmation prompts.""" - return _single_row_keyboard([ - _make_callback_button("yes", "✓ 确认", "已确认", f"{UPDATE_PROMPT_PREFIX}y", 1, "update_prompt"), - _make_callback_button("no", "✗ 取消", "已取消", f"{UPDATE_PROMPT_PREFIX}n", 0, "update_prompt")]) + return _single_row_keyboard( + "update_prompt", + ("yes", "✓ 确认", "已确认", f"{UPDATE_PROMPT_PREFIX}y", 1), + ("no", "✗ 取消", "已取消", f"{UPDATE_PROMPT_PREFIX}n", 0)) # ── ApprovalRequest + text builder ── From 7b6f60b4f9a0103cc99f11caed2e343849e3d288 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:46:51 -0700 Subject: [PATCH 17/22] =?UTF-8?q?refactor(gateway/platforms):=20runs/room?= =?UTF-8?q?=20=E2=80=94=20fold=20=5Femit=5Fto=5Fstream=20into=20=5Fmark=5F?= =?UTF-8?q?run=5Fevent,=20unify=20room-grant=20error=20builder,=20inline?= =?UTF-8?q?=20sweep=20filters,=20tighten=20layouts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gateway/platforms/api_server_room_dispatch.py | 3 +- gateway/platforms/api_server_room_grants.py | 50 +++----- .../platforms/api_server_run_idempotency.py | 13 +- gateway/platforms/api_server_runs.py | 116 +++++++----------- 4 files changed, 64 insertions(+), 118 deletions(-) diff --git a/gateway/platforms/api_server_room_dispatch.py b/gateway/platforms/api_server_room_dispatch.py index 38f56a8da8..5c755a3ceb 100644 --- a/gateway/platforms/api_server_room_dispatch.py +++ b/gateway/platforms/api_server_room_dispatch.py @@ -63,8 +63,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"]: """Validate and normalize a scoped RoomLink dispatch request.""" - _openai_error = _api_server._openai_error - room_token = self._room_grant_token(request) + _openai_error, room_token = _api_server._openai_error, self._room_grant_token(request) if not room_token: return body, None if not isinstance(body, dict) or set(body) - {"input", "hosted_room_dispatch"}: diff --git a/gateway/platforms/api_server_room_grants.py b/gateway/platforms/api_server_room_grants.py index 7cf55ec459..1c2eab3e34 100644 --- a/gateway/platforms/api_server_room_grants.py +++ b/gateway/platforms/api_server_room_grants.py @@ -2,7 +2,7 @@ import time import uuid -from typing import Any +from typing import Any, Optional try: from aiohttp import web @@ -25,18 +25,13 @@ def _require_unchanged_execution_policy(claims: dict[str, Any], execution_policy raise RoomGrantReauthorizationRequired("room execution policy changed") -def _invalid_room_grant(_openai_error) -> "web.Response": - return _json_error( - _openai_error, "Room authorization is invalid or expired.", - err_type="gateway_auth_error", code="invalid_room_grant", status=401) - - -def _room_grant_error_response(exc: Exception, *, _openai_error) -> "web.Response": - if not isinstance(exc, RoomGrantReauthorizationRequired): - return _invalid_room_grant(_openai_error) - return _json_error( - _openai_error, "Room authorization needs to be renewed.", - err_type="gateway_auth_error", code="room_reauthorization_required", status=403) +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 + else: + message, code, status = "Room authorization is invalid or expired.", "invalid_room_grant", 401 + return _json_error(_openai_error, message, err_type="gateway_auth_error", code=code, status=status) def _hard_expiry(claims: dict[str, Any]) -> float: @@ -57,8 +52,7 @@ def _local_target(claims: dict[str, Any] | None, _api_request_profile) -> tuple[ from gateway import hosted_rooms profile = _api_request_profile.get() or "default" installation_id = hosted_rooms.local_authority_gateway_id() - if claims is not None and ( - claims["target_profile"] != profile or claims["target_install_id"] != installation_id): + if claims is not None and (claims["target_profile"], claims["target_install_id"]) != (profile, installation_id): raise ValueError("room grant target does not match this profile") return profile, installation_id @@ -85,11 +79,8 @@ def _http_routes(self) -> list[tuple[str, str, Any]]: def _room_grant_token(request: "web.Request") -> str: - authorization = str(request.headers.get("Authorization") or "") - scheme, separator, token = authorization.partition(" ") - if not separator or scheme.lower() != "hermesroom": - return "" - return token.strip() + scheme, separator, token = str(request.headers.get("Authorization") or "").partition(" ") + return token.strip() if separator and scheme.lower() == "hermesroom" else "" def _room_grant_secret(self) -> bytes: @@ -156,13 +147,9 @@ async def _handle_room_member_invitation( except Exception as exc: return _json_error(_openai_error, str(exc), code="invalid_room_invitation", status=400) return web.json_response({ - "object": "hermes.room_member.invitation", - "grant": token, - "target_profile": profile, - "catalog": catalog, - "expires_at": float(claims["expires_at"]), - "status_expires_at": float(claims["status_expires_at"]), - }, status=201) + "object": "hermes.room_member.invitation", "grant": token, "target_profile": profile, + "catalog": catalog, "expires_at": float(claims["expires_at"]), + "status_expires_at": float(claims["status_expires_at"])}, status=201) async def _handle_room_member_capabilities( @@ -214,11 +201,8 @@ async def _handle_room_member_grant_refresh( except Exception as exc: return _room_grant_error_response(exc, _openai_error=_openai_error) return web.json_response({ - "object": "hermes.room_member.grant", - "grant": token, - "expires_at": now + dispatch_ttl, - "status_expires_at": hard_expiry, - "execution_policy": execution_policy}) + "object": "hermes.room_member.grant", "grant": token, "expires_at": now + dispatch_ttl, + "status_expires_at": hard_expiry, "execution_policy": execution_policy}) async def _handle_room_member_grant_revoke( @@ -240,5 +224,5 @@ async def _handle_room_member_grant_revoke( hosted_rooms.revoke_room_grant_scope( hosted_rooms.default_db_path(), claims=claims, expires_at=_hard_expiry(claims)) except Exception: - return _invalid_room_grant(_openai_error) + return _room_grant_error_response(_openai_error=_openai_error) return web.json_response({"object": "hermes.room_member.grant.revocation", "revoked": True}) diff --git a/gateway/platforms/api_server_run_idempotency.py b/gateway/platforms/api_server_run_idempotency.py index 92271c24f4..2d47e78a25 100644 --- a/gateway/platforms/api_server_run_idempotency.py +++ b/gateway/platforms/api_server_run_idempotency.py @@ -60,7 +60,6 @@ class RunIdempotencyStore: def durable(self) -> bool: """Whether reservations survive this process.""" return self._db_path is not None - def __init__(self, db_path: str = None): if db_path is None: try: @@ -106,9 +105,7 @@ class RunIdempotencyStore: self._tighten_permissions() def _tighten_permissions(self) -> None: - if not self._db_path: - return - for suffix in ("", "-wal", "-shm"): + for suffix in ("", "-wal", "-shm") if self._db_path else (): candidate = Path(self._db_path + suffix) try: if candidate.exists(): @@ -146,9 +143,8 @@ class RunIdempotencyStore: "scope,idempotency_key,fingerprint,run_id,status_json," "owner_pid,owner_started,retention_until,created_at,updated_at" ") VALUES(?,?,?,?,?,?,?,?,?,?)", - ( - scope, key, fingerprint, run_id, encoded, - int(owner_pid or 0), int(owner_started or 0), retention_until, now, now)) + (scope, key, fingerprint, run_id, encoded, int(owner_pid or 0), int(owner_started or 0), + retention_until, now, now)) self._conn.commit() return "created", _record(run_id, encoded, owner_pid, owner_started, now) | {"status": status} @@ -182,8 +178,7 @@ class RunIdempotencyStore: terminal = False if terminal: self._conn.execute( - "DELETE FROM run_idempotency WHERE scope=? AND idempotency_key=?", - (stale_scope, stale_key)) + "DELETE FROM run_idempotency WHERE scope=? AND idempotency_key=?", (stale_scope, stale_key)) def status_for_run(self, scope: str, run_id: str, *, retention_until: float = 0) -> dict[str, Any] | None: """Load one durable run status inside its authenticated scope.""" diff --git a/gateway/platforms/api_server_runs.py b/gateway/platforms/api_server_runs.py index d5050adba2..b7dcfbb3b7 100644 --- a/gateway/platforms/api_server_runs.py +++ b/gateway/platforms/api_server_runs.py @@ -24,15 +24,13 @@ from gateway.platforms.api_server_run_idempotency import TERMINAL_STATUSES logger = logging.getLogger("gateway.platforms.api_server") _ROOM_RETENTION_REQUEST_KEY = ( - RequestKey("hermes.room_run_retention_until", float) - if RequestKey is not None + RequestKey("hermes.room_run_retention_until", float) if RequestKey is not None else "hermes.room_run_retention_until") # Forwarded subagent lifecycle fields; free-text ones are secret-redacted. _SUBAGENT_EVENT_KEYS = ( - "goal", "task_count", "task_index", "subagent_id", "child_session_id", - "delegation_id", "parent_id", "depth", "model", "tool_count", "status", - "summary", "duration_seconds", "input_tokens", "output_tokens", - "reasoning_tokens", "api_calls", "cost_usd", "files_read", "files_written", + "goal", "task_count", "task_index", "subagent_id", "child_session_id", "delegation_id", "parent_id", + "depth", "model", "tool_count", "status", "summary", "duration_seconds", "input_tokens", + "output_tokens", "reasoning_tokens", "api_calls", "cost_usd", "files_read", "files_written", "output_tail") _SUBAGENT_TEXT_KEYS = ("goal", "summary", "output_tail") # Terminal usage payload: (wire key, agent attribute), in wire order. @@ -115,11 +113,9 @@ def _idempotency_capabilities(self, *, store_type) -> dict[str, Any]: def _close_run_state(self) -> None: - store = getattr(self, "_run_idempotency_store", None) - if store is None: - return try: - store.close() + if getattr(self, "_run_idempotency_store", None) is not None: + self._run_idempotency_store.close() except Exception: logger.debug("Failed to close run idempotency store for %s", self.name, exc_info=True) @@ -154,13 +150,11 @@ def _make_run_event_callback(self, run_id: str, loop: "asyncio.AbstractEventLoop def _push(event: Dict[str, Any]) -> None: self._set_run_status( - run_id, self._run_statuses.get(run_id, {}).get("status", "running"), last_event=event.get("event") - ) + run_id, self._run_statuses.get(run_id, {}).get("status", "running"), last_event=event.get("event")) q = self._run_streams.get(run_id) - if q is None: - return - with suppress(Exception): - loop.call_soon_threadsafe(q.put_nowait, event) + if q is not None: + with suppress(Exception): + loop.call_soon_threadsafe(q.put_nowait, event) def _callback(event_type: str, tool_name: str = None, preview: str = None, args=None, **kwargs): # _thinking / subagent.tool / subagent_progress are deliberately dropped (UI noise); @@ -174,12 +168,10 @@ def _make_run_event_callback(self, run_id: str, loop: "asyncio.AbstractEventLoop event["preview"] = redact_sensitive_text(str(preview), force=True) for key in _SUBAGENT_EVENT_KEYS: value = kwargs.get(key) - if value is None: - continue - # Free text may carry child tool output: force secret redaction on this public stream. - if key in _SUBAGENT_TEXT_KEYS and isinstance(value, str): - value = redact_sensitive_text(value, force=True) - event[key] = value + if value is not None: + # Free text may carry child tool output: force secret redaction on this public stream. + redact = key in _SUBAGENT_TEXT_KEYS and isinstance(value, str) + event[key] = redact_sensitive_text(value, force=True) if redact else value _push(event) return _callback @@ -265,10 +257,9 @@ def _resolve_conversation_history( if raw_history: if not isinstance(raw_history, list): return [], instructions, None, _json_error( - _openai_error, "'conversation_history' must be an array of message objects", status=400 - ) + _openai_error, "'conversation_history' must be an array of message objects", status=400) for i, entry in enumerate(raw_history): - if not isinstance(entry, dict) or "role" not in entry or "content" not in entry: + if not isinstance(entry, dict) or {"role", "content"} - set(entry): return [], instructions, None, _json_error( _openai_error, f"conversation_history[{i}] must have 'role' and 'content' fields", status=400) @@ -288,9 +279,8 @@ def _resolve_conversation_history( if isinstance(msg, dict) and msg.get("role") and msg.get("content"): content = msg["content"] if isinstance(content, list): # flatten multi-part content blocks to text - content = " ".join( - part.get("text", "") for part in content - if isinstance(part, dict) and part.get("type") == "text") + content = " ".join(p.get("text", "") for p in content + if isinstance(p, dict) and p.get("type") == "text") conversation_history.append({"role": msg["role"], "content": str(content)}) return conversation_history, instructions, stored_session_id, None @@ -335,8 +325,7 @@ class _RunLaunch: @property def approval_session_key(self) -> str: - # Approval queues are isolated per run: session ids are conversation scopes, not - # authorization namespaces, so resolving one run's approval must not unblock another's. + # Isolated per run: session ids are conversation scopes, not authorization namespaces. return self.run_id def put_event(self, event: Optional[Dict]) -> None: @@ -345,26 +334,17 @@ class _RunLaunch: self.queue.put_nowait(event) -def _optional_dict(body: Any, key: str) -> Optional[dict]: - value = body.get(key) if isinstance(body, dict) else None - return value if isinstance(value, dict) else None - - def _forget_run(self, run_id: str, *tables) -> None: """Drop *run_id* from the given run-keyed dicts/sets, then release its owner stamp.""" for table in tables: - if isinstance(table, set): - table.discard(run_id) - else: - table.pop(run_id, None) + (table.discard if isinstance(table, set) else lambda k: table.pop(k, None))(run_id) self._release_run_owner_if_forgotten(run_id) def _retire_live_run(self, run_id: str) -> None: """Retire agent/task/approval control state once the executor-backed task is done.""" - _forget_run( - self, run_id, self._active_run_agents, self._active_run_tasks, - self._run_approval_sessions, self._stopping_run_ids) + _forget_run(self, run_id, self._active_run_agents, self._active_run_tasks, self._run_approval_sessions, + self._stopping_run_ids) def _drop_run_transport(self, run_id: str) -> None: @@ -385,8 +365,10 @@ async def _handle_runs(self, request: "web.Request", *, _api_server) -> "web.Res body, room_error = await self._normalize_room_dispatch(request, body) if room_error is not None: return room_error - room_dispatch = _optional_dict(body, "hosted_room_dispatch") - room_execution_policy = _optional_dict(body, "_room_execution_policy") + room_dispatch, room_execution_policy = ( + v if isinstance(v, dict) else None for v in ( + (body.get("hosted_room_dispatch"), body.get("_room_execution_policy")) + if isinstance(body, dict) else (None, None))) idempotency_key = request.headers.get("Idempotency-Key", "").strip() if len(idempotency_key) > 255 or any(ord(ch) < 33 or ord(ch) > 126 for ch in idempotency_key): return _json_error( @@ -443,10 +425,8 @@ async def _handle_runs(self, request: "web.Request", *, _api_server) -> "web.Res # An explicit or chained session owns its routing key and is never rebound to the header. _declared_selected = not session_id and bool(gateway_session_key) session_id = session_id or self._declared_conversation_session(gateway_session_key) or run_id - q: "asyncio.Queue[Optional[Dict]]" = asyncio.Queue() - created_at = time.time() - self._run_streams[run_id] = q - self._run_streams_created[run_id] = created_at + q = self._run_streams[run_id] = asyncio.Queue() + created_at = self._run_streams_created[run_id] = time.time() self._run_approval_sessions[run_id] = run_id # approval session key (see _RunLaunch) initial_status = self._set_run_status( run_id, "queued", created_at=created_at, session_id=session_id, model=body.get("model", self._model_name)) @@ -465,22 +445,16 @@ async def _handle_runs(self, request: "web.Request", *, _api_server) -> "web.Res self, run_id, q, session_id, gateway_session_key, _declared_selected, user_message, conversation_history, agent_kwargs=dict( - ephemeral_system_prompt=instructions, session_id=session_id, - gateway_session_key=gateway_session_key, - requested_model=agent_overrides.get("requested_model"), - requested_provider=agent_overrides.get("requested_provider"), - model_options=agent_overrides.get("model_options"), route=route, - room_dispatch=room_dispatch, room_execution_policy=room_execution_policy), + ephemeral_system_prompt=instructions, session_id=session_id, gateway_session_key=gateway_session_key, + route=route, room_dispatch=room_dispatch, room_execution_policy=room_execution_policy, + **{k: agent_overrides.get(k) for k in ("requested_model", "requested_provider", "model_options")}), request_profile=_api_server._api_request_profile.get(), browser_control_principal=_api_server._api_request_browser_control_principal.get(), browser_control_transport_family=_api_server._api_request_browser_control_transport_family.get()) self._activate_admitted_request() - task = asyncio.create_task(_execute_run(self, launch, _api_server=_api_server)) - self._active_run_tasks[run_id] = task - try: + task = self._active_run_tasks[run_id] = asyncio.create_task(_execute_run(self, launch, _api_server=_api_server)) + with suppress(TypeError): self._background_tasks.add(task) # tracked for shutdown drain - except TypeError: - pass if hasattr(task, "add_done_callback"): task.add_done_callback(self._background_tasks.discard) return _accepted_response(run_id, "started", gateway_session_key, replayed=False) @@ -597,8 +571,7 @@ async def _execute_run(self, run: _RunLaunch, *, _api_server) -> None: _finish("failed", error=_redact_api_error_text(result.get("error") or "agent run failed")) else: # Undelivered steer text rides on the terminal event/status for client replay. - pending_steer = result.get("pending_steer") - extra = {"pending_steer": pending_steer} if pending_steer else {} + extra = {"pending_steer": result["pending_steer"]} if result.get("pending_steer") else {} _finish("completed", extra, output=result.get("final_response", ""), usage=usage) except asyncio.CancelledError: _finish("cancelled") @@ -646,7 +619,7 @@ def _request_owns_run(self, request: "web.Request", run_id: str) -> bool: return self._run_idempotency_store.owns_run(scope, run_id) -def _load_owned_run(self, request: "web.Request", *, _api_server, permission: Optional[str], active_fallback: bool): +def _load_owned_run(self, request, *, _api_server, permission: Optional[str], active_fallback: bool): """Authenticate (*permission* -> room-grant aware; ``None`` -> API key only) and resolve ``(run_id, status, agent, task, error)``; *active_fallback* reports a live in-process run without pollable status as ``running`` instead of 404.""" @@ -713,17 +686,13 @@ async def _handle_run_events(self, request: "web.Request", *, _api_server) -> "w return response -def _emit_to_stream(self, run_id: str, event: Dict[str, Any]) -> None: +def _mark_run_event(self, run_id: str, name: str, **fields: Any) -> None: + """Record a control-plane event on the run status and (best effort) its SSE stream.""" + self._set_run_status(run_id, "running", last_event=name) q = self._run_streams.get(run_id) if q is not None: with suppress(Exception): - q.put_nowait(event) - - -def _mark_run_event(self, run_id: str, name: str, **fields: Any) -> None: - """Record a control-plane event on the run status and its SSE stream.""" - self._set_run_status(run_id, "running", last_event=name) - _emit_to_stream(self, run_id, _run_event(run_id, name, **fields)) + q.put_nowait(_run_event(run_id, name, **fields)) _APPROVAL_CHOICE_ALIASES = {"approve": "once", "approved": "once", "allow": "once"} @@ -849,10 +818,9 @@ def _sweep_orphaned_runs_once(self, now: Optional[float] = None) -> None: """Expire old SSE buffers without treating transport age as run age.""" if now is None: now = time.time() - stale = [ - run_id for run_id, created_at in list(self._run_streams_created.items()) - if now - created_at > self._RUN_STREAM_TTL and run_id not in self._run_stream_subscribers] - for run_id in stale: + for run_id, created_at in list(self._run_streams_created.items()): + if now - created_at <= self._RUN_STREAM_TTL or run_id in self._run_stream_subscribers: + continue logger.debug("[api_server] sweeping expired run transport %s", run_id) task = self._active_run_tasks.get(run_id) # Transport TTL bounds buffering; live control state survives until the task returns. From d9756176b57054892c155230c740b24a008bbdfd Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:47:24 -0700 Subject: [PATCH 18/22] refactor(gateway/platforms): qqbot connect/_reconnect share _open_gateway_ws --- gateway/platforms/qqbot/adapter.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/gateway/platforms/qqbot/adapter.py b/gateway/platforms/qqbot/adapter.py index 6a4ea923c8..67f0309e8f 100644 --- a/gateway/platforms/qqbot/adapter.py +++ b/gateway/platforms/qqbot/adapter.py @@ -201,10 +201,7 @@ class QQAdapter(BasePlatformAdapter): timeout=30.0, follow_redirects=True, event_hooks={"response": [_ssrf_redirect_guard]}, limits=platform_httpx_limits()) - await self._ensure_token() - gateway_url = await self._get_gateway_url() - logger.info("[%s] Gateway URL: %s", self._log_tag, gateway_url) - await self._open_ws(gateway_url) + await self._open_gateway_ws(log_url=True) self._listen_task = asyncio.create_task(self._listen_loop()) self._heartbeat_task = asyncio.create_task(self._heartbeat_loop()) self._mark_connected() @@ -300,6 +297,14 @@ class QQAdapter(BasePlatformAdapter): # ── WebSocket lifecycle ── + async def _open_gateway_ws(self, *, log_url: bool = False) -> None: + """Token → gateway URL → WebSocket (shared by connect and _reconnect).""" + await self._ensure_token() + gateway_url = await self._get_gateway_url() + if log_url: + logger.info("[%s] Gateway URL: %s", self._log_tag, gateway_url) + await self._open_ws(gateway_url) + async def _open_ws(self, gateway_url: str) -> None: await self._close_ws() # Honor proxy env vars for the WebSocket (WSL setups need this). @@ -412,9 +417,7 @@ class QQAdapter(BasePlatformAdapter): self._heartbeat_interval = 30.0 # reset until Hello try: - await self._ensure_token() - gateway_url = await self._get_gateway_url() - await self._open_ws(gateway_url) + await self._open_gateway_ws() self._mark_connected() logger.info("[%s] Reconnected", self._log_tag) return True From 4b447a4d26f3e630720cec27fb1bff5cad250e60 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:49:33 -0700 Subject: [PATCH 19/22] refactor(gateway/platforms): qqbot utils/onboard compaction (coerce_list one path, BindStatus tuple) --- gateway/platforms/qqbot/onboard.py | 13 +++---------- gateway/platforms/qqbot/utils.py | 13 ++++--------- 2 files changed, 7 insertions(+), 19 deletions(-) diff --git a/gateway/platforms/qqbot/onboard.py b/gateway/platforms/qqbot/onboard.py index 856be442da..0215d41b0b 100644 --- a/gateway/platforms/qqbot/onboard.py +++ b/gateway/platforms/qqbot/onboard.py @@ -21,10 +21,7 @@ logger = logging.getLogger(__name__) class BindStatus(IntEnum): """Status codes returned by ``_poll_bind_result``.""" - NONE = 0 - PENDING = 1 - COMPLETED = 2 - EXPIRED = 3 + NONE, PENDING, COMPLETED, EXPIRED = 0, 1, 2, 3 try: @@ -73,12 +70,8 @@ def _create_bind_task(timeout: float = ONBOARD_API_TIMEOUT) -> Tuple[str, str]: def _poll_bind_result(task_id: str, timeout: float = ONBOARD_API_TIMEOUT) -> Tuple[BindStatus, str, str, str]: """Poll *task_id*; returns ``(status, bot_appid, bot_encrypt_secret, user_openid)``.""" - data = _portal_post(ONBOARD_POLL_PATH, {"task_id": task_id}, timeout, "poll_bind_result failed") - d = data.get("data", {}) - return( - BindStatus(d.get("status", 0)), str(d.get("bot_appid", "")), - d.get("bot_encrypt_secret", ""), d.get("user_openid", ""), - ) + d = _portal_post(ONBOARD_POLL_PATH, {"task_id": task_id}, timeout, "poll_bind_result failed").get("data", {}) + return BindStatus(d.get("status", 0)), str(d.get("bot_appid", "")), d.get("bot_encrypt_secret", ""), d.get("user_openid", "") def build_connect_url(task_id: str) -> str: diff --git a/gateway/platforms/qqbot/utils.py b/gateway/platforms/qqbot/utils.py index a548d3f966..0c67df45c3 100644 --- a/gateway/platforms/qqbot/utils.py +++ b/gateway/platforms/qqbot/utils.py @@ -21,10 +21,8 @@ def _get_hermes_version() -> str: def build_user_agent() -> str: """``QQBotAdapter/ (Python/; ; Hermes/)``.""" v = sys.version_info - return ( - f"QQBotAdapter/{QQBOT_VERSION} (Python/{v.major}.{v.minor}.{v.micro}; " - f"{platform.system().lower()}; Hermes/{_get_hermes_version()})" - ) + return (f"QQBotAdapter/{QQBOT_VERSION} (Python/{v.major}.{v.minor}.{v.micro}; " + f"{platform.system().lower()}; Hermes/{_get_hermes_version()})") def get_api_headers() -> Dict[str, str]: @@ -37,8 +35,5 @@ def coerce_list(value: Any) -> List[str]: """Coerce a comma-separated string / list / tuple / set / scalar into a trimmed string list.""" if value is None: return [] - if isinstance(value, str): - return [item.strip() for item in value.split(",") if item.strip()] - if isinstance(value, (list, tuple, set)): - return [str(item).strip() for item in value if str(item).strip()] - return [str(value).strip()] if str(value).strip() else [] + items = value.split(",") if isinstance(value, str) else value if isinstance(value, (list, tuple, set)) else [value] + return [s for s in (str(item).strip() for item in items) if s] From f7a2a945b96b9750990a3df245774b6a065238b3 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 19:54:45 -0700 Subject: [PATCH 20/22] refactor(gateway/platforms): keep SQL statement text on its own lines in idempotency/room-dispatch stores --- gateway/platforms/api_server_room_dispatch.py | 4 ++-- gateway/platforms/api_server_run_idempotency.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/gateway/platforms/api_server_room_dispatch.py b/gateway/platforms/api_server_room_dispatch.py index 5c755a3ceb..44f6ed940c 100644 --- a/gateway/platforms/api_server_room_dispatch.py +++ b/gateway/platforms/api_server_room_dispatch.py @@ -41,8 +41,8 @@ async def _ensure_hosted_member_session(self, dispatch: Any) -> str: "Another group already uses this room title on the target gateway. " "Rename or migrate that group before retrying.") conn.execute( - "INSERT INTO sessions(id, source, title, hidden, started_at) " - "VALUES(?, 'bot_room', ?, 1, ?)", (session_id, clean_title, time.time())) + "INSERT INTO sessions(id, source, title, hidden, started_at) VALUES(?, 'bot_room', ?, 1, ?)", + (session_id, clean_title, time.time())) return session_id return await asyncio.to_thread(db._execute_write, atomic) diff --git a/gateway/platforms/api_server_run_idempotency.py b/gateway/platforms/api_server_run_idempotency.py index 2d47e78a25..64d584d7b1 100644 --- a/gateway/platforms/api_server_run_idempotency.py +++ b/gateway/platforms/api_server_run_idempotency.py @@ -189,7 +189,8 @@ class RunIdempotencyStore: self._conn.commit() row = self._conn.execute( "SELECT status_json, owner_pid, owner_started, updated_at " - "FROM run_idempotency WHERE scope=? AND run_id=?", (scope, run_id)).fetchone() + "FROM run_idempotency WHERE scope=? AND run_id=?", + (scope, run_id)).fetchone() if row is None: return None return {k: v for k, v in _record(None, *row).items() if k != "run_id"} @@ -207,8 +208,7 @@ class RunIdempotencyStore: def owns_run(self, scope: str, run_id: str) -> bool: with self._lock: row = self._conn.execute( - "SELECT 1 FROM run_idempotency WHERE scope=? AND run_id=?", (scope, run_id) - ).fetchone() + "SELECT 1 FROM run_idempotency WHERE scope=? AND run_id=?", (scope, run_id)).fetchone() return row is not None def update_status(self, run_id: str, status: Dict[str, Any]) -> None: From 82071a6b7cb6528b3a665324fbd79c1d3c5cec72 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:04:52 -0700 Subject: [PATCH 21/22] refactor(gateway/platforms): qqbot _upload_local_file returns response only; header/retry compaction --- gateway/platforms/qqbot/adapter.py | 32 +++++++++++------------------- 1 file changed, 12 insertions(+), 20 deletions(-) diff --git a/gateway/platforms/qqbot/adapter.py b/gateway/platforms/qqbot/adapter.py index 67f0309e8f..5c10c86292 100644 --- a/gateway/platforms/qqbot/adapter.py +++ b/gateway/platforms/qqbot/adapter.py @@ -998,8 +998,8 @@ class QQAdapter(BasePlatformAdapter): return {"Authorization": f"QQBot {self._access_token}"} if self._access_token else {} async def _stt_voice_attachment( - self, url: str, content_type: str, filename: str, *, - asr_refer_text: Optional[str] = None, voice_wav_url: Optional[str] = None) -> Optional[str]: + self, url: str, content_type: str, filename: str, *, asr_refer_text: Optional[str] = None, + voice_wav_url: Optional[str] = None) -> Optional[str]: """Transcribe a voice attachment. Priority: QQ's free ``asr_refer_text`` → STT on ``voice_wav_url`` (pre-converted WAV, no SILK decode) → STT on the original URL (SILK→WAV). Returns the transcript or None.""" @@ -1283,10 +1283,7 @@ class QQAdapter(BasePlatformAdapter): async def _auth_headers(self) -> Dict[str, str]: """JSON REST headers with a fresh bot token.""" token = await self._ensure_token() - return { - "Authorization": f"QQBot {token}", - "Content-Type": "application/json", - "User-Agent": build_user_agent()} + return {"Authorization": f"QQBot {token}", "Content-Type": "application/json", "User-Agent": build_user_agent()} async def _upload_media( self, target_type: str, target_id: str, file_type: int, url: Optional[str] = None, @@ -1300,13 +1297,11 @@ class QQAdapter(BasePlatformAdapter): body["file_data"] = file_data if file_type == MEDIA_TYPE_FILE and file_name: body["file_name"] = file_name - for attempt in range(3): # retry transient upload failures try: return await self._api_request("POST", path, body, timeout=FILE_UPLOAD_TIMEOUT) except RuntimeError as exc: - err_msg = str(exc) - if attempt == 2 or any(kw in err_msg for kw in ("400", "401", "Invalid", "timeout", "Timeout")): + if attempt == 2 or any(kw in str(exc) for kw in ("400", "401", "Invalid", "timeout", "Timeout")): raise await asyncio.sleep(1.5 * (attempt + 1)) @@ -1368,8 +1363,7 @@ class QQAdapter(BasePlatformAdapter): return await sender(chat_id, content, reply_to) except Exception as exc: last_exc = exc - err = str(exc).lower() - if any(k in err for k in self._PERMANENT_SEND_ERRORS + ("bad request",)): + if any(k in str(exc).lower() for k in self._PERMANENT_SEND_ERRORS + ("bad request",)): break # permanent — don't retry if attempt < 2: delay = 1.0 * (2 ** attempt) @@ -1550,7 +1544,7 @@ class QQAdapter(BasePlatformAdapter): chat_type, chat_id, file_type, url=media_source, srv_send_msg=False, file_name=resolved_name if file_type == MEDIA_TYPE_FILE else None) else: - _, upload = await self._upload_local_file(chat_type, chat_id, media_source, file_type, file_name) + upload = await self._upload_local_file(chat_type, chat_id, media_source, file_type, file_name) file_info = upload.get("file_info") or (upload.get("data", {}) or {}).get("file_info") if not file_info: @@ -1581,11 +1575,10 @@ class QQAdapter(BasePlatformAdapter): async def _upload_local_file( self, chat_type: str, chat_id: str, media_source: str, file_type: int, file_name: Optional[str], - ) -> Tuple[str, Dict[str, Any]]: - """Chunked-upload a local file; returns ``(resolved_name, complete_response)`` - whose ``file_info`` goes into the RichMedia body. Raises UploadDailyLimitExceededError / - UploadFileTooLargeError from the uploader, ValueError for placeholder paths like - ````, FileNotFoundError.""" + ) -> Dict[str, Any]: + """Chunked-upload a local file; returns the complete response whose ``file_info`` goes + into the RichMedia body. Raises UploadDailyLimitExceededError / UploadFileTooLargeError + from the uploader, ValueError for placeholder paths like ````, FileNotFoundError.""" client = self._require_http_client() local_path = Path(media_source).expanduser() if not local_path.is_absolute(): @@ -1595,11 +1588,10 @@ class QQAdapter(BasePlatformAdapter): raise ValueError(f"Invalid media source (looks like a placeholder): {media_source!r}") raise FileNotFoundError(f"Media file not found: {local_path}") - resolved_name = file_name or local_path.name uploader = ChunkedUploader(api_request=self._api_request, http_put=client.put, log_tag=self._log_tag) - return resolved_name, await uploader.upload( + return await uploader.upload( chat_type=chat_type, target_id=chat_id, file_path=str(local_path), file_type=file_type, - file_name=resolved_name) + file_name=file_name or local_path.name) # ── Typing indicator ── From c161f62f66629224f60f802238bd6f4fe4195f9f Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:08:06 -0700 Subject: [PATCH 22/22] refactor(gateway/platforms): qqbot keyboards/constants/crypto micro-compaction --- gateway/platforms/qqbot/constants.py | 4 +--- gateway/platforms/qqbot/crypto.py | 3 +-- gateway/platforms/qqbot/keyboards.py | 6 ++---- 3 files changed, 4 insertions(+), 9 deletions(-) diff --git a/gateway/platforms/qqbot/constants.py b/gateway/platforms/qqbot/constants.py index e4fec356d7..8a203dea6a 100644 --- a/gateway/platforms/qqbot/constants.py +++ b/gateway/platforms/qqbot/constants.py @@ -4,9 +4,7 @@ from __future__ import annotations import os -# Bump on functional changes to the adapter package. -QQBOT_VERSION = "1.1.0" - +QQBOT_VERSION = "1.1.0" # bump on functional changes to the adapter package # Portal domain is overridable (QQ_PORTAL_HOST) for corporate proxies / test environments. PORTAL_HOST = os.getenv("QQ_PORTAL_HOST", "q.qq.com") diff --git a/gateway/platforms/qqbot/crypto.py b/gateway/platforms/qqbot/crypto.py index af3b5ff854..53e1a1f636 100644 --- a/gateway/platforms/qqbot/crypto.py +++ b/gateway/platforms/qqbot/crypto.py @@ -16,6 +16,5 @@ def decrypt_secret(encrypted_base64: str, key_base64: str) -> str: """Decrypt ``bot_encrypt_secret`` (base64 of ``IV(12) ‖ ciphertext ‖ tag(16)``) to a UTF-8 string.""" from cryptography.hazmat.primitives.ciphers.aead import AESGCM - raw = base64.b64decode(encrypted_base64) - # AESGCM expects ciphertext + tag concatenated. + raw = base64.b64decode(encrypted_base64) # AESGCM expects ciphertext + tag concatenated return AESGCM(base64.b64decode(key_base64)).decrypt(raw[:12], raw[12:], None).decode("utf-8") diff --git a/gateway/platforms/qqbot/keyboards.py b/gateway/platforms/qqbot/keyboards.py index 7bf02d9486..be608cb9b0 100644 --- a/gateway/platforms/qqbot/keyboards.py +++ b/gateway/platforms/qqbot/keyboards.py @@ -27,9 +27,7 @@ def _to_dict(value: Any) -> Any: """Serialize a dataclass tree in field-declaration order (the wire shape).""" if is_dataclass(value): return {f.name: _to_dict(getattr(value, f.name)) for f in fields(value)} - if isinstance(value, list): - return [_to_dict(v) for v in value] - return value + return [_to_dict(v) for v in value] if isinstance(value, list) else value class _Serializable: @@ -91,7 +89,7 @@ class InlineKeyboard(_Serializable): def parse_approval_button_data(button_data: str) -> Optional[tuple[str, str]]: """Parse approval ``button_data`` into ``(session_key, decision)`` or ``None``.""" m = _APPROVAL_DATA_RE.match(button_data or "") - return (m.group(1), m.group(2)) if m else None + return m.groups() if m else None def parse_update_prompt_button_data(button_data: str) -> Optional[str]: