From d331d09e053faed93bb3d18b2e42b24250adaaed Mon Sep 17 00:00:00 2001 From: Kondra Nagabhavani Date: Mon, 9 Mar 2026 12:39:48 +0000 Subject: [PATCH 1/2] Added sync and async api tests for Agent feature --- tests/agents/test_3001_async_tools.py | 737 +++++++++++++++++++++++ tests/agents/test_3001_tools.py | 470 +++++++++++++++ tests/agents/test_3101_async_tasks.py | 312 ++++++++++ tests/agents/test_3101_tasks.py | 281 +++++++++ tests/agents/test_3201_agents.py | 397 ++++++++++++ tests/agents/test_3201_async_agents.py | 284 +++++++++ tests/agents/test_3301_async_teams.py | 385 ++++++++++++ tests/agents/test_3301_teams.py | 415 +++++++++++++ tests/agents/test_3800_agente2e.py | 231 +++++++ tests/agents/test_3800_async_agente2e.py | 337 +++++++++++ 10 files changed, 3849 insertions(+) create mode 100644 tests/agents/test_3001_async_tools.py create mode 100644 tests/agents/test_3001_tools.py create mode 100644 tests/agents/test_3101_async_tasks.py create mode 100644 tests/agents/test_3101_tasks.py create mode 100644 tests/agents/test_3201_agents.py create mode 100644 tests/agents/test_3201_async_agents.py create mode 100644 tests/agents/test_3301_async_teams.py create mode 100644 tests/agents/test_3301_teams.py create mode 100644 tests/agents/test_3800_agente2e.py create mode 100644 tests/agents/test_3800_async_agente2e.py diff --git a/tests/agents/test_3001_async_tools.py b/tests/agents/test_3001_async_tools.py new file mode 100644 index 0000000..63d6af4 --- /dev/null +++ b/tests/agents/test_3001_async_tools.py @@ -0,0 +1,737 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2025, Oracle and/or its affiliates. +# +# Licensed under the Universal Permissive License v 1.0 as shown at +# http://oss.oracle.com/licenses/upl. +# ----------------------------------------------------------------------------- + +""" +3001 - Async API coverage for select_ai.agent AsyncTool APIs +""" + +import logging +import os +import uuid + +import oracledb +import pytest +import select_ai +from select_ai.agent import AsyncTool +from select_ai.errors import AgentToolNotFoundError + +pytestmark = pytest.mark.anyio + +# Path +PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")) +LOG_FILE = os.path.join(PROJECT_ROOT, "log", "tkex_test_3001_async_tools.log") +os.makedirs(os.path.dirname(LOG_FILE), exist_ok=True) + +# Force logging to file (pytest-proof) +root = logging.getLogger() +root.setLevel(logging.INFO) +for handler in root.handlers[:]: + root.removeHandler(handler) +file_handler = logging.FileHandler(LOG_FILE, mode="w") +file_handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s")) +root.addHandler(file_handler) +logger = logging.getLogger() + +UUID = uuid.uuid4().hex.upper() + +SQL_PROFILE_NAME = f"PYSAI_3001_SQL_PROFILE_{UUID}" +RAG_PROFILE_NAME = f"PYSAI_3001_RAG_PROFILE_{UUID}" + +SQL_TOOL_NAME = f"PYSAI_3001_SQL_TOOL_{UUID}" +RAG_TOOL_NAME = f"PYSAI_3001_RAG_TOOL_{UUID}" +PLSQL_TOOL_NAME = f"PYSAI_3001_PLSQL_TOOL_{UUID}" +WEB_SEARCH_TOOL_NAME = f"PYSAI_3001_WEB_TOOL_{UUID}" +PLSQL_FUNCTION_NAME = f"PYSAI_3001_CALC_AGE_{UUID}" +CUSTOM_ATTR_TOOL_NAME = f"PYSAI_3001_CUSTOM_ATTR_TOOL_{UUID}" +CUSTOM_ATTR_TOOL_DESCRIPTION = "Custom attr tool for async testing" +CUSTOM_NO_TYPE_TOOL_NAME = f"PYSAI_3001_CUSTOM_NO_TYPE_TOOL_{UUID}" +CUSTOM_WITH_TYPE_NO_INSTR_TOOL_NAME = ( + f"PYSAI_3001_CUSTOM_WITH_TYPE_NO_INSTR_TOOL_{UUID}" +) +CUSTOM_WITH_TYPE_AND_INSTR_TOOL_NAME = ( + f"PYSAI_3001_CUSTOM_WITH_TYPE_AND_INSTR_TOOL_{UUID}" +) +DISABLED_TOOL_NAME = f"PYSAI_3001_DISABLED_TOOL_{UUID}" +DEFAULT_STATUS_TOOL_NAME = f"PYSAI_3001_DEFAULT_STATUS_TOOL_{UUID}" +DROP_FORCE_MISSING_TOOL = f"PYSAI_3001_DROP_MISSING_{UUID}" + +EMAIL_TOOL_NAME = f"PYSAI_3001_EMAIL_TOOL_{UUID}" +SLACK_TOOL_NAME = f"PYSAI_3001_SLACK_TOOL_{UUID}" + +NEG_SQL_TOOL_NAME = f"PYSAI_3001_NEG_SQL_TOOL_{UUID}" +NEG_RAG_TOOL_NAME = f"PYSAI_3001_NEG_RAG_TOOL_{UUID}" +NEG_PLSQL_TOOL_NAME = f"PYSAI_3001_NEG_PLSQL_TOOL_{UUID}" + +EMAIL_CRED_NAME = f"PYSAI_3001_EMAIL_CRED_{UUID}" +SLACK_CRED_NAME = f"PYSAI_3001_SLACK_CRED_{UUID}" + +SMTP_USERNAME = os.getenv("PYSAI_TEST_EMAIL_CRED_USERNAME") +SMTP_PASSWORD = os.getenv("PYSAI_TEST_EMAIL_CRED_PASSWORD") +SLACK_USERNAME = os.getenv("PYSAI_TEST_SLACK_USERNAME") +SLACK_PASSWORD = os.getenv("PYSAI_TEST_SLACK_PASSWORD") + + +@pytest.fixture(autouse=True) +def log_test_name(request): + logger.info("--- Starting test: %s ---", request.function.__name__) + yield + logger.info("--- Finished test: %s ---", request.function.__name__) + + +@pytest.fixture(scope="module", autouse=True) +async def async_connect(test_env): + logger.info("Opening async database connection") + await select_ai.async_connect(**test_env.connect_params()) + yield + logger.info("Closing async database connection") + await select_ai.async_disconnect() + + +async def get_tool_status(tool_name): + logger.info("Fetching tool status for: %s", tool_name) + async with select_ai.async_cursor() as cur: + await cur.execute( + """ + SELECT status + FROM USER_AI_AGENT_TOOLS + WHERE tool_name = :tool_name + """, + {"tool_name": tool_name}, + ) + row = await cur.fetchone() + return row[0] if row else None + + +async def assert_tool_status(tool_name: str, expected_status: str) -> None: + status = await get_tool_status(tool_name) + logger.info( + "Verifying tool status | tool=%s | expected=%s | actual=%s", + tool_name, + expected_status, + status, + ) + assert status == expected_status + + +def log_tool_details(context: str, tool) -> None: + attrs = getattr(tool, "attributes", None) + tool_params = getattr(attrs, "tool_params", None) if attrs else None + + details = { + "context": context, + "tool_name": getattr(tool, "tool_name", None), + "description": getattr(tool, "description", None), + "tool_type": str(getattr(attrs, "tool_type", None)) if attrs else None, + "instruction": getattr(attrs, "instruction", None) if attrs else None, + "function": getattr(attrs, "function", None) if attrs else None, + "tool_inputs": getattr(attrs, "tool_inputs", None) if attrs else None, + "tool_params": tool_params.dict(exclude_null=False) + if tool_params is not None + else None, + } + + logger.info("TOOL_DETAILS: %s", details) + print("TOOL_DETAILS:", details) + + +@pytest.fixture(scope="module") +async def sql_profile(profile_attributes): + logger.info("Creating SQL profile: %s", SQL_PROFILE_NAME) + profile = await select_ai.AsyncProfile( + profile_name=SQL_PROFILE_NAME, + description="SQL Profile", + attributes=profile_attributes, + ) + yield profile + logger.info("Deleting SQL profile: %s", SQL_PROFILE_NAME) + await profile.delete(force=True) + + +@pytest.fixture(scope="module") +async def rag_profile(rag_profile_attributes): + logger.info("Creating RAG profile: %s", RAG_PROFILE_NAME) + profile = await select_ai.AsyncProfile( + profile_name=RAG_PROFILE_NAME, + description="RAG Profile", + attributes=rag_profile_attributes, + ) + yield profile + logger.info("Deleting RAG profile: %s", RAG_PROFILE_NAME) + await profile.delete(force=True) + + +@pytest.fixture(scope="module") +async def sql_tool(sql_profile): + logger.info("Creating SQL tool: %s", SQL_TOOL_NAME) + tool = await AsyncTool.create_sql_tool( + tool_name=SQL_TOOL_NAME, + profile_name=SQL_PROFILE_NAME, + description="SQL Tool", + replace=True, + ) + yield tool + logger.info("Deleting SQL tool: %s", SQL_TOOL_NAME) + await tool.delete(force=True) + + +@pytest.fixture(scope="module") +async def rag_tool(rag_profile): + logger.info("Creating RAG tool: %s", RAG_TOOL_NAME) + tool = await AsyncTool.create_rag_tool( + tool_name=RAG_TOOL_NAME, + profile_name=RAG_PROFILE_NAME, + description="RAG Tool", + replace=True, + ) + yield tool + logger.info("Deleting RAG tool: %s", RAG_TOOL_NAME) + await tool.delete(force=True) + + +@pytest.fixture(scope="module") +async def plsql_function(): + logger.info("Creating PL/SQL function: %s", PLSQL_FUNCTION_NAME) + ddl = f""" + CREATE OR REPLACE FUNCTION {PLSQL_FUNCTION_NAME}(p_birth_date DATE) + RETURN NUMBER IS + BEGIN + RETURN TRUNC(MONTHS_BETWEEN(SYSDATE, p_birth_date) / 12); + END; + """ + + async with select_ai.async_cursor() as cur: + await cur.execute(ddl) + + yield + + logger.info("Dropping PL/SQL function: %s", PLSQL_FUNCTION_NAME) + async with select_ai.async_cursor() as cur: + await cur.execute(f"DROP FUNCTION {PLSQL_FUNCTION_NAME}") + + +@pytest.fixture(scope="module") +async def plsql_tool(plsql_function): + logger.info("Creating PL/SQL tool: %s", PLSQL_TOOL_NAME) + tool = await AsyncTool.create_pl_sql_tool( + tool_name=PLSQL_TOOL_NAME, + function=PLSQL_FUNCTION_NAME, + description="PL/SQL Tool", + replace=True, + ) + yield tool + logger.info("Deleting PL/SQL tool: %s", PLSQL_TOOL_NAME) + await tool.delete(force=True) + + +@pytest.fixture(scope="module") +async def web_search_tool(): + logger.info("Creating Web Search tool: %s", WEB_SEARCH_TOOL_NAME) + tool = await AsyncTool.create_websearch_tool( + tool_name=WEB_SEARCH_TOOL_NAME, + description="Web Search Tool for testing", + credential_name="OPENAI_CRED", + replace=True, + ) + yield tool + logger.info("Deleting Web Search tool: %s", WEB_SEARCH_TOOL_NAME) + await tool.delete(force=True) + + +@pytest.fixture(scope="module") +async def email_credential(): + logger.info("Ensuring EMAIL credential is clean: %s", EMAIL_CRED_NAME) + credential = { + "credential_name": EMAIL_CRED_NAME, + "username": SMTP_USERNAME, + "password": SMTP_PASSWORD, + } + + try: + await select_ai.async_delete_credential(EMAIL_CRED_NAME, force=True) + except Exception: + logger.info("EMAIL credential did not exist or could not be dropped") + pass + + await select_ai.async_create_credential(credential=credential, replace=True) + logger.info("Created EMAIL credential: %s", EMAIL_CRED_NAME) + yield EMAIL_CRED_NAME + + logger.info("Deleting EMAIL credential: %s", EMAIL_CRED_NAME) + try: + await select_ai.async_delete_credential(EMAIL_CRED_NAME, force=True) + except Exception: + logger.warning("Failed to delete EMAIL credential during teardown") + pass + + +@pytest.fixture(scope="module") +async def slack_credential(): + logger.info("Ensuring SLACK credential is clean: %s", SLACK_CRED_NAME) + credential = { + "credential_name": SLACK_CRED_NAME, + "username": SLACK_USERNAME, + "password": SLACK_PASSWORD, + } + + try: + await select_ai.async_delete_credential(SLACK_CRED_NAME, force=True) + except Exception: + logger.info("SLACK credential did not exist or could not be dropped") + pass + + await select_ai.async_create_credential(credential=credential, replace=True) + logger.info("Created SLACK credential: %s", SLACK_CRED_NAME) + yield SLACK_CRED_NAME + + logger.info("Deleting SLACK credential: %s", SLACK_CRED_NAME) + try: + await select_ai.async_delete_credential(SLACK_CRED_NAME, force=True) + except Exception: + logger.warning("Failed to delete SLACK credential during teardown") + pass + + +@pytest.fixture(scope="module") +async def email_tool(email_credential): + logger.info("Creating EMAIL tool: %s", EMAIL_TOOL_NAME) + tool = await AsyncTool.create_email_notification_tool( + tool_name=EMAIL_TOOL_NAME, + credential_name=EMAIL_CRED_NAME, + recipient="kondra.nagabhavani@oracle.com", + sender="bharadwaj.vulugundam@oracle.com", + smtp_host="smtp.email.us-ashburn-1.oci.oraclecloud.com", + description="Send email", + replace=True, + ) + yield tool + logger.info("Deleting EMAIL tool: %s", EMAIL_TOOL_NAME) + await tool.delete(force=True) + + +@pytest.fixture(scope="module") +async def slack_tool(slack_credential): + logger.info("Creating SLACK tool: %s", SLACK_TOOL_NAME) + tool = None + try: + tool = await AsyncTool.create_slack_notification_tool( + tool_name=SLACK_TOOL_NAME, + credential_name=SLACK_CRED_NAME, + slack_channel="#general", + description="slack notification", + replace=True, + ) + logger.info("SLACK tool created successfully: %s", SLACK_TOOL_NAME) + yield tool + except oracledb.DatabaseError as e: + if "ORA-20052" in str(e): + logger.info("Expected ORA-20052 during SLACK tool creation: %s", e) + yield None + else: + raise + finally: + if tool is not None: + logger.info("Deleting SLACK tool: %s", SLACK_TOOL_NAME) + await tool.delete(force=True) + + +@pytest.fixture(scope="module") +async def neg_sql_tool(): + logger.info("Creating SQL tool with invalid profile: %s", NEG_SQL_TOOL_NAME) + tool = await AsyncTool.create_sql_tool( + tool_name=NEG_SQL_TOOL_NAME, + profile_name="NON_EXISTENT_PROFILE", + replace=True, + ) + yield tool + logger.info("Deleting SQL tool with invalid profile: %s", NEG_SQL_TOOL_NAME) + await tool.delete(force=True) + + +@pytest.fixture(scope="module") +async def neg_rag_tool(): + logger.info("Creating RAG tool with invalid profile: %s", NEG_RAG_TOOL_NAME) + tool = await AsyncTool.create_rag_tool( + tool_name=NEG_RAG_TOOL_NAME, + profile_name="NON_EXISTENT_RAG_PROFILE", + replace=True, + ) + yield tool + logger.info("Deleting RAG tool with invalid profile: %s", NEG_RAG_TOOL_NAME) + await tool.delete(force=True) + + +@pytest.fixture(scope="module") +async def neg_plsql_tool(): + logger.info( + "Creating PL/SQL tool with invalid function: %s", NEG_PLSQL_TOOL_NAME + ) + tool = await AsyncTool.create_pl_sql_tool( + tool_name=NEG_PLSQL_TOOL_NAME, + function="NON_EXISTENT_FUNCTION", + replace=True, + ) + yield tool + logger.info( + "Deleting PL/SQL tool with invalid function: %s", NEG_PLSQL_TOOL_NAME + ) + await tool.delete(force=True) + + +async def test_3000_sql_tool_created(sql_tool): + logger.info("Validating SQL tool creation: %s", SQL_TOOL_NAME) + log_tool_details("test_3000_sql_tool_created", sql_tool) + assert sql_tool.tool_name == SQL_TOOL_NAME + assert sql_tool.description == "SQL Tool" + assert sql_tool.attributes.tool_type == select_ai.agent.ToolType.SQL + assert sql_tool.attributes.tool_params is not None + assert sql_tool.attributes.tool_params.profile_name == SQL_PROFILE_NAME + + +async def test_3001_rag_tool_created(rag_tool): + logger.info("Validating RAG tool creation: %s", RAG_TOOL_NAME) + log_tool_details("test_3001_rag_tool_created", rag_tool) + assert rag_tool.tool_name == RAG_TOOL_NAME + assert rag_tool.description == "RAG Tool" + assert rag_tool.attributes.tool_type == select_ai.agent.ToolType.RAG + assert rag_tool.attributes.tool_params is not None + assert rag_tool.attributes.tool_params.profile_name == RAG_PROFILE_NAME + + +async def test_3002_plsql_tool_created(plsql_tool): + logger.info("Validating PL/SQL tool creation: %s", PLSQL_TOOL_NAME) + log_tool_details("test_3002_plsql_tool_created", plsql_tool) + assert plsql_tool.tool_name == PLSQL_TOOL_NAME + assert plsql_tool.description == "PL/SQL Tool" + assert plsql_tool.attributes.tool_type is None + assert plsql_tool.attributes.function == PLSQL_FUNCTION_NAME + + +async def test_3003_list_tools(): + logger.info("Listing all tools") + tools = [tool async for tool in AsyncTool.list()] + for tool in tools: + if tool.tool_name in {SQL_TOOL_NAME, RAG_TOOL_NAME, PLSQL_TOOL_NAME}: + log_tool_details("test_3003_list_tools", tool) + tool_names = {tool.tool_name for tool in tools} + logger.info("Tools present: %s", tool_names) + assert len(tools) >= 3 + assert SQL_TOOL_NAME in tool_names + assert RAG_TOOL_NAME in tool_names + assert PLSQL_TOOL_NAME in tool_names + + +async def test_3004_list_tools_regex(): + logger.info("Listing tools with regex: ^PYSAI_3001_") + tools = [ + tool async for tool in AsyncTool.list(tool_name_pattern="^PYSAI_3001_") + ] + for tool in tools: + log_tool_details("test_3004_list_tools_regex", tool) + tool_names = {tool.tool_name for tool in tools} + logger.info("Matched tools: %s", tool_names) + assert len(tools) >= 3 + assert SQL_TOOL_NAME in tool_names + assert RAG_TOOL_NAME in tool_names + assert PLSQL_TOOL_NAME in tool_names + + +async def test_3005_fetch_tool(): + logger.info("Fetching SQL tool: %s", SQL_TOOL_NAME) + tool = await AsyncTool.fetch(SQL_TOOL_NAME) + logger.info( + "Fetched SQL tool | name=%s | type=%s | profile=%s", + tool.tool_name, + tool.attributes.tool_type, + tool.attributes.tool_params.profile_name, + ) + log_tool_details("test_3005_fetch_tool", tool) + assert tool.tool_name == SQL_TOOL_NAME + assert tool.attributes.tool_type == select_ai.agent.ToolType.SQL + assert tool.attributes.tool_params.profile_name == SQL_PROFILE_NAME + + +async def test_3006_enable_disable_sql_tool(sql_tool): + logger.info("Disabling SQL tool: %s", sql_tool.tool_name) + await sql_tool.disable() + await assert_tool_status(sql_tool.tool_name, "DISABLED") + + logger.info("Enabling SQL tool: %s", sql_tool.tool_name) + await sql_tool.enable() + await assert_tool_status(sql_tool.tool_name, "ENABLED") + + +async def test_3007_web_search_tool_created(web_search_tool): + logger.info("Validating Web Search tool creation: %s", WEB_SEARCH_TOOL_NAME) + log_tool_details("test_3007_web_search_tool_created", web_search_tool) + assert web_search_tool.tool_name == WEB_SEARCH_TOOL_NAME + assert web_search_tool.attributes.tool_type == select_ai.agent.ToolType.WEBSEARCH + assert web_search_tool.attributes.tool_params.credential_name == "OPENAI_CRED" + + +async def test_3008_email_tool_created(email_tool): + logger.info("Validating EMAIL tool creation: %s", EMAIL_TOOL_NAME) + log_tool_details("test_3008_email_tool_created", email_tool) + assert email_tool.tool_name == EMAIL_TOOL_NAME + assert str(email_tool.attributes.tool_type).upper() in ("EMAIL", "NOTIFICATION") + assert email_tool.attributes.tool_params.credential_name == EMAIL_CRED_NAME + assert email_tool.attributes.tool_params.smtp_host is not None + assert str(email_tool.attributes.tool_params.notification_type).lower() == "email" + + +async def test_3009_slack_tool_created(slack_tool): + logger.info("Validating SLACK tool creation: %s", SLACK_TOOL_NAME) + if slack_tool is not None: + log_tool_details("test_3009_slack_tool_created", slack_tool) + assert slack_tool.tool_name == SLACK_TOOL_NAME + assert str(slack_tool.attributes.tool_type).upper() in ("SLACK", "NOTIFICATION") + assert slack_tool.attributes.tool_params.credential_name == SLACK_CRED_NAME + assert str(slack_tool.attributes.tool_params.notification_type).lower() == "slack" + else: + logger.info("SLACK tool not created due to expected backend-side error") + + +async def test_3009_custom_tool_attributes_roundtrip(): + logger.info( + "Validating custom tool attribute roundtrip: instruction/tool_inputs/description" + ) + tool = AsyncTool( + tool_name=CUSTOM_ATTR_TOOL_NAME, + description=CUSTOM_ATTR_TOOL_DESCRIPTION, + attributes=select_ai.agent.ToolAttributes( + function=PLSQL_FUNCTION_NAME, + instruction="Return age in years for a birth date input", + tool_inputs=[ + { + "name": "p_birth_date", + "description": "Input birth date in DATE format", + } + ], + ), + ) + await tool.create(replace=True) + try: + fetched = await AsyncTool.fetch(CUSTOM_ATTR_TOOL_NAME) + log_tool_details("test_3009_custom_tool_attributes_roundtrip", fetched) + logger.info( + "Fetched custom tool | name=%s | description=%s | instruction=%s", + fetched.tool_name, + fetched.description, + fetched.attributes.instruction, + ) + assert fetched.tool_name == CUSTOM_ATTR_TOOL_NAME + assert fetched.description == CUSTOM_ATTR_TOOL_DESCRIPTION + assert fetched.attributes.function == PLSQL_FUNCTION_NAME + assert ( + fetched.attributes.instruction + == "Return age in years for a birth date input" + ) + assert isinstance(fetched.attributes.tool_inputs, list) + assert fetched.attributes.tool_inputs[0]["name"] == "p_birth_date" + assert "birth date" in fetched.attributes.tool_inputs[0]["description"].lower() + finally: + await tool.delete(force=True) + + +async def test_3009_custom_tool_without_tool_type(): + logger.info("Validating custom tool creation with tool_type unset") + tool = AsyncTool( + tool_name=CUSTOM_NO_TYPE_TOOL_NAME, + description="Custom tool without tool_type", + attributes=select_ai.agent.ToolAttributes( + function=PLSQL_FUNCTION_NAME, + instruction="Calculate age from birth date", + ), + ) + await tool.create(replace=True) + try: + fetched = await AsyncTool.fetch(CUSTOM_NO_TYPE_TOOL_NAME) + log_tool_details("test_3009_custom_tool_without_tool_type", fetched) + assert fetched.tool_name == CUSTOM_NO_TYPE_TOOL_NAME + assert fetched.attributes.tool_type is None + assert fetched.attributes.function == PLSQL_FUNCTION_NAME + assert fetched.attributes.instruction == "Calculate age from birth date" + finally: + await tool.delete(force=True) + + +async def test_3009_custom_tool_with_tool_type_without_instruction(sql_profile): + logger.info( + "Validating custom tool creation with tool_type set and instruction unset" + ) + tool = AsyncTool( + tool_name=CUSTOM_WITH_TYPE_NO_INSTR_TOOL_NAME, + description="Custom tool with tool_type and no instruction", + attributes=select_ai.agent.ToolAttributes( + tool_type=select_ai.agent.ToolType.SQL, + tool_params=select_ai.agent.SQLToolParams( + profile_name=SQL_PROFILE_NAME + ), + ), + ) + await tool.create(replace=True) + try: + fetched = await AsyncTool.fetch(CUSTOM_WITH_TYPE_NO_INSTR_TOOL_NAME) + log_tool_details( + "test_3009_custom_tool_with_tool_type_without_instruction", fetched + ) + assert fetched.tool_name == CUSTOM_WITH_TYPE_NO_INSTR_TOOL_NAME + assert fetched.attributes.tool_type == select_ai.agent.ToolType.SQL + assert fetched.attributes.instruction is not None + assert "sql" in fetched.attributes.instruction.lower() + assert fetched.attributes.tool_params.profile_name == SQL_PROFILE_NAME + finally: + await tool.delete(force=True) + + +async def test_3009_custom_tool_with_tool_type_and_instruction(sql_profile): + logger.info( + "Validating custom tool creation with tool_type and instruction set" + ) + tool = AsyncTool( + tool_name=CUSTOM_WITH_TYPE_AND_INSTR_TOOL_NAME, + description="Custom tool with tool_type and instruction", + attributes=select_ai.agent.ToolAttributes( + tool_type=select_ai.agent.ToolType.SQL, + tool_params=select_ai.agent.SQLToolParams( + profile_name=SQL_PROFILE_NAME + ), + instruction="Use SQL profile to answer query from relational data", + ), + ) + await tool.create(replace=True) + try: + fetched = await AsyncTool.fetch(CUSTOM_WITH_TYPE_AND_INSTR_TOOL_NAME) + log_tool_details( + "test_3009_custom_tool_with_tool_type_and_instruction", fetched + ) + assert fetched.tool_name == CUSTOM_WITH_TYPE_AND_INSTR_TOOL_NAME + assert fetched.attributes.tool_type == select_ai.agent.ToolType.SQL + assert fetched.attributes.instruction is not None + assert "sql" in fetched.attributes.instruction.lower() + assert fetched.attributes.tool_params.profile_name == SQL_PROFILE_NAME + finally: + await tool.delete(force=True) + + +async def test_3010_sql_tool_with_invalid_profile_created(neg_sql_tool): + logger.info("Validating SQL tool with invalid profile") + log_tool_details("test_3010_sql_tool_with_invalid_profile_created", neg_sql_tool) + assert neg_sql_tool.tool_name == NEG_SQL_TOOL_NAME + assert neg_sql_tool.attributes.tool_type == select_ai.agent.ToolType.SQL + assert neg_sql_tool.attributes.tool_params.profile_name == "NON_EXISTENT_PROFILE" + + +async def test_3011_rag_tool_with_invalid_profile_created(neg_rag_tool): + logger.info("Validating RAG tool with invalid profile") + log_tool_details("test_3011_rag_tool_with_invalid_profile_created", neg_rag_tool) + assert neg_rag_tool.tool_name == NEG_RAG_TOOL_NAME + assert neg_rag_tool.attributes.tool_type == select_ai.agent.ToolType.RAG + assert ( + neg_rag_tool.attributes.tool_params.profile_name + == "NON_EXISTENT_RAG_PROFILE" + ) + + +async def test_3012_plsql_tool_with_invalid_function_created(neg_plsql_tool): + logger.info("Validating PL/SQL tool with invalid function") + log_tool_details( + "test_3012_plsql_tool_with_invalid_function_created", neg_plsql_tool + ) + assert neg_plsql_tool.tool_name == NEG_PLSQL_TOOL_NAME + assert neg_plsql_tool.attributes.function == "NON_EXISTENT_FUNCTION" + + +async def test_3013_fetch_non_existent_tool(): + logger.info("Fetching non-existent tool") + with pytest.raises(AgentToolNotFoundError) as exc: + await AsyncTool.fetch("TOOL_DOES_NOT_EXIST") + logger.info("Received expected error: %s", exc.value) + + +async def test_3014_list_invalid_regex(): + logger.info("Listing tools with invalid regex") + with pytest.raises(Exception) as exc: + async for _ in AsyncTool.list(tool_name_pattern="*["): + pass + logger.info("Received expected regex error: %s", exc.value) + + +async def test_3015_list_tools(): + logger.info("Listing all tools") + tools = [tool async for tool in AsyncTool.list()] + for tool in tools: + if tool.tool_name in {SQL_TOOL_NAME, RAG_TOOL_NAME, PLSQL_TOOL_NAME}: + log_tool_details("test_3015_list_tools", tool) + tool_names = {tool.tool_name for tool in tools} + logger.info("Tools present: %s", tool_names) + assert len(tools) >= 3 + assert SQL_TOOL_NAME in tool_names + assert RAG_TOOL_NAME in tool_names + assert PLSQL_TOOL_NAME in tool_names + + +async def test_3016_create_tool_default_status_enabled(sql_profile): + logger.info("Creating tool to validate default ENABLED status") + tool = await AsyncTool.create_built_in_tool( + tool_name=DEFAULT_STATUS_TOOL_NAME, + tool_type=select_ai.agent.ToolType.SQL, + tool_params=select_ai.agent.SQLToolParams(profile_name=SQL_PROFILE_NAME), + ) + try: + await assert_tool_status(DEFAULT_STATUS_TOOL_NAME, "ENABLED") + fetched = await AsyncTool.fetch(DEFAULT_STATUS_TOOL_NAME) + log_tool_details("test_3016_create_tool_default_status_enabled", fetched) + logger.info( + "Fetched created tool | name=%s | type=%s | profile=%s", + fetched.tool_name, + fetched.attributes.tool_type, + fetched.attributes.tool_params.profile_name, + ) + assert fetched.attributes.tool_type == select_ai.agent.ToolType.SQL + assert fetched.attributes.tool_params.profile_name == SQL_PROFILE_NAME + finally: + await tool.delete(force=True) + + +async def test_3017_create_tool_with_enabled_false_sets_disabled(sql_profile): + logger.info("Creating tool with enabled=False to validate DISABLED status") + tool = AsyncTool( + tool_name=DISABLED_TOOL_NAME, + attributes=select_ai.agent.ToolAttributes( + tool_type=select_ai.agent.ToolType.SQL, + tool_params=select_ai.agent.SQLToolParams( + profile_name=SQL_PROFILE_NAME + ), + ), + ) + await tool.create(enabled=False, replace=True) + try: + await assert_tool_status(DISABLED_TOOL_NAME, "DISABLED") + fetched = await AsyncTool.fetch(DISABLED_TOOL_NAME) + log_tool_details( + "test_3017_create_tool_with_enabled_false_sets_disabled", fetched + ) + assert fetched.attributes.tool_type == select_ai.agent.ToolType.SQL + finally: + await tool.delete(force=True) + + +async def test_3018_drop_tool_force_true_non_existent(): + logger.info("Validating DROP_TOOL force=True for missing tool") + tool = AsyncTool(tool_name=DROP_FORCE_MISSING_TOOL) + await tool.delete(force=True) + status = await get_tool_status(DROP_FORCE_MISSING_TOOL) + logger.info("Status after force delete on missing tool: %s", status) + assert status is None + + +async def test_3019_drop_tool_force_false_non_existent_raises(): + logger.info("Validating DROP_TOOL force=False for missing tool raises") + tool = AsyncTool(tool_name=DROP_FORCE_MISSING_TOOL) + with pytest.raises(oracledb.Error) as exc: + await tool.delete(force=False) + logger.info("Received expected drop error: %s", exc.value) diff --git a/tests/agents/test_3001_tools.py b/tests/agents/test_3001_tools.py new file mode 100644 index 0000000..058b42d --- /dev/null +++ b/tests/agents/test_3001_tools.py @@ -0,0 +1,470 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2025, Oracle and/or its affiliates. +# +# Licensed under the Universal Permissive License v 1.0 as shown at +# http://oss.oracle.com/licenses/upl. +# ----------------------------------------------------------------------------- + +""" +3001 - Complete and backend-aligned test coverage for select_ai.agent Tool APIs +(with logging for behavior visibility) +""" + +import uuid +import logging +import pytest +import os +import select_ai +import oracledb +from select_ai.agent import Tool +from select_ai.errors import AgentToolNotFoundError + +# Path +PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")) +LOG_FILE = os.path.join(PROJECT_ROOT, "log", "tkex_test_3001_tools.log") +os.makedirs(os.path.dirname(LOG_FILE), exist_ok=True) + +# Force logging to file (pytest-proof) +root = logging.getLogger() +root.setLevel(logging.INFO) + +for h in root.handlers[:]: + root.removeHandler(h) + +fh = logging.FileHandler(LOG_FILE, mode="w") +fh.setFormatter(logging.Formatter("%(levelname)s: %(message)s")) +root.addHandler(fh) + +logger = logging.getLogger() + +# ----------------------------------------------------------------------------- +# Per-test logging +# ----------------------------------------------------------------------------- + +@pytest.fixture(autouse=True) +def log_test_name(request): + logger.info(f"--- Starting test: {request.function.__name__} ---") + yield + logger.info(f"--- Finished test: {request.function.__name__} ---") + + +# ----------------------------------------------------------------------------- +# Helper Functions +# ----------------------------------------------------------------------------- + +def get_tool_status(tool_name): + with select_ai.cursor() as cur: + cur.execute(""" + SELECT status + FROM USER_AI_AGENT_TOOLS + WHERE tool_name = :tool_name + """, {"tool_name": tool_name}) + row = cur.fetchone() + return row[0] if row else None + +# ----------------------------------------------------------------------------- +# Constants +# ----------------------------------------------------------------------------- +UUID = uuid.uuid4().hex.upper() + +SQL_PROFILE_NAME = f"PYSAI_SQL_PROFILE_{UUID}" +RAG_PROFILE_NAME = f"PYSAI_RAG_PROFILE_{UUID}" + +SQL_TOOL_NAME = f"PYSAI_SQL_TOOL_{UUID}" +RAG_TOOL_NAME = f"PYSAI_RAG_TOOL_{UUID}" +PLSQL_TOOL_NAME = f"PYSAI_PLSQL_TOOL_{UUID}" +WEB_SEARCH_TOOL_NAME = f"PYSAI_WEB_TOOL_{UUID}" +PLSQL_FUNCTION_NAME = f"PYSAI_CALC_AGE_{UUID}" +smtp_username = os.getenv("PYSAI_TEST_EMAIL_CRED_USERNAME") +smtp_password = os.getenv("PYSAI_TEST_EMAIL_CRED_PASSWORD") +slack_username = os.getenv("PYSAI_TEST_SLACK_USERNAME") +slack_password = os.getenv("PYSAI_TEST_SLACK_PASSWORD") + +@pytest.fixture(scope="module") +def email_credential(): + cred_name = "EMAIL_CRED" + logger.info("Ensuring EMAIL credential is clean: %s", cred_name) + + # Drop if exists (best-effort) + try: + select_ai.delete_credential(cred_name) + logger.info("Dropped existing EMAIL credential: %s", cred_name) + except Exception as e: + logger.info("EMAIL credential did not exist or could not be dropped: %s", e) + + # Create fresh credential + credential = { + "credential_name": cred_name, + "username": smtp_username, + "password": smtp_password, + } + + select_ai.create_credential( + credential=credential, + replace=True + ) + logger.info("Created EMAIL credential: %s", cred_name) + + yield cred_name + + logger.info("Deleting EMAIL credential at teardown: %s", cred_name) + try: + select_ai.delete_credential(cred_name) + except Exception as e: + logger.warning("Failed to delete EMAIL credential during teardown: %s", e) + +@pytest.fixture(scope="module") +def slack_credential(): + cred_name = "SLACK_CRED" + logger.info("Ensuring SLACK credential is clean: %s", cred_name) + + # Drop if exists (best-effort) + try: + select_ai.delete_credential(cred_name) + logger.info("Dropped existing SLACK credential: %s", cred_name) + except Exception as e: + logger.info("SLACK credential did not exist or could not be dropped: %s", e) + + # Create fresh SLACK credential (backend-required fields) + credential = { + "credential_name": cred_name, + "username": slack_username, + "password": slack_password, + } + + select_ai.create_credential( + credential=credential, + replace=True + ) + logger.info("Created SLACK credential: %s", cred_name) + + yield cred_name + + logger.info("Deleting SLACK credential at teardown: %s", cred_name) + try: + select_ai.delete_credential(cred_name) + except Exception as e: + logger.warning("Failed to delete SLACK credential during teardown: %s", e) + + +# ----------------------------------------------------------------------------- +# Fixtures +# ----------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def sql_profile(profile_attributes): + logger.info("Creating SQL profile: %s", SQL_PROFILE_NAME) + profile = select_ai.Profile( + profile_name=SQL_PROFILE_NAME, + description="SQL Profile", + attributes=profile_attributes, + ) + yield profile + logger.info("Deleting SQL profile") + profile.delete(force=True) + + +@pytest.fixture(scope="module") +def rag_profile(rag_profile_attributes): + logger.info("Creating RAG profile: %s", RAG_PROFILE_NAME) + profile = select_ai.Profile( + profile_name=RAG_PROFILE_NAME, + description="RAG Profile", + attributes=rag_profile_attributes, + ) + yield profile + logger.info("Deleting RAG profile") + profile.delete(force=True) + + +@pytest.fixture(scope="module") +def sql_tool(sql_profile): + logger.info("Creating SQL tool: %s", SQL_TOOL_NAME) + tool = select_ai.agent.Tool.create_sql_tool( + tool_name=SQL_TOOL_NAME, + profile_name=SQL_PROFILE_NAME, + description="SQL Tool", + replace=True, + ) + yield tool + logger.info("Deleting SQL tool") + tool.delete(force=True) + + +@pytest.fixture(scope="module") +def rag_tool(rag_profile): + logger.info("Creating RAG tool: %s", RAG_TOOL_NAME) + tool = select_ai.agent.Tool.create_rag_tool( + tool_name=RAG_TOOL_NAME, + profile_name=RAG_PROFILE_NAME, + description="RAG Tool", + replace=True, + ) + yield tool + logger.info("Deleting RAG tool") + tool.delete(force=True) + + +@pytest.fixture(scope="module") +def plsql_function(): + logger.info("Creating PL/SQL function: %s", PLSQL_FUNCTION_NAME) + ddl = f""" + CREATE OR REPLACE FUNCTION {PLSQL_FUNCTION_NAME}(p_birth_date DATE) + RETURN NUMBER IS + BEGIN + RETURN TRUNC(MONTHS_BETWEEN(SYSDATE, p_birth_date) / 12); + END; + """ + with select_ai.cursor() as cur: + cur.execute(ddl) + yield + logger.info("Dropping PL/SQL function") + with select_ai.cursor() as cur: + cur.execute(f"DROP FUNCTION {PLSQL_FUNCTION_NAME}") + + +@pytest.fixture(scope="module") +def plsql_tool(plsql_function): + logger.info("Creating PL/SQL tool: %s", PLSQL_TOOL_NAME) + tool = select_ai.agent.Tool.create_pl_sql_tool( + tool_name=PLSQL_TOOL_NAME, + function=PLSQL_FUNCTION_NAME, + description="PL/SQL Tool", + replace=True, + ) + yield tool + logger.info("Deleting PL/SQL tool") + tool.delete(force=True) + +@pytest.fixture(scope="module") +def web_search_tool(): + """Fixture for Web Search Tool positive case.""" + logger.info("Creating Web Search tool: %s", WEB_SEARCH_TOOL_NAME) + tool = select_ai.agent.Tool.create_websearch_tool( + tool_name=WEB_SEARCH_TOOL_NAME, + description="Web Search Tool for testing", + credential_name="OPENAI_CRED", + replace=True, + ) + logger.info("WEBSEARCH Tool created successfully: %s", WEB_SEARCH_TOOL_NAME) + yield tool + logger.info("Deleting Web Search tool") + tool.delete(force=True) + +@pytest.fixture(scope="module") +def email_tool(email_credential): + logger.info("Creating EMAIL tool: EMAIL_TOOL") + tool = select_ai.agent.Tool.create_email_notification_tool( + tool_name="EMAIL_TOOL", + credential_name="EMAIL_CRED", + recipient="kondra.nagabhavani@oracle.com", + sender="bharadwaj.vulugundam@oracle.com", + smtp_host="smtp.email.us-ashburn-1.oci.oraclecloud.com", + description="Send email", + replace=True, + ) + logger.info("EMAIL_TOOL created successfully") + yield tool + logger.info("Deleting EMAIL tool") + tool.delete(force=True) + +@pytest.fixture(scope="module") +def slack_tool(slack_credential): + logger.info("Creating SLACK tool: SLACK_TOOL") + try: + tool = select_ai.agent.Tool.create_slack_notification_tool( + tool_name="SLACK_TOOL", + credential_name="SLACK_CRED", + slack_channel="#general", + description="slack notification", + replace=True, + ) + logger.info("SLACK_TOOL is created successfully") + yield tool + except oracledb.DatabaseError as e: + if "ORA-20052" in str(e): + logger.info(f"Expected error during tool creation: {e}") + yield None # Return None, indicating the tool creation failed but is expected + else: + raise e + finally: + if 'tool' in locals(): + logger.info("Deleting SLACK tool") + tool.delete(force=True) + +@pytest.fixture(scope="module") +def neg_sql_tool(): + logger.info("Creating SQL tool with INVALID profile: NEG_SQL_TOOL") + tool = select_ai.agent.Tool.create_sql_tool( + tool_name="NEG_SQL_TOOL", + profile_name="NON_EXISTENT_PROFILE", + replace=True, + ) + logger.info("NEG_SQL_TOOL is created successfully.") + yield tool + logger.info("Deleting NEG_SQL_TOOL") + tool.delete(force=True) + +@pytest.fixture(scope="module") +def neg_rag_tool(): + logger.info("Creating RAG tool with INVALID profile: NEG_RAG_TOOL") + tool = select_ai.agent.Tool.create_rag_tool( + tool_name="NEG_RAG_TOOL", + profile_name="NON_EXISTENT_RAG_PROFILE", + replace=True, + ) + logger.info("NEG_RAG_TOOL is created successfully") + yield tool + logger.info("Deleting NEG_RAG_TOOL") + tool.delete(force=True) + + +@pytest.fixture(scope="module") +def neg_plsql_tool(): + logger.info("Creating PL/SQL tool with INVALID function: NEG_PLSQL_TOOL") + tool = select_ai.agent.Tool.create_pl_sql_tool( + tool_name="NEG_PLSQL_TOOL", + function="NON_EXISTENT_FUNCTION", + replace=True, + ) + logger.info("NEG_PLSQL_TOOL is created successfully") + yield tool + logger.info("Deleting NEG_PLSQL_TOOL") + tool.delete(force=True) + +# ----------------------------------------------------------------------------- +# POSITIVE TESTS +# ----------------------------------------------------------------------------- + +def test_3000_sql_tool_created(sql_tool): + logger.info("Validating SQL tool creation") + logger.info("SQL Tool created successfully: %s", SQL_TOOL_NAME) + logger.info("SQL Profile created successfully: %s", SQL_PROFILE_NAME) + assert sql_tool.tool_name == SQL_TOOL_NAME + assert sql_tool.attributes.tool_params.profile_name == SQL_PROFILE_NAME + + +def test_3001_rag_tool_created(rag_tool): + logger.info("Validating RAG tool creation") + logger.info("RAG Tool created successfully: %s", RAG_TOOL_NAME) + logger.info("RAG Profile created successfully: %s", RAG_PROFILE_NAME) + assert rag_tool.tool_name == RAG_TOOL_NAME + assert rag_tool.attributes.tool_params.profile_name == RAG_PROFILE_NAME + + +def test_3002_plsql_tool_created(plsql_tool): + logger.info("Validating PL/SQL tool creation") + logger.info("PL/SQL Tool created successfully: %s", PLSQL_TOOL_NAME) + logger.info("PL/SQL function created successfully: %s", PLSQL_FUNCTION_NAME) + assert plsql_tool.tool_name == PLSQL_TOOL_NAME + assert plsql_tool.attributes.function == PLSQL_FUNCTION_NAME + + +def test_3003_list_tools(): + logger.info("Listing all tools") + tool_names = {t.tool_name for t in select_ai.agent.Tool.list()} + logger.info("Tools present: %s", tool_names) + + assert SQL_TOOL_NAME in tool_names + assert RAG_TOOL_NAME in tool_names + assert PLSQL_TOOL_NAME in tool_names + + +def test_3004_list_tools_regex(): + logger.info("Listing tools using regex ^PYSAI_") + tool_names = {t.tool_name for t in select_ai.agent.Tool.list("^PYSAI_")} + logger.info("Matched tools: %s", tool_names) + + assert SQL_TOOL_NAME in tool_names + assert RAG_TOOL_NAME in tool_names + assert PLSQL_TOOL_NAME in tool_names + + +def test_3005_fetch_tool(): + logger.info("Fetching SQL tool") + tool = select_ai.agent.Tool.fetch(SQL_TOOL_NAME) + assert tool.tool_name == SQL_TOOL_NAME + + +def test_3006_enable_disable_sql_tool(sql_tool): + logger.info("Disabling SQL tool: %s", sql_tool.tool_name) + sql_tool.disable() + + status = get_tool_status(sql_tool.tool_name) + logger.info( + "Tool status after disable | tool=%s | status=%s", + sql_tool.tool_name, + status, + ) + assert status == "DISABLED" + + logger.info("Enabling SQL tool: %s", sql_tool.tool_name) + sql_tool.enable() + + status = get_tool_status(sql_tool.tool_name) + logger.info( + "Tool status after enable | tool=%s | status=%s", + sql_tool.tool_name, + status, + ) + assert status == "ENABLED" + + +def test_3007_web_search_tool_created(web_search_tool): + logger.info("Validating Web Search tool creation") + assert web_search_tool.tool_name == WEB_SEARCH_TOOL_NAME + + +def test_3008_email_tool_created(email_tool): + logger.info("Validating EMAIL tool creation") + assert email_tool.tool_name == "EMAIL_TOOL" + + +def test_3009_slack_tool_created(slack_tool): + logger.info("Validating SLACK tool creation") + + # If the tool is None (because of expected ORA-20052 error), skip the assertion + if slack_tool is None: + logger.info("SLACK tool creation failed with expected error ORA-20052, but continuing test.") + else: + assert slack_tool.tool_name == "SLACK_TOOL" + +def test_3010_sql_tool_with_invalid_profile_created(neg_sql_tool): + logger.info("Validating SQL tool with invalid profile is stored") + assert neg_sql_tool.tool_name == "NEG_SQL_TOOL" + assert neg_sql_tool.attributes.tool_params.profile_name == "NON_EXISTENT_PROFILE" + + +def test_3011_rag_tool_with_invalid_profile_created(neg_rag_tool): + logger.info("Validating RAG tool with invalid profile is stored") + assert neg_rag_tool.tool_name == "NEG_RAG_TOOL" + assert neg_rag_tool.attributes.tool_params.profile_name == "NON_EXISTENT_RAG_PROFILE" + + +def test_3012_plsql_tool_with_invalid_function_created(neg_plsql_tool): + logger.info("Validating PL/SQL tool with invalid function is stored") + assert neg_plsql_tool.tool_name == "NEG_PLSQL_TOOL" + assert neg_plsql_tool.attributes.function == "NON_EXISTENT_FUNCTION" + + +def test_3013_fetch_non_existent_tool(): + logger.info("Fetching non-existent tool") + with pytest.raises(AgentToolNotFoundError)as exc: + select_ai.agent.Tool.fetch("TOOL_DOES_NOT_EXIST") + logger.error("%s", exc.value) + +def test_3014_list_invalid_regex(): + logger.info("Listing tools with invalid regex") + with pytest.raises(Exception) as exc: + list(select_ai.agent.Tool.list(tool_name_pattern="*[")) + logger.error("%s", exc.value) + +def test_3015_list_tools(): + logger.info("Listing all tools") + tool_names = {t.tool_name for t in select_ai.agent.Tool.list()} + logger.info("Tools present: %s", tool_names) + + assert SQL_TOOL_NAME in tool_names + assert RAG_TOOL_NAME in tool_names + assert PLSQL_TOOL_NAME in tool_names \ No newline at end of file diff --git a/tests/agents/test_3101_async_tasks.py b/tests/agents/test_3101_async_tasks.py new file mode 100644 index 0000000..a4bfbf0 --- /dev/null +++ b/tests/agents/test_3101_async_tasks.py @@ -0,0 +1,312 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2025, Oracle and/or its affiliates. +# +# Licensed under the Universal Permissive License v 1.0 as shown at +# http://oss.oracle.com/licenses/upl. +# ----------------------------------------------------------------------------- + +""" +3101 - Module for testing select_ai agent async tasks +""" + +import uuid +import logging +import os + +import oracledb +import pytest +import select_ai +from select_ai.agent import AsyncTask, TaskAttributes + +pytestmark = pytest.mark.anyio + +PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")) +LOG_FILE = os.path.join(PROJECT_ROOT, "log", "tkex_test_3100_async_tasks.log") +os.makedirs(os.path.dirname(LOG_FILE), exist_ok=True) + +root = logging.getLogger() +root.setLevel(logging.INFO) +for handler in root.handlers[:]: + root.removeHandler(handler) +file_handler = logging.FileHandler(LOG_FILE, mode="w") +file_handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s")) +root.addHandler(file_handler) +logger = logging.getLogger() + +PYSAI_3100_TASK_NAME = f"PYSAI_3100_{uuid.uuid4().hex.upper()}" +PYSAI_3100_SQL_TASK_DESCRIPTION = "PYSAI_3100_SQL_TASK_DESCRIPTION" +PYSAI_3100_DISABLED_TASK_NAME = f"PYSAI_3100_DISABLED_{uuid.uuid4().hex.upper()}" +PYSAI_3100_DEFAULT_STATUS_TASK_NAME = ( + f"PYSAI_3100_DEFAULT_STATUS_{uuid.uuid4().hex.upper()}" +) +PYSAI_3100_PARENT_TASK_NAME = f"PYSAI_3100_PARENT_{uuid.uuid4().hex.upper()}" +PYSAI_3100_CHILD_TASK_NAME = f"PYSAI_3100_CHILD_{uuid.uuid4().hex.upper()}" +PYSAI_3100_DEFAULT_HUMAN_TASK_NAME = ( + f"PYSAI_3100_DEFAULT_HUMAN_{uuid.uuid4().hex.upper()}" +) +PYSAI_3100_MISSING_TASK_NAME = f"PYSAI_3100_MISSING_{uuid.uuid4().hex.upper()}" + + +@pytest.fixture(autouse=True) +def log_test_name(request): + logger.info("--- Starting test: %s ---", request.function.__name__) + yield + logger.info("--- Finished test: %s ---", request.function.__name__) + + +@pytest.fixture(scope="module", autouse=True) +async def async_connect(test_env): + logger.info("Opening async database connection") + await select_ai.async_connect(**test_env.connect_params()) + yield + logger.info("Closing async database connection") + await select_ai.async_disconnect() + + +async def get_task_status(task_name): + logger.info("Fetching task status for: %s", task_name) + async with select_ai.async_cursor() as cur: + await cur.execute( + """ + SELECT status + FROM USER_AI_AGENT_TASKS + WHERE task_name = :task_name + """, + {"task_name": task_name}, + ) + row = await cur.fetchone() + return row[0] if row else None + + +async def assert_task_status(task_name: str, expected_status: str) -> None: + status = await get_task_status(task_name) + logger.info( + "Verifying task status | task=%s | expected=%s | actual=%s", + task_name, + expected_status, + status, + ) + assert status == expected_status + + +def log_task_details(context: str, task) -> None: + attrs = getattr(task, "attributes", None) + details = { + "context": context, + "task_name": getattr(task, "task_name", None), + "description": getattr(task, "description", None), + "instruction": getattr(attrs, "instruction", None) if attrs else None, + "tools": getattr(attrs, "tools", None) if attrs else None, + "input": getattr(attrs, "input", None) if attrs else None, + "enable_human_tool": ( + getattr(attrs, "enable_human_tool", None) if attrs else None + ), + } + logger.info("TASK_DETAILS: %s", details) + print("TASK_DETAILS:", details) + + +@pytest.fixture(scope="module") +def task_attributes(): + return TaskAttributes( + instruction="Help the user with their request about movies. " + "User question: {query}. " + "You can use SQL tool to search the data from database", + tools=["MOVIE_SQL_TOOL"], + enable_human_tool=False, + ) + + +@pytest.fixture(scope="module") +async def task(task_attributes): + task = AsyncTask( + task_name=PYSAI_3100_TASK_NAME, + description=PYSAI_3100_SQL_TASK_DESCRIPTION, + attributes=task_attributes, + ) + await task.create() + yield task + await task.delete(force=True) + + +async def test_3100(task, task_attributes): + """simple task creation""" + log_task_details("test_3100", task) + assert task.task_name == PYSAI_3100_TASK_NAME + assert task.attributes == task_attributes + assert task.description == PYSAI_3100_SQL_TASK_DESCRIPTION + assert task.attributes.instruction is not None + assert "{query}" in task.attributes.instruction + assert task.attributes.tools == ["MOVIE_SQL_TOOL"] + assert task.attributes.enable_human_tool is False + + +@pytest.mark.parametrize("task_name_pattern", [None, "^PYSAI_3100_"]) +async def test_3101(task_name_pattern): + """task list""" + if task_name_pattern: + tasks = [task async for task in select_ai.agent.AsyncTask.list(task_name_pattern)] + else: + tasks = [task async for task in select_ai.agent.AsyncTask.list()] + for task in tasks: + if task.task_name == PYSAI_3100_TASK_NAME: + log_task_details("test_3101", task) + task_names = set(task.task_name for task in tasks) + task_descriptions = set(task.description for task in tasks) + assert len(tasks) >= 1 + assert PYSAI_3100_TASK_NAME in task_names + assert PYSAI_3100_SQL_TASK_DESCRIPTION in task_descriptions + + +async def test_3102(task_attributes): + """task fetch""" + task = await select_ai.agent.AsyncTask.fetch(PYSAI_3100_TASK_NAME) + log_task_details("test_3102", task) + assert task.task_name == PYSAI_3100_TASK_NAME + assert task.attributes == task_attributes + assert task.description == PYSAI_3100_SQL_TASK_DESCRIPTION + assert task.attributes.tools == ["MOVIE_SQL_TOOL"] + assert task.attributes.input is None + assert task.attributes.enable_human_tool is False + + +async def test_3103_create_task_default_status_enabled(): + task = AsyncTask( + task_name=PYSAI_3100_DEFAULT_STATUS_TASK_NAME, + description="Default status should be enabled", + attributes=TaskAttributes( + instruction="Summarize user request: {query}", + tools=["MOVIE_SQL_TOOL"], + enable_human_tool=False, + ), + ) + await task.create(replace=True) + try: + await assert_task_status(PYSAI_3100_DEFAULT_STATUS_TASK_NAME, "ENABLED") + fetched = await AsyncTask.fetch(PYSAI_3100_DEFAULT_STATUS_TASK_NAME) + log_task_details("test_3103", fetched) + assert fetched.description == "Default status should be enabled" + assert fetched.attributes.enable_human_tool is False + finally: + await task.delete(force=True) + + +async def test_3104_create_task_with_enabled_false_sets_disabled(): + task = AsyncTask( + task_name=PYSAI_3100_DISABLED_TASK_NAME, + description="Task created disabled", + attributes=TaskAttributes( + instruction="Handle disabled task validation", + tools=["MOVIE_SQL_TOOL"], + enable_human_tool=False, + ), + ) + await task.create(enabled=False, replace=True) + try: + await assert_task_status(PYSAI_3100_DISABLED_TASK_NAME, "DISABLED") + fetched = await AsyncTask.fetch(PYSAI_3100_DISABLED_TASK_NAME) + log_task_details("test_3104", fetched) + assert fetched.description == "Task created disabled" + finally: + await task.delete(force=True) + + +async def test_3105_disable_enable_task(task): + logger.info("Disabling task: %s", task.task_name) + await task.disable() + await assert_task_status(PYSAI_3100_TASK_NAME, "DISABLED") + + logger.info("Enabling task: %s", task.task_name) + await task.enable() + await assert_task_status(PYSAI_3100_TASK_NAME, "ENABLED") + + +async def test_3106_drop_task_force_true_non_existent(): + logger.info("Dropping missing task with force=True: %s", PYSAI_3100_MISSING_TASK_NAME) + task = AsyncTask(task_name=PYSAI_3100_MISSING_TASK_NAME) + await task.delete(force=True) + status = await get_task_status(PYSAI_3100_MISSING_TASK_NAME) + logger.info("Status after force delete on missing task: %s", status) + assert status is None + + +async def test_3107_drop_task_force_false_non_existent_raises(): + logger.info("Dropping missing task with force=False: %s", PYSAI_3100_MISSING_TASK_NAME) + task = AsyncTask(task_name=PYSAI_3100_MISSING_TASK_NAME) + with pytest.raises(oracledb.Error) as exc: + await task.delete(force=False) + logger.info("Received expected drop error: %s", exc.value) + + +async def test_3108_create_task_with_input_attribute(): + logger.info("Creating parent/child tasks for input chaining validation") + parent_task = AsyncTask( + task_name=PYSAI_3100_PARENT_TASK_NAME, + description="Parent task", + attributes=TaskAttributes( + instruction="Generate an intermediate summary for: {query}", + tools=["MOVIE_SQL_TOOL"], + enable_human_tool=False, + ), + ) + child_task = AsyncTask( + task_name=PYSAI_3100_CHILD_TASK_NAME, + description="Child task with input dependency", + attributes=TaskAttributes( + instruction="Use upstream context and produce final answer", + tools=["MOVIE_SQL_TOOL"], + input=PYSAI_3100_PARENT_TASK_NAME, + enable_human_tool=False, + ), + ) + await parent_task.create(replace=True) + await child_task.create(replace=True) + try: + fetched = await AsyncTask.fetch(PYSAI_3100_CHILD_TASK_NAME) + log_task_details("test_3108_child", fetched) + assert fetched.attributes.input == PYSAI_3100_PARENT_TASK_NAME + assert fetched.attributes.tools == ["MOVIE_SQL_TOOL"] + assert fetched.description == "Child task with input dependency" + assert fetched.attributes.enable_human_tool is False + finally: + await child_task.delete(force=True) + await parent_task.delete(force=True) + + +async def test_3109_enable_human_tool_default_true(): + logger.info("Creating task to validate enable_human_tool default behavior") + task = AsyncTask( + task_name=PYSAI_3100_DEFAULT_HUMAN_TASK_NAME, + description="Default enable_human_tool check", + attributes=TaskAttributes( + instruction="Collect more details from user for: {query}", + tools=["MOVIE_SQL_TOOL"], + ), + ) + await task.create(replace=True) + try: + fetched = await AsyncTask.fetch(PYSAI_3100_DEFAULT_HUMAN_TASK_NAME) + log_task_details("test_3109", fetched) + assert fetched.attributes.enable_human_tool is True + finally: + await task.delete(force=True) + + +async def test_3110_create_requires_task_name(): + logger.info("Validating create() requires task_name") + with pytest.raises(AttributeError) as exc: + await AsyncTask( + attributes=TaskAttributes( + instruction="Missing task_name validation", tools=[] + ) + ).create() + logger.info("Received expected error: %s", exc.value) + + +async def test_3111_create_requires_attributes(): + logger.info("Validating create() requires attributes") + with pytest.raises(AttributeError) as exc: + await AsyncTask( + task_name=f"PYSAI_3100_NO_ATTR_{uuid.uuid4().hex.upper()}" + ).create() + logger.info("Received expected error: %s", exc.value) diff --git a/tests/agents/test_3101_tasks.py b/tests/agents/test_3101_tasks.py new file mode 100644 index 0000000..c02493e --- /dev/null +++ b/tests/agents/test_3101_tasks.py @@ -0,0 +1,281 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2025, Oracle and/or its affiliates. +# +# Licensed under the Universal Permissive License v 1.0 as shown at +# http://oss.oracle.com/licenses/upl. +# ----------------------------------------------------------------------------- + +""" +# 3101 - Comprehensive tests for select_ai.agent.Task with error code asserts +""" + +import uuid +import logging +import pytest +import os +import select_ai +from select_ai.agent import Task, TaskAttributes +from select_ai.errors import AgentTaskNotFoundError +import oracledb +# Path +PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")) +LOG_FILE = os.path.join(PROJECT_ROOT, "log", "tkex_test_3101_tasks.log") +os.makedirs(os.path.dirname(LOG_FILE), exist_ok=True) + +# Logging +root = logging.getLogger() +root.setLevel(logging.INFO) +for h in root.handlers[:]: + root.removeHandler(h) +fh = logging.FileHandler(LOG_FILE, mode="w") +fh.setFormatter(logging.Formatter("%(levelname)s: %(message)s")) +root.addHandler(fh) +logger = logging.getLogger() + +# ----------------------------------------------------------------------------- +# Per-test logging +# ----------------------------------------------------------------------------- + +@pytest.fixture(autouse=True) +def log_test_name(request): + logger.info(f"--- Starting test: {request.function.__name__} ---") + yield + logger.info(f"--- Finished test: {request.function.__name__} ---") + + +# ----------------------------------------------------------------------------- +# Helper Functions +# ----------------------------------------------------------------------------- + +def get_task_status(task_name): + with select_ai.cursor() as cur: + cur.execute(""" + SELECT status + FROM USER_AI_AGENT_TASKS + WHERE task_name = :task_name + """, {"task_name": task_name}) + row = cur.fetchone() + return row[0] if row else None + +# ----------------------------------------------------------------------------- +# Constants +# ----------------------------------------------------------------------------- + +BASE = f"PYSAI_3101_{uuid.uuid4().hex.upper()}" +TASK_A_NAME = f"{BASE}_TASK_A" +TASK_B_NAME = f"{BASE}_TASK_B" + +# ----------------------------------------------------------------------------- +# Helpers +# ----------------------------------------------------------------------------- + +def expect_oracle_error(expected_code, fn): + """ + Run fn and assert that expected Oracle/Agent error occurs. + expected_code: "ORA-xxxxx" or "NOT_FOUND" + """ + try: + fn() + except AgentTaskNotFoundError as e: + logger.info("Expected failure (NOT_FOUND): %s", e) + assert expected_code == "NOT_FOUND" + except oracledb.DatabaseError as e: + msg = str(e) + logger.info("Expected Oracle failure: %s", msg) + assert expected_code in msg, f"Expected {expected_code}, got {msg}" + else: + pytest.fail(f"Expected error {expected_code} did not occur") + + +# ----------------------------------------------------------------------------- +# Fixtures +# ----------------------------------------------------------------------------- + +@pytest.fixture(scope="module") +def task_a(): + logger.info("Creating TASK_A: %s", TASK_A_NAME) + attrs = TaskAttributes( + instruction="Analyze movie data for user query: {query}", + tools=["MOVIE_SQL_TOOL"], + enable_human_tool=False, + ) + task = Task(task_name=TASK_A_NAME, description="Primary analysis task", attributes=attrs) + task.create() + logger.info("TASK_A created successfully") + yield task + logger.info("Deleting TASK_A: %s", TASK_A_NAME) + task.delete(force=True) + expect_oracle_error("NOT_FOUND", lambda: Task.fetch(TASK_A_NAME)) + logger.info("TASK_A deleted successfully") + +@pytest.fixture(scope="module") +def task_b(task_a): + logger.info("Creating TASK_B: %s", TASK_B_NAME) + attrs = TaskAttributes( + instruction="Summarize insights from previous analysis", + input=TASK_A_NAME, + tools=None, + enable_human_tool=True, + ) + task = Task(task_name=TASK_B_NAME, description="Chained summarization task", attributes=attrs) + task.create() + logger.info("TASK_B created successfully") + yield task + logger.info("Deleting TASK_B: %s", TASK_B_NAME) + task.delete(force=True) + expect_oracle_error("NOT_FOUND", lambda: Task.fetch(TASK_B_NAME)) + logger.info("TASK_B deleted successfully") + +# ----------------------------------------------------------------------------- +# Positive Tests +# ----------------------------------------------------------------------------- + +def test_3100_task_creation(task_a): + logger.info("Verifying TASK_A creation") + logger.info("Task Name : %s", task_a.task_name) + logger.info("Task Description: %s", task_a.description) + logger.info("Task Attributes:") + logger.info(" enable_human_tool = %s", task_a.attributes.enable_human_tool) + logger.info(" tools = %s", task_a.attributes.tools) + assert task_a.task_name == TASK_A_NAME + assert task_a.description == "Primary analysis task" + assert task_a.attributes.enable_human_tool is False + assert task_a.attributes.tools == ["MOVIE_SQL_TOOL"] + +def test_3101_task_chaining(task_b): + logger.info("Verifying TASK_B chaining") + logger.info("TASK_B attributes:") + logger.info(" input = %s", task_b.attributes.input) + logger.info(" enable_human_tool = %s", task_b.attributes.enable_human_tool) + assert task_b.attributes.input == TASK_A_NAME + assert task_b.attributes.enable_human_tool is True + +def test_3102_fetch_task(task_a): + logger.info("Fetching TASK_A") + fetched = Task.fetch(TASK_A_NAME) + logger.info("Fetched task details:") + logger.info(" task_name = %s", fetched.task_name) + logger.info(" attributes = %s", fetched.attributes) + logger.info("Original task attributes:") + logger.info(" attributes = %s", task_a.attributes) + assert fetched.task_name == TASK_A_NAME + assert fetched.attributes == task_a.attributes + +def test_3103_list_tasks_with_regex(): + logger.info("Listing tasks with regex") + tasks = list(Task.list(f"{BASE}.*")) + names = sorted(t.task_name for t in tasks) + logger.info("Tasks returned (sorted):") + for name in names: + logger.info(" - %s", name) + assert TASK_A_NAME in names + assert TASK_B_NAME in names + + +def test_3104_disable_enable_task(task_b): + logger.info("Disabling TASK_B: %s", task_b.task_name) + task_b.disable() + + status = get_task_status(task_b.task_name) + logger.info("DB status after disable: %s", status) + assert status == "DISABLED" + + logger.info("Enabling TASK_B: %s", task_b.task_name) + task_b.enable() + + status = get_task_status(task_b.task_name) + logger.info("DB status after enable: %s", status) + assert status == "ENABLED" + +# ----------------------------------------------------------------------------- +# Negative / Edge Case Tests with Error Code Asserts +# ----------------------------------------------------------------------------- + +def test_3105_set_single_attribute_invalid(task_b): + logger.info("Setting invalid single attribute for TASK_B") + expect_oracle_error("ORA-20051", lambda: task_b.set_attribute("description", "New Desc")) + +def test_3110_fetch_non_existent_task(): + name = f"{BASE}_NO_SUCH_TASK" + logger.info("Fetching non-existent task: %s", name) + expect_oracle_error("NOT_FOUND", lambda: Task.fetch(name)) + +def test_3111_duplicate_task_creation_fails(task_a): + logger.info("Creating duplicate TASK_A without replace") + logger.info(" task_name = %s", task_a.task_name) + dup = Task( + task_name=task_a.task_name, + description="Duplicate task", + attributes=task_a.attributes, + ) + expect_oracle_error("ORA-20051", lambda: dup.create(replace=False)) + +def test_3113_set_invalid_attribute(task_a): + logger.info("Setting invalid attribute for TASK_A") + logger.info(" attribute = unknown_attribute") + expect_oracle_error("ORA-20051", lambda: task_a.set_attribute("unknown_attribute", "value")) + +def test_3114_invalid_regex_pattern(): + logger.info("Listing tasks with invalid regex") + expect_oracle_error("ORA-12726", lambda: list(Task.list("[INVALID_REGEX"))) + +def test_3115_delete_disabled_task_without_force(): + task_name = f"{BASE}_TEMP_DELETE" + logger.info("Creating and deleting disabled task: %s", task_name) + attrs = TaskAttributes(instruction="Temporary task", tools=None) + task = Task(task_name=task_name, description="Temp task", attributes=attrs) + task.create() + task.disable() + # DB verification: task is DISABLED + status = get_task_status(task_name) + logger.info("Task status before delete: %s", status) + assert status == "DISABLED" + task.delete(force=False) + # DB verification: task removed + status = get_task_status(task_name) + logger.info("Task status after delete: %s", status) + assert status is None + expect_oracle_error("NOT_FOUND", lambda: Task.fetch(task_name)) + + +def test_3116_missing_instruction(): + task_name = f"{BASE}_NO_INSTRUCTION" + logger.info("Creating task with missing instruction: %s", task_name) + attrs = TaskAttributes(instruction="", tools=None) + task = Task(task_name=task_name, attributes=attrs) + expect_oracle_error("ORA-20051", lambda: task.create()) + +def test_3117_delete_enabled_task_without_force_succeeds(): + task_name = f"{BASE}_FORCE_DELETE_TEST" + logger.info("Creating and deleting enabled task: %s", task_name) + attrs = TaskAttributes(instruction="Delete force test", tools=None) + task = Task(task_name=task_name, attributes=attrs) + task.create(enabled=True) + # DB verification: task is ENABLED + status = get_task_status(task_name) + logger.info("Task status before delete: %s", status) + assert status == "ENABLED" + task.delete(force=False) + # DB verification: task removed + status = get_task_status(task_name) + logger.info("Task status after delete: %s", status) + assert status is None + expect_oracle_error("NOT_FOUND", lambda: Task.fetch(task_name)) + + +def test_3118_delete_disabled_task_with_force_succeeds(): + task_name = f"{BASE}_DISABLED_CREATE" + logger.info("Deleting initially disabled task: %s", task_name) + attrs = TaskAttributes(instruction="Initially disabled task", tools=None) + task = Task(task_name=task_name, attributes=attrs) + task.create(enabled=False) + # DB verification: task is DISABLED + status = get_task_status(task_name) + logger.info("Task status before delete: %s", status) + assert status == "DISABLED" + task.delete(force=True) + # DB verification: task removed + status = get_task_status(task_name) + logger.info("Task status after delete: %s", status) + assert status is None + expect_oracle_error("NOT_FOUND", lambda: Task.fetch(task_name)) diff --git a/tests/agents/test_3201_agents.py b/tests/agents/test_3201_agents.py new file mode 100644 index 0000000..25ebead --- /dev/null +++ b/tests/agents/test_3201_agents.py @@ -0,0 +1,397 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2025, Oracle and/or its affiliates. +# +# Licensed under the Universal Permissive License v 1.0 as shown at +# http://oss.oracle.com/licenses/upl. +# ----------------------------------------------------------------------------- + +""" +3200 - Module for testing select_ai agents +""" + +import uuid +import logging +import pytest +import select_ai +import os +from select_ai.agent import Agent, AgentAttributes +from select_ai.errors import AgentNotFoundError +import oracledb + +# Path +PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")) +LOG_FILE = os.path.join(PROJECT_ROOT, "log", "tkex_test_3201_agents.log") +os.makedirs(os.path.dirname(LOG_FILE), exist_ok=True) + +# Force logging to file (pytest-proof) +root = logging.getLogger() +root.setLevel(logging.INFO) + +for h in root.handlers[:]: + root.removeHandler(h) + +fh = logging.FileHandler(LOG_FILE, mode="w") +fh.setFormatter(logging.Formatter("%(levelname)s: %(message)s")) +root.addHandler(fh) + +logger = logging.getLogger() + + +# ----------------------------------------------------------------------------- +# Per-test logging +# ----------------------------------------------------------------------------- + +@pytest.fixture(autouse=True) +def log_test_name(request): + logger.info(f"--- Starting test: {request.function.__name__} ---") + yield + logger.info(f"--- Finished test: {request.function.__name__} ---") + + +# ----------------------------------------------------------------------------- +# Helper Functions +# ----------------------------------------------------------------------------- + +def get_agent_status(agent_name): + with select_ai.cursor() as cur: + cur.execute(""" + SELECT status + FROM USER_AI_AGENTS + WHERE agent_name = :agent_name + """, {"agent_name": agent_name}) + row = cur.fetchone() + return row[0] if row else None + +# ----------------------------------------------------------------------------- +# Test constants +# ----------------------------------------------------------------------------- + +PYSAI_AGENT_NAME = f"PYSAI_3200_AGENT_{uuid.uuid4().hex.upper()}" +PYSAI_AGENT_DESC = "PYSAI_3200_AGENT_DESCRIPTION" +PYSAI_PROFILE_NAME = f"PYSAI_3200_PROFILE_{uuid.uuid4().hex.upper()}" + +# ----------------------------------------------------------------------------- +# Fixtures +# ----------------------------------------------------------------------------- + +@pytest.fixture(scope="module") +def python_gen_ai_profile(profile_attributes): + logger.info("Creating profile: %s", PYSAI_PROFILE_NAME) + profile = select_ai.Profile( + profile_name=PYSAI_PROFILE_NAME, + description="OCI GENAI Profile", + attributes=profile_attributes, + ) + profile.create(replace=True) + yield profile + logger.info("Deleting profile: %s", PYSAI_PROFILE_NAME) + profile.delete(force=True) + + +@pytest.fixture(scope="module") +def agent_attributes(): + return AgentAttributes( + profile_name=PYSAI_PROFILE_NAME, + role="You are an AI Movie Analyst. You analyze movies.", + enable_human_tool=False, + ) + + +@pytest.fixture(scope="module") +def agent(python_gen_ai_profile, agent_attributes): + logger.info("Creating agent: %s", PYSAI_AGENT_NAME) + agent = Agent( + agent_name=PYSAI_AGENT_NAME, + description=PYSAI_AGENT_DESC, + attributes=agent_attributes, + ) + agent.create(enabled=True, replace=True) + yield agent + logger.info("Deleting agent: %s", PYSAI_AGENT_NAME) + agent.delete(force=True) + +# ----------------------------------------------------------------------------- +# Helpers +# ----------------------------------------------------------------------------- + + +def expect_oracle_error(expected_code, fn): + """ + Run fn and assert that expected Oracle/Agent error occurs. + expected_code: "ORA-xxxxx" or "NOT_FOUND" + """ + try: + fn() + except AgentNotFoundError as e: + logger.info("Expected failure (NOT_FOUND): %s", e) + assert expected_code == "NOT_FOUND" + except oracledb.DatabaseError as e: + msg = str(e) + logger.info("Expected Oracle failure: %s", msg) + assert expected_code in msg, f"Expected {expected_code}, got {msg}" + else: + pytest.fail(f"Expected error {expected_code} did not occur") + +# ----------------------------------------------------------------------------- +# Tests +# ----------------------------------------------------------------------------- + +def test_3200_identity(agent, agent_attributes): + logger.info("Verifying agent identity") + logger.info("Agent name : %s", agent.agent_name) + logger.info("Agent description: %s", agent.description) + logger.info("Agent attributes : %s", agent.attributes) + assert agent.agent_name == PYSAI_AGENT_NAME + assert agent.description == PYSAI_AGENT_DESC + assert agent.attributes == agent_attributes + + +@pytest.mark.parametrize("pattern", [None, ".*", "^PYSAI_3200_AGENT_"]) +def test_3201_list(pattern): + logger.info("Listing agents with pattern: %s", pattern) + agents = list(Agent.list() if pattern is None else Agent.list(pattern)) + names = sorted(a.agent_name for a in agents) + logger.info("Agents found (sorted):") + for name in names: + logger.info(" - %s", name) + + assert PYSAI_AGENT_NAME in names + + +def test_3202_fetch(agent_attributes): + logger.info("Fetching agent: %s", PYSAI_AGENT_NAME) + a = Agent.fetch(PYSAI_AGENT_NAME) + logger.info("Fetched agent name : %s", a.agent_name) + logger.info("Fetched agent description: %s", a.description) + logger.info("Fetched agent attributes : %s", a.attributes) + assert a.agent_name == PYSAI_AGENT_NAME + assert a.attributes == agent_attributes + assert a.description == PYSAI_AGENT_DESC + + +def test_3203_fetch_non_existing(): + name = f"PYSAI_NO_SUCH_AGENT_{uuid.uuid4().hex}" + logger.info("Fetching non-existing agent: %s", name) + expect_oracle_error("NOT_FOUND", lambda: Agent.fetch(name)) + + +def test_3204_disable_enable(agent): + logger.info("Disabling agent: %s", agent.agent_name) + agent.disable() + + status = get_agent_status(agent.agent_name) + logger.info("Agent status after disable: %s", status) + assert status == "DISABLED" + + logger.info("Enabling agent: %s", agent.agent_name) + agent.enable() + + status = get_agent_status(agent.agent_name) + logger.info("Agent status after enable: %s", status) + assert status == "ENABLED" + + +def test_3205_set_attribute(agent): + logger.info("Setting role attribute on agent: %s", agent.agent_name) + agent.set_attribute("role", "You are a DB assistant") + + a = Agent.fetch(PYSAI_AGENT_NAME) + logger.info("Updated role attribute: %s", a.attributes.role) + + assert "DB assistant" in a.attributes.role + + +def test_3206_set_attributes(agent): + logger.info("Replacing agent attributes") + + new_attrs = AgentAttributes( + profile_name=PYSAI_PROFILE_NAME, + role="You are a cloud architect", + enable_human_tool=True, + ) + + logger.info("New attributes: %s", new_attrs) + agent.set_attributes(new_attrs) + + a = Agent.fetch(PYSAI_AGENT_NAME) + logger.info("Fetched attributes after replace: %s", a.attributes) + + assert a.attributes == new_attrs + + +def test_3207_set_attribute_invalid_key(agent): + logger.info("Setting invalid attribute key on agent: %s", agent.agent_name) + expect_oracle_error("ORA-20050", lambda: agent.set_attribute("no_such_key", 123)) + +def test_3208_set_attribute_none(agent): + logger.info("Setting attribute 'role' to None on agent: %s", agent.agent_name) + expect_oracle_error("ORA-20050", lambda: agent.set_attribute("role", None)) + +def test_3209_set_attribute_empty(agent): + logger.info("Setting attribute 'role' to empty string on agent: %s", agent.agent_name) + expect_oracle_error("ORA-20050", lambda: agent.set_attribute("role", "")) + +def test_3210_create_existing_without_replace(agent_attributes): + logger.info("Create existing agent without replace should fail") + a = Agent( + agent_name=PYSAI_AGENT_NAME, + description="X", + attributes=agent_attributes, + ) + expect_oracle_error("ORA-20050", lambda: a.create(replace=False)) + +def test_3211_delete_and_recreate(agent_attributes): + name = f"PYSAI_RECREATE_{uuid.uuid4().hex}" + logger.info("Create agent: %s", name) + #Create agent + a = Agent(name, attributes=agent_attributes) + a.create() + # Verify created + fetched = Agent.fetch(name) + logger.info("Agent created successfully: %s", fetched.agent_name) + assert fetched.agent_name == name + #Delete agent + logger.info("Delete agent: %s", name) + a.delete(force=True) + # Verify deleted + logger.info("Attempting fetch after delete for agent: %s", name) + expect_oracle_error("NOT_FOUND", lambda: Agent.fetch(name)) + logger.info("Agent deleted successfully: %s", name) + #Recreate agent + logger.info("Recreate agent: %s", name) + a.create(replace=False) + # Verify recreated + fetched_recreated = Agent.fetch(name) + logger.info("Agent recreated successfully: %s", fetched_recreated.agent_name) + assert fetched_recreated.agent_name == name + #Final cleanup + logger.info("Cleanup agent: %s", name) + a.delete(force=True) + # Verify cleanup + logger.info("Attempting fetch after delete for agent: %s", name) + expect_oracle_error("NOT_FOUND", lambda: Agent.fetch(name)) + logger.info("Final cleanup successful for agent: %s", name) + + +def test_3212_disable_after_delete(agent_attributes): + name = f"PYSAI_TMP_DEL_{uuid.uuid4().hex}" + logger.info("Creating agent: %s", name) + a = Agent(name, attributes=agent_attributes) + a.create() + logger.info("Agent created successfully: %s", name) + + logger.info("Fetching agent to verify creation: %s", name) + fetched = Agent.fetch(name) + logger.info("Fetched agent: %s", fetched.agent_name) + + logger.info("Deleting agent: %s", name) + a.delete(force=True) + logger.info("Agent deleted, verifying deletion: %s", name) + logger.info("Attempting fetch after delete for agent: %s", name) + expect_oracle_error("NOT_FOUND", lambda: Agent.fetch(name)) + logger.info("Confirmed agent no longer exists: %s", name) + + logger.info("Attempting to disable deleted agent: %s", name) + expect_oracle_error("ORA-20050", lambda: a.disable()) + logger.info("Disable after delete confirmed error for agent: %s", name) + + +def test_3213_enable_after_delete(agent_attributes): + name = f"PYSAI_TMP_DEL_{uuid.uuid4().hex}" + logger.info("Creating agent: %s", name) + a = Agent(name, attributes=agent_attributes) + a.create() + logger.info("Agent created successfully: %s", name) + + logger.info("Fetching agent to verify creation: %s", name) + fetched = Agent.fetch(name) + logger.info("Fetched agent: %s", fetched.agent_name) + + logger.info("Deleting agent: %s", name) + a.delete(force=True) + logger.info("Agent deleted, verifying deletion: %s", name) + logger.info("Attempting fetch after delete for agent: %s", name) + expect_oracle_error("NOT_FOUND", lambda: Agent.fetch(name)) + logger.info("Confirmed agent no longer exists: %s", name) + + logger.info("Attempting to enable deleted agent: %s", name) + expect_oracle_error("ORA-20050", lambda: a.enable()) + logger.info("Enable after delete confirmed error for agent: %s", name) + + +def test_3214_set_attribute_after_delete(agent_attributes): + name = f"PYSAI_TMP_DEL_{uuid.uuid4().hex}" + logger.info("Creating agent: %s", name) + a = Agent(name, attributes=agent_attributes) + a.create() + logger.info("Agent created successfully: %s", name) + + logger.info("Fetching agent to verify creation: %s", name) + fetched = Agent.fetch(name) + logger.info("Fetched agent: %s", fetched.agent_name) + + logger.info("Deleting agent: %s", name) + a.delete(force=True) + logger.info("Agent deleted, verifying deletion: %s", name) + logger.info("Attempting fetch after delete for agent: %s", name) + expect_oracle_error("NOT_FOUND", lambda: Agent.fetch(name)) + logger.info("Confirmed agent no longer exists: %s", name) + + logger.info("Attempting to set attribute on deleted agent: %s", name) + expect_oracle_error("ORA-20050", lambda: a.set_attribute("role", "X")) + logger.info("Set attribute after delete confirmed error for agent: %s", name) + + +def test_3215_double_delete(agent_attributes): + name = f"PYSAI_TMP_DOUBLE_DEL_{uuid.uuid4().hex}" + logger.info("Creating agent: %s", name) + a = Agent(name, attributes=agent_attributes) + a.create() + logger.info("Agent created successfully: %s", name) + + logger.info("Fetching agent to verify creation: %s", name) + fetched = Agent.fetch(name) + logger.info("Fetched agent: %s", fetched.agent_name) + + logger.info("Deleting agent first time: %s", name) + a.delete(force=True) + logger.info("First delete done, verifying deletion: %s", name) + logger.info("Attempting fetch after first delete for agent: %s", name) + expect_oracle_error("NOT_FOUND", lambda: Agent.fetch(name)) + logger.info("Confirmed agent no longer exists: %s", name) + + logger.info("Deleting agent second time (should not fail): %s", name) + a.delete(force=True) + logger.info("Second delete completed, verifying still deleted: %s", name) + expect_oracle_error("NOT_FOUND", lambda: Agent.fetch(name)) + logger.info("Confirmed agent still does not exist after double delete: %s", name) + + +def test_3216_fetch_after_delete(agent_attributes): + name = f"PYSAI_TMP_FETCH_DEL_{uuid.uuid4().hex}" + logger.info("Creating agent: %s", name) + a = Agent(name, attributes=agent_attributes) + a.create() + logger.info("Agent created successfully: %s", name) + + logger.info("Fetching agent to verify creation: %s", name) + fetched = Agent.fetch(name) + logger.info("Fetched agent: %s", fetched.agent_name) + + logger.info("Deleting agent: %s", name) + a.delete(force=True) + logger.info("Agent deleted, verifying deletion: %s", name) + logger.info("Attempting fetch after delete for agent: %s", name) + expect_oracle_error("NOT_FOUND", lambda: Agent.fetch(name)) + logger.info("Confirmed agent no longer exists: %s", name) + + +def test_3217_list_all_non_empty(): + logger.info("Listing all agents") + agents = list(Agent.list()) + names = sorted(a.agent_name for a in agents) + logger.info("Total agents found: %d", len(names)) + logger.info("Agent names:") + for name in names: + logger.info(" - %s", name) + assert len(names) > 0 diff --git a/tests/agents/test_3201_async_agents.py b/tests/agents/test_3201_async_agents.py new file mode 100644 index 0000000..dcc5b7b --- /dev/null +++ b/tests/agents/test_3201_async_agents.py @@ -0,0 +1,284 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2025, Oracle and/or its affiliates. +# +# Licensed under the Universal Permissive License v 1.0 as shown at +# http://oss.oracle.com/licenses/upl. +# ----------------------------------------------------------------------------- + +""" +3200 - Module for testing select_ai async agents +""" + +import logging +import os +import uuid + +import oracledb +import pytest +import select_ai +from select_ai.agent import AgentAttributes, AsyncAgent +from select_ai.errors import AgentNotFoundError + +pytestmark = pytest.mark.anyio + +PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")) +LOG_FILE = os.path.join(PROJECT_ROOT, "log", "tkex_test_3200_async_agents.log") +os.makedirs(os.path.dirname(LOG_FILE), exist_ok=True) + +root = logging.getLogger() +root.setLevel(logging.INFO) +for handler in root.handlers[:]: + root.removeHandler(handler) +file_handler = logging.FileHandler(LOG_FILE, mode="w") +file_handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s")) +root.addHandler(file_handler) +logger = logging.getLogger() + +PYSAI_3200_AGENT_NAME = f"PYSAI_3200_AGENT_{uuid.uuid4().hex.upper()}" +PYSAI_3200_AGENT_DESCRIPTION = "PYSAI_3200_AGENT_DESCRIPTION" +PYSAI_3200_PROFILE_NAME = f"PYSAI_3200_PROFILE_{uuid.uuid4().hex.upper()}" +PYSAI_3200_DISABLED_AGENT_NAME = ( + f"PYSAI_3200_DISABLED_AGENT_{uuid.uuid4().hex.upper()}" +) +PYSAI_3200_MISSING_AGENT_NAME = ( + f"PYSAI_3200_MISSING_AGENT_{uuid.uuid4().hex.upper()}" +) + + +@pytest.fixture(autouse=True) +def log_test_name(request): + logger.info("--- Starting test: %s ---", request.function.__name__) + yield + logger.info("--- Finished test: %s ---", request.function.__name__) + + +@pytest.fixture(scope="module", autouse=True) +async def async_connect(test_env): + logger.info("Opening async database connection") + await select_ai.async_connect(**test_env.connect_params()) + yield + logger.info("Closing async database connection") + await select_ai.async_disconnect() + + +def log_agent_details(context: str, agent) -> None: + attrs = getattr(agent, "attributes", None) + details = { + "context": context, + "agent_name": getattr(agent, "agent_name", None), + "description": getattr(agent, "description", None), + "profile_name": getattr(attrs, "profile_name", None) if attrs else None, + "role": getattr(attrs, "role", None) if attrs else None, + "enable_human_tool": ( + getattr(attrs, "enable_human_tool", None) if attrs else None + ), + } + logger.info("AGENT_DETAILS: %s", details) + print("AGENT_DETAILS:", details) + + +async def get_agent_status(agent_name): + logger.info("Fetching agent status for: %s", agent_name) + async with select_ai.async_cursor() as cur: + await cur.execute( + """ + SELECT status + FROM USER_AI_AGENTS + WHERE agent_name = :agent_name + """, + {"agent_name": agent_name}, + ) + row = await cur.fetchone() + return row[0] if row else None + + +async def assert_agent_status(agent_name: str, expected_status: str) -> None: + status = await get_agent_status(agent_name) + logger.info( + "Verifying agent status | agent=%s | expected=%s | actual=%s", + agent_name, + expected_status, + status, + ) + assert status == expected_status + + +@pytest.fixture(scope="module") +async def async_python_gen_ai_profile(profile_attributes): + logger.info("Creating profile: %s", PYSAI_3200_PROFILE_NAME) + profile = await select_ai.AsyncProfile( + profile_name=PYSAI_3200_PROFILE_NAME, + description="OCI GENAI Profile", + attributes=profile_attributes, + ) + yield profile + logger.info("Deleting profile: %s", PYSAI_3200_PROFILE_NAME) + await profile.delete(force=True) + + +@pytest.fixture(scope="module") +def agent_attributes(): + return AgentAttributes( + profile_name=PYSAI_3200_PROFILE_NAME, + role=( + "You are an AI Movie Analyst. " + "You can help answer movie-related questions." + ), + enable_human_tool=False, + ) + + +@pytest.fixture(scope="module") +async def agent(async_python_gen_ai_profile, agent_attributes): + logger.info("Creating async agent: %s", PYSAI_3200_AGENT_NAME) + agent_obj = AsyncAgent( + agent_name=PYSAI_3200_AGENT_NAME, + attributes=agent_attributes, + description=PYSAI_3200_AGENT_DESCRIPTION, + ) + await agent_obj.create(enabled=True, replace=True) + yield agent_obj + logger.info("Deleting async agent: %s", PYSAI_3200_AGENT_NAME) + await agent_obj.delete(force=True) + + +async def test_3200_identity(agent, agent_attributes): + log_agent_details("test_3200_identity", agent) + assert agent.agent_name == PYSAI_3200_AGENT_NAME + assert agent.attributes == agent_attributes + assert agent.description == PYSAI_3200_AGENT_DESCRIPTION + assert agent.attributes.enable_human_tool is False + + +@pytest.mark.parametrize("agent_name_pattern", [None, ".*", "^PYSAI_3200_AGENT_"]) +async def test_3201_list(agent_name_pattern): + logger.info("Listing agents with pattern=%s", agent_name_pattern) + if agent_name_pattern: + agents = [ + a async for a in select_ai.agent.AsyncAgent.list(agent_name_pattern) + ] + else: + agents = [a async for a in select_ai.agent.AsyncAgent.list()] + + for a in agents: + if a.agent_name == PYSAI_3200_AGENT_NAME: + log_agent_details("test_3201_list", a) + + agent_names = set(a.agent_name for a in agents) + agent_descriptions = set(a.description for a in agents) + assert len(agents) >= 1 + assert PYSAI_3200_AGENT_NAME in agent_names + assert PYSAI_3200_AGENT_DESCRIPTION in agent_descriptions + + +async def test_3202_fetch(agent_attributes): + a = await AsyncAgent.fetch(agent_name=PYSAI_3200_AGENT_NAME) + log_agent_details("test_3202_fetch", a) + assert a.agent_name == PYSAI_3200_AGENT_NAME + assert a.attributes == agent_attributes + assert a.description == PYSAI_3200_AGENT_DESCRIPTION + + +async def test_3203_fetch_non_existing(): + name = f"PYSAI_NO_SUCH_AGENT_{uuid.uuid4().hex.upper()}" + logger.info("Fetching non-existing async agent: %s", name) + with pytest.raises(AgentNotFoundError) as exc: + await AsyncAgent.fetch(name) + logger.info("Received expected error: %s", exc.value) + + +async def test_3204_create_agent_default_status_enabled(agent_attributes): + name = f"PYSAI_3200_STATUS_ENABLED_{uuid.uuid4().hex.upper()}" + a = AsyncAgent( + agent_name=name, + description="Default enabled status", + attributes=agent_attributes, + ) + await a.create(replace=True) + try: + await assert_agent_status(name, "ENABLED") + fetched = await AsyncAgent.fetch(name) + log_agent_details("test_3204_create_agent_default_status_enabled", fetched) + assert fetched.description == "Default enabled status" + finally: + await a.delete(force=True) + + +async def test_3205_create_agent_with_enabled_false_sets_disabled(agent_attributes): + a = AsyncAgent( + agent_name=PYSAI_3200_DISABLED_AGENT_NAME, + description="Initially disabled", + attributes=agent_attributes, + ) + await a.create(enabled=False, replace=True) + try: + await assert_agent_status(PYSAI_3200_DISABLED_AGENT_NAME, "DISABLED") + fetched = await AsyncAgent.fetch(PYSAI_3200_DISABLED_AGENT_NAME) + log_agent_details( + "test_3205_create_agent_with_enabled_false_sets_disabled", fetched + ) + assert fetched.description == "Initially disabled" + finally: + await a.delete(force=True) + + +async def test_3206_set_attribute(agent): + logger.info("Setting role attribute on async agent: %s", agent.agent_name) + await agent.set_attribute("role", "You are a DB assistant") + updated = await AsyncAgent.fetch(PYSAI_3200_AGENT_NAME) + log_agent_details("test_3206_set_attribute", updated) + assert "DB assistant" in updated.attributes.role + + +async def test_3207_set_attributes(agent): + logger.info("Replacing async agent attributes") + new_attrs = AgentAttributes( + profile_name=PYSAI_3200_PROFILE_NAME, + role="You are a cloud architect", + enable_human_tool=True, + ) + await agent.set_attributes(new_attrs) + updated = await AsyncAgent.fetch(PYSAI_3200_AGENT_NAME) + log_agent_details("test_3207_set_attributes", updated) + assert updated.attributes == new_attrs + + +async def test_3208_set_attribute_invalid_key(agent): + logger.info("Setting invalid attribute key on async agent") + with pytest.raises(oracledb.DatabaseError) as exc: + await agent.set_attribute("no_such_key", 123) + logger.info("Received expected Oracle error: %s", exc.value) + assert "ORA-20050" in str(exc.value) + + +async def test_3209_drop_agent_force_true_non_existent(): + logger.info("Dropping missing agent with force=True") + a = AsyncAgent(agent_name=PYSAI_3200_MISSING_AGENT_NAME) + await a.delete(force=True) + status = await get_agent_status(PYSAI_3200_MISSING_AGENT_NAME) + logger.info("Status after force delete on missing agent: %s", status) + assert status is None + + +async def test_3210_drop_agent_force_false_non_existent_raises(): + logger.info("Dropping missing agent with force=False") + a = AsyncAgent(agent_name=PYSAI_3200_MISSING_AGENT_NAME) + with pytest.raises(oracledb.DatabaseError) as exc: + await a.delete(force=False) + logger.info("Received expected Oracle error: %s", exc.value) + + +async def test_3211_create_requires_agent_name(agent_attributes): + logger.info("Validating async create() requires agent_name") + with pytest.raises(AttributeError) as exc: + await AsyncAgent(attributes=agent_attributes).create() + logger.info("Received expected error: %s", exc.value) + + +async def test_3212_create_requires_attributes(): + logger.info("Validating async create() requires attributes") + with pytest.raises(AttributeError) as exc: + await AsyncAgent( + agent_name=f"PYSAI_3200_NO_ATTR_{uuid.uuid4().hex.upper()}" + ).create() + logger.info("Received expected error: %s", exc.value) diff --git a/tests/agents/test_3301_async_teams.py b/tests/agents/test_3301_async_teams.py new file mode 100644 index 0000000..0d06959 --- /dev/null +++ b/tests/agents/test_3301_async_teams.py @@ -0,0 +1,385 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2025, Oracle and/or its affiliates. +# +# Licensed under the Universal Permissive License v 1.0 as shown at +# http://oss.oracle.com/licenses/upl. +# ----------------------------------------------------------------------------- + +""" +3301 - Async contract, regression and corner-case tests for select_ai.agent.AsyncTeam +""" + +import logging +import os +import uuid + +import oracledb +import pytest +import select_ai +from select_ai.agent import ( + AgentAttributes, + AsyncAgent, + AsyncTask, + AsyncTeam, + TaskAttributes, + TeamAttributes, +) +from select_ai.errors import AgentTeamNotFoundError + +pytestmark = pytest.mark.anyio + +# ----------------------------------------------------------------------------- +# Logging +# ----------------------------------------------------------------------------- + +PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")) +LOG_DIR = os.path.join(PROJECT_ROOT, "log") +os.makedirs(LOG_DIR, exist_ok=True) +LOG_FILE = os.path.join(LOG_DIR, "tkex_test_3301_async_teams.log") + +root = logging.getLogger() +root.setLevel(logging.INFO) +for h in root.handlers[:]: + root.removeHandler(h) + +fh = logging.FileHandler(LOG_FILE, mode="w") +fh.setFormatter(logging.Formatter("%(levelname)s: %(message)s")) +root.addHandler(fh) + +logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) + + +# ----------------------------------------------------------------------------- +# Per-test logging + async connection +# ----------------------------------------------------------------------------- + +@pytest.fixture(autouse=True) +def log_test_name(request): + logger.info("--- Starting test: %s ---", request.function.__name__) + yield + logger.info("--- Finished test: %s ---", request.function.__name__) + + +@pytest.fixture(scope="module", autouse=True) +async def async_connect(test_env): + logger.info("Opening async database connection") + await select_ai.async_connect(**test_env.connect_params()) + yield + logger.info("Closing async database connection") + await select_ai.async_disconnect() + + +# ----------------------------------------------------------------------------- +# Helpers +# ----------------------------------------------------------------------------- + +async def expect_async_error(expected_code, coro_fn): + """ + expected_code: + - "NOT_FOUND" + - "ORA-20053" + - "ORA-xxxxx" + """ + try: + await coro_fn() + except AgentTeamNotFoundError as exc: + logger.info("Expected failure (NOT_FOUND): %s", exc) + assert expected_code == "NOT_FOUND" + except oracledb.DatabaseError as exc: + msg = str(exc) + logger.info("Expected Oracle failure: %s", msg) + assert expected_code in msg, f"Expected {expected_code}, got: {msg}" + except Exception as exc: + msg = str(exc) + logger.info("Expected generic failure: %s", msg) + assert expected_code in msg, f"Expected {expected_code}, got: {msg}" + else: + pytest.fail(f"Expected error {expected_code} did not occur") + + +def log_team_details(context: str, team) -> None: + attrs = getattr(team, "attributes", None) + details = { + "context": context, + "team_name": getattr(team, "team_name", None), + "description": getattr(team, "description", None), + "process": getattr(attrs, "process", None) if attrs else None, + "agents": getattr(attrs, "agents", None) if attrs else None, + } + logger.info("TEAM_DETAILS: %s", details) + print("TEAM_DETAILS:", details) + + +async def get_team_status(team_name: str): + logger.info("Fetching team status for: %s", team_name) + async with select_ai.async_cursor() as cur: + await cur.execute( + """ + SELECT status + FROM USER_AI_AGENT_TEAMS + WHERE agent_team_name = :team_name + """, + {"team_name": team_name}, + ) + row = await cur.fetchone() + return row[0] if row else None + + +async def assert_team_status(team_name: str, expected_status: str) -> None: + status = await get_team_status(team_name) + logger.info( + "Verifying team status | team=%s | expected=%s | actual=%s", + team_name, + expected_status, + status, + ) + assert status == expected_status + + +# ----------------------------------------------------------------------------- +# Test constants +# ----------------------------------------------------------------------------- + +PYSAI_TEAM_AGENT_NAME = f"PYSAI_TEAM_AGENT_{uuid.uuid4().hex.upper()}" +PYSAI_TEAM_PROFILE_NAME = f"PYSAI_TEAM_PROFILE_{uuid.uuid4().hex.upper()}" +PYSAI_TEAM_TASK_NAME = f"PYSAI_TEAM_TASK_{uuid.uuid4().hex.upper()}" +PYSAI_TEAM_NAME = f"PYSAI_TEAM_{uuid.uuid4().hex.upper()}" +PYSAI_TEAM_DESC = "PYSAI ASYNC TEAM FINAL CONTRACT TEST" + + +# ----------------------------------------------------------------------------- +# Fixtures +# ----------------------------------------------------------------------------- + +@pytest.fixture(scope="module") +async def python_gen_ai_profile(profile_attributes): + logger.info("Creating profile: %s", PYSAI_TEAM_PROFILE_NAME) + + oci_compartment_id = os.getenv("PYSAI_TEST_OCI_COMPARTMENT_ID") + if oci_compartment_id: + profile_attributes.oci_compartment_id = oci_compartment_id + + profile = await select_ai.AsyncProfile( + profile_name=PYSAI_TEAM_PROFILE_NAME, + description="OCI GENAI Profile", + attributes=profile_attributes, + ) + + yield profile + + logger.info("Deleting profile: %s", PYSAI_TEAM_PROFILE_NAME) + await profile.delete(force=True) + + +@pytest.fixture(scope="module") +def task_attributes(): + return TaskAttributes( + instruction="Help the user. Question: {query}", + enable_human_tool=False, + ) + + +@pytest.fixture(scope="module") +async def task(task_attributes): + logger.info("Creating task: %s", PYSAI_TEAM_TASK_NAME) + task_obj = AsyncTask( + task_name=PYSAI_TEAM_TASK_NAME, + description="Test Task", + attributes=task_attributes, + ) + await task_obj.create(replace=True) + yield task_obj + logger.info("Deleting task: %s", PYSAI_TEAM_TASK_NAME) + await task_obj.delete(force=True) + + +@pytest.fixture(scope="module") +async def agent(python_gen_ai_profile): + logger.info("Creating agent: %s", PYSAI_TEAM_AGENT_NAME) + agent_obj = AsyncAgent( + agent_name=PYSAI_TEAM_AGENT_NAME, + description="Test Agent", + attributes=AgentAttributes( + profile_name=PYSAI_TEAM_PROFILE_NAME, + role="You are a helpful AI assistant", + enable_human_tool=False, + ), + ) + await agent_obj.create(enabled=True, replace=True) + yield agent_obj + logger.info("Deleting agent: %s", PYSAI_TEAM_AGENT_NAME) + await agent_obj.delete(force=True) + + +@pytest.fixture(scope="module") +def team_attributes(agent, task): + return TeamAttributes( + agents=[{"name": agent.agent_name, "task": task.task_name}], + process="sequential", + ) + + +@pytest.fixture(scope="module") +async def team(team_attributes): + logger.info("Creating team: %s", PYSAI_TEAM_NAME) + team_obj = AsyncTeam( + team_name=PYSAI_TEAM_NAME, + attributes=team_attributes, + description=PYSAI_TEAM_DESC, + ) + await team_obj.create(enabled=True, replace=True) + yield team_obj + logger.info("Deleting team: %s", PYSAI_TEAM_NAME) + await team_obj.delete(force=True) + + +# ----------------------------------------------------------------------------- +# Tests +# ----------------------------------------------------------------------------- + +async def test_3300_create_and_identity(team, team_attributes): + log_team_details("test_3300_create_and_identity", team) + assert team.team_name == PYSAI_TEAM_NAME + assert team.description == PYSAI_TEAM_DESC + assert team.attributes == team_attributes + + +@pytest.mark.parametrize("pattern", [None, ".*", "^PYSAI_TEAM_"]) +async def test_3301_list(pattern): + logger.info("Listing teams using pattern: %s", pattern) + teams = [t async for t in AsyncTeam.list(pattern)] if pattern else [t async for t in AsyncTeam.list()] + for t in teams: + if t.team_name == PYSAI_TEAM_NAME: + log_team_details("test_3301_list", t) + names = [t.team_name for t in teams] + assert PYSAI_TEAM_NAME in names + + +async def test_3302_fetch(team_attributes): + t = await AsyncTeam.fetch(PYSAI_TEAM_NAME) + log_team_details("test_3302_fetch", t) + assert t.attributes == team_attributes + + +async def test_3303_run(team): + response = await team.run( + prompt="What is 2+2?", + params={"conversation_id": str(uuid.uuid4())}, + ) + logger.info("Team run response: %s", response) + assert isinstance(response, str) + assert len(response) > 0 + + +async def test_3304_disable_enable_contract(team): + logger.info("Disabling team: %s", team.team_name) + await team.disable() + await assert_team_status(team.team_name, "DISABLED") + await expect_async_error("ORA-20053", lambda: team.disable()) + + logger.info("Enabling team: %s", team.team_name) + await team.enable() + await assert_team_status(team.team_name, "ENABLED") + await expect_async_error("ORA-20053", lambda: team.enable()) + + +async def test_3305_set_attribute_process(team): + await team.set_attribute("process", "sequential") + fetched = await AsyncTeam.fetch(PYSAI_TEAM_NAME) + log_team_details("test_3305_set_attribute_process", fetched) + assert fetched.attributes.process == "sequential" + + +async def test_3306_set_attributes(team, agent, task): + new_attrs = TeamAttributes( + agents=[{"name": agent.agent_name, "task": task.task_name}], + process="sequential", + ) + await team.set_attributes(new_attrs) + fetched = await AsyncTeam.fetch(PYSAI_TEAM_NAME) + log_team_details("test_3306_set_attributes", fetched) + assert fetched.attributes == new_attrs + + +async def test_3307_replace_create(team_attributes): + team2 = AsyncTeam(PYSAI_TEAM_NAME, team_attributes, "REPLACED DESC") + await team2.create(enabled=True, replace=True) + fetched = await AsyncTeam.fetch(PYSAI_TEAM_NAME) + log_team_details("test_3307_replace_create", fetched) + assert fetched.description == "REPLACED DESC" + + +async def test_3308_fetch_non_existing(): + name = f"NO_SUCH_{uuid.uuid4().hex.upper()}" + await expect_async_error("NOT_FOUND", lambda: AsyncTeam.fetch(name)) + + +async def test_3311_set_attribute_invalid_key(team): + await expect_async_error("ORA-20053", lambda: team.set_attribute("no_such_attr", "x")) + + +async def test_3312_set_attribute_none(team): + await expect_async_error("ORA-20053", lambda: team.set_attribute("process", None)) + + +async def test_3313_set_attribute_empty(team): + await expect_async_error("ORA-20053", lambda: team.set_attribute("process", "")) + + +async def test_3314_set_attribute_invalid_value(team): + await expect_async_error( + "ORA-20053", + lambda: team.set_attribute("process", "not_a_real_process"), + ) + + +async def test_3315_disable_after_delete(team_attributes): + name = f"TMP_{uuid.uuid4().hex.upper()}" + t = AsyncTeam(name, team_attributes, "TMP") + await t.create() + await t.delete(force=True) + await expect_async_error("ORA-20053", lambda: t.disable()) + + +async def test_3316_enable_after_delete(team_attributes): + name = f"TMP_{uuid.uuid4().hex.upper()}" + t = AsyncTeam(name, team_attributes, "TMP") + await t.create() + await t.delete(force=True) + await expect_async_error("ORA-20053", lambda: t.enable()) + + +async def test_3317_set_attribute_after_delete(team_attributes): + name = f"TMP_{uuid.uuid4().hex.upper()}" + t = AsyncTeam(name, team_attributes, "TMP") + await t.create() + await t.delete(force=True) + await expect_async_error("ORA-20053", lambda: t.set_attribute("process", "sequential")) + + +async def test_3318_double_delete(team_attributes): + name = f"TMP_{uuid.uuid4().hex.upper()}" + t = AsyncTeam(name, team_attributes, "TMP") + await t.create() + await t.delete(force=True) + await expect_async_error("ORA-20053", lambda: t.delete(force=False)) + + +async def test_3319_create_existing_without_replace(team_attributes): + name = f"TMP_{uuid.uuid4().hex.upper()}" + t1 = AsyncTeam(name, team_attributes, "TMP1") + await t1.create(replace=False) + await expect_async_error( + "ORA-20053", + lambda: AsyncTeam(name, team_attributes, "TMP2").create(replace=False), + ) + await t1.delete(force=True) + + +async def test_3320_fetch_after_delete(team_attributes): + name = f"TMP_{uuid.uuid4().hex.upper()}" + t = AsyncTeam(name, team_attributes, "TMP") + await t.create() + await t.delete(force=True) + await expect_async_error("NOT_FOUND", lambda: AsyncTeam.fetch(name)) diff --git a/tests/agents/test_3301_teams.py b/tests/agents/test_3301_teams.py new file mode 100644 index 0000000..66398f9 --- /dev/null +++ b/tests/agents/test_3301_teams.py @@ -0,0 +1,415 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2025, Oracle and/or its affiliates. +# +# Licensed under the Universal Permissive License v 1.0 as shown at +# http://oss.oracle.com/licenses/upl. +# ----------------------------------------------------------------------------- + +""" +3301 - Final contract, regression and corner-case tests for select_ai.agent.Team +""" + +import uuid +import logging +import os +import pytest +import select_ai +import oracledb + +from select_ai.agent import ( + Agent, + AgentAttributes, + Task, + TaskAttributes, + Team, + TeamAttributes, +) + +from select_ai.errors import AgentTeamNotFoundError + +# ----------------------------------------------------------------------------- +# Logging +# ----------------------------------------------------------------------------- + +PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")) +LOG_DIR = os.path.join(PROJECT_ROOT, "log") +os.makedirs(LOG_DIR, exist_ok=True) + +LOG_FILE = os.path.join(LOG_DIR, "tkex_test_3301_teams.log") + +root = logging.getLogger() +root.setLevel(logging.INFO) + +for h in root.handlers[:]: + root.removeHandler(h) + +fh = logging.FileHandler(LOG_FILE, mode="w") +fh.setFormatter(logging.Formatter("%(levelname)s: %(message)s")) +root.addHandler(fh) + +LOGGER = logging.getLogger(__name__) +LOGGER.setLevel(logging.INFO) + +def log_step(msg): + LOGGER.info("%s", msg) + +def log_ok(msg): + LOGGER.info("%s", msg) +logger = LOGGER + + +# ----------------------------------------------------------------------------- +# Per-test logging +# ----------------------------------------------------------------------------- + +@pytest.fixture(autouse=True) +def log_test_name(request): + logger.info(f"--- Starting test: {request.function.__name__} ---") + yield + logger.info(f"--- Finished test: {request.function.__name__} ---") + + +# ----------------------------------------------------------------------------- +# Strict error checker (LIKE 3101 / 3201) +# ----------------------------------------------------------------------------- + +def expect_error(expected_code, fn): + """ + expected_code: + - "NOT_FOUND" + - "ORA-20051" + - "ORA-xxxxx" + """ + try: + fn() + except AgentTeamNotFoundError as e: + LOGGER.info("Expected failure (NOT_FOUND): %s", e) + assert expected_code == "NOT_FOUND" + except oracledb.DatabaseError as e: + msg = str(e) + LOGGER.info("Expected Oracle failure: %s", msg) + assert expected_code in msg, f"Expected {expected_code}, got: {msg}" + except Exception as e: + LOGGER.info("Expected generic failure: %s", e) + assert expected_code in str(e), f"Expected {expected_code}, got: {e}" + else: + pytest.fail(f"Expected error {expected_code} did not occur") + +# ----------------------------------------------------------------------------- +# Test constants +# ----------------------------------------------------------------------------- + +PYSAI_TEAM_AGENT_NAME = f"PYSAI_TEAM_AGENT_{uuid.uuid4().hex.upper()}" +PYSAI_TEAM_PROFILE_NAME = f"PYSAI_TEAM_PROFILE_{uuid.uuid4().hex.upper()}" +PYSAI_TEAM_TASK_NAME = f"PYSAI_TEAM_TASK_{uuid.uuid4().hex.upper()}" +PYSAI_TEAM_NAME = f"PYSAI_TEAM_{uuid.uuid4().hex.upper()}" +PYSAI_TEAM_DESC = "PYSAI TEAM FINAL CONTRACT TEST" + +# ----------------------------------------------------------------------------- +# Fixtures +# ----------------------------------------------------------------------------- + +# @pytest.fixture(scope="module", autouse=True) +# def _connect(): +# select_ai.connect() +# yield +# select_ai.disconnect() + +# @pytest.fixture(scope="module") +# def profile_attributes(): +# return { +# "provider": "oci_genai", +# "model": "cohere.command-r-plus" +# } + +@pytest.fixture(scope="module") +def python_gen_ai_profile(profile_attributes): + log_step(f"Creating profile: {PYSAI_TEAM_PROFILE_NAME}") + + oci_compartment_id = os.getenv("PYSAI_TEST_OCI_COMPARTMENT_ID") + if not oci_compartment_id: + raise RuntimeError("PYSAI_TEST_OCI_COMPARTMENT_ID not set") + + # ---- EXTEND existing ProfileAttributes object ---- + profile_attributes.oci_compartment_id = oci_compartment_id + + # ---- STRICT TYPE CHECK ---- + assert isinstance( + profile_attributes, + select_ai.ProfileAttributes + ), "profile_attributes must be ProfileAttributes object" + + profile = select_ai.Profile( + profile_name=PYSAI_TEAM_PROFILE_NAME, + description="OCI GENAI Profile", + attributes=profile_attributes, # <-- pass object, NOT dict + ) + + profile.create(replace=True) + + yield profile + + log_step(f"Deleting profile: {PYSAI_TEAM_PROFILE_NAME}") + profile.delete(force=True) + + +@pytest.fixture(scope="module") +def task_attributes(): + return TaskAttributes( + instruction="Help the user. Question: {query}", + enable_human_tool=False, + ) + +@pytest.fixture(scope="module") +def task(task_attributes): + log_step(f"Creating task: {PYSAI_TEAM_TASK_NAME}") + task = Task( + task_name=PYSAI_TEAM_TASK_NAME, + description="Test Task", + attributes=task_attributes, + ) + task.create(replace=True) + yield task + log_step(f"Deleting task: {PYSAI_TEAM_TASK_NAME}") + task.delete(force=True) + +@pytest.fixture(scope="module") +def agent(python_gen_ai_profile): + log_step(f"Creating agent: {PYSAI_TEAM_AGENT_NAME}") + agent = Agent( + agent_name=PYSAI_TEAM_AGENT_NAME, + description="Test Agent", + attributes=AgentAttributes( + profile_name=PYSAI_TEAM_PROFILE_NAME, + role="You are a helpful AI assistant", + enable_human_tool=False, + ), + ) + agent.create(enabled=True, replace=True) + yield agent + log_step(f"Deleting agent: {PYSAI_TEAM_AGENT_NAME}") + agent.delete(force=True) + +@pytest.fixture(scope="module") +def team_attributes(agent, task): + return TeamAttributes( + agents=[{"name": agent.agent_name, "task": task.task_name}], + process="sequential", + ) + +@pytest.fixture(scope="module") +def team(team_attributes): + log_step(f"Creating team: {PYSAI_TEAM_NAME}") + team = Team( + team_name=PYSAI_TEAM_NAME, + attributes=team_attributes, + description=PYSAI_TEAM_DESC, + ) + team.create(enabled=True, replace=True) + yield team + log_step(f"Deleting team: {PYSAI_TEAM_NAME}") + team.delete(force=True) + +# ----------------------------------------------------------------------------- +# Tests +# ----------------------------------------------------------------------------- + +# ----------------------------------------------------------------------------- +# Logging-enhanced Team tests +# ----------------------------------------------------------------------------- + +def test_3300_create_and_identity(team, team_attributes): + log_step("Validating team identity and attributes") + log_step(f"Team name: {team.team_name}") + log_step(f"Team description: {team.description}") + log_step(f"Team attributes: {team.attributes}") + assert team.team_name == PYSAI_TEAM_NAME + assert team.description == PYSAI_TEAM_DESC + assert team.attributes == team_attributes + log_ok("Team identity and attributes OK") + + +@pytest.mark.parametrize("pattern", [None, ".*", "^PYSAI_TEAM_"]) +def test_3301_list(pattern): + log_step(f"Listing teams using pattern: {pattern}") + teams = list(Team.list(pattern)) if pattern else list(Team.list()) + names = [t.team_name for t in teams] + log_step(f"Teams found: {names}") + assert PYSAI_TEAM_NAME in names + log_ok("Team found in list") + + +def test_3302_fetch(team_attributes): + log_step(f"Fetching team: {PYSAI_TEAM_NAME}") + t = Team.fetch(PYSAI_TEAM_NAME) + log_step(f"Fetched team attributes: {t.attributes}") + assert t.attributes == team_attributes + log_ok("Fetch OK") + + +def test_3303_run(team): + log_step(f"Running team: {team.team_name}") + response = team.run("What is 2+2?", {"conversation_id": str(uuid.uuid4())}) + log_step(f"Team run response: {response}") + assert isinstance(response, str) + assert len(response) > 0 + log_ok("Run OK") + + +def test_3304_disable_enable_contract(team): + log_step(f"Disabling team: {team.team_name}") + team.disable() + log_step("Team disabled successfully") + expect_error("ORA-20053", lambda: team.disable()) + log_step(f"Enabling team: {team.team_name}") + team.enable() + log_step("Team enabled successfully") + expect_error("ORA-20053", lambda: team.enable()) + + +def test_3305_set_attribute_process(team): + log_step(f"Setting team attribute 'process' to 'sequential': {team.team_name}") + team.set_attribute("process", "sequential") + fetched = Team.fetch(PYSAI_TEAM_NAME) + log_step(f"Fetched attribute process: {fetched.attributes.process}") + assert fetched.attributes.process == "sequential" + log_ok("Set attribute OK") + + +def test_3306_set_attributes(team, agent, task): + new_attrs = TeamAttributes( + agents=[{"name": agent.agent_name, "task": task.task_name}], + process="sequential", + ) + log_step(f"Replacing team attributes: {team.team_name}") + log_step(f"New attributes: {new_attrs}") + team.set_attributes(new_attrs) + fetched = Team.fetch(PYSAI_TEAM_NAME) + log_step(f"Fetched attributes after replace: {fetched.attributes}") + assert fetched.attributes == new_attrs + log_ok("Set attributes OK") + + +def test_3307_replace_create(team_attributes): + log_step(f"Replacing existing team: {PYSAI_TEAM_NAME}") + team2 = Team(PYSAI_TEAM_NAME, team_attributes, "REPLACED DESC") + team2.create(enabled=True, replace=True) + fetched = Team.fetch(PYSAI_TEAM_NAME) + log_step(f"Fetched team description after replace: {fetched.description}") + assert fetched.description == "REPLACED DESC" + log_ok("Replace OK") + + +def test_3308_fetch_non_existing(): + name = f"NO_SUCH_{uuid.uuid4().hex}" + log_step(f"Fetching non-existing team: {name}") + expect_error("NOT_FOUND", lambda: Team.fetch(name)) + log_ok("Fetch non-existing confirmed error") + + +def test_3311_set_attribute_invalid_key(team): + log_step(f"Setting invalid attribute key on team: {team.team_name}") + expect_error("ORA-20053", lambda: team.set_attribute("no_such_attr", "x")) + log_ok("Set invalid attribute confirmed error") + + +def test_3312_set_attribute_none(team): + log_step(f"Setting team attribute 'process' to None: {team.team_name}") + expect_error("ORA-20053", lambda: team.set_attribute("process", None)) + log_ok("Set attribute None confirmed error") + + +def test_3313_set_attribute_empty(team): + log_step(f"Setting team attribute 'process' to empty string: {team.team_name}") + expect_error("ORA-20053", lambda: team.set_attribute("process", "")) + log_ok("Set attribute empty confirmed error") + + +def test_3314_set_attribute_invalid_value(team): + log_step(f"Setting team attribute 'process' to invalid value: {team.team_name}") + expect_error("ORA-20053", lambda: team.set_attribute("process", "not_a_real_process")) + log_ok("Set attribute invalid value confirmed error") + + +def test_3315_disable_after_delete(team_attributes): + name = f"TMP_{uuid.uuid4().hex}" + log_step(f"Creating temporary team: {name}") + t = Team(name, team_attributes, "TMP") + t.create() + log_step(f"Deleting temporary team: {name}") + t.delete(force=True) + log_step(f"Attempting to disable deleted team: {name}") + expect_error("ORA-20053", lambda: t.disable()) + log_ok("Disable after delete confirmed error") + + +def test_3316_enable_after_delete(team_attributes): + name = f"TMP_{uuid.uuid4().hex}" + log_step(f"Creating temporary team: {name}") + t = Team(name, team_attributes, "TMP") + t.create() + log_step(f"Deleting temporary team: {name}") + t.delete(force=True) + log_step(f"Attempting to enable deleted team: {name}") + expect_error("ORA-20053", lambda: t.enable()) + log_ok("Enable after delete confirmed error") + + +def test_3317_set_attribute_after_delete(team_attributes): + name = f"TMP_{uuid.uuid4().hex}" + log_step(f"Creating temporary team: {name}") + t = Team(name, team_attributes, "TMP") + t.create() + log_step(f"Deleting temporary team: {name}") + t.delete(force=True) + log_step(f"Attempting to set attribute on deleted team: {name}") + expect_error("ORA-20053", lambda: t.set_attribute("process", "sequential")) + log_ok("Set attribute after delete confirmed error") + + +def test_3318_double_delete(team_attributes): + name = f"TMP_{uuid.uuid4().hex}" + log_step(f"Creating temporary team: {name}") + t = Team(name, team_attributes, "TMP") + t.create() + log_step(f"Deleting team first time: {name}") + t.delete(force=True) + log_step(f"Deleting team second time: {name}") + expect_error("ORA-20053", lambda: t.delete(force=False)) + log_ok("Double delete confirmed error") + + +def test_3319_create_existing_without_replace(team_attributes): + name = f"TMP_{uuid.uuid4().hex}" + log_step(f"Creating team: {name}") + t1 = Team(name, team_attributes, "TMP1") + t1.create(replace=False) + log_step(f"Attempting to create existing team without replace: {name}") + expect_error("ORA-20053", lambda: Team(name, team_attributes, "TMP2").create(replace=False)) + t1.delete(force=True) + log_ok("Create existing without replace confirmed error") + + +def test_3320_fetch_after_delete(team_attributes): + name = f"TMP_{uuid.uuid4().hex}" + log_step(f"Creating temporary team: {name}") + t = Team(name, team_attributes, "TMP") + t.create() + log_step(f"Deleting temporary team: {name}") + t.delete(force=True) + log_step(f"Fetching deleted team: {name}") + expect_error("NOT_FOUND", lambda: Team.fetch(name)) + log_ok("Fetch after delete confirmed error") + + +def test_3321_double_delete(team_attributes): + name = f"TMP_{uuid.uuid4().hex}" + log_step(f"Creating temporary team: {name}") + t = Team(name, team_attributes, "TMP") + t.create() + log_step(f"Deleting team first time: {name}") + t.delete(force=True) + log_step(f"Deleting team second time: {name}") + # Second delete without force to actually raise the error + expect_error("ORA-20053", lambda: t.delete(force=False)) + log_ok("Double delete confirmed error") diff --git a/tests/agents/test_3800_agente2e.py b/tests/agents/test_3800_agente2e.py new file mode 100644 index 0000000..9abd3e0 --- /dev/null +++ b/tests/agents/test_3800_agente2e.py @@ -0,0 +1,231 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2025, Oracle and/or its affiliates. +# +# Licensed under the Universal Permissive License v 1.0 as shown at +# http://oss.oracle.com/licenses/upl. +# ----------------------------------------------------------------------------- + +import uuid +import time +import os +import logging +import pytest +from contextlib import contextmanager + +import select_ai +from select_ai.agent import ( + Agent, + AgentAttributes, + Task, + TaskAttributes, + Team, + TeamAttributes, + Tool, + ToolParams, + ToolAttributes, +) + +# ---------------------------------------------------------------------- +# LOGGING +# ---------------------------------------------------------------------- + + +# Path +PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")) +LOG_FILE = os.path.join(PROJECT_ROOT, "log", "tkex_test_3800_agente2e.log") +os.makedirs(os.path.dirname(LOG_FILE), exist_ok=True) + +# Force logging to file (pytest-proof) +root = logging.getLogger() +root.setLevel(logging.INFO) + +for h in root.handlers[:]: + root.removeHandler(h) + +fh = logging.FileHandler(LOG_FILE, mode="w") +fh.setFormatter(logging.Formatter("%(levelname)s: %(message)s")) +root.addHandler(fh) + +logger = logging.getLogger() + + +@contextmanager +def log_step(step): + logger.info("START: %s", step) + start = time.time() + try: + yield + logger.info("END: %s (%.2fs)", step, time.time() - start) + except Exception: + logger.exception("FAILED: %s", step) + raise + + +# ---------------------------------------------------------------------- +# MAIN TEST +# ---------------------------------------------------------------------- + +def test_agent_end_to_end(profile_attributes): + """ + End-to-end Select AI Agent integration test. + + """ + + # ------------------------------- + # PROFILE + # ------------------------------- + logger.info("Starting End-to-End Agent Test") + + # ---------------- PROFILE ---------------- + + oci_compartment_id = os.getenv("PYSAI_TEST_OCI_COMPARTMENT_ID") + assert oci_compartment_id, "PYSAI_TEST_OCI_COMPARTMENT_ID not set" + + profile_attributes.provider.oci_compartment_id = oci_compartment_id + + select_ai.Profile( + profile_name="GEN1_PROFILE", + attributes=profile_attributes, + replace=True, + ) + + # ------------------------------- + # AGENT + # ------------------------------- + with log_step("Create agent"): + agent = Agent( + agent_name="CustomerAgent", + attributes=AgentAttributes( + profile_name="GEN1_PROFILE", + role="You are an experienced customer agent handling returns.", + enable_human_tool=True, + ), + ) + agent.create(replace=True) + + assert agent.agent_name == "CustomerAgent" + + # ------------------------------- + # TOOLS + # ------------------------------- + with log_step("Create tools"): + + # Human tool + Tool.create_built_in_tool( + tool_name="Human", + description="Human intervention tool", + tool_type="HUMAN", + tool_params=ToolParams(), + replace=True, + ) + + websearch_tool = Tool( + tool_name="Websearch", + attributes=ToolAttributes( + tool_type="WEBSEARCH", + instruction="Use this tool to find the current price of a product from a URL.", + tool_params=ToolParams( + credential_name="OPENAI_CRED" + ), + ), + ) + websearch_tool.create(replace=True) + + # Email notification tool + email_tool = Tool( + tool_name="Email", + attributes=ToolAttributes( + tool_type="NOTIFICATION", + tool_params=ToolParams( + credential_name="EMAIL_CRED", + notification_type="EMAIL", + recipient=os.getenv("PYSAI_TEST_EMAIL_RECIPIENT"), + sender=os.getenv("PYSAI_TEST_EMAIL_SENDER"), + smtp_host=os.getenv("PYSAI_TEST_EMAIL_SMTPHOST"), + ), + ), + ) + email_tool.create(replace=True) + + assert Tool("Human") is not None + assert Tool("Email") is not None + + # ------------------------------- + # TASK + # ------------------------------- + with log_step("Create task"): + task = Task( + task_name="Return_And_Price_Match", + attributes=TaskAttributes( + instruction=( + "Process a product return request from a customer. " + "1. Ask customer the reason for return (price match or defective). " + "2. If price match: " + " a. Request customer to provide a price match link. " + " b. Use websearch tool to get the price for that price match link" + " c. Ask customer if they want a refund and specify how much refund. " + " d. Send email notification only if customer accepts the refund. " + "3. If defective: " + " a. Process the defective return." + ), + tools=["Human", "Websearch", "Email"], + ), + ) + task.create(replace=True) + + assert task.task_name == "Return_And_Price_Match" + assert set(task.attributes.tools) == {"Human", "Websearch", "Email"} + + assert task.task_name == "Return_And_Price_Match" + # Corrected assert to match the 3 tools + assert set(task.attributes.tools) == {"Human", "Websearch", "Email"} + + # ------------------------------- + # TEAM + # ------------------------------- + with log_step("Create team"): + team = Team( + team_name="ReturnAgency", + attributes=TeamAttributes( + agents=[{ + "name": "CustomerAgent", + "task": "Return_And_Price_Match", + }], + process="sequential", + ), + ) + team.create(enabled=True, replace=True) + + assert team.team_name == "ReturnAgency" + + # ------------------------------- + # RUN CONVERSATION + # ------------------------------- + with log_step("Run agent conversation"): + conversation_id = str(uuid.uuid4()) + + prompts = [ + "I want to return an office chair", + "The price when I bought it is 100. But I found a cheaper price", + "Here is the price match link https://www.ikea.com/us/en/p/stefan-chair-brown-black-00211088/", + "Yes, I would like to proceed with a refund", + "If you havent started the refund, please do" + ] + + for idx, prompt in enumerate(prompts, start=1): + logger.info("USER %d: %s", idx, prompt) + + response = team.run( + prompt=prompt, + params={"conversation_id": conversation_id}, + ) + + # ---- PRINT + LOG RESPONSE ---- + print(f"\nAGENT RESPONSE {idx}:\n{response}\n") + logger.info("AGENT RESPONSE %d: %s", idx, response) + + assert response is not None + assert isinstance(response, (str, dict)) + + if isinstance(response, dict): + assert response diff --git a/tests/agents/test_3800_async_agente2e.py b/tests/agents/test_3800_async_agente2e.py new file mode 100644 index 0000000..7607076 --- /dev/null +++ b/tests/agents/test_3800_async_agente2e.py @@ -0,0 +1,337 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2025, Oracle and/or its affiliates. +# +# Licensed under the Universal Permissive License v 1.0 as shown at +# http://oss.oracle.com/licenses/upl. +# ----------------------------------------------------------------------------- + +""" +3800 - Async end-to-end Select AI Agent integration test +""" + +import logging +import os +import time +import uuid +from contextlib import contextmanager + +import pytest +import select_ai +from select_ai.agent import ( + AgentAttributes, + AsyncAgent, + AsyncTask, + AsyncTeam, + AsyncTool, + TaskAttributes, + TeamAttributes, + ToolAttributes, + ToolParams, +) + +pytestmark = pytest.mark.anyio + +# ---------------------------------------------------------------------- +# LOGGING +# ---------------------------------------------------------------------- + +PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")) +LOG_FILE = os.path.join(PROJECT_ROOT, "log", "tkex_test_3800_async_agente2e.log") +os.makedirs(os.path.dirname(LOG_FILE), exist_ok=True) + +root = logging.getLogger() +root.setLevel(logging.INFO) +for h in root.handlers[:]: + root.removeHandler(h) + +fh = logging.FileHandler(LOG_FILE, mode="w") +fh.setFormatter(logging.Formatter("%(levelname)s: %(message)s")) +root.addHandler(fh) + +logger = logging.getLogger(__name__) + + +@contextmanager +def log_step(step): + logger.info("START: %s", step) + start = time.time() + try: + yield + logger.info("END: %s (%.2fs)", step, time.time() - start) + except Exception: + logger.exception("FAILED: %s", step) + raise + + +def _safe_dict(obj): + if obj is None: + return None + if hasattr(obj, "dict"): + try: + return obj.dict(exclude_null=False) + except TypeError: + return obj.dict() + return str(obj) + + +def log_object_details(context: str, object_type: str, obj) -> None: + details = {"context": context, "object_type": object_type} + + if object_type == "profile": + details.update( + { + "profile_name": getattr(obj, "profile_name", None), + "description": getattr(obj, "description", None), + "attributes": _safe_dict(getattr(obj, "attributes", None)), + } + ) + elif object_type == "agent": + details.update( + { + "agent_name": getattr(obj, "agent_name", None), + "description": getattr(obj, "description", None), + "attributes": _safe_dict(getattr(obj, "attributes", None)), + } + ) + elif object_type == "tool": + details.update( + { + "tool_name": getattr(obj, "tool_name", None), + "description": getattr(obj, "description", None), + "attributes": _safe_dict(getattr(obj, "attributes", None)), + } + ) + elif object_type == "task": + details.update( + { + "task_name": getattr(obj, "task_name", None), + "description": getattr(obj, "description", None), + "attributes": _safe_dict(getattr(obj, "attributes", None)), + } + ) + elif object_type == "team": + details.update( + { + "team_name": getattr(obj, "team_name", None), + "description": getattr(obj, "description", None), + "attributes": _safe_dict(getattr(obj, "attributes", None)), + } + ) + else: + details["repr"] = str(obj) + + logger.info("OBJECT_DETAILS: %s", details) + print("OBJECT_DETAILS:", details) + + +@pytest.fixture(scope="module", autouse=True) +async def async_connect(test_env): + logger.info("Opening async database connection") + await select_ai.async_connect(**test_env.connect_params()) + yield + logger.info("Closing async database connection") + await select_ai.async_disconnect() + + +async def test_agent_end_to_end_async(profile_attributes): + """End-to-end Select AI Agent integration test (async).""" + + run_id = uuid.uuid4().hex.upper() + + profile_name = f"GEN1_PROFILE_{run_id}" + agent_name = f"CustomerAgent_{run_id}" + human_tool_name = f"Human_{run_id}" + websearch_tool_name = f"Websearch_{run_id}" + email_tool_name = f"Email_{run_id}" + task_name = f"Return_And_Price_Match_{run_id}" + team_name = f"ReturnAgency_{run_id}" + + created = { + "team": None, + "task": None, + "tools": [], + "agent": None, + "profile": None, + } + + logger.info("Starting async End-to-End Agent Test") + logger.info( + "Run identifiers | profile=%s agent=%s task=%s team=%s", + profile_name, + agent_name, + task_name, + team_name, + ) + + oci_compartment_id = os.getenv("PYSAI_TEST_OCI_COMPARTMENT_ID") + assert oci_compartment_id, "PYSAI_TEST_OCI_COMPARTMENT_ID not set" + + profile_attributes.provider.oci_compartment_id = oci_compartment_id + + try: + with log_step("Create profile"): + profile = await select_ai.AsyncProfile( + profile_name=profile_name, + attributes=profile_attributes, + replace=True, + ) + created["profile"] = profile + logger.info("Created profile: %s", profile.profile_name) + log_object_details("create_profile", "profile", profile) + + with log_step("Create agent"): + agent = AsyncAgent( + agent_name=agent_name, + attributes=AgentAttributes( + profile_name=profile_name, + role=( + "You are an experienced customer agent handling returns." + ), + enable_human_tool=True, + ), + ) + await agent.create(replace=True) + created["agent"] = agent + logger.info("Created agent: %s", agent.agent_name) + log_object_details("create_agent", "agent", agent) + assert agent.agent_name == agent_name + + with log_step("Create tools"): + human_tool = await AsyncTool.create_built_in_tool( + tool_name=human_tool_name, + description="Human intervention tool", + tool_type=select_ai.agent.ToolType.HUMAN, + tool_params=ToolParams(), + replace=True, + ) + created["tools"].append(human_tool) + + websearch_tool = AsyncTool( + tool_name=websearch_tool_name, + attributes=ToolAttributes( + tool_type=select_ai.agent.ToolType.WEBSEARCH, + instruction=( + "Use this tool to find current product price from a URL." + ), + tool_params=ToolParams(credential_name="OPENAI_CRED"), + ), + ) + await websearch_tool.create(replace=True) + created["tools"].append(websearch_tool) + log_object_details("create_websearch_tool", "tool", websearch_tool) + + email_tool = AsyncTool( + tool_name=email_tool_name, + attributes=ToolAttributes( + tool_type=select_ai.agent.ToolType.NOTIFICATION, + tool_params=ToolParams( + credential_name="EMAIL_CRED", + notification_type="EMAIL", + recipient=os.getenv("PYSAI_TEST_EMAIL_RECIPIENT"), + sender=os.getenv("PYSAI_TEST_EMAIL_SENDER"), + smtp_host=os.getenv("PYSAI_TEST_EMAIL_SMTPHOST"), + ), + ), + ) + await email_tool.create(replace=True) + created["tools"].append(email_tool) + log_object_details("create_email_tool", "tool", email_tool) + + logger.info( + "Created tools: %s", + [t.tool_name for t in created["tools"]], + ) + log_object_details("create_human_tool", "tool", human_tool) + assert len(created["tools"]) == 3 + + with log_step("Create task"): + task = AsyncTask( + task_name=task_name, + attributes=TaskAttributes( + instruction=( + "Process a product return request from a customer. " + "1. Ask customer reason for return (price match or defective). " + "2. If price match: request link, use websearch, ask refund amount, " + "send email only if accepted. " + "3. If defective: process defective return." + ), + tools=[human_tool_name, websearch_tool_name, email_tool_name], + ), + ) + await task.create(replace=True) + created["task"] = task + + logger.info("Created task: %s", task.task_name) + log_object_details("create_task", "task", task) + assert task.task_name == task_name + assert set(task.attributes.tools) == { + human_tool_name, + websearch_tool_name, + email_tool_name, + } + + with log_step("Create team"): + team = AsyncTeam( + team_name=team_name, + attributes=TeamAttributes( + agents=[ + { + "name": agent_name, + "task": task_name, + } + ], + process="sequential", + ), + ) + await team.create(enabled=True, replace=True) + created["team"] = team + + logger.info("Created team: %s", team.team_name) + log_object_details("create_team", "team", team) + assert team.team_name == team_name + + with log_step("Run async agent conversation"): + conversation_id = str(uuid.uuid4()) + prompts = [ + "I want to return an office chair", + "The price when I bought it is 100. I found a cheaper price", + "Price match link https://www.ikea.com/us/en/p/stefan-chair-brown-black-00211088/", + "Yes, I would like to proceed with a refund", + "If you have not started the refund, please do", + ] + + for idx, prompt in enumerate(prompts, start=1): + logger.info("USER %d: %s", idx, prompt) + response = await team.run( + prompt=prompt, + params={"conversation_id": conversation_id}, + ) + + print(f"\nASYNC AGENT RESPONSE {idx}:\n{response}\n") + logger.info("ASYNC AGENT RESPONSE %d: %s", idx, response) + + assert response is not None + assert isinstance(response, str) + assert len(response.strip()) > 0 + + finally: + with log_step("Cleanup async e2e objects"): + if created["team"] is not None: + logger.info("Deleting team: %s", created["team"].team_name) + await created["team"].delete(force=True) + + if created["task"] is not None: + logger.info("Deleting task: %s", created["task"].task_name) + await created["task"].delete(force=True) + + for tool in reversed(created["tools"]): + logger.info("Deleting tool: %s", tool.tool_name) + await tool.delete(force=True) + + if created["agent"] is not None: + logger.info("Deleting agent: %s", created["agent"].agent_name) + await created["agent"].delete(force=True) + + if created["profile"] is not None: + logger.info("Deleting profile: %s", created["profile"].profile_name) + await created["profile"].delete(force=True) From c01eb0e549b8bc5e10530b4a1790ea55b4221c68 Mon Sep 17 00:00:00 2001 From: Kondra Nagabhavani Date: Fri, 20 Mar 2026 10:23:01 +0000 Subject: [PATCH 2/2] Addressed review comments and enhanced tests --- tests/agents/test_3001_async_tools.py | 35 ++-- tests/agents/test_3001_tools.py | 208 +++++++++++++++++++-- tests/agents/test_3101_async_tasks.py | 68 +++++++ tests/agents/test_3101_tasks.py | 42 +++++ tests/agents/test_3201_agents.py | 122 +++++++++++-- tests/agents/test_3201_async_agents.py | 132 ++++++++++++++ tests/agents/test_3301_async_teams.py | 8 + tests/agents/test_3800_agente2e.py | 219 ++++++++++++++++++++++- tests/agents/test_3800_async_agente2e.py | 22 ++- 9 files changed, 804 insertions(+), 52 deletions(-) diff --git a/tests/agents/test_3001_async_tools.py b/tests/agents/test_3001_async_tools.py index 63d6af4..ed760a3 100644 --- a/tests/agents/test_3001_async_tools.py +++ b/tests/agents/test_3001_async_tools.py @@ -494,7 +494,7 @@ async def test_3009_slack_tool_created(slack_tool): logger.info("SLACK tool not created due to expected backend-side error") -async def test_3009_custom_tool_attributes_roundtrip(): +async def test_3010_custom_tool_attributes_roundtrip(): logger.info( "Validating custom tool attribute roundtrip: instruction/tool_inputs/description" ) @@ -536,7 +536,7 @@ async def test_3009_custom_tool_attributes_roundtrip(): await tool.delete(force=True) -async def test_3009_custom_tool_without_tool_type(): +async def test_3011_custom_tool_without_tool_type(): logger.info("Validating custom tool creation with tool_type unset") tool = AsyncTool( tool_name=CUSTOM_NO_TYPE_TOOL_NAME, @@ -549,6 +549,13 @@ async def test_3009_custom_tool_without_tool_type(): await tool.create(replace=True) try: fetched = await AsyncTool.fetch(CUSTOM_NO_TYPE_TOOL_NAME) + logger.info( + "Fetched custom tool | name=%s | type=%s | function=%s | instruction=%s", + fetched.tool_name, + fetched.attributes.tool_type, + fetched.attributes.function, + fetched.attributes.instruction, + ) log_tool_details("test_3009_custom_tool_without_tool_type", fetched) assert fetched.tool_name == CUSTOM_NO_TYPE_TOOL_NAME assert fetched.attributes.tool_type is None @@ -558,7 +565,7 @@ async def test_3009_custom_tool_without_tool_type(): await tool.delete(force=True) -async def test_3009_custom_tool_with_tool_type_without_instruction(sql_profile): +async def test_3012_custom_tool_with_tool_type_without_instruction(sql_profile): logger.info( "Validating custom tool creation with tool_type set and instruction unset" ) @@ -587,7 +594,7 @@ async def test_3009_custom_tool_with_tool_type_without_instruction(sql_profile): await tool.delete(force=True) -async def test_3009_custom_tool_with_tool_type_and_instruction(sql_profile): +async def test_3013_custom_tool_with_tool_type_and_instruction(sql_profile): logger.info( "Validating custom tool creation with tool_type and instruction set" ) @@ -617,7 +624,7 @@ async def test_3009_custom_tool_with_tool_type_and_instruction(sql_profile): await tool.delete(force=True) -async def test_3010_sql_tool_with_invalid_profile_created(neg_sql_tool): +async def test_3014_sql_tool_with_invalid_profile_created(neg_sql_tool): logger.info("Validating SQL tool with invalid profile") log_tool_details("test_3010_sql_tool_with_invalid_profile_created", neg_sql_tool) assert neg_sql_tool.tool_name == NEG_SQL_TOOL_NAME @@ -625,7 +632,7 @@ async def test_3010_sql_tool_with_invalid_profile_created(neg_sql_tool): assert neg_sql_tool.attributes.tool_params.profile_name == "NON_EXISTENT_PROFILE" -async def test_3011_rag_tool_with_invalid_profile_created(neg_rag_tool): +async def test_3015_rag_tool_with_invalid_profile_created(neg_rag_tool): logger.info("Validating RAG tool with invalid profile") log_tool_details("test_3011_rag_tool_with_invalid_profile_created", neg_rag_tool) assert neg_rag_tool.tool_name == NEG_RAG_TOOL_NAME @@ -636,7 +643,7 @@ async def test_3011_rag_tool_with_invalid_profile_created(neg_rag_tool): ) -async def test_3012_plsql_tool_with_invalid_function_created(neg_plsql_tool): +async def test_3016_plsql_tool_with_invalid_function_created(neg_plsql_tool): logger.info("Validating PL/SQL tool with invalid function") log_tool_details( "test_3012_plsql_tool_with_invalid_function_created", neg_plsql_tool @@ -645,14 +652,14 @@ async def test_3012_plsql_tool_with_invalid_function_created(neg_plsql_tool): assert neg_plsql_tool.attributes.function == "NON_EXISTENT_FUNCTION" -async def test_3013_fetch_non_existent_tool(): +async def test_3017_fetch_non_existent_tool(): logger.info("Fetching non-existent tool") with pytest.raises(AgentToolNotFoundError) as exc: await AsyncTool.fetch("TOOL_DOES_NOT_EXIST") logger.info("Received expected error: %s", exc.value) -async def test_3014_list_invalid_regex(): +async def test_3018_list_invalid_regex(): logger.info("Listing tools with invalid regex") with pytest.raises(Exception) as exc: async for _ in AsyncTool.list(tool_name_pattern="*["): @@ -660,7 +667,7 @@ async def test_3014_list_invalid_regex(): logger.info("Received expected regex error: %s", exc.value) -async def test_3015_list_tools(): +async def test_3019_list_tools(): logger.info("Listing all tools") tools = [tool async for tool in AsyncTool.list()] for tool in tools: @@ -674,7 +681,7 @@ async def test_3015_list_tools(): assert PLSQL_TOOL_NAME in tool_names -async def test_3016_create_tool_default_status_enabled(sql_profile): +async def test_3020_create_tool_default_status_enabled(sql_profile): logger.info("Creating tool to validate default ENABLED status") tool = await AsyncTool.create_built_in_tool( tool_name=DEFAULT_STATUS_TOOL_NAME, @@ -697,7 +704,7 @@ async def test_3016_create_tool_default_status_enabled(sql_profile): await tool.delete(force=True) -async def test_3017_create_tool_with_enabled_false_sets_disabled(sql_profile): +async def test_3021_create_tool_with_enabled_false_sets_disabled(sql_profile): logger.info("Creating tool with enabled=False to validate DISABLED status") tool = AsyncTool( tool_name=DISABLED_TOOL_NAME, @@ -720,7 +727,7 @@ async def test_3017_create_tool_with_enabled_false_sets_disabled(sql_profile): await tool.delete(force=True) -async def test_3018_drop_tool_force_true_non_existent(): +async def test_3022_drop_tool_force_true_non_existent(): logger.info("Validating DROP_TOOL force=True for missing tool") tool = AsyncTool(tool_name=DROP_FORCE_MISSING_TOOL) await tool.delete(force=True) @@ -729,7 +736,7 @@ async def test_3018_drop_tool_force_true_non_existent(): assert status is None -async def test_3019_drop_tool_force_false_non_existent_raises(): +async def test_3023_drop_tool_force_false_non_existent_raises(): logger.info("Validating DROP_TOOL force=False for missing tool raises") tool = AsyncTool(tool_name=DROP_FORCE_MISSING_TOOL) with pytest.raises(oracledb.Error) as exc: diff --git a/tests/agents/test_3001_tools.py b/tests/agents/test_3001_tools.py index 058b42d..106ee64 100644 --- a/tests/agents/test_3001_tools.py +++ b/tests/agents/test_3001_tools.py @@ -75,6 +75,18 @@ def get_tool_status(tool_name): PLSQL_TOOL_NAME = f"PYSAI_PLSQL_TOOL_{UUID}" WEB_SEARCH_TOOL_NAME = f"PYSAI_WEB_TOOL_{UUID}" PLSQL_FUNCTION_NAME = f"PYSAI_CALC_AGE_{UUID}" +CUSTOM_ATTR_TOOL_NAME = f"PYSAI_3001_CUSTOM_ATTR_TOOL_{UUID}" +CUSTOM_ATTR_TOOL_DESCRIPTION = "Custom attr tool for sync testing" +CUSTOM_NO_TYPE_TOOL_NAME = f"PYSAI_3001_CUSTOM_NO_TYPE_TOOL_{UUID}" +CUSTOM_WITH_TYPE_NO_INSTR_TOOL_NAME = ( + f"PYSAI_3001_CUSTOM_WITH_TYPE_NO_INSTR_TOOL_{UUID}" +) +CUSTOM_WITH_TYPE_AND_INSTR_TOOL_NAME = ( + f"PYSAI_3001_CUSTOM_WITH_TYPE_AND_INSTR_TOOL_{UUID}" +) +DISABLED_TOOL_NAME = f"PYSAI_3001_DISABLED_TOOL_{UUID}" +DEFAULT_STATUS_TOOL_NAME = f"PYSAI_3001_DEFAULT_STATUS_TOOL_{UUID}" +DROP_FORCE_MISSING_TOOL = f"PYSAI_3001_DROP_MISSING_{UUID}" smtp_username = os.getenv("PYSAI_TEST_EMAIL_CRED_USERNAME") smtp_password = os.getenv("PYSAI_TEST_EMAIL_CRED_PASSWORD") slack_username = os.getenv("PYSAI_TEST_SLACK_USERNAME") @@ -430,41 +442,217 @@ def test_3009_slack_tool_created(slack_tool): else: assert slack_tool.tool_name == "SLACK_TOOL" -def test_3010_sql_tool_with_invalid_profile_created(neg_sql_tool): +def test_3010_custom_tool_attributes_roundtrip(): + logger.info( + "Validating custom tool attribute roundtrip: instruction/tool_inputs/description" + ) + tool = Tool( + tool_name=CUSTOM_ATTR_TOOL_NAME, + description=CUSTOM_ATTR_TOOL_DESCRIPTION, + attributes=select_ai.agent.ToolAttributes( + function=PLSQL_FUNCTION_NAME, + instruction="Return age in years for a birth date input", + tool_inputs=[ + { + "name": "p_birth_date", + "description": "Input birth date in DATE format", + } + ], + ), + ) + tool.create(replace=True) + try: + fetched = select_ai.agent.Tool.fetch(CUSTOM_ATTR_TOOL_NAME) + logger.info( + "Fetched custom tool | name=%s | description=%s | instruction=%s", + fetched.tool_name, + fetched.description, + fetched.attributes.instruction, + ) + assert fetched.tool_name == CUSTOM_ATTR_TOOL_NAME + assert fetched.description == CUSTOM_ATTR_TOOL_DESCRIPTION + assert fetched.attributes.function == PLSQL_FUNCTION_NAME + assert ( + fetched.attributes.instruction + == "Return age in years for a birth date input" + ) + assert isinstance(fetched.attributes.tool_inputs, list) + assert fetched.attributes.tool_inputs[0]["name"] == "p_birth_date" + assert "birth date" in fetched.attributes.tool_inputs[0]["description"].lower() + finally: + tool.delete(force=True) + + +def test_3011_custom_tool_without_tool_type(): + logger.info("Validating custom tool creation with tool_type unset") + tool = Tool( + tool_name=CUSTOM_NO_TYPE_TOOL_NAME, + description="Custom tool without explicit tool_type", + attributes=select_ai.agent.ToolAttributes( + function=PLSQL_FUNCTION_NAME, + ), + ) + tool.create(replace=True) + try: + fetched = select_ai.agent.Tool.fetch(CUSTOM_NO_TYPE_TOOL_NAME) + assert fetched.tool_name == CUSTOM_NO_TYPE_TOOL_NAME + assert fetched.attributes.function == PLSQL_FUNCTION_NAME + assert fetched.attributes.tool_type is None + assert fetched.description == "Custom tool without explicit tool_type" + finally: + tool.delete(force=True) + + +def test_3012_custom_tool_with_tool_type_without_instruction(sql_profile): + logger.info("Validating custom tool with tool_type and no instruction") + tool = Tool( + tool_name=CUSTOM_WITH_TYPE_NO_INSTR_TOOL_NAME, + description="Custom tool with tool_type and no instruction", + attributes=select_ai.agent.ToolAttributes( + tool_type=select_ai.agent.ToolType.SQL, + tool_params=select_ai.agent.SQLToolParams( + profile_name=SQL_PROFILE_NAME + ), + ), + ) + tool.create(replace=True) + try: + fetched = select_ai.agent.Tool.fetch(CUSTOM_WITH_TYPE_NO_INSTR_TOOL_NAME) + logger.info( + "Fetched custom tool | name=%s | type=%s | instruction=%s | profile=%s", + fetched.tool_name, + fetched.attributes.tool_type, + fetched.attributes.instruction, + fetched.attributes.tool_params.profile_name, + ) + assert fetched.tool_name == CUSTOM_WITH_TYPE_NO_INSTR_TOOL_NAME + assert fetched.attributes.tool_type == select_ai.agent.ToolType.SQL + assert fetched.attributes.instruction is not None + assert "sql" in fetched.attributes.instruction.lower() + assert fetched.attributes.tool_params.profile_name == SQL_PROFILE_NAME + finally: + tool.delete(force=True) + + +def test_3013_custom_tool_with_tool_type_and_instruction(sql_profile): + logger.info("Validating custom tool with tool_type and instruction") + tool = Tool( + tool_name=CUSTOM_WITH_TYPE_AND_INSTR_TOOL_NAME, + description="Custom tool with tool_type and instruction", + attributes=select_ai.agent.ToolAttributes( + tool_type=select_ai.agent.ToolType.SQL, + tool_params=select_ai.agent.SQLToolParams( + profile_name=SQL_PROFILE_NAME + ), + instruction="Use SQL profile to answer query from relational data", + ), + ) + tool.create(replace=True) + try: + fetched = select_ai.agent.Tool.fetch(CUSTOM_WITH_TYPE_AND_INSTR_TOOL_NAME) + assert fetched.tool_name == CUSTOM_WITH_TYPE_AND_INSTR_TOOL_NAME + assert fetched.attributes.tool_type == select_ai.agent.ToolType.SQL + assert fetched.attributes.instruction is not None + assert "sql" in fetched.attributes.instruction.lower() + assert fetched.attributes.tool_params.profile_name == SQL_PROFILE_NAME + finally: + tool.delete(force=True) + + +def test_3014_sql_tool_with_invalid_profile_created(neg_sql_tool): logger.info("Validating SQL tool with invalid profile is stored") assert neg_sql_tool.tool_name == "NEG_SQL_TOOL" assert neg_sql_tool.attributes.tool_params.profile_name == "NON_EXISTENT_PROFILE" -def test_3011_rag_tool_with_invalid_profile_created(neg_rag_tool): +def test_3015_rag_tool_with_invalid_profile_created(neg_rag_tool): logger.info("Validating RAG tool with invalid profile is stored") assert neg_rag_tool.tool_name == "NEG_RAG_TOOL" assert neg_rag_tool.attributes.tool_params.profile_name == "NON_EXISTENT_RAG_PROFILE" -def test_3012_plsql_tool_with_invalid_function_created(neg_plsql_tool): +def test_3016_plsql_tool_with_invalid_function_created(neg_plsql_tool): logger.info("Validating PL/SQL tool with invalid function is stored") assert neg_plsql_tool.tool_name == "NEG_PLSQL_TOOL" assert neg_plsql_tool.attributes.function == "NON_EXISTENT_FUNCTION" -def test_3013_fetch_non_existent_tool(): +def test_3017_fetch_non_existent_tool(): logger.info("Fetching non-existent tool") - with pytest.raises(AgentToolNotFoundError)as exc: + with pytest.raises(AgentToolNotFoundError) as exc: select_ai.agent.Tool.fetch("TOOL_DOES_NOT_EXIST") - logger.error("%s", exc.value) + logger.error("%s", exc.value) + -def test_3014_list_invalid_regex(): +def test_3018_list_invalid_regex(): logger.info("Listing tools with invalid regex") with pytest.raises(Exception) as exc: list(select_ai.agent.Tool.list(tool_name_pattern="*[")) - logger.error("%s", exc.value) + logger.error("%s", exc.value) -def test_3015_list_tools(): + +def test_3019_list_tools(): logger.info("Listing all tools") tool_names = {t.tool_name for t in select_ai.agent.Tool.list()} logger.info("Tools present: %s", tool_names) assert SQL_TOOL_NAME in tool_names assert RAG_TOOL_NAME in tool_names - assert PLSQL_TOOL_NAME in tool_names \ No newline at end of file + assert PLSQL_TOOL_NAME in tool_names + + +def test_3020_create_tool_default_status_enabled(sql_profile): + logger.info("Creating tool to validate default ENABLED status") + tool = select_ai.agent.Tool.create_built_in_tool( + tool_name=DEFAULT_STATUS_TOOL_NAME, + tool_type=select_ai.agent.ToolType.SQL, + tool_params=select_ai.agent.SQLToolParams(profile_name=SQL_PROFILE_NAME), + ) + try: + status = get_tool_status(DEFAULT_STATUS_TOOL_NAME) + logger.info("Tool status after create: %s", status) + assert status == "ENABLED" + fetched = select_ai.agent.Tool.fetch(DEFAULT_STATUS_TOOL_NAME) + assert fetched.attributes.tool_type == select_ai.agent.ToolType.SQL + assert fetched.attributes.tool_params.profile_name == SQL_PROFILE_NAME + finally: + tool.delete(force=True) + + +def test_3021_create_tool_with_enabled_false_sets_disabled(sql_profile): + logger.info("Creating tool with enabled=False to validate DISABLED status") + tool = Tool( + tool_name=DISABLED_TOOL_NAME, + attributes=select_ai.agent.ToolAttributes( + tool_type=select_ai.agent.ToolType.SQL, + tool_params=select_ai.agent.SQLToolParams( + profile_name=SQL_PROFILE_NAME + ), + ), + ) + tool.create(enabled=False, replace=True) + try: + status = get_tool_status(DISABLED_TOOL_NAME) + logger.info("Tool status after create(enabled=False): %s", status) + assert status == "DISABLED" + fetched = select_ai.agent.Tool.fetch(DISABLED_TOOL_NAME) + assert fetched.attributes.tool_type == select_ai.agent.ToolType.SQL + finally: + tool.delete(force=True) + + +def test_3022_drop_tool_force_true_non_existent(): + logger.info("Validating DROP_TOOL force=True for missing tool") + tool = Tool(tool_name=DROP_FORCE_MISSING_TOOL) + tool.delete(force=True) + status = get_tool_status(DROP_FORCE_MISSING_TOOL) + logger.info("Status after force delete on missing tool: %s", status) + assert status is None + + +def test_3023_drop_tool_force_false_non_existent_raises(): + logger.info("Validating DROP_TOOL force=False for missing tool raises") + tool = Tool(tool_name=DROP_FORCE_MISSING_TOOL) + with pytest.raises(oracledb.Error) as exc: + tool.delete(force=False) + logger.info("Received expected drop error: %s", exc.value) diff --git a/tests/agents/test_3101_async_tasks.py b/tests/agents/test_3101_async_tasks.py index a4bfbf0..091e272 100644 --- a/tests/agents/test_3101_async_tasks.py +++ b/tests/agents/test_3101_async_tasks.py @@ -207,6 +207,10 @@ async def test_3104_create_task_with_enabled_false_sets_disabled(): fetched = await AsyncTask.fetch(PYSAI_3100_DISABLED_TASK_NAME) log_task_details("test_3104", fetched) assert fetched.description == "Task created disabled" + + logger.info("Enabling task created with enabled=False: %s", task.task_name) + await task.enable() + await assert_task_status(PYSAI_3100_DISABLED_TASK_NAME, "ENABLED") finally: await task.delete(force=True) @@ -221,6 +225,36 @@ async def test_3105_disable_enable_task(task): await assert_task_status(PYSAI_3100_TASK_NAME, "ENABLED") +async def test_3105b_set_single_attribute_invalid(task): + logger.info("Setting invalid single attribute for async task: %s", task.task_name) + with pytest.raises(oracledb.DatabaseError) as exc: + await task.set_attribute("description", "New Desc") + logger.info("Received expected Oracle error: %s", exc.value) + assert "ORA-20051" in str(exc.value) + + +async def test_3105c_duplicate_task_creation_fails(task): + logger.info("Creating duplicate async task without replace: %s", task.task_name) + dup = AsyncTask( + task_name=task.task_name, + description="Duplicate task", + attributes=task.attributes, + ) + with pytest.raises(oracledb.Error) as exc: + await dup.create(replace=False) + logger.info("Received expected duplicate create error: %s", exc.value) + assert "ORA-20051" in str(exc.value) + + +async def test_3105d_invalid_regex_pattern(): + logger.info("Listing async tasks with invalid regex") + with pytest.raises(oracledb.Error) as exc: + async for _ in AsyncTask.list("[INVALID_REGEX"): + pass + logger.info("Received expected invalid regex error: %s", exc.value) + assert "ORA-12726" in str(exc.value) + + async def test_3106_drop_task_force_true_non_existent(): logger.info("Dropping missing task with force=True: %s", PYSAI_3100_MISSING_TASK_NAME) task = AsyncTask(task_name=PYSAI_3100_MISSING_TASK_NAME) @@ -310,3 +344,37 @@ async def test_3111_create_requires_attributes(): task_name=f"PYSAI_3100_NO_ATTR_{uuid.uuid4().hex.upper()}" ).create() logger.info("Received expected error: %s", exc.value) + + +async def test_3112_enable_deleted_task_object_raises(): + logger.info("Creating task to validate object behavior after delete") + task_name = f"PYSAI_3100_DELETED_{uuid.uuid4().hex.upper()}" + attrs = TaskAttributes( + instruction="Validate task object after delete for: {query}", + tools=["MOVIE_SQL_TOOL"], + enable_human_tool=False, + ) + task = AsyncTask( + task_name=task_name, + description="Task deleted before reuse", + attributes=attrs, + ) + + await task.create(replace=True) + await assert_task_status(task_name, "ENABLED") + + await task.delete(force=True) + status = await get_task_status(task_name) + logger.info("Task status after delete: %s", status) + assert status is None + + logger.info("Verifying in-memory task object is still populated") + assert task.task_name == task_name + assert task.description == "Task deleted before reuse" + assert task.attributes == attrs + + logger.info("Attempting to enable deleted task using same object") + with pytest.raises(oracledb.DatabaseError) as exc: + await task.enable() + logger.info("Received expected error when enabling deleted task: %s", exc.value) + assert "ORA-20051" in str(exc.value) diff --git a/tests/agents/test_3101_tasks.py b/tests/agents/test_3101_tasks.py index c02493e..886a2d6 100644 --- a/tests/agents/test_3101_tasks.py +++ b/tests/agents/test_3101_tasks.py @@ -279,3 +279,45 @@ def test_3118_delete_disabled_task_with_force_succeeds(): logger.info("Task status after delete: %s", status) assert status is None expect_oracle_error("NOT_FOUND", lambda: Task.fetch(task_name)) + + logger.info("Attempting operational use of deleted task object: %s", task_name) + expect_oracle_error("ORA-20051", lambda: task.enable()) + + +def test_3119_double_delete_force_true_succeeds(): + task_name = f"{BASE}_DOUBLE_DELETE_FORCE_TRUE" + logger.info("Creating task for double delete with force=True: %s", task_name) + attrs = TaskAttributes(instruction="Double delete force true", tools=None) + task = Task(task_name=task_name, attributes=attrs) + task.create(enabled=True) + + task.delete(force=True) + status = get_task_status(task_name) + logger.info("Task status after first delete: %s", status) + assert status is None + + logger.info("Deleting already deleted task with force=True: %s", task_name) + task.delete(force=True) + status = get_task_status(task_name) + logger.info("Task status after second delete with force=True: %s", status) + assert status is None + expect_oracle_error("NOT_FOUND", lambda: Task.fetch(task_name)) + + +def test_3120_double_delete_force_false_raises(): + task_name = f"{BASE}_DOUBLE_DELETE_FORCE_FALSE" + logger.info("Creating task for double delete with force=False: %s", task_name) + attrs = TaskAttributes(instruction="Double delete force false", tools=None) + task = Task(task_name=task_name, attributes=attrs) + task.create(enabled=True) + + task.delete(force=False) + status = get_task_status(task_name) + logger.info("Task status after first delete: %s", status) + assert status is None + + logger.info("Deleting already deleted task with force=False: %s", task_name) + with pytest.raises(oracledb.DatabaseError) as exc: + task.delete(force=False) + logger.info("Received expected Oracle error on second delete: %s", exc.value) + expect_oracle_error("NOT_FOUND", lambda: Task.fetch(task_name)) diff --git a/tests/agents/test_3201_agents.py b/tests/agents/test_3201_agents.py index 25ebead..4573889 100644 --- a/tests/agents/test_3201_agents.py +++ b/tests/agents/test_3201_agents.py @@ -69,6 +69,8 @@ def get_agent_status(agent_name): PYSAI_AGENT_NAME = f"PYSAI_3200_AGENT_{uuid.uuid4().hex.upper()}" PYSAI_AGENT_DESC = "PYSAI_3200_AGENT_DESCRIPTION" PYSAI_PROFILE_NAME = f"PYSAI_3200_PROFILE_{uuid.uuid4().hex.upper()}" +PYSAI_DISABLED_AGENT_NAME = f"PYSAI_3200_DISABLED_AGENT_{uuid.uuid4().hex.upper()}" +PYSAI_MISSING_AGENT_NAME = f"PYSAI_3200_MISSING_AGENT_{uuid.uuid4().hex.upper()}" # ----------------------------------------------------------------------------- # Fixtures @@ -175,7 +177,77 @@ def test_3203_fetch_non_existing(): expect_oracle_error("NOT_FOUND", lambda: Agent.fetch(name)) -def test_3204_disable_enable(agent): +def test_3204_create_agent_default_status_enabled(agent_attributes): + name = f"PYSAI_3200_STATUS_ENABLED_{uuid.uuid4().hex.upper()}" + logger.info("Creating agent with default enabled status: %s", name) + a = Agent( + agent_name=name, + description="Default enabled status", + attributes=agent_attributes, + ) + a.create(replace=True) + try: + status = get_agent_status(name) + logger.info("Agent status after create: %s", status) + assert status == "ENABLED" + + fetched = Agent.fetch(name) + logger.info("Fetched created agent: %s", fetched.agent_name) + assert fetched.description == "Default enabled status" + finally: + a.delete(force=True) + + +def test_3205_create_agent_with_enabled_false_sets_disabled(agent_attributes): + logger.info("Creating disabled agent: %s", PYSAI_DISABLED_AGENT_NAME) + a = Agent( + agent_name=PYSAI_DISABLED_AGENT_NAME, + description="Initially disabled", + attributes=agent_attributes, + ) + a.create(enabled=False, replace=True) + try: + status = get_agent_status(PYSAI_DISABLED_AGENT_NAME) + logger.info("Agent status after create(enabled=False): %s", status) + assert status == "DISABLED" + + fetched = Agent.fetch(PYSAI_DISABLED_AGENT_NAME) + logger.info("Fetched disabled agent: %s", fetched.agent_name) + assert fetched.description == "Initially disabled" + finally: + a.delete(force=True) + + +def test_3206_drop_agent_force_true_non_existent(): + logger.info("Dropping missing agent with force=True: %s", PYSAI_MISSING_AGENT_NAME) + a = Agent(agent_name=PYSAI_MISSING_AGENT_NAME) + a.delete(force=True) + status = get_agent_status(PYSAI_MISSING_AGENT_NAME) + logger.info("Status after force delete on missing agent: %s", status) + assert status is None + + +def test_3207_drop_agent_force_false_non_existent_raises(): + logger.info("Dropping missing agent with force=False: %s", PYSAI_MISSING_AGENT_NAME) + a = Agent(agent_name=PYSAI_MISSING_AGENT_NAME) + expect_oracle_error("ORA-20050", lambda: a.delete(force=False)) + + +def test_3208_create_requires_agent_name(agent_attributes): + logger.info("Validating create() requires agent_name") + with pytest.raises(AttributeError) as exc: + Agent(attributes=agent_attributes).create() + logger.info("Received expected error: %s", exc.value) + + +def test_3209_create_requires_attributes(): + logger.info("Validating create() requires attributes") + with pytest.raises(AttributeError) as exc: + Agent(agent_name=f"PYSAI_3200_NO_ATTR_{uuid.uuid4().hex.upper()}").create() + logger.info("Received expected error: %s", exc.value) + + +def test_3210_disable_enable(agent): logger.info("Disabling agent: %s", agent.agent_name) agent.disable() @@ -191,7 +263,7 @@ def test_3204_disable_enable(agent): assert status == "ENABLED" -def test_3205_set_attribute(agent): +def test_3211_set_attribute(agent): logger.info("Setting role attribute on agent: %s", agent.agent_name) agent.set_attribute("role", "You are a DB assistant") @@ -201,7 +273,7 @@ def test_3205_set_attribute(agent): assert "DB assistant" in a.attributes.role -def test_3206_set_attributes(agent): +def test_3212_set_attributes(agent): logger.info("Replacing agent attributes") new_attrs = AgentAttributes( @@ -219,19 +291,19 @@ def test_3206_set_attributes(agent): assert a.attributes == new_attrs -def test_3207_set_attribute_invalid_key(agent): +def test_3213_set_attribute_invalid_key(agent): logger.info("Setting invalid attribute key on agent: %s", agent.agent_name) expect_oracle_error("ORA-20050", lambda: agent.set_attribute("no_such_key", 123)) -def test_3208_set_attribute_none(agent): +def test_3214_set_attribute_none(agent): logger.info("Setting attribute 'role' to None on agent: %s", agent.agent_name) expect_oracle_error("ORA-20050", lambda: agent.set_attribute("role", None)) -def test_3209_set_attribute_empty(agent): +def test_3215_set_attribute_empty(agent): logger.info("Setting attribute 'role' to empty string on agent: %s", agent.agent_name) expect_oracle_error("ORA-20050", lambda: agent.set_attribute("role", "")) -def test_3210_create_existing_without_replace(agent_attributes): +def test_3216_create_existing_without_replace(agent_attributes): logger.info("Create existing agent without replace should fail") a = Agent( agent_name=PYSAI_AGENT_NAME, @@ -240,7 +312,7 @@ def test_3210_create_existing_without_replace(agent_attributes): ) expect_oracle_error("ORA-20050", lambda: a.create(replace=False)) -def test_3211_delete_and_recreate(agent_attributes): +def test_3217_delete_and_recreate(agent_attributes): name = f"PYSAI_RECREATE_{uuid.uuid4().hex}" logger.info("Create agent: %s", name) #Create agent @@ -273,7 +345,7 @@ def test_3211_delete_and_recreate(agent_attributes): logger.info("Final cleanup successful for agent: %s", name) -def test_3212_disable_after_delete(agent_attributes): +def test_3218_disable_after_delete(agent_attributes): name = f"PYSAI_TMP_DEL_{uuid.uuid4().hex}" logger.info("Creating agent: %s", name) a = Agent(name, attributes=agent_attributes) @@ -296,7 +368,7 @@ def test_3212_disable_after_delete(agent_attributes): logger.info("Disable after delete confirmed error for agent: %s", name) -def test_3213_enable_after_delete(agent_attributes): +def test_3219_enable_after_delete(agent_attributes): name = f"PYSAI_TMP_DEL_{uuid.uuid4().hex}" logger.info("Creating agent: %s", name) a = Agent(name, attributes=agent_attributes) @@ -319,7 +391,7 @@ def test_3213_enable_after_delete(agent_attributes): logger.info("Enable after delete confirmed error for agent: %s", name) -def test_3214_set_attribute_after_delete(agent_attributes): +def test_3220_set_attribute_after_delete(agent_attributes): name = f"PYSAI_TMP_DEL_{uuid.uuid4().hex}" logger.info("Creating agent: %s", name) a = Agent(name, attributes=agent_attributes) @@ -342,7 +414,7 @@ def test_3214_set_attribute_after_delete(agent_attributes): logger.info("Set attribute after delete confirmed error for agent: %s", name) -def test_3215_double_delete(agent_attributes): +def test_3221_double_delete_force_true(agent_attributes): name = f"PYSAI_TMP_DOUBLE_DEL_{uuid.uuid4().hex}" logger.info("Creating agent: %s", name) a = Agent(name, attributes=agent_attributes) @@ -367,7 +439,29 @@ def test_3215_double_delete(agent_attributes): logger.info("Confirmed agent still does not exist after double delete: %s", name) -def test_3216_fetch_after_delete(agent_attributes): +def test_3222_double_delete_force_false_raises(agent_attributes): + name = f"PYSAI_TMP_DOUBLE_DEL_FALSE_{uuid.uuid4().hex}" + logger.info("Creating agent: %s", name) + a = Agent(name, attributes=agent_attributes) + a.create() + logger.info("Agent created successfully: %s", name) + + logger.info("Fetching agent to verify creation: %s", name) + fetched = Agent.fetch(name) + logger.info("Fetched agent: %s", fetched.agent_name) + + logger.info("Deleting agent first time with force=False: %s", name) + a.delete(force=False) + logger.info("First delete done, verifying deletion: %s", name) + expect_oracle_error("NOT_FOUND", lambda: Agent.fetch(name)) + logger.info("Confirmed agent no longer exists: %s", name) + + logger.info("Deleting agent second time with force=False: %s", name) + expect_oracle_error("ORA-20050", lambda: a.delete(force=False)) + logger.info("Confirmed second delete with force=False raises error: %s", name) + + +def test_3223_fetch_after_delete(agent_attributes): name = f"PYSAI_TMP_FETCH_DEL_{uuid.uuid4().hex}" logger.info("Creating agent: %s", name) a = Agent(name, attributes=agent_attributes) @@ -386,7 +480,7 @@ def test_3216_fetch_after_delete(agent_attributes): logger.info("Confirmed agent no longer exists: %s", name) -def test_3217_list_all_non_empty(): +def test_3224_list_all_non_empty(): logger.info("Listing all agents") agents = list(Agent.list()) names = sorted(a.agent_name for a in agents) diff --git a/tests/agents/test_3201_async_agents.py b/tests/agents/test_3201_async_agents.py index dcc5b7b..58f102c 100644 --- a/tests/agents/test_3201_async_agents.py +++ b/tests/agents/test_3201_async_agents.py @@ -77,6 +77,20 @@ def log_agent_details(context: str, agent) -> None: print("AGENT_DETAILS:", details) +async def expect_async_error(expected_code, coro_fn): + try: + await coro_fn() + except AgentNotFoundError as exc: + logger.info("Expected failure (NOT_FOUND): %s", exc) + assert expected_code == "NOT_FOUND" + except oracledb.DatabaseError as exc: + msg = str(exc) + logger.info("Expected Oracle failure: %s", msg) + assert expected_code in msg, f"Expected {expected_code}, got: {msg}" + else: + pytest.fail(f"Expected error {expected_code} did not occur") + + async def get_agent_status(agent_name): logger.info("Fetching agent status for: %s", agent_name) async with select_ai.async_cursor() as cur: @@ -282,3 +296,121 @@ async def test_3212_create_requires_attributes(): agent_name=f"PYSAI_3200_NO_ATTR_{uuid.uuid4().hex.upper()}" ).create() logger.info("Received expected error: %s", exc.value) + + +async def test_3213_disable_enable(agent): + logger.info("Disabling async agent: %s", agent.agent_name) + await agent.disable() + await assert_agent_status(agent.agent_name, "DISABLED") + + logger.info("Enabling async agent: %s", agent.agent_name) + await agent.enable() + await assert_agent_status(agent.agent_name, "ENABLED") + + +async def test_3214_set_attribute_none(agent): + logger.info("Setting role=None on async agent: %s", agent.agent_name) + await expect_async_error("ORA-20050", lambda: agent.set_attribute("role", None)) + + +async def test_3215_set_attribute_empty(agent): + logger.info("Setting role='' on async agent: %s", agent.agent_name) + await expect_async_error("ORA-20050", lambda: agent.set_attribute("role", "")) + + +async def test_3216_create_existing_without_replace(agent_attributes): + logger.info("Creating duplicate async agent without replace") + dup = AsyncAgent( + agent_name=PYSAI_3200_AGENT_NAME, + description="Duplicate async agent", + attributes=agent_attributes, + ) + await expect_async_error("ORA-20050", lambda: dup.create(replace=False)) + + +async def test_3217_delete_and_recreate(agent_attributes): + name = f"PYSAI_RECREATE_{uuid.uuid4().hex.upper()}" + logger.info("Creating async agent: %s", name) + a = AsyncAgent(name, attributes=agent_attributes) + await a.create() + + fetched = await AsyncAgent.fetch(name) + log_agent_details("test_3217_created", fetched) + assert fetched.agent_name == name + + logger.info("Deleting async agent: %s", name) + await a.delete(force=True) + await expect_async_error("NOT_FOUND", lambda: AsyncAgent.fetch(name)) + + logger.info("Recreating async agent: %s", name) + await a.create(replace=False) + recreated = await AsyncAgent.fetch(name) + log_agent_details("test_3217_recreated", recreated) + assert recreated.agent_name == name + + await a.delete(force=True) + await expect_async_error("NOT_FOUND", lambda: AsyncAgent.fetch(name)) + + +async def test_3218_disable_after_delete(agent_attributes): + name = f"PYSAI_TMP_DEL_{uuid.uuid4().hex.upper()}" + a = AsyncAgent(name, attributes=agent_attributes) + await a.create() + await a.delete(force=True) + await expect_async_error("NOT_FOUND", lambda: AsyncAgent.fetch(name)) + await expect_async_error("ORA-20050", lambda: a.disable()) + + +async def test_3219_enable_after_delete(agent_attributes): + name = f"PYSAI_TMP_DEL_{uuid.uuid4().hex.upper()}" + a = AsyncAgent(name, attributes=agent_attributes) + await a.create() + await a.delete(force=True) + await expect_async_error("NOT_FOUND", lambda: AsyncAgent.fetch(name)) + await expect_async_error("ORA-20050", lambda: a.enable()) + + +async def test_3220_set_attribute_after_delete(agent_attributes): + name = f"PYSAI_TMP_DEL_{uuid.uuid4().hex.upper()}" + a = AsyncAgent(name, attributes=agent_attributes) + await a.create() + await a.delete(force=True) + await expect_async_error("NOT_FOUND", lambda: AsyncAgent.fetch(name)) + await expect_async_error("ORA-20050", lambda: a.set_attribute("role", "X")) + + +async def test_3221_double_delete_force_true(agent_attributes): + name = f"PYSAI_TMP_DOUBLE_DEL_{uuid.uuid4().hex.upper()}" + a = AsyncAgent(name, attributes=agent_attributes) + await a.create() + await a.delete(force=True) + await expect_async_error("NOT_FOUND", lambda: AsyncAgent.fetch(name)) + await a.delete(force=True) + await expect_async_error("NOT_FOUND", lambda: AsyncAgent.fetch(name)) + + +async def test_3222_double_delete_force_false_raises(agent_attributes): + name = f"PYSAI_TMP_DOUBLE_DEL_FALSE_{uuid.uuid4().hex.upper()}" + a = AsyncAgent(name, attributes=agent_attributes) + await a.create() + await a.delete(force=False) + await expect_async_error("NOT_FOUND", lambda: AsyncAgent.fetch(name)) + await expect_async_error("ORA-20050", lambda: a.delete(force=False)) + + +async def test_3223_fetch_after_delete(agent_attributes): + name = f"PYSAI_TMP_FETCH_DEL_{uuid.uuid4().hex.upper()}" + a = AsyncAgent(name, attributes=agent_attributes) + await a.create() + await a.delete(force=True) + await expect_async_error("NOT_FOUND", lambda: AsyncAgent.fetch(name)) + + +async def test_3224_list_all_non_empty(): + logger.info("Listing all async agents") + agents = [a async for a in AsyncAgent.list()] + names = sorted(a.agent_name for a in agents) + logger.info("Total async agents found: %d", len(names)) + for name in names: + logger.info(" - %s", name) + assert len(names) > 0 diff --git a/tests/agents/test_3301_async_teams.py b/tests/agents/test_3301_async_teams.py index 0d06959..3a54a3f 100644 --- a/tests/agents/test_3301_async_teams.py +++ b/tests/agents/test_3301_async_teams.py @@ -383,3 +383,11 @@ async def test_3320_fetch_after_delete(team_attributes): await t.create() await t.delete(force=True) await expect_async_error("NOT_FOUND", lambda: AsyncTeam.fetch(name)) + +async def test_3321_double_delete(team_attributes): + name = f"TMP_{uuid.uuid4().hex.upper()}" + t = AsyncTeam(name, team_attributes, "TMP") + await t.create() + await t.delete(force=True) + await expect_async_error("ORA-20053", lambda: t.delete(force=False)) + diff --git a/tests/agents/test_3800_agente2e.py b/tests/agents/test_3800_agente2e.py index 9abd3e0..634d21e 100644 --- a/tests/agents/test_3800_agente2e.py +++ b/tests/agents/test_3800_agente2e.py @@ -61,11 +61,182 @@ def log_step(step): raise +def _safe_dict(obj): + if obj is None: + return None + if hasattr(obj, "dict"): + try: + return obj.dict(exclude_null=False) + except TypeError: + return obj.dict() + return str(obj) + + +def log_object_details(context: str, object_type: str, obj) -> None: + details = {"context": context, "object_type": object_type} + + if object_type == "profile": + details.update( + { + "profile_name": getattr(obj, "profile_name", None), + "description": getattr(obj, "description", None), + "attributes": _safe_dict(getattr(obj, "attributes", None)), + } + ) + elif object_type == "agent": + details.update( + { + "agent_name": getattr(obj, "agent_name", None), + "description": getattr(obj, "description", None), + "attributes": _safe_dict(getattr(obj, "attributes", None)), + } + ) + elif object_type == "tool": + details.update( + { + "tool_name": getattr(obj, "tool_name", None), + "description": getattr(obj, "description", None), + "attributes": _safe_dict(getattr(obj, "attributes", None)), + } + ) + elif object_type == "task": + details.update( + { + "task_name": getattr(obj, "task_name", None), + "description": getattr(obj, "description", None), + "attributes": _safe_dict(getattr(obj, "attributes", None)), + } + ) + elif object_type == "team": + details.update( + { + "team_name": getattr(obj, "team_name", None), + "description": getattr(obj, "description", None), + "attributes": _safe_dict(getattr(obj, "attributes", None)), + } + ) + else: + details["repr"] = str(obj) + + logger.info("OBJECT_DETAILS: %s", details) + print("OBJECT_DETAILS:", details) + + +@pytest.fixture(scope="session") +def setup_test_user(test_env): + try: + select_ai.disconnect() + except Exception: + pass + + select_ai.connect(**test_env.connect_params(admin=True)) + try: + try: + select_ai.grant_privileges(users=[test_env.test_user]) + except Exception as exc: + msg = str(exc) + if ( + "ORA-01749" not in msg + and "Cannot GRANT or REVOKE privileges to or from yourself" not in msg + ): + raise + + select_ai.grant_http_access( + users=[test_env.test_user], + provider_endpoint=select_ai.OpenAIProvider.provider_endpoint, + ) + finally: + select_ai.disconnect() + select_ai.connect(**test_env.connect_params()) + + +@pytest.fixture(scope="session") +def openai_cred(): + api_key = os.getenv("PYSAI_TEST_OPENAI_API_KEY") + assert api_key, "PYSAI_TEST_OPENAI_API_KEY not set" + + select_ai.create_credential( + credential={ + "credential_name": "OPENAI_CRED", + "username": "openai", + "password": api_key, + }, + replace=True, + ) + + return "OPENAI_CRED" + + +@pytest.fixture(scope="session") +def email_cred(): + smtp_username = os.getenv("PYSAI_TEST_EMAIL_CRED_USERNAME") + smtp_password = os.getenv("PYSAI_TEST_EMAIL_CRED_PASSWORD") + + assert smtp_username, "PYSAI_TEST_EMAIL_CRED_USERNAME not set" + assert smtp_password, "PYSAI_TEST_EMAIL_CRED_PASSWORD not set" + + select_ai.create_credential( + credential={ + "credential_name": "EMAIL_CRED", + "username": smtp_username, + "password": smtp_password, + }, + replace=True, + ) + + return "EMAIL_CRED" + + +@pytest.fixture(scope="session") +def allow_network_acl(): + with select_ai.cursor() as cur: + cur.execute("SELECT USER FROM dual") + db_user = cur.fetchone()[0] + + def append_ace(host, privileges): + try: + cur.execute( + f""" + BEGIN + DBMS_NETWORK_ACL_ADMIN.APPEND_HOST_ACE( + host => '{host}', + ace => xs$ace_type( + privilege_list => xs$name_list({','.join([f"'{p}'" for p in privileges])}), + principal_name => '{db_user}', + principal_type => xs_acl.ptype_db + ) + ); + END; + """ + ) + except Exception as exc: + msg = str(exc) + if ( + "ORA-46212" in msg + or "ORA-46313" in msg + or "already exists" in msg + ): + return + raise + + append_ace( + "smtp.email.us-ashburn-1.oci.oraclecloud.com", + ["connect", "smtp"], + ) + + for host in ["api.openai.com", "a.co","amazon.in"]: + append_ace(host, ["connect", "http"]) + + yield + + # ---------------------------------------------------------------------- # MAIN TEST # ---------------------------------------------------------------------- -def test_agent_end_to_end(profile_attributes): +def test_3800_agent_end_to_end( + profile_attributes, setup_test_user, openai_cred, email_cred, allow_network_acl +): """ End-to-end Select AI Agent integration test. @@ -83,11 +254,12 @@ def test_agent_end_to_end(profile_attributes): profile_attributes.provider.oci_compartment_id = oci_compartment_id - select_ai.Profile( + profile = select_ai.Profile( profile_name="GEN1_PROFILE", attributes=profile_attributes, replace=True, ) + log_object_details("create_profile", "profile", profile) # ------------------------------- # AGENT @@ -102,6 +274,7 @@ def test_agent_end_to_end(profile_attributes): ), ) agent.create(replace=True) + log_object_details("create_agent", "agent", agent) assert agent.agent_name == "CustomerAgent" @@ -130,8 +303,13 @@ def test_agent_end_to_end(profile_attributes): ), ) websearch_tool.create(replace=True) + log_object_details("create_websearch_tool", "tool", websearch_tool) # Email notification tool + email_recipient = os.getenv("PYSAI_TEST_EMAIL_RECIPIENT") + email_sender = os.getenv("PYSAI_TEST_EMAIL_SENDER") + assert email_recipient, "PYSAI_TEST_EMAIL_RECIPIENT not set" + assert email_sender, "PYSAI_TEST_EMAIL_SENDER not set" email_tool = Tool( tool_name="Email", attributes=ToolAttributes( @@ -139,13 +317,14 @@ def test_agent_end_to_end(profile_attributes): tool_params=ToolParams( credential_name="EMAIL_CRED", notification_type="EMAIL", - recipient=os.getenv("PYSAI_TEST_EMAIL_RECIPIENT"), - sender=os.getenv("PYSAI_TEST_EMAIL_SENDER"), - smtp_host=os.getenv("PYSAI_TEST_EMAIL_SMTPHOST"), + recipient=email_recipient, + sender=email_sender, + smtp_host="smtp.email.us-ashburn-1.oci.oraclecloud.com", ), ), ) email_tool.create(replace=True) + log_object_details("create_email_tool", "tool", email_tool) assert Tool("Human") is not None assert Tool("Email") is not None @@ -172,6 +351,7 @@ def test_agent_end_to_end(profile_attributes): ), ) task.create(replace=True) + log_object_details("create_task", "task", task) assert task.task_name == "Return_And_Price_Match" assert set(task.attributes.tools) == {"Human", "Websearch", "Email"} @@ -195,6 +375,7 @@ def test_agent_end_to_end(profile_attributes): ), ) team.create(enabled=True, replace=True) + log_object_details("create_team", "team", team) assert team.team_name == "ReturnAgency" @@ -207,9 +388,8 @@ def test_agent_end_to_end(profile_attributes): prompts = [ "I want to return an office chair", "The price when I bought it is 100. But I found a cheaper price", - "Here is the price match link https://www.ikea.com/us/en/p/stefan-chair-brown-black-00211088/", + "Here is the price match link 'https://www.ikea.com/us/en/p/stefan-chair-brown-black-00211088/'", "Yes, I would like to proceed with a refund", - "If you havent started the refund, please do" ] for idx, prompt in enumerate(prompts, start=1): @@ -229,3 +409,28 @@ def test_agent_end_to_end(profile_attributes): if isinstance(response, dict): assert response + + with select_ai.cursor() as cur: + cur.execute( + """ + SELECT * FROM user_ai_agent_tool_history + """ + ) + tool_history = cur.fetchall() + + decoded_tool_history = [] + for row in tool_history: + decoded_row = [] + for value in row: + if hasattr(value, "read"): + decoded_row.append(value.read()) + else: + decoded_row.append(value) + decoded_tool_history.append(tuple(decoded_row)) + + print(decoded_tool_history) + logger.info("Tool history rows fetched: %d", len(decoded_tool_history)) + for row in decoded_tool_history: + logger.info("TOOL_HISTORY_ROW: %s", row) + + assert decoded_tool_history diff --git a/tests/agents/test_3800_async_agente2e.py b/tests/agents/test_3800_async_agente2e.py index 7607076..191f777 100644 --- a/tests/agents/test_3800_async_agente2e.py +++ b/tests/agents/test_3800_async_agente2e.py @@ -126,14 +126,18 @@ def log_object_details(context: str, object_type: str, obj) -> None: @pytest.fixture(scope="module", autouse=True) async def async_connect(test_env): - logger.info("Opening async database connection") - await select_ai.async_connect(**test_env.connect_params()) + logger.info( + "Opening async admin database connection | user=%s | dsn=%s", + test_env.admin_user, + test_env.connect_string, + ) + await select_ai.async_connect(**test_env.connect_params(admin=True)) yield - logger.info("Closing async database connection") + logger.info("Closing async admin database connection") await select_ai.async_disconnect() -async def test_agent_end_to_end_async(profile_attributes): +async def test_3800_agent_end_to_end_async(profile_attributes): """End-to-end Select AI Agent integration test (async).""" run_id = uuid.uuid4().hex.upper() @@ -220,6 +224,10 @@ async def test_agent_end_to_end_async(profile_attributes): created["tools"].append(websearch_tool) log_object_details("create_websearch_tool", "tool", websearch_tool) + email_recipient = os.getenv("PYSAI_TEST_EMAIL_RECIPIENT") + email_sender = os.getenv("PYSAI_TEST_EMAIL_SENDER") + assert email_recipient, "PYSAI_TEST_EMAIL_RECIPIENT not set" + assert email_sender, "PYSAI_TEST_EMAIL_SENDER not set" email_tool = AsyncTool( tool_name=email_tool_name, attributes=ToolAttributes( @@ -227,9 +235,9 @@ async def test_agent_end_to_end_async(profile_attributes): tool_params=ToolParams( credential_name="EMAIL_CRED", notification_type="EMAIL", - recipient=os.getenv("PYSAI_TEST_EMAIL_RECIPIENT"), - sender=os.getenv("PYSAI_TEST_EMAIL_SENDER"), - smtp_host=os.getenv("PYSAI_TEST_EMAIL_SMTPHOST"), + recipient=email_recipient, + sender=email_sender, + smtp_host="smtp.email.us-ashburn-1.oci.oraclecloud.com", ), ), )