diff --git a/cmd/internal/imports.go b/cmd/internal/imports.go index f020503313fc..f4d366a8fafa 100644 --- a/cmd/internal/imports.go +++ b/cmd/internal/imports.go @@ -108,6 +108,7 @@ import ( _ "github.com/googleapis/genai-toolbox/internal/tools/http" _ "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/lookeragent" _ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookerconversationalanalytics" _ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookercreateprojectdirectory" _ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookercreateprojectfile" diff --git a/docs/en/resources/tools/looker/looker-agent.md b/docs/en/resources/tools/looker/looker-agent.md new file mode 100644 index 000000000000..e3b9cd038662 --- /dev/null +++ b/docs/en/resources/tools/looker/looker-agent.md @@ -0,0 +1,77 @@ +--- +title: "looker-agent" +type: docs +weight: 1 +description: > + Manage Looker Agents +aliases: +- /resources/tools/looker-agent +--- + +# Looker Agent + +The `looker-agent` tool allows LLMs to manage Looker Agents. It supports listing, retrieving, creating, and deleting agents using the Looker Go SDK. + +It's compatible with the following sources: + +- [looker](../../sources/looker.md) + +## Configuration + +To use the `looker-agent` tool, you must define it in your `server.yaml` file. + +```yaml +tools: + - name: looker_agent_manage + type: looker-agent + source: my_looker_source + description: Manage Looker AI Agents. +``` + +## Parameters + +| **Parameter** | **Type** | **Required** | **Description** | +|:-------------|:--------:|:------------:|:----------------| +| `operation` | `string` | Yes | The operation to perform. Must be one of: `list`, `get`, `create`, or `delete`. | +| `agent_id` | `string` | No | The ID of the agent. Required for `get` and `delete` operations. | +| `name` | `string` | No | The name of the agent. Required for `create` operation. | + +## Operations + +### List Agents +Retrieve a list of all agents. +```json +{ + "operation": "list" +} +``` + +### Get Agent +Retrieve details of a specific agent by its ID. +```json +{ + "operation": "get", + "agent_id": "12345" +} +``` + +### Create Agent +Create a new agent with the given name. +```json +{ + "operation": "create", + "name": "My AI Assistant" +} +``` + +### Delete Agent +Delete an agent by its ID. +```json +{ + "operation": "delete", + "agent_id": "12345" +} +``` + +## Dependencies +This tool requires the underlying Looker Go SDK to support the `Agent` API resource (v0.26.6+). diff --git a/internal/tools/looker/lookeragent/lookeragent.go b/internal/tools/looker/lookeragent/lookeragent.go new file mode 100644 index 000000000000..7a76f150594d --- /dev/null +++ b/internal/tools/looker/lookeragent/lookeragent.go @@ -0,0 +1,205 @@ +// 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 lookeragent + +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-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) { + operationParameter := parameters.NewStringParameter("operation", "The operation to perform. Must be one of: `list`, `get`, `create`, or `delete`.") + agentIdParameter := parameters.NewStringParameterWithDefault("agent_id", "", "The ID of the agent. Required for `get` and `delete` operations.") + nameParameter := parameters.NewStringParameterWithDefault("name", "", "The name of the agent. Required for `create` operation.") + params := parameters.Parameters{operationParameter, agentIdParameter, nameParameter} + + annotations := cfg.Annotations + + 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, "looker_agent params = ", mapParams) + operation := mapParams["operation"].(string) + agentId := mapParams["agent_id"].(string) + name := mapParams["name"].(string) + + switch operation { + case "list": + 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 + case "get": + if agentId == "" { + return nil, util.NewClientServerError(fmt.Sprintf("%s operation: agent_id must be specified", operation), 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 + case "create": + if name == "" { + return nil, util.NewClientServerError(fmt.Sprintf("%s operation: name must be specified", operation), http.StatusBadRequest, nil) + } + body := v4.WriteAgent{ + Name: &name, + } + 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 + case "delete": + if agentId == "" { + return nil, util.NewClientServerError(fmt.Sprintf("%s operation: agent_id must be specified", operation), 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 + default: + return nil, util.NewClientServerError(fmt.Sprintf("unknown operation: %s. Must be one of `list`, `get`, `create`, or `delete`", operation), http.StatusBadRequest, 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/lookeragent/lookeragent_test.go b/internal/tools/looker/lookeragent/lookeragent_test.go new file mode 100644 index 000000000000..da31989141d1 --- /dev/null +++ b/internal/tools/looker/lookeragent/lookeragent_test.go @@ -0,0 +1,107 @@ +// 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 lookeragent_test + +import ( + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/googleapis/genai-toolbox/internal/server" + "github.com/googleapis/genai-toolbox/internal/testutils" + lkr "github.com/googleapis/genai-toolbox/internal/tools/looker/lookeragent" +) + +func TestParseFromYamlLookerAgent(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: example_tool + type: looker-agent + source: my-instance + description: some description + `, + want: server.ToolConfigs{ + "example_tool": lkr.Config{ + Name: "example_tool", + Type: "looker-agent", + Source: "my-instance", + Description: "some description", + AuthRequired: []string{}, + }, + }, + }, + } + for _, tc := range tcs { + t.Run(tc.desc, func(t *testing.T) { + // Parse contents + _, _, _, got, _, _, err := server.UnmarshalResourceConfig(ctx, testutils.FormatYaml(tc.in)) + if err != nil { + t.Fatalf("unable to unmarshal: %s", err) + } + if diff := cmp.Diff(tc.want, got); diff != "" { + t.Fatalf("incorrect parse: diff %v", diff) + } + }) + } +} + +func TestFailParseFromYamlLookerAgent(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: example_tool + type: looker-agent + source: my-instance + method: GOT + description: some description + `, + err: "error unmarshaling tool: unable to parse tool \"example_tool\" as type \"looker-agent\": [3:1] unknown field \"method\"\n 1 | authRequired: []\n 2 | description: some description\n> 3 | method: GOT\n ^\n 4 | name: example_tool\n 5 | source: my-instance\n 6 | type: looker-agent", + }, + } + for _, tc := range tcs { + t.Run(tc.desc, func(t *testing.T) { + // Parse contents + _, _, _, _, _, _, err := server.UnmarshalResourceConfig(ctx, testutils.FormatYaml(tc.in)) + if err == nil { + t.Fatalf("expect parsing to fail") + } + errStr := err.Error() + if !strings.Contains(errStr, tc.err) { + t.Fatalf("unexpected error string: got %q, want substring %q", errStr, tc.err) + } + }) + } +}