数据截至 (上游 commit 7830cc746c11)
Agent 驱动的 CLI 与 skills 下发
30 秒导读: Wren 的核心野心不是「给人一个 SQL 工具」,而是「把自己做成一个给 AI agent 用的工具」。 这一章讲这条设计主线:一个 typer 写的
wren命令,把所有能力挂成子命令树;再在其上架两个专门的 「agent 接口」——(a)wren skills get把工作流指南按需、随版本下发给 agent;(b)wren ask把用户的自然语言问题包一层 prompt 交给 agent,自己从不执行。看完你会明白 Wren 为什么把「内容」 塞进 CLI 而不写死进 agent。
本章只讲 CLI 装配 与 agent 交互层。MDL 语义模型、引擎治理、记忆检索、GenBI 的内部实现分别属于 02 / 03 / 04 / 05,这里只讲它们怎么被挂上来、agent 怎么进入它们,不展开。
1. 这是什么(零基础也能懂)
一句话定义: wren 是一个命令行程序,但它的真正用户不是坐在终端前的人,而是替人干活的 AI agent。
先建立一个直觉。一般 CLI 是「人打命令 → 机器执行」。Wren 反过来设计:它假设一个 LLM agent
(Claude Code、Cursor、某个 MCP 客户端……)会去调 wren,所以它不仅提供「执行」类命令,还提供两类
专门喂给 agent 的东西:
- 工作流指南(skills)——"要连一个新数据库,按这几步走"这种 markdown 剧本,agent 读了才知道怎么编排多步任务。
- prompt 塑形(ask)——把用户一句"上个月营收多少"包装成一段结构化 prompt,让 agent 有章可循。
给谁用 / 解决什么问题: 设想你在用一个 AI 编码助手,对它说"帮我把 Stripe 的数据接进来做分析"。
agent 本身不知道 Wren 的具体步骤。于是它先跑 wren skills get dlt-connector,拿到一份分四阶段的操作
指南,再照着一步步调 wren 的其它子命令。Wren 把「怎么做」的知识,做成了 CLI 能吐出来的内容。
用起来什么样: 一个 agent 的最小交互序列,直观感受一下——
# 1) agent 先问 Wren:你有哪些工作流指南?
wren skills list
# 2) 用户想上手 → 取 onboarding 指南,照着走
wren skills get onboarding
# 3) 用户问了个数据问题 → 把它包成一段 agent 能执行的 prompt
wren ask "上个月每个地区的营收是多少" --direct
# 4) agent 照包好的流程,最终执行一条走语义层的查询
wren --sql "SELECT region, SUM(amount) FROM orders GROUP BY region"
一句话直觉/类比: 把 wren 想成一家餐厅的后厨对讲系统。人类顾客(用户)提需求;服务员(agent)
不用背整本菜谱,而是随时对着对讲机问后厨"这道菜怎么做"(skills get),后厨把当前版本的做法念给他听。
菜谱印在后厨墙上(随 wheel 走),不是抄在服务员口袋里(不写死进 agent)——所以厨房换了配方,服务员立刻同步。
本节不出现底层代码。目标:明白「这是一个把自己做成 agent 工具的 CLI」。
2. 顶层全景(它大概怎么转)
Wren 的 CLI 是一个 typer app —— app = typer.Typer(name="wren")
(core/wren/src/wren/cli.py:14),下面挂了两类东西:
- 一个默认命令 + 直接执行类子命令(query / dry-run / dry-plan)——真正打数据库。
- 一堆子命令组(sub-typer)——每组是一个独立文件里的
typer.Typer(),用app.add_typer(...)挂上来。
怎么读下面这张图: 从上往下是「一个根 app 装配一切」;右侧标了每个部件所在文件。带 ★ 的两支是本章重点的 agent 接口,其余子命令组只讲「怎么挂」,内部实现留给后续章节。
wren (typer app) cli.py:14 app = Typer(name="wren")
│
┌────────────────────────────┼───────────────────────────────────────┐
│ 默认命令 & 直接执行 │ 子命令组 (add_typer / command 挂载) │
│ cli.py:355 main() │ cli.py:594-627 │
│ --sql 即查询 │ │
│ query / dry-run / dry-plan │ │
└────────────────────────────┘ │
│ │
┌───────────┬───────────┼───────────┬───────────┬────────────┐ │
▼ ▼ ▼ ▼ ▼ ▼ │
★ skills ★ ask context docs genbi memory
skills_cli ask_cli context_cli docs_cli genbi/cli memory/cli
下发指南 塑形 prompt MDL 项目 连接字段文档 仪表盘生成 记忆检索
→本章 §4 →本章 §5 →ch.02 →(文档生成) →ch.05 →ch.04
(还有 cube / utils / profile / serve 四组,同样 add_typer 挂上)
部件一句话职责:
| 子命令组 | 干什么 | 挂载点 | 内部实现在哪 |
|---|---|---|---|
(默认) / query / dry-run / dry-plan | 走 MDL 语义层执行/校验 SQL | cli.py:355 main / cli.py:428 query | ch.03 |
skills ★ | 把 bundled 工作流指南下发给 agent | cli.py:608 add_typer(skills_app) | 本章 §4 |
ask ★ | 把用户 prompt 包成 agent 用的 prompt | cli.py:603 command("ask") | 本章 §5 |
context | YAML MDL 项目生命周期(init/build/validate/show) | cli.py:605 add_typer(context_app) | ch.02 |
docs | 生成各数据源的连接字段文档 | cli.py:596 add_typer(docs_app) | 本章简述 |
genbi | 从语义层生成并部署仪表盘 app | cli.py:622 add_typer(genbi_app) | ch.05 |
memory | schema 上下文 + 召回过往查询 | cli.py:617 add_typer(memory_app) | ch.04 |
cube / utils / profile | 度量、类型工具、连接 profile | cli.py:599-623 add_typer(...) | (相关章节) |
serve | 把 wren 能力以 MCP server 等形式对外提供 | cli.py:627 add_typer(serve_app) | (本章只列挂载) |
主线走一遍(高层): agent 拿到用户请求 → 若是探索类,先 wren skills list / skills get <name>
拿指南(§4)→ 若是数据问题,可选 wren ask 把问题塑形(§5)→ 照指南编排:context show 看模型、
memory recall 找相似历史、最后 wren query 执行 → 用自然语言回答。
3. CLI 是怎么装配起来的(装配,不展开实现)
这节讲一个根 typer app 如何把散在各文件里的子命令组合起来。这是理解「agent 接口挂在哪」的地基。
3.1 根 app 与两条挂载路径
cli.py 顶部先建根 app,再在文件末尾统一 import 各子 app 并挂载
(core/wren/src/wren/cli.py:594-627)。typer 有两种挂法,Wren 两种都用:
app.add_typer(sub_app)——把一个命令组整体挂上(wren context …、wren skills …)。 绝大多数子命令用这种。app.command(name="ask")(fn)——把单个函数挂成一个平级命令。ask特殊,它只有一个动作, 于是从ask_cliimport 出ask函数、直接注册成wren ask(cli.py:598import、cli.py:603注册), 不套一层ask命令组。
一段示意,帮你看清「装配」长什么样(示意,非源码):
# 示意,非源码:根 app 把各文件里的 sub-typer 收拢到一起
app = typer.Typer(name="wren") # 根
from wren.skills_cli import skills_app # 每个子命令组是独立文件里的 Typer()
app.add_typer(skills_app) # 挂成 `wren skills …`
from wren.ask_cli import ask as _ask # ask 只有一个动作
app.command(name="ask")(_ask) # 直接挂成平级的 `wren ask`
真实装配见 cli.py:594-627:docs_app、context_app、cube_app、utils_app、skills_app、
memory_app、genbi_app、profile_app、serve_app(对外提供 MCP 等服务,见 serve_cli.py)
逐个 add_typer,ask 用 command(name="ask") 注册。
3.2 默认命令:不带子命令时就是 query
wren --sql "…" 不写任何子命令也能查询。靠的是回调上的
@app.callback(invoke_without_command=True)(core/wren/src/wren/cli.py:355 main):
- 若
ctx.invoked_subcommand非空 → 交给对应子命 令,main直接 return(cli.py:410-411)。 - 否则,有
--sql就执行查询,没有就打印 help(cli.py:412-422)。
这让「查询」成为默认动作,同时保留 wren query 显式子命令(cli.py:428 query)给脚本用。
3.3 两个跨命令共享的约定:_require_mdl 与 _DEFAULT_CONN
装配层还统一了「上下文从哪来」——agent 少填参数的关键。
_require_mdl(mdl)(core/wren/src/wren/cli.py:23):没显式给--mdl时, 自动发现项目根、取<project>/target/mdl.json;缺了就提示run \wren context build` first。 于是 agent 在项目目录里直接wren --sql …` 即可,不必每次指路。MDL 是什么、怎么 build → 见 ch.02。_DEFAULT_CONN = _WREN_HOME / "connection_info.json"(cli.py:16-17): 连接信息默认落在~/.wren/。没给--connection-*时从这里(或 profile)自动读 (_load_conncli.py:78)。
为什么对 agent 重要: 这两个默认值把「MDL 路径」「连接信息」从每条命令的必填参数里拿掉了。 agent 只要在对的目录、事先建好 profile,就能直接执行——少一个要它猜的参数,就少一处出错。 连接/profile 的解析细节属于引擎章,这里只点明「装配层替 agent 兜了底」。
4. Agent 接口(a):skills 按需下发
这是本章第一个核心机制。要解决的小问题:agent 怎么知道一个多步工作流该怎么走?
4.1 思路:内容随版本走,不写死进 agent
朴素做法是把工作流写进 agent 的 system prompt,或做成一个静态 skill 文件缓存在 agent 侧。Wren 拒绝这么做,
理由一句话:agent 侧的缓存会和 CLI 版本漂移。今天 wren-engine 升级、命令变了,agent 口袋里那份旧指南
就错了。
Wren 的解法:把指南塞进 wheel,用 CLI 现取现给。指南是 package data,随 pip install wrenai 的版本
一起分发;agent 每次 wren skills get <name> 都拿到和当前 CLI 完全匹配的那一份。skill 里也反复强调这点——
"served by the wren CLI, so it always matches your installed wren-engine version"(见 usage/SKILL.md 抬头)。
4.2 内容长在哪:skills_content/ 六份指南
指南以纯 markdown 存在 wheel 内 core/wren/src/wren/skills_content/<name>/SKILL.md。当前六份,各司一段工作流:
| skill 名 | 覆盖的工作流 | 角色一句话 |
|---|---|---|
onboarding | 环境检查 → 项目脚手架 → .env 连接 → 首查 | 端到端把用户领进门,"one step per turn、绝不在 chat 里要凭据" |
usage | 日常问数:取 schema → 召回历史 → 写 SQL → 执行 → 学习 | 数据问题的主流程指南(带 memory.md / wren-sql.md 参考) |
generate-mdl | 探库 → 类型归一 → 生成 MDL YAML | 从既有数据库反建语义项目 |
dlt-connector | 用 dlt 拉 SaaS 数据入 DuckDB → 反建 Wren 项目 → 验证 | 接 HubSpot/Stripe/… 等 SaaS(带 introspect_dlt.py 脚本) |
enrich-context | 读 raw/ 手册/术语表 → 补 DB schema 装不下的业务语义(单位、枚举、cube) | 填「业务上下文」缺口,grill/auto-pilot 两模式 |
genbi | genbi build 出指令 → agent 写 app → register/verify/deploy | 把语义层做成可分享仪表盘(→ ch.05) |
关键设计:指南只「指路」,不「抄录」。例如 onboarding/SKILL.md 明确说 "per-datasource setup notes,
complete troubleshooting playbook live in the docs, not here"——它的职责是强制 agent 侧规则
(一步一回合、绝不在 chat 里要凭据)并把 agent 派发到正确的 doc 或兄弟 skill,而不是把所有细节堆进自己。
这样一份 SKILL.md 保持精悍,细节各归其位。
4.3 真实实现:从 wheel 里读文件
下发逻辑集中在 core/wren/src/wren/skills_delivery.py,极简:
- 定位内容根:
_content_root()(skills_delivery.py:35)用importlib.resources.files("wren") / "skills_content"拿到 wheel 内目录的 traversable。 用importlib.resources而非硬编码文件路径,是因为装进 wheel/zip 后也能读——这正是"随包分发"的技术前提。 - 取一份指南:
get_skill(name, full=False)(skills_delivery.py:48)读<name>/SKILL.md返回;--full时把references/*.md按文件名排序拼在后面,各加一个分隔标题(skills_delivery.py:57-69)。 - 列全部:
list_skills()(skills_delivery.py:82)遍历目录,每个有SKILL.md的算一个, 用 frontmatter 的description首句做 summary(_summaryskills_delivery.py:103)。 - 取脚本:
get_script(name, script)(skills_delivery.py:72)从<name>/scripts/找同名脚本 (如dlt-connector的introspect_dlt)。 - 数据结构
SkillInfo(skills_delivery.py:27)只装name / summary / references / scripts四个字段, 是list输出的载体。
CLI 侧 skills_cli.py 只是薄薄一层 typer 包装:wren skills list(skills_cli.py:17 list_cmd)、
wren skills get <name> [--full] [--script …](skills_cli.py:36 get),把上面几个函数的结果
typer.echo 到 stdout,找不到就报 unknown skill(skills_cli.py:52-58)。
一段示意,把「取指南」的核心想法演出来(示意,非源码):
# 示意,非源码:skills get 的骨架
def get_skill(name, full=False):
root = resources.files("wren") / "skills_content" # wheel 内目录
skill = root / name
text = (skill / "SKILL.md").read_text() # 主指南
if not full:
return text # 默认只给主指南
refs = sorted((skill / "references").glob("*.md")) # --full 追加参考
return text + "".join(f"\n---\n{r.read_text()}" for r in refs)
4.4 外部发现桩:agent 一开始怎么知道有 wren skills
有个先有鸡还是先有蛋的问题:指南在 CLI 里,可 agent 一开始并不知道该去调 CLI。Wren 用一个约 50 行的
discovery stub 解决——放在仓库 skills/ 下,给 agent 平台去发现:
skills/wren/SKILL.md——一个标准 skill 文件,但正文只教一件事:"真正的指南在 CLI 里,去调wren skills list/wren skills get <name>/wren ask …"。它的 frontmatterdescription塞满触发词 (install wren、connect database、generate mdl、build a dashboard……),让 agent 平台能在合适时机命中它。skills/index.json——skill bundle 的清单(约 23 行),同样自我定位为 "Discovery stub … The actual workflow guides and prompt helpers live inside the wren CLI"。
stub 里最能说明设计意图的一句原话:
This is a discovery stub. The actual workflow guides and prompt helpers
live inside the `wren` CLI itself, so they always match the installed
wren-engine version (no skill cache, no version drift).
两层结构一句话: 外层 stub(装进 agent 平台,只负责"被发现 + 指路进 CLI",小而稳、极少改动)+ 内层 bundled 指南(装进 wheel,承载会随版本变的真实工作流)。易变的内容随版本走,稳定的入口才进 agent。
agent 平台发现 skills/wren/SKILL.md ── 50 行桩,教一句话:去调 `wren skills …`
│ (frontmatter 全是触发词,负责被命中)
▼
wren skills list ─► wren skills get <name> ─► 拿到随版本走的完整指南
│ skills_content/<name>/SKILL.md
▼
照指南编排:context show / memory recall / query …
5. Agent 接口(b):ask 提示塑形
第二个核心机制。要解决的小问题:用户丢来一句自然语言,怎么变成 agent 能稳定照做的 prompt?
5.1 思路:只塑形,不执行
wren ask 做的事非常克制:把用户那句话套进一个模板,渲染出一段 prompt,typer.echo 到 stdout,
到此为止。它自己从不连数据库、不跑查询——生成的 prompt 交给上游 agent 去消费执行。
文件抬头写得很直白:"It does not execute any query — it produces a prompt for an agent to consume"
(core/wren/src/wren/ask.py:4-6)。
这条边界很重要:ask 是纯函数式的 prompt 工厂,职责单一,好测、无副作用。
5.2 两种模式:guided 给弱模型,direct 给强模型
同一个问题,包法有两种(MODES = ("guided", "direct"),ask.py:19),对应两个模板文件
core/wren/src/wren/ask_templates/{guided,direct}.md.tmpl:
| 模式 | 给谁 | 包法 | 模板大小 |
|---|---|---|---|
guided | 较弱的 LLM | 前置一套严格任务流:分 A(数据问题)/B(探索项目)两类,每类列出编号步骤(context show → memory recall → 写 SQL → dry-plan → query),外加约束(用模型名、别编列名、别在 chat 里要凭据) | ~805 字节 |
direct | 较强的 LLM | 最小包裹:一句"你有 Wren CLI,跑 wren skills list / wren --help 自己发现能力"+ 用户问题 | ~150 字节 |
直觉:弱模型需要手把手的清单才不跑偏;强模型给太多步骤反而束手束脚,给它工具入口和目标即可,让它自己规划。
direct.md.tmpl 全文只有三行,把这份"