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
80 changes: 80 additions & 0 deletions apps/backend/src/agents/tools/display-map.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { buildMapPoints, MAX_MAP_POINTS, resolveColumnName } from '@nao/shared';
import { displayMap } from '@nao/shared/tools';

import { DisplayMapOutput, renderToModelOutput } from '../../components/tool-outputs';
import { getQueryResult } from '../../services/query-result.service';
import { createTool } from '../../utils/tools';

export default createTool<displayMap.Input, displayMap.Output>({
description:
'Display an interactive map of geographic data from a previous `execute_sql` tool call. The query result must contain latitude and longitude columns in decimal degrees.',
inputSchema: displayMap.InputSchema,
outputSchema: displayMap.OutputSchema,

execute: async (input, context) => {
const queryResult = await getQueryResult(context, input.query_id);
if (!queryResult) {
return {
_version: '1',
success: false,
error: `Query result "${input.query_id}" not found. Run \`execute_sql\` first and use the id from its output.`,
};
}

const latitudeColumn = resolveColumnName(queryResult.columns, input.latitude_key);
const longitudeColumn = resolveColumnName(queryResult.columns, input.longitude_key);
const missingColumn = [latitudeColumn, longitudeColumn].find((column) => !queryResult.columns.includes(column));
if (missingColumn) {
return {
_version: '1',
success: false,
error: `Column "${missingColumn}" not found in the query result. Available columns: ${queryResult.columns.join(', ')}.`,
};
}
if (latitudeColumn === longitudeColumn) {
return {
_version: '1',
success: false,
error: 'latitude_key and longitude_key must reference different columns.',
};
}

const points = buildMapPoints(queryResult.data, {
Comment thread
tckaros marked this conversation as resolved.
...input,
latitude_key: latitudeColumn,
longitude_key: longitudeColumn,
});
if (points.length === 0) {
return {
_version: '1',
success: false,
error: 'The query result contains no rows with valid decimal-degree coordinates, so there is nothing to plot.',
};
}

const warnings: string[] = [];
const missingPopupKeys = [input.label_key, ...(input.tooltip_keys ?? [])]
.filter((key): key is string => !!key)
.filter((key) => !queryResult.columns.includes(resolveColumnName(queryResult.columns, key)));
if (missingPopupKeys.length > 0) {
warnings.push(
`Popup column(s) ${missingPopupKeys.map((key) => `"${key}"`).join(', ')} not found in the query result, so popups will not show them. Available columns: ${queryResult.columns.join(', ')}.`,
);
}
if (points.length > MAX_MAP_POINTS) {
warnings.push(
`The map only renders the first ${MAX_MAP_POINTS} points — mention this to the user, or aggregate in SQL to stay under the limit.`,
);
}

return {
_version: '1',
success: true,
point_count: points.length,
dropped_row_count: queryResult.data.length - points.length,
...(warnings.length > 0 && { warning: warnings.join(' ') }),
};
},

toModelOutput: ({ output }) => renderToModelOutput(DisplayMapOutput({ output }), output),
});
39 changes: 36 additions & 3 deletions apps/backend/src/agents/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { mcpService } from '../../services/mcp';
import { AgentSettings } from '../../types/agent-settings';
import clarification from './clarification';
import displayChart from './display-chart';
import displayMap from './display-map';
import executePython from './execute-python';
import executeSandboxedCode from './execute-sandboxed-code';
import executeSql from './execute-sql';
Expand All @@ -19,10 +20,18 @@ import search from './search';
import story from './story';
import suggestFollowUps from './suggest-follow-ups';

/**
* Tools whose output only the web chat can render — excluded from automations and the MCP sub-agent.
* Messaging providers keep them and degrade to an "open in nao" link (a card on Slack/Teams/Telegram,
* a plain-text link on WhatsApp).
*/
export const WEB_CHAT_ONLY_TOOLS = ['display_map'];

export const tools = {
story,
clarification,
display_chart: displayChart,
display_map: displayMap,
...(executePython && { execute_python: executePython }),
...(executeSandboxedCode && { execute_sandboxed_code: executeSandboxedCode }),
execute_sql: executeSql,
Expand All @@ -49,6 +58,12 @@ export const getTools = (
* only discover context, not query the warehouse or render charts.
*/
builtinToolAllowlist?: string[];
/**
* Drops these built-in tools from the returned set. Used by runs whose
* surface cannot render a tool's output (e.g. `display_map` outside the
* web chat: automations, MCP sub-agent, WhatsApp).
*/
excludeBuiltinTools?: string[];
} = {},
) => {
const configuredServers = new Set(mcpService.getConfiguredServerNames());
Expand All @@ -66,6 +81,7 @@ export const getTools = (
execute_sandboxed_code,
clarification: clarificationTool,
suggest_follow_ups,
display_map: displayMapTool,
...rest
} = tools;
const baseTools = options.excludeFollowUps ? rest : { ...rest, suggest_follow_ups };
Expand All @@ -76,13 +92,30 @@ export const getTools = (
...mcpTools,
...(agentSettings?.experimental?.pythonSandboxing && execute_python && { execute_python }),
...(agentSettings?.experimental?.sandboxes && execute_sandboxed_code && { execute_sandboxed_code }),
...(agentSettings?.experimental?.displayMap && { display_map: displayMapTool }),
...extraTools,
};

let result = allTools;
if (options.builtinToolAllowlist) {
const allowed = new Set([...options.builtinToolAllowlist, ...Object.keys(extraTools ?? {})]);
return Object.fromEntries(Object.entries(allTools).filter(([name]) => allowed.has(name))) as typeof allTools;
const allowed = new Set(options.builtinToolAllowlist);
result = keepTools(result, extraTools, (name) => allowed.has(name));
}
if (options.excludeBuiltinTools) {
const excluded = new Set(options.excludeBuiltinTools);
result = keepTools(result, extraTools, (name) => !excluded.has(name));
}

return result;
};

return allTools;
const keepTools = <T extends Record<string, unknown>>(
toolset: T,
extraTools: Record<string, unknown> | undefined,
shouldKeep: (name: string) => boolean,
): T => {
const extraToolNames = new Set(Object.keys(extraTools ?? {}));
return Object.fromEntries(
Object.entries(toolset).filter(([name]) => shouldKeep(name) || extraToolNames.has(name)),
) as T;
};
11 changes: 11 additions & 0 deletions apps/backend/src/components/ai/system-prompt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ type SystemPromptProps = {
mcpServers?: string[];
timezone?: string;
testMode?: boolean;
/** Names of the tools in the run's tool set — rules for surface-dependent tools (e.g. display_map) are only emitted when the tool is present. Omit to include every rule. */
toolNames?: string[];
};

export const MEMORY_TOKEN_LIMIT = 1000;
Expand All @@ -34,7 +36,9 @@ export function SystemPrompt({
mcpServers = [],
timezone,
testMode,
toolNames,
}: SystemPromptProps) {
const hasTool = (name: string) => !toolNames || toolNames.includes(name);
const visibleMemories = getMemoriesInTokenRange(memories, MEMORY_TOKEN_LIMIT);
const dialectToolCallRules = getDialectToolCallRules(connections);
const dialectSqlQueryRules = getDialectSqlQueryRules(connections);
Expand Down Expand Up @@ -97,6 +101,13 @@ export function SystemPrompt({
</List>
<Title level={2}>Chart Rules</Title>
<List>
{hasTool('display_map') && (
<ListItem>
Use display_map instead of display_chart for spatial point data: query results with latitude and
longitude columns (store locations, delivery points, events). Coordinates must be in decimal
degrees — select or convert them in SQL if needed.
</ListItem>
)}
<ListItem>
For display_chart x_axis_type: use "date" only when x-axis values are parseable by JavaScript Date
(e.g. YYYY-MM-DD). Use "category" for quarter labels (quarter_ending), fiscal periods (FY25-Q1), or
Expand Down
20 changes: 20 additions & 0 deletions apps/backend/src/components/tool-outputs/display-map.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { displayMap } from '@nao/shared/tools';

import { Block } from '../../lib/markdown';

export function DisplayMapOutput({ output }: { output: displayMap.Output }) {
if (output.error) {
return <Block>Could not display the map: {output.error}</Block>;
}
const points = output.point_count != null ? ` with ${output.point_count} point(s)` : '';
const dropped = output.dropped_row_count
? ` ${output.dropped_row_count} row(s) were dropped for missing or invalid coordinates — adjust the SQL if they should appear.`
: '';
const warning = output.warning ? ` Warning: ${output.warning}` : '';
return (
<Block>
Map displayed successfully{points}.{dropped}
{warning}
</Block>
);
}
1 change: 1 addition & 0 deletions apps/backend/src/components/tool-outputs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { ReactNode } from 'react';
import { renderToMarkdown } from '../../lib/markdown/render-to-markdown';

export { DisplayChartOutput } from './display-chart';
export { DisplayMapOutput } from './display-map';
export { ExecuteSqlOutput } from './execute-sql';
export { GrepOutput } from './grep';
export { ListOutput } from './list';
Expand Down
3 changes: 2 additions & 1 deletion apps/backend/src/handlers/automation.handler.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { InferUIMessageChunk } from 'ai';
import { getToolName, isToolUIPart, readUIMessageStream } from 'ai';

import { getTools } from '../agents/tools';
import { getTools, WEB_CHAT_ONLY_TOOLS } from '../agents/tools';
import { renderAutomationRunPrompt } from '../components/ai/automation-run-prompt';
import type { DBAutomationRun, DBScheduledJob } from '../db/abstractSchema';
import type { AutomationWithSchedule } from '../queries/automation.queries';
Expand Down Expand Up @@ -142,6 +142,7 @@ async function finishAutomationRun(automation: AutomationWithSchedule, run: DBAu
mcpEnabled: automation.mcpEnabled,
mcpServers: automation.mcpServers,
excludeFollowUps: true,
excludeBuiltinTools: WEB_CHAT_ONLY_TOOLS,
},
),
},
Expand Down
7 changes: 5 additions & 2 deletions apps/backend/src/mcp/tools/sub-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { type InferUIMessageChunk, readUIMessageStream } from 'ai';
import { z } from 'zod';

import { WEB_CHAT_ONLY_TOOLS } from '../../agents/tools';
import * as chatQueries from '../../queries/chat.queries';
import * as storyQueries from '../../queries/story.queries';
import { agentService } from '../../services/agent';
import { agentService, defaultAgentToolsExcluding } from '../../services/agent';
import { mcpService } from '../../services/mcp';
import { skillService } from '../../services/skill';
import type { UIMessage, UIMessagePart } from '../../types/chat';
Expand Down Expand Up @@ -129,7 +130,9 @@ export function registerSubAgentTools(server: McpServer, ctx: McpContext): void
const { chat, uiMessages } = await buildChatContext(ctx.projectId, ctx.userId, question, chatId);
const naoChatUrl = chatUrl(chat.id);

const agent = await agentService.create(chat);
const agent = await agentService.create(chat, undefined, {
tools: defaultAgentToolsExcluding(WEB_CHAT_ONLY_TOOLS),
});
askNaoRuns.start(chat.id);
const runPromise = runAskNaoInBackground(agent, uiMessages, chat.id, naoChatUrl);

Expand Down
7 changes: 7 additions & 0 deletions apps/backend/src/services/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,12 @@ export type AgentToolsResolver = (context: AgentToolsContext) => AgentTools | Pr
export const defaultAgentTools: AgentToolsResolver = ({ chat, agentSettings, webTools }) =>
getTools(agentSettings, webTools ?? {}, { testMode: chat.testMode });

/** Default tool set minus the given built-ins — for runs whose surface cannot render them. */
export const defaultAgentToolsExcluding =
(excludeBuiltinTools: string[]): AgentToolsResolver =>
({ chat, agentSettings, webTools }) =>
getTools(agentSettings, webTools ?? {}, { testMode: chat.testMode, excludeBuiltinTools });

/**
* Admin-mode tool set: the same `execute_sql` tool the chat already uses (it
* runs against nao's own app database when `ToolContext.adminMode` is set),
Expand Down Expand Up @@ -589,6 +595,7 @@ class AgentManager {
mcpServers,
timezone,
testMode: this.chat.testMode,
toolNames: Object.keys(this._agentTools),
}),
);
const renderedPrompt = provider
Expand Down
36 changes: 33 additions & 3 deletions apps/backend/src/services/slack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
createFeedbackModal,
createImageBlock,
createLiveToolCall,
createMapLinkCard,
createStopButtonCard,
createSummaryToolCalls,
createTextBlock,
Expand Down Expand Up @@ -665,7 +666,7 @@ class ProjectSlackBot {
ctx: ConversationContext,
): Promise<StreamState> {
const state: StreamState = {
renderedChartIds: new Set(),
renderedToolCallIds: new Set(),
sqlOutputs: new Map(),
lastUpdateAt: Date.now(),
toolGroup: new Map(),
Expand All @@ -691,6 +692,8 @@ class ProjectSlackBot {
this._handleSqlPart(part, state);
} else if (part.type === 'tool-display_chart') {
await this._handleChartPart(part, state, ctx);
} else if (part.type === 'tool-display_map') {
await this._handleMapPart(part, state, ctx);
}
}

Expand Down Expand Up @@ -725,7 +728,7 @@ class ProjectSlackBot {
state: StreamState,
ctx: ConversationContext,
): Promise<void> {
if (part.state !== 'output-available' || state.renderedChartIds.has(part.toolCallId)) {
if (part.state !== 'output-available' || state.renderedToolCallIds.has(part.toolCallId)) {
return;
}
if (part.input.chart_type === 'table') {
Expand All @@ -743,7 +746,7 @@ class ProjectSlackBot {
dateFormat: displaySettings.dateFormat,
});
const chartId = await chartImageQueries.saveChart(part.toolCallId, png.toString('base64'));
state.renderedChartIds.add(part.toolCallId);
state.renderedToolCallIds.add(part.toolCallId);

if (this._config.transportMode === 'socket') {
await this._uploadChartImageFile(png, sqlOutput.name, ctx);
Expand All @@ -763,6 +766,33 @@ class ProjectSlackBot {
}
}

private async _handleMapPart(
part: Extract<UIMessagePart, { type: 'tool-display_map' }>,
state: StreamState,
ctx: ConversationContext,
): Promise<void> {
if (
part.state !== 'output-available' ||
!part.output.success ||
state.renderedToolCallIds.has(part.toolCallId)
) {
return;
}
state.renderedToolCallIds.add(part.toolCallId);
try {
const chatUrl = new URL(ctx.chatId, this._redirectUrl).toString();
ctx.textBlockIndex = -1;
ctx.textBlockCount = 0;
ctx.blocks.push(...createMapLinkCard(part.input.title, chatUrl));
await ctx.convMessage?.edit(Card({ children: ctx.blocks }));
} catch (error) {
logger.error(`Map link card failed: ${String(error)}`, {
source: 'system',
context: { chatId: ctx.chatId, toolCallId: part.toolCallId },
});
}
}

private async _uploadChartImageFile(png: Buffer, name: string | null, ctx: ConversationContext): Promise<void> {
const [, channelId, threadTs] = ctx.thread.id.split(':');
const filename = name ? `${name.toLowerCase().replace(/\s+/g, '_')}.png` : 'chart.png';
Expand Down
Loading
Loading