一、前言:技能和规划,Agent 从"会做"到"会管自己"

写过几个鸿蒙 Agent Demo 后,你会撞上四个具体问题:

  1. 上下文塞不下——把所有能力(烘焙技巧、文档写作、代码搜索)的详细 Prompt 全塞进 system prompt,Token 爆炸,模型注意力涣散,回答质量下降。

  2. 技能用错时机——烘焙 Demo 里问"今天天气怎样",Agent 还在用"烘焙专家"人设回答,因为那个 Prompt 永远在场。

  3. 多步任务跑偏——让 Agent "查三样原料、计算配比、生成清单",它查了两样就开始算,漏了一样还嘴硬说做完了。

  4. 想给技能加"自动执行脚本"——看到别的生态里 Skill 能跑 .js,想在鸿蒙上也搞一个,一查发现沙箱里没有可安全假设的 JS 运行时,强行接入等于在应用里开一个不受控的代码执行入口。

这四个问题的根因是同一个:Agent 缺少"按需加载能力"和"先规划后执行"的机制,而"自由执行脚本"在鸿蒙的安全模型下不可行。ArkAgent 的答案是一套组合拳——对象技能 + 目录技能 + 动态激活 + 四档规划器 + 计划审批流,并且明确把 JS Runtime 移出 1.0。

本文就把这套机制从头拆到尾。


二、技能系统全景:对象型 vs 目录型

2.1 两类技能的定位

ArkAgent 把技能分成两种互斥的模式,同一个 Runtime 上只能开一种:

┌─────────────────────────── ArkAgent 技能系统 ───────────────────────────┐
│                                                                         │
│   对象技能(Object Skill)            目录技能(Directory Skill)         │
│   ─────────────────────────           ─────────────────────────          │
│   • Skill 对象数组                    • skillDirectoryPath 指向根目录     │
│   • 代码里 new 出来                   • 扫描文件系统发现 SKILL.md          │
│   • 带 SkillToolBinding 工具          • 只注入说明,工具由宿主提供          │
│   • forceActivate / 动态激活          • $name 显式提及触发注入             │
│   • activate_skills 工具管理          • 渐进式读取(progressive)          │
│                                                                         │
│   互斥:SkillConfigValidator.validateModeMutex                          │
│   有对象技能 + 配了 skillDirectoryPath → skill_mode_conflict 报错         │
└─────────────────────────────────────────────────────────────────────────┘

两者的核心差异:

维度

对象技能

目录技能

定义方式

代码里 Skill.create(...)

磁盘上的 SKILL.md 文件

工具来源

SkillToolBinding[] 内置

宿主提供(如 read_skill_file

激活方式

模型调 activate_skills / forceActivate

用户 $name 提及 或 描述匹配

System Prompt

激活后注入 skill.systemPrompt

按需读取 SKILL.md 正文注入

适合场景

能力固定、需要工具的内置技能

可扩展、用户可自定义的工作流说明

发现成本

编译期确定

运行时扫描(受深度限制)

2.2 为什么互斥?——避免两套激活语义打架

互斥不是洁癖,是工程必需。对象技能靠 activate_skills 工具切换激活态,目录技能靠 $name 提及按轮注入。如果同时开两套,模型会收到两套互相矛盾的"怎么用技能"指令,而且两套工具表会冲突。SkillConfigValidator.validateModeMutex 在 Runtime 构造期就硬失败,不把这个问题拖到运行时:

// skill/Skill.ets —— 模式互斥校验
static validateModeMutex(hasObjectSkills: boolean, skillDirectoryPath?: string):
  Result<boolean, ArkAgentError> {
  const dir = skillDirectoryPath !== undefined ? skillDirectoryPath.trim() : ''
  if (hasObjectSkills && dir.length > 0) {
    return Result.failure<boolean, ArkAgentError>(ArkAgentError.config(
      'skill_mode_conflict',
      'Object skills and skillDirectoryPath cannot be enabled at the same time'))
  }
  return Result.success<boolean, ArkAgentError>(true)
}

注意它返回的是 config 层错误——这是配置问题,不是运行时问题,不可重试,应该在构造期就暴露给开发者。


三、对象技能模型:private 构造器 + static 工厂

3.1 Skill 的不可变设计

Skill 用了一个经典的 ArkTS 模式:构造器 private,只能通过 static create() 工厂创建。为什么?因为 ArkTS 不支持 final 字段的延迟校验,如果构造器 public,调用方可以传一个空 name 进来,对象一旦构造完就带着脏数据到处跑。工厂方法把校验前置到构造的唯一入口:

// skill/Skill.ets
export class Skill {
  readonly name: string
  readonly description: string
  readonly systemPrompt: string
  readonly tools: SkillToolBinding[]
  readonly forceActivate: boolean

  private constructor(name: string, description: string, systemPrompt: string,
    tools: SkillToolBinding[], forceActivate: boolean) {
    this.name = name
    this.description = name
    // ...全部 readonly 赋值
  }

  static create(name: string, description: string, systemPrompt: string,
    tools: SkillToolBinding[] = [], forceActivate: boolean = false): Result<Skill, ArkAgentError> {
    const trimmed = name.trim()
    if (trimmed.length === 0) {
      return Result.failure(ArkAgentError.config('skill_name_empty', 'Skill name must not be empty'))
    }
    // 工具名唯一性校验
    const seen = new Set<string>()
    for (let i = 0; i < tools.length; i++) {
      const toolName = tools[i].definition.name
      if (toolName.trim().length === 0) {
        return Result.failure(ArkAgentError.config('skill_tool_name_empty', ...))
      }
      if (seen.has(toolName)) {
        return Result.failure(ArkAgentError.config('skill_tool_name_duplicate', ...))
      }
      seen.add(toolName)
    }
    return Result.success(new Skill(trimmed, description, systemPrompt, tools.slice(), forceActivate))
  }
}

两个关键校验:

  • name 非空——技能名是激活/停用的主键,空名会让 activate_skills 无法寻址。

  • 工具名内部唯一——同一个技能注册两个同名工具,模型调用时分不清走哪个执行器。

tools.slice() 是防御性拷贝,避免外部数组后续被改动影响技能内部的工具列表。

3.2 SkillToolBinding:定义与执行器绑定

一个技能拥有的工具用 SkillToolBinding 打包——它把给模型看的 ToolDefinition 和实际执行的 ToolExecutor 绑在一起。这样激活一个技能,就能同时拿到它的说明和它附带的工具:

// skill/Skill.ets
export class SkillToolBinding {
  readonly definition: ToolDefinition   // 给模型看的 schema
  readonly executor: ToolExecutor       // 实际执行逻辑
}

激活技能后,Runtime 在下一次模型调用前执行 rebuildComposition(),把这个技能的 definition 合并进 working tool registry。这就是"动态工具表面"——基座 registry 不可变,但每次请求前按激活态重建工作集。

3.3 ReservedToolNames:管理工具名是保留字

有一批工具名是系统保留的,技能的工具不能跟它们撞名:

// skill/Skill.ets
export class ReservedToolNames {
  static readonly activateSkills: string = 'activate_skills'
  static readonly deactivateSkills: string = 'deactivate_skills'
  static readonly writeTodos: string = 'write_todos'
  static readonly retrieveMemory: string = 'retrieve_memory'
  static readonly delegateTask: string = 'delegate_task'

  static all(): string[] { return [/* 上面五个 */] }
}

SkillConfigValidator.validateObjectSkills 会做四重交叉校验:

校验

错误码

含义

技能名唯一

skill_name_duplicate

两个技能同名,激活时寻址歧义

工具不撞保留名

skill_tool_reserved

技能工具叫 write_todos 会和规划器打架

工具不撞基座工具

skill_tool_base_collision

技能工具和已注册的基座工具重名

跨技能工具不重名

skill_tool_cross_skill_duplicate

两个技能各注册一个同名工具

这四道校验都在 Runtime 构造期跑,失败即抛 config 错误,不进运行时。


四、forceActivate:不可关闭的核心能力

4.1 强制激活的语义

forceActivate=true 的技能是"核心身份",它有三个特性:

  1. 永远激活——不需要模型调 activate_skills,从第一轮就在场。

  2. 不可停用——模型调 deactivate_skills 会被拒绝,返回 "force activated, do not try to deactivate"。

  3. 不进可变激活列表——sanitizeActiveSkills 会把强制技能名从可变列表里剔除。

看清洗函数的实现,它把强制技能从"可选已知集合"里排除掉:

// skill/Skill.ets
export function sanitizeActiveSkills(raw: string[] | undefined, skills: Skill[]): string[] {
  if (raw === undefined || raw.length === 0 || skills.length === 0) {
    return []
  }
  const knownOptional = new Set<string>()
  for (let i = 0; i < skills.length; i++) {
    if (!skills[i].forceActivate) {          // ← 强制技能不进可选集合
      knownOptional.add(skills[i].name)
    }
  }
  const out: string[] = []
  const seen = new Set<string>()
  for (let i = 0; i < raw.length; i++) {
    const name = raw[i]
    if (!knownOptional.has(name)) { continue }  // ← 未知/强制/已删的都丢掉
    if (seen.has(name)) { continue }             // ← 去重
    seen.add(name)
    out.push(name)
  }
  return out
}

这个函数在 Runtime 从持久化状态恢复 activeSkills 时调用。它处理三种脏数据:

  • 未知名的残留——旧版本删掉的技能,持久化里还留着。

  • 强制技能误入——某种途径把强制技能写进了可变列表(不该出现,但 sanitize 兜底)。

  • 重复名——历史写入 bug 导致的重复。

4.2 只有强制技能时,不注册管理工具

一个巧妙的优化:如果所有技能都是 forceActivate,那就没有"可选技能"可激活/停用,activate_skills / deactivate_skills 工具压根不注册。这避免了模型对着一个没有可操作对象的工具瞎调。测试里明确验证了这一点:

// SkillPlanner.test.ets
it('given_force_skill_when_deactivate_then_remains_active_in_prompt', 0, async () => {
  const skill = makeSkill('core', true)   // ← 全是强制技能
  const runtime = new AgentRuntime(baseConfig(client, new ToolRegistry(), 'sess', PlanMode.none, [skill]))
  const names = runtime.getWorkingToolDefinitions()
  let hasDeactivate = false
  let hasActivate = false
  for (let i = 0; i < names.length; i++) {
    if (names[i].name === 'deactivate_skills') hasDeactivate = true
    if (names[i].name === 'activate_skills') hasActivate = true
  }
  expect(hasDeactivate).assertFalse()     // ← 没有可选技能,不注册管理工具
  expect(hasActivate).assertFalse()
})

4.3 动态技能 Prompt 的三段式

buildSkillSystemPrompt 把对象技能的 system prompt 分成三段:

# DYNAMIC SKILL SYSTEM
## 1. CORE CAPABILITIES (IMMUTABLE)     ← forceActivate 技能,🔒 ACTIVE
## 2. OPTIONAL CAPABILITIES (DYNAMIC)    ← 可选技能,🟢 ACTIVE / ⚪ INACTIVE
## 3. SKILL MANAGEMENT PROTOCOLS         ← 什么时候调 activate/deactivate
## 4. ACTIVE SKILL INSTRUCTIONS          ← 当前激活技能的完整 systemPrompt

未激活的可选技能只出现在"能力目录"里(名字 + 描述 + 状态图标),不注入详细 Prompt。只有激活后才把 skill.systemPrompt 展开到第 4 段。这就是"按需加载"——上下文里只放当前需要的能力细节。


五、技能管理工具:activate_skills / deactivate_skills

5.1 SkillActivationHost:隔离可变状态

技能管理工具需要一个可变的 host 接口来读写激活态,而不是直接碰全局状态:

// skill/SkillTools.ets
export interface SkillActivationHost {
  getSkills(): Skill[]             // 全部注册技能
  getActiveSkills(): string[]      // 当前激活的可选技能名
  setActiveSkills(names: string[]): void
}

Runtime 实现这个接口,工具通过它更新激活列表。这样工具逻辑可单测,不依赖 Runtime 内部细节。

5.2 activate_skills 的四类结果

activate_skills 执行器对每个请求的名字分四类返回,结构化地告诉模型发生了什么:

// skill/SkillTools.ets —— ActivateSkillsExecutor 核心
for (let i = 0; i < skillNames.length; i++) {
  const name = skillNames[i]
  if (!available.has(name)) {
    notFound.push(name)              // ① 不存在
    continue
  }
  if (forceSet.has(name)) {
    forceActivated.push(name)        // ② 强制技能(已经在场,不重复加)
    continue
  }
  if (!activeSet.has(name)) {
    active.push(name)
    activeSet.add(name)
    added.push(name)                 // ③ 新激活
  } else {
    alreadyActive.push(name)         // ④ 已激活(幂等)
  }
}

返回文本会把四类情况都列出来:

Skills have been force activated: identity
Skills have been activated: coder
Skills are already active: writer
Skills not found: missing_skill

为什么要把"已激活"和"不存在"分开告诉模型?因为模型可能重复调 activate_skills(它不知道自己上次调过),幂等的"already active"反馈让它安心往下走;而"not found"是真正的错误,模型应该换个名字或放弃。

5.3 deactivate_skills:强制技能拒绝停用

停用执行器对强制技能有特殊处理——直接拒绝,不修改任何状态:

// skill/SkillTools.ets —— DeactivateSkillsExecutor
for (let i = 0; i < skillNames.length; i++) {
  const name = skillNames[i]
  if (forceSet.has(name)) {
    forceActivated.push(name)        // ← 强制技能,拒绝停用
    continue
  }
  const idx = active.indexOf(name)
  if (idx >= 0) {
    active.splice(idx, 1)
    removed.push(name)               // ← 正常停用
  } else {
    notFound.push(name)              // ← 本来就没激活
  }
}

返回里对强制技能会明确加一句 "Do not try to deactivate"——直接在工具结果里给模型下指令,避免它反复尝试。


六、目录技能:从文件系统发现 SKILL.md

6.1 SKILL.md 的格式

目录技能的元数据用 YAML frontmatter 描述,解析逻辑非常克制——只认 --- 分隔的简单键值对,不引入完整 YAML 解析器:

---
name: proofing
description: 面团发酵工作流与检查清单
---
# Proofing Skill

1. 检查面团温度
2. 记录醒发时间
3. 用指压测试判断完成度

frontmatter 必填两个字段:

字段

必填

校验

失败错误

name

trim 后非空

missing skill name

description

trim 后非空

missing skill description

解析函数 parseDirectorySkillContent 是纯函数,可单测:

// skill/DirectorySkill.ets
export function parseDirectorySkillContent(content: string, skillPath: string):
  DirectorySkillMetadata {
  const frontmatter = extractFrontmatter(content)
  if (frontmatter === undefined) {
    throw new Error('missing YAML frontmatter delimited by ---')
  }
  const parsed = parseSimpleFrontmatter(frontmatter)
  const nameRaw = parsed.get('name')
  const name = nameRaw !== undefined ? nameRaw.trim() : ''
  if (name.length === 0) {
    throw new Error('missing skill name')
  }
  const descriptionRaw = parsed.get('description')
  const description = descriptionRaw !== undefined ? descriptionRaw.trim() : ''
  if (description.length === 0) {
    throw new Error('missing skill description')
  }
  return new DirectorySkillMetadata(name, description, path)
}

6.2 发现流程:递归扫描 + 损坏隔离

loadDirectorySkillsFromRoot 是目录技能的发现入口。它的设计有两个重点:默认深度 6 防止无限递归,单个损坏不阻断其他

// skill/DirectorySkill.ets
export const DEFAULT_DIRECTORY_SKILL_MAX_DEPTH: number = 6

export async function loadDirectorySkillsFromRoot(
  access: SkillDirectoryAccess,
  rootDirectoryPath: string,
  maxDepth: number = DEFAULT_DIRECTORY_SKILL_MAX_DEPTH
): Promise<DirectorySkillLoadResult> {
  const root = SkillPathUtil.normalizePath(rootDirectoryPath)
  if (!(await access.rootExists(root))) {
    return new DirectorySkillLoadResult([], [
      new DirectorySkillLoadError(root, 'skill directory does not exist')])
  }
  const files = await access.findSkillMdFiles(root, maxDepth)
  const skills: DirectorySkillMetadata[] = []
  const errors: DirectorySkillLoadError[] = []
  const seenPaths = new Set<string>()

  for (let i = 0; i < files.length; i++) {
    const filePath = files[i]
    // ...路径去重、边界检查
    try {
      const content = await access.readTextWithinRoot(root, normalized)
      const metadata = parseDirectorySkillContent(content, normalized)
      skills.push(metadata)                    // ← 成功的进 skills
    } catch (e) {
      errors.push(new DirectorySkillLoadError(filePath, message))  // ← 失败的进 errors
    }
  }
  skills.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0)  // 按名稳定排序
  return new DirectorySkillLoadResult(skills, errors)
}

返回的 DirectorySkillLoadResult 同时带 skillserrors——合法的技能正常加载,损坏的变成 error 条目,互不影响。测试验证了这个隔离:

// SkillPlanner.test.ets
it('given_corrupt_and_valid_when_load_then_isolates_error', 0, async () => {
  access.injectSkillFile('/skills/bad/SKILL.md', 'no frontmatter')   // ← 损坏
  access.injectSkillFile('/skills/good/SKILL.md', '---\nname: good\ndescription: ok\n---\nbody')
  const loaded = await loadDirectorySkillsFromRoot(access, '/skills', 6)
  expect(loaded.skills.length).assertEqual(1)     // ← 好的照常加载
  expect(loaded.skills[0].name).assertEqual('good')
  expect(loaded.errors.length >= 1).assertTrue()  // ← 坏的进了 errors
})

深度限制的测试也值得一看——maxDepth=2 时,深层目录的 SKILL.md 被跳过:

it('given_depth_limit_when_load_then_skips_deeper', 0, async () => {
  access.injectSkillFile('/skills/a/b/c/SKILL.md', ...)  // depth 4,超限
  access.injectSkillFile('/skills/shallow/SKILL.md', ...) // depth 2,刚好
  const loaded = await loadDirectorySkillsFromRoot(access, '/skills', 2)
  expect(loaded.skills.length).assertEqual(1)
  expect(loaded.skills[0].name).assertEqual('shallow')
})

6.3 显式提及注入:$name 与路径标记

目录技能的激活靠用户消息里的"显式提及"。collectExplicitDirectorySkillMentions 识别三种提及形式:

提及形式

示例

说明

$name

Please use $writer

美元符 + 技能名

路径标记

[$name](/skills/x/SKILL.md)

Markdown 链接形式,可消歧

纯文本名

use writer now

仅当名字唯一时生效

路径标记存在的原因是重名消歧。如果两个技能都叫 dup(在不同目录),$dup 无法确定指哪个,但 [$dup](/skills/b/SKILL.md) 可以:

// SkillPlanner.test.ets
it('given_path_tagged_mention_when_duplicate_names_then_disambiguates', 0, () => {
  const skills = [
    new DirectorySkillMetadata('dup', 'a', '/skills/a/SKILL.md'),
    new DirectorySkillMetadata('dup', 'b', '/skills/b/SKILL.md')
  ]
  const mentioned = collectExplicitDirectorySkillMentions(
    [user('use [$dup](/skills/b/SKILL.md)')], skills)
  expect(mentioned.length).assertEqual(1)
  expect(mentioned[0].pathToSkillMd).assertEqual('/skills/b/SKILL.md')  // ← 精确定位
})

被提及的技能,其 SKILL.md 正文会以 <skill> 标签注入到对话历史里,模型据此执行工作流:

// skill/DirectorySkill.ets —— buildDirectorySkillInjections
const payload =
  `<skill>\n` +
  `<name>${skill.name}</name>\n` +
  `<path>${skill.pathToSkillMd}</path>\n` +
  `${content}\n` +           // ← SKILL.md 正文
  `</skill>`

6.4 渐进式读取:只读需要的部分

目录技能的 System Prompt 明确要求模型"progressive disclosure"(渐进式展开),不要一次性把技能目录里所有文件都读进来:

- How to use a skill (progressive disclosure):
  1) After deciding to use a skill, open its SKILL.md. Read only enough to follow the workflow.
  2) When SKILL.md references relative paths (e.g., references/foo.md), resolve them
     relative to the skill directory listed above first.
  3) If SKILL.md points to extra folders such as references/, load only the specific files
     needed for the request; don't bulk-load everything.
  4) If assets/ or templates exist, reuse them instead of recreating from scratch.

这段 Prompt 是上下文管理的核心——技能可能带大量参考资料,但每轮只读当前步骤需要的那个文件。这和对象技能的"激活才注入详细 Prompt"是同一个思路:按需加载,控制上下文体积


七、SkillDirectoryAccess:受控文件访问 SPI

7.1 为什么不直接用 FileSystem

目录技能的文件访问需要一个独立 SPI(SkillDirectoryAccess),不能直接复用通用 FileSystem。原因是目录技能多了三个约束:

  1. 递归发现——要能 listSync 遍历目录找 SKILL.md,基础 FileSystem 不一定支持。

  2. 根目录边界——所有读取必须落在配置的 skill root 内,拒绝 .. 越界。

  3. 符号链接拒绝——鸿蒙沙箱里符号链接可能指向 root 外,必须拒绝而非跟随。

// skill/SkillDirectoryAccess.ets
export interface SkillDirectoryAccess {
  rootExists(rootPath: string): Promise<boolean>
  findSkillMdFiles(rootPath: string, maxDepth: number): Promise<string[]>
  readTextWithinRoot(rootPath: string, targetPath: string): Promise<string>
  normalizePath(path: string): string
  resolveWithinRoot(rootPath: string, targetPath: string): Result<string, ArkAgentError>
}

7.2 路径越界的三道防线

SkillPathUtil.resolveWithinRoot 是路径安全的核心。它做三件事:

// skill/SkillDirectoryAccess.ets
static resolveWithinRoot(rootPath: string, targetPath: string): Result<string, ArkAgentError> {
  const root = SkillPathUtil.normalizePath(rootPath)
  // ...空检查
  let candidate: string
  if (target.startsWith('/')) {
    candidate = target                       // 绝对路径,但仍要检查是否在 root 下
  } else {
    candidate = `${rootNorm}/${target}`      // 相对路径,拼到 root 下
  }
  const absolute = SkillPathUtil.collapseDotSegments(candidate)  // ← 折叠 . 和 ..
  if (absolute === undefined) {
    return Result.failure(SkillPathError.security('skill_path_escape', ...))  // .. 逃逸
  }
  if (!SkillPathUtil.isUnderRoot(rootNorm, absolute)) {
    return Result.failure(SkillPathError.security('skill_path_escape', ...))  // 不在 root 下
  }
  return Result.success(absolute)
}

collapseDotSegments 是关键——它模拟 URL 规范的 ./.. 折叠,一旦 .. 要逃出 root 就返回 undefined。测试覆盖了经典攻击向量:

// SkillPlanner.test.ets
it('given_path_escape_when_resolve_then_rejects', 0, () => {
  const r = SkillPathUtil.resolveWithinRoot('/skills', '../etc/passwd')
  expect(r.ok).assertFalse()
  expect(r.error?.code).assertEqual('skill_path_escape')
})

注意错误层是 security——这是安全违规,不是普通的路径错误,不可重试。

7.3 符号链接:拒绝而非跟随

HarmonySkillDirectoryAccess 对符号链接的策略是完全拒绝(不跟随),这是阶段 6 第二轮验收修复的 P1 问题。实现里用 lstatSync(不跟随链接的 stat)而非 statSync

// platform/HarmonySkillDirectoryAccess.ets
async readTextWithinRoot(rootPath: string, targetPath: string): Promise<string> {
  const resolved = this.resolveWithinRoot(rootPath, targetPath)
  if (!resolved.ok || resolved.value === undefined) {
    throw resolved.error as ArkAgentError
  }
  // 读取前再查一次 leaf 是不是符号链接(TOCTOU best-effort)
  try {
    const st = fs.lstatSync(resolved.value)        // ← lstatSync,不跟随
    if (st.isSymbolicLink()) {
      throw SkillPathError.security('skill_symlink_rejected', ...)
    }
  } catch (e) {
    if (e instanceof ArkAgentError) { throw e }
  }
  return await this.fs.readText(resolved.value)
}

发现阶段(findSkillMdFileswalk)也逐个 lstatSync,遇到符号链接直接 continue,不发现也不下钻。而且 rejectSymlinksOnPath 会检查从 root 到目标路径上每一段有没有符号链接——防止中间某个目录是链接的情况。

安全防线总览:
  ① normalizePath    —— 统一分隔符,去 skill:// 前缀
  ② collapseDotSegments —— 折叠 .. 和 .,逃逸即拒
  ③ isUnderRoot      —— 最终路径必须在 root 前缀下
  ④ lstatSync(每段)  —— 路径上任何一段是符号链接即拒(不跟随)
  ⑤ read 前 lstatSync(leaf) —— TOCTOU 兜底再查一次

八、Planner 四档模式:从"不规划"到"先审批再执行"

8.1 PlanMode 的四种姿态

规划器的核心是 PlanMode 枚举,四档对应不同的自治级别:

// planner/Planner.ets
export enum PlanMode {
  none = 'none',     // 不提供 write_todos
  auto = 'auto',     // 可选 Todo 跟踪,模型自行决定
  must = 'must',     // 复杂任务必须先建计划(Prompt 约束)
  review = 'review'  // 首个计划需用户审批后才允许业务工具
}

模式

write_todos 工具

Prompt 约束

业务工具门禁

典型场景

none

不注册

简单问答 Agent,不需要规划

auto

注册

建议复杂任务用

助手类 Agent,让模型自己判断

must

注册

复杂任务必须先规划

流程敏感但信任模型自律

review

注册

必须先建计划

审批前封锁

高风险/高成本任务,人审计划

planModeEnablesTools 决定是否注册 write_todos:

export function planModeEnablesTools(mode: PlanMode): boolean {
  return mode === PlanMode.auto || mode === PlanMode.must || mode === PlanMode.review
}

只有 none 不注册 write_todos。测试验证:

it('given_plan_mode_none_when_runtime_then_no_write_todos', 0, () => {
  const runtime = new AgentRuntime(baseConfig(..., PlanMode.none))
  const names = runtime.getWorkingToolDefinitions()
  let hasWrite = false
  for (let i = 0; i < names.length; i++) {
    if (names[i].name === 'write_todos') hasWrite = true
  }
  expect(hasWrite).assertFalse()   // ← none 模式没有 write_todos
})

8.2 PlanStep 的四状态

规划步骤有四个状态,语义严格:

// planner/Planner.ets
export enum PlanStepStatus {
  pending = 'pending',
  inProgress = 'in_progress',
  completed = 'completed',
  cancelled = 'cancelled'
}

状态

含义

约束

pending

还没开始

in_progress

正在做

全局最多 1 个

completed

做完了

cancelled

不需要了

"最多一个 in_progress"是硬约束——它防止模型把所有步骤都标成 in_progress 然后假装在推进。校验在两个地方做:PlanStateCodec.decode(反序列化时)和 PlanUpdateValidator.validateTodos(write_todos 调用时)。

8.3 write_todos:全量替换语义

write_todos 的语义是全量替换——每次调用传入完整的 todos 数组,整体覆盖现有计划,不是增量更新。这个设计简化了模型推理(不用记"现在是第几步、上次改到哪"),也避免了增量冲突。

Schema 定义:

// planner/Planner.ets
function writeTodosSchema(): JsonObject {
  const todoProps = new JsonObject()
    .set('description', JsonValue.object(new JsonObject()
      .set('type', JsonValue.string('string'))
      .set('description', JsonValue.string('The description of the task.'))))
    .set('status', JsonValue.object(new JsonObject()
      .set('type', JsonValue.string('string'))
      .set('description', JsonValue.string('pending | in_progress | completed | cancelled'))))
  // ... items 数组
}

工具描述里明确写了使用方法:

## Methodology
1. Create the todo list based on task complexity.
2. Keep the list updated as you progress.
3. Mark in_progress before starting a step; mark completed only when done.
4. Replace the entire list on every call (full replacement semantics).
5. Never invent status values outside the four public values.

最后一条"Never invent status values outside the four public values"是关键——模型可能自作主张发明 doingblockedskipped 这样的状态,校验器必须拒绝。

8.4 原子校验:失败不部分更新

PlanUpdateValidator.validateTodos 是严格校验器,任何一个 todo 不合法,整个更新失败,不部分写入状态:

// planner/Planner.ets
static validateTodos(todos: JsonValue[]): Result<PlanState, ArkAgentError> {
  const steps: PlanStep[] = []
  let inProgressCount = 0
  for (let i = 0; i < todos.length; i++) {
    const obj = todos[i].asObject()
    if (obj === undefined) {
      return Result.failure(ArkAgentError.tool('plan_todo_type', `todos[${i}] must be an object`))
    }
    const descriptionRaw = obj.get('description')?.asString()
    if (descriptionRaw === undefined) {
      return Result.failure(ArkAgentError.tool('plan_todo_description_type', ...))
    }
    const description = descriptionRaw.trim()
    if (description.length === 0) {
      return Result.failure(ArkAgentError.tool('plan_todo_description_empty', ...))
    }
    const statusStr = obj.get('status')?.asString()
    if (statusStr === undefined) {
      return Result.failure(ArkAgentError.tool('plan_todo_status_type', ...))
    }
    const status = PlanStateCodec.parseStatus(statusStr)
    if (status === undefined) {
      return Result.failure(ArkAgentError.tool('plan_todo_status_unknown',
        `todos[${i}].status unknown: ${statusStr}`))
    }
    if (status === PlanStepStatus.inProgress) { inProgressCount++ }
    steps.push(new PlanStep(description, status))
  }
  if (inProgressCount > 1) {
    return Result.failure(ArkAgentError.tool('plan_multiple_in_progress',
      'At most one todo may be in_progress at a time'))
  }
  return Result.success(new PlanState(steps))
}

校验失败的三种典型情况,测试都有覆盖:

// SkillPlanner.test.ets
it('given_empty_description_when_validate_then_fails', ...)        // plan_todo_description_empty
it('given_unknown_status_when_validate_then_fails', ...)           // plan_todo_status_unknown(doing)
it('given_two_in_progress_when_validate_then_fails', ...)          // plan_multiple_in_progress

校验失败时不发 PlanChangedEvent、不写 AgentState——这是原子性保证。测试明确验证"非法更新后,plan 仍是 undefined,事件数为 0":

it('given_invalid_write_todos_when_run_then_no_plan_update_or_event', 0, async () => {
  const badArgs = '{"todos":[{"description":"a","status":"in_progress"},' +
    '{"description":"b","status":"in_progress"}]}'  // ← 两个 in_progress
  // ...
  await runToEnd(runtime, [user('plan')])
  expect(runtime.getPlan()).assertEqual(undefined)   // ← plan 没被创建
  expect(planEvents).assertEqual(0)                   // ← 没发事件
})

8.5 write_todos 成功后的三件事

执行器成功校验后做三件事:

// planner/Planner.ets —— WriteTodosExecutor.execute
const plan = validated.value
this.host.applyPlan(plan)                           // ① 写入 AgentState.plan
if (this.onPlanChanged !== undefined) {
  this.onPlanChanged(plan)                          // ② 回调(发 PlanChangedEvent)
}
if (this.host.requestPlanReviewAfterWrite !== undefined) {
  this.host.requestPlanReviewAfterWrite(plan)       // ③ review 模式触发审批等待
}
// 返回当前计划的可读文本
let buffer = 'Successfully updated the todo list. The current list is now:\n'
for (let i = 0; i < plan.steps.length; i++) {
  buffer += `${i + 1}. [${step.status}] ${step.description}\n`
}

第 ③ 步只在 review 模式下触发——Runtime 实现了 requestPlanReviewAfterWrite,它会设置 PendingPlanReview 并在当前工具批次结束后暂停。


九、PlanMode.review:审批前的业务工具封锁

9.1 为什么要 review 模式

automust 模式有个共同弱点:它们只靠 Prompt 约束模型"先规划",但模型完全可以跳过规划直接调业务工具。如果你在做的是一个"执行成本高/风险大"的 Agent(比如批量发邮件、删除文件、调付费 API),你需要硬性门禁——没有批准的计划,业务工具一律不准跑。这就是 review 模式。

review 模式的 System Prompt 多了四条约束:

// planner/Planner.ets —— buildPlannerSystemPrompt
if (mode === PlanMode.review) {
  body += '- Plan review mode: you MUST call write_todos to establish a plan first.\n'
  body += '- After writing the plan, wait for the user to approve before calling any other business tools.\n'
  body += '- If the user requests revision, update the plan with write_todos and wait again.\n'
  body += '- Do not invent user approval; only the host approval UI can approve the plan.\n'
}

最后一条"不要虚构用户批准"很重要——模型可能在没收到批准信号时自欺欺人地往下走,Prompt 明确禁止。

9.2 PendingPlanReview:可持久化的审批冻结

审批状态用 PendingPlanReview 记录,它是可序列化的,能随 AgentState 持久化(schema v3+):

// state/PendingPlanReview.ets
export enum PlanReviewDecision {
  pending = 'pending',
  approved = 'approved',
  denied = 'denied',
  revise = 'revise'        // 用户要求模型修改计划
}

export class PendingPlanReview {
  readonly planId: string
  readonly createdAtMs: number
  decision: PlanReviewDecision
  reason?: string
  decidedAtMs?: number

  isPending(): boolean {
    return this.decision === PlanReviewDecision.pending
  }
}

注意它和危险工具的 PendingApproval分离的——两套审批机制独立运作,不混用。计划审批管的是"整个计划的执行权",危险工具审批管的是"单个工具的执行权"。

9.3 管理工具在审批前仍可用

review 模式封锁的是"业务工具",但管理工具(write_todos、activate_skills、deactivate_skills)在审批前仍然允许——因为模型可能需要修订计划或调整技能。isPlannerManagementTool 定义了白名单:

// planner/Planner.ets
export function isPlannerManagementTool(name: string): boolean {
  return name === ReservedToolNames.writeTodos ||
    name === ReservedToolNames.activateSkills ||
    name === ReservedToolNames.deactivateSkills
}

9.4 review 模式的完整生命周期

测试覆盖了 review 模式的三个关键路径——等待审批、审批通过后执行、要求修改后重新等待:

路径一:写入计划后冻结

// SkillPlanner.test.ets
it('given_review_mode_when_write_todos_then_suspends_for_plan_approval', 0, async () => {
  const args = '{"todos":[{"description":"step1","status":"pending"},...]}'
  const client = scriptedClient([
    ScriptedTurn.tools([new ToolCall('t1', 'write_todos', args)]),
    ScriptedTurn.text('should not reach if suspended')   // ← 这轮不该执行
  ])
  const runtime = new AgentRuntime(baseConfig(client, ..., PlanMode.review, ..., controller))
  const result = await runtime.run([user('make a plan')])
  expect(result.stopReason).assertEqual(AgentStopReason.awaitingPlanApproval)  // ← 暂停
  expect(runtime.isAwaitingPlanApproval()).assertTrue()
  expect(client.getStreamCallCount()).assertEqual(1)     // ← 只调了一次模型
})

路径二:未批准时业务工具被封锁

it('given_review_pending_when_business_tool_then_denied', 0, async () => {
  const echo = new EchoTool()
  registry.register(new ToolDefinition('echo', ...), echo)
  const client = scriptedClient([
    ScriptedTurn.tools([new ToolCall('c1', 'echo', '{"q":"x"}')]),  // ← 没规划就调业务工具
    ScriptedTurn.text('ok')
  ])
  const runtime = new AgentRuntime(baseConfig(client, registry, 'sess', PlanMode.review))
  await runToEnd(runtime, [user('echo without plan')])
  expect(echo.callCount).assertEqual(0)                   // ← 被封锁,没执行
})

路径三:审批通过后恢复执行

it('given_plan_approved_when_resume_then_business_tools_allowed', 0, async () => {
  // 第一轮:建计划 → 冻结
  const first = await runtime.run([user('plan then work')])
  expect(first.stopReason).assertEqual(AgentStopReason.awaitingPlanApproval)
  // 用户批准
  const decision = await controller.approvePlan('ok')
  expect(runtime.isResumable()).assertTrue()
  // 恢复执行
  const second = await runtime.resume()
  expect(echo.callCount).assertEqual(1)                   // ← 业务工具执行了
})

路径四:要求修改 → 重新建计划 → 再次等待

it('given_plan_revise_when_resume_then_rewrite_and_wait_again', 0, async () => {
  await runtime.run([user('plan')])                       // v1 计划 → 冻结
  await controller.requestPlanRevision('rewrite')         // 用户要求改
  const mid = await runtime.resume()                      // 模型重写 v2
  expect(mid.stopReason).assertEqual(AgentStopReason.awaitingPlanApproval)  // ← 再次冻结
  expect(runtime.getPlan()?.steps[0].description).assertEqual('v2')
  expect(approvalCount).assertEqual(2)                    // ← 两次审批请求
  expect(echo.callCount).assertEqual(0)                   // ← 业务工具仍封锁
  await controller.approvePlan('v2 ok')                   // 批准 v2
  await runtime.resume()
  expect(echo.callCount).assertEqual(1)                   // ← 终于执行
})

这个 revise 路径最重要——它验证了"每次修订计划都要重新审批",模型不能靠反复 write_todos 绕过审批。

9.5 review 模式状态流转图

                    write_todos (首个计划)
          ┌─────────────────────────────────────┐
          │                                     ▼
   [无计划/空闲]    ┌─────────────┐      [PendingPlanReview]
          │        │ plan written │            │ pending
          │        └──────────────┘            │
          │                                      │
          │           ┌────────────┬────────────┼────────────┐
          │           ▼            ▼            ▼            ▼
          │      approved       denied       revise       (超时/取消)
          │           │            │            │
          │           ▼            ▼            ▼
          │    [恢复执行,     [封锁,等用户    [恢复,模型重写
          │     业务工具       新指令]         计划,再次冻结]
          │     放行]
          └──────────────────────────────────────────────────

十、⚠️ 踩坑:JS Runtime 安全边界不可控(ADR-0012)

10.1 为什么这个坑值得单独讲

技能系统最诱人的扩展方向是"让技能自带可执行脚本"——目录里放一个 index.js,技能激活时自动跑起来。很多 Agent 生态就是这么做的。但在鸿蒙上,这条路在 1.0 被正式切掉了(ADR-0012),不是"暂时没做",而是"明确不做"。理解这个决策的原因,比知道结论更重要。

10.2 症状:想给技能加脚本执行能力

最初的设计(ADR-0009,后被取代)想这样实现:定义一个 JavaScriptRuntime SPI,默认 unsupported,阶段 6 再做 HarmonyOS 实现。技能目录里放 .js 文件,通过 RunJavaScript 工具执行。听起来合理——ArkTS 本身就跑在 JS 引擎上,执行 JS 不是问题。

10.3 根因:安全边界无法证明

问题不在"能不能执行 JS",而在"能不能安全地执行不可信的 JS"。鸿蒙应用沙箱里没有等价于 Node.js 进程的隔离机制,任意动态脚本执行涉及五个无法回避的风险:

风险维度

具体问题

鸿蒙上的困境

隔离

JS 代码能访问哪些对象

无进程级隔离,共享 ArkTS 运行时,难划边界

超时

死循环脚本怎么停

没有可靠的执行中断机制

取消

用户取消时怎么杀脚本

异步任务取消链难以注入脚本内部

文件系统

脚本能读写哪些路径

沙箱文件访问权限难收窄到技能目录

网络

脚本能发哪些请求

HTTPS 白名单策略对脚本内 fetch 难以生效

ADR-0012 的原话是"安全证明周期不可控"——意思是这些风险的验证工作量太大,且任何一个没堵住就是安全漏洞。与其带一个不安全的实现进 1.0,不如不做。

10.4 修复:移出 1.0,目录技能只做"说明 + 受控读取"

ADR-0012 的决策是干脆利落的四条:

  1. 1.0 不实现 JavaScript Runtime、SPI、Bridge 注册表、占位实现、RunJavaScript 工具——一个都不留。

  2. 不预留空壳——公共 API、Tool 列表、Prompt 里都不能出现 JS 执行能力。空壳会被误用。

  3. 目录技能降级为:SKILL.md 发现 + frontmatter 解析 + 受控路径的渐进式指令注入 + 宿主提供的根目录边界内文件读取。

  4. .js 文件只能作为纯文本资源被宿主文件工具读取,不得被执行。

第 2 条"不预留空壳"是最容易忽视的。有人会觉得"先定义 SPI,默认 unsupported,以后再填实现"很安全。但空壳 API 一旦进入 1.0 公共面,用户就会依赖它的签名,后续移除是破坏性变更;而且空壳会被误以为"已经支持",引发安全期待。所以决策是连空壳都不要。

代码层面的验证——目录技能的 System Prompt 里不含任何 JS 执行引导,测试明确断言:

// SkillPlanner.test.ets
it('given_directory_prompt_when_build_then_no_javascript_execution', 0, () => {
  const skills = [new DirectorySkillMetadata('x', 'd', '/skills/x/SKILL.md')]
  const prompt = buildDirectorySkillsSystemPrompt(skills) as string
  expect(prompt.indexOf('RunJavaScript') < 0).assertTrue()   // ← 没有 RunJavaScript
  expect(prompt.indexOf('JavaScript') < 0).assertTrue()      // ← 没有 JavaScript
  expect(prompt.indexOf('.js') < 0).assertTrue()             // ← 没有 .js
})

宿主提供的文件工具(read_skill_file)虽然能读 .js 文件,但描述里写的是"读取文本文件",明确是文本资源,不是执行:

// entry/.../Phase6Skills.ets
new ToolDefinition(
  'read_skill_file',
  '在已配置的 Skill 根目录内读取文本文件(含 SKILL.md 与 .js 文本资源)。禁止越界路径。',
  ...
)

10.5 被拒绝的方案

ADR-0012 列了两个被否决的备选:

方案

被拒绝的原因

阶段 6 实现受限 JS Runtime

安全证明周期不可控,阻塞整个 Skill/Planner 交付

仅定义 SPI + unsupported 默认实现

空壳 API 进入 1.0 公共面,易被误用,后续移除是破坏性变更

最终选择的是"移出 1.0,目录技能只读说明与资源"——与验收范围一致,风险最低。代价是:依赖脚本型技能的工作流,需要宿主用原生 Tool 或预编译适配器替代。这个代价是值得的,因为 1.0 的首要目标是"安全可用的 Agent SDK",不是"功能最全的 Agent SDK"。

10.6 这个决策的工程价值

这个踩坑(更准确说是"主动避坑")的真正价值在于:它示范了如何在安全红线面前做减法。很多 SDK 会因为"功能完整性"的压力而带病上线——先实现再说,安全问题以后补。ArkAgent 的选择相反:宁可 1.0 有能力缺口(脚本型技能),也不引入无法证明安全的执行能力。这个判断让整个技能子系统的边界变得清晰:对象技能是代码内置的、目录技能是文档驱动的,两者都不涉及动态代码执行,安全模型简单可验证。


十一、最佳实践清单

11.1 技能模式选择

  • ✅ 能力固定且需要工具 → 用对象技能,代码里 Skill.create 内置 SkillToolBinding

  • ✅ 能力可扩展、用户可自定义工作流 → 用目录技能,放 SKILL.md 让系统发现

  • ✅ 两种模式只用一种,构造期让 validateModeMutex 帮你卡住冲突

  • ❌ 不要同时开对象技能和目录技能——激活语义会打架

  • ❌ 不要用对象技能去模拟"用户可编辑的技能"——对象是编译期固定的

11.2 forceActivate 的使用

  • ✅ 核心身份/安全相关的技能用 forceActivate=true(如"礼貌简洁"人设)

  • ✅ 全是强制技能时,放心不注册 activate/deactivate 工具(系统自动优化)

  • ❌ 不要把所有技能都设成 forceActivate——那就失去了按需加载的意义

  • ❌ 不要试图把 forceActivate 技能写进可变 activeSkills 列表——sanitize 会清掉它

11.3 目录技能的 SKILL.md 编写

  • ✅ frontmatter 必填 namedescription,trim 后非空

  • ✅ 正文写工作流步骤,引导模型"读哪个文件、按什么顺序做"

  • ✅ 引用相对路径时用技能目录为基准(如 references/foo.md

  • ✅ 重名技能用路径标记消歧:[$name](/path/to/SKILL.md)

  • ❌ 不要在 SKILL.md 里放需要执行脚本的指令——1.0 不支持 JS 执行

  • ❌ 不要把技能目录嵌套超过 6 层——默认 DEFAULT_DIRECTORY_SKILL_MAX_DEPTH = 6

11.4 Planner 模式选择

  • ✅ 简单问答 Agent → PlanMode.none,不注册 write_todos,省 Token

  • ✅ 助手类 Agent → PlanMode.auto,让模型自己判断要不要规划

  • ✅ 流程敏感但信任模型 → PlanMode.must,Prompt 约束复杂任务必须先规划

  • ✅ 高风险/高成本任务 → PlanMode.review,硬性审批门禁

  • ❌ 不要用 must 代替 review——must 只是 Prompt 约束,模型可以跳过

  • ❌ 不要在同一轮里放多个 in_progress 步骤——校验器会拒绝

11.5 write_todos 的调用

  • ✅ 每次调用传完整的 todos 数组(全量替换语义)

  • ✅ 开始一个步骤前标 in_progress,做完标 completed

  • ✅ 状态只用四个合法值:pending / in_progress / completed / cancelled

  • ❌ 不要发明 doingblockedskipped 等状态——plan_todo_status_unknown 报错

  • ❌ 不要依赖增量更新——write_todos 没有"追加一个步骤"的语义

11.6 安全边界

  • ✅ 目录技能根目录用专门的 SPI(SkillDirectoryAccess),不直接用通用 FileSystem

  • ✅ 符号链接一律拒绝(lstatSync 不跟随),宁可误杀不可漏放

  • ✅ 路径校验用 resolveWithinRoot.. 逃逸和绝对路径越界都要拒

  • ❌ 不要在技能工具里放宽路径约束"图方便"——这是安全红线


十二、常见错误对照表

错误做法

问题

正确做法

Skill.create('', ...) 用 public 构造器

private 构造器无法 new,必须走工厂

Skill.create(name, ...) 校验 name 非空

技能工具名叫 write_todos

撞保留名,和规划器打架

避开 ReservedToolNames.all(),用业务专属名

同时传对象技能和 skillDirectoryPath

skill_mode_conflict 构造期报错

二选一,validateModeMutex 会强制

SKILL.md 不写 frontmatter

missing YAML frontmatter delimited by ---

首行 ---,写 name/description,再 --- 收尾

SKILL.md 只写 name 不写 description

missing skill description

name 和 description 都必填

目录技能根目录配成 /

深度 6 也扫不全,性能差

配到技能集合的父目录,如 /data/skills

activate_skills 传强制技能名

返回 "force activated" 但不重复加入

强制技能无需激活,本来就在场

deactivate_skills 停用强制技能

被拒绝,提示 "do not try to deactivate"

强制技能不可停用,设计如此

write_todos 传状态 doing

plan_todo_status_unknown 原子失败

用四合法值之一:pending/in_progress/completed/cancelled

write_todos 传两个 in_progress

plan_multiple_in_progress 原子失败

全局最多一个 in_progress

write_todos 传空描述(trim 后空)

plan_todo_description_empty

描述 trim 后必须非空

review 模式未批准就调业务工具

工具被封锁,callCount=0

先 write_todos 建计划,等 approvePlan 后 resume

在技能目录里放 .js 期望执行

1.0 不支持 JS 执行

用宿主原生 Tool 替代,.js 只能当文本读

用符号链接扩展技能目录

skill_symlink_rejected,lstatSync 拒绝

把实际文件放进根目录,不用软链


十三、验证清单(真机 Review)

对象技能

  • 创建带工具的可选技能,未激活时工具不在 working registry

  • 模型调 activate_skills 后,下一轮 working registry 出现该工具

  • 激活后 system prompt 包含技能的详细 systemPrompt

  • 未激活技能在 prompt 里只显示 ⚪ [INACTIVE] 摘要

  • activate_skills 传未知名,返回 notFound,状态不变

  • 重复激活同一技能,幂等,返回 already active

  • deactivate_skills 强制技能,被拒绝

  • 全强制技能时,activate/deactivate 工具不注册

forceActivate 与持久化

  • 强制技能首轮就在 prompt 里,标 🔒 ACTIVE

  • 杀进程恢复后,强制技能仍在场

  • 持久化的 activeSkills 含旧技能名,sanitize 后被过滤

  • 持久化的 activeSkills 含强制技能名,sanitize 后被剔除(不进可变列表)

  • 持久化的 activeSkills 含重复名,sanitize 后去重

目录技能

  • 根目录下放多个 SKILL.md,发现后按名排序

  • frontmatter 缺 name 或 description,该技能进 errors 不阻断其他

  • 深度超过 maxDepth 的 SKILL.md 被跳过

  • $skillname 提及触发 SKILL.md 正文注入

  • 路径标记 [$name](path) 在重名时精确消歧

  • ../etc/passwd 路径被 skill_path_escape 拒绝

  • 符号链接目录下的 SKILL.md 不被发现(lstatSync 拒绝)

  • 目录技能 Prompt 不含 RunJavaScript / JavaScript / .js

Planner

  • PlanMode.none 不注册 write_todos

  • auto/must/review 注册 write_todos

  • write_todos 成功后发 PlanChangedEvent,写 AgentState.plan

  • write_todos 传 doing 状态,原子失败,不发事件,plan 不变

  • write_todos 传两个 in_progress,原子失败

  • write_todos 传空描述,原子失败

  • 四状态(pending/in_progress/completed/cancelled)编解码 roundtrip 一致

PlanMode.review

  • review 模式首个 write_todos 后,stopReason 为 awaitingPlanApproval

  • 审批前调业务工具,被封锁(callCount=0)

  • approvePlan 后 resume,业务工具放行

  • requestPlanRevision 后 resume,模型重写计划并再次冻结

  • review 冻结状态可持久化,杀进程恢复后仍 awaiting

  • 管理工具(write_todos/activate/deactivate)审批前仍可用


十四、构建验证

# 构建 agent_core HAR
NODE_HOME=/Applications/DevEco-Studio.app/Contents/tools/node \
  DEVECO_SDK_HOME=/Applications/DevEco-Studio.app/Contents/sdk \
  /Applications/DevEco-Studio.app/Contents/tools/hvigor/bin/hvigorw assembleHar --no-daemon

# 运行 Skill/Planner 单元测试
NODE_HOME=/Applications/DevEco-Studio.app/Contents/tools/node \
  DEVECO_SDK_HOME=/Applications/DevEco-Studio.app/Contents/sdk \
  /Applications/DevEco-Studio.app/Contents/tools/hvigor/bin/hvigorw test --no-daemon

# 构建 entry HAP
NODE_HOME=/Applications/DevEco-Studio.app/Contents/tools/node \
  DEVECO_SDK_HOME=/Applications/DevEco-Studio.app/Contents/sdk \
  /Applications/DevEco-Studio.app/Contents/tools/hvigor/bin/hvigorw assembleHap --no-daemon

构建结果(阶段 6 第二轮验收,2026-07-13):

CompileArkTS passed
BUILD SUCCESSFUL

三道门禁:assembleHar 通过、根级 test 通过(SkillPlanner 套件全绿,无 Error in)、assembleHap 通过。测试覆盖:

  • SkillPlanner.test.ets —— 对象技能创建/校验/激活/停用、目录技能发现/解析/提及注入、Planner 四模式/write_todos 原子校验/PlanMode.review 三态审批、activeSkills/plan 持久化恢复。

验收过程中的两轮迭代(来自阶段 6 报告):

轮次

问题

修复

第一轮

P1:符号链接可绕过 Skill 根目录

HarmonySkillDirectoryAccesslstatSync 拒绝全部符号链接,发现/读取/路径段校验全覆盖

第一轮

P2:Planner 错误码被覆盖

ToolRegistry 原样保留 ArkAgentError,补 Registry + Runtime 路径断言

第一轮

产品预期:缺计划审批

新增 PlanMode.review,write_todos 后暂停、approve/deny/revise、未批准封锁业务工具

真机验收:用户人工确认对象技能、目录技能、Planner、review 计划审批四项全部通过。


十五、写在最后

技能和规划是 Agent 从"会做单步任务"到"会管自己"的两块拼图。技能让它按需加载能力而不撑爆上下文,规划让它先想清楚再动手而不跑偏。在鸿蒙上做这两件事,关键判断有三个:

第一,技能分两种且互斥。 对象技能是代码内置的、带工具的、可动态激活的;目录技能是文档驱动的、可扩展的、靠提及注入的。两套激活语义不能混用,构造期就卡死冲突。

第二,规划有四档,自治级别递增。 none 不规划、auto 可选规划、must 强烈建议规划、review 硬性审批门禁。review 模式是唯一能阻止模型"跳过规划直接干活"的机制——靠的是 PendingPlanReview 冻结和业务工具封锁,不是 Prompt 里一句"请先规划"。

第三,JS Runtime 在 1.0 明确不做。 不是技术做不到,是安全证明做不到。鸿蒙沙箱没有等价的进程级隔离,不可信脚本的执行边界无法收敛。宁可 1.0 有能力缺口,也不带病上线。

这三条判断合起来,让技能和规划子系统的安全模型简单可验证:对象技能是代码内置的、目录技能是文档驱动的、规划是 Prompt + 工具门禁的,全程不涉及动态代码执行。

记住这几条口诀:

对象目录两套技能,互斥校验构造期。 private 构造 static 造,name 非空工具不重名。 forceActivate 锁核心,不可停用不入列。 激活才注详细词,未激活只显名和描。 目录扫 SKILL.md,深度六层损坏隔。 美元提及注正文,路径标记消重名。 符号链接全拒绝,lstatSync 不跟随。 规划四档 none 起,auto 可选 must 必。 review 审批最严格,未批业务全封锁。 write_todos 全量换,四态最多一进行。 空描述与未知态,原子失败不发文。 JS 执行一刀切,安全证明不可控。 不留空壳不预留,1.0 边界保清晰。

本文是 ArkAgent 鸿蒙教程系列的第十篇。前面的文章讲了架构、接入、流式、JSON、工具调用;这篇拆解了 Agent "管理自己"的两大子系统——技能与规划。如果你正在鸿蒙上做需要按需加载能力或多步任务规划的 Agent,可以把这套"对象技能 + 目录技能 + 四档规划器 + review 审批"的模型直接作为你的基线。尤其记住:不要为了"功能全"而引入无法证明安全的执行能力——减法有时比加法更重要。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐