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:
Dr. Strangelove
2025-11-06 16:45:31 -05:00
committed by GitHub
parent 7e7b572bd2
commit 30857c2294
9 changed files with 442 additions and 2 deletions

View File

@@ -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"

View File

@@ -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"},
},
},
},

View File

@@ -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

View File

@@ -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.

View 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. |

View File

@@ -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

View 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
}

View File

@@ -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)
}
})
}
}

View File

@@ -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")