feat(agents): make sources optional and drop the synthetic "Default" source

The sources list used to start with a fake "Default" entry that had no id
and, at run time, meant "no source, skip retrieval". The agent form
pre-selected it, snapped back to it when the last source was deselected,
and refused to publish without it, so a new agent always looked like it
had a knowledge base when it had none.

Backend
- /api/sources returns only ingested sources; no placeholder row.
- Publishing an agent no longer requires a source on create or update.
  The legacy "default" value is still accepted and maps to NULL.

Frontend
- The agent source picker starts empty, can be cleared, and shows a hint
  that a source-less agent answers from the model and its tools only.
- The picker groups sources into "Your sources" and "Shared with team"
  when any team-shared source exists, shows "N sources selected" for a
  multi-selection, and gets the same "Go to Sources" / "Upload new"
  footer as the chat picker. A source uploaded from the form is selected
  when it lands.
- Source selection serialisation and the picker id live in one helper
  shared with the chat picker; the four copies in the form are gone.
- The client no longer seeds a placeholder source in the store, and the
  dead auto-select of a "default" document is removed.
This commit is contained in:
Alex committed 2026-09-09 17:59:08 +01:00
1 parent fb15649a3f
commit 79d418f32a
23 files changed
+532 -365

No files matched your search

@@ -647,9 +647,9 @@ class StreamProcessor:
def _configure_source(self):
"""Configure the source based on agent data.
The literal string ``"default"`` is a placeholder meaning "no
ingested source" and is normalized to an empty source so that no
retrieval is attempted.
The literal string ``"default"`` is a legacy placeholder (older
clients sent it for "no ingested source") and is normalized to an
empty source so that no retrieval is attempted.
"""
if self._agent_data:
agent_data = self._agent_data
+5 -38
View File
@@ -76,7 +76,8 @@ AGENT_TYPE_SCHEMAS = {
# is acceptable and maps to NULL downstream.
"validate_published": ["name", "description"],
"validate_draft": [],
"require_source": True,
# No source is required: a source-less agent answers from the model
# and its tools, and ``source``/``sources`` map to NULL downstream.
"fields": [
"name",
"description",
@@ -661,20 +662,6 @@ class CreateAgent(Resource):
if data.get("status") == "published":
required_fields = schema["required_published"]
validate_fields = schema["validate_published"]
if (
schema.get("require_source")
and not data.get("source")
and not data.get("sources")
):
return make_response(
jsonify(
{
"success": False,
"message": "Either 'source' or 'sources' field is required for published agents",
}
),
400,
)
else:
required_fields = schema["required_draft"]
validate_fields = schema["validate_draft"]
@@ -1190,29 +1177,9 @@ class UpdateAgent(Resource):
)
if not final_value:
missing_published_fields.append(field_label)
source_final = update_fields.get(
"source_id", existing_agent.get("source_id"),
)
extra_final = update_fields.get(
"extra_source_ids", existing_agent.get("extra_source_ids") or [],
)
# ``retriever`` carries the runtime identity for
# agents that publish against the synthetic
# "Default" source (frontend's auto-selected
# ``{name: "Default", retriever: "classic"}``
# entry has no ``id``, so ``source_id`` ends up
# NULL even though the user picked something).
# Without this fallback the most common new-agent
# publish flow gets a 400.
retriever_final = update_fields.get(
"retriever", existing_agent.get("retriever"),
)
if (
not source_final
and not extra_final
and not retriever_final
):
missing_published_fields.append("Source or retriever")
# Sources are optional: a published agent with no
# ``source_id`` and no ``extra_source_ids`` skips
# retrieval and answers from the model and its tools.
if missing_published_fields:
return _reject(
"Cannot publish agent. Missing or invalid required "
+4 -10
View File
@@ -73,16 +73,10 @@ class CombinedJson(Resource):
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
data = [
{
"name": "Default",
"date": "default",
"model": settings.EMBEDDINGS_NAME,
"location": "remote",
"tokens": "",
"retriever": "classic",
}
]
# Only ingested sources are listed. There is no synthetic "Default"
# entry: "no source" is expressed by an empty selection on the client,
# and the answer path treats a missing source as "skip retrieval".
data = []
try:
with db_readonly() as conn: