From a2097ba8ebab9d8c173190000c6644b21422737e Mon Sep 17 00:00:00 2001 From: Yuan Teoh <45984206+Yuan325@users.noreply.github.com> Date: Tue, 3 Feb 2026 12:22:03 -0800 Subject: [PATCH 1/6] docs: add index page for cloud logging admin tools (#2414) Add _index page for cloud logging admin tools for drop down. --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- docs/en/resources/tools/cloudloggingadmin/_index.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 docs/en/resources/tools/cloudloggingadmin/_index.md diff --git a/docs/en/resources/tools/cloudloggingadmin/_index.md b/docs/en/resources/tools/cloudloggingadmin/_index.md new file mode 100644 index 0000000000..a5b34e9a76 --- /dev/null +++ b/docs/en/resources/tools/cloudloggingadmin/_index.md @@ -0,0 +1,8 @@ +--- +title: "Cloud Logging Admin" +linkTitle: "Cloud Logging Admin" +type: docs +weight: 1 +description: > + Tools that work with Cloud Logging Admin Sources. +--- From 732eaed41d4c0e26c328b97576b214b3d4d2b276 Mon Sep 17 00:00:00 2001 From: Mend Renovate Date: Wed, 4 Feb 2026 02:40:22 +0000 Subject: [PATCH 2/6] chore(deps): update github actions (#2386) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Type | Update | Change | |---|---|---|---| | [actions/cache](https://redirect.github.com/actions/cache) ([changelog](https://redirect.github.com/actions/cache/compare/8b402f58fbc84540c8b491a91e594a4576fec3d7..cdf6c1fa76f9f475f3d7449005a359c84ca0f306)) | action | digest | `8b402f5` → `cdf6c1f` | | [actions/checkout](https://redirect.github.com/actions/checkout) ([changelog](https://redirect.github.com/actions/checkout/compare/8e8c483db84b4bee98b60c0593521ed34d9990e8..de0fac2e4500dabe0009e67214ff5f5447ce83dd)) | action | digest | `8e8c483` → `de0fac2` | --- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 👻 **Immortal**: This PR will be recreated if closed unmerged. Get [config help](https://redirect.github.com/renovatebot/renovate/discussions) if that's undesired. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/googleapis/genai-toolbox). Co-authored-by: Averi Kitsch --- .github/workflows/deploy_dev_docs.yaml | 4 ++-- .github/workflows/deploy_previous_version_docs.yaml | 4 ++-- .github/workflows/deploy_versioned_docs.yaml | 2 +- .github/workflows/docs_preview_clean.yaml | 2 +- .github/workflows/docs_preview_deploy.yaml | 4 ++-- .github/workflows/link_checker_workflow.yaml | 4 ++-- .github/workflows/publish-mcp.yml | 2 +- 7 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/deploy_dev_docs.yaml b/.github/workflows/deploy_dev_docs.yaml index d51207e1ad..d71f1db273 100644 --- a/.github/workflows/deploy_dev_docs.yaml +++ b/.github/workflows/deploy_dev_docs.yaml @@ -40,7 +40,7 @@ jobs: group: docs-deployment cancel-in-progress: false steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 0 # Fetch all history for .GitInfo and .Lastmod @@ -56,7 +56,7 @@ jobs: node-version: "22" - name: Cache dependencies - uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5 + uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5 with: path: ~/.npm key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} diff --git a/.github/workflows/deploy_previous_version_docs.yaml b/.github/workflows/deploy_previous_version_docs.yaml index 5c238d18b4..1c642262e7 100644 --- a/.github/workflows/deploy_previous_version_docs.yaml +++ b/.github/workflows/deploy_previous_version_docs.yaml @@ -30,14 +30,14 @@ jobs: steps: - name: Checkout main branch (for latest templates and theme) - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: ref: 'main' submodules: 'recursive' fetch-depth: 0 - name: Checkout old content from tag into a temporary directory - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: ref: ${{ github.event.inputs.version_tag }} path: 'old_version_source' # Checkout into a temp subdir diff --git a/.github/workflows/deploy_versioned_docs.yaml b/.github/workflows/deploy_versioned_docs.yaml index 42d0bd1a20..67e809935e 100644 --- a/.github/workflows/deploy_versioned_docs.yaml +++ b/.github/workflows/deploy_versioned_docs.yaml @@ -30,7 +30,7 @@ jobs: cancel-in-progress: false steps: - name: Checkout Code at Tag - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: ref: ${{ github.event.release.tag_name }} diff --git a/.github/workflows/docs_preview_clean.yaml b/.github/workflows/docs_preview_clean.yaml index ba44bfcc8b..a3a1f07857 100644 --- a/.github/workflows/docs_preview_clean.yaml +++ b/.github/workflows/docs_preview_clean.yaml @@ -34,7 +34,7 @@ jobs: group: "preview-${{ github.event.number }}" cancel-in-progress: true steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: ref: versioned-gh-pages diff --git a/.github/workflows/docs_preview_deploy.yaml b/.github/workflows/docs_preview_deploy.yaml index 769b4c5dc5..fda0e4895f 100644 --- a/.github/workflows/docs_preview_deploy.yaml +++ b/.github/workflows/docs_preview_deploy.yaml @@ -49,7 +49,7 @@ jobs: group: "preview-${{ github.event.number }}" cancel-in-progress: true steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: # Checkout the PR's HEAD commit (supports forks). ref: ${{ github.event.pull_request.head.sha }} @@ -67,7 +67,7 @@ jobs: node-version: "22" - name: Cache dependencies - uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5 + uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5 with: path: ~/.npm key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} diff --git a/.github/workflows/link_checker_workflow.yaml b/.github/workflows/link_checker_workflow.yaml index 189016dbc4..ae51a4b08b 100644 --- a/.github/workflows/link_checker_workflow.yaml +++ b/.github/workflows/link_checker_workflow.yaml @@ -22,10 +22,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout Repository - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Restore lychee cache - uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5 + uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5 with: path: .lycheecache key: cache-lychee-${{ github.sha }} diff --git a/.github/workflows/publish-mcp.yml b/.github/workflows/publish-mcp.yml index dc84fbb759..32264b393c 100644 --- a/.github/workflows/publish-mcp.yml +++ b/.github/workflows/publish-mcp.yml @@ -29,7 +29,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Wait for image in Artifact Registry shell: bash From 80ef34621453b77bdf6a6016c354f102a17ada04 Mon Sep 17 00:00:00 2001 From: Haoyu Wang Date: Wed, 4 Feb 2026 15:51:14 -0500 Subject: [PATCH 3/6] feat(cli/skills): add support for generating agent skills from toolset (#2392) ## Description This PR introduces a new skills-generate command that enables users to generate standardized agent skills from their existing Toolbox tool configurations. This facilitates the integration of Toolbox tools into agentic workflows by automatically creating skill descriptions (SKILL.md) and executable wrappers. - New Subcommand: Implemented skills-generate, which automates the creation of agent skill packages including metadata and executable scripts. - Skill Generation: Added logic to generate SKILL.md files with parameter schemas and Node.js wrappers for cross-platform tool execution. - Toolset Integration: Supports selective generation of skills based on defined toolsets, including support for both local files and prebuilt configurations. - Testing: Added unit tests for the generation logic and integration tests for the CLI command. - Documentation: Created a new "how-to" guide for generating skills and updated the CLI reference documentation. --- cmd/root.go | 3 + cmd/skill_generate_test.go | 179 +++++++++++++ docs/en/how-to/generate_skill.md | 112 +++++++++ docs/en/how-to/invoke_tool.md | 5 +- docs/en/reference/cli.md | 35 ++- internal/cli/skills/command.go | 237 ++++++++++++++++++ internal/cli/skills/generator.go | 296 ++++++++++++++++++++++ internal/cli/skills/generator_test.go | 347 ++++++++++++++++++++++++++ internal/server/common_test.go | 135 ---------- internal/server/mocks.go | 159 ++++++++++++ 10 files changed, 1368 insertions(+), 140 deletions(-) create mode 100644 cmd/skill_generate_test.go create mode 100644 docs/en/how-to/generate_skill.md create mode 100644 internal/cli/skills/command.go create mode 100644 internal/cli/skills/generator.go create mode 100644 internal/cli/skills/generator_test.go create mode 100644 internal/server/mocks.go diff --git a/cmd/root.go b/cmd/root.go index d0d11e1a07..c4afedbbb2 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -35,6 +35,7 @@ import ( yaml "github.com/goccy/go-yaml" "github.com/googleapis/genai-toolbox/internal/auth" "github.com/googleapis/genai-toolbox/internal/cli/invoke" + "github.com/googleapis/genai-toolbox/internal/cli/skills" "github.com/googleapis/genai-toolbox/internal/embeddingmodels" "github.com/googleapis/genai-toolbox/internal/log" "github.com/googleapis/genai-toolbox/internal/prebuiltconfigs" @@ -401,6 +402,8 @@ func NewCommand(opts ...Option) *Command { // Register subcommands for tool invocation baseCmd.AddCommand(invoke.NewCommand(cmd)) + // Register subcommands for skill generation + baseCmd.AddCommand(skills.NewCommand(cmd)) return cmd } diff --git a/cmd/skill_generate_test.go b/cmd/skill_generate_test.go new file mode 100644 index 0000000000..3b91dc590b --- /dev/null +++ b/cmd/skill_generate_test.go @@ -0,0 +1,179 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestGenerateSkill(t *testing.T) { + // Create a temporary directory for tests + tmpDir := t.TempDir() + outputDir := filepath.Join(tmpDir, "skills") + + // Create a tools.yaml file with a sqlite tool + toolsFileContent := ` +sources: + my-sqlite: + kind: sqlite + database: test.db +tools: + hello-sqlite: + kind: sqlite-sql + source: my-sqlite + description: "hello tool" + statement: "SELECT 'hello' as greeting" +` + + toolsFilePath := filepath.Join(tmpDir, "tools.yaml") + if err := os.WriteFile(toolsFilePath, []byte(toolsFileContent), 0644); err != nil { + t.Fatalf("failed to write tools file: %v", err) + } + + args := []string{ + "skills-generate", + "--tools-file", toolsFilePath, + "--output-dir", outputDir, + "--name", "hello-sqlite", + "--description", "hello tool", + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + _, got, err := invokeCommandWithContext(ctx, args) + if err != nil { + t.Fatalf("command failed: %v\nOutput: %s", err, got) + } + + // Verify generated directory structure + skillPath := filepath.Join(outputDir, "hello-sqlite") + if _, err := os.Stat(skillPath); os.IsNotExist(err) { + t.Fatalf("skill directory not created: %s", skillPath) + } + + // Check SKILL.md + skillMarkdown := filepath.Join(skillPath, "SKILL.md") + content, err := os.ReadFile(skillMarkdown) + if err != nil { + t.Fatalf("failed to read SKILL.md: %v", err) + } + + expectedFrontmatter := `--- +name: hello-sqlite +description: hello tool +---` + if !strings.HasPrefix(string(content), expectedFrontmatter) { + t.Errorf("SKILL.md does not have expected frontmatter format.\nExpected prefix:\n%s\nGot:\n%s", expectedFrontmatter, string(content)) + } + + if !strings.Contains(string(content), "## Usage") { + t.Errorf("SKILL.md does not contain '## Usage' section") + } + + if !strings.Contains(string(content), "## Scripts") { + t.Errorf("SKILL.md does not contain '## Scripts' section") + } + + if !strings.Contains(string(content), "### hello-sqlite") { + t.Errorf("SKILL.md does not contain '### hello-sqlite' tool header") + } + + // Check script file + scriptFilename := "hello-sqlite.js" + scriptPath := filepath.Join(skillPath, "scripts", scriptFilename) + if _, err := os.Stat(scriptPath); os.IsNotExist(err) { + t.Fatalf("script file not created: %s", scriptPath) + } + + scriptContent, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatalf("failed to read script file: %v", err) + } + if !strings.Contains(string(scriptContent), "hello-sqlite") { + t.Errorf("script file does not contain expected tool name") + } + + // Check assets + assetPath := filepath.Join(skillPath, "assets", "hello-sqlite.yaml") + if _, err := os.Stat(assetPath); os.IsNotExist(err) { + t.Fatalf("asset file not created: %s", assetPath) + } + assetContent, err := os.ReadFile(assetPath) + if err != nil { + t.Fatalf("failed to read asset file: %v", err) + } + if !strings.Contains(string(assetContent), "hello-sqlite") { + t.Errorf("asset file does not contain expected tool name") + } +} + +func TestGenerateSkill_NoConfig(t *testing.T) { + tmpDir := t.TempDir() + outputDir := filepath.Join(tmpDir, "skills") + + args := []string{ + "skills-generate", + "--output-dir", outputDir, + "--name", "test", + "--description", "test", + } + + _, _, err := invokeCommandWithContext(context.Background(), args) + if err == nil { + t.Fatal("expected command to fail when no configuration is provided and tools.yaml is missing") + } + + // Should not have created the directory if no config was processed + if _, err := os.Stat(outputDir); !os.IsNotExist(err) { + t.Errorf("output directory should not have been created") + } +} + +func TestGenerateSkill_MissingArguments(t *testing.T) { + tmpDir := t.TempDir() + toolsFilePath := filepath.Join(tmpDir, "tools.yaml") + if err := os.WriteFile(toolsFilePath, []byte("tools: {}"), 0644); err != nil { + t.Fatalf("failed to write tools file: %v", err) + } + + tests := []struct { + name string + args []string + }{ + { + name: "missing name", + args: []string{"skills-generate", "--tools-file", toolsFilePath, "--description", "test"}, + }, + { + name: "missing description", + args: []string{"skills-generate", "--tools-file", toolsFilePath, "--name", "test"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, got, err := invokeCommandWithContext(context.Background(), tt.args) + if err == nil { + t.Fatalf("expected command to fail due to missing arguments, but it succeeded\nOutput: %s", got) + } + }) + } +} diff --git a/docs/en/how-to/generate_skill.md b/docs/en/how-to/generate_skill.md new file mode 100644 index 0000000000..7fa731e85b --- /dev/null +++ b/docs/en/how-to/generate_skill.md @@ -0,0 +1,112 @@ +--- +title: "Generate Agent Skills" +type: docs +weight: 10 +description: > + How to generate agent skills from a toolset. +--- + +The `skills-generate` command allows you to convert a **toolset** into an **Agent Skill**. A toolset is a collection of tools, and the generated skill will contain metadata and execution scripts for all tools within that toolset, complying with the [Agent Skill specification](https://agentskills.io/specification). + +## Before you begin + +1. Make sure you have the `toolbox` executable in your PATH. +2. Make sure you have [Node.js](https://nodejs.org/) installed on your system. + +## Generating a Skill from a Toolset + +A skill package consists of a `SKILL.md` file (with required YAML frontmatter) and a set of Node.js scripts. Each tool defined in your toolset maps to a corresponding script in the generated Node.js scripts (`.js`) that work across different platforms (Linux, macOS, Windows). + + +### Command Usage + +The basic syntax for the command is: + +```bash +toolbox skills-generate \ + --name \ + --toolset \ + --description \ + --output-dir +``` + +- ``: Can be `--tools-file`, `--tools-files`, `--tools-folder`, and `--prebuilt`. See the [CLI Reference](../reference/cli.md) for details. +- `--name`: Name of the generated skill. +- `--description`: Description of the generated skill. +- `--toolset`: (Optional) Name of the toolset to convert into a skill. If not provided, all tools will be included. +- `--output-dir`: (Optional) Directory to output generated skills (default: "skills"). + +{{< notice note >}} +**Note:** The `` must follow the Agent Skill [naming convention](https://agentskills.io/specification): it must contain only lowercase alphanumeric characters and hyphens, cannot start or end with a hyphen, and cannot contain consecutive hyphens (e.g., `my-skill`, `data-processing`). +{{< /notice >}} + +### Example: Custom Tools File + +1. Create a `tools.yaml` file with a toolset and some tools: + + ```yaml + tools: + tool_a: + description: "First tool" + run: + command: "echo 'Tool A'" + tool_b: + description: "Second tool" + run: + command: "echo 'Tool B'" + toolsets: + my_toolset: + tools: + - tool_a + - tool_b + ``` + +2. Generate the skill: + + ```bash + toolbox --tools-file tools.yaml skills-generate \ + --name "my-skill" \ + --toolset "my_toolset" \ + --description "A skill containing multiple tools" \ + --output-dir "generated-skills" + ``` + +3. The generated skill directory structure: + + ```text + generated-skills/ + └── my-skill/ + ├── SKILL.md + ├── assets/ + │ ├── tool_a.yaml + │ └── tool_b.yaml + └── scripts/ + ├── tool_a.js + └── tool_b.js + ``` + + In this example, the skill contains two Node.js scripts (`tool_a.js` and `tool_b.js`), each mapping to a tool in the original toolset. + +### Example: Prebuilt Configuration + +You can also generate skills from prebuilt toolsets: + +```bash +toolbox --prebuilt alloydb-postgres-admin skills-generate \ + --name "alloydb-postgres-admin" \ + --description "skill for performing administrative operations on alloydb" +``` + +## Installing the Generated Skill in Gemini CLI + +Once you have generated a skill, you can install it into the Gemini CLI using the `gemini skills install` command. + +### Installation Command + +Provide the path to the directory containing the generated skill: + +```bash +gemini skills install /path/to/generated-skills/my-skill +``` + +Alternatively, use ~/.gemini/skills as the `--output-dir` to generate the skill straight to the Gemini CLI. diff --git a/docs/en/how-to/invoke_tool.md b/docs/en/how-to/invoke_tool.md index 7448de13fa..4fc23d3a2c 100644 --- a/docs/en/how-to/invoke_tool.md +++ b/docs/en/how-to/invoke_tool.md @@ -20,14 +20,15 @@ The `invoke` command allows you to invoke tools defined in your configuration di 1. Make sure you have the `toolbox` binary installed or built. 2. Make sure you have a valid tool configuration file (e.g., `tools.yaml`). -## Basic Usage +### Command Usage The basic syntax for the command is: ```bash -toolbox [--tools-file | --prebuilt ] invoke [params] +toolbox invoke [params] ``` +- ``: Can be `--tools-file`, `--tools-files`, `--tools-folder`, and `--prebuilt`. See the [CLI Reference](../reference/cli.md) for details. - ``: The name of the tool you want to call. This must match the name defined in your `tools.yaml`. - `[params]`: (Optional) A JSON string representing the arguments for the tool. diff --git a/docs/en/reference/cli.md b/docs/en/reference/cli.md index 150171f3aa..11549c2830 100644 --- a/docs/en/reference/cli.md +++ b/docs/en/reference/cli.md @@ -32,7 +32,8 @@ description: > ## Sub Commands -### `invoke` +
+invoke Executes a tool directly with the provided parameters. This is useful for testing tool configurations and parameters without needing a full client setup. @@ -42,8 +43,36 @@ Executes a tool directly with the provided parameters. This is useful for testin toolbox invoke [params] ``` -- ``: The name of the tool to execute (as defined in your configuration). -- `[params]`: (Optional) A JSON string containing the parameters for the tool. +**Arguments:** + +- `tool-name`: The name of the tool to execute (as defined in your configuration). +- `params`: (Optional) A JSON string containing the parameters for the tool. + +For more detailed instructions, see [Invoke Tools via CLI](../how-to/invoke_tool.md). + +
+ +
+skills-generate + +Generates a skill package from a specified toolset. Each tool in the toolset will have a corresponding Node.js execution script in the generated skill. + +**Syntax:** + +```bash +toolbox skills-generate --name --description --toolset --output-dir +``` + +**Flags:** + +- `--name`: Name of the generated skill. +- `--description`: Description of the generated skill. +- `--toolset`: (Optional) Name of the toolset to convert into a skill. If not provided, all tools will be included. +- `--output-dir`: (Optional) Directory to output generated skills (default: "skills"). + +For more detailed instructions, see [Generate Agent Skills](../how-to/generate_skill.md). + +
## Examples diff --git a/internal/cli/skills/command.go b/internal/cli/skills/command.go new file mode 100644 index 0000000000..d8b2d286a9 --- /dev/null +++ b/internal/cli/skills/command.go @@ -0,0 +1,237 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package skills + +import ( + "context" + _ "embed" + "fmt" + "os" + "path/filepath" + "sort" + + "github.com/googleapis/genai-toolbox/internal/log" + "github.com/googleapis/genai-toolbox/internal/server" + "github.com/googleapis/genai-toolbox/internal/server/resources" + "github.com/googleapis/genai-toolbox/internal/tools" + + "github.com/spf13/cobra" +) + +// RootCommand defines the interface for required by skills-generate subcommand. +// This allows subcommands to access shared resources and functionality without +// direct coupling to the root command's implementation. +type RootCommand interface { + // Config returns a copy of the current server configuration. + Config() server.ServerConfig + + // LoadConfig loads and merges the configuration from files, folders, and prebuilts. + LoadConfig(ctx context.Context) error + + // Setup initializes the runtime environment, including logging and telemetry. + // It returns the updated context and a shutdown function to be called when finished. + Setup(ctx context.Context) (context.Context, func(context.Context) error, error) + + // Logger returns the logger instance. + Logger() log.Logger +} + +// Command is the command for generating skills. +type Command struct { + *cobra.Command + rootCmd RootCommand + name string + description string + toolset string + outputDir string +} + +// NewCommand creates a new Command. +func NewCommand(rootCmd RootCommand) *cobra.Command { + cmd := &Command{ + rootCmd: rootCmd, + } + cmd.Command = &cobra.Command{ + Use: "skills-generate", + Short: "Generate skills from tool configurations", + RunE: func(c *cobra.Command, args []string) error { + return cmd.run(c) + }, + } + + cmd.Flags().StringVar(&cmd.name, "name", "", "Name of the generated skill.") + cmd.Flags().StringVar(&cmd.description, "description", "", "Description of the generated skill") + cmd.Flags().StringVar(&cmd.toolset, "toolset", "", "Name of the toolset to convert into a skill. If not provided, all tools will be included.") + cmd.Flags().StringVar(&cmd.outputDir, "output-dir", "skills", "Directory to output generated skills") + + _ = cmd.MarkFlagRequired("name") + _ = cmd.MarkFlagRequired("description") + return cmd.Command +} + +func (c *Command) run(cmd *cobra.Command) error { + ctx, cancel := context.WithCancel(cmd.Context()) + defer cancel() + + ctx, shutdown, err := c.rootCmd.Setup(ctx) + if err != nil { + return err + } + defer func() { + _ = shutdown(ctx) + }() + + logger := c.rootCmd.Logger() + + // Load and merge tool configurations + if err := c.rootCmd.LoadConfig(ctx); err != nil { + return err + } + + if err := os.MkdirAll(c.outputDir, 0755); err != nil { + errMsg := fmt.Errorf("error creating output directory: %w", err) + logger.ErrorContext(ctx, errMsg.Error()) + return errMsg + } + + logger.InfoContext(ctx, fmt.Sprintf("Generating skill '%s'...", c.name)) + + // Initialize toolbox and collect tools + allTools, err := c.collectTools(ctx) + if err != nil { + errMsg := fmt.Errorf("error collecting tools: %w", err) + logger.ErrorContext(ctx, errMsg.Error()) + return errMsg + } + + if len(allTools) == 0 { + logger.InfoContext(ctx, "No tools found to generate.") + return nil + } + + // Generate the combined skill directory + skillPath := filepath.Join(c.outputDir, c.name) + if err := os.MkdirAll(skillPath, 0755); err != nil { + errMsg := fmt.Errorf("error creating skill directory: %w", err) + logger.ErrorContext(ctx, errMsg.Error()) + return errMsg + } + + // Generate assets directory + assetsPath := filepath.Join(skillPath, "assets") + if err := os.MkdirAll(assetsPath, 0755); err != nil { + errMsg := fmt.Errorf("error creating assets dir: %w", err) + logger.ErrorContext(ctx, errMsg.Error()) + return errMsg + } + + // Generate scripts directory + scriptsPath := filepath.Join(skillPath, "scripts") + if err := os.MkdirAll(scriptsPath, 0755); err != nil { + errMsg := fmt.Errorf("error creating scripts dir: %w", err) + logger.ErrorContext(ctx, errMsg.Error()) + return errMsg + } + + // Iterate over keys to ensure deterministic order + var toolNames []string + for name := range allTools { + toolNames = append(toolNames, name) + } + sort.Strings(toolNames) + + for _, toolName := range toolNames { + // Generate YAML config in asset directory + minimizedContent, err := generateToolConfigYAML(c.rootCmd.Config(), toolName) + if err != nil { + errMsg := fmt.Errorf("error generating filtered config for %s: %w", toolName, err) + logger.ErrorContext(ctx, errMsg.Error()) + return errMsg + } + + specificToolsFileName := fmt.Sprintf("%s.yaml", toolName) + if minimizedContent != nil { + destPath := filepath.Join(assetsPath, specificToolsFileName) + if err := os.WriteFile(destPath, minimizedContent, 0644); err != nil { + errMsg := fmt.Errorf("error writing filtered config for %s: %w", toolName, err) + logger.ErrorContext(ctx, errMsg.Error()) + return errMsg + } + } + + // Generate wrapper script in scripts directory + scriptContent, err := generateScriptContent(toolName, specificToolsFileName) + if err != nil { + errMsg := fmt.Errorf("error generating script content for %s: %w", toolName, err) + logger.ErrorContext(ctx, errMsg.Error()) + return errMsg + } + + scriptFilename := filepath.Join(scriptsPath, fmt.Sprintf("%s.js", toolName)) + if err := os.WriteFile(scriptFilename, []byte(scriptContent), 0755); err != nil { + errMsg := fmt.Errorf("error writing script %s: %w", scriptFilename, err) + logger.ErrorContext(ctx, errMsg.Error()) + return errMsg + } + } + + // Generate SKILL.md + skillContent, err := generateSkillMarkdown(c.name, c.description, allTools) + if err != nil { + errMsg := fmt.Errorf("error generating SKILL.md content: %w", err) + logger.ErrorContext(ctx, errMsg.Error()) + return errMsg + } + skillMdPath := filepath.Join(skillPath, "SKILL.md") + if err := os.WriteFile(skillMdPath, []byte(skillContent), 0644); err != nil { + errMsg := fmt.Errorf("error writing SKILL.md: %w", err) + logger.ErrorContext(ctx, errMsg.Error()) + return errMsg + } + + logger.InfoContext(ctx, fmt.Sprintf("Successfully generated skill '%s' with %d tools.", c.name, len(allTools))) + + return nil +} + +func (c *Command) collectTools(ctx context.Context) (map[string]tools.Tool, error) { + // Initialize Resources + sourcesMap, authServicesMap, embeddingModelsMap, toolsMap, toolsetsMap, promptsMap, promptsetsMap, err := server.InitializeConfigs(ctx, c.rootCmd.Config()) + if err != nil { + return nil, fmt.Errorf("failed to initialize resources: %w", err) + } + + resourceMgr := resources.NewResourceManager(sourcesMap, authServicesMap, embeddingModelsMap, toolsMap, toolsetsMap, promptsMap, promptsetsMap) + + result := make(map[string]tools.Tool) + + if c.toolset == "" { + return toolsMap, nil + } + + ts, ok := resourceMgr.GetToolset(c.toolset) + if !ok { + return nil, fmt.Errorf("toolset %q not found", c.toolset) + } + + for _, t := range ts.Tools { + if t != nil { + tool := *t + result[tool.McpManifest().Name] = tool + } + } + + return result, nil +} diff --git a/internal/cli/skills/generator.go b/internal/cli/skills/generator.go new file mode 100644 index 0000000000..a9e20fc9e3 --- /dev/null +++ b/internal/cli/skills/generator.go @@ -0,0 +1,296 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package skills + +import ( + "bytes" + "encoding/json" + "fmt" + "sort" + "strings" + "text/template" + + "github.com/goccy/go-yaml" + "github.com/googleapis/genai-toolbox/internal/server" + "github.com/googleapis/genai-toolbox/internal/sources" + "github.com/googleapis/genai-toolbox/internal/tools" + "github.com/googleapis/genai-toolbox/internal/util/parameters" +) + +const skillTemplate = `--- +name: {{.SkillName}} +description: {{.SkillDescription}} +--- + +## Usage + +All scripts can be executed using Node.js. Replace ` + "`" + `` + "`" + ` and ` + "`" + `` + "`" + ` with actual values. + +**Bash:** +` + "`" + `node scripts/.js '{"": ""}'` + "`" + ` + +**PowerShell:** +` + "`" + `node scripts/.js '{\"\": \"\"}'` + "`" + ` + +## Scripts + +{{range .Tools}} +### {{.Name}} + +{{.Description}} + +{{.ParametersSchema}} + +--- +{{end}} +` + +type toolTemplateData struct { + Name string + Description string + ParametersSchema string +} + +type skillTemplateData struct { + SkillName string + SkillDescription string + Tools []toolTemplateData +} + +// generateSkillMarkdown generates the content of the SKILL.md file. +// It includes usage instructions and a reference section for each tool in the skill, +// detailing its description and parameters. +func generateSkillMarkdown(skillName, skillDescription string, toolsMap map[string]tools.Tool) (string, error) { + var toolsData []toolTemplateData + + // Order tools based on name + var toolNames []string + for name := range toolsMap { + toolNames = append(toolNames, name) + } + sort.Strings(toolNames) + + for _, name := range toolNames { + tool := toolsMap[name] + manifest := tool.Manifest() + + parametersSchema, err := formatParameters(manifest.Parameters) + if err != nil { + return "", err + } + + toolsData = append(toolsData, toolTemplateData{ + Name: name, + Description: manifest.Description, + ParametersSchema: parametersSchema, + }) + } + + data := skillTemplateData{ + SkillName: skillName, + SkillDescription: skillDescription, + Tools: toolsData, + } + + tmpl, err := template.New("markdown").Parse(skillTemplate) + if err != nil { + return "", fmt.Errorf("error parsing markdown template: %w", err) + } + + var buf strings.Builder + if err := tmpl.Execute(&buf, data); err != nil { + return "", fmt.Errorf("error executing markdown template: %w", err) + } + + return buf.String(), nil +} + +const nodeScriptTemplate = `#!/usr/bin/env node + +const { spawn, execSync } = require('child_process'); +const path = require('path'); +const fs = require('fs'); + +const toolName = "{{.Name}}"; +const toolsFileName = "{{.ToolsFileName}}"; + +function getToolboxPath() { + try { + const checkCommand = process.platform === 'win32' ? 'where toolbox' : 'which toolbox'; + const globalPath = execSync(checkCommand, { stdio: 'pipe', encoding: 'utf-8' }).trim(); + if (globalPath) { + return globalPath.split('\n')[0].trim(); + } + } catch (e) { + // Ignore error; + } + const localPath = path.resolve(__dirname, '../../../toolbox'); + if (fs.existsSync(localPath)) { + return localPath; + } + throw new Error("Toolbox binary not found"); +} + +let toolboxBinary; +try { + toolboxBinary = getToolboxPath(); +} catch (err) { + console.error("Error:", err.message); + process.exit(1); +} + +let configArgs = []; +if (toolsFileName) { + configArgs.push("--tools-file", path.join(__dirname, "..", "assets", toolsFileName)); +} + +const args = process.argv.slice(2); +const toolboxArgs = [...configArgs, "invoke", toolName, ...args]; + +const child = spawn(toolboxBinary, toolboxArgs, { stdio: 'inherit' }); + +child.on('close', (code) => { + process.exit(code); +}); + +child.on('error', (err) => { + console.error("Error executing toolbox:", err); + process.exit(1); +}); +` + +type scriptData struct { + Name string + ToolsFileName string +} + +// generateScriptContent creates the content for a Node.js wrapper script. +// This script invokes the toolbox CLI with the appropriate configuration +// (using a generated tools file) and arguments to execute the specific tool. +func generateScriptContent(name string, toolsFileName string) (string, error) { + data := scriptData{ + Name: name, + ToolsFileName: toolsFileName, + } + + tmpl, err := template.New("script").Parse(nodeScriptTemplate) + if err != nil { + return "", fmt.Errorf("error parsing script template: %w", err) + } + + var buf strings.Builder + if err := tmpl.Execute(&buf, data); err != nil { + return "", fmt.Errorf("error executing script template: %w", err) + } + + return buf.String(), nil +} + +// formatParameters converts a list of parameter manifests into a formatted JSON schema string. +// This schema is used in the skill documentation to describe the input parameters for a tool. +func formatParameters(params []parameters.ParameterManifest) (string, error) { + if len(params) == 0 { + return "", nil + } + + properties := make(map[string]interface{}) + var required []string + + for _, p := range params { + paramMap := map[string]interface{}{ + "type": p.Type, + "description": p.Description, + } + if p.Default != nil { + paramMap["default"] = p.Default + } + properties[p.Name] = paramMap + if p.Required { + required = append(required, p.Name) + } + } + + schema := map[string]interface{}{ + "type": "object", + "properties": properties, + } + if len(required) > 0 { + schema["required"] = required + } + + schemaJSON, err := json.MarshalIndent(schema, "", " ") + if err != nil { + return "", fmt.Errorf("error generating parameters schema: %w", err) + } + + return fmt.Sprintf("#### Parameters\n\n```json\n%s\n```", string(schemaJSON)), nil +} + +// generateToolConfigYAML generates the YAML configuration for a single tool and its dependency (source). +// It extracts the relevant tool and source configurations from the server config and formats them +// into a YAML document suitable for inclusion in the skill's assets. +func generateToolConfigYAML(cfg server.ServerConfig, toolName string) ([]byte, error) { + toolCfg, ok := cfg.ToolConfigs[toolName] + if !ok { + return nil, fmt.Errorf("error finding tool config: %s", toolName) + } + + var buf bytes.Buffer + encoder := yaml.NewEncoder(&buf) + + // Process Tool Config + toolWrapper := struct { + Kind string `yaml:"kind"` + Config tools.ToolConfig `yaml:",inline"` + }{ + Kind: "tools", + Config: toolCfg, + } + + if err := encoder.Encode(toolWrapper); err != nil { + return nil, fmt.Errorf("error encoding tool config: %w", err) + } + + // Process Source Config + var toolMap map[string]interface{} + b, err := yaml.Marshal(toolCfg) + if err != nil { + return nil, fmt.Errorf("error marshaling tool config: %w", err) + } + if err := yaml.Unmarshal(b, &toolMap); err != nil { + return nil, fmt.Errorf("error unmarshaling tool config map: %w", err) + } + + if sourceName, ok := toolMap["source"].(string); ok && sourceName != "" { + sourceCfg, ok := cfg.SourceConfigs[sourceName] + if !ok { + return nil, fmt.Errorf("error finding source config: %s", sourceName) + } + + sourceWrapper := struct { + Kind string `yaml:"kind"` + Config sources.SourceConfig `yaml:",inline"` + }{ + Kind: "sources", + Config: sourceCfg, + } + + if err := encoder.Encode(sourceWrapper); err != nil { + return nil, fmt.Errorf("error encoding source config: %w", err) + } + } + + return buf.Bytes(), nil +} diff --git a/internal/cli/skills/generator_test.go b/internal/cli/skills/generator_test.go new file mode 100644 index 0000000000..bd3a462180 --- /dev/null +++ b/internal/cli/skills/generator_test.go @@ -0,0 +1,347 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package skills + +import ( + "context" + "strings" + "testing" + + "github.com/googleapis/genai-toolbox/internal/server" + "github.com/googleapis/genai-toolbox/internal/sources" + "github.com/googleapis/genai-toolbox/internal/tools" + "github.com/googleapis/genai-toolbox/internal/util/parameters" + "go.opentelemetry.io/otel/trace" +) + +type MockToolConfig struct { + Name string `yaml:"name"` + Type string `yaml:"type"` + Source string `yaml:"source"` + Other string `yaml:"other"` + Parameters parameters.Parameters `yaml:"parameters"` +} + +func (m MockToolConfig) ToolConfigType() string { + return m.Type +} + +func (m MockToolConfig) Initialize(map[string]sources.Source) (tools.Tool, error) { + return nil, nil +} + +type MockSourceConfig struct { + Name string `yaml:"name"` + Type string `yaml:"type"` + ConnectionString string `yaml:"connection_string"` +} + +func (m MockSourceConfig) SourceConfigType() string { + return m.Type +} + +func (m MockSourceConfig) Initialize(context.Context, trace.Tracer) (sources.Source, error) { + return nil, nil +} + +func TestFormatParameters(t *testing.T) { + tests := []struct { + name string + params []parameters.ParameterManifest + wantContains []string + wantErr bool + }{ + { + name: "empty parameters", + params: []parameters.ParameterManifest{}, + wantContains: []string{""}, + }, + { + name: "single required string parameter", + params: []parameters.ParameterManifest{ + { + Name: "param1", + Description: "A test parameter", + Type: "string", + Required: true, + }, + }, + wantContains: []string{ + "## Parameters", + "```json", + `"type": "object"`, + `"properties": {`, + `"param1": {`, + `"type": "string"`, + `"description": "A test parameter"`, + `"required": [`, + `"param1"`, + }, + }, + { + name: "mixed parameters with defaults", + params: []parameters.ParameterManifest{ + { + Name: "param1", + Description: "Param 1", + Type: "string", + Required: true, + }, + { + Name: "param2", + Description: "Param 2", + Type: "integer", + Default: 42, + Required: false, + }, + }, + wantContains: []string{ + `"param1": {`, + `"param2": {`, + `"default": 42`, + `"required": [`, + `"param1"`, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := formatParameters(tt.params) + if (err != nil) != tt.wantErr { + t.Errorf("formatParameters() error = %v, wantErr %v", err, tt.wantErr) + return + } + if tt.wantErr { + return + } + + if len(tt.params) == 0 { + if got != "" { + t.Errorf("formatParameters() = %v, want empty string", got) + } + return + } + + for _, want := range tt.wantContains { + if !strings.Contains(got, want) { + t.Errorf("formatParameters() result missing expected string: %s\nGot:\n%s", want, got) + } + } + }) + } +} + +func TestGenerateSkillMarkdown(t *testing.T) { + toolsMap := map[string]tools.Tool{ + "tool1": server.MockTool{ + Description: "First tool", + Params: []parameters.Parameter{ + parameters.NewStringParameter("p1", "d1"), + }, + }, + } + + got, err := generateSkillMarkdown("MySkill", "My Description", toolsMap) + if err != nil { + t.Fatalf("generateSkillMarkdown() error = %v", err) + } + + expectedSubstrings := []string{ + "name: MySkill", + "description: My Description", + "## Usage", + "All scripts can be executed using Node.js", + "**Bash:**", + "`node scripts/.js '{\"\": \"\"}'`", + "**PowerShell:**", + "`node scripts/.js '{\"\": \"\"}'`", + "## Scripts", + "### tool1", + "First tool", + "## Parameters", + } + + for _, s := range expectedSubstrings { + if !strings.Contains(got, s) { + t.Errorf("generateSkillMarkdown() missing substring %q", s) + } + } +} + +func TestGenerateScriptContent(t *testing.T) { + tests := []struct { + name string + toolName string + toolsFileName string + wantContains []string + }{ + { + name: "basic script", + toolName: "test-tool", + toolsFileName: "", + wantContains: []string{ + `const toolName = "test-tool";`, + `const toolsFileName = "";`, + `const toolboxArgs = [...configArgs, "invoke", toolName, ...args];`, + }, + }, + { + name: "script with tools file", + toolName: "complex-tool", + toolsFileName: "tools.yaml", + wantContains: []string{ + `const toolName = "complex-tool";`, + `const toolsFileName = "tools.yaml";`, + `configArgs.push("--tools-file", path.join(__dirname, "..", "assets", toolsFileName));`, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := generateScriptContent(tt.toolName, tt.toolsFileName) + if err != nil { + t.Fatalf("generateScriptContent() error = %v", err) + } + + for _, s := range tt.wantContains { + if !strings.Contains(got, s) { + t.Errorf("generateScriptContent() missing substring %q\nGot:\n%s", s, got) + } + } + }) + } +} + +func TestGenerateToolConfigYAML(t *testing.T) { + cfg := server.ServerConfig{ + ToolConfigs: server.ToolConfigs{ + "tool1": MockToolConfig{ + Name: "tool1", + Type: "custom-tool", + Source: "src1", + Other: "foo", + }, + "toolNoSource": MockToolConfig{ + Name: "toolNoSource", + Type: "http", + }, + "toolWithParams": MockToolConfig{ + Name: "toolWithParams", + Type: "custom-tool", + Parameters: []parameters.Parameter{ + parameters.NewStringParameter("param1", "desc1"), + }, + }, + "toolWithMissingSource": MockToolConfig{ + Name: "toolWithMissingSource", + Type: "custom-tool", + Source: "missing-src", + }, + }, + SourceConfigs: server.SourceConfigs{ + "src1": MockSourceConfig{ + Name: "src1", + Type: "postgres", + ConnectionString: "conn1", + }, + }, + } + + tests := []struct { + name string + toolName string + wantContains []string + wantErr bool + wantNil bool + }{ + { + name: "tool with source", + toolName: "tool1", + wantContains: []string{ + "kind: tools", + "name: tool1", + "type: custom-tool", + "source: src1", + "other: foo", + "---", + "kind: sources", + "name: src1", + "type: postgres", + "connection_string: conn1", + }, + }, + { + name: "tool without source", + toolName: "toolNoSource", + wantContains: []string{ + "kind: tools", + "name: toolNoSource", + "type: http", + }, + }, + { + name: "tool with parameters", + toolName: "toolWithParams", + wantContains: []string{ + "kind: tools", + "name: toolWithParams", + "type: custom-tool", + "parameters:", + "- name: param1", + "type: string", + "description: desc1", + }, + }, + { + name: "non-existent tool", + toolName: "missing-tool", + wantErr: true, + }, + { + name: "tool with missing source config", + toolName: "toolWithMissingSource", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotBytes, err := generateToolConfigYAML(cfg, tt.toolName) + if (err != nil) != tt.wantErr { + t.Errorf("generateToolConfigYAML() error = %v, wantErr %v", err, tt.wantErr) + return + } + if tt.wantErr { + return + } + + if tt.wantNil { + if gotBytes != nil { + t.Errorf("generateToolConfigYAML() expected nil, got %s", string(gotBytes)) + } + return + } + + got := string(gotBytes) + for _, want := range tt.wantContains { + if !strings.Contains(got, want) { + t.Errorf("generateToolConfigYAML() result missing expected string: %q\nGot:\n%s", want, got) + } + } + }) + } +} diff --git a/internal/server/common_test.go b/internal/server/common_test.go index 54109ac467..8944cfba20 100644 --- a/internal/server/common_test.go +++ b/internal/server/common_test.go @@ -24,7 +24,6 @@ import ( "testing" "github.com/go-chi/chi/v5" - "github.com/googleapis/genai-toolbox/internal/embeddingmodels" "github.com/googleapis/genai-toolbox/internal/log" "github.com/googleapis/genai-toolbox/internal/prompts" "github.com/googleapis/genai-toolbox/internal/server/resources" @@ -41,140 +40,6 @@ var ( _ prompts.Prompt = MockPrompt{} ) -// MockTool is used to mock tools in tests -type MockTool struct { - Name string - Description string - Params []parameters.Parameter - manifest tools.Manifest - unauthorized bool - requiresClientAuthrorization bool -} - -func (t MockTool) Invoke(context.Context, tools.SourceProvider, parameters.ParamValues, tools.AccessToken) (any, error) { - mock := []any{t.Name} - return mock, nil -} - -func (t MockTool) ToConfig() tools.ToolConfig { - return nil -} - -// claims is a map of user info decoded from an auth token -func (t MockTool) ParseParams(data map[string]any, claimsMap map[string]map[string]any) (parameters.ParamValues, error) { - return parameters.ParseParams(t.Params, data, claimsMap) -} - -func (t MockTool) EmbedParams(ctx context.Context, paramValues parameters.ParamValues, embeddingModelsMap map[string]embeddingmodels.EmbeddingModel) (parameters.ParamValues, error) { - return parameters.EmbedParams(ctx, t.Params, paramValues, embeddingModelsMap, nil) -} - -func (t MockTool) Manifest() tools.Manifest { - pMs := make([]parameters.ParameterManifest, 0, len(t.Params)) - for _, p := range t.Params { - pMs = append(pMs, p.Manifest()) - } - return tools.Manifest{Description: t.Description, Parameters: pMs} -} - -func (t MockTool) Authorized(verifiedAuthServices []string) bool { - // defaulted to true - return !t.unauthorized -} - -func (t MockTool) RequiresClientAuthorization(tools.SourceProvider) (bool, error) { - // defaulted to false - return t.requiresClientAuthrorization, nil -} - -func (t MockTool) GetParameters() parameters.Parameters { - return t.Params -} - -func (t MockTool) McpManifest() tools.McpManifest { - properties := make(map[string]parameters.ParameterMcpManifest) - required := make([]string, 0) - authParams := make(map[string][]string) - - for _, p := range t.Params { - name := p.GetName() - paramManifest, authParamList := p.McpManifest() - properties[name] = paramManifest - required = append(required, name) - - if len(authParamList) > 0 { - authParams[name] = authParamList - } - } - - toolsSchema := parameters.McpToolsSchema{ - Type: "object", - Properties: properties, - Required: required, - } - - mcpManifest := tools.McpManifest{ - Name: t.Name, - Description: t.Description, - InputSchema: toolsSchema, - } - - if len(authParams) > 0 { - mcpManifest.Metadata = map[string]any{ - "toolbox/authParams": authParams, - } - } - - return mcpManifest -} - -func (t MockTool) GetAuthTokenHeaderName(tools.SourceProvider) (string, error) { - return "Authorization", nil -} - -// MockPrompt is used to mock prompts in tests -type MockPrompt struct { - Name string - Description string - Args prompts.Arguments -} - -func (p MockPrompt) SubstituteParams(vals parameters.ParamValues) (any, error) { - return []prompts.Message{ - { - Role: "user", - Content: fmt.Sprintf("substituted %s", p.Name), - }, - }, nil -} - -func (p MockPrompt) ParseArgs(data map[string]any, claimsMap map[string]map[string]any) (parameters.ParamValues, error) { - var params parameters.Parameters - for _, arg := range p.Args { - params = append(params, arg.Parameter) - } - return parameters.ParseParams(params, data, claimsMap) -} - -func (p MockPrompt) Manifest() prompts.Manifest { - var argManifests []parameters.ParameterManifest - for _, arg := range p.Args { - argManifests = append(argManifests, arg.Manifest()) - } - return prompts.Manifest{ - Description: p.Description, - Arguments: argManifests, - } -} - -func (p MockPrompt) McpManifest() prompts.McpManifest { - return prompts.GetMcpManifest(p.Name, p.Description, p.Args) -} - -func (p MockPrompt) ToConfig() prompts.PromptConfig { - return nil -} - var tool1 = MockTool{ Name: "no_params", Params: []parameters.Parameter{}, diff --git a/internal/server/mocks.go b/internal/server/mocks.go new file mode 100644 index 0000000000..60aa4f6212 --- /dev/null +++ b/internal/server/mocks.go @@ -0,0 +1,159 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package server + +import ( + "context" + "fmt" + + "github.com/googleapis/genai-toolbox/internal/embeddingmodels" + "github.com/googleapis/genai-toolbox/internal/prompts" + "github.com/googleapis/genai-toolbox/internal/tools" + "github.com/googleapis/genai-toolbox/internal/util/parameters" +) + +// MockTool is used to mock tools in tests +type MockTool struct { + Name string + Description string + Params []parameters.Parameter + manifest tools.Manifest + unauthorized bool + requiresClientAuthrorization bool +} + +func (t MockTool) Invoke(context.Context, tools.SourceProvider, parameters.ParamValues, tools.AccessToken) (any, error) { + mock := []any{t.Name} + return mock, nil +} + +func (t MockTool) ToConfig() tools.ToolConfig { + return nil +} + +// claims is a map of user info decoded from an auth token +func (t MockTool) ParseParams(data map[string]any, claimsMap map[string]map[string]any) (parameters.ParamValues, error) { + return parameters.ParseParams(t.Params, data, claimsMap) +} + +func (t MockTool) EmbedParams(ctx context.Context, paramValues parameters.ParamValues, embeddingModelsMap map[string]embeddingmodels.EmbeddingModel) (parameters.ParamValues, error) { + return parameters.EmbedParams(ctx, t.Params, paramValues, embeddingModelsMap, nil) +} + +func (t MockTool) Manifest() tools.Manifest { + pMs := make([]parameters.ParameterManifest, 0, len(t.Params)) + for _, p := range t.Params { + pMs = append(pMs, p.Manifest()) + } + return tools.Manifest{Description: t.Description, Parameters: pMs} +} + +func (t MockTool) Authorized(verifiedAuthServices []string) bool { + // defaulted to true + return !t.unauthorized +} + +func (t MockTool) RequiresClientAuthorization(tools.SourceProvider) (bool, error) { + // defaulted to false + return t.requiresClientAuthrorization, nil +} + +func (t MockTool) GetParameters() parameters.Parameters { + return t.Params +} + +func (t MockTool) McpManifest() tools.McpManifest { + properties := make(map[string]parameters.ParameterMcpManifest) + required := make([]string, 0) + authParams := make(map[string][]string) + + for _, p := range t.Params { + name := p.GetName() + paramManifest, authParamList := p.McpManifest() + properties[name] = paramManifest + required = append(required, name) + + if len(authParamList) > 0 { + authParams[name] = authParamList + } + } + + toolsSchema := parameters.McpToolsSchema{ + Type: "object", + Properties: properties, + Required: required, + } + + mcpManifest := tools.McpManifest{ + Name: t.Name, + Description: t.Description, + InputSchema: toolsSchema, + } + + if len(authParams) > 0 { + mcpManifest.Metadata = map[string]any{ + "toolbox/authParams": authParams, + } + } + + return mcpManifest +} + +func (t MockTool) GetAuthTokenHeaderName(tools.SourceProvider) (string, error) { + return "Authorization", nil +} + +// MockPrompt is used to mock prompts in tests +type MockPrompt struct { + Name string + Description string + Args prompts.Arguments +} + +func (p MockPrompt) SubstituteParams(vals parameters.ParamValues) (any, error) { + return []prompts.Message{ + { + Role: "user", + Content: fmt.Sprintf("substituted %s", p.Name), + }, + }, nil +} + +func (p MockPrompt) ParseArgs(data map[string]any, claimsMap map[string]map[string]any) (parameters.ParamValues, error) { + var params parameters.Parameters + for _, arg := range p.Args { + params = append(params, arg.Parameter) + } + return parameters.ParseParams(params, data, claimsMap) +} + +func (p MockPrompt) Manifest() prompts.Manifest { + var argManifests []parameters.ParameterManifest + for _, arg := range p.Args { + argManifests = append(argManifests, arg.Manifest()) + } + return prompts.Manifest{ + Description: p.Description, + Arguments: argManifests, + } +} + +func (p MockPrompt) McpManifest() prompts.McpManifest { + return prompts.GetMcpManifest(p.Name, p.Description, p.Args) +} + +func (p MockPrompt) ToConfig() prompts.PromptConfig { + return nil +} From 5e4f2a131f85613d4d5e82f75df1e1468948e1ec Mon Sep 17 00:00:00 2001 From: Averi Kitsch Date: Fri, 6 Feb 2026 10:01:58 -0800 Subject: [PATCH 4/6] docs: add managed connection pooling to docs (#2425) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description > Should include a concise description of the changes (bug or feature), it's > impact, along with a summary of the solution ## PR Checklist > Thank you for opening a Pull Request! Before submitting your PR, there are a > few things you can do to make sure it goes smoothly: - [ ] Make sure you reviewed [CONTRIBUTING.md](https://github.com/googleapis/genai-toolbox/blob/main/CONTRIBUTING.md) - [ ] Make sure to open an issue as a [bug/issue](https://github.com/googleapis/genai-toolbox/issues/new/choose) before writing your code! That way we can discuss the change, evaluate designs, and agree on the general idea - [ ] Ensure the tests and linter pass - [ ] Code coverage does not decrease (if any source code was changed) - [ ] Appropriate docs were updated (if necessary) - [ ] Make sure to add `!` if this involve a breaking change 🛠️ Fixes # --- docs/en/resources/sources/alloydb-pg.md | 9 +++++++++ docs/en/resources/sources/cloud-sql-pg.md | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/docs/en/resources/sources/alloydb-pg.md b/docs/en/resources/sources/alloydb-pg.md index cf6d848ae6..b7fe99c759 100644 --- a/docs/en/resources/sources/alloydb-pg.md +++ b/docs/en/resources/sources/alloydb-pg.md @@ -194,6 +194,15 @@ Use environment variable replacement with the format ${ENV_NAME} instead of hardcoding your secrets into the configuration file. {{< /notice >}} +### Managed Connection Pooling + +Toolbox automatically supports [Managed Connection Pooling][alloydb-mcp]. If your AlloyDB instance has Managed Connection Pooling enabled, the connection will immediately benefit from increased throughput and reduced latency. + +The interface is identical, so there's no additional configuration required on the client. For more information on configuring your instance, see the [AlloyDB Managed Connection Pooling documentation][alloydb-mcp-docs]. + +[alloydb-mcp]: https://cloud.google.com/blog/products/databases/alloydb-managed-connection-pooling +[alloydb-mcp-docs]: https://cloud.google.com/alloydb/docs/configure-managed-connection-pooling + ## Reference | **field** | **type** | **required** | **description** | diff --git a/docs/en/resources/sources/cloud-sql-pg.md b/docs/en/resources/sources/cloud-sql-pg.md index 3b5f54781d..182b54e914 100644 --- a/docs/en/resources/sources/cloud-sql-pg.md +++ b/docs/en/resources/sources/cloud-sql-pg.md @@ -195,6 +195,15 @@ Use environment variable replacement with the format ${ENV_NAME} instead of hardcoding your secrets into the configuration file. {{< /notice >}} +### Managed Connection Pooling + +Toolbox automatically supports [Managed Connection Pooling][csql-mcp]. If your Cloud SQL for PostgreSQL instance has Managed Connection Pooling enabled, the connection will immediately benefit from increased throughput and reduced latency. + +The interface is identical, so there's no additional configuration required on the client. For more information on configuring your instance, see the [Cloud SQL Managed Connection Pooling documentation][csql-mcp-docs]. + +[csql-mcp]: https://docs.cloud.google.com/sql/docs/postgres/managed-connection-pooling +[csql-mcp-docs]: https://docs.cloud.google.com/sql/docs/postgres/configure-mcp + ## Reference | **field** | **type** | **required** | **description** | From a15a12873f936b0102aeb9500cc3bcd71bb38c34 Mon Sep 17 00:00:00 2001 From: "Dr. Strangelove" Date: Fri, 6 Feb 2026 17:17:45 -0500 Subject: [PATCH 5/6] feat(tools/looker): Validate project tool (#2430) ## Description Validate changes to a LookML project and report LookML errors. ## PR Checklist > Thank you for opening a Pull Request! Before submitting your PR, there are a > few things you can do to make sure it goes smoothly: - [x] Make sure you reviewed [CONTRIBUTING.md](https://github.com/googleapis/genai-toolbox/blob/main/CONTRIBUTING.md) - [x] Make sure to open an issue as a [bug/issue](https://github.com/googleapis/genai-toolbox/issues/new/choose) before writing your code! That way we can discuss the change, evaluate designs, and agree on the general idea - [x] Ensure the tests and linter pass - [x] Code coverage does not decrease (if any source code was changed) - [x] Appropriate docs were updated (if necessary) - [x] Make sure to add `!` if this involve a breaking change --- .lycheeignore | 3 +- cmd/root.go | 1 + cmd/root_test.go | 2 +- docs/en/reference/prebuilt-tools.md | 1 + .../tools/looker/looker-validate-project.md | 47 +++++ internal/prebuiltconfigs/tools/looker.yaml | 16 ++ .../lookervalidateproject.go | 177 ++++++++++++++++++ .../lookervalidateproject_test.go | 109 +++++++++++ tests/looker/looker_integration_test.go | 25 +++ 9 files changed, 379 insertions(+), 2 deletions(-) create mode 100644 docs/en/resources/tools/looker/looker-validate-project.md create mode 100644 internal/tools/looker/lookervalidateproject/lookervalidateproject.go create mode 100644 internal/tools/looker/lookervalidateproject/lookervalidateproject_test.go diff --git a/.lycheeignore b/.lycheeignore index baec3c4449..9ce08c5e6a 100644 --- a/.lycheeignore +++ b/.lycheeignore @@ -37,8 +37,9 @@ https://dev.mysql.com/doc/refman/8.4/en/sql-prepared-statements.html https://dev.mysql.com/doc/refman/8.4/en/user-names.html # npmjs links can occasionally trigger rate limiting during high-frequency CI builds -https://www.npmjs.com/package/@toolbox-sdk/core https://www.npmjs.com/package/@toolbox-sdk/adk +https://www.npmjs.com/package/@toolbox-sdk/core +https://www.npmjs.com/package/@toolbox-sdk/server https://www.oceanbase.com/ # Ignore social media and blog profiles to reduce external request overhead diff --git a/cmd/root.go b/cmd/root.go index c4afedbbb2..5e59997211 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -163,6 +163,7 @@ import ( _ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookerrundashboard" _ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookerrunlook" _ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookerupdateprojectfile" + _ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookervalidateproject" _ "github.com/googleapis/genai-toolbox/internal/tools/mindsdb/mindsdbexecutesql" _ "github.com/googleapis/genai-toolbox/internal/tools/mindsdb/mindsdbsql" _ "github.com/googleapis/genai-toolbox/internal/tools/mongodb/mongodbaggregate" diff --git a/cmd/root_test.go b/cmd/root_test.go index 3c55e83d93..f26bd1706a 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -2370,7 +2370,7 @@ func TestPrebuiltTools(t *testing.T) { wantToolset: server.ToolsetConfigs{ "looker_tools": tools.ToolsetConfig{ Name: "looker_tools", - ToolNames: []string{"get_models", "get_explores", "get_dimensions", "get_measures", "get_filters", "get_parameters", "query", "query_sql", "query_url", "get_looks", "run_look", "make_look", "get_dashboards", "run_dashboard", "make_dashboard", "add_dashboard_element", "add_dashboard_filter", "generate_embed_url", "health_pulse", "health_analyze", "health_vacuum", "dev_mode", "get_projects", "get_project_files", "get_project_file", "create_project_file", "update_project_file", "delete_project_file", "get_connections", "get_connection_schemas", "get_connection_databases", "get_connection_tables", "get_connection_table_columns"}, + ToolNames: []string{"get_models", "get_explores", "get_dimensions", "get_measures", "get_filters", "get_parameters", "query", "query_sql", "query_url", "get_looks", "run_look", "make_look", "get_dashboards", "run_dashboard", "make_dashboard", "add_dashboard_element", "add_dashboard_filter", "generate_embed_url", "health_pulse", "health_analyze", "health_vacuum", "dev_mode", "get_projects", "get_project_files", "get_project_file", "create_project_file", "update_project_file", "delete_project_file", "validate_project", "get_connections", "get_connection_schemas", "get_connection_databases", "get_connection_tables", "get_connection_table_columns"}, }, }, }, diff --git a/docs/en/reference/prebuilt-tools.md b/docs/en/reference/prebuilt-tools.md index d8a2e806b3..7a52236dfa 100644 --- a/docs/en/reference/prebuilt-tools.md +++ b/docs/en/reference/prebuilt-tools.md @@ -488,6 +488,7 @@ See [Usage Examples](../reference/cli.md#examples). * `create_project_file`: Create a new LookML file. * `update_project_file`: Update an existing LookML file. * `delete_project_file`: Delete a LookML file. + * `validate_project`: Check the syntax of a LookML project. * `get_connections`: Get the available connections in a Looker instance. * `get_connection_schemas`: Get the available schemas in a connection. * `get_connection_databases`: Get the available databases in a connection. diff --git a/docs/en/resources/tools/looker/looker-validate-project.md b/docs/en/resources/tools/looker/looker-validate-project.md new file mode 100644 index 0000000000..956588b11d --- /dev/null +++ b/docs/en/resources/tools/looker/looker-validate-project.md @@ -0,0 +1,47 @@ +--- +title: "looker-validate-project" +type: docs +weight: 1 +description: > + A "looker-validate-project" tool checks the syntax of a LookML project and reports any errors +aliases: +- /resources/tools/looker-validate-project +--- + +## About + +A "looker-validate-project" tool checks the syntax of a LookML project and reports any errors + +It's compatible with the following sources: + +- [looker](../../sources/looker.md) + +`looker-validate-project` accepts a project_id parameter. + +## Example + +```yaml +tools: + validate_project: + kind: looker-validate-project + source: looker-source + description: | + This tool checks a LookML project for syntax errors. + + Prerequisite: The Looker session must be in Development Mode. Use `dev_mode: true` first. + + Parameters: + - project_id (required): The unique ID of the LookML project. + + Output: + A list of error details including the file path and line number, and also a list of models + that are not currently valid due to LookML errors. +``` + +## Reference + +| **field** | **type** | **required** | **description** | +|-------------|:--------:|:------------:|----------------------------------------------------| +| kind | string | true | Must be "looker-validate-project". | +| source | string | true | Name of the source Looker instance. | +| description | string | true | Description of the tool that is passed to the LLM. | diff --git a/internal/prebuiltconfigs/tools/looker.yaml b/internal/prebuiltconfigs/tools/looker.yaml index 442cd11106..c6bbd51c56 100644 --- a/internal/prebuiltconfigs/tools/looker.yaml +++ b/internal/prebuiltconfigs/tools/looker.yaml @@ -959,6 +959,21 @@ tools: Output: A confirmation message upon successful file deletion. + validate_project: + kind: looker-validate-project + source: looker-source + description: | + This tool checks a LookML project for syntax errors. + + Prerequisite: The Looker session must be in Development Mode. Use `dev_mode: true` first. + + Parameters: + - project_id (required): The unique ID of the LookML project. + + Output: + A list of error details including the file path and line number, and also a list of models + that are not currently valid due to LookML errors. + get_connections: kind: looker-get-connections source: looker-source @@ -1072,6 +1087,7 @@ toolsets: - create_project_file - update_project_file - delete_project_file + - validate_project - get_connections - get_connection_schemas - get_connection_databases diff --git a/internal/tools/looker/lookervalidateproject/lookervalidateproject.go b/internal/tools/looker/lookervalidateproject/lookervalidateproject.go new file mode 100644 index 0000000000..e36c3a4dd2 --- /dev/null +++ b/internal/tools/looker/lookervalidateproject/lookervalidateproject.go @@ -0,0 +1,177 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package lookervalidateproject + +import ( + "context" + "fmt" + + yaml "github.com/goccy/go-yaml" + "github.com/googleapis/genai-toolbox/internal/embeddingmodels" + "github.com/googleapis/genai-toolbox/internal/sources" + "github.com/googleapis/genai-toolbox/internal/tools" + "github.com/googleapis/genai-toolbox/internal/util" + "github.com/googleapis/genai-toolbox/internal/util/parameters" + + "github.com/looker-open-source/sdk-codegen/go/rtl" + v4 "github.com/looker-open-source/sdk-codegen/go/sdk/v4" +) + +const resourceType string = "looker-validate-project" + +func init() { + if !tools.Register(resourceType, newConfig) { + panic(fmt.Sprintf("tool type %q already registered", resourceType)) + } +} + +func newConfig(ctx context.Context, name string, decoder *yaml.Decoder) (tools.ToolConfig, error) { + actual := Config{Name: name} + if err := decoder.DecodeContext(ctx, &actual); err != nil { + return nil, err + } + return actual, nil +} + +type compatibleSource interface { + UseClientAuthorization() bool + GetAuthTokenHeaderName() string + LookerApiSettings() *rtl.ApiSettings + GetLookerSDK(string) (*v4.LookerSDK, error) +} + +type Config struct { + Name string `yaml:"name" validate:"required"` + Type string `yaml:"type" validate:"required"` + Source string `yaml:"source" validate:"required"` + Description string `yaml:"description" validate:"required"` + AuthRequired []string `yaml:"authRequired"` + Annotations *tools.ToolAnnotations `yaml:"annotations,omitempty"` +} + +// validate interface +var _ tools.ToolConfig = Config{} + +func (cfg Config) ToolConfigType() string { + return resourceType +} + +func (cfg Config) Initialize(srcs map[string]sources.Source) (tools.Tool, error) { + projectIdParameter := parameters.NewStringParameter("project_id", "The id of the project to validate") + params := parameters.Parameters{projectIdParameter} + + annotations := cfg.Annotations + if annotations == nil { + readOnlyHint := true + annotations = &tools.ToolAnnotations{ + ReadOnlyHint: &readOnlyHint, + } + } + + mcpManifest := tools.GetMcpManifest(cfg.Name, cfg.Description, cfg.AuthRequired, params, annotations) + + // finish tool setup + return Tool{ + Config: cfg, + Parameters: params, + manifest: tools.Manifest{ + Description: cfg.Description, + Parameters: params.Manifest(), + AuthRequired: cfg.AuthRequired, + }, + mcpManifest: mcpManifest, + }, nil +} + +// validate interface +var _ tools.Tool = Tool{} + +type Tool struct { + Config + Parameters parameters.Parameters `yaml:"parameters"` + manifest tools.Manifest + mcpManifest tools.McpManifest +} + +func (t Tool) ToConfig() tools.ToolConfig { + return t.Config +} + +func (t Tool) Invoke(ctx context.Context, resourceMgr tools.SourceProvider, params parameters.ParamValues, accessToken tools.AccessToken) (any, error) { + source, err := tools.GetCompatibleSource[compatibleSource](resourceMgr, t.Source, t.Name, t.Type) + if err != nil { + return nil, err + } + + logger, err := util.LoggerFromContext(ctx) + if err != nil { + return nil, fmt.Errorf("unable to get logger from ctx: %s", err) + } + + sdk, err := source.GetLookerSDK(string(accessToken)) + if err != nil { + return nil, fmt.Errorf("error getting sdk: %w", err) + } + + mapParams := params.AsMap() + projectId, ok := mapParams["project_id"].(string) + if !ok { + return nil, fmt.Errorf("'project_id' must be a string, got %T", mapParams["project_id"]) + } + + resp, err := sdk.ValidateProject(projectId, "", source.LookerApiSettings()) + if err != nil { + return nil, fmt.Errorf("error making validate_project request: %w", err) + } + + logger.DebugContext(ctx, "Got response of %v\n", resp) + + return resp, nil +} + +func (t Tool) EmbedParams(ctx context.Context, paramValues parameters.ParamValues, embeddingModelsMap map[string]embeddingmodels.EmbeddingModel) (parameters.ParamValues, error) { + return parameters.EmbedParams(ctx, t.Parameters, paramValues, embeddingModelsMap, nil) +} + +func (t Tool) Manifest() tools.Manifest { + return t.manifest +} + +func (t Tool) McpManifest() tools.McpManifest { + return t.mcpManifest +} + +func (t Tool) Authorized(verifiedAuthServices []string) bool { + return tools.IsAuthorized(t.AuthRequired, verifiedAuthServices) +} + +func (t Tool) RequiresClientAuthorization(resourceMgr tools.SourceProvider) (bool, error) { + source, err := tools.GetCompatibleSource[compatibleSource](resourceMgr, t.Source, t.Name, t.Type) + if err != nil { + return false, err + } + return source.UseClientAuthorization(), nil +} + +func (t Tool) GetAuthTokenHeaderName(resourceMgr tools.SourceProvider) (string, error) { + source, err := tools.GetCompatibleSource[compatibleSource](resourceMgr, t.Source, t.Name, t.Type) + if err != nil { + return "", err + } + return source.GetAuthTokenHeaderName(), nil +} + +func (t Tool) GetParameters() parameters.Parameters { + return t.Parameters +} diff --git a/internal/tools/looker/lookervalidateproject/lookervalidateproject_test.go b/internal/tools/looker/lookervalidateproject/lookervalidateproject_test.go new file mode 100644 index 0000000000..c71721ac9d --- /dev/null +++ b/internal/tools/looker/lookervalidateproject/lookervalidateproject_test.go @@ -0,0 +1,109 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package lookervalidateproject_test + +import ( + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/googleapis/genai-toolbox/internal/server" + "github.com/googleapis/genai-toolbox/internal/testutils" + lkr "github.com/googleapis/genai-toolbox/internal/tools/looker/lookervalidateproject" +) + +func TestParseFromYamlLookerValidateProject(t *testing.T) { + ctx, err := testutils.ContextWithNewLogger() + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + tcs := []struct { + desc string + in string + want server.ToolConfigs + }{ + { + desc: "basic example", + in: ` + kind: tools + name: example_tool + type: looker-validate-project + source: my-instance + description: some description + `, + want: server.ToolConfigs{ + "example_tool": lkr.Config{ + Name: "example_tool", + Type: "looker-validate-project", + Source: "my-instance", + Description: "some description", + AuthRequired: []string{}, + }, + }, + }, + } + for _, tc := range tcs { + t.Run(tc.desc, func(t *testing.T) { + // Parse contents + _, _, _, got, _, _, err := server.UnmarshalResourceConfig(ctx, testutils.FormatYaml(tc.in)) + if err != nil { + t.Fatalf("unable to unmarshal: %s", err) + } + if diff := cmp.Diff(tc.want, got); diff != "" { + t.Fatalf("incorrect parse: diff %v", diff) + } + }) + } + +} + +func TestFailParseFromYamlLookerValidateProject(t *testing.T) { + ctx, err := testutils.ContextWithNewLogger() + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + tcs := []struct { + desc string + in string + err string + }{ + { + desc: "Invalid method", + in: ` + kind: tools + name: example_tool + type: looker-validate-project + source: my-instance + method: GOT + description: some description + `, + err: "error unmarshaling tools: unable to parse tool \"example_tool\" as type \"looker-validate-project\": [3:1] unknown field \"method\"\n 1 | authRequired: []\n 2 | description: some description\n> 3 | method: GOT\n ^\n 4 | name: example_tool\n 5 | source: my-instance\n 6 | type: looker-validate-project", + }, + } + for _, tc := range tcs { + t.Run(tc.desc, func(t *testing.T) { + // Parse contents + _, _, _, _, _, _, err := server.UnmarshalResourceConfig(ctx, testutils.FormatYaml(tc.in)) + if err == nil { + t.Fatalf("expect parsing to fail") + } + errStr := err.Error() + if !strings.Contains(errStr, tc.err) { + t.Fatalf("unexpected error string: got %q, want substring %q", errStr, tc.err) + } + }) + } + +} diff --git a/tests/looker/looker_integration_test.go b/tests/looker/looker_integration_test.go index 944cbcb926..3aa795683e 100644 --- a/tests/looker/looker_integration_test.go +++ b/tests/looker/looker_integration_test.go @@ -222,6 +222,11 @@ func TestLooker(t *testing.T) { "source": "my-instance", "description": "Simple tool to test end to end functionality.", }, + "validate_project": map[string]any{ + "type": "looker-validate-project", + "source": "my-instance", + "description": "Simple tool to test end to end functionality.", + }, "generate_embed_url": map[string]any{ "type": "looker-generate-embed-url", "source": "my-instance", @@ -1446,6 +1451,23 @@ func TestLooker(t *testing.T) { }, }, ) + tests.RunToolGetTestByName(t, "validate_project", + map[string]any{ + "validate_project": map[string]any{ + "description": "Simple tool to test end to end functionality.", + "authRequired": []any{}, + "parameters": []any{ + map[string]any{ + "authSources": []any{}, + "description": "The id of the project to validate", + "name": "project_id", + "required": true, + "type": "string", + }, + }, + }, + }, + ) tests.RunToolGetTestByName(t, "generate_embed_url", map[string]any{ "generate_embed_url": map[string]any{ @@ -1665,6 +1687,9 @@ func TestLooker(t *testing.T) { wantResult = "deleted" tests.RunToolInvokeParametersTest(t, "delete_project_file", []byte(`{"project_id": "the_look", "file_path": "foo.view.lkml"}`), wantResult) + wantResult = "\"errors\":[]" + tests.RunToolInvokeParametersTest(t, "validate_project", []byte(`{"project_id": "the_look"}`), wantResult) + wantResult = "production" tests.RunToolInvokeParametersTest(t, "dev_mode", []byte(`{"devMode": false}`), wantResult) From bb40cdf78cc168a6ba5f10197b39ba35cb850ce6 Mon Sep 17 00:00:00 2001 From: Twisha Bansal <58483338+twishabansal@users.noreply.github.com> Date: Mon, 9 Feb 2026 11:05:29 +0530 Subject: [PATCH 6/6] refactor: consolidate quickstart tests into universal runner (#2413) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Tested locally. Last successful runs: - Python: https://pantheon.corp.google.com/cloud-build/builds;region=global/e86608d7-b9df-46b3-b8e6-60f0d6144b59;step=0?e=13802955&mods=-autopush_coliseum&project=toolbox-testing-438616 - JS: https://pantheon.corp.google.com/cloud-build/builds;region=global/7d8803fb-3e4e-4d0b-8b60-44f0c7962132?e=13802955&mods=-autopush_coliseum&project=toolbox-testing-438616 - Go: https://pantheon.corp.google.com/cloud-build/builds;region=global/a0d0d693-ec14-422f-aa62-b0ae664005ff?e=13802955&mods=-autopush_coliseum&project=toolbox-testing-438616 Note: After merging change path to cloud build yaml files for all these triggers: quickstart-python, quickstart-python-test-on-merge and same for JS and Go. ## PR Checklist > Thank you for opening a Pull Request! Before submitting your PR, there are a > few things you can do to make sure it goes smoothly: - [ ] Make sure you reviewed [CONTRIBUTING.md](https://github.com/googleapis/genai-toolbox/blob/main/CONTRIBUTING.md) - [ ] Make sure to open an issue as a [bug/issue](https://github.com/googleapis/genai-toolbox/issues/new/choose) before writing your code! That way we can discuss the change, evaluate designs, and agree on the general idea - [ ] Ensure the tests and linter pass - [ ] Code coverage does not decrease (if any source code was changed) - [ ] Appropriate docs were updated (if necessary) - [ ] Make sure to add `!` if this involve a breaking change 🛠️ Fixes # --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: Yuan Teoh <45984206+Yuan325@users.noreply.github.com> --- .ci/quickstart_test/run_go_tests.sh | 125 ----------- .ci/quickstart_test/run_js_tests.sh | 125 ----------- .ci/quickstart_test/run_py_tests.sh | 115 ---------- .../go.integration.cloudbuild.yaml | 9 +- .../js.integration.cloudbuild.yaml | 9 +- .../py.integration.cloudbuild.yaml | 9 +- .ci/sample_tests/run_tests.sh | 202 ++++++++++++++++++ .../setup_hotels.sql} | 0 8 files changed, 223 insertions(+), 371 deletions(-) delete mode 100644 .ci/quickstart_test/run_go_tests.sh delete mode 100644 .ci/quickstart_test/run_js_tests.sh delete mode 100644 .ci/quickstart_test/run_py_tests.sh rename .ci/{quickstart_test => sample_tests/quickstart}/go.integration.cloudbuild.yaml (84%) rename .ci/{quickstart_test => sample_tests/quickstart}/js.integration.cloudbuild.yaml (84%) rename .ci/{quickstart_test => sample_tests/quickstart}/py.integration.cloudbuild.yaml (84%) create mode 100644 .ci/sample_tests/run_tests.sh rename .ci/{quickstart_test/setup_hotels_sample.sql => sample_tests/setup_hotels.sql} (100%) diff --git a/.ci/quickstart_test/run_go_tests.sh b/.ci/quickstart_test/run_go_tests.sh deleted file mode 100644 index 43874931b2..0000000000 --- a/.ci/quickstart_test/run_go_tests.sh +++ /dev/null @@ -1,125 +0,0 @@ -# Copyright 2025 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -#!/bin/bash - -set -e - -TABLE_NAME="hotels_go" -QUICKSTART_GO_DIR="docs/en/getting-started/quickstart/go" -SQL_FILE=".ci/quickstart_test/setup_hotels_sample.sql" - -PROXY_PID="" -TOOLBOX_PID="" - -install_system_packages() { - apt-get update && apt-get install -y \ - postgresql-client \ - wget \ - gettext-base \ - netcat-openbsd -} - -start_cloud_sql_proxy() { - wget "https://storage.googleapis.com/cloud-sql-connectors/cloud-sql-proxy/v2.10.0/cloud-sql-proxy.linux.amd64" -O /usr/local/bin/cloud-sql-proxy - chmod +x /usr/local/bin/cloud-sql-proxy - cloud-sql-proxy "${CLOUD_SQL_INSTANCE}" & - PROXY_PID=$! - - for i in {1..30}; do - if nc -z 127.0.0.1 5432; then - echo "Cloud SQL Proxy is up and running." - return - fi - sleep 1 - done - - echo "Cloud SQL Proxy failed to start within the timeout period." - exit 1 -} - -setup_toolbox() { - TOOLBOX_YAML="/tools.yaml" - echo "${TOOLS_YAML_CONTENT}" > "$TOOLBOX_YAML" - if [ ! -f "$TOOLBOX_YAML" ]; then echo "Failed to create tools.yaml"; exit 1; fi - wget "https://storage.googleapis.com/genai-toolbox/v${VERSION}/linux/amd64/toolbox" -O "/toolbox" - chmod +x "/toolbox" - /toolbox --tools-file "$TOOLBOX_YAML" & - TOOLBOX_PID=$! - sleep 2 -} - -setup_orch_table() { - export TABLE_NAME - envsubst < "$SQL_FILE" | psql -h "$PGHOST" -p "$PGPORT" -U "$DB_USER" -d "$DATABASE_NAME" -} - -run_orch_test() { - local orch_dir="$1" - local orch_name - orch_name=$(basename "$orch_dir") - - if [ "$orch_name" == "openAI" ]; then - echo -e "\nSkipping framework '${orch_name}': Temporarily excluded." - return - fi - - ( - set -e - setup_orch_table - - echo "--- Preparing module for $orch_name ---" - cd "$orch_dir" - - if [ -f "go.mod" ]; then - go mod tidy - fi - - cd .. - - export ORCH_NAME="$orch_name" - - echo "--- Running tests for $orch_name ---" - go test -v ./... - ) -} - -cleanup_all() { - echo "--- Final cleanup: Shutting down processes and dropping table ---" - if [ -n "$TOOLBOX_PID" ]; then - kill $TOOLBOX_PID || true - fi - if [ -n "$PROXY_PID" ]; then - kill $PROXY_PID || true - fi -} -trap cleanup_all EXIT - -# Main script execution -install_system_packages -start_cloud_sql_proxy - -export PGHOST=127.0.0.1 -export PGPORT=5432 -export PGPASSWORD="$DB_PASSWORD" -export GOOGLE_API_KEY="$GOOGLE_API_KEY" - -setup_toolbox - -for ORCH_DIR in "$QUICKSTART_GO_DIR"/*/; do - if [ ! -d "$ORCH_DIR" ]; then - continue - fi - run_orch_test "$ORCH_DIR" -done diff --git a/.ci/quickstart_test/run_js_tests.sh b/.ci/quickstart_test/run_js_tests.sh deleted file mode 100644 index 71816542f5..0000000000 --- a/.ci/quickstart_test/run_js_tests.sh +++ /dev/null @@ -1,125 +0,0 @@ -# Copyright 2025 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -#!/bin/bash - -set -e - -TABLE_NAME="hotels_js" -QUICKSTART_JS_DIR="docs/en/getting-started/quickstart/js" -SQL_FILE=".ci/quickstart_test/setup_hotels_sample.sql" - -# Initialize process IDs to empty at the top of the script -PROXY_PID="" -TOOLBOX_PID="" - -install_system_packages() { - apt-get update && apt-get install -y \ - postgresql-client \ - wget \ - gettext-base \ - netcat-openbsd -} - -start_cloud_sql_proxy() { - wget "https://storage.googleapis.com/cloud-sql-connectors/cloud-sql-proxy/v2.10.0/cloud-sql-proxy.linux.amd64" -O /usr/local/bin/cloud-sql-proxy - chmod +x /usr/local/bin/cloud-sql-proxy - cloud-sql-proxy "${CLOUD_SQL_INSTANCE}" & - PROXY_PID=$! - - for i in {1..30}; do - if nc -z 127.0.0.1 5432; then - echo "Cloud SQL Proxy is up and running." - return - fi - sleep 1 - done - - echo "Cloud SQL Proxy failed to start within the timeout period." - exit 1 -} - -setup_toolbox() { - TOOLBOX_YAML="/tools.yaml" - echo "${TOOLS_YAML_CONTENT}" > "$TOOLBOX_YAML" - if [ ! -f "$TOOLBOX_YAML" ]; then echo "Failed to create tools.yaml"; exit 1; fi - wget "https://storage.googleapis.com/genai-toolbox/v${VERSION}/linux/amd64/toolbox" -O "/toolbox" - chmod +x "/toolbox" - /toolbox --tools-file "$TOOLBOX_YAML" & - TOOLBOX_PID=$! - sleep 2 -} - -setup_orch_table() { - export TABLE_NAME - envsubst < "$SQL_FILE" | psql -h "$PGHOST" -p "$PGPORT" -U "$DB_USER" -d "$DATABASE_NAME" -} - -run_orch_test() { - local orch_dir="$1" - local orch_name - orch_name=$(basename "$orch_dir") - - ( - set -e - echo "--- Preparing environment for $orch_name ---" - setup_orch_table - - cd "$orch_dir" - echo "Installing dependencies for $orch_name..." - if [ -f "package-lock.json" ]; then - npm ci - else - npm install - fi - - cd .. - - echo "--- Running tests for $orch_name ---" - export ORCH_NAME="$orch_name" - node --test quickstart.test.js - - echo "--- Cleaning environment for $orch_name ---" - rm -rf "${orch_name}/node_modules" - ) -} - -cleanup_all() { - echo "--- Final cleanup: Shutting down processes and dropping table ---" - if [ -n "$TOOLBOX_PID" ]; then - kill $TOOLBOX_PID || true - fi - if [ -n "$PROXY_PID" ]; then - kill $PROXY_PID || true - fi -} -trap cleanup_all EXIT - -# Main script execution -install_system_packages -start_cloud_sql_proxy - -export PGHOST=127.0.0.1 -export PGPORT=5432 -export PGPASSWORD="$DB_PASSWORD" -export GOOGLE_API_KEY="$GOOGLE_API_KEY" - -setup_toolbox - -for ORCH_DIR in "$QUICKSTART_JS_DIR"/*/; do - if [ ! -d "$ORCH_DIR" ]; then - continue - fi - run_orch_test "$ORCH_DIR" -done diff --git a/.ci/quickstart_test/run_py_tests.sh b/.ci/quickstart_test/run_py_tests.sh deleted file mode 100644 index 3d559838d9..0000000000 --- a/.ci/quickstart_test/run_py_tests.sh +++ /dev/null @@ -1,115 +0,0 @@ -# Copyright 2025 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -#!/bin/bash - -set -e - -TABLE_NAME="hotels_python" -QUICKSTART_PYTHON_DIR="docs/en/getting-started/quickstart/python" -SQL_FILE=".ci/quickstart_test/setup_hotels_sample.sql" - -PROXY_PID="" -TOOLBOX_PID="" - -install_system_packages() { - apt-get update && apt-get install -y \ - postgresql-client \ - python3-venv \ - wget \ - gettext-base \ - netcat-openbsd -} - -start_cloud_sql_proxy() { - wget "https://storage.googleapis.com/cloud-sql-connectors/cloud-sql-proxy/v2.10.0/cloud-sql-proxy.linux.amd64" -O /usr/local/bin/cloud-sql-proxy - chmod +x /usr/local/bin/cloud-sql-proxy - cloud-sql-proxy "${CLOUD_SQL_INSTANCE}" & - PROXY_PID=$! - - for i in {1..30}; do - if nc -z 127.0.0.1 5432; then - echo "Cloud SQL Proxy is up and running." - return - fi - sleep 1 - done - - echo "Cloud SQL Proxy failed to start within the timeout period." - exit 1 -} - -setup_toolbox() { - TOOLBOX_YAML="/tools.yaml" - echo "${TOOLS_YAML_CONTENT}" > "$TOOLBOX_YAML" - if [ ! -f "$TOOLBOX_YAML" ]; then echo "Failed to create tools.yaml"; exit 1; fi - wget "https://storage.googleapis.com/genai-toolbox/v${VERSION}/linux/amd64/toolbox" -O "/toolbox" - chmod +x "/toolbox" - /toolbox --tools-file "$TOOLBOX_YAML" & - TOOLBOX_PID=$! - sleep 2 -} - -setup_orch_table() { - export TABLE_NAME - envsubst < "$SQL_FILE" | psql -h "$PGHOST" -p "$PGPORT" -U "$DB_USER" -d "$DATABASE_NAME" -} - -run_orch_test() { - local orch_dir="$1" - local orch_name - orch_name=$(basename "$orch_dir") - ( - set -e - setup_orch_table - cd "$orch_dir" - local VENV_DIR=".venv" - python3 -m venv "$VENV_DIR" - source "$VENV_DIR/bin/activate" - pip install -r requirements.txt - echo "--- Running tests for $orch_name ---" - cd .. - ORCH_NAME="$orch_name" pytest - rm -rf "$VENV_DIR" - ) -} - -cleanup_all() { - echo "--- Final cleanup: Shutting down processes and dropping table ---" - if [ -n "$TOOLBOX_PID" ]; then - kill $TOOLBOX_PID || true - fi - if [ -n "$PROXY_PID" ]; then - kill $PROXY_PID || true - fi -} -trap cleanup_all EXIT - -# Main script execution -install_system_packages -start_cloud_sql_proxy - -export PGHOST=127.0.0.1 -export PGPORT=5432 -export PGPASSWORD="$DB_PASSWORD" -export GOOGLE_API_KEY="$GOOGLE_API_KEY" - -setup_toolbox - -for ORCH_DIR in "$QUICKSTART_PYTHON_DIR"/*/; do - if [ ! -d "$ORCH_DIR" ]; then - continue - fi - run_orch_test "$ORCH_DIR" -done diff --git a/.ci/quickstart_test/go.integration.cloudbuild.yaml b/.ci/sample_tests/quickstart/go.integration.cloudbuild.yaml similarity index 84% rename from .ci/quickstart_test/go.integration.cloudbuild.yaml rename to .ci/sample_tests/quickstart/go.integration.cloudbuild.yaml index cf9128ce60..24b14bfc26 100644 --- a/.ci/quickstart_test/go.integration.cloudbuild.yaml +++ b/.ci/sample_tests/quickstart/go.integration.cloudbuild.yaml @@ -23,13 +23,18 @@ steps: - | set -ex export VERSION=$(cat ./cmd/version.txt) - chmod +x .ci/quickstart_test/run_go_tests.sh - .ci/quickstart_test/run_go_tests.sh + chmod +x .ci/sample_tests/run_tests.sh + .ci/sample_tests/run_tests.sh env: - 'CLOUD_SQL_INSTANCE=${_CLOUD_SQL_INSTANCE}' - 'GCP_PROJECT=${_GCP_PROJECT}' - 'DATABASE_NAME=${_DATABASE_NAME}' - 'DB_USER=${_DB_USER}' + - 'TARGET_ROOT=docs/en/getting-started/quickstart/go' + - 'TARGET_LANG=go' + - 'TABLE_NAME=hotels_go' + - 'SQL_FILE=.ci/sample_tests/setup_hotels.sql' + - 'AGENT_FILE_PATTERN=quickstart.go' secretEnv: ['TOOLS_YAML_CONTENT', 'GOOGLE_API_KEY', 'DB_PASSWORD'] availableSecrets: diff --git a/.ci/quickstart_test/js.integration.cloudbuild.yaml b/.ci/sample_tests/quickstart/js.integration.cloudbuild.yaml similarity index 84% rename from .ci/quickstart_test/js.integration.cloudbuild.yaml rename to .ci/sample_tests/quickstart/js.integration.cloudbuild.yaml index cbf4e8547f..6e5aac6b07 100644 --- a/.ci/quickstart_test/js.integration.cloudbuild.yaml +++ b/.ci/sample_tests/quickstart/js.integration.cloudbuild.yaml @@ -23,13 +23,18 @@ steps: - | set -ex export VERSION=$(cat ./cmd/version.txt) - chmod +x .ci/quickstart_test/run_js_tests.sh - .ci/quickstart_test/run_js_tests.sh + chmod +x .ci/sample_tests/run_tests.sh + .ci/sample_tests/run_tests.sh env: - 'CLOUD_SQL_INSTANCE=${_CLOUD_SQL_INSTANCE}' - 'GCP_PROJECT=${_GCP_PROJECT}' - 'DATABASE_NAME=${_DATABASE_NAME}' - 'DB_USER=${_DB_USER}' + - 'TARGET_ROOT=docs/en/getting-started/quickstart/js' + - 'TARGET_LANG=js' + - 'TABLE_NAME=hotels_js' + - 'SQL_FILE=.ci/sample_tests/setup_hotels.sql' + - 'AGENT_FILE_PATTERN=quickstart.js' secretEnv: ['TOOLS_YAML_CONTENT', 'GOOGLE_API_KEY', 'DB_PASSWORD'] availableSecrets: diff --git a/.ci/quickstart_test/py.integration.cloudbuild.yaml b/.ci/sample_tests/quickstart/py.integration.cloudbuild.yaml similarity index 84% rename from .ci/quickstart_test/py.integration.cloudbuild.yaml rename to .ci/sample_tests/quickstart/py.integration.cloudbuild.yaml index 8fd3834d6c..da8daf678f 100644 --- a/.ci/quickstart_test/py.integration.cloudbuild.yaml +++ b/.ci/sample_tests/quickstart/py.integration.cloudbuild.yaml @@ -23,13 +23,18 @@ steps: - | set -ex export VERSION=$(cat ./cmd/version.txt) - chmod +x .ci/quickstart_test/run_py_tests.sh - .ci/quickstart_test/run_py_tests.sh + chmod +x .ci/sample_tests/run_tests.sh + .ci/sample_tests/run_tests.sh env: - 'CLOUD_SQL_INSTANCE=${_CLOUD_SQL_INSTANCE}' - 'GCP_PROJECT=${_GCP_PROJECT}' - 'DATABASE_NAME=${_DATABASE_NAME}' - 'DB_USER=${_DB_USER}' + - 'TARGET_ROOT=docs/en/getting-started/quickstart/python' + - 'TARGET_LANG=python' + - 'TABLE_NAME=hotels_python' + - 'SQL_FILE=.ci/sample_tests/setup_hotels.sql' + - 'AGENT_FILE_PATTERN=quickstart.py' secretEnv: ['TOOLS_YAML_CONTENT', 'GOOGLE_API_KEY', 'DB_PASSWORD'] availableSecrets: diff --git a/.ci/sample_tests/run_tests.sh b/.ci/sample_tests/run_tests.sh new file mode 100644 index 0000000000..5de9234eb0 --- /dev/null +++ b/.ci/sample_tests/run_tests.sh @@ -0,0 +1,202 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -e + +# --- Configuration (from Environment Variables) --- +# TARGET_ROOT: The directory to search for tests (e.g., docs/en/getting-started/quickstart/js) +# TARGET_LANG: python, js, go +# TABLE_NAME: Database table name to use +# SQL_FILE: Path to the SQL setup file +# AGENT_FILE_PATTERN: Filename to look for (e.g., quickstart.js or agent.py) + +VERSION=$(cat ./cmd/version.txt) + +# Process IDs & Logs +PROXY_PID="" +TOOLBOX_PID="" +PROXY_LOG="cloud_sql_proxy.log" +TOOLBOX_LOG="toolbox_server.log" + +install_system_packages() { + echo "Installing system packages..." + apt-get update && apt-get install -y \ + postgresql-client \ + wget \ + gettext-base \ + netcat-openbsd + + if [[ "$TARGET_LANG" == "python" ]]; then + apt-get install -y python3-venv + fi +} + +start_cloud_sql_proxy() { + echo "Starting Cloud SQL Proxy..." + wget -q "https://storage.googleapis.com/cloud-sql-connectors/cloud-sql-proxy/v2.10.0/cloud-sql-proxy.linux.amd64" -O /usr/local/bin/cloud-sql-proxy + chmod +x /usr/local/bin/cloud-sql-proxy + cloud-sql-proxy "${CLOUD_SQL_INSTANCE}" > "$PROXY_LOG" 2>&1 & + PROXY_PID=$! + + # Health Check + for i in {1..30}; do + if nc -z 127.0.0.1 5432; then + echo "Cloud SQL Proxy is up and running." + return + fi + sleep 1 + done + echo "ERROR: Cloud SQL Proxy failed to start. Logs:" + cat "$PROXY_LOG" + exit 1 +} + +setup_toolbox() { + echo "Setting up Toolbox server..." + TOOLBOX_YAML="/tools.yaml" + echo "${TOOLS_YAML_CONTENT}" > "$TOOLBOX_YAML" + wget -q "https://storage.googleapis.com/genai-toolbox/v${VERSION}/linux/amd64/toolbox" -O "/toolbox" + chmod +x "/toolbox" + /toolbox --tools-file "$TOOLBOX_YAML" > "$TOOLBOX_LOG" 2>&1 & + TOOLBOX_PID=$! + + # Health Check + for i in {1..15}; do + if nc -z 127.0.0.1 5000; then + echo "Toolbox server is up and running." + return + fi + sleep 1 + done + echo "ERROR: Toolbox server failed to start. Logs:" + cat "$TOOLBOX_LOG" + exit 1 +} + +setup_db_table() { + echo "Setting up database table $TABLE_NAME using $SQL_FILE..." + export TABLE_NAME + envsubst < "$SQL_FILE" | psql -h 127.0.0.1 -p 5432 -U "$DB_USER" -d "$DATABASE_NAME" +} + +run_python_test() { + local dir=$1 + local name=$(basename "$dir") + echo "--- Running Python Test: $name ---" + ( + cd "$dir" + python3 -m venv .venv + source .venv/bin/activate + pip install -q -r requirements.txt pytest + + cd .. + local test_file=$(find . -maxdepth 1 -name "*test.py" | head -n 1) + if [ -n "$test_file" ]; then + echo "Found native test: $test_file. Running pytest..." + export ORCH_NAME="$name" + export PYTHONPATH="../" + pytest "$test_file" + else + echo "No native test found. running agent directly..." + export PYTHONPATH="../" + python3 "${name}/${AGENT_FILE_PATTERN}" + fi + rm -rf "${name}/.venv" + ) +} + +run_js_test() { + local dir=$1 + local name=$(basename "$dir") + echo "--- Running JS Test: $name ---" + ( + cd "$dir" + if [ -f "package-lock.json" ]; then npm ci -q; else npm install -q; fi + + cd .. + # Looking for a JS test file in the parent directory + local test_file=$(find . -maxdepth 1 -name "*test.js" | head -n 1) + if [ -n "$test_file" ]; then + echo "Found native test: $test_file. Running node --test..." + export ORCH_NAME="$name" + node --test "$test_file" + else + echo "No native test found. running agent directly..." + node "${name}/${AGENT_FILE_PATTERN}" + fi + rm -rf "${name}/node_modules" + ) +} + +run_go_test() { + local dir=$1 + local name=$(basename "$dir") + + if [ "$name" == "openAI" ]; then + echo -e "\nSkipping framework '${name}': Temporarily excluded." + return + fi + + echo "--- Running Go Test: $name ---" + ( + cd "$dir" + if [ -f "go.mod" ]; then + go mod tidy + fi + + cd .. + local test_file=$(find . -maxdepth 1 -name "*test.go" | head -n 1) + if [ -n "$test_file" ]; then + echo "Found native test: $test_file. Running go test..." + export ORCH_NAME="$name" + go test -v ./... + else + echo "No native test found. running agent directly..." + cd "$name" + go run "." + fi + ) +} + +cleanup() { + echo "Cleaning up background processes..." + [ -n "$TOOLBOX_PID" ] && kill "$TOOLBOX_PID" || true + [ -n "$PROXY_PID" ] && kill "$PROXY_PID" || true +} +trap cleanup EXIT + +# --- Execution --- +install_system_packages +start_cloud_sql_proxy + +export PGHOST=127.0.0.1 +export PGPORT=5432 +export PGPASSWORD="$DB_PASSWORD" +export GOOGLE_API_KEY="$GOOGLE_API_KEY" + +setup_toolbox +setup_db_table + +echo "Scanning $TARGET_ROOT for tests with pattern $AGENT_FILE_PATTERN..." + +find "$TARGET_ROOT" -name "$AGENT_FILE_PATTERN" | while read -r agent_file; do + sample_dir=$(dirname "$agent_file") + if [[ "$TARGET_LANG" == "python" ]]; then + run_python_test "$sample_dir" + elif [[ "$TARGET_LANG" == "js" ]]; then + run_js_test "$sample_dir" + elif [[ "$TARGET_LANG" == "go" ]]; then + run_go_test "$sample_dir" + fi +done diff --git a/.ci/quickstart_test/setup_hotels_sample.sql b/.ci/sample_tests/setup_hotels.sql similarity index 100% rename from .ci/quickstart_test/setup_hotels_sample.sql rename to .ci/sample_tests/setup_hotels.sql