I3eka commented on code in PR #42805: URL: https://github.com/apache/superset/pull/42805#discussion_r4003216835
########## superset/ai/orchestrator.py: ########## @@ -0,0 +1,647 @@ +# 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( Review Comment: I have kept this open and linked the corresponding dependent-PR threads back here. The documented AI event bus and the general cancellation cache are separate; the downstream compatibility refresh does not supply a shared cancellation backend or a process-separated regression. This still needs a base-owned fix before worker-mode signoff. ########## superset/models/ai.py: ########## @@ -0,0 +1,270 @@ +# 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. +""" +Persistence for AI assistant conversations. + +Conversations live in Superset's own metadata database, which keeps the +feature deployable with no extra infrastructure and makes ownership +enforceable with the same DAO filters used everywhere else. + +These models live under ``superset/models/`` rather than inside +``superset/ai/`` because background workers need them without importing the +API module. +""" + +from __future__ import annotations + +import uuid as uuid_module +from typing import Any + +import sqlalchemy as sa +from flask_appbuilder import Model +from sqlalchemy.orm import relationship, validates +from sqlalchemy_utils import UUIDType + +from superset.ai.types import ( + MessageExtra, + MessageRole, + MessageStatus, + ThreadStatus, +) +from superset.models.helpers import AuditMixinNullable +from superset.utils import json +from superset.utils.core import MediumText + +#: Bumped when the meaning of keys inside an ``extra_json`` blob changes, so a +#: reader can tell an old row from a new one instead of guessing. +EXTRA_JSON_VERSION = 1 + + +class AIChatThread(AuditMixinNullable, Model): + """ + One conversation between a user and the assistant. + + Ownership is expressed through ``created_by_fk`` (supplied by + :class:`AuditMixinNullable`) and enforced by the DAO's base filter, so a + thread identifier is not by itself a capability. + """ + + __tablename__ = "ai_chat_threads" + __table_args__ = ( + # Serves the "my threads, most recent first" list query. + sa.Index("ix_ai_chat_threads_owner_recent", "created_by_fk", "changed_on"), + ) + + id = sa.Column(sa.Integer, primary_key=True) + #: The only identifier exposed over HTTP. Integer ids stay internal. + uuid = sa.Column( + UUIDType(binary=True), + nullable=False, + unique=True, + default=uuid_module.uuid4, + ) + + title = sa.Column(sa.String(512), nullable=True) + status = sa.Column( + sa.String(32), + nullable=False, + default=ThreadStatus.ACTIVE.value, + server_default=ThreadStatus.ACTIVE.value, + ) + #: Which agent profile this thread was last run with. + agent_key = sa.Column(sa.String(64), nullable=True) + extra_json = sa.Column(MediumText(), nullable=True) + + # No ``passive_deletes``: SQLite does not enforce foreign keys unless + # ``PRAGMA foreign_keys=ON``, so deferring the cascade to the database + # would orphan messages there. The ORM deletes children itself, and the + # ``ON DELETE CASCADE`` in the DDL remains a backstop for direct SQL. + messages = relationship( + "AIChatMessage", + back_populates="thread", + cascade="all, delete-orphan", + order_by="AIChatMessage.created_on", + ) + + def __repr__(self) -> str: + return f"<AIChatThread {self.uuid} [{self.status}]>" + + @validates("status") + def _validate_status(self, _key: str, value: Any) -> str: + """Reject unknown lifecycle values at assignment time.""" + return ThreadStatus(value).value + + @property + def message_count(self) -> int: + """ + Number of stored messages. + + Derived rather than denormalised: a counter column would have to be kept + in step with cascade deletes and retention pruning, and a counter that + drifts is worse than one query — it reports a conversation length nobody + can reconcile against the rows. + """ + return len(self.messages) + + @property + def extra(self) -> dict[str, Any]: + """Parsed ``extra_json``, or an empty dict when absent or corrupt.""" + return _load_json_object(self.extra_json) + + +class AIChatMessage(AuditMixinNullable, Model): + """ + A single turn in a conversation. + + Assistant messages are inserted before inference begins so a client that + reconnects mid-run has a row to attach to, then transition through + ``streaming`` to a terminal status. + """ + + __tablename__ = "ai_chat_messages" + __table_args__ = ( + sa.Index("ix_ai_chat_messages_thread_created", "thread_id", "created_on"), + # Makes client-supplied idempotency real: replaying a request cannot + # create a second row for the same turn. The constraint carries the role + # as well, because one request legitimately produces both a user message + # and the assistant message answering it. + sa.UniqueConstraint( + "thread_id", + "request_id", + "role", + name="uq_ai_chat_messages_thread_request_role", + ), + ) + + id = sa.Column(sa.Integer, primary_key=True) + uuid = sa.Column( + UUIDType(binary=True), + nullable=False, + unique=True, + default=uuid_module.uuid4, + ) + + thread_id = sa.Column( + sa.Integer, + sa.ForeignKey("ai_chat_threads.id", ondelete="CASCADE"), + nullable=False, + ) + role = sa.Column(sa.String(16), nullable=False) + #: Unbounded model or user text; deliberately not a plain ``Text`` column, + #: which caps at 64 KB on MySQL. + content = sa.Column(MediumText(), nullable=False, default="") + status = sa.Column( + sa.String(32), + nullable=False, + default=MessageStatus.COMPLETE.value, + server_default=MessageStatus.COMPLETE.value, + ) + #: Client-generated idempotency key for the turn. + request_id = sa.Column(sa.String(96), nullable=True) + #: Serialised :class:`~superset.ai.types.MessageExtra`. + extra_json = sa.Column(MediumText(), nullable=True) + + thread = relationship("AIChatThread", back_populates="messages") + + def __repr__(self) -> str: + return f"<AIChatMessage {self.uuid} {self.role} [{self.status}]>" + + @validates("role") + def _validate_role(self, _key: str, value: Any) -> str: + """Reject unknown authors at assignment time.""" + return MessageRole(value).value + + @validates("status") + def _validate_status(self, _key: str, value: Any) -> str: + """Reject unknown lifecycle values at assignment time.""" + return MessageStatus(value).value + + @property + def is_terminal(self) -> bool: + """Whether this message will never change again.""" + return MessageStatus(self.status) in MessageStatus.terminal() + + @property + def extra(self) -> MessageExtra: + """Parsed ``extra_json``, or an empty dict when absent or corrupt.""" + return _load_json_object(self.extra_json) # type: ignore[return-value] + + def update_extra(self, updates: MessageExtra) -> None: + """ + Merge keys into ``extra_json``. + + Merge rather than replace, because a run writes tool calls and token + usage at different moments. + """ + merged: dict[str, Any] = dict(self.extra) + merged.update(updates) + merged["version"] = EXTRA_JSON_VERSION + self.extra_json = json.dumps(merged) + + +class AIChatFeedback(AuditMixinNullable, Model): + """ + A thumbs up or down on an assistant message. + + A first-class table rather than a log line, so the signal can actually be + aggregated and joined back to the conversation that produced it. + """ + + __tablename__ = "ai_chat_feedback" + __table_args__ = ( + # One verdict per user per message; a repeat vote updates in place. + sa.UniqueConstraint( + "message_id", + "created_by_fk", + name="uq_ai_chat_feedback_message_user", + ), + ) + + id = sa.Column(sa.Integer, primary_key=True) + uuid = sa.Column( + UUIDType(binary=True), + nullable=False, + unique=True, + default=uuid_module.uuid4, + ) + + message_id = sa.Column( + sa.Integer, + sa.ForeignKey("ai_chat_messages.id", ondelete="CASCADE"), + nullable=False, + ) + liked = sa.Column(sa.Boolean, nullable=False) + comment = sa.Column(MediumText(), nullable=True) + + message = relationship("AIChatMessage") Review Comment: Keeping this base thread open: an ORM feedback cascade and the foreign-keys-disabled SQLite regression are still required. The dependent authoring PR's unit pass is not evidence that deletion removes those feedback rows, and I have not marked that concern resolved there. ########## docs/admin_docs/configuration/ai-assistant.mdx: ########## @@ -0,0 +1,489 @@ +--- +title: AI Assistant +hide_title: true +sidebar_position: 17 +version: 1 +--- + +# AI Assistant + +The AI Assistant is a conversational interface for exploring your data. A user +asks a question in plain language; the assistant finds relevant datasets, +inspects their schema, writes and runs read-only SQL, and answers with both the +result and the query it used. + +Superset ships **no model provider and talks to no model vendor by default**. +The feature is disabled, and even when enabled it returns `404` until you point +it at a provider you control. Nothing is sent anywhere until you configure it. + +## Enabling it + +Two things are required: the feature flag, and a provider. + +```python +# superset_config.py +FEATURE_FLAGS = { + "AI_ASSISTANT": True, +} + +AI_LLM_PROVIDER_CLASS = "superset.ai.llm.anthropic.AnthropicProvider" +AI_LLM_PROVIDER_CONFIG = { + "api_key": os.environ["ANTHROPIC_API_KEY"], + "models": { + "default": "claude-sonnet-4-5", + "fast": "claude-haiku-4-5", + "reasoning": "claude-opus-4-1", + }, +} +``` + +Install the matching extra: + +```bash +pip install "apache-superset[ai-anthropic]" # or [ai-openai] +``` + +Then run `superset init` so the assistant's permissions are created and assigned +to roles. Without this the endpoints return `403`. + +Conversations are stored in Superset's metadata database, so no extra +infrastructure is needed for the default configuration. + +### Which roles get access + +`superset init` grants `can_read`/`can_write` on `AIAssistant` to **Admin** and +**Alpha** only. "Write" here means writing one's own conversation — the +assistant's tools are read-only and it cannot create or modify assets. + +**Gamma does not get it by default.** The assistant runs queries and costs +money per question, so it is granted deliberately rather than inherited. To +give it to Gamma users, add `can_read`/`can_write` on `AIAssistant` to Gamma or +to a custom role. + +Every query the assistant runs is subject to the *user's own* database and +dataset permissions. It cannot read anything the person chatting with it could +not read themselves. + +Because it is not in Gamma, it is also not inherited by the Public role when +`PUBLIC_ROLE_LIKE = "Gamma"` — an anonymous visitor cannot reach the assistant +unless you grant it explicitly. + +## Choosing a provider + +`AI_LLM_PROVIDER_CLASS` is a dotted path to a +`superset.ai.llm.base.BaseLLMProvider` subclass. Two are bundled: + +| Class | Use for | +| --- | --- | +| `superset.ai.llm.anthropic.AnthropicProvider` | The Anthropic Messages API | +| `superset.ai.llm.openai_compatible.OpenAICompatibleProvider` | OpenAI, and anything exposing an OpenAI-compatible endpoint — vLLM, Ollama, a private gateway | + +`AI_LLM_PROVIDER_CONFIG` is passed to the provider's constructor and its +contents are provider-defined. For the OpenAI-compatible provider, `base_url` +points it anywhere: + +```python +AI_LLM_PROVIDER_CLASS = "superset.ai.llm.openai_compatible.OpenAICompatibleProvider" +AI_LLM_PROVIDER_CONFIG = { + "base_url": "https://llm.internal.example.com/v1", + "api_key": os.environ["MY_GATEWAY_KEY"], + "models": {"default": "our-hosted-model"}, +} +``` + +Everything vendor-specific — URLs, authentication, model naming — lives in the +provider. Superset core contains none of it, so a self-hosted model or a private +gateway needs configuration rather than a fork. + +### Model tiers and selection + +Profiles and prompts refer to capability *tiers* (`default`, `fast`, +`reasoning`), never to a vendor's model names. The provider maps tiers to +concrete models via the `models` dict. A tier you do not configure is an error +when requested, never a silent substitution — so cost and answer quality stay +attributable to the model actually used. + +Users may also pin a specific model per turn. Only models present in your +`models` mapping are accepted; anything else is rejected. + +## Agent profiles + +A profile bundles the decisions that differ between a quick answer and a careful +investigation: which tools are available, which model tier, and how many steps. +Two ship by default — `default` and `analyst`. + +**Which tools a model may invoke is a decision each deployment makes**, so +profiles are fully configurable. `AI_AGENT_PROFILES` maps a profile key to the +fields you want to override, leaving the rest alone: + +```python +AI_AGENT_PROFILES = { + # Let the assistant search and inspect, but never run SQL. + "default": {"tools": ["search_assets", "list_databases", "get_schema"]}, + + # Let the analyst profile think harder and longer. + "analyst": {"model_alias": "reasoning", "max_turns": 60}, + + # Add a profile only some users may select. + "deep": { + "name": "Deep analysis", + "description": "Slow, thorough, multi-step.", + "tools": ["search_assets", "get_schema", "execute_sql"], + "required_permission": ("can_write", "AIAssistant"), + }, +} +``` + +A tool name that does not exist is an error naming the typo and listing the +valid names, rather than an assistant that quietly lacks a capability. An empty +`tools` list is valid and means conversation with no data access. + +`required_permission` is enforced on both the listing *and* the run path, so a +profile a user cannot see is also one they cannot invoke by posting its key. + +### Available tools + +| Tool | What it does | +| --- | --- | +| `search_assets` | Finds datasets, charts and dashboards the user can see | +| `list_databases` | Lists database connections exposed to SQL Lab | +| `get_schema` | Lists schemas, tables and columns | +| `execute_sql` | Runs a **read-only** query | +| `validate_sql` | Checks a query without running it | +| `get_chart_context` | Reads a chart's definition | +| `get_dashboard_context` | Reads a dashboard's definition | + +## Customising the prompt + +Three levers, in increasing order of bluntness. + +**Add to it.** `AI_EXTRA_PROMPT_SECTIONS` appends your own sections. This is +where deployment-specific knowledge belongs — your table conventions, your +warehouse's dialect quirks, how your business defines a metric. The shipped +prompt is deliberately generic and mentions no particular database engine. + +**Remove from it.** `AI_DISABLED_PROMPT_SECTIONS` drops a shipped section by +key, for when you disagree with one. The safety section cannot be disabled. + +**Replace it.** `AI_SYSTEM_PROMPT` substitutes the whole thing. + +:::warning +Setting `AI_SYSTEM_PROMPT` discards the shipped safety and prompt-injection +rules along with everything else. Your deployment then owns them. +::: + +`AI_SYSTEM_PROMPT_MUTATOR` is a last-mile callable applied after assembly, +mirroring `SQL_QUERY_MUTATOR`. + +## Where turns execute + +`AI_ASSISTANT_EXECUTION_MODE` decides where the work happens. + +**`"inline"`** (default) runs the turn in the web process. Nothing extra to +deploy. + +**`"worker"`** hands it to Celery. Web workers stay free, and a browser that +loses its connection can rejoin a run in progress. It requires Celery and a +Redis event bus: + +```python +AI_ASSISTANT_EXECUTION_MODE = "worker" +AI_ASSISTANT_EVENT_BUS = "redis" +AI_ASSISTANT_EVENT_BUS_CACHE_CONFIG = { + "CACHE_TYPE": "RedisCache", + "CACHE_REDIS_HOST": "redis", + "CACHE_REDIS_PORT": 6379, + "CACHE_REDIS_DB": 0, +} + +class CeleryConfig: + imports = ( + # ... your existing imports ... + "superset.ai.tasks", + ) +``` + +Streams need Redis commands the general-purpose cache client does not expose, +which is why the bus is configured separately rather than reusing `CACHE_CONFIG`. + +Selecting `"worker"` with the in-memory event bus raises rather than leaving +every stream silently empty, and so does selecting the Redis bus without a +usable connection. + +A turn is deliberately **not** retried after a worker crash: inference costs +money, and re-running a turn the user may already have partly seen would charge +twice. The message records that it failed and the user can ask again. + +## Safety and limits + +Guards are applied before any tool runs, configured via +`AI_AGENT_TOOL_POLICIES`: + +- **Read-only SQL.** Enforced using Superset's own SQL parser, not pattern + matching — so a write hidden behind a comment, a CTE, a second statement, or + an unparseable construct is refused. `EXPLAIN`, `SHOW` and `DESCRIBE` are + permitted; everything the parser cannot vouch for is not. +- **Identifier safety.** Table and column names are resolved against metadata + the user may see rather than interpolated into SQL. + +These bound blast radius; they do not replace authorization. Every tool that +touches a data-bearing object performs the same permission check the REST API +does. + +Result sizes are capped by `AI_AGENT_MAX_RESULT_ROWS` and +`AI_AGENT_MAX_RESULT_BYTES`, and truncation is reported rather than hidden. Turn +length is bounded by `AI_AGENT_MAX_TURNS` and `AI_AGENT_TIMEOUT_SECONDS`; a run +that exhausts either answers with what it has. + +Content that arrives from your warehouse or asset metadata — table comments, +chart titles, column labels — is marked as untrusted in the prompt, because a +value in a database is data and not an instruction. + +### Cancellation + +Cancellation is cooperative: a run stops at its next step boundary. A run inside +a single long model call or a single long query will not stop until that call +returns. + +## Monitoring and tracing + +Superset bundles **no integration with any AI monitoring product**. Instead it +exposes a small sink interface, `AITelemetry`, and calls it once per run, once +per model round trip and once per tool call. Whatever you already use — +Braintrust, LangSmith, Langfuse, Arize Phoenix, an OpenTelemetry collector, a +self-hosted alternative, or a table in your own warehouse — you connect by +implementing that interface and listing it in `AI_TELEMETRY`. + +Entries are instances or dotted paths, exactly as for `EVENT_LOGGER` and +`STATS_LOGGER`. Two sinks ship in-tree and depend on nothing external: + +```python +# superset_config.py +import logging + +from superset.ai.telemetry import LoggingAITelemetry, StatsLoggerAITelemetry + +AI_TELEMETRY = [ + # One structured line per span, at the level you choose. + LoggingAITelemetry(level=logging.INFO), + # Counters and timings through your configured STATS_LOGGER. + StatsLoggerAITelemetry(), +] +``` + +`StatsLoggerAITelemetry` emits under a `superset.ai.` prefix: `run.start`, +`run.end`, `run.outcome.<outcome>`, `run.duration_ms`, `run.turns`, +`run.tokens.input`, `run.tokens.output`, `model_call`, +`model_call.duration_ms`, `model_call.error`, `error`, and per tool +`tool_call.<tool>`, `tool_call.<tool>.duration_ms`, `tool_call.<tool>.error` +and `tool_call.<tool>.truncated`. User, run and thread identifiers deliberately +never appear in a metric name — a metric per user is how a metrics backend gets +brought down. That detail belongs in a trace, which is what a custom sink is +for. + +### The content trade-off + +`AI_TELEMETRY_REDACT_CONTENT` defaults to `True`, and telemetry then carries +**structure and measurements only**: durations, token counts, model names, tool +names, outcomes, error classes, and the run, thread and user identifiers. No +question, no answer, no SQL, no row of data. Redaction is applied where the +trace is built, so a sink cannot receive content by accident even if it looks +for it. + +Setting it to `False` is what makes a trace genuinely useful for debugging +answer quality — you can read the prompt that produced a wrong answer and the +statement it ran. It also means the text of business questions and values from +your warehouse leave Superset for whichever service your sinks talk to. In many +organisations that is a decision for someone other than the person editing the +config file. `AI_TELEMETRY_MAX_CONTENT_CHARS` (default 10,000) caps any single +content field so one large result cannot dominate a payload. + +### A custom sink + +Every method has a no-op default, so implement only the ones you need — a sink +that only wants token counts overrides `on_model_call` and nothing else. + +```python +from superset.ai.telemetry import AITelemetry, ModelCallTrace, RunTrace + + +class TracingServiceTelemetry(AITelemetry): + """Forwards runs to an external tracing service.""" + + def __init__(self, client): + self._client = client + + def on_run_start(self, run: RunTrace) -> None: + self._client.start_span(run.run_id, name="superset.ai.run", attributes={ + "thread": run.thread_uuid, + "user": run.user_id, + }) + + def on_model_call(self, run: RunTrace, call: ModelCallTrace) -> None: + self._client.event(run.run_id, "model_call", { + "turn": call.turn, + "model": call.model, + "input_tokens": call.input_tokens, + "output_tokens": call.output_tokens, + # None unless you have turned redaction off. + "prompt": call.system_prompt, + }) + + def on_run_end(self, run: RunTrace) -> None: + self._client.end_span(run.run_id, status=str(run.outcome), attributes={ + "duration_ms": run.duration_ms, + "turns": run.turns, + "usage": run.usage, + }) + + +AI_TELEMETRY = [TracingServiceTelemetry(client=my_tracing_client)] +``` + +Three things to know before you write one: + +- **Sinks are called on the thread answering the user.** Anything that makes a + network call should hand off to a queue or a background thread; otherwise a + slow monitoring backend becomes slow answers. +- **A sink that raises cannot break a run.** Failures are logged once and + ignored, and the other configured sinks still receive everything. The same + applies to a dotted path that will not import: it is skipped with a warning + rather than taking the assistant down, because a missing observer loses the + record of a run and not the run itself. +- **`agent_key`, `model` and `question` are resolved after the run starts**, so + a `RunTrace` passed to `on_run_start` may carry less than the one passed to + the later hooks. Read those on `on_run_end`. + +## Connecting your own MCP servers + +The assistant's built-in tools cover Superset itself. To let it reach anything +else — your data catalog, a metrics service, a ticketing system — attach an +[MCP](https://modelcontextprotocol.io) server. Superset bundles no third-party +integration and connects to nothing by default; you name the servers. + +```bash +pip install "apache-superset[ai-mcp]" +``` + +```python +AI_AGENT_MCP_SERVERS = { + "acme_catalog": { + "url": "https://mcp.acme.internal/mcp", + "transport": "streamable_http", # or "sse" + "headers": {"Authorization": f"Bearer {os.environ['ACME_MCP_TOKEN']}"}, + "timeout_seconds": 30, + "tool_allowlist": ["search_tables"], # omit to offer every tool + }, +} + +# Then let a profile use it. +AI_AGENT_PROFILES = { + "default": {"mcp_servers": ["acme_catalog"]}, +} +``` + +Its tools appear to the model as `mcp__acme_catalog__search_tables`. The +namespace means a foreign tool can never shadow a built-in one, and it is the +name to use in `tool_allowlist` and `tool_denylist`. + +### What Superset does to keep a foreign server contained + +A third-party server is untrusted input, and possibly untrusted intent: + +- **Everything it returns is marked as untrusted** before the model sees it, so + text in a tool result is treated as data rather than instructions. Tool + *descriptions* get the same treatment, since they enter the prompt every turn. +- **No Superset credential is ever forwarded.** Only the headers you configured + for that server are sent — never the user's session cookie, CSRF token, or an + inbound authorization header. +- **SQL execution through a foreign server is refused by default.** Superset's + read-only enforcement and per-dataset authorization cannot apply to a query + another system runs, so allowing it would silently bypass both. Set + `AI_AGENT_MCP_DENY_FOREIGN_SQL = False` to accept that trade deliberately. +- **Results obey the same size cap** as built-in tools, and the cap is applied + while reading, so a hostile server cannot exhaust memory before truncation. +- **A server being down does not break the assistant.** Discovery failure means + that server contributes no tools for the turn; the built-ins keep working. + +A profile naming a server you have not configured is an error, because a typo +there is indistinguishable at runtime from an agent that has quietly lost a +capability. Note that discovery happens per turn, so a slow server adds its +latency to every turn that uses it. + +## Retention + +Conversations are kept for `AI_ASSISTANT_MESSAGE_RETENTION_DAYS` (default 30). +Pruning is not automatic — schedule it if you want it enforced. + +## Trying it locally + +The development `docker compose` stack can bring the assistant up against the +example data. Put the settings in `docker/.env-local`, which is untracked: + +```bash +# docker/.env-local + +# Point at any OpenAI-compatible endpoint, including a private gateway. +SUPERSET_AI_LLM_BASE_URL=https://your-gateway/v1 +SUPERSET_AI_LLM_API_KEY=your-token +SUPERSET_AI_MODEL_DEFAULT=your-model-name +``` + +```bash +docker compose up +``` + +The assistant appears once both a URL and a key are present; with neither set the +stack behaves exactly as it did before. The model providers are optional extras, +so add whichever one you need to `docker/requirements-local.txt`: Review Comment: Correct. The dependent authoring docs in #43133 now install the provider before `docker compose up` and explicitly mention restarting application containers when adding dependencies to an already-running stack. That does not update this author's base branch, so this thread remains open until the same ordering is adopted here. -- 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]
