From abbf958fbcc1bd7a4c8e1d2761a1c55ee6225c16 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Sun, 14 Jun 2026 01:53:59 +0500 Subject: [PATCH] =?UTF-8?q?feat(service-ai):=20visualize=5Fdata=20tool=20?= =?UTF-8?q?=E2=80=94=20return=20charts=20from=20AI=20data=20queries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `visualize_data` AI tool so the data-query assistant can answer with a CHART instead of plain text/markdown. The tool runs an aggregation through the existing analytics service (auto-inferred cube), maps the result into the SDUI `` contract, and emits it to the client as a `data-chart` custom stream part (same onProgress → Vercel UI-message-stream channel that `data-build-progress` already uses). It also returns a compact textual summary so the model narrates the answer alongside the rendered chart. - tools/visualize-data.tool.ts: VISUALIZE_DATA_TOOL + handler + register fn. function+field → analytics measure key (count / _sum / …); single dimension → x-axis; measures → series; chartType (bar/line/pie/…). - plugin.ts: register when an analytics service is present; persist as tool metadata in lockstep (Studio visibility). - skills/data-explorer-skill.ts: expose visualize_data + chart trigger phrases and guidance to prefer it for "chart/plot/trend/breakdown" requests. Frontend rendering lands in objectui (plugin-chatbot). Verified end-to-end in the console against a live LLM: AI picks visualize_data → analytics aggregates → data-chart part → bar chart renders in the chat bubble. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/services/service-ai/src/index.ts | 8 + packages/services/service-ai/src/plugin.ts | 29 +- .../src/skills/data-explorer-skill.ts | 12 +- .../src/tools/visualize-data.tool.test.ts | 175 +++++++++ .../src/tools/visualize-data.tool.ts | 340 ++++++++++++++++++ 5 files changed, 560 insertions(+), 4 deletions(-) create mode 100644 packages/services/service-ai/src/tools/visualize-data.tool.test.ts create mode 100644 packages/services/service-ai/src/tools/visualize-data.tool.ts diff --git a/packages/services/service-ai/src/index.ts b/packages/services/service-ai/src/index.ts index 7b2080ed00..fba1b0c25b 100644 --- a/packages/services/service-ai/src/index.ts +++ b/packages/services/service-ai/src/index.ts @@ -100,6 +100,14 @@ export { } from './tools/query-data.tool.js'; export type { QueryDataToolContext, QueryPlan } from './tools/query-data.tool.js'; +// visualize_data tool (analytics aggregation → SDUI chart via `data-chart` part) +export { + VISUALIZE_DATA_TOOL, + createVisualizeDataHandler, + registerVisualizeDataTool, +} from './tools/visualize-data.tool.js'; +export type { VisualizeDataToolContext } from './tools/visualize-data.tool.js'; + // Routes export { buildAIRoutes } from './routes/ai-routes.js'; export { buildAgentRoutes } from './routes/agent-routes.js'; diff --git a/packages/services/service-ai/src/plugin.ts b/packages/services/service-ai/src/plugin.ts index e1a410bc20..8c72c703de 100644 --- a/packages/services/service-ai/src/plugin.ts +++ b/packages/services/service-ai/src/plugin.ts @@ -2,7 +2,7 @@ import type { Plugin, PluginContext } from '@objectstack/core'; import { readEnvWithDeprecation } from '@objectstack/types'; -import type { IAIService, IAIConversationService, IAutomationService, IDataEngine, IEmbedder, IMetadataService, LLMAdapter } from '@objectstack/spec/contracts'; +import type { IAIService, IAIConversationService, IAnalyticsService, IAutomationService, IDataEngine, IEmbedder, IMetadataService, LLMAdapter } from '@objectstack/spec/contracts'; import { EMBEDDER_SERVICE } from '@objectstack/spec/contracts'; import type * as AI from '@objectstack/spec/ai'; import { AIService } from './ai-service.js'; @@ -20,6 +20,7 @@ import { AiTraceView, AiMessageView, AiPendingActionView, AiEvalCaseView, AiEval import { EvalRunner } from './eval/index.js'; import { registerDataTools } from './tools/data-tools.js'; import { registerQueryDataTool } from './tools/query-data.tool.js'; +import { registerVisualizeDataTool, VISUALIZE_DATA_TOOL } from './tools/visualize-data.tool.js'; import { registerActionsAsTools } from './tools/action-tools.js'; import { AgentRuntime } from './agent-runtime.js'; import { SkillRegistry } from './skill-registry.js'; @@ -705,6 +706,23 @@ export class AIServicePlugin implements Plugin { }); ctx.logger.info('[AI] Built-in data tools registered'); + // Register visualize_data when an analytics service is available — it + // turns an aggregation into an SDUI chart that renders inline in chat + // (emitted as a `data-chart` stream part). Only needs analytics, so it + // sits outside the metadata gate below. + let analyticsService: IAnalyticsService | undefined; + try { + analyticsService = ctx.getService('analytics'); + } catch { + analyticsService = undefined; + } + if (analyticsService) { + registerVisualizeDataTool(this.service.toolRegistry, { analytics: analyticsService }); + ctx.logger.info('[AI] visualize_data tool registered'); + } else { + ctx.logger.debug('[AI] No analytics service — visualize_data tool not registered'); + } + // Register query_data tool when metadata service is also available — // it composes AI + Metadata + Data into a single NL-to-records call. if (metadataService) { @@ -770,7 +788,12 @@ export class AIServicePlugin implements Plugin { // Register data tools as metadata (for Studio visibility) if (metadataService) { const { DATA_TOOL_DEFINITIONS } = await import('./tools/data-tools.js'); - for (const toolDef of DATA_TOOL_DEFINITIONS) { + // visualize_data is only usable (and only registered above) when an + // analytics service is present — persist it as metadata in lockstep. + const toolDefsToPersist = analyticsService + ? [...DATA_TOOL_DEFINITIONS, VISUALIZE_DATA_TOOL] + : DATA_TOOL_DEFINITIONS; + for (const toolDef of toolDefsToPersist) { const toolExists = typeof metadataService.exists === 'function' ? await withTimeout(metadataService.exists('tool', toolDef.name)) @@ -794,7 +817,7 @@ export class AIServicePlugin implements Plugin { } } } - ctx.logger.info(`[AI] ${DATA_TOOL_DEFINITIONS.length} data tools registered as metadata`); + ctx.logger.info(`[AI] ${toolDefsToPersist.length} data tools registered as metadata`); } // Register the built-in agent + skills (requires metadata service). diff --git a/packages/services/service-ai/src/skills/data-explorer-skill.ts b/packages/services/service-ai/src/skills/data-explorer-skill.ts index c14890f2f4..c49bba7a74 100644 --- a/packages/services/service-ai/src/skills/data-explorer-skill.ts +++ b/packages/services/service-ai/src/skills/data-explorer-skill.ts @@ -25,6 +25,7 @@ Capabilities: - Query records with filters, sorting, and pagination - Look up individual records by ID - Perform aggregations and statistical analysis (count, sum, avg, min, max) +- Render results as a CHART when a visualization communicates the answer better than text Guidelines: 1. Always use the describe_object tool first to understand a table's structure before querying it. @@ -33,7 +34,8 @@ Guidelines: 4. When presenting data, format it in a clear and readable way using markdown tables or bullet lists. 5. For large result sets, summarize the data and mention the total count. 6. When performing aggregations, explain the results in plain language. -7. If a query returns no results, suggest possible reasons and alternative queries. +7. Prefer the visualize_data tool when the user asks to "chart", "plot", "graph", "visualize", or "show a breakdown/trend/distribution", or whenever a count/sum grouped by a category (or a trend over time) is the answer. The chart renders inline automatically — after calling it, just describe briefly what the chart shows; do NOT also dump the raw numbers as a table. +8. If a query returns no results, suggest possible reasons and alternative queries. 8. Never expose internal IDs unless the user explicitly asks for them. 9. Always answer in the same language the user is using.`, tools: [ @@ -43,6 +45,7 @@ Guidelines: 'query_records', 'get_record', 'aggregate_data', + 'visualize_data', ], triggerPhrases: [ 'show me', @@ -54,6 +57,13 @@ Guidelines: 'aggregate', 'sum', 'average', + 'chart', + 'plot', + 'graph', + 'visualize', + 'trend', + 'breakdown', + 'distribution', ], active: true, }; diff --git a/packages/services/service-ai/src/tools/visualize-data.tool.test.ts b/packages/services/service-ai/src/tools/visualize-data.tool.test.ts new file mode 100644 index 0000000000..1baad35085 --- /dev/null +++ b/packages/services/service-ai/src/tools/visualize-data.tool.test.ts @@ -0,0 +1,175 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import type { AnalyticsQuery, AnalyticsResult } from '@objectstack/spec/contracts'; +import { createVisualizeDataHandler, type VisualizeDataToolContext } from './visualize-data.tool.js'; +import type { ToolExecutionContext } from './tool-registry.js'; + +/** + * `visualize_data` runs an analytics aggregation and emits the chart-ready + * result as a `data-chart` custom stream part (via `ctx.onProgress`), while + * returning a compact textual summary for the model to narrate. These tests + * pin both halves of that contract. + */ + +/** Build a tool context whose analytics service records the query it received. */ +function makeCtx(opts: { + result?: AnalyticsResult; + onQuery?: (q: AnalyticsQuery) => void; + throwError?: string; +}): VisualizeDataToolContext { + return { + analytics: { + query: async (q: AnalyticsQuery): Promise => { + opts.onQuery?.(q); + if (opts.throwError) throw new Error(opts.throwError); + return ( + opts.result ?? { + rows: [ + { status: 'won', count: 5 }, + { status: 'lost', count: 2 }, + ], + fields: [ + { name: 'status', type: 'string' }, + { name: 'count', type: 'number', label: 'Count' }, + ], + } + ); + }, + getMeta: async () => [], + } as never, + }; +} + +/** Collect the `data-chart` parts emitted through onProgress. */ +function makeExecCtx(): { ctx: ToolExecutionContext; parts: Array<{ type: string; id?: string; data?: unknown }> } { + const parts: Array<{ type: string; id?: string; data?: unknown }> = []; + const ctx: ToolExecutionContext = { + onProgress: (p) => parts.push(p), + }; + return { ctx, parts }; +} + +describe('visualize_data', () => { + it('emits a data-chart part shaped for the SDUI renderer and returns a summary', async () => { + const handler = createVisualizeDataHandler(makeCtx({})); + const { ctx, parts } = makeExecCtx(); + + const out = JSON.parse( + (await handler( + { + objectName: 'opportunity', + dimension: 'status', + measures: [{ function: 'count' }], + chartType: 'bar', + title: 'Deals by status', + }, + ctx, + )) as string, + ); + + // One data-chart part emitted, carrying the chart descriptor. + expect(parts).toHaveLength(1); + expect(parts[0].type).toBe('data-chart'); + expect(parts[0].id).toBeTruthy(); + const chart = parts[0].data as Record; + expect(chart.type).toBe('chart'); + expect(chart.chartType).toBe('bar'); + expect(chart.title).toBe('Deals by status'); + expect(chart.xAxisKey).toBe('status'); + expect(chart.series).toEqual([{ dataKey: 'count', label: 'Count' }]); + expect(chart.data).toHaveLength(2); + + // Textual summary for the model — names the chart, not a raw table dump. + expect(out.rendered).toBe('chart'); + expect(out.categories).toBe(2); + expect(out.measures).toEqual(['count']); + }); + + it('maps function+field to the analytics suffix measure key (amount_sum)', async () => { + let received: AnalyticsQuery | undefined; + const handler = createVisualizeDataHandler( + makeCtx({ + onQuery: (q) => (received = q), + result: { + rows: [{ region: 'NA', amount_sum: 1000 }], + fields: [ + { name: 'region', type: 'string' }, + { name: 'amount_sum', type: 'number' }, + ], + }, + }), + ); + const { ctx, parts } = makeExecCtx(); + + await handler( + { + objectName: 'order', + dimension: 'region', + measures: [{ function: 'sum', field: 'amount', label: 'Revenue' }], + where: { stage: { $ne: 'draft' } }, + }, + ctx, + ); + + expect(received?.cube).toBe('order'); + expect(received?.measures).toEqual(['amount_sum']); + expect(received?.dimensions).toEqual(['region']); + expect(received?.where).toEqual({ stage: { $ne: 'draft' } }); + + // Series dataKey equals the measure key; the caller's label is preserved. + const chart = parts[0].data as Record; + expect(chart.series).toEqual([{ dataKey: 'amount_sum', label: 'Revenue' }]); + }); + + it('defaults chartType to bar and supports multiple measures as series', async () => { + const handler = createVisualizeDataHandler( + makeCtx({ + result: { + rows: [{ month: '2026-01', count: 10, amount_sum: 500 }], + fields: [], + }, + }), + ); + const { ctx, parts } = makeExecCtx(); + + await handler( + { + objectName: 'order', + dimension: 'month', + measures: [{ function: 'count' }, { function: 'sum', field: 'amount' }], + }, + ctx, + ); + + const chart = parts[0].data as Record; + expect(chart.chartType).toBe('bar'); + expect(chart.series.map((s: any) => s.dataKey)).toEqual(['count', 'amount_sum']); + }); + + it('returns a structured error (no chart emitted) when the analytics query fails', async () => { + const handler = createVisualizeDataHandler(makeCtx({ throwError: 'no such cube' })); + const { ctx, parts } = makeExecCtx(); + + const out = JSON.parse( + (await handler( + { objectName: 'ghost', dimension: 'x', measures: [{ function: 'count' }] }, + ctx, + )) as string, + ); + + expect(parts).toHaveLength(0); + expect(out.error).toMatch(/Analytics query failed: no such cube/); + }); + + it('rejects calls with no valid measures', async () => { + const handler = createVisualizeDataHandler(makeCtx({})); + const { ctx, parts } = makeExecCtx(); + + const out = JSON.parse( + (await handler({ objectName: 'order', measures: [] }, ctx)) as string, + ); + expect(parts).toHaveLength(0); + expect(out.error).toMatch(/at least one measure/i); + }); +}); diff --git a/packages/services/service-ai/src/tools/visualize-data.tool.ts b/packages/services/service-ai/src/tools/visualize-data.tool.ts new file mode 100644 index 0000000000..cc0dd82638 --- /dev/null +++ b/packages/services/service-ai/src/tools/visualize-data.tool.ts @@ -0,0 +1,340 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import type { + AIToolDefinition, + AnalyticsQuery, + AnalyticsResult, + IAnalyticsService, +} from '@objectstack/spec/contracts'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; +import type { ToolHandler, ToolRegistry, ToolExecutionContext } from './tool-registry.js'; + +// --------------------------------------------------------------------------- +// Context — injected once at registration time +// --------------------------------------------------------------------------- + +/** + * Services required by the {@link VISUALIZE_DATA_TOOL}. + * + * The tool composes the analytics service (semantic aggregation) with the + * AI stream's `data-*` custom-part channel: it runs an analytical query and + * emits the chart-ready result back to the client as a `data-chart` part, + * which the chat UI renders inline with the platform's SDUI `` + * component. The model still receives a compact textual summary so it can + * narrate the answer in prose alongside the rendered chart. + */ +export interface VisualizeDataToolContext { + /** Analytics / BI service for semantic aggregation (ADR-0021). */ + analytics: IAnalyticsService; + /** Max number of categories (grouped rows) charted per call. Default 50. */ + maxCategories?: number; +} + +/** Aggregation function a measure may request. */ +type AggFunction = 'count' | 'sum' | 'avg' | 'min' | 'max' | 'count_distinct'; + +/** Chart types this tool can emit — a subset the SDUI `` renderer supports. */ +type ChartType = + | 'bar' + | 'column' + | 'horizontal-bar' + | 'line' + | 'area' + | 'pie' + | 'donut' + | 'radar' + | 'scatter'; + +/** + * Translate a {@link ToolExecutionContext} into the ObjectQL + * {@link ExecutionContext} the analytics service expects — mirrors + * `data-tools.ts#buildEngineContext` so the chart query is scoped to the + * same tenant / RLS as the rest of the agent's data access. + */ +function buildAnalyticsContext(ctx?: ToolExecutionContext): ExecutionContext { + if (ctx?.actor) { + return { + userId: ctx.actor.id, + roles: ctx.actor.roles ?? [], + permissions: ctx.actor.permissions ?? [], + isSystem: false, + ...(ctx.environmentId ? { tenantId: ctx.environmentId } : {}), + ...(ctx.traceId ? { traceId: ctx.traceId } : {}), + }; + } + return { roles: [], permissions: [], isSystem: true }; +} + +/** + * Derive the analytics measure key for a `{ function, field }` pair, matching + * the suffix convention recognised by the analytics service's auto-inferred + * cube (`inferMeasure` in service-analytics): `count`, `_sum`, + * `_avg`, `_min`, `_max`, `_count_distinct`. + * + * The returned key is BOTH the measure passed to `analytics.query()` and the + * column name the result rows are keyed by — so it doubles as the chart + * series `dataKey`. + */ +function measureKey(fn: AggFunction, field?: string): string { + if (fn === 'count') return 'count'; + const f = (field ?? '').trim(); + if (!f) { + // sum/avg/min/max/count_distinct require a field; fall back to count so + // the query still succeeds rather than producing an invalid measure. + return 'count'; + } + return `${f}_${fn}`; +} + +/** Best-effort human label for a measure when the caller / analytics gives none. */ +function defaultMeasureLabel(fn: AggFunction, field?: string): string { + const pretty = (s: string) => + s.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); + if (fn === 'count') return 'Count'; + const f = field ? pretty(field) : ''; + const verb: Record = { + count: 'Count', + sum: 'Total', + avg: 'Average', + min: 'Min', + max: 'Max', + count_distinct: 'Distinct', + }; + return `${verb[fn]} ${f}`.trim(); +} + +// --------------------------------------------------------------------------- +// Tool definition +// --------------------------------------------------------------------------- + +/** + * Tool advertised to the LLM. The model calls this when a visualisation + * (rather than a list of records or a number) is the best answer — e.g. + * "show me sales by region", "chart tasks per status", "trend of signups + * by month". + */ +export const VISUALIZE_DATA_TOOL: AIToolDefinition = { + name: 'visualize_data', + label: 'Visualize Data (Chart)', + description: + 'Aggregate a data object and render the result as a CHART in the chat. ' + + 'Use this — instead of query_records / aggregate_data — whenever the ' + + 'best answer is a visualization (counts/sums grouped by a category, a ' + + 'trend over time, a distribution, etc.). The chart is shown to the user ' + + 'automatically; you only need to briefly describe what it shows. ' + + 'Field names in `dimension`, `measures[].field` and `where` MUST be real ' + + 'fields obtained from describe_object — do NOT guess generic names.', + parameters: { + type: 'object', + properties: { + objectName: { + type: 'string', + description: 'The snake_case name of the object to aggregate (e.g. "task", "crm_account").', + }, + dimension: { + type: 'string', + description: + 'The field to group by — becomes the chart\'s category axis ' + + '(x-axis for bar/line, slices for pie). Omit only for a single ' + + 'whole-object aggregate.', + }, + measures: { + type: 'array', + minItems: 1, + items: { + type: 'object', + properties: { + function: { + type: 'string', + enum: ['count', 'sum', 'avg', 'min', 'max', 'count_distinct'], + description: 'Aggregation function. Use count with no field to count records.', + }, + field: { + type: 'string', + description: 'Field to aggregate. Required for sum/avg/min/max/count_distinct; omit for count.', + }, + label: { + type: 'string', + description: 'Human-readable series label shown in the legend (optional).', + }, + }, + required: ['function'], + additionalProperties: false, + }, + description: 'One or more measures to plot. Each becomes a series in the chart.', + }, + chartType: { + type: 'string', + enum: ['bar', 'column', 'horizontal-bar', 'line', 'area', 'pie', 'donut', 'radar', 'scatter'], + description: + 'Visualization type. Default "bar". Use line/area for time trends, ' + + 'pie/donut for parts-of-a-whole (single measure), bar/column for comparisons.', + }, + where: { + type: 'object', + description: + 'Filter applied before aggregation. MongoDB-style FilterCondition, ' + + 'same rules as query_records: keys MUST be real field names from ' + + 'describe_object.', + }, + title: { + type: 'string', + description: 'Optional chart title shown above the chart.', + }, + limit: { + type: 'number', + description: 'Max number of categories to chart (default 50).', + }, + }, + required: ['objectName', 'measures'], + additionalProperties: false, + }, +}; + +// Module-level counter giving each emitted chart a stable, unique part id so +// the AI SDK keeps every chart as its own part (vs. reconciling them into one). +// A plain counter is fine — ids only need to be unique within a stream. +let chartSeq = 0; + +/** + * Create the handler for {@link VISUALIZE_DATA_TOOL}. + * + * Flow: build an {@link AnalyticsQuery} from the tool args → run it through + * {@link IAnalyticsService.query} (auto-inferred cube when none is defined) + * → shape the rows into the SDUI `` contract → emit a `data-chart` + * custom part via `ctx.onProgress` so the chat renders it inline → return a + * compact JSON summary for the model to narrate. + */ +export function createVisualizeDataHandler(ctx: VisualizeDataToolContext): ToolHandler { + const maxCategories = ctx.maxCategories ?? 50; + + return async (args: Record, execCtx?: ToolExecutionContext): Promise => { + const objectName = typeof args.objectName === 'string' ? args.objectName.trim() : ''; + if (!objectName) { + return JSON.stringify({ error: 'objectName is required' }); + } + + const rawMeasures = Array.isArray(args.measures) ? args.measures : []; + if (rawMeasures.length === 0) { + return JSON.stringify({ error: 'At least one measure is required' }); + } + + // Normalise measures → analytics measure keys + series descriptors. + const measures: Array<{ key: string; fn: AggFunction; field?: string; label: string }> = []; + for (const m of rawMeasures) { + if (!m || typeof m !== 'object') continue; + const mm = m as Record; + const fn = mm.function as AggFunction; + if (!fn) continue; + const field = typeof mm.field === 'string' ? mm.field.trim() || undefined : undefined; + const key = measureKey(fn, field); + const label = + typeof mm.label === 'string' && mm.label.trim() + ? mm.label.trim() + : defaultMeasureLabel(fn, field); + // De-dup identical measure keys (same fn+field) — they'd collide as columns. + if (measures.some((x) => x.key === key)) continue; + measures.push({ key, fn, field, label }); + } + if (measures.length === 0) { + return JSON.stringify({ error: 'No valid measures — each measure needs a `function`' }); + } + + const dimension = typeof args.dimension === 'string' ? args.dimension.trim() || undefined : undefined; + const chartType: ChartType = + typeof args.chartType === 'string' && args.chartType + ? (args.chartType as ChartType) + : 'bar'; + const where = + args.where && typeof args.where === 'object' && !Array.isArray(args.where) + ? (args.where as Record) + : undefined; + const limit = + typeof args.limit === 'number' && args.limit > 0 + ? Math.min(args.limit, maxCategories) + : maxCategories; + const title = typeof args.title === 'string' && args.title.trim() ? args.title.trim() : undefined; + + const query: AnalyticsQuery = { + cube: objectName, + measures: measures.map((m) => m.key), + ...(dimension ? { dimensions: [dimension] } : {}), + ...(where ? { where } : {}), + limit, + }; + + let result: AnalyticsResult; + try { + result = await ctx.analytics.query(query, buildAnalyticsContext(execCtx)); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return JSON.stringify({ + error: `Analytics query failed: ${message}`, + hint: 'Verify objectName and field names via describe_object.', + }); + } + + const rows = Array.isArray(result.rows) ? result.rows : []; + + // Prefer analytics-provided field labels (select option text, measure + // labels) when present — they read better than our derived defaults. + const fieldLabel = new Map(); + for (const f of result.fields ?? []) { + if (f?.name && f.label) fieldLabel.set(f.name, f.label); + } + + const series = measures.map((m) => ({ + dataKey: m.key, + label: fieldLabel.get(m.key) ?? m.label, + })); + + // SDUI `` descriptor — matches ChartRenderer's `schema` contract. + const chartDescriptor = { + type: 'chart', + chartType, + ...(title ? { title } : {}), + data: rows, + ...(dimension ? { xAxisKey: dimension } : {}), + series, + }; + + // Emit the chart as a custom `data-chart` stream part. With a unique id the + // client keeps each chart as its own part; chat UIs that don't understand + // `data-chart` simply ignore it and fall back to the textual summary. + execCtx?.onProgress?.({ + type: 'data-chart', + id: `chart-${chartSeq++}`, + data: chartDescriptor, + }); + + // Compact summary for the model to narrate. Cap the inlined rows so a wide + // result doesn't blow up the context window — the user already sees the + // full chart. + const PREVIEW = 20; + return JSON.stringify({ + rendered: 'chart', + chartType, + object: objectName, + ...(dimension ? { dimension } : {}), + measures: series.map((s) => s.dataKey), + categories: rows.length, + rows: rows.slice(0, PREVIEW), + ...(rows.length > PREVIEW ? { note: `Showing first ${PREVIEW} of ${rows.length} rows; the full chart is rendered for the user.` } : {}), + }); + }; +} + +/** + * Register {@link VISUALIZE_DATA_TOOL} on a {@link ToolRegistry}. + * + * @example + * ```ts + * registerVisualizeDataTool(aiService.toolRegistry, { analytics }); + * ``` + */ +export function registerVisualizeDataTool( + registry: ToolRegistry, + context: VisualizeDataToolContext, +): void { + registry.register(VISUALIZE_DATA_TOOL, createVisualizeDataHandler(context)); +}