Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license

This commit is contained in:
2026-09-05 13:26:46 +03:00
commit 03634b1ca3
11340 changed files with 3442369 additions and 0 deletions
+272
View File
@@ -0,0 +1,272 @@
"""Wake an existing agent session from a background completion event.
Two delivery strategies, selected by the target adapter's
``supports_async_delivery`` capability flag:
* Push-capable adapters (telegram, discord, plugin platforms, ...): inject a
synthetic ``MessageEvent(internal=True)`` through ``adapter.handle_message``
— the pre-existing wake path, preserved exactly.
* Stateless request/response adapters (the API server,
``supports_async_delivery = False``): ``handle_message`` would run the wake
turn under a ``build_session_key()``-derived key
(``agent:main:api_server:group:<sid>``) that NEVER matches the raw
``X-Hermes-Session-Id`` key real gateway/HQ turns run under
(``_bind_api_server_session``), so the wake lands in a parallel, invisible
session. Instead we self-POST ``/v1/chat/completions`` on the in-pod API
server with the raw session id in the ``X-Hermes-Session-Id`` header — the
exact entry point real turns use — so the wake turn resumes the REAL
session, with full history, and its result is visible the next time the
client polls/reopens the conversation.
Async-delegation completions are the exception on the stateless path
(#85957): after the parent turn ends (``finish_reason=stop``, SSE
``event.complete``) the CLIENT owns the next turn, so a completion must never
be self-POSTed as a new ``role=user`` prompt — that starts an unauthorized
agent turn that can blow through a pending human-confirmation gate. Instead
``persist_delegation_delivery`` writes the completion into the session
transcript as a durable DELIVERY row (``role=user`` +
``display_kind="async_delegation_complete"`` — the same bookkeeping shape the
TUI/desktop pollers use), so clients polling
``GET /api/sessions/{id}/messages`` see the result immediately and the next
REAL client turn carries it as context, without any model turn running.
Failures RAISE (after bounded retries on transient errors) so callers can
rewind cursors / retry instead of silently losing the event.
"""
from __future__ import annotations
import asyncio
import logging
from typing import Any, Optional
logger = logging.getLogger(__name__)
# A wake self-post runs the entire agent turn synchronously (stream=false);
# generous ceiling so long tool-using turns aren't killed mid-flight.
WAKE_TURN_TIMEOUT_SECONDS = 600.0
# Backoff delays between retries on transient failures (429 concurrency cap,
# connection errors). The API server has no per-session lock — concurrent
# turns on one session are last-writer-wins — but it DOES enforce a global
# max_concurrent_runs cap via HTTP 429, which is worth waiting out.
_RETRY_DELAYS_SECONDS = (2.0, 5.0, 10.0)
def adapter_supports_push(adapter: Any) -> bool:
"""Whether this adapter can push a message to the user after a turn ends.
Mirrors ``gateway.session_context.async_delivery_supported`` but reads the
capability off the adapter class (``supports_async_delivery``) instead of
the request-scoped contextvar — background watchers run outside any bound
session context. Adapters that don't declare the flag are push-capable.
"""
return bool(getattr(adapter, "supports_async_delivery", True))
async def deliver_wake(
adapter: Any,
*,
text: str,
session_id: str = "",
source: Any = None,
) -> None:
"""Deliver a wake turn to the session behind ``adapter``.
``session_id`` is the RAW session id (the ``X-Hermes-Session-Id`` value /
``state.db`` key) — required for non-push adapters. ``source`` is the
``SessionSource`` used to build the synthetic event — required for
push-capable adapters.
Raises on failure (bad arguments, exhausted retries, HTTP error) so the
caller can rewind/retry instead of treating the wake as delivered.
"""
if adapter_supports_push(adapter):
if source is None:
raise ValueError(
"deliver_wake: push-capable adapter requires a SessionSource"
)
from gateway.platforms.base import MessageEvent, MessageType
synth_event = MessageEvent(
text=text,
message_type=MessageType.TEXT,
source=source,
internal=True,
)
await adapter.handle_message(synth_event)
return
if not session_id:
raise ValueError(
"deliver_wake: non-push adapter (supports_async_delivery=False) "
"requires the raw session id to self-post the wake turn"
)
await _self_post_chat_completion(adapter, text=text, session_id=session_id)
def _delegation_display_metadata(evt: dict) -> dict:
"""Display-only metadata for a persisted delegation delivery row.
Mirrors ``tui_gateway.server._async_delegation_display_metadata`` (the
same ``display_kind`` consumer contract) without importing the TUI stack
into the gateway.
"""
raw_results = evt.get("results")
results = [r for r in raw_results if isinstance(r, dict)] if isinstance(
raw_results, list
) else []
task_count = len(results) or 1
completed_count = sum(
1 for r in results if r.get("status") in {"completed", "success"}
)
failed_count = sum(
1 for r in results if r.get("status") in {"failed", "error"}
)
metadata = {
"delegation_id": str(evt.get("delegation_id") or ""),
"task_count": task_count,
"completed_count": completed_count or task_count - failed_count,
"failed_count": failed_count,
}
duration = evt.get("total_duration_seconds") or evt.get("duration_seconds")
if isinstance(duration, (int, float)):
metadata["duration_seconds"] = duration
return metadata
async def persist_delegation_delivery(
adapter: Any, *, text: str, session_id: str, evt: Optional[dict] = None
) -> None:
"""Persist an async-delegation completion as a durable DELIVERY row.
#85957: on stateless api_server sessions the client owns the turn after
``event.complete`` — a completion must never become a new ``role=user``
prompt via the self-post (that starts an unauthorized agent turn and can
cross a pending human-confirmation gate). Instead, append the completion
to the session transcript as a timeline bookkeeping row
(``display_kind="async_delegation_complete"`` + display metadata — the
exact shape the TUI/desktop delivery path persists), WITHOUT running any
agent turn. Clients polling ``GET /api/sessions/{id}/messages`` see it
immediately; the pre-request repair belt folds it into the next real
client turn as context.
Raises on failure so the caller can release the durable claim and retry
instead of losing the completion.
"""
if not session_id:
raise ValueError(
"persist_delegation_delivery: raw session id required to persist "
"the completion on the api_server session transcript"
)
ensure = getattr(adapter, "_ensure_session_db", None)
db: Any = await asyncio.to_thread(ensure) if callable(ensure) else None
if db is None:
raise RuntimeError(
"persist_delegation_delivery: api_server SessionDB unavailable — "
f"cannot persist completion for session {session_id}"
)
await asyncio.to_thread(
db.append_message,
session_id,
"user",
content=text,
display_kind="async_delegation_complete",
display_metadata=_delegation_display_metadata(evt or {}),
)
logger.info(
"async delegation completion persisted as delivery row for "
"api_server session %s (no wake turn)",
session_id,
)
async def _self_post_chat_completion(
adapter: Any, *, text: str, session_id: str
) -> None:
"""POST the wake text to the in-pod API server as a normal session turn.
Uses the adapter's own bind host/port/key (``ApiServerAdapter.__init__``).
Session continuation via ``X-Hermes-Session-Id`` is 403-gated on
``API_SERVER_KEY`` being configured, so a missing key is a hard error —
raise loudly rather than run the wake in a fresh fingerprint-derived
session nobody is looking at.
"""
import aiohttp
host = str(getattr(adapter, "_host", "") or "127.0.0.1")
if host in ("0.0.0.0", "::", "*"):
# Wildcard bind address — connect over loopback.
host = "127.0.0.1"
port = int(getattr(adapter, "_port", 0) or 8642)
api_key = str(getattr(adapter, "_api_key", "") or "")
if not api_key:
raise RuntimeError(
"wake self-post requires API_SERVER_KEY: session continuation via "
"X-Hermes-Session-Id is rejected (403) on an unauthenticated API "
"server, so the wake cannot reach the target session"
)
if ":" in host and not host.startswith("["):
host = f"[{host}]" # bare IPv6 literal
url = f"http://{host}:{port}/v1/chat/completions"
headers = {
"Authorization": f"Bearer {api_key}",
"X-Hermes-Session-Id": session_id,
}
payload = {
"model": str(getattr(adapter, "_model_name", "") or "hermes-agent"),
"messages": [{"role": "user", "content": text}],
"stream": False,
}
last_err: Optional[BaseException] = None
attempts = 1 + len(_RETRY_DELAYS_SECONDS)
for attempt in range(attempts):
if attempt:
await asyncio.sleep(_RETRY_DELAYS_SECONDS[attempt - 1])
try:
timeout = aiohttp.ClientTimeout(total=WAKE_TURN_TIMEOUT_SECONDS)
async with aiohttp.ClientSession(timeout=timeout) as http:
async with http.post(url, json=payload, headers=headers) as resp:
if resp.status == 429:
# Global concurrency cap (max_concurrent_runs) —
# transient; back off and retry.
last_err = RuntimeError(
f"wake self-post got HTTP 429 (concurrency cap) "
f"for session {session_id}"
)
logger.warning(
"%s; attempt %d/%d", last_err, attempt + 1, attempts
)
continue
if resp.status >= 400:
body = (await resp.text())[:300]
# Non-transient (auth/validation) — fail immediately.
raise RuntimeError(
f"wake self-post failed for session {session_id}: "
f"HTTP {resp.status}: {body}"
)
await resp.read()
logger.info(
"wake self-post delivered for session %s (attempt %d)",
session_id,
attempt + 1,
)
return
except (aiohttp.ClientError, asyncio.TimeoutError, OSError) as exc:
last_err = exc
logger.warning(
"wake self-post transient failure for session %s "
"(attempt %d/%d): %s",
session_id,
attempt + 1,
attempts,
exc,
)
continue
raise RuntimeError(
f"wake self-post gave up for session {session_id} after "
f"{attempts} attempts: {last_err}"
) from last_err