Ctrl K
ComunidadGuías…

claude_side_question.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""Claude Code's native /btw control on an already connected SDK client.

Claude Code 2.1.261 supports ``side_question`` and request-scoped
``control_cancel_request`` on its stream-json control channel. Python Agent SDK
0.2.130 does not wrap this control yet. This small compatibility adapter uses
the SDK's existing response router; it never consumes the main message stream,
submits a user turn, forks a session, or interrupts/disconnects the parent.

The caller must run on the client owner's event loop and hold its lifecycle
lease for this entire await. The native provider snapshots its current full
conversation (including tool results), denies tools, and skips transcript writes.
"""
from __future__ import annotations

import asyncio
from contextlib import suppress
import json
import re
import uuid
from typing import Any

from side_questions import (
    MAX_OUTPUT_BYTES,
    MAX_QUESTION_CHARS,
    REQUEST_TIMEOUT_SECONDS,
    SideQuestionError,
)


CONTEXT_NOTE = (
    "Uses Claude's native conversation context, including completed tool results. "
    "The reply currently being written may not be included. "
    "Side questions have no tools and do not enter the main conversation."
)
CANCEL_TIMEOUT_SECONDS = 5.0
_OWNED_REQUEST_ID = re.compile(r"agentsdock_side_[0-9a-f]{32}\Z")


def is_native_side_question_progress(message: Any) -> bool:
    """Keep only our native control progress out of the main-turn stream.

    Recognize the reserved request namespace even after cancellation removes
    its pending entry, since a late progress packet still belongs to the side
    request. Other native controls and ordinary system messages remain visible.
    """
    if isinstance(message, dict):
        if message.get("type") != "system":
            return False
        subtype, data = message.get("subtype"), message
    else:
        subtype, data = getattr(message, "subtype", None), getattr(message, "data", None)
    if subtype != "control_request_progress" or not isinstance(data, dict):
        return False
    request_id = data.get("request_id")
    return isinstance(request_id, str) and _OWNED_REQUEST_ID.fullmatch(request_id) is not None


def _native_query(client: Any) -> Any:
    """Fail closed if this SDK cannot route our separate native control."""
    query = getattr(client, "_query", None)
    if (
        query is None
        or not getattr(query, "is_streaming_mode", False)
        or not getattr(query, "_initialized", False)
        or getattr(query, "_closed", True)
        or not isinstance(getattr(query, "pending_control_responses", None), dict)
        or not isinstance(getattr(query, "pending_control_results", None), dict)
        or not callable(getattr(getattr(query, "transport", None), "write", None))
    ):
        raise SideQuestionError(503, "Claude native side questions require a connected compatible SDK session")
    return query


def _request(question: str, history: list[dict] | None) -> dict:
    if not isinstance(question, str) or not question.strip() or len(question) > MAX_QUESTION_CHARS:
        raise SideQuestionError(400, "Side questions must contain 1–8000 characters")
    try:
        question.encode("utf-8")
    except UnicodeEncodeError:
        raise SideQuestionError(400, "Side questions must contain valid Unicode text") from None
    history = [] if history is None else history
    if not isinstance(history, list) or len(history) > 20:
        raise SideQuestionError(400, "Native side history must contain at most 20 exchanges")
    pairs = []
    for item in history:
        if not isinstance(item, dict) or set(item) != {"question", "response"}:
            raise SideQuestionError(400, "Native side history must contain question/response pairs")
        for text in item.values():
            if not isinstance(text, str) or not text.strip():
                raise SideQuestionError(400, "Native side-history exchanges must contain text")
            try:
                text.encode("utf-8")
            except UnicodeEncodeError:
                raise SideQuestionError(400, "Native side history must contain valid Unicode text") from None
        pairs.append(dict(item))
    request = {"subtype": "side_question", "question": question}
    if pairs:
        request["history"] = pairs
    return request


def _answer(result: Any) -> dict:
    # Provider errors can contain paths, credentials or raw upstream content.
    # They are deliberately not returned to an HTTP caller.
    if isinstance(result, Exception):
        raise SideQuestionError(503, "Claude native side question failed or is unsupported by this CLI")
    if not isinstance(result, dict) or result.get("subtype") != "success":
        raise SideQuestionError(502, "Claude returned an invalid native side-question response")
    payload = result.get("response")
    if not isinstance(payload, dict):
        raise SideQuestionError(502, "Claude returned an invalid native side-question response")
    answer = payload.get("response")
    if payload.get("synthetic") is not False:
        raise SideQuestionError(502, "Claude could not answer this side question from its current context")
    if not isinstance(answer, str) or not answer.strip():
        raise SideQuestionError(502, "Claude returned no native side-question answer")
    try:
        encoded = answer.encode("utf-8")
    except UnicodeEncodeError:
        raise SideQuestionError(502, "Claude returned an invalid native side-question answer") from None
    if len(encoded) > MAX_OUTPUT_BYTES:
        raise SideQuestionError(502, "Claude returned an oversized native side-question answer")
    return {"answer": answer, "context_note": CONTEXT_NOTE}


async def ask_native_side_question(
    client: Any,
    question: str,
    *,
    history: list[dict] | None = None,
    timeout_seconds: float = REQUEST_TIMEOUT_SECONDS,
) -> dict:
    """Ask native /btw without acquiring the parent's main-turn authority.

    Cancellation sends only this control's opaque ID. The parent's existing SDK
    reader delivers the response into the same event tables it uses for all
    other controls. No second stream reader or main-turn command is created.
    """
    request = _request(question, history)
    query = _native_query(client)
    if timeout_seconds <= 0:
        raise ValueError("timeout_seconds must be positive")
    request_id = f"agentsdock_side_{uuid.uuid4().hex}"
    event = asyncio.Event()
    query.pending_control_responses[request_id] = event
    wire = json.dumps({"type": "control_request", "request_id": request_id, "request": request}) + "\n"
    sending = asyncio.create_task(query.transport.write(wire), name=f"claude-side-send:{request_id}")

    async def exchange() -> Any:
        await asyncio.shield(sending)
        await event.wait()
        return query.pending_control_results.get(request_id)

    receiving = asyncio.create_task(exchange(), name=f"claude-side-result:{request_id}")

    async def cancel_exact_request() -> None:
        # Join the original write before cancellation so even a slow transport
        # cannot deliver our cancellation before the side question exists.
        with suppress(Exception):
            await asyncio.shield(sending)
        if event.is_set():
            return
        await query.transport.write(json.dumps({
            "type": "control_cancel_request", "request_id": request_id,
        }) + "\n")
        await event.wait()

    async def cancel_and_settle() -> None:
        cleanup = asyncio.create_task(cancel_exact_request(), name=f"claude-side-cancel:{request_id}")
        deadline = asyncio.get_running_loop().time() + CANCEL_TIMEOUT_SECONDS
        try:
            while not cleanup.done():
                remaining = deadline - asyncio.get_running_loop().time()
                if remaining <= 0:
                    break
                try:
                    await asyncio.wait_for(asyncio.shield(cleanup), remaining)
                except asyncio.CancelledError:
                    # A duplicate disconnect must not interrupt exact-ID cleanup.
                    continue
                except Exception:
                    break
        finally:
            if not cleanup.done():
                cleanup.cancel()
            with suppress(BaseException):
                await cleanup

    try:
        result = await asyncio.wait_for(asyncio.shield(receiving), timeout_seconds)
        return _answer(result)
    except asyncio.CancelledError:
        await cancel_and_settle()
        raise
    except TimeoutError:
        await cancel_and_settle()
        raise SideQuestionError(504, "Claude native side question timed out") from None
    except SideQuestionError:
        raise
    except Exception:
        # A transport can report failure after delivering a frame. Attempt the
        # exact native cancellation without changing the main connection.
        await cancel_and_settle()
        raise SideQuestionError(503, "Claude native side-question connection is unavailable") from None
    finally:
        query.pending_control_responses.pop(request_id, None)
        query.pending_control_results.pop(request_id, None)
        for task in (receiving, sending):
            if not task.done():
                task.cancel()
            with suppress(BaseException):
                await task