From cb0b35215817850f25bbcea74867709d6ecaa15d Mon Sep 17 00:00:00 2001 From: Abhishek Singh Date: Thu, 20 Aug 2026 21:32:18 -0700 Subject: [PATCH 01/14] Changes to support A2A server plus gcloud setup --- .dockerignore | 23 ++++ .gcloudignore | 24 ++++ README.md | 43 ++++++ gcloud/Dockerfile | 25 ++++ gcloud/README.md | 74 +++++++++++ gcloud/bootstrap.sh | 86 ++++++++++++ gcloud/build-image.sh | 23 ++++ gcloud/cloudbuild.yaml | 19 +++ gcloud/deploy-cloud-run.sh | 93 +++++++++++++ gcloud/run-a2a-server.sh | 12 ++ pyproject.toml | 8 +- samples/agent/websearch_agent.py | 4 +- samples/vector_index_create.py | 2 +- src/select_ai/a2a_server.py | 218 +++++++++++++++++++++++++++++++ src/select_ai/cli/a2a.py | 129 ++++++++++++++++++ src/select_ai/cli/main.py | 2 + 16 files changed, 779 insertions(+), 6 deletions(-) create mode 100644 .dockerignore create mode 100644 .gcloudignore create mode 100644 gcloud/Dockerfile create mode 100644 gcloud/README.md create mode 100755 gcloud/bootstrap.sh create mode 100755 gcloud/build-image.sh create mode 100644 gcloud/cloudbuild.yaml create mode 100755 gcloud/deploy-cloud-run.sh create mode 100755 gcloud/run-a2a-server.sh create mode 100644 src/select_ai/a2a_server.py create mode 100644 src/select_ai/cli/a2a.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a4f01ef --- /dev/null +++ b/.dockerignore @@ -0,0 +1,23 @@ +.git +.venv +.pytest_cache +.ruff_cache +__pycache__ +*.py[cod] +*.egg-info +.coverage +htmlcov +build +dist +docs +tests +samples +.env +.env.* +wallet +wallets +*.pem +*.key +*.sso +*.p12 +*.zip diff --git a/.gcloudignore b/.gcloudignore new file mode 100644 index 0000000..cec864b --- /dev/null +++ b/.gcloudignore @@ -0,0 +1,24 @@ +# Keep the Cloud Build source upload small and exclude local credentials. +#!include:.gitignore + +.git +.gcloudignore +.venv +.venv*/ +.env +.env.* +wallet/ +wallets/ +*.pem +*.key +*.sso +*.p12 +*.zip +__pycache__/ +.pytest_cache/ +.ruff_cache/ +build/ +dist/ +docs/ +tests/ +samples/ diff --git a/README.md b/README.md index 38a930e..aa42688 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,12 @@ Install the optional command line interface: python3 -m pip install 'select_ai[cli]' ``` +Install A2A server support: + +```bash +python3 -m pip install 'select_ai[a2a]' +``` + ## Documentation See [Select AI for Python documentation][documentation] @@ -37,6 +43,43 @@ profiles: select-ai chat --profile OCI_AI_PROFILE ``` +### A2A Server + +Expose one Oracle Database AI agent team as an A2A JSON-RPC HTTP server: + +```bash +select-ai a2a serve --team SALES_ANALYST --port 8000 +``` + +The command obtains database connection settings from its options or the +`SELECT_AI_*` environment variables. Its Agent Card is available at +`/.well-known/agent-card.json`, and its JSON-RPC endpoint is +`/a2a/jsonrpc/`. Set `--public-url` when the server is behind a proxy or load +balancer so that clients receive its externally reachable URL. + +The server accepts both A2A 1.x and the A2A v0.3 JSON-RPC streaming protocol +for compatibility with Gemini Enterprise. + +Generate the A2A v0.3 Agent Card to paste into Gemini Enterprise after the +service has a public URL: + +```bash +select-ai a2a agent-card \ + --team ORACLE_AI_DATABASE_AGENT \ + --public-url https://YOUR-SERVICE.run.app +``` + +### Cloud Run + +The repository includes an Oracle Linux 10 / Python 3.12 container image in +the `gcloud` directory for Cloud Run. Configure `A2A_TEAM`, `PUBLIC_URL`, and the standard +`SELECT_AI_*` connection environment variables at deployment. Inject +`SELECT_AI_PASSWORD` from Secret Manager; never add database credentials to +the image or source tree. + +See [gcloud/README.md](gcloud/README.md) for one-time secret setup, image +build, and per-team deployment commands. + ![Select AI CLI demo](doc/source/image/select_ai_cli_demo.gif) ### Basic Example diff --git a/gcloud/Dockerfile b/gcloud/Dockerfile new file mode 100644 index 0000000..ce96f02 --- /dev/null +++ b/gcloud/Dockerfile @@ -0,0 +1,25 @@ +FROM oraclelinux:10-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PATH=/opt/venv/bin:$PATH + +RUN microdnf install -y python3 python3-pip ca-certificates \ + && microdnf clean all + +WORKDIR /app + +COPY pyproject.toml README.md LICENSE.txt ./ +COPY src ./src + +RUN python3 -m venv /opt/venv \ + && python -m pip install --no-cache-dir --upgrade pip setuptools wheel \ + && python -m pip install --no-cache-dir '.[a2a]' + +COPY gcloud/run-a2a-server.sh /app/gcloud/run-a2a-server.sh + +RUN chmod 0555 /app/gcloud/run-a2a-server.sh + +EXPOSE 8080 + +ENTRYPOINT ["/app/gcloud/run-a2a-server.sh"] diff --git a/gcloud/README.md b/gcloud/README.md new file mode 100644 index 0000000..6681b20 --- /dev/null +++ b/gcloud/README.md @@ -0,0 +1,74 @@ +# Google Cloud deployment + +This directory separates the three deployment concerns: + +```text +bootstrap.sh one-time project and Secret Manager setup +build-image.sh source → one generic Artifact Registry image +deploy-cloud-run.sh existing image → one Cloud Run service/team +``` + +## 1. One-time setup and secrets + +Run: + +```bash +gcloud/bootstrap.sh +``` + +It enables the required APIs, creates the `select-ai` Artifact Registry Docker +repository, creates the `oracle-a2a-runtime` service account, prompts for the +ADB username/password/connect descriptor, and stores them as Secret Manager +secrets. It also grants only that runtime service account access to the +secrets. + +The deployed container receives those secrets as: + +```text +SELECT_AI_USER +SELECT_AI_PASSWORD +SELECT_AI_DB_CONNECT_STRING +``` + +## 2. Build the generic image when code changes + +Run: + +```bash +gcloud/build-image.sh +``` + +Cloud Build receives the repository source (filtered by `.gcloudignore`) and +uses `gcloud/Dockerfile`. It builds the generic image: + +```text +REGION-docker.pkg.dev/PROJECT_ID/select-ai/select-ai-a2a-server:IMAGE_TAG +``` + +The database team name is not baked into the image. + +## 3. Deploy one or more teams from the same image + +Use the image URI emitted by `build-image.sh`: + +```bash +IMAGE_URI=REGION-docker.pkg.dev/PROJECT_ID/select-ai/select-ai-a2a-server:IMAGE_TAG \ +SERVICE=oracle-database-a2a \ +A2A_TEAM=ORACLE_AI_DATABASE_AGENT \ +gcloud/deploy-cloud-run.sh +``` + +Deploy another team without rebuilding: + +```bash +IMAGE_URI=REGION-docker.pkg.dev/PROJECT_ID/select-ai/select-ai-a2a-server:IMAGE_TAG \ +SERVICE=sales-analyst-a2a \ +A2A_TEAM=SALES_ANALYST \ +gcloud/deploy-cloud-run.sh +``` + +The deploy script injects the Secret Manager values as Cloud Run environment +variables. It does not upload source code or build an image. After deployment, +it calls `/.well-known/agent-card.json` using +`gcloud auth print-identity-token` and pretty-prints the result. The active +gcloud user therefore needs `roles/run.invoker` on the service. diff --git a/gcloud/bootstrap.sh b/gcloud/bootstrap.sh new file mode 100755 index 0000000..c40d114 --- /dev/null +++ b/gcloud/bootstrap.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash + +# One-time Google Cloud setup for the Select AI A2A server. +# Creates the Artifact Registry repository, runtime service account, and +# Secret Manager secrets. It prompts for database values and never writes +# them to source files. + +set -euo pipefail + +project_id="${PROJECT_ID:-$(gcloud config get-value project 2>/dev/null)}" +region="${REGION:-us-central1}" +repository="${REPOSITORY:-select-ai}" +runtime_sa_name="${RUNTIME_SA_NAME:-oracle-a2a-runtime}" +db_user_secret="${DB_USER_SECRET:-select-ai-db-user}" +db_password_secret="${DB_PASSWORD_SECRET:-select-ai-db-password}" +db_dsn_secret="${DB_DSN_SECRET:-select-ai-db-connect-string}" + +if [[ -z "$project_id" || "$project_id" == "(unset)" ]]; then + echo "Set PROJECT_ID or configure one with: gcloud config set project PROJECT_ID" >&2 + exit 1 +fi + +runtime_sa="${runtime_sa_name}@${project_id}.iam.gserviceaccount.com" + +gcloud services enable \ + run.googleapis.com \ + cloudbuild.googleapis.com \ + artifactregistry.googleapis.com \ + secretmanager.googleapis.com \ + --project="$project_id" + +if ! gcloud artifacts repositories describe "$repository" \ + --location="$region" --project="$project_id" >/dev/null 2>&1; then + gcloud artifacts repositories create "$repository" \ + --repository-format=docker \ + --location="$region" \ + --project="$project_id" +fi + +if ! gcloud iam service-accounts describe "$runtime_sa" \ + --project="$project_id" >/dev/null 2>&1; then + gcloud iam service-accounts create "$runtime_sa_name" \ + --project="$project_id" \ + --display-name="Oracle Select AI A2A runtime" +fi + +read -r -p "ADB user: " db_user +read -r -s -p "ADB password: " db_password +echo +read -r -p "ADB connect descriptor: " db_dsn +trap 'unset db_user db_password db_dsn' EXIT + +add_secret() { + local name="$1" + local value="$2" + + if gcloud secrets describe "$name" --project="$project_id" >/dev/null 2>&1; then + printf %s "$value" | gcloud secrets versions add "$name" \ + --project="$project_id" --data-file=- >/dev/null + else + printf %s "$value" | gcloud secrets create "$name" \ + --project="$project_id" \ + --replication-policy=automatic \ + --data-file=- >/dev/null + fi + + gcloud secrets add-iam-policy-binding "$name" \ + --project="$project_id" \ + --member="serviceAccount:$runtime_sa" \ + --role="roles/secretmanager.secretAccessor" >/dev/null +} + +add_secret "$db_user_secret" "$db_user" +add_secret "$db_password_secret" "$db_password" +add_secret "$db_dsn_secret" "$db_dsn" + +cat <&2 + exit 1 +fi + +gcloud builds submit "$repo_root" \ + --project="$project_id" \ + --config="$repo_root/gcloud/cloudbuild.yaml" \ + --substitutions="_REGION=$region,_REPOSITORY=$repository,_IMAGE_TAG=$image_tag" + +echo "$region-docker.pkg.dev/$project_id/$repository/select-ai-a2a-server:$image_tag" diff --git a/gcloud/cloudbuild.yaml b/gcloud/cloudbuild.yaml new file mode 100644 index 0000000..cde90f7 --- /dev/null +++ b/gcloud/cloudbuild.yaml @@ -0,0 +1,19 @@ +# Build one reusable A2A server image. Database team selection is Cloud Run +# configuration, not an image-build input. +steps: + - name: gcr.io/cloud-builders/docker + args: + - build + - --file + - gcloud/Dockerfile + - --tag + - ${_REGION}-docker.pkg.dev/$PROJECT_ID/${_REPOSITORY}/select-ai-a2a-server:${_IMAGE_TAG} + - . + +images: + - ${_REGION}-docker.pkg.dev/$PROJECT_ID/${_REPOSITORY}/select-ai-a2a-server:${_IMAGE_TAG} + +substitutions: + _REGION: us-central1 + _REPOSITORY: select-ai + _IMAGE_TAG: latest diff --git a/gcloud/deploy-cloud-run.sh b/gcloud/deploy-cloud-run.sh new file mode 100755 index 0000000..9901867 --- /dev/null +++ b/gcloud/deploy-cloud-run.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash + +# Deploy an existing Select AI A2A server image to private Cloud Run. +# +# Prerequisites: +# * gcloud is authenticated and has deployment permissions. +# * Run bootstrap.sh to create the runtime service account and secrets. +# * Run build-image.sh to create IMAGE_URI when application code changes. +# +# Override any setting by exporting it before running this script. + +set -euo pipefail + +project_id="${PROJECT_ID:-$(gcloud config get-value project 2>/dev/null)}" +region="${REGION:-us-central1}" +service="${SERVICE:-oracle-a2a-agent}" +a2a_team="${A2A_TEAM:-ORACLE_AI_DATABASE_AGENT}" +runtime_sa="${RUNTIME_SA:-oracle-a2a-runtime@${project_id}.iam.gserviceaccount.com}" +image_uri="${IMAGE_URI:-}" +db_user_secret="${DB_USER_SECRET:-select-ai-db-user}" +db_password_secret="${DB_PASSWORD_SECRET:-select-ai-db-password}" +db_dsn_secret="${DB_DSN_SECRET:-select-ai-db-connect-string}" +memory="${MEMORY:-1Gi}" +timeout="${TIMEOUT:-900}" +max_instances="${MAX_INSTANCES:-1}" + +if [[ -z "$project_id" || "$project_id" == "(unset)" ]]; then + echo "Set PROJECT_ID or configure one with: gcloud config set project PROJECT_ID" >&2 + exit 1 +fi + +if [[ -z "$image_uri" ]]; then + echo "Set IMAGE_URI to an image created by gcloud/build-image.sh" >&2 + exit 1 +fi + +active_account="$(gcloud auth list --filter=status:ACTIVE --format='value(account)')" +if [[ -z "$active_account" ]]; then + echo "No active gcloud account. Run: gcloud auth login" >&2 + exit 1 +fi + +for secret in "$db_user_secret" "$db_password_secret" "$db_dsn_secret"; do + gcloud secrets describe "$secret" --project="$project_id" >/dev/null +done + +gcloud iam service-accounts describe "$runtime_sa" \ + --project="$project_id" >/dev/null + +# The entrypoint requires PUBLIC_URL. This first revision is immediately +# followed by an update using the actual URL returned by Cloud Run. +gcloud run deploy "$service" \ + --image="$image_uri" \ + --project="$project_id" \ + --region="$region" \ + --service-account="$runtime_sa" \ + --no-allow-unauthenticated \ + --port=8080 \ + --memory="$memory" \ + --timeout="$timeout" \ + --max-instances="$max_instances" \ + --set-env-vars="A2A_TEAM=$a2a_team,PUBLIC_URL=https://pending.invalid" \ + --update-secrets="SELECT_AI_USER=$db_user_secret:1,SELECT_AI_PASSWORD=$db_password_secret:1,SELECT_AI_DB_CONNECT_STRING=$db_dsn_secret:1" + +service_url="$(gcloud run services describe "$service" \ + --project="$project_id" \ + --region="$region" \ + --format='value(status.url)')" + +gcloud run services update "$service" \ + --project="$project_id" \ + --region="$region" \ + --update-env-vars="PUBLIC_URL=$service_url" + +echo "Fetching the authenticated A2A Agent Card..." +agent_card="$(curl --fail --silent --show-error \ + -H "Authorization: Bearer $(gcloud auth print-identity-token)" \ + "$service_url/.well-known/agent-card.json")" + +printf '%s\n' "$agent_card" | python3 -m json.tool + +cat <=1.0.3", + "uvicorn>=0.30", +] test = [ "anyio", "pytest", diff --git a/samples/agent/websearch_agent.py b/samples/agent/websearch_agent.py index 9692781..ab120e1 100644 --- a/samples/agent/websearch_agent.py +++ b/samples/agent/websearch_agent.py @@ -110,7 +110,5 @@ # Run the Agent Team for conversation_id, prompt in USER_QUERIES.items(): - response = team.run( - prompt=prompt, params={"conversation_id": conversation_id} - ) + response = team.run(prompt=prompt) print(response) diff --git a/samples/vector_index_create.py b/samples/vector_index_create.py index 839283c..26e0266 100644 --- a/samples/vector_index_create.py +++ b/samples/vector_index_create.py @@ -45,7 +45,7 @@ # the objects reside in ObjectStore and the vector database is # Oracle vector_index_attributes = select_ai.OracleVectorIndexAttributes( - location="https://objectstorage.us-ashburn-1.oraclecloud.com/n/dwcsdev/b/conda-environment/o/tenant1-pdb3/graph", + location="https://objectstorage.us-ashburn-1.oraclecloud.com/n/dwcsdev/b/conda-environment/o/tenant1-pdb3/graph/*.json", object_storage_credential_name="my_oci_ai_profile_key", ) diff --git a/src/select_ai/a2a_server.py b/src/select_ai/a2a_server.py new file mode 100644 index 0000000..cd463ae --- /dev/null +++ b/src/select_ai/a2a_server.py @@ -0,0 +1,218 @@ +# ----------------------------------------------------------------------------- +# 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. +# ----------------------------------------------------------------------------- + +"""A2A HTTP server support for Oracle Database AI agent teams.""" + +from asyncio import Lock +from contextlib import asynccontextmanager +from typing import Optional + +import select_ai +from select_ai.agent import AsyncTeam +from select_ai.version import __version__ + + +def _a2a_imports(): + """Load optional A2A dependencies only when the server is requested.""" + try: + from a2a.helpers import new_task_from_user_message, new_text_part + from a2a.server.agent_execution import AgentExecutor + from a2a.server.request_handlers import DefaultRequestHandler + from a2a.server.routes import ( + create_agent_card_routes, + create_jsonrpc_routes, + ) + from a2a.server.tasks import InMemoryTaskStore, TaskUpdater + from a2a.types import ( + AgentCapabilities, + AgentCard, + AgentInterface, + AgentSkill, + ) + from starlette.applications import Starlette + except ImportError as exc: + raise RuntimeError( + "A2A server support requires the optional 'a2a' extra. " + "Install it with: pip install 'select_ai[a2a]'" + ) from exc + + return { + "AgentCapabilities": AgentCapabilities, + "AgentCard": AgentCard, + "AgentExecutor": AgentExecutor, + "AgentInterface": AgentInterface, + "AgentSkill": AgentSkill, + "DefaultRequestHandler": DefaultRequestHandler, + "InMemoryTaskStore": InMemoryTaskStore, + "Starlette": Starlette, + "TaskUpdater": TaskUpdater, + "create_agent_card_routes": create_agent_card_routes, + "create_jsonrpc_routes": create_jsonrpc_routes, + "new_task_from_user_message": new_task_from_user_message, + "new_text_part": new_text_part, + } + + +def ensure_a2a_dependencies() -> None: + """Raise a helpful error when the optional A2A dependencies are absent.""" + _a2a_imports() + + +def create_app( # noqa: PLR0913 + team_name: str, + public_url: str, + user: str, + password: str, + dsn: str, + wallet_location: Optional[str] = None, + wallet_password: Optional[str] = None, + description: Optional[str] = None, + pool_max_size: int = 10, +): + """Build an A2A JSON-RPC application for one database AI agent team.""" + if pool_max_size < 1: + raise ValueError("pool_max_size must be at least 1") + + imports = _a2a_imports() + agent_card = _build_agent_card(imports, team_name, public_url, description) + executor = _build_executor(imports, team_name) + handler = imports["DefaultRequestHandler"]( + agent_executor=executor, + task_store=imports["InMemoryTaskStore"](), + agent_card=agent_card, + ) + + @asynccontextmanager + async def lifespan(app): + connect_args = { + "user": user, + "password": password, + "dsn": dsn, + "min_size": 1, + "max_size": pool_max_size, + } + if wallet_location: + connect_args["wallet_location"] = wallet_location + connect_args["config_dir"] = wallet_location + if wallet_password: + connect_args["wallet_password"] = wallet_password + select_ai.create_pool_async(**connect_args) + try: + yield + finally: + await select_ai.async_disconnect() + + routes = imports["create_agent_card_routes"](agent_card) + routes.extend( + imports["create_jsonrpc_routes"]( + handler, + rpc_url="/a2a/jsonrpc/", + enable_v0_3_compat=True, + ) + ) + return imports["Starlette"](routes=routes, lifespan=lifespan) + + +def _build_agent_card(imports, team_name, public_url, description): + description = description or f"Oracle Database AI agent team {team_name}." + endpoint = f"{public_url.rstrip('/')}/a2a/jsonrpc/" + return imports["AgentCard"]( + name=team_name, + description=description, + version=__version__, + default_input_modes=["text/plain"], + default_output_modes=["text/plain"], + capabilities=imports["AgentCapabilities"](streaming=True), + supported_interfaces=[ + imports["AgentInterface"]( + protocol_binding="JSONRPC", + protocol_version="1.0", + url=endpoint, + ), + imports["AgentInterface"]( + protocol_binding="JSONRPC", + protocol_version="0.3", + url=endpoint, + ), + ], + skills=[ + imports["AgentSkill"]( + id=team_name.lower(), + name=team_name, + description=description, + tags=["oracle", "database", "select-ai"], + examples=[], + input_modes=["text/plain"], + output_modes=["text/plain"], + ) + ], + ) + + +def _build_executor(imports, team_name): + agent_executor = imports["AgentExecutor"] + task_updater = imports["TaskUpdater"] + new_task_from_user_message = imports["new_task_from_user_message"] + new_text_part = imports["new_text_part"] + conversation_ids = {} + conversation_lock = Lock() + + async def get_database_conversation_id(task): + """Create one Oracle conversation for each A2A context.""" + a2a_context_id = task.context_id or task.id + async with conversation_lock: + conversation_id = conversation_ids.get(a2a_context_id) + if conversation_id: + return conversation_id + + conversation = select_ai.AsyncConversation( + attributes=select_ai.ConversationAttributes( + title=f"A2A {team_name}", + description=f"A2A context {a2a_context_id}", + ) + ) + conversation_id = await conversation.create() + conversation_ids[a2a_context_id] = conversation_id + return conversation_id + + class DatabaseTeamExecutor(agent_executor): + async def execute(self, context, event_queue): + if context.current_task: + task = context.current_task + else: + task = new_task_from_user_message(context.message) + await event_queue.enqueue_event(task) + + updater = task_updater( + event_queue=event_queue, + task_id=task.id, + context_id=task.context_id, + ) + await updater.start_work() + conversation_id = await get_database_conversation_id(task) + result = await AsyncTeam(team_name=team_name).run( + prompt=context.get_user_input(), + params={"conversation_id": conversation_id}, + ) + await updater.add_artifact( + parts=[new_text_part(result or "")], + name="database-agent-result", + last_chunk=True, + ) + await updater.complete() + + async def cancel(self, context, event_queue): + if context.current_task is None: + return + updater = task_updater( + event_queue=event_queue, + task_id=context.current_task.id, + context_id=context.current_task.context_id, + ) + await updater.cancel() + + return DatabaseTeamExecutor() diff --git a/src/select_ai/cli/a2a.py b/src/select_ai/cli/a2a.py new file mode 100644 index 0000000..a681446 --- /dev/null +++ b/src/select_ai/cli/a2a.py @@ -0,0 +1,129 @@ +# ----------------------------------------------------------------------------- +# 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. +# ----------------------------------------------------------------------------- + +import getpass +import json + +import click + +from select_ai.cli.common import connection_options +from select_ai.version import __version__ + + +@click.group() +def a2a(): + """Serve Select AI database agent teams through A2A.""" + + +@a2a.command() +@click.option("--team", "team_name", required=True, help="Database AI team.") +@click.option("--host", default="127.0.0.1", show_default=True) +@click.option("--port", default=8000, show_default=True, type=int) +@click.option( + "--public-url", + help="Public base URL advertised in the A2A Agent Card.", +) +@click.option("--description", help="A2A agent description.") +@click.option( + "--pool-max-size", + default=10, + show_default=True, + type=click.IntRange(min=1), + help="Maximum asynchronous Oracle connections.", +) +@connection_options +def serve( + team_name, + host, + port, + public_url, + description, + pool_max_size, + user, + password, + dsn, + wallet_location, + wallet_password, +): + """Start an A2A HTTP server for one database AI agent team.""" + try: + from select_ai.a2a_server import ( + create_app, + ensure_a2a_dependencies, + ) + + ensure_a2a_dependencies() + import uvicorn + except RuntimeError as exc: + raise click.ClickException(str(exc)) from exc + except ImportError as exc: + raise click.ClickException( + "A2A server support requires the optional 'a2a' extra. " + "Install it with: pip install 'select_ai[a2a]'" + ) from exc + + if password is None: + password = getpass.getpass("Database password: ") + if user is None or dsn is None: + raise click.ClickException( + "--user and --dsn (or their SELECT_AI_* environment variables) " + "are required" + ) + if public_url is None: + public_url = f"http://{host}:{port}" + + app = create_app( + team_name=team_name, + public_url=public_url, + user=user, + password=password, + dsn=dsn, + wallet_location=wallet_location, + wallet_password=wallet_password, + description=description, + pool_max_size=pool_max_size, + ) + + click.echo( + f"A2A Agent Card: {public_url.rstrip('/')}/.well-known/agent-card.json" + ) + uvicorn.run(app, host=host, port=port) + + +@a2a.command("agent-card") +@click.option("--team", "team_name", required=True, help="Database AI team.") +@click.option( + "--public-url", + required=True, + help="Public base URL of the A2A server.", +) +@click.option("--description", help="A2A agent description.") +def agent_card(team_name, public_url, description): + """Print a Gemini Enterprise-compatible A2A v0.3 Agent Card.""" + description = description or ( + f"Oracle Database AI agent team {team_name}." + ) + endpoint = f"{public_url.rstrip('/')}/a2a/jsonrpc/" + card = { + "protocolVersion": "0.3", + "name": team_name, + "description": description, + "url": endpoint, + "version": __version__, + "capabilities": {"streaming": True}, + "skills": [ + { + "id": team_name.lower(), + "name": team_name, + "description": description, + "tags": ["oracle", "database", "select-ai"], + } + ], + "defaultInputModes": ["text/plain"], + "defaultOutputModes": ["text/plain"], + } + click.echo(json.dumps(card, indent=2)) diff --git a/src/select_ai/cli/main.py b/src/select_ai/cli/main.py index 3016f79..a8303e0 100644 --- a/src/select_ai/cli/main.py +++ b/src/select_ai/cli/main.py @@ -16,6 +16,7 @@ def cli(): ) else: + from select_ai.cli.a2a import a2a from select_ai.cli.chat import chat from select_ai.cli.profile import profile_group from select_ai.cli.sql import sql @@ -27,6 +28,7 @@ def cli(): cli.add_command(chat) cli.add_command(sql) cli.add_command(profile_group, "profile") + cli.add_command(a2a) if __name__ == "__main__": From de9bb7dc0e9172910472e06671baf75a10216a55 Mon Sep 17 00:00:00 2001 From: Abhishek Singh Date: Fri, 21 Aug 2026 15:34:12 -0700 Subject: [PATCH 02/14] Update deprecated model --- tests/profiles/test_1600_generate.py | 4 ++-- tests/profiles/test_1700_generate_async.py | 4 ++-- tests/profiles/test_1800_chat_session.py | 2 +- tests/profiles/test_1900_chat_session_async.py | 2 +- tests/test_1000_basic_sanity.py | 4 ++-- tests/test_1100_basic_sanity_async.py | 4 ++-- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/tests/profiles/test_1600_generate.py b/tests/profiles/test_1600_generate.py index d9661ff..52e846b 100644 --- a/tests/profiles/test_1600_generate.py +++ b/tests/profiles/test_1600_generate.py @@ -67,7 +67,7 @@ def generate_profile(generate_profile_attributes): ) profile.set_attribute( attribute_name="model", - attribute_value="meta.llama-3.1-405b-instruct", + attribute_value="meta.llama-3.3-70b-instruct", ) yield profile logger.info("Deleting generate profile %s", profile.profile_name) @@ -99,7 +99,7 @@ def negative_profile(test_env, oci_credential, generate_provider): ) profile.set_attribute( attribute_name="model", - attribute_value="meta.llama-3.1-405b-instruct", + attribute_value="meta.llama-3.3-70b-instruct", ) yield profile logger.info("Deleting negative generate profile %s", profile.profile_name) diff --git a/tests/profiles/test_1700_generate_async.py b/tests/profiles/test_1700_generate_async.py index fdb1d9b..90c47da 100644 --- a/tests/profiles/test_1700_generate_async.py +++ b/tests/profiles/test_1700_generate_async.py @@ -71,7 +71,7 @@ async def async_generate_profile(async_generate_profile_attributes): ) await profile.set_attribute( attribute_name="model", - attribute_value="meta.llama-3.1-405b-instruct", + attribute_value="meta.llama-3.3-70b-instruct", ) yield profile logger.info("Deleting async generate profile %s", profile.profile_name) @@ -105,7 +105,7 @@ async def async_negative_profile( ) await profile.set_attribute( attribute_name="model", - attribute_value="meta.llama-3.1-405b-instruct", + attribute_value="meta.llama-3.3-70b-instruct", ) yield profile logger.info( diff --git a/tests/profiles/test_1800_chat_session.py b/tests/profiles/test_1800_chat_session.py index a05901f..7f4855b 100644 --- a/tests/profiles/test_1800_chat_session.py +++ b/tests/profiles/test_1800_chat_session.py @@ -96,7 +96,7 @@ def chat_session_profile(oci_credential, chat_session_provider): ) profile.set_attribute( attribute_name="model", - attribute_value="meta.llama-3.1-405b-instruct", + attribute_value="meta.llama-3.3-70b-instruct", ) yield profile logger.info("Deleting chat session profile %s", profile.profile_name) diff --git a/tests/profiles/test_1900_chat_session_async.py b/tests/profiles/test_1900_chat_session_async.py index cea0d17..fb079d4 100644 --- a/tests/profiles/test_1900_chat_session_async.py +++ b/tests/profiles/test_1900_chat_session_async.py @@ -98,7 +98,7 @@ async def async_chat_session_profile( ) await profile.set_attribute( attribute_name="model", - attribute_value="meta.llama-3.1-405b-instruct", + attribute_value="meta.llama-3.3-70b-instruct", ) yield profile logger.info("Deleting async chat session profile %s", profile.profile_name) diff --git a/tests/test_1000_basic_sanity.py b/tests/test_1000_basic_sanity.py index c248e98..27cd565 100644 --- a/tests/test_1000_basic_sanity.py +++ b/tests/test_1000_basic_sanity.py @@ -76,7 +76,7 @@ def test_1003(oci_gen_ai_profile): def test_1004(oci_gen_ai_profile): """Chat for a simple NL prompt""" oci_gen_ai_profile.set_attribute( - attribute_name="model", attribute_value="meta.llama-3.1-405b-instruct" + attribute_name="model", attribute_value="meta.llama-3.3-70b-instruct" ) prompt = "What is a database?" chat = oci_gen_ai_profile.chat(prompt) @@ -87,7 +87,7 @@ def test_1004(oci_gen_ai_profile): def test_1005(oci_gen_ai_profile): """Run SQL for a simple NL prompt""" oci_gen_ai_profile.set_attribute( - attribute_name="model", attribute_value="meta.llama-3.1-405b-instruct" + attribute_name="model", attribute_value="meta.llama-3.3-70b-instruct" ) prompt = "How many gymnast in the table?" df = oci_gen_ai_profile.run_sql(prompt) diff --git a/tests/test_1100_basic_sanity_async.py b/tests/test_1100_basic_sanity_async.py index 0b9b8e4..f50723f 100644 --- a/tests/test_1100_basic_sanity_async.py +++ b/tests/test_1100_basic_sanity_async.py @@ -75,7 +75,7 @@ async def test_1103(async_oci_gen_ai_profile): async def test_1104(async_oci_gen_ai_profile): """Chat for a simple NL prompt""" await async_oci_gen_ai_profile.set_attribute( - attribute_name="model", attribute_value="meta.llama-3.1-405b-instruct" + attribute_name="model", attribute_value="meta.llama-3.3-70b-instruct" ) prompt = "What is a database?" chat = await async_oci_gen_ai_profile.chat(prompt) @@ -86,7 +86,7 @@ async def test_1104(async_oci_gen_ai_profile): async def test_1105(async_oci_gen_ai_profile): """Run SQL for a simple NL prompt""" await async_oci_gen_ai_profile.set_attribute( - attribute_name="model", attribute_value="meta.llama-3.1-405b-instruct" + attribute_name="model", attribute_value="meta.llama-3.3-70b-instruct" ) prompt = "How many gymnast in the table?" df = await async_oci_gen_ai_profile.run_sql(prompt) From f85514207d76c1e775958dc35a95a90eb320eefd Mon Sep 17 00:00:00 2001 From: Abhishek Singh Date: Wed, 26 Aug 2026 13:44:59 -0700 Subject: [PATCH 03/14] Add A2A server support --- .gcloudignore | 6 + README.md | 34 ++- doc/source/user_guide/cli.rst | 2 +- doc/source/user_guide/installation.rst | 4 +- docker/Dockerfile | 22 ++ docker/a2a-entrypoint.sh | 33 +++ gcloud/Dockerfile | 25 -- gcloud/README.md | 175 +++++++++---- gcloud/bootstrap.sh | 86 ------- gcloud/build-image.sh | 23 -- gcloud/cloudbuild.yaml | 6 +- gcloud/deploy-cloud-run.sh | 93 ------- gcloud/deploy.sh | 300 +++++++++++++++++++++++ gcloud/run-a2a-server.sh | 12 - pyproject.toml | 6 +- samples/profile_create.py | 2 +- src/select_ai/a2a_server.py | 218 ---------------- src/select_ai/agent/a2a/__init__.py | 8 + src/select_ai/agent/a2a/context_store.py | 138 +++++++++++ src/select_ai/agent/a2a/server.py | 190 ++++++++++++++ src/select_ai/agent/a2a/task_store.py | 243 ++++++++++++++++++ src/select_ai/cli/a2a.py | 26 +- tests/a2a/test_agent_card.py | 55 +++++ 23 files changed, 1162 insertions(+), 545 deletions(-) create mode 100644 docker/Dockerfile create mode 100644 docker/a2a-entrypoint.sh delete mode 100644 gcloud/Dockerfile delete mode 100755 gcloud/bootstrap.sh delete mode 100755 gcloud/build-image.sh delete mode 100755 gcloud/deploy-cloud-run.sh create mode 100755 gcloud/deploy.sh delete mode 100755 gcloud/run-a2a-server.sh delete mode 100644 src/select_ai/a2a_server.py create mode 100644 src/select_ai/agent/a2a/__init__.py create mode 100644 src/select_ai/agent/a2a/context_store.py create mode 100644 src/select_ai/agent/a2a/server.py create mode 100644 src/select_ai/agent/a2a/task_store.py create mode 100644 tests/a2a/test_agent_card.py diff --git a/.gcloudignore b/.gcloudignore index cec864b..f2fd78d 100644 --- a/.gcloudignore +++ b/.gcloudignore @@ -19,6 +19,12 @@ __pycache__/ .ruff_cache/ build/ dist/ +doc/ docs/ tests/ samples/ +*.docx +*.pdf +*.png +*.jpg +*.jpeg diff --git a/README.md b/README.md index aa42688..5cbd863 100644 --- a/README.md +++ b/README.md @@ -20,11 +20,7 @@ Install the optional command line interface: python3 -m pip install 'select_ai[cli]' ``` -Install A2A server support: - -```bash -python3 -m pip install 'select_ai[a2a]' -``` +The CLI extra includes A2A server support. ## Documentation @@ -36,12 +32,15 @@ Examples can be found in the [/samples][samples] directory ## Command Line Interface -The optional `select-ai` command provides an interactive chat REPL for Select AI -profiles: +The optional `select-ai` command provides interactive chat, SQL, profile +management, and A2A server tools for Select AI: +### Chat ```bash select-ai chat --profile OCI_AI_PROFILE ``` +![Select AI CLI demo](doc/source/image/select_ai_cli_demo.gif) + ### A2A Server @@ -57,6 +56,11 @@ The command obtains database connection settings from its options or the `/a2a/jsonrpc/`. Set `--public-url` when the server is behind a proxy or load balancer so that clients receive its externally reachable URL. +For Autonomous Database mTLS, also set `SELECT_AI_WALLET_LOCATION` to the +directory containing the unzipped wallet and set `SELECT_AI_WALLET_PASSWORD`. +The CLI passes both values to the Select AI SDK as `wallet_location` and +`wallet_password`. + The server accepts both A2A 1.x and the A2A v0.3 JSON-RPC streaming protocol for compatibility with Gemini Enterprise. @@ -69,20 +73,12 @@ select-ai a2a agent-card \ --public-url https://YOUR-SERVICE.run.app ``` -### Cloud Run - -The repository includes an Oracle Linux 10 / Python 3.12 container image in -the `gcloud` directory for Cloud Run. Configure `A2A_TEAM`, `PUBLIC_URL`, and the standard -`SELECT_AI_*` connection environment variables at deployment. Inject -`SELECT_AI_PASSWORD` from Secret Manager; never add database credentials to -the image or source tree. +## Cloud Run -See [gcloud/README.md](gcloud/README.md) for one-time secret setup, image -build, and per-team deployment commands. - -![Select AI CLI demo](doc/source/image/select_ai_cli_demo.gif) +Deploy the A2A server to Cloud Run using the instructions in +[gcloud/README.md](https://github.com/oracle/python-select-ai/blob/main/gcloud/README.md). -### Basic Example +## Basic Example ```python import select_ai diff --git a/doc/source/user_guide/cli.rst b/doc/source/user_guide/cli.rst index d8d084e..76e2855 100644 --- a/doc/source/user_guide/cli.rst +++ b/doc/source/user_guide/cli.rst @@ -32,7 +32,7 @@ workflows will be added in upcoming releases as the CLI evolves. :width: 100% The package provides an optional ``select-ai`` command line tool. Install the -CLI extra to use it: +CLI extra to use it, including the A2A server commands: .. code-block:: bash diff --git a/doc/source/user_guide/installation.rst b/doc/source/user_guide/installation.rst index 078f39d..f338f5b 100644 --- a/doc/source/user_guide/installation.rst +++ b/doc/source/user_guide/installation.rst @@ -72,8 +72,8 @@ are isolated from your system Python installation. python -m pip install --upgrade "select_ai[cli]" - This installs the ``select-ai`` command. See :ref:`Command Line Interface - `. + This installs the ``select-ai`` command and its A2A server support. See + :ref:`Command Line Interface `. 6. If you are behind a proxy, use the ``--proxy`` option. For example: diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..ae074c3 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,22 @@ +FROM oraclelinux:10-slim + +ENV PATH=/opt/venv/bin:$PATH + +RUN microdnf update -y \ + && microdnf install -y python3 python3-pip ca-certificates unzip \ + && microdnf clean all + +WORKDIR /app + +COPY pyproject.toml README.md LICENSE.txt ./ +COPY src ./src + +RUN python3 -m venv /opt/venv \ + && python -m pip install --no-cache-dir --upgrade pip setuptools wheel \ + && python -m pip install --no-cache-dir '.[a2a]' + +COPY docker/a2a-entrypoint.sh /app/docker/a2a-entrypoint.sh + +RUN chmod 0555 /app/docker/a2a-entrypoint.sh + +ENTRYPOINT ["select-ai"] diff --git a/docker/a2a-entrypoint.sh b/docker/a2a-entrypoint.sh new file mode 100644 index 0000000..b4326d6 --- /dev/null +++ b/docker/a2a-entrypoint.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env sh + +# Cloud Run-only A2A launcher. It expands the optional wallet archive mounted +# by deploy.sh, then starts the generic select-ai CLI in A2A server mode. + +set -eu + +wallet_archive=/var/run/secrets/select-ai-wallet/wallet.zip +wallet_root=/tmp/select-ai-wallet + +if [ -f "$wallet_archive" ]; then + mkdir -p "$wallet_root" + chmod 700 "$wallet_root" + unzip -q "$wallet_archive" -d "$wallet_root" + + wallet_file="$(find "$wallet_root" -type f -name ewallet.pem -print -quit)" + if [ -z "$wallet_file" ]; then + echo "Wallet ZIP does not contain ewallet.pem" >&2 + exit 1 + fi + export SELECT_AI_WALLET_LOCATION="$(dirname "$wallet_file")" +fi + +: "${SELECT_AI_A2A_TEAM:?SELECT_AI_A2A_TEAM is required}" +: "${PUBLIC_URL:?PUBLIC_URL is required}" +: "${SELECT_AI_POOL_MAX_SIZE:=10}" + +exec select-ai a2a serve \ + --team "$SELECT_AI_A2A_TEAM" \ + --host 0.0.0.0 \ + --port "${PORT:-8080}" \ + --pool-max-size "$SELECT_AI_POOL_MAX_SIZE" \ + --public-url "$PUBLIC_URL" diff --git a/gcloud/Dockerfile b/gcloud/Dockerfile deleted file mode 100644 index ce96f02..0000000 --- a/gcloud/Dockerfile +++ /dev/null @@ -1,25 +0,0 @@ -FROM oraclelinux:10-slim - -ENV PYTHONDONTWRITEBYTECODE=1 \ - PYTHONUNBUFFERED=1 \ - PATH=/opt/venv/bin:$PATH - -RUN microdnf install -y python3 python3-pip ca-certificates \ - && microdnf clean all - -WORKDIR /app - -COPY pyproject.toml README.md LICENSE.txt ./ -COPY src ./src - -RUN python3 -m venv /opt/venv \ - && python -m pip install --no-cache-dir --upgrade pip setuptools wheel \ - && python -m pip install --no-cache-dir '.[a2a]' - -COPY gcloud/run-a2a-server.sh /app/gcloud/run-a2a-server.sh - -RUN chmod 0555 /app/gcloud/run-a2a-server.sh - -EXPOSE 8080 - -ENTRYPOINT ["/app/gcloud/run-a2a-server.sh"] diff --git a/gcloud/README.md b/gcloud/README.md index 6681b20..e075515 100644 --- a/gcloud/README.md +++ b/gcloud/README.md @@ -1,74 +1,159 @@ -# Google Cloud deployment +# Deploy the Select AI A2A server to Google Cloud -This directory separates the three deployment concerns: +`gcloud/deploy.sh` builds or selects a Select AI container image, creates or +updates a private Cloud Run service, and configures its database secrets. Run +it on a machine with the [Google Cloud CLI](https://cloud.google.com/sdk/docs/install) +installed and authenticated to the target project. -```text -bootstrap.sh one-time project and Secret Manager setup -build-image.sh source → one generic Artifact Registry image -deploy-cloud-run.sh existing image → one Cloud Run service/team -``` +## IAM permissions + +The scripts use the active `gcloud` identity. They do not elevate its access. + +### Deployer (the active gcloud identity) + +| Operation | Required permissions | +| --- | --- | +| Inspect and create the Artifact Registry repository | `artifactregistry.repositories.get`, `artifactregistry.repositories.create` | +| Inspect and create the default runtime service account | `iam.serviceAccounts.get`, `iam.serviceAccounts.create` | +| Deploy or update Cloud Run | `run.services.create`, `run.services.update`, `run.services.get`, `run.operations.get`; `iam.serviceAccounts.actAs` on the runtime service account; `artifactregistry.repositories.downloadArtifacts` on the image repository | +| With `--build`, upload local source, submit, and wait for a build | `storage.buckets.get`, `storage.objects.create` on the configured source-staging bucket; `cloudbuild.builds.create`, `cloudbuild.builds.get`, `serviceusage.services.use` | +| Inspect, create, and add versions to database or wallet secrets | `secretmanager.secrets.get`, `secretmanager.secrets.create`, `secretmanager.versions.add` | +| Grant the runtime account access to those secrets | `secretmanager.secrets.getIamPolicy`, `secretmanager.secrets.setIamPolicy` | +| Grant Gemini Enterprise and the active gcloud identity access to the service | `run.services.getIamPolicy`, `run.services.setIamPolicy` | +| Obtain the project number | `resourcemanager.projects.get` | + +### Runtime service account + +| Operation | Required permissions | +| --- | --- | +| Read database and wallet secrets while serving requests | `secretmanager.versions.access` | -## 1. One-time setup and secrets +### Other service identities -Run: +| Principal | Operation | Required permissions | +| --- | --- | --- | +| Cloud Build execution service account | With `--build`, push the built image | `artifactregistry.repositories.uploadArtifacts` | +| Gemini Enterprise service agent | Invoke the private Cloud Run service | `run.routes.invoke` | +| Active gcloud identity | Fetch the Agent Card after deployment | `run.routes.invoke` | + +The source-staging bucket is Cloud Build's default unless a custom bucket is +configured. Cloud Build also needs access to its build-log destination; the +default same-project build account has that access. If your organization uses +a custom build service account, source bucket, or log bucket, its administrator +must grant the equivalent Cloud Storage permissions on those resources. + +Google Cloud references: [Service Usage access control](https://cloud.google.com/service-usage/docs/access-control), [Cloud Run deployment permissions](https://cloud.google.com/run/docs/reference/iam/roles), [Secret Manager access control](https://cloud.google.com/secret-manager/docs/access-control), [Artifact Registry roles](https://cloud.google.com/artifact-registry/docs/access-control), and [Cloud Build roles](https://cloud.google.com/build/docs/iam-roles-permissions). + +The rows that set IAM policy are administrative mutations. They are present +because `deploy.sh` creates and rotates secrets and configures private-service +invocation. If your customer deployment identity must not change IAM, provision +the secrets and the `secretmanager.versions.access`/`run.routes.invoke` +permissions beforehand, then +remove those policy-setting commands from the deployment workflow. + +## Prerequisite: enable project APIs once + +An administrator must enable these APIs once for the project: ```bash -gcloud/bootstrap.sh +gcloud services enable \ + run.googleapis.com \ + cloudbuild.googleapis.com \ + artifactregistry.googleapis.com \ + secretmanager.googleapis.com \ + discoveryengine.googleapis.com \ + --project PROJECT_ID ``` -It enables the required APIs, creates the `select-ai` Artifact Registry Docker -repository, creates the `oracle-a2a-runtime` service account, prompts for the -ADB username/password/connect descriptor, and stores them as Secret Manager -secrets. It also grants only that runtime service account access to the -secrets. - -The deployed container receives those secrets as: +## Deploy (and update) the A2A server -```text -SELECT_AI_USER -SELECT_AI_PASSWORD -SELECT_AI_DB_CONNECT_STRING +```bash +gcloud/deploy.sh --build ``` -## 2. Build the generic image when code changes +On the first deployment, the script prompts for the ADB user, password, and +connect descriptor. It stores them in Secret Manager under names based on the +Cloud Run service, and grants only the runtime service account access. The +container receives the values as `SELECT_AI_USER`, `SELECT_AI_PASSWORD`, and +`SELECT_AI_DB_CONNECT_STRING`; they are never placed in the image or source +tree. + +### Optional: Autonomous Database mTLS wallet -Run: +The Select AI SDK already supports `wallet_location` and `wallet_password`. +For Cloud Run, pass the path to the downloaded Autonomous Database wallet ZIP +on the first deployment (or when replacing it): ```bash -gcloud/build-image.sh +gcloud/deploy.sh --wallet-archive /path/to/Wallet_database.zip ``` -Cloud Build receives the repository source (filtered by `.gcloudignore`) and -uses `gcloud/Dockerfile`. It builds the generic image: +The script prompts for the wallet password, stores the ZIP and password as +service-specific Secret Manager secrets, and grants access only to the runtime +service account. Cloud Run mounts the ZIP read-only; its A2A launcher expands it +into ephemeral `/tmp` storage before starting the SDK, verifies it contains +`ewallet.pem`, and sets `SELECT_AI_WALLET_LOCATION` to that file's directory. +Do not commit the wallet ZIP or put its contents in the image. + +Later deploys reuse the wallet. To replace it, pass `--wallet-archive` again. -```text -REGION-docker.pkg.dev/PROJECT_ID/select-ai/select-ai-a2a-server:IMAGE_TAG +The first deployment needs `--build` (or an explicit `--image-uri`). Later +deployments reuse the image already deployed to the service, so changing Cloud +Run configuration or secrets does not create another image. The command +deploys private Cloud Run, sets the final public URL in the Agent Card, grants your active +gcloud identity and Gemini Enterprise Discovery Engine service agent the +`run.routes.invoke` permission for this Cloud Run service. + +The default Cloud Run service is `oracle-a2a-agent`. Its default Agent Team, +installed in Oracle Database, is `ORACLE_AI_DATABASE_AGENT`. Override either +with explicit options: + +```bash +gcloud/deploy.sh --service sales-analyst-a2a --a2a-team SALES_ANALYST ``` -The database team name is not baked into the image. +Use a distinct `--service` value for each A2A team. Each service gets distinct Secret +Manager secret names by default, so credentials remain attached to that A2A +server. -## 3. Deploy one or more teams from the same image +`--max-instances` controls the number of Cloud Run containers. Each container +can use up to 10 Oracle connections by default; change that limit with +`--pool-max-size`, for example `gcloud/deploy.sh --pool-max-size 20`. -Use the image URI emitted by `build-image.sh`: +### Update the Select AI SDK or this repository + +Update the checkout (or modify its dependency version), then explicitly build +and deploy the new image: ```bash -IMAGE_URI=REGION-docker.pkg.dev/PROJECT_ID/select-ai/select-ai-a2a-server:IMAGE_TAG \ -SERVICE=oracle-database-a2a \ -A2A_TEAM=ORACLE_AI_DATABASE_AGENT \ -gcloud/deploy-cloud-run.sh +git pull +gcloud/deploy.sh --build ``` -Deploy another team without rebuilding: +`--build` creates a freshly tagged image from the current source; without it, +the existing image is reused. Existing database secrets are reused without +prompting. To rotate the ADB credentials, explicitly request it: ```bash -IMAGE_URI=REGION-docker.pkg.dev/PROJECT_ID/select-ai/select-ai-a2a-server:IMAGE_TAG \ -SERVICE=sales-analyst-a2a \ -A2A_TEAM=SALES_ANALYST \ -gcloud/deploy-cloud-run.sh +gcloud/deploy.sh --rotate-db-credentials ``` -The deploy script injects the Secret Manager values as Cloud Run environment -variables. It does not upload source code or build an image. After deployment, -it calls `/.well-known/agent-card.json` using -`gcloud auth print-identity-token` and pretty-prints the result. The active -gcloud user therefore needs `roles/run.invoker` on the service. +### What `cloudbuild.yaml` does + +`gcloud/deploy.sh --build` uses `gcloud/cloudbuild.yaml` to tell Cloud Build to build +`docker/Dockerfile` and push it to Artifact Registry. It is build configuration, +not a command you run. The build context is the repository root, so the image +can install the Select AI source from `pyproject.toml` and `src/`. + +### Cloud Build upload contents + +Before the build starts, `gcloud builds submit` archives and uploads the +repository root. The root `.gcloudignore` excludes local virtual environments, +generated documentation, test data, caches, credentials, and Git metadata. +Keep `src/`, `pyproject.toml`, `docker/`, and `gcloud/` in the upload; they are +required to build the image. If the upload is unexpectedly large, check local +directories against `.gcloudignore` before running `--build` again. + +After a successful deployment, the script prints the A2A Agent Card JSON. +Paste that JSON into Gemini Enterprise to register the private service. The +required Gemini Enterprise invocation permission has already been added. diff --git a/gcloud/bootstrap.sh b/gcloud/bootstrap.sh deleted file mode 100755 index c40d114..0000000 --- a/gcloud/bootstrap.sh +++ /dev/null @@ -1,86 +0,0 @@ -#!/usr/bin/env bash - -# One-time Google Cloud setup for the Select AI A2A server. -# Creates the Artifact Registry repository, runtime service account, and -# Secret Manager secrets. It prompts for database values and never writes -# them to source files. - -set -euo pipefail - -project_id="${PROJECT_ID:-$(gcloud config get-value project 2>/dev/null)}" -region="${REGION:-us-central1}" -repository="${REPOSITORY:-select-ai}" -runtime_sa_name="${RUNTIME_SA_NAME:-oracle-a2a-runtime}" -db_user_secret="${DB_USER_SECRET:-select-ai-db-user}" -db_password_secret="${DB_PASSWORD_SECRET:-select-ai-db-password}" -db_dsn_secret="${DB_DSN_SECRET:-select-ai-db-connect-string}" - -if [[ -z "$project_id" || "$project_id" == "(unset)" ]]; then - echo "Set PROJECT_ID or configure one with: gcloud config set project PROJECT_ID" >&2 - exit 1 -fi - -runtime_sa="${runtime_sa_name}@${project_id}.iam.gserviceaccount.com" - -gcloud services enable \ - run.googleapis.com \ - cloudbuild.googleapis.com \ - artifactregistry.googleapis.com \ - secretmanager.googleapis.com \ - --project="$project_id" - -if ! gcloud artifacts repositories describe "$repository" \ - --location="$region" --project="$project_id" >/dev/null 2>&1; then - gcloud artifacts repositories create "$repository" \ - --repository-format=docker \ - --location="$region" \ - --project="$project_id" -fi - -if ! gcloud iam service-accounts describe "$runtime_sa" \ - --project="$project_id" >/dev/null 2>&1; then - gcloud iam service-accounts create "$runtime_sa_name" \ - --project="$project_id" \ - --display-name="Oracle Select AI A2A runtime" -fi - -read -r -p "ADB user: " db_user -read -r -s -p "ADB password: " db_password -echo -read -r -p "ADB connect descriptor: " db_dsn -trap 'unset db_user db_password db_dsn' EXIT - -add_secret() { - local name="$1" - local value="$2" - - if gcloud secrets describe "$name" --project="$project_id" >/dev/null 2>&1; then - printf %s "$value" | gcloud secrets versions add "$name" \ - --project="$project_id" --data-file=- >/dev/null - else - printf %s "$value" | gcloud secrets create "$name" \ - --project="$project_id" \ - --replication-policy=automatic \ - --data-file=- >/dev/null - fi - - gcloud secrets add-iam-policy-binding "$name" \ - --project="$project_id" \ - --member="serviceAccount:$runtime_sa" \ - --role="roles/secretmanager.secretAccessor" >/dev/null -} - -add_secret "$db_user_secret" "$db_user" -add_secret "$db_password_secret" "$db_password" -add_secret "$db_dsn_secret" "$db_dsn" - -cat <&2 - exit 1 -fi - -gcloud builds submit "$repo_root" \ - --project="$project_id" \ - --config="$repo_root/gcloud/cloudbuild.yaml" \ - --substitutions="_REGION=$region,_REPOSITORY=$repository,_IMAGE_TAG=$image_tag" - -echo "$region-docker.pkg.dev/$project_id/$repository/select-ai-a2a-server:$image_tag" diff --git a/gcloud/cloudbuild.yaml b/gcloud/cloudbuild.yaml index cde90f7..c21d6fc 100644 --- a/gcloud/cloudbuild.yaml +++ b/gcloud/cloudbuild.yaml @@ -5,13 +5,13 @@ steps: args: - build - --file - - gcloud/Dockerfile + - docker/Dockerfile - --tag - - ${_REGION}-docker.pkg.dev/$PROJECT_ID/${_REPOSITORY}/select-ai-a2a-server:${_IMAGE_TAG} + - ${_REGION}-docker.pkg.dev/$PROJECT_ID/${_REPOSITORY}/select-ai:${_IMAGE_TAG} - . images: - - ${_REGION}-docker.pkg.dev/$PROJECT_ID/${_REPOSITORY}/select-ai-a2a-server:${_IMAGE_TAG} + - ${_REGION}-docker.pkg.dev/$PROJECT_ID/${_REPOSITORY}/select-ai:${_IMAGE_TAG} substitutions: _REGION: us-central1 diff --git a/gcloud/deploy-cloud-run.sh b/gcloud/deploy-cloud-run.sh deleted file mode 100755 index 9901867..0000000 --- a/gcloud/deploy-cloud-run.sh +++ /dev/null @@ -1,93 +0,0 @@ -#!/usr/bin/env bash - -# Deploy an existing Select AI A2A server image to private Cloud Run. -# -# Prerequisites: -# * gcloud is authenticated and has deployment permissions. -# * Run bootstrap.sh to create the runtime service account and secrets. -# * Run build-image.sh to create IMAGE_URI when application code changes. -# -# Override any setting by exporting it before running this script. - -set -euo pipefail - -project_id="${PROJECT_ID:-$(gcloud config get-value project 2>/dev/null)}" -region="${REGION:-us-central1}" -service="${SERVICE:-oracle-a2a-agent}" -a2a_team="${A2A_TEAM:-ORACLE_AI_DATABASE_AGENT}" -runtime_sa="${RUNTIME_SA:-oracle-a2a-runtime@${project_id}.iam.gserviceaccount.com}" -image_uri="${IMAGE_URI:-}" -db_user_secret="${DB_USER_SECRET:-select-ai-db-user}" -db_password_secret="${DB_PASSWORD_SECRET:-select-ai-db-password}" -db_dsn_secret="${DB_DSN_SECRET:-select-ai-db-connect-string}" -memory="${MEMORY:-1Gi}" -timeout="${TIMEOUT:-900}" -max_instances="${MAX_INSTANCES:-1}" - -if [[ -z "$project_id" || "$project_id" == "(unset)" ]]; then - echo "Set PROJECT_ID or configure one with: gcloud config set project PROJECT_ID" >&2 - exit 1 -fi - -if [[ -z "$image_uri" ]]; then - echo "Set IMAGE_URI to an image created by gcloud/build-image.sh" >&2 - exit 1 -fi - -active_account="$(gcloud auth list --filter=status:ACTIVE --format='value(account)')" -if [[ -z "$active_account" ]]; then - echo "No active gcloud account. Run: gcloud auth login" >&2 - exit 1 -fi - -for secret in "$db_user_secret" "$db_password_secret" "$db_dsn_secret"; do - gcloud secrets describe "$secret" --project="$project_id" >/dev/null -done - -gcloud iam service-accounts describe "$runtime_sa" \ - --project="$project_id" >/dev/null - -# The entrypoint requires PUBLIC_URL. This first revision is immediately -# followed by an update using the actual URL returned by Cloud Run. -gcloud run deploy "$service" \ - --image="$image_uri" \ - --project="$project_id" \ - --region="$region" \ - --service-account="$runtime_sa" \ - --no-allow-unauthenticated \ - --port=8080 \ - --memory="$memory" \ - --timeout="$timeout" \ - --max-instances="$max_instances" \ - --set-env-vars="A2A_TEAM=$a2a_team,PUBLIC_URL=https://pending.invalid" \ - --update-secrets="SELECT_AI_USER=$db_user_secret:1,SELECT_AI_PASSWORD=$db_password_secret:1,SELECT_AI_DB_CONNECT_STRING=$db_dsn_secret:1" - -service_url="$(gcloud run services describe "$service" \ - --project="$project_id" \ - --region="$region" \ - --format='value(status.url)')" - -gcloud run services update "$service" \ - --project="$project_id" \ - --region="$region" \ - --update-env-vars="PUBLIC_URL=$service_url" - -echo "Fetching the authenticated A2A Agent Card..." -agent_card="$(curl --fail --silent --show-error \ - -H "Authorization: Bearer $(gcloud auth print-identity-token)" \ - "$service_url/.well-known/agent-card.json")" - -printf '%s\n' "$agent_card" | python3 -m json.tool - -cat <&2; usage >&2; exit 2 ;; + esac +done + +if [[ -z "$project_id" ]]; then + project_id="$(gcloud config get-value project 2>/dev/null || true)" +fi + +if [[ -z "$project_id" || "$project_id" == "(unset)" ]]; then + echo "Pass --project or configure one with: gcloud config set project PROJECT_ID" >&2 + exit 1 +fi + +runtime_sa="${runtime_sa:-${runtime_sa_name}@${project_id}.iam.gserviceaccount.com}" +db_user_secret="${db_user_secret:-${service}-db-user}" +db_password_secret="${db_password_secret:-${service}-db-password}" +db_dsn_secret="${db_dsn_secret:-${service}-db-connect-string}" +wallet_secret="${wallet_secret:-${service}-wallet}" +wallet_password_secret="${wallet_password_secret:-${service}-wallet-password}" + +if [[ "$build_image" == true && -n "$image_uri" ]]; then + echo "--build and --image-uri cannot be used together." >&2 + exit 2 +fi +if [[ "$build_image" == false && -n "$image_tag" ]]; then + echo "--image-tag requires --build." >&2 + exit 2 +fi +if ! [[ "$pool_max_size" =~ ^[1-9][0-9]*$ ]]; then + echo "--pool-max-size must be a positive integer." >&2 + exit 2 +fi + +if ! gcloud artifacts repositories describe "$repository" --location="$region" \ + --project="$project_id" >/dev/null 2>&1; then + gcloud artifacts repositories create "$repository" \ + --repository-format=docker --location="$region" --project="$project_id" +fi + +if ! gcloud iam service-accounts describe "$runtime_sa" --project="$project_id" >/dev/null 2>&1; then + if [[ "$runtime_sa_explicit" == true ]]; then + echo "Runtime service account does not exist: $runtime_sa" >&2 + exit 1 + fi + gcloud iam service-accounts create "$runtime_sa_name" --project="$project_id" \ + --display-name="Oracle Select AI A2A runtime" +fi + +# Deployments made with the former scripts used these shared secret names. +# Reuse them automatically so an existing service can be updated without +# re-entering credentials. New services receive service-specific names above. +service_exists=false +if gcloud run services describe "$service" --project="$project_id" --region="$region" >/dev/null 2>&1; then + service_exists=true +fi + +create_or_rotate_secrets=false +if [[ "$rotate_db_credentials" == true ]]; then + create_or_rotate_secrets=true +else + for secret in "$db_user_secret" "$db_password_secret" "$db_dsn_secret"; do + if ! gcloud secrets describe "$secret" --project="$project_id" >/dev/null 2>&1; then + create_or_rotate_secrets=true + break + fi + done +fi + +if [[ "$create_or_rotate_secrets" == true ]]; then + echo "Creating or rotating ADB credentials for Cloud Run service: $service" + read -r -p "ADB user: " db_user + read -r -s -p "ADB password: " db_password + echo + read -r -p "ADB connect descriptor: " db_dsn + trap 'unset db_user db_password db_dsn' EXIT + + add_secret() { + local name="$1" + local value="$2" + if gcloud secrets describe "$name" --project="$project_id" >/dev/null 2>&1; then + printf %s "$value" | gcloud secrets versions add "$name" --project="$project_id" --data-file=- >/dev/null + else + printf %s "$value" | gcloud secrets create "$name" --project="$project_id" --replication-policy=automatic --data-file=- >/dev/null + fi + gcloud secrets add-iam-policy-binding "$name" --project="$project_id" \ + --member="serviceAccount:$runtime_sa" --role="roles/secretmanager.secretAccessor" >/dev/null + } + + add_secret "$db_user_secret" "$db_user" + add_secret "$db_password_secret" "$db_password" + add_secret "$db_dsn_secret" "$db_dsn" +fi + +# An Oracle mTLS wallet is a ZIP archive containing several files, so it is +# mounted as a Secret Manager volume rather than exposed as an environment +# variable. Pass --wallet-archive to enable or replace this optional configuration. +wallet_enabled=false +if gcloud secrets describe "$wallet_secret" --project="$project_id" >/dev/null 2>&1 \ + && gcloud secrets describe "$wallet_password_secret" --project="$project_id" >/dev/null 2>&1; then + wallet_enabled=true +fi +if [[ -n "$wallet_archive" ]]; then + if [[ ! -f "$wallet_archive" ]]; then + echo "--wallet-archive must name an existing wallet ZIP file: $wallet_archive" >&2 + exit 1 + fi + read -r -s -p "ADB wallet password: " wallet_password + echo + trap 'unset db_user db_password db_dsn wallet_password' EXIT + + if gcloud secrets describe "$wallet_secret" --project="$project_id" >/dev/null 2>&1; then + gcloud secrets versions add "$wallet_secret" --project="$project_id" --data-file="$wallet_archive" >/dev/null + else + gcloud secrets create "$wallet_secret" --project="$project_id" --replication-policy=automatic --data-file="$wallet_archive" >/dev/null + fi + if gcloud secrets describe "$wallet_password_secret" --project="$project_id" >/dev/null 2>&1; then + printf %s "$wallet_password" | gcloud secrets versions add "$wallet_password_secret" --project="$project_id" --data-file=- >/dev/null + else + printf %s "$wallet_password" | gcloud secrets create "$wallet_password_secret" --project="$project_id" --replication-policy=automatic --data-file=- >/dev/null + fi + wallet_enabled=true +fi +if [[ "$wallet_enabled" == true ]]; then + for secret in "$wallet_secret" "$wallet_password_secret"; do + gcloud secrets add-iam-policy-binding "$secret" --project="$project_id" \ + --member="serviceAccount:$runtime_sa" --role="roles/secretmanager.secretAccessor" >/dev/null + done +fi + +if [[ "$build_image" == true ]]; then + image_tag="${image_tag:-$(git -C "$repo_root" rev-parse --short HEAD)-$(date -u +%Y%m%d%H%M%S)}" + image_uri="$region-docker.pkg.dev/$project_id/$repository/select-ai:$image_tag" + echo "Building $image_uri" + gcloud builds submit "$repo_root" --project="$project_id" \ + --config="$repo_root/gcloud/cloudbuild.yaml" \ + --substitutions="_REGION=$region,_REPOSITORY=$repository,_IMAGE_TAG=$image_tag" +elif [[ -z "$image_uri" && "$service_exists" == true ]]; then + image_uri="$(gcloud run services describe "$service" --project="$project_id" --region="$region" \ + --format='value(spec.template.spec.containers[0].image)')" +elif [[ -z "$image_uri" ]]; then + echo "First deployment requires --build or --image-uri." >&2 + exit 2 +fi + +# Cloud Run needs a URL before the server can construct its Agent Card. Deploy +# once with a placeholder, then update PUBLIC_URL with the assigned URL. +secret_mappings=( + "SELECT_AI_USER=$db_user_secret:latest" + "SELECT_AI_PASSWORD=$db_password_secret:latest" + "SELECT_AI_DB_CONNECT_STRING=$db_dsn_secret:latest" +) +if [[ "$wallet_enabled" == true ]]; then + secret_mappings+=( + "/var/run/secrets/select-ai-wallet/wallet.zip=$wallet_secret:latest" + "SELECT_AI_WALLET_PASSWORD=$wallet_password_secret:latest" + ) +fi +secret_mappings_csv="$(IFS=,; echo "${secret_mappings[*]}")" + +gcloud run deploy "$service" --image="$image_uri" --project="$project_id" --region="$region" \ + --service-account="$runtime_sa" --no-allow-unauthenticated --port=8080 \ + --command="/app/docker/a2a-entrypoint.sh" \ + --memory="$memory" --timeout="$timeout" --max-instances="$max_instances" \ + --set-env-vars="SELECT_AI_A2A_TEAM=$a2a_team,PUBLIC_URL=https://pending.invalid,SELECT_AI_POOL_MAX_SIZE=$pool_max_size" \ + --update-secrets="$secret_mappings_csv" + +service_url="$(gcloud run services describe "$service" --project="$project_id" --region="$region" --format='value(status.url)')" +gcloud run services update "$service" --project="$project_id" --region="$region" --update-env-vars="PUBLIC_URL=$service_url" + +project_number="$(gcloud projects describe "$project_id" --format='value(projectNumber)')" +gemini_sa="service-$project_number@gcp-sa-discoveryengine.iam.gserviceaccount.com" +gcloud run services add-iam-policy-binding "$service" --project="$project_id" --region="$region" \ + --member="serviceAccount:$gemini_sa" --role="roles/run.invoker" >/dev/null + +active_account="$(gcloud auth list --filter=status:ACTIVE --format='value(account)')" +if [[ -z "$active_account" ]]; then + echo "No active gcloud account. Run: gcloud auth login" >&2 + exit 1 +fi +if gcloud iam service-accounts describe "$active_account" --project="$project_id" >/dev/null 2>&1; then + deployer_member="serviceAccount:$active_account" +else + deployer_member="user:$active_account" +fi +gcloud run services add-iam-policy-binding "$service" --project="$project_id" --region="$region" \ + --member="$deployer_member" --role="roles/run.invoker" >/dev/null + +echo "Cloud Run URL: $service_url" +echo "Fetching the authenticated A2A Agent Card..." +agent_card_file="$(mktemp)" +trap 'rm -f "$agent_card_file"' EXIT +if ! curl --fail --silent --show-error \ + --retry 12 --retry-all-errors --retry-delay 5 --retry-max-time 120 \ + --connect-timeout 10 --max-time 30 \ + -H "Authorization: Bearer $(gcloud auth print-identity-token)" \ + --output "$agent_card_file" \ + "$service_url/.well-known/agent-card.json"; then + echo "Unable to fetch the A2A Agent Card after waiting for Cloud Run to become available." >&2 + exit 1 +fi +python3 -m json.tool < "$agent_card_file" + +cat <=1.0.3", "uvicorn>=0.30", ] +a2a = [ + "select_ai[cli]", +] test = [ "anyio", "pytest", diff --git a/samples/profile_create.py b/samples/profile_create.py index 06aaa4b..da69a93 100644 --- a/samples/profile_create.py +++ b/samples/profile_create.py @@ -22,7 +22,7 @@ select_ai.connect(user=user, password=password, dsn=dsn) provider = select_ai.OCIGenAIProvider( - region="us-chicago-1", oci_apiformat="GENERIC" + region="us-chicago-1", oci_apiformat="GENERIC", model="openai.gpt-4.1" ) profile_attributes = select_ai.ProfileAttributes( credential_name="my_oci_ai_profile_key", diff --git a/src/select_ai/a2a_server.py b/src/select_ai/a2a_server.py deleted file mode 100644 index cd463ae..0000000 --- a/src/select_ai/a2a_server.py +++ /dev/null @@ -1,218 +0,0 @@ -# ----------------------------------------------------------------------------- -# 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. -# ----------------------------------------------------------------------------- - -"""A2A HTTP server support for Oracle Database AI agent teams.""" - -from asyncio import Lock -from contextlib import asynccontextmanager -from typing import Optional - -import select_ai -from select_ai.agent import AsyncTeam -from select_ai.version import __version__ - - -def _a2a_imports(): - """Load optional A2A dependencies only when the server is requested.""" - try: - from a2a.helpers import new_task_from_user_message, new_text_part - from a2a.server.agent_execution import AgentExecutor - from a2a.server.request_handlers import DefaultRequestHandler - from a2a.server.routes import ( - create_agent_card_routes, - create_jsonrpc_routes, - ) - from a2a.server.tasks import InMemoryTaskStore, TaskUpdater - from a2a.types import ( - AgentCapabilities, - AgentCard, - AgentInterface, - AgentSkill, - ) - from starlette.applications import Starlette - except ImportError as exc: - raise RuntimeError( - "A2A server support requires the optional 'a2a' extra. " - "Install it with: pip install 'select_ai[a2a]'" - ) from exc - - return { - "AgentCapabilities": AgentCapabilities, - "AgentCard": AgentCard, - "AgentExecutor": AgentExecutor, - "AgentInterface": AgentInterface, - "AgentSkill": AgentSkill, - "DefaultRequestHandler": DefaultRequestHandler, - "InMemoryTaskStore": InMemoryTaskStore, - "Starlette": Starlette, - "TaskUpdater": TaskUpdater, - "create_agent_card_routes": create_agent_card_routes, - "create_jsonrpc_routes": create_jsonrpc_routes, - "new_task_from_user_message": new_task_from_user_message, - "new_text_part": new_text_part, - } - - -def ensure_a2a_dependencies() -> None: - """Raise a helpful error when the optional A2A dependencies are absent.""" - _a2a_imports() - - -def create_app( # noqa: PLR0913 - team_name: str, - public_url: str, - user: str, - password: str, - dsn: str, - wallet_location: Optional[str] = None, - wallet_password: Optional[str] = None, - description: Optional[str] = None, - pool_max_size: int = 10, -): - """Build an A2A JSON-RPC application for one database AI agent team.""" - if pool_max_size < 1: - raise ValueError("pool_max_size must be at least 1") - - imports = _a2a_imports() - agent_card = _build_agent_card(imports, team_name, public_url, description) - executor = _build_executor(imports, team_name) - handler = imports["DefaultRequestHandler"]( - agent_executor=executor, - task_store=imports["InMemoryTaskStore"](), - agent_card=agent_card, - ) - - @asynccontextmanager - async def lifespan(app): - connect_args = { - "user": user, - "password": password, - "dsn": dsn, - "min_size": 1, - "max_size": pool_max_size, - } - if wallet_location: - connect_args["wallet_location"] = wallet_location - connect_args["config_dir"] = wallet_location - if wallet_password: - connect_args["wallet_password"] = wallet_password - select_ai.create_pool_async(**connect_args) - try: - yield - finally: - await select_ai.async_disconnect() - - routes = imports["create_agent_card_routes"](agent_card) - routes.extend( - imports["create_jsonrpc_routes"]( - handler, - rpc_url="/a2a/jsonrpc/", - enable_v0_3_compat=True, - ) - ) - return imports["Starlette"](routes=routes, lifespan=lifespan) - - -def _build_agent_card(imports, team_name, public_url, description): - description = description or f"Oracle Database AI agent team {team_name}." - endpoint = f"{public_url.rstrip('/')}/a2a/jsonrpc/" - return imports["AgentCard"]( - name=team_name, - description=description, - version=__version__, - default_input_modes=["text/plain"], - default_output_modes=["text/plain"], - capabilities=imports["AgentCapabilities"](streaming=True), - supported_interfaces=[ - imports["AgentInterface"]( - protocol_binding="JSONRPC", - protocol_version="1.0", - url=endpoint, - ), - imports["AgentInterface"]( - protocol_binding="JSONRPC", - protocol_version="0.3", - url=endpoint, - ), - ], - skills=[ - imports["AgentSkill"]( - id=team_name.lower(), - name=team_name, - description=description, - tags=["oracle", "database", "select-ai"], - examples=[], - input_modes=["text/plain"], - output_modes=["text/plain"], - ) - ], - ) - - -def _build_executor(imports, team_name): - agent_executor = imports["AgentExecutor"] - task_updater = imports["TaskUpdater"] - new_task_from_user_message = imports["new_task_from_user_message"] - new_text_part = imports["new_text_part"] - conversation_ids = {} - conversation_lock = Lock() - - async def get_database_conversation_id(task): - """Create one Oracle conversation for each A2A context.""" - a2a_context_id = task.context_id or task.id - async with conversation_lock: - conversation_id = conversation_ids.get(a2a_context_id) - if conversation_id: - return conversation_id - - conversation = select_ai.AsyncConversation( - attributes=select_ai.ConversationAttributes( - title=f"A2A {team_name}", - description=f"A2A context {a2a_context_id}", - ) - ) - conversation_id = await conversation.create() - conversation_ids[a2a_context_id] = conversation_id - return conversation_id - - class DatabaseTeamExecutor(agent_executor): - async def execute(self, context, event_queue): - if context.current_task: - task = context.current_task - else: - task = new_task_from_user_message(context.message) - await event_queue.enqueue_event(task) - - updater = task_updater( - event_queue=event_queue, - task_id=task.id, - context_id=task.context_id, - ) - await updater.start_work() - conversation_id = await get_database_conversation_id(task) - result = await AsyncTeam(team_name=team_name).run( - prompt=context.get_user_input(), - params={"conversation_id": conversation_id}, - ) - await updater.add_artifact( - parts=[new_text_part(result or "")], - name="database-agent-result", - last_chunk=True, - ) - await updater.complete() - - async def cancel(self, context, event_queue): - if context.current_task is None: - return - updater = task_updater( - event_queue=event_queue, - task_id=context.current_task.id, - context_id=context.current_task.context_id, - ) - await updater.cancel() - - return DatabaseTeamExecutor() diff --git a/src/select_ai/agent/a2a/__init__.py b/src/select_ai/agent/a2a/__init__.py new file mode 100644 index 0000000..f4786b6 --- /dev/null +++ b/src/select_ai/agent/a2a/__init__.py @@ -0,0 +1,8 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2026, Oracle and/or its affiliates. +# +# Licensed under the Universal Permissive License v 1.0 as shown at +# https://oss.oracle.com/licenses/upl. +# ----------------------------------------------------------------------------- + +"""A2A support for Select AI Agent Teams.""" diff --git a/src/select_ai/agent/a2a/context_store.py b/src/select_ai/agent/a2a/context_store.py new file mode 100644 index 0000000..6dacc37 --- /dev/null +++ b/src/select_ai/agent/a2a/context_store.py @@ -0,0 +1,138 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2026, Oracle and/or its affiliates. +# +# Licensed under the Universal Permissive License v 1.0 as shown at +# https://oss.oracle.com/licenses/upl. +# ----------------------------------------------------------------------------- + +"""Oracle Database storage for A2A-to-Oracle conversation mappings.""" + +from asyncio import Lock +from typing import Optional + +import oracledb +from a2a.server.context import ServerCallContext +from a2a.server.owner_resolver import OwnerResolver, resolve_user_scope + +import select_ai +from select_ai.db import async_get_connection + +_CREATE_TABLE = """ + BEGIN + EXECUTE IMMEDIATE ' + CREATE TABLE SELECT_AI_A2A_CONTEXTS ( + owner VARCHAR2(512) NOT NULL, + context_id VARCHAR2(255) NOT NULL, + conversation_id VARCHAR2(255) NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL, + CONSTRAINT select_ai_a2a_contexts_pk PRIMARY KEY (owner, context_id) + )'; + EXECUTE IMMEDIATE ' + COMMENT ON TABLE SELECT_AI_A2A_CONTEXTS + IS ''Managed by select_ai.a2a.context_store'''; + EXCEPTION + WHEN OTHERS THEN + IF SQLCODE != -955 THEN + RAISE; + END IF; + END; +""" + + +class OracleContextStore: + """Persist one Oracle conversation for each A2A context.""" + + def __init__( + self, + owner_resolver: OwnerResolver = resolve_user_scope, + ) -> None: + self.owner_resolver = owner_resolver + self.initialized = False + self.initialize_lock = Lock() + + async def initialize(self) -> None: + """Create the context mapping table if it does not already exist.""" + if self.initialized: + return + async with self.initialize_lock: + if self.initialized: + return + await self._execute(_CREATE_TABLE) + self.initialized = True + + async def get_or_create( + self, + context_id: str, + context: ServerCallContext, + team_name: str, + ) -> str: + """Return the Oracle conversation for an A2A context, creating it once.""" + await self.initialize() + owner = self._owner(context) + conversation_id = await self._get(owner, context_id) + if conversation_id: + return conversation_id + + conversation = select_ai.AsyncConversation( + attributes=select_ai.ConversationAttributes( + title=f"A2A {team_name}", + description=f"A2A context {context_id}", + ) + ) + conversation_id = await conversation.create() + try: + await self._execute( + """ + INSERT INTO SELECT_AI_A2A_CONTEXTS ( + owner, context_id, conversation_id, created_at + ) VALUES ( + :owner, :context_id, :conversation_id, SYSTIMESTAMP + ) + """, + owner=owner, + context_id=context_id, + conversation_id=conversation_id, + ) + except oracledb.DatabaseError as error: + if error.args[0].code != 1: + raise + existing_conversation_id = await self._get(owner, context_id) + if existing_conversation_id: + return existing_conversation_id + raise + return conversation_id + + async def _get(self, owner: str, context_id: str) -> Optional[str]: + row = await self._fetchone( + """ + SELECT conversation_id + FROM SELECT_AI_A2A_CONTEXTS + WHERE owner = :owner AND context_id = :context_id + """, + owner=owner, + context_id=context_id, + ) + return row[0] if row else None + + def _owner(self, context: ServerCallContext) -> str: + return self.owner_resolver(context) or "anonymous" + + @staticmethod + async def _execute(statement: str, **parameters) -> None: + async with async_get_connection() as connection: + cursor = connection.cursor() + try: + await cursor.execute(statement, **parameters) + await connection.commit() + finally: + cursor.close() + + @staticmethod + async def _fetchone(statement: str, **parameters): + async with async_get_connection() as connection: + cursor = connection.cursor() + try: + await cursor.execute(statement, **parameters) + return await cursor.fetchone() + finally: + cursor.close() diff --git a/src/select_ai/agent/a2a/server.py b/src/select_ai/agent/a2a/server.py new file mode 100644 index 0000000..686200c --- /dev/null +++ b/src/select_ai/agent/a2a/server.py @@ -0,0 +1,190 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2026, Oracle and/or its affiliates. +# +# Licensed under the Universal Permissive License v 1.0 as shown at +# https://oss.oracle.com/licenses/upl. +# ----------------------------------------------------------------------------- + +"""A2A HTTP server for Oracle Database AI Agent Teams.""" + +from contextlib import asynccontextmanager +from typing import Optional + +from a2a.compat.v0_3.conversions import to_compat_agent_card +from a2a.helpers import new_task_from_user_message, new_text_part +from a2a.server.agent_execution import AgentExecutor +from a2a.server.request_handlers import DefaultRequestHandler +from a2a.server.routes import create_jsonrpc_routes +from a2a.server.tasks import TaskUpdater +from a2a.types import AgentCapabilities, AgentCard, AgentInterface, AgentSkill +from starlette.applications import Starlette +from starlette.responses import JSONResponse +from starlette.routing import Route + +import select_ai +from select_ai.agent import AsyncTeam +from select_ai.agent.a2a.context_store import OracleContextStore +from select_ai.agent.a2a.task_store import OracleTaskStore +from select_ai.version import __version__ + + +class DatabaseTeamExecutor(AgentExecutor): + """Execute A2A requests with one Oracle conversation per A2A context.""" + + def __init__(self, team_name: str, context_store: OracleContextStore): + self.team_name = team_name + self.context_store = context_store + + async def execute(self, context, event_queue): + if context.current_task: + task = context.current_task + else: + task = new_task_from_user_message(context.message) + await event_queue.enqueue_event(task) + + updater = TaskUpdater( + event_queue=event_queue, + task_id=task.id, + context_id=task.context_id, + ) + await updater.start_work() + conversation_id = await self.context_store.get_or_create( + context_id=task.context_id or task.id, + context=context.call_context, + team_name=self.team_name, + ) + result = await AsyncTeam(team_name=self.team_name).run( + prompt=context.get_user_input(), + params={"conversation_id": conversation_id}, + ) + await updater.add_artifact( + parts=[new_text_part(result or "")], + name="database-agent-result", + last_chunk=True, + ) + await updater.complete() + + async def cancel(self, context, event_queue): + if context.current_task is None: + return + updater = TaskUpdater( + event_queue=event_queue, + task_id=context.current_task.id, + context_id=context.current_task.context_id, + ) + await updater.cancel() + + +def create_app( # noqa: PLR0913 + team_name: str, + public_url: str, + user: str, + password: str, + dsn: str, + wallet_location: Optional[str] = None, + wallet_password: Optional[str] = None, + description: Optional[str] = None, + pool_max_size: int = 10, +) -> Starlette: + """Build an A2A JSON-RPC application for one database AI Agent Team.""" + if pool_max_size < 1: + raise ValueError("pool_max_size must be at least 1") + + agent_card = _build_agent_card(team_name, public_url, description) + compat_agent_card = _build_v03_agent_card(agent_card) + task_store = OracleTaskStore() + context_store = OracleContextStore() + handler = DefaultRequestHandler( + agent_executor=DatabaseTeamExecutor(team_name, context_store), + task_store=task_store, + agent_card=agent_card, + ) + + @asynccontextmanager + async def lifespan(app): + connect_args = { + "user": user, + "password": password, + "dsn": dsn, + "min_size": 1, + "max_size": pool_max_size, + } + if wallet_location: + connect_args["wallet_location"] = wallet_location + connect_args["config_dir"] = wallet_location + if wallet_password: + connect_args["wallet_password"] = wallet_password + select_ai.create_pool_async(**connect_args) + try: + await task_store.initialize() + await context_store.initialize() + yield + finally: + await select_ai.async_disconnect() + + async def get_agent_card(request): + """Serve the documented A2A v0.3 card required by Gemini Enterprise.""" + return JSONResponse(compat_agent_card) + + routes = [ + Route( + "/.well-known/agent-card.json", + get_agent_card, + methods=["GET"], + ) + ] + routes.extend( + create_jsonrpc_routes( + handler, + rpc_url="/a2a/jsonrpc/", + enable_v0_3_compat=True, + ) + ) + return Starlette(routes=routes, lifespan=lifespan) + + +def _build_agent_card( + team_name: str, + public_url: str, + description: Optional[str], +) -> AgentCard: + description = description or f"Oracle Database AI agent team {team_name}." + endpoint = f"{public_url.rstrip('/')}/a2a/jsonrpc/" + return AgentCard( + name=team_name, + description=description, + version=__version__, + default_input_modes=["text/plain"], + default_output_modes=["text/plain"], + capabilities=AgentCapabilities(streaming=True), + supported_interfaces=[ + AgentInterface( + protocol_binding="JSONRPC", + protocol_version="1.0", + url=endpoint, + ), + AgentInterface( + protocol_binding="JSONRPC", + protocol_version="0.3", + url=endpoint, + ), + ], + skills=[ + AgentSkill( + id=team_name.lower(), + name=team_name, + description=description, + tags=["oracle", "database", "select-ai"], + examples=[], + input_modes=["text/plain"], + output_modes=["text/plain"], + ) + ], + ) + + +def _build_v03_agent_card(agent_card: AgentCard) -> dict: + """Return the standalone A2A v0.3 discovery representation.""" + return to_compat_agent_card(agent_card).model_dump( + by_alias=True, exclude_none=True + ) diff --git a/src/select_ai/agent/a2a/task_store.py b/src/select_ai/agent/a2a/task_store.py new file mode 100644 index 0000000..3962e8e --- /dev/null +++ b/src/select_ai/agent/a2a/task_store.py @@ -0,0 +1,243 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2026, Oracle and/or its affiliates. +# +# Licensed under the Universal Permissive License v 1.0 as shown at +# https://oss.oracle.com/licenses/upl. +# ----------------------------------------------------------------------------- + +"""Oracle Database implementation of the A2A TaskStore interface.""" + +from __future__ import annotations + +from asyncio import Lock +from typing import Optional + +from a2a.server.context import ServerCallContext +from a2a.server.owner_resolver import OwnerResolver, resolve_user_scope +from a2a.server.tasks.task_store import TaskStore +from a2a.types import a2a_pb2 +from a2a.types.a2a_pb2 import Task +from a2a.utils.constants import DEFAULT_LIST_TASKS_PAGE_SIZE +from a2a.utils.errors import InvalidParamsError +from a2a.utils.task import decode_page_token, encode_page_token +from google.protobuf.json_format import MessageToJson, Parse, ParseDict + +from select_ai.db import async_get_connection + +_CREATE_TABLE = """ + BEGIN + EXECUTE IMMEDIATE ' + CREATE TABLE SELECT_AI_A2A_TASKS ( + owner VARCHAR2(512) NOT NULL, + task_id VARCHAR2(255) NOT NULL, + context_id VARCHAR2(255), + task_json CLOB NOT NULL CHECK (task_json IS JSON), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL, + CONSTRAINT select_ai_a2a_tasks_pk PRIMARY KEY (owner, task_id) + )'; + EXECUTE IMMEDIATE ' + COMMENT ON TABLE SELECT_AI_A2A_TASKS + IS ''Managed by select_ai.a2a.task_store'''; + EXCEPTION + WHEN OTHERS THEN + IF SQLCODE != -955 THEN + RAISE; + END IF; + END; +""" + + +class OracleTaskStore(TaskStore): + """Persist A2A tasks in Oracle Database using Select AI's connection pool.""" + + def __init__( + self, + owner_resolver: OwnerResolver = resolve_user_scope, + ) -> None: + self.owner_resolver = owner_resolver + self.initialized = False + self.initialize_lock = Lock() + + async def initialize(self) -> None: + """Create the task table if it does not already exist.""" + if self.initialized: + return + async with self.initialize_lock: + if self.initialized: + return + await self._execute(_CREATE_TABLE) + self.initialized = True + + async def save(self, task: Task, context: ServerCallContext) -> None: + """Insert or update a task for its resolved owner.""" + await self.initialize() + await self._execute( + """ + MERGE INTO SELECT_AI_A2A_TASKS target + USING ( + SELECT :owner AS owner, :task_id AS task_id FROM dual + ) source + ON (target.owner = source.owner AND target.task_id = source.task_id) + WHEN MATCHED THEN UPDATE SET + context_id = :context_id, + task_json = :task_json, + updated_at = SYSTIMESTAMP + WHEN NOT MATCHED THEN INSERT ( + owner, task_id, context_id, task_json, updated_at + ) VALUES ( + :owner, :task_id, :context_id, :task_json, SYSTIMESTAMP + ) + """, + owner=self._owner(context), + task_id=task.id, + context_id=task.context_id, + task_json=MessageToJson(task), + ) + + async def get( + self, + task_id: str, + context: ServerCallContext, + ) -> Optional[Task]: + """Return a task by ID for its resolved owner.""" + await self.initialize() + row = await self._fetchone( + """ + SELECT task_json + FROM SELECT_AI_A2A_TASKS + WHERE owner = :owner AND task_id = :task_id + """, + owner=self._owner(context), + task_id=task_id, + ) + if row is None: + return None + return await self._task_from_json(row[0]) + + async def list( + self, + params: a2a_pb2.ListTasksRequest, + context: ServerCallContext, + ) -> a2a_pb2.ListTasksResponse: + """Return filtered, paginated tasks for the resolved owner.""" + await self.initialize() + rows = await self._fetchall( + """ + SELECT task_json + FROM SELECT_AI_A2A_TASKS + WHERE owner = :owner + ORDER BY updated_at DESC, task_id DESC + """, + owner=self._owner(context), + ) + tasks = [await self._task_from_json(row[0]) for row in rows] + tasks = self._filter_tasks(tasks, params) + total_size = len(tasks) + start_index = self._page_start_index(tasks, params.page_token) + page_size = params.page_size or DEFAULT_LIST_TASKS_PAGE_SIZE + end_index = start_index + page_size + page = tasks[start_index:end_index] + next_page_token = ( + encode_page_token(tasks[end_index].id) + if end_index < total_size + else None + ) + return a2a_pb2.ListTasksResponse( + tasks=page, + total_size=total_size, + page_size=page_size, + next_page_token=next_page_token, + ) + + async def delete(self, task_id: str, context: ServerCallContext) -> None: + """Delete a task by ID for its resolved owner.""" + await self.initialize() + await self._execute( + """ + DELETE FROM SELECT_AI_A2A_TASKS + WHERE owner = :owner AND task_id = :task_id + """, + owner=self._owner(context), + task_id=task_id, + ) + + @staticmethod + def _filter_tasks( + tasks: list[Task], + params: a2a_pb2.ListTasksRequest, + ) -> list[Task]: + if params.context_id: + tasks = [ + task for task in tasks if task.context_id == params.context_id + ] + if params.status: + tasks = [ + task + for task in tasks + if task.HasField("status") + and task.status.state == params.status + ] + if params.HasField("status_timestamp_after"): + timestamp_after = params.status_timestamp_after.ToJsonString() + tasks = [ + task + for task in tasks + if task.HasField("status") + and task.status.HasField("timestamp") + and task.status.timestamp.ToJsonString() >= timestamp_after + ] + return tasks + + def _owner(self, context: ServerCallContext) -> str: + return self.owner_resolver(context) or "anonymous" + + @staticmethod + def _page_start_index(tasks: list[Task], page_token: str) -> int: + if not page_token: + return 0 + task_id = decode_page_token(page_token) + for index, task in enumerate(tasks): + if task.id == task_id: + return index + raise InvalidParamsError(f"Invalid page token: {page_token}") + + @staticmethod + async def _task_from_json(value) -> Task: + if hasattr(value, "read"): + value = await value.read() + task = Task() + if isinstance(value, dict): + ParseDict(value, task) + else: + Parse(value, task) + return task + + @staticmethod + async def _execute(statement: str, **parameters) -> None: + async with async_get_connection() as connection: + cursor = connection.cursor() + try: + await cursor.execute(statement, **parameters) + await connection.commit() + finally: + cursor.close() + + @staticmethod + async def _fetchone(statement: str, **parameters): + async with async_get_connection() as connection: + cursor = connection.cursor() + try: + await cursor.execute(statement, **parameters) + return await cursor.fetchone() + finally: + cursor.close() + + @staticmethod + async def _fetchall(statement: str, **parameters): + async with async_get_connection() as connection: + cursor = connection.cursor() + try: + await cursor.execute(statement, **parameters) + return await cursor.fetchall() + finally: + cursor.close() diff --git a/src/select_ai/cli/a2a.py b/src/select_ai/cli/a2a.py index a681446..9e3bb70 100644 --- a/src/select_ai/cli/a2a.py +++ b/src/select_ai/cli/a2a.py @@ -13,6 +13,14 @@ from select_ai.cli.common import connection_options from select_ai.version import __version__ +try: + import uvicorn + + from select_ai.agent.a2a.server import create_app +except ImportError: + create_app = None + uvicorn = None + @click.group() def a2a(): @@ -50,21 +58,11 @@ def serve( wallet_password, ): """Start an A2A HTTP server for one database AI agent team.""" - try: - from select_ai.a2a_server import ( - create_app, - ensure_a2a_dependencies, - ) - - ensure_a2a_dependencies() - import uvicorn - except RuntimeError as exc: - raise click.ClickException(str(exc)) from exc - except ImportError as exc: + if create_app is None or uvicorn is None: raise click.ClickException( - "A2A server support requires the optional 'a2a' extra. " - "Install it with: pip install 'select_ai[a2a]'" - ) from exc + "A2A server support requires the optional 'cli' extra. " + "Install it with: pip install 'select_ai[cli]'" + ) if password is None: password = getpass.getpass("Database password: ") diff --git a/tests/a2a/test_agent_card.py b/tests/a2a/test_agent_card.py new file mode 100644 index 0000000..8b543c4 --- /dev/null +++ b/tests/a2a/test_agent_card.py @@ -0,0 +1,55 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2026, Oracle and/or its affiliates. +# +# Licensed under the Universal Permissive License v 1.0 as shown at +# https://oss.oracle.com/licenses/upl. +# ----------------------------------------------------------------------------- + +import asyncio +import json + +import pytest + +pytest.importorskip("a2a") + +from select_ai.agent.a2a.server import ( + _build_agent_card, + _build_v03_agent_card, + create_app, +) + + +def test_v03_discovery_card_is_gemini_enterprise_compatible(): + card = _build_agent_card( + team_name="ORACLE_AI_DATABASE_AGENT", + public_url="https://agent.example.com", + description=None, + ) + + payload = _build_v03_agent_card(card) + + assert payload["protocolVersion"] == "0.3" + assert payload["url"] == "https://agent.example.com/a2a/jsonrpc/" + assert "supportedInterfaces" not in payload + + +def test_discovery_route_serves_only_the_v03_agent_card(): + app = create_app( + team_name="ORACLE_AI_DATABASE_AGENT", + public_url="https://agent.example.com", + user="user", + password="password", + dsn="database", + ) + route = next( + route + for route in app.routes + if route.path == "/.well-known/agent-card.json" + ) + + response = asyncio.run(route.endpoint(None)) + payload = json.loads(response.body) + + assert payload["protocolVersion"] == "0.3" + assert payload["url"] == "https://agent.example.com/a2a/jsonrpc/" + assert "supportedInterfaces" not in payload From 426cf5a23a9c105bdca07d54858bc41c80f2dfbc Mon Sep 17 00:00:00 2001 From: Abhishek Singh Date: Wed, 26 Aug 2026 13:49:40 -0700 Subject: [PATCH 04/14] Upgraded version to 1.5.0 --- src/select_ai/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/select_ai/version.py b/src/select_ai/version.py index 2691fab..e44a1fe 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.1" +__version__ = "1.5.0" From af6b9d9521ae0c23285e4e1d2ea368ab19e45c57 Mon Sep 17 00:00:00 2001 From: Abhishek Singh Date: Wed, 26 Aug 2026 15:22:00 -0700 Subject: [PATCH 05/14] Install dependencies during test --- .github/workflows/test.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 4b35757..2dd3c6b 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -50,7 +50,7 @@ jobs: run: | python -m pip install --upgrade pip setuptools pip install pytest anyio - pip install -e . + pip install -e ".[cli]" - name: Wait for ADB Free Container run: | From 2f439ea8294690a667fd777a2bf1f87da349a152 Mon Sep 17 00:00:00 2001 From: Abhishek Singh Date: Wed, 26 Aug 2026 21:53:56 -0700 Subject: [PATCH 06/14] Added samples for A2A client --- .dockerignore | 1 + .gcloudignore | 1 + .gitignore | 1 + pyproject.toml | 2 +- samples/README.md | 29 ++++++++++++++++ samples/a2a/blocking_task.py | 48 +++++++++++++++++++++++++++ samples/a2a/task_poll.py | 64 ++++++++++++++++++++++++++++++++++++ 7 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 samples/a2a/blocking_task.py create mode 100644 samples/a2a/task_poll.py diff --git a/.dockerignore b/.dockerignore index a4f01ef..439d722 100644 --- a/.dockerignore +++ b/.dockerignore @@ -21,3 +21,4 @@ wallets *.sso *.p12 *.zip +build/ diff --git a/.gcloudignore b/.gcloudignore index f2fd78d..f1312b1 100644 --- a/.gcloudignore +++ b/.gcloudignore @@ -28,3 +28,4 @@ samples/ *.png *.jpg *.jpeg +build/ diff --git a/.gitignore b/.gitignore index 1bf21f0..3c02fc7 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ sample_connect.py async_pipeline_test.py parquet.py local_sample +build/ diff --git a/pyproject.toml b/pyproject.toml index 0607211..1c39728 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,7 @@ dependencies = [ cli = [ "click", "a2a-sdk[http-server]>=1.0.3", - "uvicorn>=0.30", + "uvicorn[standard]>=0.30", ] a2a = [ "select_ai[cli]", diff --git a/samples/README.md b/samples/README.md index 73b1a6b..858e66f 100644 --- a/samples/README.md +++ b/samples/README.md @@ -23,6 +23,35 @@ Some of the new samples use this optional environment variable: - `SELECT_AI_PROFILE_NAME` — existing profile for the conversation and supervised-team samples. +## A2A non-blocking task polling + +Start a Select AI A2A server before running these samples: + +```bash +select-ai a2a serve --team ORACLE_AI_DATABASE_AGENT --port 8000 +``` + +After starting a local A2A server, run the fixed sales-analysis prompt as a +non-blocking task and poll it until completion: + +```bash +python samples/a2a/task_poll.py +``` + +The sample sends the A2A v0.3 `message/send` request with +`configuration.blocking: false`, prints the returned task ID, and polls +`tasks/get`. Edit `ENDPOINT` or `PROMPT` at the top of the script if needed. + +To compare it with the default blocking behavior, run: + +```bash +python samples/a2a/blocking_task.py +``` + +This sample intentionally omits `configuration.blocking`. The server waits +for the database work to finish and returns the completed Task in the initial +`message/send` response; no polling is needed. + `SELECT_AI_DB_CONNECT_STRING` can be in any one of the following formats diff --git a/samples/a2a/blocking_task.py b/samples/a2a/blocking_task.py new file mode 100644 index 0000000..32112c2 --- /dev/null +++ b/samples/a2a/blocking_task.py @@ -0,0 +1,48 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2026, Oracle and/or its affiliates. +# +# Licensed under the Universal Permissive License v 1.0 as shown at +# https://oss.oracle.com/licenses/upl. +# ----------------------------------------------------------------------------- + +"""Send a blocking A2A request and receive its completed task.""" + +import json +import uuid +from urllib.request import Request, urlopen + +ENDPOINT = "http://127.0.0.1:8000/a2a/jsonrpc/" +PROMPT = "What were last month's sales by product category?" + + +# There is intentionally no "configuration": {"blocking": false} here. +# Omitting it is blocking by default, so this call waits for the database work +# to finish before the server returns the Task. +request = Request( + ENDPOINT, + data=json.dumps( + { + "jsonrpc": "2.0", + "id": str(uuid.uuid4()), + "method": "message/send", + "params": { + "message": { + "messageId": str(uuid.uuid4()), + "role": "user", + "parts": [{"kind": "text", "text": PROMPT}], + } + }, + } + ).encode(), + headers={"Content-Type": "application/json"}, + method="POST", +) + +with urlopen(request) as response: # noqa: S310 + body = json.load(response) +if "error" in body: + raise RuntimeError(body["error"]) + +task = body["result"] +print(f"Task {task['id']}: {task['status']['state']}") +print(json.dumps(task, indent=2)) diff --git a/samples/a2a/task_poll.py b/samples/a2a/task_poll.py new file mode 100644 index 0000000..8ee4cfa --- /dev/null +++ b/samples/a2a/task_poll.py @@ -0,0 +1,64 @@ +# ----------------------------------------------------------------------------- +# Copyright (c) 2026, Oracle and/or its affiliates. +# +# Licensed under the Universal Permissive License v 1.0 as shown at +# https://oss.oracle.com/licenses/upl. +# ----------------------------------------------------------------------------- + +"""Start a non-blocking A2A task, then poll until it completes.""" + +import json +import time +import uuid +from urllib.request import Request, urlopen + +ENDPOINT = "http://127.0.0.1:8000/a2a/jsonrpc/" +PROMPT = "What were last month's sales by product category?" +TERMINAL_STATES = {"completed", "failed", "canceled", "rejected"} + + +def call(method, params): + """Make one A2A v0.3 JSON-RPC call.""" + request = Request( + ENDPOINT, + data=json.dumps( + { + "jsonrpc": "2.0", + "id": str(uuid.uuid4()), + "method": method, + "params": params, + } + ).encode(), + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urlopen(request) as response: # noqa: S310 + body = json.load(response) + if "error" in body: + raise RuntimeError(body["error"]) + return body["result"] + + +# blocking=False returns immediately with a Task. Database work continues on +# the server while this client polls tasks/get. +task = call( + "message/send", + { + "message": { + "messageId": str(uuid.uuid4()), + "role": "user", + "parts": [{"kind": "text", "text": PROMPT}], + }, + "configuration": {"blocking": False}, + }, +) + +task_id = task["id"] +print(f"Task {task_id}: {task['status']['state']}") + +while task["status"]["state"] not in TERMINAL_STATES: + time.sleep(1) + task = call("tasks/get", {"id": task_id}) + print(f"Task {task_id}: {task['status']['state']}") + +print(json.dumps(task, indent=2)) From 7bf37661c1c6ce2fd7df8794a57a0f8478429366 Mon Sep 17 00:00:00 2001 From: Abhishek Singh Date: Thu, 27 Aug 2026 09:22:00 -0700 Subject: [PATCH 07/14] Remove provider_endpoint while serializing ProfileAttributes. Database doesnt like it --- samples/profile_create_aws.py | 72 ++++++++++++++++++++++++++++++ samples/profile_create_azure.py | 79 +++++++++++++++++++++++++++++++++ samples/profile_create_gcp.py | 70 +++++++++++++++++++++++++++++ src/select_ai/async_profile.py | 4 +- src/select_ai/base_profile.py | 6 ++- src/select_ai/profile.py | 4 +- src/select_ai/provider.py | 56 ++++++++++++++++++++--- 7 files changed, 279 insertions(+), 12 deletions(-) create mode 100644 samples/profile_create_aws.py create mode 100644 samples/profile_create_azure.py create mode 100644 samples/profile_create_gcp.py diff --git a/samples/profile_create_aws.py b/samples/profile_create_aws.py new file mode 100644 index 0000000..40f6ab5 --- /dev/null +++ b/samples/profile_create_aws.py @@ -0,0 +1,72 @@ +# ----------------------------------------------------------------------------- +# 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. +# ----------------------------------------------------------------------------- + +"""Create an AWS Bedrock Select AI profile using only select_ai APIs. + +Before running, source test.env. The script grants the required database HTTP +access, creates/replaces AWS_CRED, and creates/replaces the profile. +""" + +import os +from pprint import pformat + +import select_ai + +AWS_HOST = "bedrock-runtime.us-east-1.amazonaws.com" +CREDENTIAL_NAME = "AWS_CRED" +PROFILE_NAME = "aws_bedrock_meta_prf" + +admin_user = os.environ["SELECT_AI_ADMIN_USER"] +admin_password = os.environ["SELECT_AI_ADMIN_PASSWORD"] +app_user = os.environ["SELECT_AI_USER"] +app_password = os.environ["SELECT_AI_PASSWORD"] +dsn = os.environ["SELECT_AI_DB_CONNECT_STRING"] + +# Equivalent to DBMS_NETWORK_ACL_ADMIN.APPEND_HOST_ACE(..., 'http'). +select_ai.connect(user=admin_user, password=admin_password, dsn=dsn) +try: + select_ai.grant_network_access( + users=app_user, + host=AWS_HOST, + privileges="http", + ) +finally: + select_ai.disconnect() + +select_ai.connect(user=app_user, password=app_password, dsn=dsn) +try: + select_ai.create_credential( + { + "credential_name": CREDENTIAL_NAME, + "username": os.environ["AWS_ACCESS_KEY_ID"], + "password": os.environ["AWS_SECRET_ACCESS_KEY"], + }, + replace=True, + ) + + profile = select_ai.Profile( + profile_name=PROFILE_NAME, + attributes=select_ai.ProfileAttributes( + credential_name=CREDENTIAL_NAME, + provider=select_ai.AWSProvider( + region="us-east-1", + model="meta.llama3-70b-instruct-v1:0", + embedding_model="amazon.titan-embed-text-v1", + ), + object_list=[{"owner": app_user, "name": "CUSTOMERS"}], + conversation=True, + temperature=1, + max_tokens=1500, + ), + replace=True, + ) + + print("Created profile:", profile.profile_name) + print(pformat(profile.get_attributes().dict(exclude_null=False))) + print("Chat response:", profile.chat("Reply with: AWS chat succeeded.")) +finally: + select_ai.disconnect() diff --git a/samples/profile_create_azure.py b/samples/profile_create_azure.py new file mode 100644 index 0000000..75a7ce8 --- /dev/null +++ b/samples/profile_create_azure.py @@ -0,0 +1,79 @@ +# ----------------------------------------------------------------------------- +# 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. +# ----------------------------------------------------------------------------- + +"""Create an Azure OpenAI Select AI profile using only select_ai APIs. + +Before running, source test.env. The script grants the required database HTTP +access, creates/replaces AZUREAI_CRED, and creates/replaces the profile. +""" + +import os +from pprint import pformat + +import select_ai + +AZURE_HOST = "adbst-ai-resource-japan-east.openai.azure.com" +AZURE_RESOURCE = "ADBST-AI-RESOURCE-JAPAN-EAST" +AZURE_DEPLOYMENT = "ADBST-AI-RESOURCE-JAPAN-EAST-DEPLOYMENT" +AZURE_EMBEDDING_DEPLOYMENT = ( + "ADBST-AI-RESOURCE-JAPAN-EAST-text-embedding-3-large" +) +CREDENTIAL_NAME = "AZUREAI_CRED" +PROFILE_NAME = "azureai_prf" + +admin_user = os.environ["SELECT_AI_ADMIN_USER"] +admin_password = os.environ["SELECT_AI_ADMIN_PASSWORD"] +app_user = os.environ["SELECT_AI_USER"] +app_password = os.environ["SELECT_AI_PASSWORD"] +dsn = os.environ["SELECT_AI_DB_CONNECT_STRING"] + +# Equivalent to DBMS_NETWORK_ACL_ADMIN.APPEND_HOST_ACE(..., 'http'). +select_ai.connect(user=admin_user, password=admin_password, dsn=dsn) +try: + select_ai.grant_network_access( + users=app_user, + host=AZURE_HOST, + privileges="http", + ) +finally: + select_ai.disconnect() + +select_ai.connect(user=app_user, password=app_password, dsn=dsn) +try: + select_ai.create_credential( + { + "credential_name": CREDENTIAL_NAME, + "username": "azure", + "password": os.environ["AZURE_API_KEY"], + }, + replace=True, + ) + + profile = select_ai.Profile( + profile_name=PROFILE_NAME, + attributes=select_ai.ProfileAttributes( + credential_name=CREDENTIAL_NAME, + provider=select_ai.AzureProvider( + azure_resource_name=AZURE_RESOURCE, + azure_deployment_name=AZURE_DEPLOYMENT, + azure_embedding_deployment_name=AZURE_EMBEDDING_DEPLOYMENT, + ), + object_list=[{"owner": app_user, "name": "CUSTOMERS"}], + conversation=True, + temperature=1, + max_tokens=1500, + seed=20, + ), + replace=True, + ) + p = select_ai.Profile.fetch(profile_name=PROFILE_NAME) + print(p) + print("Created profile:", profile.profile_name) + print(pformat(profile.get_attributes().dict(exclude_null=False))) + print("Chat response:", profile.chat("Reply with: Azure chat succeeded.")) +finally: + select_ai.disconnect() diff --git a/samples/profile_create_gcp.py b/samples/profile_create_gcp.py new file mode 100644 index 0000000..5db5f95 --- /dev/null +++ b/samples/profile_create_gcp.py @@ -0,0 +1,70 @@ +# ----------------------------------------------------------------------------- +# 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. +# ----------------------------------------------------------------------------- + +"""Create a Google Gemini Select AI profile using only select_ai APIs. + +Before running, source test.env. The script grants the required database HTTP +access, creates/replaces GOOGLE_CRED, and creates/replaces the profile. +""" + +import os +from pprint import pformat + +import select_ai + +GCP_HOST = "generativelanguage.googleapis.com" +CREDENTIAL_NAME = "GOOGLE_CRED" +PROFILE_NAME = "google_gemini_3_6_flash" + +admin_user = os.environ["SELECT_AI_ADMIN_USER"] +admin_password = os.environ["SELECT_AI_ADMIN_PASSWORD"] +app_user = os.environ["SELECT_AI_USER"] +app_password = os.environ["SELECT_AI_PASSWORD"] +dsn = os.environ["SELECT_AI_DB_CONNECT_STRING"] + +# Equivalent to DBMS_NETWORK_ACL_ADMIN.APPEND_HOST_ACE(..., 'http'). +select_ai.connect(user=admin_user, password=admin_password, dsn=dsn) +try: + select_ai.grant_network_access( + users=app_user, + host=GCP_HOST, + privileges="http", + ) +finally: + select_ai.disconnect() + +select_ai.connect(user=app_user, password=app_password, dsn=dsn) +try: + select_ai.create_credential( + { + "credential_name": CREDENTIAL_NAME, + "username": "GOOGLE", + "password": os.environ["GOOGLE_API_KEY"], + }, + replace=True, + ) + + profile = select_ai.Profile( + profile_name=PROFILE_NAME, + attributes=select_ai.ProfileAttributes( + credential_name=CREDENTIAL_NAME, + provider=select_ai.GoogleProvider( + embedding_model="gemini-embedding-001", + model="gemini-3.6-flash", + ), + temperature=1, + max_tokens=1500, + seed=20, + ), + replace=True, + ) + + print("Created profile:", profile.profile_name) + print(pformat(profile.get_attributes().dict(exclude_null=False))) + print("Chat response:", profile.chat("Reply with: GCP chat succeeded.")) +finally: + select_ai.disconnect() diff --git a/src/select_ai/async_profile.py b/src/select_ai/async_profile.py index 43ce4cb..4efaaca 100644 --- a/src/select_ai/async_profile.py +++ b/src/select_ai/async_profile.py @@ -191,8 +191,8 @@ async def set_attribute( """ self.attributes.set_attribute(attribute_name, attribute_value) if isinstance(attribute_value, Provider): - for k, v in attribute_value.dict().items(): - await self._set_attribute(k, v) + for k, v in attribute_value.profile_dict().items(): + await self._set_attribute(Provider.key_alias(k), v) else: await self._set_attribute(attribute_name, attribute_value) diff --git a/src/select_ai/base_profile.py b/src/select_ai/base_profile.py index 02103dd..2a62142 100644 --- a/src/select_ai/base_profile.py +++ b/src/select_ai/base_profile.py @@ -50,6 +50,8 @@ class ProfileAttributes(SelectAIDataClass): most relevant tables or all tables to the LLM. Supported values are - 'automated' and 'all' :param select_ai.Provider provider: AI Provider + :param int seed: Signed 64-bit integer used to make model output more + reproducible when the provider supports it. :param str stop_tokens: The generated text will be terminated at the beginning of the earliest stop sequence. Sequence will be incorporated into the text. The attribute value must be a valid array of string values @@ -75,7 +77,7 @@ class ProfileAttributes(SelectAIDataClass): object_list: Optional[List[Mapping]] = None object_list_mode: Optional[str] = None provider: Optional[Provider] = None - seed: Optional[str] = None + seed: Optional[int] = None stop_tokens: Optional[str] = None streaming: Optional[str] = None temperature: Optional[float] = None @@ -92,7 +94,7 @@ def json(self, exclude_null=True): attributes = {} for k, v in self.dict(exclude_null=exclude_null).items(): if isinstance(v, Provider): - for provider_k, provider_v in v.dict( + for provider_k, provider_v in v.profile_dict( exclude_null=exclude_null ).items(): attributes[Provider.key_alias(provider_k)] = provider_v diff --git a/src/select_ai/profile.py b/src/select_ai/profile.py index 69a7c5b..695fbc4 100644 --- a/src/select_ai/profile.py +++ b/src/select_ai/profile.py @@ -165,8 +165,8 @@ def set_attribute( """ self.attributes.set_attribute(attribute_name, attribute_value) if isinstance(attribute_value, Provider): - for k, v in attribute_value.dict().items(): - self._set_attribute(k, v) + for k, v in attribute_value.profile_dict().items(): + self._set_attribute(Provider.key_alias(k), v) else: self._set_attribute(attribute_name, attribute_value) diff --git a/src/select_ai/provider.py b/src/select_ai/provider.py index dd00cf6..83e547a 100644 --- a/src/select_ai/provider.py +++ b/src/select_ai/provider.py @@ -86,6 +86,23 @@ def keys(cls): "aws_apiformat", } + def profile_dict(self, exclude_null=True): + """Return provider attributes suitable for a DBMS_CLOUD_AI profile. + + The result contains only values held by this provider instance. In + particular, native provider endpoints remain available for network + access configuration but are omitted from database profile payloads. + OpenAI and endpoint-only custom providers retain provider_endpoint. + """ + attributes = self.dict(exclude_null=exclude_null) + if not self.should_include_provider_endpoint(): + attributes.pop("provider_endpoint", None) + return attributes + + def should_include_provider_endpoint(self) -> bool: + """Whether to include provider_endpoint in a DBMS_CLOUD_AI profile.""" + return True + @dataclass class AzureProvider(Provider): @@ -106,7 +123,13 @@ class AzureProvider(Provider): def __post_init__(self): super().__post_init__() - self.provider_endpoint = f"{self.azure_resource_name}.openai.azure.com" + if self.provider_endpoint is None: + self.provider_endpoint = ( + f"{self.azure_resource_name}.openai.azure.com" + ) + + def should_include_provider_endpoint(self) -> bool: + return False @dataclass @@ -140,6 +163,9 @@ class OCIGenAIProvider(Provider): oci_endpoint_id: Optional[str] = None oci_runtimetype: Optional[str] = None + def should_include_provider_endpoint(self) -> bool: + return False + @dataclass class CohereProvider(Provider): @@ -148,7 +174,10 @@ class CohereProvider(Provider): """ provider_name: str = COHERE - provider_endpoint = "api.cohere.ai" + provider_endpoint: Optional[str] = "api.cohere.ai" + + def should_include_provider_endpoint(self) -> bool: + return False @dataclass @@ -158,7 +187,10 @@ class GoogleProvider(Provider): """ provider_name: str = GOOGLE - provider_endpoint = "generativelanguage.googleapis.com" + provider_endpoint: Optional[str] = "generativelanguage.googleapis.com" + + def should_include_provider_endpoint(self) -> bool: + return False @dataclass @@ -168,7 +200,10 @@ class HuggingFaceProvider(Provider): """ provider_name: str = HUGGINGFACE - provider_endpoint = "api-inference.huggingface.co" + provider_endpoint: Optional[str] = "api-inference.huggingface.co" + + def should_include_provider_endpoint(self) -> bool: + return False @dataclass @@ -182,7 +217,13 @@ class AWSProvider(Provider): def __post_init__(self): super().__post_init__() - self.provider_endpoint = f"bedrock-runtime.{self.region}.amazonaws.com" + if self.provider_endpoint is None: + self.provider_endpoint = ( + f"bedrock-runtime.{self.region}.amazonaws.com" + ) + + def should_include_provider_endpoint(self) -> bool: + return False @dataclass @@ -192,4 +233,7 @@ class AnthropicProvider(Provider): """ provider_name: str = ANTHROPIC - provider_endpoint = "api.anthropic.com" + provider_endpoint: Optional[str] = "api.anthropic.com" + + def should_include_provider_endpoint(self) -> bool: + return False From 7a30f57a3f18739432233dc8028c054fdc315b05 Mon Sep 17 00:00:00 2001 From: Abhishek Singh Date: Thu, 27 Aug 2026 10:05:35 -0700 Subject: [PATCH 08/14] Added enable/disable methods in profile --- src/select_ai/async_profile.py | 24 +++++++++++++++++++++++ src/select_ai/profile.py | 24 +++++++++++++++++++++++ tests/profiles/test_1200_profile.py | 22 +++++++++++++++++++++ tests/profiles/test_1300_profile_async.py | 22 +++++++++++++++++++++ 4 files changed, 92 insertions(+) diff --git a/src/select_ai/async_profile.py b/src/select_ai/async_profile.py index 4efaaca..7b3d778 100644 --- a/src/select_ai/async_profile.py +++ b/src/select_ai/async_profile.py @@ -275,6 +275,30 @@ async def delete(self, force=False) -> None: """ await self._delete(profile_name=self.profile_name, force=force) + async def enable(self) -> None: + """Asynchronously enable this AI profile in the database. + + :return: None + :raises: oracledb.DatabaseError + """ + async with async_cursor() as cr: + await cr.callproc( + "DBMS_CLOUD_AI.ENABLE_PROFILE", + keyword_parameters={"profile_name": self.profile_name}, + ) + + async def disable(self) -> None: + """Asynchronously disable this AI profile in the database. + + :return: None + :raises: oracledb.DatabaseError + """ + async with async_cursor() as cr: + await cr.callproc( + "DBMS_CLOUD_AI.DISABLE_PROFILE", + keyword_parameters={"profile_name": self.profile_name}, + ) + @classmethod async def delete_profile(cls, profile_name: str, force: bool = False): """Asynchronously deletes an AI profile from the database diff --git a/src/select_ai/profile.py b/src/select_ai/profile.py index 695fbc4..de2cb37 100644 --- a/src/select_ai/profile.py +++ b/src/select_ai/profile.py @@ -247,6 +247,30 @@ def delete(self, force=False) -> None: """ self._delete(profile_name=self.profile_name, force=force) + def enable(self) -> None: + """Enable this AI profile in the database. + + :return: None + :raises: oracledb.DatabaseError + """ + with cursor() as cr: + cr.callproc( + "DBMS_CLOUD_AI.ENABLE_PROFILE", + keyword_parameters={"profile_name": self.profile_name}, + ) + + def disable(self) -> None: + """Disable this AI profile in the database. + + :return: None + :raises: oracledb.DatabaseError + """ + with cursor() as cr: + cr.callproc( + "DBMS_CLOUD_AI.DISABLE_PROFILE", + keyword_parameters={"profile_name": self.profile_name}, + ) + @classmethod def delete_profile(cls, profile_name: str, force: bool = False): """Class method to delete an AI profile from the database diff --git a/tests/profiles/test_1200_profile.py b/tests/profiles/test_1200_profile.py index 16d5626..cfaab13 100644 --- a/tests/profiles/test_1200_profile.py +++ b/tests/profiles/test_1200_profile.py @@ -382,3 +382,25 @@ def test_1218(python_gen_ai_profile): text="Thank you", source_language="en", target_language="de" ) assert response == "Danke" + + +def test_1219_profile_status(python_gen_ai_profile, cursor): + """Disable and re-enable a profile.""" + try: + python_gen_ai_profile.disable() + cursor.execute( + "SELECT status FROM USER_CLOUD_AI_PROFILES " + "WHERE profile_name = :profile_name", + profile_name=python_gen_ai_profile.profile_name, + ) + assert cursor.fetchone()[0] == "DISABLED" + finally: + # Keep the shared fixture usable if the status assertion fails. + python_gen_ai_profile.enable() + + cursor.execute( + "SELECT status FROM USER_CLOUD_AI_PROFILES " + "WHERE profile_name = :profile_name", + profile_name=python_gen_ai_profile.profile_name, + ) + assert cursor.fetchone()[0] == "ENABLED" diff --git a/tests/profiles/test_1300_profile_async.py b/tests/profiles/test_1300_profile_async.py index 2a34d14..0de11ee 100644 --- a/tests/profiles/test_1300_profile_async.py +++ b/tests/profiles/test_1300_profile_async.py @@ -475,3 +475,25 @@ async def test_1318(python_gen_ai_profile): text="Thank you", source_language="en", target_language="de" ) assert response == "Danke" + + +async def test_1319_profile_status(python_gen_ai_profile, async_cursor): + """Disable and re-enable an async profile.""" + try: + await python_gen_ai_profile.disable() + await async_cursor.execute( + "SELECT status FROM USER_CLOUD_AI_PROFILES " + "WHERE profile_name = :profile_name", + profile_name=python_gen_ai_profile.profile_name, + ) + assert (await async_cursor.fetchone())[0] == "DISABLED" + finally: + # Keep the shared fixture usable if the status assertion fails. + await python_gen_ai_profile.enable() + + await async_cursor.execute( + "SELECT status FROM USER_CLOUD_AI_PROFILES " + "WHERE profile_name = :profile_name", + profile_name=python_gen_ai_profile.profile_name, + ) + assert (await async_cursor.fetchone())[0] == "ENABLED" From 376d80ba284f93b0a1a51695188facfcc8e6a4fc Mon Sep 17 00:00:00 2001 From: Abhishek Singh Date: Thu, 27 Aug 2026 10:19:23 -0700 Subject: [PATCH 09/14] source_language and target_language are optional params to translate() and can be passed during profile creation --- doc/source/user_guide/profile_attributes.rst | 5 +++++ src/select_ai/async_profile.py | 14 ++++++++++---- src/select_ai/base_profile.py | 8 ++++++++ src/select_ai/profile.py | 14 ++++++++++---- 4 files changed, 33 insertions(+), 8 deletions(-) diff --git a/doc/source/user_guide/profile_attributes.rst b/doc/source/user_guide/profile_attributes.rst index 4adf67a..c725aba 100644 --- a/doc/source/user_guide/profile_attributes.rst +++ b/doc/source/user_guide/profile_attributes.rst @@ -74,6 +74,11 @@ Attribute groups - Tunes model generation behavior. * - ``conversation`` - Enables conversation history for context-aware chat workflows. + * - ``source_language``, ``target_language`` + - Set default languages for ``Profile.translate()`` and + ``AsyncProfile.translate()``. If no source language is configured or + supplied per call, the provider detects it. A target language must be + supplied either per call or in the profile. * - ``vector_index_name``, ``enable_sources``, ``enable_source_offsets``, ``enable_custom_source_uri`` - Configures retrieval-augmented generation and source reporting for diff --git a/src/select_ai/async_profile.py b/src/select_ai/async_profile.py index 7b3d778..91572fe 100644 --- a/src/select_ai/async_profile.py +++ b/src/select_ai/async_profile.py @@ -814,14 +814,20 @@ async def run_pipeline( return responses async def translate( - self, text: str, source_language: str, target_language: str + self, + text: str, + source_language: Optional[str] = None, + target_language: Optional[str] = None, ) -> Union[str, None]: """ - Translate a text using a source language and a target language + Translate text using the supplied languages or the profile defaults. :param str text: Text to translate - :param str source_language: Source language - :param str target_language: Target language + :param str source_language: Source language. When omitted, the profile + value is used; if the profile does not define one, the provider + detects the source language. + :param str target_language: Target language. When omitted, the profile + value is used. :return: str """ parameters = { diff --git a/src/select_ai/base_profile.py b/src/select_ai/base_profile.py index 2a62142..41f9b90 100644 --- a/src/select_ai/base_profile.py +++ b/src/select_ai/base_profile.py @@ -52,6 +52,9 @@ class ProfileAttributes(SelectAIDataClass): :param select_ai.Provider provider: AI Provider :param int seed: Signed 64-bit integer used to make model output more reproducible when the provider supports it. + :param str source_language: Default language of text passed to the + translate operation. If omitted, the translation provider can detect the + source language. :param str stop_tokens: The generated text will be terminated at the beginning of the earliest stop sequence. Sequence will be incorporated into the text. The attribute value must be a valid array of string values @@ -59,6 +62,9 @@ class ProfileAttributes(SelectAIDataClass): :param float temperature: Temperature is a non-negative float number used to tune the degree of randomness. Lower temperatures mean less random generations. + :param str target_language: Default language into which text is translated. + This is required by the database when no target language is supplied to + the translate operation. :param str vector_index_name: Name of the vector index """ @@ -78,9 +84,11 @@ class ProfileAttributes(SelectAIDataClass): object_list_mode: Optional[str] = None provider: Optional[Provider] = None seed: Optional[int] = None + source_language: Optional[str] = None stop_tokens: Optional[str] = None streaming: Optional[str] = None temperature: Optional[float] = None + target_language: Optional[str] = None vector_index_name: Optional[str] = None def __post_init__(self): diff --git a/src/select_ai/profile.py b/src/select_ai/profile.py index de2cb37..23f2b72 100644 --- a/src/select_ai/profile.py +++ b/src/select_ai/profile.py @@ -737,14 +737,20 @@ def generate_synthetic_data( ) def translate( - self, text: str, source_language: str, target_language: str + self, + text: str, + source_language: Optional[str] = None, + target_language: Optional[str] = None, ) -> Union[str, None]: """ - Translate a text using a source language and a target language + Translate text using the supplied languages or the profile defaults. :param str text: Text to translate - :param str source_language: Source language - :param str target_language: Target language + :param str source_language: Source language. When omitted, the profile + value is used; if the profile does not define one, the provider + detects the source language. + :param str target_language: Target language. When omitted, the profile + value is used. :return: str """ parameters = { From 82c6b2d73f500262a75bf8df89334b43516334f7 Mon Sep 17 00:00:00 2001 From: Abhishek Singh Date: Thu, 27 Aug 2026 10:40:03 -0700 Subject: [PATCH 10/14] Removed defaults in SyntheticDataParams. Use DB side defaults --- doc/source/user_guide/synthetic_data.rst | 4 +++- src/select_ai/synthetic_data.py | 13 ++++++------ tests/gsd/test_2000_synthetic_data.py | 25 ++++++++++++++++++++++++ 3 files changed, 35 insertions(+), 7 deletions(-) diff --git a/doc/source/user_guide/synthetic_data.rst b/doc/source/user_guide/synthetic_data.rst index c607360..726b13f 100644 --- a/doc/source/user_guide/synthetic_data.rst +++ b/doc/source/user_guide/synthetic_data.rst @@ -72,7 +72,9 @@ Use ``SyntheticDataParams`` to control how generation is performed: ``sample_rows`` controls how many existing rows are used as examples for the model. ``table_statistics`` and ``comments`` include additional table metadata. ``priority`` controls resource priority for generation work; supported values -are ``HIGH``, ``MEDIUM``, and ``LOW``. +are ``HIGH``, ``MEDIUM``, and ``LOW``. All parameters are optional. Parameters +that are not supplied are omitted from the request, allowing the database to +apply its defaults. Sync and async APIs =================== diff --git a/src/select_ai/synthetic_data.py b/src/select_ai/synthetic_data.py index a047af5..f5e2d85 100644 --- a/src/select_ai/synthetic_data.py +++ b/src/select_ai/synthetic_data.py @@ -20,22 +20,23 @@ class SyntheticDataParams(SelectAIDataClass): to guide the LLM in data generation :param bool table_statistics: Enable or disable the use of table - statistics information. Default value is False + statistics information. When omitted, the database default is used. :param str priority: Assign a priority value that defines the number of parallel requests sent to the LLM for generating synthetic data. Tasks with a higher priority will consume more database resources and - complete faster. Possible values are: HIGH, MEDIUM, LOW + complete faster. Possible values are: HIGH, MEDIUM, LOW. When omitted, + the database default is used. :param bool comments: Enable or disable sending comments to the LLM to - guide data generation. Default value is False + guide data generation. When omitted, the database default is used. """ sample_rows: Optional[int] = None - table_statistics: Optional[bool] = False - priority: Optional[str] = "HIGH" - comments: Optional[bool] = False + table_statistics: Optional[bool] = None + priority: Optional[str] = None + comments: Optional[bool] = None @dataclass diff --git a/tests/gsd/test_2000_synthetic_data.py b/tests/gsd/test_2000_synthetic_data.py index 4662dbd..13c4b59 100644 --- a/tests/gsd/test_2000_synthetic_data.py +++ b/tests/gsd/test_2000_synthetic_data.py @@ -201,3 +201,28 @@ def test_2009_params_json_string_is_coerced(): assert isinstance(attributes.params, SyntheticDataParams) assert attributes.params.sample_rows == 1 assert attributes.params.table_statistics is True + + +def test_2010_params_omit_unspecified_values(): + """Only explicitly supplied parameters are serialized.""" + params = SyntheticDataParams(sample_rows=1) + + assert params.dict() == {"sample_rows": 1} + + +def test_2011_empty_params_serialize_as_empty_json_object(): + """An empty params object is serialized as an empty JSON object.""" + attributes = SyntheticDataAttributes( + object_name="people", params=SyntheticDataParams() + ) + + assert attributes.prepare()["params"] == "{}" + + +def test_2012_generate_with_empty_params(synthetic_profile): + """The database accepts an empty JSON object for params.""" + attributes = _build_attributes(params=SyntheticDataParams()) + + result = synthetic_profile.generate_synthetic_data(attributes) + + assert result is None From 772137631b14f09ee8f9460bc37fb19eb931b2d0 Mon Sep 17 00:00:00 2001 From: Abhishek Singh Date: Thu, 27 Aug 2026 12:57:44 -0700 Subject: [PATCH 11/14] Added History APIs to query USER_AI_AGENT_*_HISTORY views --- doc/source/user_guide/agent.rst | 38 +++ doc/source/user_guide/async_agent.rst | 14 + samples/agent/async/agent_history_list.py | 46 ++++ samples/agent/history_list.py | 32 +++ src/select_ai/agent/__init__.py | 11 + src/select_ai/agent/history.py | 295 ++++++++++++++++++++++ src/select_ai/agent/sql.py | 34 +++ tests/agents/test_3300_teams.py | 83 +++++- tests/agents/test_3700_async_teams.py | 96 ++++++- 9 files changed, 643 insertions(+), 6 deletions(-) create mode 100644 samples/agent/async/agent_history_list.py create mode 100644 samples/agent/history_list.py create mode 100644 src/select_ai/agent/history.py diff --git a/doc/source/user_guide/agent.rst b/doc/source/user_guide/agent.rst index aad3c00..d86e366 100644 --- a/doc/source/user_guide/agent.rst +++ b/doc/source/user_guide/agent.rst @@ -446,6 +446,44 @@ operations. .. latex:clearpage:: +************* +Agent history +************* + +``TeamHistory``, ``TaskHistory``, and ``ToolHistory`` provide typed, +read-only access to the current user's Select AI Agent history views. They +query only ``USER_AI_AGENT_TEAM_HISTORY``, ``USER_AI_AGENT_TASK_HISTORY``, +and ``USER_AI_AGENT_TOOL_HISTORY`` respectively. Results are yielded newest +first. Tool ``input`` and ``output`` values are decoded to Python objects when +they contain valid JSON; other CLOB payloads are returned as strings. + +.. code-block:: python + + from select_ai.agent import TaskHistory, TeamHistory, ToolHistory + + for run in TeamHistory.list(team_name="MOVIE_AGENT_TEAM", limit=10): + print(run.team_exec_id, run.state) + + for run in TaskHistory.list(team_exec_id=""): + print(run.task_name, run.result) + + for call in ToolHistory.list(tool_name="MOVIE_SQL_TOOL", limit=20): + print(call.input, call.output) + +The sample retrieves a team's latest execution and uses its ``team_exec_id`` +to retrieve the associated task and tool history. + +.. autoclass:: select_ai.agent.TeamHistory + :members: + +.. autoclass:: select_ai.agent.TaskHistory + :members: + +.. autoclass:: select_ai.agent.ToolHistory + :members: + +.. latex:clearpage:: + ***************** AI agent examples ***************** diff --git a/doc/source/user_guide/async_agent.rst b/doc/source/user_guide/async_agent.rst index df17e2d..5f48f42 100644 --- a/doc/source/user_guide/async_agent.rst +++ b/doc/source/user_guide/async_agent.rst @@ -5,6 +5,20 @@ use ``asyncio`` and ``select_ai.async_connect()`` or ``select_ai.create_pool_async()``. +The history API follows the same pattern. ``AsyncTeamHistory``, +``AsyncTaskHistory``, and ``AsyncToolHistory`` query only the current user's +history views and yield typed events newest first. + +.. code-block:: python + + from select_ai.agent import AsyncToolHistory + + async for call in AsyncToolHistory.list(limit=10): + print(call.tool_name, call.output) + +The async sample retrieves a team's latest execution and uses its +``team_exec_id`` to retrieve the associated task and tool history. + The async agent object model mirrors the synchronous agent object model: .. list-table:: Sync and async agent APIs diff --git a/samples/agent/async/agent_history_list.py b/samples/agent/async/agent_history_list.py new file mode 100644 index 0000000..b9f84f7 --- /dev/null +++ b/samples/agent/async/agent_history_list.py @@ -0,0 +1,46 @@ +# ----------------------------------------------------------------------------- +# 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. +# ----------------------------------------------------------------------------- + +"""Asynchronously debug the latest execution of one agent team.""" + +import asyncio +import os +from pprint import pprint + +import select_ai +from select_ai.agent import ( + AsyncTaskHistory, + AsyncTeamHistory, + AsyncToolHistory, +) + +user = os.getenv("SELECT_AI_USER") +password = os.getenv("SELECT_AI_PASSWORD") +dsn = os.getenv("SELECT_AI_DB_CONNECT_STRING") +team_name = "ORACLE_AI_DATABASE_AGENT" + + +async def main(): + await select_ai.async_connect(user=user, password=password, dsn=dsn) + + # Replace team_name with team_exec_id when the application has recorded it. + async for team_run in AsyncTeamHistory.list(team_name=team_name, limit=1): + pprint(team_run) + + # team_exec_id scopes the remaining history to the same execution. + async for task_run in AsyncTaskHistory.list( + team_exec_id=team_run.team_exec_id + ): + pprint(task_run) + + async for tool_run in AsyncToolHistory.list( + team_exec_id=team_run.team_exec_id + ): + pprint(tool_run) + + +asyncio.run(main()) diff --git a/samples/agent/history_list.py b/samples/agent/history_list.py new file mode 100644 index 0000000..94dc555 --- /dev/null +++ b/samples/agent/history_list.py @@ -0,0 +1,32 @@ +# ----------------------------------------------------------------------------- +# 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. +# ----------------------------------------------------------------------------- + +"""Debug the latest execution of one agent team.""" + +import os +from pprint import pprint + +import select_ai +from select_ai.agent import TaskHistory, TeamHistory, ToolHistory + +user = os.getenv("SELECT_AI_USER") +password = os.getenv("SELECT_AI_PASSWORD") +dsn = os.getenv("SELECT_AI_DB_CONNECT_STRING") +team_name = "ORACLE_AI_DATABASE_AGENT" + +select_ai.connect(user=user, password=password, dsn=dsn) + +# Replace team_name with team_exec_id when the application has recorded it. +for team_run in TeamHistory.list(team_name=team_name, limit=1): + pprint(team_run) + + # team_exec_id scopes the remaining history to the same execution. + for task_run in TaskHistory.list(team_exec_id=team_run.team_exec_id): + pprint(task_run) + + for tool_run in ToolHistory.list(team_exec_id=team_run.team_exec_id): + pprint(tool_run) diff --git a/src/select_ai/agent/__init__.py b/src/select_ai/agent/__init__.py index 6f29cc8..9cb4ae2 100644 --- a/src/select_ai/agent/__init__.py +++ b/src/select_ai/agent/__init__.py @@ -8,6 +8,17 @@ from .core import Agent, AgentAttributes, AsyncAgent from .definition import async_get_definition, get_definition +from .history import ( + AsyncTaskHistory, + AsyncTeamHistory, + AsyncToolHistory, + TaskHistory, + TaskHistoryEvent, + TeamHistory, + TeamHistoryEvent, + ToolHistory, + ToolHistoryEvent, +) from .task import AsyncTask, Task, TaskAttributes from .team import AsyncTeam, Team, TeamAttributes from .tool import ( diff --git a/src/select_ai/agent/history.py b/src/select_ai/agent/history.py new file mode 100644 index 0000000..2cda87b --- /dev/null +++ b/src/select_ai/agent/history.py @@ -0,0 +1,295 @@ +# ----------------------------------------------------------------------------- +# 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. +# ----------------------------------------------------------------------------- + +"""Typed access to the current user's Select AI Agent history views.""" + +import json +from dataclasses import dataclass +from datetime import datetime +from typing import ( + Any, + AsyncGenerator, + Iterator, + Optional, + Sequence, + Type, + TypeVar, +) + +import oracledb + +from select_ai._abc import SelectAIDataClass +from select_ai.agent.sql import ( + LIST_USER_AI_AGENT_TASK_HISTORY, + LIST_USER_AI_AGENT_TEAM_HISTORY, + LIST_USER_AI_AGENT_TOOL_HISTORY, +) +from select_ai.db import async_cursor, cursor + + +@dataclass +class TeamHistoryEvent(SelectAIDataClass): + """One row from ``USER_AI_AGENT_TEAM_HISTORY``.""" + + team_exec_id: str + team_name: str + state: str + start_date: Optional[datetime] = None + end_date: Optional[datetime] = None + conversation_id: Optional[str] = None + params: Optional[str] = None + + +@dataclass +class TaskHistoryEvent(SelectAIDataClass): + """One row from ``USER_AI_AGENT_TASK_HISTORY``.""" + + team_exec_id: str + team_name: str + task_order: Optional[int] + agent_name: str + task_name: Optional[str] + conversation_params: Optional[str] + input: Optional[str] + result: Optional[str] + state: str + start_date: Optional[datetime] + end_date: Optional[datetime] + + +@dataclass +class ToolHistoryEvent(SelectAIDataClass): + """One row from ``USER_AI_AGENT_TOOL_HISTORY``.""" + + invocation_id: int + team_exec_id: str + task_order: Optional[int] + tool_name: Optional[str] + agent_name: Optional[str] + task_name: Optional[str] + start_date: Optional[datetime] + end_date: Optional[datetime] + input: Optional[Any] + output: Optional[Any] + tool_output: Optional[str] + + def __post_init__(self): + super().__post_init__() + self.input = _load_json(self.input) + self.output = _load_json(self.output) + + +HistoryEvent = TypeVar("HistoryEvent", bound=SelectAIDataClass) + + +def _load_json(value: Optional[str]) -> Any: + if value is None: + return None + try: + return json.loads(value) + except (TypeError, json.JSONDecodeError): + return value + + +def _validate_limit(limit: Optional[int]) -> None: + if limit is not None and (not isinstance(limit, int) or limit < 1): + raise ValueError("'limit' must be a positive integer or None") + + +def _read_lobs(row: Sequence[object]) -> tuple: + return tuple( + value.read() if isinstance(value, oracledb.LOB) else value + for value in row + ) + + +async def _async_read_lobs(row: Sequence[object]) -> tuple: + values = [] + for value in row: + if isinstance(value, oracledb.AsyncLOB): + value = await value.read() + values.append(value) + return tuple(values) + + +def _events( + query: str, + event_type: Type[HistoryEvent], + parameters: dict, + limit: Optional[int], +) -> Iterator[HistoryEvent]: + _validate_limit(limit) + with cursor() as cr: + cr.execute(query, parameters) + count = 0 + for row in cr: + yield event_type(*_read_lobs(row)) + count += 1 + if limit is not None and count >= limit: + break + + +async def _async_events( + query: str, + event_type: Type[HistoryEvent], + parameters: dict, + limit: Optional[int], +) -> AsyncGenerator[HistoryEvent, None]: + _validate_limit(limit) + async with async_cursor() as cr: + await cr.execute(query, parameters) + count = 0 + async for row in cr: + yield event_type(*await _async_read_lobs(row)) + count += 1 + if limit is not None and count >= limit: + break + + +class TeamHistory: + """Read runs from ``USER_AI_AGENT_TEAM_HISTORY``.""" + + @classmethod + def list( + cls, + team_name: Optional[str] = None, + team_exec_id: Optional[str] = None, + limit: Optional[int] = None, + ) -> Iterator[TeamHistoryEvent]: + """Yield team runs ordered from newest to oldest.""" + yield from _events( + LIST_USER_AI_AGENT_TEAM_HISTORY, + TeamHistoryEvent, + {"team_name": team_name, "team_exec_id": team_exec_id}, + limit, + ) + + +class TaskHistory: + """Read task runs from ``USER_AI_AGENT_TASK_HISTORY``.""" + + @classmethod + def list( + cls, + team_name: Optional[str] = None, + task_name: Optional[str] = None, + agent_name: Optional[str] = None, + team_exec_id: Optional[str] = None, + limit: Optional[int] = None, + ) -> Iterator[TaskHistoryEvent]: + """Yield task runs ordered from newest to oldest.""" + yield from _events( + LIST_USER_AI_AGENT_TASK_HISTORY, + TaskHistoryEvent, + { + "team_name": team_name, + "task_name": task_name, + "agent_name": agent_name, + "team_exec_id": team_exec_id, + }, + limit, + ) + + +class ToolHistory: + """Read tool calls from ``USER_AI_AGENT_TOOL_HISTORY``.""" + + @classmethod + def list( + cls, + tool_name: Optional[str] = None, + task_name: Optional[str] = None, + agent_name: Optional[str] = None, + team_exec_id: Optional[str] = None, + limit: Optional[int] = None, + ) -> Iterator[ToolHistoryEvent]: + """Yield tool calls ordered from newest to oldest.""" + yield from _events( + LIST_USER_AI_AGENT_TOOL_HISTORY, + ToolHistoryEvent, + { + "tool_name": tool_name, + "task_name": task_name, + "agent_name": agent_name, + "team_exec_id": team_exec_id, + }, + limit, + ) + + +class AsyncTeamHistory: + """Asynchronously read runs from ``USER_AI_AGENT_TEAM_HISTORY``.""" + + @classmethod + async def list( + cls, + team_name: Optional[str] = None, + team_exec_id: Optional[str] = None, + limit: Optional[int] = None, + ) -> AsyncGenerator[TeamHistoryEvent, None]: + """Yield team runs ordered from newest to oldest.""" + async for event in _async_events( + LIST_USER_AI_AGENT_TEAM_HISTORY, + TeamHistoryEvent, + {"team_name": team_name, "team_exec_id": team_exec_id}, + limit, + ): + yield event + + +class AsyncTaskHistory: + """Asynchronously read task runs from ``USER_AI_AGENT_TASK_HISTORY``.""" + + @classmethod + async def list( + cls, + team_name: Optional[str] = None, + task_name: Optional[str] = None, + agent_name: Optional[str] = None, + team_exec_id: Optional[str] = None, + limit: Optional[int] = None, + ) -> AsyncGenerator[TaskHistoryEvent, None]: + """Yield task runs ordered from newest to oldest.""" + async for event in _async_events( + LIST_USER_AI_AGENT_TASK_HISTORY, + TaskHistoryEvent, + { + "team_name": team_name, + "task_name": task_name, + "agent_name": agent_name, + "team_exec_id": team_exec_id, + }, + limit, + ): + yield event + + +class AsyncToolHistory: + """Asynchronously read tool calls from ``USER_AI_AGENT_TOOL_HISTORY``.""" + + @classmethod + async def list( + cls, + tool_name: Optional[str] = None, + task_name: Optional[str] = None, + agent_name: Optional[str] = None, + team_exec_id: Optional[str] = None, + limit: Optional[int] = None, + ) -> AsyncGenerator[ToolHistoryEvent, None]: + """Yield tool calls ordered from newest to oldest.""" + async for event in _async_events( + LIST_USER_AI_AGENT_TOOL_HISTORY, + ToolHistoryEvent, + { + "tool_name": tool_name, + "task_name": task_name, + "agent_name": agent_name, + "team_exec_id": team_exec_id, + }, + limit, + ): + yield event diff --git a/src/select_ai/agent/sql.py b/src/select_ai/agent/sql.py index b56cf8c..126f7c4 100644 --- a/src/select_ai/agent/sql.py +++ b/src/select_ai/agent/sql.py @@ -80,3 +80,37 @@ FROM USER_AI_AGENT_TEAMS t WHERE REGEXP_LIKE(t.AGENT_TEAM_NAME, :team_name_pattern, 'i') """ + + +LIST_USER_AI_AGENT_TEAM_HISTORY = """ +SELECT team_exec_id, team_name, state, start_date, end_date, conversation_id, + params +FROM user_ai_agent_team_history +WHERE (:team_name IS NULL OR team_name = :team_name) + AND (:team_exec_id IS NULL OR team_exec_id = :team_exec_id) +ORDER BY start_date DESC NULLS LAST +""" + + +LIST_USER_AI_AGENT_TASK_HISTORY = """ +SELECT team_exec_id, team_name, task_order, agent_name, task_name, + conversation_params, input, result, state, start_date, end_date +FROM user_ai_agent_task_history +WHERE (:team_name IS NULL OR team_name = :team_name) + AND (:task_name IS NULL OR task_name = :task_name) + AND (:agent_name IS NULL OR agent_name = :agent_name) + AND (:team_exec_id IS NULL OR team_exec_id = :team_exec_id) +ORDER BY start_date DESC NULLS LAST +""" + + +LIST_USER_AI_AGENT_TOOL_HISTORY = """ +SELECT invocation_id, team_exec_id, task_order, tool_name, agent_name, + task_name, start_date, end_date, input, output, tool_output +FROM user_ai_agent_tool_history +WHERE (:tool_name IS NULL OR tool_name = :tool_name) + AND (:task_name IS NULL OR task_name = :task_name) + AND (:agent_name IS NULL OR agent_name = :agent_name) + AND (:team_exec_id IS NULL OR team_exec_id = :team_exec_id) +ORDER BY start_date DESC NULLS LAST +""" diff --git a/tests/agents/test_3300_teams.py b/tests/agents/test_3300_teams.py index 435efb8..58d694a 100644 --- a/tests/agents/test_3300_teams.py +++ b/tests/agents/test_3300_teams.py @@ -18,8 +18,12 @@ AgentAttributes, Task, TaskAttributes, + TaskHistory, Team, TeamAttributes, + TeamHistory, + Tool, + ToolHistory, ) PYSAI_3300_AGENT_NAME = f"PYSAI_3300_AGENT_{uuid.uuid4().hex.upper()}" @@ -29,6 +33,8 @@ PYSAI_3300_TASK_DESCRIPTION = "PYSAI_3100_SQL_TASK_DESCRIPTION" PYSAI_3300_TEAM_NAME = f"PYSAI_3300_TEAM_{uuid.uuid4().hex.upper()}" PYSAI_3300_TEAM_DESCRIPTION = "PYSAI_3300_TEAM_DESCRIPTION" +PYSAI_3300_FUNCTION_NAME = f"PYSAI_3300_FUNCTION_{uuid.uuid4().hex.upper()}" +PYSAI_3300_TOOL_NAME = f"PYSAI_3300_TOOL_{uuid.uuid4().hex.upper()}" @pytest.fixture(scope="module") @@ -43,10 +49,36 @@ def python_gen_ai_profile(profile_attributes): @pytest.fixture(scope="module") -def task_attributes(): +def history_tool(): + with select_ai.cursor() as cr: + cr.execute( + f""" + CREATE OR REPLACE FUNCTION {PYSAI_3300_FUNCTION_NAME} + RETURN VARCHAR2 + IS + BEGIN + RETURN '{"message":"history test complete"}'; + END; + """ + ) + + tool = Tool.create_pl_sql_tool( + tool_name=PYSAI_3300_TOOL_NAME, + function=PYSAI_3300_FUNCTION_NAME, + description="Returns JSON with the history test result", + ) + yield tool + tool.delete(force=True) + with select_ai.cursor() as cr: + cr.execute(f"DROP FUNCTION {PYSAI_3300_FUNCTION_NAME}") + + +@pytest.fixture(scope="module") +def task_attributes(history_tool): return TaskAttributes( - instruction="Help the user with their request about movies. " - "User question: {query}. ", + instruction="You must call the available tool exactly once, then " + "answer the user's question using its result. User question: {query}.", + tools=[history_tool.tool_name], enable_human_tool=False, ) @@ -142,3 +174,48 @@ def test_3303(team): assert len(response) > 0 finally: conversation.delete(force=True) + + +def test_3304_team_and_task_history(team): + """Run a team and retrieve its generated team and task history rows.""" + conversation = select_ai.Conversation( + attributes=select_ai.ConversationAttributes( + title="Agent history test", + description="Conversation for agent history test", + ) + ) + conversation.create() + try: + response = team.run( + prompt="Reply with one sentence about the movie Titanic.", + params={"conversation_id": conversation.conversation_id}, + ) + assert isinstance(response, str) + assert response + + team_runs = list(TeamHistory.list(team_name=team.team_name, limit=1)) + assert len(team_runs) == 1 + assert team_runs[0].team_name == team.team_name + assert team_runs[0].team_exec_id + assert team_runs[0].conversation_id == conversation.conversation_id + + task_runs = list( + TaskHistory.list(team_exec_id=team_runs[0].team_exec_id, limit=1) + ) + assert len(task_runs) == 1 + assert task_runs[0].team_name == team.team_name + assert task_runs[0].task_name == PYSAI_3300_TASK_NAME + + tool_runs = list( + ToolHistory.list( + tool_name=PYSAI_3300_TOOL_NAME, + team_exec_id=team_runs[0].team_exec_id, + limit=1, + ) + ) + assert len(tool_runs) == 1 + assert tool_runs[0].tool_name == PYSAI_3300_TOOL_NAME + assert tool_runs[0].invocation_id + assert tool_runs[0].output == {"message": "history test complete"} + finally: + conversation.delete(force=True) diff --git a/tests/agents/test_3700_async_teams.py b/tests/agents/test_3700_async_teams.py index a7fc909..75547c2 100644 --- a/tests/agents/test_3700_async_teams.py +++ b/tests/agents/test_3700_async_teams.py @@ -17,7 +17,11 @@ AgentAttributes, AsyncAgent, AsyncTask, + AsyncTaskHistory, AsyncTeam, + AsyncTeamHistory, + AsyncTool, + AsyncToolHistory, TaskAttributes, TeamAttributes, ) @@ -29,6 +33,8 @@ PYSAI_3700_TASK_DESCRIPTION = "PYSAI_3100_SQL_TASK_DESCRIPTION" PYSAI_3700_TEAM_NAME = f"PYSAI_3700_TEAM_{uuid.uuid4().hex.upper()}" PYSAI_3700_TEAM_DESCRIPTION = "PYSAI_3700_TEAM_DESCRIPTION" +PYSAI_3700_FUNCTION_NAME = f"PYSAI_3700_FUNCTION_{uuid.uuid4().hex.upper()}" +PYSAI_3700_TOOL_NAME = f"PYSAI_3700_TOOL_{uuid.uuid4().hex.upper()}" @pytest.fixture(scope="module") @@ -43,10 +49,36 @@ async def python_gen_ai_profile(profile_attributes): @pytest.fixture(scope="module") -def task_attributes(): +async def history_tool(): + async with select_ai.async_cursor() as cr: + await cr.execute( + f""" + CREATE OR REPLACE FUNCTION {PYSAI_3700_FUNCTION_NAME} + RETURN VARCHAR2 + IS + BEGIN + RETURN '{"message":"async history test complete"}'; + END; + """ + ) + + tool = await AsyncTool.create_pl_sql_tool( + tool_name=PYSAI_3700_TOOL_NAME, + function=PYSAI_3700_FUNCTION_NAME, + description="Returns JSON with the async history test result", + ) + yield tool + await tool.delete(force=True) + async with select_ai.async_cursor() as cr: + await cr.execute(f"DROP FUNCTION {PYSAI_3700_FUNCTION_NAME}") + + +@pytest.fixture(scope="module") +async def task_attributes(history_tool): return TaskAttributes( - instruction="Help the user with their request about movies. " - "User question: {query}. ", + instruction="You must call the available tool exactly once, then " + "answer the user's question using its result. User question: {query}.", + tools=[history_tool.tool_name], enable_human_tool=False, ) @@ -142,3 +174,61 @@ async def test_3303(team): assert len(response) > 0 finally: await conversation.delete(force=True) + + +async def test_3304_async_team_and_task_history(team): + """Run a team and retrieve its generated history rows asynchronously.""" + conversation = select_ai.AsyncConversation( + attributes=select_ai.ConversationAttributes( + title="Async agent history test", + description="Conversation for async agent history test", + ) + ) + await conversation.create() + try: + response = await team.run( + prompt="Reply with one sentence about the movie Titanic.", + params={"conversation_id": conversation.conversation_id}, + ) + assert isinstance(response, str) + assert response + + team_runs = [ + run + async for run in AsyncTeamHistory.list( + team_name=team.team_name, + limit=1, + ) + ] + assert len(team_runs) == 1 + assert team_runs[0].team_name == team.team_name + assert team_runs[0].team_exec_id + assert team_runs[0].conversation_id == conversation.conversation_id + + task_runs = [ + run + async for run in AsyncTaskHistory.list( + team_exec_id=team_runs[0].team_exec_id, + limit=1, + ) + ] + assert len(task_runs) == 1 + assert task_runs[0].team_name == team.team_name + assert task_runs[0].task_name == PYSAI_3700_TASK_NAME + + tool_runs = [ + run + async for run in AsyncToolHistory.list( + tool_name=PYSAI_3700_TOOL_NAME, + team_exec_id=team_runs[0].team_exec_id, + limit=1, + ) + ] + assert len(tool_runs) == 1 + assert tool_runs[0].tool_name == PYSAI_3700_TOOL_NAME + assert tool_runs[0].invocation_id + assert tool_runs[0].output == { + "message": "async history test complete" + } + finally: + await conversation.delete(force=True) From b523e42725e668af407476b28ee3df0524d10d8f Mon Sep 17 00:00:00 2001 From: Abhishek Singh Date: Thu, 27 Aug 2026 13:40:33 -0700 Subject: [PATCH 12/14] Updated Readme and copyright header --- README.md | 110 +++++++++++------- .../image/select_ai_a2a_server_demo.gif | Bin 0 -> 29222 bytes docker/Dockerfile | 7 ++ docker/a2a-entrypoint.sh | 9 +- gcloud/cloudbuild.yaml | 7 ++ gcloud/deploy.sh | 7 ++ 6 files changed, 94 insertions(+), 46 deletions(-) create mode 100644 doc/source/image/select_ai_a2a_server_demo.gif diff --git a/README.md b/README.md index 5cbd863..7bd2a08 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,22 @@ Select AI for Python enables you to ask questions of your database data using na Select AI for Python enables you to leverage the broader Python ecosystem in combination with generative AI and database functionality - bridging the gap between the DBMS_CLOUD_AI PL/SQL package and Python's rich ecosystem. It provides intuitive objects and methods for AI model interaction. +## Table of Contents + +- [Installation](#installation) +- [Documentation](#documentation) +- [Getting Started](#getting-started) + - [Async Example](#async-example) +- [Command Line Interface](#command-line-interface) + - [Chat](#chat) + - [A2A Server](#a2a-server) + - [Cloud Run](#cloud-run) +- [Samples](#samples) +- [Help](#help) +- [Contributing](#contributing) +- [Security](#security) +- [License](#license) + ## Installation @@ -26,9 +42,48 @@ The CLI extra includes A2A server support. See [Select AI for Python documentation][documentation] -## Samples +## Getting Started + +```python +import select_ai + +user = "" +password = "" +dsn = "" + +select_ai.connect(user=user, password=password, dsn=dsn) +profile = select_ai.Profile(profile_name="oci_ai_profile") +# run_sql returns a pandas dataframe +df = profile.run_sql(prompt="How many promotions?") +print(df.columns) +print(df) +``` -Examples can be found in the [/samples][samples] directory +### Async Example + +```python + +import asyncio + +import select_ai + +user = "" +password = "" +dsn = "" + +# This example shows how to asynchronously run sql +async def main(): + await select_ai.async_connect(user=user, password=password, dsn=dsn) + async_profile = await select_ai.AsyncProfile( + profile_name="async_oci_ai_profile", + ) + # run_sql returns a pandas df + df = await async_profile.run_sql("How many promotions?") + print(df) + +asyncio.run(main()) + +``` ## Command Line Interface @@ -36,11 +91,12 @@ The optional `select-ai` command provides interactive chat, SQL, profile management, and A2A server tools for Select AI: ### Chat + ```bash select-ai chat --profile OCI_AI_PROFILE ``` -![Select AI CLI demo](doc/source/image/select_ai_cli_demo.gif) +![Select AI CLI demo](doc/source/image/select_ai_cli_demo.gif) ### A2A Server @@ -50,6 +106,8 @@ Expose one Oracle Database AI agent team as an A2A JSON-RPC HTTP server: select-ai a2a serve --team SALES_ANALYST --port 8000 ``` +![Select AI A2A server demo](doc/source/image/select_ai_a2a_server_demo.gif) + The command obtains database connection settings from its options or the `SELECT_AI_*` environment variables. Its Agent Card is available at `/.well-known/agent-card.json`, and its JSON-RPC endpoint is @@ -73,53 +131,15 @@ select-ai a2a agent-card \ --public-url https://YOUR-SERVICE.run.app ``` -## Cloud Run +#### Cloud Run Deploy the A2A server to Cloud Run using the instructions in [gcloud/README.md](https://github.com/oracle/python-select-ai/blob/main/gcloud/README.md). -## Basic Example - -```python -import select_ai - -user = "" -password = "" -dsn = "" - -select_ai.connect(user=user, password=password, dsn=dsn) -profile = select_ai.Profile(profile_name="oci_ai_profile") -# run_sql returns a pandas dataframe -df = profile.run_sql(prompt="How many promotions?") -print(df.columns) -print(df) -``` - -### Async Example - -```python - -import asyncio - -import select_ai - -user = "" -password = "" -dsn = "" - -# This example shows how to asynchronously run sql -async def main(): - await select_ai.async_connect(user=user, password=password, dsn=dsn) - async_profile = await select_ai.AsyncProfile( - profile_name="async_oci_ai_profile", - ) - # run_sql returns a pandas df - df = await async_profile.run_sql("How many promotions?") - print(df) +## Samples -asyncio.run(main()) +For in-depth examples, see the [/samples][samples] directory. -``` ## Help Questions can be asked in [GitHub Discussions][ghdiscussions]. @@ -136,7 +156,7 @@ Please consult the [security guide][security] for our responsible security vulne ## License -Copyright (c) 2025 Oracle and/or its affiliates. +Copyright (c) 2025, 2026 Oracle and/or its affiliates. Released under the Universal Permissive License v1.0 as shown at . diff --git a/doc/source/image/select_ai_a2a_server_demo.gif b/doc/source/image/select_ai_a2a_server_demo.gif new file mode 100644 index 0000000000000000000000000000000000000000..9b0ab9fbfde9ebb681df8171ad7e53a3773a36dd GIT binary patch literal 29222 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

{(=Pt$2A5O zM3#96!9i-eE4dhsPT_!v@W+MlTqoalNyy+qNW^te<-l8)PG6MFV8BM8AYnu{w>Y^b zqCmqFWzz>Y%U|i08uDRBsb3W9rQy%!%}a)4?{R9^H%y+Nd$Ze=rC>bB#+V^zExp9PsHjLUudj!sr-B#I}GEEpL7@&zGY_zgEQ22BVEp8ywczbp6`(`OzzCY4eyLisEL%oPk!!B5}A zgByb~W+r)c?mUiwY9E{+Kb?ctn0{**;DI$DRGJVsU)ml-=8S2&lT!@UWClKZh|hg0 zW`loqjG2Y-q( z(BnmO)q;FAUDCfCrYbSJj+$kv|H&XPBGPV39hZuJGwF|^mt)ka^4$ z27FaOewy?%E&6Oh77b4fN|k`@3Yp+{ybp%h#bbQ=fR>j;Y@IA(Q@d{!xRqu6kii1N z8^v>N^Z!rzt|ZJc{#sf!T#!0-h$H|EXN#xnPsIr`J|guv;^8uivv)Rzv_2H0Prf@b zg-i@FPBauc5ZK9){yNy>%wyAbt>i6Gu$0^L)a@@_OM1V-T(%=_yO(6!F;ik&h$uRb zK*pnMz2V(-aX$_!KWY+tDk|UME#;aoL!BhRkN_1Kf)%HD1BI=?cnp!IEJct?7G)YS z33XDs5~?ASK!$A8VP5p+3LC9L6sn2?`eG!Z;<{RAwEcHDd{wJbB@~%DsWFhuSMfkl zX4Q^!adm%{sYC@vXBy`3!j@a6Hi^Q|$AR2GAOU6=2~%p&U}-Uao7LFdp!{3uS8>zu>)fx=Jc=H^ zx(lRM&}KbBrgfQAUHo1vR6ET@iqXzB)_I^==usgsuBi~tc|y-3nb7*x86HDmdennX zkl~20@E(L^>NgL;*7^LKK0FV?{*dXPO9f($1ltG!B|6Y+z1v3P^U|&$&qk_rf&hF7 zC=o{QpDaCpVaAb^iT)b~I9Ju@Xh=)0CwrT~E7gTDDV($+^@~vb!;CG8SG`_UH(7U& zuK^>fDdY9;4)hX7*2}JrBmLB76QM~kfglU2S3BUO7p4O-ND)x5QdTPI?(Tk{X9Gfm zLv8OmVukxElvw_9nn2z94O{w4yz}H(A*LtXW4{ByUx+}NorSM6o$o{Xk$)rd6-r1C zMWad}69fiAm3#-<`ow!WKfF1wj0ZSSV!KK6;VrI0k>Jm)dL#6DL~a8d*#qxVszO;i zaG87PP9Rst(CR#}^+i_^D_==S=AJWzGo<^rxfheF1~LN{qSQ#kvL&d~8QmpL%h3PE z3Tlz~Q%~e*U`xF023cK!Y+6CKStS-}KO;8eRAT&0X}gdvzW(Y)Pua*Se8U*%W$Cp0 zPBxOMcO|KQ$P7$KfI(;M`$KKf3Uc|b<+^0zyk{bkhLM9#Wb7itF}Mlz8&Jv%AuBCF zt|Xn&w`CWmBsmJqs%S_1JTQ1o#bnSoWCfAvn4+8d%KU0=37YaV*lT0}WW2w0`y5LB zRTuJImbzO^|I0TWa@|3$yQ#vrEdKrYKN$m0SMc8y@!Qg3u|{g+v=J=h^Up^)+F&HQ>rstk*~?bf&3b5k-UMW*J=w#|Buei%j2b zZ8mGIJaX#q2MA><3HJwx6ALh4f-_5qOaOWPByI^%W zU8)3oGYzs_y_#R^zI+n`Y5FEYwiEN=<4W)7AK<82|A+0t(JzXrLT3$QdNyDSxdzTW?`wb8BKd9#aw(NKScK{|N(*N(4-TxiclmL8V z3+$QM&6e_47B{&3{lBQDT#2u_&&dCvnlmI-N-5l|jxHk?(Nof&nWL!z3KBacKE`Z| zqz%;ONyX^xMRR?YFdJV$GSEba;ciKE#L34ba%(^0+wN5C-+cf7P^zg!Rz zdp~jfhidi^(gNIettpp~(Q4sR6h4GIy|9ldTtX`GG8-iHdI|`DB@)W{J32v)^A1!& z(alw95c-^mkgO2Ct`#6*j)I=D@YM_H$f#NE;YGA$E_wYB}}C#OeT`Dix><8aS);@DMLh2T?+!)b7K^UXa|#3 zczcaS{%QWv6BZ;Pev8Bv;uSy_x1leFgx34p6dnchX9AM_N zD1^+$b)%YS2uqR9BSlyjEGI2(VYg}_NfCEp%yg2JJ;WdfBPnX1)HdHdSXQ819~aYh z^gTwzW1!d^U|Z<_5y3IVpoL4ak+`e`j*~f8irC}i(1bte!=>m@u&(ya0XdvzLTrc} zW+i(P+EwM?k9Iz0cVs<(<*k-3rsZKk2#Q^-Bo{hr|5{;+wEN$8664?d@z?HI<8`Kd%rYD^PbJb{w6 zdv1oJ3`=km-4+0J$a`G9mAv~niE^1JFN@NkXB|NUxgmsjWJyuhb@T|LrIl+_7y1N3 zGE$^uMs?m;$-56}*c1!d@jViKM9n#X=MJNkD%yo2+?7R8_+-qwk8*xF%SH1_v8#Aq7}YQ@>Fy z1K13RLwi7@@61I3zCyGQKM;vW{t*B{q&Ertc%uXwjc{~P9@T-crywIK6{4Nb#VZ-K z+92T{J^xoD=iSxR{wACh`?3V`I>ot?Q536Kg&?2> zsNo44_#zBb>9%XCaWkCEaVe8&G^TB7bU?Gh*VR>hBmvlf!F0Pd#&P_@ii=a;Gst3S z;+{i7ZCRGshDPylqYO~oxZhKDD5B)jgW;207>r%7GtO|G$k(n-&c+gAWctiS`crEi zBpVe4WN4RhgJc`YVG_$g0Z`Fkf76*PtSuaWY)}m{^2-~GXIcox{bre%m1-a+g6Lm; zyJ%)ss(T`2n~`8pDH;rw&cqXkS(-FQ=`h%|A~8jYQSs021?`bgegRBtuQd`=so*5Q zAqc-35QuRzXyFUYra%`R@D5m7RpCrdlL^7F!I13oNl`dpvnwRUpyVirMPv4v@LHH+ z3t8+J0|iL=$k126kvuciXiAD{L9$xtdh!(!1pi4ob`s`9DJPj%k*Sc&zxOlK3f7lWCu2 zvUVCKBKm98h=HJSA%AQP#J_3IiT`9i{A>?im?M~sDm*sB)q=e+!%>{vM&6O|>p6>@6T^}R6MMxc+e1m{p z04?3|qaO?AImb*GHHJ5EhJxdl?$v@e+SyaNe!OM~>ZX}@;2g&DE5X#43wQ5`_~o1TN-SI1X3?1}@`u>CynX6* zKDQ@!k;*Ii4FbsdCxQ{-7549Vs`?0oNR0q*XwzaPlI-~)KIwA}Zjw&jY8+Xy-Sug< zLY$X7ZSB$`18Xf&K{_LrU0Qe>*U=*Vq~W$0jgNLZ^00viQv6LvFYdH+c>*;snzDM- zlPxGc`4{8Rfo))PF*PBHbg`^4Q)Qy)Ue?8jhiH4TrdBP0fy+bVqR}_>>#rS?7bZB8 zCf~GGmcjlCp5hN>X#cp)zHoFrLH{n}ulY=M<|)U`L!D{Z(0fwdUseee&p=zT6*i2h zs{QAbt?ENbM|hQ*jJ>*ki`F@V6qe!&T5EOIqwR_FX}rv;h}i1zJ-{;J^FHC8^TjXa zYbFy(W#oMMJ?OJA$j^zVH=eQnj2u_zHE0>g_Hw8h7hX9Y+wKvUhdSY)h1@x&h~DIAFu-x5vK&n%?|7U-nGsTN}-L`d5(rJpWm92&Vx4iYG%OFNWGODvzma? ziFLCO1fIvD%DZgCwT0Vzv!^ss4P>ycrLfp6UsN!IKiTciNMiLZr93&&sX8;2aDgC) zBf>QO3pXV%#O4Kuf~&)M+wMl&%K{Pdv}bE41aR=!9oi=-XWa@k)&b?lf!D_=wLZ`& zZOKQ}mHh5Cc3y@1yP}eCoSu!kUG>5njq%(afGL5~ze3T~Asre3;fg@cMuORA9@ zm?K~KJx4vvrdpsq8mA?ifH(NL33Mf;Bcwlws5h zeKxY{EXy?%y$b)!se&B2_Qntcp%}@G3FEUSo(u?@q;lcbhaTZ=Q?Kj|TUWft+v#Kc zMl!$1l5kFfI=fy1fvnPUy!E8xE!APJgk`trK1Q@_d!X-=1TlW~6TsPh-r1^~YfnpA zN?3fP%VPeJGK=db)5Xbekd1&AQQHT`i!dAW9gL}&w>kCJVU`L53D4)+`Kkxa-2))2`$s(j zf}!UJ@7vy_Y%t@#-wk__dr9)_<6j|P!t@pza1LaaN1O-{VzM?*=2Q4Dy3U@B__2hC zXCaTcpRzecc^^2Cc$%IIgltR0q%UDtsV;d-2e1eEmd09g%-N6GmL z2aC#)N@>%F*o#tAMQj%-ke5?B=7DgEM<7bl?Y#R`qVWc&aInlF+NzBghU^XXBL~tY zd<0wuu%RT31(2lcf(w>CgdI2(>kx>-`|=n#h_iP=3SW|k7F|Swu!1jla*9|bB=|G` z1JYiWMiEV{Ttf#MBI7Sll0>1@glxrdX;v_xBDz}cirI(3SnQOEvdhUI`ob>31=cyL zLhI>{fWb;C9iLzx`|_HP--pPC4pCT3ZMJCsdgWu@l_+AWKeK^Q?G&ll47QR%hgkoA z5j#9;u@ET-Gh@LwT=GhYC^|Y%5=v=G2J94a3640oiIOsZhbhij9TjF}ijj}YgG0w< z!3UT;&swZ;zRpY(Q~+?vYfeSWn>p)5Tcu;iAaTG_w>w&{ti^KbX3);=vKjngVfzpx zUmhbX?fX@=mlQ-wNVgJ#T_FmOec2&G&gVQY>Hc20`IN&#<@+$!n};y#EClNsl$qm^ zkrVIf!Hk2cnd~Z=@A8Zn(l4hwN4yU2pVynxM^t}1roMbAY2?!r1fj>uYI9`D;^1%4 zSKXlydLeL9cp+g{)+#2C)1Ih(kFaTshUVIbw-VxPr9d#?=9C&B09K5pix54)e2xn~ zwXz8cZiDAw^iyi~99kAX(DpSg8g66ox?`zb^HjD$%o;g$tB+H`~W+to{E3i4pW zpe=?;pLXosz1~G0!kG%wk^ASc_-7U?N?bAzWLHM-y}V=4F&OtGT+qXBta8RGo9YO?3Dqwnu!Giu6>+`{luXO(d=0ey4j&KR89 z@cR&GYwrv!(gN^ys7_E|AOt>m$7N*`(=?bb58)9<)`{GO!qql5Q2UCl%F9ZKEG0F5 zpk&u~L!3?*K&}|Ec2?&tbxXAcgP}4JjfBvK(W3!SOq>qNp_+bEnrlVnP-GZ!QBjO! z^b9xE0J|#Lxqs};kuqE?^s)rP^9i(%zk03^Svs{bp(WXPDKn^rWSL--*@yv3jTDZE zT`>@Y`un9g@CMcLt7ng^^4;E&)eOLJzN&q~y_H%Tq2iXQhRv6cm|W0qNF9J~N8=bR zE0w`mty6k@37mt?$gBQ+0~KX5x_38@#fd6D0kf@-npe9bsB>wzoGx>giirqg<_=%S zUsn7b_BrA5ELB6mKk!#8fvRNKncv8xu~T-G-^uNKKGTY)uH}fj*BmvWqM;p22G*+$ znSu3|h%ZuLPOnq>qew3DV6E&V#mL5EOov5*Nr<|kHK_tpCUf=6PKrj_TirEeRb;7L z0K;=1otTZ+q;nILy=H8b!t-w*yRQ*>d?`)$9OuN#6inyqAG>HHv+*pmlyjlt>Z zTb=S%Ea6a#5d%At>;;tp*HMjH^2_a;2 zSL?i%2a;x2RIVhe9QppG$J7pdYLH;&n%kBB#^}VBuc!2js_gR{;yPHhiQB~2n+;t? zWFd}SN}wybUMM3x`^wxCyNIVwYY*QZwmo=|tUo__qh-kcw=814QdegFYQAI69mLOxFhfjU5*z!x>bTr% z#DB$;^i06ae12dB7HhTXz1=Jr^MLQ)GY=nC`8b9}rHsYnOk1)?5tSrAp^|+ke>h{n_VOa9t`e z!gB@13~s48e9ek^hkLPavctNTo^$6Njt@$Lz+yNQUwWM#wnF`Qph6%C=PdEl5dhz! zX0@{uDnQp*3RTP^RfMIw>tpn^kwkEoNipc! zjhE8aDvJ^DWyFW-qysT}T4j2ALC5&kK=Blmp z>rc@Wcj$xiiV`5A(gC$npKYj7IFeF`%mn;2ve_B}Eo^Lqhtg#&RO|rZyn^g~cAm9s z{%)M+O)9A`u7JrXSh<{p{x8yIOewhTD7;H4SW3~qKt>FZ&)wcDvXu$tSSma&rLtX7 zI2>O1s|!;RPUq@lU>pHyO*&76*jfcxjVTpOEhPte3HOzX)5AZ{lp-8UF|DOIPo^cF zDObf5rvq=kNs0}GE71#EG)px-%XTuEdiqsml|6t04Pmawx#7Yz5Mk;Zs8JM!%~H#4 zTGe47JTykBf`AYXF1PjsUHi&CJo(*Q!T+75PeR20A1v*Y0x7d7Fco$ix*1S!l_zF= z_P_mv$F8NIoh>WmM{b^{0|beJARgvmaF~BVl{Oqq2nc01nYcy{`EY&99l2-+cdIo^ zd~cg0)qsIXhY%lJ^9qRjE}FDB-x=O;?D}Ts%FA-COs{LH{Lh-W;K?L-JL!`}9rQX- zVXk*QAXiI0KuP?$-Rqo$Ee}EzJ?O!B86WGK3bQlhaqV%Vd%sG%CZR|tcTT1#5vUQm zyRvH7t}e%YJ)SQipyOc{R81}*pTB=^04P^U9W0mG4u6lZH+!M95WluDFJdb1DG|CV z2|rER?+>QOioAlJJnE2wa2BB+JsNiWHKa`BZSeV{7vE)=PX04HvG$GvJE@PM><2(4 ziTWc_&#&a4+2?+qs^rPT?`=KBt{v_21{UiW=1 zSS~{p8xCj-bmN>Fv6u+Zali_qHkRCRI^wPSA>i(slJxsCx-+ipuC$r7Y zk4vr)zUb1y!L%sUXt2@`$sWzDf~j7PzXsa#SmzbN$-g!qsslFO!#AHD+L&C7Il-L& zmMyqcSrY{vjG_~rv5&U$o-kdAgEy=*>Cv!O8cKp6pb#d?cJ`z6-==*#@|{2ygSioN zjxD(YVui5+2aJ4R@f6r9AB%!Zh4)gRtNtuLQh-0=I6L_yN+QJ-chOwHm&x%#z-~#S ztCXbH=Tga1o4Oe4Gc)pUj-7+8ihrve0rZ})w@6&Mn>?7Mk6n{>Hks&s&yji-wGPmZ z84a-+XbL=k1U&#d$`P9UEz|6r!_pnc6a4=s-xGrz5t94ZZ!b=x{^6Q_MGzepI^Y+L zgH!#Pm7$hI>jM&Qr+oqzrNHjTGHBQ|Cl6WGt~?9}otkoW*(GLX$GB#jEfIOGF3$;P zpWufPAWHn#mo2W~RIs#q104pJLa6szhxx_*{;<+JL_GE3Wc3#q=7C6HeJ;mKelGcz zzHfk(<1;N+6eNbUq6vejsUYtsk-@{EUrf3lWKm%=7auX&HGI!p;^L@nQNRZK3O;&= z;Q&x}^QE|y1R4M*5rCUx5h&OIG;^qBl`w=m(muY@`65g5<|6fQtX+KD%~D2{QVHPk zVyY2t<2X58Q1h{LO(C;B?+uLe=HFava{g!qu3=4+El~yKE-K9cj?pZ9g5m#aQyKte zbGJ%!Rs5VV?w6M~*l|k#b!O_wk^mCEYX;LAm^pea7kvgvlHZwgGF|ffZXN&7>AW{a*vwg4uPKXK7z5 z%P!@()N-GzMm^t6F{7?%oaGls#||K*sJpSDJeolw{u<`l;l**n-={3}`t76T;@K)< zNbM#I6Dk2KjdewmxuuMmZU9BVw*AKc*6y~(5lv>LpSsW1&e=@iXmfC7%xK?g0 zj6aq>(aPI}ES1!FMDYV~R%oXC?VY(Bk2F6BoEB9akt^v?3XJlR3UtrG{%BwSBDa_3 z`9dXEugPElc}k{kg}VqCf9|u%@CR#HM;Q zRT)@{#+718;~gK&!4TCKcnJH3Py6U>UffijX+}r%n$h$Qs@wnBtV1eXdOq{ateVuE zQhEo?i3Y3X$bV()&l<0>Uh#M!6GYR}HwJBBy{*R+$qrw*g6kt)2aN;TqyDTf=ROg( z39bB!d0+m4&J(x)0G9bem|%B}%U8inPnO2_!)Jy6aN`BIXEEH>Wim{UpOLFZ6L=cM za6RVy^nolbM~4UKZ;Pqyz7wi;St>2^vjy~!k3Z7-4bG&gmX;pnMRJZcRZ?~pBi@2yL zw9_Ubz#q7uv@>cy{^@Ms@&bJ6{WxOADbvB^L&hUnV&thg&00%ceB|S(`ad5Fwq#A8 zH-E|%1jLtMtk<=>KZuq+kw%K4t%8s@-xQX5t{LA5_R+6I7<+B#zHUlehdrpviRy!t4}IP2&+ z_ZY;Nvn+qCah4D#`fv#K97h1jT1B8>Z3pAW$4F? zLpdn$c#SKpIPx!En#2tywy$%lBzg?-I@1j+RwCvi{bmhI$T$I_xnv?K9xDhjG6T5=j%z~Jy) z=OBpR0Ik9w5g5Z!!U@>+=G7R0y)6ZI)(ha%@s7QP{F*Y1QqM=iMKdkn6XA?GgQ9tM z5hAt-Iau^+ujo0l$Z4%;(WCewxmW_`@u9aEno}ISTD&D(A_OaGt}p(YQu2*h@=c+5 zZ@xsTzU0?>2~s~5Z6M4@MWZP#|a1&0@j__wg!;u{W zjW$SF{-Al!sfFIe^)EjNfmsgMkaLoU9pj;A8dx9x}%TeT2 zKWp-r`HS`tXBA+jxXG3|8m!v;#^7t#LQU4b{HFy&i^J?Y;#=N7XBrJmA2n>_ny=xF zOqaF(oJ__5LP@?())G!SdJb6jUJp9s$5NQTc~eGr6%nn3w{VR6l85q3@O2^*eg4Qg z_=_5ajrcR!qc4e!0BI3oxu+XiBFUq@-a+S8N)XAXb(SIPtO{F6Qb}a^+oMDbQB0t> zGS7GBP&UBGnjUOCE&2r=tyv+H^70o# ze^|<23?OAALOaHwwTs3qK$FiqXluy|?-Bv-yV2Pz|xX zNQwf$qcvD}BQB&&2nn)3lEe#`HXmEppa6$2H|Ris69D?y7~pcHbW-IuPgnDkw()3E zOP>SCID#z~!k@f?1g^I z@RI~M4*{YfykEX9n=2T6T?8Y`MUQv&542w77AfvsE>w2;xlk8rjFx4Si6GRs^TQs2 zu5LQD2F*Ez+W^?d9ECQduwR4YFA6;8^o{J>ArgiZr>^sTAiUs0m~Mg~b1??oX3wil zY3<56v#j(sezKz{REgmP7{sh=#K^eA6{ z{(m1mi_JBuZ6l?^c;EI{gk=g{5;lNHY9Gcw0u{FZ@`v~-6i5_Wp`oH+CRcDR!Ni#% zpYt>mMXQZfFaV!sJ5cdAEBrUZNa$lzI5?Z+i9_cAcH>fi{yqT^XDj%NW29BhrHK2Y z-^HGRu^LMC9CC5(5N!`CCZy#O;n53*d7qM$o60yR!^QBf)G(Q1b1-d7&N{#bcP|^@ zgNEa1-g(Vb4$pxD{V_vTP5ty^k|z;LVBMqh72A`7#McxU`jOVf2@wrah$?b^tsJ85 z*?Ypk9ue@^p`d*HIpA@bzk02VIZ*)b?>q@3b0Vc4znXDO5v}15#Sr+NBDsccKc3Ff zCB@Ua6XZenp|8?b*uuHEa1_8bRYVxblEqKwKx=c0#ql)6p{^k+?3vnFb~*l{8{y*! zgoE7zD{JSZ>`JVJvcW~4O$XDv2BYVP?D6w;&#NnR3@&ADH5;BVXTo=FDBKTbdaaJy>LF(iR zfonj?7MS4H4?d_}E7e9x&DM##Dw@YsJJGpTIPu#|39@M3D3EyRtkjjv^7R}w$9h{< z%L<{Tm!TK{t?Pnd++=dCay=+&H9AO&${;>zI>fkqV@^QK$?&EelK(^tS?F8JJ!;|c zc&AM#j@LHVdPW6+GezB_-Q{^Fx03UZ?R2MJv=pGuLvXDYBy_Y zK?iq*)D?s+ARD(Pn5^z{jPK3zhq+r?~GQSl6w^9T2ragU9 zjBNKYm9BWOv=S5Mh&q!@|LhOwCe_MA9>>ttC{Y|v1Iq3y#5_J%|44paj<$cq{EIe= zdpXoJ{L$h*P%-upf*z5r{@aG?QQ!|TsD1PNIrwW70sQ>VJ*8)|#A}s3f=JTTZ$kQs zDoVthYVI*i9l^ zlL8+<=?kN`x;~4lmG3*9>dRDuzp?S_ zw_d5tIvD}n1H>C~5+=4G)vp=-k(UE5e2U_5TRb~7MfkyCPY85baIgFOYJ?BNFV$sc z`g;YT*K*J|{^<*-=6PZoA%eP6i#Rg1*D)scv=f5Fz)?9YMI<2!?5 ze-`A=?I0ZkXFcVEGkr|IaetB6f5<2OPfm>ZIBK$)z+Tp**IvF2`1^{eW25u#oWOlW z0DnTBczh1>`=%!ZN9&^82Jk)3XhPm>!ETvW_el_xGVaSn( zm-VkA(C=iDIZ6SeoKpb7XV5C;;;qOSrz7;A%6iXK?+}mSp(p=TBFe0U$VoEeZYsYx z@s1?IMw4HAr9wa@c*gQL-7E(+l6Is1;3J8Q=O7H9H<*)7(N~2Bht!M~4HP}?ETS=Rs^+Th~V5o%MVE(dlFEIxxBX`|Lz4t$7SABrt>||lQ*PKyU`RyOtYRH+YblDtfeGH z?9c7%Gm)8P8=MixX&Hz*^L}kNECd%lmWxeEy zV9pFP!`HK4{Pd~VEr+4#AQ>??a%6|Bh*mJTB%+717KtKh{j2GoWY8C<&Ncxi4U{9_;JAFS}*I;V%J@K?ko4x3Ay zuauDemv|ksJFLU-DBrh<2;L5Wpuu^!-k>GCPS{4Ml9JE;#kJ8hscbY9h|y$$ZkufI zInz)2N*SiE>SYxTlsaEq75T=n{x|!~QAH3sK)a#G02~+qC02~WSjYm`8;|`6*7Jv* z1pD;~U!`p3v(ILs`clE4NTX|t;KW~P@2t}?Y3cAx!$%^`hW#Z!I4|hffCVYlD;o(n zQF%7=7kEmWwW{dEKDtQ{BN1lERjO8RmhWc>@@bf+tv5Rj+J^3bmnA%K0UZrPzP8YG z(Q7}!dmQIrUnoo)M7fPe;!-O|o%y-B)=M_%yPYMD)!>QADuH9n!Wl#)))C_Ywn?<+*Yx#za}TKie80w55haZ=_|XmK8e51OB?oI zecfXMyYP1KL=}g-&JC>dvaCFbVD{aS4n>@66B-YIqcmM zMQu9$v8nVSBN3fg$||2VZb^5)izrpF@+{2V4=j3qJK|5o9XwS2l$7<`kK&(`jpmiX zZR4Bo#P0JGQ^$?E-`B9}qZr?9z^xw%meL2VDi9)mmow93V>RnWXi^9_05PiSAHIc* z2D!Gt`voA-qtWvb^kf}5GuD(@(;r!VM(7T>*{|P9(r)Th^3A6t3`U`>Z$WM<-;KM^ zsJVY)(CDC)_p0JJWwV(;6~%d0BL=bCvc`e{=Qmw>`B2DS(}}+eZbPFk{;cl3>I$?i zd53Xzq-^%bBRlBMsICv9GM2Y1$A)v3hLuM9`~GgfV+>}X5%H+8ds-}gON61NNcljJ zM^e9mkI}Q+PEFJ^5)MjaC%`5F!MD%w{i+EO6Tg{pZP241fGk1@(s@N%_g<1Y%cCd$ zaNmEE4r-Z==e{}SQjnNm#^DhSd40>e=FJ34Jf_TNqC;ikyxqe~Jm}nqMGkp+jwZG0 z%%ntus^dYAaY|TdptmOS(fGATm_egIb6Ut4;WUH3s8EHa=Y$HTZxcqYIux9G1D~;b zj75*dM+8Rv9!6(Mviu`^&qu1p>3j0vW$aQuTmwJ2mdbw<6OX(9@b97aK5#!+hzXR8 ziV3Rd3d3X+66m5rV%3qj5%_#d$l^`>xb@vk;PW&EyYuyi>mL3g2%y@t%+s}?30_$_-$W@)6OYfXxnA|%}LcY#HN@LAff`@$qe?n<#h) zvRk}Y&G6OMphL#3@!UqqU(4hLmb3YKzIgq?!@k5CAh@@cCj^l z@r}-1B8xB&v4KS|34dAy)t2PWE-9Qs3&o&rroBJ}EWiUGegNwCNkrg4NbCwnu>X4@ zKlq(HfXS!#DZq+g2XybfByU_u;l_NFr9kQ9!(|H~x$TXuU-=h%`3H?}AC-4Yu4= z6U58E5%?f0>*9L;6MlenHS6q}P(biD3uK-eC^)-OoP-)F+Bh$X@zGvYA&P{)*=Y3J z?B6W?8RB$4Z&Crr^OGlVjr9RKHfU}QSHk{@4b(AOtC5HOCm;`mAq?hZ$JJ! sx_ZLje{t*4+bvlsz|o)cFK1Ljd-!?3ttS_^U)|dla$Gk&SXLST4|PML$^ZZW literal 0 HcmV?d00001 diff --git a/docker/Dockerfile b/docker/Dockerfile index ae074c3..acfc00f 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,3 +1,10 @@ +# ----------------------------------------------------------------------------- +# 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 oraclelinux:10-slim ENV PATH=/opt/venv/bin:$PATH diff --git a/docker/a2a-entrypoint.sh b/docker/a2a-entrypoint.sh index b4326d6..258a9b1 100644 --- a/docker/a2a-entrypoint.sh +++ b/docker/a2a-entrypoint.sh @@ -1,4 +1,11 @@ -#!/usr/bin/env sh +#!/usr/bin/env bash + +# ----------------------------------------------------------------------------- +# 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. +# ----------------------------------------------------------------------------- # Cloud Run-only A2A launcher. It expands the optional wallet archive mounted # by deploy.sh, then starts the generic select-ai CLI in A2A server mode. diff --git a/gcloud/cloudbuild.yaml b/gcloud/cloudbuild.yaml index c21d6fc..9977f13 100644 --- a/gcloud/cloudbuild.yaml +++ b/gcloud/cloudbuild.yaml @@ -1,3 +1,10 @@ +# ----------------------------------------------------------------------------- +# 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. +# ----------------------------------------------------------------------------- + # Build one reusable A2A server image. Database team selection is Cloud Run # configuration, not an image-build input. steps: diff --git a/gcloud/deploy.sh b/gcloud/deploy.sh index c5449e7..0eeab66 100755 --- a/gcloud/deploy.sh +++ b/gcloud/deploy.sh @@ -1,5 +1,12 @@ #!/usr/bin/env bash +# ----------------------------------------------------------------------------- +# 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. +# ----------------------------------------------------------------------------- + # Deploy one Select AI A2A server to private Cloud Run. On its first run it # creates the ADB secrets used by this service. Later runs reuse both those # secrets and the service's currently deployed image. From b8d47b1ad86a0d8d94b252cf6b090b24edb9dae3 Mon Sep 17 00:00:00 2001 From: Abhishek Singh Date: Thu, 27 Aug 2026 16:17:08 -0700 Subject: [PATCH 13/14] Fix tests --- tests/agents/test_3300_teams.py | 2 +- tests/agents/test_3700_async_teams.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/agents/test_3300_teams.py b/tests/agents/test_3300_teams.py index 58d694a..6f9ff31 100644 --- a/tests/agents/test_3300_teams.py +++ b/tests/agents/test_3300_teams.py @@ -57,7 +57,7 @@ def history_tool(): RETURN VARCHAR2 IS BEGIN - RETURN '{"message":"history test complete"}'; + RETURN '{{"message":"history test complete"}}'; END; """ ) diff --git a/tests/agents/test_3700_async_teams.py b/tests/agents/test_3700_async_teams.py index 75547c2..e78f670 100644 --- a/tests/agents/test_3700_async_teams.py +++ b/tests/agents/test_3700_async_teams.py @@ -57,7 +57,7 @@ async def history_tool(): RETURN VARCHAR2 IS BEGIN - RETURN '{"message":"async history test complete"}'; + RETURN '{{"message":"async history test complete"}}'; END; """ ) From 8ff857853ddef4ce3981c68d3bc55e2d095d83de Mon Sep 17 00:00:00 2001 From: Abhishek Singh Date: Thu, 27 Aug 2026 16:59:58 -0700 Subject: [PATCH 14/14] Updated tests to fix assertions --- src/select_ai/agent/a2a/server.py | 33 +++++++++++++++++++++++++-- tests/agents/test_3300_teams.py | 5 +++- tests/agents/test_3700_async_teams.py | 3 ++- 3 files changed, 37 insertions(+), 4 deletions(-) diff --git a/src/select_ai/agent/a2a/server.py b/src/select_ai/agent/a2a/server.py index 686200c..6b28a26 100644 --- a/src/select_ai/agent/a2a/server.py +++ b/src/select_ai/agent/a2a/server.py @@ -7,11 +7,16 @@ """A2A HTTP server for Oracle Database AI Agent Teams.""" +import json from contextlib import asynccontextmanager from typing import Optional from a2a.compat.v0_3.conversions import to_compat_agent_card -from a2a.helpers import new_task_from_user_message, new_text_part +from a2a.helpers import ( + new_data_part, + new_task_from_user_message, + new_text_part, +) from a2a.server.agent_execution import AgentExecutor from a2a.server.request_handlers import DefaultRequestHandler from a2a.server.routes import create_jsonrpc_routes @@ -27,6 +32,25 @@ from select_ai.agent.a2a.task_store import OracleTaskStore from select_ai.version import __version__ +_A2UI_MIME_TYPE = "application/a2ui+json" + + +def _a2ui_payload(result: str | None) -> dict | None: + """Return an A2UI response envelope, if ``RUN_TEAM`` returned one.""" + if not result: + return None + try: + payload = json.loads(result) + except json.JSONDecodeError: + return None + if not isinstance(payload, dict): + return None + if payload.get("metadata", {}).get("mimeType") != _A2UI_MIME_TYPE: + return None + if not isinstance(payload.get("data"), list): + return None + return payload + class DatabaseTeamExecutor(AgentExecutor): """Execute A2A requests with one Oracle conversation per A2A context.""" @@ -57,8 +81,13 @@ async def execute(self, context, event_queue): prompt=context.get_user_input(), params={"conversation_id": conversation_id}, ) + a2ui_payload = _a2ui_payload(result) await updater.add_artifact( - parts=[new_text_part(result or "")], + parts=( + [new_data_part(a2ui_payload)] + if a2ui_payload is not None + else [new_text_part(result or "")] + ), name="database-agent-result", last_chunk=True, ) diff --git a/tests/agents/test_3300_teams.py b/tests/agents/test_3300_teams.py index 6f9ff31..bcc42d8 100644 --- a/tests/agents/test_3300_teams.py +++ b/tests/agents/test_3300_teams.py @@ -216,6 +216,9 @@ def test_3304_team_and_task_history(team): assert len(tool_runs) == 1 assert tool_runs[0].tool_name == PYSAI_3300_TOOL_NAME assert tool_runs[0].invocation_id - assert tool_runs[0].output == {"message": "history test complete"} + assert tool_runs[0].output == { + "status": "success", + "result": '\'{"message":"history test complete"}\'', + } finally: conversation.delete(force=True) diff --git a/tests/agents/test_3700_async_teams.py b/tests/agents/test_3700_async_teams.py index e78f670..ceef86a 100644 --- a/tests/agents/test_3700_async_teams.py +++ b/tests/agents/test_3700_async_teams.py @@ -228,7 +228,8 @@ async def test_3304_async_team_and_task_history(team): assert tool_runs[0].tool_name == PYSAI_3700_TOOL_NAME assert tool_runs[0].invocation_id assert tool_runs[0].output == { - "message": "async history test complete" + "status": "success", + "result": '\'{"message":"async history test complete"}\'', } finally: await conversation.delete(force=True)