From 8dfa4104a3f0b1e921dc330ea3c83a350fecf206 Mon Sep 17 00:00:00 2001 From: dfliess Date: Tue, 22 Sep 2026 08:23:48 +0200 Subject: [PATCH 1/3] fix(ai): show chat references as readable text in conversation titles The conversation title is the first prompt truncated to 50 bytes, taken as-is. Prompts carry each @ or / reference as markup, e.g. type="metricsView" metricsView="orders", which is longer than the title, so a conversation that starts with a reference gets a title like `type="metricsView" metricsView=...` in the chat sidebar, its history menu and the full-page conversation list. Replace each reference with readable text before truncating: the display name of the referenced metrics view, measure, dimension or canvas when the session can access it (falling back to its name), `/name` for skills, the dates of a time range in its time zone, and the name for the other types. References of unknown types are dropped. Co-Authored-By: Claude Opus 5 (1M context) --- runtime/ai/router_agent.go | 92 +++++++++++++++++++++++++++++++- runtime/ai/router_agent_test.go | 94 +++++++++++++++++++++++++++++++++ runtime/ai/skill_references.go | 16 ++++-- 3 files changed, 196 insertions(+), 6 deletions(-) diff --git a/runtime/ai/router_agent.go b/runtime/ai/router_agent.go index 1224fe8693fc..805b5baa4b02 100644 --- a/runtime/ai/router_agent.go +++ b/runtime/ai/router_agent.go @@ -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" ) @@ -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 } @@ -246,6 +248,94 @@ 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": + 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) + 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, "" or " to " 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. diff --git a/runtime/ai/router_agent_test.go b/runtime/ai/router_agent_test.go index 7a325f0a58ad..e187b99d689c 100644 --- a/runtime/ai/router_agent_test.go +++ b/runtime/ai/router_agent_test.go @@ -63,3 +63,97 @@ 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] +`, + }, + }) + testruntime.RequireReconcileState(t, rt, instanceID, 6, 0, 0) + + cases := []struct { + prompt string + title string + }{ + { + prompt: `type="metricsView" metricsView="orders" by month`, + title: "Sales by month", + }, + { + prompt: `Explain what drives type="measure" metricsView="orders" measure="revenue"?`, + title: "Explain what drives Net Revenue?", + }, + { + prompt: `Revenue by type="dimension" metricsView="orders" dimension="country"`, + title: "Revenue by Customer Country", + }, + { + prompt: `Summarize type="canvas" canvas="overview"`, + title: "Summarize Sales Overview", + }, + { + // The time range is shown as dates in the reference's time zone. + prompt: `type="timeRange" timeZone="Europe/Madrid" granularity="day" timeRange="2026-07-31T22:00:00.000Z to 2026-08-30T22:00:00.000Z" vs last year`, + title: "2026-08-01 – 2026-08-31 vs last year", + }, + { + prompt: `type="skill" skill="monthly-close" for March`, + title: "/monthly-close for March", + }, + { + prompt: `Nulls in type="column" model="orders" column="revenue" columnType="INTEGER"`, + title: "Nulls in revenue", + }, + { + // A reference to a resource that doesn't exist shows its name. + prompt: `type="metricsView" metricsView="missing" trend`, + title: "missing trend", + }, + { + // A reference of an unknown type is dropped. + prompt: `type="unknown" unknown="x" hello`, + title: "hello", + }, + { + // The title is truncated after the references are replaced. + prompt: `type="metricsView" metricsView="orders" how much did we sell to each customer last month?`, + title: "Sales how much did we sell to each customer las...", + }, + } + 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) + } +} diff --git a/runtime/ai/skill_references.go b/runtime/ai/skill_references.go index 9eff8d36c9cb..f0260ef91cfa 100644 --- a/runtime/ai/skill_references.go +++ b/runtime/ai/skill_references.go @@ -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 } From 43cf2bb2b0bb74a9f52e8bc9d194e32f1e07113e Mon Sep 17 00:00:00 2001 From: dfliess Date: Tue, 22 Sep 2026 08:40:59 +0200 Subject: [PATCH 2/3] fix(ai): don't split a UTF-8 rune when truncating conversation titles promptToTitle cut the title at byte 47 with title[:47], which can land inside a multi-byte rune. The dangling lead byte followed by "..." is invalid UTF-8, and protobuf refuses to marshal it ("string field contains invalid UTF-8"), so the conversation can't be returned by the API. Titles in languages with accents hit this, and the display names that now replace chat references make it more likely. Add truncateUTF8, which backs off to the previous rune boundary, and use it for the title. Co-Authored-By: Claude Opus 5 (1M context) --- runtime/ai/router_agent.go | 4 ++-- runtime/ai/router_agent_test.go | 6 ++++++ runtime/ai/truncate.go | 18 ++++++++++++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) create mode 100644 runtime/ai/truncate.go diff --git a/runtime/ai/router_agent.go b/runtime/ai/router_agent.go index 805b5baa4b02..ba33b69aa861 100644 --- a/runtime/ai/router_agent.go +++ b/runtime/ai/router_agent.go @@ -236,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. diff --git a/runtime/ai/router_agent_test.go b/runtime/ai/router_agent_test.go index e187b99d689c..341b35b47bfd 100644 --- a/runtime/ai/router_agent_test.go +++ b/runtime/ai/router_agent_test.go @@ -1,6 +1,7 @@ package ai_test import ( + "strings" "testing" "github.com/rilldata/rill/runtime/ai" @@ -144,6 +145,11 @@ rows: prompt: `type="metricsView" metricsView="orders" 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) diff --git a/runtime/ai/truncate.go b/runtime/ai/truncate.go new file mode 100644 index 000000000000..e938cb9856d1 --- /dev/null +++ b/runtime/ai/truncate.go @@ -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] +} From d14c1230a432516544d1b79eefe362e6cef80fad Mon Sep 17 00:00:00 2001 From: dfliess Date: Tue, 22 Sep 2026 09:28:12 +0200 Subject: [PATCH 3/3] fix(ai): show canvas components by their title in conversation titles Components defined in a canvas are named after their position, e.g. sales--component-1-0, which says nothing in a conversation title. Show a component reference by the component's title, then its display name, then the display name of its canvas, and only then its name. Everyone can read a component, and access to it is decided by the canvas that shows it, as in ResolveCanvas. So the component's title is only used when it belongs to the referenced canvas and the session can access that canvas. Co-Authored-By: Claude Opus 5 (1M context) --- runtime/ai/router_agent.go | 18 ++++++++++ runtime/ai/router_agent_test.go | 63 ++++++++++++++++++++++++++++++++- 2 files changed, 80 insertions(+), 1 deletion(-) diff --git a/runtime/ai/router_agent.go b/runtime/ai/router_agent.go index ba33b69aa861..20e7616425f3 100644 --- a/runtime/ai/router_agent.go +++ b/runtime/ai/router_agent.go @@ -283,6 +283,24 @@ func (t *RouterAgent) readableChatReferences(ctx context.Context, prompt string) } 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"]) diff --git a/runtime/ai/router_agent_test.go b/runtime/ai/router_agent_test.go index 341b35b47bfd..3869d234e7d1 100644 --- a/runtime/ai/router_agent_test.go +++ b/runtime/ai/router_agent_test.go @@ -4,7 +4,10 @@ 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" ) @@ -92,10 +95,37 @@ rows: - 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, 6, 0, 0) + testruntime.RequireReconcileState(t, rt, instanceID, 9, 0, 0) cases := []struct { prompt string @@ -117,6 +147,21 @@ rows: prompt: `Summarize type="canvas" canvas="overview"`, title: "Summarize Sales Overview", }, + { + // A canvas component is shown by its title. + prompt: `Explain type="canvasComponent" canvas="overview" canvasComponent="overview--component-1-0"`, + title: "Explain Revenue by country", + }, + { + // A canvas component without a title is shown by the canvas it belongs to. + prompt: `Explain type="canvasComponent" canvas="overview" canvasComponent="overview--component-0-0"`, + title: "Explain Sales Overview", + }, + { + // A component that doesn't belong to the referenced canvas isn't shown by its title. + prompt: `Explain type="canvasComponent" canvas="overview" canvasComponent="restricted--component-0-0"`, + title: "Explain Sales Overview", + }, { // The time range is shown as dates in the reference's time zone. prompt: `type="timeRange" timeZone="Europe/Madrid" granularity="day" timeRange="2026-07-31T22:00:00.000Z to 2026-08-30T22:00:00.000Z" vs last year`, @@ -162,4 +207,20 @@ rows: 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 type="canvasComponent" canvas="restricted" canvasComponent="restricted--component-0-0"`, + Agent: ai.AnalystAgentName, + SkipHandoff: true, + }) + require.NoError(t, err) + require.Equal(t, "Explain restricted--component-0-0", s.Title()) }