Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -167,3 +167,5 @@ POSTHOG_DISABLED=false # (used to opt-out of tracking)
# BETA_AUTOMATIONS_ENABLED=false
# Set to true to enable the Context Recommendations feature.
# BETA_CONTEXT_RECOMMENDATIONS_ENABLED=false
# Set to true to enable interactive story filters.
# BETA_STORY_FILTERS_ENABLED=false
2 changes: 2 additions & 0 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@ jobs:
-e "ENABLE_USER_SIGNUP=false" \
-e "BETA_AUTOMATIONS_ENABLED=true" \
-e "BETA_CONTEXT_RECOMMENDATIONS_ENABLED=true" \
-e "BETA_STORY_FILTERS_ENABLED=true" \
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
-e "SMTP_PASSWORD=${{ secrets.SMTP_PASSWORD }}" \
-e "SMTP_HOST=${{ secrets.SMTP_HOST }}" \
-e "SMTP_PORT=${{ secrets.SMTP_PORT }}" \
Expand Down Expand Up @@ -271,6 +272,7 @@ jobs:
-e "ENABLE_USER_SIGNUP=true" \
-e "BETA_AUTOMATIONS_ENABLED=true" \
-e "BETA_CONTEXT_RECOMMENDATIONS_ENABLED=true" \
-e "BETA_STORY_FILTERS_ENABLED=true" \
-e "GOOGLE_CLIENT_ID=${{ secrets.NAOCLOUD_GOOGLE_CLIENT_ID }}" \
-e "GOOGLE_CLIENT_SECRET=${{ secrets.NAOCLOUD_GOOGLE_CLIENT_SECRET }}" \
getnao/nao:${{ needs.merge.outputs.image-tag }}
Expand Down
97 changes: 80 additions & 17 deletions apps/backend/src/agents/tools/execute-sql.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,34 @@
import { sqlIncludesFilterTemplate, stripSqlFilterBlocks, validateSqlFilterTemplate } from '@nao/shared/sql-template';
import type { executeSql } from '@nao/shared/tools';
import { executeSql as schemas } from '@nao/shared/tools';

import { ExecuteSqlOutput, renderToModelOutput } from '../../components/tool-outputs';
import { env } from '../../env';
import { getExecuteSqlPartByQueryIdInChat, updateExecuteSqlPart } from '../../queries/execute-sql.queries';
import { ToolContext } from '../../types/tools';
import { detectQueryRowLimit, isReadOnlySqlQuery } from '../../utils/sql-filter';
import { createTool } from '../../utils/tools';
import { queryAppDb } from './query-app-db';

export async function executeQuery(
{ sql_query, database_id }: executeSql.Input,
{ sql_query, database_id, query_id }: executeSql.Input,
context: ToolContext,
): Promise<executeSql.Output> {
const templateWarnings = env.BETA_STORY_FILTERS_ENABLED ? validateSqlFilterTemplate(sql_query) : [];
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
const effectiveSql = stripSqlFilterBlocks(sql_query);
if (templateWarnings.length > 0 && sqlIncludesFilterTemplate(effectiveSql)) {
throw new Error(`Invalid story filter SQL template: ${templateWarnings.join(' ')}`);
}
const writePermEnabled = context.agentSettings?.sql?.dangerouslyWritePermEnabled ?? false;
if (!writePermEnabled && !(await isReadOnlySqlQuery(sql_query))) {
if (!writePermEnabled && !(await isReadOnlySqlQuery(effectiveSql))) {
throw new Error(
'Write SQL operations are disabled. Only SELECT queries are allowed. ' +
'Enable "Dangerous write permissions" in the admin panel to allow INSERT, UPDATE, DELETE and DDL queries.',
);
}

if (context.adminMode) {
return executeAppDbQuery(sql_query, context);
return withTemplateWarnings(await executeAppDbQuery(effectiveSql, context, query_id), templateWarnings);
}

const naoProjectFolder = context.projectFolder;
Expand All @@ -32,7 +39,7 @@ export async function executeQuery(
'Content-Type': 'application/json',
},
body: JSON.stringify({
sql: sql_query,
sql: effectiveSql,
nao_project_folder: naoProjectFolder,
...(database_id && { database_id }),
...(Object.keys(envVars).length > 0 && { env_vars: envVars }),
Expand All @@ -46,23 +53,30 @@ export async function executeQuery(
}

const data = await response.json();
const id = `query_${crypto.randomUUID().slice(0, 8)}` as const;
const id = query_id ?? (`query_${crypto.randomUUID().slice(0, 8)}` as const);

context.queryResults.set(id, { columns: data.columns, data: data.data });

const appliedLimit = detectQueryRowLimit(sql_query);
const appliedLimit = detectQueryRowLimit(effectiveSql);

return {
_version: '1',
...data,
id,
...(appliedLimit !== null && { applied_limit: appliedLimit }),
};
return withTemplateWarnings(
{
_version: '1',
...data,
id,
...(appliedLimit !== null && { applied_limit: appliedLimit }),
},
templateWarnings,
);
}

async function executeAppDbQuery(sqlQuery: string, context: ToolContext): Promise<executeSql.Output> {
async function executeAppDbQuery(
sqlQuery: string,
context: ToolContext,
queryId?: `query_${string}`,
): Promise<executeSql.Output> {
const { columns, rows } = await queryAppDb(context.projectId, sqlQuery);
const id = `query_${crypto.randomUUID().slice(0, 8)}` as const;
const id = queryId ?? (`query_${crypto.randomUUID().slice(0, 8)}` as const);
context.queryResults.set(id, { columns, data: rows });
const appliedLimit = detectQueryRowLimit(sqlQuery);
return {
Expand All @@ -75,11 +89,60 @@ async function executeAppDbQuery(sqlQuery: string, context: ToolContext): Promis
};
}

export default createTool<executeSql.Input, executeSql.Output>({
description:
function withTemplateWarnings(output: executeSql.Output, templateWarnings: string[]): executeSql.Output {
if (templateWarnings.length === 0) {
return output;
}
return { ...output, template_warnings: templateWarnings };
}

async function updateExistingQuery(
input: executeSql.Input & { query_id: `query_${string}` },
context: ToolContext,
): Promise<executeSql.Output> {
const existing = await getExecuteSqlPartByQueryIdInChat(context.chatId, input.query_id);
if (!existing) {
throw new Error(
`Query ${input.query_id} not found in this chat. Use execute_sql without query_id to create a new query.`,
);
}

const nextInput: executeSql.Input = {
sql_query: input.sql_query,
database_id: input.database_id ?? existing.toolInput.database_id,
name: input.name ?? existing.toolInput.name,
};

const output = await executeQuery({ ...nextInput, query_id: input.query_id }, context);
await updateExecuteSqlPart(existing.toolCallId, nextInput, output);
return output;
}

function buildExecuteSqlToolDescription() {
return [
'Execute a SQL query against the connected database and return the results. If multiple databases are configured, specify the database_id.',
...(env.BETA_STORY_FILTERS_ENABLED
? [
'Story filters may be embedded as SQL template blocks that are stripped when this tool runs in chat.',
'Correct syntax: WHERE 1 = 1 {% filter country %} AND country IN ({{ filters.country.sql }}) {% endfilter %}.',
"For date_range filters, {{ filters.<id>.sql }} already expands to 'start' AND 'end', so write: {% filter period %} AND order_date BETWEEN {{ filters.period.sql }} {% endfilter %}.",
'Never use filters.<id>.start, .end, .value, or placeholders outside {% filter %} blocks — placeholders outside blocks are rejected; other invalid templates return template_warnings.',
'Prefer query_id when adding story filter templates so existing <chart>/<table> tags keep working.',
]
: []),
'To edit a previous query in-place (keep the same query_id for charts/stories), pass query_id from an earlier execute_sql result.',
].join(' ');
}

export default createTool<executeSql.Input, executeSql.Output>({
description: buildExecuteSqlToolDescription(),
inputSchema: schemas.InputSchema,
outputSchema: schemas.OutputSchema,
execute: executeQuery,
execute: async (input, context) => {
if (input.query_id) {
return updateExistingQuery({ ...input, query_id: input.query_id }, context);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Editing a query creates a second persisted execute_sql result with the same ID, so later chart/data lookups can read the stale duplicate because query-ID lookups have no ordering. Persist the replacement only once, or make query-ID retrieval/update select a deterministic canonical/latest part.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/backend/src/agents/tools/execute-sql.ts, line 127:

<comment>Editing a query creates a second persisted `execute_sql` result with the same ID, so later chart/data lookups can read the stale duplicate because query-ID lookups have no ordering. Persist the replacement only once, or make query-ID retrieval/update select a deterministic canonical/latest part.</comment>

<file context>
@@ -75,11 +82,51 @@ async function executeAppDbQuery(sqlQuery: string, context: ToolContext): Promis
-	execute: executeQuery,
+	execute: async (input, context) => {
+		if (input.query_id) {
+			return updateExistingQuery({ ...input, query_id: input.query_id }, context);
+		}
+		return executeQuery(input, context);
</file context>

}
return executeQuery(input, context);
},
toModelOutput: ({ output }) => renderToModelOutput(ExecuteSqlOutput({ output }), output),
});
38 changes: 35 additions & 3 deletions apps/backend/src/agents/tools/story.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,28 +3,46 @@ import { story } from '@nao/shared/tools';

import { renderToModelOutput, StoryOutput } from '../../components/tool-outputs';
import { db } from '../../db/db';
import { env } from '../../env';
import { getDisplayChartTableFormatsForChat } from '../../queries/chart-image';
import * as storyQueries from '../../queries/story.queries';
import * as storyFolderQueries from '../../queries/story-folder.queries';
import { getStoryTemplateWarnings } from '../../services/story-template-validation';
import type { ToolContext } from '../../types/tools';
import { createTool } from '../../utils/tools';

export default createTool<story.Input, story.Output>({
description: [
const STORY_FILTER_DESCRIPTION = [
'Story-level filters are declared via <filter id="..." label="..." type="select|multi_select|search|date_range" ... />.',
'For select/multi_select, provide either table+column (options from SELECT DISTINCT) or options=\'["a","b"]\' (hardcoded values).',
'When using table+column and multiple databases are configured, set database_id on the filter so option loading targets the correct database.',
'Matching SQL must use template blocks that reference the same filter id, e.g. WHERE 1 = 1 {% filter country %} AND country IN ({{ filters.country.sql }}) {% endfilter %}.',
"For date_range, {{ filters.<id>.sql }} already expands to 'start' AND 'end' — write {% filter period %} AND order_date BETWEEN {{ filters.period.sql }} {% endfilter %}. Never use .start/.end/.value.",
'Chat and live refresh strip unset filter blocks so the query still runs; active story filter selections re-render and re-execute SQL.',
'Invalid filter templates are reported as template_warnings in the tool result — fix them before finishing.',
'When adding filters to existing charts, prefer execute_sql with query_id set to the existing query so chart/table tags keep the same query_id.',
].join(' ');

function buildStoryToolDescription() {
return [
'Create or modify a nao Story — an interactive document combining markdown text and chart visualizations.',
'Use "create" to initialize a new story, "update" to search-and-replace within it (producing a new version),',
'or "replace" to overwrite the entire content (producing a new version).',
'Charts are embedded via <chart query_id="..." chart_type="..." x_axis_key="..." series=\'[...]\' title="..." />.',
'For kpi_card charts you may add comparison_mode="percentage|variation|absolute" to show a period-over-period change pill; this requires the query to return at least two time-ordered rows (one per period) for the metric, and kpi_card does not need x_axis_key.',
'SQL result tables are embedded via <table query_id="..." title="..." />.',
...(env.BETA_STORY_FILTERS_ENABLED ? [STORY_FILTER_DESCRIPTION] : []),
'Use <grid>...</grid> to place 2–4 charts/tables side by side; its direct <chart>/<table> blocks are the columns.',
'For unequal columns add widths="w1,w2,..." to the <grid> — one positive integer per column giving its relative width (e.g. widths="2,1" makes the first column twice as wide as the second). The number of values must equal the number of columns; omit widths for equal columns. Choose widths that fit the content, e.g. a wide time-series next to a narrow KPI or pie.',
'Use consecutive <tab title="...">...</tab> blocks to organize a story into top-level tabs.',
'Default to a single flowing story. Use tabs only when the user asks for tabs, or when the content splits into clearly distinct sections that are better separated than stacked (e.g. overview vs. detail, one topic/department/metric per tab). Avoid tabs for a short or single-topic story. Always follow the user\'s explicit request (e.g. "a tab per chart" means one chart per tab). When using tabs, the entire story must consist of <tab title="...">...</tab> blocks — no content outside a tab.',
'A story can also be refered as a "canva", an "artifact" or a "report".',
'Users may edit stories directly; the tool result always reflects the latest version, including user edits.',
'Unless explicitly stated, dont use the stories to display a chart, but the display_chart tool.',
].join(' '),
].join(' ');
}

export default createTool<story.Input, story.Output>({
description: buildStoryToolDescription(),
inputSchema: story.InputSchema,
outputSchema: story.OutputSchema,

Expand Down Expand Up @@ -77,6 +95,7 @@ export default createTool<story.Input, story.Output>({
version: version.version,
code: version.code,
title: version.title,
...(await storyTemplateWarnings(chatId, version.code)),
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
};
}

Expand Down Expand Up @@ -115,6 +134,7 @@ export default createTool<story.Input, story.Output>({
version: version.version,
code: version.code,
title: version.title,
...(await storyTemplateWarnings(chatId, version.code)),
};
}

Expand All @@ -141,6 +161,7 @@ export default createTool<story.Input, story.Output>({
version: version.version,
code: version.code,
title: version.title,
...(await storyTemplateWarnings(chatId, version.code)),
};
},

Expand All @@ -152,6 +173,17 @@ async function carryOverTableFormatting(code: string, chatId: string): Promise<s
return injectTableFormatting(code, formatsByQueryId);
}

/** The story version is already committed at this point, so a warning failure must not fail the tool. */
async function storyTemplateWarnings(chatId: string, code: string): Promise<{ template_warnings?: string[] }> {
try {
const warnings = await getStoryTemplateWarnings(chatId, code);
return warnings.length > 0 ? { template_warnings: warnings } : {};
} catch (error) {
console.error('Failed to compute story template warnings', error);
return {};
}
}

function rememberStoryArtifact(context: ToolContext, id: string, title: string): void {
const existing = context.generatedArtifacts.stories.find((story) => story.id === id);
if (existing) {
Expand Down
47 changes: 43 additions & 4 deletions apps/backend/src/components/tool-outputs/execute-sql.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,35 @@
import { pluralize } from '@nao/shared';
import type { executeSql } from '@nao/shared/tools';

import { Block, ListItem, Span, Title, TitledList } from '../../lib/markdown';
import { Block, List, ListItem, Span, Title, TitledList } from '../../lib/markdown';
import { QueryRows } from './query-rows';

const MAX_ROWS = 40;

export const ExecuteSqlOutput = ({ output, maxRows = MAX_ROWS }: { output: executeSql.Output; maxRows?: number }) => {
const templateWarnings = output.template_warnings ?? [];

if (output.superseded) {
return (
<Block>
Stale result: query {output.id} was re-run later in this conversation. Refer to the latest execute_sql
result with this query id (or call read_query_result) for the current SQL and rows.
</Block>
);
}

if (output.data.length === 0) {
return <Block>The query was successfully executed and returned no rows.</Block>;
return (
<Block>
The query was successfully executed and returned no rows.
{templateWarnings.length > 0 && (
<>
<Span>Query ID: {output.id}</Span>
<TemplateWarnings warnings={templateWarnings} />
</>
)}
</Block>
);
}

const isTruncated = output.data.length > maxRows;
Expand All @@ -23,8 +44,8 @@ export const ExecuteSqlOutput = ({ output, maxRows = MAX_ROWS }: { output: execu
<Span>Query ID: {output.id}</Span>

<TitledList title={`${pluralize('Column', output.columns.length)} (${output.columns.length})`}>
{output.columns.map((column) => (
<ListItem>{column}</ListItem>
{output.columns.map((column, index) => (
<ListItem key={`${column}-${index}`}>{column}</ListItem>
))}
</TitledList>

Expand All @@ -41,6 +62,8 @@ export const ExecuteSqlOutput = ({ output, maxRows = MAX_ROWS }: { output: execu
</Span>
)}

{templateWarnings.length > 0 && <TemplateWarnings warnings={templateWarnings} />}

<QueryRows rows={visibleRows} />

{remainingRows > 0 && (
Expand All @@ -52,3 +75,19 @@ export const ExecuteSqlOutput = ({ output, maxRows = MAX_ROWS }: { output: execu
</Block>
);
};

function TemplateWarnings({ warnings }: { warnings: string[] }) {
return (
<Block>
<Span>
Story filter template warnings — the baseline query ran (filter blocks stripped), but story filters will
not apply correctly until you fix the SQL with execute_sql (prefer query_id):
</Span>
<List>
{warnings.map((warning) => (
<ListItem key={warning}>{warning}</ListItem>
))}
</List>
</Block>
);
}
17 changes: 16 additions & 1 deletion apps/backend/src/components/tool-outputs/story.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { story } from '@nao/shared/tools';

import { Block } from '../../lib/markdown';
import { Block, List, ListItem, Span } from '../../lib/markdown';

export type StoryModelOutput = story.Output & {
_stale?: boolean;
Expand All @@ -21,6 +21,8 @@ export function StoryOutput({ output }: { output: StoryModelOutput }) {
);
}

const templateWarnings = output.template_warnings ?? [];

return (
<Block>
Story "{output.title}" (v{output.version}) — {output.id}
Expand All @@ -30,6 +32,19 @@ export function StoryOutput({ output }: { output: StoryModelOutput }) {
current version. Base any further changes on this content.
</Block>
)}
{templateWarnings.length > 0 && (
<Block>
<Span>
Story filter template warnings — fix the referenced SQL with execute_sql (prefer query_id)
and/or the story filter tags before considering this story complete:
</Span>
<List>
{templateWarnings.map((warning) => (
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
<ListItem key={warning}>{warning}</ListItem>
))}
</List>
</Block>
)}
<Block>{output.code}</Block>
</Block>
);
Expand Down
6 changes: 6 additions & 0 deletions apps/backend/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,12 @@ const envSchema = z.object({
.optional()
.default('false')
.transform((val) => val === 'true'),

BETA_STORY_FILTERS_ENABLED: z
.enum(['true', 'false'])
.optional()
.default('false')
.transform((val) => val === 'true'),
});

const result = envSchema.safeParse(process.env);
Expand Down
Loading
Loading