diff --git a/runtime/ai/router_agent.go b/runtime/ai/router_agent.go index 1224fe8693f..20e7616425f 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 } @@ -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. @@ -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) + 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 7a325f0a58a..3869d234e7d 100644 --- a/runtime/ai/router_agent_test.go +++ b/runtime/ai/router_agent_test.go @@ -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" ) @@ -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: `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", + }, + { + // 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`, + 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...", + }, + { + // 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 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()) +} diff --git a/runtime/ai/skill_references.go b/runtime/ai/skill_references.go index 9eff8d36c9c..f0260ef91cf 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 } diff --git a/runtime/ai/truncate.go b/runtime/ai/truncate.go new file mode 100644 index 00000000000..e938cb9856d --- /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] +}