feat(skill): skill 定向模型——ppt 触发自动热切 GLM-5.2(bump 0.39.0)
内置 skill 可在 frontmatter 声明 model: <family.variant>(ppt → glm.pro52)。 单一执行点:load_skill 命中 → run 内热切 self.llm/self.caps(下一轮生效, 记账/压缩阈值自动跟上)+ 持久化 tasks 双列 + emit model_switch 事件;切失败 降级 warn 原模型继续。send 侧档外静默降级对定向模型豁免(免费档第二条消息 不被降回 flash)。跳档位门控(产品决策);只信内置 skill(用户 skill 的 model 字段忽略,防绕门控);不自动切回。"开关"= frontmatter 那一行本身(registry 每 run 现扫,删行即停,per-skill 粒度)—— 曾实现 app_settings 表 + admin 总开关 + 建 task 预切,架构评审后砍掉(第二事实源/第二执行点),详 DESIGN §3.5。 前端:直播流 model_switch 插模型分隔线 + 顶栏下拉同步(档外定向模型临时补 option 如实显示)。新增 tests/test_skill_model_pinning.py。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
d24165a2fe
commit
43775d531a
|
|
@ -88,6 +88,8 @@ yaml 是手填的,probe 用真实调用对账:`basic_chat` / `parallel_tools` /
|
||||||
|
|
||||||
**用户私有 skill(多来源 registry,2026-06-11)**:`SkillRegistry` 收**有序来源列表**——内置 `ROOT/skills`(只读)+ 用户 `user_root/.skills`(可写,per-user)。用户来源排后,**同名覆盖内置(user wins)**;覆盖在 discovery 显式标注,不静默。取舍:① **user wins** 而非 namespace 隔离——核心用例是"copy 内置 skill 再改",同名覆盖才符合"我的覆盖全局"直觉,且 skill 是纯指引、覆盖只作用于该用户自己会话,blast radius 锁死;② **创作走 host-side typed tool**(`save_skill`/`fork_skill`)而非 fs/shell——fs 的 base_dir 锚 cwd(host)/ 容器 wd(docker),够不到 `user_root/.skills`,跨 backend 不可靠;host-side 工具知 user_root,一个落点两模式通吃(与 seedream/document_* 持 key host-side 同范式),且 `fork_skill` copytree 整目录解决"带脚本 skill 的 fork";③ 用户来源加载失败(YAML 坏 / 缺 description)收进 `load_errors` 注入 prompt 提示用户修,不静默丢、不崩整次扫描。
|
**用户私有 skill(多来源 registry,2026-06-11)**:`SkillRegistry` 收**有序来源列表**——内置 `ROOT/skills`(只读)+ 用户 `user_root/.skills`(可写,per-user)。用户来源排后,**同名覆盖内置(user wins)**;覆盖在 discovery 显式标注,不静默。取舍:① **user wins** 而非 namespace 隔离——核心用例是"copy 内置 skill 再改",同名覆盖才符合"我的覆盖全局"直觉,且 skill 是纯指引、覆盖只作用于该用户自己会话,blast radius 锁死;② **创作走 host-side typed tool**(`save_skill`/`fork_skill`)而非 fs/shell——fs 的 base_dir 锚 cwd(host)/ 容器 wd(docker),够不到 `user_root/.skills`,跨 backend 不可靠;host-side 工具知 user_root,一个落点两模式通吃(与 seedream/document_* 持 key host-side 同范式),且 `fork_skill` copytree 整目录解决"带脚本 skill 的 fork";③ 用户来源加载失败(YAML 坏 / 缺 description)收进 `load_errors` 注入 prompt 提示用户修,不静默丢、不崩整次扫描。
|
||||||
|
|
||||||
|
**Skill 定向模型(frontmatter `model:`,2026-07-06)**:内置 skill 可在 frontmatter 声明 `model: <family.variant>`(如 ppt → `glm.pro52`),表示"该工作流用这个模型效果最好"。架构 = 声明 → 执行 → 兜底 → 呈现四层,**单一执行点**:① 执行 —— 对话中 `load_skill` 命中即 **run 内热切**(agent_builder 注入 switcher 闭包,loop 替换 `self.llm/self.caps`,下一轮 LLM 调用即生效;记账/压缩阈值/reasoning 都读 `self.caps` 自动跟上),并持久化 `tasks.model/model_profile`(resume / 下条 send 沿用);切失败(yaml 配错/缺 key)降级 warn、原模型继续。② 兜底 —— send 侧"档外模型静默降级"对定向模型豁免(判定:落到档外只有"管理员降档"与"skill 定向"两条路,`profile ∈ 内置 skill 定向集合`即放行)。③ 呈现 —— loop emit `model_switch` 事件,前端插模型分隔线 + 顶栏下拉同步。取舍:**定向模型是产品决策,跳过档位门控**(任何档位用户触发该 skill 都可用);**只信内置 skill**(用户 skill 的 `model` 字段忽略,否则自写 frontmatter 即绕门控);**切换不自动切回**("skill 结束"不可判定,且同 task 后续多半仍在该 skill 语境);**不设独立开关、不做建 task 时预切** —— registry 每 run 现扫 SKILL.md,frontmatter 那一行本身就是热配置(删行下个 run 停用,per-skill 粒度),全局 DB 开关是同一行为的第二事实源、建 task 预切是第二执行点,都砍(评估过 `app_settings` 开关表方案,放弃)。
|
||||||
|
|
||||||
### 3.6 Session 与 Task
|
### 3.6 Session 与 Task
|
||||||
|
|
||||||
**Session**(`core/session.py`)= 消息列表 + meta,**直接 ORM 写 PG `messages` 表**(append-only,`jsonb` 存 LiteLLM 原样 payload)。
|
**Session**(`core/session.py`)= 消息列表 + meta,**直接 ORM 写 PG `messages` 表**(append-only,`jsonb` 存 LiteLLM 原样 payload)。
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。
|
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。
|
||||||
|
|
||||||
最后更新:2026-07-03(web 进度 dock 展开遮挡最新内容:贴底时补触底,bump 0.38.1)
|
最后更新:2026-07-06(skill 定向模型:ppt 触发自动热切 GLM-5.2,bump 0.39.0)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -21,6 +21,9 @@
|
||||||
|
|
||||||
## 已完成关键能力
|
## 已完成关键能力
|
||||||
|
|
||||||
|
### 2026-07-06 / skill 定向模型:ppt 触发自动热切 GLM-5.2(bump 0.39.0)
|
||||||
|
内置 skill 可在 frontmatter 声明 `model: <family.variant>`(ppt → `glm.pro52`)。单一执行点:对话中 `load_skill` 命中 → run 内热切(agent_builder 注入 switcher 闭包,loop 换 `self.llm/self.caps` 下一轮即生效 + 持久化 tasks 双列 + emit `model_switch` 事件),失败降级 warn 原模型继续;send 侧档外静默降级对定向模型豁免(否则免费档下条消息被降回 flash)。跳档位门控(产品决策,任何档位可用);只信内置 skill(用户 skill 的 model 字段忽略,防自写 frontmatter 绕门控);不自动切回;"开关"= frontmatter 那一行本身(registry 每 run 现扫,删行即停,per-skill 粒度)—— 曾实现 `app_settings` 表 + admin 总开关 + 建 task 预切,架构评审后砍掉(第二事实源 / 第二执行点,见 DESIGN §3.5)。前端:直播流 `model_switch` 插模型分隔线 + 顶栏下拉同步(档外定向模型临时补 option 如实显示)。新增 `tests/test_skill_model_pinning.py`(frontmatter 解析 + loop 热切)。改 core/skills.py、core/loop.py、core/agent_builder.py、web/app.py、web/static/js/chat.js、skills/ppt/SKILL.md;详 DESIGN §3.5。
|
||||||
|
|
||||||
### 2026-07-03 / web 列表状态灯挪到文件夹行左侧,数据行均匀分布(bump 0.38.8)
|
### 2026-07-03 / web 列表状态灯挪到文件夹行左侧,数据行均匀分布(bump 0.38.8)
|
||||||
用户建议:状态放文件夹名左侧、时间那行正常分布。落地:终态徽章 + 运行圆点挪进文件夹行行首(`● 📁 ppt4`,行首左上区最先被扫到;无文件夹行的 task 回落到数据行行首,`syncTaskRowRunIndicator` 按同规则找 host:`.wd-line` 优先、`.meta.stats` 兜底);底部数据行只剩纯数据(skill/条/tok/时间),改 `justify-content:space-between` 均匀铺开,时间自然落行尾。改 `web/static/js/chat.js` + `web/static/dev.html`。
|
用户建议:状态放文件夹名左侧、时间那行正常分布。落地:终态徽章 + 运行圆点挪进文件夹行行首(`● 📁 ppt4`,行首左上区最先被扫到;无文件夹行的 task 回落到数据行行首,`syncTaskRowRunIndicator` 按同规则找 host:`.wd-line` 优先、`.meta.stats` 兜底);底部数据行只剩纯数据(skill/条/tok/时间),改 `justify-content:space-between` 均匀铺开,时间自然落行尾。改 `web/static/js/chat.js` + `web/static/dev.html`。
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# zcbot Skill 清单
|
# zcbot Skill 清单
|
||||||
|
|
||||||
服务对象:中国建筑材料科学研究总院 —— 无机非金属材料 R&D(水泥 / 混凝土 / 玻璃 / 陶瓷 / 耐火 / 新型建材)
|
服务对象:中国建筑材料科学研究总院 —— 无机非金属材料 R&D(水泥 / 混凝土 / 玻璃 / 陶瓷 / 耐火 / 新型建材)
|
||||||
最后更新:2026-07-02(ppt skill 加渲图验收闭环 + 导出验收硬门 + 几何质检)
|
最后更新:2026-07-06(ppt skill 定向 GLM-5.2 模型 —— frontmatter `model:` 机制,load 时自动切换)
|
||||||
Skill 总数:17
|
Skill 总数:17
|
||||||
|
|
||||||
zcbot 的"skill"是一份可加载的工作流脚本(`skills/<name>/SKILL.md` + 配套 templates / scripts / Python helper),模型在识别用户意图后挂载对应 skill,按其内置的阶段化流程产出可交付物。本文档面向**使用方 / 协作方**,按"做什么、什么时候用、什么时候别用、典型产物"组织。
|
zcbot 的"skill"是一份可加载的工作流脚本(`skills/<name>/SKILL.md` + 配套 templates / scripts / Python helper),模型在识别用户意图后挂载对应 skill,按其内置的阶段化流程产出可交付物。本文档面向**使用方 / 协作方**,按"做什么、什么时候用、什么时候别用、典型产物"组织。
|
||||||
|
|
@ -177,6 +177,8 @@ zcbot 的"skill"是一份可加载的工作流脚本(`skills/<name>/SKILL.md` +
|
||||||
- ⛔ 用户明确说"报告 / 文档 / 纪要"等纯文档产物 → 不走本 skill
|
- ⛔ 用户明确说"报告 / 文档 / 纪要"等纯文档产物 → 不走本 skill
|
||||||
- ⚠️ 用户说"汇报 / 方案 / 材料"等产物形态不明 → **先反问** PPT 还是 Word/Markdown,确认后再 load
|
- ⚠️ 用户说"汇报 / 方案 / 材料"等产物形态不明 → **先反问** PPT 还是 Word/Markdown,确认后再 load
|
||||||
|
|
||||||
|
**定向模型**:frontmatter 定向 `glm.pro52`(GLM-5.2)—— 对话中 load 本 skill 时自动切换到该模型(不限档位,不自动切回;后续消息沿用,可在顶栏下拉手动切走)。
|
||||||
|
|
||||||
**默认主题 —— 自由设计**(content-driven):按内容+受众+选定 visual_style 派生配色版式,spec 阶段给 ≥3 套候选挑;商务红/品牌色作为候选之一,用户点名或素材有 brand guideline 才锁定。
|
**默认主题 —— 自由设计**(content-driven):按内容+受众+选定 visual_style 派生配色版式,spec 阶段给 ≥3 套候选挑;商务红/品牌色作为候选之一,用户点名或素材有 brand guideline 才锁定。
|
||||||
|
|
||||||
**八条对齐**(spec 阶段定稿,a–h):画布 / 页数 / 受众+核心信息+投递目的 / mode+visual_style / 配色 / 图标库 / 字体+字号 / 配图。确认后产出两份引擎契约:`design_spec.md`(人读叙事)+ `spec_lock.md`(机读执行锁,executor 每页重读、抗长 deck 漂移)。
|
**八条对齐**(spec 阶段定稿,a–h):画布 / 页数 / 受众+核心信息+投递目的 / mode+visual_style / 配色 / 图标库 / 字体+字号 / 配图。确认后产出两份引擎契约:`design_spec.md`(人读叙事)+ `spec_lock.md`(机读执行锁,executor 每页重读、抗长 deck 漂移)。
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||||
# 改版本只动这一行。
|
# 改版本只动这一行。
|
||||||
__version__ = "0.38.8"
|
__version__ = "0.39.0"
|
||||||
|
|
|
||||||
|
|
@ -686,9 +686,40 @@ def build_agent(
|
||||||
# dispatch 到 per-user 容器(其他工具仍 host)。docker 路径要求 lifespan 已 `init_pool`。
|
# dispatch 到 per-user 容器(其他工具仍 host)。docker 路径要求 lifespan 已 `init_pool`。
|
||||||
host_executor = HostExecutor(tools)
|
host_executor = HostExecutor(tools)
|
||||||
executor = _resolve_executor(host_executor, uid, ur_path, working_dir_path)
|
executor = _resolve_executor(host_executor, uid, ur_path, working_dir_path)
|
||||||
|
|
||||||
|
def _skill_model_switch(skill_name: str, current_profile: str):
|
||||||
|
"""skill 定向模型:load_skill 后按内置 skill frontmatter `model:` 热切。
|
||||||
|
|
||||||
|
只信内置 skill —— 用户 skill 可自写 frontmatter,照单全收等于绕开档位门控。
|
||||||
|
内置 skill 指定模型是产品决策:任何档位用户触发该 skill 都可用,不过档位门控。
|
||||||
|
"开关"即 frontmatter 那一行本身(registry 每 run 现扫,删 `model:` 行下个 run
|
||||||
|
就停,per-skill 粒度、无需重启);返回 None = 不切。成功时顺手持久化
|
||||||
|
tasks.model/model_profile:下条 send / resume 沿用新模型(send 侧档外降级
|
||||||
|
对 skill 定向模型有对应豁免,见 web/app.py)。
|
||||||
|
"""
|
||||||
|
sk = skills.get(skill_name)
|
||||||
|
if sk is None or sk.source != "builtin":
|
||||||
|
return None
|
||||||
|
profile = (sk.model or "").strip()
|
||||||
|
if not profile or profile == current_profile:
|
||||||
|
return None
|
||||||
|
new_caps = ModelCapabilities.load(profile, ROOT / cfg["models_dir"])
|
||||||
|
new_llm = LLM(new_caps) # 缺 api key 会 raise → loop 捕获转 warn,不切换
|
||||||
|
from sqlalchemy import update as _update
|
||||||
|
from core.storage import session_scope as _scope
|
||||||
|
from core.storage.models import Task as _Task
|
||||||
|
with _scope() as _s:
|
||||||
|
_s.execute(
|
||||||
|
_update(_Task)
|
||||||
|
.where(_Task.task_id == task_id)
|
||||||
|
.values(model=new_caps.model_id, model_profile=profile)
|
||||||
|
)
|
||||||
|
return profile, new_caps, new_llm
|
||||||
|
|
||||||
agent = AgentLoop(
|
agent = AgentLoop(
|
||||||
llm, executor, session, caps,
|
llm, executor, session, caps,
|
||||||
user_id=uid, working_dir=working_dir_path, sink=sink,
|
user_id=uid, working_dir=working_dir_path, sink=sink,
|
||||||
|
skill_model_switch=_skill_model_switch,
|
||||||
)
|
)
|
||||||
if cancel_check is not None:
|
if cancel_check is not None:
|
||||||
agent.cancel_check = cancel_check
|
agent.cancel_check = cancel_check
|
||||||
|
|
|
||||||
35
core/loop.py
35
core/loop.py
|
|
@ -204,6 +204,7 @@ class AgentLoop:
|
||||||
sink: Optional[Any] = None,
|
sink: Optional[Any] = None,
|
||||||
max_iterations: Optional[int] = None,
|
max_iterations: Optional[int] = None,
|
||||||
cancel_check: Optional[Callable[[], bool]] = None,
|
cancel_check: Optional[Callable[[], bool]] = None,
|
||||||
|
skill_model_switch: Optional[Callable[[str, str], Optional[Tuple[str, Any, Any]]]] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.llm = llm
|
self.llm = llm
|
||||||
self.executor = executor
|
self.executor = executor
|
||||||
|
|
@ -220,6 +221,10 @@ class AgentLoop:
|
||||||
# ③ tool_calls 之间。chunk 间 poll 让 cancel 延迟从「整轮 generation 时长」
|
# ③ tool_calls 之间。chunk 间 poll 让 cancel 延迟从「整轮 generation 时长」
|
||||||
# (几十秒)降到「单 chunk 间隔」(~100ms)。
|
# (几十秒)降到「单 chunk 间隔」(~100ms)。
|
||||||
self.cancel_check = cancel_check
|
self.cancel_check = cancel_check
|
||||||
|
# skill 定向模型(agent_builder 注入):load_skill 成功后以 (skill_name, 当前 profile)
|
||||||
|
# 调它,返回 (新 profile, caps, llm) 则热切 —— 本 run 内下一次 LLM 调用即生效。
|
||||||
|
# None(CLI 旧调用方 / 测试)= 不启用。loop 不碰 DB/config,切换判定与持久化全在闭包里。
|
||||||
|
self.skill_model_switch = skill_model_switch
|
||||||
# 病理性重复调用守卫(同名同参 + 无产出),活在本次 run 内,不跨 task。
|
# 病理性重复调用守卫(同名同参 + 无产出),活在本次 run 内,不跨 task。
|
||||||
self._repeat_guard = _RepeatGuard()
|
self._repeat_guard = _RepeatGuard()
|
||||||
# 全局「无进展」计数:连续多少步整步无净产出。有净产出清零,见 run loop 熔断。
|
# 全局「无进展」计数:连续多少步整步无净产出。有净产出清零,见 run loop 熔断。
|
||||||
|
|
@ -524,6 +529,36 @@ class AgentLoop:
|
||||||
result = result[:MAX_LEN] + f"\n[... truncated, {len(result) - MAX_LEN} chars ...]"
|
result = result[:MAX_LEN] + f"\n[... truncated, {len(result) - MAX_LEN} chars ...]"
|
||||||
truncated = True
|
truncated = True
|
||||||
|
|
||||||
|
# skill 定向模型:load_skill 成功且该 skill frontmatter 指定了模型 → 热切,
|
||||||
|
# 本 run 内下一轮 LLM 即用新模型(记账/压缩阈值/reasoning 都读 self.caps,自动跟上)。
|
||||||
|
# 切换说明追加在截断之后,不会被 16k 截掉。切失败(配错/缺 key)→ warn 后原模型继续。
|
||||||
|
if (
|
||||||
|
name == "load_skill"
|
||||||
|
and self.skill_model_switch is not None
|
||||||
|
and not result.startswith("[Error]")
|
||||||
|
):
|
||||||
|
cur_profile = f"{self.caps.family}.{self.caps.variant}"
|
||||||
|
try:
|
||||||
|
switched = self.skill_model_switch(str(args.get("name", "")), cur_profile)
|
||||||
|
except Exception as e:
|
||||||
|
switched = None
|
||||||
|
self._emit({
|
||||||
|
"type": "warn",
|
||||||
|
"msg": f"skill 定向模型切换失败,继续用 {cur_profile}: {type(e).__name__}: {e}",
|
||||||
|
})
|
||||||
|
if switched:
|
||||||
|
new_profile, new_caps, new_llm = switched
|
||||||
|
self.caps, self.llm = new_caps, new_llm
|
||||||
|
result += (
|
||||||
|
f"\n\n[模型切换] 该 skill 指定模型 {new_profile},"
|
||||||
|
f"已从 {cur_profile} 自动切换,本 task 后续消息也沿用 {new_profile}。"
|
||||||
|
)
|
||||||
|
self._emit({
|
||||||
|
"type": "model_switch",
|
||||||
|
"model_profile": new_profile,
|
||||||
|
"from": cur_profile,
|
||||||
|
})
|
||||||
|
|
||||||
# 登记结果做重复检测(用截断后、未加提示的原始结果算指纹,保证同输出哈希一致)。
|
# 登记结果做重复检测(用截断后、未加提示的原始结果算指纹,保证同输出哈希一致)。
|
||||||
unproductive, productive = self._repeat_guard.record(name, args, result)
|
unproductive, productive = self._repeat_guard.record(name, args, result)
|
||||||
if unproductive >= _RepeatGuard.SOFT:
|
if unproductive >= _RepeatGuard.SOFT:
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,10 @@ class Skill:
|
||||||
description: str
|
description: str
|
||||||
skill_dir: Path
|
skill_dir: Path
|
||||||
source: str = "builtin" # 'builtin' | 'user'
|
source: str = "builtin" # 'builtin' | 'user'
|
||||||
|
# frontmatter 可选 `model: <family.variant>`:该 skill 定向模型。load_skill 命中时
|
||||||
|
# 由 agent_builder 注入的 switcher 热切(仅内置 skill 生效 —— 用户 skill 若照单全收
|
||||||
|
# 等于让任何人自写 frontmatter 绕开档位门控)。空 = 不定向,跟随 task 当前模型。
|
||||||
|
model: str = ""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def skill_md(self) -> Path:
|
def skill_md(self) -> Path:
|
||||||
|
|
@ -80,7 +84,8 @@ class Skill:
|
||||||
desc = meta.get("description") or ""
|
desc = meta.get("description") or ""
|
||||||
if not desc:
|
if not desc:
|
||||||
raise SkillLoadError("缺 description(frontmatter 必须有 name + description)")
|
raise SkillLoadError("缺 description(frontmatter 必须有 name + description)")
|
||||||
return cls(name=name, description=desc, skill_dir=skill_dir, source=source)
|
model = str(meta.get("model") or "").strip()
|
||||||
|
return cls(name=name, description=desc, skill_dir=skill_dir, source=source, model=model)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
---
|
---
|
||||||
name: ppt
|
name: ppt
|
||||||
description: 生成 PowerPoint 演示文稿 (.pptx) 文件。✅ 触发:用户明确点名 PPT / 幻灯片 / 演示文稿 / .pptx / slide / deck 之一。⛔ 不触发:用户明确说要"报告 / 文档 / 纪要"等指向纯文档形式的产物。⚠️ 歧义先反问:用户说"汇报 / 方案 / 材料"等产物形态不明的词、且没说成品形式时,不要直接 load 本 skill 也不要假定走文档,先反问一句"这份要做成 PPT 演示稿,还是 Word/Markdown 文档?" 用户确认 PPT 后再 load。
|
description: 生成 PowerPoint 演示文稿 (.pptx) 文件。✅ 触发:用户明确点名 PPT / 幻灯片 / 演示文稿 / .pptx / slide / deck 之一。⛔ 不触发:用户明确说要"报告 / 文档 / 纪要"等指向纯文档形式的产物。⚠️ 歧义先反问:用户说"汇报 / 方案 / 材料"等产物形态不明的词、且没说成品形式时,不要直接 load 本 skill 也不要假定走文档,先反问一句"这份要做成 PPT 演示稿,还是 Word/Markdown 文档?" 用户确认 PPT 后再 load。
|
||||||
|
model: glm.pro52
|
||||||
---
|
---
|
||||||
|
|
||||||
# PPT(SVG-first)
|
# PPT(SVG-first)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,169 @@
|
||||||
|
"""skill 定向模型(frontmatter `model:`)测试。
|
||||||
|
|
||||||
|
覆盖两层:
|
||||||
|
1. Skill.from_dir 解析 frontmatter 可选 `model` 字段(无 → "";有 → strip 后原样)。
|
||||||
|
2. AgentLoop._execute_tool_call 的热切挂点:load_skill 成功且 switcher 返回新
|
||||||
|
(profile, caps, llm) → self.caps/self.llm 替换 + 结果尾追加说明 + emit model_switch;
|
||||||
|
load_skill 报错 / switcher 抛异常 → 不切换、原模型继续。
|
||||||
|
|
||||||
|
switcher 本体(内置 skill 判定 + 档位豁免 + tasks 持久化)在 agent_builder 闭包里,
|
||||||
|
依赖 DB,不在此测;loop 侧只关心"给了切换指令就正确执行、失败不破坏 run"。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
from core.loop import AgentLoop # noqa: E402
|
||||||
|
from core.skills import Skill # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def _write_skill(root: Path, name: str, frontmatter_extra: str = "") -> Path:
|
||||||
|
d = root / name
|
||||||
|
d.mkdir()
|
||||||
|
(d / "SKILL.md").write_text(
|
||||||
|
f"---\nname: {name}\ndescription: 测试用\n{frontmatter_extra}---\n\n# body\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
class TestSkillModelField(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmpdir = tempfile.TemporaryDirectory()
|
||||||
|
self.root = Path(self.tmpdir.name)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.tmpdir.cleanup()
|
||||||
|
|
||||||
|
def test_no_model_field_defaults_empty(self):
|
||||||
|
sk = Skill.from_dir(_write_skill(self.root, "plain"))
|
||||||
|
self.assertEqual(sk.model, "")
|
||||||
|
|
||||||
|
def test_model_field_parsed_and_stripped(self):
|
||||||
|
sk = Skill.from_dir(_write_skill(self.root, "pinned", "model: glm.pro52 \n"))
|
||||||
|
self.assertEqual(sk.model, "glm.pro52")
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeSession:
|
||||||
|
task_id = "00000000-0000-0000-0000-000000000000"
|
||||||
|
messages: list = []
|
||||||
|
|
||||||
|
def append(self, msg):
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeExecutor:
|
||||||
|
"""load_skill 返回可配置文本;其余不需要。"""
|
||||||
|
|
||||||
|
def __init__(self, result_text: str):
|
||||||
|
self._result = result_text
|
||||||
|
|
||||||
|
def schemas(self):
|
||||||
|
return []
|
||||||
|
|
||||||
|
def call_tool(self, name, args, ctx):
|
||||||
|
return SimpleNamespace(content=self._result)
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_caps(family="deepseek_v4", variant="flash"):
|
||||||
|
return SimpleNamespace(
|
||||||
|
family=family, variant=variant, max_iterations=10,
|
||||||
|
default_reasoning_effort="", reliable_context=64000,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_loop(result_text: str, switcher) -> AgentLoop:
|
||||||
|
loop = AgentLoop(
|
||||||
|
llm=SimpleNamespace(tag="old_llm"),
|
||||||
|
executor=_FakeExecutor(result_text),
|
||||||
|
session=_FakeSession(),
|
||||||
|
capabilities=_fake_caps(),
|
||||||
|
user_id="00000000-0000-0000-0000-000000000000",
|
||||||
|
working_dir=Path("."),
|
||||||
|
skill_model_switch=switcher,
|
||||||
|
)
|
||||||
|
loop.events = []
|
||||||
|
loop.sink = SimpleNamespace(emit=lambda ev: loop.events.append(ev))
|
||||||
|
return loop
|
||||||
|
|
||||||
|
|
||||||
|
def _load_skill_tc(skill_name="ppt"):
|
||||||
|
return SimpleNamespace(
|
||||||
|
id="tc1",
|
||||||
|
function=SimpleNamespace(name="load_skill", arguments=f'{{"name": "{skill_name}"}}'),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoopHotSwap(unittest.TestCase):
|
||||||
|
def test_switch_applies_new_caps_and_llm(self):
|
||||||
|
new_caps = _fake_caps("glm", "pro52")
|
||||||
|
new_llm = SimpleNamespace(tag="new_llm")
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def switcher(skill_name, cur_profile):
|
||||||
|
calls.append((skill_name, cur_profile))
|
||||||
|
return "glm.pro52", new_caps, new_llm
|
||||||
|
|
||||||
|
loop = _make_loop("[skill=ppt, dir=x]\n# PPT", switcher)
|
||||||
|
result, _ = loop._execute_tool_call(_load_skill_tc())
|
||||||
|
|
||||||
|
self.assertEqual(calls, [("ppt", "deepseek_v4.flash")])
|
||||||
|
self.assertIs(loop.caps, new_caps)
|
||||||
|
self.assertIs(loop.llm, new_llm)
|
||||||
|
self.assertIn("[模型切换]", result)
|
||||||
|
self.assertIn("glm.pro52", result)
|
||||||
|
types = [e["type"] for e in loop.events]
|
||||||
|
self.assertIn("model_switch", types)
|
||||||
|
ms = next(e for e in loop.events if e["type"] == "model_switch")
|
||||||
|
self.assertEqual(ms["model_profile"], "glm.pro52")
|
||||||
|
self.assertEqual(ms["from"], "deepseek_v4.flash")
|
||||||
|
|
||||||
|
def test_no_switch_when_switcher_returns_none(self):
|
||||||
|
loop = _make_loop("[skill=ppt, dir=x]\n# PPT", lambda n, c: None)
|
||||||
|
old_caps, old_llm = loop.caps, loop.llm
|
||||||
|
result, _ = loop._execute_tool_call(_load_skill_tc())
|
||||||
|
self.assertIs(loop.caps, old_caps)
|
||||||
|
self.assertIs(loop.llm, old_llm)
|
||||||
|
self.assertNotIn("[模型切换]", result)
|
||||||
|
self.assertNotIn("model_switch", [e["type"] for e in loop.events])
|
||||||
|
|
||||||
|
def test_error_result_skips_switcher(self):
|
||||||
|
calls = []
|
||||||
|
loop = _make_loop(
|
||||||
|
"[Error] skill 'nope' not found.",
|
||||||
|
lambda n, c: calls.append(n),
|
||||||
|
)
|
||||||
|
loop._execute_tool_call(_load_skill_tc("nope"))
|
||||||
|
self.assertEqual(calls, [])
|
||||||
|
|
||||||
|
def test_switcher_exception_warns_and_keeps_model(self):
|
||||||
|
def switcher(n, c):
|
||||||
|
raise FileNotFoundError("glm.yaml missing")
|
||||||
|
|
||||||
|
loop = _make_loop("[skill=ppt, dir=x]\n# PPT", switcher)
|
||||||
|
old_caps, old_llm = loop.caps, loop.llm
|
||||||
|
result, _ = loop._execute_tool_call(_load_skill_tc())
|
||||||
|
self.assertIs(loop.caps, old_caps)
|
||||||
|
self.assertIs(loop.llm, old_llm)
|
||||||
|
self.assertNotIn("[模型切换]", result)
|
||||||
|
warns = [e for e in loop.events if e["type"] == "warn"]
|
||||||
|
self.assertTrue(any("切换失败" in e["msg"] for e in warns))
|
||||||
|
|
||||||
|
def test_other_tools_never_trigger_switcher(self):
|
||||||
|
calls = []
|
||||||
|
loop = _make_loop("file content", lambda n, c: calls.append(n))
|
||||||
|
tc = SimpleNamespace(
|
||||||
|
id="tc2", function=SimpleNamespace(name="read", arguments='{"path": "a.txt"}')
|
||||||
|
)
|
||||||
|
loop._execute_tool_call(tc)
|
||||||
|
self.assertEqual(calls, [])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
24
web/app.py
24
web/app.py
|
|
@ -471,6 +471,25 @@ def _resolve_model_profile(profile: str, user_id: Optional[UUID] = None) -> tupl
|
||||||
return name, caps.model_id
|
return name, caps.model_id
|
||||||
|
|
||||||
|
|
||||||
|
def _skill_pinned_profiles() -> set:
|
||||||
|
"""全部内置 skill 定向的 model profile 集合(send 侧档外降级豁免用)。
|
||||||
|
|
||||||
|
task 落到档外模型只有两条路:管理员下调档位(该降),或 skill 定向(load_skill
|
||||||
|
热切写入,不该降 —— 产品决策放行)。档内模型根本进不了降级分支,所以
|
||||||
|
"profile ∈ 本集合"即可判定 skill 定向来源,无需追溯该 task 当初怎么切的。
|
||||||
|
只扫内置来源(与热切 switcher 的"只信内置"一致);registry 现扫 ~3ms 且只在
|
||||||
|
"当前模型已档外"的罕见分支才调。frontmatter 删掉 `model:` 行 → 集合随之收缩,
|
||||||
|
存量定向 task 下条消息自然落回 flash。
|
||||||
|
"""
|
||||||
|
from core.agent_builder import load_config
|
||||||
|
from core.paths import ROOT
|
||||||
|
from core.skills import SkillRegistry, SkillSource
|
||||||
|
|
||||||
|
cfg = load_config()
|
||||||
|
reg = SkillRegistry(SkillSource(ROOT / cfg.get("skills_dir", "skills"), "builtin"))
|
||||||
|
return {s.model for s in reg.skills.values() if s.model}
|
||||||
|
|
||||||
|
|
||||||
async def _run_channel_conversation(app, uid, text, attachments, *, channel):
|
async def _run_channel_conversation(app, uid, text, attachments, *, channel):
|
||||||
"""渠道无关的入站对话核心(§8.7):解析/建该用户该渠道常驻 task → 落盘附件 → 抢 run 锁
|
"""渠道无关的入站对话核心(§8.7):解析/建该用户该渠道常驻 task → 落盘附件 → 抢 run 锁
|
||||||
→ _run_agent_bg → 取 assistant 回复文本。两渠道各一张会话 task,互不串扰。
|
→ _run_agent_bg → 取 assistant 回复文本。两渠道各一张会话 task,互不串扰。
|
||||||
|
|
@ -2394,7 +2413,10 @@ def create_app() -> FastAPI:
|
||||||
).first()
|
).first()
|
||||||
plan = (urow.plan if urow else "") or ""
|
plan = (urow.plan if urow else "") or ""
|
||||||
role = (urow.role if urow else "user") or "user"
|
role = (urow.role if urow else "user") or "user"
|
||||||
if not is_allowed(cur_profile, plan, role):
|
# skill 定向模型豁免:档外但属内置 skill 定向(建 task 默认 / load_skill
|
||||||
|
# 热切写入)→ 不降,产品决策放行。开关关闭时集合为空 → 恢复降级,存量
|
||||||
|
# 定向 task 自然落回 flash。
|
||||||
|
if not is_allowed(cur_profile, plan, role) and cur_profile not in _skill_pinned_profiles():
|
||||||
fb_profile, fb_model_id = _resolve_model_profile(FALLBACK_MODEL_PROFILE)
|
fb_profile, fb_model_id = _resolve_model_profile(FALLBACK_MODEL_PROFILE)
|
||||||
values["model_profile"] = fb_profile
|
values["model_profile"] = fb_profile
|
||||||
values["model"] = fb_model_id
|
values["model"] = fb_model_id
|
||||||
|
|
|
||||||
|
|
@ -512,9 +512,14 @@ function renderModelDropdown(t) {
|
||||||
// 模型清单未加载好(或为空)时不渲染下拉,但 task 仍可正常用(后端走 task.model_profile)
|
// 模型清单未加载好(或为空)时不渲染下拉,但 task 仍可正常用(后端走 task.model_profile)
|
||||||
if (!state.models || state.models.length === 0) return "";
|
if (!state.models || state.models.length === 0) return "";
|
||||||
const cur = t.model_profile || "";
|
const cur = t.model_profile || "";
|
||||||
const opts = state.models.map(m =>
|
let opts = state.models.map(m =>
|
||||||
`<option value="${escapeHtml(m.profile)}" ${m.profile === cur ? "selected" : ""}>${escapeHtml(m.display_name)}</option>`
|
`<option value="${escapeHtml(m.profile)}" ${m.profile === cur ? "selected" : ""}>${escapeHtml(m.display_name)}</option>`
|
||||||
).join("");
|
).join("");
|
||||||
|
// 当前模型不在该用户档位清单里(skill 定向模型,如 ppt → glm.pro52)→ 补一项,
|
||||||
|
// 下拉如实显示;用户仍可切回档内模型。
|
||||||
|
if (cur && !state.models.some(m => m.profile === cur)) {
|
||||||
|
opts += `<option value="${escapeHtml(cur)}" selected>${escapeHtml(cur)}</option>`;
|
||||||
|
}
|
||||||
return `<span class="muted small mdl-wrap" style="display:inline-flex;align-items:center;gap:4px;"><span class="mdl-text">模型</span><span class="mdl-icon" aria-hidden="true">💬</span><select id="chat-model-sel" class="small" style="width:auto;padding:1px 4px;font-size:12px;" title="切换 task 模型(下条消息生效)">${opts}</select></span>`;
|
return `<span class="muted small mdl-wrap" style="display:inline-flex;align-items:center;gap:4px;"><span class="mdl-text">模型</span><span class="mdl-icon" aria-hidden="true">💬</span><select id="chat-model-sel" class="small" style="width:auto;padding:1px 4px;font-size:12px;" title="切换 task 模型(下条消息生效)">${opts}</select></span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1750,6 +1755,33 @@ function handleSseEvent(ev, asstCard, ctx) {
|
||||||
if (isProducer) upgradeMediaArtifacts(asstCard);
|
if (isProducer) upgradeMediaArtifacts(asstCard);
|
||||||
}
|
}
|
||||||
if (visible) scheduleFilesRefresh(); // 工具调用结果回来,FS 可能被改了,debounce 刷新右侧
|
if (visible) scheduleFilesRefresh(); // 工具调用结果回来,FS 可能被改了,debounce 刷新右侧
|
||||||
|
} else if (t === "model_switch") {
|
||||||
|
// skill 定向模型热切(load_skill 后):流里插一条与历史重渲同款的模型分隔线,
|
||||||
|
// 并同步顶栏下拉 + taskMeta(后端已持久化 tasks.model_profile,刷新后一致)。
|
||||||
|
const prof = (ev.data && ev.data.model_profile) || "";
|
||||||
|
if (prof) {
|
||||||
|
const dn = (state.models.find(x => x.profile === prof) || {}).display_name || prof;
|
||||||
|
closeTextSeg(ctx);
|
||||||
|
const sep = document.createElement("div");
|
||||||
|
sep.className = "model-switch muted";
|
||||||
|
sep.style.cssText = "margin:8px 0;text-align:center;font-size:11px;letter-spacing:0.5px;";
|
||||||
|
sep.textContent = `── ${dn}(skill 指定)──`;
|
||||||
|
asstCard.appendChild(sep);
|
||||||
|
if (visible && state.taskMeta) {
|
||||||
|
state.taskMeta.model_profile = prof;
|
||||||
|
const sel = $("chat-model-sel");
|
||||||
|
if (sel) {
|
||||||
|
// 档外定向模型不在该用户的下拉清单里 → 临时补一项,保持显示真实
|
||||||
|
if (![...sel.options].some(o => o.value === prof)) {
|
||||||
|
const o = document.createElement("option");
|
||||||
|
o.value = prof; o.textContent = dn;
|
||||||
|
sel.appendChild(o);
|
||||||
|
}
|
||||||
|
sel.value = prof;
|
||||||
|
}
|
||||||
|
$("chat-hint").textContent = `skill 指定模型 → ${dn}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
} else if (t === "cancelled") {
|
} else if (t === "cancelled") {
|
||||||
const badge = document.createElement("div");
|
const badge = document.createElement("div");
|
||||||
badge.className = "cancelled-badge";
|
badge.className = "cancelled-badge";
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue