diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..439d722 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,24 @@ +.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 +build/ diff --git a/.gcloudignore b/.gcloudignore new file mode 100644 index 0000000..f1312b1 --- /dev/null +++ b/.gcloudignore @@ -0,0 +1,31 @@ +# 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/ +doc/ +docs/ +tests/ +samples/ +*.docx +*.pdf +*.png +*.jpg +*.jpeg +build/ 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: | 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/README.md b/README.md index 38a930e..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 @@ -20,26 +36,13 @@ Install the optional command line interface: python3 -m pip install 'select_ai[cli]' ``` +The CLI extra includes A2A server support. + ## Documentation See [Select AI for Python documentation][documentation] -## Samples - -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: - -```bash -select-ai chat --profile OCI_AI_PROFILE -``` - -![Select AI CLI demo](doc/source/image/select_ai_cli_demo.gif) - -### Basic Example +## Getting Started ```python import select_ai @@ -81,6 +84,62 @@ async def main(): asyncio.run(main()) ``` + +## Command Line Interface + +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 + +Expose one Oracle Database AI agent team as an A2A JSON-RPC HTTP server: + +```bash +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 +`/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. + +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 + +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). + +## Samples + +For in-depth examples, see the [/samples][samples] directory. + ## Help Questions can be asked in [GitHub Discussions][ghdiscussions]. @@ -97,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 0000000..9b0ab9f Binary files /dev/null and b/doc/source/image/select_ai_a2a_server_demo.gif differ 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/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/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/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/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..acfc00f --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,29 @@ +# ----------------------------------------------------------------------------- +# 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 + +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..258a9b1 --- /dev/null +++ b/docker/a2a-entrypoint.sh @@ -0,0 +1,40 @@ +#!/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. + +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/README.md b/gcloud/README.md new file mode 100644 index 0000000..e075515 --- /dev/null +++ b/gcloud/README.md @@ -0,0 +1,159 @@ +# Deploy the Select AI A2A server to Google Cloud + +`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. + +## 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` | + +### Other service identities + +| 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 services enable \ + run.googleapis.com \ + cloudbuild.googleapis.com \ + artifactregistry.googleapis.com \ + secretmanager.googleapis.com \ + discoveryengine.googleapis.com \ + --project PROJECT_ID +``` + +## Deploy (and update) the A2A server + +```bash +gcloud/deploy.sh --build +``` + +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 + +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/deploy.sh --wallet-archive /path/to/Wallet_database.zip +``` + +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. + +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 +``` + +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. + +`--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`. + +### 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 +git pull +gcloud/deploy.sh --build +``` + +`--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 +gcloud/deploy.sh --rotate-db-credentials +``` + +### 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/cloudbuild.yaml b/gcloud/cloudbuild.yaml new file mode 100644 index 0000000..9977f13 --- /dev/null +++ b/gcloud/cloudbuild.yaml @@ -0,0 +1,26 @@ +# ----------------------------------------------------------------------------- +# 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: + - name: gcr.io/cloud-builders/docker + args: + - build + - --file + - docker/Dockerfile + - --tag + - ${_REGION}-docker.pkg.dev/$PROJECT_ID/${_REPOSITORY}/select-ai:${_IMAGE_TAG} + - . + +images: + - ${_REGION}-docker.pkg.dev/$PROJECT_ID/${_REPOSITORY}/select-ai:${_IMAGE_TAG} + +substitutions: + _REGION: us-central1 + _REPOSITORY: select-ai + _IMAGE_TAG: latest diff --git a/gcloud/deploy.sh b/gcloud/deploy.sh new file mode 100755 index 0000000..0eeab66 --- /dev/null +++ b/gcloud/deploy.sh @@ -0,0 +1,307 @@ +#!/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. + +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: gcloud/deploy.sh [options] + +Deploy the Select AI A2A server to private Cloud Run. + +Options: + --project PROJECT Google Cloud project (defaults to gcloud config project) + --region REGION Cloud Run and Artifact Registry region (default: us-central1) + --repository REPOSITORY Docker repository name (default: select-ai) + --service SERVICE Cloud Run service name (default: oracle-a2a-agent) + --a2a-team TEAM Agent Team installed in Oracle Database (default: ORACLE_AI_DATABASE_AGENT) + --runtime-sa EMAIL Runtime service-account email + --runtime-sa-name NAME Default runtime service-account name (default: oracle-a2a-runtime) + --db-user-secret NAME Secret name for the ADB user + --db-password-secret NAME Secret name for the ADB password + --db-dsn-secret NAME Secret name for the ADB connect descriptor + --wallet-secret NAME Secret name for the wallet archive + --wallet-password-secret NAME Secret name for the wallet password + --wallet-archive PATH Wallet ZIP to upload or replace + --memory MEMORY Cloud Run memory limit (default: 1Gi) + --timeout SECONDS Cloud Run request timeout (default: 900) + --max-instances COUNT Cloud Run maximum instances (default: 1) + --pool-max-size COUNT Maximum Oracle connections per instance (default: 10) + --image-uri URI Deploy this container image + --build Build the current checkout before deploying + --image-tag TAG Tag for --build (default: git SHA plus UTC timestamp) + --rotate-db-credentials Prompt for and rotate ADB credentials + -h, --help Show this help +EOF +} + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +project_id="" +region="us-central1" +repository="select-ai" +service="oracle-a2a-agent" +a2a_team="ORACLE_AI_DATABASE_AGENT" +runtime_sa="" +runtime_sa_name="oracle-a2a-runtime" +runtime_sa_explicit=false +db_user_secret="" +db_password_secret="" +db_dsn_secret="" +wallet_secret="" +wallet_password_secret="" +wallet_archive="" +memory="1Gi" +timeout="900" +max_instances="1" +pool_max_size="10" +image_uri="" +image_tag="" +build_image=false +rotate_db_credentials=false + +while [[ $# -gt 0 ]]; do + case "$1" in + --project) project_id="${2:?--project requires a value}"; shift 2 ;; + --region) region="${2:?--region requires a value}"; shift 2 ;; + --repository) repository="${2:?--repository requires a value}"; shift 2 ;; + --service) service="${2:?--service requires a value}"; shift 2 ;; + --a2a-team) a2a_team="${2:?--a2a-team requires a value}"; shift 2 ;; + --runtime-sa) runtime_sa="${2:?--runtime-sa requires a value}"; runtime_sa_explicit=true; shift 2 ;; + --runtime-sa-name) runtime_sa_name="${2:?--runtime-sa-name requires a value}"; shift 2 ;; + --db-user-secret) db_user_secret="${2:?--db-user-secret requires a value}"; shift 2 ;; + --db-password-secret) db_password_secret="${2:?--db-password-secret requires a value}"; shift 2 ;; + --db-dsn-secret) db_dsn_secret="${2:?--db-dsn-secret requires a value}"; shift 2 ;; + --wallet-secret) wallet_secret="${2:?--wallet-secret requires a value}"; shift 2 ;; + --wallet-password-secret) wallet_password_secret="${2:?--wallet-password-secret requires a value}"; shift 2 ;; + --wallet-archive) wallet_archive="${2:?--wallet-archive requires a value}"; shift 2 ;; + --memory) memory="${2:?--memory requires a value}"; shift 2 ;; + --timeout) timeout="${2:?--timeout requires a value}"; shift 2 ;; + --max-instances) max_instances="${2:?--max-instances requires a value}"; shift 2 ;; + --pool-max-size) pool_max_size="${2:?--pool-max-size requires a value}"; shift 2 ;; + --image-uri) image_uri="${2:?--image-uri requires a value}"; shift 2 ;; + --build) build_image=true; shift ;; + --image-tag) image_tag="${2:?--image-tag requires a value}"; shift 2 ;; + --rotate-db-credentials) rotate_db_credentials=true; shift ;; + -h|--help) usage; exit 0 ;; + *) echo "Unknown option: $1" >&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[standard]>=0.30", +] +a2a = [ + "select_ai[cli]", ] test = [ "anyio", 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)) 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/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/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/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/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/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/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..6b28a26 --- /dev/null +++ b/src/select_ai/agent/a2a/server.py @@ -0,0 +1,219 @@ +# ----------------------------------------------------------------------------- +# 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.""" + +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_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 +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__ + +_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.""" + + 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}, + ) + a2ui_payload = _a2ui_payload(result) + await updater.add_artifact( + 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, + ) + 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/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/src/select_ai/async_profile.py b/src/select_ai/async_profile.py index 43ce4cb..91572fe 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) @@ -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 @@ -790,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 02103dd..41f9b90 100644 --- a/src/select_ai/base_profile.py +++ b/src/select_ai/base_profile.py @@ -50,6 +50,11 @@ 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 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 @@ -57,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 """ @@ -75,10 +83,12 @@ 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 + 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): @@ -92,7 +102,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/cli/a2a.py b/src/select_ai/cli/a2a.py new file mode 100644 index 0000000..9e3bb70 --- /dev/null +++ b/src/select_ai/cli/a2a.py @@ -0,0 +1,127 @@ +# ----------------------------------------------------------------------------- +# 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__ + +try: + import uvicorn + + from select_ai.agent.a2a.server import create_app +except ImportError: + create_app = None + uvicorn = None + + +@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.""" + if create_app is None or uvicorn is None: + raise click.ClickException( + "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: ") + 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__": diff --git a/src/select_ai/profile.py b/src/select_ai/profile.py index 69a7c5b..23f2b72 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) @@ -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 @@ -713,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 = { 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 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/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" 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 diff --git a/tests/agents/test_3300_teams.py b/tests/agents/test_3300_teams.py index 435efb8..bcc42d8 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,51 @@ 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 == { + "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 a7fc909..ceef86a 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,62 @@ 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 == { + "status": "success", + "result": '\'{"message":"async history test complete"}\'', + } + finally: + await conversation.delete(force=True) 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 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" 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)