技能开发规范(修订版)
什么时候读:需要核对完整规范时。本文 = 原文 + 已确认的文档订正。 原文逐字保留在
spec-full.md;本文对照线上代码修正了几处确定是文档错的地方, 每处订正用【订正 2026-08|来源: 文件】标出,方便与原文对账。 2026-10 起原「待定」各项已按正式代码(routers + executor / pipeline / compat / actor_ctx)定案并入:workspace_dir由 executor 注入、结构类 owner/admin、入口签名按安装预检。订正处标【订正 2026-10|来源: 文件】,定案过程见pending-issues.md。面向技能作者(含 AI 代码生成)。本文档定义:技能的形态与目录结构、平台注入的 运行时上下文、配置与密钥的取用、文件与产物的收发约定、返回信封、数据表操作。 按本文档编写的技能可直接被平台加载运行,并兼容主流 Agent Skill(OpenClaw / Hermes)布局。
修订记录:2026-08 对照 datatable 技能实现与踩坑档案 #54–#72 补订,补订处标
★2026-08 补。 2026-10 对照backend/api/routers/*.py(skills / nocodb / roles_chat_* )逐条核对,订正处标【订正 2026-10】。
1. 技能形态
| skill_type | 说明 | 入口 | 是否暴露给模型 |
|---|---|---|---|
handler | 代码型(默认)。平台按 ExecType 执行代码 | 见 2.3 | 是(trigger.type=explicit) |
skill_md | 文档型。平台不执行代码,把 SKILL.md 交给隔离子代理按文档执行 | 无 | 是 |
knowledge | 纯知识型,只参与检索 | 无 | 否(不进工具清单) |
handler 支持的 ExecType 与取上下文的方式:
| ExecType | 触发条件 | 输入 | 上下文 | 能否拿 db |
|---|---|---|---|---|
| PYTHON(进程内) | exec.config 是 .py,模块内有入口函数 | 函数参数 | 函数参数 | 仅 async def |
| PYTHON(CLI) | 脚本同时含 argparse 与 __main__,或运行中抛 SystemExit | argv | SKILL_CONTEXT 环境变量 | 否 |
| SHELL / INLINE_SHELL / NODE / RUBY / GO | 按 exec.primary.type | argv + SKILL_INPUT | SKILL_CONTEXT | 否 |
| HTTP | exec.config 指向 TOML | 请求体 | 不适用 | 否 |
| BROWSER / MCP | — | — | — | 未实现 |
db 是活的 SQLAlchemy AsyncSession,不参与序列化:子进程的 SKILL_CONTEXT 里没有它,
同步入口被平台显式置为 None。需要数据库的技能必须写成进程内 async Python。
2. 技能目录与文件
2.1 目录结构
<skill_name>/
├── SKILL.md # 必需。frontmatter + 说明文档
├── handler.py # handler 型必需
├── schema.json # 可选。完整 JSON Schema,优先级高于代码内常量
├── references/ # 可选。长文档拆分到这里
│ └── *.md
└── *.toml # 可选。HTTP 型的 exec 配置
平台按以下顺序定位 SKILL.md,取第一个存在的:
<skill_dir>/<frontmatter.skill_md 或 SKILL.md>
<skill_dir>/<origin_path>/{SKILL.md, skill.md}
<skill_dir>/optional-skills/<name>
<skill_dir>/docs/<name>
<skill_dir>/README.md
2.2 SKILL.md frontmatter
---
name: fetch_price
description: 一句话说清「什么时候该调它」,模型靠这句选工具
source: builtin # builtin / shared / market
category: data # 会以 [category] 前缀出现在工具描述里
trigger-type: explicit # explicit 才会进模型的工具清单
cost-tier: low
tags: finance, price
async: false # true = 长耗时技能,见 §8
force_raw_prompt: false # true = 用用户原文覆盖 prompt 字段,见 §3.4
metadata:
openclaw:
primaryEnv: XXX_API_KEY # 主凭证变量名
requires:
env: [XXX_API_KEY] # 也支持 [{name: X, required: true}]
bins: [curl] # 需要的命令行工具
---
requires 在技能执行之前校验:缺 env 直接返回 needs_config 提示,不进入执行;
声明了 exec_mode: shell 或 runtime 的技能不校验 bins(在容器里跑,宿主机有无无关)。
【订正 2026-10|来源: routers/skills.py _load_skill_md_meta / _build_market_row / _env_schema_from_dir】
- frontmatter 用
yaml.safe_load解析,失败退回逐行k: v解析(嵌套块、列表全丢)。值里含「英文冒号+空格」须加引号。 - 平台从顶层读
category/cost-tier/version/tags/skill_type/env,不读metadata下的同名键。 category只认office finance ecommerce marketing promotion trading data crm channel media integration compliance general; 上例的data合法,workflow/dev之类会被按关键词猜或落general。cost-tier∈zero low medium high。- 控制台参数表单读顶层
env(或environment_variables/requires-env/secrets/inputs);metadata.openclaw.requires只管执行前校验。两处各管一段,需要配置的技能两处都写;零配置写env: []。 name须匹配^[a-z0-9][a-z0-9_\-]*$且与目录名一致(安装预检)。
文档长度:skill_md 型的 SKILL.md 超过 8000 字符会被截头部注入,尾部丢失。
主文档保持精简,细节放 references/,在正文写明「执行 X 前先读 references/xxx.md」。
read_skill_file 单次返回上限 40000 字符。
2.2.1 与主流 Agent Skill 市场的兼容 【补 2026-10】
主流校验器顶层只认 name description license compatibility allowed-tools metadata;本平台从顶层读
category cost-tier async env ui skill_type 等(不读 metadata 下同名键)。默认写顶层;要同时发主流市场,
须先由平台在 _build_market_row / _env_schema_from_dir / _ui_entry_from_meta 补「顶层取不到再读 metadata.<键>」回退,
未落地前挪进 metadata 等于没写。
2.3 handler.py 入口
async def execute(input_data: dict, runtime_context: dict) -> dict:
...
| 项 | 规则 |
|---|---|
| 函数名 | execute / run / handler / main / handle,按此顺序取第一个存在的。【订正 2026-10|来源: routers/skills.py _preflight_check、roles_chat_skill_tools.py _do_create / _do_edit】 这是执行器的兼容规则;安装预检只认 execute,且要求参数名为 input_data / runtime_context、为 async def,create_skill / edit_skill 把这些问题当拦截。新技能一律写 async def execute(input_data, runtime_context) |
| 传参 | 按必填位置参数个数绑定:0 → f();1 → f(input);2 及以上 → f(input, ctx) |
| 第二参数 | 不得设默认值(约定保持不变)。【订正 2026-08|来源: compat.bind_entry_args】 现版 bind_entry_args 用 sig.bind 先试 (input, ctx),带默认值也会绑上——即「否则 ctx 丢失」在当前代码下已不必然。但两条路径都安全时保守更省心,且 datatable 自身注释仍要求别给,故约定维持「不得给」。检查器对此降为 WARN 而非 ERROR |
| 返回 | dict;非 dict 被包成 {"result": ...} |
| 异步 | 需要直连库(db_factory)/ call_skill 时必须 async def。见 §7A |
| 无入口函数 | 平台回退去找同目录/上级的 SKILL.md,把技能静默降级为文档型(症状不是报错) |
子进程型取上下文:
input = json.loads(os.environ.get("SKILL_INPUT") or "{}")
rc = json.loads(os.environ.get("SKILL_CONTEXT") or "{}")
stdout 输出约定(子进程型),平台按顺序尝试解析:整个 stdout 为 JSON → 最后一行为
JSON → 媒体路径行 → 兜底包成 {"output": "<原始 stdout>"}。
日志一律走 stderr;退出码非 0 判为技术失败。子进程默认超时 60 秒。
3. 输入
3.1 input_schema
标准 JSON Schema,放 schema.json 或代码内常量(schema.json 优先)。
{
"type": "object",
"properties": {
"symbol": {"type": "string", "description": "标的代码,如 BTC / AAPL"},
"image": {"type": "string", "x-input-type": "image"},
"limit": {"type": "integer", "default": 100}
},
"required": ["symbol"]
}
约束:
properties的值必须是对象,不能写成裸字符串("symbol": "string"会被平台修正, 但不同 provider 的元校验可能直接拒绝整批工具)。additionalProperties/items只能是布尔或对象。description是模型填参的唯一依据,写清取值范围和格式,不要只写字段名。- 必填校验:缺字段和传空值(
""/[]/{}/ 纯空白)都判为未提供,0和false是合法值。校验不通过时平台把错误回给模型自纠,不会静默补默认值。
3.2 子命令
技能可声明 cli_commands: {子命令: schema}。数量在 1~45 之间时,平台把技能扇出成
多个工具名 <skill>__<sub>;执行前还原为 skill + input["_subcommand"],
CLI 型技能的 _subcommand 会被放到 argv[0](argparse 的硬要求),
参数校验也改用该子命令的 schema。
3.3 文件参数
路径归一:以下字段名的值会被平台解析成绝对路径 —
input output file path video audio image src dst
inputs files videos images clips sources paths bgm input_file file_path
规则:绝对路径原样保留;相对路径只取 basename,挂到会话工作区
(模型拼的目录前缀一律丢弃,避免双层路径)。output / dst / output_path / out
视为待生成文件,无条件落到工作区;其余字段的文件不存在时保留原值(报错更清楚)。
附件引用:模型看到的素材清单里每项带引用 ID(img_1 / file_1)。平台在执行前:
- 字段值或数组项精确等于引用 ID → 替换成真实
local_path; code/command/script/cmd/instruction/shell/bash这类字段内的 子串引用也会替换,路径统一转 posix 正斜杠,含空格时自动补引号;- 数组字段自动去重。
技能内直接使用拿到的路径即可,不要再拼目录。
3.4 prompt 字段
生成类技能(文生图/文生视频等)可声明 force_raw_prompt: true,平台会用用户本轮原文
覆盖 schema 中第一个必填字段。委托场景(上游产出而非用户直给)不覆盖。
3.5 动作型技能的入参归一 ★2026-08 补
以 action 分发多种操作的技能(datatable、各分析师角色技能等),模型填参会系统性地漂移:
把中文动作名翻成英文、把参数键翻成英文、把「确认」翻成 confirmed: true、把用户整句
「确认初始化」塞进 action。文档写得再清楚也挡不住,归一必须在技能侧做,文档侧只是补充。
- action 别名归一:大小写、下划线/连字符、常见英文同义词、中文夹字,统一映射到
dispatch 里真实存在的规范 action(别名表的值集合必须 ⊆ 规范 action 集合,加自检断言)。
归一发生时在返回里回写
action_note: "initialize → 初始化",让模型下次直接用对。 - 参数键别名归一:
record/row/values → data、filter → filters、order_by → sort、confirm/approve/确定 → 确认等,在 dispatch 之前统一归口;同样回显归一结果。 - 无 action 一律拒绝:返回
need_param=action+ 支持的 action 清单。不得按入参形状 默认成某个写动作——更新即使幂等也是写动作,定时任务必须显式传 action。 - 确认只能来自用户原话:写动作采用「试算/预演 → 确认 → 执行」三段时,
确认=true只接受两种来源——模型按用户指示显式传入,或action以「确认」开头(去前缀归一到 写动作并补确认=true)。技能不得自行补确认,也不得把「预演成功」视为确认。 - 预演返回必须回显收到的参数,并写明「缺
确认=true所以只预演」。同一动作同样参数 连续出现 3 次一模一样的预演卡片,说明参数没翻对,不是技能坏了;回显就是让模型不用猜。 - 返回给用户看的「下一步」是给用户的,不是给模型的;技能在返回里不要用指令式措辞
(「下一步:说拉数据」会被模型当成自己的指令直接执行),改为「可继续的操作:…」并
明确标注
for_user。
4. runtime_context(全局注入)
4.1 键的分组
runtime_context
├── 固定上下文段 —— 谁、从哪来、什么时候、说了什么(恒存在)
├── 平台注入段 —— 组织/角色/会话/权限/会话对象
└── 配置段 —— skill_configs / skill_args 及其扁平副本
4.2 固定上下文段
契约:下列键恒存在。无值时给对应类型的空值,不缺键。技能判空即可,
不需要 .get(k, default)。
| key | 类型 | 空值 | 说明 |
|---|---|---|---|
raw_input | str | "" | 用户本轮原文 |
attachments | list[dict] | [] | 见 4.5 |
actor_name | str | "" | 姓名;无档案时回落为渠道昵称,不得用于实名核验 |
actor_emp_no | str | "" | 工号 |
actor_department | str | "" | 部门(裸文本,非组织树节点) |
actor_department_path | str | "" | 部门全路径,形如 /总部/销售中心/销售一部/。格式与匹配方式见 4.5 |
actor_position | str | "" | 职位 |
actor_title | str | "" | 职级。审批阈值判断用此字段,不用 position |
actor_phone | str | "" | 电话 |
actor_email | str | "" | 邮箱 |
actor_tags | list[str] | [] | 员工标签 |
actor_key | str | "" | 发起人定位键(contact_key) |
channel | str | "" | web / web_admin / 第三方渠道名 |
triggered_at | float | 0.0 | 触发时间,秒级时间戳,不是 ISO 串 |
triggered_at_str | str | "" | YYYY-MM-DD HH:MM,UTC |
actor_id | str | "" | 内部主键 |
actor_is_admin | bool | False | 管理员代填标记,见 4.4 |
★2026-08 补工号键只有actor_emp_no(actor_ctx.FIXED_CTX_FIELDS是唯一事实源, 没有emp_no),且call_skill只透传actor_*。datatable handler 曾读emp_no, 经 call_skill 调用时工号必为空,row_scope="own"的表一律 fail closed——已改为 读actor_emp_no并回退emp_no。新技能取工号一律用actor_emp_no,不得自造别名。
4.3 平台注入段
| key | 类型 | 说明 |
|---|---|---|
org_id | str | 组织 ID,取自 Agent.org_id 原值,可能为空。数据表操作遇空应拒绝执行;若技能自行按 org 查档案/组织表,须按 4.6 补伪 org 兜底 |
role_id | str | 当前角色 |
session_id | str | 当前会话 |
user_id | str | 当前用户,可能为空 |
team_id | str | 所属团队,可能为空 |
team_role | str | owner / admin / member。结构性操作的鉴权依据。【订正 2026-10|来源: roles_chat_helpers / routers/skills.py】 对话线按 Team.owner_id == user_id → owner,否则 TeamMember.role,取不到为空;页面面板 / 试运行线(_build_skill_runtime_ctx)不注入,按缺失处理 |
is_admin | bool | 会话是否管理员会话 |
chat_mode | bool | 对话线标记。为 True 时启用删除确认等交互式保护。★2026-08 补 现状:pipeline.build_runtime_ctx 对对话线/定时任务/链一律写死 True,删除确认闸三条线同样生效,见 §7.7 |
allow_sql | bool | ★2026-08 补 SQL 通道开关,缺省 True。【订正 2026-08|来源: pipeline / datatable 注释自述】 这是有意保留、当前未启用的开关:build_runtime_ctx 不注入它,datatable 用 ctx.get("allow_sql", True) 兜底,注释明写「留着给将来某角色只许走结构化 query 用,不是现状」。所以技能读它一律缺省 True;将来某条线注入 False 会随 call_skill 下传(§9.1)。不是 bug,不要改代码去注入它 |
workspace_dir | str | 会话工作区绝对路径。【订正 2026-10|来源: executor.execute】 不在 build_runtime_ctx 而在 executor.execute 里 setdefault 注入(= $AGENT_WORKSPACE_DIR/<role_id>/<session_id 或 shared>),所有执行线都有;仅 role_id 为空时缺失,读时用 .get 兜底 |
db | AsyncSession / None | 遗留预注入连接,新技能不要用(生命周期归上游、横跨整个父调用含慢 I/O)。仅对未迁移技能、进程内 async 入口注入;同步入口为 None。直连库改用 db_factory 现开现还,见 §7A |
db_factory | callable | 直连库的唯一入口。 恒注入。库访问收进单一 _db(),按操作 async with db_factory() as s 现开一条短会话、用完即还,不横跨慢 I/O 持有。见 §7A |
provider | object / None | 【订正 2026-08|来源: executor.execute / _role_provider】 当前角色绑定模型的 provider 对象,进程内技能调大模型用;取不到为 None。子进程拿不到(不可序列化,被 _build_subprocess_env 剔除),子进程走 MYINC_LLM_* / OPENAI_* / ANTHROPIC_* 环境变量。详见 §10 |
abort_signal | asyncio.Event / None | 用户点了停止时被 set,长循环应轮询它 |
channel_registry | object | 渠道注册表(仅对话线;异步线为空) |
request | object | FastAPI Request(仅对话线;异步线为空) |
skill_config.sandbox | dict | 沙箱配置 |
以 _ 开头的键(如 _meta、_llm_conn)为平台内部字段,不注入子进程,技能不得依赖。_llm_conn 是带 key 的中间 dict,仅供 executor 内部还原成 LLM 环境变量。
4.4 管理员代填
actor_is_admin=True 时,平台在注入前清空下列身份键,防止按管理员本人的部门/职级
判断申请人:
清空:actor_title, actor_department, actor_department_path, actor_position,
actor_tags, actor_key, actor_id, actor_emp_no
保留:actor_name, actor_phone, actor_email(承担审计的「谁填的」)
凡使用 actor_title / actor_department 做判断的分支,必须显式处理空值,
不得把空值当最低档位放行:
if ctx.get("actor_is_admin") or not ctx.get("actor_title"):
return {"success": False, "need_param": "actor_title",
"user_message": "取不到申请人职级,请补充申请人工号后重试。"}
4.5 actor_department_path 格式
/根部门/…/上级部门/本级部门/
例:/总部/销售中心/销售一部/
| 项 | 定义 |
|---|---|
| 分隔符 | /,首尾也带 |
| 顺序 | 顶层在前,本级在末 |
| 是否含本级 | 含。末段即 actor_department |
| 是否含组织根 | 不含组织名。首段是组织树的顶层部门 |
| 空值 | "":无部门、无 org_id、或解析失败 |
| 部门不在组织树 | 退化成单段 /<部门名>/。与「本就只有一级部门」不可区分 |
| 停用部门 | 保留在路径中(停部门不动员工档案,过滤会让链断在中间) |
| 深度上限 | 20 层,超出从顶端截断(防脏数据成环) |
| 代填时 | 被清空(见 4.4),部门类判断一律走不到 |
匹配必须带首尾斜杠:
if "/销售中心/" in ctx["actor_department_path"]: # 正确
if "销售中心" in ctx["actor_department_path"]: # 错误:会误命中「销售中心部」
判「是否本级」用末段,不要用 in:
segs = [x for x in ctx["actor_department_path"].split("/") if x]
current = segs[-1] if segs else ""
待核对:工作流线的该字段由 bridge 独立注入,与本节实现是否同源尚未确认。 若两侧不一致,同一条规则在对话线和工作流线的匹配结果会不同。
4.6 org_id 与伪组织兜底
runtime_context["org_id"] 是 Agent.org_id 的原值,未做兜底,可能是空串。
但平台的身份/组织解析在其为空时会兜底成伪 org r_<role_id>,
ChatUser / OrgDepartment 的行就是按那个值存的。
因此技能若自行按 org 查档案表或组织表,必须用同一兜底,否则一行都查不到且不报错:
org_id = (ctx.get("org_id") or "").strip() or f"r_{ctx.get('role_id', '')}"
数据表操作(§7)不适用本兜底:org_id 为空即配置异常,应直接拒绝执行,
不得用伪 org 拼出一个新 schema。
4.7 attachments 项结构
{"type": "image", # 新技能只读此字段
"att_type": "image", # 过渡期别名,与 type 同值,两版本后移除
"filename": "报销单.pdf",
"local_path": "/abs/path/...", # 可能为空
"url": "https://..."} # 可能为空
清单包含本轮上传、历史上传、以及此前技能生成的产物。
【核对 2026-10|来源: actor_ctx.build_ctx / norm_attachments】 上表即实际形状:对话层原始清单经 norm_attachments 归一成这五个键(原始的 source / turn 等被丢弃)。
4.8 身份不可信输入
工号、角色、组织、团队角色一律取自 runtime_context。input_data 中的同名字段视为
模型可伪造,必须忽略。
5. 配置与密钥
5.1 注入结构
平台按角色隔离存储技能参数,执行前注入:
ctx["skill_configs"] = {"<skill_name>": {"api_token": "...", ...}, "api_token": "...", ...}
ctx["skill_args"] = {"api_token": "...", ...}
ctx["api_token"] = "..." # 顶层扁平副本
同一份值会出现在四处(技能名嵌套、skill_configs 扁平、skill_args 扁平、顶层),
CLI 风格的 --api-token 归一为 api_token。技能按下列顺序取,取到即止:
def _cfg(ctx, key, skill_name):
for src in (ctx.get("skill_configs", {}).get(skill_name) or {},
ctx.get("skill_configs") or {},
ctx.get("skill_args") or {},
ctx):
v = src.get(key)
if v is not None and str(v).strip():
return str(v)
return os.getenv(key, "")
5.2 凭证
- 名字含
key/token/secret/password/credential/auth的参数 自动加密存储,且从暴露给模型的 schema 中摘除——模型不填,平台执行时注入。 技能不得要求模型传密钥。★2026-08 补匹配是子串不是后缀:技能内的大常量名(如PARAM_KEYS、AUTH_HEADERS) 也会被扫描命中并被摘除/加密。非凭证的常量名避开这些子串;自测脚本同样要按子串扫。 - 凭证只能由用户在对话中直接提供:平台校验该值必须出现在用户本轮原文中, 否则拒绝保存。技能不得自行生成或从历史拼凑凭证。
- 子进程/容器型技能的凭证走环境变量,文档中用
$VAR引用,不展开、不打印。
6. 输出
6.1 返回信封
{"success": True, "data": {...}} # 或按技能自身语义组织的字段
失败时:
| 字段 | 语义 |
|---|---|
success / ok | False = 失败。平台据此翻转「假成功」,缺省或 None 不算失败 |
user_message | 面向用户的说明,可直接转述 |
error | 面向模型的错误原因。失败必须给原因,为空会被平台补成无意义文案。【订正 2026-08|来源: pipeline.normalize_result】 平台取原因的顺序:ok=False 时 error→msg;success=False 时 stderr→error→msg→user_message。所以只给 user_message 也能当原因,但显式给 error 最稳 |
need_param | 缺失或形状错误的参数名 |
error_type | recoverable(改参数可成功)/ config(配置或授权问题)/ transient(抖动)。【订正 2026-10|来源: roles_chat_helpers _RECOVERABLE_TYPES】 recoverable / retryable / need_input / need_param(或带 need_param 键)视为可恢复:喂回模型自纠、不计失败预算,此时 non_retriable 被忽略 |
non_retriable | true = 相同参数重试必然失败。平台会把该技能拉黑本轮 |
redirect | 建议改用的 action / 技能 |
action_note | ★2026-08 补 可选。技能对 action / 参数键做过别名归一时回写归一结果(见 §3.5),成功失败都可带 |
约束:
- 可自纠的错误必须标
recoverable并给出改法,不得表述为「内部错误 / 联系管理员」 ——模型会照抄给用户然后停手。 - 部分成功必须显式标注:在返回里写明成功与失败条数,不得只返回
success=true。 non_retriable只给部署级问题(缺模块、配置错、授权不足)。连接抖动、 对象过期这类下一秒就能好的错误不要标,否则技能被拉黑。- 失败预算:
同一技能连续失败 2 次被本轮封禁,累计 4 次强制收尾;同一【订正 2026-10|来源: roles_chat_helpers】 单技能失败上限(工具, 参数)组合出现 3 次判定死循环。FAIL_PER_TOOL默认 4、全局FAIL_TOTAL默认 8;同一(工具, 参数)第 4 次调用判死循环并停用;不可恢复失败带non_retriable立即拉黑。技能应保证「同参数重试无意义」时明确报错。 - 【订正 2026-10|来源: roles_chat_helpers
retry_with_backoff调用处 /_skills_last_failed】 技能抛出 Timeout / Connection / OSError / SQLAlchemy Operational / Interface 异常时,平台整次重跑至多 3 次;本轮已失败过、或近 30 分钟最近 2 次都失败(熔断)的技能只跑 1 次。 非幂等写操作须幂等或自行捕获。
6.2 直显块
{"display_md": "**结果**\n\n| 列 | 值 |\n|---|---|\n| a | 1 |",
"display_only": True} # 可选,见下
display_md 会在富前端渠道(web / web_admin)直接追加到回复正文,文本渠道回退给模型
转述。用于表格、地图链接、清单类结果,避免模型复述时丢失或篡改数据。
【订正 2026-10|来源: roles_chat_helpers】 对话线未实现
display_only(无代码读该键),现状一律追加。下文是规范定义,不能依赖。
display_only(可选,默认 false) 控制 display_md 与模型正文的关系:
| 值 | 语义 | 用在哪 |
|---|---|---|
省略 / false | 追加:display_md 接在模型正文之后(历史行为) | 直显块是对模型解读的补充(图表、附表,正文仍需模型说明) |
true | 替换:丢弃模型本轮自述的正文,只呈现 display_md | 直显块本身就是完整答案,且逐字不许被改写(帮助、规则/清单、状态表、审计流水) |
判据是「这段 display_md 需不需要模型再补一句话」:不需要就置 true。
置 true 的两个动机,缺一即可:去重——模型常会自己也写一版平行答案,与直显块措辞
不同、无法靠子串去重,两份都显示就是前端重复;防篡改——凡是阈值、规则、清单这类
"一个字都不许改"的内容,不该经过模型的嘴转述(模型会把 ADX>25 说成 >30)。
约束:
- 仅富前端渠道(web / web_admin)生效;文本渠道无论此标记都回退模型转述。
- 一轮内多个技能都产出直显块时,第一个
display_only=true的块会清掉同轮之前已追加的 普通直显块,只保留独占块(及其后的产物链接)。混用需注意技能调用顺序。 - 只影响展示与落库正文,不影响工具执行、来源标注、产物采集。
6.3 产物
技能产出文件时,返回下列字段(顶层单产物 / 数组多产物均可):
{"ok": True,
"filename": "report.xlsx",
"local_path": "/abs/workspace/report.xlsx",
"media": "file", # image / video / audio / file
"images": [{...}], "videos": [{...}], "files": [{...}], "audios": [{...}]}
平台的后处理链(技能无需自己做):
- 远程 URL 物化:输出里的远程链接会被下载到会话工作区。
明确字段(
urls/image_url(s)/video_url(s)/audio_url(s)/file_url(s)/output_url/result_url/oss_url/cdn_url/download_url/abs_url) 无条件下载;含糊字段(url/link/href/src/image/video/audio/file)和正文里发现的链接先探 Content-Type,网页/JSON 类跳过,最多处理 8 个。 单文件上限默认 200 MB。已带存在的local_path的条目跳过,幂等。 - 产物登记:工作区里的文件登记为会话附件,正文自动补下载链接。
直接写到工作区、没在返回里声明的文件也会被兜底采集(
.py/.js/.sh/.log/.tmp/.srt/.pyc除外,除非用户本轮明确要脚本)。 - 渠道投递:按 media / 扩展名分发到渠道的 send_image / send_video / send_file。
技能只需保证文件真实落在工作区(或返回可下载 URL),不要自己拼下载链接或域名。
★2026-08 补 中间文件不是产物。 兜底采集不区分「给用户的」和「技能自用的」——
导入用的 JSON(*_import_*.json)、删除备份、临时拼接文件落在工作区根目录,都会被当附件
展示给用户(踩坑 #63:20 个导入文件全被当作产物)。规则:中间文件放工作区子目录;
成功即删;失败保留供排查并在返回里点名。
6.4 结果卸载
成功且序列化后超过 12000 字符的返回会被写入工作区文件,上下文里只保留收据
(offloaded=True、文件名、字段名清单、数组计数与首条样例)。以下字段会被保留在收据里:
filename local_path download_url abs_url media ok images videos files audios display_md
因此大结果要放在可被卸载的字段里,关键的少量字段(状态、计数、产物路径)放顶层。 读取类工具(file_read 等)不做卸载,应自带分页。
7. 数据表操作
技能不自建数据库连接(不 create_engine)、不自拼建表 DDL。所有数据表操作通过统一入口下发;
datatable 接口表达不了的直连读写(批量分页、多表 join、读平台自身表)见 §7A:
input_data = {"action": "<操作名>", "payload": {...}}
payload 中的参数也允许平铺在顶层,由入口自动归拢。
★2026-08 补 统一入口的技能注册名在不同平台可能不同(线上为 datatable,历史为 nocodb)。
技能侧写死候选名列表逐个探测(list_tables 回「未注册」换下一个),命中即缓存,
探测结果进「环境」自检和连接类错误的信封里;确认后把真名放第一位。
7.1 存储模型
| 项 | 规则 |
|---|---|
| 组织隔离 | 每个 org 一个 PG schema:org_data_<org_id 去非字母数字并小写> |
| 逻辑表 | schema 下的真实表,表名即中文名 |
| 系统列 | id(bigserial 主键)、created_at 由平台创建,不得自建、不得写入 |
| 业务语义 | 字段类型/必填/唯一/选项/精度存于元数据表,不从 information_schema 推断 |
| 标识符 | 中文、字母、数字、下划线;1~50 字符;不得数字开头 |
| 外键 | 不使用 |
7.2 权限模型
表级:授权表是「例外表」不是白名单——某表无授权记录即全开;多角色时权限取并集、 行范围取宽;若某表存在未配置的角色,该表按全开处理。
行级:row_scope="own" 时按归属字段 = 当前操作人工号强制过滤,三处收口缺一不可:
筛选注入(query / aggregate / update_by / delete_by)、id 条件追加(update / delete /
delete_batch)、写入盖值(insert / insert_batch / insert_from_file / upsert)。
附加约束:归属字段必须真实存在否则拒绝;取不到工号 fail closed;归属字段不得被
update 修改;行数统计同样受行级过滤约束;row_scope="own" 的角色禁用 SQL 通道。
操作级:【订正 2026-10|定案,来源: routers/nocodb.py _require_struct_admin / _resolve_team_role,见 pending-issues ISSUE-2】
结构类(create_table / add_field / update_field / delete_field / delete_table,及 SQL 通道的 CREATE / 结构变更)
仅 team_role ∈ {owner, admin}——即本规范原文。2026-08 记录的「按账号类型:团队角色可、子账号不可」与正式代码冲突,撤回。
| 谁 | 结构类操作 |
|---|---|
团队主账号(Team.owner_id == user_id,team_role = owner) | ✅ |
| 被设为 admin 的团队成员(子账号) | ✅ |
| 普通成员(member)/ 取不到 team_role | ❌ 不可自纠错误 |
SQL 类仍要求 allow_sql=True;批量脚本仍仅管理员线。
现状与出入(技能作者不要依赖):
- Web 数据管理接口(
/api/v1/nocodb)已按上表执行;技能线 datatable handler 的两处team_role判断仍是注释状态 (平台落地项,见 ISSUE-2),经 call_skill 调用时任何角色都能建表。技能不得把「会被拒」当安全边界; 确需限制的,技能自己先判ctx["team_role"]。- 「
row_scope=own禁 SQL」在代码里不是按 row_scope 判的,只看allow_sql(缺省 True,当前无任何线注入 False)。所以 own 范围的行级隔离在 SQL 通道前是 不成立的——裸 SELECT 可绕过行过滤。这是已知的、有意保留的现状:对话线允许即兴 SQL 的价值高于行级隔离。将来要对某角色启用 own 范围表,必须同时给该角色所在的 每条线注入allow_sql=False,否则隔离是假的。allow_sql已加入call_skill透传白名单(§9.1)。现状下它是空操作;一旦某条线 设了 False,子技能会继承而不是以缺省 True 开回来。
7.3 操作清单
| action | 必填参数 | 返回 |
|---|---|---|
list_tables | — | [{id, title, schema, qualified}] |
list_columns | table_id | [{title, uidt, required, options, precision, scale}] |
query | table_id | {list, pageInfo} |
aggregate | table_id, metrics | {rows, group_by, metrics} |
insert | table_id, data | 写入后的完整记录 |
insert_batch | table_id, records | {inserted, skipped, failed, errors, verified_total} |
insert_from_file | table_id, file | 同上。可选 mapping({目标列: 源键})、defaults(每行固定值)、on_conflict=skip ★2026-08 补 |
upsert | table_id, key_fields, data 或 records | {inserted, updated, failed, errors, verified_total} |
update | table_id, row_id, data | 更新后的完整记录 |
update_by | table_id, filters, data | {updated} |
delete | table_id, row_id | true |
delete_batch | table_id, row_ids | {deleted, requested, backup_file} |
delete_by | table_id, filters | {deleted, backup_file} |
sql_query | sql | {rows, row_count, sql} |
sql_execute | sql | {affected, rows?, created?, table_id?} |
create_table | table_name, fields | {id, title};表已存在时返回已有表并带 already_exists=true ★2026-08 补。【订正 2026-08|来源: datatable schema.json】 fields 每项 = {"title": "字段名", "type": "文本", "required"?: false, "unique"?: false, "options"?: "选项A,选项B"};title/type 必填,options 仅单选/多选用(逗号分隔字符串),type 取值见 §7.5 |
add_field | table_id, field | 字段定义 |
update_field | table_id, field_title, patch | 字段最新定义 |
delete_field | table_id, field_title | {deleted, field} |
delete_table | table_id | true |
table_id 是 UUID 不是表名;缺失时入口返回可用表清单供选择。
★2026-08 补 缺 table_id 但给了 table_name(或 table / table_title)时,入口按
title 精确匹配自动解析成 id,不额外耗一轮;匹配不到才返回清单。
7.4 参数规范
filters:数组,项间 AND(需要 OR 用 sql_query)。
[{"field": "部门", "op": "eq", "value": "研发"},
{"field": "薪资", "op": "between", "value": [8000, 20000]}]
op:eq ne gt gte lt lte contains in(数组)between(两元素数组)
is_null not_null(省略 value)。不得写成字段映射 {"部门": "研发"};
字段不存在时报错,不静默丢弃;值里残留未解析的引用变量({xxx} / $steps.)时拒绝执行。
【订正 2026-08|来源: datatable _check_unresolved_refs】 该拒绝返回 non_retriable: True,
会把 datatable 本轮拉黑——链里引用名打错的代价是整轮失去数据表,务必让引用名可解析。
★2026-08 补 入口对字段映射形状做了容错(转成 eq 条件),但数组里无法解析的项
(字符串、空串)会被静默丢弃,丢光了就是全表返回——写 filters 时按规范形状写,别赌容错。
metrics:[{"field": "租金", "op": "sum", "alias": "总租金"}],
op ∈ {sum, avg, max, min, count},count 可省略 field。
sort / limit / offset:sort 单字段,前缀 - 降序,默认 id DESC;
query 上限 1000(默认 100),aggregate 上限 10000(默认 1000)。
写入:data 是单条对象不得传数组;records 是对象数组;
on_conflict=skip 要求表上已有唯一约束;upsert 必须给 key_fields。
批量路径:1 条用 insert;2 ~ 100 条用 insert_batch;超过 100 条必须先把数据
一次性整份写入工作区 JSON 文件,再用 insert_from_file 导入,不得拆成多次 insert_batch。
★2026-08 补 两条补充:
insert的data传数组时入口会放行并按批量处理,但这条路没有 100 条上限检查 (insert_batch/upsert有)。大批量不得借insert绕过文件线。insert_from_file的导入文件由调用方技能生成,也由调用方负责清理:放工作区子目录, 导入成功后删除,失败保留(见 §6.3)。datatable 本身不删文件。
7.5 字段类型与写入值
【订正 2026-08|来源: datatable schema.json + service】 create_table / add_field 的
type 枚举实测为:文本 长文本 整数 数字 金额 百分比 日期 日期时间 时间
勾选 单选 多选 邮箱 电话 链接。
- 原文列出的
JSON和高精度数字不在建表枚举里,建表时传它们会被拒。存 JSON 用长文本(text)列 + 应用层解析。 金额/百分比是可直接传给建表的类型(落库归一为数字+ 预设精度:金额 18,2;百分比 12,4;默认 38,2)。它们是「类型」不是「别名」——这点与原文措辞不同。高精度数字(38,18)没有对应枚举值;需要高精度时用数字并接受默认精度,或走 SQL 通道自定义numeric(p,s)(但 SQL 建表不能定义 id/created_at/PRIMARY KEY)。
逻辑类型(读结构时 list_columns 返回的 uidt 是另一套内部名,不能拿去回填建表 fields)。
写入值校验同原文:数字全角转半角、允许千分位逗号;含数量级词(万/亿/千/k/M/bn)一律拒绝, 不静默换算;整数收到小数拒绝;日期用 ISO;单选/多选值必须在已配选项内; 未知字段静默丢弃;空串归一为 NULL。必填校验只在插入时执行。
7.6 SQL 通道
一次一条语句,禁止分号拼接;表名写裸名由系统路由;建表不得定义 id / created_at /
PRIMARY KEY;UPDATE / DELETE 必须带 WHERE;禁止事务控制语句;禁止访问 public、
pg_catalog、information_schema 及其他组织的 schema;CREATE 仅支持 TABLE / INDEX / VIEW。
补充约束(2026-08 实测确认):
- 列类型不支持 JSONB。JSON 数据用
text列存字符串、应用层解析;语句中不得 出现::jsonb强转(DEFAULT '[]'::jsonb写成DEFAULT '[]')。text 不校验 JSON 合法性,技能解析处须兜住坏 JSON(跳过坏行并在返回里点名,不整轮失败)。 - 建表字段名受 §7.1 标识符规则约束(中文、字母、数字、下划线;1~50 字符;
不得数字开头)。
%等符号不允许:「容差%」应写「容差百分比」。此限制只针对 标识符,数据值不受限(行内存 'MA20偏离%' 合法)。 - 字符串字面量内
:后紧跟字母或数字会被识别为绑定参数占位符,导致报错A value is required for bind parameter。内联 JSON 字面量必须在冒号后留空格 ("value": 0.85,而非"value":0.85);运行时向 JSON 列写数据优先走结构化insert/update(data 传对象,由平台处理转义),而不是拼 SQL。
7.7 删除保护
chat_mode=True 且影响行数超过 10(或未知)时返回 confirm_token,用户确认后原参数不变、
携带 token 重调,5 分钟有效。所有删除在执行前把命中行整份落盘到工作区
del_backup_<表>_<时间戳>.json,文件名经 backup_file 返回;备份失败记日志不阻断删除。
filters / row_ids 为空一律拒绝,避免误删全表。
★2026-08 补 精确口径:确认与备份覆盖 delete_batch(按 row_ids 个数)、delete_by
(按命中行数)、sql_execute 的 DELETE(按预检行数,预检失败视为未知→要确认);
按单个 row_id 的 delete 既不确认也不备份。备份文件落在工作区根目录,会被
§6.3 的兜底采集当附件展示——这是刻意的(用户能看到被删的数据)。
★2026-08 补 chat_mode 三条线都是 True。 pipeline.build_runtime_ctx 写死
chat_mode=True,不区分对话线/定时任务/技能链。后果:链或定时任务里 delete_by /
delete_batch / SQL DELETE 命中超过 10 行,同样返回 confirm_token 等确认——而链里
没有人能回 token,该步必然卡死。链内删除要么控制在 ≤10 行(分批),要么改走
sql_execute 且预检行数 ≤10;真需要大批量自动清理,给 SkillRunPolicy 加 chat_mode
字段随 policy 走(CHAIN_POLICY 置 False),不要在技能里绕闸。
7.8 技能内约束(datatable 语义)
★2026-08 补 直连库的通用会话纪律(单一 _db()、现开现还、commit、不横跨慢 I/O、25P02)
已移入 §7A。以下只列 datatable 自身语义:
- 批量写入逐行 SAVEPOINT 隔离,单行失败不影响整批。
- 不假设唯一约束存在;
upsert按key_fields匹配,不依赖约束。 - 返回可核验的数字(
inserted/updated/skipped/failed/verified_total), 不返回无数据支撑的成功结论。
7A. 数据库直连(每次开一条短会话,用完即还)
★2026-08 补与 §7 并列。§7 是「结构化数据表走 datatable 统一入口」;本节是「技能自己读写库」 的唯一正确姿势。两条路可在同一技能里混用:能用 datatable 的 action 表达就走 datatable,表达不了 (批量分页、upsert、多表 join、读写平台自身表)才直连。能用 datatable 就别直连——直连要自己 扛提交、org 边界、字段校验、约束自检,datatable 已替你扛了。范例实现:trade-okxbn-kline的_db()。
7A.1 单一 DB 出入口,每次现开现还,不用 ctx["db"]
直连库把访问收进一个 _db() 小函数,每次从 ctx["db_factory"] 现开一条短会话,
async with 用完即还,commit= 按需提交,异常交给 async with 自动回滚+关闭。
不读 ctx["db"]。 它是平台过渡期对未迁移技能的预注入连接(executor 对不在 _SELF_ACQUIRE
名单里的技能,call_skill 旧路径仍预开一条注入),生命周期归上游、横跨整个父调用(含慢 LLM /
HTTP),持有它 = 慢 I/O 期间攥着连接不放。新技能不依赖它;平台把各技能迁到自开会话后这条注入会移除。
不在整段 handler 顶上开一条 async with 用到底——那样 handler 里每次慢 HTTP / 调模型都攥着它。
范例技能一次 ingest 拉几十次行情 API,全程无 DB 连接在手,靠「每次 _db() 现开现还」做到。
【来源: trade-okxbn-kline _db() / executor _SELF_ACQUIRE】 标准写法:
async def _db(rc, sql, params=None, *, fetch=False, commit=False):
factory = (rc or {}).get("db_factory") # 平台注入;缺了属平台故障
if factory is None:
raise RuntimeError("缺少 db_factory")
from sqlalchemy import text
async with factory() as session: # 一次调用 = 一条短会话 = 一个事务
result = await session.execute(text(sql), params or {})
rows = [dict(m) for m in result.mappings().all()] if fetch else None
if commit: # 写必须显式 commit,否则静默不落库
await session.commit()
return rows
params 传 list 时对同一条 SQL 批量执行(executemany)。
7A.2 为什么现开现还,而不是开一条用到底
- 不横跨慢 I/O 持连接:两次 DB 操作之间的 HTTP / 调模型 / sleep,全程手里没有连接。
- 失败会话即弃、不连坐:某条语句炸了,这条会话被
async with回滚+关闭,下一次_db()拿到干净会话——不会出现「一条语句失败后同 session 后续全报事务已中止(PG25P02)」。 - 回滚/关闭免手写:
async with退出时异常自动 rollback、正常按 commit 与否收尾。
跨语句原子(要么全成要么全滚、且中间无慢 I/O):在一个 _db() 调用里用一条会话连着执行、
最后 commit。不要为原子性把会话开在 handler 顶上跨慢 I/O。手动攥一条会话跨语句时,异常要
rollback 再复用(否则 25P02),且 rollback 会 expire ORM 对象——异常分支要用的名字提前取成普通变量。
7A.3 形态限制
| 形态 | 能否直连库 | 取连接方式 |
|---|---|---|
| 进程内 async Python | ✅ | ctx["db_factory"] 现开现还 |
| 进程内同步 Python | ❌ | 平台把 ctx["db"] 置 None;同步入口也不该跑 async 会话 |
| 子进程 / NODE / RUBY / GO / SHELL | ❌ | db_factory 是 callable,被 _json_sanitize 丢弃,SKILL_CONTEXT 里没有 |
| 文档型 skill_md | ❌ | 不执行代码 |
拿不到库的形态要落库:拆两个技能——取数随形态,落库写成进程内 async Python,取数那步 call_skill 调(§9.3)。
7A.4 org schema、表名、约束(直连时自己拼)
- org schema 与 datatable 同口径
org_data_<org_id 去非字母数字并小写>;表名全限定schema."表", 不靠 search_path;标识符加双引号避开保留字(interval/open/close/value等)。 org_id取自ctx,可能为空:直连写业务库遇空拒绝(§7);只有自查平台档案/组织表才用伪 orgr_<role_id>兜底(§4.6)。- 表由 datatable 的
create_table建,不用裸 SQL 建表(平台建表检查器剥 PRIMARY KEY、自动加 自增 id,裸建对不上)。upsert 依赖的唯一约束可能不存在(被剥了):不假设,查pg_constraint, 缺了先去重再ALTER TABLE ADD CONSTRAINT … UNIQUE(...),如实报告删了多少行(见范例_ensure_unique)。
7A.5 call_skill 前先 commit
子技能开的是另一条会话,看不到未提交的写入。范例的「每次 _db(commit=True)」天然满足;手动攥一条
会话跨语句时,call 前先 await session.commit()(对齐 §9.1 硬约束 1)。
7A.6 自测
把 _db(连同网络层)做成全技能唯一 seam,globals()["_db"] = _fake_db monkeypatch 即可离线跑通
全链路。真库端到端仍要过一次(提交落盘、约束、:name::type 绑定歧义、批量 upsert 幂等——mock 盖不到)。
8. 异步技能
耗时超过对话可等待范围的生成类技能,在 frontmatter 声明 async: true。行为:
- 非 web 渠道立即返回
{"ok": true, "async": true, "message": "..."},真实执行转入后台; 完成后按发起渠道主动推送产物。web 渠道仍走同步路径。【订正 2026-10|来源: roles_chat_helpers_dispatch_async】external_id以orgdeleg:开头的组织委托会话虽是 web_admin,也转后台。后台 ctx 不显式写user_id/team_id,team_role是否随固定上下文带入不可依赖。 - 后台执行时
request/channel_registry/abort_signal为空, 配置在派发前已预取并平铺进skill_configs/skill_args/ 顶层,取法同 §5.1。 - 后台
db是独立会话,同时提供db_factory。
不声明 async 的技能受单工具墙钟约束(默认 240 秒 【订正 2026-10|来源: pipeline.resolve_wall_sec】 技能墙钟默认 SKILL_WALL_DEFAULT = 600 秒,可在 frontmatter 声明 wall_sec / timeout_sec 放宽,上限 SKILL_WALL_HARDCAP = 1800;对话线外层另有 TOOL_WALL_SEC 默认 1800),超时中止。
长循环应轮询 ctx["abort_signal"],被 set 时尽快返回。
9. 技能间协作
技能是独立执行单元,不得互相 import(executor 按独立脚本加载,跨技能 import 运行时不成立)。
需要在技能内部调用另一个技能时,用平台注入的 ctx["call_skill"]。
9.1 call_skill
result = await ctx["call_skill"](
"<skill_name>",
{"字段": "值"}, # 显式入参
source=上一步产出, # 可选,供 template / passthrough 取值
template={"目标字段": "文案 {源字段}"}, # 可选
passthrough=False, # 可选,整份 source 透传
其它字段="值", # 可选,等价于写进第二个参数
)
| 项 | 规则 |
|---|---|
| 入参优先级 | 显式 dict / kwargs > template > passthrough > 被调技能自身配置 |
| 配置注入 | 被调技能的 skill_configs 由平台按 role_id + 被调技能名 重新查库注入,不继承调用方的 |
| 透传的上下文 | ★2026-08 补 精确白名单 = _CALL_PASS_BASE(role_id session_id user_id team_id org_id team_role is_admin channel chat_mode workspace_dir channel_registry db_factory abort_signal allow_sql)+ ACTOR_KEYS(十个 actor_*)+ EXTRA_KEYS(actor_id actor_is_admin)。调用方的 skill_configs / skill_args / 扁平参数、以及词表外的自造键(如 emp_no)不透传。★2026-08 补(LLM) provider 对象与 _llm_conn 也不透传/不进子进程;子技能需要模型时由平台按子技能自己的 role_id 重新解析注入,见 §10 |
db | 每次调用新开独立会话,子技能出错不会污染调用方事务 |
| 返回 | 【订正 2026-08|来源: pipeline.execute_skill】 成功 = 被调技能的 output dict 原样返回,不额外包一层 data——例如 datatable 返回 {"success": True, "data": {...}},query 结果在 r["data"]["list"]。【订正 2026-10|来源: executor._make_call_skill】 失败(执行失败或被调方自报 ok/success 为 False)= {"ok": False, "error": <被调方 error/msg,否则「技能自报失败」>, "skill": ..., "output": <被调方原始信封>},被调方的 user_message / need_param 在 r["output"] 里。不抛异常 |
| 层数 | 最多 3 层;不得直接自调 |
四条硬约束:
- 调用前先 commit。 子技能用的是另一个 session,调用方未提交的写入它看不到。
- 只有进程内 async Python 能用。
call_skill是 callable,无法穿越进程边界; CLI / NODE / SHELL / GO 型技能的SKILL_CONTEXT里没有这个键。 async: true的技能不要用 call_skill 调。 后台派发在对话层,经call_skill会同步执行, 并占用父技能的墙钟预算(TOOL_WALL_SEC)。- 产物后处理不生效。 远程 URL 物化、附件登记、结果卸载都在对话层。 子技能只回 URL 时不会落盘,需要产物的场景由调用方自己保证文件落在工作区。
参数由技能作者在代码里写死,不经过模型——这正是 call_skill 相对「让模型连调多个工具」的价值: 零 token、参数不会漂移、不会被编造。
9.2 通知与消息投递
平台提供两个投递技能,按收件人形态选,不是按内容选:
| 技能 | 用于 | target / to |
|---|---|---|
notify | 单向渠道:固定群、Webhook、邮件、Apprise URL | target="ops"(管理员配的别名)或裸 Apprise URL |
send_message | 双向渠道:飞书/企微/钉钉/Telegram/web 的具体收件人 | to="feishu:ou_xxx",多人传列表 |
call = ctx["call_skill"]
# 单向:推到固定运维群
await call("notify", {
"message": "3号机维修完成,费用 1200 元",
"title": "维修完成",
"target": "ops",
"attach": [report_path], # 可选,本地绝对路径
})
# 双向:发给指定的人
await call("send_message", {
"message": "您报修的 3号机 已修复。",
"to": f"{order['origin_channel']}:{order['origin_openid']}",
"attach": [photo_path],
})
# 两者可并行
await asyncio.gather(
call("notify", {"message": text, "target": "ops"}),
call("send_message", {"message": text, "to": ["feishu:ou_a", "feishu:ou_b"]}),
)
收件人必须来自业务数据,不能来自 actor_*。 actor_* 是「本轮说话的人」;
维修工回「完成了」时 actor 是维修工,报修人是另一个人。报修人的渠道和 ID 必须在
建单时就存进工单表(origin_channel / origin_openid / origin_session_id),
不能事后从当前会话推断。
【订正 2026-08|来源: send_message handler _norm_targets / _send_web】
send_message 的 to 认四种形态:"feishu:ou_xxx"、"ou_xxx"(配 channel= 补前缀)、
{"channel": "feishu", "contact": "ou_xxx"}、以上的列表。冒号后那段按渠道分两类:
| 目标渠道 | to 冒号后 | 建单时 origin_openid 存 |
|---|---|---|
| 第三方(飞书/企微/钉钉/Telegram) | 渠道 openid = ctx["actor_key"] | ctx["actor_key"] |
| web / web_admin | session_id(不是 openid) | ctx["session_id"] |
web 分支 handler 不走 adapter,而是拿冒号后段当 session_id 去 db.get(ChatSession, ...)
写 ChatMessage + WS 推送。拿 web 登录用户的 actor_key(那是 ChatUser.id)当 to 会
查不到会话、静默计入 failed。第三方渠道匿名访客 / 管理员代填时 actor_key 为空,也发不出,建单时要挡。
send_message / notify 的成功判据是返回里的 sent,不是 ok。
send_message 返回 {"ok", "sent": [...], "failed": [...], "message"},
ok = bool(sent) and not failed——部分成功时 ok 是 False 但 sent 非空:
r = await call("send_message", {...})
if not r.get("ok"):
logger.warning("通知失败:%s", r.get("error")) # 通知失败不应回滚业务
投递不等于等回复。 两个技能都是发出即结束。要「发出去 → 等对方回一句 → 继续」, 走审批那套(落 pending 记录,入站消息命中后反向驱动),不要指望在技能内 await 到人的回复。
9.3 其它串联方式
- 平台
skill_schedule多步链(steps):定时任务场景,链内建「空结果护栏」——上一步产出空则中止。 - 形态拿不到
db时(CLI / 其它语言 / 文档型),把「取数」和「落库」拆成两个技能, 落库那步写成进程内 async Python,由取数那步用 call_skill 调用。
10. 调用平台大模型
★2026-08 补(LLM)|来源: executor.py execute / _llm_env_for / _build_subprocess_env
技能内要调用「当前角色绑定的那个模型」(二次推理、改写、分类打分),按形态二选一:
10.1 进程内 async Python:ctx["provider"]
平台在 execute() 里用 _role_provider(role_id) 解析角色绑定的 ModelConfig,
把 provider 对象注入 ctx["provider"](取不到为 None)。
from backend.infra.providers.base import Message
provider = ctx.get("provider")
if provider is None:
return {"success": False, "error_type": "config", "non_retriable": True,
"error": "当前角色未配置可用模型", "user_message": "请先为角色绑定大模型。"}
resp = await provider.complete([Message(role="user", content=prompt)],
system="……", max_tokens=1024, temperature=0.3)
text = resp.text # CompletionResult.text
接口(base.py LLMProvider,2026-08 核实):complete(messages, *, system, max_tokens, temperature, options) -> CompletionResult(.text/.stop_reason/.input_tokens/.output_tokens);
stream(...) 逐 token;tool_call(...) 带工具;属性 model / provider_name / last_usage。
二次推理首选 complete()。Message(role, content) 是 dataclass;CallOptions(model=,temperature=,max_tokens=) 可单次覆盖。
10.2 子进程 / CLI / 其它语言:环境变量
provider 对象不可序列化,进不了子进程(_build_subprocess_env 剔除)。子进程用
_llm_env_for 注入的标准 SDK 变量,官方 SDK 直接可用:
| 变量 | 何时有 | 值 |
|---|---|---|
OPENAI_API_KEY / OPENAI_BASE_URL | provider 非 anthropic | key / base(补到 /v1) |
ANTHROPIC_API_KEY / ANTHROPIC_BASE_URL | provider = anthropic | key / base(裸 base) |
MYINC_LLM_KEY | 有 key | key |
MYINC_LLM_BASE | 有 base | openai 支带 /v1、anthropic 支裸 base(见 10.4) |
MYINC_LLM_MODEL | 配了模型 | 模型名(唯一带模型名的变量) |
10.3 无 key 的模型(ollama 等)不注入任何变量
_llm_env_for 在 if not key: return {}。子进程取不到 LLM 变量是正常情况(角色配了本地无 key 模型),按「无可用模型」优雅降级,不要报错崩掉。
10.4 MYINC_LLM_BASE 的 /v1 不一致(真实行为,非笔误)
openai 协议支:base 就地改成 base+"/v1" 后才写入 → MYINC_LLM_BASE 带 /v1;
anthropic 支:写裸 base → 不带 /v1。自己拼 URL 时注意;能用官方 SDK 就用
OPENAI_BASE_URL / ANTHROPIC_BASE_URL,各自是对的。
10.5 async 后台技能
【订正 2026-10|定案,来源: roles_chat_helpers _run_async_skill】 后台派发新建 SkillExecutor() 并调用
executor.execute(...),与对话线同一入口;provider 在 execute() 内按 role_id 注入,所以后台同样有 ctx["provider"]
(子进程型同样有 LLM 环境变量)。照常判空即可。
10.6 与 call_skill 的关系
provider 与 _llm_conn 都不在透传白名单、不进子进程。子技能需要模型时,平台按
子技能自己的 role_id 重新解析注入,不继承调用方。call_skill 调用要用模型的子技能时,
不用也无法手动传 provider。技能不要读 ctx["_llm_conn"](内部字段)。
11. 上线前 checklist
形态与入口
- 入口是
async def execute(input_data, runtime_context)(【订正 2026-10】安装预检要求),第二参数无默认值 - 碰数据库 →
async def;直连从db_factory现开现还、不读ctx["db"](见 §7A) - 子进程型:日志走 stderr,stdout 只打最终 JSON
- 模块内确有入口函数(漏了会被静默降级成文档型,不报错)
输入
-
schema.json的required与实际必填一致,description写清取值范围 - 文件参数用约定字段名,直接使用平台给的绝对路径,不再拼目录
- 不从
input_data读身份类字段 -
★2026-08 补动作型技能:action / 参数键别名归一,别名表值集合 ⊆ 规范 action 集合(加断言),归一后回写action_note -
★2026-08 补无 action 一律返回need_param=action+ 清单,不按入参形状默认成写动作 -
★2026-08 补写动作的确认只认用户原话 / 「确认」前缀,预演返回回显收到的参数
上下文
- 固定词表的键直接判空,未写多余的
.get(k, "") -
actor_is_admin/ 身份键为空的分支已处理,未把空值当最低档位放行 -
triggered_at当 float 用;attachments 读type不依赖att_type -
org_id缺失时返回error_type=config+non_retriable=true -
★2026-08 补工号取actor_emp_no(不读emp_no);行级过滤依赖它的技能在真库验过「非管理员能看到自己的行」,且经 call_skill 路径也验过一次
配置
- 密钥按 §5.1 的四层顺序取,未要求模型传密钥
- 文档中的凭证只用
$VAR引用,无「打印出来确认」的步骤 -
★2026-08 补非凭证常量名不含 key/token/secret/password/credential/auth 子串;自测扫描按子串而非后缀
输出
- 失败必带
error;可自纠的标recoverable并给改法 -
non_retriable只用于部署级问题 - 部分成功显式标注成功/失败条数
- 产物返回
filename/local_path,未自拼下载链接 - 大结果放可卸载字段,关键少量字段放顶层
- 直显块:
display_only对话线未实现(【订正 2026-10】),只有追加;不许改写的文本另在角色提示词里明令不复述 -
★2026-08 补中间文件(导入 JSON、临时拼接)放工作区子目录,成功即删,失败保留并点名 -
★2026-08 补返回里给用户看的「下一步」不用指令式措辞,标注for_user
数据库直连 ★2026-08 补
- 进程内
async,库访问收进单一_db(),每次从db_factory现开现还,没读ctx["db"] - 写操作
commit;跨语句原子才用一条会话,否则每条各自短会话 - 两次 DB 操作之间的慢 I/O 期间手里无连接
- 表名全限定、标识符加引号;不裸建表;upsert 前查约束不假设存在
-
_db可 monkeypatch 离线自测;真库端到端验过一次 - 子进程 / 其它语言不直连;需落库拆成两个技能
数据表
- 表名全限定、写操作提交、保留字加引号、绑定参数不写
:name::type - SQL 通道:不用 JSONB(JSON 存 text);字段名符合 §7.1 标识符规则(禁
%等符号) - SQL 字符串字面量内无
:紧跟字母/数字模式(JSON 冒号后留空格),JSON 列写入优先走结构化 insert - 不存在的表先查存在性;每组数据独立 try/except + rollback
- 批量按 1 / ≤100 / 文件导入 三档选路
- 真库端到端验过一次(SQL 语法、提交是否落盘、约束、事务连坐,mock 覆盖不到)
-
★2026-08 补数据表技能名写候选列表探测(datatable在前、nocodb兜底),探测结果进环境自检 -
★2026-08 补需要行级隔离(own 范围)的表,对应角色的每条线(对话/定时/工作流)都确认已注入allow_sql=False;未注入则隔离不成立,不要配 own 范围 -
★2026-08 补链/定时任务里的删除步骤,单次命中 ≤10 行(chat_mode三条线恒 True,超过即等确认)
文档
- SKILL.md 主文档 < 8000 字符,细节拆
references/并在正文写明何时读取 - frontmatter 声明
requires.env/bins,缺配置时能被前置检查拦住 - 未 import 其它技能
-
★2026-08 补动作型技能的 SKILL.md 明写「action 必须传中文原词」+ 三个调用示例;帮助表每行左列是触发场景(「连得上数据库吗」),右列是它回答什么
技能间调用
- 调 call_skill 前已 commit 自己的写入
- 收件人取自业务数据,未用 actor_* 当收件人
- 判失败看返回的 ok / sent,未假定调用一定成功
- 未对 async 技能使用 call_skill
- 通知失败不回滚主业务
调用大模型(★2026-08 补(LLM))
- 用模型前判
ctx["provider"]/ 环境变量为空,空则error_type=config+non_retriable,不崩 - 进程内用
ctx["provider"];子进程用MYINC_LLM_*/OPENAI_*/ANTHROPIC_*,不指望子进程有 provider 对象 - ollama 等无 key 模型 → 无变量是正常,优雅降级
- 自己拼 URL 留意
MYINC_LLM_BASE两支/v1不一致;能用官方 SDK 就用标准变量 - 不读
ctx["_llm_conn"](内部字段)