diff --git a/DEVELOPER.md b/DEVELOPER.md index 59527a6176d2..56bcbc32ecb4 100644 --- a/DEVELOPER.md +++ b/DEVELOPER.md @@ -530,7 +530,6 @@ When adding or updating a Source page, your markdown file must strictly adhere t When adding or updating a Tool page, your markdown file must strictly adhere to the following architectural rules: * **Location:** Native tools must be placed inside a nested `tools/` directory. - * **Frontmatter:** The `title` field must end with the word "Tool" (e.g., `title: "execute-sql Tool"`). * **No H1 Headings:** Do not use H1 (`#`) tags in the markdown body. The page title is automatically generated from the frontmatter. * **H2 Heading Hierarchy:** You must use H2 (`##`) headings in a strict, specific order. * **Required Headings:** `About`, `Example` diff --git a/GEMINI.md b/GEMINI.md index 0c113e047775..3add708c8a65 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -201,9 +201,8 @@ When generating or editing documentation for this repository, you must strictly ##### Tool Page Constraints (`integrations/**/tools/*.md`) 1. **Location:** All native tools must reside inside a nested `tools/` subdirectory. The `tools/` directory must contain an `_index.md` file consisting **strictly of frontmatter**. -2. **Title Convention:** The YAML frontmatter `title` must always end with "Tool" (e.g., `title: "Execute SQL Tool"`). -3. **No H1 Tags:** Never generate H1 (`#`) headings in the markdown body. -4. **Strict H2 Ordering:** You must use the following H2 (`##`) headings in this exact sequence. +2. **No H1 Tags:** Never generate H1 (`#`) headings in the markdown body. +3. **Strict H2 Ordering:** You must use the following H2 (`##`) headings in this exact sequence. * `## About` (Required) * `## Compatible Sources` (Optional) * `## Requirements` (Optional) @@ -214,7 +213,7 @@ When generating or editing documentation for this repository, you must strictly * `## Advanced Usage` (Optional) * `## Troubleshooting` (Optional) * `## Additional Resources` (Optional) -5. **Shortcode Placement:** If you generate the `## Compatible Sources` section, you must include the `{{< compatible-sources >}}` shortcode beneath it. +4. **Shortcode Placement:** If you generate the `## Compatible Sources` section, you must include the `{{< compatible-sources >}}` shortcode beneath it. ##### Samples Architecture Constraints Sample code is aggregated visually in the UI via the Samples section, but the physical markdown files are distributed logically based on their scope. diff --git a/cmd/internal/imports.go b/cmd/internal/imports.go index 126f9451a0a0..018d0c67012f 100644 --- a/cmd/internal/imports.go +++ b/cmd/internal/imports.go @@ -113,13 +113,16 @@ import ( _ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookeradddashboardelement" _ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookeradddashboardfilter" _ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookerconversationalanalytics" + _ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookercreateagent" _ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookercreateprojectdirectory" _ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookercreateprojectfile" _ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookercreateviewfromtable" + _ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookerdeleteagent" _ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookerdeleteprojectdirectory" _ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookerdeleteprojectfile" _ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookerdevmode" _ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookergenerateembedurl" + _ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookergetagent" _ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookergetconnectiondatabases" _ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookergetconnections" _ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookergetconnectionschemas" @@ -142,6 +145,7 @@ import ( _ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookerhealthanalyze" _ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookerhealthpulse" _ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookerhealthvacuum" + _ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookerlistagents" _ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookermakedashboard" _ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookermakelook" _ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookerquery" @@ -150,6 +154,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/lookerrunlookmltests" + _ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookerupdateagent" _ "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" diff --git a/docs/en/integrations/looker/tools/looker-create-agent.md b/docs/en/integrations/looker/tools/looker-create-agent.md new file mode 100644 index 000000000000..608a57656e9e --- /dev/null +++ b/docs/en/integrations/looker/tools/looker-create-agent.md @@ -0,0 +1,51 @@ +--- +title: "looker-create-agent Tool" +type: docs +weight: 1 +description: > + "looker-create-agent" creates a Looker Conversation Analytics agent. +--- + +## About + +The `looker-create-agent` tool allows LLMs to create a Looker Agent using the Looker Go SDK. + +```json +{ + "name": "looker-create-agent", + "parameters": { + "name": "My Agent", + "instructions": "You are a helpful assistant.", + "sources": [{"model": "my_model", "explore": "my_explore"}], + "code_interpreter": true + } +} +``` + +## Compatible Sources + +{{< compatible-sources >}} + +## Example + +```yaml +kind: tool +name: create_agent +type: looker-create-agent +source: my-looker-instance +description: | + Create a new Looker agent. + - `name` (string): The name of the agent. + - `description` (string): The description of the agent. + - `instructions` (string): The instructions (system prompt) for the agent. + - `sources` (array): Optional. A list of JSON-encoded data sources for the agent (e.g., `[{"model": "my_model", "explore": "my_explore"}]`). + - `code_interpreter` (boolean): Optional. Enables Code Interpreter for this Agent. +``` + +## Reference + +| **field** | **type** | **required** | **description** | +|-------------|:--------:|:------------:|----------------------------------------------------| +| type | string | true | Must be "looker-create-agent". | +| source | string | true | Name of the Looker source. | +| description | string | true | Description of the tool that is passed to the LLM. | diff --git a/docs/en/integrations/looker/tools/looker-delete-agent.md b/docs/en/integrations/looker/tools/looker-delete-agent.md new file mode 100644 index 000000000000..76612b2c1f45 --- /dev/null +++ b/docs/en/integrations/looker/tools/looker-delete-agent.md @@ -0,0 +1,46 @@ +--- +title: "looker-delete-agent Tool" +type: docs +weight: 1 +description: > + "looker-delete-agent" deletes a Looker Conversation Analytics agent. +--- + +## About + +The `looker-delete-agent` tool allows LLMs to delete a Looker Agent using the Looker Go SDK. + +```json +{ + "name": "looker-delete-agent", + "parameters": { + "agent_id": "123" + } +} +``` + +## Compatible Sources + +{{< compatible-sources >}} + +## Example + +To use the `looker-delete-agent` tool, you must define it in your `server.yaml` file. + +```yaml +kind: tool +name: delete_agent +type: looker-delete-agent +source: my-looker-instance +description: | + Delete a Looker agent. + - `agent_id` (string): The ID of the agent. +``` + +## Reference + +| **field** | **type** | **required** | **description** | +|-------------|:--------:|:------------:|----------------------------------------------------| +| type | string | true | Must be "looker-delete-agent". | +| source | string | true | Name of the Looker source. | +| description | string | true | Description of the tool that is passed to the LLM. | diff --git a/docs/en/integrations/looker/tools/looker-get-agent.md b/docs/en/integrations/looker/tools/looker-get-agent.md new file mode 100644 index 000000000000..fdfbf56c6d7a --- /dev/null +++ b/docs/en/integrations/looker/tools/looker-get-agent.md @@ -0,0 +1,46 @@ +--- +title: "looker-get-agent Tool" +type: docs +weight: 1 +description: > + "looker-get-agent" retrieves a Looker Conversation Analytics agent. +--- + +## About + +The `looker-get-agent` tool allows LLMs to retrieve a specific Looker Agent by ID using the Looker Go SDK. + +To use the `looker-get-agent` tool, you must define it in your `server.yaml` file. + +```json +{ + "name": "looker-get-agent", + "parameters": { + "agent_id": "123" + } +} +``` + +## Compatible Sources + +{{< compatible-sources >}} + +## Example + +```yaml +kind: tool +name: get_agent +type: looker-get-agent +source: my-looker-instance +description: | + Retrieve a Looker agent. + - `agent_id` (string): The ID of the agent. +``` + +## Reference + +| **field** | **type** | **required** | **description** | +|-------------|:--------:|:------------:|----------------------------------------------------| +| type | string | true | Must be "looker-get-agent". | +| source | string | true | Name of the Looker source. | +| description | string | true | Description of the tool that is passed to the LLM. | diff --git a/docs/en/integrations/looker/tools/looker-list-agents.md b/docs/en/integrations/looker/tools/looker-list-agents.md new file mode 100644 index 000000000000..43a4f312cfdd --- /dev/null +++ b/docs/en/integrations/looker/tools/looker-list-agents.md @@ -0,0 +1,43 @@ +--- +title: "looker-list-agents Tool" +type: docs +weight: 1 +description: > + "looker-list-agents" retrieves the list of Looker Conversation Analytics agents. +--- + +## About + +The `looker-list-agents` tool allows LLMs to list Looker Agents using the Looker Go SDK. + +```json +{ + "name": "looker-list-agents" +} +``` + +## Compatible Sources + +{{< compatible-sources >}} + +## Example + +To use the `looker-list-agents` tool, you must define it in your `server.yaml` file. + +```yaml +kind: tool +name: list_agents +type: looker-list-agents +source: my-looker-instance +description: | + List all Looker agents. + This tool takes no parameters. +``` + +## Reference + +| **field** | **type** | **required** | **description** | +|-------------|:--------:|:------------:|----------------------------------------------------| +| type | string | true | Must be "looker-list-agents". | +| source | string | true | Name of the Looker source. | +| description | string | true | Description of the tool that is passed to the LLM. | diff --git a/docs/en/integrations/looker/tools/looker-update-agent.md b/docs/en/integrations/looker/tools/looker-update-agent.md new file mode 100644 index 000000000000..470c63f6b506 --- /dev/null +++ b/docs/en/integrations/looker/tools/looker-update-agent.md @@ -0,0 +1,52 @@ +--- +title: "looker-update-agent Tool" +type: docs +weight: 1 +description: > + "looker-update-agent" updates a Looker Conversation Analytics agent. +--- + +## About + +The `looker-update-agent` tool allows LLMs to update an existing Looker Agent using the Looker Go SDK. + +```json +{ + "name": "looker-update-agent", + "parameters": { + "agent_id": "123", + "name": "Updated Agent Name" + } +} +``` + +## Compatible Sources + +{{< compatible-sources >}} + +## Example + +To use the `looker-update-agent` tool, you must define it in your `server.yaml` file. + +```yaml +kind: tool +name: update_agent +type: looker-update-agent +source: my-looker-instance +description: | + Update a Looker agent. + - `agent_id` (string): The ID of the agent. + - `name` (string): The name of the agent. + - `description` (string): The description of the agent. + - `instructions` (string): The instructions (system prompt) for the agent. + - `sources` (array): Optional. A list of JSON-encoded data sources for the agent (e.g., `[{"model": "my_model", "explore": "my_explore"}]`). + - `code_interpreter` (boolean): Optional. Enables Code Interpreter for this Agent. +``` + +## Reference + +| **field** | **type** | **required** | **description** | +|-------------|:--------:|:------------:|----------------------------------------------------| +| type | string | true | Must be "looker-update-agent". | +| source | string | true | Name of the Looker source. | +| description | string | true | Description of the tool that is passed to the LLM. | diff --git a/internal/tools/looker/lookercreateagent/lookercreateagent.go b/internal/tools/looker/lookercreateagent/lookercreateagent.go new file mode 100644 index 000000000000..c6ffa196b466 --- /dev/null +++ b/internal/tools/looker/lookercreateagent/lookercreateagent.go @@ -0,0 +1,239 @@ +// 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 lookercreateagent + +import ( + "context" + "fmt" + "net/http" + + 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-create-agent" + +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) { + nameParameter := parameters.NewStringParameterWithDefault("name", "", "The name of the agent.") + descriptionParameter := parameters.NewStringParameterWithDefault("description", "", "The description of the agent.") + instructionsParameter := parameters.NewStringParameterWithDefault("instructions", "", "The instructions (system prompt) for the agent.") + sourcesParameter := parameters.NewArrayParameterWithRequired( + "sources", + "Optional. A list of JSON-encoded data sources for the agent (e.g., [{\"model\": \"my_model\", \"explore\": \"my_explore\"}]).", + false, + parameters.NewMapParameter( + "source", + "A JSON-encoded source object with 'model' and 'explore' keys.", + "string", + ), + ) + codeInterpreterParameter := parameters.NewBooleanParameterWithDefault("code_interpreter", false, "Optional. Enables Code Interpreter for this Agent.") + params := parameters.Parameters{nameParameter, descriptionParameter, instructionsParameter, sourcesParameter, codeInterpreterParameter} + + annotations := &tools.ToolAnnotations{} + if cfg.Annotations != nil { + *annotations = *cfg.Annotations + } + readOnlyHint := false + annotations.ReadOnlyHint = &readOnlyHint + + mcpManifest := tools.GetMcpManifest(cfg.Name, cfg.Description, cfg.AuthRequired, params, annotations) + + 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, util.ToolboxError) { + source, err := tools.GetCompatibleSource[compatibleSource](resourceMgr, t.Source, t.Name, t.Type) + if err != nil { + return nil, util.NewClientServerError("source used is not compatible with the tool", http.StatusInternalServerError, err) + } + + logger, err := util.LoggerFromContext(ctx) + if err != nil { + return nil, util.NewClientServerError("unable to get logger from ctx", http.StatusInternalServerError, err) + } + + sdk, err := source.GetLookerSDK(string(accessToken)) + if err != nil { + return nil, util.NewClientServerError(fmt.Sprintf("error getting sdk: %v", err), http.StatusInternalServerError, err) + } + + mapParams := params.AsMap() + logger.DebugContext(ctx, fmt.Sprintf("%s params = ", t.Name), mapParams) + + var name, description, instructions string + if v, ok := mapParams["name"].(string); ok { + name = v + } + if v, ok := mapParams["description"].(string); ok { + description = v + } + if v, ok := mapParams["instructions"].(string); ok { + instructions = v + } + + agentSources := make([]v4.Source, 0) + if sources, ok := mapParams["sources"].([]any); ok { + for _, s := range sources { + source := s.(map[string]any) + model, ok := source["model"].(string) + if !ok { + return nil, util.NewClientServerError("invalid source format: expected model of type string", http.StatusBadRequest, nil) + } + explore, ok := source["explore"].(string) + if !ok { + return nil, util.NewClientServerError("invalid source format: expected explore of type string", http.StatusBadRequest, nil) + } + agentSources = append(agentSources, v4.Source{ + Model: &model, + Explore: &explore, + }) + } + } else { + return nil, util.NewClientServerError(fmt.Sprintf("invalid sources. got %T, expected []any", mapParams["sources"]), http.StatusBadRequest, nil) + } + + codeInterpreter, hasCodeInterpreter := mapParams["code_interpreter"].(bool) + + if name == "" { + return nil, util.NewClientServerError(fmt.Sprintf("%s operation: name must be specified", t.Type), http.StatusBadRequest, nil) + } + body := v4.WriteAgent{ + Name: &name, + } + if description != "" { + body.Description = &description + } + if instructions != "" { + context := v4.Context{ + Instructions: &instructions, + } + body.Context = &context + } + if len(agentSources) > 0 { + body.Sources = &agentSources + } + if hasCodeInterpreter { + body.CodeInterpreter = &codeInterpreter + } + resp, err := sdk.CreateAgent(body, "", source.LookerApiSettings()) + if err != nil { + return nil, util.NewClientServerError(fmt.Sprintf("error making create_agent request: %s", err), http.StatusInternalServerError, err) + } + 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) 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) Authorized(verifiedAuthServices []string) bool { + return tools.IsAuthorized(t.AuthRequired, verifiedAuthServices) +} + +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/lookercreateagent/lookercreateagent_test.go b/internal/tools/looker/lookercreateagent/lookercreateagent_test.go new file mode 100644 index 000000000000..7c6b1118a142 --- /dev/null +++ b/internal/tools/looker/lookercreateagent/lookercreateagent_test.go @@ -0,0 +1,290 @@ +// 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 lookercreateagent_test + +import ( + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/googleapis/genai-toolbox/internal/server" + "github.com/googleapis/genai-toolbox/internal/sources" + "github.com/googleapis/genai-toolbox/internal/testutils" + "github.com/googleapis/genai-toolbox/internal/tools" + lkr "github.com/googleapis/genai-toolbox/internal/tools/looker/lookercreateagent" + "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" +) + +func TestParseFromYaml(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: tool + name: test_tool + type: looker-create-agent + source: my-instance + description: some description + `, + want: server.ToolConfigs{ + "test_tool": lkr.Config{ + Name: "test_tool", + Type: "looker-create-agent", + Source: "my-instance", + Description: "some description", + AuthRequired: []string{}, + }, + }, + }, + } + for _, tc := range tcs { + t.Run(tc.desc, func(t *testing.T) { + _, _, _, 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 TestFailParseFromYaml(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: tool + name: test_tool + type: looker-create-agent + source: my-instance + method: GOT + description: some description + `, + err: "unknown field \"method\"", + }, + } + for _, tc := range tcs { + t.Run(tc.desc, func(t *testing.T) { + _, _, _, _, _, _, 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) + } + }) + } +} + +type MockSource struct { + sources.Source +} + +func (m MockSource) UseClientAuthorization() bool { + return false +} + +func (m MockSource) GetAuthTokenHeaderName() string { + return "Authorization" +} + +func (m MockSource) LookerApiSettings() *rtl.ApiSettings { + return &rtl.ApiSettings{} +} + +func (m MockSource) GetLookerSDK(string) (*v4.LookerSDK, error) { + return &v4.LookerSDK{}, nil +} + +type MockSourceProvider struct { + tools.SourceProvider + source MockSource +} + +func (m MockSourceProvider) GetSource(name string) (sources.Source, bool) { + return m.source, true +} + +func TestInvokeValidation(t *testing.T) { + ctx, err := testutils.ContextWithNewLogger() + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + cfg := lkr.Config{ + Name: "test_tool", + Type: "looker-create-agent", + Source: "my-instance", + Description: "test description", + } + + tool, err := cfg.Initialize(nil) + if err != nil { + t.Fatalf("failed to initialize tool: %v", err) + } + + resourceMgr := MockSourceProvider{source: MockSource{}} + + tcs := []struct { + desc string + params parameters.ParamValues + wantErr string + }{ + { + desc: "missing name", + params: parameters.ParamValues{ + {Name: "description", Value: "description test"}, + {Name: "sources", Value: []any{ + map[string]any{"model": "test", "explore": "test"}, + }}, + }, + wantErr: "name must be specified", + }, + { + desc: "invalid source format", + params: parameters.ParamValues{ + {Name: "name", Value: "test"}, + {Name: "description", Value: "description test"}, + {Name: "sources", Value: []any{ + map[string]any{"model": 123, "explore": "test"}, + }}, + }, + wantErr: "invalid source format: expected model of type string", + }, + } + + for _, tc := range tcs { + t.Run(tc.desc, func(t *testing.T) { + _, err := tool.Invoke(ctx, resourceMgr, tc.params, "") + if err == nil { + t.Fatalf("expect error, got nil") + } + if !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("unexpected error: got %q, want substring %q", err.Error(), tc.wantErr) + } + }) + } + +} + +func TestManifest(t *testing.T) { + cfg := lkr.Config{ + Name: "test_tool", + Type: "looker-create-agent", + Source: "my-instance", + Description: "test description", + } + + tool, err := cfg.Initialize(nil) + if err != nil { + t.Fatalf("failed to initialize tool: %v", err) + } + + manifest := tool.Manifest() + if manifest.Description != cfg.Description { + t.Errorf("manifest description mismatch: got %q, want %q", manifest.Description, cfg.Description) + } + + expectedParams := []string{"name", "instructions", "sources", "code_interpreter"} + for _, p := range expectedParams { + found := false + for _, mp := range manifest.Parameters { + if mp.Name == p { + found = true + break + } + } + if !found { + t.Errorf("expected parameter %q not found in manifest", p) + } + } +} + +func TestMcpManifest(t *testing.T) { + cfg := lkr.Config{ + Name: "test_tool", + Type: "looker-create-agent", + Source: "my-instance", + Description: "test description", + } + + tool, err := cfg.Initialize(nil) + if err != nil { + t.Fatalf("failed to initialize tool: %v", err) + } + + mcp := tool.McpManifest() + if mcp.Name != cfg.Name { + t.Errorf("mcp manifest name mismatch: got %q, want %q", mcp.Name, cfg.Name) + } + + properties := mcp.InputSchema.Properties + expectedParams := []string{"name", "instructions", "sources", "code_interpreter"} + for _, p := range expectedParams { + if _, ok := properties[p]; !ok { + t.Errorf("parameter %q not found in MCP properties", p) + } + } +} + +func TestAnnotations(t *testing.T) { + readOnlyTrue := true + cfg := lkr.Config{ + Name: "test_tool", + Type: "looker-create-agent", + Source: "my-instance", + Description: "test description", + Annotations: &tools.ToolAnnotations{ + ReadOnlyHint: &readOnlyTrue, + }, + } + + tool, err := cfg.Initialize(nil) + if err != nil { + t.Fatalf("failed to initialize tool: %v", err) + } + + mcp := tool.McpManifest() + if mcp.Annotations == nil { + t.Fatal("mcp manifest annotations is nil") + } + if mcp.Annotations.ReadOnlyHint == nil { + t.Fatal("mcp manifest ReadOnlyHint is nil") + } + if *mcp.Annotations.ReadOnlyHint != false { + t.Errorf("ReadOnlyHint should be false, got %v", *mcp.Annotations.ReadOnlyHint) + } +} diff --git a/internal/tools/looker/lookerdeleteagent/lookerdeleteagent.go b/internal/tools/looker/lookerdeleteagent/lookerdeleteagent.go new file mode 100644 index 000000000000..fffdf9f6949a --- /dev/null +++ b/internal/tools/looker/lookerdeleteagent/lookerdeleteagent.go @@ -0,0 +1,181 @@ +// 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 lookerdeleteagent + +import ( + "context" + "fmt" + "net/http" + + 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-delete-agent" + +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) { + agentIdParameter := parameters.NewStringParameterWithDefault("agent_id", "", "The ID of the agent.") + params := parameters.Parameters{agentIdParameter} + + annotations := &tools.ToolAnnotations{} + if cfg.Annotations != nil { + *annotations = *cfg.Annotations + } + readOnlyHint := false + destructiveHint := true + annotations.ReadOnlyHint = &readOnlyHint + annotations.DestructiveHint = &destructiveHint + + mcpManifest := tools.GetMcpManifest(cfg.Name, cfg.Description, cfg.AuthRequired, params, annotations) + + 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, util.ToolboxError) { + source, err := tools.GetCompatibleSource[compatibleSource](resourceMgr, t.Source, t.Name, t.Type) + if err != nil { + return nil, util.NewClientServerError("source used is not compatible with the tool", http.StatusInternalServerError, err) + } + + logger, err := util.LoggerFromContext(ctx) + if err != nil { + return nil, util.NewClientServerError("unable to get logger from ctx", http.StatusInternalServerError, err) + } + + sdk, err := source.GetLookerSDK(string(accessToken)) + if err != nil { + return nil, util.NewClientServerError(fmt.Sprintf("error getting sdk: %v", err), http.StatusInternalServerError, err) + } + + mapParams := params.AsMap() + logger.DebugContext(ctx, fmt.Sprintf("%s params = ", t.Name), mapParams) + + var agentId string + if v, ok := mapParams["agent_id"].(string); ok { + agentId = v + } + + if agentId == "" { + return nil, util.NewClientServerError(fmt.Sprintf("%s operation: agent_id must be specified", t.Type), http.StatusBadRequest, nil) + } + resp, err := sdk.DeleteAgent(agentId, "", source.LookerApiSettings()) + if err != nil { + return nil, util.NewClientServerError(fmt.Sprintf("error making delete_agent request: %s", err), http.StatusInternalServerError, err) + } + 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) 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) Authorized(verifiedAuthServices []string) bool { + return tools.IsAuthorized(t.AuthRequired, verifiedAuthServices) +} + +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/lookerdeleteagent/lookerdeleteagent_test.go b/internal/tools/looker/lookerdeleteagent/lookerdeleteagent_test.go new file mode 100644 index 000000000000..0b5c2ab0a3c9 --- /dev/null +++ b/internal/tools/looker/lookerdeleteagent/lookerdeleteagent_test.go @@ -0,0 +1,282 @@ +// 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 lookerdeleteagent_test + +import ( + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/googleapis/genai-toolbox/internal/server" + "github.com/googleapis/genai-toolbox/internal/sources" + "github.com/googleapis/genai-toolbox/internal/testutils" + "github.com/googleapis/genai-toolbox/internal/tools" + lkr "github.com/googleapis/genai-toolbox/internal/tools/looker/lookerdeleteagent" + "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" +) + +func TestParseFromYaml(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: tool + name: test_tool + type: looker-delete-agent + source: my-instance + description: some description + `, + want: server.ToolConfigs{ + "test_tool": lkr.Config{ + Name: "test_tool", + Type: "looker-delete-agent", + Source: "my-instance", + Description: "some description", + AuthRequired: []string{}, + }, + }, + }, + } + for _, tc := range tcs { + t.Run(tc.desc, func(t *testing.T) { + _, _, _, 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 TestFailParseFromYaml(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: tool + name: test_tool + type: looker-delete-agent + source: my-instance + method: GOT + description: some description + `, + err: "unknown field \"method\"", + }, + } + for _, tc := range tcs { + t.Run(tc.desc, func(t *testing.T) { + _, _, _, _, _, _, 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) + } + }) + } +} + +type MockSource struct { + sources.Source +} + +func (m MockSource) UseClientAuthorization() bool { + return false +} + +func (m MockSource) GetAuthTokenHeaderName() string { + return "Authorization" +} + +func (m MockSource) LookerApiSettings() *rtl.ApiSettings { + return &rtl.ApiSettings{} +} + +func (m MockSource) GetLookerSDK(string) (*v4.LookerSDK, error) { + return &v4.LookerSDK{}, nil +} + +type MockSourceProvider struct { + tools.SourceProvider + source MockSource +} + +func (m MockSourceProvider) GetSource(name string) (sources.Source, bool) { + return m.source, true +} + +func TestInvokeValidation(t *testing.T) { + ctx, err := testutils.ContextWithNewLogger() + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + cfg := lkr.Config{ + Name: "test_tool", + Type: "looker-delete-agent", + Source: "my-instance", + Description: "test description", + } + + tool, err := cfg.Initialize(nil) + if err != nil { + t.Fatalf("failed to initialize tool: %v", err) + } + + resourceMgr := MockSourceProvider{source: MockSource{}} + + tcs := []struct { + desc string + params parameters.ParamValues + wantErr string + }{ + { + desc: "missing agent_id", + params: parameters.ParamValues{}, + wantErr: "agent_id must be specified", + }, + } + + for _, tc := range tcs { + t.Run(tc.desc, func(t *testing.T) { + _, err := tool.Invoke(ctx, resourceMgr, tc.params, "") + if err == nil { + t.Fatalf("expect error, got nil") + } + if !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("unexpected error: got %q, want substring %q", err.Error(), tc.wantErr) + } + }) + } + +} + +func TestManifest(t *testing.T) { + cfg := lkr.Config{ + Name: "test_tool", + Type: "looker-delete-agent", + Source: "my-instance", + Description: "test description", + } + + tool, err := cfg.Initialize(nil) + if err != nil { + t.Fatalf("failed to initialize tool: %v", err) + } + + manifest := tool.Manifest() + if manifest.Description != cfg.Description { + t.Errorf("manifest description mismatch: got %q, want %q", manifest.Description, cfg.Description) + } + + expectedParams := []string{"agent_id"} + for _, p := range expectedParams { + found := false + for _, mp := range manifest.Parameters { + if mp.Name == p { + found = true + break + } + } + if !found { + t.Errorf("expected parameter %q not found in manifest", p) + } + } +} + +func TestMcpManifest(t *testing.T) { + cfg := lkr.Config{ + Name: "test_tool", + Type: "looker-delete-agent", + Source: "my-instance", + Description: "test description", + } + + tool, err := cfg.Initialize(nil) + if err != nil { + t.Fatalf("failed to initialize tool: %v", err) + } + + mcp := tool.McpManifest() + if mcp.Name != cfg.Name { + t.Errorf("mcp manifest name mismatch: got %q, want %q", mcp.Name, cfg.Name) + } + + properties := mcp.InputSchema.Properties + expectedParams := []string{"agent_id"} + for _, p := range expectedParams { + if _, ok := properties[p]; !ok { + t.Errorf("parameter %q not found in MCP properties", p) + } + } +} + +func TestAnnotations(t *testing.T) { + readOnlyTrue := true + destructiveFalse := false + cfg := lkr.Config{ + Name: "test_tool", + Type: "looker-delete-agent", + Source: "my-instance", + Description: "test description", + Annotations: &tools.ToolAnnotations{ + ReadOnlyHint: &readOnlyTrue, + DestructiveHint: &destructiveFalse, + }, + } + + tool, err := cfg.Initialize(nil) + if err != nil { + t.Fatalf("failed to initialize tool: %v", err) + } + + mcp := tool.McpManifest() + if mcp.Annotations == nil { + t.Fatal("mcp manifest annotations is nil") + } + if mcp.Annotations.ReadOnlyHint == nil { + t.Fatal("mcp manifest ReadOnlyHint is nil") + } + if *mcp.Annotations.ReadOnlyHint != false { + t.Errorf("ReadOnlyHint should be false, got %v", *mcp.Annotations.ReadOnlyHint) + } + if mcp.Annotations.DestructiveHint == nil { + t.Fatal("mcp manifest DestructiveHint is nil") + } + if *mcp.Annotations.DestructiveHint != true { + t.Errorf("DestructiveHint should be true, got %v", *mcp.Annotations.DestructiveHint) + } +} diff --git a/internal/tools/looker/lookergetagent/lookergetagent.go b/internal/tools/looker/lookergetagent/lookergetagent.go new file mode 100644 index 000000000000..0da8fdae87af --- /dev/null +++ b/internal/tools/looker/lookergetagent/lookergetagent.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 lookergetagent + +import ( + "context" + "fmt" + "net/http" + + 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-get-agent" + +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) { + agentIdParameter := parameters.NewStringParameterWithDefault("agent_id", "", "The ID of the agent.") + params := parameters.Parameters{agentIdParameter} + + annotations := &tools.ToolAnnotations{} + if cfg.Annotations != nil { + *annotations = *cfg.Annotations + } + readOnlyHint := true + annotations.ReadOnlyHint = &readOnlyHint + + mcpManifest := tools.GetMcpManifest(cfg.Name, cfg.Description, cfg.AuthRequired, params, annotations) + + 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, util.ToolboxError) { + source, err := tools.GetCompatibleSource[compatibleSource](resourceMgr, t.Source, t.Name, t.Type) + if err != nil { + return nil, util.NewClientServerError("source used is not compatible with the tool", http.StatusInternalServerError, err) + } + + logger, err := util.LoggerFromContext(ctx) + if err != nil { + return nil, util.NewClientServerError("unable to get logger from ctx", http.StatusInternalServerError, err) + } + + sdk, err := source.GetLookerSDK(string(accessToken)) + if err != nil { + return nil, util.NewClientServerError(fmt.Sprintf("error getting sdk: %v", err), http.StatusInternalServerError, err) + } + + mapParams := params.AsMap() + logger.DebugContext(ctx, fmt.Sprintf("%s params = ", t.Name), mapParams) + + var agentId string + if v, ok := mapParams["agent_id"].(string); ok { + agentId = v + } + + if agentId == "" { + return nil, util.NewClientServerError(fmt.Sprintf("%s operation: agent_id must be specified", t.Type), http.StatusBadRequest, nil) + } + resp, err := sdk.GetAgent(agentId, "", source.LookerApiSettings()) + if err != nil { + return nil, util.NewClientServerError(fmt.Sprintf("error making get_agent request: %s", err), http.StatusInternalServerError, err) + } + 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) 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) Authorized(verifiedAuthServices []string) bool { + return tools.IsAuthorized(t.AuthRequired, verifiedAuthServices) +} + +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/lookergetagent/lookergetagent_test.go b/internal/tools/looker/lookergetagent/lookergetagent_test.go new file mode 100644 index 000000000000..6fba53ced2af --- /dev/null +++ b/internal/tools/looker/lookergetagent/lookergetagent_test.go @@ -0,0 +1,274 @@ +// 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 lookergetagent_test + +import ( + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/googleapis/genai-toolbox/internal/server" + "github.com/googleapis/genai-toolbox/internal/sources" + "github.com/googleapis/genai-toolbox/internal/testutils" + "github.com/googleapis/genai-toolbox/internal/tools" + lkr "github.com/googleapis/genai-toolbox/internal/tools/looker/lookergetagent" + "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" +) + +func TestParseFromYaml(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: tool + name: test_tool + type: looker-get-agent + source: my-instance + description: some description + `, + want: server.ToolConfigs{ + "test_tool": lkr.Config{ + Name: "test_tool", + Type: "looker-get-agent", + Source: "my-instance", + Description: "some description", + AuthRequired: []string{}, + }, + }, + }, + } + for _, tc := range tcs { + t.Run(tc.desc, func(t *testing.T) { + _, _, _, 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 TestFailParseFromYaml(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: tool + name: test_tool + type: looker-get-agent + source: my-instance + method: GOT + description: some description + `, + err: "unknown field \"method\"", + }, + } + for _, tc := range tcs { + t.Run(tc.desc, func(t *testing.T) { + _, _, _, _, _, _, 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) + } + }) + } +} + +type MockSource struct { + sources.Source +} + +func (m MockSource) UseClientAuthorization() bool { + return false +} + +func (m MockSource) GetAuthTokenHeaderName() string { + return "Authorization" +} + +func (m MockSource) LookerApiSettings() *rtl.ApiSettings { + return &rtl.ApiSettings{} +} + +func (m MockSource) GetLookerSDK(string) (*v4.LookerSDK, error) { + return &v4.LookerSDK{}, nil +} + +type MockSourceProvider struct { + tools.SourceProvider + source MockSource +} + +func (m MockSourceProvider) GetSource(name string) (sources.Source, bool) { + return m.source, true +} + +func TestInvokeValidation(t *testing.T) { + ctx, err := testutils.ContextWithNewLogger() + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + cfg := lkr.Config{ + Name: "test_tool", + Type: "looker-get-agent", + Source: "my-instance", + Description: "test description", + } + + tool, err := cfg.Initialize(nil) + if err != nil { + t.Fatalf("failed to initialize tool: %v", err) + } + + resourceMgr := MockSourceProvider{source: MockSource{}} + + tcs := []struct { + desc string + params parameters.ParamValues + wantErr string + }{ + { + desc: "missing agent_id", + params: parameters.ParamValues{}, + wantErr: "agent_id must be specified", + }, + } + + for _, tc := range tcs { + t.Run(tc.desc, func(t *testing.T) { + _, err := tool.Invoke(ctx, resourceMgr, tc.params, "") + if err == nil { + t.Fatalf("expect error, got nil") + } + if !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("unexpected error: got %q, want substring %q", err.Error(), tc.wantErr) + } + }) + } + +} + +func TestManifest(t *testing.T) { + cfg := lkr.Config{ + Name: "test_tool", + Type: "looker-get-agent", + Source: "my-instance", + Description: "test description", + } + + tool, err := cfg.Initialize(nil) + if err != nil { + t.Fatalf("failed to initialize tool: %v", err) + } + + manifest := tool.Manifest() + if manifest.Description != cfg.Description { + t.Errorf("manifest description mismatch: got %q, want %q", manifest.Description, cfg.Description) + } + + expectedParams := []string{"agent_id"} + for _, p := range expectedParams { + found := false + for _, mp := range manifest.Parameters { + if mp.Name == p { + found = true + break + } + } + if !found { + t.Errorf("expected parameter %q not found in manifest", p) + } + } +} + +func TestMcpManifest(t *testing.T) { + cfg := lkr.Config{ + Name: "test_tool", + Type: "looker-get-agent", + Source: "my-instance", + Description: "test description", + } + + tool, err := cfg.Initialize(nil) + if err != nil { + t.Fatalf("failed to initialize tool: %v", err) + } + + mcp := tool.McpManifest() + if mcp.Name != cfg.Name { + t.Errorf("mcp manifest name mismatch: got %q, want %q", mcp.Name, cfg.Name) + } + + properties := mcp.InputSchema.Properties + expectedParams := []string{"agent_id"} + for _, p := range expectedParams { + if _, ok := properties[p]; !ok { + t.Errorf("parameter %q not found in MCP properties", p) + } + } +} + +func TestAnnotations(t *testing.T) { + readOnlyFalse := false + cfg := lkr.Config{ + Name: "test_tool", + Type: "looker-get-agent", + Source: "my-instance", + Description: "test description", + Annotations: &tools.ToolAnnotations{ + ReadOnlyHint: &readOnlyFalse, + }, + } + + tool, err := cfg.Initialize(nil) + if err != nil { + t.Fatalf("failed to initialize tool: %v", err) + } + + mcp := tool.McpManifest() + if mcp.Annotations == nil { + t.Fatal("mcp manifest annotations is nil") + } + if mcp.Annotations.ReadOnlyHint == nil { + t.Fatal("mcp manifest ReadOnlyHint is nil") + } + if *mcp.Annotations.ReadOnlyHint != true { + t.Errorf("ReadOnlyHint should be true, got %v", *mcp.Annotations.ReadOnlyHint) + } +} diff --git a/internal/tools/looker/lookerlistagents/lookerlistagents.go b/internal/tools/looker/lookerlistagents/lookerlistagents.go new file mode 100644 index 000000000000..02eb774200bc --- /dev/null +++ b/internal/tools/looker/lookerlistagents/lookerlistagents.go @@ -0,0 +1,170 @@ +// 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 lookerlistagents + +import ( + "context" + "fmt" + "net/http" + + 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-list-agents" + +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) { + params := parameters.Parameters{} + + annotations := &tools.ToolAnnotations{} + if cfg.Annotations != nil { + *annotations = *cfg.Annotations + } + readOnlyHint := true + annotations.ReadOnlyHint = &readOnlyHint + + mcpManifest := tools.GetMcpManifest(cfg.Name, cfg.Description, cfg.AuthRequired, params, annotations) + + 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, util.ToolboxError) { + source, err := tools.GetCompatibleSource[compatibleSource](resourceMgr, t.Source, t.Name, t.Type) + if err != nil { + return nil, util.NewClientServerError("source used is not compatible with the tool", http.StatusInternalServerError, err) + } + + logger, err := util.LoggerFromContext(ctx) + if err != nil { + return nil, util.NewClientServerError("unable to get logger from ctx", http.StatusInternalServerError, err) + } + + sdk, err := source.GetLookerSDK(string(accessToken)) + if err != nil { + return nil, util.NewClientServerError(fmt.Sprintf("error getting sdk: %v", err), http.StatusInternalServerError, err) + } + + mapParams := params.AsMap() + logger.DebugContext(ctx, fmt.Sprintf("%s params = ", t.Name), mapParams) + + resp, err := sdk.SearchAgents(v4.RequestSearchAgents{}, source.LookerApiSettings()) + if err != nil { + return nil, util.NewClientServerError(fmt.Sprintf("error making search_agents request: %s", err), http.StatusInternalServerError, err) + } + 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) 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) Authorized(verifiedAuthServices []string) bool { + return tools.IsAuthorized(t.AuthRequired, verifiedAuthServices) +} + +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/lookerlistagents/lookerlistagents_test.go b/internal/tools/looker/lookerlistagents/lookerlistagents_test.go new file mode 100644 index 000000000000..1ab8ac9ffe0e --- /dev/null +++ b/internal/tools/looker/lookerlistagents/lookerlistagents_test.go @@ -0,0 +1,235 @@ +// 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 lookerlistagents_test + +import ( + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/googleapis/genai-toolbox/internal/server" + "github.com/googleapis/genai-toolbox/internal/sources" + "github.com/googleapis/genai-toolbox/internal/testutils" + "github.com/googleapis/genai-toolbox/internal/tools" + lkr "github.com/googleapis/genai-toolbox/internal/tools/looker/lookerlistagents" + "github.com/looker-open-source/sdk-codegen/go/rtl" + v4 "github.com/looker-open-source/sdk-codegen/go/sdk/v4" +) + +func TestParseFromYaml(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: tool + name: test_tool + type: looker-list-agents + source: my-instance + description: some description + `, + want: server.ToolConfigs{ + "test_tool": lkr.Config{ + Name: "test_tool", + Type: "looker-list-agents", + Source: "my-instance", + Description: "some description", + AuthRequired: []string{}, + }, + }, + }, + } + for _, tc := range tcs { + t.Run(tc.desc, func(t *testing.T) { + _, _, _, 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 TestFailParseFromYaml(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: tool + name: test_tool + type: looker-list-agents + source: my-instance + method: GOT + description: some description + `, + err: "unknown field \"method\"", + }, + } + for _, tc := range tcs { + t.Run(tc.desc, func(t *testing.T) { + _, _, _, _, _, _, 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) + } + }) + } +} + +type MockSource struct { + sources.Source +} + +func (m MockSource) UseClientAuthorization() bool { + return false +} + +func (m MockSource) GetAuthTokenHeaderName() string { + return "Authorization" +} + +func (m MockSource) LookerApiSettings() *rtl.ApiSettings { + return &rtl.ApiSettings{} +} + +func (m MockSource) GetLookerSDK(string) (*v4.LookerSDK, error) { + return &v4.LookerSDK{}, nil +} + +type MockSourceProvider struct { + tools.SourceProvider + source MockSource +} + +func (m MockSourceProvider) GetSource(name string) (sources.Source, bool) { + return m.source, true +} + +func TestInvokeValidation(t *testing.T) { + resourceMgr := MockSourceProvider{source: MockSource{}} + + // No validation errors to mock for this simple tool that throws errors from Invoke directly + _ = resourceMgr + +} + +func TestManifest(t *testing.T) { + cfg := lkr.Config{ + Name: "test_tool", + Type: "looker-list-agents", + Source: "my-instance", + Description: "test description", + } + + tool, err := cfg.Initialize(nil) + if err != nil { + t.Fatalf("failed to initialize tool: %v", err) + } + + manifest := tool.Manifest() + if manifest.Description != cfg.Description { + t.Errorf("manifest description mismatch: got %q, want %q", manifest.Description, cfg.Description) + } + + expectedParams := []string{} + for _, p := range expectedParams { + found := false + for _, mp := range manifest.Parameters { + if mp.Name == p { + found = true + break + } + } + if !found { + t.Errorf("expected parameter %q not found in manifest", p) + } + } +} + +func TestMcpManifest(t *testing.T) { + cfg := lkr.Config{ + Name: "test_tool", + Type: "looker-list-agents", + Source: "my-instance", + Description: "test description", + } + + tool, err := cfg.Initialize(nil) + if err != nil { + t.Fatalf("failed to initialize tool: %v", err) + } + + mcp := tool.McpManifest() + if mcp.Name != cfg.Name { + t.Errorf("mcp manifest name mismatch: got %q, want %q", mcp.Name, cfg.Name) + } + + properties := mcp.InputSchema.Properties + expectedParams := []string{} + for _, p := range expectedParams { + if _, ok := properties[p]; !ok { + t.Errorf("parameter %q not found in MCP properties", p) + } + } +} + +func TestAnnotations(t *testing.T) { + readOnlyFalse := false + cfg := lkr.Config{ + Name: "test_tool", + Type: "looker-list-agents", + Source: "my-instance", + Description: "test description", + Annotations: &tools.ToolAnnotations{ + ReadOnlyHint: &readOnlyFalse, + }, + } + + tool, err := cfg.Initialize(nil) + if err != nil { + t.Fatalf("failed to initialize tool: %v", err) + } + + mcp := tool.McpManifest() + if mcp.Annotations == nil { + t.Fatal("mcp manifest annotations is nil") + } + if mcp.Annotations.ReadOnlyHint == nil { + t.Fatal("mcp manifest ReadOnlyHint is nil") + } + if *mcp.Annotations.ReadOnlyHint != true { + t.Errorf("ReadOnlyHint should be true, got %v", *mcp.Annotations.ReadOnlyHint) + } +} diff --git a/internal/tools/looker/lookerupdateagent/lookerupdateagent.go b/internal/tools/looker/lookerupdateagent/lookerupdateagent.go new file mode 100644 index 000000000000..5e0cf162fbf6 --- /dev/null +++ b/internal/tools/looker/lookerupdateagent/lookerupdateagent.go @@ -0,0 +1,246 @@ +// 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 lookerupdateagent + +import ( + "context" + "fmt" + "net/http" + + 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-update-agent" + +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) { + agentIdParameter := parameters.NewStringParameterWithDefault("agent_id", "", "The ID of the agent.") + nameParameter := parameters.NewStringParameterWithDefault("name", "", "The name of the agent.") + descriptionParameter := parameters.NewStringParameterWithDefault("description", "", "The description of the agent.") + instructionsParameter := parameters.NewStringParameterWithDefault("instructions", "", "The instructions (system prompt) for the agent.") + sourcesParameter := parameters.NewArrayParameterWithRequired( + "sources", + "Optional. A list of JSON-encoded data sources for the agent (e.g., [{\"model\": \"my_model\", \"explore\": \"my_explore\"}]).", + false, + parameters.NewMapParameter( + "source", + "A JSON-encoded source object with 'model' and 'explore' keys.", + "string", + ), + ) + codeInterpreterParameter := parameters.NewBooleanParameterWithDefault("code_interpreter", false, "Optional. Enables Code Interpreter for this Agent.") + params := parameters.Parameters{agentIdParameter, nameParameter, descriptionParameter, instructionsParameter, sourcesParameter, codeInterpreterParameter} + + annotations := &tools.ToolAnnotations{} + if cfg.Annotations != nil { + *annotations = *cfg.Annotations + } + readOnlyHint := false + annotations.ReadOnlyHint = &readOnlyHint + + mcpManifest := tools.GetMcpManifest(cfg.Name, cfg.Description, cfg.AuthRequired, params, annotations) + + 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, util.ToolboxError) { + source, err := tools.GetCompatibleSource[compatibleSource](resourceMgr, t.Source, t.Name, t.Type) + if err != nil { + return nil, util.NewClientServerError("source used is not compatible with the tool", http.StatusInternalServerError, err) + } + + logger, err := util.LoggerFromContext(ctx) + if err != nil { + return nil, util.NewClientServerError("unable to get logger from ctx", http.StatusInternalServerError, err) + } + + sdk, err := source.GetLookerSDK(string(accessToken)) + if err != nil { + return nil, util.NewClientServerError(fmt.Sprintf("error getting sdk: %v", err), http.StatusInternalServerError, err) + } + + mapParams := params.AsMap() + logger.DebugContext(ctx, fmt.Sprintf("%s params = ", t.Name), mapParams) + + var agentId, name, description, instructions string + if v, ok := mapParams["agent_id"].(string); ok { + agentId = v + } + if v, ok := mapParams["name"].(string); ok { + name = v + } + if v, ok := mapParams["description"].(string); ok { + description = v + } + if v, ok := mapParams["instructions"].(string); ok { + instructions = v + } + + codeInterpreter, hasCodeInterpreter := mapParams["code_interpreter"].(bool) + + if agentId == "" { + return nil, util.NewClientServerError(fmt.Sprintf("%s operation: agent_id must be specified", t.Type), http.StatusBadRequest, nil) + } + + agentSources := make([]v4.Source, 0) + if sources, ok := mapParams["sources"].([]any); ok { + for _, s := range sources { + source := s.(map[string]any) + model, ok := source["model"].(string) + if !ok { + return nil, util.NewClientServerError("invalid source format: expected model of type string", http.StatusBadRequest, nil) + } + explore, ok := source["explore"].(string) + if !ok { + return nil, util.NewClientServerError("invalid source format: expected explore of type string", http.StatusBadRequest, nil) + } + agentSources = append(agentSources, v4.Source{ + Model: &model, + Explore: &explore, + }) + } + } else { + if _, ok := mapParams["sources"]; ok { + return nil, util.NewClientServerError(fmt.Sprintf("invalid sources. got %T, expected []any", mapParams["sources"]), http.StatusBadRequest, nil) + } + } + + body := v4.WriteAgent{} + if name != "" { + body.Name = &name + } + if description != "" { + body.Description = &description + } + if instructions != "" { + body.Context = &v4.Context{ + Instructions: &instructions, + } + } + if len(agentSources) > 0 { + body.Sources = &agentSources + } + if hasCodeInterpreter { + body.CodeInterpreter = &codeInterpreter + } + resp, err := sdk.UpdateAgent(agentId, body, "", source.LookerApiSettings()) + if err != nil { + return nil, util.NewClientServerError(fmt.Sprintf("error making update_agent request: %s", err), http.StatusInternalServerError, err) + } + 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) 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) Authorized(verifiedAuthServices []string) bool { + return tools.IsAuthorized(t.AuthRequired, verifiedAuthServices) +} + +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/lookerupdateagent/lookerupdateagent_test.go b/internal/tools/looker/lookerupdateagent/lookerupdateagent_test.go new file mode 100644 index 000000000000..1eb19a73ce07 --- /dev/null +++ b/internal/tools/looker/lookerupdateagent/lookerupdateagent_test.go @@ -0,0 +1,284 @@ +// 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 lookerupdateagent_test + +import ( + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/googleapis/genai-toolbox/internal/server" + "github.com/googleapis/genai-toolbox/internal/sources" + "github.com/googleapis/genai-toolbox/internal/testutils" + "github.com/googleapis/genai-toolbox/internal/tools" + lkr "github.com/googleapis/genai-toolbox/internal/tools/looker/lookerupdateagent" + "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" +) + +func TestParseFromYaml(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: tool + name: test_tool + type: looker-update-agent + source: my-instance + description: some description + `, + want: server.ToolConfigs{ + "test_tool": lkr.Config{ + Name: "test_tool", + Type: "looker-update-agent", + Source: "my-instance", + Description: "some description", + AuthRequired: []string{}, + }, + }, + }, + } + for _, tc := range tcs { + t.Run(tc.desc, func(t *testing.T) { + _, _, _, 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 TestFailParseFromYaml(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: tool + name: test_tool + type: looker-update-agent + source: my-instance + method: GOT + description: some description + `, + err: "unknown field \"method\"", + }, + } + for _, tc := range tcs { + t.Run(tc.desc, func(t *testing.T) { + _, _, _, _, _, _, 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) + } + }) + } +} + +type MockSource struct { + sources.Source +} + +func (m MockSource) UseClientAuthorization() bool { + return false +} + +func (m MockSource) GetAuthTokenHeaderName() string { + return "Authorization" +} + +func (m MockSource) LookerApiSettings() *rtl.ApiSettings { + return &rtl.ApiSettings{} +} + +func (m MockSource) GetLookerSDK(string) (*v4.LookerSDK, error) { + return &v4.LookerSDK{}, nil +} + +type MockSourceProvider struct { + tools.SourceProvider + source MockSource +} + +func (m MockSourceProvider) GetSource(name string) (sources.Source, bool) { + return m.source, true +} + +func TestInvokeValidation(t *testing.T) { + ctx, err := testutils.ContextWithNewLogger() + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + cfg := lkr.Config{ + Name: "test_tool", + Type: "looker-update-agent", + Source: "my-instance", + Description: "test description", + } + + tool, err := cfg.Initialize(nil) + if err != nil { + t.Fatalf("failed to initialize tool: %v", err) + } + + resourceMgr := MockSourceProvider{source: MockSource{}} + + tcs := []struct { + desc string + params parameters.ParamValues + wantErr string + }{ + { + desc: "missing agent_id", + params: parameters.ParamValues{}, + wantErr: "agent_id must be specified", + }, + { + desc: "invalid source format", + params: parameters.ParamValues{ + {Name: "agent_id", Value: "test"}, + {Name: "sources", Value: []any{ + map[string]any{"model": 123, "explore": "test"}, + }}, + }, + wantErr: "invalid source format: expected model of type string", + }, + } + + for _, tc := range tcs { + t.Run(tc.desc, func(t *testing.T) { + _, err := tool.Invoke(ctx, resourceMgr, tc.params, "") + if err == nil { + t.Fatalf("expect error, got nil") + } + if !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("unexpected error: got %q, want substring %q", err.Error(), tc.wantErr) + } + }) + } + +} + +func TestManifest(t *testing.T) { + cfg := lkr.Config{ + Name: "test_tool", + Type: "looker-update-agent", + Source: "my-instance", + Description: "test description", + } + + tool, err := cfg.Initialize(nil) + if err != nil { + t.Fatalf("failed to initialize tool: %v", err) + } + + manifest := tool.Manifest() + if manifest.Description != cfg.Description { + t.Errorf("manifest description mismatch: got %q, want %q", manifest.Description, cfg.Description) + } + + expectedParams := []string{"agent_id", "name", "instructions", "sources", "code_interpreter"} + for _, p := range expectedParams { + found := false + for _, mp := range manifest.Parameters { + if mp.Name == p { + found = true + break + } + } + if !found { + t.Errorf("expected parameter %q not found in manifest", p) + } + } +} + +func TestMcpManifest(t *testing.T) { + cfg := lkr.Config{ + Name: "test_tool", + Type: "looker-update-agent", + Source: "my-instance", + Description: "test description", + } + + tool, err := cfg.Initialize(nil) + if err != nil { + t.Fatalf("failed to initialize tool: %v", err) + } + + mcp := tool.McpManifest() + if mcp.Name != cfg.Name { + t.Errorf("mcp manifest name mismatch: got %q, want %q", mcp.Name, cfg.Name) + } + + properties := mcp.InputSchema.Properties + expectedParams := []string{"agent_id", "name", "instructions", "sources", "code_interpreter"} + for _, p := range expectedParams { + if _, ok := properties[p]; !ok { + t.Errorf("parameter %q not found in MCP properties", p) + } + } +} + +func TestAnnotations(t *testing.T) { + readOnlyTrue := true + cfg := lkr.Config{ + Name: "test_tool", + Type: "looker-update-agent", + Source: "my-instance", + Description: "test description", + Annotations: &tools.ToolAnnotations{ + ReadOnlyHint: &readOnlyTrue, + }, + } + + tool, err := cfg.Initialize(nil) + if err != nil { + t.Fatalf("failed to initialize tool: %v", err) + } + + mcp := tool.McpManifest() + if mcp.Annotations == nil { + t.Fatal("mcp manifest annotations is nil") + } + if mcp.Annotations.ReadOnlyHint == nil { + t.Fatal("mcp manifest ReadOnlyHint is nil") + } + if *mcp.Annotations.ReadOnlyHint != false { + t.Errorf("ReadOnlyHint should be false, got %v", *mcp.Annotations.ReadOnlyHint) + } +} diff --git a/tests/looker/looker_integration_test.go b/tests/looker/looker_integration_test.go index 98efb21c0a7a..174550d34406 100644 --- a/tests/looker/looker_integration_test.go +++ b/tests/looker/looker_integration_test.go @@ -293,6 +293,31 @@ func TestLooker(t *testing.T) { "source": "my-instance", "description": "Simple tool to test end to end functionality.", }, + "list_agents": map[string]any{ + "type": "looker-list-agents", + "source": "my-instance", + "description": "Simple tool to test end to end functionality.", + }, + "get_agent": map[string]any{ + "type": "looker-get-agent", + "source": "my-instance", + "description": "Simple tool to test end to end functionality.", + }, + "create_agent": map[string]any{ + "type": "looker-create-agent", + "source": "my-instance", + "description": "Simple tool to test end to end functionality.", + }, + "update_agent": map[string]any{ + "type": "looker-update-agent", + "source": "my-instance", + "description": "Simple tool to test end to end functionality.", + }, + "delete_agent": map[string]any{ + "type": "looker-delete-agent", + "source": "my-instance", + "description": "Simple tool to test end to end functionality.", + }, }, } @@ -1900,24 +1925,6 @@ func TestLooker(t *testing.T) { wantResult = "null" tests.RunToolInvokeParametersTest(t, "get_dashboards", []byte(`{"title": "FOO", "desc": "BAR"}`), wantResult) - wantResult = "\"Connection\":\"thelook\"" - tests.RunToolInvokeParametersTest(t, "health_pulse", []byte(`{"action": "check_db_connections"}`), wantResult) - - wantResult = "[]" - tests.RunToolInvokeParametersTest(t, "health_pulse", []byte(`{"action": "check_schedule_failures"}`), wantResult) - - wantResult = "[{\"Feature\":\"Unsupported in Looker (Google Cloud core)\"}]" - tests.RunToolInvokeParametersTest(t, "health_pulse", []byte(`{"action": "check_legacy_features"}`), wantResult) - - wantResult = "\"Project\":\"the_look\"" - tests.RunToolInvokeParametersTest(t, "health_analyze", []byte(`{"action": "projects"}`), wantResult) - - wantResult = "\"Model\":\"the_look\"" - tests.RunToolInvokeParametersTest(t, "health_analyze", []byte(`{"action": "explores", "project": "the_look", "model": "the_look", "explore": "inventory_items"}`), wantResult) - - wantResult = "\"Model\":\"the_look\"" - tests.RunToolInvokeParametersTest(t, "health_vacuum", []byte(`{"action": "models"}`), wantResult) - wantResult = "the_look" tests.RunToolInvokeSimpleTest(t, "get_projects", wantResult) @@ -2001,6 +2008,23 @@ func TestLooker(t *testing.T) { wantResult = "/login/embed?t=" // testing for specific substring, since url is dynamic tests.RunToolInvokeParametersTest(t, "generate_embed_url", []byte(`{"type": "dashboards", "id": "1"}`), wantResult) + wantResult = fmt.Sprintf("agent_%s", randstr) + tests.RunToolInvokeParametersTest(t, "create_agent", []byte(fmt.Sprintf(`{"name": "agent_%s", "description": "test description", "instructions": "test instructions", "sources": [{"model": "the_look", "explore": "events"}]}`, randstr)), wantResult) + + agentId, _ := findTestAgentId(t, fmt.Sprintf("agent_%s", randstr)) + + wantResult = fmt.Sprintf("agent_%s", randstr) + tests.RunToolInvokeParametersTest(t, "list_agents", []byte(`{}`), wantResult) + + wantResult = fmt.Sprintf("agent_%s", randstr) + tests.RunToolInvokeParametersTest(t, "get_agent", []byte(fmt.Sprintf(`{"agent_id": "%s"}`, agentId)), wantResult) + + wantResult = fmt.Sprintf("agent_%s", randstr) + tests.RunToolInvokeParametersTest(t, "update_agent", []byte(fmt.Sprintf(`{"agent_id": "%s", "sources": [{"model": "system__activity", "explore": "history"}]}`, agentId)), wantResult) + + wantResult = "" + tests.RunToolInvokeParametersTest(t, "delete_agent", []byte(fmt.Sprintf(`{"agent_id": "%s"}`, agentId)), wantResult) + runConversationalAnalytics(t, "system__activity", "content_usage") deleteLook := testMakeLook(t, randstr) @@ -2010,6 +2034,40 @@ func TestLooker(t *testing.T) { defer deleteDashboard() testAddDashboardFilter(t, dashboardId) testAddDashboardElement(t, dashboardId) + + wantResult = "\"Connection\":\"thelook\"" + tests.RunToolInvokeParametersTest(t, "health_pulse", []byte(`{"action": "check_db_connections"}`), wantResult) + + wantResult = "[]" + tests.RunToolInvokeParametersTest(t, "health_pulse", []byte(`{"action": "check_schedule_failures"}`), wantResult) + + wantResult = "[{\"Feature\":\"Unsupported in Looker (Google Cloud core)\"}]" + tests.RunToolInvokeParametersTest(t, "health_pulse", []byte(`{"action": "check_legacy_features"}`), wantResult) + + wantResult = "\"Project\":\"the_look\"" + tests.RunToolInvokeParametersTest(t, "health_analyze", []byte(`{"action": "projects"}`), wantResult) + + wantResult = "\"Model\":\"the_look\"" + tests.RunToolInvokeParametersTest(t, "health_analyze", []byte(`{"action": "explores", "project": "the_look", "model": "the_look", "explore": "inventory_items"}`), wantResult) + + wantResult = "\"Model\":\"the_look\"" + tests.RunToolInvokeParametersTest(t, "health_vacuum", []byte(`{"action": "models"}`), wantResult) +} + +func findTestAgentId(t *testing.T, name string) (string, error) { + sdk := newLookerTestSDK(t) + reqSearchAgents := v4.RequestSearchAgents{ + Name: &name, + } + agents, err := sdk.SearchAgents(reqSearchAgents, nil) + if len(agents) == 0 { + t.Fatalf("Failed to find agent %s", name) + } else if len(agents) > 1 { + t.Fatalf("Found more than one agent with name %s", name) + } + agentId := *agents[0].Id + t.Logf("Found Agent Id %s", agentId) + return agentId, err } func runConversationalAnalytics(t *testing.T, modelName, exploreName string) {