diff --git a/application/agents/base.py b/application/agents/base.py index 83afdef3..b8efeb24 100644 --- a/application/agents/base.py +++ b/application/agents/base.py @@ -98,6 +98,7 @@ class BaseAgent(ABC): user_api_key=user_api_key, user=self.user, decoded_token=decoded_token, + agent_id=agent_id, ) self.attachments = attachments or [] diff --git a/application/agents/default_tools.py b/application/agents/default_tools.py new file mode 100644 index 00000000..cc230488 --- /dev/null +++ b/application/agents/default_tools.py @@ -0,0 +1,368 @@ +"""Default chat tools — config-free tools on by default in chats.""" + +from __future__ import annotations + +import importlib +import inspect +import logging +import uuid +from typing import Any, Dict, List, Optional + +from application.core.settings import settings + +logger = logging.getLogger(__name__) + +# Fixed namespace — never regenerate; produced ids are persisted. +_DEFAULT_TOOL_NAMESPACE = uuid.UUID("6b1d3f2a-9c84-4d17-bf6e-2a0c5e8d4471") + +# Tool names whose storage tables FK ``tool_id`` to ``user_tools.id``; +# a synthetic id has no row, so a write would FK-violate. Schema-rot +# guard: ``tests.agents.test_default_tools.TestFkBoundToolsIsInSync``. +_FK_BOUND_TOOLS = frozenset({"notes", "todo_list"}) + +# Tools that should NEVER appear in a headless run (scheduled or webhook). +# ``scheduler`` only makes sense from an interactive chat — letting an LLM +# call ``schedule_task`` from a scheduled run chains new schedules each fire, +# bounded only by ``SCHEDULE_MAX_PER_USER`` (cost foot-gun, confusing UX). +_HEADLESS_EXCLUDED_TOOLS = frozenset({"scheduler"}) + +# Agent-selectable builtins: hidden from the Add-Tool catalog (internal=True) +# and exposed to the agent picker via the same synthetic-id machinery as +# default tools. Names may overlap with DEFAULT_CHAT_TOOLS (e.g. ``scheduler``) +# — both registries share ``_DEFAULT_TOOL_NAMESPACE`` so the same uuid5 +# resolves either way (the dual-flag row carries ``default`` AND ``builtin``). +BUILTIN_AGENT_TOOLS: tuple = ("scheduler",) + +_tool_cache: Dict[str, Optional[Any]] = {} +_ids_cache: Dict[tuple, Dict[str, str]] = {} +_id_set_cache: Dict[tuple, frozenset] = {} +_loaded_cache: Dict[tuple, List[str]] = {} +_builtin_ids_cache: Dict[tuple, Dict[str, str]] = {} +_builtin_id_set_cache: Dict[tuple, frozenset] = {} +_builtin_loaded_cache: Dict[tuple, List[str]] = {} + + +def _load_tool(tool_name: str) -> Optional[Any]: + """Return a metadata-only instance of a tool, or None if it has no class.""" + # Imports just the named module (not the whole package) — avoids the + # circular import via ``mcp_tool`` → ``application.api.user``. + if tool_name in _tool_cache: + return _tool_cache[tool_name] + + from application.agents.tools.base import Tool + + instance: Optional[Any] = None + try: + module = importlib.import_module(f"application.agents.tools.{tool_name}") + except ModuleNotFoundError: + _tool_cache[tool_name] = None + return None + for _, obj in inspect.getmembers(module, inspect.isclass): + if issubclass(obj, Tool) and obj is not Tool: + try: + instance = obj({}) + except Exception: + logger.warning( + "DEFAULT_CHAT_TOOLS entry %r failed to instantiate; skipping.", + tool_name, + ) + instance = None + break + _tool_cache[tool_name] = instance + return instance + + +def default_tool_id(tool_name: str) -> str: + """Return the deterministic synthetic id for a default tool name.""" + return str(uuid.uuid5(_DEFAULT_TOOL_NAMESPACE, tool_name)) + + +def default_tool_ids() -> Dict[str, str]: + """Map each configured default-tool name to its synthetic id (memoized).""" + key = tuple(settings.DEFAULT_CHAT_TOOLS) + cached = _ids_cache.get(key) + if cached is None: + cached = {name: default_tool_id(name) for name in key} + _ids_cache[key] = cached + return cached + + +def is_default_tool_id(tool_id: Any) -> bool: + """Return True if ``tool_id`` is a synthetic default-tool id.""" + if not tool_id: + return False + key = tuple(settings.DEFAULT_CHAT_TOOLS) + cached = _id_set_cache.get(key) + if cached is None: + cached = frozenset(default_tool_ids().values()) + _id_set_cache[key] = cached + return str(tool_id) in cached + + +def default_tool_name_for_id(tool_id: Any) -> Optional[str]: + """Return the default-tool name for a synthetic id, or None.""" + target = str(tool_id) if tool_id else "" + for name, synthetic_id in default_tool_ids().items(): + if synthetic_id == target: + return name + return None + + +def builtin_agent_tool_ids() -> Dict[str, str]: + """Map each agent-selectable builtin to its synthetic id (memoized).""" + key = tuple(BUILTIN_AGENT_TOOLS) + cached = _builtin_ids_cache.get(key) + if cached is None: + cached = {name: default_tool_id(name) for name in key} + _builtin_ids_cache[key] = cached + return cached + + +def is_builtin_agent_tool_id(tool_id: Any) -> bool: + """Return True if ``tool_id`` is an agent-selectable builtin synthetic id.""" + if not tool_id: + return False + key = tuple(BUILTIN_AGENT_TOOLS) + cached = _builtin_id_set_cache.get(key) + if cached is None: + cached = frozenset(builtin_agent_tool_ids().values()) + _builtin_id_set_cache[key] = cached + return str(tool_id) in cached + + +def builtin_agent_tool_name_for_id(tool_id: Any) -> Optional[str]: + """Return the builtin tool name for a synthetic id, or None.""" + target = str(tool_id) if tool_id else "" + for name, synthetic_id in builtin_agent_tool_ids().items(): + if synthetic_id == target: + return name + return None + + +def synthesized_tool_name_for_id(tool_id: Any) -> Optional[str]: + """Return the tool name for any synthetic id (default or builtin), or None.""" + return default_tool_name_for_id(tool_id) or builtin_agent_tool_name_for_id(tool_id) + + +def is_synthesized_tool_id(tool_id: Any) -> bool: + """Return True for any synthetic id (default chat or agent-builtin).""" + return is_default_tool_id(tool_id) or is_builtin_agent_tool_id(tool_id) + + +def loaded_default_tools() -> List[str]: + """Return configured default-tool names that resolve to a loaded tool.""" + # Silent + memoized — runs per request; the one-time skip notice + # for unimplemented names lives in ``validate_default_chat_tools``. + key = tuple(settings.DEFAULT_CHAT_TOOLS) + cached = _loaded_cache.get(key) + if cached is None: + cached = [name for name in key if _load_tool(name) is not None] + _loaded_cache[key] = cached + return cached + + +def loaded_builtin_agent_tools() -> List[str]: + """Return builtin agent-tool names that resolve to a loaded tool.""" + key = tuple(BUILTIN_AGENT_TOOLS) + cached = _builtin_loaded_cache.get(key) + if cached is None: + cached = [name for name in key if _load_tool(name) is not None] + _builtin_loaded_cache[key] = cached + return cached + + +def validate_default_chat_tools() -> List[str]: + """Validate ``DEFAULT_CHAT_TOOLS`` at startup; return the usable names.""" + skipped = [ + name for name in settings.DEFAULT_CHAT_TOOLS if _load_tool(name) is None + ] + if skipped: + logger.debug( + "DEFAULT_CHAT_TOOLS entries with no loaded tool, skipped: %s. " + "Each activates automatically once its tool exists.", + ", ".join(skipped), + ) + usable = loaded_default_tools() + for name in usable: + if name in _FK_BOUND_TOOLS: + raise ValueError( + f"DEFAULT_CHAT_TOOLS entry {name!r} has a storage table " + f"that foreign-keys tool_id to user_tools; a default tool " + f"has a synthetic id with no user_tools row, so it would " + f"fail at write time. It cannot be defaulted on." + ) + requirements = _load_tool(name).get_config_requirements() or {} + required = [ + key for key, spec in requirements.items() + if isinstance(spec, dict) and spec.get("required") + ] + if required: + raise ValueError( + f"DEFAULT_CHAT_TOOLS entry {name!r} requires config " + f"fields {required}; only config-free tools may be " + "defaulted on." + ) + if usable: + logger.info("Default chat tools active: %s", ", ".join(usable)) + return usable + + +def _tool_display(tool_name: str) -> str: + """Return the human-readable display name from the tool docstring.""" + tool = _load_tool(tool_name) + doc = (tool.__doc__ or "").strip() if tool else "" + first_line = doc.split("\n", 1)[0].strip() if doc else "" + return first_line or tool_name + + +def _tool_description(tool_name: str) -> str: + """Return the tool description (docstring lines after the first).""" + tool = _load_tool(tool_name) + doc = (tool.__doc__ or "").strip() if tool else "" + parts = doc.split("\n", 1) + return parts[1].strip() if len(parts) > 1 else "" + + +def synthesize_default_tool(tool_name: str) -> Optional[Dict[str, Any]]: + """Build an in-memory ``user_tools``-shaped row for a default tool.""" + tool = _load_tool(tool_name) + if tool is None: + return None + synthetic_id = default_tool_id(tool_name) + return { + "id": synthetic_id, + "_id": synthetic_id, + "name": tool_name, + "display_name": _tool_display(tool_name), + "custom_name": "", + "description": _tool_description(tool_name), + "config": {}, + "config_requirements": {}, + "actions": tool.get_actions_metadata() or [], + "status": True, + "default": True, + } + + +def synthesize_builtin_agent_tool(tool_name: str) -> Optional[Dict[str, Any]]: + """Build an in-memory ``user_tools``-shaped row for a builtin agent tool.""" + tool = _load_tool(tool_name) + if tool is None: + return None + synthetic_id = default_tool_id(tool_name) + return { + "id": synthetic_id, + "_id": synthetic_id, + "name": tool_name, + "display_name": _tool_display(tool_name), + "custom_name": "", + "description": _tool_description(tool_name), + "config": {}, + "config_requirements": {}, + "actions": tool.get_actions_metadata() or [], + "status": True, + "default": False, + "builtin": True, + } + + +def synthesize_tool_by_name(tool_name: str) -> Optional[Dict[str, Any]]: + """Synthesize the row for any default or builtin tool name.""" + if tool_name in BUILTIN_AGENT_TOOLS: + return synthesize_builtin_agent_tool(tool_name) + return synthesize_default_tool(tool_name) + + +def disabled_default_tools(user_doc: Optional[Dict[str, Any]]) -> List[str]: + """Return the user's opt-out list from ``tool_preferences``.""" + if not isinstance(user_doc, dict): + return [] + prefs = user_doc.get("tool_preferences") or {} + if not isinstance(prefs, dict): + return [] + disabled = prefs.get("disabled_default_tools") or [] + if not isinstance(disabled, list): + return [] + return [str(name) for name in disabled] + + +def synthesized_default_tools( + user_doc: Optional[Dict[str, Any]] = None, + *, + headless: bool = False, +) -> List[Dict[str, Any]]: + """Return synthesized default-tool rows for an agentless chat.""" + # Agent-bound chats must NOT call this — they resolve exactly + # ``agents.tools``. Disabled defaults are dropped. ``headless=True`` + # additionally drops chat-only tools (e.g. ``scheduler``) so a scheduled + # / webhook LLM can't re-schedule itself. + disabled = set(disabled_default_tools(user_doc)) + rows: List[Dict[str, Any]] = [] + for name in loaded_default_tools(): + if name in disabled: + continue + if headless and name in _HEADLESS_EXCLUDED_TOOLS: + continue + row = synthesize_default_tool(name) + if row is not None: + rows.append(row) + return rows + + +def is_headless_excluded_tool(tool_name: Optional[str]) -> bool: + """Return True if ``tool_name`` must be hidden from headless runs.""" + return bool(tool_name) and tool_name in _HEADLESS_EXCLUDED_TOOLS + + +def default_tools_for_management( + user_doc: Optional[Dict[str, Any]] = None, +) -> List[Dict[str, Any]]: + """Return every loaded default tool with its on/off ``status``.""" + # Unlike ``synthesized_default_tools`` (chat toolset), this keeps + # disabled tools so the management UI can render their toggle. + disabled = set(disabled_default_tools(user_doc)) + rows: List[Dict[str, Any]] = [] + for name in loaded_default_tools(): + row = synthesize_default_tool(name) + if row is None: + continue + row["status"] = name not in disabled + rows.append(row) + return rows + + +def builtin_agent_tools_for_management() -> List[Dict[str, Any]]: + """Return every loaded agent-builtin tool for the agent picker (no per-user state).""" + rows: List[Dict[str, Any]] = [] + for name in loaded_builtin_agent_tools(): + row = synthesize_builtin_agent_tool(name) + if row is None: + continue + rows.append(row) + return rows + + +def resolve_tool_by_id( + tool_id: Any, + user: Optional[str], + *, + user_tools_repo: Any = None, +) -> Optional[Dict[str, Any]]: + """Resolve a tool by id: default/builtin synthetic id, else user_tools row. + + Dual-registered tools (e.g. ``scheduler``) get both flags on the resolved + row so callers can branch on either path without losing the discriminator. + """ + default_name = default_tool_name_for_id(tool_id) + builtin_name = builtin_agent_tool_name_for_id(tool_id) + if default_name is not None and builtin_name is not None: + row = synthesize_default_tool(default_name) or {} + row["builtin"] = True + return row or None + if default_name is not None: + return synthesize_default_tool(default_name) + if builtin_name is not None: + return synthesize_builtin_agent_tool(builtin_name) + if user_tools_repo is None or not user: + return None + return user_tools_repo.get_any(str(tool_id), user) diff --git a/application/agents/headless_runner.py b/application/agents/headless_runner.py new file mode 100644 index 00000000..706df673 --- /dev/null +++ b/application/agents/headless_runner.py @@ -0,0 +1,173 @@ +"""Shared headless agent runner used by webhooks and scheduled runs.""" + +from __future__ import annotations + +import logging +from typing import Any, Dict, Iterable, List, Optional + +from application.agents.agent_creator import AgentCreator +from application.agents.tool_executor import ToolExecutor +from application.api.answer.services.stream_processor import get_prompt +from application.core.settings import settings +from application.retriever.retriever_creator import RetrieverCreator +from application.storage.db.repositories.sources import SourcesRepository +from application.storage.db.session import db_readonly + +logger = logging.getLogger(__name__) + + +def _resolve_owner(agent_config: Dict[str, Any]) -> Optional[str]: + return agent_config.get("user_id") or agent_config.get("user") + + +def _resolve_agent_id(agent_config: Dict[str, Any]) -> Optional[str]: + raw = agent_config.get("id") or agent_config.get("_id") + return str(raw) if raw else None + + +def run_agent_headless( + agent_config: Dict[str, Any], + query: str, + *, + tool_allowlist: Optional[Iterable[str]] = None, + model_id_override: Optional[str] = None, + endpoint: str = "headless", + chat_history: Optional[List[Dict[str, Any]]] = None, + conversation_id: Optional[str] = None, +) -> Dict[str, Any]: + """Run an agent with no live client; returns a structured outcome dict.""" + from application.core.model_utils import ( + get_api_key_for_provider, + get_default_model_id, + get_provider_from_model_id, + validate_model_id, + ) + from application.utils import calculate_doc_token_budget + + owner = _resolve_owner(agent_config) + if not owner: + raise ValueError("Agent config is missing user_id; cannot run headless.") + decoded_token = {"sub": owner} + + retriever_kind = agent_config.get("retriever", "classic") + source_id = agent_config.get("source_id") or agent_config.get("source") + source_active: Any = {} + if source_id: + with db_readonly() as conn: + src_row = SourcesRepository(conn).get(str(source_id), owner) + if src_row: + source_active = str(src_row["id"]) + retriever_kind = src_row.get("retriever", retriever_kind) + source = {"active_docs": source_active} + chunks = int(agent_config.get("chunks", 2) or 2) + prompt_id = agent_config.get("prompt_id", "default") + user_api_key = agent_config.get("key") + agent_id = _resolve_agent_id(agent_config) + agent_type = agent_config.get("agent_type", "classic") + json_schema = agent_config.get("json_schema") + prompt = get_prompt(prompt_id) + + candidate_model = model_id_override or agent_config.get("default_model_id") or "" + if candidate_model and validate_model_id(candidate_model, user_id=owner): + model_id = candidate_model + else: + model_id = get_default_model_id() + if candidate_model: + logger.warning( + "Agent %s references unknown model_id %r; falling back to %r", + agent_id, candidate_model, model_id, + ) + provider = ( + get_provider_from_model_id(model_id, user_id=owner) + if model_id + else settings.LLM_PROVIDER + ) + system_api_key = get_api_key_for_provider(provider or settings.LLM_PROVIDER) + doc_token_limit = calculate_doc_token_budget(model_id=model_id, user_id=owner) + + retriever = RetrieverCreator.create_retriever( + retriever_kind, + source=source, + chat_history=chat_history or [], + prompt=prompt, + chunks=chunks, + doc_token_limit=doc_token_limit, + model_id=model_id, + user_api_key=user_api_key, + agent_id=agent_id, + decoded_token=decoded_token, + ) + retrieved_docs: List[Dict[str, Any]] = [] + try: + docs = retriever.search(query) + if docs: + retrieved_docs = docs + except Exception as exc: + logger.warning("Headless retrieve failed: %s", exc) + + tool_executor = ToolExecutor( + user_api_key=user_api_key, + user=owner, + decoded_token=decoded_token, + agent_id=agent_id, + headless=True, + tool_allowlist=list(tool_allowlist or []), + ) + if conversation_id: + tool_executor.conversation_id = str(conversation_id) + + agent = AgentCreator.create_agent( + agent_type, + endpoint=endpoint, + llm_name=provider or settings.LLM_PROVIDER, + model_id=model_id, + api_key=system_api_key, + agent_id=agent_id, + user_api_key=user_api_key, + prompt=prompt, + chat_history=chat_history or [], + retrieved_docs=retrieved_docs, + decoded_token=decoded_token, + attachments=[], + json_schema=json_schema, + tool_executor=tool_executor, + ) + if conversation_id: + agent.conversation_id = str(conversation_id) + + answer_full = "" + thought = "" + sources_log: List[Dict[str, Any]] = [] + tool_calls: List[Dict[str, Any]] = [] + for event in agent.gen(query=query): + if not isinstance(event, dict): + continue + if "answer" in event: + answer_full += str(event["answer"]) + elif "sources" in event: + sources_log.extend(event["sources"]) + elif "tool_calls" in event: + tool_calls.extend(event["tool_calls"]) + elif "thought" in event: + thought += str(event["thought"]) + + denied = list(getattr(tool_executor, "headless_denials", [])) + error_type = "tool_not_allowed" if denied and not answer_full.strip() else None + + # Use the LLM accumulator (gen_token_usage / stream_token_usage decorators); + # current_token_count is a context-size sentinel, not a usage tally. + llm_usage = getattr(getattr(agent, "llm", None), "token_usage", None) or {} + prompt_tokens = int(llm_usage.get("prompt_tokens", 0) or 0) + generated_tokens = int(llm_usage.get("generated_tokens", 0) or 0) + + return { + "answer": answer_full, + "thought": thought, + "sources": sources_log, + "tool_calls": tool_calls, + "prompt_tokens": prompt_tokens, + "generated_tokens": generated_tokens, + "denied": denied, + "error_type": error_type, + "model_id": model_id, + } diff --git a/application/agents/scheduler_utils.py b/application/agents/scheduler_utils.py new file mode 100644 index 00000000..2553bc59 --- /dev/null +++ b/application/agents/scheduler_utils.py @@ -0,0 +1,131 @@ +"""Cron/tz computations for the scheduler (shared by dispatcher, routes, and tool).""" + +from __future__ import annotations + +import re +from datetime import datetime, timedelta, timezone +from typing import Optional +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + +from croniter import croniter + + +_DELAY_RE = re.compile(r"^\s*(\d+)\s*(s|m|h|d)\s*$", re.IGNORECASE) +_DELAY_MULTIPLIERS = {"s": 1, "m": 60, "h": 3600, "d": 86_400} + + +class ScheduleValidationError(ValueError): + """Raised when a schedule's cron, run_at, or delay is invalid.""" + + +def resolve_timezone(tz_name: Optional[str]) -> ZoneInfo: + """Return a ``ZoneInfo`` for ``tz_name`` (default UTC).""" + name = (tz_name or "UTC").strip() or "UTC" + try: + return ZoneInfo(name) + except ZoneInfoNotFoundError as exc: + raise ScheduleValidationError(f"Unknown timezone: {name}") from exc + + +def parse_cron(expression: str) -> None: + """Validate a 5-field cron expression; raise on bad input.""" + # croniter defers some malformed inputs until get_next, so force one here. + if not expression or not isinstance(expression, str): + raise ScheduleValidationError("Cron expression is required.") + fields = expression.strip().split() + if len(fields) != 5: + raise ScheduleValidationError("Cron expression must have 5 fields.") + try: + itr = croniter(expression, datetime.now(timezone.utc)) + itr.get_next(datetime) + except (ValueError, KeyError) as exc: + raise ScheduleValidationError(f"Invalid cron expression: {exc}") from exc + + +_CRON_INTERVAL_WINDOW = 64 + + +def cron_interval_seconds(expression: str, tz_name: Optional[str]) -> int: + """Return the smallest gap between ticks in a rolling window (enforces SCHEDULE_MIN_INTERVAL). + + Walks _CRON_INTERVAL_WINDOW ticks because bursty expressions like + ``* 9 * * *`` have tiny within-burst gaps and huge between-burst gaps; + sampling only two adjacent ticks would miss the small gap. + """ + parse_cron(expression) + tz = resolve_timezone(tz_name) + anchor_local = datetime.now(timezone.utc).astimezone(tz) + itr = croniter(expression, anchor_local) + prev = itr.get_next(datetime) + smallest: Optional[int] = None + for _ in range(_CRON_INTERVAL_WINDOW - 1): + nxt = itr.get_next(datetime) + gap = int((nxt - prev).total_seconds()) + if gap > 0 and (smallest is None or gap < smallest): + smallest = gap + prev = nxt + return smallest if smallest is not None else 0 + + +def next_cron_run( + expression: str, + tz_name: Optional[str], + after: Optional[datetime] = None, +) -> datetime: + """Return the next fire time strictly after ``after`` (UTC, tz-aware). + + Evaluates the cadence in the schedule's IANA tz so DST boundaries land on + the intended local clock-time (e.g. 9 AM Warsaw stays 9 AM across the jump). + """ + parse_cron(expression) + tz = resolve_timezone(tz_name) + anchor_utc = after if after is not None else datetime.now(timezone.utc) + if anchor_utc.tzinfo is None: + anchor_utc = anchor_utc.replace(tzinfo=timezone.utc) + anchor_local = anchor_utc.astimezone(tz) + itr = croniter(expression, anchor_local) + nxt_local = itr.get_next(datetime) + return nxt_local.astimezone(timezone.utc) + + +def parse_delay(delay: str) -> timedelta: + """Parse a duration like ``30m`` / ``2h`` / ``1d`` into a timedelta.""" + if not isinstance(delay, str): + raise ScheduleValidationError("delay must be a string like '30m' or '2h'.") + match = _DELAY_RE.match(delay) + if not match: + raise ScheduleValidationError( + "delay must look like '30s', '15m', '2h', or '1d'." + ) + amount, unit = int(match.group(1)), match.group(2).lower() + if amount <= 0: + raise ScheduleValidationError("delay must be positive.") + return timedelta(seconds=amount * _DELAY_MULTIPLIERS[unit]) + + +def parse_run_at(run_at: str, tz_name: Optional[str] = None) -> datetime: + """Parse an ISO 8601 timestamp; naive values resolve in ``tz_name``. + + Naive values inside the DST "fall back" hour resolve to the earlier instance + (zoneinfo default fold=0); pass an explicit offset to select the later one. + """ + if not isinstance(run_at, str) or not run_at.strip(): + raise ScheduleValidationError("run_at must be an ISO 8601 string.") + try: + parsed = datetime.fromisoformat(run_at.strip().replace("Z", "+00:00")) + except ValueError as exc: + raise ScheduleValidationError(f"Invalid run_at: {exc}") from exc + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=resolve_timezone(tz_name)) + return parsed.astimezone(timezone.utc) + + +def clamp_once_horizon(run_at: datetime, max_horizon_seconds: int) -> None: + """Raise when ``run_at`` is in the past or beyond the once-task horizon.""" + now = datetime.now(timezone.utc) + if run_at <= now: + raise ScheduleValidationError("run_at is in the past.") + if max_horizon_seconds > 0 and run_at - now > timedelta(seconds=max_horizon_seconds): + raise ScheduleValidationError( + "run_at is beyond the maximum allowed scheduling horizon." + ) diff --git a/application/agents/tool_executor.py b/application/agents/tool_executor.py index 20398d8c..a7cac646 100644 --- a/application/agents/tool_executor.py +++ b/application/agents/tool_executor.py @@ -3,6 +3,11 @@ import uuid from collections import Counter from typing import Any, Dict, List, Optional, Tuple +from application.agents.default_tools import ( + is_headless_excluded_tool, + resolve_tool_by_id, + synthesized_default_tools, +) from application.agents.tools.tool_action_parser import ToolActionParser from application.agents.tools.tool_manager import ToolManager from application.security.encryption import decrypt_credentials @@ -12,6 +17,7 @@ from application.storage.db.repositories.tool_call_attempts import ( ToolCallAttemptsRepository, ) from application.storage.db.repositories.user_tools import UserToolsRepository +from application.storage.db.repositories.users import UsersRepository from application.storage.db.session import db_readonly, db_session logger = logging.getLogger(__name__) @@ -113,10 +119,22 @@ class ToolExecutor: user_api_key: Optional[str] = None, user: Optional[str] = None, decoded_token: Optional[Dict] = None, + agent_id: Optional[str] = None, + *, + headless: bool = False, + tool_allowlist: Optional[List[str]] = None, ): self.user_api_key = user_api_key self.user = user self.decoded_token = decoded_token + self.agent_id = agent_id + # Headless mode (scheduled / webhook): no human to resolve a pause, + # so check_pause returns headless_denied sentinels instead. + self.headless = bool(headless) + # Tool-instance ids pre-authorized for headless approval-gated execution. + self.tool_allowlist: set = ( + {str(x) for x in tool_allowlist} if tool_allowlist else set() + ) self.tool_calls: List[Dict] = [] self._loaded_tools: Dict[str, object] = {} self.conversation_id: Optional[str] = None @@ -124,6 +142,8 @@ class ToolExecutor: self.client_tools: Optional[List[Dict]] = None self._name_to_tool: Dict[str, Tuple[str, str]] = {} self._tool_to_name: Dict[Tuple[str, str], str] = {} + # Filled by the LLMHandler.handle_tool_calls headless loop. + self.headless_denials: List[Dict] = [] def get_tools(self) -> Dict[str, Dict]: """Load tool configs from DB based on user context. @@ -140,29 +160,54 @@ class ToolExecutor: return tools def _get_tools_by_api_key(self, api_key: str) -> Dict[str, Dict]: + """Resolve an agent's toolset — exactly ``agents.tools``, no defaults.""" # Per-operation session: the answer pipeline spans a long-lived # generator; wrapping it in a single connection would pin a PG # conn for the whole stream. Open, fetch, close. with db_readonly() as conn: agent_data = AgentsRepository(conn).find_by_key(api_key) tool_ids = agent_data.get("tools", []) if agent_data else [] - if not tool_ids: - return {} tools_repo = UserToolsRepository(conn) + owner = ( + (agent_data.get("user_id") or agent_data.get("user")) + if agent_data + else None + ) tools: List[Dict] = [] - owner = (agent_data.get("user_id") or agent_data.get("user")) if agent_data else None for tid in tool_ids: - row = None - if owner: - row = tools_repo.get_any(str(tid), owner) - if row is not None: - tools.append(row) - return {str(tool["id"]): tool for tool in tools} if tools else {} + row = resolve_tool_by_id(tid, owner, user_tools_repo=tools_repo) + if row is None: + continue + # Headless runs (scheduled / webhook) drop chat-only tools + # like ``scheduler`` so a fire-time LLM can't chain schedules. + if self.headless and is_headless_excluded_tool(row.get("name")): + continue + tools.append(row) + return {str(tool["id"]): tool for tool in tools} def _get_user_tools(self, user: str = "local") -> Dict[str, Dict]: + """Resolve an agentless chat's toolset: explicit user tools plus defaults.""" with db_readonly() as conn: user_tools = UserToolsRepository(conn).list_active_for_user(user) - return {str(i): tool for i, tool in enumerate(user_tools)} + user_doc = ( + UsersRepository(conn).get(user) if self.agent_id is None else None + ) + # Headless agentless runs (e.g. scheduled fire) drop chat-only + # tools (``scheduler``) from explicit user_tools too. + filtered_user_tools = [ + t for t in user_tools + if not (self.headless and is_headless_excluded_tool(t.get("name"))) + ] + # Index keys (ints) and synthetic uuid5 keys can't collide. + tools: Dict[str, Dict] = { + str(i): tool for i, tool in enumerate(filtered_user_tools) + } + if self.agent_id is None: + for default_row in synthesized_default_tools( + user_doc, headless=self.headless, + ): + tools[str(default_row["id"])] = default_row + return tools def merge_client_tools( self, tools_dict: Dict, client_tools: List[Dict] @@ -300,9 +345,11 @@ class ToolExecutor: def check_pause( self, tools_dict: Dict, call, llm_class_name: str ) -> Optional[Dict]: - """Check if a tool call requires pausing for approval or client execution. + """Return a pending-action dict (approval / client / headless_denied) or None. - Returns a dict describing the pending action if pause is needed, None otherwise. + In headless mode the dict's pause_type is ``headless_denied`` so the + upstream loop synthesizes a tool result instead of pausing (nothing can + resume a scheduled / webhook run). """ parser = ToolActionParser(llm_class_name, name_mapping=self._name_to_tool) tool_id, action_name, call_args = parser.parse_args(call) @@ -313,9 +360,26 @@ class ToolExecutor: return None # Will be handled as error by execute() tool_data = tools_dict[tool_id] + arguments = call_args if isinstance(call_args, dict) else {} # Client-side tools if tool_data.get("client_side"): + if self.headless: + return { + "call_id": call_id, + "name": llm_name, + "tool_name": tool_data.get("name", "unknown"), + "tool_id": tool_id, + "action_name": action_name, + "llm_name": llm_name, + "arguments": arguments, + "pause_type": "headless_denied", + "deny_reason": ( + "Client-side tools cannot run in headless / scheduled runs." + ), + "error_type": "tool_not_allowed", + "thought_signature": getattr(call, "thought_signature", None), + } return { "call_id": call_id, "name": llm_name, @@ -323,7 +387,7 @@ class ToolExecutor: "tool_id": tool_id, "action_name": action_name, "llm_name": llm_name, - "arguments": call_args if isinstance(call_args, dict) else {}, + "arguments": arguments, "pause_type": "requires_client_execution", "thought_signature": getattr(call, "thought_signature", None), } @@ -340,6 +404,27 @@ class ToolExecutor: ) if action_data.get("require_approval"): + if self.headless: + tool_row_id = str(tool_data.get("id") or tool_id) + if tool_row_id in self.tool_allowlist: + # Pre-authorized for headless execution — fall through. + return None + return { + "call_id": call_id, + "name": llm_name, + "tool_name": tool_data.get("name", "unknown"), + "tool_id": tool_id, + "action_name": action_name, + "llm_name": llm_name, + "arguments": arguments, + "pause_type": "headless_denied", + "deny_reason": ( + "This tool requires approval and is not in the run's " + "tool_allowlist." + ), + "error_type": "tool_not_allowed", + "thought_signature": getattr(call, "thought_signature", None), + } return { "call_id": call_id, "name": llm_name, @@ -347,7 +432,7 @@ class ToolExecutor: "tool_id": tool_id, "action_name": action_name, "llm_name": llm_name, - "arguments": call_args if isinstance(call_args, dict) else {}, + "arguments": arguments, "pause_type": "awaiting_approval", "thought_signature": getattr(call, "thought_signature", None), } @@ -623,6 +708,13 @@ class ToolExecutor: tool_config["tool_id"] = str(row_id) if self.conversation_id: tool_config["conversation_id"] = self.conversation_id + if tool_data["name"] == "scheduler": + # Agent-bound: stamp schedules.agent_id. Agentless: the tool + # falls back to ``origin_conversation_id`` as the schedule's + # conversation home. + tool_config["agent_id"] = ( + str(self.agent_id) if self.agent_id else None + ) if tool_data["name"] == "mcp_tool": tool_config["query_mode"] = True diff --git a/application/agents/tools/scheduler.py b/application/agents/tools/scheduler.py new file mode 100644 index 00000000..328ce16d --- /dev/null +++ b/application/agents/tools/scheduler.py @@ -0,0 +1,342 @@ +"""Scheduler tool: one-time agent tasks in agent-bound or agentless chats.""" + +from __future__ import annotations + +import json +import logging +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional + +from application.agents.scheduler_utils import ( + ScheduleValidationError, + clamp_once_horizon, + parse_delay, + parse_run_at, +) +from application.core.settings import settings +from application.storage.db.base_repository import looks_like_uuid +from application.storage.db.repositories.schedules import SchedulesRepository +from application.storage.db.session import db_readonly, db_session + +from .base import Tool + + +logger = logging.getLogger(__name__) + + +class SchedulerTool(Tool): + """ + Scheduling + Schedules a one-time task for the agent to run at a chosen time or delay. + """ + + # internal=True keeps scheduler out of /api/available_tools and the + # agentless Add-Tool modal; tool_manager.load_tool still lazy-loads it + # per-user at execute time (same as memory/notes/todo_list). + internal: bool = True + + def __init__( + self, + tool_config: Optional[Dict[str, Any]] = None, + user_id: Optional[str] = None, + ) -> None: + cfg = tool_config or {} + self.user_id: Optional[str] = user_id + self.agent_id: Optional[str] = cfg.get("agent_id") + self.conversation_id: Optional[str] = cfg.get("conversation_id") + + def execute_action(self, action_name: str, **kwargs: Any) -> str: + """Dispatch on the LLM-supplied action name.""" + if not self.user_id: + return "Error: SchedulerTool requires a valid user_id." + # Agent-bound: agent_id must look like a UUID. Agentless: agent_id is + # absent; an originating conversation is then mandatory (the schedule's + # conversation home, used for history + output append). + if self.agent_id and not looks_like_uuid(str(self.agent_id)): + return "Error: SchedulerTool received an invalid agent_id." + if not self.agent_id and not self.conversation_id: + return ( + "Error: SchedulerTool requires an agent_id or a " + "conversation_id (no conversation home)." + ) + if action_name == "schedule_task": + return self._schedule_task( + instruction=kwargs.get("instruction", ""), + delay=kwargs.get("delay"), + run_at=kwargs.get("run_at"), + tz=kwargs.get("timezone"), + ) + if action_name == "list_scheduled_tasks": + return self._list_scheduled_tasks() + if action_name == "cancel_scheduled_task": + return self._cancel_scheduled_task(kwargs.get("task_id", "")) + return f"Unknown action: {action_name}" + + def get_actions_metadata(self) -> List[Dict[str, Any]]: + """Action schemas for the LLM tool catalogue.""" + return [ + { + "name": "schedule_task", + "description": ( + "Schedule a one-time task. Provide either a `delay` " + "(e.g. '30m', '2h', '1d') from now, or a `run_at` ISO-8601 " + "absolute time. Optionally pass an IANA `timezone` to resolve " + "naive run_at values. The instruction is the task that will " + "execute at fire time (including delivery, e.g. 'send to my " + "Telegram'). For recurring schedules in an agent chat, point " + "the user to the agent's Schedules tab." + ), + "parameters": { + "type": "object", + "properties": { + "instruction": { + "type": "string", + "description": "What the agent should do at fire time.", + }, + "delay": { + "type": "string", + "description": "Duration like '30m', '2h', '1d'.", + }, + "run_at": { + "type": "string", + "description": "Absolute ISO 8601 timestamp.", + }, + "timezone": { + "type": "string", + "description": ( + "IANA timezone (e.g. Europe/Warsaw) for naive run_at." + ), + }, + }, + "required": ["instruction"], + }, + }, + { + "name": "list_scheduled_tasks", + "description": ( + "List pending one-time tasks for the current chat. " + "Agent-bound chats scope to user+agent; agentless chats " + "scope to user+originating conversation." + ), + "parameters": {"type": "object", "properties": {}}, + }, + { + "name": "cancel_scheduled_task", + "description": "Cancel a pending one-time task by its task_id.", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "The schedule id returned by schedule_task.", + }, + }, + "required": ["task_id"], + }, + }, + ] + + def get_config_requirements(self) -> Dict[str, Any]: + return {} + + def _schedule_task( + self, + instruction: str, + delay: Optional[str], + run_at: Optional[str], + tz: Optional[str], + ) -> str: + if not instruction or not isinstance(instruction, str): + return "Error: instruction is required." + if not delay and not run_at: + return "Error: provide either `delay` or `run_at`." + if delay and run_at: + return "Error: provide only one of `delay` or `run_at`." + + try: + if delay: + fire = datetime.now(timezone.utc) + parse_delay(delay) + else: + fire = parse_run_at(run_at, tz) + clamp_once_horizon(fire, settings.SCHEDULE_ONCE_MAX_HORIZON) + except ScheduleValidationError as exc: + return f"Error: {exc}" + + with db_readonly() as conn: + count = SchedulesRepository(conn).count_active_for_user(self.user_id) + if ( + settings.SCHEDULE_MAX_PER_USER > 0 + and count >= settings.SCHEDULE_MAX_PER_USER + ): + return ( + "Error: you have reached the maximum number of active schedules." + ) + + # Chat-created tasks default to the user's non-approval tools (for the + # agent's toolset when agent-bound, or the user's defaults+user_tools + # when agentless). + allowlist = _safe_default_allowlist(self.agent_id, self.user_id) + + auto_name = _name_from_instruction(instruction) + try: + with db_session() as conn: + created = SchedulesRepository(conn).create( + user_id=self.user_id, + agent_id=self.agent_id, + trigger_type="once", + instruction=instruction.strip(), + name=auto_name, + run_at=fire, + next_run_at=fire, + timezone=tz or "UTC", + tool_allowlist=allowlist, + origin_conversation_id=self.conversation_id, + created_via="chat", + ) + except Exception as exc: + logger.exception("schedule_task create failed: %s", exc) + return "Error: failed to create scheduled task." + return json.dumps( + { + "task_id": str(created["id"]), + "resolved_run_at": _iso_utc(fire), + "timezone": tz or "UTC", + "instruction": instruction.strip(), + "name": auto_name, + } + ) + + def _list_scheduled_tasks(self) -> str: + """Pending one-time tasks for this user, oldest fire first. + + Agent-bound chats scope to user+agent. Agentless chats scope to user+ + origin_conversation_id so a user only sees tasks created from this chat. + """ + with db_readonly() as conn: + repo = SchedulesRepository(conn) + if self.agent_id: + rows = repo.list_for_agent( + self.agent_id, + self.user_id, + statuses=["active"], + trigger_type="once", + ) + else: + rows = repo.list_for_conversation( + self.user_id, + self.conversation_id, + statuses=["active"], + trigger_type="once", + ) + # Values arrive as ISO strings (coerce_pg_native); string sentinel keeps types uniform. + rows.sort(key=lambda r: r.get("next_run_at") or "9999-12-31T23:59:59Z") + items = [ + { + "task_id": str(r["id"]), + "instruction": r.get("instruction"), + "name": r.get("name"), + "resolved_run_at": _iso_utc(r.get("next_run_at")), + "timezone": r.get("timezone"), + "status": r.get("status"), + } + for r in rows + ] + return json.dumps({"tasks": items}) + + def _cancel_scheduled_task(self, task_id: str) -> str: + if not task_id or not looks_like_uuid(str(task_id)): + return "Error: task_id must be a valid id." + with db_session() as conn: + repo = SchedulesRepository(conn) + # Agentless: scope cancel to user + originating conversation so a + # user can only cancel tasks they created in the current chat. + if not self.agent_id: + row = repo.get(task_id, self.user_id) + if row is None or row.get("agent_id") is not None or ( + str(row.get("origin_conversation_id") or "") + != str(self.conversation_id or "") + ): + return ( + "Error: scheduled task not found or already terminal." + ) + ok = repo.cancel(task_id, self.user_id) + if not ok: + return "Error: scheduled task not found or already terminal." + return json.dumps({"task_id": str(task_id), "status": "cancelled"}) + + +def _name_from_instruction(instruction: str, *, max_len: int = 80) -> str: + """Compact display name derived from the instruction's first line.""" + first_line = instruction.strip().split("\n", 1)[0] + if len(first_line) <= max_len: + return first_line + return first_line[: max_len - 1] + "…" + + +def _iso_utc(value: Any) -> Optional[str]: + """Render a datetime (or ISO string) as RFC3339 UTC; ``None`` passes through.""" + if value is None: + return None + if isinstance(value, str): + try: + value = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return value + if value.tzinfo is None: + value = value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + + +def _safe_default_allowlist( + agent_id: Optional[str], user_id: str, +) -> List[str]: + """Return ids of available tools whose actions are all non-approval. + + Agent-bound: the agent's ``agents.tools`` entries. + Agentless: the user's active ``user_tools`` rows plus synthesized default + chat tools (resolved against ``settings.DEFAULT_CHAT_TOOLS`` and the + user's ``tool_preferences.disabled_default_tools`` opt-outs). + """ + from application.agents.default_tools import ( + resolve_tool_by_id, + synthesized_default_tools, + ) + from application.storage.db.repositories.agents import AgentsRepository + from application.storage.db.repositories.user_tools import UserToolsRepository + from application.storage.db.repositories.users import UsersRepository + + def _is_safe(row: Dict[str, Any]) -> bool: + actions = row.get("actions") or [] + return not any(a.get("require_approval") for a in actions) + + safe_ids: List[str] = [] + try: + with db_readonly() as conn: + tools_repo = UserToolsRepository(conn) + if agent_id: + agent = AgentsRepository(conn).get(agent_id, user_id) + tool_ids = (agent or {}).get("tools") or [] + for raw_id in tool_ids: + tool_id = str(raw_id) + row = resolve_tool_by_id( + tool_id, user_id, user_tools_repo=tools_repo, + ) + if not row or not _is_safe(row): + continue + safe_ids.append(tool_id) + else: + # Agentless: explicit user_tools (active=true) + synthesized + # defaults respecting the user's opt-out preferences. + user_doc = UsersRepository(conn).get(user_id) + for row in tools_repo.list_active_for_user(user_id): + if not _is_safe(row): + continue + safe_ids.append(str(row["id"])) + for default_row in synthesized_default_tools(user_doc): + if not _is_safe(default_row): + continue + safe_ids.append(str(default_row["id"])) + except Exception: # pragma: no cover — best-effort fallback + logger.exception("scheduler: default allowlist build failed") + return [] + return safe_ids diff --git a/application/agents/tools/tool_manager.py b/application/agents/tools/tool_manager.py index 41970eac..34924511 100644 --- a/application/agents/tools/tool_manager.py +++ b/application/agents/tools/tool_manager.py @@ -28,7 +28,10 @@ class ToolManager: module = importlib.import_module(f"application.agents.tools.{tool_name}") for member_name, obj in inspect.getmembers(module, inspect.isclass): if issubclass(obj, Tool) and obj is not Tool: - if tool_name in {"mcp_tool", "notes", "memory", "todo_list"} and user_id: + if ( + tool_name in {"mcp_tool", "notes", "memory", "todo_list", "scheduler"} + and user_id + ): return obj(tool_config, user_id) else: return obj(tool_config) @@ -36,7 +39,10 @@ class ToolManager: def execute_action(self, tool_name, action_name, user_id=None, **kwargs): if tool_name not in self.tools: raise ValueError(f"Tool '{tool_name}' not loaded") - if tool_name in {"mcp_tool", "memory", "todo_list", "notes"} and user_id: + if ( + tool_name in {"mcp_tool", "memory", "todo_list", "notes", "scheduler"} + and user_id + ): tool_config = self.config.get(tool_name, {}) tool = self.load_tool(tool_name, tool_config, user_id) return tool.execute_action(action_name, **kwargs) diff --git a/application/alembic/versions/0009_tool_preferences.py b/application/alembic/versions/0009_tool_preferences.py new file mode 100644 index 00000000..c6388f28 --- /dev/null +++ b/application/alembic/versions/0009_tool_preferences.py @@ -0,0 +1,83 @@ +"""0009 default chat tools — users.tool_preferences + memories.tool_id. + +Adds ``users.tool_preferences`` JSONB and drops the +``memories.tool_id`` FK to ``user_tools`` (synthetic default-tool ids +have no ``user_tools`` row). Delete-cascade for real tools is kept via +an AFTER DELETE trigger on ``user_tools``. Idempotent both ways. + +Revision ID: 0009_tool_preferences +Revises: 0008_ingest_progress_status +""" + +from typing import Sequence, Union + +from alembic import op + + +revision: str = "0009_tool_preferences" +down_revision: Union[str, None] = "0008_ingest_progress_status" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute( + """ + ALTER TABLE users + ADD COLUMN IF NOT EXISTS tool_preferences JSONB + NOT NULL DEFAULT '{}'::jsonb; + """ + ) + + op.execute( + "ALTER TABLE memories DROP CONSTRAINT IF EXISTS memories_tool_id_fkey;" + ) + + op.execute( + """ + CREATE OR REPLACE FUNCTION cleanup_tool_memories() RETURNS trigger + LANGUAGE plpgsql AS $$ + BEGIN + DELETE FROM memories WHERE tool_id = OLD.id; + RETURN OLD; + END; + $$; + """ + ) + # DROP-then-CREATE — no CREATE OR REPLACE TRIGGER for this signature. + op.execute( + "DROP TRIGGER IF EXISTS user_tools_cleanup_memories ON user_tools;" + ) + op.execute( + "CREATE TRIGGER user_tools_cleanup_memories " + "AFTER DELETE ON user_tools " + "FOR EACH ROW EXECUTE FUNCTION cleanup_tool_memories();" + ) + + +def downgrade() -> None: + op.execute( + "DROP TRIGGER IF EXISTS user_tools_cleanup_memories ON user_tools;" + ) + op.execute("DROP FUNCTION IF EXISTS cleanup_tool_memories();") + # DESTRUCTIVE: restoring the FK requires every memories.tool_id to + # reference a real user_tools row. Any memory written by a built-in + # default tool (synthetic uuid5 id, no user_tools row) is permanently + # DELETED here so the constraint can be re-created. Downgrading 0009 + # therefore loses all built-in-memory-tool data — by necessity, since + # the restored schema cannot represent it. + op.execute( + """ + DELETE FROM memories + WHERE tool_id IS NOT NULL + AND tool_id NOT IN (SELECT id FROM user_tools); + """ + ) + op.execute( + """ + ALTER TABLE memories + ADD CONSTRAINT memories_tool_id_fkey + FOREIGN KEY (tool_id) REFERENCES user_tools(id) ON DELETE CASCADE; + """ + ) + op.execute("ALTER TABLE users DROP COLUMN IF EXISTS tool_preferences;") diff --git a/application/alembic/versions/0010_schedules.py b/application/alembic/versions/0010_schedules.py new file mode 100644 index 00000000..fae829c9 --- /dev/null +++ b/application/alembic/versions/0010_schedules.py @@ -0,0 +1,147 @@ +"""0010 scheduler — schedules + schedule_runs tables. + +Revision ID: 0010_schedules +Revises: 0009_tool_preferences +""" + +from typing import Sequence, Union + +from alembic import op + + +revision: str = "0010_schedules" +down_revision: Union[str, None] = "0009_tool_preferences" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute( + """ + CREATE TABLE schedules ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id TEXT NOT NULL, + agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE, + trigger_type TEXT NOT NULL, + name TEXT, + instruction TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + cron TEXT, + run_at TIMESTAMPTZ, + timezone TEXT NOT NULL DEFAULT 'UTC', + next_run_at TIMESTAMPTZ, + last_run_at TIMESTAMPTZ, + end_at TIMESTAMPTZ, + tool_allowlist JSONB NOT NULL DEFAULT '[]'::jsonb, + model_id TEXT, + token_budget INTEGER, + origin_conversation_id UUID REFERENCES conversations(id) ON DELETE SET NULL, + created_via TEXT NOT NULL DEFAULT 'ui', + consecutive_failure_count INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT schedules_trigger_type_chk + CHECK (trigger_type IN ('once', 'recurring')), + CONSTRAINT schedules_status_chk + CHECK (status IN ('active', 'paused', 'completed', 'cancelled')), + CONSTRAINT schedules_created_via_chk + CHECK (created_via IN ('chat', 'ui')), + CONSTRAINT schedules_recurring_cron_chk + CHECK (trigger_type <> 'recurring' OR cron IS NOT NULL), + CONSTRAINT schedules_once_run_at_chk + CHECK (trigger_type <> 'once' OR run_at IS NOT NULL) + ); + """ + ) + + op.execute( + "CREATE INDEX schedules_user_idx ON schedules (user_id);" + ) + op.execute( + "CREATE INDEX schedules_agent_idx ON schedules (agent_id);" + ) + # Dispatcher hot path: status='active' AND next_run_at <= now(). + op.execute( + "CREATE INDEX schedules_due_idx " + "ON schedules (status, next_run_at) " + "WHERE status = 'active';" + ) + + op.execute( + "CREATE TRIGGER schedules_set_updated_at " + "BEFORE UPDATE ON schedules " + "FOR EACH ROW EXECUTE FUNCTION set_updated_at();" + ) + + op.execute( + """ + CREATE TABLE schedule_runs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + schedule_id UUID NOT NULL REFERENCES schedules(id) ON DELETE CASCADE, + user_id TEXT NOT NULL, + agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE, + status TEXT NOT NULL DEFAULT 'pending', + scheduled_for TIMESTAMPTZ NOT NULL, + trigger_source TEXT NOT NULL DEFAULT 'cron', + started_at TIMESTAMPTZ, + finished_at TIMESTAMPTZ, + output TEXT, + output_truncated BOOLEAN NOT NULL DEFAULT false, + error TEXT, + error_type TEXT, + prompt_tokens INTEGER NOT NULL DEFAULT 0, + generated_tokens INTEGER NOT NULL DEFAULT 0, + conversation_id UUID, + message_id UUID, + celery_task_id TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT schedule_runs_status_chk + CHECK (status IN ( + 'pending', 'running', 'success', 'failed', 'skipped', 'timeout' + )), + CONSTRAINT schedule_runs_trigger_source_chk + CHECK (trigger_source IN ('cron', 'manual')), + CONSTRAINT schedule_runs_error_type_chk + CHECK (error_type IS NULL OR error_type IN ( + 'auth_expired', 'tool_not_allowed', 'budget_exceeded', + 'timeout', 'agent_error', 'internal', 'missed', 'overlap' + )) + ); + """ + ) + + # Dedup primitive: racing dispatchers hit ON CONFLICT on this index. + op.execute( + "CREATE UNIQUE INDEX schedule_runs_dedup_uidx " + "ON schedule_runs (schedule_id, scheduled_for);" + ) + op.execute( + "CREATE INDEX schedule_runs_schedule_recent_idx " + "ON schedule_runs (schedule_id, scheduled_for DESC);" + ) + op.execute( + "CREATE INDEX schedule_runs_user_idx ON schedule_runs (user_id);" + ) + op.execute( + "CREATE INDEX schedule_runs_running_idx " + "ON schedule_runs (status, started_at) " + "WHERE status = 'running';" + ) + op.execute( + "CREATE TRIGGER schedule_runs_set_updated_at " + "BEFORE UPDATE ON schedule_runs " + "FOR EACH ROW EXECUTE FUNCTION set_updated_at();" + ) + + +def downgrade() -> None: + # Drop triggers explicitly (grep-able) before CASCADE-dropping the tables. + op.execute( + "DROP TRIGGER IF EXISTS schedule_runs_set_updated_at ON schedule_runs;" + ) + op.execute("DROP TABLE IF EXISTS schedule_runs CASCADE;") + op.execute( + "DROP TRIGGER IF EXISTS schedules_set_updated_at ON schedules;" + ) + op.execute("DROP TABLE IF EXISTS schedules CASCADE;") diff --git a/application/alembic/versions/0011_schedules_nullable_agent.py b/application/alembic/versions/0011_schedules_nullable_agent.py new file mode 100644 index 00000000..ebe0ba0f --- /dev/null +++ b/application/alembic/versions/0011_schedules_nullable_agent.py @@ -0,0 +1,53 @@ +"""0011 scheduler — make schedules.agent_id / schedule_runs.agent_id nullable. + +Agentless schedules (created from agentless chats via the dual-registered +``scheduler`` default chat tool) carry ``agent_id IS NULL``. Existing FK + +``ON DELETE CASCADE`` semantics on ``agents(id)`` are unaffected — Postgres +only cascades when the parent row is deleted, NULL rows aren't matched. + +Revision ID: 0011_schedules_nullable_agent +Revises: 0010_schedules +""" + +from typing import Sequence, Union + +from alembic import op + + +revision: str = "0011_schedules_nullable_agent" +down_revision: Union[str, None] = "0010_schedules" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute("ALTER TABLE schedules ALTER COLUMN agent_id DROP NOT NULL;") + op.execute("ALTER TABLE schedule_runs ALTER COLUMN agent_id DROP NOT NULL;") + + +def downgrade() -> None: + # Destructive otherwise: agentless rows have agent_id IS NULL by design, + # so restoring NOT NULL must fail loudly if any exist. + op.execute( + """ + DO $$ + DECLARE + sched_nulls INTEGER; + run_nulls INTEGER; + BEGIN + SELECT count(*) INTO sched_nulls + FROM schedules WHERE agent_id IS NULL; + SELECT count(*) INTO run_nulls + FROM schedule_runs WHERE agent_id IS NULL; + IF sched_nulls > 0 OR run_nulls > 0 THEN + RAISE EXCEPTION + 'Cannot downgrade 0011: agentless rows present ' + '(schedules=%, schedule_runs=%). ' + 'Delete or reassign them before retrying.', + sched_nulls, run_nulls; + END IF; + END$$; + """ + ) + op.execute("ALTER TABLE schedule_runs ALTER COLUMN agent_id SET NOT NULL;") + op.execute("ALTER TABLE schedules ALTER COLUMN agent_id SET NOT NULL;") diff --git a/application/api/answer/routes/base.py b/application/api/answer/routes/base.py index dfe885ff..c3aaefd7 100644 --- a/application/api/answer/routes/base.py +++ b/application/api/answer/routes/base.py @@ -325,6 +325,17 @@ class BaseAnswerResource: "Could not set tool_executor.message_id; tool-call correlation will be missing for message_id=%s", reserved_message_id, ) + # The reservation above may create the conversation row (first turn in + # a new chat). Propagate that fresh id to the tool_executor so tools + # that need a conversation home (e.g. ``scheduler`` in agentless chats) + # see it on the very first call instead of waiting for the next turn. + if conversation_id and getattr(agent, "tool_executor", None): + try: + agent.tool_executor.conversation_id = str(conversation_id) + except Exception: + logger.debug( + "Could not set tool_executor.conversation_id post-reserve", + ) # Per-stream monotonic SSE event id. Allocated by ``_emit`` and # threaded through both the wire format (``id: \\n``) and diff --git a/application/api/answer/services/stream_processor.py b/application/api/answer/services/stream_processor.py index 0caa1748..e6bb7549 100644 --- a/application/api/answer/services/stream_processor.py +++ b/application/api/answer/services/stream_processor.py @@ -6,6 +6,7 @@ from pathlib import Path from typing import Any, Dict, Optional, Set from application.agents.agent_creator import AgentCreator +from application.agents.default_tools import synthesized_default_tools from application.api.answer.services.compression import CompressionOrchestrator from application.api.answer.services.compression.token_counter import TokenCounter from application.api.answer.services.conversation_service import ConversationService @@ -25,6 +26,7 @@ from application.storage.db.repositories.attachments import AttachmentsRepositor from application.storage.db.repositories.prompts import PromptsRepository from application.storage.db.repositories.sources import SourcesRepository from application.storage.db.repositories.user_tools import UserToolsRepository +from application.storage.db.repositories.users import UsersRepository from application.storage.db.session import db_readonly, db_session from application.retriever.retriever_creator import RetrieverCreator from application.utils import ( @@ -293,7 +295,7 @@ class StreamProcessor: return attachments def _validate_and_set_model(self): - """Validate and set model_id from request""" + """Pick model_id with agent authority on agent-bound chats.""" from application.core.model_settings import ModelRegistry requested_model = self.data.get("model_id") @@ -302,6 +304,20 @@ class StreamProcessor: caller_user_id = self.initial_user_id owner_user_id = self.agent_config.get("user_id") or caller_user_id + # Agent-bound: agent's default_model_id wins, body's model_id is dropped. + agent_bound = self._agent_data is not None + if agent_bound: + agent_default_model = self.agent_config.get("default_model_id", "") + if agent_default_model and validate_model_id( + agent_default_model, user_id=owner_user_id + ): + self.model_id = agent_default_model + self.model_user_id = owner_user_id + else: + self.model_id = get_default_model_id() + self.model_user_id = None + return + if requested_model: if not validate_model_id(requested_model, user_id=caller_user_id): registry = ModelRegistry.get_instance() @@ -321,15 +337,8 @@ class StreamProcessor: self.model_id = requested_model self.model_user_id = caller_user_id else: - agent_default_model = self.agent_config.get("default_model_id", "") - if agent_default_model and validate_model_id( - agent_default_model, user_id=owner_user_id - ): - self.model_id = agent_default_model - self.model_user_id = owner_user_id - else: - self.model_id = get_default_model_id() - self.model_user_id = None + self.model_id = get_default_model_id() + self.model_user_id = None def _get_agent_key(self, agent_id: Optional[str], user_id: Optional[str]) -> tuple: """Get API key for agent with access control.""" @@ -385,6 +394,7 @@ class StreamProcessor: raise def _get_data_from_api_key(self, api_key: str) -> Dict[str, Any]: + """Resolve agent metadata + the unioned source set for the given key.""" with db_readonly() as conn: agent = AgentsRepository(conn).find_by_key(api_key) if not agent: @@ -395,36 +405,66 @@ class StreamProcessor: data: Dict[str, Any] = dict(agent) data["user"] = agent.get("user_id") - # Resolve the primary source row (if any) for retriever/chunks. - source_id = agent.get("source_id") - if source_id: - source_doc = sources_repo.get(str(source_id), agent.get("user_id")) + # Active sources = primary ∪ extras, primary first, deduplicated. + # ``_configure_source`` ignores an empty ``data["sources"]``, + # so the primary must appear in the union too — not only in + # the legacy ``data["source"]`` slot. + sources_list: list = [] + seen: set = set() + owner = agent.get("user_id") + primary_id = agent.get("source_id") + # ``sources`` row may have NULL ``retriever``/``chunks`` — + # fall back to the agent's value (``dict.get`` returns None + # even when the key exists with value None). + if primary_id: + source_doc = sources_repo.get(str(primary_id), owner) if source_doc: - data["source"] = str(source_doc["id"]) - data["retriever"] = source_doc.get( - "retriever", data.get("retriever") + sid = str(source_doc["id"]) + data["source"] = sid + src_retriever = source_doc.get("retriever") + if src_retriever: + data["retriever"] = src_retriever + src_chunks = source_doc.get("chunks") + if src_chunks is not None: + data["chunks"] = src_chunks + sources_list.append( + { + "id": sid, + "retriever": src_retriever or "classic", + "chunks": ( + src_chunks if src_chunks is not None + else data.get("chunks", "2") + ), + } ) - data["chunks"] = source_doc.get("chunks", data.get("chunks")) + seen.add(sid) else: data["source"] = None else: data["source"] = None - sources_list = [] - extra = agent.get("extra_source_ids") or [] - if extra: - for sid in extra: - source_doc = sources_repo.get(str(sid), agent.get("user_id")) - if source_doc: - sources_list.append( - { - "id": str(source_doc["id"]), - "retriever": source_doc.get("retriever", "classic"), - "chunks": source_doc.get( - "chunks", data.get("chunks", "2") - ), - } - ) + for sid_raw in agent.get("extra_source_ids") or []: + if not sid_raw: + continue + source_doc = sources_repo.get(str(sid_raw), owner) + if not source_doc: + continue + sid = str(source_doc["id"]) + if sid in seen: + continue + src_retriever = source_doc.get("retriever") + src_chunks = source_doc.get("chunks") + sources_list.append( + { + "id": sid, + "retriever": src_retriever or "classic", + "chunks": ( + src_chunks if src_chunks is not None + else data.get("chunks", "2") + ), + } + ) + seen.add(sid) data["sources"] = sources_list data["default_model_id"] = data.get("default_model_id", "") return data @@ -589,7 +629,7 @@ class StreamProcessor: ) def _configure_retriever(self): - """Assemble retriever config with precedence: request > agent > default.""" + """Assemble retriever config; agent's values are authoritative when bound.""" # BYOM scope: owner for shared-agent BYOM, caller for own BYOM, # None for built-ins. Without ``user_id`` here, the doc budget # falls back to settings.DEFAULT_LLM_TOKEN_LIMIT and overfills @@ -598,12 +638,11 @@ class StreamProcessor: model_id=self.model_id, user_id=self.model_user_id ) - # Start with defaults retriever_name = "classic" chunks = 2 - # Layer agent-level config (if present) - if self._agent_data: + if self._agent_data is not None: + # Agent-bound: agent wins, body's retriever/chunks are dropped. if self._agent_data.get("retriever"): retriever_name = self._agent_data["retriever"] if self._agent_data.get("chunks") is not None: @@ -614,18 +653,17 @@ class StreamProcessor: f"Invalid agent chunks value: {self._agent_data['chunks']}, " "using default value 2" ) - - # Explicit request values win over agent config - if "retriever" in self.data: - retriever_name = self.data["retriever"] - if "chunks" in self.data: - try: - chunks = int(self.data["chunks"]) - except (ValueError, TypeError): - logger.warning( - f"Invalid request chunks value: {self.data['chunks']}, " - "using default value 2" - ) + else: + if "retriever" in self.data: + retriever_name = self.data["retriever"] + if "chunks" in self.data: + try: + chunks = int(self.data["chunks"]) + except (ValueError, TypeError): + logger.warning( + f"Invalid request chunks value: {self.data['chunks']}, " + "using default value 2" + ) self.retriever_config = { "retriever_name": retriever_name, @@ -633,7 +671,7 @@ class StreamProcessor: "doc_token_limit": doc_token_limit, } - # isNoneDoc without an API key forces no retrieval + # isNoneDoc without an API key forces no retrieval (agentless only) api_key = self.data.get("api_key") or self.agent_key if not api_key and "isNoneDoc" in self.data and self.data["isNoneDoc"]: self.retriever_config["chunks"] = 0 @@ -708,17 +746,26 @@ class StreamProcessor: try: user_id = self.initial_user_id or "local" + agentless = self.agent_id is None with db_readonly() as conn: user_tools = UserToolsRepository(conn).list_active_for_user(user_id) + user_doc = ( + UsersRepository(conn).get(user_id) if agentless else None + ) - if not user_tools: + default_docs = ( + synthesized_default_tools(user_doc) if agentless else [] + ) + tool_docs = list(user_tools) + default_docs + if not tool_docs: return None tools_data = {} - for tool_doc in user_tools: + for tool_doc in tool_docs: tool_name = tool_doc.get("name") - tool_id = str(tool_doc.get("_id")) + tool_id = str(tool_doc.get("_id") or tool_doc.get("id")) + is_default = bool(tool_doc.get("default")) if filtering_enabled: required_actions_by_name = required_tool_actions.get( @@ -731,11 +778,18 @@ class StreamProcessor: if not required_actions: continue else: + # No template names a default tool, so running its + # actions blind would only inject noise. + if is_default: + continue required_actions = None tool_data = self._fetch_tool_data(tool_doc, required_actions) if tool_data: - tools_data[tool_name] = tool_data + # Defaults reachable by synthetic id only — the name + # key stays bound to an explicit row of the same name. + if not is_default: + tools_data[tool_name] = tool_data tools_data[tool_id] = tool_data return tools_data if tools_data else None @@ -982,6 +1036,7 @@ class StreamProcessor: user_api_key=user_api_key, user=self.initial_user_id, decoded_token=self.decoded_token, + agent_id=agent_id, ) tool_executor.conversation_id = conversation_id # Restore client tools so they stay available for subsequent LLM calls @@ -1130,6 +1185,7 @@ class StreamProcessor: user_api_key=self.agent_config["user_api_key"], user=user, decoded_token=self.decoded_token, + agent_id=self.agent_id, ) tool_executor.conversation_id = self.conversation_id # Pass client-side tools so they get merged in get_tools() @@ -1137,7 +1193,6 @@ class StreamProcessor: if client_tools: tool_executor.client_tools = client_tools - # Base agent kwargs agent_kwargs = { "endpoint": "stream", "llm_name": provider or settings.LLM_PROVIDER, diff --git a/application/api/user/base.py b/application/api/user/base.py index f86c5c69..c571ee2f 100644 --- a/application/api/user/base.py +++ b/application/api/user/base.py @@ -83,13 +83,15 @@ def resolve_tool_details(tool_ids): """ Resolve tool IDs to their display details. - Accepts either Postgres UUIDs or legacy Mongo ObjectId strings (mixed - lists are supported — each id is looked up via ``get_any``, which - resolves to whichever column matches). Unknown ids are silently + Accepts Postgres UUIDs, legacy Mongo ObjectId strings, or the + synthetic ids of default chat tools / agent-selectable builtins + (mixed lists are supported). Synthetic ids are resolved in memory; + real ids are looked up via ``get_any``. Unknown ids are silently skipped. Args: - tool_ids: List of tool IDs (UUIDs or legacy Mongo ObjectId strings). + tool_ids: List of tool IDs (UUIDs, legacy ObjectId strings, or + synthetic default-tool / builtin ids). Returns: List of tool details with ``id``, ``name``, and ``display_name``. @@ -97,19 +99,37 @@ def resolve_tool_details(tool_ids): if not tool_ids: return [] + from application.agents.default_tools import ( + is_synthesized_tool_id, + synthesize_tool_by_name, + synthesized_tool_name_for_id, + ) + uuid_ids: list[str] = [] legacy_ids: list[str] = [] + default_details: list[dict] = [] for tid in tool_ids: if not tid: continue tid_str = str(tid) + if is_synthesized_tool_id(tid_str): + synth = synthesize_tool_by_name(synthesized_tool_name_for_id(tid_str)) + if synth is not None: + default_details.append( + { + "id": tid_str, + "name": synth.get("name", ""), + "display_name": synth.get("display_name", ""), + } + ) + continue if looks_like_uuid(tid_str): uuid_ids.append(tid_str) else: legacy_ids.append(tid_str) if not uuid_ids and not legacy_ids: - return [] + return default_details rows: list[dict] = [] with db_readonly() as conn: @@ -132,7 +152,7 @@ def resolve_tool_details(tool_ids): ) rows.extend(row_to_dict(r) for r in result.fetchall()) - return [ + return default_details + [ { "id": str(tool.get("id") or tool.get("legacy_mongo_id") or ""), "name": tool.get("name", "") or "", diff --git a/application/api/user/reconciliation.py b/application/api/user/reconciliation.py index 8e885384..72c4a5be 100644 --- a/application/api/user/reconciliation.py +++ b/application/api/user/reconciliation.py @@ -4,7 +4,8 @@ from __future__ import annotations import logging import uuid -from typing import Any, Dict, Optional +from datetime import datetime, timezone +from typing import Any, Dict, Optional, TYPE_CHECKING from sqlalchemy import Connection @@ -16,6 +17,9 @@ from application.storage.db.repositories.reconciliation import ( ) from application.storage.db.repositories.stack_logs import StackLogsRepository +if TYPE_CHECKING: + from application.storage.db.repositories.schedules import SchedulesRepository + logger = logging.getLogger(__name__) @@ -46,6 +50,7 @@ def run_reconciliation() -> Dict[str, Any]: "tool_calls_failed": 0, "ingests_stalled": 0, "idempotency_pending_failed": 0, + "schedule_runs_failed": 0, } with engine.begin() as conn: @@ -169,9 +174,101 @@ def run_reconciliation() -> Dict[str, Any]: }, ) + # Q6: scheduler runs stuck in 'running' past the soft-time-limit window. + from application.storage.db.repositories.schedule_runs import ( + ScheduleRunsRepository, + ) + from application.storage.db.repositories.schedules import SchedulesRepository + from application.core.settings import settings as _settings + + stuck_age = max( + 15, int(_settings.SCHEDULE_RUN_TIMEOUT // 60) + 5, + ) + with engine.begin() as conn: + runs_repo = ScheduleRunsRepository(conn) + schedules_repo = SchedulesRepository(conn) + for run in runs_repo.list_stuck_running(age_minutes=stuck_age): + runs_repo.update( + run["id"], + { + "status": "timeout", + "finished_at": datetime.now(timezone.utc), + "error_type": "timeout", + "error": ( + "reconciler: schedule_run stuck in 'running' past " + f"{stuck_age} min" + ), + }, + ) + schedules_repo.bump_failure_count(str(run["schedule_id"])) + _terminal_flip_once_schedule( + schedules_repo, str(run["schedule_id"]), + ) + summary["schedule_runs_failed"] += 1 + _emit_alert( + conn, + name="reconciler_schedule_run_timeout", + user_id=run.get("user_id"), + detail={ + "run_id": str(run["id"]), + "schedule_id": str(run["schedule_id"]), + }, + ) + + # Q7: scheduler runs orphaned in 'pending' — dispatcher committed but + # apply_async failed (broker outage / crash mid-dispatch). + with engine.begin() as conn: + runs_repo = ScheduleRunsRepository(conn) + schedules_repo = SchedulesRepository(conn) + for run in runs_repo.list_stuck_pending(age_minutes=stuck_age): + runs_repo.update( + run["id"], + { + "status": "failed", + "finished_at": datetime.now(timezone.utc), + "error_type": "internal", + "error": ( + "reconciler: schedule_run stuck in 'pending' past " + f"{stuck_age} min (worker_never_started)" + ), + }, + ) + schedules_repo.bump_failure_count(str(run["schedule_id"])) + _terminal_flip_once_schedule( + schedules_repo, str(run["schedule_id"]), + ) + summary["schedule_runs_failed"] += 1 + _emit_alert( + conn, + name="reconciler_schedule_run_pending", + user_id=run.get("user_id"), + detail={ + "run_id": str(run["id"]), + "schedule_id": str(run["schedule_id"]), + }, + ) + return summary +def _terminal_flip_once_schedule( + schedules_repo: "SchedulesRepository", schedule_id: str, +) -> None: + """Flip a once-schedule to 'completed' after its run terminates. + + Recurring schedules keep firing; once-schedules would otherwise read + 'active forever' since next_run_at is already NULL. + """ + schedule = schedules_repo.get_internal(schedule_id) + if schedule is None or schedule.get("trigger_type") != "once": + return + if schedule.get("status") in {"completed", "cancelled"}: + return + schedules_repo.update_internal( + schedule_id, {"status": "completed", "next_run_at": None}, + ) + + def _emit_alert( conn: Connection, *, diff --git a/application/api/user/routes.py b/application/api/user/routes.py index 0507f14d..675f0d01 100644 --- a/application/api/user/routes.py +++ b/application/api/user/routes.py @@ -11,6 +11,7 @@ from .attachments import attachments_ns from .conversations import conversations_ns from .models import models_ns from .prompts import prompts_ns +from .schedules import schedules_ns from .sharing import sharing_ns from .sources import sources_chunks_ns, sources_ns, sources_upload_ns from .tools import tools_mcp_ns, tools_ns @@ -40,6 +41,9 @@ api.add_namespace(agents_folders_ns) # Prompts api.add_namespace(prompts_ns) +# Schedules +api.add_namespace(schedules_ns) + # Sharing api.add_namespace(sharing_ns) diff --git a/application/api/user/scheduler_dispatcher.py b/application/api/user/scheduler_dispatcher.py new file mode 100644 index 00000000..3632ea97 --- /dev/null +++ b/application/api/user/scheduler_dispatcher.py @@ -0,0 +1,186 @@ +"""Schedule dispatcher: poll Postgres, claim due rows under FOR UPDATE SKIP LOCKED, +advance next_run_at atomically with the run claim, then enqueue. + +Per-schedule IANA tz semantics (croniter+zoneinfo) outside Celery's app-wide tz, +plus Postgres-native dedup avoid Redis visibility_timeout double-fires. +""" + +from __future__ import annotations + +import logging +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, List, Optional + +from application.agents.scheduler_utils import next_cron_run +from application.core.settings import settings +from application.storage.db.engine import get_engine +from application.storage.db.repositories.schedule_runs import ( + ScheduleRunsRepository, +) +from application.storage.db.repositories.schedules import SchedulesRepository + +logger = logging.getLogger(__name__) + + +def _normalize_dt(value: Any) -> Optional[datetime]: + """Accept a datetime / ISO string / None and return a tz-aware UTC dt.""" + if value is None: + return None + if isinstance(value, datetime): + return value.astimezone(timezone.utc) if value.tzinfo else ( + value.replace(tzinfo=timezone.utc) + ) + if isinstance(value, str): + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + return parsed.astimezone(timezone.utc) if parsed.tzinfo else ( + parsed.replace(tzinfo=timezone.utc) + ) + return None + + +def _compute_next( + schedule: Dict[str, Any], + *, + after: datetime, +) -> Optional[datetime]: + """Next next_run_at for a recurring schedule, or None when past end_at.""" + cron = schedule.get("cron") + if not cron: + return None + end_at = _normalize_dt(schedule.get("end_at")) + candidate = next_cron_run(cron, schedule.get("timezone"), after=after) + if end_at is not None and candidate > end_at: + return None + return candidate + + +def dispatch_due_runs() -> Dict[str, int]: + """One dispatcher tick; returns counts for schedule_syncs-style logging.""" + if not settings.POSTGRES_URI: + return {"enqueued": 0, "skipped": 0, "advanced": 0} + + from application.api.user.tasks import execute_scheduled_run + + now = datetime.now(timezone.utc) + grace = timedelta(seconds=max(0, settings.SCHEDULE_MISFIRE_GRACE)) + engine = get_engine() + counts = {"enqueued": 0, "skipped": 0, "advanced": 0} + enqueue_args: List[str] = [] + + with engine.begin() as conn: + schedules_repo = SchedulesRepository(conn) + runs_repo = ScheduleRunsRepository(conn) + for schedule in schedules_repo.list_due(): + scheduled_for = _normalize_dt(schedule.get("next_run_at")) + if scheduled_for is None: + continue + + trigger_type = schedule.get("trigger_type") + agent_id_raw = schedule.get("agent_id") + agent_id = str(agent_id_raw) if agent_id_raw else None + + # Misfire grace applies to recurring only — once-tasks fire late, not vanish. + if ( + trigger_type == "recurring" + and grace > timedelta(0) + and (now - scheduled_for) > grace + ): + runs_repo.record_skipped( + str(schedule["id"]), + schedule["user_id"], + agent_id, + scheduled_for, + error_type="missed", + error="misfire grace exceeded", + ) + counts["skipped"] += 1 + nxt = _compute_next(schedule, after=now) + if nxt is None: + schedules_repo.update_internal( + str(schedule["id"]), + {"status": "completed", "next_run_at": None, + "last_run_at": now}, + ) + else: + schedules_repo.update_internal( + str(schedule["id"]), + {"next_run_at": nxt, "last_run_at": now}, + ) + counts["advanced"] += 1 + continue + + # Overlap guard: never enqueue while a previous run is active. + if runs_repo.has_active_run(str(schedule["id"])): + runs_repo.record_skipped( + str(schedule["id"]), + schedule["user_id"], + agent_id, + scheduled_for, + error_type="overlap", + error="previous run still active", + ) + counts["skipped"] += 1 + if trigger_type == "recurring": + nxt = _compute_next(schedule, after=scheduled_for) + schedules_repo.update_internal( + str(schedule["id"]), + {"next_run_at": nxt, "last_run_at": now}, + ) + else: + # Once: null next_run_at so we don't re-pick; the in-flight + # run will terminal-flip the schedule when it finishes. + schedules_repo.update_internal( + str(schedule["id"]), + {"next_run_at": None, "last_run_at": now}, + ) + continue + + # Dedup primitive: two racing dispatchers see exactly one row. + run = runs_repo.record_pending( + str(schedule["id"]), + schedule["user_id"], + agent_id, + scheduled_for, + trigger_source="cron", + ) + if run is None: + counts["skipped"] += 1 + else: + enqueue_args.append(str(run["id"])) + counts["enqueued"] += 1 + + # Advance: recurring picks next tick, once nulls next_run_at + # (worker terminal-flips status on completion). + if trigger_type == "recurring": + nxt = _compute_next(schedule, after=scheduled_for) + if nxt is None: + schedules_repo.update_internal( + str(schedule["id"]), + {"status": "completed", "next_run_at": None, + "last_run_at": now}, + ) + else: + schedules_repo.update_internal( + str(schedule["id"]), + {"next_run_at": nxt, "last_run_at": now}, + ) + else: + schedules_repo.update_internal( + str(schedule["id"]), + {"next_run_at": None, "last_run_at": now}, + ) + counts["advanced"] += 1 + + # Enqueue after commit so the worker sees the schedule_runs row on pick-up. + for run_id in enqueue_args: + try: + execute_scheduled_run.apply_async(args=[run_id], queue="docsgpt") + except Exception: + logger.exception( + "dispatcher: failed to enqueue execute_scheduled_run for %s", + run_id, + ) + return counts diff --git a/application/api/user/scheduler_worker.py b/application/api/user/scheduler_worker.py new file mode 100644 index 00000000..e43acdbe --- /dev/null +++ b/application/api/user/scheduler_worker.py @@ -0,0 +1,433 @@ +"""Body of ``execute_scheduled_run`` — runs a single agent execution. + +Not a DURABLE_TASK: agent runs have side effects (messages, CRM writes) +and blind auto-retry would double them. Failures after agent.gen starts +are terminal and recorded; only the pre-start load is retry-safe. +""" + +from __future__ import annotations + +import logging +from datetime import datetime, timezone +from typing import Any, Dict, Optional + +from celery.exceptions import SoftTimeLimitExceeded +from sqlalchemy import text as sql_text + +from application.agents.headless_runner import run_agent_headless +from application.core.settings import settings +from application.events.publisher import publish_user_event +from application.storage.db.base_repository import row_to_dict +from application.storage.db.engine import get_engine +from application.storage.db.repositories.conversations import ( + ConversationsRepository, +) +from application.storage.db.repositories.schedule_runs import ( + ScheduleRunsRepository, +) +from application.storage.db.repositories.schedules import SchedulesRepository +from application.storage.db.repositories.token_usage import TokenUsageRepository + +logger = logging.getLogger(__name__) + + +# Cap output verbatim in the run log; beyond the cap we keep the head and stamp output_truncated. +_OUTPUT_CAP_CHARS = 24_000 + + +def _agent_config_for_schedule(schedule: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """Resolve the agent row (agent-bound) or build an ephemeral classic config. + + For agentless schedules (``agent_id IS NULL``), the worker constructs an + in-memory agent shape carrying just enough fields for ``run_agent_headless``: + classic agent type, system-default retriever/chunks/prompt, no source, and + the optional ``model_id`` override. The runtime toolset is rebuilt by + ``ToolExecutor`` at fire time (current ``user_tools`` + non-disabled, + non-headless-excluded defaults), so a snapshot here would be dead code. + """ + if schedule.get("agent_id"): + engine = get_engine() + with engine.connect() as conn: + row = conn.execute( + sql_text("SELECT * FROM agents WHERE id = CAST(:id AS uuid)"), + {"id": str(schedule["agent_id"])}, + ).fetchone() + return row_to_dict(row) if row is not None else None + return _ephemeral_agent_for_agentless(schedule) + + +def _ephemeral_agent_for_agentless( + schedule: Dict[str, Any], +) -> Optional[Dict[str, Any]]: + """Build an agent-shaped config for a schedule with no parent agent.""" + # ``agent_config["tools"]`` is intentionally omitted: ``run_agent_headless`` + # never reads it. The runtime toolset is rebuilt by + # ``ToolExecutor._get_user_tools(owner)`` at fire time — same dereference + # the agent-bound path uses, so a tool added/disabled after creation is + # reflected. Headless mode there filters chat-only tools (``scheduler``). + user_id = schedule.get("user_id") + if not user_id: + return None + return { + "id": None, + "user_id": user_id, + "agent_type": "classic", + "retriever": "classic", + "chunks": 2, + "prompt_id": "default", + "source_id": None, + "default_model_id": schedule.get("model_id") or "", + } + + +def _load_chat_history(schedule: Dict[str, Any]) -> list: + """Originating conversation history (one-time only; recurring has none).""" + origin = schedule.get("origin_conversation_id") + if not origin or schedule.get("trigger_type") != "once": + return [] + user_id = schedule.get("user_id") + if not user_id: + return [] + try: + engine = get_engine() + with engine.connect() as conn: + conv = ConversationsRepository(conn).get_any(str(origin), user_id) + if conv is None: + return [] + messages = ConversationsRepository(conn).get_messages(str(conv["id"])) + except Exception: + logger.exception("scheduler: failed loading chat history") + return [] + history: list = [] + for msg in messages: + if msg.get("prompt") and msg.get("response"): + history.append({ + "prompt": msg["prompt"], + "response": msg["response"], + }) + return history + + +def _publish_run_event( + event_type: str, run: Dict[str, Any], schedule: Dict[str, Any], **extra: Any +) -> None: + """Best-effort SSE publish for a scheduler run state transition.""" + user_id = run.get("user_id") or schedule.get("user_id") + if not user_id: + return + agent_id_raw = schedule.get("agent_id") + payload = { + "run_id": str(run["id"]), + "schedule_id": str(schedule["id"]), + "agent_id": str(agent_id_raw) if agent_id_raw else None, + "trigger_type": schedule.get("trigger_type"), + "status": run.get("status"), + **extra, + } + try: + publish_user_event( + user_id, + event_type, + payload, + scope={"kind": "schedule", "id": str(schedule["id"])}, + ) + except Exception: + logger.exception( + "scheduler: SSE publish failed event=%s run=%s", + event_type, run.get("id"), + ) + + +def _publish_message_appended( + user_id: str, + conversation_id: str, + message: Dict[str, Any], + schedule_id: str, + run_id: str, +) -> None: + """SSE message-appended event for a one-time run's chat turn.""" + try: + publish_user_event( + user_id, + "schedule.message.appended", + { + "conversation_id": str(conversation_id), + "message_id": str(message["id"]), + "schedule_id": str(schedule_id), + "run_id": str(run_id), + "position": int(message.get("position", 0)), + }, + scope={"kind": "conversation", "id": str(conversation_id)}, + ) + except Exception: + logger.exception( + "scheduler: message.appended publish failed run=%s", run_id, + ) + + +def _append_one_time_turn( + schedule: Dict[str, Any], + run: Dict[str, Any], + outcome: Dict[str, Any], +) -> Optional[Dict[str, Any]]: + """Insert an assistant turn in the originating conversation (once only).""" + origin = schedule.get("origin_conversation_id") + if not origin: + return None + engine = get_engine() + user_id = schedule.get("user_id") + metadata = { + "scheduled": True, + "schedule_id": str(schedule["id"]), + "run_id": str(run["id"]), + "scheduled_run_at": ( + run.get("scheduled_for") + if isinstance(run.get("scheduled_for"), str) + else None + ), + } + with engine.begin() as conn: + conv = ConversationsRepository(conn).get_any(str(origin), user_id) + if conv is None: + return None + message = ConversationsRepository(conn).append_message( + str(conv["id"]), + { + "prompt": schedule.get("instruction") or "", + "response": outcome.get("answer") or "", + "thought": outcome.get("thought") or "", + "sources": outcome.get("sources") or [], + "tool_calls": outcome.get("tool_calls") or [], + "model_id": outcome.get("model_id"), + "metadata": metadata, + }, + ) + return message + + +def execute_scheduled_run_body(run_id: str, celery_task_id: Optional[str]) -> Dict[str, Any]: + """Execute one scheduled run by id; returns a result dict for tracing.""" + if not settings.POSTGRES_URI: + return {"status": "skipped", "reason": "POSTGRES_URI not set"} + + engine = get_engine() + + with engine.connect() as conn: + run = ScheduleRunsRepository(conn).get_internal(run_id) + if run is None: + return {"status": "skipped", "reason": "run not found"} + schedule = SchedulesRepository(conn).get_internal(str(run["schedule_id"])) + if schedule is None: + return {"status": "skipped", "reason": "schedule not found"} + + # Refuse non-runnable terminal states; manual run-now bypasses. + if run.get("status") != "pending": + return {"status": "skipped", "reason": f"run status={run.get('status')}"} + if schedule.get("status") in {"cancelled", "completed"} and run.get( + "trigger_source" + ) != "manual": + with engine.begin() as conn: + ScheduleRunsRepository(conn).update( + run_id, + { + "status": "skipped", + "finished_at": datetime.now(timezone.utc), + "error_type": "internal", + "error": "schedule no longer active", + }, + ) + return {"status": "skipped", "reason": "schedule terminal"} + + agent_config = _agent_config_for_schedule(schedule) + if agent_config is None: + with engine.begin() as conn: + updated = ScheduleRunsRepository(conn).update( + run_id, + { + "status": "failed", + "finished_at": datetime.now(timezone.utc), + "error_type": "internal", + "error": "agent missing", + }, + ) + SchedulesRepository(conn).bump_failure_count(str(schedule["id"])) + _publish_run_event("schedule.run.failed", updated or run, schedule, + error="agent missing") + return {"status": "failed", "reason": "agent missing"} + + with engine.begin() as conn: + if not ScheduleRunsRepository(conn).mark_running(run_id, celery_task_id): + return {"status": "skipped", "reason": "lost race to mark_running"} + + started = datetime.now(timezone.utc) + instruction = schedule.get("instruction") or "" + allowlist = schedule.get("tool_allowlist") or [] + chat_history = _load_chat_history(schedule) + outcome: Dict[str, Any] + error_type: Optional[str] = None + error_text: Optional[str] = None + timed_out = False + try: + outcome = run_agent_headless( + agent_config, + instruction, + tool_allowlist=allowlist, + model_id_override=schedule.get("model_id"), + endpoint="schedule", + conversation_id=schedule.get("origin_conversation_id"), + chat_history=chat_history, + ) + except SoftTimeLimitExceeded: + timed_out = True + outcome = {"answer": "", "tool_calls": [], "sources": [], "thought": ""} + error_type = "timeout" + error_text = "run exceeded soft time limit" + except Exception as exc: + outcome = {"answer": "", "tool_calls": [], "sources": [], "thought": ""} + error_type = "agent_error" + error_text = str(exc) + logger.exception("scheduler: agent run failed run=%s", run_id) + + finished = datetime.now(timezone.utc) + + # Headless denial with no usable output → tool_not_allowed. + if ( + error_type is None + and (outcome.get("denied") or []) + and not (outcome.get("answer") or "").strip() + ): + error_type = "tool_not_allowed" + error_text = "headless allowlist blocked required tool" + + prompt_tokens = int(outcome.get("prompt_tokens", 0) or 0) + generated_tokens = int(outcome.get("generated_tokens", 0) or 0) + used_tokens = prompt_tokens + generated_tokens + if ( + schedule.get("token_budget") is not None + and int(schedule["token_budget"]) > 0 + and used_tokens > int(schedule["token_budget"]) + ): + error_type = "budget_exceeded" + error_text = ( + f"used {used_tokens} tokens exceeds budget " + f"{schedule['token_budget']}" + ) + + answer = outcome.get("answer") or "" + truncated = False + if len(answer) > _OUTPUT_CAP_CHARS: + answer = answer[:_OUTPUT_CAP_CHARS] + truncated = True + + new_status = ( + "timeout" if timed_out else ("failed" if error_type else "success") + ) + + with engine.begin() as conn: + update_fields: Dict[str, Any] = { + "status": new_status, + "started_at": started, + "finished_at": finished, + "output": answer or None, + "output_truncated": truncated, + "prompt_tokens": prompt_tokens, + "generated_tokens": generated_tokens, + } + if error_type: + update_fields["error_type"] = error_type + update_fields["error"] = error_text + updated_run = ScheduleRunsRepository(conn).update(run_id, update_fields) + if used_tokens > 0: + agent_id_raw = schedule.get("agent_id") + try: + TokenUsageRepository(conn).insert( + user_id=schedule.get("user_id"), + api_key=None, + prompt_tokens=prompt_tokens, + generated_tokens=generated_tokens, + timestamp=finished, + agent_id=str(agent_id_raw) if agent_id_raw else None, + source="schedule", + request_id=str(run_id), + ) + except Exception: + logger.exception( + "scheduler: token_usage insert failed run=%s", run_id, + ) + schedules_repo = SchedulesRepository(conn) + autopaused = False + if new_status == "success": + schedules_repo.reset_failure_count(str(schedule["id"])) + elif new_status in ("failed", "timeout"): + count = schedules_repo.bump_failure_count(str(schedule["id"])) + if ( + settings.SCHEDULE_AUTOPAUSE_FAILURES > 0 + and count >= settings.SCHEDULE_AUTOPAUSE_FAILURES + and schedule.get("trigger_type") == "recurring" + ): + autopaused = schedules_repo.autopause(str(schedule["id"])) + # Once: terminal-flip on cron-fired runs only; manual runs on a + # still-active once-schedule leave the future cadence intact. + if ( + schedule.get("trigger_type") == "once" + and run.get("trigger_source") != "manual" + and schedule.get("status") == "active" + ): + schedules_repo.update_internal( + str(schedule["id"]), + {"status": "completed", "next_run_at": None}, + ) + + appended: Optional[Dict[str, Any]] = None + if ( + schedule.get("trigger_type") == "once" + and new_status == "success" + and schedule.get("origin_conversation_id") + ): + try: + appended = _append_one_time_turn(schedule, updated_run or run, outcome) + except Exception: + logger.exception( + "scheduler: append turn failed run=%s", run_id, + ) + if appended is not None: + with engine.begin() as conn: + ScheduleRunsRepository(conn).update( + run_id, + { + "conversation_id": str(appended["conversation_id"]), + "message_id": str(appended["id"]), + }, + ) + _publish_message_appended( + schedule.get("user_id"), + str(appended["conversation_id"]), + appended, + str(schedule["id"]), + run_id, + ) + + if new_status == "success": + _publish_run_event("schedule.run.completed", updated_run or run, schedule) + else: + _publish_run_event( + "schedule.run.failed", + updated_run or run, + schedule, + error_type=error_type, + error=error_text, + ) + + if autopaused: + _publish_run_event( + "schedule.autopaused", + updated_run or run, + schedule, + consecutive_failure_count=settings.SCHEDULE_AUTOPAUSE_FAILURES, + ) + + return { + "status": new_status, + "run_id": run_id, + "error_type": error_type, + } diff --git a/application/api/user/schedules/__init__.py b/application/api/user/schedules/__init__.py new file mode 100644 index 00000000..6d82ed0b --- /dev/null +++ b/application/api/user/schedules/__init__.py @@ -0,0 +1,5 @@ +"""Schedules module.""" + +from .routes import schedules_ns + +__all__ = ["schedules_ns"] diff --git a/application/api/user/schedules/routes.py b/application/api/user/schedules/routes.py new file mode 100644 index 00000000..9f82bac3 --- /dev/null +++ b/application/api/user/schedules/routes.py @@ -0,0 +1,589 @@ +"""Schedules REST API (owner-scoped via request.decoded_token).""" + +from __future__ import annotations + +import functools +import logging +from datetime import datetime, timezone +from typing import Any, Callable, Dict, Optional + +from flask import current_app, jsonify, make_response, request +from flask_restx import Namespace, Resource, fields + +from application.agents.scheduler_utils import ( + ScheduleValidationError, + clamp_once_horizon, + cron_interval_seconds, + next_cron_run, + parse_cron, + parse_run_at, + resolve_timezone, +) +from application.api import api +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.schedule_runs import ( + ScheduleRunsRepository, +) +from application.storage.db.repositories.schedules import SchedulesRepository +from application.storage.db.session import db_readonly, db_session + + +logger = logging.getLogger(__name__) + + +schedules_ns = Namespace( + "schedules", description="Agent schedule management", path="/api", +) + + +def _ok(data: Any, status: int = 200): + return make_response(jsonify(data), status) + + +def _err(message: str, status: int = 400): + return make_response(jsonify({"success": False, "message": message}), status) + + +def _safe_route(func: Callable) -> Callable: + """Decorator: log + mask exceptions that escape a route body as 500. + + ``ScheduleValidationError`` messages are explicitly surfaced via + ``_err(str(exc))`` at each call site (deliberate, user-safe). This + decorator only fires for *unexpected* exceptions (DB driver errors, + NPEs, etc.) that escape the route body. The full trace is logged + server-side; the response body carries no internal detail. + """ + @functools.wraps(func) + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except Exception as exc: # noqa: BLE001 + try: + current_app.logger.exception( + "unhandled exception in schedules route %s: %s", + func.__qualname__, exc, + ) + except RuntimeError: + # Out of Flask app context (rare in tests); use module logger. + logger.exception( + "unhandled exception in schedules route %s: %s", + func.__qualname__, exc, + ) + return _err("internal error", 500) + return wrapper + + +def _format_schedule(row: Dict[str, Any]) -> Dict[str, Any]: + """Render a schedule row for the API (id-as-string + ISO timestamps).""" + if not row: + return {} + out = dict(row) + for key in ( + "id", "agent_id", "origin_conversation_id", + ): + if out.get(key) is not None: + out[key] = str(out[key]) + out.pop("_id", None) # drop dual-id legacy mirror + return out + + +def _format_run(row: Dict[str, Any]) -> Dict[str, Any]: + """Render a schedule_run row for the API.""" + if not row: + return {} + out = dict(row) + for key in ( + "id", "schedule_id", "agent_id", "conversation_id", "message_id", + ): + if out.get(key) is not None: + out[key] = str(out[key]) + out.pop("_id", None) + return out + + +def _agent_owned(agent_id: str, user_id: str) -> Optional[Dict[str, Any]]: + if not looks_like_uuid(str(agent_id)): + return None + with db_readonly() as conn: + return AgentsRepository(conn).get_any(agent_id, user_id) + + +def _user_id() -> Optional[str]: + decoded = getattr(request, "decoded_token", None) + if not decoded: + return None + return decoded.get("sub") + + +@schedules_ns.route("/agents//schedules") +class AgentSchedules(Resource): + @api.doc(description="List schedules for an agent (recurring + one-time).") + @_safe_route + def get(self, agent_id): + user_id = _user_id() + if not user_id: + return _err("unauthorized", 401) + agent = _agent_owned(agent_id, user_id) + if agent is None: + return _err("agent not found", 404) + try: + with db_readonly() as conn: + rows = SchedulesRepository(conn).list_for_agent( + str(agent["id"]), user_id, + ) + except Exception as exc: + current_app.logger.error("list schedules failed: %s", exc, exc_info=True) + return _err("internal error", 500) + return _ok({"schedules": [_format_schedule(r) for r in rows]}) + + create_model = api.model( + "ScheduleCreate", + { + "instruction": fields.String(required=True), + "trigger_type": fields.String( + required=False, + description="'recurring' (default) or 'once'", + ), + "cron": fields.String( + required=False, + description="Required when trigger_type == 'recurring'", + ), + "run_at": fields.String( + required=False, + description="ISO 8601 — required when trigger_type == 'once'", + ), + "timezone": fields.String(required=False), + "name": fields.String(required=False), + "end_at": fields.String(required=False, description="ISO 8601"), + "tool_allowlist": fields.List(fields.String, required=False), + "model_id": fields.String(required=False), + "token_budget": fields.Integer(required=False), + }, + ) + + @api.expect(create_model) + @api.doc(description="Create a schedule (recurring or one-time) for an agent.") + @_safe_route + def post(self, agent_id): + user_id = _user_id() + if not user_id: + return _err("unauthorized", 401) + agent = _agent_owned(agent_id, user_id) + if agent is None: + return _err("agent not found", 404) + data = request.get_json(silent=True) or {} + instruction = (data.get("instruction") or "").strip() + tz_name = (data.get("timezone") or "UTC").strip() or "UTC" + trigger_type = (data.get("trigger_type") or "recurring").strip().lower() + if trigger_type not in ("recurring", "once"): + return _err("trigger_type must be 'recurring' or 'once'") + if not instruction: + return _err("instruction is required") + try: + resolve_timezone(tz_name) + except ScheduleValidationError as exc: + return _err(str(exc)) + token_budget = data.get("token_budget") + if token_budget is not None: + try: + token_budget = int(token_budget) + if token_budget < 0: + raise ValueError + except (TypeError, ValueError): + return _err("token_budget must be a non-negative integer") + with db_readonly() as conn: + count = SchedulesRepository(conn).count_active_for_user(user_id) + if ( + settings.SCHEDULE_MAX_PER_USER > 0 + and count >= settings.SCHEDULE_MAX_PER_USER + ): + return _err("max schedules per user reached", 429) + + if trigger_type == "once": + run_at_raw = (data.get("run_at") or "").strip() + if not run_at_raw: + return _err("run_at is required for trigger_type 'once'") + try: + fire = parse_run_at(run_at_raw, tz_name) + clamp_once_horizon( + fire, settings.SCHEDULE_ONCE_MAX_HORIZON, + ) + except ScheduleValidationError as exc: + return _err(str(exc)) + try: + with db_session() as conn: + created = SchedulesRepository(conn).create( + user_id=user_id, + agent_id=str(agent["id"]), + trigger_type="once", + instruction=instruction, + run_at=fire, + next_run_at=fire, + timezone=tz_name, + name=(data.get("name") or "").strip() or None, + tool_allowlist=data.get("tool_allowlist") or [], + model_id=(data.get("model_id") or None), + token_budget=token_budget, + created_via="ui", + ) + except Exception as exc: + current_app.logger.error( + "create one-time schedule failed: %s", exc, exc_info=True, + ) + return _err("internal error", 500) + return _ok({"schedule": _format_schedule(created)}, status=201) + + cron = (data.get("cron") or "").strip() + if not cron: + return _err("cron is required") + try: + parse_cron(cron) + except ScheduleValidationError as exc: + return _err(str(exc)) + min_interval = max(0, int(settings.SCHEDULE_MIN_INTERVAL)) + if min_interval > 0: + try: + cadence = cron_interval_seconds(cron, tz_name) + except ScheduleValidationError as exc: + return _err(str(exc)) + if cadence < min_interval: + return _err( + "cadence below minimum interval " + f"({cadence}s < {min_interval}s)", + ) + end_at = None + if data.get("end_at"): + try: + end_at = datetime.fromisoformat( + str(data["end_at"]).replace("Z", "+00:00"), + ) + except ValueError: + return _err("invalid end_at") + try: + next_run = next_cron_run(cron, tz_name, after=datetime.now(timezone.utc)) + except ScheduleValidationError as exc: + return _err(str(exc)) + if end_at is not None and next_run > end_at: + return _err("end_at is before the first cron tick") + try: + with db_session() as conn: + created = SchedulesRepository(conn).create( + user_id=user_id, + agent_id=str(agent["id"]), + trigger_type="recurring", + instruction=instruction, + cron=cron, + timezone=tz_name, + next_run_at=next_run, + end_at=end_at, + name=(data.get("name") or "").strip() or None, + tool_allowlist=data.get("tool_allowlist") or [], + model_id=(data.get("model_id") or None), + token_budget=token_budget, + created_via="ui", + ) + except Exception as exc: + current_app.logger.error( + "create schedule failed: %s", exc, exc_info=True, + ) + return _err("internal error", 500) + return _ok({"schedule": _format_schedule(created)}, status=201) + + +@schedules_ns.route("/schedules/") +class ScheduleResource(Resource): + @api.doc(description="Get schedule by id.") + @_safe_route + def get(self, schedule_id): + user_id = _user_id() + if not user_id: + return _err("unauthorized", 401) + if not looks_like_uuid(schedule_id): + return _err("invalid schedule id", 400) + with db_readonly() as conn: + row = SchedulesRepository(conn).get(schedule_id, user_id) + if row is None: + return _err("schedule not found", 404) + return _ok({"schedule": _format_schedule(row)}) + + @api.doc(description="Edit a schedule's editable fields.") + @_safe_route + def put(self, schedule_id): + user_id = _user_id() + if not user_id: + return _err("unauthorized", 401) + if not looks_like_uuid(schedule_id): + return _err("invalid schedule id", 400) + data = request.get_json(silent=True) or {} + fields_in: Dict[str, Any] = {} + if "instruction" in data: + inst = (data["instruction"] or "").strip() + if not inst: + return _err("instruction must not be empty") + fields_in["instruction"] = inst + if "cron" in data: + cron = (data["cron"] or "").strip() + try: + parse_cron(cron) + except ScheduleValidationError as exc: + return _err(str(exc)) + fields_in["cron"] = cron + if "timezone" in data: + tz_name = (data["timezone"] or "UTC").strip() or "UTC" + try: + resolve_timezone(tz_name) + except ScheduleValidationError as exc: + return _err(str(exc)) + fields_in["timezone"] = tz_name + if "tool_allowlist" in data: + fields_in["tool_allowlist"] = data["tool_allowlist"] or [] + if "name" in data: + fields_in["name"] = (data["name"] or "").strip() or None + if "model_id" in data: + fields_in["model_id"] = (data["model_id"] or None) + if "token_budget" in data: + tb = data["token_budget"] + if tb is not None: + try: + tb = int(tb) + if tb < 0: + raise ValueError + except (TypeError, ValueError): + return _err("token_budget must be a non-negative integer") + fields_in["token_budget"] = tb + if "end_at" in data: + if data["end_at"]: + try: + fields_in["end_at"] = datetime.fromisoformat( + str(data["end_at"]).replace("Z", "+00:00"), + ) + except ValueError: + return _err("invalid end_at") + else: + fields_in["end_at"] = None + # Recompute next_run_at when cron/tz changes. + with db_session() as conn: + existing = SchedulesRepository(conn).get(schedule_id, user_id) + if existing is None: + return _err("schedule not found", 404) + if ( + ("cron" in fields_in or "timezone" in fields_in) + and existing.get("trigger_type") == "recurring" + ): + cron_eff = fields_in.get("cron") or existing.get("cron") + tz_eff = fields_in.get("timezone") or existing.get("timezone") + if cron_eff: + min_interval = max(0, int(settings.SCHEDULE_MIN_INTERVAL)) + if min_interval > 0: + try: + cadence = cron_interval_seconds(cron_eff, tz_eff) + except ScheduleValidationError as exc: + return _err(str(exc)) + if cadence < min_interval: + return _err( + "cadence below minimum interval " + f"({cadence}s < {min_interval}s)", + ) + try: + fields_in["next_run_at"] = next_cron_run( + cron_eff, tz_eff, after=datetime.now(timezone.utc), + ) + except ScheduleValidationError as exc: + return _err(str(exc)) + updated = SchedulesRepository(conn).update( + schedule_id, user_id, fields_in, + ) + return _ok({"schedule": _format_schedule(updated or {})}) + + @api.doc(description="Pause / resume a schedule.") + @_safe_route + def patch(self, schedule_id): + user_id = _user_id() + if not user_id: + return _err("unauthorized", 401) + if not looks_like_uuid(schedule_id): + return _err("invalid schedule id", 400) + data = request.get_json(silent=True) or {} + action = (data.get("action") or "").lower().strip() + if action not in {"pause", "resume"}: + return _err("action must be 'pause' or 'resume'") + with db_session() as conn: + existing = SchedulesRepository(conn).get(schedule_id, user_id) + if existing is None: + return _err("schedule not found", 404) + if existing.get("status") in ("cancelled", "completed"): + return _err("schedule is terminal", 409) + if action == "pause": + fields_in: Dict[str, Any] = {"status": "paused", "next_run_at": None} + else: + # Resume: recurring recomputes from now; once honours run_at if still future. + fields_in = {"status": "active"} + if existing.get("trigger_type") == "recurring": + try: + fields_in["next_run_at"] = next_cron_run( + existing["cron"], + existing["timezone"], + after=datetime.now(timezone.utc), + ) + except ScheduleValidationError as exc: + return _err(str(exc)) + else: + new_run_at = data.get("run_at") + if new_run_at: + try: + run_at_dt = datetime.fromisoformat( + str(new_run_at).replace("Z", "+00:00"), + ) + except ValueError: + return _err("invalid run_at") + if run_at_dt <= datetime.now(timezone.utc): + return _err( + "run_at must be in the future to resume", 409, + ) + fields_in["next_run_at"] = run_at_dt + fields_in["run_at"] = run_at_dt + else: + run_at = existing.get("run_at") + if run_at: + if isinstance(run_at, str): + try: + run_at_dt = datetime.fromisoformat( + run_at.replace("Z", "+00:00"), + ) + except ValueError: + return _err("schedule run_at is invalid") + else: + run_at_dt = run_at + if run_at_dt <= datetime.now(timezone.utc): + return _err( + "the once schedule has elapsed; recreate " + "it or supply a new run_at", + 409, + ) + fields_in["next_run_at"] = run_at_dt + updated = SchedulesRepository(conn).update( + schedule_id, user_id, fields_in, + ) + if action == "resume": + SchedulesRepository(conn).reset_failure_count(schedule_id) + return _ok({"schedule": _format_schedule(updated or {})}) + + @api.doc(description="Cancel / delete a schedule.") + @_safe_route + def delete(self, schedule_id): + user_id = _user_id() + if not user_id: + return _err("unauthorized", 401) + if not looks_like_uuid(schedule_id): + return _err("invalid schedule id", 400) + with db_session() as conn: + ok = SchedulesRepository(conn).delete(schedule_id, user_id) + if not ok: + return _err("schedule not found", 404) + return _ok({"success": True}) + + +@schedules_ns.route("/schedules//run") +class ScheduleRunNow(Resource): + @api.doc(description="Run a schedule immediately (trigger_source='manual').") + @_safe_route + def post(self, schedule_id): + user_id = _user_id() + if not user_id: + return _err("unauthorized", 401) + if not looks_like_uuid(schedule_id): + return _err("invalid schedule id", 400) + # FOR UPDATE serializes concurrent Run-Now POSTs (timestamp-unique + # scheduled_for values would otherwise sneak past the unique index). + with db_session() as conn: + schedule = SchedulesRepository(conn).get_for_update( + schedule_id, user_id, + ) + if schedule is None: + return _err("schedule not found", 404) + if schedule.get("status") == "cancelled": + return _err("schedule is cancelled", 409) + if ScheduleRunsRepository(conn).has_active_run(schedule_id): + return _err("a run is already in flight", 409) + scheduled_for = datetime.now(timezone.utc) + agent_id_raw = schedule.get("agent_id") + run = ScheduleRunsRepository(conn).record_pending( + schedule_id, + user_id, + str(agent_id_raw) if agent_id_raw else None, + scheduled_for, + trigger_source="manual", + ) + if run is None: + return _err("could not claim run (concurrent dispatch)", 409) + # Import inside the handler to avoid a circular tasks <-> routes import. + try: + from application.api.user.tasks import execute_scheduled_run + execute_scheduled_run.apply_async(args=[str(run["id"])], queue="docsgpt") + except Exception as exc: + current_app.logger.error( + "run-now enqueue failed: %s", exc, exc_info=True, + ) + return _err("enqueue failed", 500) + return _ok({"run": _format_run(run)}, status=202) + + +@schedules_ns.route("/schedules//runs") +class ScheduleRunList(Resource): + @api.doc( + description="Paginated run log for a schedule.", + params={"limit": "Page size (default 50)", "offset": "Page offset"}, + ) + @_safe_route + def get(self, schedule_id): + user_id = _user_id() + if not user_id: + return _err("unauthorized", 401) + if not looks_like_uuid(schedule_id): + return _err("invalid schedule id", 400) + try: + limit = max(1, min(int(request.args.get("limit", 50)), 200)) + except (TypeError, ValueError): + limit = 50 + try: + offset = max(0, int(request.args.get("offset", 0))) + except (TypeError, ValueError): + offset = 0 + with db_readonly() as conn: + schedule = SchedulesRepository(conn).get(schedule_id, user_id) + if schedule is None: + return _err("schedule not found", 404) + rows = ScheduleRunsRepository(conn).list_runs( + schedule_id, user_id, limit=limit, offset=offset, + ) + return _ok( + { + "runs": [_format_run(r) for r in rows], + "limit": limit, + "offset": offset, + } + ) + + +@schedules_ns.route("/schedules//runs/") +class ScheduleRunDetail(Resource): + @api.doc(description="Full output / error for a single run.") + @_safe_route + def get(self, schedule_id, run_id): + user_id = _user_id() + if not user_id: + return _err("unauthorized", 401) + if not looks_like_uuid(schedule_id) or not looks_like_uuid(run_id): + return _err("invalid id", 400) + with db_readonly() as conn: + schedule = SchedulesRepository(conn).get(schedule_id, user_id) + if schedule is None: + return _err("schedule not found", 404) + run = ScheduleRunsRepository(conn).get(run_id, user_id) + if run is None or str(run.get("schedule_id")) != str( + schedule["id"] + ): + return _err("run not found", 404) + return _ok({"run": _format_run(run)}) diff --git a/application/api/user/tasks.py b/application/api/user/tasks.py index ea7385dd..0e185f0a 100644 --- a/application/api/user/tasks.py +++ b/application/api/user/tasks.py @@ -204,8 +204,64 @@ def ingest_connector_task( return resp +@celery.task(bind=True, acks_late=False) +def dispatch_scheduled_runs(self): + """Beat-driven scheduler poller (body in scheduler_dispatcher).""" + from application.api.user.scheduler_dispatcher import dispatch_due_runs + + return dispatch_due_runs() + + +@celery.task( + bind=True, + acks_late=True, + # Not DURABLE_TASK: agent runs have side effects; blind retry would double them. + autoretry_for=(), + max_retries=0, +) +def execute_scheduled_run(self, run_id): + """Execute one scheduled run; soft-time-limit honors SCHEDULE_RUN_TIMEOUT.""" + from application.api.user.scheduler_worker import execute_scheduled_run_body + + return execute_scheduled_run_body(run_id, getattr(self.request, "id", None)) + + +# Bind runtime soft-time-limit so the prefork worker can raise mid-agent. +try: + from application.core.settings import settings as _scheduler_settings + execute_scheduled_run.soft_time_limit = max( + 30, int(_scheduler_settings.SCHEDULE_RUN_TIMEOUT), + ) + execute_scheduled_run.time_limit = ( + execute_scheduled_run.soft_time_limit + 60 + ) +except Exception: + pass + + +@celery.task(bind=True, acks_late=False) +def cleanup_schedule_runs(self): + """Trim ``schedule_runs`` per ``SCHEDULE_RUN_OUTPUT_RETENTION_DAYS``.""" + from application.core.settings import settings + if not settings.POSTGRES_URI: + return {"deleted": 0, "skipped": "POSTGRES_URI not set"} + + from application.storage.db.engine import get_engine + from application.storage.db.repositories.schedule_runs import ( + ScheduleRunsRepository, + ) + + ttl_days = settings.SCHEDULE_RUN_OUTPUT_RETENTION_DAYS + engine = get_engine() + with engine.begin() as conn: + deleted = ScheduleRunsRepository(conn).cleanup_older_than(ttl_days) + return {"deleted": deleted, "ttl_days": ttl_days} + + @celery.on_after_configure.connect def setup_periodic_tasks(sender, **kwargs): + from application.core.settings import settings + sender.add_periodic_task( timedelta(days=1), schedule_syncs.s("daily"), @@ -251,6 +307,22 @@ def setup_periodic_tasks(sender, **kwargs): cleanup_message_events.s(), name="cleanup-message-events", ) + sender.add_periodic_task( + timedelta(hours=24), + cleanup_orphan_memories.s(), + name="cleanup-orphan-memories", + ) + # Scheduler dispatcher and run-log trim. + sender.add_periodic_task( + timedelta(seconds=max(15, settings.SCHEDULE_DISPATCHER_INTERVAL)), + dispatch_scheduled_runs.s(), + name="dispatch-scheduled-runs", + ) + sender.add_periodic_task( + timedelta(hours=24), + cleanup_schedule_runs.s(), + name="cleanup-schedule-runs", + ) @celery.task(bind=True) @@ -339,6 +411,29 @@ def cleanup_message_events(self): return {"deleted": deleted, "ttl_days": ttl_days} +@celery.task(bind=True, acks_late=False) +def cleanup_orphan_memories(self): + """Sweep orphan memories left by the 0009 FK-to-trigger orphan window. + + A ``memories`` INSERT for a real ``tool_id`` racing a ``user_tools`` + DELETE leaves a permanent orphan the dropped FK would have rejected. + Default-tool synthetic ids are preserved (legitimate built-in data). + """ + from application.core.settings import settings + if not settings.POSTGRES_URI: + return {"deleted": 0, "skipped": "POSTGRES_URI not set"} + + from application.agents.default_tools import default_tool_ids + from application.storage.db.engine import get_engine + from application.storage.db.repositories.memories import MemoriesRepository + + keep_tool_ids = list(default_tool_ids().values()) + engine = get_engine() + with engine.begin() as conn: + deleted = MemoriesRepository(conn).delete_orphans(keep_tool_ids) + return {"deleted": deleted} + + @celery.task(bind=True, acks_late=False) def version_check_task(self): """Periodic anonymous version check. diff --git a/application/api/user/tools/routes.py b/application/api/user/tools/routes.py index 204c86dd..8cdbe35b 100644 --- a/application/api/user/tools/routes.py +++ b/application/api/user/tools/routes.py @@ -3,6 +3,15 @@ from flask import current_app, jsonify, make_response, request from flask_restx import fields, Namespace, Resource +from application.agents.default_tools import ( + builtin_agent_tools_for_management, + BUILTIN_AGENT_TOOLS, + default_tool_name_for_id, + default_tools_for_management, + is_builtin_agent_tool_id, + is_default_tool_id, + is_synthesized_tool_id, +) from application.agents.tools.spec_parser import parse_spec from application.agents.tools.tool_manager import ToolManager from application.api import api @@ -11,6 +20,7 @@ from application.security.encryption import decrypt_credentials, encrypt_credent from application.storage.db.repositories.notes import NotesRepository from application.storage.db.repositories.todos import TodosRepository from application.storage.db.repositories.user_tools import UserToolsRepository +from application.storage.db.repositories.users import UsersRepository from application.storage.db.session import db_readonly, db_session from application.utils import check_required_fields, validate_function_name @@ -208,6 +218,7 @@ class GetTools(Resource): user = decoded_token.get("sub") with db_readonly() as conn: rows = UserToolsRepository(conn).list_for_user(user) + user_doc = UsersRepository(conn).get(user) user_tools = [] for row in rows: tool_copy = _row_to_api(row) @@ -227,6 +238,29 @@ class GetTools(Resource): tool_copy["config"].pop("encrypted_credentials", None) user_tools.append(tool_copy) + + # ``scheduler`` is dual-registered (default chat tool + agent- + # selectable builtin) and resolves to the same synthetic uuid5 id. + # Surface a single row with both flags so the frontend can show it + # in the management page (toggle) and the agent picker. + seen_ids: set = set() + for default_row in default_tools_for_management(user_doc): + default_copy = _row_to_api(default_row) + default_copy["default"] = True + if default_copy.get("name") in BUILTIN_AGENT_TOOLS: + default_copy["builtin"] = True + seen_ids.add(str(default_copy["id"])) + user_tools.append(default_copy) + # Builtins (e.g. scheduler) hidden from Add-Tool catalog, visible + # to the agent picker. Skip ones already added via the default + # path — both registries share ``_DEFAULT_TOOL_NAMESPACE``. + for builtin_row in builtin_agent_tools_for_management(): + builtin_copy = _row_to_api(builtin_row) + if str(builtin_copy["id"]) in seen_ids: + continue + builtin_copy["builtin"] = True + builtin_copy["default"] = False + user_tools.append(builtin_copy) except Exception as err: current_app.logger.error(f"Error getting user tools: {err}", exc_info=True) return make_response(jsonify({"success": False}), 400) @@ -367,6 +401,46 @@ class UpdateTool(Resource): missing_fields = check_required_fields(data, required_fields) if missing_fields: return missing_fields + # Default-tool branch first: a dual-registered tool (e.g. ``scheduler``) + # matches BOTH ``is_default_tool_id`` and ``is_builtin_agent_tool_id``. + # The toggle in Tools settings is the per-user opt-out for the + # agentless default — it must reach the ``set_default_tool_enabled`` + # path, not the builtin "not editable" reject. + if is_default_tool_id(data["id"]): + if "status" not in data: + return make_response( + jsonify( + { + "success": False, + "message": "Default tools are not editable; " + "only their on/off status can be changed.", + } + ), + 400, + ) + tool_name = default_tool_name_for_id(data["id"]) + try: + with db_session() as conn: + UsersRepository(conn).set_default_tool_enabled( + user, tool_name, bool(data["status"]) + ) + except Exception as err: + current_app.logger.error( + f"Error updating default tool: {err}", exc_info=True + ) + return make_response(jsonify({"success": False}), 400) + return make_response(jsonify({"success": True}), 200) + if is_builtin_agent_tool_id(data["id"]): + return make_response( + jsonify( + { + "success": False, + "message": "Built-in agent tools are not editable; " + "add them to an agent via the agent picker.", + } + ), + 400, + ) try: update_data: dict = {} for key in ("name", "displayName", "customName", "description", "actions"): @@ -471,6 +545,17 @@ class UpdateToolConfig(Resource): missing_fields = check_required_fields(data, required_fields) if missing_fields: return missing_fields + if is_synthesized_tool_id(data["id"]): + return make_response( + jsonify( + { + "success": False, + "message": "Default and built-in tools are config-free " + "and cannot be configured.", + } + ), + 400, + ) try: with db_session() as conn: repo = UserToolsRepository(conn) @@ -550,6 +635,16 @@ class UpdateToolActions(Resource): missing_fields = check_required_fields(data, required_fields) if missing_fields: return missing_fields + if is_synthesized_tool_id(data["id"]): + return make_response( + jsonify( + { + "success": False, + "message": "Default and built-in tools' actions are not editable.", + } + ), + 400, + ) try: with db_session() as conn: repo = UserToolsRepository(conn) @@ -595,6 +690,27 @@ class UpdateToolStatus(Resource): if missing_fields: return missing_fields try: + # Default branch first so a dual-registered id (e.g. ``scheduler``) + # writes the per-user opt-out instead of being rejected as a + # not-editable builtin (both predicates match the same uuid5). + if is_default_tool_id(data["id"]): + tool_name = default_tool_name_for_id(data["id"]) + with db_session() as conn: + UsersRepository(conn).set_default_tool_enabled( + user, tool_name, bool(data["status"]) + ) + return make_response(jsonify({"success": True}), 200) + if is_builtin_agent_tool_id(data["id"]): + return make_response( + jsonify( + { + "success": False, + "message": "Built-in agent tools have no per-user " + "toggle; add them to an agent via the agent picker.", + } + ), + 400, + ) with db_session() as conn: repo = UserToolsRepository(conn) tool_doc = repo.get_any(data["id"], user) @@ -633,6 +749,16 @@ class DeleteTool(Resource): missing_fields = check_required_fields(data, required_fields) if missing_fields: return missing_fields + if is_synthesized_tool_id(data["id"]): + return make_response( + jsonify( + { + "success": False, + "message": "Built-in tools cannot be deleted; disable them instead.", + } + ), + 400, + ) try: with db_session() as conn: repo = UserToolsRepository(conn) diff --git a/application/app.py b/application/app.py index 23a59099..6956d06d 100644 --- a/application/app.py +++ b/application/app.py @@ -48,6 +48,12 @@ ensure_database_ready( logger=logging.getLogger("application.app"), ) +from application.agents.default_tools import ( # noqa: E402 + validate_default_chat_tools, +) + +validate_default_chat_tools() + app = Flask(__name__) app.register_blueprint(user) app.register_blueprint(answer) diff --git a/application/core/settings.py b/application/core/settings.py index 61ea4b35..b4373f2b 100644 --- a/application/core/settings.py +++ b/application/core/settings.py @@ -189,6 +189,11 @@ class Settings(BaseSettings): # Tool pre-fetch settings ENABLE_TOOL_PREFETCH: bool = True + # Config-free tools on by default in agentless chats. ``scheduler`` is + # dual-registered (also in ``BUILTIN_AGENT_TOOLS``) so the same synthetic id + # resolves whether reached via defaults or the agent picker. + DEFAULT_CHAT_TOOLS: list = ["memory", "read_webpage", "scheduler"] + # Conversation Compression Settings ENABLE_CONVERSATION_COMPRESSION: bool = True COMPRESSION_THRESHOLD_PERCENTAGE: float = 0.8 # Trigger at 80% of context @@ -232,6 +237,16 @@ class Settings(BaseSettings): # flows without unbounded table growth. MESSAGE_EVENTS_RETENTION_DAYS: int = 14 + # Scheduler (see scheduler.md). + SCHEDULE_DISPATCHER_INTERVAL: int = 30 + SCHEDULE_MIN_INTERVAL: int = 900 + SCHEDULE_MAX_PER_USER: int = 50 + SCHEDULE_RUN_TIMEOUT: int = 600 + SCHEDULE_MISFIRE_GRACE: int = 60 + SCHEDULE_AUTOPAUSE_FAILURES: int = 3 + SCHEDULE_ONCE_MAX_HORIZON: int = 31_536_000 + SCHEDULE_RUN_OUTPUT_RETENTION_DAYS: int = 90 + @field_validator("POSTGRES_URI", mode="before") @classmethod def _normalize_postgres_uri_validator(cls, v): diff --git a/application/llm/handlers/base.py b/application/llm/handlers/base.py index f77cb3ea..bfe8f7d3 100644 --- a/application/llm/handlers/base.py +++ b/application/llm/handlers/base.py @@ -850,6 +850,79 @@ class LLMHandler(ABC): tools_dict, call, llm_class ) if pause_info: + # Headless (scheduled / webhook): synthesize a denial tool message + # so the LLM finishes gracefully instead of stalling on a pause + # nobody will resolve, then journal so the reconciler sees it. + if pause_info.get("pause_type") == "headless_denied": + deny_reason = pause_info.get( + "deny_reason", "Tool blocked in headless mode." + ) + args_str = ( + json.dumps(call.arguments) + if isinstance(call.arguments, dict) + else (call.arguments or "{}") + ) + tool_call_obj = { + "id": pause_info["call_id"], + "type": "function", + "function": { + "name": call.name, + "arguments": args_str, + }, + } + if getattr(call, "thought_signature", None): + tool_call_obj["thought_signature"] = call.thought_signature + updated_messages.append({ + "role": "assistant", + "content": None, + "tool_calls": [tool_call_obj], + }) + denial_call = ToolCall( + id=pause_info["call_id"], + name=call.name, + arguments=call.arguments, + ) + updated_messages.append( + self.create_tool_message( + denial_call, + f"Tool denied (headless): {deny_reason}", + ) + ) + if hasattr(agent.tool_executor, "headless_denials"): + agent.tool_executor.headless_denials.append(pause_info) + from application.agents.tool_executor import ( + _mark_failed, + _record_proposed, + ) + + _record_proposed( + pause_info["call_id"], + pause_info["tool_name"], + pause_info["action_name"], + pause_info.get("arguments") or {}, + tool_id=pause_info.get("tool_id"), + ) + _mark_failed( + pause_info["call_id"], + f"headless: {deny_reason}", + ) + yield { + "type": "tool_call", + "data": { + "tool_name": pause_info["tool_name"], + "call_id": pause_info["call_id"], + "action_name": pause_info.get( + "llm_name", pause_info["name"] + ), + "arguments": pause_info["arguments"], + "status": "denied", + "error": deny_reason, + "error_type": pause_info.get( + "error_type", "tool_not_allowed" + ), + }, + } + continue # Yield pause event so the client knows this tool is waiting yield { "type": "tool_call", diff --git a/application/requirements.txt b/application/requirements.txt index 8c33566c..5414df03 100644 --- a/application/requirements.txt +++ b/application/requirements.txt @@ -7,6 +7,7 @@ beautifulsoup4==4.14.3 cel-python==0.5.0 celery==5.6.3 celery-redbeat==2.3.3 +croniter==6.2.2 cryptography==46.0.7 dataclasses-json==0.6.7 defusedxml==0.7.1 diff --git a/application/storage/db/models.py b/application/storage/db/models.py index ed67dddf..7b04b599 100644 --- a/application/storage/db/models.py +++ b/application/storage/db/models.py @@ -47,6 +47,7 @@ users_table = Table( nullable=False, server_default='{"pinned": [], "shared_with_me": []}', ), + Column("tool_preferences", JSONB, nullable=False, server_default="{}"), Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()), Column("updated_at", DateTime(timezone=True), nullable=False, server_default=func.now()), ) @@ -254,7 +255,8 @@ memories_table = Table( metadata, Column("id", UUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid()), Column("user_id", Text, nullable=False), - Column("tool_id", UUID(as_uuid=True), ForeignKey("user_tools.id", ondelete="CASCADE")), + # No FK since 0009 — delete-cascade preserved by trigger. + Column("tool_id", UUID(as_uuid=True)), Column("path", Text, nullable=False), Column("content", Text, nullable=False), Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()), @@ -598,3 +600,79 @@ workflow_runs_table = Table( Column("ended_at", DateTime(timezone=True)), Column("legacy_mongo_id", Text), ) + + +# --- Scheduler (migration 0010) --------------------------------------------- + +schedules_table = Table( + "schedules", + metadata, + Column("id", UUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid()), + Column("user_id", Text, nullable=False), + # Nullable as of 0011: agentless chats create one-time schedules whose + # run is built ephemerally at fire time from system defaults. + Column( + "agent_id", + UUID(as_uuid=True), + ForeignKey("agents.id", ondelete="CASCADE"), + ), + Column("trigger_type", Text, nullable=False), + Column("name", Text), + Column("instruction", Text, nullable=False), + Column("status", Text, nullable=False, server_default="active"), + Column("cron", Text), + Column("run_at", DateTime(timezone=True)), + Column("timezone", Text, nullable=False, server_default="UTC"), + Column("next_run_at", DateTime(timezone=True)), + Column("last_run_at", DateTime(timezone=True)), + Column("end_at", DateTime(timezone=True)), + Column("tool_allowlist", JSONB, nullable=False, server_default="[]"), + Column("model_id", Text), + Column("token_budget", Integer), + Column( + "origin_conversation_id", + UUID(as_uuid=True), + ForeignKey("conversations.id", ondelete="SET NULL"), + ), + Column("created_via", Text, nullable=False, server_default="ui"), + Column("consecutive_failure_count", Integer, nullable=False, server_default="0"), + Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()), + Column("updated_at", DateTime(timezone=True), nullable=False, server_default=func.now()), +) + +schedule_runs_table = Table( + "schedule_runs", + metadata, + Column("id", UUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid()), + Column( + "schedule_id", + UUID(as_uuid=True), + ForeignKey("schedules.id", ondelete="CASCADE"), + nullable=False, + ), + Column("user_id", Text, nullable=False), + # Nullable as of 0011 (mirrors ``schedules.agent_id``); FK CASCADE + # established in 0010 to match the direct ``agents`` reference. + Column( + "agent_id", + UUID(as_uuid=True), + ForeignKey("agents.id", ondelete="CASCADE"), + ), + Column("status", Text, nullable=False, server_default="pending"), + Column("scheduled_for", DateTime(timezone=True), nullable=False), + Column("trigger_source", Text, nullable=False, server_default="cron"), + Column("started_at", DateTime(timezone=True)), + Column("finished_at", DateTime(timezone=True)), + Column("output", Text), + Column("output_truncated", Boolean, nullable=False, server_default="false"), + Column("error", Text), + Column("error_type", Text), + Column("prompt_tokens", Integer, nullable=False, server_default="0"), + Column("generated_tokens", Integer, nullable=False, server_default="0"), + Column("conversation_id", UUID(as_uuid=True)), + Column("message_id", UUID(as_uuid=True)), + Column("celery_task_id", Text), + Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()), + Column("updated_at", DateTime(timezone=True), nullable=False, server_default=func.now()), + UniqueConstraint("schedule_id", "scheduled_for", name="schedule_runs_dedup_uidx"), +) diff --git a/application/storage/db/repositories/memories.py b/application/storage/db/repositories/memories.py index 89a3c647..33f2efed 100644 --- a/application/storage/db/repositories/memories.py +++ b/application/storage/db/repositories/memories.py @@ -86,6 +86,24 @@ class MemoriesRepository: ) return result.rowcount + def delete_orphans(self, keep_tool_ids: Optional[list[str]] = None) -> int: + """Delete memories whose tool_id has no user_tools row, except keep_tool_ids.""" + keep = [str(tid) for tid in (keep_tool_ids or [])] + result = self._conn.execute( + text( + """ + DELETE FROM memories + WHERE tool_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM user_tools u WHERE u.id = memories.tool_id + ) + AND NOT (tool_id = ANY(CAST(:keep AS uuid[]))) + """ + ), + {"keep": keep}, + ) + return result.rowcount + def update_path(self, user_id: str, tool_id: str, old_path: str, new_path: str) -> bool: result = self._conn.execute( text( diff --git a/application/storage/db/repositories/schedule_runs.py b/application/storage/db/repositories/schedule_runs.py new file mode 100644 index 00000000..aeb5f38b --- /dev/null +++ b/application/storage/db/repositories/schedule_runs.py @@ -0,0 +1,278 @@ +"""Repository for ``schedule_runs`` (record_pending is the dedup primitive).""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any, Optional + +from sqlalchemy import Connection, text + +from application.storage.db.base_repository import row_to_dict + + +_ALLOWED_UPDATES = frozenset( + { + "status", "started_at", "finished_at", "output", "output_truncated", + "error", "error_type", "prompt_tokens", "generated_tokens", + "conversation_id", "message_id", "celery_task_id", + } +) + + +class ScheduleRunsRepository: + """CRUD + dedup insert + reconciliation sweep for ``schedule_runs``.""" + + def __init__(self, conn: Connection) -> None: + self._conn = conn + + def record_pending( + self, + schedule_id: str, + user_id: str, + agent_id: Optional[str], + scheduled_for: datetime, + *, + trigger_source: str = "cron", + ) -> Optional[dict]: + """Insert a ``pending`` row; ``None`` on conflict (already claimed).""" + row = self._conn.execute( + text( + """ + INSERT INTO schedule_runs ( + schedule_id, user_id, agent_id, scheduled_for, + trigger_source, status + ) VALUES ( + CAST(:schedule_id AS uuid), + :user_id, + CAST(:agent_id AS uuid), + :scheduled_for, + :trigger_source, + 'pending' + ) + ON CONFLICT (schedule_id, scheduled_for) DO NOTHING + RETURNING * + """ + ), + { + "schedule_id": str(schedule_id), + "user_id": user_id, + "agent_id": str(agent_id) if agent_id else None, + "scheduled_for": scheduled_for, + "trigger_source": trigger_source, + }, + ).fetchone() + return row_to_dict(row) if row is not None else None + + def record_skipped( + self, + schedule_id: str, + user_id: str, + agent_id: Optional[str], + scheduled_for: datetime, + *, + error_type: str, + error: Optional[str] = None, + ) -> Optional[dict]: + """Write a terminal ``skipped`` row; returns ``None`` on conflict.""" + row = self._conn.execute( + text( + """ + INSERT INTO schedule_runs ( + schedule_id, user_id, agent_id, scheduled_for, + trigger_source, status, started_at, finished_at, + error, error_type + ) VALUES ( + CAST(:schedule_id AS uuid), + :user_id, + CAST(:agent_id AS uuid), + :scheduled_for, + 'cron', + 'skipped', + now(), + now(), + :error, + :error_type + ) + ON CONFLICT (schedule_id, scheduled_for) DO NOTHING + RETURNING * + """ + ), + { + "schedule_id": str(schedule_id), + "user_id": user_id, + "agent_id": str(agent_id) if agent_id else None, + "scheduled_for": scheduled_for, + "error": error, + "error_type": error_type, + }, + ).fetchone() + return row_to_dict(row) if row is not None else None + + def get(self, run_id: str, user_id: str) -> Optional[dict]: + """Fetch an owned run row.""" + row = self._conn.execute( + text( + "SELECT * FROM schedule_runs " + "WHERE id = CAST(:id AS uuid) AND user_id = :user_id" + ), + {"id": str(run_id), "user_id": user_id}, + ).fetchone() + return row_to_dict(row) if row is not None else None + + def get_internal(self, run_id: str) -> Optional[dict]: + """Fetch a run row with no ownership scoping (worker-only).""" + row = self._conn.execute( + text("SELECT * FROM schedule_runs WHERE id = CAST(:id AS uuid)"), + {"id": str(run_id)}, + ).fetchone() + return row_to_dict(row) if row is not None else None + + def has_active_run(self, schedule_id: str) -> bool: + """True iff a ``pending``/``running`` run exists for the schedule.""" + scalar = self._conn.execute( + text( + "SELECT 1 FROM schedule_runs " + "WHERE schedule_id = CAST(:id AS uuid) " + "AND status IN ('pending', 'running') " + "LIMIT 1" + ), + {"id": str(schedule_id)}, + ).first() + return scalar is not None + + def list_runs( + self, + schedule_id: str, + user_id: str, + *, + limit: int = 50, + offset: int = 0, + ) -> list[dict]: + """Paginated newest-first run log for an owned schedule.""" + rows = self._conn.execute( + text( + """ + SELECT * FROM schedule_runs + WHERE schedule_id = CAST(:id AS uuid) AND user_id = :user_id + ORDER BY scheduled_for DESC + LIMIT :limit OFFSET :offset + """ + ), + { + "id": str(schedule_id), + "user_id": user_id, + "limit": int(limit), + "offset": int(offset), + }, + ).fetchall() + return [row_to_dict(r) for r in rows] + + def update(self, run_id: str, fields: dict) -> Optional[dict]: + """Apply a whitelisted partial update to a run row.""" + filtered = {k: v for k, v in fields.items() if k in _ALLOWED_UPDATES} + if not filtered: + return self.get_internal(run_id) + set_parts: list[str] = [] + params: dict[str, Any] = {"id": str(run_id)} + for key, val in filtered.items(): + if key in ("conversation_id", "message_id"): + set_parts.append(f"{key} = CAST(:{key} AS uuid)") + params[key] = str(val) if val else None + else: + set_parts.append(f"{key} = :{key}") + params[key] = val + sql = ( + "UPDATE schedule_runs SET " + ", ".join(set_parts) + + " WHERE id = CAST(:id AS uuid) RETURNING *" + ) + row = self._conn.execute(text(sql), params).fetchone() + return row_to_dict(row) if row is not None else None + + def mark_running(self, run_id: str, celery_task_id: Optional[str]) -> bool: + """Flip ``pending`` → ``running`` and stamp ``started_at``.""" + result = self._conn.execute( + text( + """ + UPDATE schedule_runs + SET status = 'running', + started_at = now(), + celery_task_id = :celery_task_id + WHERE id = CAST(:id AS uuid) + AND status = 'pending' + """ + ), + {"id": str(run_id), "celery_task_id": celery_task_id}, + ) + return (result.rowcount or 0) > 0 + + def list_stuck_running( + self, *, age_minutes: int = 15, limit: int = 50, + ) -> list[dict]: + """Lock ``running`` rows past the soft-time-limit envelope.""" + rows = self._conn.execute( + text( + """ + SELECT * FROM schedule_runs + WHERE status = 'running' + AND started_at IS NOT NULL + AND started_at < now() - make_interval(mins => :age) + ORDER BY started_at ASC + LIMIT :limit + FOR UPDATE SKIP LOCKED + """ + ), + {"age": int(age_minutes), "limit": int(limit)}, + ).fetchall() + return [row_to_dict(r) for r in rows] + + def list_stuck_pending( + self, *, age_minutes: int = 15, limit: int = 50, + ) -> list[dict]: + """Lock 'pending' rows whose worker never picked them up (created_at-based).""" + rows = self._conn.execute( + text( + """ + SELECT * FROM schedule_runs + WHERE status = 'pending' + AND started_at IS NULL + AND created_at < now() - make_interval(mins => :age) + ORDER BY created_at ASC + LIMIT :limit + FOR UPDATE SKIP LOCKED + """ + ), + {"age": int(age_minutes), "limit": int(limit)}, + ).fetchall() + return [row_to_dict(r) for r in rows] + + def cleanup_older_than( + self, + ttl_days: int, + *, + keep_recent_per_schedule: int = 50, + ) -> int: + """Trim run rows older than ``ttl_days``, keeping the recent log slice.""" + if ttl_days <= 0: + raise ValueError("ttl_days must be positive") + result = self._conn.execute( + text( + """ + DELETE FROM schedule_runs + WHERE id IN ( + SELECT id FROM ( + SELECT id, + ROW_NUMBER() OVER ( + PARTITION BY schedule_id + ORDER BY scheduled_for DESC + ) AS rn, + created_at + FROM schedule_runs + ) ranked + WHERE ranked.rn > :keep + AND ranked.created_at < now() - make_interval(days => :ttl) + ) + """ + ), + {"keep": int(keep_recent_per_schedule), "ttl": int(ttl_days)}, + ) + return int(result.rowcount or 0) diff --git a/application/storage/db/repositories/schedules.py b/application/storage/db/repositories/schedules.py new file mode 100644 index 00000000..d4c97f4d --- /dev/null +++ b/application/storage/db/repositories/schedules.py @@ -0,0 +1,352 @@ +"""Repository for the ``schedules`` table (CRUD + dispatcher claim query).""" + +from __future__ import annotations + +import json +from datetime import datetime +from typing import Any, Iterable, Optional + +from sqlalchemy import Connection, text + +from application.storage.db.base_repository import row_to_dict + + +_ALLOWED_UPDATES = frozenset( + { + "name", "instruction", "status", "cron", "run_at", "timezone", + "next_run_at", "last_run_at", "end_at", "tool_allowlist", + "model_id", "token_budget", "consecutive_failure_count", + "origin_conversation_id", + } +) + + +class SchedulesRepository: + """CRUD + dispatcher hot path for ``schedules``.""" + + def __init__(self, conn: Connection) -> None: + self._conn = conn + + def create( + self, + user_id: str, + agent_id: Optional[str], + trigger_type: str, + instruction: str, + *, + cron: Optional[str] = None, + run_at: Optional[datetime] = None, + timezone: str = "UTC", + next_run_at: Optional[datetime] = None, + end_at: Optional[datetime] = None, + name: Optional[str] = None, + tool_allowlist: Optional[Iterable[str]] = None, + model_id: Optional[str] = None, + token_budget: Optional[int] = None, + origin_conversation_id: Optional[str] = None, + created_via: str = "ui", + status: str = "active", + ) -> dict: + """Insert a new schedule and return the populated row.""" + params = { + "user_id": user_id, + "agent_id": str(agent_id) if agent_id else None, + "trigger_type": trigger_type, + "instruction": instruction, + "cron": cron, + "run_at": run_at, + "tz": timezone, + "next_run_at": next_run_at, + "end_at": end_at, + "name": name, + "allowlist": json.dumps(list(tool_allowlist or [])), + "model_id": model_id, + "token_budget": int(token_budget) if token_budget is not None else None, + "origin_conversation_id": ( + str(origin_conversation_id) if origin_conversation_id else None + ), + "created_via": created_via, + "status": status, + } + row = self._conn.execute( + text( + """ + INSERT INTO schedules ( + user_id, agent_id, trigger_type, instruction, status, + cron, run_at, timezone, next_run_at, end_at, name, + tool_allowlist, model_id, token_budget, + origin_conversation_id, created_via + ) VALUES ( + :user_id, + CAST(:agent_id AS uuid), + :trigger_type, + :instruction, + :status, + :cron, + :run_at, + :tz, + :next_run_at, + :end_at, + :name, + CAST(:allowlist AS jsonb), + :model_id, + :token_budget, + CAST(:origin_conversation_id AS uuid), + :created_via + ) RETURNING * + """ + ), + params, + ).fetchone() + return row_to_dict(row) + + def get(self, schedule_id: str, user_id: str) -> Optional[dict]: + """Fetch an owned schedule (None when missing or owned by another).""" + row = self._conn.execute( + text( + "SELECT * FROM schedules " + "WHERE id = CAST(:id AS uuid) AND user_id = :user_id" + ), + {"id": str(schedule_id), "user_id": user_id}, + ).fetchone() + return row_to_dict(row) if row is not None else None + + def get_internal(self, schedule_id: str) -> Optional[dict]: + """Fetch a schedule with no ownership scoping (worker-only).""" + row = self._conn.execute( + text("SELECT * FROM schedules WHERE id = CAST(:id AS uuid)"), + {"id": str(schedule_id)}, + ).fetchone() + return row_to_dict(row) if row is not None else None + + def get_for_update( + self, schedule_id: str, user_id: str, + ) -> Optional[dict]: + """Owned fetch with FOR UPDATE; closes the Run-Now TOCTOU.""" + row = self._conn.execute( + text( + "SELECT * FROM schedules " + "WHERE id = CAST(:id AS uuid) AND user_id = :user_id " + "FOR UPDATE" + ), + {"id": str(schedule_id), "user_id": user_id}, + ).fetchone() + return row_to_dict(row) if row is not None else None + + def list_for_agent( + self, + agent_id: str, + user_id: str, + *, + statuses: Optional[Iterable[str]] = None, + trigger_type: Optional[str] = None, + ) -> list[dict]: + """Owned schedules for an agent, newest-created first.""" + sql = ( + "SELECT * FROM schedules " + "WHERE agent_id = CAST(:agent_id AS uuid) AND user_id = :user_id" + ) + params: dict[str, Any] = {"agent_id": str(agent_id), "user_id": user_id} + if statuses is not None: + status_list = [str(s) for s in statuses] + if not status_list: + return [] + placeholders = ", ".join(f":s{i}" for i, _ in enumerate(status_list)) + sql += f" AND status IN ({placeholders})" + for i, s in enumerate(status_list): + params[f"s{i}"] = s + if trigger_type: + sql += " AND trigger_type = :trigger_type" + params["trigger_type"] = trigger_type + sql += " ORDER BY created_at DESC" + rows = self._conn.execute(text(sql), params).fetchall() + return [row_to_dict(r) for r in rows] + + def list_for_conversation( + self, + user_id: str, + origin_conversation_id: str, + *, + statuses: Optional[Iterable[str]] = None, + trigger_type: Optional[str] = None, + ) -> list[dict]: + """Owned agentless schedules anchored to an originating conversation.""" + sql = ( + "SELECT * FROM schedules " + "WHERE user_id = :user_id " + "AND agent_id IS NULL " + "AND origin_conversation_id = CAST(:conv AS uuid)" + ) + params: dict[str, Any] = { + "user_id": user_id, + "conv": str(origin_conversation_id), + } + if statuses is not None: + status_list = [str(s) for s in statuses] + if not status_list: + return [] + placeholders = ", ".join(f":s{i}" for i, _ in enumerate(status_list)) + sql += f" AND status IN ({placeholders})" + for i, s in enumerate(status_list): + params[f"s{i}"] = s + if trigger_type: + sql += " AND trigger_type = :trigger_type" + params["trigger_type"] = trigger_type + sql += " ORDER BY created_at DESC" + rows = self._conn.execute(text(sql), params).fetchall() + return [row_to_dict(r) for r in rows] + + def list_for_user(self, user_id: str, *, limit: int = 200) -> list[dict]: + """Owned schedules across all agents — admin / debugging path.""" + rows = self._conn.execute( + text( + "SELECT * FROM schedules WHERE user_id = :user_id " + "ORDER BY created_at DESC LIMIT :limit" + ), + {"user_id": user_id, "limit": int(limit)}, + ).fetchall() + return [row_to_dict(r) for r in rows] + + def count_active_for_user(self, user_id: str) -> int: + """Active+paused schedules for quota enforcement.""" + scalar = self._conn.execute( + text( + "SELECT COUNT(*) FROM schedules " + "WHERE user_id = :user_id AND status IN ('active', 'paused')" + ), + {"user_id": user_id}, + ).scalar() + return int(scalar or 0) + + def list_due(self, *, limit: int = 100) -> list[dict]: + """Lock and return schedules with ``next_run_at <= now()``.""" + rows = self._conn.execute( + text( + """ + SELECT * FROM schedules + WHERE status = 'active' + AND next_run_at IS NOT NULL + AND next_run_at <= now() + AND (end_at IS NULL OR next_run_at <= end_at) + ORDER BY next_run_at ASC + LIMIT :limit + FOR UPDATE SKIP LOCKED + """ + ), + {"limit": int(limit)}, + ).fetchall() + return [row_to_dict(r) for r in rows] + + def update( + self, + schedule_id: str, + user_id: str, + fields: dict, + ) -> Optional[dict]: + """Apply a whitelisted partial update; return the new row or None.""" + filtered = {k: v for k, v in fields.items() if k in _ALLOWED_UPDATES} + if not filtered: + return self.get(schedule_id, user_id) + set_parts: list[str] = [] + params: dict[str, Any] = {"id": str(schedule_id), "user_id": user_id} + for key, val in filtered.items(): + if key == "tool_allowlist": + set_parts.append("tool_allowlist = CAST(:tool_allowlist AS jsonb)") + params["tool_allowlist"] = json.dumps(list(val or [])) + elif key == "origin_conversation_id": + set_parts.append( + "origin_conversation_id = CAST(:origin_conversation_id AS uuid)" + ) + params["origin_conversation_id"] = str(val) if val else None + else: + set_parts.append(f"{key} = :{key}") + params[key] = val + sql = ( + "UPDATE schedules SET " + ", ".join(set_parts) + + " WHERE id = CAST(:id AS uuid) AND user_id = :user_id " + "RETURNING *" + ) + row = self._conn.execute(text(sql), params).fetchone() + return row_to_dict(row) if row is not None else None + + def update_internal(self, schedule_id: str, fields: dict) -> None: + """Apply a whitelisted partial update from a worker context.""" + filtered = {k: v for k, v in fields.items() if k in _ALLOWED_UPDATES} + if not filtered: + return + set_parts: list[str] = [] + params: dict[str, Any] = {"id": str(schedule_id)} + for key, val in filtered.items(): + if key == "tool_allowlist": + set_parts.append("tool_allowlist = CAST(:tool_allowlist AS jsonb)") + params["tool_allowlist"] = json.dumps(list(val or [])) + elif key == "origin_conversation_id": + set_parts.append( + "origin_conversation_id = CAST(:origin_conversation_id AS uuid)" + ) + params["origin_conversation_id"] = str(val) if val else None + else: + set_parts.append(f"{key} = :{key}") + params[key] = val + sql = ( + "UPDATE schedules SET " + ", ".join(set_parts) + + " WHERE id = CAST(:id AS uuid)" + ) + self._conn.execute(text(sql), params) + + def cancel(self, schedule_id: str, user_id: str) -> bool: + """Soft-cancel — flips ``status`` to ``cancelled`` and clears ``next_run_at``.""" + result = self._conn.execute( + text( + "UPDATE schedules SET status = 'cancelled', next_run_at = NULL " + "WHERE id = CAST(:id AS uuid) AND user_id = :user_id " + "AND status NOT IN ('cancelled', 'completed')" + ), + {"id": str(schedule_id), "user_id": user_id}, + ) + return (result.rowcount or 0) > 0 + + def delete(self, schedule_id: str, user_id: str) -> bool: + """Hard-delete an owned schedule and its runs (FK cascade).""" + result = self._conn.execute( + text( + "DELETE FROM schedules " + "WHERE id = CAST(:id AS uuid) AND user_id = :user_id" + ), + {"id": str(schedule_id), "user_id": user_id}, + ) + return (result.rowcount or 0) > 0 + + def bump_failure_count(self, schedule_id: str) -> int: + """Increment ``consecutive_failure_count`` and return the new value.""" + row = self._conn.execute( + text( + "UPDATE schedules " + "SET consecutive_failure_count = consecutive_failure_count + 1 " + "WHERE id = CAST(:id AS uuid) " + "RETURNING consecutive_failure_count" + ), + {"id": str(schedule_id)}, + ).fetchone() + return int(row[0]) if row is not None else 0 + + def reset_failure_count(self, schedule_id: str) -> None: + """Reset the failure counter to 0 after a successful run.""" + self._conn.execute( + text( + "UPDATE schedules SET consecutive_failure_count = 0 " + "WHERE id = CAST(:id AS uuid)" + ), + {"id": str(schedule_id)}, + ) + + def autopause(self, schedule_id: str) -> bool: + """Flip an active schedule to ``paused`` after repeated failures.""" + result = self._conn.execute( + text( + "UPDATE schedules SET status = 'paused', next_run_at = NULL " + "WHERE id = CAST(:id AS uuid) AND status = 'active'" + ), + {"id": str(schedule_id)}, + ) + return (result.rowcount or 0) > 0 diff --git a/application/storage/db/repositories/users.py b/application/storage/db/repositories/users.py index 2827f518..a128612f 100644 --- a/application/storage/db/repositories/users.py +++ b/application/storage/db/repositories/users.py @@ -175,6 +175,67 @@ class UsersRepository: {"user_id": user_id, "agent_id": agent_id}, ) + def set_default_tool_enabled( + self, user_id: str, tool_name: str, enabled: bool + ) -> None: + """Toggle a default chat tool in ``tool_preferences`` (idempotent).""" + self.upsert(user_id) + if enabled: + self._conn.execute( + text( + """ + UPDATE users + SET tool_preferences = jsonb_set( + COALESCE(tool_preferences, '{}'::jsonb), + '{disabled_default_tools}', + COALESCE( + ( + SELECT jsonb_agg(elem) + FROM jsonb_array_elements( + COALESCE( + tool_preferences->'disabled_default_tools', + '[]'::jsonb + ) + ) AS elem + WHERE (elem #>> '{}') != :tool_name + ), + '[]'::jsonb + ) + ), + updated_at = now() + WHERE user_id = :user_id + """ + ), + {"user_id": user_id, "tool_name": tool_name}, + ) + else: + self._conn.execute( + text( + """ + UPDATE users + SET tool_preferences = jsonb_set( + COALESCE(tool_preferences, '{}'::jsonb), + '{disabled_default_tools}', + CASE + WHEN COALESCE( + tool_preferences->'disabled_default_tools', + '[]'::jsonb + ) @> to_jsonb(CAST(:tool_name AS text)) + THEN tool_preferences->'disabled_default_tools' + ELSE + COALESCE( + tool_preferences->'disabled_default_tools', + '[]'::jsonb + ) || to_jsonb(CAST(:tool_name AS text)) + END + ), + updated_at = now() + WHERE user_id = :user_id + """ + ), + {"user_id": user_id, "tool_name": tool_name}, + ) + # ------------------------------------------------------------------ # Private helpers # ------------------------------------------------------------------ diff --git a/application/worker.py b/application/worker.py index 5ee089d4..69da5e0f 100755 --- a/application/worker.py +++ b/application/worker.py @@ -16,9 +16,6 @@ from urllib.parse import urljoin import requests -from application.agents.agent_creator import AgentCreator -from application.api.answer.services.stream_processor import get_prompt - from application.core.settings import settings from application.events.publisher import publish_user_event from application.parser.chunking import Chunker @@ -34,7 +31,6 @@ from application.parser.remote.remote_creator import ( normalize_remote_data, ) from application.parser.schema.base import Document -from application.retriever.retriever_creator import RetrieverCreator from application.storage.db.base_repository import looks_like_uuid from application.storage.db.repositories.agents import AgentsRepository @@ -391,146 +387,6 @@ def upload_index(full_path, file_data): file.close() -def run_agent_logic(agent_config, input_data): - try: - from application.core.model_utils import ( - get_api_key_for_provider, - get_default_model_id, - get_provider_from_model_id, - validate_model_id, - ) - from application.utils import calculate_doc_token_budget - - retriever = agent_config.get("retriever", "classic") - # agent_config is a PG row dict: ``source_id`` is a UUID, and the - # retriever/chunks live on the source row. Resolve source row for - # its retriever/chunks if the agent points at one. - source_id = agent_config.get("source_id") or agent_config.get("source") - source_active = {} - if source_id: - with db_readonly() as conn: - src_row = SourcesRepository(conn).get( - str(source_id), - agent_config.get("user_id") or agent_config.get("user"), - ) - if src_row: - source_active = str(src_row["id"]) - retriever = src_row.get("retriever", retriever) - source = {"active_docs": source_active} - chunks = int(agent_config.get("chunks", 2) or 2) - prompt_id = agent_config.get("prompt_id", "default") - user_api_key = agent_config["key"] - agent_id = ( - str(agent_config.get("id")) - if agent_config.get("id") - else (str(agent_config.get("_id")) if agent_config.get("_id") else None) - ) - agent_type = agent_config.get("agent_type", "classic") - owner = agent_config.get("user_id") or agent_config.get("user") - decoded_token = {"sub": owner} - json_schema = agent_config.get("json_schema") - prompt = get_prompt(prompt_id) - - # Determine model_id: check agent's default_model_id, fallback to system default - agent_default_model = agent_config.get("default_model_id", "") - if agent_default_model and validate_model_id( - agent_default_model, user_id=owner - ): - model_id = agent_default_model - else: - model_id = get_default_model_id() - if agent_default_model: - # Stored model_id no longer resolves in the registry. Log so - # operators can detect bad YAML edits before users complain; - # behavior matches the historical silent fallback. - logging.warning( - "Agent %s references unknown model_id %r; falling back to %r", - agent_id, - agent_default_model, - model_id, - ) - - # Get provider and API key for the selected model - provider = ( - get_provider_from_model_id(model_id, user_id=owner) - if model_id - else settings.LLM_PROVIDER - ) - system_api_key = get_api_key_for_provider(provider or settings.LLM_PROVIDER) - - # Calculate proper doc_token_limit based on model's context window - doc_token_limit = calculate_doc_token_budget( - model_id=model_id, user_id=owner - ) - - retriever = RetrieverCreator.create_retriever( - retriever, - source=source, - chat_history=[], - prompt=prompt, - chunks=chunks, - doc_token_limit=doc_token_limit, - model_id=model_id, - user_api_key=user_api_key, - agent_id=agent_id, - decoded_token=decoded_token, - ) - - # Pre-fetch documents using the retriever - retrieved_docs = [] - try: - docs = retriever.search(input_data) - if docs: - retrieved_docs = docs - except Exception as e: - logging.warning(f"Failed to retrieve documents: {e}") - - agent = AgentCreator.create_agent( - agent_type, - endpoint="webhook", - llm_name=provider or settings.LLM_PROVIDER, - model_id=model_id, - api_key=system_api_key, - agent_id=agent_id, - user_api_key=user_api_key, - prompt=prompt, - chat_history=[], - retrieved_docs=retrieved_docs, - decoded_token=decoded_token, - attachments=[], - json_schema=json_schema, - ) - answer = agent.gen(query=input_data) - response_full = "" - thought = "" - source_log_docs = [] - tool_calls = [] - - for line in answer: - if "answer" in line: - response_full += str(line["answer"]) - elif "sources" in line: - source_log_docs.extend(line["sources"]) - elif "tool_calls" in line: - tool_calls.extend(line["tool_calls"]) - elif "thought" in line: - thought += line["thought"] - result = { - "answer": response_full, - "sources": source_log_docs, - "tool_calls": tool_calls, - "thought": thought, - } - # Per-activity summary fields (answer_length, thought_length, - # source_count, tool_call_count) now ride on the inner - # ``activity_finished`` event emitted by ``log_activity`` around - # ``Agent.gen`` above; no separate ``agent_response`` log needed. - return result - except Exception as e: - logging.error(f"Error in run_agent_logic: {e}", exc_info=True) - raise - - # Define the main function for ingesting and processing documents. @@ -1685,7 +1541,21 @@ def agent_webhook_worker(self, agent_id, payload): raise self.update_state(state="PROGRESS", meta={"current": 50}) try: - result = run_agent_logic(agent_config, input_data) + # Shared headless path with the scheduler; approval-gated tools auto-deny. + from application.agents.headless_runner import run_agent_headless + + outcome = run_agent_headless( + agent_config, + input_data, + tool_allowlist=_webhook_tool_allowlist(agent_config), + endpoint="webhook", + ) + result = { + "answer": outcome.get("answer", ""), + "sources": outcome.get("sources", []), + "tool_calls": outcome.get("tool_calls", []), + "thought": outcome.get("thought", ""), + } except Exception as e: logging.error(f"Error running agent logic: {e}", exc_info=True) raise @@ -1698,6 +1568,11 @@ def agent_webhook_worker(self, agent_id, payload): self.update_state(state="PROGRESS", meta={"current": 100}) +def _webhook_tool_allowlist(agent_config): + """Deny-all on approval-gated tools for webhooks (per-agent opt-in is TBD).""" + return [] + + def ingest_connector( self, job_name: str, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8e008ef3..db144f91 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -18,6 +18,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "copy-to-clipboard": "^3.3.3", + "date-fns": "^4.2.1", "i18next": "^26.0.4", "i18next-browser-languagedetector": "^8.2.1", "lodash": "^4.18.1", @@ -27,6 +28,7 @@ "radix-ui": "^1.4.3", "react": "^19.1.0", "react-chartjs-2": "^5.3.0", + "react-day-picker": "^10.0.1", "react-dom": "^19.2.5", "react-dropzone": "^15.0.0", "react-google-drive-picker": "^1.2.2", @@ -419,6 +421,12 @@ "integrity": "sha512-lB59uJoaGIfOOL9knQqQRfhl9g7x8/wqFkp13zTdkRu1huG9kg6IJs1O8hqj9rs6h7orGxHJUKb+mX3rPbWGhA==", "license": "Apache-2.0" }, + "node_modules/@date-fns/tz": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.5.0.tgz", + "integrity": "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==", + "license": "MIT" + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -6515,6 +6523,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/date-fns": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.2.1.tgz", + "integrity": "sha512-37RhSdxaG1suen6VDCza6rNrQfooyQh57HFVPwQGEq2QWliVLzPQZ8Oa017weOu+HZCnzI7N3Pf/wyoBKfEqrA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/dayjs": { "version": "1.11.19", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", @@ -11371,6 +11389,32 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/react-day-picker": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-10.0.1.tgz", + "integrity": "sha512-eNh6BlwcYInWaJtRv18mXQ06Ys/H6rdTZAnTaSdOYJuTpwP1JMCHNd1FDRadA+gbeinq+psdULN5Xnowy9mV8w==", + "license": "MIT", + "dependencies": { + "@date-fns/tz": "^1.4.1", + "date-fns": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/gpbl" + }, + "peerDependencies": { + "@types/react": ">=16.8.0", + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/react-dom": { "version": "19.2.5", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", diff --git a/frontend/package.json b/frontend/package.json index e5396327..a9056883 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -31,6 +31,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "copy-to-clipboard": "^3.3.3", + "date-fns": "^4.2.1", "i18next": "^26.0.4", "i18next-browser-languagedetector": "^8.2.1", "lodash": "^4.18.1", @@ -40,6 +41,7 @@ "radix-ui": "^1.4.3", "react": "^19.1.0", "react-chartjs-2": "^5.3.0", + "react-day-picker": "^10.0.1", "react-dom": "^19.2.5", "react-dropzone": "^15.0.0", "react-google-drive-picker": "^1.2.2", diff --git a/frontend/public/toolIcons/tool_scheduler.svg b/frontend/public/toolIcons/tool_scheduler.svg new file mode 100644 index 00000000..1071c65c --- /dev/null +++ b/frontend/public/toolIcons/tool_scheduler.svg @@ -0,0 +1 @@ + diff --git a/frontend/src/agents/AgentLogs.tsx b/frontend/src/agents/AgentLogs.tsx index d6916436..04157412 100644 --- a/frontend/src/agents/AgentLogs.tsx +++ b/frontend/src/agents/AgentLogs.tsx @@ -1,19 +1,18 @@ import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useSelector } from 'react-redux'; -import { useNavigate, useParams } from 'react-router-dom'; +import { useParams } from 'react-router-dom'; import userService from '../api/services/userService'; -import ArrowLeft from '../assets/arrow-left.svg'; import Spinner from '../components/Spinner'; import { selectToken } from '../preferences/preferenceSlice'; import Analytics from '../settings/Analytics'; import Logs from '../settings/Logs'; +import AgentPageHeader from './AgentPageHeader'; import { Agent } from './types'; export default function AgentLogs() { const { t } = useTranslation(); - const navigate = useNavigate(); const { agentId } = useParams(); const token = useSelector(selectToken); @@ -37,24 +36,21 @@ export default function AgentLogs() { useEffect(() => { if (agentId) fetchAgent(agentId); }, [agentId, token]); + + const agentEditPath = + agent?.agent_type === 'workflow' + ? `/agents/workflow/edit/${agentId}` + : `/agents/edit/${agentId}`; + return (
-
- -

- {t('agents.backToAll')} -

-
-
-

- {t('agents.logs.title')} -

-
+
{agent && (
@@ -78,7 +74,6 @@ export default function AgentLogs() { )} {loadingAgent ? (
- {' '}
) : ( diff --git a/frontend/src/agents/AgentPageHeader.tsx b/frontend/src/agents/AgentPageHeader.tsx new file mode 100644 index 00000000..c4505074 --- /dev/null +++ b/frontend/src/agents/AgentPageHeader.tsx @@ -0,0 +1,155 @@ +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Link } from 'react-router-dom'; + +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from '@/components/ui/breadcrumb'; +import { cn } from '@/lib/utils'; + +export type AgentPageTab = 'overview' | 'logs' | 'schedules'; + +type AgentPageHeaderProps = { + agentId?: string; + agentName?: string; + /** Route shape for the agent's own root page. Defaults to classic edit URL. */ + agentEditPath?: string; + currentPage: AgentPageTab; + /** Optional className wrapper for layout tweaks per page. */ + className?: string; + /** + * Drop the 1px baseline border under the tabs row. Use when the header is + * embedded in a container that already provides its own bottom border + * (e.g. the workflow builder's fixed toolbar) to avoid a double rule. + */ + inline?: boolean; +}; + +/** + * Shared chrome for the agent sub-pages (Overview/Edit, Logs, Schedules). + * + * Top: shadcn Breadcrumb (`Agents > > `). + * Bottom: underline-style sub-nav linking between the agent's sub-pages. + */ +export default function AgentPageHeader({ + agentId, + agentName, + agentEditPath, + currentPage, + className, + inline = false, +}: AgentPageHeaderProps) { + const { t } = useTranslation(); + + const editPath = + agentEditPath ?? (agentId ? `/agents/edit/${agentId}` : '/agents'); + const tabs = useMemo( + () => [ + { + id: 'overview' as const, + label: t('agents.pageHeader.tabs.overview'), + href: editPath, + }, + { + id: 'logs' as const, + label: t('agents.pageHeader.tabs.logs'), + href: agentId ? `/agents/logs/${agentId}` : '#', + }, + { + id: 'schedules' as const, + label: t('agents.pageHeader.tabs.schedules'), + href: agentId ? `/agents/schedules/${agentId}` : '#', + }, + ], + [agentId, editPath, t], + ); + + const currentTabLabel = + tabs.find((tab) => tab.id === currentPage)?.label ?? ''; + const displayName = agentName?.trim() || t('agents.pageHeader.fallbackName'); + + return ( +
+ + + + + {t('agents.pageHeader.crumbs.agents')} + + + + + {currentPage === 'overview' ? ( + + {displayName} + + ) : ( + + + {displayName} + + + )} + + {currentPage !== 'overview' && ( + <> + + + {currentTabLabel} + + + )} + + + + +
+ ); +} diff --git a/frontend/src/agents/NewAgent.tsx b/frontend/src/agents/NewAgent.tsx index f12063ec..96d9d6ce 100644 --- a/frontend/src/agents/NewAgent.tsx +++ b/frontend/src/agents/NewAgent.tsx @@ -1,12 +1,20 @@ import isEqual from 'lodash/isEqual'; +import { MoreHorizontal } from 'lucide-react'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useDispatch, useSelector } from 'react-redux'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; + import modelService from '../api/services/modelService'; import userService from '../api/services/userService'; -import ArrowLeft from '../assets/arrow-left.svg'; import SourceIcon from '../assets/source.svg'; import Dropdown from '../components/Dropdown'; import { FileUpload } from '../components/FileUpload'; @@ -29,6 +37,7 @@ import PromptsModal from '../preferences/PromptsModal'; import Prompts from '../settings/Prompts'; import { UserToolType } from '../settings/types'; import { getToolDisplayName } from '../utils/toolUtils'; +import AgentPageHeader from './AgentPageHeader'; import AgentPreview from './AgentPreview'; import { Agent, ToolSummary } from './types'; import WorkflowBuilder from './workflow/WorkflowBuilder'; @@ -113,7 +122,6 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { buttonText: t('agents.form.buttons.publish'), showDelete: false, showSaveDraft: true, - showLogs: false, showAccessDetails: false, trackChanges: false, }, @@ -122,7 +130,6 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { buttonText: t('agents.form.buttons.save'), showDelete: true, showSaveDraft: false, - showLogs: true, showAccessDetails: true, trackChanges: true, }, @@ -131,7 +138,6 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { buttonText: t('agents.form.buttons.publish'), showDelete: true, showSaveDraft: true, - showLogs: false, showAccessDetails: false, trackChanges: false, }, @@ -439,12 +445,29 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { const response = await userService.getUserTools(token); if (!response.ok) throw new Error('Failed to fetch tools'); const data = await response.json(); + // Group ordering: builtins -> defaults -> user tools (sorted via the + // MultiSelectPopup first-appearance grouping). + const groupFor = (tool: UserToolType): string => { + if (tool.builtin) return t('agents.form.toolsPopup.groupBuiltin'); + if (tool.default) return t('agents.form.toolsPopup.groupDefault'); + return t('agents.form.toolsPopup.groupCustom'); + }; const tools: OptionType[] = data.tools.map((tool: UserToolType) => ({ id: tool.id, label: getToolDisplayName(tool), icon: `/toolIcons/tool_${tool.name}.svg`, name: tool.name, + group: groupFor(tool), })); + const groupOrder = [ + t('agents.form.toolsPopup.groupBuiltin'), + t('agents.form.toolsPopup.groupDefault'), + t('agents.form.toolsPopup.groupCustom'), + ]; + tools.sort( + (a, b) => + groupOrder.indexOf(a.group || '') - groupOrder.indexOf(b.group || ''), + ); setUserTools(tools); }; const getModels = async () => { @@ -698,42 +721,34 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { jsonSchemaText !== initialJsonSchemaText; setHasChanges(isChanged); }, [agent, dispatch, effectiveMode, imageFile, jsonSchemaText]); + // Only show the agent sub-nav once the agent has an id (i.e. not the bare + // ``new`` mode). The sub-nav links to Logs/Schedules which require an id. + const showAgentNav = effectiveMode === 'edit' && Boolean(agent.id); + return (
-
- -

- {t('agents.backToAll')} -

-
-
-

- {modeConfig[effectiveMode].heading} -

+ {showAgentNav ? ( + + ) : null} +
{agent.agent_type === 'workflow' && (
)} -
- - {modeConfig[effectiveMode].showDelete && agent.id && ( +
+ {hasChanges && ( )} {modeConfig[effectiveMode].showSaveDraft && ( @@ -753,23 +768,6 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { )} - {modeConfig[effectiveMode].showAccessDetails && ( - - )} - {modeConfig[effectiveMode].showAccessDetails && ( - - )} + {modeConfig[effectiveMode].showAccessDetails && ( + + + + + + setAgentDetails('ACTIVE')}> + {t('agents.form.buttons.accessDetails')} + + + + )}
@@ -1345,6 +1363,29 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
)}
+ {modeConfig[effectiveMode].showDelete && agent.id && ( +
+
+
+

+ {t('agents.form.dangerZone.heading')} +

+

+ {t('agents.form.dangerZone.description')} +

+
+ +
+
+ )}

diff --git a/frontend/src/agents/index.tsx b/frontend/src/agents/index.tsx index 988345ad..1ed0072a 100644 --- a/frontend/src/agents/index.tsx +++ b/frontend/src/agents/index.tsx @@ -3,6 +3,7 @@ import { Route, Routes } from 'react-router-dom'; import AgentLogs from './AgentLogs'; import AgentsList from './AgentsList'; import NewAgent from './NewAgent'; +import SchedulesView from './schedules/SchedulesView'; import SharedAgent from './SharedAgent'; import WorkflowBuilder from './workflow/WorkflowBuilder'; @@ -13,6 +14,7 @@ export default function Agents() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/agents/schedules/RunDetailDrawer.tsx b/frontend/src/agents/schedules/RunDetailDrawer.tsx new file mode 100644 index 00000000..9c05de15 --- /dev/null +++ b/frontend/src/agents/schedules/RunDetailDrawer.tsx @@ -0,0 +1,101 @@ +import { useEffect } from 'react'; + +import type { ScheduleRun } from '../types/schedule'; +import ScheduleStatusBadge from './StatusBadge'; + +export type RunDetailDrawerProps = { + run: ScheduleRun | null; + onClose: () => void; +}; + +const formatTimestamp = (value?: string | null): string => { + if (!value) return '—'; + const d = new Date(value); + if (Number.isNaN(d.getTime())) return value; + return d.toLocaleString(); +}; + +/** Side drawer with a single run's output / error (terminal-state only). */ +export default function RunDetailDrawer({ + run, + onClose, +}: RunDetailDrawerProps) { + useEffect(() => { + if (!run) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + document.addEventListener('keydown', onKey); + return () => document.removeEventListener('keydown', onKey); + }, [run, onClose]); + + if (!run) return null; + return ( + <> +