Files
DocsGPT/docsgpt/api/user/agents/routes.py
T
Alex c26b8a3403 fix(agents): gate agent pinning on visibility
PinAgent looked agents up with no ownership, share or team predicate, and
PinnedAgents returned those rows through a hand-rolled dict that bypassed
the blanking _format_agent_output applies. Anyone holding an agent id --
a share-link recipient learns the real UUID -- could pin it and keep
reading its name, description, prompt, tools, type, status and masked key
after the share was revoked.

Add _user_may_pin: the owner, a team grantee, a recipient of a still-live
share link, or a system template. PinAgent checks it before pinning and
PinnedAgents re-checks every row on read, so a revoked grant drops out of
the list instead of persisting. Unpinning stays open regardless of current
access, or a revoked share would strand a pin the user cannot clear. The
masked key is now owner-only, matching _format_agent_output.

Adds the first cross-user pin tests: every existing one pinned the
caller's own agent.
2026-09-11 11:28:14 +01:00

1955 lines
85 KiB
Python

"""Agent management routes."""
import datetime
import json
import uuid
from flask import current_app, jsonify, make_response, request
from flask_restx import fields, Namespace, Resource
from pydantic import ValidationError as PydanticValidationError
from docsgpt.api import api
from docsgpt.guardrails.config import AgentConfig
from docsgpt.api.user.base import (
copy_agent_image_for_user,
handle_image_upload,
resolve_prompt_name,
resolve_source_details,
resolve_tool_details,
storage,
)
from docsgpt.core.json_schema_utils import (
JsonSchemaValidationError,
normalize_json_schema_payload,
)
from docsgpt.core.settings import settings
from docsgpt.storage.db.base_repository import looks_like_uuid
from docsgpt.api.user.team_sharing import (
can_access,
team_access_for,
visible_with_access,
)
from docsgpt.agents.default_tools import is_synthesized_tool_id
from docsgpt.storage.db.repositories.agent_folders import AgentFoldersRepository
from docsgpt.storage.db.repositories.agents import AgentsRepository
from docsgpt.storage.db.repositories.user_custom_models import (
UserCustomModelsRepository,
)
from docsgpt.storage.db.repositories.user_tools import UserToolsRepository
from docsgpt.storage.db.repositories.conversations import ConversationsRepository
from docsgpt.storage.db.repositories.shared_conversations import (
SharedConversationsRepository,
)
from docsgpt.storage.db.repositories.stack_logs import StackLogsRepository
from docsgpt.storage.db.repositories.token_usage import TokenUsageRepository
from docsgpt.storage.db.repositories.users import UsersRepository
from docsgpt.storage.db.repositories.workflows import WorkflowsRepository
from docsgpt.storage.db.session import db_readonly, db_session
from docsgpt.utils import (
check_required_fields,
generate_image_url,
validate_required_fields,
)
agents_ns = Namespace("agents", description="Agent management operations", path="/api")
# Returned verbatim on a rejected ``config`` write. Deliberately static — the
# validator's own text can carry input-derived detail, and an error body is not
# the place for it.
INVALID_CONFIG_MESSAGE = (
"Invalid config: one or more guardrail controls failed validation."
)
AGENT_TYPE_SCHEMAS = {
"classic": {
"required_published": [
"name",
"description",
"chunks",
"retriever",
"prompt_id",
],
"required_draft": ["name"],
# ``prompt_id`` intentionally omitted — the "default" sentinel
# is acceptable and maps to NULL downstream.
"validate_published": ["name", "description"],
"validate_draft": [],
# 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",
"agent_type",
"status",
"key",
"image",
"source_id",
"extra_source_ids",
"chunks",
"retriever",
"prompt_id",
"tools",
"json_schema",
"models",
"default_model_id",
"folder_id",
"limited_token_mode",
"token_limit",
"limited_request_mode",
"request_limit",
"allow_system_prompt_override",
"config",
],
},
"workflow": {
"required_published": ["name", "workflow"],
"required_draft": ["name"],
"validate_published": ["name", "workflow"],
"validate_draft": [],
"fields": [
"name",
"description",
"agent_type",
"status",
"key",
"workflow_id",
"folder_id",
"limited_token_mode",
"token_limit",
"limited_request_mode",
"request_limit",
"allow_system_prompt_override",
"config",
],
},
}
AGENT_TYPE_SCHEMAS["react"] = AGENT_TYPE_SCHEMAS["classic"]
AGENT_TYPE_SCHEMAS["agentic"] = AGENT_TYPE_SCHEMAS["classic"]
AGENT_TYPE_SCHEMAS["research"] = AGENT_TYPE_SCHEMAS["classic"]
AGENT_TYPE_SCHEMAS["openai"] = AGENT_TYPE_SCHEMAS["classic"]
def normalize_workflow_reference(workflow_value):
"""Normalize workflow references from form/json payloads into a string id."""
if workflow_value is None:
return None
if isinstance(workflow_value, dict):
return (
workflow_value.get("id")
or workflow_value.get("_id")
or workflow_value.get("workflow_id")
)
if isinstance(workflow_value, str):
value = workflow_value.strip()
if not value:
return ""
try:
parsed = json.loads(value)
if isinstance(parsed, str):
return parsed.strip()
if isinstance(parsed, dict):
return (
parsed.get("id") or parsed.get("_id") or parsed.get("workflow_id")
)
except json.JSONDecodeError:
pass
return value
return str(workflow_value)
def _resolve_workflow_for_user(conn, workflow_value, user):
"""Resolve and ownership-check a workflow value, returning its PG UUID."""
workflow_id = normalize_workflow_reference(workflow_value)
if not workflow_id:
return None, None
repo = WorkflowsRepository(conn)
if looks_like_uuid(workflow_id):
workflow = repo.get(workflow_id, user)
else:
workflow = repo.get_by_legacy_id(workflow_id, user)
if workflow is None:
return None, make_response(
jsonify({"success": False, "message": "Workflow not found"}), 404
)
return str(workflow["id"]), None
def _resolve_folder_id(conn, folder_id, user):
"""Resolve a folder id (UUID or legacy) to its PG UUID; error response otherwise."""
if not folder_id:
return None, None
repo = AgentFoldersRepository(conn)
folder = None
if looks_like_uuid(folder_id):
folder = repo.get(folder_id, user)
if folder is None:
folder = repo.get_by_legacy_id(folder_id, user)
if folder is None:
return None, make_response(
jsonify({"success": False, "message": "Folder not found"}), 404
)
return str(folder["id"]), None
def _reject(message: str, user: str, field: str = "-"):
"""Log a request-validation rejection at WARN and return its 400 response.
Every validation branch in the agent write paths used to ``make_response``
a 400 without logging anything, so a rejected update left no server-side
trace — the only evidence was the request span's status code. The client
compounded it by discarding the response body, which made an entirely
deterministic failure undiagnosable from any telemetry we keep. Route all
400s through here so the field and user always reach the logs.
Args:
message: User-facing reason, returned verbatim in the response body.
user: Subject claim of the caller, for correlating with client reports.
field: Name of the offending field, or ``"-"`` when not field-specific.
Returns:
A Flask 400 response carrying ``{"success": False, "message": ...}``.
"""
current_app.logger.warning(
"Agent update rejected: %s (field=%s, user=%s)", message, field, user
)
return make_response(jsonify({"success": False, "message": message}), 400)
def _format_agent_output(
agent: dict,
*,
pinned: bool = False,
include_key_masked: bool = True,
ownership: str = "user",
team_access: str | None = None,
resolve_names: bool = False,
) -> dict:
"""Shape a PG agent row into the outward API response dict.
Translates PG snake_case columns to the camelCase/frontend keys that
the React client expects, preserving ``source``/``sources`` naming on
the response even though storage uses ``source_id`` /
``extra_source_ids``.
``ownership`` is ``"user"`` for the caller's own agents or ``"team"`` for
ones shared with a team they're in; ``team_access`` (``viewer``/``editor``)
is set on team-shared agents so the UI can gate edit controls.
"""
source_id = agent.get("source_id")
extra_source_ids = agent.get("extra_source_ids") or []
source_value = str(source_id) if source_id else ""
sources_list = [str(s) for s in extra_source_ids if s]
out = {
"id": str(agent["id"]),
"name": agent.get("name", ""),
"slug": agent.get("slug", "") or "",
"description": agent.get("description", "") or "",
"image": (
generate_image_url(
agent["image"], agent["id"], agent.get("user_id")
)
if agent.get("image")
else ""
),
"source": source_value,
"sources": sources_list,
"chunks": str(agent["chunks"]) if agent.get("chunks") is not None else "2",
"retriever": agent.get("retriever", "") or "",
"prompt_id": str(agent["prompt_id"]) if agent.get("prompt_id") else "",
"tools": agent.get("tools", []) or [],
"tool_details": resolve_tool_details(agent.get("tools", []) or []),
"agent_type": agent.get("agent_type", "") or "",
"status": agent.get("status", "") or "",
"json_schema": agent.get("json_schema"),
"config": agent.get("config") or {},
"limited_token_mode": bool(agent.get("limited_token_mode", False)),
"token_limit": agent.get("token_limit") or settings.DEFAULT_AGENT_LIMITS["token_limit"],
"limited_request_mode": bool(agent.get("limited_request_mode", False)),
"request_limit": agent.get("request_limit") or settings.DEFAULT_AGENT_LIMITS["request_limit"],
"created_at": agent.get("created_at", ""),
"updated_at": agent.get("updated_at", ""),
"last_used_at": agent.get("last_used_at", ""),
"pinned": pinned,
"shared": bool(agent.get("shared", False)),
"shared_metadata": agent.get("shared_metadata", {}) or {},
"shared_token": agent.get("shared_token", "") or "",
"models": agent.get("models", []) or [],
"default_model_id": agent.get("default_model_id", "") or "",
"folder_id": str(agent["folder_id"]) if agent.get("folder_id") else None,
"workflow": str(agent["workflow_id"]) if agent.get("workflow_id") else None,
"allow_system_prompt_override": bool(
agent.get("allow_system_prompt_override", False)
),
"ownership": ownership,
"team_access": team_access,
}
# Resolve prompt/source NAMES by id (owner-agnostic) so a team member
# viewing a shared agent sees the owner's prompt + source names instead of
# a blank prompt / "External KB" (the client otherwise resolves these from
# the caller's own lists, which don't contain the owner's resources).
if resolve_names:
out["prompt_name"] = resolve_prompt_name(agent.get("prompt_id"))
out["source_details"] = resolve_source_details(
([source_id] if source_id else []) + list(extra_source_ids)
)
# Never expose the owner's share/API secrets to a team grantee — the
# public ``shared_token`` and the (masked) agent ``key`` are owner-only.
if ownership == "team":
out["shared_token"] = ""
return out
if include_key_masked:
key_val = agent.get("key") or ""
out["key"] = (
f"{key_val[:4]}...{key_val[-4:]}" if key_val else ""
)
return out
def _build_create_kwargs(data: dict, *, image_url: str, agent_type: str) -> dict:
"""Translate request data + resolved references into AgentsRepository.create kwargs."""
kwargs: dict = {}
schema = AGENT_TYPE_SCHEMAS.get(agent_type, AGENT_TYPE_SCHEMAS["classic"])
allowed_fields = set(schema["fields"])
for key in (
"description", "agent_type", "key", "retriever",
"default_model_id",
):
if key in allowed_fields and data.get(key) not in (None, ""):
kwargs[key] = data[key]
if image_url and "image" in allowed_fields:
kwargs["image"] = image_url
if "source_id" in allowed_fields and data.get("source_id"):
kwargs["source_id"] = data["source_id"]
if "extra_source_ids" in allowed_fields and data.get("extra_source_ids"):
kwargs["extra_source_ids"] = data["extra_source_ids"]
if "prompt_id" in allowed_fields:
prompt_val = data.get("prompt_id")
if prompt_val and prompt_val != "default" and looks_like_uuid(prompt_val):
kwargs["prompt_id"] = prompt_val
if "folder_id" in allowed_fields and data.get("folder_id"):
kwargs["folder_id"] = data["folder_id"]
if "workflow_id" in allowed_fields and data.get("workflow_id"):
kwargs["workflow_id"] = data["workflow_id"]
if "chunks" in allowed_fields:
chunks_val = data.get("chunks")
if chunks_val not in (None, ""):
try:
kwargs["chunks"] = int(chunks_val)
except (TypeError, ValueError):
current_app.logger.debug(
"Ignoring invalid 'chunks' value while building agent create kwargs: %r",
chunks_val,
)
for key in ("limited_token_mode", "limited_request_mode", "allow_system_prompt_override"):
if key in allowed_fields and key in data:
raw = data[key]
kwargs[key] = raw == "True" if isinstance(raw, str) else bool(raw)
for key in ("token_limit", "request_limit"):
if key in allowed_fields and data.get(key) not in (None, ""):
try:
kwargs[key] = int(data[key])
except (TypeError, ValueError):
current_app.logger.debug(
"Ignoring invalid %s value while building agent create kwargs: %r",
key,
data.get(key),
)
if "tools" in allowed_fields and data.get("tools") is not None:
kwargs["tools"] = data["tools"]
if "json_schema" in allowed_fields and data.get("json_schema") is not None:
kwargs["json_schema"] = data["json_schema"]
if "models" in allowed_fields and data.get("models") is not None:
kwargs["models"] = data["models"]
if "config" in allowed_fields and data.get("config") is not None:
kwargs["config"] = data["config"]
return kwargs
def normalize_agent_config(raw):
"""Validate an inbound ``config`` payload, returning the normalized dict.
Strict on write: an unknown check, an action a stage cannot honour, or bad
per-check settings is a 400 rather than a silently-ignored control that the
operator believes is protecting them.
Args:
raw: The ``config`` value from the request (dict, JSON string, or None).
Returns:
The normalized config dict, or None when nothing was supplied.
Raises:
ValueError: When the payload cannot be validated.
"""
if raw is None or raw == "":
return None
if isinstance(raw, str):
try:
raw = json.loads(raw)
except (json.JSONDecodeError, ValueError):
raise ValueError("config must be a JSON object")
if not isinstance(raw, dict):
raise ValueError("config must be a JSON object")
try:
return AgentConfig.model_validate(raw).model_dump(mode="json")
except PydanticValidationError as exc:
raise ValueError(_first_pydantic_error(exc))
def _first_pydantic_error(exc: PydanticValidationError) -> str:
"""Render the first validation error as a short, user-facing message."""
errors = exc.errors()
if not errors:
return "config is invalid"
first = errors[0]
location = ".".join(str(part) for part in first.get("loc", ()) if part != "__root__")
message = str(first.get("msg", "invalid")).replace("Value error, ", "")
return f"config.{location}: {message}" if location else f"config: {message}"
@agents_ns.route("/get_agent")
class GetAgent(Resource):
@api.doc(params={"id": "Agent ID"}, description="Get agent by ID")
def get(self):
if not (decoded_token := request.decoded_token):
return {"success": False}, 401
if not (agent_id := request.args.get("id")):
return {"success": False, "message": "ID required"}, 400
try:
user = decoded_token["sub"]
ownership, team_access = "user", None
with db_readonly() as conn:
repo = AgentsRepository(conn)
agent = repo.get_any(agent_id, user)
if not agent:
# Team fallback: only after a grant check, fetch ownerless.
team_access = team_access_for(conn, user, "agent", agent_id)
if team_access:
agent = repo.get_by_id(agent_id)
ownership = "team"
if not agent:
return {"status": "Not found"}, 404
data = _format_agent_output(
agent,
ownership=ownership,
team_access=team_access,
resolve_names=True,
)
return make_response(jsonify(data), 200)
except Exception as e:
current_app.logger.error(f"Agent fetch error: {e}", exc_info=True)
return {"success": False}, 400
@agents_ns.route("/get_agents")
class GetAgents(Resource):
@api.doc(description="Retrieve agents for the user")
def get(self):
if not (decoded_token := request.decoded_token):
return {"success": False}, 401
user = decoded_token.get("sub")
try:
with db_session() as conn:
users_repo = UsersRepository(conn)
user_doc = users_repo.upsert(user)
pinned_ids = set(
user_doc.get("agent_preferences", {}).get("pinned", [])
if isinstance(user_doc.get("agent_preferences"), dict)
else []
)
agents_repo = AgentsRepository(conn)
agents = agents_repo.list_for_user(user)
owned_ids = {str(a["id"]) for a in agents}
# Append agents shared with the caller's teams (dedup vs owned).
team_shared = visible_with_access(conn, user, "agent")
shared_ids = [aid for aid in team_shared if aid not in owned_ids]
shared_agents = agents_repo.list_by_ids(shared_ids)
# Every agent is listed: one with no source skips retrieval and
# answers from the model and its tools, so it is still runnable.
list_agents = [
_format_agent_output(agent, pinned=str(agent["id"]) in pinned_ids)
for agent in agents
]
list_agents += [
_format_agent_output(
agent,
ownership="team",
team_access=team_shared.get(str(agent["id"])),
)
for agent in shared_agents
]
except Exception as err:
current_app.logger.error(f"Error retrieving agents: {err}", exc_info=True)
return make_response(jsonify({"success": False}), 400)
return make_response(jsonify(list_agents), 200)
@agents_ns.route("/create_agent")
class CreateAgent(Resource):
create_agent_model = api.model(
"CreateAgentModel",
{
"name": fields.String(required=True, description="Name of the agent"),
"description": fields.String(
required=True, description="Description of the agent"
),
"image": fields.Raw(
required=False, description="Image file upload", type="file"
),
"source": fields.String(
required=False, description="Source ID (legacy single source)"
),
"sources": fields.List(
fields.String,
required=False,
description="List of source identifiers for multiple sources",
),
"chunks": fields.Integer(required=False, description="Chunks count"),
"retriever": fields.String(required=False, description="Retriever ID"),
"prompt_id": fields.String(required=False, description="Prompt ID"),
"tools": fields.List(
fields.String, required=False, description="List of tool identifiers"
),
"agent_type": fields.String(
required=False,
description="Type of the agent (classic, react, workflow). Defaults to 'classic' for backwards compatibility.",
),
"status": fields.String(
required=True, description="Status of the agent (draft or published)"
),
"workflow": fields.String(
required=False, description="Workflow ID for workflow-type agents"
),
"json_schema": fields.Raw(
required=False,
description="JSON schema for enforcing structured output format",
),
"limited_token_mode": fields.Boolean(
required=False, description="Whether the agent is in limited token mode"
),
"token_limit": fields.Integer(
required=False, description="Token limit for the agent in limited mode"
),
"limited_request_mode": fields.Boolean(
required=False,
description="Whether the agent is in limited request mode",
),
"request_limit": fields.Integer(
required=False,
description="Request limit for the agent in limited mode",
),
"models": fields.List(
fields.String,
required=False,
description="List of available model IDs for this agent",
),
"default_model_id": fields.String(
required=False, description="Default model ID for this agent"
),
"folder_id": fields.String(
required=False, description="Folder ID to organize the agent"
),
"allow_system_prompt_override": fields.Boolean(
required=False,
description="Allow API callers to override the system prompt via the v1 endpoint",
),
},
)
@api.expect(create_agent_model)
@api.doc(description="Create a new agent")
def post(self):
if not (decoded_token := request.decoded_token):
return {"success": False}, 401
user = decoded_token.get("sub")
if request.content_type == "application/json":
data = request.get_json()
else:
data = request.form.to_dict()
if "tools" in data:
try:
data["tools"] = json.loads(data["tools"])
except json.JSONDecodeError:
data["tools"] = []
if "sources" in data:
try:
data["sources"] = json.loads(data["sources"])
except json.JSONDecodeError:
data["sources"] = []
if "json_schema" in data:
try:
data["json_schema"] = json.loads(data["json_schema"])
except json.JSONDecodeError:
data["json_schema"] = None
if "models" in data:
try:
data["models"] = json.loads(data["models"])
except json.JSONDecodeError:
data["models"] = []
if "json_schema" in data:
try:
data["json_schema"] = normalize_json_schema_payload(
data.get("json_schema")
)
except JsonSchemaValidationError:
return make_response(
jsonify({"success": False, "message": "Invalid JSON schema"}),
400,
)
if "config" in data:
try:
normalized_config = normalize_agent_config(data.get("config"))
except ValueError as exc:
# Static message, detail to the log: validation internals must
# not reach the caller (same policy as SourceConfig writes in
# api/user/sources/routes.py). The builder validates the same
# rules client-side, so this path is for API callers.
current_app.logger.warning(
"Agent config rejected on create (user=%s): %s", user, exc
)
return make_response(
jsonify({"success": False, "message": INVALID_CONFIG_MESSAGE}),
400,
)
if normalized_config is None:
data.pop("config", None)
else:
data["config"] = normalized_config
if data.get("status") not in ["draft", "published"]:
return make_response(
jsonify(
{
"success": False,
"message": "Status must be either 'draft' or 'published'",
}
),
400,
)
agent_type = data.get("agent_type", "")
if not agent_type or agent_type not in AGENT_TYPE_SCHEMAS:
schema = AGENT_TYPE_SCHEMAS["classic"]
if not agent_type:
agent_type = "classic"
else:
schema = AGENT_TYPE_SCHEMAS[agent_type]
is_published = data.get("status") == "published"
if data.get("status") == "published":
required_fields = schema["required_published"]
validate_fields = schema["validate_published"]
else:
required_fields = schema["required_draft"]
validate_fields = schema["validate_draft"]
missing_fields = check_required_fields(data, required_fields)
invalid_fields = validate_required_fields(data, validate_fields)
if missing_fields:
return missing_fields
if invalid_fields:
return invalid_fields
image_url, error = handle_image_upload(request, "", user, storage)
if error:
return make_response(
jsonify({"success": False, "message": "Image upload failed"}), 400
)
try:
key = str(uuid.uuid4()) if is_published else ""
with db_session() as conn:
# Resolve folder.
pg_folder_id = None
if data.get("folder_id"):
pg_folder_id, err = _resolve_folder_id(
conn, data["folder_id"], user,
)
if err:
return err
# Resolve workflow for workflow-type agents.
pg_workflow_id = None
if agent_type == "workflow":
pg_workflow_id, err = _resolve_workflow_for_user(
conn, data.get("workflow"), user,
)
if err and is_published:
return err
if pg_workflow_id is None and is_published:
return make_response(
jsonify({"success": False, "message": "Workflow is required"}),
400,
)
# Resolve sources — only UUIDs accepted post-cutover.
source_id_resolved = None
extra_source_ids: list[str] = []
if data.get("sources"):
for src in data["sources"]:
if src == "default":
continue
if looks_like_uuid(src):
extra_source_ids.append(src)
else:
source_value = data.get("source", "")
if source_value and source_value != "default" and looks_like_uuid(source_value):
source_id_resolved = source_value
# Team-sharing write gate: you may reference sources/prompts you
# own or that a team has shared with you directly. (Transitive
# access through a shared agent is a separate run-time concept,
# intentionally not gated here.)
referenced_sources = (
[source_id_resolved] if source_id_resolved else []
) + extra_source_ids
for sid in referenced_sources:
if not can_access(conn, "source", sid, user):
return make_response(
jsonify({"success": False, "message": "Source not accessible"}),
403,
)
prompt_ref = data.get("prompt_id")
if prompt_ref and prompt_ref != "default" and looks_like_uuid(prompt_ref):
if not can_access(conn, "prompt", prompt_ref, user):
return make_response(
jsonify({"success": False, "message": "Prompt not accessible"}),
403,
)
build_data = dict(data)
build_data["folder_id"] = pg_folder_id
build_data["workflow_id"] = pg_workflow_id
build_data["source_id"] = source_id_resolved
build_data["extra_source_ids"] = extra_source_ids
build_data["key"] = key
build_data["agent_type"] = agent_type
# For classic agents: default chunks/retriever if nothing else supplied.
if agent_type != "workflow":
if build_data.get("chunks") in (None, ""):
build_data["chunks"] = 2
if (
not source_id_resolved
and not extra_source_ids
and not build_data.get("retriever")
):
build_data["retriever"] = "classic"
kwargs = _build_create_kwargs(
build_data, image_url=image_url, agent_type=agent_type,
)
agent_row = AgentsRepository(conn).create(
user,
data["name"],
data["status"],
**kwargs,
)
new_id = str(agent_row["id"])
except Exception as err:
current_app.logger.error(f"Error creating agent: {err}", exc_info=True)
return make_response(jsonify({"success": False}), 400)
return make_response(jsonify({"id": new_id, "key": key}), 201)
@agents_ns.route("/update_agent/<string:agent_id>")
class UpdateAgent(Resource):
update_agent_model = api.model(
"UpdateAgentModel",
{
"name": fields.String(required=True, description="New name of the agent"),
"description": fields.String(
required=True, description="New description of the agent"
),
"image": fields.Raw(
required=False, description="Image file upload", type="file"
),
"source": fields.String(
required=False, description="Source ID (legacy single source)"
),
"sources": fields.List(
fields.String,
required=False,
description="List of source identifiers for multiple sources",
),
"chunks": fields.Integer(required=False, description="Chunks count"),
"retriever": fields.String(required=False, description="Retriever ID"),
"prompt_id": fields.String(required=False, description="Prompt ID"),
"tools": fields.List(
fields.String, required=False, description="List of tool identifiers"
),
"agent_type": fields.String(
required=False,
description="Type of the agent (classic, react, workflow). Defaults to 'classic' for backwards compatibility.",
),
"status": fields.String(
required=True, description="Status of the agent (draft or published)"
),
"workflow": fields.String(
required=False, description="Workflow ID for workflow-type agents"
),
"json_schema": fields.Raw(
required=False,
description="JSON schema for enforcing structured output format",
),
"limited_token_mode": fields.Boolean(
required=False, description="Whether the agent is in limited token mode"
),
"token_limit": fields.Integer(
required=False, description="Token limit for the agent in limited mode"
),
"limited_request_mode": fields.Boolean(
required=False,
description="Whether the agent is in limited request mode",
),
"request_limit": fields.Integer(
required=False,
description="Request limit for the agent in limited mode",
),
"models": fields.List(
fields.String,
required=False,
description="List of available model IDs for this agent",
),
"default_model_id": fields.String(
required=False, description="Default model ID for this agent"
),
"folder_id": fields.String(
required=False, description="Folder ID to organize the agent"
),
"allow_system_prompt_override": fields.Boolean(
required=False,
description="Allow API callers to override the system prompt via the v1 endpoint",
),
},
)
@api.expect(update_agent_model)
@api.doc(description="Update an existing agent")
def put(self, agent_id):
if not (decoded_token := request.decoded_token):
return make_response(
jsonify({"success": False, "message": "Unauthorized"}), 401
)
user = decoded_token.get("sub")
try:
if request.content_type and "application/json" in request.content_type:
data = request.get_json()
else:
data = request.form.to_dict()
json_fields = ["tools", "sources", "json_schema", "models", "config"]
for field in json_fields:
if field in data and data[field]:
try:
data[field] = json.loads(data[field])
except json.JSONDecodeError:
return _reject(
f"Invalid JSON format for field: {field}",
user,
field,
)
if data.get("json_schema") == "":
data["json_schema"] = None
except Exception as err:
current_app.logger.error(
f"Error parsing request data: {err}", exc_info=True
)
return make_response(
jsonify({"success": False, "message": "Invalid request data"}), 400
)
try:
with db_session() as conn:
agents_repo = AgentsRepository(conn)
is_team_editor = False
existing_agent = agents_repo.get_any(agent_id, user)
if not existing_agent:
# Team write path: only an 'editor' grant may modify a
# team-shared agent; a 'viewer' is read-only. Fetch the
# ownerless row only AFTER confirming editor access.
access = team_access_for(conn, user, "agent", agent_id)
if access == "editor":
existing_agent = agents_repo.get_by_id(agent_id)
is_team_editor = True
elif access == "viewer":
return make_response(
jsonify(
{"success": False, "message": "Read-only: editor access required"}
),
403,
)
if not existing_agent:
return make_response(
jsonify(
{"success": False, "message": "Agent not found or not authorized"}
),
404,
)
pg_agent_id = str(existing_agent["id"])
existing_image = existing_agent.get("image", "") or ""
image_url, image_error = handle_image_upload(
request,
existing_image,
existing_agent.get("user_id") or user,
storage,
)
if image_error:
return image_error
update_fields: dict = {}
allowed_fields = [
"name",
"description",
"source",
"sources",
"chunks",
"retriever",
"prompt_id",
"tools",
"agent_type",
"status",
"json_schema",
"limited_token_mode",
"token_limit",
"limited_request_mode",
"request_limit",
"models",
"config",
"default_model_id",
"folder_id",
"workflow",
"allow_system_prompt_override",
]
for field in allowed_fields:
if field not in data:
continue
if field == "status":
new_status = data.get("status")
if new_status not in ["draft", "published"]:
return _reject(
"Invalid status value. Must be 'draft' or 'published'",
user,
field,
)
update_fields["status"] = new_status
elif field == "source":
source_id = data.get("source")
if not source_id or source_id == "default":
update_fields["source_id"] = None
elif looks_like_uuid(source_id):
update_fields["source_id"] = source_id
else:
return _reject(
f"Invalid source ID format: {source_id}", user, field
)
elif field == "sources":
sources_list = data.get("sources", []) or []
if not isinstance(sources_list, list):
update_fields["extra_source_ids"] = []
continue
valid: list[str] = []
for src in sources_list:
if src == "default":
continue
if looks_like_uuid(src):
valid.append(src)
else:
return _reject(
f"Invalid source ID in list: {src}", user, field
)
update_fields["extra_source_ids"] = valid
elif field == "chunks":
chunks_value = data.get("chunks")
if chunks_value in ("", None):
update_fields["chunks"] = 2
else:
try:
chunks_int = int(chunks_value)
if chunks_int < 0:
return _reject(
"Chunks value must be a non-negative integer",
user,
field,
)
update_fields["chunks"] = chunks_int
except (ValueError, TypeError):
return _reject(
f"Invalid chunks value: {chunks_value}", user, field
)
elif field == "tools":
tools_list = data.get("tools", [])
if not isinstance(tools_list, list):
return _reject("Tools must be a list", user, field)
update_fields["tools"] = tools_list
elif field == "json_schema":
json_schema = data.get("json_schema")
if json_schema is not None:
try:
update_fields["json_schema"] = normalize_json_schema_payload(
json_schema
)
except JsonSchemaValidationError:
return _reject("Invalid JSON schema", user, field)
else:
update_fields["json_schema"] = None
elif field == "config":
try:
normalized_config = normalize_agent_config(data.get("config"))
except ValueError as exc:
current_app.logger.warning(
"Agent config rejected on update (user=%s): %s",
user,
exc,
)
return _reject(INVALID_CONFIG_MESSAGE, user, field)
update_fields["config"] = normalized_config or {}
elif field == "limited_token_mode":
raw_value = data.get("limited_token_mode", False)
bool_value = (
raw_value == "True"
if isinstance(raw_value, str)
else bool(raw_value)
)
update_fields["limited_token_mode"] = bool_value
if bool_value and data.get("token_limit") is None:
return _reject(
"Token limit must be provided when limited token mode is enabled",
user,
field,
)
elif field == "limited_request_mode":
raw_value = data.get("limited_request_mode", False)
bool_value = (
raw_value == "True"
if isinstance(raw_value, str)
else bool(raw_value)
)
update_fields["limited_request_mode"] = bool_value
if bool_value and data.get("request_limit") is None:
return _reject(
"Request limit must be provided when limited request mode is enabled",
user,
field,
)
elif field == "token_limit":
token_limit = data.get("token_limit")
update_fields["token_limit"] = int(token_limit) if token_limit else 0
# NOTE: unreachable from a multipart/form submit. ``data``
# then comes from ``request.form.to_dict()``, so this is
# the *string* "False" and ``not "False"`` is False. Left
# as-is deliberately: tightening it here would start
# rejecting form payloads that currently succeed.
if update_fields["token_limit"] > 0 and not data.get("limited_token_mode"):
return _reject(
"Token limit cannot be set when limited token mode is disabled",
user,
field,
)
elif field == "request_limit":
request_limit = data.get("request_limit")
update_fields["request_limit"] = int(request_limit) if request_limit else 0
# Same string-truthiness caveat as ``token_limit`` above.
if update_fields["request_limit"] > 0 and not data.get("limited_request_mode"):
return _reject(
"Request limit cannot be set when limited request mode is disabled",
user,
field,
)
elif field == "folder_id":
folder_input = data.get("folder_id")
if folder_input:
pg_folder_id, folder_err = _resolve_folder_id(
conn, folder_input, user,
)
if folder_err:
return folder_err
update_fields["folder_id"] = pg_folder_id
else:
update_fields["folder_id"] = None
elif field == "workflow":
workflow_required = (
data.get("status", existing_agent.get("status")) == "published"
and data.get("agent_type", existing_agent.get("agent_type"))
== "workflow"
)
workflow_input = data.get("workflow")
normalized = normalize_workflow_reference(workflow_input)
if not normalized:
if workflow_required:
return _reject("Workflow is required", user, field)
update_fields["workflow_id"] = None
else:
pg_workflow_id, wf_err = _resolve_workflow_for_user(
conn, workflow_input, user,
)
if wf_err:
return wf_err
update_fields["workflow_id"] = pg_workflow_id
elif field == "prompt_id":
value = data["prompt_id"]
if not value or value == "default":
update_fields["prompt_id"] = None
elif looks_like_uuid(value):
update_fields["prompt_id"] = value
else:
return _reject(f"Invalid prompt_id: {value}", user, field)
elif field == "allow_system_prompt_override":
raw_value = data.get("allow_system_prompt_override", False)
update_fields["allow_system_prompt_override"] = (
raw_value == "True"
if isinstance(raw_value, str)
else bool(raw_value)
)
else:
value = data[field]
if field in ["name", "description", "agent_type"]:
if not value or not str(value).strip():
return _reject(
f"Field '{field}' cannot be empty", user, field
)
update_fields[field] = value
if image_url and image_url != existing_image:
update_fields["image"] = image_url
if not update_fields:
return _reject("No valid update data provided", user)
newly_generated_key = None
final_status = update_fields.get("status", existing_agent.get("status"))
final_agent_type = update_fields.get(
"agent_type", existing_agent.get("agent_type")
)
if final_status == "published":
if final_agent_type == "workflow":
missing_published_fields = []
if not update_fields.get("name", existing_agent.get("name")):
missing_published_fields.append("Agent name")
workflow_final = update_fields.get(
"workflow_id", existing_agent.get("workflow_id"),
)
if not workflow_final:
missing_published_fields.append("Workflow")
if missing_published_fields:
return _reject(
"Cannot publish workflow agent. Missing required "
f"fields: {', '.join(missing_published_fields)}",
user,
",".join(missing_published_fields),
)
else:
# ``prompt_id`` is intentionally omitted: the
# frontend's "default" choice maps to NULL here
# (see the prompt_id branch above), and NULL
# means "use the built-in default prompt" which
# is a valid published-agent state.
missing_published_fields = []
for req_field, field_label in (
("name", "Agent name"),
("description", "Agent description"),
("chunks", "Chunks count"),
("agent_type", "Agent type"),
):
final_value = update_fields.get(
req_field, existing_agent.get(req_field)
)
if not final_value:
missing_published_fields.append(field_label)
# 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 "
f"fields: {', '.join(missing_published_fields)}",
user,
",".join(missing_published_fields),
)
if not existing_agent.get("key"):
newly_generated_key = str(uuid.uuid4())
update_fields["key"] = newly_generated_key
# Re-validate referenced sources/prompt on write — a team editor
# must not ATTACH a source/prompt they can't access. References
# already on the agent (set by the owner) are exempt: an editor
# may keep them even when they aren't independently shared, so a
# plain save of an owner-configured agent isn't rejected — only
# NEWLY added refs need a grant. Owners pass can_access for their
# own resources regardless, so this carve-out is a no-op for them
# and the gate stays effective against an editor adding new refs.
# Mirrors the unchanged-tool carve-out below.
existing_source_refs = {
str(s)
for s in (
[existing_agent.get("source_id")]
+ list(existing_agent.get("extra_source_ids") or [])
)
if s
}
referenced_sources = (
([update_fields["source_id"]] if update_fields.get("source_id") else [])
+ (update_fields.get("extra_source_ids") or [])
)
for sid in referenced_sources:
if str(sid) in existing_source_refs:
continue
if not can_access(conn, "source", sid, user):
return make_response(
jsonify({"success": False, "message": "Source not accessible"}), 403
)
new_prompt_id = update_fields.get("prompt_id")
if (
new_prompt_id
and str(new_prompt_id) != str(existing_agent.get("prompt_id") or "")
and not can_access(conn, "prompt", new_prompt_id, user)
):
return make_response(
jsonify({"success": False, "message": "Prompt not accessible"}), 403
)
# A team editor must not attach tools they can't access onto a
# shared agent: at run time the agent-key path resolves+decrypts
# tools as the OWNER, so an unchecked tool here would let an
# editor invoke arbitrary owner credentials. Owners are
# unrestricted (they own their tools). Default/builtin synthetic
# tool ids belong to no one and are always allowed.
if is_team_editor and "tools" in update_fields:
from docsgpt.agents.default_tools import is_synthesized_tool_id
existing_tools = {
str(t) for t in (existing_agent.get("tools") or [])
}
for tid in update_fields["tools"] or []:
tid_s = str(tid)
if tid_s in existing_tools or is_synthesized_tool_id(tid_s):
continue
if not can_access(conn, "tool", tid_s, user):
return make_response(
jsonify(
{"success": False, "message": "Tool not accessible"}
),
403,
)
# Per-agent quota lives on the row and is pooled across all
# members; only the owner may resize that shared pool, so a
# team editor's quota changes are dropped.
if is_team_editor:
for _q in (
"token_limit", "request_limit",
"limited_token_mode", "limited_request_mode",
# Guardrails are the owner's policy for their agent.
# An editor who could clear them would silently strip
# protection from everyone else using it.
"config",
):
update_fields.pop(_q, None)
# Apply update. Owner writes use the dual-key guard; team-editor
# writes go by-id (already authorized) with an optimistic-lock
# check when the client supplies the row's expected updated_at.
if is_team_editor:
result = agents_repo.update_by_id(
pg_agent_id,
update_fields,
expected_updated_at=data.get("expected_updated_at"),
)
if result is None:
return make_response(
jsonify(
{
"success": False,
"message": "Agent was modified by someone else",
"code": "stale_write",
}
),
409,
)
updated = bool(result)
else:
updated = agents_repo.update(pg_agent_id, user, update_fields)
if not updated:
return make_response(
jsonify(
{
"success": False,
"message": "Agent not found or update failed",
}
),
404,
)
except Exception as err:
current_app.logger.error(
f"Error updating agent {agent_id}: {err}", exc_info=True
)
return make_response(
jsonify({"success": False, "message": "Database error during update"}),
500,
)
response_data = {
"success": True,
"id": pg_agent_id,
"message": "Agent updated successfully",
}
if newly_generated_key:
response_data["key"] = newly_generated_key
return make_response(jsonify(response_data), 200)
@agents_ns.route("/regenerate_agent_key/<string:agent_id>")
class RegenerateAgentKey(Resource):
@api.doc(
params={"agent_id": "ID of the agent"},
description=(
"Rotate an agent's API key. The previous key is invalidated "
"immediately and a fresh key is returned once. Historical logging "
"and usage records are re-pointed to the new key so analytics stay "
"intact. Owner only."
),
)
def post(self, agent_id):
if not (decoded_token := request.decoded_token):
return make_response(
jsonify({"success": False, "message": "Unauthorized"}), 401
)
user = decoded_token.get("sub")
try:
with db_session() as conn:
agents_repo = AgentsRepository(conn)
# Owner-only: rotating a credential is destructive to live
# integrations, so this is intentionally stricter than
# update_agent (which also allows team editors).
existing_agent = agents_repo.get_any(agent_id, user)
if not existing_agent:
return make_response(
jsonify(
{
"success": False,
"message": "Agent not found or not authorized",
}
),
404,
)
pg_agent_id = str(existing_agent["id"])
old_key = existing_agent.get("key")
if not old_key:
# Draft agents have no key; the first key is minted on
# publish. Nothing to rotate.
return make_response(
jsonify(
{
"success": False,
"message": "Publish the agent before resetting its key",
}
),
400,
)
new_key = str(uuid.uuid4())
updated = agents_repo.update(pg_agent_id, user, {"key": new_key})
if not updated:
return make_response(
jsonify(
{
"success": False,
"message": "Failed to regenerate key",
}
),
500,
)
# Re-point every api_key reference to the new key so rotation
# doesn't break history or live links. token_usage/stack_logs
# (0026) carry agent_id, but rows can still be api_key-only:
# conversations set api_key without an agent_id unless the caller
# passes one, stack_logs rows may have a NULL agent_id, and the
# 24h rate-limit window is keyed by api_key only (token_usage).
# shared_conversations is functional, not just analytics: the
# public share endpoint hands its stored api_key to the widget,
# so a stale key would break promptable shared links.
# All rewrites run in the same transaction as the key swap.
StackLogsRepository(conn).reassign_api_key(
old_key=old_key, new_key=new_key
)
TokenUsageRepository(conn).reassign_api_key(
old_key=old_key, new_key=new_key
)
ConversationsRepository(conn).reassign_api_key(
old_key=old_key, new_key=new_key
)
SharedConversationsRepository(conn).reassign_api_key(
old_key=old_key, new_key=new_key
)
except Exception as err:
current_app.logger.error(
f"Error regenerating agent key: {err}", exc_info=True
)
return make_response(
jsonify(
{"success": False, "message": "Error regenerating agent key"}
),
500,
)
return make_response(jsonify({"success": True, "key": new_key}), 200)
@agents_ns.route("/delete_agent")
class DeleteAgent(Resource):
@api.doc(params={"id": "ID of the agent"}, description="Delete an agent by ID")
def delete(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
agent_id = request.args.get("id")
if not agent_id:
return make_response(
jsonify({"success": False, "message": "ID is required"}), 400
)
try:
with db_session() as conn:
agents_repo = AgentsRepository(conn)
agent = agents_repo.get_any(agent_id, user)
if not agent:
return make_response(
jsonify({"success": False, "message": "Agent not found"}), 404
)
pg_agent_id = str(agent["id"])
workflow_id = agent.get("workflow_id")
# For workflow-type agents, delete the owned workflow in the
# same transaction. workflow_nodes/workflow_edges cascade
# via ON DELETE CASCADE so the single owner-scoped delete
# suffices — and it must stay the ONLY delete: an explicit
# (unscoped) node/edge cleanup here once let deleting an agent
# whose ``workflow_id`` pointed at another user's workflow gut
# that user's graph.
if agent.get("agent_type") == "workflow" and workflow_id:
try:
WorkflowsRepository(conn).delete(str(workflow_id), user)
except Exception as wf_err:
current_app.logger.warning(
f"Workflow cleanup failed for agent {pg_agent_id}: {wf_err}"
)
agents_repo.delete(pg_agent_id, user)
# Strip pinned/shared entries for this agent from the owner's prefs.
UsersRepository(conn).remove_agent_from_all(user, pg_agent_id)
except Exception as err:
current_app.logger.error(f"Error deleting agent: {err}", exc_info=True)
return make_response(jsonify({"success": False}), 400)
return make_response(jsonify({"id": pg_agent_id}), 200)
def _user_may_pin(conn, agent: dict, user_id: str, shared_with_me: set) -> bool:
"""Whether ``user_id`` may pin, and keep reading, ``agent``.
Pinning follows visibility: the owner, a team grantee, anyone who opened a
still-live share link, and the premade system templates. Without this gate a
pin is an unscoped read of any agent id.
Args:
conn: Open database connection.
agent: The agent row, needing at least ``id``, ``user_id`` and ``shared``.
user_id: The caller.
shared_with_me: Agent ids the caller reached through a share link.
Returns:
True if the caller is allowed to see the agent.
"""
owner_id = agent.get("user_id")
if owner_id == user_id:
return True
if owner_id in ("system", "__system__"):
return True
agent_id = str(agent["id"])
if agent.get("shared") and agent_id in shared_with_me:
return True
return can_access(conn, "agent", agent_id, user_id)
@agents_ns.route("/pinned_agents")
class PinnedAgents(Resource):
@api.doc(description="Get pinned agents for the user")
def get(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user_id = decoded_token.get("sub")
try:
with db_session() as conn:
users_repo = UsersRepository(conn)
user_doc = users_repo.upsert(user_id)
prefs = (
user_doc.get("agent_preferences", {})
if isinstance(user_doc.get("agent_preferences"), dict)
else {}
)
pinned_ids = prefs.get("pinned", []) or []
shared_with_me = set(prefs.get("shared_with_me", []) or [])
if not pinned_ids:
return make_response(jsonify([]), 200)
uuid_pinned = [pid for pid in pinned_ids if looks_like_uuid(pid)]
non_uuid = [pid for pid in pinned_ids if not looks_like_uuid(pid)]
if uuid_pinned:
from sqlalchemy import text as _sql_text
result = conn.execute(
_sql_text(
"SELECT * FROM agents "
"WHERE id = ANY(CAST(:ids AS uuid[]))"
),
{"ids": uuid_pinned},
)
pinned_agents = [dict(row._mapping) for row in result.fetchall()]
else:
pinned_agents = []
existing_ids = {str(a["id"]) for a in pinned_agents}
stale = [pid for pid in uuid_pinned if pid not in existing_ids]
stale.extend(non_uuid)
if stale:
users_repo.remove_pinned_bulk(user_id, stale)
# A pin is not proof of access: a share or team grant can be
# revoked long after the agent was pinned, so re-check every
# row on read rather than trusting the stored id list.
pinned_agents = [
agent
for agent in pinned_agents
if _user_may_pin(conn, agent, user_id, shared_with_me)
]
list_pinned_agents = []
for agent in pinned_agents:
source_id = agent.get("source_id")
list_pinned_agents.append(
{
"id": str(agent["id"]),
"name": agent.get("name", ""),
"description": agent.get("description", ""),
"image": (
generate_image_url(
agent["image"], agent["id"], agent.get("user_id")
)
if agent.get("image")
else ""
),
"source": str(source_id) if source_id else "",
"chunks": str(agent["chunks"]) if agent.get("chunks") is not None else "",
"retriever": agent.get("retriever", "") or "",
"prompt_id": str(agent["prompt_id"]) if agent.get("prompt_id") else "",
"tools": agent.get("tools", []) or [],
"tool_details": resolve_tool_details(agent.get("tools", []) or []),
"agent_type": agent.get("agent_type", "") or "",
"status": agent.get("status", "") or "",
"created_at": agent.get("created_at", ""),
"updated_at": agent.get("updated_at", ""),
"last_used_at": agent.get("last_used_at", ""),
"key": (
f"{agent['key'][:4]}...{agent['key'][-4:]}"
if agent.get("key") and agent.get("user_id") == user_id
else ""
),
"pinned": True,
}
)
except Exception as err:
current_app.logger.error(f"Error retrieving pinned agents: {err}")
return make_response(jsonify({"success": False}), 400)
return make_response(jsonify(list_pinned_agents), 200)
@agents_ns.route("/template_agents")
class GetTemplateAgents(Resource):
@api.doc(description="Get template/premade agents")
def get(self):
try:
from sqlalchemy import text as _sql_text
with db_readonly() as conn:
result = conn.execute(
_sql_text(
"SELECT * FROM agents "
"WHERE user_id IN ('system', '__system__') "
"ORDER BY name"
),
)
template_rows = [dict(row._mapping) for row in result.fetchall()]
template_agents = [
{
"id": str(agent["id"]),
"name": agent.get("name"),
"description": agent.get("description") or "",
"image": (
generate_image_url(
agent["image"], agent["id"], agent.get("user_id")
)
if agent.get("image")
else ""
),
}
for agent in template_rows
]
return make_response(jsonify(template_agents), 200)
except Exception as e:
current_app.logger.error(f"Template agents fetch error: {e}", exc_info=True)
return make_response(jsonify({"success": False}), 400)
def _filter_adoptable_tool_ids(conn, user: str, tool_ids) -> list:
"""Keep only tool ids the adopter can actually run.
Builtin synthetic ids resolve for everyone; a ``user_tools`` row must be
the adopter's own — the runtime resolves tools strictly owner-scoped, so
a template owner's tool id would just be dropped (with a log line) on
every run. Filtering at adopt time makes the gap visible in the builder
instead of at run time.
"""
tools_repo = UserToolsRepository(conn)
kept = []
for tid in tool_ids or []:
tid = str(tid)
if is_synthesized_tool_id(tid) or (
looks_like_uuid(tid) and tools_repo.get_any(tid, user)
):
kept.append(tid)
return kept
def _strip_foreign_node_refs(conn, user: str):
"""Build a ``clone_to_user`` node-config transform for adoption.
Agent nodes carry raw tool/source/model ids from the template owner. The
engine resolves all three owner-scoped at run time (dropping misses with
only a log line), so unresolvable refs would survive the clone as ghost
references — shown in the builder, never used. Strip them instead, so
the adopter sees empty fields to fill in.
"""
def transform(node_type: str, config: dict) -> dict:
if node_type != "agent":
return config
# Node settings may sit nested under ``config`` — mirror the engine,
# which reads ``node.config.get("config", node.config)``.
nested = config.get("config")
cfg = nested if isinstance(nested, dict) else config
if cfg.get("tools"):
cfg["tools"] = _filter_adoptable_tool_ids(conn, user, cfg["tools"])
if cfg.get("sources"):
sources = cfg["sources"]
if not isinstance(sources, list):
sources = [sources]
cfg["sources"] = [
str(s)
for s in sources
if s and can_access(conn, "source", str(s), user)
]
model_id = cfg.get("model_id")
if (
model_id
and looks_like_uuid(str(model_id))
and not UserCustomModelsRepository(conn).get(str(model_id), user)
):
cfg["model_id"] = None
if cfg is not config:
# Flat leftovers of these keys are ignored by the engine but would
# carry the template owner's raw ids along; drop them.
for key in ("tools", "sources", "model_id"):
config.pop(key, None)
return config
return transform
@agents_ns.route("/adopt_agent")
class AdoptAgent(Resource):
@api.doc(params={"id": "Agent ID"}, description="Adopt an agent by ID")
def post(self):
if not (decoded_token := request.decoded_token):
return make_response(jsonify({"success": False}), 401)
if not (agent_id := request.args.get("id")):
return make_response(
jsonify({"success": False, "message": "ID required"}), 400
)
try:
user = decoded_token["sub"]
from sqlalchemy import text as _sql_text
with db_session() as conn:
# Template lookup: user_id must be 'system' or '__system__'.
if looks_like_uuid(agent_id):
template_row = conn.execute(
_sql_text(
"SELECT * FROM agents "
"WHERE id = CAST(:id AS uuid) "
"AND user_id IN ('system', '__system__')"
),
{"id": agent_id},
).fetchone()
else:
template_row = conn.execute(
_sql_text(
"SELECT * FROM agents "
"WHERE legacy_mongo_id = :id "
"AND user_id IN ('system', '__system__')"
),
{"id": agent_id},
).fetchone()
if template_row is None:
return make_response(jsonify({"status": "Not found"}), 404)
template = dict(template_row._mapping)
now = datetime.datetime.now(datetime.timezone.utc)
new_key = str(uuid.uuid4())
# Copy content columns only. Identity / idempotency columns
# (key, slug, shared_token, incoming_webhook_token) are
# deliberately NOT copied — the adopted agent is a fresh copy
# and must get its own values (slug stays NULL until exported).
create_kwargs: dict = {}
for col in (
"description", "agent_type", "retriever",
"default_model_id",
"source_id", "prompt_id", "folder_id",
"extra_source_ids",
):
val = template.get(col)
if val not in (None, ""):
create_kwargs[col] = val
# ``workflow_id`` must NOT be copied by reference: the runtime
# resolves it strictly owner-scoped, so the adopter would get
# "Workflow not found or inaccessible" on every run. Deep-copy
# the template's graph into a workflow the adopter owns,
# stripping node refs (tools/sources/models) the adopter can't
# resolve — they are owner-scoped at run time too.
if template.get("workflow_id"):
cloned_workflow = WorkflowsRepository(conn).clone_to_user(
str(template["workflow_id"]),
user,
from_owner=template.get("user_id"),
node_config_transform=_strip_foreign_node_refs(conn, user),
)
if cloned_workflow is None:
# A workflow agent without its graph can never run —
# fail the adopt instead of publishing a broken agent.
current_app.logger.error(
"Adopt: template %s references workflow %s not owned "
"by %s; adoption refused",
template.get("id"),
template.get("workflow_id"),
template.get("user_id"),
)
return make_response(
jsonify(
{
"success": False,
"message": "This template's workflow is "
"unavailable; it cannot be adopted",
}
),
500,
)
create_kwargs["workflow_id"] = str(cloned_workflow["id"])
# The avatar must live under the adopter's own upload
# directory: image paths are validated against their owner,
# so a copied reference to the template owner's blob would
# fail closed and render blank.
adopted_image = copy_agent_image_for_user(
template.get("image"), user, storage
)
if adopted_image:
create_kwargs["image"] = adopted_image
for col in ("json_schema", "models", "shared_metadata", "config"):
if template.get(col) is not None:
create_kwargs[col] = template[col]
# Tool ids resolve owner-scoped at run time, so the template
# owner's rows would silently drop on every run — keep only
# what the adopter can actually use (builtins, own tools).
if template.get("tools") is not None:
create_kwargs["tools"] = _filter_adoptable_tool_ids(
conn, user, template["tools"]
)
for col in ("chunks", "token_limit", "request_limit"):
if template.get(col) is not None:
create_kwargs[col] = template[col]
for col in (
"limited_token_mode", "limited_request_mode",
"allow_system_prompt_override",
):
if template.get(col) is not None:
create_kwargs[col] = bool(template[col])
create_kwargs["key"] = new_key
create_kwargs["last_used_at"] = now
new_agent = AgentsRepository(conn).create(
user,
template.get("name") or "",
"published",
**create_kwargs,
)
response_agent = _format_agent_output(new_agent, include_key_masked=False)
response_agent["key"] = new_key
return make_response(
jsonify({"success": True, "agent": response_agent}), 200
)
except Exception as e:
current_app.logger.error(f"Agent adopt error: {e}", exc_info=True)
return make_response(jsonify({"success": False}), 400)
@agents_ns.route("/pin_agent")
class PinAgent(Resource):
@api.doc(params={"id": "ID of the agent"}, description="Pin or unpin an agent")
def post(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user_id = decoded_token.get("sub")
agent_id = request.args.get("id")
if not agent_id:
return make_response(
jsonify({"success": False, "message": "ID is required"}), 400
)
try:
with db_session() as conn:
# Any user can pin any agent they can *see* — owned, team-shared,
# reached through a share link, or a system template. The lookup
# is deliberately not owner-scoped, so visibility is checked
# separately below.
from sqlalchemy import text as _sql_text
if looks_like_uuid(agent_id):
agent_row = conn.execute(
_sql_text(
"SELECT id, user_id, shared FROM agents "
"WHERE id = CAST(:id AS uuid)"
),
{"id": agent_id},
).fetchone()
else:
agent_row = conn.execute(
_sql_text(
"SELECT id, user_id, shared FROM agents "
"WHERE legacy_mongo_id = :id"
),
{"id": agent_id},
).fetchone()
if agent_row is None:
return make_response(
jsonify({"success": False, "message": "Agent not found"}),
404,
)
pg_agent_id = str(agent_row._mapping["id"])
users_repo = UsersRepository(conn)
user_doc = users_repo.upsert(user_id)
prefs = (
user_doc.get("agent_preferences", {})
if isinstance(user_doc.get("agent_preferences"), dict)
else {}
)
pinned_list = prefs.get("pinned", []) or []
if pg_agent_id in pinned_list:
# Unpinning stays open regardless of current access, or a
# revoked share would leave a pin the user can't clear.
users_repo.remove_pinned(user_id, pg_agent_id)
action = "unpinned"
else:
if not _user_may_pin(
conn,
dict(agent_row._mapping),
user_id,
set(prefs.get("shared_with_me", []) or []),
):
return make_response(
jsonify({"success": False, "message": "Agent not found"}),
404,
)
users_repo.add_pinned(user_id, pg_agent_id)
action = "pinned"
except Exception as err:
current_app.logger.error(f"Error pinning/unpinning agent: {err}")
return make_response(
jsonify({"success": False, "message": "Server error"}), 500
)
return make_response(jsonify({"success": True, "action": action}), 200)
@agents_ns.route("/remove_shared_agent")
class RemoveSharedAgent(Resource):
@api.doc(
params={"id": "ID of the shared agent"},
description="Remove a shared agent from the current user's shared list",
)
def delete(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user_id = decoded_token.get("sub")
agent_id = request.args.get("id")
if not agent_id:
return make_response(
jsonify({"success": False, "message": "ID is required"}), 400
)
try:
with db_session() as conn:
from sqlalchemy import text as _sql_text
if looks_like_uuid(agent_id):
agent_row = conn.execute(
_sql_text(
"SELECT id FROM agents "
"WHERE id = CAST(:id AS uuid) AND shared = true"
),
{"id": agent_id},
).fetchone()
else:
agent_row = conn.execute(
_sql_text(
"SELECT id FROM agents "
"WHERE legacy_mongo_id = :id AND shared = true"
),
{"id": agent_id},
).fetchone()
if agent_row is None:
return make_response(
jsonify({"success": False, "message": "Shared agent not found"}),
404,
)
pg_agent_id = str(agent_row._mapping["id"])
users_repo = UsersRepository(conn)
users_repo.upsert(user_id)
users_repo.remove_agent_from_all(user_id, pg_agent_id)
return make_response(jsonify({"success": True, "action": "removed"}), 200)
except Exception as err:
current_app.logger.error(f"Error removing shared agent: {err}")
return make_response(
jsonify({"success": False, "message": "Server error"}), 500
)