chore: import upstream snapshot with attribution
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Backend release / release (push) Has been cancelled
Bandit Security Scan / bandit_scan (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / manifest (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / manifest (push) Has been cancelled
Python linting / ruff (push) Has been cancelled
Run python tests with pytest / Run tests and count coverage (3.12) (push) Has been cancelled
React Widget Build / build (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Backend release / release (push) Has been cancelled
Bandit Security Scan / bandit_scan (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / manifest (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / manifest (push) Has been cancelled
Python linting / ruff (push) Has been cancelled
Run python tests with pytest / Run tests and count coverage (3.12) (push) Has been cancelled
React Widget Build / build (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
"""Agents module."""
|
||||
|
||||
from .routes import agents_ns
|
||||
from .sharing import agents_sharing_ns
|
||||
from .webhooks import agents_webhooks_ns
|
||||
from .folders import agents_folders_ns
|
||||
from .portability import agents_portability_ns
|
||||
|
||||
__all__ = [
|
||||
"agents_ns",
|
||||
"agents_sharing_ns",
|
||||
"agents_webhooks_ns",
|
||||
"agents_folders_ns",
|
||||
"agents_portability_ns",
|
||||
]
|
||||
@@ -0,0 +1,366 @@
|
||||
"""
|
||||
Agent folders management routes.
|
||||
Provides virtual folder organization for agents (Google Drive-like structure).
|
||||
"""
|
||||
|
||||
from flask import current_app, jsonify, make_response, request
|
||||
from flask_restx import Namespace, Resource, fields
|
||||
from sqlalchemy import text as _sql_text
|
||||
|
||||
from application.api import api
|
||||
from application.storage.db.base_repository import looks_like_uuid
|
||||
from application.storage.db.repositories.agent_folders import AgentFoldersRepository
|
||||
from application.storage.db.repositories.agents import AgentsRepository
|
||||
from application.storage.db.session import db_readonly, db_session
|
||||
|
||||
|
||||
agents_folders_ns = Namespace(
|
||||
"agents_folders", description="Agent folder management", path="/api/agents/folders"
|
||||
)
|
||||
|
||||
|
||||
def _resolve_folder_id(repo: AgentFoldersRepository, folder_id: str, user: str):
|
||||
"""Resolve a folder id that may be either a UUID or legacy Mongo ObjectId."""
|
||||
if not folder_id:
|
||||
return None
|
||||
if looks_like_uuid(folder_id):
|
||||
row = repo.get(folder_id, user)
|
||||
if row is not None:
|
||||
return row
|
||||
return repo.get_by_legacy_id(folder_id, user)
|
||||
|
||||
|
||||
def _folder_error_response(message: str, err: Exception):
|
||||
current_app.logger.error(f"{message}: {err}", exc_info=True)
|
||||
return make_response(jsonify({"success": False, "message": message}), 400)
|
||||
|
||||
|
||||
def _serialize_folder(f: dict) -> dict:
|
||||
created_at = f.get("created_at")
|
||||
updated_at = f.get("updated_at")
|
||||
return {
|
||||
"id": str(f["id"]),
|
||||
"name": f.get("name"),
|
||||
"parent_id": str(f["parent_id"]) if f.get("parent_id") else None,
|
||||
"created_at": created_at.isoformat() if hasattr(created_at, "isoformat") else created_at,
|
||||
"updated_at": updated_at.isoformat() if hasattr(updated_at, "isoformat") else updated_at,
|
||||
}
|
||||
|
||||
|
||||
@agents_folders_ns.route("/")
|
||||
class AgentFolders(Resource):
|
||||
@api.doc(description="Get all folders for the user")
|
||||
def get(self):
|
||||
decoded_token = request.decoded_token
|
||||
if not decoded_token:
|
||||
return make_response(jsonify({"success": False}), 401)
|
||||
user = decoded_token.get("sub")
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
folders = AgentFoldersRepository(conn).list_for_user(user)
|
||||
result = [_serialize_folder(f) for f in folders]
|
||||
return make_response(jsonify({"folders": result}), 200)
|
||||
except Exception as err:
|
||||
return _folder_error_response("Failed to fetch folders", err)
|
||||
|
||||
@api.doc(description="Create a new folder")
|
||||
@api.expect(
|
||||
api.model(
|
||||
"CreateFolder",
|
||||
{
|
||||
"name": fields.String(required=True, description="Folder name"),
|
||||
"parent_id": fields.String(required=False, description="Parent folder ID"),
|
||||
},
|
||||
)
|
||||
)
|
||||
def post(self):
|
||||
decoded_token = request.decoded_token
|
||||
if not decoded_token:
|
||||
return make_response(jsonify({"success": False}), 401)
|
||||
user = decoded_token.get("sub")
|
||||
data = request.get_json()
|
||||
if not data or not data.get("name"):
|
||||
return make_response(jsonify({"success": False, "message": "Folder name is required"}), 400)
|
||||
|
||||
parent_id_input = data.get("parent_id")
|
||||
description = data.get("description")
|
||||
|
||||
try:
|
||||
with db_session() as conn:
|
||||
repo = AgentFoldersRepository(conn)
|
||||
pg_parent_id = None
|
||||
if parent_id_input:
|
||||
parent = _resolve_folder_id(repo, parent_id_input, user)
|
||||
if not parent:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Parent folder not found"}),
|
||||
404,
|
||||
)
|
||||
pg_parent_id = str(parent["id"])
|
||||
folder = repo.create(
|
||||
user, data["name"],
|
||||
description=description,
|
||||
parent_id=pg_parent_id,
|
||||
)
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"id": str(folder["id"]),
|
||||
"name": folder["name"],
|
||||
"parent_id": pg_parent_id,
|
||||
}
|
||||
),
|
||||
201,
|
||||
)
|
||||
except Exception as err:
|
||||
return _folder_error_response("Failed to create folder", err)
|
||||
|
||||
|
||||
@agents_folders_ns.route("/<string:folder_id>")
|
||||
class AgentFolder(Resource):
|
||||
@api.doc(description="Get a specific folder with its agents")
|
||||
def get(self, folder_id):
|
||||
decoded_token = request.decoded_token
|
||||
if not decoded_token:
|
||||
return make_response(jsonify({"success": False}), 401)
|
||||
user = decoded_token.get("sub")
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
folders_repo = AgentFoldersRepository(conn)
|
||||
folder = _resolve_folder_id(folders_repo, folder_id, user)
|
||||
if not folder:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Folder not found"}),
|
||||
404,
|
||||
)
|
||||
pg_folder_id = str(folder["id"])
|
||||
|
||||
agents_rows = conn.execute(
|
||||
_sql_text(
|
||||
"SELECT id, name, description FROM agents "
|
||||
"WHERE user_id = :user_id AND folder_id = CAST(:fid AS uuid) "
|
||||
"ORDER BY created_at DESC"
|
||||
),
|
||||
{"user_id": user, "fid": pg_folder_id},
|
||||
).fetchall()
|
||||
agents_list = [
|
||||
{
|
||||
"id": str(row._mapping["id"]),
|
||||
"name": row._mapping["name"],
|
||||
"description": row._mapping.get("description", "") or "",
|
||||
}
|
||||
for row in agents_rows
|
||||
]
|
||||
|
||||
subfolders = folders_repo.list_children(pg_folder_id, user)
|
||||
subfolders_list = [
|
||||
{"id": str(sf["id"]), "name": sf["name"]}
|
||||
for sf in subfolders
|
||||
]
|
||||
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"id": pg_folder_id,
|
||||
"name": folder["name"],
|
||||
"parent_id": (
|
||||
str(folder["parent_id"]) if folder.get("parent_id") else None
|
||||
),
|
||||
"agents": agents_list,
|
||||
"subfolders": subfolders_list,
|
||||
}
|
||||
),
|
||||
200,
|
||||
)
|
||||
except Exception as err:
|
||||
return _folder_error_response("Failed to fetch folder", err)
|
||||
|
||||
@api.doc(description="Update a folder")
|
||||
def put(self, folder_id):
|
||||
decoded_token = request.decoded_token
|
||||
if not decoded_token:
|
||||
return make_response(jsonify({"success": False}), 401)
|
||||
user = decoded_token.get("sub")
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return make_response(jsonify({"success": False, "message": "No data provided"}), 400)
|
||||
|
||||
try:
|
||||
with db_session() as conn:
|
||||
repo = AgentFoldersRepository(conn)
|
||||
folder = _resolve_folder_id(repo, folder_id, user)
|
||||
if not folder:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Folder not found"}),
|
||||
404,
|
||||
)
|
||||
pg_folder_id = str(folder["id"])
|
||||
|
||||
update_fields: dict = {}
|
||||
if "name" in data:
|
||||
update_fields["name"] = data["name"]
|
||||
if "description" in data:
|
||||
update_fields["description"] = data["description"]
|
||||
if "parent_id" in data:
|
||||
parent_input = data.get("parent_id")
|
||||
if parent_input:
|
||||
if parent_input == folder_id or parent_input == pg_folder_id:
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"success": False,
|
||||
"message": "Cannot set folder as its own parent",
|
||||
}
|
||||
),
|
||||
400,
|
||||
)
|
||||
parent = _resolve_folder_id(repo, parent_input, user)
|
||||
if not parent:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Parent folder not found"}),
|
||||
404,
|
||||
)
|
||||
if str(parent["id"]) == pg_folder_id:
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"success": False,
|
||||
"message": "Cannot set folder as its own parent",
|
||||
}
|
||||
),
|
||||
400,
|
||||
)
|
||||
update_fields["parent_id"] = str(parent["id"])
|
||||
else:
|
||||
update_fields["parent_id"] = None
|
||||
|
||||
if update_fields:
|
||||
repo.update(pg_folder_id, user, update_fields)
|
||||
|
||||
return make_response(jsonify({"success": True}), 200)
|
||||
except Exception as err:
|
||||
return _folder_error_response("Failed to update folder", err)
|
||||
|
||||
@api.doc(description="Delete a folder")
|
||||
def delete(self, folder_id):
|
||||
decoded_token = request.decoded_token
|
||||
if not decoded_token:
|
||||
return make_response(jsonify({"success": False}), 401)
|
||||
user = decoded_token.get("sub")
|
||||
try:
|
||||
with db_session() as conn:
|
||||
repo = AgentFoldersRepository(conn)
|
||||
folder = _resolve_folder_id(repo, folder_id, user)
|
||||
if not folder:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Folder not found"}),
|
||||
404,
|
||||
)
|
||||
pg_folder_id = str(folder["id"])
|
||||
# Clear folder assignments from agents; self-FK
|
||||
# ``ON DELETE SET NULL`` handles child folders.
|
||||
AgentsRepository(conn).clear_folder_for_all(pg_folder_id, user)
|
||||
deleted = repo.delete(pg_folder_id, user)
|
||||
if not deleted:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Folder not found"}),
|
||||
404,
|
||||
)
|
||||
return make_response(jsonify({"success": True}), 200)
|
||||
except Exception as err:
|
||||
return _folder_error_response("Failed to delete folder", err)
|
||||
|
||||
|
||||
@agents_folders_ns.route("/move_agent")
|
||||
class MoveAgentToFolder(Resource):
|
||||
@api.doc(description="Move an agent to a folder or remove from folder")
|
||||
@api.expect(
|
||||
api.model(
|
||||
"MoveAgent",
|
||||
{
|
||||
"agent_id": fields.String(required=True, description="Agent ID to move"),
|
||||
"folder_id": fields.String(required=False, description="Target folder ID (null to remove from folder)"),
|
||||
},
|
||||
)
|
||||
)
|
||||
def post(self):
|
||||
decoded_token = request.decoded_token
|
||||
if not decoded_token:
|
||||
return make_response(jsonify({"success": False}), 401)
|
||||
user = decoded_token.get("sub")
|
||||
data = request.get_json()
|
||||
if not data or not data.get("agent_id"):
|
||||
return make_response(jsonify({"success": False, "message": "Agent ID is required"}), 400)
|
||||
|
||||
agent_id_input = data["agent_id"]
|
||||
folder_id_input = data.get("folder_id")
|
||||
|
||||
try:
|
||||
with db_session() as conn:
|
||||
agents_repo = AgentsRepository(conn)
|
||||
agent = agents_repo.get_any(agent_id_input, user)
|
||||
if not agent:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Agent not found"}),
|
||||
404,
|
||||
)
|
||||
pg_folder_id = None
|
||||
if folder_id_input:
|
||||
folders_repo = AgentFoldersRepository(conn)
|
||||
folder = _resolve_folder_id(folders_repo, folder_id_input, user)
|
||||
if not folder:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Folder not found"}),
|
||||
404,
|
||||
)
|
||||
pg_folder_id = str(folder["id"])
|
||||
agents_repo.set_folder(str(agent["id"]), user, pg_folder_id)
|
||||
return make_response(jsonify({"success": True}), 200)
|
||||
except Exception as err:
|
||||
return _folder_error_response("Failed to move agent", err)
|
||||
|
||||
|
||||
@agents_folders_ns.route("/bulk_move")
|
||||
class BulkMoveAgents(Resource):
|
||||
@api.doc(description="Move multiple agents to a folder")
|
||||
@api.expect(
|
||||
api.model(
|
||||
"BulkMoveAgents",
|
||||
{
|
||||
"agent_ids": fields.List(fields.String, required=True, description="List of agent IDs"),
|
||||
"folder_id": fields.String(required=False, description="Target folder ID"),
|
||||
},
|
||||
)
|
||||
)
|
||||
def post(self):
|
||||
decoded_token = request.decoded_token
|
||||
if not decoded_token:
|
||||
return make_response(jsonify({"success": False}), 401)
|
||||
user = decoded_token.get("sub")
|
||||
data = request.get_json()
|
||||
if not data or not data.get("agent_ids"):
|
||||
return make_response(jsonify({"success": False, "message": "Agent IDs are required"}), 400)
|
||||
|
||||
agent_ids = data["agent_ids"]
|
||||
folder_id_input = data.get("folder_id")
|
||||
|
||||
try:
|
||||
with db_session() as conn:
|
||||
agents_repo = AgentsRepository(conn)
|
||||
pg_folder_id = None
|
||||
if folder_id_input:
|
||||
folders_repo = AgentFoldersRepository(conn)
|
||||
folder = _resolve_folder_id(folders_repo, folder_id_input, user)
|
||||
if not folder:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Folder not found"}),
|
||||
404,
|
||||
)
|
||||
pg_folder_id = str(folder["id"])
|
||||
for agent_id_input in agent_ids:
|
||||
agent = agents_repo.get_any(agent_id_input, user)
|
||||
if agent is not None:
|
||||
agents_repo.set_folder(str(agent["id"]), user, pg_folder_id)
|
||||
return make_response(jsonify({"success": True}), 200)
|
||||
except Exception as err:
|
||||
return _folder_error_response("Failed to move agents", err)
|
||||
@@ -0,0 +1,949 @@
|
||||
"""Agent YAML export / import.
|
||||
|
||||
Agents are mostly references (sources, tools, prompt, models). Export
|
||||
translates those into portable identifiers and never emits a secret;
|
||||
import resolves them back against the importing user — matching existing
|
||||
resources or creating them from the file plus user-supplied tokens.
|
||||
|
||||
Re-import is idempotent at every level: the agent is matched by
|
||||
``metadata.id`` then ``metadata.slug``, tools by ``(type, name)``,
|
||||
prompts by ``(name, content)``, custom models by
|
||||
``(display_name, upstream_model_id, base_url)`` — so the same file
|
||||
applied twice updates rather than duplicating.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Optional
|
||||
|
||||
import yaml
|
||||
from flask import current_app, jsonify, make_response, request
|
||||
from flask_restx import Namespace, Resource
|
||||
|
||||
from application.agents.default_tools import (
|
||||
default_tool_id,
|
||||
is_synthesized_tool_id,
|
||||
synthesize_tool_by_name,
|
||||
synthesized_tool_name_for_id,
|
||||
)
|
||||
from application.api import api
|
||||
from application.core.model_utils import validate_model_id
|
||||
from application.core.url_validation import SSRFError, validate_url
|
||||
from application.security.safe_url import UnsafeUserUrlError, validate_user_base_url
|
||||
from application.storage.db.base_repository import looks_like_uuid
|
||||
from application.storage.db.repositories.agents import AgentsRepository
|
||||
from application.storage.db.repositories.prompts import PromptsRepository
|
||||
from application.storage.db.repositories.sources import SourcesRepository
|
||||
from application.storage.db.repositories.user_custom_models import (
|
||||
UserCustomModelsRepository,
|
||||
)
|
||||
from application.storage.db.repositories.user_tools import UserToolsRepository
|
||||
from application.storage.db.session import db_readonly, db_session
|
||||
|
||||
|
||||
API_VERSION = "docsgpt.arc53.com/v1"
|
||||
KIND = "Agent"
|
||||
|
||||
# Import safety bounds (DoS): cap the document size and the number of
|
||||
# child resources a single import may reference / create.
|
||||
MAX_IMPORT_BYTES = 512 * 1024
|
||||
MAX_LIST_ITEMS = 100
|
||||
|
||||
|
||||
class AgentImportError(Exception):
|
||||
"""Raised when an agent YAML document is malformed or unsupported."""
|
||||
|
||||
|
||||
class _SafeNoAliasLoader(yaml.SafeLoader):
|
||||
"""SafeLoader that also rejects anchors/aliases (billion-laughs guard)."""
|
||||
|
||||
def compose_node(self, parent, index):
|
||||
if self.check_event(yaml.events.AliasEvent):
|
||||
raise AgentImportError("YAML anchors/aliases are not allowed")
|
||||
return super().compose_node(parent, index)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Slug helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def slugify(name: Optional[str]) -> str:
|
||||
"""Turn an agent name into a url-safe slug; never empty."""
|
||||
s = (name or "").strip().lower()
|
||||
s = re.sub(r"[^a-z0-9]+", "-", s)
|
||||
s = re.sub(r"-{2,}", "-", s).strip("-")
|
||||
return s or "agent"
|
||||
|
||||
|
||||
def _unique_slug(
|
||||
repo: AgentsRepository,
|
||||
user: str,
|
||||
base: str,
|
||||
*,
|
||||
exclude_id: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Return ``base`` (slugified) or the first ``-N`` variant free for ``user``."""
|
||||
base = slugify(base)
|
||||
candidate = base
|
||||
n = 2
|
||||
while True:
|
||||
found = repo.find_by_slug(user, candidate)
|
||||
if found is None or (exclude_id and str(found["id"]) == str(exclude_id)):
|
||||
return candidate
|
||||
candidate = f"{base}-{n}"
|
||||
n += 1
|
||||
|
||||
|
||||
def ensure_agent_slug(conn, agent: dict, user: str) -> str:
|
||||
"""Return the agent's slug, lazily assigning and persisting one if absent."""
|
||||
existing = agent.get("slug")
|
||||
if existing:
|
||||
return str(existing)
|
||||
repo = AgentsRepository(conn)
|
||||
slug = _unique_slug(repo, user, agent.get("name"), exclude_id=str(agent["id"]))
|
||||
repo.update(str(agent["id"]), user, {"slug": slug})
|
||||
return slug
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool-manager access (lazy — avoids importing the tool registry at module load)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _tool_manager():
|
||||
from application.api.user.tools.routes import tool_manager
|
||||
|
||||
return tool_manager
|
||||
|
||||
|
||||
def _tool_instance(tool_type: str):
|
||||
return _tool_manager().tools.get(tool_type)
|
||||
|
||||
|
||||
def _secret_field_names(config_requirements: dict, *, required_only: bool = False) -> list:
|
||||
out = []
|
||||
for key, spec in (config_requirements or {}).items():
|
||||
if not isinstance(spec, dict) or not spec.get("secret"):
|
||||
continue
|
||||
if required_only and not spec.get("required"):
|
||||
continue
|
||||
out.append(key)
|
||||
return out
|
||||
|
||||
|
||||
def _live_requires_secrets(tool_type: str) -> list:
|
||||
inst = _tool_instance(tool_type)
|
||||
if inst is None:
|
||||
return []
|
||||
return _secret_field_names(inst.get_config_requirements() or {})
|
||||
|
||||
|
||||
def _safe_export_config(stored_config: dict, config_requirements: dict) -> dict:
|
||||
"""Return only config values provably non-secret (allowlist).
|
||||
|
||||
Export must never leak a credential. A key is emitted ONLY if
|
||||
``config_requirements`` declares it and does not mark it secret.
|
||||
Anything not described by requirements — e.g. ``api_tool``/MCP free-form
|
||||
``headers``, ``url`` query strings, ``server_url`` that routinely embed
|
||||
tokens, or tools with empty ``config_requirements`` — is dropped. Reuse
|
||||
on import matches the existing tool; create re-collects config from the
|
||||
user. This blocklist-free approach can't be defeated by a stale or empty
|
||||
``config_requirements``.
|
||||
"""
|
||||
requirements = config_requirements or {}
|
||||
safe: dict[str, Any] = {}
|
||||
for key, value in (stored_config or {}).items():
|
||||
spec = requirements.get(key)
|
||||
if isinstance(spec, dict) and not spec.get("secret"):
|
||||
safe[key] = value
|
||||
return safe
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Export
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _serialize_prompt(conn, agent: dict, user: str):
|
||||
prompt_id = agent.get("prompt_id")
|
||||
if not prompt_id:
|
||||
return "default"
|
||||
row = PromptsRepository(conn).get(str(prompt_id), user)
|
||||
if not row:
|
||||
return "default"
|
||||
return {"name": row.get("name") or "", "content": row.get("content") or ""}
|
||||
|
||||
|
||||
def _serialize_sources(conn, agent: dict, user: str) -> list:
|
||||
ids: list[str] = []
|
||||
if agent.get("source_id"):
|
||||
ids.append(str(agent["source_id"]))
|
||||
for sid in agent.get("extra_source_ids") or []:
|
||||
s = str(sid)
|
||||
if s and s not in ids:
|
||||
ids.append(s)
|
||||
repo = SourcesRepository(conn)
|
||||
out = []
|
||||
for sid in ids:
|
||||
row = repo.get(sid, user)
|
||||
if not row:
|
||||
continue
|
||||
out.append(
|
||||
{
|
||||
"name": row.get("name") or "",
|
||||
"type": row.get("type") or "",
|
||||
"ref": sid,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _serialize_tools(conn, agent: dict, user: str) -> list:
|
||||
repo = UserToolsRepository(conn)
|
||||
out = []
|
||||
for tid in agent.get("tools") or []:
|
||||
tid_str = str(tid)
|
||||
if is_synthesized_tool_id(tid_str):
|
||||
out.append({"type": synthesized_tool_name_for_id(tid_str), "builtin": True})
|
||||
continue
|
||||
row = repo.get_any(tid_str, user)
|
||||
if not row:
|
||||
continue
|
||||
requirements = row.get("config_requirements") or {}
|
||||
requires_secrets = _secret_field_names(requirements)
|
||||
entry: dict[str, Any] = {
|
||||
"type": row.get("name") or "",
|
||||
# Raw custom_name (may be "") so import matching is symmetric and
|
||||
# idempotent — see _find_user_tool.
|
||||
"name": row.get("custom_name") or "",
|
||||
"display_name": row.get("display_name") or "",
|
||||
"description": row.get("description") or "",
|
||||
"config": _safe_export_config(row.get("config") or {}, requirements),
|
||||
"ref": tid_str,
|
||||
}
|
||||
if requires_secrets:
|
||||
entry["requires_secrets"] = requires_secrets
|
||||
out.append(entry)
|
||||
return out
|
||||
|
||||
|
||||
def _serialize_models(conn, agent: dict, user: str) -> dict:
|
||||
repo = UserCustomModelsRepository(conn)
|
||||
|
||||
def describe(model_id: str):
|
||||
mid = str(model_id)
|
||||
if looks_like_uuid(mid):
|
||||
row = repo.get(mid, user)
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
"type": "custom",
|
||||
"display_name": row.get("display_name") or "",
|
||||
"upstream_model_id": row.get("upstream_model_id") or "",
|
||||
"base_url": row.get("base_url") or "",
|
||||
"capabilities": row.get("capabilities") or {},
|
||||
"requires_secrets": ["api_key"],
|
||||
"ref": mid,
|
||||
}
|
||||
return mid
|
||||
|
||||
available = []
|
||||
for m in agent.get("models") or []:
|
||||
described = describe(m)
|
||||
if described is not None:
|
||||
available.append(described)
|
||||
|
||||
default_value = ""
|
||||
raw_default = agent.get("default_model_id") or ""
|
||||
if raw_default:
|
||||
described = describe(raw_default)
|
||||
if isinstance(described, dict):
|
||||
default_value = described["display_name"]
|
||||
elif isinstance(described, str):
|
||||
default_value = described
|
||||
return {"default": default_value, "available": available}
|
||||
|
||||
|
||||
def serialize_agent(conn, agent: dict, user: str) -> dict:
|
||||
"""Build the portable export document for an agent row."""
|
||||
spec = {
|
||||
"name": agent.get("name") or "",
|
||||
"description": agent.get("description") or "",
|
||||
"agent_type": agent.get("agent_type") or "classic",
|
||||
"retriever": agent.get("retriever") or "classic",
|
||||
"chunks": int(agent["chunks"]) if agent.get("chunks") is not None else None,
|
||||
"prompt": _serialize_prompt(conn, agent, user),
|
||||
"model": _serialize_models(conn, agent, user),
|
||||
"sources": _serialize_sources(conn, agent, user),
|
||||
"tools": _serialize_tools(conn, agent, user),
|
||||
"limits": {
|
||||
"limited_token_mode": bool(agent.get("limited_token_mode", False)),
|
||||
"token_limit": agent.get("token_limit"),
|
||||
"limited_request_mode": bool(agent.get("limited_request_mode", False)),
|
||||
"request_limit": agent.get("request_limit"),
|
||||
},
|
||||
"json_schema": agent.get("json_schema"),
|
||||
"allow_system_prompt_override": bool(agent.get("allow_system_prompt_override", False)),
|
||||
}
|
||||
return {
|
||||
"apiVersion": API_VERSION,
|
||||
"kind": KIND,
|
||||
"metadata": {"id": str(agent["id"]), "slug": agent.get("slug") or ""},
|
||||
"spec": spec,
|
||||
}
|
||||
|
||||
|
||||
class _AgentYamlDumper(yaml.SafeDumper):
|
||||
"""SafeDumper that renders multi-line strings as block scalars."""
|
||||
|
||||
|
||||
def _str_representer(dumper, data):
|
||||
style = "|" if "\n" in data else None
|
||||
return dumper.represent_scalar("tag:yaml.org,2002:str", data, style=style)
|
||||
|
||||
|
||||
_AgentYamlDumper.add_representer(str, _str_representer)
|
||||
|
||||
|
||||
def agent_to_yaml(export: dict) -> str:
|
||||
"""Serialize an export document to YAML, preserving key order."""
|
||||
return yaml.dump(
|
||||
export,
|
||||
Dumper=_AgentYamlDumper,
|
||||
sort_keys=False,
|
||||
default_flow_style=False,
|
||||
allow_unicode=True,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Parse
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def parse_agent_yaml(text: str) -> dict:
|
||||
"""Parse and shallow-validate an agent YAML document."""
|
||||
if text and len(text) > MAX_IMPORT_BYTES:
|
||||
raise AgentImportError("Import document too large")
|
||||
try:
|
||||
doc = yaml.load(text, Loader=_SafeNoAliasLoader)
|
||||
except yaml.YAMLError as exc:
|
||||
raise AgentImportError(f"Invalid YAML: {exc}") from exc
|
||||
if not isinstance(doc, dict):
|
||||
raise AgentImportError("Top-level YAML must be a mapping")
|
||||
if doc.get("kind") != KIND:
|
||||
raise AgentImportError(f"Unsupported kind {doc.get('kind')!r}; expected {KIND!r}")
|
||||
if not str(doc.get("apiVersion") or "").startswith("docsgpt."):
|
||||
raise AgentImportError(f"Unsupported apiVersion {doc.get('apiVersion')!r}")
|
||||
spec = doc.get("spec")
|
||||
if not isinstance(spec, dict):
|
||||
raise AgentImportError("Missing or invalid 'spec'")
|
||||
if not spec.get("name"):
|
||||
raise AgentImportError("spec.name is required")
|
||||
if spec.get("agent_type") == "workflow":
|
||||
raise AgentImportError("Workflow agents are not supported for import yet")
|
||||
for key in ("sources", "tools"):
|
||||
value = spec.get(key)
|
||||
if isinstance(value, list) and len(value) > MAX_LIST_ITEMS:
|
||||
raise AgentImportError(f"Too many {key} (max {MAX_LIST_ITEMS})")
|
||||
available = (spec.get("model") or {}).get("available")
|
||||
if isinstance(available, list) and len(available) > MAX_LIST_ITEMS:
|
||||
raise AgentImportError(f"Too many models (max {MAX_LIST_ITEMS})")
|
||||
return doc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reference resolution (shared by plan + apply)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _resolve_target(conn, user: str, metadata: dict) -> dict:
|
||||
repo = AgentsRepository(conn)
|
||||
metadata = metadata or {}
|
||||
agent_id = metadata.get("id")
|
||||
if agent_id and looks_like_uuid(str(agent_id)):
|
||||
row = repo.get(str(agent_id), user)
|
||||
if row:
|
||||
return {
|
||||
"action": "update",
|
||||
"agent_id": str(row["id"]),
|
||||
"matched_by": "id",
|
||||
"status": row.get("status"),
|
||||
}
|
||||
slug = metadata.get("slug")
|
||||
if slug:
|
||||
row = repo.find_by_slug(user, str(slug))
|
||||
if row:
|
||||
return {
|
||||
"action": "update",
|
||||
"agent_id": str(row["id"]),
|
||||
"matched_by": "slug",
|
||||
"status": row.get("status"),
|
||||
}
|
||||
return {"action": "create", "agent_id": None, "matched_by": None, "status": None}
|
||||
|
||||
|
||||
def _find_user_tool(user_tools: list, tool_type: str, custom_name: str):
|
||||
"""Match a stored tool by ``(type, custom_name)`` for idempotent reuse.
|
||||
|
||||
Compares against the stored ``custom_name`` directly (export emits the
|
||||
raw value), so a named tool matches exactly and an unnamed one (``""``)
|
||||
reuses the first same-type tool rather than spawning a duplicate.
|
||||
"""
|
||||
target = custom_name or ""
|
||||
for row in user_tools:
|
||||
if (row.get("name") or "") != tool_type:
|
||||
continue
|
||||
if (row.get("custom_name") or "") == target:
|
||||
return row
|
||||
return None
|
||||
|
||||
|
||||
def _find_custom_model(customs: list, spec_model: dict):
|
||||
for row in customs:
|
||||
if (
|
||||
(row.get("display_name") or "") == (spec_model.get("display_name") or "")
|
||||
and (row.get("upstream_model_id") or "") == (spec_model.get("upstream_model_id") or "")
|
||||
and (row.get("base_url") or "") == (spec_model.get("base_url") or "")
|
||||
):
|
||||
return row
|
||||
return None
|
||||
|
||||
|
||||
def _tool_key(index: int) -> str:
|
||||
return f"tool-{index}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plan (dry run)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def plan_import(conn, user: str, doc: dict) -> dict:
|
||||
"""Resolve every reference without writing; returns a resolution report."""
|
||||
spec = doc["spec"]
|
||||
target = _resolve_target(conn, user, doc.get("metadata") or {})
|
||||
|
||||
sources_repo = SourcesRepository(conn)
|
||||
sources = []
|
||||
for src in spec.get("sources") or []:
|
||||
name = src.get("name") or ""
|
||||
match = sources_repo.find_by_name(user, name)
|
||||
sources.append(
|
||||
{
|
||||
"name": name,
|
||||
"type": src.get("type") or "",
|
||||
"status": "matched" if match else "missing",
|
||||
"target_id": str(match["id"]) if match else None,
|
||||
}
|
||||
)
|
||||
|
||||
user_tools = UserToolsRepository(conn).list_for_user(user)
|
||||
tools = []
|
||||
for index, tool in enumerate(spec.get("tools") or []):
|
||||
tool_type = tool.get("type") or ""
|
||||
if tool.get("builtin"):
|
||||
available = synthesize_tool_by_name(tool_type) is not None
|
||||
tools.append(
|
||||
{
|
||||
"key": _tool_key(index),
|
||||
"type": tool_type,
|
||||
"builtin": True,
|
||||
"status": "builtin" if available else "unavailable",
|
||||
"target_id": default_tool_id(tool_type) if available else None,
|
||||
}
|
||||
)
|
||||
continue
|
||||
custom_name = tool.get("name") or ""
|
||||
match = _find_user_tool(user_tools, tool_type, custom_name)
|
||||
if match:
|
||||
tools.append(
|
||||
{
|
||||
"key": _tool_key(index),
|
||||
"type": tool_type,
|
||||
"name": custom_name,
|
||||
"status": "reuse",
|
||||
"target_id": str(match["id"]),
|
||||
}
|
||||
)
|
||||
continue
|
||||
available = _tool_instance(tool_type) is not None
|
||||
tools.append(
|
||||
{
|
||||
"key": _tool_key(index),
|
||||
"type": tool_type,
|
||||
"name": custom_name,
|
||||
"status": "create" if available else "unavailable",
|
||||
"requires_secrets": tool.get("requires_secrets") or _live_requires_secrets(tool_type),
|
||||
}
|
||||
)
|
||||
|
||||
prompt_spec = spec.get("prompt")
|
||||
if isinstance(prompt_spec, dict):
|
||||
existing = PromptsRepository(conn).find(
|
||||
user, prompt_spec.get("name") or "", prompt_spec.get("content") or ""
|
||||
)
|
||||
prompt = {"status": "reuse" if existing else "create", "name": prompt_spec.get("name") or ""}
|
||||
else:
|
||||
prompt = {"status": "default"}
|
||||
|
||||
customs = UserCustomModelsRepository(conn).list_for_user(user)
|
||||
models = []
|
||||
for entry in (spec.get("model") or {}).get("available") or []:
|
||||
if isinstance(entry, str):
|
||||
models.append(
|
||||
{
|
||||
"id": entry,
|
||||
"status": "matched" if validate_model_id(entry, user) else "unavailable",
|
||||
}
|
||||
)
|
||||
elif isinstance(entry, dict):
|
||||
match = _find_custom_model(customs, entry)
|
||||
models.append(
|
||||
{
|
||||
"display_name": entry.get("display_name") or "",
|
||||
"status": "reuse" if match else "create",
|
||||
"requires_secrets": ["api_key"] if not match else [],
|
||||
}
|
||||
)
|
||||
|
||||
return {"target": target, "sources": sources, "tools": tools, "prompt": prompt, "models": models}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Apply
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _apply_prompt(conn, user: str, spec: dict) -> Optional[str]:
|
||||
prompt_spec = spec.get("prompt")
|
||||
if not isinstance(prompt_spec, dict):
|
||||
return None
|
||||
row = PromptsRepository(conn).find_or_create(
|
||||
user, prompt_spec.get("name") or "Imported prompt", prompt_spec.get("content") or ""
|
||||
)
|
||||
return str(row["id"])
|
||||
|
||||
|
||||
def _apply_sources(conn, user: str, spec: dict, resolution: dict, warnings: list) -> list:
|
||||
repo = SourcesRepository(conn)
|
||||
mapping = resolution.get("sources")
|
||||
if not isinstance(mapping, dict):
|
||||
mapping = {}
|
||||
resolved: list[str] = []
|
||||
for src in spec.get("sources") or []:
|
||||
name = src.get("name") or ""
|
||||
mapped = mapping.get(name)
|
||||
if mapped:
|
||||
# Ownership-check the client-supplied id before linking (IDOR guard).
|
||||
owned = repo.get_any(str(mapped), user)
|
||||
if owned:
|
||||
resolved.append(str(owned["id"]))
|
||||
else:
|
||||
warnings.append(f"Source mapping for '{name}' is not yours; ignored")
|
||||
continue
|
||||
match = repo.find_by_name(user, name)
|
||||
if match:
|
||||
resolved.append(str(match["id"]))
|
||||
else:
|
||||
warnings.append(f"Source '{name}' not found; left unattached")
|
||||
seen: set = set()
|
||||
out = []
|
||||
for sid in resolved:
|
||||
if sid not in seen:
|
||||
seen.add(sid)
|
||||
out.append(sid)
|
||||
return out
|
||||
|
||||
|
||||
def _validate_tool_urls(tool_type: str, config: dict) -> Optional[str]:
|
||||
"""SSRF-guard imported tool config to parity with the create routes.
|
||||
|
||||
Returns an error message if a URL is unsafe, else None.
|
||||
"""
|
||||
try:
|
||||
if tool_type == "mcp_tool":
|
||||
server_url = (config.get("server_url") or "").strip()
|
||||
if server_url:
|
||||
validate_url(server_url)
|
||||
elif tool_type == "api_tool":
|
||||
url = (config.get("url") or "").strip()
|
||||
if url:
|
||||
validate_url(url)
|
||||
except SSRFError:
|
||||
return f"Tool '{tool_type}' has an unsafe URL; not created"
|
||||
return None
|
||||
|
||||
|
||||
def _create_tool_from_spec(conn, user: str, tool: dict, secrets: dict, warnings: list) -> Optional[str]:
|
||||
from application.api.user.tools.routes import _encrypt_secret_fields, transform_actions
|
||||
|
||||
tool_type = tool.get("type") or ""
|
||||
inst = _tool_instance(tool_type)
|
||||
if inst is None:
|
||||
warnings.append(f"Tool type '{tool_type}' not available on this instance; skipped")
|
||||
return None
|
||||
config_requirements = inst.get_config_requirements() or {}
|
||||
config = dict(tool.get("config") or {})
|
||||
config.update(secrets or {})
|
||||
missing = [k for k in _secret_field_names(config_requirements, required_only=True) if not config.get(k)]
|
||||
if missing:
|
||||
warnings.append(f"Tool '{tool_type}' needs secret(s) {missing}; not created")
|
||||
return None
|
||||
url_error = _validate_tool_urls(tool_type, config)
|
||||
if url_error:
|
||||
warnings.append(url_error)
|
||||
return None
|
||||
provided_secrets = [k for k in _secret_field_names(config_requirements) if config.get(k)]
|
||||
storage_config = _encrypt_secret_fields(config, config_requirements, user)
|
||||
if provided_secrets and not storage_config.get("encrypted_credentials"):
|
||||
warnings.append(f"Tool '{tool_type}' secret encryption failed; not created")
|
||||
return None
|
||||
actions = transform_actions(inst.get_actions_metadata() or [])
|
||||
created = UserToolsRepository(conn).create(
|
||||
user,
|
||||
tool_type,
|
||||
config=storage_config,
|
||||
custom_name=tool.get("name") or "",
|
||||
display_name=tool.get("display_name") or tool_type,
|
||||
description=tool.get("description") or "",
|
||||
config_requirements=config_requirements,
|
||||
actions=actions,
|
||||
status=True,
|
||||
)
|
||||
return str(created["id"])
|
||||
|
||||
|
||||
def _apply_tools(conn, user: str, spec: dict, resolution: dict, warnings: list) -> list:
|
||||
repo = UserToolsRepository(conn)
|
||||
user_tools = repo.list_for_user(user)
|
||||
decisions = resolution.get("tools")
|
||||
if not isinstance(decisions, dict):
|
||||
decisions = {}
|
||||
out: list[str] = []
|
||||
for index, tool in enumerate(spec.get("tools") or []):
|
||||
tool_type = tool.get("type") or ""
|
||||
decision = decisions.get(_tool_key(index))
|
||||
if not isinstance(decision, dict):
|
||||
decision = {}
|
||||
choice = decision.get("decision")
|
||||
|
||||
if tool.get("builtin"):
|
||||
if synthesize_tool_by_name(tool_type) is None:
|
||||
warnings.append(f"Built-in tool '{tool_type}' unavailable; skipped")
|
||||
continue
|
||||
out.append(default_tool_id(tool_type))
|
||||
continue
|
||||
|
||||
if choice == "skip":
|
||||
continue
|
||||
if choice == "reuse" and decision.get("tool_id"):
|
||||
# Ownership-check the client-supplied id before linking (IDOR guard).
|
||||
tid = str(decision["tool_id"])
|
||||
if is_synthesized_tool_id(tid) or repo.get_any(tid, user):
|
||||
out.append(tid)
|
||||
else:
|
||||
warnings.append(f"Tool reuse id '{tid}' is not yours; ignored")
|
||||
continue
|
||||
|
||||
custom_name = tool.get("name") or ""
|
||||
match = _find_user_tool(user_tools, tool_type, custom_name)
|
||||
if match and choice != "create":
|
||||
out.append(str(match["id"]))
|
||||
continue
|
||||
|
||||
created_id = _create_tool_from_spec(conn, user, tool, decision.get("secrets") or {}, warnings)
|
||||
if created_id:
|
||||
out.append(created_id)
|
||||
# Record so a later identical spec entry reuses rather than re-creates.
|
||||
user_tools.append({"id": created_id, "name": tool_type, "custom_name": custom_name})
|
||||
return out
|
||||
|
||||
|
||||
def _apply_models(conn, user: str, spec: dict, resolution: dict, warnings: list):
|
||||
model_spec = spec.get("model") or {}
|
||||
repo = UserCustomModelsRepository(conn)
|
||||
customs = repo.list_for_user(user)
|
||||
decisions = resolution.get("models")
|
||||
if not isinstance(decisions, dict):
|
||||
decisions = {}
|
||||
name_to_id: dict[str, str] = {}
|
||||
models: list[str] = []
|
||||
|
||||
for entry in model_spec.get("available") or []:
|
||||
if isinstance(entry, str):
|
||||
if validate_model_id(entry, user):
|
||||
models.append(entry)
|
||||
name_to_id[entry] = entry
|
||||
else:
|
||||
warnings.append(f"Model '{entry}' not available; skipped")
|
||||
continue
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
display_name = entry.get("display_name") or ""
|
||||
match = _find_custom_model(customs, entry)
|
||||
if match:
|
||||
models.append(str(match["id"]))
|
||||
name_to_id[display_name] = str(match["id"])
|
||||
continue
|
||||
decision = decisions.get(display_name)
|
||||
api_key = decision.get("api_key") if isinstance(decision, dict) else None
|
||||
if not api_key:
|
||||
warnings.append(f"Custom model '{display_name}' needs an API key; skipped")
|
||||
continue
|
||||
base_url = entry.get("base_url") or ""
|
||||
try:
|
||||
validate_user_base_url(base_url)
|
||||
except UnsafeUserUrlError:
|
||||
warnings.append(f"Custom model '{display_name}' has an unsafe base URL; skipped")
|
||||
continue
|
||||
created = repo.create(
|
||||
user,
|
||||
entry.get("upstream_model_id") or "",
|
||||
display_name,
|
||||
base_url,
|
||||
api_key,
|
||||
capabilities=entry.get("capabilities") or {},
|
||||
)
|
||||
if not created.get("api_key_encrypted"):
|
||||
warnings.append(f"Custom model '{display_name}' key encryption failed; skipped")
|
||||
continue
|
||||
models.append(str(created["id"]))
|
||||
name_to_id[display_name] = str(created["id"])
|
||||
|
||||
default_id = ""
|
||||
raw_default = model_spec.get("default") or ""
|
||||
if raw_default:
|
||||
if raw_default in name_to_id:
|
||||
default_id = name_to_id[raw_default]
|
||||
elif validate_model_id(raw_default, user):
|
||||
default_id = raw_default
|
||||
else:
|
||||
# Custom-model default carried as its display_name — resolve against
|
||||
# the user's own models even if it wasn't in `available`.
|
||||
owned = next(
|
||||
(c for c in customs if (c.get("display_name") or "") == raw_default), None
|
||||
)
|
||||
if owned:
|
||||
default_id = str(owned["id"])
|
||||
else:
|
||||
warnings.append(f"Default model '{raw_default}' not resolved")
|
||||
return (models or None), (default_id or None)
|
||||
|
||||
|
||||
def _limit(spec: dict, key: str):
|
||||
return (spec.get("limits") or {}).get(key)
|
||||
|
||||
|
||||
def apply_import(conn, user: str, doc: dict, resolution: Optional[dict] = None) -> dict:
|
||||
"""Create or update an agent from a parsed YAML doc.
|
||||
|
||||
A newly created agent lands as a draft. An update to an existing agent
|
||||
(matched by id or slug) preserves that agent's current status, so
|
||||
re-importing over a published agent keeps it live — its API key and any
|
||||
active users are unaffected — rather than silently reverting it to draft.
|
||||
|
||||
On update the YAML is authoritative: fields it specifies are written even
|
||||
when that clears a value (e.g. removing all models, dropping ``json_schema``,
|
||||
or switching ``prompt`` back to default), so re-importing an edited file is
|
||||
a true sync rather than an additive merge.
|
||||
"""
|
||||
if not isinstance(resolution, dict):
|
||||
resolution = {}
|
||||
spec = doc["spec"]
|
||||
metadata = doc.get("metadata") or {}
|
||||
warnings: list[str] = []
|
||||
|
||||
target = _resolve_target(conn, user, metadata)
|
||||
|
||||
prompt_id = _apply_prompt(conn, user, spec)
|
||||
source_ids = _apply_sources(conn, user, spec, resolution, warnings)
|
||||
tool_ids = _apply_tools(conn, user, spec, resolution, warnings)
|
||||
models, default_model_id = _apply_models(conn, user, spec, resolution, warnings)
|
||||
|
||||
agents_repo = AgentsRepository(conn)
|
||||
is_update = bool(target.get("action") == "update" and target.get("agent_id"))
|
||||
exclude_id = str(target["agent_id"]) if is_update else None
|
||||
# Slug is recomputed from the file, not preserved from the matched row:
|
||||
# a round-tripped export carries metadata.slug so it stays stable, but an
|
||||
# update-by-id whose file omits the slug and renames the agent rewrites the
|
||||
# slug to follow the new name (excluding the row's own slug from collision
|
||||
# checks). Intended — the slug tracks the file.
|
||||
slug = _unique_slug(agents_repo, user, metadata.get("slug") or spec.get("name"), exclude_id=exclude_id)
|
||||
|
||||
try:
|
||||
chunks_value = int(spec["chunks"]) if spec.get("chunks") is not None else 2
|
||||
except (TypeError, ValueError):
|
||||
chunks_value = 2
|
||||
|
||||
# YAML-authoritative fields — written even when the resolved value is None,
|
||||
# so a re-import can CLEAR models / json_schema / prompt-to-default. On
|
||||
# create, AgentsRepository.create() skips None kwargs, so Nones become
|
||||
# column defaults.
|
||||
authoritative: dict[str, Any] = {
|
||||
"description": spec.get("description") or "",
|
||||
"agent_type": spec.get("agent_type") or "classic",
|
||||
"chunks": chunks_value,
|
||||
"prompt_id": prompt_id,
|
||||
"tools": tool_ids,
|
||||
"json_schema": spec.get("json_schema"),
|
||||
"models": models,
|
||||
"default_model_id": default_model_id,
|
||||
"extra_source_ids": source_ids,
|
||||
"limited_token_mode": bool(_limit(spec, "limited_token_mode")),
|
||||
"limited_request_mode": bool(_limit(spec, "limited_request_mode")),
|
||||
"allow_system_prompt_override": bool(spec.get("allow_system_prompt_override")),
|
||||
"slug": slug,
|
||||
}
|
||||
# Optional fields — applied only when present so a partial file doesn't wipe them.
|
||||
optional = {
|
||||
"retriever": spec.get("retriever") or ("classic" if not source_ids else None),
|
||||
"token_limit": _limit(spec, "token_limit"),
|
||||
"request_limit": _limit(spec, "request_limit"),
|
||||
}
|
||||
optional = {k: v for k, v in optional.items() if v is not None}
|
||||
|
||||
if is_update:
|
||||
# Status is intentionally omitted so an update never changes it:
|
||||
# re-importing over a published agent keeps it live (its API key and
|
||||
# any active users are unaffected) instead of reverting it to draft.
|
||||
# Only a brand-new agent (the create path below) starts as a draft.
|
||||
fields = {**authoritative, **optional, "name": spec.get("name")}
|
||||
if agents_repo.update(str(target["agent_id"]), user, fields):
|
||||
return {
|
||||
"agent_id": str(target["agent_id"]),
|
||||
"action": "updated",
|
||||
"status": target.get("status") or "draft",
|
||||
"slug": slug,
|
||||
"warnings": warnings,
|
||||
}
|
||||
# Row vanished between resolve and write — fall through to create.
|
||||
|
||||
row = agents_repo.create(user, spec.get("name"), "draft", **{**authoritative, **optional})
|
||||
return {
|
||||
"agent_id": str(row["id"]),
|
||||
"action": "created",
|
||||
"status": "draft",
|
||||
"slug": slug,
|
||||
"warnings": warnings,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTTP surface
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
agents_portability_ns = Namespace(
|
||||
"agents", description="Agent import/export operations", path="/api"
|
||||
)
|
||||
|
||||
|
||||
def _read_import_payload(req):
|
||||
"""Pull (yaml_text, resolution) from a JSON body or uploaded file.
|
||||
|
||||
Enforces a body-size cap before reading so a giant document can't be
|
||||
buffered into memory.
|
||||
"""
|
||||
if req.content_length and req.content_length > MAX_IMPORT_BYTES:
|
||||
raise AgentImportError("Import document too large")
|
||||
content_type = req.content_type or ""
|
||||
if "application/json" in content_type:
|
||||
data = req.get_json(silent=True) or {}
|
||||
resolution = data.get("resolution")
|
||||
return (
|
||||
data.get("yaml") or data.get("content") or "",
|
||||
resolution if isinstance(resolution, dict) else {},
|
||||
)
|
||||
if "file" in req.files:
|
||||
raw = req.files["file"].read(MAX_IMPORT_BYTES + 1)
|
||||
if len(raw) > MAX_IMPORT_BYTES:
|
||||
raise AgentImportError("Import document too large")
|
||||
return raw.decode("utf-8", "replace"), {}
|
||||
# Raw body: read from the stream with a hard cap so a chunked request
|
||||
# (no Content-Length) can't be buffered unbounded into memory.
|
||||
raw = req.stream.read(MAX_IMPORT_BYTES + 1)
|
||||
if len(raw) > MAX_IMPORT_BYTES:
|
||||
raise AgentImportError("Import document too large")
|
||||
return raw.decode("utf-8", "replace"), {}
|
||||
|
||||
|
||||
@agents_portability_ns.route("/export_agent")
|
||||
class ExportAgent(Resource):
|
||||
@api.doc(params={"id": "Agent ID"}, description="Export an agent as YAML")
|
||||
def get(self):
|
||||
if not (decoded_token := request.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)
|
||||
with db_session() as conn:
|
||||
repo = AgentsRepository(conn)
|
||||
agent = repo.get_any(agent_id, user)
|
||||
if not agent:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Agent not found"}), 404
|
||||
)
|
||||
if (agent.get("agent_type") or "") == "workflow":
|
||||
return make_response(
|
||||
jsonify(
|
||||
{"success": False, "message": "Workflow agents can't be exported yet"}
|
||||
),
|
||||
400,
|
||||
)
|
||||
agent["slug"] = ensure_agent_slug(conn, agent, user)
|
||||
export = serialize_agent(conn, agent, user)
|
||||
body = agent_to_yaml(export)
|
||||
filename = f"{export['metadata'].get('slug') or 'agent'}.agent.yaml"
|
||||
response = make_response(body, 200)
|
||||
response.headers["Content-Type"] = "application/x-yaml; charset=utf-8"
|
||||
response.headers["Content-Disposition"] = f'attachment; filename="{filename}"'
|
||||
return response
|
||||
|
||||
|
||||
@agents_portability_ns.route("/import_agent/plan")
|
||||
class ImportAgentPlan(Resource):
|
||||
@api.doc(description="Dry-run an agent YAML import and return the resolution plan")
|
||||
def post(self):
|
||||
if not (decoded_token := request.decoded_token):
|
||||
return make_response(jsonify({"success": False}), 401)
|
||||
user = decoded_token.get("sub")
|
||||
try:
|
||||
yaml_text, _ = _read_import_payload(request)
|
||||
doc = parse_agent_yaml(yaml_text)
|
||||
except AgentImportError as exc:
|
||||
return make_response(jsonify({"success": False, "message": str(exc)}), 400)
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
plan = plan_import(conn, user, doc)
|
||||
except Exception:
|
||||
current_app.logger.error("Agent import plan failed", exc_info=True)
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Could not analyze the agent file"}), 500
|
||||
)
|
||||
return make_response(jsonify({"success": True, "plan": plan}), 200)
|
||||
|
||||
|
||||
@agents_portability_ns.route("/import_agent")
|
||||
class ImportAgent(Resource):
|
||||
@api.doc(description="Import an agent from YAML (created as a draft)")
|
||||
def post(self):
|
||||
if not (decoded_token := request.decoded_token):
|
||||
return make_response(jsonify({"success": False}), 401)
|
||||
user = decoded_token.get("sub")
|
||||
try:
|
||||
yaml_text, resolution = _read_import_payload(request)
|
||||
doc = parse_agent_yaml(yaml_text)
|
||||
except AgentImportError as exc:
|
||||
return make_response(jsonify({"success": False, "message": str(exc)}), 400)
|
||||
try:
|
||||
with db_session() as conn:
|
||||
result = apply_import(conn, user, doc, resolution)
|
||||
except Exception:
|
||||
current_app.logger.error("Agent import failed", exc_info=True)
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Import failed"}), 500
|
||||
)
|
||||
return make_response(jsonify({"success": True, **result}), 200)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,274 @@
|
||||
"""Agent management sharing functionality."""
|
||||
|
||||
import datetime
|
||||
import secrets
|
||||
|
||||
from flask import current_app, jsonify, make_response, request
|
||||
from flask_restx import fields, Namespace, Resource
|
||||
from sqlalchemy import text as _sql_text
|
||||
|
||||
from application.api import api
|
||||
from application.core.settings import settings
|
||||
from application.api.user.base import resolve_tool_details
|
||||
from application.storage.db.base_repository import looks_like_uuid
|
||||
from application.storage.db.repositories.agents import AgentsRepository
|
||||
from application.storage.db.repositories.users import UsersRepository
|
||||
from application.storage.db.session import db_readonly, db_session
|
||||
from application.utils import generate_image_url
|
||||
|
||||
agents_sharing_ns = Namespace(
|
||||
"agents", description="Agent management operations", path="/api"
|
||||
)
|
||||
|
||||
|
||||
def _serialize_agent_basic(agent: dict) -> dict:
|
||||
"""Shape a PG agent row into the API response dict."""
|
||||
source_id = agent.get("source_id")
|
||||
return {
|
||||
"id": str(agent["id"]),
|
||||
"user": agent.get("user_id", ""),
|
||||
"name": agent.get("name", ""),
|
||||
"image": (
|
||||
generate_image_url(agent["image"]) if agent.get("image") else ""
|
||||
),
|
||||
"description": agent.get("description", ""),
|
||||
"source": str(source_id) if source_id else "",
|
||||
"chunks": str(agent["chunks"]) if agent.get("chunks") is not None else "0",
|
||||
"retriever": agent.get("retriever", "classic") or "classic",
|
||||
"prompt_id": str(agent["prompt_id"]) if agent.get("prompt_id") else "default",
|
||||
"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": agent.get("limited_token_mode", False),
|
||||
"token_limit": agent.get("token_limit") or settings.DEFAULT_AGENT_LIMITS["token_limit"],
|
||||
"limited_request_mode": 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", ""),
|
||||
"shared": bool(agent.get("shared", False)),
|
||||
"shared_token": agent.get("shared_token", "") or "",
|
||||
"shared_metadata": agent.get("shared_metadata", {}) or {},
|
||||
}
|
||||
|
||||
|
||||
@agents_sharing_ns.route("/shared_agent")
|
||||
class SharedAgent(Resource):
|
||||
@api.doc(
|
||||
params={
|
||||
"token": "Shared token of the agent",
|
||||
},
|
||||
description="Get a shared agent by token or ID",
|
||||
)
|
||||
def get(self):
|
||||
shared_token = request.args.get("token")
|
||||
|
||||
if not shared_token:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Token or ID is required"}), 400
|
||||
)
|
||||
try:
|
||||
with db_readonly() as conn:
|
||||
shared_agent = AgentsRepository(conn).find_by_shared_token(
|
||||
shared_token,
|
||||
)
|
||||
if not shared_agent:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Shared agent not found"}),
|
||||
404,
|
||||
)
|
||||
agent_id = str(shared_agent["id"])
|
||||
data = _serialize_agent_basic(shared_agent)
|
||||
|
||||
if data["tools"]:
|
||||
enriched_tools = []
|
||||
for detail in data["tool_details"]:
|
||||
enriched_tools.append(detail.get("name", ""))
|
||||
data["tools"] = enriched_tools
|
||||
decoded_token = getattr(request, "decoded_token", None)
|
||||
if decoded_token:
|
||||
user_id = decoded_token.get("sub")
|
||||
owner_id = shared_agent.get("user_id")
|
||||
|
||||
if user_id != owner_id:
|
||||
with db_session() as conn:
|
||||
users_repo = UsersRepository(conn)
|
||||
users_repo.upsert(user_id)
|
||||
users_repo.add_shared(user_id, agent_id)
|
||||
return make_response(jsonify(data), 200)
|
||||
except Exception as err:
|
||||
current_app.logger.error(f"Error retrieving shared agent: {err}")
|
||||
return make_response(jsonify({"success": False}), 400)
|
||||
|
||||
|
||||
@agents_sharing_ns.route("/shared_agents")
|
||||
class SharedAgents(Resource):
|
||||
@api.doc(description="Get shared agents explicitly shared with the user")
|
||||
def get(self):
|
||||
try:
|
||||
decoded_token = request.decoded_token
|
||||
if not decoded_token:
|
||||
return make_response(jsonify({"success": False}), 401)
|
||||
user_id = decoded_token.get("sub")
|
||||
|
||||
with db_session() as conn:
|
||||
users_repo = UsersRepository(conn)
|
||||
user_doc = users_repo.upsert(user_id)
|
||||
shared_with_ids = (
|
||||
user_doc.get("agent_preferences", {}).get("shared_with_me", [])
|
||||
if isinstance(user_doc.get("agent_preferences"), dict)
|
||||
else []
|
||||
)
|
||||
# Keep only UUID-shaped ids; ObjectId leftovers are stripped below.
|
||||
uuid_ids = [sid for sid in shared_with_ids if looks_like_uuid(sid)]
|
||||
non_uuid_ids = [sid for sid in shared_with_ids if not looks_like_uuid(sid)]
|
||||
|
||||
if uuid_ids:
|
||||
result = conn.execute(
|
||||
_sql_text(
|
||||
"SELECT * FROM agents "
|
||||
"WHERE id = ANY(CAST(:ids AS uuid[])) "
|
||||
"AND shared = true"
|
||||
),
|
||||
{"ids": uuid_ids},
|
||||
)
|
||||
shared_agents = [dict(row._mapping) for row in result.fetchall()]
|
||||
else:
|
||||
shared_agents = []
|
||||
|
||||
found_ids_set = {str(agent["id"]) for agent in shared_agents}
|
||||
stale_ids = [sid for sid in uuid_ids if sid not in found_ids_set]
|
||||
stale_ids.extend(non_uuid_ids)
|
||||
if stale_ids:
|
||||
users_repo.remove_shared_bulk(user_id, stale_ids)
|
||||
|
||||
pinned_ids = set(
|
||||
user_doc.get("agent_preferences", {}).get("pinned", [])
|
||||
if isinstance(user_doc.get("agent_preferences"), dict)
|
||||
else []
|
||||
)
|
||||
|
||||
list_shared_agents = []
|
||||
for agent in shared_agents:
|
||||
agent_id_str = str(agent["id"])
|
||||
list_shared_agents.append(
|
||||
{
|
||||
"id": agent_id_str,
|
||||
"name": agent.get("name", ""),
|
||||
"description": agent.get("description", ""),
|
||||
"image": (
|
||||
generate_image_url(agent["image"]) if agent.get("image") 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": agent.get("limited_token_mode", False),
|
||||
"token_limit": agent.get("token_limit") or settings.DEFAULT_AGENT_LIMITS["token_limit"],
|
||||
"limited_request_mode": 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", ""),
|
||||
"pinned": agent_id_str in pinned_ids,
|
||||
"shared": bool(agent.get("shared", False)),
|
||||
"shared_token": agent.get("shared_token", "") or "",
|
||||
"shared_metadata": agent.get("shared_metadata", {}) or {},
|
||||
}
|
||||
)
|
||||
|
||||
return make_response(jsonify(list_shared_agents), 200)
|
||||
except Exception as err:
|
||||
current_app.logger.error(f"Error retrieving shared agents: {err}")
|
||||
return make_response(jsonify({"success": False}), 400)
|
||||
|
||||
|
||||
@agents_sharing_ns.route("/share_agent")
|
||||
class ShareAgent(Resource):
|
||||
@api.expect(
|
||||
api.model(
|
||||
"ShareAgentModel",
|
||||
{
|
||||
"id": fields.String(required=True, description="ID of the agent"),
|
||||
"shared": fields.Boolean(
|
||||
required=True, description="Share or unshare the agent"
|
||||
),
|
||||
"username": fields.String(
|
||||
required=False, description="Name of the user"
|
||||
),
|
||||
},
|
||||
)
|
||||
)
|
||||
@api.doc(description="Share or unshare an agent")
|
||||
def put(self):
|
||||
decoded_token = request.decoded_token
|
||||
if not decoded_token:
|
||||
return make_response(jsonify({"success": False}), 401)
|
||||
user = decoded_token.get("sub")
|
||||
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Missing JSON body"}), 400
|
||||
)
|
||||
agent_id = data.get("id")
|
||||
shared = data.get("shared")
|
||||
username = data.get("username", "")
|
||||
|
||||
if not agent_id:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "ID is required"}), 400
|
||||
)
|
||||
if shared is None:
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"success": False,
|
||||
"message": "Shared parameter is required and must be true or false",
|
||||
}
|
||||
),
|
||||
400,
|
||||
)
|
||||
shared_token = None
|
||||
try:
|
||||
with db_session() as conn:
|
||||
repo = AgentsRepository(conn)
|
||||
agent = repo.get_any(agent_id, user)
|
||||
if not agent:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Agent not found"}), 404
|
||||
)
|
||||
if shared:
|
||||
shared_metadata = {
|
||||
"shared_by": username,
|
||||
"shared_at": datetime.datetime.now(
|
||||
datetime.timezone.utc
|
||||
).isoformat(),
|
||||
}
|
||||
shared_token = secrets.token_urlsafe(32)
|
||||
repo.update(
|
||||
str(agent["id"]), user,
|
||||
{
|
||||
"shared": True,
|
||||
"shared_token": shared_token,
|
||||
"shared_metadata": shared_metadata,
|
||||
},
|
||||
)
|
||||
else:
|
||||
repo.update(
|
||||
str(agent["id"]), user,
|
||||
{
|
||||
"shared": False,
|
||||
"shared_token": None,
|
||||
"shared_metadata": None,
|
||||
},
|
||||
)
|
||||
except Exception as err:
|
||||
current_app.logger.error(f"Error sharing/unsharing agent: {err}", exc_info=True)
|
||||
return make_response(jsonify({"success": False, "error": "Failed to update agent sharing status"}), 400)
|
||||
return make_response(
|
||||
jsonify({"success": True, "shared_token": shared_token}), 200
|
||||
)
|
||||
@@ -0,0 +1,232 @@
|
||||
"""Agent management webhook handlers."""
|
||||
|
||||
import secrets
|
||||
import uuid
|
||||
|
||||
from flask import current_app, jsonify, make_response, request
|
||||
from flask_restx import Namespace, Resource
|
||||
from sqlalchemy import text as sql_text
|
||||
|
||||
from application.api import api
|
||||
from application.api.user.base import require_agent
|
||||
from application.api.user.tasks import process_agent_webhook
|
||||
from application.core.settings import settings
|
||||
from application.storage.db.base_repository import looks_like_uuid
|
||||
from application.storage.db.repositories.agents import AgentsRepository
|
||||
from application.storage.db.repositories.idempotency import IdempotencyRepository
|
||||
from application.storage.db.session import db_readonly, db_session
|
||||
|
||||
|
||||
agents_webhooks_ns = Namespace(
|
||||
"agents", description="Agent management operations", path="/api"
|
||||
)
|
||||
|
||||
|
||||
_IDEMPOTENCY_KEY_MAX_LEN = 256
|
||||
|
||||
|
||||
def _read_idempotency_key():
|
||||
"""Return (key, error_response). Empty header → (None, None); oversized → (None, 400)."""
|
||||
key = request.headers.get("Idempotency-Key")
|
||||
if not key:
|
||||
return None, None
|
||||
if len(key) > _IDEMPOTENCY_KEY_MAX_LEN:
|
||||
return None, make_response(
|
||||
jsonify(
|
||||
{
|
||||
"success": False,
|
||||
"message": (
|
||||
f"Idempotency-Key exceeds maximum length of "
|
||||
f"{_IDEMPOTENCY_KEY_MAX_LEN} characters"
|
||||
),
|
||||
}
|
||||
),
|
||||
400,
|
||||
)
|
||||
return key, None
|
||||
|
||||
|
||||
def _scoped_idempotency_key(idempotency_key, scope):
|
||||
"""``{scope}:{key}`` so different agents can't collide on the same key."""
|
||||
if not idempotency_key or not scope:
|
||||
return None
|
||||
return f"{scope}:{idempotency_key}"
|
||||
|
||||
|
||||
@agents_webhooks_ns.route("/agent_webhook")
|
||||
class AgentWebhook(Resource):
|
||||
@api.doc(
|
||||
params={"id": "ID of the agent"},
|
||||
description="Generate webhook URL for the agent",
|
||||
)
|
||||
def get(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_readonly() as conn:
|
||||
agent = AgentsRepository(conn).get_any(agent_id, user)
|
||||
if not agent:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Agent not found"}), 404
|
||||
)
|
||||
webhook_token = agent.get("incoming_webhook_token")
|
||||
if not webhook_token:
|
||||
webhook_token = secrets.token_urlsafe(32)
|
||||
with db_session() as conn:
|
||||
AgentsRepository(conn).update(
|
||||
str(agent["id"]), user,
|
||||
{"incoming_webhook_token": webhook_token},
|
||||
)
|
||||
base_url = settings.API_URL.rstrip("/")
|
||||
full_webhook_url = f"{base_url}/api/webhooks/agents/{webhook_token}"
|
||||
except Exception as err:
|
||||
current_app.logger.error(
|
||||
f"Error generating webhook URL: {err}", exc_info=True
|
||||
)
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Error generating webhook URL"}),
|
||||
400,
|
||||
)
|
||||
return make_response(
|
||||
jsonify({"success": True, "webhook_url": full_webhook_url}), 200
|
||||
)
|
||||
|
||||
|
||||
@agents_webhooks_ns.route("/webhooks/agents/<string:webhook_token>")
|
||||
class AgentWebhookListener(Resource):
|
||||
method_decorators = [require_agent]
|
||||
|
||||
def _enqueue_webhook_task(self, agent_id_str, payload, source_method, agent=None):
|
||||
if not payload:
|
||||
current_app.logger.warning(
|
||||
f"Webhook ({source_method}) received for agent {agent_id_str} with empty payload."
|
||||
)
|
||||
current_app.logger.info(
|
||||
f"Incoming {source_method} webhook for agent {agent_id_str}. Enqueuing task with payload: {payload}"
|
||||
)
|
||||
|
||||
idempotency_key, key_error = _read_idempotency_key()
|
||||
if key_error is not None:
|
||||
return key_error
|
||||
# Resolve to PG UUID first so dedup writes don't crash on legacy ids.
|
||||
agent_uuid = None
|
||||
if agent is not None:
|
||||
candidate = str(agent.get("id") or "")
|
||||
if looks_like_uuid(candidate):
|
||||
agent_uuid = candidate
|
||||
if idempotency_key and agent_uuid is None:
|
||||
current_app.logger.warning(
|
||||
"Skipping webhook idempotency dedup: agent %s has non-UUID id",
|
||||
agent_id_str,
|
||||
)
|
||||
idempotency_key = None
|
||||
# Agent-scoped (webhooks have no user_id).
|
||||
scoped_key = _scoped_idempotency_key(idempotency_key, agent_uuid)
|
||||
# Claim before enqueue; the loser returns the winner's task_id.
|
||||
predetermined_task_id = None
|
||||
if scoped_key:
|
||||
predetermined_task_id = str(uuid.uuid4())
|
||||
with db_session() as conn:
|
||||
claimed = IdempotencyRepository(conn).record_webhook(
|
||||
key=scoped_key,
|
||||
agent_id=agent_uuid,
|
||||
task_id=predetermined_task_id,
|
||||
response_json={
|
||||
"success": True, "task_id": predetermined_task_id,
|
||||
},
|
||||
)
|
||||
if claimed is None:
|
||||
with db_readonly() as conn:
|
||||
cached = IdempotencyRepository(conn).get_webhook(scoped_key)
|
||||
if cached is not None:
|
||||
return make_response(jsonify(cached["response_json"]), 200)
|
||||
return make_response(
|
||||
jsonify({"success": True, "task_id": "deduplicated"}), 200
|
||||
)
|
||||
|
||||
try:
|
||||
apply_kwargs = dict(
|
||||
kwargs={
|
||||
"agent_id": agent_id_str,
|
||||
"payload": payload,
|
||||
# Scoped so the worker dedup row matches the HTTP claim.
|
||||
"idempotency_key": scoped_key or idempotency_key,
|
||||
},
|
||||
)
|
||||
if predetermined_task_id is not None:
|
||||
apply_kwargs["task_id"] = predetermined_task_id
|
||||
task = process_agent_webhook.apply_async(**apply_kwargs)
|
||||
current_app.logger.info(
|
||||
f"Task {task.id} enqueued for agent {agent_id_str} ({source_method})."
|
||||
)
|
||||
response_payload = {"success": True, "task_id": task.id}
|
||||
return make_response(jsonify(response_payload), 200)
|
||||
except Exception as err:
|
||||
current_app.logger.error(
|
||||
f"Error enqueuing webhook task ({source_method}) for agent {agent_id_str}: {err}",
|
||||
exc_info=True,
|
||||
)
|
||||
if scoped_key:
|
||||
# Roll back the claim so a retry can succeed.
|
||||
try:
|
||||
with db_session() as conn:
|
||||
conn.execute(
|
||||
sql_text(
|
||||
"DELETE FROM webhook_dedup "
|
||||
"WHERE idempotency_key = :k"
|
||||
),
|
||||
{"k": scoped_key},
|
||||
)
|
||||
except Exception:
|
||||
current_app.logger.exception(
|
||||
"Failed to release webhook_dedup claim for key=%s",
|
||||
scoped_key,
|
||||
)
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Error processing webhook"}), 500
|
||||
)
|
||||
|
||||
@api.doc(
|
||||
description=(
|
||||
"Webhook listener for agent events (POST). Expects JSON payload, which "
|
||||
"is used to trigger processing. Honors an optional ``Idempotency-Key`` "
|
||||
"header: a repeat request with the same key within 24h returns the "
|
||||
"original cached response and does not re-enqueue the task."
|
||||
),
|
||||
)
|
||||
def post(self, webhook_token, agent, agent_id_str):
|
||||
payload = request.get_json()
|
||||
if payload is None:
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"success": False,
|
||||
"message": "Invalid or missing JSON data in request body",
|
||||
}
|
||||
),
|
||||
400,
|
||||
)
|
||||
return self._enqueue_webhook_task(
|
||||
agent_id_str, payload, source_method="POST", agent=agent,
|
||||
)
|
||||
|
||||
@api.doc(
|
||||
description=(
|
||||
"Webhook listener for agent events (GET). Uses URL query parameters as "
|
||||
"payload to trigger processing. Honors an optional ``Idempotency-Key`` "
|
||||
"header: a repeat request with the same key within 24h returns the "
|
||||
"original cached response and does not re-enqueue the task."
|
||||
),
|
||||
)
|
||||
def get(self, webhook_token, agent, agent_id_str):
|
||||
payload = request.args.to_dict(flat=True)
|
||||
return self._enqueue_webhook_task(
|
||||
agent_id_str, payload, source_method="GET", agent=agent,
|
||||
)
|
||||
Reference in New Issue
Block a user