Files
wehub-resource-sync fed8b2eed7
Backend release / release (push) Waiting to run
Bandit Security Scan / bandit_scan (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / manifest (push) Blocked by required conditions
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Waiting to run
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Waiting to run
Build and push DocsGPT FE Docker image for development / manifest (push) Blocked by required conditions
Python linting / ruff (push) Waiting to run
Run python tests with pytest / Run tests and count coverage (3.12) (push) Waiting to run
React Widget Build / build (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 13:28:29 +08:00

1659 lines
72 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 application.api import api
from application.api.user.base import (
handle_image_upload,
resolve_prompt_name,
resolve_source_details,
resolve_tool_details,
storage,
)
from application.core.json_schema_utils import (
JsonSchemaValidationError,
normalize_json_schema_payload,
)
from application.core.settings import settings
from application.storage.db.base_repository import looks_like_uuid
from application.api.user.team_sharing import (
can_access,
team_access_for,
visible_with_access,
)
from application.storage.db.repositories.agent_folders import AgentFoldersRepository
from application.storage.db.repositories.agents import AgentsRepository
from application.storage.db.repositories.users import UsersRepository
from application.storage.db.repositories.workflow_edges import WorkflowEdgesRepository
from application.storage.db.repositories.workflow_nodes import WorkflowNodesRepository
from application.storage.db.repositories.workflows import WorkflowsRepository
from application.storage.db.session import db_readonly, db_session
from application.utils import (
check_required_fields,
generate_image_url,
validate_required_fields,
)
agents_ns = Namespace("agents", description="Agent management operations", path="/api")
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": [],
"require_source": True,
"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",
],
},
"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",
],
},
}
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 _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"]) 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"),
"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", "image", "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"]
return kwargs
@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)
def _is_runnable(agent: dict) -> bool:
return bool(
agent.get("source_id")
or (agent.get("extra_source_ids") or [])
or agent.get("retriever")
or agent.get("agent_type") == "workflow"
)
list_agents = [
_format_agent_output(agent, pinned=str(agent["id"]) in pinned_ids)
for agent in agents
if _is_runnable(agent)
]
list_agents += [
_format_agent_output(
agent,
ownership="team",
team_access=team_shared.get(str(agent["id"])),
)
for agent in shared_agents
if _is_runnable(agent)
]
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 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"]
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"]
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.String(
required=False, description="New image URL or identifier"
),
"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"]
for field in json_fields:
if field in data and data[field]:
try:
data[field] = json.loads(data[field])
except json.JSONDecodeError:
return make_response(
jsonify(
{
"success": False,
"message": f"Invalid JSON format for field: {field}",
}
),
400,
)
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"])
image_url, image_error = handle_image_upload(
request, existing_agent.get("image", "") or "", user, storage,
)
if image_error:
return image_error
update_fields: dict = {}
allowed_fields = [
"name",
"description",
"image",
"source",
"sources",
"chunks",
"retriever",
"prompt_id",
"tools",
"agent_type",
"status",
"json_schema",
"limited_token_mode",
"token_limit",
"limited_request_mode",
"request_limit",
"models",
"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 make_response(
jsonify(
{
"success": False,
"message": "Invalid status value. Must be 'draft' or 'published'",
}
),
400,
)
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 make_response(
jsonify(
{
"success": False,
"message": f"Invalid source ID format: {source_id}",
}
),
400,
)
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 make_response(
jsonify(
{
"success": False,
"message": f"Invalid source ID in list: {src}",
}
),
400,
)
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 make_response(
jsonify(
{
"success": False,
"message": "Chunks value must be a non-negative integer",
}
),
400,
)
update_fields["chunks"] = chunks_int
except (ValueError, TypeError):
return make_response(
jsonify(
{
"success": False,
"message": f"Invalid chunks value: {chunks_value}",
}
),
400,
)
elif field == "tools":
tools_list = data.get("tools", [])
if not isinstance(tools_list, list):
return make_response(
jsonify({"success": False, "message": "Tools must be a list"}),
400,
)
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 make_response(
jsonify({"success": False, "message": "Invalid JSON schema"}),
400,
)
else:
update_fields["json_schema"] = None
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 make_response(
jsonify(
{
"success": False,
"message": "Token limit must be provided when limited token mode is enabled",
}
),
400,
)
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 make_response(
jsonify(
{
"success": False,
"message": "Request limit must be provided when limited request mode is enabled",
}
),
400,
)
elif field == "token_limit":
token_limit = data.get("token_limit")
update_fields["token_limit"] = int(token_limit) if token_limit else 0
if update_fields["token_limit"] > 0 and not data.get("limited_token_mode"):
return make_response(
jsonify(
{
"success": False,
"message": "Token limit cannot be set when limited token mode is disabled",
}
),
400,
)
elif field == "request_limit":
request_limit = data.get("request_limit")
update_fields["request_limit"] = int(request_limit) if request_limit else 0
if update_fields["request_limit"] > 0 and not data.get("limited_request_mode"):
return make_response(
jsonify(
{
"success": False,
"message": "Request limit cannot be set when limited request mode is disabled",
}
),
400,
)
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 make_response(
jsonify({"success": False, "message": "Workflow is required"}),
400,
)
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 make_response(
jsonify(
{"success": False, "message": f"Invalid prompt_id: {value}"}
),
400,
)
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 make_response(
jsonify(
{
"success": False,
"message": f"Field '{field}' cannot be empty",
}
),
400,
)
update_fields[field] = value
if image_url:
update_fields["image"] = image_url
if not update_fields:
return make_response(
jsonify(
{
"success": False,
"message": "No valid update data provided",
}
),
400,
)
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 make_response(
jsonify(
{
"success": False,
"message": f"Cannot publish workflow agent. Missing required fields: {', '.join(missing_published_fields)}",
}
),
400,
)
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)
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")
if missing_published_fields:
return make_response(
jsonify(
{
"success": False,
"message": f"Cannot publish agent. Missing or invalid required fields: {', '.join(missing_published_fields)}",
}
),
400,
)
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 application.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",
):
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("/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 a single workflow delete suffices.
if agent.get("agent_type") == "workflow" and workflow_id:
try:
WorkflowNodesRepository(conn).delete_by_workflow(
str(workflow_id),
)
WorkflowEdgesRepository(conn).delete_by_workflow(
str(workflow_id),
)
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)
@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)
pinned_ids = (
user_doc.get("agent_preferences", {}).get("pinned", [])
if isinstance(user_doc.get("agent_preferences"), dict)
else []
)
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)
list_pinned_agents = []
for agent in pinned_agents:
source_id = agent.get("source_id")
if not source_id and not agent.get("retriever"):
continue
list_pinned_agents.append(
{
"id": str(agent["id"]),
"name": agent.get("name", ""),
"description": agent.get("description", ""),
"image": (
generate_image_url(agent["image"]) 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")
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": agent.get("image") or "",
}
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)
@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", "image", "retriever",
"default_model_id",
"source_id", "prompt_id", "folder_id", "workflow_id",
"extra_source_ids",
):
val = template.get(col)
if val not in (None, ""):
create_kwargs[col] = val
for col in ("tools", "json_schema", "models", "shared_metadata"):
if template.get(col) is not None:
create_kwargs[col] = template[col]
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 — including
# shared ones. Use the non-user-scoped lookup so pins
# aren't restricted to owner-only.
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)"),
{"id": agent_id},
).fetchone()
else:
agent_row = conn.execute(
_sql_text(
"SELECT id 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)
pinned_list = (
user_doc.get("agent_preferences", {}).get("pinned", [])
if isinstance(user_doc.get("agent_preferences"), dict)
else []
)
if pg_agent_id in pinned_list:
users_repo.remove_pinned(user_id, pg_agent_id)
action = "unpinned"
else:
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
)