mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-11 03:12:55 +00:00
A member-mode tool runs on each caller's own account, so the owner's account no longer marks it stopped: it is active with the note per_user_account, and only an admin turning its service off stops it. connection_removed now needs the note remove_connection leaves on a kept tool, so a tool that never had a connection (a tokenless ntfy) runs. Whom to ask is contact_role resource_owner; contact names them only when the reader can see the resource or they own the holder. connection carries its id only with can_reconnect and the account's own name only for its owner. Run state and audience are best effort (a failure logs and leaves them out of the read), resolve answers are cached for the rest of a read, and a workflow read names node resources by the run state's own names.
2250 lines
98 KiB
Python
2250 lines
98 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.api.audit import record_event
|
|
from docsgpt.api.pat.rules import filter_listing, mask_agent_key, may_see_agent_keys
|
|
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 canonical_uuid, looks_like_uuid
|
|
from docsgpt.api.user.resource_access import (
|
|
AccessDenied,
|
|
agent_refs,
|
|
best_effort,
|
|
cached_resolves,
|
|
delete_settings,
|
|
parse_confirmations,
|
|
payload_for,
|
|
plan_sponsors,
|
|
require,
|
|
resolve,
|
|
resource_states,
|
|
settings_many,
|
|
sponsor_audience,
|
|
sponsor_details,
|
|
sponsor_refusal,
|
|
)
|
|
from docsgpt.api.user.team_sharing import (
|
|
can_access,
|
|
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
|
|
|
|
|
|
# What a caller who reached an agent only through its public share link may
|
|
# do: chat with it and pin it. A team grant, when there is one, wins.
|
|
LINK_SHARED_ACCESS = {"access": "viewer", "allowed_actions": ["pin", "use"]}
|
|
|
|
# Agent fields that are the owner's policy (guardrails and the pooled quota).
|
|
POLICY_FIELDS = (
|
|
"config", "token_limit", "request_limit", "limited_token_mode", "limited_request_mode",
|
|
)
|
|
|
|
|
|
def _denied(err: AccessDenied):
|
|
"""The JSON error response for an :class:`AccessDenied`."""
|
|
return make_response(jsonify({"success": False, "message": err.message}), err.status)
|
|
|
|
|
|
def _tool_attachable(conn, tool_id: str, caller: str) -> bool:
|
|
"""Whether ``caller`` may newly attach ``tool_id`` to an agent.
|
|
|
|
Builtin synthetic ids belong to no one. Otherwise the caller must own the
|
|
tool or reach it with ``use_in_own``. The agent owner owning it is not
|
|
enough: an editor could otherwise wire the owner's private tool (run with
|
|
the owner's credentials) into an agent the editor controls.
|
|
|
|
Args:
|
|
conn: Open database connection.
|
|
tool_id: The tool id being attached.
|
|
caller: The user making the change.
|
|
|
|
Returns:
|
|
True when the attachment is allowed.
|
|
"""
|
|
tid = str(tool_id)
|
|
if is_synthesized_tool_id(tid):
|
|
return True
|
|
ra = resolve(conn, "tool", tid, caller)
|
|
return ra is not None and ra.can("use_in_own")
|
|
|
|
|
|
def _ref_attachable(conn, resource_type: str, resource_id: str, caller: str) -> bool:
|
|
"""Whether ``caller`` may newly reference a source/prompt from an agent.
|
|
|
|
Like tools, the caller's own access counts, not the agent owner's.
|
|
|
|
Args:
|
|
conn: Open database connection.
|
|
resource_type: ``source`` or ``prompt``.
|
|
resource_id: The referenced id.
|
|
caller: The user making the change.
|
|
|
|
Returns:
|
|
True when the caller owns it or a team grant reaches them.
|
|
"""
|
|
if not resource_id:
|
|
return True
|
|
return can_access(conn, resource_type, str(resource_id), caller)
|
|
|
|
|
|
def _policy_changed(existing: dict, update_fields: dict) -> bool:
|
|
"""True when ``update_fields`` changes any guardrail or quota value."""
|
|
for key in POLICY_FIELDS:
|
|
if key not in update_fields:
|
|
continue
|
|
new, old = update_fields[key], existing.get(key)
|
|
if key == "config":
|
|
if (new or {}) != (old or {}):
|
|
return True
|
|
elif key.startswith("limited_"):
|
|
if bool(new) != bool(old):
|
|
return True
|
|
elif int(new or 0) != int(old or 0):
|
|
return True
|
|
return False
|
|
|
|
|
|
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,
|
|
access: dict | None = None,
|
|
) -> 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.
|
|
|
|
``access`` is the caller's ``{"access", "allowed_actions"}`` payload
|
|
(owner with default switches when omitted). It is embedded verbatim and
|
|
decides what leaves the server: the full guardrail ``config`` needs
|
|
``view``, the (masked) ``key`` and public ``shared_token`` need
|
|
``manage_access_details``.
|
|
"""
|
|
if access is None:
|
|
access = payload_for("agent", "owner", {})
|
|
allowed = set(access.get("allowed_actions") or [])
|
|
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 "6",
|
|
"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,
|
|
"access": access.get("access"),
|
|
"allowed_actions": sorted(allowed),
|
|
}
|
|
# Guardrail policy is edit-page config; a chat-only caller doesn't need it
|
|
# (and reading the banned-term list makes evading it trivial).
|
|
if "view" not in allowed:
|
|
out["config"] = {}
|
|
# 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)
|
|
)
|
|
# The public ``shared_token`` and the (masked) agent ``key`` are access
|
|
# details: only a caller who may manage them sees them.
|
|
if "manage_access_details" not in allowed:
|
|
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 keep_owner_only_config(config: dict, existing_agent: dict, is_team_editor: bool) -> dict:
|
|
"""Keep the parts of an agent's config only its owner may change.
|
|
|
|
``api_write_allowlist`` lets anyone with the agent's API key act on the
|
|
owner's connected accounts, so a team editor's update keeps the stored
|
|
value whatever it sends.
|
|
|
|
Args:
|
|
config: The normalized config from the request.
|
|
existing_agent: The agent row being updated.
|
|
is_team_editor: The caller edits through a team share, not as owner.
|
|
|
|
Returns:
|
|
The config to store.
|
|
"""
|
|
if not is_team_editor:
|
|
return config
|
|
stored = AgentConfig.parse(existing_agent.get("config")).api_write_allowlist
|
|
return {**config, "api_write_allowlist": stored}
|
|
|
|
|
|
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"]
|
|
agent = None
|
|
sponsored: list = []
|
|
states: list = []
|
|
audience = None
|
|
with db_readonly() as conn, cached_resolves():
|
|
# Anyone who can see the agent reads it (a viewer needs it to
|
|
# chat); what they get back is trimmed by their actions.
|
|
ra = resolve(conn, "agent", agent_id, user)
|
|
if ra is not None:
|
|
agent = AgentsRepository(conn).get_by_id(ra.resource_id)
|
|
# Edit-page detail: who vouches for resources the owner can't
|
|
# use, and which attached resources stopped running and why,
|
|
# with their names. Only for people who may edit the agent:
|
|
# the names can be an editor's private resources. Run state
|
|
# never fails the read.
|
|
if agent and ra.can("edit"):
|
|
sponsored = sponsor_details(conn, "agent", agent, viewer=user)
|
|
states = best_effort(
|
|
conn, "the agent's resource states",
|
|
lambda: resource_states(conn, "agent", agent, agent_refs(agent), user), [],
|
|
)
|
|
audience = best_effort(
|
|
conn, "the agent's audience",
|
|
lambda: sponsor_audience(conn, "agent", agent, states, sponsored), None,
|
|
)
|
|
if not agent:
|
|
return {"status": "Not found"}, 404
|
|
is_owner = ra.access == "owner"
|
|
data = _format_agent_output(
|
|
agent,
|
|
ownership="user" if is_owner else "team",
|
|
team_access=None if is_owner else ra.access,
|
|
resolve_names=True,
|
|
access=ra.payload(),
|
|
)
|
|
data["resource_sponsors"] = sponsored
|
|
data["resource_states"] = states
|
|
if audience is not None:
|
|
data["sponsor_audience"] = audience
|
|
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)
|
|
|
|
switches = settings_many(
|
|
conn, "agent", [str(a["id"]) for a in agents + shared_agents]
|
|
)
|
|
|
|
# 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,
|
|
access=payload_for("agent", "owner", switches[str(agent["id"])]),
|
|
)
|
|
for agent in agents
|
|
]
|
|
list_agents += [
|
|
_format_agent_output(
|
|
agent,
|
|
ownership="team",
|
|
team_access=team_shared.get(str(agent["id"])),
|
|
access=payload_for(
|
|
"agent",
|
|
team_shared.get(str(agent["id"])),
|
|
switches[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(filter_listing(request, "agents", 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(canonical_uuid(src))
|
|
else:
|
|
source_value = data.get("source", "")
|
|
if source_value and source_value != "default" and looks_like_uuid(source_value):
|
|
source_id_resolved = canonical_uuid(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,
|
|
)
|
|
# Tools run with the agent owner's credentials: attach only your
|
|
# own, or ones a team lets you use in your agents.
|
|
for tid in data.get("tools") or []:
|
|
if not _tool_attachable(conn, tid, user):
|
|
return make_response(
|
|
jsonify({"success": False, "message": "Tool not accessible"}),
|
|
403,
|
|
)
|
|
|
|
build_data = dict(data)
|
|
if isinstance(data.get("tools"), list):
|
|
build_data["tools"] = [canonical_uuid(t) for t in data["tools"]]
|
|
if looks_like_uuid(data.get("prompt_id")):
|
|
build_data["prompt_id"] = canonical_uuid(data["prompt_id"])
|
|
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"] = 6
|
|
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"])
|
|
record_event(
|
|
conn,
|
|
"agent.created",
|
|
actor=user,
|
|
agent_id=new_id,
|
|
name=data["name"],
|
|
status=data["status"],
|
|
agent_type=agent_type,
|
|
)
|
|
except Exception as err:
|
|
current_app.logger.error(f"Error creating agent: {err}", exc_info=True)
|
|
return make_response(jsonify({"success": False}), 400)
|
|
# A token without agents:keys never receives the plaintext agent key.
|
|
visible_key = key if may_see_agent_keys(request) else mask_agent_key(key)
|
|
return make_response(jsonify({"id": new_id, "key": visible_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)
|
|
try:
|
|
ra = require(conn, "agent", agent_id, user, "edit")
|
|
except AccessDenied as denied:
|
|
return _denied(denied)
|
|
# Every write lands as the owner; the row is fetched only after
|
|
# the access check above.
|
|
owner_id = ra.owner_id
|
|
is_team_editor = ra.access != "owner"
|
|
existing_agent = agents_repo.get_by_id(ra.resource_id)
|
|
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 ""
|
|
# The image is stored only once the save is known to go
|
|
# ahead, so a refused save (a sponsor confirmation round
|
|
# trip, a validation error) leaves no orphaned file.
|
|
image_file = request.files.get("image")
|
|
has_new_image = bool(image_file and image_file.filename)
|
|
|
|
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,
|
|
)
|
|
if new_status != existing_agent.get("status") and not ra.can("publish"):
|
|
return _denied(AccessDenied(403, "Your access to this item doesn't allow that"))
|
|
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"] = canonical_uuid(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(canonical_uuid(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"] = 6
|
|
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"] = [canonical_uuid(t) for t in 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"] = keep_owner_only_config(
|
|
normalized_config or {}, existing_agent, is_team_editor,
|
|
)
|
|
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":
|
|
# Folders are the owner's own organisation; re-sending
|
|
# the current folder is a no-op anyone may do.
|
|
folder_input = data.get("folder_id") or None
|
|
current_folder = (
|
|
str(existing_agent["folder_id"])
|
|
if existing_agent.get("folder_id")
|
|
else None
|
|
)
|
|
if folder_input == current_folder:
|
|
update_fields["folder_id"] = current_folder
|
|
continue
|
|
if not ra.can("move_folder"):
|
|
return _denied(
|
|
AccessDenied(403, "Only the owner can move this agent between folders")
|
|
)
|
|
if folder_input:
|
|
pg_folder_id, folder_err = _resolve_folder_id(
|
|
conn, folder_input, owner_id,
|
|
)
|
|
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)
|
|
pg_workflow_id = None
|
|
else:
|
|
# The agent runs its workflow as the owner, so it
|
|
# must be one of the owner's workflows.
|
|
pg_workflow_id, wf_err = _resolve_workflow_for_user(
|
|
conn, workflow_input, owner_id,
|
|
)
|
|
if wf_err:
|
|
return wf_err
|
|
# Only the owner may change which workflow the agent
|
|
# uses, detaching included: editing rights on this
|
|
# agent extend to the graph it uses, so swapping in the
|
|
# workflow of another of the owner's agents would hand
|
|
# that graph to the editor. Editing the graph itself
|
|
# goes through the workflow routes.
|
|
current_workflow = existing_agent.get("workflow_id")
|
|
if is_team_editor and pg_workflow_id != (
|
|
str(current_workflow) if current_workflow else None
|
|
):
|
|
return _denied(
|
|
AccessDenied(403, "Only the owner can change this agent's workflow")
|
|
)
|
|
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"] = canonical_uuid(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 not update_fields and not has_new_image:
|
|
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 _ref_attachable(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 _ref_attachable(conn, "prompt", new_prompt_id, user)
|
|
):
|
|
return make_response(
|
|
jsonify({"success": False, "message": "Prompt not accessible"}), 403
|
|
)
|
|
# Tools run with the OWNER's credentials (the agent-key path
|
|
# resolves and decrypts them as the owner), so a newly attached
|
|
# tool must be the caller's own or reach them with
|
|
# ``use_in_own`` -- the owner owning it is not enough. Tools
|
|
# already on the agent stay. Builtin synthetic ids belong to no
|
|
# one and are always allowed.
|
|
if "tools" in update_fields:
|
|
existing_tools = {
|
|
str(t) for t in (existing_agent.get("tools") or [])
|
|
}
|
|
for tid in update_fields["tools"] or []:
|
|
if str(tid) in existing_tools:
|
|
continue
|
|
if not _tool_attachable(conn, tid, user):
|
|
return make_response(
|
|
jsonify(
|
|
{"success": False, "message": "Tool not accessible"}
|
|
),
|
|
403,
|
|
)
|
|
|
|
# Guardrails and the pooled quota are policy: an unchanged
|
|
# value re-sent by a full-form save is fine, a change needs
|
|
# ``edit_policy``.
|
|
if not ra.can("edit_policy") and _policy_changed(existing_agent, update_fields):
|
|
return _denied(
|
|
AccessDenied(403, "Your access doesn't allow changing guardrails or limits")
|
|
)
|
|
|
|
# A resource the owner can't use runs as the editor who
|
|
# attached it (its sponsor). Only someone who owns or edits
|
|
# it may sponsor it, and only after confirming: the save is
|
|
# refused (409) until ``confirm_sponsor`` lists every
|
|
# resource it would newly sponsor.
|
|
after_save = dict(existing_agent)
|
|
for ref_field in ("source_id", "extra_source_ids", "prompt_id", "tools"):
|
|
if ref_field in update_fields:
|
|
after_save[ref_field] = update_fields[ref_field]
|
|
plan = plan_sponsors(
|
|
conn,
|
|
"agent",
|
|
existing_agent,
|
|
owner_id,
|
|
user,
|
|
agent_refs(after_save),
|
|
previous_refs=agent_refs(existing_agent),
|
|
confirmed=parse_confirmations(data.get("confirm_sponsor")),
|
|
)
|
|
refusal = sponsor_refusal(
|
|
conn, "agent", existing_agent, plan, api_key=bool(update_fields.get("key")) or None
|
|
)
|
|
if refusal is not None:
|
|
body, status = refusal
|
|
return make_response(jsonify(body), status)
|
|
if plan.sponsors != (existing_agent.get("resource_sponsors") or {}):
|
|
update_fields["resource_sponsors"] = plan.sponsors
|
|
|
|
if has_new_image:
|
|
image_url, image_error = handle_image_upload(
|
|
request,
|
|
existing_image,
|
|
existing_agent.get("user_id") or user,
|
|
storage,
|
|
)
|
|
if image_error:
|
|
return image_error
|
|
if image_url and image_url != existing_image:
|
|
update_fields["image"] = image_url
|
|
|
|
# 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)
|
|
# An update that changed no field is not an event.
|
|
if updated and update_fields:
|
|
record_event(
|
|
conn,
|
|
"agent.updated",
|
|
actor=user,
|
|
agent_id=pg_agent_id,
|
|
# Names only: values can hold prompts and credentials.
|
|
fields=sorted(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",
|
|
}
|
|
# A freshly minted key is an access detail: returned only to a caller
|
|
# who may manage it (the key is still stored either way).
|
|
if newly_generated_key and ra.can("manage_access_details"):
|
|
response_data["key"] = (
|
|
newly_generated_key
|
|
if may_see_agent_keys(request)
|
|
else mask_agent_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)
|
|
# Rotating the key is an access detail: the owner, or an editor
|
|
# while ``editors_can_manage_access_details`` is on.
|
|
try:
|
|
ra = require(conn, "agent", agent_id, user, "manage_access_details")
|
|
except AccessDenied as denied:
|
|
return _denied(denied)
|
|
existing_agent = agents_repo.get_by_id(ra.resource_id)
|
|
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, ra.owner_id, {"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
|
|
)
|
|
record_event(
|
|
conn,
|
|
"agent.key_regenerated",
|
|
actor=user,
|
|
agent_id=pg_agent_id,
|
|
name=existing_agent.get("name"),
|
|
)
|
|
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)
|
|
try:
|
|
ra = require(conn, "agent", agent_id, user, "delete")
|
|
except AccessDenied as denied:
|
|
return _denied(denied)
|
|
owner_id = ra.owner_id
|
|
agent = agents_repo.get_by_id(ra.resource_id)
|
|
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), owner_id)
|
|
except Exception as wf_err:
|
|
current_app.logger.warning(
|
|
f"Workflow cleanup failed for agent {pg_agent_id}: {wf_err}"
|
|
)
|
|
# Team grants go with the row (AFTER DELETE trigger, 0021);
|
|
# the switches table has no FK, so drop it explicitly.
|
|
agents_repo.delete(pg_agent_id, owner_id)
|
|
delete_settings(conn, "agent", pg_agent_id)
|
|
# Strip pinned/shared entries for this agent from the owner's
|
|
# (and the deleting editor's) prefs.
|
|
users_repo = UsersRepository(conn)
|
|
for uid in {owner_id, user}:
|
|
users_repo.remove_agent_from_all(uid, pg_agent_id)
|
|
record_event(
|
|
conn,
|
|
"agent.deleted",
|
|
actor=user,
|
|
agent_id=pg_agent_id,
|
|
name=agent.get("name"),
|
|
agent_type=agent.get("agent_type"),
|
|
)
|
|
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)
|
|
]
|
|
# A pin reached only through a share link (or a template)
|
|
# has no grant: chat + pin, nothing more.
|
|
access_by_id = {}
|
|
for agent in pinned_agents:
|
|
ra = resolve(conn, "agent", str(agent["id"]), user_id)
|
|
access_by_id[str(agent["id"])] = (
|
|
ra.payload() if ra is not None else dict(LINK_SHARED_ACCESS)
|
|
)
|
|
|
|
list_pinned_agents = []
|
|
for agent in pinned_agents:
|
|
source_id = agent.get("source_id")
|
|
access = access_by_id[str(agent["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 "manage_access_details" in access["allowed_actions"]
|
|
else ""
|
|
),
|
|
"pinned": True,
|
|
**access,
|
|
}
|
|
)
|
|
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 if may_see_agent_keys(request) else mask_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
|
|
)
|