I3eka commented on code in PR #43135:
URL: https://github.com/apache/superset/pull/43135#discussion_r4002570905


##########
superset/ai/api.py:
##########
@@ -0,0 +1,994 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""
+REST API for the AI assistant.
+
+Every route carries ``@protect()`` and is reached through ``@expose`` on a
+``BaseSupersetApi`` subclass, which is what makes Flask-AppBuilder's
+authorization actually run. Ownership is enforced a second time in the command
+and DAO layers, so a conversation identifier is never on its own a capability.
+"""
+
+from __future__ import annotations
+
+import logging
+import time
+from collections.abc import Generator
+from typing import Any, cast
+
+from flask import current_app, request, Response, stream_with_context
+from flask_appbuilder.api import expose, permission_name, protect, safe
+from marshmallow import ValidationError
+
+from superset.ai.events import (
+    error_event,
+    KEEPALIVE_FRAME,
+    KEEPALIVE_INTERVAL_SECONDS,
+)
+from superset.ai.schemas import (
+    AgentResponseSchema,
+    CancelPostSchema,
+    FeedbackPostSchema,
+    MessagePostSchema,
+    RunAcceptedResponseSchema,
+    SuggestedPromptsPostSchema,
+    ThreadDetailResponseSchema,
+    ThreadPostSchema,
+    ThreadPutSchema,
+    ThreadResponseSchema,
+)
+from superset.ai.types import MessageRole, MessageStatus
+from superset.commands.ai.exceptions import (
+    AIChatMessageInvalidError,
+    AIChatMessageNotFoundError,
+    AIChatThreadInvalidError,
+    AIChatThreadNotFoundError,
+)
+from superset.extensions import event_logger
+from superset.utils.core import get_user_id
+from superset.utils.decorators import transaction
+from superset.views.base_api import BaseSupersetApi, statsd_metrics
+
+logger = logging.getLogger(__name__)
+
+#: Upper bound on how long a client may hold a stream open, so an abandoned
+#: browser tab cannot pin a worker indefinitely.
+_STREAM_TIMEOUT_SECONDS = 900
+
+#: How often a reader checks the event bus for new frames.
+#:
+#: Deliberately separate from ``KEEPALIVE_INTERVAL_SECONDS``. Passing the
+#: keep-alive interval as the poll interval made the reader sleep fifteen 
seconds
+#: between checks and then deliver everything that had accumulated in one 
batch —
+#: so a worker-mode run showed no streaming at all: the answer and every tool 
call
+#: appeared in fifteen-second lumps. One controls responsiveness, the other how
+#: often an idle connection is reassured; they are not the same number.
+_EVENT_POLL_SECONDS = 0.1
+
+
+class AIRestApi(BaseSupersetApi):
+    """Conversations with the AI assistant."""
+
+    resource_name = "ai"
+    openapi_spec_tag = "AI Assistant"
+    allow_browser_login = True
+    class_permission_name = "AIAssistant"
+
+    openapi_spec_component_schemas = (
+        AgentResponseSchema,
+        CancelPostSchema,
+        FeedbackPostSchema,
+        MessagePostSchema,
+        RunAcceptedResponseSchema,
+        SuggestedPromptsPostSchema,
+        ThreadDetailResponseSchema,
+        ThreadPostSchema,
+        ThreadPutSchema,
+        ThreadResponseSchema,
+    )
+
+    @expose("/agent/", methods=("GET",))
+    @protect()
+    @safe
+    @statsd_metrics
+    @permission_name("read")
+    def agents(self) -> Response:
+        """List agent profiles the current user may select.
+        ---
+        get:
+          summary: List available agent profiles
+          responses:
+            200:
+              description: Available profiles
+              content:
+                application/json:
+                  schema:
+                    type: object
+                    properties:
+                      result:
+                        type: array
+                        items:
+                          $ref: '#/components/schemas/AgentResponseSchema'
+            401:
+              $ref: '#/components/responses/401'
+            403:
+              $ref: '#/components/responses/403'
+            404:
+              $ref: '#/components/responses/404'
+        """
+        if (unavailable := self._reject_if_unconfigured()) is not None:
+            return unavailable
+
+        from superset.ai.factories import get_profiles
+
+        profiles = get_profiles().visible_to_current_user()
+        return self.response(200, result=[p.to_public_dict() for p in 
profiles])
+
+    @expose("/model/", methods=("GET",))
+    @protect()
+    @safe
+    @statsd_metrics
+    @permission_name("read")
+    def models(self) -> Response:
+        """List models this deployment has configured.
+        ---
+        get:
+          summary: List selectable models
+          responses:
+            200:
+              description: Configured model identifiers
+              content:
+                application/json:
+                  schema:
+                    type: object
+                    properties:
+                      result:
+                        type: array
+                        items:
+                          type: string
+            401:
+              $ref: '#/components/responses/401'
+            404:
+              $ref: '#/components/responses/404'
+        """
+        if (unavailable := self._reject_if_unconfigured()) is not None:
+            return unavailable
+
+        from superset.ai.factories import get_provider
+
+        return self.response(200, result=get_provider().available_models())
+
+    @expose("/thread/", methods=("POST",))
+    @protect()
+    @safe
+    @statsd_metrics
+    @permission_name("write")
+    @event_logger.log_this_with_context(
+        action=lambda self, *args, **kwargs: 
f"{self.__class__.__name__}.post_thread",
+        log_to_statsd=False,
+    )
+    def post_thread(self) -> Response:
+        """Create a conversation.
+        ---
+        post:
+          summary: Create a conversation
+          requestBody:
+            content:
+              application/json:
+                schema:
+                  $ref: '#/components/schemas/ThreadPostSchema'
+          responses:
+            201:
+              description: Conversation created
+              content:
+                application/json:
+                  schema:
+                    type: object
+                    properties:
+                      result:
+                        $ref: '#/components/schemas/ThreadResponseSchema'
+            400:
+              $ref: '#/components/responses/400'
+            401:
+              $ref: '#/components/responses/401'
+            404:
+              $ref: '#/components/responses/404'
+        """
+        if (unavailable := self._reject_if_unconfigured()) is not None:
+            return unavailable
+
+        from superset.commands.ai import CreateAIChatThreadCommand
+
+        try:
+            payload = ThreadPostSchema().load(request.json or {})
+        except ValidationError as error:
+            return self.response_400(message=error.messages)
+        try:
+            thread = CreateAIChatThreadCommand(
+                user_id=self._user_id(),
+                title=payload.get("title"),
+                agent_key=payload.get("agent_key"),
+            ).run()
+        except AIChatThreadInvalidError as ex:
+            return self.response_422(message=str(ex))
+        return self.response(201, result=_thread_dict(thread))
+
+    @expose("/thread/", methods=("GET",))
+    @protect()
+    @safe
+    @statsd_metrics
+    @permission_name("read")
+    def get_threads(self) -> Response:
+        """List the current user's conversations.
+        ---
+        get:
+          summary: List conversations
+          parameters:
+          - in: query
+            name: limit
+            schema:
+              type: integer
+          - in: query
+            name: offset
+            schema:
+              type: integer
+          responses:
+            200:
+              description: Conversations
+              content:
+                application/json:
+                  schema:
+                    type: object
+                    properties:
+                      count:
+                        type: integer
+                      result:
+                        type: array
+                        items:
+                          $ref: '#/components/schemas/ThreadResponseSchema'
+            401:
+              $ref: '#/components/responses/401'
+            404:
+              $ref: '#/components/responses/404'
+        """
+        if (unavailable := self._reject_if_unconfigured()) is not None:
+            return unavailable
+
+        from superset.daos.ai import AIChatThreadDAO
+
+        limit = request.args.get("limit", type=int) or 50
+        offset = request.args.get("offset", type=int) or 0
+        threads = AIChatThreadDAO.find_all_for_user(
+            self._user_id(), limit=limit, offset=offset
+        )
+        return self.response(
+            200,
+            count=len(threads),
+            result=[_thread_dict(thread) for thread in threads],
+        )
+
+    @expose("/thread/<thread_uuid>", methods=("GET",))
+    @protect()
+    @safe
+    @statsd_metrics
+    @permission_name("read")
+    def get_thread(self, thread_uuid: str) -> Response:
+        """Fetch a conversation and its messages.
+        ---
+        get:
+          summary: Get a conversation
+          parameters:
+          - in: path
+            name: thread_uuid
+            required: true
+            schema:
+              type: string
+              format: uuid
+          responses:
+            200:
+              description: Conversation with messages
+              content:
+                application/json:
+                  schema:
+                    type: object
+                    properties:
+                      result:
+                        $ref: '#/components/schemas/ThreadDetailResponseSchema'
+            401:
+              $ref: '#/components/responses/401'
+            404:
+              $ref: '#/components/responses/404'
+        """
+        if (unavailable := self._reject_if_unconfigured()) is not None:
+            return unavailable
+
+        from superset.daos.ai import (
+            AIChatFeedbackDAO,
+            AIChatMessageDAO,
+            AIChatThreadDAO,
+        )
+
+        user_id = self._user_id()
+        thread = AIChatThreadDAO.find_by_uuid_for_user(thread_uuid, user_id)
+        if thread is None:
+            return self.response_404()
+
+        messages = AIChatMessageDAO.find_for_thread(thread)
+        # Resolved for the whole transcript at once so the panel can show which
+        # replies this user already rated; without it a reload loses the 
verdict
+        # and the message looks unrated.
+        verdicts = AIChatFeedbackDAO.find_verdicts_for_user(
+            [message.id for message in messages], user_id
+        )
+        detail = _thread_dict(thread)
+        detail["messages"] = [
+            _message_dict(message, liked=verdicts.get(message.id))
+            for message in messages
+        ]
+        return self.response(200, result=detail)
+
+    @expose("/thread/<thread_uuid>", methods=("PUT",))
+    @protect()
+    @safe
+    @statsd_metrics
+    @permission_name("write")
+    @event_logger.log_this_with_context(
+        action=lambda self, *args, **kwargs: 
f"{self.__class__.__name__}.put_thread",
+        log_to_statsd=False,
+    )
+    def put_thread(self, thread_uuid: str) -> Response:
+        """Rename or archive a conversation.
+        ---
+        put:
+          summary: Update a conversation
+          parameters:
+          - in: path
+            name: thread_uuid
+            required: true
+            schema:
+              type: string
+              format: uuid
+          requestBody:
+            content:
+              application/json:
+                schema:
+                  $ref: '#/components/schemas/ThreadPutSchema'
+          responses:
+            200:
+              description: Conversation updated
+            401:
+              $ref: '#/components/responses/401'
+            404:
+              $ref: '#/components/responses/404'
+            422:
+              $ref: '#/components/responses/422'
+        """
+        if (unavailable := self._reject_if_unconfigured()) is not None:
+            return unavailable
+
+        from superset.commands.ai import UpdateAIChatThreadCommand
+
+        try:
+            payload = ThreadPutSchema().load(request.json or {})
+        except ValidationError as error:
+            return self.response_400(message=error.messages)
+        try:
+            thread = UpdateAIChatThreadCommand(
+                thread_uuid,
+                self._user_id(),
+                title=payload.get("title"),
+                status=payload.get("status"),
+            ).run()
+        except AIChatThreadNotFoundError:
+            return self.response_404()
+        except AIChatThreadInvalidError as ex:
+            return self.response_422(message=str(ex))
+        return self.response(200, result=_thread_dict(thread))
+
+    @expose("/thread/<thread_uuid>", methods=("DELETE",))
+    @protect()
+    @safe
+    @statsd_metrics
+    @permission_name("write")
+    @event_logger.log_this_with_context(
+        action=lambda self, *args, **kwargs: 
f"{self.__class__.__name__}.delete_thread",
+        log_to_statsd=False,
+    )
+    def delete_thread(self, thread_uuid: str) -> Response:
+        """Delete a conversation and its messages.
+        ---
+        delete:
+          summary: Delete a conversation
+          parameters:
+          - in: path
+            name: thread_uuid
+            required: true
+            schema:
+              type: string
+              format: uuid
+          responses:
+            200:
+              description: Conversation deleted
+            401:
+              $ref: '#/components/responses/401'
+            404:
+              $ref: '#/components/responses/404'
+        """
+        if (unavailable := self._reject_if_unconfigured()) is not None:
+            return unavailable
+
+        from superset.commands.ai import DeleteAIChatThreadCommand
+
+        try:
+            DeleteAIChatThreadCommand(thread_uuid, self._user_id()).run()
+        except AIChatThreadNotFoundError:
+            return self.response_404()
+        return self.response(200, message="OK")
+
+    @expose("/thread/<thread_uuid>/message", methods=("POST",))
+    @protect()
+    @safe
+    @statsd_metrics
+    @permission_name("write")
+    @event_logger.log_this_with_context(
+        action=lambda self, *args, **kwargs: 
f"{self.__class__.__name__}.post_message",
+        log_to_statsd=False,
+    )
+    def post_message(self, thread_uuid: str) -> Response:
+        """Post a user message and start a run.
+        ---
+        post:
+          summary: Post a message
+          description: >
+            Stores the user's message, creates a placeholder assistant message,
+            and starts a run. Returns immediately; consume the answer from the
+            stream endpoint using the returned run identifier.
+          parameters:
+          - in: path
+            name: thread_uuid
+            required: true
+            schema:
+              type: string
+              format: uuid
+          requestBody:
+            content:
+              application/json:
+                schema:
+                  $ref: '#/components/schemas/MessagePostSchema'
+          responses:
+            202:
+              description: Run accepted
+              content:
+                application/json:
+                  schema:
+                    type: object
+                    properties:
+                      result:
+                        $ref: '#/components/schemas/RunAcceptedResponseSchema'
+            400:
+              $ref: '#/components/responses/400'
+            401:
+              $ref: '#/components/responses/401'
+            404:
+              $ref: '#/components/responses/404'
+            422:
+              $ref: '#/components/responses/422'
+        """
+        if (unavailable := self._reject_if_unconfigured()) is not None:
+            return unavailable
+
+        from superset.ai.orchestrator import new_run_id
+        from superset.commands.ai import AppendAIChatMessageCommand
+
+        try:
+            payload = MessagePostSchema().load(request.json or {})
+        except ValidationError as error:
+            return self.response_400(message=error.messages)
+        user_id = self._user_id()
+
+        try:
+            user_message = AppendAIChatMessageCommand(
+                thread_uuid,
+                user_id,
+                MessageRole.USER,
+                payload["content"],
+                request_id=payload.get("request_id"),
+            ).run()
+            # Created up front so a client that reconnects before any token
+            # arrives still has a row to attach its stream to.
+            assistant_message_command = AppendAIChatMessageCommand(
+                thread_uuid,
+                user_id,
+                MessageRole.ASSISTANT,
+                "",
+                request_id=payload.get("request_id"),
+                status=MessageStatus.PENDING,
+            )
+            assistant_message = assistant_message_command.run()
+        except AIChatThreadNotFoundError:
+            return self.response_404()
+        except (AIChatMessageInvalidError, AIChatThreadInvalidError) as ex:
+            return self.response_422(message=str(ex))
+
+        if assistant_message_command.created:
+            run_id = new_run_id()
+            _record_run_context(assistant_message, run_id, payload)
+            self._start_run(

Review Comment:
   There is still no newer base fix for this to import; the master refresh does 
not repair ambiguous broker submission or the stranded pending-row guard. 
Keeping this open. A retry must reuse durable run state without blindly 
duplicating a possibly submitted turn.



##########
superset/ai/orchestrator.py:
##########
@@ -0,0 +1,649 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""
+Runs one assistant turn end to end.
+
+Sits between the HTTP layer and the runtime: loads the conversation, assembles
+the prompt, resolves the tools the chosen profile allows, drives the runtime,
+publishes every event to the bus, and records the outcome on the assistant
+message.
+
+Deliberately independent of *where* it runs. The same function body serves the
+inline path and the Celery path, which is what makes the execution mode a
+configuration choice rather than two implementations that drift apart.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+import uuid as uuid_module
+from collections.abc import AsyncIterator, Iterator
+from dataclasses import dataclass
+from typing import Any
+
+from superset.ai.events import (
+    cancelled_event,
+    done_event,
+    error_event,
+    GENERIC_ERROR_MESSAGE,
+    session_event,
+    StreamEvent,
+)
+from superset.ai.llm.base import Message
+from superset.ai.telemetry import bind_run, current_run, start_run
+from superset.ai.types import MessageRole, MessageStatus, RunOutcome, 
StreamEventType
+from superset.utils.decorators import transaction
+
+logger = logging.getLogger(__name__)
+
+#: Cache key prefix for a run's cancellation flag. A flag rather than a signal
+#: because a worker cannot be interrupted mid-call reliably; the runtime checks
+#: this between steps.
+_CANCEL_PREFIX = "ai-cancel-"
+
+#: How long a cancellation request stays meaningful.
+_CANCEL_TTL_SECONDS = 900
+
+#: Stored when a run is stopped before it produced any answer, so the
+#: transcript still records that the turn happened.
+_STOPPED_WITHOUT_ANSWER = "_Stopped before an answer was produced._"
+
+#: Stored when a run exhausted its time budget without saying anything. Phrased
+#: as something the user can act on, because retrying is usually the right 
move.
+_TIMED_OUT_WITHOUT_ANSWER = (
+    "The assistant ran out of time before it could answer. Please try again."
+)
+
+#: Ceiling on the page context recorded on a message. Well below the prompt's 
own
+#: limit: this is stored per turn and read back with the whole transcript.
+_RECORDED_CONTEXT_LIMIT = 4_000
+
+
+@dataclass
+class TurnRequest:
+    """One unit of work: answer the latest message on a thread."""
+
+    thread_uuid: str
+    user_id: int
+    run_id: str
+    #: Assistant message row to fill in. Created before the run starts so a
+    #: client that reconnects has something to attach to.
+    assistant_message_uuid: str
+    profile_key: str | None = None
+    #: Concrete model to pin, overriding the profile's tier.
+    model: str | None = None
+    #: What the user had on screen when they asked. Supplied by the client,
+    #: which is the only party that knows which tab is open, what is typed in
+    #: the editor and which filters are applied.
+    page_context: dict[str, Any] | None = None
+
+    def to_payload(self) -> dict[str, Any]:
+        """Serialise for the task broker."""
+        return {
+            "thread_uuid": self.thread_uuid,
+            "user_id": self.user_id,
+            "run_id": self.run_id,
+            "assistant_message_uuid": self.assistant_message_uuid,
+            "profile_key": self.profile_key,
+            "model": self.model,
+            "page_context": self.page_context,
+        }
+
+    @classmethod
+    def from_payload(cls, payload: dict[str, Any]) -> TurnRequest:
+        """Rebuild from a broker payload."""
+        return cls(**payload)
+
+
+def new_run_id() -> str:
+    """Identifier for one run, used as the event-stream key."""
+    return str(uuid_module.uuid4())
+
+
+#: Runs cancelled in this process.
+#:
+#: Held alongside the cache rather than instead of it. Superset's default cache
+#: is a null cache, which accepts a write and discards it — so a cache-only
+#: implementation would leave cancellation silently broken on a default 
install,
+#: with the button appearing to work and nothing stopping. This set makes 
inline
+#: execution correct with no cache at all; the cache is what carries a
+#: cancellation across processes for worker execution.
+_CANCELLED_LOCALLY: set[str] = set()
+
+
+def request_cancel(run_id: str) -> None:
+    """
+    Ask a run to stop.
+
+    Cooperative by design: the flag is recorded here and observed by the 
runtime
+    between steps. A run blocked inside a single long model call or query will
+    not notice until that call returns, which is a real limit worth documenting
+    rather than hiding.
+    """
+    from superset.extensions import cache_manager
+
+    _CANCELLED_LOCALLY.add(run_id)
+    try:
+        cache_manager.cache.set(
+            f"{_CANCEL_PREFIX}{run_id}", True, timeout=_CANCEL_TTL_SECONDS
+        )
+    except Exception:  # pylint: disable=broad-except
+        logger.warning("Could not record cancellation for AI run %s", run_id)
+
+
+def is_cancelled(run_id: str) -> bool:
+    """Whether a stop has been requested for this run."""
+    from superset.extensions import cache_manager
+
+    if run_id in _CANCELLED_LOCALLY:
+        return True
+    try:
+        return bool(cache_manager.cache.get(f"{_CANCEL_PREFIX}{run_id}"))
+    except Exception:  # pylint: disable=broad-except
+        # A cache that cannot be read must not make every run appear cancelled;
+        # that would stop all inference the moment the cache went away.
+        return False
+
+
+def clear_cancel(run_id: str) -> None:
+    """Drop a run's cancellation flag."""
+    from superset.extensions import cache_manager
+
+    _CANCELLED_LOCALLY.discard(run_id)
+    try:
+        cache_manager.cache.delete(f"{_CANCEL_PREFIX}{run_id}")
+    except Exception:  # pylint: disable=broad-except
+        logger.debug("Could not clear cancellation flag for AI run %s", run_id)
+
+
+def stream_turn(request: TurnRequest) -> Iterator[StreamEvent]:
+    """
+    Answer a turn, yielding events as they happen.
+
+    This is the primary entry point. Inline execution consumes it directly from
+    inside the streaming response, which means the producer and the reader are
+    the same process by construction — important because Superset runs several
+    web workers, and a turn that published to one process's in-memory queue
+    while the browser's stream landed on another would appear to hang forever.
+
+    Never raises for an operational failure: a failure is an ``error`` event 
and
+    an ``error`` message status, because the caller may already have flushed
+    response headers or may be a worker with no one to report to.
+    """
+    recorder = start_run(
+        run_id=request.run_id,
+        thread_uuid=request.thread_uuid,
+        user_id=request.user_id,
+    )
+    # Shared with ``_run`` so the ``finally`` below can see the runtime's
+    # partial result and whether the message was already written.
+    state: dict[str, Any] = {}
+    try:
+        # Bound here rather than inside ``_run`` so that a run which fails 
before
+        # it has resolved a profile still produces a start and an end, and so
+        # that the runtime can report its own spans without the runtime 
contract
+        # growing a telemetry parameter.
+        with bind_run(recorder):
+            recorder.run_started()
+            yield from _run(request, state)
+    except Exception as ex:  # pylint: disable=broad-except
+        logger.exception("AI turn failed for run %s", request.run_id)
+        recorder.error(ex)
+        recorder.run_ended(outcome=RunOutcome.ERROR)
+        answer, extra = _partial_from_state(state)
+        extra["outcome"] = RunOutcome.ERROR.value
+        _finalise_message(
+            request.assistant_message_uuid,
+            # The generic text rather than the exception: this is persisted and
+            # served back to the browser, so it must not carry internals. The
+            # detail is in the log line above, keyed by run id.
+            content=answer or GENERIC_ERROR_MESSAGE,
+            status=MessageStatus.ERROR,
+            extra=extra,
+        )
+        state["finalised"] = True
+        yield error_event()
+        yield done_event(ok=False)
+    finally:
+        clear_cancel(request.run_id)
+        # A client that stops the run, or simply navigates away, abandons this
+        # generator part-way through. Nothing above will have written the
+        # message, so it would otherwise sit in ``streaming`` with no content
+        # for ever — the user loses both the partial answer and any record that
+        # the turn happened. Persist whatever was produced.
+        _abandon_message(request.assistant_message_uuid, state)
+        # Idempotent, so the ordinary paths above win.
+        recorder.run_ended(outcome=RunOutcome.CANCELLED)
+
+
+def execute_turn(request: TurnRequest) -> RunOutcome:
+    """
+    Answer a turn, publishing events to the event bus.
+
+    Used by worker execution, where the reader is in another process. Shares 
its
+    whole body with :func:`stream_turn` so the two execution modes cannot drift
+    apart in behaviour.
+    """
+    from superset.ai.eventbus import get_event_bus
+
+    bus = get_event_bus()
+    outcome = RunOutcome.SUCCESS
+
+    for event in stream_turn(request):
+        bus.publish(request.run_id, event)
+        if event.type is StreamEventType.ERROR:
+            outcome = RunOutcome.ERROR
+        elif event.type is StreamEventType.CANCELLED:
+            outcome = RunOutcome.CANCELLED
+        elif event.type is StreamEventType.DONE and not 
event.payload.get("ok"):
+            # A run that ended un-ok without an explicit error frame timed out.
+            if outcome is RunOutcome.SUCCESS:
+                outcome = RunOutcome.TIMEOUT
+
+    return outcome
+
+
+def _run(request: TurnRequest, state: dict[str, Any]) -> Iterator[StreamEvent]:
+    """Assemble and drive the run. See :func:`stream_turn` for error policy."""
+    if not _claim_pending(request.assistant_message_uuid):
+        # A second inline GET or a duplicate queued task must not repeat a turn
+        # that is already running or terminal.
+        state["finalised"] = True
+        logger.info("AI message %s is already claimed", 
request.assistant_message_uuid)
+        return
+
+    from superset.ai.factories import (
+        get_profiles,
+        get_provider,
+        get_runtime,
+        get_tools_for_profile,
+    )
+    from superset.ai.policy import load_policy_chain
+    from superset.ai.runtime.base import RunRequest
+    from superset.daos.ai import AIChatMessageDAO, AIChatThreadDAO
+
+    recorder = current_run()
+
+    thread = AIChatThreadDAO.find_by_uuid_for_user(request.thread_uuid, 
request.user_id)
+    if thread is None:
+        # The thread vanished between accepting the message and running it.
+        recorder.run_ended(outcome=RunOutcome.ERROR)
+        yield error_event("That conversation is no longer available.")
+        yield done_event(ok=False)
+        return
+
+    profile = get_profiles().get(request.profile_key)
+    tools = get_tools_for_profile(profile)
+    provider = get_provider()
+    runtime = get_runtime(provider)
+    state["runtime"] = runtime
+
+    yield session_event(request.thread_uuid, request.assistant_message_uuid)
+
+    from superset.ai.page_context import render_page_context
+
+    history = _build_history(AIChatMessageDAO.find_for_thread(thread))

Review Comment:
   This history boundary issue remains open: the worker still needs history 
bounded at its own placeholder rather than later queued messages. The current 
base head has not supplied that fix, so I have not resolved the thread after 
syncing master.



##########
superset/ai/orchestrator.py:
##########
@@ -0,0 +1,649 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""
+Runs one assistant turn end to end.
+
+Sits between the HTTP layer and the runtime: loads the conversation, assembles
+the prompt, resolves the tools the chosen profile allows, drives the runtime,
+publishes every event to the bus, and records the outcome on the assistant
+message.
+
+Deliberately independent of *where* it runs. The same function body serves the
+inline path and the Celery path, which is what makes the execution mode a
+configuration choice rather than two implementations that drift apart.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+import uuid as uuid_module
+from collections.abc import AsyncIterator, Iterator
+from dataclasses import dataclass
+from typing import Any
+
+from superset.ai.events import (
+    cancelled_event,
+    done_event,
+    error_event,
+    GENERIC_ERROR_MESSAGE,
+    session_event,
+    StreamEvent,
+)
+from superset.ai.llm.base import Message
+from superset.ai.telemetry import bind_run, current_run, start_run
+from superset.ai.types import MessageRole, MessageStatus, RunOutcome, 
StreamEventType
+from superset.utils.decorators import transaction
+
+logger = logging.getLogger(__name__)
+
+#: Cache key prefix for a run's cancellation flag. A flag rather than a signal
+#: because a worker cannot be interrupted mid-call reliably; the runtime checks
+#: this between steps.
+_CANCEL_PREFIX = "ai-cancel-"
+
+#: How long a cancellation request stays meaningful.
+_CANCEL_TTL_SECONDS = 900
+
+#: Stored when a run is stopped before it produced any answer, so the
+#: transcript still records that the turn happened.
+_STOPPED_WITHOUT_ANSWER = "_Stopped before an answer was produced._"
+
+#: Stored when a run exhausted its time budget without saying anything. Phrased
+#: as something the user can act on, because retrying is usually the right 
move.
+_TIMED_OUT_WITHOUT_ANSWER = (
+    "The assistant ran out of time before it could answer. Please try again."
+)
+
+#: Ceiling on the page context recorded on a message. Well below the prompt's 
own
+#: limit: this is stored per turn and read back with the whole transcript.
+_RECORDED_CONTEXT_LIMIT = 4_000
+
+
+@dataclass
+class TurnRequest:
+    """One unit of work: answer the latest message on a thread."""
+
+    thread_uuid: str
+    user_id: int
+    run_id: str
+    #: Assistant message row to fill in. Created before the run starts so a
+    #: client that reconnects has something to attach to.
+    assistant_message_uuid: str
+    profile_key: str | None = None
+    #: Concrete model to pin, overriding the profile's tier.
+    model: str | None = None
+    #: What the user had on screen when they asked. Supplied by the client,
+    #: which is the only party that knows which tab is open, what is typed in
+    #: the editor and which filters are applied.
+    page_context: dict[str, Any] | None = None
+
+    def to_payload(self) -> dict[str, Any]:
+        """Serialise for the task broker."""
+        return {
+            "thread_uuid": self.thread_uuid,
+            "user_id": self.user_id,
+            "run_id": self.run_id,
+            "assistant_message_uuid": self.assistant_message_uuid,
+            "profile_key": self.profile_key,
+            "model": self.model,
+            "page_context": self.page_context,
+        }
+
+    @classmethod
+    def from_payload(cls, payload: dict[str, Any]) -> TurnRequest:
+        """Rebuild from a broker payload."""
+        return cls(**payload)
+
+
+def new_run_id() -> str:
+    """Identifier for one run, used as the event-stream key."""
+    return str(uuid_module.uuid4())
+
+
+#: Runs cancelled in this process.
+#:
+#: Held alongside the cache rather than instead of it. Superset's default cache
+#: is a null cache, which accepts a write and discards it — so a cache-only
+#: implementation would leave cancellation silently broken on a default 
install,
+#: with the button appearing to work and nothing stopping. This set makes 
inline
+#: execution correct with no cache at all; the cache is what carries a
+#: cancellation across processes for worker execution.
+_CANCELLED_LOCALLY: set[str] = set()
+
+
+def request_cancel(run_id: str) -> None:
+    """
+    Ask a run to stop.
+
+    Cooperative by design: the flag is recorded here and observed by the 
runtime
+    between steps. A run blocked inside a single long model call or query will
+    not notice until that call returns, which is a real limit worth documenting
+    rather than hiding.
+    """
+    from superset.extensions import cache_manager
+
+    _CANCELLED_LOCALLY.add(run_id)
+    try:
+        cache_manager.cache.set(
+            f"{_CANCEL_PREFIX}{run_id}", True, timeout=_CANCEL_TTL_SECONDS
+        )
+    except Exception:  # pylint: disable=broad-except
+        logger.warning("Could not record cancellation for AI run %s", run_id)
+
+
+def is_cancelled(run_id: str) -> bool:
+    """Whether a stop has been requested for this run."""
+    from superset.extensions import cache_manager
+
+    if run_id in _CANCELLED_LOCALLY:
+        return True
+    try:
+        return bool(cache_manager.cache.get(f"{_CANCEL_PREFIX}{run_id}"))
+    except Exception:  # pylint: disable=broad-except
+        # A cache that cannot be read must not make every run appear cancelled;
+        # that would stop all inference the moment the cache went away.
+        return False
+
+
+def clear_cancel(run_id: str) -> None:
+    """Drop a run's cancellation flag."""
+    from superset.extensions import cache_manager
+
+    _CANCELLED_LOCALLY.discard(run_id)
+    try:
+        cache_manager.cache.delete(f"{_CANCEL_PREFIX}{run_id}")
+    except Exception:  # pylint: disable=broad-except
+        logger.debug("Could not clear cancellation flag for AI run %s", run_id)
+
+
+def stream_turn(request: TurnRequest) -> Iterator[StreamEvent]:
+    """
+    Answer a turn, yielding events as they happen.
+
+    This is the primary entry point. Inline execution consumes it directly from
+    inside the streaming response, which means the producer and the reader are
+    the same process by construction — important because Superset runs several
+    web workers, and a turn that published to one process's in-memory queue
+    while the browser's stream landed on another would appear to hang forever.
+
+    Never raises for an operational failure: a failure is an ``error`` event 
and
+    an ``error`` message status, because the caller may already have flushed
+    response headers or may be a worker with no one to report to.
+    """
+    recorder = start_run(
+        run_id=request.run_id,
+        thread_uuid=request.thread_uuid,
+        user_id=request.user_id,
+    )
+    # Shared with ``_run`` so the ``finally`` below can see the runtime's
+    # partial result and whether the message was already written.
+    state: dict[str, Any] = {}
+    try:
+        # Bound here rather than inside ``_run`` so that a run which fails 
before
+        # it has resolved a profile still produces a start and an end, and so
+        # that the runtime can report its own spans without the runtime 
contract
+        # growing a telemetry parameter.
+        with bind_run(recorder):
+            recorder.run_started()
+            yield from _run(request, state)
+    except Exception as ex:  # pylint: disable=broad-except
+        logger.exception("AI turn failed for run %s", request.run_id)
+        recorder.error(ex)
+        recorder.run_ended(outcome=RunOutcome.ERROR)
+        answer, extra = _partial_from_state(state)
+        extra["outcome"] = RunOutcome.ERROR.value
+        _finalise_message(
+            request.assistant_message_uuid,
+            # The generic text rather than the exception: this is persisted and
+            # served back to the browser, so it must not carry internals. The
+            # detail is in the log line above, keyed by run id.
+            content=answer or GENERIC_ERROR_MESSAGE,
+            status=MessageStatus.ERROR,
+            extra=extra,
+        )
+        state["finalised"] = True
+        yield error_event()
+        yield done_event(ok=False)
+    finally:
+        clear_cancel(request.run_id)
+        # A client that stops the run, or simply navigates away, abandons this
+        # generator part-way through. Nothing above will have written the
+        # message, so it would otherwise sit in ``streaming`` with no content
+        # for ever — the user loses both the partial answer and any record that
+        # the turn happened. Persist whatever was produced.
+        _abandon_message(request.assistant_message_uuid, state)
+        # Idempotent, so the ordinary paths above win.
+        recorder.run_ended(outcome=RunOutcome.CANCELLED)
+
+
+def execute_turn(request: TurnRequest) -> RunOutcome:
+    """
+    Answer a turn, publishing events to the event bus.
+
+    Used by worker execution, where the reader is in another process. Shares 
its
+    whole body with :func:`stream_turn` so the two execution modes cannot drift
+    apart in behaviour.
+    """
+    from superset.ai.eventbus import get_event_bus
+
+    bus = get_event_bus()
+    outcome = RunOutcome.SUCCESS
+
+    for event in stream_turn(request):
+        bus.publish(request.run_id, event)
+        if event.type is StreamEventType.ERROR:
+            outcome = RunOutcome.ERROR
+        elif event.type is StreamEventType.CANCELLED:
+            outcome = RunOutcome.CANCELLED
+        elif event.type is StreamEventType.DONE and not 
event.payload.get("ok"):
+            # A run that ended un-ok without an explicit error frame timed out.
+            if outcome is RunOutcome.SUCCESS:
+                outcome = RunOutcome.TIMEOUT
+
+    return outcome
+
+
+def _run(request: TurnRequest, state: dict[str, Any]) -> Iterator[StreamEvent]:
+    """Assemble and drive the run. See :func:`stream_turn` for error policy."""
+    if not _claim_pending(request.assistant_message_uuid):
+        # A second inline GET or a duplicate queued task must not repeat a turn
+        # that is already running or terminal.
+        state["finalised"] = True
+        logger.info("AI message %s is already claimed", 
request.assistant_message_uuid)
+        return
+
+    from superset.ai.factories import (
+        get_profiles,
+        get_provider,
+        get_runtime,
+        get_tools_for_profile,
+    )
+    from superset.ai.policy import load_policy_chain
+    from superset.ai.runtime.base import RunRequest
+    from superset.daos.ai import AIChatMessageDAO, AIChatThreadDAO
+
+    recorder = current_run()
+
+    thread = AIChatThreadDAO.find_by_uuid_for_user(request.thread_uuid, 
request.user_id)
+    if thread is None:
+        # The thread vanished between accepting the message and running it.
+        recorder.run_ended(outcome=RunOutcome.ERROR)
+        yield error_event("That conversation is no longer available.")
+        yield done_event(ok=False)
+        return
+
+    profile = get_profiles().get(request.profile_key)
+    tools = get_tools_for_profile(profile)
+    provider = get_provider()
+    runtime = get_runtime(provider)
+    state["runtime"] = runtime
+
+    yield session_event(request.thread_uuid, request.assistant_message_uuid)
+
+    from superset.ai.page_context import render_page_context
+
+    history = _build_history(AIChatMessageDAO.find_for_thread(thread))
+    # Recorded as well as prompted with, so the transcript can show what the
+    # assistant was told about the user's screen. An answer that looks wrong is
+    # usually an answer to a different question than the reader assumed, and 
the
+    # page context is where that difference lives.
+    rendered_context = render_page_context(request.page_context)
+    state["page_context"] = rendered_context
+    system_prompt = _build_system_prompt(tools, rendered_context)
+    model = _resolved_model(provider, request.model, profile)
+
+    recorder.describe(
+        agent_key=profile.key,
+        model=model,
+        question=_latest_question(history),
+    )
+
+    run_request = RunRequest(
+        messages=history,
+        system_prompt=system_prompt,
+        tools=tools,
+        policies=load_policy_chain(),
+        model_alias=profile.model_alias,

Review Comment:
   The pinned-model propagation remains unfixed in the base and in this branch. 
Recording the resolved model is not proof the provider used it. The refresh 
therefore leaves this thread open until the actual request propagation and 
regression land.



##########
superset/ai/eventbus.py:
##########
@@ -0,0 +1,314 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""
+Carries streamed events from whatever produced them to the HTTP response.
+
+Two implementations, matching the two execution modes. Inline execution needs
+nothing more than an in-process queue. Worker execution needs a shared,
+*replayable* channel — replayable because a browser that loses its connection
+must be able to rejoin a run already in progress, which rules out
+publish/subscribe: a subscriber that was absent when an event was published
+never sees it.
+
+The Redis implementation therefore uses streams, and reuses the cache backend
+that Superset's async-query channel already configures rather than introducing
+a second Redis client to operate.
+"""
+
+from __future__ import annotations
+
+import logging
+import queue
+from abc import ABC, abstractmethod
+from collections.abc import Iterator
+from typing import Any
+
+from superset.ai.events import StreamEvent
+from superset.ai.types import StreamEventType
+from superset.utils import json
+
+logger = logging.getLogger(__name__)
+
+#: Yielded by :meth:`BaseEventBus.consume` when nothing arrived within the poll
+#: interval, so a caller can emit a keep-alive rather than block indefinitely.
+IDLE = None
+
+#: Terminal event types. Seeing one ends consumption, so a reader does not hang
+#: waiting for a producer that has already finished.
+_TERMINAL = frozenset(
+    {StreamEventType.DONE, StreamEventType.ERROR, StreamEventType.CANCELLED}
+)
+
+
+class BaseEventBus(ABC):
+    """A per-run channel of events."""
+
+    @abstractmethod
+    def publish(self, run_id: str, event: StreamEvent) -> None:
+        """Append an event to a run's channel."""
+
+    @abstractmethod
+    def consume(
+        self,
+        run_id: str,
+        timeout_seconds: float,
+        poll_seconds: float = 1.0,
+    ) -> Iterator[StreamEvent | None]:
+        """
+        Yield a run's events until a terminal one arrives or time runs out.
+
+        Yields :data:`IDLE` when a poll interval passes with nothing new, which
+        is the caller's cue to send a keep-alive frame.
+        """
+
+    @abstractmethod
+    def close(self, run_id: str) -> None:
+        """Release any resources held for a run."""
+
+
+class MemoryEventBus(BaseEventBus):
+    """
+    An in-process queue per run.
+
+    Correct only when the producer and the streaming request share a process.
+    Selecting this alongside worker execution would leave every stream silent,
+    which :func:`get_event_bus` refuses to allow.
+    """
+
+    def __init__(self) -> None:
+        self._queues: dict[str, queue.SimpleQueue[StreamEvent]] = {}
+
+    def _queue_for(self, run_id: str) -> queue.SimpleQueue[StreamEvent]:
+        return self._queues.setdefault(run_id, queue.SimpleQueue())
+
+    def publish(self, run_id: str, event: StreamEvent) -> None:
+        self._queue_for(run_id).put(event)
+
+    def consume(
+        self,
+        run_id: str,
+        timeout_seconds: float,
+        poll_seconds: float = 1.0,
+    ) -> Iterator[StreamEvent | None]:
+        import time
+
+        # Deliberately not ``_queue_for``: reading must not create a channel.
+        # This bus lives for the life of the process, so a client polling
+        # unknown run identifiers would otherwise grow the dict without bound.
+        channel = self._queues.get(run_id)
+        deadline = time.monotonic() + timeout_seconds
+
+        while True:
+            remaining = deadline - time.monotonic()
+            if remaining <= 0:
+                return
+            if channel is None:
+                # The producer may not have published yet; look again rather
+                # than deciding the run does not exist. Only report idle if it
+                # is still absent, so a channel that appeared during the wait
+                # is drained on this pass instead of costing an extra tick.
+                channel = self._queues.get(run_id)
+                if channel is None:
+                    yield IDLE
+                    time.sleep(min(poll_seconds, remaining))
+                continue
+            try:
+                # Bounded by whichever is sooner, so a generous poll interval
+                # cannot overshoot the caller's deadline.
+                event = channel.get(timeout=min(poll_seconds, remaining))
+            except queue.Empty:
+                yield IDLE
+                continue
+            yield event
+            if event.type in _TERMINAL:
+                return
+
+    def close(self, run_id: str) -> None:
+        self._queues.pop(run_id, None)
+
+
+class RedisStreamEventBus(BaseEventBus):
+    """
+    A Redis stream per run.
+
+    Replayable by construction: a reconnecting reader starts from the beginning
+    of the stream and catches up, which is what makes worker execution usable
+    from a browser on a flaky connection.
+    """
+
+    def __init__(
+        self,
+        cache: Any,
+        prefix: str = "ai-events-",
+        ttl_seconds: int = 900,
+    ) -> None:
+        self._cache = cache
+        self._prefix = prefix
+        self._ttl = ttl_seconds
+
+    def _stream(self, run_id: str) -> str:
+        return f"{self._prefix}{run_id}"
+
+    def publish(self, run_id: str, event: StreamEvent) -> None:
+        payload = {
+            "data": json.dumps({"type": event.type.value, "payload": 
event.payload})
+        }
+        # A failure to publish must not kill the run that is producing useful
+        # work; the reader will time out and the answer is still persisted.
+        try:
+            self._cache.xadd(self._stream(run_id), payload, "*", 10_000)

Review Comment:
   This stream-retention issue remains open in this branch. The 
first-publish/progress TTL fix is now covered in the existing telemetry 
follow-up #43134; it has not landed in #42805 or been inherited here. I am not 
marking this resolved based on another PR's passing test.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to