diff --git a/.bumpversion.cfg b/.bumpversion.cfg index e75000c3f..422d69f62 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 4.5.0a7 +current_version = 4.5.0a8 commit = False tag = False parse = (?P\d+)\.(?P\d+)\.(?P\d+)(rc(?P\d+))? diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 75585f746..360934be6 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -65,6 +65,7 @@ jobs: POSTGRES_PASSWORD: nwa POSTGRES_HOST: postgres ENVIRONMENT: TESTING + SEARCH_ENABLED: true - name: "Upload coverage to Codecov" uses: codecov/codecov-action@v3 diff --git a/orchestrator/__init__.py b/orchestrator/__init__.py index cbbee9278..2df8cc99b 100644 --- a/orchestrator/__init__.py +++ b/orchestrator/__init__.py @@ -13,7 +13,7 @@ """This is the orchestrator workflow engine.""" -__version__ = "4.5.0a7" +__version__ = "4.5.0a8" from structlog import get_logger @@ -25,18 +25,9 @@ from orchestrator.llm_settings import llm_settings from orchestrator.settings import app_settings -if llm_settings.LLM_ENABLED: - try: - from importlib import import_module +if llm_settings.SEARCH_ENABLED or llm_settings.AGENT_ENABLED: - import_module("pydantic_ai") - from orchestrator.agentic_app import AgenticOrchestratorCore as OrchestratorCore - - except ImportError: - logger.error( - "Unable to import 'pydantic_ai' module, please install the orchestrator with llm dependencies. `pip install orchestrator-core[llm]", - ) - exit(1) + from orchestrator.agentic_app import LLMOrchestratorCore as OrchestratorCore else: from orchestrator.app import OrchestratorCore # type: ignore[assignment] diff --git a/orchestrator/agentic_app.py b/orchestrator/agentic_app.py index 726114e8c..a656578be 100644 --- a/orchestrator/agentic_app.py +++ b/orchestrator/agentic_app.py @@ -1,8 +1,8 @@ #!/usr/bin/env python3 """The main application module. -This module contains the main `AgenticOrchestratorCore` class for the `FastAPI` backend and -provides the ability to run the CLI. +This module contains the main `LLMOrchestratorCore` class for the `FastAPI` backend and +provides the ability to run the CLI with LLM features (search and/or agent). """ # Copyright 2019-2025 SURF # Licensed under the Apache License, Version 2.0 (the "License"); @@ -16,68 +16,84 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. -from typing import Any +from typing import TYPE_CHECKING, Any import typer -from pydantic_ai.models.openai import OpenAIModel -from pydantic_ai.toolsets import FunctionToolset from structlog import get_logger from orchestrator.app import OrchestratorCore from orchestrator.cli.main import app as cli_app from orchestrator.llm_settings import LLMSettings, llm_settings +if TYPE_CHECKING: + from pydantic_ai.models.openai import OpenAIModel + from pydantic_ai.toolsets import FunctionToolset + logger = get_logger(__name__) -class AgenticOrchestratorCore(OrchestratorCore): +class LLMOrchestratorCore(OrchestratorCore): def __init__( self, *args: Any, - llm_model: OpenAIModel | str = "gpt-4o-mini", llm_settings: LLMSettings = llm_settings, - agent_tools: list[FunctionToolset] | None = None, + agent_model: "OpenAIModel | str | None" = None, + agent_tools: "list[FunctionToolset] | None" = None, **kwargs: Any, ) -> None: - """Initialize the `AgenticOrchestratorCore` class. + """Initialize the `LLMOrchestratorCore` class. - This class takes the same arguments as the `OrchestratorCore` class. + This class extends `OrchestratorCore` with LLM features (search and agent). + It runs the search migration and mounts the agent endpoint based on feature flags. Args: *args: All the normal arguments passed to the `OrchestratorCore` class. - llm_model: An OpenAI model class or string, not limited to OpenAI models (gpt-4o-mini etc) llm_settings: A class of settings for the LLM + agent_model: Override the agent model (defaults to llm_settings.AGENT_MODEL) agent_tools: A list of tools that can be used by the agent **kwargs: Additional arguments passed to the `OrchestratorCore` class. Returns: None """ - self.llm_model = llm_model - self.agent_tools = agent_tools self.llm_settings = llm_settings + self.agent_model = agent_model or llm_settings.AGENT_MODEL + self.agent_tools = agent_tools super().__init__(*args, **kwargs) - logger.info("Mounting the agent") - self.register_llm_integration() - - def register_llm_integration(self) -> None: - """Register the Agent endpoint. - - This helper includes the agent router on the application with auth dependencies. - - Returns: - None - - """ - from fastapi import Depends - - from orchestrator.search.agent import build_agent_router - from orchestrator.security import authorize - - agent_router = build_agent_router(self.llm_model, self.agent_tools) - self.include_router(agent_router, prefix="/agent", dependencies=[Depends(authorize)]) + # Run search migration if search or agent is enabled + if self.llm_settings.SEARCH_ENABLED or self.llm_settings.AGENT_ENABLED: + logger.info("Running search migration") + try: + from orchestrator.db import db + from orchestrator.search.llm_migration import run_migration + + with db.engine.begin() as connection: + run_migration(connection) + except ImportError as e: + logger.error( + "Unable to run search migration. Please install search dependencies: " + "`pip install orchestrator-core[search]`", + error=str(e), + ) + raise + + # Mount agent endpoint if agent is enabled + if self.llm_settings.AGENT_ENABLED: + logger.info("Initializing agent features", model=self.agent_model) + try: + from orchestrator.search.agent import build_agent_router + + agent_app = build_agent_router(self.agent_model, self.agent_tools) + self.mount("/agent", agent_app) + except ImportError as e: + logger.error( + "Unable to initialize agent features. Please install agent dependencies: " + "`pip install orchestrator-core[agent]`", + error=str(e), + ) + raise main_typer_app = typer.Typer() diff --git a/orchestrator/api/api_v1/api.py b/orchestrator/api/api_v1/api.py index 7797aac91..9994ee5f9 100644 --- a/orchestrator/api/api_v1/api.py +++ b/orchestrator/api/api_v1/api.py @@ -89,7 +89,7 @@ ws.router, prefix="/ws", tags=["Core", "Events"] ) # Auth on the websocket is handled in the Websocket Manager -if llm_settings.LLM_ENABLED: +if llm_settings.SEARCH_ENABLED: from orchestrator.api.api_v1.endpoints import search api_router.include_router( diff --git a/orchestrator/cli/main.py b/orchestrator/cli/main.py index c78ab090d..3fcba1b23 100644 --- a/orchestrator/cli/main.py +++ b/orchestrator/cli/main.py @@ -25,10 +25,21 @@ app.add_typer(database.app, name="db", help="Interact with the application database") app.add_typer(generate.app, name="generate", help="Generate products, workflows and other artifacts") -if llm_settings.LLM_ENABLED: - from orchestrator.cli import search +if llm_settings.SEARCH_ENABLED: + from orchestrator.cli.search import index_llm, resize_embedding, search_explore, speedtest - search.register_commands(app) + app.add_typer(index_llm.app, name="index", help="(Re-)Index the search table.") + app.add_typer(search_explore.app, name="search", help="Try out different search types.") + app.add_typer( + resize_embedding.app, + name="embedding", + help="Resize the vector dimension of the embedding column in the search table.", + ) + app.add_typer( + speedtest.app, + name="speedtest", + help="Search performance testing and analysis.", + ) if __name__ == "__main__": diff --git a/orchestrator/llm_settings.py b/orchestrator/llm_settings.py index 7552ef0dd..e7a39608c 100644 --- a/orchestrator/llm_settings.py +++ b/orchestrator/llm_settings.py @@ -18,7 +18,10 @@ class LLMSettings(BaseSettings): - LLM_ENABLED: bool = False # Default to false + # Feature flags for LLM functionality + SEARCH_ENABLED: bool = False # Enable search/indexing with embeddings + AGENT_ENABLED: bool = False # Enable agentic functionality + # Pydantic-ai Agent settings AGENT_MODEL: str = "gpt-4o-mini" # See pydantic-ai docs for supported models. AGENT_MODEL_VERSION: str = "2025-01-01-preview" @@ -30,11 +33,11 @@ class LLMSettings(BaseSettings): 0.1, description="Safety margin as a percentage (e.g., 0.1 for 10%) for token budgeting.", ge=0, le=1 ) - # The following settings are only needed for local models. + # The following settings are only needed for local models or system constraints. # By default, they are set conservative assuming a small model like All-MiniLM-L6-V2. OPENAI_BASE_URL: str | None = None EMBEDDING_FALLBACK_MAX_TOKENS: int | None = 512 - EMBEDDING_MAX_BATCH_SIZE: int | None = 32 + EMBEDDING_MAX_BATCH_SIZE: int | None = None # General LiteLLM settings LLM_MAX_RETRIES: int = 3 diff --git a/orchestrator/migrations/versions/schema/2025-08-12_52b37b5b2714_search_index_model_for_llm_integration.py b/orchestrator/migrations/versions/schema/2025-08-12_52b37b5b2714_search_index_model_for_llm_integration.py deleted file mode 100644 index 1bb8a13aa..000000000 --- a/orchestrator/migrations/versions/schema/2025-08-12_52b37b5b2714_search_index_model_for_llm_integration.py +++ /dev/null @@ -1,95 +0,0 @@ -"""Search index model for llm integration. - -Revision ID: 52b37b5b2714 -Revises: 850dccac3b02 -Create Date: 2025-08-12 22:34:26.694750 - -""" - -import sqlalchemy as sa -from alembic import op -from pgvector.sqlalchemy import Vector -from sqlalchemy.dialects import postgresql -from sqlalchemy_utils import LtreeType - -from orchestrator.search.core.types import FieldType - -# revision identifiers, used by Alembic. -revision = "52b37b5b2714" -down_revision = "850dccac3b02" -branch_labels = None -depends_on = None - -TABLE = "ai_search_index" -IDX_EMBED_HNSW = "ix_flat_embed_hnsw" -IDX_PATH_GIST = "ix_flat_path_gist" -IDX_PATH_BTREE = "ix_flat_path_btree" -IDX_VALUE_TRGM = "ix_flat_value_trgm" -IDX_CONTENT_HASH = "idx_ai_search_index_content_hash" - -TARGET_DIM = 1536 - - -def upgrade() -> None: - # Create PostgreSQL extensions - op.execute("CREATE EXTENSION IF NOT EXISTS ltree;") - op.execute("CREATE EXTENSION IF NOT EXISTS unaccent;") - op.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm;") - op.execute("CREATE EXTENSION IF NOT EXISTS vector;") - - # Create the ai_search_index table - op.create_table( - TABLE, - sa.Column("entity_type", sa.Text, nullable=False), - sa.Column("entity_id", postgresql.UUID, nullable=False), - sa.Column("path", LtreeType, nullable=False), - sa.Column("value", sa.Text, nullable=False), - sa.Column("embedding", Vector(TARGET_DIM), nullable=True), - sa.Column("content_hash", sa.String(64), nullable=False), - sa.PrimaryKeyConstraint("entity_id", "path", name="pk_ai_search_index"), - ) - - field_type_enum = sa.Enum(*[ft.value for ft in FieldType], name="field_type") - field_type_enum.create(op.get_bind(), checkfirst=True) - op.add_column( - TABLE, - sa.Column("value_type", field_type_enum, nullable=False, server_default=FieldType.STRING.value), - ) - op.alter_column(TABLE, "value_type", server_default=None) - - op.create_index(op.f("ix_ai_search_index_entity_id"), TABLE, ["entity_id"], unique=False) - op.create_index(IDX_CONTENT_HASH, TABLE, ["content_hash"]) - - op.create_index( - IDX_PATH_GIST, - TABLE, - ["path"], - postgresql_using="GIST", - postgresql_ops={"path": "gist_ltree_ops"}, - ) - op.create_index(IDX_PATH_BTREE, TABLE, ["path"]) - op.create_index(IDX_VALUE_TRGM, TABLE, ["value"], postgresql_using="GIN", postgresql_ops={"value": "gin_trgm_ops"}) - - op.create_index( - IDX_EMBED_HNSW, - TABLE, - ["embedding"], - postgresql_using="HNSW", - postgresql_with={"m": 16, "ef_construction": 64}, - postgresql_ops={"embedding": "vector_l2_ops"}, - ) - - -def downgrade() -> None: - # Drop all indexes - op.drop_index(IDX_EMBED_HNSW, table_name=TABLE, if_exists=True) - op.drop_index(IDX_VALUE_TRGM, table_name=TABLE, if_exists=True) - op.drop_index(IDX_PATH_BTREE, table_name=TABLE, if_exists=True) - op.drop_index(IDX_PATH_GIST, table_name=TABLE, if_exists=True) - op.drop_index(IDX_CONTENT_HASH, table_name=TABLE, if_exists=True) - op.drop_index(op.f("ix_ai_search_index_entity_id"), table_name=TABLE, if_exists=True) - - # Drop table and enum - op.drop_table(TABLE, if_exists=True) - field_type_enum = sa.Enum(name="field_type") - field_type_enum.drop(op.get_bind(), checkfirst=True) diff --git a/orchestrator/search/docs/running_local_text_embedding_inference.md b/orchestrator/search/docs/running_local_text_embedding_inference.md index 82f2621c5..122c5b9cf 100644 --- a/orchestrator/search/docs/running_local_text_embedding_inference.md +++ b/orchestrator/search/docs/running_local_text_embedding_inference.md @@ -18,6 +18,7 @@ Point your backend to the local endpoint and declare the new vector size: ```env OPENAI_BASE_URL=http://localhost:8080/v1 EMBEDDING_DIMENSION=384 +EMBEDDING_MAX_BATCH_SIZE=32 # Not required when using OpenAI embeddings ``` Depending on the model, you might want to change the `EMBEDDING_FALLBACK_MAX_TOKENS` and `EMBEDDING_MAX_BATCH_SIZE` settings, which are set conservatively and according to the requirements of the setup used in this example. diff --git a/orchestrator/search/indexing/indexer.py b/orchestrator/search/indexing/indexer.py index b739b12cf..1f5ae23d8 100644 --- a/orchestrator/search/indexing/indexer.py +++ b/orchestrator/search/indexing/indexer.py @@ -226,9 +226,7 @@ def _generate_upsert_batches( safe_margin = int(max_ctx * llm_settings.EMBEDDING_SAFE_MARGIN_PERCENT) token_budget = max(1, max_ctx - safe_margin) - max_batch_size = None - if llm_settings.OPENAI_BASE_URL: # We are using a local model - max_batch_size = llm_settings.EMBEDDING_MAX_BATCH_SIZE + max_batch_size = llm_settings.EMBEDDING_MAX_BATCH_SIZE for entity_id, field in fields_to_upsert: if field.value_type.is_embeddable(field.value): diff --git a/orchestrator/search/llm_migration.py b/orchestrator/search/llm_migration.py new file mode 100644 index 000000000..bfe5831a7 --- /dev/null +++ b/orchestrator/search/llm_migration.py @@ -0,0 +1,102 @@ +# Copyright 2019-2025 SURF +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Simple search migration function that runs when SEARCH_ENABLED = True.""" + +from sqlalchemy import text +from sqlalchemy.engine import Connection +from structlog import get_logger + +from orchestrator.search.core.types import FieldType + +logger = get_logger(__name__) + +TABLE = "ai_search_index" +TARGET_DIM = 1536 + + +def run_migration(connection: Connection) -> None: + """Run LLM migration with ON CONFLICT DO NOTHING pattern.""" + logger.info("Running LLM migration") + + try: + # Create PostgreSQL extensions + connection.execute(text("CREATE EXTENSION IF NOT EXISTS ltree;")) + connection.execute(text("CREATE EXTENSION IF NOT EXISTS unaccent;")) + connection.execute(text("CREATE EXTENSION IF NOT EXISTS pg_trgm;")) + connection.execute(text("CREATE EXTENSION IF NOT EXISTS vector;")) + + # Create field_type enum + field_type_values = "', '".join([ft.value for ft in FieldType]) + connection.execute( + text( + f""" + DO $$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'field_type') THEN + CREATE TYPE field_type AS ENUM ('{field_type_values}'); + END IF; + END $$; + """ + ) + ) + + # Create table with ON CONFLICT DO NOTHING pattern + connection.execute( + text( + f""" + CREATE TABLE IF NOT EXISTS {TABLE} ( + entity_type TEXT NOT NULL, + entity_id UUID NOT NULL, + path LTREE NOT NULL, + value TEXT NOT NULL, + embedding VECTOR({TARGET_DIM}), + content_hash VARCHAR(64) NOT NULL, + value_type field_type NOT NULL DEFAULT '{FieldType.STRING.value}', + CONSTRAINT pk_ai_search_index PRIMARY KEY (entity_id, path) + ); + """ + ) + ) + + # Drop default + connection.execute(text(f"ALTER TABLE {TABLE} ALTER COLUMN value_type DROP DEFAULT;")) + + # Create indexes with IF NOT EXISTS + connection.execute(text(f"CREATE INDEX IF NOT EXISTS ix_ai_search_index_entity_id ON {TABLE} (entity_id);")) + connection.execute( + text(f"CREATE INDEX IF NOT EXISTS idx_ai_search_index_content_hash ON {TABLE} (content_hash);") + ) + connection.execute( + text(f"CREATE INDEX IF NOT EXISTS ix_flat_path_gist ON {TABLE} USING GIST (path gist_ltree_ops);") + ) + connection.execute(text(f"CREATE INDEX IF NOT EXISTS ix_flat_path_btree ON {TABLE} (path);")) + connection.execute( + text(f"CREATE INDEX IF NOT EXISTS ix_flat_value_trgm ON {TABLE} USING GIN (value gin_trgm_ops);") + ) + connection.execute( + text( + f"CREATE INDEX IF NOT EXISTS ix_flat_embed_hnsw ON {TABLE} USING HNSW (embedding vector_l2_ops) WITH (m = 16, ef_construction = 64);" + ) + ) + + connection.commit() + logger.info("LLM migration completed successfully") + + except Exception as e: + logger.error("LLM migration failed", error=str(e)) + raise Exception( + f"LLM migration failed. This likely means the pgvector extension " + f"is not installed. Please install pgvector and ensure your PostgreSQL " + f"version supports it. Error: {e}" + ) from e diff --git a/orchestrator/workflows/steps.py b/orchestrator/workflows/steps.py index d5a9560e3..f06aa17ee 100644 --- a/orchestrator/workflows/steps.py +++ b/orchestrator/workflows/steps.py @@ -156,7 +156,7 @@ def refresh_subscription_search_index(subscription: SubscriptionModel | None) -> """ try: reset_search_index() - if llm_settings.LLM_ENABLED and subscription: + if llm_settings.SEARCH_ENABLED and subscription: run_indexing_for_entity(EntityType.SUBSCRIPTION, str(subscription.subscription_id)) except Exception: # Don't fail workflow in case of unexpected error diff --git a/pyproject.toml b/pyproject.toml index 4a3bea396..1d2cb5c6f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -79,7 +79,10 @@ Source = "https://github.com/workfloworchestrator/orchestrator-core" celery = [ "celery~=5.5.1", ] -llm = [ +search = [ + "litellm>=1.75.7", +] +agent = [ "pydantic-ai-slim ==0.7.0", "ag-ui-protocol>=0.1.8", "litellm>=1.75.7", @@ -224,6 +227,7 @@ ban-relative-imports = "all" "orchestrator/devtools/scripts/*" = ["S101", "T201"] "test/*" = ["S101", "B033", "N816", "N802", "T201"] "orchestrator/__init__.py" = ["E402"] +"orchestrator/search/llm_migration.py" = ["S608"] [tool.ruff.lint.pydocstyle] convention = "google" diff --git a/setup.cfg b/setup.cfg index ea2de816b..fabd92cf6 100644 --- a/setup.cfg +++ b/setup.cfg @@ -25,6 +25,8 @@ markers= acceptance: Acceptance Tests. Needs special handling. regression: Tests that cover bugs that are fixed to prevent them from regressing celery: Tests that require celery functionality + search: Tests that require search/indexing functionality and the search extra dependencies + agent: Tests that require agentic functionality and the agent extra dependencies [mypy] diff --git a/test/unit_tests/search/conftest.py b/test/unit_tests/search/conftest.py index 79ade0d1b..d32c5d41a 100644 --- a/test/unit_tests/search/conftest.py +++ b/test/unit_tests/search/conftest.py @@ -42,6 +42,19 @@ SimpleSubscription, ) +# Mark all tests in this directory with the search marker +pytestmark = pytest.mark.search + + +def pytest_ignore_collect(collection_path, config): + """Ignore collecting tests from this directory when search is disabled.""" + from orchestrator.llm_settings import llm_settings + + # Skip this entire directory if search is disabled + if not llm_settings.SEARCH_ENABLED: + return True + return False + def pytest_addoption(parser): parser.addoption( diff --git a/uv.lock b/uv.lock index 769e7f574..72de34b56 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.11, <3.14" resolution-markers = [ "python_full_version >= '3.13'", @@ -1911,13 +1911,16 @@ dependencies = [ ] [package.optional-dependencies] +agent = [ + { name = "ag-ui-protocol" }, + { name = "litellm" }, + { name = "pydantic-ai-slim" }, +] celery = [ { name = "celery" }, ] -llm = [ - { name = "ag-ui-protocol" }, +search = [ { name = "litellm" }, - { name = "pydantic-ai-slim" }, ] [package.dev-dependencies] @@ -1976,7 +1979,7 @@ docs = [ [package.metadata] requires-dist = [ - { name = "ag-ui-protocol", marker = "extra == 'llm'", specifier = ">=0.1.8" }, + { name = "ag-ui-protocol", marker = "extra == 'agent'", specifier = ">=0.1.8" }, { name = "alembic", specifier = "==1.16.1" }, { name = "anyio", specifier = ">=3.7.0" }, { name = "apscheduler", specifier = ">=3.11.0" }, @@ -1988,7 +1991,8 @@ requires-dist = [ { name = "fastapi-etag", specifier = "==0.4.0" }, { name = "itsdangerous", specifier = ">=2.2.0" }, { name = "jinja2", specifier = "==3.1.6" }, - { name = "litellm", marker = "extra == 'llm'", specifier = ">=1.75.7" }, + { name = "litellm", marker = "extra == 'agent'", specifier = ">=1.75.7" }, + { name = "litellm", marker = "extra == 'search'", specifier = ">=1.75.7" }, { name = "more-itertools", specifier = "~=10.7.0" }, { name = "nwa-stdlib", specifier = "~=1.9.2" }, { name = "oauth2-lib", specifier = ">=2.4.1" }, @@ -1997,7 +2001,7 @@ requires-dist = [ { name = "prometheus-client", specifier = "==0.22.1" }, { name = "psycopg2-binary", specifier = "==2.9.10" }, { name = "pydantic", extras = ["email"], specifier = "~=2.11.7" }, - { name = "pydantic-ai-slim", marker = "extra == 'llm'", specifier = "==0.7.0" }, + { name = "pydantic-ai-slim", marker = "extra == 'agent'", specifier = "==0.7.0" }, { name = "pydantic-forms", specifier = ">=1.4.0" }, { name = "pydantic-settings", specifier = "~=2.9.1" }, { name = "python-dateutil", specifier = "==2.8.2" }, @@ -2014,7 +2018,7 @@ requires-dist = [ { name = "typer", specifier = "==0.15.4" }, { name = "uvicorn", extras = ["standard"], specifier = "~=0.34.0" }, ] -provides-extras = ["celery", "llm"] +provides-extras = ["agent", "celery", "search"] [package.metadata.requires-dev] dev = [