技能间协作:call_skill、通知、消息
什么时候读:技能内要调别的技能(含 datatable)、发通知、发消息给某人、或设计多步链时。
目录
- call_skill 签名与规则
- 四条硬约束
- 通知与消息投递(notify / send_message)
- 其它串联方式
1. call_skill
技能是独立执行单元,不得互相 import。要调另一个技能用 ctx["call_skill"]。
result = await ctx["call_skill"](
"<skill_name>",
{"字段": "值"}, # 显式入参
source=上一步产出, # 可选,供 template / passthrough 取值
template={"目标字段": "文案 {源字段}"}, # 可选
passthrough=False, # 可选,整份 source 透传
其它字段="值", # 可选,等价于写进第二个参数
)
| 项 | 规则 |
|---|---|
| 入参优先级 | 显式 dict / kwargs > template > passthrough > 被调技能自身配置 |
| 配置注入 | 被调技能的 skill_configs 按 role_id + 被调技能名 重新查库注入,不继承调用方 |
| 透传的上下文 | 白名单(平台常量 _CALL_PASS_BASE + ACTOR_KEYS + EXTRA_KEYS)= 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_* + actor_id actor_is_admin。调用方的 skill_configs / skill_args / 扁平参数、词表外自造键(如 emp_no)不透传。workspace_dir 虽在白名单里,但上游从不注入(见 pending-issues ISSUE-1),子技能同样按 _workspace(ctx) 自推 |
db | 每次调用新开独立会话,子技能出错不污染调用方事务 |
| 返回 | 成功 = 被调技能的 output dict 原样(不包 data;datatable 的 data 是它自己的信封)。失败(执行失败,或被调技能自报 ok/success 为 False)= {"ok": False, "error": <被调方 error 或 msg,都没有则「技能自报失败」>, "skill": 名, "output": <被调方原始信封>}(executor _make_call_skill)。所以 datatable 的 user_message / need_param / available_tables 在 r["output"] 里。不抛异常 |
| 层数 | 最多 3 层;不得直接自调 |
参数由技能作者在代码里写死,不经过模型——零 token、不漂移、不编造。这是 call_skill 相对「让模型连调多个工具」的价值。
2. 四条硬约束
- 调用前先 commit。 子技能是另一个 session,调用方未提交的写入它看不到。直连库用 code-facts.md §3.7 /
templates/handler_async.py的_db(commit=True)现开现还写法时天然满足(每次写即已提交);ctx["db"]是遗留注入,新技能别用。 - 只有进程内 async Python 能用。 CLI / NODE / SHELL / GO 的
SKILL_CONTEXT里没有这个键。 async: true的技能不要用 call_skill 调。 会同步执行并占用父技能的墙钟。- 产物后处理不生效。 远程 URL 物化、附件登记、结果卸载都在对话层。子技能只回 URL 不会落盘;需要产物的场景由调用方保证文件落在工作区。
判失败看返回的 ok:
# 成功:就是子技能自己的信封,不额外包 data。
# datatable 成功 {"success": True, "data": {...}},query 结果在 r["data"]["list"],不是 r["list"]。
# 失败:call_skill 统一包成 {"ok": False, "error", "skill", "output": 子技能原始信封}。
r = await ctx["call_skill"]("xxx", {...})
if r.get("ok") is False or r.get("success") is False:
up = r.get("output") if isinstance(r.get("output"), dict) else r
return {"success": False, "upstream": up,
"error": f"xxx 失败:{up.get('error') or up.get('user_message') or r.get('error')}",
"need_param": up.get("need_param"),
"error_type": up.get("error_type", "recoverable")}
3. 通知与消息投递
按收件人形态选,不按内容选:
| 技能 | 用于 | 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),不能事后从当前会话推断。
send_message 的 to 支持四种形态(按 handler _norm_targets 实测):"feishu:ou_xxx"、
"ou_xxx"(配 channel= 补前缀)、{"channel": "feishu", "contact": "ou_xxx"}、以上的列表。
冒号后那段填什么,按渠道分两类(handler 里 web 走的是另一条路):
| 目标渠道 | to 里的 ID | 建单时存什么 |
|---|---|---|
| 第三方渠道(飞书/企微/钉钉/Telegram) | 渠道 openid = ctx["actor_key"] | origin_channel=ctx["channel"], origin_openid=ctx["actor_key"] |
| web / web_admin | session_id(不是 openid) | origin_channel=ctx["channel"], origin_openid=ctx["session_id"] |
web 分支 handler 用冒号后段当 session_id 去 db.get(ChatSession, ...) 并写 ChatMessage + WS 推送;
拿 web 登录用户的 actor_key(那是 ChatUser.id)当 to 会查不到会话、静默失败。所以:
if ctx["channel"] in ("web", "web_admin"):
origin = {"origin_channel": ctx["channel"],
"origin_openid": ctx["session_id"]} # web:冒号后是 session_id
else:
origin = {"origin_channel": ctx["channel"],
"origin_openid": ctx["actor_key"]} # 第三方:冒号后是渠道 openid
origin["origin_session_id"] = ctx["session_id"]
# 之后:to = f"{origin['origin_channel']}:{origin['origin_openid']}"
actor_is_admin=True 时 actor_key 被清空(管理员代填),此时建单要另外要求填写报修人,不能留空。
send_message 返回 {"ok", "sent": [...], "failed": [...], "message"},
ok = bool(sent) and not failed——部分成功时 ok 是 False 但 sent 非空。
要判「有没有发出去」看 sent,判「是否全部成功」才看 ok。notify 同理,判据是 sent。
r = await call("send_message", {...})
if not r.get("ok"):
logger.warning("通知失败:%s", r.get("error")) # 通知失败不回滚业务
投递不等于等回复。 两个技能发出即结束。要「发出去 → 等对方回一句 → 继续」,走审批那套(落 pending 记录,入站消息命中后反向驱动),不要在技能内 await 人的回复。
4. 其它串联方式
- 多步链(chain 型技能 / 工作流执行技能 /
skill_schedulesteps):引用语法、失败策略、并行分层见chain.md。链内删除注意 datatable.md §8(>10 行会卡在确认)。 - 形态拿不到
db时(CLI / 其它语言 / 文档型),把「取数」和「落库」拆成两个技能,落库写成进程内 async Python,由取数那步用 call_skill 调。