mirror of
https://github.com/googleapis/genai-toolbox.git
synced 2026-01-31 02:08:23 -05:00
Compare commits
13 Commits
skillgen
...
processing
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82502ddfb4 | ||
|
|
42d9cd0e42 | ||
|
|
e0245946ea | ||
|
|
321a8835fc | ||
|
|
7d9946026e | ||
|
|
0dfcf24859 | ||
|
|
be0b7fc96e | ||
|
|
d7016d2251 | ||
|
|
d44283ffcf | ||
|
|
69e3f2eb24 | ||
|
|
c724bea786 | ||
|
|
4bc684d3ed | ||
|
|
9434450a65 |
@@ -35,7 +35,6 @@ 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"
|
||||
@@ -402,8 +401,6 @@ 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
|
||||
}
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,14 @@ To connect to the database to explore and query data, search the MCP store for t
|
||||
|
||||
In the Antigravity MCP Store, click the "Install" button.
|
||||
|
||||
> [!NOTE]
|
||||
> On first use, the installation process automatically downloads and uses
|
||||
> [MCP Toolbox](https://www.npmjs.com/package/@toolbox-sdk/server)
|
||||
> `>=0.26.0`. To update MCP Toolbox, use:
|
||||
> ```npm i -g @toolbox-sdk/server@latest```
|
||||
> To always run the latest version, update the MCP server configuration to use:
|
||||
> ```npx -y @toolbox-sdk/server@latest --prebuilt alloydb-postgres-admin```.
|
||||
|
||||
You'll now be able to see all enabled tools in the "Tools" tab.
|
||||
|
||||
> [!NOTE]
|
||||
|
||||
@@ -27,6 +27,13 @@ For AlloyDB infrastructure management, search the MCP store for the AlloyDB for
|
||||
## Install & Configuration
|
||||
|
||||
1. In the Antigravity MCP Store, click the "Install" button.
|
||||
> [!NOTE]
|
||||
> On first use, the installation process automatically downloads and uses
|
||||
> [MCP Toolbox](https://www.npmjs.com/package/@toolbox-sdk/server)
|
||||
> `>=0.26.0`. To update MCP Toolbox, use:
|
||||
> ```npm i -g @toolbox-sdk/server@latest```
|
||||
> To always run the latest version, update the MCP server configuration to use:
|
||||
> ```npx -y @toolbox-sdk/server@latest --prebuilt alloydb-postgres```.
|
||||
|
||||
2. Add the required inputs for your [cluster](https://docs.cloud.google.com/alloydb/docs/cluster-list) in the configuration pop-up, then click "Save". You can update this configuration at any time in the "Configure" tab.
|
||||
|
||||
|
||||
@@ -21,6 +21,13 @@ An editor configured to use the BigQuery MCP server can use its AI capabilities
|
||||
## Install & Configuration
|
||||
|
||||
1. In the Antigravity MCP Store, click the "Install" button.
|
||||
> [!NOTE]
|
||||
> On first use, the installation process automatically downloads and uses
|
||||
> [MCP Toolbox](https://www.npmjs.com/package/@toolbox-sdk/server)
|
||||
> `>=0.26.0`. To update MCP Toolbox, use:
|
||||
> ```npm i -g @toolbox-sdk/server@latest```
|
||||
> To always run the latest version, update the MCP server configuration to use:
|
||||
> ```npx -y @toolbox-sdk/server@latest --prebuilt bigquery```.
|
||||
|
||||
2. Add the required inputs in the configuration pop-up, then click "Save". You can update this configuration at any time in the "Configure" tab.
|
||||
|
||||
|
||||
@@ -23,6 +23,14 @@ To connect to the database to explore and query data, search the MCP store for t
|
||||
|
||||
In the Antigravity MCP Store, click the "Install" button.
|
||||
|
||||
> [!NOTE]
|
||||
> On first use, the installation process automatically downloads and uses
|
||||
> [MCP Toolbox](https://www.npmjs.com/package/@toolbox-sdk/server)
|
||||
> `>=0.26.0`. To update MCP Toolbox, use:
|
||||
> ```npm i -g @toolbox-sdk/server@latest```
|
||||
> To always run the latest version, update the MCP server configuration to use:
|
||||
> ```npx -y @toolbox-sdk/server@latest --prebuilt cloud-sql-mssql-admin```.
|
||||
|
||||
You'll now be able to see all enabled tools in the "Tools" tab.
|
||||
|
||||
> [!NOTE]
|
||||
|
||||
@@ -24,6 +24,13 @@ For Cloud SQL infrastructure management, search the MCP store for the Cloud SQL
|
||||
## Install & Configuration
|
||||
|
||||
1. In the Antigravity MCP Store, click the "Install" button.
|
||||
> [!NOTE]
|
||||
> On first use, the installation process automatically downloads and uses
|
||||
> [MCP Toolbox](https://www.npmjs.com/package/@toolbox-sdk/server)
|
||||
> `>=0.26.0`. To update MCP Toolbox, use:
|
||||
> ```npm i -g @toolbox-sdk/server@latest```
|
||||
> To always run the latest version, update the MCP server configuration to use:
|
||||
> ```npx -y @toolbox-sdk/server@latest --prebuilt cloud-sql-mssql```.
|
||||
|
||||
2. Add the required inputs for your [instance](https://cloud.google.com/sql/docs/sqlserver/instance-info) in the configuration pop-up, then click "Save". You can update this configuration at any time in the "Configure" tab.
|
||||
|
||||
|
||||
@@ -23,6 +23,14 @@ To connect to the database to explore and query data, search the MCP store for t
|
||||
|
||||
In the Antigravity MCP Store, click the "Install" button.
|
||||
|
||||
> [!NOTE]
|
||||
> On first use, the installation process automatically downloads and uses
|
||||
> [MCP Toolbox](https://www.npmjs.com/package/@toolbox-sdk/server)
|
||||
> `>=0.26.0`. To update MCP Toolbox, use:
|
||||
> ```npm i -g @toolbox-sdk/server@latest```
|
||||
> To always run the latest version, update the MCP server configuration to use:
|
||||
> ```npx -y @toolbox-sdk/server@latest --prebuilt cloud-sql-mysql-admin```.
|
||||
|
||||
You'll now be able to see all enabled tools in the "Tools" tab.
|
||||
|
||||
> [!NOTE]
|
||||
|
||||
@@ -26,6 +26,13 @@ For Cloud SQL infrastructure management, search the MCP store for the Cloud SQL
|
||||
## Install & Configuration
|
||||
|
||||
1. In the Antigravity MCP Store, click the "Install" button.
|
||||
> [!NOTE]
|
||||
> On first use, the installation process automatically downloads and uses
|
||||
> [MCP Toolbox](https://www.npmjs.com/package/@toolbox-sdk/server)
|
||||
> `>=0.26.0`. To update MCP Toolbox, use:
|
||||
> ```npm i -g @toolbox-sdk/server@latest```
|
||||
> To always run the latest version, update the MCP server configuration to use:
|
||||
> ```npx -y @toolbox-sdk/server@latest --prebuilt cloud-sql-mysql```.
|
||||
|
||||
2. Add the required inputs for your [instance](https://cloud.google.com/sql/docs/mysql/instance-info) in the configuration pop-up, then click "Save". You can update this configuration at any time in the "Configure" tab.
|
||||
|
||||
|
||||
@@ -23,6 +23,14 @@ To connect to the database to explore and query data, search the MCP store for t
|
||||
|
||||
In the Antigravity MCP Store, click the "Install" button.
|
||||
|
||||
> [!NOTE]
|
||||
> On first use, the installation process automatically downloads and uses
|
||||
> [MCP Toolbox](https://www.npmjs.com/package/@toolbox-sdk/server)
|
||||
> `>=0.26.0`. To update MCP Toolbox, use:
|
||||
> ```npm i -g @toolbox-sdk/server@latest```
|
||||
> To always run the latest version, update the MCP server configuration to use:
|
||||
> ```npx -y @toolbox-sdk/server@latest --prebuilt cloud-sql-postgres-admin```.
|
||||
|
||||
You'll now be able to see all enabled tools in the "Tools" tab.
|
||||
|
||||
> [!NOTE]
|
||||
|
||||
@@ -26,6 +26,13 @@ For Cloud SQL infrastructure management, search the MCP store for the Cloud SQL
|
||||
## Install & Configuration
|
||||
|
||||
1. In the Antigravity MCP Store, click the "Install" button.
|
||||
> [!NOTE]
|
||||
> On first use, the installation process automatically downloads and uses
|
||||
> [MCP Toolbox](https://www.npmjs.com/package/@toolbox-sdk/server)
|
||||
> `>=0.26.0`. To update MCP Toolbox, use:
|
||||
> ```npm i -g @toolbox-sdk/server@latest```
|
||||
> To always run the latest version, update the MCP server configuration to use:
|
||||
> ```npx -y @toolbox-sdk/server@latest --prebuilt cloud-sql-postgres```.
|
||||
|
||||
2. Add the required inputs for your [instance](https://cloud.google.com/sql/docs/postgres/instance-info) in the configuration pop-up, then click "Save". You can update this configuration at any time in the "Configure" tab.
|
||||
|
||||
|
||||
@@ -20,6 +20,13 @@ An editor configured to use the Dataplex MCP server can use its AI capabilities
|
||||
## Install & Configuration
|
||||
|
||||
1. In the Antigravity MCP Store, click the "Install" button.
|
||||
> [!NOTE]
|
||||
> On first use, the installation process automatically downloads and uses
|
||||
> [MCP Toolbox](https://www.npmjs.com/package/@toolbox-sdk/server)
|
||||
> `>=0.26.0`. To update MCP Toolbox, use:
|
||||
> ```npm i -g @toolbox-sdk/server@latest```
|
||||
> To always run the latest version, update the MCP server configuration to use:
|
||||
> ```npx -y @toolbox-sdk/server@latest --prebuilt dataplex```.
|
||||
|
||||
2. Add the required inputs in the configuration pop-up, then click "Save". You can update this configuration at any time in the "Configure" tab.
|
||||
|
||||
|
||||
@@ -21,6 +21,13 @@ An editor configured to use the Looker MCP server can use its AI capabilities to
|
||||
## Install & Configuration
|
||||
|
||||
1. In the Antigravity MCP Store, click the "Install" button.
|
||||
> [!NOTE]
|
||||
> On first use, the installation process automatically downloads and uses
|
||||
> [MCP Toolbox](https://www.npmjs.com/package/@toolbox-sdk/server)
|
||||
> `>=0.26.0`. To update MCP Toolbox, use:
|
||||
> ```npm i -g @toolbox-sdk/server@latest```
|
||||
> To always run the latest version, update the MCP server configuration to use:
|
||||
> ```npx -y @toolbox-sdk/server@latest --prebuilt looker```.
|
||||
|
||||
2. Add the required inputs for your [instance](https://docs.cloud.google.com/looker/docs/set-up-and-administer-looker) in the configuration pop-up, then click "Save". You can update this configuration at any time in the "Configure" tab.
|
||||
|
||||
|
||||
@@ -21,6 +21,13 @@ An editor configured to use the Cloud Spanner MCP server can use its AI capabili
|
||||
## Install & Configuration
|
||||
|
||||
1. In the Antigravity MCP Store, click the "Install" button.
|
||||
> [!NOTE]
|
||||
> On first use, the installation process automatically downloads and uses
|
||||
> [MCP Toolbox](https://www.npmjs.com/package/@toolbox-sdk/server)
|
||||
> `>=0.26.0`. To update MCP Toolbox, use:
|
||||
> ```npm i -g @toolbox-sdk/server@latest```
|
||||
> To always run the latest version, update the MCP server configuration to use:
|
||||
> ```npx -y @toolbox-sdk/server@latest --prebuilt spanner```.
|
||||
|
||||
2. Add the required inputs for your [instance](https://docs.cloud.google.com/spanner/docs/instances) in the configuration pop-up, then click "Save". You can update this configuration at any time in the "Configure" tab.
|
||||
|
||||
|
||||
@@ -12,10 +12,17 @@ The MCP Toolbox for Databases Server gives AI-powered development tools the abil
|
||||
## Install & Configuration
|
||||
|
||||
1. In the Antigravity MCP Store, click the **Install** button. A configuration window will appear.
|
||||
> [!NOTE]
|
||||
> On first use, the installation process automatically downloads and uses
|
||||
> [MCP Toolbox](https://www.npmjs.com/package/@toolbox-sdk/server)
|
||||
> `>=0.26.0`. To update MCP Toolbox, use:
|
||||
> ```npm i -g @toolbox-sdk/server@latest```
|
||||
> To always run the latest version, update the MCP server configuration to use:
|
||||
> ```npx -y @toolbox-sdk/server@latest```.
|
||||
|
||||
2. Create your [`tools.yaml` configuration file](https://googleapis.github.io/genai-toolbox/getting-started/configure/).
|
||||
3. Create your [`tools.yaml` configuration file](https://googleapis.github.io/genai-toolbox/getting-started/configure/).
|
||||
|
||||
3. In the configuration window, enter the full absolute path to your `tools.yaml` file and click **Save**.
|
||||
4. In the configuration window, enter the full absolute path to your `tools.yaml` file and click **Save**.
|
||||
|
||||
> [!NOTE]
|
||||
> If you encounter issues with Windows Defender blocking the execution, you may need to configure an allowlist. See [Configure exclusions for Microsoft Defender Antivirus](https://learn.microsoft.com/en-us/microsoft-365/security/defender-endpoint/configure-exclusions-microsoft-defender-antivirus?view=o365-worldwide) for more details.
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
---
|
||||
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 skill.
|
||||
|
||||
### Command Signature
|
||||
|
||||
The `skills-generate` command follows this signature:
|
||||
|
||||
```bash
|
||||
toolbox [--tools-file <path> | --prebuilt <name>] skills-generate \
|
||||
--name <skill-name> \
|
||||
--toolset <toolset-name> \
|
||||
--description <description> \
|
||||
--output-dir <output-directory>
|
||||
```
|
||||
|
||||
> **Note:** The `<skill-name>` must follow the Agent Skill naming convention: 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`).
|
||||
|
||||
### 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"
|
||||
```
|
||||
|
||||
## Output Directory
|
||||
|
||||
By default, skills are generated in the `skills` directory. You can specify a different output directory using the `--output-dir` flag.
|
||||
|
||||
## Shared Node.js Scripts
|
||||
|
||||
The `skills-generate` command generates shared Node.js scripts (`.js`) that work across different platforms (Linux, macOS, Windows). This ensures that the generated skills are portable.
|
||||
|
||||
## 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
|
||||
```
|
||||
@@ -32,8 +32,7 @@ description: >
|
||||
|
||||
## Sub Commands
|
||||
|
||||
<details>
|
||||
<summary><code>invoke</code></summary>
|
||||
### `invoke`
|
||||
|
||||
Executes a tool directly with the provided parameters. This is useful for testing tool configurations and parameters without needing a full client setup.
|
||||
|
||||
@@ -46,30 +45,6 @@ toolbox invoke <tool-name> [params]
|
||||
- `<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.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><code>skills-generate</code></summary>
|
||||
|
||||
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 [--tools-file <path> | --prebuilt <name>] skills-generate --name <name> --description <description> --toolset <toolset> --output-dir <output>
|
||||
```
|
||||
|
||||
**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).
|
||||
|
||||
</details>
|
||||
|
||||
## Examples
|
||||
|
||||
### Transport Configuration
|
||||
|
||||
47
docs/en/samples/pre_post_processing/_index.md
Normal file
47
docs/en/samples/pre_post_processing/_index.md
Normal file
@@ -0,0 +1,47 @@
|
||||
---
|
||||
title: "Pre and Post processing"
|
||||
type: docs
|
||||
weight: 1
|
||||
description: >
|
||||
Pre and Post processing in GenAI applications.
|
||||
---
|
||||
|
||||
Pre and post processing allow developers to intercept and modify interactions between the agent and its tools or the user.
|
||||
|
||||
> **Note**: These capabilities are typically features of **orchestration frameworks** (like LangChain, LangGraph, or Agent Builder) rather than the Toolbox SDK itself. However, Toolbox tools are designed to fully leverage these framework capabilities to support robust, secure, and compliant agent architectures.
|
||||
|
||||
## Types of Processing
|
||||
|
||||
### Pre-processing
|
||||
|
||||
Pre-processing occurs before a tool is executed or an agent processes a message. Key types include:
|
||||
|
||||
- **Input Sanitization & Redaction**: Detecting and masking sensitive information (like PII) in user queries or tool arguments to prevent it from being logged or sent to unauthorized systems.
|
||||
- **Business Logic Validation**: Verifying that the proposed action complies with business rules (e.g., ensuring a requested hotel stay does not exceed 14 days, or checking if a user has sufficient permission).
|
||||
- **Security Guardrails**: Analyzing inputs for potential prompt injection attacks or malicious payloads.
|
||||
|
||||
### Post-processing
|
||||
|
||||
Post-processing occurs after a tool has executed or the model has generated a response. Key types include:
|
||||
|
||||
- **Response Enrichment**: Injecting additional data into the tool output that wasn't part of the raw API response (e.g., calculating loyalty points earned based on the booking value).
|
||||
- **Output Formatting**: Transforming raw data (like JSON or XML) into a more human-readable or model-friendly format to improve the agent's understanding.
|
||||
- **Compliance Auditing**: Logging the final outcome of transactions, including the original request and the result, to a secure audit trail.
|
||||
|
||||
## Processing Scopes
|
||||
|
||||
While processing logic can be applied at various levels (Agent, Model, Tool), this guide primarily focuses on **Tool Level** processing, which is most relevant for granular control over tool execution.
|
||||
|
||||
### Tool Level (Primary Focus)
|
||||
|
||||
Wraps individual tool executions. This is best for logic specific to a single tool or a set of tools.
|
||||
|
||||
- **Scope**: Intercepts the raw inputs (arguments) to a tool and its outputs.
|
||||
- **Use Cases**: Argument validation, output formatting, specific privacy rules for sensitive tools.
|
||||
|
||||
### Comparison with Other Levels
|
||||
|
||||
It is helpful to understand how tool-level processing differs from other scopes:
|
||||
|
||||
- **Model Level**: Intercepts individual calls to the LLM (prompts and responses). Unlike tool-level, this applies globally to all text sent/received, making it better for global PII redaction or token tracking.
|
||||
- **Agent Level**: Wraps the high-level execution loop (e.g., a "turn" in the conversation). Unlike tool-level, this envelopes the entire turn (user input to final response), making it suitable for session management or end-to-end auditing.
|
||||
5
docs/en/samples/pre_post_processing/golden.txt
Normal file
5
docs/en/samples/pre_post_processing/golden.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
Final Client Response:
|
||||
AI:
|
||||
Booking Confirmed!
|
||||
Loyalty Points
|
||||
POLICY CHECK: Intercepting 'book-hotel'
|
||||
31
docs/en/samples/pre_post_processing/python.md
Normal file
31
docs/en/samples/pre_post_processing/python.md
Normal file
@@ -0,0 +1,31 @@
|
||||
---
|
||||
title: "(Python) Pre and post processing"
|
||||
type: docs
|
||||
weight: 4
|
||||
description: >
|
||||
How to add pre and post processing to your Python toolbox applications.
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
This tutorial assumes that you have set up a basic toolbox application as described in the [local quickstart](../../getting-started/local_quickstart).
|
||||
|
||||
This guide demonstrates how to implement these patterns in your Toolbox applications.
|
||||
|
||||
## Python
|
||||
|
||||
{{< tabpane persist=header >}}
|
||||
{{% tab header="ADK" text=true %}}
|
||||
Coming soon.
|
||||
{{% /tab %}}
|
||||
{{% tab header="Langchain" text=true %}}
|
||||
The following example demonstrates how to use `ToolboxClient` with LangChain's middleware to implement pre and post processing for tool calls.
|
||||
|
||||
```py
|
||||
{{< include "python/langchain/agent.py" >}}
|
||||
```
|
||||
|
||||
For more information, see the [LangChain Middleware documentation](https://docs.langchain.com/oss/python/langchain/middleware/custom#wrap-style-hooks).
|
||||
You can also add model-level (`wrap_model`) and agent-level (`before_agent`, `after_agent`) hooks to intercept messages at different stages of the execution loop. See the [LangChain Middleware documentation](https://docs.langchain.com/oss/python/langchain/middleware/custom#wrap-style-hooks) for details on these additional hook types.
|
||||
{{% /tab %}}
|
||||
{{< /tabpane >}}
|
||||
4
docs/en/samples/pre_post_processing/python/__init__.py
Normal file
4
docs/en/samples/pre_post_processing/python/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
# This file makes the 'pre_post_processing/python' directory a Python package.
|
||||
|
||||
# You can include any package-level initialization logic here if needed.
|
||||
# For now, this file is empty.
|
||||
58
docs/en/samples/pre_post_processing/python/agent_test.py
Normal file
58
docs/en/samples/pre_post_processing/python/agent_test.py
Normal file
@@ -0,0 +1,58 @@
|
||||
# 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.
|
||||
|
||||
import asyncio
|
||||
import importlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ORCH_NAME = os.environ.get("ORCH_NAME")
|
||||
module_path = f"python.{ORCH_NAME}.agent"
|
||||
agent = importlib.import_module(module_path)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def golden_keywords():
|
||||
"""Loads expected keywords from the golden.txt file."""
|
||||
golden_file_path = Path(__file__).resolve().parent.parent / "golden.txt"
|
||||
if not golden_file_path.exists():
|
||||
pytest.fail(f"Golden file not found: {golden_file_path}")
|
||||
try:
|
||||
with open(golden_file_path, "r") as f:
|
||||
return [line.strip() for line in f.readlines() if line.strip()]
|
||||
except Exception as e:
|
||||
pytest.fail(f"Could not read golden.txt: {e}")
|
||||
|
||||
|
||||
# --- Execution Tests ---
|
||||
class TestExecution:
|
||||
"""Test framework execution and output validation."""
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def script_output(self, capsys):
|
||||
"""Run the agent function and return its output."""
|
||||
asyncio.run(agent.main())
|
||||
return capsys.readouterr()
|
||||
|
||||
def test_script_runs_without_errors(self, script_output):
|
||||
"""Test that the script runs and produces no stderr."""
|
||||
assert script_output.err == "", f"Script produced stderr: {script_output.err}"
|
||||
|
||||
def test_keywords_in_output(self, script_output, golden_keywords):
|
||||
"""Test that expected keywords are present in the script's output."""
|
||||
output = script_output.out
|
||||
missing_keywords = [kw for kw in golden_keywords if kw not in output]
|
||||
assert not missing_keywords, f"Missing keywords in output: {missing_keywords}"
|
||||
111
docs/en/samples/pre_post_processing/python/langchain/agent.py
Normal file
111
docs/en/samples/pre_post_processing/python/langchain/agent.py
Normal file
@@ -0,0 +1,111 @@
|
||||
# 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.
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
|
||||
from langchain.agents import create_agent
|
||||
from langchain.agents.middleware import wrap_tool_call
|
||||
from langchain_core.messages import ToolMessage
|
||||
from langchain_google_vertexai import ChatVertexAI
|
||||
from toolbox_langchain import ToolboxClient
|
||||
|
||||
system_prompt = """
|
||||
You're a helpful hotel assistant. You handle hotel searching, booking and
|
||||
cancellations. When the user searches for a hotel, mention it's name, id,
|
||||
location and price tier. Always mention hotel ids while performing any
|
||||
searches. This is very important for any operations. For any bookings or
|
||||
cancellations, please provide the appropriate confirmation. Be sure to
|
||||
update checkin or checkout dates if mentioned by the user.
|
||||
Don't ask for confirmations from the user.
|
||||
"""
|
||||
|
||||
|
||||
# Pre processing
|
||||
@wrap_tool_call
|
||||
async def enforce_business_rules(request, handler):
|
||||
"""
|
||||
Business Logic Validation:
|
||||
Enforces max stay duration (e.g., max 14 days).
|
||||
"""
|
||||
tool_call = request.tool_call
|
||||
name = tool_call["name"]
|
||||
args = tool_call["args"]
|
||||
|
||||
print(f"POLICY CHECK: Intercepting '{name}'")
|
||||
|
||||
if name == "update-hotel":
|
||||
if "checkin_date" in args and "checkout_date" in args:
|
||||
try:
|
||||
start = datetime.fromisoformat(args["checkin_date"])
|
||||
end = datetime.fromisoformat(args["checkout_date"])
|
||||
duration = (end - start).days
|
||||
|
||||
if duration > 14:
|
||||
print("BLOCKED: Stay too long")
|
||||
return ToolMessage(
|
||||
content="Error: Maximum stay duration is 14 days.",
|
||||
tool_call_id=tool_call["id"],
|
||||
)
|
||||
except ValueError:
|
||||
pass # Ignore invalid date formats
|
||||
|
||||
return await handler(request)
|
||||
|
||||
|
||||
# Post processing
|
||||
@wrap_tool_call
|
||||
async def enrich_response(request, handler):
|
||||
"""
|
||||
Post-Processing & Enrichment:
|
||||
Adds loyalty points information to successful bookings.
|
||||
Standardizes output format.
|
||||
"""
|
||||
result = await handler(request)
|
||||
|
||||
if isinstance(result, ToolMessage):
|
||||
content = str(result.content)
|
||||
tool_name = request.tool_call["name"]
|
||||
|
||||
if tool_name == "book-hotel" and "Error" not in content:
|
||||
loyalty_bonus = 500
|
||||
result.content = f"Booking Confirmed! \n You earned {loyalty_bonus} Loyalty Points with this stay.\n\nSystem Details: {content}"
|
||||
|
||||
return result
|
||||
|
||||
|
||||
async def main():
|
||||
async with ToolboxClient("http://127.0.0.1:5000") as client:
|
||||
tools = await client.aload_toolset("my-toolset")
|
||||
model = ChatVertexAI(model="gemini-2.5-flash")
|
||||
agent = create_agent(
|
||||
system_prompt=system_prompt,
|
||||
model=model,
|
||||
tools=tools,
|
||||
middleware=[enforce_business_rules, enrich_response],
|
||||
)
|
||||
|
||||
user_input = "Book hotel with id 3."
|
||||
response = await agent.ainvoke(
|
||||
{"messages": [{"role": "user", "content": user_input}]}
|
||||
)
|
||||
|
||||
print("-" * 50)
|
||||
print("Final Client Response:")
|
||||
last_ai_msg = response["messages"][-1].content
|
||||
print(f"AI: {last_ai_msg}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,2 @@
|
||||
langchain==1.2.6
|
||||
toolbox-langchain==0.5.7
|
||||
@@ -1,299 +0,0 @@
|
||||
// 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"
|
||||
|
||||
"github.com/googleapis/genai-toolbox/internal/log"
|
||||
"github.com/googleapis/genai-toolbox/internal/server"
|
||||
"github.com/googleapis/genai-toolbox/internal/server/resources"
|
||||
|
||||
"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
|
||||
}
|
||||
|
||||
// Parameter represents a parameter of a tool.
|
||||
type Parameter struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Type string `json:"type"`
|
||||
Default interface{} `json:"default"`
|
||||
Required bool `json:"required"`
|
||||
}
|
||||
|
||||
// Tool represents a tool.
|
||||
type Tool struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Parameters []Parameter `json:"parameters"`
|
||||
}
|
||||
|
||||
// Config represents the structure of the tools.yaml file.
|
||||
type Config struct {
|
||||
Sources map[string]interface{} `yaml:"sources,omitempty"`
|
||||
Tools map[string]map[string]interface{} `yaml:"tools"`
|
||||
}
|
||||
|
||||
// serverConfig holds the configuration used to start the toolbox server.
|
||||
type serverConfig struct {
|
||||
prebuiltConfigs []string
|
||||
toolsFile 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 (and generated skill folder). If provided, only tools in this toolset are generated.")
|
||||
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()
|
||||
|
||||
toolsFile, err := cmd.Flags().GetString("tools-file")
|
||||
if err != nil {
|
||||
errMsg := fmt.Errorf("error getting tools-file flag: %w", err)
|
||||
logger.ErrorContext(ctx, errMsg.Error())
|
||||
return errMsg
|
||||
}
|
||||
prebuiltConfigs, err := cmd.Flags().GetStringSlice("prebuilt")
|
||||
if err != nil {
|
||||
errMsg := fmt.Errorf("error getting prebuilt flag: %w", err)
|
||||
logger.ErrorContext(ctx, errMsg.Error())
|
||||
return errMsg
|
||||
}
|
||||
|
||||
// Load and merge tool configurations
|
||||
if err := c.rootCmd.LoadConfig(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(prebuiltConfigs) == 0 && toolsFile == "" {
|
||||
logger.InfoContext(ctx, "No configurations found to process. Use --tools-file or --prebuilt.")
|
||||
return nil
|
||||
}
|
||||
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))
|
||||
|
||||
config := serverConfig{
|
||||
prebuiltConfigs: prebuiltConfigs,
|
||||
toolsFile: toolsFile,
|
||||
}
|
||||
|
||||
// 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
|
||||
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 if needed
|
||||
assetsPath := filepath.Join(skillPath, "assets")
|
||||
if toolsFile != "" {
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
for _, tool := range allTools {
|
||||
specificToolsFileName := ""
|
||||
if toolsFile != "" {
|
||||
minimizedContent, err := generateFilteredConfig(toolsFile, tool.Name)
|
||||
if err != nil {
|
||||
logger.ErrorContext(ctx, fmt.Sprintf("Error generating filtered config for %s: %v", tool.Name, err))
|
||||
}
|
||||
|
||||
if minimizedContent != nil {
|
||||
specificToolsFileName = fmt.Sprintf("%s.yaml", tool.Name)
|
||||
destPath := filepath.Join(assetsPath, specificToolsFileName)
|
||||
if err := os.WriteFile(destPath, minimizedContent, 0644); err != nil {
|
||||
logger.ErrorContext(ctx, fmt.Sprintf("Error writing filtered config for %s: %v", tool.Name, err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scriptContent, err := generateScriptContent(tool.Name, config, specificToolsFileName)
|
||||
if err != nil {
|
||||
logger.ErrorContext(ctx, fmt.Sprintf("Error generating script content for %s: %v", tool.Name, err))
|
||||
} else {
|
||||
scriptFilename := filepath.Join(scriptsPath, fmt.Sprintf("%s.js", tool.Name))
|
||||
if err := os.WriteFile(scriptFilename, []byte(scriptContent), 0755); err != nil {
|
||||
logger.ErrorContext(ctx, fmt.Sprintf("Error writing script %s: %v", scriptFilename, err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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]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]Tool)
|
||||
|
||||
var toolsToProcess []string
|
||||
|
||||
if c.toolset != "" {
|
||||
ts, ok := resourceMgr.GetToolset(c.toolset)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("toolset %q not found", c.toolset)
|
||||
}
|
||||
toolsToProcess = ts.ToolNames
|
||||
} else {
|
||||
// All tools
|
||||
for name := range toolsMap {
|
||||
toolsToProcess = append(toolsToProcess, name)
|
||||
}
|
||||
}
|
||||
|
||||
for _, toolName := range toolsToProcess {
|
||||
t, ok := resourceMgr.GetTool(toolName)
|
||||
if !ok {
|
||||
// Should happen only if toolset refers to non-existent tool, but good to check
|
||||
continue
|
||||
}
|
||||
|
||||
params := []Parameter{}
|
||||
for _, p := range t.GetParameters() {
|
||||
manifest := p.Manifest()
|
||||
params = append(params, Parameter{
|
||||
Name: p.GetName(),
|
||||
Description: manifest.Description, // Use description from manifest
|
||||
Type: p.GetType(),
|
||||
Default: p.GetDefault(),
|
||||
Required: p.GetRequired(),
|
||||
})
|
||||
}
|
||||
|
||||
manifest := t.Manifest()
|
||||
result[toolName] = Tool{
|
||||
Name: toolName,
|
||||
Description: manifest.Description,
|
||||
Parameters: params,
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -1,253 +0,0 @@
|
||||
// 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 (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"github.com/goccy/go-yaml"
|
||||
)
|
||||
|
||||
const skillTemplate = `---
|
||||
name: {{.SkillName}}
|
||||
description: {{.SkillDescription}}
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
All scripts can be executed using Node.js. Replace ` + "`" + `<param_name>` + "`" + ` and ` + "`" + `<param_value>` + "`" + ` with actual values.
|
||||
|
||||
**Bash:**
|
||||
` + "`" + `node scripts/<script_name>.js '{"<param_name>": "<param_value>"}'` + "`" + `
|
||||
|
||||
**PowerShell:**
|
||||
` + "`" + `node scripts/<script_name>.js '{\"<param_name>\": \"<param_value>\"}'` + "`" + `
|
||||
|
||||
## 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
|
||||
}
|
||||
|
||||
func generateSkillMarkdown(skillName, skillDescription string, toolsMap map[string]Tool) (string, error) {
|
||||
var toolsData []toolTemplateData
|
||||
|
||||
// Order tools based on name
|
||||
var tools []Tool
|
||||
for _, tool := range toolsMap {
|
||||
tools = append(tools, tool)
|
||||
}
|
||||
sort.Slice(tools, func(i, j int) bool {
|
||||
return tools[i].Name < tools[j].Name
|
||||
})
|
||||
|
||||
for _, tool := range tools {
|
||||
parametersSchema, err := formatParameters(tool.Parameters)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
toolsData = append(toolsData, toolTemplateData{
|
||||
Name: tool.Name,
|
||||
Description: tool.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 } = require('child_process');
|
||||
const path = require('path');
|
||||
|
||||
const toolName = "{{.Name}}";
|
||||
const prebuiltNames = {{.PrebuiltNamesJSON}};
|
||||
const toolsFileName = "{{.ToolsFileName}}";
|
||||
|
||||
let configArgs = [];
|
||||
if (prebuiltNames.length > 0) {
|
||||
prebuiltNames.forEach(name => {
|
||||
configArgs.push("--prebuilt", name);
|
||||
});
|
||||
}
|
||||
|
||||
if (toolsFileName) {
|
||||
configArgs.push("--tools-file", path.join(__dirname, "..", "assets", toolsFileName));
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const toolboxArgs = [...configArgs, "invoke", toolName, ...args];
|
||||
|
||||
const command = process.platform === 'win32' ? 'toolbox.exe' : 'toolbox';
|
||||
|
||||
const child = spawn(command, 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
|
||||
PrebuiltNamesJSON string
|
||||
ToolsFileName string
|
||||
}
|
||||
|
||||
func generateScriptContent(name string, config serverConfig, toolsFileName string) (string, error) {
|
||||
prebuiltJSON, err := json.Marshal(config.prebuiltConfigs)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error marshaling prebuilt configs: %w", err)
|
||||
}
|
||||
|
||||
data := scriptData{
|
||||
Name: name,
|
||||
PrebuiltNamesJSON: string(prebuiltJSON),
|
||||
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
|
||||
}
|
||||
|
||||
func formatParameters(params []Parameter) (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
|
||||
}
|
||||
|
||||
func generateFilteredConfig(toolsFile, toolName string) ([]byte, error) {
|
||||
data, err := os.ReadFile(toolsFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error reading %s: %w", toolsFile, err)
|
||||
}
|
||||
var cfg Config
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("error parsing YAML from %s: %w", toolsFile, err)
|
||||
}
|
||||
|
||||
if _, ok := cfg.Tools[toolName]; !ok {
|
||||
return nil, nil // Tool not found in this file
|
||||
}
|
||||
|
||||
filteredCfg := Config{
|
||||
Tools: map[string]map[string]interface{}{
|
||||
toolName: cfg.Tools[toolName],
|
||||
},
|
||||
}
|
||||
|
||||
// Add relevant source if exists
|
||||
if src, ok := cfg.Tools[toolName]["source"].(string); ok && src != "" {
|
||||
if sourceData, exists := cfg.Sources[src]; exists {
|
||||
if filteredCfg.Sources == nil {
|
||||
filteredCfg.Sources = make(map[string]interface{})
|
||||
}
|
||||
filteredCfg.Sources[src] = sourceData
|
||||
}
|
||||
}
|
||||
|
||||
filteredData, err := yaml.Marshal(filteredCfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error marshaling filtered tools for %s: %w", toolName, err)
|
||||
}
|
||||
return filteredData, nil
|
||||
}
|
||||
@@ -1,306 +0,0 @@
|
||||
// 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 (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/goccy/go-yaml"
|
||||
"github.com/google/go-cmp/cmp"
|
||||
)
|
||||
|
||||
func TestFormatParameters(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
params []Parameter
|
||||
wantContains []string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "empty parameters",
|
||||
params: []Parameter{},
|
||||
wantContains: []string{""},
|
||||
},
|
||||
{
|
||||
name: "single required string parameter",
|
||||
params: []Parameter{
|
||||
{
|
||||
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: []Parameter{
|
||||
{
|
||||
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) {
|
||||
tools := map[string]Tool{
|
||||
"tool1": {
|
||||
Name: "tool1",
|
||||
Description: "First tool",
|
||||
Parameters: []Parameter{
|
||||
{Name: "p1", Type: "string", Description: "d1", Required: true},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got, err := generateSkillMarkdown("MySkill", "My Description", tools)
|
||||
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/<script_name>.js '{\"<param_name>\": \"<param_value>\"}'`",
|
||||
"**PowerShell:**",
|
||||
"`node scripts/<script_name>.js '{\\\"<param_name>\\\": \\\"<param_value>\\\"}'`",
|
||||
"## Scripts",
|
||||
"### tool1",
|
||||
"First tool",
|
||||
"## Parameters",
|
||||
}
|
||||
|
||||
for _, s := range expectedSubstrings {
|
||||
if !strings.Contains(got, s) {
|
||||
t.Errorf("generateSkillMarkdown() missing substring %q", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateShellScriptContent(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
toolName string
|
||||
config serverConfig
|
||||
toolsFileName string
|
||||
wantContains []string
|
||||
}{
|
||||
{
|
||||
name: "basic script",
|
||||
toolName: "test-tool",
|
||||
config: serverConfig{
|
||||
prebuiltConfigs: []string{},
|
||||
},
|
||||
toolsFileName: "",
|
||||
wantContains: []string{
|
||||
`const toolName = "test-tool";`,
|
||||
`const prebuiltNames = [];`,
|
||||
`const toolsFileName = "";`,
|
||||
`const toolboxArgs = [...configArgs, "invoke", toolName, ...args];`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "script with prebuilts and tools file",
|
||||
toolName: "complex-tool",
|
||||
config: serverConfig{
|
||||
prebuiltConfigs: []string{"pre1", "pre2"},
|
||||
},
|
||||
toolsFileName: "tools.yaml",
|
||||
wantContains: []string{
|
||||
`const toolName = "complex-tool";`,
|
||||
`const prebuiltNames = ["pre1","pre2"];`,
|
||||
`const toolsFileName = "tools.yaml";`,
|
||||
`configArgs.push("--prebuilt", name);`,
|
||||
`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.config, tt.toolsFileName)
|
||||
if err != nil {
|
||||
t.Fatalf("generateShellScriptContent() error = %v", err)
|
||||
}
|
||||
|
||||
for _, s := range tt.wantContains {
|
||||
if !strings.Contains(got, s) {
|
||||
t.Errorf("generateShellScriptContent() missing substring %q\nGot:\n%s", s, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateFilteredConfig(t *testing.T) {
|
||||
// Setup temporary directory and file
|
||||
tmpDir := t.TempDir()
|
||||
toolsFile := filepath.Join(tmpDir, "tools.yaml")
|
||||
|
||||
configContent := `
|
||||
sources:
|
||||
src1:
|
||||
type: "postgres"
|
||||
connection_string: "conn1"
|
||||
src2:
|
||||
type: "mysql"
|
||||
connection_string: "conn2"
|
||||
tools:
|
||||
tool1:
|
||||
source: "src1"
|
||||
query: "SELECT 1"
|
||||
tool2:
|
||||
source: "src2"
|
||||
query: "SELECT 2"
|
||||
tool3:
|
||||
type: "http" # No source
|
||||
`
|
||||
if err := os.WriteFile(toolsFile, []byte(configContent), 0644); err != nil {
|
||||
t.Fatalf("Failed to create temp tools file: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
toolName string
|
||||
wantCfg Config
|
||||
wantErr bool
|
||||
wantNil bool
|
||||
}{
|
||||
{
|
||||
name: "tool with source",
|
||||
toolName: "tool1",
|
||||
wantCfg: Config{
|
||||
Sources: map[string]interface{}{
|
||||
"src1": map[string]interface{}{
|
||||
"type": "postgres",
|
||||
"connection_string": "conn1",
|
||||
},
|
||||
},
|
||||
Tools: map[string]map[string]interface{}{
|
||||
"tool1": {
|
||||
"source": "src1",
|
||||
"query": "SELECT 1",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "tool without source",
|
||||
toolName: "tool3",
|
||||
wantCfg: Config{
|
||||
Tools: map[string]map[string]interface{}{
|
||||
"tool3": {
|
||||
"type": "http",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "non-existent tool",
|
||||
toolName: "missing-tool",
|
||||
wantNil: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotBytes, err := generateFilteredConfig(toolsFile, tt.toolName)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("generateFilteredConfig() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if tt.wantErr {
|
||||
return
|
||||
}
|
||||
|
||||
if tt.wantNil {
|
||||
if gotBytes != nil {
|
||||
t.Errorf("generateFilteredConfig() expected nil, got %s", string(gotBytes))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
var gotCfg Config
|
||||
if err := yaml.Unmarshal(gotBytes, &gotCfg); err != nil {
|
||||
t.Errorf("Failed to unmarshal result: %v", err)
|
||||
}
|
||||
|
||||
if diff := cmp.Diff(tt.wantCfg, gotCfg); diff != "" {
|
||||
t.Errorf("generateFilteredConfig() mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user