Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 111 additions & 3 deletions runtime/ai/router_agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@ import (
"regexp"
"slices"
"strings"
"time"

"github.com/google/jsonschema-go/jsonschema"
"github.com/modelcontextprotocol/go-sdk/mcp"
aiv1 "github.com/rilldata/rill/proto/gen/rill/ai/v1"
runtimev1 "github.com/rilldata/rill/proto/gen/rill/runtime/v1"
"github.com/rilldata/rill/runtime"
"github.com/rilldata/rill/runtime/metricsview"
)
Expand Down Expand Up @@ -79,7 +81,7 @@ func (t *RouterAgent) Handler(ctx context.Context, args *RouterAgentArgs) (*Rout

// Handle title
if s.Title() == "" && args.Prompt != "" {
err := s.UpdateTitle(ctx, promptToTitle(args.Prompt))
err := s.UpdateTitle(ctx, promptToTitle(t.readableChatReferences(ctx, args.Prompt)))
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -234,9 +236,9 @@ func promptToTitle(message string) string {
title := whitespaceRegexp.ReplaceAllString(message, " ")
title = strings.TrimSpace(title)

// Truncate to 50 characters.
// Truncate to 50 bytes, without splitting a multi-byte rune.
if len(title) > 50 {
title = title[:47] + "..."
title = truncateUTF8(title, 47) + "..."
}

// Fallback title if empty.
Expand All @@ -246,6 +248,112 @@ func promptToTitle(message string) string {
return title
}

// readableChatReferences replaces the chat references in a prompt with readable text, so that a conversation title made from it doesn't show their markup.
// Resources that the session can access are shown by their display name, and other references by their name.
func (t *RouterAgent) readableChatReferences(ctx context.Context, prompt string) string {
return chatReferenceRegexp.ReplaceAllStringFunc(prompt, func(ref string) string {
attrs := chatReferenceAttrs(ref)
switch attrs["type"] {
case "metricsView":
mv := t.accessibleResource(ctx, runtime.ResourceKindMetricsView, attrs["metricsView"]).GetMetricsView().GetState().GetValidSpec()
if name := mv.GetDisplayName(); name != "" {
return name
}
return attrs["metricsView"]
case "measure":
mv := t.accessibleResource(ctx, runtime.ResourceKindMetricsView, attrs["metricsView"]).GetMetricsView().GetState().GetValidSpec()
for _, m := range mv.GetMeasures() {
if m.Name == attrs["measure"] && m.DisplayName != "" {
return m.DisplayName
}
}
return attrs["measure"]
case "dimension", "dimensionValues":
mv := t.accessibleResource(ctx, runtime.ResourceKindMetricsView, attrs["metricsView"]).GetMetricsView().GetState().GetValidSpec()
for _, d := range mv.GetDimensions() {
if d.Name == attrs["dimension"] && d.DisplayName != "" {
return d.DisplayName
}
}
return attrs["dimension"]
case "canvas":
canvas := t.accessibleResource(ctx, runtime.ResourceKindCanvas, attrs["canvas"]).GetCanvas().GetState().GetValidSpec()
if name := canvas.GetDisplayName(); name != "" {
return name
}
return attrs["canvas"]
case "canvasComponent":
// Everyone can read a component, and access to it is decided by the canvas that shows it (see runtime.ResolveCanvas).
// So a component is shown by its title only if it belongs to the referenced canvas and the session can access that canvas.
canvas := t.accessibleResource(ctx, runtime.ResourceKindCanvas, attrs["canvas"]).GetCanvas().GetState().GetValidSpec()
components := map[string]bool{}
runtime.CollectCanvasComponentNames(canvas.GetRows(), components)
if components[attrs["canvasComponent"]] {
// Components defined in a canvas are named after their position, so prefer their title.
cmp := t.accessibleResource(ctx, runtime.ResourceKindComponent, attrs["canvasComponent"]).GetComponent().GetState().GetValidSpec()
if title := cmp.GetRendererProperties().GetFields()["title"].GetStringValue(); title != "" {
return title
}
if name := cmp.GetDisplayName(); name != "" {
return name
}
}
if name := canvas.GetDisplayName(); name != "" {
return name
}
return attrs["canvasComponent"]
case "timeRange":
return formatTimeRangeReference(attrs["timeRange"], attrs["timeZone"])
case "skill":
return "/" + attrs["skill"]
case "model":
return attrs["model"]
case "column":
return attrs["column"]
default:
return ""
}
})
}

// accessibleResource returns the named resource if it exists and the session can access it, and nil otherwise.
// It doesn't return errors because the title falls back to the reference's name when the resource can't be read.
func (t *RouterAgent) accessibleResource(ctx context.Context, kind, name string) *runtimev1.Resource {
session := GetSession(ctx)

ctrl, err := t.Runtime.Controller(ctx, session.InstanceID())
if err != nil {
return nil
}
r, err := ctrl.Get(ctx, &runtimev1.ResourceName{Kind: kind, Name: name}, false)
Comment on lines +321 to +328
if err != nil {
return nil
}
r, access, err := t.Runtime.ApplySecurityPolicy(ctx, session.InstanceID(), session.Claims(), r)
if err != nil || !access {
return nil
}
return r
}

// formatTimeRangeReference formats the value of a time range reference, "<start>" or "<start> to <end>" in RFC3339, as dates in the given time zone.
// It returns the value unchanged if it can't be parsed.
func formatTimeRangeReference(value, timeZone string) string {
loc, err := time.LoadLocation(timeZone)
if err != nil {
loc = time.UTC
}
var dates []string
for _, part := range strings.Split(value, " to ") {
ts, err := time.Parse(time.RFC3339, part)
if err != nil {
return value
}
dates = append(dates, ts.In(loc).Format(time.DateOnly))
}
return strings.Join(dates, " – ")
}

// mapAgentErr maps common agent errors to more user-friendly messages.
//
// NOTE: For context errors, it does not include the underlying error to keep messages clean.
Expand Down
161 changes: 161 additions & 0 deletions runtime/ai/router_agent_test.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
package ai_test

import (
"strings"
"testing"

"github.com/google/uuid"
"github.com/rilldata/rill/runtime"
"github.com/rilldata/rill/runtime/ai"
"github.com/rilldata/rill/runtime/pkg/activity"
"github.com/rilldata/rill/runtime/testruntime"
"github.com/stretchr/testify/require"
)
Expand Down Expand Up @@ -63,3 +67,160 @@ func TestRouterAgent(t *testing.T) {
require.Equal(t, c.agent, res.Agent, "prompt: %q", c.prompt)
}
}

// TestRouterAgentTitle verifies that the conversation title shows the chat references in the first prompt as readable text instead of their markup.
func TestRouterAgentTitle(t *testing.T) {
rt, instanceID := testruntime.NewInstanceWithOptions(t, testruntime.InstanceOptions{
Files: map[string]string{
"rill.yaml": ``,
"models/orders.sql": `SELECT 1 AS revenue, 'US' AS country`,
"metrics/orders.yaml": `
type: metrics_view
display_name: Sales
model: orders
dimensions:
- name: country
display_name: Customer Country
column: country
measures:
- name: revenue
display_name: Net Revenue
expression: SUM(revenue)
`,
"canvases/overview.yaml": `
type: canvas
display_name: Sales Overview
rows:
- items:
- kpi_grid:
metrics_view: orders
measures: [revenue]
- items:
- bar_chart:
metrics_view: orders
title: Revenue by country
x:
field: country
type: nominal
y:
field: revenue
type: quantitative
`,
"canvases/restricted.yaml": `
type: canvas
display_name: Restricted
security:
access: false
rows:
- items:
- bar_chart:
metrics_view: orders
title: Revenue by customer
x:
field: country
type: nominal
y:
field: revenue
type: quantitative
`,
},
})
testruntime.RequireReconcileState(t, rt, instanceID, 9, 0, 0)

cases := []struct {
prompt string
title string
}{
{
prompt: `<chat-reference>type="metricsView" metricsView="orders"</chat-reference> by month`,
title: "Sales by month",
},
{
prompt: `Explain what drives <chat-reference>type="measure" metricsView="orders" measure="revenue"</chat-reference>?`,
title: "Explain what drives Net Revenue?",
},
{
prompt: `Revenue by <chat-reference>type="dimension" metricsView="orders" dimension="country"</chat-reference>`,
title: "Revenue by Customer Country",
},
{
prompt: `Summarize <chat-reference>type="canvas" canvas="overview"</chat-reference>`,
title: "Summarize Sales Overview",
},
{
// A canvas component is shown by its title.
prompt: `Explain <chat-reference>type="canvasComponent" canvas="overview" canvasComponent="overview--component-1-0"</chat-reference>`,
title: "Explain Revenue by country",
},
{
// A canvas component without a title is shown by the canvas it belongs to.
prompt: `Explain <chat-reference>type="canvasComponent" canvas="overview" canvasComponent="overview--component-0-0"</chat-reference>`,
title: "Explain Sales Overview",
},
{
// A component that doesn't belong to the referenced canvas isn't shown by its title.
prompt: `Explain <chat-reference>type="canvasComponent" canvas="overview" canvasComponent="restricted--component-0-0"</chat-reference>`,
title: "Explain Sales Overview",
},
{
// The time range is shown as dates in the reference's time zone.
prompt: `<chat-reference>type="timeRange" timeZone="Europe/Madrid" granularity="day" timeRange="2026-07-31T22:00:00.000Z to 2026-08-30T22:00:00.000Z"</chat-reference> vs last year`,
title: "2026-08-01 – 2026-08-31 vs last year",
},
{
prompt: `<chat-reference>type="skill" skill="monthly-close"</chat-reference> for March`,
title: "/monthly-close for March",
},
{
prompt: `Nulls in <chat-reference>type="column" model="orders" column="revenue" columnType="INTEGER"</chat-reference>`,
title: "Nulls in revenue",
},
{
// A reference to a resource that doesn't exist shows its name.
prompt: `<chat-reference>type="metricsView" metricsView="missing"</chat-reference> trend`,
title: "missing trend",
},
{
// A reference of an unknown type is dropped.
prompt: `<chat-reference>type="unknown" unknown="x"</chat-reference> hello`,
title: "hello",
},
{
// The title is truncated after the references are replaced.
prompt: `<chat-reference>type="metricsView" metricsView="orders"</chat-reference> how much did we sell to each customer last month?`,
title: "Sales how much did we sell to each customer las...",
},
{
// The truncation doesn't split a multi-byte rune: the 47-byte cut lands inside the first "ú".
prompt: strings.Repeat("a", 46) + "úúúú",
title: strings.Repeat("a", 46) + "...",
},
}
for _, c := range cases {
s := newSession(t, rt, instanceID)
var res *ai.RouterAgentResult
_, err := s.CallTool(t.Context(), ai.RoleUser, ai.RouterAgentName, &res, ai.RouterAgentArgs{
Prompt: c.prompt,
Agent: ai.AnalystAgentName,
SkipHandoff: true,
})
require.NoError(t, err)
require.Equal(t, c.title, s.Title(), "prompt: %q", c.prompt)
}

// With security checks, a component of a canvas that the session can't access isn't shown by its title.
s, err := ai.NewRunner(rt, activity.NewNoopClient()).Session(t.Context(), &ai.SessionOptions{
InstanceID: instanceID,
Claims: &runtime.SecurityClaims{UserID: uuid.NewString(), Permissions: []runtime.Permission{runtime.UseAI, runtime.ReadObjects}},
UserAgent: "rill-evals",
})
require.NoError(t, err)
var res *ai.RouterAgentResult
_, err = s.CallTool(t.Context(), ai.RoleUser, ai.RouterAgentName, &res, ai.RouterAgentArgs{
Prompt: `Explain <chat-reference>type="canvasComponent" canvas="restricted" canvasComponent="restricted--component-0-0"</chat-reference>`,
Agent: ai.AnalystAgentName,
SkipHandoff: true,
})
require.NoError(t, err)
require.Equal(t, "Explain restricted--component-0-0", s.Title())
}
16 changes: 11 additions & 5 deletions runtime/ai/skill_references.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,21 @@ func loadReferencedSkills(ctx context.Context, s *Session, prompt string, skills
return nil
}

// chatReferenceAttrs returns the key="value" attributes of a chat reference.
func chatReferenceAttrs(ref string) map[string]string {
attrs := map[string]string{}
for _, attr := range chatReferenceAttrRegexp.FindAllStringSubmatch(ref, -1) {
attrs[attr[1]] = attr[2]
}
return attrs
}

// referencedSkills returns the skills referenced in a prompt with a chat reference of type "skill", once each and in order of first reference.
// References to skills that are not in the given list are ignored.
func referencedSkills(prompt string, skills []*Skill) []*Skill {
var res []*Skill
for _, ref := range chatReferenceRegexp.FindAllStringSubmatch(prompt, -1) {
attrs := map[string]string{}
for _, attr := range chatReferenceAttrRegexp.FindAllStringSubmatch(ref[1], -1) {
attrs[attr[1]] = attr[2]
}
for _, ref := range chatReferenceRegexp.FindAllString(prompt, -1) {
attrs := chatReferenceAttrs(ref)
if attrs["type"] != "skill" {
continue
}
Expand Down
18 changes: 18 additions & 0 deletions runtime/ai/truncate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package ai

import "unicode/utf8"

// truncateUTF8 returns a prefix of s at most maxLen bytes long, never splitting a multi-byte UTF-8
// rune: if the byte cut lands inside a rune, it backs off to the previous rune boundary. Callers
// append their own ellipsis. Byte-indexed slicing (s[:maxLen]) is not safe for this: a dangling lead
// byte followed by an appended "..." is invalid UTF-8, which protobuf refuses to marshal in a string field.
func truncateUTF8(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
cut := maxLen
for cut > 0 && !utf8.RuneStart(s[cut]) {
cut--
}
return s[:cut]
}
Loading