mirror of
https://github.com/googleapis/genai-toolbox.git
synced 2026-01-09 07:28:05 -05:00
feat(tools/looker-run-dashboard): new run_dashboard tool (#1858)
## Description The run_dashboard tool will run the query associated with each tile of the dashboard and return the full set of query results. It enables the agent to answer questions like "Summarize this dashboard for me". --------- Co-authored-by: Yuan Teoh <45984206+Yuan325@users.noreply.github.com>
This commit is contained in:
@@ -127,6 +127,7 @@ import (
|
||||
_ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookerquery"
|
||||
_ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookerquerysql"
|
||||
_ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookerqueryurl"
|
||||
_ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookerrundashboard"
|
||||
_ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookerrunlook"
|
||||
_ "github.com/googleapis/genai-toolbox/internal/tools/looker/lookerupdateprojectfile"
|
||||
_ "github.com/googleapis/genai-toolbox/internal/tools/mindsdb/mindsdbexecutesql"
|
||||
|
||||
@@ -1514,7 +1514,7 @@ func TestPrebuiltTools(t *testing.T) {
|
||||
wantToolset: server.ToolsetConfigs{
|
||||
"looker_tools": tools.ToolsetConfig{
|
||||
Name: "looker_tools",
|
||||
ToolNames: []string{"get_models", "get_explores", "get_dimensions", "get_measures", "get_filters", "get_parameters", "query", "query_sql", "query_url", "get_looks", "run_look", "make_look", "get_dashboards", "make_dashboard", "add_dashboard_element", "health_pulse", "health_analyze", "health_vacuum", "dev_mode", "get_projects", "get_project_files", "get_project_file", "create_project_file", "update_project_file", "delete_project_file", "get_connections", "get_connection_schemas", "get_connection_databases", "get_connection_tables", "get_connection_table_columns"},
|
||||
ToolNames: []string{"get_models", "get_explores", "get_dimensions", "get_measures", "get_filters", "get_parameters", "query", "query_sql", "query_url", "get_looks", "run_look", "make_look", "get_dashboards", "run_dashboard", "make_dashboard", "add_dashboard_element", "health_pulse", "health_analyze", "health_vacuum", "dev_mode", "get_projects", "get_project_files", "get_project_file", "create_project_file", "update_project_file", "delete_project_file", "get_connections", "get_connection_schemas", "get_connection_databases", "get_connection_tables", "get_connection_table_columns"},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -315,6 +315,7 @@ instance and create new saved content.
|
||||
1. **run_look**: Run a saved Look and return the data
|
||||
1. **make_look**: Create a saved Look in Looker and return the URL
|
||||
1. **get_dashboards**: Return the saved dashboards that match a title or description
|
||||
1. **run_dashbaord**: Run the queries associated with a dashboard and return the data
|
||||
1. **make_dashboard**: Create a saved dashboard in Looker and return the URL
|
||||
1. **add_dashboard_element**: Add a tile to a dashboard
|
||||
|
||||
|
||||
@@ -386,6 +386,7 @@ details on how to connect your AI tools (IDEs) to databases via Toolbox and MCP.
|
||||
* `run_look`: Runs the query associated with a look.
|
||||
* `make_look`: Creates a new look.
|
||||
* `get_dashboards`: Searches for saved dashboards.
|
||||
* `run_dashboard`: Runs the queries associated with a dashboard.
|
||||
* `make_dashboard`: Creates a new dashboard.
|
||||
* `add_dashboard_element`: Adds a tile to a dashboard.
|
||||
* `health_pulse`: Test the health of a Looker instance.
|
||||
|
||||
43
docs/en/resources/tools/looker/looker-run-dashboard.md
Normal file
43
docs/en/resources/tools/looker/looker-run-dashboard.md
Normal file
@@ -0,0 +1,43 @@
|
||||
---
|
||||
title: "looker-run-dashboard"
|
||||
type: docs
|
||||
weight: 1
|
||||
description: >
|
||||
"looker-run-dashboard" runs the queries associated with a dashboard.
|
||||
aliases:
|
||||
- /resources/tools/looker-run-dashboard
|
||||
---
|
||||
|
||||
## About
|
||||
|
||||
The `looker-run-dashboard` tool runs the queries associated with a
|
||||
dashboard.
|
||||
|
||||
It's compatible with the following sources:
|
||||
|
||||
- [looker](../../sources/looker.md)
|
||||
|
||||
`looker-run-dashboard` takes one parameter, the `dashboard_id`.
|
||||
|
||||
## Example
|
||||
|
||||
```yaml
|
||||
tools:
|
||||
run_dashboard:
|
||||
kind: looker-run-dashboard
|
||||
source: looker-source
|
||||
description: |
|
||||
run_dashboard Tool
|
||||
|
||||
This tools runs the query associated with each tile in a dashboard
|
||||
and returns the data in a JSON structure. It accepts the dashboard_id
|
||||
as the parameter.
|
||||
```
|
||||
|
||||
## Reference
|
||||
|
||||
| **field** | **type** | **required** | **description** |
|
||||
|-------------|:--------:|:------------:|----------------------------------------------------|
|
||||
| kind | string | true | Must be "looker-run-dashboard" |
|
||||
| source | string | true | Name of the source the SQL should execute on. |
|
||||
| description | string | true | Description of the tool that is passed to the LLM. |
|
||||
@@ -663,6 +663,16 @@ tools:
|
||||
|
||||
The result of the get_dashboards tool is a list of json objects.
|
||||
|
||||
run_dashboard:
|
||||
kind: looker-run-dashboard
|
||||
source: looker-source
|
||||
description: |
|
||||
run_dashboard Tool
|
||||
|
||||
This tools runs the query associated with each tile in a dashboard
|
||||
and returns the data in a JSON structure. It accepts the dashboard_id
|
||||
as the parameter.
|
||||
|
||||
make_dashboard:
|
||||
kind: looker-make-dashboard
|
||||
source: looker-source
|
||||
@@ -886,6 +896,7 @@ toolsets:
|
||||
- run_look
|
||||
- make_look
|
||||
- get_dashboards
|
||||
- run_dashboard
|
||||
- make_dashboard
|
||||
- add_dashboard_element
|
||||
- health_pulse
|
||||
|
||||
267
internal/tools/looker/lookerrundashboard/lookerrundashboard.go
Normal file
267
internal/tools/looker/lookerrundashboard/lookerrundashboard.go
Normal file
@@ -0,0 +1,267 @@
|
||||
// Copyright 2025 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
package lookerrundashboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
yaml "github.com/goccy/go-yaml"
|
||||
"github.com/googleapis/genai-toolbox/internal/sources"
|
||||
lookersrc "github.com/googleapis/genai-toolbox/internal/sources/looker"
|
||||
"github.com/googleapis/genai-toolbox/internal/tools"
|
||||
"github.com/googleapis/genai-toolbox/internal/tools/looker/lookercommon"
|
||||
"github.com/googleapis/genai-toolbox/internal/util"
|
||||
|
||||
"github.com/looker-open-source/sdk-codegen/go/rtl"
|
||||
v4 "github.com/looker-open-source/sdk-codegen/go/sdk/v4"
|
||||
)
|
||||
|
||||
const kind string = "looker-run-dashboard"
|
||||
|
||||
func init() {
|
||||
if !tools.Register(kind, newConfig) {
|
||||
panic(fmt.Sprintf("tool kind %q already registered", kind))
|
||||
}
|
||||
}
|
||||
|
||||
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 Config struct {
|
||||
Name string `yaml:"name" validate:"required"`
|
||||
Kind string `yaml:"kind" validate:"required"`
|
||||
Source string `yaml:"source" validate:"required"`
|
||||
Description string `yaml:"description" validate:"required"`
|
||||
AuthRequired []string `yaml:"authRequired"`
|
||||
}
|
||||
|
||||
// validate interface
|
||||
var _ tools.ToolConfig = Config{}
|
||||
|
||||
func (cfg Config) ToolConfigKind() string {
|
||||
return kind
|
||||
}
|
||||
|
||||
func (cfg Config) Initialize(srcs map[string]sources.Source) (tools.Tool, error) {
|
||||
// verify source exists
|
||||
rawS, ok := srcs[cfg.Source]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("no source named %q configured", cfg.Source)
|
||||
}
|
||||
|
||||
// verify the source is compatible
|
||||
s, ok := rawS.(*lookersrc.Source)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid source for %q tool: source kind must be `looker`", kind)
|
||||
}
|
||||
|
||||
dashboardidParameter := tools.NewStringParameter("dashboard_id", "The id of the dashboard to run.")
|
||||
|
||||
parameters := tools.Parameters{
|
||||
dashboardidParameter,
|
||||
}
|
||||
|
||||
mcpManifest := tools.GetMcpManifest(cfg.Name, cfg.Description, cfg.AuthRequired, parameters)
|
||||
|
||||
// finish tool setup
|
||||
return Tool{
|
||||
Name: cfg.Name,
|
||||
Kind: kind,
|
||||
Parameters: parameters,
|
||||
AuthRequired: cfg.AuthRequired,
|
||||
UseClientOAuth: s.UseClientOAuth,
|
||||
Client: s.Client,
|
||||
ApiSettings: s.ApiSettings,
|
||||
manifest: tools.Manifest{
|
||||
Description: cfg.Description,
|
||||
Parameters: parameters.Manifest(),
|
||||
AuthRequired: cfg.AuthRequired,
|
||||
},
|
||||
mcpManifest: mcpManifest,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// validate interface
|
||||
var _ tools.Tool = Tool{}
|
||||
|
||||
type Tool struct {
|
||||
Name string `yaml:"name"`
|
||||
Kind string `yaml:"kind"`
|
||||
UseClientOAuth bool
|
||||
Client *v4.LookerSDK
|
||||
ApiSettings *rtl.ApiSettings
|
||||
AuthRequired []string `yaml:"authRequired"`
|
||||
Parameters tools.Parameters `yaml:"parameters"`
|
||||
manifest tools.Manifest
|
||||
mcpManifest tools.McpManifest
|
||||
}
|
||||
|
||||
func (t Tool) Invoke(ctx context.Context, params tools.ParamValues, accessToken tools.AccessToken) (any, error) {
|
||||
logger, err := util.LoggerFromContext(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to get logger from ctx: %s", err)
|
||||
}
|
||||
logger.DebugContext(ctx, "params = ", params)
|
||||
paramsMap := params.AsMap()
|
||||
|
||||
dashboard_id := paramsMap["dashboard_id"].(string)
|
||||
|
||||
sdk, err := lookercommon.GetLookerSDK(t.UseClientOAuth, t.ApiSettings, t.Client, accessToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error getting sdk: %w", err)
|
||||
}
|
||||
dashboard, err := sdk.Dashboard(dashboard_id, "", t.ApiSettings)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error getting dashboard: %w", err)
|
||||
}
|
||||
|
||||
data := make(map[string]any)
|
||||
data["tiles"] = make([]any, 0)
|
||||
if dashboard.Title != nil {
|
||||
data["title"] = *dashboard.Title
|
||||
}
|
||||
if dashboard.Description != nil {
|
||||
data["description"] = *dashboard.Description
|
||||
}
|
||||
|
||||
channels := make([]<-chan map[string]any, len(*dashboard.DashboardElements))
|
||||
for i, element := range *dashboard.DashboardElements {
|
||||
channels[i] = tileQueryWorker(ctx, sdk, t.ApiSettings, i, element)
|
||||
}
|
||||
|
||||
for resp := range merge(channels...) {
|
||||
data["tiles"] = append(data["tiles"].([]any), resp)
|
||||
}
|
||||
|
||||
logger.DebugContext(ctx, "data = ", data)
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (t Tool) ParseParams(data map[string]any, claims map[string]map[string]any) (tools.ParamValues, error) {
|
||||
return tools.ParseParams(t.Parameters, data, claims)
|
||||
}
|
||||
|
||||
func (t Tool) Manifest() tools.Manifest {
|
||||
return t.manifest
|
||||
}
|
||||
|
||||
func (t Tool) McpManifest() tools.McpManifest {
|
||||
return t.mcpManifest
|
||||
}
|
||||
|
||||
func (t Tool) Authorized(verifiedAuthServices []string) bool {
|
||||
return tools.IsAuthorized(t.AuthRequired, verifiedAuthServices)
|
||||
}
|
||||
|
||||
func (t Tool) RequiresClientAuthorization() bool {
|
||||
return t.UseClientOAuth
|
||||
}
|
||||
|
||||
func tileQueryWorker(ctx context.Context, sdk *v4.LookerSDK, options *rtl.ApiSettings, index int, element v4.DashboardElement) <-chan map[string]any {
|
||||
out := make(chan map[string]any)
|
||||
|
||||
go func() {
|
||||
defer close(out)
|
||||
|
||||
data := make(map[string]any)
|
||||
data["index"] = index
|
||||
if element.Title != nil {
|
||||
data["title"] = *element.Title
|
||||
}
|
||||
if element.TitleText != nil {
|
||||
data["title_text"] = *element.TitleText
|
||||
}
|
||||
if element.SubtitleText != nil {
|
||||
data["subtitle_text"] = *element.SubtitleText
|
||||
}
|
||||
if element.BodyText != nil {
|
||||
data["body_text"] = *element.BodyText
|
||||
}
|
||||
|
||||
var q v4.Query
|
||||
if element.Query != nil {
|
||||
data["element_type"] = "query"
|
||||
q = *element.Query
|
||||
} else if element.Look != nil {
|
||||
data["element_type"] = "look"
|
||||
q = *element.Look.Query
|
||||
} else {
|
||||
// Just a text element
|
||||
data["element_type"] = "text"
|
||||
out <- data
|
||||
return
|
||||
}
|
||||
|
||||
wq := v4.WriteQuery{
|
||||
Model: q.Model,
|
||||
View: q.View,
|
||||
Fields: q.Fields,
|
||||
Pivots: q.Pivots,
|
||||
Filters: q.Filters,
|
||||
Sorts: q.Sorts,
|
||||
QueryTimezone: q.QueryTimezone,
|
||||
Limit: q.Limit,
|
||||
}
|
||||
query_result, err := lookercommon.RunInlineQuery(ctx, sdk, &wq, "json", options)
|
||||
if err != nil {
|
||||
data["query_status"] = "error running query"
|
||||
out <- data
|
||||
return
|
||||
}
|
||||
var resp []any
|
||||
e := json.Unmarshal([]byte(query_result), &resp)
|
||||
if e != nil {
|
||||
data["query_status"] = "error parsing query result"
|
||||
out <- data
|
||||
return
|
||||
}
|
||||
data["query_status"] = "success"
|
||||
data["query_result"] = resp
|
||||
out <- data
|
||||
}()
|
||||
return out
|
||||
}
|
||||
|
||||
func merge(channels ...<-chan map[string]any) <-chan map[string]any {
|
||||
var wg sync.WaitGroup
|
||||
out := make(chan map[string]any)
|
||||
|
||||
output := func(c <-chan map[string]any) {
|
||||
for n := range c {
|
||||
out <- n
|
||||
}
|
||||
wg.Done()
|
||||
}
|
||||
wg.Add(len(channels))
|
||||
for _, c := range channels {
|
||||
go output(c)
|
||||
}
|
||||
|
||||
// Start a goroutine to close out once all the output goroutines are
|
||||
// done. This must start after the wg.Add call.
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(out)
|
||||
}()
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// Copyright 2025 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package lookerrundashboard_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
yaml "github.com/goccy/go-yaml"
|
||||
"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/lookerrundashboard"
|
||||
)
|
||||
|
||||
func TestParseFromYamlLookerRunDashboard(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: `
|
||||
tools:
|
||||
example_tool:
|
||||
kind: looker-run-dashboard
|
||||
source: my-instance
|
||||
description: some description
|
||||
`,
|
||||
want: server.ToolConfigs{
|
||||
"example_tool": lkr.Config{
|
||||
Name: "example_tool",
|
||||
Kind: "looker-run-dashboard",
|
||||
Source: "my-instance",
|
||||
Description: "some description",
|
||||
AuthRequired: []string{},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tc := range tcs {
|
||||
t.Run(tc.desc, func(t *testing.T) {
|
||||
got := struct {
|
||||
Tools server.ToolConfigs `yaml:"tools"`
|
||||
}{}
|
||||
// Parse contents
|
||||
err := yaml.UnmarshalContext(ctx, testutils.FormatYaml(tc.in), &got)
|
||||
if err != nil {
|
||||
t.Fatalf("unable to unmarshal: %s", err)
|
||||
}
|
||||
if diff := cmp.Diff(tc.want, got.Tools); diff != "" {
|
||||
t.Fatalf("incorrect parse: diff %v", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestFailParseFromYamlLookerRunDashboard(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: `
|
||||
tools:
|
||||
example_tool:
|
||||
kind: looker-run-dashboard
|
||||
source: my-instance
|
||||
method: GOT
|
||||
description: some description
|
||||
`,
|
||||
err: "unable to parse tool \"example_tool\" as kind \"looker-run-dashboard\": [4:1] unknown field \"method\"\n 1 | authRequired: []\n 2 | description: some description\n 3 | kind: looker-run-dashboard\n> 4 | method: GOT\n ^\n 5 | source: my-instance",
|
||||
},
|
||||
}
|
||||
for _, tc := range tcs {
|
||||
t.Run(tc.desc, func(t *testing.T) {
|
||||
got := struct {
|
||||
Tools server.ToolConfigs `yaml:"tools"`
|
||||
}{}
|
||||
// Parse contents
|
||||
err := yaml.UnmarshalContext(ctx, testutils.FormatYaml(tc.in), &got)
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
@@ -71,7 +71,7 @@ func getLookerVars(t *testing.T) map[string]any {
|
||||
|
||||
func TestLooker(t *testing.T) {
|
||||
sourceConfig := getLookerVars(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
testLogger, err := log.NewStdLogger(os.Stdout, os.Stderr, "info")
|
||||
|
||||
Reference in New Issue
Block a user