From 2ef16a3dc5a118715561517bb8ad38c3de3bfe94 Mon Sep 17 00:00:00 2001 From: Abhishek Singh Date: Wed, 29 Jul 2026 11:07:53 -0700 Subject: [PATCH 1/4] Added supervisor agent feature and APIs --- src/select_ai/agent/__init__.py | 3 +- src/select_ai/agent/core.py | 5 +- src/select_ai/agent/definition.py | 52 +++++++++++ src/select_ai/agent/team.py | 48 ++++++++++ src/select_ai/agent/tool.py | 51 +++++++++++ src/select_ai/version.py | 2 +- tests/agents/test_3001_async_tools.py | 20 +++++ tests/agents/test_3001_tools.py | 20 +++++ tests/agents/test_3201_agents.py | 119 ++++++++++++++++++++----- tests/agents/test_3201_async_agents.py | 22 ++++- tests/agents/test_3301_async_teams.py | 15 ++++ tests/agents/test_3301_teams.py | 52 +++++++++++ 12 files changed, 381 insertions(+), 28 deletions(-) create mode 100644 src/select_ai/agent/definition.py diff --git a/src/select_ai/agent/__init__.py b/src/select_ai/agent/__init__.py index e6f4bd4..6f29cc8 100644 --- a/src/select_ai/agent/__init__.py +++ b/src/select_ai/agent/__init__.py @@ -1,5 +1,5 @@ # ----------------------------------------------------------------------------- -# Copyright (c) 2025, Oracle and/or its affiliates. +# Copyright (c) 2025, 2026, Oracle and/or its affiliates. # # Licensed under the Universal Permissive License v 1.0 as shown at # http://oss.oracle.com/licenses/upl. @@ -7,6 +7,7 @@ from .core import Agent, AgentAttributes, AsyncAgent +from .definition import async_get_definition, get_definition from .task import AsyncTask, Task, TaskAttributes from .team import AsyncTeam, Team, TeamAttributes from .tool import ( diff --git a/src/select_ai/agent/core.py b/src/select_ai/agent/core.py index bed1edc..a4c8ce3 100644 --- a/src/select_ai/agent/core.py +++ b/src/select_ai/agent/core.py @@ -1,5 +1,5 @@ # ----------------------------------------------------------------------------- -# Copyright (c) 2025, Oracle and/or its affiliates. +# Copyright (c) 2025, 2026, Oracle and/or its affiliates. # # Licensed under the Universal Permissive License v 1.0 as shown at # http://oss.oracle.com/licenses/upl. @@ -36,11 +36,14 @@ class AgentAttributes(SelectAIDataClass): :param str role: Agent's role also sent to LLM :param bool enable_human_tool: Enable human tool support. Agent will ask question to the user for any clarification + :param bool supervisor: Whether the agent supervises other agents in a + supervised team. """ profile_name: str role: str enable_human_tool: Optional[bool] = True + supervisor: Optional[bool] = None class BaseAgent(ABC): diff --git a/src/select_ai/agent/definition.py b/src/select_ai/agent/definition.py new file mode 100644 index 0000000..a8fb59a --- /dev/null +++ b/src/select_ai/agent/definition.py @@ -0,0 +1,52 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2026, Oracle and/or its affiliates. +# +# Licensed under the Universal Permissive License v 1.0 as shown at +# http://oss.oracle.com/licenses/upl. +# ----------------------------------------------------------------------------- + +from typing import Optional + +import oracledb + +from select_ai.db import async_cursor, cursor + + +def get_definition(object_type: str, object_name: str) -> Optional[str]: + """Return the canonical PL/SQL definition for an AI object. + + :param str object_type: Object type: ``AGENT``, ``TASK``, ``TOOL``, or + ``TEAM``. + :param str object_name: Name of the object to export. + :return: A PL/SQL block that recreates the object, or ``None``. + :rtype: str or None + """ + with cursor() as cr: + data = cr.callfunc( + "DBMS_CLOUD_AI_AGENT.GET_DEFINITION", + oracledb.DB_TYPE_CLOB, + keyword_parameters={ + "object_type": object_type, + "object_name": object_name, + }, + ) + return data.read() if data is not None else None + + +async def async_get_definition( + object_type: str, object_name: str +) -> Optional[str]: + """Asynchronously return the canonical PL/SQL definition for an AI object. + + Parameters and return value are the same as :func:`get_definition`. + """ + async with async_cursor() as cr: + data = await cr.callfunc( + "DBMS_CLOUD_AI_AGENT.GET_DEFINITION", + oracledb.DB_TYPE_CLOB, + keyword_parameters={ + "object_type": object_type, + "object_name": object_name, + }, + ) + return await data.read() if data is not None else None diff --git a/src/select_ai/agent/team.py b/src/select_ai/agent/team.py index e5682a2..42239f1 100644 --- a/src/select_ai/agent/team.py +++ b/src/select_ai/agent/team.py @@ -45,10 +45,18 @@ class TeamAttributes(SelectAIDataClass): :param str process: Execution order of tasks. Currently only "sequential" is supported. + :param str supervisor_agent: Name of the agent that supervises the team + when using a supervised execution process. + + :param str supervisor_task: Database-generated task associated with the + supervisor agent. + """ agents: List[Mapping] process: str = "sequential" + supervisor_agent: Optional[str] = None + supervisor_task: Optional[str] = None class BaseTeam(ABC): @@ -332,6 +340,26 @@ def run(self, prompt: str = None, params: Mapping = None): result = None return result + def describe_team(self) -> Optional[str]: + """Return this team's JSON metadata and aggregated skills.""" + with cursor() as cr: + data = cr.callfunc( + "DBMS_CLOUD_AI_AGENT.DESCRIBE_TEAM", + oracledb.DB_TYPE_CLOB, + keyword_parameters={"team_name": self.team_name}, + ) + return data.read() if data is not None else None + + def list_tools(self) -> Optional[str]: + """Return JSON metadata for the tools available to this team.""" + with cursor() as cr: + data = cr.callfunc( + "DBMS_CLOUD_AI_AGENT.LIST_TOOLS", + oracledb.DB_TYPE_CLOB, + keyword_parameters={"team_name": self.team_name}, + ) + return data.read() if data is not None else None + @classmethod def export_team( cls, @@ -776,6 +804,26 @@ async def run(self, prompt: str = None, params: Mapping = None): result = None return result + async def describe_team(self) -> Optional[str]: + """Asynchronously return this team's JSON metadata and skills.""" + async with async_cursor() as cr: + data = await cr.callfunc( + "DBMS_CLOUD_AI_AGENT.DESCRIBE_TEAM", + oracledb.DB_TYPE_CLOB, + keyword_parameters={"team_name": self.team_name}, + ) + return await data.read() if data is not None else None + + async def list_tools(self) -> Optional[str]: + """Asynchronously return JSON metadata for this team's tools.""" + async with async_cursor() as cr: + data = await cr.callfunc( + "DBMS_CLOUD_AI_AGENT.LIST_TOOLS", + oracledb.DB_TYPE_CLOB, + keyword_parameters={"team_name": self.team_name}, + ) + return await data.read() if data is not None else None + @classmethod async def export_team( cls, diff --git a/src/select_ai/agent/tool.py b/src/select_ai/agent/tool.py index b4e2aaf..a9d58fd 100644 --- a/src/select_ai/agent/tool.py +++ b/src/select_ai/agent/tool.py @@ -652,6 +652,34 @@ def enable(self): }, ) + def run_tool(self, input: str) -> Optional[str]: + """Run this tool directly and return its result. + + :param str input: Tool input payload. + :return: The tool result. + :rtype: str or None + """ + with cursor() as cr: + data = cr.callfunc( + "DBMS_CLOUD_AI_AGENT.RUN_TOOL", + oracledb.DB_TYPE_CLOB, + keyword_parameters={ + "tool_name": self.tool_name, + "input": input, + }, + ) + return data.read() if data is not None else None + + def describe_tool(self) -> Optional[str]: + """Return this tool's JSON metadata and function arguments.""" + with cursor() as cr: + data = cr.callfunc( + "DBMS_CLOUD_AI_AGENT.DESCRIBE_TOOL", + oracledb.DB_TYPE_CLOB, + keyword_parameters={"tool_name": self.tool_name}, + ) + return data.read() if data is not None else None + @classmethod def fetch(cls, tool_name: str) -> "Tool": """ @@ -1119,6 +1147,29 @@ async def enable(self): }, ) + async def run_tool(self, input: str) -> Optional[str]: + """Asynchronously run this tool directly and return its result.""" + async with async_cursor() as cr: + data = await cr.callfunc( + "DBMS_CLOUD_AI_AGENT.RUN_TOOL", + oracledb.DB_TYPE_CLOB, + keyword_parameters={ + "tool_name": self.tool_name, + "input": input, + }, + ) + return await data.read() if data is not None else None + + async def describe_tool(self) -> Optional[str]: + """Asynchronously return this tool's JSON metadata.""" + async with async_cursor() as cr: + data = await cr.callfunc( + "DBMS_CLOUD_AI_AGENT.DESCRIBE_TOOL", + oracledb.DB_TYPE_CLOB, + keyword_parameters={"tool_name": self.tool_name}, + ) + return await data.read() if data is not None else None + @classmethod async def fetch(cls, tool_name: str) -> "AsyncTool": """ diff --git a/src/select_ai/version.py b/src/select_ai/version.py index 7eaa33f..2691fab 100644 --- a/src/select_ai/version.py +++ b/src/select_ai/version.py @@ -5,4 +5,4 @@ # http://oss.oracle.com/licenses/upl. # ----------------------------------------------------------------------------- -__version__ = "1.4.0" +__version__ = "1.4.1" diff --git a/tests/agents/test_3001_async_tools.py b/tests/agents/test_3001_async_tools.py index a1b9de5..9ebfd4a 100644 --- a/tests/agents/test_3001_async_tools.py +++ b/tests/agents/test_3001_async_tools.py @@ -9,6 +9,7 @@ 3001 - Async API coverage for select_ai.agent AsyncTool APIs """ +import json import logging import os import uuid @@ -794,3 +795,22 @@ async def test_3023_drop_tool_force_false_non_existent_raises(): with pytest.raises(oracledb.Error) as exc: await tool.delete(force=False) logger.info("Received expected drop error: %s", exc.value) + + +async def test_3024_describe_tool(plsql_tool): + """Return JSON metadata for a registered async PL/SQL tool.""" + description_json = await plsql_tool.describe_tool() + description = json.loads(description_json) + + assert isinstance(description, dict) + assert description["attributes"]["function"] == PLSQL_FUNCTION_NAME + assert description["description"] == plsql_tool.description + assert description["function_args"] + + +async def test_3025_run_tool(plsql_tool): + """Invoke a registered async PL/SQL tool with JSON input.""" + result = await plsql_tool.run_tool('{"p_birth_date":"2000-01-01"}') + + assert isinstance(result, str) + assert result diff --git a/tests/agents/test_3001_tools.py b/tests/agents/test_3001_tools.py index e23b0ec..0285629 100644 --- a/tests/agents/test_3001_tools.py +++ b/tests/agents/test_3001_tools.py @@ -10,6 +10,7 @@ (with logging for behavior visibility) """ +import json import logging import os import uuid @@ -701,3 +702,22 @@ def test_3023_drop_tool_force_false_non_existent_raises(): with pytest.raises(oracledb.Error) as exc: tool.delete(force=False) logger.info("Received expected drop error: %s", exc.value) + + +def test_3024_describe_tool(plsql_tool): + """Return JSON metadata for a registered PL/SQL tool.""" + description_json = plsql_tool.describe_tool() + description = json.loads(description_json) + + assert isinstance(description, dict) + assert description["attributes"]["function"] == PLSQL_FUNCTION_NAME + assert description["description"] == plsql_tool.description + assert description["function_args"] + + +def test_3025_run_tool(plsql_tool): + """Invoke a registered PL/SQL tool with a JSON input payload.""" + result = plsql_tool.run_tool('{"p_birth_date":"2000-01-01"}') + + assert isinstance(result, str) + assert result diff --git a/tests/agents/test_3201_agents.py b/tests/agents/test_3201_agents.py index 4573889..f626d8d 100644 --- a/tests/agents/test_3201_agents.py +++ b/tests/agents/test_3201_agents.py @@ -1,5 +1,5 @@ # ----------------------------------------------------------------------------- -# Copyright (c) 2025, Oracle and/or its affiliates. +# Copyright (c) 2025, 2026, Oracle and/or its affiliates. # # Licensed under the Universal Permissive License v 1.0 as shown at # http://oss.oracle.com/licenses/upl. @@ -9,17 +9,20 @@ 3200 - Module for testing select_ai agents """ -import uuid import logging +import os +import uuid + +import oracledb import pytest import select_ai -import os -from select_ai.agent import Agent, AgentAttributes +from select_ai.agent import Agent, AgentAttributes, get_definition from select_ai.errors import AgentNotFoundError -import oracledb # Path -PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")) +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) @@ -41,6 +44,7 @@ # Per-test logging # ----------------------------------------------------------------------------- + @pytest.fixture(autouse=True) def log_test_name(request): logger.info(f"--- Starting test: {request.function.__name__} ---") @@ -52,16 +56,21 @@ def log_test_name(request): # Helper Functions # ----------------------------------------------------------------------------- + def get_agent_status(agent_name): with select_ai.cursor() as cur: - cur.execute(""" + cur.execute( + """ SELECT status FROM USER_AI_AGENTS WHERE agent_name = :agent_name - """, {"agent_name": agent_name}) + """, + {"agent_name": agent_name}, + ) row = cur.fetchone() return row[0] if row else None + # ----------------------------------------------------------------------------- # Test constants # ----------------------------------------------------------------------------- @@ -69,13 +78,18 @@ 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()}" +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 # ----------------------------------------------------------------------------- + @pytest.fixture(scope="module") def python_gen_ai_profile(profile_attributes): logger.info("Creating profile: %s", PYSAI_PROFILE_NAME) @@ -112,6 +126,7 @@ def agent(python_gen_ai_profile, agent_attributes): logger.info("Deleting agent: %s", PYSAI_AGENT_NAME) agent.delete(force=True) + # ----------------------------------------------------------------------------- # Helpers # ----------------------------------------------------------------------------- @@ -134,10 +149,12 @@ def expect_oracle_error(expected_code, fn): 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) @@ -219,7 +236,9 @@ def test_3205_create_agent_with_enabled_false_sets_disabled(agent_attributes): def test_3206_drop_agent_force_true_non_existent(): - logger.info("Dropping missing agent with force=True: %s", PYSAI_MISSING_AGENT_NAME) + 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) @@ -228,7 +247,9 @@ def test_3206_drop_agent_force_true_non_existent(): def test_3207_drop_agent_force_false_non_existent_raises(): - logger.info("Dropping missing agent with force=False: %s", PYSAI_MISSING_AGENT_NAME) + 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)) @@ -243,7 +264,9 @@ def test_3208_create_requires_agent_name(agent_attributes): 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() + Agent( + agent_name=f"PYSAI_3200_NO_ATTR_{uuid.uuid4().hex.upper()}" + ).create() logger.info("Received expected error: %s", exc.value) @@ -293,16 +316,26 @@ def test_3212_set_attributes(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)) + expect_oracle_error( + "ORA-20050", lambda: agent.set_attribute("no_such_key", 123) + ) + def test_3214_set_attribute_none(agent): - logger.info("Setting attribute 'role' to None on agent: %s", agent.agent_name) + 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_3215_set_attribute_empty(agent): - logger.info("Setting attribute 'role' to empty string on agent: %s", agent.agent_name) + 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_3216_create_existing_without_replace(agent_attributes): logger.info("Create existing agent without replace should fail") a = Agent( @@ -312,31 +345,34 @@ def test_3216_create_existing_without_replace(agent_attributes): ) expect_oracle_error("ORA-20050", lambda: a.create(replace=False)) + def test_3217_delete_and_recreate(agent_attributes): name = f"PYSAI_RECREATE_{uuid.uuid4().hex}" logger.info("Create agent: %s", name) - #Create agent + # 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 + # 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 + # 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) + logger.info( + "Agent recreated successfully: %s", fetched_recreated.agent_name + ) assert fetched_recreated.agent_name == name - #Final cleanup + # Final cleanup logger.info("Cleanup agent: %s", name) a.delete(force=True) # Verify cleanup @@ -411,7 +447,9 @@ def test_3220_set_attribute_after_delete(agent_attributes): 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) + logger.info( + "Set attribute after delete confirmed error for agent: %s", name + ) def test_3221_double_delete_force_true(agent_attributes): @@ -436,7 +474,9 @@ def test_3221_double_delete_force_true(agent_attributes): 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) + logger.info( + "Confirmed agent still does not exist after double delete: %s", name + ) def test_3222_double_delete_force_false_raises(agent_attributes): @@ -458,7 +498,9 @@ def test_3222_double_delete_force_false_raises(agent_attributes): 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) + logger.info( + "Confirmed second delete with force=False raises error: %s", name + ) def test_3223_fetch_after_delete(agent_attributes): @@ -489,3 +531,32 @@ def test_3224_list_all_non_empty(): for name in names: logger.info(" - %s", name) assert len(names) > 0 + + +def test_3225_get_definition(agent): + """Return a canonical PL/SQL definition for an existing AI agent.""" + definition = get_definition("AGENT", agent.agent_name) + + assert definition is not None + assert agent.agent_name in definition.upper() + assert "DBMS_CLOUD_AI_AGENT.CREATE_AGENT" in definition.upper() + + +def test_3226_create_supervisor_agent(agent_attributes): + """Create and fetch an agent configured as a team supervisor.""" + name = f"PYSAI_SUPERVISOR_AGENT_{uuid.uuid4().hex.upper()}" + attributes = AgentAttributes( + profile_name=agent_attributes.profile_name, + role="You supervise and coordinate the team.", + enable_human_tool=False, + supervisor=True, + ) + supervisor_agent = Agent(name, attributes=attributes) + supervisor_agent.create(replace=True) + + try: + fetched = Agent.fetch(name) + assert fetched.attributes is not None + assert fetched.attributes.supervisor is True + finally: + supervisor_agent.delete(force=True) diff --git a/tests/agents/test_3201_async_agents.py b/tests/agents/test_3201_async_agents.py index 5f92677..445c11e 100644 --- a/tests/agents/test_3201_async_agents.py +++ b/tests/agents/test_3201_async_agents.py @@ -1,5 +1,5 @@ # ----------------------------------------------------------------------------- -# Copyright (c) 2025, Oracle and/or its affiliates. +# Copyright (c) 2025, 2026, Oracle and/or its affiliates. # # Licensed under the Universal Permissive License v 1.0 as shown at # http://oss.oracle.com/licenses/upl. @@ -420,3 +420,23 @@ async def test_3224_list_all_non_empty(): for name in names: logger.info(" - %s", name) assert len(names) > 0 + + +async def test_3225_create_supervisor_agent(agent_attributes): + """Create and fetch an async agent configured as a team supervisor.""" + name = f"PYSAI_SUPERVISOR_AGENT_{uuid.uuid4().hex.upper()}" + attributes = AgentAttributes( + profile_name=agent_attributes.profile_name, + role="You supervise and coordinate the team.", + enable_human_tool=False, + supervisor=True, + ) + supervisor_agent = AsyncAgent(name, attributes=attributes) + await supervisor_agent.create(replace=True) + + try: + fetched = await AsyncAgent.fetch(name) + assert fetched.attributes is not None + assert fetched.attributes.supervisor is True + finally: + await supervisor_agent.delete(force=True) diff --git a/tests/agents/test_3301_async_teams.py b/tests/agents/test_3301_async_teams.py index bd13bd3..a88f479 100644 --- a/tests/agents/test_3301_async_teams.py +++ b/tests/agents/test_3301_async_teams.py @@ -468,3 +468,18 @@ async def test_3321_double_delete(team_attributes): await t.create() await t.delete(force=True) await expect_async_error("ORA-20053", lambda: t.delete(force=False)) + + +async def test_3322_describe_team(team): + """Return metadata and aggregated skills for an async team.""" + description = json.loads(await team.describe_team()) + + assert description["name"] == team.team_name + assert isinstance(description["skills"], list) + + +async def test_3323_list_tools(team): + """Return the JSON array of tools available to an async team.""" + tools = json.loads(await team.list_tools()) + + assert isinstance(tools, list) diff --git a/tests/agents/test_3301_teams.py b/tests/agents/test_3301_teams.py index c620a4f..3dfd346 100644 --- a/tests/agents/test_3301_teams.py +++ b/tests/agents/test_3301_teams.py @@ -514,3 +514,55 @@ def test_3321_double_delete(team_attributes): # Second delete without force to actually raise the error expect_error("ORA-20053", lambda: t.delete(force=False)) log_ok("Double delete confirmed error") + + +def test_3322_create_team_with_supervisor(agent, task): + """Create and fetch a sequential team with a supervisor agent.""" + supervisor_name = f"PYSAI_SUPERVISOR_AGENT_{uuid.uuid4().hex.upper()}" + team_name = f"PYSAI_SUPERVISED_TEAM_{uuid.uuid4().hex.upper()}" + supervisor_agent = Agent( + agent_name=supervisor_name, + attributes=AgentAttributes( + profile_name=PYSAI_TEAM_PROFILE_NAME, + role="You delegate work to specialist agents.", + enable_human_tool=False, + supervisor=True, + ), + ) + supervisor_agent.create(replace=True) + + supervised_team = Team( + team_name=team_name, + attributes=TeamAttributes( + process="sequential", + supervisor_agent=supervisor_name, + agents=[{"name": agent.agent_name, "task": task.task_name}], + ), + ) + try: + supervised_team.create(replace=True) + fetched = Team.fetch(team_name) + assert fetched.attributes is not None + assert fetched.attributes.process == "sequential" + assert fetched.attributes.supervisor_agent == supervisor_name + assert fetched.attributes.agents == [ + {"name": agent.agent_name, "task": task.task_name} + ] + finally: + supervised_team.delete(force=True) + supervisor_agent.delete(force=True) + + +def test_3323_describe_team(team): + """Return metadata and aggregated skills for an existing team.""" + description = json.loads(team.describe_team()) + + assert description["name"] == team.team_name + assert isinstance(description["skills"], list) + + +def test_3324_list_tools(team): + """Return the JSON array of tools available to an existing team.""" + tools = json.loads(team.list_tools()) + + assert isinstance(tools, list) From 4f0d5c06d21ba306684d258c4523878fbeff9ccc Mon Sep 17 00:00:00 2001 From: Abhishek Singh Date: Wed, 29 Jul 2026 11:30:20 -0700 Subject: [PATCH 2/4] Added conversation add tag, remove tag, delete prompt and list prompts APIs --- src/select_ai/__init__.py | 1 + src/select_ai/conversation.py | 187 +++++++++++++++++- src/select_ai/sql.py | 20 ++ tests/profiles/test_1400_conversation.py | 18 ++ .../profiles/test_1500_conversation_async.py | 23 +++ tests/profiles/test_1800_chat_session.py | 19 ++ .../profiles/test_1900_chat_session_async.py | 22 +++ 7 files changed, 289 insertions(+), 1 deletion(-) diff --git a/src/select_ai/__init__.py b/src/select_ai/__init__.py index d1327a5..4f4871a 100644 --- a/src/select_ai/__init__.py +++ b/src/select_ai/__init__.py @@ -12,6 +12,7 @@ AsyncConversation, Conversation, ConversationAttributes, + ConversationPrompt, ) from .credential import ( async_create_credential, diff --git a/src/select_ai/conversation.py b/src/select_ai/conversation.py index 414717e..ae3cb62 100644 --- a/src/select_ai/conversation.py +++ b/src/select_ai/conversation.py @@ -17,10 +17,16 @@ from select_ai.errors import ConversationNotFoundError from select_ai.sql import ( GET_USER_CONVERSATION_ATTRIBUTES, + LIST_USER_CONVERSATION_PROMPTS, LIST_USER_CONVERSATIONS, ) -__all__ = ["AsyncConversation", "Conversation", "ConversationAttributes"] +__all__ = [ + "AsyncConversation", + "Conversation", + "ConversationAttributes", + "ConversationPrompt", +] @dataclass @@ -53,6 +59,33 @@ def json(self, exclude_null=True): return json.dumps(attributes) +@dataclass +class ConversationPrompt: + """A prompt and response stored in a conversation's history.""" + + conversation_prompt_id: str + conversation_id: str + conversation_title: str + profile_name: Optional[str] + prompt_action: Optional[str] + prompt: Optional[str] + prompt_response: Optional[str] + created: Optional[datetime.datetime] + modified: Optional[datetime.datetime] + client_identifier: Optional[str] + client_ip: Optional[str] + sid: Optional[int] + serial: Optional[int] + + +def _read_lob(value): + return value.read() if isinstance(value, oracledb.LOB) else value + + +async def _async_read_lob(value): + return await value.read() if hasattr(value, "read") else value + + class _BaseConversation: def __init__( @@ -108,6 +141,82 @@ def delete(self, force: bool = False): }, ) + def delete_prompt( + self, conversation_prompt_id: str, force: bool = False + ) -> None: + """Delete a stored prompt from this conversation. + + :param str conversation_prompt_id: Identifier of the prompt to delete. + :param bool force: Ignore a missing prompt when ``True``. + """ + with cursor() as cr: + cr.callproc( + "DBMS_CLOUD_AI.DELETE_CONVERSATION_PROMPT", + keyword_parameters={ + "conversation_prompt_id": conversation_prompt_id, + "force": force, + }, + ) + + def add_tag(self, tag_key: str, tag_value: str) -> None: + """Add or update a tag on this conversation. + + :param str tag_key: Tag name. + :param str tag_value: Value associated with the tag name. + """ + with cursor() as cr: + cr.callproc( + "DBMS_CLOUD_AI.ADD_CONVERSATION_TAG", + keyword_parameters={ + "conversation_id": self.conversation_id, + "tag_key": tag_key, + "tag_value": tag_value, + }, + ) + + def remove_tag(self, tag_key: str, force: bool = False) -> None: + """Remove a tag from this conversation. + + :param str tag_key: Tag name to remove. + :param bool force: Ignore a missing tag when ``True``. + """ + with cursor() as cr: + cr.callproc( + "DBMS_CLOUD_AI.REMOVE_CONVERSATION_TAG", + keyword_parameters={ + "conversation_id": self.conversation_id, + "tag_key": tag_key, + "force": force, + }, + ) + + def list_prompts(self) -> Iterator[ConversationPrompt]: + """List the prompts and responses stored for this conversation. + + :return: Prompts in creation order. + """ + with cursor() as cr: + cr.execute( + LIST_USER_CONVERSATION_PROMPTS, + conversation_id=self.conversation_id, + ) + for row in cr.fetchall(): + yield ConversationPrompt( + conversation_prompt_id=row[0], + conversation_id=row[1], + conversation_title=row[2], + profile_name=row[3], + prompt_action=row[4], + prompt=_read_lob(row[5]), + prompt_response=_read_lob(row[6]), + created=row[7], + modified=row[8], + client_identifier=row[9], + client_ip=row[10], + sid=row[11], + serial=row[12], + ) + @classmethod def fetch(cls, conversation_id: str) -> "Conversation": """Fetch conversation attributes from the database @@ -222,6 +331,82 @@ async def delete(self, force: bool = False): }, ) + async def delete_prompt( + self, conversation_prompt_id: str, force: bool = False + ) -> None: + """Delete a stored prompt from this conversation. + + :param str conversation_prompt_id: Identifier of the prompt to delete. + :param bool force: Ignore a missing prompt when ``True``. + """ + async with async_cursor() as cr: + await cr.callproc( + "DBMS_CLOUD_AI.DELETE_CONVERSATION_PROMPT", + keyword_parameters={ + "conversation_prompt_id": conversation_prompt_id, + "force": force, + }, + ) + + async def add_tag(self, tag_key: str, tag_value: str) -> None: + """Add or update a tag on this conversation. + + :param str tag_key: Tag name. + :param str tag_value: Value associated with the tag name. + """ + async with async_cursor() as cr: + await cr.callproc( + "DBMS_CLOUD_AI.ADD_CONVERSATION_TAG", + keyword_parameters={ + "conversation_id": self.conversation_id, + "tag_key": tag_key, + "tag_value": tag_value, + }, + ) + + async def remove_tag(self, tag_key: str, force: bool = False) -> None: + """Remove a tag from this conversation. + + :param str tag_key: Tag name to remove. + :param bool force: Ignore a missing tag when ``True``. + """ + async with async_cursor() as cr: + await cr.callproc( + "DBMS_CLOUD_AI.REMOVE_CONVERSATION_TAG", + keyword_parameters={ + "conversation_id": self.conversation_id, + "tag_key": tag_key, + "force": force, + }, + ) + + async def list_prompts(self) -> AsyncGenerator[ConversationPrompt, None]: + """List the prompts and responses stored for this conversation. + + :return: Prompts in creation order. + """ + async with async_cursor() as cr: + await cr.execute( + LIST_USER_CONVERSATION_PROMPTS, + conversation_id=self.conversation_id, + ) + for row in await cr.fetchall(): + yield ConversationPrompt( + conversation_prompt_id=row[0], + conversation_id=row[1], + conversation_title=row[2], + profile_name=row[3], + prompt_action=row[4], + prompt=await _async_read_lob(row[5]), + prompt_response=await _async_read_lob(row[6]), + created=row[7], + modified=row[8], + client_identifier=row[9], + client_ip=row[10], + sid=row[11], + serial=row[12], + ) + @classmethod async def fetch(cls, conversation_id: str) -> "AsyncConversation": """Fetch conversation attributes from the database""" diff --git a/src/select_ai/sql.py b/src/select_ai/sql.py index 4eaaa39..dbc91e7 100644 --- a/src/select_ai/sql.py +++ b/src/select_ai/sql.py @@ -127,6 +127,26 @@ """ +LIST_USER_CONVERSATION_PROMPTS = """ +SELECT conversation_prompt_id, + conversation_id, + conversation_title, + profile_name, + prompt_action, + prompt, + prompt_response, + created, + modified, + client_identifier, + client_ip, + sid, + serial# +FROM USER_CLOUD_AI_CONVERSATION_PROMPTS +WHERE conversation_id = :conversation_id +ORDER BY created +""" + + GET_VECTOR_PIPELINE_LAST_EXECUTION = """ SELECT CAST(last_execution AT TIME ZONE 'UTC' AS TIMESTAMP) FROM USER_CLOUD_PIPELINES diff --git a/tests/profiles/test_1400_conversation.py b/tests/profiles/test_1400_conversation.py index 1cc132c..c2fe854 100644 --- a/tests/profiles/test_1400_conversation.py +++ b/tests/profiles/test_1400_conversation.py @@ -276,3 +276,21 @@ def test_1419_create_with_description_none(conversation_factory): attrs = conv.get_attributes() assert attrs.title == f"{CONVERSATION_PREFIX}_NONE_DESC" assert attrs.description is None + + +def test_1420_add_update_and_remove_tag(conversation): + """Conversation tags can be added, updated, and removed.""" + conversation.add_tag("PROJECT", "SALES") + conversation.add_tag("PROJECT", "MARKETING") + conversation.remove_tag("PROJECT") + conversation.remove_tag("PROJECT", force=True) + + +def test_1421_delete_missing_prompt_with_force(conversation): + """Deleting a missing conversation prompt succeeds when forced.""" + conversation.delete_prompt(str(uuid.uuid4()).upper(), force=True) + + +def test_1422_list_prompts_for_new_conversation(conversation): + """A new conversation has no stored prompts.""" + assert list(conversation.list_prompts()) == [] diff --git a/tests/profiles/test_1500_conversation_async.py b/tests/profiles/test_1500_conversation_async.py index cca8f84..09f3a53 100644 --- a/tests/profiles/test_1500_conversation_async.py +++ b/tests/profiles/test_1500_conversation_async.py @@ -325,3 +325,26 @@ async def test_1519_create_with_description_none(async_conversation_factory): attributes = await conversation.get_attributes() assert attributes.title == f"{CONVERSATION_PREFIX}_NONE_DESC" assert attributes.description is None + + +@pytest.mark.anyio +async def test_1520_add_update_and_remove_tag(async_conversation): + """Async conversation tags can be added, updated, and removed.""" + await async_conversation.add_tag("PROJECT", "SALES") + await async_conversation.add_tag("PROJECT", "MARKETING") + await async_conversation.remove_tag("PROJECT") + await async_conversation.remove_tag("PROJECT", force=True) + + +@pytest.mark.anyio +async def test_1521_delete_missing_prompt_with_force(async_conversation): + """Forced deletion of a missing async conversation prompt succeeds.""" + await async_conversation.delete_prompt( + str(uuid.uuid4()).upper(), force=True + ) + + +@pytest.mark.anyio +async def test_1522_list_prompts_for_new_conversation(async_conversation): + """A new async conversation has no stored prompts.""" + assert [prompt async for prompt in async_conversation.list_prompts()] == [] diff --git a/tests/profiles/test_1800_chat_session.py b/tests/profiles/test_1800_chat_session.py index 4fcbadd..4390f23 100644 --- a/tests/profiles/test_1800_chat_session.py +++ b/tests/profiles/test_1800_chat_session.py @@ -271,6 +271,25 @@ def test_1805_invalid_conversation_object(chat_session_profile): pass +def test_1806_list_prompts(chat_session_profile, conversation_factory): + """A chat prompt is available from the conversation prompt history.""" + conversation = conversation_factory(title="Prompt History") + prompt_text = "Reply with the word prompt-history." + with chat_session_profile.chat_session( + conversation=conversation + ) as session: + session.chat(prompt=prompt_text) + + prompts = list(conversation.list_prompts()) + assert prompts + stored_prompt = next( + prompt for prompt in prompts if prompt.prompt == prompt_text + ) + assert stored_prompt.conversation_id == conversation.conversation_id + assert stored_prompt.conversation_prompt_id + assert stored_prompt.prompt_response + + # def test_1806_missing_conversation_attributes(chat_session_profile): # """Conversation without attributes raises error""" # conversation = Conversation(attributes=None) diff --git a/tests/profiles/test_1900_chat_session_async.py b/tests/profiles/test_1900_chat_session_async.py index 1450eac..a5ab0af 100644 --- a/tests/profiles/test_1900_chat_session_async.py +++ b/tests/profiles/test_1900_chat_session_async.py @@ -295,3 +295,25 @@ async def test_1906_missing_conversation_attributes( conversation=conversation ): await conversation.chat(prompt="Hello World") + + +@pytest.mark.anyio +async def test_1907_list_prompts( + async_chat_session_profile, async_conversation_factory +): + """An async chat prompt is available from conversation prompt history.""" + conversation = await async_conversation_factory(title="Prompt History") + prompt_text = "Reply with the word prompt-history." + async with async_chat_session_profile.chat_session( + conversation=conversation + ) as session: + await session.chat(prompt=prompt_text) + + prompts = [prompt async for prompt in conversation.list_prompts()] + assert prompts + stored_prompt = next( + prompt for prompt in prompts if prompt.prompt == prompt_text + ) + assert stored_prompt.conversation_id == conversation.conversation_id + assert stored_prompt.conversation_prompt_id + assert stored_prompt.prompt_response From 14590eb831dc88046a8ebc682b739889ed42973e Mon Sep 17 00:00:00 2001 From: Abhishek Singh Date: Wed, 29 Jul 2026 13:00:44 -0700 Subject: [PATCH 3/4] Added samples for new APIs --- samples/README.md | 5 + samples/agent/async/get_definition.py | 42 +++++++++ .../agent/async/team_supervisor_inspect.py | 91 +++++++++++++++++++ samples/agent/async/tool_run_describe.py | 61 +++++++++++++ samples/agent/get_definition.py | 37 ++++++++ samples/agent/team_supervisor_inspect.py | 85 +++++++++++++++++ samples/agent/tool_run_describe.py | 56 ++++++++++++ samples/async/conversation_prompts_tags.py | 57 ++++++++++++ samples/conversation_prompts_tags.py | 45 +++++++++ 9 files changed, 479 insertions(+) create mode 100644 samples/agent/async/get_definition.py create mode 100644 samples/agent/async/team_supervisor_inspect.py create mode 100644 samples/agent/async/tool_run_describe.py create mode 100644 samples/agent/get_definition.py create mode 100644 samples/agent/team_supervisor_inspect.py create mode 100644 samples/agent/tool_run_describe.py create mode 100644 samples/async/conversation_prompts_tags.py create mode 100644 samples/conversation_prompts_tags.py diff --git a/samples/README.md b/samples/README.md index af0844e..73b1a6b 100644 --- a/samples/README.md +++ b/samples/README.md @@ -18,6 +18,11 @@ export TNS_ADMIN= > grant privileges to regular user. They are used in 2 sample scripts > `enable_ai_provider.py` and `disable_ai_provider.py` +Some of the new samples use this optional environment variable: + +- `SELECT_AI_PROFILE_NAME` — existing profile for the conversation and + supervised-team samples. + `SELECT_AI_DB_CONNECT_STRING` can be in any one of the following formats diff --git a/samples/agent/async/get_definition.py b/samples/agent/async/get_definition.py new file mode 100644 index 0000000..1381d59 --- /dev/null +++ b/samples/agent/async/get_definition.py @@ -0,0 +1,42 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2026, Oracle and/or its affiliates. +# +# Licensed under the Universal Permissive License v 1.0 as shown at +# http://oss.oracle.com/licenses/upl. +# ----------------------------------------------------------------------------- + +# ----------------------------------------------------------------------------- +# agent/async/get_definition.py +# +# Create a temporary task and asynchronously print its PL/SQL definition. +# ----------------------------------------------------------------------------- + +import asyncio +import os +import uuid + +import select_ai +from select_ai.agent import AsyncTask, TaskAttributes, async_get_definition + +user = os.getenv("SELECT_AI_USER") +password = os.getenv("SELECT_AI_PASSWORD") +dsn = os.getenv("SELECT_AI_DB_CONNECT_STRING") + + +async def main(): + await select_ai.async_connect(user=user, password=password, dsn=dsn) + task = AsyncTask( + task_name=f"SAMPLE_DEFINITION_TASK_{uuid.uuid4().hex.upper()}", + attributes=TaskAttributes( + instruction="Answer the user's question: {query}", tools=[] + ), + ) + await task.create(replace=True) + + try: + print(await async_get_definition("TASK", task.task_name)) + finally: + await task.delete(force=True) + + +asyncio.run(main()) diff --git a/samples/agent/async/team_supervisor_inspect.py b/samples/agent/async/team_supervisor_inspect.py new file mode 100644 index 0000000..90acc9c --- /dev/null +++ b/samples/agent/async/team_supervisor_inspect.py @@ -0,0 +1,91 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2026, Oracle and/or its affiliates. +# +# Licensed under the Universal Permissive License v 1.0 as shown at +# http://oss.oracle.com/licenses/upl. +# ----------------------------------------------------------------------------- + +# ----------------------------------------------------------------------------- +# agent/async/team_supervisor_inspect.py +# +# Async version of the supervised team creation and inspection sample. +# Requires SELECT_AI_PROFILE_NAME to name an existing AI profile. +# ----------------------------------------------------------------------------- + +import asyncio +import os +import uuid + +import select_ai +from select_ai.agent import ( + AgentAttributes, + AsyncAgent, + AsyncTask, + AsyncTeam, + TaskAttributes, + TeamAttributes, +) + +user = os.getenv("SELECT_AI_USER") +password = os.getenv("SELECT_AI_PASSWORD") +dsn = os.getenv("SELECT_AI_DB_CONNECT_STRING") +profile_name = os.getenv("SELECT_AI_PROFILE_NAME", "LLAMA_4_MAVERICK") + + +async def main(): + suffix = uuid.uuid4().hex.upper() + await select_ai.async_connect(user=user, password=password, dsn=dsn) + + task = AsyncTask( + task_name=f"SAMPLE_TASK_{suffix}", + attributes=TaskAttributes( + instruction="Answer the user's question: {query}", + tools=[], + enable_human_tool=False, + ), + ) + worker = AsyncAgent( + agent_name=f"SAMPLE_WORKER_{suffix}", + attributes=AgentAttributes( + profile_name=profile_name, + role="You answer user questions.", + enable_human_tool=False, + ), + ) + supervisor = AsyncAgent( + agent_name=f"SAMPLE_SUPERVISOR_{suffix}", + attributes=AgentAttributes( + profile_name=profile_name, + role="You supervise and coordinate the team.", + enable_human_tool=False, + supervisor=True, + ), + ) + team = AsyncTeam( + team_name=f"SAMPLE_TEAM_{suffix}", + attributes=TeamAttributes( + agents=[{"name": worker.agent_name, "task": task.task_name}], + process="sequential", + supervisor_agent=supervisor.agent_name, + ), + ) + + await task.create(replace=True) + await worker.create(replace=True) + await supervisor.create(replace=True) + await team.create(replace=True) + + try: + fetched = await AsyncTeam.fetch(team.team_name) + print("Supervisor agent:", fetched.attributes.supervisor_agent) + print("Supervisor task:", fetched.attributes.supervisor_task) + print("Team description:", await team.describe_team()) + print("Team tools:", await team.list_tools()) + finally: + await team.delete(force=True) + await supervisor.delete(force=True) + await worker.delete(force=True) + await task.delete(force=True) + + +asyncio.run(main()) diff --git a/samples/agent/async/tool_run_describe.py b/samples/agent/async/tool_run_describe.py new file mode 100644 index 0000000..850b163 --- /dev/null +++ b/samples/agent/async/tool_run_describe.py @@ -0,0 +1,61 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2026, Oracle and/or its affiliates. +# +# Licensed under the Universal Permissive License v 1.0 as shown at +# http://oss.oracle.com/licenses/upl. +# ----------------------------------------------------------------------------- + +# ----------------------------------------------------------------------------- +# agent/async/tool_run_describe.py +# +# Async version of the direct tool execution sample. The database user needs +# permission to create a function. +# ----------------------------------------------------------------------------- + +import asyncio +import os +import uuid + +import select_ai +from select_ai.agent import AsyncTool + +user = os.getenv("SELECT_AI_USER") +password = os.getenv("SELECT_AI_PASSWORD") +dsn = os.getenv("SELECT_AI_DB_CONNECT_STRING") + + +async def main(): + await select_ai.async_connect(user=user, password=password, dsn=dsn) + suffix = uuid.uuid4().hex.upper() + function_name = f"SAMPLE_CALCULATE_AGE_{suffix}" + tool_name = f"SAMPLE_AGE_TOOL_{suffix}" + + async with select_ai.async_cursor() as cursor: + await cursor.execute( + f""" + CREATE OR REPLACE FUNCTION {function_name}(p_birth_date DATE) + RETURN NUMBER IS + BEGIN + RETURN TRUNC(MONTHS_BETWEEN(SYSDATE, p_birth_date) / 12); + END; + """ + ) + + tool = await AsyncTool.create_pl_sql_tool( + tool_name=tool_name, + function=function_name, + description="Calculate age from a birth date", + ) + + try: + print("Tool description:") + print(await tool.describe_tool()) + print("Tool result:") + print(await tool.run_tool('{"p_birth_date":"2000-01-01"}')) + finally: + await tool.delete(force=True) + async with select_ai.async_cursor() as cursor: + await cursor.execute(f"DROP FUNCTION {function_name}") + + +asyncio.run(main()) diff --git a/samples/agent/get_definition.py b/samples/agent/get_definition.py new file mode 100644 index 0000000..53e8bb8 --- /dev/null +++ b/samples/agent/get_definition.py @@ -0,0 +1,37 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2026, Oracle and/or its affiliates. +# +# Licensed under the Universal Permissive License v 1.0 as shown at +# http://oss.oracle.com/licenses/upl. +# ----------------------------------------------------------------------------- + +# ----------------------------------------------------------------------------- +# agent/get_definition.py +# +# Create a temporary task and print its canonical PL/SQL definition. +# ----------------------------------------------------------------------------- + +import os +import uuid + +import select_ai +from select_ai.agent import Task, TaskAttributes, get_definition + +user = os.getenv("SELECT_AI_USER") +password = os.getenv("SELECT_AI_PASSWORD") +dsn = os.getenv("SELECT_AI_DB_CONNECT_STRING") + +select_ai.connect(user=user, password=password, dsn=dsn) + +task = Task( + task_name=f"SAMPLE_DEFINITION_TASK_{uuid.uuid4().hex.upper()}", + attributes=TaskAttributes( + instruction="Answer the user's question: {query}", tools=[] + ), +) +task.create(replace=True) + +try: + print(get_definition("TASK", task.task_name)) +finally: + task.delete(force=True) diff --git a/samples/agent/team_supervisor_inspect.py b/samples/agent/team_supervisor_inspect.py new file mode 100644 index 0000000..5c5cc98 --- /dev/null +++ b/samples/agent/team_supervisor_inspect.py @@ -0,0 +1,85 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2026, Oracle and/or its affiliates. +# +# Licensed under the Universal Permissive License v 1.0 as shown at +# http://oss.oracle.com/licenses/upl. +# ----------------------------------------------------------------------------- + +# ----------------------------------------------------------------------------- +# agent/team_supervisor_inspect.py +# +# Create a supervised team, then inspect its metadata and available tools. +# Requires SELECT_AI_PROFILE_NAME to name an existing AI profile. +# ----------------------------------------------------------------------------- + +import os +import uuid + +import select_ai +from select_ai.agent import ( + Agent, + AgentAttributes, + Task, + TaskAttributes, + Team, + TeamAttributes, +) + +user = os.getenv("SELECT_AI_USER") +password = os.getenv("SELECT_AI_PASSWORD") +dsn = os.getenv("SELECT_AI_DB_CONNECT_STRING") +profile_name = os.getenv("SELECT_AI_PROFILE_NAME", "LLAMA_4_MAVERICK") +suffix = uuid.uuid4().hex.upper() + +select_ai.connect(user=user, password=password, dsn=dsn) + +task = Task( + task_name=f"SAMPLE_TASK_{suffix}", + attributes=TaskAttributes( + instruction="Answer the user's question: {query}", + tools=[], + enable_human_tool=False, + ), +) +worker = Agent( + agent_name=f"SAMPLE_WORKER_{suffix}", + attributes=AgentAttributes( + profile_name=profile_name, + role="You answer user questions.", + enable_human_tool=False, + ), +) +supervisor = Agent( + agent_name=f"SAMPLE_SUPERVISOR_{suffix}", + attributes=AgentAttributes( + profile_name=profile_name, + role="You supervise and coordinate the team.", + enable_human_tool=False, + supervisor=True, + ), +) +team = Team( + team_name=f"SAMPLE_TEAM_{suffix}", + attributes=TeamAttributes( + agents=[{"name": worker.agent_name, "task": task.task_name}], + process="sequential", + supervisor_agent=supervisor.agent_name, + ), +) + +task.create(replace=True) +worker.create(replace=True) +supervisor.create(replace=True) +team.create(replace=True) + +try: + fetched = Team.fetch(team.team_name) + print("Supervisor agent:", fetched.attributes.supervisor_agent) + print("Supervisor task:", fetched.attributes.supervisor_task) + print("Team description:", team.describe_team()) + print("Team tools:", team.list_tools()) +finally: + team.delete(force=True) + supervisor.delete(force=True) + worker.delete(force=True) + task.delete(force=True) diff --git a/samples/agent/tool_run_describe.py b/samples/agent/tool_run_describe.py new file mode 100644 index 0000000..f83fdf1 --- /dev/null +++ b/samples/agent/tool_run_describe.py @@ -0,0 +1,56 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2026, Oracle and/or its affiliates. +# +# Licensed under the Universal Permissive License v 1.0 as shown at +# http://oss.oracle.com/licenses/upl. +# ----------------------------------------------------------------------------- + +# ----------------------------------------------------------------------------- +# agent/tool_run_describe.py +# +# Create a temporary PL/SQL tool, inspect its metadata, and invoke it. +# The database user needs permission to create a function. +# ----------------------------------------------------------------------------- + +import os +import uuid + +import select_ai +from select_ai.agent import Tool + +user = os.getenv("SELECT_AI_USER") +password = os.getenv("SELECT_AI_PASSWORD") +dsn = os.getenv("SELECT_AI_DB_CONNECT_STRING") + +select_ai.connect(user=user, password=password, dsn=dsn) + +suffix = uuid.uuid4().hex.upper() +function_name = f"SAMPLE_CALCULATE_AGE_{suffix}" +tool_name = f"SAMPLE_AGE_TOOL_{suffix}" + +with select_ai.cursor() as cursor: + cursor.execute( + f""" + CREATE OR REPLACE FUNCTION {function_name}(p_birth_date DATE) + RETURN NUMBER IS + BEGIN + RETURN TRUNC(MONTHS_BETWEEN(SYSDATE, p_birth_date) / 12); + END; + """ + ) + +tool = Tool.create_pl_sql_tool( + tool_name=tool_name, + function=function_name, + description="Calculate age from a birth date", +) + +try: + print("Tool description:") + print(tool.describe_tool()) + print("Tool result:") + print(tool.run_tool('{"p_birth_date":"2000-01-01"}')) +finally: + tool.delete(force=True) + with select_ai.cursor() as cursor: + cursor.execute(f"DROP FUNCTION {function_name}") diff --git a/samples/async/conversation_prompts_tags.py b/samples/async/conversation_prompts_tags.py new file mode 100644 index 0000000..0962ec6 --- /dev/null +++ b/samples/async/conversation_prompts_tags.py @@ -0,0 +1,57 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2026, Oracle and/or its affiliates. +# +# Licensed under the Universal Permissive License v 1.0 as shown at +# http://oss.oracle.com/licenses/upl. +# ----------------------------------------------------------------------------- + +# ----------------------------------------------------------------------------- +# async/conversation_prompts_tags.py +# +# Async version of the conversation prompt and tag sample. Requires +# SELECT_AI_PROFILE_NAME to name an existing AI profile. +# ----------------------------------------------------------------------------- + +import asyncio +import os + +import select_ai + +user = os.getenv("SELECT_AI_USER") +password = os.getenv("SELECT_AI_PASSWORD") +dsn = os.getenv("SELECT_AI_DB_CONNECT_STRING") +profile_name = os.getenv("SELECT_AI_PROFILE_NAME", "oci_ai_profile") + + +async def main(): + await select_ai.async_connect(user=user, password=password, dsn=dsn) + conversation = select_ai.AsyncConversation( + attributes=select_ai.ConversationAttributes( + title="Async prompt and tag sample" + ) + ) + await conversation.create() + profile = await select_ai.AsyncProfile(profile_name=profile_name) + + try: + await conversation.add_tag("PROJECT", "SELECT_AI") + await conversation.add_tag("PROJECT", "SELECT_AI_SAMPLES") + async with profile.chat_session(conversation=conversation) as session: + print( + await session.chat( + "Reply with exactly: conversation history works." + ) + ) + prompts = [prompt async for prompt in conversation.list_prompts()] + for prompt in prompts: + print(f"Prompt {prompt.conversation_prompt_id}: {prompt.prompt}") + + await conversation.delete_prompt(prompts[-1].conversation_prompt_id) + prompts = [prompt async for prompt in conversation.list_prompts()] + print("Prompts after deletion:", prompts) + await conversation.remove_tag("PROJECT") + finally: + await conversation.delete(force=True) + + +asyncio.run(main()) diff --git a/samples/conversation_prompts_tags.py b/samples/conversation_prompts_tags.py new file mode 100644 index 0000000..a63bb26 --- /dev/null +++ b/samples/conversation_prompts_tags.py @@ -0,0 +1,45 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2026, Oracle and/or its affiliates. +# +# Licensed under the Universal Permissive License v 1.0 as shown at +# http://oss.oracle.com/licenses/upl. +# ----------------------------------------------------------------------------- + +# ----------------------------------------------------------------------------- +# conversation_prompts_tags.py +# +# Create conversation history, add and remove tags, and delete a stored prompt. +# Requires SELECT_AI_PROFILE_NAME to name an existing AI profile. +# ----------------------------------------------------------------------------- + +import os + +import select_ai + +user = os.getenv("SELECT_AI_USER") +password = os.getenv("SELECT_AI_PASSWORD") +dsn = os.getenv("SELECT_AI_DB_CONNECT_STRING") + +select_ai.connect(user=user, password=password, dsn=dsn) + +conversation = select_ai.Conversation( + attributes=select_ai.ConversationAttributes(title="Prompt and tag sample") +) +conversation.create() +profile = select_ai.Profile(profile_name="oci_ai_profile") + +try: + conversation.add_tag("PROJECT", "SELECT_AI") + conversation.add_tag("PROJECT", "SELECT_AI_SAMPLES") # Updates the tag. + with profile.chat_session(conversation=conversation) as session: + print(session.chat("Reply with exactly: conversation history works.")) + + prompts = list(conversation.list_prompts()) + for prompt in prompts: + print(f"Prompt {prompt.conversation_prompt_id}: {prompt.prompt}") + + conversation.delete_prompt(prompts[-1].conversation_prompt_id) + print("Prompts after deletion:", list(conversation.list_prompts())) + conversation.remove_tag("PROJECT") +finally: + conversation.delete(force=True) From d31134659b7240ed3abc2ae599762e1fa6beb137 Mon Sep 17 00:00:00 2001 From: Abhishek Singh Date: Wed, 29 Jul 2026 15:19:48 -0700 Subject: [PATCH 4/4] Isolate index pipelines --- .../test_5500_async_enable_disable_index.py | 113 +++++++++++------- .../test_5500_enable_disable_index.py | 100 +++++++++------- 2 files changed, 126 insertions(+), 87 deletions(-) diff --git a/tests/vector_index/test_5500_async_enable_disable_index.py b/tests/vector_index/test_5500_async_enable_disable_index.py index a1e24f9..7d74eb2 100644 --- a/tests/vector_index/test_5500_async_enable_disable_index.py +++ b/tests/vector_index/test_5500_async_enable_disable_index.py @@ -7,6 +7,7 @@ import asyncio import logging +import time import oracledb import pytest @@ -69,32 +70,11 @@ async def setup_and_teardown(request, async_connect, enabledisable_params): object_storage_credential_name=request.cls.objstore_cred, ) request.cls.vector_index_attributes = vi_attrs - request.cls.index_name = p["enabledisable_index_name"] - - vector_index = AsyncVectorIndex( - index_name=request.cls.index_name, - attributes=vi_attrs, - description="Test vector index", - profile=request.cls.profile, - ) - await vector_index.create(replace=True) - - created_indexes = [idx.index_name async for idx in AsyncVectorIndex.list()] - assert ( - request.cls.index_name.upper() in created_indexes - ), f"VectorIndex {request.cls.index_name} was not created" + request.cls.base_index_name = p["enabledisable_index_name"] yield logger.info("=== Tearing down TestAsyncEnableDisableVectorIndex class ===") - try: - await request.cls.delete_index_with_retry( - AsyncVectorIndex(index_name=request.cls.index_name), - force=True, - ) - except Exception as exc: - logger.info("Warning: drop vector index failed: %s", exc) - try: await request.cls.profile.delete() except Exception as exc: @@ -113,16 +93,37 @@ async def setup_and_teardown(request, async_connect, enabledisable_params): @pytest.fixture(autouse=True) async def vector_index_state(request): + """Create an isolated vector index for each test method.""" logger.info("--- Starting test: %s ---", request.function.__name__) - request.cls.vecidx = AsyncVectorIndex() - request.cls.async_vector_index = await AsyncVectorIndex.fetch( - request.cls.index_name + index_name = f"{request.cls.base_index_name}_{request.function.__name__}" + vector_index = AsyncVectorIndex( + index_name=index_name, + attributes=request.cls.vector_index_attributes, + description="Test vector index", + profile=request.cls.profile, ) - logger.info(request.cls.async_vector_index.index_name) - await request.cls.async_vector_index.enable() - await asyncio.sleep(1) - yield - logger.info("--- Finished test: %s ---", request.function.__name__) + await vector_index.create(replace=True) + + # Test instances, unlike the class, are not shared between test methods. + request.instance.index_name = index_name + request.instance.async_vector_index = await AsyncVectorIndex.fetch( + index_name + ) + logger.info(request.instance.async_vector_index.index_name) + + try: + yield + finally: + try: + await request.cls.delete_index_with_retry( + AsyncVectorIndex(index_name=index_name), + force=True, + ) + except Exception as exc: + logger.warning( + "Warning: drop vector index %s failed: %s", index_name, exc + ) + logger.info("--- Finished test: %s ---", request.function.__name__) @pytest.mark.usefixtures("enabledisable_params", "setup_and_teardown") @@ -287,8 +288,12 @@ async def delete_index_with_retry( ) await asyncio.sleep(delay) - async def wait_for_status_table(self, status_table, retries=5, delay=2): - for _ in range(retries): + async def wait_for_status_table(self, status_table, timeout=60, delay=2): + """Wait until the asynchronously-created pipeline status table exists.""" + deadline = time.monotonic() + timeout + attempts = 0 + while True: + attempts += 1 try: async with select_ai.async_cursor() as cursor: await cursor.execute( @@ -296,14 +301,27 @@ async def wait_for_status_table(self, status_table, retries=5, delay=2): ) return await cursor.fetchone() except oracledb.DatabaseError as exc: - if "ORA-00942" in str(exc): - await asyncio.sleep(delay) - continue - raise - return None - - async def wait_for_pipeline_entry(self, pipeline_name, retries=5, delay=2): - for _ in range(retries): + if "ORA-00942" not in str(exc): + raise + if time.monotonic() >= deadline: + logger.info( + "Status table %s was not visible after %s attempts " + "within %s seconds.", + status_table, + attempts, + timeout, + ) + return None + await asyncio.sleep(delay) + + async def wait_for_pipeline_entry( + self, pipeline_name, timeout=60, delay=2 + ): + """Wait for ENABLE_VECTOR_INDEX to publish pipeline metadata.""" + deadline = time.monotonic() + timeout + attempts = 0 + while True: + attempts += 1 async with select_ai.async_cursor() as cursor: await cursor.execute( "SELECT status_table FROM user_cloud_pipelines " @@ -313,8 +331,16 @@ async def wait_for_pipeline_entry(self, pipeline_name, retries=5, delay=2): row = await cursor.fetchone() if row and row[0]: return row[0] + if time.monotonic() >= deadline: + logger.info( + "Pipeline %s was not visible after %s attempts within " + "%s seconds.", + pipeline_name, + attempts, + timeout, + ) + return None await asyncio.sleep(delay) - return None async def test_5501(self): """Disabling and enabling the vector index.""" @@ -443,6 +469,11 @@ async def test_5509(self): async def test_5510(self): """Pipeline metadata is available after enabling the vector index.""" + logger.info( + "Disabling then enabling vector index: %s", self.index_name + ) + await self.async_vector_index.disable() + await self.async_vector_index.enable() pipeline_name = f"{self.index_name.upper()}$VECPIPELINE" logger.info("Checking pipeline activity after enabling vector index") status_table = await self.wait_for_pipeline_entry(pipeline_name) diff --git a/tests/vector_index/test_5500_enable_disable_index.py b/tests/vector_index/test_5500_enable_disable_index.py index a429f79..7084ccc 100644 --- a/tests/vector_index/test_5500_enable_disable_index.py +++ b/tests/vector_index/test_5500_enable_disable_index.py @@ -70,32 +70,11 @@ def setup_and_teardown(request, connect, enabledisable_params): object_storage_credential_name=request.cls.objstore_cred, ) request.cls.vector_index_attributes = vi_attrs - request.cls.index_name = p["enabledisable_index_name"] - vector_index = select_ai.VectorIndex( - index_name=request.cls.index_name, - attributes=vi_attrs, - description="Test vector index", - profile=request.cls.profile, - ) - vector_index.create(replace=True) - - try: - created_indexes = [idx.index_name for idx in VectorIndex.list()] - except Exception: - created_indexes = [idx.index_name for idx in VectorIndex().list()] - assert ( - request.cls.index_name.upper() in created_indexes - ), f"VectorIndex {request.cls.index_name} was not created" + request.cls.base_index_name = p["enabledisable_index_name"] yield logger.info("=== Tearing down TestEnableDisableVectorIndex class ===") - try: - vector_index = VectorIndex(index_name=request.cls.index_name) - request.cls.delete_index_with_retry(vector_index, force=True) - except Exception as e: - logger.info(f"Warning: drop vector index failed: {e}") - request.cls.delete_profile(request.cls.profile) request.cls.delete_credential() logger.info("Teardown complete.\n") @@ -271,42 +250,60 @@ def delete_index_with_retry( def setup_method(self, method): logger.info(f"SetUp for {method.__name__}") self.objstore_cred = self.__class__.objstore_cred - self.vecidx = select_ai.VectorIndex() - indexes = list( - self.vecidx.list(index_name_pattern=f"^{self.index_name}$") - ) - assert len(indexes) == 1, ( - f"Expected exactly one vector index named {self.index_name}, " - f"got {len(indexes)}" + self.index_name = f"{self.base_index_name}_{method.__name__}" + self.vector_index = VectorIndex( + index_name=self.index_name, + attributes=self.vector_index_attributes, + description="Test vector index", + profile=self.profile, ) - self.vector_index = indexes[0] + self.vector_index.create(replace=True) logger.info(self.vector_index.index_name) - try: - self.vector_index.enable() - time.sleep(1) - except oracledb.DatabaseError as e: - if "ORA-20000" not in str(e): - raise def teardown_method(self, method): logger.info(f"TearDown for {method.__name__}") + try: + self.delete_index_with_retry( + VectorIndex(index_name=self.index_name), force=True + ) + except Exception as exc: + logger.warning( + "Warning: drop vector index %s failed: %s", + self.index_name, + exc, + ) - def wait_for_status_table(self, cursor, status_table, retries=5, delay=2): - for _ in range(retries): + def wait_for_status_table(self, cursor, status_table, timeout=60, delay=2): + """Wait until the asynchronously-created pipeline status table exists.""" + deadline = time.monotonic() + timeout + attempts = 0 + while True: + attempts += 1 try: cursor.execute(f"SELECT COUNT(*) FROM {status_table}") return cursor.fetchone() except oracledb.DatabaseError as e: - if "ORA-00942" in str(e): - time.sleep(delay) - continue - raise - return None + if "ORA-00942" not in str(e): + raise + if time.monotonic() >= deadline: + logger.info( + "Status table %s was not visible after %s attempts " + "within %s seconds.", + status_table, + attempts, + timeout, + ) + return None + time.sleep(delay) def wait_for_pipeline_entry( - self, cursor, pipeline_name, retries=5, delay=2 + self, cursor, pipeline_name, timeout=60, delay=2 ): - for _ in range(retries): + """Wait for ENABLE_VECTOR_INDEX to publish pipeline metadata.""" + deadline = time.monotonic() + timeout + attempts = 0 + while True: + attempts += 1 cursor.execute( "SELECT status_table FROM user_cloud_pipelines WHERE pipeline_name = :1", [pipeline_name], @@ -314,8 +311,16 @@ def wait_for_pipeline_entry( row = cursor.fetchone() if row and row[0]: return row[0] + if time.monotonic() >= deadline: + logger.info( + "Pipeline %s was not visible after %s attempts within " + "%s seconds.", + pipeline_name, + attempts, + timeout, + ) + return None time.sleep(delay) - return None def test_5501(self): """Disabling and enabling the vector index.""" @@ -435,6 +440,9 @@ def test_5509(self): def test_5510(self): """Pipeline metadata is available after enabling the vector index.""" + logger.info(f"Disabling then enabling vector index: {self.index_name}") + self.vector_index.disable() + self.vector_index.enable() pipeline_name = f"{self.index_name.upper()}$VECPIPELINE" logger.info(f"Checking pipeline activity after enabling vector index") with select_ai.cursor() as cursor: