跳到主要内容

工具带:SQL、图表、故事与沙箱

30 秒导读: 上一章讲的 Agent 主循环 是"引擎";这一章讲引擎驱动的那组手脚——nao 给模型的每一个工具。核心一句话:每个工具都是同一套四段式——先校验入参、再把动作落到真实目标(数据库/图表/文档/沙箱)、再把结果回写进一次运行的共享 ToolContext、最后把结果渲染成模型能读的 Markdown。全章以 execute_sql 这条最长的链为主线,顺带把其余工具一次讲清。

本章不讲循环怎么把这些工具串起来跑(那是 02-agent-loop.md);只讲每一只手脚自己怎么实现。上下文即文件系统的那套 read/grep/list01-context-filesystem.md 已讲,这里不重复。


1. 这是什么(零基础也能懂)

一句话定义: 工具(tool)就是模型被允许调用的一个函数——它有名字、有参数 schema、有一段真实执行的代码。模型只会"说"要调用哪个工具、传什么参数;真正干活的是 nao 后端里对应的那段 TypeScript / Python。

给谁用、解决什么问题: 假设一个业务同事在聊天框里问"上个季度北美区营收环比怎么样,画个图"。模型自己不会连数据库、不会画图、更不会跑 Python。它能做的只有:调 execute_sql 拿数、调 display_chart 出图、必要时调 execute_sandboxed_code 跑一段 pandas。工具带就是把"模型的话"翻译成"对真实系统的真实操作"的那一层。

nao 的工具清单(内置那批):

工具名干什么
execute_sql对连上的仓库跑一条(默认只读)SQL,返回行数据
read_query_result翻页读上一条 execute_sql 的缓存结果,不重新跑 SQL
display_chart把某条查询结果渲染成图表
story创建/改写一份可交互文档(story),内嵌图表与表格
execute_python在一个纯 TS 的 Python 解释器里跑一小段计算(无联网)
execute_sandboxed_code在一个真沙箱 VM 里跑 Python / shell(可联网、可装包)
clarification反问用户一个澄清问题,然后停下等回复
suggest_follow_ups一轮结束时给出用户可能想追问的下一步
read / grep / list / search读上下文文件系统(见 01 章)
web_search / web_fetch走各大模型厂商自带的联网检索
MCP 工具外部 MCP server 暴露进来的第三方工具

一句话直觉: 把模型想成一个只会说话、动不了手的分析师;工具带就是它的手、它的键盘、它的画笔。而这套手脚最讲究的一件事是:不能让分析师随手把数据库写坏——所以最重的那只手 execute_sql,门口站着一道"只读闸"。


2. 顶层全景(一个工具长什么样)

2.1 所有工具的共同骨架:createTool

nao 的每个内置工具都由一个薄封装 createTool 生成。它做的唯一一件事,是让工具的执行函数能拿到一份类型化的运行上下文 ToolContext——Vercel AI SDK 原生只透传一个 experimental_context,createTool 把它转成有类型的 ToolContext 交给你。

真实实现只有十几行(apps/backend/src/utils/tools.ts:11 createTool):

// 示意,非源码:createTool 的核心
export const createTool = (opts) =>
tool({
...opts,
// AI SDK 把上下文塞在 experimental_context 里,这里转成有类型的 ToolContext
execute: (input, { experimental_context }) =>
opts.execute(input, experimental_context as ToolContext),
});

一个工具定义因此长成固定的四件套:

字段作用
description给模型看的用途说明(常常写得很长,是隐性的"工具级 prompt")
inputSchema / outputSchemazod schema,既约束模型出参,也约束返回值
execute真正干活的函数,拿到 inputToolContext
toModelOutput把执行结果渲染成模型能读的文本(见 2.3)

2.2 ToolContext:一次运行里所有工具的公用黑板

execute 拿到的第二个参数 ToolContext,是同一次 agent 运行内所有工具共享的可变状态(apps/backend/src/types/tools.ts:15 ToolContext)。它是本章的暗线——工具之间不直接说话,而是通过往这块黑板上写、从这块黑板上读来协作:

字段谁写谁读
queryResults (Map)execute_sql 把结果按 query_id 塞进去display_chart / read_query_result / 沙箱按 id 取回
generatedArtifacts.chartsdisplay_chart 推入图表配置循环结束后前端渲染
generatedArtifacts.storiesstory 推入 story 元信息同上
projectFolder循环启动时注入所有需要读文件/连库的工具
agentSettings循环启动时注入execute_sql 读写权限开关等
azureAccessTokenEE 版 Microsoft 登录注入,OSS 恒为 nullexecute_sql 的联邦鉴权

queryResults 就是"图表工具从不重新跑 SQL"的秘诀:数据只查一次,后续所有引用都走这个 id → 缓存(apps/backend/src/types/tools.ts:34)。

2.3 toModelOutput:结果不是塞 JSON,而是渲染成 Markdown

一个容易忽略但很关键的设计:工具执行完得到的是结构化对象,但喂回给模型的不是这个对象的 JSON,而是先用一组 JSX 组件渲染成 Markdown。桥梁是 renderToModelOutput(apps/backend/src/components/tool-outputs/index.ts:16):

// 示意,非源码
export function renderToModelOutput(node, fallback) {
const markdown = renderToMarkdown(node); // 把 JSX 组件树渲染成 markdown
return { type: 'text', value: markdown || JSON.stringify(fallback) };
}

于是每个工具的 toModelOutput 都是 renderToModelOutput(SomeOutput({ output }), output) 这种形状——用一个专门的组件(ExecuteSqlOutputDisplayChartOutputStoryOutput…)决定"这条工具结果讲给模型听时长什么样"。这一步是控制模型注意力的地方:比如把整表 40 行截断、把"结果可能被 LIMIT 截断了"的告警写进去(见 3.5)。

2.4 工具怎么被注册进循环

getTools 组装最终交给模型的工具集(apps/backend/src/agents/tools/index.ts:34)。它不是简单地全给,而是按设置和平台能力做门控:

getTools(agentSettings, ...)

├─ 基础工具:story / execute_sql / read_query_result / grep / list / read / search
├─ + suggest_follow_ups (除非 excludeFollowUps)
├─ + clarification (除非 testMode)
├─ + MCP 工具 (除非 mcpEnabled=false)
├─ + execute_python (仅当 experimental.pythonSandboxing 且平台支持)
├─ + execute_sandboxed_code (仅当 experimental.sandboxes 且平台支持)
└─ + extraTools

注意最后两个是双重门控:既要用户在设置里开开关,又要对应的原生二进制在本机能加载——execute_python / execute_sandboxed_code 若原生绑定加载失败,整个工具在 index.ts 里干脆是 null,...(x && { ... }) 直接不注册它(apps/backend/src/agents/tools/index.ts:66-67)。


3. 主线:execute_sql 的端到端路径

这是整条工具带工程含量最高的一支。难点从来不是"跑一条 SQL",而是"让一个语言模型对生产仓库跑 SQL,还不能把库写坏、还得支持十几种方言、还得把结果精准回喂"。跟着一次调用从头走到尾。

3.1 全景图:一次 execute_sql 穿过几层

怎么读这张图:从上到下是一次调用的时间顺序;跨过中间那条虚线就从 TypeScript 世界进了 Python 世界。

模型说:execute_sql({ sql_query, database_id? })

▼ [TS] apps/backend/src/agents/tools/execute-sql.ts
① 只读闸:isReadOnlySqlQuery(sql) ← 除非管理员开了 dangerous write
② HTTP POST → http://localhost:{FASTAPI_PORT}/execute_sql
──────────────────────────────────────────────── TS │ Python 分界

▼ [PY] apps/backend/fastapi/main.py :execute_sql
③ NaoConfig.try_load(项目目录) ← 载入 nao_config.yaml
④ 选库:1 个直接用 / 多个按 database_id 挑
⑤ 看 auth_mode:
azure_entra_id → execute_sql_with_token(sql, token)
其它 → execute_sql(sql)

▼ [PY] cli/nao_core/config/databases/<方言>.py
⑥ 13 种方言适配器之一:connect() 建 ibis 连接 → raw_sql(sql)
⑦ 游标 → pandas DataFrame

▼ 回到 TS
⑧ df → JSON → context.queryResults.set(id, ...) ← 落到黑板
⑨ detectQueryRowLimit(sql) 算截断告警
⑩ toModelOutput:渲染成 markdown 表 + 告警,喂回模型

3.2 第 ① 步:只读闸(这是 SQL 工具的灵魂)

在把 SQL 发出去之前,TS 侧先做一道纯正则的静态判定:这条语句是不是只读的?若不是,且管理员没显式打开"危险写权限",直接抛错拒绝(apps/backend/src/agents/tools/execute-sql.ts:16):

// 示意,非源码:execute-sql.ts 的开头
const writePermEnabled = context.agentSettings?.sql?.dangerouslyWritePermEnabled ?? false;
if (!writePermEnabled && !(await isReadOnlySqlQuery(sql_query))) {
throw new Error('Write SQL operations are disabled. Only SELECT queries are allowed. ...');
}

判定逻辑在 isReadOnlySqlQuery(apps/backend/src/utils/sql-filter.ts:6),它比"看开头是不是 SELECT"讲究得多:

  • 先剥注释、再按分号拆多条语句,要求每一条都只读(stripComments + splitStatements,sql-filter.ts:124/:131),拆分时还得尊重引号内的分号。
  • 每条语句过一个写操作黑名单正则 WRITE_STATEMENT_RE(sql-filter.ts:1):INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|TRUNCATE|REPLACE|MERGE|GRANT|REVOKE|RENAME|CALL|EXEC|… 命中即判写。
  • SELECT 直接放行;WITH(CTE)要特别处理:跳过所有 CTE 定义的括号、看主操作是不是 SELECT(getWithMainKeyword,sql-filter.ts:168)——防止 WITH x AS (...) DELETE ... 这种把写操作藏在 CTE 后面的绕过。

关键细节: 这是一道保守的白名单闸——拿不准就判为"不是只读"从而拒绝(空语句列表也返回 false,sql-filter.ts:9)。它是正则启发式,不是 SQL 解析器;设计上宁可错杀,不放过写操作。

3.3 第 ② 步:TS 通过 HTTP 调 Python

TS 侧不自己连数据库——它把 SQL POST 给同机的 FastAPI 服务(apps/backend/src/agents/tools/execute-sql.ts:25)。请求体带上 SQL、项目目录、可选的 database_id、环境变量,以及(EE 版才有的)azure_access_token:

// 示意,非源码
const response = await fetch(`http://localhost:${env.FASTAPI_PORT}/execute_sql`, {
method: 'POST',
body: JSON.stringify({
sql: sql_query,
nao_project_folder: naoProjectFolder,
...(database_id && { database_id }),
...(context.azureAccessToken && { azure_access_token: context.azureAccessToken }),
}),
});

为什么要跨语言? 因为数据库连接、方言适配、pandas 全在 Python 的 nao-core CLI 里(那也是 nao sync 抽取元数据用的同一套代码)。TS 负责 agent 编排,Python 负责数据平面,两边用一个本地 HTTP 端点缝合。

3.4 第 ③–⑤ 步:Python 侧选库 + 按鉴权模式分流

FastAPI 的 execute_sql 处理函数(apps/backend/fastapi/main.py:223)是数据平面的入口:

  1. 载入配置:NaoConfig.try_load(project_path, raise_on_error=True, ...)(main.py:226),把项目里的 nao_config.yaml 读成配置对象。
  2. 选库(main.py:239-263):只配了 1 个库就直接用;配了多个则必须靠 database_id 精确匹配 db.name,匹配不到就把可用库名列出来报 400——这也是为什么工具 schema 里 database_id 在多库时是必填。
  3. auth_mode 分流(main.py:265):
# 示意,非源码
auth_mode_value = getattr(getattr(db_config, "auth_mode", None), "value", None)
if auth_mode_value == "azure_entra_id":
if not request.azure_access_token:
raise HTTPException(400, "azure_access_token is required ...")
df = db_config.execute_sql_with_token(request.sql, request.azure_access_token)
else:
df = db_config.execute_sql(request.sql)

精华设计: 当库配成 azure_entra_id(Azure Entra ID 联邦)时,运行时查询必须带最终用户自己的 access token,绝不用配置里那份 user/password——那份只给 nao sync 抽元数据用。这样仓库上的每查一次都保留发起人的身份,而不是所有人共用一个服务账号。Redshift 甚至在 execute_sql 里硬拦:一旦 auth_mode=azure_entra_id 还走无 token 的路径,直接 RuntimeError(cli/nao_core/config/databases/redshift.py:315)。

3.5 第 ⑥–⑦ 步:13 种方言适配器

拿到 db_config 后,真正连库执行的是方言适配器。所有适配器都是抽象基类 DatabaseConfig 的子类(cli/nao_core/config/databases/base.py:75),通过一个判别联合type 字段选出正确的类(cli/nao_core/config/databases/__init__.py:24 AnyDatabaseConfig,Discriminator("type"))。

注册在案的方言有 13 种(base.py:18 DatabaseType 枚举):

分类方言
云数仓snowflake bigquery redshift databricks fabric
通用 SQLpostgres mysql mssql duckdb
大数据 / 湖仓athena trino starrocks clickhouse

基类给了一个默认 execute_sql,把"连库 → 跑 SQL → 转 DataFrame"这套流程模板化了(base.py:164)。核心是走 ibis 的 raw_sql,再用一串 hasattr 兼容各驱动千奇百怪的游标(base.py:170):

# 示意,非源码:base.py execute_sql 的取数部分
cursor = conn.raw_sql(sql)
if hasattr(cursor, "fetchdf"): return cursor.fetchdf() # duckdb
if hasattr(cursor, "to_dataframe"): return cursor.to_dataframe() # bigquery 系
if hasattr(cursor, "to_pandas"): return cursor.to_pandas()
# ClickHouse: result_rows + column_names;其它:description + fetchall

子类各自只覆写自己独有的部分,这就是"适配器"的精髓——共性上提到基类,差异下沉到子类:

子类覆写点例子
connect() 怎么建连接DuckDB 用 ibis.duckdb.connect(...)(duckdb.py:36);:memory: 库才可写,文件库强制只读
execute_sql_with_token()只有需要联邦鉴权的库才实现,如 Redshift 用 JWT 走 redshift_connector(redshift.py:329)
auth_mode 枚举Redshift(RedshiftAuthMode,redshift.py:20)、Fabric(FabricAuthMode)各自定义
方言相关的类型转换DuckDB 把复杂类型 CAST(... AS VARCHAR)(duckdb.py:18)

3.6 第 ⑧–⑩ 步:回到 TS,落黑板 + 渲染

Python 把 DataFrame 转成 { data, row_count, columns, dialect } 回给 TS(main.py:283,df.to_dict(orient="records"))。TS 侧收尾做三件事(apps/backend/src/agents/tools/execute-sql.ts:44):

// 示意,非源码
const data = await response.json();
const id = `query_${crypto.randomUUID().slice(0, 8)}`;
context.queryResults.set(id, { columns: data.columns, data: data.data }); // ⑧ 落黑板
const appliedLimit = detectQueryRowLimit(sql_query); // ⑨ 算截断
return { _version: '1', ...data, id, ...(appliedLimit !== null && { applied_limit: appliedLimit }) };

⑧ 落黑板:结果按新生成的 query_id 存进 context.queryResults。这个 id 就是后续 display_chart / read_query_result / 沙箱引用同一份数据的钥匙。

⑨ 截断告警:detectQueryRowLimit(apps/backend/src/utils/sql-filter.ts:41)是另一段精巧启发式——它先把字符串字面量和括号内内容都用空格抹掉(maskQuotedStrings + extractTopLevelSql),只在最外层LIMIT / FETCH FIRST / TOP,故意忽略子查询和 CTE 里的 limit。目的是判断"这次结果是不是被 SQL 自己的 LIMIT 卡到了上限"。

⑩ 渲染给模型:ExecuteSqlOutput 组件(apps/backend/src/components/tool-outputs/execute-sql.tsx:9)负责把结果讲给模型听,这里藏着两个体贴的细节:

  • 行数上限 40:超过就截断,并附一句"还有 N 行,调 read_query_result 带 offset 看更多"(execute-sql.tsx:46)。
  • LIMIT 命中告警:当 row_count >= applied_limit 时,插入一段醒目警告,明确告诉模型"这个行数是 LIMIT 撞出来的,不是总数,别当成 exact count 汇报,要真总数请另跑 COUNT(*)"(execute-sql.tsx:18-42)。这是在主动防止模型把截断结果误读成全量统计

3.7 配套:read_query_result 翻页

execute_sql 只给模型看 40 行,但数据全缓存着。read_query_result 就是翻页器——不重跑 SQL,而是按 query_id 从缓存切片(apps/backend/src/agents/tools/read-query-result.ts:15)。

取数走 getQueryResult(apps/backend/src/services/query-result.service.ts:10),它有一层回退:先查本次运行的 queryResults 内存 Map;没有(比如是上一轮对话跑的查询)就去聊天消息历史里捞,捞到再缓存回内存。所以"翻看几轮前的某条查询结果"也能工作。


4. 其余工具:各司其职

4.1 display_chart:把查询结果变成图

图表工具不碰数据库,它只吃一个 query_id 和一份图表配置。execute(apps/backend/src/agents/tools/display-chart.ts:11)做的就两件事:一串入参校验,然后把配置推进黑板。

校验规则很具体(display-chart.ts:15-36):

规则原因
直角/极坐标图必须有 x_axis_keybar/line/area/scatter/radar 没有横轴键画不出来
pie 图只能有 1 个 series饼图就一维
stacked_bar / stacked_area 至少 2 个 series只有一层堆叠没意义,提示模型去 pivot 数据
series 不能为空没有数据列

支持的图型在共享 schema 里枚举(apps/shared/src/tools/display-chart.ts:3 ChartTypeEnum:bar/stacked_bar/line/area/stacked_area/pie/kpi_card/scatter/radar)。校验通过后 context.generatedArtifacts.charts.push(input)(display-chart.ts:40)——真正的绘制在前端,靠 apps/sharedchart-builder.tsx / chart-block.ts 把配置 + queryResults 渲染成组件。

4.2 story:版本化的可交互文档

story 是"报告 / canvas / artifact"工具——一份混排 Markdown 文本和图表的可交互文档。它有三个动作,共同点是每次修改都产生一个新版本(apps/backend/src/agents/tools/story.ts:39,storyQueries.createStoryVersion):

action语义关键实现
create新建一份 story先查重(同 chat+slug 已存在则拒绝),入库并挂到用户私有根目录
update局部搜索替换,出新版本code.indexOf(search) 精确定位后拼接替换(story.ts:85);搜不到就报错
replace整篇覆盖,出新版本直接用新 code 建新版本

update 用的是字符串精确 search-replace(story.ts:81-92):找到 search 首次出现的位置,把它换成 replace。这跟编码 agent 的"编辑应用"是同一种思路——比让模型重发整篇省 token,也更不容易改乱无关部分。搜索串找不到就直接失败并把现有版本原样带回(fail(..., existing)),保证工具结果永远反映最新版(含用户手改)。

文档里的图表/表格靠自定义标签内嵌(见工具 description,story.ts:15-17):

<chart query_id="..." chart_type="..." x_axis_key="..." series='[...]' title="..." />
<table query_id="..." title="..." />
<grid cols="2"> ...两个图并排... </grid>

这些标签由 apps/shared/src/chart-block.ts:20buildStoryChartBlock 生成(注意属性值都过 escapeDoubleQuotedStoryAttr 转义)。前端编辑器实时跑 validateStoryCode(apps/shared/src/story-validation.ts:34,在 story-code-view.tsx:47 调用),用正则逐个检查 <chart>/<table>/<grid>:标签是否自闭合、series JSON 是否合法、<grid> 是否有闭合标签(story-validation.ts:45 起),把作者错误当场标红。

4.3 execute_python vs execute_sandboxed_code:两种"跑代码"

两个都能跑 Python,但沙箱边界完全不同,用途也不同:

维度execute_pythonexecute_sandboxed_code
引擎@pydantic/monty——纯 TS 的 Python 解释器@boxlite-ai/boxlite——真 microVM 沙箱
联网❌ 无networkEnabled: true
装包box.installPackages(...)
资源硬上限:30s / 64MB / 递归 500(execute-python.ts:17)vm_size 选规格,5 分钟 TTL 复用
文件访问把项目文件读成内存 virtualFS,通过外部函数 read_file 暴露把项目目录、聊天图片、query_id 导出的 CSV 拷进 VM
门控标志isPythonAvailable(execute-python.ts:143)isSandboxAvailable(execute-sandboxed-code.ts:286)

execute_python(monty) 走的是"暂停/恢复"模型:Python 里调 read_file 这类外部函数时,解释器返回一个 MontySnapshot,TS 侧执行该函数、再 resume 回去(execute-python.ts:62 的 while 循环)。可用的外部函数在 apps/shared/src/tools/execute-python.ts:37 EXTERNAL_FUNCTIONS 里定义(如 read_file)。这套适合无副作用的纯计算

execute_sandboxed_code(boxlite) 是重装备:维护一个沙箱池 sandboxPool(execute-sandboxed-code.ts:29),按 sandbox_id 复用同一个 VM(带 5 分钟 TTL 续期),这样多次调用能延续状态。它把 data_files 里指定的 query_id 结果转成 CSV 拷进 VM(queryResultToCsv,execute-sandboxed-code.ts:60),Python 就能 pd.read_csv 分析真实查询结果。

踩过的坑(源码注释直说): boxlite v0.3.0 停止一个 box 会腐蚀运行时,导致后续所有 box 创建失败——所以 evictSandbox 故意不调 box.stop(),靠 GC 回收(execute-sandboxed-code.ts:38)。这是文档级的"别照直觉来"警告。

4.4 循环控制工具:clarificationsuggest_follow_ups

这两个工具几乎不干活,它们的价值在于"被调用"这个信号本身——用来控制循环节奏(循环怎么响应见 02-agent-loop.md)。

  • clarification(apps/backend/src/agents/tools/clarification.ts:4):execute 只返回 success,toModelOutput 硬编码一句"澄清问题已发给用户,停下等回复"。description 里明确约束:一次只问一个问题、可给 2–5 个互斥选项、绝不和 suggest_follow_ups 同时调
  • suggest_follow_ups(apps/backend/src/agents/tools/suggest-follow-ups.ts:4):同样是空执行,约定为"一轮里最后调用、只调一次",调用即意味着这一轮收尾。

它们是用工具调用来表达"我要暂停 / 我结束了"这种控制流意图——比让模型用自然语言表达更可被程序捕获。

4.5 上下文诊断工具:record-recommendationpropose-context-fix

这一对是给"上下文体检"类专门运行用的(不在默认工具集里,由专门流程注入)。它们靠闭包收集器工作,而不是往数据库直写:

  • createRecommendationCollector(apps/backend/src/agents/tools/record-recommendation.ts:43)返回 record / resolve 两个工具,分别把"发现的问题"和"已修复的指纹"推进闭包里的数组,供流程结束后统一处理。
  • createContextFixCollector(apps/backend/src/agents/tools/propose-context-fix.ts:75)返回 edit_file / propose_manual_fixedit_file(:94)把对人写上下文文件的 search-replace 累积成完整的前后内容(供 UI 出 diff、PR builder 逐字写),并拒绝编辑自动生成的文件(databases/** 会被 nao sync 覆盖)——这类只能走 propose_manual_fix(:128),产出给用户自己的编码 LLM 用的现成 prompt。

4.6 web_search:直接借用模型厂商的联网能力

nao 不自己实现搜索引擎,而是转接各家模型自带的检索工具(apps/backend/src/agents/tools/web-search.ts:22 createWebSearchTools):

providerweb_searchweb_fetch
OpenAItools.webSearch({ searchContextSize: 'medium' })
Anthropictools.webSearch_20250305({ maxUses: 5 })tools.webFetch_20250910({ maxUses: 3 })
Googletools.googleSearch({})

换 provider 时,能不能联网、怎么联网就跟着变——WEB_SEARCH_PROVIDERS(web-search.ts:20)标记了哪些 provider 支持。

4.7 MCP 工具:把第三方工具接进来

除了内置工具,nao 还能挂载外部 MCP(Model Context Protocol)server 暴露的工具。mcpService.getMcpTools(apps/backend/src/services/mcp.ts:82)按选中的 server 名把它们收集进来,统一做两件适配:

  • 命名空间前缀:prefixToolName(serverName, toolName)servername__toolname(apps/backend/src/utils/tools.ts:315,services/mcp.ts:216),避免不同 server 的同名工具撞车。
  • schema 消毒:sanitizeTools(utils/tools.ts:273)修正 MCP schema 常见毛病(数组缺 itemsrequired: null 等),好让它们能被 AI SDK 接受。

反方向,nao 也能把自己的 agent 工具反过来暴露成 MCP 工具(apps/backend/src/mcp/tools/register-mcp-tool.ts:34 registerMcpTool),让别的系统调用 nao。


5. 巧妙之处(可带走的技术)

  • 只读闸是"每条语句都得干净"的白名单,而非"看开头"。 剥注释 → 按引号感知拆分号 → 每条过写操作黑名单 → CTE 追主操作,专门堵 WITH ... DELETE 这类绕过(apps/backend/src/utils/sql-filter.ts:6)。宁可错杀。
  • 截断告警是主动喂给模型的"别误读"提示。 detectQueryRowLimit 抹掉子查询只看最外层 LIMIT,再由 ExecuteSqlOutput 在命中上限时插入"这不是总数"警告(execute-sql.tsx:18-42)——把一个常见的模型幻觉在渲染层就掐掉。
  • 数据只查一次,query_id 全程流通。 结果落 queryResults 黑板后,画图、翻页、沙箱分析全走 id 引用,零重复查询(types/tools.ts:34);跨轮还能从消息历史回捞(query-result.service.ts:10)。
  • 方言适配用"基类模板 + 判别联合"。 共性(连库→raw_sql→DataFrame)在 base.py:164,差异(connect、token 鉴权、类型转换)下沉子类,新增一种库只写一个子类 + 进 AnyDatabaseConfig(__init__.py:24)。
  • 联邦鉴权保留最终用户身份。 azure_entra_id 模式运行时强制用用户 token,配置里的密码只给 sync 用,Redshift 甚至在代码里硬拦无 token 的执行(redshift.py:315)。
  • 控制流用工具调用来表达。 clarification / suggest_follow_ups 几乎空实现,靠"被调用"这一事实驱动循环暂停/收尾——比解析自然语言意图可靠。
  • 诚实的坑注释。 boxlite 不 stop() 的那段注释(execute-sandboxed-code.ts:38)把上游 bug 直接写进代码,避免后人"好心"加回 stop。

6. 边界与局限

  • 只读闸是正则,不是解析器。 它靠关键字黑名单和括号平衡的启发式(sql-filter.ts),理论上存在被畸形 SQL 绕过的可能;它的立场是保守拒绝,不是形式化证明。真要写库得管理员显式开 dangerouslyWritePermEnabled
  • execute_python 能力受限且看平台。 monty 无联网、无第三方包、有硬资源上限;@pydantic/monty / @boxlite-ai/boxlite 原生绑定在某些平台(如 Linux ARM64)加载失败时,对应工具直接不注册(execute-python.ts:10execute-sandboxed-code.ts:12)。
  • execute_sql_with_token 不是每种库都实现。 基类没有默认实现;只有需要联邦鉴权的库(如 Redshift)覆写了它——对未实现的库启用 azure_entra_id 会失败。
  • azureAccessToken 在 OSS 版恒为 null。 联邦鉴权那条路径属于 EE 版 Microsoft 集成(types/tools.ts:27 注释直说)。
  • 截断检测是启发式。 detectQueryRowLimit 靠正则,复杂 SQL(奇特的 TOP/FETCH 变体)可能漏检或误检;它只是"告警",不改变实际返回。

7. 横向对比

同 shelf 的编码类 / computer-use / browser agent,都要解决"把模型的话精确落到真实目标"这个共同难题,只是目标不同:

目标落到代表对应到 nao
代码文件编码 agentstory 的 search-replace、edit_file 的唯一性校验(同一种精确编辑思路)
整台电脑 GUIcomputer-use
浏览器 / 网页browser agentsweb_search/web_fetch(但 nao 是转接厂商能力,不自己驱动浏览器)
数据库 / 表格data agentsexecute_sql + 方言适配器 + 只读闸(nao 的主战场)

nao 的取舍很鲜明:把数据平面做深(13 种方言、联邦鉴权、只读闸、截断告警),把执行平面要么下沉到沙箱、要么转接给模型厂商。相比通用编码 agent 更专、更重"数据正确性"。


8. 代码地图(导航索引)

主题文件路径符号名
工具通用封装apps/backend/src/utils/tools.tscreateTool
结果渲染成 Markdownapps/backend/src/components/tool-outputs/index.tsrenderToModelOutput
工具注册 + 门控apps/backend/src/agents/tools/index.tstools, getTools
共享运行上下文apps/backend/src/types/tools.tsToolContext, QueryResult, GeneratedArtifacts
execute_sql (TS 侧)apps/backend/src/agents/tools/execute-sql.tsexecuteQuery
只读校验 / 截断检测apps/backend/src/utils/sql-filter.tsisReadOnlySqlQuery, detectQueryRowLimit, WRITE_STATEMENT_RE, getWithMainKeyword
execute_sql (Python 入口)apps/backend/fastapi/main.pyexecute_sql
方言基类cli/nao_core/config/databases/base.pyDatabaseConfig, DatabaseType, execute_sql
方言判别联合cli/nao_core/config/databases/__init__.pyAnyDatabaseConfig, parse_database_config
方言子类样例cli/nao_core/config/databases/{duckdb,redshift,snowflake}.pyDuckDBConfig.connect, RedshiftConfig.execute_sql_with_token, RedshiftAuthMode
SQL 结果渲染apps/backend/src/components/tool-outputs/execute-sql.tsxExecuteSqlOutput
结果翻页apps/backend/src/agents/tools/read-query-result.ts · services/query-result.service.tsgetQueryResult
图表工具apps/backend/src/agents/tools/display-chart.ts · apps/shared/src/tools/display-chart.tsChartTypeEnum, SeriesConfigSchema
图表构建/内嵌apps/shared/src/chart-builder.tsx · apps/shared/src/chart-block.tsbuildStoryChartBlock
故事工具apps/backend/src/agents/tools/story.tsstory (create/update/replace)
故事校验apps/shared/src/story-validation.tsvalidateStoryCode, validateChartBlocks
Python 解释器apps/backend/src/agents/tools/execute-python.ts · apps/shared/src/tools/execute-python.tsisPythonAvailable, EXTERNAL_FUNCTIONS
真沙箱apps/backend/src/agents/tools/execute-sandboxed-code.tsisSandboxAvailable, getOrCreateSandbox, sandboxPool
循环控制工具apps/backend/src/agents/tools/{clarification,suggest-follow-ups}.tsclarification, suggestFollowUps
上下文诊断apps/backend/src/agents/tools/{record-recommendation,propose-context-fix}.tscreateRecommendationCollector, createContextFixCollector
联网检索apps/backend/src/agents/tools/web-search.tscreateWebSearchTools, WEB_SEARCH_PROVIDERS
MCP 集成apps/backend/src/services/mcp.ts · apps/backend/src/mcp/tools/register-mcp-tool.tsgetMcpTools, prefixToolName, registerMcpTool