好,这一课我们用一个具体问题贯穿:

同样传入 6 个数字,为什么插件有时拒绝,有时可以计算?

我们会保持工具名称、计算算法和输入结构不变,只改变插件配置,并观察它什么时候生效。

本课仍使用 0.1.1-rc.2,继续修改你已经跑通的两个文件,不需要重新安装 Harness。

一、先分清两种输入

上一课,工具收到的是:

{
  "values": [10, 12, 14]
}

这是“这一次要计算哪些数字”。

本课新增配置:

config:
  maxItems: 5

这是“这个插件一次最多允许处理多少个数字”。

两者进入代码的位置不同:

内容本例进入哪里
插件配置maxItems: 5apply(ctx, config)config
单次调用参数values: [10,12,14]execute(args, exec)args

这里的 maxItems 是我们自己给配置项起的名字,不是 Harness 内置的特殊关键字。

而加载条目中的 config,是框架支持的插件配置入口。官方配置教程

你可以先记住:

配置决定这次运行采用什么规则;调用参数提供这一次处理什么数据。

这句话针对我们当前的插件设计,不意味着所有业务都必须这样划分。具体哪些内容应该可配置,是插件开发者的设计选择。

二、先改造文件

先等当前 Agent 任务结束,在运行 Harness 的终端按一次 Ctrl+C,等到回到 PowerShell 提示符。

然后把现有的 index.mjscordis.patch.yml 复制一份作为上一课的备份,再修改原文件。

1. 修改配置文件

打开:

D:\DeepSeek\plugins\number-stats\cordis.patch.yml

改为:

- insert:
    - id: lesson-number-stats
      name: 'file:///D:/DeepSeek/plugins/number-stats/index.mjs'
      config:
        maxItems: 5

注意三个位置:

  • configidname 同级。
  • maxItems 缩进在 config 下。
  • 保留你已经验证成功的 file:///D:/... 路径写法。

现在,这个加载条目不仅告诉 Harness“加载哪个插件”,还附带了“给这个插件什么配置”。

2. 修改插件代码

本课继续采用不依赖额外 npm 包的写法。

配置检查由我们编写的普通函数 readConfig 完成。稍后会明确说明:这和框架自动进行配置校验有什么区别。

index.mjs 替换为下面的完整代码:

export const name = 'number-stats';
export const inject = ['tools'];

function readConfig(config) {
  if (config === null || typeof config !== 'object' || Array.isArray(config)) {
    throw new Error('配置错误:config 必须是对象');
  }

  for (const key of Object.keys(config)) {
    if (key !== 'maxItems') {
      throw new Error('配置错误:未知配置项 ' + key);
    }
  }

  const maxItems = config.maxItems === undefined ? 1000 : config.maxItems;
  if (!Number.isSafeInteger(maxItems) || maxItems < 1) {
    throw new Error('配置错误:maxItems 必须是正的安全整数');
  }
  return { maxItems };
}

function calculateStats(values, maxItems) {
  if (!Array.isArray(values)) {
    throw new Error('调用参数错误:values 必须是数组');
  }
  if (values.length < 1 || values.length > maxItems) {
    throw new Error(
      '调用参数错误:values 数量应为 1 到 ' + maxItems +
      ',实际为 ' + values.length
    );
  }

  let sum = 0;
  for (const value of values) {
    if (!Number.isFinite(value)) {
      throw new Error('调用参数错误:每一项都必须是有限数字');
    }
    sum += value;
  }
  if (!Number.isFinite(sum)) {
    throw new Error('计算错误:数值总和超出本示例支持的范围');
  }
  return { count: values.length, mean: sum / values.length };
}

export function apply(ctx, config = {}) {
  const { maxItems } = readConfig(config);
  let calls = 0;

  console.info('[number-stats] apply maxItems=' + maxItems);

  ctx.tools.register({
    name: 'lesson_number_stats',
    description: '计算数字的数量和算术平均值;每次接受 1 到 ' + maxItems + ' 个数字。',
    parameters: {
      type: 'object',
      properties: {
        values: {
          type: 'array',
          items: { type: 'number' },
          description: '要统计的有限数字数组'
        }
      },
      required: ['values'],
      additionalProperties: false
    },
    output: {
      schema: {
        type: 'object',
        properties: {
          count: { type: 'integer' },
          mean: { type: 'number' }
        },
        required: ['count', 'mean'],
        additionalProperties: false
      },
      render(_args, value) {
        return [{ type: 'text', text: JSON.stringify(value) }];
      }
    },
    async execute(args, exec) {
      const callNumber = ++calls;
      console.info('[number-stats] execute #' + callNumber + ' start');

      try {
        exec.signal.throwIfAborted();
        if (
          args === null || typeof args !== 'object' || Array.isArray(args) ||
          !Object.hasOwn(args, 'values') || Object.keys(args).length !== 1
        ) {
          throw new Error('调用参数错误:参数必须是只包含 values 字段的对象');
        }

        const result = calculateStats(args.values, maxItems);
        console.info('[number-stats] execute #' + callNumber + ' success');
        return result;
      } catch (error) {
        const message = error instanceof Error ? error.message : String(error);
        console.info('[number-stats] execute #' + callNumber + ' failed: ' + message);
        throw error;
      }
    }
  });

  ctx.effect(() => {
    return () => {
      console.info(
        '[number-stats] cleanup maxItems=' + maxItems + ', calls=' + calls
      );
    };
  });

  console.info('[number-stats] registered lesson_number_stats');
}

代码已经过本地模拟上下文测试,包括配置检查、不同上限、异常输入和清理回调。下面需要你在实际 Harness 中验证加载、调用及退出行为。

三、先理解新增的四处代码

上一课讲过的输入、输出结构没有改变。这次重点只看新增部分。

1. readConfig:检查规则是否有效

看这一行:

const maxItems = config.maxItems === undefined ? 1000 : config.maxItems;

含义是:

  • 配置提供了 maxItems,就使用配置值。
  • 没有提供,才使用默认值 1000

这里的 1000 是“默认值”,不再是无法调整的固定限制。

随后:

if (!Number.isSafeInteger(maxItems) || maxItems < 1) {
  throw new Error('配置错误:maxItems 必须是正的安全整数');
}

它拒绝不合适的配置,例如:

maxItems: 0

maxItems: -1

maxItems: '5'

最后一个虽然看起来像数字,但加引号后是字符串,我们没有设计自动转换。

另外,未知配置项也会被拒绝。例如把 maxItems 拼成 maxItem,不会默默使用默认值掩盖这个错误。

2. apply:取得配置,并建立本次运行的状态

export function apply(ctx, config = {}) {
  const { maxItems } = readConfig(config);
  let calls = 0;

当配置是:

config:
  maxItems: 5

通过检查后,这次 apply 中就有:

maxItems = 5
calls = 0

其中:

  • maxItems 保存本次激活所采用的上限。
  • calls 记录这次激活期间,执行函数被进入了多少次。

calls 是我们自己添加的教学计数器,不是 Harness 的 Step 数,也不是 Session 的消息数。

3. execute 为什么能使用 apply 里的变量?

执行函数中有:

const callNumber = ++calls;

以及:

const result = calculateStats(args.values, maxItems);

callsmaxItems 都是在外层 apply 中定义的。

这依靠 JavaScript 的闭包

注册工具时,框架保存了执行函数;这个函数仍然可以访问它创建时所在作用域中的变量。

所以,不需要每调用一次工具,就重新执行一次 apply

在没有发生重新激活的情况下:

  • 第一次调用使用上限 5,计数变成 1
  • 第二次调用仍使用上限 5,计数变成 2
  • 第三次调用仍使用上限 5,计数变成 3

这里保存的是该插件本次激活的状态,不是每个聊天会话各有一份的状态。使用同一个插件实例的会话会共享这份计数;新建聊天不应被当作重置它的办法。

另外,工具描述也使用了 maxItems

description: '计算数字的数量和算术平均值;每次接受 1 到 ' + maxItems + ' 个数字。'

这样模型看到的描述与当前运行规则一致。

但描述只是说明,真正拦截超量输入的,仍然是执行代码中的检查。

4. ctx.effect:登记“以后清理时要做什么”

这段代码分为两层函数:

ctx.effect(() => {
  return () => {
    console.info(
      '[number-stats] cleanup maxItems=' + maxItems + ', calls=' + calls
    );
  };
});

它的执行时机是:

  1. 调用 ctx.effect 时,外层函数立即执行。
  2. 外层函数返回一个清理函数。
  3. 框架保存这个清理函数,在所属插件卸载时执行它。官方 Effect 接口

所以,启动时不会立即打印 cleanup

本例清理函数只打印日志,没有关闭数据库或释放文件句柄,因为我们的统计工具没有创建这些资源。

还要注意:

工具的自动注销来自 ctx.tools.register 的框架管理,不是因为我们打印了一行 cleanup

以后插件创建连接、监听器等资源时,才需要根据资源类型设计相应的清理逻辑。官方插件生命周期说明

四、实验一:上限为 5,观察三次调用

第一步:检查语法并启动

先在powershell中执行:

node --check "D:\DeepSeek\plugins\number-stats\index.mjs"

没有报错,再执行:

npx.cmd @deepseek-ai/dsh@0.1.1-rc.2 --profile web --patch "D:\DeepSeek\plugins\number-stats\cordis.patch.yml"

启动过程中,预期出现:

[number-stats] apply maxItems=5
[number-stats] registered lesson_number_stats

这说明代码读到了配置中的 5,并完成了工具注册。

这些文字是我们自己编写的定位日志,不是 Harness 内置的状态字段。

第二步:调用一次合法输入

在 Web 界面使用 Standard 模式,发送:

请只调用一次 lesson_number_stats,参数为 {"values":[10,12,14]},然后报告工具返回的结果。

预期工具结果:

预期终端日志:

[number-stats] execute #1 start
[number-stats] execute #1 success

注意:不应该因为这次调用,再出现一次 apply maxItems=5

这里的 success 表示我们的计算代码走到了成功返回的位置;最终仍应核对 Harness 中的工具结果。

第三步:测试超过上限的输入

发送:

这是一次工具参数边界测试。请只调用一次 lesson_number_stats,参数为 {"values":[1,2,3,4,5,6]}。不要拆分数组或修改参数。如果工具返回错误,直接报告错误,不要自动重试。

如果模型确实按要求发起了这次调用,预期终端日志是:

如果模型确实按要求发起了这次调用,预期终端日志是:

[number-stats] execute #2 start
[number-stats] execute #2 failed: 调用参数错误:values 数量应为 1 到 5,实际为 6

工具调用应返回错误,而不是统计结果。

这里的 catch 做了两件事:

console.info(...);
throw error;

先记录失败,再把错误继续交给 Harness 处理,而不是吞掉错误后假装执行成功。

**如果模型没有实际调用,或自行拆成两次调用,这个边界测试就没有按预期完成。**不能把模型口头说“超出上限”当作代码校验已经被执行。

第四步:再次调用合法输入

发送:

请只调用一次 lesson_number_stats,参数为 {"values":[-2,0,8]},报告工具返回的结果。

预期结果:

{"count":3,"mean":2}

如果前面的调用次数完全符合实验安排,终端应出现:

[number-stats] execute #3 start
[number-stats] execute #3 success

这证明了一件重要的事:

一次工具调用失败,不等于整个插件已经卸载或失效。

工具执行层会处理执行函数抛出的错误;这与插件初始化失败是不同的错误路径。官方工具执行约定

五、实验二:修改配置,观察旧状态结束、新状态建立

第一步:正常停止当前 Harness

等当前任务结束,在启动 Harness 的终端按一次 Ctrl+C,等待正常退出。

如果此前恰好进入执行函数三次,预期看到:

[number-stats] cleanup maxItems=5, calls=3

如果实际次数不同,以你的调用记录为准。模型重试、其他会话调用或插件重新激活,都可能使日志与示例不完全相同。

这行日志说明我们的清理回调被执行了,不是说整个应用的所有资源都已经完成清理。仍要等到 PowerShell 提示符重新出现。

CLI 的正常信号退出流程会先处置已挂载的根上下文。官方 CLI 退出行为

第二步:只修改配置中的数字

保持 index.mjs 不变。

cordis.patch.yml 改为:

- insert:
    - id: lesson-number-stats
      name: 'file:///D:/DeepSeek/plugins/number-stats/index.mjs'
      config:
        maxItems: 10

然后使用刚才同一条启动命令重新运行。

预期看到:

[number-stats] apply maxItems=10
[number-stats] registered lesson_number_stats

这一次,新的 apply 读取到了 10

第三步:再次传入相同的 6 个数字

发送:

请只调用一次 lesson_number_stats,参数为 {"values":[1,2,3,4,5,6]},报告工具返回的结果。

预期结果:

{"count":6,"mean":3.5}

本次启动后的第一次调用,预期日志是:

这里同时验证了两件事:

  • 上限变成 10,同样的数据现在可以处理。
  • 计数重新从 1 开始,因为重新执行 apply 时又创建了 calls = 0

计数不是保存到磁盘的历史记录。它只存在于本次激活建立的内存状态中。

为什么这里明确要求重启?

因为:

修改磁盘上的配置文件,不等于运行中的插件已经采用新配置。

本例的执行函数使用的是初始化时取得的 maxItems,不会每次调用都重新读取 YAML。

框架可以通过配置更新或热替换机制重新激活插件,但是否会自动检测你的文件变化,要看具体加载与监听机制。本课使用明确重启,先让执行时机可观察;不能因此总结成“Harness 修改配置永远必须重启整个应用”。官方插件更新接口

六、实验三:故意写错配置,观察错误发生在哪一层

这次会故意让启动失败。先正常停止当前 Harness,再进行修改。

把配置中的值改为:

config:
  maxItems: 0

使用同一条命令启动。

预期错误中包含:

配置错误:maxItems 必须是正的安全整数

这次不需要发送聊天消息,启动过程中就会出错。

原因是代码顺序如下:

export function apply(ctx, config = {}) {
  const { maxItems } = readConfig(config);

  // 配置检查通过后,才会继续注册工具
}

readConfig 抛出异常后,后面的工具注册代码不会执行。

特别注意:我们把 apply maxItems=... 日志写在配置检查之后。因此,配置错误时看不到这条日志,并不意味着 apply 从未被调用;它已经进入,只是在检查配置时失败了。

现在把两种错误放在一起看:

情况出错位置本例中的影响
maxItems: 0apply 开头的配置检查插件初始化失败,工具没有完成注册
上限为 5,调用传入 6 个数字execute 中的参数检查这一次调用失败,后续合法调用仍可进行

实验完成后,把 maxItems 恢复为 10,再重新启动,留下一个可正常工作的版本。

七、把“配置校验”与官方机制对齐

这里需要补齐一个架构上的重要区别。

我们当前的写法是:

export function apply(ctx, config = {}) {
  const { maxItems } = readConfig(config);
}

因此:

  • 框架负责传入配置。
  • 我们负责在 apply 内校验,并补上默认值。

Cordis 还支持插件导出一个名为 Config 的配置校验器。这个导出是可选的;有合适的校验器时,框架可以在插件入口执行前进行配置校验。官方插件接口定义

官方教程通常使用 Schemastery 来编写这个校验器,并在校验器中声明默认值。官方配置校验示例

所以,不要混淆:

  • 小写 config:传给插件的配置数据。
  • 大写 Config:框架约定的可选校验器导出。

也不要这样写:

export const Config = {
  maxItems: 1000
};

然后以为框架会把它当作默认配置。这里需要的是符合框架接口的校验器,不是随意放一个配置对象。

本课先把数据传递、校验位置和执行时机看清楚;后续工程化时,再把校验规则统一到正式的 Schema 中。

Logo

欢迎加入DeepSeek 技术社区。在这里,你可以找到志同道合的朋友,共同探索AI技术的奥秘。

更多推荐