扩展 API 参考
为 Ishiki 编写扩展、引擎与资源方案的接口契约
本文面向二次开发者。Ishiki 的可编程面包括扩展(Extension)、三族引擎(Context / Wakeup / Toolcall Engine)与资源方案(SchemeHandler),接口由主包 koishi-plugin-ishiki 导出,源码位于 packages/koishi-plugin-ishiki/src/。
扩展:贡献工具与提示词
扩展是一个继承 Extension 的 Koishi Service,服务名为 ishiki.ext.<名字>。profile 的 extends.<名字> 决定是否启用,enable: false 的条目在解析时丢弃。
import { appendFile } from "node:fs/promises";
import { join } from "node:path";
import { Logger, Schema, Service } from "koishi";
import type { Context } from "koishi";
import { Extension, jsonSchema, tool } from "koishi-plugin-ishiki";
import type { ExtensionHost, ExtensionInstance, ToolSet } from "koishi-plugin-ishiki";
export interface NoteSettings {
prefix: string;
}
const NoteSettings: Schema<NoteSettings> = Schema.object({
prefix: Schema.string().default("note"),
});
class NoteExtension extends Extension<NoteSettings> {
public readonly Schema = NoteSettings;
public constructor(ctx: Context, config: NoteExtension.Config) {
super(ctx, "note");
this.logger.level = config.logLevel;
}
public [Service.invoke](host: ExtensionHost, config: NoteSettings): ExtensionInstance {
return {
extendInstructions: () => "Use note_write to record durable notes.",
extendTools: (): ToolSet => ({
note_write: tool({
description: "Record a short durable note.",
inputSchema: jsonSchema<{ text: string }>({
type: "object",
properties: { text: { type: "string" } },
required: ["text"],
}),
outputSchema: jsonSchema<{ url: string }>({
type: "object",
properties: { url: { type: "string" } },
required: ["url"],
}),
execute: async ({ text }) => {
await appendFile(join(host.home, "notes.txt"), `${config.prefix}: ${text}\n`, "utf8");
return { url: "local://notes.txt" };
},
}),
}),
};
}
}
namespace NoteExtension {
export interface Config {
logLevel: number;
}
export const Config: Schema<Config> = Schema.object({
logLevel: Schema.number().default(Logger.INFO),
});
}
export default NoteExtension;实例契约(src/extension.ts):
import type { AgentRuntime, ToolSet } from "koishi-plugin-ishiki";
import type { Awaitable } from "koishi";
export interface ExtensionInstance {
attach?(handle: AgentRuntime): Awaitable<void | (() => void)>;
extendInstructions?(): Awaitable<string>;
extendTools?(): Awaitable<ToolSet>;
stop?(): Awaitable<void>;
}装配与生命周期:
Schema是实例配置的 Schema,对应 profile 的extends.<名字>.config。它与插件自身的 Koishi 配置(构造器第二个参数、命名空间Config)相互独立;缺省时实例配置原样透传。配置校验在 profile 激活时执行一次,无效配置会使该 profile 激活失败并记录错误。[Service.invoke](host, config)在每个 runtime 创建时调用一次,host是该 runtime 的ExtensionHost。返回undefined表示这个 runtime 不贡献任何内容。- 每个 runtime 得到独立的
ExtensionInstance。需要跨 runtime 共享的连接池、索引等状态放在 Extension Service 实例上,并按host.root区分 profile;在host.fiber的dispose事件里关闭。 extendTools与extendInstructions在每轮第一步组装工具面与提示词时调用;开启 toolsearch 时,extendTools还会在初始化阶段调用一次,用于写入基线目录。内核不缓存返回值,跨轮稳定的结果请放在实例闭包里。- 工具合并顺序为:内核工具(
read、send_message、finish)、各扩展按extends顺序贡献的工具、wait,随后是 codemode 收窄与 toolsearch 的search。工具撞名在实际组装时抛出ToolConflictError;每轮组装时的错误进入该轮次的turn.failed,开启 toolsearch 时的初始化组装也可能报告错误。wait、codemode、search是内核保留名。 attach(handle)在 runtime 构造完成后调用一次。返回值可以是释放函数,也可以是解析为释放函数的 Promise;抛错只记录警告,不会阻止 runtime 启动。stop()与attach返回的释放函数都在 runtime 停止时执行,stop()先执行。定时器、订阅与外部资源在这两个位置清理。host.resources.attach(handler)注册资源方案并返回注销函数。资源中心按 runtime 隔离,注销函数在 runtime 停止前调用即可。
运行时句柄(AgentRuntime)
attach(handle) 收到的 AgentRuntime 暴露宿主自用的原语:
Prop
Type
deliver 与 wake 的完整语义见下文「运行时:输入提交与唤醒」。
ExtensionHost 字段
Prop
Type
可复用的工具构造件
从主包导出(@yesimagent/core 再导出 AI SDK 全量):
tool({ description, inputSchema, outputSchema, execute, toModelOutput? }):定义工具;inputSchema用jsonSchema<T>()声明。jsonSchema;类型ToolSet、ToolCallers(工具路由表,codemode 用它将工具收进沙箱)。- 模型可见的工具结果会经过统一输出出口(截断、媒体归档)。在工具内自行截断会绕开 artifact 续读,保持原样返回即可。
现成范例
packages/workspace:完整扩展,四个工具、技能发现、两个自定义 scheme、实例配置 Schema 与懒加载的沙箱单例。packages/mcp-client:按 profile 共享连接池、mcp://不透明 scheme、跨 server 的工具名冲突检查。packages/onebot-actions:构建期生成的动作目录、按模式增减的执行工具、onebot://scheme,以及把资源字节发布到 Koishi HTTP 服务的临时 URL。
引擎:替换三族行为之一
三族引擎都是继承各自基类的 Koishi Service,服务名由基类的 GetName 生成:
ishiki.engine.context.<名字>,基类ContextEngine,provider 的[Service.invoke](host: ExtensionHost, config)返回ContextEngineInstance。ishiki.engine.wakeup.<名字>,基类WakeupEngine,provider 的[Service.invoke](host: ExtensionHost, config)返回WakeupEngineInstance。ishiki.engine.toolcall.<名字>,基类ToolcallEngine,provider 的[Service.invoke](host: ExtensionHost, config)返回ToolcallEngineInstance。
一个 runtime 在每一族里只使用一个变体。provider 在 profile 激活时按配置里的名字取到,实例创建时机见下文各族的说明。
上下文引擎
实例方法全部可选:
Prop
Type
ContextRequest 提供 turnId 与 signal。实例按 runtime 创建,可以保存该会话的预算、投影或摘要状态;事实流已有条目不被修改,压缩结果作为新条目追加。
下面的引擎保留默认消息历史,并在每轮读取 profile 目录中的任务记录,将内容加入系统提示词。使用前创建 briefing.md,写入需要模型持续参考的任务或背景;修改文件后,下一轮会读取更新内容。
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { Service } from "koishi";
import type { Context } from "koishi";
import { ContextEngine } from "koishi-plugin-ishiki";
import type { ContextEngineInstance, ContextEngines, EngineConfig, ExtensionHost } from "koishi-plugin-ishiki";
export interface BriefingConfig {
filename: string;
}
declare module "koishi-plugin-ishiki" {
interface ContextEngines {
briefing: BriefingConfig;
}
}
export class BriefingContextEngine extends ContextEngine<"briefing"> {
public constructor(ctx: Context) {
super(ctx, "briefing");
}
public [Service.invoke](host: ExtensionHost, config: EngineConfig<Pick<ContextEngines, "briefing">>): ContextEngineInstance {
const filename = resolve(host.root, config.briefing?.filename ?? "briefing.md");
return {
instructions: () => readFile(filename, "utf8"),
};
}
}
export default BriefingContextEngine;唤醒引擎
实例只有一个必选方法:
import type { Agent } from "koishi-plugin-ishiki";
export interface WakeupEngineInstance {
attach(agent: Agent): () => void;
}attach 在 runtime 初始化时调用一次。实例按 runtime 创建,状态只在当前 runtime 内共享。引擎可以订阅 agent.channel 的事件、维护定时器或读取外部状态,在合适的时机调用 agent.continue()。返回值在 runtime 停止时执行,负责取消订阅、清除定时器与放弃未完成的任务。接口允许在没有外部输入时按自己的计划开启轮次,完整示例见引擎系统。
开启轮次前检查 agent.isIdle();异步判定返回后再检查一次。订阅回调会被 core 等待,耗时任务要脱离回调执行并自行捕获错误。
工具调用引擎
ToolcallEngineInstance 是一个抽象类:
import { wrapLanguageModel } from "koishi-plugin-ishiki";
import type { LanguageModelV4, LanguageModelV4Middleware } from "koishi-plugin-ishiki";
export abstract class ToolcallEngineInstance {
protected abstract middleware(): LanguageModelV4Middleware | undefined;
public wrap(model: LanguageModelV4): LanguageModelV4 {
const middleware = this.middleware();
if (middleware === undefined) return model;
return wrapLanguageModel({ model, middleware });
}
}middleware()返回LanguageModelV4Middleware;wrap()用wrapLanguageModel组合它。返回undefined表示不改写模型调用(native的做法)。- 覆盖
wrap()可以返回自定义的LanguageModelV4。多阶段、多模型编排都写在这个实现里:narrative的NarrativeToolcallInstance在doGenerate/doStream内运行 Narrator,再分支到 Action Decoder 或 Replyer,最后返回标准的 content、finishReason 与 usage。它要求叙事上下文提供的输入标记;缺少标记时该轮报错。 - 实例在每个 runtime 创建时生成一次,
wrap()也随之调用一次。实例可以持有当前 runtime 的状态,但不得修改或替换由多个 runtime 共用的底层模型对象。
下面的引擎通过 middleware 给每次模型调用追加一条系统说明:
import { Service } from "koishi";
import type { Context } from "koishi";
import { ToolcallEngine, ToolcallEngineInstance } from "koishi-plugin-ishiki";
import type { EngineConfig, ExtensionHost, LanguageModelV4CallOptions, LanguageModelV4Middleware, ToolcallEngines } from "koishi-plugin-ishiki";
export interface GroundingConfig {
instruction: string;
}
declare module "koishi-plugin-ishiki" {
interface ToolcallEngines {
grounding: GroundingConfig;
}
}
class GroundingInstance extends ToolcallEngineInstance {
public constructor(private readonly instruction: string) {
super();
}
protected middleware(): LanguageModelV4Middleware {
return {
specificationVersion: "v4",
transformParams: async ({ params }): Promise<LanguageModelV4CallOptions> => ({
...params,
prompt: [{ role: "system", content: this.instruction }, ...params.prompt],
}),
};
}
}
export class GroundingToolcallEngine extends ToolcallEngine<"grounding"> {
public constructor(ctx: Context) {
super(ctx, "grounding");
}
public [Service.invoke](_host: ExtensionHost, config: EngineConfig<Pick<ToolcallEngines, "grounding">>): ToolcallEngineInstance {
return new GroundingInstance(config.grounding?.instruction ?? "Cite a resource URL for every factual claim.");
}
}
export default GroundingToolcallEngine;把变体配置写进引擎 map 即可获得 profile 配置段的类型支持:
export {};
declare module "koishi-plugin-ishiki" {
interface ContextEngines {
briefing: { filename: string };
}
interface WakeupEngines {
rhythm: { everySeconds: number };
}
interface ToolcallEngines {
grounding: { instruction: string };
}
}profile 片段写作 context: { engine: briefing, briefing: { filename: briefing.md } },其余两族同理。
装配与等待
主插件在构造时注册内置变体;自定义引擎作为普通 Koishi 插件安装,由插件自己的 ctx.plugin 注册服务。profile 激活时,内核把三族的服务名和已启用扩展的服务名一起交给 ctx.inject:缺少服务时记录一条 missing required service 并停在等待状态,服务出现后继续装配。
运行时:输入提交与唤醒
AgentRuntime 提供 deliver(event) 与 wake(prompt?),分别负责提交输入与触发一轮。
deliver(event)是纯摄取:捕获附件,然后agent.send(event, { trigger: false, ifBusy: "join" }),成功投递后返回。返回不代表已经落盘。空闲时持久化在后台进行;有轮次正在运行时消息接入那一轮,在这一步的输出批次之后一起提交。提交完成的信号是message.appended通知。它不做决定,也不开启轮次。wake(prompt?)是绕开唤醒引擎的显式唤醒,供扩展的定时器与主动消息使用。prompt缺失或只含空白时,它使用Continue.作为开场内容;其他文本原样传给agent.continue(prompt)。
agent.continue(prompt?) 来自 @yesimagent/core:空闲时它排入一轮并返回 turn id;带 prompt 时该轮以这段文本作为开场的 user 消息(原文写入,不做包装),不带时直接基于已有历史开始,不写占位消息。AgentRuntime.wake 总会传入非空内容。忙碌或已有待发唤醒时,请求并入唯一那个待发唤醒并返回 undefined,待队列排空即触发;此时多次 continue 的 prompt 以 ; 连接进同一条消息。
wait 条件同样直接调用 continue,与引擎的订阅互不相干:引擎无法阻止 wait 或显式 wake 打开一轮。
资源方案:让 read 认识你的 URL
实现 SchemeHandler 并用 context.resources.attach(handler) 注册。中心只按 scheme 路由,handler 收到完整 URL,自行解释路径、query、view 或嵌套 URI。例如 clock://now 返回当前时间:
import { Buffer } from "node:buffer";
import { ResourceError } from "koishi-plugin-ishiki";
import type { ResourcePayload, SchemeHandler } from "koishi-plugin-ishiki";
export class ClockHandler implements SchemeHandler {
public readonly scheme = "clock";
public resolve(input: string): ResourcePayload {
const url = new URL(input);
if (url.hostname !== "now") {
throw new ResourceError("resource_not_found", "expected clock://now");
}
const text = new Date().toISOString();
return { type: "text", url: input, text, size: Buffer.byteLength(text) };
}
}SchemeHandler.resolve 可以返回 ResourcePayload 或它的 Promise。ResourcePayload 分成文本 { type: 'text', url, text, size, mediaType?, filename?, source? } 与媒体 { type: 'file', url, bytes, mediaType, size, filename?, immutable }。媒体的 immutable: true 用于已固定内容的引用;动态来源使用 false,作为历史附件时由统一出口固化快照。
普通 handler 无需提供续读协议:返回的文本在模型出口超预算时完整固化为 artifact,再从 artifact 分页。codemode 内的 read 取得未经模型出口截断的原始内容。
文件型 scheme 继承 FileScheme,声明 scheme 并实现受保护的 open(url: FileUrl)。返回的 FileEntry 指明文件路径、可选 filesystem、媒体类型和元数据;bytes 可以直接携带内存中的内容。未提供 filesystem 时使用宿主文件系统;提供实际沙箱的 ResourceFileSystem 时,读取同一个虚拟文件视图。readFileBuffer、stat、readdir 是必需能力,readText / readFilePrefix 为可选的流式优化。
FileScheme 复用行选择、元数据视图与无损 offset 续读。其读取来源随结构化结果传递,不依赖闭包或跨调用缓存。根目录和符号链接约束由具体 scheme 的 open 实现负责;不能把宿主路径当作虚拟路径。
约束:
asset/artifact/local是保留方案,注册同名 handler 会被拒绝;注册返回的 disposer 用于注销,同一个 scheme 不能重复注册。- 使用
ResourceError表达资源错误,例如invalid_resource_uri、unsupported_view、resource_unavailable、resource_not_found、resource_too_large、resource_read_failed。 - scheme 注册和存储按 Runtime 隔离。URL 只在对应的资源中心里解析,同一个字符串在不同 runtime 或部署中不保证指向同一内容。
- 工具是否接受 URL 由它自己的输入契约决定。资源中心不会改写任意工具的入参;HTTP(S) 资源可以按同一接口实现 handler,当前不内置。
事件与消息类型
IshikiEvent(src/types/messages.ts)是 core AgentCustomMessage 声明的联合:
ishiki.message.created/ishiki.message.deleted:入站消息与撤回。ishiki.attachment:附件记录,由 runtime 或统一输出出口写入。ishiki.tools.catalog:toolsearch 的目录更新。
平台事件由会话处理器整形成 ishiki.message.created 或 ishiki.message.deleted 后进入事实流。附件与工具目录由内核运行过程生成,三类记录共同构成后续上下文投影的输入。
入站事件先写进事实流,message.appended 通知随后到达每条订阅。同一批通知里还混着工具目录、附件与模型输出,谁关心什么由订阅方自己过滤。扩展通常通过 extendTools / extendInstructions / attach 参与;直接订阅 agent 事件的是唤醒引擎,以及需要维护投影状态的上下文引擎。
错误与排查
- 主包再导出
@yesimagent/core(含 AI SDK 的类型、工具构造件与createAgent)。模型网关通过ctx.ishiki.gateway使用,读取它时需在插件类上声明static inject = ["ishiki"];需要网关的显式类型时从@yesimagent/gateway引入,并把该包列进自己的依赖。 - 失败位置各不相同:缺少服务时 profile 停在等待态;profile 配置损坏或认领冲突时该 profile 被跳过;扩展 Schema 校验失败时 profile 激活失败;工具重名发生在轮次组装,表现为该轮
turn.failed;引擎 provider 在创建实例时抛错,则对应的 profile 或地址无法建立,错误记入日志。 - 开发期把
logLevel开到 3 定位问题;debugStream会实时输出模型流。